Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions SWEET_python/city_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions changelog/2026-10.md
Original file line number Diff line number Diff line change
@@ -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))
1 change: 1 addition & 0 deletions changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
191 changes: 191 additions & 0 deletions tests/test_default_waste_per_capita.py
Original file line number Diff line number Diff line change
@@ -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
Loading