{ "cells": [ { "cell_type": "markdown", "id": "28c4658c", "metadata": {}, "source": [ "# Process \n", "\n", "*Click the badge below to try this tutorial interactively in your browser:*\n", "\n", "[![Launch Binder](../images/custom_binder_logo.svg)](https://mybinder.org/v2/gh/QSD-Group/QSDsan-env/main?urlpath=git-pull%3Frepo%3Dhttps%253A%252F%252Fgithub.com%252FQSD-group%252FQSDsan%26urlpath%3Dlab%252Ftree%252FQSDsan%252Fdocs%252Fsource%252Ftutorials%26branch%3Dmain)\n", "\n", "*You can also run this tutorial in [Google Colab](https://colab.research.google.com). It takes a one-time setup per session: follow the [Colab instructions](https://qsdsan.readthedocs.io/en/latest/tutorials/index.html#run-in-colab).*\n", "\n", "- **Prepared by:**\n", "\n", " - [Joy Zhang](https://github.com/joyxyz1994/)\n", "\n", "- **Learning objectives.** After this tutorial, you will be able to:\n", "\n", " - Define a `Process` with rate expressions and stoichiometry\n", " - Group processes into a `Processes` collection\n", " - Use processes inside a dynamic unit to drive state evolution\n", "\n", "- **Prerequisites:** [6. System](https://qsdsan.readthedocs.io/en/latest/tutorials/6_System.html)\n", "\n", "- **Covered topics:**\n", "\n", " - 1. Understanding Process/Processes/process_models\n", " - 2. Making a simple biokinetic model\n", " - 3. Advanced features\n", "\n", "> **Companion video.** A walkthrough of this tutorial is available on YouTube ([Part 1](https://youtu.be/r9HrfTH9_Tg?si=Fqa96s1Tywda-nZM), [Part 2](https://youtu.be/noVSJboqSuc?si=4lvflq1LtCBuyvvf)), presented by [Joy Zhang](https://github.com/joyxyz1994/). Recorded against `QSDsan` v1.2.5. The concepts still apply, but if the code on screen differs from this notebook, follow the notebook.\n" ] }, { "cell_type": "markdown", "id": "3f63ca9d787e", "metadata": {}, "source": [ "\n", "\n", "## Setup\n", "\n", "Import `QSDsan` and confirm the installed version.\n" ] }, { "cell_type": "code", "execution_count": 1, "id": "3dc1138e", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:55:16.045133Z", "iopub.status.busy": "2026-05-28T00:55:16.045133Z", "iopub.status.idle": "2026-05-28T00:56:10.890836Z", "shell.execute_reply": "2026-05-28T00:56:10.890836Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "This tutorial was made with qsdsan v1.5.3.\n" ] } ], "source": [ "import qsdsan as qs\n", "print(f'This tutorial was made with qsdsan v{qs.__version__}.')" ] }, { "cell_type": "markdown", "id": "f6ac4147", "metadata": {}, "source": [ "## 1. Understanding `Process`/`Processes`/`process_models` \n", "This tutorial focuses on the *Process* module in `qsdsan`, which provides the modeling capacity for dynamic conversions of waste streams in a `SanUnit`. With it, simulations can describe not only a system's mass and energy flows at steady state but also how those flows change over time." ] }, { "cell_type": "markdown", "id": "bfe4aa18", "metadata": {}, "source": [ "Similar to the [*Component*](https://qsdsan.readthedocs.io/en/latest/tutorials/2_Component.html) module, the *Process* module includes three main classes: `Process`, `Processes`, and `CompiledProcesses`.\n", "\n", "The `Process` class is used to model individual dynamic processes with user-defined stoichiometry and reaction kinetics. The `Processes` class is simply a collection of multiple `Process` objects. Compilation of `Processes` yields `CompiledProcesses`, which provides additional attributes of the collection. It is often used to implement (pseudo-)mechanistic models with parallel biological and/or physiochemical processes in dynamic simulations of treatment systems. " ] }, { "cell_type": "code", "execution_count": 2, "id": "61b1bd62", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:10.894303Z", "iopub.status.busy": "2026-05-28T00:56:10.894303Z", "iopub.status.idle": "2026-05-28T00:56:10.909011Z", "shell.execute_reply": "2026-05-28T00:56:10.906998Z" } }, "outputs": [ { "data": { "text/html": [ "" ], "text/plain": [ "" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from IPython.display import Image\n", "Image(url='https://lucid.app/publicSegments/view/2c231fa2-6065-46b9-83af-a790ce38b6c0/image.png', width=600)" ] }, { "cell_type": "markdown", "id": "b7f9ccfc", "metadata": {}, "source": [ "In Section 1, we will focus on understanding the structures of these classes by going over some existing processes in `qsdsan`. Next section will guide you through the creation of your own `Processes` subclass." ] }, { "cell_type": "markdown", "id": "0535456f", "metadata": {}, "source": [ "### 1.1. `Processes` vs. `process_models`\n", "You might remember `SanUnit` vs. `unit_operations` from the [SanUnit tutorial](https://qsdsan.readthedocs.io/en/latest/tutorials/4_SanUnit_basic.html#1.1.-SanUnit-and-unit_operations). `Processes` and `process_models` have the same kind of distinction: `Processes` is a class, while `process_models` is the package that holds the pre-built process models." ] }, { "cell_type": "code", "execution_count": 3, "id": "75766cf7", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:10.914465Z", "iopub.status.busy": "2026-05-28T00:56:10.914465Z", "iopub.status.idle": "2026-05-28T00:56:10.922752Z", "shell.execute_reply": "2026-05-28T00:56:10.922752Z" } }, "outputs": [ { "data": { "text/plain": [ "qsdsan._process.Process" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# If you check\n", "qs.Process\n", "# or \n", "# qs.Processes\n", "# or\n", "# qs.CompiledProcesses" ] }, { "cell_type": "markdown", "id": "2f7a09f6", "metadata": {}, "source": [ "This means `Process`/`Processes`/`CompiledProcesses` is a class in the `_process` module (i.e., the codes defining these classes are in the `_process.py` file within the `qsdsan` directory)." ] }, { "cell_type": "code", "execution_count": 4, "id": "50db4564", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:10.924772Z", "iopub.status.busy": "2026-05-28T00:56:10.924772Z", "iopub.status.idle": "2026-05-28T00:56:10.947217Z", "shell.execute_reply": "2026-05-28T00:56:10.947217Z" } }, "outputs": [ { "data": { "text/plain": [ "" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# If you check\n", "qs.process_models" ] }, { "cell_type": "markdown", "id": "587963f4", "metadata": {}, "source": [ "This shows that `process_models` is actually a package (i.e., a directory) rather than a single class. (Why? See [here](https://qsdsan.readthedocs.io/en/latest/tutorials/4_SanUnit_basic.html#1.1.-SanUnit-and-unit_operations).) It bundles the pre-built process models shipped with `qsdsan`.\n", "\n", "
\n", "\n", "**Note:** `qsdsan.processes` is a legacy alias for `qsdsan.process_models` (just as `qsdsan.sanunits` aliases `qsdsan.unit_operations`). Both names point to the same package, but `process_models` is the current, preferred name.\n", "\n", "
\n", "\n", "For the full inventory of available models and helper functions, see the [Process Models API reference](https://qsdsan.readthedocs.io/en/latest/api/process_models/index.html). Briefly, what `qsdsan.process_models.__all__` exposes falls into three groups:\n", "\n", "- `CompiledProcesses` subclasses such as `ASM1`, `ASM2d`, `mASM2d`, `ADM1`, `ADM1p`, and `PM2` (parallel multi-process biokinetic models),\n", "- helper functions named `create__cmps` that build a matching `CompiledComponents` set, plus rate-function utilities like `pH_inhibit` and `Hill_inhibit`, and\n", "- standalone classes like `Decay` and `KineticReaction` for simple processes whose state variables can be solved analytically.\n", "\n", "Submodule scripts inside the package carry a single leading underscore by convention (e.g., `_asm1` for the ASM1 implementation) but can still be accessed directly:" ] }, { "cell_type": "code", "execution_count": 5, "id": "82751cf8", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:10.951215Z", "iopub.status.busy": "2026-05-28T00:56:10.951215Z", "iopub.status.idle": "2026-05-28T00:56:10.958976Z", "shell.execute_reply": "2026-05-28T00:56:10.958022Z" } }, "outputs": [ { "data": { "text/plain": [ "" ] }, "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# For example, `_asm1` is the submodule that defines the ASM1 model.\n", "qs.process_models._asm1" ] }, { "cell_type": "markdown", "id": "f53faf8f", "metadata": {}, "source": [ "For the rest of this section, we will use [activated sludge model no. 1 (ASM1)](https://doi.org/10.2166/9781780402369) as an example to learn about the core classes in the `_process` module." ] }, { "cell_type": "markdown", "id": "72eb8c3d", "metadata": {}, "source": [ "#### ASM1\n", "ASM1 is a mathematical model for the activated sludge processes in wastewater treatment systems. It uses **13** components to characterize wastewater and activated sludge, including biomass, organic substrates (COD), organic and inorganic nitrogens (N), dissolved oxygen (DO), alkalinity etc. \n", "\n", "ASM1 includes **8** transformation processes of the components (e.g., hydrolysis, biomass growth and decay). The stoichiometry of each process considers conservations of COD, N, and electric charges. The process rates are dependent on environmental conditions and state variables (i.e., relevant component concentrations)." ] }, { "cell_type": "code", "execution_count": 6, "id": "7e4b98a4", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:10.961644Z", "iopub.status.busy": "2026-05-28T00:56:10.961644Z", "iopub.status.idle": "2026-05-28T00:56:11.341416Z", "shell.execute_reply": "2026-05-28T00:56:11.339880Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CompiledComponents([\n", " S_I, S_S, X_I, X_S, \n", " X_BH, X_BA, X_P, S_O, \n", " S_NO, S_NH, S_ND, X_ND,\n", " S_ALK, S_N2, H2O, \n", "])\n" ] } ], "source": [ "# Before we get to the subsections, let's get ready by loading ASM1-related objects in qsdsan\n", "from qsdsan.process_models import create_asm1_cmps, ASM1\n", "cmps = create_asm1_cmps()\n", "cmps.show()" ] }, { "cell_type": "markdown", "id": "04e12c48", "metadata": {}, "source": [ "This means the `create_asm1_cmps` function returns a `CompiledComponents` object corresponding to the 13 components in ASM1 besides water.\n", "\n", "
\n", "\n", "**Note:** Classes in the `Process` module cannot be used independent of the `Component` module. Therefore, it is common practice to create corresponding `CompiledComponents` objects and set thermo before creating `Process` instances.\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": 7, "id": "2b7ccd5b", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:11.341416Z", "iopub.status.busy": "2026-05-28T00:56:11.341416Z", "iopub.status.idle": "2026-05-28T00:56:11.352685Z", "shell.execute_reply": "2026-05-28T00:56:11.352685Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Thermo(\n", " chemicals=CompiledComponents([\n", " S_I, S_S, X_I, X_S, \n", " X_BH, X_BA, X_P, S_O, \n", " S_NO, S_NH, S_ND, X_ND,\n", " S_ALK, S_N2, H2O, \n", " ]),\n", " mixture=IdealMixture(...\n", " include_excess_energies=False\n", " ),\n", " Gamma=DortmundActivityCoefficients,\n", " Phi=IdealFugacityCoefficients,\n", " PCF=MockPoyintingCorrectionFactors\n", ")\n" ] } ], "source": [ "# By default, the thermo is set with this `CompiledComponents` object upon its creation.\n", "# We can verify that by calling the `get_thermo` function\n", "qs.get_thermo()" ] }, { "cell_type": "code", "execution_count": 8, "id": "bdf27943", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:11.358291Z", "iopub.status.busy": "2026-05-28T00:56:11.358291Z", "iopub.status.idle": "2026-05-28T00:56:14.059360Z", "shell.execute_reply": "2026-05-28T00:56:14.059360Z" }, "scrolled": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "ASM1([aero_growth_hetero, anox_growth_hetero, aero_growth_auto, decay_hetero, decay_auto, ammonification, hydrolysis, hydrolysis_N])\n" ] } ], "source": [ "# Next we need to create an instance of the ASM1 model\n", "# Without getting into the details of ASM1, we will leave all parameters at their default values \n", "asm1 = ASM1()\n", "asm1.show()" ] }, { "cell_type": "markdown", "id": "f1a543d1", "metadata": {}, "source": [ "This shows that `asm1` is composed of the 8 transformation processes." ] }, { "cell_type": "markdown", "id": "cc423985", "metadata": {}, "source": [ "### 1.2. `Process`\n", "The `Process` class is developed to model individual *dynamic* processes. *Dynamic* means that the rate of reaction is finite (i.e., the reaction is not instantaneous) and often changes with the state of the system. Each of the eight transformation process in ASM1 is described by a `Process` object. " ] }, { "cell_type": "code", "execution_count": 9, "id": "25a826a8", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.062067Z", "iopub.status.busy": "2026-05-28T00:56:14.062067Z", "iopub.status.idle": "2026-05-28T00:56:14.079311Z", "shell.execute_reply": "2026-05-28T00:56:14.076857Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Process: aero_growth_hetero\n", "[stoichiometry] S_S: -1.0/Y_H\n", " X_BH: 1.00\n", " S_O: 1.0*(Y_H - 1.0)/Y_H\n", " S_NH: -0.0800\n", " S_ALK: -0.0686\n", "[reference] X_BH\n", "[rate equation] S_NH*S_O*S_S*X_BH*mu_H/((K_N...\n", "[parameters] Y_H: 0.67\n", " Y_A: 0.24\n", " f_P: 0.08\n", " mu_H: 4\n", " K_S: 10\n", " K_O_H: 0.2\n", " K_NO: 0.5\n", " b_H: 0.3\n", " mu_A: 0.5\n", " K_NH: 1\n", " K_O_A: 0.4\n", " b_A: 0.05\n", " eta_g: 0.8\n", " k_a: 0.05\n", " k_h: 3\n", " K_X: 0.1\n", " eta_h: 0.8\n", "[dynamic parameters] \n" ] } ], "source": [ "# Let's take the 0th process in `asm1` as an example to learn about `Process`.\n", "# Each process in a `CompiledProcesses` object is stored as a named attribute,\n", "# so we can grab one with normal attribute access (equivalent to `asm1.tuple[0]`):\n", "p1 = asm1.aero_growth_hetero\n", "p1.show()" ] }, { "cell_type": "markdown", "id": "2eea412e", "metadata": {}, "source": [ "
\n", "Python Aside: dot access and getattr (click to expand)\n", "\n", "`obj.attr` and `getattr(obj, 'attr')` retrieve the same value; the difference is that the second form takes the attribute name as a string, so it's the one to reach for when the name is computed at runtime:\n", "\n", "```python\n", "process_id = 'aero_growth_hetero'\n", "asm1.aero_growth_hetero # direct attribute access\n", "getattr(asm1, process_id) # equivalent, lookup by string\n", "```\n", "\n", "That matters when you want to iterate over a list of process IDs or pass an attribute name through a function. For everything else, the dot form reads more naturally.\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "a41e3301", "metadata": {}, "source": [ "`p1` is an instance of `Process` describing the growth of heterotrophic biomass under aerobic conditions. Its `show` method prints `p1`'s defining characteristics, all stored as attributes on the object: stoichiometry, reference, rate equation, parameters, and dynamic parameters." ] }, { "cell_type": "code", "execution_count": 10, "id": "baea578a", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.081905Z", "iopub.status.busy": "2026-05-28T00:56:14.081905Z", "iopub.status.idle": "2026-05-28T00:56:14.098342Z", "shell.execute_reply": "2026-05-28T00:56:14.098342Z" } }, "outputs": [ { "data": { "text/plain": [ "{'S_S': -1.49253731343284,\n", " 'X_BH': 1.00000000000000,\n", " 'S_O': -0.492537313432836,\n", " 'S_NH': -0.0800000000000000,\n", " 'S_ALK': -0.0685997415522571}" ] }, "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# For example, we can retrieve information on the stoichiometry of this process\n", "p1.stoichiometry" ] }, { "cell_type": "markdown", "id": "abdb5953", "metadata": {}, "source": [ "Unlike what's printed by the `show` method, a dictionary of evaluated stoichiometric coefficients is returned. The coefficients indicate that to produce 1g COD's worth of `X_BH`, approximately 1.49g COD's worth of `S_S`, 0.08g N's worth of `S_NH`, and 0.49g `S_O` are consumed. The values of these stoichiometric coefficients are dependent on the parameter values in the formulas. We'll see how stoichiometry will change in response to different parameter values later.\n", "\n", "
\n", "\n", "**Note:** The stoichiometry must be defined and interpreted in consistency with the components' units of measure. You can check the units by calling `.measured_as`.\n", "\n", "
\n", "\n", "Note that `X_BH` has a stoichiometric coefficient of 1, which means `X_BH` is a \"reference component\" (i.e., the process rate calculated by the rate equation is also `X_BH`'s rate of production/consumption)." ] }, { "cell_type": "code", "execution_count": 11, "id": "acb5b8e0", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.102477Z", "iopub.status.busy": "2026-05-28T00:56:14.102477Z", "iopub.status.idle": "2026-05-28T00:56:14.114764Z", "shell.execute_reply": "2026-05-28T00:56:14.113324Z" } }, "outputs": [ { "data": { "text/plain": [ "'X_BH'" ] }, "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# This information can also be accessed by calling the `ref_component` property.\n", "p1.ref_component" ] }, { "cell_type": "code", "execution_count": 12, "id": "d0612896", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.116769Z", "iopub.status.busy": "2026-05-28T00:56:14.116769Z", "iopub.status.idle": "2026-05-28T00:56:14.142770Z", "shell.execute_reply": "2026-05-28T00:56:14.140724Z" }, "scrolled": true }, "outputs": [ { "data": { "text/latex": [ "$\\displaystyle \\frac{4.0 S_{NH} S_{O} S_{S} X_{BH}}{\\left(S_{NH} + 1.0\\right) \\left(S_{O} + 0.2\\right) \\left(S_{S} + 10.0\\right)}$" ], "text/plain": [ "4.0*S_NH*S_O*S_S*X_BH/((S_NH + 1.0)*(S_O + 0.2)*(S_S + 10.0))" ] }, "execution_count": 12, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Another defining characteristics of a process is its rate equation, which is stored as a\n", "# property of the `Process` object\n", "p1.rate_equation" ] }, { "cell_type": "markdown", "id": "91dff6cf", "metadata": {}, "source": [ "The output above is the rate equation with parameter values **already substituted in**, which is convenient for evaluation but hides the parameter symbols. The four symbols that remain ($S_{NH}$, $S_O$, $S_S$, $X_{BH}$) are the state variables this process's rate depends on.\n", "\n", "How do we know this function takes concentrations and not, say, mass flowrates? It comes down to the **units of the parameters**. To see the un-substituted form (with parameters still as symbols), `Process` exposes the symbolic equation via the `_rate_equation` attribute. The leading underscore marks it as a private/internal attribute (not part of the everyday public API), but it's the cleanest way to surface which parameters a specific process's rate depends on:" ] }, { "cell_type": "code", "execution_count": 13, "id": "0ce7b4df", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.147797Z", "iopub.status.busy": "2026-05-28T00:56:14.147797Z", "iopub.status.idle": "2026-05-28T00:56:14.163063Z", "shell.execute_reply": "2026-05-28T00:56:14.163063Z" } }, "outputs": [ { "data": { "text/latex": [ "$\\displaystyle \\frac{S_{NH} S_{O} S_{S} X_{BH} \\mu_{H}}{\\left(K_{NH} + S_{NH}\\right) \\left(K_{O H} + S_{O}\\right) \\left(K_{S} + S_{S}\\right)}$" ], "text/plain": [ "S_NH*S_O*S_S*X_BH*mu_H/((K_NH + S_NH)*(K_O_H + S_O)*(K_S + S_S))" ] }, "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# The un-substituted, symbolic form of the rate equation\n", "p1._rate_equation" ] }, { "cell_type": "markdown", "id": "1fe47073", "metadata": {}, "source": [ "Now we can see $\\mu_H$, $K_S$, $K_{NH}$, and $K_{O,H}$ are this process's rate parameters. Their units aren't stored on the `Process` object itself; they're part of the model's specification, documented in the `ASM1` class docstring. We can pull just the relevant parameter lines:" ] }, { "cell_type": "code", "execution_count": 14, "id": "50b77ac8", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.168337Z", "iopub.status.busy": "2026-05-28T00:56:14.168337Z", "iopub.status.idle": "2026-05-28T00:56:14.179244Z", "shell.execute_reply": "2026-05-28T00:56:14.177711Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "mu_H : float, optional\n", " Heterotrophic maximum specific growth rate, in [d^(-1)]. The default\n", "\n", "K_S : float, optional\n", " Readily biodegradable substrate half saturation coefficient, in [g COD/m^3].\n", "\n", "K_O_H : float, optional\n", " Oxygen half saturation coefficient for heterotrophic growth, in [g O2/m^3].\n", "\n", "K_NH : float, optional\n", " Ammonium (nutrient) half saturation coefficient, in [g N/m^3]. The default\n", "\n" ] } ], "source": [ "# Each parameter is documented as a two-line block in `ASM1.__doc__`:\n", "# : float, optional\n", "# , in []. The default is ...\n", "# We slice out the blocks for the four parameters in p1's rate equation.\n", "lines = ASM1.__doc__.splitlines()\n", "for i, line in enumerate(lines):\n", " for p in ('mu_H ', 'K_S ', 'K_NH ', 'K_O_H '):\n", " if line.strip().startswith(p):\n", " print(line.strip())\n", " print(' ' + lines[i + 1].strip())\n", " print()\n", " break" ] }, { "cell_type": "markdown", "id": "c0edbd9d", "metadata": {}, "source": [ "Since $\\mu_H$ is in $d^{-1}$ and $K_S$, $K_{NH}$, $K_{O,H}$ are concentrations in $g/m^3$, the rate equation produces a process rate in $d^{-1}$ (the time derivative of $X_{BH}$ concentration) only when $S_{NH}$, $S_O$, $S_S$, and $X_{BH}$ are also in $g/m^3$. This is part of ASM1's mathematical specification: biokinetic models conventionally define state variables as component concentrations." ] }, { "cell_type": "markdown", "id": "eval-intro-2026", "metadata": {}, "source": [ "Now that we know what units the inputs need, let's **evaluate** the rate equation: substitute numerical values for the state variables ($S_{NH}$, $S_O$, $S_S$, $X_{BH}$) and compute a single number, the process rate at that operating point. Parameter values are already baked into the formula, so we only need to supply state values.\n", "\n", "Rate functions in `qsdsan` expect a 1-D array of length `len(cmps) + 1`: the first `len(cmps)` entries are component concentrations in the order given by `cmps.IDs`, and the last entry is the volumetric flowrate $Q$. ($Q$ doesn't appear in the ASM1 process rates, but it's part of the state because reactor-scale dynamics need it.) For a more meaningful evaluation, let's build a snapshot of a BSM1-style aerobic reactor with realistic mixed-liquor concentrations:" ] }, { "cell_type": "code", "execution_count": 15, "id": "0142f901", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.184464Z", "iopub.status.busy": "2026-05-28T00:56:14.179244Z", "iopub.status.idle": "2026-05-28T00:56:14.193866Z", "shell.execute_reply": "2026-05-28T00:56:14.192249Z" } }, "outputs": [ { "data": { "text/plain": [ "array([3.000e+01, 1.500e+00, 5.100e+01, 5.000e+01, 2.500e+03, 1.500e+02,\n", " 6.000e+02, 2.000e+00, 8.000e+00, 2.000e+00, 7.000e-01, 5.000e+00,\n", " 7.000e+00, 1.500e+01, 1.000e+03, 1.845e+04])" ] }, "execution_count": 15, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import numpy as np\n", "\n", "# Snapshot of a BSM1-style aerobic reactor. Concentrations follow the units\n", "# documented by the ASM1 paper: COD-based components in g COD/m^3, nitrogen\n", "# components in g N/m^3, dissolved oxygen in g O2/m^3, alkalinity in mol/m^3.\n", "state = np.array([\n", " 30.0, # S_I soluble inert\n", " 1.5, # S_S readily biodegradable substrate (low: mostly consumed)\n", " 51.0, # X_I particulate inert\n", " 50.0, # X_S slowly biodegradable substrate\n", " 2500.0, # X_BH heterotrophic biomass\n", " 150.0, # X_BA autotrophic biomass\n", " 600.0, # X_P inert decay products\n", " 2.0, # S_O dissolved oxygen (aerated)\n", " 8.0, # S_NO nitrate + nitrite\n", " 2.0, # S_NH ammonium (residual)\n", " 0.7, # S_ND soluble organic N\n", " 5.0, # X_ND particulate organic N\n", " 7.0, # S_ALK alkalinity\n", " 15.0, # S_N2 dinitrogen\n", " 1000.0, # H2O bulk\n", " 18446.0, # Q volumetric flowrate [m^3/d] (BSM1 dry-weather)\n", "])\n", "state" ] }, { "cell_type": "code", "execution_count": 16, "id": "aceafb27", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.198215Z", "iopub.status.busy": "2026-05-28T00:56:14.194884Z", "iopub.status.idle": "2026-05-28T00:56:14.223398Z", "shell.execute_reply": "2026-05-28T00:56:14.223398Z" } }, "outputs": [ { "data": { "text/plain": [ "790.5138339920948" ] }, "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Note that the `rate_equation` attribute only stores the formula.\n", "# The evaluation of process rate is done through the `rate_function` attribute, which is rendered\n", "# automatically from the rate equation.\n", "p1.rate_function(state)" ] }, { "cell_type": "markdown", "id": "b9fd6405", "metadata": {}, "source": [ "At this operating point, the rate function returns about $790~g\\,COD/m^3/d$: the rate at which heterotrophic biomass ($X_{BH}$) is being produced per unit reactor volume per day. As a sanity check, with $X_{BH} \\approx 2500~g\\,COD/m^3$ this corresponds to a specific growth rate of roughly $790/2500 \\approx 0.32~d^{-1}$, well below the $\\mu_H = 4~d^{-1}$ ceiling because $S_S$ is the limiting substrate (its Monod term $S_S/(K_S+S_S) = 1.5/11.5 \\approx 0.13$ is by far the smallest of the three).\n", "\n", "
\n", "\n", "**Note:** Both `stoichiometry` and `rate_equation` are properties of the `Process` object. Changing the formulas of stoichiometric coefficients or rate equation are prohibited after initiation of the object.\n", "\n", "
\n", "\n", "As you may remember from the `ASM1` documentation and the `asm1.show()` printout above, **parameters** and **dynamic parameters** are two more important attributes of a `Process` object. Both represent intrinsic characteristics of the process and affect the evaluation of stoichiometry and process rate, but they differ in how they're evaluated:\n", "\n", "- **Parameters** are static numerical values that don't change during simulation (e.g., $\\mu_H = 4~d^{-1}$). All of ASM1's parameters are of this kind.\n", "- **Dynamic parameters** are functions of state variables or time that get re-evaluated as the system state evolves. They're useful when a stoichiometric or kinetic coefficient depends on the current operating point but can't be folded neatly into the rate equation itself. ASM1 has none; we'll create one in Section 3.\n", "\n", "Let's first look at the static parameters." ] }, { "cell_type": "code", "execution_count": 17, "id": "62dc9537", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.226424Z", "iopub.status.busy": "2026-05-28T00:56:14.226424Z", "iopub.status.idle": "2026-05-28T00:56:14.239989Z", "shell.execute_reply": "2026-05-28T00:56:14.237943Z" } }, "outputs": [ { "data": { "text/plain": [ "{'Y_H': 0.67,\n", " 'Y_A': 0.24,\n", " 'f_P': 0.08,\n", " 'mu_H': 4.0,\n", " 'K_S': 10.0,\n", " 'K_O_H': 0.2,\n", " 'K_NO': 0.5,\n", " 'b_H': 0.3,\n", " 'mu_A': 0.5,\n", " 'K_NH': 1.0,\n", " 'K_O_A': 0.4,\n", " 'b_A': 0.05,\n", " 'eta_g': 0.8,\n", " 'k_a': 0.05,\n", " 'k_h': 3.0,\n", " 'K_X': 0.1,\n", " 'eta_h': 0.8}" ] }, "execution_count": 17, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# For `asm1` and the individual processes within `asm1`, parameters are stored as a dictionary\n", "p1.parameters" ] }, { "cell_type": "markdown", "id": "cd5798d1", "metadata": {}, "source": [ "Note that `p1` and `asm1` share the dictionary of parameters, although not all parameters included in this dictionary are used in the stoichiometry or process rate calculations for `p1`. This is to make sure the parameter values are consistent across different processes in `asm1`." ] }, { "cell_type": "code", "execution_count": 18, "id": "35cfb083", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.244501Z", "iopub.status.busy": "2026-05-28T00:56:14.244501Z", "iopub.status.idle": "2026-05-28T00:56:14.252960Z", "shell.execute_reply": "2026-05-28T00:56:14.251078Z" } }, "outputs": [ { "data": { "text/plain": [ "True" ] }, "execution_count": 18, "metadata": {}, "output_type": "execute_result" } ], "source": [ "asm1.parameters\n", "p1.parameters is asm1.parameters" ] }, { "cell_type": "code", "execution_count": 19, "id": "0ab063bf", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.256826Z", "iopub.status.busy": "2026-05-28T00:56:14.256826Z", "iopub.status.idle": "2026-05-28T00:56:14.266804Z", "shell.execute_reply": "2026-05-28T00:56:14.264884Z" } }, "outputs": [ { "data": { "text/plain": [ "{'Y_H': 0.8,\n", " 'Y_A': 0.24,\n", " 'f_P': 0.08,\n", " 'mu_H': 6.0,\n", " 'K_S': 8.0,\n", " 'K_O_H': 0.2,\n", " 'K_NO': 0.5,\n", " 'b_H': 0.3,\n", " 'mu_A': 0.5,\n", " 'K_NH': 1.0,\n", " 'K_O_A': 0.4,\n", " 'b_A': 0.05,\n", " 'eta_g': 0.8,\n", " 'k_a': 0.05,\n", " 'k_h': 3.0,\n", " 'K_X': 0.1,\n", " 'eta_h': 0.8}" ] }, "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# If you update parameter values for `p1`, the same parameters will be updated accordingly for\n", "# `asm1` and any other processes in `asm1`.\n", "p1.set_parameters(mu_H=6.0, K_S=8.0, Y_H=0.8)\n", "asm1.parameters" ] }, { "cell_type": "code", "execution_count": 20, "id": "c69a557c", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.266804Z", "iopub.status.busy": "2026-05-28T00:56:14.266804Z", "iopub.status.idle": "2026-05-28T00:56:14.278137Z", "shell.execute_reply": "2026-05-28T00:56:14.278137Z" } }, "outputs": [ { "data": { "text/plain": [ "1435.4066985645932" ] }, "execution_count": 20, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# If you evaluate process rate or stoichiometry again with the same input, \n", "# you should now expect different output.\n", "# p1.stoichiometry\n", "p1.rate_function(state)" ] }, { "cell_type": "markdown", "id": "dyn-params-intro", "metadata": {}, "source": [ "Now the **dynamic parameters**. Since all of ASM1's parameters are static, `p1`'s `dynamic_parameters` dict should be empty. We can verify this directly:" ] }, { "cell_type": "code", "execution_count": 21, "id": "dyn-params-check", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.282160Z", "iopub.status.busy": "2026-05-28T00:56:14.282160Z", "iopub.status.idle": "2026-05-28T00:56:14.292468Z", "shell.execute_reply": "2026-05-28T00:56:14.290448Z" } }, "outputs": [ { "data": { "text/plain": [ "{}" ] }, "execution_count": 21, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# `dynamic_parameters` is the public dict of `DynamicParameter` objects attached\n", "# to this process; empty when all parameters are static (as in ASM1).\n", "p1.dynamic_parameters" ] }, { "cell_type": "markdown", "id": "9d4b20da", "metadata": {}, "source": [ "In addition to the attributes above, `conserved_for` is another useful attribute, especially for biochemical processes. It dictates the conservation principles followed by the stoichiometric coefficients of the the process. " ] }, { "cell_type": "code", "execution_count": 22, "id": "b7115278", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.295687Z", "iopub.status.busy": "2026-05-28T00:56:14.295687Z", "iopub.status.idle": "2026-05-28T00:56:14.304135Z", "shell.execute_reply": "2026-05-28T00:56:14.304135Z" } }, "outputs": [ { "data": { "text/plain": [ "('COD', 'charge', 'N')" ] }, "execution_count": 22, "metadata": {}, "output_type": "execute_result" } ], "source": [ "p1.conserved_for" ] }, { "cell_type": "markdown", "id": "e62602b2", "metadata": {}, "source": [ "We can see `p1` is subject to conservations of COD, N, and electric charge, as defined by ASM1. This information is useful for automatic derivation of unknown stoichiometric coefficients upon initiation of the `Process` object. If numerical values are provided for all stoichiometric coefficients when creating the `Process` object, this information can then be used by `check_conservation` to check if these values satisfy the conservation principles." ] }, { "cell_type": "code", "execution_count": 23, "id": "772e7e78", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.308325Z", "iopub.status.busy": "2026-05-28T00:56:14.308325Z", "iopub.status.idle": "2026-05-28T00:56:14.330576Z", "shell.execute_reply": "2026-05-28T00:56:14.330576Z" } }, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "C:\\Users\\Yalin\\Documents\\Coding\\QSDsan-platform\\QSDsan\\qsdsan\\_process.py:518: UserWarning: The following materials aren't strictly conserved by the stoichiometric coefficients. A positive value means the material is created, a negative value means the material is destroyed:\n", " charge: -5.20417042793042E-18\n", " warn(\"The following materials aren't strictly conserved by the \"\n" ] } ], "source": [ "# No return indicates that all materials in `conserved_for` are conserved.\n", "# Otherwise, a warning or a `RuntimeError` will be raised.\n", "p1.check_conservation()" ] }, { "cell_type": "markdown", "id": "1b9a5fd8", "metadata": {}, "source": [ "### 1.3. `Processes` and `CompiledProcesses`\n", "It is more often than not you're working with more than one dynamic process, which often makes it impossible to derive an analytical solution to the dynamics or the steady state of the system. This is when `Processes` and `CompiledProcesses` come in handy.\n", "\n", "As is mentioned above, `Processes` is simply a collection of multiple `Process` objects. Compiling a `Processes` object yields a `CompiledProcesses` object. Note that `CompiledProcesses` is also a subclass of `Processes`, i.e., it has all of `Processes`'s attributes and more." ] }, { "cell_type": "markdown", "id": "ce2eb558", "metadata": {}, "source": [ "We know that `ASM1` is a subclass of `CompiledProcesses`, which makes `asm1` an instance of `CompiledProcesses`." ] }, { "cell_type": "markdown", "id": "d16e19f5", "metadata": {}, "source": [ "What's actually inside a `CompiledProcesses` object versus a plain `Processes`? `compile()` doesn't add new information — it derives extra structures (the shared parameter dictionaries, the stoichiometry matrix, the compiled rate function) from the constituent `Process` objects and stores them on the collection. We can see the difference by rebuilding `asm1` as a plain `Processes` from its tuple of `Process` objects, then re-compiling and comparing `__dict__`:" ] }, { "cell_type": "code", "execution_count": 24, "id": "9f6ae9b6", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.336462Z", "iopub.status.busy": "2026-05-28T00:56:14.335953Z", "iopub.status.idle": "2026-05-28T00:56:14.340792Z", "shell.execute_reply": "2026-05-28T00:56:14.340792Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Processes([aero_growth_hetero, anox_growth_hetero, aero_growth_auto, decay_hetero, decay_auto, ammonification, hydrolysis, hydrolysis_N])\n" ] } ], "source": [ "# Rebuild a plain `Processes` from the same `Process` objects that make up `asm1`\n", "asm1 = qs.Processes(asm1.tuple)\n", "asm1.show()" ] }, { "cell_type": "code", "execution_count": 25, "id": "3b1e8036", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.344259Z", "iopub.status.busy": "2026-05-28T00:56:14.344259Z", "iopub.status.idle": "2026-05-28T00:56:14.353677Z", "shell.execute_reply": "2026-05-28T00:56:14.353677Z" }, "scrolled": true }, "outputs": [ { "data": { "text/plain": [ "{'aero_growth_hetero': ,\n", " 'anox_growth_hetero': ,\n", " 'aero_growth_auto': ,\n", " 'decay_hetero': ,\n", " 'decay_auto': ,\n", " 'ammonification': ,\n", " 'hydrolysis': ,\n", " 'hydrolysis_N': }" ] }, "execution_count": 25, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# A plain `Processes` only stores individual `Process` objects as attributes\n", "asm1.__dict__" ] }, { "cell_type": "markdown", "id": "9174ba4e", "metadata": {}, "source": [ "A plain `Processes` object only stores individual `Process` objects as attributes." ] }, { "cell_type": "code", "execution_count": 26, "id": "48443f1d", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.358677Z", "iopub.status.busy": "2026-05-28T00:56:14.358677Z", "iopub.status.idle": "2026-05-28T00:56:14.460875Z", "shell.execute_reply": "2026-05-28T00:56:14.458340Z" }, "scrolled": true }, "outputs": [ { "data": { "text/plain": [ "{'aero_growth_hetero': ,\n", " 'anox_growth_hetero': ,\n", " 'aero_growth_auto': ,\n", " 'decay_hetero': ,\n", " 'decay_auto': ,\n", " 'ammonification': ,\n", " 'hydrolysis': ,\n", " 'hydrolysis_N': ,\n", " 'tuple': (,\n", " ,\n", " ,\n", " ,\n", " ,\n", " ,\n", " ,\n", " ),\n", " 'size': 8,\n", " 'IDs': ('aero_growth_hetero',\n", " 'anox_growth_hetero',\n", " 'aero_growth_auto',\n", " 'decay_hetero',\n", " 'decay_auto',\n", " 'ammonification',\n", " 'hydrolysis',\n", " 'hydrolysis_N'),\n", " '_index': {'aero_growth_hetero': 0,\n", " 'anox_growth_hetero': 1,\n", " 'aero_growth_auto': 2,\n", " 'decay_hetero': 3,\n", " 'decay_auto': 4,\n", " 'ammonification': 5,\n", " 'hydrolysis': 6,\n", " 'hydrolysis_N': 7},\n", " '_components': CompiledComponents([S_I, S_S, X_I, X_S, X_BH, X_BA, X_P, S_O, S_NO, S_NH, S_ND, X_ND, S_ALK, S_N2, H2O]),\n", " '_parameters': {'Y_H': 0.8,\n", " 'Y_A': 0.24,\n", " 'f_P': 0.08,\n", " 'mu_H': 6.0,\n", " 'K_S': 8.0,\n", " 'K_O_H': 0.2,\n", " 'K_NO': 0.5,\n", " 'b_H': 0.3,\n", " 'mu_A': 0.5,\n", " 'K_NH': 1.0,\n", " 'K_O_A': 0.4,\n", " 'b_A': 0.05,\n", " 'eta_g': 0.8,\n", " 'k_a': 0.05,\n", " 'k_h': 3.0,\n", " 'K_X': 0.1,\n", " 'eta_h': 0.8},\n", " '_dyn_params': {},\n", " '_stoichiometry': [[0,\n", " -1.25000000000000,\n", " 0,\n", " 0,\n", " 1.00000000000000,\n", " 0,\n", " 0,\n", " -0.250000000000000,\n", " 0,\n", " -0.0800000000000000,\n", " 0,\n", " 0,\n", " -0.0685997415522571,\n", " 0,\n", " 0],\n", " [0,\n", " -1.25000000000000,\n", " 0,\n", " 0,\n", " 1.00000000000000,\n", " 0,\n", " 0,\n", " 0,\n", " -0.0875000000000000,\n", " -0.0800000000000001,\n", " 0,\n", " 0,\n", " 0.00643122577052393,\n", " 0.0875000000000000,\n", " 0],\n", " [0,\n", " 0,\n", " 0,\n", " 0,\n", " 0,\n", " 1.00000000000000,\n", " 0,\n", " -18.0476190476190,\n", " 4.16666666666667,\n", " -4.24666666666667,\n", " 0,\n", " 0,\n", " -7.21440615324570,\n", " 0,\n", " 0],\n", " [0,\n", " 0,\n", " 0,\n", " 0.920000000000000,\n", " -1.00000000000000,\n", " 0,\n", " 0.0800000000000000,\n", " 0,\n", " 0,\n", " 0,\n", " 0,\n", " 0.0752000000000000,\n", " 0,\n", " 0,\n", " 0],\n", " [0,\n", " 0,\n", " 0,\n", " 0.920000000000000,\n", " 0,\n", " -1.00000000000000,\n", " 0.0800000000000000,\n", " 0,\n", " 0,\n", " 0,\n", " 0,\n", " 0.0752000000000000,\n", " 0,\n", " 0,\n", " 0],\n", " [0,\n", " 0,\n", " 0,\n", " 0,\n", " 0,\n", " 0,\n", " 0,\n", " 0,\n", " 0,\n", " 1.00000000000000,\n", " -1.00000000000000,\n", " 0,\n", " 0.857496769403214,\n", " 0,\n", " 0],\n", " [0, 1.0, 0, -1.0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],\n", " [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1.0, -1.0, 0, 0, 0]],\n", " '_stoichio_lambdified': None,\n", " '_rate_equations': (S_NH*S_O*S_S*X_BH*mu_H/((K_NH + S_NH)*(K_O_H + S_O)*(K_S + S_S)),\n", " K_O_H*S_NH*S_NO*S_S*X_BH*eta_g*mu_H/((K_NH + S_NH)*(K_NO + S_NO)*(K_O_H + S_O)*(K_S + S_S)),\n", " S_NH*S_O*X_BA*mu_A/((K_NH + S_NH)*(K_O_A + S_O)),\n", " X_BH*b_H,\n", " X_BA*b_A,\n", " S_ND*X_BH*k_a,\n", " X_BH*X_S*k_h*(K_O_H*S_NO*eta_h/((K_NO + S_NO)*(K_O_H + S_O)) + S_O/(K_O_H + S_O))/(K_X*X_BH + X_S),\n", " X_BH*X_ND*k_h*(K_O_H*S_NO*eta_h/((K_NO + S_NO)*(K_O_H + S_O)) + S_O/(K_O_H + S_O))/(K_X*X_BH + X_S)),\n", " '_production_rates': [0,\n", " -1.25*K_O_H*S_NH*S_NO*S_S*X_BH*eta_g*mu_H/((K_NH + S_NH)*(K_NO + S_NO)*(K_O_H + S_O)*(K_S + S_S)) - 1.25*S_NH*S_O*S_S*X_BH*mu_H/((K_NH + S_NH)*(K_O_H + S_O)*(K_S + S_S)) + 1.0*X_BH*X_S*k_h*(K_O_H*S_NO*eta_h/((K_NO + S_NO)*(K_O_H + S_O)) + S_O/(K_O_H + S_O))/(K_X*X_BH + X_S),\n", " 0,\n", " 0.92*X_BA*b_A - 1.0*X_BH*X_S*k_h*(K_O_H*S_NO*eta_h/((K_NO + S_NO)*(K_O_H + S_O)) + S_O/(K_O_H + S_O))/(K_X*X_BH + X_S) + 0.92*X_BH*b_H,\n", " 1.0*K_O_H*S_NH*S_NO*S_S*X_BH*eta_g*mu_H/((K_NH + S_NH)*(K_NO + S_NO)*(K_O_H + S_O)*(K_S + S_S)) + 1.0*S_NH*S_O*S_S*X_BH*mu_H/((K_NH + S_NH)*(K_O_H + S_O)*(K_S + S_S)) - 1.0*X_BH*b_H,\n", " 1.0*S_NH*S_O*X_BA*mu_A/((K_NH + S_NH)*(K_O_A + S_O)) - 1.0*X_BA*b_A,\n", " 0.08*X_BA*b_A + 0.08*X_BH*b_H,\n", " -0.25*S_NH*S_O*S_S*X_BH*mu_H/((K_NH + S_NH)*(K_O_H + S_O)*(K_S + S_S)) - 18.047619047619*S_NH*S_O*X_BA*mu_A/((K_NH + S_NH)*(K_O_A + S_O)),\n", " -0.0875*K_O_H*S_NH*S_NO*S_S*X_BH*eta_g*mu_H/((K_NH + S_NH)*(K_NO + S_NO)*(K_O_H + S_O)*(K_S + S_S)) + 4.16666666666667*S_NH*S_O*X_BA*mu_A/((K_NH + S_NH)*(K_O_A + S_O)),\n", " -0.0800000000000001*K_O_H*S_NH*S_NO*S_S*X_BH*eta_g*mu_H/((K_NH + S_NH)*(K_NO + S_NO)*(K_O_H + S_O)*(K_S + S_S)) + 1.0*S_ND*X_BH*k_a - 0.08*S_NH*S_O*S_S*X_BH*mu_H/((K_NH + S_NH)*(K_O_H + S_O)*(K_S + S_S)) - 4.24666666666667*S_NH*S_O*X_BA*mu_A/((K_NH + S_NH)*(K_O_A + S_O)),\n", " -1.0*S_ND*X_BH*k_a + 1.0*X_BH*X_ND*k_h*(K_O_H*S_NO*eta_h/((K_NO + S_NO)*(K_O_H + S_O)) + S_O/(K_O_H + S_O))/(K_X*X_BH + X_S),\n", " 0.0752*X_BA*b_A - 1.0*X_BH*X_ND*k_h*(K_O_H*S_NO*eta_h/((K_NO + S_NO)*(K_O_H + S_O)) + S_O/(K_O_H + S_O))/(K_X*X_BH + X_S) + 0.0752*X_BH*b_H,\n", " 0.00643122577052393*K_O_H*S_NH*S_NO*S_S*X_BH*eta_g*mu_H/((K_NH + S_NH)*(K_NO + S_NO)*(K_O_H + S_O)*(K_S + S_S)) + 0.857496769403214*S_ND*X_BH*k_a - 0.0685997415522571*S_NH*S_O*S_S*X_BH*mu_H/((K_NH + S_NH)*(K_O_H + S_O)*(K_S + S_S)) - 7.2144061532457*S_NH*S_O*X_BA*mu_A/((K_NH + S_NH)*(K_O_A + S_O)),\n", " 0.0875*K_O_H*S_NH*S_NO*S_S*X_BH*eta_g*mu_H/((K_NH + S_NH)*(K_NO + S_NO)*(K_O_H + S_O)*(K_S + S_S)),\n", " 0],\n", " '_rate_function': None}" ] }, "execution_count": 26, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Re-compile to get the `CompiledProcesses` form back\n", "asm1.compile()\n", "asm1.__dict__" ] }, { "cell_type": "markdown", "id": "a304d82e", "metadata": {}, "source": [ "A `CompiledProcesses` object has many more attributes that streamline the use of dynamic process models. We didn't supply any extra information to `compile()`: every new attribute is derived from the constituent `Process` objects." ] }, { "cell_type": "markdown", "id": "process-share-note", "metadata": {}, "source": [ "
\n", "\n", "**Note: a `Process` belongs to one `CompiledProcesses` at a time.** Unlike `Component`, which can be reused freely across different `CompiledComponents` sets, `compile()` rewires each constituent process's parameter dictionary to point at the new collection's. If you put the same `Process` object into a second `Processes` and compile that, the process is then bound to the second collection, and `set_parameters` on the first no longer propagates to it. In practice, build a fresh `Process` (or copy it with `Process.copy`) when you want it in a different `CompiledProcesses`.\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "t10-petersen-matrix", "metadata": {}, "source": [ "The `.stoichiometry` attribute of a `CompiledProcesses` returns a table with processes as rows, components as columns, and the stoichiometric coefficients in the cells. This layout is known as the **Petersen (Gujer) matrix**. It is widely used in biokinetic models such as Activated Sludge Model (ASM1/2/3) and the Anaerobic Digestion Model No. 1 ([Tutorial 12](https://qsdsan.readthedocs.io/en/latest/tutorials/12_Anaerobic_Digestion_Model_No_1.html)). Calling the matrix $\\mathbf{A}$ (rows = processes, columns = components), the per-component net production or consumption rate is $\\mathbf{r} = \\mathbf{A}^{T} \\boldsymbol{\\rho}$, with $\\boldsymbol{\\rho}$ the vector of process rates — exactly the form `CompiledProcesses.rate_function` evaluates below." ] }, { "cell_type": "code", "execution_count": 27, "id": "027cbd6b", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.466039Z", "iopub.status.busy": "2026-05-28T00:56:14.460875Z", "iopub.status.idle": "2026-05-28T00:56:14.494910Z", "shell.execute_reply": "2026-05-28T00:56:14.494910Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
S_IS_SX_IX_SX_BH...S_NDX_NDS_ALKS_N2H2O
aero_growth_hetero0-1.25001...00-0.068600
anox_growth_hetero0-1.25001...000.006430.08750
aero_growth_auto00000...00-7.2100
decay_hetero0000.92-1...00.0752000
decay_auto0000.920...00.0752000
ammonification00000...-100.85700
hydrolysis010-10...00000
hydrolysis_N00000...1-1000
\n", "

8 rows × 15 columns

\n", "
" ], "text/plain": [ " S_I S_S X_I X_S X_BH ... S_ND X_ND S_ALK S_N2 H2O\n", "aero_growth_hetero 0 -1.25 0 0 1 ... 0 0 -0.0686 0 0\n", "anox_growth_hetero 0 -1.25 0 0 1 ... 0 0 0.00643 0.0875 0\n", "aero_growth_auto 0 0 0 0 0 ... 0 0 -7.21 0 0\n", "decay_hetero 0 0 0 0.92 -1 ... 0 0.0752 0 0 0\n", "decay_auto 0 0 0 0.92 0 ... 0 0.0752 0 0 0\n", "ammonification 0 0 0 0 0 ... -1 0 0.857 0 0\n", "hydrolysis 0 1 0 -1 0 ... 0 0 0 0 0\n", "hydrolysis_N 0 0 0 0 0 ... 1 -1 0 0 0\n", "\n", "[8 rows x 15 columns]" ] }, "execution_count": 27, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# `.stoichiometry` returns the Petersen matrix described above\n", "asm1.stoichiometry" ] }, { "cell_type": "code", "execution_count": 28, "id": "18541f64", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.499296Z", "iopub.status.busy": "2026-05-28T00:56:14.499296Z", "iopub.status.idle": "2026-05-28T00:56:14.568223Z", "shell.execute_reply": "2026-05-28T00:56:14.567213Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
rate_equation
aero_growth_hetero6.0*S_NH*S_O*S_S*X_BH/((S_NH + ...
anox_growth_hetero0.96*S_NH*S_NO*S_S*X_BH/((S_NH ...
aero_growth_auto0.5*S_NH*S_O*X_BA/((S_NH + 1.0)...
decay_hetero0.3*X_BH
decay_auto0.05*X_BA
ammonification0.05*S_ND*X_BH
hydrolysis3.0*X_BH*X_S*(0.16*S_NO/((S_NO ...
hydrolysis_N3.0*X_BH*X_ND*(0.16*S_NO/((S_NO...
\n", "
" ], "text/plain": [ " rate_equation\n", "aero_growth_hetero 6.0*S_NH*S_O*S_S*X_BH/((S_NH + ...\n", "anox_growth_hetero 0.96*S_NH*S_NO*S_S*X_BH/((S_NH ...\n", "aero_growth_auto 0.5*S_NH*S_O*X_BA/((S_NH + 1.0)...\n", "decay_hetero 0.3*X_BH\n", "decay_auto 0.05*X_BA\n", "ammonification 0.05*S_ND*X_BH\n", "hydrolysis 3.0*X_BH*X_S*(0.16*S_NO/((S_NO ...\n", "hydrolysis_N 3.0*X_BH*X_ND*(0.16*S_NO/((S_NO..." ] }, "execution_count": 28, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Similarly for the rate equations\n", "asm1.rate_equations" ] }, { "cell_type": "code", "execution_count": 29, "id": "a872533e", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.568223Z", "iopub.status.busy": "2026-05-28T00:56:14.568223Z", "iopub.status.idle": "2026-05-28T00:56:14.605219Z", "shell.execute_reply": "2026-05-28T00:56:14.605219Z" } }, "outputs": [ { "data": { "text/plain": [ "array([1435.407, 108.078, 41.667, 750. , 7.5 , 87.5 ,\n", " 1221.925, 122.193])" ] }, "execution_count": 29, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# More importantly, the `rate_function` attribute of a `CompiledProcesses` now outputs an array\n", "# with each element corresponding orderly to the individual processes\n", "asm1.rate_function(state)" ] }, { "cell_type": "markdown", "id": "75516266", "metadata": {}, "source": [ "With this, the evaluation of all process rates can be done with one command and the output data type is convenient for matrix operations.\n", "\n", "To calculate the overall rate of production for a component, we need to first estimate its rates of production (positive means being produced, negative means being consumed) by each process and then sum them up. In mathematical forms, let's say $a_{ij}$ represents the stoichiometric coefficient of component $j$ in process $i$ (i.e., value on the $i$ th row and $j$ th column of the stoichiometry matrix). Denote $\\rho_i$ as process $i$'s reaction rate, now the overall production rate of component $j$ should be\n", "\n", "$$r_j = \\sum_i{a_{ij}\\cdot\\rho_i}$$\n", "\n", "In matrix notation, this calculation can be neatly described as\n", "\n", "$$\\mathbf{r} = \\mathbf{A^T} \\mathbf{\\rho}$$\n", "\n", "where $\\mathbf{A}$ is the stoichiometry matrix and $\\mathbf{\\rho}$ is the array of process rates." ] }, { "cell_type": "code", "execution_count": 30, "id": "81541f71", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.610823Z", "iopub.status.busy": "2026-05-28T00:56:14.610823Z", "iopub.status.idle": "2026-05-28T00:56:14.863416Z", "shell.execute_reply": "2026-05-28T00:56:14.863416Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
rate_of_production
S_I0
S_S-1.2*S_NH*S_NO*S_S*X_BH/((S_NH ...
X_I0
X_S0.046*X_BA - 3.0*X_BH*X_S*(0.16...
X_BH0.96*S_NH*S_NO*S_S*X_BH/((S_NH ...
X_BA0.5*S_NH*S_O*X_BA/((S_NH + 1.0)...
X_P0.004*X_BA + 0.024*X_BH
S_O-1.5*S_NH*S_O*S_S*X_BH/((S_NH +...
S_NO-0.084*S_NH*S_NO*S_S*X_BH/((S_N...
S_NH0.05*S_ND*X_BH - 0.076800000000...
S_ND-0.05*S_ND*X_BH + 3.0*X_BH*X_ND...
X_ND0.00376*X_BA - 3.0*X_BH*X_ND*(0...
S_ALK0.0428748384701607*S_ND*X_BH + ...
S_N20.084*S_NH*S_NO*S_S*X_BH/((S_NH...
H2O0
\n", "
" ], "text/plain": [ " rate_of_production\n", "S_I 0\n", "S_S -1.2*S_NH*S_NO*S_S*X_BH/((S_NH ...\n", "X_I 0\n", "X_S 0.046*X_BA - 3.0*X_BH*X_S*(0.16...\n", "X_BH 0.96*S_NH*S_NO*S_S*X_BH/((S_NH ...\n", "X_BA 0.5*S_NH*S_O*X_BA/((S_NH + 1.0)...\n", "X_P 0.004*X_BA + 0.024*X_BH\n", "S_O -1.5*S_NH*S_O*S_S*X_BH/((S_NH +...\n", "S_NO -0.084*S_NH*S_NO*S_S*X_BH/((S_N...\n", "S_NH 0.05*S_ND*X_BH - 0.076800000000...\n", "S_ND -0.05*S_ND*X_BH + 3.0*X_BH*X_ND...\n", "X_ND 0.00376*X_BA - 3.0*X_BH*X_ND*(0...\n", "S_ALK 0.0428748384701607*S_ND*X_BH + ...\n", "S_N2 0.084*S_NH*S_NO*S_S*X_BH/((S_NH...\n", "H2O 0" ] }, "execution_count": 30, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# This matrix operation is already streamlined for `CompiledProcesses` objects, you can see the \n", "# mathematical form of the rates of production as a function of component concentrations.\n", "asm1.production_rates" ] }, { "cell_type": "code", "execution_count": 31, "id": "b508e20f", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.868726Z", "iopub.status.busy": "2026-05-28T00:56:14.868726Z", "iopub.status.idle": "2026-05-28T00:56:14.878265Z", "shell.execute_reply": "2026-05-28T00:56:14.877215Z" } }, "outputs": [ { "data": { "text/plain": [ "array([ 0. , -707.43 , 0. , -525.025, 793.484, 34.167,\n", " 60.6 , -1110.836, 164.154, -212.923, 34.693, -65.229,\n", " -323.343, 9.457, 0. ])" ] }, "execution_count": 31, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# To evaluate the rates of production for all components, all you need to\n", "# do is to call the `production_rates_eval` method.\n", "asm1.production_rates_eval(state)" ] }, { "cell_type": "markdown", "id": "d83291b1", "metadata": {}, "source": [ "With these convenient evaluation methods, `asm1` can now be used in `SanUnit` to model system dynamics (e.g., reactor effluent concentrations over time). See the [Dynamic Simulation tutorial](https://qsdsan.readthedocs.io/en/latest/tutorials/11_Dynamic_Simulation.html) for how to integrate process models with `SanUnit` subclasses and enable dynamic simulations." ] }, { "cell_type": "markdown", "id": "a8068669", "metadata": {}, "source": [ "Congratulations! You've understood the structure and the core capacities of the `_process` module. 🎉 " ] }, { "cell_type": "markdown", "id": "ce48aa2e", "metadata": {}, "source": [ "## 2. Making a simple biokinetic model \n", "\n", "In this section, we will get some hands-on experience with the `_process` module. For demonstration purpose, we will implement a simple biokinetic model with the classes we learned in the previous section. It's even better if you have your own mathematical model of dynamic processes in mind.\n", "\n", "Let's get started with defining the mathematical model." ] }, { "cell_type": "markdown", "id": "b6a928f2", "metadata": {}, "source": [ "\n", "\n", "### The biokinetic model\n", "Let's consider a simplified activated sludge system with 5 components besides water: \n", "- particulate organic substrate ($X_S$, measured as COD)\n", "- soluble organic substrate ($S_S$, measured as COD)\n", "- dissolved oxygen ($O_2$, measured as itself)\n", "- dissolved CO2 ($CO_2$, measured as C)\n", "- biomass ($X_B$, measured as COD)\n", "\n", "This model uses 3 dynamic processes to describe the transformations among the components:\n", "1. Hydrolysis (i.e., $X_S$ transforming into $S_S$)\n", "2. Growth of biomass (i.e., $X_B$ utilizing $S_S$ and $O_2$ to grow)\n", "3. Decay of biomass (i.e., $X_B$ dying and transforming into $X_S$)\n", "\n", "To mathematically characterize these processes, we need to define the stoichiometry and the process rate equation of each process. Let's say, for process 1 and 3:\n", "$$X_S \\Rightarrow S_S,~~\\rho_1 = k_{hyd}X_S$$\n", "$$X_B \\Rightarrow X_S,~~\\rho_3 = b\\cdot X_B$$\n", "where $k_{hyd}$ is the hydrolysis rate constant, $b$ is the biomass decay rate constant, both in $d^{-1}\\cdot m^3\\cdot g^{-1}$.\n", "\n", "For biomass growth, assuming the yield of biomass is $y_B$ \\[gCOD $X_B$/gCOD $S_S$\\], then the stoichiometry can be defined as:\n", "$$\\frac{1}{y_B}S_S + a_1O_2 \\Rightarrow X_B + a_2CO_2$$\n", "where $a_1$ and $a_2$ need to be determined by conservations of COD and carbon.\n", "Similar to other activated sludge models, we can use a Monod-type function to represent biomass growth rate, i.e.,\n", "$$\\rho_2 = \\mu_B\\cdot \\frac{S_S}{K_S+S_S}X_B$$" ] }, { "cell_type": "markdown", "id": "b51bd5c3", "metadata": {}, "source": [ "### 2.1. Defining the components\n", "For convenience, we will find similar components that have been defined in the default set. Note that we will need to consider conservations of COD and C in the stoichiometry of process 2. Therefore, we should make sure the `Component` objects we pick have appropriate values for COD content (`i_COD`) and carbon content (`i_C`). " ] }, { "cell_type": "code", "execution_count": 32, "id": "cad5a69b", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:14.885362Z", "iopub.status.busy": "2026-05-28T00:56:14.880344Z", "iopub.status.idle": "2026-05-28T00:56:15.351525Z", "shell.execute_reply": "2026-05-28T00:56:15.351525Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CompiledComponents([\n", " X_S, S_S, O2, CO2,\n", " X_B, H2O,\n", "])\n" ] } ], "source": [ "# Load the default set of components\n", "cmps_all = qs.Components.load_default()\n", "\n", "# Copy select components from the default set and give them new IDs\n", "X_S = cmps_all.X_B_Subst.copy('X_S')\n", "S_S = cmps_all.S_F.copy('S_S')\n", "O2 = cmps_all.S_O2.copy('O2')\n", "CO2 = cmps_all.S_CO3.copy('CO2')\n", "X_B = cmps_all.X_OHO.copy('X_B')\n", "\n", "# Compile them into a new set. Don't forget the water!\n", "cmps_bkm = qs.Components([X_S, S_S, O2, CO2, X_B, cmps_all.H2O])\n", "cmps_bkm.compile(ignore_inaccurate_molar_weight=True)\n", "cmps_bkm.show()" ] }, { "cell_type": "markdown", "id": "1e0c82ed", "metadata": {}, "source": [ "
\n", "\n", "**Note:** You might've noticed H2O isn't an active component in any process but is always included in the `CompiledComponents` set. This is because water is assumed to be the bulk liquid and is needed for the convergence of total volumetric flows in simulation regardless of the process model.\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": 33, "id": "494eb3e8", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:15.356153Z", "iopub.status.busy": "2026-05-28T00:56:15.356153Z", "iopub.status.idle": "2026-05-28T00:56:15.365616Z", "shell.execute_reply": "2026-05-28T00:56:15.363868Z" } }, "outputs": [ { "data": { "text/plain": [ "(True, True, True)" ] }, "execution_count": 33, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Now we can check if their `measured_as` attributes are correctly set\n", "(X_S.measured_as == 'COD',\n", " CO2.measured_as == 'C',\n", " O2.measured_as is None)" ] }, { "cell_type": "code", "execution_count": 34, "id": "2de7d166", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:15.365616Z", "iopub.status.busy": "2026-05-28T00:56:15.365616Z", "iopub.status.idle": "2026-05-28T00:56:15.376136Z", "shell.execute_reply": "2026-05-28T00:56:15.374619Z" } }, "outputs": [ { "data": { "text/plain": [ "-1.0" ] }, "execution_count": 34, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Then you can check relevant `i_` properties of the components\n", "# For example, O2 should have a negative COD content, or more specifically, -1 gCOD/gO2\n", "O2.i_COD" ] }, { "cell_type": "markdown", "id": "3767f498", "metadata": {}, "source": [ "
\n", "\n", "**Tip:** It is not always/just `i_COD` and `i_C` that are important. For example, if a model involves nitrification/denitrification processes, it is critical that the components' `i_N` values are set correctly. It ultimately boils down to what conservation principles need to be considered.\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": 35, "id": "7b264f76", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:15.378647Z", "iopub.status.busy": "2026-05-28T00:56:15.378647Z", "iopub.status.idle": "2026-05-28T00:56:15.382655Z", "shell.execute_reply": "2026-05-28T00:56:15.382655Z" } }, "outputs": [], "source": [ "# A final preparation step is to set thermo with the components\n", "qs.set_thermo(cmps_bkm)" ] }, { "cell_type": "markdown", "id": "9d9a485a", "metadata": {}, "source": [ "It is common practice to wrap up the creation of model-specific `CompiledComponents` objects in a function for convenient reuse. The `create__cmps` functions we've seen in the previous section is a good example." ] }, { "cell_type": "markdown", "id": "5ab8d5af", "metadata": {}, "source": [ "### 2.2. Defining the processes\n", "With the components ready, we can now define individual dynamic processes in the biokinetic model as `Process` objects." ] }, { "cell_type": "markdown", "id": "8fcc2ab6", "metadata": {}, "source": [ "For process 1 and process 3, the stoichiometric coefficients are known and the rate equations take simple forms with just one parameter." ] }, { "cell_type": "code", "execution_count": 36, "id": "7b7437a4", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:15.385743Z", "iopub.status.busy": "2026-05-28T00:56:15.385743Z", "iopub.status.idle": "2026-05-28T00:56:15.394565Z", "shell.execute_reply": "2026-05-28T00:56:15.392007Z" } }, "outputs": [], "source": [ "# To define `Process` objects for these two processes, we just need to input their \n", "# stoichiometries and rate equations\n", "\n", "# Process 1: hydrolysis\n", "pc1 = qs.Process(ID='hydrolysis',\n", " reaction='X_S -> S_S',\n", " ref_component='X_S',\n", " rate_equation='k_hyd*X_S',\n", " conserved_for=(),\n", " parameters=('k_hyd',))" ] }, { "cell_type": "markdown", "id": "097171ce", "metadata": {}, "source": [ "Note that the information on stoichiometry is reflected in the `reaction` argument. `ref_component` is set to be `X_S` because its stoichiometric coefficient is -1. \n", "\n", "No conservation principle is used in the initiation of `pc1` (i.e., `conserved_for` is an empty tuple). This is fine, because there's no unknown stoichiometric coefficient that relies on conservation principles to solve. However, this doesn't mean the process isn't subject to any conservation principle. The 1 to 1 stoichiometry of this process reflects the conservation of COD, as both $X_S$ and $S_S$ are measured as COD." ] }, { "cell_type": "code", "execution_count": 37, "id": "4f98d569", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:15.396079Z", "iopub.status.busy": "2026-05-28T00:56:15.396079Z", "iopub.status.idle": "2026-05-28T00:56:15.403781Z", "shell.execute_reply": "2026-05-28T00:56:15.403781Z" } }, "outputs": [], "source": [ "# We can verify this with the `check_conservation` method\n", "# To do that, `conserved_for` needs to be set first\n", "pc1.conserved_for = ('COD',)\n", "pc1.check_conservation()" ] }, { "cell_type": "markdown", "id": "265192c8", "metadata": {}, "source": [ "No return means `pc1` conserves COD.\n", "\n", "
\n", "\n", "**Note:** `conserved_for` is treated as an iterable in the `check_conservation` method. So, when there's only one conservation principle, we should make sure the entire string is recognized as one element in iteration.\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": 38, "id": "c7ce74d2", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:15.406512Z", "iopub.status.busy": "2026-05-28T00:56:15.406512Z", "iopub.status.idle": "2026-05-28T00:56:16.715760Z", "shell.execute_reply": "2026-05-28T00:56:16.715760Z" }, "tags": [ "raises-exception" ] }, "outputs": [ { "ename": "ValueError", "evalue": "Components do not have i_O attribute.", "output_type": "error", "traceback": [ "\u001b[31m---------------------------------------------------------------------------\u001b[39m", "\u001b[31mAttributeError\u001b[39m Traceback (most recent call last)", "\u001b[36mFile \u001b[39m\u001b[32m~\\Documents\\Coding\\QSDsan-platform\\QSDsan\\qsdsan\\_process.py:608\u001b[39m, in \u001b[36mProcess.conserved_for\u001b[39m\u001b[34m(self, materials)\u001b[39m\n\u001b[32m 607\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m mat \u001b[38;5;129;01min\u001b[39;00m materials:\n\u001b[32m--> \u001b[39m\u001b[32m608\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m: \u001b[30;43mget\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mComponent\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43mf\u001b[39;49m\u001b[30;43m'\u001b[39;49m\u001b[30;43mi_\u001b[39;49m\u001b[30;43;01m{\u001b[39;49;00m\u001b[30;43mmat\u001b[39;49m\u001b[30;43;01m}\u001b[39;49;00m\u001b[30;43m'\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 609\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m: \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mComponents do not have i_\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mmat\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m attribute.\u001b[39m\u001b[33m'\u001b[39m)\n", "\u001b[31mAttributeError\u001b[39m: type object 'Component' has no attribute 'i_O'", "\nDuring handling of the above exception, another exception occurred:\n", "\u001b[31mValueError\u001b[39m Traceback (most recent call last)", "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[38]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[38;5;66;03m# For example, if we do this, we will get an error message because the function interprets it as\u001b[39;00m\n\u001b[32m 2\u001b[39m \u001b[38;5;66;03m# checking conservations of 'C', 'O', and 'D', respectively\u001b[39;00m\n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m pc1.conserved_for = (\u001b[33m'COD'\u001b[39m) \u001b[38;5;66;03m# note the missing comma, which makes it a string instead of a tuple\u001b[39;00m\n\u001b[32m 4\u001b[39m pc1.check_conservation()\n", "\u001b[36mFile \u001b[39m\u001b[32m~\\Documents\\Coding\\QSDsan-platform\\QSDsan\\qsdsan\\_process.py:609\u001b[39m, in \u001b[36mProcess.conserved_for\u001b[39m\u001b[34m(self, materials)\u001b[39m\n\u001b[32m 607\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m mat \u001b[38;5;129;01min\u001b[39;00m materials:\n\u001b[32m 608\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m: get(Component, \u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mi_\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mmat\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m'\u001b[39m)\n\u001b[32m--> \u001b[39m\u001b[32m609\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m: \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mComponents do not have i_\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mmat\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m attribute.\u001b[39m\u001b[33m'\u001b[39m)\n\u001b[32m 610\u001b[39m \u001b[38;5;28mself\u001b[39m._conserved_for = materials\n", "\u001b[31mValueError\u001b[39m: Components do not have i_O attribute." ] } ], "source": [ "# For example, if we do this, we will get an error message because the function interprets it as \n", "# checking conservations of 'C', 'O', and 'D', respectively\n", "pc1.conserved_for = ('COD') # note the missing comma, which makes it a string instead of a tuple\n", "pc1.check_conservation()" ] }, { "cell_type": "markdown", "id": "3b1edaeb", "metadata": {}, "source": [ "Note that the initiation also asks for `parameters` (`k_hyd` in this case). This is to help identify any symbolic parameter in the stoichiometry and the rate equation." ] }, { "cell_type": "code", "execution_count": 39, "id": "e7facea8", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:16.723984Z", "iopub.status.busy": "2026-05-28T00:56:16.717775Z", "iopub.status.idle": "2026-05-28T00:56:16.736506Z", "shell.execute_reply": "2026-05-28T00:56:16.732214Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Process: hydrolysis\n", "[stoichiometry] X_S: -1\n", " S_S: 1\n", "[reference] X_S\n", "[rate equation] X_S*k_hyd\n", "[parameters] k_hyd: k_hyd\n", "[dynamic parameters] \n" ] } ], "source": [ "# Upon initiation, the parameters are stored as symbols. We still need to set values to them\n", "# before we can evaluate process rate.\n", "pc1.show()" ] }, { "cell_type": "code", "execution_count": 40, "id": "9d199cf0", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:16.743610Z", "iopub.status.busy": "2026-05-28T00:56:16.742585Z", "iopub.status.idle": "2026-05-28T00:56:16.949371Z", "shell.execute_reply": "2026-05-28T00:56:16.947364Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Process: growth\n", "[stoichiometry] S_S: -1/y_B\n", " O2: (y_B - 1.0)/y_B\n", " CO2: 0.002*(160.0 - 183.0*y_B)/y_B\n", " X_B: 1.00\n", "[reference] X_B\n", "[rate equation] S_S*X_B*mu_B/(K_S + S_S)\n", "[parameters] y_B: y_B\n", " mu_B: mu_B\n", " K_S: K_S\n", "[dynamic parameters] \n" ] } ], "source": [ "# At this point, the initiation of process 3 should be quite straightforward.\n", "# Here shows an alternative way to input stoichiometry\n", "pc3 = qs.Process('decay',\n", " reaction={'X_B':-1, 'X_S':1},\n", " ref_component='X_B',\n", " rate_equation='b*X_B',\n", " conserved_for=('COD',),\n", " parameters=('b',))\n", "\n", "# Note that when initiating a process with unknown stoichiometric coefficients,\n", "# we can use `?` as placeholders in the reaction string.\n", "pc2 = qs.Process('growth',\n", " reaction='[1/y_B]S_S + [?]O2 -> X_B + [?]CO2',\n", " ref_component='X_B',\n", " rate_equation='mu_B * S_S/(K_S+S_S) * X_B',\n", " conserved_for=('COD', 'C'),\n", " parameters=('y_B', 'mu_B', 'K_S'))\n", "pc2.show()" ] }, { "cell_type": "markdown", "id": "68105aaf", "metadata": {}, "source": [ "Because there're two unknown stoichiometric coefficients, we need two conservation principles (i.e., two equations) to determine their values. It is convenient to solve for stoichiometric coefficients marked as `?` by specifying `conserved_for` in the initiation of `Process` objects." ] }, { "cell_type": "markdown", "id": "93a8008d", "metadata": {}, "source": [ "### 2.3. Compiling the processes" ] }, { "cell_type": "code", "execution_count": 41, "id": "18d7995b", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:16.954686Z", "iopub.status.busy": "2026-05-28T00:56:16.953895Z", "iopub.status.idle": "2026-05-28T00:56:16.969660Z", "shell.execute_reply": "2026-05-28T00:56:16.968422Z" }, "scrolled": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CompiledProcesses([hydrolysis, growth, decay])\n" ] } ], "source": [ "# Now the final step is to compile the individual processes into a biokinetic model\n", "bkm = qs.Processes([pc1, pc2, pc3])\n", "bkm.compile()\n", "bkm.show()" ] }, { "cell_type": "markdown", "id": "shared-params-warning", "metadata": {}, "source": [ "
\n", "\n", "**Warning:** Within a single `CompiledProcesses`, parameters across all of its constituent `Process` objects are merged into a **single shared dictionary** on compile. If two processes in the same collection happen to declare the same symbol with different intended meanings, the second one silently overwrites the first. Use distinct parameter symbols within a collection (e.g., `K_S_growth` vs. `K_S_decay`, not `K_S` in both). However, reusing the same symbol in *separate* `CompiledProcesses` collections is fine: each collection has its own parameter dictionary.\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": 42, "id": "0a4159a3", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:16.974869Z", "iopub.status.busy": "2026-05-28T00:56:16.974869Z", "iopub.status.idle": "2026-05-28T00:56:16.984384Z", "shell.execute_reply": "2026-05-28T00:56:16.982375Z" } }, "outputs": [ { "data": { "text/plain": [ "{'k_hyd': k_hyd, 'y_B': y_B, 'mu_B': mu_B, 'K_S': K_S, 'b': b}" ] }, "execution_count": 42, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# All process parameters are merged into one shared dictionary on compile\n", "bkm.parameters" ] }, { "cell_type": "code", "execution_count": 43, "id": "6bec6d90", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:16.988333Z", "iopub.status.busy": "2026-05-28T00:56:16.988333Z", "iopub.status.idle": "2026-05-28T00:56:17.007790Z", "shell.execute_reply": "2026-05-28T00:56:17.005520Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
X_SS_SO2CO2X_BH2O
hydrolysis-110000
growth0-1.25-0.250.03410
decay1000-10
\n", "
" ], "text/plain": [ " X_S S_S O2 CO2 X_B H2O\n", "hydrolysis -1 1 0 0 0 0\n", "growth 0 -1.25 -0.25 0.034 1 0\n", "decay 1 0 0 0 -1 0" ] }, "execution_count": 43, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# After setting parameter values, the model will be ready\n", "bkm.set_parameters(k_hyd=3.0, y_B=0.8, mu_B=4.0, K_S=9.0, b=0.4)\n", "bkm.stoichiometry" ] }, { "cell_type": "code", "execution_count": 44, "id": "f13307e3", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.008402Z", "iopub.status.busy": "2026-05-28T00:56:17.008402Z", "iopub.status.idle": "2026-05-28T00:56:17.038460Z", "shell.execute_reply": "2026-05-28T00:56:17.038460Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
rate_of_production
X_S0.4*X_B - 3.0*X_S
S_S-5.0*S_S*X_B/(S_S + 9.0) + 3.0*X_S
O2-1.0*S_S*X_B/(S_S + 9.0)
CO20.136*S_S*X_B/(S_S + 9.0)
X_B4.0*S_S*X_B/(S_S + 9.0) - 0.4*X_B
H2O0
\n", "
" ], "text/plain": [ " rate_of_production\n", "X_S 0.4*X_B - 3.0*X_S\n", "S_S -5.0*S_S*X_B/(S_S + 9.0) + 3.0*X_S\n", "O2 -1.0*S_S*X_B/(S_S + 9.0)\n", "CO2 0.136*S_S*X_B/(S_S + 9.0)\n", "X_B 4.0*S_S*X_B/(S_S + 9.0) - 0.4*X_B\n", "H2O 0" ] }, "execution_count": 44, "metadata": {}, "output_type": "execute_result" } ], "source": [ "bkm.production_rates" ] }, { "cell_type": "markdown", "id": "737dcc12", "metadata": {}, "source": [ "### 2.4. Batch mode for creating processes\n", "It'd be very tedious to create individual `Process` objects one by one and compile them, especially for models with a large number of parallel processes and/or components. Therefore, the `_process` module includes convenient classmethods to create processes in batch." ] }, { "cell_type": "code", "execution_count": 45, "id": "1b8ebfca", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.044799Z", "iopub.status.busy": "2026-05-28T00:56:17.044096Z", "iopub.status.idle": "2026-05-28T00:56:17.059818Z", "shell.execute_reply": "2026-05-28T00:56:17.059292Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
X_SS_SO2CO2X_BH2OUnnamed: 7
hydrolysis-110000k_hyd*X_S
growth0(-1)/y_B??10mu_B*S_S/(K_S + S_S)*X_B
decay1000-10b*X_B
\n", "
" ], "text/plain": [ " X_S S_S O2 CO2 X_B H2O Unnamed: 7\n", "hydrolysis -1 1 0 0 0 0 k_hyd*X_S\n", "growth 0 (-1)/y_B ? ? 1 0 mu_B*S_S/(K_S + S_S)*X_B\n", "decay 1 0 0 0 -1 0 b*X_B" ] }, "execution_count": 45, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Stoichiometry and rate equations are commonly stored in tabular form\n", "# (.csv, .tsv, or Excel). `Processes.load_from_file` consumes such a table\n", "# directly. Here's the example for our biokinetic model:\n", "from qsdsan.utils import load_data\n", "df_bkm = load_data('assets/_bkm.csv', index_col=0)\n", "df_bkm" ] }, { "cell_type": "markdown", "id": "2d6bd7c2", "metadata": {}, "source": [ "This table contains the stoichiometry matrix and the array of rate equations. If the table is organized with process IDs as row indices and component IDs as column names, we can use it as direct input to the `Processes.load_from_file` classmethod to create processes in batch." ] }, { "cell_type": "code", "execution_count": 46, "id": "8703f7bf", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.064619Z", "iopub.status.busy": "2026-05-28T00:56:17.064619Z", "iopub.status.idle": "2026-05-28T00:56:17.155496Z", "shell.execute_reply": "2026-05-28T00:56:17.155496Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CompiledProcesses([hydrolysis, growth, decay])\n" ] } ], "source": [ "# `conserved_for` is required: pass a tuple to apply the same rules to every\n", "# process. (We'll see two more forms below.)\n", "bkm_batch = qs.Processes.load_from_file(\n", " path='assets/_bkm.csv',\n", " conserved_for=('COD', 'C'),\n", " parameters=('k_hyd', 'y_B', 'mu_B', 'K_S', 'b'),\n", " compile=True,\n", ")\n", "bkm_batch.show()" ] }, { "cell_type": "code", "execution_count": 47, "id": "4105ea73", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.159264Z", "iopub.status.busy": "2026-05-28T00:56:17.159264Z", "iopub.status.idle": "2026-05-28T00:56:17.173154Z", "shell.execute_reply": "2026-05-28T00:56:17.173154Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
X_SS_SO2CO2X_BH2O
hydrolysis-110000
growth0-1.0/y_B1.0*(y_B - 1.0)/y_B0.002*(160.0 - 183.0*y_B)/y_B1.000000000000000
decay1000-10
\n", "
" ], "text/plain": [ " X_S S_S O2 CO2 X_B H2O\n", "hydrolysis -1 1 0 0 0 0\n", "growth 0 -1.0/y_B 1.0*(y_B - 1.0)/y_B 0.002*(160.0 - 183.0*y_B)/y_B 1.00000000000000 0\n", "decay 1 0 0 0 -1 0" ] }, "execution_count": 47, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# You can see `bkm_batch` is equivalent to the `bkm` we created above\n", "bkm_batch.stoichiometry\n", "# bkm_batch.rate_equations\n", "# bkm_batch.parameters" ] }, { "cell_type": "code", "execution_count": 48, "id": "ba36c5ed", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.178139Z", "iopub.status.busy": "2026-05-28T00:56:17.178139Z", "iopub.status.idle": "2026-05-28T00:56:17.185788Z", "shell.execute_reply": "2026-05-28T00:56:17.185788Z" } }, "outputs": [ { "data": { "text/plain": [ "'X_S'" ] }, "execution_count": 48, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# The reference component of each process is inferred from its stoichiometry\n", "bkm_batch.decay.ref_component" ] }, { "cell_type": "markdown", "id": "conserved-uniform-intro", "metadata": {}, "source": [ "Notice that we passed a single tuple `conserved_for=('COD', 'C')` to `load_from_file` above. Unlike the manual construction in section 2.2 (where each `Process` could carry its own `conserved_for`), the tuple form of `load_from_file` applies the same conservation rules to every process in the batch. That's convenient but may not be accurate: the decay process, for instance, doesn't actually conserve carbon in this model. We can see the consequence by running `check_conservation` on the decay process:" ] }, { "cell_type": "code", "execution_count": 49, "id": "57c80cc6", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.190820Z", "iopub.status.busy": "2026-05-28T00:56:17.189519Z", "iopub.status.idle": "2026-05-28T00:56:17.238135Z", "shell.execute_reply": "2026-05-28T00:56:17.236142Z" }, "tags": [ "raises-exception" ] }, "outputs": [ { "ename": "RuntimeError", "evalue": "The following materials are unconserved by the stoichiometric coefficients. A positive value means the material is created, a negative value means the material is destroyed:\n C: -0.05", "output_type": "error", "traceback": [ "\u001b[31m---------------------------------------------------------------------------\u001b[39m", "\u001b[31mRuntimeError\u001b[39m Traceback (most recent call last)", "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[49]\u001b[39m\u001b[32m, line 4\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[38;5;66;03m# Decay's stoichiometry doesn't balance carbon, so check_conservation\u001b[39;00m\n\u001b[32m 2\u001b[39m \u001b[38;5;66;03m# raises a RuntimeError when called against the uniformly-applied\u001b[39;00m\n\u001b[32m 3\u001b[39m \u001b[38;5;66;03m# (\"COD\", \"C\") rules.\u001b[39;00m\n\u001b[32m----> \u001b[39m\u001b[32m4\u001b[39m bkm_batch.decay.check_conservation()\n", "\u001b[36mFile \u001b[39m\u001b[32m~\\Documents\\Coding\\QSDsan-platform\\QSDsan\\qsdsan\\_process.py:505\u001b[39m, in \u001b[36mProcess.check_conservation\u001b[39m\u001b[34m(self, rtol, atol)\u001b[39m\n\u001b[32m 503\u001b[39m materials = \u001b[38;5;28mself\u001b[39m._conserved_for\n\u001b[32m 504\u001b[39m unconserved = [(materials[i], ic_dot_v[i]) \u001b[38;5;28;01mfor\u001b[39;00m i, conserved \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28menumerate\u001b[39m(conserved_arr) \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m conserved]\n\u001b[32m--> \u001b[39m\u001b[32m505\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[33m\"\u001b[39m\u001b[33mThe following materials are unconserved by the \u001b[39m\u001b[33m\"\u001b[39m\n\u001b[32m 506\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mstoichiometric coefficients. A positive value \u001b[39m\u001b[33m\"\u001b[39m\n\u001b[32m 507\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mmeans the material is created, a negative value \u001b[39m\u001b[33m\"\u001b[39m\n\u001b[32m 508\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mmeans the material is destroyed:\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m \u001b[39m\u001b[33m\"\u001b[39m\n\u001b[32m 509\u001b[39m + \u001b[33m\"\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m \u001b[39m\u001b[33m\"\u001b[39m.join([\u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mmaterial\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mvalue\u001b[38;5;132;01m:\u001b[39;00m\u001b[33m.2f\u001b[39m\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m material, value \u001b[38;5;129;01min\u001b[39;00m unconserved]))\n\u001b[32m 510\u001b[39m \u001b[38;5;28;01melif\u001b[39;00m isa(v, \u001b[38;5;28mlist\u001b[39m):\n\u001b[32m 511\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m ic \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n", "\u001b[31mRuntimeError\u001b[39m: The following materials are unconserved by the stoichiometric coefficients. A positive value means the material is created, a negative value means the material is destroyed:\n C: -0.05" ] } ], "source": [ "# Decay's stoichiometry doesn't balance carbon, so check_conservation\n", "# raises a RuntimeError when called against the uniformly-applied\n", "# (\"COD\", \"C\") rules.\n", "bkm_batch.decay.check_conservation()" ] }, { "cell_type": "markdown", "id": "e9e75735", "metadata": {}, "source": [ "The error above tells us decay conserves COD but not carbon under the current `i_C` values. Actually, we wanted decay checked only for COD (matching the manual `pc3` in section 2.2). To recover per-process rules without leaving the batch workflow, pass `conserved_for` as a dict keyed by process ID:" ] }, { "cell_type": "code", "execution_count": 50, "id": "cf5c08b4", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.241922Z", "iopub.status.busy": "2026-05-28T00:56:17.239451Z", "iopub.status.idle": "2026-05-28T00:56:17.249771Z", "shell.execute_reply": "2026-05-28T00:56:17.248657Z" } }, "outputs": [ { "data": { "text/plain": [ "{'X_S': 0.32, 'S_S': 0.32, 'O2': 0.0, 'CO2': 1.0, 'X_B': 0.366, 'H2O': 0.0}" ] }, "execution_count": 50, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# `i_C` values for each component,\n", "# if you want to manually check the `i_C` values for all components in the model\n", "dict(zip(cmps_bkm.IDs, cmps_bkm.i_C))" ] }, { "cell_type": "code", "execution_count": 51, "id": "conserved-dict-load", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.254004Z", "iopub.status.busy": "2026-05-28T00:56:17.254004Z", "iopub.status.idle": "2026-05-28T00:56:17.321430Z", "shell.execute_reply": "2026-05-28T00:56:17.320070Z" } }, "outputs": [], "source": [ "# Dict form: per-process conservation rules. Process IDs absent from the dict\n", "# fall back to the default ('COD', 'N', 'P', 'charge').\n", "bkm_batch = qs.Processes.load_from_file(\n", " path='assets/_bkm.csv',\n", " conserved_for={\n", " 'hydrolysis': (), # known 1-to-1 COD swap, no further check needed\n", " 'growth': ('COD', 'C'), # solves the two `?` coefficients\n", " 'decay': ('COD',), # carbon isn't tracked through decay here\n", " },\n", " parameters=('k_hyd', 'y_B', 'mu_B', 'K_S', 'b'),\n", " compile=True,\n", ")\n", "# decay now passes its own (COD-only) conservation check\n", "bkm_batch.decay.check_conservation()" ] }, { "cell_type": "markdown", "id": "conserved-dict-followup", "metadata": {}, "source": [ "No warning this time: each process is checked against the rules we actually want." ] }, { "cell_type": "markdown", "id": "conserved-file-intro", "metadata": {}, "source": [ "#### Embedding rules in the file\n", "\n", "When the conservation rules are an intrinsic part of the model rather than something to tweak per call, you can carry them inside the data file itself. Add a column named `conserved_for` whose entries are comma-separated material names (empty cell = no conservation). `assets/_bkm_with_conserved.csv` is the same biokinetic model with that column appended:" ] }, { "cell_type": "code", "execution_count": 52, "id": "conserved-file-show", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.323942Z", "iopub.status.busy": "2026-05-28T00:56:17.323942Z", "iopub.status.idle": "2026-05-28T00:56:17.336650Z", "shell.execute_reply": "2026-05-28T00:56:17.336650Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
X_SS_SO2CO2X_BH2Oconserved_forUnnamed: 8
hydrolysis-110000NaNk_hyd*X_S
growth0(-1)/y_B??10COD,Cmu_B*S_S/(K_S + S_S)*X_B
decay1000-10CODb*X_B
\n", "
" ], "text/plain": [ " X_S S_S O2 CO2 X_B H2O conserved_for Unnamed: 8\n", "hydrolysis -1 1 0 0 0 0 NaN k_hyd*X_S\n", "growth 0 (-1)/y_B ? ? 1 0 COD,C mu_B*S_S/(K_S + S_S)*X_B\n", "decay 1 0 0 0 -1 0 COD b*X_B" ] }, "execution_count": 52, "metadata": {}, "output_type": "execute_result" } ], "source": [ "df_bkm_cf = load_data('assets/_bkm_with_conserved.csv', index_col=0)\n", "df_bkm_cf" ] }, { "cell_type": "code", "execution_count": 53, "id": "conserved-file-load", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.340845Z", "iopub.status.busy": "2026-05-28T00:56:17.340845Z", "iopub.status.idle": "2026-05-28T00:56:17.412282Z", "shell.execute_reply": "2026-05-28T00:56:17.412282Z" } }, "outputs": [ { "data": { "text/plain": [ "(('COD',), None)" ] }, "execution_count": 53, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Passing `conserved_for=None` tells `load_from_file` to use the column\n", "# from the file. Processes without a value in the column get the default tuple.\n", "bkm_batch = qs.Processes.load_from_file(\n", " path='assets/_bkm_with_conserved.csv',\n", " conserved_for=None,\n", " parameters=('k_hyd', 'y_B', 'mu_B', 'K_S', 'b'),\n", " compile=True,\n", ")\n", "# decay was assigned ('COD',) directly by the file column\n", "bkm_batch.decay.conserved_for, bkm_batch.decay.check_conservation()" ] }, { "cell_type": "markdown", "id": "conserved-file-outro", "metadata": {}, "source": [ "Same model, same per-process rules as the dict form, but the rules now live alongside the stoichiometry and rate equations. Use whichever form fits your workflow: the tuple form for quick uniform application, the dict form for per-call overrides, and the file column when the rules belong with the model definition." ] }, { "cell_type": "markdown", "id": "dfad74b6", "metadata": {}, "source": [ "## 3. Advanced features \n", "To provide a flexible and user-friendly platform for process modeling, many advanced features are included in the `_process` module to streamline the implementation of process models with various mathematical complexity.\n", "\n", "Let's again use the biokinetic model above as an example to learn about these features. \n", "\n", "### 3.1. `DynamicParameter`\n", "What if the yield of biomass growth is dependent on the abundance of soluble organic substrate? Assume this relation can be described as\n", "$$y_B = y_{max}\\cdot\\sqrt{\\frac{S_S}{X_S + S_S}}$$\n", "\n", "In this case, we will need to define $y_B$ as a `DynamicParameter` object because its value changes with system state during simulation, and we will use the decorators to do so." ] }, { "cell_type": "code", "execution_count": 54, "id": "29c15cb9", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.412282Z", "iopub.status.busy": "2026-05-28T00:56:17.412282Z", "iopub.status.idle": "2026-05-28T00:56:17.422796Z", "shell.execute_reply": "2026-05-28T00:56:17.422796Z" } }, "outputs": [], "source": [ "@pc2.dynamic_parameter(symbol='y_B', params={'y_max':0.8})\n", "def yB_eval(state_arr, params):\n", " X_S, S_S = state_arr[:2] # assuming the first 6 elements are component concentrations\n", " y_max = params['y_max']\n", " y_B = y_max * (S_S/(X_S+S_S))**(1/2)\n", " return y_B" ] }, { "cell_type": "markdown", "id": "f3cb8fee", "metadata": {}, "source": [ "Three things are happening in the cell above:\n", "\n", "- `pc2.dynamic_parameter(...)` is a **decorator factory**: it collects the arguments below and returns the actual decorator that wraps `yB_eval`. (For a refresher on decorators and decorator factories, see the **Python Aside** in the [SanUnit (advanced) tutorial](https://qsdsan.readthedocs.io/en/latest/tutorials/5_SanUnit_advanced.html#3.2.-cost-decorator).)\n", "- `symbol='y_B'` names the dynamic parameter. It must match the symbol used in the process's stoichiometry or rate equation (here, `y_B` from the `[1/y_B]S_S + ...` stoichiometry in `pc2`).\n", "- `params={'y_max': 0.8}` holds fixed values that the evaluation function needs but that do not change during simulation. They are handed back to the function on every call.\n", "\n", "The decorated function `yB_eval` accepts up to two positional arguments (it raises `ValueError` if you declare more):\n", "\n", "- `state_arr`: a numpy array of current state-variable values (component concentrations followed by $Q$, in the order of `cmps.IDs`). Slice it to pick out the components your formula needs.\n", "- `params`: the same dict that was passed to the decorator. Look up `params['y_max']` (and any other fixed inputs).\n", "\n", "It returns a single number — the current value of `y_B` — which QSDsan splices into the process's stoichiometry and rate equation each time `params_eval` (or `production_rates_eval`) runs." ] }, { "cell_type": "code", "execution_count": 55, "id": "7fe90104", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.426969Z", "iopub.status.busy": "2026-05-28T00:56:17.426969Z", "iopub.status.idle": "2026-05-28T00:56:17.434480Z", "shell.execute_reply": "2026-05-28T00:56:17.434480Z" } }, "outputs": [ { "data": { "text/plain": [ "{'k_hyd': 3.0, 'y_B': 0.8, 'mu_B': 4.0, 'K_S': 9.0, 'b': 0.4}" ] }, "execution_count": 55, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# At this point, the value for `y_B` is not updated yet, since we haven't evaluated it\n", "# with input of component concentrations\n", "bkm.parameters" ] }, { "cell_type": "code", "execution_count": 56, "id": "9121e9cc", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.439118Z", "iopub.status.busy": "2026-05-28T00:56:17.434480Z", "iopub.status.idle": "2026-05-28T00:56:17.444736Z", "shell.execute_reply": "2026-05-28T00:56:17.444736Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Process: growth\n", "[stoichiometry] S_S: -1/y_B\n", " O2: (y_B - 1.0)/y_B\n", " CO2: 0.002*(160.0 - 183.0*y_B)/y_B\n", " X_B: 1.00\n", "[reference] X_B\n", "[rate equation] S_S*X_B*mu_B/(K_S + S_S)\n", "[parameters] k_hyd: 3\n", " y_B: 0.8\n", " mu_B: 4\n", " K_S: 9\n", " b: 0.4\n", "[dynamic parameters] \n" ] } ], "source": [ "# But the list of dynamic parameters have been updated\n", "pc2.show()" ] }, { "cell_type": "code", "execution_count": 57, "id": "248e0922", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.447253Z", "iopub.status.busy": "2026-05-28T00:56:17.447253Z", "iopub.status.idle": "2026-05-28T00:56:17.456036Z", "shell.execute_reply": "2026-05-28T00:56:17.455016Z" } }, "outputs": [ { "data": { "text/plain": [ "qsdsan._process.DynamicParameter" ] }, "execution_count": 57, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# `y_B` is now a `DynamicParameter` object stored in the `dynamic_parameters` dict\n", "type(pc2.dynamic_parameters['y_B'])" ] }, { "cell_type": "code", "execution_count": 58, "id": "9dc27821", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.456036Z", "iopub.status.busy": "2026-05-28T00:56:17.456036Z", "iopub.status.idle": "2026-05-28T00:56:17.465678Z", "shell.execute_reply": "2026-05-28T00:56:17.465678Z" }, "scrolled": false }, "outputs": [ { "data": { "text/plain": [ "{'k_hyd': 3.0, 'y_B': 0.5656854249492381, 'mu_B': 4.0, 'K_S': 9.0, 'b': 0.4}" ] }, "execution_count": 58, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Assuming component concentrations are all 1.\n", "state_bkm = np.ones(6)\n", "\n", "# This function evaluates the dynamic parameters\n", "bkm.params_eval(state_bkm)\n", "bkm.parameters" ] }, { "cell_type": "code", "execution_count": 59, "id": "d765098e", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.468633Z", "iopub.status.busy": "2026-05-28T00:56:17.468633Z", "iopub.status.idle": "2026-05-28T00:56:17.482127Z", "shell.execute_reply": "2026-05-28T00:56:17.482127Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
X_SS_SO2CO2X_BH2O
hydrolysis-110000
growth0-1.77-0.7680.210
decay1000-10
\n", "
" ], "text/plain": [ " X_S S_S O2 CO2 X_B H2O\n", "hydrolysis -1 1 0 0 0 0\n", "growth 0 -1.77 -0.768 0.2 1 0\n", "decay 1 0 0 0 -1 0" ] }, "execution_count": 59, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Then the stoichiometry is updated accordingly\n", "bkm.stoichiometry" ] }, { "cell_type": "markdown", "id": "9a01e9ae", "metadata": {}, "source": [ "Note that in simulations, you don't need to call the `params_eval` method explicitly unless you would like to capture the dynamics of parameter values. The evaluation of dynamic parameters is performed within `production_rates_eval` (i.e., the calculation of overall production rates of the components)." ] }, { "cell_type": "markdown", "id": "6b94a0b7", "metadata": {}, "source": [ "### 3.2. `Kinetics` and `MultiKinetics`\n", "`Kinetics` and `MultiKinetics` are classes that allow defining process rates as functions.\n", "\n", "`Kinetics` is a subclass of `DynamicParameter`, because process rate in essence is also a function of state variables and additional static parameters." ] }, { "cell_type": "code", "execution_count": 60, "id": "cb7c404e", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.482127Z", "iopub.status.busy": "2026-05-28T00:56:17.482127Z", "iopub.status.idle": "2026-05-28T00:56:17.493585Z", "shell.execute_reply": "2026-05-28T00:56:17.493585Z" } }, "outputs": [ { "data": { "text/plain": [ "" ] }, "execution_count": 60, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# The use of `Kinetics` to define process rate is also similar\n", "# For example, for the decay process\n", "@pc3.kinetics(parameters={'b':0.4})\n", "def rho3_eval(state_arr, params):\n", " X_B = state_arr[4]\n", " b = params['b']\n", " return b*X_B\n", "\n", "pc3.rate_function" ] }, { "cell_type": "markdown", "id": "157bf1b6", "metadata": {}, "source": [ "This may seem trivial, but it's helpful when the form of process rate gets more complex. For example, the rate of substrate uptake in [anaerobic digestion model no.1](https://doi.org/10.2166/wst.2002.0292) is dependent on pH, which is dynamically determined by a linear equation system and thus cannot be expressed neatly as a function of state variables.\n", "\n", "`MultiKinetics` serves a similar purpose but calculates the rate of multiple processes at once. It is designed to be used with `CompiledProcesses` objects. This is convenient for models with similar process rate algorithms across multiple processes in the sense that vectorizing the shared algorithm can often improve computational speed in simulation. Another application of `MultiKinetics` is models sharing intermediate variables (e.g., pH) across multiple process rate calculation.\n", "\n", "Let's assume the reaction rates of all three processes in the biokinetic model above are now dependent on the abundance of oxygen. Here are the new rate equations:\n", "$$\\rho_1 = k_{hyd}X_S\\cdot \\sqrt{\\frac{O_2}{O_2+K_{O2}}}$$\n", "$$\\rho_2 = \\mu_BX_B\\cdot \\frac{S_S}{K_S+S_S} \\sqrt{\\frac{O_2}{O_2+K_{O2}}}$$\n", "$$\\rho_3 = b\\cdot X_B\\cdot (1.5- \\sqrt{\\frac{O_2}{O_2+K_{O2}}})$$\n", "\n", "How do we use `MultiKinetics` to implement this change?" ] }, { "cell_type": "code", "execution_count": 61, "id": "5fdddd88", "metadata": { "execution": { "iopub.execute_input": "2026-05-28T00:56:17.496255Z", "iopub.status.busy": "2026-05-28T00:56:17.496255Z", "iopub.status.idle": "2026-05-28T00:56:17.505216Z", "shell.execute_reply": "2026-05-28T00:56:17.505216Z" } }, "outputs": [ { "data": { "text/plain": [ "array([1.732, 2.309, 0.369])" ] }, "execution_count": 61, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Define the function\n", "def rhos_eval(state_arr, params):\n", " X_S, S_S, O2, CO2, X_B = state_arr[:5]\n", " I_O2 = (O2/(O2+params['K_O2']))**(1/2)\n", " rho1 = params['k_hyd']*X_S*I_O2\n", " rho2 = params['mu_B']*X_B*I_O2\n", " rho3 = params['b']*X_B*(1.5-I_O2)\n", " return np.array([rho1, rho2, rho3])\n", "\n", "# Set it as the rate function of the `CompiledProcesses` object\n", "bkm.set_rate_function(rhos_eval)\n", "\n", "# Assign parameter values\n", "bkm.rate_function.set_params(K_O2=2.0, **bkm.parameters)\n", "\n", "# The evaluation of the rate function is the same as before\n", "bkm.rate_function(state_bkm)" ] }, { "cell_type": "markdown", "id": "nav-footer-10_process", "metadata": {}, "source": [ "\n", "\n", "---\n", "\n", "↑ Back to top\n", "\n", "↑ Back to top" ] } ], "metadata": { "kernelspec": { "display_name": ".venv", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.7" }, "varInspector": { "cols": { "lenName": 16, "lenType": 16, "lenVar": 40 }, "kernels_config": { "python": { "delete_cmd_postfix": "", "delete_cmd_prefix": "del ", "library": "var_list.py", "varRefreshCmd": "print(var_dic_list())" }, "r": { "delete_cmd_postfix": ") ", "delete_cmd_prefix": "rm(", "library": "var_list.r", "varRefreshCmd": "cat(var_dic_list()) " } }, "types_to_exclude": [ "module", "function", "builtin_function_or_method", "instance", "_Feature" ], "window_display": false } }, "nbformat": 4, "nbformat_minor": 5 }