mambo_power.market¶
Market clearing at three granularities — one period nodally, a whole horizon, and one period zonally with the redispatch that makes it deliverable — and, since M7, a fourth mode in which the supply curve is not read from the network but decided: generators bid, and the market clears their offers. See the nodal manual page for the elastic-demand formulation, LMP/settlement decomposition, the price-taker reduction and the oracle convention, the multiperiod manual page for ramp coupling, storage state of charge, the cyclic horizon and the per-period settlement, and the zonal manual page for the three-solve chain, corridors, and the three separated gap figures.
mambo_power.market
¶
Market clearing (epic Design §2 market/): welfare-maximizing DC-OPF over generator costs
and load bids, decomposed into LMPs and settlement. Built directly on
mambo_power.opf.dc_opf/lmp_decomposition per ADR-006's reuse seam.
Four entry points. Two share a shape at two horizons:
solve_nodal clears one period, and
solve_multiperiod clears a Scenario's whole horizon as
one coupled LP/QP with ramp coupling and storage; a one-period multiperiod clearing reproduces the
nodal one exactly (wave M5 AC-4). The third, solve_zonal, clears
one period at zonal granularity and then redispatches onto the real network, reporting what that
market design costs against the nodal optimum -- so it drives three solves rather than one, and
solve_nodal is one of them. The fourth, solve_agents, is the
first whose supply curve is decided rather than read from the network: generators bid through a
Strategy, round after round, and the market clears their
offers until the offer vector settles (wave M7).
AgentSetError
¶
Bases: ValueError
A caller mistake in the agent set -- how options.strategies (or the in-process
strategies argument) relates to the network -- caught before any solve starts.
A ValueError subclass, deliberately, for the same two reasons as
UnzonedBusError. It stays a ValueError because that is
what solve_agents has always raised for these and what an in-process caller catches.
It is a distinguishable type because jobs' runner cannot otherwise tell it apart from
any other ValueError a solve might raise -- and the clearing's own
NonConvexCostError / NonConcaveBidError are
ValueError subclasses. Catching bare ValueError relabelled an engine rejection of a
non-convex cost as VALIDATION at options.strategies, a field the caller need not have
set, while market.nodal reported the same network as INTERNAL (audit finding 2, M7
S10). Only this type maps to VALIDATION; everything else keeps the verdict every other
kind gives it.
Raised by _resolve_agents (two agent sources at once, a strategy on a generator the
network does not have, one its arrays do not carry, one with no cost, a
MarkupStrategy step too coarse for offer_tol) and by
_initial_offers (a strategy that cannot bid on its generator's true cost).
MarketAgentsOptions
¶
Bases: BaseModel
Options of a market.agents run: who bids, how long the loop may run, and what counts
as settled.
Sits beside solve_agents the way
MarketZonalOptions sits beside its own solver. Like that
one, its fields are market-design data rather than solver tuning -- which strategy each
generator plays is a choice about the game being simulated, not a knob on HiGHS.
strategies
class-attribute
instance-attribute
¶
strategies: dict[str, StrategyConfig]
Generator id -> the bidding rule that generator plays. A generator not named here is not an agent: it offers its own true cost, exactly as market.nodal would clear it. An empty mapping is therefore meaningful and not a missing argument -- it is a market in which nobody bids strategically.
max_iterations
class-attribute
instance-attribute
¶
Most best-response update rounds to run after round 0 (which is the initial offer and responds to nothing). Reaching it ends the run with termination_reason == "iteration_cap" and converged False; it is never reported as a cycle, and a cycle is never reported as it.
offer_tol
class-attribute
instance-attribute
¶
Largest offer-vector oscillation amplitude, in cost-coefficient units, that still counts as converged once the loop detects a repeated state. This is a derived quantity, not a tuning knob: a fixed-step climber settles into an oscillation of two steps about an on-grid optimum and three about a half-grid one, so a markup agent of step s needs offer_tol >= 3*s (MarkupStrategy.min_offer_tol) -- which the validator below enforces rather than hopes for. The default admits only an offer vector that has genuinely come to rest, which is what an all-price-taker run does.
MarketMultiperiodOptions
¶
Bases: BaseModel
Options of a market.multiperiod clearing.
No fields yet, for the same reason MarketNodalOptions has
none: a solver-tuning field is added the first time a caller actually needs one. It exists
now, rather than being omitted, because the options model is what the registered
market.multiperiod jobs kind validates a request against -- and because the
array-level builder
deliberately takes no options parameter at all, so this is the one place multiperiod
options can live.
MarketNodalOptions
¶
Bases: BaseModel
Options of a market.nodal clearing.
No fields yet: mirrors OpfDcOptions's own precedent (a
solver-tuning field is added the first time a caller actually needs one, not invented
speculatively). It exists rather than being omitted because the market.nodal
KindSpec names it as the model every request's options is
validated against, and a kind with no options model rejects any key at all.
CorridorLimit
¶
Bases: BaseModel
One inter-zonal corridor's transfer capacity — an entry of
MarketZonalOptions.corridors.
Why this is an option and not a model field. A corridor capacity is a transfer limit
between two zones, and the domain model deliberately has no transfer-capacity entity,
because a real NTC is administratively negotiated data that no network file carries and no
branch rating uniquely determines. So capacities are supplied per solve, by the caller who
knows them. A defensible default, if you need one, is the sum of rating_mva over the pair's
cut-set — which is what tests/_zones.py's corridors() builds.
Why a row model rather than a {(z1, z2): cap} mapping. The mapping is the shape the
array-level builder takes and the shape MarketZonalOptions.corridor_map hands it. It
is not a shape a pydantic options model can carry, because a dict keyed by a tuple does
not survive a JSON round trip: pydantic serialises the key ("1", "2") to the string
"1,2" and then refuses to validate that string back into a tuple. An options model that
cannot round-trip through JSON is a jobs request form that cannot round-trip either, and
an exact JSON round trip is a standing requirement on every kind — so the serialisable shape is
the one stored, and the mapping is derived on the way to the builder.
zone1
class-attribute
instance-attribute
¶
One end of the corridor: a zone id present in the network. A zone id no bus carries is rejected at solve time, when the network is in hand (jobs: VALIDATION).
zone2
class-attribute
instance-attribute
¶
The other end; must differ from zone1, and the resulting unordered pair must not appear elsewhere in the list. Both are enforced on MarketZonalOptions itself, before any solve (jobs: BAD_OPTIONS).
cap_mw
class-attribute
instance-attribute
¶
Transfer capacity, MW, as a magnitude: the corridor is bounded at [-cap_mw, +cap_mw], so it constrains both directions equally. 0 is allowed and means a tie that exists but can carry nothing. null means unbounded -- the copper plate: the corridor stays in the LP with no bound at all, which is a different market from deleting the entry (that islands the two zones and reports no capacity price for them). A number must be finite: inf is rejected, so the copper plate has exactly one spelling on this model and it is one every JSON parser reads.
MarketZonalOptions
¶
Bases: BaseModel
Options of a market.zonal clearing.
Unlike MarketNodalOptions and
MarketMultiperiodOptions, this one has a field from
the start, and it is not solver tuning: corridors is market design data the model
deliberately does not carry (see CorridorLimit). An empty list is a meaningful
default and not a missing argument -- it means no zone pair may exchange anything, so each
zone must supply itself.
corridors
class-attribute
instance-attribute
¶
corridors: list[CorridorLimit]
Transfer capacity per tied zone pair. A pair absent from this list has no corridor at all and so cannot exchange power -- which is a stronger statement than a corridor of capacity 0 only in that no capacity shadow price is reported for it. At most MAX_CORRIDORS (500) entries -- a request/response-size guard, since this list is echoed verbatim into every result's provenance.options; 500 exhausts a 32-zone network, which is above every zonal market in operation.
corridor_map
¶
corridor_map() -> dict[ZoneKey, float]
corridors as the {(zone1, zone2): cap_mw} mapping
zonal_dc_opf takes. Keys are left in the order given; the
builder normalises each to sorted order. This is a dict comprehension and so cannot report
a repeated key -- which is exactly why the repeat is rejected on the model above, before
any mapping is built.
This is also where the copper plate changes spelling. On the wire an unbounded corridor is
null, because that is what JSON has; the array-level builder wants inf, because
that is what HiGHS has (it maps it to kHighsInf). Neither layer has to know about the
other's, and the translation is one line here rather than a non-standard token in every
request and response body.
Source code in src/mambo_power/market/zonal.py
MissingCostError
¶
Bases: ValueError
A generator has no cost (Generator.cost is None) and the caller supplied no override
for it, so there is nothing to price its dispatch with. The message names the public
remedies only -- Generator.cost, or in_service = False -- since the costs=
overlay is gen_cost_coeffs's own parameter, filled by market.agents from the
strategies, and not reachable from any solve_* (M8 critic nit 23).
Raised by mambo_power.opf.gen_cost_coeffs before any solve is attempted (M8 walk,
surprise 3): a cost-less generator used to get an all-zero coefficient row, which priced it
at zero and let a network with no economic data at all -- every RAW import, a MATPOWER case
without gencost -- clear an OPF at objective_cost 0.0 with all load on one free unit,
a wrong-but-optimal-looking dispatch of the same class NonConvexCostError refuses.
The message names every offending generator id; generator_ids carries them.
Source code in src/mambo_power/opf/dc_opf.py
NonConcaveBidError
¶
Bases: ValueError
A demand bid is non-concave: either a piecewise-linear bid's breakpoint slopes are not
non-increasing, or a quadratic (polynomial) bid has v2 > 0.
The demand-side mirror of NonConvexCostError (module docstring, "Elastic demand"),
raised by dc_opf before any HiGHS object is created: the concave segment/hypograph LP
encoding, and the QP Hessian's positive semi-definiteness (built from −v2), are only valid
for a concave value curve — silently solving a non-concave one would give a
wrong-but-optimal-looking dispatch rather than fail loudly (research §1.1, §1.2).
NonConvexCostError
¶
Bases: ValueError
A generator cost is non-convex: either a PiecewiseCost's
breakpoint slopes are not non-decreasing, or a quadratic cost has c2 < 0.
Raised by dc_opf before any HiGHS object is created (module docstring, "PWL costs" /
"Elastic demand"): the convex segment/epigraph LP encoding, and the QP Hessian's positive
semi-definiteness, are only valid for a convex cost, and silently solving a non-convex one
would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §2.1, §1.2).
opf-local — PiecewiseCost itself validates only
strictly-increasing p_mw, not convexity (record/m3-research.md §2.3).
solve_agents
¶
solve_agents(
scenario: Scenario,
options: MarketAgentsOptions | None = None,
*,
strategies: Mapping[str, Strategy] | None = None,
) -> MarketAgentsResult
Run the best-response loop of scenario.network (module docstring) and return the final
round's clearing beside how the loop ended.
The in-process seam. strategies maps a generator id to any structurally-conforming
Strategy object, and is used instead of
options.strategies -- giving both raises, so an agent set always has exactly one source
and the result can say which rule ran. This is deliberate design, not a hole left open for a
test: it is the surface for a caller whose bidding rule StrategyConfig cannot
express -- a rule with parameters the union does not carry, or one belonging to the caller
rather than to this library -- and it is the reason
Strategy is a structural
typing.Protocol (design D3(a)) rather than a closed union. Without it the Protocol
would be decorative, since nothing would ever accept an object that merely conforms to it.
Only the config union crosses JSON, so only options.strategies can reach this through
jobs, and the wave's own jobs coverage (AC-6) is unaffected by anything passed here.
provenance.options echoes options either way, which is why
strategy -- carrying the config kind
or, for an injected object, its class name -- and not the provenance, is the record of which
rule actually produced each offer.
Never raises for an infeasible or unbounded LP: a round that fails to clear ends the run and
is reported through status/message, mirroring
mambo_power.market.nodal.solve_nodal's never-raise convention. Does raise
AgentSetError (a ValueError) up front for a caller mistake in the agent set (see
_resolve_agents),
and NonConvexCostError /
NonConcaveBidError for a cost or bid the clearing cannot
accept -- including an offer a strategy produced, which is checked on the offer, every
round, exactly as it would be on a true cost -- and TypeError at the call site, before
that round's clearing, for a strategy whose offer returned something other than a
GeneratorCost (see _checked_offer). A strategy that
cannot bid on its generator's
true cost at all (MarkupStrategy on a non-linear cost,
which raises NotImplementedError from its own offer) is one of the up-front
ValueError cases: _initial_offers collects round 0's offers before the first
clearing and re-raises that error with the generator id, so the mistake
reaches jobs as VALIDATION like the other four rather than escaping the loop as
INTERNAL.
Scenario and Network are not modified -- the offers reach the clearing as coefficients
(AC-2).
Source code in src/mambo_power/market/agents.py
490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 | |
solve_multiperiod
¶
solve_multiperiod(
scenario: Scenario,
options: MarketMultiperiodOptions | None = None,
) -> MarketMultiperiodResult
Clear scenario over its whole horizon as one coupled LP/QP (module docstring):
per-period dispatch, per-bus LMPs, per-storage charge/discharge/SoC, per-period settlement,
and horizon totals.
scenario.periods is None clears a single period from the network's own loads, reproducing
mambo_power.market.nodal.solve_nodal exactly (wave AC-4).
Never raises for an infeasible or unbounded LP/QP -- reported through
MarketMultiperiodResult.status/message, mirroring solve_nodal's never-raise
convention. Raises NonConvexCostError up front for a
non-convex generator cost, NonConcaveBidError for a
non-concave load bid, and ValueError for a ramp limit of exactly zero (which would
freeze a unit for the whole horizon) -- all before any solve is attempted. The scenario is
not modified.
Source code in src/mambo_power/market/multiperiod.py
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 | |
load_bid_coeffs
¶
load_bid_coeffs(
net: Network, arr: NetworkArrays
) -> tuple[PolyBidCoeffs, PwlBids]
Per-load (v2, v1, v0) plus any PWL bids, from Load.bid -- the demand-side mirror
of mambo_power.opf.gen_cost_coeffs. A load with no bid (bid is None) contributes
to neither mapping, so dc_opf leaves it purely on the
fixed-RHS side (its module docstring, "Elastic demand").
Public (not module-private) for the same reason
gen_cost_coeffs is:
mambo_power.market.multiperiod.solve_multiperiod needs the identical bid extraction
and calls this rather than carrying a second copy (M4 review Duplication FLAG, M4/R2's own
resolution applied to the demand side).
Source code in src/mambo_power/market/nodal.py
solve_nodal
¶
solve_nodal(
scenario: Scenario,
options: MarketNodalOptions | None = None,
) -> MarketNodalResult
Welfare-maximizing DC-OPF of scenario.network (module docstring): dispatch
(generators and every load, bid or fixed), per-bus LMPs, and settlement.
Never raises for an infeasible or unbounded LP/QP -- reported through
MarketNodalResult.status/message, mirroring mambo_power.opf.solve_dc_opf's
never-raise convention. Raises NonConvexCostError up front
for a non-convex generator cost and NonConcaveBidError for
a non-concave load bid (both before any solve is attempted). The network is not modified.
Source code in src/mambo_power/market/nodal.py
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | |
solve_zonal
¶
solve_zonal(
scenario: Scenario,
options: MarketZonalOptions | None = None,
) -> MarketZonalResult
Clear scenario.network zonally, redispatch it onto the real network, and compare the
result against the nodal optimum (module docstring).
options.corridors supplies each tied zone pair's transfer capacity; the zone partition is
read from Bus.zone. With no corridors at all, every zone must supply itself -- a
legitimate (and often infeasible) market design, not an error.
Never raises for an infeasible or unbounded stage -- reported through
MarketZonalResult.status/message, naming the stage. Raises ValueError for a
bus with no zone or a malformed corridor list, :class:~mambo_power.opf.dc_opf.
NonConvexCostError / NonConcaveBidError for a cost or bid
curve the shared extractor rejects -- all before any solve is attempted. The network is not
modified.
Source code in src/mambo_power/market/zonal.py
560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 | |
zone_partition
¶
zone_partition(
net: Network, arr: NetworkArrays
) -> dict[str, str]
{bus id: zone id} for every bus NetworkArrays keeps,
read straight off Bus.zone.
Public for the same reason gen_cost_coeffs and
load_bid_coeffs are: it is the model-to-solver extraction
step for one more kind of network data, and a caller driving
zonal_dc_opf directly needs exactly this mapping.
Raises UnzonedBusError -- a ValueError subclass carrying every offending bus id --
if any kept bus has zone is None. A partition with a hole has no defensible repair: that
bus's load and generation must enter some zone's balance row, and choosing one for the caller
would clear a market for a network they did not describe. (Buses NetworkArrays drops -- out
of service, or on an islanded component -- are not consulted: they have no columns and no load
in the LP.)
Bus.zone is legitimately optional in the model, so this is not something
validate_network can catch: every other kind solves an unzoned
network happily. It is a requirement of this analysis, which is why it is raised here and
why the market.zonal runner is what translates it into a VALIDATION failure.
Source code in src/mambo_power/market/zonal.py
Welfare LP over a Scenario¶
mambo_power.market.nodal
¶
market.nodal clearing: the Scenario-facing welfare-maximizing DC-OPF wrapper.
solve_nodal mirrors mambo_power.opf.solve_dc_opf (same provenance/PTDF-reuse/
id-keyed-result shape) but pulls both generator costs (Generator.cost) and load bids
(Load.bid) from scenario.network, calls the extended
mambo_power.opf.dc_opf.dc_opf with both, and decomposes the result into per-bus LMPs
(mambo_power.opf.dc_opf.lmp_decomposition, M3's, reused verbatim per ADR-006) plus
settlement (payments, receipts, congestion rent): total load payment minus total generator
receipts equals the congestion rent, i.e. -sum(mu_k * flow_k) over the binding branches
(see the wave spec's AC-4 for the exact identity and its proof).
Branch rows (M7 W4, AC-8). dc_opf's own OpfSolution
carries no per-branch flow -- only the PTDF matrix and the flow-limit duals -- so the flow
flow_k = PTDF[k] . (net injection) + phase-shift injection is derived from the dispatch already
solved for, in mambo_power.market._clearing.clearing_rows: one construction, shared with
mambo_power.market.agents.solve_agents (M7 S11), and not a parallel formula -- see that
module's docstring for the derivation and the AC-8 readback that checks it.
NonConcaveBidError
¶
Bases: ValueError
A demand bid is non-concave: either a piecewise-linear bid's breakpoint slopes are not
non-increasing, or a quadratic (polynomial) bid has v2 > 0.
The demand-side mirror of NonConvexCostError (module docstring, "Elastic demand"),
raised by dc_opf before any HiGHS object is created: the concave segment/hypograph LP
encoding, and the QP Hessian's positive semi-definiteness (built from −v2), are only valid
for a concave value curve — silently solving a non-concave one would give a
wrong-but-optimal-looking dispatch rather than fail loudly (research §1.1, §1.2).
NonConvexCostError
¶
Bases: ValueError
A generator cost is non-convex: either a PiecewiseCost's
breakpoint slopes are not non-decreasing, or a quadratic cost has c2 < 0.
Raised by dc_opf before any HiGHS object is created (module docstring, "PWL costs" /
"Elastic demand"): the convex segment/epigraph LP encoding, and the QP Hessian's positive
semi-definiteness, are only valid for a convex cost, and silently solving a non-convex one
would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §2.1, §1.2).
opf-local — PiecewiseCost itself validates only
strictly-increasing p_mw, not convexity (record/m3-research.md §2.3).
MarketNodalOptions
¶
Bases: BaseModel
Options of a market.nodal clearing.
No fields yet: mirrors OpfDcOptions's own precedent (a
solver-tuning field is added the first time a caller actually needs one, not invented
speculatively). It exists rather than being omitted because the market.nodal
KindSpec names it as the model every request's options is
validated against, and a kind with no options model rejects any key at all.
load_bid_coeffs
¶
load_bid_coeffs(
net: Network, arr: NetworkArrays
) -> tuple[PolyBidCoeffs, PwlBids]
Per-load (v2, v1, v0) plus any PWL bids, from Load.bid -- the demand-side mirror
of mambo_power.opf.gen_cost_coeffs. A load with no bid (bid is None) contributes
to neither mapping, so dc_opf leaves it purely on the
fixed-RHS side (its module docstring, "Elastic demand").
Public (not module-private) for the same reason
gen_cost_coeffs is:
mambo_power.market.multiperiod.solve_multiperiod needs the identical bid extraction
and calls this rather than carrying a second copy (M4 review Duplication FLAG, M4/R2's own
resolution applied to the demand side).
Source code in src/mambo_power/market/nodal.py
solve_nodal
¶
solve_nodal(
scenario: Scenario,
options: MarketNodalOptions | None = None,
) -> MarketNodalResult
Welfare-maximizing DC-OPF of scenario.network (module docstring): dispatch
(generators and every load, bid or fixed), per-bus LMPs, and settlement.
Never raises for an infeasible or unbounded LP/QP -- reported through
MarketNodalResult.status/message, mirroring mambo_power.opf.solve_dc_opf's
never-raise convention. Raises NonConvexCostError up front
for a non-convex generator cost and NonConcaveBidError for
a non-concave load bid (both before any solve is attempted). The network is not modified.
Source code in src/mambo_power/market/nodal.py
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | |
Multiperiod clearing over a horizon¶
mambo_power.market.multiperiod
¶
market.multiperiod clearing: the Scenario-facing wrapper over the multiperiod builder.
solve_multiperiod is the exact multiperiod sibling of
mambo_power.market.nodal.solve_nodal and sits at the same altitude: it is the
model-side extraction and settlement layer over
mambo_power.opf.multiperiod.multiperiod_dc_opf, precisely as market.nodal sits over
mambo_power.opf.dc_opf.dc_opf. Nothing numeric happens here; what happens here is turning
Scenario data into the array-level builder's arguments, and turning its solution back into
id-keyed rows with a settlement attached.
What is extracted, and from where.
- Generator costs --
mambo_power.opf.gen_cost_coeffs, shared verbatim withmarket.nodal(M4's Step-6 review raised a Duplication FLAG over exactly this, and M4/R2 made the helper public so both market modules could call the one copy). - Load bids --
mambo_power.market.nodal.load_bid_coeffs, likewise shared rather than copied. Bids are horizon-invariant: per-period offers and bids are the wave's own Not-Doing list. - Per-period fixed load -- each
Period'sload_p_mwresolved intoNetworkArrays.load_idspositions, with a load the period's dict omits falling back to its ownLoad.p_mw(Periodis an override, not a complete specification). - Ramp limits --
Generator.ramp_up_mw/ramp_down_mwgathered into(n_gen,)arrays the same waygen_cost_coeffsgathers costs.NetworkArrayscarries no ramp fields; the ramp data lives on the entity, so this is where it becomes an array. - Per-period LMPs --
mambo_power.opf.dc_opf.lmp_decomposition(M3's, unmodified), fed periodt's own balance and flow-limit duals against the single PTDF matrix the builder already returned.
A period-less scenario is a one-period horizon. Scenario.periods is None means
single-period (the model's own documented meaning), so solve_multiperiod clears T = 1
with period_load_mw=None -- which makes the builder's fixed-load and flow-constant
expressions literally dc_opf's, so the result is bit-for-bit market.nodal's (wave
AC-4). It is not an error and not a special case; it is the degenerate end of the same code path.
Settlement. Payments, receipts and congestion rent are computed per period, directly from
that period's LMPs and dispatch. Storage is settled as the third participant it physically is --
it pays for what it stores and is paid for what it returns -- and
mambo_power.results.multiperiod states the identity that makes that necessary rather than
decorative, together with the general form's pf_shift/g_shunt correction terms.
NonConcaveBidError
¶
Bases: ValueError
A demand bid is non-concave: either a piecewise-linear bid's breakpoint slopes are not
non-increasing, or a quadratic (polynomial) bid has v2 > 0.
The demand-side mirror of NonConvexCostError (module docstring, "Elastic demand"),
raised by dc_opf before any HiGHS object is created: the concave segment/hypograph LP
encoding, and the QP Hessian's positive semi-definiteness (built from −v2), are only valid
for a concave value curve — silently solving a non-concave one would give a
wrong-but-optimal-looking dispatch rather than fail loudly (research §1.1, §1.2).
NonConvexCostError
¶
Bases: ValueError
A generator cost is non-convex: either a PiecewiseCost's
breakpoint slopes are not non-decreasing, or a quadratic cost has c2 < 0.
Raised by dc_opf before any HiGHS object is created (module docstring, "PWL costs" /
"Elastic demand"): the convex segment/epigraph LP encoding, and the QP Hessian's positive
semi-definiteness, are only valid for a convex cost, and silently solving a non-convex one
would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §2.1, §1.2).
opf-local — PiecewiseCost itself validates only
strictly-increasing p_mw, not convexity (record/m3-research.md §2.3).
MarketMultiperiodOptions
¶
Bases: BaseModel
Options of a market.multiperiod clearing.
No fields yet, for the same reason MarketNodalOptions has
none: a solver-tuning field is added the first time a caller actually needs one. It exists
now, rather than being omitted, because the options model is what the registered
market.multiperiod jobs kind validates a request against -- and because the
array-level builder
deliberately takes no options parameter at all, so this is the one place multiperiod
options can live.
solve_multiperiod
¶
solve_multiperiod(
scenario: Scenario,
options: MarketMultiperiodOptions | None = None,
) -> MarketMultiperiodResult
Clear scenario over its whole horizon as one coupled LP/QP (module docstring):
per-period dispatch, per-bus LMPs, per-storage charge/discharge/SoC, per-period settlement,
and horizon totals.
scenario.periods is None clears a single period from the network's own loads, reproducing
mambo_power.market.nodal.solve_nodal exactly (wave AC-4).
Never raises for an infeasible or unbounded LP/QP -- reported through
MarketMultiperiodResult.status/message, mirroring solve_nodal's never-raise
convention. Raises NonConvexCostError up front for a
non-convex generator cost, NonConcaveBidError for a
non-concave load bid, and ValueError for a ramp limit of exactly zero (which would
freeze a unit for the whole horizon) -- all before any solve is attempted. The scenario is
not modified.
Source code in src/mambo_power/market/multiperiod.py
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 | |
Zonal clearing and redispatch¶
The three-solve chain: a zonal clearing, a minimum-cost redispatch onto the real network, and
market.solve_nodal as the reference. Its module docstring states where each of the three
reported figures comes from and why the third one is not sign-constrained.
mambo_power.market.zonal
¶
market.zonal clearing: zonal market, min-cost redispatch, nodal reference — and what the
distance between them costs.
solve_zonal is the third Scenario-facing market entry point, at exactly the altitude
solve_nodal and
solve_multiperiod sit at: model-side extraction and
settlement over array-level builders that do the numerics. Nothing here builds a row or a column.
What is new is that it drives three solves instead of one, and that its result is their
relationship rather than any one of them.
The chain, in order, and why each stage is there.
- Zones off the model.
Bus.zoneandnet.zoneshave been in the schema from the start and are populated by every MATPOWER import; this is where they become solver input. The partition is read, never derived: a bus with no zone is an error, because there is no defensible default for whose balance row its load belongs in. - Zonal clearing —
zonal_dc_opfon that partition and the caller's corridor capacities. One price per zone, the intra-zone grid ignored, inter-zonal exchange bounded by one corridor variable per tied zone pair. This is the market the participants actually clear in, and its schedule is generally not something the real network can carry. - Min-cost redispatch —
redispatch_dc_opffrom that schedule, with the true cost and bid curves in the objective and the real PTDF flow rows reinstated. This is the operator's action after the market closes. - The nodal reference —
solve_nodalon the same scenario. It is the yardstick, and it is a genuinely separate solve rather than a quantity inferred from stage 3, precisely because stage 3's agreement with it is the thing the tests assert. Inferring the reference from the thing being tested would make that assertion vacuous. - Composition into
MarketZonalResult.
Why stage 4 is not redundant, even though its answer is predictable. The redispatch
objective is the true welfare function over nodal's exact feasible set, so the redispatched point
is the nodal optimum — welfare_gap is 0 by
theorem. That makes stage 4 a check on the chain rather than a source of new information, and
a check is worth its solve: it is the one thing that would notice if the redispatch LP's feasible
set quietly stopped being nodal's. It also supplies the reference welfare and generation cost
that the other two figures are measured against.
Where each of the three figures comes from, computed one way. Welfare figures are needed at
three different points — the zonal schedule, the final schedule and the nodal optimum — and only
some of them are reported by the solve that produced them (:class:~mambo_power.opf.zonal.
ZonalSolution has a generation cost but no demand value; :class:~mambo_power.results.
MarketNodalResult has neither). Rather than mix reported figures with derived ones, this module
evaluates the true cost and bid curves itself at all three points, through the one pair of
helpers _generation_cost / _demand_value, so that every difference taken below is
like-for-like. Those helpers are an independent evaluation path from the LPs' own epigraph/
hypograph encoding, and tests/unit/test_market_zonal.py asserts they agree with
objective_cost /
demand_value — an agreement between two
constructions, not a tautology.
redispatch_payment = [cost(final) - cost(zonal)] + [value(d_zonal) - value(d_final)]— the settlement figure the operator actually pays: extra generation cost plus curtailment compensation at bid value. Algebraically identical towelfare(zonal) - welfare(final), which is why it is non-negative wherever the zonal LP is a relaxation of the nodal one: it is exactly the welfare the zonal clearing promised and the network could not deliver.welfare_gap = welfare(nodal) - welfare(final)— the exactness row;0by the theorem above.generation_cost_gap = cost(zonal) - cost(nodal)— the unsigned diagnostic.
The three are two quantities and a combination, and the combination is worth naming. By that
same theorem cost(final) == cost(nodal), so with A = cost(final) - cost(zonal) and
B = value(d_zonal) - value(d_final) the three fields are A + B, 0 and -A. Hence
``redispatch_payment + generation_cost_gap == value(d_zonal) - value(d_final)``
exactly: adding the diagnostic to the settlement cancels the generation-cost term and leaves the
curtailment compensation on its own. That is the whole of what the third field adds — on a
network where no load carries a bid curve it is identically zero and generation_cost_gap is
precisely -redispatch_payment, while on rated case30 with bids it is 0.94 of a 14.51 $/h
payment. Read the pair that way and the fields stop looking redundant; read them as three
independent numbers and a sign flip will pass for information.
Why the diagnostic measures the zonal point and not the final one. The obvious
definition of a generation-cost gap is cost(final) - cost(nodal), and it is the informative one
under an anchored-rate redispatch objective — that objective lands somewhere other than nodal,
and it can land at strictly lower generation cost while destroying welfare. The anchored rate was
rejected (see mambo_power.opf.redispatch), and under true curves
cost(final) - cost(nodal) is identically zero: the same theorem that makes welfare_gap zero
makes it zero, so it would be a second copy of the exactness row rather than a diagnostic. The
quantity that survives is the zonal point's cost against nodal's, and the warning survives with
it, assumption-free: welfare is what the relaxation argument orders, generation cost is not, so a
zonal clearing can be welfare-better and generation-cost-cheaper or dearer than nodal. That is
the figure this module reports, and the reason its description insists it is not
sign-constrained.
Never raises for a solve that does not converge. A non-Optimal stage — zonal, redispatch
or nodal — comes back as status plus a message naming that stage, this package's standing
convention. Malformed input still raises up front, and which exception it raises decides how
a caller of mambo_power.jobs.run is told whose mistake it was. A corridor list that is
ambiguous on its own — a self-pair, the same unordered pair twice — is rejected by
MarketZonalOptions's validator before any solve, so it arrives as BAD_OPTIONS. A
corridor naming a zone the network does not have is only detectable once both are in hand, so
_reject_corridors_naming_absent_zones raises
NetworkValidationError with a DANGLING_REF issue and it arrives as
VALIDATION. A bus carrying no zone is the same kind of mistake one layer further in, and
arrives the same way: zone_partition raises UnzonedBusError and the runner
translates it, one DANGLING_REF issue per offending bus. A non-convex generator cost and a
non-concave load bid raise their own typed errors.
MAX_CORRIDORS
module-attribute
¶
Upper bound on MarketZonalOptions.corridors' length: a request/response-size guard, not
a solver limit — the same guard MAX_PERIODS puts on
Scenario.periods, applied to the other user-supplied list this package takes.
The honest bound is the network's own: a partition into n zones admits at most n(n-1)/2
distinct pairs, and a corridor list longer than that necessarily repeats one (now rejected on its
own). But n is a property of the network and this is an options model, which has none — so the
bound here is a fixed number chosen to sit above every network anyone clears zonally and below the
sizes that make the response a problem. corridors is echoed verbatim into every result's
provenance.options, so the list's length is paid twice, once inbound and once out.
500 covers a 32-zone network exhaustively (496 pairs, measured 22,025 bytes of options JSON), and 32 zones is already above Europe's day-ahead market, the largest zonal design in operation at around 25 bidding zones. Above it, growth is quadratic and unbounded in an options field: 200 zones is 19,900 corridors and 913,425 bytes echoed back per solve. Review F2 measured 20,000 entries accepted before this bound existed.
NonConcaveBidError
¶
Bases: ValueError
A demand bid is non-concave: either a piecewise-linear bid's breakpoint slopes are not
non-increasing, or a quadratic (polynomial) bid has v2 > 0.
The demand-side mirror of NonConvexCostError (module docstring, "Elastic demand"),
raised by dc_opf before any HiGHS object is created: the concave segment/hypograph LP
encoding, and the QP Hessian's positive semi-definiteness (built from −v2), are only valid
for a concave value curve — silently solving a non-concave one would give a
wrong-but-optimal-looking dispatch rather than fail loudly (research §1.1, §1.2).
NonConvexCostError
¶
Bases: ValueError
A generator cost is non-convex: either a PiecewiseCost's
breakpoint slopes are not non-decreasing, or a quadratic cost has c2 < 0.
Raised by dc_opf before any HiGHS object is created (module docstring, "PWL costs" /
"Elastic demand"): the convex segment/epigraph LP encoding, and the QP Hessian's positive
semi-definiteness, are only valid for a convex cost, and silently solving a non-convex one
would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §2.1, §1.2).
opf-local — PiecewiseCost itself validates only
strictly-increasing p_mw, not convexity (record/m3-research.md §2.3).
UnzonedBusError
¶
Bases: ValueError
A network zone_partition cannot partition: at least one bus the solve keeps carries
no Bus.zone.
A ValueError subclass, deliberately. It has to stay a ValueError because that
is what zone_partition has always raised and what a caller driving it directly catches.
It has to be a distinguishable type because jobs' runner cannot otherwise tell this
apart from any other ValueError a solve might raise, and the difference matters: this one
is the caller's network data, so it belongs in a VALIDATION failure naming the buses, not
in the INTERNAL bucket the jobs manual defines as "anything else the runner raised
(singular matrix, a bug)".
bus_ids carries every offending bus, in NetworkArrays order, so the runner can
report all of them rather than the first.
Source code in src/mambo_power/market/zonal.py
CorridorLimit
¶
Bases: BaseModel
One inter-zonal corridor's transfer capacity — an entry of
MarketZonalOptions.corridors.
Why this is an option and not a model field. A corridor capacity is a transfer limit
between two zones, and the domain model deliberately has no transfer-capacity entity,
because a real NTC is administratively negotiated data that no network file carries and no
branch rating uniquely determines. So capacities are supplied per solve, by the caller who
knows them. A defensible default, if you need one, is the sum of rating_mva over the pair's
cut-set — which is what tests/_zones.py's corridors() builds.
Why a row model rather than a {(z1, z2): cap} mapping. The mapping is the shape the
array-level builder takes and the shape MarketZonalOptions.corridor_map hands it. It
is not a shape a pydantic options model can carry, because a dict keyed by a tuple does
not survive a JSON round trip: pydantic serialises the key ("1", "2") to the string
"1,2" and then refuses to validate that string back into a tuple. An options model that
cannot round-trip through JSON is a jobs request form that cannot round-trip either, and
an exact JSON round trip is a standing requirement on every kind — so the serialisable shape is
the one stored, and the mapping is derived on the way to the builder.
zone1
class-attribute
instance-attribute
¶
One end of the corridor: a zone id present in the network. A zone id no bus carries is rejected at solve time, when the network is in hand (jobs: VALIDATION).
zone2
class-attribute
instance-attribute
¶
The other end; must differ from zone1, and the resulting unordered pair must not appear elsewhere in the list. Both are enforced on MarketZonalOptions itself, before any solve (jobs: BAD_OPTIONS).
cap_mw
class-attribute
instance-attribute
¶
Transfer capacity, MW, as a magnitude: the corridor is bounded at [-cap_mw, +cap_mw], so it constrains both directions equally. 0 is allowed and means a tie that exists but can carry nothing. null means unbounded -- the copper plate: the corridor stays in the LP with no bound at all, which is a different market from deleting the entry (that islands the two zones and reports no capacity price for them). A number must be finite: inf is rejected, so the copper plate has exactly one spelling on this model and it is one every JSON parser reads.
MarketZonalOptions
¶
Bases: BaseModel
Options of a market.zonal clearing.
Unlike MarketNodalOptions and
MarketMultiperiodOptions, this one has a field from
the start, and it is not solver tuning: corridors is market design data the model
deliberately does not carry (see CorridorLimit). An empty list is a meaningful
default and not a missing argument -- it means no zone pair may exchange anything, so each
zone must supply itself.
corridors
class-attribute
instance-attribute
¶
corridors: list[CorridorLimit]
Transfer capacity per tied zone pair. A pair absent from this list has no corridor at all and so cannot exchange power -- which is a stronger statement than a corridor of capacity 0 only in that no capacity shadow price is reported for it. At most MAX_CORRIDORS (500) entries -- a request/response-size guard, since this list is echoed verbatim into every result's provenance.options; 500 exhausts a 32-zone network, which is above every zonal market in operation.
corridor_map
¶
corridor_map() -> dict[ZoneKey, float]
corridors as the {(zone1, zone2): cap_mw} mapping
zonal_dc_opf takes. Keys are left in the order given; the
builder normalises each to sorted order. This is a dict comprehension and so cannot report
a repeated key -- which is exactly why the repeat is rejected on the model above, before
any mapping is built.
This is also where the copper plate changes spelling. On the wire an unbounded corridor is
null, because that is what JSON has; the array-level builder wants inf, because
that is what HiGHS has (it maps it to kHighsInf). Neither layer has to know about the
other's, and the translation is one line here rather than a non-standard token in every
request and response body.
Source code in src/mambo_power/market/zonal.py
zone_partition
¶
zone_partition(
net: Network, arr: NetworkArrays
) -> dict[str, str]
{bus id: zone id} for every bus NetworkArrays keeps,
read straight off Bus.zone.
Public for the same reason gen_cost_coeffs and
load_bid_coeffs are: it is the model-to-solver extraction
step for one more kind of network data, and a caller driving
zonal_dc_opf directly needs exactly this mapping.
Raises UnzonedBusError -- a ValueError subclass carrying every offending bus id --
if any kept bus has zone is None. A partition with a hole has no defensible repair: that
bus's load and generation must enter some zone's balance row, and choosing one for the caller
would clear a market for a network they did not describe. (Buses NetworkArrays drops -- out
of service, or on an islanded component -- are not consulted: they have no columns and no load
in the LP.)
Bus.zone is legitimately optional in the model, so this is not something
validate_network can catch: every other kind solves an unzoned
network happily. It is a requirement of this analysis, which is why it is raised here and
why the market.zonal runner is what translates it into a VALIDATION failure.
Source code in src/mambo_power/market/zonal.py
solve_zonal
¶
solve_zonal(
scenario: Scenario,
options: MarketZonalOptions | None = None,
) -> MarketZonalResult
Clear scenario.network zonally, redispatch it onto the real network, and compare the
result against the nodal optimum (module docstring).
options.corridors supplies each tied zone pair's transfer capacity; the zone partition is
read from Bus.zone. With no corridors at all, every zone must supply itself -- a
legitimate (and often infeasible) market design, not an error.
Never raises for an infeasible or unbounded stage -- reported through
MarketZonalResult.status/message, naming the stage. Raises ValueError for a
bus with no zone or a malformed corridor list, :class:~mambo_power.opf.dc_opf.
NonConvexCostError / NonConcaveBidError for a cost or bid
curve the shared extractor rejects -- all before any solve is attempted. The network is not
modified.
Source code in src/mambo_power/market/zonal.py
560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 | |
The strategy seam¶
One generator's bidding rule, and nothing else: an own-node Observation in, the GeneratorCost
it offers next out. Observation carries the agent's own true cost curve and active limits, the
round it is bidding into, and its own last two rounds of (offer, bus LMP, cleared MW) — it
names no rival, no other bus, and no part of the clearing as a whole, so a strategy cannot
reconstruct the market it is bidding into. Two rounds rather than one because a single round tells
an agent whether it is marginal but not whether its last move helped. A round that has not
happened is None, never a zero-valued RoundRecord, and a record from the wrong round is
rejected rather than accepted as adjacent.
Strategy is a typing.Protocol, so an in-process caller may hand the seam any object with a
matching offer method without inheriting from anything. What crosses JSON is never a callable:
StrategyConfig is a discriminated union on kind — the same shape as GeneratorCost and
LoadBid — and build_strategy is the one place a config becomes an instance.
MarkupStrategy is scoped to a linear PolynomialCost and raises NotImplementedError on
any other cost shape: a piecewise or higher-degree curve has no single scalar the climb has
established a meaning for. Every generator in every MATPOWER fixture this package ships carries a
quadratic cost (147 of 147, measured), so a markup agent applies only to a network built for one;
PriceTakerStrategy carries no such restriction and returns the true cost verbatim whatever its
shape.
mambo_power.market.strategy
¶
market.strategy: the seam between an agent's own history and the offer it makes next
(wave M7 W2, design D3).
Own-node, not world-model. Observation carries exactly what one generator can see
about itself — its own true cost curve and capacity, the round it is bidding into, and its own
last two rounds of (offer, bus LMP, cleared MW) — and nothing about any rival, any other
bus, or the network. That is deliberate (D3(b)): a strategy that could reconstruct the clearing or
infer the merit order could short-circuit the game the fixed-point loop in market.agents
(W3) exists to play out. Two rounds, not one, because a one-round view can tell an agent whether
it is marginal but not whether its last move helped — measured 2026-08-28 (spec A4), the rules
computable from one round either cycle or settle at a markup gain of $0.02/h. The first two
rounds necessarily have fewer than two prior points; Observation says so explicitly
through Observation.previous_round / Observation.two_rounds_ago being None,
never through a fabricated zero-valued RoundRecord.
Stateless by construction. Strategy is a typing.Protocol with one method,
offer(observation) -> GeneratorCost, and every strategy here is a pure function of that single
argument: it reads no attribute of itself that isn't a fixed parameter set at construction (e.g.
MarkupStrategy's step), and it writes nothing back. The loop supplies the history by
constructing a fresh Observation each round; a strategy that cached anything between
calls would make a run something other than a pure function of (network, strategies,
tolerance), which is exactly the property scope answer 2 asks for.
Structural, not nominal. A typing.Protocol was chosen over an ABC (D3(a)) because the
repo has no other ABC to match and the interface is one method: mypy checks conformance
structurally, and an in-process caller may pass any object with a matching offer method
without inheriting from anything. What crosses the jobs surface, though, is never a callable —
StrategyConfig is a discriminated union on kind (mirroring
GeneratorCost at model/entities.py:87), and
build_strategy resolves a config to an instance. A config round-trips through JSON; a
Strategy instance does not need to.
What ships, and what does not. PriceTakerStrategy offers true cost, unchanged, every
round — the AC-3 reproduction depends on this being exact, not approximate.
MarkupStrategy is a fixed-step two-point hill climb on the agent's own profit (A4): keep
the last direction if it raised profit, reverse it if not, and never offer below true cost. "Not"
means a real decrease, at the profit's own scale, not a tie masquerading as one — an agent
sitting at capacity while price is set elsewhere sees consecutive rounds whose LMP differs only by
solver-noise ULPs, and a strict < flips direction on that noise, turning a settled strategic
climb into the true-cost outcome presented as convergence (found downstream, S4, on the AC-5
duopoly: iteration 4, offers frozen at true cost, reported converged). See
MarkupStrategy's own docstring for the tolerance. It is
scoped to a linear PolynomialCost (coefficients =
[c1, c0]) because that is the only cost shape this wave's fixtures and A4's own measurement use;
a piecewise or higher-degree cost has no established single scalar to climb on, so
MarkupStrategy raises rather than inventing one. Both strategies are provably local
best-responders (Not Doing): neither evaluates a candidate offer against a market clearing, so
neither can be a global best response. Stateful, learning or seeded strategies are out of scope
(Not Doing) — the Strategy Protocol does not forbid one, but this module ships none.
StrategyConfig
module-attribute
¶
StrategyConfig = Annotated[
PriceTakerConfig | MarkupConfig,
Field(discriminator="kind"),
]
What crosses JSON: a discriminated union on kind, mirroring
GeneratorCost at model/entities.py:87. Never a callable --
build_strategy is the one place a config becomes a Strategy instance.
RoundRecord
¶
Bases: BaseModel
One past round's own-node outcome: what the agent offered, and what it got for it.
offer is the whole GeneratorCost the agent bid that
round (not just a scalar), because that is what the loop actually held; a strategy that needs
a scalar reading of it derives one, as MarkupStrategy does. round_index is
carried on the record itself (not just implied by which Observation slot it fills)
so that slot can be checked against it: Observation rejects a stale record --
one genuinely from some other round -- and not only a missing one. Without this, a caller
could hand round 2's outcome to an observation whose previous_round should be round 5's,
and nothing would notice the pair was never adjacent.
Observation
¶
Bases: BaseModel
An agent's own-node view of the market, as of the round it is about to bid into.
Own-node only (D3(b)): the generator's own true cost and capacity, the round index, and its own last two rounds' outcomes. Nothing here names another generator, another bus, or the clearing as a whole.
previous_round and two_rounds_ago are None exactly when that round has not
happened: both are None for the very first round's observation (there is no round to
report), and only previous_round is set for the second round's (there is one prior round,
not two). None is a documented "this round does not exist yet" marker — never a silent
zero-valued RoundRecord standing in for missing history. Two shapes of a bad history
are rejected below, both by _history_is_contiguous: a missing entry
(two_rounds_ago set while previous_round is not) and a stale one (either record
present but its own round_index is not exactly round_index - 1 / round_index - 2)
— a stale pair silently accepted as adjacent would be exactly the kind of
plausible-wrong-answer this epic keeps finding.
round_index
class-attribute
instance-attribute
¶
The round for which this offer is being decided.
true_cost
class-attribute
instance-attribute
¶
The generator's own true cost curve (Generator.cost, never the offer).
p_min_mw
class-attribute
instance-attribute
¶
The generator's own lower active limit, MW.
p_max_mw
class-attribute
instance-attribute
¶
The generator's own upper active limit, MW.
previous_round
class-attribute
instance-attribute
¶
previous_round: RoundRecord | None
Round round_index - 1's own outcome; None when round_index == 0, i.e. there is no prior round at all.
two_rounds_ago
class-attribute
instance-attribute
¶
two_rounds_ago: RoundRecord | None
Round round_index - 2's own outcome; None when round_index <= 1, i.e. there is at most one prior round.
Strategy
¶
Bases: Protocol
One generator's bidding rule: its own observation in, its next offer out.
Structural (D3(a)): any object with a matching offer method satisfies this Protocol, no
inheritance required. Every implementation here holds no state that changes between calls —
see the module docstring.
offer
¶
offer(observation: Observation) -> GeneratorCost
The generator's offer for observation.round_index, a pure function of
observation.
PriceTakerStrategy
¶
Offers the generator's own true cost, unchanged, every round.
Ignores observation.round_index and both history fields entirely -- there is nothing a
price-taker's own past has to tell it. AC-3 depends on this being the true cost exactly
(the same coefficients, not a numerically close approximation), which is what returning
observation.true_cost verbatim guarantees.
offer
¶
offer(observation: Observation) -> GeneratorCost
MarkupStrategy
¶
A fixed-step two-point hill climb on the agent's own profit (A4, measured 2026-08-28).
The rule. Let offer[t-1] / offer[t-2] be the marginal-cost levels of the last two
rounds' offers and profit[t-1] / profit[t-2] be (own bus LMP - own true marginal
cost) * own cleared MW at those rounds:
- direction is
sign(offer[t-1] - offer[t-2]), defaulting to+1when there is no prior movement to read (offer[t-1] == offer[t-2], ort-2does not exist yet); -
direction reverses if the last move made things really worse:
profit[t-1] < profit[t-2]and the two are not a tie withinmath.isclose(..., rel_tol=1e-9, abs_tol=1e-9). The tolerance is relative, not the reference probe's absolute1e-9(.bionic/docs/record/m7-tmp/m7-a4-two-point-climb.py:79): an agent sitting at capacity while price is set elsewhere sees consecutive rounds whose LMP differs only by the solver's own ULP noise -- on the AC-5 duopoly (300 MW, price $40.00) that is a profit difference of order1e-12, comfortably inside a relative 1e-9 band and comfortably outside what an absolute1e-9band catches once profit is in the thousands of dollars, as it is on every fixture this wave uses. A strict<(no tolerance at all) flips direction on that noise and turns a settled strategic climb into the true-cost outcome presented as convergence; -
direction is
-1outright when the agent cleared nothing in both of the last two rounds (cleared_mw <= _IDLE_MW_ABS_TOL, 1e-9 MW, att-1andt-2-- not an exact zero, because HiGHS can return 1e-14 MW for a unit it did not dispatch, and 1e-9 MW is seven orders under the smallest quantity any fixture trades in). An undispatched agent's profit is0 == 0round after round, which the tie rule above correctly reads as "not worse" -- and so, left to the first two rules, it would keep the default+1and climb by one step per round until the iteration cap (walk finding, M7 S9: a $30 true cost climbed to $130 on a market clearing at $22). A higher offer cannot help an agent nobody is dispatching; the only move with any chance of a sale is back down, and the floor below stops that walk at true cost. Two rounds, not one: one idle round can be the one the probe just priced itself out in, and the real-decrease rule already reverses on that; - the new offer is
offer[t-1] + direction * step, floored at the agent's own true marginal cost -- a markup never goes negative relative to cost.
The two base cases. Round 0 (observation.previous_round is None) has no offer[t-1]
for the rule to start from, so it offers true cost, exactly as PriceTakerStrategy
would -- there is nothing yet to have an opinion about. Round 1
(observation.two_rounds_ago is None) has offer[t-1] but no offer[t-2]: direction
defaults to +1 and there is no profit comparison to make, so it is a pure upward probe.
Why this is a local best response, not a global one (Not Doing). The rule only ever compares the two most recent profits it has actually observed; it never evaluates a candidate offer against a market clearing. Where a competing unit creates a discontinuity between this agent's cost and its true profit peak, the climb provably stalls at the local optimum on its side of that discontinuity (A4 measured: $9,497.52 against a derivable $12,250). This module does not claim otherwise.
step also fixes the loop's convergence tolerance from the other side (A9): once a
fixed-step climber arrives it oscillates by two steps about an optimum that sits on its
own grid, and by three when the optimum sits halfway between two grid points -- the two
straddling offers then tie in profit, the tie rule above keeps direction, and the climb
overshoots one extra step before the real decrease reverses it (a period-6 orbit; found by
the M7 critic at true cost 33.33, step 0.01, where the old 2 * step tolerance reported
the settled run as a cycle after 3,339 rounds). A strictly concave profit cannot tie three
consecutive grid points, so three steps is the widest settled orbit there is, and
min_offer_tol -- market.agents' floor on offer_tol -- is 3 * step.
Source code in src/mambo_power/market/strategy.py
min_offer_tol
property
¶
The widest oscillation this strategy settles into, 3 * step (class docstring) --
the least offer_tol under which market.agents reads its arrival as convergence
rather than as a cycle. The one place that constant lives.
offer
¶
offer(observation: Observation) -> GeneratorCost
The two-point climb described above, applied to observation's own history.
Source code in src/mambo_power/market/strategy.py
PriceTakerConfig
¶
Bases: BaseModel
Config for PriceTakerStrategy. No parameters: it always offers true cost.
MarkupConfig
¶
Bases: BaseModel
Config for MarkupStrategy.
step
class-attribute
instance-attribute
¶
Fixed offer step, $/MWh per round. Bounds the loop's own convergence tolerance from below (A9): offer_tol must be >= 3 * step, the widest orbit a settled climb has (MarkupStrategy.min_offer_tol).
build_strategy
¶
build_strategy(config: StrategyConfig) -> Strategy
Resolve a StrategyConfig to the Strategy instance it names.
Source code in src/mambo_power/market/strategy.py
The best-response loop¶
solve_agents is the fourth market mode, and the first whose input is an output of a decision.
Each round hands every agent its own Observation, collects the GeneratorCost each one offers,
and clears those offers through the general array-level path — gen_cost_coeffs +
load_bid_coeffs + dc_opf, never a delegation to solve_nodal. The offers are an overlay:
they reach the clearing as coefficients, and Generator.cost is never written to, which is the
only reason the true cost and the offered cost remain two comparable things.
Updates are simultaneous, in NetworkArrays generator order, and that is contract rather than
implementation detail. Termination is classified by the amplitude of the oscillation the loop
settles into, not by the mere fact of one: a fixed-step climber never comes to rest, it dithers by
two steps about its optimum (three when the optimum sits between two grid points), so
offer_tol >= 3 * step is derived rather than tuned —
and MarketAgentsOptions rejects a configuration that violates it rather than silently reporting
a successful climb as a cycle. Settlement is computed once, on the final round's clearing, at the
final round's prices; the intermediate rounds are the agents' search, not markets anybody was paid
for.
See the agents manual page for the loop round by round, the two economic statements, and the limits of a local best response.
mambo_power.market.agents
¶
market.agents: the fixed-point loop that lets generators bid instead of being dispatched
at cost (wave M7 W3).
One round. Every agent's Strategy is handed its own
Observation -- its own true cost and capacity, the round
index, and its own last two rounds of (offer, bus LMP, cleared MW) -- and returns a
GeneratorCost. Those offers become an overlay:
mambo_power.opf.gen_cost_coeffs is called with costs=<the offer map>, so the offered
curve reaches dc_opf through the same union-to-coefficients
mapping a true cost does (spec A2), and Generator.cost is never written to. That is the whole
of AC-2: Scenario and Network come out of a run byte-identical, while the coefficients the
array builder saw differ from the true ones.
Not a delegation. The clearing here is the general array-level path --
gen_cost_coeffs + mambo_power.market.nodal.load_bid_coeffs + dc_opf -- and never a
call to mambo_power.market.nodal.solve_nodal, deliberately (design, "Rejected
alternatives"): an all-price-taker short-circuit would make AC-3 true by construction while
bypassing the loop, the overlay and the offer map it exists to prove honest.
Updates are simultaneous (W3, A8), in NetworkArrays.gen_ids order where order is
observable at all: every agent's round-r offer is computed from round r-1's clearing,
before any of them is cleared. An earlier draft specified round-robin on the strength of a sweep
of exact best response, which cycles under simultaneous updates in five of six duopoly
configurations -- but an exact best response requires clearing the market, which the own-node
observation deliberately withholds, so that sweep is not about the strategies this wave ships.
Measured with the strategies that are computable, both orders reach the same point on the AC-5
duopoly. The rule is part of the contract, not an implementation detail.
Termination, and why it is classified by amplitude (W3, A9). A fixed-step climber never comes
to rest: it oscillates by two steps about its optimum -- three when the optimum sits halfway
between two of its grid points -- which is the expected end state and not a failure. So the loop
watches for a repeated state and then measures the amplitude of the cycle it found:
amplitude within offer_tol is convergence, amplitude above it is a genuine
cycle, and neither of those is the iteration cap. Reporting a cycle as a cap hit -- or as
convergence -- would be a confident wrong diagnosis of exactly the kind this epic has named in
every wave, which is why MarketAgentsResult spends three
enumerated words on it instead of a flag.
Settlement is computed once, on the final round's clearing, at the final round's prices -- never accumulated across rounds. The intermediate rounds are the agents' search, not a sequence of markets that anybody was paid for.
DEFAULT_MAX_ITERATIONS
module-attribute
¶
Default max_iterations. A bound, not a target: the wave's own slowest measured climb is
the AC-5 duopoly at 84 update rounds with a step of $0.50/MWh, and halving the step roughly
doubles the count (84 / 44 / 24 rounds at steps of 0.5 / 1.0 / 2.0, measured 2026-08-28), so 200
covers a step of $0.25/MWh as well. A run that reaches it is reported as having reached it
(termination_reason == "iteration_cap"), never quietly presented as settled.
AgentSetError
¶
Bases: ValueError
A caller mistake in the agent set -- how options.strategies (or the in-process
strategies argument) relates to the network -- caught before any solve starts.
A ValueError subclass, deliberately, for the same two reasons as
UnzonedBusError. It stays a ValueError because that is
what solve_agents has always raised for these and what an in-process caller catches.
It is a distinguishable type because jobs' runner cannot otherwise tell it apart from
any other ValueError a solve might raise -- and the clearing's own
NonConvexCostError / NonConcaveBidError are
ValueError subclasses. Catching bare ValueError relabelled an engine rejection of a
non-convex cost as VALIDATION at options.strategies, a field the caller need not have
set, while market.nodal reported the same network as INTERNAL (audit finding 2, M7
S10). Only this type maps to VALIDATION; everything else keeps the verdict every other
kind gives it.
Raised by _resolve_agents (two agent sources at once, a strategy on a generator the
network does not have, one its arrays do not carry, one with no cost, a
MarkupStrategy step too coarse for offer_tol) and by
_initial_offers (a strategy that cannot bid on its generator's true cost).
MarketAgentsOptions
¶
Bases: BaseModel
Options of a market.agents run: who bids, how long the loop may run, and what counts
as settled.
Sits beside solve_agents the way
MarketZonalOptions sits beside its own solver. Like that
one, its fields are market-design data rather than solver tuning -- which strategy each
generator plays is a choice about the game being simulated, not a knob on HiGHS.
strategies
class-attribute
instance-attribute
¶
strategies: dict[str, StrategyConfig]
Generator id -> the bidding rule that generator plays. A generator not named here is not an agent: it offers its own true cost, exactly as market.nodal would clear it. An empty mapping is therefore meaningful and not a missing argument -- it is a market in which nobody bids strategically.
max_iterations
class-attribute
instance-attribute
¶
Most best-response update rounds to run after round 0 (which is the initial offer and responds to nothing). Reaching it ends the run with termination_reason == "iteration_cap" and converged False; it is never reported as a cycle, and a cycle is never reported as it.
offer_tol
class-attribute
instance-attribute
¶
Largest offer-vector oscillation amplitude, in cost-coefficient units, that still counts as converged once the loop detects a repeated state. This is a derived quantity, not a tuning knob: a fixed-step climber settles into an oscillation of two steps about an on-grid optimum and three about a half-grid one, so a markup agent of step s needs offer_tol >= 3*s (MarkupStrategy.min_offer_tol) -- which the validator below enforces rather than hopes for. The default admits only an offer vector that has genuinely come to rest, which is what an all-price-taker run does.
solve_agents
¶
solve_agents(
scenario: Scenario,
options: MarketAgentsOptions | None = None,
*,
strategies: Mapping[str, Strategy] | None = None,
) -> MarketAgentsResult
Run the best-response loop of scenario.network (module docstring) and return the final
round's clearing beside how the loop ended.
The in-process seam. strategies maps a generator id to any structurally-conforming
Strategy object, and is used instead of
options.strategies -- giving both raises, so an agent set always has exactly one source
and the result can say which rule ran. This is deliberate design, not a hole left open for a
test: it is the surface for a caller whose bidding rule StrategyConfig cannot
express -- a rule with parameters the union does not carry, or one belonging to the caller
rather than to this library -- and it is the reason
Strategy is a structural
typing.Protocol (design D3(a)) rather than a closed union. Without it the Protocol
would be decorative, since nothing would ever accept an object that merely conforms to it.
Only the config union crosses JSON, so only options.strategies can reach this through
jobs, and the wave's own jobs coverage (AC-6) is unaffected by anything passed here.
provenance.options echoes options either way, which is why
strategy -- carrying the config kind
or, for an injected object, its class name -- and not the provenance, is the record of which
rule actually produced each offer.
Never raises for an infeasible or unbounded LP: a round that fails to clear ends the run and
is reported through status/message, mirroring
mambo_power.market.nodal.solve_nodal's never-raise convention. Does raise
AgentSetError (a ValueError) up front for a caller mistake in the agent set (see
_resolve_agents),
and NonConvexCostError /
NonConcaveBidError for a cost or bid the clearing cannot
accept -- including an offer a strategy produced, which is checked on the offer, every
round, exactly as it would be on a true cost -- and TypeError at the call site, before
that round's clearing, for a strategy whose offer returned something other than a
GeneratorCost (see _checked_offer). A strategy that
cannot bid on its generator's
true cost at all (MarkupStrategy on a non-linear cost,
which raises NotImplementedError from its own offer) is one of the up-front
ValueError cases: _initial_offers collects round 0's offers before the first
clearing and re-raises that error with the generator id, so the mistake
reaches jobs as VALIDATION like the other four rather than escaping the loop as
INTERNAL.
Scenario and Network are not modified -- the offers reach the clearing as coefficients
(AC-2).
Source code in src/mambo_power/market/agents.py
490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 | |