monthly_budget¶
A cap on what each technology may generate per calendar month — an aggregate over a coarser grouping of time, written with the same operator that places a generator on a bus.
✔ Agrees with hand-written linopy 0.9.0 — objective 9500, matched to
rtol=1e-09.
The problem¶
\(\mathrm{month}\) is a coordinate the snapshot dimension declares, not a calendar the language understands. Its values arrive as a column in the snapshot index.
Compare transport: there, \(\mathrm{gen\_bus}\) is a relation over
generator and the sum is over generators at a bus. Here \(\mathrm{month\_of}\) is a
relation over snapshot and the sum is over snapshots in a month. It is the
same construct — sum(by=) — and time is not a special axis.
The model¶
The same model, as math
A cap on what each technology may generate per calendar month — an aggregate over a coarser grouping of time than the model is dispatched on.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{T}\) | index \(t\) — snapshot with \(\mathrm{month\_of}: \mathcal{T} \to \mathcal{M}\) — dispatch periods, each falling in one month |
| \(\mathcal{M}\) | index \(m\) — month with \(\mathrm{month\_of}: \mathcal{T} \to \mathcal{M}\) — the grouping the budget is stated over |
| \(\mathcal{G}\) | index \(g\) — generator — generating units |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\bar p\) | p_max over \(\mathcal{G}\) — installed capacity |
| \(c\) | cost over \(\mathcal{G}\) — marginal cost |
| \(\ell\) | load over \(\mathcal{T}\) — demand to be met |
| \(\bar E\) | monthly_cap over \(\mathcal{M} \times \mathcal{G}\) — the budget the group sum is checked against, one per month and technology |
Variables¶
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{T} \times \mathcal{G}\) — output of a generator in a snapshot |
Objective¶
Subject to¶
balance
monthly_budget
Variable domains¶
p
The tabs start from the instance's tables — one frame per parameter.
description: >-
A cap on what each technology may generate per calendar month — an aggregate
over a coarser grouping of time than the model is dispatched on.
dimensions:
snapshot:
description: dispatch periods, each falling in one month
dtype: datetime
month:
description: the grouping the budget is stated over
dtype: str
generator:
description: generating units
dtype: str
relations:
month_of:
description: the month a snapshot falls in
key: snapshot
values: month
parameters:
p_max:
description: installed capacity
dims: [generator]
cost:
description: marginal cost
dims: [generator]
load:
description: demand to be met
dims: [snapshot]
monthly_cap:
description: the budget the group sum is checked against, one per month and technology
dims: [month, generator]
variables:
p:
description: output of a generator in a snapshot
dims: [snapshot, generator]
bounds:
lower: 0
upper: p_max
constraints:
balance:
dims: [snapshot]
expression: sum(p, over=generator) == load
monthly_budget:
description: what a generator produces across a month stays inside that month's budget
dims: [month, generator]
expression: sum(p, by=month_of, over=snapshot, into=month) <= monthly_cap
objective:
sense: minimize
description: total cost of generation over the horizon
expression: sum(p * cost)
The model-building half of examples/ports/references/linopy/monthly_budget.py:
def build(tables: dict[str, pd.DataFrame]) -> linopy.Model:
"""The instance's tables as a linopy model, row for row.
``tables`` is the same mapping the specsolve call attaches as ``sources``.
"""
p_max: pd.Series = tables['p_max'].set_index('generator')['value']
cost: pd.Series = tables['cost'].set_index('generator')['value']
load: pd.Series = tables['load'].set_index('snapshot')['value']
cap = xr.DataArray(tables['monthly_cap'].pivot(index='month', columns='generator', values='value'))
month = xr.DataArray(tables['month_of'].set_index('snapshot')['month'])
m = linopy.Model()
p = m.add_variables(lower=0, upper=p_max, coords=[load.index, p_max.index], name='p')
m.add_constraints(p.sum('generator') == load, name='balance')
m.add_constraints(p.groupby(month).sum() <= cap, name='monthly_budget')
m.add_objective((p * cost).sum())
return m
The grouping is data¶
You produce the month_of relation before the model, by whatever rule you want:
month_of = pl.DataFrame({'snapshot': hours}).with_columns(pl.col('snapshot').dt.strftime('%Y-%m').alias('month'))
That produces the table the model attaches under month_of: every snapshot
beside the month it falls in, and nothing else:
shape: (6, 2)
┌─────────────────────┬─────────┐
│ snapshot ┆ month │
│ --- ┆ --- │
│ datetime[μs] ┆ str │
╞═════════════════════╪═════════╡
│ 2030-01-01 00:00:00 ┆ 2030-01 │
│ 2030-01-16 00:00:00 ┆ 2030-01 │
│ 2030-01-31 00:00:00 ┆ 2030-01 │
│ 2030-02-15 00:00:00 ┆ 2030-02 │
│ 2030-03-02 00:00:00 ┆ 2030-03 │
│ 2030-03-17 00:00:00 ┆ 2030-03 │
└─────────────────────┴─────────┘
Three snapshots in January, one in February, two in March: sum(by=) needs a
partition, not equal groups.
That one expression is the only place a calendar appears. Swap it for
dt.quarter(), a fiscal-year relation, a hand-built table of representative
periods or peak/off-peak blocks, and the model is unchanged. The language has
no resample: or reduce_to_monthly(): a relation covers all of those cases.
Reading it back¶
The budget's dual is a shadow price per month and technology, so a binding cap says what relaxing it would be worth:
month generator dual
2030-01 wind -49.0 ← binding: displacing gas (50) with wind (1)
2030-02 wind -0.0
2030-03 wind -0.0
Per-month results need no language support: a primal is a tidy frame, so a
join and a group_by do it:
Why month is a dimension¶
A keyed relation is a function between two dimensions, so it needs a
codomain. Three things rest on month being one:
sum(by=)lands terms on the dimension the relation's value column is over. The expression's dims are therefore[month, generator], and adims:can only name declared dimensions.monthly_capis indexed by month. A parameter carries values at coordinates; it cannot be the thing adims:ranges over.- It is what makes a typo an error. A value in the snapshot index that is
not a coordinate of
monthis rejected at attach time:
DataError: dimension 'snapshot' coordinate 'month' has value(s) that are
not 'month' coordinates: '2030-3'
Without a declared target there is nothing to check against, and 2030-3
beside 2030-03 would become a fourth group with a budget of its own, solved
without a word. The same check catches a generator assigned to a bus that does
not exist.
Null is still legal. A snapshot belonging to no month contributes its terms nowhere, as a generator on no bus does. Absent is a claim; misspelled is a mistake.
What this cannot do¶
sum(by=) takes a partition: month_of is a function from snapshot to
month, so a snapshot belongs to at most one group, and a group with no members
contributes nothing.
It cannot express an overlapping aggregate such as trailing twelve months, at every month: each snapshot would belong to twelve groups, and no single column can say so. That is a sliding window over a variable, #468.
The same split appears one level up, where a process loops over plans rather than an expression over rows (#457): slicing a model per group is a partition, slicing it per window overlaps.