diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index bda2ed9..3beeba8 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -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: diff --git a/changelog/2026-10.md b/changelog/2026-10.md index f325bfb..55bd18e 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -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**: @@ -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), @@ -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 diff --git a/changelog/README.md b/changelog/README.md index f5728ae..21959c5 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) +- [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) 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)