diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index a7b7e8f..0727881 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -7201,6 +7201,9 @@ def implement_dst_changes_simple_v1_5( mcf_defaults.MCF_UNCATEGORISED, index=range(MODEL_START_YEAR, MODEL_END_YEAR + 1), ) + # Keep these oxidation factors: Landfill.estimate_emissions would + # reset them from gas_capture, which is still False for the two + # converted sites, to 0.1 and 0. skip_ox = True if move_gas: @@ -7251,6 +7254,7 @@ def implement_dst_changes_simple_v1_5( lf.scenario = 1 else: + skip_ox = True if add_gas: for lf in scenario_parameters.landfills: if lf.site_type == "Sanitary Landfill": @@ -7538,7 +7542,7 @@ def fill_missing_fields(d: dict) -> dict: ) # scenario_parameters.repopulate_attr_dicts() # does this need to come sooner? Does anything in the above functions rely on the attr dicts? - skip_ox = scenario_parameters.sites_method + # skip_ox comes from the branch above, so the oxidation it set is kept. for landfill in scenario_parameters.landfills: landfill.estimate_emissions(skip_ox=skip_ox) # print(landfill.emissions) diff --git a/changelog/2026-10.md b/changelog/2026-10.md new file mode 100644 index 0000000..4857b77 --- /dev/null +++ b/changelog/2026-10.md @@ -0,0 +1,57 @@ +# SWEET_python Changelog — October 2026 + +**Highlights:** The City DST's "add gas capture" lever now applies the oxidation +it sets, not just the gas capture. Its results fall another 10–13% after the +implementation year: a city that landfills everything ends up 65.3% below the +no-change scenario in 2040 instead of 60% (model-output change). + +## Fixed + +- **"Add gas capture" applies the oxidation it sets** (model-output change). In + the generic branch of `City.implement_dst_changes_simple_v1_5`, `add_gas` + converts a city's two sites without gas capture after the implementation year: + - the landfill gets 60% gas capture and an oxidation factor of 0.22 (was 0.1) + - the dumpsite becomes a controlled dumpsite with 30% capture and 0.1 (was 0) + + It set `skip_ox = True` so that `Landfill.estimate_emissions` would keep those + factors. The emissions loop at the end of the method then replaced it with + `skip_ox = scenario_parameters.sites_method`, which is false on this branch. + So `estimate_emissions` reset each factor from the site's `gas_capture` flag, + which `add_gas` doesn't set: 0.1 for the landfill and 0 for the dumpsite, in + every year. The gas capture applied; the oxidation never did. Both lines date + from the repository's first commit. + + 0.22 and 0.1 are the values `estimate_emissions` itself gives a landfill and a + controlled dumpsite with gas collection (its `ox_cap` table, and + `defaults_2019.oxidation_factor["with_lfg"]`). The real-sites branch also gives + a sanitary landfill 0.22 when it adds capture. Each branch now sets `skip_ox` + itself, and the loop uses it. The real-sites branch keeps `skip_ox = True`. + + Measured across WasteMAP's June 2026 cities table (5,689 city names) and a + Custom Location in each of 249 countries: gas capture added in 2026, with each + city's own diversions. + - **The 168 cities the UI offers the lever to** (no gas capture, some landfill) + fall another 10.0–13.3% in 2040. Against the no-change scenario: + - Lima and Riyadh, which landfill everything: −60.0% → −65.3% + - Cairo, which dumps 82% of its waste: −37.3% → −43.9% + - **A city that only dumps its waste** goes from −30.0% to −37.0%. That covers + 187 cities, among them Lagos, Karachi, Dhaka and Delhi. The UI doesn't offer + them the lever, but the API runs it. + - **Custom Locations** move the same way in the 218 countries where they + landfill or dump. The other 31 have the empty disposal split that + [#74](https://github.com/RMI/SWEET_python/pull/74) fixes. + - **Brazil's 4,694 precomputed cities don't move yet.** WasteMAP's `load_city` + sends them down the real-sites branch, where `add_gas` changes nothing for + generic sites. Once [RMI/WasteMAP#851](https://github.com/RMI/WasteMAP/pull/851) + sends them down the generic branch, they fall another 10.0–13.3% as well. + - **Nothing else moves.** The no-change scenario is bit-identical for every + city and Custom Location, and so is every `add_gas` result up to and + including the implementation year. Without `add_gas`, `skip_ox` is what it + was, so no other lever changes. No scenario that ran before fails now, or the + reverse. + + Climate TRACE's pipeline doesn't call this method, so its outputs do not move. + + Tests: `tests/test_add_gas_capture_oxidation.py`. + ([#78](https://github.com/RMI/SWEET_python/pull/78), + [RMI/WasteMAP#856](https://github.com/RMI/WasteMAP/pull/856)) diff --git a/changelog/README.md b/changelog/README.md index 4bb1323..2ca579c 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -15,6 +15,7 @@ The project does not publish semantic version tags, so releases are tracked by Newest first: +- [2026-10](2026-10.md) — the City DST's "add gas capture" lever applies the oxidation it sets (0.22 for the landfill it fits with gas capture, 0.1 for the dumpsite it converts) instead of resetting both to the no-capture defaults at the end of `implement_dst_changes_simple_v1_5`; its results fall another 10–13% after the implementation year, a city that landfills everything from −60% to −65.3% against no change in 2040 (model-output change) - [2026-09](2026-09.md) — WasteMAP's site tool, city tool and Custom Location grow waste with the country's UN population year by year, as Climate TRACE does; big cities keep their own UN city rates (model-output change); model output frames get one fixed waste-type column order instead of a per-process one (a `set` fed the ordering, and Python randomizes set iteration per process), moving values in the last bit only, where a sum runs across the reordered columns; City DST's diversion sum stops passing pandas a keyword pandas is removing, which would have broken the model path on pandas 4 behind a misleading `AttributeError` (no output change); annual model no longer charges a flare's inefficiency twice (a hardcoded 2% slip stacked on the `flaring` destruction efficiency), so it agrees with the monthly model again and a site with gas capture emits ~3.5% less (model-output change); `gas_capture_efficiency` bounded to [0, 1], where an out-of-range value used to produce negative emissions silently; one methane density constant instead of two 9.1% apart - [2026-08](2026-08.md) — Single-site adst gains optional `depth` (deep-dump MCF bump) and `k_override` (caller-supplied decomposition rate) inputs, restoring the last two site-DST levers; `/sdst` flaring efficiency reaches the model again after a variable-name bug silently forced flare destruction to 0.98; annual model applies cover oxidation by emission year not deposit year, fixing biocover having no effect on closed landfills (WasteMAP #719); `City.sdst_v1_5` custom-site path holds the scenario equal to the baseline before the implementation year even when composition changes (was back-dating the new composition onto pre-implementation deposits) (model-output change); MCF consolidated into a new `SWEET_python.mcf` module and both dump types moved to the IPCC uncategorised-SWDS 0.6 (open dumps up from 0.4, controlled dumps down from 0.7), with a supplied depth now selecting the deep/shallow category (model-output change) - [2026-07](2026-07.md) — All ten waste types eligible for combustion (metal/glass/other added); methane-only model treats combustion as landfill diversion (model-output change) diff --git a/tests/test_add_gas_capture_oxidation.py b/tests/test_add_gas_capture_oxidation.py new file mode 100644 index 0000000..9445680 --- /dev/null +++ b/tests/test_add_gas_capture_oxidation.py @@ -0,0 +1,186 @@ +"""Regression tests for the oxidation the City DST's "add gas capture" lever sets +(``add_gas`` in ``City.implement_dst_changes_simple_v1_5``, generic branch). + +Bug (fixed): after the implementation year, ``add_gas`` gives the landfill +without capture 60% gas capture and an oxidation factor of 0.22, and converts the +dumpsite to a controlled dumpsite with 30% capture and 0.1. It sets +``skip_ox = True`` so ``Landfill.estimate_emissions`` keeps those oxidation +factors. The emissions loop at the end of the method overwrote that with +``skip_ox = scenario_parameters.sites_method``, which is falsy on this branch, so +``estimate_emissions`` reset each oxidation factor to the default for a site +without gas capture: 0.1 for the landfill, 0 for the dumpsite. Gas capture still +applied; the oxidation never did. A city that landfills everything came out 60% +below the no-change scenario after the implementation year instead of 65.3%. + +0.22 and 0.1 are the values ``Landfill.estimate_emissions`` itself gives a +landfill and a controlled dumpsite with gas collection (its ``ox_cap`` table, and +``defaults_2019.oxidation_factor["with_lfg"]``). + +These tests build a city without gas capture from country defaults +(``dst_baseline_blank``, no database), give it a split, and run the real +``implement_dst_changes_simple_v1_5``. +""" + +import copy + +import pandas as pd +import pytest + +from SWEET_python.city_params import City, DiversionFractions +from SWEET_python.class_defs import SplitFractions +from SWEET_python.landfill import Landfill + +_COUNTRY, _POP, _PRECIP, _TEMP = "Mexico", 2_000_000, 600.0, 17.0 +_IMPLEMENT_YEAR, _SCENARIO = 2026, 1 +_BEFORE = slice(None, _IMPLEMENT_YEAR) +_AFTER = slice(_IMPLEMENT_YEAR + 1, None) + +# Each site add_gas converts: (gas capture it adds, oxidation factor before, after). +_LANDFILL_WITHOUT_CAPTURE = (0.6, 0.1, 0.22) +_DUMPSITE = (0.3, 0.0, 0.1) + + +def _city_with_split(with_capture, without_capture, dumpsite) -> City: + """A city whose baseline sends its disposal to the generic split given, as + WasteMAP's ``load_city`` builds one from the cities table.""" + city = City("add_gas_oxidation_regression") + city.dst_baseline_blank(_COUNTRY, _POP, _PRECIP, _TEMP) + baseline = city.baseline_parameters + baseline.split_fractions = SplitFractions( + landfill_w_capture=with_capture, + landfill_wo_capture=without_capture, + dumpsite=dumpsite, + ) + city._calculate_divs() + baseline.repopulate_attr_dicts() + for landfill in baseline.landfills: + landfill.estimate_emissions() + city.estimate_diversion_emissions(scenario=0) + city.sum_landfill_emissions(scenario=0) + return city + + +@pytest.fixture(scope="module") +def city_without_capture() -> City: + """Half its disposal to a landfill without gas capture, half to a dumpsite: a + city the UI offers "add gas capture" to.""" + return _city_with_split(0.0, 0.5, 0.5) + + +def _implement( + city: City, + add_gas: int, + new_gas_pct: float = 0.0, + div_fractions: DiversionFractions = None, + sites_method=None, +): + """Run the city DST on a copy of ``city``, keeping its diversions unless + others are given.""" + city = copy.deepcopy(city) + if div_fractions is None: + div = city.baseline_parameters.div_fractions.iloc[0] + div_fractions = DiversionFractions( + compost=float(div["compost"]), + anaerobic=float(div["anaerobic"]), + combustion=float(div["combustion"]), + recycling=float(div["recycling"]), + ) + if sites_method is not None: + city.baseline_parameters.sites_method = sites_method + city.implement_dst_changes_simple_v1_5( + div_fractions, + add_gas, + 0, + new_gas_pct, + 0.0, + _IMPLEMENT_YEAR, + _SCENARIO, + ) + return city.scenario_parameters[_SCENARIO - 1] + + +@pytest.mark.parametrize("new_gas_pct", [0.0, 0.2]) +@pytest.mark.parametrize( + "index, site", + [(1, _LANDFILL_WITHOUT_CAPTURE), (2, _DUMPSITE)], + ids=["landfill", "dumpsite"], +) +def test_add_gas_keeps_the_oxidation_it_sets(city_without_capture, index, site, new_gas_pct): + """Before the fix the landfill came out at 0.1 and the dumpsite at 0 in every + year. The UI also lets a city add a new landfill with gas capture alongside.""" + _, before, after = site + oxidation = _implement(city_without_capture, 1, new_gas_pct).landfills[index].oxidation_factor + + assert isinstance(oxidation, pd.Series), f"reset to {oxidation!r}" + assert (oxidation.loc[_BEFORE] == before).all() + assert (oxidation.loc[_AFTER] == after).all() + + +@pytest.mark.parametrize( + "index, site", + [(1, _LANDFILL_WITHOUT_CAPTURE), (2, _DUMPSITE)], + ids=["landfill", "dumpsite"], +) +def test_add_gas_emissions_include_the_oxidation(city_without_capture, index, site): + """After the implementation year each converted site emits what it did, less + the gas captured, with the new oxidation in place of the old: 0.4 x 0.78 / 0.9 + for the landfill and 0.7 x 0.9 for the dumpsite. Before the fix the factors + were capture alone, 0.4 and 0.7. Up to the implementation year nothing moves.""" + capture, before, after = site + no_change = _implement(city_without_capture, 0).landfills[index].emissions["total"] + added = _implement(city_without_capture, 1).landfills[index].emissions["total"] + + pd.testing.assert_series_equal(added.loc[_BEFORE], no_change.loc[_BEFORE]) + factor = (1 - capture) * (1 - after) / (1 - before) + pd.testing.assert_series_equal(added.loc[_AFTER], no_change.loc[_AFTER] * factor, rtol=1e-12) + + +def test_a_city_that_landfills_everything_falls_65_percent(): + """No diversion and all disposal to a landfill without capture: add_gas cuts + the city's emissions after the implementation year by 65.3%, not 60%.""" + city = _city_with_split(0.0, 1.0, 0.0) + no_diversion = DiversionFractions(compost=0.0, anaerobic=0.0, combustion=0.0, recycling=0.0) + no_change = _implement(city, 0, div_fractions=no_diversion).total_emissions["total"] + added = _implement(city, 1, div_fractions=no_diversion).total_emissions["total"] + + assert 1 - added.loc[2040] / no_change.loc[2040] == pytest.approx(0.6533, abs=1e-4) + + +def test_without_add_gas_each_site_keeps_its_default_oxidation(city_without_capture): + """The no-change scenario is untouched: every site takes the default for its + type and gas capture, and the landfills emit what the baseline does.""" + scenario = _implement(city_without_capture, 0) + + assert [lf.oxidation_factor for lf in scenario.landfills] == [0.22, 0.1, 0] + for scenario_lf, baseline_lf in zip( + scenario.landfills, city_without_capture.baseline_parameters.landfills + ): + pd.testing.assert_frame_equal(scenario_lf.emissions, baseline_lf.emissions, rtol=1e-12) + + +@pytest.mark.parametrize( + "sites_method, add_gas, skip_ox", + [ + (False, 0, False), + (False, 1, True), + # WasteMAP's Brazilian cities-table rows took this branch with these same + # generic landfills before RMI/WasteMAP#851. Real sites are ``advanced`` + # and keep the oxidation they carry either way. + (True, 0, True), + (True, 1, True), + ], +) +def test_each_branch_passes_its_own_skip_ox( + city_without_capture, monkeypatch, sites_method, add_gas, skip_ox +): + passed = [] + estimate_emissions = Landfill.estimate_emissions + + def recording(self, skip_ox=False, **kwargs): + passed.append(skip_ox) + return estimate_emissions(self, skip_ox=skip_ox, **kwargs) + + monkeypatch.setattr(Landfill, "estimate_emissions", recording) + _implement(city_without_capture, add_gas, sites_method=sites_method) + + assert passed == [skip_ox] * 3