Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/plan-household-prefs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

The Plan screen follows the household forecast slider and battery-export choice already on the box. Passive and Active are no longer buttons. Use the plan hands a manual house back to the mode the box maps from those prefs.
5 changes: 5 additions & 0 deletions .changeset/stop-batteries-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

The manual mode formerly labelled Idle is Stop batteries, matching the box. The mode key is still idle.
5 changes: 3 additions & 2 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ roles:
# ---------------------------------------------------------------------------
ops:
- { name: site.mode.set, scope: ftw.mode.write, desc: Change the site operating mode }
- { name: planner.prefs.set, scope: ftw.mode.write, desc: Set household planner preferences }
- { name: battery.hold, scope: ftw.dispatch.write, desc: Hold the battery at a fixed setpoint }
- { name: loadpoint.hold, scope: ftw.dispatch.write, desc: Charge the car now at a fixed current }
- { name: loadpoint.boost, scope: ftw.dispatch.write, desc: Boost the car from the house battery }
Expand All @@ -134,8 +135,8 @@ ops:
# release, and it is the same degrade-don't-die rule as capabilities.
#
# tier decides placement, not permission:
# primary — the forecast-driven strategies, shown as the main choices
# advanced — manual fallbacks, behind a "More ways to run it" disclosure
# primary — forecast-driven strategies; the Plan card sets them through household prefs, not buttons
# advanced — manual fallbacks, behind "Manual…"
# hidden — valid over the API but never rendered as a button
# ---------------------------------------------------------------------------
modes:
Expand Down
3 changes: 2 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,8 @@ is a different instruction than the one given, it is a `cmd`; if it is merely a
late setting, it is a passthrough.*

