Skip to content

microgrid.engine.extend

For a client's own plant model: the port and the price rule over it.

A plant model is any object that keeps PlantModel: it projects a horizon as SlotFlow s, its dispatch's bookkeeping in each slot's SlotDispatch, answers the price rule's questions about its battery through Storage, and reads its meters into Flows. microgrid.engine.testing.assert_plant_model_contract checks one.

The operations the engine is built from, for a client that brings its own flows, traces or prices (a scorer, a backtest): the price rule over given flows (price_flows, with the noise floors it reads them at: MODEL for a plant model's flows, OBSERVED for metered ones; the house importing and PV spilling in a slot, as it reads them: importing, spilling), the valuation of given PV scenarios (value_energy) and the surplus forecast (forecast_surplus).

Pure: no I/O.

MODEL module-attribute

MODEL: Final = Noise(
    spill_kw=0.001,
    short_kw=0.001,
    charge_kw=0.001,
    topup_kw=_TOLERANCE,
    need_kw=_TOLERANCE,
    soc=1e-06,
)

The plant model's flows: float residue only (plant.surplus's 1 W floor). The house importing too: a battery that runs empty at dawn with 50 W short is empty (20 Wh a slot missed it and priced the night at the next fill).

OBSERVED module-attribute

OBSERVED: Final = Noise(
    spill_kw=0.12,
    short_kw=0.12,
    charge_kw=0.12,
    topup_kw=0.5,
    need_kw=0.12,
    soc=0.005,
    regulation_kw=0.3,
)

Metered flows: the default floors (EngineConfig.observed), measured on the plant the engine was first validated on (the 30 days to 2026-09-24); a plant whose meters report differently sets its own. The measurements:

  • spill, import, need: above the model's own import floor, 0.12 kW (20 Wh in a ten-minute slot). The grid and battery sensors report at different instants: net PV export far from full (SOC under 95 %) was up to 0.066 kW in 90 % of exporting slots, 0.13 kW in 95 %; the 5 % above the floor are real exports on PV ramps, while the battery charged below its limit.
  • PV charging: 0.12 kW, the same floor. Where PV fell 0.5 kW or more short of the site's consumption, so it could not charge the battery, the meters booked PV charging of at most 0.051 kW in 99.9 % of 2455 slots, and over 0.12 kW once (sensor timing).
  • top-up: 0.5 kW. Every grid-charging run the plant made peaked at 2.9 kW or more; the sensors' timing made single slots of up to 0.42 kW (0.07 kWh).
  • full and empty: half a point of SOC. Saturation read exactly in 269 of 280 exporting slots at full; the reserve exactly in 64 of 65 importing slots near it.
  • regulation: 0.3 kW. While the battery served the house with headroom, the grid supplied up to 0.14 kW in 99.5 % of slots. With the battery idle above its reserve, imports over 0.12 kW were either the dusk hand-off (up to 0.2 kW, a slot as PV fades) or the plant holding the battery (0.4 kW and up, the house load): the first is the battery's, the second the grid's.

MeterReading

MeterReading = Mapping[str, float | None]

PvKw

PvKw = Sequence[float] | Mapping[str, Sequence[float]]

SurplusForecast dataclass

SurplusForecast(
    starts: tuple[datetime, ...],
    surplus_kw: tuple[float | None, ...],
    surplus_kw_p10: tuple[float | None, ...],
    regime: tuple[Regime | None, ...],
    regime_p10: tuple[Regime | None, ...],
    surplus_value: tuple[float | None, ...],
    surplus_value_p10: tuple[float | None, ...],
    flows: tuple[SlotFlow, ...],
    flows_p10: tuple[SlotFlow, ...] = (),
)

Surplus and regime per slot, from the p50 and pessimistic (p10) PV cases.

Every per-slot tuple is None from the first slot whose inputs were unknown.

starts instance-attribute

starts: tuple[datetime, ...]

surplus_kw instance-attribute

surplus_kw: tuple[float | None, ...]

PV the plan exports or curtails: additive, the energy there is to share.

surplus_kw_p10 instance-attribute

surplus_kw_p10: tuple[float | None, ...]

regime instance-attribute

regime: tuple[Regime | None, ...]

regime_p10 instance-attribute

regime_p10: tuple[Regime | None, ...]

surplus_value instance-attribute

surplus_value: tuple[float | None, ...]

Per kWh of the surplus (Surplus.value).

surplus_value_p10 instance-attribute

surplus_value_p10: tuple[float | None, ...]

flows instance-attribute

flows: tuple[SlotFlow, ...]

