Skip to content

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

PLANT_SCHEMA: Final = 'plant/2'

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

WORKING_KW = 0.05

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.

capacity_kwh instance-attribute

capacity_kwh: float

charge_kw instance-attribute

charge_kw: float

discharge_kw instance-attribute

discharge_kw: float

eta_charge class-attribute instance-attribute

eta_charge: float = 0.94

eta_discharge class-attribute instance-attribute

eta_discharge: float = 0.94

reserve_soc class-attribute instance-attribute

reserve_soc: float = 0.0

saturation_soc class-attribute instance-attribute

saturation_soc: float = 1.0

wear_charge_per_kwh class-attribute instance-attribute

wear_charge_per_kwh: float = 0.0

Wear per AC kWh charged, and per AC kWh discharged, in the prices' currency.

wear_discharge_per_kwh class-attribute instance-attribute

wear_discharge_per_kwh: float = 0.0

loss_charge_per_kw class-attribute instance-attribute

loss_charge_per_kw: float = 0.0

The conversion's loss growth, AC kW per (DC kW)^2, each way.

loss_discharge_per_kw class-attribute instance-attribute

loss_discharge_per_kw: float = 0.0

meter_charge class-attribute instance-attribute

meter_charge: float = 1.0

Stored kWh per metered kWh charged, and drawn per metered kWh discharged.

meter_discharge class-attribute instance-attribute

meter_discharge: float = 1.0

round_trip property

round_trip: float

AC out per AC in, at low power.

wear_per_delivered_kwh property

wear_per_delivered_kwh: float

The wear a delivered AC kWh carries: it was charged as 1 / round_trip AC kWh, and then discharged (at low power).

__post_init__

__post_init__() -> None

stored_by

stored_by(ac_kw: float) -> float

Stored kW a charge of ac_kw AC adds.

charge_for

charge_for(stored_kw: float) -> float

AC kW that adds stored_kw stored (the inverse of stored_by).

drawn_by

drawn_by(ac_kw: float) -> float

Stored kW a discharge delivering ac_kw AC draws.

delivered_by

delivered_by(drawn_kw: float) -> float

AC kW delivered by drawing drawn_kw stored (the inverse of drawn_by), at most the inverter's peak.

charge_ac

charge_ac(metered_kw: float) -> float

AC kW a charge the battery's meter reads as metered_kw took.

discharge_ac

discharge_ac(metered_kw: float) -> float

AC kW a discharge the battery's meter reads as metered_kw delivered.

eta_charge_at

eta_charge_at(ac_kw: float = 0.0) -> float

Stored per AC kWh, for one more kWh charged while charging at ac_kw.

eta_discharge_at

eta_discharge_at(ac_kw: float = 0.0) -> float

AC per stored kWh, for one more kWh delivered while delivering ac_kw.

refill_value

refill_value(
    price: float, *, charging_kw: float = 0.0
) -> float

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

displaced_value(
    price: float, *, discharging_kw: float = 0.0
) -> float

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

minimum_spread(charge_price: float) -> float

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).

battery instance-attribute

battery: Battery

connection instance-attribute

connection: Connection

form class-attribute instance-attribute

form: Form = 'folded'

inverter class-attribute instance-attribute

inverter: Inverter | None = None

Its own inverter, on the AC bus; None on a DC bus (the hybrid is there).

dc_charge_kw class-attribute instance-attribute

dc_charge_kw: float | None = None

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.

dc_discharge_kw class-attribute instance-attribute

dc_discharge_kw: float | None = None

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.

eta_in instance-attribute

eta_in: float

eta_out instance-attribute

eta_out: float

loss_in_per_kw class-attribute instance-attribute

loss_in_per_kw: float = 0.0

loss_out_per_kw class-attribute instance-attribute

loss_out_per_kw: float = 0.0

Dispatch dataclass

Dispatch(
    grid_charge: tuple[GridCharge, ...] = (),
    export_windows: tuple[ExportWindow, ...] = (),
    charge_order: ChargeOrder = "pv_first",
    hold_rules: tuple[HoldRule, ...] = (),
)

