diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index a7b7e8f..6f642fc 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -1196,11 +1196,12 @@ def load_andre_params(self, row, backfill=False): self.waste_mass_defaults = True if iso3 in defaults_2019.msw_per_capita_country: waste_per_capita = defaults_2019.msw_per_capita_country[iso3] - year_of_data_msw = 2019 else: waste_per_capita = defaults_2019.msw_per_capita_defaults[region] - year_of_data_msw = 2019 waste_mass_load = waste_per_capita * population / 1000 * 365 + # A rate times this population is already the population year's + # waste, so the year adjustment below must not move it again. + year_of_data_msw = int(year_of_data_pop) # Subtract mass that is informally collected # self.informal_fraction = np.nan_to_num(row['percent_informal_sector_percent_collected_by_informal_sector_percent']) / 100 @@ -3189,11 +3190,12 @@ def import_basics(self, row) -> None: waste_mass_defaults = True if self.iso3 in defaults_2019.msw_per_capita_country: waste_per_capita = defaults_2019.msw_per_capita_country[self.iso3] - year_of_data_msw = 2019 else: waste_per_capita = defaults_2019.msw_per_capita_defaults[self.region] - year_of_data_msw = 2019 waste_mass_load = waste_per_capita * population / 1000 * 365 + # A rate times this population is already the population year's + # waste, so the year adjustment below must not move it again. + year_of_data_msw = int(year_of_data_pop) # Subtract mass that is informally collected # self.informal_fraction = np.nan_to_num(row['percent_informal_sector_percent_collected_by_informal_sector_percent']) / 100 diff --git a/changelog/2026-10.md b/changelog/2026-10.md new file mode 100644 index 0000000..6673ebc --- /dev/null +++ b/changelog/2026-10.md @@ -0,0 +1,44 @@ +# SWEET_python Changelog — October 2026 + +**Highlights:** A city with no waste figure of its own now generates waste at +its country's per-capita default. `load_andre_params` used to move that default +from 2019 to the city's population year, although it already described the +population year, so the 39 cities that take this path came out up to 35% below +or 9% above the default (model-output change). + +## Fixed + +- **A city with no waste figure generates waste at the default rate** + (model-output change). When a cities-table row has neither + `msw_generated_metric_tons_per_year` nor `msw_collected_metric_tons_per_year`, + `City.load_andre_params` sizes the city's waste as the IPCC per-capita default + (`defaults_2019.msw_per_capita_country`, else the region's + `msw_per_capita_defaults`) times the city's population. That population is + the one for the row's `population_year`, so the mass already describes that + year. The loader labelled it 2019 instead, and the adjustment that moves a + surveyed tonnage from its survey year to the population year then moved it a + second time. The city got the default × P(population year) / P(2019), or the + compounded equivalent for a big city on its own UN rates. The default now + takes the population year as its year, so that adjustment leaves it alone. A + surveyed tonnage is still moved as before. + - **Who moves:** the 39 of 5,962 cities in WasteMAP's June 2026 cities table + with "Defaults Used: Waste Mass". Their waste, and so their CH4, changes by + −7.9% (Abeokuta, population year 2022) to +53.9% (Kano, 2006): 30 rise and + 9 fall. Kuwait City goes from 7.92 to 8.36 kg per person per day (+5.5%), + Tel Aviv +14.7%, Melbourne +5.4%. Their 2025 total, 395 kt CH4 as + published, rises by about 27 kt. + - **What else changes for those rows:** "Year of Data Collection (MSW)" + becomes the population year (2005–2022) instead of 2019. WasteMAP's map + build appends that year to the three "Data Source" columns, and its API + returns it as `waste_mass_year`, so those follow. + - **When:** nothing moves until WasteMAP's cities table is rebuilt, because + the map and the City DST read the published table. Climate TRACE doesn't + call `load_andre_params`. + - `City.import_basics`, the SINIR loader, had the same rule and gets the same + fix. Only `model_city_via_sites` reaches it, and nothing calls that. + - The copies in `import_basics_site` are unchanged. They size a site for a + placeholder population of 100, so a default there means nothing either + way, and Climate TRACE drops a site with no tonnage before SWEET runs. + + Tests: `tests/test_default_waste_per_capita.py`. + ([#79](https://github.com/RMI/SWEET_python/pull/79)) diff --git a/changelog/README.md b/changelog/README.md index 4bb1323..4963c6b 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 city with no waste figure of its own generates waste at its country's per-capita default, instead of having that default moved a second time as if it described 2019; the 39 such cities in WasteMAP's cities table change by −8% to +54% (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_default_waste_per_capita.py b/tests/test_default_waste_per_capita.py new file mode 100644 index 0000000..9a635ab --- /dev/null +++ b/tests/test_default_waste_per_capita.py @@ -0,0 +1,191 @@ +"""A city with no waste figure of its own generates waste at the per-capita default. + +When a cities-table row has neither a generated nor a collected tonnage, +`City.load_andre_params` sizes the city's waste from the IPCC per-capita default: +the rate times the city's population, which is the population of the row's +population year. That mass already describes the population year. The loader +nonetheless labelled it 2019 and then ran the adjustment that moves a surveyed +tonnage from its survey year to the population year, so the default was moved a +second time and the city got default x P(population year) / P(2019). + +In the June 2026 cities table, Kuwait City published 7.92 kg/person/day against +Kuwait's default of 3.05 t/yr (8.36 kg/day), and Eldoret (population year 2005) +got 57% of Kenya's rate. 39 cities take this path. A default now reaches the model +unchanged, and is published with the year it describes: the population year. +""" + +import numpy as np +import pandas as pd +import pytest + +from SWEET_python import defaults_2019 +from SWEET_python.city_params import City +from SWEET_python.population import country_population_series + +NOT_REPORTED = [ + "msw_generated_metric_tons_per_year", + "msw_collected_metric_tons_per_year", + "msw_collected_year", + "msw_generated_year", + "composition_food_organic_waste_percent", + "composition_yard_garden_green_waste_percent", + "composition_wood_percent", + "composition_paper_cardboard_percent", + "composition_textiles_percent", + "composition_plastic_percent", + "composition_metal_percent", + "composition_glass_percent", + "composition_rubber_leather_percent", + "composition_other_percent", + "waste_treatment_compost_percent", + "waste_treatment_anaerobic_digestion_percent", + "waste_treatment_incineration_percent", + "waste_treatment_advanced_thermal_treatment_percent", + "waste_treatment_recycling_percent", + "waste_treatment_sanitary_landfill_landfill_gas_system_percent", + "waste_treatment_controlled_landfill_percent", + "waste_treatment_landfill_unspecified_percent", + "waste_treatment_open_dump_percent", +] + +# (city, country, ISO3, population, population year, the city's own UN rates), as +# the June 2026 table published them. Kuwait City and Namangan are big enough to +# keep their own rates; Eldoret and Kadoma City grow with their country's population +# series. Uzbekistan has no country row, so Namangan takes Central Asia's rate. +# Kadoma City's population year is after 2019, so the old code moved it up, not down. +CITIES = [ + ("Kuwait City", "Kuwait", "KWT", 2_779_000, 2015, 1.0574, 1.0134), + ("Eldoret", "Kenya", "KEN", 167_016, 2005, 1.0551, 1.0405), + ("Kadoma City", "Zimbabwe", "ZWE", 116_300, 2022, 1.0334, 1.0241), + ("Namangan", "Uzbekistan", "UZB", 521_000, 2015, 1.0237, 1.0134), +] +IDS = [city[0] for city in CITIES] + + +def _row(country, iso3, population, population_year, historic, future, **reported): + """A cities-query row with nothing measured but the population, plus ``reported``.""" + row = pd.Series( + { + "population_data_source": "World Bank", + "country": country, + "iso": iso3, + "population_year": float(population_year), + "population_count": float(population), + "historic_growth_rate": historic, + "future_growth_rate": future, + "latitude": 0.0, + "longitude": 0.0, + "mean_yearly_precip_2000_2021": 600.0, + "mean_yearly_temp_2000_2021": 20.0, + **{column: np.nan for column in NOT_REPORTED}, + **reported, + } + ) + # The cities query selects data_collection_year twice (composition, then + # treatment), and the loader falls back to the first when no MSW year is given. + return pd.concat([row, pd.Series([2015.0, 2015.0], index=["data_collection_year"] * 2)]) + + +def _default_rate(country, iso3): + """kg/person/day: the country's IPCC row, else its region's.""" + if iso3 in defaults_2019.msw_per_capita_country: + return defaults_2019.msw_per_capita_country[iso3] + return defaults_2019.msw_per_capita_defaults[defaults_2019.region_lookup[country]] + + +def _loaded(name, row): + city = City(name) + city.load_andre_params(row) + return city + + +def _emissions(city): + """Total CH4 by year, the way the cities pipeline runs a loaded city.""" + for landfill in city.baseline_parameters.landfills: + landfill.estimate_emissions() + city.estimate_diversion_emissions(scenario=0) + city.sum_landfill_emissions(scenario=0, simple=True) + return city.baseline_parameters.total_emissions["total"] + + +@pytest.mark.parametrize("name, country, iso3, population, year, historic, future", CITIES, ids=IDS) +def test_a_city_with_no_waste_figure_generates_the_default_rate( + name, country, iso3, population, year, historic, future +): + city = _loaded(name, _row(country, iso3, population, year, historic, future)) + params = city.baseline_parameters + rate = _default_rate(country, iso3) + + assert city.waste_mass_defaults is True + assert params.waste_per_capita == pytest.approx(rate, rel=1e-12) + assert params.waste_mass.iloc[0] == pytest.approx(rate * population * 365 / 1000, rel=1e-12) + + +@pytest.mark.parametrize("name, country, iso3, population, year, historic, future", CITIES, ids=IDS) +def test_a_default_rate_is_published_with_the_population_year( + name, country, iso3, population, year, historic, future +): + params = _loaded(name, _row(country, iso3, population, year, historic, future)).baseline_parameters + + assert params.year_of_data_msw == params.year_of_data_pop == year + + +@pytest.mark.parametrize("name, country, iso3, population, year, historic, future", CITIES, ids=IDS) +def test_a_defaulted_city_is_modelled_as_if_it_reported_the_default_rate( + name, country, iso3, population, year, historic, future +): + tonnage = _default_rate(country, iso3) * population * 365 / 1000 + defaulted = _loaded(name, _row(country, iso3, population, year, historic, future)) + reported = _loaded( + name, + _row( + country, iso3, population, year, historic, future, + msw_generated_metric_tons_per_year=tonnage, + msw_generated_year=float(year), + ), + ) + + assert np.allclose(_emissions(defaulted), _emissions(reported), rtol=1e-12, atol=0) + + +def test_a_surveyed_tonnage_still_moves_from_its_survey_year_to_the_population_year(): + """The adjustment the default skipped is still right for a measured figure.""" + # A big city with its own rates: compounded from the 2012 survey to 2015. + kuwait = _loaded( + "Kuwait City", + _row( + "Kuwait", "KWT", 2_779_000, 2015, 1.0574, 1.0134, + msw_generated_metric_tons_per_year=1_500_000.0, + msw_generated_year=2012.0, + ), + ).baseline_parameters + assert kuwait.waste_mass.iloc[0] == pytest.approx(1_500_000 * 1.0574**3, rel=1e-12) + assert kuwait.year_of_data_msw == 2012 + + # A small city: back from the 2010 survey to 2005 along Kenya's population. + kenya = country_population_series("KEN") + eldoret = _loaded( + "Eldoret", + _row( + "Kenya", "KEN", 167_016, 2005, 1.0551, 1.0405, + msw_generated_metric_tons_per_year=30_000.0, + msw_generated_year=2010.0, + ), + ).baseline_parameters + assert eldoret.waste_mass.iloc[0] == pytest.approx( + 30_000 * kenya.loc[2005] / kenya.loc[2010], rel=1e-12 + ) + assert eldoret.year_of_data_msw == 2010 + + +def test_the_legacy_sinir_loader_generates_the_default_rate_too(): + """`import_basics` (reached only from `model_city_via_sites`) has the same rule.""" + basics = City("Kuwait City").import_basics( + _row("Kuwait", "KWT", 2_779_000, 2015, 1.0574, 1.0134) + ) + rate = defaults_2019.msw_per_capita_country["KWT"] + + assert basics["waste_mass_defaults"] is True + assert basics["waste_per_capita"] == pytest.approx(rate, rel=1e-12) + assert basics["waste_mass"] == pytest.approx(rate * 2_779_000 * 365 / 1000, rel=1e-12) + assert basics["year_of_data_msw"] == basics["year_of_data_pop"] == 2015