Skip to content

Measure the pre-1962 back-cast's hectares on each year's own borders - #776

Draft
lbm364dl wants to merge 5 commits into
mainfrom
polity/761
Draft

Measure the pre-1962 back-cast's hectares on each year's own borders#776
lbm364dl wants to merge 5 commits into
mainfrom
polity/761

Conversation

@lbm364dl

Copy link
Copy Markdown
Collaborator

What was wrong

The pre-1962 back-cast estimates production as tonnes = ha * t_ha.

The yield half is already historical. .fill_yields() back-casts t_ha
with fill_proxy_growth(proxy_col = "yield") against the international-yields
pin — 1,058,295 usable pre-1962 observations, 1840-1961, 179 areas, 155 items.
Nothing in this PR touches it.

The area half was not. It came from the luh2-areas pin, which is LUH2 land
pre-aggregated to present-day ISO3, so a row labelled with the 1961 entity
was measured on the borders that entity has today. Land enters through exactly
one seam, the merge in .fill_pre_faostat(), and that seam is what this PR
changes.

Evidence it reproduced BEFORE the change

Re-measured from scratch, not taken from #761 on trust. Gridded LUH2 summed
inside Ethiopia's polygons against the luh2-areas pin for area 238:

year polity pin, present-day borders (Mha) inside that year's own polygon
1850 ETH-1800-1889 3.222 1.293
1900 ETH-1897-1902 6.133 5.784
1940 ETH-1936-1941 8.472 8.447
1951 ETH-1941-1952 9.237 9.241
1952 ETH-1952-1993 9.413 9.918
1961 ETH-1952-1993 11.341 11.947

1850 is 2.5x too large on present-day borders; the series converges by 1940 and
sits ~5% higher after 1952, which is Eritrea, correctly included. The grid path
reproduces the pin on Ethiopian cropland to within 0.5% at every year, so the
new producer is measuring the same thing, spatially.

Two things #761 did not know, both found by measurement, both load-bearing:

  1. The pin's aggregation rule is centroid, winner-take-all. luh2-areas and
    read_luh2_landuse() both key on spatialize-country-grid, one area_code
    per 0.5° cell with no share column. Weighting LUH2 by a raw fractional
    polygon coverage instead loses 12-15% of the land of every island and heavily
    coastal country (Philippines 5.92 → 5.12 Mha, Denmark 2.42 → 2.05, Greece
    3.31 → 2.92), because LUH2's state fractions are fractions of the whole cell
    and already discount open water — weighting them by the cell's land share
    discounts it twice. Renormalising the shares to one per cell, which is what
    build_cell_polity_fraction() already does, fixes it: global 1961 cropland
    goes 1547.9 → 1327.8 Mha against a raw LUH2 total of 1332.4 and a pin total
    of 1315.5.

  2. The unfloored resolver does not hand you a territory. In 1961, 62
    reporting buckets
    resolve to the residual ROW-1850-2025, and dozens more
    resolve out of span to a state that did not exist (bucket 185 Russia inside
    the USSR, bucket 167 Czechia inside Czechoslovakia). Sharing each cell among
    everything that claims it then gave every ROW cell to 62 buckets at 1/62
    each — which is what shrank Belgium-Luxembourg to 0.026 Mha of cropland
    in the first working version. Neither case is borders, so neither is
    measured.

What I changed

R/historical_land_areas.Rbuild_historical_land_areas() emits the
seam's own shape, (year, area_code, Cropland, Pasture, agriland) in Mha, with
the hectares summed from gridded LUH2 inside the polygon of the polity that
area_code resolves to in that year, resolved unfloored
(backcast_anchor = -Inf, the switch .resolve_hist_trade_polities() already
uses). Polygons come from get_polity_geometries(), rasterised once per polity
onto the LUH2 grid with terra::extract(exact = TRUE).

The series is not re-keyed. fill_proxy_growth() groups by fill_cols,
which includes area_code; keying on polity_code would start a new group at
every period boundary and fragment the series silently, which is the
whep#589/#632 failure. (year, area_code) stays the key and only the values
change.

The boundary rule is boundary_step, and both options are implemented and
measured, because fill_proxy_growth() reads nothing but year-on-year ratios,
so a change of territory can only reach the back-cast as a ratio:

  • "relink" (default) re-measures the previous year inside the incoming
    polygon before taking the ratio, so only within-territory growth is ever used.
  • "level_step" takes the ratio between the two polygons as measured, so the
    territorial change passes through as a level step.

