Skip to content

mambo_power.model

The network data model: the Network root, every entity, the named validation errors and the public re-check. See the manual page for field tables and the validation-code reference.

mambo_power.model

Data model: the pydantic v2 Network and its entities, named validation errors, typed import warnings, and the island repair every importer applies before validation.

BranchKind module-attribute

BranchKind = Literal['line', 'transformer']

What a Branch is. Exporters route on it (a pandapower trafo row, a PyPSA Transformer, a RAW transformer record); the solvers do not read it.

BusType module-attribute

BusType = Literal['slack', 'pv', 'pq']

Bus role in power flow. MATPOWER type 4 (isolated) maps to in_service=False instead.

ValidationCode module-attribute

ValidationCode = Literal[
    "NO_SLACK",
    "MULTIPLE_SLACK",
    "DISCONNECTED_BUS",
    "DUPLICATE_ID",
    "DANGLING_REF",
    "BAD_BASE",
    "BAD_RANGE",
]

The closed set of network-level validation codes (wave M1 design item 5).

ImportIssueCode module-attribute

ImportIssueCode = Literal[
    "ISLAND_DEACTIVATED",
    "BASE_KV_REPLACED",
    "GENCOST_REACTIVE_IGNORED",
    "CSV_MANIFEST_INVALID",
    "CSV_SCHEMA_VERSION",
    "CSV_MISSING_TABLE",
    "CSV_UNKNOWN_COLUMN",
    "CSV_MISSING_COLUMN",
    "CSV_DUPLICATE_ID",
    "CSV_BAD_VALUE",
    "CSV_ORPHAN_ROW",
    "RAW_NO_COSTS",
    "RAW_LOAD_ZIP_FOLDED",
    "RAW_BRANCH_END_SHUNT_FOLDED",
    "RAW_XFMR_MAGNETISING_FOLDED",
    "RAW_THREE_WINDING_IGNORED",
    "RAW_SWITCHED_SHUNT_IGNORED",
    "RAW_SECTION_IGNORED",
    "EXTRA_EXT_GRID_DEMOTED",
    "GEN_SLACK_PROMOTED",
    "TAP_CHANGER_TYPE_UNSUPPORTED",
    "COLUMN_DROPPED",
    "ELEMENT_DROPPED",
    "FIELD_DEFAULTED",
    "FIELD_DROPPED",
    "COST_DROPPED",
    "BID_DROPPED",
    "PYPSA_PWL_COST_DROPPED",
    "PYPSA_COST_DEGREE_DROPPED",
    "PYPSA_LOAD_BID_DROPPED",
    "PYPSA_ZONE_DROPPED",
    "PYPSA_GEN_Q_LIMITS_DROPPED",
    "PYPSA_GEN_RAMP_DROPPED",
    "PYPSA_GEN_VSET_CONFLICT",
    "PYPSA_UNRATED_S_NOM_DEFAULTED",
    "PYPSA_COST_NONCONVEX",
]

The closed set of importer/repair warning codes.

ISLAND_DEACTIVATEDmambo_power.model.repair_islands switched an island off. BASE_KV_REPLACED — MATPOWER BASE_KV <= 0 replaced by the importer default. GENCOST_REACTIVE_IGNORED — a 2 * ngen-row gencost had its reactive half dropped.

The CSV_* codes are mambo_power.io.csv_bundle import errors (never warnings): CSV_MANIFEST_INVALIDmanifest.json missing, unparsable, or disagreeing with the tables; CSV_SCHEMA_VERSION — the manifest names a schema version this build does not read; CSV_MISSING_TABLE — a table file the bundle must carry is absent; CSV_UNKNOWN_COLUMN / CSV_MISSING_COLUMN — a table header is not the model's field list; CSV_DUPLICATE_ID — an id appears twice in one table; CSV_BAD_VALUE — a cell does not parse as its column's type (nan included), or an entity fails its own field validation; CSV_ORPHAN_ROW — a cost/bid side-table row whose owner is absent or carries no kind.

RAW v33 import (mambo_power.io.psse_raw, M8 W4):

