Change a model¶
The dispatch model from Run a model, changed three ways, cheapest
first. Every block is a function of a spec (the YAML, as a dict) and its
sources, so blocks re-run in any order mean the same thing.
- New numbers:
update, and the solver keeps the model it has loaded. - More rows: the same math over a longer axis.
- New math: patch the
dictand re-run.
Every block on this page runs when the site is built, and what you see under it is what it printed on this commit. A block that raises fails the build.
import polars as pl
from mathspec import to_markdown, to_spec
import specsolve as sps
SPEC = 'examples/dispatch.yaml'
GENERATORS = ['wind', 'solar', 'gas']
sources = {
'snapshot': pl.DataFrame({'snapshot': range(6)}),
'generator': pl.DataFrame({'generator': GENERATORS}),
'p_max': pl.DataFrame({'generator': GENERATORS, 'value': [80.0, 40.0, 200.0]}),
'cost': pl.DataFrame({'generator': GENERATORS, 'value': [0.0, 0.0, 60.0]}),
'load': pl.DataFrame({'snapshot': range(6), 'value': [90.0, 120.0, 150.0, 180.0, 140.0, 100.0]}),
}
print(to_markdown(SPEC))
Least-cost dispatch of a generator fleet against an hourly load.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{T}\) | index \(t\) — snapshot — dispatch periods |
| \(\mathcal{G}\) | index \(g\) — generator — generating units |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathrm{p}^{\mathrm{max}}\) | p_max over \(\mathcal{G}\) — installed capacity |
| \(\mathrm{load}\) | load over \(\mathcal{T}\) — demand to be met |
| \(\mathrm{cost}\) | cost over \(\mathcal{G}\) — marginal cost |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{T} \times \mathcal{G}\) — output of a generator in a snapshot |
Upright is what the data supplies — a parameter such as \(\mathrm{p}^{\mathrm{max}}\), a coordinate map, a label — and italic is what the solver chooses, such as \(p\). An index is italic too, being what a quantifier chooses, and a set is script.
Objective¶
Subject to¶
power_balance
Variable domains¶
p
1. New numbers¶
Build once, then update per run. New costs go onto the model HiGHS holds,
and the matrix is never handed over twice.
model = sps.build(SPEC, sources)
rows = []
for gas_cost in (40.0, 60.0, 90.0):
costs = pl.DataFrame({'generator': GENERATORS, 'value': [0.0, 0.0, gas_cost]})
rows.append({'gas_cost': gas_cost, 'objective': model.update({'cost': costs}).solve().objective})
sweep = pl.DataFrame(rows)
reused = model.diagnostics()
print(f'{reused.loads} model loaded, {reused.solves} solves')
print(sweep)
loads is 1 against solves of 3: three answers, one load.
model.update(x).solve() gives what sps.solve(SPEC, sources | x) gives,
always. The next block solves the last cost from scratch to show it.
fresh = sps.solve(SPEC, sources | {'cost': costs}).objective
updated = sweep.filter(pl.col('gas_cost') == 90.0).item(0, 'objective')
print(f'updated {updated:,.1f} — fresh build {fresh:,.1f}')
2. More rows¶
A longer horizon is a longer table plus the index to match.
horizon = pl.DataFrame(
{
'snapshot': range(12),
'value': [90.0, 120.0, 150.0, 180.0, 140.0, 100.0, 95.0, 130.0, 160.0, 190.0, 150.0, 110.0],
}
)
index = pl.DataFrame({'snapshot': range(12)})
schedule = model.update({'snapshot': index, 'load': horizon}).solve().primal('p')
grown = model.diagnostics()
print(f'{schedule.height} rows of p now, and {grown.loads} loads over {grown.solves} solves')
print(schedule.head())
36 rows of p now, and 2 loads over 4 solves
shape: (5, 3)
┌──────────┬───────────┬───────┐
│ snapshot ┆ generator ┆ value │
│ --- ┆ --- ┆ --- │
│ i64 ┆ str ┆ f64 │
╞══════════╪═══════════╪═══════╡
│ 0 ┆ wind ┆ 50.0 │
│ 0 ┆ solar ┆ 40.0 │
│ 0 ┆ gas ┆ 0.0 │
│ 1 ┆ wind ┆ 80.0 │
│ 1 ┆ solar ┆ 40.0 │
└──────────┴───────────┴───────┘
loads is 2 now: new coordinates renumber the columns, so this model was
loaded from scratch. The answer is the same either way.
schedule.pivot(on='generator', index='snapshot', values='value') is the
wide view.
3. New math¶
to_dict() is the spec as data, and every verb takes a dict. An edit is a
key, and to_spec validates it again. Below, a ramp limit on gas, which
needs a parameter as well as a constraint.
spec = to_spec(SPEC).to_dict()
spec['parameters']['ramp_max'] = {'dims': ['generator']}
spec['constraints']['ramp_up'] = {
'dims': ['snapshot', 'generator'],
'expression': 'p - shift(p, along=snapshot, offset=1) <= ramp_max',
}
ramp_max = pl.DataFrame({'generator': GENERATORS, 'value': [100.0, 100.0, 20.0]})
base = sps.solve(SPEC, sources).objective
ramped = sps.solve(spec, sources | {'ramp_max': ramp_max}).objective
print(pl.DataFrame({'model': ['dispatch', 'dispatch + ramp limit'], 'objective': [base, ramped]}))
The limit binds: gas starts climbing early, and free wind is curtailed to make room. The math re-renders from the patched spec:
Least-cost dispatch of a generator fleet against an hourly load.
Objective¶
Subject to¶
power_balance
ramp_up
Variable domains¶
p
An edit the language refuses is refused before any data is attached:
typo = {
**spec,
'constraints': {
**spec['constraints'],
'peak': {'dims': ['snapshot'], 'expression': 'sum(p, over=generators) <= load'},
},
}
try:
sps.check(typo)
except sps.errors.LanguageError as exc:
print(exc)
What leaves the session¶
The spec, as a file you can diff and commit:
version: 0
description: Least-cost dispatch of a generator fleet against an hourly load.
dimensions:
snapshot:
dtype: int
description: dispatch periods
generator:
dtype: str
description: generating units
parameters:
p_max:
dims:
- generator
dtype: float
description: installed capacity
load:
dims:
- snapshot
dtype: float
description: demand to be met
cost:
dims:
- generator
dtype: float
description: marginal cost
ramp_max:
dims:
- generator
dtype: float
variables:
p:
dims:
- snapshot
- generator
where: p_max > 0
bounds:
lower: 0.0
upper: p_max
domain: continuous
absence: undefined
description: output of a generator in a snapshot
constraints:
power_balance:
dims:
- snapshot
expression: sum(p, over=generator) == load
ramp_up:
dims:
- snapshot
- generator
expression: p - shift(p, along=snapshot, offset=1) <= ramp_max
objective:
sense: minimize
expression: sum(p * cost)
description: total cost of generation over the horizon
Where next¶
| Sweep a model | the next tutorial: one model once per scenario, then window by window |
| Warm-starting a re-solve | keep the solver's work between solves, not only the model |
| Fixing, relaxing and removing | linopy's fix, relax and remove_constraints, as these three loops |
| Debugging a wrong answer | read the row a build produced, when the file looks right and the answer is not |