Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/lib/format/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,35 @@ describe('formatPrice', () => {
expect(formatPrice(NaN, 'SEK')).toBeNull()
})
})

describe('the plan beside what happens now', () => {
const offPlan = {
status: 'off_plan', why: 'less_solar', battery_w: -1231, planned_battery_w: 2595,
pv_w: -1392, planned_pv_w: -3814, load_w: 1230, planned_load_w: 1219, ev_w: 0, planned_ev_w: 0,
}

it('says what the battery does and why, not the plan\'s sentence', () => {
const h = planHeadline(plan([slot(0, 2595)]), T0, offPlan)
expect(h.text).toBe('Discharging 1.2 kW. The plan said: charge 2.6 kW. The sun gives 1.4 kW; the forecast said 3.8 kW. The battery covers the home, so you buy less.')
expect(h.slotIndex).toBe(0)
})

it('keeps the plan\'s words while it holds, and on an old box', () => {
const on = planHeadline(plan([slot(0, 2595)]), T0, { ...offPlan, status: 'as_planned' })
expect(on.text).toMatch(/^The battery is charging/)
expect(planHeadline(plan([slot(0, 2595)]), T0, null).text).toBe(on.text)
})

it('gives the box card the same lines', () => {
const b = planBrief(plan([slot(0, 2595)]), T0, { mode: 'planner_arbitrage', planNow: offPlan })
expect(b.state).toEqual({ label: 'Not as planned', tone: 'idle' })
expect(b.action).toBe('Now: discharging 1.2 kW')
expect(b.time).toBe('The plan said: charge 2.6 kW')
expect(b.reason).toBe('The sun gives 1.4 kW; the forecast said 3.8 kW. The battery covers the home, so you buy less')
})

it('does not hide a stale plan behind the comparison', () => {
const b = planBrief(plan([slot(0, 2595)], { stale: true }), T0, { mode: 'planner_arbitrage', planNow: offPlan })
expect(b.state.label).toBe('Fallback active')
})
})
26 changes: 24 additions & 2 deletions src/lib/format/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import type { Plan, PlanSlot, PlanReason, SiteMode, ModeInfo } from '$lib/protocol/messages'
import { toDisplay, unitFor } from '$vendor/ftw/price-units.js'
import { planNowWords } from '$vendor/ftw/control-feedback.js'
import { formatPower } from './power'

