Fix, relax, remove¶
The verbs a reader arriving from linopy reaches for first — x.fix(v),
x.relax(), remove_constraints — and how each is spelled here. None of them
is a method, which is
hard rule 5:
a verb that changes what the math says would make the model something you have
to run in order to read. What replaces them is one of the three loops from
the previous page:
| 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
import lpspec as lps
MODEL = '../examples/dispatch.yaml'
GENERATORS = ['wind', 'solar', 'gas']
sources = {
'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 — both bounds, one number, no rebuild¶
A fix wants two parameters, not one: a bound parameter has to be total over
the variable's coordinates — a sparse "only the pinned rows" frame is a load
error, not an implied unpinned — and "free" means a lower of 0 against an upper
of p_max, which is two different numbers.
Note what is not reused: p_max stays the size the where mask reads.
Pinning through it would work until a pin crossed zero, at which point the labels
renumber and the solver reloads.
pinnable = lps.load_model(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.rebind({'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: the pinned solve went to the model HiGHS already
held. That is the argument for spelling a fix as bounds rather than as a
p == p_pin row — the row version costs a constraint per pinned variable and
puts the information in a shadow price instead of a reduced cost.
The row earns its place in one case: when what you are fixing is a
combination, sum(p, over=generator) == target, which is not a bound at all.
Relax — a domain is a declaration¶
Integrality is not a property of the solve, it is what the column is, so
changing it is loop 3: patch domain: and build again. What the MILP costs you
is not only time — an integer variable makes duals undefined, and asking says
so rather than handing back a number that means nothing.
integral = lps.load_model(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 — drop the key, or mask the rows¶
A constraint family is a key in a mapping, so removing it is pop. Below: the
ramp limit from the previous page, added and then taken away, with the objective
following it both ways.
ramped = lps.load_model(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, which keeps the
declaration and builds no rows where the mask is false — so a family is switched
off by rebinding the parameter it masks on. That is loop 1 in spelling and loop 2
in cost: a mask that changes membership renumbers labels, and that model is
loaded again and solved cold. diagnostics().loads is where you see which one
you got, and omissions is the same story from the outside — rows a constraint
declared but did not build.
What is still missing¶
An IIS on an infeasible model, and printing a single built row with its
coefficients. Both are linopy's, both are genuinely ahead of anything here, and
the second is the one to want first — activity() and dual() give a row's
value and its price, but not the row.
The whole relationship, including where else linopy leads, is relationship to linopy and the honest snapshot.