microgrid.plant¶
The plant model and its simulator (microgrid.plant).
A plant is described by a PlantConfig (plant/2), given as data.
Plant is that plant behind the port
(PlantModel): one configurable model, not one class per
topology, running every configuration through one dispatch. Plant.project projects
the slots it is given from a state of charge, PV and load, and returns their flows
(SlotFlow): where every kWh goes, the battery's charge,
what is imported and exported.
The plant model is useful beyond the marginal price, so this package stands alone: it
imports nothing of the price model (microgrid.engine) or the command
(microgrid.cli), and importing it loads neither. The price model is one client of
it.
The public names: PlantConfig and every part of it (the battery's parameters,
Battery, the grid-charge windows, GridCharge, ...), Plant,
PlantConfigError, the inverter's working threshold (WORKING_KW), the
flow types (flows), the port a plant model implements (port), and
examples.
BATTERY_FIELDS
module-attribute
¶
BATTERY_FIELDS: Final[tuple[str, ...]] = (
"capacity_kwh",
"charge_kw",
"discharge_kw",
"eta_charge",
"eta_discharge",
"reserve_soc",
"saturation_soc",
"wear_charge_per_kwh",
"wear_discharge_per_kwh",
"loss_charge_per_kw",
"loss_discharge_per_kw",
"meter_charge",
"meter_discharge",
)
Battery's fields, named here so the schema changes only on purpose (a
test holds this to Battery's fields).
PLANT_SCHEMA
module-attribute
¶
The mapping's schema. It changes only by version, and grows additively within one.
plant/2: the wear's keys name no currency (wear_charge_per_kwh,
wear_discharge_per_kwh; plant/1 said usd), as money is in the prices' own.
WORKING_KW
module-attribute
¶
The battery works, and the inverter draws Inverter.working_kw more, above
this, kW either way: idle, the battery's meter reads under 0.02 kW in 306 of the
351 slots under 0.05 kW, and the counts climb from 0.05 kW up (measured on the
reference plant's meters, 30 days to 2026-09-25; microgrid fit-battery prints
both, and fits the draw with it).
Battery
dataclass
¶
Battery(
capacity_kwh: float,
charge_kw: float,
discharge_kw: float,
eta_charge: float = 0.94,
eta_discharge: float = 0.94,
reserve_soc: float = 0.0,
saturation_soc: float = 1.0,
wear_charge_per_kwh: float = 0.0,
wear_discharge_per_kwh: float = 0.0,
loss_charge_per_kw: float = 0.0,
loss_discharge_per_kw: float = 0.0,
meter_charge: float = 1.0,
meter_discharge: float = 1.0,
)
A battery, as the plant model needs it.
reserve_soc is the discharge floor the plant keeps (backup reserve);
saturation_soc is where charging is treated as full, below 1.0 because
the BMS tapers the charge rate near the top.
Conversion (fitted by microgrid fit-battery). What converts its port's power to
stored energy and back (on its own inverter, AC; folded on a hybrid's DC bus, the
hybrid's AC side) loses more with the power converted. Charging
at P kW DC takes P / eta_c' + loss_charge_per_kw x P^2 kW AC and
stores meter_charge x P; discharging P kW DC draws meter_discharge
x P stored and delivers eta_d' x P - loss_discharge_per_kw x P^2 AC,
where eta_c' = eta_charge / meter_charge and eta_d' = eta_discharge x
meter_discharge. So eta_charge (AC to stored) and eta_discharge
(stored to AC) are the efficiencies at low power, and the loss terms how
they fall with power. meter_charge and meter_discharge are the
battery's own meter, which reads DC at the battery (1: it reads stored
energy). The inverter's fixed draws are the plant's
(Inverter.standby_kw, working_kw). The defaults (no loss
growth, a meter that reads stored energy) are one efficiency each way.
Wear is the battery's own cost of a kWh through it, per AC kWh, each
direction (docs/methodology/stored-value.md): the hurdle a charge or a
discharge has to clear, which dead_band and minimum_spread
apply.
wear_charge_per_kwh
class-attribute
instance-attribute
¶
Wear per AC kWh charged, and per AC kWh discharged, in the prices' currency.
loss_charge_per_kw
class-attribute
instance-attribute
¶
The conversion's loss growth, AC kW per (DC kW)^2, each way.
meter_charge
class-attribute
instance-attribute
¶
Stored kWh per metered kWh charged, and drawn per metered kWh discharged.
wear_per_delivered_kwh
property
¶
The wear a delivered AC kWh carries: it was charged as 1 /
round_trip AC kWh, and then discharged (at low power).
charge_for
¶
AC kW that adds stored_kw stored (the inverse of stored_by).
delivered_by
¶
AC kW delivered by drawing drawn_kw stored (the inverse of drawn_by),
at most the inverter's peak.
charge_ac
¶
AC kW a charge the battery's meter reads as metered_kw took.
discharge_ac
¶
AC kW a discharge the battery's meter reads as metered_kw delivered.
eta_charge_at
¶
Stored per AC kWh, for one more kWh charged while charging at ac_kw.
eta_discharge_at
¶
AC per stored kWh, for one more kWh delivered while delivering ac_kw.
refill_value
¶
The cost per stored kWh the plant puts back by charging at price per AC
kWh, while charging at charging_kw: (p + c_ch) / eta_c.
displaced_value
¶
The worth per stored kWh that would have served a load in place of an import
at price, delivered while delivering discharging_kw: eta_d (p - c_dis),
its AC kWh less their discharge wear.
dead_band
¶
dead_band(
stored_value: float,
*,
charging_kw: float = 0.0,
discharging_kw: float = 0.0,
) -> tuple[float, float]
Where the battery holds, for stored energy worth stored_value per stored
kWh (docs/methodology/stored-value.md, The dead band): charging pays below the first
(eta_c v - c_ch), discharging above the second (v / eta_d + c_dis),
each at the margin of the power already flowing. They are also what a PV kWh
diverted from charging, and a battery kWh drawn, cost. For stored energy worth
less than nothing (a strongly negative export price) the band inverts: losses
then burn energy that costs to keep, and the first is the dearer.
minimum_spread
¶
The least a use must be worth, per kWh, for a kWh charged at charge_price
to be discharged into it, at low power: (p_c + c_ch) / eta_rt + c_dis
(docs/methodology/stored-value.md, The minimum spread).
BatteryPart
dataclass
¶
BatteryPart(
battery: Battery,
connection: Connection,
form: Form = "folded",
inverter: Inverter | None = None,
dc_charge_kw: float | None = None,
dc_discharge_kw: float | None = None,
)
The battery, and where it connects.
battery's conversion is from its connection point to stored energy. On the
AC bus that is its own inverter's path, and inverter holds that inverter's
rating and draws. On a hybrid's DC bus it is either the whole path from the AC
bus with the meter at the DC bus (folded: this site's fit, unsplit) or the
DC bus to stored energy alone (split: the hybrid's Conversion
holds DC bus to AC).
inverter
class-attribute
instance-attribute
¶
Its own inverter, on the AC bus; None on a DC bus (the hybrid is there).
dc_charge_kw
class-attribute
instance-attribute
¶
Its own DC limits, kW, on a DC bus. A folded battery with anything else on its
DC bus needs them: battery.charge_kw and discharge_kw are AC-side, and
with these given the plant model reads these in their place (the hybrid's
rating bounds the AC side). Without them a folded battery's AC-side limits hold,
at the DC bus through its curve. A split battery's charge_kw and
discharge_kw are at the DC bus already, and the lower of each pair binds.
Conversion
dataclass
¶
Conversion(
eta_in: float,
eta_out: float,
loss_in_per_kw: float = 0.0,
loss_out_per_kw: float = 0.0,
)
A converter between a DC bus and the AC bus: the hybrid inverter's, where no
folded battery holds it. The same law as Battery's conversion:
low-power efficiencies each way, and AC kW lost per (DC kW)^2 each way.
Dispatch
dataclass
¶
Dispatch(
grid_charge: tuple[GridCharge, ...] = (),
export_windows: tuple[ExportWindow, ...] = (),
charge_order: ChargeOrder = "pv_first",
hold_rules: tuple[HoldRule, ...] = (),
)
ExportWindow
dataclass
¶
The plant discharging the battery to the grid, in local time, within a day.
floor_soc
instance-attribute
¶
The window stops at this SOC, at or above the reserve, until it ends: the battery does not run up and export again in the same window.
GridCharge
dataclass
¶
GridCharge(
start: time,
end: time,
target_soc: float = 1.0,
cover_until: time | None = None,
timing: Literal["early", "late"] = "early",
)
The plant's off-peak grid-charge window, in local time.
Inside the window the grid tops the battery up to a target that PV alone
would not reach by end. The target is target_soc, or, when
cover_until is given, what the house needs from end until then (the
expensive hours, net of the PV then and of discharge losses), whichever is
lower. cover_until at or before end means that time the next day
(midnight: 00:00); within the window's own hours it is refused, as the next
day's window tops up first.
timing is when in the window the grid tops up: "early", from the
window's start, as soon as PV will fall short; or "late", only what the
rest of the window could no longer add at the charge limit, so the top-up
comes as late as it can and still ends by end (as measured on the reference
plant: its top-ups since 2026-09-17 started 13:50-14:50 and took only what PV had
not delivered). Either way the plant holds the battery while it is short of its
target in the window.
GridLimits
dataclass
¶
HoldRule
dataclass
¶
The plant holding its battery through a DC-bus load's session: while the DC-bus
loads draw more than dc_load_above_kw, a session that starts with the SOC below
start_below_soc keeps the battery from discharging, to any load or the grid,
until it ends. A session that starts at or above it drains the battery as the
policy does. Measured on the reference plant's meters: it held the battery through
13 of 14 of its DC-bus load's sessions this way at about 65 % to 2026-09-17, and
through all nine since, started at 32-85 %.
Inverter
dataclass
¶
Inverter(
ac_rating_kw: float | None = None,
standby_kw: float = 0.0,
working_kw: float = 0.0,
working_threshold_kw: float = WORKING_KW,
conversion: Conversion | None = None,
)
An inverter: a hybrid with a DC bus (PlantConfig.hybrid), or a
battery's own on the AC bus (BatteryPart.inverter).
ac_rating_kw
class-attribute
instance-attribute
¶
Its AC rating, on the net power crossing it, kW. None: no limit binds.
standby_kw
class-attribute
instance-attribute
¶
Its own draws, kW: always, and more while the battery works (microgrid
fit-battery fits both). Every
inverter's standby counts; working_kw is the one the battery converts
through's (PlantConfig.battery_inverter), so a hybrid beside a battery on its
own inverter, or with none, adds no working draw.
working_threshold_kw
class-attribute
instance-attribute
¶
The battery works above this, kW either way (WORKING_KW's measurement).
conversion
class-attribute
instance-attribute
¶
DC bus to AC: a hybrid's, where its battery is split or absent. None where a folded battery holds it, and on a battery's own inverter (the battery's conversion is its inverter's).
Load
dataclass
¶
Meter
dataclass
¶
Meter(
name: str,
measures: Measures,
array: str | None = None,
at: MeterAt | None = None,
sign: float = 1.0,
)
A meter the plant reads: its kW is keyed by name in a MeterReading.
pv: an array's output (array), or every array's together (arrayNone; only where they share one coupling), in the array's terms: AC after an AC array's own inverters, DC at a DC array's.grid: the site's meter, AC.battery: at its DC side (at"dc", the default), or its own inverter's AC side ("ac", a battery on the AC bus only). For a battery on a DC bus, folded or split, the DC side is the bus, as this site's meter reads; for one on its own inverter, its DC terminals, converted byBattery's law.consumption: the AC loads and the inverters' draw, metered in the grid's place: one of the two closes the balance, which gives the other.dc_load: the DC-bus loads' draw, DC at the loads.
sign is the meter's convention: 1 where positive is PV made, importing,
charging or drawing; -1 where it is the opposite.
PlantConfig
dataclass
¶
PlantConfig(
tz: ZoneInfo,
pv: tuple[PvArray, ...] = (),
battery: BatteryPart | None = None,
hybrid: Inverter | None = None,
grid: GridLimits = GridLimits(),
loads: tuple[Load, ...] = (Load("house"),),
dispatch: Dispatch = Dispatch(),
meters: tuple[Meter, ...] | None = None,
)
A plant: its topology and parameters.
hybrid
class-attribute
instance-attribute
¶
The hybrid inverter, where the plant has a DC bus.
loads
class-attribute
instance-attribute
¶
At least one load on the AC bus: the house.
meters
class-attribute
instance-attribute
¶
The meters the plant reads; None: today's set (meter_set).
meter_set
property
¶
The meters the plant reads: as configured, or by default the set this
site has: pv for every array together where there is PV (where the arrays
have both couplings, one per array, named pv:<array>, which no other default
name can be), grid, battery at its DC side where there is a battery, and
dc_load where a load is on the DC bus.
battery_inverter
property
¶
The inverter the battery converts through, whose draws its work adds: its own on the AC bus, or the hybrid on its DC bus. None without a battery.
from_mapping
classmethod
¶
The configuration in data (the plant/2 schema), in tz, whose key
must be data["timezone"]. Every field's and part's problem is named at
once; the rules across parts follow once the parts are sound.
to_mapping
¶
Plain data that from_mapping reads back to an equal configuration.
A field that is None is left out.
PlantConfigError
¶
Bases: ValueError
A configuration the plant model cannot take; errors names each problem
with its path.
PvArray
dataclass
¶
PvArray(
name: str,
coupling: Coupling,
dc_rating_kw: float | None = None,
ac_rating_kw: float | None = None,
)
One PV array. Its forecast is in its coupling's terms: AC kW for an AC-coupled array, DC kW for a DC-coupled one.
Plant
dataclass
¶
The plant config describes, behind the port (port.PlantModel).
It runs PV of either coupling or both, per array or, where every array has one
coupling, as one series shared by their DC ratings; or none; a battery on its own
inverter or on a hybrid's DC bus, folded or split, or none; a hybrid with DC PV,
with or without a battery of its own or beside one on its own inverter; loads on the
AC bus and the hybrid's DC bus; any grid-charge and battery-export windows, PV
charging first or the target first; and the grid's limits. Today's configurations
the general dispatch (_paths), and a plant without a battery or hybrid
_without_battery. Two plants are equal when their
configurations are (the time zone compared as ZoneInfo
compares: by identity, so the client passes one zone object, as
PlantConfig asks).
pv_one_series
property
¶
Whether the plant takes one PV series for all its arrays: every array has one
coupling and, where any is capped, each has its DC rating (_pv's rule).
battery
property
¶
The battery's conversion and limits: what the service's battery ledger books
lots in, and the issue's battery block publishes. A topology of its own would
answer these its own way. Raises PlantConfigError for a plant without a battery.
inverter
property
¶
The inverter the battery converts through, whose draws its work adds
(PlantConfig.battery_inverter). Raises PlantConfigError for a
plant without a battery.
own_draw_kw
¶
The inverter's own draw in a metered slot, kW: its standby, and its overhead
while the battery charges or discharges over its working threshold
(Inverter.working_kw, working_threshold_kw), by more than float noise
as the projection reads it. Metered consumption holds it; the house meter and
the plant model's load do not. With a hybrid beside a battery's own inverter,
both standbys. 0 without an inverter (_without_battery).
Where PV charging in standby would take the battery over the threshold but the
working overhead would leave it at or under it, the projection keeps the
standby. The meters cannot tell that slot from one working with the house
working_kw lower: the same charge, grid and consumption. Read as working, as
the inverter is with the battery moving over its threshold.
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, ...]
pv_from_meters
¶
The PV reading meters, for one slot: per array, keyed by name, where
per_array and each array has its meter; else the site's (one coupling's)
total. None where a PV meter is unknown, or the meters cannot say it.
metered_slot
¶
A metered slot's mean flows (ledger.integrate) as the plant model's slot,
between the metered SOCs: its sources and sinks on the AC side, no battery
export (as metered_flow), the losses and draws at 0. The battery's port
flows and the hybrid's crossing are worked out from the mean AC charge and
discharge, as a projection of them would be, each way on its own: a slot of
means can both charge and discharge. The battery's DC-bus paths (DC PV into it,
it serving DC-bus loads) add to its port flows as they are, DC, and do not cross
the hybrid. The DC PV the hybrid converts is not in the metered
flows, so the crossing is the battery's AC alone.
metered_flow
¶
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.
dc_pv_to_battery
class-attribute
instance-attribute
¶
DC PV into a battery on the DC bus, DC kW; the battery serving DC-bus loads.
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).
pv_clipped
class-attribute
instance-attribute
¶
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 into the battery on the DC bus, DC kW.
dc_pv_to_ac
class-attribute
instance-attribute
¶
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 serving DC-bus loads, DC kW.
battery_to_dc_load
class-attribute
instance-attribute
¶
The battery serving DC-bus loads, DC kW.
battery_export
class-attribute
instance-attribute
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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 load a stored kWh would serve: what the price rule reads.
battery_out
property
¶
All the battery delivers, kW: to the AC side, to DC-bus loads and exported, each in its path's terms.
moving
property
¶
What the battery charges or discharges, kW, each path in its terms (all AC on this site's plant).
pv_spare
property
¶
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).
DC_PV
class-attribute
instance-attribute
¶
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.
has_pv
property
¶
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
¶
Its PV arrays' names: what PV per array is keyed by.
pv_per_array
property
¶
Whether it needs PV per array: its arrays have both couplings, so no one series can say what each makes.
import_limit_kw
property
¶
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
¶
Whether a load connects to the hybrid's DC bus: then its forecast and live
draw are inputs (dc_load_kw).
meters
property
¶
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
¶
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
¶
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
¶
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).
reserve_soc
property
¶
The SOC (0-1) the plant keeps in reserve: empty, to the price rule.
wear_discharge_per_kwh
property
¶
Wear per stored kWh delivered, in the prices' currency.
charge_wear_rate
¶
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
¶
AC kW a load could take from the PV now charging the battery, in what the load would receive.
can_charge_more
¶
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
¶
Whether the battery delivers all it can, within margin_kw.
can_serve
¶
Whether the battery could also deliver more_kw in this slot.
grid_refills_diverted_pv
¶
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
¶
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
¶
kW more the battery could deliver in this slot, hours long.
refill_value
¶
The cost per stored kWh, refilled from source at price in this slot
(that path's conversion at that power, and wear).
displaced_value
¶
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
¶
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 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.
microgrid.plant.flows¶
What a projection is: per-slot flows, the surplus they leave, and the regime
(microgrid.plant.flows, public).
These are the types the plant model returns, for any use of it; the price model
(microgrid.engine) reads them too, and they know nothing of it: a plant model
projects SlotFlow s, and the price model reads their surplus and regime
without knowing the plant. Flows is the metered counterpart: one slot's
meters, split between sources and sinks by the plant (plant._meters.split_paths).
Surplus is PV the site cannot use: more than the house takes and the battery can store, so the plan exports it (or curtails it above an export limit). It is what the plant would observably export, slot by slot, and it is additive: its sum over a day is the PV energy there is to share. While the battery is still charging and nothing is exported, there is no surplus. Battery export is never surplus. A load that is running is in the load series, so the surplus is net of its draw.
Pure: no I/O. Times are UTC.
Regime
¶
Bases: StrEnum
What +1 kWh of flexible load in a slot would displace.
BATTERY
class-attribute
instance-attribute
¶
Energy the battery would otherwise hold or deliver.
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.
dc_pv_to_battery
class-attribute
instance-attribute
¶
DC PV into a battery on the DC bus, DC kW; the battery serving DC-bus loads.
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.
refilled
instance-attribute
¶
Freed PV charged: on the AC bus (AC kWh), and on the battery's DC bus (DC kWh).
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
¶
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
¶
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
¶
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.
crossing_ac
class-attribute
instance-attribute
¶
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
¶
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.
bus_clipped
class-attribute
instance-attribute
¶
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
¶
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
¶
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
¶
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).
pv_clipped
class-attribute
instance-attribute
¶
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 into the battery on the DC bus, DC kW.
dc_pv_to_ac
class-attribute
instance-attribute
¶
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 serving DC-bus loads, DC kW.
battery_to_dc_load
class-attribute
instance-attribute
¶
The battery serving DC-bus loads, DC kW.
battery_export
class-attribute
instance-attribute
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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 load a stored kWh would serve: what the price rule reads.
battery_out
property
¶
All the battery delivers, kW: to the AC side, to DC-bus loads and exported, each in its path's terms.
moving
property
¶
What the battery charges or discharges, kW, each path in its terms (all AC on this site's plant).
pv_spare
property
¶
PV the loads do not use: charging, exported or curtailed (not clipped, which nothing could use).
Surplus
dataclass
¶
PV the plan exports or curtails in each slot, and what a kWh of it is worth: the
average over the slot's surplus, export and curtailment weighted by energy, as a
load taking the surplus pays it. A marginal kWh is priced at the margin instead
(_value._spill_value: curtailed PV first, at 0, under an export limit).
curtailed
instance-attribute
¶
True where more of it is curtailed than exported.
value
instance-attribute
¶
The price per kWh a flexible load takes from it: the slot's export price for the exported part, 0 for the curtailed part, weighted by energy. None where there is no surplus, or exported surplus has no price.
pv_slots
¶
How many slots pv covers (every array the same; a mapping with none, 0).
pv_slice
¶
pv over slots start to end: a list for one series, as callers built
it before PV per array, or each array's.
pv_frozen
¶
pv that cannot be changed: a tuple, or a read-only mapping of tuples.
pv_joined
¶
first for one slot, then rest at slots: the live value's window at
measured PV, then the forecast (_live.revalue). A valuation per array takes PV
measured per array, and one for the site its total.
surplus
¶
Surplus per slot of flows (module docstring), valued at p_export.
regimes
¶
regimes(
flows: Sequence[SlotFlow],
surplus: Surplus,
import_limit_kw: float | None = None,
divertible_kw: Callable[[SlotFlow], float]
| None = None,
) -> tuple[Regime, ...]
What +1 kWh of flexible load would displace in each slot. At the grid's
import_limit_kw a kWh more is not imported: the battery's (its charge or its
discharge), or, where load is unserved, the import as a floor.
divertible_kw is the storage's answer for the PV charging it that a load could
take (Storage.divertible_kw): without it, the AC PV charging it.
microgrid.plant.port¶
The plant model behind a port: what the price model may ask of a plant.
The price model needs to know how the plant will behave (its flows over the
horizon, and how they change with more load) and a few things about its battery. It
asks them here, and never what the plant is: topology, the
inverter's conversion, the dispatch and the meters are a plant model's. The library's
microgrid.plant.Plant is one, for any topology plant/2 describes; a
client's own model is another (microgrid.engine.extend, checked by
microgrid.engine.testing).
Every comparison keeps today's floating-point form, so the predicates take a margin rather than return a limit to compare against: written another way, an answer could differ in the last bit, and the output must not.
Neutral, like microgrid.plant.flows: it imports neither a plant
model nor the price model. Pure: no I/O.
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).
DC_PV
class-attribute
instance-attribute
¶
PV on the hybrid's DC bus (SlotFlow.dc_pv_to_battery). A plant without that
path raises ValueError when asked about it.
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).
reserve_soc
property
¶
The SOC (0-1) the plant keeps in reserve: empty, to the price rule.
wear_discharge_per_kwh
property
¶
Wear per stored kWh delivered, in the prices' currency.
charge_wear_rate
¶
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
¶
AC kW a load could take from the PV now charging the battery, in what the load would receive.
can_charge_more
¶
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
¶
Whether the battery delivers all it can, within margin_kw.
can_serve
¶
Whether the battery could also deliver more_kw in this slot.
grid_refills_diverted_pv
¶
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
¶
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
¶
kW more the battery could deliver in this slot, hours long.
refill_value
¶
The cost per stored kWh, refilled from source at price in this slot
(that path's conversion at that power, and wear).
displaced_value
¶
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
¶
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 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.
PlantModel
¶
Bases: Protocol
How the plant will behave: the price model's only view of it.
has_pv
property
¶
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
¶
Its PV arrays' names: what PV per array is keyed by.
pv_per_array
property
¶
Whether it needs PV per array: its arrays have both couplings, so no one series can say what each makes.
import_limit_kw
property
¶
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
¶
Whether a load connects to the hybrid's DC bus: then its forecast and live
draw are inputs (dc_load_kw).
meters
property
¶
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
¶
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
¶
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
¶
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.