reserves¶
Energy and reserve co-optimization on a two-bus grid: offers are (generator, market, tranche) triples, reserve zones overlap, and one line dangles. The model exists to prove a claim — every many-to-many shape the language covers, in one instance, each one load-bearing.
✔ Agrees with hand-written linopy 0.9.0 — objective 915, matched to
rtol=1e-09.
The problem¶
A relation either is an axis — the pair set reified as a dimension whose legs are lookups — or is data weighting one aggregation. Both appear here. The offer set is the first kind, three-legged:
a per-offer cap assembled by pulling two other dimensions' parameters back through two legs of one edge set. The zones are the second kind: membership with a weight is a sparse parameter, and the zone requirement is the contraction
— multiply by the incidence table, sum the dimension away. A generator may back several zones at different weights, which is exactly what no lookup can say and no lookup needs to.
The model¶
The same model, as math
Energy and reserve co-optimization on a two-bus grid: an offer is a generator, market and tranche together, reserve zones overlap, and one line dangles. The model exists to prove a claim — every many-to-many shape the language covers, in one instance, each one load-bearing.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{B}\) | index \(b\) --- bus --- network nodes |
| \(\mathcal{G}\) | index \(g\) --- generator with \(\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B}\) --- generating units, each sitting on one bus |
| \(\mathcal{M}\) | index \(m\) --- market --- reserve markets, each with a requirement to fill |
| \(\mathcal{T}\) | index \(t\) --- tranche --- how fast a reserve has to be deliverable |
| \(\mathcal{Z}\) | index \(z\) --- zone --- reserve zones, which overlap |
| \(\mathcal{L}\) | index \(l\) --- line with \(\mathrm{line\_from}: \mathcal{L} \to \mathcal{B},\enspace \mathrm{line\_to}: \mathcal{L} \to \mathcal{B}\) --- transmission lines, which may have an open end |
| \(\mathcal{O}\) | index \(o\) --- offer with \(\mathrm{gen\_of}: \mathcal{O} \to \mathcal{G},\enspace \mathrm{market\_of}: \mathcal{O} \to \mathcal{M},\enspace \mathrm{tranche\_of}: \mathcal{O} \to \mathcal{T}\) --- one generator's bid into one market at one tranche |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(p^{\mathrm{max}}\) | p_max over \(\mathcal{G}\) --- installed capacity |
| \(\mathit{energy\_cost}\) | energy_cost over \(\mathcal{G}\) --- cost of one unit of output |
| \(\mathit{load}\) | load over \(\mathcal{B}\) --- demand at each bus |
| \(\mathit{cap}\) | cap over \(\mathcal{L}\) --- forward transmission limit |
| \(\mathit{neg\_cap}\) | neg_cap over \(\mathcal{L}\) --- reverse transmission limit |
| \(\mathit{bus}^{\mathrm{cap}}\) | bus_cap over \(\mathcal{B}\) --- most a bus may export over any one line |
| \(\mathit{offer}^{\mathrm{cost}}\) | offer_cost over \(\mathcal{O}\) --- cost of holding one unit of reserve on an offer |
| \(\mathit{req}\) | req over \(\mathcal{M}\) --- reserve a market has to be filled with |
| \(\mathit{tranche}^{\mathrm{frac}}\) | tranche_frac over \(\mathcal{T}\) --- share of capacity a generator may offer at a tranche |
| \(\mathit{zone}^{\mathrm{share}}\) | zone_share over \(\mathcal{G} \times \mathcal{Z}\) --- how much of a generator's reserve counts towards a zone — a generator may back several zones at a per-zone weight, so this cannot be a lookup over the generator, which is single-valued per label; rows are absent where a generator backs no part of a zone |
| \(\mathit{zone}^{\mathrm{req}}\) | zone_req over \(\mathcal{Z}\) --- reserve a zone has to be covered by |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{G}\) --- output of a generator |
| \(f\) | f over \(\mathcal{L}\) --- flow on a line, signed towards its line_to bus |
| \(r\) | r over \(\mathcal{O}\) --- reserve held against an offer |
Objective¶
Subject to¶
balance
export_cap
requirement
headroom
offer_cap
zone_cover
Variable domains¶
p
f
r
The tabs start from the instance's tables — one frame per parameter.
description: >-
Energy and reserve co-optimization on a two-bus grid: an offer is a
generator, market and tranche together, reserve zones overlap, and one line
dangles. The model exists to prove a claim — every many-to-many shape the
language covers, in one instance, each one load-bearing.
dimensions:
bus:
description: network nodes
dtype: str
values: [b1, b2]
generator:
description: generating units, each sitting on one bus
dtype: str
market:
description: reserve markets, each with a requirement to fill
dtype: str
values: [m1, m2]
tranche:
description: how fast a reserve has to be deliverable
dtype: str
values: [fast, slow]
zone:
description: reserve zones, which overlap
dtype: str
values: [z1, z2]
line:
description: transmission lines, which may have an open end
dtype: str
offer:
description: one generator's bid into one market at one tranche
dtype: str
lookups:
gen_bus: {over: generator, into: bus, description: "the bus a generator sits on"}
line_from: {over: line, into: bus, description: "the bus a line leaves, null where the end is open"}
line_to: {over: line, into: bus, description: "the bus a line arrives at, null where the end is open"}
gen_of: {over: offer, into: generator, description: "the generator behind an offer"}
market_of: {over: offer, into: market, description: "the market an offer is made into"}
tranche_of: {over: offer, into: tranche, description: "the tranche an offer is made at"}
parameters:
p_max:
description: installed capacity
dims: [generator]
energy_cost:
description: cost of one unit of output
dims: [generator]
load:
description: demand at each bus
dims: [bus]
cap:
description: forward transmission limit
dims: [line]
neg_cap:
description: reverse transmission limit
dims: [line]
bus_cap:
description: most a bus may export over any one line
dims: [bus]
offer_cost:
description: cost of holding one unit of reserve on an offer
dims: [offer]
req:
description: reserve a market has to be filled with
dims: [market]
tranche_frac:
description: share of capacity a generator may offer at a tranche
dims: [tranche]
zone_share:
description: >-
how much of a generator's reserve counts towards a zone — a generator may
back several zones at a per-zone weight, so this cannot be a lookup over
the generator, which is single-valued per label; rows are absent where a
generator backs no part of a zone
dims: [generator, zone]
zone_req:
description: reserve a zone has to be covered by
dims: [zone]
variables:
p:
description: output of a generator
foreach: [generator]
bounds:
lower: 0
f:
description: flow on a line, signed towards its `line_to` bus
foreach: [line]
bounds:
lower: neg_cap
upper: cap
r:
description: reserve held against an offer
foreach: [offer]
bounds:
lower: 0
expressions:
reserve_of:
expression: sum(r, by=gen_of)
description: all the reserve a generator holds, across every offer it made
constraints:
balance:
description: what is generated at a bus plus what arrives over the lines meets the load there
foreach: [bus]
expression: >-
sum(p, by=gen_bus)
+ sum(f, by=line_to)
- sum(f, by=line_from)
== load
export_cap:
description: a line carries no more than the bus it leaves is allowed to export
foreach: [line]
expression: f <= at(bus_cap, by=line_from)
requirement:
description: the offers made into a market fill its requirement
foreach: [market]
expression: sum(r, by=market_of) >= req
headroom:
description: a generator's output plus the reserve it holds stays inside its capacity
foreach: [generator]
expression: p + reserve_of <= p_max
offer_cap:
description: >-
an offer is capped by its tranche's share of its generator's capacity —
two other dimensions' parameters pulled back through two legs of one edge
set
foreach: [offer]
expression: r <= at(tranche_frac, by=tranche_of) * at(p_max, by=gen_of)
zone_cover:
description: the weighted reserve of the generators backing a zone covers its requirement
foreach: [zone]
expression: sum(zone_share * reserve_of, over=generator) >= zone_req
objective:
sense: minimize
description: what the energy costs to generate, plus what the reserve costs to hold
expression: p * energy_cost + r * offer_cost
The model-building half of examples/ports/references/linopy/reserves.py,
which states every mapping as a dense incidence matrix (its indicator
helper) and multiplies through by hand — the identical algebra with no
lpspec construct anywhere near it:
def build(tables: dict[str, pd.DataFrame]) -> linopy.Model:
"""The instance's tables as a linopy model, row for row.
``tables`` is the same mapping the lpspec call binds as ``sources``.
"""
series = {
k: tables[k].set_index(tables[k].columns[0])['value']
for k in (
'p_max',
'energy_cost',
'load',
'cap',
'neg_cap',
'bus_cap',
'offer_cost',
'req',
'tranche_frac',
'zone_req',
)
}
buses = pd.Index(series['load'].index, name='bus')
offers = tables['offer'].set_index('offer')
zones = pd.Index(series['zone_req'].index, name='zone')
gen_at = indicator(buses, tables['generator'], 'generator', 'gen_bus')
line_in = indicator(buses, tables['line'], 'line', 'line_to')
line_out = indicator(buses, tables['line'], 'line', 'line_from')
offer_gen = indicator(pd.Index(series['p_max'].index, name='generator'), tables['offer'], 'offer', 'gen_of')
offer_market = indicator(pd.Index(series['req'].index, name='market'), tables['offer'], 'offer', 'market_of')
zone_at = pd.DataFrame(0.0, index=zones, columns=series['p_max'].index)
for gen, zone, share in zip(
tables['zone_share']['generator'], tables['zone_share']['zone'], tables['zone_share']['value'], strict=True
):
zone_at.loc[zone, gen] = share
zone_at.columns.name = 'generator'
r_cap = offers['tranche_of'].map(series['tranche_frac']) * offers['gen_of'].map(series['p_max'])
f_cap = tables['line'].set_index('line')['line_from'].map(series['bus_cap'])
m = linopy.Model()
p = m.add_variables(lower=0, coords=[series['p_max'].index], name='p')
f = m.add_variables(lower=series['neg_cap'], upper=series['cap'], coords=[series['cap'].index], name='f')
r = m.add_variables(lower=0, upper=r_cap, coords=[offers.index], name='r')
m.add_constraints(
(p * gen_at).sum('generator') + (f * line_in).sum('line') - (f * line_out).sum('line') == series['load'],
name='balance',
)
m.add_constraints(f <= f_cap, name='export_cap')
m.add_constraints((r * offer_market).sum('offer') >= series['req'], name='requirement')
reserve_of = (r * offer_gen).sum('offer')
m.add_constraints(p + reserve_of <= series['p_max'], name='headroom')
m.add_constraints((reserve_of * xr.DataArray(zone_at)).sum('generator') >= series['zone_req'], name='zone_cover')
m.add_objective((p * series['energy_cost']).sum() + (r * series['offer_cost']).sum())
return m
What it proves¶
Present is not proven, so each shape carries the one data mutation that must
move the optimum — held by tests/test_reserves.py, alongside the three-way
agreement (both lanes, the written LP file, and the incidence-matrix reference
above; the balance duals are checked too).
| Shape | Where | Idiom | Mutation that moves the optimum |
|---|---|---|---|
| self-relation, used in both directions | lines bus→bus, balance sums through line_from and line_to |
edge dimension + leg lookups | — (the balance is every other row's feasibility) |
| parallel edges | l1, l2 both b2→b1 |
member identity is the label, not the endpoint pair | drop l2 → dearer |
| dangling member | l4's line_to is null |
a partial lookup: the open end aggregates nowhere | point l4 at b1 → cheaper |
| pullback through a leg | f ≤ at(bus_cap, by=line_from) |
at() |
uncap the exporting bus → cheaper |
| k-ary edge set | offers carry gen_of, market_of, tranche_of |
three legs, one edge dimension | — (structure, pinned by test) |
| duplicate pair | o1, o2 share all three legs |
multiplicity is real capacity | drop o2 → dearer |
| two pullbacks through two legs | the offer cap above | at() * at() |
o4 sits exactly at its cap |
| weighted n-to-n membership | zone_share, g2 in both zones at 0.5 / 1.0 |
incidence parameter, contracted | zero g2's z2 share → dearer |
Zone z1 stays slack by design: it holds the overlap (g2 at weight 0.5)
while z2 holds the bindingness, so the fractional weight is shown without
double-loading one constraint.
The optimum, by hand¶
Energy: b2's cheap surplus exports over l1 (pinned at 15 by bus_cap, not
its own 20) and l2 (its own 8), so g3 runs 40 local + 23 export = 63 and
g1 covers the rest of b1, 47 — cost 785. Reserves: m1's 55 takes both
parallel g1 offers at their 25 caps (o2 first at cost 1, then o1 at 2)
plus 5 of o3, whose seat on g2 is also what closes zone z2 at exactly
25; m2's 20 is o4 at its tranche cap — cost 130. Total 915, nodal
prices 10 at b1 and 5 at b2.