Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions SWEET_python/city_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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(
Expand Down
19 changes: 18 additions & 1 deletion changelog/2026-07.md
Original file line number Diff line number Diff line change
@@ -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))
2 changes: 1 addition & 1 deletion changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions tests/test_gas_capture_efficiency.py
Original file line number Diff line number Diff line change
@@ -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)