seasons¶
A store that cycles inside each season rather than across the horizon, with seasons of different lengths — one balance row says it, because the wrap is the season's own and no level is carried from one season into the next.
The problem¶
A store that must come back to where it started needs its first position to read
its last. edge='wrap' says that about the axis:
which on this instance links snapshot 7 to snapshot 1 and makes the whole horizon one cycle: winter opens holding what summer left, and sells it at winter's best price. That is a different model, and a plausible-looking one — it solves, and its objective is higher.
The cycle a multi-period model means is per period, and by= says which:
The translation walks inside the group the lookup makes, so a season's first snapshot reads that season's last, whatever length each season happens to be — four snapshots and three here, and nothing in the file says so.
The model¶
The same model, as math
A store that cycles inside each season rather than across the horizon, with seasons of different lengths — one balance row says it, because the wrap is the season's own and no level is carried from one season into the next.
Sets¶
| Symbol | Meaning |
|---|---|
| \(\mathcal{T}\) | index \(t\) --- snapshot with \(\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}\) --- dispatch periods in order |
| \(\mathcal{S}\) | index \(s\) --- season --- the blocks the store cycles over |
Parameters¶
| Symbol | Meaning |
|---|---|
| \(\mathit{inflow}\) | inflow over \(\mathcal{T}\) --- energy arriving in a snapshot, whether or not it is wanted then |
| \(\mathit{price}\) | price over \(\mathcal{T}\) --- what one unit of release earns in a snapshot |
Variables¶
| Symbol | Meaning |
|---|---|
| \(\mathit{soc}\) | soc over \(\mathcal{T}\) --- energy held at the end of a snapshot |
| \(\mathit{release}\) | release over \(\mathcal{T}\) --- energy released in a snapshot |
\(t \ominus k\) denotes cyclic translation: index \(t-k\) taken modulo the size of the dimension (roll). Plain \(t-k\) (shift) has no wraparound --- terms translated past the edge are simply absent.
Objective¶
Subject to¶
season_balance
Variable domains¶
soc
release
The tabs start from the instance's tables — one frame per parameter.
description: >-
A store that cycles inside each season rather than across the horizon, with
seasons of different lengths — one balance row says it, because the wrap is
the season's own and no level is carried from one season into the next.
dimensions:
snapshot:
description: dispatch periods in order
dtype: int
season:
description: the blocks the store cycles over
dtype: str
lookups:
season_of:
description: the season a snapshot falls in
over: snapshot
into: season
parameters:
inflow:
description: energy arriving in a snapshot, whether or not it is wanted then
dims: [snapshot]
price:
description: what one unit of release earns in a snapshot
dims: [snapshot]
variables:
soc:
description: energy held at the end of a snapshot
foreach: [snapshot]
bounds:
lower: 0
upper: 60
release:
description: energy released in a snapshot
foreach: [snapshot]
bounds:
lower: 0
constraints:
season_balance:
description: >-
the level carried into a snapshot is the previous snapshot's, and a
season's first snapshot carries from that season's own last — so each
season ends where it began and hands the next one nothing
foreach: [snapshot]
expression: soc == shift(soc, over=snapshot, offset=1, edge='wrap', by=season_of) + inflow - release
objective:
sense: maximize
description: revenue from what the store releases
expression: release * price
What the answer looks like¶
snapshot season price inflow release soc
1 winter 1 0 0 0
2 winter 2 10 0 10
3 winter 5 0 10 0 ← winter's inflow, at winter's best price
4 winter 3 0 0 0 ← closes where it opened
5 summer 4 0 6 0 ← sells *before* its inflow arrives
6 summer 1 6 0 6
7 summer 2 0 0 6 ← closes where it opened, three snapshots later
Objective 74.0. Summer is the half worth reading: it opens holding 6, sells that at the price-4 snapshot before its own inflow has arrived, and the inflow at snapshot 6 puts the 6 back so the season closes where it opened. Nothing constrains the level a season starts at except that it must return to it — which is what a cycle is, and what no clause here has to name.
Written against the axis instead, the same instance gives 80.0: the extra 6 comes out of winter, which had it to give only because summer's closing level leaked across the boundary.
Why it is one row¶
Per-season cycling is expressible without this operator, as a level each season
begins and ends at: a variable per season, an opening row that reads it, and a
closing row that pins it. Substituting the closing row into the opening one
gives exactly the equation above — three constraints and an auxiliary variable
saying what one by= says, and the substitution is the proof they are the same
model.
What the operator adds is that the boundary stops being something the file has to mention. There is no first snapshot named anywhere, so there is nothing to re-check when the horizon is renumbered, extended, or cut into different seasons.
The grouping is data¶
season_of is a column of the snapshot index, so the same file expresses
maintenance campaigns, representative days, market quarters or contract windows.
Move two snapshots between seasons by editing that column and no clause changes:
shape: (7, 2)
┌──────────┬───────────┐
│ snapshot ┆ season_of │
│ --- ┆ --- │
│ i64 ┆ str │
╞══════════╪═══════════╡
│ 1 ┆ winter │
│ 2 ┆ winter │
│ 3 ┆ winter │
│ 4 ┆ winter │
│ 5 ┆ summer │
│ 6 ┆ summer │
│ 7 ┆ summer │
└──────────┴───────────┘
A snapshot the column leaves null belongs to no season, so it reaches nothing and
its row is not built — the same reading a null gets in
sum(by=).
Compare monthly budget, where such a column groups a sum, and multi-period, where it carries a capacity decision down onto the snapshots it covers. One lookup, three jobs.