Plant-wide simulation#
Beyond a single reactor, aquakin.plant assembles full treatment-plant
flowsheets — reactors, clarifiers, mixers, splitters, digesters, and their
recycle loops — and integrates the whole plant under one solve. The IWA
benchmark simulation models BSM1 and BSM2 ship ready to build.
The benchmark plants#
Load the biological model, build the plant, wire an influent, and drive it to steady state:
import jax.numpy as jnp
import aquakin
from aquakin.plant.bsm import build_bsm1, load_bsm1_influent, evaluate_bsm1
model = aquakin.load_model("asm1")
plant = build_bsm1(model) # 5 aerated/anoxic reactors + secondary clarifier + recycles
# A constant average-load influent, wired to the plant's canonical inlet.
plant.add_influent("feed", model.influent(
{"SI": 30.0, "SS": 69.5, "XI": 51.2, "XS": 202.32, "XB_H": 28.17,
"SNH": 31.56, "SND": 6.95, "XND": 10.59, "SALK": 7.0}, Q=18446.0))
# Integrate until the plant settles — a self-terminating steady-state event,
# so there is no horizon to guess.
ss = plant.run_to_steady_state()
print("converged:", ss.converged, "after", round(ss.time), "days")
Reconstruct the clarified effluent from the steady state and read its quality:
eff = plant.stream(ss.solution, plant.effluent_endpoint)
print("effluent SNH:", round(float(eff.C_named("SNH")[-1]), 2), "g N / m³")
build_bsm2(model) assembles the full BSM2 sludge train — primary clarifier,
thickener, an ADM1 digester with the ASM1↔ADM1 interfaces, dewatering, and the
reject-water recycle — the same way.
Steady state, faster#
run_to_steady_state() integrates forward until the plant settles. For design
sweeps you can instead snap straight to the steady state algebraically, via
pseudo-transient continuation — typically about 10× faster, robust on stiff
topologies, and differentiable, so jax.grad of a loss on the steady state
flows to the plant parameters:
ss = plant.steady_state()
print("converged:", ss.converged, "in", int(ss.iterations), "iterations")
Dynamic runs and performance indices#
For a dynamic simulation, drive a fresh plant with a time-varying influent, warm-started from the steady state, and score the benchmark performance indices:
dyn = build_bsm1(model)
dyn.add_influent("feed", load_bsm1_influent("dry", model)) # 14-day diurnal load
sol = dyn.solve(t_span=(0.0, 14.0), t_eval=jnp.linspace(0.0, 14.0, 15), y0=ss.state)
ev = evaluate_bsm1(dyn, sol) # Effluent Quality & Operational Cost indices
print(f"EQI = {ev.eqi:.0f} kg/d OCI = {ev.oci:.0f}")
evaluate_bsm2(plant, sol, params) does the same for BSM2 and additionally
reports the physical flows (aeration energy, pumping, sludge and methane
production) that feed the reporting layer below.
Reporting: GHG, cost, and scenarios#
On top of the EQI/OCI evaluation, aquakin reports a greenhouse-gas
footprint (CO₂e/d) and a monetised operating cost (currency/d), and
tabulates scenarios side by side:
ev = aquakin.evaluate_bsm2(plant, sol, params)
n2o = aquakin.direct_n2o_emission(plant, sol) # 0 unless the model resolves N2O
fp = aquakin.carbon_footprint( # kg CO2e/d, with a breakdown
ev.total_energy(), grid_factor=0.4, n2o_emission=n2o,
methane_production=ev.methane_production, ch4_fugitive_fraction=0.015)
oc = aquakin.operating_cost( # currency/d OPEX
energy_kwh_per_d=ev.total_energy(), carbon_kg_cod_per_d=ev.carbon_mass,
sludge_kg_tss_per_d=ev.sludge_production, methane_kg_per_d=ev.methane_production,
factors=aquakin.CostFactors(energy_price=0.12), co2e_per_d=fp.total_co2e)
print(fp) # labelled CO2e breakdown
print(oc) # labelled cost breakdown
print(aquakin.kpi_comparison({"baseline": ev, "low-DO": ev_low_do}).table())
carbon_footprint weights direct N₂O, grid-energy CO₂e, and fugitive biogas
methane, crediting recovered biogas energy; operating_cost prices energy,
carbon, sludge, and biogas; and kpi_comparison collects any of these report
objects into one standardised KPI table.
Building a custom flowsheet#
The benchmark builders are convenience wrappers over the general Plant API.
To assemble your own flowsheet, construct the units, add them (downstream-first
if there is a recycle), wire influents and connections, and solve:
plant = aquakin.Plant("my_plant")
tank = aquakin.CSTRUnit("aerobic", model, volume=1333.0,
input_port_names=["in"], conditions={"T": 293.15},
aeration=aquakin.Aeration(kla=240.0))
plant.add_unit(tank)
plant.add_influent("feed",
model.influent({"SS": 60.0, "SNH": 25.0}, Q=18446.0),
to="aerobic.in") # wire the feed to the tank's inlet
sol = plant.solve(t_span=(0.0, 10.0), t_eval=t_eval)
Units are wired to each other with plant.connect("aerobic.out", "clarifier.in");
add_influent(..., to=...) attaches an external feed to a unit port.
The plant library also provides secondary clarifiers, an ADM1 digester,
sequencing-batch and membrane-bioreactor units, IFAS/MBBR units, disinfection
(UV and chlorine contact), dosing, aeration-system physics, and temperature
models. See the API reference for the full unit catalog, and
Plant.calibrate in Sensitivity & calibration
for fitting plant parameters to measured streams.