Skip to content
Merged
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
19 changes: 17 additions & 2 deletions SWEET_python/city_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,12 +309,27 @@ def _city_growth(iso3, anchor_year, population, city_historic, city_future):
return _country_growth(iso3, anchor_year, city_historic, city_future)


#: SWEET's own country names, lower-cased, to the codes its defaults are keyed by.
_ISO3_BY_NAME = {name.lower(): iso3 for name, iso3 in defaults_2019.country_to_iso3.items()}


def _iso3_for(country):
"""The ISO3 code for a country code or name. Raises LookupError if there is none.

An exact match first, because the fuzzy search ranks subdivisions: it resolves
"MUS" to Turkey, whose province Mus matches, rather than Mauritius.
SWEET's own country table first, by code and then by name, ignoring case as
pycountry does. pycountry has no Kosovo: it can't find XKX, the code SWEET's
defaults are keyed by, and its fuzzy search resolves "Kosovo" to Serbia, whose
province Kosovo-Metohija matches.

Then pycountry, an exact match first, because the fuzzy search ranks
subdivisions: it resolves "MUS" to Turkey, whose province Mus matches, rather
than Mauritius.
"""
if isinstance(country, str):
if country.upper() in defaults_2019.region_lookup_iso3:
return country.upper()
if country.lower() in _ISO3_BY_NAME:
return _ISO3_BY_NAME[country.lower()]
try:
return pycountry.countries.lookup(country).alpha_3
except LookupError:
Expand Down
52 changes: 50 additions & 2 deletions changelog/2026-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ it sets, not just the gas capture. Its results fall another 10–13% after the
implementation year: a city that landfills everything ends up 65.3% below the
no-change scenario in 2040 instead of 60% (model-output change).

A Custom Location in Kosovo builds. `City.dst_baseline_blank`
raised "Country 'XKX' not found." for Kosovo's code, so WasteMAP's city tool
answered 500, and the site tool modelled a point in Kosovo as Serbia. SWEET now
looks a country up in its own table before asking pycountry, which has no Kosovo
(model-output change for a country given by name).

## Added

- **Seven more Climate TRACE modelling decisions are marked where SWEET applies them**:
Expand Down Expand Up @@ -191,8 +197,8 @@ no-change scenario in 2040 instead of 60% (model-output change).
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).
249 countries a Custom Location could be built for. Kosovo's `XKX` couldn't
be looked up then; #77 fixes that.

