From b1e108139c30bc027d85b015314d9db549c9cdb0 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 07:50:20 -0700 Subject: [PATCH 1/6] A place's population by year can be worked out once and handed back to SWEET city_population_trajectory and country_population_trajectory give a city's or a country's population in every model year, with what its shape comes from, and load_csv_new takes a stored trajectory as population_series. WasteMAP's map build stores them so its city DST grows a city the way the build did. Co-Authored-By: Claude Opus 5.5 --- SWEET_python/city_params.py | 118 +++++++++++++++++++++++--- changelog/2026-10.md | 17 ++++ tests/test_population_trajectory.py | 126 ++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 tests/test_population_trajectory.py diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index bb7bbd7..4733981 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -311,6 +311,96 @@ def _city_growth(iso3, anchor_year, population, city_historic, city_future): return _country_growth(iso3, anchor_year, city_historic, city_future) +# What a population trajectory's shape comes from. WasteMAP publishes one of these with +# each trajectory its map-data build stores. +UN_CITY_RATES = "UN city rates" +UN_CITY_TRAJECTORY = "UN city trajectory" +COUNTRY_CURVE = "country curve" +COUNTRY_FALLBACK_RATES = "country fallback rates" + +#: The growth a Custom Location gets in a country SWEET has no population series for: +#: the world's urban population, 751 million in 1950, 4.3 billion in 2020 and 5.3 +#: billion projected for 2035, either side of CUSTOM_LOCATION_POPULATION_YEAR. +FALLBACK_GROWTH_RATE_HISTORIC = (4_300_000_000 / 751_000_000) ** (1 / (2020 - 1950)) +FALLBACK_GROWTH_RATE_FUTURE = (5_300_000_000 / 4_300_000_000) ** (1 / (2035 - 2020)) +#: The year a Custom Location's population describes (City.dst_baseline_blank). +CUSTOM_LOCATION_POPULATION_YEAR = 2022 + + +def _log_linear(points, years): + """``points`` ({year: people}) at each of ``years``, growing at a constant rate between + neighbouring points and at the nearest pair's rate beyond the first or last.""" + known = pd.Series(points, dtype=float).dropna() + known = known[known > 0].sort_index() + if len(known) < 2: + raise ValueError("A city's UN points need at least two years with a population.") + x = known.index.to_numpy(dtype=float) + y = np.log(known.to_numpy()) + years = np.asarray(years, dtype=float) + out = np.interp(years, x, y) + before, after = years < x[0], years > x[-1] + out[before] = y[0] + (years[before] - x[0]) * (y[1] - y[0]) / (x[1] - x[0]) + out[after] = y[-1] + (years[after] - x[-1]) * (y[-1] - y[-2]) / (x[-1] - x[-2]) + return np.exp(out) + + +def city_population_trajectory( + iso3, population_year, population, city_historic, city_future, city_points=None +): + """A city's population in every model year, and what its shape comes from. + + Returns ``(trajectory, source)``: people by year, MODEL_START_YEAR to + MODEL_END_YEAR, passing through ``population`` in ``population_year``. The shape is + the one SWEET grows the city's waste by (_city_growth): a UN-listed city's own two + rates, compounded either side of its population year (UN_CITY_RATES), or its + country's UN population year by year (COUNTRY_CURVE). So the trajectory, handed back + to SWEET as ``population_series``, grows the waste as SWEET would have. The rates + are a ratio, not a percent: 1.02 for 2% a year. + + ``city_points`` ({year: people}, the UN's own points for this city) replaces that + shape (UN_CITY_TRAJECTORY): constant growth between neighbouring points, the nearest + pair's beyond them. It is where full UN city trajectories come in, so no default + uses it. Pass only a city's own points: below 300,000 people the cities table's UN + figures are the nearest listed city's (UN_LISTED_CITY_MIN_POPULATION). + """ + years = np.arange(MODEL_START_YEAR, MODEL_END_YEAR + 1) + if city_points is not None: + shape = _log_linear(city_points, np.append(years, population_year)) + factors = shape[:-1] / shape[-1] + source = UN_CITY_TRAJECTORY + else: + series, historic, future = _city_growth( + iso3, population_year, population, city_historic, city_future + ) + factors = growth_factors_for_years(years, population_year, historic, future, series) + source = COUNTRY_CURVE if series is not None else UN_CITY_RATES + return pd.Series(population * factors, index=years, dtype=float), source + + +def country_population_trajectory(iso3): + """A country's population in every model year, and what it comes from. + + Returns ``(trajectory, source)``. With a UN WPP2024 series (population.py) it is + that, in people (COUNTRY_CURVE). Without one it is FALLBACK_GROWTH_RATE_* + compounded either side of CUSTOM_LOCATION_POPULATION_YEAR, which is how a Custom + Location there grows (COUNTRY_FALLBACK_RATES). SWEET has no population for such a + place, so that trajectory is 1.0 in CUSTOM_LOCATION_POPULATION_YEAR: its shape is + real and its level is not. + """ + years = np.arange(MODEL_START_YEAR, MODEL_END_YEAR + 1) + series = country_population_series(iso3) + if series is not None: + people = (series.reindex(years) * 1000).round() # the table is in thousands + return people.astype(float), COUNTRY_CURVE + factors = growth_factors_for_years( + years, + CUSTOM_LOCATION_POPULATION_YEAR, + FALLBACK_GROWTH_RATE_HISTORIC, + FALLBACK_GROWTH_RATE_FUTURE, + ) + return pd.Series(factors, index=years, dtype=float), COUNTRY_FALLBACK_RATES + + #: 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()} @@ -967,7 +1057,13 @@ def load_from_csv(self, db: pd.DataFrame, scenario: int = 0) -> None: self.baseline_parameters = city_parameters - def load_csv_new(self, db: pd.DataFrame, scenario: int = 0, dst: bool = False) -> None: + def load_csv_new( + self, + db: pd.DataFrame, + scenario: int = 0, + dst: bool = False, + population_series: Optional[pd.Series] = None, + ) -> None: """ Loads model parameters from the RMI WasteMAP GitHub repo data file. This replaces the deprecated load_from_csv method. @@ -975,6 +1071,9 @@ def load_csv_new(self, db: pd.DataFrame, scenario: int = 0, dst: bool = False) - Args: db (pd.DataFrame): DataFrame containing model parameters for all cities. scenario (str): The scenario name, defaults to 'baseline'. + population_series (pd.Series): the city's population by year, worked out + once by city_population_trajectory and stored. The city's waste grows + along it instead of along the shape _city_growth would choose now. Returns: None @@ -1131,6 +1230,7 @@ def load_csv_new(self, db: pd.DataFrame, scenario: int = 0, dst: bool = False) - # population, year by year (_city_growth). city_growth_historic = city_data["Population Growth Rate: Historic (%)"].values[0] / 100 + 1 city_growth_future = city_data["Population Growth Rate: Future (%)"].values[0] / 100 + 1 + stored_series = population_series population_series, growth_rate_historic, growth_rate_future = _city_growth( self.country, year_of_data_pop, @@ -1138,6 +1238,9 @@ def load_csv_new(self, db: pd.DataFrame, scenario: int = 0, dst: bool = False) - city_growth_historic, city_growth_future, ) + if stored_series is not None: + # The two rates stay the ones a person reads; the stored series is the growth. + population_series = stored_series city_instance_attrs = { "city_name": self.city_name, @@ -4860,14 +4963,9 @@ def dst_baseline_blank( precip_zone = defaults_2019.get_precipitation_zone(precipitation) years = range(MODEL_START_YEAR, MODEL_END_YEAR + 1) - # Calculate growth rates - population_1950 = 751_000_000 - population_2020 = 4_300_000_000 - population_2035 = 5_300_000_000 - growth_rate_historic = (population_2020 / population_1950) ** ( - 1 / (2020 - 1950) - ) - growth_rate_future = (population_2035 / population_2020) ** (1 / (2035 - 2020)) + # The rates for a country SWEET has no population series for. + growth_rate_historic = FALLBACK_GROWTH_RATE_HISTORIC + growth_rate_future = FALLBACK_GROWTH_RATE_FUTURE # Calculate waste per capita waste_per_capita = defaults_2019.msw_per_capita_country.get( @@ -4886,7 +4984,7 @@ def dst_baseline_blank( [waste_fractions_normalized] * len(years), index=years ) - year_of_data_pop = 2022 + year_of_data_pop = CUSTOM_LOCATION_POPULATION_YEAR # Calculate MEF for compost try: diff --git a/changelog/2026-10.md b/changelog/2026-10.md index 74e26c5..9ece92d 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -50,6 +50,23 @@ or 9% above the default (model-output change). ## Added +- **A place's population by year can be worked out once and handed back.** + `city_population_trajectory` gives a city's population in every model year, in + people, and what its shape comes from: a UN-listed city's own two rates ("UN city + rates") or its country's UN population ("country curve"), the choice + `_city_growth` makes. `country_population_trajectory` gives a country's UN + population, or for the 12 places with none the constant rate a Custom Location + there grows by ("country fallback rates"). `load_csv_new` takes a stored + trajectory as `population_series` and grows the city along it. WasteMAP's map + build stores one per city and country, and its city DST hands a city's back, so + the city DST cannot grow a city differently from the build behind it when + WasteMAP's SWEET pin moves. Handed back, a trajectory grows a city as SWEET would + have, to within about one part in 10^15: SWEET divides each year's population by + the population-year figure, which can land a unit in the last place away from the + factor it used to compute. An optional `city_points` argument takes the UN's own + points for a city instead (constant growth between them); nothing uses it yet. + No other number moves. + - **Seven more Climate TRACE modelling decisions are marked where SWEET applies them**: waste before 1970 counted as zero, a site's last intake in the year before it closes, intake following population, the decay rate from climate, each waste type's methane diff --git a/tests/test_population_trajectory.py b/tests/test_population_trajectory.py new file mode 100644 index 0000000..18a0a14 --- /dev/null +++ b/tests/test_population_trajectory.py @@ -0,0 +1,126 @@ +"""A place's population by year, worked out once and stored, grows its waste as SWEET would. + +WasteMAP's map build stores every city's and country's population trajectory with the +build, and its city DST hands a city's back to `load_csv_new` as `population_series`. +Before that the build and the city DST each re-ran `_city_growth`, so they could grow a +city differently whenever WasteMAP's SWEET pin moved. These tests hold +`city_population_trajectory` to `_city_growth`'s choice for both kinds of city, the +country trajectories to what a Custom Location grows by, and `load_csv_new` to the +series it is handed. +""" + +import numpy as np +import pandas as pd +import pytest + +from SWEET_python import city_params +from SWEET_python.city_params import ( + City, + city_population_trajectory, + country_population_trajectory, +) +from SWEET_python.population import country_population_series +from test_load_csv_composition import CITIES, _cities_table_row + +YEARS = list(range(1970, 2051)) + + +def test_a_big_city_follows_its_own_two_rates(): + trajectory, source = city_population_trajectory("NGA", 2020, 15_000_000.0, 1.0556, 1.036) + + assert source == city_params.UN_CITY_RATES == "UN city rates" + assert list(trajectory.index) == YEARS + assert trajectory.loc[2020] == 15_000_000.0 + assert trajectory.loc[2030] == pytest.approx(15_000_000 * 1.036**10, rel=1e-13) + assert trajectory.loc[2000] == pytest.approx(15_000_000 * 1.0556**-20, rel=1e-13) + + +@pytest.mark.parametrize( + "population, historic, future", + [(250_000.0, 1.0556, 1.036), (1_800_000.0, 1.0022, 1.1687)], + ids=["a town under 300k", "implausible rates"], +) +def test_any_other_city_follows_its_countrys_un_population(population, historic, future): + trajectory, source = city_population_trajectory("BRA", 2022, population, historic, future) + brazil = country_population_series("BRA") + + assert source == city_params.COUNTRY_CURVE == "country curve" + assert trajectory.loc[2022] == population + np.testing.assert_allclose( + trajectory / population, (brazil / brazil.loc[2022]).loc[YEARS], rtol=1e-14 + ) + + +def test_a_citys_own_un_points_replace_the_shape(): + points = {2000: 1_000_000, 2010: 1_100_000, 2020: 1_210_000} # +10% a decade + + trajectory, source = city_population_trajectory( + "BRA", 2015, 2_000_000.0, 1.05, 1.05, city_points=points + ) + + assert source == city_params.UN_CITY_TRAJECTORY == "UN city trajectory" + assert trajectory.loc[2015] == pytest.approx(2_000_000.0, rel=1e-14) + decade = 1.1 + assert trajectory.loc[2025] / trajectory.loc[2015] == pytest.approx(decade, rel=1e-12) + # Beyond the last point it keeps the last pair's rate, and before the first the first's. + assert trajectory.loc[2050] / trajectory.loc[2020] == pytest.approx(decade**3, rel=1e-12) + assert trajectory.loc[1970] / trajectory.loc[2000] == pytest.approx(decade**-3, rel=1e-12) + + +def test_one_un_point_is_not_a_trajectory(): + with pytest.raises(ValueError, match="at least two"): + city_population_trajectory("BRA", 2015, 2e6, 1.05, 1.05, city_points={2020: 1e6, 2025: np.nan}) + + +def test_a_country_trajectory_is_its_un_population_in_people(): + trajectory, source = country_population_trajectory("USA") + + assert source == "country curve" + assert list(trajectory.index) == YEARS + assert trajectory.loc[2023] == 342_475_098.0 + # Kosovo is in the UN's table under XKX. + assert country_population_trajectory("XKX")[1] == "country curve" + + +def test_a_country_without_a_un_population_grows_as_a_custom_location_there_does(): + trajectory, source = country_population_trajectory("ATA") + city = City("custom") + city.dst_baseline_blank("Antarctica", 100_000, 800.0, -20.0) + waste = city.baseline_parameters.waste_generated_df.sum(axis=1) + + assert source == city_params.COUNTRY_FALLBACK_RATES == "country fallback rates" + assert trajectory.loc[city_params.CUSTOM_LOCATION_POPULATION_YEAR] == 1.0 + np.testing.assert_allclose(trajectory.loc[YEARS], (waste / waste.loc[2022]).loc[YEARS], rtol=1e-13) + + +def _load(population_series=None): + """São Paulo's published row, as WasteMAP's city DST loads it.""" + city = City("São Paulo") + city.load_csv_new( + _cities_table_row("São Paulo", **CITIES["São Paulo"]), + dst=True, + population_series=population_series, + ) + city.baseline_parameters.sites_method = False + city._calculate_divs() + return city.baseline_parameters + + +def test_load_csv_new_grows_a_city_along_the_trajectory_it_is_handed(): + inputs = CITIES["São Paulo"] + stored, _ = city_population_trajectory( + "BRA", 2022, inputs["population"], inputs["historic"] / 100 + 1, inputs["future"] / 100 + 1 + ) + flat = pd.Series(inputs["population"], index=YEARS) + + worked_out, along_stored, along_flat = _load(), _load(stored), _load(flat) + + waste = lambda p: p.waste_generated_df.sum(axis=1) # noqa: E731 + np.testing.assert_allclose(waste(along_stored), waste(worked_out), rtol=1e-14) + assert waste(along_flat).loc[2050] == pytest.approx(waste(along_flat).loc[2022], rel=1e-12) + assert waste(along_flat).loc[2050] != pytest.approx(waste(worked_out).loc[2050], rel=1e-3) + # The two rates a person reads are still _city_growth's. + assert (along_flat.growth_rate_historic, along_flat.growth_rate_future) == ( + worked_out.growth_rate_historic, + worked_out.growth_rate_future, + ) From 5e24783482d07a6981ffa4b46ac5b949dd7d3017 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 08:40:16 -0700 Subject: [PATCH 2/6] The changelog links the PR Co-Authored-By: Claude Opus 5.5 --- changelog/2026-10.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/2026-10.md b/changelog/2026-10.md index 9ece92d..6159571 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -65,7 +65,7 @@ or 9% above the default (model-output change). the population-year figure, which can land a unit in the last place away from the factor it used to compute. An optional `city_points` argument takes the UN's own points for a city instead (constant growth between them); nothing uses it yet. - No other number moves. + No other number moves. ([#91](https://github.com/RMI/SWEET_python/pull/91)) - **Seven more Climate TRACE modelling decisions are marked where SWEET applies them**: waste before 1970 counted as zero, a site's last intake in the year before it closes, From c1065dd91d453f952b21bda281696058a13a9b61 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 08:41:34 -0700 Subject: [PATCH 3/6] The changelog links the WasteMAP PR Co-Authored-By: Claude Opus 5.5 --- changelog/2026-10.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog/2026-10.md b/changelog/2026-10.md index 6159571..6e0d930 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -65,7 +65,8 @@ or 9% above the default (model-output change). the population-year figure, which can land a unit in the last place away from the factor it used to compute. An optional `city_points` argument takes the UN's own points for a city instead (constant growth between them); nothing uses it yet. - No other number moves. ([#91](https://github.com/RMI/SWEET_python/pull/91)) + No other number moves. ([#91](https://github.com/RMI/SWEET_python/pull/91), + [RMI/WasteMAP#888](https://github.com/RMI/WasteMAP/pull/888)) - **Seven more Climate TRACE modelling decisions are marked where SWEET applies them**: waste before 1970 counted as zero, a site's last intake in the year before it closes, From 67e21ca6815a28119f48a42d4635871183383ea9 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 09:01:46 -0700 Subject: [PATCH 4/6] A country's trajectory keeps the UN's fractions of a person Rounding to whole people bent the curve of the few places WPP interpolates (Svalbard and Jan Mayen: 2,591.78 people in 2022, the 1975/2022 ratio off by 0.03%), so a Custom Location there would not grow as dst_baseline_blank does. Co-Authored-By: Claude Opus 5.5 --- SWEET_python/city_params.py | 6 ++++-- tests/test_population_trajectory.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 4733981..133e96f 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -390,8 +390,10 @@ def country_population_trajectory(iso3): years = np.arange(MODEL_START_YEAR, MODEL_END_YEAR + 1) series = country_population_series(iso3) if series is not None: - people = (series.reindex(years) * 1000).round() # the table is in thousands - return people.astype(float), COUNTRY_CURVE + # The table is in thousands. Not rounded to whole people: a few small places + # carry interpolated fractions (Svalbard's 2,591.78 in 2022), and rounding + # them would bend the curve a Custom Location there grows by. + return series.reindex(years).astype(float) * 1000, COUNTRY_CURVE factors = growth_factors_for_years( years, CUSTOM_LOCATION_POPULATION_YEAR, diff --git a/tests/test_population_trajectory.py b/tests/test_population_trajectory.py index 18a0a14..845bc10 100644 --- a/tests/test_population_trajectory.py +++ b/tests/test_population_trajectory.py @@ -82,6 +82,19 @@ def test_a_country_trajectory_is_its_un_population_in_people(): assert country_population_trajectory("XKX")[1] == "country curve" +def test_a_small_countrys_trajectory_keeps_the_uns_fractions(): + """Svalbard and Jan Mayen's row carries interpolated fractions of a thousand + (2,591.78 people in 2022). Rounded to whole people its curve moved by 0.03%.""" + trajectory, _ = country_population_trajectory("SJM") + svalbard = country_population_series("SJM") + + np.testing.assert_allclose( + (trajectory / trajectory.loc[2022]).to_numpy(), + (svalbard / svalbard.loc[2022]).loc[YEARS].to_numpy(), + rtol=1e-14, + ) + + def test_a_country_without_a_un_population_grows_as_a_custom_location_there_does(): trajectory, source = country_population_trajectory("ATA") city = City("custom") From fe196c4d8aab5f36267815fea5cd203db93ad074 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 08:55:25 -0700 Subject: [PATCH 5/6] A Custom Location can grow along a stored country trajectory City.dst_baseline_blank takes population_series, as load_csv_new does: the country's population by year from country_population_trajectory, which WasteMAP's map build stores. The Custom Location's waste, and its scenario's, grow along it instead of along SWEET's own table; the tonnage still comes from the population given, and the two rates a person reads stay the country's. Without it nothing changes. Handed the country's own trajectory, every country's Custom Location comes out within 5.9e-16 of before, and the 12 with fallback rates exactly. Co-Authored-By: Claude Opus 5.5 --- SWEET_python/city_params.py | 15 ++++++- changelog/2026-10.md | 10 +++++ tests/test_population_trajectory.py | 61 +++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 133e96f..72a6edb 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -4938,7 +4938,12 @@ def _calculate_diverted_masses(self, scenario: int = 0) -> None: parameters.divs = divs def dst_baseline_blank( - self, country: str, population: int, precipitation: float, temperature: float + self, + country: str, + population: int, + precipitation: float, + temperature: float, + population_series: Optional[pd.Series] = None, ) -> None: """ Initializes the baseline scenario with given parameters for a blank/custom city. @@ -4947,6 +4952,10 @@ def dst_baseline_blank( country (str): The country name. population (int): Population of the city. precipitation (float): Average annual precipitation in mm/year. + population_series (pd.Series): the country's population by year, worked + out once by country_population_trajectory and stored. The city's waste + grows along it instead of along SWEET's own table. Only its shape + counts: the city's waste is set by ``population``. Returns: None @@ -5121,9 +5130,13 @@ def calculate_component_fractions( waste_masses_df = waste_fractions_df * waste_mass # The country's population, year by year. The constants above are one rate for # every country; they stand only for a country SWEET has no series for. + stored_series = population_series population_series, growth_rate_historic, growth_rate_future = _country_growth( iso3, year_of_data_pop, growth_rate_historic, growth_rate_future ) + if stored_series is not None: + # The two rates stay the ones a person reads; the stored series is the growth. + population_series = stored_series waste_generated_df = WasteGeneratedDF.create( waste_masses_df, MODEL_START_YEAR, diff --git a/changelog/2026-10.md b/changelog/2026-10.md index 6e0d930..8e1f643 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -68,6 +68,16 @@ or 9% above the default (model-output change). No other number moves. ([#91](https://github.com/RMI/SWEET_python/pull/91), [RMI/WasteMAP#888](https://github.com/RMI/WasteMAP/pull/888)) +- **A Custom Location can grow along a stored country trajectory.** + `City.dst_baseline_blank` takes `population_series`, as `load_csv_new` does: the + country's population by year from `country_population_trajectory`, stored with + WasteMAP's map build. The Custom Location's waste, and its scenario's, grow along + it instead of along SWEET's own table; the city's tonnage still comes from the + population it is given, and the two growth rates a person reads are still the + country's. Without it nothing changes. Handed the country's own trajectory, a + Custom Location grows as before, to within 5.9e-16 in every country, and the 12 + with fallback rates exactly. + - **Seven more Climate TRACE modelling decisions are marked where SWEET applies them**: waste before 1970 counted as zero, a site's last intake in the year before it closes, intake following population, the decay rate from climate, each waste type's methane diff --git a/tests/test_population_trajectory.py b/tests/test_population_trajectory.py index 845bc10..5dad5e3 100644 --- a/tests/test_population_trajectory.py +++ b/tests/test_population_trajectory.py @@ -137,3 +137,64 @@ def test_load_csv_new_grows_a_city_along_the_trajectory_it_is_handed(): worked_out.growth_rate_historic, worked_out.growth_rate_future, ) + + +def _custom_location(country, population_series=None): + """A Custom Location, as WasteMAP's city DST builds one.""" + city = City("custom") + city.dst_baseline_blank(country, 100_000, 800.0, 15.0, population_series=population_series) + return city + + +def test_a_custom_location_grows_along_the_country_trajectory_it_is_handed(): + stored, _ = country_population_trajectory("USA") + flat = pd.Series(331_000_000.0, index=YEARS) + + worked_out, along_stored, along_flat = ( + _custom_location("United States", series).baseline_parameters + for series in (None, stored, flat) + ) + + waste = lambda p: p.waste_generated_df.sum(axis=1) # noqa: E731 + np.testing.assert_allclose(waste(along_stored), waste(worked_out), rtol=1e-14) + np.testing.assert_allclose( + (waste(along_stored) / waste(along_stored).loc[2022]).loc[YEARS], + stored / stored.loc[city_params.CUSTOM_LOCATION_POPULATION_YEAR], + rtol=1e-14, + ) + # Only the shape counts: the waste is the caller's population's, in every case. + assert waste(along_flat).loc[2022] == waste(worked_out).loc[2022] + assert waste(along_flat).loc[2050] == pytest.approx(waste(along_flat).loc[2022], rel=1e-12) + assert waste(along_flat).loc[2050] != pytest.approx(waste(worked_out).loc[2050], rel=1e-3) + # The two rates a person reads are still the country's. + assert (along_flat.growth_rate_historic, along_flat.growth_rate_future) == ( + worked_out.growth_rate_historic, + worked_out.growth_rate_future, + ) + + +def test_a_custom_location_with_its_fallback_trajectory_is_unchanged(): + stored, source = country_population_trajectory("ATA") + assert source == city_params.COUNTRY_FALLBACK_RATES + + worked_out = _custom_location("Antarctica").baseline_parameters + along_stored = _custom_location("Antarctica", stored).baseline_parameters + + pd.testing.assert_frame_equal(along_stored.waste_generated_df, worked_out.waste_generated_df) + assert (along_stored.growth_rate_historic, along_stored.growth_rate_future) == ( + city_params.FALLBACK_GROWTH_RATE_HISTORIC, + city_params.FALLBACK_GROWTH_RATE_FUTURE, + ) + + +def test_a_custom_location_scenario_grows_along_the_trajectory_it_is_handed(): + flat = pd.Series(331_000_000.0, index=YEARS) + city = _custom_location("United States", flat) + for landfill in city.baseline_parameters.landfills: + landfill.estimate_emissions() + + divs = city_params.DiversionFractions(compost=0.1, anaerobic=0.0, combustion=0.0, recycling=0.1) + city.implement_dst_changes_simple_v1_5(divs, False, False, 0.0, 0.0, 2030, 1, 0.0) + waste = city.scenario_parameters[0].waste_generated_df.df.sum(axis=1) + + assert waste.loc[2050] == pytest.approx(waste.loc[2030], rel=1e-12) From d49270441a54e44a5f06bb07f8cba21a055a88f5 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 09:16:20 -0700 Subject: [PATCH 6/6] The changelog links the PRs Co-Authored-By: Claude Opus 5.5 --- changelog/2026-10.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog/2026-10.md b/changelog/2026-10.md index 8e1f643..daf39ba 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -76,7 +76,8 @@ or 9% above the default (model-output change). population it is given, and the two growth rates a person reads are still the country's. Without it nothing changes. Handed the country's own trajectory, a Custom Location grows as before, to within 5.9e-16 in every country, and the 12 - with fallback rates exactly. + with fallback rates exactly. ([#94](https://github.com/RMI/SWEET_python/pull/94), + [RMI/WasteMAP#890](https://github.com/RMI/WasteMAP/pull/890)) - **Seven more Climate TRACE modelling decisions are marked where SWEET applies them**: waste before 1970 counted as zero, a site's last intake in the year before it closes,