From b2329ae0391aeac7378861bfb45d5ddb9945cc99 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Mon, 17 Aug 2026 21:05:28 -0700 Subject: [PATCH 1/2] Fix City.sdst_v1_5 applying scenario composition before the implementation year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit City.sdst_v1_5 models the baseline and scenario as two independent landfills. Before the scenario's implementation year nothing has been changed, so the scenario landfill must receive exactly the baseline waste — mass and composition — and emit identically until then. The scenario waste-mass series already splices the total mass at implement_year, but the scenario composition (fractions) was applied to every year. The two TRACE-reconciled branches (real sites) correct this with `waste_masses_df_scenario.loc[:implement_year-1, :] = waste_masses_df_baseline...`, and the newer advanced_dst / advanced_dst_city engines force baseline pre-implement too. Only the blank/custom-site branch (baseline_data=None, a "Custom Location" in the /sdst UI) omitted the splice, so a scenario that changed the waste composition wrongly back-dated the new composition onto deposits from before the implementation year — the baseline and scenario diverged before the change was even implemented (~6% on the scenario series in a representative case). Add the same pre-implement splice the sibling branches already use. It is a no-op whenever baseline and scenario composition match, so it only changes output for a composition-changing scenario on the custom-site path. model-output-change. Adds tests/test_sdst_v1_5_preimplement_composition.py (fails on the pre-fix engine). Co-Authored-By: Claude Opus 4.8 --- SWEET_python/city_params.py | 12 ++ ...test_sdst_v1_5_preimplement_composition.py | 178 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 tests/test_sdst_v1_5_preimplement_composition.py diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 30681c9..9fd70cc 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -8276,6 +8276,18 @@ def _apply_open_close_window( waste_masses_df_scenario, "scenario" ) else: + # Before the implementation year nothing has changed, so the scenario + # landfill must receive exactly the baseline waste -- mass *and* + # composition. The two trace-reconciled branches above already splice + # this (waste_masses_df_scenario.loc[:implement_year-1] = + # waste_masses_df_baseline...); this blank/custom-site branch omitted + # it, so a scenario that changed the waste composition wrongly + # back-dated the new composition onto deposits from before + # implement_year. Mirror the sibling branches (and + # advanced_dst.run_advanced_dst) by forcing baseline pre-implement. + generated_waste_masses_scenario.loc[:implement_year - 1, :] = ( + generated_waste_masses_baseline.loc[:implement_year - 1, :] + ) waste_masses_df_baseline = _apply_open_close_window( generated_waste_masses_baseline, "baseline" ) diff --git a/tests/test_sdst_v1_5_preimplement_composition.py b/tests/test_sdst_v1_5_preimplement_composition.py new file mode 100644 index 0000000..6bc4915 --- /dev/null +++ b/tests/test_sdst_v1_5_preimplement_composition.py @@ -0,0 +1,178 @@ +"""Regression test: sdst scenario == baseline before the implementation year. + +``City.sdst_v1_5`` models the baseline and the scenario as two independent +landfills. Before the scenario's implementation year nothing has been changed, so +the scenario landfill must receive exactly the same waste as the baseline — the +same total mass *and* the same composition — and therefore emit exactly the same +methane in every year before ``implement_year``. + +For real (TRACE-reconciled) sites the engine already enforced this by splicing the +scenario waste back to the baseline pre-implement. The blank/custom-site branch +(``baseline_data=None`` — a "Custom Location" in the /sdst UI) omitted that splice: +it applied the *scenario* waste composition to every year, so a scenario that +changed the waste composition wrongly back-dated the new composition onto deposits +from before the implementation year. This test drives that exact path (a custom +site with a different scenario composition) and asserts the pre-implement +scenario emissions equal the baseline. It fails on the pre-fix engine. +""" + +import pandas as pd +import pytest + +from SWEET_python.city_params import City +from SWEET_python.class_defs import Variant + +COMPONENT_ORDER = [ + "food", + "green", + "wood", + "paper_cardboard", + "textiles", + "plastic", + "metal", + "glass", + "rubber", + "other", +] + +OPEN_YEAR = 2000 +CLOSE_YEAR = 2050 +IMPLEMENT_YEAR = 2025 +MODEL_YEAR_MAX = 2050 + +# Baseline and scenario compositions differ; both sum to 1.0. +BASELINE_FRACTIONS = [0.5, 0.1, 0.05, 0.1, 0.05, 0.1, 0.02, 0.03, 0.0, 0.05] +SCENARIO_FRACTIONS = [0.3, 0.1, 0.05, 0.2, 0.05, 0.15, 0.05, 0.05, 0.0, 0.05] + + +def _run_sdst(baseline_fractions, scenario_fractions): + """Drive City.sdst_v1_5 for a blank/custom single site (baseline_data=None).""" + years = pd.Index(range(OPEN_YEAR, MODEL_YEAR_MAX + 1)) + + def _expand(values): + return pd.DataFrame( + [list(values)] * len(years), index=years, columns=COMPONENT_ORDER, dtype=float + ) + + waste_mass_year = Variant[int](baseline=2025, scenario=2025) + city = City("preimplement_composition_test") + city.cityparams_obj_for_blank_site( + country="BRA", + population=None, + precipitation=500.0, + temperature=10.0, + waste_fractions=Variant(baseline=list(baseline_fractions), scenario=list(scenario_fractions)), + waste_mass_year=waste_mass_year, + growth_rate_override=0.0, + ) + + city.sdst_v1_5( + precipitation=500.0, + new_waste_fractions={"baseline": _expand(baseline_fractions), "scenario": _expand(scenario_fractions)}, + new_landfill_types=Variant(baseline=[2], scenario=[0]), + new_gas_efficiency=Variant(baseline=[0.0], scenario=[0.6]), + new_landfill_open_close_dates=Variant( + baseline=[(OPEN_YEAR, CLOSE_YEAR)], scenario=[(OPEN_YEAR, CLOSE_YEAR)] + ), + scenario=1, + landfill_split_timeline=Variant( + baseline={year: [1.0] for year in years}, scenario={year: [1.0] for year in years} + ), + new_landfill_latlons=None, + new_landfill_areas=None, + new_covertypes=None, + new_coverthicknesses=None, + waste_burning=Variant(baseline=0.0, scenario=0.0), + new_landfill_flaring=Variant(baseline=[0.98], scenario=[0.98]), + fancy_ox=None, + new_waste_mass=Variant(baseline=10000.0, scenario=10000.0), + waste_mass_year=waste_mass_year, + depths=Variant(baseline=[3.0], scenario=[3.0]), + ks_overrides=Variant(baseline=0.2, scenario=0.2), + biocover={"baseline": 0.0, "scenario": 0.0}, + oxidation_override=None, + baseline_data=None, + implement_year=IMPLEMENT_YEAR, + growth_rate_override=0.0, + country_growth_defaults=[1.0, 1.0], + ) + return ( + city.baseline_parameters.total_emissions["total"], + city.scenario_parameters[0].total_emissions["total"], + ) + + +def test_scenario_matches_baseline_before_implement_year(): + baseline, scenario = _run_sdst(BASELINE_FRACTIONS, SCENARIO_FRACTIONS) + + for year in range(OPEN_YEAR, IMPLEMENT_YEAR): + assert scenario.loc[year] == pytest.approx(baseline.loc[year], abs=1e-9), ( + f"scenario emissions in {year} (< implement year {IMPLEMENT_YEAR}) must " + f"equal baseline: got scenario={scenario.loc[year]}, baseline={baseline.loc[year]}" + ) + + +def test_scenario_diverges_from_baseline_after_implement_year(): + # Sanity check that the scenario really is a different scenario (dump -> landfill + # with gas capture), so the pre-implement equality above is a real constraint, + # not a degenerate baseline==scenario run. + baseline, scenario = _run_sdst(BASELINE_FRACTIONS, SCENARIO_FRACTIONS) + + assert scenario.loc[2050] < baseline.loc[2050] + + +def test_pre_implement_equality_holds_when_only_composition_changes(): + # Even with no landfill-type/gas change at all, a pure composition change must + # not affect pre-implement emissions. + years = pd.Index(range(OPEN_YEAR, MODEL_YEAR_MAX + 1)) + + def _expand(values): + return pd.DataFrame( + [list(values)] * len(years), index=years, columns=COMPONENT_ORDER, dtype=float + ) + + waste_mass_year = Variant[int](baseline=2025, scenario=2025) + city = City("preimplement_composition_only") + city.cityparams_obj_for_blank_site( + country="BRA", + population=None, + precipitation=500.0, + temperature=10.0, + waste_fractions=Variant(baseline=list(BASELINE_FRACTIONS), scenario=list(SCENARIO_FRACTIONS)), + waste_mass_year=waste_mass_year, + growth_rate_override=0.0, + ) + city.sdst_v1_5( + precipitation=500.0, + new_waste_fractions={"baseline": _expand(BASELINE_FRACTIONS), "scenario": _expand(SCENARIO_FRACTIONS)}, + new_landfill_types=Variant(baseline=[0], scenario=[0]), + new_gas_efficiency=Variant(baseline=[0.0], scenario=[0.0]), + new_landfill_open_close_dates=Variant( + baseline=[(OPEN_YEAR, CLOSE_YEAR)], scenario=[(OPEN_YEAR, CLOSE_YEAR)] + ), + scenario=1, + landfill_split_timeline=Variant( + baseline={year: [1.0] for year in years}, scenario={year: [1.0] for year in years} + ), + new_landfill_latlons=None, + new_landfill_areas=None, + new_covertypes=None, + new_coverthicknesses=None, + waste_burning=Variant(baseline=0.0, scenario=0.0), + new_landfill_flaring=None, + fancy_ox=None, + new_waste_mass=Variant(baseline=10000.0, scenario=10000.0), + waste_mass_year=waste_mass_year, + depths=Variant(baseline=[3.0], scenario=[3.0]), + ks_overrides=Variant(baseline=0.2, scenario=0.2), + biocover={"baseline": 0.0, "scenario": 0.0}, + oxidation_override=None, + baseline_data=None, + implement_year=IMPLEMENT_YEAR, + growth_rate_override=0.0, + country_growth_defaults=[1.0, 1.0], + ) + baseline = city.baseline_parameters.total_emissions["total"] + scenario = city.scenario_parameters[0].total_emissions["total"] + for year in range(OPEN_YEAR, IMPLEMENT_YEAR): + assert scenario.loc[year] == pytest.approx(baseline.loc[year], abs=1e-9) From 0bf7eeab224da079964d5347e49b925286f000cf Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Mon, 17 Aug 2026 21:08:38 -0700 Subject: [PATCH 2/2] Changelog: sdst_v1_5 pre-implement composition fix Co-Authored-By: Claude Opus 4.8 --- changelog/2026-08.md | 21 ++++++++++++++++++++- changelog/README.md | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/changelog/2026-08.md b/changelog/2026-08.md index 2210c48..ff86727 100644 --- a/changelog/2026-08.md +++ b/changelog/2026-08.md @@ -1,12 +1,31 @@ # SWEET_python Changelog — August 2026 -**Highlights:** The single-site advanced DST (`advanced_dst`) gains two optional inputs — `depth` and `k_override` — that restore the last two site-DST (`City.sdst_v1_5`) levers the adst model had no equivalent for. Both default to "derive as before," so existing adst calls are byte-for-byte unaffected; they are opt-in and not a model-output change for current callers. Separately, a variable-name bug in `City.sdst_v1_5` was silently discarding the `/sdst` flaring efficiency and forcing flare destruction to the 0.98 default on every run. Sites that set a non-default flaring efficiency now model the value the user supplied. This is a model-output change for any sdst run with a non-0.98 flaring efficiency. Also, the annual model (`estimate_emissions2`) now applies cover oxidation by emission year rather than deposit year, so a mid-life oxidation change (e.g. biocover) affects methane emitted from that year on — fixing biocover having no effect on already-closed landfills (WasteMAP #719). This is a model-output change for scenarios that vary oxidation over time; constant-oxidation runs are unchanged. +**Highlights:** The single-site advanced DST (`advanced_dst`) gains two optional inputs — `depth` and `k_override` — that restore the last two site-DST (`City.sdst_v1_5`) levers the adst model had no equivalent for. Both default to "derive as before," so existing adst calls are byte-for-byte unaffected; they are opt-in and not a model-output change for current callers. Separately, a variable-name bug in `City.sdst_v1_5` was silently discarding the `/sdst` flaring efficiency and forcing flare destruction to the 0.98 default on every run. Sites that set a non-default flaring efficiency now model the value the user supplied. This is a model-output change for any sdst run with a non-0.98 flaring efficiency. Also, the annual model (`estimate_emissions2`) now applies cover oxidation by emission year rather than deposit year, so a mid-life oxidation change (e.g. biocover) affects methane emitted from that year on — fixing biocover having no effect on already-closed landfills (WasteMAP #719). This is a model-output change for scenarios that vary oxidation over time; constant-oxidation runs are unchanged. Finally, `City.sdst_v1_5`'s blank/custom-site path now holds the scenario equal to the baseline for every year before the implementation year even when the scenario changes the waste composition — it was back-dating the new composition onto pre-implementation deposits, so baseline and scenario diverged before the change was even implemented. This is a model-output change only for a composition-changing scenario on the custom-site path; composition-stable runs and every baseline are byte-for-byte unchanged. ## Added - `AdvancedDSTRequest.depth` (`Optional[Variant[float]]`, metres): a controlled or open dump (landfill type 1 or 2) deeper than 5 m has its methane correction factor raised to 0.8, matching `City.sdst_v1_5`'s deep-dump rule (deep dumps decompose more anaerobically). Implemented in `dst_common.mcf_series`, which now accepts optional per-variant `baseline_depth`/`scenario_depth`; a `None` depth (the default) leaves MCF at the per-type value, and the rule never applies to engineered landfills (type 0). Restores the `/sdst` "Depth" control for adst. ([#40](https://github.com/RMI/SWEET_python/pull/40)) - `AdvancedDSTRequest.k_override` (`Optional[Variant[YearlyFloat]]`): a per-year decomposition rate `k` that, when supplied, is applied uniformly to every biodegradable component and bypasses the derived (temperature/precipitation/composition) k — mirroring `City.sdst_v1_5`'s `ks_overrides`. Implemented via the new `dst_common.uniform_decomposition_rates` helper; spliced at `implement_year` like every other adst scenario input. Restores the `/sdst` "Degradation rate (k)" control for adst. ([#40](https://github.com/RMI/SWEET_python/pull/40)) ## Fixed +- `City.sdst_v1_5` now holds the scenario equal to the baseline for every year + **before the implementation year**, even when the scenario changes the waste + composition. The method models the baseline and scenario as two landfills; the + scenario waste-mass series splices the *total mass* at `implement_year` but + applied the *scenario composition* to every year. On the blank/custom-site path + (`baseline_data=None` — a "Custom Location" in `/sdst`) a scenario that changed + composition therefore back-dated the new composition onto deposits from before + the change was implemented, and the two series diverged pre-implement (~6% on + the scenario series in a representative dump→landfill case). The two + TRACE-reconciled branches already spliced the scenario back to the baseline + pre-implement (`waste_masses_df_scenario.loc[:implement_year-1, :] = + waste_masses_df_baseline...`), as do `advanced_dst.run_advanced_dst` and + `advanced_dst_city.run_advanced_dst_city`; only the `else` (custom-site) branch + omitted it. It now applies the same splice, before the open/close window. + **Model-output change:** a composition-changing scenario on the custom-site path + now produces different (correct) results; a scenario that does not change + composition — and every baseline — is byte-for-byte unchanged. Adds + `tests/test_sdst_v1_5_preimplement_composition.py`. + ([#47](https://github.com/RMI/SWEET_python/pull/47)) - `model_v2.SWEET.estimate_emissions2` now applies cover oxidation by **emission year**, not deposit year. The method builds `(deposit_year, emission_year)` methane matrices and sums over deposit years; gas capture and flaring already diff --git a/changelog/README.md b/changelog/README.md index bf86bb2..e8fb8ac 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -15,7 +15,7 @@ The project does not publish semantic version tags, so releases are tracked by Newest first: -- [2026-08](2026-08.md) — Single-site adst gains optional `depth` (deep-dump MCF bump) and `k_override` (caller-supplied decomposition rate) inputs, restoring the last two site-DST levers; `/sdst` flaring efficiency reaches the model again after a variable-name bug silently forced flare destruction to 0.98; annual model applies cover oxidation by emission year not deposit year, fixing biocover having no effect on closed landfills (WasteMAP #719) (model-output change) +- [2026-08](2026-08.md) — Single-site adst gains optional `depth` (deep-dump MCF bump) and `k_override` (caller-supplied decomposition rate) inputs, restoring the last two site-DST levers; `/sdst` flaring efficiency reaches the model again after a variable-name bug silently forced flare destruction to 0.98; annual model applies cover oxidation by emission year not deposit year, fixing biocover having no effect on closed landfills (WasteMAP #719); `City.sdst_v1_5` custom-site path holds the scenario equal to the baseline before the implementation year even when composition changes (was back-dating the new composition onto pre-implementation deposits) (model-output change) - [2026-07](2026-07.md) — All ten waste types eligible for combustion (metal/glass/other added); methane-only model treats combustion as landfill diversion (model-output change) - [2026-06](2026-06.md) — New single-site and city-level ADST modeling modules, min-cost max-flow rewrite of the city DST diversion allocator, physical-k fix for cold/dry sites, no more spurious negative food-waste mass - [2026-05](2026-05.md) — SDST models from a landfill's actual open year (1950–2050), Central Asia/Afghanistan disposal-default fix, auto-Jira issue tooling, professional-comment cleanup