diff --git a/SWEET_python/advanced_dst_city.py b/SWEET_python/advanced_dst_city.py index 2f6578c..2a9948d 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,123 @@ 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: + # An empty scenario split means the baseline's, as in + # `run_advanced_dst_city`; `variant_get` falls back only on None. + timeline = ( + request.landfill_split_timeline[variant] + or request.landfill_split_timeline["baseline"] + ) + 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 +784,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 +1024,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 +1211,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 +1228,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 +1314,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..316400a 100644 --- a/changelog/2026-10.md +++ b/changelog/2026-10.md @@ -46,8 +46,31 @@ 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. + ([#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 `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..6352554 --- /dev/null +++ b/tests/test_adst_city_other_landfills.py @@ -0,0 +1,308 @@ +"""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_an_empty_scenario_split_is_the_baseline_s_with_other_landfills(): + """`scenario: {}` means the baseline's shares, as it does without other landfills.""" + empty = _request([_site()], [0.25], other=_other(), scenario_shares={}) + stated = _request([_site()], [0.25], other=_other()) + + _assert_same_run(_run(empty), _run(stated)) + for entry_point in (run_advanced_dst_city_limits, run_advanced_dst_city_defaults): + entry_point(empty) # refused before, as "landfill_split_timeline is required" + + +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())