"""Diffuser / blower aeration-design physics layered on the kLa abstraction.
The kinetic model aerates a tank through a per-species mass-transfer coefficient
``kLa`` (see :class:`~aquakin.plant.cstr.Aeration`), and the Copp-2002 benchmark
scores aeration *energy* with a fixed correlation ``AE ∝ Σ V_i·kLa_i``. Real
aeration sizing and energy benchmarking need the blower/diffuser physics behind
that ``kLa``: how much **air** must be blown to produce it, and the **power** to
compress that air against the submergence head.
:class:`AerationSystem` is that design, kept **standalone** -- it does not change
the kinetic ``kLa`` interface. Given the ``kLa`` a solve produced (per tank, over
time) it computes:
* the **standard oxygen transfer rate** the air must deliver,
``SOTR = kLa · C_s,std · V`` (the clean-water transfer at zero DO that defines
the diffuser airflow -- a given ``kLa`` needs a given airflow, independent of
the operating DO deficit);
* the **airflow** ``Q_air = SOTR / (SOTE · o2_per_air)`` from the diffuser's
standard oxygen-transfer efficiency ``SOTE`` (which rises with submergence);
* the blower **discharge pressure** ``p_atm + ρ_w·g·depth + headloss`` from the
diffuser submergence; and
* the blower **power** by adiabatic compression,
``P = (Q·p1/η)·(γ/(γ−1))·[(p2/p1)^((γ−1)/γ) − 1]``.
Because the blower power is linear in airflow and airflow is linear in ``kLa``,
the aeration **energy** ``∫ Σ_i P_i dt`` has the same form as the Copp
correlation but with a mechanistic coefficient (SOTE / depth / blower efficiency)
in place of the fixed one -- so it is a principled refinement, and it stays
``jit`` / ``jax.grad`` clean. The α / β / temperature field corrections live on
:class:`~aquakin.plant.cstr.Aeration` (they shape the ``kLa`` and the driving
force in the solve); ``AerationSystem`` adds the diffuser-fouling factor ``F`` on
the standard efficiency and the blower curve.
All quantities are in days / metres / kPa / kW so they compose with the plant's
time unit and the existing energy kernels (kWh/d).
"""
from __future__ import annotations
from dataclasses import dataclass
import jax.numpy as jnp
from aquakin.plant._constants import HOURS_PER_DAY, MINUTES_PER_DAY, SECONDS_PER_DAY
from aquakin.plant.metrics import time_average
# Standard physical constants (SI).
_RHO_WATER = 1000.0 # kg/m3
_G = 9.80665 # m/s2
[docs]
@dataclass(frozen=True)
class AerationSystem:
"""Diffuser + blower design for the mechanistic aeration energy / airflow.
Standalone: pass it to :func:`blower_energy` / :func:`design_summary`, or to
``evaluate_bsm1`` / ``evaluate_bsm2`` via ``aeration_system=`` (where it
replaces the Copp aeration-energy term). It does **not** change the kinetic
``kLa`` interface.
Parameters
----------
depth : float
Diffuser submergence (m). Sets both the standard transfer efficiency
(when ``sote`` is derived from ``sote_per_meter``) and the blower
discharge head.
sote : float, optional
Standard oxygen-transfer efficiency at ``depth`` (a fraction in
``(0, 1]``: the share of supplied oxygen that dissolves under standard
conditions -- clean water, 20 °C, zero DO). If ``None`` (default) it is
``sote_per_meter · depth``.
sote_per_meter : float
Standard transfer efficiency per metre of submergence, used when ``sote``
is ``None``. Default 0.06 (~6 %/m, typical fine-bubble diffusers).
fouling_F : float
Diffuser fouling factor multiplying the standard efficiency (``<= 1``;
diffuser scaling/biofilm reduce transfer over time). Default 1.0.
standard_do_sat : float
Clean-water dissolved-oxygen saturation at the standard temperature
(g O₂/m³). Default 9.09 (20 °C, 1 atm). The ``kLa·C_s,std·V`` standard
transfer rate the airflow must deliver uses this.
o2_per_air : float
Mass of oxygen per cubic metre of supplied air at the blower's standard
conditions (kg O₂/m³). Default 0.279 (air density ~1.204 kg/m³ at 20 °C ×
0.2318 kg O₂/kg air).
blower_efficiency : float
Wire-to-air efficiency of the blower (motor × compression). Default 0.6.
headloss_kpa : float
Extra discharge head beyond the static submergence -- diffuser and piping
losses (kPa). Default 0.
p_atm_kpa : float
Inlet (atmospheric) pressure (kPa). Default 101.325.
gamma : float
Ratio of specific heats for air, for the adiabatic compression. Default
1.4.
"""
depth: float
sote: float | None = None
sote_per_meter: float = 0.06
fouling_F: float = 1.0
standard_do_sat: float = 9.09
o2_per_air: float = 0.279
blower_efficiency: float = 0.6
headloss_kpa: float = 0.0
p_atm_kpa: float = 101.325
gamma: float = 1.4
def __post_init__(self) -> None:
if self.depth <= 0.0:
raise ValueError(f"AerationSystem depth must be > 0, got {self.depth}.")
eff = self.effective_sote()
if not (0.0 < eff <= 1.0):
raise ValueError(
f"AerationSystem effective SOTE must be in (0, 1], got {eff} "
f"(sote={self.sote}, sote_per_meter={self.sote_per_meter}, "
f"depth={self.depth}, fouling_F={self.fouling_F})."
)
for name in ("o2_per_air", "blower_efficiency", "standard_do_sat"):
if getattr(self, name) <= 0.0:
raise ValueError(f"AerationSystem {name} must be > 0, got {getattr(self, name)}.")
if self.gamma <= 1.0:
raise ValueError(f"AerationSystem gamma must be > 1, got {self.gamma}.")
[docs]
def effective_sote(self) -> float:
"""The standard oxygen-transfer efficiency actually used: the declared
``sote`` (or ``sote_per_meter · depth``) reduced by the fouling factor."""
base = self.sote if self.sote is not None else self.sote_per_meter * self.depth
return base * self.fouling_F
[docs]
def discharge_pressure_kpa(self) -> float:
"""Blower discharge pressure: atmospheric + the static submergence head
``ρ_w·g·depth`` + the diffuser/piping ``headloss_kpa`` (kPa)."""
static = _RHO_WATER * _G * self.depth / 1000.0 # Pa -> kPa
return self.p_atm_kpa + static + self.headloss_kpa
[docs]
def required_airflow(kla, volume, system: AerationSystem):
"""Air flow rate (m³/d) that produces ``kLa`` in a tank of ``volume`` m³.
A given ``kLa`` is delivered by a given airflow: the standard oxygen transfer
``SOTR = kLa · C_s,std · V`` (g O₂/d, the clean-water transfer at zero DO)
must equal ``SOTE · o2_per_air · Q_air``. So
``Q_air = SOTR / (SOTE · o2_per_air)``. The operating DO deficit does not
enter -- it sets how much oxygen *dissolves* (the AOTR), not the air needed.
Parameters
----------
kla : float or jnp.ndarray
Mass-transfer coefficient (1/d).
volume : float or jnp.ndarray
Tank liquid volume (m³).
system : AerationSystem
Returns
-------
jnp.ndarray
Air flow rate (m³/d).
"""
sotr_kg_per_d = jnp.asarray(kla) * system.standard_do_sat * jnp.asarray(volume) / 1000.0
return sotr_kg_per_d / (system.effective_sote() * system.o2_per_air)
[docs]
def blower_power_kw(airflow_m3_per_d, system: AerationSystem):
"""Blower shaft+motor power (kW) to compress ``airflow_m3_per_d`` from the
inlet pressure to the diffuser discharge pressure (adiabatic).
``P = (Q·p1/η)·(γ/(γ−1))·[(p2/p1)^((γ−1)/γ) − 1]`` with ``Q`` in m³/s and the
pressures in Pa, returned in kW. Linear in the airflow (the pressure ratio is
fixed by the submergence), so AD-clean.
"""
Q = jnp.asarray(airflow_m3_per_d) / SECONDS_PER_DAY # m3/s
p1 = system.p_atm_kpa * 1000.0 # Pa
p2 = system.discharge_pressure_kpa() * 1000.0 # Pa
n = (system.gamma - 1.0) / system.gamma
watts = (Q * p1 / system.blower_efficiency) / n * ((p2 / p1) ** n - 1.0)
return watts / 1000.0 # kW
[docs]
def blower_airflow_total(t, kla_history, volumes, system: AerationSystem) -> float:
"""Time-averaged total air flow over the window (m³/d), summed across tanks.
``kla_history`` is ``(n_t, n_tanks)`` and ``volumes`` is ``(n_tanks,)`` --
the same arguments as :func:`aquakin.plant.metrics.aeration_energy`."""
kla_history = jnp.asarray(kla_history)
volumes = jnp.asarray(volumes)
q = required_airflow(kla_history, volumes[None, :], system) # (n_t, n_tanks)
return float(time_average(jnp.sum(q, axis=1), t))
[docs]
def blower_energy(t, kla_history, volumes, system: AerationSystem) -> float:
"""Aeration energy (kWh/d) from the blower model, the mechanistic replacement
for :func:`aquakin.plant.metrics.aeration_energy`.
Sums the blower power across tanks at each save time and time-averages over
the window, ×24 h to give energy per day. Same call signature as the Copp
correlation (``t``, ``kla_history`` ``(n_t, n_tanks)``, ``volumes``
``(n_tanks,)``) so it is a drop-in.
"""
kla_history = jnp.asarray(kla_history)
volumes = jnp.asarray(volumes)
q = required_airflow(kla_history, volumes[None, :], system) # (n_t, n_tanks) m3/d
power = blower_power_kw(q, system) # (n_t, n_tanks) kW
total_power = jnp.sum(power, axis=1) # (n_t,) kW
return float(time_average(total_power, t) * HOURS_PER_DAY)
[docs]
@dataclass(frozen=True)
class AerationDesignPoint:
"""Sizing summary for one tank at one ``kLa`` (a design point)."""
kla: float # 1/d
volume: float # m3
sote: float # effective standard transfer efficiency (fraction)
sotr: float # standard oxygen transfer rate (kg O2/d)
airflow: float # m3/d
discharge_pressure: float # kPa
power: float # kW
def report(self) -> str:
return (
f"Aeration design point (kLa={self.kla:g} 1/d, V={self.volume:g} m3):\n"
f" SOTE {self.sote * 100:7.2f} %\n"
f" SOTR {self.sotr:10.1f} kg O2/d\n"
f" Air flow {self.airflow:10.1f} m3/d "
f"({self.airflow / MINUTES_PER_DAY:.2f} m3/min)\n"
f" Discharge pressure {self.discharge_pressure:10.2f} kPa\n"
f" Blower power {self.power:10.2f} kW"
)
def __str__(self) -> str: # pragma: no cover - thin delegation
return self.report()
[docs]
def design_summary(kla, volume, system: AerationSystem) -> AerationDesignPoint:
"""Size the air flow and blower power for one tank at a steady ``kLa``.
The standalone sizing entry point: returns an :class:`AerationDesignPoint`
with the effective SOTE, the standard oxygen transfer rate, the air flow, the
blower discharge pressure and the blower power.
"""
q = required_airflow(kla, volume, system)
p = blower_power_kw(q, system)
sotr = float(kla) * system.standard_do_sat * float(volume) / 1000.0
return AerationDesignPoint(
kla=float(kla),
volume=float(volume),
sote=system.effective_sote(),
sotr=sotr,
airflow=float(q),
discharge_pressure=system.discharge_pressure_kpa(),
power=float(p),
)