What the plant does on its own, beyond self-consumption.

grid_charge class-attribute instance-attribute

grid_charge: tuple[GridCharge, ...] = ()

export_windows class-attribute instance-attribute

export_windows: tuple[ExportWindow, ...] = ()

charge_order class-attribute instance-attribute

charge_order: ChargeOrder = 'pv_first'

PV before the grid (this site), or the grid as a window's target requires regardless of spare PV.

hold_rules class-attribute instance-attribute

hold_rules: tuple[HoldRule, ...] = ()

Rules under which the plant holds the battery.

ExportWindow dataclass

ExportWindow(
    start: time,
    end: time,
    power_kw: float,
    floor_soc: float,
    yields: Yields = "pv",
)

The plant discharging the battery to the grid, in local time, within a day.

start instance-attribute

start: time

end instance-attribute

end: time

power_kw instance-attribute

power_kw: float

Held through the window, AC kW.

floor_soc instance-attribute

floor_soc: float

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.

yields class-attribute instance-attribute

yields: Yields = 'pv'

Which yields at the export limit.

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.

start instance-attribute

start: time

end instance-attribute

end: time

target_soc class-attribute instance-attribute

target_soc: float = 1.0

cover_until class-attribute instance-attribute

cover_until: time | None = None

timing class-attribute instance-attribute

timing: Literal['early', 'late'] = 'early'

__post_init__

__post_init__() -> None

GridLimits dataclass

GridLimits(
    import_limit_kw: float | None = None,
    export_limit_kw: float | None = None,
)

The grid connection's limits, kW. None: no limit binds. An export limit of 0 means the site may not export.

import_limit_kw class-attribute instance-attribute

import_limit_kw: float | None = None

export_limit_kw class-attribute instance-attribute

export_limit_kw: float | None = None

HoldRule dataclass

HoldRule(dc_load_above_kw: float, start_below_soc: float)

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 %.

dc_load_above_kw instance-attribute

dc_load_above_kw: float

A session is the DC-bus loads drawing more than this, DC kW.

start_below_soc instance-attribute

start_below_soc: float

The SOC (0-1) a session must start below to hold the battery.

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

ac_rating_kw: float | None = None

Its AC rating, on the net power crossing it, kW. None: no limit binds.

standby_kw class-attribute instance-attribute

standby_kw: float = 0.0

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_kw class-attribute instance-attribute

working_kw: float = 0.0

working_threshold_kw class-attribute instance-attribute

working_threshold_kw: float = WORKING_KW

The battery works above this, kW either way (WORKING_KW's measurement).

conversion class-attribute instance-attribute

conversion: Conversion | None = None

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

Load(name: str, connection: Connection = 'ac')

A load, by where it connects. The load forecast owns its kW (R-9).

name instance-attribute

name: str

connection class-attribute instance-attribute

connection: Connection = 'ac'

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 (array None; 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 by Battery'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.

name instance-attribute

name: str

measures instance-attribute

measures: Measures

array class-attribute instance-attribute

array: str | None = None

at class-attribute instance-attribute

at: MeterAt | None = None

sign class-attribute instance-attribute

sign: float = 1.0

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.

tz instance-attribute

tz: ZoneInfo

pv class-attribute instance-attribute

pv: tuple[PvArray, ...] = ()

battery class-attribute instance-attribute

battery: BatteryPart | None = None

hybrid class-attribute instance-attribute

hybrid: Inverter | None = None

The hybrid inverter, where the plant has a DC bus.

grid class-attribute instance-attribute

grid: GridLimits = field(default_factory=GridLimits)

loads class-attribute instance-attribute

loads: tuple[Load, ...] = (Load('house'),)

At least one load on the AC bus: the house.

dispatch class-attribute instance-attribute

dispatch: Dispatch = field(default_factory=Dispatch)

meters class-attribute instance-attribute

meters: tuple[Meter, ...] | None = None

The meters the plant reads; None: today's set (meter_set).

meter_set property

meter_set: tuple[Meter, ...]

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

battery_inverter: Inverter | None

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.

__post_init__

__post_init__() -> None

from_mapping classmethod

from_mapping(data: object, *, tz: ZoneInfo) -> PlantConfig

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

to_mapping() -> dict[str, object]

Plain data that from_mapping reads back to an equal configuration. A field that is None is left out.

PlantConfigError

PlantConfigError(errors: Sequence[str])

Bases: ValueError

A configuration the plant model cannot take; errors names each problem with its path.

errors instance-attribute

errors = tuple(errors)

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.

name instance-attribute

name: str

coupling instance-attribute

coupling: Coupling

dc_rating_kw class-attribute instance-attribute

dc_rating_kw: float | None = None

The MPPT limit, DC kW: it caps the array's DC power. None: no limit binds.

ac_rating_kw class-attribute instance-attribute

ac_rating_kw: float | None = None

An AC-coupled array's own inverter's rating, AC kW. None: no limit binds.

Plant dataclass

Plant(config: PlantConfig)

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).

config instance-attribute

config: PlantConfig

storage property

storage: PathStorage | None

has_pv property

has_pv: bool

pv_arrays property

pv_arrays: tuple[str, ...]

pv_per_array property

pv_per_array: bool

pv_one_series property

pv_one_series: bool

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).

