From 28255fa7001d3e59c37e77460ba7d51ab931a132 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 8 Nov 2026 11:11:26 +0100 Subject: [PATCH 1/2] feat(plan): say what happens now when it is not the plan The plan's headline said "The battery is charging" and the card said "Charge battery" over a battery that discharged. A control row said "as planned" for any battery in a planner mode. The box now says whether the batteries follow this quarter's plan and why not. The Plan screen and the Now card say what the battery does, what the plan said and the reason. control-feedback.js is re-copied from srcfl/ftw. Co-Authored-By: Claude Opus 5.5 --- src/lib/format/plan.test.ts | 32 +++++++++++++++++ src/lib/format/plan.ts | 26 ++++++++++++-- src/lib/sim/api.ts | 1 + src/lib/state/flow.ts | 1 + src/lib/state/now-status.ts | 4 +-- src/lib/ui/ControlFeedback.svelte.test.ts | 2 +- src/vendor/ftw/control-feedback.d.ts | 1 + src/vendor/ftw/control-feedback.js | 41 +++++++++++++++++++++- src/vendor/ftw/digests.json | 2 +- src/views/Now.control-proof.svelte.test.ts | 2 +- src/views/NowOutlook.svelte | 1 + src/views/Plan.svelte | 30 +++++++++++++++- 12 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/lib/format/plan.test.ts b/src/lib/format/plan.test.ts index 6568e94..436ccbb 100644 --- a/src/lib/format/plan.test.ts +++ b/src/lib/format/plan.test.ts @@ -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') + }) +}) diff --git a/src/lib/format/plan.ts b/src/lib/format/plan.ts index 86f8b77..af48a15 100644 --- a/src/lib/format/plan.ts +++ b/src/lib/format/plan.ts @@ -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' /** @@ -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) { @@ -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) @@ -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 ?? [] @@ -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 diff --git a/src/lib/sim/api.ts b/src/lib/sim/api.ts index 35e0772..42147c7 100644 --- a/src/lib/sim/api.ts +++ b/src/lib/sim/api.ts @@ -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 } : {}), } } diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts index df8b565..20f6ca9 100644 --- a/src/lib/state/flow.ts +++ b/src/lib/state/flow.ts @@ -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 diff --git a/src/lib/state/now-status.ts b/src/lib/state/now-status.ts index 2210a7f..ac428c9 100644 --- a/src/lib/state/now-status.ts +++ b/src/lib/state/now-status.ts @@ -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 | undefined let expiry: ReturnType | undefined @@ -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() diff --git a/src/lib/ui/ControlFeedback.svelte.test.ts b/src/lib/ui/ControlFeedback.svelte.test.ts index 7b7fca5..d371e05 100644 --- a/src/lib/ui/ControlFeedback.svelte.test.ts +++ b/src/lib/ui/ControlFeedback.svelte.test.ts @@ -5,7 +5,7 @@ import ControlFeedback from './ControlFeedback.svelte' afterEach(cleanup) const row = (extra: Record = {}) => ({ - 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, }) diff --git a/src/vendor/ftw/control-feedback.d.ts b/src/vendor/ftw/control-feedback.d.ts index bb82bbb..c2579bd 100644 --- a/src/vendor/ftw/control-feedback.d.ts +++ b/src/vendor/ftw/control-feedback.d.ts @@ -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(planets: T[], value: unknown, live?: boolean): (T & { controlMark?: ControlMark; placeholder?: boolean; clickable?: boolean })[] diff --git a/src/vendor/ftw/control-feedback.js b/src/vendor/ftw/control-feedback.js index 34c3066..b24b6dc 100644 --- a/src/vendor/ftw/control-feedback.js +++ b/src/vendor/ftw/control-feedback.js @@ -1,4 +1,4 @@ -// Vendored from srcfl/ftw web/control-feedback.js at 452ce6204962d08dbde75f6eef17f2dee3f361c6. +// Vendored from srcfl/ftw web/control-feedback.js at ed341192. // 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: @@ -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'; } @@ -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'; diff --git a/src/vendor/ftw/digests.json b/src/vendor/ftw/digests.json index 0fc0f56..4b127fc 100644 --- a/src/vendor/ftw/digests.json +++ b/src/vendor/ftw/digests.json @@ -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", diff --git a/src/views/Now.control-proof.svelte.test.ts b/src/views/Now.control-proof.svelte.test.ts index 18b4765..315c923 100644 --- a/src/views/Now.control-proof.svelte.test.ts +++ b/src/views/Now.control-proof.svelte.test.ts @@ -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))}) diff --git a/src/views/NowOutlook.svelte b/src/views/NowOutlook.svelte index b61c523..95c8505 100644 --- a/src/views/NowOutlook.svelte +++ b/src/views/NowOutlook.svelte @@ -62,6 +62,7 @@ planBrief(plan.plan, nowMs, { mode: plan.actualMode, dispatchBlockedBy: site.session.dispatchBlockedBy, + planNow: statusFresh ? status?.plan_now : null, }) ) diff --git a/src/views/Plan.svelte b/src/views/Plan.svelte index 24d8706..d1e30a4 100644 --- a/src/views/Plan.svelte +++ b/src/views/Plan.svelte @@ -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 @@ -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(null) + const askPlanNow = $derived(site.documentVisible && + site.session.phase === 'streaming' && site.session.caps.has(CAP_API_PASSTHROUGH)) + $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( From 4ea78ce03686c6128ecb5693bb5b2b1633ef487d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 8 Nov 2026 11:29:04 +0100 Subject: [PATCH 2/2] chore(vendor): name the merged commit of control-feedback.js Co-Authored-By: Claude Opus 5.5 --- src/vendor/ftw/control-feedback.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vendor/ftw/control-feedback.js b/src/vendor/ftw/control-feedback.js index b24b6dc..44fc235 100644 --- a/src/vendor/ftw/control-feedback.js +++ b/src/vendor/ftw/control-feedback.js @@ -1,4 +1,4 @@ -// Vendored from srcfl/ftw web/control-feedback.js at ed341192. +// 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: