Skip to content

mambo_power.market

Market clearing at three granularities — one period nodally, a whole horizon, and one period zonally with the redispatch that makes it deliverable — and, since M7, a fourth mode in which the supply curve is not read from the network but decided: generators bid, and the market clears their offers. See the nodal manual page for the elastic-demand formulation, LMP/settlement decomposition, the price-taker reduction and the oracle convention, the multiperiod manual page for ramp coupling, storage state of charge, the cyclic horizon and the per-period settlement, and the zonal manual page for the three-solve chain, corridors, and the three separated gap figures.

mambo_power.market

Market clearing (epic Design §2 market/): welfare-maximizing DC-OPF over generator costs and load bids, decomposed into LMPs and settlement. Built directly on mambo_power.opf.dc_opf/lmp_decomposition per ADR-006's reuse seam.

Four entry points. Two share a shape at two horizons: solve_nodal clears one period, and solve_multiperiod clears a Scenario's whole horizon as one coupled LP/QP with ramp coupling and storage; a one-period multiperiod clearing reproduces the nodal one exactly (wave M5 AC-4). The third, solve_zonal, clears one period at zonal granularity and then redispatches onto the real network, reporting what that market design costs against the nodal optimum -- so it drives three solves rather than one, and solve_nodal is one of them. The fourth, solve_agents, is the first whose supply curve is decided rather than read from the network: generators bid through a Strategy, round after round, and the market clears their offers until the offer vector settles (wave M7).

AgentSetError

Bases: ValueError

A caller mistake in the agent set -- how options.strategies (or the in-process strategies argument) relates to the network -- caught before any solve starts.

A ValueError subclass, deliberately, for the same two reasons as UnzonedBusError. It stays a ValueError because that is what solve_agents has always raised for these and what an in-process caller catches. It is a distinguishable type because jobs' runner cannot otherwise tell it apart from any other ValueError a solve might raise -- and the clearing's own NonConvexCostError / NonConcaveBidError are ValueError subclasses. Catching bare ValueError relabelled an engine rejection of a non-convex cost as VALIDATION at options.strategies, a field the caller need not have set, while market.nodal reported the same network as INTERNAL (audit finding 2, M7 S10). Only this type maps to VALIDATION; everything else keeps the verdict every other kind gives it.