import_limit_kw property

import_limit_kw: float | None

has_dc_loads property

has_dc_loads: bool

battery property

battery: Battery

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

inverter: Inverter

The inverter the battery converts through, whose draws its work adds (PlantConfig.battery_inverter). Raises PlantConfigError for a plant without a battery.

meters property

meters: Mapping[str, str]

__post_init__

__post_init__() -> None

own_draw_kw

own_draw_kw(flow: Flows) -> float

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

pv_from_meters(
    reading: MeterReading, *, per_array: bool
) -> PvKw | None

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.

read_meters

read_meters(reading: MeterReading) -> Flows | None

metered_slot

metered_slot(
    start: datetime,
    soc_start: float,
    soc_end: float,
    flows: Flows,
) -> SlotFlow

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

metered_flow(
    start: datetime,
    soc: float | None,
    reading: MeterReading,
) -> SlotFlow | None

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.

consumed instance-attribute

consumed: float

pv_to_load instance-attribute

pv_to_load: float

battery_to_load instance-attribute

battery_to_load: float

grid_to_load instance-attribute

grid_to_load: float

pv_to_battery instance-attribute

pv_to_battery: float

grid_to_battery instance-attribute

grid_to_battery: float

pv_export instance-attribute

pv_export: float

battery_export instance-attribute

battery_export: float

dc_pv_to_battery class-attribute instance-attribute

dc_pv_to_battery: float = 0.0

DC PV into a battery on the DC bus, DC kW; the battery serving DC-bus loads.

battery_to_dc_load class-attribute instance-attribute

battery_to_dc_load: float = 0.0

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).

start instance-attribute

start: datetime

soc_start instance-attribute

soc_start: float | None

None for a plant without a battery.

soc_end instance-attribute

soc_end: float | None

pv_to_load class-attribute instance-attribute

pv_to_load: float = 0.0

pv_to_battery class-attribute instance-attribute

pv_to_battery: float = 0.0

pv_export class-attribute instance-attribute

pv_export: float = 0.0

pv_curtail class-attribute instance-attribute

pv_curtail: float = 0.0

battery_to_load class-attribute instance-attribute

battery_to_load: float = 0.0

grid_to_load class-attribute instance-attribute

grid_to_load: float = 0.0

grid_to_battery class-attribute instance-attribute

grid_to_battery: float = 0.0

pv_clipped class-attribute instance-attribute

pv_clipped: float = 0.0

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_to_battery: float = 0.0

DC PV into the battery on the DC bus, DC kW.

dc_pv_to_ac class-attribute instance-attribute

dc_pv_to_ac: float = 0.0

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_to_dc_load: float = 0.0

DC PV serving DC-bus loads, DC kW.

battery_to_dc_load class-attribute instance-attribute

