From 2b95a2b4803640eb80cd003508a048966b80605b Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Thu, 8 Oct 2026 17:02:17 -0700 Subject: [PATCH 1/2] A Custom Location in Kosovo builds: SWEET's own country table before pycountry pycountry has no Kosovo. _iso3_for asked it first, so XKX, the code SWEET's defaults and WasteMAP's country list use, raised LookupError, and dst_baseline_blank and cityparams_obj_for_blank_site raised "Country 'XKX' not found." (WasteMAP's city tool answered 500). By name, the fuzzy search resolved "Kosovo" to Serbia, so sdst_prepopulate prefilled a point in Kosovo as Serbia. _iso3_for now tries region_lookup_iso3 by code, then country_to_iso3 by name, both ignoring case, before pycountry. The other 249 codes WasteMAP offers resolve as before, so their output is bit-identical. Co-Authored-By: Claude Opus 5.5 --- SWEET_python/city_params.py | 19 +++++- changelog/2026-10.md | 51 +++++++++++++++ changelog/README.md | 1 + tests/test_country_lookup.py | 119 +++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 changelog/2026-10.md create mode 100644 tests/test_country_lookup.py diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index a7b7e8f..4845bbe 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -237,12 +237,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: diff --git a/changelog/2026-10.md b/changelog/2026-10.md new file mode 100644 index 0000000..24e958f --- /dev/null +++ b/changelog/2026-10.md @@ -0,0 +1,51 @@ +# SWEET_python Changelog — October 2026 + +**Highlights:** 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). + +## Fixed + +- **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,771 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`. + ([#PR](https://github.com/RMI/SWEET_python/pull/PR), + [RMI/WasteMAP#WMPR](https://github.com/RMI/WasteMAP/pull/WMPR)) diff --git a/changelog/README.md b/changelog/README.md index 4bb1323..3ec020c 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 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) diff --git a/tests/test_country_lookup.py b/tests/test_country_lookup.py new file mode 100644 index 0000000..11e12e0 --- /dev/null +++ b/tests/test_country_lookup.py @@ -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) From ec34d31328dba862892e2516502ff35c38c12809 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Thu, 8 Oct 2026 17:11:53 -0700 Subject: [PATCH 2/2] Changelog links the paired WasteMAP PR Co-Authored-By: Claude Opus 5.5 --- changelog/2026-10.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog/2026-10.md b/changelog/2026-10.md index 24e958f..5ef9cc8 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -47,5 +47,5 @@ looks a country up in its own table before asking pycountry, which has no Kosovo `_iso3_for`. Tests: `tests/test_country_lookup.py`. - ([#PR](https://github.com/RMI/SWEET_python/pull/PR), - [RMI/WasteMAP#WMPR](https://github.com/RMI/WasteMAP/pull/WMPR)) + ([#77](https://github.com/RMI/SWEET_python/pull/77), + [RMI/WasteMAP#857](https://github.com/RMI/WasteMAP/pull/857))