Skip to content
120 changes: 110 additions & 10 deletions SWEET_python/city_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,98 @@ 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:
# 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,
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()}

Expand Down Expand Up @@ -967,14 +1059,23 @@ 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.

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
Expand Down Expand Up @@ -1131,13 +1232,17 @@ 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,
city_data["Population"].values[0],
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,
Expand Down Expand Up @@ -4858,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(
Expand All @@ -4884,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:
Expand Down
18 changes: 18 additions & 0 deletions changelog/2026-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ take to other landfills, so a caller no longer has to make the shares add up to

## 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. ([#91](https://github.com/RMI/SWEET_python/pull/91),
[RMI/WasteMAP#888](https://github.com/RMI/WasteMAP/pull/888))

- **The city-level advanced DST can send the rest of a city's waste to other
landfills.** `AdvancedDSTCityRequest` gains `other_landfills`, a landfill spec
(`OtherLandfillsSpec`) with no open or close years and no gate total. When it
Expand Down
139 changes: 139 additions & 0 deletions tests/test_population_trajectory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""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_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")
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,
)
Loading