The problem
Energy-system, climate, and policy models carve space into discrete regions. Different models — often different vintages of the same model — pick different carvings: one nation, five grid regions, thirty-two model regions, forty-six zones. The region codes are arbitrary, the weights (area, population) are data, and converting values between carvings is error-prone precisely where it matters: totals must conserve, intensities must average correctly, and region systems drawn by different hands rarely nest.
geoscales represents any such carving as a
Geoscale: a set of atoms (the finest regions)
plus ordered geoframes that group them.
Atoms and geoframes
The atom layer is the finest partition — every other resolution is a grouping of atoms. A geoframe is one named resolution: a column over the atoms assigning each to a region code. Geoframes are ordered coarsest first, and the object’s hierarchy is that ordered set:
gs <- geoscale_example()
gs
#> Geoscale: example
#> Description: Synthetic example: reused code, non-nesting geoframe pair, and an unassigned atom
#> Geoframes (4, coarsest first):
#> - country (2) [1 atom(s) unassigned]
#> - state (3) [1 atom(s) unassigned]
#> - zone (3) [1 atom(s) unassigned]
#> - atom (7)
#> Atoms: 7
#> Weights: km2, pop (default: km2)Geoframes are partitions, not necessarily a tree
This is the main thing that differs from the time domain. Time timeframes nest cleanly: every hour belongs to exactly one day. Spatial geoframes frequently do not — two carvings of the same country can cross-cut, with a region of one straddling two regions of the other:
geoscale_nests(gs, "country", "state")
#> [1] TRUE
geoscale_nests(gs, "state", "zone") # ZB straddles two states
#> [1] FALSE
#> attr(,"offenders")
#> [1] "ZB"Real data behaves the same way. In the IDEEA region system for India,
the r32o11 code APY merges Andhra Pradesh with
part of Puducherry, so the 36-region carving does not nest
inside the 32-region one. This is exactly why the atom layer exists, and
why recast_geoscale() always routes through it: neither
direction of a conversion ever assumes the geoframes nest.
Region IDs are authored, and not unique across geoframes
The naming lattice’s ID rule (see the glossary
below): time COMPOSES node IDs from members; space AUTHORS
them. A timeslice ID like "d015_h00" is assembled from
its members; a region ID like "N1" simply is the
member, in its geoframe. Authored codes are reused freely across
geoframes:
geoscale_children(gs, "state", "N1")
#> [1] "N1"
geoscale_children(gs, "zone", "N1") # same code, different geoframe
#> [1] "A1" "A2"So a bare code is ambiguous, and every lookup names its geoframe — a
geoframe argument is required throughout the API.
Shares and weights
A Geoscale carries one or more named weight columns
on its atoms (area, population, …); meta$default_weight
names the default. share is the universal
derived quantity: a fraction of the whole that sums to coverage and
aggregates by SUM — the same word with the same meaning as in
timescales:
geoscale_share(gs, "state", weight = "km2", within = "country")
#> state country km2 share
#> 1 N1 N 300 0.3
#> 2 N2 N 700 0.7
#> 3 S1 S 1100 1.0Weights drive the conversion rules: sum splits a coarse
value across atoms proportionally to the chosen weight,
weighted_mean aggregates an intensity up with atom weights.
There is deliberately no fallback rule — a value column
with neither an explicit rule= nor a registry entry is an
error, in both siblings.
Design boundaries
-
The package ships no maps and selects no
boundaries. Providers (
ne_geoscale(), your own viaregister_geoscale_provider()) pass a point of view through and record it in@meta; the choice stays yours. - Everything derivable is computed on demand. Parent/child tables, ancestry, shares, dissolved geometry — nothing is cached on the object.
-
Explicit registries only. Constructors return
values; behavior changes only through
register_geoscale_rule(),register_geoscale_map(),register_geoscale_provider(). - Label-stable. A Geoscale’s region codes and their order are fixed at construction; conversions never mutate them.
The *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.
Where to next?
-
Data structures — anatomy of a
Geoscaleobject and its registries. - Data manipulation — attach, recast, crosswalks, backends.
-
Visualization — maps with
geom_geoscale()and the structure figures.
