The domain model

One sign convention, a quarter-hour grid that survives both DST transitions, an electrical tree, and commands that cannot exist without a reason.

hems-core is the vocabulary every other crate speaks. It holds facts and no rules: what a site is made of, what a quarter hour is, what a measurement and a command are. Whether a heat pump is a steuerbare Verbrauchseinrichtung under § 14a is a rule and lives in hems-grid; what a kilowatt-hour costs is hems-tariff; what to do about either is hems-optimizer and hems-realtime.

The regulatory vocabulary the workspace shares — Fallgruppe, the Bundesland calendar, the market identifiers — comes from metering, so hems and the metering layer can never disagree about what a quarter hour or a Fallgruppe is.

One sign convention

Every power and energy value uses the load convention: positive is power flowing into the thing being measured.

ThingPositiveNegative
Grid connectionimport (Netzbezug)export (Einspeisung)
Photovoltaic arraystandby draw at nightproduction
Batterychargingdischarging
Wallboxcharging the cardischarging it (V2H / V2G)
Heat pump, hot-water tank, household loadconsumption—

The cost is that production is negative, which reads oddly the first time. What it buys is one invariant that holds everywhere and is testable:

grid connection power  ==  Σ (power of every asset behind it)

Site::balance_residual is that equation. A residual that is not near zero means a meter is missing or mis-signed, and the arbiter reports it rather than optimising against a fiction — the failure that otherwise shows up as a house that keeps buying electricity it cannot account for.

The quarter-hour grid, and the two days a year that are not 96 slots

Fifteen minutes is the settlement grain of the German market and, since the SDAC move to 15-minute market time units on 1 October 2025, also the grain of the day-ahead price. So it is the grain the planner works in.

Slot is identified by its UTC start, and that is deliberate. Every offset Europe/Berlin has ever used is a whole number of hours, so a quarter-hour boundary in UTC is also one in local time and slot arithmetic stays plain UTC arithmetic across both transitions. Two questions genuinely need the time zone:

  • which local day a slot belongs to — a day has 92, 96 or 100 slots;
  • the wall-clock time a slot starts at, which is what a § 14a Modul 3 window or a comfort schedule is written in.

Both go through metering::calendar. On the long October day the repeated hour is inside the same Modul 3 window twice; on the short March day the skipped hour is inside none. Both are what the price sheet means.

Assets, and what an asset is not

An Asset is a thing behind the connection point with an identity, a rating and a set of capabilities. The interesting ones are not the obvious ones.

AssetThe part that is easy to get wrong
Batteryasymmetric charge and discharge ratings, a backup reserve that is a promise, and round-trip efficiency that belongs in the fill rate rather than in the power
Evsea charge point is off, or at at least 6 A per conductor (IEC 61851). There is nothing in between, and a plan that trickles 500 W into it delivers nothing
HeatPumphow it is controlled — a ceiling, or one of the four SG Ready contact states — is a different fact from what it draws
DhwTanka store, not a second thermal model: three hundred litres between 45 and 60 °C are five kilowatt-hours of heat that can be bought hours before they are used. All five facts are the installation’s — volume, heater, coefficient (1,0 for an immersion heater, 3,0 for a hot-water heat pump), standing loss, and the three temperatures. t_set_c is the one that is easy to misread: it bounds nothing the plan does, and it is where the household’s own thermostat would sit with no manager at all — so it is what the baseline is held at, and therefore what the saving is measured against
PvArrayits § 9 EEG status is a declaration about the installation, not an inference from its nameplate
FlexibleLoadLoadKind::Shiftable carries the Programme it will run, quarter hour by quarter hour

That last one is the clearest case of a fact the type system can make unrepresentable. A dishwasher’s programme is shaped — two kilowatts to heat, two hundred watts to wash, two kilowatts again to dry. An appliance that announces flexibility and cannot say what shape it takes has told a manager nothing it can act on, so the shape is not optional.

Wiring and mode are different questions

PhaseConnection is what a device is wired to. PhaseMode is what it is using right now. Answering the second with the first is how an 11 kW wallbox drawing symmetrically on three conductors acquires the 4,6 kVA unbalance limit that only applies to a single-phase device — and loses two thirds of its charging power to a rule that was never about it.

The electrical tree

A house is not a single busbar. A wallbox in the garage sits behind a sub-distribution board with its own cable and its own fuse, and everything behind that board is bounded by it — independently of, and usually well below, the main connection.

