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
¶
Lots
dataclass
¶
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).
pool
class-attribute
instance-attribute
¶
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.
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.
charged
instance-attribute
¶
Stored kWh added, and what the AC energy cost (price x AC kWh).
to_load
instance-attribute
¶
Stored kWh drawn for the house, and their value.
to_export
instance-attribute
¶
Stored kWh drawn for export, and their value.
unexplained
instance-attribute
¶
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).
pool
class-attribute
instance-attribute
¶
All held after the slot, pooled, and its cost (Ledger.pool).
delivered_per_drawn
class-attribute
instance-attribute
¶
AC kWh the slot's discharge delivered per stored kWh it drew (0: none).
to_dc_load
class-attribute
instance-attribute
¶
Stored kWh drawn for DC-bus loads, and their value; and the DC kWh they received per stored kWh (0: none).
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
¶
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.
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
¶
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
¶
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.