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/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 9fd70cc..85fec97 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 @@ -104,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 @@ -1085,9 +1094,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] @@ -1802,11 +1808,8 @@ def sinar_city_and_site(self, row, linker, for_trace=False): "Controlled Dumpsite": 1, "Dumpsite": 2, } - mcf_options = { - "Sanitary Landfill": 1, - "Controlled Dumpsite": 0.7, - "Dumpsite": 0.4, - } + # 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, @@ -1824,7 +1827,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 = {} @@ -1867,10 +1872,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] @@ -1955,10 +1957,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] @@ -2241,11 +2240,6 @@ def site_only_estimate(self, row=None, pop_data=None): "Controlled Dumpsite": 1, "Dumpsite": 2, } - mcf_options = { - "Sanitary Landfill": 1, - "Controlled Dumpsite": 0.7, - "Dumpsite": 0.4, - } ox_options = { "ox_nocap": { "Sanitary Landfill": 0.1, @@ -2263,7 +2257,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 [ @@ -2289,10 +2285,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 @@ -2468,11 +2461,6 @@ def site_only_estimate_trace(self, canonical_row=None, time_series_rows=None, po "Controlled Dumpsite": 1, "Dumpsite": 2, } - mcf_options = { - "Sanitary Landfill": 1, - "Controlled Dumpsite": 0.7, - "Dumpsite": 0.4, - } ox_options = { "ox_nocap": { "Sanitary Landfill": 0.1, @@ -2584,10 +2572,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': @@ -2792,11 +2777,6 @@ def citysite_estimate_trace(self, canonical_row=None, time_series_rows=None, cit "Controlled Dumpsite": 1, "Dumpsite": 2, } - mcf_options = { - "Sanitary Landfill": 1, - "Controlled Dumpsite": 0.7, - "Dumpsite": 0.4, - } ox_options = { "ox_nocap": { "Sanitary Landfill": 0.1, @@ -2868,10 +2848,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': @@ -4336,7 +4313,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), + # 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, @@ -7092,10 +7071,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.4 skip_ox = True if move_gas: @@ -7618,9 +7601,8 @@ 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_options = [1, 0.6, 0.4] + # 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] @@ -7639,14 +7621,10 @@ 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] + ) + mcf["scenario"] = mcf_defaults.mcf_for_site(lf_type, depths["scenario"][i]) # Handle baseline first if i >= len(new_gas_efficiency["baseline"]): @@ -8325,9 +8303,8 @@ 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_options = [1, 0.6, 0.4] + # 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] @@ -8350,14 +8327,8 @@ 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]) + mcf["scenario"] = mcf_defaults.mcf_for_site(new_lf_type, depths["scenario"][0]) # Handle baseline first if new_gas_efficiency["baseline"][0] == 0.0: @@ -8728,9 +8699,8 @@ 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_options = [1, 0.6, 0.4] + # 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] @@ -8749,9 +8719,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_options[lf_type] - if (depth > 5) and (lf_type in (1, 2)): - mcf = 0.8 + 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] @@ -8977,13 +8945,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/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 diff --git a/SWEET_python/dst_common.py b/SWEET_python/dst_common.py index 2cfd623..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,18 +56,15 @@ "textiles", ] -# 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] -SITE_TYPE_NAMES: List[str] = ["landfill", "controlled_dumpsite", "dumpsite"] +# 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 -# 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 +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} @@ -263,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( @@ -284,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..9c81e99 --- /dev/null +++ b/SWEET_python/mcf.py @@ -0,0 +1,115 @@ +"""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. + +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 +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) -> 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. + 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. + """ + 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] + 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 ff86727..cd2271c 100644 --- a/changelog/2026-08.md +++ b/changelog/2026-08.md @@ -1,12 +1,95 @@ # 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, 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 +- **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", 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), 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. + ([#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 + 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` — 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 e8fb8ac..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) +- [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..9a1e4bc --- /dev/null +++ b/tests/test_mcf.py @@ -0,0 +1,91 @@ +"""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, 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(): + 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()