Skip to contents

The toolkit at a glance

verb direction what it does
datetime_to_timeslice() datetimes → labels cut a datetime vector to a calendar’s timeslice IDs
expand_calendar() calendar → grid enumerate the base datetime grid of a model year
join_calendar() calendar → columns attach labels, timeframe columns, share/weight to a table (no aggregation)
recast_calendar() calendar A → calendar B convert values between two resolutions, one rule per column
recast_to_timebase() / recast_from_timebase() the route halves project down to the base grid / aggregate up from it
calendar_map() A → B crosswalk the conversion, materialised as a small table
register_calendar_rule() / register_calendar_map() / register_calendar_conversion() registries per-column rules, exact crosswalks, functional overrides

Everything below runs on the shipped merra2_cities sample (three cities × 8,760 hours of 2019) in tidyverse style — data flows through |>, and every verb accepts a data.frame, tibble, data.table, dtplyr, or arrow input (see Backends).

From datetimes to timeslices

datetime_to_timeslice() is the entry point: it applies the calendar’s alignment rules and returns the timeslice ID each instant falls in (instants the calendar does not cover come back NA):

cal <- calendars$m12_h24

merra2_cities |>
  mutate(timeslice = datetime_to_timeslice(datetime, cal)) |>
  select(city, datetime, timeslice, T10M) |>
  head(3)
#>      city            datetime timeslice T10M
#> 1 Beijing 2019-01-01 00:30:00   m01_h00  -10
#> 2 Beijing 2019-01-01 01:30:00   m01_h01   -9
#> 3 Beijing 2019-01-01 02:30:00   m01_h02   -6

The inverse view is expand_calendar(): one row per base-grid point of a model year, with the timeslice that point belongs to — the year-aware, leap-aware grid every conversion routes through:

expand_calendar(calendars$m12, year = 2020, by = "day") |> head(3)
#>     datetime year timeslice
#> 1 2020-01-01 2020       m01
#> 2 2020-01-02 2020       m01
#> 3 2020-01-03 2020       m01

Attaching a calendar to a table

join_calendar() decorates rather than converts: it adds a label column named after the calendar, plus optional timeframe columns and share/weight, all "<name>."-prefixed. Keys are auto-detected — a column named like the calendar, else timeslice, else a datetime column (labels then computed on the base grid):

panel <- merra2_cities |>
  mutate(timeslice = datetime_to_timeslice(datetime, cal)) |>
  summarise(across(c(T10M, SWGDN), mean), .by = c(city, timeslice))

panel |>
  join_calendar(cal, timeframes = TRUE, meta = TRUE) |>
  head(3)
#>      city timeslice      T10M    SWGDN m12_h24 m12_h24.MONTH m12_h24.HOUR
#> 1 Beijing   m01_h00 -6.064516 115.1290 m01_h00           m01          h00
#> 2 Beijing   m01_h01 -4.193548 253.3226 m01_h01           m01          h01
#> 3 Beijing   m01_h02 -1.774194 364.8065 m01_h02           m01          h02
#>   m12_h24.share m12_h24.weight
#> 1   0.003538813             31
#> 2   0.003538813             31
#> 3   0.003538813             31

Because every calendar attaches under its own name, several can coexist on one dataset — and a table carrying two label columns is itself an empirical crosswalk between those calendars:

two <- merra2_cities |>
  filter(city == "Helsinki") |>
  join_calendar(calendars$m12_h24) |>
  join_calendar(calendars$q4_hp3)
two |> select(datetime, m12_h24, q4_hp3) |> head(3)
#>              datetime m12_h24 q4_hp3
#> 1 2019-01-01 00:30:00    <NA>   <NA>
#> 2 2019-01-01 01:30:00    <NA>   <NA>
#> 3 2019-01-01 02:30:00    <NA>   <NA>

Existing columns are never overwritten — a clashing attach errors instead.

Recasting between calendars

recast_calendar() converts values from one calendar to another — aggregation and disaggregation are the same operation, routed A -> base -> B through the shared grid. Three things to know:

1. One rule per value column, and the rule is mandatory. Pass rule= for all columns, or register per-column rules once; a column with neither errors — a silently guessed rule is a silent unit error.

register_calendar_rule("T10M",  "weighted_mean")   # intensive: temperature
register_calendar_rule("SWGDN", "sum")             # extensive proxy: energy

q <- panel |>
  recast_calendar(cal, calendars$q4, year = 2019)
head(q, 4)
#>   timeslice    city      T10M     SWGDN
#> 1        Q1 Beijing  1.571296 10997.608
#> 2        Q2 Beijing 20.296245 19162.382
#> 3        Q3 Beijing 25.331975 16604.304
#> 4        Q4 Beijing  4.986866  8578.376
rule meaning
sum conserve totals (split down, sum up)
weighted_mean share-weighted mean (declared shares)
mean plain time-weighted mean over grid points
copy constant within the target, error otherwise
sd dispersion over grid points (aggregation only)

2. Identifier columns ride along. Columns that are neither the key nor values (here city) are grouping columns, so panel data converts in one call — and totals conserve per group:

annual <- panel |>
  select(city, timeslice, SWGDN) |>
  recast_calendar(cal, to = "ANNUAL", year = 2019)
annual
#>    timeslice      city    SWGDN
#> 1     ANNUAL   Beijing 55342.67
#> 2     ANNUAL Cape Town 64983.83
#> 3     ANNUAL     Dakar 74552.53
#> 4     ANNUAL     Delhi 64217.18
#> 5     ANNUAL  Helsinki 36073.01
#> 6     ANNUAL  Honolulu 73953.58
#> 7     ANNUAL   Jakarta 64483.46
#> 8     ANNUAL      Lima 77294.96
#> 9     ANNUAL    Lisbon 60641.57
#> 10    ANNUAL Reykjavik 32893.18
#> 11    ANNUAL    Sydney 63287.80
#> 12    ANNUAL     Tokyo 53330.26

# the same totals, straight from the panel:
panel |> summarise(SWGDN = sum(SWGDN), .by = city)
#>         city    SWGDN
#> 1    Beijing 55342.67
#> 2  Cape Town 64983.83
#> 3      Dakar 74552.53
#> 4      Delhi 64217.18
#> 5   Helsinki 36073.01
#> 6   Honolulu 73953.58
#> 7    Jakarta 64483.46
#> 8       Lima 77294.96
#> 9     Lisbon 60641.57
#> 10 Reykjavik 32893.18
#> 11    Sydney 63287.80
#> 12     Tokyo 53330.26

(to = also accepts a timeframe name of the source calendar — the within-calendar aggregation shortcut, "ANNUAL" being the root.)

3. Coverage is explicit. Grid points the target does not cover are dropped with a warning by default; na_action = "keep" retains them as an explicit NA-timeslice row so totals conserve, and na_action = "error" refuses. Materialised results always return the FULL target vocabulary (with NA where nothing landed), so downstream joins see a stable schema.

The route halves

recast_calendar() is the fused route; its halves are public. recast_to_timebase() projects timeslice data DOWN to the base grid (extensive columns split so totals conserve; a weight column carries the source shares), and recast_from_timebase() aggregates datetime-keyed rows UP into any calendar — their composition is exactly recast_calendar():

m <- data.frame(timeslice = sprintf("m%02d", 1:12), energy = 1:12)

g <- m |>
  recast_to_timebase(calendars$m12, year = 2019, rule = "sum", by = "day")
head(g, 2)
#>     datetime year     energy      weight
#> 1 2019-01-01 2019 0.03225806 0.002739726
#> 2 2019-01-02 2019 0.03225806 0.002739726
sum(g$energy)                      # 78 -- conserved on the grid
#> [1] 78

g |>
  recast_from_timebase(calendars$q4, rule = "sum", by = "day")
#>   timeslice year energy
#> 1        Q1 2019      6
#> 2        Q2 2019     15
#> 3        Q3 2019     24
#> 4        Q4 2019     33

Use the halves when you need to do something on the grid between the two ends — join weather covariates by datetime, filter a season out, resample — with conservation guarantees intact.

The crosswalk, inspectable and overridable

Every recast is a join against a small crosswalk table — one row per overlapping timeslice pair with grid counts and share weights. calendar_map() exposes it:

calendar_map(calendars$m12, calendars$q4, year = 2019) |> head(4)
#>   year m12 q4 n_from n_overlap          w
#> 1 2019 m01 Q1     31        31 0.08493151
#> 2 2019 m02 Q1     28        28 0.07671233
#> 3 2019 m03 Q1     31        31 0.08493151
#> 4 2019 m04 Q2     30        30 0.08219178

When an exact correspondence is known (hand-audited concordances, provably nested designs), register_calendar_map() short-circuits the grid derivation for that pair; register_calendar_conversion() goes one step further and replaces the whole conversion with your function. Both are keyed by calendar names, like the attach.

Backends

The verbs above are single dplyr pipelines, so the SAME code runs over an in-memory data.frame/tibble, a data.table (via dtplyr), or an arrow Dataset/query. Eager inputs come back in their own class; lazy inputs return the uncollected query unless collect = TRUE:

dt <- data.table::as.data.table(panel)

dt |>
  recast_calendar(cal, calendars$q4, year = 2019, rule = "sum") |>
  class()                          # data.table in, data.table out
#> [1] "data.table" "data.frame"

lazy <- dtplyr::lazy_dt(dt) |>
  recast_calendar(cal, calendars$q4, year = 2019, rule = "sum")
class(lazy)                        # the query, not the result
#> [1] "dtplyr_step_call" "dtplyr_step"
head(as.data.frame(dplyr::collect(lazy)), 3)
#>   timeslice    city      T10M    SWGDN
#> 1        Q1 Beijing  106.4044 10997.61
#> 2        Q2 Beijing 1460.2989 19162.38
#> 3        Q3 Beijing 1822.0688 16604.30

Two contract details worth knowing for lazy sources (dtplyr, arrow): results carry the observed target timeslices only — the full-vocabulary completion happens on materialisation — and the calendar side of every join is a small in-memory frame, so an on-disk arrow dataset is never pulled into memory for the calendar arithmetic.

Across dimensions

The bare recast() generic dispatches on the scale object, so one verb chains time and space when the sibling package geoscales is loaded:

x |>
  recast(cal_hourly, cal_monthly, year = 2021, rule = "sum") |>
  recast(gs, to = "r32o11", rule = "sum")     # a geoscales Geoscale

Same contract on both sides: explicit rules (never a silent fallback), identifier columns preserved, backends welcome.

Where to next?

  • Concepts — why the route always goes through the base grid, and the shared *scales naming lattice.
  • Calendar catalog — the 43 shipped designs, by family.
  • Visualization — the same pipelines flowing into ggplot2.