Skip to content

Network model

mambo_power.model defines the Network root and its entities as pydantic v2 models. The JSON form of these models is the native file format; the JSON schema is generated from them and snapshot-tested. This page is the class-by-class reference: fields, units, optionality and the invariants that validation enforces. The API reference lists the same classes with their signatures.

Conventions that apply everywhere

Convention Rule
Units Physical: MW, MVAr, MVA, kV, MWh, degrees. Branch r, x, b are per-unit on Network.base_mva. Voltages *_pu are per-unit of the bus base_kv. Per-unit conversion of powers happens only in NetworkArrays.
Field names snake_case with a unit suffix: p_mw, q_mvar, base_kv, v_set_pu, shift_deg, energy_mwh.
Ids Stable strings, unique within their collection. References (Branch.from_bus, Generator.bus, Bus.zone) are ids, never indices.
in_service A boolean on every element; defaults to True. Out-of-service elements stay in the file and are dropped from the numerics.
Unknown fields Rejected (extra="forbid"): a typo in a file is an error, not silently dropped data.
Non-finite numbers Rejected (allow_inf_nan=False). A quantity that does not exist is None.
Optional fields Default to None and are omitted from native JSON on write.

Network

The root document. Construction — Network(...), Network.model_validate(...), Network.model_validate_json(...) — runs every cross-entity invariant in one pass and raises NetworkValidationError listing every issue found.

Field Type Default Meaning
schema_version Literal[1] 1 Format version stamp; bumps only on a breaking schema change.
base_mva float required System MVA base for every per-unit quantity. Must be > 0 (BAD_BASE).
buses list[Bus] [] At least one in-service slack is required (NO_SLACK).
branches list[Branch] [] Lines and transformers.
generators list[Generator] [] Dispatchable injections.
loads list[Load] [] Fixed demand.
shunts list[Shunt] [] Fixed shunt admittances.
storage list[Storage] [] Energy storage; read by market.multiperiod since wave M5.
zones list[Zone] [] Named bus groupings.

Methods:

  • Network.json_schema() -> dict — the JSON schema of the native format (see JSON schema).
  • The usual pydantic surface: model_dump(), model_dump_json(), model_validate(), model_validate_json(), model_copy().

Bus