The p50 case's run, and the p10 case's: the valuation prices the p10 case on the same run rather than projecting it again.

flows_p10 class-attribute instance-attribute

flows_p10: tuple[SlotFlow, ...] = ()

EnergyValue dataclass

EnergyValue(
    price: tuple[float, ...],
    price_p10: tuple[float, ...],
    certainty_spill: tuple[float, ...],
    certainty_covered: tuple[float, ...],
    pv_value: tuple[PvValue | None, ...] = (),
    battery_value: tuple[BatteryValue | None, ...] = (),
    stored_value: tuple[float, ...] = (),
    unserved: tuple[float, ...] = (),
    model: Model | None = None,
)

Per slot: the price of one more kWh of flexible load, and how sure it is.

price instance-attribute

price: tuple[float, ...]

Per kWh, expected over the PV scenarios.

price_p10 instance-attribute

price_p10: tuple[float, ...]

Per kWh in the pessimistic PV case (R-6): priced on Solcast's p10.

certainty_spill instance-attribute

certainty_spill: tuple[float, ...]

Scenario weight in which the kWh costs only exported or curtailed PV (0-1).

certainty_covered instance-attribute

certainty_covered: tuple[float, ...]

Scenario weight in which the sun charges the battery enough for the expensive hours ahead (0-1).

pv_value class-attribute instance-attribute

pv_value: tuple[PvValue | None, ...] = ()

Values by source (R-2, R-3), expected over the scenarios in which the source has energy to give (None: in none). A PV kWh diverted to a flexible load, and how that PV was routed; a battery kWh drawn for one, and what the battery buys it back with.

battery_value class-attribute instance-attribute

battery_value: tuple[BatteryValue | None, ...] = ()

stored_value class-attribute instance-attribute

stored_value: tuple[float, ...] = ()

Per stored kWh (FlowPrices.stored_value), expected over the scenarios.

unserved class-attribute instance-attribute

unserved: tuple[float, ...] = ()

Scenario weight in which the slot has unserved load (0-1): its price there is the import as a floor, not a reachable source.

model class-attribute instance-attribute

model: Model | None = None

The scenarios as run, for running them again (_live.revalue).

Event

Bases: StrEnum

What priced a slot's kWh (module docstring, the rule).

SPILL class-attribute instance-attribute

SPILL = 'spill'

PV spilling in the slot itself.

IMPORT class-attribute instance-attribute

IMPORT = 'import'

The house importing in the slot itself.

FILL class-attribute instance-attribute

FILL = 'fill'

Later: the battery fills and spills.

TOPUP class-attribute instance-attribute

TOPUP = 'topup'

Later: the plant tops the battery up from the grid.

EMPTY class-attribute instance-attribute

EMPTY = 'empty'

Later: the battery runs empty and the house imports.

EXPORT class-attribute instance-attribute

EXPORT = 'export'

Later: a battery-export run ends at its floor sooner, a kWh less exported.

UNSERVED class-attribute instance-attribute

UNSERVED = 'unserved'

Load no source can serve, now or where the battery empties at the import limit, priced at the slot's import as a floor.

HORIZON class-attribute instance-attribute

HORIZON = 'horizon'

None of them within the flows: the day's import price.

FlowPrices dataclass

FlowPrices(
    prices: tuple[float, ...],
    served: tuple[Served, ...],
    make_up: tuple[float, ...],
    make_up_event: tuple[Event, ...],
    stored_value: tuple[float, ...],
    spilled: tuple[bool, ...],
    covered: tuple[bool, ...],
    events: tuple[Event, ...],
    event_at: tuple[int, ...],
    outcome_at: tuple[int, ...],
    cover_at: tuple[int, ...],
    resolved: tuple[bool, ...],
    covered_resolved: tuple[bool, ...],
    pv_prices: tuple[float, ...] = (),
    grid_diverted: tuple[float, ...] = (),
)

The rule over one sequence of slot flows, per slot.

prices instance-attribute

prices: tuple[float, ...]

Per kWh used in the slot: what serves it (served), at its cost.

served instance-attribute

served: tuple[Served, ...]

make_up instance-attribute

make_up: tuple[float, ...]

Per kWh the battery gives up in the slot: what it buys it back at (the next fill's export, top-up's or empty's import, or the day's import), before losses and wear; with what (make_up_event).

make_up_event instance-attribute

make_up_event: tuple[Event, ...]

stored_value instance-attribute

stored_value: tuple[float, ...]

Per stored kWh the battery gives up in the slot (module docstring): Battery.refill_value of the make-up after a fill or a top-up, Battery.displaced_value after an empty battery or the horizon.

