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..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 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: @@ -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(