From 23f9177f9ac29683ae37b1a6bbf343acb2676898 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Thu, 9 Jul 2026 17:45:05 -0700 Subject: [PATCH 1/4] Modeled landfills use measured site oxidation when available Previously every modeled landfill got a type/gas-capture default oxidation (sanitary 0.10 no-capture / 0.22 with capture; controlled dump 0.05/0.10; dumpsite 0). The pipeline input query carries a per-site `oxidation` column (populated for ~19% of rows from the standardized source table, NULL for the GPW/OSM area-matched branch) that was never read. site_only_estimate_trace and citysite_estimate_trace now prefer that input value over the default via a new _build_oxidation_series helper: - site-wide mean of non-null input values as the full-series baseline, then a per-year overwrite (mean of conflicting same-year rows) keyed by reported_emissions_year where present -- mirrors the existing gas_collection_efficiency handling; - no input -> the prior type/gas-capture default, broadcast across all years; - values used as-is (no clamping); the dead 0.25 biocover clamp does not apply. model-output-change: emissions shift for the ~19% of sites with a measured oxidation. Not a breaking change (schemas/contracts unchanged). Paired with RMI_Climate_TRACE_Waste_Methane ers-update. Co-Authored-By: Claude Opus 4.8 --- SWEET_python/city_params.py | 70 +++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index d4e2ddf..c1a0f8a 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -30,6 +30,64 @@ import time +def _build_oxidation_series(default_value, canonical_row, time_series_rows, years_range): + """Per-year oxidation factor for one modeled landfill, preferring per-site input + oxidation over the type/gas-capture default. + + A site can have several input rows from independent sources. Oxidation carries no + year of its own in the source data -- the value that varies row-to-row is the + *emissions* year (``reported_emissions_year``), not an oxidation year -- so we use + the site-wide mean of the available input oxidation values as a constant baseline + across all model years, then overwrite individual years with that year's value + (mean of any conflicting same-year rows) where an emissions year is present. + + When the site has no usable input oxidation, fall back to ``default_value`` (the + type/gas-capture default) broadcast across all years -- i.e. the prior behaviour. + Input values are used as-is (no clamping); e.g. a measured 0.35 passes through. + + ``time_series_rows`` is a DataFrame for multi-row sites and a Series for single-row + sites; ``canonical_row`` is the single deduped row. Either may carry ``oxidation``. + """ + years_index = pd.Index(years_range) + series = pd.Series(float(default_value), index=years_index) + + # Assemble the site's input rows as a frame so single-row (Series) and multi-row + # (DataFrame) sites are handled uniformly. Prefer the multi-row frame -- it holds + # every source record -- and fall back to the single canonical row. + if isinstance(time_series_rows, pd.DataFrame): + frame = time_series_rows + elif isinstance(canonical_row, pd.DataFrame): + frame = canonical_row + elif isinstance(canonical_row, pd.Series): + frame = canonical_row.to_frame().T + else: + frame = None + + if frame is None or 'oxidation' not in frame.columns: + return series + + ox = pd.to_numeric(frame['oxidation'], errors='coerce') + if not ox.notna().any(): + return series # no input oxidation -> keep the type/gas-capture default + + # 1) Site-wide mean as the full-series baseline. + series[:] = float(ox[ox.notna()].mean()) + + # 2) Overwrite individual years where an emissions year ties a value to a year. + if 'reported_emissions_year' in frame.columns: + yr = pd.to_numeric(frame['reported_emissions_year'], errors='coerce') + mask = ox.notna() & yr.notna() + if mask.any(): + per_year = pd.Series(ox[mask].to_numpy(), index=yr[mask].astype(int).to_numpy()) + per_year = per_year.groupby(level=0).mean() + lo, hi = int(years_index.min()), int(years_index.max()) + per_year = per_year[(per_year.index >= lo) & (per_year.index <= hi)] + if not per_year.empty: + series.loc[per_year.index] = per_year.to_numpy() + + return series + + # The way this model is set up is based on the unit of a City, corresponding to the City class. # 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. @@ -2486,6 +2544,9 @@ def site_only_estimate_trace(self, canonical_row=None, time_series_rows=None, po ) fraction_of_waste_vector.loc[open_date:close_date-1] = 1.0 id = int(canonical_row['asset_identifier']) + oxidation_series = _build_oxidation_series( + oxidation_value, canonical_row, time_series_rows, self.years_range + ) new_landfill = Landfill( open_date=open_date, close_date=close_date, @@ -2506,7 +2567,7 @@ def site_only_estimate_trace(self, canonical_row=None, time_series_rows=None, po advanced=True, latlon=(self.latitude, self.longitude), ks=baseline.ks, - oxidation_factor=pd.Series(oxidation_value, index=self.years_range), + oxidation_factor=oxidation_series, rmi_id=id, ) baseline.landfills.append(new_landfill) @@ -2755,6 +2816,9 @@ def citysite_estimate_trace(self, canonical_row=None, time_series_rows=None, cit close_date = int(close_date) id = int(canonical_row['asset_identifier']) + oxidation_series = _build_oxidation_series( + oxidation_value, canonical_row, time_series_rows, self.years_range + ) baseline._singapore_k(advanced_baseline=True) if (citysite_rows is None) or (isinstance(citysite_rows, pd.Series)): fraction_of_waste_vector = pd.Series( @@ -2781,7 +2845,7 @@ def citysite_estimate_trace(self, canonical_row=None, time_series_rows=None, cit advanced=True, latlon=(self.latitude, self.longitude), ks=baseline.ks, - oxidation_factor=pd.Series(oxidation_value, index=self.years_range), + oxidation_factor=oxidation_series, rmi_id=id, city_id=citysite_rows['city_id'] ) @@ -2876,7 +2940,7 @@ def citysite_estimate_trace(self, canonical_row=None, time_series_rows=None, cit advanced=True, latlon=(self.latitude, self.longitude), ks=baseline.ks, - oxidation_factor=pd.Series(oxidation_value, index=self.years_range), + oxidation_factor=oxidation_series, rmi_id=id, city_id=city_id, ) From e88a3d9914e8d3aa93dcad7da945fc34746e6f52 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Thu, 9 Jul 2026 18:30:00 -0700 Subject: [PATCH 2/4] Set baseline flaring efficiency explicitly to DEFAULT_FLARE_EFFICIENCY The modeled baseline previously left each landfill's `flaring` unset, so it fell to model_v2's internal default (0.98 in the monthly path used by TRACE). There is no per-site flaring-efficiency source column (the source has only flared volumes and a has_flaring boolean), so no per-site value can be read. site_only_estimate_trace and citysite_estimate_trace now set flaring explicitly to dst_common.DEFAULT_FLARE_EFFICIENCY (0.98) on every TRACE landfill, so the value no longer depends on model internals. A function-local import is used because dst_common imports city_params (a top-level import would be circular). No TRACE output change: baseline flaring was already effectively 0.98 via the monthly default, so this is numerically identical. Mitigation still raises it to 0.98/0.99 for the few improve-GCCS strategies via _gccs_flaring's floor (max/clip). Deliberately NOT touched: model_v2's default handling. The annual estimate_emissions2 defaults unset flaring to 1.0 vs the monthly estimate_emissions_monthly's 0.98 -- a real inconsistency, but TRACE/mitigation only use the monthly path, and changing the annual default would shift WasteMAP/DST output. Left as a separate, optional cleanup. Paired with RMI_Climate_TRACE_Waste_Methane ers-update. Co-Authored-By: Claude Opus 4.8 --- SWEET_python/city_params.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index c1a0f8a..6834164 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -2547,6 +2547,12 @@ def site_only_estimate_trace(self, canonical_row=None, time_series_rows=None, po oxidation_series = _build_oxidation_series( oxidation_value, canonical_row, time_series_rows, self.years_range ) + # Baseline flaring destruction efficiency, set explicitly to the canonical default + # (was unset -> fell to model_v2's internal default). No per-site source exists; + # mitigation raises it to 0.98/0.99 via _gccs_flaring (max/clip). Local import + # avoids the dst_common <-> city_params import cycle. + from SWEET_python.dst_common import DEFAULT_FLARE_EFFICIENCY + flaring_series = pd.Series(DEFAULT_FLARE_EFFICIENCY, index=self.years_range) new_landfill = Landfill( open_date=open_date, close_date=close_date, @@ -2561,7 +2567,7 @@ def site_only_estimate_trace(self, canonical_row=None, time_series_rows=None, po scenario=0, new_baseline=True, gas_capture_efficiency=gas_capture_efficiency, - # flaring=pd.Series(flaring, index=year_range), + flaring=flaring_series, # leachate_circulate=leachate_circulate[i], fraction_of_waste_vector=fraction_of_waste_vector, advanced=True, @@ -2819,6 +2825,11 @@ def citysite_estimate_trace(self, canonical_row=None, time_series_rows=None, cit oxidation_series = _build_oxidation_series( oxidation_value, canonical_row, time_series_rows, self.years_range ) + # Baseline flaring destruction efficiency, set explicitly to the canonical default + # (see site_only_estimate_trace). Applies to both the single- and multi-city + # landfill constructors below. Local import avoids the dst_common import cycle. + from SWEET_python.dst_common import DEFAULT_FLARE_EFFICIENCY + flaring_series = pd.Series(DEFAULT_FLARE_EFFICIENCY, index=self.years_range) baseline._singapore_k(advanced_baseline=True) if (citysite_rows is None) or (isinstance(citysite_rows, pd.Series)): fraction_of_waste_vector = pd.Series( @@ -2839,7 +2850,7 @@ def citysite_estimate_trace(self, canonical_row=None, time_series_rows=None, cit scenario=0, new_baseline=True, gas_capture_efficiency=gas_capture_efficiency, - # flaring=pd.Series(flaring, index=year_range), + flaring=flaring_series, # leachate_circulate=leachate_circulate[i], fraction_of_waste_vector=fraction_of_waste_vector, advanced=True, @@ -2934,7 +2945,7 @@ def citysite_estimate_trace(self, canonical_row=None, time_series_rows=None, cit scenario=0, new_baseline=True, gas_capture_efficiency=gas_capture_efficiency, - # flaring=pd.Series(flaring, index=year_range), + flaring=flaring_series, # leachate_circulate=leachate_circulate[i], fraction_of_waste_vector=fraction_of_waste_df[city_id], advanced=True, From de1ef58d263fa3c273583a3ef95aeebe1f58bfc0 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Mon, 13 Jul 2026 12:23:16 -0700 Subject: [PATCH 3/4] Document intended cities/sites oxidation fallback in _build_oxidation_series The default-broadcast fallback when the input frame lacks a per-year 'oxidation' column is deliberate: it supports older callers (city DST / make_cities_table) that predate per-year oxidation. The Climate TRACE sites and cities pipelines both SELECT oxidation into their frames, so measured oxidation is used there. Comment only; no behavior change. Co-Authored-By: Claude Opus 4.8 --- SWEET_python/city_params.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 6834164..85a7aeb 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -63,6 +63,14 @@ def _build_oxidation_series(default_value, canonical_row, time_series_rows, year else: frame = None + # This default fallback is intentional and load-bearing for callers whose input + # frame carries no per-year `oxidation` column -- e.g. the older city DST / + # make_cities_table path, which predates per-year oxidation and supplies a single + # baseline value elsewhere. The Climate TRACE sites AND cities pipelines both SELECT + # `oxidation` into their multi-row frames (landfill_table_ops), so measured oxidation + # IS used there; this only defaults when the column is genuinely absent. (Edge case, + # not produced by any current caller: oxidation present only on canonical_row while a + # DataFrame time_series_rows lacks it would be skipped here.) if frame is None or 'oxidation' not in frame.columns: return series From 7e7d1ef275a2e54d7599eaa7e169807f9af5b420 Mon Sep 17 00:00:00 2001 From: Hugh Runyan Date: Mon, 13 Jul 2026 12:46:36 -0700 Subject: [PATCH 4/4] Honor single-row Series time_series_rows in _build_oxidation_series Copilot #34: the docstring says time_series_rows is a Series for single-row sites, but the branch chain skipped it and fell to canonical_row. Add the Series branch so a single-row site's own time_series_rows oxidation is used (matches the documented contract). Harmless in the Climate TRACE pipelines (multi-row DataFrame path, or canonical_row carries the same value), but removes the code/docstring mismatch. Co-Authored-By: Claude Opus 4.8 --- SWEET_python/city_params.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 85a7aeb..273794f 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -53,9 +53,12 @@ def _build_oxidation_series(default_value, canonical_row, time_series_rows, year # Assemble the site's input rows as a frame so single-row (Series) and multi-row # (DataFrame) sites are handled uniformly. Prefer the multi-row frame -- it holds - # every source record -- and fall back to the single canonical row. + # every source record -- then the single-row time_series_rows (documented Series + # case), and finally the single canonical row. if isinstance(time_series_rows, pd.DataFrame): frame = time_series_rows + elif isinstance(time_series_rows, pd.Series): + frame = time_series_rows.to_frame().T elif isinstance(canonical_row, pd.DataFrame): frame = canonical_row elif isinstance(canonical_row, pd.Series):