Skip to content

microgrid.engine.analysis.scoring

The checks that run the plant and price models against what the meters did.

What a kWh realised. The price, regime and certainties are claims about what a kWh of flexible load would cost. Over metered flows the rule says what one did (realise, observed_flows, observed_regime), and an hour of them can be routed through the plant model and priced (route_hour, price_hour). The scores over those (the forecast's errors by hours ahead, BUCKETS) are a client's to keep.

Pure: no I/O.

BUCKETS module-attribute

BUCKETS: Final[tuple[tuple[timedelta, timedelta], ...]] = (
    (timedelta(0), timedelta(hours=1)),
    (timedelta(hours=1), timedelta(hours=3)),
    (timedelta(hours=3), timedelta(hours=12)),
    (timedelta(hours=12), timedelta(hours=48)),
)

Horizon buckets, [from, to) ahead of the issue.

RoutedHour dataclass

RoutedHour(
    regime: Regime,
    resolved: bool,
    price: float,
    event: Event,
    event_at: datetime | None,
    spilled: bool,
)

The plant model run from one metered slot over what happened, read by the rule.

regime instance-attribute

regime: Regime

The model's first slot, read as the meters are read (observed_regime).

resolved instance-attribute

resolved: bool

Whether the rule found what prices the first slot within the window.

price instance-attribute

price: float

event instance-attribute

event: Event

event_at instance-attribute

event_at: datetime | None

The start of the slot the event happened in; None when not resolved.

spilled instance-attribute

spilled: bool

Realised dataclass

Realised(
    price: Mapping[datetime, float],
    event: Mapping[datetime, tuple[Event, datetime]],
    outcome: Mapping[datetime, tuple[Event, datetime]],
    regime: Mapping[datetime, Regime],
    spilled: Mapping[datetime, bool],
    covered: Mapping[datetime, bool],
    cover_outcome: Mapping[datetime, datetime],
)

What a kWh used in each slot turned out to cost, by slot start (None: unknown).

price instance-attribute

price: Mapping[datetime, float]

event instance-attribute

event: Mapping[datetime, tuple[Event, datetime]]

What priced it (microgrid.engine.extend.Event), and the start of the slot it happened in.

outcome instance-attribute

outcome: Mapping[datetime, tuple[Event, datetime]]

The outcome it rests on: the event's kind and the first slot of its run (FlowPrices.outcome_at). Slots that share one are nearly one trial.

regime instance-attribute

regime: Mapping[datetime, Regime]

spilled instance-attribute

spilled: Mapping[datetime, bool]

covered instance-attribute

covered: Mapping[datetime, bool]

cover_outcome instance-attribute

cover_outcome: Mapping[datetime, datetime]

The expensive run each covered is about, by its first slot.

storage_of

storage_of(plant: PlantModel) -> Storage

plant's battery, as the rule asks about it. The checks here score a battery's regimes, so they take a plant with one; the engine also values plants without.

soc_end

soc_end(flow: SlotFlow) -> float

flow's SOC at its end: a plant with a battery projects one, and metered flows carry the meters' (observed_flows).

bucket_label

bucket_label(low: timedelta, high: timedelta) -> str

A BUCKETS entry's name, e.g. "3-12h".

hours_ahead_bucket

hours_ahead_bucket(ahead: timedelta) -> str | None

The BUCKETS name ahead falls in; None beyond the last.

observed_flows

observed_flows(
    starts: Sequence[datetime],
    flows: Sequence[Flows | None],
    stored_after: Sequence[float | None],
    stored_before: float | None,
    *,
    plant: Plant,
) -> list[SlotFlow | None]

The metered flows (microgrid.engine.ledger.integrate) as plant's slot flows (Plant.metered_slot), which its battery's answers read.

SOC at a slot's start is the metered stored energy at the previous slot's end; None where the flows or either SOC are unknown. A slot flow has no battery export: where the battery exported, its SOC still falls by it, and battery_exports names those slots for realise to leave out.

Every load the plant metered stays in, the DC-bus load's included: it is a load like any other.

battery_exports

battery_exports(
    starts: Sequence[datetime],
    flows: Sequence[Flows | None],
    noise: Noise = OBSERVED,
) -> frozenset[datetime]

The slots in which the battery exported: the rule cannot say what a kWh then displaced, since the model has no battery export.

Not where the PV left over after the site's consumption could have been the whole export: the split (split_paths) gives export to a discharging battery first, and at full, sensor timing can show a discharge while PV exports (1 of the 16 battery exports at full in one site's 30 days to 2026-09-25).

inverter_kw

inverter_kw(plant: Plant, flow: Flows) -> float

The inverter's own draw in a metered slot, kW (Plant.own_draw_kw). Metered consumption holds it; the house meter and the plant model's load do not.

served_load

served_load(
    flows: Sequence[Flows | None], *, plant: Plant
) -> list[float | None]

Per slot, the load the plant served as its model takes it, kW; None unmetered.

The site's consumption less the inverter's own draw (inverter_kw), which the plant model adds itself. The consumption is derived from the plant's PV, grid and battery meters, so it includes the DC-bus load: a load like any other. The routing check and the plant's ground truth both feed the model this.

route_hour

route_hour(
    plant: PlantModel,
    window: Sequence[SlotFlow],
    load_kw: Sequence[float],
    p_import: Sequence[float],
    p_export: Sequence[float],
    *,
    day: int,
    dc_load_kw: Sequence[float] | None = None,
    held_at_start: bool | None = None,
    noise: Noise = OBSERVED,
    step: timedelta = SLOT,
) -> RoutedHour

The plant model over window's metered PV and load_kw, from its first slot's metered SOC, priced by the rule with the meters' noise floors (noise: the engine's EngineConfig.observed, OBSERVED by default): what the model says would have priced a kWh in the first slot. A plant model with a DC-bus load takes its DC kW and whether a session under way holds the battery. step: the slots' length (EngineConfig.slot).

price_hour

price_hour(
    storage: Storage,
    modelled: Sequence[SlotFlow],
    p_import: Sequence[float],
    p_export: Sequence[float],
    *,
    day: int,
    noise: Noise = OBSERVED,
) -> RoutedHour

A projection from a metered slot, priced by the rule with the meters' noise floors (noise): what the model says would have priced a kWh in its first slot. The caller projects, so any plant model can be priced.

observed_regime

observed_regime(
    storage: Storage,
    flow: SlotFlow,
    noise: Noise = OBSERVED,
) -> Regime

What +1 kWh displaced in a metered slot, as regimes says of the model's.

PV spilling is export: curtailment is not metered. A battery above its reserve, not topping up and with the house not importing, would have served the kWh (battery), idle or not: it sits idle where PV just covers the house. At the reserve it is import.

realise

realise(
    storage: Storage,
    flows: Sequence[SlotFlow | None],
    p_import: Sequence[float],
    p_export: Sequence[float],
    *,
    step: timedelta,
    unscored: Collection[datetime] = frozenset(),
    noise: Noise = OBSERVED,
    stored_cost: Sequence[float | None] | None = None,
) -> Realised

The rule over what was metered: each unbroken run of known slots on its own. stored_cost: per slot, the blended cost of what the battery held at its start (the ledger over the meters; Pricing.battery_value = "stored_cost"), which a battery kWh is priced at; without it, the make-up's.

A slot is priced only when its event lies inside its run (resolved): a run ends at the data's end or at a slot the meters did not see, and what came after that is not known. Slots in unscored (battery_exports) are followed through but get no values of their own.

So the realised set leans away from slots whose event lay across a gap in the meters, and from the hours the battery exported in.