From 6aa9e6b2e5e8d8bae20063dc035b61b36b7bec52 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 25 Aug 2026 06:52:16 -0700 Subject: [PATCH 1/6] Open dumps use the IPCC uncategorised-SWDS MCF (0.6), not unmanaged-shallow (0.4) IPCC splits unmanaged SWDS by waste depth: >5 m is "unmanaged deep" (MCF 0.8), shallower is "unmanaged shallow" (0.4), and a site whose depth is unknown is "uncategorised" (0.6). SWEET applied the 0.8 bump wherever a depth was supplied but fell back to 0.4 when it was not, treating "depth unknown" as "known to be shallow". No dump carries a depth in practice. In the August 2026 Climate TRACE input table, depth is populated for 450 of 4,635 sanitary landfills (where MCF is 1.0 regardless) and for 0 of 8,067 dumpsites and 0 of 959 controlled dumpsites, so the deep-dump rule never fires and 0.4 was applied universally. Changed in every MCF table: dst_common.MCF_BY_TYPE; the site-type tables in sinar_city_and_site, site_only_estimate, site_only_estimate_trace and citysite_estimate_trace; the landfill-type tables in sdst_v1_5, implement_dst_changes_advanced and advanced_baseline; the pre-implementation dump MCF in implement_dst_changes_simple_v1_5; and the generic city dumpsite bucket in _calculate_divs. The depth > 5 m rule is untouched. Model-output change: MCF multiplies generation linearly, so methane generation rises 50% at every open-dump site. Co-Authored-By: Claude Opus 5 --- SWEET_python/city_params.py | 48 ++++++++++++++++++++++++++++++------- SWEET_python/dst_common.py | 6 ++++- changelog/2026-08.md | 32 ++++++++++++++++++++++++- changelog/README.md | 2 +- 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 9fd70cc..350a2f6 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -1802,10 +1802,14 @@ def sinar_city_and_site(self, row, linker, for_trace=False): "Controlled Dumpsite": 1, "Dumpsite": 2, } + # MCF by site type. "Dumpsite" carries the IPCC uncategorised-SWDS default + # (0.6), not unmanaged-shallow (0.4): waste depth is absent for nearly every + # site, so we cannot assert a dump is shallow. Where depth IS known and + # exceeds 5 m, the rule further down still raises MCF to 0.8. mcf_options = { "Sanitary Landfill": 1, "Controlled Dumpsite": 0.7, - "Dumpsite": 0.4, + "Dumpsite": 0.6, } ox_options = { "ox_nocap": { @@ -2241,10 +2245,14 @@ def site_only_estimate(self, row=None, pop_data=None): "Controlled Dumpsite": 1, "Dumpsite": 2, } + # MCF by site type. "Dumpsite" carries the IPCC uncategorised-SWDS default + # (0.6), not unmanaged-shallow (0.4): waste depth is absent for nearly every + # site, so we cannot assert a dump is shallow. Where depth IS known and + # exceeds 5 m, the rule further down still raises MCF to 0.8. mcf_options = { "Sanitary Landfill": 1, "Controlled Dumpsite": 0.7, - "Dumpsite": 0.4, + "Dumpsite": 0.6, } ox_options = { "ox_nocap": { @@ -2468,10 +2476,14 @@ def site_only_estimate_trace(self, canonical_row=None, time_series_rows=None, po "Controlled Dumpsite": 1, "Dumpsite": 2, } + # MCF by site type. "Dumpsite" carries the IPCC uncategorised-SWDS default + # (0.6), not unmanaged-shallow (0.4): waste depth is absent for nearly every + # site, so we cannot assert a dump is shallow. Where depth IS known and + # exceeds 5 m, the rule further down still raises MCF to 0.8. mcf_options = { "Sanitary Landfill": 1, "Controlled Dumpsite": 0.7, - "Dumpsite": 0.4, + "Dumpsite": 0.6, } ox_options = { "ox_nocap": { @@ -2792,10 +2804,14 @@ def citysite_estimate_trace(self, canonical_row=None, time_series_rows=None, cit "Controlled Dumpsite": 1, "Dumpsite": 2, } + # MCF by site type. "Dumpsite" carries the IPCC uncategorised-SWDS default + # (0.6), not unmanaged-shallow (0.4): waste depth is absent for nearly every + # site, so we cannot assert a dump is shallow. Where depth IS known and + # exceeds 5 m, the rule further down still raises MCF to 0.8. mcf_options = { "Sanitary Landfill": 1, "Controlled Dumpsite": 0.7, - "Dumpsite": 0.4, + "Dumpsite": 0.6, } ox_options = { "ox_nocap": { @@ -4336,7 +4352,9 @@ def _calculate_divs(self, advanced_baseline=False, advanced_dst=False) -> None: open_date=MODEL_START_YEAR, close_date=MODEL_END_YEAR, site_type="dumpsite", - mcf=pd.Series(0.4, index=years), + # IPCC uncategorised-SWDS MCF (0.6); the generic city split has no + # depth to distinguish unmanaged-deep from unmanaged-shallow. + mcf=pd.Series(0.6, index=years), city_params_dict=city_params_dict, city_instance_attrs=city_parameters.city_instance_attrs, landfill_index=2, @@ -7095,7 +7113,7 @@ def implement_dst_changes_simple_v1_5( scenario_parameters.landfills[2].mcf = pd.Series( 0.7, index=range(MODEL_START_YEAR, MODEL_END_YEAR + 1) ) - scenario_parameters.landfills[2].mcf.loc[:implement_year] = 0.4 + scenario_parameters.landfills[2].mcf.loc[:implement_year] = 0.6 skip_ox = True if move_gas: @@ -7620,7 +7638,11 @@ def implement_dst_changes_advanced( city_params_dict = self.update_cityparams_dict(scenario_parameters) # mcfs = [1, 0.7, 0.4] # Should this include ameliorated? # mcf_ameliorated = [0.7, 0.4, 0.1] - mcf_options = [1, 0.6, 0.4] + # MCF by landfill type index: 0 landfill, 1 controlled dump, 2 open dump. + # Index 2 carries the IPCC uncategorised-SWDS default (0.6), not + # unmanaged-shallow (0.4), because waste depth is absent for nearly every + # site. Depth > 5 m, where known, still raises MCF to 0.8 below. + mcf_options = [1, 0.6, 0.6] gas_capture_efficiencies = {} gas_capture_efficiencies["ameliorated"] = [0.5, 0.3, 0] gas_capture_efficiencies["not_ameliorated"] = [0.6, 0.45, 0] @@ -8327,7 +8349,11 @@ def _apply_open_close_window( city_params_dict = self.update_cityparams_dict(scenario_parameters) # mcfs = [1, 0.7, 0.4] # Should this include ameliorated? # mcf_ameliorated = [0.7, 0.4, 0.1] - mcf_options = [1, 0.6, 0.4] + # MCF by landfill type index: 0 landfill, 1 controlled dump, 2 open dump. + # Index 2 carries the IPCC uncategorised-SWDS default (0.6), not + # unmanaged-shallow (0.4), because waste depth is absent for nearly every + # site. Depth > 5 m, where known, still raises MCF to 0.8 below. + mcf_options = [1, 0.6, 0.6] gas_capture_efficiencies = {} gas_capture_efficiencies["ameliorated"] = [0.5, 0.3, 0] gas_capture_efficiencies["not_ameliorated"] = [0.6, 0.45, 0] @@ -8730,7 +8756,11 @@ def advanced_baseline( city_params_dict = self.update_cityparams_dict(scenario_parameters) # mcfs = [1, 0.7, 0.4] # Should this include ameliorated? # mcf_ameliorated = [0.7, 0.4, 0.1] - mcf_options = [1, 0.6, 0.4] + # MCF by landfill type index: 0 landfill, 1 controlled dump, 2 open dump. + # Index 2 carries the IPCC uncategorised-SWDS default (0.6), not + # unmanaged-shallow (0.4), because waste depth is absent for nearly every + # site. Depth > 5 m, where known, still raises MCF to 0.8 below. + mcf_options = [1, 0.6, 0.6] # mcfs['ameliorated'] = {} # mcf_options['not_ameliorated'] = {} # mcfs['ameliorated']['gas_capture'] = [0.18, 0, 0] diff --git a/SWEET_python/dst_common.py b/SWEET_python/dst_common.py index 2cfd623..a2e1e86 100644 --- a/SWEET_python/dst_common.py +++ b/SWEET_python/dst_common.py @@ -57,7 +57,11 @@ # Methane correction factor by landfill type (index == LandfillType value): # 0 landfill, 1 controlled dump, 2 open dump. -MCF_BY_TYPE: List[float] = [1.0, 0.6, 0.4] +# The open-dump value is the IPCC "uncategorised SWDS" default (0.6) rather than +# "unmanaged shallow" (0.4). Waste depth is absent for nearly every site we model, +# so an open dump cannot be asserted to be shallow; where depth IS supplied and +# exceeds DEEP_SITE_DEPTH_M, DEEP_DUMP_MCF still applies (see _mcf_for_type). +MCF_BY_TYPE: List[float] = [1.0, 0.6, 0.6] SITE_TYPE_NAMES: List[str] = ["landfill", "controlled_dumpsite", "dumpsite"] # A controlled/open dump deeper than DEEP_SITE_DEPTH_M behaves more like an diff --git a/changelog/2026-08.md b/changelog/2026-08.md index ff86727..8648b4c 100644 --- a/changelog/2026-08.md +++ b/changelog/2026-08.md @@ -1,11 +1,41 @@ # 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. 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. +**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. Separately, the methane correction factor for open dumps moves from the IPCC "unmanaged shallow" default (0.4) to the "uncategorised SWDS" default (0.6). Waste depth is absent for every dump in the data we model, so the old value asserted a shallowness we cannot observe. This raises modelled methane generation at open-dump sites by 50% and is a model-output change on every path that types a site as a dump. ## 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)) +## Changed +- **Open dumps now use the IPCC "uncategorised SWDS" methane correction factor + (0.6) instead of "unmanaged shallow" (0.4).** The two IPCC unmanaged categories + are split by waste depth: deeper than 5 m is "unmanaged deep" (MCF 0.8), + shallower is "unmanaged shallow" (0.4), and a site whose depth is unknown is + "uncategorised" (0.6). SWEET already applied the 0.8 deep-dump bump wherever a + depth was supplied, but fell back to 0.4 when it was not — treating "we do not + know the depth" as "we know it is shallow". In practice no dump or controlled + dump carries a depth at all (in the August 2026 Climate TRACE input table, + depth is populated for 450 of 4,635 sanitary landfills and for 0 of 8,067 + dumpsites and 0 of 959 controlled dumpsites), so 0.4 was applied universally. + 0.6 is the value IPCC 2006 Vol. 5 Table 3.1 prescribes for exactly that case. + Changed in every MCF table: `dst_common.MCF_BY_TYPE` (adst and city adst), the + site-type tables in `City.sinar_city_and_site`, `City.site_only_estimate`, + `City.site_only_estimate_trace` and `City.citysite_estimate_trace`, the + landfill-type tables in `City.sdst_v1_5`, `City.implement_dst_changes_advanced` + and `City.advanced_baseline`, the pre-implementation dump MCF in + `City.implement_dst_changes_simple_v1_5`, and the generic city dumpsite bucket + in `City._calculate_divs`. The depth > 5 m rule is untouched and still raises + MCF to 0.8 wherever a depth is supplied. **Model-output change:** methane + generation rises 50% at every open-dump site (MCF multiplies generation + linearly), so any run containing a dump produces higher emissions. + Two consequences worth knowing: a dump whose depth *is* supplied and is under + 5 m now also gets 0.6 rather than 0.4 (no such site exists in the Climate TRACE + inputs, but a DST user can enter one); and in the DST landfill-type tables, + where a controlled dump is 0.6, converting an open dump to a controlled dump no + longer raises MCF — the site-estimate tables keep a controlled dump at 0.7, so + the gradient survives there. + ([#49](https://github.com/RMI/SWEET_python/pull/49)) + ## 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 diff --git a/changelog/README.md b/changelog/README.md index e8fb8ac..994252f 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); `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-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); open dumps move to the IPCC uncategorised-SWDS MCF 0.6 (was unmanaged-shallow 0.4), since waste depth is never supplied for a dump (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 From e0db2b037a3a1cde6cb60b83c28a456763a366f8 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 25 Aug 2026 08:26:05 -0700 Subject: [PATCH 2/6] Controlled dumps join open dumps at the uncategorised MCF 0.6; one MCF table Follows the open-dump 0.4 -> 0.6 move in 6aa9e6b with the other half of the same argument. Controlled dumpsites drop 0.7 -> 0.6. "Controlled dumpsite" is not an IPCC category and does not map onto one: the rows a partly-managed site could sit in span a wide range depending on how well it is run, which is exactly what our inputs do not record. 0.7 asserted a management standard we have no evidence for; 0.6 ("uncategorised SWDS") is the value IPCC prescribes for not knowing. Waste depth is populated for 0 of 959 controlled dumpsites, same as for dumpsites. A supplied depth now selects the specific IPCC category in both directions. The rule only ever raised MCF to 0.8 above 5 m and otherwise fell through to the type default, so once that default became 0.6 there was no route to "unmanaged shallow" (0.4) even for a site known to be shallow. Now: above 5 m 0.8, at or below 0.4, absent (None/NaN) 0.6. Live on the two Climate TRACE site paths, which read a real waste_depth, and on advanced_dst, whose depth field is optional. Not applied on the /sdst request paths, which pass the new trust_shallow_depth=False. Those callers always send a number - the DST form defaults depth to 3 m and sdst_prepopulate fabricates 3 m for any dumpsite - so "no answer" cannot be told apart from "3 m", and a shallow reading would drop every default scenario to 0.4. Closing that needs the caller to send a null. The table itself moves to a new SWEET_python.mcf module. It had been copied into ten call sites and drifted apart into a dict-keyed family (controlled dump 0.7) and a list-keyed family (0.6); all ten now resolve through mcf.mcf_for_site. dst_common re-exports its old names, so importers are unaffected. First test coverage for MCF, in tests/test_mcf.py. Also fixed along the way: - sinar_city_and_site tested `site_type in (1, 2)` against a site-type string in its second per-landfill branch, so the deep-dump bump could never fire there. Masked by the hardcoded depth, so no output change. - sinar_city_and_site and site_only_estimate hardcoded `depth = 3` as a stand-in for "no depth available", which read as a claim the site was shallow. Both now pass None. No output change. - advanced_baseline no longer raises TypeError on its own `depth=None` default. Model-output change: MCF multiplies generation linearly, so modelled methane generation falls 14% at every controlled dumpsite. MCF no longer distinguishes the two dump types, so converting an open dump to a controlled dump changes emissions only through oxidation and gas capture. Co-Authored-By: Claude Opus 5 --- SWEET_python/advanced_dst.py | 10 ++- SWEET_python/city_params.py | 156 ++++++++++++----------------------- SWEET_python/dst_common.py | 43 ++++------ SWEET_python/mcf.py | 125 ++++++++++++++++++++++++++++ changelog/2026-08.md | 97 +++++++++++++++------- changelog/README.md | 2 +- tests/test_mcf.py | 102 +++++++++++++++++++++++ 7 files changed, 371 insertions(+), 164 deletions(-) create mode 100644 SWEET_python/mcf.py create mode 100644 tests/test_mcf.py diff --git a/SWEET_python/advanced_dst.py b/SWEET_python/advanced_dst.py index ef119c5..97fc041 100644 --- a/SWEET_python/advanced_dst.py +++ b/SWEET_python/advanced_dst.py @@ -67,10 +67,12 @@ class AdvancedDSTRequest(BaseModel): depth: Optional[Variant[float]] = Field( None, description=( - "Site depth in metres. A controlled/open dump (type 1 or 2) deeper " - "than 5 m has its MCF raised to 0.8 (deep dumps decompose more " - "anaerobically), matching City.sdst_v1_5. Omit to derive MCF from " - "landfill type alone." + "Site depth in metres. For a controlled/open dump (type 1 or 2) the " + "depth selects the IPCC unmanaged category: deeper than 5 m raises " + "MCF to 0.8, at or below 5 m lowers it to 0.4. Omit (the default) " + "when the depth is unknown, which keeps the IPCC uncategorised 0.6 " + "\u2014 an omitted depth is not read as shallow. Never applies to an " + "engineered landfill (type 0), which is 1.0 regardless." ), ) landfill_open_close: Variant[tuple[int, int]] = Field( diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 350a2f6..805dd94 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -22,6 +22,7 @@ from SWEET_python.landfill import Landfill from SWEET_python.singapore_k import compute_singapore_k import SWEET_python.defaults_2019 as defaults_2019 +import SWEET_python.mcf as mcf_defaults import psycopg2 from psycopg2.extras import RealDictCursor from sqlalchemy import create_engine, text @@ -1802,15 +1803,8 @@ def sinar_city_and_site(self, row, linker, for_trace=False): "Controlled Dumpsite": 1, "Dumpsite": 2, } - # MCF by site type. "Dumpsite" carries the IPCC uncategorised-SWDS default - # (0.6), not unmanaged-shallow (0.4): waste depth is absent for nearly every - # site, so we cannot assert a dump is shallow. Where depth IS known and - # exceeds 5 m, the rule further down still raises MCF to 0.8. - mcf_options = { - "Sanitary Landfill": 1, - "Controlled Dumpsite": 0.7, - "Dumpsite": 0.6, - } + # MCF by site type; see SWEET_python.mcf for the values and the depth rule. + mcf_options = dict(mcf_defaults.MCF_BY_SITE_TYPE_NAME) ox_options = { "ox_nocap": { "Sanitary Landfill": 0.1, @@ -1828,7 +1822,9 @@ def sinar_city_and_site(self, row, linker, for_trace=False): "Controlled Dumpsite": 0.45, "Dumpsite": 0.0, } - depth = 3 + # No depth is available on this path. None means "unknown", which keeps + # MCF at the uncategorised per-type value; it is not read as shallow. + depth = None landfills = linker["site_id"].unique().tolist() lifespans = {} site_types = {} @@ -1871,10 +1867,7 @@ def sinar_city_and_site(self, row, linker, for_trace=False): site_type = site_data.at[0, "site_type"] site_types[landfill] = site_type site_type_idx = get_site_type_idx[site_type] - if (depth > 5) and (site_type_idx in (1, 2)): - mcfs[landfill] = 0.8 - else: - mcfs[landfill] = mcf_options[site_type] + mcfs[landfill] = mcf_defaults.mcf_for_site(site_type_idx, depth) if "Yes" in site_data["fgc_lfg_collection_system_in_place"].unique(): gas_capture_presences[landfill] = True oxidation_values[landfill] = ox_options["ox_cap"][site_type] @@ -1959,10 +1952,7 @@ def sinar_city_and_site(self, row, linker, for_trace=False): site_type = site_data.at[0, "site_type"] site_types[landfill] = site_type site_type_idx = get_site_type_idx[site_type] - if (depth > 5) and (site_type in (1, 2)): - mcfs[landfill] = 0.8 - else: - mcfs[landfill] = mcf_options[site_type] + mcfs[landfill] = mcf_defaults.mcf_for_site(site_type_idx, depth) if "Yes" in site_data["fgc_lfg_collection_system_in_place"].unique(): gas_capture_presences[landfill] = True oxidation_values[landfill] = ox_options["ox_cap"][site_type] @@ -2245,15 +2235,6 @@ def site_only_estimate(self, row=None, pop_data=None): "Controlled Dumpsite": 1, "Dumpsite": 2, } - # MCF by site type. "Dumpsite" carries the IPCC uncategorised-SWDS default - # (0.6), not unmanaged-shallow (0.4): waste depth is absent for nearly every - # site, so we cannot assert a dump is shallow. Where depth IS known and - # exceeds 5 m, the rule further down still raises MCF to 0.8. - mcf_options = { - "Sanitary Landfill": 1, - "Controlled Dumpsite": 0.7, - "Dumpsite": 0.6, - } ox_options = { "ox_nocap": { "Sanitary Landfill": 0.1, @@ -2271,7 +2252,9 @@ def site_only_estimate(self, row=None, pop_data=None): "Controlled Dumpsite": 0.45, "Dumpsite": 0.0, } - depth = 3 + # No depth is available on this path. None means "unknown", which keeps + # MCF at the uncategorised per-type value; it is not read as shallow. + depth = None site_type = row["Site Type"].values[0] if site_type not in get_site_type_idx.keys(): if self.region in [ @@ -2297,10 +2280,7 @@ def site_only_estimate(self, row=None, pop_data=None): gas_capture_presence = False oxidation_value = ox_options["ox_nocap"][site_type] gas_capture_efficiency = gas_eff_options[site_type] - if (depth > 5.0) and (site_type_idx in (1, 2)): - mcf = 0.8 - else: - mcf = mcf_options[site_type] + mcf = mcf_defaults.mcf_for_site(site_type_idx, depth) open_date = row['Site Open Year'].fillna(MODEL_START_YEAR).values[0] if open_date < MODEL_START_YEAR: open_date = MODEL_START_YEAR @@ -2476,15 +2456,6 @@ def site_only_estimate_trace(self, canonical_row=None, time_series_rows=None, po "Controlled Dumpsite": 1, "Dumpsite": 2, } - # MCF by site type. "Dumpsite" carries the IPCC uncategorised-SWDS default - # (0.6), not unmanaged-shallow (0.4): waste depth is absent for nearly every - # site, so we cannot assert a dump is shallow. Where depth IS known and - # exceeds 5 m, the rule further down still raises MCF to 0.8. - mcf_options = { - "Sanitary Landfill": 1, - "Controlled Dumpsite": 0.7, - "Dumpsite": 0.6, - } ox_options = { "ox_nocap": { "Sanitary Landfill": 0.1, @@ -2596,10 +2567,7 @@ def site_only_estimate_trace(self, canonical_row=None, time_series_rows=None, po gas_capture_efficiency = 0 gas_capture_efficiency = pd.Series(gas_capture_efficiency, index=self.years_range) - if (depth > 5.0) and (site_type_idx in (1, 2)): - mcf = 0.8 - else: - mcf = mcf_options[site_type] + mcf = mcf_defaults.mcf_for_site(site_type_idx, depth) open_date = canonical_row['site_open_year'] if isinstance(open_date, str): if open_date[-2:] == '.0': @@ -2804,15 +2772,6 @@ def citysite_estimate_trace(self, canonical_row=None, time_series_rows=None, cit "Controlled Dumpsite": 1, "Dumpsite": 2, } - # MCF by site type. "Dumpsite" carries the IPCC uncategorised-SWDS default - # (0.6), not unmanaged-shallow (0.4): waste depth is absent for nearly every - # site, so we cannot assert a dump is shallow. Where depth IS known and - # exceeds 5 m, the rule further down still raises MCF to 0.8. - mcf_options = { - "Sanitary Landfill": 1, - "Controlled Dumpsite": 0.7, - "Dumpsite": 0.6, - } ox_options = { "ox_nocap": { "Sanitary Landfill": 0.1, @@ -2884,10 +2843,7 @@ def citysite_estimate_trace(self, canonical_row=None, time_series_rows=None, cit gas_capture_efficiency = gas_eff_options[site_type] gas_capture_efficiency = pd.Series(gas_capture_efficiency, index=self.years_range) - if (depth > 5.0) and (site_type_idx in (1, 2)): - mcf = 0.8 - else: - mcf = mcf_options[site_type] + mcf = mcf_defaults.mcf_for_site(site_type_idx, depth) open_date = canonical_row['site_open_year'] if isinstance(open_date, str): if open_date[-2:] == '.0': @@ -4352,9 +4308,9 @@ def _calculate_divs(self, advanced_baseline=False, advanced_dst=False) -> None: open_date=MODEL_START_YEAR, close_date=MODEL_END_YEAR, site_type="dumpsite", - # IPCC uncategorised-SWDS MCF (0.6); the generic city split has no - # depth to distinguish unmanaged-deep from unmanaged-shallow. - mcf=pd.Series(0.6, index=years), + # The generic city split carries no depth, so the dumpsite bucket + # takes the uncategorised MCF (see SWEET_python.mcf). + mcf=pd.Series(mcf_defaults.MCF_UNCATEGORISED, index=years), city_params_dict=city_params_dict, city_instance_attrs=city_parameters.city_instance_attrs, landfill_index=2, @@ -7110,10 +7066,14 @@ def implement_dst_changes_simple_v1_5( scenario_parameters.landfills[2].oxidation_factor.loc[ :implement_year ] = 0.0 + # Converting the dumpsite to a controlled dumpsite leaves MCF + # unchanged: both dump types take the uncategorised 0.6 (see + # SWEET_python.mcf). The conversion's benefit here comes from the + # gas capture and oxidation set just above. scenario_parameters.landfills[2].mcf = pd.Series( - 0.7, index=range(MODEL_START_YEAR, MODEL_END_YEAR + 1) + mcf_defaults.MCF_UNCATEGORISED, + index=range(MODEL_START_YEAR, MODEL_END_YEAR + 1), ) - scenario_parameters.landfills[2].mcf.loc[:implement_year] = 0.6 skip_ox = True if move_gas: @@ -7636,13 +7596,11 @@ def implement_dst_changes_advanced( # Set up new landfills city_params_dict = self.update_cityparams_dict(scenario_parameters) - # mcfs = [1, 0.7, 0.4] # Should this include ameliorated? - # mcf_ameliorated = [0.7, 0.4, 0.1] - # MCF by landfill type index: 0 landfill, 1 controlled dump, 2 open dump. - # Index 2 carries the IPCC uncategorised-SWDS default (0.6), not - # unmanaged-shallow (0.4), because waste depth is absent for nearly every - # site. Depth > 5 m, where known, still raises MCF to 0.8 below. - mcf_options = [1, 0.6, 0.6] + # MCF by landfill type index; see SWEET_python.mcf. This path passes + # trust_shallow_depth=False: the DST request always carries a number for + # depth (the form defaults to 3 m), so "no answer" cannot be told apart + # from "3 m" here, and reading a shallow depth as IPCC unmanaged-shallow + # would drop every default scenario to 0.4. The deep bump still applies. gas_capture_efficiencies = {} gas_capture_efficiencies["ameliorated"] = [0.5, 0.3, 0] gas_capture_efficiencies["not_ameliorated"] = [0.6, 0.45, 0] @@ -7661,14 +7619,12 @@ def implement_dst_changes_advanced( # Get MCF old_lf_type = new_landfill_types["baseline"][i] - mcf["baseline"] = mcf_options[old_lf_type] - mcf["scenario"] = mcf_options[lf_type] - - if (depths["baseline"][i] > 5) and (old_lf_type in (1, 2)): - mcf["baseline"] = 0.8 - - if (depths["scenario"][i] > 5) and (lf_type in (1, 2)): - mcf["scenario"] = 0.8 + mcf["baseline"] = mcf_defaults.mcf_for_site( + old_lf_type, depths["baseline"][i], trust_shallow_depth=False + ) + mcf["scenario"] = mcf_defaults.mcf_for_site( + lf_type, depths["scenario"][i], trust_shallow_depth=False + ) # Handle baseline first if i >= len(new_gas_efficiency["baseline"]): @@ -8347,13 +8303,11 @@ def _apply_open_close_window( # Set up new landfills city_params_dict = self.update_cityparams_dict(scenario_parameters) - # mcfs = [1, 0.7, 0.4] # Should this include ameliorated? - # mcf_ameliorated = [0.7, 0.4, 0.1] - # MCF by landfill type index: 0 landfill, 1 controlled dump, 2 open dump. - # Index 2 carries the IPCC uncategorised-SWDS default (0.6), not - # unmanaged-shallow (0.4), because waste depth is absent for nearly every - # site. Depth > 5 m, where known, still raises MCF to 0.8 below. - mcf_options = [1, 0.6, 0.6] + # MCF by landfill type index; see SWEET_python.mcf. This path passes + # trust_shallow_depth=False: the DST request always carries a number for + # depth (the form defaults to 3 m), so "no answer" cannot be told apart + # from "3 m" here, and reading a shallow depth as IPCC unmanaged-shallow + # would drop every default scenario to 0.4. The deep bump still applies. gas_capture_efficiencies = {} gas_capture_efficiencies["ameliorated"] = [0.5, 0.3, 0] gas_capture_efficiencies["not_ameliorated"] = [0.6, 0.45, 0] @@ -8376,14 +8330,12 @@ def _apply_open_close_window( # Get MCF old_lf_type = new_landfill_types["baseline"][0] - mcf["baseline"] = mcf_options[old_lf_type] - mcf["scenario"] = mcf_options[new_lf_type] - - if (depths["baseline"][0] > 5) and (old_lf_type in (1, 2)): - mcf["baseline"] = 0.8 - - if (depths["scenario"][0] > 5) and (new_lf_type in (1, 2)): - mcf["scenario"] = 0.8 + mcf["baseline"] = mcf_defaults.mcf_for_site( + old_lf_type, depths["baseline"][0], trust_shallow_depth=False + ) + mcf["scenario"] = mcf_defaults.mcf_for_site( + new_lf_type, depths["scenario"][0], trust_shallow_depth=False + ) # Handle baseline first if new_gas_efficiency["baseline"][0] == 0.0: @@ -8754,13 +8706,11 @@ def advanced_baseline( # Set up new landfills city_params_dict = self.update_cityparams_dict(scenario_parameters) - # mcfs = [1, 0.7, 0.4] # Should this include ameliorated? - # mcf_ameliorated = [0.7, 0.4, 0.1] - # MCF by landfill type index: 0 landfill, 1 controlled dump, 2 open dump. - # Index 2 carries the IPCC uncategorised-SWDS default (0.6), not - # unmanaged-shallow (0.4), because waste depth is absent for nearly every - # site. Depth > 5 m, where known, still raises MCF to 0.8 below. - mcf_options = [1, 0.6, 0.6] + # MCF by landfill type index; see SWEET_python.mcf. This path passes + # trust_shallow_depth=False: the DST request always carries a number for + # depth (the form defaults to 3 m), so "no answer" cannot be told apart + # from "3 m" here, and reading a shallow depth as IPCC unmanaged-shallow + # would drop every default scenario to 0.4. The deep bump still applies. # mcfs['ameliorated'] = {} # mcf_options['not_ameliorated'] = {} # mcfs['ameliorated']['gas_capture'] = [0.18, 0, 0] @@ -8779,9 +8729,9 @@ def advanced_baseline( for i, lf_type in enumerate(new_landfill_types): # Make the MCF, oxidation, and efficiency vectors years = pd.Index(range(MODEL_START_YEAR, MODEL_END_YEAR + 1)) - mcf = mcf_options[lf_type] - if (depth > 5) and (lf_type in (1, 2)): - mcf = 0.8 + mcf = mcf_defaults.mcf_for_site( + lf_type, depth, trust_shallow_depth=False + ) # Handle no gas capture first if new_gas_efficiency[i] == 0: # mcf = mcf_options['not_ameliorated']['no_gas_capture'][lf_type] diff --git a/SWEET_python/dst_common.py b/SWEET_python/dst_common.py index a2e1e86..83c57ac 100644 --- a/SWEET_python/dst_common.py +++ b/SWEET_python/dst_common.py @@ -15,6 +15,7 @@ import pandas as pd import SWEET_python.defaults_2019 as defaults_2019 +import SWEET_python.mcf as mcf_defaults from SWEET_python.city_params import City, CustomError from SWEET_python.class_defs import DecompositionRates from SWEET_python.constants import MODEL_END_YEAR @@ -55,22 +56,15 @@ "textiles", ] -# Methane correction factor by landfill type (index == LandfillType value): -# 0 landfill, 1 controlled dump, 2 open dump. -# The open-dump value is the IPCC "uncategorised SWDS" default (0.6) rather than -# "unmanaged shallow" (0.4). Waste depth is absent for nearly every site we model, -# so an open dump cannot be asserted to be shallow; where depth IS supplied and -# exceeds DEEP_SITE_DEPTH_M, DEEP_DUMP_MCF still applies (see _mcf_for_type). -MCF_BY_TYPE: List[float] = [1.0, 0.6, 0.6] -SITE_TYPE_NAMES: List[str] = ["landfill", "controlled_dumpsite", "dumpsite"] - -# A controlled/open dump deeper than DEEP_SITE_DEPTH_M behaves more like an -# anaerobic engineered landfill, so its MCF is raised to DEEP_DUMP_MCF. This -# mirrors City.sdst_v1_5's depth rule; it does not apply to engineered landfills -# (type 0), whose MCF is already 1.0. -DEEP_SITE_DEPTH_M = 5.0 -DEEP_DUMP_MCF = 0.8 -DEEP_MCF_DUMP_TYPES = (1, 2) # controlled dump, open dump +# Methane correction factors live in SWEET_python.mcf, which is the single source +# of truth for the table and for the depth rule. These names are re-exported so +# existing importers of dst_common keep working. +MCF_BY_TYPE: List[float] = mcf_defaults.MCF_BY_TYPE +SITE_TYPE_NAMES: List[str] = mcf_defaults.SITE_TYPE_NAMES + +DEEP_SITE_DEPTH_M = mcf_defaults.DEEP_SITE_DEPTH_M +DEEP_DUMP_MCF = mcf_defaults.MCF_UNMANAGED_DEEP +DEEP_MCF_DUMP_TYPES = mcf_defaults.DEPTH_SENSITIVE_TYPES # Oxidation factor lookup, mirroring City.sdst_v1_5 / Landfill.estimate_emissions. OX_NOCAP: Dict[str, float] = {"landfill": 0.1, "controlled_dumpsite": 0.05, "dumpsite": 0.0} @@ -267,15 +261,8 @@ def oxidation_series( def _mcf_for_type(site_type_idx: int, depth: Optional[float]) -> float: - """MCF for one site type, raised for deep controlled/open dumps. - - A dump (type 1 or 2) deeper than ``DEEP_SITE_DEPTH_M`` gets ``DEEP_DUMP_MCF``; - otherwise the standard per-type MCF applies. ``depth`` of ``None`` (the - default when no depth is supplied) leaves MCF at the per-type value. - """ - if depth is not None and depth > DEEP_SITE_DEPTH_M and site_type_idx in DEEP_MCF_DUMP_TYPES: - return DEEP_DUMP_MCF - return MCF_BY_TYPE[site_type_idx] + """MCF for one site type and depth. Thin alias for ``mcf.mcf_for_site``.""" + return mcf_defaults.mcf_for_site(site_type_idx, depth) def mcf_series( @@ -288,9 +275,9 @@ def mcf_series( ) -> pd.Series: """MCF series for one landfill: baseline type before implement_year, scenario after. - A controlled/open dump deeper than ``DEEP_SITE_DEPTH_M`` has its MCF raised to - ``DEEP_DUMP_MCF`` (deep dumps decompose more anaerobically), matching - City.sdst_v1_5. Depths of ``None`` leave MCF at the per-type value. + Depth selects the IPCC unmanaged category for a dump - deeper than + ``DEEP_SITE_DEPTH_M`` is 0.8, at or below is 0.4. A depth of ``None`` means + unknown and keeps the per-type uncategorised value. See ``SWEET_python.mcf``. """ series = pd.Series(_mcf_for_type(baseline_type, baseline_depth), index=years, dtype=float) series.loc[implement_year:] = _mcf_for_type(scenario_type, scenario_depth) diff --git a/SWEET_python/mcf.py b/SWEET_python/mcf.py new file mode 100644 index 0000000..7c7b37e --- /dev/null +++ b/SWEET_python/mcf.py @@ -0,0 +1,125 @@ +"""Methane correction factor (MCF) — the single source of truth for SWEET. + +MCF is the fraction of the degradable carbon deposited at a site that decomposes +anaerobically. It multiplies modelled methane generation linearly, so a change +here moves every downstream emissions number by the same proportion. + +Every MCF in SWEET resolves through :func:`mcf_for_site`. The table used to be +copied literally into ten call sites across ``city_params`` and ``dst_common``, +which is how the values drifted apart; do not reintroduce a literal. + +Site types +---------- +SWEET types a site as one of three (the index is the ``LandfillType`` value): + +=== ==================== ==== +idx SWEET label MCF +=== ==================== ==== +0 landfill 1.0 +1 controlled dumpsite 0.6 +2 dumpsite (open dump) 0.6 +=== ==================== ==== + +Index 0 is the IPCC "managed - anaerobic" category, 1.0. + +Indices 1 and 2 both take the IPCC "uncategorised SWDS" default of 0.6. IPCC +2006 Vol. 5 Ch. 3 Table 3.1 splits unmanaged sites by waste depth - deeper than +``DEEP_SITE_DEPTH_M`` is "unmanaged deep" (0.8), shallower is "unmanaged +shallow" (0.4) - and prescribes 0.6 for a site whose depth is unknown. Waste +depth is unpopulated for every dump and controlled dump in the data we model, +so 0.6 is the category that actually applies, not a compromise between the +other two. + +A controlled dumpsite carries that same 0.6 rather than a value of its own. +"Controlled dumpsite" is a SWEET/WasteMAP label, not an IPCC category, and it +does not map onto one: the IPCC rows a partly-managed site could plausibly sit +in span a wide range, and our inputs record nothing about how well any +individual site is run. "Uncategorised" is the honest reading of what we know. + +Consequence worth knowing: MCF no longer distinguishes a controlled dump from +an open dump, so converting one to the other changes modelled generation only +through cover oxidation and gas capture, which SWEET models separately. + +Depth +----- +Where a depth *is* supplied for a dump it selects the specific IPCC category +instead of the uncategorised default. A depth of ``None`` or ``NaN`` means +"unknown", not "shallow", and keeps the per-type value - that distinction is +the whole point of the uncategorised row. Depth never applies to an engineered +landfill (index 0), which is already fully anaerobic at 1.0. + +Note that the deep threshold is applied as a strict ``>``, so a site recorded at +exactly 5.0 m reads as shallow. IPCC words the category as ">= 5 m". The +difference only matters for a site whose depth is recorded as exactly 5, and the +strict comparison is what SWEET has always used. +""" + +from typing import Dict, List, Optional + +import pandas as pd + +# IPCC 2006 Vol. 5 Ch. 3 Table 3.1. +MCF_MANAGED_ANAEROBIC = 1.0 +MCF_UNMANAGED_DEEP = 0.8 +MCF_UNCATEGORISED = 0.6 +MCF_UNMANAGED_SHALLOW = 0.4 + +DEEP_SITE_DEPTH_M = 5.0 + +LANDFILL_TYPE = 0 +CONTROLLED_DUMPSITE_TYPE = 1 +DUMPSITE_TYPE = 2 +# Only the two dump types read a depth. An engineered landfill is 1.0 regardless. +DEPTH_SENSITIVE_TYPES = (CONTROLLED_DUMPSITE_TYPE, DUMPSITE_TYPE) + +# Indexed by LandfillType. +MCF_BY_TYPE: List[float] = [ + MCF_MANAGED_ANAEROBIC, + MCF_UNCATEGORISED, + MCF_UNCATEGORISED, +] + +SITE_TYPE_NAMES: List[str] = ["landfill", "controlled_dumpsite", "dumpsite"] + +# The same table keyed by the site-type strings the site/city estimate paths +# carry, rather than by LandfillType index. +MCF_BY_SITE_TYPE_NAME: Dict[str, float] = { + "Sanitary Landfill": MCF_MANAGED_ANAEROBIC, + "Controlled Dumpsite": MCF_UNCATEGORISED, + "Dumpsite": MCF_UNCATEGORISED, +} + + +def mcf_for_site( + site_type_idx: int, + depth: Optional[float] = None, + *, + trust_shallow_depth: bool = True, +) -> float: + """MCF for one site, given its type and (optionally) its waste depth. + + Args: + site_type_idx: ``LandfillType`` value - 0 landfill, 1 controlled dump, + 2 open dump. + depth: Waste depth in metres, or ``None``/``NaN`` when it is unknown. + Unknown keeps the per-type value; it is not read as shallow. + trust_shallow_depth: Whether a supplied depth at or below + ``DEEP_SITE_DEPTH_M`` may be read as IPCC "unmanaged shallow" (0.4). + Pass ``False`` for a caller that cannot represent "unknown depth" + and therefore sends a placeholder number when it has no answer - + for such a caller a shallow reading would silently apply 0.4 to + every site rather than only to the genuinely shallow ones. The deep + bump still applies either way. + + Returns: + The methane correction factor, between 0 and 1. + """ + if site_type_idx not in DEPTH_SENSITIVE_TYPES: + return MCF_BY_TYPE[site_type_idx] + if depth is None or pd.isna(depth): + return MCF_BY_TYPE[site_type_idx] + if depth > DEEP_SITE_DEPTH_M: + return MCF_UNMANAGED_DEEP + if trust_shallow_depth: + return MCF_UNMANAGED_SHALLOW + return MCF_BY_TYPE[site_type_idx] diff --git a/changelog/2026-08.md b/changelog/2026-08.md index 8648b4c..dac85db 100644 --- a/changelog/2026-08.md +++ b/changelog/2026-08.md @@ -1,42 +1,83 @@ # 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. 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. Separately, the methane correction factor for open dumps moves from the IPCC "unmanaged shallow" default (0.4) to the "uncategorised SWDS" default (0.6). Waste depth is absent for every dump in the data we model, so the old value asserted a shallowness we cannot observe. This raises modelled methane generation at open-dump sites by 50% and is a model-output change on every path that types a site as a dump. +**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. Separately, methane correction factors are now defined once, in a new `SWEET_python.mcf` module, instead of being copied into ten call sites, and both dump types move to the IPCC "uncategorised SWDS" default of 0.6 — open dumps up from "unmanaged shallow" 0.4, controlled dumps down from 0.7. Waste depth is absent for every dump in the data we model, so neither of the old values described a category we can actually observe. Where a depth *is* supplied the full IPCC split now applies, so a site recorded shallower than 5 m reads as 0.4 rather than falling back to the type default. Modelled methane generation rises 50% at open-dump sites and falls 14% at controlled dumps; this is a model-output change on every path that types a site as a dump. ## 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.depth` (`Optional[Variant[float]]`, metres): for a controlled or open dump (landfill type 1 or 2) the depth selects the IPCC unmanaged category — deeper than 5 m raises the methane correction factor to 0.8, at or below 5 m lowers it to 0.4 — matching `City.sdst_v1_5`'s deep-dump rule. Implemented in `dst_common.mcf_series`, which now accepts optional per-variant `baseline_depth`/`scenario_depth`; a `None` depth (the default) means unknown and keeps the uncategorised 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)) *(the shallow half of the rule arrived with [#49](https://github.com/RMI/SWEET_python/pull/49))* - `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)) ## Changed -- **Open dumps now use the IPCC "uncategorised SWDS" methane correction factor - (0.6) instead of "unmanaged shallow" (0.4).** The two IPCC unmanaged categories - are split by waste depth: deeper than 5 m is "unmanaged deep" (MCF 0.8), - shallower is "unmanaged shallow" (0.4), and a site whose depth is unknown is - "uncategorised" (0.6). SWEET already applied the 0.8 deep-dump bump wherever a - depth was supplied, but fell back to 0.4 when it was not — treating "we do not - know the depth" as "we know it is shallow". In practice no dump or controlled - dump carries a depth at all (in the August 2026 Climate TRACE input table, - depth is populated for 450 of 4,635 sanitary landfills and for 0 of 8,067 - dumpsites and 0 of 959 controlled dumpsites), so 0.4 was applied universally. - 0.6 is the value IPCC 2006 Vol. 5 Table 3.1 prescribes for exactly that case. - Changed in every MCF table: `dst_common.MCF_BY_TYPE` (adst and city adst), the - site-type tables in `City.sinar_city_and_site`, `City.site_only_estimate`, - `City.site_only_estimate_trace` and `City.citysite_estimate_trace`, the - landfill-type tables in `City.sdst_v1_5`, `City.implement_dst_changes_advanced` - and `City.advanced_baseline`, the pre-implementation dump MCF in +- **Both dump types now use the IPCC "uncategorised SWDS" methane correction + factor (0.6).** Open dumps move up from "unmanaged shallow" (0.4) and + controlled dumpsites move down from 0.7. IPCC 2006 Vol. 5 Ch. 3 Table 3.1 + splits unmanaged sites by waste depth — deeper than 5 m is "unmanaged deep" + (0.8), shallower is "unmanaged shallow" (0.4) — and prescribes 0.6 for a site + whose depth is unknown. SWEET already applied the 0.8 bump wherever a depth was + supplied, but fell back to 0.4 when it was not, treating "we do not know the + depth" as "we know it is shallow". In practice no dump carries a depth at all: + in the August 2026 Climate TRACE input table, depth is populated for 450 of + 4,635 sanitary landfills (MCF 1.0 regardless) and for 0 of 8,067 dumpsites and + 0 of 959 controlled dumpsites. "Controlled dumpsite" is not an IPCC category + and takes the same uncategorised value for the same reason: the IPCC rows it + could plausibly sit in span a wide range depending on how well a site is run, + which is exactly what our inputs do not record, so 0.7 was asserting a + management standard we have no evidence for. + **Model-output change:** MCF multiplies generation linearly, so modelled + methane generation rises 50% at every open dump and falls 14% at every + controlled dumpsite. Two consequences worth knowing: MCF no longer + distinguishes the two dump types, so a scenario that converts an open dump to a + controlled dump now changes emissions only through cover oxidation and gas + capture (which SWEET models separately); and the strategy of raising MCF by + reclassifying a site is gone from the DST. + ([#49](https://github.com/RMI/SWEET_python/pull/49)) +- **A supplied waste depth now selects the specific IPCC category, including + "unmanaged shallow".** The depth rule was one-directional — it raised MCF to + 0.8 above 5 m and otherwise fell through to the type default — so once the type + default became 0.6 there was no way to reach 0.4 even for a site known to be + shallow. A dump with a depth at or below 5 m now reads as 0.4, above 5 m as + 0.8, and only an absent depth (`None`/`NaN`) keeps the uncategorised 0.6. This + is reachable today on the two Climate TRACE site paths + (`City.site_only_estimate_trace`, `City.citysite_estimate_trace`, which read a + real `waste_depth`) and on `advanced_dst`, whose `depth` field is optional and + defaults to `None`. + It is deliberately **not** applied on the `/sdst` request paths + (`City.sdst_v1_5`, `City.implement_dst_changes_advanced`, + `City.advanced_baseline`), which pass the new `trust_shallow_depth=False`. Those + callers always send a number for depth — the DST form defaults it to 3 m and + `sdst_prepopulate` fabricates 3 m for any dumpsite — so "no answer" cannot be + told apart from "3 m" there, and a shallow reading would drop every default + scenario to 0.4 rather than only the genuinely shallow sites. Closing that gap + needs the caller to be able to send a null depth. + ([#49](https://github.com/RMI/SWEET_python/pull/49)) +- **Methane correction factors are defined once, in the new `SWEET_python.mcf` + module.** The table and the depth rule were previously copied into ten call + sites — four site-type-name tables (`City.sinar_city_and_site`, + `City.site_only_estimate`, `City.site_only_estimate_trace`, + `City.citysite_estimate_trace`), three landfill-type-index tables + (`City.sdst_v1_5`, `City.implement_dst_changes_advanced`, + `City.advanced_baseline`), `dst_common.MCF_BY_TYPE`, the dump conversion in `City.implement_dst_changes_simple_v1_5`, and the generic city dumpsite bucket - in `City._calculate_divs`. The depth > 5 m rule is untouched and still raises - MCF to 0.8 wherever a depth is supplied. **Model-output change:** methane - generation rises 50% at every open-dump site (MCF multiplies generation - linearly), so any run containing a dump produces higher emissions. - Two consequences worth knowing: a dump whose depth *is* supplied and is under - 5 m now also gets 0.6 rather than 0.4 (no such site exists in the Climate TRACE - inputs, but a DST user can enter one); and in the DST landfill-type tables, - where a controlled dump is 0.6, converting an open dump to a controlled dump no - longer raises MCF — the site-estimate tables keep a controlled dump at 0.7, so - the gradient survives there. + in `City._calculate_divs` — and had drifted apart. All ten now resolve through + `mcf.mcf_for_site(site_type_idx, depth)`. `dst_common` re-exports + `MCF_BY_TYPE`, `SITE_TYPE_NAMES`, `DEEP_SITE_DEPTH_M`, `DEEP_DUMP_MCF` and + `DEEP_MCF_DUMP_TYPES` so existing importers are unaffected. New coverage in + `tests/test_mcf.py`; MCF had none before. ([#49](https://github.com/RMI/SWEET_python/pull/49)) ## Fixed +- `City.sinar_city_and_site` tested `site_type in (1, 2)` against a site-type + *string* in its second per-landfill branch (the first branch correctly used + `site_type_idx`), so the deep-dump MCF bump could never fire there. The + hardcoded 3 m depth on that path meant the condition was already always false, + so no output changes; the defect would have surfaced the moment a real depth + reached it. Both branches now call `mcf.mcf_for_site`. +- `City.sinar_city_and_site` and `City.site_only_estimate` hardcoded `depth = 3`, + a placeholder standing in for "no depth available" that read as a claim the + site was shallow. Both now pass `None`, which is what they mean. No output + change: 3 m and `None` both resolve to the per-type value. +- `mcf.mcf_for_site` tolerates a `None` or `NaN` depth, so + `City.advanced_baseline` no longer raises `TypeError` on its own + `depth: float = None` default. - `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 diff --git a/changelog/README.md b/changelog/README.md index 994252f..156f0f8 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); `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); open dumps move to the IPCC uncategorised-SWDS MCF 0.6 (was unmanaged-shallow 0.4), since waste depth is never supplied for a dump (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); MCF consolidated into a new `SWEET_python.mcf` module and both dump types moved to the IPCC uncategorised-SWDS 0.6 (open dumps up from 0.4, controlled dumps down from 0.7), with a supplied depth now selecting the deep/shallow category (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 diff --git a/tests/test_mcf.py b/tests/test_mcf.py new file mode 100644 index 0000000..2abec55 --- /dev/null +++ b/tests/test_mcf.py @@ -0,0 +1,102 @@ +"""The methane correction factor table and its depth rule. + +MCF multiplies methane generation linearly, so these values move every emissions +number SWEET produces. They had no test coverage before, which is how the copies +scattered through city_params drifted apart. +""" + +import numpy as np +import pandas as pd +import pytest + +import SWEET_python.mcf as mcf +from SWEET_python import dst_common + + +LANDFILL, CONTROLLED_DUMP, OPEN_DUMP = 0, 1, 2 + + +def test_table_matches_ipcc_categories(): + assert mcf.MCF_BY_TYPE == [1.0, 0.6, 0.6] + assert mcf.MCF_BY_SITE_TYPE_NAME == { + "Sanitary Landfill": 1.0, + "Controlled Dumpsite": 0.6, + "Dumpsite": 0.6, + } + + +def test_both_dump_types_share_the_uncategorised_value(): + # "Controlled dumpsite" is not an IPCC category and our inputs say nothing + # about how well a site is run, so it takes the same uncategorised value as + # an open dump. Converting one to the other is deliberately an MCF no-op. + assert mcf.mcf_for_site(CONTROLLED_DUMP) == mcf.mcf_for_site(OPEN_DUMP) + + +@pytest.mark.parametrize("site_type", [CONTROLLED_DUMP, OPEN_DUMP]) +@pytest.mark.parametrize("unknown", [None, np.nan, pd.NA]) +def test_unknown_depth_is_uncategorised_not_shallow(site_type, unknown): + # The whole point of the uncategorised row: absent depth must not be read as + # a claim that the site is shallow. + assert mcf.mcf_for_site(site_type, unknown) == mcf.MCF_UNCATEGORISED + + +@pytest.mark.parametrize("site_type", [CONTROLLED_DUMP, OPEN_DUMP]) +def test_supplied_depth_selects_the_specific_ipcc_category(site_type): + assert mcf.mcf_for_site(site_type, 12.0) == mcf.MCF_UNMANAGED_DEEP + assert mcf.mcf_for_site(site_type, 2.0) == mcf.MCF_UNMANAGED_SHALLOW + # The threshold is strict, so exactly 5 m reads as shallow. + assert mcf.mcf_for_site(site_type, 5.0) == mcf.MCF_UNMANAGED_SHALLOW + assert mcf.mcf_for_site(site_type, 5.01) == mcf.MCF_UNMANAGED_DEEP + + +@pytest.mark.parametrize("depth", [None, np.nan, 0.5, 40.0]) +def test_engineered_landfill_ignores_depth(depth): + assert mcf.mcf_for_site(LANDFILL, depth) == mcf.MCF_MANAGED_ANAEROBIC + + +@pytest.mark.parametrize("depth", [0.0, 3.0, 5.0]) +def test_untrusted_shallow_depth_falls_back_to_uncategorised(depth): + # The DST request always carries a number for depth, so a shallow reading + # there would apply 0.4 to every default scenario rather than to the + # genuinely shallow sites. + assert ( + mcf.mcf_for_site(OPEN_DUMP, depth, trust_shallow_depth=False) + == mcf.MCF_UNCATEGORISED + ) + + +def test_untrusted_depth_still_takes_the_deep_bump(): + assert ( + mcf.mcf_for_site(OPEN_DUMP, 12.0, trust_shallow_depth=False) + == mcf.MCF_UNMANAGED_DEEP + ) + + +def test_numpy_integer_site_type_resolves(): + assert mcf.mcf_for_site(np.int64(OPEN_DUMP)) == mcf.MCF_UNCATEGORISED + + +def test_dst_common_reexports_stay_in_sync(): + # advanced_dst and the WasteMAP backend import these names from dst_common. + assert dst_common.MCF_BY_TYPE is mcf.MCF_BY_TYPE + assert dst_common.DEEP_SITE_DEPTH_M == mcf.DEEP_SITE_DEPTH_M + assert dst_common.DEEP_DUMP_MCF == mcf.MCF_UNMANAGED_DEEP + assert dst_common.DEEP_MCF_DUMP_TYPES == mcf.DEPTH_SENSITIVE_TYPES + + +def test_mcf_series_splices_at_implement_year(): + years = pd.Index(range(2020, 2026)) + series = dst_common.mcf_series(OPEN_DUMP, LANDFILL, 2023, years) + assert series.loc[2022] == mcf.MCF_UNCATEGORISED + assert series.loc[2023] == mcf.MCF_MANAGED_ANAEROBIC + assert series.loc[2025] == mcf.MCF_MANAGED_ANAEROBIC + + +def test_mcf_series_reads_a_supplied_shallow_depth(): + # adst's depth argument defaults to None, so a number there is a real answer + # and the shallow category applies. + years = pd.Index(range(2020, 2026)) + series = dst_common.mcf_series( + OPEN_DUMP, OPEN_DUMP, 2023, years, baseline_depth=2.0, scenario_depth=2.0 + ) + assert (series == mcf.MCF_UNMANAGED_SHALLOW).all() From 3caa834d0362f92a96b4c5800c0aab47bfa5bad5 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 25 Aug 2026 08:52:08 -0700 Subject: [PATCH 3/6] Depth reaches the city-level advanced DST, and no caller fabricates one Two follow-ons to the MCF work, both from the same principle: a depth is a claim about a site, so code with no depth to offer has to say "unknown" rather than pick a plausible number. City-level ADST gains CityLandfillSpec.depth, threaded into mcf_series exactly as the single-site endpoint does. It was the only DST path that could not express depth at all, which is why WasteMAP's advanced DST shipped a Deep/Shallow/Unknown control with nowhere to send it. Optional, so existing calls are unchanged. sdst_prepopulate stops deriving a depth from the site type - 100 m for a landfill or controlled dump, 3 m for a dumpsite. Nothing on that path ever knew a depth; it now returns None. Under the two-directional rule the old values were actively wrong, reading as "definitely deep" or "definitely shallow". With no caller left inventing a depth, mcf_for_site's trust_shallow_depth guard goes away and every path takes a supplied depth at face value: >5 m is 0.8, <=5 m is 0.4, absent is 0.6. Model-output change for /sdst on a site with no recorded depth: MCF 0.4 -> 0.6, so modelled generation rises 50%. Pairs with WasteMAP branches msw-ai-data-source-review (backend + SDST form) and adst-depth-reaches-the-model. Co-Authored-By: Claude Opus 5 --- SWEET_python/advanced_dst_city.py | 21 ++++++++++++-- SWEET_python/city_params.py | 47 +++++++++++-------------------- SWEET_python/mcf.py | 28 ++++++------------ changelog/2026-08.md | 41 ++++++++++++++++----------- tests/test_mcf.py | 21 ++++---------- 5 files changed, 73 insertions(+), 85 deletions(-) diff --git a/SWEET_python/advanced_dst_city.py b/SWEET_python/advanced_dst_city.py index a7b87a2..af06c87 100644 --- a/SWEET_python/advanced_dst_city.py +++ b/SWEET_python/advanced_dst_city.py @@ -53,6 +53,17 @@ class CityLandfillSpec(BaseModel): landfill_type: Variant[LandfillType] = Field( ..., description="Site type: 0 landfill, 1 controlled dump, 2 open dump." ) + depth: Optional[Variant[float]] = Field( + None, + description=( + "Site depth in metres. For a controlled/open dump (type 1 or 2) the " + "depth selects the IPCC unmanaged category: deeper than 5 m raises " + "MCF to 0.8, at or below 5 m lowers it to 0.4. Omit (the default) " + "when the depth is unknown, which keeps the IPCC uncategorised 0.6 " + "\u2014 an omitted depth is not read as shallow. Never applies to an " + "engineered landfill (type 0), which is 1.0 regardless." + ), + ) landfill_open_close: Variant[tuple[int, int]] = Field( ..., description="(open_year, close_year) of this site." ) @@ -374,8 +385,14 @@ def run_advanced_dst_city(request: AdvancedDSTCityRequest) -> dict[str, pd.DataF baseline_shares.append(share_base) scenario_shares.append(share_scen) - mcf_base = common.mcf_series(base_type, base_type, implement_year, years) - mcf_scen = common.mcf_series(base_type, scen_type, implement_year, years) + base_depth = common.variant_get(spec.depth, "baseline") + scen_depth = common.variant_get(spec.depth, "scenario") + mcf_base = common.mcf_series( + base_type, base_type, implement_year, years, base_depth, base_depth + ) + mcf_scen = common.mcf_series( + base_type, scen_type, implement_year, years, base_depth, scen_depth + ) ox_base = common.oxidation_series(base_type, base_type, gas_base, bio_base, implement_year, years) ox_scen = common.oxidation_series(base_type, scen_type, gas_scen, bio_scen, implement_year, years) baseline_ox.append(ox_base) diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 805dd94..a630d15 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -7596,11 +7596,8 @@ def implement_dst_changes_advanced( # Set up new landfills city_params_dict = self.update_cityparams_dict(scenario_parameters) - # MCF by landfill type index; see SWEET_python.mcf. This path passes - # trust_shallow_depth=False: the DST request always carries a number for - # depth (the form defaults to 3 m), so "no answer" cannot be told apart - # from "3 m" here, and reading a shallow depth as IPCC unmanaged-shallow - # would drop every default scenario to 0.4. The deep bump still applies. + # MCF by landfill type index; see SWEET_python.mcf. A depth of None + # means the DST caller had no answer, and keeps the uncategorised value. gas_capture_efficiencies = {} gas_capture_efficiencies["ameliorated"] = [0.5, 0.3, 0] gas_capture_efficiencies["not_ameliorated"] = [0.6, 0.45, 0] @@ -7620,11 +7617,9 @@ def implement_dst_changes_advanced( # Get MCF old_lf_type = new_landfill_types["baseline"][i] mcf["baseline"] = mcf_defaults.mcf_for_site( - old_lf_type, depths["baseline"][i], trust_shallow_depth=False - ) - mcf["scenario"] = mcf_defaults.mcf_for_site( - lf_type, depths["scenario"][i], trust_shallow_depth=False + old_lf_type, depths["baseline"][i] ) + mcf["scenario"] = mcf_defaults.mcf_for_site(lf_type, depths["scenario"][i]) # Handle baseline first if i >= len(new_gas_efficiency["baseline"]): @@ -8303,11 +8298,8 @@ def _apply_open_close_window( # Set up new landfills city_params_dict = self.update_cityparams_dict(scenario_parameters) - # MCF by landfill type index; see SWEET_python.mcf. This path passes - # trust_shallow_depth=False: the DST request always carries a number for - # depth (the form defaults to 3 m), so "no answer" cannot be told apart - # from "3 m" here, and reading a shallow depth as IPCC unmanaged-shallow - # would drop every default scenario to 0.4. The deep bump still applies. + # MCF by landfill type index; see SWEET_python.mcf. A depth of None + # means the DST caller had no answer, and keeps the uncategorised value. gas_capture_efficiencies = {} gas_capture_efficiencies["ameliorated"] = [0.5, 0.3, 0] gas_capture_efficiencies["not_ameliorated"] = [0.6, 0.45, 0] @@ -8330,12 +8322,8 @@ def _apply_open_close_window( # Get MCF old_lf_type = new_landfill_types["baseline"][0] - mcf["baseline"] = mcf_defaults.mcf_for_site( - old_lf_type, depths["baseline"][0], trust_shallow_depth=False - ) - mcf["scenario"] = mcf_defaults.mcf_for_site( - new_lf_type, depths["scenario"][0], trust_shallow_depth=False - ) + mcf["baseline"] = mcf_defaults.mcf_for_site(old_lf_type, depths["baseline"][0]) + mcf["scenario"] = mcf_defaults.mcf_for_site(new_lf_type, depths["scenario"][0]) # Handle baseline first if new_gas_efficiency["baseline"][0] == 0.0: @@ -8706,11 +8694,8 @@ def advanced_baseline( # Set up new landfills city_params_dict = self.update_cityparams_dict(scenario_parameters) - # MCF by landfill type index; see SWEET_python.mcf. This path passes - # trust_shallow_depth=False: the DST request always carries a number for - # depth (the form defaults to 3 m), so "no answer" cannot be told apart - # from "3 m" here, and reading a shallow depth as IPCC unmanaged-shallow - # would drop every default scenario to 0.4. The deep bump still applies. + # MCF by landfill type index; see SWEET_python.mcf. A depth of None + # means the DST caller had no answer, and keeps the uncategorised value. # mcfs['ameliorated'] = {} # mcf_options['not_ameliorated'] = {} # mcfs['ameliorated']['gas_capture'] = [0.18, 0, 0] @@ -8729,9 +8714,7 @@ def advanced_baseline( for i, lf_type in enumerate(new_landfill_types): # Make the MCF, oxidation, and efficiency vectors years = pd.Index(range(MODEL_START_YEAR, MODEL_END_YEAR + 1)) - mcf = mcf_defaults.mcf_for_site( - lf_type, depth, trust_shallow_depth=False - ) + mcf = mcf_defaults.mcf_for_site(lf_type, depth) # Handle no gas capture first if new_gas_efficiency[i] == 0: # mcf = mcf_options['not_ameliorated']['no_gas_capture'][lf_type] @@ -8957,13 +8940,15 @@ async def sdst_prepopulate( if site_type in ["Landfill", "Sanitary Landfill"]: site_type = 0 - depth = 100 elif site_type == "Controlled Dumpsite": site_type = 1 - depth = 100 else: site_type = 2 - depth = 3 + # This path resolves a site type from a location; it never learns a waste + # depth. None says so. It used to return 100 m for a landfill/controlled + # dump and 3 m for a dumpsite, which the MCF rule read as a firm claim + # that the site was deep or shallow (see SWEET_python.mcf). + depth = None # SQL query to get average precipitation and temperature using provided latitude and longitude QUERY_WEATHER = """ diff --git a/SWEET_python/mcf.py b/SWEET_python/mcf.py index 7c7b37e..9c81e99 100644 --- a/SWEET_python/mcf.py +++ b/SWEET_python/mcf.py @@ -48,6 +48,11 @@ the whole point of the uncategorised row. Depth never applies to an engineered landfill (index 0), which is already fully anaerobic at 1.0. +For that distinction to survive, a caller with no depth to offer must pass +``None`` rather than a plausible-looking number. Several used to substitute +3 m or 100 m, which read here as a firm claim that the site was shallow or +deep; none do any more, and none should be reintroduced. + Note that the deep threshold is applied as a strict ``>``, so a site recorded at exactly 5.0 m reads as shallow. IPCC words the category as ">= 5 m". The difference only matters for a site whose depth is recorded as exactly 5, and the @@ -90,26 +95,15 @@ } -def mcf_for_site( - site_type_idx: int, - depth: Optional[float] = None, - *, - trust_shallow_depth: bool = True, -) -> float: +def mcf_for_site(site_type_idx: int, depth: Optional[float] = None) -> float: """MCF for one site, given its type and (optionally) its waste depth. Args: site_type_idx: ``LandfillType`` value - 0 landfill, 1 controlled dump, 2 open dump. depth: Waste depth in metres, or ``None``/``NaN`` when it is unknown. - Unknown keeps the per-type value; it is not read as shallow. - trust_shallow_depth: Whether a supplied depth at or below - ``DEEP_SITE_DEPTH_M`` may be read as IPCC "unmanaged shallow" (0.4). - Pass ``False`` for a caller that cannot represent "unknown depth" - and therefore sends a placeholder number when it has no answer - - for such a caller a shallow reading would silently apply 0.4 to - every site rather than only to the genuinely shallow ones. The deep - bump still applies either way. + A supplied number is always taken at face value; unknown keeps the + uncategorised per-type value and is never read as shallow. Returns: The methane correction factor, between 0 and 1. @@ -118,8 +112,4 @@ def mcf_for_site( return MCF_BY_TYPE[site_type_idx] if depth is None or pd.isna(depth): return MCF_BY_TYPE[site_type_idx] - if depth > DEEP_SITE_DEPTH_M: - return MCF_UNMANAGED_DEEP - if trust_shallow_depth: - return MCF_UNMANAGED_SHALLOW - return MCF_BY_TYPE[site_type_idx] + return MCF_UNMANAGED_DEEP if depth > DEEP_SITE_DEPTH_M else MCF_UNMANAGED_SHALLOW diff --git a/changelog/2026-08.md b/changelog/2026-08.md index dac85db..04b06bc 100644 --- a/changelog/2026-08.md +++ b/changelog/2026-08.md @@ -31,23 +31,30 @@ reclassifying a site is gone from the DST. ([#49](https://github.com/RMI/SWEET_python/pull/49)) - **A supplied waste depth now selects the specific IPCC category, including - "unmanaged shallow".** The depth rule was one-directional — it raised MCF to - 0.8 above 5 m and otherwise fell through to the type default — so once the type - default became 0.6 there was no way to reach 0.4 even for a site known to be - shallow. A dump with a depth at or below 5 m now reads as 0.4, above 5 m as - 0.8, and only an absent depth (`None`/`NaN`) keeps the uncategorised 0.6. This - is reachable today on the two Climate TRACE site paths - (`City.site_only_estimate_trace`, `City.citysite_estimate_trace`, which read a - real `waste_depth`) and on `advanced_dst`, whose `depth` field is optional and - defaults to `None`. - It is deliberately **not** applied on the `/sdst` request paths - (`City.sdst_v1_5`, `City.implement_dst_changes_advanced`, - `City.advanced_baseline`), which pass the new `trust_shallow_depth=False`. Those - callers always send a number for depth — the DST form defaults it to 3 m and - `sdst_prepopulate` fabricates 3 m for any dumpsite — so "no answer" cannot be - told apart from "3 m" there, and a shallow reading would drop every default - scenario to 0.4 rather than only the genuinely shallow sites. Closing that gap - needs the caller to be able to send a null depth. + "unmanaged shallow", and no caller invents one any more.** The depth rule was + one-directional — it raised MCF to 0.8 above 5 m and otherwise fell through to + the type default — so once that default became 0.6 there was no way to reach + 0.4 even for a site known to be shallow. A dump with a depth at or below 5 m + now reads as 0.4, above 5 m as 0.8, and only an absent depth (`None`/`NaN`) + keeps the uncategorised 0.6. + That distinction only works if a caller with no depth says so, and several + substituted a plausible number instead. `City.sdst_prepopulate` returned 100 m + for a landfill or controlled dump and 3 m for a dumpsite, derived purely from + the site type; `City.sinar_city_and_site` and `City.site_only_estimate` + hardcoded 3 m. All now pass `None`. WasteMAP's paired branch removes the same + fabrication from its side (the DST form's `'3'` default and the prepopulate + endpoint's `3.0` fallback). + **Model-output change** for any `/sdst` run on a site with no recorded depth: + MCF goes 0.4 to 0.6, so modelled generation rises 50%. Runs that supply a real + depth are unaffected above 5 m and move 0.6 to 0.4 below it. + ([#49](https://github.com/RMI/SWEET_python/pull/49)) +- `CityLandfillSpec.depth` (`Optional[Variant[float]]`, metres): the city-level + advanced DST now accepts a per-landfill depth and threads it into + `dst_common.mcf_series`, matching `AdvancedDSTRequest.depth` on the single-site + endpoint. It was the only DST path with no way to express depth at all, which + is why WasteMAP's advanced DST had a Deep/Shallow/Unknown control that could + not be sent anywhere. Omitted by default, so existing city-adst calls are + unchanged. ([#49](https://github.com/RMI/SWEET_python/pull/49)) - **Methane correction factors are defined once, in the new `SWEET_python.mcf` module.** The table and the depth rule were previously copied into ten call diff --git a/tests/test_mcf.py b/tests/test_mcf.py index 2abec55..9a1e4bc 100644 --- a/tests/test_mcf.py +++ b/tests/test_mcf.py @@ -54,22 +54,11 @@ def test_engineered_landfill_ignores_depth(depth): assert mcf.mcf_for_site(LANDFILL, depth) == mcf.MCF_MANAGED_ANAEROBIC -@pytest.mark.parametrize("depth", [0.0, 3.0, 5.0]) -def test_untrusted_shallow_depth_falls_back_to_uncategorised(depth): - # The DST request always carries a number for depth, so a shallow reading - # there would apply 0.4 to every default scenario rather than to the - # genuinely shallow sites. - assert ( - mcf.mcf_for_site(OPEN_DUMP, depth, trust_shallow_depth=False) - == mcf.MCF_UNCATEGORISED - ) - - -def test_untrusted_depth_still_takes_the_deep_bump(): - assert ( - mcf.mcf_for_site(OPEN_DUMP, 12.0, trust_shallow_depth=False) - == mcf.MCF_UNMANAGED_DEEP - ) +@pytest.mark.parametrize("depth", [0.0, 0.5, 3.0, 5.0]) +def test_any_supplied_shallow_depth_is_taken_at_face_value(depth): + # No caller substitutes a placeholder number for a depth it does not know, + # so a number here is always a real answer. + assert mcf.mcf_for_site(OPEN_DUMP, depth) == mcf.MCF_UNMANAGED_SHALLOW def test_numpy_integer_site_type_resolves(): From 55bbf0a2a20597ef1c91623254073cafbcdd10c6 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Tue, 25 Aug 2026 08:53:54 -0700 Subject: [PATCH 4/6] Remove a dead 3 m depth placeholder in load_andre_params Assigned and never read. Harmless, but it is the last of the fabricated depths, and leaving it invites someone to wire it up as if it meant something. Co-Authored-By: Claude Opus 5 --- SWEET_python/city_params.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index a630d15..c6ca6b9 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -1086,9 +1086,6 @@ def load_andre_params(self, row, backfill=False): precip_zone = defaults_2019.get_precipitation_zone(precip) temperature = row["mean_yearly_temp_2000_2021"] - # depth - depth = 3 # m - # k values, which are decomposition rates # ks = defaults_2019.k_defaults[precip_zone] From c80cc529c375ed6f24310218440507a926782216 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Wed, 26 Aug 2026 06:43:44 -0700 Subject: [PATCH 5/6] changelog: name the TRACE-pipeline depth fabrication among the removed placeholders pipeline_runner.main filled an absent waste_depth with 3 on the frame handed to SWEET as the canonical row. Depth is NULL for all 9,026 dumps in the Climate TRACE input, so that placeholder alone would have held every dump at the shallow 0.4 and turned this entry's move to 0.6 into a no-op for dumpsites and a 0.7 -> 0.4 drop for controlled dumps. Fixed in RMI_Climate_TRACE_Waste_Methane on the paired branch (1e2a6d0). Co-Authored-By: Claude Opus 5 --- changelog/2026-08.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/changelog/2026-08.md b/changelog/2026-08.md index 04b06bc..cd2271c 100644 --- a/changelog/2026-08.md +++ b/changelog/2026-08.md @@ -43,7 +43,12 @@ the site type; `City.sinar_city_and_site` and `City.site_only_estimate` hardcoded 3 m. All now pass `None`. WasteMAP's paired branch removes the same fabrication from its side (the DST form's `'3'` default and the prepopulate - endpoint's `3.0` fallback). + endpoint's `3.0` fallback), and the Climate TRACE pipeline removes one more in + `pipeline_runner.main`, which filled an absent `waste_depth` with `3` on the + frame it hands SWEET as the canonical row. That one mattered most: depth is + NULL for all 9,026 dumps in the TRACE input, so without it every dump would + have modelled at 0.4 and the change below would have been a no-op for + dumpsites and a 0.7 -> 0.4 drop for controlled dumps. **Model-output change** for any `/sdst` run on a site with no recorded depth: MCF goes 0.4 to 0.6, so modelled generation rises 50%. Runs that supply a real depth are unaffected above 5 m and move 0.6 to 0.4 below it. From bf8749e723122cebd25e380838fde3e7facd056f Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Wed, 26 Aug 2026 08:48:02 -0700 Subject: [PATCH 6/6] Deposition starts in 1970, not 1990 The cutoff was a truncation of the decay tail, not a neutral choice. Methane emitted today comes from decades of accumulated stock, so zeroing deposition before the cutoff understates every site with a long landfilling history, worst in cold/dry climates where the IPCC k values are lowest and the tail longest. Russia deposited as much municipal waste before 1990 as it did 1990-2021. Measured on a Russian-profile depthless dumpsite with no reported open year: 2021 emissions rise 1.21x (1.31x in 2015, decaying to 1.16x by 2026 -- the restored stock is old, so the uplift decays, which is the expected physics). 1970 rather than 1950 because the WPP2024 population series backing the waste projection is credible per-year that far back while per-capita generation before ~1970 is not, and the residual tail before 1970 is small at every k in defaults_2019. _population_series_from_pop_data's bounds now default to the modeling window instead of literal years. Its all-or-nothing column guard means a pops_yearly.csv that starts later than MODEL_START_YEAR returns None for EVERY country and silently reverts a whole run to frozen-CAGR growth; tying the default to the constant turns that into a visible mismatch instead of a quiet regression. Requires the matching pops_yearly.csv (1970-2050) in blob before any run. Co-Authored-By: Claude Opus 5 --- SWEET_python/city_params.py | 18 +++++++++++++----- SWEET_python/constants.py | 21 ++++++++++++++++++++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index c6ca6b9..85fec97 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -105,13 +105,21 @@ def _build_oxidation_series(default_value, canonical_row, time_series_rows, year # Cities can have multiple sets of CityParameters, one for each scenario. # Sets of CityParameters can have one or more landfills, dumpsites, waste to energy, etc. # Even for modeling a single landfill, City and CityParameters classes need to be used. -def _population_series_from_pop_data(pop_data, iso3, start_year=1990, end_year=2050): +def _population_series_from_pop_data(pop_data, iso3, start_year=MODEL_START_YEAR, + end_year=MODEL_END_YEAR): """Extract the WPP2024 per-year population Series for ``iso3`` from ``pop_data``. - ``pop_data`` carries ``pop_1990``..``pop_2050`` columns when the yearly WPP - table is available (see helper_functions.load_population_data). Returns a - year-indexed Series, or ``None`` when the columns/country are absent so the - caller falls back to the frozen-CAGR ``growth_rate_*`` scalars. + ``pop_data`` carries ``pop_{MODEL_START_YEAR}``..``pop_{MODEL_END_YEAR}`` columns + when the yearly WPP table is available (see helper_functions.load_population_data). + Returns a year-indexed Series, or ``None`` when the columns/country are absent so + the caller falls back to the frozen-CAGR ``growth_rate_*`` scalars. + + The bounds default to the modeling window rather than to literals, because the + ``all(c in pop_data.columns)`` guard below is all-or-nothing: a pops_yearly.csv + that starts later than MODEL_START_YEAR returns None for EVERY country and + silently reverts the whole run to frozen-CAGR growth. Keeping the default tied to + the constant makes that a loud missing-column mismatch instead of a quiet + regression. """ if pop_data is None or iso3 is None: return None diff --git a/SWEET_python/constants.py b/SWEET_python/constants.py index d851780..f47b5d7 100644 --- a/SWEET_python/constants.py +++ b/SWEET_python/constants.py @@ -6,7 +6,26 @@ source data, but models treat deposition as starting in MODEL_START_YEAR. Export and display windows (Climate TRACE submissions, WasteMAP charts) are filters over this window, never separate modeling horizons. + +MODEL_START_YEAR moved 1990 -> 1970 on 2026-08-26. The cutoff is a truncation of +the decay tail, not a neutral choice: methane emitted today comes from decades of +accumulated stock, so zeroing deposition before the cutoff understates every site +with a long landfilling history -- most severely in cold/dry climates, where the +IPCC k values are lowest and the tail is longest. Measured on the 08_24_26 run, +Russia deposited as much municipal waste before 1990 as it did 1990-2021, and +restoring the earlier stock raises its national FOD by ~20%. 1970 is chosen over +1950 because the population series backing the waste projection (WPP2024, via +pops_yearly.csv) is credible per-year that far back while per-capita generation +before ~1970 is not, and because the residual tail before 1970 is small at every +k in defaults_2019. + +CHANGING THIS CONSTANT REQUIRES A MATCHING pops_yearly.csv. The waste series is +population-driven, and city_params._population_series_from_pop_data returns None +unless the table carries EVERY column from MODEL_START_YEAR onward -- which +silently drops every country back to the frozen-CAGR growth scalars. Regenerate +with diagnostic_scripts/generate_pops_yearly.py and upload to blob +static_data/pops_yearly.csv BEFORE the constant lands in a run. """ -MODEL_START_YEAR: int = 1990 +MODEL_START_YEAR: int = 1970 MODEL_END_YEAR: int = 2050