Skip to contents

Produce WHEP's canonical spatial support unit, the polycell: a 0.5-degree grid cell intersected with a polity over that polity's validity interval. Each row carries the polity's territory in the cell decomposed into three separately addressable categories, polity_area_ha = land_area_ha + inland_water_ha + ice_area_ha, so that aggregating polycells to a polity changes no absolute value and no quantity crosses a border it does not belong to.

Areas are geodesic, from sf::st_area() on unprojected WGS84 with spherical (s2) geometry, matching WHEP's own spherical convention; cell_area_ha keeps the package formula so it stays bit-identical to build_cell_polity(). Territory is the union of live real polities; land claimed by no live polity is emitted in the "unassigned" attribute rather than renormalised away.

The default grain is interval-keyed: one row per polycell per interval over which every area is constant, carrying start_year and end_year. Supply years to expand to one row per polycell-year, which is what expand_polycell_years() does on demand. No area varies within an interval, so the interval grain is the form to store.

Usage

build_polycell_support(
  years = NULL,
  geometries = NULL,
  water = NULL,
  ice = NULL,
  data = list(),
  aggregates = c("exclude", "overlap_layer")
)

Arguments

years

Optional integer vector of calendar years. NULL (default) returns the interval-keyed grain; a vector expands to one row per polycell-year and adds a year column.

geometries

An sf table of polity geometries with at least polity_code, start_year and end_year; defaults to get_polity_geometries(). start_year is inclusive; end_year is exclusive at a succession and inclusive at the open end, the convention polities is documented under, and neither bound is ever parsed out of polity_code. The intervals of one polity must partition time: two that overlap are an error rather than a shape the producer reconciles, and abort with class whep_pcs_overlapping_interval. Optional wiki_status, polity_type, polygon_status and area_code columns are honoured.

water

Optional per-cell tibble of inland water with lon, lat and water_frac, a fraction of the whole cell, as read_glwd_water() returns it.

ice

Optional sf polygon layer of glaciated area, as read_glaciated_areas() returns it, subtracted per polycell by exact geodesic intersection.

data

Optional named list of auxiliary layers: luh2 the validation layer (lon, lat, terrestrial_ha, e.g. read_luh2_terrestrial()); crosswalk the deployed build_cell_polity() table; producer_crosswalk a freshly built build_cell_polity_fraction() table; and crosswalk_year, the year whose polycells the crosswalk's present-day geometry describes (default 2015). The two crosswalks are read only by the DA-12 footprint reconciliation: no crosswalk column is carried into the output, and no crosswalk row the intersection did not reproduce is appended to it.

aggregates

What to do with polity_type == "aggregate" rows, which cannot join the partition because an aggregate's polygon covers its members'. "exclude" (default) drops them, which is what every published polycell table holds. "overlap_layer" clips them too and emits them alongside the partition marked support_role == "overlap" – see The aggregate overlap layer below.

Value

