Tutorial 3 · A nodal market¶
Difficulty: intermediate · ~6–8 min read
Tutorial 2 solved for the cheapest dispatch — one planner, minimising total cost, with demand fixed. This tutorial clears an actual market: generators submit offers (which may or may not equal their true cost), demand can be elastic — willing to buy more at a lower price and less at a higher one — and the outcome isn't just "the cheapest dispatch," it's a price everyone pays or gets paid, with real settlement.
Under the hood, mambo_power.market is built directly on the DC-OPF machinery from tutorial 2:
the same LP/QP, extended with elastic-demand columns. See Nodal market for
the full formulation.
Building a Scenario¶
The market clearings take a Scenario, not a bare Network — Scenario(network=net) is
self-contained, and later tutorials (and the manual's multiperiod market)
use the same wrapper for a multi-period horizon. This tutorial stays single-period.
Bid data lives on the entities themselves: a generator's offer is its existing Generator.cost
(unchanged since tutorial 1 and 2), and a load's bid is the new Load.bid field — a
PolynomialBid or PiecewiseBid that mirrors a generator's cost curve, with one difference:
direction. A generator's cost curve says what it costs to produce more; a load's bid curve
says what it's worth to consume more. A load with no bid stays fixed demand, exactly as in
every earlier tutorial.
We'll build a small two-bus network by hand, the same fixture used by
examples/09_nodal_market.py: a cheap generator and an expensive one,
one fixed load and one load that bids a two-segment piecewise-linear demand curve, with the
branch between them rated tightly enough to actually bind.
from mambo_power import market
from mambo_power.model import (
Branch,
Bus,
Generator,
Load,
Network,
PiecewiseBid,
PolynomialCost,
Scenario,
)
net = Network(
base_mva=100.0,
buses=[
Bus(id="b1", base_kv=138.0, type="slack"),
Bus(id="b2", base_kv=138.0, type="pq"),
],
branches=[
Branch(id="br12", from_bus="b1", to_bus="b2", r=0.0, x=0.1, b=0.0, rating_mva=20.0),
],
generators=[
Generator(
id="g1",
bus="b1",
p_mw=0,
q_mvar=0,
p_min_mw=0,
p_max_mw=100,
q_min_mvar=0,
q_max_mvar=0,
v_set_pu=1.0,
cost=PolynomialCost(coefficients=[10.0, 0.0]), # $10/MWh, linear
),
Generator(
id="g2",
bus="b2",
p_mw=0,
q_mvar=0,
p_min_mw=0,
p_max_mw=100,
q_min_mvar=0,
q_max_mvar=0,
v_set_pu=1.0,
cost=PolynomialCost(coefficients=[50.0, 0.0]), # $50/MWh, linear
),
],
loads=[
Load(id="d0", bus="b1", p_mw=10.0, q_mvar=0.0), # fixed -- no bid
Load(
id="d1",
bus="b2",
p_mw=100.0,
q_mvar=0.0,
# marginal value $45/MWh on [0, 50], $20/MWh on [50, 100]
bid=PiecewiseBid(points=[(0.0, 0.0), (50.0, 2250.0), (100.0, 3250.0)]),
),
],
)
Clearing the market¶
market.solve_nodal pulls the generator costs and load bids straight off the network and clears
the welfare-maximising LP: minimise generation cost, minus maximise demand value, subject to the
network. It never raises on an infeasible or unbounded solve — like every solver in this
package, a failure comes back as a status you check, not an exception you catch.
result = market.solve_nodal(Scenario(network=net))
print("status:", result.status)
print("dispatch:")
for g in result.generators:
print(f" gen {g.id:4s} bus {g.bus:3s} {g.p_mw:7.3f} MW")
for d in result.loads:
tag = " (fixed, no bid)" if d.id == "d0" else " (elastic, bid)"
print(f" load {d.id:4s} bus {d.bus:3s} {d.p_mw:7.3f} MW{tag}")
status: Optimal dispatch: gen g1 bus b1 30.000 MW gen g2 bus b2 0.000 MW load d0 bus b1 10.000 MW (fixed, no bid) load d1 bus b2 20.000 MW (elastic, bid)
g1 is cheap and the branch is rated at only 20 MVA, so g1 alone can't serve everyone —
the network genuinely constrains how much of the cheap power can reach b2. The elastic load
d1 doesn't buy its full 100 MW, but not because its own bid stops being worth it: d1's bid
values every MW up to 50 at a flat $45/MWh, well above `g1`'s $10/MWh cost, so d1 would happily
keep buying past 20 MW on price alone. The branch's 20 MVA cap is the sole thing stopping it —
that's the whole binding constraint here, and it's exactly what a nonzero congestion component in
the next cell's LMPs will confirm.
print("LMPs:")
for b in result.buses:
print(f" {b.id}: lmp {b.lmp:7.3f} energy {b.energy:7.3f} congestion {b.congestion:7.3f}")
LMPs: b1: lmp 10.000 energy 10.000 congestion 0.000 b2: lmp 45.000 energy 10.000 congestion 35.000
b1's LMP is pure energy — nothing is standing between the slack bus and its own generator.
b2's LMP is higher: energy plus a nonzero congestion component, because the rated branch is
binding and getting one more MW to b2 means paying for capacity on that branch, not just for
generation.
Settlement: who pays whom¶
Clearing a price is only half of what a market does — the other half is settlement: actually
moving money. Every load pays its bus's LMP for what it consumes; every generator is paid its
bus's LMP for what it produces. When LMPs differ across buses (because of congestion), what
loads pay in total and what generators are paid in total don't match — the difference is called
congestion rent, and it's exactly the value that whoever owns the constrained branch could
in principle capture (in most real markets, this rent funds transmission investment or is
rebated to ratepayers, depending on market design — mambo_power computes the number, it doesn't
prescribe what happens to it).
The three settlement figures satisfy an identity that's worth checking directly rather than
trusting by construction: total_load_payment - total_generator_receipts == congestion_rent.
print(
f"total load payment: ${result.total_load_payment:,.2f}\n"
f"total generator receipts: ${result.total_generator_receipts:,.2f}\n"
f"congestion rent: ${result.congestion_rent:,.2f}"
)
identity_holds = (
abs((result.total_load_payment - result.total_generator_receipts) - result.congestion_rent)
< 1e-6
)
print("settlement identity (payment - receipts == congestion rent) holds:", identity_holds)
total load payment: $1,000.00 total generator receipts: $300.00 congestion rent: $700.00 settlement identity (payment - receipts == congestion rent) holds: True
Loads paid more in total than generators were paid, and the gap is exactly the congestion rent generated by the binding branch — money that exists because the network itself, not any participant, is what's scarce here.
Next¶
Tutorial 4 — where next is shorter: a guided fork pointing toward the two
places this story branches from here — generators that choose what to offer instead of bidding
their true cost (market.agents), and getting real-world grid data in and out of this package at
all (io.*).