On Ethiopia at 1952, when Eritrea joins, the land ratio is +1.9% under
"relink" and +8.0% under "level_step". Expressed as a series, "relink"
is a chain-link from the last year backwards, which has a second property worth
naming: both sides of every ratio are then measured with the same year's
polity set, so a constant overlap between two polygons cancels exactly.

Behind an argument, default unchanged.
build_primary_production(land_method = c("present_day", "historical_polity")).
The default path is untouched — .extend_historical() still reads the
luh2-areas pin through the same code, now extracted into
.land_wide_from_areas(). The chosen method is recorded in source, the column
this series' provenance already lives in: LUH2_polity_cropland /
LUH2_polity_agriland. See "the open decision" for why not a method_land
column.

data-raw/historical_land_areas.R materialises the full 1850-1961 table.
A pin is out of scope for me (I cannot upload to the board), so the script is
the reproducible generator and names the provenance a pin would need to record.

How I verified

  • Ethiopia, the easy case. Reproduced every checkpoint above, then the
    handover: growth at 1952 is 1.0795 under "level_step" and 1.0193 under
    "relink", against 1.0205 in 1951 and 1.0198 in 1953. The step lasts exactly
    one year.

  • A fully dissolved federation. All four are reached without
    federation_land = "successor_union", because each has a polygon of its own:
    Belgium-Luxembourg and the USSR over the full 1850-1961, Czechoslovakia and
    Yugoslavia over 1917-1961 (before 1917 they resolve out of span — correctly:
    they were Austria-Hungary). The USSR walks its own three-period chain
    F228-1800-1856, F228-1856-1905, F228-1945-1991 — 107.7 Mha of cropland
    in 1850 rising to 237.9 in 1961.

  • A chain with gaps. Czechoslovakia and Yugoslavia are exactly that case,
    and the boundary rule fires inside them: 1918, 1919 and 1944-1946 for
    Czechoslovakia, 1918-1919 for Yugoslavia, each moving the level by >1%.

  • Global, not one country. At 1961 the historical series sits within 5% of
    the present-day pin for 70 of 106 buckets, median ratio 0.980.

  • Four guards, each proven load-bearing by reintroducing the defect,
    confirming the failure, and restoring:

    defect injected tests that fail
    the data.table year-shadowing bug 2
    boundary rule made a no-op 1 (2 assertions)
    cell land not shared between polygons 1 (2 assertions)
    residual/out-of-span filter removed 1

    The first of those is not hypothetical: polity_areas[year == year] shadows
    the argument with the column and silently keeps every row. It made the polity
    set never change, so every boundary step vanished — and the test caught it
    before the real run did.

  • A defect the tests found in my own code. The chain-link accumulated from
    the front, so a single unmeasurable year made the running total NA and wiped
    out a bucket's whole 112-year series instead of just the part the break cuts
    off. Four polities reachable from a reporting area carry no polygon, so it was
    reachable. Fixed to accumulate from the end; guarded.

  • Full suite green (no failures, only the usual WHEP_*/LPJmL skips); air format ., devtools::document(), lintr clean, pkgdown reference complete.

Moves published values

Nothing moves by default. land_method = "present_day" is the default and
its code path is byte-for-byte the old one.

Under land_method = "historical_polity", on back-cast crop tonnage weighted by
1961 FAOSTAT production (6,288 Mt over 176 buckets):

year "relink" net / abs "level_step" net / abs
1850 −17.2% / 19.2% −8.7% / 31.3%
1875 −10.0% / 11.3% −0.3% / 26.9%
1900 −5.5% / 6.5% +2.4% / 22.9%
1925 −3.3% / 3.8% +3.4% / 10.5%
1950 −1.0% / 1.3% −0.7% / 1.3%
1961 −0.2% / 0.2% −0.2% / 0.2%

The two boundary rules differ from each other by 18.0% of back-cast tonnage
at 1850 and 0.07% by 1960. 56 of 105 buckets move by more than 1% at 1850
between them, the largest being Poland (+294%), Greece (+130%), Ethiopia
(+114%), Egypt (−84%) and Austria (−81%).