Tests: `tests/test_custom_location_disposal_split.py`.
([#74](https://github.com/RMI/SWEET_python/pull/74),
Expand Down Expand Up @@ -290,6 +296,48 @@ no-change scenario in 2040 instead of 60% (model-output change).
([#78](https://github.com/RMI/SWEET_python/pull/78),
[RMI/WasteMAP#856](https://github.com/RMI/WasteMAP/pull/856))

- **A Custom Location or site in Kosovo is modelled as Kosovo** (model-output
change for a country given by name). `_iso3_for`, which `dst_baseline_blank`,
`cityparams_obj_for_blank_site` and `sdst_prepopulate` use to turn the caller's
country into a code, asked pycountry first. pycountry has no Kosovo:
- **By code it raised.** XKX is the code SWEET's defaults and WasteMAP's
country list key Kosovo by. pycountry raised `LookupError` for it, so
`dst_baseline_blank` and `cityparams_obj_for_blank_site` raised
`ValueError("Country 'XKX' not found.")`. WasteMAP's city tool answered 500
for a Custom Location in Kosovo, and its site tool did the same for a
Climate TRACE site there.
- **By name it was Serbia.** The fuzzy search resolved "Kosovo" to Serbia,
whose province Kosovo-Metohija matches. `sdst_prepopulate` names the country
with the geocoder's English name, so the site tool's prefill for a point in
Kosovo returned Serbia's code and defaults, and the run that followed
modelled Serbia. The same search resolved "Curacao" to the Netherlands, and
found nothing for Macau, Pitcairn Islands, Svalbard and Jan Mayen Islands,
US Minor Islands, or French Southern and Antarctic Lands.

`_iso3_for` now tries SWEET's own table first, by code
(`defaults_2019.region_lookup_iso3`) and then by name
(`defaults_2019.country_to_iso3`), ignoring case as pycountry does. Anything
else goes to pycountry as before. Kosovo has no country rows in SWEET's
defaults, so it takes Southern Europe's, and it grows with its own UN
population series.

Effect:
- **A Custom Location in Kosovo** of 2 million people (500 mm, 25 °C) emits
38,768 t CH4 in 2040. By code it raised before; by name it was Serbia's
26,818 t.
- **The other 249 countries WasteMAP offers are bit-identical.** Each code
already resolved to itself.
- **The site tool's prefill for a point in Kosovo** returns XKX instead of
SRB. With it come Southern Europe's composition (food 35.7% instead of
Serbia's 44.3%), a food k of 0.333 instead of 0.143, and Kosovo's population
growth (−0.08% a year instead of −0.76%).
- **Climate TRACE's pipeline is unaffected.** None of its paths calls
`_iso3_for`.

Tests: `tests/test_country_lookup.py`.
([#77](https://github.com/RMI/SWEET_python/pull/77),
[RMI/WasteMAP#857](https://github.com/RMI/WasteMAP/pull/857))

## Known limitations

- **The three regions fall back to all dumpsite, Brunei apart.** Central Asia
Expand Down
2 changes: 1 addition & 1 deletion changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
- [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)
- [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
119 changes: 119 additions & 0 deletions tests/test_country_lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""A Custom Location in Kosovo builds, and a site in Kosovo is modelled as Kosovo.

pycountry has no Kosovo. ``_iso3_for`` asked pycountry first, so Kosovo's code XKX,
which SWEET's defaults and WasteMAP's country list both use, raised LookupError:
``City.dst_baseline_blank`` turned it into "Country 'XKX' not found." and WasteMAP's
city tool answered 500. By name it was worse: the fuzzy search resolved "Kosovo" to
Serbia, whose province Kosovo-Metohija matches, so the site tool's prefill for a
point in Kosovo returned Serbia's defaults and code. SWEET's own country table now
comes first.
"""

import asyncio
from types import SimpleNamespace

import pandas as pd
import pytest

from SWEET_python import city_params, defaults_2019
from SWEET_python.city_params import City
from SWEET_python.class_defs import Variant
from SWEET_python.population import country_population_series

KOSOVO = country_population_series("XKX")


def test_a_custom_location_in_kosovo_builds():
city = City("*")
city.dst_baseline_blank("XKX", 2_000_000, 500.0, 25.0)

assert city.baseline_parameters.total_emissions["total"].loc[2040] > 0
assert city.baseline_parameters.population_series.equals(KOSOVO)


def test_a_custom_site_in_kosovo_builds():
city = City("custom site")
city.cityparams_obj_for_blank_site(
country="XKX",
population=None,
precipitation=500.0,
temperature=10.0,
waste_fractions=Variant(baseline=[0.1] * 10, scenario=[0.1] * 10),
waste_mass_year=Variant[int](baseline=2025, scenario=2025),
)

assert city.baseline_parameters.population_series.equals(KOSOVO)


def test_the_site_tool_prefills_a_point_in_kosovo_as_kosovo(monkeypatch):
"""The prefill names the country with the geocoder's English name, "Kosovo"."""
place = SimpleNamespace(raw={"address": {"country": "Kosovo"}})
monkeypatch.setattr(
city_params, "create_geolocator", lambda: SimpleNamespace(reverse=lambda *a, **k: place)
)

def no_database(*args, **kwargs):
raise OSError("no database in this test")

async def no_async_database(*args, **kwargs):
no_database()

# The weather lookup falls back to its defaults when the database is unreachable.
monkeypatch.setattr(city_params.socket, "create_connection", no_database)
monkeypatch.setattr(city_params.asyncpg, "connect", no_async_database)

prefill = asyncio.run(
City("latlon_lookup").sdst_prepopulate(
"db", 5432, "user", "password", "wastemap", "disable",
sites_list=pd.DataFrame(),
latlon=(42.66, 21.17), # Pristina
)
)

assert prefill["iso3"] == "XKX"


@pytest.mark.parametrize(
"given, iso3",
[
("XKX", "XKX"),
("xkx", "XKX"),
("Kosovo", "XKX"), # was Serbia
("kosovo", "XKX"),
("Curacao", "CUW"), # was the Netherlands, whose subdivision Curaçao matches
("Macau", "MAC"), # these four raised LookupError
("Pitcairn Islands", "PCN"),
("Svalbard and Jan Mayen Islands", "SJM"),
("US Minor Islands", "UMI"),
("Curaçao", "CUW"), # pycountry still answers for names SWEET spells differently
("United States", "USA"),
],
)
def test_sweets_own_country_table_comes_before_pycountry(given, iso3):
assert city_params._iso3_for(given) == iso3


def _resolve(country):
try:
return city_params._iso3_for(country)
except LookupError:
return None


def test_every_country_sweet_has_defaults_for_is_found_by_its_code():
wrong = {code: _resolve(code) for code in defaults_2019.region_lookup_iso3}
assert {code: got for code, got in wrong.items() if got != code} == {}


def test_every_country_sweet_names_is_found_by_its_name():
wrong = {name: _resolve(name) for name in defaults_2019.country_to_iso3}
assert {
name: got for name, got in wrong.items() if got != defaults_2019.country_to_iso3[name]
} == {}


def test_a_country_that_does_not_exist_is_still_not_found():
assert _resolve("ZZZ") is None
assert _resolve("Narnia") is None
with pytest.raises(ValueError, match="Country 'ZZZ' not found"):
City("*").dst_baseline_blank("ZZZ", 2_000_000, 500.0, 25.0)
Loading