battery_to_dc_load: float = 0.0

The battery serving DC-bus loads, DC kW.

battery_export class-attribute instance-attribute

battery_export: float = 0.0

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

ac_to_dc_load: float = 0.0

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

unserved: float = 0.0

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

conversion_loss: float = 0.0

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

inverter_draw: float = 0.0

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

dispatch: SlotDispatch = field(
    default_factory=lambda: _NO_DISPATCH
)

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_reachable: float

Unserved load a stored kWh would serve: what the price rule reads.

battery_out property

battery_out: float

All the battery delivers, kW: to the AC side, to DC-bus loads and exported, each in its path's terms.

moving property

moving: float

What the battery charges or discharges, kW, each path in its terms (all AC on this site's plant).

pv_spare property

pv_spare: float

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).

PV class-attribute instance-attribute

PV = 'pv'

PV on the AC bus (SlotFlow.pv_to_battery).

GRID class-attribute instance-attribute

GRID = 'grid'

The grid (SlotFlow.grid_to_battery).

DC_PV class-attribute instance-attribute

DC_PV = 'dc_pv'

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.

storage property

storage: Storage | None

The battery as the price rule sees it; None without one.

has_pv property

has_pv: bool

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

pv_arrays: tuple[str, ...]

Its PV arrays' names: what PV per array is keyed by.

pv_per_array property

pv_per_array: bool

Whether it needs PV per array: its arrays have both couplings, so no one series can say what each makes.

import_limit_kw property

import_limit_kw: float | None

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

has_dc_loads: bool

Whether a load connects to the hybrid's DC bus: then its forecast and live draw are inputs (dc_load_kw).

meters property

meters: Mapping[str, str]

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

pv_from_meters(
    reading: MeterReading, *, per_array: bool
) -> PvKw | None

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

read_meters(reading: MeterReading) -> Flows | None

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

metered_flow(
    start: datetime,
    soc: float | None,
    reading: MeterReading,
) -> SlotFlow | None

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).

capacity_kwh property

capacity_kwh: float

What it stores, kWh.

reserve_soc property

reserve_soc: float

The SOC (0-1) the plant keeps in reserve: empty, to the price rule.

saturation_soc property

saturation_soc: float

The SOC (0-1) at which it counts as full.

wear_discharge_per_kwh property

wear_discharge_per_kwh: float

Wear per stored kWh delivered, in the prices' currency.

charge_wear_rate

charge_wear_rate(source: ChargeSource) -> float

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

divertible_kw(flow: SlotFlow) -> float

AC kW a load could take from the PV now charging the battery, in what the load would receive.

can_charge_more

can_charge_more(
    flow: SlotFlow,
    source: ChargeSource,
    *,
    margin_kw: float = 0.0,
) -> bool

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

at_discharge_limit(
    flow: SlotFlow, *, margin_kw: float = 0.0
) -> bool

Whether the battery delivers all it can, within margin_kw.

can_serve

can_serve(flow: SlotFlow, more_kw: float) -> bool

Whether the battery could also deliver more_kw in this slot.

grid_refills_diverted_pv

grid_refills_diverted_pv(flow: SlotFlow) -> bool

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

takes_clipped_pv(flow: SlotFlow) -> bool

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

headroom(flow: SlotFlow, hours: float) -> float

kW more the battery could deliver in this slot, hours long.

refill_value

refill_value(
    price: float, source: ChargeSource, flow: SlotFlow
) -> float

