diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index a7b7e8f..c039069 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -450,6 +450,54 @@ def __init__(self, code: str, message: str): super().__init__(self.message) +def _move_to_existing_gas_capture( + split_fractions: SplitFractions, existing_gas_pct: float +) -> SplitFractions: + """The city's disposal split with ``existing_gas_pct`` of it going to the + landfills it already has with gas capture: the City DST's "move waste to + existing gas capture" lever. + + The landfill and dumpsite shares without capture take the rest, + ``1 - existing_gas_pct``, in the proportions they had to each other. A city + sending 34% of its waste to capture and 66% to a landfill without it, moved to + 90%, sends the other 10% to that landfill. + + Raises CustomError("INVALID_PARAMETERS") when the share is outside [0, 1], when + the city has no landfill with gas capture to move waste to, and when it has no + site without capture to move waste away to. + """ + if not 0.0 <= existing_gas_pct <= 1.0: + raise CustomError( + "INVALID_PARAMETERS", + "The share of waste to existing gas capture must be between 0 and 1, " + f"not {existing_gas_pct}", + ) + with_capture = split_fractions.landfill_w_capture + without_capture = split_fractions.landfill_wo_capture + split_fractions.dumpsite + if with_capture <= 0: + if existing_gas_pct > 0: + raise CustomError( + "INVALID_PARAMETERS", + "This city has no landfill with gas capture to move waste to", + ) + return split_fractions.model_copy() + if without_capture <= 0: + if existing_gas_pct < 1: + raise CustomError( + "INVALID_PARAMETERS", + "This city sends all of its waste to landfills with gas capture, " + "so there is no site without capture to move waste to", + ) + return split_fractions.model_copy() + scale = (1 - existing_gas_pct) / without_capture + return SplitFractions( + landfill_w_capture=existing_gas_pct, + landfill_wo_capture=split_fractions.landfill_wo_capture * scale, + dumpsite=split_fractions.dumpsite * scale, + new_w_capture=split_fractions.new_w_capture, + ) + + class City: def __init__(self, city_name: str): """ @@ -7204,18 +7252,19 @@ def implement_dst_changes_simple_v1_5( skip_ox = True if move_gas: - original_gas_pct = ( - scenario_parameters.split_fractions.landfill_w_capture + scenario_parameters.split_fractions = _move_to_existing_gas_capture( + scenario_parameters.split_fractions, existing_gas_pct + ) + # Each landfill's waste comes from its own fraction_of_waste + # (LandfillWasteMassDF.create below), not from split_fractions. + split = scenario_parameters.split_fractions + scenario_parameters.landfills[0].fraction_of_waste = ( + split.landfill_w_capture ) - scenario_parameters.split_fractions.landfill_w_capture = ( - existing_gas_pct + scenario_parameters.landfills[1].fraction_of_waste = ( + split.landfill_wo_capture ) - if original_gas_pct > 0: - ratio = existing_gas_pct / original_gas_pct - else: - ratio = 0 - scenario_parameters.split_fractions.landfill_wo_capture *= ratio - scenario_parameters.split_fractions.dumpsite *= ratio + scenario_parameters.landfills[2].fraction_of_waste = split.dumpsite if new_gas_pct > 0: scenario_parameters.landfills.append( @@ -7232,7 +7281,8 @@ def implement_dst_changes_simple_v1_5( total = sum(scenario_parameters.split_fractions.model_dump().values()) if abs(total - 1.0) > 1e-3: raise CustomError( - f"Invalid split fractions: {scenario_parameters.split_fractions}" + "INVALID_PARAMETERS", + f"Invalid split fractions: {scenario_parameters.split_fractions}", ) scenario_parameters.landfills[0].fraction_of_waste = ( scenario_parameters.split_fractions.landfill_w_capture diff --git a/changelog/2026-10.md b/changelog/2026-10.md new file mode 100644 index 0000000..201706a --- /dev/null +++ b/changelog/2026-10.md @@ -0,0 +1,52 @@ +# SWEET_python Changelog — October 2026 + +**Highlights:** The City DST's "move waste to existing gas capture" lever now +changes a city's emissions. For a city on the generic landfill split, it worked +out a new split and never gave it to the landfills, so every setting returned the +no-change scenario. Moved to 90% gas capture, Lahore now emits 45% less in 2040 +(model-output change). + +## Fixed + +- **Moving waste to a city's existing gas capture changes its emissions** + (model-output change). `City.implement_dst_changes_simple_v1_5` with + `move_gas=1` and `existing_gas_pct` is the City DST's "% of waste diverted to + existing landfill with gas capture" slider. Its generic branch rewrote + `split_fractions`, but each landfill's waste comes from its own + `fraction_of_waste`, and only the "new landfill with gas capture" block copied + the split there. WasteMAP offers this slider only to a city that already has gas + capture, and switches the new-landfill lever off for exactly those cities, so the + two never ran together. On the June 2026 cities table, all 7 cities with a live + slider (Mexico City, Lahore, Santiago, Amman, Tegucigalpa, Cebu City, Tunis) + returned the no-change scenario exactly, at every setting. + + The arithmetic was wrong as well. It set the capture share to `existing_gas_pct` + and multiplied the shares without capture by `existing / original`, scaling them + up rather than down: Lahore's landfill without capture came out at 171% of the + city's waste, and Mexico City's at 3,848%. A city with no capture had both shares + zeroed. With a new landfill with gas capture as well, the inflated split failed + its sum check, and that check built `CustomError` with one argument, so the API + returned a 500. + + The new `_move_to_existing_gas_capture` gives the capture share + `existing_gas_pct`. The landfill and dumpsite without capture share the rest in + the proportions they had, and the scenario applies the split to the landfills + from the implementation year. In 2040: + - Moved to 90% capture: Lahore −45%, Mexico City −43%, Cebu City −30%. + - Moved to 100%: Lahore −53%, Mexico City −48%. + - Moving waste off capture raises emissions. Santiago (98.8% to capture) moved + to 0% emits 96% more. + + Three requests the lever can't carry out now return `INVALID_PARAMETERS` + instead of the no-change scenario: + - a share outside [0, 1]; + - a share above 0 for a city with no landfill with gas capture (WasteMAP doesn't + offer the lever there); + - less than 100% for a city that sends all its waste to capture. WasteMAP + disables the slider for the 41 such cities in the June table and sends 100%, + which leaves the split as it is. + + Nothing else moves. The no-change scenario, the other levers and the 41 cities at + 100% give the same numbers as before. The sum check now raises a proper + `INVALID_PARAMETERS` too. + ([#75](https://github.com/RMI/SWEET_python/pull/75), [RMI/WasteMAP#854](https://github.com/RMI/WasteMAP/pull/854)) diff --git a/changelog/README.md b/changelog/README.md index 4bb1323..cf084ed 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 "move waste to existing gas capture" slider changes a city's emissions; it computed a new split and never applied it to the landfills, so every setting returned the no-change scenario for the 7 cities with a live slider (its arithmetic also scaled the shares without capture up, not down) (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_move_to_existing_gas_capture.py b/tests/test_move_to_existing_gas_capture.py new file mode 100644 index 0000000..9c60872 --- /dev/null +++ b/tests/test_move_to_existing_gas_capture.py @@ -0,0 +1,252 @@ +"""Regression tests for the City DST's "move waste to existing gas capture" lever +(``move_gas`` and ``existing_gas_pct`` in ``City.implement_dst_changes_simple_v1_5``). + +Bug (fixed): for a city on the generic landfill split, the lever rewrote +``split_fractions`` but never passed the new split to the landfills. Each +landfill's waste comes from its own ``fraction_of_waste``, which only the +"new landfill with gas capture" block updated, and the frontend never sends that +lever together with this one. So the slider changed nothing: Lahore (34% of its +waste to gas capture) moved to 90% returned exactly the no-change scenario, as did +every other city that has a live slider. + +The arithmetic was wrong too. It set the capture share and then multiplied the +shares without capture by ``existing / original``, scaling them up: Lahore's +landfill without capture came out at 171% of the city's waste, Mexico City's at +3,848%. With no capture to start from, it zeroed them. Combined with a new +landfill with gas capture, the inflated split failed its sum check and raised a +``TypeError`` (``CustomError`` was built with one argument), which the API +returned as a 500. + +The fix gives the capture share ``existing_gas_pct`` and the landfill and dumpsite +without capture the rest, in their original proportions, then applies that split +to the landfills. + +These tests build a city with 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 math + +import pandas as pd +import pytest + +from SWEET_python.city_params import City, CustomError, DiversionFractions +from SWEET_python.class_defs import SplitFractions + +_COUNTRY, _POP, _PRECIP, _TEMP = "Mexico", 2_000_000, 600.0, 17.0 +_IMPLEMENT_YEAR, _SCENARIO = 2026, 1 +# 30% of disposal to a landfill with gas capture, 50% to one without, 20% to a +# dumpsite, so the move has two sites without capture to scale. +_WITH_CAPTURE, _WITHOUT_CAPTURE, _DUMPSITE = 0.3, 0.5, 0.2 + + +def _city_with_split(with_capture, without_capture, dumpsite) -> City: + """A city whose baseline sends its disposal to the generic split given. + + ``dst_baseline_blank`` builds every Custom Location with no gas capture, so the + split is replaced and the landfills rebuilt from it, which is what WasteMAP's + ``load_city`` does after reading a city's split from the cities table. + """ + city = City("move_gas_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_with_capture() -> City: + return _city_with_split(_WITH_CAPTURE, _WITHOUT_CAPTURE, _DUMPSITE) + + +def _implement(city: City, move_gas: int, existing_gas_pct: float, new_gas_pct: float = 0.0): + """Run the city DST on a copy of ``city``, keeping its diversions.""" + city = copy.deepcopy(city) + 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"]), + ) + city.implement_dst_changes_simple_v1_5( + div_fractions, + 0, + move_gas, + new_gas_pct, + existing_gas_pct, + _IMPLEMENT_YEAR, + _SCENARIO, + ) + return city.scenario_parameters[_SCENARIO - 1] + + +def _total(scenario_parameters) -> pd.Series: + return scenario_parameters.total_emissions["total"] + + +def _moved_split(existing_gas_pct): + rest = 1 - existing_gas_pct + without_capture_total = _WITHOUT_CAPTURE + _DUMPSITE + return ( + existing_gas_pct, + _WITHOUT_CAPTURE * rest / without_capture_total, + _DUMPSITE * rest / without_capture_total, + ) + + +def test_moving_waste_to_gas_capture_lowers_emissions(city_with_capture): + """Before the fix this returned the no-change scenario exactly.""" + no_change = _total(_implement(city_with_capture, 0, 0.0)) + moved = _total(_implement(city_with_capture, 1, 0.9)) + + pd.testing.assert_series_equal( + moved.loc[: _IMPLEMENT_YEAR - 1], no_change.loc[: _IMPLEMENT_YEAR - 1] + ) + after = slice(_IMPLEMENT_YEAR + 1, None) + assert (moved.loc[after] < no_change.loc[after]).all(), ( + "moving waste to gas capture did not lower emissions after " + f"{_IMPLEMENT_YEAR}: 2040 {moved.loc[2040]:.1f} vs {no_change.loc[2040]:.1f}" + ) + + +def test_moving_waste_off_gas_capture_raises_emissions(city_with_capture): + no_change = _total(_implement(city_with_capture, 0, 0.0)) + moved = _total(_implement(city_with_capture, 1, 0.0)) + + after = slice(_IMPLEMENT_YEAR + 1, None) + assert (moved.loc[after] > no_change.loc[after]).all(), ( + "moving all waste off gas capture did not raise emissions after " + f"{_IMPLEMENT_YEAR}: 2040 {moved.loc[2040]:.1f} vs {no_change.loc[2040]:.1f}" + ) + + +@pytest.mark.parametrize("existing_gas_pct", [0.0, 0.6, 0.9, 1.0]) +def test_landfills_receive_the_moved_split(city_with_capture, existing_gas_pct): + """The landfill and dumpsite without capture share what capture does not + take, in their original 5:2 proportion, and the split still sums to 1.""" + scenario = _implement(city_with_capture, 1, existing_gas_pct) + expected = _moved_split(existing_gas_pct) + + split = scenario.split_fractions + assert (split.landfill_w_capture, split.landfill_wo_capture, split.dumpsite) == pytest.approx( + expected, abs=1e-12 + ) + fractions = [landfill.fraction_of_waste for landfill in scenario.landfills] + assert fractions == pytest.approx(list(expected), abs=1e-12) + assert sum(fractions) == pytest.approx(1.0, abs=1e-12) + + +def test_moved_waste_is_reallocated_not_created_or_lost(city_with_capture): + """From the implementation year each landfill's waste is the no-change + scenario's, scaled by its new share over its old one, and the total + landfilled each year is unchanged. Before it, nothing moves.""" + no_change = _implement(city_with_capture, 0, 0.0) + moved = _implement(city_with_capture, 1, 0.9) + + after = slice(_IMPLEMENT_YEAR, None) + before = slice(None, _IMPLEMENT_YEAR - 1) + old_split = (_WITH_CAPTURE, _WITHOUT_CAPTURE, _DUMPSITE) + for old_share, new_share, base_lf, moved_lf in zip( + old_split, _moved_split(0.9), no_change.landfills, moved.landfills + ): + pd.testing.assert_frame_equal( + moved_lf.waste_mass_df.loc[before], base_lf.waste_mass_df.loc[before] + ) + pd.testing.assert_frame_equal( + moved_lf.waste_mass_df.loc[after], + base_lf.waste_mass_df.loc[after] * (new_share / old_share), + rtol=1e-12, + ) + + def landfilled(scenario): + return sum(lf.waste_mass_df.sum(axis=1) for lf in scenario.landfills) + + pd.testing.assert_series_equal(landfilled(moved), landfilled(no_change), rtol=1e-12) + + +def test_the_city_own_share_is_the_no_change_scenario(city_with_capture): + no_change = _total(_implement(city_with_capture, 0, 0.0)) + unmoved = _total(_implement(city_with_capture, 1, _WITH_CAPTURE)) + + pd.testing.assert_series_equal(unmoved, no_change, rtol=1e-12) + + +def test_moving_and_adding_a_new_landfill_with_gas_capture(city_with_capture): + """The new landfill takes its share off the top of the moved split. The UI + never sends both levers, but the API accepts them; before the fix the + inflated split failed the sum check with a TypeError (an API 500).""" + scenario = _implement(city_with_capture, 1, 0.9, new_gas_pct=0.2) + + expected = [share * 0.8 for share in _moved_split(0.9)] + [0.2] + fractions = [landfill.fraction_of_waste for landfill in scenario.landfills] + assert fractions == pytest.approx(expected, abs=1e-12) + + +@pytest.mark.parametrize("existing_gas_pct", [-0.1, 1.1, math.nan]) +def test_a_share_outside_zero_to_one_is_rejected(city_with_capture, existing_gas_pct): + with pytest.raises(CustomError) as excinfo: + _implement(city_with_capture, 1, existing_gas_pct) + assert excinfo.value.code == "INVALID_PARAMETERS" + + +@pytest.mark.parametrize( + "split", + [ + (0.0, 0.7, 0.3), + # No disposal at all, the split a Custom Location had in 31 countries + # before RMI/SWEET_python#74. + (0.0, 0.0, 0.0), + ], +) +def test_a_city_without_gas_capture_has_no_landfill_to_move_waste_to(split): + """The UI only offers the lever to a city with gas capture. Before the fix + the API returned the no-change scenario, as if the move had been made. A + share of 0 asks for no change, which is what it gets.""" + city = _city_with_split(*split) + with pytest.raises(CustomError) as excinfo: + _implement(city, 1, 0.5) + assert excinfo.value.code == "INVALID_PARAMETERS" + + pd.testing.assert_series_equal( + _total(_implement(city, 1, 0.0)), _total(_implement(city, 0, 0.0)), rtol=1e-12 + ) + + +def test_a_city_all_to_gas_capture_has_no_site_to_move_waste_back_to(): + """41 cities in the June 2026 cities table send all their disposal to gas + capture. The UI disables the slider for them and sends 100%.""" + city = _city_with_split(1.0, 0.0, 0.0) + with pytest.raises(CustomError) as excinfo: + _implement(city, 1, 0.5) + assert excinfo.value.code == "INVALID_PARAMETERS" + + pd.testing.assert_series_equal( + _total(_implement(city, 1, 1.0)), _total(_implement(city, 0, 0.0)), rtol=1e-12 + ) + + +def test_lahore_moved_to_ninety_percent(): + """Lahore's split in the June 2026 cities table. Before the fix the split came + out as 90% / 171% / 0% and never reached the landfills.""" + city = _city_with_split(0.34430946, 0.65569054, 0.0) + scenario = _implement(city, 1, 0.9) + + split = scenario.split_fractions + assert (split.landfill_w_capture, split.landfill_wo_capture, split.dumpsite) == pytest.approx( + (0.9, 0.1, 0.0), abs=1e-12 + ) + fractions = [landfill.fraction_of_waste for landfill in scenario.landfills] + assert fractions == pytest.approx([0.9, 0.1, 0.0], abs=1e-12)