Coverage cost. 177 of 262 buckets can be measured on their own borders over
1850-1961. Weighted by tonnage that is small: 5 buckets carrying 1961 crop
tonnage lose their back-cast entirely, 9 Mt = 0.1% of the total
— Syria (6.9
Mt, its 1961 polity is shared with another bucket), Eswatini, Equatorial Guinea,
New Caledonia, the Faroe Islands. A bucket that cannot be measured is simply not
back-cast, which is what already happens to an area with no LUH2 match.

What I deliberately did not do

  • Did not touch the yield half. The 1,058,295 observations are untouched and
    .fill_yields() is not in the diff.
  • Did not re-key the series to polity_code. See above.
  • Did not change the default. This is a science decision, not a fix.
  • Did not publish a pin. The generator is in data-raw/ and says what a pin
    would need to record (LUH2 vintage + polities snapshot). Without it,
    land_method = "historical_polity" recomputes: ~7 s/year of gridded LUH2 plus
    one pass of polygon rasterisation, so ~15 minutes for the full span.
  • Did not dodge the join-audit gate. .land_in_polygons() keys on
    polity_code with no year, and a polity code already names its own period, so
    it is classified time_invariant and the ratchet goes 59 → 60 with the reason
    written into the row — the "rise that is a fall" case the test's own comment
    describes. Nothing was rewritten as match().
  • Did not fix the upstream polygon problems this makes load-bearing. See
    below.

The open decision

The two boundary rules answer different questions, and #761 asks for both.

Its evidence section wants 1850 Ethiopia to come out small — "the current
figure credits pre-expansion Ethiopia with cropland in regions annexed in the
1880s-90s". That is "level_step". Its design section wants the 1952 Eritrea
step not to propagate. That is "relink". Because the consumer reads only
ratios, you cannot have both: either a territorial change scales the earlier
years or it does not, and the 1880s annexation and the 1952 federation are
structurally the same event.

Measured on Ethiopia, "relink" puts 1850 cropland at 3.24 Mha — within 0.6% of
the present-day pin's 3.22 — while "level_step" puts it at 1.52 Mha. So on
that country the approved rule reproduces the framing the issue set out to
change. Globally it does not (it still moves 19.2% of 1850 tonnage), but the
tension is real and it is the owner's call. I shipped "relink" as the default
because that is the rule the issue approves, and "level_step" selectable and
documented beside it, with the numbers above.

Three smaller calls, all reversible:

  1. source, not a method_land column. CLAUDE.md's multi-method rule asks
    for method_<quantity>; the requirement that the default reproduce today's
    output exactly rules out adding a column to it. source already carries
    LUH2_cropland / LUH2_agriland, so it is this pipeline's method channel.
    Say the word and it becomes a real column.
  2. The measurability filters. "Not a residual, not out of span" is the
    conservative reading. The alternative — give a bucket its residual's land and
    let its back-cast follow world growth — is defensible and would restore the
    85 buckets, at the cost of saying something false about their borders.
  3. Renormalised shares. Chosen to match build_cell_polity_fraction() and
    because the coastal double-discount is measurable. The centroid,
    winner-take-all alternative is what the deployed pin uses and would reproduce
    its levels more closely at the cost of dropping coastal cells entirely.

What this makes load-bearing upstream. Polygon quality now moves numbers
where it was cosmetic. Concretely observed here: present-day polity polygons
overlap on 17,495 of 68,704 cells, up to 3 deep, so any sum across buckets
double-counts by ~17% (individual buckets are unaffected, and the chain-link
makes a constant overlap cancel in the ratio — a changing one does not).
Five polities reachable from a reporting area have no polygon at all
BLZ-1800-1886, CAN-1800-1866, CAN-1866-1870, CHL-1810-1884,
PRY-1811-1870 — so Canada's series starts in 1870, Chile's in 1884, Paraguay's
in 1870 and Belize's in 1886, and nothing earlier is back-cast for them. That is
whep-polities#155, #123 and #100 becoming real.

Refs #761. Part of the polity migration epic #458.

🤖 Generated with Claude Code

lbm364dl and others added 5 commits August 12, 2026 13:08
…orders

