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.