Changelog¶
All notable changes to mambo-power are recorded here. The format follows
Keep a Changelog and the project follows
Semantic Versioning. python-semantic-release inserts each new release
directly below this line, computed from conventional-commit messages, starting from the
v0.1.0 tag (wave M9) — this preamble is deliberately written to stay true before and after
that first release lands, so it can never repeat the contradiction a stale "nothing released
yet" sentence caused here once (Step-6 critic finding C2: the tool writes below this exact
line, permanently, so a release-state claim here has a shelf life of one release).
v0.1.0 (2026-09-01)¶
- Initial Release
Pre-release history¶
One hand-written section per wave, newest first — written before any release existed, and before
python-semantic-release (wave M9) started generating new entries above this heading from the
v0.1.0 tag forward, one per computed version, from conventional-commit messages. Nothing below
this heading is a PyPI release; the heading says "history," not "Released," on purpose — see the
preamble above. Which waves have merged to epic/01-foundation and which are still on their own
branch is tracked in the home page's roadmap table, not restated here, so this page
cannot go stale about it.
Added — wave M9 (release-0.1)¶
- Four narrative tutorial notebooks.
docs/tutorials/{01-first-power-flow, 02-dc-opf-and-n1, 03-nodal-market, 04-where-next}.ipynb, difficulty-tiered (beginner through a shorter guided fork) and prose-heavy — a different reader thanexamples/*.py's terse scripts — each self-contained and referencing the one before it: tutorial 1 loadscase14and contrastspf.solve_dc/solve_ac; tutorial 2 runsopf.solve_dc_opffor dispatch and LMPs pluscontingency.n1screening; tutorial 3 runsmarket.solve_nodalwith elastic demand and narrates settlement/congestion rent; tutorial 4 is a guided fork betweenmarket.agents(strategic bidding) andio.*(interop), one worked example of each.docs/tutorials/index.mdstates the arc and each notebook's tier. Every code cell was proved twice before being committed with its output baked in — once as a standalone script against the real package, once via a full fresh-kernelnbconvert --executepass. - Notebooks execute in CI.
nbmake(newdev-group dependency) runs the four notebooks as a dedicatedtutorialsCI job (pytest --nbmake docs/tutorials/*.ipynb, fresh kernel, fails on any raised exception) — kept off the five-leg OS/Python test matrix, since a notebook run is solver-heavy and re-running the same four notebooks five times would spend CI minutes for no extra coverage. - Notebooks render on the site.
mkdocs-jupyter(newdocs-group dependency) renders the four notebooks under a new top-levelTutorialsnav section, betweenGetting startedandManual;execute: false— the outputsnbmakealready verified fresh in CI are what's shown, not a second, slower re-execution atmkdocs buildtime.Manual's own 12 entries are untouched, in both content and order. - Home page and roadmap.
docs/index.md's status prose and roadmap table readmergedfor every wave M1 through M9; the "where do I go" table gains aTutorialsrow ahead of theManualrows. - PyPI-sequencing drift guard.
scripts/check_pypi_sequencing.py, run in CI, assertsdocs/getting-started.md's PyPI install instructions are never present without a matchingv0.1.0+ tag reachable fromHEAD— the "not on PyPI yet" text this page carries through Step 8's merge can only flip topip install mambo-powerin the same action as the tag push, and any later wave that tried to jump the gun would fail this guard rather than drift silently. python-semantic-release, configured inpyproject.toml's new[tool.semantic_release]table, computes the next version from conventional-commit messages onepic/01-foundation(feat→minor,fix/perf→patch, aBREAKING CHANGE:footer→major), starting from a manually-cutv0.1.0— never from this repo's full pre-semantic-release history. Its built-in update mode inserts each new release's section directly below the<!-- version list -->flag now at the top of this page; the nine wave sections that used to sit directly under## [Unreleased]are unchanged, byte-for-byte, just relocated under## Pre-release historybelow. Two operational notes for whoever runs a release: on Windows, a localsemantic-releaseinvocation needsPYTHONUTF8=1set first, or this page's non-ASCII prose crashes its default-codepage read (GitHub Actions' own UTF-8 locale is not expected to hit this); andsemantic-release changelogis not idempotent against an untagged state — running it twice with no intervening tag duplicates the generated section, so a release always goes throughsemantic-release version(which tags and moves the insertion boundary in the same step), never thechangelogsubcommand repeated on its own.- PyPI trusted publishing.
.github/workflows/publish.ymltriggers only on a pushedv*tag (never on a plain commit, a PR, orworkflow_dispatch); builds the wheel and sdist and publishes viapypa/gh-action-pypi-publishusing OIDC — no token or secret anywhere in the workflow file or repo settings; runs in apypiGitHub environment (ownermambo10005, repomambo-power) that a required-reviewer rule on the environment gates as the manual-approval step before anything actually publishes.
Fixed — case30 redispatch/zonal dual degeneracy in CI (2026-08-31)¶
test_market_zonal.py's AC-4 LMP comparison andtest_opf_redispatch.py's D1 theorem test on case30 asserted a specific dual solution where the LP has more than one: a zero-injection radial node makes two rated branches' PTDF rows exactly redundant, so HiGHS has legitimate freedom in how it splits the shadow price between them, and which split it picks is platform-sensitive (reproduced failing onwindows-latestatcdb4fefand onubuntu-latestat the pushed M9 head, never both). Fixed by quotienting the comparison by proven PTDF-row redundancy (tests/_degeneracy.py) rather than widening a tolerance — Design › Decisions gains ADR-012.
Fixed — the DC-OPF phase-shifter flow defect (M7 F1, M8 A19)¶
opf.dc_opf's flow-limit row constant,opf.solve_dc_opf's derivedbranches[].p_from_mw,market.solve_nodal/solve_agents' derived branch flows,opf.multiperiod_dc_opf's per-period flow-limit row constant, andopf.redispatch_dc_opf's flow-limit row constant and its derived, reportedbranch_flow_mwall omitted the phase shifter'sp_shiftbus injection from their PTDF product, so any network with a non-zeroshift_deg(from any importer) got wrongopf/marketbranch flows — includingmarket.solve_zonal's publicbranches[].p_from_mw(sourced fromopf.redispatch_dc_opf) andmarket.solve_multiperiod(sourced fromopf.multiperiod_dc_opf) — and a generously rated shifter loop could come backInfeasiblewith no flows at all —pf.solve_dcwas always right. Fixed at all five sites:numerics.bbus.flow_from_ptdf(ptdf, injection_mw, arr)(the identityflow = ptdf @ (injection_mw - p_shift·base_mva) + pf_shift·base_mva, matchingpf.solve_dcexactly) is now called directly byopf.solve_dc_opf,market._clearingandopf.redispatch_dc_opf's reportedbranch_flow_mw;opf.dc_opf's,opf.multiperiod_dc_opf's andopf.redispatch_dc_opf's own flow-limit row constants each fold in the samep_shiftterm by hand, since eachconst/const_kis added to a linear combination of decision variables rather than a full injection vector. Every existing fixture hasshift_deg == 0everywhere, sop_shift(arr) == 0identically and the fix is a byte-for-byte no-op on them; a new generously-rated three-bus shifter loop (tests/_shifter.py) is the fix's own regression fixture, checked againstpf.solve_dcand PyPSA'slpf()at two asymmetric shift angles, extended toopf.multiperiod_dc_opf,opf.redispatch_dc_opf,market.solve_zonalandmarket.solve_multiperiodonce a Step-6 critic review found the latter two sites' own copies of the same defect, untouched by the first pass. The File formats limitations sections no longer carry this caveat.
Added — wave M8 (interop)¶
io.pandapower_json:load/loads/load_with_reportread app.to_jsondocument (bus,ext_grid,gen,sgen,load,shunt,line, two-windingtrafo,poly_cost,pwl_cost; no results table) with the measured per-unit conversions — liner/x/bonvn_kv²/sn_mva, trafo impedance fromvk_percenton the system base, the tap changer folded intotap_ratio, the shunt sign flipped from consumption to injection; the first in-serviceext_gridis the slack and any further one is demoted to a PV generator with a warning.dumps/dump/dumps_with_reportwrite a documentpp.from_jsonloads and on which pandapower's ownrundcpp/runppagree withpf.solve_dc/pf.solve_acto 1e-13° and 1e-15 pu. Strictnets_equalon the round trip holds for the cost tables only; every carried value survives at 1e-12 and the set is pinned in the test rather than papered over (F2).io.pypsa:to_network/to_network_with_reportbuild apypsa.Network— lines in ohm/siemens, transformers asmodel="pi"on their owns_nomwithtap_ratioandphase_shift, generators withp_nom,p_min_pu,marginal_cost(+marginal_cost_quadratic), the constant term in amarginal_cost_constantcolumn, unrated branches ats_nom = 1e5, and never a generatorp_set(it pins dispatch). PyPSAoptimize()reproducesopf.solve_dc_opf's objective to 1e-8 on case14 / case30 / case118; the one 1.87e-3 MW dispatch residual on case118 is HiGHS's, measured and pinned (F3). Piecewise costs, degree > 2, load bids, zones and generator Q limits are dropped and reported, never approximated. PyPSA 1.2.4'soptimize()ignoresphase_shift, so DC-OPF parity is for shift-free networks.io.psse_raw:load/loads/load_with_reportread PSS/E RAW v33 — case identification, bus, load, fixed shunt, generator, branch, two-winding transformer (four-line records;CZ/CW/CMconverted as MATPOWER'spsse_convert_xfmrdoes), area, zone; ZIP loads, branch end shunts and magnetising admittances are folded and reported; three-winding transformers, switched shunts, owners and every later section are skipped with one report entry per record. RAW carries no costs, and the importer says so (RAW_NO_COSTS) rather than inventing any. Fixturescase14_v33.rawandsynthetic_quirks_v33.rawwith provenance.io.csv_bundle:dump(net, dir)/load(dir)—manifest.jsonplus one CSV per entity table headed by the model's field names, costs and bids as long-format side tables, empty cell =None, ids as text, floats viarepr.load(dump(net)) == netbit-exactly on every bundled fixture; a bundle that is not exact is refused with every problem listed (CSV_*codes), and an optional string field holding""is refused on write because the bundle could not read it back.model.Branch.kind: "line" | "transformer", defaulted at construction —"transformer"iff the tap is off-nominal or the shift non-zero — so no existing file changes; importers set it from the source table (a neutral-tap transformer stays one), exporters route onBranch.is_transformer(kindor an off-nominal tap/shift, so a tap assigned after construction is still exported), an explicit"line"with a tap is promoted to"transformer", and every dump (model_dump, native, CSV) writes the promoted kind so a file never sayslinebeside a tap. The JSON schema snapshot moved once.io.report.ExportReport, mirroringImportReport(same issue record,warnings/errors,codes,as_strings,raise_on_error), returned by every exporter under one rule: an empty report means the conversion was lossless, and anything dropped, approximated or repaired is an issue naming the element id and the field.io.limitations.LIMITATIONSregisters every code the four modules can emit;tests/unit/test_io_limitations.pyfails on a code the manual does not name.- Docs: File formats gains a section per format (sections read, derived
ids, column / record maps, warnings, errors, limitations, example); API pages for the four
modules;
examples/13_interop.pyruns every format on case14 with its report. - Known limitation carried out of the wave (F1 / A19):
opf.dc_opf's flow rows omit the phase shifter's PTDF term, so a network with a non-zeroshift_deg— from any format — gets wrongopf/marketbranch flows until the standalone fix lands;pf.solve_dcis right and agrees with pandapower'srundcppand PyPSA'slpf(). Stated under every importer's limitations in the manual. Fixed — see "Fixed", above.
Changed — wave M8 (interop)¶
opf/market/jobsrefuse a cost-less generator.opf.solve_dc_opf,market.solve_nodal/solve_multiperiod/solve_zonal/solve_agentsand theopf.dc,market.*job kinds now raiseopf.MissingCostError(aValueError, naming every in-service generator whoseGenerator.cost is None) before any solve. Until M8 such a generator got an all-zero coefficient row — priced free — so a network with no economic data at all (every RAW import, a MATPOWER case withoutgencost) cleared atobjective_cost 0.0with all load on one unit. Fix the data: setGenerator.cost, or take the generator out of service (only in-service generators are priced).jobs.runmaps it to afailedresult witherror.code == "VALIDATION"anderror.issues is None— the ids are in the message, not inissues, becauseValidationCodeis the closed set of the model's own invariants and a missing cost is legal model data that only the pricing kinds refuse.pf.*andn1are untouched; a cost-less generator that is out of service is not in the priced set and does not raise. Design › Decisions gains ADR-011: every format pivots throughNetwork, and what a format cannot carry is a report entry, never a guess.
Added — wave M7 (agent-based bidding)¶
market.solve_agents(scenario, options=None, *, strategies=None) -> MarketAgentsResult: the fourth market mode and the first whose input is the output of a decision. Each round every generator'sStrategysees anObservation(its own true cost, bounds, and its own last two rounds — price at its bus, cleared MW, the offer it made) and returns the cost curve it offers (anyGeneratorCost); the market clears the offered curves with the ordinary nodal DC-OPF, and the loop repeats until the offers settle. Updates are simultaneous. The observation is an own-node view on purpose: no rival's offer, cost or dispatch ever reaches a strategy.market.strategy: theStrategyprotocol,PriceTakerStrategy(offers the true cost verbatim — the same object, not a reconstruction) andMarkupStrategy(a two-point hill climb on its own profit with a fixedstep, never offering below its true cost).StrategyConfigis the JSON-facing discriminated union andbuild_strategyturns it into an instance, so a strategy crossesjobsas data and never as a callable.results.MarketAgentsResult: the final round's nodal result plus per-agentAgentOfferResult(true cost, final offer, profit) and atermination_reasonthat is one of three words —converged,iteration_cap,cycle— rather than a flag that could be read as settled when the loop merely stopped. Convergence is decided on the amplitude of the last cycle of offers againstoffer_tol, with a tie rule so that a comparison exact in arithmetic is not decided by float noise (found on both sides of the same boundary during the wave). The derived floor onoffer_tolis3 * step, not2 * step: a profit peak halfway between two grid points ties the two straddling offers, and the settled orbit is then three steps wide (critic finding, M7 S11;MarkupStrategy.min_offer_tolis the one place the constant lives).jobs:kind="market.agents", the eighth kind;MarketAgentsOptionsand its strategies cross as JSON. Caller mistakes (an unknown generator id,offer_tolbelow3 * step, a markup strategy on a non-linear cost, a bad iteration cap) map toVALIDATION, notINTERNAL.opf.dc_opfnow raises when a generator appears inpwl_costsand has a nonzerocost_coeffsrow — the generator-side mirror of the load-side guard that has always existed. Without it the unit's cost is charged twice (polynomial term plus epigraph rows) and the LP solves happily: on case14 the doubly-charged form drove one generator from 223 MW to zero and raised the objective by $2,409.70 with status stillOptimal. Five waves never hit it becausegen_cost_coeffszeroes the row by construction; M7 is the first to assemble coefficients per round from strategy output. Disclosed as a behaviour change.MarketNodalResult.branches: per-branch flows on the nodal result, derived from the PTDF matrix and the phase-shift term, so the agents result can report congestion without a second solve.- A manual page (Agent-based bidding) and
examples/12_agent_market.py. Design › Decisions gains ADR-010: offers as an overlay the loop supplies, the own-node two-round observation, and what "settled" means.
Added — wave M6 (zonal market and redispatch)¶
market.solve_zonal(scenario, options=None) -> MarketZonalResult: a market cleared at zonal granularity, then redispatched onto the real network, then measured against the nodal optimum — three chained solves whose content is their relationship. Because the redispatch objective is the true cost and bid curves over the nodal problem's own feasible set, the redispatched point is the nodal optimum, so what the comparison measures is the cost of the market design alone and not the quality of a redispatch heuristic.MarketZonalResultcarries both dispatch layers (what the market sold, what the network delivers), the deltas between them per participant and per direction, zone prices, per-bus LMPs and per-branch flows and duals.model.ZoneandBus.zoneare read by a solver for the first time, having been in the schema and populated by every MATPOWER import since M1. The partition is read, never derived: an in-service bus with no zone is aValueErrorrather than a default, because that bus's load has to enter some zone's balance row.market.CorridorLimitandmarket.MarketZonalOptions: transfer capacity per tied zone pair, supplied per solve rather than stored on the network. A negotiated transfer capacity is not determined by any branch rating and no bundled fixture carries one, so a model entity would be inventing committed data. The row-model shape (rather than a{(z1, z2): cap}mapping) is what makes the options object survive a JSON round trip, which everyjobsrequest form must.opf.zonal.zonal_dc_opf(arr, cost_coeffs, zone_of_bus, corridors, ...) -> ZonalSolution: one balance row per zone, one bounded exchange column per tied zone pair, and no branch flow rows at all — each zone a copper plate internally, no PTDF matrix ever built. A zone's price is its own balance row's dual; a corridor's capacity price is its column's reduced cost as a magnitude, non-negative in both flow directions.opf.redispatch.redispatch_dc_opf(arr, cost_coeffs, p0, d0, ...) -> RedispatchSolution: the minimum-cost move from a zonal operating point to a network-feasible one, with Δ⁺/Δ⁻ columns on both sides of the market (demand can be restored, not only curtailed) and bounds shifted by the starting point so the final point ranges over exactly the box the nodal problem has. Reported deltas are netted to the canonical representative, sofinal == p0 + up - downandup * down == 0hold exactly whatever vertex the solver returns. The two sides are named for what happens to the participant:delta_up_mw/delta_down_mwon a generator row,delta_restore_mw/delta_curtail_mwon aMarketZonalResultload row.results.zonal:MarketZonalResultwithZonePriceResult,GenRedispatchResultandLoadRedispatchResult. Three deliberately separate figures —redispatch_payment(a settlement figure),welfare_gap(an exactness row,0by the theorem above) andgeneration_cost_gap(a diagnostic that is not sign-constrained: a zonal clearing can burn less fuel than the nodal optimum while being welfare-worse). It is also the first market result type carrying per-branch flows and their shadow prices, which makes both sides of the settlement identity computable from the result object alone, with no second solve.jobs:market.zonalregistered as a seventh kind.- Oracle: PyPSA with one bus per zone joined by
Links carrying the corridor capacities — column-for-column the engine's own corridor variable, where aLinewould not be (three corridors close a loop and Kirchhoff's voltage law would pin the split by reactance). The partition and capacities are handed to the oracle independently of the engine. TheLinkform is an exact LP equivalence, so the pinned residuals are four orders tighter than this package's usual parity bands: objective 1.67e-15 relative, 1.59e-12 MW, 7.11e-15 $/MWh. - Fixtures, derived at test time and committing no new files:
tests/_zones.pypromotes case30's three MATPOWERAREAgroups to realZoneentities and derives each corridor's capacity as the sum ofrating_mvaover its cut-set; case300's four realZONEgroups are used directly. - Manual › Zonal market and a new runnable
example. Manual › Results gains a
section per market result type, Design › Decisions gains ADR-006 through
ADR-009, and the zonal page names the two things its own worked variations make true and its
prose did not say: when
redispatch_paymentis negative, and thatredispatch_payment + generation_cost_gapis exactly the curtailment compensation.
Added — wave M5 (multiperiod market)¶
market.solve_multiperiod(scenario, options=None) -> MarketMultiperiodResult: a whole horizon cleared as one coupled LP/QP, notTstacked single-period clearings. Three row families a single instant cannot have — a ramp row tying periodttot-1, a state-of-charge row tying the horizon into one energy budget, and a cyclic row closing it atsoc[T-1] == soc_initial * energy_mwh.MarketPeriodResultcarries that period's own dispatch, its per-bus LMPs split byopf.lmp_decomposition, and five settlement figures;MarketMultiperiodResultcarries their plain sum as horizon totals. AScenariowithperiods=Nonedegenerates tomarket.solve_nodalbit-for-bit on every bundled fixture, case300 included.model.Period(load_p_mw)andScenario.periods: list[Period] | None: an id-keyed override of eachLoad.p_mwfor that period, not a scale factor — a load absent from the dict keeps its ownp_mw. Keys are checked against the scenario's network byScenario's own validator, since a barePeriodhas no network to check against. On a load carrying abidthe override moves the upper bound of its elastic column too,Load.p_mwbeing that load's maximum served quantity.market.solve_nodalignoresperiodsentirely and stays a single-period entry point.Generator.ramp_up_mw/ramp_down_mw, bothfloat | None, in MW per period — physical, like every otherGeneratorfield, rather than PyPSA's per-unit-of-p_nomconvention.Nonemeans unconstrained and builds no row at all; a limit of exactly0is rejected byNetworkitself with aBAD_RANGEvalidation issue.GenPeriodDispatchResult.ramp_dualreports the row's shadow price under HiGHS's own sign convention — negative when the ramp-up side binds, positive when the ramp-down side does.model.Storageis read by a solver for the first time, having been in the schema and solver-ignored since M1. Two nonnegative power columns per unit per period plus an explicitsoccolumn, because the charge and discharge efficiencies enter the SoC row with different coefficients (+eta_cagainst-1/eta_d) — an asymmetry one signed column cannot express in a linear row.StorageDispatchResultreportscharge_mw,discharge_mw, end-of-periodsoc_mwhandsoc_dual. A unit settles on both sides of the market, and the settlement identity does not close if a dispatched one is left out.opf.multiperiod_dc_opf(arr, cost_coeffs, n_periods, ...) -> MultiperiodSolution, withMultiperiodDuals: the array-level builder, ondc_opf's own row-family helpers rather than a second solver. The variable vector is two tiers, notTself-contained blocks —T * (n_gen + n_demand + 3*n_storage)period-major columns the quadratic Hessian covers exactly, then the free PWL variables — becausedc_opfpasses its Hessian over a prefix of the columns.jobs:market.multiperiodregistered as a sixth kind, andSolveRequestnow takes either anetworkor ascenario, resolved through the newSolveRequest.resolved_scenario. A horizon needsScenario.periods, which a bareNetworkcannot supply.- Oracle: PyPSA multi-period
optimizewithStorageUnitandramp_limit_up/ramp_limit_down, on a 24-period rated case14 with a lossy unit and an asymmetric generator ramp limit both genuinely engaged. Measured worst-case residuals: objective 4.35e-13 relative, per-generator per-period dispatch 3.01e-4 MW, net storage power 1.10e-4 MW, state of charge 1.25e-4 MWh, per-bus per-period LMP 4.24e-5 $/MWh. Two limits of that oracle — PyPSA's transformer ratings, and the fixture's inability to tell the two efficiencies apart — are disclosed on the manual page rather than tolerated silently. - Fixtures, derived at test time and committing no new files:
tests/_periods.py(a 24-hour raised-cosine profile, 0.7x at hour 4 up to 1.2x twelve hours later, applied as a single system-wide curve — the two-archetype design it started from was measured infeasible against the derived ratings) andtests/_storage.py(one unit at 15% of the network's own total base-case load with a 4-hour duration,soc_initial = 0.5, deliberately asymmetric efficiencies). - Documentation: the multiperiod manual and API page,
examples/10_multiperiod_market.py, andtests/unit/test_docs_registry_listing.py, which pins the three places the jobs manual states the registry's contents against the registry itself — that hand-pasted list had gone two waves stale before anything checked it.
Added — wave M4 (nodal market)¶
market.solve_nodal(scenario, options=None) -> MarketNodalResult: a day-ahead nodal energy market cleared as a welfare LP/QP — generation cost minimised, demand value maximised — subject to the same linearised networkopf.dc_opfsolves, with per-bus LMPs and settlement. Built directly onopf.dc_opfandopf.dc_opf.lmp_decomposition, called verbatim rather than reimplemented.model.Scenario(network): the self-contained clearing input, embedding theNetworkdirectly, mirroringjobs.SolveRequest's own pattern rather than an id/path cross-reference — no such resolution mechanism exists anywhere else in this codebase.Load.bid: aPolynomialBid | PiecewiseBiddiscriminated union mirroringGeneratorCostfield-for-field with one difference — direction.bid is Nonestays fixed demand, so every M1–M3 network behaves exactly as it did.- Elastic demand inside
opf.dc_opf, through two optional parameters (demand_bid_coeffs,demand_pwl_bids) that leave every M2/M3 caller unaffected: one new LP column per bid load, bounded[load_p_min_mw, load_p_max_mw]with no sign flip (the column is the load's own served demand, not a negative-bound pseudo-generator), a matching-1-signed term in the balance and PTDF flow-limit rows, and a hypograph encoding for a piecewise bid — the concave mirror of the convex epigraph already used for PWL generator costs.OpfSolution.demand_dispatch_mwanddemand_boundare explicit new fields, never overloading the generator-side ones.dc_opfresolves the double-counting itself, removing each bid load's own historical contribution from the fixed RHS, so the caller passesNetworkArrayscompletely unmodified. market.NonConcaveBidError, raised before any HiGHS object is created for a bid whose marginal value is not non-increasing (a non-concave PWL sequence, or a polynomial bid withv2 > 0).results:MarketNodalResult,LoadDispatchResult.MarketNodalResult.loadscarries one row for every load in the network, bid or not — a bid load'sp_mwis its solved elastic dispatch, a fixed load keeps its ownLoad.p_mwwithbound_dual == 0.0— matching the settlement identity's own derivation, which sumsLMP · p_dover every load.- Settlement:
total_load_payment,total_generator_receiptsandcongestion_rent, each computed directly from prices and quantities rather than asserted equal to the others by construction, and proved against-Σ_k μ_k · flow_kon a hand-KKT-verified 2-bus case and independently on real multi-bus fixtures with derived bids. - The price-taker reduction, proved rather than assumed: where every load's bid value exceeds
every achievable price up to its own fixed historical demand,
solve_nodal's dispatch, duals and LMPs are identical to plainopf.solve_dc_opfon that same demand as fixed load. NetworkArrays: per-load identity (load_ids,load_bus,load_p_min_pu/load_p_max_pu), the same per-entity treatment generators already had.jobs:market.nodalregistered as a fifth kind, with the non-"Optimal"status translation factored into a helper shared withopf.dc.- Oracle: pandapower
rundcoppvia thesgenframing — each bid load dropped as aloadrow and rebuilt as a sign-flipped, negative-boundsgen(min_p_mw = -p_mw, max_p_mw = 0) whose poly-cost coefficients are the bid's own sign-flipped, proved exact against a hand KKT solve before any test was written. The more natural-lookingload-row framing reproducibly fails to converge inrundcopp; the parity module's docstring records that precisely so a future reader does not have to rediscover it. Measured on case14 with every load bid: dispatch within 1e-6 MW, LMP within 1e-3 $/MWh. tests/_bids.py: bid curves derived at test time from a fixture's own already-committedLoad.p_mwandGenerator.cost— marginal value descending linearly fromVOLL_PER_MWH = 10,000$/MWh atp = 0to that fixture's own generation-fleet max marginal cost atp = load.p_mw— the same no-new-fixture-data disciplinetests/_rated.pyestablished.- Documentation: the nodal-market manual and API page, and
examples/09_nodal_market.py.
Added — wave M3 (DC optimal power flow, N-1 screening)¶
opf.solve_dc_opf(net, options=OpfDcOptions()) -> OpfDcResult: cost-minimising DC optimal power flow over HiGHS — one column per generator bounded by its own declared[p_min_mw, p_max_mw], one system-wide nodal-balance equality row whose dual is the energy component of every LMP, and one PTDF-based flow-limit row per branch, reusing thenumerics.ptdfalready parity-tested on its own.opf.dc_opf.dc_opfis the array-level solver. A pure LP with no Hessian at all when every generator'sc2is exactly 0, transparently a convex QP viaHighs.passHessianwhen one is not — every bundled OPF fixture carries genuine nonzero quadratic coefficients, so matching real fixture data needs the quadratic term, not just the linear one.- Convex piecewise-linear generator costs through
dc_opf'spwl_costs: the standard segment/epigraph encoding — one freecost_gvariable with objective coefficient 1, plus one inequality row per segment. It composes unchanged with the QP path, so one network may mix quadratic and PWL generators in the same solve. A non-convex breakpoint sequence raisesopf.NonConvexCostErrorbefore any HiGHS object is created, rather than a wrong-but-optimal-looking answer; a degree-3-or-higherPolynomialCostraisesNotImplementedErrorat cost extraction. opf.dc_opf.lmp_decomposition(duals, ptdf) -> LmpBreakdown: standalone and independent ofdc_opf/solve_dc_opf, callable with any hand-built duals/PTDF pair, splitting every bus's price into a system-wide-uniform energy component and a congestion component (that bus's exposure to every binding flow-limit row).solve_dc_opfcalls it to populateOpfDcResult.lmp.OpfDcOptions.ac_check: re-runspf.solve_acon the dispatched network — a deep copy with each in-service generator'sp_mwoverwritten, id-keyed, from the DC-OPF dispatch — and attaches aFeasibilityReportof thermal (loading_pct > 100%) and voltage violations. It reports; it does not re-dispatch. case14's own DC-OPF-optimal dispatch lands 3 buses outside their declared 1.06 pu upper bound once AC-solved.contingency.n1(net, options=None) -> N1Result: N-1 branch-contingency screening as screen-then-confirm.screen_n1DC-solves the base case once, then estimates every other branch's post-outage flow fromnumerics.lodf, skipping bridge outages entirely since LODF is undefined where the outage disconnects the network;confirm_n1re-solves only what the screen flagged, against one deep copy whosein_serviceflag is flipped and restored per outage rather than a fresh copy each time (measured ~20x slower the naive way on case300).N1Resultcarriesoutages— per flagged branch: rating, LODF estimate, DC-re-solved flow, and whether the re-solve confirms a violation — andbridge_branch_ids. Branch outages only; generator outages, N-2+ and any redispatch on a violation are explicit carry-overs.- The agreement guarantee: on all five bundled OPF fixtures with derived ratings, the screen-then-confirm pipeline's confirmed-violating outage set is exactly the set a brute-force sweep finds with no LODF pre-filter at all (case14 18, case_ieee30 34, case57 75, case118 166, case300 293) — it misses no confirmed violation the brute force catches, and confirms nothing the brute force would not.
results:OpfDcResult,GenDispatchResult,OpfBranchFlowResult,BusLmpResult,FeasibilityReportwithThermalViolationandVoltageViolation,N1Result,N1OutageResult,N1BranchFlag.jobs:opf.dcandn1registered as kinds, plus two new failure codes,INFEASIBLE_LPandUNBOUNDED_LP, so a non-"Optimal"LP/QP comes back as a structured failure rather than a "successful" result carrying a meaningless dispatch.solve_dc_opfitself still never raises for one — it is reported throughOpfDcResult.status/message, mirroringpf.solve_ac's never-raise-on-non-convergence convention.tests/_rated.py:rating_mva = max(1.2 * |base_case_p_from_mw|, 1.0), derived at test time from each fixture's own unmodified base-case DC dispatch. No bundled fixture carries a realRATE_A(every branch reads 0, MATPOWER's "unlimited" convention), so nothing had anything for a flow-limit row or a contingency screen to bind against. The first of this repository's derived-fixture helpers, and the disciplinetests/_bids.py,tests/_periods.pyandtests/_storage.pyeach followed after it.- Fixture:
fixtures/matpower/derived/case14_pwl.m, two of case14's five generators converted to convex piecewise-linear cost with the other three keeping their real quadratic coefficients. pandapower'srundcopprefuses to mix quadratic and piecewise costs anywhere in one network, so this fixture cannot be oracled by it at all; verification fell back to an independent lambda-iteration economic dispatch, which also surfaced a genuine LP degeneracy — two breakpoints tie in marginal cost, so how the two affected generators split their combined output has multiple optima. Asserted as an interval, not a false-precise split, with the other three generators and the total system cost uniquely pinned. - Named as a real formulation difference rather than rounded into a looser tolerance:
opf.dc_opf's PTDF-based dispatch and pandapower's theta-basedrundcoppare genuinely different formulations that happen to agree on every bundled fixture.rundcoppmarks the slack-bus generatorcontrollable=False, making its dispatch the network's balance residual;dc_opfmakes every generator, that one included, a bounded decision variable. The two conditions under which they must agree are asserted directly in the parity suite, not assumed. - Documentation: the DC-OPF and N-1 manual pages and their API pages, and
examples/08_opf_and_n1.py.
Added — wave M2 (power flow)¶
pf.solve_dc(net) -> DcPowerFlowResult: DC power flow \(B'\theta = P - P_\text{shift}\) with phase-shifter injections, flows via \(B_f\), slack balance to the first in-service slack-bus generator (MATPOWERrundcpfsemantics).pf.dc.solve(arr) -> DcSolutionis the positional solver. Parity with pandapowerrundcppwithin 1e-9 on every fixture including case300.results: typed, id-keyed result models —BusResult,BranchResult,GenResult,ResultProvenance(engine, version, kind, solver, started_at, elapsed_s, options),DcPowerFlowResult,AcPowerFlowResult— with exact JSON round-trip and a positionalto_arrays()view;dc_result_from_arraysbuilder.- Fixtures:
case300.mverbatim from MATPOWER with recorded sha256 and a licence note (public IEEE test data as distributed by MATPOWER); derived case14 variants underfixtures/matpower/derived/exercising effective bus roles, a slack without a generator and an island. - Documentation site (this site): mkdocs-material with mkdocstrings API reference, manual
pages for the model, file formats, numerics, power flow, results and the jobs API, mermaid
architecture and data-model diagrams, condensed design decisions, CI job
docs(mkdocs build --strict) and a GitHub Pages deploy workflow. tests/unit/test_docstrings.py: every public module, class, function and method inmambo_powermust carry a docstring.jobs: the stateless, JSON-serialisable job surface (ADR-004) —SolveRequest(kind, network, options, job_id),SolveResult(kind, job_id, status, result, error, provenance, warnings),StructuredError(code, message, issues, details), theKINDSregistry (KindSpec,register,kinds) withpf.acandpf.dc, andrun/run_json. Every failure is astatus="failed"result with a stable code (UNKNOWN_KIND,BAD_OPTIONS,VALIDATIONwith every issue,NO_SLACK_GENERATOR,BAD_REQUEST,INTERNAL); a non-converged power flow isstatus="ok"withconverged=False; warnings emitted by the solve are attached as strings. Manual page with executed examples and API reference page.pf.solve_ac(net, *, options=AcOptions()) -> AcPowerFlowResult: sparse polar Newton-Raphson AC power flow (MATPOWERnewtonpfformulation,scipy.sparse.linalg.splu), tolerance 1e-8 pu on the mismatch ∞-norm, flat or warm start (init="auto"|"flat"), reactive-limit enforcement with pandapower semantics (pin PV→PQ at the limit, never restore, slack never limited, ≤max_q_roundsrounds); non-convergence isconverged=False, never an exception.pf.ac_newton.newtonis the positional solver;results.ac_result_from_arraysbuilds the typed result. Parity with pandapowerrunppat machine precision on case14, case_ieee30, case57, case118 (Q-limits on) and case300 (Q-limits off and on), identical pinned sets; MATPOWER stored columns within 2e-3 pu / 0.5 deg outside the documented exclusions; case300 cold solve measured and echoed in CI.- Effective bus roles (
numerics.effective_roles,EffectiveRoles): a PV bus without an in-service generator solves as PQ, a slack without one raisesNoSlackGeneratorError, the last in-service generator's setpoint wins with aSetpointConflictWarningwhen setpoints differ. Both solvers andBusResult.role_effectiveuse the effective roles. - Island repair in importers (
model.repair_islands,model.repair_islands_entities): buses unreachable from the slack and their elements are deactivated before validation, reported as typedImportIssue(code="ISLAND_DEACTIVATED", bus_ids, element_ids); the model itself still rejects islands (DISCONNECTED_BUS).io.matpower.load_with_report/loads_with_reportreturn anImportReportof typed issues; theload_with_warningsstrings are the same entries renderedCODE: message. examples/: seven runnable scripts (load and validate, AC power flow, DC power flow, jobs API, roles and islands, network matrices, results and export), each run bytests/unit/test_examples_run.pyand by theexamplesCI job, and embedded byte-for-byte in the documentation's Examples gallery.- Documentation: the power-flow manual covers the AC solver as shipped (options, formulation,
Q-limit loop diagram, warm start, parity and timing tables); the model and formats manuals
document
ImportIssue,ImportReportandrepair_islands; getting started runs an AC power flow.
Added — wave M1 (substrate)¶
- uv-managed
src/layout with hatchling; ruff, mypy--strict, pytest tiersunit/parity/property; GitHub Actions CI on Ubuntu, macOS and Windows (Python 3.12) plus Ubuntu 3.11 and 3.13; pandapower and PyPSA installed as development-only oracles. model: pydantic v2NetworkwithBus,Branch,Generator(optionalPolynomialCost/PiecewiseCost),Load,Shunt,Storage,Zone,Geo. Physical units (MW, MVAr, kV, MWh, degrees), branch impedances in pu onbase_mva, stable string ids,in_servicebooleans,schema_version = 1.- All-issues validation:
NetworkValidationErrorcarrying everyValidationIssue(code, path, message)with codesNO_SLACK,MULTIPLE_SLACK,DISCONNECTED_BUS,DUPLICATE_ID,DANGLING_REF,BAD_BASE,BAD_RANGE;validate_networkas the public re-check; non-finite floats rejected; unknown fields rejected. Network.json_schema()with a committed snapshot test; native JSON round-trip is identity on every fixture (io.native:load,loads,save,dumps).io.matpower:load,loads,load_with_warnings,loads_with_warningsfor MATPOWER caseformat v2 files includinggencost(MODEL 1 and 2,2 * ngenrows tolerated);BASE_KV <= 0repaired to 1.0 with a warning; bus type 4 mapped to an out-of-service bus; BOM and CRLF tolerated;MatpowerImportErrorwith codesMISSING_BASE_MVA,MISSING_SECTION,UNTERMINATED_MATRIX,BAD_NUMBER,BAD_ROW. Parity with pandapowerfrom_mpcon case14, case30, case_ieee30, case57, case118.numerics:NetworkArrays(in-service positional view, the single per-unit conversion site),ybus/yf_yt(MATPOWERmakeYbus),bbus/bf/p_shift(makeBdc),ptdf(sparse LU, zero slack column),lodfwithNaNbridge columns, and graph-theoreticbridges. Ybus parity with pandapower within 1e-9; PTDF/LODF checked against dense re-derivation and brute-force outages; hypothesis property tests over random radial and meshed networks.- Packaging:
uv buildwheel ships only the package andpy.typed; sdist carriestests/andfixtures/; CI installs both into clean virtual environments and loads case14. - Fixtures:
case14,case30,case_ieee30,case57,case118withPROVENANCE.mdandSOURCES.md.
Changed¶
- The API reference renders every pydantic model's fields. Result and options models put their
prose in
Field(description=...), which is a call argument rather than a docstring, so the documentation generator saw undocumented attributes and dropped them:mambo_power.resultspublished two field entries across every result type on it, andMarketZonalResult's field names occurred nowhere on the site. They now render with their descriptions, and an attribute's shown default is the field's actual default rather than the wholeField(...)call. M6. opf.dc_opf's cost/bid extraction and both convexity guards are one shared helper, so the nodal, multiperiod, zonal and redispatch builders cannot get them subtly different — they do not each implement them. Extracted and proved behaviour-preserving before any zonal row was written; no public behaviour changed. M6.Scenario.periodsaccepts at most 200 entries andMarketZonalOptions.corridorsat most 500. An unbounded list in a network-facing model is an unbounded solve; 200 periods is more than eight days at hourly resolution, and 500 corridors is a complete graph on 32 zones. The corridor list is echoed back inprovenance.options, so its bound holds the response down too. Both are listed on Manual > Jobs API. M6.jobs.SolveRequestnow takes exactly one ofnetworkandscenario; neither or both is aValueError, andBAD_REQUESTthroughrun_json. A request carrying only anetwork— every M2–M4 caller, and every stored request JSON — keeps working unchanged, wrapped as a single-periodScenariobyresolved_scenario. That wrap left the individual runners as a result, and every kind now runs through one uniformRunner. M5.opf.dc_opf's nodal-balance, PTDF flow-limit, epigraph and hypograph row families are built by internal helpers rather than inline, soopf.multiperiod_dc_opfcalls the same code once per period instead of carrying a second copy of it. Extracted and proved behaviour-preserving before any multiperiod row existed; no public behaviour changed. M5.opf.gen_cost_coeffsandmarket.load_bid_coeffsare public, somarket.nodalandmarket.multiperiodshare one cost extraction and one bid extraction rather than each carrying its own copy. M4 for the generator side, M5 for the demand side.- A quadratic
GeneratorCostwithc2 < 0is now rejected asNonConvexCostErrorbefore any solve. M3 checked convexity only for piecewise costs; the quadratic gap was found while building the demand-side guard and closed in the same commit, rather than shipping an asymmetric check. M4. - The settlement identity on the nodal-market page is stated in
its narrow form,
congestion_rent == -Σ_k μ_k · flow_k, which is exact only on a network with no phase-shifting transformer and no bus shunt conductance — every fixture M4's own tests use, but notcase300. The general form, with both correction terms, is on the multiperiod page. The value was always right; it is the name that is narrower than the number. M5. - MATPOWER repair warnings are now
CODE: messagestrings (BASE_KV_REPLACED,GENCOST_REACTIVE_IGNORED,ISLAND_DEACTIVATED) — M2, with island repair. - The typed import-issue record is
model.ImportIssue(ImportIssueCode); it was briefly namedImportWarningon the wave branch, which shadowed the Python built-in. Behaviour is unchanged. fixtures/matpower/PROVENANCE.md, case300: the reference-solution wording now carries the measured residual against the AC solver (8.5e-3 pu worst, 11 of 300 buses beyond 2e-3) and withdraws the earlier "0.107 pu" and "pandapower cannot converge with Q-limits" figures, which came from a tap-side defect in the research's oracle copy, not from the data.