mambo_power.opf¶
DC optimal power flow: single-period, multiperiod, zonal, and redispatch. All four builders share one row-family core and one cost/bid extractor. See the manual page for the formulation, duals, piecewise-linear costs and the pandapower formulation caveat, the multiperiod manual page for the coupling row families, and the zonal manual page for the per-zone balance rows, the corridor sign convention and the redispatch delta columns.
mambo_power.opf
¶
Optimal power flow (epic Design §2 opf/): DC-OPF with duals (W1, W2).
solve_dc_opf is the thin Network-facing wrapper around the array-level
mambo_power.opf.dc_opf.dc_opf (mirrors mambo_power.pf.solve_dc /
mambo_power.pf.dc.solve): derives cost_coeffs/pwl_costs from each generator's
PolynomialCost or PiecewiseCost (raising
NonConvexCostError up front for a non-convex piecewise cost,
spec design item 4, and MissingCostError for a generator with
no cost — both re-exported here), calls dc_opf,
decomposes the duals into LMPs (mambo_power.opf.dc_opf.lmp_decomposition) and builds a
typed OpfDcResult.
multiperiod_dc_opf (wave M5 W2) and its two result types are
re-exported here alongside them: it is the T-coupled-period sibling of dc_opf, built from
the same row-family core (ADR-007), and market.multiperiod imports it from this package the
way market.nodal imports gen_cost_coeffs. There is no solve_multiperiod wrapper in
opf — the Scenario-facing entry point lives in market/ because a horizon needs
Scenario.periods, which a bare Network cannot supply.
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
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).
OpfDcOptions
¶
Bases: BaseModel
Options of the DC-OPF LP/QP solve, plus the Network-level AC-feasibility check (W6).
No solver-tuning field yet: HiGHS needs none for the problems this wave builds (small dense
LPs/QPs, always solved to default tolerances) — one is added here, not invented
speculatively, the first time a caller actually needs to tune something (mirrors the
guidance behind AcOptions's much larger option set: every
field there controls real solver behaviour). ac_check is read only by
mambo_power.opf.solve_dc_opf; dc_opf itself ignores it (see the del
options below) since the array-level LP has no notion of a Network to AC-solve.
ac_check
class-attribute
instance-attribute
¶
Re-run pf.solve_ac on the dispatched network and attach a results.FeasibilityReport as OpfDcResult.ac_check.
MultiperiodDuals
dataclass
¶
MultiperiodDuals(
balance: FloatArray,
flow_limit: FloatArray,
gen_bound: FloatArray,
demand_bound: FloatArray,
ramp: FloatArray,
soc_balance: FloatArray,
storage_power_limit: FloatArray,
storage_soc_bound: FloatArray,
cyclic: FloatArray,
)
Shadow prices from one multiperiod_dc_opf solve.
Every array is period-major: row t is period t's own slice, in NetworkArrays
order along the second axis. balance and flow_limit row t are exactly the
pair lmp_decomposition takes for period t.
balance
instance-attribute
¶
(T,) — shadow price of each period's own nodal-balance row, $/MWh: that period's
energy component. May be negative in a ramp-constrained period, and legitimately so: an
extra MW of load in a period that a ramp row binds out of can let a cheap unit start earlier
and displace an expensive one later (this module's own AC-2 test derives such a case by
hand).
flow_limit
instance-attribute
¶
(T, n_branch) — per-period, per-branch shadow price of the [-rating, rating] flow
row; 0 off the binding set, including every unrated branch.
gen_bound
instance-attribute
¶
(T, n_gen) — per-period reduced cost of each generator's [p_min, p_max] bound.
demand_bound
instance-attribute
¶
(T, n_demand) — per-period reduced cost of each elastic load's own bound, in the
caller's ascending bid-index order (as MultiperiodSolution.demand_dispatch_mw).
ramp
instance-attribute
¶
(T-1, n_gen) — dual of the two-sided ramp row coupling period t-1 to t (row
t-1 of this array), dense over all generators: exactly 0 for a generator that has
no ramp row at all, which is also what a slack ramp row returns. Negative when the ramp-up
side binds, positive when the ramp-down side does — HiGHS's own row-dual sign, the same
convention flow_limit carries. Shape (0, n_gen) when T == 1.
soc_balance
instance-attribute
¶
(T, n_storage) — dual of each unit's per-period SoC equality row, $/MWh, carrying
HiGHS's own row-dual sign (the convention flow_limit and ramp carry too). It
is the negative of the marginal value of stored energy, so it comes out negative
wherever an extra MWh in the unit is worth having: -lambda_t / eta_c where the unit
charges on an interior column and -eta_d * lambda_t where it discharges on one — e.g.
exactly -45.0 against a 50 $/MWh price at eta_d = 0.9, hand-derived from the KKT
conditions in tests/unit/test_opf_multiperiod.py. Read the worth of an MWh as
-soc_balance.
storage_power_limit
instance-attribute
¶
(T, n_storage) — dual of the shared charge + discharge <= p_max_mw row; 0 unless
the unit's combined throughput is at its converter rating.
storage_soc_bound
instance-attribute
¶
(T, n_storage) — reduced cost of the soc column's [0, energy_mwh] bound,
non-zero at either end of it: a unit sitting empty binds that bound exactly as much as a
unit sitting full, and an empty unit is the commoner reading of the two (research §7.3's
mu_soc is the full-end case). 0 only where the state of charge is strictly interior.
cyclic
instance-attribute
¶
(n_storage,) — dual of the end-of-horizon soc[T-1] == soc_initial equality row: the
cost the cyclic condition itself imposes, separable from the SoC dynamics above it.
MultiperiodSolution
dataclass
¶
MultiperiodSolution(
status: str,
n_periods: int,
dispatch_mw: FloatArray,
demand_dispatch_mw: FloatArray,
storage_charge_mw: FloatArray,
storage_discharge_mw: FloatArray,
storage_soc_mwh: FloatArray,
ptdf: FloatArray,
objective_cost: float,
duals: MultiperiodDuals | None,
message: str | None = None,
)
Result of one multiperiod_dc_opf solve.
Every quantity array is period-major — row t is period t — so a caller slices a period
out rather than reshaping. All are zero-filled at the declared shape when status is not
"Optimal", mirroring OpfSolution.
status
instance-attribute
¶
HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).
n_periods
instance-attribute
¶
T — the number of coupled periods solved, echoed back so a caller reading only this
object still knows the leading axis of every array below.
dispatch_mw
instance-attribute
¶
(T, n_gen) per-generator dispatch, MW, NetworkArrays generator order.
demand_dispatch_mw
instance-attribute
¶
(T, n_demand) per-elastic-load dispatch, MW. Column order is the caller's own bid-index
set, sorted(set(demand_bid_coeffs or {}) | set(demand_pwl_bids or {})) — identical to
demand_dispatch_mw's. Width 0 when no bid was
supplied.
storage_charge_mw
instance-attribute
¶
(T, n_storage) charging power, MW, NetworkArrays storage order; nonnegative.
storage_discharge_mw
instance-attribute
¶
(T, n_storage) discharging power, MW; nonnegative. min(charge, discharge) is
expected to be ~0 (AC-3's committed invariant) but is not structurally forced — see the
module docstring on why banning overlap can make the LP infeasible.
storage_soc_mwh
instance-attribute
¶
(T, n_storage) state of charge at the end of each period, MWh. storage_soc_mwh[-1]
== soc_initial * energy_mwh exactly, by the cyclic row.
ptdf
instance-attribute
¶
The single PTDF matrix used for every period's flow rows, returned for reuse (LMP
decomposition) exactly as ptdf is.
objective_cost
instance-attribute
¶
Total generation cost over the whole horizon, $ — sum_t sum_g cost(p_g[t]), including
every generator's constant term in every period. 0.0 when status != "Optimal". Storage
itself is costless here: Storage carries no cost field, so a unit's
only economic footprint is the round-trip loss it imposes on generation.
message
class-attribute
instance-attribute
¶
Diagnostic when status != "Optimal"; None otherwise.
RedispatchSolution
dataclass
¶
RedispatchSolution(
status: str,
dispatch_mw: FloatArray,
demand_dispatch_mw: FloatArray,
delta_up_mw: FloatArray,
delta_down_mw: FloatArray,
demand_delta_up_mw: FloatArray,
demand_delta_down_mw: FloatArray,
branch_flow_mw: FloatArray,
ptdf: FloatArray,
objective_cost: float,
demand_value: float,
duals: OpfDuals | None,
demand_bound: FloatArray,
message: str | None = None,
)
Result of one redispatch_dc_opf solve.
Modelled on OpfSolution /
MultiperiodSolution: every array is zero-filled at its
declared shape when status is not "Optimal", and duals is None exactly
then.
status
instance-attribute
¶
HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).
dispatch_mw
instance-attribute
¶
(n_gen,) final per-generator dispatch, MW, NetworkArrays generator order —
p0 + delta_up_mw − delta_down_mw, exactly.
demand_dispatch_mw
instance-attribute
¶
(n_demand,) final per-elastic-load served demand, MW. Column order is the caller's
own bid-index set, sorted(set(demand_bid_coeffs or {}) | set(demand_pwl_bids or {})) —
identical to demand_dispatch_mw's. Width 0 when no
bid was supplied.
delta_up_mw
instance-attribute
¶
(n_gen,) upward redispatch, MW, nonnegative. Netted against
delta_down_mw (module docstring, "Reported deltas are netted"), so at most one of the
pair is nonzero for any generator.
delta_down_mw
instance-attribute
¶
(n_gen,) downward redispatch, MW, nonnegative.
demand_delta_up_mw
instance-attribute
¶
(n_demand,) demand restored, MW, nonnegative — served demand above d0.
demand_delta_down_mw
instance-attribute
¶
(n_demand,) demand curtailed, MW, nonnegative — served demand below d0.
branch_flow_mw
instance-attribute
¶
(n_branch,) branch flow at the final point, MW, NetworkArrays branch order:
PTDF @ (net injection) + phase-shift injection, the same construction
mambo_power.opf.solve_dc_opf uses for
OpfBranchFlowResult. Present so that a feasibility readback
(every flow within its rating) and the settlement identity (congestion rent against
-Σ μ_k f_k) are both computable from this object alone, without a second solve; all-zero
when status != "Optimal".
ptdf
instance-attribute
¶
The PTDF matrix this solve built for its flow-limit rows, returned for reuse (LMP
decomposition) exactly as ptdf is. Present
regardless of status.
objective_cost
instance-attribute
¶
Total generation cost at the final dispatch, $/h — Σ (c2·p² + c1·p + c0) over
quadratic generators plus each PWL generator's own epigraph value, including every constant
term. Identical semantics to objective_cost, so
the two are directly comparable (the wave's generation_cost_gap). 0.0 when status !=
"Optimal".
demand_value
instance-attribute
¶
Total bid value of the final served demand, $/h — Σ (v2·d² + v1·d + v0) over
quadratic bids plus each PWL bid's own hypograph value, including every constant term (the
generator-side mirror of objective_cost, which also carries its c0). 0.0 when
status != "Optimal" and 0.0 when there is no elastic demand at all.
duals
instance-attribute
¶
duals: OpfDuals | None
None exactly when status != "Optimal". See OpfDuals.gen_bound and this
class's own note: because the redispatch LP's rows are nodal's rows, duals.balance and
duals.flow_limit are the nodal energy price and the nodal congestion duals — the exact
pair lmp_decomposition takes. duals.gen_bound is the
reduced cost of each generator's Δp+ column, which by the chain rule
∂L/∂Δp+ = ∂L/∂p is that generator's own [p_min, p_max] reduced cost at the final
point.
demand_bound
instance-attribute
¶
(n_demand,) reduced cost of each elastic load's Δd+ column — its own
[d_min, d_max] reduced cost at the final point, by the same chain rule as
duals.gen_bound. Mirrors
demand_bound, which is likewise a field of the
solution rather than of OpfDuals.
message
class-attribute
instance-attribute
¶
Diagnostic when status != "Optimal"; None otherwise.
welfare
property
¶
demand_value − objective_cost, $/h — the quantity this LP's objective maximises,
and the one welfare_gap compares against
nodal's.
ZonalDuals
dataclass
¶
Shadow prices from one zonal_dc_opf solve.
zone_price
instance-attribute
¶
(n_zone,) — each zone's clearing price, $/MWh, in ZonalSolution.zone_ids order:
the dual of that zone's own balance row (module docstring, "Zone price"). The per-zone
counterpart of balance, and equal to it in every zone
when no corridor binds.
corridor_cap
instance-attribute
¶
(n_corridor,) — each corridor's capacity shadow price, $/MWh, in
ZonalSolution.corridor_ids order: how much the objective improves per extra MW of that
corridor's cap, in whichever direction it is binding. Non-negative, and exactly 0 on a
corridor that is not at either of its bounds (module docstring, "Corridor capacity shadow
price"; _corridor_cap_price). Where corridor (z1, z2) binds and the zones on
either side both price at an interior marginal unit, this equals |price[z2] − price[z1]|
— an identity the tests assert, not one this field is computed from.
ZonalSolution
dataclass
¶
ZonalSolution(
status: str,
zone_ids: list[str],
corridor_ids: list[ZoneKey],
dispatch_mw: FloatArray,
demand_dispatch_mw: FloatArray,
demand_bound: FloatArray,
corridor_flow_mw: FloatArray,
objective_cost: float,
duals: ZonalDuals | None,
message: str | None = None,
)
Result of one zonal_dc_opf solve.
Modelled field-for-field on OpfSolution, minus the two things
a zonal clearing has no notion of (a PTDF matrix and per-branch flow duals) and plus the three
a nodal one has no notion of (zone ids, corridor ids and corridor flows). Every quantity array
is zero-filled at its declared shape when status is not "Optimal", exactly as
OpfSolution's are.
status
instance-attribute
¶
HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).
zone_ids
instance-attribute
¶
The solve's zones, sorted, one per balance row. Echoed back because — unlike
arr.gen_ids, which the caller already holds — this ordering is derived here (from the
zone_of_bus argument's own value set) and is the axis every zonal array below is in.
Present regardless of status.
corridor_ids
instance-attribute
¶
corridor_ids: list[ZoneKey]
The solve's corridors, sorted, each a (z1, z2) tuple with z1 < z2 — the axis of
corridor_flow_mw and ZonalDuals.corridor_cap, and the key whose order fixes
the flow sign convention (module docstring). Echoed back for the same reason
zone_ids is; the caller's own corridors keys may have been given unsorted.
dispatch_mw
instance-attribute
¶
(n_gen,) per-generator dispatch, MW, NetworkArrays generator order.
demand_dispatch_mw
instance-attribute
¶
(n_demand,) per-elastic-load dispatch, MW. Column order is the caller's own bid-index
set, sorted(set(demand_bid_coeffs or {}) | set(demand_pwl_bids or {})) — identical to
demand_dispatch_mw's. Length 0 when no bid was
supplied for any load.
demand_bound
instance-attribute
¶
(n_demand,) reduced cost of each elastic load's [load_p_min_mw, load_p_max_mw]
bound, same order as demand_dispatch_mw; 0 unless that load is pinned at a bound.
Sits on the solution rather than on ZonalDuals because that is where
demand_bound sits. Required here, rather than
defaulted as OpfSolution's is: that default exists only because the field was added to
an already-shipped dataclass, which is not this one's history.
corridor_flow_mw
instance-attribute
¶
(n_corridor,) net inter-zonal transfer on each corridor, MW, in corridor_ids
order. Positive means z1 -> z2 for that corridor's own sorted key (module docstring);
|flow| == cap exactly where the corridor binds.
objective_cost
instance-attribute
¶
Total generation cost, $/h — Σ (c2·p² + c1·p + c0) at the found dispatch plus every
PWL generator's own cost_g, including constant terms. Deliberately identical in meaning to
objective_cost, which likewise stays
generation-cost-only even with elastic demand in the same solve: it is not HiGHS's own
objective value, which with bid loads present also nets in the negated demand value. 0.0 when
status != "Optimal".
message
class-attribute
instance-attribute
¶
Diagnostic when status != "Optimal"; None otherwise.
multiperiod_dc_opf
¶
multiperiod_dc_opf(
arr: NetworkArrays,
cost_coeffs: FloatArray,
n_periods: int,
*,
period_load_mw: FloatArray | None = None,
ramp_up_mw: FloatArray | None = None,
ramp_down_mw: FloatArray | None = None,
pwl_costs: Mapping[int, Sequence[tuple[float, float]]]
| None = None,
demand_bid_coeffs: Mapping[
int, tuple[float, float, float]
]
| None = None,
demand_pwl_bids: Mapping[
int, Sequence[tuple[float, float]]
]
| None = None,
) -> MultiperiodSolution
Solve n_periods coupled DC-OPF periods as one LP/QP (module docstring).
cost_coeffs, pwl_costs, demand_bid_coeffs and demand_pwl_bids are exactly
dc_opf's, and are horizon-invariant: M5 varies the fixed
load across periods and nothing else (per-period offers/bids are the wave's own Not-Doing
list). period_load_mw is (n_periods, n_load) in MW, indexed by
NetworkArrays.load_ids; None means every period carries the network's own load, which
reduces the whole solve to n_periods identical copies of dc_opf's LP coupled only by
whatever ramp/SoC rows the other arguments ask for. ramp_up_mw/ramp_down_mw are
(n_gen,) in MW; None, inf and nan all mark an unconstrained generator, for
which no ramp row is built at all.
Storage is read straight off arr's per-storage identity arrays (M5 W4) — a network with no
Storage builds no storage column and no SoC row, so the storage
formulation costs a storage-free caller nothing.
Raises NonConvexCostError /
NonConcaveBidError up front for a non-convex cost or
non-concave bid, and ValueError for a mis-shaped argument — all before any HiGHS
object exists. Never raises for an infeasible or unbounded model: that is reported through
status/message, as in dc_opf.
Source code in src/mambo_power/opf/multiperiod.py
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 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 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 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 | |
redispatch_dc_opf
¶
redispatch_dc_opf(
arr: NetworkArrays,
cost_coeffs: FloatArray,
p0_mw: FloatArray,
d0_mw: FloatArray | None = None,
*,
pwl_costs: Mapping[int, Sequence[tuple[float, float]]]
| None = None,
demand_bid_coeffs: Mapping[
int, tuple[float, float, float]
]
| None = None,
demand_pwl_bids: Mapping[
int, Sequence[tuple[float, float]]
]
| None = None,
) -> RedispatchSolution
Minimum-cost redispatch of arr from the operating point (p0_mw, d0_mw) onto the
real network (module docstring).
cost_coeffs/pwl_costs/demand_bid_coeffs/demand_pwl_bids are exactly
dc_opf's, validated by the one shared
_extract_and_validate (ADR-008), and carry the same contracts: a PWL generator's
cost_coeffs row is all-zero, a load index may not appear in both bid maps, and arr is
passed unmodified (the builder removes each bid load's own historical p_mw from the
fixed RHS itself).
p0_mw is (n_gen,) in NetworkArrays generator order. d0_mw is (n_demand,) in
the caller's ascending bid-index order — the order
demand_dispatch_mw comes back in, so a zonal
solve's own demand output is passed straight through. It may be None exactly when there is
no elastic demand.
Raises ValueError for a mis-shaped argument or for a starting quantity outside its own
generator/load bounds (naming that generator or load, BOUND_TOL_MW slack);
NonConvexCostError /
NonConcaveBidError up front for a non-convex cost or
non-concave bid, before any HiGHS object exists. Never raises for an infeasible or unbounded
model — reported through status/message, this package's standing convention.
Source code in src/mambo_power/opf/redispatch.py
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 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 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 | |
zonal_dc_opf
¶
zonal_dc_opf(
arr: NetworkArrays,
cost_coeffs: FloatArray,
zone_of_bus: Mapping[str, str] | Sequence[str],
corridors: Mapping[ZoneKey, float],
*,
pwl_costs: Mapping[int, Sequence[tuple[float, float]]]
| None = None,
demand_bid_coeffs: Mapping[
int, tuple[float, float, float]
]
| None = None,
demand_pwl_bids: Mapping[
int, Sequence[tuple[float, float]]
]
| None = None,
) -> ZonalSolution
Clear arr at zonal granularity (module docstring): minimise sum cost(p_g) - sum
value(p_d) subject to one balance row per zone, coupled only by one bounded exchange
column per tied zone-pair -- no branch flow rows anywhere.
cost_coeffs is (n_gen, 3), columns [c2, c1, c0], generator order, and it,
pwl_costs, demand_bid_coeffs and demand_pwl_bids are exactly
dc_opf's, validated by the very same
_extract_and_validate -- so
NonConvexCostError and
NonConcaveBidError are raised here on the same inputs, and
before any HiGHS object exists. zone_of_bus is either a {bus id: zone id} mapping or a
positional sequence of labels in NetworkArrays bus order; every bus must carry a zone.
corridors maps an unordered zone pair to that corridor's transfer capacity in MW; keys are
normalised to sorted order and caps must be non-negative (0 and inf both allowed).
A single zone with no corridors is a legitimate solve, not a degenerate case to guard against:
it is the same LP dc_opf builds for an unrated network (module
docstring, "Degenerate case").
Raises ValueError for a malformed partition or corridor map (_zone_labels,
_normalise_corridors). Never raises for an infeasible or unbounded model -- reported
through status/message, mirroring dc_opf.
Source code in src/mambo_power/opf/zonal.py
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 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 | |
gen_cost_coeffs
¶
gen_cost_coeffs(
net: Network,
arr: NetworkArrays,
*,
costs: Mapping[str, GeneratorCost] | None = None,
) -> tuple[FloatArray, PwlCosts]
Per-generator [c2, c1, c0] (NetworkArrays generator order) plus any PWL costs,
from Generator.cost or from an explicit costs source.
Returns (coeffs, pwl_costs): coeffs is (n_gen, 3); a generator with a
PiecewiseCost gets an all-zero row because its cost is captured
entirely by the epigraph rows dc_opf builds from
pwl_costs instead (spec design item 4). A generator with no cost (cost is None
after the costs overlay below) raises MissingCostError
(re-exported here as MissingCostError) naming every such generator, before any
solve: it used to get an all-zero row too — "free" — which let a network with no economic
data at all (every RAW import) clear an OPF at zero cost with all load on one unit (M8 walk,
surprise 3). pwl_costs maps generator index to that generator's raw
PiecewiseCost.points; dc_opf raises NonConvexCostError (re-exported as
NonConvexCostError on this module) if any entry's breakpoint slopes are not
non-decreasing, before any solve is attempted.
The cost source (M7 W3, spec A2). costs maps a generator id to the
GeneratorCost to use in place of that generator's own
Generator.cost; a generator absent from the mapping keeps its own cost, and costs=None
— every pre-M7 call site — is exactly "every generator keeps its own". This is what makes a
strategic offer overlay and the true-cost extraction one function under two arguments
rather than two implementations of one mapping:
mambo_power.market.agents.solve_agents passes each round's offer map here instead of
assembling (cost_coeffs, pwl_costs) itself, so the all-zero-row convention above — and
dc_opf's generator-side overlap guard, which exists precisely
because a hand-rolled assembler can break that convention — hold identically for an offer and
for a true cost. The network is never touched: an offer is a choice of coefficients, not a
mutation of Generator.cost (AC-2).
Raises ValueError if costs names a generator id that is not in net, or one that is
in net but absent from arr (out of service, or on a bus that is). In both cases the
entry would otherwise be silently ignored, and a cost source that quietly does nothing is
exactly the plausible-wrong-answer class this repo keeps finding.
Exported (not module-private) because mambo_power.market.nodal.solve_nodal needs the
identical generator-cost extraction and imports this rather than carrying its own copy
(M4 review Duplication FLAG) — the demand-bid-side mirror
(mambo_power.market.nodal.load_bid_coeffs) has no prior-wave analog to share.
Source code in src/mambo_power/opf/__init__.py
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 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 | |
solve_dc_opf
¶
solve_dc_opf(
net: Network, options: OpfDcOptions | None = None
) -> OpfDcResult
DC-OPF of net (module docstring): dispatch, LMPs, branch flows, shadow prices.
Never raises for an infeasible or unbounded LP/QP — reported through
OpfDcResult.status/message, mirroring mambo_power.pf.solve_ac's
never-raise-on-non-convergence convention. Raises NonConvexCostError up front for a
generator whose PiecewiseCost is not convex, and
MissingCostError for one with no cost at all (see gen_cost_coeffs). The
network is not modified. OpfDcResult.ac_check stays None
unless options.ac_check is true and the LP/QP solved to "Optimal"; when it fires
(W6), a fresh deep copy of
net has each in-service generator's p_mw overwritten from the dispatch (id-keyed),
mambo_power.pf.solve_ac re-solves that copy, and
mambo_power.results.feasibility_report builds the report from that solved state plus
the copy's own declared bounds.
Source code in src/mambo_power/opf/__init__.py
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | |
LP/QP builder over arrays¶
mambo_power.opf.dc_opf
¶
DC-OPF LP/QP builder over HiGHS (spec design item 1; W1, W2).
Array-level entry point: dc_opf is pure numerics over
NetworkArrays plus a caller-supplied cost-coefficient array — no
Network/Scenario dependency, mirroring mambo_power.pf.ac_newton.newton /
mambo_power.pf.dc.solve. The Network-facing wrapper is
mambo_power.opf.solve_dc_opf.
Formulation. One decision variable per generator (NetworkArrays generator order),
bounded by its declared [p_min_mw, p_max_mw]. Two row families, both built directly on
highspy.Highs with the exact addVars/changeColsCost/addRows CSR API proven
in record/m3-research.md §1:
- One system-wide nodal-balance equality row:
Σ p_g == Σ p_load + Σ g_shunt(a DC/ lossless network has no other sink; phase-shifter injections net to zero system-wide by construction, so they never enter this row — see the module's own derivation in the implementation comments below). Its dual is the energy component of every bus's LMP (lmp_decomposition). - One PTDF-based flow-limit row per branch:
-rating <= Σ_g PTDF[k, gen_bus[g]]·p_g + const_k <= rating, whereconst_kfolds in the branch's fixed (load/shunt/phase-shift) contribution to its flow — see the derivation indc_opf. Unrated branches (rating == inf) get an unconstrained row (never binds; its dual is always 0). Its dual is the congestion component.
Cost. cost_coeffs is a caller-supplied (n_gen, 3) array, columns [c2, c1, c0]
(PolynomialCost order, "highest order first", zero-padded — see
mambo_power.opf.solve_dc_opf's cost extraction). The wave's five OPF-parity fixtures
carry genuine nonzero quadratic (c2) coefficients (confirmed by direct probe against every
fixture's raw gencost block — no fixture's generator cost is purely linear), and pandapower's
own rundcopp honours them (_from_ppc_gencost maps MATPOWER's c2/c1/c0
straight into cp2_eur_per_mw2/cp1_eur_per_mw/cp0_eur, the same unscaled
cost(p) = c2·p² + c1·p + c0 convention MATPOWER's gencost itself uses) — so matching
pandapower's dispatch on real fixture data requires honouring the quadratic term, not just the
linear one. dc_opf therefore stays a pure LP (no Hessian call at all) whenever every
generator's c2 is exactly 0 — the common case, and the literal "single LP" the wave spec
describes — and transparently extends to a convex QP via Highs.passHessian only when a
nonzero c2 is present. Both paths were probed directly against an independent hand-KKT
solve before being relied on here (.bionic/tmp/m3-s2-progress.md): HiGHS's diagonal-Hessian
convention is 0.5·xᵀQx, so the Hessian value for generator g is 2·c2[g]; the
resulting dispatch and row_dual/col_dual values matched the hand solve exactly, so no
QP-specific dual-reading logic is needed — dc_opf reads duals identically in both cases.
Startup/shutdown costs (PolynomialCost's startup/shutdown
fields) are not modelled: this is a single-period economic dispatch over already-committed
generators (no unit-commitment decision), matching pandapower's own rundcopp, which does not
model them either.
Duals. After Highs.run(), Highs.getSolution().row_dual/col_dual are read
directly — proven generically in record/m3-research.md §1, and re-verified here for both the
pure-LP and the QP path. No PTDF-reconstruction fallback is needed.
Row-family core (M5 W1). Every row family described here is built by its own internal helper —
_balance_row, _flow_limit_rows, _epigraph_rows, _hypograph_rows —
each returning a _RowBlock (the CSR triple highspy.Highs.addRows takes) rather
than touching a highspy.Highs object; dc_opf assembles its model by handing those
blocks to _add_rows, and is the only thing here that owns a solver object. Two properties
of the helpers are deliberate rather than incidental: each takes the LP column indices its
coefficients attach to as parameters instead of assuming dc_opf's own
[gen | demand | cost_g | val_d] layout, and none of them holds or mutates state across calls,
so a family can be built any number of times against any column layout. That is what makes
ADR-007's "one place the balance row is assembled" literally true for a multi-period builder,
which constructs the same balance/flow/epigraph/hypograph rows once per period instead of
reproducing the idioms. The extraction is a pure refactor: dc_opf's signature, the LP it
hands HiGHS (structural zeros included) and its results are unchanged (wave M5 AC-1).
PWL costs (W4, spec design item 4). A generator with a convex piecewise-linear cost is
passed via dc_opf's optional pwl_costs argument — a {generator_index:
[(p_mw, cost), ...]} mapping (PiecewiseCost.points, verbatim) — instead of through
cost_coeffs (that generator's cost_coeffs row is all-zero: its cost is captured entirely
by the rows built here, reusing the existing "no cost -> all-zero row -> free" convention rather
than adding a second one). The standard convex segment/epigraph LP encoding (research §2.1):
for each PWL generator g with breakpoints (p_0,c_0)...(p_n,c_n), one new free decision
variable cost_g is added with objective coefficient 1 (so minimising the LP pulls it down
to the tightest bound), plus one inequality row per segment i:
cost_g >= slope_i * p_g + intercept_i where slope_i = (c_{i+1}-c_i)/(p_{i+1}-p_i) and
intercept_i = c_i - slope_i * p_i. Because the segment slopes are non-decreasing (convex —
enforced below), the upper envelope of these lines equals the true piecewise cost exactly on
[p_0, p_n], so at the LP optimum cost_g is pinned to cost(p_g) exactly — the standard
epigraph trick; it composes with the QP path above unchanged (a network may mix quadratic and
PWL generators in the same solve — exercised by this wave's own case14_pwl.m fixture).
Only valid when the breakpoints span the generator's own [p_min, p_max] (true of every
PWL generator this wave's own fixture uses); outside that range the epigraph rows extrapolate
along the boundary segments' slopes, which is not necessarily the caller's intent — not checked
here, since no caller in this codebase currently violates it.
A non-convex breakpoint sequence (a decreasing segment slope) is rejected by
NonConvexCostError, raised by dc_opf itself before any HiGHS object is created
(fail fast, not a wrong-but-optimal-looking LP answer — research §2.1: an LP built from a
non-convex PWL curve silently produces the wrong dispatch, since the encoding above is only valid
for convex costs). This is deliberately an opf-local check, not a retroactive change to
PiecewiseCost's own validation (which checks only strictly-increasing
p_mw — record/m3-research.md §2.3; a carry-over for a later wave, not silently dropped).
dc_opf also rejects a quadratic generator cost with c2 < 0 (non-convex) the same way,
closing a gap that predates this wave (M4 research §1.2) — both checks raise
NonConvexCostError, the same error family, since they are the same underlying failure
mode (a non-convex cost fed to a convex-cost-only LP/QP encoding).
Elastic demand (M4 W1, design item 1). dc_opf gains two optional parameters,
demand_bid_coeffs and demand_pwl_bids, both defaulting to None — every M2/M3 caller is
completely unaffected (the code paths below are additive; with no elastic loads, every new array
has length 0 and the LP is byte-for-byte the pre-M4 one). Both are keyed by load index in
NetworkArrays.load_ids order (mirroring how pwl_costs is keyed by generator index) — a
load index appearing in either mapping becomes an elastic-demand LP column; a load index appearing
in neither stays represented only through the fixed balance/flow-row RHS, exactly as today.
demand_bid_coeffs maps a load index to (v2, v1, v0) (mirrors cost_coeffs' [c2, c1,
c0] row, highest order first) for a polynomial (linear/quadratic) marginal-value curve;
demand_pwl_bids maps a load index to [(p_mw, value), ...] breakpoints (mirrors
pwl_costs) for a piecewise-linear one. A load index must not appear in both.
Each elastic load gets one new decision variable, bounded [load_p_min_mw, load_p_max_mw] (from
NetworkArrays.load_p_min_pu/load_p_max_pu, W3) — no sign flip: unlike the rejected
pseudo-generator trick (research §2.2, Option A), a bid-load's own dispatch is a non-negative
quantity in its own right. The nodal-balance row gains a −1-signed term per elastic-load
column (Σp_g − Σp_d == fixed_load + shunt) and each flow-limit row gains a
−PTDF[k, load_bus[d]]-signed term, the exact mirror of the +PTDF[k, gen_bus[g]] generator
term (so flow_k = Σ_g PTDF[k,gen_bus]·p_g − Σ_d PTDF[k,load_bus]·p_d + const_k — the same
convention M4 research §4.1 hand-derives and this module's own AC-1 test reproduces exactly).
A polynomial bid's marginal value is v1 + 2·v2·p; concavity (non-increasing marginal value)
requires v2 <= 0 — the literal sign mirror of the generator-side c2 >= 0 requirement above.
A piecewise-linear bid's breakpoints must have non-increasing segment slopes — the mirror of
the convex epigraph's non-decreasing requirement. Either violation raises
NonConcaveBidError before any HiGHS object is created, the demand-side twin of
NonConvexCostError. The PWL encoding itself is a hypograph (concave "min of
supporting lines"), the sign-mirror of the epigraph above: one free val_d variable per PWL
bid-load with objective coefficient −1 (so minimising Σcost_g − Σval_d pulls val_d up
to its tightest bound), plus one row per segment, val_d <= slope_i·p_d + intercept_i.
Double-counting contract. NetworkArrays.p_load_pu is the aggregate fixed load at each
bus, built from every in-service load's own p_mw regardless of whether it later turns out to
be elastic (W3's own docstring is explicit that this is unconditional). Rather than requiring the
caller to pre-subtract each bid-load's contribution from p_load_pu before calling dc_opf
(a fragile, easy-to-get-wrong contract, since the caller would need to reconstruct exactly which
per-bus amount to remove), dc_opf does this subtraction itself: for every load index
appearing in demand_bid_coeffs/demand_pwl_bids, it reads that load's own contribution
directly off arr.load_p_max_pu[idx] (which W3 built from the identical ld.p_mw source the
p_load_pu aggregate itself sums — the two are provably in sync, not merely assumed to be) and
removes exactly that amount, at that load's own bus, from the fixed RHS before adding the load's
new LP column. A caller therefore passes arr unmodified — the same NetworkArrays it
would pass for a plain fixed-load solve — and supplies bid data only for whichever loads are
actually meant to be elastic; dc_opf guarantees no double-counting on its own.
ColArray
module-attribute
¶
LP column index / CSR index array — int32, the width HiGHS's own API takes (_RowBlock).
Distinct from IntArray, which is the int64 width every
NetworkArrays bus index array uses.
SOLVER
module-attribute
¶
Solver backend name stamped into the result provenance.
OpfDcOptions
¶
Bases: BaseModel
Options of the DC-OPF LP/QP solve, plus the Network-level AC-feasibility check (W6).
No solver-tuning field yet: HiGHS needs none for the problems this wave builds (small dense
LPs/QPs, always solved to default tolerances) — one is added here, not invented
speculatively, the first time a caller actually needs to tune something (mirrors the
guidance behind AcOptions's much larger option set: every
field there controls real solver behaviour). ac_check is read only by
mambo_power.opf.solve_dc_opf; dc_opf itself ignores it (see the del
options below) since the array-level LP has no notion of a Network to AC-solve.
ac_check
class-attribute
instance-attribute
¶
Re-run pf.solve_ac on the dispatched network and attach a results.FeasibilityReport as OpfDcResult.ac_check.
OpfDuals
dataclass
¶
Shadow prices from one dc_opf solve, in NetworkArrays order.
balance
instance-attribute
¶
Shadow price of the single system-wide nodal-balance row, $/MWh — the energy component
of every bus's LMP (lmp_decomposition); also exactly equal to an unconstrained
slack-bus generator's own linear cost coefficient, since the slack bus's PTDF column is
always zero (no congestion term enters its stationarity condition).
flow_limit
instance-attribute
¶
Per-branch shadow price of the [-rating, rating] flow row, branch order; 0 off the
binding set (including every unrated branch, whose row never binds).
gen_bound
instance-attribute
¶
Per-generator reduced cost of its [p_min, p_max] bound, generator order; 0 unless the
generator is pinned at a bound.
OpfSolution
dataclass
¶
OpfSolution(
status: str,
dispatch_mw: FloatArray,
ptdf: FloatArray,
objective_cost: float,
duals: OpfDuals | None,
message: str | None = None,
demand_dispatch_mw: FloatArray = (lambda: zeros(0))(),
demand_bound: FloatArray = (lambda: zeros(0))(),
)
Result of one dc_opf solve.
status
instance-attribute
¶
HiGHS's own model-status string. This wave's callers branch on "Optimal",
"Infeasible" and "Unbounded"; any other status HiGHS can report (e.g. a time or
iteration limit) is passed through verbatim rather than mapped, since none of this wave's
options can trigger one.
dispatch_mw
instance-attribute
¶
Per-generator dispatch, MW, generator order; all-zero when status != "Optimal".
ptdf
instance-attribute
¶
The PTDF matrix dc_opf already built to construct its flow-limit rows (module
docstring), returned so callers (mambo_power.opf.solve_dc_opf) can reuse it instead
of recomputing — ptdf is ~31% of a warm solve_dc_opf
call on case300, so computing it twice was ~62% of that call's runtime (review Performance
FLAG). Present regardless of status: it is built before Highs.run() and does not
depend on the solve's outcome.
objective_cost
instance-attribute
¶
Total generation cost, $/h — Σ (c2·p² + c1·p + c0) at the found dispatch, including
every generator's constant term (HiGHS's own objective value omits it, since a constant
does not affect the optimum; added back here for a cost figure comparable to an external
oracle's). 0.0 when status != "Optimal".
message
class-attribute
instance-attribute
¶
Diagnostic when status != "Optimal"; None otherwise.
demand_dispatch_mw
class-attribute
instance-attribute
¶
Per-elastic-load dispatch, MW (M4 W1) — never overloads dispatch_mw, which stays
generator-only. Order: sorted(set(demand_bid_coeffs or {}) | set(demand_pwl_bids or {})),
i.e. the caller's own bid-index set (into NetworkArrays.load_ids), ascending — the caller
already has this set (it built the bid mappings), so no extra id list is threaded through
here, mirroring how dispatch_mw itself relies on the caller already knowing
arr.gen_ids. Length 0 (not all-zero at generator length) when no bid was supplied for any
load — including when status != "Optimal".
demand_bound
class-attribute
instance-attribute
¶
Per-elastic-load reduced cost of its [load_p_min_mw, load_p_max_mw] bound, same order
as demand_dispatch_mw; 0 unless that load is pinned at a bound.
LmpBreakdown
dataclass
¶
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).
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).
lmp_decomposition
¶
lmp_decomposition(
duals: OpfDuals, ptdf: FloatArray
) -> LmpBreakdown
Per-bus LMP = balance dual (energy) + Σ(flow-limit-row duals × that bus's PTDF column).
Standalone and independent of dc_opf/mambo_power.opf.solve_dc_opf — callable
with any hand-built OpfDuals/PTDF pair (spec design item 2); a later wave's
market.nodal calls this identically with its own duals.
Source code in src/mambo_power/opf/dc_opf.py
dc_opf
¶
dc_opf(
arr: NetworkArrays,
cost_coeffs: FloatArray,
options: OpfDcOptions,
pwl_costs: Mapping[int, Sequence[tuple[float, float]]]
| None = None,
demand_bid_coeffs: Mapping[
int, tuple[float, float, float]
]
| None = None,
demand_pwl_bids: Mapping[
int, Sequence[tuple[float, float]]
]
| None = None,
*,
ptdf: FloatArray | None = None,
) -> OpfSolution
Solve the DC-OPF/welfare LP/QP of arr (module docstring): minimise Σ cost(p_g) −
Σ value(p_d) subject to one system-wide nodal-balance row and one PTDF-based flow-limit row
per branch, over generator and elastic-load bounds. cost_coeffs is (n_gen, 3), columns
[c2, c1, c0], generator order. pwl_costs (module docstring, "PWL costs") is an
optional {generator_index: points} map for any generator whose cost is convex
piecewise-linear instead of polynomial — that generator's own cost_coeffs row should be
all-zero. demand_bid_coeffs/demand_pwl_bids (module docstring, "Elastic demand") are
optional {load_index: ...} maps, both defaulting to None (no elastic demand — every
M2/M3 caller's exact behavior); a load index must not appear in both. Raises
NonConvexCostError up front (before any HiGHS object exists) for a non-convex
generator cost (piecewise-linear or c2 < 0 quadratic), and NonConcaveBidError for
a non-concave demand bid (piecewise-linear or v2 > 0 quadratic). Never raises for an
infeasible or unbounded model — reported through status/message.
ptdf is an optional precomputed PTDF matrix of arr ((n_branch, n_bus), as
ptdf returns it) for a caller that solves the same network
many times with different coefficients — mambo_power.market.agents.solve_agents
clears one network once per round, and the matrix was 70% of a 200-round run when rebuilt
every time (M7 S11). It is a cache, not a different model: the rows are built from it exactly
as from a freshly computed one, so the result is bitwise-identical either way
(tests/unit/test_opf_dc.py). None (the default) computes it here, which keeps every
other caller unchanged. A matrix of the wrong shape is a ValueError up front — the
one way a stale cache from another network can be told apart from this one.
Source code in src/mambo_power/opf/dc_opf.py
803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 | |
Multiperiod LP/QP builder over arrays¶
The T-loop over dc_opf's own row families plus the three coupling families. Its module
docstring carries the column layout and the row-index contract the duals are read back against.
mambo_power.opf.multiperiod
¶
Multiperiod DC-OPF LP/QP builder over HiGHS (wave M5 W2; AC-2, AC-3).
Array-level entry point: multiperiod_dc_opf solves T coupled periods in one LP/QP.
It is the direct sibling of mambo_power.opf.dc_opf.dc_opf, at the same altitude (pure
numerics over NetworkArrays, no Network/Scenario
dependency, ADR-006's array-level seam); the Scenario-facing wrapper is
mambo_power.market.multiperiod.solve_multiperiod.
One builder, not two. ADR-007 binds multiperiod to the same row-family core, and M5's W1
extraction is what makes that literally true: the per-period nodal-balance row, PTDF flow-limit
rows and PWL epigraph/hypograph rows here are built by dc_opf's own
_balance_row / _flow_limit_rows /
_epigraph_rows / _hypograph_rows,
called once per period against that period's own column indices. Nothing in those four families
is reimplemented here. What is new is the three coupling families below, which are exactly the
families a single-period solve cannot have.
Column layout — two tiers, not one block per period. record/m5-research.md §2.2 describes
the variable vector as T per-period blocks concatenated, each block ending in that period's
PWL free variables. That does not survive contact with dc_opf's documented Hessian-ordering
constraint: the quadratic cost Hessian is passed once, over a prefix of the columns, before any
free cost_g/val_d column exists. So the free variables are hoisted into a second tier:
- tier 1 (
T * (n_gen + n_demand + 3*n_storage)columns), period-major, each period's block being[gen | demand | charge | discharge | soc]. The Hessian covers exactly this tier. - tier 2 (
T * (n_pwl + n_demand_pwl)columns), period-major, each period's block being[cost_g | val_d].
At T == 1 with no storage this is column-for-column, row-for-row and call-for-call the model
dc_opf itself builds, which is what makes the degenerate case exact rather than merely close
(wave AC-4).
Storage columns (tactical default T2). Two nonnegative columns per unit per period —
charge and discharge, each bounded [0, p_max_mw] — plus an explicit soc column
bounded [0, energy_mwh], rather than one signed power column: the charge and discharge
efficiencies enter the SoC row with different coefficients (+eta_c against -1/eta_d), an
asymmetry a single signed column cannot express in one linear row (research §3.1). Simultaneous
charge and discharge is therefore representable, and deliberately so: research §3.2 constructs a
network where forbidding it makes the LP infeasible, so this formulation bounds the overlap
with a shared charge + discharge <= p_max_mw row rather than banning it with a binary. The
"does it actually happen on our own data" question is settled by a committed invariant test, not
by assumption (AC-3), and that test is paired with the constructed network where it must happen,
so the near-zero reading is a real measurement rather than an absence.
Row families and their order. Row indices are read back for duals, so the order is a contract, stated here and depended on nowhere else:
===== ======================================== ==================================================
tier family row index
===== ======================================== ==================================================
1 nodal balance, one per period t
2 PTDF flow limit, per branch per period T + t*n_branch + k
3 SoC balance, per unit per period T*(1 + n_branch) + t*n_storage + s
4 charge + discharge <= p_max after tier 3, t*n_storage + s
5 cyclic soc[T-1] == soc_initial after tier 4, s
6 ramp coupling, per ramped gen per pair after tier 5, (t-1)*n_ramped + j
7 PWL epigraph rows, per period after tier 6 — internal encoding detail
8 PWL hypograph rows, per period last — internal encoding detail
===== ======================================== ==================================================
This is this module's own contract, not one inherited from dc_opf: dc_opf gets balance
at row 0 and flows at 1..n_branch because it adds them first, and the same reasoning is
re-derived here for T periods rather than assumed to carry over. Tiers 7-8 stay last for the
same reason they do in dc_opf — they are an encoding detail of the PWL cost curves and are
never part of MultiperiodDuals' shape.
Coupling row families.
- Ramp coupling — one two-sided row per ramp-limited generator per adjacent period pair
t = 1..T-1:-ramp_down_mw[g] <= p_g[t] - p_g[t-1] <= ramp_up_mw[g]. A generator with neither limit set gets no row at all (not a row with a large finite bound), and one with only one of the two set gets a genuinely unbounded side (±highspy.kHighsInf). A ramp limit of exactly0is rejected: it would mean "frozen for the whole horizon", which is MATPOWER's unpopulated-ramp-column trap rather than anything a caller means (research §4.2). - SoC balance — one equality row per unit per period. At
t = 0it anchors to the unit's own initial energy,soc[0] - eta_c*charge[0] + discharge[0]/eta_d == soc_initial*energy_mwh; fort >= 1it couples adjacent periods,soc[t] - soc[t-1] - eta_c*charge[t] + discharge[t]/eta_d == 0. - Cyclic end of horizon —
soc[T-1] == soc_initial*energy_mwh, one equality row per unit. Not configurable: M5's scope answer 2 is "cyclic", and a free or fixed-target end state would be a third code path this wave deliberately does not ship.
Period-varying data. Only the load varies by period (period_load_mw), matching
Period, which carries a per-load override and nothing else. That one
array moves two things, because Load.p_mw means two things: a fixed load's whole demand, and
an elastic load's maximum served quantity (M4's elastic-demand contract). So a period's value
sets both the fixed-load total in that period's balance/flow rows and the upper bound of that
load's elastic column, if it has one. Moving only the first would be a silent no-op on every load
that bids: the two cancel exactly. Costs, bids, generator bounds, ratings and the PTDF matrix
are horizon-invariant — a bid load's willingness-to-pay curve is fixed by hour even though the
quantity it is bid against is not — and the PTDF is computed once
and reused across every period, which assumes a static topology over the horizon (no intra-horizon
switching or outage), consistent with the wave's Not-Doing list (research §2.2).
Duals. Read back from Highs.getSolution().row_dual/col_dual exactly as dc_opf
does, under HiGHS's own convention reduced_cost_j = c_j - sum_r y_r * a_rj. Per-period LMPs
come from feeding MultiperiodDuals' period t slice to
lmp_decomposition, unchanged from M3.
MultiperiodDuals
dataclass
¶
MultiperiodDuals(
balance: FloatArray,
flow_limit: FloatArray,
gen_bound: FloatArray,
demand_bound: FloatArray,
ramp: FloatArray,
soc_balance: FloatArray,
storage_power_limit: FloatArray,
storage_soc_bound: FloatArray,
cyclic: FloatArray,
)
Shadow prices from one multiperiod_dc_opf solve.
Every array is period-major: row t is period t's own slice, in NetworkArrays
order along the second axis. balance and flow_limit row t are exactly the
pair lmp_decomposition takes for period t.
balance
instance-attribute
¶
(T,) — shadow price of each period's own nodal-balance row, $/MWh: that period's
energy component. May be negative in a ramp-constrained period, and legitimately so: an
extra MW of load in a period that a ramp row binds out of can let a cheap unit start earlier
and displace an expensive one later (this module's own AC-2 test derives such a case by
hand).
flow_limit
instance-attribute
¶
(T, n_branch) — per-period, per-branch shadow price of the [-rating, rating] flow
row; 0 off the binding set, including every unrated branch.
gen_bound
instance-attribute
¶
(T, n_gen) — per-period reduced cost of each generator's [p_min, p_max] bound.
demand_bound
instance-attribute
¶
(T, n_demand) — per-period reduced cost of each elastic load's own bound, in the
caller's ascending bid-index order (as MultiperiodSolution.demand_dispatch_mw).
ramp
instance-attribute
¶
(T-1, n_gen) — dual of the two-sided ramp row coupling period t-1 to t (row
t-1 of this array), dense over all generators: exactly 0 for a generator that has
no ramp row at all, which is also what a slack ramp row returns. Negative when the ramp-up
side binds, positive when the ramp-down side does — HiGHS's own row-dual sign, the same
convention flow_limit carries. Shape (0, n_gen) when T == 1.
soc_balance
instance-attribute
¶
(T, n_storage) — dual of each unit's per-period SoC equality row, $/MWh, carrying
HiGHS's own row-dual sign (the convention flow_limit and ramp carry too). It
is the negative of the marginal value of stored energy, so it comes out negative
wherever an extra MWh in the unit is worth having: -lambda_t / eta_c where the unit
charges on an interior column and -eta_d * lambda_t where it discharges on one — e.g.
exactly -45.0 against a 50 $/MWh price at eta_d = 0.9, hand-derived from the KKT
conditions in tests/unit/test_opf_multiperiod.py. Read the worth of an MWh as
-soc_balance.
storage_power_limit
instance-attribute
¶
(T, n_storage) — dual of the shared charge + discharge <= p_max_mw row; 0 unless
the unit's combined throughput is at its converter rating.
storage_soc_bound
instance-attribute
¶
(T, n_storage) — reduced cost of the soc column's [0, energy_mwh] bound,
non-zero at either end of it: a unit sitting empty binds that bound exactly as much as a
unit sitting full, and an empty unit is the commoner reading of the two (research §7.3's
mu_soc is the full-end case). 0 only where the state of charge is strictly interior.
cyclic
instance-attribute
¶
(n_storage,) — dual of the end-of-horizon soc[T-1] == soc_initial equality row: the
cost the cyclic condition itself imposes, separable from the SoC dynamics above it.
MultiperiodSolution
dataclass
¶
MultiperiodSolution(
status: str,
n_periods: int,
dispatch_mw: FloatArray,
demand_dispatch_mw: FloatArray,
storage_charge_mw: FloatArray,
storage_discharge_mw: FloatArray,
storage_soc_mwh: FloatArray,
ptdf: FloatArray,
objective_cost: float,
duals: MultiperiodDuals | None,
message: str | None = None,
)
Result of one multiperiod_dc_opf solve.
Every quantity array is period-major — row t is period t — so a caller slices a period
out rather than reshaping. All are zero-filled at the declared shape when status is not
"Optimal", mirroring OpfSolution.
status
instance-attribute
¶
HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).
n_periods
instance-attribute
¶
T — the number of coupled periods solved, echoed back so a caller reading only this
object still knows the leading axis of every array below.
dispatch_mw
instance-attribute
¶
(T, n_gen) per-generator dispatch, MW, NetworkArrays generator order.
demand_dispatch_mw
instance-attribute
¶
(T, n_demand) per-elastic-load dispatch, MW. Column order is the caller's own bid-index
set, sorted(set(demand_bid_coeffs or {}) | set(demand_pwl_bids or {})) — identical to
demand_dispatch_mw's. Width 0 when no bid was
supplied.
storage_charge_mw
instance-attribute
¶
(T, n_storage) charging power, MW, NetworkArrays storage order; nonnegative.
storage_discharge_mw
instance-attribute
¶
(T, n_storage) discharging power, MW; nonnegative. min(charge, discharge) is
expected to be ~0 (AC-3's committed invariant) but is not structurally forced — see the
module docstring on why banning overlap can make the LP infeasible.
storage_soc_mwh
instance-attribute
¶
(T, n_storage) state of charge at the end of each period, MWh. storage_soc_mwh[-1]
== soc_initial * energy_mwh exactly, by the cyclic row.
ptdf
instance-attribute
¶
The single PTDF matrix used for every period's flow rows, returned for reuse (LMP
decomposition) exactly as ptdf is.
objective_cost
instance-attribute
¶
Total generation cost over the whole horizon, $ — sum_t sum_g cost(p_g[t]), including
every generator's constant term in every period. 0.0 when status != "Optimal". Storage
itself is costless here: Storage carries no cost field, so a unit's
only economic footprint is the round-trip loss it imposes on generation.
message
class-attribute
instance-attribute
¶
Diagnostic when status != "Optimal"; None otherwise.
multiperiod_dc_opf
¶
multiperiod_dc_opf(
arr: NetworkArrays,
cost_coeffs: FloatArray,
n_periods: int,
*,
period_load_mw: FloatArray | None = None,
ramp_up_mw: FloatArray | None = None,
ramp_down_mw: FloatArray | None = None,
pwl_costs: Mapping[int, Sequence[tuple[float, float]]]
| None = None,
demand_bid_coeffs: Mapping[
int, tuple[float, float, float]
]
| None = None,
demand_pwl_bids: Mapping[
int, Sequence[tuple[float, float]]
]
| None = None,
) -> MultiperiodSolution
Solve n_periods coupled DC-OPF periods as one LP/QP (module docstring).
cost_coeffs, pwl_costs, demand_bid_coeffs and demand_pwl_bids are exactly
dc_opf's, and are horizon-invariant: M5 varies the fixed
load across periods and nothing else (per-period offers/bids are the wave's own Not-Doing
list). period_load_mw is (n_periods, n_load) in MW, indexed by
NetworkArrays.load_ids; None means every period carries the network's own load, which
reduces the whole solve to n_periods identical copies of dc_opf's LP coupled only by
whatever ramp/SoC rows the other arguments ask for. ramp_up_mw/ramp_down_mw are
(n_gen,) in MW; None, inf and nan all mark an unconstrained generator, for
which no ramp row is built at all.
Storage is read straight off arr's per-storage identity arrays (M5 W4) — a network with no
Storage builds no storage column and no SoC row, so the storage
formulation costs a storage-free caller nothing.
Raises NonConvexCostError /
NonConcaveBidError up front for a non-convex cost or
non-concave bid, and ValueError for a mis-shaped argument — all before any HiGHS
object exists. Never raises for an infeasible or unbounded model: that is reported through
status/message, as in dc_opf.
Source code in src/mambo_power/opf/multiperiod.py
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 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 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 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 | |
Zonal LP/QP builder over arrays¶
One balance row per zone, one bounded exchange column per tied zone pair, and no branch flow rows at all. Its module docstring carries the corridor sign convention, the column layout and the argument for omitting phase shifters from the per-zone rows.
mambo_power.opf.zonal
¶
Zonal clearing LP/QP builder over HiGHS.
Array-level entry point: zonal_dc_opf clears a market at zonal granularity — one
price per zone, the intra-zone grid ignored entirely. It is the third caller of dc_opf's
row-family core (ADR-007) and the second of _extract_and_validate
(ADR-008), at the same altitude as dc_opf and
multiperiod_dc_opf: pure numerics over
NetworkArrays plus a caller-supplied zone partition and corridor
map, with no Network/Scenario dependency. The Scenario-facing entry point is
solve_zonal.
What a zonal clearing is, as an LP. The nodal LP (dc_opf)
carries one system-wide balance row and one PTDF flow-limit row per branch. The zonal LP replaces
both: one balance row per zone, and — instead of any branch-level flow row — one bounded
exchange variable per tied zone-pair, whose bound is that corridor's transfer capacity. That
is the design this builder implements: each zone is a copper plate internally, and the only thing
limiting where power comes from is how much a corridor can carry. There are deliberately no
intra-zone flow rows, and no flow rows at all —
_flow_limit_rows is never called here, and no PTDF matrix is ever
built. The whole point of the zonal design is that the intra-zone grid does not constrain
the clearing; a solve that consulted the PTDF would be modelling something else.
Column layout — two tiers, mirroring multiperiod_dc_opf:
- tier 1:
[gen (n_gen) | demand (n_demand) | corridor (n_corridor)]. - tier 2:
[cost_g (n_pwl) | val_d (n_demand_pwl)]— the free PWL epigraph/hypograph columns, exactly asdc_opfappends them.
The quadratic Hessian covers the dispatch columns only ([gen | demand]), and is passed
before the corridor columns are appended — so this is dc_opf's own already-proven ordering
("Hessian over a column prefix, then append more columns") unchanged, not a new one. A corridor
column is a transfer, never a cost or a value: it carries no objective coefficient and no
quadratic term, so it has nothing to contribute to a Hessian in the first place.
Corridor sign convention. corridors is keyed by an unordered zone pair, given as a tuple
(z1, z2); zonal_dc_opf normalises each key to sorted order (z1 < z2) and uses
positive == power flowing z1 -> z2. Concretely, corridor (z1, z2)'s column enters zone
z1's balance row as a withdrawal (coefficient -1) and zone z2's as an injection
(+1), and its variable bounds are the plain, symmetric [-cap_mw, +cap_mw] — the corridor's
capacity is a variable bound, not a row. So a negative
ZonalSolution.corridor_flow_mw entry means that corridor is carrying power the other way,
z2 -> z1, and is at -cap when it binds in that direction. Written out for a two-zone
network with one corridor, the pair of balance rows is p_A - f_AB == L_A and
p_B + f_AB == L_B.
Row layout. One balance row per zone, in ZonalSolution.zone_ids order (sorted), at row
indices 0 .. n_zone-1; then the PWL epigraph rows, then the PWL hypograph rows, both of which
are an internal encoding detail whose row indices nothing reads back. Zone z's row is::
Σ p_g[z] − Σ p_d[z] + Σ f[·, z] − Σ f[z, ·] == fixed_load_mw[z] + shunt_mw[z]
built by dc_opf's own _balance_row — the identical helper, the
identical +1/-1 sign convention, just handed each zone's own column sets and each zone's
own fixed right-hand side. Nothing about the balance row's algebra is reimplemented here.
Zone price. Zone z's clearing price is its own balance row's dual, read straight off
HiGHS (ZonalDuals.zone_price) — the per-zone counterpart of
balance, and the sole owner of the "zone price" concept
in this package. Two zones joined by a slack corridor necessarily price identically: summing
their balance rows cancels the exchange column entirely, collapsing them into the single
system-wide row dc_opf already builds. Prices separate exactly when a corridor binds, and by
exactly that corridor's own capacity shadow price.
Corridor capacity shadow price. ZonalDuals.corridor_cap is the shadow price of the
corridor's capacity: the rate at which the objective would improve per extra MW of cap, in
whichever direction the corridor is actually binding. It is therefore non-negative by
construction and 0 on a slack corridor, regardless of which way the flow runs. HiGHS reports
a bounded column's reduced cost with a sign that depends on which bound is active (negative at the
upper bound of a minimisation, positive at the lower); since relaxing the capacity moves the
active bound outward either way, the capacity price is that reduced cost's magnitude — see
_corridor_cap_price, which derives the equality rather than asserting it.
Degenerate case: one zone, no corridors. Explicitly allowed, and equal to dc_opf on an
unrated network (every rating_mva absent). With a single zone the per-zone balance rows
collapse to the one system-wide row, and with no corridors there is no exchange column — so the
only structural difference from dc_opf is the n_branch unconstrained flow-limit rows
dc_opf still builds and this builder never does. Those rows cannot bind, so the two LPs have
the same feasible set and the same optimum; they are nonetheless different LPs handed to HiGHS,
so the agreement is asserted to a measured tolerance, never bitwise — floating-point
reductions in a different order do not have to agree in the last bit, and on some platforms they
do not. The measured agreement on case30 is far tighter than the pinned tolerance — see
tests/unit/test_opf_zonal.py.
Phase shifters do not enter the zonal balance rows. dc_opf omits phase-shift injections
from its single balance row because they cancel system-wide by construction; per zone they
do not, since a phase shifter on a tie line injects in one zone and withdraws in the other. They
are omitted here regardless, and deliberately: a phase shifter is a device for steering flow on a
branch model this LP does not have. Whatever inter-zone transfer it would produce is already, and
entirely, what the corridor variable represents — bounded by the corridor's own capacity rather
than by a device setting. Folding pf_shift into a zone's fixed right-hand side would instead
force a transfer the zonal abstraction has no basis for, on top of the free one. At one zone
this omission is not merely defensible but exactly dc_opf's own (the system-wide cancellation),
which is what keeps the degenerate case above exact.
Everything else is dc_opf's. Cost/bid extraction, both convexity guards, the bid-index
range check and the polynomial/piecewise exclusivity rule come from the single shared
_extract_and_validate (ADR-008's whole point: this builder cannot
get them subtly different, because it does not implement them). The PWL epigraph/hypograph rows
come from _epigraph_rows /
_hypograph_rows verbatim. The elastic-demand double-counting
contract is honoured exactly as dc_opf honours it — each bid load's own historical p_mw is
removed from its own bus before that bus's fixed load is aggregated into its zone's row — so a
caller passes arr unmodified here too.
ZoneKey
module-attribute
¶
An unordered zone pair, carried as a sorted tuple (z1 < z2) — a corridor's identity.
ZonalDuals
dataclass
¶
Shadow prices from one zonal_dc_opf solve.
zone_price
instance-attribute
¶
(n_zone,) — each zone's clearing price, $/MWh, in ZonalSolution.zone_ids order:
the dual of that zone's own balance row (module docstring, "Zone price"). The per-zone
counterpart of balance, and equal to it in every zone
when no corridor binds.
corridor_cap
instance-attribute
¶
(n_corridor,) — each corridor's capacity shadow price, $/MWh, in
ZonalSolution.corridor_ids order: how much the objective improves per extra MW of that
corridor's cap, in whichever direction it is binding. Non-negative, and exactly 0 on a
corridor that is not at either of its bounds (module docstring, "Corridor capacity shadow
price"; _corridor_cap_price). Where corridor (z1, z2) binds and the zones on
either side both price at an interior marginal unit, this equals |price[z2] − price[z1]|
— an identity the tests assert, not one this field is computed from.
ZonalSolution
dataclass
¶
ZonalSolution(
status: str,
zone_ids: list[str],
corridor_ids: list[ZoneKey],
dispatch_mw: FloatArray,
demand_dispatch_mw: FloatArray,
demand_bound: FloatArray,
corridor_flow_mw: FloatArray,
objective_cost: float,
duals: ZonalDuals | None,
message: str | None = None,
)
Result of one zonal_dc_opf solve.
Modelled field-for-field on OpfSolution, minus the two things
a zonal clearing has no notion of (a PTDF matrix and per-branch flow duals) and plus the three
a nodal one has no notion of (zone ids, corridor ids and corridor flows). Every quantity array
is zero-filled at its declared shape when status is not "Optimal", exactly as
OpfSolution's are.
status
instance-attribute
¶
HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).
zone_ids
instance-attribute
¶
The solve's zones, sorted, one per balance row. Echoed back because — unlike
arr.gen_ids, which the caller already holds — this ordering is derived here (from the
zone_of_bus argument's own value set) and is the axis every zonal array below is in.
Present regardless of status.
corridor_ids
instance-attribute
¶
corridor_ids: list[ZoneKey]
The solve's corridors, sorted, each a (z1, z2) tuple with z1 < z2 — the axis of
corridor_flow_mw and ZonalDuals.corridor_cap, and the key whose order fixes
the flow sign convention (module docstring). Echoed back for the same reason
zone_ids is; the caller's own corridors keys may have been given unsorted.
dispatch_mw
instance-attribute
¶
(n_gen,) per-generator dispatch, MW, NetworkArrays generator order.
demand_dispatch_mw
instance-attribute
¶
(n_demand,) per-elastic-load dispatch, MW. Column order is the caller's own bid-index
set, sorted(set(demand_bid_coeffs or {}) | set(demand_pwl_bids or {})) — identical to
demand_dispatch_mw's. Length 0 when no bid was
supplied for any load.
demand_bound
instance-attribute
¶
(n_demand,) reduced cost of each elastic load's [load_p_min_mw, load_p_max_mw]
bound, same order as demand_dispatch_mw; 0 unless that load is pinned at a bound.
Sits on the solution rather than on ZonalDuals because that is where
demand_bound sits. Required here, rather than
defaulted as OpfSolution's is: that default exists only because the field was added to
an already-shipped dataclass, which is not this one's history.
corridor_flow_mw
instance-attribute
¶
(n_corridor,) net inter-zonal transfer on each corridor, MW, in corridor_ids
order. Positive means z1 -> z2 for that corridor's own sorted key (module docstring);
|flow| == cap exactly where the corridor binds.
objective_cost
instance-attribute
¶
Total generation cost, $/h — Σ (c2·p² + c1·p + c0) at the found dispatch plus every
PWL generator's own cost_g, including constant terms. Deliberately identical in meaning to
objective_cost, which likewise stays
generation-cost-only even with elastic demand in the same solve: it is not HiGHS's own
objective value, which with bid loads present also nets in the negated demand value. 0.0 when
status != "Optimal".
message
class-attribute
instance-attribute
¶
Diagnostic when status != "Optimal"; None otherwise.
zonal_dc_opf
¶
zonal_dc_opf(
arr: NetworkArrays,
cost_coeffs: FloatArray,
zone_of_bus: Mapping[str, str] | Sequence[str],
corridors: Mapping[ZoneKey, float],
*,
pwl_costs: Mapping[int, Sequence[tuple[float, float]]]
| None = None,
demand_bid_coeffs: Mapping[
int, tuple[float, float, float]
]
| None = None,
demand_pwl_bids: Mapping[
int, Sequence[tuple[float, float]]
]
| None = None,
) -> ZonalSolution
Clear arr at zonal granularity (module docstring): minimise sum cost(p_g) - sum
value(p_d) subject to one balance row per zone, coupled only by one bounded exchange
column per tied zone-pair -- no branch flow rows anywhere.
cost_coeffs is (n_gen, 3), columns [c2, c1, c0], generator order, and it,
pwl_costs, demand_bid_coeffs and demand_pwl_bids are exactly
dc_opf's, validated by the very same
_extract_and_validate -- so
NonConvexCostError and
NonConcaveBidError are raised here on the same inputs, and
before any HiGHS object exists. zone_of_bus is either a {bus id: zone id} mapping or a
positional sequence of labels in NetworkArrays bus order; every bus must carry a zone.
corridors maps an unordered zone pair to that corridor's transfer capacity in MW; keys are
normalised to sorted order and caps must be non-negative (0 and inf both allowed).
A single zone with no corridors is a legitimate solve, not a degenerate case to guard against:
it is the same LP dc_opf builds for an unrated network (module
docstring, "Degenerate case").
Raises ValueError for a malformed partition or corridor map (_zone_labels,
_normalise_corridors). Never raises for an infeasible or unbounded model -- reported
through status/message, mirroring dc_opf.
Source code in src/mambo_power/opf/zonal.py
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 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 | |
Redispatch LP/QP builder over arrays¶
The minimum-cost move from a zonal operating point to a network-feasible one, with delta columns on both sides of the market. Its module docstring carries the true-curve objective, the linking column piecewise-linear participants need, and the theorem that makes the result the nodal optimum.
mambo_power.opf.redispatch
¶
Min-cost redispatch LP/QP from a zonal operating point.
Array-level entry point: redispatch_dc_opf is pure numerics over
NetworkArrays plus the caller's cost/bid data and a starting
operating point (p0, d0) — no Network/Scenario dependency, exactly as
dc_opf and
multiperiod_dc_opf are. It is the third caller of
dc_opf's row-family core (ADR-007/ADR-008): _extract_and_validate, _balance_row,
_flow_limit_rows, _epigraph_rows, _hypograph_rows and _add_rows are imported and
used unmodified, and no new row-family helper is introduced — the one genuinely new row this
module needs (the PWL linking equality below) is an instance of _balance_row, whose own
docstring records that it is pure algebra over LP column indices and "does not care what a column
represents".
What the LP is. Given a zonal-cleared operating point — a per-generator dispatch p0 and a
per-bid-load served demand d0, typically produced by opf.zonal's network-blind clearing
and therefore not deliverable on the real network — find the cheapest way to move to a point the
real network can carry. The move is expressed as four nonnegative column families:
Δp+_g in [0, p_max_g − p0_g]andΔp-_g in [0, p0_g − p_min_g]per generator;Δd+_d in [0, d_max_d − d0_d]andΔd-_d in [0, d0_d − d_min_d]per bid load.
The final point is p_g = p0_g + Δp+_g − Δp-_g and d_d = d0_d + Δd+_d − Δd-_d. Because the
bounds are exactly the shifted generator/load bounds, the final point ranges over precisely
[p_min, p_max] and [d_min, d_max] — the same box nodal has, no larger and no smaller.
(NetworkArrays.load_p_min_pu is all-zero on every network this package builds today, so
[0, d0_d − d_min_d] is currently just [0, d0_d]; it is written against d_min anyway,
because it is d_min and not 0 that the theorem below actually needs.)
True cost/value curves in the objective. The objective is the true welfare function
evaluated at the final quantity — Σ_g cost_g(p0_g + Δp+_g − Δp-_g) −
Σ_d value_d(d0_d + Δd+_d − Δd-_d) — not a linear rate anchored at (p0, d0). The anchored
rate is the cheaper and more common formulation, and it was rejected: linearising each curve at the
starting point carries a systematic over-curtailment bias, because an elastic load's marginal value
is evaluated where it started rather than where it ends up, so curtailing it looks cheaper the
further it is curtailed. A worked example on a two-bus network drives a load to zero and reports a
generation cost of 0 against nodal's 1800 while destroying welfare. So the objective here is the
exact one, mechanically:
- quadratic participants —
cost_g(p0+u) = c2·(p0+u)² + c1·(p0+u) + c0expands to a constant, a linear term(c1 + 2·c2·p0)·uand a quadratic termc2·u²whereu = Δp+ − Δp-. The linear part is a column cost (+mconΔp+,−mconΔp-); the quadratic part is a 2x2 Hessian block coupling the pair,2·c2·[[1, −1], [−1, 1]], not the diagonal blockdc_opfpasses over its single dispatch column. The demand side mirrors it with−v2/−mv(the same sign mirrordc_opfalready draws). The constant terms (c2·p0² + c1·p0 + c0,v2·d0² + v1·d0 + v0) are dropped from the LP, as constants always are, and added back intoRedispatchSolution.objective_cost/demand_value, which are recomputed from the final quantities directly. - piecewise-linear participants — the epigraph/hypograph encoding needs the cost row to see
one column carrying the final quantity, and here the final quantity spans two. So a PWL
participant gets one extra column
q(bounded by its own[p_min, p_max]/[d_min, d_max]) tied to its delta pair by the linking equalityq + Δ- − Δ+ == p0— an ordinary_balance_rowwithq/Δ-on the injection side andΔ+on the withdrawal side._epigraph_rows/_hypograph_rowsare then called verbatim, withqwheredc_opfpasses its dispatch column. Only PWL participants pay for this column; a quadratic one has noqat all.
The double-counting contract, applied to the zonal point. dc_opf owns the rule that the
caller passes arr unmodified and the builder removes each bid load's historical p_mw from
the fixed RHS itself (its module docstring, "Elastic demand"). This module keeps that rule and
extends it by one step: since the delta columns measure movement away from (p0, d0), the
zonal point itself is fixed data and belongs on the right-hand side too.
- balance:
Σ Δp+ − Σ Δp- + Σ Δd- − Σ Δd+ == total_fixed − Σ_g p0_g + Σ_d d0_d, i.e.dc_opf's owntotal_fixedwith the zonal point moved across the equals sign. - flow limits:
const_kgains+ Σ_g PTDF[k, gen_bus[g]]·p0_g − Σ_d PTDF[k, load_bus[d]]·d0_d— the same fold-every-fixed-contribution-into-const_kconventiondc_opfuses for fixed load and shunts, applied to the zonal quantities._flow_limit_rowsitself is unchanged: theΔp+/Δd-columns go in as injections at the generator/load bus andΔp-/Δd+as withdrawals at the same buses, which is exactly the sign each already carries indc_opf.
The theorem this buys, and why it is a feature rather than a redundancy. Because the objective
is the true welfare function and the constraints reconstruct nodal's exact feasible set, this LP's
solution is the nodal optimum: redispatch_dc_opf(arr, ..., p0, d0) returns the same final
point as dc_opf(arr, ...) for any feasible (p0, d0), to solver tolerance — never
bitwise, since two different LPs reduce their floating-point sums in different orders. Redispatch
is therefore not an approximation of nodal that might land somewhere worse; the thing it measures
is the distance travelled from the zonal point to nodal — redispatch volume and the payment that
settles it — which is precisely the cost of the zonal market design.
tests/unit/test_opf_redispatch.py asserts the theorem from two unrelated starting points on two
fixtures, and its paired negative substitutes an anchored-rate objective in a scratch tree.
Reported deltas are netted. The objective depends on the pair only through
u = Δ+ − Δ-, so any (Δ+ + α, Δ- + α) is exactly as optimal and the split is a solver
choice, not a modelling one. RedispatchSolution therefore reports the canonical
representative — delta_up = max(u, 0), delta_down = max(−u, 0), computed from the solved
columns — so that final == p0 + delta_up − delta_down and delta_up · delta_down == 0 hold
exactly, on every platform, whatever vertex HiGHS returns. The raw columns are never surfaced.
BOUND_TOL_MW
module-attribute
¶
Slack allowed when checking p0/d0 against their own declared bounds, MW.
A zonal operating point arrives from another solve, so a generator cleared at its p_max
routinely lands a few ulp outside it. Rejecting that would make the redispatch stage fail on
exactly the points it exists to fix, so the check tolerates BOUND_TOL_MW and the derived delta
caps are floored at 0 (max(p_max − p0, 0)) rather than allowed to go negative. A point outside
by more than this is a real caller error and raises.
RedispatchSolution
dataclass
¶
RedispatchSolution(
status: str,
dispatch_mw: FloatArray,
demand_dispatch_mw: FloatArray,
delta_up_mw: FloatArray,
delta_down_mw: FloatArray,
demand_delta_up_mw: FloatArray,
demand_delta_down_mw: FloatArray,
branch_flow_mw: FloatArray,
ptdf: FloatArray,
objective_cost: float,
demand_value: float,
duals: OpfDuals | None,
demand_bound: FloatArray,
message: str | None = None,
)
Result of one redispatch_dc_opf solve.
Modelled on OpfSolution /
MultiperiodSolution: every array is zero-filled at its
declared shape when status is not "Optimal", and duals is None exactly
then.
status
instance-attribute
¶
HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).
dispatch_mw
instance-attribute
¶
(n_gen,) final per-generator dispatch, MW, NetworkArrays generator order —
p0 + delta_up_mw − delta_down_mw, exactly.
demand_dispatch_mw
instance-attribute
¶
(n_demand,) final per-elastic-load served demand, MW. Column order is the caller's
own bid-index set, sorted(set(demand_bid_coeffs or {}) | set(demand_pwl_bids or {})) —
identical to demand_dispatch_mw's. Width 0 when no
bid was supplied.
delta_up_mw
instance-attribute
¶
(n_gen,) upward redispatch, MW, nonnegative. Netted against
delta_down_mw (module docstring, "Reported deltas are netted"), so at most one of the
pair is nonzero for any generator.
delta_down_mw
instance-attribute
¶
(n_gen,) downward redispatch, MW, nonnegative.
demand_delta_up_mw
instance-attribute
¶
(n_demand,) demand restored, MW, nonnegative — served demand above d0.
demand_delta_down_mw
instance-attribute
¶
(n_demand,) demand curtailed, MW, nonnegative — served demand below d0.
branch_flow_mw
instance-attribute
¶
(n_branch,) branch flow at the final point, MW, NetworkArrays branch order:
PTDF @ (net injection) + phase-shift injection, the same construction
mambo_power.opf.solve_dc_opf uses for
OpfBranchFlowResult. Present so that a feasibility readback
(every flow within its rating) and the settlement identity (congestion rent against
-Σ μ_k f_k) are both computable from this object alone, without a second solve; all-zero
when status != "Optimal".
ptdf
instance-attribute
¶
The PTDF matrix this solve built for its flow-limit rows, returned for reuse (LMP
decomposition) exactly as ptdf is. Present
regardless of status.
objective_cost
instance-attribute
¶
Total generation cost at the final dispatch, $/h — Σ (c2·p² + c1·p + c0) over
quadratic generators plus each PWL generator's own epigraph value, including every constant
term. Identical semantics to objective_cost, so
the two are directly comparable (the wave's generation_cost_gap). 0.0 when status !=
"Optimal".
demand_value
instance-attribute
¶
Total bid value of the final served demand, $/h — Σ (v2·d² + v1·d + v0) over
quadratic bids plus each PWL bid's own hypograph value, including every constant term (the
generator-side mirror of objective_cost, which also carries its c0). 0.0 when
status != "Optimal" and 0.0 when there is no elastic demand at all.
duals
instance-attribute
¶
duals: OpfDuals | None
None exactly when status != "Optimal". See OpfDuals.gen_bound and this
class's own note: because the redispatch LP's rows are nodal's rows, duals.balance and
duals.flow_limit are the nodal energy price and the nodal congestion duals — the exact
pair lmp_decomposition takes. duals.gen_bound is the
reduced cost of each generator's Δp+ column, which by the chain rule
∂L/∂Δp+ = ∂L/∂p is that generator's own [p_min, p_max] reduced cost at the final
point.
demand_bound
instance-attribute
¶
(n_demand,) reduced cost of each elastic load's Δd+ column — its own
[d_min, d_max] reduced cost at the final point, by the same chain rule as
duals.gen_bound. Mirrors
demand_bound, which is likewise a field of the
solution rather than of OpfDuals.
message
class-attribute
instance-attribute
¶
Diagnostic when status != "Optimal"; None otherwise.
welfare
property
¶
demand_value − objective_cost, $/h — the quantity this LP's objective maximises,
and the one welfare_gap compares against
nodal's.
redispatch_dc_opf
¶
redispatch_dc_opf(
arr: NetworkArrays,
cost_coeffs: FloatArray,
p0_mw: FloatArray,
d0_mw: FloatArray | None = None,
*,
pwl_costs: Mapping[int, Sequence[tuple[float, float]]]
| None = None,
demand_bid_coeffs: Mapping[
int, tuple[float, float, float]
]
| None = None,
demand_pwl_bids: Mapping[
int, Sequence[tuple[float, float]]
]
| None = None,
) -> RedispatchSolution
Minimum-cost redispatch of arr from the operating point (p0_mw, d0_mw) onto the
real network (module docstring).
cost_coeffs/pwl_costs/demand_bid_coeffs/demand_pwl_bids are exactly
dc_opf's, validated by the one shared
_extract_and_validate (ADR-008), and carry the same contracts: a PWL generator's
cost_coeffs row is all-zero, a load index may not appear in both bid maps, and arr is
passed unmodified (the builder removes each bid load's own historical p_mw from the
fixed RHS itself).
p0_mw is (n_gen,) in NetworkArrays generator order. d0_mw is (n_demand,) in
the caller's ascending bid-index order — the order
demand_dispatch_mw comes back in, so a zonal
solve's own demand output is passed straight through. It may be None exactly when there is
no elastic demand.
Raises ValueError for a mis-shaped argument or for a starting quantity outside its own
generator/load bounds (naming that generator or load, BOUND_TOL_MW slack);
NonConvexCostError /
NonConcaveBidError up front for a non-convex cost or
non-concave bid, before any HiGHS object exists. Never raises for an infeasible or unbounded
model — reported through status/message, this package's standing convention.
Source code in src/mambo_power/opf/redispatch.py
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 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 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 | |