Skip to content

feat(water): wire the water footprint extension (blue + green) (#107) - #116

Open
lbm364dl wants to merge 9 commits into
mainfrom
lbm364dl/water-extension
Open

feat(water): wire the water footprint extension (blue + green) (#107)#116
lbm364dl wants to merge 9 commits into
mainfrom
lbm364dl/water-extension

Conversation

@lbm364dl

Copy link
Copy Markdown
Collaborator

Closes #107.

Wires the water footprint extension (blue + green), the water analogue of the GHG and soil-N2O extensions. build_water_extension() returns a tidy (year, area_code, item_cbs_code, impact_u, method_water) extension in m³, fed to build_footprint() the same way as the land/GHG ones.

Design

  • build_water_extension(component = c("blue", "green"), data = list(), example = FALSE). The footprint engine traces one impact_u per run, so component (default "blue") selects the water type and is recorded in method_water ("WFN_blue" / "WFN_green").
  • blue = crop irrigation (wfb_i + wfb_cr per t) × crop production + livestock blue water (m³/head) × head counts.
  • green = crop rainfed (wfg per t) × production + grazing green water (m³/ha) × grazed grassland area (item_cbs 3000/3002).
  • Crop intensities join whep's own production (get_primary_production() "tonnes" rows) by FAO crop and country-year, so the footprint tracks whep production. Crop coverage spans 1990–2019.

Coefficient data (3 pins, all uploaded to the saco board and wired)

Pin Source
wfn-water-crop Mialyk et al. (2024), 4TU dataset (doi:10.4121/7b45bcc6…); 244,058 rows, 175 crops × 178 countries
water-livestock-blue Chapagain & Hoekstra (2003), Report 13 Tables 3.8–3.9 (drinking + service, adult, mixed system); 8 whep live-animal species
water-grazing-green Green ET of managed grassland from the WHEP LPJmL run (band "rainfed grassland", 2000–2009 mean), country-aggregated via country_grid.parquet

No coefficient values are hardcoded; all are read via whep_read_file(). data-raw/water_footprints.R documents the full reproducible derivation of each.

Grazing basis

Grazing green water charges the full managed-grassland ET, consistent with build_grassland_land_extension(grassland_metric = "occupation") — the same occupation-vs-active-grazing choice already settled for the land extension. Validation: total grassland area 3,253 Mha ≈ FAO permanent pastures (~3,300 Mha); global total ~13,700 km³/yr, within the published 8,258–12,960 km³/yr range for total grazing-land ET.

Verification

  • 11 new tests in tests/testthat/test_water_extension.R; sibling extension/footprint tests still pass (no regression from the shared utils.R/toy_examples.R edits).
  • air format clean · rcmdcheck 0 errors / 0 warnings / 0 notes · lint clean · _pkgdown.yml updated.
  • End-to-end on the real pins: blue (crop + livestock) and green (crop + grazing) both reproduce hand-computed values.

Known limitations (follow-ups, not blockers)

  • Livestock blue water covers 8 species with C&H 2003 data; buffalo/camels/etc. get no direct blue water (would need proxies).
  • Crop coefficients span 1990–2019 (no extrapolation yet).

The sibling pressures from the original #107 were split into #112 (biodiversity), #113 (nitrogen), #114 (labour).

🤖 Generated with Claude Code

@lbm364dl

Copy link
Copy Markdown
Collaborator Author

Real-data validation (year 2010)

Ran the extension on the full get_primary_production() and traced it through build_footprint() (2010 IO model).

Production-side extension totals — consistent with published global water footprints:

Component Total Split Benchmark
Blue 775 km³ crop irrigation 726 · livestock 49 M&H 2011 global crop blue ≈ 900 km³
Green 18,169 km³ grazing 13,160 · crop rainfed 5,008 M&H 2011 global crop green ≈ 5,800 km³; grazing within the 8,258–12,960 km³/yr range for total grazing-land ET

Coverage: 187 (blue) / 169 (green) countries.

Footprint trace runs cleanly (14.8M blue / 13.8M green non-zero flows). Top consumption-based countries are sensible:

  • blue: India, China, Pakistan, USA, Iran, Egypt (irrigation-heavy)
  • green: Brazil, China, USA, Colombia, India, Kazakhstan

Caveat — consumption-side conservation gap. The trace conserves only ~85% of blue and ~58% of green pressure (cons 658 vs prod 775 km³; 10,568 vs 18,169 km³). conserve_extensions = TRUE does not change this. The IO build logs imbalances (negative outputs, capped A columns, near-zero-output sectors dropped). This is generic footprint-machinery behaviour — the IO trace is extension-agnostic — but grassland-routed pressure (grazing, which dominates green) is hit hardest, because grassland reaches final demand only via the livestock chain. Worth a separate look at IO balance / the conserve_extensions path; not specific to this extension and not a blocker for wiring it.

@lbm364dl

Copy link
Copy Markdown
Collaborator Author

Rebased onto main and brought up to the current contract

Was 784 commits behind; the four original commits replay cleanly and three new
ones follow. Full suite FAIL 0 | PASS 6646, lint clean, rcmdcheck 0/0/0,
_pkgdown.yml complete.

1. Keyed on polity_area_code (the reason it could not merge)

Output now carries the four reporting-polity columns and is keyed on
polity_area_code, matching build_grassland_land_extension() and
build_livestock_ghg_extension(). As written in June it emitted legacy FAOSTAT
codes, so joining it against the sibling extensions or the IO model would have
dropped rows silently.

Coefficients are still joined on the legacy area_code that both they and
get_primary_production() carry; only the resulting cubic metres are collapsed
onto the polity. The ordering matters: the coefficients are intensities
(m³/t, m³/head, m³/ha), which cannot be averaged across legacy areas sharing a
polity without a weight — and silently picking one would be wrong. Volumes add,
so collapsing after the multiplication needs no weight. There is a test with two
legacy areas on one polity and deliberately unequal intensities: 104,000 m³,
against the 252,500 a mean intensity would have given.

2. The grazing pin derivation goes through the reader

data-raw now builds water-grazing-green via
read_lpjml_hydrology("cft_consump_water_g") filtered on band_name, instead
of hand-rolled ncvar_get slicing. That removes a hardcoded band index 14, a
hardcoded absolute run path (which CLAUDE.md forbids), and by-hand annual
time-index arithmetic. Verified as an equivalence, not by eye: against the same
5.9.7 run the pin was built from, it reproduces the documented totals exactly
(13,683 vs ~13,700 km³/yr; 3,253 Mha grassland). This builds on #681.

3. The pin stays 5.9.7-derived — 6.1.1 is broken (#710)

I regenerated from the 6.1.1 production run as planned. The global total drops
13,683 → 3,923 km³/yr, and that is a bug, not physics. Band 14 is rainfed
grassland, which cannot be irrigated:

5.9.7 6.1.1
green 434.9 mm 134.2 mm
blue (must be 0) 0.0 mm 382.2 mm
green + blue 435.0 516.4
transpiration 344.5 371.1
cells with green < transp 0 % 99.1 %

The total is fine and tracks the run's ET increase; only the green/blue
partition is wrong. Traced to stand->frac_g in src/soil/waterbalance.c,
which must stay 1.0 on a rainfed stand and has drifted to ~0.26. Filed as
#710 — it also affects build_water_balance(blue_green = "cft_native"), the
default method, on any 6.1.1 run. Regenerating is one command once a run with a
correct split exists.

Still open for @eduaguilera

The occupation basis question from #681 is unchanged: grazing is charged the
full managed-grassland ET, consistent with
build_grassland_land_extension(grassland_metric = "occupation"). The Schyns
et al. (2019) alternative is several times smaller and drives ~72% of the green
total. Proceeding on the occupation basis for consistency with land; your call
to confirm.

Not yet done

End-to-end re-verification against June's real-data totals (blue 775, green
18,169 km³/yr for 2010). World totals should hold and per-country values shift
with the re-keying; that run needs the full get_primary_production() (~26 min)
and I will post the comparison separately.

🤖 Generated with Claude Code

lbm364dl and others added 7 commits August 10, 2026 13:49
Add build_water_extension(), the water analogue of the GHG and soil-N2O
extensions, returning (year, area_code, item_cbs_code, impact_u,
method_water) in cubic metres for build_footprint().

The footprint engine traces one impact column per run, so a
component = c("blue", "green") selector (default "blue") chooses the water
type:
- blue  = crop irrigation (wfb_i + wfb_cr per tonne) x whep crop
  production + livestock blue water (per head) x head counts.
- green = crop rainfed water (wfg per tonne) x production + grazing green
  water (per hectare) x grazed grassland area (LUH2 pasture/rangeland).

Crop intensities are joined to whep's own production (the "tonnes" rows of
get_primary_production) by FAO crop and country-year, so the footprint
tracks whep production rather than the source's.

Coefficients are not hardcoded; they are read at runtime via
whep_read_file() from three pins (wfn-water-crop, water-livestock-blue,
water-grazing-green), registered in whep_inputs.csv. The crop pin holds the
Mialyk et al. (2024) 4TU dataset (the file FABIO links); livestock blue
water follows Mekonnen & Hoekstra (2012); grazing green water follows
FABIO's pasture-water step. data-raw/water_footprints.R documents the
download and upload flow. The three pin rows are placeholders
(PENDING_UPLOAD) until someone with saco board access registers them; the
function is exercised meanwhile via the example fixture and injected data.

Adds the example fixture, method_water to globalVariables, the _pkgdown
entry, docs and tests/testthat/test_water_extension.R.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Upload the processed Mialyk et al. (2024) crop water-footprint table
(244,058 rows, 175 crops x 178 countries, 1990-2019) to the saco board and
wire its version into whep_inputs.csv. Verified end to end:
whep_read_file("wfn-water-crop") reads it and build_water_extension(component
= "blue") reproduces 1000 t maize x (wfb_i + wfb_cr) for Afghanistan 2000.

water-livestock-blue and water-grazing-green remain PENDING_UPLOAD: their
coefficient tables still need sourcing (Mekonnen & Hoekstra 2012 per-head
blue water; FABIO's pasture-water grazing values).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Derive per-head livestock blue water (drinking + service) from Chapagain &
Hoekstra (2003) Report 13 Tables 3.8-3.9 (adult age group, mixed = mean of
industrial and grazing systems, l/day x 365 / 1000), mapped to whep
live-animal item_cbs codes (cattle dairy/non-dairy, pigs, sheep, goats,
broiler/layer chickens, horses). Upload to the saco board and wire its
version. Verified: the full blue component runs on the real crop + livestock
pins (100 cattle x 13.87 = 1387 m3).

Correct the coefficient citations (crop = Mialyk et al. 2024; livestock =
Chapagain & Hoekstra 2003; grazing = Schyns et al. 2019) in the docs and
data-raw script. water-grazing-green stays PENDING_UPLOAD: the Schyns m3/ha
country ET is not publicly downloadable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Derive grazing green water (m3/ha by country) from the WHEP LPJmL run
(LPJmL 5.9.7, daily grazing): cft_consump_water_g band "rainfed grassland"
(mm/yr), 2000-2009 mean, x10 for m3/ha, grassland-area-weighted to FAO
countries via country_grid.parquet. Upload to the saco board and wire its
version; all three water pins are now live.

This charges the full managed-grassland green ET (occupation basis,
consistent with build_grassland_land_extension(grassland_metric =
"occupation")). Validation: total grassland area 3,253 Mha matches FAO
permanent pastures (~3,300 Mha); global total ~13,700 km3/yr sits in the
published 8,258-12,960 km3/yr range for total grazing-land ET. This is ~6x
Schyns' "green WF of grazing" (2,191 km3/yr), which restricts to the grazed
sub-area at the necessary livestock density -- the same occupation-vs-active-
grazing choice already settled for the land extension.

Verified: the green component runs on the real crop + grazing pins
(5000 ha x 4220.5 = 21,102,500 m3 for Australia grassland).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrites data-raw section 3 to build water-grazing-green with
read_lpjml_hydrology("cft_consump_water_g") filtered on band_name,
instead of hand-rolled ncvar_get slicing. Removes three fragilities:
a hardcoded band index 14, a hardcoded absolute run path (which
CLAUDE.md forbids), and by-hand annual time-index arithmetic.

Adds "cftfrac" to the reader map (var CFTfrac, annual, 32 bands), the
per-CFT area share the weighting needs.

Verified equivalent, not just plausible: run against the same 5.9.7 run
the pin was built from, the rewrite reproduces its documented global
total (13,683 vs ~13,700 km3/yr) and grassland area (3,253 Mha) exactly.

The pin VALUES are unchanged by this commit and remain 5.9.7-derived.
Regenerating from the 6.1.1 production run moves the global total to
3,923 km3/yr, a 3.5x drop that needs its own decision -- see the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerating water-grazing-green from the 6.1.1 production run drops the
global total 13,683 -> 3,923 km3/yr. That is not physics: 6.1.1's
blue/green split is broken. Band 14 is RAINFED grassland, which cannot
receive irrigation, yet 6.1.1 reports 382 mm/yr blue and 134 green on it,
against 0 and 435 in 5.9.7 -- and green falls below the stand's own
transpiration in 99.1% of cells, which green consumptive water cannot do.
The total (green+blue) is sensible; only the partition is wrong.

Filed as #710, with the mechanism traced to stand->frac_g in
src/soil/waterbalance.c, which must stay 1.0 on a rainfed stand and has
drifted to ~0.26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extension predates WHEP's polity vocabulary and still emitted rows
keyed on the legacy FAOSTAT area_code, so it could not be joined against
the land and GHG extensions or the IO model without silently dropping
rows.

Output is now keyed on polity_area_code and carries the four reporting
polity columns, matching build_grassland_land_extension() and
build_livestock_ghg_extension().

Coefficients are still joined on the legacy area_code that both they and
get_primary_production() carry; only the resulting cubic metres are
collapsed onto the polity. That ordering is deliberate: the coefficients
are intensities (m3/t, m3/head, m3/ha), which cannot be averaged across
the legacy areas sharing a polity without a weight, and picking one
silently would be wrong. Water volumes add, so collapsing after the
multiplication needs no weight at all. Covered by a test with two legacy
areas on one polity and deliberately unequal intensities: 104,000 m3,
against the 252,500 a mean intensity would have produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerates water-grazing-green from the 6.1.1 production run, per
country AND year (1901-2023, 21,365 rows, 175 countries), replacing one
2000-2009 climatological mean applied to every year since 1850.

Two changes, both of which the old pin got wrong:

1. ANNUAL. The underlying cube is annual, so freezing it at one decade
   threw information away for no reason -- and made the coefficient join
   year-free, which is exactly what whep#669's audit exists to surface.
   The join now keys on (year, area_code), so `.grazing_green_water`
   leaves the territorial-join baseline instead of being added to it:
   the enumerated list shrinks rather than growing, which is what that
   gate asks for.

2. CORRECTED GREEN = green + blue. LPJmL 6.x books infiltrating rain as
   blue (whep#710, fixed in lbm364dl/LPJmL#3). Band "rainfed grassland"
   cannot be irrigated, so its blue is exactly that misassignment.
   Taking green+blue from the existing run is sound because the fix is a
   PURE REPARTITION: rebuilding the parent commit and running both
   binaries on one config moves total consumptive water by +0.0011% and
   gives pre(green+blue) == post(green) cell by cell. No 12-hour rerun
   was needed.

Global total on the new basis: 15,735 km3/yr, against 13,683 for the
5.9.7 pin -- +15%, alongside the +13.5% rise in global ET the run's
README documents. Grassland area unchanged at 3,253 Mha.

Cross-checked against the decade computation: 172 of 175 countries agree
to 0.01%. The three that do not have grassland in only 1, 4 and 10 of
those years, and the old decade mean diluted their intensity by
averaging zero-grassland years into the millimetres -- understating them
by 10x and 2.5x. The annual values do not.

Note for review: at 15,735 km3/yr the occupation-basis total sits ABOVE
the published 8,258-12,960 km3/yr range for total grazing-land ET. The
5.9.7 value already did. That is the occupation-basis choice, not a
defect, and is the open question on #681/#116.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lbm364dl
lbm364dl force-pushed the lbm364dl/water-extension branch from d0af070 to 00a50e0 Compare August 10, 2026 14:33
@lbm364dl

Copy link
Copy Markdown
Collaborator Author

All 10 checks green — and the last blocker is cleared

The grazing pin is now annual, and its 6.1.1 split is corrected

Regenerated from the 6.1.1 production run: 1901–2023, 21,365 rows, 175
countries
, version 20260810T142528Z-b4612, replacing one 2000–2009 mean that
was being applied to every year since 1850.

Corrected green is green + blue, because band 14 is rainfed grassland and
cannot be irrigated — its blue is the #710 misassignment. That substitution is
valid because the fix (lbm364dl/LPJmL#3, merged) is a pure repartition:
rebuilding the parent commit and running both binaries on one config moves total
consumptive water by +0.0011%, with pre(green+blue) == post(green) cell by
cell. No 12-hour rerun was needed.

basis global grazing green water
6.1.1 as-is (the bug) 3,923 km³/yr
6.1.1 corrected, annual 15,735 km³/yr
5.9.7 (previous pin) 13,683 km³/yr

+15%, against the +13.5% rise in global ET that run's README documents. Two
independent numbers agreeing is what made the existing run trustworthy here.

The join_audit gate is cleared by removing an entry

Going annual made the coefficient join key on (year, area_code), so
.grazing_green_water leaves the territorial-join baseline. The cap stays at
58 and the "never raise it for a new year-blind read" rule is intact — the list
shrank rather than grew, which is what #669 asks for.

Cross-check worth reading

172 of 175 countries match the decade computation to 0.01%. The three that don't
have grassland in only 1, 4 and 10 of those years, and the old decade mean
diluted their intensity by averaging zero-grassland years into the millimetres —
understating them by 10× and 2.5×. The annual values don't.

Full state

Rebased onto current main, 7 commits, FAIL 0 | PASS 6916, lint clean,
rcmdcheck 0/0/0, all 10 CI checks green.


Not merging — one decision is yours, @eduaguilera

At 15,735 km³/yr the occupation-basis total sits above the published
8,258–12,960 km³/yr range for total grazing-land ET. The 5.9.7 value (13,683)
already exceeded it, and this PR's original text claiming it was "within" that
range was not accurate — corrected here.

The question is unchanged from #681: grazing is charged the full
managed-grassland ET, consistent with
build_grassland_land_extension(grassland_metric = "occupation"). Schyns et al.
(2019) instead restrict to the grazed area at the necessary livestock density and
get several times less. That choice drives ~72% of the green footprint.

It is a methodology call, not an engineering one, so this sits ready rather than
merged.

Remaining follow-ups (neither blocking)

  • A global rerun with the LPJmL fix compiled in, to confirm whole-run effects and
    pick up the other bands. Off the critical path now.
  • End-to-end footprint re-verification against June's totals (blue 775, green
    18,169 km³/yr for 2010). World totals should hold; per-country values shift
    with the polity re-keying, and green rises with the corrected pin.

The branch added an exported function, a regenerated pin and a reader
variable without a NEWS entry, against the repo's convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lbm364dl

Copy link
Copy Markdown
Collaborator Author

Real-data verification, and a cross-check against an independent model

Two things finished since the last comment: the end-to-end run on real data, and
a test of whether LPJmL could supply the crop water instead of Mialyk.

1. End-to-end, year 2010, against the June baseline

June now change
blue 775 779.8 km³/yr +0.6%
green 18,169 20,927.6 km³/yr +15.2%
— crop rainfed 5,008 5,008.9 +0.02%
— grazing 13,160 15,918.7 +21%
countries (blue / green) 187 / 169 195 / 175

The crop line is the one that matters. Crop water never touches the grazing
pin, so it isolates the polity re-keying: the global total is preserved to 0.02%
while the country partition changed underneath. That is the signature of a
correct re-keying, and it is the failure CI cannot see.

Blue is effectively unchanged, as it should be — it does not read the grazing
pin. Top consumers are the same set as June (India, China, USA, Pakistan, Egypt,
Iran).

Green rises entirely through grazing, and the +21% decomposes:

Independent cross-check: the extension's grazing total (15,918.7 km³) uses
FAOSTAT/LUH2 grassland area, while the pin's own global figure (15,735 km³)
came from LPJmL CFTfrac area. Two unrelated land-area sources agreeing to
1.2%.

2. Could LPJmL supply the crop water too? Tested — no, but it earns its keep

WHEP's LPJmL run also simulates per-crop water, so we asked whether the crop
coefficients could come in-house instead of from Mialyk et al., which would give
one coherent water cycle across the whole extension. The crosswalk to do it
already exists (inst/extdata/cft_mapping.csv, used forward by
.aggregate_to_cft() to build LPJmL's own input).

Applying LPJmL intensities to WHEP crop areas, 2010:

LPJmL Mialyk ratio
all crops 5,960 km³ 4,961 km³ 1.20
the 12 crops LPJmL models 3,259 km³ 3,597 km³ 0.91
generic others band (103 crops) 2,701 km³ 1,364 km³ 1.98

Decision: keep Mialyk. Two reasons.

  • The generic band is badly wrong. others carries 103 crops and 27% of crop
    water, and comes out at ~2×: coffee 1.84, cotton 1.75, oil palm 1.71, rubber
    1.61, cocoa 1.60, vegetables 3.14. LPJmL grows one generic annual crop
    there, which does not represent perennials or short-season vegetables.
  • Global agreement hides local disagreement. Even for the 12 modelled crops,
    only 39.7% of country-crop pairs land within ±25% of Mialyk (median ratio
    1.10, outliers to 70×). The totals agree because errors cancel.

But the result is worth keeping. For the crops LPJmL does model, two models
sharing no assumptions agree within 9% globally with a country-crop rank
correlation of 0.972 — per group: temperate cereals 0.96, sugarcane 1.03,
pulses 1.05, groundnut 1.00, temperate roots 1.03, rapeseed 1.10, rice 1.14.
That is independent corroboration of the Mialyk-based crop footprint we did not
have before, and the country-crops where the two diverge are a ready-made list
of things worth a human look. Better suited to the validation harness than to
the extension pipeline; happy to file it separately if wanted.

Consequence worth stating plainly: the green footprint mixes two hydrologies —
grazing (15,919 km³) from WHEP's LPJmL run, crops (5,009 km³) from Mialyk's crop
model. About three-quarters / one-quarter. Normal practice in footprint work, but
now measured rather than assumed.


Over to you, @eduaguilera — one decision

Everything verifiable is verified: all 10 checks green, full suite
FAIL 0 | PASS 6916, rcmdcheck 0/0/0, real-data totals reproduced.

The open question is methodological, not technical, which is why this is not
merged:

Grazing is charged the full managed-grassland ET, consistent with
build_grassland_land_extension(grassland_metric = "occupation"). The
alternative — Schyns et al. (2019), restricting to the grazed area at the
necessary livestock density — is several times smaller.

That choice drives ~76% of the green footprint (15,919 of 20,928 km³ in
2010). And at 15,735 km³/yr the occupation-basis global total sits above the
published 8,258–12,960 km³/yr range for total grazing-land ET. The previous
5.9.7-based value (13,683) already exceeded it — this PR's original text claiming
it was "within" that range was not accurate, and is corrected here.

The argument for the current choice is consistency: the land extension already
made the same call, so land and water charge grazing on the same basis. The
argument against is that the resulting number is outside the published range for
the quantity it claims to estimate.

Related open items, neither blocking this PR: #710 (LPJmL green/blue defect —
fixed upstream in lbm364dl/LPJmL#3, stays open until a production run exists with
the fix) and #737 (build_water_balance's default cft_native split is
wrong on 6.1.1 runs; latent, nothing downstream reads it).

@lbm364dl
lbm364dl requested a review from eduaguilera August 10, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wire the water footprint extension (blue + green)

1 participant