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
41 changes: 41 additions & 0 deletions src/lib/format/savings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { buildSavingsPeriods, formatCompactMinor, toSavingsDay } from './savings'

describe('buildSavingsPeriods', () => {
it('splits today, seven days and the box month the way the dashboard does', () => {
const days = [
{ day: '2026-07-01', savedOre: 100, resolution: 'slot' },
{ day: '2026-07-10', savedOre: 200, resolution: 'slot' },
{ day: '2026-07-14', savedOre: 50, resolution: 'no_prices' },
{ day: '2026-07-15', savedOre: 300, resolution: 'slot' },
]
const p = buildSavingsPeriods(days)
expect(p.today.savedMinor).toBe(300)
expect(p.today.available).toBe(true)
expect(p.week.savedMinor).toBe(600)
expect(p.week.complete).toBe(false)
expect(p.month.savedMinor).toBe(600)
})

it('marks today unavailable when the latest day has no prices', () => {
const p = buildSavingsPeriods([
{ day: '2026-07-14', savedOre: 200, resolution: 'slot' },
{ day: '2026-07-15', savedOre: 0, resolution: 'no_prices' },
])
expect(p.today.available).toBe(false)
expect(p.week.available).toBe(true)
})

it('ignores a row the box did not date', () => {
expect(toSavingsDay({ saved_ore: 10 })).toBeNull()
expect(toSavingsDay({ day: '2026-07-15', saved_ore: 12.4 })?.savedOre).toBe(12.4)
})
})

describe('formatCompactMinor', () => {
it('matches the dashboard compact rounding', () => {
expect(formatCompactMinor(24700)).toBe('+247')
expect(formatCompactMinor(1234)).toBe('+12.3')
expect(formatCompactMinor(-80)).toBe('−0.80')
})
})
83 changes: 83 additions & 0 deletions src/lib/format/savings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/* Compact savings figures, from GET /api/savings/daily.
*
* The box page's compact card is a self-fetching web component. It talks
* HTTP to the box origin, which this app does not have, so the math is
* copied here and the fetch goes through the session. Same rounding, same
* periods, so a person comparing the two screens sees the same money.
*/

export const SAVINGS_LOOKBACK_DAYS = 31

export interface SavingsDay {
day: string
savedOre: number
resolution: string
}

export interface SavingsPeriod {
savedMinor: number
pricedDays: number
totalDays: number
available: boolean
complete: boolean
}

export interface SavingsPeriods {
today: SavingsPeriod
week: SavingsPeriod
month: SavingsPeriod
}

function finite(value: unknown): number {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}

function summarize(rows: readonly SavingsDay[]): SavingsPeriod {
const priced = rows.filter((row) => row.resolution !== 'no_prices')
return {
savedMinor: priced.reduce((sum, row) => sum + row.savedOre, 0),
pricedDays: priced.length,
totalDays: rows.length,
available: priced.length > 0,
complete: rows.length > 0 && priced.length === rows.length,
}
}

/** One row off the wire, unknown-tolerant. */
export function toSavingsDay(row: {
day?: unknown
saved_ore?: unknown
resolution?: unknown
}): SavingsDay | null {
const day = typeof row.day === 'string' ? row.day : ''
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null
return {
day,
savedOre: finite(row.saved_ore),
resolution: typeof row.resolution === 'string' ? row.resolution : 'slot',
}
}

export function buildSavingsPeriods(days: readonly SavingsDay[]): SavingsPeriods {
const rows = [...days].sort((a, b) => a.day.localeCompare(b.day))
const latest = rows[rows.length - 1]
const monthKey = latest ? latest.day.slice(0, 7) : ''

return {
today: summarize(rows.slice(-1)),
week: summarize(rows.slice(-7)),
month: summarize(monthKey ? rows.filter((row) => row.day.startsWith(`${monthKey}-`)) : []),
}
}

/**
* Minor units in, signed major units out. The currency sits once in the
* heading, which leaves room for the three values on a phone.
*/
export function formatCompactMinor(minor: number): string {
const major = finite(minor) / 100
const absolute = Math.abs(major)
const digits = absolute >= 100 ? 0 : absolute >= 10 ? 1 : 2
return (major >= 0 ? '+' : '−') + absolute.toFixed(digits)
}
96 changes: 95 additions & 1 deletion src/lib/sim/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* test here passed while a real box refused what the app had drawn.
*/

