Power-flow state (shared spec)
Power-flow state — shared classification spec¶
Status: cli + hass signed off; ready to implement. No implementation yet — this
pins the semantics so the eventual givenergy_modbus.model.flows primitive, the cli
TUI, and the hass dashboard all classify instantaneous power flow the same way. Both
frontends have confirmed the decisions below; the enum member names settle the agreed
naming. hass will also call headline(decompose(...)) coordinator-side to back a
translated flow-state enum sensor, so the headline member names are user-facing there.
Why this exists¶
Two frontends already classify "what is the system doing right now" from the same three power readings, and they do it differently:
- givenergy-cli (
givenergy_cli/glance.py::flow_status): one mutually-exclusive headline — 8 states, grid-over-battery precedence, stateless, 50 W idle threshold. - givenergy-hass (
givenergy-glancecustom element,feat/dashboard-glance-mode): five independent flow booleans composed into ~13 natural-language sentences, with stateful Schmitt-trigger hysteresis (200 W on / 80 W off). It is JavaScript and cannot import a Python library.
Because hass is JS, the convergence is a shared specification with a Python
reference implementation (used directly by the cli; mirrored in JS by hass), not a
shared import. A single FlowState enum would serve only the cli — it cannot express
hass's simultaneous "exporting and charging" composites. So the canonical primitive
is the stateless decomposition below; each frontend collapses it to its own
headline, and hysteresis stays a frontend concern.
Inputs and sign conventions¶
All three readings come straight off the inverter registers (single-phase shown; three-phase exposes the same concepts):
| Input | Register | Sign convention |
|---|---|---|
pv |
IR(18)+IR(20) p_pv1+p_pv2 |
≥ 0 (generation) |
grid |
IR(30) p_grid_out (external CT) |
+ = export, − = import |
battery |
IR(52) p_battery |
+ = discharge, − = charge |
load (IR(42) p_load_demand) is not an input to the decomposition — it is derived
from the incoming edges so the displayed home figure equals the sum of the rendered
flows (avoids drift between the independent load sensor and the source sensors). Note
IR(42) already includes the EPS branch IR(31) — consumers must not add EPS on top
(see the IR(42) Def docstring and tests/debug/eps_in_load_demand.py).
Units: the primitive takes kW floats (both frontends display kW; hass converts
its W readings at the boundary). The idle threshold is a kW argument (idle=0.05).
Canonical stateless output¶
decompose(pv, grid, battery, *, idle=0.05) returns, for one instant:
Flow facts (booleans, each magnitude > idle):
solar_on, exporting, importing, charging, discharging.
Per-edge magnitudes (kW ≥ 0), with solar as the priority source. This refines
hass's greedy allocation: every edge is capped by min(remaining source, remaining
sink) so no edge can exceed the power measured on either side, and the slack between
the independently-sensed sources and sinks is surfaced as an explicit residual rather
than absorbed into an edge (these are separate sensors; small drift is expected and
must not be allowed to invent power):
batt_charge = max(0, -battery) grid_import = max(0, -grid)
batt_discharge = max(0, battery) grid_export = max(0, grid)
solar_gen = max(0, pv)
# Allocate sources to the MEASURED sinks (battery charge, grid export), each edge
# capped by min(remaining source, remaining sink). Solar serves first; battery and
# grid backfill. Every term is >= 0 and <= the measured value on both sides.
solar_to_batt = min(solar_gen, batt_charge)
grid_to_batt = min(grid_import, batt_charge - solar_to_batt)
solar_to_grid = min(solar_gen - solar_to_batt, grid_export)
batt_to_grid = min(batt_discharge, grid_export - solar_to_grid)
# Home is the DERIVED sink: each source's remainder after the measured sinks.
solar_to_home = solar_gen - solar_to_batt - solar_to_grid
batt_to_home = batt_discharge - batt_to_grid
grid_to_home = grid_import - grid_to_batt
home = solar_to_home + batt_to_home + grid_to_home
# Drift: a measured sink no source could cover (sensors disagree). Surfaced, never
# folded into an edge. |residual| above ~0.1 kW (100 W) is the agreed "sensors
# disagree" flag; multi-sensor skew runs tens of watts, so 100 W clears noise.
residual_charge = batt_charge - solar_to_batt - grid_to_batt
residual_export = grid_export - solar_to_grid - batt_to_grid
This output is stateless and unfiltered — no hysteresis, no idle smoothing beyond the boolean threshold. Frontends layer their own smoothing on top (e.g. hass folds a sub-100 W residual into the dominant edge for display tidiness; that's a presentation choice, not part of the core).
How each frontend collapses it¶
cli — single headline (FlowState), grid > battery > solar precedence:
The source-split states (EXPORTING_SOLAR vs EXPORTING_BATTERY, CHARGING_FROM_SOLAR
vs CHARGING) are decided by the dominant edge, not merely by whether solar is present
— otherwise a trickle of solar would mislabel a battery-led export. Ties go to solar.
(The member names use "solar" throughout, matching the solar_to_* edges, rather than
mixing in "pv".)
| Condition | State |
|---|---|
grid_export > idle and solar_to_grid >= batt_to_grid |
EXPORTING_SOLAR |
grid_export > idle and solar_to_grid < batt_to_grid |
EXPORTING_BATTERY |
grid_import > idle |
IMPORTING |
batt_discharge > idle |
DISCHARGING |
batt_charge > idle and solar_to_batt >= grid_to_batt |
CHARGING_FROM_SOLAR |
batt_charge > idle |
CHARGING |
solar_gen > idle |
SOLAR_COVERING_HOUSE |
| otherwise | IDLE |
hass — composite sentence from the boolean combination (e.g. solar_on &
exporting & charging → "Solar covering the house and charging the battery, exporting
the surplus"). hass keeps its Schmitt-trigger hysteresis (enter at 200 W, leave at
80 W, with prior-state memory) by feeding the smoothed booleans — this is a frontend
concern and deliberately not in the stateless core. hass also uses the per-edge
magnitudes directly to drive its animated SVG.
Planned implementation (sketch — not built yet)¶
givenergy_modbus/model/flows.py, following the model-package idioms (module-level
pure functions like resolve_model; StrEnum/IntEnum per model/inverter.py):
from dataclasses import dataclass
from enum import StrEnum
class FlowState(StrEnum):
EXPORTING_SOLAR = "exporting_solar"
EXPORTING_BATTERY = "exporting_battery"
IMPORTING = "importing"
DISCHARGING = "discharging"
CHARGING_FROM_SOLAR = "charging_from_solar"
CHARGING = "charging"
SOLAR_COVERING_HOUSE = "solar_covering_house"
IDLE = "idle"
@dataclass(frozen=True)
class FlowDecomposition:
solar_on: bool
exporting: bool
importing: bool
charging: bool
discharging: bool
solar_to_home: float
solar_to_grid: float
solar_to_batt: float
grid_to_home: float
grid_to_batt: float
batt_to_home: float
batt_to_grid: float
home: float
residual_charge: float
residual_export: float
def decompose(pv: float, grid: float, battery: float, *, idle: float = 0.05) -> FlowDecomposition: ...
def headline(d: FlowDecomposition) -> FlowState: ... # the cli collapse above
The cli swaps its local flow_status for headline(decompose(...)) plus its own
display strings; hass mirrors decompose + the headline table in JS and keeps its
hysteresis and sentence composition.
Resolved decisions (cli + hass)¶
- Units — kW. The core takes kW; hass converts its W readings at the boundary.
- Edge set — the seven edges, no EPS edge. EPS is already inside IR(42)
home(the inclusion finding below), so a distinct EPS edge would re-introduce a double-count. A frontend that wants to show EPS does so as a sub-figure, not an edge. - Residual — surfaced, tolerance 0.1 kW (100 W).
residual_charge/residual_exportare surfaced raw and never attributed to an edge; |residual| above 0.1 kW is the shared "sensors disagree" flag. Folding a sub-tolerance residual into the dominant edge is a frontend display choice (hass does; the core does not). - Hysteresis — frontend-side. The core is stateless; cli smooths via its snapshot history, hass via its 200/80 W Schmitt. No shared hysteresis helper.
- Headline state set — the 8 states, source-split by edge. Matches the cli TUI 1:1;
hass also consumes
headline(decompose(...))coordinator-side for a flow-state enum sensor, so the member names are user-facing on both sides.
References¶
- cli reference:
givenergy_cli/glance.py::flow_status - hass reference:
givenergy-glanceelement,feat/dashboard-glance-modebranch of givenergy-hass - EPS-in-load finding: IR(42) Def docstring +
tests/debug/eps_in_load_demand.py