Skip to content

microgrid.engine.ledger

The battery's lots: where its energy came from and what it cost.

The method: docs/methodology/lots.md. What the battery holds by source, and what it cost: a client publishes the split, and the floor (floor_per_kwh). All it holds also as one pool (Ledger.pool), whose running average cost prices a kWh it delivers, run forward along each projection (project_average, through the plant's Storage): a battery kWh is priced at what the energy stored in the battery cost. The pool, not the lots' blend: a draw takes the average, so only what charges it moves the price, not the order the lots are drawn in. The types (Lots, Amount, Source) and the projection are the engine's neutral accounting, which the price model reads without this module.

Units. Lots hold stored kWh (DC side). The plant model's meter reading (PlantModel.read_meters) turns the plant's DC battery power into AC flows; charging c AC kWh at price p stores c·η_c at a basis of p/η_c per stored kWh. Delivering d AC kWh draws d/η_d stored kWh, and the drawn value is the delivery's cost, conversion loss included.

Flows at sample resolution. integrate reads the metered powers at every change of any of them and sums over the slot. A 10-minute net mean blurs two states into one: when a DC-bus load's session starts the battery swings from charging on PV to discharging into it, and the mean looks like the grid charging the battery (on the reference plant's meters, 10-minute means booked 55-250 Wh of such "grid lots" a session). Only stretches where the telemetry path was alive count (the caller's alive spans, series.alive); a slot with any silent stretch moves nothing, and the meter anchor catches up.

Order. The house is served from grid lots first and exports come from PV lots first (restricted-first: grid energy can go to one destination, PV to either); within a source, a weighted average. Energy of unknown origin is drawn first by both.

The meter wins. After each slot the lots are scaled to the metered stored energy, both ways and pro rata at their own basis, so what the model does not explain (self-discharge, the BMS, meter timing, a wrong efficiency) shows as unexplained, never as drift, and never as free energy. A draw larger than the lots hold scales them up the same way first.

Unknown energy (one rule): what the battery held when the ledger started, or what the meter shows when the model held nothing, has no known cost. It is kept apart (Source.UNKNOWN) at no value, reported as such, and never sets a floor (floor_per_kwh). The price values it at the import price, as if bought (blended_basis's unknown_basis): not knowing what it cost moves the price toward the import, as every missing input does (R-7).

Efficiencies. A charge is booked through the plant's paths where the caller gives the slot as the plant model's (Ledger): each source's stored kWh per kWh, by the path it took (Storage.stored_per_kwh). Else through the battery's fitted conversion (Battery.stored_by), whose loss grows with power: PV and the grid charge through the same inverter on an AC-coupled plant, so they share it, and the grid's top-ups lose more only because they run at the charge limit. A discharge is drawn through Battery.drawn_by.

Pure: no I/O.

Amount dataclass

Amount(kwh: float = 0.0, value: float = 0.0)

Stored kWh and their value, in the prices' currency.

kwh class-attribute instance-attribute

kwh: float = 0.0

value class-attribute instance-attribute

value: float = 0.0

unit property

unit: float

Value per stored kWh (0 when empty).

__add__

__add__(other: Amount) -> Amount

Lots dataclass

Lots(
    as_of: datetime,
    held: Mapping[Source, Amount],
    pool: Amount | None = None,
)

What the battery holds, by source, as of the end of the last slot the ledger saw; and all of it as one pool, its running average cost (pool: what prices a kWh the battery delivers).

as_of instance-attribute

as_of: datetime

held instance-attribute

held: Mapping[Source, Amount]

pool class-attribute instance-attribute

pool: Amount | None = None

All the battery holds and its cost, every source pooled: charging adds at cost, a draw takes at the average. None: not kept (the price derives it from held).

Source

Bases: StrEnum

Where a lot's energy came from.

UNKNOWN class-attribute instance-attribute

UNKNOWN = 'unknown'

Of unknown origin: held when the ledger started, or appearing when the model held nothing. No known cost.

PV class-attribute instance-attribute

PV = 'pv'

GRID class-attribute instance-attribute

GRID = 'grid'

LedgerSlot dataclass

LedgerSlot(
    start: datetime,
    charged: Mapping[Source, Amount],
    to_load: Mapping[Source, Amount],
    to_export: Mapping[Source, Amount],
    unexplained: Amount,
    held: Mapping[Source, Amount],
    pool: Amount = Amount(),
    delivered_per_drawn: float = 0.0,
    to_dc_load: Mapping[Source, Amount] = dict(),
    dc_delivered_per_drawn: float = 0.0,
)

What one slot did to the lots.

start instance-attribute

start: datetime

charged instance-attribute

charged: Mapping[Source, Amount]

Stored kWh added, and what the AC energy cost (price x AC kWh).

to_load instance-attribute

to_load: Mapping[Source, Amount]

Stored kWh drawn for the house, and their value.

to_export instance-attribute

to_export: Mapping[Source, Amount]

Stored kWh drawn for export, and their value.

unexplained instance-attribute

unexplained: Amount

The meter against the model: kWh > 0 more than the model held, < 0 less; at the lots' own basis (at no cost only when the model held nothing).

held instance-attribute

held: Mapping[Source, Amount]

The lots after the slot.

pool class-attribute instance-attribute

pool: Amount = field(default_factory=Amount)

All held after the slot, pooled, and its cost (Ledger.pool).

delivered_per_drawn class-attribute instance-attribute

delivered_per_drawn: float = 0.0

AC kWh the slot's discharge delivered per stored kWh it drew (0: none).

to_dc_load class-attribute instance-attribute

to_dc_load: Mapping[Source, Amount] = field(
    default_factory=dict
)

Stored kWh drawn for DC-bus loads, and their value; and the DC kWh they received per stored kWh (0: none).

dc_delivered_per_drawn class-attribute instance-attribute

dc_delivered_per_drawn: float = 0.0

Ledger

Ledger(
    battery: Battery,
    unknown_kwh: float = 0.0,
    *,
    unknown_basis: float | None = None,
    storage: Storage | None = None,
)

The battery's lots, advanced one slot at a time.

What it needs of the plant (the extension tier). A charge is booked through storage's paths where the caller gives each slot as the plant model's (step's slot: Plant.metered_slot), so a battery on a hybrid's DC bus costs grid and PV energy by the paths they took (Storage.stored_per_kwh); without them, through battery's own law (an AC-coupled battery's, exactly). A discharge is drawn through battery's law either way: the meter anchor (stored_after) scales what any law leaves out. So a client with its own plant model passes a Battery describing its battery's conversion and its Storage; or keeps its lots itself and hands them to the engine as Lots, whose price needs Storage.stored_per_kwh alone (project_average).

unknown_kwh: what the battery holds now, of unknown origin: at no cost in the lots; in the pool at unknown_basis per stored kWh, else at the first slot's import price, as if bought (R-7: toward the import), through storage's grid path where given. What the meter later finds in an empty model is priced at that slot's import.

pool property

pool: Amount

All the battery holds and its cost, pooled: charging adds at cost, a draw takes at the average, the meter's scaling keeps it. Its unit is the running average cost per stored kWh, what prices a kWh the battery delivers.

held property

held: Mapping[Source, Amount]

step

step(
    start: datetime,
    flows: Flows | None,
    hours: float,
    *,
    pv_price: float,
    grid_price: float,
    stored_after: float | None,
    slot: SlotFlow | None = None,
) -> LedgerSlot

Advance one slot.

flows None (telemetry missing) moves nothing; pv_price and grid_price are per kWh charged in the slot (AC, or DC for DC PV); stored_after is the metered stored energy at the slot's end, kWh (None: unknown). slot: the same slot as the plant model's (Plant.metered_slot), whose charge storage converts by path (the class's docstring).

blended_basis

blended_basis(
    held: Mapping[Source, Amount], unknown_basis: float
) -> float | None

The blended cost of what the battery holds, per stored kWh: solar at the export it gave up, grid energy at what it was bought for, energy of unknown origin at unknown_basis (per stored kWh); None when it holds nothing.

project_average

project_average(
    storage: Storage,
    flows: Sequence[SlotFlow],
    hours: Sequence[float],
    p_import: Sequence[float],
    p_export: Sequence[float],
    *,
    lots: Lots | None,
) -> list[float | None]

Per stored kWh, at the start of each slot of flows: the running average cost of all the battery holds then, its pool now (lots; a client that keeps the lots alone: their blend) run forward along the flows.

Charging adds at cost, through each source's own path (storage.stored_per_kwh): solar at the export it gives up (AC or DC PV alike, per kWh of it), the grid at the import it pays. After each slot the pool is scaled to the projection's stored energy, which takes a discharge at the average. Energy of unknown origin (all of it without lots, or what appears in an empty pool) is priced as bought: the slot's import per stored kWh through the grid's path at the charge limit (R-7: a missing input moves the price toward the import). None where it holds nothing.

integrate

integrate(
    series: Mapping[str, Series[float]],
    starts: Sequence[datetime],
    step: timedelta,
    *,
    plant: PlantModel,
    alive_spans: Sequence[tuple[datetime, datetime]]
    | None = None,
    stale_nonzero: Mapping[str, timedelta] | None = None,
) -> list[Flows | None]

Each slot's flows, as plant reads its meters (PlantModel.read_meters) over every stretch the metered powers hold (series.segments; mean kW).

series holds one power series per meter of plant, named as plant.meters names them: any meter set. A stretch counts only while every meter has a value and the path is alive (alive_spans, from series.alive; none: always); a slot with any stretch that does not is None. stale_nonzero: a meter whose reading other than 0 has been silent that long has no value (a lost 0 that ended a DC-bus load's session, say).

soc_boundary

soc_boundary(
    soc_means: Sequence[float | None], index: int
) -> float | None

The SOC at the end of slot index, from 10-minute means.

The mean of the two slots either side of the boundary: a slot's mean is its middle's value, and the next slot's alone would lag the boundary by half a slot (the lots would be anchored five minutes late).

run_ledger

run_ledger(
    battery: Battery,
    starts: Sequence[datetime],
    flows: Sequence[Flows | None],
    hours: float,
    pv_price: Sequence[float],
    grid_price: Sequence[float],
    stored_after: Sequence[float | None],
    *,
    unknown_kwh: float = 0.0,
) -> list[LedgerSlot]

The ledger over starts, every per-slot sequence covering them.

floor_per_kwh

floor_per_kwh(
    held: Mapping[Source, Amount],
    battery: Battery,
    *,
    draw_kwh: float,
    draw_kw: float,
) -> float | None

The myopic guard: what the next draw_kwh drawn cost the site.

A draw takes restricted-first (grid-charged energy before PV), down to the battery's reserve at draw_kw; the floor is the average basis of what it takes, per kWh delivered (basis/eta_d at that power), plus the wear a delivered kWh carries (Battery.wear_per_delivered_kwh). Averaged over a draw, not the first lot: a sliver of grid energy must not price the whole draw. A consumer that cannot read the forward-looking price should not spend battery energy on a use worth less. The price takes the running average of all the battery holds (Ledger.pool); this takes the next draw's.

None when the battery holds nothing above its reserve, or when energy of unknown origin (no known cost) would be drawn.