Source code for aquakin.plant.ghg

"""Greenhouse-gas (GHG) accounting for a plant solution.

A wastewater treatment plant's carbon footprint has three contributions, all
expressed here as a CO₂-equivalent mass flow (kg CO₂e/d) via 100-year global
warming potentials (GWPs):

* **Direct N₂O** -- nitrous oxide stripped from the aerated reactors. N₂O is a
  potent greenhouse gas (GWP ~273) and the dominant *direct* footprint of an
  activated-sludge plant. It is produced by the nitrifier pathways resolved in
  the N₂O kinetic models (a tracked dissolved ``SN2O`` state); the stripping
  to atmosphere is a reactor concern, computed here from the dissolved
  concentration and the aeration intensity (:func:`stripped_n2o`).
* **Energy CO₂e** -- the indirect footprint of the electricity the plant draws
  (aeration + pumping + mixing), via a grid carbon-intensity factor.
* **Methane** -- a *credit* when the digester biogas offsets fossil energy, and
  a *fugitive emission* for the fraction of CH₄ that leaks unburned (GWP ~27 for
  biogenic methane).

The kernels here are generic (they take energy / mass numbers); the BSM2
plant-coupled entry points (:func:`aquakin.plant.bsm.direct_n2o_emission`,
which reconstructs the stripped N₂O from a solved plant) live alongside
``evaluate_bsm2`` and feed :func:`carbon_footprint`.

The GWP and grid-factor defaults are documented, representative values, not
universal constants -- override them for a specific accounting standard
(IPCC assessment report) or grid.
"""

from __future__ import annotations

import textwrap
from dataclasses import dataclass

import jax.numpy as jnp

from aquakin.plant.metrics import time_average

# IPCC AR6 (2021) 100-year global warming potentials (kg CO₂e / kg gas).
# N₂O and *biogenic* CH₄ (the wastewater case -- carbon of recent biological
# origin); fossil CH₄ is slightly higher (~29.8). Override per accounting
# standard (AR5: N₂O 265, CH₄ 28; AR4: N₂O 298, CH₄ 25).
GWP_N2O = 273.0
GWP_CH4 = 27.0

# Molar-mass ratio N₂O / N₂ = 44 / 28: converts an N₂O-N mass (the dissolved
# state is referenced to its nitrogen) to an N₂O gas mass.
_N2O_PER_N = 44.0 / 28.0

# Representative grid carbon intensity (kg CO₂e / kWh). Highly region- and
# year-dependent; supply the actual factor for a real accounting.
DEFAULT_GRID_FACTOR = 0.4


# The three scalar CO₂e converters below are **post-solve, eager-only** reporting
# helpers: they take concrete per-day mass/energy flows already reduced from a
# solution and coerce with ``float(...)``, so they are not differentiable and not
# meant for the traced hot path (unlike the ``jnp``-based ``stripped_n2o`` history
# integral). Feed them the eager flows from an evaluation; do not call them under
# ``jax.jit`` / ``jax.grad``.


