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.
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 ayearcolumn.- geometries
An
sftable of polity geometries with at leastpolity_code,start_yearandend_year; defaults toget_polity_geometries().start_yearis inclusive;end_yearis exclusive at a succession and inclusive at the open end, the conventionpolitiesis documented under, and neither bound is ever parsed out ofpolity_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 classwhep_pcs_overlapping_interval. Optionalwiki_status,polity_type,polygon_statusandarea_codecolumns are honoured.- water
Optional per-cell
tibbleof inland water withlon,latandwater_frac, a fraction of the whole cell, asread_glwd_water()returns it.- ice
Optional
sfpolygon layer of glaciated area, asread_glaciated_areas()returns it, subtracted per polycell by exact geodesic intersection.- data
Optional named list of auxiliary layers:
luh2the validation layer (lon,lat,terrestrial_ha, e.g.read_luh2_terrestrial());crosswalkthe deployedbuild_cell_polity()table;producer_crosswalka freshly builtbuild_cell_polity_fraction()table; andcrosswalk_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 markedsupport_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:
| Definition | Global area |
| Whole 0.5-degree cells holding any land | 14.3195 Gha |
| HaNi's own land mask | 13.5977 Gha |
| Union of the live polity polygons | 13.4267 Gha |
LUH2 terrestrial, (1 - icwtr) * carea | 12.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>