The cost per stored kWh, refilled from source at price in this slot (that path's conversion at that power, and wear).

displaced_value

displaced_value(
    price: float, flow: SlotFlow | None = None
) -> float

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

dead_band(
    stored_value: float, flow: SlotFlow
) -> tuple[float, float]

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_per_kwh(
    source: ChargeSource, flow: SlotFlow | None = None
) -> float

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.

PvKw

PvKw = Sequence[float] | Mapping[str, Sequence[float]]

MeterReading

MeterReading = Mapping[str, float | None]

Regime

Bases: StrEnum

What +1 kWh of flexible load in a slot would displace.

EXPORT class-attribute instance-attribute

EXPORT = 'export'

PV that would be exported.

CURTAIL class-attribute instance-attribute

CURTAIL = 'curtail'

PV that would be curtailed.

BATTERY class-attribute instance-attribute

BATTERY = 'battery'

Energy the battery would otherwise hold or deliver.

IMPORT class-attribute instance-attribute

IMPORT = 'import'

Grid import.

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.

consumed instance-attribute

consumed: float

pv_to_load instance-attribute

pv_to_load: float

battery_to_load instance-attribute

battery_to_load: float

grid_to_load instance-attribute

grid_to_load: float

pv_to_battery instance-attribute

pv_to_battery: float

grid_to_battery instance-attribute

grid_to_battery: float

pv_export instance-attribute

pv_export: float

battery_export instance-attribute

battery_export: float

dc_pv_to_battery class-attribute instance-attribute

dc_pv_to_battery: float = 0.0

DC PV into a battery on the DC bus, DC kW; the battery serving DC-bus loads.

battery_to_dc_load class-attribute instance-attribute

battery_to_dc_load: float = 0.0

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.

export_lost instance-attribute

export_lost: float

not_delivered instance-attribute

not_delivered: float

imported instance-attribute

imported: float

refilled instance-attribute

refilled: float

Freed PV charged: on the AC bus (AC kWh), and on the battery's DC bus (DC kWh).

refilled_dc instance-attribute

refilled_dc: float

carried instance-attribute

carried: float

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

held: bool = False

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

topup_gap: float | None = None

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

crossing: float = 0.0

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.

battery_port class-attribute instance-attribute

battery_port: float = 0.0

crossing_ac class-attribute instance-attribute

crossing_ac: float | None = None

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

battery_port_in: float = 0.0

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.

battery_port_out class-attribute instance-attribute

battery_port_out: float = 0.0

bus_clipped class-attribute instance-attribute

bus_clipped: float = 0.0

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

run_end: RunEnd | None = None

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

unserved_unreachable: float = 0.0

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

hold_session: bool | None = None

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).

start instance-attribute

start: datetime

soc_start instance-attribute

soc_start: float | None

None for a plant without a battery.

soc_end instance-attribute

soc_end: float | None

pv_to_load class-attribute instance-attribute

pv_to_load: float = 0.0

pv_to_battery class-attribute instance-attribute

pv_to_battery: float = 0.0

pv_export class-attribute instance-attribute

pv_export: float = 0.0

pv_curtail class-attribute instance-attribute

pv_curtail: float = 0.0

battery_to_load class-attribute instance-attribute

battery_to_load: float = 0.0

grid_to_load class-attribute instance-attribute

grid_to_load: float = 0.0

grid_to_battery class-attribute instance-attribute

grid_to_battery: float = 0.0

pv_clipped class-attribute instance-attribute

pv_clipped: float = 0.0

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_to_battery: float = 0.0

DC PV into the battery on the DC bus, DC kW.

dc_pv_to_ac class-attribute instance-attribute

dc_pv_to_ac: float = 0.0

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_to_dc_load: float = 0.0

DC PV serving DC-bus loads, DC kW.

battery_to_dc_load class-attribute instance-attribute

battery_to_dc_load: float = 0.0

The battery serving DC-bus loads, DC kW.

battery_export class-attribute instance-attribute

battery_export: float = 0.0

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

ac_to_dc_load: float = 0.0

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

unserved: float = 0.0

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

conversion_loss: float = 0.0

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

inverter_draw: float = 0.0

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

dispatch: SlotDispatch = field(
    default_factory=lambda: _NO_DISPATCH
)

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_reachable: float

Unserved load a stored kWh would serve: what the price rule reads.

battery_out property

battery_out: float

All the battery delivers, kW: to the AC side, to DC-bus loads and exported, each in its path's terms.

moving property

moving: float

