From 7ad1919fe498b2fcf6de7a377d6f55219465c666 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Mon, 19 Oct 2026 20:36:55 -0700 Subject: [PATCH 1/8] A city's waste that no listed landfill takes goes to other landfills AdvancedDSTCityRequest gains other_landfills: one landfill spec with no open or close years and no gate total. Each year it takes 1 minus the shares of the listed landfills open that year, so a caller can state the sites it knows and leave the rest, without balancing the shares to 1. A listed landfill's share in years it is shut goes there too, instead of being spread over the open ones, and a year claiming more than 1 is scaled down rather than refused. landfills may now be empty. Every entry point first lists other landfills as one more landfill (_with_other_landfills), so a run with them is the same run, bit for bit, as one listing that landfill at the leftover share. Requests without the field are unchanged. Co-Authored-By: Claude Opus 5.5 --- SWEET_python/advanced_dst_city.py | 244 ++++++++++++++++--- changelog/2026-10.md | 22 ++ tests/test_adst_city_other_landfills.py | 298 ++++++++++++++++++++++++ 3 files changed, 538 insertions(+), 26 deletions(-) create mode 100644 tests/test_adst_city_other_landfills.py diff --git a/SWEET_python/advanced_dst_city.py b/SWEET_python/advanced_dst_city.py index 2f6578c..9047c52 100644 --- a/SWEET_python/advanced_dst_city.py +++ b/SWEET_python/advanced_dst_city.py @@ -31,6 +31,10 @@ * a facility may set ``combusts``: it burns the waste routed to it instead of depositing it, and only the unburnable reject is deposited. See :class:`CityLandfillSpec`. +* ``other_landfills`` (optional) is where the rest goes. With it, each year's + fractions may sum to less than 1, a listed landfill's share goes to other + landfills in the years it is shut, and ``landfills`` may be empty. See + :class:`OtherLandfillsSpec`. Diversion math, reject rates, the per-landfill split, and emissions aggregation mirror the tested ``City`` machinery (``_calculate_diverted_masses``, @@ -42,16 +46,22 @@ import numpy as np import pandas as pd -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator import SWEET_python.defaults_2019 as defaults_2019 from SWEET_python.city_params import City, CityParameters, CustomError from SWEET_python.class_defs import DivsDF, LandfillType, LandfillWasteMassDF, Variant +from SWEET_python.constants import MODEL_START_YEAR from SWEET_python import dst_common as common from SWEET_python import site_inflow from SWEET_python.dst_common import YearlyFloat, YearlyFractions, YearlyOptionalFloat -__all__ = ["AdvancedDSTCityRequest", "CityLandfillSpec", "run_advanced_dst_city"] +__all__ = [ + "AdvancedDSTCityRequest", + "CityLandfillSpec", + "OtherLandfillsSpec", + "run_advanced_dst_city", +] #: The diversion pathways `DivsDF` carries, in the order the mass flow reports. DIVERSION_PATHWAYS: tuple[str, ...] = ("compost", "anaerobic", "combustion", "recycling") @@ -155,6 +165,44 @@ class CityLandfillSpec(BaseModel): ) +class OtherLandfillsSpec(CityLandfillSpec): + """Where the city's landfilled waste goes when no listed landfill takes it. + + Each year, other landfills take whatever share of the city's landfilled + waste the listed landfills leave: what their shares fall short of 1 by, and + the share of any listed landfill that is shut that year. So a caller can + state the sites it knows about and leave the rest here, without balancing + the shares to 1 first. + + They are modelled as one more landfill, with every per-site field a listed + one has. Two fields are not taken. Other landfills accept waste in every + model year, so they have no open or close years. They take only the city's + own waste, so they have no gate total. + """ + + landfill_open_close: Optional[Variant[tuple[int, int]]] = Field( + None, + description=( + "Not taken: other landfills accept waste from the model's first year " + "and never close, so every year's leftover has somewhere to go." + ), + ) + + @model_validator(mode="after") + def _take_the_city_s_leftover_every_year(self) -> "OtherLandfillsSpec": + if self.landfill_open_close is not None: + raise ValueError( + "other_landfills take no landfill_open_close: they accept waste " + "in every model year." + ) + if self.accepted_waste_mass is not None: + raise ValueError( + "other_landfills take no accepted_waste_mass: they take only the " + "city's own waste." + ) + return self + + class AdvancedDSTCityRequest(BaseModel): """Request body for the city-level advanced DST endpoint.""" @@ -173,15 +221,22 @@ class AdvancedDSTCityRequest(BaseModel): ), ) landfills: List[CityLandfillSpec] = Field( - ..., min_length=1, description="One entry per landfill in the city." + default_factory=list, + description=( + "One entry per landfill in the city. It may be empty when " + "`other_landfills` is given, which then takes all of the city's " + "landfilled waste." + ), ) - landfill_split_timeline: Variant[Dict[int, List[float]]] = Field( - ..., + landfill_split_timeline: Optional[Variant[Dict[int, List[float]]]] = Field( + None, description=( "Fraction of the city's landfilled (net-of-diversion) waste sent to " "each landfill per year: {year: [frac_landfill_0, frac_landfill_1, ...]}. " - "The list order matches the `landfills` list, and each year's fractions " - "should sum to ~1." + "The list order matches the `landfills` list. Required whenever " + "`landfills` lists any. Without `other_landfills`, each year's " + "fractions should sum to ~1. With it, they may sum to less, and " + "other landfills take the rest." ), ) diversion_fractions: Optional[Variant[Dict[str, YearlyFloat]]] = Field( @@ -206,6 +261,34 @@ class AdvancedDSTCityRequest(BaseModel): temperature: float = Field(10.0, description="Average annual temperature, deg C.") country: Optional[str] = Field(None, description="ISO3 country code (identity).") rmi_id: Optional[int] = Field(None, description="City/site identifier (identity).") + other_landfills: Optional[OtherLandfillsSpec] = Field( + None, + description=( + "Where the city's landfilled waste goes when no listed landfill takes " + "it. Each year, other landfills take 1 minus the shares of the listed " + "landfills open that year. That includes the share of a listed " + "landfill that is shut that year, which without this field is spread " + "over the open ones instead. A year whose open listed landfills " + "claim more than 1 is scaled down to 1, and other landfills take " + "nothing that year. They are reported after the listed landfills, " + "as the last entry of `site_emissions` and of the mass flow's " + "`sites`. Omit (the default) and the shares must sum to ~1, as " + "before." + ), + ) + + @model_validator(mode="after") + def _the_waste_has_somewhere_to_go(self) -> "AdvancedDSTCityRequest": + if not self.landfills and self.other_landfills is None: + raise ValueError( + "landfills must list at least one landfill unless other_landfills " + "is given." + ) + if self.landfills and self.landfill_split_timeline is None: + raise ValueError( + "landfill_split_timeline is required when landfills lists any." + ) + return self # --------------------------------------------------------------------------- # @@ -279,9 +362,11 @@ def _mass_flow( the total quietly composts metal and glass. ``sites`` is per landfill in request order, already windowed by each one's - open/close years. It sums to ``landfilled`` in every year that has at least - one open landfill, because the split is renormalized over whichever ones are - accepting waste (see ``_renormalize_over_open``). The one case where the two + open/close years, with other landfills last when the request has them. It + sums to ``landfilled`` in every year that has at least one open landfill, + because the split is renormalized over whichever ones are accepting waste + (see ``_renormalize_over_open``) -- and other landfills are open in every + year. The one case where the two differ is a year with *no* landfill open at all: that city's post-diversion waste has nowhere to go, and the gap is reported rather than hidden — ``landfilled`` is what the city disposed of, ``sites`` is what arrived @@ -563,6 +648,118 @@ def _renormalize_over_open( return renormalized +def _validate_share_range(checked: pd.DataFrame, label: str) -> None: + """Every share in ``checked`` must be a fraction, within the share tolerance.""" + out_of_range = (checked < -SHARE_SUM_TOLERANCE) | ( + checked > 1.0 + SHARE_SUM_TOLERANCE + ) + if bool(out_of_range.to_numpy().any()): + rows = out_of_range.any(axis=1) + first_year = int(rows[rows].index[0]) + column = int(out_of_range.loc[first_year].idxmax()) + raise CustomError( + "invalid_parameters", + f"{label} landfill waste_share fractions must each be between 0 and 1 " + f"(year {first_year}, landfill {column} is " + f"{float(checked.loc[first_year, column]):.3f}).", + ) + + +def _with_other_landfills(request: AdvancedDSTCityRequest) -> AdvancedDSTCityRequest: + """The request with its other landfills listed as one more landfill. + + Every entry point calls this first, so the rest of the module only ever + sees listed landfills whose shares sum to 1. Other landfills become the + last landfill: open from the model's first year, never closed, and holding + each year's leftover. The run is the same, bit for bit, as one where the + caller listed that landfill itself at those shares, because it *is* that + run. + + For each variant and each year: + + * a listed landfill keeps the share it was given in the years it accepts + waste. In the years it is shut (not yet open, or closed by its own dates + or by a scenario's), that share goes to other landfills instead of being + spread over the listed landfills still open; + * other landfills take 1 minus what the open listed landfills claim; + * a year whose open listed landfills claim more than 1 is scaled down to 1, + and other landfills take nothing that year. + + Each listed share is checked to be a fraction as submitted, so the scaling + can't hide a negative share or one above 1. + """ + other = request.other_landfills + if other is None: + return request + + # Other landfills open with the model, so that every modelled year's + # leftover has a destination: at MODEL_START_YEAR, or with a listed + # landfill that states an earlier opening. `validate_years` checks the + # listed dates and returns that earliest open year. + pairs = [(MODEL_START_YEAR, common.NEVER_CLOSES)] + for spec in request.landfills: + baseline_dates = spec.landfill_open_close["baseline"] + scenario_dates = spec.landfill_open_close["scenario"] or baseline_dates + pairs += [tuple(baseline_dates), tuple(scenario_dates)] + open_year = common.validate_years(pairs, int(request.implement_year)) + years = pd.Index(range(open_year, common.MODEL_YEAR_MAX + 1), name="year") + + n_listed = len(request.landfills) + variants = ("baseline", "scenario") + splits: Dict[str, pd.DataFrame] = {} + accepting: Dict[str, pd.DataFrame] = {} + for variant in variants: + if n_listed: + timeline = common.variant_get(request.landfill_split_timeline, variant) + splits[variant] = _split_timeline_to_df(timeline, years, n_listed) + accepting[variant] = _accepting_mask( + _accepting_windows(request, variant), splits[variant].columns, years + ) + else: + splits[variant] = pd.DataFrame(index=years) + accepting[variant] = pd.DataFrame(index=years) + # The scenario tracks the baseline before changes take effect. Spliced + # before the check, as `run_advanced_dst_city` does, so a pre-implement + # scenario row the model discards can't get the request refused. + implement_year = int(request.implement_year) + splits["scenario"].loc[: implement_year - 1, :] = splits["baseline"].loc[ + : implement_year - 1, : + ] + accepting["scenario"].loc[: implement_year - 1, :] = accepting["baseline"].loc[ + : implement_year - 1, : + ] + + rows: Dict[str, Dict[int, List[float]]] = {} + for variant in variants: + _validate_share_range(splits[variant], variant) + listed = splits[variant].where(accepting[variant], 0.0) + claimed = listed.sum(axis=1) + over = claimed > 1.0 + listed.loc[over] = listed.loc[over].div(claimed[over], axis=0) + leftover = (1.0 - listed.sum(axis=1)).clip(lower=0.0) + rows[variant] = { + int(year): [float(x) for x in listed.loc[year]] + [float(leftover.loc[year])] + for year in years + } + + always_open = (open_year, common.NEVER_CLOSES) + listed_other = CityLandfillSpec.model_validate( + { + **other.model_dump(exclude={"landfill_open_close", "accepted_waste_mass"}), + "landfill_open_close": {"baseline": always_open, "scenario": always_open}, + } + ) + return request.model_copy( + update={ + "landfills": [*request.landfills, listed_other], + "landfill_split_timeline": Variant[Dict[int, List[float]]]( + baseline=rows["baseline"], scenario=rows["scenario"] + ), + "other_landfills": None, + } + ) + + def _validate_shares(split: pd.DataFrame, accepting: pd.DataFrame, label: str) -> None: """Each year's landfill shares must be fractions, and must sum to ~1. @@ -582,21 +779,7 @@ def _validate_shares(split: pd.DataFrame, accepting: pd.DataFrame, label: str) - that waste to go, so no set of shares can account for it, and whatever was submitted is discarded either way. """ - somewhere_open_rows = accepting.any(axis=1) - checked = split.loc[somewhere_open_rows] - out_of_range = (checked < -SHARE_SUM_TOLERANCE) | ( - checked > 1.0 + SHARE_SUM_TOLERANCE - ) - if bool(out_of_range.to_numpy().any()): - rows = out_of_range.any(axis=1) - first_year = int(rows[rows].index[0]) - column = int(out_of_range.loc[first_year].idxmax()) - raise CustomError( - "invalid_parameters", - f"{label} landfill waste_share fractions must each be between 0 and 1 " - f"(year {first_year}, landfill {column} is " - f"{float(checked.loc[first_year, column]):.3f}).", - ) + _validate_share_range(split.loc[accepting.any(axis=1)], label) total = split.sum(axis=1) somewhere_open = accepting.any(axis=1) @@ -836,6 +1019,7 @@ def run_advanced_dst_city_limits( All of it reflects the prevention already in the request; a caller asking "how much further could I go" gets an answer consistent with where it is. """ + request = _with_other_landfills(request) implement_year = int(request.implement_year) years = _model_years(request) @@ -1022,7 +1206,8 @@ def run_advanced_dst_city_defaults( """The rates the model uses at each site that does not state its own. Returns ``{"baseline": [...], "scenario": [...]}``, one entry per landfill - in request order, each ``{"k", "oxidation"}`` indexed by year: + in request order, with the request's ``other_landfills`` last when it has + them, each ``{"k", "oxidation"}`` indexed by year: * ``k`` -- the decomposition rate (1/yr) derived from the city's temperature, precipitation and composition after food waste prevention. @@ -1038,6 +1223,7 @@ def run_advanced_dst_city_defaults( Nothing here runs the decay model, so it is cheap enough to call on every edit. """ + request = _with_other_landfills(request) implement_year = int(request.implement_year) years = _model_years(request) @@ -1123,7 +1309,13 @@ def run_advanced_dst_city( through the decay kernel, summed -- never a ratio applied to one of them, which is wrong by tens of percent wherever the city's share of a site moves over time. Asking for them changes no emissions. + + A request with ``other_landfills`` has them modelled as one more landfill, + after the listed ones: they are the last entry of ``site_emissions`` and of + the mass flow's ``sites``. See ``_with_other_landfills`` for the shares they + take. """ + request = _with_other_landfills(request) implement_year = int(request.implement_year) # --- Years: validate every landfill's open/close window --- diff --git a/changelog/2026-10.md b/changelog/2026-10.md index 5f96c06..d5b5184 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -46,8 +46,30 @@ A site in the city-level advanced DST can state its own decay rate and oxidation rate, and a new call reports the rates the model would use at each site that states none. No request that leaves them out changes. +A city-level advanced DST request can send whatever its listed landfills don't +take to other landfills, so a caller no longer has to make the shares add up to +1. No request that leaves them out changes. + ## Added +- **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 + is given: + - each year, other landfills take 1 minus the shares of the listed landfills + open that year. That includes the share of a listed landfill that is shut + that year, which is otherwise spread over the open ones; + - a year whose open listed landfills claim more than 1 is scaled down to 1 + instead of being refused, and other landfills take nothing that year; + - `landfills` may be empty, and `landfill_split_timeline` is needed only + when `landfills` lists any. + + Other landfills are modelled as one more landfill, opened with the model and + never closed. They are reported last in `site_emissions`, in the mass flow's + `sites` and in `run_advanced_dst_city_defaults`. A run with them is the same, + bit for bit, as a run that lists that landfill at the leftover share, and a + test holds the two to that. Without the field, nothing changes. + - **A city-level advanced DST site can state its own `k` and oxidation rate.** `CityLandfillSpec` gains `k_override` and `oxidation_override`, the site DST's `ks_overrides` and `oxidation_override` for one landfill. Each is optional and diff --git a/tests/test_adst_city_other_landfills.py b/tests/test_adst_city_other_landfills.py new file mode 100644 index 0000000..819dc4c --- /dev/null +++ b/tests/test_adst_city_other_landfills.py @@ -0,0 +1,298 @@ +"""Other landfills take whatever share of a city's landfilled waste the listed ones leave. + +``AdvancedDSTCityRequest.other_landfills`` lets a caller state the sites it +knows about and leave the rest of the city's disposal to one more landfill, +without balancing the shares to 1 first. Each year other landfills take 1 minus +the shares of the listed landfills open that year, so a shut site's share goes +to them rather than being spread over the sites still open. + +The test that matters most is the equivalence: a request with other landfills +is the same run, bit for bit, as one that lists that landfill itself at the +leftover share. That is what makes other landfills a convenience for the caller +rather than a second model to keep in step with the first. +""" + +import pandas as pd +import pytest +from pydantic import ValidationError + +from SWEET_python.advanced_dst_city import ( + AdvancedDSTCityRequest, + run_advanced_dst_city, + run_advanced_dst_city_defaults, + run_advanced_dst_city_limits, +) +from SWEET_python.city_params import CustomError +from SWEET_python.constants import MODEL_START_YEAR +from SWEET_python.dst_common import MODEL_YEAR_MAX, NEVER_CLOSES + +YEARS = range(MODEL_START_YEAR, MODEL_YEAR_MAX + 1) +FRACTIONS = [0.5, 0.1, 0.05, 0.1, 0.05, 0.1, 0.02, 0.03, 0.0, 0.05] # sums to 1.0 +IMPLEMENT_YEAR = 2025 +TOTAL_WASTE = 20_000.0 + +LANDFILL, CONTROLLED_DUMP, OPEN_DUMP = 0, 1, 2 +ALWAYS_OPEN = (MODEL_START_YEAR, NEVER_CLOSES) + + +def _constant(value): + return {y: value for y in YEARS} + + +def _site(*, site_type=LANDFILL, gas=0.0, dates=ALWAYS_OPEN, scenario_dates=None): + return dict( + landfill_type={"baseline": site_type, "scenario": site_type}, + landfill_open_close={ + "baseline": list(dates), + "scenario": list(scenario_dates or dates), + }, + gas_capture_efficiency={"baseline": _constant(gas), "scenario": _constant(gas)}, + ) + + +def _other(*, site_type=OPEN_DUMP, gas=0.0, **fields): + """Other landfills: a site with no open/close years of its own.""" + return dict( + landfill_type={"baseline": site_type, "scenario": site_type}, + gas_capture_efficiency={"baseline": _constant(gas), "scenario": _constant(gas)}, + **fields, + ) + + +def _body(landfills, shares=None, *, other=None, scenario_shares=None, **extra): + """A city composting and recycling 10% each, with `landfills` at `shares`.""" + body = dict( + city_name="Elsewhereville", + precipitation=1200.0, + temperature=20.0, + implement_year=IMPLEMENT_YEAR, + waste_mass={"baseline": _constant(TOTAL_WASTE), "scenario": _constant(TOTAL_WASTE)}, + waste_fractions={"baseline": _constant(FRACTIONS), "scenario": _constant(FRACTIONS)}, + diversion_fractions={ + "baseline": {"compost": _constant(0.1), "recycling": _constant(0.1)}, + "scenario": {"compost": _constant(0.1), "recycling": _constant(0.1)}, + }, + landfills=landfills, + country="USA", + ) + if shares is not None: + body["landfill_split_timeline"] = { + "baseline": _constant(list(shares)), + "scenario": scenario_shares + if scenario_shares is not None + else _constant(list(shares)), + } + if other is not None: + body["other_landfills"] = other + body.update(extra) + return body + + +def _request(*args, **kwargs) -> AdvancedDSTCityRequest: + return AdvancedDSTCityRequest(**_body(*args, **kwargs)) + + +def _run(request): + return run_advanced_dst_city(request, with_mass_flow=True, with_site_emissions=True) + + +def _site_shares(result, variant): + """Each landfill's share of what the city landfilled, per year.""" + flow = result["mass_flow"][variant] + landfilled = flow["landfilled"].sum(axis=1) + return pd.concat(flow["sites"], axis=1).div(landfilled, axis=0) + + +def _assert_same_run(left, right): + for variant in ("baseline", "scenario"): + pd.testing.assert_frame_equal(left[variant], right[variant], check_exact=True) + for mine, theirs in zip( + left["site_emissions"][variant], right["site_emissions"][variant], strict=True + ): + pd.testing.assert_frame_equal(mine.total, theirs.total, check_exact=True) + for mine, theirs in zip( + left["mass_flow"][variant]["sites"], + right["mass_flow"][variant]["sites"], + strict=True, + ): + pd.testing.assert_series_equal(mine, theirs, check_exact=True) + + +# --------------------------------------------------------------------------- # +# Other landfills take the rest +# --------------------------------------------------------------------------- # +def test_other_landfills_take_what_the_listed_shares_leave(): + result = _run(_request([_site(), _site()], [0.25, 0.375], other=_other())) + + for variant in ("baseline", "scenario"): + shares = _site_shares(result, variant) + assert list(shares.columns) == [0, 1, 2] + assert shares[0].to_numpy() == pytest.approx(0.25) + assert shares[1].to_numpy() == pytest.approx(0.375) + assert shares[2].to_numpy() == pytest.approx(0.375) + + +def test_a_run_with_other_landfills_is_the_run_that_lists_them(): + """The equivalence that makes other landfills safe to rely on.""" + with_other = _run( + _request( + [_site(gas=0.6), _site(site_type=OPEN_DUMP)], + [0.25, 0.375], + other=_other(site_type=LANDFILL), + ) + ) + listed = _run( + _request( + [_site(gas=0.6), _site(site_type=OPEN_DUMP), _site(site_type=LANDFILL)], + [0.25, 0.375, 0.375], + ) + ) + _assert_same_run(with_other, listed) + + +def test_other_landfills_alone_take_all_of_the_city_s_waste(): + alone = _run(_request([], other=_other())) + listed = _run(_request([_site(site_type=OPEN_DUMP)], [1.0])) + + _assert_same_run(alone, listed) + assert _site_shares(alone, "baseline")[0].to_numpy() == pytest.approx(1.0) + + +def test_a_shut_site_s_share_goes_to_other_landfills(): + """Not spread over the sites still open, as it is without other landfills.""" + closes = 2030 + result = _run( + _request( + [_site(dates=(MODEL_START_YEAR, closes)), _site()], + [0.25, 0.375], + other=_other(), + ) + ) + + shares = _site_shares(result, "baseline") + assert shares.loc[: closes - 1, 0].to_numpy() == pytest.approx(0.25) + assert shares.loc[closes:, 0].to_numpy() == pytest.approx(0.0) + assert shares[1].to_numpy() == pytest.approx(0.375) + assert shares.loc[: closes - 1, 2].to_numpy() == pytest.approx(0.375) + assert shares.loc[closes:, 2].to_numpy() == pytest.approx(0.625) + + +def test_a_site_not_yet_open_leaves_its_share_to_other_landfills(): + opens = 2010 + shares = _site_shares( + _run(_request([_site(dates=(opens, NEVER_CLOSES))], [0.4], other=_other())), + "baseline", + ) + assert shares.loc[: opens - 1, 1].to_numpy() == pytest.approx(1.0) + assert shares.loc[opens:, 1].to_numpy() == pytest.approx(0.6) + + +def test_a_scenario_closure_sends_the_share_to_other_landfills_in_the_scenario(): + closes = 2035 + result = _run( + _request( + [_site(scenario_dates=(MODEL_START_YEAR, closes)), _site()], + [0.25, 0.375], + other=_other(), + ) + ) + + baseline = _site_shares(result, "baseline") + scenario = _site_shares(result, "scenario") + assert baseline[2].to_numpy() == pytest.approx(0.375) + assert scenario.loc[: closes - 1, 2].to_numpy() == pytest.approx(0.375) + assert scenario.loc[closes:, 2].to_numpy() == pytest.approx(0.625) + assert scenario[1].to_numpy() == pytest.approx(0.375) + + +def test_shares_over_one_are_scaled_down_and_other_landfills_take_nothing(): + shares = _site_shares( + _run(_request([_site(), _site()], [0.8, 0.6], other=_other())), "baseline" + ) + assert shares[0].to_numpy() == pytest.approx(0.8 / 1.4) + assert shares[1].to_numpy() == pytest.approx(0.6 / 1.4) + assert shares[2].to_numpy() == pytest.approx(0.0) + + +def test_other_landfills_open_with_a_listed_site_that_opens_before_the_model(): + early = MODEL_START_YEAR - 10 + shares = _site_shares( + _run(_request([_site(dates=(early, NEVER_CLOSES))], [0.4], other=_other())), + "baseline", + ) + assert int(shares.index.min()) == early + assert shares[1].to_numpy() == pytest.approx(0.6) + + +# --------------------------------------------------------------------------- # +# The other entry points see the same landfills +# --------------------------------------------------------------------------- # +def test_defaults_report_other_landfills_last(): + with_other = run_advanced_dst_city_defaults( + _request([_site(gas=0.6)], [0.25], other=_other(site_type=OPEN_DUMP)) + ) + listed = run_advanced_dst_city_defaults( + _request([_site(gas=0.6), _site(site_type=OPEN_DUMP)], [0.25, 0.75]) + ) + for variant in ("baseline", "scenario"): + assert len(with_other[variant]) == 2 + for mine, theirs in zip(with_other[variant], listed[variant], strict=True): + pd.testing.assert_series_equal(mine["k"], theirs["k"], check_exact=True) + pd.testing.assert_series_equal( + mine["oxidation"], theirs["oxidation"], check_exact=True + ) + + +def test_limits_take_a_request_with_only_other_landfills(): + with_other = run_advanced_dst_city_limits(_request([], other=_other())) + listed = run_advanced_dst_city_limits(_request([_site()], [1.0])) + for variant in ("baseline", "scenario"): + pd.testing.assert_frame_equal( + with_other[variant]["max_diversion"], listed[variant]["max_diversion"] + ) + + +# --------------------------------------------------------------------------- # +# Requests that are refused, and the one that still is without other landfills +# --------------------------------------------------------------------------- # +def test_without_other_landfills_the_shares_must_still_sum_to_one(): + with pytest.raises(CustomError, match="sum to ~1"): + run_advanced_dst_city(_request([_site(), _site()], [0.25, 0.375])) + + +@pytest.mark.parametrize("shares", [[1.5, 0.0], [-0.5, 0.25]]) +def test_a_listed_share_must_still_be_a_fraction(shares): + with pytest.raises(CustomError, match="between 0 and 1"): + run_advanced_dst_city(_request([_site(), _site()], shares, other=_other())) + + +def test_a_pre_implement_scenario_row_the_model_discards_is_not_refused(): + scenario = {y: ([1.5, 0.0] if y < IMPLEMENT_YEAR else [0.25, 0.375]) for y in YEARS} + result = _run( + _request( + [_site(), _site()], [0.25, 0.375], other=_other(), scenario_shares=scenario + ) + ) + assert _site_shares(result, "scenario")[2].to_numpy() == pytest.approx(0.375) + + +@pytest.mark.parametrize( + "field, value", + [ + ("landfill_open_close", {"baseline": [2000, 2050], "scenario": [2000, 2050]}), + ("accepted_waste_mass", {"baseline": {2020: 1.0}, "scenario": {2020: 1.0}}), + ], +) +def test_other_landfills_take_no_dates_and_no_gate_total(field, value): + with pytest.raises(ValidationError, match=f"other_landfills take no {field}"): + _request([], other=_other(**{field: value})) + + +def test_a_request_needs_listed_landfills_or_other_landfills(): + with pytest.raises(ValidationError, match="unless other_landfills is given"): + _request([]) + + +def test_listed_landfills_still_need_their_shares(): + with pytest.raises(ValidationError, match="landfill_split_timeline is required"): + _request([_site()], other=_other()) From 594869cec125515415ceb69033acb42fc574a0db Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Mon, 19 Oct 2026 20:38:36 -0700 Subject: [PATCH 2/8] The changelog links the PR Co-Authored-By: Claude Opus 5.5 --- changelog/2026-10.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog/2026-10.md b/changelog/2026-10.md index d5b5184..ed1f31a 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -69,6 +69,7 @@ take to other landfills, so a caller no longer has to make the shares add up to `sites` and in `run_advanced_dst_city_defaults`. A run with them is the same, bit for bit, as a run that lists that landfill at the leftover share, and a test holds the two to that. Without the field, nothing changes. + ([#90](https://github.com/RMI/SWEET_python/pull/90)) - **A city-level advanced DST site can state its own `k` and oxidation rate.** `CityLandfillSpec` gains `k_override` and `oxidation_override`, the site DST's From 3bbe7c873e57bf4e4ecc7693faf23b3ebc0244bf Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Mon, 19 Oct 2026 21:06:56 -0700 Subject: [PATCH 3/8] The changelog links the WasteMAP 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 ed1f31a..316400a 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -69,7 +69,7 @@ take to other landfills, so a caller no longer has to make the shares add up to `sites` and in `run_advanced_dst_city_defaults`. A run with them is the same, bit for bit, as a run that lists that landfill at the leftover share, and a test holds the two to that. Without the field, nothing changes. - ([#90](https://github.com/RMI/SWEET_python/pull/90)) + ([#90](https://github.com/RMI/SWEET_python/pull/90), [RMI/WasteMAP#887](https://github.com/RMI/WasteMAP/pull/887)) - **A city-level advanced DST site can state its own `k` and oxidation rate.** `CityLandfillSpec` gains `k_override` and `oxidation_override`, the site DST's From 37806d113ce22909848234cdd124053e0e236444 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 07:50:20 -0700 Subject: [PATCH 4/8] 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 | 16 ++++ tests/test_population_trajectory.py | 126 ++++++++++++++++++++++++++++ 3 files changed, 250 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 5f91894..2ed904a 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, @@ -4858,14 +4961,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( @@ -4884,7 +4982,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 5f96c06..163472c 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -48,6 +48,22 @@ at each site that states none. No request that leaves them out changes. ## 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. - **A city-level advanced DST site can state its own `k` and oxidation rate.** `CityLandfillSpec` gains `k_override` and `oxidation_override`, the site DST's `ks_overrides` and `oxidation_override` for one landfill. Each is optional and 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 997e9ebdefdc0fb19fa791efacc12af62df307a5 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 08:40:16 -0700 Subject: [PATCH 5/8] The changelog links the PR Co-Authored-By: Claude Opus 5.5 --- changelog/2026-10.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog/2026-10.md b/changelog/2026-10.md index 39c5dec..18b6d95 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -67,7 +67,9 @@ take to other landfills, so a caller no longer has to make the shares add up to 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), + [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 From 0f50e1a500844a4a9d0b9e5f8188d5b8a2029cb0 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 09:01:46 -0700 Subject: [PATCH 6/8] 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 2ed904a..9b8472f 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 9f371e638d2d74183b522b49e8e47458f1307b5d Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 08:55:25 -0700 Subject: [PATCH 7/8] 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 9b8472f..7d0a2a7 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -4936,7 +4936,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. @@ -4945,6 +4950,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 @@ -5119,9 +5128,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 18b6d95..be6dcbd 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -70,6 +70,16 @@ take to other landfills, so a caller no longer has to make the shares add up to 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. + - **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 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 869f310fed2673ccdcfba30bd7f2f1b84ec60449 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 20 Oct 2026 09:16:22 -0700 Subject: [PATCH 8/8] 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 be6dcbd..d844bb7 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -78,7 +78,8 @@ take to other landfills, so a caller no longer has to make the shares add up to 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. ([#95](https://github.com/RMI/SWEET_python/pull/95), + [RMI/WasteMAP#891](https://github.com/RMI/WasteMAP/pull/891)) - **The city-level advanced DST can send the rest of a city's waste to other landfills.** `AdvancedDSTCityRequest` gains `other_landfills`, a landfill spec