diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index bb7bbd7..133e96f 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -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()} @@ -967,7 +1059,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 +1073,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 +1232,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 +1240,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 +4965,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 +4986,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..6e0d930 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -50,6 +50,24 @@ 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. ([#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, 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..845bc10 --- /dev/null +++ b/tests/test_population_trajectory.py @@ -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, + )