Working with typical periods¶
aggregate() also returns the bookkeeping that links every typical period back to the original
calendar. This guide reads and uses those links.
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.io as pio
import tsam
pio.renderers.default = "notebook_connected"
raw = pd.read_csv("../data/testdata.csv", index_col=0, parse_dates=True)
data = raw.loc["2010-01-01":"2010-02-11"] # six weeks of hourly data
UNITS = {"GHI": "W/m²", "T": "°C", "Wind": "m/s", "Load": "MW"}
result = tsam.aggregate(data, n_clusters=6, period_duration="1D")
What you get back¶
cluster_representatives— the typical days.cluster_counts— how many real days each stands for.cluster_assignments— for each original day, in calendar order, which typical day represents it.
print("cluster_counts:", result.cluster_counts)
print("cluster_assignments:", result.cluster_assignments)
result.assignments.head()
cluster_counts: {0: 10.0, 1: 6.0, 2: 10.0, 3: 7.0, 4: 8.0, 5: 1.0}
cluster_assignments: [0 0 0 0 0 0 2 3 3 3 3 1 1 4 4 4 4 4 1 1 3 3 2 2 2 0 1 3 2 2 2 2 0 0 2 5 4
4 4 1 0 2]
| period_idx | timestep_idx | cluster_idx | |
|---|---|---|---|
| 2010-01-01 00:30:00 | 0 | 0 | 0 |
| 2010-01-01 01:30:00 | 0 | 1 | 0 |
| 2010-01-01 02:30:00 | 0 | 2 | 0 |
| 2010-01-01 03:30:00 | 0 | 3 | 0 |
| 2010-01-01 04:30:00 | 0 | 4 | 0 |
result.assignments is the same mapping per timestep — handy for joining onto the original
series. To see it on the calendar:
result.plot.clusters_over_time(
columns=["Load"],
units=UNITS,
title="Which typical day represents each real day",
)
Members of a typical day¶
The assignment runs the other way too — every typical day has a set of member days:
cluster = 0
members = np.where(result.cluster_assignments == cluster)[0]
print(f"Typical day {cluster} represents {len(members)} real days: {members.tolist()}")
result.plot.cluster_members(columns=["Load"], clusters=[cluster], units=UNITS)
Typical day 0 represents 10 real days: [0, 1, 2, 3, 4, 5, 25, 32, 33, 40]
Map model results back¶
disaggregate() expands a per-typical-period result over the original timeline, replacing each
original day with the row of its representative. Disaggregating the representatives themselves
reproduces the reconstruction exactly:
back = result.disaggregate(result.cluster_representatives)
print(
"disaggregate(representatives) == reconstructed:",
np.allclose(back.values, result.reconstructed.values),
)
# Any frame shaped like cluster_representatives maps back the same way — here a
# stand-in for a model's per-typical-day dispatch decision.
model_output = result.cluster_representatives[["Load"]] * 0.5
full = result.disaggregate(model_output)
px.line(
full.reset_index(names="time"),
x="time",
y="Load",
title="A per-typical-day result expanded to the full six weeks",
labels={"Load": f"dispatch [{UNITS['Load']}]", "time": "time"},
)
disaggregate(representatives) == reconstructed: True
Inter-period storage¶
cluster_assignments is ordered, keeping the calendar sequence of which typical day stands in
for each real day. That sequence is what seasonal-storage formulations consume: Kotzur et al.
(2018) split the storage state into an intra-period part (within a typical day) and an
inter-period part carried across the real days in sequence, using precisely this assignment
k = f(day). tsam supplies the linkage; the constraints live in your optimization model.
seq = pd.DataFrame(
{
"day": np.arange(len(result.cluster_assignments)),
"typical_day": [str(int(c)) for c in result.cluster_assignments],
}
)
fig = px.scatter(
seq,
x="day",
y="typical_day",
color="typical_day",
category_orders={
"typical_day": [
str(c) for c in sorted({int(c) for c in result.cluster_assignments})
]
},
title="Sequence of typical days across the six weeks (k = f(day))",
)
fig.update_traces(marker={"size": 11})
fig.update_layout(
xaxis_title="original day (calendar order)",
yaxis_title="typical day",
showlegend=False,
)
fig
Supply your own grouping¶
Every ClusterConfig(method=...) asks tsam to infer the grouping. If you already know the
grouping you want — day-of-week, season × weekday, a rule from a regulator — build the assignment
vector yourself and hand it to ClusteringResult.apply(), which reuses a given
cluster_assignments instead of running a clustering algorithm.
Below, the six weeks are grouped by day of the week. No tsam method can produce this:
averaging cuts the calendar into consecutive blocks, and the feature-based methods group by
value similarity, not by calendar rule.
from tsam import ClusteringResult
day_starts = data.index[::24] # one timestamp per period
weekday_id = day_starts.dayofweek.to_numpy() # Mon=0 ... Sun=6
by_weekday = ClusteringResult(
period_duration=24.0,
n_timesteps_per_period=24,
cluster_assignments=tuple(int(c) for c in weekday_id),
representation="mean", # each group -> its average
temporal_resolution=1.0,
)
result_weekday = by_weekday.apply(data)
names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
print("typical days:", result_weekday.n_clusters, "(one per weekday)")
for cluster_id, count in result_weekday.cluster_counts.items():
mean_load = result_weekday.cluster_representatives.loc[cluster_id, "Load"].mean()
print(
f" {names[cluster_id]}: {mean_load:7.1f} {UNITS['Load']} ({int(count)} days)"
)
typical days: 7 (one per weekday) Mon: 525.8 MW (6 days) Tue: 462.3 MW (6 days) Wed: 433.9 MW (6 days) Thu: 534.1 MW (6 days) Fri: 498.9 MW (6 days) Sat: 528.3 MW (6 days) Sun: 535.1 MW (6 days)
- Optimization workflow — the full hand-off to a model.
- Clustering methods — why the sequence jumps around, and the two methods that keep calendar order.
- Further reading — Kotzur et al. (2018).