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, so the same model expresses weeks, seasons, fiscal quarters, peak/off-peak blocks or representative days by changing that one column and nothing else.
Compare transport: there, \(\mathrm{gen\_bus}\) is a lookup over
generator and the sum is over generators at a bus. Here \(\mathrm{month\_of}\) is a
lookup 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 --- 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
lookups:
month_of:
description: the month a snapshot falls in
over: snapshot
into: 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
foreach: [snapshot, generator]
bounds:
lower: 0
upper: p_max
constraints:
balance:
foreach: [snapshot]
expression: sum(p, over=generator) == load
monthly_budget:
description: what a generator produces across a month stays inside that month's budget
foreach: [month, generator]
expression: sum(p, by=month_of) <= monthly_cap
objective:
sense: minimize
description: total cost of generation over the horizon
expression: sum(p * cost, over=generator)
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 lpspec call binds 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['snapshot'].set_index('snapshot')['month_of'].rename('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¶
The month column is produced before the model, by whatever rule you want:
index = pl.DataFrame({'snapshot': hours}).with_columns(pl.col('snapshot').dt.strftime('%Y-%m').alias('month'))
What that produces is the snapshot index the model binds against — a second column beside the timestamps, and nothing else:
shape: (6, 2)
┌─────────────────────┬──────────┐
│ snapshot ┆ month_of │
│ --- ┆ --- │
│ 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 anywhere. Swap it for
dt.quarter(), a fiscal-year lookup, or a hand-built table of representative
periods and the model is unchanged — which is why there is no resample: or
reduce_to_monthly() in the language and never will be. A domain helper would
cover one of those cases; a mapping column covers all of them.
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 at all — a primal is a tidy frame,
so it is a join and a group_by:
Why month is a dimension¶
A lookup is a function between two dimensions, so it needs a
codomain. month being one is not ceremony — three things rest on it:
sum(by=)lands terms on the dimension the lookup targets. The expression's dims are therefore[month, generator], and aforeach:can only name declared dimensions.monthly_capis indexed by month. A parameter carries values at coordinates; it cannot be the thing aforeachranges over. So month could not be a parameter even if the grouping did not need it.- It is what makes a typo an error. A value in the snapshot index that is
not a coordinate of
monthis rejected at bind time:
DataError: dimension 'snapshot' coordinate 'month' has value(s) that are
not 'month' coordinates: '2030-3'
That third one is the load-bearing reason. With no declared target there is
nothing to check against, and 2030-3 sitting beside 2030-03 would quietly
become a fourth group with a budget of its own — a smaller problem, solved
without a word. It is the same check that 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, exactly as a generator on no bus does (the declaration rules). 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 with a month belongs to exactly one group. Unequal groups
are fine, and a group with no members contributes nothing — as does a snapshot
whose coordinate is null, which belongs to no group and lands nowhere.
What it cannot express is an overlapping aggregate — "trailing twelve months, at every month" — because each snapshot would belong to twelve groups and no single column can say so. That is a sliding window over a variable, and it is the fixed-width window, #468.
The same split shows up one level up, where a process loops over plans
rather than an expression looping over rows
(#457): slicing a model per
group is a partition, slicing it per window overlaps. Here sum(by=)
partitions, and the overlapping counterpart is the piece that has not landed.