What the battery charges or discharges, kW, each path in its terms (all AC on this site's plant).

pv_spare property

pv_spare: float

PV the loads do not use: charging, exported or curtailed (not clipped, which nothing could use).

Surplus dataclass

Surplus(
    kw: tuple[float, ...],
    curtailed: tuple[bool, ...],
    value: tuple[float | None, ...],
)

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).

kw instance-attribute

kw: tuple[float, ...]

curtailed instance-attribute

curtailed: tuple[bool, ...]

True where more of it is curtailed than exported.

value instance-attribute

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

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

pv_slots(pv: PvKw) -> int

How many slots pv covers (every array the same; a mapping with none, 0).

pv_slice

pv_slice(
    pv: PvKw, start: int, end: int | None = None
) -> PvKw

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_frozen(pv: PvKw) -> PvKw

pv that cannot be changed: a tuple, or a read-only mapping of tuples.

pv_joined

pv_joined(
    first: float | Mapping[str, float],
    rest: PvKw,
    slots: Sequence[int],
) -> PvKw

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(
    flows: Sequence[SlotFlow],
    p_export: Sequence[float | None] | None = None,
) -> 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).

PV class-attribute instance-attribute

PV = 'pv'

PV on the AC bus (SlotFlow.pv_to_battery).

GRID class-attribute instance-attribute

GRID = 'grid'

The grid (SlotFlow.grid_to_battery).

DC_PV class-attribute instance-attribute

DC_PV = 'dc_pv'

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).

capacity_kwh property

capacity_kwh: float

What it stores, kWh.

reserve_soc property

reserve_soc: float

The SOC (0-1) the plant keeps in reserve: empty, to the price rule.

saturation_soc property

saturation_soc: float

The SOC (0-1) at which it counts as full.

wear_discharge_per_kwh property

wear_discharge_per_kwh: float

Wear per stored kWh delivered, in the prices' currency.

charge_wear_rate

charge_wear_rate(source: ChargeSource) -> float

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

divertible_kw(flow: SlotFlow) -> float

AC kW a load could take from the PV now charging the battery, in what the load would receive.

can_charge_more

can_charge_more(
    flow: SlotFlow,
    source: ChargeSource,
    *,
    margin_kw: float = 0.0,
) -> bool

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

at_discharge_limit(
    flow: SlotFlow, *, margin_kw: float = 0.0
) -> bool

Whether the battery delivers all it can, within margin_kw.

can_serve

can_serve(flow: SlotFlow, more_kw: float) -> bool

Whether the battery could also deliver more_kw in this slot.

grid_refills_diverted_pv

grid_refills_diverted_pv(flow: SlotFlow) -> bool

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

takes_clipped_pv(flow: SlotFlow) -> bool

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

headroom(flow: SlotFlow, hours: float) -> float

kW more the battery could deliver in this slot, hours long.

refill_value

refill_value(
    price: float, source: ChargeSource, flow: SlotFlow
) -> float

The cost per stored kWh, refilled from source at price in this slot (that path's conversion at that power, and wear).

displaced_value

displaced_value(
    price: float, flow: SlotFlow | None = None
) -> float

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

dead_band(
    stored_value: float, flow: SlotFlow
) -> tuple[float, float]

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_per_kwh(
    source: ChargeSource, flow: SlotFlow | None = None
) -> float

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.

storage property

storage: Storage | None

The battery as the price rule sees it; None without one.

has_pv property

has_pv: bool

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

pv_arrays: tuple[str, ...]

Its PV arrays' names: what PV per array is keyed by.

pv_per_array property

pv_per_array: bool

Whether it needs PV per array: its arrays have both couplings, so no one series can say what each makes.

import_limit_kw property

import_limit_kw: float | None

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

has_dc_loads: bool

Whether a load connects to the hybrid's DC bus: then its forecast and live draw are inputs (dc_load_kw).

meters property

meters: Mapping[str, str]

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

pv_from_meters(
    reading: MeterReading, *, per_array: bool
) -> PvKw | None

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

read_meters(reading: MeterReading) -> Flows | None

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

metered_flow(
    start: datetime,
    soc: float | None,
    reading: MeterReading,
) -> SlotFlow | None

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.