flowchart TD
  R["Grid connection
63 A · both directions"] --> SB1["Sub-board: garage
32 A"] R --> SB2["Sub-board: utility room
25 A"] R --> H["Household load
uncontrollable"] SB1 --> EV["Wallbox 11 kW"] SB2 --> HP["Heat pump 8 kW"] SB2 --> DHW["Hot-water tank 2 kW"] R --> BAT["Battery 5 kW"] R --> PV["Roof 9,8 kWp"]

Circuits form a tree rooted at the grid connection, and the guard narrows every asset’s interval by every limit on its path to the root. That is the same mechanism the § 14a and § 9 EEG limits use — they are simply limits that sit at the root. Load management that only knows the main fuse either trips the sub-board or leaves capacity unused, and there is no third option.

Envelopes: every layer narrows, nobody widens

Every layer of hems hands down an interval rather than a number.

flowchart LR
  A["Physically possible
−5 … 11 kW"] --> B["Guard: fuses, ratings,
reserve, § 14a, § 9 EEG"] B --> C["Plan: what it wants
this quarter hour"] C --> D["Arbiter: a point
inside what is left"] D --> E["Device: what it will
actually accept"]

The idea is OpenEMS’s — a scheduler that hands each controller an interval of possible solutions that later controllers can only shrink — made explicit. An Envelope has a floor and a ceiling, both of which may be either sign, and an empty interval is a real outcome rather than a bug: two rules that cannot both be satisfied. Envelope::resolve keeps the stricter ceiling, because exceeding a grid limit is worse than falling short of a floor.

This is what makes “the grid limit was respected” an intersection rather than a code path somebody has to remember to write — see Architecture.

A command cannot exist without a reason

“Why did my wallbox stop at 17:04?” is the single most common question a home energy manager has to answer — to the customer, to the installer, and to the network operator, who may ask an operator to show that a § 14a reduction was actually carried out [BK6-22-300 A1 7.2].

So Setpoint::new is the only constructor and it takes a Reason. There is no way to build a command that cannot say where it came from, and a Reason carries an Authority:

AuthoritySet byExample
Guarda grid rule or a safety limitGuardRule::Lpc — a § 14a reduction
Realtimethe arbiter, inside the guard’s boundstracking the plan’s energy through a cloud
Planthe plannerthis slot’s target for the battery
Userthe household“charge now”, a holiday mode

Authority is what makes “the grid limit wins” a checked property rather than a convention: the ordering is a fact about the type, and the property test that asserts it over a thousand random households reads it rather than re-deriving it.

Command is deliberately a small closed set — an active power, a consumption ceiling, a production ceiling, a charging current, a phase count, an operation mode, on/off. A ceiling is not a target: the asset is free to use less, which is what every § 14a and § 9 EEG limit actually says.

Non-finite values stop at the boundary

The unit constructors are infallible and cheap, and debug_assert! finiteness. The gate that matters is where a number becomes an action: Setpoint::new refuses a non-finite command, so a NaN out of a broken driver or a degenerate solve can never reach a device.

The building is a store, and it is discretised exactly

hems-core::thermal carries a two-mass RC model of the house: an air node with a time constant of about thirteen minutes, and a fabric node with one measured in days. The fabric usually stores several times what the household battery does, and it is free.

It is stepped by an exact zero-order hold — the coefficients come from a matrix exponential — rather than by explicit Euler. At a quarter-hour step the explicit scheme gets the fast eigenvalue’s sign wrong and the input gain 64 % too large, and is only conditionally stable. The exact step is a contraction at any step size, carries no discretisation error under the assumption a quarter-hour plan already makes, and is still linear in the heat input — which is what keeps the planner a linear program. The planner has the numbers.

The same Rc2Discrete serves the planner, the rule-based baseline it compares itself against, and the simulator that answers it. One model, and no way for the plan and the house to disagree about physics for numerical reasons.

The heat nobody paid for

The model carries two more numbers: an effective solar aperture and a constant internal gain — the sun through the glazing, and the people, the cooking and everything electric that ends up warm. Both are identified from the household’s own record along with the fabric.

They are first-order, not a refinement. The average archetype loses (21 − T_out) / 6 kW, which is 2,7 kW at 5 °C outdoors; a clear March noon puts about 550 W/m² on a vertical south plane, and 4,5 m² of aperture turns that into 2,5 kW, with the internal gains another 0,45. On the days a heating plan is most worth making, the free heat exceeds the demand, and in January it is about half of it. A plan without it heats a room the sun is already warming and pays the comfort slack for the overshoot — the opposite of the way a conservative omission would be wrong.

It costs the programme nothing: the state equation is already linear in the heat input, and free heat is a known constant per slot rather than a decision, so it is an offset on the right-hand side.

Everything takes time as a parameter

No function in hems-core reads a clock, opens a socket, spawns a task or contains unsafe. just purity fails the build if it — or any of the other nine domain crates — reaches for one. That is what makes a whole winter day, DST transition included, a unit test that runs in milliseconds.

use hems_core::prelude::*;

let limit = Setpoint::new(
    AssetId::new("wallbox-garage")?,
    Command::ConsumptionCeiling(Power::from_kw(4.2)),
    Reason::guard(GuardRule::Lpc),
    time::macros::datetime!(2026-01-15 17:04:00 UTC),
)?;

assert_eq!(limit.authority(), Authority::Guard);