diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 68aebc4..4028d53 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -522,6 +522,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): """ @@ -7234,18 +7282,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( @@ -7262,7 +7311,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 index c4bba5c..054186c 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -21,6 +21,12 @@ Vietnam, South Africa, Canada and Germany. Its disposal split came out (0, 0, 0), so its landfills took none of its waste. It now gets the split the cities table gives a city with no landfill data (model-output change). +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). + ## Added - **Seven more Climate TRACE modelling decisions are marked where SWEET applies them**: @@ -187,6 +193,49 @@ cities table gives a city with no landfill data (model-output change). ([#74](https://github.com/RMI/SWEET_python/pull/74), [RMI/WasteMAP#853](https://github.com/RMI/WasteMAP/pull/853)) +- **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)) + ## Known limitations - **The three regions fall back to all dumpsite, Brunei apart.** Central Asia diff --git a/changelog/README.md b/changelog/README.md index 0268bed..decbbfe 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -30,7 +30,7 @@ shows the month up to that release. Entries are not grouped by release. Newest first: -- [2026-10](2026-10.md) — Indonesia sizes its waste from its own IPCC per-capita row instead of the South-Eastern Asia default, 2.4× higher (model-output change); a site with no recorded type is guessed by Climate TRACE's rule (income, region and named countries) in every SWEET path, moving 55 countries' unknown sites from open dump to sanitary landfill in the site tool (model-output change); gas collection efficiency and oxidation follow a site's reports year by year on rows that carry their year, and a modelled site keeps its measured efficiency (no output change on current data); `load_csv_new` normalizes a city's composition as the other loaders do, so WasteMAP's city DST runs for São Paulo (99%) and Brasília (87%), whose every scenario it rejected as "Invalid waste fractions"; the city DST of Brazil's precomputed cities scales by 100 ÷ their published sum (Brasília +14.9%, most −0.1%), and the no-change scenario reproduces the baseline (model-output change); a Custom Location city models landfill methane in every country: in 31 (Southern Asia, South-Eastern Asia and Southern Africa, which have no regional disposal row, plus Canada, Germany and Switzerland, whose rows are all zeros) its disposal split was (0, 0, 0), so its landfills took none of its waste; it now gets the cities table's split for a city with no landfill data (model-output change) +- [2026-10](2026-10.md) — Indonesia sizes its waste from its own IPCC per-capita row instead of the South-Eastern Asia default, 2.4× higher (model-output change); a site with no recorded type is guessed by Climate TRACE's rule (income, region and named countries) in every SWEET path, moving 55 countries' unknown sites from open dump to sanitary landfill in the site tool (model-output change); gas collection efficiency and oxidation follow a site's reports year by year on rows that carry their year, and a modelled site keeps its measured efficiency (no output change on current data); `load_csv_new` normalizes a city's composition as the other loaders do, so WasteMAP's city DST runs for São Paulo (99%) and Brasília (87%), whose every scenario it rejected as "Invalid waste fractions"; the city DST of Brazil's precomputed cities scales by 100 ÷ their published sum (Brasília +14.9%, most −0.1%), and the no-change scenario reproduces the baseline (model-output change); a Custom Location city models landfill methane in every country: in 31 (Southern Asia, South-Eastern Asia and Southern Africa, which have no regional disposal row, plus Canada, Germany and Switzerland, whose rows are all zeros) its disposal split was (0, 0, 0), so its landfills took none of its waste; it now gets the cities table's split for a city with no landfill data (model-output change); 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)