From 4e4d50217e1e15ea438b2454eb8f8ceae38b2c54 Mon Sep 17 00:00:00 2001 From: Jeffrey Seifried Date: Tue, 10 Nov 2026 15:32:46 -0800 Subject: [PATCH 1/2] Polish for tools --- jdluc/emit.py | 10 +++++----- jdluc/jurisdictional_direct.py | 3 ++- jdluc/jurisdictional_direct_proxy.py | 3 ++- jdluc/statistical.py | 3 ++- tools/build-soy-brazil-figure.py | 20 ++++++++++++-------- tools/measure-drift.py | 18 +++++++++++++++--- 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/jdluc/emit.py b/jdluc/emit.py index ea35766..5eff15e 100644 --- a/jdluc/emit.py +++ b/jdluc/emit.py @@ -242,18 +242,18 @@ def get_year_to_linear_discount_weight(assessment_year: int) -> dict[int, float] """The LSR Guidance's linear amortization factor for a conversion in each year (Table 7.11): 9.75% in the assessment year, falling 0.5 points a year to 0.25% nineteen years before it. """ + # NB: nothing can depart in the lookback's first year, which has no year before it to compare + # with + _, *conversion_years = get_lookback_years_range(assessment_year=assessment_year) year_to_weight = { - year: (39 - 2 * (assessment_year - year)) / 400 - # NB: nothing can depart in the lookback's first year, which has no year before it to - # compare with - for year in get_lookback_years_range(assessment_year=assessment_year)[1:] + year: (39 - 2 * (assessment_year - year)) / 400 for year in conversion_years } assert math.isclose(sum(year_to_weight.values()), 1) return year_to_weight assert all( - set(get_lookback_years_range(assessment_year=assessment_year)[1:]) + set(get_year_to_linear_discount_weight(assessment_year=assessment_year)) <= set(gnw_tcl.LOSS_YEARS) and set(get_lookback_years_range(assessment_year=assessment_year)) <= set(gpw_grassland.YEARS) diff --git a/jdluc/jurisdictional_direct.py b/jdluc/jurisdictional_direct.py index 08b9a9e..a1e72b2 100644 --- a/jdluc/jurisdictional_direct.py +++ b/jdluc/jurisdictional_direct.py @@ -240,7 +240,8 @@ def it() -> collections.abc.Iterator[dict[str, float | str]]: } if data := list(it()): - assert set(data[0]) == set(SCHEMA) + first_record, *_ = data + assert set(first_record) == set(SCHEMA) return ( pandas.DataFrame.from_records(columns=list(SCHEMA), data=data) .astype(SCHEMA) diff --git a/jdluc/jurisdictional_direct_proxy.py b/jdluc/jurisdictional_direct_proxy.py index b4c84d3..c63061e 100644 --- a/jdluc/jurisdictional_direct_proxy.py +++ b/jdluc/jurisdictional_direct_proxy.py @@ -373,7 +373,8 @@ def it() -> collections.abc.Iterator[dict[str, float | str]]: } if data := list(it()): - assert set(data[0]) == set(SCHEMA) + first_record, *_ = data + assert set(first_record) == set(SCHEMA) return ( pandas.DataFrame.from_records(columns=list(SCHEMA), data=data) .astype(SCHEMA) diff --git a/jdluc/statistical.py b/jdluc/statistical.py index 5d99e91..ceb6247 100644 --- a/jdluc/statistical.py +++ b/jdluc/statistical.py @@ -736,7 +736,8 @@ def it() -> collections.abc.Iterator[dict[str, float | str]]: } if data := list(it()): - assert set(data[0]) == set(SCHEMA) + first_record, *_ = data + assert set(first_record) == set(SCHEMA) return ( pandas.DataFrame.from_records(columns=list(SCHEMA), data=data) .astype(SCHEMA) diff --git a/tools/build-soy-brazil-figure.py b/tools/build-soy-brazil-figure.py index 8294101..b25f9d5 100644 --- a/tools/build-soy-brazil-figure.py +++ b/tools/build-soy-brazil-figure.py @@ -80,11 +80,14 @@ logger = logging.getLogger(__name__) Conversion = emit.Conversion -# The lookback opens at the assessment year less twenty, and nothing can have departed in that -# first year -- a departure needs the year before it to compare against -LOOKBACK_YEARS = emit.get_lookback_years_range( - assessment_year=emit.DEFAULT_ASSESSMENT_YEAR -)[1:] +# The years a conversion can be dated to, each with its discount weight: the lookback less its +# first year, in which nothing can have departed -- a departure needs the year before it +LOOKBACK_YEARS = tuple( + emit.get_year_to_linear_discount_weight( + assessment_year=emit.DEFAULT_ASSESSMENT_YEAR + ) +) +FIRST_LOOKBACK_YEAR, *_, LAST_LOOKBACK_YEAR = LOOKBACK_YEARS # Hotter reads as more carbon released: forest sources red and orange, grassland tan and olive @@ -164,7 +167,8 @@ def conversion_artists() -> tuple[ cmap = matplotlib.colors.ListedColormap( [CONVERSION_COLOR[c] for c in conversions] ).with_extremes(bad=TRANSPARENT) - boundaries = [c.value - 0.5 for c in conversions] + [conversions[-1].value + 0.5] + *_, last_conversion = conversions + boundaries = [c.value - 0.5 for c in conversions] + [last_conversion.value + 0.5] norm = matplotlib.colors.BoundaryNorm(boundaries, cmap.N) handles = [ Line2D( @@ -206,7 +210,7 @@ def draw_conversion_year( ) cmap = matplotlib.colors.ListedColormap(colours).with_extremes(bad=TRANSPARENT) norm = matplotlib.colors.BoundaryNorm( - [*(year - 0.5 for year in LOOKBACK_YEARS), LOOKBACK_YEARS[-1] + 0.5], cmap.N + [*(year - 0.5 for year in LOOKBACK_YEARS), LAST_LOOKBACK_YEAR + 0.5], cmap.N ) im = ax.imshow( numpy.where(numpy.isin(layer.data, LOOKBACK_YEARS), layer.data, numpy.nan), @@ -359,7 +363,7 @@ def out(name: str) -> str: im_year, cax=ax_lc0.inset_axes((0.05, 0.1, 0.5, 0.04)), orientation="horizontal", - ticks=(LOOKBACK_YEARS[0], *LOOKBACK_YEARS[4::5]), + ticks=(FIRST_LOOKBACK_YEAR, *LOOKBACK_YEARS[4::5]), ) year_bar.ax.tick_params(labelsize=7) draw_conversion(ax_lc1, conversion, "") diff --git a/tools/measure-drift.py b/tools/measure-drift.py index bb66d6a..3e886f8 100644 --- a/tools/measure-drift.py +++ b/tools/measure-drift.py @@ -442,7 +442,9 @@ def print_worst_rows( for value, column, row, before_value, after_value in sorted(records, reverse=True)[ :limit ]: - admin_id, commodity_id = row[1], row[2] + level_to_value = dict(zip(baseline.index.names, row, strict=True)) + admin_id = level_to_value["admin_id"] + commodity_id = level_to_value["commodity_id"] print( f" {commodity_id!s:<16}{admin_id!s:<9}{column:<34}" f"{before_value:>13.5g}{after_value:>13.5g}{value:>11.3e}" @@ -483,8 +485,18 @@ def get_outside(df: pandas.DataFrame) -> pandas.DataFrame: if not relative.notna().any(): blind += 1 elif float(relative.max()) > worst: - row = typing.cast(tuple[str, ...], relative.idxmax()) - worst, where = float(relative.max()), f"{column:s} {row[2]!s}/{row[1]:s}" + level_to_value = dict( + zip( + left.index.names, + typing.cast(tuple[str, ...], relative.idxmax()), + strict=True, + ) + ) + worst = float(relative.max()) + where = ( + f"{column:s} {level_to_value['commodity_id']!s}/" + f"{level_to_value['admin_id']:s}" + ) # Three distinct outcomes, and the first two used to print alike: something moved, nothing was # comparable, and everything was comparable and sat exactly still measured = ( From 90534aa8b3ff5cff9d208e899d7e645c33ecdba4 Mon Sep 17 00:00:00 2001 From: Jeffrey Seifried Date: Tue, 10 Nov 2026 15:35:12 -0800 Subject: [PATCH 2/2] clarify the production weighting for sLUC --- docs/data.md | 28 ++++++++++++++-------------- docs/methodology.md | 10 ++++++++++ 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/docs/data.md b/docs/data.md index e97b123..e709b13 100644 --- a/docs/data.md +++ b/docs/data.md @@ -86,20 +86,20 @@ The final per-(jurisdiction, commodity) table produced by the `trace` stage. Ind 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 | +| 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): the crop map in the assessment year (direct leg), MapSPAM's snapshots weighted like production (statistical leg), CROPGRIDS' 2020 area (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. diff --git a/docs/methodology.md b/docs/methodology.md index 2a776f3..384dbe2 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -278,6 +278,16 @@ Where no reference year places the group in a pixel there is no basis for a spli **Windowed production denominator (a departure from WRI).** The emissions numerator is time-resolved: it links conversion emissions to each crop's *historical* expansion, weighted by recency. Dividing that by a single *current-year* production snapshot — as in WRI's methodology — would place the numerator and denominator on inconsistent time bases. A crop that expanded early in the window and then contracted would carry real conversion emissions against a shrunken (or zero) present-day production, yielding an inflated or undefined emissions factor. Instead, production is reduced over the same 20-year lookback window using the same linear temporal ramp applied to emissions: each conversion year's discount weight is split evenly between the two MapSPAM snapshots of the span holding it, a year after 2020 gives its whole weight to the 2020 snapshot, and production is the snapshots' mean under those weights (`get_snapshot_year_to_weight` in `statistical.py`). This ties emission allocation and production to the same years with the same recency weighting. When production is flat across the window the result reduces exactly to the current-production value; it diverges only when production actually changed. This consistent linkage is a material improvement over attributing historical-expansion emissions to present-day production alone. +The weights depend on the assessment year, because the lookback moves with it and every conversion year after 2020 adds its whole weight to the 2020 snapshot: + +| Assessment year | 2000 | 2005 | 2010 | 2020 | +| --------------- | ------ | ----- | ------- | ----- | +| 2020 | 3.125% | 12.5% | 46.875% | 37.5% | +| 2022 | 1.125% | 8% | 39.375% | 51.5% | +| 2024 | 0.125% | 4.5% | 31.875% | 63.5% | + +A later assessment year therefore leans further on MapSPAM 2020: a crop whose production grew after 2010 has more production for 2024 than for 2020, and one that shrank has less. It is never the assessment year's own production, since MapSPAM ends in 2020. A commodity's area is the same weighted mean of its snapshots, and so is a herd's production: its heads at each snapshot times FAOSTAT's carcass weight per head in that year. + ### Jurisdictional direct proxy (`jurisdictional_direct_proxy.py`) 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.