RAW_NO_COSTS — RAW carries no economic data; every generator imports with cost=None (once per file). RAW_LOAD_ZIP_FOLDED — a load's constant-current/constant-admittance parts (IP IQ YP YQ) were folded into p_mw/q_mvar at the bus's VM (MATPOWER's rule). RAW_BRANCH_END_SHUNT_FOLDED — a branch end shunt (GI BI / GJ BJ) became a Shunt. RAW_XFMR_MAGNETISING_FOLDED — a transformer's MAG1/MAG2 became a Shunt at the from bus. RAW_THREE_WINDING_IGNORED — a three-winding transformer record (K != 0) was skipped. RAW_SWITCHED_SHUNT_IGNORED — a switched shunt record was skipped (BINIT not folded). RAW_SECTION_IGNORED — a record of an unread section (owner, DC lines, FACTS, ...) was skipped; the message names the section and the record. Wave M8 interop codes (io.pandapower_json first; later format modules reuse them):

EXTRA_EXT_GRID_DEMOTED — a second in-service ext_grid imported as a PV generator. GEN_SLACK_PROMOTED — with no in-service ext_grid, a gen with slack = True (pandapower's ext_grid-less reference bus) imported as the slack generator. TAP_CHANGER_TYPE_UNSUPPORTED — a pandapower trafo tap changer the model cannot express (an unknown tap_changer_type, an Ideal shifter with both step columns set, a tap_side that is neither hv nor lv); imported at the nominal tap. COLUMN_DROPPED — an imported column value the model has no field for (element and field named). ELEMENT_DROPPED — a whole row (either direction) with no counterpart on the other side. FIELD_DEFAULTED — a required field the source left empty (or the target format demands), filled with a stated default. FIELD_DROPPED — an exported model field the target format has no column for. COST_DROPPED — a generator cost the target format cannot express (never approximated). BID_DROPPED — a load bid the target format cannot express. PYPSA_* — fields mambo_power.io.pypsa dropped because PyPSA cannot carry them (piecewise or degree > 2 costs, load bids, zones, generator Q limits, a ramp on a zero-capacity generator), collapsed (disagreeing voltage setpoints at one bus) or approximated (an unrated branch written with the s_nom sentinel, PYPSA_UNRATED_S_NOM_DEFAULTED), plus PYPSA_COST_NONCONVEX for a concave quadratic cost (c2 < 0) exported unchanged that PyPSA's solver will refuse.

Branch

Bases: _Entity

Line or transformer between two buses. Tap is on the from side (MATPOWER model).

id class-attribute instance-attribute

id: str

Unique within branches.

from_bus class-attribute instance-attribute

from_bus: str

Bus id of the from (tap) side.

to_bus class-attribute instance-attribute

to_bus: str

Bus id of the to side.

r class-attribute instance-attribute

r: float

Series resistance, per unit on base_mva.

x class-attribute instance-attribute

x: float

Series reactance, per unit on base_mva.

b class-attribute instance-attribute

b: float

Total line charging susceptance, per unit on base_mva.

rating_mva class-attribute instance-attribute

rating_mva: float | None

Thermal rating, MVA; None = none.

tap_ratio class-attribute instance-attribute

tap_ratio: float | None

Off-nominal tap; None = 1.0.

shift_deg class-attribute instance-attribute

shift_deg: float | None

Phase shift, degrees; None = 0.

kind class-attribute instance-attribute

kind: BranchKind = 'line'

'line' or 'transformer'. Defaults to 'transformer' when tap_ratio is not None/1.0 or shift_deg is not None/0.0, else 'line'; an explicit 'transformer' at nominal tap is kept; an explicit 'line' with a tap or shift is promoted to 'transformer' at validation. Assignment after construction does not re-run this rule: exporters route on is_transformer, which reads the fields.

is_transformer property

is_transformer: bool

What exporters route on: kind == "transformer" or an off-nominal tap/shift.

kind is derived at validation and entities are mutable, so br.tap_ratio = 1.05 on a line leaves kind == "line" in memory until the next validation. Reading the fields here means an exporter never drops such a tap, and every serialisation (model_dump, the native and CSV dumps) writes this answer as kind so a file never says line beside a tap (M8 critic nit 24).

Bus

Bases: _Entity

Electrical node. vm_pu/va_deg hold an initial or last-solved state, if any.

id class-attribute instance-attribute

id: str

Unique within buses.

base_kv class-attribute instance-attribute

base_kv: float

Nominal voltage, kV. Must be > 0.

type class-attribute instance-attribute

type: BusType

Power-flow role: exactly one in-service slack per network.

vm_pu class-attribute instance-attribute

vm_pu: float | None

Voltage magnitude, per unit.

va_deg class-attribute instance-attribute

va_deg: float | None

Voltage angle, degrees.

v_min_pu class-attribute instance-attribute

v_min_pu: float | None

Lower voltage limit, per unit.

v_max_pu class-attribute instance-attribute

v_max_pu: float | None

Upper voltage limit, per unit.

area class-attribute instance-attribute

area: str | None

Free-form area label (MATPOWER AREA).

zone class-attribute instance-attribute

zone: str | None

Zone id; must resolve to zones[].id.

Generator

Bases: _Entity

Dispatchable injection. cost is model-present and solver-ignored until M3.

id class-attribute instance-attribute

id: str

Unique within generators.

bus class-attribute instance-attribute

bus: str

Bus id.

p_mw class-attribute instance-attribute

p_mw: float

Active power setpoint or dispatch, MW.

q_mvar class-attribute instance-attribute

q_mvar: float

Reactive power setpoint or dispatch, MVAr.

p_min_mw class-attribute instance-attribute

p_min_mw: float

Lower active limit, MW; must be <= p_max_mw.

p_max_mw class-attribute instance-attribute

p_max_mw: float

Upper active limit, MW.

q_min_mvar class-attribute instance-attribute

q_min_mvar: float

Lower reactive limit, MVAr; must be <= q_max_mvar.

q_max_mvar class-attribute instance-attribute

q_max_mvar: float

Upper reactive limit, MVAr.

v_set_pu class-attribute instance-attribute

v_set_pu: float

Voltage setpoint, per unit.

ramp_up_mw class-attribute instance-attribute

ramp_up_mw: float | None

Max increase from the previous period, MW; None = none.

ramp_down_mw class-attribute instance-attribute

ramp_down_mw: float | None

Max decrease from the previous period, MW; None = none.

Geo

Bases: _Entity

Geographic position of a bus in decimal degrees (WGS 84).

lat class-attribute instance-attribute

lat: float

Latitude, decimal degrees.

lon class-attribute instance-attribute

lon: float

Longitude, decimal degrees.

Load

Bases: _Entity

Demand at a bus, fixed or elastic. bid is read by market.nodal and market.multiperiod; opf.solve_dc_opf ignores it and serves p_mw in full.

id class-attribute instance-attribute

id: str

Unique within loads.

bus class-attribute instance-attribute

bus: str

Bus id.

p_mw class-attribute instance-attribute

p_mw: float

Active demand, MW. With a bid, this is the maximum quantity that can be served, and the bid decides where in [0, p_mw] the load actually clears; a Period override of it therefore moves that maximum too.

q_mvar class-attribute instance-attribute

q_mvar: float

Reactive demand, MVAr.

bid class-attribute instance-attribute

bid: LoadBid | None

Elastic-demand bid covering this Load's entire p_mw; market.nodal and market.multiperiod read it, opf.solve_dc_opf does not. There is no per-Load partial-capacity split -- bidding only part of a load's capacity, with the rest must-serve, means splitting it into two Load entities at the same bus (one bid=None fixed, one bid-carrying).

PiecewiseBid

Bases: _Entity

Demand-side mirror of PiecewiseCost: piecewise-linear (p_mw, value) breakpoints.

Only the same structural checks PiecewiseCost gets (at least two points, strictly increasing p_mw) apply here; the concavity direction of the value curve is checked at solve time, not here (see PolynomialBid).

points class-attribute instance-attribute

points: list[tuple[float, float]]

(p_mw, value) breakpoints, at least two and at most 200 (each adds one hypograph row to opf.dc_opf's LP); p_mw must be strictly increasing.

PiecewiseCost

Bases: _Entity

MATPOWER gencost MODEL 1: piecewise-linear (p_mw, cost) breakpoints, increasing in p.

points class-attribute instance-attribute

points: list[tuple[float, float]]

(p_mw, cost) breakpoints, at least two and at most 200 (each adds one epigraph row to opf.dc_opf's LP); p_mw must be strictly increasing.

startup class-attribute instance-attribute

startup: float = 0.0

Startup cost.

shutdown class-attribute instance-attribute

shutdown: float = 0.0

Shutdown cost.

PolynomialBid

Bases: _Entity

Demand-side mirror of PolynomialCost: value(p_mw) = sum(c_k * p_mw**k).

Marginal value must be non-increasing (concave value) for a valid market clearing; that convexity-direction check is opf.dc_opf's job at solve time (NonConcaveBidError), not construction-time here — mirrors how PolynomialCost also has no construction-time convexity check.

coefficients class-attribute instance-attribute

coefficients: list[float]

Polynomial coefficients (at least one), highest order first, value per hour.

PolynomialCost

Bases: _Entity

MATPOWER gencost MODEL 2: cost(p_mw) = sum(c_k * p_mw**k), highest order first.

coefficients class-attribute instance-attribute

coefficients: list[float]

Polynomial coefficients (at least one), highest order first, cost per hour.

startup class-attribute instance-attribute

startup: float = 0.0

Startup cost.

shutdown class-attribute instance-attribute

shutdown: float = 0.0

Shutdown cost.

Shunt

Bases: _Entity

Fixed shunt at 1.0 pu voltage, MATPOWER GS/BS sign convention.

id class-attribute instance-attribute

id: str

Unique within shunts.

bus class-attribute instance-attribute

bus: str

Bus id.

g_mw class-attribute instance-attribute

g_mw: float

Conductance as MW consumed at 1.0 pu; positive consumes.

b_mvar class-attribute instance-attribute

b_mvar: float

Susceptance as MVAr injected at 1.0 pu; positive injects.

Storage

Bases: _Entity

Energy storage. Read by market.multiperiod, which gives each unit a charge, a discharge and a state-of-charge column per period; every other solver ignores it.

id class-attribute instance-attribute

id: str

Unique within storage.

bus class-attribute instance-attribute

bus: str

Bus id.

p_max_mw class-attribute instance-attribute

p_max_mw: float

Charge/discharge power limit, MW.

energy_mwh class-attribute instance-attribute

energy_mwh: float

Energy capacity, MWh.

soc_initial class-attribute instance-attribute

soc_initial: float

Initial state of charge, fraction of energy_mwh in [0, 1].

efficiency_charge class-attribute instance-attribute

efficiency_charge: float

Charging efficiency in (0, 1].

efficiency_discharge class-attribute instance-attribute

efficiency_discharge: float

Discharging efficiency in (0, 1].

Zone

Bases: _Entity

Named grouping of buses (MATPOWER loss zone, market zone, ...).

id class-attribute instance-attribute

id: str

Unique within zones.

NetworkValidationError

NetworkValidationError(issues: Iterable[ValidationIssue])

Bases: Exception

Raised by Network when one or more invariants fail.

Subclasses Exception rather than ValueError on purpose: pydantic-core converts any ValueError raised inside a validator into its own ValidationError, which would hide .issues behind a generic message. A plain Exception propagates unchanged through Network(...), model_validate and model_validate_json.

Source code in src/mambo_power/model/errors.py
def __init__(self, issues: Iterable[ValidationIssue]) -> None:
    self.issues = list(issues)
    super().__init__(self._format(self.issues))

codes property

codes: set[str]

The distinct codes present, for quick membership checks.

ValidationIssue

Bases: BaseModel

One invariant violation: stable code, document path, readable message.

Network

Bases: BaseModel

A complete power network. Construction validates every invariant in one pass.

Any violation raises NetworkValidationError listing every issue found. Mutating a constructed network does not re-validate; call validate_network to re-check.

base_mva class-attribute instance-attribute

base_mva: float

System MVA base for all per-unit quantities. Must be > 0.

json_schema classmethod

json_schema() -> dict[str, Any]

The JSON schema of the native file format (snapshot-tested).

Source code in src/mambo_power/model/network.py
@classmethod
def json_schema(cls) -> dict[str, Any]:
    """The JSON schema of the native file format (snapshot-tested)."""
    return cls.model_json_schema()

Period

Bases: BaseModel

One period's load overrides within a multi-period Scenario.

load_p_mw is an id-keyed override of each Load's p_mw for this period, not a scale factor: a load id absent from the dict falls back unchanged to that Load's own p_mw. mambo_power.market.multiperiod.solve_multiperiod resolves it into that period's fixed load and, for a load carrying a bid, the upper bound of its elastic column — Load.p_mw means both, so an override of it moves both. Every key must resolve to a real Load id in the scenario's network, checked by Scenario rather than here, since a bare Period has no network to check against.

The value range is exactly Load.p_mw's, deliberately: an override may not be narrower than the field it overrides, or a horizon could not express a load the network itself is allowed to carry. Load.p_mw has no lower bound and case300 ships eight negative loads (a net injection at a load bus), so a >= 0 rule here would reject even the identity profile {ld.id: ld.p_mw for ld in case300.loads} — a horizon that changes nothing — on a network market.nodal clears without complaint. allow_inf_nan=False still holds: a non-finite override is meaningless in any period.

load_p_mw class-attribute instance-attribute

load_p_mw: dict[str, float]

Per-load active-power override for this period, MW, keyed by Load id. The same range as Load.p_mw, negatives included; must be finite.

Scenario

Bases: BaseModel

A market scenario to clear: the network to clear it against, and its periods, if any.

Embeds network: Network directly, mirroring jobs.models.SolveRequest's self-contained pattern rather than an id/path cross-reference — no such resolution mechanism exists anywhere else in this codebase. Network's own model_validator(mode="after") runs while Scenario is being constructed (it is a nested pydantic model field), so every invariant Network already checks — including dangling references — is checked here too, with no separate pass needed.

periods: list[Period] | None = NoneNone means single-period: market.nodal's existing behaviour is unaffected (AC-4, wave M5). No agent-strategy fields this wave: their eventual shape is genuinely undesigned by M7, unlike Storage's successful M1 stub, which had a full spec before it shipped (design interview 2026-08-24, ratified; wave spec Design item 3).

network class-attribute instance-attribute

network: Network

The network to clear; the scenario is self-contained.

periods class-attribute instance-attribute

periods: list[Period] | None

Per-period load overrides; None = single-period, market.nodal semantics unchanged. If given, must be non-empty and at most MAX_PERIODS (200) entries -- a wire-format decompression-bomb guard: constraint-matrix nonzeros scale with horizon length, and M5 measured a 33,997-byte request expanding to 20,088,000 of them.

ImportIssue

Bases: BaseModel

One repair an importer performed: stable code, message, and the ids it touched.

bus_ids class-attribute instance-attribute

bus_ids: list[str]

Buses involved, if any.

element_ids class-attribute instance-attribute

element_ids: list[str]

Non-bus elements involved (branches, generators, loads, shunts, storage).

repair_islands

repair_islands(
    net: Network,
) -> tuple[Network, list[ImportIssue]]

Return a new, validated Network with every island switched off, plus warnings.

net is not modified. On an already-connected network the result is an equal copy and the warning list is empty.

Source code in src/mambo_power/model/islands.py
def repair_islands(net: Network) -> tuple[Network, list[ImportIssue]]:
    """Return a new, validated :class:`Network` with every island switched off, plus warnings.

    ``net`` is not modified. On an already-connected network the result is an equal copy and
    the warning list is empty.
    """
    buses, branches, generators, loads, shunts, storage, warnings = repair_islands_entities(
        net.buses, net.branches, net.generators, net.loads, net.shunts, net.storage
    )
    repaired = Network(
        schema_version=net.schema_version,
        base_mva=net.base_mva,
        buses=buses,
        branches=branches,
        generators=generators,
        loads=loads,
        shunts=shunts,
        storage=storage,
        zones=list(net.zones),
    )
    return repaired, warnings

repair_islands_entities

repair_islands_entities(
    buses: Sequence[Bus],
    branches: Sequence[Branch],
    generators: Sequence[Generator],
    loads: Sequence[Load],
    shunts: Sequence[Shunt],
    storage: Sequence[Storage],
) -> RepairedEntities

Deactivate island buses and their elements on raw entity lists; inputs are not mutated.

Returns new lists (copies only where an element changed) and one warning per island. Works before validation, so it accepts entity lists that Network would reject with DISCONNECTED_BUS.

Source code in src/mambo_power/model/islands.py
def repair_islands_entities(
    buses: Sequence[Bus],
    branches: Sequence[Branch],
    generators: Sequence[Generator],
    loads: Sequence[Load],
    shunts: Sequence[Shunt],
    storage: Sequence[Storage],
) -> RepairedEntities:
    """Deactivate island buses and their elements on raw entity lists; inputs are not mutated.

    Returns new lists (copies only where an element changed) and one warning per island.
    Works before validation, so it accepts entity lists that :class:`Network` would reject
    with ``DISCONNECTED_BUS``.
    """
    live = {bus.id for bus in buses if bus.in_service}
    sources = [bus.id for bus in buses if bus.in_service and bus.type == "slack"]
    if not sources:
        return (
            list(buses),
            list(branches),
            list(generators),
            list(loads),
            list(shunts),
            list(storage),
            [],
        )

    adjacency: dict[str, list[str]] = {bus_id: [] for bus_id in live}
    for branch in branches:
        if branch.in_service and branch.from_bus in live and branch.to_bus in live:
            adjacency[branch.from_bus].append(branch.to_bus)
            adjacency[branch.to_bus].append(branch.from_bus)

    reached = _bfs(sources, adjacency)
    island_buses = {bus_id for bus_id in live if bus_id not in reached}
    if not island_buses:
        return (
            list(buses),
            list(branches),
            list(generators),
            list(loads),
            list(shunts),
            list(storage),
            [],
        )

    new_buses = [
        bus.model_copy(update={"in_service": False}) if bus.id in island_buses else bus
        for bus in buses
    ]
    new_branches = [
        br.model_copy(update={"in_service": False})
        if br.in_service and (br.from_bus in island_buses or br.to_bus in island_buses)
        else br
        for br in branches
    ]
    new_generators = _deactivate_at(generators, island_buses)
    new_loads = _deactivate_at(loads, island_buses)
    new_shunts = _deactivate_at(shunts, island_buses)
    new_storage = _deactivate_at(storage, island_buses)

    warnings: list[ImportIssue] = []
    for component in _components(island_buses, adjacency, buses):
        members = set(component)
        element_ids: list[str] = []
        element_ids.extend(
            br.id
            for br in branches
            if br.in_service and (br.from_bus in members or br.to_bus in members)
        )
        for elements in (generators, loads, shunts, storage):
            element_ids.extend(el.id for el in elements if el.in_service and el.bus in members)
        noun = "bus" if len(component) == 1 else "buses"
        message = (
            f"{noun} {', '.join(component)} cannot reach slack bus {', '.join(sources)} over "
            f"in-service branches; deactivated with attached elements "
            f"[{', '.join(element_ids)}]"
        )
        warnings.append(
            ImportIssue(
                code="ISLAND_DEACTIVATED",
                message=message,
                bus_ids=list(component),
                element_ids=element_ids,
            )
        )

    return new_buses, new_branches, new_generators, new_loads, new_shunts, new_storage, warnings

validate_network

validate_network(net: Network) -> list[ValidationIssue]

Run every cross-entity invariant and return all issues found (empty = valid).

Source code in src/mambo_power/model/network.py
def validate_network(net: Network) -> list[ValidationIssue]:
    """Run every cross-entity invariant and return all issues found (empty = valid)."""
    issues: list[ValidationIssue] = []

    def add(code: ValidationCode, path: str, message: str) -> None:
        issues.append(ValidationIssue(code=code, path=path, message=message))

    collections: list[tuple[str, Sequence[_HasId]]] = [
        ("buses", net.buses),
        ("branches", net.branches),
        ("generators", net.generators),
        ("loads", net.loads),
        ("shunts", net.shunts),
        ("storage", net.storage),
        ("zones", net.zones),
    ]
    for name, items in collections:
        seen: set[str] = set()
        for index, item in enumerate(items):
            if item.id in seen:
                add("DUPLICATE_ID", f"{name}[{index}].id", f'duplicate id "{item.id}" in {name}')
            seen.add(item.id)

    if not net.base_mva > 0:
        add("BAD_BASE", "base_mva", f"base_mva must be > 0, got {net.base_mva}")
    for index, bus in enumerate(net.buses):
        if not bus.base_kv > 0:
            add(
                "BAD_BASE",
                f"buses[{index}].base_kv",
                f'bus "{bus.id}": base_kv must be > 0, got {bus.base_kv}',
            )

    bus_ids = {bus.id for bus in net.buses}
    zone_ids = {zone.id for zone in net.zones}
    for index, bus in enumerate(net.buses):
        if bus.zone is not None and bus.zone not in zone_ids:
            add(
                "DANGLING_REF",
                f"buses[{index}].zone",
                f'bus "{bus.id}": zone references missing zone "{bus.zone}"',
            )
    for index, branch in enumerate(net.branches):
        for field in ("from_bus", "to_bus"):
            ref: str = getattr(branch, field)
            if ref not in bus_ids:
                add(
                    "DANGLING_REF",
                    f"branches[{index}].{field}",
                    f'branch "{branch.id}": {field} references missing bus "{ref}"',
                )
    at_bus: list[tuple[str, Sequence[_AtBus]]] = [
        ("generators", net.generators),
        ("loads", net.loads),
        ("shunts", net.shunts),
        ("storage", net.storage),
    ]
    for name, elements in at_bus:
        for index, element in enumerate(elements):
            if element.bus not in bus_ids:
                add(
                    "DANGLING_REF",
                    f"{name}[{index}].bus",
                    f'{name} "{element.id}": bus references missing bus "{element.bus}"',
                )

    for index, bus in enumerate(net.buses):
        if bus.v_min_pu is not None and bus.v_max_pu is not None and bus.v_min_pu > bus.v_max_pu:
            add(
                "BAD_RANGE",
                f"buses[{index}].v_min_pu",
                f'bus "{bus.id}": v_min_pu {bus.v_min_pu} > v_max_pu {bus.v_max_pu}',
            )
    for index, branch in enumerate(net.branches):
        if branch.from_bus == branch.to_bus:
            add(
                "BAD_RANGE",
                f"branches[{index}].to_bus",
                f'branch "{branch.id}": from_bus and to_bus are both "{branch.from_bus}"',
            )
        if branch.tap_ratio is not None and not branch.tap_ratio > 0:
            add(
                "BAD_RANGE",
                f"branches[{index}].tap_ratio",
                f'branch "{branch.id}": tap_ratio must be > 0, got {branch.tap_ratio}',
            )
        if branch.r == 0 and branch.x == 0:
            add(
                "BAD_RANGE",
                f"branches[{index}].x",
                f'branch "{branch.id}": r and x are both 0 (no series impedance)',
            )
        if branch.rating_mva is not None and not branch.rating_mva > 0:
            add(
                "BAD_RANGE",
                f"branches[{index}].rating_mva",
                f'branch "{branch.id}": rating_mva must be > 0 when given, got {branch.rating_mva}',
            )
    for index, gen in enumerate(net.generators):
        if gen.p_min_mw > gen.p_max_mw:
            add(
                "BAD_RANGE",
                f"generators[{index}].p_min_mw",
                f'generator "{gen.id}": p_min_mw {gen.p_min_mw} > p_max_mw {gen.p_max_mw}',
            )
        if gen.q_min_mvar > gen.q_max_mvar:
            add(
                "BAD_RANGE",
                f"generators[{index}].q_min_mvar",
                f'generator "{gen.id}": q_min_mvar {gen.q_min_mvar} > q_max_mvar {gen.q_max_mvar}',
            )
        for field in ("ramp_up_mw", "ramp_down_mw"):
            ramp: float | None = getattr(gen, field)
            if ramp is not None and not ramp > 0:
                add(
                    "BAD_RANGE",
                    f"generators[{index}].{field}",
                    f'generator "{gen.id}": {field} must be > 0 when given, got {ramp}',
                )
        if gen.cost is not None and gen.cost.kind == "polynomial" and not gen.cost.coefficients:
            add(
                "BAD_RANGE",
                f"generators[{index}].cost.coefficients",
                f'generator "{gen.id}": polynomial cost needs at least one coefficient',
            )
        if gen.cost is not None and gen.cost.kind == "piecewise":
            p_values = [p for p, _ in gen.cost.points]
            if len(p_values) < 2:
                add(
                    "BAD_RANGE",
                    f"generators[{index}].cost.points",
                    f'generator "{gen.id}": piecewise cost needs at least two points',
                )
            elif any(
                later <= earlier for earlier, later in zip(p_values, p_values[1:], strict=False)
            ):
                add(
                    "BAD_RANGE",
                    f"generators[{index}].cost.points",
                    f'generator "{gen.id}": piecewise cost p_mw values must be strictly increasing',
                )
    for index, load in enumerate(net.loads):
        if load.bid is not None and load.bid.kind == "polynomial" and not load.bid.coefficients:
            add(
                "BAD_RANGE",
                f"loads[{index}].bid.coefficients",
                f'load "{load.id}": polynomial bid needs at least one coefficient',
            )
        if load.bid is not None and load.bid.kind == "piecewise":
            p_values = [p for p, _ in load.bid.points]
            if len(p_values) < 2:
                add(
                    "BAD_RANGE",
                    f"loads[{index}].bid.points",
                    f'load "{load.id}": piecewise bid needs at least two points',
                )
            elif any(
                later <= earlier for earlier, later in zip(p_values, p_values[1:], strict=False)
            ):
                add(
                    "BAD_RANGE",
                    f"loads[{index}].bid.points",
                    f'load "{load.id}": piecewise bid p_mw values must be strictly increasing',
                )
    for index, unit in enumerate(net.storage):
        if not 0.0 <= unit.soc_initial <= 1.0:
            add(
                "BAD_RANGE",
                f"storage[{index}].soc_initial",
                f'storage "{unit.id}": soc_initial must be in [0, 1], got {unit.soc_initial}',
            )
        for field in ("efficiency_charge", "efficiency_discharge"):
            value: float = getattr(unit, field)
            if not 0.0 < value <= 1.0:
                add(
                    "BAD_RANGE",
                    f"storage[{index}].{field}",
                    f'storage "{unit.id}": {field} must be in (0, 1], got {value}',
                )
        # Sizing, checked here for the same reason ramp_up_mw/ramp_down_mw are: a solver reads
        # these now. Zero is the dangerous half -- an unsized unit clears "Optimal" with every
        # storage row trivially satisfied and the unit silently inert, a confidently wrong-shaped
        # answer; a negative one merely reaches HiGHS as an empty [0, negative] bound and comes
        # back "Infeasible" with nothing naming the cause. Both are caught here instead.
        for field in ("p_max_mw", "energy_mwh"):
            size: float = getattr(unit, field)
            if not size > 0:
                add(
                    "BAD_RANGE",
                    f"storage[{index}].{field}",
                    f'storage "{unit.id}": {field} must be > 0, got {size}',
                )

    slack_buses = [bus for bus in net.buses if bus.type == "slack" and bus.in_service]
    if not slack_buses:
        add("NO_SLACK", "buses", "no in-service slack bus defined")
    elif len(slack_buses) > 1:
        listed = ", ".join(f'"{bus.id}"' for bus in slack_buses)
        add(
            "MULTIPLE_SLACK",
            "buses",
            f"expected exactly one in-service slack bus, found {len(slack_buses)}: {listed}",
        )

    _check_connectivity(net, slack_buses, add)
    return issues

Entities

mambo_power.model.entities

Network entities — the native file format IS these models.

Units are physical (wave M1 design item 1): MW, MVAr, kV, MWh, degrees; branch r/x/b are per-unit on the network's base_mva. Per-unit conversion lives in numerics, never here. Field names are snake_case with a unit suffix (design item 2). Every model rejects unknown fields so a typo in a file is an error, not silently dropped data.

Cross-entity invariants (slack count, connectivity, references, ranges) are checked by mambo_power.model.Network, not by the entities themselves, so that one validation pass can report every problem at once.

BusType module-attribute

BusType = Literal['slack', 'pv', 'pq']

Bus role in power flow. MATPOWER type 4 (isolated) maps to in_service=False instead.

BranchKind module-attribute

BranchKind = Literal['line', 'transformer']

What a Branch is. Exporters route on it (a pandapower trafo row, a PyPSA Transformer, a RAW transformer record); the solvers do not read it.

Geo

Bases: _Entity

Geographic position of a bus in decimal degrees (WGS 84).

lat class-attribute instance-attribute

lat: float

Latitude, decimal degrees.

lon class-attribute instance-attribute

lon: float

Longitude, decimal degrees.

Bus

Bases: _Entity

Electrical node. vm_pu/va_deg hold an initial or last-solved state, if any.

id class-attribute instance-attribute

id: str

Unique within buses.

base_kv class-attribute instance-attribute

base_kv: float

Nominal voltage, kV. Must be > 0.

type class-attribute instance-attribute

type: BusType

Power-flow role: exactly one in-service slack per network.

vm_pu class-attribute instance-attribute

vm_pu: float | None

Voltage magnitude, per unit.

va_deg class-attribute instance-attribute

va_deg: float | None

Voltage angle, degrees.

v_min_pu class-attribute instance-attribute

v_min_pu: float | None

Lower voltage limit, per unit.

v_max_pu class-attribute instance-attribute

v_max_pu: float | None

Upper voltage limit, per unit.

area class-attribute instance-attribute

area: str | None

Free-form area label (MATPOWER AREA).

zone class-attribute instance-attribute

zone: str | None

Zone id; must resolve to zones[].id.

Branch

Bases: _Entity

Line or transformer between two buses. Tap is on the from side (MATPOWER model).

id class-attribute instance-attribute

id: str

Unique within branches.

from_bus class-attribute instance-attribute

from_bus: str

Bus id of the from (tap) side.

to_bus class-attribute instance-attribute

to_bus: str

Bus id of the to side.

r class-attribute instance-attribute

r: float

Series resistance, per unit on base_mva.

x class-attribute instance-attribute

x: float

Series reactance, per unit on base_mva.

b class-attribute instance-attribute

b: float

Total line charging susceptance, per unit on base_mva.

rating_mva class-attribute instance-attribute

rating_mva: float | None

Thermal rating, MVA; None = none.

tap_ratio class-attribute instance-attribute

tap_ratio: float | None

Off-nominal tap; None = 1.0.

shift_deg class-attribute instance-attribute

shift_deg: float | None

Phase shift, degrees; None = 0.

kind class-attribute instance-attribute

kind: BranchKind = 'line'

'line' or 'transformer'. Defaults to 'transformer' when tap_ratio is not None/1.0 or shift_deg is not None/0.0, else 'line'; an explicit 'transformer' at nominal tap is kept; an explicit 'line' with a tap or shift is promoted to 'transformer' at validation. Assignment after construction does not re-run this rule: exporters route on is_transformer, which reads the fields.

is_transformer property

is_transformer: bool

What exporters route on: kind == "transformer" or an off-nominal tap/shift.

kind is derived at validation and entities are mutable, so br.tap_ratio = 1.05 on a line leaves kind == "line" in memory until the next validation. Reading the fields here means an exporter never drops such a tap, and every serialisation (model_dump, the native and CSV dumps) writes this answer as kind so a file never says line beside a tap (M8 critic nit 24).

PolynomialCost

Bases: _Entity

MATPOWER gencost MODEL 2: cost(p_mw) = sum(c_k * p_mw**k), highest order first.

coefficients class-attribute instance-attribute

coefficients: list[float]

Polynomial coefficients (at least one), highest order first, cost per hour.

startup class-attribute instance-attribute

startup: float = 0.0

Startup cost.

shutdown class-attribute instance-attribute

shutdown: float = 0.0

Shutdown cost.

PiecewiseCost

Bases: _Entity

MATPOWER gencost MODEL 1: piecewise-linear (p_mw, cost) breakpoints, increasing in p.

points class-attribute instance-attribute

points: list[tuple[float, float]]

(p_mw, cost) breakpoints, at least two and at most 200 (each adds one epigraph row to opf.dc_opf's LP); p_mw must be strictly increasing.

startup class-attribute instance-attribute

startup: float = 0.0

Startup cost.

shutdown class-attribute instance-attribute

shutdown: float = 0.0

Shutdown cost.

PolynomialBid

Bases: _Entity

Demand-side mirror of PolynomialCost: value(p_mw) = sum(c_k * p_mw**k).

Marginal value must be non-increasing (concave value) for a valid market clearing; that convexity-direction check is opf.dc_opf's job at solve time (NonConcaveBidError), not construction-time here — mirrors how PolynomialCost also has no construction-time convexity check.

coefficients class-attribute instance-attribute

coefficients: list[float]

Polynomial coefficients (at least one), highest order first, value per hour.

PiecewiseBid

Bases: _Entity

Demand-side mirror of PiecewiseCost: piecewise-linear (p_mw, value) breakpoints.

Only the same structural checks PiecewiseCost gets (at least two points, strictly increasing p_mw) apply here; the concavity direction of the value curve is checked at solve time, not here (see PolynomialBid).

points class-attribute instance-attribute

points: list[tuple[float, float]]

(p_mw, value) breakpoints, at least two and at most 200 (each adds one hypograph row to opf.dc_opf's LP); p_mw must be strictly increasing.

Generator

Bases: _Entity

Dispatchable injection. cost is model-present and solver-ignored until M3.

id class-attribute instance-attribute

id: str

Unique within generators.

bus class-attribute instance-attribute

bus: str

Bus id.

p_mw class-attribute instance-attribute

p_mw: float

Active power setpoint or dispatch, MW.

q_mvar class-attribute instance-attribute

q_mvar: float

Reactive power setpoint or dispatch, MVAr.

p_min_mw class-attribute instance-attribute

p_min_mw: float

Lower active limit, MW; must be <= p_max_mw.

p_max_mw class-attribute instance-attribute

p_max_mw: float

Upper active limit, MW.

q_min_mvar class-attribute instance-attribute

q_min_mvar: float

Lower reactive limit, MVAr; must be <= q_max_mvar.

q_max_mvar class-attribute instance-attribute

q_max_mvar: float

Upper reactive limit, MVAr.

v_set_pu class-attribute instance-attribute

v_set_pu: float

Voltage setpoint, per unit.

ramp_up_mw class-attribute instance-attribute

ramp_up_mw: float | None

Max increase from the previous period, MW; None = none.

ramp_down_mw class-attribute instance-attribute

ramp_down_mw: float | None

Max decrease from the previous period, MW; None = none.

Load

Bases: _Entity

Demand at a bus, fixed or elastic. bid is read by market.nodal and market.multiperiod; opf.solve_dc_opf ignores it and serves p_mw in full.

id class-attribute instance-attribute

id: str

Unique within loads.

bus class-attribute instance-attribute

bus: str

Bus id.

p_mw class-attribute instance-attribute

p_mw: float

Active demand, MW. With a bid, this is the maximum quantity that can be served, and the bid decides where in [0, p_mw] the load actually clears; a Period override of it therefore moves that maximum too.

q_mvar class-attribute instance-attribute

q_mvar: float

Reactive demand, MVAr.

bid class-attribute instance-attribute

bid: LoadBid | None

Elastic-demand bid covering this Load's entire p_mw; market.nodal and market.multiperiod read it, opf.solve_dc_opf does not. There is no per-Load partial-capacity split -- bidding only part of a load's capacity, with the rest must-serve, means splitting it into two Load entities at the same bus (one bid=None fixed, one bid-carrying).

Shunt

Bases: _Entity

Fixed shunt at 1.0 pu voltage, MATPOWER GS/BS sign convention.

id class-attribute instance-attribute

id: str

Unique within shunts.

bus class-attribute instance-attribute

bus: str

Bus id.

g_mw class-attribute instance-attribute

g_mw: float

Conductance as MW consumed at 1.0 pu; positive consumes.

b_mvar class-attribute instance-attribute

b_mvar: float

Susceptance as MVAr injected at 1.0 pu; positive injects.

Storage

Bases: _Entity

Energy storage. Read by market.multiperiod, which gives each unit a charge, a discharge and a state-of-charge column per period; every other solver ignores it.

id class-attribute instance-attribute

id: str

Unique within storage.

bus class-attribute instance-attribute

bus: str

Bus id.

p_max_mw class-attribute instance-attribute

p_max_mw: float

Charge/discharge power limit, MW.

energy_mwh class-attribute instance-attribute

energy_mwh: float

Energy capacity, MWh.

soc_initial class-attribute instance-attribute

soc_initial: float

Initial state of charge, fraction of energy_mwh in [0, 1].

efficiency_charge class-attribute instance-attribute

efficiency_charge: float

Charging efficiency in (0, 1].

efficiency_discharge class-attribute instance-attribute

efficiency_discharge: float

Discharging efficiency in (0, 1].

Zone

Bases: _Entity

Named grouping of buses (MATPOWER loss zone, market zone, ...).

id class-attribute instance-attribute

id: str

Unique within zones.

Validation errors

mambo_power.model.errors

Named validation errors for mambo_power.model.Network.

Every invariant violation is reported as a ValidationIssue with a stable code, a path into the network document (buses[3].base_kv) and a human-readable message. NetworkValidationError carries all issues found in one pass, never just the first.

ValidationCode module-attribute

ValidationCode = Literal[
    "NO_SLACK",
    "MULTIPLE_SLACK",
    "DISCONNECTED_BUS",
    "DUPLICATE_ID",
    "DANGLING_REF",
    "BAD_BASE",
    "BAD_RANGE",
]

The closed set of network-level validation codes (wave M1 design item 5).

ValidationIssue

Bases: BaseModel

One invariant violation: stable code, document path, readable message.

NetworkValidationError

NetworkValidationError(issues: Iterable[ValidationIssue])

Bases: Exception

Raised by Network when one or more invariants fail.

Subclasses Exception rather than ValueError on purpose: pydantic-core converts any ValueError raised inside a validator into its own ValidationError, which would hide .issues behind a generic message. A plain Exception propagates unchanged through Network(...), model_validate and model_validate_json.

Source code in src/mambo_power/model/errors.py
def __init__(self, issues: Iterable[ValidationIssue]) -> None:
    self.issues = list(issues)
    super().__init__(self._format(self.issues))

codes property

codes: set[str]

The distinct codes present, for quick membership checks.

Network and invariants

mambo_power.model.network

The Network root model and its cross-entity invariants.

Network

Bases: BaseModel

A complete power network. Construction validates every invariant in one pass.

Any violation raises NetworkValidationError listing every issue found. Mutating a constructed network does not re-validate; call validate_network to re-check.

base_mva class-attribute instance-attribute

base_mva: float

System MVA base for all per-unit quantities. Must be > 0.

json_schema classmethod

json_schema() -> dict[str, Any]

The JSON schema of the native file format (snapshot-tested).

Source code in src/mambo_power/model/network.py
@classmethod
def json_schema(cls) -> dict[str, Any]:
    """The JSON schema of the native file format (snapshot-tested)."""
    return cls.model_json_schema()

validate_network

validate_network(net: Network) -> list[ValidationIssue]

Run every cross-entity invariant and return all issues found (empty = valid).

Source code in src/mambo_power/model/network.py
def validate_network(net: Network) -> list[ValidationIssue]:
    """Run every cross-entity invariant and return all issues found (empty = valid)."""
    issues: list[ValidationIssue] = []

    def add(code: ValidationCode, path: str, message: str) -> None:
        issues.append(ValidationIssue(code=code, path=path, message=message))

    collections: list[tuple[str, Sequence[_HasId]]] = [
        ("buses", net.buses),
        ("branches", net.branches),
        ("generators", net.generators),
        ("loads", net.loads),
        ("shunts", net.shunts),
        ("storage", net.storage),
        ("zones", net.zones),
    ]
    for name, items in collections:
        seen: set[str] = set()
        for index, item in enumerate(items):
            if item.id in seen:
                add("DUPLICATE_ID", f"{name}[{index}].id", f'duplicate id "{item.id}" in {name}')
            seen.add(item.id)

    if not net.base_mva > 0:
        add("BAD_BASE", "base_mva", f"base_mva must be > 0, got {net.base_mva}")
    for index, bus in enumerate(net.buses):
        if not bus.base_kv > 0:
            add(
                "BAD_BASE",
                f"buses[{index}].base_kv",
                f'bus "{bus.id}": base_kv must be > 0, got {bus.base_kv}',
            )

    bus_ids = {bus.id for bus in net.buses}
    zone_ids = {zone.id for zone in net.zones}
    for index, bus in enumerate(net.buses):
        if bus.zone is not None and bus.zone not in zone_ids:
            add(
                "DANGLING_REF",
                f"buses[{index}].zone",
                f'bus "{bus.id}": zone references missing zone "{bus.zone}"',
            )
    for index, branch in enumerate(net.branches):
        for field in ("from_bus", "to_bus"):
            ref: str = getattr(branch, field)
            if ref not in bus_ids:
                add(
                    "DANGLING_REF",
                    f"branches[{index}].{field}",
                    f'branch "{branch.id}": {field} references missing bus "{ref}"',
                )
    at_bus: list[tuple[str, Sequence[_AtBus]]] = [
        ("generators", net.generators),
        ("loads", net.loads),
        ("shunts", net.shunts),
        ("storage", net.storage),
    ]
    for name, elements in at_bus:
        for index, element in enumerate(elements):
            if element.bus not in bus_ids:
                add(
                    "DANGLING_REF",
                    f"{name}[{index}].bus",
                    f'{name} "{element.id}": bus references missing bus "{element.bus}"',
                )

    for index, bus in enumerate(net.buses):
        if bus.v_min_pu is not None and bus.v_max_pu is not None and bus.v_min_pu > bus.v_max_pu:
            add(
                "BAD_RANGE",
                f"buses[{index}].v_min_pu",
                f'bus "{bus.id}": v_min_pu {bus.v_min_pu} > v_max_pu {bus.v_max_pu}',
            )
    for index, branch in enumerate(net.branches):
        if branch.from_bus == branch.to_bus:
            add(
                "BAD_RANGE",
                f"branches[{index}].to_bus",
                f'branch "{branch.id}": from_bus and to_bus are both "{branch.from_bus}"',
            )
        if branch.tap_ratio is not None and not branch.tap_ratio > 0:
            add(
                "BAD_RANGE",
                f"branches[{index}].tap_ratio",
                f'branch "{branch.id}": tap_ratio must be > 0, got {branch.tap_ratio}',
            )
        if branch.r == 0 and branch.x == 0:
            add(
                "BAD_RANGE",
                f"branches[{index}].x",
                f'branch "{branch.id}": r and x are both 0 (no series impedance)',
            )
        if branch.rating_mva is not None and not branch.rating_mva > 0:
            add(
                "BAD_RANGE",
                f"branches[{index}].rating_mva",
                f'branch "{branch.id}": rating_mva must be > 0 when given, got {branch.rating_mva}',
            )
    for index, gen in enumerate(net.generators):
        if gen.p_min_mw > gen.p_max_mw:
            add(
                "BAD_RANGE",
                f"generators[{index}].p_min_mw",
                f'generator "{gen.id}": p_min_mw {gen.p_min_mw} > p_max_mw {gen.p_max_mw}',
            )
        if gen.q_min_mvar > gen.q_max_mvar:
            add(
                "BAD_RANGE",
                f"generators[{index}].q_min_mvar",
                f'generator "{gen.id}": q_min_mvar {gen.q_min_mvar} > q_max_mvar {gen.q_max_mvar}',
            )
        for field in ("ramp_up_mw", "ramp_down_mw"):
            ramp: float | None = getattr(gen, field)
            if ramp is not None and not ramp > 0:
                add(
                    "BAD_RANGE",
                    f"generators[{index}].{field}",
                    f'generator "{gen.id}": {field} must be > 0 when given, got {ramp}',
                )
        if gen.cost is not None and gen.cost.kind == "polynomial" and not gen.cost.coefficients:
            add(
                "BAD_RANGE",
                f"generators[{index}].cost.coefficients",
                f'generator "{gen.id}": polynomial cost needs at least one coefficient',
            )
        if gen.cost is not None and gen.cost.kind == "piecewise":
            p_values = [p for p, _ in gen.cost.points]
            if len(p_values) < 2:
                add(
                    "BAD_RANGE",
                    f"generators[{index}].cost.points",
                    f'generator "{gen.id}": piecewise cost needs at least two points',
                )
            elif any(
                later <= earlier for earlier, later in zip(p_values, p_values[1:], strict=False)
            ):
                add(
                    "BAD_RANGE",
                    f"generators[{index}].cost.points",
                    f'generator "{gen.id}": piecewise cost p_mw values must be strictly increasing',
                )
    for index, load in enumerate(net.loads):
        if load.bid is not None and load.bid.kind == "polynomial" and not load.bid.coefficients:
            add(
                "BAD_RANGE",
                f"loads[{index}].bid.coefficients",
                f'load "{load.id}": polynomial bid needs at least one coefficient',
            )
        if load.bid is not None and load.bid.kind == "piecewise":
            p_values = [p for p, _ in load.bid.points]
            if len(p_values) < 2:
                add(
                    "BAD_RANGE",
                    f"loads[{index}].bid.points",
                    f'load "{load.id}": piecewise bid needs at least two points',
                )
            elif any(
                later <= earlier for earlier, later in zip(p_values, p_values[1:], strict=False)
            ):
                add(
                    "BAD_RANGE",
                    f"loads[{index}].bid.points",
                    f'load "{load.id}": piecewise bid p_mw values must be strictly increasing',
                )
    for index, unit in enumerate(net.storage):
        if not 0.0 <= unit.soc_initial <= 1.0:
            add(
                "BAD_RANGE",
                f"storage[{index}].soc_initial",
                f'storage "{unit.id}": soc_initial must be in [0, 1], got {unit.soc_initial}',
            )
        for field in ("efficiency_charge", "efficiency_discharge"):
            value: float = getattr(unit, field)
            if not 0.0 < value <= 1.0:
                add(
                    "BAD_RANGE",
                    f"storage[{index}].{field}",
                    f'storage "{unit.id}": {field} must be in (0, 1], got {value}',
                )
        # Sizing, checked here for the same reason ramp_up_mw/ramp_down_mw are: a solver reads
        # these now. Zero is the dangerous half -- an unsized unit clears "Optimal" with every
        # storage row trivially satisfied and the unit silently inert, a confidently wrong-shaped
        # answer; a negative one merely reaches HiGHS as an empty [0, negative] bound and comes
        # back "Infeasible" with nothing naming the cause. Both are caught here instead.
        for field in ("p_max_mw", "energy_mwh"):
            size: float = getattr(unit, field)
            if not size > 0:
                add(
                    "BAD_RANGE",
                    f"storage[{index}].{field}",
                    f'storage "{unit.id}": {field} must be > 0, got {size}',
                )

    slack_buses = [bus for bus in net.buses if bus.type == "slack" and bus.in_service]
    if not slack_buses:
        add("NO_SLACK", "buses", "no in-service slack bus defined")
    elif len(slack_buses) > 1:
        listed = ", ".join(f'"{bus.id}"' for bus in slack_buses)
        add(
            "MULTIPLE_SLACK",
            "buses",
            f"expected exactly one in-service slack bus, found {len(slack_buses)}: {listed}",
        )

    _check_connectivity(net, slack_buses, add)
    return issues