Skip to content

What a change is worth

How to read from one solve what a small change would be worth: to a constraint, to a variable's bound, or to a value in your data. Each answer is a rate, in objective units per unit of the thing you change.

You ask The change is to Read
What does one more unit of load cost in each snapshot? a constraint dual
How much cheaper would gas have to be to run? a variable at its bound reduced_cost
Where is more capacity worth most? Which input matters? a value in your data parameter_dual

The examples solve dispatch with the data of Your data. A reduced cost is read only where the solve asks for it:

import polars as pl

import specsolve as sps

generators = pl.read_csv('examples/tutorial/data/generators.csv')
load = pl.read_csv('examples/tutorial/data/load.csv')
sources = {
    'generator': generators['generator'],
    'snapshot': load['snapshot'],
    'p_max': generators.select('generator', value='p_max'),
    'cost': generators.select('generator', value='cost'),
    'load': load,
}
result = sps.solve('examples/dispatch.yaml', sources, outputs={'reduced_cost'})
print(result.objective)
10500.0

The price of a constraint

dual gives the rate at which the objective rises as a constraint's right side rises. For power_balance, that is the price of one more unit of load:

print(result.dual('power_balance'))
shape: (4, 2)
┌──────────┬───────┐
│ snapshot ┆ value │
│ ---      ┆ ---   │
│ i64      ┆ f64   │
╞══════════╪═══════╡
│ 0        ┆ 10.0  │
│ 1        ┆ 50.0  │
│ 2        ┆ 50.0  │
│ 3        ┆ 50.0  │
└──────────┴───────┘

Wind alone meets the load in snapshot 0, so a unit there costs 10. Gas sets the price at 50 in the other snapshots.

What a variable's bound costs

reduced_cost gives the rate at which the objective rises as a variable's binding bound rises. A variable between its bounds has zero:

print(result.reduced_cost('p'))
shape: (8, 3)
┌──────────┬───────────┬───────┐
│ snapshot ┆ generator ┆ value │
│ ---      ┆ ---       ┆ ---   │
│ i64      ┆ str       ┆ f64   │
╞══════════╪═══════════╪═══════╡
│ 0        ┆ wind      ┆ 0.0   │
│ 0        ┆ gas       ┆ 40.0  │
│ 1        ┆ wind      ┆ -40.0 │
│ 1        ┆ gas       ┆ 0.0   │
│ 2        ┆ wind      ┆ -40.0 │
│ 2        ┆ gas       ┆ 0.0   │
│ 3        ┆ wind      ┆ -40.0 │
│ 3        ┆ gas       ┆ 0.0   │
└──────────┴───────────┴───────┘

Gas sits at zero in snapshot 0 with a reduced cost of 40. It runs there only once it is 40 cheaper. Wind sits at its capacity in the other snapshots, and each unit of room there saves 40.

What a value in your data is worth

parameter_dual gives the dual of each value of a parameter: the rate at which the objective rises with it, in the shape of the parameter's own table:

print(result.parameter_dual('p_max'))
print(result.parameter_dual('cost'))
shape: (3, 2)
┌───────────┬────────┐
│ generator ┆ value  │
│ ---       ┆ ---    │
│ str       ┆ f64    │
╞═══════════╪════════╡
│ wind      ┆ -120.0 │
│ solar     ┆ 0.0    │
│ gas       ┆ 0.0    │
└───────────┴────────┘
shape: (3, 2)
┌───────────┬───────┐
│ generator ┆ value │
│ ---       ┆ ---   │
│ str       ┆ f64   │
╞═══════════╪═══════╡
│ wind      ┆ 300.0 │
│ solar     ┆ 0.0   │
│ gas       ┆ 150.0 │
└───────────┴───────┘

One more unit of wind capacity saves 120: 40 in each of the three snapshots where it runs at its limit. A rise of one in a generator's cost adds its output: 300 for wind and 150 for gas.

A parameter dual reads every place the model uses the value: an objective coefficient, a bound, a coefficient in a row, a right side. Where the value stands alone on a right side, it is the dual: parameter_dual('load') equals dual('power_balance').

Solar has a rate of 0, though it is cheaper than gas. The file builds no output for a generator with no capacity, and a parameter dual holds the model as it was built. To price the first unit of solar, use update and solve again.

Which input matters most

A rate is per unit of its own input, so a capacity and a cost cannot be ranked by their rates. Multiply each rate by its value: the sum is what a 1% rise of every value of that input changes the objective by.

rows = []
for name in ('cost', 'load', 'p_max'):
    rate = result.parameter_dual(name)
    keys = [c for c in rate.columns if c != 'value']
    data = sources[name].rename({'value': 'data'})
    one_percent = rate.join(data, on=keys).select(pl.col('value') * pl.col('data')).sum().item() / 100
    rows.append({'input': name, 'one_percent': one_percent})

print(pl.DataFrame(rows).sort(pl.col('one_percent').abs(), descending=True))
shape: (3, 2)
┌───────┬─────────────┐
│ input ┆ one_percent │
│ ---   ┆ ---         │
│ str   ┆ f64         │
╞═══════╪═════════════╡
│ load  ┆ 201.0       │
│ cost  ┆ 105.0       │
│ p_max ┆ -96.0       │
└───────┴─────────────┘

The load forecast moves the cost most: 1% more load costs 201. 1% higher costs add 105, and 1% more capacity saves 96.

A rate or a sweep

A rate holds while the change leaves the same generators at the same limits. A larger change moves them, and the rate with them: in Change it and solve again, each step of 40 more wind saves less than the one before. For a change that large, solve each case with Solving many scenarios. Rank the inputs as above first, and sweep the ones that matter.