diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 5f91894..bb7bbd7 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -1406,11 +1406,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 @@ -3372,11 +3373,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 index 40f7b1d..74e26c5 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -42,6 +42,12 @@ A blank or junk country is not found rather than modelled as the UK or Italy, and the site tool's prefill finds the country for a point in Turkey, both Congos and eight more places it used to fail on. +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). + ## Added - **Seven more Climate TRACE modelling decisions are marked where SWEET applies them**: @@ -392,6 +398,43 @@ and eight more places it used to fail on. ([#80](https://github.com/RMI/SWEET_python/pull/80), [RMI/WasteMAP#859](https://github.com/RMI/WasteMAP/pull/859)) +- **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 (Hugh's call, + 2026-10-17). WasteMAP's map build appends that year to the three "Data + Source" columns, so those follow. WasteMAP's API already returns the + population year as `waste_mass_year` (RMI/WasteMAP#860), so the label and + that field now agree. + - **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)) + ## 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 06b5f15..46de57c 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); 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); 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); a Custom Location in Kosovo builds: SWEET looks a country up in its own table before asking pycountry, which has no Kosovo, so Kosovo's code XKX no longer raises "Country 'XKX' not found." (WasteMAP answered 500), and the name "Kosovo" no longer resolves to Serbia, which the site tool's prefill for a point in Kosovo returned (model-output change for a country given by name); a blank or junk country is not found instead of being modelled as another country (`""` was the UK, `"None"` Italy), and the site tool's prefill finds the country for a point in Turkey, both Congos and eight more places, and says so for a point in none, where it crashed (model-output change for a point in Sint Maarten) +- [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); 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); a Custom Location in Kosovo builds: SWEET looks a country up in its own table before asking pycountry, which has no Kosovo, so Kosovo's code XKX no longer raises "Country 'XKX' not found." (WasteMAP answered 500), and the name "Kosovo" no longer resolves to Serbia, which the site tool's prefill for a point in Kosovo returned (model-output change for a country given by name); a blank or junk country is not found instead of being modelled as another country (`""` was the UK, `"None"` Italy), and the site tool's prefill finds the country for a point in Turkey, both Congos and eight more places, and says so for a point in none, where it crashed (model-output change for a point in Sint Maarten); 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