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
19 changes: 17 additions & 2 deletions SWEET_python/city_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions changelog/2026-10.md
Original file line number Diff line number Diff line change
@@ -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`.
([#77](https://github.com/RMI/SWEET_python/pull/77),
[RMI/WasteMAP#857](https://github.com/RMI/WasteMAP/pull/857))
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 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