The refusal carries an `op` argument **only when a command for that route
exists**. Today three do: `POST /api/mode` names `site.mode.set`,
exists**. Today four do: `POST /api/mode` names `site.mode.set`,
`POST /api/planner/prefs` names `planner.prefs.set`,
`POST /api/loadpoints/{id}/soc` names `loadpoint.soc.set`, and
`POST /api/loadpoints/{id}/target` names `loadpoint.surplus_only.set` — the
one field of that route's body the session can set; the target and its
Expand Down
1 change: 1 addition & 0 deletions src/lib/format/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function commandHelp(result: CmdResult): string {
return 'Your box is still starting. Give it a minute.'
case 'E_UNAVAILABLE':
if (result.error.args?.['op'] === 'loadpoint.surplus_only.set') return 'Solar rule not saved. Your previous choice is unchanged. Try again.'
if (result.error.args?.['op'] === 'planner.prefs.set') return "Your box couldn't save how the plan runs. Try again."
return "Your box can't reach the charger right now. Try again shortly."
default:
return "That didn't go through. Try again."
Expand Down
63 changes: 63 additions & 0 deletions src/lib/format/plan-prefs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import {
PLANNER_FALLBACK_MODE,
exportSentence,
hedgeLine,
mappedPlannerMode,
prefsFromWire,
type SaleSlot,
} from './plan-prefs'

const slot = (startMs: number, batteryW: number, gridW: number): SaleSlot => ({
startMs,
durationMs: 15 * 60_000,
batteryW,
gridW,
})

describe('planner prefs from the box', () => {
it('keeps the slider on safety_k and does not invent a mode from export', () => {
const prefs = prefsFromWire({
safety_k: 0.4,
forecast_trust: 'cautious',
battery_export: 'allowed',
mapped_mode: 'planner_arbitrage',
})
expect(prefs.safetyK).toBe(0.4)
expect(prefs.forecastTrust).toBe('balanced')
expect(prefs.batteryExport).toBe('allowed')
expect(prefs.mappedMode).toBe('planner_arbitrage')
})

it('uses the passive mode when mapped_mode is missing or not a planner key', () => {
expect(mappedPlannerMode({})).toBe(PLANNER_FALLBACK_MODE)
expect(mappedPlannerMode({ mapped_mode: 'self_consumption' })).toBe(PLANNER_FALLBACK_MODE)
expect(mappedPlannerMode({ mapped_mode: 'planner_arbitrage' })).toBe('planner_arbitrage')
expect(prefsFromWire({ battery_export: 'allowed' }).mappedMode).toBe(PLANNER_FALLBACK_MODE)
})

it('says what the forecast margin is, in the box’s words', () => {
expect(hedgeLine(0)).toBe('No forecast margin requested.')
expect(hedgeLine(1)).toMatch(/forecast margin varies by interval/)
})

it('names a battery sale, a solar export, an allowed idle, and a block', () => {
const start = Date.parse('2026-07-15T18:00:00')
const hh = (ms: number) => {
const d = new Date(ms)
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
expect(exportSentence([slot(start, -400, -400)], 'allowed', start)).toBe(
`Battery sale planned ${hh(start)}–${hh(start + 15 * 60_000)}.`
)
expect(exportSentence([slot(start, 0, -400)], 'not_allowed', start)).toBe(
'Solar export only; the battery is not selling.'
)
expect(exportSentence([slot(start, 200, 200)], 'allowed', start)).toBe(
'Battery export is allowed, but FTW found no worthwhile sale.'
)
expect(exportSentence([slot(start, 200, 200)], 'unknown', start)).toBe(
'Battery sale blocked: permission is off or not checked.'
)
})
})
163 changes: 163 additions & 0 deletions src/lib/format/plan-prefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/* Household planner prefs, in the box's own words.
*
* safety_k is the slider. forecast_trust is the enum an older box still
* answers with. mapped_mode is the box's mapping of battery_export onto a
* planner mode — this file never derives one from the other.
*/

export const SAFETY_K_MIN = 0
export const SAFETY_K_MAX = 2
export const SAFETY_K_STEP = 0.05

/** What "Use the plan" sends when the prefs read fails or names nothing usable. */
export const PLANNER_FALLBACK_MODE = 'planner_passive_arbitrage'

export type BatteryExport = 'unknown' | 'not_allowed' | 'allowed'
export type ForecastTrust = 'cautious' | 'balanced' | 'bold'

export interface PlannerPrefsWire {
forecast_trust?: unknown
battery_export?: unknown
safety_k?: unknown
mapped_k?: unknown
mapped_mode?: unknown
}

export interface PlannerPrefs {
forecastTrust: ForecastTrust
batteryExport: BatteryExport
safetyK: number
/** The box's mapped_mode, or the passive fallback when the field is unusable. */
mappedMode: string
}

const SALE_W = 100

export function clampSafetyK(v: number): number {
if (!Number.isFinite(v)) return 1
if (v < SAFETY_K_MIN) return SAFETY_K_MIN
if (v > SAFETY_K_MAX) return SAFETY_K_MAX
return v
}

/** The slider's own resolution, without trailing zeros. */
export function formatSafetyK(k: number): string {
return String(Math.round(clampSafetyK(k) * 100) / 100)
}

export function trustFromSafetyK(k: number): ForecastTrust {
const n = clampSafetyK(k)
if (n <= 0.25) return 'bold'
if (n < 1.5) return 'balanced'
return 'cautious'
}

function safetyKFromTrust(trust: ForecastTrust): number {
if (trust === 'cautious') return 2
if (trust === 'bold') return 0
return 1
}

function asTrust(v: unknown): ForecastTrust {
return v === 'cautious' || v === 'balanced' || v === 'bold' ? v : 'balanced'
}

function asExport(v: unknown): BatteryExport {
return v === 'allowed' || v === 'not_allowed' || v === 'unknown' ? v : 'unknown'
}

/**
* The planner mode "Use the plan" will ask for.
*
* A string that starts with `planner_` is the box's answer. Anything else —
* a failed read, a missing field — is the mode that never sells from the battery.
*/
export function mappedPlannerMode(wire: { mapped_mode?: unknown } | null | undefined): string {
const mapped = wire?.mapped_mode
return typeof mapped === 'string' && mapped.startsWith('planner_') ? mapped : PLANNER_FALLBACK_MODE
}

function finiteK(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}

export function prefsFromWire(wire: PlannerPrefsWire | null | undefined): PlannerPrefs {
const trust = asTrust(wire?.forecast_trust)
const safetyK = clampSafetyK(
finiteK(wire?.safety_k) ?? finiteK(wire?.mapped_k) ?? safetyKFromTrust(trust)
)
return {
forecastTrust: trustFromSafetyK(safetyK),
batteryExport: asExport(wire?.battery_export),
safetyK,
mappedMode: mappedPlannerMode(wire),
}
}

/** What the dashboard shows under the manual drawer for a mode the plan is not running. */
const MANUAL_HINT: Record<string, string> = {
self_consumption:
'Self (manual). Simple grid-zero controller with no planner; charges surplus and discharges to cover local import.',
peak_shaving: 'Manual peak shaving. Limits grid import to the peak-limit setting.',
charge: 'Manual full charge — forces the battery to charge regardless of price.',
idle:
"Stop batteries. Every battery is held at 0 W while this mode is on, so none drifts back to the inverter's own behaviour. Fuse protection still applies. EV charging and PV curtailment carry on.",
}

/** Empty while a planner mode is driving. The slider is the explanation then. */
export function strategyHint(mode: string | null | undefined): string {
if (!mode || mode.startsWith('planner_')) return ''
return MANUAL_HINT[mode] ?? ''
}

export function hedgeLine(k: number): string {
return clampSafetyK(k) === 0
? 'No forecast margin requested.'
: 'The forecast margin varies by interval. This box has not supplied separate forecast and planning values.'
}

export interface SaleSlot {
startMs: number
durationMs: number
batteryW: number
gridW: number
}

function isBatterySale(slot: SaleSlot): boolean {
return slot.batteryW < -SALE_W && slot.gridW < -SALE_W
}

function isGridExport(slot: SaleSlot): boolean {
return slot.gridW < -SALE_W
}

function clock(ms: number): string {
const d = new Date(ms)
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}

export function exportSentence(
slots: readonly SaleSlot[],
exportPermission: BatteryExport,
nowMs: number
): string {
const sale = slots.filter(isBatterySale)
if (sale.length > 0) {
const upcoming = sale.filter((s) => s.startMs + s.durationMs > nowMs)
const block = upcoming.length > 0 ? upcoming : sale
let last = block[0]!
for (let i = 1; i < block.length; i++) {
const expected = last.startMs + last.durationMs
const next = block[i]!
if (Math.abs(next.startMs - expected) > 1000) break
last = next
}
const end = last.startMs + last.durationMs
return 'Battery sale planned ' + clock(block[0]!.startMs) + '–' + clock(end) + '.'
}
if (slots.some(isGridExport)) return 'Solar export only; the battery is not selling.'
if (exportPermission === 'allowed') {
return 'Battery export is allowed, but FTW found no worthwhile sale.'
}
return 'Battery sale blocked: permission is off or not checked.'
}
1 change: 1 addition & 0 deletions src/lib/protocol/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ export function carriesOverSession(contentType: string | undefined): boolean {
* reads both back.
*/
export const OP_SET_MODE = 'site.mode.set'
export const OP_PLANNER_PREFS_SET = 'planner.prefs.set'
export const OP_BATTERY_HOLD = 'battery.hold'
export const OP_LOADPOINT_HOLD = 'loadpoint.hold'
export const OP_LOADPOINT_BOOST = 'loadpoint.boost'
Expand Down
17 changes: 16 additions & 1 deletion src/lib/sim/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
*/

import { DAY_ANCHOR_PERMILLE, sample, stepSoc, type HouseConfig, type Reading } from './energy'
import { OP_SET_MODE, ROLE_OWNER, ROLE_VIEWER, type Role } from '$lib/protocol/messages'
import { OP_PLANNER_PREFS_SET, OP_SET_MODE, ROLE_OWNER, ROLE_VIEWER, type Role } from '$lib/protocol/messages'
import { roleHasScope } from '$lib/protocol/contract'
import { wireBytes } from '$lib/protocol/frame'
import { buildEnrollmentUrl } from '$lib/identity/enrollment'
Expand Down Expand Up @@ -95,6 +95,8 @@ export interface RouteFacts {
const ROUTES: Record<string, RouteFacts> = {
// Reads the app makes, and one it never will.
'GET /api/status': { tier: 'read' },
'GET /api/planner/prefs': { tier: 'read' },
'POST /api/planner/prefs': { tier: 'actuate', cmdOp: OP_PLANNER_PREFS_SET },
'GET /api/energy/daily': { tier: 'read' },
'GET /api/savings/daily': { tier: 'read' },
'GET /api/app-link/devices': { tier: 'read' },
Expand Down Expand Up @@ -256,6 +258,8 @@ export interface SimApiOptions {
* the same moment or the hero and the charger sheet disagree.
*/
liveReading?: () => Reading | null
/** GET /api/planner/prefs, as the box currently holds it. */
plannerPrefs?: () => Record<string, unknown>
}

const DAY_MS = 86_400_000
Expand Down Expand Up @@ -336,9 +340,11 @@ export class SimApi {
#testPushes = 0
/** How many times this box was asked to restart, for a test to look at. */
#restarts = 0
#plannerPrefs?: () => Record<string, unknown>

constructor(opts: SimApiOptions) {
this.#opts = opts
if (opts.plannerPrefs) this.#plannerPrefs = opts.plannerPrefs
const now = opts.now()
this.#devices = [
{
Expand Down Expand Up @@ -502,6 +508,15 @@ export class SimApi {
const route = matched.pattern

if (route === 'GET /api/status') return this.#status()
if (route === 'GET /api/planner/prefs') {
return json(200, this.#plannerPrefs?.() ?? {
forecast_trust: 'balanced',
battery_export: 'unknown',
safety_k: 1,
mapped_k: 1,
mapped_mode: 'planner_passive_arbitrage',
})
}
if (route === 'GET /api/energy/daily') return this.#energyDaily(req.query)
if (route === 'GET /api/savings/daily') return this.#savingsDaily(req.query)
if (route === 'GET /api/loadpoints') return this.#loadpoints()
Expand Down
Loading
Loading