Raised by _resolve_agents (two agent sources at once, a strategy on a generator the network does not have, one its arrays do not carry, one with no cost, a MarkupStrategy step too coarse for offer_tol) and by _initial_offers (a strategy that cannot bid on its generator's true cost).

MarketAgentsOptions

Bases: BaseModel

Options of a market.agents run: who bids, how long the loop may run, and what counts as settled.

Sits beside solve_agents the way MarketZonalOptions sits beside its own solver. Like that one, its fields are market-design data rather than solver tuning -- which strategy each generator plays is a choice about the game being simulated, not a knob on HiGHS.

strategies class-attribute instance-attribute

strategies: dict[str, StrategyConfig]

Generator id -> the bidding rule that generator plays. A generator not named here is not an agent: it offers its own true cost, exactly as market.nodal would clear it. An empty mapping is therefore meaningful and not a missing argument -- it is a market in which nobody bids strategically.

max_iterations class-attribute instance-attribute

max_iterations: int = 200

Most best-response update rounds to run after round 0 (which is the initial offer and responds to nothing). Reaching it ends the run with termination_reason == "iteration_cap" and converged False; it is never reported as a cycle, and a cycle is never reported as it.

offer_tol class-attribute instance-attribute

offer_tol: float = 1e-09

Largest offer-vector oscillation amplitude, in cost-coefficient units, that still counts as converged once the loop detects a repeated state. This is a derived quantity, not a tuning knob: a fixed-step climber settles into an oscillation of two steps about an on-grid optimum and three about a half-grid one, so a markup agent of step s needs offer_tol >= 3*s (MarkupStrategy.min_offer_tol) -- which the validator below enforces rather than hopes for. The default admits only an offer vector that has genuinely come to rest, which is what an all-price-taker run does.

MarketMultiperiodOptions

Bases: BaseModel

Options of a market.multiperiod clearing.

No fields yet, for the same reason MarketNodalOptions has none: a solver-tuning field is added the first time a caller actually needs one. It exists now, rather than being omitted, because the options model is what the registered market.multiperiod jobs kind validates a request against -- and because the array-level builder deliberately takes no options parameter at all, so this is the one place multiperiod options can live.

MarketNodalOptions

Bases: BaseModel

Options of a market.nodal clearing.

No fields yet: mirrors OpfDcOptions's own precedent (a solver-tuning field is added the first time a caller actually needs one, not invented speculatively). It exists rather than being omitted because the market.nodal KindSpec names it as the model every request's options is validated against, and a kind with no options model rejects any key at all.

CorridorLimit

Bases: BaseModel

One inter-zonal corridor's transfer capacity — an entry of MarketZonalOptions.corridors.

Why this is an option and not a model field. A corridor capacity is a transfer limit between two zones, and the domain model deliberately has no transfer-capacity entity, because a real NTC is administratively negotiated data that no network file carries and no branch rating uniquely determines. So capacities are supplied per solve, by the caller who knows them. A defensible default, if you need one, is the sum of rating_mva over the pair's cut-set — which is what tests/_zones.py's corridors() builds.

Why a row model rather than a {(z1, z2): cap} mapping. The mapping is the shape the array-level builder takes and the shape MarketZonalOptions.corridor_map hands it. It is not a shape a pydantic options model can carry, because a dict keyed by a tuple does not survive a JSON round trip: pydantic serialises the key ("1", "2") to the string "1,2" and then refuses to validate that string back into a tuple. An options model that cannot round-trip through JSON is a jobs request form that cannot round-trip either, and an exact JSON round trip is a standing requirement on every kind — so the serialisable shape is the one stored, and the mapping is derived on the way to the builder.

zone1 class-attribute instance-attribute

zone1: str

One end of the corridor: a zone id present in the network. A zone id no bus carries is rejected at solve time, when the network is in hand (jobs: VALIDATION).

zone2 class-attribute instance-attribute

zone2: str

The other end; must differ from zone1, and the resulting unordered pair must not appear elsewhere in the list. Both are enforced on MarketZonalOptions itself, before any solve (jobs: BAD_OPTIONS).

cap_mw class-attribute instance-attribute

cap_mw: Annotated[float, Field(ge=0.0)] | None

Transfer capacity, MW, as a magnitude: the corridor is bounded at [-cap_mw, +cap_mw], so it constrains both directions equally. 0 is allowed and means a tie that exists but can carry nothing. null means unbounded -- the copper plate: the corridor stays in the LP with no bound at all, which is a different market from deleting the entry (that islands the two zones and reports no capacity price for them). A number must be finite: inf is rejected, so the copper plate has exactly one spelling on this model and it is one every JSON parser reads.

MarketZonalOptions

Bases: BaseModel

Options of a market.zonal clearing.

Unlike MarketNodalOptions and MarketMultiperiodOptions, this one has a field from the start, and it is not solver tuning: corridors is market design data the model deliberately does not carry (see CorridorLimit). An empty list is a meaningful default and not a missing argument -- it means no zone pair may exchange anything, so each zone must supply itself.

corridors class-attribute instance-attribute

corridors: list[CorridorLimit]

Transfer capacity per tied zone pair. A pair absent from this list has no corridor at all and so cannot exchange power -- which is a stronger statement than a corridor of capacity 0 only in that no capacity shadow price is reported for it. At most MAX_CORRIDORS (500) entries -- a request/response-size guard, since this list is echoed verbatim into every result's provenance.options; 500 exhausts a 32-zone network, which is above every zonal market in operation.

corridor_map

corridor_map() -> dict[ZoneKey, float]

corridors as the {(zone1, zone2): cap_mw} mapping zonal_dc_opf takes. Keys are left in the order given; the builder normalises each to sorted order. This is a dict comprehension and so cannot report a repeated key -- which is exactly why the repeat is rejected on the model above, before any mapping is built.

This is also where the copper plate changes spelling. On the wire an unbounded corridor is null, because that is what JSON has; the array-level builder wants inf, because that is what HiGHS has (it maps it to kHighsInf). Neither layer has to know about the other's, and the translation is one line here rather than a non-standard token in every request and response body.

Source code in src/mambo_power/market/zonal.py
def corridor_map(self) -> dict[ZoneKey, float]:
    """:attr:`corridors` as the ``{(zone1, zone2): cap_mw}`` mapping
    :func:`~mambo_power.opf.zonal.zonal_dc_opf` takes. Keys are left in the order given; the
    builder normalises each to sorted order. This is a dict comprehension and so cannot report
    a repeated key -- which is exactly why the repeat is rejected on the model above, before
    any mapping is built.

    This is also where the copper plate changes spelling. On the wire an unbounded corridor is
    ``null``, because that is what JSON has; the array-level builder wants ``inf``, because
    that is what HiGHS has (it maps it to ``kHighsInf``). Neither layer has to know about the
    other's, and the translation is one line here rather than a non-standard token in every
    request and response body.
    """
    return {
        (entry.zone1, entry.zone2): math.inf if entry.cap_mw is None else entry.cap_mw
        for entry in self.corridors
    }

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

NonConvexCostError

Bases: ValueError

A generator cost is non-convex: either a PiecewiseCost's breakpoint slopes are not non-decreasing, or a quadratic cost has c2 < 0.

Raised by dc_opf before any HiGHS object is created (module docstring, "PWL costs" / "Elastic demand"): the convex segment/epigraph LP encoding, and the QP Hessian's positive semi-definiteness, are only valid for a convex cost, and silently solving a non-convex one would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §2.1, §1.2). opf-local — PiecewiseCost itself validates only strictly-increasing p_mw, not convexity (record/m3-research.md §2.3).

solve_agents

solve_agents(
    scenario: Scenario,
    options: MarketAgentsOptions | None = None,
    *,
    strategies: Mapping[str, Strategy] | None = None,
) -> MarketAgentsResult

Run the best-response loop of scenario.network (module docstring) and return the final round's clearing beside how the loop ended.

The in-process seam. strategies maps a generator id to any structurally-conforming Strategy object, and is used instead of options.strategies -- giving both raises, so an agent set always has exactly one source and the result can say which rule ran. This is deliberate design, not a hole left open for a test: it is the surface for a caller whose bidding rule StrategyConfig cannot express -- a rule with parameters the union does not carry, or one belonging to the caller rather than to this library -- and it is the reason Strategy is a structural typing.Protocol (design D3(a)) rather than a closed union. Without it the Protocol would be decorative, since nothing would ever accept an object that merely conforms to it. Only the config union crosses JSON, so only options.strategies can reach this through jobs, and the wave's own jobs coverage (AC-6) is unaffected by anything passed here. provenance.options echoes options either way, which is why strategy -- carrying the config kind or, for an injected object, its class name -- and not the provenance, is the record of which rule actually produced each offer.

Never raises for an infeasible or unbounded LP: a round that fails to clear ends the run and is reported through status/message, mirroring mambo_power.market.nodal.solve_nodal's never-raise convention. Does raise AgentSetError (a ValueError) up front for a caller mistake in the agent set (see _resolve_agents), and NonConvexCostError / NonConcaveBidError for a cost or bid the clearing cannot accept -- including an offer a strategy produced, which is checked on the offer, every round, exactly as it would be on a true cost -- and TypeError at the call site, before that round's clearing, for a strategy whose offer returned something other than a GeneratorCost (see _checked_offer). A strategy that cannot bid on its generator's true cost at all (MarkupStrategy on a non-linear cost, which raises NotImplementedError from its own offer) is one of the up-front ValueError cases: _initial_offers collects round 0's offers before the first clearing and re-raises that error with the generator id, so the mistake reaches jobs as VALIDATION like the other four rather than escaping the loop as INTERNAL.

Scenario and Network are not modified -- the offers reach the clearing as coefficients (AC-2).

Source code in src/mambo_power/market/agents.py
def solve_agents(
    scenario: Scenario,
    options: MarketAgentsOptions | None = None,
    *,
    strategies: Mapping[str, Strategy] | None = None,
) -> MarketAgentsResult:
    """Run the best-response loop of ``scenario.network`` (module docstring) and return the final
    round's clearing beside how the loop ended.

    **The in-process seam.** ``strategies`` maps a generator id to any structurally-conforming
    :class:`~mambo_power.market.strategy.Strategy` object, and is used *instead of*
    ``options.strategies`` -- giving both raises, so an agent set always has exactly one source
    and the result can say which rule ran. This is deliberate design, not a hole left open for a
    test: it is the surface for a caller whose bidding rule :data:`StrategyConfig` **cannot
    express** -- a rule with parameters the union does not carry, or one belonging to the caller
    rather than to this library -- and it is the reason
    :class:`~mambo_power.market.strategy.Strategy` is a structural
    :class:`typing.Protocol` (design D3(a)) rather than a closed union. Without it the Protocol
    would be decorative, since nothing would ever accept an object that merely conforms to it.
    Only the config union crosses JSON, so only ``options.strategies`` can reach this through
    ``jobs``, and the wave's own jobs coverage (AC-6) is unaffected by anything passed here.
    ``provenance.options`` echoes ``options`` either way, which is why
    :attr:`~mambo_power.results.agents.AgentOfferResult.strategy` -- carrying the config ``kind``
    or, for an injected object, its class name -- and not the provenance, is the record of which
    rule actually produced each offer.

    Never raises for an infeasible or unbounded LP: a round that fails to clear ends the run and
    is reported through ``status``/``message``, mirroring
    :func:`mambo_power.market.nodal.solve_nodal`'s never-raise convention. Does raise
    :class:`AgentSetError` (a ``ValueError``) up front for a caller mistake in the agent set (see
    :func:`_resolve_agents`),
    and :class:`~mambo_power.opf.dc_opf.NonConvexCostError` /
    :class:`~mambo_power.opf.dc_opf.NonConcaveBidError` for a cost or bid the clearing cannot
    accept -- including an *offer* a strategy produced, which is checked on the offer, every
    round, exactly as it would be on a true cost -- and ``TypeError`` at the call site, before
    that round's clearing, for a strategy whose ``offer`` returned something other than a
    :class:`~mambo_power.model.GeneratorCost` (see :func:`_checked_offer`). A strategy that
    cannot bid on its generator's
    true cost at all (:class:`~mambo_power.market.strategy.MarkupStrategy` on a non-linear cost,
    which raises ``NotImplementedError`` from its own ``offer``) is one of the up-front
    ``ValueError`` cases: :func:`_initial_offers` collects round 0's offers before the first
    clearing and re-raises that error with the generator id, so the mistake
    reaches ``jobs`` as ``VALIDATION`` like the other four rather than escaping the loop as
    ``INTERNAL``.

    ``Scenario`` and ``Network`` are not modified -- the offers reach the clearing as coefficients
    (AC-2).
    """
    opts = options if options is not None else MarketAgentsOptions()
    started_at = datetime.now(UTC)
    clock = time.perf_counter()
    net = scenario.network
    arr = NetworkArrays.from_network(net)
    agents = _resolve_agents(net, arr, opts, strategies)
    offers = _initial_offers(agents)
    demand_bid_coeffs, demand_pwl_bids = load_bid_coeffs(net, arr)
    elastic_idxs = sorted(set(demand_bid_coeffs) | set(demand_pwl_bids))
    # The network never changes between rounds -- only the offers do -- so the PTDF (and the
    # B-bus / incidence factorisation beneath it) is built once here and handed to every round's
    # clearing. Rebuilt per round it was 70% of a 200-round case14 run (critic finding 3, M7 S11);
    # passing it changes no number (tests/unit/test_market_agents.py, the cache test).
    ptdf_matrix = compute_ptdf(arr)

    history: list[_Round] = []
    seen: dict[tuple[tuple[str, ...], tuple[str, ...]], int] = {}
    reason: TerminationReason = "iteration_cap"
    solution: OpfSolution | None = None
    breakdown: LmpBreakdown | None = None
    round_index = 0
    while True:
        cost_coeffs, pwl_costs = gen_cost_coeffs(net, arr, costs=offers)
        solution = dc_opf(
            arr,
            cost_coeffs,
            OpfDcOptions(),
            pwl_costs=pwl_costs or None,
            demand_bid_coeffs=demand_bid_coeffs or None,
            demand_pwl_bids=demand_pwl_bids or None,
            ptdf=ptdf_matrix,
        )
        if solution.status != "Optimal" or solution.duals is None:
            return MarketAgentsResult(
                provenance=_provenance(opts, started_at, time.perf_counter() - clock),
                status=solution.status,
                message=solution.message,
                iterations=round_index,
                converged=False,
                termination_reason=None,
            )
        breakdown = lmp_decomposition(solution.duals, solution.ptdf)
        history.append(
            _Round(
                offers=offers,
                cost_coeffs=cost_coeffs,
                dispatch_mw=solution.dispatch_mw,
                lmp=breakdown.lmp,
            )
        )
        # The loop's state going into round r+1 is the pair (round r-1's offers, round r's
        # offers): every strategy is a pure function of its own last two rounds, and each round's
        # LMPs and dispatch are a deterministic function of that round's offers. So a repeat of
        # this pair means every subsequent round replays the ones after its first occurrence --
        # the sequence is periodic from here, and what remains is to classify how wide the
        # oscillation is, not whether it will end.
        if round_index >= 1:
            key = (
                _offer_key(history[round_index - 1].offers, agents),
                _offer_key(history[round_index].offers, agents),
            )
            first_seen = seen.get(key)
            if first_seen is not None:
                period = round_index - first_seen
                amplitude = _amplitude(history[round_index + 1 - period :], agents)
                reason = "converged" if _settled(amplitude, opts.offer_tol) else "cycle"
                break
            seen[key] = round_index
        if round_index >= opts.max_iterations:
            reason = "iteration_cap"
            break
        round_index += 1
        offers = {
            agent.id: _checked_offer(agent, _observation(agent, round_index, history))
            for agent in agents
        }

    assert breakdown is not None  # set on every Optimal round, and the loop broke on one
    # The final round's rows and settlement -- the one construction market.nodal applies to its
    # single clearing (market/_clearing.py), applied to this loop's last one. Settlement is the
    # final round's alone: computed directly from that dispatch and those LMPs, never accumulated
    # over the search that led to it.
    rows = clearing_rows(net, arr, solution, breakdown.lmp, elastic_idxs)
    final = history[-1]
    offer_rows = [
        AgentOfferResult(
            id=agent.id,
            strategy=agent.label,
            offer=final.offers[agent.id],
            true_cost=agent.true_cost,
            cleared_mw=float(final.dispatch_mw[agent.index]),
            markup=_cost_at(final.offers[agent.id], float(final.dispatch_mw[agent.index]))
            - _cost_at(agent.true_cost, float(final.dispatch_mw[agent.index])),
        )
        for agent in agents
    ]
    return MarketAgentsResult(
        provenance=_provenance(opts, started_at, time.perf_counter() - clock),
        status=solution.status,
        message=None,
        generators=rows.generators,
        loads=rows.loads,
        buses=[
            BusLmpResult(
                id=bus_id,
                lmp=float(breakdown.lmp[i]),
                energy=float(breakdown.energy[i]),
                congestion=float(breakdown.congestion[i]),
            )
            for i, bus_id in enumerate(arr.bus_ids)
        ],
        branches=rows.branches,
        offers=offer_rows,
        iterations=round_index,
        converged=reason == "converged",
        termination_reason=reason,
        total_load_payment=rows.total_load_payment,
        total_generator_receipts=rows.total_generator_receipts,
        congestion_rent=rows.total_load_payment - rows.total_generator_receipts,
    )

solve_multiperiod

solve_multiperiod(
    scenario: Scenario,
    options: MarketMultiperiodOptions | None = None,
) -> MarketMultiperiodResult

Clear scenario over its whole horizon as one coupled LP/QP (module docstring): per-period dispatch, per-bus LMPs, per-storage charge/discharge/SoC, per-period settlement, and horizon totals.

scenario.periods is None clears a single period from the network's own loads, reproducing mambo_power.market.nodal.solve_nodal exactly (wave AC-4).

Never raises for an infeasible or unbounded LP/QP -- reported through MarketMultiperiodResult.status/message, mirroring solve_nodal's never-raise convention. Raises NonConvexCostError up front for a non-convex generator cost, NonConcaveBidError for a non-concave load bid, and ValueError for a ramp limit of exactly zero (which would freeze a unit for the whole horizon) -- all before any solve is attempted. The scenario is not modified.

Source code in src/mambo_power/market/multiperiod.py
def solve_multiperiod(
    scenario: Scenario, options: MarketMultiperiodOptions | None = None
) -> MarketMultiperiodResult:
    """Clear ``scenario`` over its whole horizon as one coupled LP/QP (module docstring):
    per-period dispatch, per-bus LMPs, per-storage charge/discharge/SoC, per-period settlement,
    and horizon totals.

    ``scenario.periods is None`` clears a single period from the network's own loads, reproducing
    :func:`mambo_power.market.nodal.solve_nodal` exactly (wave AC-4).

    Never raises for an infeasible or unbounded LP/QP -- reported through
    ``MarketMultiperiodResult.status``/``message``, mirroring ``solve_nodal``'s never-raise
    convention. Raises :class:`~mambo_power.opf.dc_opf.NonConvexCostError` up front for a
    non-convex generator cost, :class:`~mambo_power.opf.dc_opf.NonConcaveBidError` for a
    non-concave load bid, and :class:`ValueError` for a ramp limit of exactly zero (which would
    freeze a unit for the whole horizon) -- all before any solve is attempted. The scenario is
    not modified.
    """
    opts = options if options is not None else MarketMultiperiodOptions()
    started_at = datetime.now(UTC)
    clock = time.perf_counter()
    net = scenario.network
    arr = NetworkArrays.from_network(net)
    cost_coeffs, pwl_costs = gen_cost_coeffs(net, arr)
    demand_bid_coeffs, demand_pwl_bids = load_bid_coeffs(net, arr)
    ramp_up_mw, ramp_down_mw = _ramp_limits(net, arr)

    periods = scenario.periods
    n_periods = 1 if periods is None else len(periods)
    # None (rather than a materialised copy of the network's own loads) is deliberate: it is what
    # makes the builder evaluate dc_opf's literal fixed-load expression, hence AC-4's exactness.
    fixed_load_mw = None if periods is None else _period_load_mw(net, arr, periods)

    solution = multiperiod_dc_opf(
        arr,
        cost_coeffs,
        n_periods,
        period_load_mw=fixed_load_mw,
        ramp_up_mw=ramp_up_mw,
        ramp_down_mw=ramp_down_mw,
        pwl_costs=pwl_costs or None,
        demand_bid_coeffs=demand_bid_coeffs or None,
        demand_pwl_bids=demand_pwl_bids or None,
    )
    elapsed_s = time.perf_counter() - clock
    provenance = ResultProvenance(
        engine="mambo-power",
        version=mambo_power.__version__,
        kind="market.multiperiod",
        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 MarketMultiperiodResult(
            provenance=provenance,
            status=solution.status,
            message=solution.message,
            n_periods=n_periods,
        )

    # The elastic-load column order is the builder's own: sorted(bid indices), exactly as
    # market.nodal reads OpfSolution.demand_dispatch_mw.
    elastic_idxs = sorted(set(demand_bid_coeffs) | set(demand_pwl_bids))
    elastic_pos = {idx: j for j, idx in enumerate(elastic_idxs)}
    period_results = [
        _period_rows(net, arr, solution, t, fixed_load_mw=fixed_load_mw, elastic_pos=elastic_pos)
        for t in range(n_periods)
    ]

    def horizon(field: str) -> float:
        return float(sum(getattr(p, field) for p in period_results))

    return MarketMultiperiodResult(
        provenance=provenance,
        status=solution.status,
        message=None,
        n_periods=n_periods,
        periods=period_results,
        objective_cost=solution.objective_cost,
        total_load_payment=horizon("total_load_payment"),
        total_generator_receipts=horizon("total_generator_receipts"),
        total_storage_charge_payment=horizon("total_storage_charge_payment"),
        total_storage_discharge_revenue=horizon("total_storage_discharge_revenue"),
        congestion_rent=horizon("congestion_rent"),
    )

load_bid_coeffs

load_bid_coeffs(
    net: Network, arr: NetworkArrays
) -> tuple[PolyBidCoeffs, PwlBids]

Per-load (v2, v1, v0) plus any PWL bids, from Load.bid -- the demand-side mirror of mambo_power.opf.gen_cost_coeffs. A load with no bid (bid is None) contributes to neither mapping, so dc_opf leaves it purely on the fixed-RHS side (its module docstring, "Elastic demand").

Public (not module-private) for the same reason gen_cost_coeffs is: mambo_power.market.multiperiod.solve_multiperiod needs the identical bid extraction and calls this rather than carrying a second copy (M4 review Duplication FLAG, M4/R2's own resolution applied to the demand side).

Source code in src/mambo_power/market/nodal.py
def load_bid_coeffs(net: Network, arr: NetworkArrays) -> tuple[PolyBidCoeffs, PwlBids]:
    """Per-load ``(v2, v1, v0)`` plus any PWL bids, from ``Load.bid`` -- the demand-side mirror
    of :func:`mambo_power.opf.gen_cost_coeffs`. A load with no bid (``bid is None``) contributes
    to neither mapping, so :func:`~mambo_power.opf.dc_opf.dc_opf` leaves it purely on the
    fixed-RHS side (its module docstring, "Elastic demand").

    Public (not module-private) for the same reason
    :func:`~mambo_power.opf.gen_cost_coeffs` is:
    :func:`mambo_power.market.multiperiod.solve_multiperiod` needs the identical bid extraction
    and calls this rather than carrying a second copy (M4 review Duplication FLAG, M4/R2's own
    resolution applied to the demand side).
    """
    loads_by_id = {ld.id: ld for ld in net.loads}
    demand_bid_coeffs: PolyBidCoeffs = {}
    demand_pwl_bids: PwlBids = {}
    for i, load_id in enumerate(arr.load_ids):
        bid = loads_by_id[load_id].bid
        if bid is None:
            continue
        if bid.kind == "piecewise":
            demand_pwl_bids[i] = list(bid.points)
            continue
        values = list(bid.coefficients)
        if len(values) > 3:
            raise NotImplementedError(
                f'load "{load_id}" has a degree-{len(values) - 1} polynomial bid; '
                "market.nodal supports polynomial bids up to quadratic only"
            )
        row = [0.0, 0.0, 0.0]
        row[3 - len(values) :] = values
        demand_bid_coeffs[i] = (row[0], row[1], row[2])
    return demand_bid_coeffs, demand_pwl_bids

solve_nodal

solve_nodal(
    scenario: Scenario,
    options: MarketNodalOptions | None = None,
) -> MarketNodalResult

Welfare-maximizing DC-OPF of scenario.network (module docstring): dispatch (generators and every load, bid or fixed), per-bus LMPs, and settlement.

Never raises for an infeasible or unbounded LP/QP -- reported through MarketNodalResult.status/message, mirroring mambo_power.opf.solve_dc_opf's never-raise convention. Raises NonConvexCostError up front for a non-convex generator cost and NonConcaveBidError for a non-concave load bid (both before any solve is attempted). The network is not modified.

Source code in src/mambo_power/market/nodal.py
def solve_nodal(scenario: Scenario, options: MarketNodalOptions | None = None) -> MarketNodalResult:
    """Welfare-maximizing DC-OPF of ``scenario.network`` (module docstring): dispatch
    (generators and every load, bid or fixed), per-bus LMPs, and settlement.

    Never raises for an infeasible or unbounded LP/QP -- reported through
    ``MarketNodalResult.status``/``message``, mirroring :func:`mambo_power.opf.solve_dc_opf`'s
    never-raise convention. Raises :class:`~mambo_power.opf.dc_opf.NonConvexCostError` up front
    for a non-convex generator cost and :class:`~mambo_power.opf.dc_opf.NonConcaveBidError` for
    a non-concave load bid (both before any solve is attempted). The network is not modified.
    """
    opts = options if options is not None else MarketNodalOptions()
    started_at = datetime.now(UTC)
    clock = time.perf_counter()
    net = scenario.network
    arr = NetworkArrays.from_network(net)
    cost_coeffs, pwl_costs = gen_cost_coeffs(net, arr)
    demand_bid_coeffs, demand_pwl_bids = load_bid_coeffs(net, arr)
    solution = dc_opf(
        arr,
        cost_coeffs,
        OpfDcOptions(),
        pwl_costs=pwl_costs or None,
        demand_bid_coeffs=demand_bid_coeffs or None,
        demand_pwl_bids=demand_pwl_bids or None,
    )
    elapsed_s = time.perf_counter() - clock
    provenance = ResultProvenance(
        engine="mambo-power",
        version=mambo_power.__version__,
        kind="market.nodal",
        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 MarketNodalResult(
            provenance=provenance, status=solution.status, message=solution.message
        )

    # solve_nodal reuses the PTDF matrix dc_opf already built (OpfSolution.ptdf docstring),
    # mirroring solve_dc_opf's own reuse (review Performance FLAG, carried forward from M3).
    ptdf_matrix = solution.ptdf
    lmp = lmp_decomposition(solution.duals, ptdf_matrix)
    # Every load gets a row, and the branch rows and settlement are the one construction shared
    # with market.agents (market/_clearing.py, whose docstring carries the AC-8 derivation and
    # the settlement note this block used to carry). The elastic load indices are handed over in
    # the same load-index order dc_opf itself uses (sorted(demand_bid_coeffs.keys() |
    # demand_pwl_bids.keys())).
    elastic_idxs = sorted(set(demand_bid_coeffs) | set(demand_pwl_bids))
    rows = clearing_rows(net, arr, solution, lmp.lmp, elastic_idxs)
    generators, loads, branches = rows.generators, rows.loads, rows.branches
    total_load_payment, total_generator_receipts = (
        rows.total_load_payment,
        rows.total_generator_receipts,
    )
    congestion_rent = total_load_payment - total_generator_receipts

    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)
    ]

    return MarketNodalResult(
        provenance=provenance,
        status=solution.status,
        message=None,
        generators=generators,
        loads=loads,
        buses=buses,
        branches=branches,
        total_load_payment=total_load_payment,
        total_generator_receipts=total_generator_receipts,
        congestion_rent=congestion_rent,
    )

solve_zonal

solve_zonal(
    scenario: Scenario,
    options: MarketZonalOptions | None = None,
) -> MarketZonalResult

Clear scenario.network zonally, redispatch it onto the real network, and compare the result against the nodal optimum (module docstring).

options.corridors supplies each tied zone pair's transfer capacity; the zone partition is read from Bus.zone. With no corridors at all, every zone must supply itself -- a legitimate (and often infeasible) market design, not an error.

Never raises for an infeasible or unbounded stage -- reported through MarketZonalResult.status/message, naming the stage. Raises ValueError for a bus with no zone or a malformed corridor list, :class:~mambo_power.opf.dc_opf. NonConvexCostError / NonConcaveBidError for a cost or bid curve the shared extractor rejects -- all before any solve is attempted. The network is not modified.

Source code in src/mambo_power/market/zonal.py
def solve_zonal(scenario: Scenario, options: MarketZonalOptions | None = None) -> MarketZonalResult:
    """Clear ``scenario.network`` zonally, redispatch it onto the real network, and compare the
    result against the nodal optimum (module docstring).

    ``options.corridors`` supplies each tied zone pair's transfer capacity; the zone partition is
    read from ``Bus.zone``. With no corridors at all, every zone must supply itself -- a
    legitimate (and often infeasible) market design, not an error.

    Never raises for an infeasible or unbounded stage -- reported through
    ``MarketZonalResult.status``/``message``, naming the stage. Raises :class:`ValueError` for a
    bus with no zone or a malformed corridor list, :class:`~mambo_power.opf.dc_opf.
    NonConvexCostError` / :class:`~mambo_power.opf.dc_opf.NonConcaveBidError` for a cost or bid
    curve the shared extractor rejects -- all before any solve is attempted. The network is not
    modified.
    """
    opts = options if options is not None else MarketZonalOptions()
    started_at = datetime.now(UTC)
    clock = time.perf_counter()
    net = scenario.network
    arr = NetworkArrays.from_network(net)
    cost_coeffs, pwl_costs = gen_cost_coeffs(net, arr)
    demand_bid_coeffs, demand_pwl_bids = load_bid_coeffs(net, arr)
    elastic_idxs = sorted(set(demand_bid_coeffs) | set(demand_pwl_bids))
    partition = zone_partition(net, arr)
    _reject_corridors_naming_absent_zones(opts, partition)
    corridor_caps = opts.corridor_map()

    def _provenance() -> ResultProvenance:
        return ResultProvenance(
            engine="mambo-power",
            version=mambo_power.__version__,
            kind="market.zonal",
            solver="highspy.Highs",
            started_at=started_at,
            elapsed_s=time.perf_counter() - clock,
            options=opts.model_dump(),
        )

    # --- stage 2: the zonal clearing.
    zonal: ZonalSolution = zonal_dc_opf(
        arr,
        cost_coeffs,
        partition,
        corridor_caps,
        pwl_costs=pwl_costs or None,
        demand_bid_coeffs=demand_bid_coeffs or None,
        demand_pwl_bids=demand_pwl_bids or None,
    )
    if zonal.status != "Optimal" or zonal.duals is None:
        return MarketZonalResult(
            provenance=_provenance(),
            status=zonal.status,
            message=f"zonal clearing stage: {zonal.message}",
        )

    # --- stage 3: min-cost redispatch from the zonal point onto the real network.
    final: RedispatchSolution = redispatch_dc_opf(
        arr,
        cost_coeffs,
        zonal.dispatch_mw,
        zonal.demand_dispatch_mw,
        pwl_costs=pwl_costs or None,
        demand_bid_coeffs=demand_bid_coeffs or None,
        demand_pwl_bids=demand_pwl_bids or None,
    )
    if final.status != "Optimal" or final.duals is None:
        return MarketZonalResult(
            provenance=_provenance(),
            status=final.status,
            message=f"redispatch stage: {final.message}",
        )

    # --- stage 4: the nodal reference, a separate solve on the same scenario (module docstring).
    nodal = solve_nodal(scenario)
    if nodal.status != "Optimal":
        return MarketZonalResult(
            provenance=_provenance(),
            status=nodal.status,
            message=f"nodal reference stage: {nodal.message}",
        )

    # --- stage 5: compose. Every welfare figure below is evaluated on the true curves by the one
    # pair of helpers, at all three points, so the differences are like-for-like.
    p_nodal, d_nodal = _nodal_quantities(net, arr, nodal.generators, nodal.loads, elastic_idxs)
    cost_zonal = _generation_cost(cost_coeffs, pwl_costs, zonal.dispatch_mw)
    value_zonal = _demand_value(
        demand_bid_coeffs, demand_pwl_bids, zonal.demand_dispatch_mw, elastic_idxs
    )
    cost_final = _generation_cost(cost_coeffs, pwl_costs, final.dispatch_mw)
    value_final = _demand_value(
        demand_bid_coeffs, demand_pwl_bids, final.demand_dispatch_mw, elastic_idxs
    )
    cost_nodal = _generation_cost(cost_coeffs, pwl_costs, p_nodal)
    value_nodal = _demand_value(demand_bid_coeffs, demand_pwl_bids, d_nodal, elastic_idxs)

    redispatch_payment = (cost_final - cost_zonal) + (value_zonal - value_final)
    welfare_gap = (value_nodal - cost_nodal) - (value_final - cost_final)
    generation_cost_gap = cost_zonal - cost_nodal

    lmp = lmp_decomposition(final.duals, final.ptdf)
    return MarketZonalResult(
        provenance=_provenance(),
        status="Optimal",
        message=None,
        zones=[
            ZonePriceResult(id=zone_id, price=float(zonal.duals.zone_price[z]))
            for z, zone_id in enumerate(zonal.zone_ids)
        ],
        generators=_dispatch_rows(arr, zonal.dispatch_mw, zonal.duals.gen_bound),
        loads=_load_rows(net, arr, zonal.demand_dispatch_mw, zonal.demand_bound, elastic_idxs),
        redispatch_generators=[
            GenRedispatchResult(
                id=gen_id,
                bus=arr.bus_ids[int(arr.gen_bus[i])],
                delta_up_mw=float(final.delta_up_mw[i]),
                delta_down_mw=float(final.delta_down_mw[i]),
            )
            for i, gen_id in enumerate(arr.gen_ids)
        ],
        redispatch_loads=_redispatch_load_rows(net, arr, final, elastic_idxs),
        generators_final=_dispatch_rows(arr, final.dispatch_mw, final.duals.gen_bound),
        loads_final=_load_rows(
            net, arr, final.demand_dispatch_mw, final.demand_bound, elastic_idxs
        ),
        branches=[
            OpfBranchFlowResult(
                id=branch_id,
                from_bus=arr.bus_ids[int(arr.f[k])],
                to_bus=arr.bus_ids[int(arr.t[k])],
                p_from_mw=float(final.branch_flow_mw[k]),
                flow_limit_dual=float(final.duals.flow_limit[k]),
            )
            for k, branch_id in enumerate(arr.branch_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)
        ],
        redispatch_payment=redispatch_payment,
        welfare_gap=welfare_gap,
        generation_cost_gap=generation_cost_gap,
    )

zone_partition

zone_partition(
    net: Network, arr: NetworkArrays
) -> dict[str, str]

{bus id: zone id} for every bus NetworkArrays keeps, read straight off Bus.zone.

Public for the same reason gen_cost_coeffs and load_bid_coeffs are: it is the model-to-solver extraction step for one more kind of network data, and a caller driving zonal_dc_opf directly needs exactly this mapping.

Raises UnzonedBusError -- a ValueError subclass carrying every offending bus id -- if any kept bus has zone is None. A partition with a hole has no defensible repair: that bus's load and generation must enter some zone's balance row, and choosing one for the caller would clear a market for a network they did not describe. (Buses NetworkArrays drops -- out of service, or on an islanded component -- are not consulted: they have no columns and no load in the LP.)

Bus.zone is legitimately optional in the model, so this is not something validate_network can catch: every other kind solves an unzoned network happily. It is a requirement of this analysis, which is why it is raised here and why the market.zonal runner is what translates it into a VALIDATION failure.

Source code in src/mambo_power/market/zonal.py
def zone_partition(net: Network, arr: NetworkArrays) -> dict[str, str]:
    """``{bus id: zone id}`` for every bus :class:`~mambo_power.numerics.NetworkArrays` keeps,
    read straight off ``Bus.zone``.

    Public for the same reason :func:`~mambo_power.opf.gen_cost_coeffs` and
    :func:`~mambo_power.market.nodal.load_bid_coeffs` are: it is the model-to-solver extraction
    step for one more kind of network data, and a caller driving
    :func:`~mambo_power.opf.zonal.zonal_dc_opf` directly needs exactly this mapping.

    Raises :class:`UnzonedBusError` -- a ``ValueError`` subclass carrying every offending bus id --
    if any kept bus has ``zone is None``. A partition with a hole has no defensible repair: that
    bus's load and generation must enter *some* zone's balance row, and choosing one for the caller
    would clear a market for a network they did not describe. (Buses ``NetworkArrays`` drops -- out
    of service, or on an islanded component -- are not consulted: they have no columns and no load
    in the LP.)

    ``Bus.zone`` is legitimately optional in the model, so this is not something
    :func:`~mambo_power.model.validate_network` can catch: every other kind solves an unzoned
    network happily. It is a requirement of *this* analysis, which is why it is raised here and
    why the ``market.zonal`` runner is what translates it into a ``VALIDATION`` failure.
    """
    zone_of = {bus.id: bus.zone for bus in net.buses}
    missing = [bus_id for bus_id in arr.bus_ids if zone_of.get(bus_id) is None]
    if missing:
        raise UnzonedBusError(
            missing,
            f"{len(missing)} of {len(arr.bus_ids)} in-service buses carry no zone (first: "
            f'"{missing[0]}") -- a zonal clearing needs every bus assigned to exactly one zone. '
            "Set Bus.zone (every MATPOWER import populates it from the ZONE column).",
        )
    return {bus_id: str(zone_of[bus_id]) for bus_id in arr.bus_ids}

Welfare LP over a Scenario

mambo_power.market.nodal

market.nodal clearing: the Scenario-facing welfare-maximizing DC-OPF wrapper. solve_nodal mirrors mambo_power.opf.solve_dc_opf (same provenance/PTDF-reuse/ id-keyed-result shape) but pulls both generator costs (Generator.cost) and load bids (Load.bid) from scenario.network, calls the extended mambo_power.opf.dc_opf.dc_opf with both, and decomposes the result into per-bus LMPs (mambo_power.opf.dc_opf.lmp_decomposition, M3's, reused verbatim per ADR-006) plus settlement (payments, receipts, congestion rent): total load payment minus total generator receipts equals the congestion rent, i.e. -sum(mu_k * flow_k) over the binding branches (see the wave spec's AC-4 for the exact identity and its proof).

Branch rows (M7 W4, AC-8). dc_opf's own OpfSolution carries no per-branch flow -- only the PTDF matrix and the flow-limit duals -- so the flow flow_k = PTDF[k] . (net injection) + phase-shift injection is derived from the dispatch already solved for, in mambo_power.market._clearing.clearing_rows: one construction, shared with mambo_power.market.agents.solve_agents (M7 S11), and not a parallel formula -- see that module's docstring for the derivation and the AC-8 readback that checks it.

NonConcaveBidError

Bases: ValueError

A demand bid is non-concave: either a piecewise-linear bid's breakpoint slopes are not non-increasing, or a quadratic (polynomial) bid has v2 > 0.

The demand-side mirror of NonConvexCostError (module docstring, "Elastic demand"), raised by dc_opf before any HiGHS object is created: the concave segment/hypograph LP encoding, and the QP Hessian's positive semi-definiteness (built from −v2), are only valid for a concave value curve — silently solving a non-concave one would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §1.1, §1.2).

NonConvexCostError

Bases: ValueError

A generator cost is non-convex: either a PiecewiseCost's breakpoint slopes are not non-decreasing, or a quadratic cost has c2 < 0.

Raised by dc_opf before any HiGHS object is created (module docstring, "PWL costs" / "Elastic demand"): the convex segment/epigraph LP encoding, and the QP Hessian's positive semi-definiteness, are only valid for a convex cost, and silently solving a non-convex one would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §2.1, §1.2). opf-local — PiecewiseCost itself validates only strictly-increasing p_mw, not convexity (record/m3-research.md §2.3).

MarketNodalOptions

Bases: BaseModel

Options of a market.nodal clearing.

No fields yet: mirrors OpfDcOptions's own precedent (a solver-tuning field is added the first time a caller actually needs one, not invented speculatively). It exists rather than being omitted because the market.nodal KindSpec names it as the model every request's options is validated against, and a kind with no options model rejects any key at all.

load_bid_coeffs

load_bid_coeffs(
    net: Network, arr: NetworkArrays
) -> tuple[PolyBidCoeffs, PwlBids]

Per-load (v2, v1, v0) plus any PWL bids, from Load.bid -- the demand-side mirror of mambo_power.opf.gen_cost_coeffs. A load with no bid (bid is None) contributes to neither mapping, so dc_opf leaves it purely on the fixed-RHS side (its module docstring, "Elastic demand").

Public (not module-private) for the same reason gen_cost_coeffs is: mambo_power.market.multiperiod.solve_multiperiod needs the identical bid extraction and calls this rather than carrying a second copy (M4 review Duplication FLAG, M4/R2's own resolution applied to the demand side).

Source code in src/mambo_power/market/nodal.py
def load_bid_coeffs(net: Network, arr: NetworkArrays) -> tuple[PolyBidCoeffs, PwlBids]:
    """Per-load ``(v2, v1, v0)`` plus any PWL bids, from ``Load.bid`` -- the demand-side mirror
    of :func:`mambo_power.opf.gen_cost_coeffs`. A load with no bid (``bid is None``) contributes
    to neither mapping, so :func:`~mambo_power.opf.dc_opf.dc_opf` leaves it purely on the
    fixed-RHS side (its module docstring, "Elastic demand").

    Public (not module-private) for the same reason
    :func:`~mambo_power.opf.gen_cost_coeffs` is:
    :func:`mambo_power.market.multiperiod.solve_multiperiod` needs the identical bid extraction
    and calls this rather than carrying a second copy (M4 review Duplication FLAG, M4/R2's own
    resolution applied to the demand side).
    """
    loads_by_id = {ld.id: ld for ld in net.loads}
    demand_bid_coeffs: PolyBidCoeffs = {}
    demand_pwl_bids: PwlBids = {}
    for i, load_id in enumerate(arr.load_ids):
        bid = loads_by_id[load_id].bid
        if bid is None:
            continue
        if bid.kind == "piecewise":
            demand_pwl_bids[i] = list(bid.points)
            continue
        values = list(bid.coefficients)
        if len(values) > 3:
            raise NotImplementedError(
                f'load "{load_id}" has a degree-{len(values) - 1} polynomial bid; '
                "market.nodal supports polynomial bids up to quadratic only"
            )
        row = [0.0, 0.0, 0.0]
        row[3 - len(values) :] = values
        demand_bid_coeffs[i] = (row[0], row[1], row[2])
    return demand_bid_coeffs, demand_pwl_bids

solve_nodal

solve_nodal(
    scenario: Scenario,
    options: MarketNodalOptions | None = None,
) -> MarketNodalResult

Welfare-maximizing DC-OPF of scenario.network (module docstring): dispatch (generators and every load, bid or fixed), per-bus LMPs, and settlement.

Never raises for an infeasible or unbounded LP/QP -- reported through MarketNodalResult.status/message, mirroring mambo_power.opf.solve_dc_opf's never-raise convention. Raises NonConvexCostError up front for a non-convex generator cost and NonConcaveBidError for a non-concave load bid (both before any solve is attempted). The network is not modified.

Source code in src/mambo_power/market/nodal.py
def solve_nodal(scenario: Scenario, options: MarketNodalOptions | None = None) -> MarketNodalResult:
    """Welfare-maximizing DC-OPF of ``scenario.network`` (module docstring): dispatch
    (generators and every load, bid or fixed), per-bus LMPs, and settlement.

    Never raises for an infeasible or unbounded LP/QP -- reported through
    ``MarketNodalResult.status``/``message``, mirroring :func:`mambo_power.opf.solve_dc_opf`'s
    never-raise convention. Raises :class:`~mambo_power.opf.dc_opf.NonConvexCostError` up front
    for a non-convex generator cost and :class:`~mambo_power.opf.dc_opf.NonConcaveBidError` for
    a non-concave load bid (both before any solve is attempted). The network is not modified.
    """
    opts = options if options is not None else MarketNodalOptions()
    started_at = datetime.now(UTC)
    clock = time.perf_counter()
    net = scenario.network
    arr = NetworkArrays.from_network(net)
    cost_coeffs, pwl_costs = gen_cost_coeffs(net, arr)
    demand_bid_coeffs, demand_pwl_bids = load_bid_coeffs(net, arr)
    solution = dc_opf(
        arr,
        cost_coeffs,
        OpfDcOptions(),
        pwl_costs=pwl_costs or None,
        demand_bid_coeffs=demand_bid_coeffs or None,
        demand_pwl_bids=demand_pwl_bids or None,
    )
    elapsed_s = time.perf_counter() - clock
    provenance = ResultProvenance(
        engine="mambo-power",
        version=mambo_power.__version__,
        kind="market.nodal",
        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 MarketNodalResult(
            provenance=provenance, status=solution.status, message=solution.message
        )

    # solve_nodal reuses the PTDF matrix dc_opf already built (OpfSolution.ptdf docstring),
    # mirroring solve_dc_opf's own reuse (review Performance FLAG, carried forward from M3).
    ptdf_matrix = solution.ptdf
    lmp = lmp_decomposition(solution.duals, ptdf_matrix)
    # Every load gets a row, and the branch rows and settlement are the one construction shared
    # with market.agents (market/_clearing.py, whose docstring carries the AC-8 derivation and
    # the settlement note this block used to carry). The elastic load indices are handed over in
    # the same load-index order dc_opf itself uses (sorted(demand_bid_coeffs.keys() |
    # demand_pwl_bids.keys())).
    elastic_idxs = sorted(set(demand_bid_coeffs) | set(demand_pwl_bids))
    rows = clearing_rows(net, arr, solution, lmp.lmp, elastic_idxs)
    generators, loads, branches = rows.generators, rows.loads, rows.branches
    total_load_payment, total_generator_receipts = (
        rows.total_load_payment,
        rows.total_generator_receipts,
    )
    congestion_rent = total_load_payment - total_generator_receipts

    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)
    ]

    return MarketNodalResult(
        provenance=provenance,
        status=solution.status,
        message=None,
        generators=generators,
        loads=loads,
        buses=buses,
        branches=branches,
        total_load_payment=total_load_payment,
        total_generator_receipts=total_generator_receipts,
        congestion_rent=congestion_rent,
    )

Multiperiod clearing over a horizon

mambo_power.market.multiperiod

market.multiperiod clearing: the Scenario-facing wrapper over the multiperiod builder.

solve_multiperiod is the exact multiperiod sibling of mambo_power.market.nodal.solve_nodal and sits at the same altitude: it is the model-side extraction and settlement layer over mambo_power.opf.multiperiod.multiperiod_dc_opf, precisely as market.nodal sits over mambo_power.opf.dc_opf.dc_opf. Nothing numeric happens here; what happens here is turning Scenario data into the array-level builder's arguments, and turning its solution back into id-keyed rows with a settlement attached.

What is extracted, and from where.

  • Generator costs -- mambo_power.opf.gen_cost_coeffs, shared verbatim with market.nodal (M4's Step-6 review raised a Duplication FLAG over exactly this, and M4/R2 made the helper public so both market modules could call the one copy).
  • Load bids -- mambo_power.market.nodal.load_bid_coeffs, likewise shared rather than copied. Bids are horizon-invariant: per-period offers and bids are the wave's own Not-Doing list.
  • Per-period fixed load -- each Period's load_p_mw resolved into NetworkArrays.load_ids positions, with a load the period's dict omits falling back to its own Load.p_mw (Period is an override, not a complete specification).
  • Ramp limits -- Generator.ramp_up_mw/ramp_down_mw gathered into (n_gen,) arrays the same way gen_cost_coeffs gathers costs. NetworkArrays carries no ramp fields; the ramp data lives on the entity, so this is where it becomes an array.
  • Per-period LMPs -- mambo_power.opf.dc_opf.lmp_decomposition (M3's, unmodified), fed period t's own balance and flow-limit duals against the single PTDF matrix the builder already returned.

A period-less scenario is a one-period horizon. Scenario.periods is None means single-period (the model's own documented meaning), so solve_multiperiod clears T = 1 with period_load_mw=None -- which makes the builder's fixed-load and flow-constant expressions literally dc_opf's, so the result is bit-for-bit market.nodal's (wave AC-4). It is not an error and not a special case; it is the degenerate end of the same code path.

Settlement. Payments, receipts and congestion rent are computed per period, directly from that period's LMPs and dispatch. Storage is settled as the third participant it physically is -- it pays for what it stores and is paid for what it returns -- and mambo_power.results.multiperiod states the identity that makes that necessary rather than decorative, together with the general form's pf_shift/g_shunt correction terms.

NonConcaveBidError

Bases: ValueError

A demand bid is non-concave: either a piecewise-linear bid's breakpoint slopes are not non-increasing, or a quadratic (polynomial) bid has v2 > 0.

The demand-side mirror of NonConvexCostError (module docstring, "Elastic demand"), raised by dc_opf before any HiGHS object is created: the concave segment/hypograph LP encoding, and the QP Hessian's positive semi-definiteness (built from −v2), are only valid for a concave value curve — silently solving a non-concave one would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §1.1, §1.2).

NonConvexCostError

Bases: ValueError

A generator cost is non-convex: either a PiecewiseCost's breakpoint slopes are not non-decreasing, or a quadratic cost has c2 < 0.

Raised by dc_opf before any HiGHS object is created (module docstring, "PWL costs" / "Elastic demand"): the convex segment/epigraph LP encoding, and the QP Hessian's positive semi-definiteness, are only valid for a convex cost, and silently solving a non-convex one would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §2.1, §1.2). opf-local — PiecewiseCost itself validates only strictly-increasing p_mw, not convexity (record/m3-research.md §2.3).

MarketMultiperiodOptions

Bases: BaseModel

Options of a market.multiperiod clearing.

No fields yet, for the same reason MarketNodalOptions has none: a solver-tuning field is added the first time a caller actually needs one. It exists now, rather than being omitted, because the options model is what the registered market.multiperiod jobs kind validates a request against -- and because the array-level builder deliberately takes no options parameter at all, so this is the one place multiperiod options can live.

solve_multiperiod

solve_multiperiod(
    scenario: Scenario,
    options: MarketMultiperiodOptions | None = None,
) -> MarketMultiperiodResult

Clear scenario over its whole horizon as one coupled LP/QP (module docstring): per-period dispatch, per-bus LMPs, per-storage charge/discharge/SoC, per-period settlement, and horizon totals.

scenario.periods is None clears a single period from the network's own loads, reproducing mambo_power.market.nodal.solve_nodal exactly (wave AC-4).

Never raises for an infeasible or unbounded LP/QP -- reported through MarketMultiperiodResult.status/message, mirroring solve_nodal's never-raise convention. Raises NonConvexCostError up front for a non-convex generator cost, NonConcaveBidError for a non-concave load bid, and ValueError for a ramp limit of exactly zero (which would freeze a unit for the whole horizon) -- all before any solve is attempted. The scenario is not modified.

Source code in src/mambo_power/market/multiperiod.py
def solve_multiperiod(
    scenario: Scenario, options: MarketMultiperiodOptions | None = None
) -> MarketMultiperiodResult:
    """Clear ``scenario`` over its whole horizon as one coupled LP/QP (module docstring):
    per-period dispatch, per-bus LMPs, per-storage charge/discharge/SoC, per-period settlement,
    and horizon totals.

    ``scenario.periods is None`` clears a single period from the network's own loads, reproducing
    :func:`mambo_power.market.nodal.solve_nodal` exactly (wave AC-4).

    Never raises for an infeasible or unbounded LP/QP -- reported through
    ``MarketMultiperiodResult.status``/``message``, mirroring ``solve_nodal``'s never-raise
    convention. Raises :class:`~mambo_power.opf.dc_opf.NonConvexCostError` up front for a
    non-convex generator cost, :class:`~mambo_power.opf.dc_opf.NonConcaveBidError` for a
    non-concave load bid, and :class:`ValueError` for a ramp limit of exactly zero (which would
    freeze a unit for the whole horizon) -- all before any solve is attempted. The scenario is
    not modified.
    """
    opts = options if options is not None else MarketMultiperiodOptions()
    started_at = datetime.now(UTC)
    clock = time.perf_counter()
    net = scenario.network
    arr = NetworkArrays.from_network(net)
    cost_coeffs, pwl_costs = gen_cost_coeffs(net, arr)
    demand_bid_coeffs, demand_pwl_bids = load_bid_coeffs(net, arr)
    ramp_up_mw, ramp_down_mw = _ramp_limits(net, arr)

    periods = scenario.periods
    n_periods = 1 if periods is None else len(periods)
    # None (rather than a materialised copy of the network's own loads) is deliberate: it is what
    # makes the builder evaluate dc_opf's literal fixed-load expression, hence AC-4's exactness.
    fixed_load_mw = None if periods is None else _period_load_mw(net, arr, periods)

    solution = multiperiod_dc_opf(
        arr,
        cost_coeffs,
        n_periods,
        period_load_mw=fixed_load_mw,
        ramp_up_mw=ramp_up_mw,
        ramp_down_mw=ramp_down_mw,
        pwl_costs=pwl_costs or None,
        demand_bid_coeffs=demand_bid_coeffs or None,
        demand_pwl_bids=demand_pwl_bids or None,
    )
    elapsed_s = time.perf_counter() - clock
    provenance = ResultProvenance(
        engine="mambo-power",
        version=mambo_power.__version__,
        kind="market.multiperiod",
        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 MarketMultiperiodResult(
            provenance=provenance,
            status=solution.status,
            message=solution.message,
            n_periods=n_periods,
        )

    # The elastic-load column order is the builder's own: sorted(bid indices), exactly as
    # market.nodal reads OpfSolution.demand_dispatch_mw.
    elastic_idxs = sorted(set(demand_bid_coeffs) | set(demand_pwl_bids))
    elastic_pos = {idx: j for j, idx in enumerate(elastic_idxs)}
    period_results = [
        _period_rows(net, arr, solution, t, fixed_load_mw=fixed_load_mw, elastic_pos=elastic_pos)
        for t in range(n_periods)
    ]

    def horizon(field: str) -> float:
        return float(sum(getattr(p, field) for p in period_results))

    return MarketMultiperiodResult(
        provenance=provenance,
        status=solution.status,
        message=None,
        n_periods=n_periods,
        periods=period_results,
        objective_cost=solution.objective_cost,
        total_load_payment=horizon("total_load_payment"),
        total_generator_receipts=horizon("total_generator_receipts"),
        total_storage_charge_payment=horizon("total_storage_charge_payment"),
        total_storage_discharge_revenue=horizon("total_storage_discharge_revenue"),
        congestion_rent=horizon("congestion_rent"),
    )

Zonal clearing and redispatch

The three-solve chain: a zonal clearing, a minimum-cost redispatch onto the real network, and market.solve_nodal as the reference. Its module docstring states where each of the three reported figures comes from and why the third one is not sign-constrained.

mambo_power.market.zonal

market.zonal clearing: zonal market, min-cost redispatch, nodal reference — and what the distance between them costs.

solve_zonal is the third Scenario-facing market entry point, at exactly the altitude solve_nodal and solve_multiperiod sit at: model-side extraction and settlement over array-level builders that do the numerics. Nothing here builds a row or a column. What is new is that it drives three solves instead of one, and that its result is their relationship rather than any one of them.

The chain, in order, and why each stage is there.

  1. Zones off the model. Bus.zone and net.zones have been in the schema from the start and are populated by every MATPOWER import; this is where they become solver input. The partition is read, never derived: a bus with no zone is an error, because there is no defensible default for whose balance row its load belongs in.
  2. Zonal clearingzonal_dc_opf on that partition and the caller's corridor capacities. One price per zone, the intra-zone grid ignored, inter-zonal exchange bounded by one corridor variable per tied zone pair. This is the market the participants actually clear in, and its schedule is generally not something the real network can carry.
  3. Min-cost redispatchredispatch_dc_opf from that schedule, with the true cost and bid curves in the objective and the real PTDF flow rows reinstated. This is the operator's action after the market closes.
  4. The nodal referencesolve_nodal on the same scenario. It is the yardstick, and it is a genuinely separate solve rather than a quantity inferred from stage 3, precisely because stage 3's agreement with it is the thing the tests assert. Inferring the reference from the thing being tested would make that assertion vacuous.
  5. Composition into MarketZonalResult.

Why stage 4 is not redundant, even though its answer is predictable. The redispatch objective is the true welfare function over nodal's exact feasible set, so the redispatched point is the nodal optimum — welfare_gap is 0 by theorem. That makes stage 4 a check on the chain rather than a source of new information, and a check is worth its solve: it is the one thing that would notice if the redispatch LP's feasible set quietly stopped being nodal's. It also supplies the reference welfare and generation cost that the other two figures are measured against.

Where each of the three figures comes from, computed one way. Welfare figures are needed at three different points — the zonal schedule, the final schedule and the nodal optimum — and only some of them are reported by the solve that produced them (:class:~mambo_power.opf.zonal. ZonalSolution has a generation cost but no demand value; :class:~mambo_power.results. MarketNodalResult has neither). Rather than mix reported figures with derived ones, this module evaluates the true cost and bid curves itself at all three points, through the one pair of helpers _generation_cost / _demand_value, so that every difference taken below is like-for-like. Those helpers are an independent evaluation path from the LPs' own epigraph/ hypograph encoding, and tests/unit/test_market_zonal.py asserts they agree with objective_cost / demand_value — an agreement between two constructions, not a tautology.

  • redispatch_payment = [cost(final) - cost(zonal)] + [value(d_zonal) - value(d_final)] — the settlement figure the operator actually pays: extra generation cost plus curtailment compensation at bid value. Algebraically identical to welfare(zonal) - welfare(final), which is why it is non-negative wherever the zonal LP is a relaxation of the nodal one: it is exactly the welfare the zonal clearing promised and the network could not deliver.
  • welfare_gap = welfare(nodal) - welfare(final) — the exactness row; 0 by the theorem above.
  • generation_cost_gap = cost(zonal) - cost(nodal) — the unsigned diagnostic.

The three are two quantities and a combination, and the combination is worth naming. By that same theorem cost(final) == cost(nodal), so with A = cost(final) - cost(zonal) and B = value(d_zonal) - value(d_final) the three fields are A + B, 0 and -A. Hence

``redispatch_payment + generation_cost_gap == value(d_zonal) - value(d_final)``

exactly: adding the diagnostic to the settlement cancels the generation-cost term and leaves the curtailment compensation on its own. That is the whole of what the third field adds — on a network where no load carries a bid curve it is identically zero and generation_cost_gap is precisely -redispatch_payment, while on rated case30 with bids it is 0.94 of a 14.51 $/h payment. Read the pair that way and the fields stop looking redundant; read them as three independent numbers and a sign flip will pass for information.

Why the diagnostic measures the zonal point and not the final one. The obvious definition of a generation-cost gap is cost(final) - cost(nodal), and it is the informative one under an anchored-rate redispatch objective — that objective lands somewhere other than nodal, and it can land at strictly lower generation cost while destroying welfare. The anchored rate was rejected (see mambo_power.opf.redispatch), and under true curves cost(final) - cost(nodal) is identically zero: the same theorem that makes welfare_gap zero makes it zero, so it would be a second copy of the exactness row rather than a diagnostic. The quantity that survives is the zonal point's cost against nodal's, and the warning survives with it, assumption-free: welfare is what the relaxation argument orders, generation cost is not, so a zonal clearing can be welfare-better and generation-cost-cheaper or dearer than nodal. That is the figure this module reports, and the reason its description insists it is not sign-constrained.

Never raises for a solve that does not converge. A non-Optimal stage — zonal, redispatch or nodal — comes back as status plus a message naming that stage, this package's standing convention. Malformed input still raises up front, and which exception it raises decides how a caller of mambo_power.jobs.run is told whose mistake it was. A corridor list that is ambiguous on its own — a self-pair, the same unordered pair twice — is rejected by MarketZonalOptions's validator before any solve, so it arrives as BAD_OPTIONS. A corridor naming a zone the network does not have is only detectable once both are in hand, so _reject_corridors_naming_absent_zones raises NetworkValidationError with a DANGLING_REF issue and it arrives as VALIDATION. A bus carrying no zone is the same kind of mistake one layer further in, and arrives the same way: zone_partition raises UnzonedBusError and the runner translates it, one DANGLING_REF issue per offending bus. A non-convex generator cost and a non-concave load bid raise their own typed errors.

MAX_CORRIDORS module-attribute

MAX_CORRIDORS = 500

Upper bound on MarketZonalOptions.corridors' length: a request/response-size guard, not a solver limit — the same guard MAX_PERIODS puts on Scenario.periods, applied to the other user-supplied list this package takes.

The honest bound is the network's own: a partition into n zones admits at most n(n-1)/2 distinct pairs, and a corridor list longer than that necessarily repeats one (now rejected on its own). But n is a property of the network and this is an options model, which has none — so the bound here is a fixed number chosen to sit above every network anyone clears zonally and below the sizes that make the response a problem. corridors is echoed verbatim into every result's provenance.options, so the list's length is paid twice, once inbound and once out.

500 covers a 32-zone network exhaustively (496 pairs, measured 22,025 bytes of options JSON), and 32 zones is already above Europe's day-ahead market, the largest zonal design in operation at around 25 bidding zones. Above it, growth is quadratic and unbounded in an options field: 200 zones is 19,900 corridors and 913,425 bytes echoed back per solve. Review F2 measured 20,000 entries accepted before this bound existed.

NonConcaveBidError

Bases: ValueError

A demand bid is non-concave: either a piecewise-linear bid's breakpoint slopes are not non-increasing, or a quadratic (polynomial) bid has v2 > 0.

The demand-side mirror of NonConvexCostError (module docstring, "Elastic demand"), raised by dc_opf before any HiGHS object is created: the concave segment/hypograph LP encoding, and the QP Hessian's positive semi-definiteness (built from −v2), are only valid for a concave value curve — silently solving a non-concave one would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §1.1, §1.2).

NonConvexCostError

Bases: ValueError

A generator cost is non-convex: either a PiecewiseCost's breakpoint slopes are not non-decreasing, or a quadratic cost has c2 < 0.

Raised by dc_opf before any HiGHS object is created (module docstring, "PWL costs" / "Elastic demand"): the convex segment/epigraph LP encoding, and the QP Hessian's positive semi-definiteness, are only valid for a convex cost, and silently solving a non-convex one would give a wrong-but-optimal-looking dispatch rather than fail loudly (research §2.1, §1.2). opf-local — PiecewiseCost itself validates only strictly-increasing p_mw, not convexity (record/m3-research.md §2.3).

UnzonedBusError

UnzonedBusError(bus_ids: Sequence[str], message: str)

Bases: ValueError

A network zone_partition cannot partition: at least one bus the solve keeps carries no Bus.zone.

A ValueError subclass, deliberately. It has to stay a ValueError because that is what zone_partition has always raised and what a caller driving it directly catches. It has to be a distinguishable type because jobs' runner cannot otherwise tell this apart from any other ValueError a solve might raise, and the difference matters: this one is the caller's network data, so it belongs in a VALIDATION failure naming the buses, not in the INTERNAL bucket the jobs manual defines as "anything else the runner raised (singular matrix, a bug)".

bus_ids carries every offending bus, in NetworkArrays order, so the runner can report all of them rather than the first.

Source code in src/mambo_power/market/zonal.py
def __init__(self, bus_ids: Sequence[str], message: str) -> None:
    self.bus_ids = list(bus_ids)
    super().__init__(message)

CorridorLimit

Bases: BaseModel

One inter-zonal corridor's transfer capacity — an entry of MarketZonalOptions.corridors.

Why this is an option and not a model field. A corridor capacity is a transfer limit between two zones, and the domain model deliberately has no transfer-capacity entity, because a real NTC is administratively negotiated data that no network file carries and no branch rating uniquely determines. So capacities are supplied per solve, by the caller who knows them. A defensible default, if you need one, is the sum of rating_mva over the pair's cut-set — which is what tests/_zones.py's corridors() builds.

Why a row model rather than a {(z1, z2): cap} mapping. The mapping is the shape the array-level builder takes and the shape MarketZonalOptions.corridor_map hands it. It is not a shape a pydantic options model can carry, because a dict keyed by a tuple does not survive a JSON round trip: pydantic serialises the key ("1", "2") to the string "1,2" and then refuses to validate that string back into a tuple. An options model that cannot round-trip through JSON is a jobs request form that cannot round-trip either, and an exact JSON round trip is a standing requirement on every kind — so the serialisable shape is the one stored, and the mapping is derived on the way to the builder.

zone1 class-attribute instance-attribute

zone1: str

One end of the corridor: a zone id present in the network. A zone id no bus carries is rejected at solve time, when the network is in hand (jobs: VALIDATION).

zone2 class-attribute instance-attribute

zone2: str

The other end; must differ from zone1, and the resulting unordered pair must not appear elsewhere in the list. Both are enforced on MarketZonalOptions itself, before any solve (jobs: BAD_OPTIONS).

cap_mw class-attribute instance-attribute

cap_mw: Annotated[float, Field(ge=0.0)] | None

Transfer capacity, MW, as a magnitude: the corridor is bounded at [-cap_mw, +cap_mw], so it constrains both directions equally. 0 is allowed and means a tie that exists but can carry nothing. null means unbounded -- the copper plate: the corridor stays in the LP with no bound at all, which is a different market from deleting the entry (that islands the two zones and reports no capacity price for them). A number must be finite: inf is rejected, so the copper plate has exactly one spelling on this model and it is one every JSON parser reads.

MarketZonalOptions

Bases: BaseModel

Options of a market.zonal clearing.

Unlike MarketNodalOptions and MarketMultiperiodOptions, this one has a field from the start, and it is not solver tuning: corridors is market design data the model deliberately does not carry (see CorridorLimit). An empty list is a meaningful default and not a missing argument -- it means no zone pair may exchange anything, so each zone must supply itself.

corridors class-attribute instance-attribute

corridors: list[CorridorLimit]

Transfer capacity per tied zone pair. A pair absent from this list has no corridor at all and so cannot exchange power -- which is a stronger statement than a corridor of capacity 0 only in that no capacity shadow price is reported for it. At most MAX_CORRIDORS (500) entries -- a request/response-size guard, since this list is echoed verbatim into every result's provenance.options; 500 exhausts a 32-zone network, which is above every zonal market in operation.

corridor_map

corridor_map() -> dict[ZoneKey, float]

corridors as the {(zone1, zone2): cap_mw} mapping zonal_dc_opf takes. Keys are left in the order given; the builder normalises each to sorted order. This is a dict comprehension and so cannot report a repeated key -- which is exactly why the repeat is rejected on the model above, before any mapping is built.

This is also where the copper plate changes spelling. On the wire an unbounded corridor is null, because that is what JSON has; the array-level builder wants inf, because that is what HiGHS has (it maps it to kHighsInf). Neither layer has to know about the other's, and the translation is one line here rather than a non-standard token in every request and response body.

Source code in src/mambo_power/market/zonal.py
def corridor_map(self) -> dict[ZoneKey, float]:
    """:attr:`corridors` as the ``{(zone1, zone2): cap_mw}`` mapping
    :func:`~mambo_power.opf.zonal.zonal_dc_opf` takes. Keys are left in the order given; the
    builder normalises each to sorted order. This is a dict comprehension and so cannot report
    a repeated key -- which is exactly why the repeat is rejected on the model above, before
    any mapping is built.

    This is also where the copper plate changes spelling. On the wire an unbounded corridor is
    ``null``, because that is what JSON has; the array-level builder wants ``inf``, because
    that is what HiGHS has (it maps it to ``kHighsInf``). Neither layer has to know about the
    other's, and the translation is one line here rather than a non-standard token in every
    request and response body.
    """
    return {
        (entry.zone1, entry.zone2): math.inf if entry.cap_mw is None else entry.cap_mw
        for entry in self.corridors
    }

zone_partition

zone_partition(
    net: Network, arr: NetworkArrays
) -> dict[str, str]

{bus id: zone id} for every bus NetworkArrays keeps, read straight off Bus.zone.

Public for the same reason gen_cost_coeffs and load_bid_coeffs are: it is the model-to-solver extraction step for one more kind of network data, and a caller driving zonal_dc_opf directly needs exactly this mapping.

Raises UnzonedBusError -- a ValueError subclass carrying every offending bus id -- if any kept bus has zone is None. A partition with a hole has no defensible repair: that bus's load and generation must enter some zone's balance row, and choosing one for the caller would clear a market for a network they did not describe. (Buses NetworkArrays drops -- out of service, or on an islanded component -- are not consulted: they have no columns and no load in the LP.)

Bus.zone is legitimately optional in the model, so this is not something validate_network can catch: every other kind solves an unzoned network happily. It is a requirement of this analysis, which is why it is raised here and why the market.zonal runner is what translates it into a VALIDATION failure.

Source code in src/mambo_power/market/zonal.py
def zone_partition(net: Network, arr: NetworkArrays) -> dict[str, str]:
    """``{bus id: zone id}`` for every bus :class:`~mambo_power.numerics.NetworkArrays` keeps,
    read straight off ``Bus.zone``.

    Public for the same reason :func:`~mambo_power.opf.gen_cost_coeffs` and
    :func:`~mambo_power.market.nodal.load_bid_coeffs` are: it is the model-to-solver extraction
    step for one more kind of network data, and a caller driving
    :func:`~mambo_power.opf.zonal.zonal_dc_opf` directly needs exactly this mapping.

    Raises :class:`UnzonedBusError` -- a ``ValueError`` subclass carrying every offending bus id --
    if any kept bus has ``zone is None``. A partition with a hole has no defensible repair: that
    bus's load and generation must enter *some* zone's balance row, and choosing one for the caller
    would clear a market for a network they did not describe. (Buses ``NetworkArrays`` drops -- out
    of service, or on an islanded component -- are not consulted: they have no columns and no load
    in the LP.)

    ``Bus.zone`` is legitimately optional in the model, so this is not something
    :func:`~mambo_power.model.validate_network` can catch: every other kind solves an unzoned
    network happily. It is a requirement of *this* analysis, which is why it is raised here and
    why the ``market.zonal`` runner is what translates it into a ``VALIDATION`` failure.
    """
    zone_of = {bus.id: bus.zone for bus in net.buses}
    missing = [bus_id for bus_id in arr.bus_ids if zone_of.get(bus_id) is None]
    if missing:
        raise UnzonedBusError(
            missing,
            f"{len(missing)} of {len(arr.bus_ids)} in-service buses carry no zone (first: "
            f'"{missing[0]}") -- a zonal clearing needs every bus assigned to exactly one zone. '
            "Set Bus.zone (every MATPOWER import populates it from the ZONE column).",
        )
    return {bus_id: str(zone_of[bus_id]) for bus_id in arr.bus_ids}

solve_zonal

solve_zonal(
    scenario: Scenario,
    options: MarketZonalOptions | None = None,
) -> MarketZonalResult

Clear scenario.network zonally, redispatch it onto the real network, and compare the result against the nodal optimum (module docstring).

options.corridors supplies each tied zone pair's transfer capacity; the zone partition is read from Bus.zone. With no corridors at all, every zone must supply itself -- a legitimate (and often infeasible) market design, not an error.

Never raises for an infeasible or unbounded stage -- reported through MarketZonalResult.status/message, naming the stage. Raises ValueError for a bus with no zone or a malformed corridor list, :class:~mambo_power.opf.dc_opf. NonConvexCostError / NonConcaveBidError for a cost or bid curve the shared extractor rejects -- all before any solve is attempted. The network is not modified.

Source code in src/mambo_power/market/zonal.py
def solve_zonal(scenario: Scenario, options: MarketZonalOptions | None = None) -> MarketZonalResult:
    """Clear ``scenario.network`` zonally, redispatch it onto the real network, and compare the
    result against the nodal optimum (module docstring).

    ``options.corridors`` supplies each tied zone pair's transfer capacity; the zone partition is
    read from ``Bus.zone``. With no corridors at all, every zone must supply itself -- a
    legitimate (and often infeasible) market design, not an error.

    Never raises for an infeasible or unbounded stage -- reported through
    ``MarketZonalResult.status``/``message``, naming the stage. Raises :class:`ValueError` for a
    bus with no zone or a malformed corridor list, :class:`~mambo_power.opf.dc_opf.
    NonConvexCostError` / :class:`~mambo_power.opf.dc_opf.NonConcaveBidError` for a cost or bid
    curve the shared extractor rejects -- all before any solve is attempted. The network is not
    modified.
    """
    opts = options if options is not None else MarketZonalOptions()
    started_at = datetime.now(UTC)
    clock = time.perf_counter()
    net = scenario.network
    arr = NetworkArrays.from_network(net)
    cost_coeffs, pwl_costs = gen_cost_coeffs(net, arr)
    demand_bid_coeffs, demand_pwl_bids = load_bid_coeffs(net, arr)
    elastic_idxs = sorted(set(demand_bid_coeffs) | set(demand_pwl_bids))
    partition = zone_partition(net, arr)
    _reject_corridors_naming_absent_zones(opts, partition)
    corridor_caps = opts.corridor_map()

    def _provenance() -> ResultProvenance:
        return ResultProvenance(
            engine="mambo-power",
            version=mambo_power.__version__,
            kind="market.zonal",
            solver="highspy.Highs",
            started_at=started_at,
            elapsed_s=time.perf_counter() - clock,
            options=opts.model_dump(),
        )

    # --- stage 2: the zonal clearing.
    zonal: ZonalSolution = zonal_dc_opf(
        arr,
        cost_coeffs,
        partition,
        corridor_caps,
        pwl_costs=pwl_costs or None,
        demand_bid_coeffs=demand_bid_coeffs or None,
        demand_pwl_bids=demand_pwl_bids or None,
    )
    if zonal.status != "Optimal" or zonal.duals is None:
        return MarketZonalResult(
            provenance=_provenance(),
            status=zonal.status,
            message=f"zonal clearing stage: {zonal.message}",
        )

    # --- stage 3: min-cost redispatch from the zonal point onto the real network.
    final: RedispatchSolution = redispatch_dc_opf(
        arr,
        cost_coeffs,
        zonal.dispatch_mw,
        zonal.demand_dispatch_mw,
        pwl_costs=pwl_costs or None,
        demand_bid_coeffs=demand_bid_coeffs or None,
        demand_pwl_bids=demand_pwl_bids or None,
    )
    if final.status != "Optimal" or final.duals is None:
        return MarketZonalResult(
            provenance=_provenance(),
            status=final.status,
            message=f"redispatch stage: {final.message}",
        )

    # --- stage 4: the nodal reference, a separate solve on the same scenario (module docstring).
    nodal = solve_nodal(scenario)
    if nodal.status != "Optimal":
        return MarketZonalResult(
            provenance=_provenance(),
            status=nodal.status,
            message=f"nodal reference stage: {nodal.message}",
        )

    # --- stage 5: compose. Every welfare figure below is evaluated on the true curves by the one
    # pair of helpers, at all three points, so the differences are like-for-like.
    p_nodal, d_nodal = _nodal_quantities(net, arr, nodal.generators, nodal.loads, elastic_idxs)
    cost_zonal = _generation_cost(cost_coeffs, pwl_costs, zonal.dispatch_mw)
    value_zonal = _demand_value(
        demand_bid_coeffs, demand_pwl_bids, zonal.demand_dispatch_mw, elastic_idxs
    )
    cost_final = _generation_cost(cost_coeffs, pwl_costs, final.dispatch_mw)
    value_final = _demand_value(
        demand_bid_coeffs, demand_pwl_bids, final.demand_dispatch_mw, elastic_idxs
    )
    cost_nodal = _generation_cost(cost_coeffs, pwl_costs, p_nodal)
    value_nodal = _demand_value(demand_bid_coeffs, demand_pwl_bids, d_nodal, elastic_idxs)

    redispatch_payment = (cost_final - cost_zonal) + (value_zonal - value_final)
    welfare_gap = (value_nodal - cost_nodal) - (value_final - cost_final)
    generation_cost_gap = cost_zonal - cost_nodal

    lmp = lmp_decomposition(final.duals, final.ptdf)
    return MarketZonalResult(
        provenance=_provenance(),
        status="Optimal",
        message=None,
        zones=[
            ZonePriceResult(id=zone_id, price=float(zonal.duals.zone_price[z]))
            for z, zone_id in enumerate(zonal.zone_ids)
        ],
        generators=_dispatch_rows(arr, zonal.dispatch_mw, zonal.duals.gen_bound),
        loads=_load_rows(net, arr, zonal.demand_dispatch_mw, zonal.demand_bound, elastic_idxs),
        redispatch_generators=[
            GenRedispatchResult(
                id=gen_id,
                bus=arr.bus_ids[int(arr.gen_bus[i])],
                delta_up_mw=float(final.delta_up_mw[i]),
                delta_down_mw=float(final.delta_down_mw[i]),
            )
            for i, gen_id in enumerate(arr.gen_ids)
        ],
        redispatch_loads=_redispatch_load_rows(net, arr, final, elastic_idxs),
        generators_final=_dispatch_rows(arr, final.dispatch_mw, final.duals.gen_bound),
        loads_final=_load_rows(
            net, arr, final.demand_dispatch_mw, final.demand_bound, elastic_idxs
        ),
        branches=[
            OpfBranchFlowResult(
                id=branch_id,
                from_bus=arr.bus_ids[int(arr.f[k])],
                to_bus=arr.bus_ids[int(arr.t[k])],
                p_from_mw=float(final.branch_flow_mw[k]),
                flow_limit_dual=float(final.duals.flow_limit[k]),
            )
            for k, branch_id in enumerate(arr.branch_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)
        ],
        redispatch_payment=redispatch_payment,
        welfare_gap=welfare_gap,
        generation_cost_gap=generation_cost_gap,
    )

The strategy seam

One generator's bidding rule, and nothing else: an own-node Observation in, the GeneratorCost it offers next out. Observation carries the agent's own true cost curve and active limits, the round it is bidding into, and its own last two rounds of (offer, bus LMP, cleared MW) — it names no rival, no other bus, and no part of the clearing as a whole, so a strategy cannot reconstruct the market it is bidding into. Two rounds rather than one because a single round tells an agent whether it is marginal but not whether its last move helped. A round that has not happened is None, never a zero-valued RoundRecord, and a record from the wrong round is rejected rather than accepted as adjacent.

Strategy is a typing.Protocol, so an in-process caller may hand the seam any object with a matching offer method without inheriting from anything. What crosses JSON is never a callable: StrategyConfig is a discriminated union on kind — the same shape as GeneratorCost and LoadBid — and build_strategy is the one place a config becomes an instance.

MarkupStrategy is scoped to a linear PolynomialCost and raises NotImplementedError on any other cost shape: a piecewise or higher-degree curve has no single scalar the climb has established a meaning for. Every generator in every MATPOWER fixture this package ships carries a quadratic cost (147 of 147, measured), so a markup agent applies only to a network built for one; PriceTakerStrategy carries no such restriction and returns the true cost verbatim whatever its shape.

mambo_power.market.strategy

market.strategy: the seam between an agent's own history and the offer it makes next (wave M7 W2, design D3).

Own-node, not world-model. Observation carries exactly what one generator can see about itself — its own true cost curve and capacity, the round it is bidding into, and its own last two rounds of (offer, bus LMP, cleared MW) — and nothing about any rival, any other bus, or the network. That is deliberate (D3(b)): a strategy that could reconstruct the clearing or infer the merit order could short-circuit the game the fixed-point loop in market.agents (W3) exists to play out. Two rounds, not one, because a one-round view can tell an agent whether it is marginal but not whether its last move helped — measured 2026-08-28 (spec A4), the rules computable from one round either cycle or settle at a markup gain of $0.02/h. The first two rounds necessarily have fewer than two prior points; Observation says so explicitly through Observation.previous_round / Observation.two_rounds_ago being None, never through a fabricated zero-valued RoundRecord.

Stateless by construction. Strategy is a typing.Protocol with one method, offer(observation) -> GeneratorCost, and every strategy here is a pure function of that single argument: it reads no attribute of itself that isn't a fixed parameter set at construction (e.g. MarkupStrategy's step), and it writes nothing back. The loop supplies the history by constructing a fresh Observation each round; a strategy that cached anything between calls would make a run something other than a pure function of (network, strategies, tolerance), which is exactly the property scope answer 2 asks for.

Structural, not nominal. A typing.Protocol was chosen over an ABC (D3(a)) because the repo has no other ABC to match and the interface is one method: mypy checks conformance structurally, and an in-process caller may pass any object with a matching offer method without inheriting from anything. What crosses the jobs surface, though, is never a callable — StrategyConfig is a discriminated union on kind (mirroring GeneratorCost at model/entities.py:87), and build_strategy resolves a config to an instance. A config round-trips through JSON; a Strategy instance does not need to.

What ships, and what does not. PriceTakerStrategy offers true cost, unchanged, every round — the AC-3 reproduction depends on this being exact, not approximate. MarkupStrategy is a fixed-step two-point hill climb on the agent's own profit (A4): keep the last direction if it raised profit, reverse it if not, and never offer below true cost. "Not" means a real decrease, at the profit's own scale, not a tie masquerading as one — an agent sitting at capacity while price is set elsewhere sees consecutive rounds whose LMP differs only by solver-noise ULPs, and a strict < flips direction on that noise, turning a settled strategic climb into the true-cost outcome presented as convergence (found downstream, S4, on the AC-5 duopoly: iteration 4, offers frozen at true cost, reported converged). See MarkupStrategy's own docstring for the tolerance. It is scoped to a linear PolynomialCost (coefficients = [c1, c0]) because that is the only cost shape this wave's fixtures and A4's own measurement use; a piecewise or higher-degree cost has no established single scalar to climb on, so MarkupStrategy raises rather than inventing one. Both strategies are provably local best-responders (Not Doing): neither evaluates a candidate offer against a market clearing, so neither can be a global best response. Stateful, learning or seeded strategies are out of scope (Not Doing) — the Strategy Protocol does not forbid one, but this module ships none.

StrategyConfig module-attribute

StrategyConfig = Annotated[
    PriceTakerConfig | MarkupConfig,
    Field(discriminator="kind"),
]

What crosses JSON: a discriminated union on kind, mirroring GeneratorCost at model/entities.py:87. Never a callable -- build_strategy is the one place a config becomes a Strategy instance.

RoundRecord

Bases: BaseModel

One past round's own-node outcome: what the agent offered, and what it got for it.

offer is the whole GeneratorCost the agent bid that round (not just a scalar), because that is what the loop actually held; a strategy that needs a scalar reading of it derives one, as MarkupStrategy does. round_index is carried on the record itself (not just implied by which Observation slot it fills) so that slot can be checked against it: Observation rejects a stale record -- one genuinely from some other round -- and not only a missing one. Without this, a caller could hand round 2's outcome to an observation whose previous_round should be round 5's, and nothing would notice the pair was never adjacent.

round_index class-attribute instance-attribute

round_index: int

The round this outcome is from.

offer class-attribute instance-attribute

offer: GeneratorCost

The generator's own offer that round.

lmp class-attribute instance-attribute

lmp: float

The LMP at the generator's own bus that round, $/MWh.

cleared_mw class-attribute instance-attribute

cleared_mw: float

The generator's own cleared dispatch that round, MW.

Observation

Bases: BaseModel

An agent's own-node view of the market, as of the round it is about to bid into.

Own-node only (D3(b)): the generator's own true cost and capacity, the round index, and its own last two rounds' outcomes. Nothing here names another generator, another bus, or the clearing as a whole.

previous_round and two_rounds_ago are None exactly when that round has not happened: both are None for the very first round's observation (there is no round to report), and only previous_round is set for the second round's (there is one prior round, not two). None is a documented "this round does not exist yet" marker — never a silent zero-valued RoundRecord standing in for missing history. Two shapes of a bad history are rejected below, both by _history_is_contiguous: a missing entry (two_rounds_ago set while previous_round is not) and a stale one (either record present but its own round_index is not exactly round_index - 1 / round_index - 2) — a stale pair silently accepted as adjacent would be exactly the kind of plausible-wrong-answer this epic keeps finding.

round_index class-attribute instance-attribute

round_index: int

The round for which this offer is being decided.

true_cost class-attribute instance-attribute

true_cost: GeneratorCost

The generator's own true cost curve (Generator.cost, never the offer).

p_min_mw class-attribute instance-attribute

p_min_mw: float

The generator's own lower active limit, MW.

p_max_mw class-attribute instance-attribute

p_max_mw: float

The generator's own upper active limit, MW.

previous_round class-attribute instance-attribute

previous_round: RoundRecord | None

Round round_index - 1's own outcome; None when round_index == 0, i.e. there is no prior round at all.

two_rounds_ago class-attribute instance-attribute

two_rounds_ago: RoundRecord | None

Round round_index - 2's own outcome; None when round_index <= 1, i.e. there is at most one prior round.

Strategy

Bases: Protocol

One generator's bidding rule: its own observation in, its next offer out.

Structural (D3(a)): any object with a matching offer method satisfies this Protocol, no inheritance required. Every implementation here holds no state that changes between calls — see the module docstring.

offer

offer(observation: Observation) -> GeneratorCost

The generator's offer for observation.round_index, a pure function of observation.

Source code in src/mambo_power/market/strategy.py
def offer(self, observation: Observation) -> GeneratorCost:
    """The generator's offer for ``observation.round_index``, a pure function of
    *observation*."""
    ...

PriceTakerStrategy

Offers the generator's own true cost, unchanged, every round.

Ignores observation.round_index and both history fields entirely -- there is nothing a price-taker's own past has to tell it. AC-3 depends on this being the true cost exactly (the same coefficients, not a numerically close approximation), which is what returning observation.true_cost verbatim guarantees.

offer

offer(observation: Observation) -> GeneratorCost

observation.true_cost, verbatim.

Source code in src/mambo_power/market/strategy.py
def offer(self, observation: Observation) -> GeneratorCost:
    """*observation.true_cost*, verbatim."""
    return observation.true_cost

MarkupStrategy

MarkupStrategy(step: float)

A fixed-step two-point hill climb on the agent's own profit (A4, measured 2026-08-28).

The rule. Let offer[t-1] / offer[t-2] be the marginal-cost levels of the last two rounds' offers and profit[t-1] / profit[t-2] be (own bus LMP - own true marginal cost) * own cleared MW at those rounds:

  • direction is sign(offer[t-1] - offer[t-2]), defaulting to +1 when there is no prior movement to read (offer[t-1] == offer[t-2], or t-2 does not exist yet);
  • direction reverses if the last move made things really worse: profit[t-1] < profit[t-2] and the two are not a tie within math.isclose(..., rel_tol=1e-9, abs_tol=1e-9). The tolerance is relative, not the reference probe's absolute 1e-9 (.bionic/docs/record/m7-tmp/m7-a4-two-point-climb.py:79): an agent sitting at capacity while price is set elsewhere sees consecutive rounds whose LMP differs only by the solver's own ULP noise -- on the AC-5 duopoly (300 MW, price $40.00) that is a profit difference of order 1e-12, comfortably inside a relative 1e-9 band and comfortably outside what an absolute 1e-9 band catches once profit is in the thousands of dollars, as it is on every fixture this wave uses. A strict < (no tolerance at all) flips direction on that noise and turns a settled strategic climb into the true-cost outcome presented as convergence;

  • direction is -1 outright when the agent cleared nothing in both of the last two rounds (cleared_mw <= _IDLE_MW_ABS_TOL, 1e-9 MW, at t-1 and t-2 -- not an exact zero, because HiGHS can return 1e-14 MW for a unit it did not dispatch, and 1e-9 MW is seven orders under the smallest quantity any fixture trades in). An undispatched agent's profit is 0 == 0 round after round, which the tie rule above correctly reads as "not worse" -- and so, left to the first two rules, it would keep the default +1 and climb by one step per round until the iteration cap (walk finding, M7 S9: a $30 true cost climbed to $130 on a market clearing at $22). A higher offer cannot help an agent nobody is dispatching; the only move with any chance of a sale is back down, and the floor below stops that walk at true cost. Two rounds, not one: one idle round can be the one the probe just priced itself out in, and the real-decrease rule already reverses on that;

  • the new offer is offer[t-1] + direction * step, floored at the agent's own true marginal cost -- a markup never goes negative relative to cost.

The two base cases. Round 0 (observation.previous_round is None) has no offer[t-1] for the rule to start from, so it offers true cost, exactly as PriceTakerStrategy would -- there is nothing yet to have an opinion about. Round 1 (observation.two_rounds_ago is None) has offer[t-1] but no offer[t-2]: direction defaults to +1 and there is no profit comparison to make, so it is a pure upward probe.

Why this is a local best response, not a global one (Not Doing). The rule only ever compares the two most recent profits it has actually observed; it never evaluates a candidate offer against a market clearing. Where a competing unit creates a discontinuity between this agent's cost and its true profit peak, the climb provably stalls at the local optimum on its side of that discontinuity (A4 measured: $9,497.52 against a derivable $12,250). This module does not claim otherwise.

step also fixes the loop's convergence tolerance from the other side (A9): once a fixed-step climber arrives it oscillates by two steps about an optimum that sits on its own grid, and by three when the optimum sits halfway between two grid points -- the two straddling offers then tie in profit, the tie rule above keeps direction, and the climb overshoots one extra step before the real decrease reverses it (a period-6 orbit; found by the M7 critic at true cost 33.33, step 0.01, where the old 2 * step tolerance reported the settled run as a cycle after 3,339 rounds). A strictly concave profit cannot tie three consecutive grid points, so three steps is the widest settled orbit there is, and min_offer_tol -- market.agents' floor on offer_tol -- is 3 * step.

Source code in src/mambo_power/market/strategy.py
def __init__(self, step: float) -> None:
    # ``not (step > 0)`` rather than ``step <= 0``: NaN compares False both ways, so the
    # latter let ``step=nan`` through, and ``max(true_level, nan)`` then made the strategy a
    # silent price-taker reporting ``converged`` (critic finding 5, M7 S11). ``inf`` is
    # refused for the same reason a config's ``gt=0`` would refuse it: an infinite step is
    # no climb at all.
    if not (step > 0) or not math.isfinite(step):
        raise ValueError(f"MarkupStrategy.step must be positive and finite, got {step}")
    self.step = step

min_offer_tol property

min_offer_tol: float

The widest oscillation this strategy settles into, 3 * step (class docstring) -- the least offer_tol under which market.agents reads its arrival as convergence rather than as a cycle. The one place that constant lives.

offer

offer(observation: Observation) -> GeneratorCost

The two-point climb described above, applied to observation's own history.

Source code in src/mambo_power/market/strategy.py
def offer(self, observation: Observation) -> GeneratorCost:
    """The two-point climb described above, applied to *observation*'s own history."""
    true_level = _marginal_offer(observation.true_cost, what="observation.true_cost")
    previous = observation.previous_round
    if previous is None:
        return observation.true_cost

    offer_prev = _marginal_offer(previous.offer, what="observation.previous_round.offer")
    two_ago = observation.two_rounds_ago
    if two_ago is None:
        direction = 1.0
    else:
        offer_2ago = _marginal_offer(two_ago.offer, what="observation.two_rounds_ago.offer")
        direction = 1.0 if offer_prev >= offer_2ago else -1.0
        profit_prev = (previous.lmp - true_level) * previous.cleared_mw
        profit_2ago = (two_ago.lmp - true_level) * two_ago.cleared_mw
        really_decreased = profit_prev < profit_2ago and not math.isclose(
            profit_prev, profit_2ago, rel_tol=_PROFIT_TIE_REL_TOL, abs_tol=_PROFIT_TIE_REL_TOL
        )
        if really_decreased:
            direction = -direction
        if previous.cleared_mw <= _IDLE_MW_ABS_TOL and two_ago.cleared_mw <= _IDLE_MW_ABS_TOL:
            direction = -1.0

    new_level = max(true_level, offer_prev + direction * self.step)
    return _with_marginal_offer(observation.true_cost, new_level)

PriceTakerConfig

Bases: BaseModel

Config for PriceTakerStrategy. No parameters: it always offers true cost.

MarkupConfig

Bases: BaseModel

Config for MarkupStrategy.

step class-attribute instance-attribute

step: float

Fixed offer step, $/MWh per round. Bounds the loop's own convergence tolerance from below (A9): offer_tol must be >= 3 * step, the widest orbit a settled climb has (MarkupStrategy.min_offer_tol).

build_strategy

build_strategy(config: StrategyConfig) -> Strategy

Resolve a StrategyConfig to the Strategy instance it names.

Source code in src/mambo_power/market/strategy.py
def build_strategy(config: StrategyConfig) -> Strategy:
    """Resolve a :data:`StrategyConfig` to the :class:`Strategy` instance it names."""
    if config.kind == "price_taker":
        return PriceTakerStrategy()
    return MarkupStrategy(step=config.step)

The best-response loop

solve_agents is the fourth market mode, and the first whose input is an output of a decision. Each round hands every agent its own Observation, collects the GeneratorCost each one offers, and clears those offers through the general array-level path — gen_cost_coeffs + load_bid_coeffs + dc_opf, never a delegation to solve_nodal. The offers are an overlay: they reach the clearing as coefficients, and Generator.cost is never written to, which is the only reason the true cost and the offered cost remain two comparable things.

Updates are simultaneous, in NetworkArrays generator order, and that is contract rather than implementation detail. Termination is classified by the amplitude of the oscillation the loop settles into, not by the mere fact of one: a fixed-step climber never comes to rest, it dithers by two steps about its optimum (three when the optimum sits between two grid points), so offer_tol >= 3 * step is derived rather than tuned — and MarketAgentsOptions rejects a configuration that violates it rather than silently reporting a successful climb as a cycle. Settlement is computed once, on the final round's clearing, at the final round's prices; the intermediate rounds are the agents' search, not markets anybody was paid for.

See the agents manual page for the loop round by round, the two economic statements, and the limits of a local best response.

mambo_power.market.agents

market.agents: the fixed-point loop that lets generators bid instead of being dispatched at cost (wave M7 W3).

One round. Every agent's Strategy is handed its own Observation -- its own true cost and capacity, the round index, and its own last two rounds of (offer, bus LMP, cleared MW) -- and returns a GeneratorCost. Those offers become an overlay: mambo_power.opf.gen_cost_coeffs is called with costs=<the offer map>, so the offered curve reaches dc_opf through the same union-to-coefficients mapping a true cost does (spec A2), and Generator.cost is never written to. That is the whole of AC-2: Scenario and Network come out of a run byte-identical, while the coefficients the array builder saw differ from the true ones.

Not a delegation. The clearing here is the general array-level path -- gen_cost_coeffs + mambo_power.market.nodal.load_bid_coeffs + dc_opf -- and never a call to mambo_power.market.nodal.solve_nodal, deliberately (design, "Rejected alternatives"): an all-price-taker short-circuit would make AC-3 true by construction while bypassing the loop, the overlay and the offer map it exists to prove honest.

Updates are simultaneous (W3, A8), in NetworkArrays.gen_ids order where order is observable at all: every agent's round-r offer is computed from round r-1's clearing, before any of them is cleared. An earlier draft specified round-robin on the strength of a sweep of exact best response, which cycles under simultaneous updates in five of six duopoly configurations -- but an exact best response requires clearing the market, which the own-node observation deliberately withholds, so that sweep is not about the strategies this wave ships. Measured with the strategies that are computable, both orders reach the same point on the AC-5 duopoly. The rule is part of the contract, not an implementation detail.

Termination, and why it is classified by amplitude (W3, A9). A fixed-step climber never comes to rest: it oscillates by two steps about its optimum -- three when the optimum sits halfway between two of its grid points -- which is the expected end state and not a failure. So the loop watches for a repeated state and then measures the amplitude of the cycle it found: amplitude within offer_tol is convergence, amplitude above it is a genuine cycle, and neither of those is the iteration cap. Reporting a cycle as a cap hit -- or as convergence -- would be a confident wrong diagnosis of exactly the kind this epic has named in every wave, which is why MarketAgentsResult spends three enumerated words on it instead of a flag.

Settlement is computed once, on the final round's clearing, at the final round's prices -- never accumulated across rounds. The intermediate rounds are the agents' search, not a sequence of markets that anybody was paid for.

DEFAULT_MAX_ITERATIONS module-attribute

DEFAULT_MAX_ITERATIONS = 200

Default max_iterations. A bound, not a target: the wave's own slowest measured climb is the AC-5 duopoly at 84 update rounds with a step of $0.50/MWh, and halving the step roughly doubles the count (84 / 44 / 24 rounds at steps of 0.5 / 1.0 / 2.0, measured 2026-08-28), so 200 covers a step of $0.25/MWh as well. A run that reaches it is reported as having reached it (termination_reason == "iteration_cap"), never quietly presented as settled.

AgentSetError

Bases: ValueError

A caller mistake in the agent set -- how options.strategies (or the in-process strategies argument) relates to the network -- caught before any solve starts.

A ValueError subclass, deliberately, for the same two reasons as UnzonedBusError. It stays a ValueError because that is what solve_agents has always raised for these and what an in-process caller catches. It is a distinguishable type because jobs' runner cannot otherwise tell it apart from any other ValueError a solve might raise -- and the clearing's own NonConvexCostError / NonConcaveBidError are ValueError subclasses. Catching bare ValueError relabelled an engine rejection of a non-convex cost as VALIDATION at options.strategies, a field the caller need not have set, while market.nodal reported the same network as INTERNAL (audit finding 2, M7 S10). Only this type maps to VALIDATION; everything else keeps the verdict every other kind gives it.

Raised by _resolve_agents (two agent sources at once, a strategy on a generator the network does not have, one its arrays do not carry, one with no cost, a MarkupStrategy step too coarse for offer_tol) and by _initial_offers (a strategy that cannot bid on its generator's true cost).

MarketAgentsOptions

Bases: BaseModel

Options of a market.agents run: who bids, how long the loop may run, and what counts as settled.

Sits beside solve_agents the way MarketZonalOptions sits beside its own solver. Like that one, its fields are market-design data rather than solver tuning -- which strategy each generator plays is a choice about the game being simulated, not a knob on HiGHS.

strategies class-attribute instance-attribute

strategies: dict[str, StrategyConfig]

Generator id -> the bidding rule that generator plays. A generator not named here is not an agent: it offers its own true cost, exactly as market.nodal would clear it. An empty mapping is therefore meaningful and not a missing argument -- it is a market in which nobody bids strategically.

max_iterations class-attribute instance-attribute

max_iterations: int = 200

Most best-response update rounds to run after round 0 (which is the initial offer and responds to nothing). Reaching it ends the run with termination_reason == "iteration_cap" and converged False; it is never reported as a cycle, and a cycle is never reported as it.

offer_tol class-attribute instance-attribute

offer_tol: float = 1e-09

Largest offer-vector oscillation amplitude, in cost-coefficient units, that still counts as converged once the loop detects a repeated state. This is a derived quantity, not a tuning knob: a fixed-step climber settles into an oscillation of two steps about an on-grid optimum and three about a half-grid one, so a markup agent of step s needs offer_tol >= 3*s (MarkupStrategy.min_offer_tol) -- which the validator below enforces rather than hopes for. The default admits only an offer vector that has genuinely come to rest, which is what an all-price-taker run does.

solve_agents

solve_agents(
    scenario: Scenario,
    options: MarketAgentsOptions | None = None,
    *,
    strategies: Mapping[str, Strategy] | None = None,
) -> MarketAgentsResult

Run the best-response loop of scenario.network (module docstring) and return the final round's clearing beside how the loop ended.

The in-process seam. strategies maps a generator id to any structurally-conforming Strategy object, and is used instead of options.strategies -- giving both raises, so an agent set always has exactly one source and the result can say which rule ran. This is deliberate design, not a hole left open for a test: it is the surface for a caller whose bidding rule StrategyConfig cannot express -- a rule with parameters the union does not carry, or one belonging to the caller rather than to this library -- and it is the reason Strategy is a structural typing.Protocol (design D3(a)) rather than a closed union. Without it the Protocol would be decorative, since nothing would ever accept an object that merely conforms to it. Only the config union crosses JSON, so only options.strategies can reach this through jobs, and the wave's own jobs coverage (AC-6) is unaffected by anything passed here. provenance.options echoes options either way, which is why strategy -- carrying the config kind or, for an injected object, its class name -- and not the provenance, is the record of which rule actually produced each offer.

Never raises for an infeasible or unbounded LP: a round that fails to clear ends the run and is reported through status/message, mirroring mambo_power.market.nodal.solve_nodal's never-raise convention. Does raise AgentSetError (a ValueError) up front for a caller mistake in the agent set (see _resolve_agents), and NonConvexCostError / NonConcaveBidError for a cost or bid the clearing cannot accept -- including an offer a strategy produced, which is checked on the offer, every round, exactly as it would be on a true cost -- and TypeError at the call site, before that round's clearing, for a strategy whose offer returned something other than a GeneratorCost (see _checked_offer). A strategy that cannot bid on its generator's true cost at all (MarkupStrategy on a non-linear cost, which raises NotImplementedError from its own offer) is one of the up-front ValueError cases: _initial_offers collects round 0's offers before the first clearing and re-raises that error with the generator id, so the mistake reaches jobs as VALIDATION like the other four rather than escaping the loop as INTERNAL.

Scenario and Network are not modified -- the offers reach the clearing as coefficients (AC-2).

Source code in src/mambo_power/market/agents.py
def solve_agents(
    scenario: Scenario,
    options: MarketAgentsOptions | None = None,
    *,
    strategies: Mapping[str, Strategy] | None = None,
) -> MarketAgentsResult:
    """Run the best-response loop of ``scenario.network`` (module docstring) and return the final
    round's clearing beside how the loop ended.

    **The in-process seam.** ``strategies`` maps a generator id to any structurally-conforming
    :class:`~mambo_power.market.strategy.Strategy` object, and is used *instead of*
    ``options.strategies`` -- giving both raises, so an agent set always has exactly one source
    and the result can say which rule ran. This is deliberate design, not a hole left open for a
    test: it is the surface for a caller whose bidding rule :data:`StrategyConfig` **cannot
    express** -- a rule with parameters the union does not carry, or one belonging to the caller
    rather than to this library -- and it is the reason
    :class:`~mambo_power.market.strategy.Strategy` is a structural
    :class:`typing.Protocol` (design D3(a)) rather than a closed union. Without it the Protocol
    would be decorative, since nothing would ever accept an object that merely conforms to it.
    Only the config union crosses JSON, so only ``options.strategies`` can reach this through
    ``jobs``, and the wave's own jobs coverage (AC-6) is unaffected by anything passed here.
    ``provenance.options`` echoes ``options`` either way, which is why
    :attr:`~mambo_power.results.agents.AgentOfferResult.strategy` -- carrying the config ``kind``
    or, for an injected object, its class name -- and not the provenance, is the record of which
    rule actually produced each offer.

    Never raises for an infeasible or unbounded LP: a round that fails to clear ends the run and
    is reported through ``status``/``message``, mirroring
    :func:`mambo_power.market.nodal.solve_nodal`'s never-raise convention. Does raise
    :class:`AgentSetError` (a ``ValueError``) up front for a caller mistake in the agent set (see
    :func:`_resolve_agents`),
    and :class:`~mambo_power.opf.dc_opf.NonConvexCostError` /
    :class:`~mambo_power.opf.dc_opf.NonConcaveBidError` for a cost or bid the clearing cannot
    accept -- including an *offer* a strategy produced, which is checked on the offer, every
    round, exactly as it would be on a true cost -- and ``TypeError`` at the call site, before
    that round's clearing, for a strategy whose ``offer`` returned something other than a
    :class:`~mambo_power.model.GeneratorCost` (see :func:`_checked_offer`). A strategy that
    cannot bid on its generator's
    true cost at all (:class:`~mambo_power.market.strategy.MarkupStrategy` on a non-linear cost,
    which raises ``NotImplementedError`` from its own ``offer``) is one of the up-front
    ``ValueError`` cases: :func:`_initial_offers` collects round 0's offers before the first
    clearing and re-raises that error with the generator id, so the mistake
    reaches ``jobs`` as ``VALIDATION`` like the other four rather than escaping the loop as
    ``INTERNAL``.

    ``Scenario`` and ``Network`` are not modified -- the offers reach the clearing as coefficients
    (AC-2).
    """
    opts = options if options is not None else MarketAgentsOptions()
    started_at = datetime.now(UTC)
    clock = time.perf_counter()
    net = scenario.network
    arr = NetworkArrays.from_network(net)
    agents = _resolve_agents(net, arr, opts, strategies)
    offers = _initial_offers(agents)
    demand_bid_coeffs, demand_pwl_bids = load_bid_coeffs(net, arr)
    elastic_idxs = sorted(set(demand_bid_coeffs) | set(demand_pwl_bids))
    # The network never changes between rounds -- only the offers do -- so the PTDF (and the
    # B-bus / incidence factorisation beneath it) is built once here and handed to every round's
    # clearing. Rebuilt per round it was 70% of a 200-round case14 run (critic finding 3, M7 S11);
    # passing it changes no number (tests/unit/test_market_agents.py, the cache test).
    ptdf_matrix = compute_ptdf(arr)

    history: list[_Round] = []
    seen: dict[tuple[tuple[str, ...], tuple[str, ...]], int] = {}
    reason: TerminationReason = "iteration_cap"
    solution: OpfSolution | None = None
    breakdown: LmpBreakdown | None = None
    round_index = 0
    while True:
        cost_coeffs, pwl_costs = gen_cost_coeffs(net, arr, costs=offers)
        solution = dc_opf(
            arr,
            cost_coeffs,
            OpfDcOptions(),
            pwl_costs=pwl_costs or None,
            demand_bid_coeffs=demand_bid_coeffs or None,
            demand_pwl_bids=demand_pwl_bids or None,
            ptdf=ptdf_matrix,
        )
        if solution.status != "Optimal" or solution.duals is None:
            return MarketAgentsResult(
                provenance=_provenance(opts, started_at, time.perf_counter() - clock),
                status=solution.status,
                message=solution.message,
                iterations=round_index,
                converged=False,
                termination_reason=None,
            )
        breakdown = lmp_decomposition(solution.duals, solution.ptdf)
        history.append(
            _Round(
                offers=offers,
                cost_coeffs=cost_coeffs,
                dispatch_mw=solution.dispatch_mw,
                lmp=breakdown.lmp,
            )
        )
        # The loop's state going into round r+1 is the pair (round r-1's offers, round r's
        # offers): every strategy is a pure function of its own last two rounds, and each round's
        # LMPs and dispatch are a deterministic function of that round's offers. So a repeat of
        # this pair means every subsequent round replays the ones after its first occurrence --
        # the sequence is periodic from here, and what remains is to classify how wide the
        # oscillation is, not whether it will end.
        if round_index >= 1:
            key = (
                _offer_key(history[round_index - 1].offers, agents),
                _offer_key(history[round_index].offers, agents),
            )
            first_seen = seen.get(key)
            if first_seen is not None:
                period = round_index - first_seen
                amplitude = _amplitude(history[round_index + 1 - period :], agents)
                reason = "converged" if _settled(amplitude, opts.offer_tol) else "cycle"
                break
            seen[key] = round_index
        if round_index >= opts.max_iterations:
            reason = "iteration_cap"
            break
        round_index += 1
        offers = {
            agent.id: _checked_offer(agent, _observation(agent, round_index, history))
            for agent in agents
        }

    assert breakdown is not None  # set on every Optimal round, and the loop broke on one
    # The final round's rows and settlement -- the one construction market.nodal applies to its
    # single clearing (market/_clearing.py), applied to this loop's last one. Settlement is the
    # final round's alone: computed directly from that dispatch and those LMPs, never accumulated
    # over the search that led to it.
    rows = clearing_rows(net, arr, solution, breakdown.lmp, elastic_idxs)
    final = history[-1]
    offer_rows = [
        AgentOfferResult(
            id=agent.id,
            strategy=agent.label,
            offer=final.offers[agent.id],
            true_cost=agent.true_cost,
            cleared_mw=float(final.dispatch_mw[agent.index]),
            markup=_cost_at(final.offers[agent.id], float(final.dispatch_mw[agent.index]))
            - _cost_at(agent.true_cost, float(final.dispatch_mw[agent.index])),
        )
        for agent in agents
    ]
    return MarketAgentsResult(
        provenance=_provenance(opts, started_at, time.perf_counter() - clock),
        status=solution.status,
        message=None,
        generators=rows.generators,
        loads=rows.loads,
        buses=[
            BusLmpResult(
                id=bus_id,
                lmp=float(breakdown.lmp[i]),
                energy=float(breakdown.energy[i]),
                congestion=float(breakdown.congestion[i]),
            )
            for i, bus_id in enumerate(arr.bus_ids)
        ],
        branches=rows.branches,
        offers=offer_rows,
        iterations=round_index,
        converged=reason == "converged",
        termination_reason=reason,
        total_load_payment=rows.total_load_payment,
        total_generator_receipts=rows.total_generator_receipts,
        congestion_rent=rows.total_load_payment - rows.total_generator_receipts,
    )