/**
Expand Down Expand Up @@ -79,7 +80,7 @@ export interface PlanHeadline {
* cannot see is what is about to happen, and that is the reason to look at a
* plan at all.
*/
export function planHeadline(plan: Plan | null, nowMs: number): PlanHeadline {
export function planHeadline(plan: Plan | null, nowMs: number, planNow?: unknown): PlanHeadline {
if (!plan) return { text: 'No plan yet.', slotIndex: null }

if (plan.stale) {
Expand All @@ -94,6 +95,15 @@ export function planHeadline(plan: Plan | null, nowMs: number): PlanHeadline {
)
if (current === -1) return { text: 'No plan for right now.', slotIndex: null }

// The box says the batteries are not doing what this quarter planned. The
// plan's own sentence would read "The battery is charging" over a battery
// that is not.
const off = planNowWords(planNow)
if (off) {
const words = [`${off.now}.`, `${off.plan}.`, off.why, off.then].filter(Boolean).join(' ')
return { text: words, slotIndex: current }
}

const now = plan.slots[current]!
const nowAction = slotAction(now)

Expand Down Expand Up @@ -140,7 +150,7 @@ export interface PlanBrief {
export function planBrief(
plan: Plan | null,
nowMs: number,
opts: { mode?: string | null; dispatchBlockedBy?: readonly string[] } = {}
opts: { mode?: string | null; dispatchBlockedBy?: readonly string[]; planNow?: unknown } = {}
): PlanBrief {
const mode = opts.mode ?? null
const blocked = opts.dispatchBlockedBy ?? []
Expand Down Expand Up @@ -179,6 +189,18 @@ export function planBrief(
}
}

const off = planNowWords(opts.planNow)
if (off) {
return {
state: { label: 'Not as planned', tone: 'idle' },
action: `Now: ${off.now.charAt(0).toLowerCase()}${off.now.slice(1)}`,
time: off.plan,
// The card adds its own full stop.
reason: [off.why, off.then].filter(Boolean).join(' ').replace(/\.$/, ''),
constraint: constraintLine(plan, blocked, plan.slots[current]!),
}
}

const now = plan.slots[current]!
const nowAction = slotAction(now)
let runEnd = current
Expand Down
1 change: 1 addition & 0 deletions src/lib/sim/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,7 @@ function controlFeedback(driver: string, kind: string, watts: number, now: numbe
driver, kind, mode: 'planner_arbitrage', status: 'following', reason: 'power_observed', severity: 'info', evidence: 'measured',
readings_fresh: true, requested_w: watts, sent_w: watts, readback_w: watts, actual_w: watts,
command_at_ms: now - 4_000, observed_at_ms: now - 1_000, site_confirmation: 'device_response_unconfirmed',
...(kind === 'battery' ? { on_plan: true } : {}),
}
}

Expand Down
1 change: 1 addition & 0 deletions src/lib/state/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export interface StatusEnergyToday {

export interface SiteStatus {
control_feedback?: unknown
plan_now?: unknown
grid_w?: unknown
pv_w?: unknown
bat_w?: unknown
Expand Down
4 changes: 2 additions & 2 deletions src/lib/state/now-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function asStatus(wire: unknown): SiteStatus | null {
* Poll /api/status while the session is live. Calls `onStatus` with each
* snapshot that looks like one. Returns a stop function.
*/
export function watchStatus(site: SiteStore, onStatus: (snapshot: StatusSnapshot) => void): () => void {
export function watchStatus(site: SiteStore, onStatus: (snapshot: StatusSnapshot) => void, periodMs = PERIOD_MS): () => void {
let stopped = false
let timer: ReturnType<typeof setTimeout> | undefined
let expiry: ReturnType<typeof setTimeout> | undefined
Expand Down Expand Up @@ -70,7 +70,7 @@ export function watchStatus(site: SiteStore, onStatus: (snapshot: StatusSnapshot
}
} else expire()
if (stopped) return
timer = setTimeout(() => void tick(), PERIOD_MS)
timer = setTimeout(() => void tick(), periodMs)
}

void tick()
Expand Down
2 changes: 1 addition & 1 deletion src/lib/ui/ControlFeedback.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import ControlFeedback from './ControlFeedback.svelte'
afterEach(cleanup)

const row = (extra: Record<string, unknown> = {}) => ({
driver: 'Sungrow', kind: 'battery', mode: 'planner_arbitrage', status: 'following', reason: 'power_observed',
driver: 'Sungrow', kind: 'battery', mode: 'planner_arbitrage', on_plan: true, status: 'following', reason: 'power_observed',
severity: 'info', evidence: 'measured', readings_fresh: true, sent_w: -2250, actual_w: -2249, readback_w: -2250,
command_at_ms: 1, observed_at_ms: 2, site_confirmation: 'device_response_unconfirmed', ...extra,
})
Expand Down
1 change: 1 addition & 0 deletions src/vendor/ftw/control-feedback.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,6 @@ export function controlReceipt(row: ControlFeedback, live?: boolean): { step: st
export function controlNumbers(row: ControlFeedback, live?: boolean): [string, string][]
export function controlCurve(row: ControlFeedback, live?: boolean): { device: string; site: string; label: string; scale: string; duration: string } | null
export function controlForPlanet(value: unknown, planet?: ControlScope): ControlFeedback[]
export function planNowWords(value: unknown): { now: string; plan: string; why: string; then: string } | null
export function controlSummary(value: unknown, live?: boolean): { title: string; tone: ControlTone }
export function withControlMarks<T>(planets: T[], value: unknown, live?: boolean): (T & { controlMark?: ControlMark; placeholder?: boolean; clickable?: boolean })[]
41 changes: 40 additions & 1 deletion src/vendor/ftw/control-feedback.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Vendored from srcfl/ftw web/control-feedback.js at 452ce6204962d08dbde75f6eef17f2dee3f361c6.
// Vendored from srcfl/ftw web/control-feedback.js at 1206ee46627ea6dda2b0b83ec37641d6c52ef712.
// Do not edit here; change the box source, then copy it here.
//
// Words for Core's answer to "Are we in control?". Core owns every verdict:
Expand Down Expand Up @@ -51,6 +51,8 @@ function target(row) {
function source(row) {
if (row.mode === 'manual') return 'as you asked';
if (row.mode === 'solar') return 'from spare solar';
// A battery's row says "as planned" only when Core says it is.
if (row.kind === 'battery') return row.on_plan === true ? 'as planned' : 'as FTW asked';
if (row.mode === 'plan' || String(row.mode || '').startsWith('planner')) return 'as planned';
return 'as FTW asked';
}
Expand Down Expand Up @@ -164,6 +166,43 @@ function proofLine(row) {

// One answer per device function: a title, one sentence and, when the owner
// can act, the next step. Tone follows Core's severity and status.
function planBattery(watts, charge, discharge, rest) {
if (Math.abs(watts) < 100) return rest;
return `${watts > 0 ? charge : discharge} ${controlPower(watts)}`;
}

function planNowWhy(p) {
const pair = (subject, actual, planned, source) => num(actual) && num(planned) ? `${subject} ${controlPower(actual)}; ${source} said ${controlPower(planned)}.` : '';
switch (p.why) {
case 'less_solar': case 'more_solar': return pair('The sun gives', p.pv_w, p.planned_pv_w, 'the forecast');
case 'more_home': case 'less_home': return pair('The home uses', p.load_w, p.planned_load_w, 'the forecast');
case 'more_car': case 'less_car': return pair('The car takes', p.ev_w, p.planned_ev_w, 'the plan');
case 'manual': return 'You are holding the battery by hand.';
case 'device': return REASON_TITLES[p.device_reason] ? `${REASON_TITLES[p.device_reason]}.` : 'The battery cannot follow the plan right now.';
default: return 'FTW follows what the home and the sun do now, not the forecast.';
}
}

function planNowThen(p) {
if (!/^(less|more)_(solar|home|car)$/.test(p.why || '')) return '';
const now = p.battery_w, planned = p.planned_battery_w;
if (now <= -100) return planned > -100 || Math.abs(now) > Math.abs(planned) ? 'The battery covers the home, so you buy less.' : 'The battery gives only what the home needs.';
if (now >= 100) return 'The battery charges with what is left over.';
return planned > 0 ? 'Nothing is left over to charge with.' : 'The battery rests.';
}

// What the batteries do now beside what the plan meant, and why, when Core
// says they differ. Null when they do not. Core decides; this only words it.
export function planNowWords(value) {
if (!value || value.status !== 'off_plan' || !num(value.battery_w) || !num(value.planned_battery_w)) return null;
return {
now: capital(planBattery(value.battery_w, 'charging', 'discharging', 'idle')),
plan: `The plan said: ${planBattery(value.planned_battery_w, 'charge', 'discharge', 'rest')}`,
why: planNowWhy(value),
then: planNowThen(value),
};
}

export function controlStatus(row, live = true) {
if (!live) return {title: 'Status not current', text: 'FTW has not reported for a while.', proof: '', next: 'Waiting for a fresh report.', tone: 'stale'};
const title = REASON_TITLES[row.reason] || STATUS_TITLES[row.status] || 'Checking';
Expand Down
2 changes: 1 addition & 1 deletion src/vendor/ftw/digests.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"api-fetch.js": "ae8d2c3a8508d0b20c4afb599cf41f1cf5a5bba55deb6f496a530c233ea69934",
"control-feedback.js": "d238068237bc350f19b1e04eff4a06324475093602994d2cb169eb7d4c0eafde",
"control-feedback.js": "362107be294aaf357b7e494b2a9667102f04996ed8e326d52540e87aade42dec",
"ftw-bar-chart.js": "08b61533ed52b0031a39e723278c812b2b27356c1d574b65dfd5603992065d82",
"ftw-element.js": "7241b586582187cef2d76d3ee11bbc596c4f428cea3a5c8c84227e113aa3c1a0",
"ftw-energy-flow.js": "ced3308f92e0d49b41e7af5038a85e0986a0d879febf3b1541385028af5aa693",
Expand Down
2 changes: 1 addition & 1 deletion src/views/Now.control-proof.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ test('opens the device panel with the answer first and keeps an open receipt thr
const status={grid_w:1000,load_w:500,drivers:{sungrow:{status:'ok',bat_w:1000}},control_feedback:[
lost
? {driver:'sungrow',kind:'battery',mode:'planner_arbitrage',status:'no_contact',reason:'readings_lost',severity:'alarm',evidence:'accepted',readings_fresh:false,actual_w:null}
: {driver:'sungrow',kind:'battery',mode:'planner_arbitrage',status:'following',reason:'power_observed',severity:'info',evidence:'confirmed',readings_fresh:true,actual_w:1000,confirmed_at_ms:Date.now()},
: {driver:'sungrow',kind:'battery',mode:'planner_arbitrage',on_plan:true,status:'following',reason:'power_observed',severity:'info',evidence:'confirmed',readings_fresh:true,actual_w:1000,confirmed_at_ms:Date.now()},
{driver:'easee',kind:'ev',status:'no_contact',reason:'readings_lost',severity:'alarm',evidence:'accepted',readings_fresh:false,actual_w:null},
]}
return Promise.resolve({status:200,headers:{},body:new TextEncoder().encode(JSON.stringify(status))})
Expand Down
1 change: 1 addition & 0 deletions src/views/NowOutlook.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
planBrief(plan.plan, nowMs, {
mode: plan.actualMode,
dispatchBlockedBy: site.session.dispatchBlockedBy,
planNow: statusFresh ? status?.plan_now : null,
})
)

Expand Down
30 changes: 29 additions & 1 deletion src/views/Plan.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
type BatteryExport,
} from '$lib/format/plan-prefs'
import { formatPower } from '$lib/format/power'
import { CAP_API_PASSTHROUGH } from '$lib/protocol/contract'

const PLAN_NOW_EVERY_MS = 10_000

interface Props {
site: SiteStore
Expand Down Expand Up @@ -77,7 +80,32 @@

onDestroy(() => plan.destroy())

const headline = $derived(planHeadline(plan.plan, nowMs))
// Whether the batteries do what this quarter planned, and why not. The plan
// alone cannot say, so this screen asks the box too, gently.
let planNow = $state<unknown>(null)
const askPlanNow = $derived(site.documentVisible &&
site.session.phase === 'streaming' && site.session.caps.has(CAP_API_PASSTHROUGH))
Comment on lines +86 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop status polling when the Plan tab is hidden

After the user first opens Plan, this poller continues requesting /api/status every 10 seconds even after they switch tabs. src/App.svelte retains visited views and hides them with hidden={displayedRoute !== 'plan'}, so site.documentVisible only detects a backgrounded document, not an inactive Plan view. Returning to Now therefore runs this poller alongside Now's existing status watcher, and other tabs acquire unnecessary continuous box requests. Pass an active-view flag into Plan and include it in askPlanNow, as Now already does.

Useful? React with 👍 / 👎.

$effect(() => {
if (!askPlanNow) {
planNow = null
return
}
const s = untrack(() => site)
let stop: (() => void) | undefined
let cancelled = false
void import('$lib/state/now-status').then((m) => {
if (cancelled) return
stop = m.watchStatus(s, (next) => {
planNow = next.fresh ? next.status?.plan_now ?? null : null
}, PLAN_NOW_EVERY_MS)
})
return () => {
cancelled = true
stop?.()
}
})

const headline = $derived(planHeadline(plan.plan, nowMs, planNow))

/** The next twelve hours. Beyond that a plan is a guess about a guess. */
const slots = $derived(
Expand Down
Loading