Skip to content

mambo_power.io.pypsa

PyPSA export: to_network(net) returns a pypsa.Network built from a Network; to_network_with_report also returns the ExportReport naming every field PyPSA cannot carry (piecewise and degree > 2 costs, load bids, zones, generator reactive limits). PyPSA is imported lazily. No generator ever carries p_set — PyPSA's optimiser reads it as a fixed dispatch. CODES lists the report codes this module can emit.

mambo_power.io.pypsa

PyPSA export (wave M8, W3): Networkpypsa.Network, plus what was dropped.

PyPSA is imported lazily, inside to_network, so the core package keeps its zero-optional-dependency import (design item R9; pypsa is a dev extra).

Field map (m8-research.md §2; verified against opf.solve_dc_opf by tests/parity/test_pypsa_export_vs_pypsa.py):

  • BusBus: v_nom = base_kv, v_mag_pu_min/max, control from type (Slack/PV/PQ), x/y from geo (lon, lat), v_mag_pu_set from the bus's in-service generators' v_set_pu (an out-of-service unit's setpoint is not what the bus holds); area/zone ride along as custom columns (PyPSA keeps unknown columns through CSV export/import). PyPSA buses have no active flag, so in_service is kept as a custom in_service column and every element at an out-of-service bus is exported active = False — what numerics.NetworkArrays does with such elements, so the solvers on both sides see the same network.
  • Branch with is_transformer false (kind == "line" at a nominal tap) → Line in physical units on the from-bus base: Zb = base_kv² / base_mva; r, x in ohm (× Zb), b in siemens (÷ Zb).
  • Branch with is_transformer (kind == "transformer" or an off-nominal tap/shift, so a line mutated to carry a tap is not dropped) → Transformer(model="pi") with r, x, b per unit on the transformer's own s_nom (impedances r, x × s_nom / base_mva; the admittance b × base_mva / s_nom), tap_ratio, tap_side=0 (mambo's tap is on the from side) and phase_shift in degrees.
  • rating_mvas_nom; an unrated branch gets UNRATED_S_NOM_MVA because PyPSA's optimiser reads s_nom == 0 as "carries nothing", not "unlimited" -- an approximation, so the report names each such branch (PYPSA_UNRATED_S_NOM_DEFAULTED, M8 walk surprise 4).
  • GeneratorGenerator: p_nom = max(|p_min_mw|, |p_max_mw|), p_min_pu/p_max_pu as fractions of it (so p_nom == p_max_mw in the ordinary case and a negative-only range survives too), marginal_cost = c1, marginal_cost_quadratic = c2, the constant c0 in the custom COST_CONSTANT_COLUMN column (n.objective excludes constants), ramp_limit_up/down as fractions of p_nom, start_up_cost/shut_down_cost, control, active. p_set is never written: a non-NaN p_set pins the dispatch in optimize() (tests/parity/test_opf_vs_pypsa.py's root cause).
  • phase_shift is carried faithfully and honoured by PyPSA's linear power flow (n.lpf() agrees with pf.solve_dc on a shifted loop, sign included), but PyPSA 1.2.4's optimize() never reads it (pypsa/optimization has no reference to phase_shift; measured in tests/parity/test_pypsa_export_vs_pypsa.py). The AC-3 parity claim is therefore for shift-free networks; a phase shifter is exported, not dropped, so it is not reported.
  • LoadLoad(p_set, q_set); ShuntShuntImpedance(g, b) in siemens (MW / kV²); StorageStorageUnit(p_nom, max_hours, state_of_charge_initial, efficiency_store, efficiency_dispatch); base_mvan.meta["base_mva"].

Dropped and reported (design item D1 — never approximated; each entry names the element id and the field): piecewise costs, polynomial costs of effective degree > 2, load bids, zones, generator reactive limits, a ramp on a zero-capacity generator, and disagreeing voltage setpoints at one bus (PyPSA has one v_mag_pu_set per bus). A concave quadratic cost (c2 < 0) is exported as it is and reported (PYPSA_COST_NONCONVEX): PyPSA hands it to the solver, which rejects the non-convex QP with its own error rather than a mambo issue. CODES lists the codes.

CODES module-attribute

CODES: tuple[str, ...] = (
    "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",
)

Every report code this exporter can emit (its documented limitations).

UNRATED_S_NOM_MVA module-attribute

UNRATED_S_NOM_MVA = 100000.0

s_nom written for a branch with rating_mva = None. PyPSA's optimiser bounds every branch flow by s_nom, so 0 would mean "no flow", not "no limit"; this sentinel is far above any flow on the fixtures (case300's whole dispatch is 2.4e4 MW) and small enough not to hurt the LP's scaling.

COST_CONSTANT_COLUMN module-attribute

COST_CONSTANT_COLUMN = 'marginal_cost_constant'

Custom generator column holding the polynomial cost's constant term c0 (cost per hour at any dispatch). PyPSA has no attribute for it and n.objective excludes constants; a caller comparing objectives adds n.generators[COST_CONSTANT_COLUMN].sum().

to_network

to_network(net: Network) -> Network

Export net as a pypsa.Network, discarding the report.

See to_network_with_report for what may be dropped; this form is for callers who already know their network is within what PyPSA can express.

Source code in src/mambo_power/io/pypsa.py
def to_network(net: Network) -> pypsa.Network:
    """Export ``net`` as a :class:`pypsa.Network`, discarding the report.

    See :func:`to_network_with_report` for what may be dropped; this form is for callers who
    already know their network is within what PyPSA can express.
    """
    return to_network_with_report(net)[0]

to_network_with_report

to_network_with_report(
    net: Network,
) -> tuple[Network, ExportReport]

Export net as a pypsa.Network and report every field PyPSA cannot carry.

An empty report means the export was lossless. The report only ever carries warnings — every unsupported field is dropped, never refused (D1) — so raise_on_error is a no-op.

Source code in src/mambo_power/io/pypsa.py
def to_network_with_report(net: Network) -> tuple[pypsa.Network, ExportReport]:
    """Export ``net`` as a :class:`pypsa.Network` and report every field PyPSA cannot carry.

    An empty report means the export was lossless. The report only ever carries warnings — every
    unsupported field is dropped, never refused (D1) — so ``raise_on_error`` is a no-op.
    """
    import pypsa

    report = ExportReport()
    warn = report.warnings.append
    n = pypsa.Network()
    n.meta["base_mva"] = net.base_mva

    base_kv = {bus.id: bus.base_kv for bus in net.buses}
    live = {bus.id for bus in net.buses if bus.in_service}
    _add_buses(n, net, warn)
    _add_branches(n, net, base_kv, live, warn)
    _add_generators(n, net, live, warn)
    _add_loads(n, net, live, warn)
    _add_shunts(n, net, base_kv, live)
    _add_storage(n, net, live)
    if net.zones:
        warn(
            ConversionIssue(
                code="PYPSA_ZONE_DROPPED",
                message=f"PyPSA has no zone component; dropped zones {[z.id for z in net.zones]} "
                "(bus.zone labels are kept as a custom 'zone' column on buses)",
                element_ids=[z.id for z in net.zones],
            )
        )
    return n, report