From 5f7d9d14d7e3fe37ba811acc63c078e7e712d2eb Mon Sep 17 00:00:00 2001 From: Jeffrey Seifried Date: Fri, 6 Nov 2026 09:35:19 -0800 Subject: [PATCH 1/7] Expand FAOSTAT crop production dataset from an allowlist to all crops Change index from commodity_name to commodity_id Change commodity_name to FAOSTAT name --- jdluc/__tests__/datasets/faostat_test.py | 127 +++++++++++++ jdluc/__tests__/taxonomy_test.py | 32 +--- jdluc/datasets/faostat.py | 220 +++++++++-------------- jdluc/datasets/fdap.py | 8 +- jdluc/datasets/usda_nass_quickstats.py | 6 +- tools/fit-fdap-thresholds.py | 17 +- validation/__tests__/crop_codes_test.py | 15 ++ validation/data/sources.lock.json | 6 +- validation/prepare.py | 118 ++++++------ 9 files changed, 309 insertions(+), 240 deletions(-) diff --git a/jdluc/__tests__/datasets/faostat_test.py b/jdluc/__tests__/datasets/faostat_test.py index df7689b..800f0e9 100644 --- a/jdluc/__tests__/datasets/faostat_test.py +++ b/jdluc/__tests__/datasets/faostat_test.py @@ -1,3 +1,5 @@ +import math + import pandas import pytest @@ -83,3 +85,128 @@ def test_get_species_to_livestock_units_per_head( ), stocks=get_faostat_frame(column_name="stocks_head", key_to_value=stocks), ) == pytest.approx(expected) + + +def get_bulk_row( + cpc_code: str, element_code: int, flag: str, item_code: str, value: str +) -> dict[str, str]: + """A row of the Production archive, for Brazil in 2020.""" + return { + "Area Code (M49)": "'076", + "Area": "Brazil", + "Item Code": item_code, + "Item Code (CPC)": cpc_code, + "Item": f"item {item_code:s}", + "Element Code": str(element_code), + "Year": "2020", + "Value": value, + "Flag": flag, + } + + +AREA = faostat.AREA_HARVESTED_ELEMENT_CODE +PRODUCTION = faostat.PRODUCTION_ELEMENT_CODE + + +@pytest.mark.parametrize( + ("rows", "expected"), + ( + pytest.param( + [ + get_bulk_row( + cpc_code="'0112", + element_code=AREA, + flag="A", + item_code="56", + value="10", + ), + get_bulk_row( + cpc_code="'0112", + element_code=PRODUCTION, + flag="A", + item_code="56", + value="3", + ), + ], + [(56, 10.0, 3000.0)], + id="a-crop-carries-area-and-production-in-kg", + ), + pytest.param( + [ + get_bulk_row( + cpc_code="'01377", + element_code=PRODUCTION, + flag="A", + item_code="216", + value="3", + ) + ], + [(216, math.nan, 3000.0)], + id="a-crop-with-no-area-reads-nan", + ), + pytest.param( + [ + get_bulk_row( + cpc_code="'0112", + element_code=AREA, + flag="A", + item_code="56", + value="10", + ) + ], + [], + id="a-crop-with-no-production-is-dropped", + ), + pytest.param( + [ + get_bulk_row( + cpc_code="'0112", + element_code=AREA, + flag="A", + item_code="56", + value="10", + ), + get_bulk_row( + cpc_code="'0112", + element_code=PRODUCTION, + flag=faostat.MISSING_FLAG, + item_code="56", + value="3", + ), + ], + [], + id="a-missing-value-counts-as-none", + ), + pytest.param( + [ + get_bulk_row( + cpc_code="'21111.01", + element_code=PRODUCTION, + flag="A", + item_code="867", + value="3", + ), + get_bulk_row( + cpc_code="F1717", + element_code=PRODUCTION, + flag="A", + item_code="1717", + value="3", + ), + ], + [], + id="livestock-and-aggregates-are-not-crops", + ), + ), +) +def test_get_crop_records( + rows: list[dict[str, str]], expected: list[tuple[int, float, float]] +) -> None: + records = faostat.get_crop_records(rows=rows) + column_names = ["commodity_id", "area_hectares", "production_kg"] + pandas.testing.assert_frame_equal( + pandas.DataFrame.from_records(columns=column_names, data=records), + pandas.DataFrame.from_records(columns=column_names, data=expected), + check_dtype=False, + ) + assert all(record["admin_id"] == "BRA" for record in records) diff --git a/jdluc/__tests__/taxonomy_test.py b/jdluc/__tests__/taxonomy_test.py index f2907ce..0026ee4 100644 --- a/jdluc/__tests__/taxonomy_test.py +++ b/jdluc/__tests__/taxonomy_test.py @@ -2,7 +2,6 @@ from jdluc import jurisdictional_direct, statistical from jdluc.attribute import Methodology -from jdluc.datasets import faostat, ifpri_mapspam from jdluc.taxonomy import METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID JURISDICTIONAL_DIRECT_NAME_TO_COMMODITY_ID = ( @@ -13,37 +12,14 @@ ] -# The statistical leg's crop of the same name already has its FAOSTAT item, chosen independently +# The two legs' entries are written separately, so each checks the other @pytest.mark.parametrize( "crop", list(jurisdictional_direct.Crop), ids=lambda crop: crop.name.lower() ) -def test_jurisdictional_direct_codes_match_statistical_items( +def test_jurisdictional_direct_codes_match_statistical_codes( crop: jurisdictional_direct.Crop, ) -> None: - statistical_crop = statistical.Crop[crop.name] - item_code = faostat.CROP_TO_ITEM_CODE[faostat.Crop[statistical_crop.value]] - assert JURISDICTIONAL_DIRECT_NAME_TO_COMMODITY_ID[crop.name] == item_code - - -@pytest.mark.parametrize( - "crop", - [crop for crop in statistical.Crop if crop.value in faostat.Crop.__members__], - ids=lambda crop: crop.name.lower(), -) -def test_statistical_codes_match_faostat_items(crop: statistical.Crop) -> None: - item_code = faostat.CROP_TO_ITEM_CODE[faostat.Crop[crop.value]] - assert STATISTICAL_NAME_TO_COMMODITY_ID[crop.name] == item_code - - -def test_both_coffees_are_faostat_coffee() -> None: - item_code = faostat.COMBINED_CROP_NAME_TO_ITEM_CODE[ - ifpri_mapspam.Crop2000.COFF.name - ] - assert ( - STATISTICAL_NAME_TO_COMMODITY_ID[statistical.Crop.ARABICA_COFFEE.name] - == item_code - ) assert ( - STATISTICAL_NAME_TO_COMMODITY_ID[statistical.Crop.ROBUSTA_COFFEE.name] - == item_code + JURISDICTIONAL_DIRECT_NAME_TO_COMMODITY_ID[crop.name] + == STATISTICAL_NAME_TO_COMMODITY_ID[statistical.Crop[crop.name].name] ) diff --git a/jdluc/datasets/faostat.py b/jdluc/datasets/faostat.py index 2f6b44b..dcb0732 100644 --- a/jdluc/datasets/faostat.py +++ b/jdluc/datasets/faostat.py @@ -22,10 +22,8 @@ Two bulk archives, read into three tables: -- `CROP_DATASET`: area harvested and production of each MapSPAM crop FAOSTAT names one-for-one, - plus coffee as one item, COFF, which MapSPAM splits into arabica and robusta. Yield is not - carried, since a MapSPAM group's yield is not its members'; area and production are additive, so - a consumer sums the items it wants and divides +- `CROP_DATASET`: area harvested and production of every crop item, keyed by item code in + `commodity_id`. Yield is not carried: area and production add across items, and yield does not - `LIVESTOCK_DATASET`: stocks (live animals) and meat production (carcass weight, with the bone) of five grazers - `LIVESTOCK_PATTERNS_DATASET`: stocks in livestock units of the same five grazers, from Livestock @@ -48,7 +46,7 @@ import pandas from jdluc import config, storage, utils -from jdluc.datasets import base, ifpri_mapspam, worldbank_jurisdictions +from jdluc.datasets import base, worldbank_jurisdictions logger = logging.getLogger(__name__) @@ -74,113 +72,9 @@ LIVESTOCK_UNITS_ELEMENT_CODE = 5118 # "Missing value; data cannot exist". Estimated and imputed figures are kept. MISSING_FLAG = "M" - - -class Crop(enum.IntEnum): - """The MapSPAM crops FAOSTAT reports one-for-one.""" - - BANA = enum.auto() - BARL = enum.auto() - BEAN = enum.auto() - CASS = enum.auto() - CHIC = enum.auto() - CNUT = enum.auto() - COCO = enum.auto() - COTT = enum.auto() - COWP = enum.auto() - GROU = enum.auto() - LENT = enum.auto() - MAIZ = enum.auto() - OILP = enum.auto() - PIGE = enum.auto() - PLNT = enum.auto() - POTA = enum.auto() - RAPE = enum.auto() - RICE = enum.auto() - SESA = enum.auto() - SORG = enum.auto() - SOYB = enum.auto() - SUGB = enum.auto() - SUGC = enum.auto() - SUNF = enum.auto() - SWPO = enum.auto() - TEAS = enum.auto() - TOBA = enum.auto() - WHEA = enum.auto() - YAMS = enum.auto() - - -# Keyed on code, since FAOSTAT spells an item's name differently across its own files. COTT is -# seed cotton (328), not ginned lint (767), and OILP is fruit bunches (254), not palm oil (257), -# both as MapSPAM: either mistake would scale a comparison by the milling yield. -CROP_TO_ITEM_CODE = { - Crop.BANA: 486, - Crop.BARL: 44, - Crop.BEAN: 176, - Crop.CASS: 125, - Crop.CHIC: 191, - Crop.CNUT: 249, - Crop.COCO: 661, - Crop.COTT: 328, - Crop.COWP: 195, - Crop.GROU: 242, - Crop.LENT: 201, - Crop.MAIZ: 56, - Crop.OILP: 254, - Crop.PIGE: 197, - Crop.PLNT: 489, - Crop.POTA: 116, - Crop.RAPE: 270, - Crop.RICE: 27, - Crop.SESA: 289, - Crop.SORG: 83, - Crop.SOYB: 236, - Crop.SUGB: 157, - Crop.SUGC: 156, - Crop.SUNF: 267, - Crop.SWPO: 122, - Crop.TEAS: 667, - Crop.TOBA: 826, - Crop.WHEA: 15, - Crop.YAMS: 137, -} - - -# One FAOSTAT item that MapSPAM splits in two, so neither half can take the figure. -SPLIT_CROP_NAMES = { - ifpri_mapspam.CANONICAL_CROP_CLS.ACOF.name, # both are 656, Coffee, green - ifpri_mapspam.CANONICAL_CROP_CLS.RCOF.name, - ifpri_mapspam.CANONICAL_CROP_CLS.PMIL.name, # both are 79, Millet - ifpri_mapspam.CANONICAL_CROP_CLS.SMIL.name, -} -COMBINED_CROP_NAME_TO_ITEM_CODE = { - ifpri_mapspam.Crop2000.COFF.name: 656, -} -# MapSPAM groups that aggregate many FAOSTAT items, by a member list that is MapSPAM's. -SPAM_GROUP_CROP_NAMES = { - ifpri_mapspam.CANONICAL_CROP_CLS.OCER.name, - ifpri_mapspam.CANONICAL_CROP_CLS.OFIB.name, - ifpri_mapspam.CANONICAL_CROP_CLS.OOIL.name, - ifpri_mapspam.CANONICAL_CROP_CLS.OPUL.name, - ifpri_mapspam.CANONICAL_CROP_CLS.ORTS.name, - ifpri_mapspam.CANONICAL_CROP_CLS.REST.name, - ifpri_mapspam.CANONICAL_CROP_CLS.TEMF.name, - ifpri_mapspam.CANONICAL_CROP_CLS.TROF.name, - ifpri_mapspam.CANONICAL_CROP_CLS.VEGE.name, -} -# Every canonical crop is mapped, split or grouped, so a MapSPAM rename cannot drop one silently. -assert {crop.name for crop in Crop} | SPLIT_CROP_NAMES | SPAM_GROUP_CROP_NAMES == { - crop.name for crop in ifpri_mapspam.CANONICAL_CROP_CLS -} -assert not {crop.name for crop in Crop} & (SPLIT_CROP_NAMES | SPAM_GROUP_CROP_NAMES) -assert set(CROP_TO_ITEM_CODE) == set(Crop) -assert all( - ifpri_mapspam.GROUP_TO_CONSTITUENT_NAMES[combined_crop_name] <= SPLIT_CROP_NAMES - for combined_crop_name in COMBINED_CROP_NAME_TO_ITEM_CODE -) -assert len( - set(CROP_TO_ITEM_CODE.values()) | set(COMBINED_CROP_NAME_TO_ITEM_CODE.values()) -) == len(CROP_TO_ITEM_CODE) + len(COMBINED_CROP_NAME_TO_ITEM_CODE) +# FAOSTAT's crop items are exactly CPC division 01, quoted in the bulk (`'0112` is maize). It +# includes co-products such as cotton seed (329). +CROP_CPC_PREFIX = "'01" class Species(enum.StrEnum): @@ -230,26 +124,6 @@ class ElementColumn: scale: float -CROP_ELEMENT_COLUMNS = { - AREA_HARVESTED_ELEMENT_CODE: ElementColumn( - column_name="area_hectares", - commodity_name_to_item_code={ - crop.name: item_code for crop, item_code in CROP_TO_ITEM_CODE.items() - } - | COMBINED_CROP_NAME_TO_ITEM_CODE, - required=True, - scale=1, - ), - PRODUCTION_ELEMENT_CODE: ElementColumn( - column_name="production_kg", - commodity_name_to_item_code={ - crop.name: item_code for crop, item_code in CROP_TO_ITEM_CODE.items() - } - | COMBINED_CROP_NAME_TO_ITEM_CODE, - required=True, - scale=KG_PER_TONNE, - ), -} LIVESTOCK_ELEMENT_COLUMNS = { STOCKS_ELEMENT_CODE: ElementColumn( column_name="stocks_head", @@ -384,6 +258,69 @@ def get_records_for_path( return records +def get_crop_records( + rows: collections.abc.Iterable[dict[str, str]], +) -> list[dict[str, str | float]]: + """One record per (country, crop item, year), from the Production archive's rows. + + Production is required. Area harvested reads NaN where FAOSTAT reports none (Brazil nuts). + """ + accumulated: dict[tuple[str, int, int], dict[int, float]] = {} + iso_3166_to_jurisdiction_name: dict[str, str] = {} + commodity_id_to_commodity_name: dict[int, str] = {} + seen = 0 + + for row in rows: + seen += 1 + element_code = int(row["Element Code"]) + if ( + row["Item Code (CPC)"].startswith(CROP_CPC_PREFIX) + and row["Flag"] != MISSING_FLAG + and row["Value"] + and element_code in (AREA_HARVESTED_ELEMENT_CODE, PRODUCTION_ELEMENT_CODE) + and (iso_3166 := get_iso_3166(m49_code=row["Area Code (M49)"])) is not None + ): + commodity_id = int(row["Item Code"]) + key = (iso_3166, commodity_id, int(row["Year"])) + if key not in accumulated: + accumulated[key] = {AREA_HARVESTED_ELEMENT_CODE: math.nan} + accumulated[key][element_code] = float(row["Value"]) + iso_3166_to_jurisdiction_name[iso_3166] = row["Area"] + commodity_id_to_commodity_name[commodity_id] = row["Item"] + logger.info(f"Read {seen:d} rows; kept {len(accumulated):d} keys") + + records: list[dict[str, str | float]] = [ + { + "admin_id": worldbank_jurisdictions.iso_3166_str(s=iso_3166), + "admin_level": worldbank_jurisdictions.AdminLevel.NATIONAL.name, + "area_hectares": element_code_to_value[AREA_HARVESTED_ELEMENT_CODE], + "commodity_id": commodity_id, + "commodity_name": commodity_id_to_commodity_name[commodity_id], + "jurisdiction_name": iso_3166_to_jurisdiction_name[iso_3166], + "production_kg": element_code_to_value[PRODUCTION_ELEMENT_CODE] + * KG_PER_TONNE, + "year": year, + } + for (iso_3166, commodity_id, year), element_code_to_value in sorted( + accumulated.items() + ) + if PRODUCTION_ELEMENT_CODE in element_code_to_value + ] + logger.info(f"After requiring production: {len(records):d} records") + return records + + +def get_crop_records_for_tile(tile_id: str) -> list[dict[str, str | float]]: + with tempfile.TemporaryDirectory() as local_dir: + path_to_zip = os.path.join(local_dir, "data.zip") + utils.save_remote_url_to_local_path( + local_path=path_to_zip, params={}, remote_url=BULK_URL + ) + return get_crop_records( + rows=iter_rows(member_name=BULK_MEMBER_NAME, path_to_zip=path_to_zip) + ) + + def _get_records_for_tile( tile_id: str, bulk_url: str, @@ -409,20 +346,23 @@ def _get_records_for_tile( "commodity_name", "year", ] +CROP_IDX_COLUMN_NAMES = [ + "admin_level", + "admin_id", + "jurisdiction_name", + "commodity_id", + "year", +] CROP_DATASET = base.TabularDataset( - get_records_for_tile_id=functools.partial( - _get_records_for_tile, - bulk_url=BULK_URL, - element_columns=CROP_ELEMENT_COLUMNS, - member_name=BULK_MEMBER_NAME, - ), - idx_column_names=IDX_COLUMN_NAMES, + get_records_for_tile_id=get_crop_records_for_tile, + idx_column_names=CROP_IDX_COLUMN_NAMES, product_name="production-crops", source_name="faostat", # v1: crop_name -> commodity_name # v2: coffee as COFF - version="v2", + # v3: every crop item, keyed by commodity_id + version="v3", ) LIVESTOCK_DATASET = base.TabularDataset( get_records_for_tile_id=functools.partial( diff --git a/jdluc/datasets/fdap.py b/jdluc/datasets/fdap.py index ae9d68e..fbe1593 100644 --- a/jdluc/datasets/fdap.py +++ b/jdluc/datasets/fdap.py @@ -460,8 +460,8 @@ def save_tile_id_to_local_path(local_path: str, tile_id: str) -> None: ("WSM", 2024): 0.866, }, # FAOSTAT's producers of cocoa, from a local copy of the ingested crop table - # ($INGEST_ROOT/tabular/faostat/production-crops/v2/whole-world/world.parquet): - # duckdb -c "SELECT DISTINCT admin_id FROM 'world.parquet' WHERE commodity_name = 'COCO' + # ($INGEST_ROOT/tabular/faostat/production-crops/v3/whole-world/world.parquet): + # duckdb -c "SELECT DISTINCT admin_id FROM 'world.parquet' WHERE commodity_id = 661 # AND year BETWEEN 2018 AND 2024 AND area_hectares > 0 ORDER BY admin_id" producer_iso_3166s={ "AGO", @@ -663,8 +663,8 @@ def save_tile_id_to_local_path(local_path: str, tile_id: str) -> None: ("ZWE", 2024): HIGHEST_THRESHOLD, }, # FAOSTAT's producers of coffee, from a local copy of the ingested crop table - # ($INGEST_ROOT/tabular/faostat/production-crops/v2/whole-world/world.parquet): - # duckdb -c "SELECT DISTINCT admin_id FROM 'world.parquet' WHERE commodity_name = 'COFF' + # ($INGEST_ROOT/tabular/faostat/production-crops/v3/whole-world/world.parquet): + # duckdb -c "SELECT DISTINCT admin_id FROM 'world.parquet' WHERE commodity_id = 656 # AND year BETWEEN 2018 AND 2024 AND area_hectares > 0 ORDER BY admin_id" producer_iso_3166s={ "AGO", diff --git a/jdluc/datasets/usda_nass_quickstats.py b/jdluc/datasets/usda_nass_quickstats.py index 88c4ea1..be4165a 100644 --- a/jdluc/datasets/usda_nass_quickstats.py +++ b/jdluc/datasets/usda_nass_quickstats.py @@ -92,9 +92,9 @@ class CropSeries(enum.Enum): lb_per_unit=1, unit_desc="LB / ACRE", ) - # NASS reports ginned lint, where MapSPAM, FAOSTAT and WRI all carry seed cotton -- see the - # `CROP_TO_ITEM_CODE` comment in `faostat`. 0.36 is what FAOSTAT's US seed cotton - # implies against this series over 2011-2020 (mean 0.361), and agrees with the ~35% gin turnout. + # NASS reports ginned lint, where MapSPAM, FAOSTAT (328) and WRI all carry seed cotton. 0.36 is + # what FAOSTAT's US seed cotton implies against this series over 2011-2020 (mean 0.361), and + # agrees with the ~35% gin turnout. # FAOSTAT implies ~0.40 before 2011, so revisit this if the trace window moves earlier. COTTON = Series(lb_per_unit=1, reported_fraction=0.36, unit_desc="LB / ACRE") MAIZE = Series( diff --git a/tools/fit-fdap-thresholds.py b/tools/fit-fdap-thresholds.py index a483459..dc9d077 100644 --- a/tools/fit-fdap-thresholds.py +++ b/tools/fit-fdap-thresholds.py @@ -34,7 +34,7 @@ import xarray from jdluc import emit, storage -from jdluc.datasets import faostat, fdap, ifpri_mapspam, worldbank_jurisdictions +from jdluc.datasets import faostat, fdap, worldbank_jurisdictions logger = logging.getLogger(__name__) @@ -45,9 +45,10 @@ fdap.Commodity.COCOA: fdap.COCOA_PRECISION_RECALL_CROSSOVER, fdap.Commodity.COFFEE: fdap.COFFEE_PRECISION_RECALL_CROSSOVER, } -COMMODITY_TO_FAOSTAT_NAME = { - fdap.Commodity.COCOA: faostat.Crop.COCO.name, - fdap.Commodity.COFFEE: ifpri_mapspam.Crop2000.COFF.name, +# FAOSTAT's items "Cocoa beans" and "Coffee, green" +COMMODITY_TO_FAOSTAT_ITEM_CODE = { + fdap.Commodity.COCOA: 661, + fdap.Commodity.COFFEE: 656, } # Ad Astra fitted the 2024 band to FAOSTAT 2023, before FAOSTAT published 2024, and so does this YEAR_TO_FAOSTAT_YEAR = {2018: 2018, 2020: 2020, 2022: 2022, 2024: 2023} @@ -195,9 +196,11 @@ def main() -> int: logger.info(f"Read {n:d} of {len(tasks):d} source files") crops = faostat.load(dataset=faostat.CROP_DATASET).reset_index() - area_harvested = crops[ - crops["commodity_name"] == COMMODITY_TO_FAOSTAT_NAME[commodity] - ].set_index(["admin_id", "year"])["area_hectares"] + area_harvested = ( + crops[crops["commodity_id"] == COMMODITY_TO_FAOSTAT_ITEM_CODE[commodity]] + .set_index(["admin_id", "year"])["area_hectares"] + .dropna() + ) thresholds = numpy.arange(BINS) / BINS in_range = (thresholds >= LOWEST_THRESHOLD) & (thresholds <= fdap.HIGHEST_THRESHOLD) fits: list[dict[str, float | int | str]] = [] diff --git a/validation/__tests__/crop_codes_test.py b/validation/__tests__/crop_codes_test.py index 909c6df..e50bdf8 100644 --- a/validation/__tests__/crop_codes_test.py +++ b/validation/__tests__/crop_codes_test.py @@ -63,6 +63,21 @@ def test_a_split_crop_is_compared_under_its_combined_code( ) +# Written separately from `taxonomy`, so each checks the other +@pytest.mark.parametrize( + "crop", + [ + crop + for crop in statistical.Crop + if crop.value in prepare.FAOSTAT_ITEM_CODE_TO_CROP_CODE.values() + ], + ids=lambda crop: crop.name.lower(), +) +def test_faostat_items_match_taxonomy_codes(crop: statistical.Crop) -> None: + commodity_id = STATISTICAL_NAME_TO_COMMODITY_ID[crop.name] + assert prepare.FAOSTAT_ITEM_CODE_TO_CROP_CODE[commodity_id] == crop.value + + def test_with_crop_codes_keeps_only_crop_rows() -> None: frame = pandas.DataFrame( { diff --git a/validation/data/sources.lock.json b/validation/data/sources.lock.json index 88b8319..c31ed7b 100644 --- a/validation/data/sources.lock.json +++ b/validation/data/sources.lock.json @@ -1,10 +1,10 @@ { "faostat": { "faostat.parquet": { - "bytes": 1416755, - "origin": "tabular/faostat/production-crops/v2/whole-world/world.parquet", + "bytes": 3876048, + "origin": "tabular/faostat/production-crops/v3/whole-world/world.parquet", "path": "faostat.parquet", - "sha256": "22d11e597a4b3d611cedc88f75834bb546d6d97dd7b0793c111cc3de01bdae67" + "sha256": "0af0d17818df3c0edec21fb251f4d78dc2195947ed64b91da475660d1ca1a20e" } }, "orbae": { diff --git a/validation/prepare.py b/validation/prepare.py index 6448de0..629bf27 100644 --- a/validation/prepare.py +++ b/validation/prepare.py @@ -804,6 +804,61 @@ def read_efs() -> pandas.DataFrame | None: statistical.Crop.ROBUSTA_COFFEE.value: ifpri_mapspam.Crop2000.COFF.name, statistical.Crop.SMALL_MILLET.value: ifpri_mapspam.Crop2000.MILL.name, } +# The MapSPAM crops FAOSTAT names one-for-one, cocoa, tea and tobacco among them. Coffee and millet +# are left out, since WRI's yields call arabica COFF and small millet MILL. +FAOSTAT_ITEM_CODE_TO_CROP_CODE = { + 15: ifpri_mapspam.CANONICAL_CROP_CLS.WHEA.name, + 27: ifpri_mapspam.CANONICAL_CROP_CLS.RICE.name, + 44: ifpri_mapspam.CANONICAL_CROP_CLS.BARL.name, + 56: ifpri_mapspam.CANONICAL_CROP_CLS.MAIZ.name, + 83: ifpri_mapspam.CANONICAL_CROP_CLS.SORG.name, + 116: ifpri_mapspam.CANONICAL_CROP_CLS.POTA.name, + 122: ifpri_mapspam.CANONICAL_CROP_CLS.SWPO.name, + 125: ifpri_mapspam.CANONICAL_CROP_CLS.CASS.name, + 137: ifpri_mapspam.CANONICAL_CROP_CLS.YAMS.name, + 156: ifpri_mapspam.CANONICAL_CROP_CLS.SUGC.name, + 157: ifpri_mapspam.CANONICAL_CROP_CLS.SUGB.name, + 176: ifpri_mapspam.CANONICAL_CROP_CLS.BEAN.name, + 191: ifpri_mapspam.CANONICAL_CROP_CLS.CHIC.name, + 195: ifpri_mapspam.CANONICAL_CROP_CLS.COWP.name, + 197: ifpri_mapspam.CANONICAL_CROP_CLS.PIGE.name, + 201: ifpri_mapspam.CANONICAL_CROP_CLS.LENT.name, + 236: ifpri_mapspam.CANONICAL_CROP_CLS.SOYB.name, + 242: ifpri_mapspam.CANONICAL_CROP_CLS.GROU.name, + 249: ifpri_mapspam.CANONICAL_CROP_CLS.CNUT.name, + 254: ifpri_mapspam.CANONICAL_CROP_CLS.OILP.name, + 267: ifpri_mapspam.CANONICAL_CROP_CLS.SUNF.name, + 270: ifpri_mapspam.CANONICAL_CROP_CLS.RAPE.name, + 289: ifpri_mapspam.CANONICAL_CROP_CLS.SESA.name, + 328: ifpri_mapspam.CANONICAL_CROP_CLS.COTT.name, + 486: ifpri_mapspam.CANONICAL_CROP_CLS.BANA.name, + 489: ifpri_mapspam.CANONICAL_CROP_CLS.PLNT.name, + 661: ifpri_mapspam.CANONICAL_CROP_CLS.COCO.name, + 667: ifpri_mapspam.CANONICAL_CROP_CLS.TEAS.name, + 826: ifpri_mapspam.CANONICAL_CROP_CLS.TOBA.name, +} +# MapSPAM groups of several FAOSTAT items +SPAM_GROUP_CROP_CODES = { + ifpri_mapspam.CANONICAL_CROP_CLS.OCER.name, + ifpri_mapspam.CANONICAL_CROP_CLS.OFIB.name, + ifpri_mapspam.CANONICAL_CROP_CLS.OOIL.name, + ifpri_mapspam.CANONICAL_CROP_CLS.OPUL.name, + ifpri_mapspam.CANONICAL_CROP_CLS.ORTS.name, + ifpri_mapspam.CANONICAL_CROP_CLS.REST.name, + ifpri_mapspam.CANONICAL_CROP_CLS.TEMF.name, + ifpri_mapspam.CANONICAL_CROP_CLS.TROF.name, + ifpri_mapspam.CANONICAL_CROP_CLS.VEGE.name, +} +# Every canonical crop is mapped, split or grouped +assert set(FAOSTAT_ITEM_CODE_TO_CROP_CODE.values()) | set( + SPLIT_CROP_CODE_TO_COMBINED_CROP_CODE +) | SPAM_GROUP_CROP_CODES == {crop.name for crop in ifpri_mapspam.CANONICAL_CROP_CLS} +assert len(set(FAOSTAT_ITEM_CODE_TO_CROP_CODE.values())) == len( + FAOSTAT_ITEM_CODE_TO_CROP_CODE +) +assert not set(FAOSTAT_ITEM_CODE_TO_CROP_CODE.values()) & ( + set(SPLIT_CROP_CODE_TO_COMBINED_CROP_CODE) | SPAM_GROUP_CROP_CODES +) def with_crop_codes(frame: pandas.DataFrame) -> pandas.DataFrame: @@ -1504,49 +1559,6 @@ def get_orbae_findings(frame: pandas.DataFrame) -> list[schema.Finding]: ] -# The one EPA figure this tool uses, hardcoded rather than parsed: a transcription pipeline for a single -# number is not worth the dependency, and the number is checkable by hand in a minute. -# -# EPA GHG Inventory 1990-2022, published April 2024, Chapter 6, Table 6-40 on page 6-75, "Net CO2 Flux -# from Soil, Dead Organic Matter and Biomass Carbon Stock Changes in Land Converted to Cropland by -# Land-Use Change Category (MMT CO2 Eq.)", row "Grassland Converted to Cropland". -# -# To reproduce, from a machine with network access: -# -# uv run --with pdfplumber python -c " -# import pdfplumber, urllib.request, pathlib -# u = ('https://www.epa.gov/system/files/documents/2024-04/' -# 'us-ghg-inventory-2024-chapter-6-land-use-land-use-change-and-forestry_0.pdf') -# r = urllib.request.Request(u, headers={'User-Agent': 'Mozilla/5.0'}) -# pathlib.Path('ch6.pdf').write_bytes(urllib.request.urlopen(r).read()) -# print([l for p in pdfplumber.open('ch6.pdf').pages if 'Table 6-40:' in (p.extract_text() or '') -# for l in p.extract_text().split(chr(10)) if l.startswith('Grassland Converted')])" -# -# That PDF is sha256 a9914b673a61b3bba926253f9d68168dde2ef1487c388455fd78922a69263d5d, 5.5 MiB, 194 -# pages. It is not in `sources.lock.json` because nothing here retrieves it, so there is nothing to pin. -# -# The comparison year is 2020, where EPA reports 10.6, putting sLUC's 30.9 Mt US grassland figure at -# about 2.9x -- the same comparison `docs/further_research.md` draws. The 2022 column is the easy -# mistake to make here: at 16.3 it is the row's most recent number and it is not our year. -# -# Only one mismatch remains, and it is not the temporal basis. The two sides are both annual: the -# GHGP per-year weights integrate to exactly 1.0 over the 20-year window, so `SPAN_TO_LINEAR_DISCOUNT_WEIGHT` -# allocates a conversion's emissions to one sourcing year rather than accumulating twenty, and the -# production denominator is a weighted mean over the same spans. What does differ is scope -- EPA's row -# covers all cropland where sLUC's 30.9 Mt is the corn-soy-wheat subset -- and closing that gap can only -# raise our side, so the disagreement is a floor rather than an artifact. -EPA_RELEASE = "1990-2022, published 2024" -EPA_GRASSLAND_TO_CROPLAND_MMT_BY_YEAR = { - 1990: 27.3, - 2005: 17.2, - 2018: 13.7, - 2019: 13.0, - 2020: 10.6, - 2021: 16.1, - 2022: 16.3, -} - - def read_faostat_production() -> pandas.DataFrame: """FAOSTAT's ingested production and area, cached locally and pinned by digest. @@ -1567,11 +1579,9 @@ def read_faostat_production() -> pandas.DataFrame: frame = pandas.read_parquet(FAOSTAT_CACHE) if frame.index.names != [None]: frame = frame.reset_index() - # WRI's COFF is arabica only - frame = frame[ - ~frame["commodity_name"].isin(faostat.COMBINED_CROP_NAME_TO_ITEM_CODE) - ] - return frame.rename(columns={"admin_id": "iso_3166", "commodity_name": "crop_name"}) + frame = frame[frame["commodity_id"].isin(FAOSTAT_ITEM_CODE_TO_CROP_CODE)].copy() + frame["crop_name"] = frame["commodity_id"].map(FAOSTAT_ITEM_CODE_TO_CROP_CODE) + return frame.rename(columns={"admin_id": "iso_3166"}) def get_faostat_yields() -> pandas.DataFrame: @@ -1653,24 +1663,22 @@ def get_unpaired_crop_names( """ unpaired = set(wri_yields["crop_name"]) - set(comparison["crop_name"]) compared = set(comparison["crop_name"]) - mapped = {crop.name for crop in faostat.Crop} + mapped = set(FAOSTAT_ITEM_CODE_TO_CROP_CODE.values()) + split = set(SPLIT_CROP_CODE_TO_COMBINED_CROP_CODE) reasons: dict[schema.UnpairedReason, tuple[str, ...]] = {} for reason, names in ( ( schema.UnpairedReason.SPAM_GROUP, - unpaired & faostat.SPAM_GROUP_CROP_NAMES, + unpaired & SPAM_GROUP_CROP_CODES, ), ( schema.UnpairedReason.SPAM_SPLIT, - unpaired & faostat.SPLIT_CROP_NAMES, + unpaired & split, ), (schema.UnpairedReason.TOO_FEW_COUNTRIES, (unpaired & mapped) - compared), ( schema.UnpairedReason.UNMAPPED, - unpaired - - mapped - - faostat.SPAM_GROUP_CROP_NAMES - - faostat.SPLIT_CROP_NAMES, + unpaired - mapped - SPAM_GROUP_CROP_CODES - split, ), ): if names: From e914a6c02f424cd6f735af203436edf5ccd1a112 Mon Sep 17 00:00:00 2001 From: Jeffrey Seifried Date: Fri, 6 Nov 2026 09:58:22 -0800 Subject: [PATCH 2/7] Populate taxonomy for CROPGRIDS --- jdluc/__tests__/taxonomy_test.py | 37 ++++ jdluc/taxonomy.py | 238 +++++++++++++++++++++++- validation/__tests__/crop_codes_test.py | 6 +- 3 files changed, 276 insertions(+), 5 deletions(-) diff --git a/jdluc/__tests__/taxonomy_test.py b/jdluc/__tests__/taxonomy_test.py index 0026ee4..5ebd2d6 100644 --- a/jdluc/__tests__/taxonomy_test.py +++ b/jdluc/__tests__/taxonomy_test.py @@ -2,6 +2,7 @@ from jdluc import jurisdictional_direct, statistical from jdluc.attribute import Methodology +from jdluc.datasets import tang_cropgrids from jdluc.taxonomy import METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID JURISDICTIONAL_DIRECT_NAME_TO_COMMODITY_ID = ( @@ -10,6 +11,9 @@ STATISTICAL_NAME_TO_COMMODITY_ID = METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[ Methodology.STATISTICAL ] +PROXY_NAME_TO_COMMODITY_ID = METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[ + Methodology.JURISDICTIONAL_DIRECT_PROXY +] # The two legs' entries are written separately, so each checks the other @@ -23,3 +27,36 @@ def test_jurisdictional_direct_codes_match_statistical_codes( JURISDICTIONAL_DIRECT_NAME_TO_COMMODITY_ID[crop.name] == STATISTICAL_NAME_TO_COMMODITY_ID[statistical.Crop[crop.name].name] ) + + +@pytest.mark.parametrize( + "crop", + [crop for crop in tang_cropgrids.Crop if crop.name in statistical.Crop.__members__], + ids=lambda crop: crop.name.lower(), +) +def test_proxy_codes_match_statistical_codes(crop: tang_cropgrids.Crop) -> None: + assert ( + PROXY_NAME_TO_COMMODITY_ID[crop.name] + == STATISTICAL_NAME_TO_COMMODITY_ID[crop.name] + ) + + +def test_only_kapok_and_maize_share_a_proxy_code() -> None: + commodity_id_to_crop_names = { + commodity_id: { + crop.name + for crop in tang_cropgrids.Crop + if PROXY_NAME_TO_COMMODITY_ID[crop.name] == commodity_id + } + for commodity_id in { + PROXY_NAME_TO_COMMODITY_ID[crop.name] for crop in tang_cropgrids.Crop + } + } + assert { + commodity_id: crop_names + for commodity_id, crop_names in commodity_id_to_crop_names.items() + if len(crop_names) > 1 + } == { + 56: {tang_cropgrids.Crop.MAIZE.name, tang_cropgrids.Crop.POPCORN.name}, + 310: {tang_cropgrids.Crop.KAPOKFIBER.name, tang_cropgrids.Crop.KAPOKSEED.name}, + } diff --git a/jdluc/taxonomy.py b/jdluc/taxonomy.py index 767055f..d7b8f67 100644 --- a/jdluc/taxonomy.py +++ b/jdluc/taxonomy.py @@ -8,12 +8,17 @@ - -1 and -2: DROPPED and UNATTRIBUTED, which every leg has - -3: PASTURE, the pasture no grazer takes +- -101 to -116: the proxy leg's fodder crops, which have no production - -201 and -202: OTHER_OILCROPS and OTHER_PULSES, MapSPAM groups of several FAOSTAT items + +`CommodityId` holds every code of our own and each FAOSTAT item the repo refers to by name. The +proxy leg maps most of its crops onto bare item codes. """ import enum from jdluc import attribute, emit, jurisdictional_direct, statistical +from jdluc.datasets import tang_cropgrids @enum.unique @@ -50,11 +55,28 @@ class CommodityId(enum.IntEnum): DROPPED = -1 UNATTRIBUTED = -2 PASTURE = -3 + ALFALFA = -101 + BEETS_FOR_FODDER = -102 + CABBAGE_FOR_FODDER = -103 + CARROTS_FOR_FODDER = -104 + CLOVER = -105 + FORAGE_PRODUCTS_OTHER = -106 + GRASSES_OTHER = -107 + LEGUMES_OTHER = -108 + MAIZE_FOR_FORAGE_AND_SILAGE = -109 + MIXED_GRASSES_AND_LEGUMES = -110 + GREEN_OILSEEDS_FOR_FODDER = -111 + RYE_GRASS_FOR_FORAGE_AND_SILAGE = -112 + SORGHUM_FOR_FORAGE_AND_SILAGE = -113 + SWEDES_FOR_FODDER = -114 + TURNIPS_FOR_FODDER = -115 + VEGETABLES_AND_ROOTS_FOR_FODDER = -116 OTHER_OILCROPS = -201 OTHER_PULSES = -202 -# FAOSTAT's item names, as its bulk downloads spell them +# FAOSTAT's item names, as its bulk downloads spell them, and the fodder crops' names in the +# CROPGRIDS publisher's crop table COMMODITY_ID_TO_NAME = { CommodityId.WHEAT: "Wheat", CommodityId.RICE: "Rice", @@ -88,6 +110,22 @@ class CommodityId(enum.IntEnum): CommodityId.DROPPED: "DROPPED", CommodityId.UNATTRIBUTED: "UNATTRIBUTED", CommodityId.PASTURE: "PASTURE", + CommodityId.ALFALFA: "Alfalfa", + CommodityId.BEETS_FOR_FODDER: "Beets for fodder", + CommodityId.CABBAGE_FOR_FODDER: "Cabbage for fodder", + CommodityId.CARROTS_FOR_FODDER: "Carrots for fodder", + CommodityId.CLOVER: "Clover", + CommodityId.FORAGE_PRODUCTS_OTHER: "Forage products, other", + CommodityId.GRASSES_OTHER: "Grasses, other", + CommodityId.LEGUMES_OTHER: "Legumes, other", + CommodityId.MAIZE_FOR_FORAGE_AND_SILAGE: "Maize for forage and silage", + CommodityId.MIXED_GRASSES_AND_LEGUMES: "Mixed grasses and legumes", + CommodityId.GREEN_OILSEEDS_FOR_FODDER: "Green oilseeds for fodder", + CommodityId.RYE_GRASS_FOR_FORAGE_AND_SILAGE: "Rye grass for forage and silage", + CommodityId.SORGHUM_FOR_FORAGE_AND_SILAGE: "Sorghum for forage and silage", + CommodityId.SWEDES_FOR_FODDER: "Swedes for fodder", + CommodityId.TURNIPS_FOR_FODDER: "Turnips for fodder", + CommodityId.VEGETABLES_AND_ROOTS_FOR_FODDER: "Vegetables and roots for fodder", CommodityId.OTHER_OILCROPS: "OTHER_OILCROPS", CommodityId.OTHER_PULSES: "OTHER_PULSES", } @@ -98,7 +136,9 @@ class CommodityId(enum.IntEnum): # COTTON is seed cotton: `usda_nass_quickstats` converts NASS's ginned lint to it, as MapSPAM # carries it. MapSPAM splits coffee into arabica and robusta and millet into pearl and small, where # FAOSTAT has one item for each -METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID = { +METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID: dict[ + attribute.Methodology, dict[str, int] +] = { attribute.Methodology.JURISDICTIONAL_DIRECT: { emit.NonCommodity.DROPPED.name: CommodityId.DROPPED, emit.NonCommodity.UNATTRIBUTED.name: CommodityId.UNATTRIBUTED, @@ -152,7 +192,192 @@ class CommodityId(enum.IntEnum): statistical.Livestock.BEEF_CATTLE.name: CommodityId.CATTLE_MEAT, statistical.Livestock.PASTURE.name: CommodityId.PASTURE, }, + attribute.Methodology.JURISDICTIONAL_DIRECT_PROXY: { + emit.NonCommodity.DROPPED.name: CommodityId.DROPPED, + emit.NonCommodity.UNATTRIBUTED.name: CommodityId.UNATTRIBUTED, + statistical.Livestock.BEEF_CATTLE.name: CommodityId.CATTLE_MEAT, + statistical.Livestock.PASTURE.name: CommodityId.PASTURE, + # The publisher's crop table (`CROPS_matching_v17.xlsx` in figshare's `CODES.zip`), through + # the authors' 2020 FAOSTAT extract. Both kapok crops are 310, kapok fruit + tang_cropgrids.Crop.ABACA.name: 809, + tang_cropgrids.Crop.AGAVE.name: 800, + tang_cropgrids.Crop.ALMOND.name: 221, + tang_cropgrids.Crop.ANISEETC.name: 711, + tang_cropgrids.Crop.APPLE.name: 515, + tang_cropgrids.Crop.APRICOT.name: 526, + tang_cropgrids.Crop.ARECA.name: 226, + tang_cropgrids.Crop.ARTICHOKE.name: 366, + tang_cropgrids.Crop.ASPARAGUS.name: 367, + tang_cropgrids.Crop.AVOCADO.name: 572, + tang_cropgrids.Crop.BAMBARA.name: 203, + tang_cropgrids.Crop.BANANA.name: 486, + tang_cropgrids.Crop.BARLEY.name: 44, + tang_cropgrids.Crop.BEAN.name: 176, + tang_cropgrids.Crop.BERRYNES.name: 558, + tang_cropgrids.Crop.BLUEBERRY.name: 552, + tang_cropgrids.Crop.BRAZIL.name: 216, + tang_cropgrids.Crop.BROADBEAN.name: 181, + tang_cropgrids.Crop.BUCKWHEAT.name: 89, + tang_cropgrids.Crop.CABBAGE.name: 358, + tang_cropgrids.Crop.CANARYSEED.name: 101, + tang_cropgrids.Crop.CAROB.name: 461, + tang_cropgrids.Crop.CARROT.name: 426, + tang_cropgrids.Crop.CASHEW.name: 217, + tang_cropgrids.Crop.CASHEWAPPLE.name: 591, + tang_cropgrids.Crop.CASSAVA.name: 125, + tang_cropgrids.Crop.CASTOR.name: 265, + tang_cropgrids.Crop.CAULIFLOWER.name: 393, + tang_cropgrids.Crop.CEREALNES.name: 108, + tang_cropgrids.Crop.CHERRY.name: 531, + tang_cropgrids.Crop.CHESTNUT.name: 220, + tang_cropgrids.Crop.CHICKPEA.name: 191, + tang_cropgrids.Crop.CHICORY.name: 459, + tang_cropgrids.Crop.CHILLEETC.name: 401, + tang_cropgrids.Crop.CINNAMON.name: 693, + tang_cropgrids.Crop.CITRUSNES.name: 512, + tang_cropgrids.Crop.CLOVE.name: 698, + tang_cropgrids.Crop.COCOA.name: 661, + tang_cropgrids.Crop.COCONUT.name: 249, + tang_cropgrids.Crop.COFFEE.name: 656, + tang_cropgrids.Crop.COTTON.name: 328, + tang_cropgrids.Crop.COWPEA.name: 195, + tang_cropgrids.Crop.CRANBERRY.name: 554, + tang_cropgrids.Crop.CUCUMBERETC.name: 397, + tang_cropgrids.Crop.CURRANT.name: 550, + tang_cropgrids.Crop.DATE.name: 577, + tang_cropgrids.Crop.EGGPLANT.name: 399, + tang_cropgrids.Crop.FIBRENES.name: 821, + tang_cropgrids.Crop.FIG.name: 569, + tang_cropgrids.Crop.FONIO.name: 94, + tang_cropgrids.Crop.FRUITNES.name: 619, + tang_cropgrids.Crop.GARLIC.name: 406, + tang_cropgrids.Crop.GINGER.name: 720, + tang_cropgrids.Crop.GOOSEBERRY.name: 549, + tang_cropgrids.Crop.GRAPE.name: 560, + tang_cropgrids.Crop.GRAPEFRUITETC.name: 507, + tang_cropgrids.Crop.GREENBEAN.name: 414, + tang_cropgrids.Crop.GREENCORN.name: 446, + tang_cropgrids.Crop.GREENONION.name: 402, + tang_cropgrids.Crop.GREENPEA.name: 417, + tang_cropgrids.Crop.GROUNDNUT.name: 242, + tang_cropgrids.Crop.HAZELNUT.name: 225, + tang_cropgrids.Crop.HEMP.name: 777, + tang_cropgrids.Crop.HEMPSEED.name: 336, + tang_cropgrids.Crop.HOP.name: 677, + tang_cropgrids.Crop.JUTE.name: 780, + tang_cropgrids.Crop.KAPOKFIBER.name: 310, + tang_cropgrids.Crop.KAPOKSEED.name: 310, + tang_cropgrids.Crop.KARITE.name: 263, + tang_cropgrids.Crop.KIWI.name: 592, + tang_cropgrids.Crop.KOLANUT.name: 224, + tang_cropgrids.Crop.LEMONLIME.name: 497, + tang_cropgrids.Crop.LENTIL.name: 201, + tang_cropgrids.Crop.LETTUCE.name: 372, + tang_cropgrids.Crop.LINSEED.name: 333, + tang_cropgrids.Crop.LUPIN.name: 210, + tang_cropgrids.Crop.MAIZE.name: 56, + tang_cropgrids.Crop.MANGO.name: 571, + tang_cropgrids.Crop.MATE.name: 671, + tang_cropgrids.Crop.MELONETC.name: 568, + tang_cropgrids.Crop.MELONSEED.name: 299, + tang_cropgrids.Crop.MILLET.name: 79, + tang_cropgrids.Crop.MIXEDGRAIN.name: 103, + tang_cropgrids.Crop.MUSHROOM.name: 449, + tang_cropgrids.Crop.MUSTARD.name: 292, + tang_cropgrids.Crop.NUTMEG.name: 702, + tang_cropgrids.Crop.NUTNES.name: 234, + tang_cropgrids.Crop.OATS.name: 75, + tang_cropgrids.Crop.OILPALM.name: 254, + tang_cropgrids.Crop.OILSEEDNES.name: 339, + tang_cropgrids.Crop.OKRA.name: 430, + tang_cropgrids.Crop.OLIVE.name: 260, + tang_cropgrids.Crop.ONION.name: 403, + tang_cropgrids.Crop.ORANGE.name: 490, + tang_cropgrids.Crop.PAPAYA.name: 600, + tang_cropgrids.Crop.PEA.name: 187, + tang_cropgrids.Crop.PEACHETC.name: 534, + tang_cropgrids.Crop.PEAR.name: 521, + tang_cropgrids.Crop.PEPPER.name: 687, + tang_cropgrids.Crop.PEPPERMINT.name: 748, + tang_cropgrids.Crop.PERSIMMON.name: 587, + tang_cropgrids.Crop.PIGEONPEA.name: 197, + tang_cropgrids.Crop.PINEAPPLE.name: 574, + tang_cropgrids.Crop.PISTACHIO.name: 223, + tang_cropgrids.Crop.PLANTAIN.name: 489, + tang_cropgrids.Crop.PLUM.name: 536, + tang_cropgrids.Crop.POPPY.name: 296, + tang_cropgrids.Crop.POTATO.name: 116, + tang_cropgrids.Crop.PULSENES.name: 211, + tang_cropgrids.Crop.PUMPKINETC.name: 394, + tang_cropgrids.Crop.PYRETHRUM.name: 754, + tang_cropgrids.Crop.QUINCE.name: 523, + tang_cropgrids.Crop.QUINOA.name: 92, + tang_cropgrids.Crop.RAMIE.name: 788, + tang_cropgrids.Crop.RAPESEED.name: 270, + tang_cropgrids.Crop.RASBERRY.name: 547, + tang_cropgrids.Crop.RICE.name: 27, + tang_cropgrids.Crop.ROOTNES.name: 149, + tang_cropgrids.Crop.RUBBER.name: 836, + tang_cropgrids.Crop.RYE.name: 71, + tang_cropgrids.Crop.SAFFLOWER.name: 280, + tang_cropgrids.Crop.SESAME.name: 289, + tang_cropgrids.Crop.SISAL.name: 789, + tang_cropgrids.Crop.SORGHUM.name: 83, + tang_cropgrids.Crop.SOURCHERRY.name: 530, + tang_cropgrids.Crop.SOYBEAN.name: 236, + tang_cropgrids.Crop.SPICENES.name: 723, + tang_cropgrids.Crop.SPINACH.name: 373, + tang_cropgrids.Crop.STONEFRUITNES.name: 541, + tang_cropgrids.Crop.STRAWBERRY.name: 544, + tang_cropgrids.Crop.STRINGBEAN.name: 423, + tang_cropgrids.Crop.SUGARBEET.name: 157, + tang_cropgrids.Crop.SUGARCANE.name: 156, + tang_cropgrids.Crop.SUGARNES.name: 161, + tang_cropgrids.Crop.SUNFLOWER.name: 267, + tang_cropgrids.Crop.SWEETPOTATO.name: 122, + tang_cropgrids.Crop.TANGETC.name: 495, + tang_cropgrids.Crop.TARO.name: 136, + tang_cropgrids.Crop.TEA.name: 667, + tang_cropgrids.Crop.TOBACCO.name: 826, + tang_cropgrids.Crop.TOMATO.name: 388, + tang_cropgrids.Crop.TRITICALE.name: 97, + tang_cropgrids.Crop.TROPICALNES.name: 603, + tang_cropgrids.Crop.TUNG.name: 275, + tang_cropgrids.Crop.VANILLA.name: 692, + tang_cropgrids.Crop.VEGFOR.name: 463, + tang_cropgrids.Crop.VETCH.name: 205, + tang_cropgrids.Crop.WALNUT.name: 222, + tang_cropgrids.Crop.WATERMELON.name: 567, + tang_cropgrids.Crop.WHEAT.name: 15, + tang_cropgrids.Crop.YAM.name: 137, + tang_cropgrids.Crop.YAUTIA.name: 135, + # FAOSTAT's own items, where the publisher's table names none or a retired one + tang_cropgrids.Crop.FLAX.name: 771, # replaced 773, flax fibre and tow + tang_cropgrids.Crop.GREENBROADBEAN.name: 420, + tang_cropgrids.Crop.JUTELIKEFIBER.name: 782, # formerly jute-like fibres + # Judgement: FAOSTAT's dry chillies and peppers names Pimenta, and popcorn is maize grain + tang_cropgrids.Crop.PIMENTO.name: 689, + tang_cropgrids.Crop.POPCORN.name: 56, + # Fodder, which FAOSTAT has no item for + tang_cropgrids.Crop.ALFALFA.name: CommodityId.ALFALFA, + tang_cropgrids.Crop.BEETFOR.name: CommodityId.BEETS_FOR_FODDER, + tang_cropgrids.Crop.CABBAGEFOR.name: CommodityId.CABBAGE_FOR_FODDER, + tang_cropgrids.Crop.CARROTFOR.name: CommodityId.CARROTS_FOR_FODDER, + tang_cropgrids.Crop.CLOVER.name: CommodityId.CLOVER, + tang_cropgrids.Crop.FORNES.name: CommodityId.FORAGE_PRODUCTS_OTHER, + tang_cropgrids.Crop.GRASSNES.name: CommodityId.GRASSES_OTHER, + tang_cropgrids.Crop.LEGUMENES.name: CommodityId.LEGUMES_OTHER, + tang_cropgrids.Crop.MAIZEFOR.name: CommodityId.MAIZE_FOR_FORAGE_AND_SILAGE, + tang_cropgrids.Crop.MIXEDGRASS.name: CommodityId.MIXED_GRASSES_AND_LEGUMES, + tang_cropgrids.Crop.OILSEEDFOR.name: CommodityId.GREEN_OILSEEDS_FOR_FODDER, + tang_cropgrids.Crop.RYEFOR.name: CommodityId.RYE_GRASS_FOR_FORAGE_AND_SILAGE, + tang_cropgrids.Crop.SORGHUMFOR.name: CommodityId.SORGHUM_FOR_FORAGE_AND_SILAGE, + tang_cropgrids.Crop.SWEDEFOR.name: CommodityId.SWEDES_FOR_FODDER, + tang_cropgrids.Crop.TURNIPFOR.name: CommodityId.TURNIPS_FOR_FODDER, + tang_cropgrids.Crop.VEGETABLENES.name: CommodityId.VEGETABLES_AND_ROOTS_FOR_FODDER, + }, } +assert set(METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID) == set(attribute.Methodology) assert all( set(commodity_name_to_commodity_id) == { @@ -178,6 +403,15 @@ class CommodityId(enum.IntEnum): *(livestock.name for livestock in statistical.Livestock), *(non_commodity.name for non_commodity in emit.NonCommodity), } +assert set( + METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[ + attribute.Methodology.JURISDICTIONAL_DIRECT_PROXY + ] +) == { + *(crop.name for crop in tang_cropgrids.Crop), + *(livestock.name for livestock in statistical.Livestock), + *(non_commodity.name for non_commodity in emit.NonCommodity), +} assert len( set( METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[ diff --git a/validation/__tests__/crop_codes_test.py b/validation/__tests__/crop_codes_test.py index e50bdf8..61ea675 100644 --- a/validation/__tests__/crop_codes_test.py +++ b/validation/__tests__/crop_codes_test.py @@ -24,7 +24,7 @@ ids=lambda crop: crop.name.lower(), ) def test_a_crop_with_its_own_item_keeps_its_own_code(crop: statistical.Crop) -> None: - commodity_id = STATISTICAL_NAME_TO_COMMODITY_ID[crop.name] + commodity_id = CommodityId(STATISTICAL_NAME_TO_COMMODITY_ID[crop.name]) assert prepare.COMMODITY_ID_TO_CROP_CODE[commodity_id] == crop.value @@ -56,7 +56,7 @@ def test_a_crop_with_its_own_item_keeps_its_own_code(crop: statistical.Crop) -> def test_a_split_crop_is_compared_under_its_combined_code( crop: statistical.Crop, combined_crop_code: str ) -> None: - commodity_id = STATISTICAL_NAME_TO_COMMODITY_ID[crop.name] + commodity_id = CommodityId(STATISTICAL_NAME_TO_COMMODITY_ID[crop.name]) assert prepare.COMMODITY_ID_TO_CROP_CODE[commodity_id] == combined_crop_code assert ( prepare.SPLIT_CROP_CODE_TO_COMBINED_CROP_CODE[crop.value] == combined_crop_code @@ -74,7 +74,7 @@ def test_a_split_crop_is_compared_under_its_combined_code( ids=lambda crop: crop.name.lower(), ) def test_faostat_items_match_taxonomy_codes(crop: statistical.Crop) -> None: - commodity_id = STATISTICAL_NAME_TO_COMMODITY_ID[crop.name] + commodity_id = CommodityId(STATISTICAL_NAME_TO_COMMODITY_ID[crop.name]) assert prepare.FAOSTAT_ITEM_CODE_TO_CROP_CODE[commodity_id] == crop.value From 03e41e09414839147121fff49937e41a0671a51d Mon Sep 17 00:00:00 2001 From: Jeffrey Seifried Date: Fri, 6 Nov 2026 10:04:26 -0800 Subject: [PATCH 3/7] Use FAOSTAT names instead of hardcoded ones But leave our invented mappings --- jdluc/__tests__/trace_test.py | 45 ++-- jdluc/taxonomy.py | 264 ++++++++++-------------- jdluc/trace.py | 47 +++-- tools/export-graphs.py | 56 ++--- validation/__tests__/crop_codes_test.py | 25 ++- validation/prepare.py | 62 +++--- 6 files changed, 244 insertions(+), 255 deletions(-) diff --git a/jdluc/__tests__/trace_test.py b/jdluc/__tests__/trace_test.py index b30d47e..ddd7989 100644 --- a/jdluc/__tests__/trace_test.py +++ b/jdluc/__tests__/trace_test.py @@ -5,11 +5,12 @@ from jdluc.attribute import Methodology from jdluc.datasets.worldbank_jurisdictions import AdminLevel from jdluc.statistical import HERD_HEADS_COLUMNS -from jdluc.taxonomy import CommodityId +from jdluc.taxonomy import InventedCommodityId from jdluc.trace import ( attach_ratios, derive_jurisdictional_production_kg, derive_statistical_production_kg, + get_commodity_id_to_name, iter_national_from_provincials, with_commodity_ids, ) @@ -138,7 +139,7 @@ def test_iter_national_from_provincials_single_province() -> None: [ { "commodity_hectares": 1, - "commodity_id": CommodityId.SOYA_BEANS.value, + "commodity_id": 236, "emissions_mt": 1, "forest_emissions_mt": 1, "grassland_emissions_mt": 1, @@ -159,8 +160,7 @@ def test_iter_national_from_provincials_single_province() -> None: "admin_id": "ISO_A3", "admin_level": AdminLevel.NATIONAL.name, "commodity_hectares": 1, - "commodity_id": CommodityId.SOYA_BEANS.value, - "commodity_name": "Soya beans", + "commodity_id": 236, "emissions_factor_kgco2e_per_kg": 1000, "emissions_mt": 1, "forest_emissions_mt": 1, @@ -181,7 +181,7 @@ def test_iter_national_from_provincials_multi_province_crop() -> None: { "admin_id": "ADM_0", "commodity_hectares": 1, - "commodity_id": CommodityId.MAIZE.value, + "commodity_id": 56, "emissions_mt": 1, "forest_emissions_mt": 1, "grassland_emissions_mt": 1, @@ -193,7 +193,7 @@ def test_iter_national_from_provincials_multi_province_crop() -> None: { "admin_id": "ADM_0", "commodity_hectares": 1, - "commodity_id": CommodityId.SOYA_BEANS.value, + "commodity_id": 236, "emissions_mt": 1, "forest_emissions_mt": 1, "grassland_emissions_mt": 1, @@ -205,7 +205,7 @@ def test_iter_national_from_provincials_multi_province_crop() -> None: { "admin_id": "ADM_1", "commodity_hectares": 1, - "commodity_id": CommodityId.MAIZE.value, + "commodity_id": 56, "emissions_mt": 1, "forest_emissions_mt": 1, "grassland_emissions_mt": 1, @@ -226,8 +226,7 @@ def test_iter_national_from_provincials_multi_province_crop() -> None: "admin_id": "ISO_A3", "admin_level": AdminLevel.NATIONAL.name, "commodity_hectares": 2, - "commodity_id": CommodityId.MAIZE.value, - "commodity_name": "Maize (corn)", + "commodity_id": 56, "emissions_factor_kgco2e_per_kg": 1000, "emissions_mt": 2, "forest_emissions_mt": 2, @@ -244,8 +243,7 @@ def test_iter_national_from_provincials_multi_province_crop() -> None: "admin_id": "ISO_A3", "admin_level": AdminLevel.NATIONAL.name, "commodity_hectares": 1, - "commodity_id": CommodityId.SOYA_BEANS.value, - "commodity_name": "Soya beans", + "commodity_id": 236, "emissions_factor_kgco2e_per_kg": 1000, "emissions_mt": 1, "forest_emissions_mt": 1, @@ -485,15 +483,13 @@ def test_with_commodity_ids_adds_together_the_names_sharing_an_item() -> None: ).set_index(["admin_level", "admin_id", "commodity_name", "methodology"]) result = with_commodity_ids(df=emissions, methodology=Methodology.STATISTICAL) assert result.to_dict(orient="index") == { - ("PROVINCIAL", "BRA001", CommodityId.MAIZE, "STATISTICAL"): { + ("PROVINCIAL", "BRA001", 56, "STATISTICAL"): { "commodity_hectares": 4.0, - "commodity_name": "Maize (corn)", "jurisdiction_name": "Acre", "production_kg": 40.0, }, - ("PROVINCIAL", "BRA001", CommodityId.COFFEE_GREEN, "STATISTICAL"): { + ("PROVINCIAL", "BRA001", 656, "STATISTICAL"): { "commodity_hectares": 3.0, - "commodity_name": "Coffee, green", "jurisdiction_name": "Acre", "production_kg": 30.0, }, @@ -509,7 +505,24 @@ def test_with_commodity_ids_keeps_a_missing_yield_missing() -> None: ) assert pandas.isna( result.loc[ - ("PROVINCIAL", "USA016", CommodityId.WHEAT, "JURISDICTIONAL_DIRECT"), + ("PROVINCIAL", "USA016", 15, "JURISDICTIONAL_DIRECT"), "production_kg", ] ) + + +def test_get_commodity_id_to_name_takes_crops_from_the_table_and_the_rest_from_taxonomy() -> ( + None +): + crops = pandas.DataFrame( + { + "commodity_id": [56, 56, 236], + "commodity_name": ["Maize (corn)", "Maize (corn)", "Soya beans"], + "year": [2019, 2020, 2020], + } + ).set_index(["commodity_id", "year"]) + commodity_id_to_name = get_commodity_id_to_name(crops=crops) + assert commodity_id_to_name[56] == "Maize (corn)" + assert commodity_id_to_name[236] == "Soya beans" + assert commodity_id_to_name[InventedCommodityId.DROPPED] == "DROPPED" + assert commodity_id_to_name[InventedCommodityId.ALFALFA] == "Alfalfa" diff --git a/jdluc/taxonomy.py b/jdluc/taxonomy.py index d7b8f67..0606a57 100644 --- a/jdluc/taxonomy.py +++ b/jdluc/taxonomy.py @@ -11,47 +11,18 @@ - -101 to -116: the proxy leg's fodder crops, which have no production - -201 and -202: OTHER_OILCROPS and OTHER_PULSES, MapSPAM groups of several FAOSTAT items -`CommodityId` holds every code of our own and each FAOSTAT item the repo refers to by name. The -proxy leg maps most of its crops onto bare item codes. +`InventedCommodityId` holds the codes of our own. FAOSTAT's codes are bare integers, and their names +come from the FAOSTAT crop table in `trace`. """ import enum from jdluc import attribute, emit, jurisdictional_direct, statistical -from jdluc.datasets import tang_cropgrids +from jdluc.datasets import faostat, tang_cropgrids @enum.unique -class CommodityId(enum.IntEnum): - WHEAT = 15 - RICE = 27 - BARLEY = 44 - MAIZE = 56 - MILLET = 79 - SORGHUM = 83 - POTATOES = 116 - SWEET_POTATOES = 122 - CASSAVA = 125 - YAMS = 137 - SUGAR_CANE = 156 - SUGAR_BEET = 157 - BEANS_DRY = 176 - CHICK_PEAS = 191 - COW_PEAS = 195 - PIGEON_PEAS = 197 - LENTILS = 201 - SOYA_BEANS = 236 - GROUNDNUTS = 242 - COCONUTS = 249 - OIL_PALM_FRUIT = 254 - SUNFLOWER_SEED = 267 - RAPE_OR_COLZA_SEED = 270 - SESAME_SEED = 289 - SEED_COTTON = 328 - BANANAS = 486 - PLANTAINS = 489 - COFFEE_GREEN = 656 - CATTLE_MEAT = 867 +class InventedCommodityId(enum.IntEnum): DROPPED = -1 UNATTRIBUTED = -2 PASTURE = -3 @@ -75,61 +46,38 @@ class CommodityId(enum.IntEnum): OTHER_PULSES = -202 -# FAOSTAT's item names, as its bulk downloads spell them, and the fodder crops' names in the -# CROPGRIDS publisher's crop table -COMMODITY_ID_TO_NAME = { - CommodityId.WHEAT: "Wheat", - CommodityId.RICE: "Rice", - CommodityId.BARLEY: "Barley", - CommodityId.MAIZE: "Maize (corn)", - CommodityId.MILLET: "Millet", - CommodityId.SORGHUM: "Sorghum", - CommodityId.POTATOES: "Potatoes", - CommodityId.SWEET_POTATOES: "Sweet potatoes", - CommodityId.CASSAVA: "Cassava, fresh", - CommodityId.YAMS: "Yams", - CommodityId.SUGAR_CANE: "Sugar cane", - CommodityId.SUGAR_BEET: "Sugar beet", - CommodityId.BEANS_DRY: "Beans, dry", - CommodityId.CHICK_PEAS: "Chick peas, dry", - CommodityId.COW_PEAS: "Cow peas, dry", - CommodityId.PIGEON_PEAS: "Pigeon peas, dry", - CommodityId.LENTILS: "Lentils, dry", - CommodityId.SOYA_BEANS: "Soya beans", - CommodityId.GROUNDNUTS: "Groundnuts, excluding shelled", - CommodityId.COCONUTS: "Coconuts, in shell", - CommodityId.OIL_PALM_FRUIT: "Oil palm fruit", - CommodityId.SUNFLOWER_SEED: "Sunflower seed", - CommodityId.RAPE_OR_COLZA_SEED: "Rape or colza seed", - CommodityId.SESAME_SEED: "Sesame seed", - CommodityId.SEED_COTTON: "Seed cotton, unginned", - CommodityId.BANANAS: "Bananas", - CommodityId.PLANTAINS: "Plantains and cooking bananas", - CommodityId.COFFEE_GREEN: "Coffee, green", - CommodityId.CATTLE_MEAT: "Meat of cattle with the bone, fresh or chilled", - CommodityId.DROPPED: "DROPPED", - CommodityId.UNATTRIBUTED: "UNATTRIBUTED", - CommodityId.PASTURE: "PASTURE", - CommodityId.ALFALFA: "Alfalfa", - CommodityId.BEETS_FOR_FODDER: "Beets for fodder", - CommodityId.CABBAGE_FOR_FODDER: "Cabbage for fodder", - CommodityId.CARROTS_FOR_FODDER: "Carrots for fodder", - CommodityId.CLOVER: "Clover", - CommodityId.FORAGE_PRODUCTS_OTHER: "Forage products, other", - CommodityId.GRASSES_OTHER: "Grasses, other", - CommodityId.LEGUMES_OTHER: "Legumes, other", - CommodityId.MAIZE_FOR_FORAGE_AND_SILAGE: "Maize for forage and silage", - CommodityId.MIXED_GRASSES_AND_LEGUMES: "Mixed grasses and legumes", - CommodityId.GREEN_OILSEEDS_FOR_FODDER: "Green oilseeds for fodder", - CommodityId.RYE_GRASS_FOR_FORAGE_AND_SILAGE: "Rye grass for forage and silage", - CommodityId.SORGHUM_FOR_FORAGE_AND_SILAGE: "Sorghum for forage and silage", - CommodityId.SWEDES_FOR_FODDER: "Swedes for fodder", - CommodityId.TURNIPS_FOR_FODDER: "Turnips for fodder", - CommodityId.VEGETABLES_AND_ROOTS_FOR_FODDER: "Vegetables and roots for fodder", - CommodityId.OTHER_OILCROPS: "OTHER_OILCROPS", - CommodityId.OTHER_PULSES: "OTHER_PULSES", +# The names the FAOSTAT crop table does not carry: those of our own codes, from the CROPGRIDS +# publisher's crop table for the fodder crops, and cattle meat's, which is livestock +COMMODITY_ID_TO_NAME: dict[int, str] = { + InventedCommodityId.DROPPED: "DROPPED", + InventedCommodityId.UNATTRIBUTED: "UNATTRIBUTED", + InventedCommodityId.PASTURE: "PASTURE", + InventedCommodityId.ALFALFA: "Alfalfa", + InventedCommodityId.BEETS_FOR_FODDER: "Beets for fodder", + InventedCommodityId.CABBAGE_FOR_FODDER: "Cabbage for fodder", + InventedCommodityId.CARROTS_FOR_FODDER: "Carrots for fodder", + InventedCommodityId.CLOVER: "Clover", + InventedCommodityId.FORAGE_PRODUCTS_OTHER: "Forage products, other", + InventedCommodityId.GRASSES_OTHER: "Grasses, other", + InventedCommodityId.LEGUMES_OTHER: "Legumes, other", + InventedCommodityId.MAIZE_FOR_FORAGE_AND_SILAGE: "Maize for forage and silage", + InventedCommodityId.MIXED_GRASSES_AND_LEGUMES: "Mixed grasses and legumes", + InventedCommodityId.GREEN_OILSEEDS_FOR_FODDER: "Green oilseeds for fodder", + InventedCommodityId.RYE_GRASS_FOR_FORAGE_AND_SILAGE: "Rye grass for forage and silage", + InventedCommodityId.SORGHUM_FOR_FORAGE_AND_SILAGE: "Sorghum for forage and silage", + InventedCommodityId.SWEDES_FOR_FODDER: "Swedes for fodder", + InventedCommodityId.TURNIPS_FOR_FODDER: "Turnips for fodder", + InventedCommodityId.VEGETABLES_AND_ROOTS_FOR_FODDER: "Vegetables and roots for fodder", + InventedCommodityId.OTHER_OILCROPS: "OTHER_OILCROPS", + InventedCommodityId.OTHER_PULSES: "OTHER_PULSES", + faostat.SPECIES_TO_MEAT_ITEM_CODE[faostat.Species.CATTLE]: ( + "Meat of cattle with the bone, fresh or chilled" + ), +} +assert set(COMMODITY_ID_TO_NAME) == { + *InventedCommodityId, + faostat.SPECIES_TO_MEAT_ITEM_CODE[faostat.Species.CATTLE], } -assert set(COMMODITY_ID_TO_NAME) == set(CommodityId) assert len(set(COMMODITY_ID_TO_NAME.values())) == len(COMMODITY_ID_TO_NAME) @@ -140,63 +88,67 @@ class CommodityId(enum.IntEnum): attribute.Methodology, dict[str, int] ] = { attribute.Methodology.JURISDICTIONAL_DIRECT: { - emit.NonCommodity.DROPPED.name: CommodityId.DROPPED, - emit.NonCommodity.UNATTRIBUTED.name: CommodityId.UNATTRIBUTED, - jurisdictional_direct.Crop.BARLEY.name: CommodityId.BARLEY, - jurisdictional_direct.Crop.BEAN.name: CommodityId.BEANS_DRY, - jurisdictional_direct.Crop.COTTON.name: CommodityId.SEED_COTTON, - jurisdictional_direct.Crop.MAIZE.name: CommodityId.MAIZE, - jurisdictional_direct.Crop.POTATO.name: CommodityId.POTATOES, - jurisdictional_direct.Crop.RICE.name: CommodityId.RICE, - jurisdictional_direct.Crop.SORGHUM.name: CommodityId.SORGHUM, - jurisdictional_direct.Crop.SOYBEAN.name: CommodityId.SOYA_BEANS, - jurisdictional_direct.Crop.SUGARBEET.name: CommodityId.SUGAR_BEET, - jurisdictional_direct.Crop.SUGARCANE.name: CommodityId.SUGAR_CANE, - jurisdictional_direct.Crop.WHEAT.name: CommodityId.WHEAT, + emit.NonCommodity.DROPPED.name: InventedCommodityId.DROPPED, + emit.NonCommodity.UNATTRIBUTED.name: InventedCommodityId.UNATTRIBUTED, + jurisdictional_direct.Crop.BARLEY.name: 44, + jurisdictional_direct.Crop.BEAN.name: 176, + jurisdictional_direct.Crop.COTTON.name: 328, + jurisdictional_direct.Crop.MAIZE.name: 56, + jurisdictional_direct.Crop.POTATO.name: 116, + jurisdictional_direct.Crop.RICE.name: 27, + jurisdictional_direct.Crop.SORGHUM.name: 83, + jurisdictional_direct.Crop.SOYBEAN.name: 236, + jurisdictional_direct.Crop.SUGARBEET.name: 157, + jurisdictional_direct.Crop.SUGARCANE.name: 156, + jurisdictional_direct.Crop.WHEAT.name: 15, }, attribute.Methodology.STATISTICAL: { - emit.NonCommodity.DROPPED.name: CommodityId.DROPPED, - emit.NonCommodity.UNATTRIBUTED.name: CommodityId.UNATTRIBUTED, - statistical.Crop.ARABICA_COFFEE.name: CommodityId.COFFEE_GREEN, - statistical.Crop.BANANA.name: CommodityId.BANANAS, - statistical.Crop.BARLEY.name: CommodityId.BARLEY, - statistical.Crop.BEAN.name: CommodityId.BEANS_DRY, - statistical.Crop.CASSAVA.name: CommodityId.CASSAVA, - statistical.Crop.CHICKPEA.name: CommodityId.CHICK_PEAS, - statistical.Crop.COCONUT.name: CommodityId.COCONUTS, - statistical.Crop.COTTON.name: CommodityId.SEED_COTTON, - statistical.Crop.COWPEA.name: CommodityId.COW_PEAS, - statistical.Crop.GROUNDNUT.name: CommodityId.GROUNDNUTS, - statistical.Crop.LENTIL.name: CommodityId.LENTILS, - statistical.Crop.MAIZE.name: CommodityId.MAIZE, - statistical.Crop.OILPALM.name: CommodityId.OIL_PALM_FRUIT, - statistical.Crop.OTHER_OILCROPS.name: CommodityId.OTHER_OILCROPS, - statistical.Crop.OTHER_PULSES.name: CommodityId.OTHER_PULSES, - statistical.Crop.PEARL_MILLET.name: CommodityId.MILLET, - statistical.Crop.PIGEONPEA.name: CommodityId.PIGEON_PEAS, - statistical.Crop.PLANTAIN.name: CommodityId.PLANTAINS, - statistical.Crop.POTATO.name: CommodityId.POTATOES, - statistical.Crop.RAPESEED.name: CommodityId.RAPE_OR_COLZA_SEED, - statistical.Crop.RICE.name: CommodityId.RICE, - statistical.Crop.ROBUSTA_COFFEE.name: CommodityId.COFFEE_GREEN, - statistical.Crop.SESAME_SEED.name: CommodityId.SESAME_SEED, - statistical.Crop.SMALL_MILLET.name: CommodityId.MILLET, - statistical.Crop.SORGHUM.name: CommodityId.SORGHUM, - statistical.Crop.SOYBEAN.name: CommodityId.SOYA_BEANS, - statistical.Crop.SUGARBEET.name: CommodityId.SUGAR_BEET, - statistical.Crop.SUGARCANE.name: CommodityId.SUGAR_CANE, - statistical.Crop.SUNFLOWER.name: CommodityId.SUNFLOWER_SEED, - statistical.Crop.SWEET_POTATO.name: CommodityId.SWEET_POTATOES, - statistical.Crop.WHEAT.name: CommodityId.WHEAT, - statistical.Crop.YAM.name: CommodityId.YAMS, - statistical.Livestock.BEEF_CATTLE.name: CommodityId.CATTLE_MEAT, - statistical.Livestock.PASTURE.name: CommodityId.PASTURE, + emit.NonCommodity.DROPPED.name: InventedCommodityId.DROPPED, + emit.NonCommodity.UNATTRIBUTED.name: InventedCommodityId.UNATTRIBUTED, + statistical.Crop.ARABICA_COFFEE.name: 656, + statistical.Crop.BANANA.name: 486, + statistical.Crop.BARLEY.name: 44, + statistical.Crop.BEAN.name: 176, + statistical.Crop.CASSAVA.name: 125, + statistical.Crop.CHICKPEA.name: 191, + statistical.Crop.COCONUT.name: 249, + statistical.Crop.COTTON.name: 328, + statistical.Crop.COWPEA.name: 195, + statistical.Crop.GROUNDNUT.name: 242, + statistical.Crop.LENTIL.name: 201, + statistical.Crop.MAIZE.name: 56, + statistical.Crop.OILPALM.name: 254, + statistical.Crop.OTHER_OILCROPS.name: InventedCommodityId.OTHER_OILCROPS, + statistical.Crop.OTHER_PULSES.name: InventedCommodityId.OTHER_PULSES, + statistical.Crop.PEARL_MILLET.name: 79, + statistical.Crop.PIGEONPEA.name: 197, + statistical.Crop.PLANTAIN.name: 489, + statistical.Crop.POTATO.name: 116, + statistical.Crop.RAPESEED.name: 270, + statistical.Crop.RICE.name: 27, + statistical.Crop.ROBUSTA_COFFEE.name: 656, + statistical.Crop.SESAME_SEED.name: 289, + statistical.Crop.SMALL_MILLET.name: 79, + statistical.Crop.SORGHUM.name: 83, + statistical.Crop.SOYBEAN.name: 236, + statistical.Crop.SUGARBEET.name: 157, + statistical.Crop.SUGARCANE.name: 156, + statistical.Crop.SUNFLOWER.name: 267, + statistical.Crop.SWEET_POTATO.name: 122, + statistical.Crop.WHEAT.name: 15, + statistical.Crop.YAM.name: 137, + statistical.Livestock.BEEF_CATTLE.name: faostat.SPECIES_TO_MEAT_ITEM_CODE[ + faostat.Species.CATTLE + ], + statistical.Livestock.PASTURE.name: InventedCommodityId.PASTURE, }, attribute.Methodology.JURISDICTIONAL_DIRECT_PROXY: { - emit.NonCommodity.DROPPED.name: CommodityId.DROPPED, - emit.NonCommodity.UNATTRIBUTED.name: CommodityId.UNATTRIBUTED, - statistical.Livestock.BEEF_CATTLE.name: CommodityId.CATTLE_MEAT, - statistical.Livestock.PASTURE.name: CommodityId.PASTURE, + emit.NonCommodity.DROPPED.name: InventedCommodityId.DROPPED, + emit.NonCommodity.UNATTRIBUTED.name: InventedCommodityId.UNATTRIBUTED, + statistical.Livestock.BEEF_CATTLE.name: faostat.SPECIES_TO_MEAT_ITEM_CODE[ + faostat.Species.CATTLE + ], + statistical.Livestock.PASTURE.name: InventedCommodityId.PASTURE, # The publisher's crop table (`CROPS_matching_v17.xlsx` in figshare's `CODES.zip`), through # the authors' 2020 FAOSTAT extract. Both kapok crops are 310, kapok fruit tang_cropgrids.Crop.ABACA.name: 809, @@ -359,22 +311,22 @@ class CommodityId(enum.IntEnum): tang_cropgrids.Crop.PIMENTO.name: 689, tang_cropgrids.Crop.POPCORN.name: 56, # Fodder, which FAOSTAT has no item for - tang_cropgrids.Crop.ALFALFA.name: CommodityId.ALFALFA, - tang_cropgrids.Crop.BEETFOR.name: CommodityId.BEETS_FOR_FODDER, - tang_cropgrids.Crop.CABBAGEFOR.name: CommodityId.CABBAGE_FOR_FODDER, - tang_cropgrids.Crop.CARROTFOR.name: CommodityId.CARROTS_FOR_FODDER, - tang_cropgrids.Crop.CLOVER.name: CommodityId.CLOVER, - tang_cropgrids.Crop.FORNES.name: CommodityId.FORAGE_PRODUCTS_OTHER, - tang_cropgrids.Crop.GRASSNES.name: CommodityId.GRASSES_OTHER, - tang_cropgrids.Crop.LEGUMENES.name: CommodityId.LEGUMES_OTHER, - tang_cropgrids.Crop.MAIZEFOR.name: CommodityId.MAIZE_FOR_FORAGE_AND_SILAGE, - tang_cropgrids.Crop.MIXEDGRASS.name: CommodityId.MIXED_GRASSES_AND_LEGUMES, - tang_cropgrids.Crop.OILSEEDFOR.name: CommodityId.GREEN_OILSEEDS_FOR_FODDER, - tang_cropgrids.Crop.RYEFOR.name: CommodityId.RYE_GRASS_FOR_FORAGE_AND_SILAGE, - tang_cropgrids.Crop.SORGHUMFOR.name: CommodityId.SORGHUM_FOR_FORAGE_AND_SILAGE, - tang_cropgrids.Crop.SWEDEFOR.name: CommodityId.SWEDES_FOR_FODDER, - tang_cropgrids.Crop.TURNIPFOR.name: CommodityId.TURNIPS_FOR_FODDER, - tang_cropgrids.Crop.VEGETABLENES.name: CommodityId.VEGETABLES_AND_ROOTS_FOR_FODDER, + tang_cropgrids.Crop.ALFALFA.name: InventedCommodityId.ALFALFA, + tang_cropgrids.Crop.BEETFOR.name: InventedCommodityId.BEETS_FOR_FODDER, + tang_cropgrids.Crop.CABBAGEFOR.name: InventedCommodityId.CABBAGE_FOR_FODDER, + tang_cropgrids.Crop.CARROTFOR.name: InventedCommodityId.CARROTS_FOR_FODDER, + tang_cropgrids.Crop.CLOVER.name: InventedCommodityId.CLOVER, + tang_cropgrids.Crop.FORNES.name: InventedCommodityId.FORAGE_PRODUCTS_OTHER, + tang_cropgrids.Crop.GRASSNES.name: InventedCommodityId.GRASSES_OTHER, + tang_cropgrids.Crop.LEGUMENES.name: InventedCommodityId.LEGUMES_OTHER, + tang_cropgrids.Crop.MAIZEFOR.name: InventedCommodityId.MAIZE_FOR_FORAGE_AND_SILAGE, + tang_cropgrids.Crop.MIXEDGRASS.name: InventedCommodityId.MIXED_GRASSES_AND_LEGUMES, + tang_cropgrids.Crop.OILSEEDFOR.name: InventedCommodityId.GREEN_OILSEEDS_FOR_FODDER, + tang_cropgrids.Crop.RYEFOR.name: InventedCommodityId.RYE_GRASS_FOR_FORAGE_AND_SILAGE, + tang_cropgrids.Crop.SORGHUMFOR.name: InventedCommodityId.SORGHUM_FOR_FORAGE_AND_SILAGE, + tang_cropgrids.Crop.SWEDEFOR.name: InventedCommodityId.SWEDES_FOR_FODDER, + tang_cropgrids.Crop.TURNIPFOR.name: InventedCommodityId.TURNIPS_FOR_FODDER, + tang_cropgrids.Crop.VEGETABLENES.name: InventedCommodityId.VEGETABLES_AND_ROOTS_FOR_FODDER, }, } assert set(METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID) == set(attribute.Methodology) diff --git a/jdluc/trace.py b/jdluc/trace.py index de1ad19..6d234e9 100644 --- a/jdluc/trace.py +++ b/jdluc/trace.py @@ -9,7 +9,8 @@ It then derives, identically for both, each commodity's yield (kg per hectare) and emissions factor (kgCO2e per kg), and rolls the provincials up to national totals. Returns a cached pandas.DataFrame indexed by (admin_level, admin_id, commodity_id, methodology), where -`commodity_id` is a `taxonomy.CommodityId` and the `commodity_name` column holds its name. +`commodity_id` is a FAOSTAT item code or a `taxonomy.InventedCommodityId`, and the `commodity_name` +column holds its name: FAOSTAT's, from its crop table, or `taxonomy`'s. The countries come from the positional ISO 3166 alpha-3 codes, or -- with `--backfill` -- from every country in the World Bank admin-0 layer. @@ -114,8 +115,8 @@ def derive_statistical_production_kg( def with_commodity_ids( df: pandas.DataFrame, methodology: attribute.Methodology ) -> pandas.DataFrame: - """Re-key rows named by the leg's own map onto `taxonomy.CommodityId`, adding together the rows - that share a code, such as arabica and robusta coffee. + """Re-key rows named by the leg's own map onto `taxonomy`'s codes, adding together the rows that + share a code, such as arabica and robusta coffee. """ commodity_name_to_commodity_id = ( taxonomy.METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[methodology] @@ -127,14 +128,26 @@ def with_commodity_ids( ] grouped = flat.drop(columns="commodity_name").groupby(by=list(CANONICAL_KEY)) # min_count keeps a row with no production at all, such as one NASS has no yield for, at NaN - ret = grouped.sum(min_count=1, numeric_only=True).assign( + return grouped.sum(min_count=1, numeric_only=True).assign( jurisdiction_name=grouped["jurisdiction_name"].first() ) - ret["commodity_name"] = [ - taxonomy.COMMODITY_ID_TO_NAME[taxonomy.CommodityId(commodity_id)] - for commodity_id in ret.index.get_level_values("commodity_id") - ] - return ret + + +def get_commodity_id_to_name(crops: pandas.DataFrame) -> dict[int, str]: + """Every code's name: FAOSTAT's for its crop items, from its crop table, and `taxonomy`'s for + the rest. + """ + pairs = crops.reset_index()[["commodity_id", "commodity_name"]].drop_duplicates() + assert pairs["commodity_id"].is_unique + crop_id_to_name = dict( + zip( + map(int, pairs["commodity_id"]), + map(str, pairs["commodity_name"]), + strict=True, + ) + ) + assert not set(crop_id_to_name) & set(taxonomy.COMMODITY_ID_TO_NAME) + return crop_id_to_name | taxonomy.COMMODITY_ID_TO_NAME def attach_ratios(df: pandas.DataFrame) -> pandas.DataFrame: @@ -157,8 +170,7 @@ def iter_national_from_provincials( national_name: str, provincials: pandas.DataFrame, ) -> collections.abc.Iterator[dict[str, str | float]]: - for key, group in sorted(provincials.groupby(by="commodity_id")): - commodity_id = taxonomy.CommodityId(typing.cast(int, key)) + for commodity_id, group in sorted(provincials.groupby(by="commodity_id")): def safe_div(numer: float, denom: float) -> float: return float("nan") if denom == 0 else numer / denom @@ -169,8 +181,7 @@ def safe_div(numer: float, denom: float) -> float: } yield ret | { "admin_level": worldbank_jurisdictions.AdminLevel.NATIONAL.name, - "commodity_id": int(commodity_id), - "commodity_name": taxonomy.COMMODITY_ID_TO_NAME[commodity_id], + "commodity_id": int(typing.cast(int, commodity_id)), "jurisdiction_name": national_name, "admin_id": iso_3166, "yield_kg_per_ha": safe_div( @@ -237,7 +248,7 @@ def workflow( ], ) ) - return pandas.concat( + ret = pandas.concat( [ provincials, pandas.DataFrame.from_records(iter_national).set_index( @@ -245,6 +256,14 @@ def workflow( ), ] ) + commodity_id_to_name = get_commodity_id_to_name( + crops=faostat.load(dataset=faostat.CROP_DATASET) + ) + ret["commodity_name"] = [ + commodity_id_to_name[int(commodity_id)] + for commodity_id in ret.index.get_level_values("commodity_id") + ] + return ret def main() -> int: diff --git a/tools/export-graphs.py b/tools/export-graphs.py index fcaf9cf..614d6e9 100644 --- a/tools/export-graphs.py +++ b/tools/export-graphs.py @@ -11,7 +11,7 @@ import numpy import pandas -from jdluc import attribute, taxonomy, trace +from jdluc import attribute, trace from jdluc.datasets import worldbank_jurisdictions logger = logging.getLogger(__name__) @@ -77,52 +77,54 @@ def csv(self) -> str: "Brazil | Name Unknown", } +# FAOSTAT items: 15 wheat, 27 rice, 56 maize, 83 sorghum, 156 sugar cane, 236 soya beans, 254 oil +# palm fruit, 486 bananas and 489 plantains METHODOLOGY_TO_ISO_3166_TO_COMMODITY_IDS = { attribute.Methodology.JURISDICTIONAL_DIRECT: { "USA": ( - taxonomy.CommodityId.MAIZE, - taxonomy.CommodityId.SOYA_BEANS, - taxonomy.CommodityId.WHEAT, + 56, + 236, + 15, ) }, attribute.Methodology.STATISTICAL: { "BRA": ( - taxonomy.CommodityId.MAIZE, - taxonomy.CommodityId.SOYA_BEANS, - taxonomy.CommodityId.OIL_PALM_FRUIT, + 56, + 236, + 254, ), "BOL": ( - taxonomy.CommodityId.SOYA_BEANS, - taxonomy.CommodityId.PLANTAINS, - taxonomy.CommodityId.BANANAS, + 236, + 489, + 486, ), - "ARG": (taxonomy.CommodityId.SOYA_BEANS,), - "CAN": (taxonomy.CommodityId.SOYA_BEANS,), - "IDN": (taxonomy.CommodityId.OIL_PALM_FRUIT,), - "PNG": (taxonomy.CommodityId.OIL_PALM_FRUIT,), - "GHA": (taxonomy.CommodityId.RICE, taxonomy.CommodityId.WHEAT), - "VNM": (taxonomy.CommodityId.RICE, taxonomy.CommodityId.SORGHUM), + "ARG": (236,), + "CAN": (236,), + "IDN": (254,), + "PNG": (254,), + "GHA": (27, 15), + "VNM": (27, 83), "MYS": ( - taxonomy.CommodityId.RICE, - taxonomy.CommodityId.SORGHUM, - taxonomy.CommodityId.OIL_PALM_FRUIT, + 27, + 83, + 254, ), "CUB": ( - taxonomy.CommodityId.SUGAR_CANE, - taxonomy.CommodityId.RICE, - taxonomy.CommodityId.BANANAS, + 156, + 27, + 486, ), "ZAF": ( - taxonomy.CommodityId.RICE, - taxonomy.CommodityId.WHEAT, - taxonomy.CommodityId.SORGHUM, + 27, + 15, + 83, ), }, } def subset_to_commodity_ids( - commodity_ids: tuple[taxonomy.CommodityId, ...], df: pandas.DataFrame + commodity_ids: tuple[int, ...], df: pandas.DataFrame ) -> pandas.DataFrame: ret = df[df.index.get_level_values("commodity_id").isin(commodity_ids)] missing = sorted( @@ -134,7 +136,7 @@ def subset_to_commodity_ids( def iter_dfs( - iso_3166_to_commodity_ids: dict[str, tuple[taxonomy.CommodityId, ...]], + iso_3166_to_commodity_ids: dict[str, tuple[int, ...]], methodology: attribute.Methodology, ) -> collections.abc.Iterator[pandas.DataFrame]: # The full commodity list, so this reuses the caches an ordinary trace.py run populates diff --git a/validation/__tests__/crop_codes_test.py b/validation/__tests__/crop_codes_test.py index 61ea675..9e7e617 100644 --- a/validation/__tests__/crop_codes_test.py +++ b/validation/__tests__/crop_codes_test.py @@ -5,8 +5,11 @@ from jdluc import statistical from jdluc.attribute import Methodology -from jdluc.datasets import ifpri_mapspam -from jdluc.taxonomy import METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID, CommodityId +from jdluc.datasets import faostat, ifpri_mapspam +from jdluc.taxonomy import ( + METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID, + InventedCommodityId, +) from validation import prepare STATISTICAL_NAME_TO_COMMODITY_ID = METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[ @@ -24,7 +27,7 @@ ids=lambda crop: crop.name.lower(), ) def test_a_crop_with_its_own_item_keeps_its_own_code(crop: statistical.Crop) -> None: - commodity_id = CommodityId(STATISTICAL_NAME_TO_COMMODITY_ID[crop.name]) + commodity_id = STATISTICAL_NAME_TO_COMMODITY_ID[crop.name] assert prepare.COMMODITY_ID_TO_CROP_CODE[commodity_id] == crop.value @@ -56,7 +59,7 @@ def test_a_crop_with_its_own_item_keeps_its_own_code(crop: statistical.Crop) -> def test_a_split_crop_is_compared_under_its_combined_code( crop: statistical.Crop, combined_crop_code: str ) -> None: - commodity_id = CommodityId(STATISTICAL_NAME_TO_COMMODITY_ID[crop.name]) + commodity_id = STATISTICAL_NAME_TO_COMMODITY_ID[crop.name] assert prepare.COMMODITY_ID_TO_CROP_CODE[commodity_id] == combined_crop_code assert ( prepare.SPLIT_CROP_CODE_TO_COMBINED_CROP_CODE[crop.value] == combined_crop_code @@ -74,7 +77,7 @@ def test_a_split_crop_is_compared_under_its_combined_code( ids=lambda crop: crop.name.lower(), ) def test_faostat_items_match_taxonomy_codes(crop: statistical.Crop) -> None: - commodity_id = CommodityId(STATISTICAL_NAME_TO_COMMODITY_ID[crop.name]) + commodity_id = STATISTICAL_NAME_TO_COMMODITY_ID[crop.name] assert prepare.FAOSTAT_ITEM_CODE_TO_CROP_CODE[commodity_id] == crop.value @@ -82,12 +85,12 @@ def test_with_crop_codes_keeps_only_crop_rows() -> None: frame = pandas.DataFrame( { "commodity_id": [ - CommodityId.MAIZE, - CommodityId.COFFEE_GREEN, - CommodityId.CATTLE_MEAT, - CommodityId.PASTURE, - CommodityId.DROPPED, - CommodityId.UNATTRIBUTED, + 56, # maize + 656, # coffee + faostat.SPECIES_TO_MEAT_ITEM_CODE[faostat.Species.CATTLE], + InventedCommodityId.PASTURE, + InventedCommodityId.DROPPED, + InventedCommodityId.UNATTRIBUTED, ] } ).astype({"commodity_id": int}) diff --git a/validation/prepare.py b/validation/prepare.py index 629bf27..4d5561a 100644 --- a/validation/prepare.py +++ b/validation/prepare.py @@ -760,36 +760,36 @@ def read_efs() -> pandas.DataFrame | None: # FAOSTAT has one item each for coffee and millet where MapSPAM 2005 onward has two, so those two # take MapSPAM 2000's combined codes. COMMODITY_ID_TO_CROP_CODE = { - taxonomy.CommodityId.BANANAS: statistical.Crop.BANANA.value, - taxonomy.CommodityId.BARLEY: statistical.Crop.BARLEY.value, - taxonomy.CommodityId.BEANS_DRY: statistical.Crop.BEAN.value, - taxonomy.CommodityId.CASSAVA: statistical.Crop.CASSAVA.value, - taxonomy.CommodityId.CHICK_PEAS: statistical.Crop.CHICKPEA.value, - taxonomy.CommodityId.COCONUTS: statistical.Crop.COCONUT.value, - taxonomy.CommodityId.COFFEE_GREEN: ifpri_mapspam.Crop2000.COFF.name, - taxonomy.CommodityId.COW_PEAS: statistical.Crop.COWPEA.value, - taxonomy.CommodityId.GROUNDNUTS: statistical.Crop.GROUNDNUT.value, - taxonomy.CommodityId.LENTILS: statistical.Crop.LENTIL.value, - taxonomy.CommodityId.MAIZE: statistical.Crop.MAIZE.value, - taxonomy.CommodityId.MILLET: ifpri_mapspam.Crop2000.MILL.name, - taxonomy.CommodityId.OIL_PALM_FRUIT: statistical.Crop.OILPALM.value, - taxonomy.CommodityId.OTHER_OILCROPS: statistical.Crop.OTHER_OILCROPS.value, - taxonomy.CommodityId.OTHER_PULSES: statistical.Crop.OTHER_PULSES.value, - taxonomy.CommodityId.PIGEON_PEAS: statistical.Crop.PIGEONPEA.value, - taxonomy.CommodityId.PLANTAINS: statistical.Crop.PLANTAIN.value, - taxonomy.CommodityId.POTATOES: statistical.Crop.POTATO.value, - taxonomy.CommodityId.RAPE_OR_COLZA_SEED: statistical.Crop.RAPESEED.value, - taxonomy.CommodityId.RICE: statistical.Crop.RICE.value, - taxonomy.CommodityId.SEED_COTTON: statistical.Crop.COTTON.value, - taxonomy.CommodityId.SESAME_SEED: statistical.Crop.SESAME_SEED.value, - taxonomy.CommodityId.SORGHUM: statistical.Crop.SORGHUM.value, - taxonomy.CommodityId.SOYA_BEANS: statistical.Crop.SOYBEAN.value, - taxonomy.CommodityId.SUGAR_BEET: statistical.Crop.SUGARBEET.value, - taxonomy.CommodityId.SUGAR_CANE: statistical.Crop.SUGARCANE.value, - taxonomy.CommodityId.SUNFLOWER_SEED: statistical.Crop.SUNFLOWER.value, - taxonomy.CommodityId.SWEET_POTATOES: statistical.Crop.SWEET_POTATO.value, - taxonomy.CommodityId.WHEAT: statistical.Crop.WHEAT.value, - taxonomy.CommodityId.YAMS: statistical.Crop.YAM.value, + 486: statistical.Crop.BANANA.value, + 44: statistical.Crop.BARLEY.value, + 176: statistical.Crop.BEAN.value, + 125: statistical.Crop.CASSAVA.value, + 191: statistical.Crop.CHICKPEA.value, + 249: statistical.Crop.COCONUT.value, + 656: ifpri_mapspam.Crop2000.COFF.name, + 195: statistical.Crop.COWPEA.value, + 242: statistical.Crop.GROUNDNUT.value, + 201: statistical.Crop.LENTIL.value, + 56: statistical.Crop.MAIZE.value, + 79: ifpri_mapspam.Crop2000.MILL.name, + 254: statistical.Crop.OILPALM.value, + taxonomy.InventedCommodityId.OTHER_OILCROPS: statistical.Crop.OTHER_OILCROPS.value, + taxonomy.InventedCommodityId.OTHER_PULSES: statistical.Crop.OTHER_PULSES.value, + 197: statistical.Crop.PIGEONPEA.value, + 489: statistical.Crop.PLANTAIN.value, + 116: statistical.Crop.POTATO.value, + 270: statistical.Crop.RAPESEED.value, + 27: statistical.Crop.RICE.value, + 328: statistical.Crop.COTTON.value, + 289: statistical.Crop.SESAME_SEED.value, + 83: statistical.Crop.SORGHUM.value, + 236: statistical.Crop.SOYBEAN.value, + 157: statistical.Crop.SUGARBEET.value, + 156: statistical.Crop.SUGARCANE.value, + 267: statistical.Crop.SUNFLOWER.value, + 122: statistical.Crop.SWEET_POTATO.value, + 15: statistical.Crop.WHEAT.value, + 137: statistical.Crop.YAM.value, } assert set(COMMODITY_ID_TO_CROP_CODE) == { taxonomy.METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[ @@ -873,7 +873,7 @@ def with_crop_codes(frame: pandas.DataFrame) -> pandas.DataFrame: """ crops = frame[frame["commodity_id"].isin(COMMODITY_ID_TO_CROP_CODE)].copy() crops["crop_code"] = [ - COMMODITY_ID_TO_CROP_CODE[taxonomy.CommodityId(commodity_id)] + COMMODITY_ID_TO_CROP_CODE[int(commodity_id)] for commodity_id in crops["commodity_id"] ] return crops From b5f877c248134b888bd15b5e90cf6a0e17e39e5a Mon Sep 17 00:00:00 2001 From: Jeffrey Seifried Date: Fri, 6 Nov 2026 10:20:10 -0800 Subject: [PATCH 4/7] Implement production and emissions factor computation in trace for JDP --- .../jurisdictional_direct_proxy_test.py | 25 ++- jdluc/__tests__/trace_test.py | 104 ++++++++- jdluc/jurisdictional_direct_proxy.py | 21 +- jdluc/taxonomy.py | 18 ++ jdluc/trace.py | 201 ++++++++++++++---- validation/__tests__/schema_test.py | 2 +- 6 files changed, 321 insertions(+), 50 deletions(-) diff --git a/jdluc/__tests__/jurisdictional_direct_proxy_test.py b/jdluc/__tests__/jurisdictional_direct_proxy_test.py index 7c26424..f3bfe51 100644 --- a/jdluc/__tests__/jurisdictional_direct_proxy_test.py +++ b/jdluc/__tests__/jurisdictional_direct_proxy_test.py @@ -6,6 +6,7 @@ from jdluc.datasets.gpw_grassland import DATASET as GRASSLAND from jdluc.datasets.gpw_grassland import YEARS as GRASSLAND_YEARS from jdluc.datasets.gpw_grassland import Grassland +from jdluc.datasets.gpw_livestock import Species, get_band_name from jdluc.datasets.tang_cropgrids import DATASET as CROPGRIDS from jdluc.datasets.tang_cropgrids import YEAR as CROPGRIDS_YEAR from jdluc.datasets.tang_cropgrids import Crop @@ -19,6 +20,7 @@ NonCommodity, ) from jdluc.jurisdictional_direct_proxy import ( + HERD_HEADS_COLUMN, get_commodity_name_to_totals, get_crop_to_area_share, get_name_to_band, @@ -190,10 +192,10 @@ def test_get_crop_to_area_share_divides_by_every_crop_cropgrids_maps() -> None: def test_get_commodity_name_to_totals() -> None: """Two cells of 2 and 3 hectares, one row as a province one cell tall clips to. - Cocoa grows on the first cell and no crop on the second; half of each is pasture. Every band - charges both cells alike, and each land class and component charges its own amount, so a total - reading the wrong band is visible. Cropland's peat occupation drains the whole cell and - pasture's half of it. + Cocoa grows on the first cell and no crop on the second; half of each is pasture, and each + holds 10 cattle a hectare. Every band charges both cells alike, and each land class and + component charges its own amount, so a total reading the wrong band is visible. Cropland's peat + occupation drains the whole cell and pasture's half of it. """ per_hectare = { f"{CROPLAND:s}-forest-emissions:tco2e-per-ha": 100.0, @@ -201,6 +203,7 @@ def test_get_commodity_name_to_totals() -> None: f"{CROPLAND:s}-peatland-conversion-emissions:tco2e-per-ha": 3.0, f"{CROPLAND:s}-peatland-occupation:tco2e-per-ha": PEATLAND_EMISSIONS_ANNUAL_TCO2E_PER_HA, "dropped-emissions:tco2e-per-ha": 9.0, + get_band_name(species=Species.CATTLE, year=CROPGRIDS_YEAR): 10.0, f"pasture:fraction:{CROPGRIDS_YEAR:d}": 0.5, f"{PASTURELAND:s}-forest-emissions:tco2e-per-ha": 4000.0, f"{PASTURELAND:s}-grassland-emissions:tco2e-per-ha": 500.0, @@ -230,18 +233,22 @@ def test_get_commodity_name_to_totals() -> None: { "commodity_hectares": 50.0, "emissions_mt": 320.6, + HERD_HEADS_COLUMN: numpy.nan, "forest_emissions_mt": 200.0, "grassland_emissions_mt": 40.0, "peatland_commodity_hectares": 2.0, "peatland_conversion_emissions_mt": 6.0, "peatland_occupation_emissions_mt": 74.6, - } + }, + nan_ok=True, ), # Pasture's charge on three quarters of both cells' 5 ha, and of their 2.5 ha of pasture Livestock.BEEF_CATTLE.name: pytest.approx( { "commodity_hectares": 1.875, "emissions_mt": 17169.9375, + # Both cells' whole herd, since both have pasture + HERD_HEADS_COLUMN: 50.0, "forest_emissions_mt": 15000.0, "grassland_emissions_mt": 1875.0, "peatland_commodity_hectares": 1.875, @@ -249,15 +256,19 @@ def test_get_commodity_name_to_totals() -> None: "peatland_occupation_emissions_mt": 69.9375, } ), - NonCommodity.DROPPED.name: pytest.approx({"emissions_mt": 45.0}), + NonCommodity.DROPPED.name: pytest.approx( + {"emissions_mt": 45.0, HERD_HEADS_COLUMN: numpy.nan}, nan_ok=True + ), # Cropland's charge on the second cell's 3 ha, plus the quarter of pasture's no row takes NonCommodity.UNATTRIBUTED.name: pytest.approx( { "emissions_mt": 6204.2125, "forest_emissions_mt": 5300.0, + HERD_HEADS_COLUMN: numpy.nan, "grassland_emissions_mt": 685.0, "peatland_conversion_emissions_mt": 84.0, "peatland_occupation_emissions_mt": 135.2125, - } + }, + nan_ok=True, ), } diff --git a/jdluc/__tests__/trace_test.py b/jdluc/__tests__/trace_test.py index ddd7989..1ec169d 100644 --- a/jdluc/__tests__/trace_test.py +++ b/jdluc/__tests__/trace_test.py @@ -3,12 +3,20 @@ import pytest from jdluc.attribute import Methodology +from jdluc.datasets import tang_cropgrids from jdluc.datasets.worldbank_jurisdictions import AdminLevel +from jdluc.jurisdictional_direct_proxy import HERD_HEADS_COLUMN from jdluc.statistical import HERD_HEADS_COLUMNS -from jdluc.taxonomy import InventedCommodityId +from jdluc.taxonomy import ( + CO_PRODUCT_ITEM_CODE_TO_ITEM_CODE, + METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID, + UNMAPPED_CROP_ITEM_CODES, + InventedCommodityId, +) from jdluc.trace import ( attach_ratios, derive_jurisdictional_production_kg, + derive_proxy_production_kg, derive_statistical_production_kg, get_commodity_id_to_name, iter_national_from_provincials, @@ -526,3 +534,97 @@ def test_get_commodity_id_to_name_takes_crops_from_the_table_and_the_rest_from_t assert commodity_id_to_name[236] == "Soya beans" assert commodity_id_to_name[InventedCommodityId.DROPPED] == "DROPPED" assert commodity_id_to_name[InventedCommodityId.ALFALFA] == "Alfalfa" + + +def test_derive_proxy_production_kg(caplog: pytest.LogCaptureFixture) -> None: + """Brazil's maize is FAOSTAT's 2019-2021 mean of 200 kg, split over 50 ha of maize and popcorn, + which share its item. Its cattle weigh FAOSTAT's 1050 kg of meat a year over 100 head, with + 2021's meat unreported and 2018 outside the years. Its soya beans have no CROPGRIDS area. + """ + proxy_name_to_commodity_id = METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[ + Methodology.JURISDICTIONAL_DIRECT_PROXY + ] + # Every crop item, so the coverage assertion holds + every_item_code = ( + { + proxy_name_to_commodity_id[crop.name] + for crop in tang_cropgrids.Crop + if proxy_name_to_commodity_id[crop.name] > 0 + } + | set(CO_PRODUCT_ITEM_CODE_TO_ITEM_CODE) + | UNMAPPED_CROP_ITEM_CODES + ) + crops = pandas.DataFrame.from_records( + [ + { + "admin_id": "ZZZ", + "commodity_id": code, + "production_kg": 0.0, + "year": 2020, + } + for code in sorted(every_item_code) + ] + + [ + { + "admin_id": "BRA", + "commodity_id": 56, + "production_kg": 100.0, + "year": 2019, + }, + { + "admin_id": "BRA", + "commodity_id": 56, + "production_kg": 200.0, + "year": 2020, + }, + { + "admin_id": "BRA", + "commodity_id": 56, + "production_kg": 300.0, + "year": 2021, + }, + { + "admin_id": "BRA", + "commodity_id": 236, + "production_kg": 7.0, + "year": 2020, + }, + ] + ).set_index(["admin_id", "commodity_id", "year"]) + stocks = pandas.DataFrame.from_records( + [ + ("BRA", "CATTLE", 2018, 99999.0, 100.0), + ("BRA", "CATTLE", 2019, 900.0, 100.0), + ("BRA", "CATTLE", 2020, 1200.0, 100.0), + ("BRA", "CATTLE", 2021, numpy.nan, 100.0), + ], + columns=["admin_id", "commodity_name", "year", "production_kg", "stocks_head"], + ).set_index(["admin_id", "commodity_name", "year"]) + emissions = pandas.DataFrame.from_records( + [ + ("BRA001", "MAIZE", 30.0, numpy.nan), + ("BRA002", "MAIZE", 10.0, numpy.nan), + ("BRA001", "POPCORN", 10.0, numpy.nan), + ("BRA001", "ALFALFA", 5.0, numpy.nan), + ("BRA001", "BEEF_CATTLE", 1.0, 100.0), + ("BRA001", "PASTURE", 1.0, numpy.nan), + ("BRA001", "DROPPED", 0.0, numpy.nan), + ], + columns=["admin_id", "commodity_name", "commodity_hectares", HERD_HEADS_COLUMN], + ).assign(admin_level="PROVINCIAL", methodology="JURISDICTIONAL_DIRECT_PROXY") + emissions = emissions.set_index( + ["admin_level", "admin_id", "commodity_name", "methodology"] + ) + result = derive_proxy_production_kg(crops=crops, emissions=emissions, stocks=stocks) + production_kg = result["production_kg"].droplevel(["admin_level", "methodology"]) + assert production_kg[("BRA001", "MAIZE")] == pytest.approx(120.0) + assert production_kg[("BRA002", "MAIZE")] == pytest.approx(40.0) + assert production_kg[("BRA001", "POPCORN")] == pytest.approx(40.0) + assert production_kg[("BRA001", "BEEF_CATTLE")] == pytest.approx(1050.0) + assert ( + production_kg[[("BRA001", name) for name in ("ALFALFA", "PASTURE", "DROPPED")]] + .isna() + .all() + ) + assert HERD_HEADS_COLUMN not in result.columns + assert "('BRA', 236)" in caplog.text diff --git a/jdluc/jurisdictional_direct_proxy.py b/jdluc/jurisdictional_direct_proxy.py index e4c01ff..56dbe3d 100644 --- a/jdluc/jurisdictional_direct_proxy.py +++ b/jdluc/jurisdictional_direct_proxy.py @@ -30,6 +30,7 @@ } DATASET_NAMES = (DatasetName.GPW_LIVESTOCK, DatasetName.TANG_CROPGRIDS_CROP_AREA) +HERD_HEADS_COLUMN = statistical.get_herd_heads_column(year=tang_cropgrids.YEAR) def get_name_to_band(dset: xarray.Dataset) -> dict[str, xarray.DataArray]: @@ -202,6 +203,18 @@ def get_commodity_name_to_totals( totals["peatland_occupation_emissions_mt"] = occupation totals["emissions_mt"] = sum(component_to_emissions.values(), start=occupation) + # As in `statistical`, the whole herd of every cell with any pasture + livestock_to_heads = { + livestock: dset[ + gpw_livestock.get_band_name(species=species, year=tang_cropgrids.YEAR) + ].where(dset[f"pasture:fraction:{tang_cropgrids.YEAR:d}"] > 0, other=0) + * hectares + for livestock, (species,) in statistical.LIVESTOCK_TO_SPECIES.items() + if livestock in commodities + } + for livestock, heads in livestock_to_heads.items(): + commodity_to_totals[livestock][HERD_HEADS_COLUMN] = heads + commodity_to_totals[emit.NonCommodity.DROPPED]["emissions_mt"] = ( dset["dropped-emissions:tco2e-per-ha"] * hectares ) @@ -234,7 +247,12 @@ def get_commodity_name_to_totals( start=unattributed["peatland_occupation_emissions_mt"], ) - return utils.get_sum_totals(enum_to_name_to_darray=commodity_to_totals) + return { + name: {HERD_HEADS_COLUMN: numpy.nan} | totals + for name, totals in utils.get_sum_totals( + enum_to_name_to_darray=commodity_to_totals + ).items() + } SCHEMA = { @@ -249,6 +267,7 @@ def get_commodity_name_to_totals( "peatland_commodity_hectares": float, "peatland_conversion_emissions_mt": float, "peatland_occupation_emissions_mt": float, + HERD_HEADS_COLUMN: float, } diff --git a/jdluc/taxonomy.py b/jdluc/taxonomy.py index 0606a57..b79911f 100644 --- a/jdluc/taxonomy.py +++ b/jdluc/taxonomy.py @@ -375,3 +375,21 @@ class InventedCommodityId(enum.IntEnum): attribute.Methodology.JURISDICTIONAL_DIRECT ] ) + +# FAOSTAT crop items no CROPGRIDS crop maps to: co-products, counted in the crop they come from +CO_PRODUCT_ITEM_CODE_TO_ITEM_CODE = { + 256: 254, # palm kernels + 311: 310, # kapokseed + 329: 328, # cotton seed + 378: 125, # cassava leaves + 767: 328, # cotton lint + 778: 310, # kapok fibre + 813: 249, # coir +} +assert set(CO_PRODUCT_ITEM_CODE_TO_ITEM_CODE.values()) <= set( + METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[ + attribute.Methodology.JURISDICTIONAL_DIRECT_PROXY + ].values() +) +# The rest: crops CROPGRIDS does not map (jojoba, tallowtree, leeks and other pome fruits) +UNMAPPED_CROP_ITEM_CODES = {277, 305, 407, 542} diff --git a/jdluc/trace.py b/jdluc/trace.py index 6d234e9..4a3a559 100644 --- a/jdluc/trace.py +++ b/jdluc/trace.py @@ -5,8 +5,11 @@ - JURISDICTIONAL_DIRECT: production = crop area x NASS QuickStats yield (4-year mean). - STATISTICAL: production = MAPSPAM production for crops, carried straight through the attribute, and the attribute's GPW heads x FAOSTAT carcass weight per head for livestock. + - JURISDICTIONAL_DIRECT_PROXY: production = FAOSTAT national production split among provinces + by CROPGRIDS crop area for crops, and the attribute's 2020 GPW heads x FAOSTAT carcass weight + per head for livestock. -It then derives, identically for both, each commodity's yield (kg per hectare) and emissions +It then derives, identically for all three, each commodity's yield (kg per hectare) and emissions factor (kgCO2e per kg), and rolls the provincials up to national totals. Returns a cached pandas.DataFrame indexed by (admin_level, admin_id, commodity_id, methodology), where `commodity_id` is a FAOSTAT item code or a `taxonomy.InventedCommodityId`, and the `commodity_name` @@ -25,15 +28,31 @@ import logging import typing +import numpy import pandas -from jdluc import attribute, emit, statistical, storage, taxonomy -from jdluc.datasets import faostat, usda_nass_quickstats, worldbank_jurisdictions +from jdluc import ( + attribute, + emit, + jurisdictional_direct_proxy, + statistical, + storage, + taxonomy, +) +from jdluc.datasets import ( + faostat, + gpw_livestock, + tang_cropgrids, + usda_nass_quickstats, + worldbank_jurisdictions, +) logger = logging.getLogger(__name__) NASS_YIELD_YEARS = (2017, 2018, 2019, 2020) +# The proxy leg's FAOSTAT years, centered on CROPGRIDS' +FAOSTAT_YEARS = tuple(range(tang_cropgrids.YEAR - 1, tang_cropgrids.YEAR + 2)) KG_PER_TONNE = 1000 CANONICAL_KEY = ("admin_level", "admin_id", "commodity_id", "methodology") ADDITIVE_COLUMNS = ( @@ -69,6 +88,34 @@ def derive_jurisdictional_production_kg( ) +def get_herd_production_kg( + heads: pandas.Series, + species: gpw_livestock.Species, + stocks: pandas.DataFrame, + years: tuple[int, ...], +) -> numpy.ndarray: + """Heads times FAOSTAT's national carcass weight per head: mean meat over mean stocks across + `years`. No meat, or no herd in FAOSTAT, prices at 0. + """ + livestock = stocks.reset_index() + means = ( + livestock[livestock["year"].isin(years)] + .groupby(["admin_id", "commodity_name"])[["production_kg", "stocks_head"]] + .mean() + ) + kg_per_head = (means["production_kg"] / means["stocks_head"]).fillna(0) + iso_3166s = heads.index.get_level_values("admin_id").str[:3] + return ( + heads.to_numpy() + * kg_per_head.reindex( + fill_value=0, + index=pandas.MultiIndex.from_arrays( + [iso_3166s, [species.name] * len(iso_3166s)] + ), + ).to_numpy() + ) + + def derive_statistical_production_kg( emissions: pandas.DataFrame, stocks: pandas.DataFrame ) -> pandas.DataFrame: @@ -76,42 +123,108 @@ def derive_statistical_production_kg( each snapshot times FAOSTAT's national carcass weight per standing head that year, reduced over the window with the weights `statistical.get_discounted_snapshot_mean` gives a crop's. """ - livestock = stocks.reset_index() - # No meat in a year, or no herd in FAOSTAT, prices at 0 - kg_per_head = ( - (livestock["production_kg"] / livestock["stocks_head"]) - .fillna(0) - .set_axis( - pandas.MultiIndex.from_frame( - livestock[["admin_id", "commodity_name", "year"]] - ) - ) - ) ret = emissions.copy() ret["production_kg"] = ret["production_mt"] * KG_PER_TONNE - for livestock_commodity, (species,) in statistical.LIVESTOCK_TO_SPECIES.items(): - is_herd = ( - ret.index.get_level_values("commodity_name") == livestock_commodity.name - ) - iso_3166s = ret.index.get_level_values("admin_id")[is_herd].str[:3] + for livestock, (species,) in statistical.LIVESTOCK_TO_SPECIES.items(): + is_herd = ret.index.get_level_values("commodity_name") == livestock.name ret.loc[is_herd, "production_kg"] = sum( weight - * ret.loc[is_herd, statistical.get_herd_heads_column(year=year)] - * kg_per_head.reindex( - fill_value=0, - index=pandas.MultiIndex.from_arrays( - [ - iso_3166s, - [species.name] * len(iso_3166s), - [year] * len(iso_3166s), - ] - ), - ).to_numpy() + * get_herd_production_kg( + heads=ret.loc[is_herd, statistical.get_herd_heads_column(year=year)], + species=species, + stocks=stocks, + years=(year,), + ) for year, weight in statistical.SNAPSHOT_YEAR_TO_WEIGHT.items() ) return ret.drop(columns=["production_mt", *statistical.HERD_HEADS_COLUMNS]) +def derive_proxy_production_kg( + crops: pandas.DataFrame, emissions: pandas.DataFrame, stocks: pandas.DataFrame +) -> pandas.DataFrame: + """A crop's production is FAOSTAT's national production of its item over `FAOSTAT_YEARS`, split + among provinces by CROPGRIDS crop area. A herd's is its heads times FAOSTAT's carcass weight per + head. Rows with no FAOSTAT item have none. + """ + commodity_name_to_commodity_id = ( + taxonomy.METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[ + attribute.Methodology.JURISDICTIONAL_DIRECT_PROXY + ] + ) + crop_item_codes = { + commodity_name_to_commodity_id[crop.name] + for crop in tang_cropgrids.Crop + if commodity_name_to_commodity_id[crop.name] > 0 + } + table = crops.reset_index() + # Every crop item is mapped or left out on purpose + assert ( + set(table["commodity_id"]) + == crop_item_codes + | set(taxonomy.CO_PRODUCT_ITEM_CODE_TO_ITEM_CODE) + | taxonomy.UNMAPPED_CROP_ITEM_CODES + ) + national_production = ( + table[table["year"].isin(FAOSTAT_YEARS)] + .groupby(["admin_id", "commodity_id"])["production_kg"] + .mean() + ) + + flat = emissions.reset_index() + flat["commodity_id"] = [ + commodity_name_to_commodity_id[commodity_name] + for commodity_name in flat["commodity_name"] + ] + flat["iso_3166"] = flat["admin_id"].str[:3] + national_hectares = ( + flat.groupby(["iso_3166", "commodity_id"])["commodity_hectares"] + .sum() + .rename_axis(index=["admin_id", "commodity_id"]) + ) + row_keys = pandas.MultiIndex.from_frame( + flat[["iso_3166", "commodity_id"]], names=["admin_id", "commodity_id"] + ) + item_hectares = national_hectares.reindex(row_keys) + flat["production_kg"] = ( + national_production.reindex(row_keys).to_numpy() + * flat["commodity_hectares"].to_numpy() + / item_hectares.where(item_hectares > 0).to_numpy() + ) + + # FAOSTAT production with no CROPGRIDS area to split it by + traced = national_production[ + national_production.index.get_level_values("admin_id").isin( + set(flat["iso_3166"]) + ) + & ~national_production.index.get_level_values("commodity_id").isin( + list(taxonomy.CO_PRODUCT_ITEM_CODE_TO_ITEM_CODE) + ) + ] + unplaced = traced[ + national_hectares.reindex(traced.index, fill_value=0).to_numpy() == 0 + ] + if unplaced.sum() > 0: + logger.warning( + f"{len(unplaced):d} FAOSTAT (country, item) production(s), " + f"{unplaced.sum() / KG_PER_TONNE**2:.3f} Mt in all, have no CROPGRIDS area to " + f"split them by: {sorted(unplaced.index)}" + ) + + ret = flat.drop(columns=["commodity_id", "iso_3166"]).set_index( + emissions.index.names + ) + for livestock, (species,) in statistical.LIVESTOCK_TO_SPECIES.items(): + is_herd = ret.index.get_level_values("commodity_name") == livestock.name + ret.loc[is_herd, "production_kg"] = get_herd_production_kg( + heads=ret.loc[is_herd, jurisdictional_direct_proxy.HERD_HEADS_COLUMN], + species=species, + stocks=stocks, + years=FAOSTAT_YEARS, + ) + return ret.drop(columns=jurisdictional_direct_proxy.HERD_HEADS_COLUMN) + + def with_commodity_ids( df: pandas.DataFrame, methodology: attribute.Methodology ) -> pandas.DataFrame: @@ -213,16 +326,24 @@ def workflow( "commodity_name", "methodology", ] - production = ( - derive_jurisdictional_production_kg( - emissions=emissions, raw_yields=usda_nass_quickstats.load() - ) - if methodology == attribute.Methodology.JURISDICTIONAL_DIRECT - else derive_statistical_production_kg( - emissions=emissions, - stocks=faostat.load(dataset=faostat.LIVESTOCK_DATASET), - ) - ) + match methodology: + case attribute.Methodology.JURISDICTIONAL_DIRECT: + production = derive_jurisdictional_production_kg( + emissions=emissions, raw_yields=usda_nass_quickstats.load() + ) + case attribute.Methodology.JURISDICTIONAL_DIRECT_PROXY: + production = derive_proxy_production_kg( + crops=faostat.load(dataset=faostat.CROP_DATASET), + emissions=emissions, + stocks=faostat.load(dataset=faostat.LIVESTOCK_DATASET), + ) + case attribute.Methodology.STATISTICAL: + production = derive_statistical_production_kg( + emissions=emissions, + stocks=faostat.load(dataset=faostat.LIVESTOCK_DATASET), + ) + case _: + raise ValueError(methodology) provincials = attach_ratios( df=with_commodity_ids(df=production, methodology=methodology) ) diff --git a/validation/__tests__/schema_test.py b/validation/__tests__/schema_test.py index b972424..1c9ca8a 100644 --- a/validation/__tests__/schema_test.py +++ b/validation/__tests__/schema_test.py @@ -42,7 +42,7 @@ def test_every_methodology_is_named_here() -> None: assert {schema.STATISTICAL, schema.JURISDICTIONAL_DIRECT} == { methodology.name for methodology in attribute.Methodology - # Reports no production, so it has no emissions factor to compare + # Not captured yet, so it is in no comparison if methodology != attribute.Methodology.JURISDICTIONAL_DIRECT_PROXY } From 557cff117c1d255866f7f3dbe87143876c2ea280 Mon Sep 17 00:00:00 2001 From: Jeffrey Seifried Date: Fri, 6 Nov 2026 10:21:10 -0800 Subject: [PATCH 5/7] simplify tests --- jdluc/__tests__/datasets/faostat_test.py | 140 ++++++----------------- jdluc/__tests__/taxonomy_test.py | 21 ---- jdluc/__tests__/trace_test.py | 25 +--- 3 files changed, 40 insertions(+), 146 deletions(-) diff --git a/jdluc/__tests__/datasets/faostat_test.py b/jdluc/__tests__/datasets/faostat_test.py index 800f0e9..becbb47 100644 --- a/jdluc/__tests__/datasets/faostat_test.py +++ b/jdluc/__tests__/datasets/faostat_test.py @@ -87,126 +87,60 @@ def test_get_species_to_livestock_units_per_head( ) == pytest.approx(expected) +AREA = faostat.AREA_HARVESTED_ELEMENT_CODE +PRODUCTION = faostat.PRODUCTION_ELEMENT_CODE + + def get_bulk_row( - cpc_code: str, element_code: int, flag: str, item_code: str, value: str + cpc_code: str, element_code: int, flag: str, item_code: int ) -> dict[str, str]: - """A row of the Production archive, for Brazil in 2020.""" + """A row of the Production archive, for Brazil in 2020: 10 ha harvested or 3 t produced.""" return { "Area Code (M49)": "'076", "Area": "Brazil", - "Item Code": item_code, + "Item Code": str(item_code), "Item Code (CPC)": cpc_code, - "Item": f"item {item_code:s}", + "Item": f"item {item_code:d}", "Element Code": str(element_code), "Year": "2020", - "Value": value, + "Value": {AREA: "10", PRODUCTION: "3"}[element_code], "Flag": flag, } -AREA = faostat.AREA_HARVESTED_ELEMENT_CODE -PRODUCTION = faostat.PRODUCTION_ELEMENT_CODE - - -@pytest.mark.parametrize( - ("rows", "expected"), - ( - pytest.param( - [ - get_bulk_row( - cpc_code="'0112", - element_code=AREA, - flag="A", - item_code="56", - value="10", - ), - get_bulk_row( - cpc_code="'0112", - element_code=PRODUCTION, - flag="A", - item_code="56", - value="3", - ), - ], - [(56, 10.0, 3000.0)], - id="a-crop-carries-area-and-production-in-kg", +def test_get_crop_records() -> None: + rows = [ + # Maize carries area and production, in kg + get_bulk_row(cpc_code="'0112", element_code=AREA, flag="A", item_code=56), + get_bulk_row(cpc_code="'0112", element_code=PRODUCTION, flag="A", item_code=56), + # Brazil nuts report no area, which reads NaN + get_bulk_row( + cpc_code="'01377", element_code=PRODUCTION, flag="A", item_code=216 ), - pytest.param( - [ - get_bulk_row( - cpc_code="'01377", - element_code=PRODUCTION, - flag="A", - item_code="216", - value="3", - ) - ], - [(216, math.nan, 3000.0)], - id="a-crop-with-no-area-reads-nan", + # Barley reports no production, and chick peas' is missing: both are dropped + get_bulk_row(cpc_code="'0115", element_code=AREA, flag="A", item_code=44), + get_bulk_row(cpc_code="'01703", element_code=AREA, flag="A", item_code=191), + get_bulk_row( + cpc_code="'01703", + element_code=PRODUCTION, + flag=faostat.MISSING_FLAG, + item_code=191, ), - pytest.param( - [ - get_bulk_row( - cpc_code="'0112", - element_code=AREA, - flag="A", - item_code="56", - value="10", - ) - ], - [], - id="a-crop-with-no-production-is-dropped", + # Livestock and aggregates are not crops + get_bulk_row( + cpc_code="'21111.01", element_code=PRODUCTION, flag="A", item_code=867 ), - pytest.param( - [ - get_bulk_row( - cpc_code="'0112", - element_code=AREA, - flag="A", - item_code="56", - value="10", - ), - get_bulk_row( - cpc_code="'0112", - element_code=PRODUCTION, - flag=faostat.MISSING_FLAG, - item_code="56", - value="3", - ), - ], - [], - id="a-missing-value-counts-as-none", - ), - pytest.param( - [ - get_bulk_row( - cpc_code="'21111.01", - element_code=PRODUCTION, - flag="A", - item_code="867", - value="3", - ), - get_bulk_row( - cpc_code="F1717", - element_code=PRODUCTION, - flag="A", - item_code="1717", - value="3", - ), - ], - [], - id="livestock-and-aggregates-are-not-crops", + get_bulk_row( + cpc_code="F1717", element_code=PRODUCTION, flag="A", item_code=1717 ), - ), -) -def test_get_crop_records( - rows: list[dict[str, str]], expected: list[tuple[int, float, float]] -) -> None: - records = faostat.get_crop_records(rows=rows) + ] column_names = ["commodity_id", "area_hectares", "production_kg"] pandas.testing.assert_frame_equal( - pandas.DataFrame.from_records(columns=column_names, data=records), - pandas.DataFrame.from_records(columns=column_names, data=expected), + pandas.DataFrame.from_records( + columns=column_names, data=faostat.get_crop_records(rows=rows) + ), + pandas.DataFrame.from_records( + columns=column_names, data=[(56, 10.0, 3000.0), (216, math.nan, 3000.0)] + ), check_dtype=False, ) - assert all(record["admin_id"] == "BRA" for record in records) diff --git a/jdluc/__tests__/taxonomy_test.py b/jdluc/__tests__/taxonomy_test.py index 5ebd2d6..b41052c 100644 --- a/jdluc/__tests__/taxonomy_test.py +++ b/jdluc/__tests__/taxonomy_test.py @@ -39,24 +39,3 @@ def test_proxy_codes_match_statistical_codes(crop: tang_cropgrids.Crop) -> None: PROXY_NAME_TO_COMMODITY_ID[crop.name] == STATISTICAL_NAME_TO_COMMODITY_ID[crop.name] ) - - -def test_only_kapok_and_maize_share_a_proxy_code() -> None: - commodity_id_to_crop_names = { - commodity_id: { - crop.name - for crop in tang_cropgrids.Crop - if PROXY_NAME_TO_COMMODITY_ID[crop.name] == commodity_id - } - for commodity_id in { - PROXY_NAME_TO_COMMODITY_ID[crop.name] for crop in tang_cropgrids.Crop - } - } - assert { - commodity_id: crop_names - for commodity_id, crop_names in commodity_id_to_crop_names.items() - if len(crop_names) > 1 - } == { - 56: {tang_cropgrids.Crop.MAIZE.name, tang_cropgrids.Crop.POPCORN.name}, - 310: {tang_cropgrids.Crop.KAPOKFIBER.name, tang_cropgrids.Crop.KAPOKSEED.name}, - } diff --git a/jdluc/__tests__/trace_test.py b/jdluc/__tests__/trace_test.py index 1ec169d..8176125 100644 --- a/jdluc/__tests__/trace_test.py +++ b/jdluc/__tests__/trace_test.py @@ -11,14 +11,12 @@ CO_PRODUCT_ITEM_CODE_TO_ITEM_CODE, METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID, UNMAPPED_CROP_ITEM_CODES, - InventedCommodityId, ) from jdluc.trace import ( attach_ratios, derive_jurisdictional_production_kg, derive_proxy_production_kg, derive_statistical_production_kg, - get_commodity_id_to_name, iter_national_from_provincials, with_commodity_ids, ) @@ -519,27 +517,11 @@ def test_with_commodity_ids_keeps_a_missing_yield_missing() -> None: ) -def test_get_commodity_id_to_name_takes_crops_from_the_table_and_the_rest_from_taxonomy() -> ( - None -): - crops = pandas.DataFrame( - { - "commodity_id": [56, 56, 236], - "commodity_name": ["Maize (corn)", "Maize (corn)", "Soya beans"], - "year": [2019, 2020, 2020], - } - ).set_index(["commodity_id", "year"]) - commodity_id_to_name = get_commodity_id_to_name(crops=crops) - assert commodity_id_to_name[56] == "Maize (corn)" - assert commodity_id_to_name[236] == "Soya beans" - assert commodity_id_to_name[InventedCommodityId.DROPPED] == "DROPPED" - assert commodity_id_to_name[InventedCommodityId.ALFALFA] == "Alfalfa" - - -def test_derive_proxy_production_kg(caplog: pytest.LogCaptureFixture) -> None: +def test_derive_proxy_production_kg() -> None: """Brazil's maize is FAOSTAT's 2019-2021 mean of 200 kg, split over 50 ha of maize and popcorn, which share its item. Its cattle weigh FAOSTAT's 1050 kg of meat a year over 100 head, with - 2021's meat unreported and 2018 outside the years. Its soya beans have no CROPGRIDS area. + 2021's meat unreported and 2018 outside the years. Its soya beans have no CROPGRIDS area, so no + row takes them. """ proxy_name_to_commodity_id = METHODOLOGY_TO_COMMODITY_NAME_TO_COMMODITY_ID[ Methodology.JURISDICTIONAL_DIRECT_PROXY @@ -627,4 +609,3 @@ def test_derive_proxy_production_kg(caplog: pytest.LogCaptureFixture) -> None: .all() ) assert HERD_HEADS_COLUMN not in result.columns - assert "('BRA', 236)" in caplog.text From a94f6219fef0a32340b42b37c0c7177b5393c7a0 Mon Sep 17 00:00:00 2001 From: Jeffrey Seifried Date: Fri, 6 Nov 2026 10:27:20 -0800 Subject: [PATCH 6/7] update docs --- docs/architecture.md | 2 +- docs/coverage.md | 27 +++++++++++++++++++++++++ docs/data.md | 42 +++++++++++++++++++-------------------- docs/executive_summary.md | 18 ++++++++--------- docs/further_research.md | 2 +- docs/methodology.md | 12 ++++++----- docs/validation.md | 2 +- 7 files changed, 67 insertions(+), 38 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6c6592b..cdd454b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ flowchart LR - **Harmonize** — align the ingested sources onto a common grid, one 10° tile at a time, yielding one lazy `xarray.Dataset` per tile backed by a zarr. - **Emit** — compute per-pixel land conversion events and emissions as a lazy dask/xarray graph and persist to zarr. - **Attribute** — clip per-pixel emissions to jurisdiction polygons and crop masks, rolling up to per-(jurisdiction, crop) totals via the jurisdictional-direct, statistical or jurisdictional-direct proxy leg. -- **Trace** — join the attribution rollups to production (NASS yields for the direct leg, MapSPAM for the statistical leg) to produce the final emissions-factor table. The proxy leg has no production yet. +- **Trace** — join the attribution rollups to production (NASS yields for the direct leg, MapSPAM for the statistical leg, FAOSTAT's national production split by CROPGRIDS crop area for the proxy leg) to produce the final emissions-factor table. ## Compute platform and libraries diff --git a/docs/coverage.md b/docs/coverage.md index b50a554..f478309 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -303,3 +303,30 @@ The jurisdictional-direct pipeline (USA only) covers the following 11 crops, map | SUGARBEET | SUGARBEETS | 157 (Sugar beet) | | SUGARCANE | SUGARCANE | 156 (Sugar cane) | | WHEAT | DURUM_WHEAT, SPRING_WHEAT, WINTER_WHEAT | 15 (Wheat) | + +### Jurisdictional direct proxy + +The proxy pipeline covers all 173 CROPGRIDS crops, each published under a FAOSTAT item with FAOSTAT's name for it. Most follow the CROPGRIDS publisher's crop table (`CROPS_matching_v17.xlsx`). Where that table names no item, or a retired one, FAOSTAT's current list gives flax (771), green broad beans (420) and jute-like fibres (782), and our judgement gives pimento (689, dry chillies and peppers) and popcorn (56, with maize). Several crops may share an item: maize and popcorn are both 56, and kapok fibre and kapok seed both 310. The full crosswalk is the proxy leg's entry in `jdluc/taxonomy.py`. Livestock is as in the statistical leg. + +FAOSTAT has no item for the 16 fodder crops, so each takes a code of our own and the name the CROPGRIDS publisher gives it. They carry emissions and area, but no production and so no emissions factor. + +| Crop | Published as | +| ------------ | -------------------------------------- | +| ALFALFA | -101 (Alfalfa) | +| BEETFOR | -102 (Beets for fodder) | +| CABBAGEFOR | -103 (Cabbage for fodder) | +| CARROTFOR | -104 (Carrots for fodder) | +| CLOVER | -105 (Clover) | +| FORNES | -106 (Forage products, other) | +| GRASSNES | -107 (Grasses, other) | +| LEGUMENES | -108 (Legumes, other) | +| MAIZEFOR | -109 (Maize for forage and silage) | +| MIXEDGRASS | -110 (Mixed grasses and legumes) | +| OILSEEDFOR | -111 (Green oilseeds for fodder) | +| RYEFOR | -112 (Rye grass for forage and silage) | +| SORGHUMFOR | -113 (Sorghum for forage and silage) | +| SWEDEFOR | -114 (Swedes for fodder) | +| TURNIPFOR | -115 (Turnips for fodder) | +| VEGETABLENES | -116 (Vegetables and roots for fodder) | + +Eleven FAOSTAT crop items have no CROPGRIDS crop. Seven are co-products counted in the crop they come from: palm kernels, kapokseed, kapok fibre, cotton seed, cotton lint, cassava leaves and coir. Four are crops CROPGRIDS does not map, whose production has no area to be split by and is left out: jojoba seeds, tallowtree seeds, leeks and other alliaceous vegetables, and other pome fruits. diff --git a/docs/data.md b/docs/data.md index 9e8ae16..e860fed 100644 --- a/docs/data.md +++ b/docs/data.md @@ -83,26 +83,26 @@ emissions = xarray.open_zarr("/.zarr", consolidated=Fal Deposited per data version as `emissions-factors.parquet` (see [Access](#access)). -The final per-(jurisdiction, commodity) table produced by the `trace` stage. Indexed by `(admin_level, admin_id, commodity_id, methodology)`, where `admin_level` is `PROVINCIAL` (World Bank admin-1) or `NATIONAL` (admin-0, summed from provincial rows), `methodology` is `JURISDICTIONAL_DIRECT` or `STATISTICAL`, and `commodity_id` is an integer: the FAOSTAT item code where FAOSTAT has an item, and a negative code of our own where it does not. `commodity_name` holds FAOSTAT's item name (`Maize (corn)`, `Soya beans`), or our own name for a negative code. Both legs use the same codes, so the same crop has the same `commodity_id` in each. Where MapSPAM splits one FAOSTAT item in two, the statistical leg publishes one row: arabica and robusta coffee add together as 656 (`Coffee, green`), and pearl and small millet as 79 (`Millet`). The statistical leg's `OTHER_OILCROPS` (-201) and `OTHER_PULSES` (-202) are MapSPAM groups of several FAOSTAT items, so they take codes of our own. [`coverage.md`](coverage.md#crops) lists each leg's crops with their codes. - -Four rows are not crops. Beef, 867 (`Meat of cattle with the bone, fresh or chilled`), is a livestock commodity: it carries its share of pasture's emissions and area, and production like a crop's. `PASTURE` (-3) keeps the rest of pasture's share, with emissions and area but no production. `DROPPED` (-1) and `UNATTRIBUTED` (-2) appear in both legs and are not commodities at all. `UNATTRIBUTED` holds emissions that count toward the jurisdiction's total but that none of the commodities we model takes, so the commodity rows plus `UNATTRIBUTED` add up to that total. `DROPPED` holds carbon released where, as far as our maps show, the land became neither cropland nor pasture; it does not count toward the total and comes on top of it. `PASTURE`, `DROPPED` and `UNATTRIBUTED` have no production figure, so none of them has an emissions factor. See "Rows that name no commodity" in [`methodology.md`](methodology.md). - -| Column | Type | Units | Description | -| ---------------------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------- | -| `jurisdiction_name` | string | — | Display name (e.g. `United States of America \| Iowa`) | -| `commodity_name` | string | — | FAOSTAT's item name for a positive `commodity_id`, our own name for a negative one | -| `commodity_hectares` | float64 | ha | Area the commodity occupies (land occupation), on production's discounted 2000–2020 window | -| `peatland_commodity_hectares` | float64 | ha | Commodity area on peatland | -| `emissions_mt` | float64 | t CO₂e | Total allocated LUC emissions | -| `peatland_occupation_emissions_mt` | float64 | t CO₂e | Annual peatland-occupation emissions on the commodity's pixels | -| `forest_emissions_mt` | float64 | t CO₂e | Forest-conversion emissions | -| `grassland_emissions_mt` | float64 | t CO₂e | Grassland-conversion emissions | -| `peatland_conversion_emissions_mt` | float64 | t CO₂e | Peatland-conversion (drainage-pulse) emissions | -| `production_kg` | float64 | kg | Production: crops from NASS yield × area (direct leg) or MapSPAM (statistical leg); livestock as FAOSTAT carcass weight, bone in | -| `yield_kg_per_ha` | float64 | kg/ha | `production_kg / commodity_hectares` | -| `emissions_factor_kgco2e_per_kg` | float64 | kg CO₂e / kg | `1000 × emissions_mt / production_kg` — the headline factor | - -Both attribution legs emit the same columns. +The final per-(jurisdiction, commodity) table produced by the `trace` stage. Indexed by `(admin_level, admin_id, commodity_id, methodology)`, where `admin_level` is `PROVINCIAL` (World Bank admin-1) or `NATIONAL` (admin-0, summed from provincial rows), `methodology` is `JURISDICTIONAL_DIRECT`, `STATISTICAL` or `JURISDICTIONAL_DIRECT_PROXY`, and `commodity_id` is an integer: the FAOSTAT item code where FAOSTAT has an item, and a negative code of our own where it does not. `commodity_name` holds FAOSTAT's item name (`Maize (corn)`, `Soya beans`), or our own name for a negative code. All three legs use the same codes, so the same crop has the same `commodity_id` in each. Where MapSPAM splits one FAOSTAT item in two, the statistical leg publishes one row: arabica and robusta coffee add together as 656 (`Coffee, green`), and pearl and small millet as 79 (`Millet`). The statistical leg's `OTHER_OILCROPS` (-201) and `OTHER_PULSES` (-202) are MapSPAM groups of several FAOSTAT items, so they take codes of our own, as do the proxy leg's 16 fodder crops (-101 to -116), which FAOSTAT has no item for. [`coverage.md`](coverage.md#crops) lists each leg's crops with their codes. + +Four rows are not crops. Beef, 867 (`Meat of cattle with the bone, fresh or chilled`), is a livestock commodity: it carries its share of pasture's emissions and area, and production like a crop's. `PASTURE` (-3) keeps the rest of pasture's share, with emissions and area but no production. `DROPPED` (-1) and `UNATTRIBUTED` (-2) appear in every leg and are not commodities at all. `UNATTRIBUTED` holds emissions that count toward the jurisdiction's total but that none of the commodities we model takes, so the commodity rows plus `UNATTRIBUTED` add up to that total. `DROPPED` holds carbon released where, as far as our maps show, the land became neither cropland nor pasture; it does not count toward the total and comes on top of it. `PASTURE`, `DROPPED`, `UNATTRIBUTED` and the fodder crops have no production figure, so none of them has an emissions factor. See "Rows that name no commodity" in [`methodology.md`](methodology.md). + +| Column | Type | Units | Description | +| ---------------------------------- | ------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `jurisdiction_name` | string | — | Display name (e.g. `United States of America \| Iowa`) | +| `commodity_name` | string | — | FAOSTAT's item name for a positive `commodity_id`, our own name for a negative one | +| `commodity_hectares` | float64 | ha | Area the commodity occupies (land occupation), on production's discounted 2000–2020 window; CROPGRIDS' 2020 area for the proxy leg | +| `peatland_commodity_hectares` | float64 | ha | Commodity area on peatland | +| `emissions_mt` | float64 | t CO₂e | Total allocated LUC emissions | +| `peatland_occupation_emissions_mt` | float64 | t CO₂e | Annual peatland-occupation emissions on the commodity's pixels | +| `forest_emissions_mt` | float64 | t CO₂e | Forest-conversion emissions | +| `grassland_emissions_mt` | float64 | t CO₂e | Grassland-conversion emissions | +| `peatland_conversion_emissions_mt` | float64 | t CO₂e | Peatland-conversion (drainage-pulse) emissions | +| `production_kg` | float64 | kg | Production: crops from NASS yield × area (direct leg), MapSPAM (statistical leg) or FAOSTAT split by CROPGRIDS area (proxy leg); livestock as FAOSTAT carcass weight, bone in | +| `yield_kg_per_ha` | float64 | kg/ha | `production_kg / commodity_hectares` | +| `emissions_factor_kgco2e_per_kg` | float64 | kg CO₂e / kg | `1000 × emissions_mt / production_kg` — the headline factor | + +All three legs emit the same columns. ```python import pandas @@ -110,4 +110,4 @@ import pandas emission_factors = pandas.read_parquet("emissions-factors.parquet") ``` -The `attribute` stage writes an intermediate rollup parquet indexed by `(admin_level, admin_id, commodity_name, methodology)`, where `commodity_name` is the leg's own name for the commodity (a CDL crop for the direct leg, a MapSPAM crop for the statistical leg, `BEEF_CATTLE` for beef), with the non-derived subset of these columns (`commodity_hectares`, `peatland_commodity_hectares`, `emissions_mt`, `peatland_occupation_emissions_mt`, `forest_emissions_mt`, `grassland_emissions_mt`, `peatland_conversion_emissions_mt`, and — statistical only — `production_mt` and the herd's heads at each MapSPAM snapshot, `herd_heads_2000` to `herd_heads_2020`); `trace` maps those names onto `commodity_id`, adding together the rows that share one, joins production and appends the ratio columns (`production_kg`, `yield_kg_per_ha`, `emissions_factor_kgco2e_per_kg`). +The `attribute` stage writes an intermediate rollup parquet indexed by `(admin_level, admin_id, commodity_name, methodology)`, where `commodity_name` is the leg's own name for the commodity (a CDL crop for the direct leg, a MapSPAM crop for the statistical leg, a CROPGRIDS crop for the proxy leg, `BEEF_CATTLE` for beef), with the non-derived subset of these columns (`commodity_hectares`, `peatland_commodity_hectares`, `emissions_mt`, `peatland_occupation_emissions_mt`, `forest_emissions_mt`, `grassland_emissions_mt`, `peatland_conversion_emissions_mt`, and the herd's heads: for the statistical leg `production_mt` and the heads at each MapSPAM snapshot, `herd_heads_2000` to `herd_heads_2020`, and for the proxy leg the heads in 2020, `herd_heads_2020`); `trace` maps those names onto `commodity_id`, adding together the rows that share one, joins production and appends the ratio columns (`production_kg`, `yield_kg_per_ha`, `emissions_factor_kgco2e_per_kg`). diff --git a/docs/executive_summary.md b/docs/executive_summary.md index 3d77760..b420603 100644 --- a/docs/executive_summary.md +++ b/docs/executive_summary.md @@ -19,15 +19,15 @@ This document is aimed at readers assessing how the methodology maps to the LSRS ## Data output -The published deliverable is a single per-(jurisdiction, commodity) table — the **emissions-factors parquet** — indexed by `(admin_level, admin_id, commodity_id, methodology)`, where `admin_level` is `NATIONAL` or `PROVINCIAL`, `admin_id` is the ISO 3166 alpha-3 code nationally and the World Bank admin-1 code provincially, `commodity_id` is the FAOSTAT item code (or a negative code of our own where FAOSTAT has none) with its name in `commodity_name`, and `methodology` is `JURISDICTIONAL_DIRECT` or `STATISTICAL`. Each row carries the headline emissions factor and the quantities behind it; both attribution legs (jdLUC and sLUC) emit the same columns. The full schema is in [`data.md`](data.md#3-emissions-factors-parquet). - -| Column | Units | What it is | -| -------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------- | -| `emissions_factor_kgco2e_per_kg` | kg CO₂e / kg | The headline factor — `1000 × emissions_mt / production_kg` | -| `emissions_mt` | t CO₂e | Total allocated LUC emissions | -| `production_kg` | kg | Production: crops from NASS × area (jdLUC) or MapSPAM (sLUC); livestock as FAOSTAT carcass weight, bone in | -| `commodity_hectares` | ha | Commodity area in the jurisdiction, over 2000–2020 | -| `yield_kg_per_ha` | kg/ha | `production_kg / commodity_hectares` | +The published deliverable is a single per-(jurisdiction, commodity) table — the **emissions-factors parquet** — indexed by `(admin_level, admin_id, commodity_id, methodology)`, where `admin_level` is `NATIONAL` or `PROVINCIAL`, `admin_id` is the ISO 3166 alpha-3 code nationally and the World Bank admin-1 code provincially, `commodity_id` is the FAOSTAT item code (or a negative code of our own where FAOSTAT has none) with its name in `commodity_name`, and `methodology` is `JURISDICTIONAL_DIRECT`, `STATISTICAL` or `JURISDICTIONAL_DIRECT_PROXY`. Each row carries the headline emissions factor and the quantities behind it; all three legs emit the same columns. The full schema is in [`data.md`](data.md#3-emissions-factors-parquet). + +| Column | Units | What it is | +| -------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `emissions_factor_kgco2e_per_kg` | kg CO₂e / kg | The headline factor — `1000 × emissions_mt / production_kg` | +| `emissions_mt` | t CO₂e | Total allocated LUC emissions | +| `production_kg` | kg | Production: crops from NASS × area (jdLUC), MapSPAM (sLUC) or FAOSTAT split by CROPGRIDS area (proxy); livestock as FAOSTAT carcass weight, bone in | +| `commodity_hectares` | ha | Commodity area in the jurisdiction, over 2000–2020 | +| `yield_kg_per_ha` | kg/ha | `production_kg / commodity_hectares` | ## Known deviations and open items diff --git a/docs/further_research.md b/docs/further_research.md index 3ec6b50..ca6f5e1 100644 --- a/docs/further_research.md +++ b/docs/further_research.md @@ -231,7 +231,7 @@ The 2010 snapshot ends the 2005–2010 span and starts the expansion both later **Issue**: The jurisdictional-direct leg divides emissions by `commodity_hectares x yield_kg_per_ha`, where the yield is a USDA NASS survey statistic and the hectares come from the CDL. Every one of the eleven crops `jurisdictional_direct.Crop` models carries a yield, so every one produces an emissions factor. Three properties of how that denominator is assembled are worth recording, because each is an assumption rather than a measurement. 1. **Unmatched (admin, crop) pairs are still only a log line.** NASS suppresses small and identifying cells under Title 7 U.S.C. and CIPSEA, and does not survey every crop in every state. Where the CDL sees a crop in a state that NASS does not publish a yield for, that state contributes hectares and emissions to the national rollup but no production, so the national factor is a ratio over the reporting states alone. The count of such rows is logged and then discarded. -2. **Cotton is converted between product forms by a single constant.** NASS reports cotton yield as ginned lint, while MapSPAM, FAOSTAT and WRI all carry seed cotton — see the `CROP_TO_ITEM_CODE` comment in `faostat`, which pins item 328 over ginned lint's 767 on the grounds that either mistake scales a comparison by the milling yield. `usda_nass_quickstats.Series.reported_fraction` divides the lint yield back up to the unginned crop at 0.36. That figure is what FAOSTAT's US seed-cotton yield implies against the NASS series over 2011–2020, and it agrees with the conventional ~35% gin turnout, but the same comparison implies ~0.403 over 2000–2010. The constant is therefore tied to the window `trace.NASS_YIELD_YEARS` currently averages, and a national constant hides whatever provincial variation in gin turnout exists. +2. **Cotton is converted between product forms by a single constant.** NASS reports cotton yield as ginned lint, while MapSPAM, FAOSTAT (item 328, not lint's 767) and WRI all carry seed cotton, and either mistake scales a comparison by the milling yield. `usda_nass_quickstats.Series.reported_fraction` divides the lint yield back up to the unginned crop at 0.36. That figure is what FAOSTAT's US seed-cotton yield implies against the NASS series over 2011–2020, and it agrees with the conventional ~35% gin turnout, but the same comparison implies ~0.403 over 2000–2010. The constant is therefore tied to the window `trace.NASS_YIELD_YEARS` currently averages, and a national constant hides whatever provincial variation in gin turnout exists. 3. **Two series are not quite the crop the CDL scores.** NASS renamed its rolled-up dry-bean class in 2019 and publishes no `ALL CLASSES` to fall back on, so spanning a multi-year window takes both names; the pre-2019 aggregate includes chickpeas, which the CDL scores separately as `CHICK_PEAS`. Sugarcane publishes no `ALL UTILIZATION PRACTICES` row either, so its total is `SUGAR & SEED` and includes seed cane. **Potential impact:** Modest overall, and concentrated in one crop. Cotton is the pair whose assumption moves a published number materially: carried as lint rather than seed cotton its factor would read roughly 2.8x higher. The eight crops outside maize, soybean and wheat carry 9% of US jurisdictional-direct emissions between them, so an error in their denominators is bounded by that share. The residual coverage gap is concentrated in minor producing states for the smaller crops, so it biases a national factor by whatever those states' yields differ from the surveyed ones — likely small for maize and soybean, larger for crops NASS surveys in only a handful of states. The chickpea contamination affects half the current averaging window for one crop carrying 0.627 Mt. diff --git a/docs/methodology.md b/docs/methodology.md index 0826c07..8399524 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -12,7 +12,7 @@ The pipeline quantifies the land-use-change (LUC) emissions associated with agri - **Jurisdictional direct** — high-resolution crop maps link emissions to crops by spatial intersection. Currently **United States only** (USDA Cropland Data Layer). - **Statistical** — where only coarse crop statistics exist, emissions are attributed in proportion to each crop's share of local cropland expansion. This leg is **global** (IFPRI MapSPAM). -- **Jurisdictional direct proxy** — emissions are attributed by each crop's share of CROPGRIDS' 2020 crop area. This leg is **global**, and reports no production yet. +- **Jurisdictional direct proxy** — emissions are attributed by each crop's share of CROPGRIDS' 2020 crop area, and FAOSTAT's national production is split among provinces by the same area. This leg is **global**. All three legs follow the GHGP Land Sector and Removals Standard's 20-year linearly-discounted lookback, and all three roll up to World Bank administrative jurisdictions. @@ -97,14 +97,14 @@ Every input is ingested from its upstream publisher into cloud storage as tiled | **World Bank Official Boundaries** | Admin-0/1/2 jurisdiction polygons | World Bank | vector | | **FAOSTAT Production, livestock** | National livestock stocks and carcass-weight meat production (statistical and proxy legs) | FAO bulk download | tabular | | **FAOSTAT Livestock Patterns** | National livestock units per head of each grazer (statistical and proxy legs) | FAO bulk download | tabular | -| **FAOSTAT Production, crops** | National crop production and harvested area (validation yardstick) | FAO bulk download | tabular | +| **FAOSTAT Production, crops** | National crop production and harvested area (proxy leg's crop production; validation yardstick) | FAO bulk download | tabular | | **GLAD GLCLUC v2** | Land cover / land-use time series (2000–2020) — ingestable, not read by the emissions core | GLAD/Hansen GeoTIFFs | raster | | **GPW livestock headcount** | Annual livestock heads per hectare, 2000/2005/2010/2020 (statistical and proxy legs) | Zenodo (doi:10.5281/zenodo.17491242) | raster | | **CROPGRIDS crop area** | Physical area (not harvested area) of 173 crops in 2020, at 0.05° (jurisdictional-direct proxy leg) | Figshare (doi:10.6084/m9.figshare.22491997) | raster | -The first ten rasters feed the per-pixel emissions core. CDL, MapSPAM and CROPGRIDS feed the jurisdictional-direct, statistical and proxy legs respectively, and the statistical and proxy legs also read GPW livestock and FAOSTAT's livestock tables for their livestock commodities. NASS yields and World Bank boundaries are joined downstream when building emissions factors. +The first ten rasters feed the per-pixel emissions core. CDL, MapSPAM and CROPGRIDS feed the jurisdictional-direct, statistical and proxy legs respectively, and the statistical and proxy legs also read GPW livestock and FAOSTAT's livestock tables for their livestock commodities. NASS yields, FAOSTAT's crop table and World Bank boundaries are joined downstream when building emissions factors. -FAOSTAT's crop table and GLAD GLCLUC are ingested but feed no leg, and are listed so the inventory matches `datasets.DatasetName`. FAOSTAT's crop production is the national yardstick the validation tooling measures against — the global analogue of NASS QuickStats, and deliberately outside the attribution path so that no leg can consume the number it is measured by (see `validation.md`). Its livestock table is the exception: it prices the livestock commodities' production (see `validation.md`). GLAD GLCLUC is the land-cover series the emissions core read before the three annual layers replaced it, kept ingestable because the comparisons in `further_research.md` measure against it. GPW livestock is Global Pasture Watch's FAOSTAT-adjusted annual headcount of buffalo, cattle, goats, horses and sheep. CROPGRIDS (Tang et al. 2024) combines 27 gridded crop maps, subnational statistics for 52 countries and FAOSTAT's national totals into each crop's 2020 crop area, the land the crop covers, in hectares per cell. +GLAD GLCLUC is ingested but feeds no leg, and is listed so the inventory matches `datasets.DatasetName`. FAOSTAT's crop table gives the proxy leg its crop production, and is the national yardstick the validation tooling measures the other two legs against — the global analogue of NASS QuickStats. Validation does not capture the proxy leg, so no leg it compares consumes the number it is measured by (see `validation.md`). FAOSTAT's livestock table prices the livestock commodities' production in the statistical and proxy legs. GLAD GLCLUC is the land-cover series the emissions core read before the three annual layers replaced it, kept ingestable because the comparisons in `further_research.md` measure against it. GPW livestock is Global Pasture Watch's FAOSTAT-adjusted annual headcount of buffalo, cattle, goats, horses and sheep. CROPGRIDS (Tang et al. 2024) combines 27 gridded crop maps, subnational statistics for 52 countries and FAOSTAT's national totals into each crop's 2020 crop area, the land the crop covers, in hectares per cell. GPW grassland is native to the GLAD tile grid: its published mosaics are 0.00025° with pixel edges on whole degrees, so a ten-degree tile is a windowed read at an integer offset with no resampling. Its dominant-class band separates cultivated grassland from natural/semi-natural grassland and, new in v2, open shrubland — a split the land-cover series it replaced could not express, since those codes encode cover fraction rather than vegetation type. Reported F1 from five-fold spatially blocked cross-validation is 0.64 for cultivated and 0.76 for natural/semi-natural; both are v1 figures, and open shrubland arrives in v2-beta unvalidated. @@ -267,9 +267,11 @@ Where no reference year places the group in a pixel there is no basis for a spli Each pixel's emissions are split by the land class it became, cropland or pasture, and averaged onto the CROPGRIDS grid. In each cell, each crop takes its share of the 2020 crop area of all 173 CROPGRIDS crops of what cropland is charged, and each livestock commodity takes its 2020 grazing share, as in the statistical leg, of what pasture is charged. CROPGRIDS has one year, so every span is divided by the same shares. A crop's hectares are its CROPGRIDS crop area, and `UNATTRIBUTED` is cropland's charge in cells with no CROPGRIDS crop. +Production is joined in `trace.py`, from FAOSTAT's 2019–2021 means, centered on CROPGRIDS' year. A crop's is its FAOSTAT item's national production, split among the country's provinces by their share of the country's CROPGRIDS crop area, over every crop that shares the item. `BEEF_CATTLE`'s is the cattle GPW maps in 2020 in cells holding any cultivated grassland, times FAOSTAT's national carcass weight per standing head, as in the statistical leg. FAOSTAT production where CROPGRIDS maps none of the item in that country has no province to go to; `trace.py` logs it and leaves it out. A crop's hectares are physical area while FAOSTAT counts every harvest, so `yield_kg_per_ha` reads above FAOSTAT's yield per harvested hectare for crops grown more than once a year, rice above all. + ### From rollups to emissions factors (`trace.py`) -`trace.py` takes the attribution rollup and derives the emissions factor identically for the jurisdictional-direct and statistical methodologies — the only methodology-specific step is where production comes from (NASS yield × area for direct; for statistical, MapSPAM production for crops and GPW heads × FAOSTAT carcass weight per head for livestock). It then maps each leg's own commodity names onto FAOSTAT item codes (`jdluc/taxonomy.py`), so the legs share one vocabulary, and adds together the rows that share a code, such as MapSPAM's arabica and robusta coffee. The proxy leg has no production yet, and no codes: +`trace.py` takes the attribution rollup and derives the emissions factor identically for all three methodologies — the only methodology-specific step is where production comes from (NASS yield × area for direct; for statistical, MapSPAM production for crops and GPW heads × FAOSTAT carcass weight per head for livestock; for the proxy, as above). It then maps each leg's own commodity names onto FAOSTAT item codes (`jdluc/taxonomy.py`), so the legs share one vocabulary, and adds together the rows that share a code, such as MapSPAM's arabica and robusta coffee. Each FAOSTAT item takes its name from FAOSTAT's crop table. The emissions factor is then: ``` emissions_factor_kgco2e_per_kg = Σ emissions / Σ production diff --git a/docs/validation.md b/docs/validation.md index a870e01..47e7d98 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -53,7 +53,7 @@ The split is **by what the code is, not by what it computes**. **One artifact does not reproduce from the lock: the capture.** `efs.parquet` is a re-keyed extract of the pipeline's own parquet outputs, so it is neither committed here nor re-fetchable; producing it means re-running `validation.capture`, which takes hours of pipeline. Everything upstream of a capture reproduces in seconds — eligibility, yields, the anchor tables, and `ORBAE_OVER_WRI`, whose two sides are both external. Only the FAOSTAT half of that needs more than the lock: `prepare.read_faostat_production` reads the ingested parquet rather than a second copy of the upstream archive, so a checkout that has never run the ingest fetches it once and caches it beside the pulled anchors, with `pull.record_digest` pinning what it got. Every measure with a term of ours does not. A report carrying those numbers is reproducible only alongside the run that produced them, and `code_version` and `source_version` on every row name which run that is. -FAOSTAT **is** an ingested dataset, because it is a plausible pipeline input: national tabular production statistics keyed on jdluc's own `admin_id` and `commodity_name`, the global analogue of `usda_nass_quickstats`. WRI, EPA and Orbae stay outside `jdluc/`. Every pipeline module imports `jdluc.datasets`, so putting the yardstick there would let `statistical.py` consume the number it is measured against, with no contract preventing it. For livestock it does: `trace.py` reads FAOSTAT's livestock table to price `BEEF_CATTLE`'s production, which therefore reproduces FAOSTAT's by construction, less the herd GPW places off mapped pasture. Livestock production has no independent anchor here; the crop table stays outside the attribution path. +FAOSTAT **is** an ingested dataset, because it is a plausible pipeline input: national tabular production statistics keyed on jdluc's own `admin_id` and FAOSTAT's item code in `commodity_id`, the global analogue of `usda_nass_quickstats`. WRI, EPA and Orbae stay outside `jdluc/`. Every pipeline module imports `jdluc.datasets`, so putting the yardstick there would let `statistical.py` consume the number it is measured against, with no contract preventing it. For livestock it does: `trace.py` reads FAOSTAT's livestock table to price `BEEF_CATTLE`'s production, which therefore reproduces FAOSTAT's by construction, less the herd GPW places off mapped pasture. Livestock production has no independent anchor here. The proxy leg reads the crop table for its crop production in the same way, and matches FAOSTAT's national totals by construction wherever CROPGRIDS maps the item; validation captures only the statistical and jurisdictional-direct legs, whose crop production stays independent of it. ## 3. Choosing what to validate From 0640cd317841b71b63dc5240c46e0222e492e214 Mon Sep 17 00:00:00 2001 From: Jeffrey Seifried Date: Fri, 6 Nov 2026 11:03:46 -0800 Subject: [PATCH 7/7] fix units bug --- jdluc/trace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jdluc/trace.py b/jdluc/trace.py index 4a3a559..fad0cb1 100644 --- a/jdluc/trace.py +++ b/jdluc/trace.py @@ -207,7 +207,7 @@ def derive_proxy_production_kg( if unplaced.sum() > 0: logger.warning( f"{len(unplaced):d} FAOSTAT (country, item) production(s), " - f"{unplaced.sum() / KG_PER_TONNE**2:.3f} Mt in all, have no CROPGRIDS area to " + f"{unplaced.sum() / KG_PER_TONNE**2:.3f} kt in all, have no CROPGRIDS area to " f"split them by: {sorted(unplaced.index)}" )