Three layers of abstraction
timescales is built around three concentric ideas.
Working out from the centre:
-
Timeframes — the atomic vocabulary
(
YEAR,MONTH,HOUR, …). -
Tokens — named recipes for the labels at one
timeframe (e.g.
m12=m01..m12,m12a=JAN..DEC). - Calendars — the actual partition of a year into labelled leaves, formed by composing tokens.
Most users only ever touch layer 3.
1. Timeframes
A timeframe is a unit of time at which a calendar can carry labels. The package ships with a fixed core set:
CORE_TIMEFRAMES
#> [1] "YEAR" "QUARTER" "MONTH" "MDAY" "YDAY" "HOUR"
#> [7] "MINUTE" "SECOND" "WDAY" "WHOUR" "MWEEK" "WEEK"
#> [13] "SEASON" "DAYTYPE" "HOURTYPE"The function as_timeframe() extracts the value of a
chosen timeframe from any datetime:
t <- as.POSIXct("2025-04-15 13:45", tz = "UTC")
as_timeframe(t, "MONTH")
#> [1] 4
as_timeframe(t, "MONTH", format = "token")
#> [1] "m04"
as_timeframe(t, "HOUR")
#> [1] 13
as_timeframe(t, "WDAY", format = "token")
#> [1] "TUE"format = "token" returns the same label style that the
built-in tokens use, which is what the calendar machinery needs
internally.
2. Tokens
A token is a named, reusable recipe for the labels of one timeframe, plus each label’s within-year share. Tokens are how vocabularies are shared across calendars.
list_calendar_tokens()
#> [1] "d360" "d364" "d365" "d366" "h168" "h24" "hp3" "m12" "m12a"
#> [10] "min60" "q4" "s4" "w52" "w53" "wd7" "wk2"
get_calendar_token("m12")$expand() |> head(3)
#> label share
#> 1 m01 0.08493151
#> 2 m02 0.07671233
#> 3 m03 0.08493151
get_calendar_token("h24")$expand() |> head(3)
#> label share
#> 1 h00 0.04166667
#> 2 h01 0.04166667
#> 3 h02 0.04166667A few built-ins:
| Token | Timeframe | Labels | Notes |
|---|---|---|---|
d365 |
YDAY | d001..d365 |
day-of-year; aligns drop_feb29
|
d360 |
YDAY | d001..d360 |
stylised year; aligns drop_last
|
m12 |
MONTH | m01..m12 |
numeric month |
m12a |
MONTH | JAN..DEC |
abbreviated month |
q4 |
QUARTER | Q1..Q4 |
day-weighted shares |
wd7 |
WDAY | MON..SUN |
ISO weekday order |
h24 |
HOUR | h00..h23 |
hour-of-day |
h168 |
WHOUR | h000..h167 |
hour-of-week (Mon 00:00 = h000) |
Custom tokens are added with register_calendar_token(),
optionally declaring an alignment rule (see below).
Alignment: mapping real years onto stylised ones
A d365 calendar has no label for Feb 29; a
d360 calendar has none for the last days of December.
Alignment rules (ALIGNMENT_RULES) declare what
happens to such instants: drop_feb29 (Feb 29 is
NA, later ydays shift down so Dec 31 is still
d365), drop_last, repeat_last
(clamp to the last label — how week 53 folds into w52), and
exact (error). Built-in tokens carry sensible defaults;
calendars record them in meta$alignment, and
datetime_to_timeslice() accepts an override.
d365 <- calendar_build("d365")
datetime_to_timeslice(as.Date(c("2020-02-29", "2020-12-31")), d365)
#> [1] NA "d365"3. Calendars
A Calendar is the actual product of one or more tokens. The leaves of the resulting tree are the timeslices the rest of your model talks about.
Three constructors, in increasing flexibility:
calendar("m12_h24") # layer 1: by name
calendar_build("m12", "h24") # layer 2: by token list
calendar_from_leaftable(leaves, ...) # layer 3: by raw leaf tableThe first two compose tokens with a Cartesian product; share is the
product of per-token shares scaled to year_fraction. The
third is the escape hatch for irregular calendars (e.g. representative
weeks that intentionally do not cover the full year).
What the leaves table looks like
cal <- calendar("q4_h24")
head(calendar_leaftable(cal), 3)
#> QUARTER HOUR share weight timeslice
#> 1 Q1 h00 0.01027397 90 Q1_h00
#> 2 Q2 h00 0.01038813 91 Q2_h00
#> 3 Q3 h00 0.01050228 92 Q3_h00
cat(nrow(calendar_leaftable(cal)), "leaves, share sums to", sum(calendar_leaftable(cal)$share))
#> 96 leaves, share sums to 14. Recasting between calendars
Conversion is the central verb. The same
recast_calendar() function handles upsampling,
downsampling, and irregular-to-irregular mappings, given that both
calendars cover the same year fraction.
cal_m <- calendar("m12") # source: monthly
cal_q <- calendar("q4") # target: quarterly
monthly <- data.frame(
timeslice = calendar_leaftable(cal_m)$timeslice,
load = c(120, 118, 105, 92, 85, 88, 95, 100, 98, 90, 105, 122)
)
recast_calendar(monthly, from = cal_m, to = cal_q, year = 2025,
rule = "weighted_mean", by = "day")
#> timeslice load
#> 1 Q1 114.21111
#> 2 Q2 88.29670
#> 3 Q3 97.66304
#> 4 Q4 105.67391The base calendar and the route
Every conversion routes A -> base -> B through the
base calendar — the 1:1 datetime grid built by
base_calendar() (one row per hour, day, 15 minutes, … of
real time; the data column is always datetime). Source
values are projected down to the grid, then aggregated
up to target timeslices, so aggregation and disaggregation are
one operation. The route is collapsed once into a small crosswalk table
— calendar_map_between(from, to, year) — and every
conversion is then a single join-and-aggregate pipeline over it, which
is what lets the converters run unchanged on a data.frame,
tibble, data.table, or an arrow dataset (lazy inputs return
uncollected queries).
The two halves of the route are public:
recast_to_timebase(x, cal) takes timeslice-keyed data down
to datetime rows, recast_from_timebase(x, cal) brings
datetime rows up into any calendar, and
recast_calendar(x, from, to, year) ==
recast_from_timebase(recast_to_timebase(x, from, year), to)Direct routes between two named calendars can bypass the grid:
register a function with register_calendar_conversion() or
an exact crosswalk table with
register_calendar_map_between().
Aggregation rules
The rules (CALENDAR_RULES) define behaviour in both
directions:
| Rule | Down (timeslice → grid) | Up (grid → timeslice) |
|---|---|---|
weighted_mean |
copy | mean weighted by declared share (default) |
sum |
split across grid points | sum — totals are conserved |
mean |
copy | plain (time-weighted) mean |
copy |
copy | the common value; error if not constant |
sd |
copy | spread of the fine signal |
weighted_mean and mean differ exactly when
a calendar’s declared shares differ from its real-time coverage.
Per-parameter defaults can be registered with
register_calendar_rule(). Grid points not covered by one of
the calendars are governed by na_action = "drop" (warns),
"error", or "keep" (an explicit
NA row that conserves totals).
Attaching calendars to data
join_calendar(x, cal) attaches a calendar to a dataset
as a timeslice-label column named after the calendar
(meta$name), from either a datetime column
(labels computed on the base grid) or an existing timeslice column.
Because each calendar attaches under its own name, several calendars
coexist on one dataset — and a dataset carrying two label columns is
itself a direct crosswalk between those calendars:
xt <- data.frame(datetime = seq(as.POSIXct("2025-01-01", tz = "UTC"),
by = "hour", length.out = 48))
xt <- join_calendar(xt, calendar("m12_h24"))
xt <- join_calendar(xt, calendar("q4_h24"))
head(xt, 3)
#> datetime m12_h24 q4_h24
#> 1 2025-01-01 00:00:00 m01_h00 Q1_h00
#> 2 2025-01-01 01:00:00 m01_h01 Q1_h01
#> 3 2025-01-01 02:00:00 m01_h02 Q1_h02Within-calendar aggregation and the ANNUAL root
Every calendar has an implicit whole-year root named
ANNUAL. prune_calendar() truncates a calendar
at one of its own timeframes, and recast_calendar() accepts
a timeframe name for to=:
cal <- calendar("q4_h24")
x <- data.frame(timeslice = calendar_leaftable(cal)$timeslice, energy = 1)
recast_calendar(x, cal, to = "ANNUAL", year = 2025, rule = "sum")
#> timeslice energy
#> 1 ANNUAL 96The *scales naming lattice (glossary)
The sibling packages timescales (time) and
geoscales (space) share one vocabulary, locked 2026-08. No
surviving word changes meaning between the packages; the word “levels”
is retired from both.
| term | definition | timescales | geoscales | energyRt |
|---|---|---|---|---|
| frame | a named axis of resolution; the ordered frames form the hierarchy, coarsest first, with an implicit root | @timeframes |
@geoframes |
commodity@timeframe / @geoframe
|
| members | the per-frame units — bare ordered labels, unique within their frame
("h00"; region codes); the components node IDs are made
from |
@members[[tf]] |
@members[[gf]] |
derived |
| node ID | identity of a cell at any frame. The ID rule: time COMPOSES (members
_-joined, coarsest to finest: "d015_h00");
space AUTHORS (the member is the ID, in its geoframe) |
leaftable$timeslice |
leaftable$region |
@timeframes[[f]] entries |
| leaftable | the leaf enumeration: one row per finest node — bare members per frame, plus the leaf ID and weights | @leaftable |
@leaftable |
calendar@timetable (legacy name; the bridge maps
1:1) |
| nodes at frame f | the leaf IDs of the object pruned at f
(prune_calendar() / prune_geoscale()) |
derived | derived | stored (cached view) |
| share / weight |
share = fraction of the whole; sums to coverage;
aggregates by SUM (universal). Weight conventions are documented side by
side; energyRt’s share-weighted-mean parent rule is the solver-facing
one |
share, weight columns |
named weight columns, share derived | timeslice_share |
| tokens / providers | domain-specific generators (grammar rules / data sources); deliberately separate concepts | token registry | provider registry | — |
| year-qualified calendar | YEAR as an explicit timeframe (y2020_m01_h00).
Deferred: year stays an external dimension — the pair
(year, timeslice) — matching what the solver consumes;
meta$year_qualified reserves the flag |
flag only | — | year = model dimension |
The route halves are named per domain:
recast_to_timebase() / recast_from_timebase()
route through the base datetime grid; recast_to_geoatoms()
/ recast_from_geoatoms() route through the atom layer. Both
compose into their package’s fused recast_*() verb, and
both packages resolve aggregation rules the same way: explicit
rule=, then the registry, then an error — never a silent
fallback.
Design boundaries
-
Year-bounded calendars, multi-year base. A calendar
describes one model year (or a fraction of one); the base instant grid
(
base_calendar()) spans real multi-year time, which is how leap years stay representable. Multi-year horizons remain a future layer. - Label-stable. A calendar’s timeslice IDs and ordering are fixed at construction time. Conversions never mutate them.
- Explicit registries only. Constructors return values; the token, rule, and conversion registries change behaviour only when you register into them.
