--- title: "The NestedScale object" description: "Anatomy of a NestedScale: the leaftable, frames, members, the attribute and alias tables and meta; what the validator guarantees; definition files; the rule registry." output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{The NestedScale object} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(nestedscales) ``` This vignette takes one object apart. The example is a small fuel classification with every awkward feature: a code reused at two frames, a frame that cross-cuts another, an atom with no code at one frame, and attributes stored at three different levels. ```{r fuels} lt <- data.frame( group = c("Fossil", "Fossil", "Fossil", "Fossil", "Fossil", "Bio", "Bio", "Bio", "Power"), phase = c("solid", "solid", "liquid", "liquid", "gas", "solid", "liquid", "gas", NA), carrier = c("Coal", "Coal", "Oil", "Oil", "Gas", "Biomass", "Biomass", "Biomass", "Electricity"), fuel = c("Anthracite", "Lignite", "Diesel", "Gasoline", "NaturalGas", "Wood", "Biodiesel", "Biogas", "Electricity") ) at <- data.frame( frame = c("fuel", "fuel", "carrier", "group", "fuel", "carrier"), unit = c("Anthracite", "Lignite", "Oil", "Bio", "Anthracite", "Gas"), attribute = c("lhv", "lhv", "lhv", "lhv", "ef_co2", "ef_co2"), kind = c(rep("property", 4), "factor", "factor"), value = c(26.7, 11.9, 42.5, 15.0, 98300, 56100), unit_of_measure = c(rep("GJ/t", 4), "kg/TJ", "kg/TJ"), source = "example" ) al <- data.frame( frame = c("fuel", "carrier", "fuel", "carrier", "carrier"), unit = c("Anthracite", "Coal", "Electricity", "Electricity", "Electricity"), alias = c("hard coal", "coal", "elc", "elc", "power"), source = "example" ) x <- scale_from_leaftable( lt, frames = c("group", "phase", "carrier", "fuel"), key = "fuel", attributes = at, aliases = al, name = "fuels" ) x ``` ## The object A `NestedScale` is an S7 object with seven properties: ```{r props} S7::prop_names(x) ``` | property | type | holds | |---|---|---| | `leaftable` | `data.frame` | one row per atom: one column per frame, the key column, weight columns | | `frames` | `character` | the hierarchy, ordered coarsest first | | `members` | named `list` | the ordered code vocabulary at each frame | | `key` | `character` | the name of the atom column (`"unit"` by default) | | `meta` | `list` | `name`, `desc`, `weights`, `default_weight`, and whatever the builder adds | | `attributes` | `data.frame` | values keyed by `(frame, unit)`: `attribute`, `kind`, `value`, `lower`, `upper`, `unit_of_measure`, `source`, `note` | | `aliases` | `data.frame` | alternative labels keyed by `(frame, unit)`: `alias`, `source` | Everything else -- parent/child tables, ancestry, shares, resolved attributes -- is derived on demand; nothing is cached on the object. The two tables are empty unless the builder fills them; a scale saved by an earlier version, which had no such properties, loads and validates unchanged. ## The leaftable ```{r leaftable} scale_leaftable(x) ``` Each row is one atom. The key column (`fuel` here) is unique and complete. Frame columns may hold `NA`: `Electricity` has no physical state, so it does not participate at the `phase` frame, and `na_action=` decides what recasts do about it. The key may share its name with the finest frame, as here and in the shipped `carriers`: there the frame column *is* the key column. ## Frames, members, ranks ```{r frames} scale_frames(x) scale_atom_level(x) # the finest frame, or the key scale_units(x, "carrier") # the members at one frame scale_units(x) # the atoms scale_rank(x, "carrier") ``` Codes are **not unique across frames**: `Electricity` is a group and a carrier here, and `Nuclear`, `Heat`, `Storage`, `Other` are each a group and a carrier in `carriers`. Every function that takes a code therefore takes its frame as well. Navigation is derived from the leaftable and routed through the atoms, so a frame that cross-cuts another reports overlap rather than invented containment: ```{r navigate} scale_nests(x, "carrier", "fuel") # a tree scale_nests(x, "phase", "carrier") # Biomass is solid, liquid and gas scale_parents(x, "carrier", "Biomass", target = "phase") scale_ancestors(x, "fuel", "Biogas") scale_descendants(x, "group", "Fossil") head(scale_ancestry(x), 4) ``` ## The attribute table Physical attributes live in one long table, and a row may sit on **any** member, not only on an atom: ```{r attributes} scale_attributes(x) ``` Reading with a `frame` resolves one value per member, walking to the nearest coarser frame when a member has none of its own, and says where the value came from: ```{r resolve} scale_attributes(x, "lhv", frame = "fuel")[ c("unit", "value", "unit_of_measure", "origin_frame", "origin_unit")] ``` Attributes are **not weights**. A weight says how big an atom is and drives aggregation; an attribute is a per-member conversion factor and is consumed only by `convert_scale()`. The constructor promotes numeric leaftable columns to weights, so a calorific value must be passed as `attributes=`, never as a leaftable column. Every attribute name carries a `kind`: `property` (an invertible ratio) or `factor` (one-way). ```{r kinds} SCALE_ATTRIBUTE_KINDS ``` `vignette("attributes")` has the reasoning and the full account of inheritance, sources and conversion. ## The alias table ```{r aliases} scale_aliases(x) scale_match(x, c("hard coal", "elc", "Lignite")) ``` One alias may point at several members (`elc` above); matching reports that as ambiguous unless `frame=` narrows it. Matching is exact and case-insensitive, with no fuzzy stage. ## Weights and meta ```{r meta} scale_weights(x) # none declared here S7::prop(x, "meta")[c("name", "desc")] carriers <- read_scale("carriers") scale_weights(carriers) S7::prop(carriers, "meta")[c("name", "source", "palette_version")] head(S7::prop(carriers, "meta")$labels, 4) ``` `meta$name` is load-bearing: `join_scale()` prefixes the columns it attaches with `"."`, `scale_map_between()` names its label columns after the objects, and `write_scale()` needs a name for the definition. The shipped `carriers` keeps energypal's short and long labels in `meta$labels`; colours are not stored. ## What the validator guarantees `scale_from_leaftable()` and every operation that rebuilds a scale enforce: - the key column is present, unique and complete; every frame is a leaftable column and `members` are exactly its non-`NA` values, in a stable order; - weights are numeric and finite; - every attribute and alias row names a `(frame, unit)` that is a member; `value` is finite; `lower <= value <= upper` where given; `kind` is one of `SCALE_ATTRIBUTE_KINDS` and one attribute name has one kind; `unit_of_measure` and `source` are non-empty; no duplicate `(frame, unit, attribute, source)` rows and no duplicate alias rows. The order of `frames` is a convention, coarsest first; the direction-dependent rules (`share`, residuals, reconciliation) follow it. Subsetting keeps the tables consistent: `filter_scale()` and `[` keep the attribute and alias rows of every surviving member at every frame, and `prune_scale()` keeps the rows at the frames that survive the collapse. ```{r subset} fossil <- x["group", "Fossil"] scale_attributes(fossil)[c("frame", "unit", "attribute", "value")] ``` `prune_scale()` needs the frames above the new atom level to nest, so the cross-cutting `phase` has to go first: ```{r prune} tree <- scale_from_leaftable( lt[c("group", "carrier", "fuel")], frames = c("group", "carrier", "fuel"), key = "fuel", attributes = at, aliases = al, name = "tree" ) scale_aliases(prune_scale(tree, "carrier")) ``` ## Definition files A scale is stored as a definition, not as an R object: a JSON header with the three tables inline, or, for large scales, a `.scale/` bundle directory holding the header and the tables as Arrow IPC or CSV files. The Python binding reads and writes the same files. ```{r definition} path <- file.path(tempdir(), "fuels.json") write_scale(x, path) cat(head(readLines(path), 12), sep = "\n") back <- read_scale(path) identical(scale_frames(back), scale_frames(x)) identical(scale_attributes(back), scale_attributes(x)) ``` `write_scale(format = "yaml")` writes the same header as YAML, and `read_scale()` accepts either. Installed packages register directories of definitions; `scale_catalog()` lists them and `read_scale(id)` opens one by its id: ```{r catalog} scale_catalog()[c("id", "name", "kind", "frames", "n_atoms", "package")] ``` ```{r cleanup, include = FALSE} unlink(path) ``` ## The registry One package-level registry changes behaviour explicitly. **Rules** map value-column names to their aggregation rule; there is no fallback, and an unregistered column without `rule=` is an error: ```{r rules} SCALE_RULES register_scale_rule("tj", "sum") register_scale_rule("price", "weighted_mean", weight = "tj") get_scale_rule("price") list_scale_rules() clear_scale_rules() ``` A rule may be scoped to one dimension with `scope=`, so a model's `tj` column aggregates the same way over fuels, regions and months without being registered three times -- or differently, when it should. ## Where to next? - `vignette("nestedscales")` -- the tour of the main functions. - `vignette("attributes")` -- attributes, aliases and conversion on the shipped `carriers`.