Skip to content

microgrid.engine.analysis.ground_truth

The plant model against the plant's own meters.

A plant model is only worth pricing with if what it projects is what the plant does. project_against_meters feeds a plant model what actually happened (the metered PV, the load the plant served, the metered SOC at the start), so no forecast error is in the way, and compares what it projects with the meters:

  • from each local midnight, for the day: the daily balances, and when the battery tops up, fills and runs empty (DayResult);
  • from each hour, over the price model's 48 h horizon: the SOC error by hours ahead, and whether the model's first event (fill, top-up, empty) is the meters' (HourlyRun).

Primary measures are raw meters (SOC, grid import and export, the inverter's AC meter), so a fitted conversion is never scored against itself. Two are not: no meter separates the grid's share that charges the battery, so the metered top-up (when it starts, how much) comes from the meters' split (split_paths), as each slot's routing does. Both use the battery's fitted conversion, so both are computed with a baseline's conversion pinned (pinned), and so are the inputs and the meters' full and empty thresholds. The served load is consumption less the inverter's draw, both derived through the pinned plant, so every candidate model gets the same inputs and a change in conversion shows only in its prediction.

Every projection goes through project (Projector), so pointing it at another plant model moves every measure.

What is a site's comes in from its caller, as opaque strings: each day's labels (why the model does not describe it), and the DC-bus load's draw outside the house meter. The function adds one label of its own, GAP, where the telemetry was silent. It names no load, and groups nothing: the caller groups the days and runs by their labels and sets the bars.

A plant model with a DC-bus load is fed the session as the plant met it: the house load (the load served less the DC-bus load's draw) on the AC side, the DC-bus load's meter on the DC bus, and, for a run that starts inside a session, whether its hold rules held the battery from the session's metered start. The meters are read through the pinned plant without its DC-bus loads (meters_plant), so a candidate's change shows only in its prediction. Pure: no I/O.

REACH module-attribute

REACH: Final = timedelta(hours=48)

How far an hourly run reaches: the price model's default horizon.

HORIZON module-attribute

HORIZON: Final = int(REACH / SLOT)

REACH in slots of the default length (series.SLOT); a recording on another slot (Recording.step) reaches as far in its own.

GAP module-attribute

GAP: Final = 'telemetry gap'

Projector

Bases: Protocol

Projects the plant from soc (0-1) over starts with the given PV and load, and for a plant with a DC-bus load its DC kW and whether a session under way at the first slot holds the battery: the ground truth's one call into a plant model (a PlantModel's project).

__call__

__call__(
    starts: Sequence[datetime],
    pv: Sequence[float],
    load: Sequence[float],
    soc: float,
    /,
    *,
    dc_load: Sequence[float] | None = None,
    held_at_start: bool | None = None,
) -> tuple[SlotFlow, ...]

Recording dataclass

Recording(
    starts: tuple[datetime, ...],
    flows: tuple[Flows | None, ...],
    stored_after: tuple[float | None, ...],
    stored_before: float | None,
    p_import: tuple[float, ...],
    p_export: tuple[float, ...],
    pv_kw: tuple[float | None, ...],
    house_kw: tuple[float | None, ...],
    grid_import_kw: tuple[float | None, ...],
    grid_export_kw: tuple[float | None, ...],
    inverter_out_kw: tuple[float | None, ...] | None = None,
    inverter_in_kw: tuple[float | None, ...] | None = None,
    dc_load_kw: tuple[float | None, ...] | None = None,
    step: timedelta = SLOT,
)

What the meters recorded over the window, per slot of step (UTC).

starts instance-attribute

starts: tuple[datetime, ...]

flows instance-attribute

flows: tuple[Flows | None, ...]

Split between sources and sinks at sample resolution, through the pinned conversion; None where the telemetry path was silent (the caller's reader).

stored_after instance-attribute

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

stored_before instance-attribute

stored_before: float | None

p_import instance-attribute

p_import: tuple[float, ...]

p_export instance-attribute

p_export: tuple[float, ...]

pv_kw instance-attribute

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

Raw meters, slot means, kW. The library does not read house_kw; a caller derives its DC-bus draws (dc_draws) from it.

house_kw instance-attribute

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

grid_import_kw instance-attribute

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

grid_export_kw instance-attribute

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

inverter_out_kw class-attribute instance-attribute

inverter_out_kw: tuple[float | None, ...] | None = None

The inverter's AC meter, out to the house and in from it; None unconfigured.

inverter_in_kw class-attribute instance-attribute

inverter_in_kw: tuple[float | None, ...] | None = None

dc_load_kw class-attribute instance-attribute

dc_load_kw: tuple[float | None, ...] | None = None

The DC-bus load's meter, DC kW; None where the site has none.

step class-attribute instance-attribute

step: timedelta = SLOT

The slot the recording is read on (EngineConfig.slot).

DayResult dataclass

DayResult(
    day: date,
    reasons: tuple[str, ...],
    pairs: dict[str, tuple[float, float]] | None = None,
    times: dict[str, tuple[float | None, float | None]]
    | None = None,
    soc_mae_pp: float | None = None,
    soc_max_pp: float | None = None,
)

One local day: why it is not clean, and (when it ran) its measures.

day instance-attribute

day: date

reasons instance-attribute

reasons: tuple[str, ...]

pairs class-attribute instance-attribute

pairs: dict[str, tuple[float, float]] | None = None

Measure name -> (model, meters); None where the day did not run.

times class-attribute instance-attribute

times: (
    dict[str, tuple[float | None, float | None]] | None
) = None

Event -> (model, meters) minutes elapsed since local midnight (so a difference is a true one across a DST change; clock gives the wall clock); None: not that day.

soc_mae_pp class-attribute instance-attribute

soc_mae_pp: float | None = None

soc_max_pp class-attribute instance-attribute

soc_max_pp: float | None = None

HourlyRun dataclass

HourlyRun(
    start: datetime,
    reasons: tuple[str, ...],
    touched: frozenset[date],
    full: bool,
    soc_errors: tuple[tuple[str, float], ...],
    event: tuple[Event, Event] | None,
)

A run of the plant model from one hour, over the horizon or to the first unknown input: what the caller groups by its labels.

start instance-attribute

start: datetime

reasons instance-attribute

reasons: tuple[str, ...]

Every label of every day it touches (GAP among them), sorted.

touched instance-attribute

touched: frozenset[date]

The local days it touches.

full instance-attribute

full: bool

It reached the full horizon (HORIZON).

soc_errors instance-attribute

soc_errors: tuple[tuple[str, float], ...]

Each slot's hours-ahead bucket and |SOC error|, pp, in order.

event instance-attribute

event: tuple[Event, Event] | None

The model's first event and the meters', where both are known; None otherwise.

Projection dataclass

Projection(
    days: tuple[DayResult, ...],
    runs: tuple[HourlyRun, ...],
    routing: dict[str, int],
)

What project_against_meters found: each day, each hourly run, and the routing classes ("model -> meters" counts over every day that ran).

days instance-attribute

days: tuple[DayResult, ...]

runs instance-attribute

runs: tuple[HourlyRun, ...]

routing instance-attribute

routing: dict[str, int]

clock

clock(day: date, minutes: float, tz: ZoneInfo) -> str

A DayResult.times value as the local wall clock, HH:MM.

routing_class

routing_class(
    plant: Plant, flow: SlotFlow, noise: Noise = OBSERVED
) -> str

What a slot was doing, by the meters' noise floors (noise).

net_inverter_kw

net_inverter_kw(plant: Plant, flow: SlotFlow) -> float

The model's AC power at the battery's converter, kW, > 0 out to the AC side, net of its own draw: what its AC meter reads. On a hybrid's DC bus the hybrid is the converter, and it meters all it converts (SlotDispatch.crossing_ac): the battery's flows, DC PV crossing to the AC side and the AC it takes for DC-bus loads. On its own inverter, what the battery delivers (an export too) less what it takes, as for a model that does not give the hybrid's AC.

dc_bus_inputs

dc_bus_inputs(
    plant: Plant,
    served: Sequence[float | None],
    draws: Sequence[float],
    dc_load_kw: Sequence[float | None],
    soc_start: Sequence[float | None],
) -> tuple[
    list[float | None],
    list[float | None],
    list[bool | None],
]

What a plant model with a DC-bus load is fed of a session as the plant met it, per slot: the house (the load served less the DC-bus load's draw outside the house meter, the caller's draws), the DC-bus load (its meter where it drew, else 0; None where it drew and the meter is unknown), and whether its hold rules held the battery from the session's metered start (session_holds). The ground truth and the routing check share it.

meters_plant

meters_plant(plant: Plant) -> Plant

plant as the ground truth reads the meters: without its DC-bus loads, their hold rules and the limits only they need (the battery's DC limits, the hybrid's AC rating), so a candidate with one is scored against the same meters as one without. plant itself where it has none.

session_holds

session_holds(
    plant: Plant,
    dc_kw: Sequence[float],
    soc_start: Sequence[float | None],
) -> list[bool | None]

Per slot inside a session of plant's hold rules, whether it holds the battery: any rule whose session (the DC-bus load over its dc_load_above_kw) started below its start_below_soc, by the metered SOC at the session's first slot (as the general dispatch decides; _paths); None outside every session, and where the SOC at a session's start is unknown (the engine then decides from the run's start).

The engine takes one answer for every rule (held_at_start), so overlapping rules can disagree with it; one rule is the common case. A session is the DC-bus load as fed, which is gated on its draw outside the house meter (dc_draws): a dip under its limit ends a session, and the next starts at that slot's SOC.

project_against_meters

project_against_meters(
    recording: Recording,
    *,
    pinned: Plant,
    plant: Plant,
    project: Projector,
    tz: ZoneInfo,
    labels: Mapping[date, tuple[str, ...]],
    dc_draws: Sequence[float] | None = None,
    noise: Noise = OBSERVED,
) -> Projection

Run plant (through project) against recording; pinned derives the inputs and the metered routing (module docstring). labels: each local day's labels, the caller's; GAP is prepended to a day the telemetry was silent in. dc_draws: the DC-bus load's draw outside the house meter, per slot (0 where none), for a plant model with a DC-bus load. noise: the meters' floors the routing, the events and full and empty are read by (EngineConfig.observed).