Skip to content

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 spec is examples/dispatch.yaml, as before. Every block runs when the site is built, and a block that raises fails the build.

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 fix is two bound parameters, each total over the variable's coordinates: a frame holding only the pinned rows is a load error. p_max keeps its job as the where mask, so a pin does not 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 = 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. A p == p_pin constraint would do the same at a row per pinned variable, and put the information in a shadow price instead of a reduced cost. Write a row 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 = sps.solve(integral, sources)
print(f'integer objective {milp.objective:,.1f}, has_primal {milp.has_primal}')

try:
    milp.dual('power_balance')
except sps.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  │
└──────────┴───────┘

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'] = {
    '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    │
└─────────────────┴───────────┘

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. model.row(name, **coordinate) gives one row's terms, comparison and right-hand side without a solve, and debugging a wrong answer is the recipe. The whole relationship is relationship to linopy.

Where next

Tables in, tables out feeds a model from parquet, reads the answer as tables and queries its archive.