Skip to content

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

MissingCostError(generator_ids: Sequence[str])

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
def __init__(self, generator_ids: Sequence[str]) -> None:
    self.generator_ids = list(generator_ids)
    ids = ", ".join(f'"{gen_id}"' for gen_id in self.generator_ids)
    noun = "generator" if len(self.generator_ids) == 1 else "generators"
    super().__init__(
        f"{noun} {ids} {'has' if len(self.generator_ids) == 1 else 'have'} no cost "
        "(Generator.cost is None); a DC-OPF cannot price a cost-less generator -- set "
        "Generator.cost, or take the generator out of service (only in-service generators "
        "are priced)"
    )

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

ac_check: bool = False

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

balance: FloatArray

(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

flow_limit: FloatArray

(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

gen_bound: FloatArray

(T, n_gen) — per-period reduced cost of each generator's [p_min, p_max] bound.

demand_bound instance-attribute

demand_bound: FloatArray

(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

ramp: FloatArray

(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

soc_balance: FloatArray

(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

storage_power_limit: FloatArray

(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

storage_soc_bound: FloatArray

(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

cyclic: FloatArray

(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

status: str

HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).

n_periods instance-attribute

n_periods: int

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

dispatch_mw: FloatArray

(T, n_gen) per-generator dispatch, MW, NetworkArrays generator order.

demand_dispatch_mw instance-attribute

demand_dispatch_mw: FloatArray

(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

storage_charge_mw: FloatArray

(T, n_storage) charging power, MW, NetworkArrays storage order; nonnegative.

storage_discharge_mw instance-attribute

storage_discharge_mw: FloatArray

(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

storage_soc_mwh: FloatArray

(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

ptdf: FloatArray

The single PTDF matrix used for every period's flow rows, returned for reuse (LMP decomposition) exactly as ptdf is.

objective_cost instance-attribute

objective_cost: float

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.

duals instance-attribute

duals: MultiperiodDuals | None

None exactly when status != "Optimal".

message class-attribute instance-attribute

message: str | None = None

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

status: str

HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).

dispatch_mw instance-attribute

dispatch_mw: FloatArray

(n_gen,) final per-generator dispatch, MW, NetworkArrays generator order — p0 + delta_up_mw − delta_down_mw, exactly.

demand_dispatch_mw instance-attribute

demand_dispatch_mw: FloatArray

(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

delta_up_mw: FloatArray

(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

delta_down_mw: FloatArray

(n_gen,) downward redispatch, MW, nonnegative.

demand_delta_up_mw instance-attribute

demand_delta_up_mw: FloatArray

(n_demand,) demand restored, MW, nonnegative — served demand above d0.

demand_delta_down_mw instance-attribute

demand_delta_down_mw: FloatArray

(n_demand,) demand curtailed, MW, nonnegative — served demand below d0.

branch_flow_mw instance-attribute

branch_flow_mw: FloatArray

(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

ptdf: FloatArray

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

objective_cost: float

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

demand_value: float

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

demand_bound: FloatArray

(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

message: str | None = None

Diagnostic when status != "Optimal"; None otherwise.

welfare property

welfare: float

demand_value − objective_cost, $/h — the quantity this LP's objective maximises, and the one welfare_gap compares against nodal's.

ZonalDuals dataclass

ZonalDuals(
    zone_price: FloatArray,
    corridor_cap: FloatArray,
    gen_bound: FloatArray,
)

Shadow prices from one zonal_dc_opf solve.

zone_price instance-attribute

zone_price: FloatArray

(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

corridor_cap: FloatArray

(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.

gen_bound instance-attribute

gen_bound: FloatArray

(n_gen,) — reduced cost of each generator's [p_min, p_max] bound, generator order; 0 unless that generator is pinned at a bound. Exactly gen_bound.

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

status: str

HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).

zone_ids instance-attribute

zone_ids: list[str]

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

dispatch_mw: FloatArray

(n_gen,) per-generator dispatch, MW, NetworkArrays generator order.

demand_dispatch_mw instance-attribute

demand_dispatch_mw: FloatArray

(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

demand_bound: FloatArray

(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

corridor_flow_mw: FloatArray

(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

objective_cost: float

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".

duals instance-attribute

duals: ZonalDuals | None

None exactly when status != "Optimal".

message class-attribute instance-attribute

message: str | None = None

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
def 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
    :func:`~mambo_power.opf.dc_opf.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
    :class:`~mambo_power.model.Storage` builds no storage column and no SoC row, so the storage
    formulation costs a storage-free caller nothing.

    Raises :class:`~mambo_power.opf.dc_opf.NonConvexCostError` /
    :class:`~mambo_power.opf.dc_opf.NonConcaveBidError` up front for a non-convex cost or
    non-concave bid, and :class:`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``.
    """
    n_gen = len(arr.gen_ids)
    n_load = len(arr.load_ids)
    n_storage = len(arr.storage_ids)
    n_branch = arr.n_branch

    if n_periods < 1:
        raise ValueError(f"n_periods must be >= 1, got {n_periods}")

    # --- cost/bid extraction and validation: dc_opf's own helper, not a copy of it (ADR-008).
    # It runs before period_load_mw/ramp validation because every guard it carries is promised
    # "up front" by this function's own docstring and by the two error classes' docstrings.
    problem = _extract_and_validate(
        cost_coeffs, pwl_costs, demand_bid_coeffs, demand_pwl_bids, n_gen, n_load
    )
    c2, c1, c0 = problem.c2, problem.c1, problem.c0
    v1, v2 = problem.v1, problem.v2
    pwl_gen_idxs, segments_by_gen = problem.pwl_gen_idxs, problem.segments_by_gen
    demand_pwl_idxs, demand_segments_by_load = (
        problem.demand_pwl_idxs,
        problem.demand_segments_by_load,
    )
    elastic_load_idxs = problem.elastic_load_idxs
    n_pwl, n_demand, n_demand_pwl = problem.n_pwl, problem.n_demand, problem.n_demand_pwl

    if period_load_mw is not None:
        period_load_mw = np.asarray(period_load_mw, dtype=np.float64)
        if period_load_mw.shape != (n_periods, n_load):
            raise ValueError(
                f"period_load_mw must have shape ({n_periods}, {n_load}) "
                f"(MW per load, NetworkArrays.load_ids order), got {period_load_mw.shape}"
            )
    ramp_up = _checked_ramp("ramp_up_mw", ramp_up_mw, n_gen)
    ramp_down = _checked_ramp("ramp_down_mw", ramp_down_mw, n_gen)

    # --- column layout (module docstring, "Column layout") -------------------------------------
    per_period_dispatch = n_gen + n_demand + 3 * n_storage
    per_period_free = n_pwl + n_demand_pwl
    n_dispatch_total = n_periods * per_period_dispatch

    def _cols(base: int, offset: int, count: int) -> ColArray:
        return np.arange(base + offset, base + offset + count, dtype=np.int32)

    gen_cols: list[ColArray] = []
    demand_cols: list[ColArray] = []
    charge_cols: list[ColArray] = []
    discharge_cols: list[ColArray] = []
    soc_cols: list[ColArray] = []
    for t in range(n_periods):
        base = t * per_period_dispatch
        gen_cols.append(_cols(base, 0, n_gen))
        demand_cols.append(_cols(base, n_gen, n_demand))
        charge_cols.append(_cols(base, n_gen + n_demand, n_storage))
        discharge_cols.append(_cols(base, n_gen + n_demand + n_storage, n_storage))
        soc_cols.append(_cols(base, n_gen + n_demand + 2 * n_storage, n_storage))

    cost_col_of: list[dict[int, int]] = []
    demand_val_col_of: list[dict[int, int]] = []
    for t in range(n_periods):
        base = n_dispatch_total + t * per_period_free
        cost_col_of.append(dict(zip(pwl_gen_idxs, range(base, base + n_pwl), strict=True)))
        demand_val_col_of.append(
            dict(
                zip(
                    demand_pwl_idxs,
                    range(base + n_pwl, base + n_pwl + n_demand_pwl),
                    strict=True,
                )
            )
        )
    # dc_opf keys its epigraph rows by generator index into a *dense* gen-column array; the
    # per-period equivalent is that period's own gen_cols slice.
    demand_col_of: list[dict[int, int]] = [
        dict(zip(elastic_load_idxs, cols.tolist(), strict=True)) for cols in demand_cols
    ]

    h = highspy.Highs()  # type: ignore[no-untyped-call]  # highspy ships no type stubs
    h.setOptionValue("output_flag", False)

    # --- tier 1 columns, period by period (the same addVars/changeColsCost sequence dc_opf
    # issues, repeated once per period, so a one-period solve is the identical call sequence).
    p_min = arr.gen_p_min_pu * arr.base_mva
    p_max = arr.gen_p_max_pu * arr.base_mva
    elastic_idx_arr = np.asarray(elastic_load_idxs, dtype=np.int64)
    demand_p_min = arr.load_p_min_pu[elastic_idx_arr] * arr.base_mva
    # ``(n_periods, n_demand)``: a bid load's upper bound is **that period's** own demand, not the
    # network's base one. ``Load.p_mw`` is the largest quantity an elastic load's bid can clear
    # (M4's elastic-demand contract) and ``Period.load_p_mw`` overrides ``p_mw``, so the override
    # has to move this bound with it. A bound frozen at ``arr.load_p_max_pu`` would cancel the
    # override exactly: the period's own value is already removed from the fixed-load total below
    # (the double-counting contract), so the column would re-serve the *base* quantity and a
    # profile would have no effect at all on any load that bids. The bid itself stays
    # horizon-invariant -- what moves is the quantity anchor, not the willingness-to-pay curve.
    # ``load_p_min_pu`` is not derived from ``p_mw``, so it does not move.
    if period_load_mw is None:
        demand_p_max = np.tile(arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva, (n_periods, 1))
    else:
        demand_p_max = period_load_mw[:, elastic_idx_arr]
    storage_p_max = arr.storage_p_max_pu * arr.base_mva
    storage_energy = arr.storage_energy_pu * arr.base_mva
    soc_initial_mwh = arr.storage_soc_initial * storage_energy
    eta_charge = arr.storage_efficiency_charge
    eta_discharge = arr.storage_efficiency_discharge

    for t in range(n_periods):
        if n_gen:
            h.addVars(n_gen, p_min, p_max)
            h.changeColsCost(n_gen, gen_cols[t], c1)
        if n_demand:
            h.addVars(n_demand, demand_p_min, demand_p_max[t])
            # minimising sum(cost_g) - sum(value_d): the demand column's linear coefficient is -v1
            h.changeColsCost(n_demand, demand_cols[t], -v1)
        if n_storage:
            h.addVars(n_storage, np.zeros(n_storage), storage_p_max)
            h.addVars(n_storage, np.zeros(n_storage), storage_p_max)
            h.addVars(n_storage, np.zeros(n_storage), storage_energy)

    # --- Hessian over tier 1 only, passed before any tier-2 column exists (module docstring).
    # dc_opf's own helper, not a copy of it (ADR-008 one level down): one block per period,
    # per_period_dispatch wide, whose 3*n_storage storage columns carry no quadratic term.
    _pass_diagonal_hessian(
        h,
        c2,
        v2,
        n_gen,
        n_demand,
        n_blocks=n_periods,
        block_stride=per_period_dispatch,
    )

    # --- tier 2 columns: the free PWL cost_g / val_d variables, period by period.
    for t in range(n_periods):
        if n_pwl:
            cols = np.asarray(sorted(cost_col_of[t].values()), dtype=np.int32)
            h.addVars(n_pwl, np.full(n_pwl, -highspy.kHighsInf), np.full(n_pwl, highspy.kHighsInf))
            h.changeColsCost(n_pwl, cols, np.ones(n_pwl))
        if n_demand_pwl:
            cols = np.asarray(sorted(demand_val_col_of[t].values()), dtype=np.int32)
            h.addVars(
                n_demand_pwl,
                np.full(n_demand_pwl, -highspy.kHighsInf),
                np.full(n_demand_pwl, highspy.kHighsInf),
            )
            h.changeColsCost(n_demand_pwl, cols, -np.ones(n_demand_pwl))

    # --- per-period fixed load, and the flow-row constant it drives -----------------------------
    # dc_opf's own arithmetic, evaluated once per period: the bus-aggregate fixed load minus each
    # elastic load's own contribution at its own bus (the double-counting contract). With
    # period_load_mw=None the expressions below are literally dc_opf's, which is what makes the
    # T=1 reduction exact rather than merely close. p_shift_mw (numerics.bbus.p_shift) is the
    # phase-shifter bus injection dc_opf's own const_k folds in by hand (dc_opf.py's flow-limit
    # row comment; M8 finding F1 / A19, task-shifter-flow-fix.plan.md T6) -- it is
    # period-invariant (a function of topology, not dispatch), so it is computed once here rather
    # than inside the per-period loop below.
    ptdf_matrix = compute_ptdf(arr)
    pf_shift_mw = pf_shift(arr) * arr.base_mva
    p_shift_mw = p_shift(arr) * arr.base_mva
    g_shunt_mw = arr.g_shunt_pu * arr.base_mva
    rating_mw = arr.rating_pu * arr.base_mva
    elastic_bus = arr.load_bus[elastic_idx_arr]

    total_fixed: list[float] = []
    const: list[FloatArray] = []
    for t in range(n_periods):
        if period_load_mw is None:
            p_load_mw = arr.p_load_pu * arr.base_mva
            elastic_own_mw = arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva
        else:
            p_load_mw = np.asarray(
                np.bincount(arr.load_bus, weights=period_load_mw[t], minlength=arr.n_bus),
                dtype=np.float64,
            )
            elastic_own_mw = period_load_mw[t][elastic_idx_arr]
        if n_demand:
            p_load_mw = p_load_mw - np.bincount(
                elastic_bus, weights=elastic_own_mw, minlength=arr.n_bus
            )
        total_fixed.append(float(np.sum(p_load_mw) + np.sum(g_shunt_mw)))
        const.append(pf_shift_mw - ptdf_matrix @ (p_load_mw + g_shunt_mw + p_shift_mw))

    # --- rows, in the order the module docstring's table declares -------------------------------
    # tier 1: nodal balance, one row per period. Storage discharges into the balance and charges
    # out of it, so it joins the generator/elastic-load sides of the *same* helper.
    for t in range(n_periods):
        _add_rows(
            h,
            _balance_row(
                np.concatenate([gen_cols[t], discharge_cols[t]]),
                np.concatenate([demand_cols[t], charge_cols[t]]),
                total_fixed[t],
            ),
        )

    # tier 2: PTDF flow-limit rows, per branch per period. Storage sits at a bus like anything
    # else, so its two columns carry that bus's PTDF column with the injection/withdrawal sign.
    injection_bus = np.concatenate([arr.gen_bus, arr.storage_bus])
    withdrawal_bus = np.concatenate([elastic_bus, arr.storage_bus])
    for t in range(n_periods):
        _add_rows(
            h,
            _flow_limit_rows(
                ptdf_matrix,
                np.concatenate([gen_cols[t], discharge_cols[t]]),
                injection_bus,
                np.concatenate([demand_cols[t], charge_cols[t]]),
                withdrawal_bus,
                rating_mw,
                const[t],
            ),
        )

    # tier 3: SoC balance, one equality row per unit per period.
    if n_storage:
        soc_rows: list[list[tuple[int, float]]] = []
        soc_rhs: list[float] = []
        for t in range(n_periods):
            for s in range(n_storage):
                row = [
                    (int(soc_cols[t][s]), 1.0),
                    (int(charge_cols[t][s]), -float(eta_charge[s])),
                    (int(discharge_cols[t][s]), 1.0 / float(eta_discharge[s])),
                ]
                if t == 0:
                    soc_rhs.append(float(soc_initial_mwh[s]))
                else:
                    row.append((int(soc_cols[t - 1][s]), -1.0))
                    soc_rhs.append(0.0)
                soc_rows.append(row)
        _add_rows(h, _sparse_rows(soc_rows, soc_rhs, soc_rhs))

        # tier 4: the shared power-limit row (research §3.3 option 1) — bounds how much overlap
        # the formulation can ever reach for, without banning the case where it is required.
        limit_rows = [
            [(int(charge_cols[t][s]), 1.0), (int(discharge_cols[t][s]), 1.0)]
            for t in range(n_periods)
            for s in range(n_storage)
        ]
        limit_upper = np.tile(storage_p_max, n_periods)
        _add_rows(
            h,
            _sparse_rows(limit_rows, np.full(limit_upper.size, -highspy.kHighsInf), limit_upper),
        )

        # tier 5: cyclic end-of-horizon SoC (scope answer 2 — not configurable).
        last = n_periods - 1
        cyclic_rows = [[(int(soc_cols[last][s]), 1.0)] for s in range(n_storage)]
        _add_rows(h, _sparse_rows(cyclic_rows, soc_initial_mwh, soc_initial_mwh))

    # tier 6: ramp coupling. A generator with neither limit gets no row; a one-sided limit gets a
    # genuinely infinite bound on the other side.
    ramped = np.flatnonzero(np.isfinite(ramp_up) | np.isfinite(ramp_down))
    n_ramped = int(ramped.size)
    if n_ramped and n_periods > 1:
        ramp_rows = [
            [(int(gen_cols[t][g]), 1.0), (int(gen_cols[t - 1][g]), -1.0)]
            for t in range(1, n_periods)
            for g in ramped
        ]
        down = np.where(np.isfinite(ramp_down[ramped]), -ramp_down[ramped], -highspy.kHighsInf)
        up = np.where(np.isfinite(ramp_up[ramped]), ramp_up[ramped], highspy.kHighsInf)
        _add_rows(
            h,
            _sparse_rows(ramp_rows, np.tile(down, n_periods - 1), np.tile(up, n_periods - 1)),
        )

    # tiers 7-8: PWL epigraph / hypograph rows, appended last so no dual index above shifts.
    for t in range(n_periods):
        _add_rows(h, _epigraph_rows(segments_by_gen, gen_cols[t], cost_col_of[t]))
    for t in range(n_periods):
        _add_rows(
            h, _hypograph_rows(demand_segments_by_load, demand_col_of[t], demand_val_col_of[t])
        )

    h.run()
    status = h.modelStatusToString(h.getModelStatus())
    if status != _OPTIMAL:
        return MultiperiodSolution(
            status=status,
            n_periods=n_periods,
            dispatch_mw=np.zeros((n_periods, n_gen)),
            demand_dispatch_mw=np.zeros((n_periods, n_demand)),
            storage_charge_mw=np.zeros((n_periods, n_storage)),
            storage_discharge_mw=np.zeros((n_periods, n_storage)),
            storage_soc_mwh=np.zeros((n_periods, n_storage)),
            ptdf=ptdf_matrix,
            objective_cost=0.0,
            duals=None,
            message=f"multiperiod_dc_opf: HiGHS reported model status {status!r}",
        )

    sol = h.getSolution()
    col_value = np.asarray(sol.col_value, dtype=np.float64)
    col_dual = np.asarray(sol.col_dual, dtype=np.float64)
    row_dual = np.asarray(sol.row_dual, dtype=np.float64)

    def _read(cols: list[ColArray], source: FloatArray) -> FloatArray:
        """``(T, width)`` view of one per-period column family, period-major."""
        out = np.zeros((n_periods, cols[0].size))
        for t, period_cols in enumerate(cols):
            out[t] = source[period_cols]
        return out

    dispatch_mw = _read(gen_cols, col_value)
    demand_dispatch_mw = _read(demand_cols, col_value)
    storage_charge_mw = _read(charge_cols, col_value)
    storage_discharge_mw = _read(discharge_cols, col_value)
    storage_soc_mwh = _read(soc_cols, col_value)

    # The row-order contract is declared in the module docstring's table, implemented once ~150
    # lines above, and re-derived just below as a hand-maintained running sum. Nothing else ties
    # those three together: a row family appended after tier 6 and not accounted for here shifts
    # every dual index below it, silently and by exactly its own height. The ``.reshape`` calls
    # below catch some of that, but only when a storage unit happens to exist. This does not
    # depend on anything happening to exist.
    n_epigraph = sum(len(segments_by_gen[i]) for i in pwl_gen_idxs)
    n_hypograph = sum(len(demand_segments_by_load[i]) for i in demand_pwl_idxs)
    expected_rows = (
        n_periods * (1 + n_branch + 2 * n_storage)  # tiers 1-4
        + n_storage  # tier 5 (cyclic), one per unit for the whole horizon
        + (n_periods - 1) * n_ramped  # tier 6, empty at T == 1 or with nothing ramped
        + n_periods * (n_epigraph + n_hypograph)  # tiers 7-8, one row per segment per period
    )
    assert h.getNumRow() == expected_rows, (
        f"multiperiod_dc_opf built {h.getNumRow()} rows, but the row-order contract in this "
        f"module's docstring accounts for {expected_rows} — every dual index below is read off "
        "that contract, so they must agree"
    )

    # row offsets, exactly the module docstring's table
    flow_base = n_periods
    soc_base = flow_base + n_periods * n_branch
    limit_base = soc_base + n_periods * n_storage
    cyclic_base = limit_base + n_periods * n_storage
    ramp_base = cyclic_base + n_storage

    ramp_duals = np.zeros((max(n_periods - 1, 0), n_gen))
    if n_ramped and n_periods > 1:
        block = row_dual[ramp_base : ramp_base + (n_periods - 1) * n_ramped]
        ramp_duals[:, ramped] = block.reshape(n_periods - 1, n_ramped)

    duals = MultiperiodDuals(
        balance=row_dual[:n_periods].copy(),
        flow_limit=row_dual[flow_base:soc_base].reshape(n_periods, n_branch),
        gen_bound=_read(gen_cols, col_dual),
        demand_bound=_read(demand_cols, col_dual),
        ramp=ramp_duals,
        soc_balance=row_dual[soc_base:limit_base].reshape(n_periods, n_storage),
        storage_power_limit=row_dual[limit_base:cyclic_base].reshape(n_periods, n_storage),
        storage_soc_bound=_read(soc_cols, col_dual),
        cyclic=row_dual[cyclic_base:ramp_base].copy(),
    )

    poly_gen_cost = float(np.sum(c2 * dispatch_mw**2 + c1 * dispatch_mw + c0))
    pwl_gen_cost = float(
        sum(col_value[cost_col_of[t][i]] for t in range(n_periods) for i in pwl_gen_idxs)
    )
    return MultiperiodSolution(
        status=status,
        n_periods=n_periods,
        dispatch_mw=dispatch_mw,
        demand_dispatch_mw=demand_dispatch_mw,
        storage_charge_mw=storage_charge_mw,
        storage_discharge_mw=storage_discharge_mw,
        storage_soc_mwh=storage_soc_mwh,
        ptdf=ptdf_matrix,
        objective_cost=poly_gen_cost + pwl_gen_cost,
        duals=duals,
        message=None,
    )

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
def 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
    :func:`~mambo_power.opf.dc_opf.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
    :attr:`~mambo_power.opf.dc_opf.OpfSolution.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 :class:`ValueError` for a mis-shaped argument or for a starting quantity outside its own
    generator/load bounds (naming that generator or load, :data:`BOUND_TOL_MW` slack);
    :class:`~mambo_power.opf.dc_opf.NonConvexCostError` /
    :class:`~mambo_power.opf.dc_opf.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.
    """
    n_gen = len(arr.gen_ids)
    n_load = len(arr.load_ids)
    problem = _extract_and_validate(
        cost_coeffs, pwl_costs, demand_bid_coeffs, demand_pwl_bids, n_gen, n_load
    )
    c2, c1, c0 = problem.c2, problem.c1, problem.c0
    v2, v1, v0 = problem.v2, problem.v1, problem.v0
    elastic_load_idxs = problem.elastic_load_idxs
    n_pwl, n_demand, n_demand_pwl = problem.n_pwl, problem.n_demand, problem.n_demand_pwl
    elastic_idx_arr = np.asarray(elastic_load_idxs, dtype=np.int64)
    slot_of_load = {idx: j for j, idx in enumerate(elastic_load_idxs)}

    # --- the starting point, validated against the very bounds the delta caps are derived from.
    p0 = np.asarray(p0_mw, dtype=np.float64)
    if p0.shape != (n_gen,):
        raise ValueError(f"p0_mw must have shape ({n_gen},) (one per generator), got {p0.shape}")
    d0 = np.zeros(n_demand) if d0_mw is None else np.asarray(d0_mw, dtype=np.float64)
    if d0.shape != (n_demand,):
        raise ValueError(
            f"d0_mw must have shape ({n_demand},) (one per elastic load, in ascending bid-index "
            f"order), got {d0.shape}"
        )
    p_min = arr.gen_p_min_pu * arr.base_mva
    p_max = arr.gen_p_max_pu * arr.base_mva
    d_min = arr.load_p_min_pu[elastic_idx_arr] * arr.base_mva
    d_max = arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva
    _check_point(p0, p_min, p_max, arr.gen_ids, "generator", "dispatch p0_mw")
    _check_point(
        d0,
        d_min,
        d_max,
        [arr.load_ids[i] for i in elastic_load_idxs],
        "load",
        "served demand d0_mw",
    )

    # --- tier-1 columns: the four delta families, in this order (the Hessian covers exactly this
    # prefix, and is passed before any tier-2 free column exists — dc_opf's own documented
    # ordering constraint, which multiperiod.py also obeys).
    gen_up_cols = np.arange(0, n_gen, dtype=np.int32)
    gen_down_cols = np.arange(n_gen, 2 * n_gen, dtype=np.int32)
    dem_up_cols = np.arange(2 * n_gen, 2 * n_gen + n_demand, dtype=np.int32)
    dem_down_cols = np.arange(2 * n_gen + n_demand, 2 * n_gen + 2 * n_demand, dtype=np.int32)
    n_delta = 2 * (n_gen + n_demand)

    h = highspy.Highs()  # type: ignore[no-untyped-call]  # highspy ships no type stubs
    h.setOptionValue("output_flag", False)

    # Delta bounds are the generator/load bounds shifted by the starting point, floored at 0
    # against BOUND_TOL_MW-scale noise in (p0, d0). The final quantity therefore ranges over
    # exactly [p_min, p_max] / [d_min, d_max] — the property the theorem above rests on.
    if n_gen:
        h.addVars(n_gen, np.zeros(n_gen), np.maximum(p_max - p0, 0.0))
        h.addVars(n_gen, np.zeros(n_gen), np.maximum(p0 - p_min, 0.0))
        # linear part of cost_g(p0 + u) = ... + (c1 + 2·c2·p0)·u + c2·u², u = Δ+ − Δ−.
        gen_rate = c1 + 2.0 * c2 * p0
        h.changeColsCost(n_gen, gen_up_cols, gen_rate)
        h.changeColsCost(n_gen, gen_down_cols, -gen_rate)
    if n_demand:
        h.addVars(n_demand, np.zeros(n_demand), np.maximum(d_max - d0, 0.0))
        h.addVars(n_demand, np.zeros(n_demand), np.maximum(d0 - d_min, 0.0))
        # minimising Σcost − Σvalue: the *restore* column carries −mv and the *curtail* column
        # +mv, the delta form of dc_opf's own −v1 demand convention.
        demand_rate = v1 + 2.0 * v2 * d0
        h.changeColsCost(n_demand, dem_up_cols, -demand_rate)
        h.changeColsCost(n_demand, dem_down_cols, demand_rate)

    # --- Hessian over tier 1 only: one 2x2 block per quadratic participant (module docstring).
    if n_delta:
        entries = _hessian_pairs(c2, gen_up_cols, gen_down_cols)
        entries.update(_hessian_pairs(-v2, dem_up_cols, dem_down_cols))
        if entries:
            starts: list[int] = [0]
            indices: list[int] = []
            values: list[float] = []
            for col in range(n_delta):
                for row, value in entries.get(col, []):
                    indices.append(row)
                    values.append(value)
                starts.append(len(indices))
            hess = highspy.HighsHessian()
            hess.dim_ = n_delta
            hess.format_ = highspy.HessianFormat.kTriangular
            hess.start_ = starts
            hess.index_ = indices
            hess.value_ = values
            h.passHessian(hess)

    # --- tier-2 columns: one bounded "final quantity" column q plus one free cost_g/val_d column
    # per *piecewise-linear* participant (module docstring). A quadratic participant has neither.
    next_col = n_delta
    gen_q_col_of: dict[int, int] = {}
    gen_cost_col_of: dict[int, int] = {}
    if n_pwl:
        pwl_idx = np.asarray(problem.pwl_gen_idxs, dtype=np.int64)
        q_cols = np.arange(next_col, next_col + n_pwl, dtype=np.int32)
        h.addVars(n_pwl, p_min[pwl_idx], p_max[pwl_idx])
        gen_q_col_of = dict(zip(problem.pwl_gen_idxs, q_cols.tolist(), strict=True))
        next_col += n_pwl
        cost_cols = np.arange(next_col, next_col + n_pwl, dtype=np.int32)
        h.addVars(n_pwl, np.full(n_pwl, -highspy.kHighsInf), np.full(n_pwl, highspy.kHighsInf))
        h.changeColsCost(n_pwl, cost_cols, np.ones(n_pwl))
        gen_cost_col_of = dict(zip(problem.pwl_gen_idxs, cost_cols.tolist(), strict=True))
        next_col += n_pwl

    dem_q_col_of: dict[int, int] = {}
    dem_val_col_of: dict[int, int] = {}
    if n_demand_pwl:
        # position of each PWL bid load within the ascending elastic-load order (its delta columns)
        pwl_slots = np.asarray([slot_of_load[i] for i in problem.demand_pwl_idxs], dtype=np.int64)
        q_cols = np.arange(next_col, next_col + n_demand_pwl, dtype=np.int32)
        h.addVars(n_demand_pwl, d_min[pwl_slots], d_max[pwl_slots])
        dem_q_col_of = dict(zip(problem.demand_pwl_idxs, q_cols.tolist(), strict=True))
        next_col += n_demand_pwl
        val_cols = np.arange(next_col, next_col + n_demand_pwl, dtype=np.int32)
        h.addVars(
            n_demand_pwl,
            np.full(n_demand_pwl, -highspy.kHighsInf),
            np.full(n_demand_pwl, highspy.kHighsInf),
        )
        h.changeColsCost(n_demand_pwl, val_cols, -np.ones(n_demand_pwl))
        dem_val_col_of = dict(zip(problem.demand_pwl_idxs, val_cols.tolist(), strict=True))
        next_col += n_demand_pwl

    # --- the fixed right-hand side: dc_opf's own total_fixed (every bid load's historical p_mw
    # removed from its bus, the double-counting contract) with the zonal point moved across.
    p_load_mw = arr.p_load_pu * arr.base_mva
    elastic_bus = arr.load_bus[elastic_idx_arr]
    if n_demand:
        elastic_own_mw = arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva
        p_load_mw = p_load_mw - np.bincount(
            elastic_bus, weights=elastic_own_mw, minlength=arr.n_bus
        )
    g_shunt_mw = arr.g_shunt_pu * arr.base_mva
    total_fixed = float(np.sum(p_load_mw) + np.sum(g_shunt_mw))
    balance_rhs = total_fixed - float(np.sum(p0)) + float(np.sum(d0))

    ptdf_matrix = compute_ptdf(arr)
    pf_shift_mw = pf_shift(arr) * arr.base_mva
    # p_shift_mw (numerics.bbus.p_shift) is the phase-shifter bus injection dc_opf's own const_k
    # folds in by hand (dc_opf.py's flow-limit row comment; M8 finding F1 / A19,
    # task-shifter-flow-fix.plan.md T7) -- a property of topology, not of p0/d0, so it belongs in
    # this first fold, not the zonal-point term below.
    p_shift_mw = p_shift(arr) * arr.base_mva
    const = pf_shift_mw - ptdf_matrix @ (p_load_mw + g_shunt_mw + p_shift_mw)
    # ...plus the zonal point's own (fixed) contribution to every branch's flow. p0/d0 are a
    # *decision-independent* injection like p_load_mw/g_shunt_mw above, not a phase shift, so no
    # further p_shift correction applies here -- confirmed by re-deriving the flow identity by
    # hand: flow_k = PTDF[k,:] @ (injection - p_shift) + pf_shift_k, and (p0_by_bus - d0_by_bus)
    # is exactly one more additive term of "injection", already covered by the single p_shift
    # subtraction above.
    p0_by_bus = np.bincount(arr.gen_bus, weights=p0, minlength=arr.n_bus)
    d0_by_bus = np.bincount(elastic_bus, weights=d0, minlength=arr.n_bus)
    const = const + ptdf_matrix @ (p0_by_bus - d0_by_bus)
    rating_mw = arr.rating_pu * arr.base_mva  # inf where unrated -> row never binds

    # --- rows, in the order their indices are read back below: balance is row 0, the flow-limit
    # rows are 1..n_branch, and everything after them is an internal encoding detail.
    n_rows = 1 + arr.n_branch
    injection_cols = np.concatenate([gen_up_cols, dem_down_cols])
    withdrawal_cols = np.concatenate([gen_down_cols, dem_up_cols])
    injection_bus = np.concatenate([arr.gen_bus, elastic_bus])
    withdrawal_bus = injection_bus

    _add_rows(h, _balance_row(injection_cols, withdrawal_cols, balance_rhs))
    _add_rows(
        h,
        _flow_limit_rows(
            ptdf_matrix,
            injection_cols,
            injection_bus,
            withdrawal_cols,
            withdrawal_bus,
            rating_mw,
            const,
        ),
    )

    # PWL linking equalities: q + Δ− − Δ+ == starting quantity, one per PWL participant, built
    # from _balance_row (module docstring — it is pure algebra over column indices).
    for gen_idx, q_col in gen_q_col_of.items():
        _add_rows(
            h,
            _balance_row(
                np.asarray([q_col, gen_down_cols[gen_idx]], dtype=np.int32),
                np.asarray([gen_up_cols[gen_idx]], dtype=np.int32),
                float(p0[gen_idx]),
            ),
        )
    for load_idx, q_col in dem_q_col_of.items():
        slot = slot_of_load[load_idx]
        _add_rows(
            h,
            _balance_row(
                np.asarray([q_col, dem_down_cols[slot]], dtype=np.int32),
                np.asarray([dem_up_cols[slot]], dtype=np.int32),
                float(d0[slot]),
            ),
        )

    # Epigraph/hypograph rows over the *final* quantity column, verbatim.
    q_col_by_gen = np.zeros(n_gen, dtype=np.int32)
    for gen_idx, q_col in gen_q_col_of.items():
        q_col_by_gen[gen_idx] = q_col
    _add_rows(h, _epigraph_rows(problem.segments_by_gen, q_col_by_gen, gen_cost_col_of))
    _add_rows(h, _hypograph_rows(problem.demand_segments_by_load, dem_q_col_of, dem_val_col_of))

    # The row-order contract is declared in the module docstring, implemented just above, and
    # re-derived here as a hand-maintained sum. Nothing else ties those three together: the duals
    # below are ``row_dual[0]`` and ``row_dual[1:n_rows]``, and three row families of
    # conditionally-present height (the PWL linking equalities, the epigraph and hypograph blocks)
    # are appended *after* the flow rows. A family inserted before them instead shifts every
    # flow-limit dual by exactly its own height, silently. opf/multiperiod.py carries the same
    # assert for the same reason, and it was measured there to be the only guard on its layout.
    n_linking = len(gen_q_col_of) + len(dem_q_col_of)
    n_epigraph = sum(len(segs) for segs in problem.segments_by_gen.values())
    n_hypograph = sum(len(segs) for segs in problem.demand_segments_by_load.values())
    expected_rows = n_rows + n_linking + n_epigraph + n_hypograph
    assert h.getNumRow() == expected_rows, (
        f"redispatch_dc_opf built {h.getNumRow()} rows, but the row-order contract in this "
        f"module's docstring accounts for {expected_rows} — the balance and flow-limit duals are "
        "read off that contract as row_dual[0] and row_dual[1:n_rows], so they must agree"
    )

    h.run()
    status = h.modelStatusToString(h.getModelStatus())
    if status != _OPTIMAL:
        return RedispatchSolution(
            status=status,
            dispatch_mw=np.zeros(n_gen),
            demand_dispatch_mw=np.zeros(n_demand),
            delta_up_mw=np.zeros(n_gen),
            delta_down_mw=np.zeros(n_gen),
            demand_delta_up_mw=np.zeros(n_demand),
            demand_delta_down_mw=np.zeros(n_demand),
            branch_flow_mw=np.zeros(arr.n_branch),
            ptdf=ptdf_matrix,
            objective_cost=0.0,
            demand_value=0.0,
            duals=None,
            demand_bound=np.zeros(n_demand),
            message=f"redispatch_dc_opf: HiGHS reported model status {status!r}",
        )

    sol = h.getSolution()
    col_value = np.asarray(sol.col_value, dtype=np.float64)
    # Netted deltas (module docstring): the objective sees only Δ+ − Δ−, so the canonical
    # representative is reported rather than whichever split this platform's HiGHS returned.
    gen_net = col_value[gen_up_cols] - col_value[gen_down_cols]
    dem_net = col_value[dem_up_cols] - col_value[dem_down_cols]
    delta_up_mw = np.maximum(gen_net, 0.0)
    delta_down_mw = np.maximum(-gen_net, 0.0)
    demand_delta_up_mw = np.maximum(dem_net, 0.0)
    demand_delta_down_mw = np.maximum(-dem_net, 0.0)
    dispatch_mw = p0 + gen_net
    demand_dispatch_mw = d0 + dem_net

    duals = OpfDuals(
        balance=float(sol.row_dual[0]) if n_rows else 0.0,
        flow_limit=np.asarray(sol.row_dual[1:n_rows], dtype=np.float64),
        gen_bound=np.asarray(sol.col_dual, dtype=np.float64)[gen_up_cols],
    )
    demand_bound = np.asarray(sol.col_dual, dtype=np.float64)[dem_up_cols]

    # True curves at the final point, constants included — the figures OpfSolution reports, so
    # that the wave's cost/welfare gaps compare like with like.
    poly_gen_cost = float(np.sum(c2 * dispatch_mw**2 + c1 * dispatch_mw + c0))
    pwl_gen_cost = float(sum(col_value[gen_cost_col_of[i]] for i in problem.pwl_gen_idxs))
    poly_demand_value = float(np.sum(v2 * demand_dispatch_mw**2 + v1 * demand_dispatch_mw + v0))
    pwl_demand_value = float(sum(col_value[dem_val_col_of[i]] for i in problem.demand_pwl_idxs))

    # Branch flows at the final point: flow_from_ptdf(ptdf, injection, arr) -- the same
    # PTDF-minus-shift identity solve_dc_opf and market._clearing use (module docstring;
    # M8 finding F1 / A19, task-shifter-flow-fix.plan.md T7).
    gen_by_bus = np.bincount(arr.gen_bus, weights=dispatch_mw, minlength=arr.n_bus)
    demand_by_bus = np.bincount(elastic_bus, weights=demand_dispatch_mw, minlength=arr.n_bus)
    injection_mw = gen_by_bus - demand_by_bus - p_load_mw - g_shunt_mw
    branch_flow_mw = flow_from_ptdf(ptdf_matrix, injection_mw, arr)

    return RedispatchSolution(
        status=status,
        dispatch_mw=dispatch_mw,
        demand_dispatch_mw=demand_dispatch_mw,
        delta_up_mw=delta_up_mw,
        delta_down_mw=delta_down_mw,
        demand_delta_up_mw=demand_delta_up_mw,
        demand_delta_down_mw=demand_delta_down_mw,
        branch_flow_mw=np.asarray(branch_flow_mw, dtype=np.float64),
        ptdf=ptdf_matrix,
        objective_cost=poly_gen_cost + pwl_gen_cost,
        demand_value=poly_demand_value + pwl_demand_value,
        duals=duals,
        demand_bound=demand_bound,
        message=None,
    )

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
def 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
    :func:`~mambo_power.opf.dc_opf.dc_opf`'s, validated by the very same
    :func:`~mambo_power.opf.dc_opf._extract_and_validate` -- so
    :class:`~mambo_power.opf.dc_opf.NonConvexCostError` and
    :class:`~mambo_power.opf.dc_opf.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 :func:`~mambo_power.opf.dc_opf.dc_opf` builds for an unrated network (module
    docstring, "Degenerate case").

    Raises :class:`ValueError` for a malformed partition or corridor map (:func:`_zone_labels`,
    :func:`_normalise_corridors`). Never raises for an infeasible or unbounded model -- reported
    through ``status``/``message``, mirroring ``dc_opf``.
    """
    n_gen = len(arr.gen_ids)
    n_load = len(arr.load_ids)
    problem = _extract_and_validate(
        cost_coeffs, pwl_costs, demand_bid_coeffs, demand_pwl_bids, n_gen, n_load
    )
    c2, c1, c0 = problem.c2, problem.c1, problem.c0
    v1, v2 = problem.v1, problem.v2
    elastic_load_idxs = problem.elastic_load_idxs
    n_pwl, n_demand, n_demand_pwl = problem.n_pwl, problem.n_demand, problem.n_demand_pwl

    bus_zone = _zone_labels(zone_of_bus, arr.bus_ids)
    zone_ids = sorted(set(bus_zone))
    corridor_caps = _normalise_corridors(corridors, zone_ids)
    corridor_ids = list(corridor_caps)
    n_zone, n_corridor = len(zone_ids), len(corridor_ids)
    zone_pos = {zone: i for i, zone in enumerate(zone_ids)}
    bus_zone_idx = np.asarray([zone_pos[z] for z in bus_zone], dtype=np.int64)

    n_dispatch = n_gen + n_demand
    demand_col_of = {idx: n_gen + j for j, idx in enumerate(elastic_load_idxs)}

    h = highspy.Highs()  # type: ignore[no-untyped-call]  # highspy ships no type stubs
    h.setOptionValue("output_flag", False)

    # --- tier 1a: generator and elastic-demand dispatch columns, exactly dc_opf's own block.
    if n_gen:
        h.addVars(n_gen, arr.gen_p_min_pu * arr.base_mva, arr.gen_p_max_pu * arr.base_mva)
        h.changeColsCost(n_gen, np.arange(n_gen, dtype=np.int32), c1)
    elastic_idx_arr = np.asarray(elastic_load_idxs, dtype=np.int64)
    if n_demand:
        h.addVars(
            n_demand,
            arr.load_p_min_pu[elastic_idx_arr] * arr.base_mva,
            arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva,
        )
        # minimising sum cost_g - sum value_d puts -v1 on the demand column (dc_opf, "Elastic
        # demand").
        h.changeColsCost(n_demand, np.arange(n_gen, n_dispatch, dtype=np.int32), -v1)

    # Hessian over the dispatch prefix, passed *before* any further column is appended -- the
    # ordering dc_opf already proves safe against later addVars calls (module docstring). The
    # assembly itself is dc_opf's helper, not a copy of it (ADR-008 one level down).
    _pass_diagonal_hessian(h, c2, v2, n_gen, n_demand)

    # --- tier 1b: one exchange column per corridor, bounded [-cap, +cap] (module docstring,
    # "Corridor sign convention"). No objective coefficient: a transfer is neither a cost nor a
    # value, and every economic consequence of moving power between zones is already carried by
    # the two balance rows the column appears in.
    corridor_cols = np.arange(n_dispatch, n_dispatch + n_corridor, dtype=np.int32)
    if n_corridor:
        caps = np.asarray(list(corridor_caps.values()), dtype=np.float64)
        caps = np.where(np.isinf(caps), highspy.kHighsInf, caps)
        h.addVars(n_corridor, -caps, caps)
    n_tier1 = n_dispatch + n_corridor

    # --- tier 2: the free PWL cost_g / val_d columns, appended after every tier-1 column exactly
    # as dc_opf appends them after its own dispatch block.
    cost_col_of: dict[int, int] = {}
    if n_pwl:
        cost_cols = np.arange(n_tier1, n_tier1 + n_pwl, dtype=np.int32)
        h.addVars(n_pwl, np.full(n_pwl, -highspy.kHighsInf), np.full(n_pwl, highspy.kHighsInf))
        h.changeColsCost(n_pwl, cost_cols, np.ones(n_pwl))
        cost_col_of = dict(zip(problem.pwl_gen_idxs, cost_cols.tolist(), strict=True))
    demand_val_col_of: dict[int, int] = {}
    if n_demand_pwl:
        val_cols = np.arange(n_tier1 + n_pwl, n_tier1 + n_pwl + n_demand_pwl, dtype=np.int32)
        h.addVars(
            n_demand_pwl,
            np.full(n_demand_pwl, -highspy.kHighsInf),
            np.full(n_demand_pwl, highspy.kHighsInf),
        )
        h.changeColsCost(n_demand_pwl, val_cols, -np.ones(n_demand_pwl))
        demand_val_col_of = dict(zip(problem.demand_pwl_idxs, val_cols.tolist(), strict=True))

    # --- per-zone fixed right-hand sides. The same double-counting contract dc_opf carries: each
    # elastic load's own historical p_mw (== arr.load_p_max_pu at its index) comes off its own
    # bus before that bus is aggregated into its zone, so the caller passes arr unmodified here too.
    p_load_mw = arr.p_load_pu * arr.base_mva
    if n_demand:
        p_load_mw = p_load_mw - np.bincount(
            arr.load_bus[elastic_idx_arr],
            weights=arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva,
            minlength=arr.n_bus,
        )
    fixed_bus_mw = p_load_mw + arr.g_shunt_pu * arr.base_mva
    fixed_zone_mw = np.bincount(bus_zone_idx, weights=fixed_bus_mw, minlength=n_zone)

    # --- one balance row per zone, row index == that zone's position in zone_ids. Built by
    # dc_opf's own _balance_row against this zone's own column sets: its generators and inbound
    # corridors inject, its bid loads and outbound corridors withdraw.
    gen_zone = bus_zone_idx[arr.gen_bus] if n_gen else np.zeros(0, dtype=np.int64)
    demand_zone = (
        bus_zone_idx[arr.load_bus[elastic_idx_arr]] if n_demand else np.zeros(0, dtype=np.int64)
    )
    inbound: list[list[int]] = [[] for _ in zone_ids]
    outbound: list[list[int]] = [[] for _ in zone_ids]
    for c, (z1, z2) in enumerate(corridor_ids):
        outbound[zone_pos[z1]].append(int(corridor_cols[c]))  # positive f leaves z1 ...
        inbound[zone_pos[z2]].append(int(corridor_cols[c]))  # ... and arrives in z2
    for z in range(n_zone):
        gen_cols_z = np.flatnonzero(gen_zone == z).astype(np.int32)
        demand_cols_z = (np.flatnonzero(demand_zone == z) + n_gen).astype(np.int32)
        _add_rows(
            h,
            _balance_row(
                np.concatenate([gen_cols_z, np.asarray(inbound[z], dtype=np.int32)]),
                np.concatenate([demand_cols_z, np.asarray(outbound[z], dtype=np.int32)]),
                float(fixed_zone_mw[z]),
            ),
        )

    # PWL epigraph / hypograph rows, appended after every balance row so the zone rows keep row
    # indices 0..n_zone-1 (module docstring, "Row layout").
    gen_cols = np.arange(n_gen, dtype=np.int32)
    _add_rows(h, _epigraph_rows(problem.segments_by_gen, gen_cols, cost_col_of))
    _add_rows(h, _hypograph_rows(problem.demand_segments_by_load, demand_col_of, demand_val_col_of))

    # The row-order contract is declared in the module docstring's "Row layout", implemented just
    # above, and re-derived here as a hand-maintained sum. Nothing else ties those three together:
    # ``zone_price`` below is ``row_dual[:n_zone]``, so a row family appended *before* the epigraph
    # block -- or a balance row not built for some zone -- silently reassigns every zone's price.
    # The PWL blocks are conditionally present, which is exactly when a slice-by-contract goes
    # wrong for one caller and not another. M5's own equivalent assert (opf/multiperiod.py) was
    # measured to be the only guard on its layout; this is the same guard for this one.
    n_epigraph = sum(len(segs) for segs in problem.segments_by_gen.values())
    n_hypograph = sum(len(segs) for segs in problem.demand_segments_by_load.values())
    expected_rows = n_zone + n_epigraph + n_hypograph
    assert h.getNumRow() == expected_rows, (
        f"zonal_dc_opf built {h.getNumRow()} rows, but the row-order contract in this module's "
        f"docstring accounts for {expected_rows} — the zone prices are read off that contract as "
        "row_dual[:n_zone], so they must agree"
    )

    h.run()
    status = h.modelStatusToString(h.getModelStatus())
    if status != _OPTIMAL:
        return ZonalSolution(
            status=status,
            zone_ids=zone_ids,
            corridor_ids=corridor_ids,
            dispatch_mw=np.zeros(n_gen),
            demand_dispatch_mw=np.zeros(n_demand),
            demand_bound=np.zeros(n_demand),
            corridor_flow_mw=np.zeros(n_corridor),
            objective_cost=0.0,
            duals=None,
            message=f"zonal_dc_opf: HiGHS reported model status {status!r}",
        )

    sol = h.getSolution()
    dispatch_mw = np.asarray(sol.col_value[:n_gen], dtype=np.float64)
    duals = ZonalDuals(
        zone_price=np.asarray(sol.row_dual[:n_zone], dtype=np.float64),
        corridor_cap=_corridor_cap_price(
            np.asarray(sol.col_dual[n_dispatch:n_tier1], dtype=np.float64)
        ),
        gen_bound=np.asarray(sol.col_dual[:n_gen], dtype=np.float64),
    )

    # generation cost only, computed from the dispatch rather than read off HiGHS's own objective
    # -- the identical construction, and the identical reason, as OpfSolution.objective_cost.
    poly_gen_cost = float(np.sum(c2 * dispatch_mw**2 + c1 * dispatch_mw + c0))
    pwl_gen_cost = float(sum(sol.col_value[cost_col_of[i]] for i in problem.pwl_gen_idxs))
    return ZonalSolution(
        status=status,
        zone_ids=zone_ids,
        corridor_ids=corridor_ids,
        dispatch_mw=dispatch_mw,
        demand_dispatch_mw=np.asarray(sol.col_value[n_gen:n_dispatch], dtype=np.float64),
        demand_bound=np.asarray(sol.col_dual[n_gen:n_dispatch], dtype=np.float64),
        corridor_flow_mw=np.asarray(sol.col_value[n_dispatch:n_tier1], dtype=np.float64),
        objective_cost=poly_gen_cost + pwl_gen_cost,
        duals=duals,
        message=None,
    )

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
def 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
    :class:`~mambo_power.model.PiecewiseCost` gets an all-zero row because its cost is captured
    entirely by the epigraph rows :func:`~mambo_power.opf.dc_opf.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 :class:`~mambo_power.opf.dc_opf.MissingCostError`
    (re-exported here as :data:`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``; :func:`dc_opf` raises ``NonConvexCostError`` (re-exported as
    :data:`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
    :class:`~mambo_power.model.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:
    :func:`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
    :func:`~mambo_power.opf.dc_opf.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 :func:`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
    (:func:`mambo_power.market.nodal.load_bid_coeffs`) has no prior-wave analog to share.
    """
    gens_by_id = {g.id: g for g in net.generators}
    if costs:
        in_arrays = set(arr.gen_ids)
        for gen_id in costs:
            if gen_id not in gens_by_id:
                raise ValueError(
                    f'costs names generator "{gen_id}", which is not in the network -- its entry '
                    f"would be silently ignored"
                )
            if gen_id not in in_arrays:
                raise ValueError(
                    f'costs names generator "{gen_id}", which is in the network but not in its '
                    f"arrays (out of service, or on a bus that is) -- its entry would be silently "
                    f"ignored"
                )
    coeffs = np.zeros((len(arr.gen_ids), 3))
    pwl_costs: PwlCosts = {}
    missing = [
        gen_id
        for gen_id in arr.gen_ids
        if (costs.get(gen_id, gens_by_id[gen_id].cost) if costs else gens_by_id[gen_id].cost)
        is None
    ]
    if missing:
        raise MissingCostError(missing)
    for i, gen_id in enumerate(arr.gen_ids):
        cost = costs.get(gen_id, gens_by_id[gen_id].cost) if costs else gens_by_id[gen_id].cost
        assert cost is not None  # the MissingCostError check above
        if cost.kind == "piecewise":
            pwl_costs[i] = list(cost.points)
            continue
        values = list(cost.coefficients)
        if len(values) > 3:
            raise NotImplementedError(
                f'generator "{gen_id}" has a degree-{len(values) - 1} polynomial cost; '
                "opf.solve_dc_opf supports polynomial costs up to quadratic only"
            )
        coeffs[i, 3 - len(values) :] = values  # right-align: [c1, c0] -> [0, c1, c0], etc.
    return coeffs, pwl_costs

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
def 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 :func:`mambo_power.pf.solve_ac`'s
    never-raise-on-non-convergence convention. Raises :class:`NonConvexCostError` up front for a
    generator whose :class:`~mambo_power.model.PiecewiseCost` is not convex, and
    :class:`MissingCostError` for one with no cost at all (see :func:`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),
    :func:`mambo_power.pf.solve_ac` re-solves that copy, and
    :func:`mambo_power.results.feasibility_report` builds the report from that solved state plus
    the copy's own declared bounds.
    """
    opts = options if options is not None else OpfDcOptions()
    started_at = datetime.now(UTC)
    clock = time.perf_counter()
    arr = NetworkArrays.from_network(net)
    cost_coeffs, pwl_costs = gen_cost_coeffs(net, arr)
    solution = dc_opf(arr, cost_coeffs, opts, pwl_costs=pwl_costs or None)
    elapsed_s = time.perf_counter() - clock
    provenance = ResultProvenance(
        engine="mambo-power",
        version=mambo_power.__version__,
        kind="opf.dc",
        solver="highspy.Highs",
        started_at=started_at,
        elapsed_s=elapsed_s,
        options=opts.model_dump(),
    )
    if solution.status != "Optimal" or solution.duals is None:
        return OpfDcResult(provenance=provenance, status=solution.status, message=solution.message)

    # solve_dc_opf reuses the PTDF matrix dc_opf already built (OpfSolution.ptdf docstring)
    # instead of recomputing it — review Performance FLAG, ~62% of a warm solve_dc_opf call.
    ptdf_matrix = solution.ptdf
    lmp = lmp_decomposition(solution.duals, ptdf_matrix)

    # branch flows at the found dispatch: flow_from_ptdf(ptdf, injection, arr) — the same
    # PTDF-minus-shift identity dc_opf's own flow-limit rows are built from by hand (module
    # docstring there) and pf.solve_dc solves for directly (numerics.bbus.flow_from_ptdf).
    gen_by_bus = np.bincount(arr.gen_bus, weights=solution.dispatch_mw, minlength=arr.n_bus)
    p_load_mw = arr.p_load_pu * arr.base_mva
    g_shunt_mw = arr.g_shunt_pu * arr.base_mva
    injection_mw = gen_by_bus - p_load_mw - g_shunt_mw
    flows_mw = flow_from_ptdf(ptdf_matrix, injection_mw, arr)

    generators = [
        GenDispatchResult(
            id=gen_id,
            bus=arr.bus_ids[int(arr.gen_bus[i])],
            p_mw=float(solution.dispatch_mw[i]),
            bound_dual=float(solution.duals.gen_bound[i]),
        )
        for i, gen_id in enumerate(arr.gen_ids)
    ]
    buses = [
        BusLmpResult(
            id=bus_id,
            lmp=float(lmp.lmp[i]),
            energy=float(lmp.energy[i]),
            congestion=float(lmp.congestion[i]),
        )
        for i, bus_id in enumerate(arr.bus_ids)
    ]
    branches = [
        OpfBranchFlowResult(
            id=br_id,
            from_bus=arr.bus_ids[int(arr.f[k])],
            to_bus=arr.bus_ids[int(arr.t[k])],
            p_from_mw=float(flows_mw[k]),
            flow_limit_dual=float(solution.duals.flow_limit[k]),
        )
        for k, br_id in enumerate(arr.branch_ids)
    ]
    ac_check = None
    if opts.ac_check:
        dispatched = net.model_copy(deep=True)
        p_mw_by_id = {row.id: row.p_mw for row in generators}
        for gen in dispatched.generators:
            if gen.id in p_mw_by_id:
                gen.p_mw = p_mw_by_id[gen.id]
        ac_check = feasibility_report(solve_ac(dispatched), dispatched)
    return OpfDcResult(
        provenance=provenance,
        status=solution.status,
        message=None,
        objective_cost=solution.objective_cost,
        balance_dual=solution.duals.balance,
        generators=generators,
        buses=buses,
        branches=branches,
        ac_check=ac_check,
    )

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, where const_k folds in the branch's fixed (load/shunt/phase-shift) contribution to its flow — see the derivation in dc_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

ColArray = npt.NDArray[np.int32]

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 = 'highspy.Highs'

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

ac_check: bool = False

Re-run pf.solve_ac on the dispatched network and attach a results.FeasibilityReport as OpfDcResult.ac_check.

OpfDuals dataclass

OpfDuals(
    balance: float,
    flow_limit: FloatArray,
    gen_bound: FloatArray,
)

Shadow prices from one dc_opf solve, in NetworkArrays order.

balance instance-attribute

balance: float

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

flow_limit: FloatArray

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

gen_bound: FloatArray

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

status: str

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

dispatch_mw: FloatArray

Per-generator dispatch, MW, generator order; all-zero when status != "Optimal".

ptdf instance-attribute

ptdf: FloatArray

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

objective_cost: float

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".

duals instance-attribute

duals: OpfDuals | None

None exactly when status != "Optimal".

message class-attribute instance-attribute

message: str | None = None

Diagnostic when status != "Optimal"; None otherwise.

demand_dispatch_mw class-attribute instance-attribute

demand_dispatch_mw: FloatArray = field(
    default_factory=lambda: np.zeros(0)
)

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

demand_bound: FloatArray = field(
    default_factory=lambda: np.zeros(0)
)

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

LmpBreakdown(
    lmp: FloatArray,
    energy: FloatArray,
    congestion: FloatArray,
)

Per-bus locational marginal price, decomposed into its energy and congestion terms.

lmp instance-attribute

lmp: FloatArray

energy + congestion, bus order.

energy instance-attribute

energy: FloatArray

The balance dual, broadcast to every bus (uniform system-wide energy price).

congestion instance-attribute

congestion: FloatArray

flow_limit_duals @ ptdf: each bus's exposure to every binding flow-limit row.

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

MissingCostError(generator_ids: Sequence[str])

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
def __init__(self, generator_ids: Sequence[str]) -> None:
    self.generator_ids = list(generator_ids)
    ids = ", ".join(f'"{gen_id}"' for gen_id in self.generator_ids)
    noun = "generator" if len(self.generator_ids) == 1 else "generators"
    super().__init__(
        f"{noun} {ids} {'has' if len(self.generator_ids) == 1 else 'have'} no cost "
        "(Generator.cost is None); a DC-OPF cannot price a cost-less generator -- set "
        "Generator.cost, or take the generator out of service (only in-service generators "
        "are priced)"
    )

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
def 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 :func:`dc_opf`/:func:`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.
    """
    n_bus = ptdf.shape[1]
    energy = np.full(n_bus, duals.balance, dtype=np.float64)
    congestion = np.asarray(duals.flow_limit @ ptdf, dtype=np.float64)
    return LmpBreakdown(lmp=energy + congestion, energy=energy, congestion=congestion)

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
def 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
    :class:`NonConvexCostError` up front (before any HiGHS object exists) for a non-convex
    generator cost (piecewise-linear or ``c2 < 0`` quadratic), and :class:`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
    :func:`~mambo_power.numerics.ptdf.ptdf` returns it) for a caller that solves the same network
    many times with different coefficients — :func:`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 :class:`ValueError` up front — the
    one way a stale cache from another network can be told apart from this one.
    """
    del options  # no tunable fields yet (OpfDcOptions docstring)
    n_gen = len(arr.gen_ids)
    n_load = len(arr.load_ids)
    if ptdf is not None and ptdf.shape != (arr.n_branch, arr.n_bus):
        raise ValueError(
            f"ptdf must have shape ({arr.n_branch}, {arr.n_bus}) (n_branch, n_bus) for this "
            f"network, got {ptdf.shape} — a precomputed PTDF is only valid for the network it "
            "was computed from"
        )
    problem = _extract_and_validate(
        cost_coeffs, pwl_costs, demand_bid_coeffs, demand_pwl_bids, n_gen, n_load
    )
    c2, c1, c0 = problem.c2, problem.c1, problem.c0
    v1, v2 = problem.v1, problem.v2
    pwl_gen_idxs, segments_by_gen = problem.pwl_gen_idxs, problem.segments_by_gen
    demand_pwl_idxs, demand_segments_by_load = (
        problem.demand_pwl_idxs,
        problem.demand_segments_by_load,
    )
    elastic_load_idxs = problem.elastic_load_idxs
    n_pwl, n_demand, n_demand_pwl = problem.n_pwl, problem.n_demand, problem.n_demand_pwl
    demand_col_of = {idx: n_gen + j for j, idx in enumerate(elastic_load_idxs)}

    h = highspy.Highs()  # type: ignore[no-untyped-call]  # highspy ships no type stubs
    h.setOptionValue("output_flag", False)

    p_min = arr.gen_p_min_pu * arr.base_mva
    p_max = arr.gen_p_max_pu * arr.base_mva
    if n_gen:
        h.addVars(n_gen, p_min, p_max)
        h.changeColsCost(n_gen, np.arange(n_gen, dtype=np.int32), c1)

    elastic_idx_arr = np.asarray(elastic_load_idxs, dtype=np.int64)
    if n_demand:
        demand_p_min = arr.load_p_min_pu[elastic_idx_arr] * arr.base_mva
        demand_p_max = arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva
        h.addVars(n_demand, demand_p_min, demand_p_max)
        # minimising Σcost_g − Σvalue_d means the demand column's linear objective coefficient is
        # −v1 (module docstring, "Elastic demand").
        h.changeColsCost(n_demand, np.arange(n_gen, n_gen + n_demand, dtype=np.int32), -v1)

    # combined Hessian over both dispatch blocks (generator [0, n_gen), demand [n_gen,
    # n_dispatch)), built and passed once, before any free-var (PWL cost_g/val_d) column is
    # appended — the same ordering already proven safe against later addVars calls by the
    # existing case14_pwl fixture test (quadratic + PWL generators mixed in one solve).
    n_dispatch = n_gen + n_demand
    _pass_diagonal_hessian(h, c2, v2, n_gen, n_demand)

    # PWL cost columns: one free "cost_g" variable per PWL generator, appended after the
    # n_dispatch generator+demand columns (module docstring, "PWL costs"). Objective coefficient
    # 1 — minimising the LP pulls each cost_g down to the tightest epigraph bound, i.e. exactly
    # cost(p_g).
    cost_col_of: dict[int, int] = {}
    if n_pwl:
        cost_cols = np.arange(n_dispatch, n_dispatch + n_pwl, dtype=np.int32)
        h.addVars(n_pwl, np.full(n_pwl, -highspy.kHighsInf), np.full(n_pwl, highspy.kHighsInf))
        h.changeColsCost(n_pwl, cost_cols, np.ones(n_pwl))
        cost_col_of = dict(zip(pwl_gen_idxs, cost_cols.tolist(), strict=True))

    # PWL demand columns: one free "val_d" variable per PWL bid-load, appended after the PWL cost
    # columns (module docstring, "Elastic demand"). Objective coefficient −1 — minimising
    # −Σval_d pulls each val_d up to the tightest hypograph bound, i.e. exactly value(p_d).
    demand_val_col_of: dict[int, int] = {}
    if n_demand_pwl:
        val_cols = np.arange(n_dispatch + n_pwl, n_dispatch + n_pwl + n_demand_pwl, dtype=np.int32)
        h.addVars(
            n_demand_pwl,
            np.full(n_demand_pwl, -highspy.kHighsInf),
            np.full(n_demand_pwl, highspy.kHighsInf),
        )
        h.changeColsCost(n_demand_pwl, val_cols, -np.ones(n_demand_pwl))
        demand_val_col_of = dict(zip(demand_pwl_idxs, val_cols.tolist(), strict=True))

    # --- nodal balance: Σ p_g − Σ p_d == Σ p_load_fixed + Σ g_shunt (module docstring; phase
    # shifts cancel system-wide because Σ_bus p_shift == Σ_branch (pf_shift_k − pf_shift_k) == 0
    # identically). p_load_fixed excludes every elastic load's own contribution (double-counting
    # contract, module docstring, "Elastic demand") — each elastic load's own historical p_mw
    # (== arr.load_p_max_pu at its index, by construction, W3) is removed from the bus it sits on
    # before the fixed aggregate is used anywhere below.
    p_load_mw = arr.p_load_pu * arr.base_mva
    if n_demand:
        elastic_bus = arr.load_bus[elastic_idx_arr]
        elastic_own_mw = arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva
        p_load_mw = p_load_mw - np.bincount(
            elastic_bus, weights=elastic_own_mw, minlength=arr.n_bus
        )
    g_shunt_mw = arr.g_shunt_pu * arr.base_mva
    total_fixed = float(np.sum(p_load_mw) + np.sum(g_shunt_mw))

    # --- flow-limit rows: flow_k = Σ_g PTDF[k, gen_bus[g]]·p_g − Σ_d PTDF[k, load_bus[d]]·p_d +
    # const_k, where const_k = pf_shift_mw_k − Σ_bus PTDF[k, bus]·(p_load_fixed_mw[bus] +
    # g_shunt_mw[bus] + p_shift_mw[bus]) folds in every *fixed* contribution to branch k's flow,
    # including the phase-shifter bus injection p_shift (numerics.bbus.p_shift) that the *decision
    # variables* (generator/demand dispatch) never carry -- this is the same
    # flow = PTDF @ (injection − p_shift) + pf_shift identity numerics.bbus.flow_from_ptdf applies
    # to a full injection vector (module docstring above; M8 finding F1 / A19), but const_k here
    # is a constant added to a linear combination of decision variables rather than a product with
    # one full injection vector, so it folds in p_shift by hand instead of calling that helper.
    # Row bounds: −rating_k − const_k <= row_expr_k <= rating_k − const_k.
    ptdf_matrix = compute_ptdf(arr) if ptdf is None else ptdf
    pf_shift_mw = pf_shift(arr) * arr.base_mva
    p_shift_mw = p_shift(arr) * arr.base_mva
    fixed_bus_mw = p_load_mw + g_shunt_mw
    const = pf_shift_mw - ptdf_matrix @ (fixed_bus_mw + p_shift_mw)
    rating_mw = arr.rating_pu * arr.base_mva  # inf where unrated -> row never binds

    # Each row family is built by its own helper (module docstring, "Row-family core") against
    # explicit column indices, and appended in the order their row indices are read back below:
    # the balance row is row 0, the flow-limit rows are 1..n_branch.
    n_rows = 1 + arr.n_branch
    gen_cols = np.arange(n_gen, dtype=np.int32)
    demand_cols = np.arange(n_gen, n_dispatch, dtype=np.int32)

    _add_rows(h, _balance_row(gen_cols, demand_cols, total_fixed))
    _add_rows(
        h,
        _flow_limit_rows(
            ptdf_matrix,
            gen_cols,
            arr.gen_bus,
            demand_cols,
            arr.load_bus[elastic_idx_arr],
            rating_mw,
            const,
        ),
    )

    # PWL epigraph / hypograph rows, appended after the balance and flow-limit rows so those
    # rows' indices (0 and 1..n_branch, read below) are unaffected.
    _add_rows(h, _epigraph_rows(segments_by_gen, gen_cols, cost_col_of))
    _add_rows(h, _hypograph_rows(demand_segments_by_load, demand_col_of, demand_val_col_of))

    h.run()
    status = h.modelStatusToString(h.getModelStatus())
    if status != _OPTIMAL:
        return OpfSolution(
            status=status,
            dispatch_mw=np.zeros(n_gen),
            ptdf=ptdf_matrix,
            objective_cost=0.0,
            duals=None,
            message=f"dc_opf: HiGHS reported model status {status!r}",
            demand_dispatch_mw=np.zeros(n_demand),
            demand_bound=np.zeros(n_demand),
        )

    sol = h.getSolution()
    # only the first n_dispatch columns/rows are the generator+demand dispatch / balance+flow-
    # limit rows this wave's callers know about — PWL cost_g/val_d columns and epigraph/hypograph
    # rows (if any) are appended after them and are an internal encoding detail, not part of
    # OpfSolution/OpfDuals's shape.
    dispatch_mw = np.asarray(sol.col_value[:n_gen], dtype=np.float64)
    demand_dispatch_mw = np.asarray(sol.col_value[n_gen:n_dispatch], dtype=np.float64)
    duals = OpfDuals(
        balance=float(sol.row_dual[0]) if n_rows else 0.0,
        flow_limit=np.asarray(sol.row_dual[1:n_rows], dtype=np.float64),
        gen_bound=np.asarray(sol.col_dual[:n_gen], dtype=np.float64),
    )
    demand_bound = np.asarray(sol.col_dual[n_gen:n_dispatch], dtype=np.float64)

    # objective_cost stays "total generation cost only" (unchanged M2/M3 semantics, docstring on
    # OpfSolution.objective_cost) even with elastic demand in the same solve — computed directly
    # from generator dispatch + PWL cost_g values rather than HiGHS's own combined objective
    # (which, with demand columns present, also nets in the negated demand value). Algebraically
    # identical to the pre-M4 formula (`objective_function_value + Σc0`) whenever n_demand == 0.
    poly_gen_cost = float(np.sum(c2 * dispatch_mw**2 + c1 * dispatch_mw + c0))
    pwl_gen_cost = float(sum(sol.col_value[cost_col_of[i]] for i in pwl_gen_idxs))
    objective_cost = poly_gen_cost + pwl_gen_cost
    return OpfSolution(
        status=status,
        dispatch_mw=dispatch_mw,
        ptdf=ptdf_matrix,
        objective_cost=objective_cost,
        duals=duals,
        message=None,
        demand_dispatch_mw=demand_dispatch_mw,
        demand_bound=demand_bound,
    )

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 exactly 0 is 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 = 0 it anchors to the unit's own initial energy, soc[0] - eta_c*charge[0] + discharge[0]/eta_d == soc_initial*energy_mwh; for t >= 1 it couples adjacent periods, soc[t] - soc[t-1] - eta_c*charge[t] + discharge[t]/eta_d == 0.
  • Cyclic end of horizonsoc[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

balance: FloatArray

(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

flow_limit: FloatArray

(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

gen_bound: FloatArray

(T, n_gen) — per-period reduced cost of each generator's [p_min, p_max] bound.

demand_bound instance-attribute

demand_bound: FloatArray

(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

ramp: FloatArray

(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

soc_balance: FloatArray

(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

storage_power_limit: FloatArray

(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

storage_soc_bound: FloatArray

(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

cyclic: FloatArray

(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

status: str

HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).

n_periods instance-attribute

n_periods: int

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

dispatch_mw: FloatArray

(T, n_gen) per-generator dispatch, MW, NetworkArrays generator order.

demand_dispatch_mw instance-attribute

demand_dispatch_mw: FloatArray

(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

storage_charge_mw: FloatArray

(T, n_storage) charging power, MW, NetworkArrays storage order; nonnegative.

storage_discharge_mw instance-attribute

storage_discharge_mw: FloatArray

(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

storage_soc_mwh: FloatArray

(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

ptdf: FloatArray

The single PTDF matrix used for every period's flow rows, returned for reuse (LMP decomposition) exactly as ptdf is.

objective_cost instance-attribute

objective_cost: float

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.

duals instance-attribute

duals: MultiperiodDuals | None

None exactly when status != "Optimal".

message class-attribute instance-attribute

message: str | None = None

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
def 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
    :func:`~mambo_power.opf.dc_opf.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
    :class:`~mambo_power.model.Storage` builds no storage column and no SoC row, so the storage
    formulation costs a storage-free caller nothing.

    Raises :class:`~mambo_power.opf.dc_opf.NonConvexCostError` /
    :class:`~mambo_power.opf.dc_opf.NonConcaveBidError` up front for a non-convex cost or
    non-concave bid, and :class:`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``.
    """
    n_gen = len(arr.gen_ids)
    n_load = len(arr.load_ids)
    n_storage = len(arr.storage_ids)
    n_branch = arr.n_branch

    if n_periods < 1:
        raise ValueError(f"n_periods must be >= 1, got {n_periods}")

    # --- cost/bid extraction and validation: dc_opf's own helper, not a copy of it (ADR-008).
    # It runs before period_load_mw/ramp validation because every guard it carries is promised
    # "up front" by this function's own docstring and by the two error classes' docstrings.
    problem = _extract_and_validate(
        cost_coeffs, pwl_costs, demand_bid_coeffs, demand_pwl_bids, n_gen, n_load
    )
    c2, c1, c0 = problem.c2, problem.c1, problem.c0
    v1, v2 = problem.v1, problem.v2
    pwl_gen_idxs, segments_by_gen = problem.pwl_gen_idxs, problem.segments_by_gen
    demand_pwl_idxs, demand_segments_by_load = (
        problem.demand_pwl_idxs,
        problem.demand_segments_by_load,
    )
    elastic_load_idxs = problem.elastic_load_idxs
    n_pwl, n_demand, n_demand_pwl = problem.n_pwl, problem.n_demand, problem.n_demand_pwl

    if period_load_mw is not None:
        period_load_mw = np.asarray(period_load_mw, dtype=np.float64)
        if period_load_mw.shape != (n_periods, n_load):
            raise ValueError(
                f"period_load_mw must have shape ({n_periods}, {n_load}) "
                f"(MW per load, NetworkArrays.load_ids order), got {period_load_mw.shape}"
            )
    ramp_up = _checked_ramp("ramp_up_mw", ramp_up_mw, n_gen)
    ramp_down = _checked_ramp("ramp_down_mw", ramp_down_mw, n_gen)

    # --- column layout (module docstring, "Column layout") -------------------------------------
    per_period_dispatch = n_gen + n_demand + 3 * n_storage
    per_period_free = n_pwl + n_demand_pwl
    n_dispatch_total = n_periods * per_period_dispatch

    def _cols(base: int, offset: int, count: int) -> ColArray:
        return np.arange(base + offset, base + offset + count, dtype=np.int32)

    gen_cols: list[ColArray] = []
    demand_cols: list[ColArray] = []
    charge_cols: list[ColArray] = []
    discharge_cols: list[ColArray] = []
    soc_cols: list[ColArray] = []
    for t in range(n_periods):
        base = t * per_period_dispatch
        gen_cols.append(_cols(base, 0, n_gen))
        demand_cols.append(_cols(base, n_gen, n_demand))
        charge_cols.append(_cols(base, n_gen + n_demand, n_storage))
        discharge_cols.append(_cols(base, n_gen + n_demand + n_storage, n_storage))
        soc_cols.append(_cols(base, n_gen + n_demand + 2 * n_storage, n_storage))

    cost_col_of: list[dict[int, int]] = []
    demand_val_col_of: list[dict[int, int]] = []
    for t in range(n_periods):
        base = n_dispatch_total + t * per_period_free
        cost_col_of.append(dict(zip(pwl_gen_idxs, range(base, base + n_pwl), strict=True)))
        demand_val_col_of.append(
            dict(
                zip(
                    demand_pwl_idxs,
                    range(base + n_pwl, base + n_pwl + n_demand_pwl),
                    strict=True,
                )
            )
        )
    # dc_opf keys its epigraph rows by generator index into a *dense* gen-column array; the
    # per-period equivalent is that period's own gen_cols slice.
    demand_col_of: list[dict[int, int]] = [
        dict(zip(elastic_load_idxs, cols.tolist(), strict=True)) for cols in demand_cols
    ]

    h = highspy.Highs()  # type: ignore[no-untyped-call]  # highspy ships no type stubs
    h.setOptionValue("output_flag", False)

    # --- tier 1 columns, period by period (the same addVars/changeColsCost sequence dc_opf
    # issues, repeated once per period, so a one-period solve is the identical call sequence).
    p_min = arr.gen_p_min_pu * arr.base_mva
    p_max = arr.gen_p_max_pu * arr.base_mva
    elastic_idx_arr = np.asarray(elastic_load_idxs, dtype=np.int64)
    demand_p_min = arr.load_p_min_pu[elastic_idx_arr] * arr.base_mva
    # ``(n_periods, n_demand)``: a bid load's upper bound is **that period's** own demand, not the
    # network's base one. ``Load.p_mw`` is the largest quantity an elastic load's bid can clear
    # (M4's elastic-demand contract) and ``Period.load_p_mw`` overrides ``p_mw``, so the override
    # has to move this bound with it. A bound frozen at ``arr.load_p_max_pu`` would cancel the
    # override exactly: the period's own value is already removed from the fixed-load total below
    # (the double-counting contract), so the column would re-serve the *base* quantity and a
    # profile would have no effect at all on any load that bids. The bid itself stays
    # horizon-invariant -- what moves is the quantity anchor, not the willingness-to-pay curve.
    # ``load_p_min_pu`` is not derived from ``p_mw``, so it does not move.
    if period_load_mw is None:
        demand_p_max = np.tile(arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva, (n_periods, 1))
    else:
        demand_p_max = period_load_mw[:, elastic_idx_arr]
    storage_p_max = arr.storage_p_max_pu * arr.base_mva
    storage_energy = arr.storage_energy_pu * arr.base_mva
    soc_initial_mwh = arr.storage_soc_initial * storage_energy
    eta_charge = arr.storage_efficiency_charge
    eta_discharge = arr.storage_efficiency_discharge

    for t in range(n_periods):
        if n_gen:
            h.addVars(n_gen, p_min, p_max)
            h.changeColsCost(n_gen, gen_cols[t], c1)
        if n_demand:
            h.addVars(n_demand, demand_p_min, demand_p_max[t])
            # minimising sum(cost_g) - sum(value_d): the demand column's linear coefficient is -v1
            h.changeColsCost(n_demand, demand_cols[t], -v1)
        if n_storage:
            h.addVars(n_storage, np.zeros(n_storage), storage_p_max)
            h.addVars(n_storage, np.zeros(n_storage), storage_p_max)
            h.addVars(n_storage, np.zeros(n_storage), storage_energy)

    # --- Hessian over tier 1 only, passed before any tier-2 column exists (module docstring).
    # dc_opf's own helper, not a copy of it (ADR-008 one level down): one block per period,
    # per_period_dispatch wide, whose 3*n_storage storage columns carry no quadratic term.
    _pass_diagonal_hessian(
        h,
        c2,
        v2,
        n_gen,
        n_demand,
        n_blocks=n_periods,
        block_stride=per_period_dispatch,
    )

    # --- tier 2 columns: the free PWL cost_g / val_d variables, period by period.
    for t in range(n_periods):
        if n_pwl:
            cols = np.asarray(sorted(cost_col_of[t].values()), dtype=np.int32)
            h.addVars(n_pwl, np.full(n_pwl, -highspy.kHighsInf), np.full(n_pwl, highspy.kHighsInf))
            h.changeColsCost(n_pwl, cols, np.ones(n_pwl))
        if n_demand_pwl:
            cols = np.asarray(sorted(demand_val_col_of[t].values()), dtype=np.int32)
            h.addVars(
                n_demand_pwl,
                np.full(n_demand_pwl, -highspy.kHighsInf),
                np.full(n_demand_pwl, highspy.kHighsInf),
            )
            h.changeColsCost(n_demand_pwl, cols, -np.ones(n_demand_pwl))

    # --- per-period fixed load, and the flow-row constant it drives -----------------------------
    # dc_opf's own arithmetic, evaluated once per period: the bus-aggregate fixed load minus each
    # elastic load's own contribution at its own bus (the double-counting contract). With
    # period_load_mw=None the expressions below are literally dc_opf's, which is what makes the
    # T=1 reduction exact rather than merely close. p_shift_mw (numerics.bbus.p_shift) is the
    # phase-shifter bus injection dc_opf's own const_k folds in by hand (dc_opf.py's flow-limit
    # row comment; M8 finding F1 / A19, task-shifter-flow-fix.plan.md T6) -- it is
    # period-invariant (a function of topology, not dispatch), so it is computed once here rather
    # than inside the per-period loop below.
    ptdf_matrix = compute_ptdf(arr)
    pf_shift_mw = pf_shift(arr) * arr.base_mva
    p_shift_mw = p_shift(arr) * arr.base_mva
    g_shunt_mw = arr.g_shunt_pu * arr.base_mva
    rating_mw = arr.rating_pu * arr.base_mva
    elastic_bus = arr.load_bus[elastic_idx_arr]

    total_fixed: list[float] = []
    const: list[FloatArray] = []
    for t in range(n_periods):
        if period_load_mw is None:
            p_load_mw = arr.p_load_pu * arr.base_mva
            elastic_own_mw = arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva
        else:
            p_load_mw = np.asarray(
                np.bincount(arr.load_bus, weights=period_load_mw[t], minlength=arr.n_bus),
                dtype=np.float64,
            )
            elastic_own_mw = period_load_mw[t][elastic_idx_arr]
        if n_demand:
            p_load_mw = p_load_mw - np.bincount(
                elastic_bus, weights=elastic_own_mw, minlength=arr.n_bus
            )
        total_fixed.append(float(np.sum(p_load_mw) + np.sum(g_shunt_mw)))
        const.append(pf_shift_mw - ptdf_matrix @ (p_load_mw + g_shunt_mw + p_shift_mw))

    # --- rows, in the order the module docstring's table declares -------------------------------
    # tier 1: nodal balance, one row per period. Storage discharges into the balance and charges
    # out of it, so it joins the generator/elastic-load sides of the *same* helper.
    for t in range(n_periods):
        _add_rows(
            h,
            _balance_row(
                np.concatenate([gen_cols[t], discharge_cols[t]]),
                np.concatenate([demand_cols[t], charge_cols[t]]),
                total_fixed[t],
            ),
        )

    # tier 2: PTDF flow-limit rows, per branch per period. Storage sits at a bus like anything
    # else, so its two columns carry that bus's PTDF column with the injection/withdrawal sign.
    injection_bus = np.concatenate([arr.gen_bus, arr.storage_bus])
    withdrawal_bus = np.concatenate([elastic_bus, arr.storage_bus])
    for t in range(n_periods):
        _add_rows(
            h,
            _flow_limit_rows(
                ptdf_matrix,
                np.concatenate([gen_cols[t], discharge_cols[t]]),
                injection_bus,
                np.concatenate([demand_cols[t], charge_cols[t]]),
                withdrawal_bus,
                rating_mw,
                const[t],
            ),
        )

    # tier 3: SoC balance, one equality row per unit per period.
    if n_storage:
        soc_rows: list[list[tuple[int, float]]] = []
        soc_rhs: list[float] = []
        for t in range(n_periods):
            for s in range(n_storage):
                row = [
                    (int(soc_cols[t][s]), 1.0),
                    (int(charge_cols[t][s]), -float(eta_charge[s])),
                    (int(discharge_cols[t][s]), 1.0 / float(eta_discharge[s])),
                ]
                if t == 0:
                    soc_rhs.append(float(soc_initial_mwh[s]))
                else:
                    row.append((int(soc_cols[t - 1][s]), -1.0))
                    soc_rhs.append(0.0)
                soc_rows.append(row)
        _add_rows(h, _sparse_rows(soc_rows, soc_rhs, soc_rhs))

        # tier 4: the shared power-limit row (research §3.3 option 1) — bounds how much overlap
        # the formulation can ever reach for, without banning the case where it is required.
        limit_rows = [
            [(int(charge_cols[t][s]), 1.0), (int(discharge_cols[t][s]), 1.0)]
            for t in range(n_periods)
            for s in range(n_storage)
        ]
        limit_upper = np.tile(storage_p_max, n_periods)
        _add_rows(
            h,
            _sparse_rows(limit_rows, np.full(limit_upper.size, -highspy.kHighsInf), limit_upper),
        )

        # tier 5: cyclic end-of-horizon SoC (scope answer 2 — not configurable).
        last = n_periods - 1
        cyclic_rows = [[(int(soc_cols[last][s]), 1.0)] for s in range(n_storage)]
        _add_rows(h, _sparse_rows(cyclic_rows, soc_initial_mwh, soc_initial_mwh))

    # tier 6: ramp coupling. A generator with neither limit gets no row; a one-sided limit gets a
    # genuinely infinite bound on the other side.
    ramped = np.flatnonzero(np.isfinite(ramp_up) | np.isfinite(ramp_down))
    n_ramped = int(ramped.size)
    if n_ramped and n_periods > 1:
        ramp_rows = [
            [(int(gen_cols[t][g]), 1.0), (int(gen_cols[t - 1][g]), -1.0)]
            for t in range(1, n_periods)
            for g in ramped
        ]
        down = np.where(np.isfinite(ramp_down[ramped]), -ramp_down[ramped], -highspy.kHighsInf)
        up = np.where(np.isfinite(ramp_up[ramped]), ramp_up[ramped], highspy.kHighsInf)
        _add_rows(
            h,
            _sparse_rows(ramp_rows, np.tile(down, n_periods - 1), np.tile(up, n_periods - 1)),
        )

    # tiers 7-8: PWL epigraph / hypograph rows, appended last so no dual index above shifts.
    for t in range(n_periods):
        _add_rows(h, _epigraph_rows(segments_by_gen, gen_cols[t], cost_col_of[t]))
    for t in range(n_periods):
        _add_rows(
            h, _hypograph_rows(demand_segments_by_load, demand_col_of[t], demand_val_col_of[t])
        )

    h.run()
    status = h.modelStatusToString(h.getModelStatus())
    if status != _OPTIMAL:
        return MultiperiodSolution(
            status=status,
            n_periods=n_periods,
            dispatch_mw=np.zeros((n_periods, n_gen)),
            demand_dispatch_mw=np.zeros((n_periods, n_demand)),
            storage_charge_mw=np.zeros((n_periods, n_storage)),
            storage_discharge_mw=np.zeros((n_periods, n_storage)),
            storage_soc_mwh=np.zeros((n_periods, n_storage)),
            ptdf=ptdf_matrix,
            objective_cost=0.0,
            duals=None,
            message=f"multiperiod_dc_opf: HiGHS reported model status {status!r}",
        )

    sol = h.getSolution()
    col_value = np.asarray(sol.col_value, dtype=np.float64)
    col_dual = np.asarray(sol.col_dual, dtype=np.float64)
    row_dual = np.asarray(sol.row_dual, dtype=np.float64)

    def _read(cols: list[ColArray], source: FloatArray) -> FloatArray:
        """``(T, width)`` view of one per-period column family, period-major."""
        out = np.zeros((n_periods, cols[0].size))
        for t, period_cols in enumerate(cols):
            out[t] = source[period_cols]
        return out

    dispatch_mw = _read(gen_cols, col_value)
    demand_dispatch_mw = _read(demand_cols, col_value)
    storage_charge_mw = _read(charge_cols, col_value)
    storage_discharge_mw = _read(discharge_cols, col_value)
    storage_soc_mwh = _read(soc_cols, col_value)

    # The row-order contract is declared in the module docstring's table, implemented once ~150
    # lines above, and re-derived just below as a hand-maintained running sum. Nothing else ties
    # those three together: a row family appended after tier 6 and not accounted for here shifts
    # every dual index below it, silently and by exactly its own height. The ``.reshape`` calls
    # below catch some of that, but only when a storage unit happens to exist. This does not
    # depend on anything happening to exist.
    n_epigraph = sum(len(segments_by_gen[i]) for i in pwl_gen_idxs)
    n_hypograph = sum(len(demand_segments_by_load[i]) for i in demand_pwl_idxs)
    expected_rows = (
        n_periods * (1 + n_branch + 2 * n_storage)  # tiers 1-4
        + n_storage  # tier 5 (cyclic), one per unit for the whole horizon
        + (n_periods - 1) * n_ramped  # tier 6, empty at T == 1 or with nothing ramped
        + n_periods * (n_epigraph + n_hypograph)  # tiers 7-8, one row per segment per period
    )
    assert h.getNumRow() == expected_rows, (
        f"multiperiod_dc_opf built {h.getNumRow()} rows, but the row-order contract in this "
        f"module's docstring accounts for {expected_rows} — every dual index below is read off "
        "that contract, so they must agree"
    )

    # row offsets, exactly the module docstring's table
    flow_base = n_periods
    soc_base = flow_base + n_periods * n_branch
    limit_base = soc_base + n_periods * n_storage
    cyclic_base = limit_base + n_periods * n_storage
    ramp_base = cyclic_base + n_storage

    ramp_duals = np.zeros((max(n_periods - 1, 0), n_gen))
    if n_ramped and n_periods > 1:
        block = row_dual[ramp_base : ramp_base + (n_periods - 1) * n_ramped]
        ramp_duals[:, ramped] = block.reshape(n_periods - 1, n_ramped)

    duals = MultiperiodDuals(
        balance=row_dual[:n_periods].copy(),
        flow_limit=row_dual[flow_base:soc_base].reshape(n_periods, n_branch),
        gen_bound=_read(gen_cols, col_dual),
        demand_bound=_read(demand_cols, col_dual),
        ramp=ramp_duals,
        soc_balance=row_dual[soc_base:limit_base].reshape(n_periods, n_storage),
        storage_power_limit=row_dual[limit_base:cyclic_base].reshape(n_periods, n_storage),
        storage_soc_bound=_read(soc_cols, col_dual),
        cyclic=row_dual[cyclic_base:ramp_base].copy(),
    )

    poly_gen_cost = float(np.sum(c2 * dispatch_mw**2 + c1 * dispatch_mw + c0))
    pwl_gen_cost = float(
        sum(col_value[cost_col_of[t][i]] for t in range(n_periods) for i in pwl_gen_idxs)
    )
    return MultiperiodSolution(
        status=status,
        n_periods=n_periods,
        dispatch_mw=dispatch_mw,
        demand_dispatch_mw=demand_dispatch_mw,
        storage_charge_mw=storage_charge_mw,
        storage_discharge_mw=storage_discharge_mw,
        storage_soc_mwh=storage_soc_mwh,
        ptdf=ptdf_matrix,
        objective_cost=poly_gen_cost + pwl_gen_cost,
        duals=duals,
        message=None,
    )

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 as dc_opf appends 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

ZoneKey = tuple[str, str]

An unordered zone pair, carried as a sorted tuple (z1 < z2) — a corridor's identity.

ZonalDuals dataclass

ZonalDuals(
    zone_price: FloatArray,
    corridor_cap: FloatArray,
    gen_bound: FloatArray,
)

Shadow prices from one zonal_dc_opf solve.

zone_price instance-attribute

zone_price: FloatArray

(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

corridor_cap: FloatArray

(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.

gen_bound instance-attribute

gen_bound: FloatArray

(n_gen,) — reduced cost of each generator's [p_min, p_max] bound, generator order; 0 unless that generator is pinned at a bound. Exactly gen_bound.

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

status: str

HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).

zone_ids instance-attribute

zone_ids: list[str]

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

dispatch_mw: FloatArray

(n_gen,) per-generator dispatch, MW, NetworkArrays generator order.

demand_dispatch_mw instance-attribute

demand_dispatch_mw: FloatArray

(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

demand_bound: FloatArray

(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

corridor_flow_mw: FloatArray

(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

objective_cost: float

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".

duals instance-attribute

duals: ZonalDuals | None

None exactly when status != "Optimal".

message class-attribute instance-attribute

message: str | None = None

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
def 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
    :func:`~mambo_power.opf.dc_opf.dc_opf`'s, validated by the very same
    :func:`~mambo_power.opf.dc_opf._extract_and_validate` -- so
    :class:`~mambo_power.opf.dc_opf.NonConvexCostError` and
    :class:`~mambo_power.opf.dc_opf.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 :func:`~mambo_power.opf.dc_opf.dc_opf` builds for an unrated network (module
    docstring, "Degenerate case").

    Raises :class:`ValueError` for a malformed partition or corridor map (:func:`_zone_labels`,
    :func:`_normalise_corridors`). Never raises for an infeasible or unbounded model -- reported
    through ``status``/``message``, mirroring ``dc_opf``.
    """
    n_gen = len(arr.gen_ids)
    n_load = len(arr.load_ids)
    problem = _extract_and_validate(
        cost_coeffs, pwl_costs, demand_bid_coeffs, demand_pwl_bids, n_gen, n_load
    )
    c2, c1, c0 = problem.c2, problem.c1, problem.c0
    v1, v2 = problem.v1, problem.v2
    elastic_load_idxs = problem.elastic_load_idxs
    n_pwl, n_demand, n_demand_pwl = problem.n_pwl, problem.n_demand, problem.n_demand_pwl

    bus_zone = _zone_labels(zone_of_bus, arr.bus_ids)
    zone_ids = sorted(set(bus_zone))
    corridor_caps = _normalise_corridors(corridors, zone_ids)
    corridor_ids = list(corridor_caps)
    n_zone, n_corridor = len(zone_ids), len(corridor_ids)
    zone_pos = {zone: i for i, zone in enumerate(zone_ids)}
    bus_zone_idx = np.asarray([zone_pos[z] for z in bus_zone], dtype=np.int64)

    n_dispatch = n_gen + n_demand
    demand_col_of = {idx: n_gen + j for j, idx in enumerate(elastic_load_idxs)}

    h = highspy.Highs()  # type: ignore[no-untyped-call]  # highspy ships no type stubs
    h.setOptionValue("output_flag", False)

    # --- tier 1a: generator and elastic-demand dispatch columns, exactly dc_opf's own block.
    if n_gen:
        h.addVars(n_gen, arr.gen_p_min_pu * arr.base_mva, arr.gen_p_max_pu * arr.base_mva)
        h.changeColsCost(n_gen, np.arange(n_gen, dtype=np.int32), c1)
    elastic_idx_arr = np.asarray(elastic_load_idxs, dtype=np.int64)
    if n_demand:
        h.addVars(
            n_demand,
            arr.load_p_min_pu[elastic_idx_arr] * arr.base_mva,
            arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva,
        )
        # minimising sum cost_g - sum value_d puts -v1 on the demand column (dc_opf, "Elastic
        # demand").
        h.changeColsCost(n_demand, np.arange(n_gen, n_dispatch, dtype=np.int32), -v1)

    # Hessian over the dispatch prefix, passed *before* any further column is appended -- the
    # ordering dc_opf already proves safe against later addVars calls (module docstring). The
    # assembly itself is dc_opf's helper, not a copy of it (ADR-008 one level down).
    _pass_diagonal_hessian(h, c2, v2, n_gen, n_demand)

    # --- tier 1b: one exchange column per corridor, bounded [-cap, +cap] (module docstring,
    # "Corridor sign convention"). No objective coefficient: a transfer is neither a cost nor a
    # value, and every economic consequence of moving power between zones is already carried by
    # the two balance rows the column appears in.
    corridor_cols = np.arange(n_dispatch, n_dispatch + n_corridor, dtype=np.int32)
    if n_corridor:
        caps = np.asarray(list(corridor_caps.values()), dtype=np.float64)
        caps = np.where(np.isinf(caps), highspy.kHighsInf, caps)
        h.addVars(n_corridor, -caps, caps)
    n_tier1 = n_dispatch + n_corridor

    # --- tier 2: the free PWL cost_g / val_d columns, appended after every tier-1 column exactly
    # as dc_opf appends them after its own dispatch block.
    cost_col_of: dict[int, int] = {}
    if n_pwl:
        cost_cols = np.arange(n_tier1, n_tier1 + n_pwl, dtype=np.int32)
        h.addVars(n_pwl, np.full(n_pwl, -highspy.kHighsInf), np.full(n_pwl, highspy.kHighsInf))
        h.changeColsCost(n_pwl, cost_cols, np.ones(n_pwl))
        cost_col_of = dict(zip(problem.pwl_gen_idxs, cost_cols.tolist(), strict=True))
    demand_val_col_of: dict[int, int] = {}
    if n_demand_pwl:
        val_cols = np.arange(n_tier1 + n_pwl, n_tier1 + n_pwl + n_demand_pwl, dtype=np.int32)
        h.addVars(
            n_demand_pwl,
            np.full(n_demand_pwl, -highspy.kHighsInf),
            np.full(n_demand_pwl, highspy.kHighsInf),
        )
        h.changeColsCost(n_demand_pwl, val_cols, -np.ones(n_demand_pwl))
        demand_val_col_of = dict(zip(problem.demand_pwl_idxs, val_cols.tolist(), strict=True))

    # --- per-zone fixed right-hand sides. The same double-counting contract dc_opf carries: each
    # elastic load's own historical p_mw (== arr.load_p_max_pu at its index) comes off its own
    # bus before that bus is aggregated into its zone, so the caller passes arr unmodified here too.
    p_load_mw = arr.p_load_pu * arr.base_mva
    if n_demand:
        p_load_mw = p_load_mw - np.bincount(
            arr.load_bus[elastic_idx_arr],
            weights=arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva,
            minlength=arr.n_bus,
        )
    fixed_bus_mw = p_load_mw + arr.g_shunt_pu * arr.base_mva
    fixed_zone_mw = np.bincount(bus_zone_idx, weights=fixed_bus_mw, minlength=n_zone)

    # --- one balance row per zone, row index == that zone's position in zone_ids. Built by
    # dc_opf's own _balance_row against this zone's own column sets: its generators and inbound
    # corridors inject, its bid loads and outbound corridors withdraw.
    gen_zone = bus_zone_idx[arr.gen_bus] if n_gen else np.zeros(0, dtype=np.int64)
    demand_zone = (
        bus_zone_idx[arr.load_bus[elastic_idx_arr]] if n_demand else np.zeros(0, dtype=np.int64)
    )
    inbound: list[list[int]] = [[] for _ in zone_ids]
    outbound: list[list[int]] = [[] for _ in zone_ids]
    for c, (z1, z2) in enumerate(corridor_ids):
        outbound[zone_pos[z1]].append(int(corridor_cols[c]))  # positive f leaves z1 ...
        inbound[zone_pos[z2]].append(int(corridor_cols[c]))  # ... and arrives in z2
    for z in range(n_zone):
        gen_cols_z = np.flatnonzero(gen_zone == z).astype(np.int32)
        demand_cols_z = (np.flatnonzero(demand_zone == z) + n_gen).astype(np.int32)
        _add_rows(
            h,
            _balance_row(
                np.concatenate([gen_cols_z, np.asarray(inbound[z], dtype=np.int32)]),
                np.concatenate([demand_cols_z, np.asarray(outbound[z], dtype=np.int32)]),
                float(fixed_zone_mw[z]),
            ),
        )

    # PWL epigraph / hypograph rows, appended after every balance row so the zone rows keep row
    # indices 0..n_zone-1 (module docstring, "Row layout").
    gen_cols = np.arange(n_gen, dtype=np.int32)
    _add_rows(h, _epigraph_rows(problem.segments_by_gen, gen_cols, cost_col_of))
    _add_rows(h, _hypograph_rows(problem.demand_segments_by_load, demand_col_of, demand_val_col_of))

    # The row-order contract is declared in the module docstring's "Row layout", implemented just
    # above, and re-derived here as a hand-maintained sum. Nothing else ties those three together:
    # ``zone_price`` below is ``row_dual[:n_zone]``, so a row family appended *before* the epigraph
    # block -- or a balance row not built for some zone -- silently reassigns every zone's price.
    # The PWL blocks are conditionally present, which is exactly when a slice-by-contract goes
    # wrong for one caller and not another. M5's own equivalent assert (opf/multiperiod.py) was
    # measured to be the only guard on its layout; this is the same guard for this one.
    n_epigraph = sum(len(segs) for segs in problem.segments_by_gen.values())
    n_hypograph = sum(len(segs) for segs in problem.demand_segments_by_load.values())
    expected_rows = n_zone + n_epigraph + n_hypograph
    assert h.getNumRow() == expected_rows, (
        f"zonal_dc_opf built {h.getNumRow()} rows, but the row-order contract in this module's "
        f"docstring accounts for {expected_rows} — the zone prices are read off that contract as "
        "row_dual[:n_zone], so they must agree"
    )

    h.run()
    status = h.modelStatusToString(h.getModelStatus())
    if status != _OPTIMAL:
        return ZonalSolution(
            status=status,
            zone_ids=zone_ids,
            corridor_ids=corridor_ids,
            dispatch_mw=np.zeros(n_gen),
            demand_dispatch_mw=np.zeros(n_demand),
            demand_bound=np.zeros(n_demand),
            corridor_flow_mw=np.zeros(n_corridor),
            objective_cost=0.0,
            duals=None,
            message=f"zonal_dc_opf: HiGHS reported model status {status!r}",
        )

    sol = h.getSolution()
    dispatch_mw = np.asarray(sol.col_value[:n_gen], dtype=np.float64)
    duals = ZonalDuals(
        zone_price=np.asarray(sol.row_dual[:n_zone], dtype=np.float64),
        corridor_cap=_corridor_cap_price(
            np.asarray(sol.col_dual[n_dispatch:n_tier1], dtype=np.float64)
        ),
        gen_bound=np.asarray(sol.col_dual[:n_gen], dtype=np.float64),
    )

    # generation cost only, computed from the dispatch rather than read off HiGHS's own objective
    # -- the identical construction, and the identical reason, as OpfSolution.objective_cost.
    poly_gen_cost = float(np.sum(c2 * dispatch_mw**2 + c1 * dispatch_mw + c0))
    pwl_gen_cost = float(sum(sol.col_value[cost_col_of[i]] for i in problem.pwl_gen_idxs))
    return ZonalSolution(
        status=status,
        zone_ids=zone_ids,
        corridor_ids=corridor_ids,
        dispatch_mw=dispatch_mw,
        demand_dispatch_mw=np.asarray(sol.col_value[n_gen:n_dispatch], dtype=np.float64),
        demand_bound=np.asarray(sol.col_dual[n_gen:n_dispatch], dtype=np.float64),
        corridor_flow_mw=np.asarray(sol.col_value[n_dispatch:n_tier1], dtype=np.float64),
        objective_cost=poly_gen_cost + pwl_gen_cost,
        duals=duals,
        message=None,
    )

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 participantscost_g(p0+u) = c2·(p0+u)² + c1·(p0+u) + c0 expands to a constant, a linear term (c1 + 2·c2·p0)·u and a quadratic term c2·u² where u = Δp+ − Δp-. The linear part is a column cost (+mc on Δp+, −mc on Δp-); the quadratic part is a 2x2 Hessian block coupling the pair, 2·c2·[[1, −1], [−1, 1]], not the diagonal block dc_opf passes over its single dispatch column. The demand side mirrors it with −v2/−mv (the same sign mirror dc_opf already 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 into RedispatchSolution.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 equality q + Δ- − Δ+ == p0 — an ordinary _balance_row with q/Δ- on the injection side and Δ+ on the withdrawal side. _epigraph_rows/_hypograph_rows are then called verbatim, with q where dc_opf passes its dispatch column. Only PWL participants pay for this column; a quadratic one has no q at 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 own total_fixed with the zonal point moved across the equals sign.
  • flow limits: const_k gains + Σ_g PTDF[k, gen_bus[g]]·p0_g − Σ_d PTDF[k, load_bus[d]]·d0_d — the same fold-every-fixed-contribution-into-const_k convention dc_opf uses for fixed load and shunts, applied to the zonal quantities. _flow_limit_rows itself 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 in dc_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

BOUND_TOL_MW = 1e-06

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

status: str

HiGHS's own model-status string, passed through verbatim (as OpfSolution.status).

dispatch_mw instance-attribute

dispatch_mw: FloatArray

(n_gen,) final per-generator dispatch, MW, NetworkArrays generator order — p0 + delta_up_mw − delta_down_mw, exactly.

demand_dispatch_mw instance-attribute

demand_dispatch_mw: FloatArray

(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

delta_up_mw: FloatArray

(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

delta_down_mw: FloatArray

(n_gen,) downward redispatch, MW, nonnegative.

demand_delta_up_mw instance-attribute

demand_delta_up_mw: FloatArray

(n_demand,) demand restored, MW, nonnegative — served demand above d0.

demand_delta_down_mw instance-attribute

demand_delta_down_mw: FloatArray

(n_demand,) demand curtailed, MW, nonnegative — served demand below d0.

branch_flow_mw instance-attribute

branch_flow_mw: FloatArray

(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

ptdf: FloatArray

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

objective_cost: float

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

demand_value: float

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

demand_bound: FloatArray

(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

message: str | None = None

Diagnostic when status != "Optimal"; None otherwise.

welfare property

welfare: float

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
def 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
    :func:`~mambo_power.opf.dc_opf.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
    :attr:`~mambo_power.opf.dc_opf.OpfSolution.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 :class:`ValueError` for a mis-shaped argument or for a starting quantity outside its own
    generator/load bounds (naming that generator or load, :data:`BOUND_TOL_MW` slack);
    :class:`~mambo_power.opf.dc_opf.NonConvexCostError` /
    :class:`~mambo_power.opf.dc_opf.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.
    """
    n_gen = len(arr.gen_ids)
    n_load = len(arr.load_ids)
    problem = _extract_and_validate(
        cost_coeffs, pwl_costs, demand_bid_coeffs, demand_pwl_bids, n_gen, n_load
    )
    c2, c1, c0 = problem.c2, problem.c1, problem.c0
    v2, v1, v0 = problem.v2, problem.v1, problem.v0
    elastic_load_idxs = problem.elastic_load_idxs
    n_pwl, n_demand, n_demand_pwl = problem.n_pwl, problem.n_demand, problem.n_demand_pwl
    elastic_idx_arr = np.asarray(elastic_load_idxs, dtype=np.int64)
    slot_of_load = {idx: j for j, idx in enumerate(elastic_load_idxs)}

    # --- the starting point, validated against the very bounds the delta caps are derived from.
    p0 = np.asarray(p0_mw, dtype=np.float64)
    if p0.shape != (n_gen,):
        raise ValueError(f"p0_mw must have shape ({n_gen},) (one per generator), got {p0.shape}")
    d0 = np.zeros(n_demand) if d0_mw is None else np.asarray(d0_mw, dtype=np.float64)
    if d0.shape != (n_demand,):
        raise ValueError(
            f"d0_mw must have shape ({n_demand},) (one per elastic load, in ascending bid-index "
            f"order), got {d0.shape}"
        )
    p_min = arr.gen_p_min_pu * arr.base_mva
    p_max = arr.gen_p_max_pu * arr.base_mva
    d_min = arr.load_p_min_pu[elastic_idx_arr] * arr.base_mva
    d_max = arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva
    _check_point(p0, p_min, p_max, arr.gen_ids, "generator", "dispatch p0_mw")
    _check_point(
        d0,
        d_min,
        d_max,
        [arr.load_ids[i] for i in elastic_load_idxs],
        "load",
        "served demand d0_mw",
    )

    # --- tier-1 columns: the four delta families, in this order (the Hessian covers exactly this
    # prefix, and is passed before any tier-2 free column exists — dc_opf's own documented
    # ordering constraint, which multiperiod.py also obeys).
    gen_up_cols = np.arange(0, n_gen, dtype=np.int32)
    gen_down_cols = np.arange(n_gen, 2 * n_gen, dtype=np.int32)
    dem_up_cols = np.arange(2 * n_gen, 2 * n_gen + n_demand, dtype=np.int32)
    dem_down_cols = np.arange(2 * n_gen + n_demand, 2 * n_gen + 2 * n_demand, dtype=np.int32)
    n_delta = 2 * (n_gen + n_demand)

    h = highspy.Highs()  # type: ignore[no-untyped-call]  # highspy ships no type stubs
    h.setOptionValue("output_flag", False)

    # Delta bounds are the generator/load bounds shifted by the starting point, floored at 0
    # against BOUND_TOL_MW-scale noise in (p0, d0). The final quantity therefore ranges over
    # exactly [p_min, p_max] / [d_min, d_max] — the property the theorem above rests on.
    if n_gen:
        h.addVars(n_gen, np.zeros(n_gen), np.maximum(p_max - p0, 0.0))
        h.addVars(n_gen, np.zeros(n_gen), np.maximum(p0 - p_min, 0.0))
        # linear part of cost_g(p0 + u) = ... + (c1 + 2·c2·p0)·u + c2·u², u = Δ+ − Δ−.
        gen_rate = c1 + 2.0 * c2 * p0
        h.changeColsCost(n_gen, gen_up_cols, gen_rate)
        h.changeColsCost(n_gen, gen_down_cols, -gen_rate)
    if n_demand:
        h.addVars(n_demand, np.zeros(n_demand), np.maximum(d_max - d0, 0.0))
        h.addVars(n_demand, np.zeros(n_demand), np.maximum(d0 - d_min, 0.0))
        # minimising Σcost − Σvalue: the *restore* column carries −mv and the *curtail* column
        # +mv, the delta form of dc_opf's own −v1 demand convention.
        demand_rate = v1 + 2.0 * v2 * d0
        h.changeColsCost(n_demand, dem_up_cols, -demand_rate)
        h.changeColsCost(n_demand, dem_down_cols, demand_rate)

    # --- Hessian over tier 1 only: one 2x2 block per quadratic participant (module docstring).
    if n_delta:
        entries = _hessian_pairs(c2, gen_up_cols, gen_down_cols)
        entries.update(_hessian_pairs(-v2, dem_up_cols, dem_down_cols))
        if entries:
            starts: list[int] = [0]
            indices: list[int] = []
            values: list[float] = []
            for col in range(n_delta):
                for row, value in entries.get(col, []):
                    indices.append(row)
                    values.append(value)
                starts.append(len(indices))
            hess = highspy.HighsHessian()
            hess.dim_ = n_delta
            hess.format_ = highspy.HessianFormat.kTriangular
            hess.start_ = starts
            hess.index_ = indices
            hess.value_ = values
            h.passHessian(hess)

    # --- tier-2 columns: one bounded "final quantity" column q plus one free cost_g/val_d column
    # per *piecewise-linear* participant (module docstring). A quadratic participant has neither.
    next_col = n_delta
    gen_q_col_of: dict[int, int] = {}
    gen_cost_col_of: dict[int, int] = {}
    if n_pwl:
        pwl_idx = np.asarray(problem.pwl_gen_idxs, dtype=np.int64)
        q_cols = np.arange(next_col, next_col + n_pwl, dtype=np.int32)
        h.addVars(n_pwl, p_min[pwl_idx], p_max[pwl_idx])
        gen_q_col_of = dict(zip(problem.pwl_gen_idxs, q_cols.tolist(), strict=True))
        next_col += n_pwl
        cost_cols = np.arange(next_col, next_col + n_pwl, dtype=np.int32)
        h.addVars(n_pwl, np.full(n_pwl, -highspy.kHighsInf), np.full(n_pwl, highspy.kHighsInf))
        h.changeColsCost(n_pwl, cost_cols, np.ones(n_pwl))
        gen_cost_col_of = dict(zip(problem.pwl_gen_idxs, cost_cols.tolist(), strict=True))
        next_col += n_pwl

    dem_q_col_of: dict[int, int] = {}
    dem_val_col_of: dict[int, int] = {}
    if n_demand_pwl:
        # position of each PWL bid load within the ascending elastic-load order (its delta columns)
        pwl_slots = np.asarray([slot_of_load[i] for i in problem.demand_pwl_idxs], dtype=np.int64)
        q_cols = np.arange(next_col, next_col + n_demand_pwl, dtype=np.int32)
        h.addVars(n_demand_pwl, d_min[pwl_slots], d_max[pwl_slots])
        dem_q_col_of = dict(zip(problem.demand_pwl_idxs, q_cols.tolist(), strict=True))
        next_col += n_demand_pwl
        val_cols = np.arange(next_col, next_col + n_demand_pwl, dtype=np.int32)
        h.addVars(
            n_demand_pwl,
            np.full(n_demand_pwl, -highspy.kHighsInf),
            np.full(n_demand_pwl, highspy.kHighsInf),
        )
        h.changeColsCost(n_demand_pwl, val_cols, -np.ones(n_demand_pwl))
        dem_val_col_of = dict(zip(problem.demand_pwl_idxs, val_cols.tolist(), strict=True))
        next_col += n_demand_pwl

    # --- the fixed right-hand side: dc_opf's own total_fixed (every bid load's historical p_mw
    # removed from its bus, the double-counting contract) with the zonal point moved across.
    p_load_mw = arr.p_load_pu * arr.base_mva
    elastic_bus = arr.load_bus[elastic_idx_arr]
    if n_demand:
        elastic_own_mw = arr.load_p_max_pu[elastic_idx_arr] * arr.base_mva
        p_load_mw = p_load_mw - np.bincount(
            elastic_bus, weights=elastic_own_mw, minlength=arr.n_bus
        )
    g_shunt_mw = arr.g_shunt_pu * arr.base_mva
    total_fixed = float(np.sum(p_load_mw) + np.sum(g_shunt_mw))
    balance_rhs = total_fixed - float(np.sum(p0)) + float(np.sum(d0))

    ptdf_matrix = compute_ptdf(arr)
    pf_shift_mw = pf_shift(arr) * arr.base_mva
    # p_shift_mw (numerics.bbus.p_shift) is the phase-shifter bus injection dc_opf's own const_k
    # folds in by hand (dc_opf.py's flow-limit row comment; M8 finding F1 / A19,
    # task-shifter-flow-fix.plan.md T7) -- a property of topology, not of p0/d0, so it belongs in
    # this first fold, not the zonal-point term below.
    p_shift_mw = p_shift(arr) * arr.base_mva
    const = pf_shift_mw - ptdf_matrix @ (p_load_mw + g_shunt_mw + p_shift_mw)
    # ...plus the zonal point's own (fixed) contribution to every branch's flow. p0/d0 are a
    # *decision-independent* injection like p_load_mw/g_shunt_mw above, not a phase shift, so no
    # further p_shift correction applies here -- confirmed by re-deriving the flow identity by
    # hand: flow_k = PTDF[k,:] @ (injection - p_shift) + pf_shift_k, and (p0_by_bus - d0_by_bus)
    # is exactly one more additive term of "injection", already covered by the single p_shift
    # subtraction above.
    p0_by_bus = np.bincount(arr.gen_bus, weights=p0, minlength=arr.n_bus)
    d0_by_bus = np.bincount(elastic_bus, weights=d0, minlength=arr.n_bus)
    const = const + ptdf_matrix @ (p0_by_bus - d0_by_bus)
    rating_mw = arr.rating_pu * arr.base_mva  # inf where unrated -> row never binds

    # --- rows, in the order their indices are read back below: balance is row 0, the flow-limit
    # rows are 1..n_branch, and everything after them is an internal encoding detail.
    n_rows = 1 + arr.n_branch
    injection_cols = np.concatenate([gen_up_cols, dem_down_cols])
    withdrawal_cols = np.concatenate([gen_down_cols, dem_up_cols])
    injection_bus = np.concatenate([arr.gen_bus, elastic_bus])
    withdrawal_bus = injection_bus

    _add_rows(h, _balance_row(injection_cols, withdrawal_cols, balance_rhs))
    _add_rows(
        h,
        _flow_limit_rows(
            ptdf_matrix,
            injection_cols,
            injection_bus,
            withdrawal_cols,
            withdrawal_bus,
            rating_mw,
            const,
        ),
    )

    # PWL linking equalities: q + Δ− − Δ+ == starting quantity, one per PWL participant, built
    # from _balance_row (module docstring — it is pure algebra over column indices).
    for gen_idx, q_col in gen_q_col_of.items():
        _add_rows(
            h,
            _balance_row(
                np.asarray([q_col, gen_down_cols[gen_idx]], dtype=np.int32),
                np.asarray([gen_up_cols[gen_idx]], dtype=np.int32),
                float(p0[gen_idx]),
            ),
        )
    for load_idx, q_col in dem_q_col_of.items():
        slot = slot_of_load[load_idx]
        _add_rows(
            h,
            _balance_row(
                np.asarray([q_col, dem_down_cols[slot]], dtype=np.int32),
                np.asarray([dem_up_cols[slot]], dtype=np.int32),
                float(d0[slot]),
            ),
        )

    # Epigraph/hypograph rows over the *final* quantity column, verbatim.
    q_col_by_gen = np.zeros(n_gen, dtype=np.int32)
    for gen_idx, q_col in gen_q_col_of.items():
        q_col_by_gen[gen_idx] = q_col
    _add_rows(h, _epigraph_rows(problem.segments_by_gen, q_col_by_gen, gen_cost_col_of))
    _add_rows(h, _hypograph_rows(problem.demand_segments_by_load, dem_q_col_of, dem_val_col_of))

    # The row-order contract is declared in the module docstring, implemented just above, and
    # re-derived here as a hand-maintained sum. Nothing else ties those three together: the duals
    # below are ``row_dual[0]`` and ``row_dual[1:n_rows]``, and three row families of
    # conditionally-present height (the PWL linking equalities, the epigraph and hypograph blocks)
    # are appended *after* the flow rows. A family inserted before them instead shifts every
    # flow-limit dual by exactly its own height, silently. opf/multiperiod.py carries the same
    # assert for the same reason, and it was measured there to be the only guard on its layout.
    n_linking = len(gen_q_col_of) + len(dem_q_col_of)
    n_epigraph = sum(len(segs) for segs in problem.segments_by_gen.values())
    n_hypograph = sum(len(segs) for segs in problem.demand_segments_by_load.values())
    expected_rows = n_rows + n_linking + n_epigraph + n_hypograph
    assert h.getNumRow() == expected_rows, (
        f"redispatch_dc_opf built {h.getNumRow()} rows, but the row-order contract in this "
        f"module's docstring accounts for {expected_rows} — the balance and flow-limit duals are "
        "read off that contract as row_dual[0] and row_dual[1:n_rows], so they must agree"
    )

    h.run()
    status = h.modelStatusToString(h.getModelStatus())
    if status != _OPTIMAL:
        return RedispatchSolution(
            status=status,
            dispatch_mw=np.zeros(n_gen),
            demand_dispatch_mw=np.zeros(n_demand),
            delta_up_mw=np.zeros(n_gen),
            delta_down_mw=np.zeros(n_gen),
            demand_delta_up_mw=np.zeros(n_demand),
            demand_delta_down_mw=np.zeros(n_demand),
            branch_flow_mw=np.zeros(arr.n_branch),
            ptdf=ptdf_matrix,
            objective_cost=0.0,
            demand_value=0.0,
            duals=None,
            demand_bound=np.zeros(n_demand),
            message=f"redispatch_dc_opf: HiGHS reported model status {status!r}",
        )

    sol = h.getSolution()
    col_value = np.asarray(sol.col_value, dtype=np.float64)
    # Netted deltas (module docstring): the objective sees only Δ+ − Δ−, so the canonical
    # representative is reported rather than whichever split this platform's HiGHS returned.
    gen_net = col_value[gen_up_cols] - col_value[gen_down_cols]
    dem_net = col_value[dem_up_cols] - col_value[dem_down_cols]
    delta_up_mw = np.maximum(gen_net, 0.0)
    delta_down_mw = np.maximum(-gen_net, 0.0)
    demand_delta_up_mw = np.maximum(dem_net, 0.0)
    demand_delta_down_mw = np.maximum(-dem_net, 0.0)
    dispatch_mw = p0 + gen_net
    demand_dispatch_mw = d0 + dem_net

    duals = OpfDuals(
        balance=float(sol.row_dual[0]) if n_rows else 0.0,
        flow_limit=np.asarray(sol.row_dual[1:n_rows], dtype=np.float64),
        gen_bound=np.asarray(sol.col_dual, dtype=np.float64)[gen_up_cols],
    )
    demand_bound = np.asarray(sol.col_dual, dtype=np.float64)[dem_up_cols]

    # True curves at the final point, constants included — the figures OpfSolution reports, so
    # that the wave's cost/welfare gaps compare like with like.
    poly_gen_cost = float(np.sum(c2 * dispatch_mw**2 + c1 * dispatch_mw + c0))
    pwl_gen_cost = float(sum(col_value[gen_cost_col_of[i]] for i in problem.pwl_gen_idxs))
    poly_demand_value = float(np.sum(v2 * demand_dispatch_mw**2 + v1 * demand_dispatch_mw + v0))
    pwl_demand_value = float(sum(col_value[dem_val_col_of[i]] for i in problem.demand_pwl_idxs))

    # Branch flows at the final point: flow_from_ptdf(ptdf, injection, arr) -- the same
    # PTDF-minus-shift identity solve_dc_opf and market._clearing use (module docstring;
    # M8 finding F1 / A19, task-shifter-flow-fix.plan.md T7).
    gen_by_bus = np.bincount(arr.gen_bus, weights=dispatch_mw, minlength=arr.n_bus)
    demand_by_bus = np.bincount(elastic_bus, weights=demand_dispatch_mw, minlength=arr.n_bus)
    injection_mw = gen_by_bus - demand_by_bus - p_load_mw - g_shunt_mw
    branch_flow_mw = flow_from_ptdf(ptdf_matrix, injection_mw, arr)

    return RedispatchSolution(
        status=status,
        dispatch_mw=dispatch_mw,
        demand_dispatch_mw=demand_dispatch_mw,
        delta_up_mw=delta_up_mw,
        delta_down_mw=delta_down_mw,
        demand_delta_up_mw=demand_delta_up_mw,
        demand_delta_down_mw=demand_delta_down_mw,
        branch_flow_mw=np.asarray(branch_flow_mw, dtype=np.float64),
        ptdf=ptdf_matrix,
        objective_cost=poly_gen_cost + pwl_gen_cost,
        demand_value=poly_demand_value + pwl_demand_value,
        duals=duals,
        demand_bound=demand_bound,
        message=None,
    )