diff --git a/SWEET_python/city_params.py b/SWEET_python/city_params.py index 830bf8d..72944b0 100644 --- a/SWEET_python/city_params.py +++ b/SWEET_python/city_params.py @@ -30,6 +30,24 @@ import time +def _normalize_gas_capture_efficiency(raw, default: float = 0.6) -> float: + """Coerce a source gas-capture-efficiency value to a fraction in [0, 1]. + + The source column ``gas_capture_efficiency_percent`` is a percentage, e.g. + ``50`` -> ``0.50``; a value already given as a fraction (``<= 1``) is used + as-is. Missing / NaN / non-numeric -> ``default`` (the model default). + """ + try: + value = float(raw) + except (TypeError, ValueError): + return default + if pd.isna(value): + return default + if value > 1: + value = value / 100.0 + return min(max(value, 0.0), 1.0) + + 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. @@ -710,8 +728,30 @@ def load_csv_new(self, db: pd.DataFrame, scenario: int = 0, dst: bool = False) - if non_compostable_not_targeted_total.isna().all(): non_compostable_not_targeted_total = pd.Series(0, index=years) - gas_capture_efficiency = city_data["Methane Capture Efficiency (%)"].values[0] / 100 - gas_capture_efficiency = pd.Series(gas_capture_efficiency, index=years) + def _normalize_capture_efficiency(raw): + # "Methane Capture Efficiency (%)" is usually null, but a few rows + # carry a real value that may be a fraction (0.25) or a percentage + # (25). Return a fraction in [0, 1], or None when there's no usable + # value (the landfill then falls back to the model default). + try: + value = float(raw) + except (TypeError, ValueError): + return None + if pd.isna(value) or value < 0: + return None + if value > 1: # percentage form, e.g. 60 -> 0.60 + value = value / 100.0 + return min(value, 1.0) + + _measured_capture_eff = _normalize_capture_efficiency( + city_data["Methane Capture Efficiency (%)"].values[0] + ) + # None -> leave unset so the with-capture landfill fills the model default. + gas_capture_efficiency = ( + pd.Series(_measured_capture_eff, index=years) + if _measured_capture_eff is not None + else None + ) mef_compost = city_data["MEF: Compost"].values[0] @@ -1450,7 +1490,14 @@ def calculate_component_fractions( if non_compostable_not_targeted_total.isna().all(): non_compostable_not_targeted_total = pd.Series(0, index=years) - gas_capture_efficiency = pd.Series(0.6, index=years) + # Use the city's source-reported gas-capture efficiency when present + # (gas_capture_efficiency_percent, a %). Missing -> model default 0.6. + gas_capture_efficiency = pd.Series( + _normalize_gas_capture_efficiency( + row.get("gas_capture_efficiency_percent") + ), + index=years, + ) waste_mass = pd.Series(waste_mass, index=years) @@ -4212,6 +4259,9 @@ def _calculate_divs(self, advanced_baseline=False, advanced_dst=False) -> None: landfill_index=0, fraction_of_waste=city_parameters.split_fractions.landfill_w_capture, gas_capture=True, + # Use the city's measured capture efficiency when the source data + # provides one; None falls back to the model default in Landfill. + gas_capture_efficiency=city_parameters.gas_capture_efficiency, fraction_of_waste_vector=pd.Series(city_parameters.split_fractions.landfill_w_capture, index=years), ) landfill_wo_capture = Landfill( diff --git a/changelog/2026-07.md b/changelog/2026-07.md index 2b04f52..e3f0619 100644 --- a/changelog/2026-07.md +++ b/changelog/2026-07.md @@ -1,7 +1,24 @@ # SWEET_python Changelog — July 2026 -**Highlights:** All ten waste types are now eligible for combustion (incineration) diversion — `metal`, `glass`, and `other` were previously held out as non-combustible and routed only to recycling. Because the model computes methane only (no combustion CO₂), combustion simply diverts mass away from the landfill, so any combusted waste produces no methane. This is a model-output change: for a given incineration level, the combusted composition and the resulting net-of-diversion landfill stream shift. +**Highlights:** Two city-DST model-output changes this month. (1) All ten waste types are now eligible for combustion (incineration) diversion — `metal`, `glass`, and `other` were previously held out as non-combustible and routed only to recycling; because the model computes methane only (no combustion CO₂), combustion simply diverts mass away from the landfill, so any combusted waste produces no methane. (2) The city DST now uses each city's *reported* gas-capture efficiency instead of assuming 60% for every city. ## Changed + - Combustion (incineration) is now eligible for **all ten** waste types. Added `metal`, `glass`, and `other` to every combustion-eligibility set: the five live definitions in `city_params.py` (the `City.__init__` default plus the `load_csv_new`, `import_basics`, and two `import_basics_site` construction paths), the `params` template in `config.py`, and the `DEFAULT_ELIGIBILITY` mirror in `dst_allocation.py`. Previously combustion was restricted to seven types (food, green, wood, paper_cardboard, textiles, plastic, rubber), which modeled metal/glass/other as inert and recycling-only. Since the model is methane-only, combustion just removes mass from the landfill (combusted mass generates no methane), so this widens a mass-flow pathway rather than adding an emissions pathway. **Model-output change:** the per-type combustion composition now renormalizes over ten types (the existing types' shares drop and metal/glass/other pick up a share), and the leftover-combustible remainder available to the incineration slider grows to the full leftover after compost/anaerobic/recycling. ([#33](https://github.com/RMI/SWEET_python/pull/33)) - Documented that `dst_allocation.py`'s `spare_combustibles` two-tier cost degrades to a no-op under universal combustion eligibility: with no non-combustible types left, there is nothing to steer the other treatments onto and the combustion remainder (`total − compost − anaerobic − recycling`) no longer depends on the allocation. The mechanism is retained for the general case. Verified that feasibility and allocation are identical with the penalty on vs. off. ([#33](https://github.com/RMI/SWEET_python/pull/33)) +- **City DST reads the source gas-capture efficiency.** `City.load_andre_params` + (the builder the map-data pipeline uses) previously hardcoded the with-capture + landfill's gas-capture efficiency to `0.6` for every city, discarding the + `gas_capture_efficiency_percent` value the pipeline already selects from the + database. It now reads that source value — a percentage, e.g. `50` → `0.50` — + and falls back to the `0.6` model default only when the source is missing. + The value flows through `_calculate_divs` into the with-capture landfill and + out to `cities_for_map_*.csv`'s `Methane Capture Efficiency (%)` column (which + was a flat `60` before). Adds a DB-free normalizer helper + (`_normalize_gas_capture_efficiency`, handling percentage-vs-fraction form and + NaN/non-numeric input) and regression tests + (`tests/test_gas_capture_efficiency.py`). + + **Model-output change:** cities whose source data reports a real efficiency + (currently a small number — most rows are null) will see their landfill-methane + estimates shift. Not a breaking change; downstream contracts are unchanged. ([#31](https://github.com/RMI/SWEET_python/pull/31)) diff --git a/changelog/README.md b/changelog/README.md index 0c62df3..657dc74 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-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-07](2026-07.md) — All ten waste types eligible for combustion (metal/glass/other added); city DST reads the source gas-capture efficiency instead of hardcoding 60% (model-output changes) - [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 - [2026-04](2026-04.md) — SDST baseline/scenario oxidation hardening against pandas dtype bugs, new PM2.5/PM10 particulate emissions from flared methane diff --git a/tests/test_gas_capture_efficiency.py b/tests/test_gas_capture_efficiency.py new file mode 100644 index 0000000..a99a32b --- /dev/null +++ b/tests/test_gas_capture_efficiency.py @@ -0,0 +1,45 @@ +"""Regression tests for reading the source gas-capture efficiency in the +city DST pipeline path (`City.load_andre_params`). + +Change: `load_andre_params` previously hardcoded the landfill gas-capture +efficiency to 0.6 for every city, discarding the source +`gas_capture_efficiency_percent` value that the map-data pipeline already +selects from the database. It now reads that source value (a percentage, e.g. +50 -> 0.50) via `_normalize_gas_capture_efficiency`, falling back to the 0.6 +model default when the source is missing / NaN / non-numeric. The value flows +through `_calculate_divs` into the with-capture landfill and out to +`cities_for_map_*.csv`'s "Methane Capture Efficiency (%)" column. + +In the live DB this column is almost always null (one city currently reports a +real 50%). These unit tests pin the normalizer's behaviour and are DB-free. +""" + +import pytest + +from SWEET_python.city_params import _normalize_gas_capture_efficiency as normalize + + +@pytest.mark.parametrize( + "raw, expected", + [ + (50, 0.50), # the real source value currently in the DB (percent form) + (50.0, 0.50), + ("50", 0.50), # numeric string (some DB drivers return object dtype) + (25, 0.25), # another percentage + (0.5, 0.50), # already a fraction -> used as-is + (0, 0.0), # an explicit zero is a real reported value + (150, 1.0), # implausible high percent -> clamped to 1.0 + ], +) +def test_source_values_are_normalized(raw, expected): + assert normalize(raw) == pytest.approx(expected) + + +@pytest.mark.parametrize("raw", [None, float("nan"), "", "n/a", "unknown"]) +def test_missing_or_nonnumeric_falls_back_to_default(raw): + # Missing / NaN / non-numeric -> the 0.6 model default (the common case). + assert normalize(raw) == pytest.approx(0.6) + + +def test_custom_default_is_respected(): + assert normalize(None, default=0.45) == pytest.approx(0.45)