An electrical node. vm_pu / va_deg hold an initial or last-solved state when present (MATPOWER's VM/VA columns import here); solvers use them as a warm start when every in-service bus carries both.

Field Type Unit Default Notes
id str required Unique within buses.
base_kv float kV required Nominal voltage; must be > 0 (BAD_BASE).
type "slack" \| "pv" \| "pq" required Declared power-flow role. Exactly one in-service slack per network.
in_service bool True MATPOWER type 4 (isolated) imports as in_service=False.
vm_pu float \| None pu None Voltage magnitude (initial or solved).
va_deg float \| None degrees None Voltage angle (initial or solved).
v_min_pu float \| None pu None Lower voltage limit; must be ≤ v_max_pu when both given (BAD_RANGE).
v_max_pu float \| None pu None Upper voltage limit.
area str \| None None Free-form area label (MATPOWER AREA).
zone str \| None None Must resolve to a Zone.id (DANGLING_REF). Read by market.solve_zonal.
geo Geo \| None None Position.

BusType is the type alias Literal["slack", "pv", "pq"]. The declared type is not always the role a solver uses: a PV bus without an in-service generator is solved as PQ (see Power flow › Effective bus roles).

Geo

Field Type Unit Notes
lat float decimal degrees WGS 84 latitude.
lon float decimal degrees WGS 84 longitude.

Branch

A line or a transformer between two buses. The tap is on the from side (MATPOWER's π-model with the off-nominal tap at the from end).

Field Type Unit Default Notes
id str required Unique within branches.
from_bus str required Bus id of the from (tap) side; must exist (DANGLING_REF).
to_bus str required Bus id of the to side; must exist and differ from from_bus (BAD_RANGE).
r float pu on base_mva required Series resistance.
x float pu on base_mva required Series reactance. r and x must not both be 0 (BAD_RANGE).
b float pu on base_mva required Total line-charging susceptance; builders apply b/2 per end.
rating_mva float \| None MVA None Thermal rating; None means unrated. Must be > 0 when given (BAD_RANGE).
tap_ratio float \| None None Off-nominal tap magnitude; None means 1.0. Must be > 0 when given (BAD_RANGE).
shift_deg float \| None degrees None Phase shift; None means 0.
in_service bool True
kind "line" \| "transformer" derived What the branch is, for exporters that route on it (see below).

Branch.kind

Importers and exporters need to know whether a branch is a line or a transformer — PyPSA has two components, pandapower two tables, a RAW file two record types — and the electrical fields alone do not say: a transformer at nominal tap looks exactly like a line. kind records it explicitly, with a default so that no existing file, fixture or hand-built Branch changes:

  • Default rule. When kind is not given, it is "transformer" iff tap_ratio is not None/1.0 or shift_deg is not None/0.0, else "line". Every branch that was a transformer by its data is still one; every other branch is a line.
  • The neutral-tap case. An explicit kind="transformer" with a nominal tap (tap_ratio None or 1.0, no shift) is kept — this is what an importer sets when the source says "transformer" (a pandapower trafo row at tap_pos == tap_neutral, a RAW transformer record with WINDV1 == WINDV2), so the element comes back out of an exporter as a transformer rather than being demoted to a line by its numbers. pandapower's own case14 carries such a transformer, so the rule is exercised in both directions.
  • A line with a tap is promoted. An explicit kind="line" together with a tap or a phase shift validates as kind="transformer": the data wins over the label. This is what keeps a mutated network loadable — entities are not frozen, and br.tap_ratio = 1.05 on a line does not re-run validation, so the object keeps kind == "line" in memory. Serialising it (model_dump, the native dump, a CSV row) writes "kind": "transformer" — the file carries one truth, never line beside a tap — and loading it validates to the same. The same network built fresh with that tap is equal to the reloaded one.
  • Exporters route on Branch.is_transformer, not on kind alone: it is true when kind == "transformer" or the tap/shift is off-nominal, so a tap assigned after construction is exported as a transformer (pandapower trafo, PyPSA Transformer) rather than silently dropped from a line.

Solvers never read kind; numerics builds the same π-model from r, x, b, tap_ratio and shift_deg whatever it says. The JSON schema snapshot moved once for the new field.

Generator

A dispatchable injection. cost is present in the schema from M1 so that M3's optimal power flow needs no schema bump; no power-flow solver reads it.

Field Type Unit Default Notes
id str required Unique within generators.
bus str required Bus id; must exist (DANGLING_REF).
p_mw float MW required Active setpoint (input) or dispatch.
q_mvar float MVAr required Reactive setpoint or dispatch.
p_min_mw float MW required Must be ≤ p_max_mw (BAD_RANGE).
p_max_mw float MW required
q_min_mvar float MVAr required Must be ≤ q_max_mvar (BAD_RANGE).
q_max_mvar float MVAr required
v_set_pu float pu required Voltage setpoint used when the bus is PV or slack.
in_service bool True
cost PolynomialCost \| PiecewiseCost \| None None Discriminated on kind.
ramp_up_mw float \| None MW None Max increase from the previous period. None = unconstrained; must be strictly > 0 when given. Read by market.multiperiod.
ramp_down_mw float \| None MW None Max decrease from the previous period; same rules.

Several generators may sit on one bus. Their powers and limits are summed per bus in the numerics; the voltage setpoint rule is described under effective roles.

PolynomialCost (kind = "polynomial")

MATPOWER gencost MODEL 2: \(\text{cost}(p) = \sum_k c_k\,p^k\), coefficients highest order first, cost per hour.

Field Type Default Notes
kind "polynomial" "polynomial" Discriminator.
coefficients list[float] required At least one (BAD_RANGE), highest order first.
startup float 0.0 Startup cost.
shutdown float 0.0 Shutdown cost.

PiecewiseCost (kind = "piecewise")

MATPOWER gencost MODEL 1: piecewise-linear breakpoints.

Field Type Default Notes
kind "piecewise" "piecewise" Discriminator.
points list[tuple[float, float]] required (p_mw, cost) pairs; at least two, p_mw strictly increasing (BAD_RANGE).
startup float 0.0
shutdown float 0.0

GeneratorCost is the annotated union of the two, discriminated on kind.

Load

Field Type Unit Default Notes
id str required Unique within loads.
bus str required Must exist (DANGLING_REF).
p_mw float MW required Active demand (positive consumes).
q_mvar float MVAr required Reactive demand.
in_service bool True
bid PolynomialBid \| PiecewiseBid \| None None Elastic-demand bid covering this load's entire p_mw; read by the market clearings, not by opf.solve_dc_opf.

Shunt

A fixed shunt specified by its power at 1.0 pu voltage, MATPOWER GS/BS sign convention.

Field Type Unit Default Notes
id str required Unique within shunts.
bus str required Must exist (DANGLING_REF).
g_mw float MW at 1.0 pu required Conductance; positive consumes.
b_mvar float MVAr at 1.0 pu required Susceptance; positive injects (a capacitor is positive).
in_service bool True

Storage

Read by market.multiperiod since wave M5 — schema-present and solver-ignored from M1 until then. p_max_mw caps charging and discharging together, and the two efficiencies enter the state-of-charge balance with different coefficients.

Field Type Unit Default Notes
id str required Unique within storage.
bus str required Must exist (DANGLING_REF).
p_max_mw float MW required Charge/discharge power limit; must be > 0 (BAD_RANGE).
energy_mwh float MWh required Energy capacity; must be > 0 (BAD_RANGE).
soc_initial float fraction required Initial state of charge in \([0, 1]\) (BAD_RANGE).
efficiency_charge float fraction required In \((0, 1]\) (BAD_RANGE).
efficiency_discharge float fraction required In \((0, 1]\) (BAD_RANGE).
in_service bool True

Scenario and Period

A scenario is what the market clearings take instead of a bare Network: the network to clear, plus the horizon to clear it over.

Field Type Default Notes
network Network required The network to clear; the scenario is self-contained. Network's own validator runs while the Scenario is being constructed.
periods list[Period] \| None None None = single-period, market.nodal semantics. If given, must be non-empty.

Period carries one field, load_p_mw: dict[str, float] — an id-keyed override of each Load.p_mw for that period, not a scale factor. A load absent from the dict keeps its own p_mw. Values carry exactly Load.p_mw's own range, negatives included (case300 ships eight negative loads, and an override may not be narrower than the field it overrides); only non-finite values are rejected. Every key must resolve to a Load id in the scenario's network, checked by Scenario rather than by Period, which has no network to check against. See Multiperiod market.

Zone

Field Type Default Notes
id str required Unique within zones; referenced by Bus.zone.
name str \| None None Display name.

Schema-present since M1 and solver-ignored until M6; market.solve_zonal reads the Bus.zone partition to build one market-balance row per zone. Every MATPOWER import populates zones and Bus.zone from the ZONE column, so an imported network is already partitioned — usually into a single zone, which clears exactly as a nodal market would. See Zonal market; the transfer capacity between two zones is not a model field, and that page says why.

Validation

The all-issues contract

Validation never stops at the first problem. Network construction runs every invariant and raises one NetworkValidationError whose .issues lists every ValidationIssue found, each with a stable code, a path into the document (buses[3].base_kv) and a message. .codes is the set of distinct codes for quick membership checks; str(err) prints the full list.

NetworkValidationError subclasses Exception, not ValueError. pydantic wraps any ValueError raised inside a validator into its own ValidationError and would drop the issue list; a plain Exception propagates unchanged through Network(...), model_validate and model_validate_json. Catch it by name:

from mambo_power.model import Network, NetworkValidationError

text = '{"base_mva": 100, "buses": [{"id": "a", "base_kv": 110, "type": "pq"}]}'
try:
    net = Network.model_validate_json(text)
except NetworkValidationError as err:
    for issue in err.issues:
        print(issue.code, issue.path, issue.message)
NO_SLACK buses no in-service slack bus defined

Field-level problems that pydantic itself detects — a missing required field, a string where a number is expected, an unknown field, NaN — still raise pydantic's ValidationError before the invariant pass runs.

Validation codes

Code Triggered when
NO_SLACK No bus has type="slack" and in_service=True.
MULTIPLE_SLACK More than one in-service slack bus.
DISCONNECTED_BUS An in-service bus cannot reach the slack over in-service branches whose end buses are both in service (one issue per unreachable bus). Importers repair this case by deactivating the island — see islands; the model itself stays strict.
DUPLICATE_ID Two elements in the same collection share an id (ids may repeat across collections).
DANGLING_REF Branch.from_bus / to_bus, Generator.bus, Load.bus, Shunt.bus, Storage.bus names a bus that does not exist; Bus.zone names a zone that does not exist.
BAD_BASE base_mva <= 0, or a bus with base_kv <= 0.
BAD_RANGE v_min_pu > v_max_pu; from_bus == to_bus; tap_ratio <= 0; r == x == 0; rating_mva <= 0; p_min_mw > p_max_mw; q_min_mvar > q_max_mvar; a ramp_up_mw or ramp_down_mw that is given and not > 0; an empty polynomial cost or bid; a piecewise cost or bid with fewer than two points or non-increasing p_mw; soc_initial outside \([0, 1]\); an efficiency outside \((0, 1]\); a Storage.p_max_mw or energy_mwh not > 0.

ValidationCode is the Literal of these seven strings.

validate_network

Models are mutable and mutation never re-validates. validate_network(net) runs the same invariant pass and returns the issue list (empty means valid) instead of raising:

from mambo_power.io import matpower
from mambo_power.model import validate_network

net = matpower.load("fixtures/matpower/case14.m")
net.buses[0].base_kv = 0.0
issues = validate_network(net)
assert issues and issues[0].code == "BAD_BASE"
print(issues[0].path)
buses[0].base_kv

Use it after editing a network in place and before handing it to a solver. Solvers do not re-validate either; NetworkArrays.from_network only re-checks the slack count. (The jobs API does re-validate, because it must not trust its input.)

Import issues and island repair

A ValidationIssue is something the model rejects; an ImportIssue is something an importer repaired and wants the caller to know about. Both are frozen pydantic records with a closed code set, so callers can dispatch on code without parsing text.

ImportIssue field Type Meaning
code ImportIssueCode "ISLAND_DEACTIVATED", "BASE_KV_REPLACED" or "GENCOST_REACTIVE_IGNORED".
message str Human-readable description.
bus_ids list[str] Buses involved, if any.
element_ids list[str] Branches, generators, loads, shunts or storage involved, if any.

str(issue) is the CODE: message line the legacy list[str] importer APIs return; the typed form is returned by load_with_report inside an ImportReport (see File formats). The class was first shipped as ImportWarning and renamed because that shadowed the built-in of the same name; it is a record, not a Warning, and is never passed to warnings.warn.

repair_islands

The model rejects an in-service bus that cannot reach the slack (DISCONNECTED_BUS). Real files contain such islands, so every importer runs the one shared repair before validation: repair_islands(net) -> (Network, list[ImportIssue]) walks the in-service graph from the in-service slack over in-service branches whose end buses are both in service, sets in_service=False on every unreached bus and on every in-service branch, generator, load, shunt and storage attached to it, and returns a new, validated Network plus one ISLAND_DEACTIVATED issue per island listing the buses and the elements it switched off (elements that were already out are not listed). The input is never mutated. With no in-service slack nothing is changed — that is NO_SLACK's job to report. The entity-level form, repair_islands_entities(buses, branches, generators, loads, shunts, storage), is what importers call before they construct the Network.

from mambo_power.io import matpower
from mambo_power.model import repair_islands

net = matpower.load("fixtures/matpower/case14.m")
net.branches[13].in_service = False  # branch-14 (7-8) is case14's only bridge: bus-8 islands
repaired, issues = repair_islands(net)
print([(i.code, i.bus_ids, i.element_ids) for i in issues])
print(sum(b.in_service for b in repaired.buses), "of", len(repaired.buses), "buses in service")
print(sum(b.in_service for b in net.buses), "in the untouched input")
[('ISLAND_DEACTIVATED', ['bus-8'], ['gen-5'])]
13 of 14 buses in service
14 in the untouched input

The model itself stays strict: Network.model_validate(...) with the island switched back on raises DISCONNECTED_BUS. 05_roles_and_islands.py shows both sides on the case14_island fixture.

JSON schema

Network.json_schema() returns the JSON schema (draft 2020-12, as pydantic emits it) of the native format. Entities appear under $defs; range bounds are documented in each field's description rather than as minimum/maximum keywords, because the bounds are enforced in the all-issues pass, not by pydantic field constraints.

import json
from mambo_power.model import Network

schema = Network.json_schema()
print(sorted(schema["$defs"]))
with open("network.schema.json", "w", encoding="utf-8") as fh:
    json.dump(schema, fh, indent=2)
['Branch', 'Bus', 'Generator', 'Geo', 'Load', 'PiecewiseCost', 'PolynomialCost', 'Shunt', 'Storage', 'Zone']

The committed snapshot under tests/unit/snapshots/ is compared against this output in CI, so any schema change is a visible diff.

Mutability caveats

  • Entities and the network are not frozen: net.buses[0].base_kv = 220 works and does not re-validate. Run validate_network afterwards.
  • Importers and solvers never mutate a network they are given. solve_dc(net) returns a separate result value; nothing is written back to net.buses[i].vm_pu.
  • Lists are shared by reference in model_copy(); use model_copy(deep=True) before editing a copy.
  • Because unknown fields are rejected, you cannot stash custom attributes on an entity; use a side table keyed by id.