Skip to content

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

\[\sum_{t \thinspace:\thinspace \mathrm{month}(t) = m} p_{t,g} \quad\le\quad \bar E_{m,g}\]

\(\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

\[\min \sum_{t \in \mathcal{T}} \sum_{g \in \mathcal{G}} p_{t,g} \cdot c_{g}\]

Subject to

balance

\[\sum_{g \in \mathcal{G}} p_{t,g} = \ell_{t} \qquad \forall\thinspace t \in \mathcal{T}\]

monthly_budget

\[\sum_{t \in \mathcal{T} \thinspace:\thinspace \mathrm{month\_of}(t) = m} p_{t,g} \le \bar E_{m,g} \qquad \forall\thinspace m \in \mathcal{M},\enspace g \in \mathcal{G}\]

Variable domains

p

\[0 \le p_{t,g} \le \bar p_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}\]

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)
# sources: parameter name -> frame or parquet path
with lps.solve('examples/monthly_budget.yaml', sources) as solution:
    solution.objective  # 9500.0
    solution.dual('monthly_budget')

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:

sol.primal('p').join(index, on='snapshot').group_by('month').agg(pl.col('value').sum())

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:

  1. sum(by=) lands terms on the dimension the lookup targets. The expression's dims are therefore [month, generator], and a foreach: can only name declared dimensions.
  2. monthly_cap is indexed by month. A parameter carries values at coordinates; it cannot be the thing a foreach ranges over. So month could not be a parameter even if the grouping did not need it.
  3. It is what makes a typo an error. A value in the snapshot index that is not a coordinate of month is 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.