Skip to content

Fixing, relaxing and removing

How to do what linopy's fix, relax and remove_constraints do. A fix is data, as in Change it and solve again. The other two are an edit to the spec.

linopy here rebuild
x.fix(v) both bounds read a parameter; write the same number into both no, update
x.relax() domain: in the declaration yes
remove_constraints drop the key from the spec yes

The spec is examples/dispatch.yaml.

import polars as pl
from mathspec import to_spec

import specsolve as sps

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 variable

Give the variable two bound parameters, each total over its coordinates. Keep p_max as the where mask. Then write the pinned value into both bounds with update:

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 = sps.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')
print(pl.DataFrame({'gas': ['free to dispatch', 'held at 60'], 'objective': [unpinned, held]}))
1 loads over 2 solves — a pin moves bounds, not labels
shape: (2, 2)
┌──────────────────┬───────────┐
│ gas              ┆ objective │
│ ---              ┆ ---       │
│ str              ┆ f64       │
╞══════════════════╪═══════════╡
│ free to dispatch ┆ 6600.0    │
│ held at 60       ┆ 21600.0   │
└──────────────────┴───────────┘

One load for both answers. To fix a combination of variables, write a constraint instead.

Relax integrality

Patch domain: and build again. An integer model has no duals.

integral = to_spec(MODEL).to_dict()
integral['variables']['p']['domain'] = 'integer'

milp = sps.solve(integral, sources)
print(f'integer objective {milp.objective:,.1f}, has_primal {milp.has_primal}')

try:
    milp.dual('power_balance')
except sps.errors.SpecsolveError as exc:
    print(exc)

relaxed = sps.solve(MODEL, sources)  # the same file, continuous as declared
print(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.
shape: (6, 2)
┌──────────┬───────┐
│ snapshot ┆ value │
│ ---      ┆ ---   │
│ i64      ┆ f64   │
╞══════════╪═══════╡
│ 0        ┆ -0.0  │
│ 1        ┆ 60.0  │
│ 2        ┆ 60.0  │
│ 3        ┆ 60.0  │
│ 4        ┆ 60.0  │
│ 5        ┆ -0.0  │
└──────────┴───────┘

Prices from a MIP

To price a MIP, fix its integer decisions at the answer and solve again. What is left is an LP with the same optimum, and its duals are the prices for those decisions. The model is examples/tutorial/commitment.yaml, where on says whether a generator is on. on becomes continuous, and two bounds hold it at the MIP's answer:

UNITS = 'examples/tutorial/commitment.yaml'

generators = pl.read_csv('examples/tutorial/data/generators.csv')
load = pl.read_csv('examples/tutorial/data/load.csv')
units = {'generator': generators['generator'], 'snapshot': load['snapshot'], 'load': load} | {
    name: generators.select('generator', value=name) for name in ('p_max', 'cost', 'p_min', 'no_load_cost')
}
mip = sps.solve(UNITS, units)

fixable = to_spec(UNITS).to_dict()
fixable['parameters']['on_fixed'] = {'dims': ['snapshot', 'generator']}
fixable['variables']['on'] |= {'domain': 'continuous', 'bounds': {'lower': 'on_fixed', 'upper': 'on_fixed'}}

priced = sps.solve(fixable, units | {'on_fixed': mip.primal('on')})
print(f'MIP {mip.objective}, fixed LP {priced.objective}')
print(priced.dual('power_balance'))
MIP 14000.0, fixed LP 14000.0
shape: (4, 2)
┌──────────┬───────┐
│ snapshot ┆ value │
│ ---      ┆ ---   │
│ i64      ┆ f64   │
╞══════════╪═══════╡
│ 0        ┆ 10.0  │
│ 1        ┆ 10.0  │
│ 2        ┆ 50.0  │
│ 3        ┆ 10.0  │
└──────────┴───────┘

Gas sets the price in snapshot 2. In the other snapshots wind is at the margin. The prices hold the on-off decisions fixed, so they do not include the cost of keeping gas on.

Remove a constraint

pop the constraint's key from the spec:

ramped = to_spec(MODEL).to_dict()
ramped['parameters']['ramp_max'] = {'dims': ['generator']}
ramped['constraints']['ramp_up'] = {
    'dims': ['snapshot', 'generator'],
    'expression': 'p - shift(p, along=snapshot, offset=1) <= ramp_max',
}
data = sources | {'ramp_max': pl.DataFrame({'generator': GENERATORS, 'value': [100.0, 100.0, 20.0]})}

with_ramp = sps.solve(ramped, data).objective
ramped['constraints'].pop('ramp_up')
without_ramp = sps.solve(ramped, data).objective

print(pl.DataFrame({'model': ['with ramp_up', 'ramp_up removed'], 'objective': [with_ramp, without_ramp]}))
shape: (2, 2)
┌─────────────────┬───────────┐
│ model           ┆ objective │
│ ---             ┆ ---       │
│ str             ┆ f64       │
╞═════════════════╪═══════════╡
│ with ramp_up    ┆ 8400.0    │
│ ramp_up removed ┆ 6600.0    │
└─────────────────┴───────────┘

To switch rows off with data instead, put a where on the constraint. An update that changes the mask loads the model again.