[docs] def co2e_from_energy(energy_kwh_per_d: float, grid_factor: float) -> float: """Indirect CO₂e from electricity use (kg CO₂e/d). ``= energy × grid_factor``, the energy draw (kWh/d) times the grid carbon intensity (kg CO₂e/kWh). Post-solve, eager-only (see the note above). """ return float(energy_kwh_per_d) * float(grid_factor)
[docs] def n2o_n_to_co2e(n2o_n_kg_per_d: float, gwp: float = GWP_N2O) -> float: """CO₂e of an N₂O emission given as an **N₂O-N** mass flow (kg CO₂e/d). ``= n2o_n × (44/28) × GWP`` -- the N₂O-N mass is first converted to N₂O gas mass (molar-mass ratio 44/28), then weighted by the N₂O GWP. """ return float(n2o_n_kg_per_d) * _N2O_PER_N * float(gwp)
[docs] def methane_to_co2e(ch4_kg_per_d: float, gwp: float = GWP_CH4) -> float: """CO₂e of a methane mass flow (kg CO₂e/d). ``= ch4 × GWP``.""" return float(ch4_kg_per_d) * float(gwp)
[docs] def stripped_n2o( t: jnp.ndarray, kla_o2_history: jnp.ndarray, s_n2o_history: jnp.ndarray, volumes: jnp.ndarray, *, kla_ratio: float = 1.0, s_n2o_sat: float = 0.0, ) -> float: """Time-averaged N₂O stripped from the aerated reactors (kg N₂O-N/d). Each reactor strips dissolved N₂O at the aeration mass-transfer rate:: G_i(t) = kLa_{N2O,i} × (S_{N2O,i} − S*_{N2O}) × V_i summed over reactors and time-averaged over the window. The N₂O transfer coefficient is taken as the oxygen ``kLa`` scaled by ``kla_ratio`` (the diffusivity ratio D_{N2O}/D_{O2} ≈ 1), so an *unaerated* tank (``kLa = 0``) strips nothing -- only the aerated tanks emit. The atmospheric saturation ``S*_{N2O}`` is ~0. Parameters ---------- t : (n_t,) save times (days). kla_o2_history : (n_t, n_reactors) oxygen ``kLa`` per reactor (1/d). s_n2o_history : (n_t, n_reactors) dissolved N₂O-N concentration (g N/m³). volumes : (n_reactors,) reactor liquid volumes (m³). kla_ratio : float N₂O-to-O₂ mass-transfer-coefficient ratio (default 1.0). s_n2o_sat : float Atmospheric N₂O saturation concentration (g N/m³, default 0). Returns ------- float Stripped N₂O-N mass flow (kg N/d), time-averaged. """ kla = jnp.asarray(kla_o2_history) * float(kla_ratio) s = jnp.asarray(s_n2o_history) - float(s_n2o_sat) volumes = jnp.asarray(volumes) # g N/d summed over reactors, then g→kg. flux = jnp.sum(kla * s * volumes[None, :], axis=1) * 1e-3 # (n_t,) kg N/d return float(time_average(flux, t))
[docs] @dataclass class CarbonFootprint: """A plant's greenhouse-gas footprint as a CO₂-equivalent mass flow. ``str(fp)`` / :meth:`report` give a labeled breakdown; the raw fields stay available for programmatic use and :meth:`kpis` exposes the headline numbers for a scenario KPI table. Attributes ---------- direct_n2o : float Direct N₂O emission (kg N₂O-N/d) stripped from the aerated reactors. energy_kwh : float Total electricity draw (kWh/d) attributed to the footprint (aeration + pumping + mixing). ch4_fugitive : float Fugitive (unburned, leaked) methane (kg CH₄/d). biogas_recovered_kwh : float Electricity the recovered biogas displaces (kWh/d) -- an avoided-emission credit, valued at the grid factor. grid_factor : float Grid carbon intensity used (kg CO₂e/kWh). gwp_n2o, gwp_ch4 : float The GWPs used. direct_n2o_co2e, energy_co2e, ch4_fugitive_co2e, biogas_credit_co2e : float The CO₂e contributions (kg CO₂e/d); ``biogas_credit_co2e`` is the avoided emission (subtracted from the total). total_co2e : float Net carbon footprint (kg CO₂e/d). """ direct_n2o: float energy_kwh: float ch4_fugitive: float biogas_recovered_kwh: float grid_factor: float gwp_n2o: float gwp_ch4: float direct_n2o_co2e: float energy_co2e: float ch4_fugitive_co2e: float biogas_credit_co2e: float total_co2e: float note: str = ( "GHG footprint as CO2e/d (IPCC AR6 GWPs: N2O 273, biogenic CH4 27). " "Direct N2O is stripped from the aerated reactors; energy CO2e uses the " "supplied grid carbon intensity; the biogas credit is the avoided grid " "emission of the recovered biogas energy. Defaults are representative -- " "override the GWPs / grid factor for a specific standard or grid." )
[docs] def kpis(self) -> dict: """Headline GHG KPIs (kg CO₂e/d unless noted) for a comparison table.""" return { "GHG total (kgCO2e/d)": self.total_co2e, "N2O direct (kgCO2e/d)": self.direct_n2o_co2e, "Energy (kgCO2e/d)": self.energy_co2e, "CH4 fugitive (kgCO2e/d)": self.ch4_fugitive_co2e, "Biogas credit (kgCO2e/d)": -self.biogas_credit_co2e, }
def report(self) -> str: title = "Carbon footprint (CO2e)" terms = [ ("Direct N2O", self.direct_n2o, "kg N/d", self.direct_n2o_co2e), ("Energy (grid)", self.energy_kwh, "kWh/d", self.energy_co2e), ("CH4 fugitive", self.ch4_fugitive, "kg CH4/d", self.ch4_fugitive_co2e), ("Biogas credit", self.biogas_recovered_kwh, "kWh/d", -self.biogas_credit_co2e), ] width = max(len(lbl) for lbl, *_ in terms) lines = [ title, "=" * len(title), f" Net footprint = {self.total_co2e:14.1f} kg CO2e/d (lower is better)", "", f" {'source':<{width}} {'amount':>12} {'unit':<9} {'kg CO2e/d':>12}", ] for lbl, val, unit, co2e in terms: lines.append(f" {lbl:<{width}} {val:12.3f} {unit:<9} {co2e:12.1f}") lines.append("") lines += textwrap.wrap( self.note, width=76, initial_indent=" Note: ", subsequent_indent=" " ) return "\n".join(lines) def __str__(self) -> str: return self.report()
[docs] def carbon_footprint( energy_kwh_per_d: float, *, grid_factor: float = DEFAULT_GRID_FACTOR, n2o_emission: float = 0.0, methane_production: float = 0.0, ch4_fugitive_fraction: float = 0.0, biogas_recovered_kwh: float = 0.0, gwp_n2o: float = GWP_N2O, gwp_ch4: float = GWP_CH4, ) -> CarbonFootprint: """Assemble a :class:`CarbonFootprint` from energy use and gas emissions. Parameters ---------- energy_kwh_per_d : float Total electricity draw (kWh/d) -- e.g. ``AE + PE + ME`` from a :class:`~aquakin.plant.bsm.BSM2Evaluation`. grid_factor : float Grid carbon intensity (kg CO₂e/kWh). n2o_emission : float Direct N₂O emission as an **N₂O-N** mass flow (kg N/d), e.g. from :func:`aquakin.plant.bsm.direct_n2o_emission`. methane_production : float Digester methane production (kg CH₄/d). ch4_fugitive_fraction : float Fraction of the produced methane that leaks unburned (0--1). The fugitive CH₄ is ``ch4_fugitive_fraction × methane_production``. biogas_recovered_kwh : float Electricity the recovered (combusted) biogas displaces (kWh/d) -- an avoided-emission credit at ``grid_factor``. Defaults to 0 (no credit); a typical value is the lower heating value of the non-fugitive CH₄ times a CHP electrical efficiency. gwp_n2o, gwp_ch4 : float Global warming potentials (kg CO₂e/kg gas). Returns ------- CarbonFootprint """ ch4_fugitive = float(ch4_fugitive_fraction) * float(methane_production) direct_n2o_co2e = n2o_n_to_co2e(n2o_emission, gwp_n2o) energy_co2e = co2e_from_energy(energy_kwh_per_d, grid_factor) ch4_fugitive_co2e = methane_to_co2e(ch4_fugitive, gwp_ch4) biogas_credit_co2e = co2e_from_energy(biogas_recovered_kwh, grid_factor) total = direct_n2o_co2e + energy_co2e + ch4_fugitive_co2e - biogas_credit_co2e return CarbonFootprint( direct_n2o=float(n2o_emission), energy_kwh=float(energy_kwh_per_d), ch4_fugitive=ch4_fugitive, biogas_recovered_kwh=float(biogas_recovered_kwh), grid_factor=float(grid_factor), gwp_n2o=float(gwp_n2o), gwp_ch4=float(gwp_ch4), direct_n2o_co2e=direct_n2o_co2e, energy_co2e=energy_co2e, ch4_fugitive_co2e=ch4_fugitive_co2e, biogas_credit_co2e=biogas_credit_co2e, total_co2e=total, )