import { DAY_ANCHOR_PERMILLE, sample, stepSoc, type HouseConfig } from './energy'
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 { roleHasScope } from '$lib/protocol/contract'
import { wireBytes } from '$lib/protocol/frame'
Expand Down Expand Up @@ -93,6 +93,7 @@ const ROUTES: Record<string, RouteFacts> = {
// Reads the app makes, and one it never will.
'GET /api/status': { tier: 'read' },
'GET /api/energy/daily': { tier: 'read' },
'GET /api/savings/daily': { tier: 'read' },
'GET /api/app-link/devices': { tier: 'read' },
// A read that answers with a gzipped archive. Priced read because it hands
// back nothing replayable — the session refuses it later, at the status
Expand Down Expand Up @@ -235,6 +236,11 @@ export interface SimApiOptions {
* loadpoints answer must describe the same household the stream does.
*/
loadpointState?: () => { holdW: number | null; boostActive: boolean }
/**
* The live sample the 1 Hz stream is already sending. Status must describe
* the same moment or the hero and the charger sheet disagree.
*/
liveReading?: () => Reading | null
}

const DAY_MS = 86_400_000
Expand Down Expand Up @@ -460,7 +466,9 @@ export class SimApi {
): ApiAnswer {
const route = matched.pattern

if (route === 'GET /api/status') return this.#status()
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()
if (route === 'GET /api/mpc/plan') return this.#mpcPlan()
if (route === 'PUT /api/loadpoints/{id}/schedule') {
Expand Down Expand Up @@ -538,6 +546,60 @@ export class SimApi {
return json(404, { error: 'not found' })
}

/**
* The dashboard's own snapshot. Field names match handleStatus: snake_case,
* SoC as a 0–1 fraction, energy.today in watt-hours, one object per driver.
*/
#status(): ApiAnswer {
const now = this.#opts.now()
const live = this.#opts.liveReading?.()
const door = this.#opts.loadpointState?.() ?? { holdW: null, boostActive: false }
let r = live ?? sample(this.#opts.house, now, DAY_ANCHOR_PERMILLE, this.#opts.ceilingW)
if (!live && door.holdW !== null) {
r = { ...r, evW: door.holdW, gridW: r.gridW - r.evW + door.holdW }
}

const todayMidnight = new Date(now).setHours(0, 0, 0, 0)
const today = this.#integrate(todayMidnight, now)

const drivers: Record<string, Record<string, unknown>> = {}
if (this.#opts.house.pvPeakW > 0) {
drivers['sungrow'] = { status: 'ok', pv_w: r.pvW }
}
if (this.#opts.house.batteryCapacityWh > 0) {
drivers['lynx'] = {
status: 'ok',
bat_w: r.batteryW,
bat_soc: r.batterySocPermille / 1000,
}
}
drivers['easee'] = { status: 'ok', ev_w: r.evW }

return json(200, {
grid_w: r.gridW,
pv_w: r.pvW,
bat_w: r.batteryW,
ev_w: r.evW,
load_w: r.loadW,
bat_soc: r.batterySocPermille / 1000,
fuse: {
max_amps: this.#opts.house.fuseA,
phases: this.#opts.house.phases,
voltage: 230,
},
phase_amps: Array.from(
{ length: this.#opts.house.phases },
() => r.gridW / 230 / this.#opts.house.phases,
),
phase_powers: Array.from(
{ length: this.#opts.house.phases },
() => r.gridW / this.#opts.house.phases,
),
energy: { today },
drivers,
})
}

/**
* The charger, from the same house the live view samples.
*
Expand Down Expand Up @@ -824,6 +886,38 @@ export class SimApi {
return json(200, { days: out, tz: 'Local' })
}

/**
* Site savings vs a no-PV/no-battery baseline, as handleSavingsDaily
* answers them. Costs are in öre. The numbers are a shape, not a tariff
* model: enough for the compact card to have something true to print.
*/
#savingsDaily(query: Record<string, string>): ApiAnswer {
let days = 7
const asked = Number.parseInt(query['days'] ?? '', 10)
if (Number.isFinite(asked) && asked > 0) days = asked
if (days > 90) days = 90

const nowMs = this.#opts.now()
const todayMidnight = new Date(nowMs).setHours(0, 0, 0, 0)
const out = []
for (let i = days - 1; i >= 0; i--) {
const dayStart = new Date(todayMidnight).setDate(new Date(todayMidnight).getDate() - i)
const dayEnd = Math.min(new Date(dayStart).setDate(new Date(dayStart).getDate() + 1), nowMs)
const e = this.#integrate(dayStart, dayEnd)
const baselineOre = Math.round((e.load_wh / 1000) * 150)
const actualOre = Math.round((e.import_wh / 1000) * 150 - (e.export_wh / 1000) * 60)
out.push({
day: dayKey(dayStart),
...e,
actual_cost_ore: actualOre,
baseline_cost_ore: baselineOre,
saved_ore: baselineOre - actualOre,
resolution: 'slot',
})
}
return json(200, { days: out, tz: 'Local', value_scope: 'site_total' })
}

#integrate(fromMs: number, toMs: number) {
let importWh = 0
let exportWh = 0
Expand Down
1 change: 1 addition & 0 deletions src/lib/sim/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ export class SimBox {
holdW: this.#evHold?.powerW ?? null,
boostActive: this.#evBoost !== null,
}),
liveReading: () => this.#lastReading,
})
}

Expand Down
Loading