nestedscales represents a discrete data dimension at multiple resolutions and recasts tabular data between them. A nested scale stores one row per finest-grained unit, or atom, and one column for each coarser grouping, or frame. Each frame assigns atoms to units; frames may form a hierarchy or cross-cut one another, including product-like overlays. All mappings are derived from membership at the atom level.

The package defines:

  • Structure: the levels of a scale, the units at each level, and the relations between levels (nested, crossed, uniform).
  • Transition rules between levels, chosen per value column and aware of direction: sum, weighted_mean, mean, and copy; sd for aggregation; and share or logshare for within-parent shares.
  • Operations: converting data between levels, attaching level labels to a dataset, subsetting, combining several scales into one multi-dimensional index, clustering units into new levels, and storing data together with its scales.

The package is dimension-agnostic. Time and space are provided by two packages built on it, designed for optimization and simulation models, where labeled time slices and regions serve as the index sets of variables and constraints:

  • timescales: Calendar, labeled discrete time slices (hours, days, seasons, representative days).
  • geoscales: Geoscale, labeled geographical regions with geometry.

Both classes are subclasses of NestedScale. Any other dimension (industries, income brackets, technology vintages) is declared directly from a table. vignette("nestedscales") introduces the functions; the package website includes a roadmap of what is available and what is planned.

Installation

Until the first CRAN release, install the development version from GitHub:

pak::pkg_install("optimal2050/nestedscales")

The package is pure R; its non-base runtime dependencies are dplyr, rlang and S7.

A scale in one call

Declare the levels, coarsest first:

library(nestedscales)

industries <- data.frame(
  section  = c("C", "C", "C", "D", "D"),
  division = c("C10", "C10", "C11", "D35", "D35"),
  class    = c("C101", "C102", "C110", "D351", "D352"),
  gva      = c(120, 80, 45, 300, 150)
)

ind <- scale_from_leaftable(
  industries,
  frames = c("section", "division", "class"),
  weights = "gva",
  name   = "nace"
)

ind
#> NestedScale: nace
#> Frames (3, coarsest first):
#>   - section (2)
#>     - division (3)
#>       - class (5)
#> Atoms: 5
#> Weights: gva (default: gva)

Every structural question is answered from the atoms, never from an assumed tree:

scale_frames(ind)
#> [1] "section"  "division" "class"
scale_units(ind, "division")
#> [1] "C10" "C11" "D35"
scale_nests(ind, "section", "division")
#> [1] TRUE

Hierarchies that cross-cut are normal and supported – one detailed code may belong to several aggregates. scale_nests(x, parent, child) tests whether every child code belongs to a single parent code; conversion never depends on that relationship.

summary(ind)
#> <summary of NestedScale 'nace'>
#>   frames:         section (2) / division (3) / class (5)
#>   atoms:          5
#>   weight totals:  gva = 695  (default: gva)
#>   nesting:        section > division: nested
#>   nesting:        division > class: nested

Moving data between levels

Data keyed at one level is recast to another with a rule per value column. With complete coverage, "sum" conserves totals; intensive quantities use "weighted_mean" with a declared weight:

x <- data.frame(
  class = c("C101", "C102", "C110", "D351", "D352"),
  jobs  = c(12, 8, 5, 30, 20),
  wage  = c(31, 29, 27, 45, 40)
)

recast_scale(x, ind, from = "class", to = "section",
             rule = c(jobs = "sum", wage = "weighted_mean"),
             weight = "gva")
#>   section jobs     wage
#> 1       C   25 29.61224
#> 2       D   50 43.33333

Design

  • Atoms are the ground truth. Frames are partitions of the atoms, not necessarily a strict tree; conversion always routes through the atom layer.
  • Weights are named and plural. A scale can carry gva, employment and count side by side, with one declared default.
  • Partial coverage is first class. An atom with no code at a frame is NA, not an error, and a subset records what fraction of its parent it keeps.
  • Backend-agnostic. The verbs use the dplyr interface, so the same pipeline runs on a data.frame, a data.table, or an arrow dataset; lazy inputs stay lazy.
  • scales maps data to graphical aesthetics; its discrete scales are palettes and label formatters, not resolutions of a model dimension.
  • data.tree represents strict trees as node objects. nestedscales keeps the hierarchy as a flat atom table, allows frames that cross-cut, and converts data between any two frames with weights.
  • hts reconciles forecasts across a fixed aggregation tree; reconcile_scale() here compares data aggregated with sum or weighted_mean against independent totals and can balance discrepancies.

License

Apache License 2.0.