Fix, relax, remove¶
Three verbs a linopy reader reaches for first, spelled as the loops of the previous page. None is a method here, which is hard rule 5.
| linopy | here | loop |
|---|---|---|
x.fix(v) |
both bounds read a parameter; write the same number into both | 1, data, no rebuild |
x.relax() |
domain: in the declaration |
3 |
remove_constraints |
drop the key from the spec | 3 |
The model is examples/dispatch.yaml, as before.
import polars as pl
from math_spec import to_spec
import lpspec as lps
MODEL = '../examples/dispatch.yaml'
GENERATORS = ['wind', 'solar', 'gas']
sources = {
'generator': pl.DataFrame({'generator': GENERATORS}),
'p_max': pl.DataFrame({'generator': GENERATORS, 'value': [80.0, 40.0, 200.0]}),
'snapshot': pl.DataFrame({'snapshot': range(6)}),
'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]}),
}
Fix¶
A fix takes two parameters. A bound parameter has to be total over the
variable's coordinates, so a sparse "only the pinned rows" frame is a load
error. p_max is not reused for the pin: it stays the size the where mask
reads, so a pin crossing zero cannot renumber the labels.
pinnable = to_spec(MODEL).to_dict()
pinnable['parameters']['p_lo'] = {'dims': ['snapshot', 'generator']}
pinnable['parameters']['p_hi'] = {'dims': ['snapshot', 'generator']}
pinnable['variables']['p']['bounds'] = {'lower': 'p_lo', 'upper': 'p_hi'}
grid = pl.DataFrame({'snapshot': range(6)}).join(pl.DataFrame({'generator': GENERATORS}), how='cross')
p_lo = grid.with_columns(value=pl.lit(0.0))
p_hi = grid.join(sources['p_max'], on='generator')
pinned = lps.build(pinnable, sources | {'p_lo': p_lo, 'p_hi': p_hi})
unpinned = pinned.solve().objective
hold = pl.when(pl.col('generator') == 'gas').then(60.0).otherwise(pl.col('value'))
held = pinned.update({'p_lo': p_lo.with_columns(value=hold), 'p_hi': p_hi.with_columns(value=hold)}).solve().objective
pinning = pinned.diagnostics()
print(f'{pinning.loads} loads over {pinning.solves} solves — a pin moves bounds, not labels')
pl.DataFrame({'gas': ['free to dispatch', 'held at 60'], 'objective': [unpinned, held]})
1 loads over 2 solves — a pin moves bounds, not labels
| gas | objective |
|---|---|
| str | f64 |
| "free to dispatch" | 6600.0 |
| "held at 60" | 21600.0 |
One load for both answers. A p == p_pin row would cost a constraint per
pinned variable and put the information in a shadow price instead of a
reduced cost. The row earns its place for a combination,
sum(p, over=generator) == target, which is not a bound.
Relax¶
Integrality is what the column is, so changing it is loop 3: patch domain:
and build again. An integer variable makes duals undefined, and asking for
one says so.
integral = to_spec(MODEL).to_dict()
integral['variables']['p']['domain'] = 'integer'
milp = lps.solve(integral, sources)
print(f'integer objective {milp.objective:,.1f}, has_primal {milp.has_primal}')
try:
milp.dual('power_balance')
except lps.LpspecError as exc:
print(exc)
relaxed = lps.solve(MODEL, sources) # the same file, continuous as declared
relaxed.dual('power_balance')
integer objective 6,600.0, has_primal True duals are undefined for a mixed-integer model: 'p' is not continuous. Drop the integrality to price the LP relaxation instead.
| snapshot | value |
|---|---|
| i64 | f64 |
| 0 | -0.0 |
| 1 | 60.0 |
| 2 | 60.0 |
| 3 | 60.0 |
| 4 | 60.0 |
| 5 | -0.0 |
Remove¶
A constraint family is a key in a mapping, so removing it is pop. Below,
the ramp limit from the previous page, added and taken away.
ramped = to_spec(MODEL).to_dict()
ramped['parameters']['ramp_max'] = {'dims': ['generator']}
ramped['constraints']['ramp_up'] = {
'foreach': ['snapshot', 'generator'],
'expression': 'p - shift(p, over=snapshot, offset=1) <= ramp_max',
}
data = sources | {'ramp_max': pl.DataFrame({'generator': GENERATORS, 'value': [100.0, 100.0, 20.0]})}
with_ramp = lps.solve(ramped, data).objective
ramped['constraints'].pop('ramp_up')
without_ramp = lps.solve(ramped, data).objective
pl.DataFrame({'model': ['with ramp_up', 'ramp_up removed'], 'objective': [with_ramp, without_ramp]})
| model | objective |
|---|---|
| str | f64 |
| "with ramp_up" | 8400.0 |
| "ramp_up removed" | 6600.0 |
The data-shaped alternative is a where on the constraint: the declaration
stays and builds no rows where the mask is false. That is loop 1 in spelling
and loop 2 in cost, since a mask that changes membership renumbers labels.
diagnostics().loads says which one you got, and omissions counts the rows
not built.
What is still missing¶
An IIS (irreducible infeasible subsystem) on an infeasible model. That is
linopy's, and Gurobi-only there too. model.row(name, **coordinate) gives one
row's terms, comparison and right-hand side without a solve.
The whole relationship is relationship to linopy and the honest snapshot.