Handoff: interactive reference-case diagram
A design brief for an interactive power-flow graphic to embed in these docs.
Context
Power Insight is a Home Assistant integration that answers questions like "where did the power my battery just charged with actually come from?" and "what did that save me?". The hard part is a pure-Python calculation engine that, every snapshot, splits the house's power between grid, PV systems, batteries, consumers and the unmetered base load — honouring per-device rules like "this battery may only charge from solar".
Several answers the engine gives are modelling choices, not arithmetic: more than one result is defensible. So we are building a set of reference cases — a handful of small, deliberately-chosen wirings whose every value is derived by hand and published here as the engine's specification of record. The engine is then validated against them in CI.
The goal is that a user who thinks the engine is wrong can argue with the documented model instead of reading Python. That only works if the reference cases are legible, which is where this graphic comes in: a wall of JSON and fractions is not something anyone will argue with.
There are 9 reference cases and 24 snapshots today, in docs/spec/cases/,
ordered as a ladder from a one-meter house upward.
What to build
An interactive diagram that renders one reference case, embedded in a docs page.
v1 — the scope of this brief
- A topology graphic: the devices of one reference case and the power flowing between them. Flow direction and magnitude both matter; edge width should carry watts.
- A snapshot navbar above the graphic to switch between the case's snapshots. Switching keeps the wiring and changes the flows.
- Click a device → it highlights, and a panel shows that device's values: its configuration, its role this snapshot, its reading, and either where its output went (if it is a source) or where its power came from (if it is a sink). Keyboard-selectable too, please, not click-only.
v2 — since built
A second navbar selecting which value category is displayed. It landed as
the engine's own four layers, taken from docs/spec/properties.json: the
readings and totals, source provenance, the channel split, and the monetary
model. Each re-labels the same topology and lists every property the corpus
carries a slot for underneath — because the graph can only ever re-label its
nodes, and most of what the integration computes has nowhere to live on the
picture.
v3 — the Sankey (current)
The two-column graph and its layer tabs were replaced by a three-column Sankey
(design 14a of the redesign handoff), in website/src/components/FlowSankey/:
- Sources → channels → sinks. A source's link into a channel is its
per-source channel sensor (
source_adapters_<channel>_power, Production to grid …); a channel's ribbons into the sinks aresink_adapters_source_power(Power from {source}), coloured by source so a sink's bundle is its provenance. Only the home base load's ribbons are computed in the browser (stored share × stored size). - One chart per snapshot. Every snapshot gets its own
##section on the case page, anchored by its id (#grid_unavailable), instead of a snapshot bar — so a section, not a query string, is what an issue links to. - A normalization switch (W / % of source / % of sink / €/h, with Market / Levelized) relabels the ribbons; geometry is watts in every view, and every €/h figure is an engine value.
- Hover, focus or tap names the Home Assistant sensor behind a value, from
docs/spec/cases/sensors.json— read out ofsensor.pybytools/sensor_map.pyand kept current bytest_corpus.py. - Diagnostics: a hatched part of a bar is a reading the other meters do not explain (the metering imbalance; the gaps add up to it); ▲ restriction relaxed marks a sink drawing from a source its configuration rules out; an unavailable reading draws no flows and says so.
- A tabbed table underneath: readings and totals, source × sink, per-source channels, and every engine value.
- Layout: d3-sankey for the graph and the ribbon stacking order, with the design's fixed geometry applied on top. A container query swaps in a top-to-bottom chart below 640 px.
The one thing that will bite you
A device's role changes between states. The engine classifies every adapter each snapshot from the sign of its power reading:
| reading | grid | PV | battery | consumer |
|---|---|---|---|---|
| positive | importing → source | producing → source | discharging → source | (never a source) |
| negative | exporting → sink | standby draw → sink | charging → sink | load → sink |
| exactly 0 | idle — in neither group | idle | idle | idle |
So in pv-export/export_surplus the grid is a sink being fed by the house,
while in pv-self-consumption/sunny_partial the same grid is the largest
source.
A layout that hard-codes "grid on the left, loads on the right" will break on
half the cases. The sides have to be derived from the data.
There is also a virtual node: the home base load is everything consumed
without a sensor on it. It is a sink, it competes for power like any other, and
it has no device — it appears in home_base_load_power and
home_base_load_source_shares rather than in the adapter list. It should be
drawn, and visibly distinguished from real devices.
Data contract
One JSON file per case in docs/spec/cases/, plus index.json listing them in
ladder order. Every state carries every catalogued property as the engine
computed it (results).
Read group-captivity.json and mixed-export-house.json first — between them
they exercise every shape. grid-only.json is the other end of the range: a
single adapter, and still a slot for every property.
{
"id": "group-captivity",
"title": "Group captivity",
"summary": "…prose for the page…",
"shows": ["…what this case shows about the engine…"],
"topology": [
{ "uid": "grid", "kind": "grid", "config": { "has_price_entity": true } },
{ "uid": "east", "kind": "pv", "config": { "lcoe": "1/10", "exports_power": true } },
{ "uid": "bat_a", "kind": "battery", "config": { "lcos": "3/20",
"charge_from_adapters": ["east", "west"] } }
],
"states": [
{
"id": "hall_tight_pair",
"note": "…one line on what makes this snapshot interesting…",
"readings": { "grid": "200", "east": "100", "bat_a": "-100" },
"price": "3/10",
"results": [
{ "property": "gross_power", "value": "400" },
{ "property": "combined_grid_export", "value": null }
]
}
]
}
Five things to know about it:
Every number is an exact-rational string. "1/2", "-600", "3/20",
"400". Never a JSON float — the whole point is that these values are exact and
comparable by hand. Parse by splitting on /. Display should probably show both
forms (1/2 and 50%, or 8/15 and 0.533), since the fraction is what a
reader checks and the decimal is what they intuit.
Edge flows are derived, not stored. There is one source of truth — the share matrix — and watts come from multiplying it out:
flow(source → sink) = sink_adapters_source_shares[sink][source] × |readings[sink]|
flow(source → home) = home_base_load_source_shares[source] × home_base_load_power
These files are generated, and are not the source of truth. The cases live
in tests/engine/reference/ as small classes — wiring, readings and prose — and
tools/snapshot.py passes each snapshot through the engine and writes this
JSON. Edit the Python and re-run it — an edit to a .json file here is
overwritten on the next run.
Every value comes from the engine. Each snapshot's results holds every
catalogued property, as the engine computed it at the commit the docs were
built from.
A published null is the engine reporting nothing at all, usually because a
reading upstream is unavailable — then the share matrices are null too, and a
snapshot with no provenance has no edges to draw. The design has to look
deliberate in that state rather than broken.
Don't assume a fixed property list. Render what is present: the catalog grows.
Embedding — as built
The site moved from MkDocs Material to Docusaurus 3 while this component was
being designed, so the constraints below are the ones that actually applied. The
implementation lives in website/src/components/FlowSankey/ (it was
CaseDiagram/ before v3; the constraints below carried over unchanged).
- Build-time JSON imports, never
fetch. A page imports its case data and passes it in as a prop. That removes the base-path problem entirely (the site is served under/ha-power-insight/, so any absolute fetch would 404), makes a missing or malformed case a build failure rather than a runtime one, and lets the whole diagram server-render into the static HTML. - Data is passed in, not imported by the component.
docs/is what Docusaurus snapshots when a version is cut;website/src/is shared across every version. Keeping the case data on thedocs/side and threading it through a prop is what stops an old version's page from rendering with today's numbers. - Server-rendering, so no
windowat render time. State comes from props anduseState; the URL is read in a mount effect, never during render, so the server markup and the first client render agree.<BrowserOnly>is deliberately not used — it would opt out of the SSR that makes the diagram visible without JavaScript. - Theming is pure CSS. Colours are custom properties on the component root,
redefined under
:global([data-theme='dark']). No JavaScript reads the theme, so the Docusaurus toggle just works and there is nothing to hydrate. - Inline SVG, one
viewBox,width: 100%— crisp at any zoom, themeable through the same variables, and focusable per node in a way canvas is not. - Deep links. Each snapshot is its own section, anchored by its id
(
grid-only#grid_unavailable), so an issue report can point at exactly the snapshot it means. (Before v3 the snapshot was a?state=query parameter.) - These docs get read on phones: the state cards wrap, the detail panel collapses to one column, and the SVG scales with its container.
Out of scope
- Live recalculation. The engine is Python; the browser only ever displays stored case values. A user-driven playground with arbitrary inputs is planned separately and is not this component.
- Editing. Read-only. Certification happens offline, on paper.
Two open modelling questions
Both are unresolved and both live in this data. Don't design around either answer — just don't let the diagram imply one is settled.
- In
group-captivity/unsatisfiable_overlap, captive demand (300 W) exceeds local supply (200 W), so some restriction must be broken. The engine currently servesbat_cin full and pushes a 50 W deficit onto each ofbat_aandbat_b. Whether that is the intended priority is undecided. - In
captive-battery/source_in_standby,home_base_load_powerincludes the 400 W thatbat_1drew but could not legally be attributed — so the "unmetered" load includes a device that very much has a meter on it.