spilled instance-attribute

spilled: tuple[bool, ...]

The kWh costs only spilled PV, now or when the battery fills.

covered instance-attribute

covered: tuple[bool, ...]

The expensive hours ahead are served without an import.

events instance-attribute

events: tuple[Event, ...]

What priced it, and in which slot (the flows' length for the horizon).

event_at instance-attribute

event_at: tuple[int, ...]

outcome_at instance-attribute

outcome_at: tuple[int, ...]

The first slot of the run of spilling, importing or topping up that the event belongs to: slots that share it rest on one outcome (a fill and the afternoon's spill are one).

cover_at instance-attribute

cover_at: tuple[int, ...]

The first slot of the expensive run covered is about (the flows' length: none).

resolved instance-attribute

resolved: tuple[bool, ...]

The price was settled by an event within the flows, not by the horizon's end.

covered_resolved instance-attribute

covered_resolved: tuple[bool, ...]

covered was settled within the flows: the run ahead ended in them, or an import already broke it.

pv_prices class-attribute instance-attribute

pv_prices: tuple[float, ...] = ()

Per kWh of PV diverted from charging the battery, and of the grid's charge diverted at the import limit (the supply stack's pv and grid steps): the dead band's charge side on the make-up rule; on the stored cost, PV at the export, but both at the make-up where the grid buys the kWh back in the slot. Empty without a battery.

grid_diverted class-attribute instance-attribute

grid_diverted: tuple[float, ...] = ()

Noise dataclass

Noise(
    spill_kw: float,
    short_kw: float,
    charge_kw: float,
    topup_kw: float,
    need_kw: float,
    soc: float,
    regulation_kw: float = 0.0,
)

Below these, a flow or an SOC difference is not an event for the rule.

The kW floors are for 10-minute slots (OBSERVED was measured on them).

spill_kw instance-attribute

spill_kw: float

PV exported or curtailed, net of what the battery and grid supplied in the same slot, kW.

short_kw instance-attribute

short_kw: float

The house importing, kW.

charge_kw instance-attribute

charge_kw: float

PV charging the battery, kW.

topup_kw instance-attribute

topup_kw: float

The grid charging the battery, kW.

need_kw instance-attribute

need_kw: float

The battery and the grid serving the house (PV no longer covers it), kW.

soc instance-attribute

soc: float

Full is within this much SOC of saturation, empty of the reserve (0-1).

regulation_kw class-attribute instance-attribute

regulation_kw: float = 0.0

An import up to this, kW, while the battery was above its reserve and had the headroom to serve it, is the plant's grid regulation, not the house importing (metered flows only; 0: none).

Served

Bases: StrEnum

What a kWh used in a slot takes first.

SPILL class-attribute instance-attribute

SPILL = 'spill'

PV spilling now: exported or curtailed.

PV class-attribute instance-attribute

PV = 'pv'

PV charging the battery now: a kWh diverted leaves it charged a kWh less.

BATTERY class-attribute instance-attribute

BATTERY = 'battery'

The battery, discharging or able to.

GRID class-attribute instance-attribute

GRID = 'grid'

The grid: the house importing now, the plant topping the battery up, or the battery with nothing left to give and no PV charging it.

Flows dataclass

Flows(
    consumed: float,
    pv_to_load: float,
    battery_to_load: float,
    grid_to_load: float,
    pv_to_battery: float,
    grid_to_battery: float,
    pv_export: float,
    battery_export: float,
    dc_pv_to_battery: float = 0.0,
    battery_to_dc_load: float = 0.0,
)

One slot's metered flows, mean kW, allocated to sources and sinks: AC, except the DC-bus paths, each in its path's terms as SlotFlow's are. consumed is what the AC side served: its loads, the inverters' draw, and what the hybrid took from it for DC-bus loads.

consumed instance-attribute

consumed: float

pv_to_load instance-attribute

pv_to_load: float

battery_to_load instance-attribute

battery_to_load: float

grid_to_load instance-attribute

grid_to_load: float

pv_to_battery instance-attribute

pv_to_battery: float

grid_to_battery instance-attribute

grid_to_battery: float

pv_export instance-attribute

pv_export: float

battery_export instance-attribute

battery_export: float

dc_pv_to_battery class-attribute instance-attribute

dc_pv_to_battery: float = 0.0

DC PV into a battery on the DC bus, DC kW; the battery serving DC-bus loads.

battery_to_dc_load class-attribute instance-attribute

battery_to_dc_load: float = 0.0

RunEnd dataclass

RunEnd(
    export_lost: float,
    not_delivered: float,
    imported: float,
    refilled: float,
    refilled_dc: float,
    carried: float,
)

What a stored kWh less does in the slot where a battery-export run reaches its floor: the run ends sooner. Each per stored kWh: the AC export the site loses (net of PV the run's end frees), the AC the battery no longer delivers (the discharge wear saved), the AC the house imports instead (where the floor is the reserve), the AC of freed PV that charges the battery (at its charge wear), and the stored kWh still short after the slot (signed: below 0, ahead), which the next event prices. The plant model's.

export_lost instance-attribute

export_lost: float

not_delivered instance-attribute

not_delivered: float

imported instance-attribute

imported: float

refilled instance-attribute

refilled: float

Freed PV charged: on the AC bus (AC kWh), and on the battery's DC bus (DC kWh).

refilled_dc instance-attribute

refilled_dc: float

carried instance-attribute

carried: float

SlotDispatch dataclass

SlotDispatch(
    *,
    held: bool = False,
    topup_gap: float | None = None,
    crossing: float = 0.0,
    battery_port: float = 0.0,
    crossing_ac: float | None = None,
    battery_port_in: float = 0.0,
    battery_port_out: float = 0.0,
    bus_clipped: float = 0.0,
    run_end: RunEnd | None = None,
    unserved_unreachable: float = 0.0,
    hold_session: bool | None = None,
)

One slot's dispatch bookkeeping: what a plant model that decides the battery's dispatch knows of the slot beyond its flows. A plant model without it (SlotFlow.dispatch left at its default) gets every field's default: no hold, no grid-charge window (the price rule reads the grid charging the battery instead), no battery-export run's end, no PV clipped on a DC bus, all unserved load reachable, no hold session, and no hybrid's AC (the ground truth then meters a battery on a DC bus by its own flows, which leaves out DC PV and DC-bus loads crossing).

What reads it. The price rule reads held, topup_gap, run_end, bus_clipped and unserved_unreachable (through SlotFlow.unserved_reachable); the live value reads hold_session; the general dispatch's Storage reads crossing, battery_port and its gross battery_port_in and battery_port_out; the ground truth reads crossing_ac.

held class-attribute instance-attribute

held: bool = False

The plant holds the battery (tops it up, keeps it at its target, or a hold rule keeps it through a DC-bus load's session) and the grid serves the loads. False for metered flows.

topup_gap class-attribute instance-attribute

topup_gap: float | None = None

In the plant's grid-charge window, how far short of its course to the target the battery is, stored kWh, before the slot's top-up (> 0: it tops up; about 0: on course, after an early top-up; < 0: PV will carry it there, or later in the window for a late top-up). A kWh less where it is not below 0 is bought back from the grid. None outside the window and for metered flows, which show only the grid charging.

crossing class-attribute instance-attribute

crossing: float = 0.0

The net DC kW the hybrid inverter converts from its DC bus to the AC side (< 0: from the AC side to the DC bus), and the battery's power at its own port, kW (> 0 charging; DC at a DC bus, AC on the AC bus): what a plant with a DC bus answers its Storage questions from.

battery_port class-attribute instance-attribute

battery_port: float = 0.0

crossing_ac class-attribute instance-attribute

crossing_ac: float | None = None

The AC kW the hybrid's net crossing gives the AC side (< 0: takes from it): what its AC meter reads before its own draw, the battery's flows, the DC PV crossing and the AC it takes for DC-bus loads all in it. Each part's own, so a slot run in parts holds their mean (the curve at the mean crossing is not it). 0 without a hybrid; None for metered flows and a plant model that does not give it.

battery_port_in class-attribute instance-attribute

battery_port_in: float = 0.0

What the battery took in at its port and gave out over the slot, kW, each >= 0 in battery_port's terms: a metered slot can both charge and discharge, which the net battery_port hides. A plant model that leaves both at 0 gets its storage's answers from the net: in where battery_port > 0, out where < 0.

battery_port_out class-attribute instance-attribute

battery_port_out: float = 0.0

bus_clipped class-attribute instance-attribute

bus_clipped: float = 0.0

Of SlotFlow.pv_clipped, what the hybrid clipped at its rating on its DC bus: what a battery there, with room, would take (the fill's clipped PV). The rest was capped at an array's own rating, before the bus. 0 for metered flows.

run_end class-attribute instance-attribute

run_end: RunEnd | None = None

Where a battery-export run reaches its floor in this slot, what a stored kWh less does (RunEnd); None elsewhere, and for metered flows.

unserved_unreachable class-attribute instance-attribute

unserved_unreachable: float = 0.0

Of SlotFlow.unserved, what no stored kWh could serve: a DC-bus load beyond the hybrid's rating, the battery not on its bus (on its own inverter, the battery crosses the same rating). A kWh more or less in the battery leaves it as it is, so the price rule does not read it as unserved.

hold_session class-attribute instance-attribute

hold_session: bool | None = None

In a hold rule's session, whether it holds the battery; None outside one, and for metered flows. A projection that starts in a session takes it as held_at_start, as the plant decided it where the session began.

SlotFlow dataclass

SlotFlow(
    *,
    start: datetime,
    soc_start: float | None,
    soc_end: float | None,
    pv_to_load: float = 0.0,
    pv_to_battery: float = 0.0,
    pv_export: float = 0.0,
    pv_curtail: float = 0.0,
    battery_to_load: float = 0.0,
    grid_to_load: float = 0.0,
    grid_to_battery: float = 0.0,
    pv_clipped: float = 0.0,
    dc_pv_to_battery: float = 0.0,
    dc_pv_to_ac: float = 0.0,
    dc_pv_to_dc_load: float = 0.0,
    battery_to_dc_load: float = 0.0,
    battery_export: float = 0.0,
    ac_to_dc_load: float = 0.0,
    unserved: float = 0.0,
    conversion_loss: float = 0.0,
    inverter_draw: float = 0.0,
    dispatch: SlotDispatch = (lambda: _NO_DISPATCH)(),
)

Mean power flows (kW) over one slot, by path, and the SOC (0-1) at its ends.

Paths. The pv_* fields without dc_ are PV on the AC bus: an AC array's, or a DC array's that the hybrid converted (dc_pv_to_ac). The dc_pv_* fields are a DC array's PV on the DC bus. pv_to_load, battery_to_load and grid_to_load serve the AC side: its loads, the inverters' draw (inverter_draw) and what the hybrid takes from it for DC-bus loads (ac_to_dc_load).

Units. Energy into the battery is in its source's terms (pv_to_battery AC, dc_pv_to_battery DC, grid_to_battery AC at the meter); what serves a load is what the load receives (AC on the AC side, DC for a DC-bus load); what crosses the site's meter is AC at the meter; dc_pv_to_ac is what left the DC array.

Balance. Over a slot hours long, in which the stored energy moves by stored kWh (from the SOC), sources equal sinks: the PV + grid_to_load + grid_to_battery = the loads - unserved + inverter_draw + pv_export + battery_export + pv_curtail + pv_clipped + conversion_loss + stored / hours. The PV (each array's output, in its own terms) and the loads are the slot's inputs to the projection, not fields: once a DC path carries energy, neither can be rebuilt from the fields, since the AC a DC array delivers and the AC a DC-bus load takes are inside the AC fields.

A path the plant does not have is 0. On this site's plant (AC-coupled PV, the battery folded on the hybrid's DC bus, one AC load) every dc_ path, battery_export, ac_to_dc_load and unserved is 0, and the fields up to pv_clipped carry every flow.

Keyword-only; the dispatch's bookkeeping is one record, dispatch (SlotDispatch).

start instance-attribute

start: datetime

soc_start instance-attribute

soc_start: float | None

None for a plant without a battery.

soc_end instance-attribute

soc_end: float | None

pv_to_load class-attribute instance-attribute

pv_to_load: float = 0.0

pv_to_battery class-attribute instance-attribute

pv_to_battery: float = 0.0

pv_export class-attribute instance-attribute

pv_export: float = 0.0

pv_curtail class-attribute instance-attribute

pv_curtail: float = 0.0

battery_to_load class-attribute instance-attribute

battery_to_load: float = 0.0

grid_to_load class-attribute instance-attribute

grid_to_load: float = 0.0

grid_to_battery class-attribute instance-attribute

grid_to_battery: float = 0.0

pv_clipped class-attribute instance-attribute

pv_clipped: float = 0.0

PV the plant could neither convert to AC nor store, such as DC beyond a hybrid inverter's AC rating with the battery full: no AC load can take it, so it is neither spill nor surplus, and nothing that prices spare PV reads it. The AC-coupled plant reports 0: its clip happens in the micro-inverters, before the forecast.

dc_pv_to_battery class-attribute instance-attribute

dc_pv_to_battery: float = 0.0

DC PV into the battery on the DC bus, DC kW.

dc_pv_to_ac class-attribute instance-attribute

dc_pv_to_ac: float = 0.0

DC PV the hybrid converts to the AC side, DC kW: what it delivers is in the AC bus's PV fields, and its loss in conversion_loss.

dc_pv_to_dc_load class-attribute instance-attribute

dc_pv_to_dc_load: float = 0.0

DC PV serving DC-bus loads, DC kW.

battery_to_dc_load class-attribute instance-attribute

battery_to_dc_load: float = 0.0

The battery serving DC-bus loads, DC kW.

battery_export class-attribute instance-attribute

battery_export: float = 0.0

The battery exported, AC kW at the meter. The plant model's: a metered slot leaves it out until the price rule has the export event.

ac_to_dc_load class-attribute instance-attribute

ac_to_dc_load: float = 0.0

The AC side serving DC-bus loads through the hybrid, DC kW the loads receive. The AC it takes is inside the *_to_load fields, and its loss in conversion_loss.

unserved class-attribute instance-attribute

unserved: float = 0.0

Load no source could serve, kW: what an import limit leaves (AC at the meter), or a DC-bus load beyond what the hybrid's rating lets the AC side send it (DC at the load).

conversion_loss class-attribute instance-attribute

conversion_loss: float = 0.0

Energy lost converting, kW: what the battery's charge took less what it stored, what its discharge drew less what it delivered, and the hybrid's other conversions. The plant model's; 0 for metered flows.

inverter_draw class-attribute instance-attribute

inverter_draw: float = 0.0

The inverters' own draw, kW: standby, and the overhead while the battery works. It is inside the *_to_load fields, which serve the load and it, and the balance counts it once. The plant model's; 0 for metered flows.

dispatch class-attribute instance-attribute

dispatch: SlotDispatch = field(
    default_factory=lambda: _NO_DISPATCH
)

The plant model's dispatch bookkeeping (SlotDispatch); a plant model without it leaves it at the default, every field's default.

unserved_reachable property

unserved_reachable: float

Unserved load a stored kWh would serve: what the price rule reads.

battery_out property

battery_out: float

All the battery delivers, kW: to the AC side, to DC-bus loads and exported, each in its path's terms.

moving property

moving: float

What the battery charges or discharges, kW, each path in its terms (all AC on this site's plant).

pv_spare property

pv_spare: float

PV the loads do not use: charging, exported or curtailed (not clipped, which nothing could use).

ChargeSource

Bases: StrEnum

Where a kWh that charges the battery comes from, and by which path. Not Source: the ledger names its lot sources that (ledger.Source).

PV class-attribute instance-attribute

PV = 'pv'

PV on the AC bus (SlotFlow.pv_to_battery).

GRID class-attribute instance-attribute

GRID = 'grid'

The grid (SlotFlow.grid_to_battery).

DC_PV class-attribute instance-attribute

DC_PV = 'dc_pv'

PV on the hybrid's DC bus (SlotFlow.dc_pv_to_battery). A plant without that path raises ValueError when asked about it.

PlantModel

Bases: Protocol

How the plant will behave: the price model's only view of it.

storage property

storage: Storage | None

The battery as the price rule sees it; None without one.

has_pv property

has_pv: bool

Whether the plant has PV: without it, no PV forecast is asked for (its pv_forecast and pv_power are not causes) and its PV is 0.

pv_arrays property

pv_arrays: tuple[str, ...]

Its PV arrays' names: what PV per array is keyed by.

pv_per_array property

pv_per_array: bool

Whether it needs PV per array: its arrays have both couplings, so no one series can say what each makes.

import_limit_kw property

import_limit_kw: float | None

The grid's import limit, kW AC at the meter; None where none binds. A slot at it cannot import a kWh more.

has_dc_loads property

has_dc_loads: bool

Whether a load connects to the hybrid's DC bus: then its forecast and live draw are inputs (dc_load_kw).

meters property

meters: Mapping[str, str]

Its meters: each name a MeterReading keys, to what it measures (pv, grid, battery, consumption or dc_load).

project

project(
    starts: Sequence[datetime],
    durations: Sequence[timedelta],
    pv_kw: PvKw,
    load_kw: Sequence[float],
    soc: float | None,
    *,
    dc_load_kw: Sequence[float] | None = None,
    held_at_start: bool | None = None,
    stop: Callable[[int, SlotFlow], bool] | None = None,
) -> tuple[SlotFlow, ...]

The plant's flows slot by slot from soc (0-1), as it would dispatch them, given PV (its source's terms) and the loads it serves by connection point: load_kw on the AC side, and dc_load_kw on the hybrid's DC bus, DC kW at the loads (None: none draws). A load series that holds a DC-bus load's draw is all AC, as before. A hold rule's session under way at the first slot holds the battery as held_at_start says (None: as the SOC there decides; SlotDispatch.hold_session reports it slot by slot). It is one state: with several rules, every session open at the first slot takes it, exact where one rule holds (plant/2 allows several, and a plant whose rules overlap carries the approximation). It validates only what it receives: every value is known, because the caller builds durations and ends its inputs at the first unknown slot, and a projection shorter than the horizon is the plant_model cause (R-7). A what-if is the same call with a different load. stop ends the projection early (a what-if, once the battery agrees again): the price model no longer asks for it, but it is kept for the port's stability and for the tests' re-simulation oracle (engine_fixtures.rerun_cost).

A plant without a battery takes soc None and projects SOCs of None; one without PV takes PV of 0. PV is one series for the site, or one per array named as pv_arrays (PvKw).

pv_from_meters

pv_from_meters(
    reading: MeterReading, *, per_array: bool
) -> PvKw | None

The PV reading meters for one slot, as project takes it: per array where per_array, else the site's. None where a PV meter is unknown or the meters cannot say it (one total for arrays valued apart).

read_meters

read_meters(reading: MeterReading) -> Flows | None

The plant's meters split between sources and sinks, all of it, battery export included: what the battery ledger books lots from. Each meter in its configured sign; a name the plant does not meter raises, and a meter unknown (missing, or None) leaves the split unknown: None.

metered_flow

metered_flow(
    start: datetime,
    soc: float | None,
    reading: MeterReading,
) -> SlotFlow | None

The meters now (start UTC), read as a slot's flows for the price rule (the live value; built from read_meters); None where a meter is unknown. The price model then asks importing(storage, slot, noise) with the configured floors (EngineConfig.observed), so the noise thresholds stay on its side.

Storage

Bases: Protocol

The battery as the price rule asks about it. Every question names the slot's flows and the energy's path, so a topology answers for its own conversion.

Every answer that reads the battery's discharge counts all of its output (SlotFlow.battery_out: to the AC side, to DC-bus loads and exported).

capacity_kwh property

capacity_kwh: float

What it stores, kWh.

reserve_soc property

reserve_soc: float

The SOC (0-1) the plant keeps in reserve: empty, to the price rule.

saturation_soc property

saturation_soc: float

The SOC (0-1) at which it counts as full.

wear_discharge_per_kwh property

wear_discharge_per_kwh: float

Wear per stored kWh delivered, in the prices' currency.

charge_wear_rate

charge_wear_rate(source: ChargeSource) -> float

Wear per kWh charged from source, in that source's terms (pv_to_battery, grid_to_battery or dc_pv_to_battery): the paths convert differently, so a source kWh can wear the battery differently.

divertible_kw

divertible_kw(flow: SlotFlow) -> float

AC kW a load could take from the PV now charging the battery, in what the load would receive.

can_charge_more

can_charge_more(
    flow: SlotFlow,
    source: ChargeSource,
    *,
    margin_kw: float = 0.0,
) -> bool

Whether this slot could store more from source, short of its limit by more than margin_kw (the top-up test). For the grid it includes the plant's own order of charging (Plant's, PV first: the grid charges more only where no more than margin_kw of PV is spare, since spare PV would refill the battery first).

at_discharge_limit

at_discharge_limit(
    flow: SlotFlow, *, margin_kw: float = 0.0
) -> bool

Whether the battery delivers all it can, within margin_kw.

can_serve

can_serve(flow: SlotFlow, more_kw: float) -> bool

Whether the battery could also deliver more_kw in this slot.

grid_refills_diverted_pv

grid_refills_diverted_pv(flow: SlotFlow) -> bool

Whether a top-up makes up PV a load diverted from charging (where PV charges before the grid, as Plant's dispatch has it, yes).

takes_clipped_pv

takes_clipped_pv(flow: SlotFlow) -> bool

Whether the battery, with room, would take PV this slot clips: true on the clipping hybrid's DC bus, false on its own inverter beside it, which the clipped PV cannot reach.

headroom

headroom(flow: SlotFlow, hours: float) -> float

kW more the battery could deliver in this slot, hours long.

refill_value

refill_value(
    price: float, source: ChargeSource, flow: SlotFlow
) -> float

The cost per stored kWh, refilled from source at price in this slot (that path's conversion at that power, and wear).

displaced_value

displaced_value(
    price: float, flow: SlotFlow | None = None
) -> float

The worth per stored kWh, at price once delivered (conversion, wear); at flow's power, or at low power where no one knows it.

dead_band

dead_band(
    stored_value: float, flow: SlotFlow
) -> tuple[float, float]

The prices a stored kWh worth stored_value is not worth moving between, at this slot's powers: charging pays below the first, discharging above the second.

stored_per_kwh

stored_per_kwh(
    source: ChargeSource, flow: SlotFlow | None = None
) -> float

Stored kWh per kWh charged from source (in that source's terms, as charge_wear_rate's), by that source's own path: averaged over the slot's charge where flow is given (the loss grows with power), else for a charge at the battery's own limit (a kWh bought to fill it). What the stored cost books a charge at (project_average): a topology answers for its paths, as a split battery on a hybrid's DC bus converts the grid's AC through the hybrid and then its own port, and DC PV through its port alone. A plant without the DC PV path raises ValueError when asked about it.

forecast_surplus

forecast_surplus(
    plant: PlantModel,
    starts: Sequence[datetime],
    pv_p50: PvMaybe,
    pv_p10: PvMaybe,
    load_kw: Sequence[float | None],
    soc: float | None,
    *,
    step: timedelta = SLOT,
    first_remaining: timedelta | None = None,
    p_export: Sequence[float | None] | None = None,
    dc_load_kw: Sequence[float | None] | None = None,
    held_at_start: bool | None = None,
) -> SurplusForecast

Project both PV cases through plant and derive surplus, regime and surplus value. A projection ends at the first slot whose PV or load is unknown; every later slot is None (the plant_model cause, R-7). soc is None for a plant without a battery. dc_load_kw is the DC-bus loads' (None: none draws), unknown where a slot's is None.

importing

importing(
    storage: Storage | None,
    flow: SlotFlow,
    noise: Noise = MODEL,
) -> bool

The house imports in the slot: the battery could not, or would not, serve it.

Whether the battery could carry the import is the plant model's answer (storage.can_serve). The test is on power, so a slot part way through is judged as a whole one would be. Without a battery (storage None), any import above the floor is the house's: there is no regulation.

price_flows

price_flows(
    storage: Storage | None,
    flows: Sequence[SlotFlow],
    p_import: Sequence[float],
    p_export: Sequence[float],
    day: int,
    *,
    noise: Noise = MODEL,
    import_limit_kw: float | None = None,
    stored_cost: Sequence[float | None] | None = None,
) -> FlowPrices

The rule (module docstring) over flows: a scenario's, or what was metered.

stored_cost: what a stored kWh is worth in each slot, the running average cost of what the battery holds then (ledger.project_average), per stored kWh; where given (and not None) it is the value the battery's dead band is drawn from, in place of the make-up's. The events ahead still say what displaces the kWh and how sure.

day is the slots in a day. Without a battery (storage None), see _price_without_storage. At the grid's import_limit_kw a kWh more cannot be imported: diverted PV serves it, else the grid's charge diverted, else the battery beyond its policy, else it is unserved (docs/methodology/make-up-rule.md, Limits and unusual plants).

spilling

spilling(flow: SlotFlow, noise: Noise = MODEL) -> bool

PV spills in the slot, net of what the battery and the grid supplied in it.

The model never exports while it discharges or imports, so for its flows the net is the gross; on the meters a slot can hold both (sensor timing).

value_energy

value_energy(
    plant: PlantModel,
    starts: Sequence[datetime],
    scenarios: Sequence[PvKw],
    load_kw: Sequence[float],
    soc: float | None,
    p_import: Sequence[float],
    p_export: Sequence[float],
    *,
    step: timedelta,
    first_remaining: timedelta | None = None,
    durations: Sequence[timedelta] | None = None,
    weights: Sequence[float] | None = None,
    pessimistic: PvKw | None = None,
    pessimistic_flows: Sequence[SlotFlow] | None = None,
    dc_load_kw: Sequence[float] | None = None,
    held_at_start: bool | None = None,
    lots: Lots | None = None,
    battery_value: BatteryValueRule = "stored_cost",
) -> EnergyValue

The price of one more kWh of flexible load in each slot (module docstring).

scenarios are PV forecasts (kW per slot), equally likely unless weights says otherwise; pessimistic is the PV trace price_p10 is priced on (else it is the expectation), and pessimistic_flows its run where the caller has projected it already, on these same inputs (the surplus forecast's p10 case). Every per-slot sequence covers starts. soc (0-1) is the SOC now, part way through the first slot when first_remaining is given; durations gives every slot its own length instead (the live value's), with step still the slot a day is counted in. soc is None for a plant without a battery, which then has no values by battery. load_kw is the AC side's, where a flexible load's kWh lands; dc_load_kw the DC-bus loads', which it passes to the plant as they are (None: none draws), and held_at_start whether a hold rule's session under way now holds the battery (PlantModel.project).