whep (development version)
-
A positive trade record now outranks a CBS zero, and
build_commodity_balances()gainstrade_zeroto select the old behaviour (#866). Tier 1 of the trade imputation fills the CBS from the crosswalked FAOSTAT/FishStat record withdplyr::coalesce(), which replaces only a missing value, so a CBS row carrying0used to keep the zero even when the record for the same(year, area_code, item_cbs_code)carried a positive quantity. Such a zero is not an observation. Measured at 2010 on the real pins, of the 4,493 import conflicts 4,129 come fromfaostat-fbs-newcarrying FAO flag"I"(imputed), 328 fromfaostat-fbs-oldcarrying the legacy"S"(standardized) and 36 from WHEP’s own mean of non-primary sources; neither food-balance vintage carries a single official"A"on an import or export row in 2010. FAO’s Statistical Standard Series: Observation Status Code List, Version 4 (endorsed by DCG-T on 10 July 2025) states that a missing value FAO replaces with a 0 because the phenomenon is assumed negligible is flagged"I", not"N – not significant". The default is nowtrade_zero = "prefer_record";"keep"reproduces the previous build. A non-zero CBS value is never overwritten and a zero trade record never overwrites anything, so the fill can only add trade.Published values move. Measured on the real pins, one build per year with everything else held fixed:
year published cells changed new keys dropped keys areas items 1990 76,543 17,020 (22.2%) 13,728 48 153 105 2010 104,229 26,538 (25.5%) 21,642 66 180 108 2020 109,709 31,924 (29.1%) 26,246 128 176 107 Most of the movement is keys that appear at all: a zero import is dropped by the final balance, so filling it makes the row exist. Element totals, in Mt and as a share of the element:
element 1990 2010 2020 import +4.41 (+0.61%) +9.70 (+0.61%) +3.98 (+0.19%) export +8.66 (+1.22%) +10.57 (+0.72%) +5.14 (+0.27%) food +0.85 (+0.03%) +3.67 (+0.08%) +2.19 (+0.04%) production +0.30 (+0.00%) +1.54 (+0.01%) +2.66 (+0.01%) stock_variation −4.87 (−1.48%) −3.77 (−0.78%) −1.37 (−0.26%) The tonnage concentrates in two items in every year: CBS 2657 “Beverages, Fermented” (5.75 Mt of added imports at 2010) and CBS 2764 “Marine Fish, Other” (2.31 Mt at 2010, 2.38 Mt at 2020), the two whose FBS trade column is effectively unpopulated. Denmark, Norway, Lebanon and the USA lead the areas. Production and stock variation move because a filled export on a row with no production has to be covered by the balancing cascade; that is a real cost of the choice and it is why
"keep"remains available.The supply-use identity is unaffected.
check_supply_use_balance()on the wide CBS at 2010 returns a total absolute imbalance of 370.65 Mt under both settings — identical, not merely close. The count of rows flagged at the 1e-6 absolute tolerance rises (2,073 of 17,694 to 3,550 of 20,512) purely because there are 2,818 more rows and 197 previously exact rows now carry floating-point residue: every newly flagged row’s imbalance is under 0.5 t, and the worst row in the dataset is 1.11e8 in both builds. The 370.65 Mt of pre-existing imbalance is unchanged by this and is not what this entry is about. biomass_coefsno longer ships the three spreadsheet section headers of the upstream workbook (#752).TRANSFORMED PRODUCTSandAGRO-INDUSTRY BYPRODUCTSwere entirely empty rows.ANIMAL PRODUCTSwas worse: its only seven populated cells hold 2, 4, 3, 5, 6, 7, 8 — the column-index vector theCoefssheet’s VLOOKUPs address by absolute position — which read as data claimed anEdible_portionof 4.0 and 3 kg of nitrogen per kg of fresh matter, i.e. 18.75 kg of protein per kg. They are now dropped indata-raw/harmonization_tables.R, sowhep::biomass_coefshas 418 rows rather than 421 andEdible_portionsatisfies(0, 1]outright. No published value changes: the two empty rows carried no coefficient at all, and noitem_cbs_codeinitems_fullbridged toANIMAL PRODUCTS(asserted intest_biomass_coefs_hygiene.R), whichbuild_food_supply()already filtered defensively. The remaining part of #752 — 75 rows whose proximate constituents exceed their own dry matter — is untouched and stays a coefficient question for the data owner.get_faostat_data()now resolvesISO3_CODEfrom [polity_area_crosswalk] instead of FAOSTAT’s vendoredFAOcountryProfilename table, and the hand-maintained fix block is gone (#541). The profile table is stale relative to the labels FAOSTAT publishes today, so eight of the 211 FAOSTAT area names inregions_full$FAOSTAT_namecame out withISO3_CODE = NA: three current reporters —Eswatini(SWZ),North Macedonia(MKD) andChina, Taiwan Province of(TWN) — and four former areas that appear in the historical series —Belgium-Luxembourg(BLX),Ethiopia PDR(ETH),Sudan (former)(SDN) andUSSR(SUN). All seven now resolve; theChinaaggregate (area 351) still resolves toNAby design (#158, #313). Coverage over those labels goes from 203/211 to 210/211, with zero disagreements on the codes both routes resolved. No published WHEP value changes:get_faostat_data()has no caller inR/,data-raw/or the vignettes, and every pipeline ISO3 bridge already went through the crosswalk. A user who grouped its output byISO3_CODE, however, was silently dropping Eswatini, North Macedonia and Taiwan. The codes are maintained upstream in whep-polities rather than in whep, so the one place the two routes disagreed —US Minor Is., which the FAOSTAT profile gave the non-ISOPUS— now takes the ISO 3166-1UMI.Reconstructed fodder rows no longer claim to be FAOSTAT (#937).
build_primary_production()’s yield step pivoted units into columns and dropped thesourcethe fodder chain had already resolved, so.impute_missing_values()re-derived provenance from the mere presence of a tonnage and labelled every fodder row"FAOSTAT_prod". That was a false claim for temporary grassland in particular: CBS 3002 (production item 996) appears in neither FAOSTAT production pin — its hectares come from theeu-agridb-fodderpin and its tonnage from the EU AgriDB nitrogen yield or a dry-matter estimate — yet all 494 rows of it in a 2001–2023 build read as FAOSTAT. The source is now carried through, so those rows read"EuropeAgriDB"or"DM_yield_estimate".sourceis used to rank competing sources, so a(year, area, item, unit)cell contested by a genuine FAOSTAT row and a reconstructed fodder row now resolves to the FAOSTAT one; hectares and tonnages themselves are unchanged (see the PR for the measured diff)..deduplicate_doubles()told the two copies of a double-product key apart by asking whethersourcewasNA, which only held while the yield table had no source column at all; it now names the copy explicitly, so seed cotton, oil palm fruit and flax keep their series.-
A source that keys a territory to an area code WHEP’s vocabulary does not report in that year now says so, and can never have a CBS row created for it (#884). FishStat splits Belgium out as area 255 from 1976, while every FAOSTAT product — the balance sheets and
faostat-trade-totalsalike — keys that territory 15 (Belgium-Luxembourg) until 1999 and splits 255/256 only at- Those 459 FishStat rows (6,948.4 kt over 1976-1999) can never join a CBS keyed 15, and creating the 255 row instead would put two overlapping reporting areas on one territory-year rather than fill a gap. Reading any input now warns when it reports an area outside that area’s own reporting window (
options(whep.warn_area_vintage = FALSE)silences it), thetrade_recovery = "net_import"recovery of #864 drops such keys explicitly instead of relying on its area-label join to hide them, and.cbs_bind_recovered()aborts if one reaches it. No published value changes: measured over every CBS-relevant pin, FishStat’s Belgium 255 in 1976-1999 is the only off-window area-year in any input, and none of those rows lands on a CBS row today. The check asks whether any reporting area lands on the bucket that year rather than whether the year is inside the bucket’s own window, so the deliberate folds are not flagged: bucket 238 is reported by area 62 (Ethiopia PDR) until 1992 and bucket 206 by areas 276/277 from 2012. The territory is not unrepresented either — the Belgium-Luxembourg balance sheet already reports 11.93 Mt of fish imports and 3.33 Mt of exports for those same 11 items and years, on every one of the 264 keys, so folding FishStat’s Belgium rows into bucket 15 would recover 0 t under the currentcoalesce()fill policy. Which vocabulary the two sources should share stays an open harmonization decision in #884.
- Those 459 FishStat rows (6,948.4 kt over 1976-1999) can never join a CBS keyed 15, and creating the 255 row instead would put two overlapping reporting areas on one territory-year rather than fill a gap. Reading any input now warns when it reports an area outside that area’s own reporting window (
The
historical-land-areaspin is regenerated on the current polities snapshot (#934). The pin is static per LUH2 vintage and polities snapshot, and the deployed20260818T110621Z-08814predated the #906 re-sync: PR #914’s guard named 7 polity codeswhep::politiesno longer has (CIV-1893-1900,GHA-1898-1956,HUN-1918-1919,KEN-1902-1906,LAO-1893-1953,SEN-1886-1959,TCD-1920-1960) and 256 bucket-years over 8 buckets whose territory label had moved. The replacement20260826T154952Z-48539was built bydata-raw/historical_land_areas.Rover 1850-1961 (25.8 min) against LUH2UofMD-landState-LUH2-GCB2022,polycell_support20260825T102349Z-1a0eband the 781-row snapshot; the guard names nothing on it and still fires on the old version. Published values change on the opt-inland_method = "historical_polity"path only (the default"present_day"never reads this pin). Summed over all 18,083 bucket-years the two pins share, agriland moves +0.087% (264,828 -> 265,059 Mha of bucket-years); 510 bucket-years over 50 buckets move at all, and 104 bucket-years that the old pin could not measure are now measurable (Cabo Verde and Suriname 1850-1884, Chile 1850-1882, Côte d’Ivoire 1900). Individual bucket-years move much more than the total: Estonia 1918-19 +98.5%, Poland 1919 +92.8%, Latvia 1918-19 +87.6% and Tunisia 1875-80 +84%, all because the polycell support they are measured against was itself rebuilt (20260825T102349Z-1a0eb, 721 polities against 685). Largest whole-bucket shifts are Tunisia +9.08%, Kenya -1.21%, Poland +0.97% and Ethiopia +0.65%. The.example_historical_land_areas()fixture is refreshed to match, moving three of its ten rows (itsagrilandstays the sum of the rounded parts, as its test asserts).The polycell support’s
area_codenow holds a reporting area code rather than a matrix bucket, soarea_code-keyed callers can join to it (#907).build_polycell_support()labelled each polycell withpolity_area_crosswalk$polity_area_code— the coarser key the matrix workflows aggregate on, which folds Sudan with South Sudan onto 206 and 113 reporting areas into 999 (Rest of World) — and called the columnarea_code. Every consumer of the polycell grid joins it to a table keyed on the reportingarea_code:country_areasin both spatialize engines, and the national nutrient tables in the carbon and nitrogen paths. Measured on the deployed pin at 2015, that mismatch left 7 reporting areas with no cell in the grid at all, carrying 18.75 Mha of harvested area — 1.37% of the global total: Sudan (12.06 Mha), Syria (3.83), South Sudan (2.17), North Macedonia (0.34), Eswatini (0.16), Palestine (0.11) and Equatorial Guinea (0.08). Their whole national total was dropped, and the 423 Mha of grid land sitting under 206 and 999 could receive nothing. Both the producer (.pcs_area_code()) and the carbon path’s seam (.carbon_support_to_area_code(), which every carbon, SOC, LUH2 land-use and spatialize consumer resolves its grid through) now take the reportingarea_codeoff the same crosswalk row, so a pin published before this change is corrected on read. Values move: at 2015, 1,205 polycells holding 285.42 Mha of land are re-keyed — 206 splits into 276 (186.25 Mha) and 277 (62.84), and 999 releases 212 (18.57), 69 (8.39), 61 (2.70), 154 (2.50), 153 (1.88), 209 (1.70) and 299 (0.61) — and the grid goes from 195 to 203 distinct codes at 2015 (201 to 210 over every interval). Total land is unchanged to the hectare and the per-cell land shares still sum to one, because the re-key only relabels and un-folds. A polity with no reporting region of its own (Greenland, Western Sahara, most dependencies) and a residual aggregate that answers for many (ROW, the historical Ethiopian polities) keep their bucket code — that is the only home the reporting vocabulary gives them, and it is never used as a fallback for a lookup that merely failed.polities_catsno longer folds Bhutan and Comoros into rest-of-region aggregates, so it agrees withregions_fullin every shared cell (#395). The two shipped tables disagreed on four columns for area 18 (Bhutan) and 45 (Comoros):polities_catsfiled them asRASI“Asia Other” andRAFR“Africa Other” withcbsFALSEandfabio_code999, whileregions_fullmodels both individually. The fold’s stated reason – neither country had a commodity balance sheet when the table was compiled – is refuted by the pin WHEP reads:faostat-cbs-newcarries 175 rows for Bhutan (2019-2023, 12 items) and 237 for Comoros (2010-2023, 10 items). The fold was also never coherently applied, since both rows keptpolity_area_code18 and 45 andreporting_polity_codeBTN-1800-2025/COM-1975-2025, unlike the four areaspolities_catsreally does fold (American Samoa, Andorra, Saint Pierre and Miquelon, Anguilla), which carrypolity_area_code999. Eight cells ofpolities_catschange; no published value changes, because no package code readspolities_cats– it is documentation and a registry entry, andregions_full, which the pipeline does read, is unchanged. Separately measured and left alone:regions_full$fabio_codeis not a copy of FABIO’s published region list in either direction. FABIO v1.1 (io_codes.csv, Zenodo record 2577067) publishes 191 areas plusRoW; eightregions_fullareas carry a non-999fabio_codeFABIO folds intoRoW(18, 45, 127, 145, 148, 196, 219, 227) and four carry 999 for areas FABIO models (153, 154, 209, 212, which is #556). WHEP’s country set is its own choice (#459), so that census belongs to #556 and is not decided here.build_water_balance(bands = )no longer repartitions the whole cell’s evapotranspiration (#916).bandsis documented to restrict only the per-CFT terms and to leave the water budget untouched, but the defaultblue_green = "cft_native"split was computed from the selected bands’ consumptive water and then applied to whole-cellaet_mm, so asking for one crop changed the cell’s blue/green AET. On the package’s own synthetic fixture, requestingbands = "rainfed grassland"movedaet_blue_mmfrom 160 to 120 mm/yr (-25%) andaet_green_mmfrom 560 to 600 mm/yr. The split now always uses the all-band totals, whileblue_consump_mm,green_consump_mmandcft_nir_mmstay band-restricted as documented. No published values change: nothing in WHEP passesbands, so every current caller takes thebands = NULLpath, where the two totals are identical. Thebandsdocumentation now also states that it is the only route to per-crop water today, that nothing consumes those per-crop numbers yet, and that their blue/green split is unusable on an LPJmL 6.x run without the green/blue fix.regional_mms_distributionis now traced to the source that would replace it, its live reach is stated correctly, and its values are locked (#921). No published value changes: nothing was revalued. The manure management shares this table ships are an unsourced placeholder, round to five percentage points, matching no table of the GLEAM 3.0 workbook — and they are live, so their reach is now documented exactly: through.resolve_mms_shares()they weight the Tier 2 manure CH4 methane conversion factor and the Tier 1 manure direct-N2O emission factor. They do not reach Tier 2 direct N2O, whose rows carry noregioncolumn and therefore take the pasture EF3 for every stream — the previous@sourcesaid they did. The real shares are published in Supplement S1, Tables 4.2-4.11 of FAO. 2018. GLEAM Model description, Version 2.0, Revision 5 (workbook md572fd2ea477dfe8b30cd3657b2baa4af1, re-downloaded from FAO and verified). Adopting them takes four crosswalk decisions (collapsing GLEAM’s per-production-system tables ontospecies_gen, its 10 regions onto the IPCC labels, deriving aGlobalrow GLEAM does not publish, and mapping its richer MMS vocabulary ontomms_type), so it is left to the maintainer; one illustrative crosswalk, measured on FAOSTAT 2020 head counts, moves Tier 1 manure direct N2O by -11.1% (Buffalo -57%, Poultry +22%), Tier 1 manure CO2e by -4.2% and Tier 2 manure CH4 by -26.9%. A new test locks the two effective factors the table feeds per (region, species), so a future revalue has to be deliberate.The LUH2 v2h calendar-year → time-index resolution is now one shared, tested helper, and clamping past the end of the record always warns (#256). Four places computed the index independently, with three different policies for a year the LUH2 record does not reach:
.read_luh2_year()inprepare_spatialize_all.Rclamped and warned;.luh2_country_totals()and the gridded-pasture reader clamped silently, so the publishedgridded_pasture.parquet,gridded_cropland.parquetandtype_cropland.parqueteach carry a 2023 that is bit-identical to their 2022 with nothing in the log saying so; and_gen_type_cropland.Rcomputedyr - 850 + 1bare, aborting insidencdf4::ncvar_get()withNetCDF: Index exceeds dimension boundon any vintage shorter than itsyear_range(the base v2h release stops at 2015, the pinned GCB2022 vintage at 2022). All four now callwhep:::.luh2_clamped_time_idx(), which warns naming the year it is reusing, and aborts for years before 850 CE, where there is no slice to reuse. No published value changes: index 1 = year 850 was already correct at every site (the states.nc time axis declares “years since 850-01-01” withtime[1] == 0), and the resolved index sequence is identical for the full 1851–2023 pipeline span on the reference GCB2022 vintage. The 2023 land surfaces still repeat 2022 — that is the documented intent, since FAOSTAT extends past LUH2 — it is merely audible now.The IPCC provenance of the Tier 2 sheep and goat coefficients is now recorded, and the goat values are locked by tests (#249). No published value changes:
ipcc_2019_cfiandipcc_tier2_energy_coefsalready hold the goat rows of the 2019 Refinement (Vol 4, Ch 10, Table 10.4 (Updated) Cfi = 0.315 and Table 10.5 (Updated) lowland-goat Ca = 0.019), verified against the published PDFs of both editions. What was wrong was the documentation:data-raw/livestock_coefficients.Rasserted that the 2019 Refinement “does NOT change these from the 2006 values”, when the 2006 Table 10.4 has no goat row at all — the belief that produced the original copy of the sheep Cfi 0.217 onto goats, and again of the sheep Ca 0.0107 in a later attempted fix.@sourceforipcc_tier2_energy_coefsnow names the table each column comes from and states that the storedca_pastureis the lowland/flat-pasture default, not the published hill and mountain goat coefficient (0.024).test_datasets.Rlocks all four sheep/goat cells.The offline cache fallback now finds a cached copy when the version was not pinned to a concrete string (#245).
whep_read_file()and theread_raw_inputsreaders fall back to the localpinscache when the board is unreachable, and located it by hashing the version URL. Forversion = "latest"(and for a registry entry that freezes no version) the version reaching that point isNULL, so the hashed URL was.../alias//, which matches no directory the download ever wrote: every such request aborted with “No local cached copy was found either” no matter how much was cached. The concrete version is now recovered from the cache itself — each cached directory’sdata.txtnames itscreatedtimestamp andpin_hash, and the directory is named after the hash of the resulting version URL, so the attribution is exact — and the newest cached version wins. Measured on a populated cache of 73 registry aliases,version = "latest"resolved 0 before and 70 after; frozen-version lookups resolved 70 both times, so no previously working lookup changes..pins_cache_base()also honoursPINS_CACHE_DIR,PINS_USE_CACHEandR_CONFIG_ACTIVEthe waypinsitself does, instead of always assuming therappdirsdefault. No published value changes: the same pin contents are read, only reachable offline now.get_primary_production(land_method = "historical_polity")now warns when thehistorical-land-areaspin was measured on a differentwhep::politiessnapshot (#905). The pin is static per LUH2 vintage and polities snapshot, and nothing rebuilds it when the snapshot is re-synced. The only guard it carried was on year coverage, which cannot see the failure that actually happens: a re-sync leaves every year and every reporting bucket in place and changes the territory each row was measured inside. No total or identity over the pin can detect that either — the land still adds up, on redrawn borders. The check is therefore on the input the pin records rather than on its numbers: the pin carries, per bucket-year, thepolity_codeit was measured on, and that label is compared with what the resolver returns today. Against the deployed pin20260818T110621Z-08814it names 7 polity codeswhep::politiesno longer has (CIV-1893-1900,GHA-1898-1956,HUN-1918-1919,KEN-1902-1906,LAO-1893-1953,SEN-1886-1959,TCD-1920-1960) and 256 bucket-years over 8 buckets whose label has moved. No published value changes: the defaultland_methodis"present_day", which never reads this pin, and the warning changes no number on the opt-in path. A rebuild of that path on the current snapshot and the currentpolycell_supportpin moves total pre-1962 agriland by +0.003% but individual bucket-years by up to +82% (Tunisia 1850, 1.366 → 2.486 Mha), so regenerating the pin is still outstanding.New: row-level evidence, as data (#372).
row_evidence()records what each row of a result rests on — the producing source’s immutable identifier, its version, a UTC timestamp and the producer’s documented per-row evidence fields — as a keyed sidecar table in the"whep-row-evidence/1"format, whose contract is returned byrow_evidence_schema()and can be proved withassert_table_schema(). It is a table rather than an attribute becausedplyrjoins, filters andsummarise()drop attributes silently, so attribute-borne evidence cannot survive the composition it exists to document.evidence_for()re-aligns evidence onto a table after a join and warns about rows that carry none,combine_row_evidence()merges producers deterministically without letting one overwrite another, andevidence_conflicts()reports where they disagree. This is the row-level counterpart of the dataset-levelrecord_provenance(); no existing output or published value changes.Two of the five untraceable GLEAM tables now name the FAO table they were meant to come from, and the other three record what was ruled out (#881). Documentation only: no data value changed and no published number changes. #607 left five
gleam_*tables marked “not traced”. The document that was missing is the supplementary workbook of the 2.0 model description (GLEAM_2.0_Supplement_S1.xlsx, md572fd2ea477dfe8b30cd3657b2baa4af1): GLEAM 3.0 dropped the regional herd-parameter and manure-management table families, which is why the committed 3.0 workbook has no sheet for them.gleam_animal_weightsis published there, in the live-weight block of Tables 2.4-2.16, andgleam_mms_sharesin Tables 4.2-4.11. Neither matches the shipped values – Western Europe dairy cows are 594 kg, not 650, and dairy bulls 773 kg, not 1000 – and neither has been re-ingested here, becausegleam_animal_weightsis the Tier 2 live weight and swapping it moves gross energy, hence Tier 2 enteric CH4, by -16.4% to +14.2% depending on the cohort (measured withestimate_energy_demand(), one cohort at a time, everything butweightheld fixed).gleam_milk_productionandgleam_feed_categoriesappear in no GLEAM publication at all: milk yield and lactation length are country inputs in both model descriptions, and GLEAM groups feed materials as Roughages/Cereals/By-products/Concentrates, not the six-way split shipped here.gleam_livestock_categoriestakes its cohort vocabulary from GLEAM Table 2.1 but not its Dairy/Beef layout, and sincecalculate_cohorts_systems()splits a herd1 / n_cohorts, that layout is itself a result-affecting assumption.regional_mms_distribution, which is live in the manure chain and was annotated “GLEAM 3.0 / FAO statistics”, is now marked unverified too. A guard test locks whichgleam_animal_weightsregions actually resolve, so a rename cannot silently widen the placeholders’ reach.New
empty_table_from_schema()builds the typed zero-row table a declarative schema describes (#374). It is the constructor half of the schema family added bycheck_table_schema()/assert_table_schema(): given the same serializable schema, it returns a tibble with the declared column names, in the declared order, each of the declared type, and no rows. Both functions resolve the schema through one parser, so the prototype passes the validator by construction; a schema withallow_empty = FALSEis rejected instead of returning a table that would fail its own validation, and an"any"column is built aslogical(), the type of a bareNA. This closes the gap that madeensure_columns()unusable from a schema — it needs a prototype tibble and cannot be handed a schema — so a schema stored as YAML beside an artifact can now drive both completion and validation. No published value changes: nothing in the pipeline calls it yet.population_source_reach()gains anextent_exceeds_iso3column, marking the reporting periods whose reported ISO3 codes cover LESS GROUND than the territory they stand for (#863). The successor walk stops a branch as soon as it lands on an ISO3 the caller’s vocabulary carries, which assumes a polity’siso3_codeis coextensive with its territory. For ten polities the shipped snapshot publishes, it is not: upstream declares a partition in which the parent keeps the ISO3 of one part, so the walk stops on the parent and never sees the others.SRB-2006-2008is “Serbia (including Kosovo)” carryingSRB;SUD-1956-2011is Sudan-with-South-Sudan carryingSDN;PAK-1949-1971is Pakistan-with-East-Pakistan carryingPAK. Seven reporting periods across five area codes (165, 186, 200, 206, 248) are flagged, and four of them readreach = "direct", where nothing else about the row hinted at it. Nothing is repaired and no number moves: whether the walk should descend past such a node is a methodological choice, and the two consumers want different answers, so this reports the shape and leaves the choice open. Measured on the same real data:build_primary_production( federation_land = "successor_union")is unaffected either way, because only a bucket with no LUH2 row of its own enters the walk (seven of them) and each already resolves to a complete part set. Two statements this file’s own documentation used to make were also wrong and are corrected: the polities database does publishKOS-2008-2025as a successor ofSRB-2006-2008, and Kosovo accounts for 42% of the Yugoslav SFR’s 17.5% successor-sum shortfall, not all of it — the other 10.1% is thegdp-populationpin’s Yugoslav level disagreeing with UN WPP’s own seven territories.build_fao_arable_fallow_extension()now warns where fodder crops take more than half the arable land it distributes, and the new exportedcheck_fodder_land_share()reports the share per country-year (#356). No published value changes: the check is warn-only and nothing is rescaled, dropped or reweighted. What it makes visible is that with the defaultfallow_weights = NULL— the pathbuild_land_balance_footprint()takes — the reconciliation is a pure proportional rescale of each arable crop’s cropped physical area up to FAO Arable land, so every crop’s share of the published per-crop arable land footprint is exactly its share of the input harvested area. Fodder harvested area is reconstructed rather than surveyed (dry-matter-yield imputation, EU AgriDB splicing, linear filling), and it carries 11.9% of the global arable land footprint pooled over 1850-2023 — in 1271 of 29 378 country-years more than half of it, and in 102 country-years the fodder harvested area alone exceeds the country’s entire FAO arable land (Australia 1961-1994 up to 1.57x, Paraguay up to 2.23x, Ireland 1.78x, Portugal 1.77x). Every hectare over-attributed to fodder is a hectare taken off the ordinary crops in the same country-year. Supplyingfallow_weightsfromgridded_fallow_weights()makes the fallow split independent of it.inst/scripts/prepare_spatialize_all.Rno longer divides the HaNi N-deposition raster by a fixed1e7, and reads it throughread_n_deposition()instead of its own terra path (#259). HaNi’s NetCDF declaresunits = "g N"andlong_name = "NHX-N deposition to land within the grid cell": an extensive mass per 5-arcmin cell, not a density. The retired code got both consequences wrong. It aggregated to 0.5 degrees withterra::aggregate(fun = "mean"), which leaves grams per native cell rather than the additive block mass, and then divided by1e7— 1000 g/kg times an assumed 1e4 ha per native cell, i.e. every 5-arcmin cell taken as exactly 100 km². On the WGS-84 sphere used everywhere else in the script a 5-arcmin cell is 8586 ha at the equator and 4288 ha at 60°, sodeposit_kg_n_hacame out low everywhere and progressively worse toward the poles. On real 2014 HaNi (NHx + NOy, source mass 63.006 Tg N), the old rates reconstructed 44.611 Tg — 29.2% of the deposited nitrogen lost — against 63.006 Tg (100.0000%) now. Per cell the rate rises by 16% in the Congo basin (0°N), 53% in the US Corn Belt (40°N), 90% in the Netherlands (52°N) and 178% in northern Finland (65°N). The same delegation also corrects a one-year shift: the old code derived the year from terra’s layer names as1851:2021, while the file’stimeaxis is0:170onunits = "years since 1850-01-01", so the series is1850:2020. This moves then_deposition.parquetand thelpjml_inputs/nitrogen/NHx and NOy forcing that the script writes; the cache gate now keys on amethod_depositioncolumn so an existing parquet from before this change is re-extracted rather than silently reused. It does not move any published package value:read_n_deposition()andbuild_n_deposition()inR/already block-summed the mass and divided by the truecell_area_ha, and the nitrogen balance reads those, not the script.The CBS trade aggregation no longer sums head counts into its tonnes-denominated
valuecolumn (#865)..aggregate_fao_trade_to_cbs()crosswalked the FAOSTAT trade record onto CBS items and summedvaluewithout looking atunit, which.read_fao_trade()does carry. FAOSTAT reports live animals inAn/1000 Anand bees inNo, andwhep::cbs_trade_codesmaps those onto the 20 live-animal CBS items, so head and colony counts landed in the same column as mass: 135.3 M of them against 2.85 Gt of tonnes at 2010, and between 4.3% and 11.6% of that column’s total in every year from 1961 to 2023 (the peak is 2015, where 269 M bees alone are 7.4% of it).unitis now part of the aggregation key, and the new.mass_only_trade()reduces the record to its mass rows —tfrom FAOSTAT trade,tonnesfrom FishStat — before the CBS trade imputation reads it, warning with the unit, item count and total it drops rather than discarding them silently.1000 Anis also rescaled toAn, which.normalise_units()did not do: without it broiler, duck, turkey, rabbit and rodent trade is a thousandfold low in its own unit, and CBS item 1150 (1000 Anup to 2013,Anfrom 2014) carries a 1000x step mid-series. No published value changes: verified at 2010 on the real pins, the raw CBS, the long CBS (82,587 rows) and itssum(value)(62,732,362,270) are identical before and after, because the CBS row set carries no live-animal rows for the head counts to land on — the mixing was latent. The.live_animal_cbs_codes()exclusion added fortrade_recovery = "net_import"stays, for its independent reason (it would duplicate theget_livestock_cbs()key), but the unit filter is now the wider of the two guards: it also catches CBS 1171 (“Animals live nes”), reported inAnbut typedotherand so absent from that list.New
read_fbs_population(), and a third opt-inpopulation_sourceforread_population(), so a dissolved reporting area can have a denominator at all (#862, #787). Both population sources WHEP had are keyed on a present-day ISO3 code — thegdp-populationpin has an ISO3 column, UN WPP publishesCountry/Arearows carrying one — so neither can reach a territory that no longer exists. Measured on the real inputs, the pin’sYUGstops in 1991 and itsSRB/MNEstart in 2006, and UN WPP 2024 has noSCGrecord in any year, so area 186 Serbia and Montenegro carries 110 Mt of commodity-balance food over 1992–2005 with no population row of any kind; area 151 Netherlands Antilles is the same shape over 1961–2010. The two per-capita consumers inner-join the denominator, so those area-years are absent frombuild_food_supply()andbuild_n_percapita()rather than wrong in them.read_fbs_population()reads item 2501 “Population”, element 511, from thefaostat-fbs-oldandfaostat-fbs-newpins — the same two pins the food numerator already comes from. It is keyed on the FAOSTAT area code rather than an ISO3, and FAOSTAT keeps a dissolved reporting area alive for the years it reported, so it covers area 186 for exactly 1992–2005 and area 151 for exactly 1961–2010. Bucketed ontopolity_area_codethe year-aware way it covers 195 areas over 1961–2023, and no bucket-year receives more than one FAOSTAT area, so nothing is double counted; FAOSTAT’s 42 regional and grouping aggregates resolve to no polity and are dropped. No published value changes:read_population()still defaults topopulation_source = "pin", and the new"pin_wpp_fbs_fallback"is anti-joined like the existing WPP fill, so it can only add a denominator that was missing, never move one that was published. It stays opt-in because the sources disagree on the value and not only on the coverage — for area 186 in 2000 FAOSTAT gives 10,801,000, a UN WPP 2024 territorial sum for the same ground (SRB + MNE + XKX) gives 10,104,000, 6.5% lower, and theSRB + MNEsum a successor walk can actually reach today gives 8,311,000, 23% lower, because WPP publishes Kosovo separately and it carries no WHEP area code (#863). Which of the three a dissolved federation should be given is a science decision the maintainer has not made.build_urban_n()now requires the numeric WHEParea_codeon the frames it is handed, and checks it at the input boundary instead of after transport (#597). The function builds the transport allocator’sterritorykey itself, asas.character(area_code)of bothdata$cell_polityanddata$cropland_ha, and used to resolve that key back to a numericarea_codeonly in.urban_finalise()— afterallocate_manure_transport()had already partitioned on the unresolved string, and through the manure chain’s deprecated ISO3 bridge. Two things were wrong, and neither is visible to a schema census or even to anarea_codecensus, because the output columns and the output codes are identical either way and only the cell the nitrogen lands on moves. First, the check ran after the partition it keys: two frames written in different vocabularies for the same polity ("ESP"in one,203in the other) producedterritorykeys that never met, so a source cell found no reachable sink and its whole load stayed as residual on its own cell even when that cell had no cropland at all — then was relabelled203anyway, with no warning of any kind, because the ISO3 never reached a resolver. Second, an ISO3 was accepted at all: it resolves to apolity_area_codeaggregation bucket that is not every territory’s own code, so"SSD"became 206, Sudan (former). Both frames’area_codemust now be the numeric code, whole-numbered, asbuild_cell_polity()emits it; an ISO3, an area name or a fractional value aborts with classwhep_urban_area_code_unresolved, naming the frame that carries it, before any computation runs — rather than surfacing as adplyrmutate error about aterritoryfield no caller ever supplied. Callers keyed on ISO3 should map to the code first, withadd_area_code()orregions_full. No published value changes: thespatialize-cell-polity-fractionpinbuild_cell_polity()reads is integer-keyed, and the boundary check is the exact identity on all 178 grid area codes it carries (172 underarea_key = "polity_area") and on every non-NAregions_full$code— asserted over the whole vocabulary in the tests. Nothing in the package, its scripts or its tests passed a non-numericarea_codeto this function.validation/lpjml_forcing_pins.R: the climate-forcing pins now have an impossibility check, and it records #824 rather than suppressing it.validation/lpjml_pins.Rguards the four pins carrying LPJmL output and deliberately excludes the forcing pins, reasoning that forcing does not change with the model version. True of its magnitude tier, false of its invariant tier: forcing can still be corrupt.lpjml-rsds-era5-2017-2023ships 1,823,843 negative shortwave values because #536 fixed the builder and nobody rebuilt the artifact, and nothing in the repo could see it — that script excluded the pin, andtest_data_raw_freshness.Rgatesdata/*.rdaagainstdata-raw/, not a pin against its generating script. A census of all six forcing pins shows the defect is confined to that one:rldsandwindcome off the same builder and the same ERA5 tail and are clean, because shortwave is the only one of the three that legitimately reaches exactly zero. The floor is therefore inclusive —lpjml-rsds-isimip-1901-2019has a minimum of exactly 0, so a positivity test would fail on a clean pin. The recorded count is compared bidirectionally: a rise is a new corruption, and a fall means the pin was rebuilt, which is the event #824 exists because nobody noticed. Refs #824, #536, #384.A missing
wateroricelayer no longer zero-fills silently inbuild_polycell_support()(#885). Both arguments default toNULL, the column is filled with zeros, and the producer’s identitypolity_area_ha == land_area_ha + inland_water_ha + ice_area_hastill holds — so every check passes while every lake, river and glacier inside a polity is booked as land. The deployed pin20260818T105426Z-a0330was built that way, confirmed by reading it: all 482,605 rows carryinland_water_ha == 0andice_area_ha == 0, andland_area_haequalspolity_area_hain every one, which puts 2015 land 536.0 Mha (+4.15%) above the pin built from all four layers..pcs_add_water()and.pcs_add_ice()now warn, on distinct condition classes (whep_polycell_absent_water,whep_polycell_absent_ice), naming the consequence rather than the absence. Zero-filling still happens — this changes no value, only its visibility — and remains correct for a smoke build. Does not fix the deployed pin, which needs regenerating with all four layers. Refs #885, #802.-
The three polity tables are re-synced to upstream
whep-politiesagain, and this time the re-sync closes an identity gap rather than moving one (#890, #458).polity_area_crosswalk,politiesandpolity_label_aliasesare committed build products ofdata-raw/table_mappings.R, whose inputs live outside this repo, so they drift between re-syncs andtest_data_raw_freshness.Rhad begun failing on exactly that. They are rebuilt here fromeduaguilera/whep-politiesate10c7421(“Merge pull request #597 from eduaguilera/docs/state-inventory-dead-name”), that repository’smain.What moved.
politiesgoes 779 → 781 rows: two additions, no removals, and both are territories upstream previously modelled as no polity at all —SXM-2010-2025Sint Maarten andBES-2010-2025Bonaire, Sint Eustatius and Saba. With themANT-1961-2010Netherlands Antilles finally publishes asuccessor(CUW-2010-2025; SXM-2010-2025; BES-2010-2025) and apredecessor(ANT-1816-1961);CUW-2010-2025gains its predecessor too. Beyond that the 779 shared rows move in 5polygon_sourcevalues, 2polygon_status(assigned→proxy) and 6polygon_area_km2, and in nothing else —last_ingestdoes not change on a single shared row, so the snapshot’s new maximum of 2026-08-24 is carried by the two new rows alone. The crosswalk goes 649 → 648 rows over the same 267 areas and one more polity (560 → 561): Sint Maarten used to fall back to BOTH Dutch periods (NLD-1800-1830andNLD-1830-2025) and now resolves to the single polity of its own, which is whyprefix_fallbackgoes 27 → 26 and thenot_a_reporting_arearows go 20 → 19 while still covering the same six territories.polity_label_aliasesgoes 995 → 1,003: eight newfao1952labels, plus two rules re-pointed off the joint Ruanda-Urundi entity onto its constituents —burundi | iia | 1922-1961fromRWB-1922-1962toBDI-1922-1962“Burundi (within Ruanda-Urundi)”, andrwanda | mitchell | 1953-1960toRWA-1922-1962.No published value moves, and no territorial identity moves either. Over the full
(area_code, year)grid 1850–2023 (46,284 pairs),polity_code,polity_area_code,mapping_statusandhas_geometryare unchanged for every single pair — the previous re-sync moved 126 of them, this one moves none.polity_mapping_provenance()over the crosswalk’s own grid reports the same 46,816 resolutions in the same fivemapping_sourceclasses and the same fourauthorityclasses. A realget_primary_production()returns the same 6,310,171 rows × 12 columns with zero keys added and zero removed, and all twelve columns —valueincluded, at 5.7635e12 summed — agree row for row. Onearea_codestill carries exactly onearealabel (whep#563), on 0 violations.Two behaviours do change, and both are the gap closing. Area 151 Netherlands Antilles stops being unreachable:
population_source_reach()now classifies it"successor"reachingBES, CUW, SXM, and no reporting area outside the Rest-of-World bucket is unreachable any more. And.dependency_sovereign_iso3()briefly lost Sint Maarten — see the next entry. A crown dependency no longer loses its sovereign the moment upstream gives it a polity of its own (#407).
.dependency_sovereign_iso3()is what letsdependency_land = "sovereign"book Jersey’s, Guernsey’s, the Isle of Man’s, Åland’s, Saint Barthélemy’s and Sint Maarten’s LUH2 land under the state that reports for them, none of the six having a FAOSTATarea_code. It found the sovereign by asking which reporting area shared the dependency’spolity_code— a relation that exists only while the dependency has no polity of its own. The re-sync above gave Sint MaartenSXM-2010-2025, so nothing shared its polity, the bridge silently went from six territories to five, and Sint Maarten’s land went from counted underNLDto counted nowhere. It now falls back tolegacy_polity_prefix, the same ISO3-stem-to- bucket bridge.read_fodder_euadb()uses, which names the sovereign whether or not the dependency has its own polity. The fallback fires only where the polity route finds nothing, so it cannot move an answer that route still gives: the two agree on all five it resolves, and the sixth is recovered. Default behaviour is untouched —dependency_landstill defaults to"drop".-
The
polycell_supportpin is regenerated: the aggregate overlap layer is published, and the inland-water and glacier layers are restored (#873, #885).20260825T102349Z-1a0ebreplaces20260818T105426Z-a0330, and it moves published land areas. Three things change together, because they are one regeneration.Inland water and ice are measured again. The pin published on 2026-08-18 was rebuilt without the optional
waterandicelayers, soinland_water_haandice_area_hawere zero in all 482,605 rows andland_area_haequalledpolity_area_haexactly: every lake, river and glacier inside a territory was booked as land. At 2015, land was 13.4613 Gha; it is now 12.9281 Gha, with 303.21 Mha of inland water and 231.69 Mha of ice measured separately. The largest single movers are Greenland (-178.6 Mha, almost all ice), Canada (-146.2 Mha, 122.6 Mha water and 23.6 Mha ice) and Russia (-64.8 Mha). Consumers that readland_area_hamove with it:read_luh2_landuse(area_basis = "polycell_land"),build_polycell_land_uses()andbuild_carbon_balance(). Most visibly,build_n_deposition()splits deposition between land, inland water and ice again instead of booking all of it to land.The aggregate overlap layer is published. 16,182 rows over 12,438 polycells and the 19 live aggregate polities now carry
support_role == "overlap", soread_polycell_support(role = "overlap")resolves instead of aborting and the ten pre-1962 reporting buckets whose only territory is an aggregate have territory again. The defaultrole = "partition"is unchanged, so no consumer picks the layer up without asking: verified cell for cell at 1850, 1900, 1950, 1961, 2000, 2015 and 2025, where territory, land, water and ice all differ from a partition-only build by 0.The polity table is resynced. The pin is built against
whep::politiesas it stands after the 2026-08-21 upstream sync, which the previous pin predates: 702 partition polities against 685, 29 gained and 12 lost (mostly period-boundary corrections such asSEN-1886-1959toSEN-1886-1960). 2015 territory moves by +1.7 Mha (+0.012%), and 2,613 of 411,397 shared polity-cells move by more than 1 ha, concentrated in the colonial federationsAEF-1910-1960andAOF-1895-1960. Seven of the seventeen EarthStat fertilizer crop codes named the wrong crop, in two independent copies (#889).
.earthstat_fertilizer_mapping()keys each crop-specific fertilizer raster to anitem_prod_code. Compared againstinst/extdata/earthstat_mapping.csv— the same script’s own answer for the same raster on the harvested-area layer — seven disagreed, and only two were detectable as bad codes:cassava340 andcotton274 are absent fromitems_prod, whileoilpalm217,potato328,rapeseed223,sugarcane780 andsunflower222 are all real item codes naming cashews, seed cotton, pistachios, jute and walnuts. A code that exists and names another crop joins successfully to the wrong crop, so an existence check — the obvious guard — passes on five of the seven..read_west_manure_local()carried an independent copy of the same tribble with the same seven errors, so the manure-N layer was misattributed too and a fix to the fertilizer mapping alone would not have reached it; it now reads the one shared mapping. The new guard compares codes against the CSV, asserts existence for rasters the CSV does not carry, and fails if any call site stops sharing the mapping. Regeneratingcrop_fertilizer_patternsis still required for the fix to take effect (same shape as #877). Refs #889, #876, #877, #888.New
population_source_reach()reports which areas a population source keyed on present-day ISO3 can reach, and area 151 is the one it cannot (#787). Both population sources WHEP reads — thegdp-populationpin and UN WPP 2024 — are keyed on a present-day ISO3 code, so neither can carry a territory that no longer exists. This classifies each of the 297 reporting periods inpolity_area_crosswalkas"direct","successor"(the polities database’ssuccessorrelation reaches present-day codes) or"unreachable". Measured against UN WPP 2024 when this landed, 283 were direct, 8 resolved through successors, and exactly one reporting area outside the Rest-of-World bucket was unreachable:ANT-1961-2010, area 151 Netherlands Antilles, which carries commodity-balance food in every year from 1961 to 2010. Upstream published no successor for it and modelled neither Sint Maarten nor the BES islands as polities, so reconstructing it was an upstream identity gap rather than a missing value — and the polity re-sync above closed that gap in this same release, so area 151 now reads"successor"and no reporting area outside the Rest-of-World bucket is unreachable at all. No published value changes: this reports coverage and builds no denominator. It deliberately does not fill area 151 or any other area — a successor sum over UN WPP falls 17.5% short of the pin’s own figure for the Yugoslav SFR, because WPP reports Kosovo separately and the successor relation does not name it.build_polycell_support()can now emit aggregate polities as an explicit, non-partitioning overlap layer (#803). An aggregate –BLX-1850-1999Belgium-Luxembourg,F249-1918-1990Yemen, the six residual"Other"regions – is the only territory some FAOSTAT reporting buckets ever have: bucket 15 carries data before 2000 where Belgium (255) and Luxembourg (256) carry none. Excluding them left ten buckets with no territory in at least one pre-1962 year (15, 151, 237 over 1954-1961, 249 and 901-906). They stay excluded by default, because an aggregate’s polygon covers its members’ and the support must partition each cell;aggregates = "overlap_layer"clips them too and marks every rowsupport_role,"partition"or"overlap". The partition is unchanged either way – measured polity by polity and year by year on the real Belgium/Luxembourg geometries, the maximum difference is 0 – because a cell’s inland water is apportioned over the partition’s territory alone and every diagnostic describing the partition is measured on it alone.read_polycell_support()gainsrole, defaulting to"partition", so no existing consumer can pick up an overlapping row by accident. No published value changes: the default is the status quo, no pin is regenerated, and the publishedpolycell_supporthas no aggregate rows to return.-
build_commodity_balances()gainedtrade_recovery, which lets the CBS keep an import it has no row for (#762)..cbs_impute_trade()left-joins the crosswalked FAOSTAT/FishStat trade onto the already-pivoted CBS, so it can fill an(area, item, year)the CBS already carries but never create one. An area whose balance sheet omits an item it demonstrably imports loses that import outright. Measured at 2010 on the real pins, 3,336 crosswalked import records and 2,119 export records have no row to land on; among tonnes-denominated items 1,197(area, item)pairs are net imports the CBS never sees. Singapore alone loses 72 items and 2.56 Mt of net imports.trade_recovery = "net_import"creates those rows first, so the join lands. It is restricted to what can be balanced without inventing anything: tonnes-denominated items only (live-animal trade is counted in heads and reaches the wide CBS throughget_livestock_cbs()), net importers only (a created row has no production, so a net-exported one would force the cascade to invent some), and areas the CBS already covers in that year (so thearealabel is read from the CBS bucket rather than a year-free lookup). Recovered rows carrysource = "FAOSTAT_trade".The default is
"none", which is exactly today’s behaviour, andget_wide_cbs()always uses it. Nothing published moves unless the argument is passed.What moves when it is passed. On a 2010 build (wide CBS, context window 2005-2015): 1,164 keys are added and none removed (17,685 to 18,849 rows), 1,138 of them created directly from the trade record and 26 more processed products the cascade then derives from them; 200 areas before and after. Over tonnes items, world imports rise 53.7 Mt (+3.4%), exports 12.3 Mt (+0.8%), domestic supply 41.7 Mt (+0.2%), feed 36.7 Mt (+0.4%) and food 4.1 Mt (+0.09%); 34.6 Mt of the domestic supply is fodder (items 2000, 2001, 2002), which is missing from 97 areas’ balance sheets and present in the trade record for all of them. Every recovered import reaches the output unchanged (0 of 1,138 keys differ). No pre-existing row’s supply-use residual worsens by more than 0.05 t. 205 created rows do end up with production, 210 kt in total, and all of them are processed products (beer, wine, oils, cakes, DDGS, sugar, butter) whose output the processing cascade derives from the newly recovered inputs.
Downstream, on the nourishment axis at 2010: areas classified
Underfall from 33 to 31. Singapore goes from 30.5 to 65.7 g protein/cap/day (UndertoAdequate), Bahrain from 33.7 to 79.0 (UndertoAdequate) and Qatar from 81.0 to 127.7 (AdequatetoOver). Equatorial Guinea (11.4 to 18.3) and Puerto Rico (12.5, no trade record at all) stayUnder: those two are a genuine data absence, not a join artifact. Spain, the USA, Niger, Kiribati and Saint Kitts and Nevis do not move at all.Two things are deliberately not settled here, which is why the default is unchanged. A created row has no destiny of its own, so the existing fallback assigns its whole domestic supply to the item’s default destiny — processing for rice, not food — and that is an allocation rule, not an identity. And 144 net-exported pairs / 28.9 Mt at 2010 stay uncreated, because balancing them would fabricate supply. Both are open in #762.
regions_fullandpolities_catsno longer ship theregion_testcolumn, and their remaining regional groupings now document which ones WHEP reads (#386).region_testheld two values,"Europe"and"Other", and had no consumer anywhere in the package; it was a working column left in two published tables. Both datasets drop from 39 columns to 38; no other cell changes and no published value moves. Theregions_fullhelp page gains a Which regional groupings WHEP reads section recording that six of the remaining groupings have an in-tree consumer, thatregion_codeis a 1:1 relabelling ofregion, and that the eleven unconsumed ones are shipped as reference without a re-validation promise – with the gap a consumer would inherit stated explicitly: over the 202cbsreporters the present-day taxonomies areNAfor exactly the four dissolved federations (codes 51, 186, 228, 248). Tworegion_labour_mechcells that hold a sub-region name rather than a mechanisation class are documented rather than repaired, since the correct class is not recoverable from anything the package ships.The
region_labour*column shift inregions_fullis fully documented and pinned, and is one cell wider than first reported (#855). Re-measuring the tworegion_labour_mechcells found that at Northern Mariana Islands (code 163) the neighbouringregion_labour_aggis damaged in the same way: it holds"Micronesia", that row’s ownregion_UN_subvalue, rather than one of its own nine aggregates, so repairing 163 means editing two cells. Angola’sregion_labour_aggis intact.polities_catsinherits the Angolaregion_labour_mechcell; it has no row for 163, which is not a polity. No cell changes and no published value moves – the affected columns still have no consumer in the package. The classes stay unfilled: no public taxonomy defines this mechanised/not-mechanised split, and although the other eightregion_labour == "Middle Africa"rows are allno_mechand the other eightregion_UN_sub == "Micronesia"rows are allmech, the column is not a function of either grouping, so group agreement cannot settle it.New:
check_table_schema()andassert_table_schema()validate a table against a serializable declarative schema (#373). The schema is plain data — an ordered list of column specifications carrying type, presence, bounds, an allowed-value vocabulary, uniqueness and severity, plus a table-level key, extra-column policy, column-order policy and empty-table policy — so it round-trips throughyaml/jsonliteand can be stored next to the artifact it describes. Project vocabularies and scientific bounds stay in the caller’s schema; the validator hard-codes none of them.check_table_schema()returns one deterministic diagnostic row per violation (row,column,rule,value,severity,detail) and never touches the input;assert_table_schema()is the build-time gate over the same schema and returns its input unchanged. This complementsensure_columns(), which coerces a table to a typed prototype: useensure_columns()to reach a schema andcheck_table_schema()to prove a table is already there. No published value changes — both functions are new and nothing in the pipeline calls them yet.New
write_table_checked()writes a table atomically and verifies it before it replaces anything (#375). It creates the parent directory, writes to a temporary file beside the target, reads that file back (assert_parquet_integrity()plus a row and column-name check for Parquet, a header and row-count re-read for CSV) and only then renames it into place, so an interrupted, failed or corrupt write leaves the previous artifact untouched instead of overwriting it with a partial file.overwrite = FALSErefuses an existing target, andsidecarsoptionally writes<path>.schema.yamland<path>.provenance.yaml. It also closes a silent failure mode:nanoparquet::write_parquet()given a path whose parent directory does not exist returnsNULLand writes nothing at all, whichwrite_parquet_checked()only reported as a confusing “Parquet file not found”. No published value changes: this is a new function, and no existing call site was moved onto it.The package no longer calls
dplyr::case_match(), which dplyr 1.2.0 deprecated (#850). The five call sites — thefert_typebridge in the nitrogen balance, themanure_typebridge in its inputs, the GLEAM continent and method-label helpers in the energy CO2 extension, and the live-animal unit rename in the production assembly — now usedplyr::case_when(). This is deliberately notrecode_values(), dplyr’s named successor: that function does not exist before dplyr 1.2.0, so using it would force adplyr (>= 1.2.0)bound inDESCRIPTIONand break installation for anyone on an older dplyr, whilecase_when()behaves identically on both. No published value changes: every affected helper was run over its whole input vocabulary plusNA, an unmatched value and an empty input, under dplyr 1.1.4 and 1.2.1, and the output is identical in all cases. What does change is that a build on dplyr >= 1.2.0 no longer emits deprecation warnings from these paths.The GLEAM coefficient tables now cite the FAO workbook they were actually read from, not an unregistered DOI (#607). All fifteen GLEAM
@sourcetags inR/livestock_coefs.Rcredited “MacLeod et al. (2018) GLEAM 3.0 Supplement S1”, four of them with an IOP-prefixed DOI that is not registered at all (a 404 at doi.org, “Resource not found” in Crossref), which is what produced the--as-cran“possibly invalid DOIs” NOTE. MacLeod et al. (2018) is the Animal position paper on GLEAM (10.1017/S1751731117001847), which publishes none of these tables. The twelve tables parsed fromdata-raw/GLEAM_3.0_Supplement_S1.xlsxare now cited as FAO (2022) GLEAM version 3.0 Supplement S1 by title and URL, the committed workbook having been confirmed byte-identical to the one FAO publishes; FAO issues no DOI for it. The other five –gleam_mms_shares,gleam_animal_weights,gleam_milk_production,gleam_livestock_categoriesandgleam_feed_categories– are hardcoded indata-raw/livestock_coefficients.R, could not be traced to any GLEAM document, and their documentation now says so instead of naming a source they do not have;gleam_animal_weightsfeeds the Tier 2 energy calculation, so #881 tracks sourcing it. Documentation only: no data value and no published number changes.The
ipcc_2019_*livestock coefficient tables now document what edition each of their values actually comes from (#601). No stored value changed, so no published number changes. Every one of the ten objects was checked cell by cell against the published PDFs of both the 2019 Refinement and the 2006 Guidelines, Vol 4, Ch 10 (and Ch 11 Table 11.1 for the pasture/range/paddock N2O factor). Onlyipcc_2019_boandipcc_2019_cfihold 2019 Refinement values throughout;ipcc_2019_ymis split between the two editions; the enteric, manure-CH4, MCF, nitrogen-excretion and direct-N2O tables are 2006 values, values from no IPCC table at all, or a per-head quantity the Refinement does not publish. The@sourceof each says which, names the specific cells, and gives the published alternative. Whether to revalue them, rename them or expose both editions is the open decision in #601: measured on the 2020 Tier 1 livestock chain, moving the cattle enteric table to the 2019 Refinement’s Table 10.11 raises enteric CH4 from 109.4 to 121.8 Tg (+11.3%, and +67% for Africa alone), and moving EF3 to the 2019 Table 10.21 lowers direct manure N2O from 1.65 to 1.15 Tg (-30.6%).User-supplied historical rice no longer arrives on two different mass bases (#778).
.read_historical_production()is the single reader behind the publichistorical_dataargument of bothbuild_primary_production()andbuild_commodity_balances(), but only the production side treated the rows as paddy: one 100 t paddy rice row reached item 2807 as 67 t throughbuild_primary_production()and as 100 t throughbuild_commodity_balances(), a 1.49x disagreement on a major staple. Item 2807 is milled equivalent throughout WHEP, so the CBS ingest now keys the paddy test on the row’ssource– the item label cannot carry the basis onceitems_fullhas relabelled every 2807 row “Rice and products” – using the same source list the production pipeline uses. Both paths therefore agree by construction. No published value changes:historical_datadefaults toNULLand nothing in the repository passes rice through it, so this only affects a user who supplies a historical rice series, whose pre-1961 CBS rice is now milled equivalent (0.67x its previous level) rather than paddy. The paddy assumption is now documented on bothhistorical_dataparameters; pre-divide by the extraction rate if the series is already milled.build_water_balance()now warns when the per-CFT consumptive-water inputs carry the LPJmL 6.x green/blue defect (#737). The defaultblue_green = "cft_native"method partitions evapotranspiration with thecft_consump_water_b/cft_consump_water_gcubes. LPJmL 6.x beforelbm364dl/LPJmL#3books infiltrating rain as blue water, so those cubes split it badly wrong: on the current 6.1.1 production run, rainfed grassland comes out green 134.2 / blue 382.2 mm where the same cells of a run built with the fix give 516.4 / 0.0. A rainfed crop band receives no irrigation, so blue water on one is proof of the defect, and that is what the check tests — measured over every rainfed band and cell of year 2005, the blue share is 0.899 on the affected run against 0.0199 (6.1.1 with the fix) and 0.0002 (5.9.7), so the warning fires above 0.10. It is detected from the data rather than from a version number because a run directory carries no version stamp. No published value changes: the split is still computed and returned exactly as before, andblue_green = "irrig_share"never reads these cubes. Nothing in the package consumesblue_consump_mm/green_consump_mm/aet_blue_mm/aet_green_mmyet, so this makes a trap visible rather than correcting a live error.The
spatialize-crop-patternspin was regenerated: barley is back, and the gridded harvested-area round trip closes 6 points tighter (#877).inst/extdata/earthstat_mapping.csvis the crosswalkprepare_crop_patterns()iterates – one EarthStat harvested-area raster per row – and it shipped with 169 rows against the 172 crop directories the EarthStatHarvestedAreaYield175Cropstree contains. The three missing names werebarley,greencornandhempseed, all three of whichcft_mapping.csvalready expected, socrop_patternscarried no cell for them andbuild_gridded_landuse()dropped their entire world total in every country and every year. The rows are added and the pin rebuilt from the whole EarthStat tree (version20260825T092111Z-8690d, 2,297,621 rows over 147 crops, up from 2,247,239 over 144); the regeneration reproduces the 144 crops already in the deployed pin bit-for-bit, so the only change is the three crops it adds. This moves published gridded values. Spatializing with thewheppreset and re-aggregating the cells to their reporting areas, the world harvested-area round trip goes from 0.9327 to 0.9898 at 1961 (+54.46 Mha, of which barley 53.70 Mha) and from 0.9474 to 0.9832 at 2015 (+48.96 Mha, barley 47.85 Mha); the share of areas round-tripping within 1% goes from 42.3% to 78.9% at 1961 and 34.9% to 72.3% at 2015. No other crop’s allocation moves by more than 6e-08 ha. Everything downstream ofbuild_gridded_landuse()inherits the change, includingbuild_crop_land_extension()and the gridded nitrogen balance. Hempseed contributes cells but no area yet: the national table carries no harvested area under item 336.Chillies and peppers, dry(689) andLeeks and other alliaceous vegetables(407) stay unallocated, and are meant to – EarthStat publishes no raster for either.biomass_coefsnow has one source, and the Spanish nitrogen chain moves with it (#489). The name resolved to two different tables: the packagedwhep::biomass_coefs(63 columns, rebuilt frominst/extdata/harmonization/biomass_coefs.csv) and abiomass_coefspin frozen at20250728T082553Z(41 columns). They disagreed on 12 of their 36 shared columns, sobuild_food_supply()andcreate_n_prov_destiny()ran on different nitrogen coefficients for the same commodity. The packaged table is authoritative — it reproduces the upstreamBiomass_coefs.xlsxCoefssheet cell-for-cell on all 63 columns, whereas the pin differs from upstream in 126 cells — so the pin entry has been removed fromwhep_inputsandcreate_n_prov_destiny(),validate_national_trade()and the Josette typologies now readwhep::biomass_coefs. Published values change. On the columns those callers use, 23 cells moved:Residue_kgN_kgDMfor the 17 wood and forest items rises from 0.00095 to 0.0030–0.0045 kg N per kg DM, andLysineandMethioninegain product dry-matter and nitrogen contents the pin had at roughly half and at zero. Measured end to end oncreate_n_prov_destiny()(1961–2023, all provinces): total nitrogen 410.78 to 415.42 Tg, +1.13%, concentrated inFirewood(+4.35%),Wood(+216%),Lysine(+281%) andMethionine(0 to 239,619 Mg N). By destiny,export+6.48%,population_other_uses+19.41%,livestock_mono+1.92%; by box,semi_natural_agroecosystems+5.80%,Cropland+1.01%. No other function changed: nothing read the five below-ground columns the pin carried and the packaged table does not.gleam_geographic_hierarchyno longer flags Comoros as an OECD member, cutting its energy-use CO2e by 40.6% (#574). Cell G41 of GLEAM 3.0 Supplement S1 carriesOECD = 1for Comoros, which is not an OECD member; the other 38 flagged codes are exactly the 38 real Members named on the OECD’s own membership page, so the cell is an upstream data-entry slip rather than a modelling grouping that borrows the name.data-raw/livestock_coefficients.Rnow rebuilds theoecdcolumn from that published list and aborts if the sheet ever stops listing a Member, andtest_datasets.Rpins the set equality –livestock_coefsis one of the datasets thedata-rawfreshness gate cannot rebuild, so this test is its gate..energy_country_grouping()readsoecdfor two of GLEAM’s three schemes, so Comoros moves fromOECDtoLeast developed countriesunderdevelopment3and fromOECDtoAfricaunderregion5;detailed15is unaffected. Published values change for one area:build_energy_co2_extension()overget_primary_production()gives Comoros 6.336e7 kg CO2e over 1850-2023 instead of 1.066e8 (the shipped figure was 68.3% too high), with per-kg live-weight intensities falling 128.6% (bovine), 50.4% (mutton/goat), 26.1% (pig) and 12.9% (poultry). No other area’s rows move; the global total falls by 0.00066%.The six patchwork panel plots now say which package is missing instead of failing inside
loadNamespace()(#431).plot_typology_indicators_panel(),plot_typology_periods_panel(),plot_loss_decomp_rolling_panel(),plot_loss_decomp_periods_panel(),plot_compart_factor_roll_panel()andplot_compart_factor_periods()compose their figures withpatchwork, and every panel is aggplot2object, but both packages areSuggests. Without them the functions aborted withthere is no package called 'patchwork'only after minutes of pin reads and decomposition work; their examples failed outright on any machine that lacks either package. They now callrlang::check_installed(c("ggplot2", "patchwork"))as their first statement, and their examples are wrapped in arequireNamespace()guard. No published value changes: the guard is a no-op whenever both packages are installed, which is the case on all CI platforms.build_primary_production()now reports same-source duplicate rows instead of dropping them silently (#650)..dedup_production()exists to arbitrate between competing sources for one(year, area_code, item_prod_code, unit)key, keeping the better-ranked one. Two rows carrying the same source are not that case: they are either an exact duplicate or, as in #633, two territories that should have been summed upstream, and keeping one silently lost the other’s mass. The arbitration is unchanged – summing here would double-count a FAOSTAT aggregate that legitimately arrives alongside its own components – but such a collision now raises a warning naming the affected keys and the value discarded per unit, silenceable withoptions(whep.warn_prod_dupes = FALSE).show_duplicates = TRUElikewise flags keys that repeat one source, which is why that source’s cell holds a list rather than a number. No published value changes: on a full 1850-2023 build (6,310,171 rows) every key is already unique, so the warning does not fire and dedup drops nothing.read_lpjml_hydrology()checks the run’s year coverage, and no longer documents a last year it does not read (#598). Requesting a year outside the run’s own time axis used to fail deep insidencdf4withNetCDF: Start+count exceeds dimension bound, which names neither the run, the years at fault, nor the coverage the run does have; it now aborts with all three. Which years exist is a property ofrun_dir, not of the reader: WHEP’s LPJmL runs end in 2009, 2018 and 2023 and sit side by side in one folder, and the file header claimed a fixedlastyear 2009(1308 monthly steps) that stopped being true when the reference run becameglobal_1901-2023_spinup_300_our_inputs_lpjml611(LPJmL 6.1.1, 123 years, 1476 monthly steps, the run the four LPJmL-derived pins came from, #558). The reader never used that constant — the span has always been read from the file’s time dimension — so no published value changes: every call this now rejects already aborted, only less legibly, and every call that succeeded returns byte-identical output. The check reads the written time axis, so it still cannot see a run configured past the end of its forcing (theglobal_1901-2018_spinup_200_our_inputstrap of #340).-
An
area_code-keyed frame now resolves its GLEAM/IPCC region instead of silently taking the Global emission factor (#678). The livestock emission helpers advertisearea_codeas an accepted territory key, but the shared resolver had no leg for it: it matched an explicitiso3column, then apolity_area_codeoverride table covering only dissolved federations. Anarea_code-only frame therefore resolved 8 of the 266 reporting areas and every other row fell through to the Global default. The resolver now derives the ISO3 fromarea_codeas a middle leg, takingarea_codefrom 8 resolved areas to 214 — the same 214 an explicit ISO3 resolves. The 52 that stay unresolved are the dependencies, micro-states and residual aggregates GLEAM itself does not list, and they keep the Global default as before.No published value changes. Every in-package caller reaches the resolver through
prepare_livestock_emissions(), which already attachesiso3: MEASURED on the full 241,434-row livestock frame frombuild_primary_production(), the resolved region is bit-identical before and after, with no unresolved row, sobuild_livestock_ghg_extension()and the manure chain are unmoved. What changes is the answer a user gets when callingcalculate_livestock_emissions(),calculate_enteric_ch4(),calculate_manure_emissions()orestimate_energy_demand()on a frame keyed byarea_codealone. MEASURED on the 2020 national head counts at Tier 1 (AR6 GWP100): global livestock CO2e moves from 4,277 to 4,158 Mt (-2.8%), but the per-country change ranges from -31% (Madagascar, Tanzania, Eswatini and the rest of Sub-Saharan Africa, whose IPCC dairy factor is 46 against the Global 80) to +21% (Luxembourg, Lithuania, Latvia, Switzerland), and 176 of the 195 territories move by more than 1%. read_critical_n()’s first-run fetch works from a path with a space in it, and its download-failure message no longer crashes (#451). Two defects on the on-demand Zenodo path, both found by finally exercising it against an offline 7z fixture. The 7-Zip binary back-end passed the extraction directory to7zunquoted, so a cache path containing a space (a user name is enough on macOS or Windows) split at the space: 7-Zip read the tail as a member filter, extracted nothing, exited 0, and the reader then aborted with “did not unpack as expected” after a successful 18.4 MB download. And when the download itself failed, the abort interpolated the URL as{.critn_archive_url()}, which cli reads as a style name and rejects, so the intended message was replaced by “Invalid cli literal”. Both back-ends (thearchivepackage and a7z/7za/7zr/7zzbinary) and the back-end selection are now covered by tests. No published value changes: the layers read out of a successfully extracted archive are unchanged.-
The gridded crops layer has one key now, and it is a code (#788).
build_livestock_nutrient_flows()’sgridded$cropstibble was read two incompatible ways..sci_manure_crop_layer(), the only in-package producer of a real layer and the one the carbon balance uses, setscrop = as.character(item_prod_code). The nitrogen side resolved the same column by crop name only, and aborted on anything else — so the layer the carbon path builds could not be handed tobuild_nitrogen_balance()at all: measured on real data, every one of the 9,298 rows for 2010 (all 171 crops, 1.383e9 ha) failed to match, and 1,102,005 rows over the full 1850–2023 span.The code is now the canonical key, documented on
build_livestock_nutrient_flows()andallocate_manure_to_land(), and both consumers honour it. A crop name still resolves, as a deprecated compatibility bridge that warns — a name is not a unique key (Fallownames twoitem_prod_codes, and three codes carry no name), which is why the code direction is the one kept.No published value changes. Over the full-span layer the code-to-name map is a bijection (172 codes, 172 distinct names, none missing) and both keys resolve to the identical
item_cbs_codefor every one of them, so the name-round-trip workaroundinst/scripts/run_nitrogen_balance.Rcarried was numerically lossless; it is now deleted and the driver passes.sci_manure_crop_layer()’s output straight through. build_polycell_support()now aborts on overlapping validity intervals, not only on repeated keys (#758). The existing guard keyed on(cell_id, polity_code, start_year, end_year), so it saw only the subset of overlapping validity in which two intervals of one polity in one cell are identical. Supplying the same polity at[2000, 2015)and[2010, 2020)through thegeometriesargument completed with no abort and no warning, and emitted the shared interval[2010, 2015)twice per cell, doubling that polity’s territory over those years. The producer now checks the intervals of each(cell_id, polity_code)for genuine overlap and aborts with classwhep_pcs_overlapping_interval. The comparison is strict, so a succession —[2000, 2010)then[2010, 2020),end_yearbeing exclusive at a succession — is not an overlap and still splits as before. No published value changes: the shippedpolitiestable (767 rows) carries nopolity_codetwice and no overlapping intervals, so no production build reached this, and a full offlinebuild_polycell_support()from it finds zero overlapping pairs.Parquet artifacts over 4 GiB were written corrupt, and now cannot be (#531).
nanoparquetbefore 0.5.0 stored column-chunk file offsets and sizes as 32-bit integers, so past 4 GiB (2^32 bytes) they wrapped around. The footer still declared every row group and row, but pointed at the wrong bytes for everything after the first 4 GiB: a reader returned the readable prefix and threw thrift “Deserializing page header failed” on the rest, so a consumer that did not open each row group individually silently got truncated data. Reproduced here on a 5.16 GiB file: 7 of 11 row groups readable under 0.4.2, 11 of 11 under 0.5.1, with the offsets of row group 7 short by exactly 2^32.nanoparquet (>= 0.5.0)is now required, and the newcheck_parquet_integrity()/assert_parquet_integrity()verify a file’s layout in milliseconds regardless of size (deep = TRUEalso decodes every row group).write_parquet_checked()writes and then verifies, and the gridded landuse, livestock, yield and nitrogen cubes ofbuild_gridded_landuse()andinst/scripts/run_spatialize.Rgo through it, so a bad write now aborts the build instead of shipping. The bytes written are unchanged, so no published value changes; only files that were already corrupt behave differently, and they now fail loudly.-
The three polity tables are re-synced to upstream
whep-polities(#835, #384).polity_area_crosswalk,politiesandpolity_label_aliasesare committed build products ofdata-raw/table_mappings.R, whose inputs live outside this repo, sotest_data_raw_freshness.Rcould not check them and they had been shipping from a superseded upstream revision. They are rebuilt here fromeduaguilera/whep-politiesat8e2bb78(“Merge pull request #550 from eduaguilera/fix/retest-reunion-baseline”), which is that repository’smain.What moved: the crosswalk goes 647 → 649 rows (upstream added
CPV-1800-1886Cape Verde andSUR-1800-1886Dutch Guiana);politiesgoes 767 → 779 rows (21 new codes, 9 retired) and gains apolygon_feature_datecolumn;polity_label_aliasesgoes 903 → 995 rows over 523 labels (up from 479), mostly British West Indies trade labels plus a newfederico_tenasource. Nine polity codes were re-dated by a year (Chad, Côte d’Ivoire, Ghana, Hungary, Kenya, Laos, Senegal, Syria and Antilles), and Indonesia’s post-independence boundary moved from 1969 toWhat that does to resolution: over the full
(area_code, year)grid 1850–2023 (46,284 pairs),polity_area_code— the bucket the matrix workflows aggregate on — does not change for a single pair, and neither doesmapping_statusorhas_geometry.polity_codechanges for 126 pairs, all of them FAOSTAT area 101 Indonesia, which now reportsIDN-1949-1963/IDN-1963-1976where it reportedIDN-1949-1969/IDN-1969-1976. Outputs carryingreporting_polity_codefor Indonesia therefore change label; no aggregate changes value.harmonization_tables.Randbalance_coefficients.Rboth read the crosswalk and their rebuilt tables are byte-for-byte unchanged, soregions_full,polities_catsandurban_n_referencedo not move.Two behaviour changes ride along, both from upstream filling a field that was
NA.BLX-1850-1999now publishes its successors, so FAOSTAT area 15 Belgium-Luxembourg reachesBEL+LUXin.federation_land_bridge()and its pre-1962 production is back-cast underfederation_land = "successor_union"— an opt-in; the default is"none", so nothing changes unless it is asked for. And the three USSR periods (F228-*) gainediso3_code = "SUN". The"FSU"alias itself is unchanged (FSU->F228-1945-1991, 1961-1991); what changed is that the polity -> ISO3 -> area bridge behind it no longer dead-ends onNA, so the"alias_map"route ofinst/scripts/prepare_spatialize_all.R’s grassland-share reader resolves 6,713 of 6,909 Lassaletta rows instead of 6,682. That route is not the default either. The same fill makes.successor_iso3_map("F228-1945-1991", vocab)answer"SUN"rather than the 15 republics for any caller whosevocabcontains"SUN"; the LUH2 vocabulary the one production caller passes does not. table_mappings.Ris now checked against upstream wherever upstream is checked out (#835).test_data_raw_freshness.Rgains a block that re-runs the builder and compares all five of its tables with the committed.rda, guarded on the threewhep-politiesfiles existing. It skips on CI, r-universe and CRAN, where they do not, so the suite still reads noWHEP_*path and touches no network — but a maintainer who can re-sync now finds out fromdevtools::test()rather than from a manual audit.-
The cell-polity crosswalk is a pin now, so no user regenerates it (#694, #461).
cell_polity_fraction.parquetwas the only one of the ten artefactsinst/scripts/prepare_spatialize_all.Rproduces that was not published: its nine siblings are pins, and it was gated behindWHEP_POLITY_FRACTION_PATH. But env-var gating is for the multi-GB third-party archives a user cannot be handed, and this is a 62 KB table WHEP builds itself from Natural Earth plus its owninst/extdata/regions.csv, so every user had to run the producer — which is how the retired-vocabulary copy that deleted Ethiopia and Sudan came to be the one everybody read.It is published as
spatialize-cell-polity-fraction(20260821T095211Z-a4952, 62,784 rows over 58,791 cells and 178 area codes), andbuild_cell_polity()reads that pin by default.WHEP_POLITY_FRACTION_PATHand the newpolity_fraction_path/versionarguments are overrides for a local producer build, exactly the shaperead_polycell_support()already had. Calling it with neither set no longer aborts. The published pin payload is byte-identical to the regenerated parquet #694 verified, so no published value changes.The #694 stale-vocabulary guard stays on, and now covers both routes: the override, which is the one that can go stale, and an explicitly pinned older
version.build_cell_polity()also gainedexample = TRUE, so its example runs offline instead of being skipped for want of an environment variable. build_primary_production()is nowidentical()to itself across sessions (#747). The four commodity-balance extracts it carries as its.cb_extractsattribute (fbs_new,fbs_old,cbs_crops,cbs_animals) came back in a session-dependent row order, because the parquet reads go through arrow’s multi-threaded scanner and nothing downstream pinned an order. The published frame was unaffected, but the object as a whole was not reproducible, so the naturalidentical(build_primary_production(), baseline)reproducibility check failed on a change that moved nothing..extract_cb()now sorts on its aggregation key (year,area_code,item_cbs_code,item_cbs,element,unit). No published value changes: the four extracts hold the same rows with bitwise-identical values before and after (verified at 2010-2013 on the real pins, 1,070,446 rows in total), and the CBS production aggregate derived from them is bitwise identical, totals included.-
Historical trade rows no longer carry a dissolved country’s label (#719).
.resolve_hist_trade_polities()built its ISO3 -> area bridge by keeping the first row per ISO3, and the area lookup orders byarea_code, so an ISO3 that names two FAOSTAT reporting areas entered as the lower code:ETHas 62 (“Ethiopia PDR”, dissolved 1993) rather than 238 (“Ethiopia”). The bridge now goes through.iso3_area_code_bridge(), which breaks that tie on the polities database (#586, #718), and thearealabel is attached from the resolved aggregation bucket rather than carried in from the member row – the rule.aggregate_to_polities()and.read_crop_residues()already follow.No published value moves. On the real historical-trade pins the row keys,
value,area_codeandpolity_codeare all identical before and after (248,508 rows, 18,639,792,136.89 t). Only thearealabel changes, on 81,388 of those rows across 70area_codes, from the plain FAOSTAT area name to the year-aware polity name that every other CBS source already emits – so the feed stops offering a secondareavocabulary for the samearea_code(the split that dropped 702,166 rows in #382). Two identity defects are fixed along the way: Ethiopia is no longer labelled “Ethiopia PDR” onarea_code238, and a post-1993 Ethiopian row no longer resolves to the ended polityETH-1952-1993; neither case occurs in the current pins, whose Ethiopian rows are all 1961 and whose only consumer keeps years before 1961. Bucket 206 also stops carrying two labels in one year (“Sudan (former)” fromSDNand “South Sudan” fromSSD). Four
polity_area_codebridges now read the crosswalk the pipeline resolves through, so 61 reporting areas stop being summed into bucket 999 (#716).get_arable_permanent_land()’s FAO and LUH2 legs, the crop/soil N2O extension’s country-N bridge and the feed redistribution’s cell bridge all built their bucket from the shippedpolity_area_crosswalkinstead of.polity_crosswalk(), where.unfold_rest_of_world()is applied. Since the Rest-of-World un-fold (#628) made promotion the default, those 61 areas – Syria 212, Greenland 85, Bermuda 17 and 58 more – carry their own code everywhere else in the pipeline, so each bridge was aggregating onto a bucket the side it joins against no longer has. Measured on real inputs:get_arable_permanent_land(years = 1850:2022)goes from 194 to 227 areas and moves 786,562,273 ha-yr of cropland out of bucket 999 onto the areas that reported it (Syria alone 693.6 M ha-yr); the total falls 0.017%, entirely pre-1961, because each territory now splices its LUH2 back-cast on its own FAO-1961 anchor rather than on the aggregate’s. In the crop/soil N2O extension the nitrogen that reaches crop shares rises 0.048% for synthetic fertiliser and 0.085% for applied manure (2015-2020) – bucket 999 has no crop shares, so that mass was previously dropped outright. In the feed redistribution 151 cell-polity rows (0.18% of gridded land) keep their own code instead of being re-keyed to a bucket the demand does not carry.polity_area_crosswalkno longer names an ISO3 stem after a polity (#711). Two of its columns werereporting_polity_codeandreporting_polity_name, the package’s own published names for “the polity itself” (?whep_polity_columns), but held the ISO3-like stems and legacy labels this package vendors fromregions_full.csv:"ARM","ROCE","REUR", and 0 of the 641 non-NAvalues was apolities$polity_code. The table’s own documentation sent readers there to ask which territory a row belongs to, so following it returned a column that answers nothing – the same trap #687 removed fromregions_full, in the opposite column. They are nowlegacy_polity_prefixandlegacy_polity_name, matching the vocabulary #687 settled on, and the@formatblock documents both as explicitly not an identity. This is a published schema break for anything reading those two names off the crosswalk; the answer they appeared to promise is the table’spolity_code/polity_name, and in a WHEP output it isreporting_polity_code, which is unchanged and still a real periodized code. No published values change: every cell of the table is byte-identical and nothing in the package computed from the renamed pair exceptresolve_polity_label()’s refusal list, which reads the same values under the new name and returns the same result.-
cell_polity_fraction.parquetregenerated, and a stale copy is now refused (#694). The deployed fractional cell-to-polity crosswalk had been rasterized through an olderinst/extdata/regions.csvthan the centroidcountry_gridbeside it: it keyed Ethiopia62and Sudan (former)206, plus6,125,192and205, where today’s lookup uses238/276and folds the other four upstream. Any consumer that adopted it deleted Ethiopia and Sudan outright – 27.10 Mha of harvested area (2.0 % of the global 1,365.9 Mha) and 332.0 M head (1.16 % of 28.638 bn), measured at 2015 in #461. Re-running sections 1 and 1b ofinst/scripts/prepare_spatialize_all.Ragainst the same Natural Earth polygons rebuilds it in the current vocabulary: 62,784 rows over 58,791 cells and 178 area codes, exactly the 178 the centroid grid carries, so thesetdiff()between the two grids is now empty in both directions. The centroidcountry_gridre-derives row-for-row identically (58,795 cells, 178 codes, every code equal), so only the fractional artefact moved.build_cell_polity()now aborts with classwhep_stale_cell_polity_gridwhen the parquet it is handed carries an area code today’sregions.csvno longer has, naming the codes, the cells affected and the producer re-run. This is deliberately fatal rather than a warning: the #461 warning already made the loss visible and did not stop it, because the deletion lives in the artefact. No published value changes – the"centroid"/"polycell"crosswalks that back published runs are untouched; what changes is thatcountry_grid = "fraction"no longer loses two countries, and that a stale local copy fails instead of quietly deleting them. -
resolve_polity_label()now covers the current year (#712). Its year filter readpolity_end_yearstrictly exclusively, so a polity whose interval ends at the open-period sentinel stopped covering its own terminal year: at 2025 only 1 of the 204 ISO3 codes ingleam_geographic_hierarchyresolved, against 204 at 2024, whileadd_polity_code()resolved them normally because the numeric route already goes through.polity_join_end_year(). The label route now applies the same convention – exclusive at a succession, inclusive at an open end (#577) – and a period upstream records no successor for also covers its last year away from the sentinel (ANT-1961-2010in 2010). Declared containment still outranks that widening, so a succession year keeps resolving to exactly one polity and cannot become ambiguous (#720).What changes for callers. Resolutions are only ADDED, never moved: over 1,020 identifiers x 1850:2026 the fix turns 700
NAs into codes (680 at the 2025 sentinel, 20 in the last year of a terminated period) and changes no answer that already resolved. Any consumer that asked the label route about the snapshot’s last year –mueller_synthetic_n,crops_manure_n, the GLEAM tables,R/sources.R– gotNAfor essentially every country and now gets the polity. No packaged value changes: the two in-package callers resolve below the sentinel (expand_trade_sources()stops at 2014,add_present_day_polity()asksmax(end_year) - 1), so both are unmoved. The destiny-share interpolation is keyed on
area_code, not on thearealabel (#691)..interpolate_destiny_shares()named the label beside the code in its skeleton join, its anti-join and its dedup — the last year-free territorial join in the package that read a label, and the shape behind #589 (a shared label diluted Syria’s livestock by 12x) and #563. It could not disagree in the current build, becausebalanceanddestinyare two filters of one frame, but the guarantee was the caller’s rather than the function’s and an unmatched key here drops a row silently instead of aborting. The keys are the code now and the one display label per code is re-attached at the end. No published value changes: on the real 1850-2023 frames the old and new function return 20,314,086 rows with the same(year, area_code, item_cbs_code, element)key set (0 keys either side), the same 2,845,173 summeddest_share, a maximum per-key difference of 0 and one label per code in both. Measured on a fixture where the two sides disagree about the label, the old keys turned a 6-row skeleton into 2 and lost two years of shares entirely.inst/scripts/prepare_spatialize_all.Rno longer reuses a production cache built under an older area model (#657). The on-disk.prod_cache.parquetwas invalidated on the requested year span alone, so a cache written before the polity restructure (#628, published areas 195 -> 216) kept being reused for as long as its years covered the request, and every spatialize pin derived from it inherited the oldarea_codevocabulary (codes276/277instead of206, and no cell of their own for the 21 areas promoted out of bucket 999). The cache is now keyed on a content hash of the package’s whole data payload plus the cached table’s own column set and sortedarea_codedomain, recorded in a.prod_cache.meta.rdssidecar written next to it. A cache with no sidecar – which is every cache deployed today – is discarded with a warning naming the reason. No published values change from this commit alone; the next run of the prep script rebuilds production instead of reading a stale table, which is where the spatialize pins pick up today’s area model.build_n_inputs()andbuild_nitrogen_balance()now takepolity_validity(#727). Both gained the samepolity_validity = c("keep", "flag", "drop")argument the four gridded builders they call already had, and forward it to all of them:build_ag_land_support(),build_n_deposition(),build_urban_n()andspatialize_country_n_to_crops(). The choice is then applied to the assembled inputs and to the balance rows themselves, so one call decides the fate of every row whose(area_code, year)resolves to a polity that did not exist in that year, instead of each builder deciding on its own key space."keep"stays the default, so no published value changes: a default build is byte-identical and still only warns."flag"now addsreporting_polity_out_of_spanto both outputs, and"drop"removes those rows throughout the chain. Under"drop"a non-item input supplied directly (acarbon_balancetable, say) can outlive the support rows it must be allocated over; that aborts in the existing mass check, whose message now namespolity_validityas the cause.fill_proxy_growth()no longer lags or smooths a proxy across the boundary between two series (#608). The proxy lag and theproxy_smooth_windowmoving average were taken within the aggregation group of a"variable:group"proxy spec rather than within the individual series, so when two members of one group had non-overlapping but adjacent year coverage, the first year of the later series took the last value of the earlier one as its own previous observation. Theyear == lag_yr + 1adjacency guard cannot see the difference. In the issue’s fixture (ESP withgdpto 2002, FRA from 2003, both in regioneu) FRA’s 2003 growth rate came out as 7.264463 – FRA’s 2003gdpover ESP’s 2002gdp– where the correct answer isNA, and a third member of the group with a 2002 observation was inflated 8.26-fold (200 to 1652.893). Both are nowNA. No published values change: everyfill_proxy_growth()call in the package passes a plain numeric proxy column, for which the aggregation group already equals.by, and a 11,333-row randomised fixture on that form is bit-identical before and after.Fodder is now built on
area_codealone, and no longer counts a country once per historical name (#655)..merge_euadb_fodder()and.fill_fodder_gaps()carried the periodized area label in their join and grouping keys, so onearea_codewhose label changes over the series became several independent series, and.fill_fodder_gaps()’s cross join then gave each of them the full year span. Egypt (59) ended up with three full-area copies of every fodder item — 308.6 Mt of clover in 2017 where FAOSTAT-scale production is 55.4 Mt. The label is now resolved once, at the end, from the polity crosswalk for the row’s own year, the same rule.aggregate_to_polities()labels a bucket by. Published values move: the fodder table drops from 63,484 to 60,556 rows, total harvested area by 5.37% (9.353 to 8.851 Gha summed over 1961-2019) and total production by 7.36% (226.3 to 209.6 Gt); 2,574 duplicated(year, area_code, item, unit)keys become none, and 1,709 further keys change becauseha_shareand the year-axis interpolation now run over one series per country instead of one per label (Czechoslovakia, Germany, Romania, Poland, Hungary, Bulgaria, Greece, Finland). A side effect is that a fodder row now always carries the samearealabel as the FAOSTAT crop row it is bound to: 745 of 5,769(year, area_code)pairs disagreed before, none do now.-
Three input pins were refreshed to their current upstream releases, and processing coefficients now reach 2023 with real data instead of a 7% stub (#449).
faostat-fbs-newhad been carrying a pre-October-2025 FBS vintage: 4,660,700 rows ending in 2022. Its version stamp (20260325T113807Z) recorded when the file was uploaded, not when FAO published it, so the staleness was invisible. The current FBS release (2025-10-28) has 4,820,497 rows and runs to 2023, with a completeProcessingelement for that year (5,769 rows, 211 areas, 83 items).faostat-cbs-newmoves to the 2026-06-15 CB release, growing from 58,107 rows and 11 items to 127,558 rows and 13 items.population_ygmoves from 1860-2021 to 1860-2023, taking the two new years from Spain_Hist’s own output rather than repeating 2021 (Spanish national population 47.37, 47.79 and 48.33 million over 2021-2023).Published values move.
get_processing_coefs()for 2023 goes from 359 rows, 108 Mt and 14 of 45 input items to 5,986 rows, 1,548 Mt and all 45 – 16.7x the rows and 14.3x the tonnage. That year previously held only what could be inferred from downstream production (oilseed crush, sugar and the milk-to-butter path); cereal and fruit processing, so beer, wine and flour, were absent entirely. The overlap years move much less: total processed tonnage shifts -0.374% (2019), -0.511% (2020), -0.104% (2021) and +0.607% (2022). Those are far smaller than the underlying FBS revision, which moves summedProcessingby -6.3% to +2.4% across 2010-2022, because the per-country calibration inbuild_processing_coefs()absorbs most of it.The 2026-06-15 CB release also adds a
Processedelement (code 5023) for rubber, wool and silk..harmonize_element_names()has no entry for it, so it passes through unmapped and.extract_fao()filters it out before.get_fiber_tobacco()runs, even thoughcbs_trade_codesmaps all three onto CBS items (Rubber 2672, Wool (Clean Eq.) 2746, Silk 2747). Behaviour is therefore unchanged by this release, but the flow is not negligible – in 2023 reporting countries processed 14.66 Mt of the 15.62 Mt of natural rubber they produced, and 0.591 Mt of 0.606 Mt of silk-worm cocoons. Consuming it would give those items a processing flow they have never carried, so it is tracked as #811 rather than folded in here.inst/scripts/prepare_faostat_balances.Rfetches these domains from the FAOSTAT bulk endpoint and reports the year span each file actually contains, so the next refresh is traceable to a dated FAO release rather than to whoever last downloaded a file by hand. -
build_grass_natural_carbon_inputs()now sums all fourteen natural plant functional types LPJmL 6.x writes, not the eleven LPJmL 5.x had, raising natural-land soil carbon input by ~7%. The natural-land carbon input selects PFTs by name, and the list was written for 5.x. LPJmL 6.1.1 addstropical broadleaved evergreen tree floodtolerant,C3 graminoid flood tolerantandSphagnum moss, so on a 6.x run those three matched nothing and their net primary production was dropped with no error and no warning. Measured onglobal_1901-2023_spinup_300_our_inputs_lpjml611at 2010: the mean natural carbon-input density rises from 6.739 to 7.218 MgC/ha (+7.12%), changing 33,510 of 58,795 cells, of which 10,835 rise by more than 10%; the largest single-cell change is +16.25 MgC/ha. The excluded share grows over time, from 3.31% of natural net primary production in 1901 to 8.92% in 2023, so the old behaviour biased trends as well as levels. The two flood-tolerant types carry most of it (3.33% and 3.39% of natural NPP at 2010); Sphagnum moss is the smallest at 0.58%, though it appears in the most cells.Downstream, this raises the carbon input to
build_carbon_balance()for natural land and therefore its equilibrium SOC, so it moves in the opposite direction to the excess-natural-SOC question in #799 — it is a correctness fix to the PFT set, not a calibration change, and #799 still needs its own answer.The pinned default path is not yet affected.
lpjml-grass-natural-net-cstores the already-summed natural density, so a caller without a run directory still receives the eleven-band numbers. The pin must be regenerated from a 6.x run for the two paths to agree; until then they differ by the ~7% above. A new warning fires when an LPJmL run writes natural PFT bands the list does not cover. The band selection is a name match, so a future LPJmL adding a PFT would silently drop it exactly as 6.x’s three were dropped.
build_grass_natural_carbon_inputs()now names every unmatched natural band instead. It warns rather than aborting, so a newer LPJmL still runs.Exported functions now work when the package is loaded but not attached. With
LazyData: truethe shipped datasets live in the namespace’s lazydata environment, whichlibrary(whep)puts on the search path but package code cannot see by bare name, so calling e.g.whep::get_polity_geometries()orwhep::add_polity_code()from a script withoutlibrary(whep)aborted withobject 'polities' not found..onLoad()now binds every lazy-loaded dataset into the namespace as a promise, so bare-name references resolve in both states. No published value changes: the bindings are the same objectswhep::<dataset>returns, and they stay lazy (#641).-
Every polycell-year is now partitioned into land uses, so a territorial quantity can be attributed to a land class instead of being assumed agricultural or dropped (#423).
build_polycell_land_uses()splits each polycell’sland_area_haintocropland,grassland,urban,naturalandunclassified. The level of each agricultural class comes from the statistical record, which is authoritative; LUH2 supplies only the within-country spatial pattern, taken from itsfraction(LUH2’s share of the whole cell) so the classes tile the polycell’s measured land by construction rather than to a tolerance.level_sourceandpattern_sourceare separate columns and their per-polycell difference is emitted asstatistical_pattern_disagreement_harather than absorbed intonatural; that column is the criterion for retiring LUH2 as a source. Inland water and ice are never land uses.Two conventions are worth knowing. FAO counts temporary meadows and pastures (Land Use item 6633) inside arable land while LUH2 books that ground as grassland, so that component keeps its FAO class but is spread over the LUH2 grassland pattern. And because FAOSTAT land use starts in 1961, the pasture level is back-cast before then by carrying the FAO 1961 level on LUH2’s own national trend, mirroring what
get_arable_permanent_land()already does for cropland, so the gridded grassland series does not step at the splice. A back-cast row is labelled inlevel_sourceand excluded from the statistical-versus-pattern diagnostic, since it would otherwise measure LUH2 against itself.A national total spread by the LUH2 pattern can give a polycell more agricultural land than it has, driven by countries where FAO and LUH2 disagree about how much land is permanent pasture. Measured on the function itself at 2020: 63.50 Mha, 1.33% of the anchored agricultural area, of which Saudi Arabia is 35.10 Mha and Sudan (former) 14.20 Mha.
overfull_methodselects the treatment and is recorded inmethod_overfull."spillover"(default) places the excess on same-country neighbours, widening the search ring until it is absorbed and taking non-forested natural land before forest: at 2020 it places 63.45 Mha of it across 3,878 receiving polycells, at a median ring of 2 and a maximum of 22, and names the remaining 42,765 ha inunplaceable_statistical_ha. A neighbour can only receive a class it has a row for, so land the pattern classified nowhere is reported rather than credited to a row that does not exist."cap"caps pro rata and leaves the whole 63.50 Mha inunplaceable_statistical_ha. The two are alternatives, never fallbacks, and both close the same ledger: 4,716.99 + 63.50 = 4,780.44 + 0.04 Mha of anchored area in.On a real 2020 build the five classes sum to each polycell’s
land_area_hato a maximum relative deviation of 1.7e-10 over 73,873 polycells, with none off by more than 1e-6. Global class areas come out at natural 7,985.3 Mha, grassland 3,225.3, cropland 1,565.7, urban 77.9 and unclassified 71.2.No published value changes: this adds a producer and does not alter any existing output. The ledger anchors grassland on FAO item 6655 by passing
source = "faostat_pasture"explicitly, which differs frombuild_grassland_land_extension()’s own"luh2"default; that divergence is tracked in #759 and deliberately not resolved here, because three consumers rely on the current default and one of them is this ledger’s own cropland anchor. create_n_prov_destiny()derives processed items itself, and the substitution now conserves nitrogen exactly. Processing shares and processed amounts come from theprocessing_coefspin instead of the externally builtprocessed_prov_fixedpin, and a processed item (wine, oil, flour) now subtracts its share from the primary crop’s own production rather than being added on top of it. Applying every output’scfto the same input mass did not conserve N — the sum of coefficients per input item reaches 5.22 for maize and 5.11 for wheat, because beer and starch are water-diluted — so the substitution added a net +1.875 Mt N (+1.56%) over 1860-2023, ranging from -2.75% to +7.84% by year. Output is now capped at the input’s N and only accounted N is removed, balancing to 2.1e-16 every year. Where the primary-crop N of a zero-N processed item (wine, olive oil, sugar) should go is still open (whep#432).-
The N left over from a zero-N processed-item substitution (wine, olive oil, sugar) now gets its own
processing_lossesdestiny instead of staying folded into the primary crop’s own destinies. Previously,removal_scaleonly ever removed the N actually credited to a named output, so a near-zero-N output (wine from grapes, oil from olives) left most of the diverted mass sitting with the primary crop, inflating that crop’s ownexportresidual. The full processed mass is now always removed, the credited share still goes to the processed item, and the remainder is booked asdestiny = "processing_losses",origin = "Cropland". No downstream surplus calculation (GRAFS plots, LMDI decomposition) tracks this destiny by name, so it falls into whatever each of them already treats as surplus, the same wayno_tracked_outputitems already do. Concretely,.create_land_surplus_df()computes cropland surplus as inputs minus tracked outputs, so reported cropland N surplus rises by exactly the amount removed fromexport. That is the methodological choice this destiny embodies: the residue (grape pomace, olive cake, beet pulp) is counted as surplus rather than as product, pending explicit by-product items.Fixed two latent bugs surfaced while doing this.
create_n_nat_destiny()re-derived national production as the sum of everyOrigin == Boxrow, which now includesprocessing_lossestoo, reinflating the nationalexportresidual by exactly the amount this fix removes provincially;processing_lossesis now excluded from that sum and re-added as its own row. And.combine_destinies()gave every row in a multi-row(Year, Province_name, Item)group a fullproduction_share = 1when their combined production was zero, instead of splitting evenly, so an item processed away entirely for a year duplicated its consumption once per remaining row (41 province-years, all Grapes in 1983, where fuller removal now reaches exactly zero where partial removal rarely did).Published values move. Over 1860-2023, the new
processing_lossesdestiny totals 1,899,115 Mg, averaging 2.97% of Cropland-origin flows and rising from 2.53% in 1860 to 5.34% by 2020; olives (1,460,624 Mg), barley (230,838 Mg) and grapes (139,855 Mg) account for essentially all of it.exportfalls by the same order in both outputs: from 44,226,105 Mg to 42,400,855 Mg (-1.83 Mt) increate_n_prov_destiny(), and from 17,691,588 Mg to 15,897,066 Mg (-1.79 Mt) increate_n_nat_destiny(), whoseexportis a net residual on a different basis. Reported cropland N surplus rises by 1,899,115 Mg, exactly the amount the new destiny carries. The remaining destinies move only by what the.combine_destinies()fix stops double-counting:livestock_rum-35,742 Mg,population_food-21,094 Mg,livestock_mono-12,812 Mg,population_other_uses-542 Mg.Croplandandsemi_natural_agroecosystemssoil inputs are unchanged. National totals close to +34,403 Mg (+0.0090% of total N). The GRAFS provincial chain runs to 2023 instead of stopping at 2021. The
n_balance_ygpit_all,npp_ygpit,intake_ygiacandn_excretion_ygspins now cover 1860-2023, so the internal 2021 clip and its coverage warning are gone. Processing coefficients now come fromget_processing_coefs()rather than the frozen July-2025 pin, so they track FAOSTAT as it is republished (whep#449), and with the pin refresh in whep#812 they now reach 2023 in full: 5,999 coefficient rows globally and 82 for Spain, against the 358 and 1 a stalefaostat-fbs-newvintage had allowed.population_ygcovers 1860-2023 as well, so its forward-fill is now a no-op. Three items whose processing is observed only to 2002 (coconuts), 2008 (sugar cane) and 2018 (palm kernels) are still held flat from those years, and all three are negligible in Spanish production. Published values move: the two new years are structurally complete (50 provinces, every origin and destiny) but national N falls from 5,096,664 Mg in 2021 to 4,607,682 Mg in 2022 and 4,203,893 Mg in 2023. That decline is in the input pin, not in this code — synthetic N drops 29.7% between 2021 and 2022 while cropland area stays flat at 50.56 Mha — and it has not been cross-checked against an independent source. Note also that the four analysis eras end at 2010-2020, so 2021-2023 appear in yearly and evolution views but in no period aggregate.The cropland destiny panel no longer silently drops items that have area but no tracked output.
.allocate_by_destiny_share()joined destiny shares onto item values with aleft_join, so an item with cropland area and N inputs but no output in the destiny table vanished from the compartment-factor decomposition entirely. Those rows are now kept and taggedno_tracked_output. Published values move: the recovered rows are 1.32% of cropland area and 1.55% of N inputs over the full span, rising from 0.05% in the 1860s to about 5% by the 2020s, so the trend changes and not only the level. They are almost entirely the oilseed complex — sunflower seed, rape and mustardseed, cottonseed and sugar beet — whose output the processed-item change reassigns, which makes them a quantified instance of whep#432.Three smaller accounting fixes in the typology and decomposition analysis. The Finn flow matrix was built transposed, so
.calculate_finn()read outflow where it needed inflow; mean FCI moves 0.1105 to 0.1102, but up to 0.035 absolute for a single province-year, and the 1860-versus-2020 trend tilts (whep#430).decompose_manure_losses()compared applied manure against an excretion total covering only the livestock-unit species in the panel, soloss_fracwas measured against a base 0.16% to 1.26% too small, a gap that widens over time. The N surplus panel filtered out negative values as noise; a negative surplus is a real soil deficit, and keeping it lowers pre-1950 decade means by up to 6% while affecting 86 of 8,200 province-years, all of them before 1950.-
build_historical_land_areas()no longer rasterises its own cell-by-polity intersection; it reads the polycell support. whep#776 built a second answer to a question whep#619 had already answered better:.polity_cell_cover()ranterra::extract(exact = TRUE)over every polity polygon, whereread_polycell_support()is the same intersection measured geodesically withsf::st_area()on s2, keyed on each polity’s validity interval, conserving by construction, and unable to give one cell to two overlapping polities at once. The rasteriser, its grid template, its lon/lat lookup and thesf/terrapackage assertion are all gone; this path now touches neither package. The weight ispolity_area_ha, the polity’s territory in the cell, renormalised to one per cell exactly as before — notland_area_ha, becausebuild_polycell_support()apportions inland water pro rata bypolity_area_ha, so within a cell the water cancels in that renormalisation except where its cap bites, and there 1,502 polycells covering 62.4 Mha (Canada on the Great Lakes and Hudson Bay, the USSR on the Caspian and Arctic shores) carryland_area_ha == 0and would lose their claim on the cell outright (whep#800).No published value moves on this commit, because
land_method = "present_day"is still the default and the"historical_polity"path reads thehistorical-land-areaspin rather than recomputing. What moves is whatdata-raw/historical_land_areas.Rnow produces, and the pin has to be regenerated and re-uploaded for any of it to reach a user. Regenerated over 1850-1961: 18,922 rows / 215 buckets becomes 17,187 / 198, global cropland −0.007% at 1850, −0.004% at 1900, −0.108% at 1950 and −0.440% at 1961, and Ethiopia is unchanged to four decimals at every checkpoint. 84% of shared bucket-years move by less than 0.1% and 87% by less than 1%. The large movers are territories the old route was halving: an aggregate’s polygon overlaps its members’, so a cell claimed by both was split between them, and Belgium came out at 0.567 Mha of 1961 cropland instead of 1.015, Luxembourg at 0.037 instead of 0.063, New Caledonia at exactly half and American Samoa at 51%.The loss of coverage is the other side of that, and it is the part to review.
build_polycell_support()excludespolity_type == "aggregate", because the support must be a partition and an aggregate’s polygon overlaps its members’. Nine reporting buckets whose only pre-1962 territory is such an aggregate therefore drop out — Belgium-Luxembourg, Yemen, the Netherlands Antilles and the six “Other” residual regions, together 2.04 Mha of 1961 cropland as the old route measured it — and Viet Nam keeps 1886-1953 unchanged but loses 1954-1961, the span its combined-reporting entity covers. The other eight (Cayman, Gibraltar, Mayotte, Anguilla, Turks and Caicos, Wallis and Futuna, South Georgia, the French Southern Territories, 0.001 Mha between them) carry a polygon inpolitiesbut no row in the published polycell pin, which predates the 2026-08-13 polity ingest; a refreshed polycell pin restores those.build_historical_land_areas()warns with the codes and separates the two causes, so neither loss is silent. -
The milk FAOSTAT reports as churned into butter is no longer counted as milk eaten.
cb_processinggained the one dairy pathway it lacked, “Milk - Excluding Butter” to “Butter, Ghee”. Without it, item 2848 carried aprocessingdestiny with nowhere to go, so.cbs_redistribute_notprocessed()split that mass pro-rata across food, feed, other uses and export and deleted theprocessingrow. The current behaviour was not an omission but a claim about diets: that 198 Mt of milk a year is drunk as milk (#757).Published values move, from 2010 onwards only. The old Food Balances do not report a
processingdestiny for milk at all — 1.0 Mt over 2010-2013 against the new series’ 837.5 Mt — so no year before 2010 changes. World 2010 across the 180 areas shared with thefaostat-fbs-newpin, Mt, WHEP before to WHEP after against FAOSTAT: milk food 649.1 to 497.3 against 497.2; feed 74.3 to 60.6 against 60.6; export 120.6 to 89.0 against 89.0;processing0.0 to 198.2 against 198.5. Milk food protein falls by 5.0 Mt, which is the whole of the milk discrepancy reported in #500 section 5. Butter is unchanged, production 9.27 Mt against FAOSTAT’s 9.37. The remaining 3.2% gap in milk domestic supply is the dropped losses/residuals/tourist renormalisation of #412, which this does not touch.The fraction is 0.045, the median “Butter of Cow Milk” extraction rate over the 69 countries reporting one in FAO (1997), Technical Conversion Factors for Agricultural Commodities (range 3.3-7.3%). Per-area calibration lifts it to an effective 0.0468 for 2010, against the 0.047 the FBS itself implies (global butter production over milk processing, 0.044-0.047 across 2010-2019). Every country reporting butter production also reports milk processing, and none reports butter without it.
.cbs_add_processed()gained.resolve_processed_production(), because butter is the first processing output outside the “Crop products” group, whose read production is dropped wholesale on the grounds that the pathway always replaces it. For butter the pathway is silent before 2010, so a positive pathway estimate now supersedes the read production and a zero or absent one leaves it standing. Without that distinction the trace of milk processing the old FBS records in some areas emits an empty butter row that cancels the observed one, taking world 2000 butter production from 7.378 to 3.527 Mt.Items other than milk still lose their processing destiny. Sugar (Raw Equivalent), animal fats, coconut oil and 13 smaller items have no pathway either, and roughly 17 Mt a year is still redistributed onto food and feed: 2010 coconut oil food is 58% above FAOSTAT’s, ricebran oil 45% and cottonseed oil 15%. Those carry almost no protein, so the nourishment axis is largely unaffected, but the mass accounting is not. That residue is unchanged here.
-
The polycell is now WHEP’s spatial support unit, and it carries a measured territory instead of a whole grid cell.
build_polycell_support()returns one row per 0.5-degree cell intersected with a polity over that polity’s validity interval, with the territory decomposed intopolity_area_ha = land_area_ha + inland_water_ha + ice_area_ha, all geodesic from a spherical (s2) intersection of the polity polygons. Aggregating polycells to a polity changes no absolute value and no quantity crosses a border it does not belong to, which neither of the two conventions it replaces could offer: centroid assignment gave a whole border cell to one polity, and the fractional crosswalk multiplied a valid partition of the land by the whole cell’s area. That last defect over-counted the global land base by 11.0% – 14.3195 Gha of whole cells against 12.9931 Gha of LUH2 terrestrial area – and it is the mechanism behind the inflated per-hectare deposition rates. New:build_polycell_support(),expand_polycell_years(),read_polycell_support(),read_glwd_water(),read_glaciated_areas(),read_luh2_terrestrial()andpolycell_example_geometries().-
Four definitions of “land” are live and they disagree by up to 10%, so a global area is only interpretable next to the one it was measured on. At 2015: whole 0.5-degree cells 14.3195 Gha, HaNi’s own land mask 13.5977 Gha, the union of the live polity polygons 13.4267 Gha, LUH2 terrestrial
(1 - icwtr) * carea12.9931 Gha. The support table’s territory is the third, but summingpolity_area_hadoes not reproduce it: the union is unique ground, while a sum counts shared ground once per claiming polity, so the sum at 2015 is 13.4599 Gha, above the union by the 0.0332 Gha two live polities both claim. The fourth is a validation layer whose disagreement is emitted in the"unassigned"attribute and never silently reconciled; the first is the convention being replaced. A fifth mask (the GLWD water layer’s CRU mask, 67,420 cells) is reconciled in"water_unmatched"rather than joined away. Re-derivable withinst/scripts/diagnose_polycell_support.R; the polygon row moves with the polity vintage and is measured byinst/scripts/reconcile_polity_areas.R. -
ice_area_hadoes not vary historically. It comes fromne_10m_glaciated_areas, a coarse present-day snapshot, so a historical run carries today’s ice extent and land that lay under ice in 1850 is credited toland_area_ha. That is accepted only because ice is a reporting category and not a driver: nothing divides byice_area_haor drives a flux with it. If ice ever becomes a driver the source has to be reopened. Inland water comes from the GLWD lakes-and-rivers layer at 30 arcmin (Ostberg et al. 2023, https://doi.org/10.5194/gmd-16-3375-2023), not fromne_10m_lakes, which carries roughly half of global inland water and omits the Caspian. -
The table keys on
polity_codeand nothing else.area_coderides along as a label.polity_area_crosswalkfolds 505 polity codes into 201 reporting buckets, 113 of which hold more than one polity and one of which- holds Sudan and South Sudan simultaneously, so a table whose purpose is correct territorial attribution is not keyed on it. Consumers convert at their own boundary, and that conversion is where the lossy fold happens – visible at the consumer rather than hidden in the support.
build_n_deposition()refuses an unconverted support instead of converting one silently.
- holds Sudan and South Sudan simultaneously, so a table whose purpose is correct territorial attribution is not keyed on it. Consumers convert at their own boundary, and that conversion is where the lossy fold happens – visible at the consumer rather than hidden in the support.
- The default grain is interval-keyed, one row per polycell per interval, because no area column varies by year;
expand_polycell_years()gives the per-year view on demand.start_yearis inclusive andend_yearis exclusive at a succession but inclusive at the open end, so a handover year resolves to the successor alone and the current year still resolves to the polity nothing succeeds. -
A repeated polycell key now aborts instead of losing territory in silence. The interval split reads the next breakpoint with
dplyr::lead(), which is the next breakpoint only while(cell_id, polity_code, start_year, end_year)is unique. Two rows sharing it interleave, every second row comes back withend_year == start_year, and an empty interval resolves to no year at all: measured on a two-piece fixture, 70 of a polycell’s 100 ha resolved to nothing at every year of its life, with no error, no warning and every conservation check still passing.build_polycell_support()now aborts with class"whep_pcs_repeated_key", naming the count and up to three offending keys. It does not sum the duplicates: a repeated key means the geometry table is not one row per polity interval, and repairing the arithmetic would leave the fan-out that produced it invisible. No published value changes: the shipped 753-row polity table repeats no(polity_code, start_year, end_year)among the 666 rows that get clipped, so no production build reaches the guard, and any input that did not carry a repeated key returns exactly the table it returned before.
-
Four definitions of “land” are live and they disagree by up to 10%, so a global area is only interpretable next to the one it was measured on. At 2015: whole 0.5-degree cells 14.3195 Gha, HaNi’s own land mask 13.5977 Gha, the union of the live polity polygons 13.4267 Gha, LUH2 terrestrial
-
Atmospheric deposition is now split as a mass over territory, and its two land definitions are separated.
build_n_deposition()splits each cell’s HaNi mass across the polities holding the cell in proportion topolity_area_ha(split = "auto"takes it when the support carries it and the oldpolity_fracotherwise; either can be demanded explicitly, and a demand that cannot be met aborts), then decomposes each polity’s share over land, inland water and ice (categories = "auto"). Both choices are recorded inmethod_polity_splitandmethod_area_split, so a table’s split is readable from the table.-
WHEP’s territory governs placement; HaNi’s land mask governs the total. The mass placed is HaNi’s block sum, and HaNi is referenced to the whole 5 arcmin cell inside a land-masked domain whose mask is a third land definition at 13.5977 Gha. Nothing re-references the mass to WHEP’s land: forming a rate on the whole cell and multiplying by
land_area_hawould shed about 9% of the source mass, and re-referencing to HaNi’s own mask would move the global total by about 4.5%. A global sum out of this function is therefore HaNi’s total redistributed onto WHEP’s territory, conserved exactly against the source (34.77 Tg NHx in 2014). Source: Tian et al. 2022, https://doi.org/10.5194/essd-14-4551-2022. -
Deposition scope is selectable and defaults to the whole territory.
build_n_inputs(data = list(deposition_scope = ))takes"territory"(default: land plus inland water plus ice) or"land", recorded inmethod_deposition_scope. The default is a scientific choice, not a conservative one: nitrogen deposited on a lake or a glacier still drives indirect N2O and still reaches the eutrophication pathway, so restricting the ledger to the terrestrial share would discard 0.89 Tg N of real flux that the impact terms have to account for."land"remains available for the purposes that want it and aborts if the support cannot be decomposed, rather than silently returning the whole territory. Under the default the ledger output is bit-identical to before the split. -
Known limitation, not a rounding error: eight reporting areas the deployed crosswalk carries – 61 Equatorial Guinea, 153 New Caledonia, 154 North Macedonia, 209 Eswatini, 212 Syria, 299 Palestine, 276 Sudan and 277 South Sudan – receive no deposition through the polycell path. The first six fold onto
ROW-1850-2025(polity_area_code999,fabio_row_fold) while their ownGNQ-,NCL-,MKD-,SWZ-,SYR-andPSE-codes resolve onto that same bucket 999 through thefabio_row_promotedrows added in #785, so their territory is folded into Rest of World rather than dropped: measured on this snapshot,GNQ-1968-2025builds 18 polycells (2,702,545 ha) andMKD-1991-202521 (2,539,428 ha), every row stampedarea_code999. Before #785 these codes carried no crosswalk row and were dropped outright, so the territory is now retained but still not attributed to the reporting area. Sudan and South Sudan do resolve, but both onto 206, Sudan (former), so neither 276 nor 277 is reachable on its own. The gap is identity, not extent: of the six with a directly comparable official area, all sit within 3.7% of it (Syria +0.90%, North Macedonia -1.23%, Eswatini -1.30%, New Caledonia +1.17%, Palestine +3.22%, Equatorial Guinea -3.65%). Fiji is no longer among them: since the polities refresh in #662 the crosswalk maps area 66 ontoFJI-1800-2025throughupstream_map, and the polycell build returns 60 polycells holding 1,871,003 ha, all measured ons2, reproducing the polity’s own polygon area exactly. How this ranks against the ledger’s other open terms has not been measured, so no claim is made about it.
-
WHEP’s territory governs placement; HaNi’s land mask governs the total. The mass placed is HaNi’s block sum, and HaNi is referenced to the whole 5 arcmin cell inside a land-masked domain whose mask is a third land definition at 13.5977 Gha. Nothing re-references the mass to WHEP’s land: forming a rate on the whole cell and multiplying by
-
Migrating a consumer onto the polycell: what to change and what moved. The transitional shim that let
build_polycell_support()masquerade as the old crosswalk (apolity_fraccolumn plus padding rows for cells the intersection did not reproduce) is gone. A consumer that used to multiply a rate bycell_area_ha * polity_fracnow multiplies it bypolity_area_ha, or byland_area_hawhen the quantity is genuinely terrestrial, and convertspolity_codeto its own reporting vocabulary before joining. Migrated here: deposition (build_n_deposition()), the synthetic-N grid split, the carbon path (build_carbon_balance()and its land inputs) and the compartment keying inspatialize()/spatialize_livestock(), which now abort on a support carrying no polity share instead of defaultingcell_area_frac = 1and handing a border cell wholly to one polity.-
Measured movement, at polity grain, is entirely in the deposition input term:
n_input_full_t-0.504%,n_balance_t-2.252%,surplus_t-1.055%,total_gwp_co2e_kg-0.137%. Of the -678,612.5 t N, 95.6% (-648,491.3 t) is unreachable reporting areas and only -30,121.2 t (0.107% of the term) is geometry, dominated by Canada (-1.03%). The split key itself moves 27 of 28 ledger quantities by exactly zero and the 28th by one ulp; the synthetic term moves by exactly 0 t. Basis, because it has since moved: this was measured on the polity vintage before #662, on which Fiji was unreachable too, so the unreachable share above spans nine areas rather than the eight that remain. Fiji’s part of it has not been re-measured – doing so needs the HaNi deposition rasters, which the measuring environment does not carry – so the figure is left as measured and its basis named rather than restated over a population it was not measured on. - The island states are fixed. Against official land areas, Kiribati goes from 34.3x to 1.18, Micronesia 17.5x to 1.00, French Polynesia 15.3x to 1.16, Maldives 10.3x to 0.58 – they used to draw a whole 0.5-degree cell each while carrying no LUH2 terrestrial area at all.
- Greenland reads as +419% against FAOSTAT and is not a defect: FAO’s country area for Greenland “refers to area free from ice”, so the comparable quantity is WHEP’s territory minus its 177.5 Mha of ice, which reads -12.9%.
-
Six
polity_fraccall sites remain and are deliberate. Dropping"polity_frac"fromutils::globalVariables()was used as a detector, and it named exactly six unqualified uses:.wb_finalise(),.wb_drop_polity_cols()and.wb_aggregate_polity()inwater_balance.R,aggregate_grass_to_polity()infeed_lpjml.R,.grass_to_cells()infeed_intake_redistribute.R, and.read_fraction_country_grid()inrun_spatialize.R. All four files are out of scope for this migration – the water balance is owned elsewhere, the feed path is frozen, and.read_fraction_country_grid()reads the deployed crosswalk on purpose – and they are not an oversight or an unfinished migration. The detector has done its job, so"polity_frac"is restored toutils::globalVariables()andR CMD checkis back toStatus: OK. Without it the check reportedStatus: 1 NOTEwhere merge-base main wasStatus: OK, on a check CI cannot fail:check-r-package@v2defaultserror-on: warning.
-
Measured movement, at polity grain, is entirely in the deposition input term:
-
A traded item with no production row now balances instead of vanishing.
.reestimate_domestic_supply()derives a last-resort domestic supply fromproduction + import - exportfor rows that report neither a supply nor a destiny.productionis deliberately stillNAat that point, so the imputation further down can derive it (#142), but reading it raw made the residualNA, anddplyr::if_else(NA, ...)isNA, so bothdomestic_supplyandstock_variationcame outNA. Those rows were then dropped by thevalue != 0filters downstream rather than balancing. A missing production now counts as zero in that residual only; the imputation itself is untouched.Published values move, slightly and in one direction. On a 2010 build: 12 rows are recovered and none is lost (17,648 to 17,660); 81 rows gain a domestic supply that was wrongly zero, the largest being Ireland “Miscellaneous” at 79,000 t, Switzerland at 22,000 t and Yemen tea at 17,000 t; world domestic supply rises 212 kt on 63,388 Mt (+0.0003%) and food 181 kt on 4,836 Mt (+0.004%). Every change is upward from zero. The supply-use identity improves sharply: rows off by more than 1 t fall from 144 to 67, the worst residual from 160,000 t to 29 t, and the 12
NAresiduals disappear. -
build_sjos_nitrogen()gainsnourishment_band, which makes every band choice selectable from the driver. The quality tier (quality_method/quality_variant), the loss wedge (wedge_method/wedge_coverage) and the band’s ownshortfall,ceilingandrequirement_sdwere all reachable on their builders but frozen at their defaults inside the driver, so a sensitivity could not be run end to end.ceilingin particular is the knob the band’s documentation names as WHEP’s own criterion and asks callers to sweep.An option the list does not recognise aborts. A sensitivity analysis is the worst place for a silently ignored argument: the run completes, nothing moves, and the sweep gets reported as showing insensitivity. Nothing is defaulted in the driver either — an option the caller omits is simply not passed on, so each builder’s own default applies and the two cannot drift.
-
Three guards against silent row multiplication and silent gaps, all found by review of this branch rather than in the field:
-
normalize_nourishment()now aborts when a data-framethresholdscarries two rows for oneyear/area_code. It previously duplicated the country, once per candidate band and each with its own class, so every headcount downstream counted it twice.build_nourishment_band()output is unique by construction, but the argument accepts any data frame. -
normalize_nourishment()now also warns when a matched band has a missing ceiling. The check tested the floor alone, so such a row scoredNAand disappeared from the classification without a word. -
read_wpp_population()now drops and names ISO3 codes the crosswalk does not resolve, instead of returning them on a missingarea_codeforbuild_protein_requirement()to weight into anNA-keyed country. On WPP 2024 that is 7 codes at 0.03% of world population, Kosovo the largest.
build_loss_wedge()additionally asserts one Annex 1 region per area. The packaged tables satisfy it, butdata$food_loss_regionsis injectable and two regions for one area would weight its whole basket twice, at two rates. -
-
build_protein_quality()gains tier 1a and makes it the default: per-item measured digestibility instead of a two-rate class split.method = "trs935_item"uses the true digestibility TRS 935 Table 5 publishes for each commodity — now packaged verbatim asprotein_digestibility_trs935— and falls back to the tier 1b class rate for items the report does not measure. Table 5 prints no fruit, vegetable, root, tuber or sugar row at all, so the fallback is not a corner case: on the 2010 world basket 84.5% of food protein carries a measured value and the rest takes the class rate.protein_measured_sharereports it per row.On that basket the diet quality moves from a tier 1b median of 0.867 to 0.891 (0.818–0.940), lowering the floor from 67.77 to 66.23 and the ceiling from 98.13 to 96.04. Against the flat band 50 of 167 countries change class (58 under tier 1b), and world headcounts are 216 million below requirement and 2,438 million above twice the safe level.
variantbrackets the one judgement tier 1a makes. Table 5 prints several forms of the same commodity and CBS cannot say which was eaten. The processing direction is not uniform — refining raises wheat (whole 0.86 → flour white 0.96, bran removed) and lowers maize, rice and oats (0.85 → 0.70, 0.88 → 0.75, 0.86 → 0.72, through extrusion and Maillard damage) — so there is no single axis to sweep and the bracket is carried per item."default"takes the least-processed form, the consistent partner for WHEP’s whole-commodity agronomic nitrogen;"low"and"high"give a diet-quality span of 0.853 to 0.913 at the median country. The choice is stamped inmethod_quality("trs935_item_default"), so a sensitivity is self-labelling. -
New
build_protein_score(): tier 2 of the protein-quality ladder, the full aggregate PDCAAS. It implements the aggregation FAO prints as a worked example in WHO/FAO/UNU TRS 935 Table 6 — digestible protein per item, the digestible-protein-weighted amino acid profile, the minimum ratio against the age reference pattern, truncated at 1 and multiplied by diet digestibility.Averaging per-item scores is not an approximation of this. FAO forbids it in words twice (TRS 935 p.99, FNP 92 p.17) and FNP 51 p.37 gives the reason. Because
min()is concave, the average of item scores is a rigorous lower bound on diet quality and so a rigorous upper bound on the floor. The digestible-protein weighting is the correction TRS 935 makes to its own 1991 report; on Table 6 it moves the lysine profile from 44.14 to 44.34 mg/g.Truncation follows the TRS 935 convention — score truncated at 1, then multiplied by digestibility, so the ceiling is the diet’s digestibility — not FNP 92’s, which truncates the DIAAS itself at 1.0. For a diet at score 1.4 and digestibility 0.85 the two differ by 18% of the floor, and it bites on exactly the animal-rich diets that truncate.
The function is code-complete and validated but not yet wired: it needs a per-item amino acid composition table WHEP does not have. It ships now because the aggregation is the part that is easy to get wrong, and it can be validated today against FAO’s own example.
New packaged table
protein_digestibility_trs935: TRS 935 Table 5’s 35 measured true-digestibility values (26 single foods, 9 mixed diets), transcribed verbatim. It is the input tier 1a needs, and it records the milling spread that CBS cannot observe — wheat whole 0.86 against refined 0.96, and three distinct maize rows at 0.85 / 0.87 / 0.70.-
PUBLISHED VALUES MOVE: the SJOS-N nourishment axis now classifies against a composed, per-country-year band instead of a flat 62.1 / 85.05.
build_sjos_nitrogen()gainsnourishment_thresholds, defaulting to"composed".normalize_nourishment()accepts a data frame of per-row bounds — abuild_nourishment_band()output passes straight through — alongside the scalar pair it always took.On the 2010 build, 58 of 167 countries change nourishment class, 21 of them Adequate → Under and 37 Over → Adequate. The floor moves from a flat 62.1 to a median 67.77 (58.79–88.81) and the ceiling from a flat 85.05 to a median 98.13 (84.84–107.70). Where the axis had one number for every country it now has a distribution, built from four sourced terms: the demographic requirement, within-country intake dispersion, the unavoidable-loss wedge and diet protein quality.
Those figures are measured with tier 1b protein quality, the default when this landed. Tier 1a became the default later in the same release and is what ships: 50 of 167 change class, the floor median is 66.23 and the ceiling 96.04. See the tier 1a entry at the top for the full comparison.
nourishment_thresholds = "flat"restores the old pair for continuity and sensitivity. It is not a peer of the default: of its five underlying numbers only the 46 g/cap/day floor was ever sourced, and the 1.35 multiplier behind both bounds was a preliminary presentation figure (whep#753).A row that matches no band is classified
NAand named in a warning — it never falls back to the flat pair, which would mix two threshold vintages inside one classification.One number in the composed band remains WHEP’s own criterion rather than a sourced value:
ceiling$share, the tolerated fraction of a population above twice the safe level, default 0.5. TRS 935 declines to set a tolerable upper intake, so nothing external fixes it. It is selectable, stamped inmethod_ceiling, and any published use should carry a sensitivity across it. -
New
build_protein_quality(): the band is no longer on crude protein. TRS 935 issues its safe level “for proteins with a protein digestibility-corrected amino acid score value of 1.0” (section 14.2). No real diet reaches 1.0, so every uncorrected band was low by at least1/D— for every country, in one direction.method = "digestibility_share"takes the diet’s digestibility as the protein-weighted mean of 0.95 for animal and 0.80 for plant protein, which is how TRS 935 Table 43 footnote b computes it. The animal/plant split follows FAO’s own Food Balance Sheet grouping and reconciles against FAOSTAT’s published aggregates to within 0.07% on each side.This is tier 1b of four, and a provable lower bound on the full correction, since PDCAAS is
min(1, AAS) × D ≤ D. It is conservative about the size of the correction, not about adequacy: it under-corrects and so classifies fewer countries deficient than the full amino acid score would. Tier 2 needs a per-item composition table WHEP does not have; when it lands it becomes a new method rather than silently changing this one.Quality divides both bounds, which is algebraically the diet-side correction TRS 935 section 14.1.5 prefers: it keeps the published supply series untouched and moves floor and ceiling together, where correcting only the floor would leave the ceiling on crude protein.
On the 2010 build the diet quality runs 0.82–0.91 (median 0.87), lifting the floor from a crude median of 58.60 to 67.77 g/cap/day and the ceiling from 85.68 to 98.13. That reverses the headline: the composed floor now sits above the retired flat 62.1, not below it. Against the flat band 58 of 167 countries change class, up from 21 without the correction, with 21 moving Adequate → Under. World headcounts move from 99 to 266 million people below requirement and from 3,278 to 2,258 million above twice the safe level.
These are the tier 1b figures, measured when nothing yet composed the term. Both statements were superseded within the same release: the composed band became the default of
build_sjos_nitrogen(), so the values do move, and tier 1a became the default quality method. The shipped figures are in the tier 1a entry at the top. -
read_population()can now fill its coverage gaps from UN WPP, and always reports where each row came from (#644). Thegdp-populationpin does not reach every area WHEP models, and the two per-capita consumers inner-join it, so an uncovered area is absent from their output rather than wrong in it. The newpopulation_source = "pin_wpp_fallback"fills only the country-years the pin does not reach, fromread_wpp_population(): on the real inputs that is 44 areas the pin has no row for at all (Réunion, Bhutan, Comoros, Western Sahara, New Caledonia, the French overseas departments and the small island states) and 4,755 country-years inside the pin’s own year span.The pin wins wherever both have a value, so turning the fallback on cannot move a denominator that was already published — it can only add one that was missing. It is a gap-filler rather than a replacement because the two sources disagree where they overlap: across 12,309 shared country-years by a median 0.64%, a 95th percentile of 4.4% and a maximum of 81%.
The output gains
source_pop, carrying the pin’s own vocabulary ("Original","Linear interpolation","First value carried backwards"), joined with" + "where anarea_codebucket sums ISO3 codes of differing provenance, or"UN WPP 2024"for a filled row.No published value changes: the default is
"pin". -
nourishment_thresholdsnow says which of its numbers are sourced, and its upper bound is renamed"ceiling". Four of the five values the shipped nourishment axis runs on had no source and nothing said so. A newprovenancecolumn records it per row: only the 46 g/cap/day protein floor is cited (WHO/FAO/UNU TRS 935 Table 46, the safe intake of a 55 kg adult — itself a 97.5th-percentile individual level that TRS 935 p.41 says is incorrect to apply to a population). The 63 ceiling, the 2300 and 2900 energy bounds and the 1.35 factor are labelledinherited_unsourced.Breaking for anyone filtering the table:
bound == "target"is nowbound == "ceiling"and returns zero rows under the old name.normalize_nourishment()uses that value as the top of the Adequate band, above which a country is classified Over, so “target” read as something to aim at — the opposite of its role.normalize_nourishment()also stops presenting protein and dietary energy as interchangeable. The arithmetic is shared, the bases are not: the energy bounds are unsourced, and WHEP’s energy column is gross combustion energy where a dietary kcal threshold is metabolisable. Nothing in the package reads the energy path.No published value changes: the floor and ceiling are numerically unchanged at 62.1 and 85.05 g/cap/day and every classification is identical.
-
New
build_nourishment_band(): both SJOS-N bounds are now composed from sourced terms, and the 1.35 multiplier is gone from each. It implements WHO/FAO/UNU TRS 935 Box 1 — log-deficit normal withS_D = sqrt(S_I^2 + S_R^2), prevalencePhi(-M_D/S_D)— and inverts it twice to give a floor and a ceiling on mean per-capita protein supply:bound = anchor * exp(z * S_D + S_I^2 / 2) / (1 - omega)The floor anchors on the demographically weighted average requirement at
z = qnorm(1 - shortfall); the ceiling onmultipletimes the demographically weighted safe level atz = qnorm(share).multipledefaults to 2, which TRS 935 section 13.7 calls “twice the recommended intake, previously identified as a safe upper limit … likely to be safe”; 3–4× is the report’s own sensitivity (“approach the tolerable upper limit”).The two tails do not take the same tolerance, and the model says so. Applying the floor’s 2.5% to the upper tail puts the ceiling below the floor for 162 of 167 country-years, because TRS 935 calls intakes below requirement harmful while calling twice the safe level “unlikely to be associated with any risk”.
sharetherefore defaults to 0.5 — “Over” means the typical member exceeds the limit — and that 0.5 is WHEP’s construction, not a sourced value. At 0.5 the band never inverts: the lowest ceiling (74.81) exceeds the highest floor (73.75).On the 2010 build, floor median 58.60 (53.0–73.8) against the flat 62.1, and ceiling median 85.68 (74.8–91.5) against the flat 85.05. The ceiling’s agreement with the retired number at the world median is a coincidence worth noting and not a justification: 85.05 was 63 × 1.35 and flat, this varies by country through demography, inequality and loss.
It also reports how many people, not only the country’s class:
prevalence_protein_deficit,prevalence_protein_excess,people_underandpeople_over. A country is not uniformly under or over — on the 2010 build 99 million people are below requirement and 3,278 million above twice the safe level, and the share below requirement ranges 0% to 48.9% within the countries the flat band called Adequate.The anchor is the average requirement from
build_protein_requirement(), not the safe level, because TRS 935 says applying an individual safe level to a population is incorrect (p.41) and a safe population intake “cannot be defined as a simple function of the mean requirement” (p.241). Passing a safe-level requirement warns, because the formula adds its own population margin and would count the requirement margin twice.shortfalldefaults to 2.5%, fixed independently by TRS 935 Figure 7 and by FAO’s stated lowest feasible PoU target.requirement_sddefaults to TRS 935’sS_R = 0.12and is exposed because the report itself notes that captures only about a fifth of observed between-individual variance.On the 2010 build the median country floor is 58.6 g/cap/day against the shipped flat 62.1, ranging 53.0 to 73.8, with 39 of 167 countries above 62.1. Where the axis had one number for every country it now has a distribution: demography pulls the requirement down to a median 32.0 g/cap/day, and the dispersion margin (median
S_D0.29) puts most of it back.The protein-quality term is not built, and the floor is a known understatement without it — TRS 935’s safe level is defined for a PDCAAS of 1.0 and real diets score below that, a level shift the evidence record puts at +11% to +36%.
quality = "none"is stamped inmethod_qualityso the method name cannot silently change meaning when the term lands.No published value changes. Nothing calls this function yet;
normalize_nourishment()still uses the flat threshold. -
New
build_loss_wedge(): the nourishment floor can now allow for the food that never becomes intake. The floor asks whether supply can meet needs, so it has to account for loss between the retail shelf and the mouth — but only for the part no food system avoids. Avoidable waste belongs to the over-nourishment problem, and inflating the floor by it would turn a behaviour problem into an apparent adequacy failure;omega = 0, meanwhile, asserts that all edible loss is eliminable, which SDG target 12.3 does not even aim at.The wedge is built from Gustavsson et al. (2011) Annex 4, composing only the two steps at or after retail —
DistributionandConsumption— because FBS food availability is already measured at the retail level and includes retail and consumer loss. The default"gustavsson_half_min"takes each rate’s minimum across the seven world regions and halves it, giving roughly 2.5% of protein on the 2010 world basket (floor divisor 1.026). It is documented as a deliberate lower bound, not an estimate of achievable loss: the consumption-step minimum is sub-Saharan Africa in every commodity group, and those are scarcity figures rather than efficiency figures."gustavsson_min"(roughly 4.9%) and"none"are selectable, and the choice is stamped inmethod_loss_wedge."gustavsson_regional_actual"is the sensitivity arm, giving each country its Annex 1 region’s own observed rates: 14.2% on the same basket, divisor 1.166, spanning 4.1% to 21.4% across countries. It is not an unavoidable-loss estimate, and its country structure is contested — Gustavsson’s rich-high gradient runs opposite to UNEP’s Food Waste Index — so it quantifies that disagreement rather than settling it. Annex 1’s 152 countries cover 99.0% of 2010 world food protein; the rest take the mean rate across the seven regions and are stampedmethod_region = "global_mean", or return nothing undercoverage = "annex1_only".FBS element 5123
Lossesis deliberately not used: it is pre-retail and already netted out of the Food element, so subtracting it would double-count.No published value changes. Nothing calls this function yet; the axis still uses the flat 1.35 multiplier until the remaining terms land. Two packaged coefficient tables are new (
inst/extdata/coefs/food_loss_wedge.csv, the Annex 4 rates, andinst/extdata/coefs/food_loss_item_groups.csv, the Annex 2 item-to-group mapping); both are recorded invalidation/SOURCES.md. -
New
build_protein_requirement(): the nourishment floor can now account for a population’s age and sex structure. The SJOS-N “just” axis has always compared per-capita protein supply against a flat 46 g/cap/day, which is WHO/FAO/UNU TRS 935’s safe intake for a 55 kg adult applied to whole populations including children. Children need far less in absolute terms (17.1 g/day at ages 4-6), so the flat value overstates every population’s requirement, and most in the youngest. The new function weights the TRS 935 per-class requirements by an injected population-by-age-and-sex table.It defaults to
requirement = "average", the class average requirement, because TRS 935 states that applying an individual safe level to a population is incorrect (p.41) and that a safe population intake “cannot be defined as a simple function of the mean requirement” (p.241);"safe"remains selectable for continuity. This does not lower the eventual threshold — the margin that turns an average requirement into a supply floor is applied downstream, once, over the convolution of requirement and intake variability.No published value changes yet. Nothing calls this function; the axis still uses the flat floor until the remaining terms land. The packaged requirement table is new (
inst/extdata/coefs/protein_requirement.csv), and its derivation and the TRS 935 tables behind it are recorded invalidation/SOURCES.md. -
Rice from the new FAOSTAT Food Balances is now converted to milled equivalent, so CBS item 2807 is on one mass basis. FAOSTAT publishes rice on two bases depending on vintage: the historic series carry item 2805 “Rice (Milled Equivalent)” and 2804 “Rice (Paddy Equivalent)”, while the new Food Balances carry item 2807 “Rice and products” in paddy (rough-rice) equivalent.
.fix_item_codes()selected rows for the paddy-to-milled conversion by item name, and “Rice and products” was in neither of the two names it matched, so new-FBS rice was never converted. Sincebuild_primary_production()does convert its own rice, a single item mixed milled production with paddy utilisation, and the difference was absorbed by the residualstock_variationplug. The extract path now recognises the new-FBS name as paddy; frames that have already been through theitems_fulllookup keep the previous behaviour, because there “Rice and products” is the canonical label and carries no basis information (#751).Published values move. Every element of item 2807 sourced from
faostat-fbs-newfalls by the 0.67 extraction rate. World 2010, tonnes: food 570,038,000 to 381,925,460; production 694,377,000 to 465,232,590; domestic supply 684,012,000 to 458,288,040; imports, exports, feed, seed, processing and other uses likewise. The corrected figures land close to FAOSTAT’s own published milled-equivalent series: India 2010 production is 96,455,210 against FAOSTAT item 2805’s 96,023,000, a 0.45% difference which is the gap between WHEP’s global 0.67 and FAO’s implied 0.667. Every downstream consumer of rice tonnage inherits the change, including the nourishment axis, where rice protein per tonne of food moves from 1.550x FAOSTAT to 1.039x and which was how the defect was found (#500).The historic series moves too, and by more than the FBS-new years. The old-to-new FBS harmonisation derives its scaling ratio from the 2010-2013 overlap, so with the new series on paddy and the old series on milled it was computing a median ratio of 1.4981 (= 1/0.667) for rice and scaling every FBS_Old rice year up by it — well inside the [0.2, 5] band
.clamp_fbs_scale_ratio()allows, so nothing flagged it. That ratio is now 1.0037: the two vintages agree on rice to 0.4% instead of disagreeing by 50%. Wheat, which uses one basis in both vintages, is unchanged at 1.016 and serves as the control.validation/rice_mass_basis.Ris the real-data guard. -
A promoted Rest-of-World member now publishes under its own territory, not under the bucket’s aggregate polity. Lifting the FABIO Rest-of-World fold had promoted a member’s numeric
polity_area_codeand nothing else, so all 62 folded areas reported as themselves (area_code == polity_area_code) while still carryingpolity_code == "ROW-1850-2025",polity_type "aggregate",continent "World"and no geometry – a row that reports as itself and is identified as somewhere else.data-raw/table_mappings.Rno longer discards the upstream FAOSTAT map’s answer for those areas: 36 map rows over 31 areas that reached no crosswalk row at all are now carried asmapping_source == "fabio_row_promoted", and.unfold_rest_of_world()chooses between them and the fold row per mode. This is an identity change, and it moves quantities only at the third decimal place of a percent. Over a fullget_primary_production()(6,310,390 rows) andget_wide_cbs()(2,184,850 rows) no row and no key is added or removed;reporting_polity_code/reporting_polity_namechange on 212,163 production rows across 22 areas – Syria toSYR-1946-1967before 1967 andSYR-1967-2025after it, Eswatini toSWZ-1894-2025, New Caledonia toNCL-1800-2025, Palestine toPSE-1948-2025, and 27 more. The resolution is year-aware, so a 1950 row and a 2020 row of the same area need not agree. The 30 members the upstream map names nowhere stay onROW-1850-2025; the newrow_promotion_status()reports which is which and why, splitting them intoown_polity(31),polity_unmapped(6 – a live polity exists upstream and only the map row is missing) andno_polity(24, three of which are not territories at all).options(whep.unfold_rest_of_world = "none")still restores the fold crosswalk exactly, column for column.The quantities that do move are these, and both are pre-1961. 64 rows and 1,722,000 t of historical trade for Guadeloupe and Martinique are recovered: their pre-1850 rows used to be dropped because
ROW-1850-2025begins in 1850 andadd_polity_code()refuses to extend an aggregate, and they now land onGLP-1816-2025/MTQ-1816-2025(historical trade feed +0.0093%). And 430 CBS rows (0.02% of the table, 340,474 t of movement, or 3.5e-6% of its tonnage) shift between columns in 10 areas, 96% of it Eswatini reclassifying export as seed;production,stock_additionandstock_withdrawalare identical to the last bit. Inget_primary_production()338 rows (0.005%) move by at most 5.6e-4 int_LU, in Italy, the Netherlands and Belgium, through the global-yield denominator:.fill_yields()keys on theareaLABEL as well as the code, and that label is resolved per year, so an area whose polity changes mid-series has its rows completed under both labels. 39 area codes already did that before this change and 2 more (Syria, Equatorial Guinea) now do; the pre-existing defect is filed separately.Two further consequences worth naming:
polity_coverage_gaps()now reports FAOSTAT areas 42, 88, 154, 180 and 187 as coverage gaps, because their upstream periods do not span the years FAOSTAT reports them – the fold hid that behind a period running to 2025 – and the energy CO2 extension’s opt-inunclassified = "polity_region"treatment reaches 16 live areas instead of 2, resolving the second half of #415/#646. Its default ("drop") is unchanged and moves no number. A back-cast row no longer reports
mapping_status == "matched"for a polity that was not alive in its year.add_polity_code()floors the polity-lookup year atbackcast_anchor(1961), because a pre-1961 WHEP value is a reconstruction on the anchor year’s territory – that convention is unchanged. What was wrong is that the row then claimed the polity had existed then, and for 12,208 of the 29,415 pre-1961(area, year)cells it had not: FAOSTAT area 238’s 1850 row readETH-1952-1993,matched, 102 years before that polity began. Those rows now reportmapping_status == "backcast_anchor", andpolity_coverage_gaps()reports them asgap_kind == "backcast_anchor"alongsidepolity_ended/polity_not_started. The floor was applied before the span check, so the diagnostic could previously see only 2,664 of the 12,208 cells; it now sees all of them, 9,544 of which are new.polity_bucket_coverage()surfaces the same resolver column, so itsbucket_mapping_statuswould read"backcast_anchor"for a pre-1961years =argument; on the shipped crosswalk no bucket folds more than one polity before 1961, so it emits no such row today, and itscoverageclassification is unchanged either way. No published value changes – a fullget_primary_production()(6,310,390 rows) isidentical()across the change,mapping_statusis not on any published schema by default, and thepolity_validityargument keeps its current scope, so"drop"still drops only nearest-period stand-ins (#763).The polities snapshot is re-synced to
whep-polities2830fb7, and no published value moves.politiesgains four rows (ATF-1800-2025,SGS-1800-2025,WLF-1800-2025andFEZ-1943-1951) and ten geometries, four wrongcow_codevalues are corrected (Albania 400 to 339, Comoros 403 to 581, Sao Tome and Principe 411 to 403, Sardinia 338 to 325), and six predecessor/successor edges are filled in.polity_label_aliasesgains theLibya Fezzanalias and three correctedyear_startbounds.gleam_geographic_hierarchyresolves all 204 territories:ATF,SGSandWLFcarriedNAfor want of any upstream polity and now carry one.polity_area_crosswalkkeeps all 595 rows with every routing column bit-identical – only threecow_codecells and onepolygon_statuscell change – so a fullget_primary_production()(6,310,390 rows, 1850-2023) comes back identical in all twelve columns, with no key added or removed, no(area, year)re-attributed and a zero delta in all eight units. Note for anyone reading #745: the 31 areas the upstream map names but the crosswalk resolves throughROW-1850-2025are not a stale-map artefact and this re-sync does not move them; they are the FABIO Rest-of-World fold, which outranks the map on purpose and is tracked separately (#717, #740) (#745).The pre-1962 back-cast can now measure its hectares on each year’s own borders.
tonnes = ha * t_ha: the yield half has always been historical (.fill_yields()back-castst_haagainst 1,058,295 pre-1962 observations), while the area half came from theluh2-areaspin, which is LUH2 land pre-aggregated to present-day ISO3. A row labelled with the 1961 entity was therefore measured on the borders that entity has today. The newbuild_primary_production(land_method = "historical_polity")measures it withbuild_historical_land_areas()instead: gridded LUH2 summed inside the polygon of the polityarea_coderesolves to in that year, resolved unfloored. How a change of territory reaches the back-cast is itself selectable, becausefill_proxy_growth()reads only ratios:boundary_step = "level_step"(default) lets a change of territory through as a level step, because a different polity is a different thing being measured, and"relink"re-measures the previous year inside the incoming polygon so only within-territory growth is ever used. On Ethiopia in 1952, when Eritrea joins, the 1952 land ratio is +8.0% under the default and +1.9% under"relink"."relink"suits a FIXED-territory series and is not the conservative choice here: suppressing that channel also suppresses the correction, and Ethiopia’s 1850 cropland comes back to 3.24 Mha against a present-day 3.22 – the figure this method exists to replace. Under the default it is 1.52 Mha (whep#761). No published values move by default:land_method = "present_day"is unchanged and is what the pipeline still runs. Measured over 1850-1961 against the present-day series, the historical method moves 19.2% of back-cast crop tonnage at 1850 (net -17.2%), 6.5% at 1900 and 0.2% at 1961 under"relink"; 31.3% / 22.9% / 0.2% under"level_step". Under the new method pre-1962 rows are labelledLUH2_polity_cropland/LUH2_polity_agrilandinsource. It reaches all four dissolved federations withoutfederation_land = "successor_union"– Czechoslovakia, the USSR, Yugoslavia and Belgium-Luxembourg all have polygons of their own, and the USSR walks its own three-period chain back to 1850. It also declines to measure a bucket whose polity that year is a residual standing in for dozens of areas, or a resolver stand-in from outside its period: 5 buckets carrying 1961 crop tonnage lose their back-cast entirely, 0.1% of the 1961 total, the largest being Syria (#761).The SOC climate driver read releases the LPJmL hydrology pin once it has been used. The pin carries
swc_topsoil,prec_mmandirrig_mmfor every requested year – ~12 GB at 1901-2022 – and nothing reads it after the soil-water and monthly-climate series are derived from it, but it stayed referenced through the joins in.assemble_soc_drivers(), which is where the read peaks. Peak for a full-span.cb_read_climate()goes from 43.0 GB to 36.0 GB at unchanged runtime (#624).The SOC climate drivers assemble a year at a time, and stop decorating an 86-million-row table. Two changes to the same read.
.socd_monthly_climate()joined four full-span monthly series on(lon, lat, year, month); the joins and the water balance are all within-year, so they now run per year. Andbuild_carbon_balance()no longer routes through the reporting-polity decoration: those four columns – two of them character – cost ~28 GB on the table a full span produces, and the carbon balance never reads them, keying its climate modifier on(lon, lat, area_code, year, month)and adding its own reporting columns to its own output. Polity validity still applies, since it can drop rows. Peak for an 80-year.cb_read_climate()goes from 56.5 GB to 28.1 GB and it is a third faster (205 s vs 303 s), with all 17 shared columnsidentical(). The exportedget_soc_climate_drivers()still returns the polity columns (#624).build_carbon_inputs()collapses each year’s gridded cropland inputs before gridding the next. The gridded table is ~1.25e6 rows per simulated year and its only consumer,.ci_cropland_class(), keeps about one row in forty-two – so accumulating every year first built a 1.5e8-row table at 1901-2022 and then copied it again to bind. Reducing inside the per-year loop keeps only the collapsed years. Peak for a 40-yearbuild_carbon_inputs()goes from 25.4 GB to 14.4 GB (37.8 GB before #738), with outputidentical()across all 5,275,974 rows and 12 columns. The exportedbuild_soil_carbon_inputs()still returns the full per-crop detail (#624).The HWSD readers aggregate in latitude bands instead of one whole-grid pass. Classifying the 30-arcsec HWSD grid in one go materialised ~11 GB of full-resolution intermediates to produce a few MB, and
terra::crop()pulled the whole grid into memory before any aggregation began. Every aggregated cell draws only on the source pixels beneath it, so the work splits by latitude band with no cross-band dependency as long as each band is a whole number of target rows. Peak per call goes from ~16.8 GB to ~2.6 GB and each call is faster (clay 20 s to 23 s, hydraulic 60 s to 67 s, soil pH 23 s to 26 s on a loaded machine; ~2.5x faster when measured alone). Output isidentical()at all three call sites –.cb_hwsd_clay(),read_soil_hydraulic()andread_soil_ph(). This supersedes the per-call-site reclaim added in #735, which only covered one of the three (#624).polity_area_crosswalkno longer gives an area a polity the upstream map awarded outside its fold (#741). The prefix expansion removed a candidate only when it overlapped a map span of its own area, so nothing ever asked whether upstream had already named that polity elsewhere. FAOSTAT area 62 Ethiopia PDR was therefore handedETH-1993-2025, which area 238 owns, and it escaped the same-area test on a boundary year (1993 is not<= 1992). The exclusion now also fires when the map’s owner sits outside the candidate’s fold, and the crosswalk goes from 596 to 595 rows. The mirror- image row(238, ETH-1952-1993)is deliberately kept: area 62 folds into bucket 238,reporting_polity_codeis resolved from the bucket code, and that row is the bucket’s whole pre-1993 coverage. One published identity moves, no quantity does.regions_full’s row for area 62 “Ethiopia PDR” now carriesreporting_polity_code = "ETH-1952-1993"/ “Ethiopia (1952-1993)” instead of"ETH-1993-2025"/ “Ethiopia” – that area dissolved in 1993 and never was the modern republic, so this is a correction. It is the only row of the only dataset that moves. Resolving all 266 area/bucket codes over 1850-2025 (46,816 pairs) moves 33, all of them area 62 in 1993-2025, years in which area 62 both published nothing and no longer existed. Confirmed on a real 6,310,390-rowget_primary_production(): area 62 contributes 0 rows, and Ethiopia’s bucket-238 rows still split 35,558 pre-1993 toETH-1952-1993and 10,057 from 1993 toETH-1993-2025. One consequence is now visible rather than hidden:.bucket_year_polity_conflicts()reports bucket 238 alongside bucket 206 for 1993-2025, because the removed row was manufacturing agreement between a dead reporting area and a live one.New diagnostic
polity_mapping_provenance()says which authority a row’s territorial identity rests on (#740).polity_area_crosswalkis not the upstream FAOSTAT-to-polity map: it is that map (245 of 596 rows) plus rows WHEP manufactures by ISO3-prefix match (prefix_outside_map, 262;prefix_fallback, 27) and WHEP’s own Rest-of-World bucket (fabio_row_fold, 62). Nothing said which of them a published number came through. The new function resolves(area_code, year)through the same lookup the builds use and reports the class of the crosswalk row that answered, plus anauthoritycolumn collapsing it to"upstream","whep_prefix","whep_bucket"or"unresolved". Measured on a real 6,310,390-rowget_primary_production(): 96.06% of rows and 99.76% of tonnes resolve through an upstream map row, 3.37% through the Rest-of-World bucket (the 24 reporting members #628 promoted), and 0.56% through a manufactured prefix row – every one of them FAOSTAT area 238 Ethiopia before 1993, onETH-1952-1993. Over the crosswalk’s own 1850-2025 grid, 257 of the 262prefix_outside_maprows are the resolution of no(area_code, year)at all, because the back-cast anchor floors every lookup at 1961. No published value changes: the function is a read-only diagnostic and no build path calls it.build_carbon_inputs()no longer attaches reporting polity columns to an intermediate that discards them.build_soil_carbon_inputs()produced a 5.0e7-row gridded table for a 40-year span, and adding the four reporting polity columns to it cost +20.4 GB and 20 s – two of them are character columns..ci_cropland_class()then collapsed that table 42-fold, to 1.2e6 rows, discarding all four, andbuild_carbon_inputs()re-added them to its own output. The internal path now skips them and only the exportedbuild_soil_carbon_inputs()pays. Peak for a 40-yearbuild_carbon_inputs()goes from 37.8 GB to 25.4 GB, slightly faster (725 s vs 742 s), with outputidentical()across all 5,275,974 rows and 12 columns (#624).-
BNF coefficients now ship with cell-level provenance (#497). The long
bnf_provenancesidecar is readable withwhep_coef_table("bnf_provenance")and accounts for every one of the 60 non-missing numeric cells inbnf.csvexactly once: 32 are asserted against a publication, 15 are explicit derivations, and 13 retain their existing values with genuinely unresolved authority and no guessed source attribution. It distinguishes nitrogen harvest index from Herridge’s dry-matter harvest index and identifies Lassaletta et al.- as the Environmental Research Letters 9:105011 Supplementary Methods authority. No published value changes:
bnf.csvis byte- identical and BNF runtime outputs are unchanged. Two invariants that the provenance rewrite would otherwise have dropped are kept: a mixed stand’sleguminous_sharemust stay strictly inside 0 and 1, and no Anglade-cited coefficient may coincide with a sample size reported on its own Table 1 row.
- as the Environmental Research Letters 9:105011 Supplementary Methods authority. No published value changes:
Breaking:
whep::biomass_coefsno longer exposes five unused legacy below-ground fields (#524).BG_Biomass_kgDM_ha,Root_Shoot_ratio,Root_kgC_kgDM,Rhizodeposits_mass_kgC_kgDM, andRhizodeposits_N_kgN_kgRootNhave been physically removed. Modern calculations use the item-keyedbio_coefsfieldsbg_biomass_dm_kg_ha,root_shoot_ratio,root_c_kgdm,rhizodeposit_mass_c_kgdm, andrhizodeposit_n_kgn_krootnunder their existing contract, respectively. The first two are fallbacks whenipcc_root_coefs$bg_ref_dm_t_haandipcc_root_coefs$rs_default, respectively, are unavailable;rhizodeposit_mass_c_kgdmis an integrity and documentation component already included inroot_c_kgdm, not a separate calculation input. No published number changes: the removed columns had no runtime consumer, both modern coefficient tables are byte-identical, and representative NPP, BNF, SOC, and nitrogen-input outputs are unchanged.read_soil_hydraulic()no longer holds three full-resolution HWSD rasters at once. It classifies the 30-arcsec HWSD grid once per hydraulic property (t_field,t_wilt,porosity), each costing ~11 GB of transient raster for a 3 MB result. That memory is reclaimable, but nothing triggered the collector between passes, so they accumulated (11.4 -> 22.1 -> 32.8 GB). Reclaiming between passes takes the reader’s peak from 38.2 GB to 16.8 GB, with outputidentical()and no time cost (64 s vs 68 s). This is a fixed cost on everybuild_carbon_balance()andget_soc_climate_drivers()call, independent of how many years are requested (#624).A year-scoped production build no longer depends on the window for which livestock stock combinations exist.
.build_livestock_stocks()read the stock series scoped to the caller’s window, but.combine_livestock()completes the year axis against the (area, item) combinations that read produced — so a combination absent from the window was absent from the completion, and the completed rows are what give a livestock-product row its unit downstream. The series is now read over its full span and trimmed afterwards; full-range output is unchanged. This narrows the remaining year-scoping gap (t_LUat 2010: 2.18e-04 to 1.61e-04) but does not close #666 — a scoped build still derivesLUas NA where a full build derives 0.build_carbon_balance()no longer grows its memory with the length of the span. The RothC/HSOC climate modifier is now reduced one year at a time. Attaching soil cover crosses the monthly climate table with every land-use class, which measures 0.452 GB per simulated year against 0.097 GB for the drivers themselves, and that intermediate used to be held for the whole span. Measured peaks: a 40-year build went from 61 GB (OOM-killed before finishing) to 49.9 GB (completed), while a 20-year build is unchanged at ~51 GB – the fix removes the per-year slope, not the fixed plateau. Runtime is unchanged (362 s vs 365 s at five years). Output is identical:identical()holds across all 1,166,220 rows and 17 columns of a five-year build, row order included (#624).A grid cell now spends its critical-nitrogen allowance once, instead of handing the whole allowance to every crop that shares the cell.
build_n_boundary_exceedance()compared each crop’s per-hectare pressure against the cell’s single critical value independently, so a cell with n crops was measured against n copies of one allowance and the crop exceedances did not add up to anything the source defines. The calculation is now cell-first: every crop and polity contribution in a source cell is aggregated, that one pressure is compared with that one allowance, and the resulting cell allowance, signed margin and positive overshoot are only then attributed back to crops — by input shares formetric = "input"and by signed surplus-contribution shares formetric = "surplus"(which may be negative or exceed one). Published exceedance values move, and the two metrics move differently. Formetric = "input", where crop pressure cannot be negative, the cell overshootmax(sum(a) - c, 0)is greater than or equal to the old per-crop sumsum(max(a - c, 0))for every input, so reported overshoot rises (or is unchanged when one crop holds the cell, or when neither form overshoots): on the package’s two-crop fixture, two crops of 4 t N against a 5 t N allowance went from 0 to 3 t N. Formetric = "surplus"it can move either way, because a negative crop contribution now offsets a positive one inside the cell instead of being clipped to zero crop by crop. The global magnitude is not quantified here — that needs the restricted archive and a full gridded surplus build, not something CI can reach. The newresolution = "cell"grain returns the undivided source-cell result, and the crop grains reconcile back to it algebraically.The critical surface is pinned to the deposited rasters by checksum.
read_critical_n()checks every raster a call actually reads — the selected critical surface plus the three shared input layers — againstinst/extdata/critical_n_source_manifest.csvbefore parsing, on byte count, MD5 and SHA-256, and aborts naming the file and the Zenodo record (6395016) if any differs. The manifest pins all 27 files of the archive (3 input layers, 12 critical-input and 12 critical-surplus surfaces, all 27 checksums distinct), so a partially substituted archive cannot go unnoticed on the path that consumes it. The layer also carries the depositedsource_area_haandimage_regionper cell, so IMAGE membership and source land area now arrive on the canonical integer cell key from the archive itself rather than through a year-free country-to-IMAGE join — which is why that join leaves the territorial-join baseline (whep#669).Unsupported boundary modes hard-error rather than resolve to something else. Only the source-exact
allocation_scenario = "yield_gap"is implemented on the grid;"no_increase"and"new_fixation"abort. An annual actual pressure requires an explicitactual_year, andcritical_reference_yearmust be2010, matching the fixed deposited reference surface — the year is a stated selector, not an inferred one. Where a cell’s pressure denominator is exactly or near zero, the cell result is kept whole and an explicitcell_residualrecord carries the unallocated allowance, margin and overshoot; callers that require complete crop attribution raise a typed undefined-attribution error instead of discarding or inventing the residual. Country equal-per-capita allocation is unchanged and separate, and dynamic critical values remain out of scope (whep#702). Urban N stays provisionally inside WHEP actual pressure, and manure-management boundary comparability and intensive-grass scope remain recorded provenance rather than settled choices.Six more gridded builds now say when a cell-year names a polity that did not exist.
build_water_balance()andget_soc_climate_drivers()gainedpolity_validity = c("keep", "flag", "drop")in whep#462; every other consumer of the same year-lessdata$cell_politygrid had the same defect silently.build_n_deposition(),build_urban_n(),build_ag_land_support(),aggregate_grass_to_polity(),spatialize_country_n_to_crops()andbuild_carbon_balance()now take the same argument, with the same three values, the same"keep"default and the same warning, routed through one shared helper so the eight entry points cannot drift apart. The HWSD clay/pH readers (read_soil_ph(),read_soil_hydraulic()) are documented as exempt: they use the crosswalk as a spatial extent and their output has neitheryearnorarea_code. No published value changes on the default path —"keep"reproduces today’s rows and numbers and only adds a warning, and"flag"adds one logical column."drop"does move values and is opt-in: measured on the real 58,795-cell country grid, it removes 3,181 of 30,438(area_code, year)keys over 1850-2020 (22 of 178 area codes, 21.4% of cell-years), and 34 of 3,738 keys over 2000-2020 alone.An ISO3 code naming two FAOSTAT areas no longer resolves by row order, so Ethiopian ISO3-keyed input stops being stamped with a country that dissolved in 1993. FAOSTAT keeps a pre-split entity beside its successor, so
ETHnames both 62 (“Ethiopia PDR”) and 238 (“Ethiopia”), andSDNnames both 206 and 276..iso3_to_fao_area_code()broke that tie withunique(bridge, by = "iso3c")— row order, which kept the lowest code, i.e. the dissolved 62 forETH, in every year. The tie is now broken on the polities database instead: the area code that IS its polity’spolity_area_codewins, which picks 238 forETHand leavesSDNat 206; an ISO3 still ambiguous after that rule aborts rather than being guessed. Exactly one of the 263 ISO3 codes changes. No published values move: both live callers reduce topolity_code, which is the same either way, and the population totals the historical CBS proxy fill sees are byte-identical.gleam_geographic_hierarchynow carries the polity of each country it lists. The table is GLEAM’s own registry of the countries that exist today — it has a row for South Sudan and none for any dissolved entity — but it carried no polity column at all, so every consumer resolved one ad hoc and joined on the bareiso3. That join has no year, and 38 of the 204iso3values name a different polity at 1961 than at 2010, so which one an unyeared join picked was decided by nothing. The newreporting_polity_code/reporting_polity_namecolumns hold the polity the present day resolves eachiso3to, andpolity_identity_conventions()moves the table from"recommended"to"carried". 201 of the 204 resolve, every one of them to a period that reaches the snapshot’s open end with nothing succeeding it. Three keepNAand stay visible:ATF,SGSandWLFare territories whep-polities has no polity for at all (upstream whep-polities#187). No published value changes — the seven existing columns are byte-identical and no consumer reads the new ones yet; switching a consumer’s join fromiso3to the polity would move values and is deliberately not done here.regions_fullandpolities_catsno longer carry a column namedpolity_codethat is not a polity code. Both shipped a legacy ISO3-like stem ("AFG","ROW","RAFR") under that name, of which 0 of 271 non-NAvalues was apolities$polity_code, so a join from either table topolitiesorpolity_area_crosswalkon the one column whose name promised identity came back completely empty and nothing warned. The column is nowlegacy_polity_prefix, which claims nothing; the real carrier remainsreporting_polity_code(259/259 and 198/198 non-NA, all real). This is a breaking schema change for any caller readingregions_full$polity_codeorpolities_cats$polity_code— rename the read, and if the intent was a polity, switch toreporting_polity_code. No published value changes: the two rebuilt tables areidentical()to their predecessors once the column is renamed back, and the one join inR/that used the old name (.read_fodder_euadb()’s EU AgriDB bridge, which was really an ISO3 join wearing a polity name) resolves the same 28 ADB regions to the same area codes.The last site reading
polity_end_yearas inclusive has been removed.data-raw/balance_coefficients.Rstampedurban_n_referencewith a polity code through its own copy of the year resolver, and that copy matchedpolity_end_year >= yearwhile the column is exclusive at a succession everywhere else. Over the shipped crosswalk the two readings disagree on 313(ISO3, year)pairs; 299 of those abort with two candidates, and 14 resolved silently to the interval that had ended on that year, booking a coefficient to a polity that no longer existed. The resolver now lives inR/polities.Ras.iso3_year_to_polity_code(), takes its upper bound from.polity_join_end_year()like every other call site (exclusive at a succession, inclusive at an open end), and aborts rather than answer with a dissolved polity. No published value changes: the one dataset the builder stamps is Spain over 1860–2022, covered by the single intervalESP-1800-2025on either reading, and all 23 tables the builder writes come backidentical()to the committed ones.Upstream’s succession relation is now read in both directions, so a period whose successor is only recorded on the successor’s side is no longer widened into that successor’s first year.
.polity_join_end_year()extends an OPEN period by one year, and “open” was read frompolities$successoralone.AGO-1975-2025namesANG-1905-1975as its predecessor while colonial Angola names no successor, so ANG was widened into 1975 and FAOSTAT area 7 had two resolution candidates for that year, separated only by thepolity_start_year DESCtie-break. A period another period both names as its predecessor and begins exactly at the end of now counts as succeeded; the begin-at-end test is what distinguishes a hand-over from a partial carve-out such asTRS-1947-1954out ofITA-1919-2025, whose predecessor goes on existing. No published value changes: measured over every(area_code, year)pair of the crosswalk for 1961–2025 and for 1850–2025, 0 pairs changepolity_codeand 0 changemapping_status; the joined-span conflict count goes from 1 to 0.A row with no mapped period now stands in on a polity that has not started yet rather than on one that had already ended. When
add_polity_code()finds no period covering a row’s (anchored) year it falls back to another period of the same reporting area; that fallback ranked candidates purely by distance in years, which split a single reporting area’s series between two entities at whatever year the arithmetic flipped. FAOSTAT area 178 Eritrea readERI-1889-1952, the Italian colonial administration, for 1850-1972 andERI-1993-2025from 1973; area 273 Montenegro split at 1961 betweenMNE-1913-1918andMNE-2006-2025, on a margin of one year (44 against 45). A not-yet-started period is now preferred over an ended one, so each of those areas resolves to one entity across 1850-2023, which is also what the other 22 areas with no period at the back-cast anchor — the post-Soviet and post-Yugoslav ones — already did. No published quantity changes; 235 of the crosswalk’s 46,640(area, year)pairs over 1850-2025 change which polity they name, all of them areas 178 and 273. On a real full-rangeget_primary_production()(6,310,390 rows) the out-of-span set is unchanged at 2,301 pairs / 7,247 rows, and 347 of those rows move frompolity_coverage_gaps()’s"polity_ended"class to"polity_not_started", WHEP’s documented back-cast convention, leaving"polity_ended"as FAOSTAT area 206 alone.options(whep.polity_stand_in = "nearest")restores ranking by distance alone.The pre-1962 CBS proxy fill no longer reads a territory’s identity out of its label.
.fill_with_proxies()recovered the frame’s polity by matching its(area_code, area)pair — the bucket AND the LABEL — against the crosswalk’s(polity_area_code, polity_name), while the population and land proxies were resolved from the code, year-aware. The two sides disagreed. Measured on a realbuild_commodity_balances(prim, 1955, 1965)run (121,191 frame rows, 1,267(area_code, area, year)keys): 35 keys resolved to a different polity through the label than through the code, and 70 to no polity at all. Both proxies are now keyed on the reporting bucket the frame already carries, so no label is consulted and no resolution is needed on the frame side. Published values move, in 1955-1960 only (1961 onwards is byte-identical): total tonnage -0.064% to -0.068% a year, -0.0289% over the 1955-1965 build; 9,623 of 528,769 cells change, 234 appear and 696 vanish. Burundi, Equatorial Guinea, French Guiana, Papua New Guinea, Singapore, Syria and Oman gain a population proxy they never had, and eleven areas gain an agricultural-land proxy. Eswatini stops growing on 5,409 thousand people and grows on its own 353 thousand: it, Bermuda and New Caledonia carry the shared"Rest of World"label, which used to join them onto oneROW-1850-2025proxy row holding the SUM of four promoted members’ populations — the whep#589 shape. Bermuda and the Rest-of-World bucket have no proxy of their own and so lose their pre-1961 rows entirely (12(area_code, year)pairs, 92.99 Mt) rather than keep a series synthesised from other territories’ populations; what an artificial aggregate’s proxy should be stays open (whep#493). Sudan (former, area 206) is the one bucket whose agricultural land was split across two polities: it now sums Sudan and South Sudan, which is what its CBS numerator already did, and itsagrilandproxy rises 1.47x. No(area_code, year)changes its reporting polity and noarea_codegains a second label.The pre-1962 CBS extension is keyed on
area_code, not on thearealabel.areais the periodized polity name (“Algeria (1919-1962)”) and it was a key in five places in the historical extension, including the year skeleton, which is crossed with the year axis. Two labels for one code therefore gave that code two full year skeletons rather than only a wrong name.build_commodity_balances(historical_data = )reaches exactly that:.prepare_historical_cbs()names its rows from the crosswalk’s staticarea_namewhile the FAOSTAT rows carry the periodized polity name, and for 97 of the 262 codes in that lookup the static name is not any of the code’s polity names, so the two can never agree. Measured on a fixture, one such overlap turned 77 keys into 154 rows and had the cell’s two candidate values summed downstream instead of reconciled — 240 t where the answer is 140 t. The extension now reconciles on the code, takes the best source as it always intended to, and re-attaches the code’s one display label afterwards. No published value changes withouthistorical_data:1850–2023is identical before and after, key for key.The polity a row belongs to is now carried from where it is resolved instead of re-derived at the end of every output.
.aggregate_to_polities()has always resolved the bucket’s polity in order to label the fold and then discarded the code, leaving the ~70 call sites of.add_reporting_polity_columns()to resolve it a second time from the same crosswalk. The fold now emitspolity_area_code,reporting_polity_code,reporting_polity_nameandreporting_polity_has_geometry— the published names, so no new vocabulary and no schema change — and the tail helper keeps a carried identity rather than resolving it again. It keeps it only after checking it: the identity must still match the key it sits next to (a bucket code resolves to itself, so a re-keyed frame fails that test), and the distinct(area_code, year)pairs are re-resolved and compared, which costs a fraction of the full resolution it replaces. Two non-NAanswers for one key now warn instead of one of them being published silently. No published value changes:get_primary_production()(6,310,390 rows) andget_wide_cbs()(2,098,818 rows) are identical before and after, column for column.polity_coverage_gaps()now says which direction a stand-in fell in, and the two directions are not the same defect. The newgap_kindcolumn takes"polity_ended"(the polity had ended by the row’s year, so the value covers a territory that entity no longer describes — whep#414’s case) or"polity_not_started"(the polity begins later, which is mostly WHEP’s documented pre-1961 back-cast onto the anchor-year territory). No published value changes; this is a diagnostic gaining a column. Measured on a real full-rangeget_primary_production()(6.3M rows), 7,247 rows — 0.115% — are attributed to a polity that was not live in the row’s year, and they split 3,285 rows across 3 areas"polity_ended"against 3,962 rows across 16 areas"polity_not_started". Bucket 206 is 2,938 of the ended ones, 89%; the other two, FAOSTAT area 178 Eritrea (123 rows,ERI-1889-1952) and area 273 Montenegro (224 rows,MNE-1913-1918), were not previously named anywhere. The classification is read at the year the resolver actually matched on, not the row’s year, becausebackcast_anchorfloors the lookup year: a pre-anchor row is matched as 1961 and can land on a polity that had already ended by then. That is exactly 165 rows of areas 178 and 273, which the raw-year comparison a caller could write for itself would label"polity_not_started"instead.The
arealabel a country carries through the commodity-balance build no longer depends on row order..select_best_source()reduced the long CBS input to one human-readableareaper numeric code by keeping whichever row came first.areais the periodized polity name and a code legitimately changes it at a period boundary, so one code offers several labels over a multi-year build: on a real 1850-2023 run, 75 of the 216 codes carry more than one (up to four), and shuffling the input rows flipped the label for 13 of them. The label is also a join key — a secondareavocabulary for one bucket once dropped 702,166 rows (#382) — so nothing pinned a key the build depends on. The pick is now a stated total order: the source that reports the code earliest in the order.assemble_cbs_sources()binds them in, that source’s earliest year, then the label alphabetically. No published value changes: the rule reproduces all 216 of today’s labels exactly, which is deliberate, because that same label is what the pre-1962 proxy fill reads a polity out of (#698) and changing it would silently redistribute which countries find a population and land proxy. Fixes #580.Every join that keys on a territory but not on a year is now classified, and the list can only shrink. A key of
area_codewith noyearspans every period of a territory’s history, so it asserts that the area means one thing for all time. Usually that is right – 57 of the package’s 163 territorial joins carry no year, and nearly all are a single-year scope, a table with no time dimension (a coefficient, a single-vintage map, a grid mask), an identity lookup or a diagnostic – but nothing said which, so a decision and an oversight looked alike..territorial_join_baseline()now records the verdict and the reason for each, andtest_join_audit.Rfails when a new year-free territorial join appears unclassified, when a classified one disappears without its entry, or when a further join starts keying on thearealabel. Classifying them turned up one real defect, filed as #698 with its measurement rather than fixed here, because removing it needs #493’s decision first. No published value changes (#669).Four documented examples could not say which territory their rows belong to.
build_supply_use(example = TRUE)shipped a row with noarea_codeat all (an epsilon3.33e-14husbandry use) andget_feed_intake(example = TRUE)two more, so their polity columns came outNA;build_feed_intake_local(example = TRUE)andbuild_grass_natural_carbon_inputs(example = TRUE)keyed cells by an ISO-3166 numeric code (724 Spain, 300 Greece) where the FAOSTAT area code belongs, which resolves to nothing – and one sibling row’s ISO code for Argentina, 32, is FAOSTAT’s Cameroon, so a cell in the pampas was labelled Cameroon. The two feed fixtures also predated the redistribute-feed migration and showed a 10% feed loss the current allocator cannot produce. All four fixtures are now sampled from real builds (the gridded ones keyed by the code the cell grid actually assigns: 203, 84, 9), andbuild_supply_use(example = TRUE)now covers all five documented process groups instead of three. No published value changes – these are documentation fixtures, not pipeline outputs (#417).-
split_manure_management()can now use the region-specific MMS shares, via the newmms_source = "region_specific". The default is unchanged, so no published value moves.regional_mms_distributionships 33 rows: 18 forregion == "Global"and 15 for North America (cattle, swine), Western Europe (cattle) and Latin America (cattle). The function filtered the table to"Global"unconditionally and never read the excretion’sterritory, so those 15 rows were unreachable and every territory got the global split — a global default, not a drop and not a silent zero. The territory is now resolved to its IPCC region through the same GLEAM lookup the emission-factor tables use (.gleam_region_of(), whep#465), with the Global rows as the fallback for every region and species the table does not cover.What flipping the default would move, measured on the real 2020 national chain (90.06 Mt excreted N, 195 territories): 66 territories and 5.40 Mt N (6.0% of the excreted nitrogen) change management system. The in-situ grazing stream falls from 41.36 to 40.71 Mt N (−1.6%) and the collected stream rises correspondingly; applied N moves −0.24%, volatilized N +0.94%, leached N −4.67%, direct N2O-N +0.30% and indirect N2O-N +0.84%. Rows, keys and territories are identical between the two sources, and mass is conserved per input row under both. The default stays
"regional_default"because the region-specific rows are a coarse four-pair table (data-rawdocuments them as “GLEAM 3.0 / FAO statistics (simplified)”) whose provenance has not been verified against a published GLEAM table; whether they are better than the global average is the maintainer’s call (#466). build_gridded_landuse()andbuild_gridded_livestock()now name the reporting areas theircountry_gridcannot represent at all, once per call, with the national total at stake. The existing diagnostics fire per (country, crop) per year and per (species, year), so a country the grid has no cell for anywhere was reported as one more line in a list that already names 178 codes. On today’s pinned centroid grid at 2015 the new warning reads 18 reporting areas carrying 0.109 Mha — all island or city states. Substitute the fractional crosswalk and it reads 20 areas carrying 28.90 Mha, because that parquet still keys Ethiopia62and Sudan206where the centroid grid and today’sregions.csvuse238and276. No published value changes: this is a diagnostic only (#461).run_spatialize()gains thecountry_gridoverride,"centroid"(default, today’sspatialize-country-gridpin) or"fraction"(cell_polity_fraction.parquet, which splits each border cell by fractional coverage instead of giving it whole to one polity). The engines already readpolity_fracascell_area_frac, so this is data wiring, not an engine change, and the resolved choice is recorded inrun_metadata.yaml. The default is unchanged, so no published value moves. Measured at 2015, the alternative moves 6,828 of 7,557 (country, crop) cell-share vectors, by a median L1 of 0.060 and a harvested-area-weighted mean of 0.040, and raises the compartments receiving an allocation from 33,614 to 36,226 (#461).-
polity_bucket_coverage()reported bucket 206 as a three-way fold in all 65 years and called its label an extent mismatch; both were wrong (#414). No published value changes — this is a diagnostic and the warning it drives.The fold runs 2012-2025, not 1961-2025. Measured on the FAOSTAT production pin, area 206 “Sudan (former)” carries 13,759 rows over 1961-2011 and areas 276 Sudan / 277 South Sudan carry 3,467 and 2,170 rows over 2012-2024: the three never report in the same year. The year-aware resolver answers for every
(area_code, year)pair regardless, standing in with the nearest period, and counting those stand-ins invented two members in every pre-secession year. A member now counts only when its polity is in span and the upstream map reports the area that year, which takes the report from 65 rows to 14.The label is not an extent mismatch. Bucket 206 resolves to
SUD-1956-2011, whose publishedsuccessorset is exactlySDN-2011-2025; SSD-2011-2025— the two polities the bucket folds — so that polity’s territory is the sum. What is wrong is the period: it had ended. That is now its own class,"predecessor", and"partial"is reserved for a label covering less than the value does. No bucket is"partial"today. The build-time warning says which of the two a bucket has instead of asserting the wrong one.The open decision in #414 is unchanged and unmade: no live polity means “Sudan and South Sudan”. Minting one upstream is proposed in lbm364dl/whep-polities#139; un-folding the two areas instead is costed in #680.
-
build_water_balance()can now charge a single crop’s water, and the per-CFT consumptive-water cubes are readable at all.read_lpjml_hydrology()gains"cft_consump_water_b"/"cft_consump_water_g", andbuild_water_balance(bands = )restricts the consumptive-water andcft_nirterms to named crop-functional-type bands, e.g.bands = "rainfed grassland"to charge a grazing footprint the grassland water alone rather than every crop in the cell. Bands are selected by theband_namethe file itself carries, never by index, so a run configured with a different band set aborts instead of silently charging the wrong crop.bands = NULL(the default) totals every band, so existing callers are unaffected. Three fixes were needed to get there, each of which would have produced wrong numbers rather than an error:- The
cft_nirmap entry namedmcft_nir.ncholding a monthlycft_nirvariable. No WHEP run has ever written that file: all nine runs, 5.9.7 and 6.1.1 alike, writecft_nir.ncholding annualnir. Reading it would simply have failed; nothing called it yet. - The reader assumed twelve time steps per year for every variable. The per-CFT consumptive-water cubes are annual (
nstep1, mm/yr), so their time axis was decoded as months, mapping year y to year 1901 + (y-1901)/12 and slicing the wrong years out of the file entirely. -
ncvar_get()drops length-1 dimensions, so slicing one year out of an annual per-CFT cube returned a 3-D slab whose band axis was then decoded as time — scrambling crops into years. Now read withcollapse_degen = FALSE. Monthly cubes never hit this, because a one-year slice is still twelve steps.
- The
-
New
polity_identity_conventions()states, per object, what territorial identity a WHEP table with no year dimension carries (#671). A polity code is year-scoped, so for a year-less object “attach the polity code” has no single answer: measured on the deployedspatialize-country-gridpin, 52,420 of its 58,795 cells (89.2%) sit under anarea_codethatpolity_area_crosswalkmaps to more than one polity over time, and 33 ofmueller_synthetic_n’s 156iso3clabels, 37 ofcrops_manure_n’s 184ISOlabels and 38 ofgleam_geographic_hierarchy’s 204iso3labels name a different polity at 1961 than at 2020. The register records which of #458’s three answers each object takes — present-day polity, polity-period rows, or deliberately identity-free — and the newtests/testthat/test_territorial_identity.Rchecks each claim against the object it is made about, so a year-less territory-keyed dataset can no longer arrive without one. No published value moves; nothing but the register and its guards is added.Two things it makes visible.
regions_fullandpolities_catsreally do carry the present-day polity, inreporting_polity_code, and it is exactly whatadd_polity_code(year_column = NULL)resolves for all 272 and 198 rows respectively — now asserted rather than assumed. And their column literally namedpolity_codeis not a polity code: none of its 271 values appears inpolities, because it is a legacy ISO3-like prefix, which is pinned so the two vocabularies cannot be quietly conflated. build_water_balance()andget_soc_climate_drivers()now say when a cell-year is attributed to a polity that did not yet exist, and can refuse to do it (#462). The cell-polity crosswalk is a present-day rasterization with no year dimension, while polity validity is year-scoped, so a cell labelledarea_code52 carried that label in 1901 as readily as in 2009 and the polity resolution silently substituted the nearest period,AZE-1991-2025. Measured on the deployedcell_polity_fraction.parquetover the 1901-2009 LPJmL run: 1,948 of 19,838(area_code, year)keys, 21 of 182 area codes, 14,761 of 58,791 cells — the post-Soviet and post-Yugoslav successors plus South Sudan. Both functions gainpolity_validity:"keep"(default) is the previous behaviour plus a warning naming the rows, years and area codes;"flag"adds the per-row logicalreporting_polity_out_of_span;"drop"removes those rows. No published value changes on the default, and"flag"is numerically identical to it."drop"removes 20.4% of the run’s cell-years and makes South Sudan disappear from it entirely, which is why it is opt-in.A year-scoped production build now agrees far more closely with the full-range build.
.fill_yields()interpolatesyield_calong the year axis, so a window with no neighbouring years cannot reconstruct values the full series reconstructs, and.finalise_primary()drops those rows when it melts. Requested windows are now widened by 3 years either side for the read and trimmed back afterwards. Measured against the full-range build, the largest relative difference across all units falls from 1.67e-02 to 7.21e-04 at 2015 and from 2.93e-04 to 2.84e-04 at 2010, for roughly +13 s on a scoped build. A full-range request is unaffected (#667).Year-scoped builds no longer drop split-species slaughter counts.
.compute_stock_shares()read the livestock stock series scoped to the caller’s window, but those shares are carried along the year axis precisely because thefaostat-emissions-livestockpin lags QCL slaughter by 1-2 years. A narrow window left the carry-forward nothing to fill from, and the join in.split_slaughter_by_shares()then dropped the slaughter row entirely. At 2010 that was 2 Singapore rows (Pigs, Hogs);slaughtered_headsnow agrees exactly with the full-range build instead of by 4.7e-06. The stock series is read over its full span; full-range output is unchanged (#665).-
build_carbon_balance()is about a quarter faster, with output unchanged to the last bit. The RothC/HSOC climate modifier is now computed for every cell-year at once instead of once per (cell, year, land use) – roughly 1.2e6 separate calls over five years, each of which allocated a list and accumulated over twelve months. The deficit recurrence is sequential over months but independent across cells, so the loop inverts. Measured onyears = 1901:1905: 820.4 s to 612.1 s. Peak memory is unaffected.The per-group path stays in place as the reference and still runs for models that do not use this modifier. The two agree exactly, not approximately:
identical()holds across all 1,166,220 rows and 17 columns of the five-year build, so no result changes (#630). -
build_energy_co2_extension(unclassified = "historical_region")prices the dissolved federations instead of losing them (#553). Measured on the realget_primary_production()output (6,305,656 rows, 1850-2023), 569.4 Mt of meat carcass production — 3.33% of all of it, and 15.2% of the world’s 1961 tonnage — gets no energy intensity and leaves the extension, becausegleam_geographic_hierarchyis a present-day country table with no row for the USSR, Belgium-Luxembourg, Czechoslovakia, the Yugoslav SFR or Serbia and Montenegro. Those five are now 99.998% of the loss: since the Rest-of-World fold was lifted (#628) bucket 999 no longer contributes to it at all. The new treatment groups them by running GLEAM’s own scheme rules on the OECD and EU membership they themselves held while they existed — Belgium and Luxembourg were OECD founding members and EEC founders, so Belgium-Luxembourg is OECD/EU 27; no successor of the other four was in either body before the entity dissolved, so they are non-OECD, non-EU. Rows carrymethod_energy = "GLEAM_3.0_energy_meat_historical_region", and the option is a superset of"polity_region".No published value changes: the default
unclassified = "drop"is bit-identical on the full real input (181,831 rows,sum(impact_u) = 6.530863856531e12before and after,identical()TRUE). Opting in adds 1,190 rows and 7 areas, moves no shared row by any amount, and raises total energy CO2e by +2.40% over 1850-2023 — +12.0% in 1961, +11.3% in 1990, +0.26% in 2000 and 0% from 2010 on. -
polity_area_crosswalk$mapping_statusnow uses the value it documented but never shipped, and the confidence of a mapping is documented as the pairmapping_statusxmapping_source.not_a_reporting_areasat belowmatchedin the build’scase_when, so it could only fire for a row with neither anarea_codenor apolity_code— no such row exists, and it shipped on 0 of 596 rows. The 20 rows it was written for (Aland, Saint Barthelemy, Guernsey, Jersey, the Isle of Man and Sint Maarten, whichregions_fullcarries without a FAOSTAT code, plus the six regional aggregate polities) match a polity and so readmatched, indistinguishable from a real area mapping even though they carryNAin botharea_codeandpolity_area_codeand no consumer can join to them. Status counts move from manual 27 / matched 568 / unmapped 1 to manual 27 / matched 548 / not_a_reporting_area 20 / unmapped 1. Nopolity_code,polity_area_codeor any other column moves, and no code in the package filters the crosswalk onmapping_status == "matched", so no published number changes. A consumer that does filter that way loses 20 unjoinable rows.mapping_statussays whether a polity was found, not how far to trust it:matchedcovers a curated hit in upstream’s FAOSTAT map (233 rows), a prefix-inferred historical period (247), a prefix guess for an area the map never mentions (6) and the FABIO Rest-of-World fold (62).mapping_sourcealready separates those and is non-NAon every row, so the fix for #544 is to document the pair rather than add a third vocabulary that would duplicate it (#544). get_polity_geometries(polity_codes = )now returns a usablesfobject in a session that has not loadedsf. The row subset ran through[.data.framewhenever the suggestedsfnamespace was not loaded, which keeps classsfandattr(, "sf_column")but stripssfcoff the column they point at; the result passed every cheap structural check and then aborted inside the firstsfcall, complaining about a column nobody had renamed. The function now loadssfbefore subsetting, and aborts with classwhep_sf_requiredifsfis not installed instead of returning the broken object. No published values change: the argument-less call is untouched, and both in-package callers use it.build_gridded_landuse()andbuild_gridded_livestock()take anarea_key, and say when their output cannot join a national table. The spatialize chain allocates on the raw reporting codes itscountry_areasandcountry_gridare keyed on, while whep’s polity-keyed national tables are aggregated onpolity_area_code. A reporting code that is not itself a bucket therefore left every output row carrying two territorial keys that disagree —area_code = 276besidepolity_area_code = 206— so whether a consumer joined on one or the other decided whether Sudan existed in its result (#582). Measured against the deployed pins,country_gridholds 831 such cells under 2 codes (276 Sudan, 277 South Sudan) andcountry_areas0.64% of its harvested area; the other six codes the issue listed are no longer off-bucket, because #628 gave Syria, North Macedonia, Eswatini, Equatorial Guinea, New Caledonia and Palestine their own published codes. The defaultarea_key = "grid"is unchanged bit-for-bit and now warns naming the codes that cannot join;"polity_area"re-keys the output on the bucket before the polity columns are attached, so the two keys agree in every row. No published value changes unless"polity_area"is asked for: on a 2020 Sudan/South Sudan run it conserved 21,894,526 ha and 230.7 M head exactly, kept the row count, and moved 13,447 crop rows and 3,671 livestock rows from a key no national table carries onto206. Under"polity_area"the raw code is carried, not replaced, asgrid_area_code, the shapebuild_cell_polity()adopted in #579.run_spatialize()acceptsarea_keyinoverrides.estimate_energy_demand()now warns whenwork_hours_dayis supplied without a work coefficient.whepshipscw = 0for every species, so draft work is opt-in per call viawork_coef— passing only the hours producedne_work = 0with no indication that the input had been ignored (#210). The numbers are unchanged; only the silence is. Hours filled in fromlivestock_production_defaultsnever warn, since several species carry a non-zero default and warning about those would fire on ordinary runs.The FABIO comparison’s EU aggregate is derived, and now covers the dissolved predecessors.
inst/scripts/compare_fabio_footprints.Rcarried a 28-element ISO3 literal for EU28. It is now built by.eu_aggregate_iso3()from the publishedregions_full$EU27flag plusGBR, the one membership fact no table in the package states, selected through the newWHEP_EU_AGGREGATEenvironment variable ("eu28_territory", the default,"eu27_territory","eu28_states","eu27_states"). The literal omittedBLX(Belgium-Luxembourg) andCSK(Czechoslovakia), under which FABIO and WHEP’s own CBS both book Belgium, Luxembourg, Czechia and Slovakia before those successions, so all four read as exactly zero in the 1986 benchmark year on both sides of the comparison and normally in 2000 and 2013. This moves a published number: the FABIO EU land footprint for 1986 goes from 210.4 Mha to 222.7 Mha (+12.3 Mha, +5.9%); 2000 and 2013 are bit-identical, because the predecessors carry no demand there.WHEP_EU_AGGREGATE=eu28_statesreproduces the old list, and the old numbers, exactly. Whether the comparison should report EU28 or EU27 at all is left open (#421).-
build_food_supply()andbuild_n_percapita()now name the areas they drop for having no population denominator. Both inner-join theread_population()table, so an area thegdp-populationpin does not cover was absent from their per-capita output rather than wrong in it, and nothing said so. Measured on a realget_wide_cbs(years = c(2010, 2015, 2021))plus the real pin,build_food_supply()silently lost 16 areas over 43 area-years — Bhutan, Comoros, New Caledonia, Tonga, Micronesia, Seychelles, the Faroe Islands, bucket 999 and others — carrying 0.0304% of the food protein in range. They are still dropped (no denominator is invented) but each is now named in a warning, with the share of the quantity that leaves with it.options(whep.warn_missing_population = FALSE)silences it. No published value changes: with the warning suppressed a realbuild_food_supply()run isidentical()to the one before this change.This closes #543, whose measurement it also corrects. That issue reported the area-999 denominator as covering 6 of its 62 territories and so overstating every per-capita quantity keyed on 999 by 15-43%. Since the Rest-of-World un-fold (#628) that is no longer the shape of the defect: the 6 covered members have their own area codes,
read_population()emits no 999 row at all, and CBS 999 carries zero food in 2010, 2015 and 2021 — so nothing per-capita is keyed on 999 and nothing is overstated. Routing the pin’s five continental “Other” residuals into 999, the fix the issue proposed first, would now be wrong: it would give a bucket with no food a denominator of 5.9 M people (2010) and attribute Reunion’s, Greenland’s and New Caledonia’s population to a code that no longer carries their food. -
LUH2 land is no longer discarded when one aggregation bucket holds two territories, and the pre-1962 yield back-cast no longer mixes countries that share a polity label. Two sites keyed on the
arealabel where anarea_codewas available, which is whep#632’s defect at two further sites.- The LUH2 area bridge paired each bucket’s code with its member’s name, so bucket 206 reached grassland construction as two rows — “Sudan (former)” and “South Sudan” — under one
area_code;.dedup_production()reads that as competing sources and kept one, dropping the other’s pasture. The bucket now carries one label derived from its own code, so the two are summed. - The
t_haproxy-growth fill grouped its series on the label, which is wrong both ways: “Rest of World” covers 62 reportingarea_codes, so growth rates were taken between different countries, and the label is year-aware, so one country’s own series was cut in two at every periodization boundary (area_code79 is “Germany (divided, 1949-1990)” through 1989 and “Germany” from 1990). Published values move:ha+1.04% (+5.56e9 ha-years, all of it bucket 206’s recovered pasture over 1850-2022) andt_ha-0.036% (464 cross-country fills removed, 5 real ones gained);tonnes,heads,LU,t_head,t_LUandslaughtered_headsare bit-identical. Comparing the Rest-of-World fold against the default un-folded build over its 62 areas,hagoes from 2.38x to 0.99x andtonnesfrom 0.59x to 0.82x, with the whole remainder in the pre-1962 back-cast and the observed 1962-2023 era conserved to 0.04% (#633).
- The LUH2 area bridge paired each bucket’s code with its member’s name, so bucket 206 reached grassland construction as two rows — “Sudan (former)” and “South Sudan” — under one
EU AgriDB fodder now reaches Austria and the United Kingdom.
.read_fodder_euadb()resolves the source’sRegionthroughregions_full$ADB_Region, and that column had a key for 26 of the pin’s 28 regions:ATandGBwere missing, so 2030 rows (8.8% of the input, 1961-2019) resolved to no area and were discarded without a message. Those two countries had their fodder estimated from dry-matter yields (source = "DM_yield_estimate") while their 26 EU peers used the source. Adding the two keys moves published values for area codes 11 and 229 only, and for no other area: harvested-area totals over 1850-2023 rise 6.4% (Austria) and 14.0% (United Kingdom), fodder tonnage 33.4% and 65.7%; the global harvested-area total moves +0.07% and global tonnage +1.2%. Fodder production is copied one-for-one intofeedby.primary_to_cbs(), so those areas’feedmoves by the same tonnage. A region the source adds in future thatregions_fulldoes not key now raises a warning naming it, instead of vanishing (#585).-
The last two ad-hoc country-label joins in the spatialization script are gone (#576).
inst/scripts/prepare_spatialize_all.Rmatchedwhep::crops_manure_non a rawiso3cjoin andwhep::lassaletta_grassland_shareon a country name. The manure reader now goes throughwhep::polity_label_aliaseslike the Mueller reader does, read at the vintage of its own labels rather than at Mueller’s circa-2000 base year:crops_manure_nnames Serbia, Montenegro and South Sudan separately and names no Serbia and Montenegro, Sudan (former), Czechoslovakia or Zaire, so its vocabulary is post-2011, and every year from 2011 on maps all 183 of its country labels exactly as the retired join did. No published value changes: same 31,476 rows, same 183 area codes, maximum difference 0 Mg. West et al.’sRoWaggregate is still dropped rather than equated with WHEP’s residual bucket 999, which since #628 means something else.The grassland-share reader gains a
grass_share_routeargument onprepare_nitrogen_inputs()andprepare_spatialize_all(), recorded innitrogen_inputs.parquetasmethod_grass_share. The default,"area_name", is the existing name join and is byte-identical to it."alias_map"resolves each label at its own row’s year instead: 6,633 rows against 6,370 and 137 area codes against 130, gaining China, Cote d’Ivoire, DPRepublic of Korea, Cape Verde, Swaziland, Ethiopia PDR, Belgium-Luxemburg and Occupied Palestinian Territory, and losing South Sudan and the years in which Yugoslav SFR, Czechoslovakia, Viet Nam and Botswana had no polity. Which route is right is an open question (#576); nothing switches by itself. -
get_primary_production(),get_wide_cbs()andget_processing_coefs()take ayearsargument. A scoped request now builds only that window instead of building 1850-2023 and discarding the rest. Measured for 2010: wide CBS 256 s / 23.3 GB peak to 12.6 s / 6.8 GB, primary production 168 s / 14.7 GB to 29.5 s / 2.9 GB. The full wide-CBS build peaking above 16 GB is what had been failing the r-universe check on macOS and Linux.years = NULLis unchanged in every respect, including its cache slot, so existing callers keep today’s behaviour and today’s numbers. The primary-production to CBS to processing-coefficient chain and its cache keys are now shared withbuild_io_model(), which previously carried a private copy (#367).A scoped window is close to, but not identical with, building the full range and filtering. Against the full range at 2010, wide-CBS quantity totals agree to 3.8e-04 and primary-production totals to 3.0e-04, with
ha,t_ha,LUandheadsexact. The residual sits inimportand in the livestock ratios (#625). Useyears = NULLwhen exact agreement with the published series matters. Year-scoped production builds no longer drop every forage crop. Fodder rows are synthesised across the whole year axis —
.fill_fodder_gaps()takes the union of (area, item) groups over all years and interpolates between them — so a narrow window silently lost all six forage items (Forage and silage, *,Cabbage for fodder,Forage products). At 2010 that was 137 rows, 1.16% of production tonnes, 1.85% oft_haand 1.36% of wide-CBSfeed, and it affectedbuild_io_model(years = )on every release that had it. The fodder chain now runs over the full span and trims afterwards. Full-range output is unchanged (#623).-
Livestock stocks are split on the area CODE, not the area label (#589).
.split_stock_share()divides a parent item’s production across its sub-items in proportion to their stocks, grouped by(year, area, item_prod_code). When several reporting areas share one label the group spans all of them, the share denominator sums across areas, and each area keeps only its own fraction. That became live when the Rest-of-World fold was lifted:.unfold_rest_of_world()promotespolity_area_codebut leavespolity_code/polity_namealone, so all 13 reporting members came out with their ownarea_codeand the shared label"Rest of World". Measured: Syria’s 2000 livestock read 3,408,857 head against 38,048,415 after the fix, with fractional animals (1227745.45) as the visible symptom of a share that should have been 1.slaughtered_headswas never affected, because it does not pass through this splitter — which is what made the defect look like a unit-conversion bug.The stock join, the carry-forward and the row-count grouping are re-keyed the same way. Globally this moves
heads+0.22% andLU+0.13%;ha,tonnesandslaughtered_headsare bit-identical, because only areas sharing a label were ever affected. fill_linear()no longer depends on the order its rows arrive in. Without.by, it never sorted: carrying a value forward or backward and thevalue_smooth_windowmoving average are all positional, so an unsorted input filled the wrong way round. On a 2015-2020 series anchored at 2016 and 2019, reversing the rows swapped the two carry labels, and interleaving them left both outer gaps unfilled and moved two interpolated values. Both paths now sort by.byand thentime_colfirst, and rows come back in that order — the grouped path already did throughsetkeyv(), the ungrouped one now matches it. Grouped output is unchanged for already-sorted input, which is every caller inside the package. Three further gaps in the same file are closed. Avalue_smooth_windowthat leaves a group with no valid anchor (gaps one year apart, or a window wider than the group) aborted withmissing value where TRUE/FALSE needed; both paths now share one filling core, leave those gaps as"Gap not filled", and cannot diverge again.fill_linear()used to trust a.whep_sorted_byattribute it had stamped on a previous call, which asetorderv()in between does not clear, so a reordered data.table was filled in the wrong direction and came back carrying asortedkey its rows did not obey; the sort is now verified against the rows. And infill_proxy_growth(), the documented weighted proxy syntax ("gdp:region[population]") aborted insetnames()on every call, so it had never run; with that fixed, its weights are lagged before the rows without a growth rate are dropped, which is what makes them the previous period’s weights rather than the previous surviving row’s.One unvaluable 1:n split no longer erases observed data.
harmonize_interpolate()summed the split 1:n contributions together with the already-correct"simple"component usingsum()withoutna.rm, so a single contribution with a missingvalue, or with a share that could neither be computed nor interpolated (every year of the group totalling zero makes the sharesNaN), turned the whole harmonized(item_code, year)cell intoNA/NaN— including the observed values summed into it. Unvaluable contributions are now dropped with a warning naming the affected cells, and the observed values survive. Published values change only where the old output wasNA/NaN: such a cell now holds its observed"simple"sum, or disappears if it had none. No cell that was a number before changes.build_cbs_prices()no longer drops crop residues into an NA bucket. The residue routing in.add_residue_prices()readHerb_Woody == "Woody"inside a nestedfifelse(), so every item whose herbaceous/woody habit is missing gotNAas its residue item. Those rows were pooled into oneNA-keyed group and then dropped, and the pool mixed the mass and value of unrelated items on the way. On the realfaostat-trade-bilateralpin (1986-2021) that silently discarded 72 rows (36 years x 2 elements) of residue value. Residues are now generated only for primary crops and grassland — processed and animal products never had a crop residue — and a crop with no recorded habit takes the herbaceous default, reported in a warning naming the items (currently Cottonseed, Palm kernels and Palm Oil, whoseNameis unset initems_prod_full_raw.csv). Published values move for one item,Other crop residues(2106): its tonnage basis grows by 15.0% on average (2020 exports 345.6 Mt to 401.9 Mt) and its price shifts by -2.6% on average (2020 exports -1.2%, largest single move +6.8%).Straw(2105),Firewood(2107) and every non-residue item are unchanged to the digit.calculate_soc_dynamics()returns one schema for all five SOC models. It used to hand back whichever shape the selected model happened to produce:hsoc(the default) came back long aspool/year/stock_mgc_ha/rate_mgc_hawith nosoc_total, whilerothc,icbm,amgandcenturycame back wide withsoc_totaland their own mutually exclusive pool columns (dpm/rpm/bio/hum/iom,y/o,ca/cs,str/met/act/slw/pas) — no two of the five agreed, so a caller had to branch onmodel. The selector now reshapes whichever model ran to the long schemayear,pool,stock_mgc_ha,soc_total,method_soc: pool detail is kept, the model-specific part sits in the values ofpoolinstead of in column names, and the five runs of a sensitivity analysis stack with a plaindplyr::bind_rows(). Total-only callers readdplyr::distinct(out, year, soc_total).calculate_soc_hsoc()itself is now wide like its four siblings (year,fresh,humus,iom,soc_total) and no longer returns the per-poolrate_mgc_ha, which was exactly the forward annual difference ofstock_mgc_haand is recoverable from it. No published value changes: every pool stock and everybuild_carbon_balance()equilibrium is bit-identical before and after (checked across nine HSOC parameterisations and the spin-up of all five models).-
WHEP now models the Rest-of-World reporting members in their own right. FABIO folds 61 FAOSTAT reporting areas into its single
Rest of Worldcolumn, andpolity_area_codeinherited that fold, so any territory outside FABIO’s 192-country layout was published asROW. FABIO’s layout is a methodology this package compares against, not a constraint on which territories it represents, and the country set is WHEP’s own decision (#459).The fold was also not doing what its name suggests. Of the 61 members only about a third report anything; the rest contribute no rows, so folding them is arithmetically a no-op. Everything the bucket carried came from the members that DO file returns, and the fold discarded whose data it was — Syria’s production was published as “Rest of World” despite Syria filing its own FAOSTAT returns. Promotion is therefore self-limiting: an area with no rows is unaffected either way, so no hand-maintained list of “which ones to promote” is needed.
Measured on two full-range
get_wide_cbs()builds (1850-2023): the published area count goes 195 → 216, and 21 territories become standalone — Bermuda, Cayman Islands, Cook Islands, Equatorial Guinea, Faroe Islands, French Guiana, Greenland, Guadeloupe, Martinique, New Caledonia, North Macedonia, Niue, Réunion, Eswatini, Syria, Palestine and five more. Global totals move by at most 0.99% (stock_addition), every other column inside 0.4%. Bucket 999 survives as a genuine residual for the territories that report nothing, shrinking from 15,507 rows to 516.options(whep.unfold_rest_of_world = "none")restores the fold, which is what reproducing a number published before this change requires; it warns on every crosswalk read, because such a run no longer matches the published series. The"successor_state"folds (Sudan/South Sudan into bucket 206) are untouched — those are territorial identities, not a FABIO convention, and remain the subject of #414.An earlier measurement in #419 put this change at up to 13.7x on
feed. That comparison predates thedcast()duplicate-key fix (#425/#429) and does not reproduce; #555 re-measured it at 1.0000. create_typologies_of_josette()andcreate_typologies_grafs_spain()gained anexample = FALSEargument, so both now have runnable examples like the rest of the package’s remote-data functions. Their documented@returnwas also wrong and is corrected:create_typologies_of_josette()returns a named list of three tibbles plus aggplot, not a single tibble, andcreate_typologies_grafs_spain()returnsProvince_nameandTypologieformap_yearonly, not a seven-column series over all years. No published value changes — the only new code path is theexample = TRUEearly return.An aggregation bucket now sums, and comes out under one name. The reader aggregation grouped rows by the member’s polity name as well as by
polity_area_code, so a bucket folding members that resolve to different polities was never actually summed: it came back as several rows under onearea_code, carrying differentarealabels. That is live on the shipped crosswalk, not hypothetical — bucket 206 “Sudan (former)” folds FAOSTAT areas 276 Sudan and 277 South Sudan, which resolve to two polities from 2012 on. Measured over the real pins, four sources came out split:faostat-fbs-new(2,056 duplicate(area_code, year, item, element, unit)keys),faostat-trade-totals(3,739),faostat-production(2,000) andfaostat-emissions-livestock(144). The label is now derived after the sum from the bucket’s own code — the same politypolity_bucket_coverage()reports and the reporting columns resolve — so onearea_codehas oneareain one year. Each reader’s total is unchanged to the digit and its row count falls by exactly its duplicate-key count. Published values do move, for bucket 206 only, because the duplicated keys were mishandled downstream in both directions:build_primary_production()changes 1,673 of 6,170,595 keys, all in 2012-2023, and against the raw pin the new value is the right one — bucket 206 goats in 2018 were 14,449,249 head (South Sudan alone, Sudan’s 40,846,000 dropped) and are now 55,295,249, while 2019 sugar cane was 10,898,000 t against 5,449,000 t reported and is now 5,449,000 t. On a real 2005-2020build_commodity_balances()the effect is 678 changed keys, 559 of them area 206; every other area moves by 43.4 t in total across 119 keys (largest single move 3.91 t, 4e-9% of the build). Element totals over that range move by 1.79% onstock_variationand by less than 0.03% on everything else.reporting_polity_codefor bucket 206 isSUD-1956-2011before and after.polity_end_year/end_yearis now read as exclusive everywhere, which is the convention upstreamwhep-politiespublishes: a successor’sstart_yearequals its predecessor’send_year, and 240 of the 245 FAOSTAT-map rows inpolity_area_crosswalkcarrypolity_end_year == map_year_end + 1.add_polity_code()used to join onpolity_end_year >= year, so a period answered for one year past its end. Over the 1961-2024 grid for all 266 crosswalk areas that put 7 area-years on their period’s end year, of which 3 landed in a state that had already dissolved and still read"matched"/"manual": 1993 Czechoslovakia (F51-1947-1993), 2006 Serbia and Montenegro (SCG-1992-2006) and 1992 Yugoslav SFR (F248-1991-1992). Those three now report"out_of_span". The other four are years the upstream map explicitly declares the area reports (map_year_end, inclusive), and the resolver keeps them: a reported year is never dropped for being one past a polity’s end. No published value moves: over 1850-2024 x 266 areas,polity_area_codeis unchanged on every row, the resolved-row count is unchanged (46,336 with the default back-cast anchor), and the onlypolity_codethat moves is area 273 Montenegro in 1962, fromMNE-1913-1918toMNE-2006-2025– a nearest-period stand-in either way, now landing on the nearer period.build_constant_territory_series()reads the same convention, so a dissolved polity no longer sits on top of its successors in the hand-over year (238 polities carried a polygon in 1993 on the old reading against 236, and 453 extra active polity-years over 1850-2024), where each grid cell goes to exactly one target and the predecessor was capturing the ones its successors should have received. Note thatref_year = 2025now aborts: the vintage’s open periods carry 2025 as their exclusive end, so they stop at 2024.inst/scripts/prepare_spatialize_all.Rno longer repairsmueller_synthetic_n’s FAO-style legacy ISO codes with a hand-maintained 14-entryrecode()list. The mapping now comes fromwhep::polity_label_aliasesthroughresolve_polity_label(), bridged back to the country grid’s numericarea_codethrough the polity’siso3_codeand the sameregions.csvlookup the grid is rasterised from. No published values change: the resultingcrop_synthetictable is byte-identical, 5,043 rows resolving to the same 156 area codes with a maximum rate difference of 0. Four of the 14 list entries (BHA,BAR,DMI,STL) named codes the dataset never uses.New
polity_coverage_gaps()reports the rows of a built table whosereporting_polity_codeis a nearest-period stand-in, i.e. a polity that did not exist in that row’s year.add_polity_code()has always reported these asmapping_status == "out_of_span", but the reporting-column boundary every area-keyed output crosses dropped that column, so the documented uncertainty was invisible in published data. Measured on the real FAOSTAT production path (.read_input("faostat-production")aggregated to polities, 1961-2024), 5,637 of 3,011,912 rows (0.19%) are stand-ins, all of them bucket 206 “Sudan (former)” over 2012-2024 onSUD-1956-2011; onfaostat-fbs-oldit is 972 of 5,331,877 (0.018%), the same bucket over 2012-2013. Across the whole crosswalk over 1961-2023 it is 922 of 16,658 resolved area-years in 28 areas, in both directions. No published value or column changes: the new function is a separate query, and carrying the signal on the outputs themselves is opt-in throughoptions(whep.polity_mapping_status = "flag")for a logicalreporting_polity_out_of_span, or"status"for the fullreporting_mapping_status. The default,"none", is today’s schema. Which of the two to adopt as the default is an open decision (#545).build_energy_co2_extension()gains a thirdunclassifiedtreatment,"polity_region", for the live reporting areasgleam_geographic_hierarchyhas no row for. On today’s crosswalk that is Nauru (area 148) and Tuvalu (227): they exist, report under their own area codes, and their meat production left the extension unpriced."polity_region"groups them by running GLEAM’s own scheme rules on the continent their polity carries – no grouping label is added to the package – so Tuvalu now lands on"Least developed countries", the classification.energy_ldc_iso3()already asserted for TUV while joining against a table with no TUV row. Those rows are labelled"GLEAM_3.0_energy_meat_polity_region"inmethod_energy. No published value changes: the default is still"drop", and the full 1850-2023 build is bit-identical under both"drop"and"global_mean". Measured, for the decision:"polity_region"adds 366 rows and 2 areas (61,149 to 61,515), moves no existing row by any amount, and raises total energy CO2e by 0.0000155%; it puts Nauru at 288,502 kg CO2e and Tuvalu at 424,851 kg over 1961-2023, against 664,412 and 1,775,719 under"global_mean". Whether the default should move is left open in whep#415.folded_reporting_areas()no longer calls all 61 Rest-of-World folds a FABIO convention, because for four of them it is not one. FABIO’s own published region list –io_codes.csvof the v1.1 release (Zenodo record 2577067), 192 areas x 125 commodities, the fileinst/scripts/compare_fabio.Ralready downloads – gives 153 New Caledonia, 154 North Macedonia, 209 Eswatini and 212 Syria each their own commodity block, distinct from area 999RoW; the FABIO source repository marks all fourcurrent == TRUE, which is exactly the flag itsreplace_RoW()keeps out of bucket 999.regions_fullnonetheless gives themfabio_code999 while flagging themcbsTRUE, and Syria is the single largest contributor to the fold (24,426faostat-productionrows). Those four now come back as a thirdfold_kind,"cbs_reporter_folded", separating them from the 57 folds FABIO does make; a newoptions(whep.unfold_rest_of_world = "cbs_reporters")promotes only those four, alongside the existingTRUE(equivalently"all") for all 61. No published value changes:fabio_code,polity_area_codeand every polity label are untouched on the default path, which the suite pins against the committed crosswalk. Whether to correctregions_fullis an open decision (#556); doing so is the numeric un-fold of #563’s option 3, and must not be done at the polity level alone (#480, reverted in #561).build_feed_demand()gainsregion_fallback, which decides how a reporting bucket the crosswalk leaves without a Bouwman feed region gets one. Rest of World (area_code999) folds 62 FAOSTAT reporting areas, 58 of which have a region of their own, and kept none of them; every region-keyed join therefore missed and the bucket’s feed demand went nowhere. The new default,"member_mix", splits the bucket across its members’ regions weighted by the livestock those members carry (Middle East 0.69, Southern Africa 0.18, Oceania 0.045, Eastern Europe 0.045, then five smaller regions)."none"restores the previous behaviour. This moves published values, for area 999 only. Measured over a full 1850-2023get_primary_production(): withby = "feed_type"the mix gains 5,500 keys and 926,327,446 t of dry matter (world total +0.151%) where 808,638,528 t had been dropped outright, and no key that existed before changes by more than 1e-6 t; atby = "category"area 999 goes from 808,638,528 to 926,327,446 t (+14.6%) withdemand_tier = "ipcc", and from 0 to 2,035,462,034 t withdemand_tier = "fcr". All 191 other areas are bit-identical in both tiers. The five continent residuals901-905stay unmapped on purpose: they span several Bouwman regions each and carry no production row at all.build_cell_polity()gainsarea_key, choosing which code the shared cell-area grid every gridded consumer keys on. The grid is rasterized from present-day polygons throughregions.csv, so itsarea_codeis a raw reporting-area code, not thepolity_area_codebucket every polity-keyed national table in whep is aggregated on. On the deployedcell_polity_fractionparquet 12 of its 182 codes (819 cells) are not a bucket – Syria, Palestine, Eswatini, Equatorial Guinea, North Macedonia, New Caledonia, Western Sahara, Andorra, Liechtenstein and San Marino (all folded into999Rest of World),62Ethiopia PDR (into238) and277South Sudan (into206) – so their cells match nothing on either side of the join. Measured against real 2010 harvested area, 21.09 Mha of national cropland, 1.525% of the world total, cannot be placed on any grid cell; 15.30 Mha of that is the whole of Ethiopia and 5.67 Mha is Rest of World.area_key = "polity_area"re-keys the grid on the bucket and cuts that to 0.12 Mha (0.009%). No published value changes: the default"grid"reproduces today’s output bit-for-bit and only adds a warning naming the codes, because switching moves every gridded consumer’s territorial attribution at once. Whether it should become the default is issue #460.politiesandpolity_area_crosswalkare re-synced against upstreamwhep-politiesateb02dcb(740 rows to 749), which retired or superseded 14 codes this package had been treating as live and published a replacement for each. The user-visible consequence is thatreporting_polity_codevalues change:ROW-1850-2023becomesROW-1850-2025, the six regional bucketsRAFR/RASI/REUR/RNAM/ROCE-1850-2021andRLAM-1850-2013become-1850-2025, andCAN-1948-2025becomesCAN-1949-2025. Newfoundland acceded on 31 March 1949, so calendar 1948 now resolves to pre-accession Canada (CAN-1886-1949, 9,379,600 km2) instead of post-accession Canada (9,774,537 km2) – a 394,937 km2 correction visible only where the back-cast anchor is off, i.e. historical trade sources reported under their own borders. The bucket extensions recover 88 previously unresolvable area-years over 1961-2024 (RLAMalone had lost 2014-2024), of which 20 fall inside the default1850:2023build range. No published value is expected to move:polity_area_code, the numeric bucket every matrix workflow aggregates on, is byte-identical for all 267 reporting areas, and the recovered area-years are either year 2024 (outside the default range) or areas 901-906, which are WHEP reporting labels no source dataset carries. That is a crosswalk-level measurement, not a full-pipeline one.read_population()now reports thearea_coderows that are aggregates of several territories, alongside the message it already emitted for the dropped regional residuals.area_codeispolity_area_code, a bucket rather than an identity, so with the realgdp-populationpin eight ISO3 codes fold into two rows: 999 “Rest of World” (Syria, North Macedonia, Palestine, Eswatini, Equatorial Guinea, French Guiana) and, from 2012, 206 “Sudan (former)” (Sudan + South Sudan). That is 0.35% of the population over 1850-2021 and 1.05% in 2015, against the 0.07% the existing message covered. The fold is deliberate – those are the codes the commodity balances are keyed on, so a finer key would leave their food supply with no denominator – and the@returndocumentation now says a row is an area code rather than a country. No published value changes: the output of a full real-pin read is byte-identical before and after (28,255 rows, 530,970,330,534 person-years).build_energy_co2_extension()now reports the meat production it cannot price instead of dropping it in silence. Reporting areas with no row ingleam_geographic_hierarchyget no energy intensity, so their carcass production used to leave the extension without a word: measured on the full FAOSTAT production input, that is 595 Mt of carcass weight, 3.48% of 1850-2023 and 15.3% of 1961, over eight areas – the USSR (436.8 Mt), Belgium-Luxembourg (43.9), Czechoslovakia (38.1), the Yugoslav SFR (37.8), the Rest-of-World bucket 999 (25.5), Serbia and Montenegro (12.8), Tuvalu and Nauru. A warning now names them with their tonnage and share. A newunclassifiedargument selects the treatment:"drop"(default) keeps the historical behaviour, and"global_mean"prices those areas at the unweighted world mean of the published GLEAM factors, marking the affected rows"GLEAM_3.0_energy_meat_global_mean"inmethod_energy. No published value changes on the default path (verified bit-identical on the full input);"global_mean"raises total energy CO2e by 4.4% over 1850-2023, 14.3% in 1961 and 0.17% in 2023. Which treatment is right is an open decision (#492).New
polity_bucket_coverage()reports every FABIO reporting bucket (polity_area_code) that folds more than one polity in a year, and says whether the polity the bucket itself resolves to covers the fold ("aggregate"), covers only part of it ("partial"), or is absent ("unlabelled"). Exactly one bucket in the shipped crosswalk is"partial": 206, which folds FAOSTAT areas 276 Sudan and 277 South Sudan while no live polity means “Sudan and South Sudan”. Measured on real FAOSTAT production for 2015, that bucket carries 53,124,088 t for Sudan plus 14,876,146 t for South Sudan – 21.9% of the bucket – under one polity label..aggregate_to_polities()now warns when it builds such a bucket; silence it withoptions(whep.warn_polity_folds = FALSE). No published value changes: the fold, the numeric bucket and every polity label are exactly as before, and the only new behaviour is the warning and the new function.The FABIO Rest-of-World fold is now reported instead of silent, and the measurement that was blocking a decision on it has been redone. New
folded_reporting_areas()lists every reporting area whosepolity_area_codeis not its ownarea_code: 61 areas folded into Rest of World, of which 14 carry observed data (Syria 24,426faostat-productionrows, Eswatini 12,196, Réunion 11,970, Palestine 9,606, the Faroe Islands 2,458 and nine more, 130,103 rows in total), plus 3 successor-state folds (62 into 238, 276 and 277 into 206)..aggregate_to_polities()now warns per source, naming the areas and the rows it folded, because these areas resolve withmapping_status == "matched"and so no coverage count could show them. No published value changes.options(whep.unfold_rest_of_world = TRUE)promotes each member to its own code for sensitivity work; it warns on every crosswalk read and is not a production mode. Measured on a full-rangeget_wide_cbs()(1850-2023), promoting all 61 members moves global totals by at most 1.2% (stock_addition) and under 0.1% forfeed,productionandprocessing— the 13.7x feed inflation recorded in issue #419 does not reproduce, because that comparison predates thedcast()duplicate-key fix in.select_best_source()(#425).build_primary_production()gainsfederation_land, controlling how the pre-1962 LUH2 back-cast reaches an area whose territory is a dissolved federation. LUH2 land use is keyed on present-day ISO3, so 15 Belgium-Luxembourg, 51 Czechoslovakia, 228 USSR and 248 Yugoslav SFR have no land record of their own and their pre-1962 production has never been back-cast at all – 14.3% of 1961-62 FAOSTAT production tonnage, USSR alone 12.2%.federation_land = "successor_union"rebuilds each federation’s land series as the sum of its successor states’ LUH2 land, resolved from thesuccessorrelation published inpolities, and reduces the unmatched areas from 4 to 1 (only Belgium-Luxembourg, which upstream publishes no successor for). No published value changes by default:"none"keeps current behaviour. Measured on a 1850-1965 build,"successor_union"raises global pre-1962 production tonnage by 13.9% (1850) to 19.4% (1960), moves exactly three area codes (51, 228, 248) and moves no row at or after 1961; it also closes the hard 0-to-704 Mt discontinuity USSR had at the 1961 splice (1960/1961 now differ by 1.2%).polity_area_crosswalknow takes its area-to-polity mapping from upstream’s published map (faostat_area_polity_map.csv, read viaWHEP_POLITIES_FAOSTAT_MAP, 281 rows over 228 FAOSTAT area codes) instead of inferring it from the polity-code string withsub("-.*", "", polity_code). The build aborts if the map is absent rather than falling back silently. Seven reporting areas gain a mapping no prefix could reach – Djibouti (72) had resolved to nothing, and areas 7, 20, 181, 237, 249 and 251 reachANG-1905-1975,BEC-1885-1966,SRH-1953-1964,F237-1954-1975,F249-1918-1990andNRH-1953-1964. Area 15 resolves toBLX-1850-1999rather thanBLX-1921-1999, and area 206 “Sudan (former)” toSUD-1956-2011rather than standing in on post-secessionSDN-2011-2025. Prefix inference is kept, labelled in a newmapping_sourcecolumn, only where the map is silent: seven areas it does not cover (351 and 901-906) and periods outside the spans it declares, which is what keeps pre-1961 history resolvable for sources reported under their own borders. The four-part codes that used to enter through the prefix collapse (AZE-SSR-1920-1991,IDN-BLB/JVM/OTH-1949-1951,MMR-LWR-1852-1885) are gone, taking crosswalksubnationalrows from 6 to 3 and ambiguous(area, year)resolutions from 199 to 86. This moves published values and no magnitude comparison has been run.politiesis refreshed from upstream, 603 rows to 740, because the published map names 43 polity codes the old snapshot did not contain. This is the refresh #485 drafted.The manure/nutrient chain now documents one vocabulary for
territory: a stringifiedarea_code, whatredistribute_feed()emits and what the pipeline has always passed. The@examplesofestimate_n_excretion(),split_manure_management(),apply_management_losses(),allocate_manure_to_land(),allocate_manure_transport()andbuild_livestock_nutrient_flows()used ISO literals instead ("ESP", and"ES", which the chain’s own resolver rejects outright). Passing aniso3cstill resolves, as a bridge for existing fixtures, but now warns: it can only answer withpolity_area_code, a FABIO aggregation bucket, which for 62 of the 257 ISO3 codes inregions_fullis not that territory’s own code (61 land on 999, Rest of World;"SSD"lands on 206, Sudan (former), where the numeric form"277"keeps South Sudan). No published value changes: the pipeline itself never took the ISO3 branch.urban_n_referencenow carries apolity_codecolumn ("ESP-1800-2025") alongside its numericarea_code, so the benchmark series names the territory it measures instead of only the FAOSTAT aggregation bucket203. The code is resolved per benchmark year against the polity active in that year. Additive:area_codeand every measured value are unchanged, and no exported function reads this dataset at runtime. This sets the convention for the other territory-keyed coefficient tables.add_polity_code()no longer presents a nearest-period stand-in as a real match. When no mapped period covers a row’s year the row still resolves to the nearest period of the same area, butmapping_statusnow reports"out_of_span"instead of inheriting the crosswalk’s"matched"/"manual", so a figure attributed to a polity that did not exist in that year is visible. Over the FAOSTAT era this covers 993 of 16638 resolved area-years across 36 areas, in both directions: FAOSTAT area 206 “Sudan (former)” for 1961-2010 resolved toSDN-2011-2025(post-secession Sudan, which excludes the territory those figures cover) and area 51 Czechoslovakia for 1994-2023 resolved toF51-1947-1993, a state that had dissolved. Relabelling only: nopolity_codeassignment changes (0 of 16638), and no exported table carriesmapping_status, so no published value moves.Crop-residue feed-use fractions are live again. The
residue_feed_fractioncoefficient table’s region column was namedregion_hanppbut held UN M49 sub-regions, andcalculate_residue_destinies(method = "krausmann_regional")joined it against aregion_hanppcolumn the pipeline filled fromregions_full$region_HANPP. The two vocabularies share no label, so the join matched nothing and every polity silently took the"Global"default of0.20— a table spanning0.05to0.45, dead in full. The column is renamed toregion_un_sub(values unchanged, apart fromSouth-Eastern Asia->South-eastern Asiato matchregions_full), and the method now requires aregion_un_subinput instead ofregion_hanpp; 230 of 261 areas receive a region-specific fraction, the rest (Micronesia, Polynesia, RoW and areas with no M49 sub-region) keep the0.20fallback. This moves published values:residue_feed_dm_tandresidue_burn_dm_tchange, and with thembuild_residue_feed_avail()and the nitrogen balance’sused_residue_n_t/burnt_residue_n_t.residue_soil_dm_tand their sum do not change (neither depends onfeed_use_fraction), sobuild_soil_carbon_inputs()’s residue carbon is unaffected.The pre-1962 commodity-balance fills now key their proxies on the polity rather than on an area name. Three name vocabularies met at that join: the frame carries the periodized
polity_name(FAO area 3 arrives as"Albania (1913-2025)"), the gdp/population pin carries its own labels ("Albania") and the LUH2 land table the crosswalk’s staticarea_name. 57 of the pin’s 196 names (8,263 rows, 27.8%) and 96 of the LUH2 labels (41.7% of land rows) were names no builder emits, so those territories silently kept their gaps. This moves published values: proxy coverage of the pre-1962 frame’s (year, polity) cells rises from 13,664 to 18,480 of 22,624 for population (43 polities gain a proxy, none lose one) and from 402 to 567 of 606 for agricultural land over 1900-1902 (55 gain, none lose). Aggregates that are only reached by folding other territories into them (Rest of World, 999) are still left without a proxy: what an aggregate’s proxy should be is an open methodological question (#493).Every area-keyed exported output now carries the reporting-polity columns (
polity_area_code,reporting_polity_code,reporting_polity_name,reporting_polity_has_geometry), so a caller can tell which territory a row belongs to, and whether it has a polygon, without re-joining the crosswalk. This widens the contract from 10 exports to 35, covering the balances (build_nitrogen_balance(),build_carbon_balance(),build_water_balance(), …), the footprint extensions (get_crop_land_extension(),build_livestock_ghg_extension(), …) and the gridded readers (read_luh2_landuse(),get_soc_climate_drivers()). The columns are additive: no existing column or value changes.get_faostat_data()keeps returning raw FAOSTAT area names (it is the pre-resolution reader), andbuild_grazing_feed_footprint()/build_land_balance_footprint()are aggregated over time and have no year to resolve a polity against.build_urban_n()now returns the numeric WHEParea_coderather than the character territory key its manure-transport reuse works in; an ISO3 input still resolves through the same checked resolver as the manure path.Add gridded soil water, carbon and nitrogen balances (0.5° cell × polity fragment).
build_water_balance()closes the annual cell water budget from LPJmL hydrology and exposes drainage (for N leaching) plus footprint terms;get_soc_climate_drivers()emits the monthly SOC climate drivers from CRU.build_carbon_balance()runs historical SOC dynamics (equilibrium init, LUH2-driven land-use march, LUC C-transfer) and derives ΔSON via asymmetric C:N;calculate_soc_dynamics(model = c("hsoc","rothc","icbm","amg", "century"))wraps the five SOC models (default"hsoc"), fed bybuild_soil_carbon_inputs().build_nitrogen_balance()assembles inputs (build_n_inputs(), incl.build_n_deposition()/build_urban_n()) minus the selectable losses (calculate_nh3()/calculate_soil_n2o()/calculate_n_leaching(), with the process-based MANNER NH3 model) into surplus, NUE and GWP/CO2e indicators. New readers:read_lpjml_hydrology(),read_cru_climate(),read_luh2_landuse(),read_hyde_population(),read_lpjml_wind(),read_soil_ph(),read_soil_hydraulic()(per-cell field capacity, wilting point and porosity from HWSD texture, feeding the ICBM SOC moisture modifier). New coefficient datasets for SOC turnover, humification, C:N, N2O EFs, MANNER and denitrification, the crop growth-stage soil-cover curve (soc_soil_cover_curve), and the USDA texture-class soil hydraulic properties (soil_hydraulic_by_texture) with the HWSD texture-code crosswalk (hwsd_texture_usda).Add
ensure_columns()to complete tibbles from typed zero-row prototypes, with safe casts, scalar defaults, deterministic ordering, and explicit extra-column handling.Add
decompose_weighted_ratio()for exact Kitagawa/Shapley, additive LMDI, and sequential-polar decomposition of changing aggregate ratios.build_io_model()andbuild_footprint()gain amethod = c("mass", "value")argument for co-product allocation."value"splits a multi-output process’s inputs (and the pressures embodied in them) across its products by economic value (mass times export price frombuild_cbs_prices()) instead of mass, falling back to mass for any process whose co-products lack prices. The default"mass"preserves previous results (#100).Add
build_livestock_ghg_extension(): aggregate the IPCC enteric and manure emissions pipeline into a greenhouse-gas footprint extension (kg CO2e perarea_codexitem_cbs_code), with selectable IPCC tier and GWP100 standard, ready to feedcompute_footprint().inst/scripts/footprints.Rgains aWHEP_FOOTPRINT_PRESSUREswitch to trace GHG instead of land (#100).calculate_livestock_emissions(tier = 1)now also computes manure N2O (direct and indirect) from IPCC default per-head excretion rates, so the default Tier-1 GHG footprint covers enteric CH4 + manure CH4 + manure N2O rather than methane alone (#100).Add
build_crop_soil_n2o_extension(): IPCC 2019 Tier 1 nitrous-oxide from nitrogen applied to managed soils – synthetic fertiliser (F_SN), applied manure (F_ON, FAOSTAT “Manure applied to soils”), and above-ground crop residues (F_CR, residue dry matter times Table 11.1a N content, net of removal) – as a CO2e footprint extension keyed by(area_code, item_cbs_code). F_SN and F_ON are FAOSTAT country totals allocated to crops by harvested area. Direct (EF1), indirect volatilisation (FracGASF for synthetic, FracGASM for manure; residues excluded) and leaching per Ch. 11; EFs verified against the IPCC source. Grazing deposition (F_PRP) and below-ground residue N are documented follow-ups (#100).Add
build_footprint()andalign_extension(): trace a long-format extension table through the supply chain end to end, de-duplicating the per-year alignment glue previously repeated across the footprint driver scripts (#100).Fix non-dairy cattle being misclassified as dairy in the livestock emission factors: a case-insensitive
"dairy"match also matched the"Cattle, non-dairy"item name, so beef cattle received the much larger dairy enteric and manure factors. Non-dairy cattle CH4 (Tier 1 and Tier 2) is now classified correctly.Fix
prepare_livestock_emissions()failing on real production data whenlive_anim_codeis supplied as an integer.Fix
build_io_model()not passing the now-requiredfeed_modeargument to the feed-redistribution step.Fix mojibake area labels in
regions_full,polities_catsandpolity_area_crosswalk: Curaçao, Côte d’Ivoire and “Netherlands Antilles / Curaçao” shipped with their accented letters read as pairs of Latin-1 characters. The vendored harmonization CSVs are now repaired on read indata-raw(#399).polities_catsis now derived fromregions_fullrather than vendored as a second hand-maintained copy of the same 39 columns, so the two can no longer drift. They had: 17 columns disagreed over the 198 shared area codes, and 95 of the differing cells were the literal string"0"ineia,ieaand elevenregion_*columns whereregions_fullleavesNA. Those 95 cells are nowNA; the row set, row order, column names and column types are unchanged, and the deliberate fold of Bhutan intoRASIand Comoros intoRAFRis kept as an explicit override (#406).consolidate_sources()gains two opt-intie_breakoptions for panels whose sources report exact zeros or several quality variants of one cell.coverage = "positive"counts the coverage tie-break over strictly positive values instead of non-missing ones, so a zero-padded series no longer wins on inflated coverage;quality_variants = TRUEcollapses a source’s severalquality_colvariants of a cell to its best-ranked one instead of aborting. Both default to the previous behaviour (#139).get_faostat_data()no longer attaches and then unloadsFAOSTATto makeFAOSTAT::fillCountryCode()see its lazily loadedFAOcountryProfile. The ISO3 lookup now loads that dataset explicitly and matches area names itself, reproducingfillCountryCode()’s rule (exact match against the six profile name columns, unresolved when several profile rows match). Verified identical on all 232 FAOSTAT area names inregions_fulland upstream’sfaostat_area_polity_map: 215 resolve, 0 differences. Two side effects are gone – the user’sFAOSTATsession state is left alone, and rows keep their input order instead of being sorted by area name by an internalmerge()(#520).citation("whep")now returns two entries – the package itself, carrying its CRAN DOI and all five authors, and the FABIO paper the model builds on – where it returned only the generatedDESCRIPTIONdefault before. The package entry takes its year fromDate/Publicationrather than a hardcoded one. The machine-readable equivalents,CITATION.cffandcodemeta.json, ship alongside it, and the package gained a code of conduct and a link from the README to the contributing guide. Groundwork for rOpenSci peer review (#75).The HWSD readers now say which column a local
hwsd_data.csvis missing.read_soil_ph(),read_soil_hydraulic()and the soil-carbon clay driver check the extract against the columns they are about to read and abort naming the absent ones plus the script that re-exports a complete extract, where a partial extract previously surfaced as adplyrerror (Column t_clay not found in .data) that read as a code fault rather than a stale input.inst/scripts/export_hwsd_attributes.Rnow exportst_clay, so a re-run produces an extract the clay driver can read. No published value changes: a complete extract is read exactly as before (#596).propagate_fp_uncertainty()no longer reseeds the calling session. Givenoptions = list(seed = )it calledset.seed()and left it set, so every random number drawn afterwards depended on having made the call, and in a session that had not yet used the RNG it created.Random.seedwhere there was none. The seed is now scoped to the call and the previous RNG state (or its absence) is restored on return. Seeded results are bit-identical to before; unseeded runs still consume the caller’s stream, so consecutive unseeded runs remain independent draws (#188).A failed Natural Earth download now reports how to recover instead of dying on its own error message. The abort interpolated the layer URL as
{.url {.natural_earth_url(layer)}}, and cli >= 3.4.0 reads a{}expression starting with a dot as a style name, so the branch raisedInvalid cli literaland the instructions never reached the user. The province typologies (create_typologies_grafs_spain(),create_typologies_of_josette()) are the callers that reach it. No published value changes (#594).
whep 0.3.0
CRAN release: 2026-03-03
- Add
fill_proxy_growth()andcalculate_lmdi()(@jinfama, #65). - Build datasets for GRAFS model in Spain (@AliceBeckmann, #18).
- Add harmonization functions (@justin-morgan-csic, #66).
whep 0.2.0
CRAN release: 2025-10-15
- Add gapfilling functions
fill_linear(),fill_sum()(@eduaguilera, #11). - Now examples can’t fail because of unavailable Internet resources (#58).
whep 0.1.0
CRAN release: 2025-07-25
- Work in Progress FABIO model implementation:
- Build supply-use tables (
build_supply_use()) (#17). - Balance bilateral trade (
get_bilateral_trade()) (#8, #9).
- Build supply-use tables (
- Create article
Follow the workflowfor new contributors (#1, #2, #29). - Download large datasets with
whep_read_file()andpinspackage (#29, #43). - Get raw FAOSTAT data with
get_faostat_data()wrapper (#3). - Initial CRAN submission.