A tibble whose columns are a superset of polycell_id, cell_id, lon, lat, polity_code, area_code, start_year, end_year, cell_area_ha, polity_area_ha, land_area_ha, inland_water_ha, ice_area_ha, geometry_source, polygon_status, split_method, coverage_status, support_role, area_engine and luh2_vintage, plus year when years is supplied. support_role is "partition" on every row unless aggregates = "overlap_layer" was asked for. area_engine is "s2" except on the pieces the spherical engine cannot read back, which are measured with terra::expanse() rather than dropped. Diagnostics ride as attributes: "unassigned" (the validation-layer disagreement, in both directions: unassigned_land_ha where the polities claim less than the layer and over_claimed_land_ha where they claim more), "coverage" (every live polity interval and why it did or did not produce polycells), "overlap" (cells holding more territory than the cell, because two polities were handed the same polygon), "long_edges" (polity edges the source stores as one long segment along a parallel, which s2 draws as a bulging great circle), "terra_measured" (polycells whose area came from terra), "water_excess" (inland water clamped to the polycell's territory), "water_unmatched" (cells the water layer and the polycells do not share), "footprints" and "footprint_diff" (the deployed crosswalk, the current producer and the polycell footprint, reconciled at data$crosswalk_year).

"overlap", "terra_measured", "water_excess", "water_unmatched" and "unassigned" are interval-grain, like the table itself: they carry start_year and end_year, and one cell contributes a row per interval. Summing them without first filtering to the interval covering the year of interest counts the same cell once per epoch. On the shipped polities that is the difference between 1,343 clamped polycells over all epochs and 94 in 2015.

Every row is a real polycell: polity_code, polycell_id and the area columns are populated on all of them, so sum(land_area_ha) over the output is the land the intersection measured. The DA-13 transition, which padded the table with the crosswalk rows the intersection did not reproduce and carried polity_frac alongside, ended with C9; the footprint diagnostics below are where that disagreement is now reported.

Identity is polity_code, and only polity_code. area_code rides along as a label and is not a key: a polity can have no reporting area at all, and two polities can share one. A table whose whole purpose is correct territorial attribution cannot be keyed on a code that merges two countries, so this one is not, and no reporting_polity_code or polity_area_code is derived here. A consumer joining to a reporting-vocabulary output converts at its own boundary, and that conversion is where the lossy fold happens – deliberately visible at the consumer rather than hidden in the support. build_n_deposition() refuses an unconverted support instead of converting one silently.

The label is the reporting area_code, the code space country_areas and the national nutrient tables are keyed on – not the coarser polity_area_code bucket the matrix workflows aggregate on. Writing the bucket here, which this producer did until whep#907, put 206 (Sudan plus South Sudan) and 999 (Rest of World, absorbing Syria, North Macedonia, Eswatini, Palestine, Equatorial Guinea and French Guiana among 43 territories) in a column named area_code, and every area_code-keyed consumer dropped those areas' whole national total. A polity with no reporting region of its own – Greenland, Western Sahara, most dependencies – still carries its bucket, because that is the only home the reporting vocabulary gives it.

Land definitions in play

Four definitions of "land" are live in this pipeline and they disagree by up to 10%, so a global area only means something next to the definition it was measured on. At 2015:

DefinitionGlobal area
Whole 0.5-degree cells holding any land14.3195 Gha
HaNi's own land mask13.5977 Gha
Union of the live polity polygons13.4267 Gha
LUH2 terrestrial, (1 - icwtr) * carea12.9931 Gha

polity_area_ha carries the third row's territory – the polity polygons, decomposed into land, inland water and ice – but summing it does not give the third row. The union is unique ground, which is what makes it comparable with the other three; a sum counts shared ground once per claiming polity. At 2015 sum(polity_area_ha) is 13.4599 Gha, exceeding the union by the 0.0332 Gha two live polities both claim. Quote the union for a land definition and the sum for attributed territory, and never read the difference between them as a leak. The first row is the convention this table replaces – a per-hectare rate multiplied by cell_area_ha – and it over-counts by 11.0%. The fourth is the DA-5 validation layer: its disagreement with the polygons is emitted in the "unassigned" attribute and never silently reconciled, and the polygons exceeding it by about 2.2% is what inland_water_ha + ice_area_ha has to account for. The second belongs to the deposition source and governs a different quantity – see build_n_deposition(), where WHEP's territory decides placement while HaNi's mask decides the total.

Only the first and fourth rows are constants of the inputs; inst/scripts/diagnose_polycell_support.R re-derives both. The polygon row moves with the polity vintage, so read it back off the table in hand rather than quoting it – inst/scripts/reconcile_polity_areas.R measures it.

A fifth land mask is present but deliberately absent from the ladder, because nothing is measured on it: the GLWD water layer carries the CRU mask (67,420 cells against LUH2's 64,493 terrestrial), so cells one carries and the other does not are reported in "water_unmatched" rather than dropped by an inner join.

What does not vary historically

ice_area_ha comes from ne_10m_glaciated_areas (see read_glaciated_areas()), a coarse present-day snapshot, so it is the same number in 1850 as in 2015: a historical run carries today's ice extent, and land that lay under ice in 1850 is credited to land_area_ha. This is accepted only because ice is a reporting category and not a driver – nothing in the package divides by ice_area_ha or drives a flux with it. If ice ever becomes a driver, the source has to be reopened rather than the caveat restated.

Polity geometry is likewise constant within an interval, and the GLWD file carries a single time step. That is why the default grain is interval-keyed: no area column varies by year, so a per-year grain would repeat identical rows about 173 times.

The aggregate overlap layer

An aggregate polity – BLX-1850-1999 Belgium-Luxembourg, F249-1918-1990 Yemen, the six residual "Other" regions – is a reporting bucket's territory, and its polygon covers its members'. It therefore cannot be a row of the partition: two rows claiming the same ground would hand the cell's land out twice, which is exactly what the rasterised cover this table replaced did, halving Belgium's 1961 cropland (whep#800).

Dropping it outright is not free either. FAOSTAT keys its pre-2000 data on those buckets: Belgium (255) and Luxembourg (256) carry no data before 2000, bucket 15 does, and bucket 15's only territory is BLX-1850-1999. Measured against polity_area_crosswalk over 1850-1961, ten of the 460 polities the pre-1962 resolver reaches are aggregates and ten reporting buckets have no other territory in at least one year: 15, 151, 237 (1954-1961 only), 249 and 901-906.

So both granularities are kept, and they are kept apart. With aggregates = "overlap_layer":

  • every row carries support_role, "partition" or "overlap";

  • "partition" rows are exactly what "exclude" emits – same polities, same territory, same land, water and ice, split into more intervals only where an aggregate's validity adds a breakpoint to a cell;

  • the cell's inland water is apportioned over the partition's territory in that cell, so an aggregate receives what its members receive and the members' share is not diluted by the layer covering them;

  • every diagnostic that describes the partition – "overlap", "unassigned", "water_unmatched", "footprints" – is measured on the partition alone, so admitting the layer cannot make the polygons look like they over-claim the validation layer.

The consumer contract is the other half: read_polycell_support() returns the partition unless asked otherwise, so no existing consumer can pick up an overlapping row by accident, and a consumer that wants a bucket's own territory asks for role = "overlap" (or "all") and states that it is summing a layer that double-counts by construction. Never aggregate across the two roles.

The layer is not a partition of itself either, and that is not a defect to be fixed by a tolerance: ROW-1850-2025 Rest of World contains the six regional residuals it is the sum of. Built on the 19 live aggregates of whep::polities (779 rows, ingest 2026-08-13) it is 12,644 polycells, and at 2015 it puts more territory in a cell than the cell holds in 2,751 cells shared by ROW and REUR, 92 by ROW and RAFR, 8 by ROW and ROCE and 7 by ROW and RLAM – plus 2 cells where CODRU-1922-1960 and EGYSUD-1934-1956 overlap in 1950, which is a polygon disagreement rather than nesting. So a consumer takes one polity's polycells out of the layer – the one its bucket resolves to that year – and never sums the layer as a whole. That is also why the "overlap" diagnostic keeps measuring the partition only: an over-full cell means something there, and in this layer it means nothing.

Examples

if (requireNamespace("sf", quietly = TRUE)) {
  build_polycell_support(
    years = 2015L,
    geometries = polycell_example_geometries()
  )
}
#> Warning: No `ice` layer was supplied, so ice_area_ha is identically zero.
#>  Every lake, river and glacier inside a polity is therefore booked as LAND,
#>   and the identity `polity_area_ha == land_area_ha + inland_water_ha +
#>   ice_area_ha` still holds, so no downstream check can see it.
#>  This is correct for a smoke build and wrong for a published pin (#885).
#>   Supply the layer, or state in the publishing commit that ice_area_ha is zero
#>   by construction.
#> Warning: No `water` layer was supplied, so inland_water_ha is identically zero.
#>  Every lake, river and glacier inside a polity is therefore booked as LAND,
#>   and the identity `polity_area_ha == land_area_ha + inland_water_ha +
#>   ice_area_ha` still holds, so no downstream check can see it.
#>  This is correct for a smoke build and wrong for a published pin (#885).
#>   Supply the layer, or state in the publishing commit that inland_water_ha is
#>   zero by construction.
#> # A tibble: 6 × 22
#>   polycell_id         cell_id   lon   lat polity_code area_code  year start_year
#>   <chr>                 <int> <dbl> <dbl> <chr>           <int> <int>      <int>
#> 1 AAA-2000-2020@3802…  380269  10.2  44.8 AAA-2000-2…        11  2015       2000
#> 2 AAA-2000-2020@3802…  380270  10.2  45.2 AAA-2000-2…        11  2015       2000
#> 3 AAA-2000-2020@3812…  381269  10.8  44.8 AAA-2000-2…        11  2015       2000
#> 4 AAA-2000-2020@3812…  381270  10.8  45.2 AAA-2000-2…        11  2015       2000
#> 5 AAA-2000-2020@3822…  382269  11.2  44.8 AAA-2000-2…        11  2015       2000
#> 6 AAA-2000-2020@3822…  382270  11.2  45.2 AAA-2000-2…        11  2015       2000
#> # ℹ 14 more variables: end_year <int>, cell_area_ha <dbl>,
#> #   polity_area_ha <dbl>, land_area_ha <dbl>, inland_water_ha <dbl>,
#> #   ice_area_ha <dbl>, geometry_source <chr>, polygon_status <chr>,
#> #   split_method <chr>, coverage_status <chr>, support_role <chr>,
#> #   area_engine <chr>, luh2_vintage <chr>, water_excess_ha <dbl>