Tutorial 4 · Where next¶
Difficulty: guided fork · ~4–5 min read
The first three tutorials followed one thread: load a network, solve a power flow, optimise a dispatch, clear a market. From here the package forks into two directions that don't depend on each other, and this tutorial is a short guided tour of both — enough to see what each one is for, not a full treatment of either. Pick whichever matches what you're actually trying to do, or read both; they're independent.
As with every tutorial here, direction 2 below loads bundled MATPOWER/RAW fixtures, which need a
clone of the repository on disk — pip install mambo-power alone doesn't give you those files;
see Getting started if you haven't already got one.
Direction 1 — strategic bidding (market.agents)¶
Every market mode in tutorials 1–3 reads the supply curve straight off the network: a
generator's cost is what gets minimised against, and it's dispatched at cost whether or not it
would have chosen to offer at cost. market.agents.solve_agents is different: each generator
has a strategy that decides what to offer, the offer may differ from the generator's true
cost, and the market clears the offers — not the truth. This is the tool for asking "what
happens when participants have market power and use it?" rather than "what's the
welfare-optimal outcome?"
The distinction the whole mode rests on: Generator.cost (the true cost) is never written to;
the offer is a separate object, and "markup" is the difference between them. See
Strategic bidding for the full mechanics (the observation each agent sees,
the two shipped strategies, and how the loop decides whether it's converged, cycling, or just out
of rounds).
Below: one 900 MW generator with a true cost of $20/MWh, no rival, facing a demand curve that's
willing to pay less as it buys more. A price-taking generator would just offer $20/MWh and clear
whatever the fixed dispatch gives it. A MarkupStrategy agent instead hill-climbs on its own
observed profit, round after round, marking its offer up as long as doing so keeps helping.
MarkupStrategy needs a linear cost curve to have a clean sense of "markup" at all — none of the
bundled MATPOWER fixtures ship one (they're all quadratic), so this example builds a small
network by hand rather than loading a case.
from mambo_power.market.agents import MarketAgentsOptions, solve_agents
from mambo_power.model import (
Branch,
Bus,
Generator,
Load,
Network,
PolynomialBid,
PolynomialCost,
Scenario,
)
DEMAND_BID = PolynomialBid(coefficients=[-0.05, 100.0, 0.0]) # marginal value 100 - 0.1*p
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="l2", from_bus="b1", to_bus="b2", r=0.0, x=0.05, b=0.0)],
generators=[
Generator(
id="strategic",
bus="b1",
p_mw=0.0,
q_mvar=0.0,
p_min_mw=0.0,
p_max_mw=900.0,
q_min_mvar=-9999.0,
q_max_mvar=9999.0,
v_set_pu=1.0,
cost=PolynomialCost(coefficients=[20.0, 0.0]), # true cost: $20/MWh
)
],
loads=[Load(id="d1", bus="b2", p_mw=1000.0, q_mvar=0.0, bid=DEMAND_BID)],
)
markup_options = MarketAgentsOptions(
strategies={"strategic": {"kind": "markup", "step": 0.5}}, offer_tol=1.5
)
climbed = solve_agents(Scenario(network=net), markup_options)
peak = climbed.offers[0]
print(
f"true cost $20.00/MWh -> climbed offer ${peak.offer.coefficients[0]:.2f}/MWh, "
f"cleared {peak.cleared_mw:.2f} MW, markup ${peak.markup:,.2f}/h"
)
print(
f"status {climbed.status} converged {climbed.converged} "
f"termination_reason {climbed.termination_reason} iterations {climbed.iterations}"
)
print("Generator.cost on the network, still the true curve:", net.generators[0].cost.coefficients)
true cost $20.00/MWh -> climbed offer $60.00/MWh, cleared 400.00 MW, markup $15,999.97/h status Optimal converged True termination_reason converged iterations 84 Generator.cost on the network, still the true curve: [20.0, 0.0]
The generator's offer climbed all the way to $60/MWh before stopping — not because
anything forced it to stop there, but because that's the point where demand's own bid refuses to
pay more (the manual page derives this exact number in closed form: with this demand curve, a
900 MW monopolist's profit peaks at exactly $60/MWh). And the network itself never changed:
Generator.cost still reads the true $20/MWh curve — the offer is a distinct object the market
saw, not a mutation of the truth.
Direction 2 — interchange formats (io.*)¶
The other fork has nothing to do with markets: it's about getting real grids in and out of this
package. mambo_power.io holds importers and exporters for six formats — native JSON, MATPOWER
.m (which every tutorial so far has used), pandapower JSON, PyPSA, PSS/E RAW, and a CSV bundle.
Every one of them speaks only the Network model on one side, so once a network is imported, it
runs through every solver and market mode in this package exactly the way case14 has all
tutorial long.
The shared discipline across all six: every importer returns an ImportReport and every
exporter an ExportReport, and an empty report means the conversion was lossless — anything
dropped, approximated, or repaired is named explicitly, down to the element id and field. See
File formats for the full column maps and every warning code.
Below: importing the same IEEE 14-bus system, but spelled as a PSS/E RAW file instead of a
MATPOWER .m file — the format substations and utilities actually exchange in practice. RAW
carries no generator cost data at all, so the report says exactly that rather than inventing
costs.
import numpy as np
from mambo_power import pf
from mambo_power.io import matpower, psse_raw
m_net = matpower.load("../../fixtures/matpower/case14.m")
raw_net, raw_report = psse_raw.load_with_report("../../fixtures/case14_v33.raw")
print(f"RAW import: {len(raw_net.buses)} buses, {len(raw_net.branches)} branches")
print(f"report codes {sorted(raw_report.codes)} ({len(raw_report.warnings)} issues)")
print(" ", next(str(w) for w in raw_report.warnings if w.code == "RAW_NO_COSTS"))
m_dc = pf.solve_dc(m_net)
raw_dc = pf.solve_dc(raw_net)
m_angles = np.array([b.va_deg for b in m_dc.buses])
raw_angles = np.array([b.va_deg for b in raw_dc.buses])
print(
f"pf.solve_dc on the RAW-imported network vs the MATPOWER one: "
f"worst angle difference {float(np.abs(raw_angles - m_angles).max()):.1e} deg"
)
RAW import: 14 buses, 20 branches report codes ['BASE_KV_REPLACED', 'RAW_NO_COSTS', 'RAW_SECTION_IGNORED'] (16 issues) RAW_NO_COSTS: RAW carries no cost data; all 5 generators imported with cost=None pf.solve_dc on the RAW-imported network vs the MATPOWER one: worst angle difference 0.0e+00 deg
Same electrical network, two completely different file formats, and pf.solve_dc agrees
on them to machine precision — which is the whole point of routing every format through one
shared model instead of writing format-specific solver code.
Where to read more¶
Both directions above have much more depth than fits in a short tour:
- Strategic bidding — the full observation/offer/clear loop, why it looks
at two rounds of history rather than one, termination semantics (
converged/cycle/iteration_cap), and the duopoly case where best response doesn't settle in one round. - File formats — every format's column map, every warning and repair
code, and the
io.limitationsreference that names exactly what each converter cannot carry.
And the rest of the manual, if you want the full reference rather than a narrative:
- Network model — every entity, field, unit, and validation rule.
- Power flow, DC-OPF, N-1 screening, Nodal market — the formulations behind tutorials 1–3, in full.
- Multiperiod market and Zonal market — two more market modes this tutorial series doesn't cover: a rolling multi-period horizon with storage and ramp coupling, and a zonal clearing with corridor capacities.
- Jobs API — the stateless
run(SolveRequest)surface every solver and market mode in this package is also reachable through, for a service rather than a script. - Results and Numerics — what every result object carries, and the Ybus/Bbus/PTDF/LODF machinery underneath every solver.
- Examples — thirteen short, terse, CI-run scripts, one concept each — a faster reference once the narrative in these tutorials isn't needed anymore.