diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index a7b7e8f..32eedd5 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -4725,40 +4725,11 @@ def dst_baseline_blank( textiles=pd.Series(ks.get("textiles", 0.0), index=years), ) - # Determine waste split fractions using .get() method - dumpsite_frac = defaults_2019.fraction_open_dumped_country.get( - iso3, defaults_2019.fraction_open_dumped.get(region, 0) - ) - landfill_wo_capture_frac = defaults_2019.fraction_landfilled_country.get( - iso3, defaults_2019.fraction_landfilled.get(region, 0) + # Determine waste split fractions, as the cities table does for a city + # with no landfill data + split_fractions = SplitFractions( + **defaults_2019.disposal_split_for(iso3, region) ) - landfill_w_capture_frac = 0.0 # Default as per original function - - try: - split_fractions = SplitFractions( - dumpsite=dumpsite_frac, - landfill_wo_capture=landfill_wo_capture_frac, - landfill_w_capture=landfill_w_capture_frac, - ) - except KeyError: - if self.region in defaults_2019.landfill_default_regions: - split_fractions = SplitFractions( - landfill_w_capture=0.0, landfill_wo_capture=1.0, dumpsite=0.0 - ) - else: - split_fractions = SplitFractions( - landfill_w_capture=0.0, landfill_wo_capture=0.0, dumpsite=1.0 - ) - - # Normalize split fractions - split_total = sum(split_fractions.model_dump().values()) - if split_total > 0: - split_fractions = SplitFractions( - **{ - site: frac / split_total - for site, frac in split_fractions.model_dump().items() - } - ) # Instantiate landfill objects years_range = range(MODEL_START_YEAR, MODEL_END_YEAR + 1) diff --git a/SWEET_python/defaults_2019.py b/SWEET_python/defaults_2019.py index 83ff96e..0beec23 100644 --- a/SWEET_python/defaults_2019.py +++ b/SWEET_python/defaults_2019.py @@ -1284,6 +1284,48 @@ def get_precipitation_zone(rainfall): country_to_iso3[country]: value for country, value in fraction_landfilled_country.items() } + + +def disposal_split_for(iso3, region): + """Default disposal split for a city with no landfill data of its own. + + Returns the shares of disposed waste sent to ``landfill_w_capture``, + ``landfill_wo_capture`` and ``dumpsite``, summing to 1. The country's row is + used when it sends waste to a landfill or a dump, else the region's; a place + with neither is all landfill in ``landfill_default_regions`` and all dumpsite + elsewhere. + + Southern Asia, South-Eastern Asia and Southern Africa have no regional row + (the other regional tables carry them as ``0.0 # np.nan``), so their + countries without a row of their own take that fallback. Canada's, Germany's + and Switzerland's rows are all zeros, so they take their region's, which is + landfill only. + + This is the split ``City.load_andre_params`` gives a cities-table row with no + landfill data, so a Custom Location starts from the same split as a mapped + city in the same country. + """ + for key, dumped, landfilled in ( + (iso3, fraction_open_dumped_country, fraction_landfilled_country), + (region, fraction_open_dumped, fraction_landfilled), + ): + dumpsite = dumped.get(key, 0.0) + landfill = landfilled.get(key, 0.0) + total = dumpsite + landfill + if total > 0: + return { + "landfill_w_capture": 0.0, + "landfill_wo_capture": landfill / total, + "dumpsite": dumpsite / total, + } + landfills = region in landfill_default_regions + return { + "landfill_w_capture": 0.0, + "landfill_wo_capture": 1.0 if landfills else 0.0, + "dumpsite": 0.0 if landfills else 1.0, + } + + fraction_incinerated_country = { "Japan": 0.76, "South Korea": 0.22, diff --git a/changelog/2026-10.md b/changelog/2026-10.md new file mode 100644 index 0000000..859b244 --- /dev/null +++ b/changelog/2026-10.md @@ -0,0 +1,69 @@ +# SWEET_python Changelog — October 2026 + +**Highlights:** A Custom Location city modelled no landfill methane at all in 31 +countries, among them Pakistan, India, Bangladesh, Indonesia, the Philippines, +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). + +## Fixed + +- **A Custom Location buries or dumps its waste in every country** + (model-output change). `City.dst_baseline_blank`, which WasteMAP runs for any + city not in the cities table, looked a country's disposal split up as + `fraction_open_dumped_country.get(iso3, fraction_open_dumped.get(region, 0))`, + and the same for `fraction_landfilled`. Two gaps in those tables made the split + (0, 0, 0): + - **Southern Asia, South-Eastern Asia and Southern Africa have no regional + row.** This is not a label mismatch: every other regional table carries these + three regions under the same names, as `0.0 # np.nan`. None of their 28 + countries here has a row of its own. + - **Canada's, Germany's and Switzerland's country rows are all zeros.** + + The normalization step skipped a zero total, so every landfill's + `fraction_of_waste` was 0 and the waste the city didn't divert left the model. + The `except KeyError` fallback written for this never ran, because `.get` + never raises. + + The new `defaults_2019.disposal_split_for(iso3, region)` uses the country's row + when it sends waste to a landfill or a dump, else the region's. A place with + neither is all landfill in `landfill_default_regions` and all dumpsite + elsewhere. That is the split `City.load_andre_params` gives a cities-table row + with no landfill data, which a test checks for one country per region and + rule. `load_andre_params` itself is unchanged, so the cities table doesn't + move. + + 2040 emissions for a Custom Location of 2 million people (500 mm, 25 °C): + - **The 28 countries in the three regions dump all their waste.** Pakistan + goes from 0 to 21,156 t CH4, the Philippines to 10,513, South Africa to + 33,478 and India to 5,284. + - **Canada, Germany and Switzerland landfill it.** Canada goes from 40 to + 74,846 t, Germany from 26 to 29,521 and Switzerland from 36 to 17,263. The + old figures were compost alone. + - **The other 218 countries are bit-identical.** This was checked across all + 249 countries a Custom Location can be built for (Kosovo's `XKX` can't be + looked up, before or after). + + Tests: `tests/test_custom_location_disposal_split.py`. + ([#74](https://github.com/RMI/SWEET_python/pull/74), + [RMI/WasteMAP#853](https://github.com/RMI/WasteMAP/pull/853)) + +## Known limitations + +- **The three regions fall back to all dumpsite.** Central Asia and Rest of + Oceania have done the same since #18, for the same reason: the source gives + them no disposal figures. That is coarse for countries such as South Africa, + which landfill most of their waste. The cities table uses the same fallback. + ([#74](https://github.com/RMI/SWEET_python/pull/74)) + +- **Germany and Switzerland now landfill everything they don't compost or + burn.** Their rows file the rest under "unspecified", which holds recycling, + and Custom Location has no recycling default. That overstates their + landfilling, as it already did for Japan, Austria, the Netherlands and Sweden, + whose rows landfill 1–3%. ([#74](https://github.com/RMI/SWEET_python/pull/74)) + +- **Sri Lanka gives the highest figure of the 31: 122,745 t.** Its per-capita + default in `msw_per_capita_country` is 1.86 t/year (5.1 kg/person/day). + Fourteen countries there are above 3 kg/person/day, Kuwait highest at 8.4. + That is a separate data problem, which the zero split had hidden for Sri Lanka. + ([#74](https://github.com/RMI/SWEET_python/pull/74)) diff --git a/changelog/README.md b/changelog/README.md index 4bb1323..d328d52 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) — 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-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_custom_location_disposal_split.py b/tests/test_custom_location_disposal_split.py new file mode 100644 index 0000000..08a5f2d --- /dev/null +++ b/tests/test_custom_location_disposal_split.py @@ -0,0 +1,177 @@ +"""A Custom Location buries or dumps its waste in every country. + +`City.dst_baseline_blank` (WasteMAP's Custom Location) looked up a country's +disposal split with `fraction_open_dumped_country.get(iso3, +fraction_open_dumped.get(region, 0))`, and the same for `fraction_landfilled`. +Two gaps in those tables made the split (0, 0, 0) for 31 countries: + +- Southern Asia, South-Eastern Asia and Southern Africa have no regional row. + This is not a label mismatch: every other regional table carries these three + regions under the same names, as `0.0 # np.nan`. None of their 28 countries + here has a row of its own. +- Canada's, Germany's and Switzerland's country rows are all zeros. + +With a zero split every landfill took 0% of the waste, so the city modelled no +landfill methane at all. Pakistan and the Philippines at 2M people gave 0 t CH4 +in 2040, against 28,745 t for Mexico. The `except KeyError` fallback written for +this never ran, because `.get` never raises. + +The cities table (`City.load_andre_params`) handles both gaps. A region with no +row falls back to all landfill or all dumpsite by `landfill_default_regions`, and +the three zero-row countries are landfilled. Custom Location now takes the split +the cities table gives a city with no landfill data +(`defaults_2019.disposal_split_for`). +""" + +import numpy as np +import pytest + +from SWEET_python import defaults_2019 +from SWEET_python.city_params import City + +POPULATION = 2_000_000 +PRECIPITATION = 500.0 +TEMPERATURE = 25.0 +YEAR = 2040 + +# Every country whose Custom Location split was (0, 0, 0). +NO_ROW = { + "Southern Asia": ["BGD", "BTN", "IND", "IOT", "IRN", "LKA", "MDV", "NPL", "PAK"], + "South-Eastern Asia": [ + "BRN", "IDN", "KHM", "LAO", "MMR", "MYS", "PHL", "THA", "TLS", "VNM", + ], + "Southern Africa": ["ATF", "BWA", "LSO", "MOZ", "NAM", "SHN", "SWZ", "ZAF", "ZWE"], +} +ZERO_COUNTRY_ROW = ["CAN", "CHE", "DEU"] +ZEROED = sorted([iso3 for codes in NO_ROW.values() for iso3 in codes] + ZERO_COUNTRY_ROW) + + +def _custom_location(iso3): + city = City("Custom Location") + city.dst_baseline_blank(iso3, POPULATION, PRECIPITATION, TEMPERATURE) + return city.baseline_parameters + + +class _NoDataRow(dict): + """A cities-table row in which every field not given is missing (NaN).""" + + def __missing__(self, key): + return np.nan + + +def _cities_table_split(iso3): + """The split `load_andre_params` gives a city with no landfill data in `iso3`. + + The row says where the city is and nothing about its waste: every landfill and + diversion share is NaN, so the loader takes its defaults for both, which is + the cities-table counterpart of a Custom Location. + """ + country = next( + name + for name, code in defaults_2019.country_to_iso3.items() + if code == iso3 and name in defaults_2019.region_lookup + ) + row = _NoDataRow( + { + "country": country, + "iso": iso3, + "population_count": POPULATION, + "population_year": 2022.0, + "population_data_source": "test", + "msw_generated_metric_tons_per_year": 500_000.0, + "msw_generated_year": 2022.0, + "mean_yearly_precip_2000_2021": PRECIPITATION, + "mean_yearly_temp_2000_2021": TEMPERATURE, + "Temperature (C)": TEMPERATURE, + "latitude": 0.0, + "longitude": 0.0, + "historic_growth_rate": 1.0, + "future_growth_rate": 1.0, + } + ) + city = City("Cities table") + city.load_andre_params(row) + return city.baseline_parameters.split_fractions + + +def _how_the_split_is_decided(iso3, region): + if iso3 in defaults_2019.fraction_open_dumped_country: + row = ( + defaults_2019.fraction_open_dumped_country[iso3] + + defaults_2019.fraction_landfilled_country[iso3] + ) + return "country row" if row > 0 else "all-zero country row" + if region in defaults_2019.fraction_open_dumped: + return "regional row" + return "no row" + + +def _one_country_per_region_and_rule(): + """The first country of each (region, which row decides) pair, plus Germany. + + Comparing every country with the cities table takes ten seconds; one per + pair runs every branch of both lookups for every region in a fifth of that. + Germany is added so all three zero-row countries are covered. + """ + first = {} + for iso3, region in sorted(defaults_2019.region_lookup_iso3.items()): + first.setdefault((region, _how_the_split_is_decided(iso3, region)), iso3) + return sorted(set(first.values()) | set(ZERO_COUNTRY_ROW)) + + +@pytest.mark.parametrize("iso3", ZEROED) +def test_a_custom_location_models_methane_from_the_waste_it_disposes_of(iso3): + parameters = _custom_location(iso3) + + assert sum(landfill.fraction_of_waste for landfill in parameters.landfills) == ( + pytest.approx(1.0) + ) + # Landfill methane only. Germany's total was already 26 t from composting + # while its landfills took nothing. + assert parameters.landfill_emissions.loc[YEAR, "total"] > 0 + + +@pytest.mark.parametrize("iso3", ["PAK", "PHL", "ZAF"]) +def test_a_country_in_a_region_with_no_row_dumps_its_waste(iso3): + # None of the three regions is in `landfill_default_regions`. + split = _custom_location(iso3).split_fractions + + assert (split.landfill_w_capture, split.landfill_wo_capture, split.dumpsite) == ( + 0.0, + 0.0, + 1.0, + ) + + +@pytest.mark.parametrize("iso3", ZERO_COUNTRY_ROW) +def test_a_country_whose_row_is_all_zeros_landfills_its_waste(iso3): + split = _custom_location(iso3).split_fractions + + assert (split.landfill_w_capture, split.landfill_wo_capture, split.dumpsite) == ( + 0.0, + 1.0, + 0.0, + ) + + +@pytest.mark.parametrize("iso3", _one_country_per_region_and_rule()) +def test_a_custom_location_starts_from_the_cities_tables_split(iso3): + custom = _custom_location(iso3).split_fractions + table = _cities_table_split(iso3) + + assert custom.model_dump() == pytest.approx(table.model_dump()) + + +def test_every_country_has_a_disposal_split(): + splits = { + iso3: defaults_2019.disposal_split_for(iso3, region) + for iso3, region in defaults_2019.region_lookup_iso3.items() + } + + assert { + iso3: split + for iso3, split in splits.items() + if set(split) != {"landfill_w_capture", "landfill_wo_capture", "dumpsite"} + or min(split.values()) < 0 + or sum(split.values()) != pytest.approx(1.0) + } == {}