The pre-1962 back-cast estimates production as `tonnes = ha * t_ha`. The yield
half is already historical -- `.fill_yields()` back-casts `t_ha` against the
`international-yields` pin, 1,058,295 usable pre-1962 observations over
1840-1961, and none of that is touched here. The AREA half came from the
`luh2-areas` pin, which is LUH2 land pre-aggregated to PRESENT-DAY ISO3, so a
row labelled with the 1961 entity was measured on the borders that entity has
today.

`build_historical_land_areas()` measures it on each year's own borders instead:
gridded LUH2 summed inside the polygon of the polity that `area_code` resolves
to in that year, resolved UNFLOORED (`backcast_anchor = -Inf`, the switch
`.resolve_hist_trade_polities()` already uses for genuinely historical trade).
A cell's land is shared among the polygons covering it in proportion to the
covered fraction renormalised to one per cell -- the rule
`build_cell_polity_fraction()` already uses, and the one that stops a coastal
cell being water-discounted twice.

The series is NOT re-keyed. `fill_proxy_growth()` groups by `area_code`, so
keying on `polity_code` would start a new group at every period boundary and
fragment the series silently (whep#589/#632). `(year, area_code)` stays the key
and only the values change.

At a handover year the previous year is re-measured inside the INCOMING
polity's polygon before the ratio is taken, so a territorial change stays a
level step instead of compounding as growth down the whole back-cast. Since
`fill_proxy_growth()` reads nothing but year-on-year ratios, that rule is
expressed by chain-linking the series from the last year backwards, which also
makes every ratio immune to a constant overlap between two polygons.

Behind `build_primary_production(land_method = )`, default unchanged. The
chosen method is recorded in `source` -- `LUH2_polity_cropland` /
`LUH2_polity_agriland` -- which is the column this series' provenance already
lives in; a parallel `method_land` column would say the same thing while
changing the schema of the default path.

`.territorial_join_baseline()` gains one row and the ratchet goes 59 -> 60, for
the `polity_code` join that makes the land half year-aware at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chain-link accumulated from the front (`cumsum(x)[n] - cumsum(x)`), so a
single NA anywhere in a bucket's series made the total NA and wiped out all
112 years, not just the part the break cuts off. Four polities reachable from a
reporting area carry no polygon -- BLZ-1800-1886, CAN-1800-1866, CHL-1810-1884,
PRY-1811-1870 and CAN-1866-1870 -- so this is reachable, not hypothetical.

Accumulating from the END instead breaks only the years before the hole, which
is what the warning already claimed happened. Guarded by a test that fails on
the old arithmetic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`boundary_step = "none"` read as "no method"; it is a method, and the measured
difference between the two is large enough that the naming has to say so.
Renamed to `"level_step"`: it lets a territorial change through as a level step,
so the 1850 row is scaled to the smaller empire it is labelled with, which is
the reframing this file exists for. `"relink"` removes the step and uses only
within-territory growth, which is safer against a bad polygon and is the
default.

Measured over 1850-1961 against the present-day series, on back-cast crop
tonnage weighted by 1961 FAOSTAT production:

              relink            level_step
  1850   -17.2% net, 19.2% abs   -8.7% net, 31.3% abs
  1900    -5.5% net,  6.5% abs   +2.4% net, 22.9% abs
  1950    -1.0% net,  1.3% abs   -0.7% net,  1.3% abs
  1961    -0.2% net,  0.2% abs   -0.2% net,  0.2% abs

and 18.0% of 1850 back-cast tonnage sits between the two rules, 0.07% by 1960.
The toy fixture now carries real rows from that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`R CMD check` NOTEd `.polity_cell_cover: no visible binding for global variable
'ID'`. Status: OK after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.namespace_available()` was a one-line wrapper around `requireNamespace()`
that also shadowed `.sf_namespace_available()` in polities.R. Inlined, which is
what footprint_sankey.R already does for the same check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lbm364dl lbm364dl added enhancement New feature or request needs-expert Embeds a methodological/scientific decision. Do not merge on green CI alone; tag an expert. area:production Primary & livestock production build area:regions Regions, countries, polities: harmonization & modeling priority:high We want to get this done soon labels Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:production Primary & livestock production build area:regions Regions, countries, polities: harmonization & modeling enhancement New feature or request needs-expert Embeds a methodological/scientific decision. Do not merge on green CI alone; tag an expert. priority:high We want to get this done soon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant