{ "cells": [ { "cell_type": "markdown", "id": "4b625e5a", "metadata": {}, "source": [ "# `SanUnit` (advanced) \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", " - [Yalin Li](https://github.com/yalinli2)\n", "\n", "- **Learning objectives.** After this tutorial, you will be able to:\n", "\n", " - Subclass `SanUnit` to create a custom unit operation\n", " - Implement `_run`, `_design`, and `_cost` methods\n", " - Integrate the new unit into a `System`\n", "\n", "- **Prerequisites:** [4. SanUnit (basic)](https://qsdsan.readthedocs.io/en/latest/tutorials/4_SanUnit_basic.html)\n", "\n", "- **Covered topics:**\n", "\n", " - 1. Basic structure of SanUnit subclasses\n", " - 2. Making a simple AerobicReactor\n", " - 3. Other convenient features\n", "\n", "> **Companion video.** A walkthrough of this tutorial is available on [YouTube](https://youtu.be/G20J2U8g7Dg), presented by [Hannah Lohman](https://github.com/haclohman). Recorded against `QSDsan` v1.2.0. The concepts still apply, but if the code on screen differs from this notebook, follow the notebook.\n" ] }, { "cell_type": "markdown", "id": "bcd41d4755ad", "metadata": {}, "source": [ "\n", "\n", "## Setup\n", "\n", "Import `QSDsan` and confirm the installed version.\n" ] }, { "cell_type": "code", "execution_count": 1, "id": "940d8811", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:05.769241Z", "iopub.status.busy": "2026-05-31T12:13:05.768240Z", "iopub.status.idle": "2026-05-31T12:13:25.612902Z", "shell.execute_reply": "2026-05-31T12:13:25.612902Z" } }, "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": "a271368b", "metadata": {}, "source": [ "## 1. Basic structure of `SanUnit` subclasses \n", "Building a custom unit means subclassing `SanUnit`. Assuming you are familiar with the topics covered in the [previous tutorial](https://qsdsan.readthedocs.io/en/latest/tutorials/4_SanUnit_basic.html) on `SanUnit`, we can now learn the specifics of creating subclasses." ] }, { "cell_type": "markdown", "id": "0d509dc3", "metadata": {}, "source": [ "New to Python classes (the building blocks for subclassing)? Expand the aside below for a refresher, or see the [resources in the tutorials index](https://qsdsan.readthedocs.io/en/latest/tutorials/index.html#new-to-python-or-jupyter)." ] }, { "cell_type": "markdown", "id": "50232c5b", "metadata": {}, "source": [ "
\n", "Python Aside: classes, methods, attributes, and properties (click to expand)\n", "\n", "**Classes and instances.** A class bundles data and functions; you create instances from it.\n", "\n", "```python\n", "class Apple:\n", " kind = 'fruit' # class attribute (shared by all instances)\n", " def __init__(self, name, color):\n", " self.name = name # instance attributes (per object)\n", " self.color = color\n", " def introduce(self): # instance method (takes self)\n", " print(f'{self.name} is {self.color}')\n", "\n", "gala = Apple('Gala', 'red')\n", "gala.introduce() # Gala is red\n", "```\n", "\n", "**Method kinds.** Instance methods take `self`; a `@classmethod` takes `cls` (e.g. `Components.load_default`); a `@staticmethod` takes neither. The `@` is a *decorator* — a wrapper that adds behavior.\n", "\n", "**Properties.** A `property` looks like an attribute but is computed by getter/setter functions, so you can validate or protect a value (omit the setter to make it read-only):\n", "\n", "```python\n", "class Reactor:\n", " @property\n", " def conversion(self): # getter\n", " return self._conversion\n", " @conversion.setter\n", " def conversion(self, i): # setter\n", " if not 0 <= i <= 1:\n", " raise AttributeError('conversion must be in [0, 1]')\n", " self._conversion = i\n", "```\n", "\n", "**Subclassing** reuses a parent's behavior: `class Apple2(Apple): ...` inherits `introduce`. Subclassing `SanUnit` is exactly how you build a custom unit — which is what the rest of this tutorial does.\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "55b9145f", "metadata": {}, "source": [ "### 1.1. Fundamental methods" ] }, { "cell_type": "markdown", "id": "d4eb523e", "metadata": {}, "source": [ "`SanUnit` itself is a subclass of BioSTEAM's `Unit` class. For more details, check out [BioSTEAM's documentation](https://biosteam.readthedocs.io/en/latest/tutorial/Inheriting_from_Unit.html)." ] }, { "cell_type": "markdown", "id": "fc08b63d", "metadata": {}, "source": [ "In addition to the `__init__` method for initialization, all `SanUnit` objects have three most fundamental methods (they all start with `_`, as users typically don't interact with them):\n", "\n", "- `_run`, which is used for mass and energy calculation within a unit operation (e.g., if you have an anaerobic reactor that will convert 80% of the organics in the influent, you'll want to put it in `_run`\n", "\n", " - There is also a `run` method that will call the `_run` method and any `specification` functions you define, but we will skip it for now\n", " \n", "- `_design`, this method will be called after `_run` (when you have a `System` with multiple units, then `_design` will only be called *after* all units within the system have converged). The `_design` method contains algorithms that are used in designing the unit (e.g., volume, dimensions, materials)\n", " \n", " - Material inventories calculated in `_design` are usually stored in the `design_results` dict of the unit with keys being names (`str`) of the item and values being quantities of the item\n", " - All entries in the `design_results` dict should have corresponding entries in the `_units` dict to provide units of measure to the values in the `design_results` dict\n", "\n", "- `_cost`, which will be called after `_design` to get the cost of the unit, it may leverage the inventories calculated in `_run` or `_design`\n", "\n", " - The baseline purchase cost of each inventory item is usually stored in the `baseline_purchase_costs` dict, and installed cost of this item will be calculated using different factors (`F_BM`, `F_D`, `F_P`, and `F_M` for bare module, design, pressure, and material factors, they are all dict).\n", " - Each factor defaults to 1 if not given, but if a cost item is missing from `F_BM`, BioSTEAM still costs it (bare-module factor 1) while emitting a `RuntimeWarning`. Declare the factor to silence the warning and be explicit: per item with a class-level `_F_BM_default` dict (e.g., `_F_BM_default = {'Tank': 2}`) or by setting `self.F_BM['Tank'] = 2`, or set every item to 1 at once with the `F_BM_default=1` initialization argument (handy when a unit's purchase costs already equal its installed costs)." ] }, { "cell_type": "markdown", "id": "ca158279", "metadata": {}, "source": [ "These methods will be aggregated into a `simulate` function that will call these methods (and do some other minor stuff)." ] }, { "cell_type": "markdown", "id": "fae55b86", "metadata": {}, "source": [ "
\n", "\n", "**Note:** You do NOT need to use all of these methods, and you do not need to strictly follow the functionalities above. For \n", "example, you can put cost algorithms in `_design` or even `_run`, but the latter will be strongly discouraged unless you have a good reason, as the cost algorithms will be run a lot of times when the `System` is trying to converge, which adds unnecessary overheads.\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "65f77a7b", "metadata": {}, "source": [ "
\n", "\n", "**Heads up: a unit with no `_run`.** If a unit has exactly one inlet and one outlet and you do not define `_run`, It is treated as a *static pass-through* and links the outlet to the inlet, so the two share the same flow data (re-simulating can then drain the feed). That is handy for a genuine pass-through, but it is a trap for an **abstract base class** meant only to be subclassed, where each subclass supplies its own `_run`. Declare such a base with `isabstract=True` to opt out. See example in the [TEA tutorial](https://qsdsan.readthedocs.io/en/latest/tutorials/7_TEA.html).\n", "\n", "
\n", "\n", "#### Putting them together with `simulate` \n", "\n", "When you call `unit.simulate()`, the unit runs in three stages: first `_run` (mass/energy balance and outlet streams), then `_summary` which invokes `_design` (size/geometry) and then `_cost` (purchase and installed cost). The same applies to `system.simulate()`, which dispatches `simulate()` to each unit in convergence order.\n", "\n", "A common pitfall: calling `_run` directly (for debugging, or by reaching into a unit programmatically) leaves `_design` and `_cost` untouched. The unit's outlet streams update, but its design table and cost results stay stale from the previous full simulation, which then propagates into TEA results. Always go through `simulate()` (on the unit or on the enclosing system) when you want results that downstream analysis will read.\n", "\n", "
\n", "\n", "**Heads up.** Some other tutorial cells call `simulate()` without explaining what it glues together; this is what they mean. See [Modeling Notes & Pitfalls §4.3](14_Modeling_Notes_and_Pitfalls.ipynb#2.4.-simulate-does-more-than-_run) for a worked symptom (a parameter sweep where cost never changes).\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "c55e4e63", "metadata": {}, "source": [ "### 1.2. Useful attributes\n", "Some of the class attributes that you will find useful in making your subclasses:\n", "\n", "- `_N_ins` and `_N_outs` set the number of influents and effluents of the `SanUnit`\n", "\n", " - If you are unsure of how many influents and/or effluents there will be (e.g., they can be dynamic for a mixer), you can instead set `_ins_size_is_fixed` and/or `_outs_size_is_fixed` to `False`\n", " \n", "- `construction` and `transportation` are tuple of `Construction` and `Transportation` objects for life cycle assessment (will be covered in later tutorials)\n", "- `purchase_cost` (float) and `purchase_costs` (dict) contain the total (without the `s`) and itemized purchase costs of this unit (i.e., `purchase_cost` is the sum of all the values in the `purchase_costs` dict). Purchase cost of an item is calculated by multiplying the value in the `baseline_purchase_cost` dict with the corresponding values in the `F_` dict\n", "\n", " - Similarly, `installed_cost` (float) and `installed_costs` (dict) are the total and itemized *installed* costs. For each item the installed cost is `baseline_purchase_cost × (F_BM + F_D × F_P × F_M - 1)`, so the bare-module factor `F_BM` adds installation, piping, etc. on top of the design/pressure/material adjustments\n", "\n", "- `add_OPEX` (float or `dict`, USD/hr) for operating costs beyond utilities (e.g., chemicals, labor), `uptime_ratio` (0–1) for the fraction of time the unit runs, and `lifetime` (years), which drives replacement costs in TEA/LCA\n", "- `F_mass_in` (float), `mass_in` (np.array containing the *mass* of each component), `z_mass_in` (np.array containing the *mass fraction* of each component)\n", " \n", " - Additionally, there are `F_mass_out`, `mass_out`, and `z_mass_out`, and the corresponding sets for molar and volume flows (e.g., `F_mol_in`/`F_vol_in`, etc.)\n", "\n", "- `H_in`/`H_out` (changes with T) and `Hf_in`/`Hf_out` (doesn't change with T), enthalpy and enthalpy of formation of influents and effluents, respectively\n", " \n", " - There is also another attribute `Hnet` calculated as `(H_out-H_in)+(Hf_out-Hf_in)`\n", "\n", "- `_graphics`, how the unit will be represented when you call the `diagram` function. If not given, it will be defaulted to a box\n", "\n", " - Note that if you make the subclasses of `Mixer`/`Splitter`/`HXutility`/`HXprocess`, the default graphics will be different because these units have their corresponding graphics\n", " \n", "- `results`, a method (i.e., you need to call it by `.results()` instead of just `.result`) to give you a quick summary of the unit design and cost results" ] }, { "cell_type": "markdown", "id": "0572dc37", "metadata": {}, "source": [ "## 2. Making a simple AerobicReactor \n", "Alright, all those descriptions are abstract enough, and there are many details that will be best covered in an example. So let's assume we want to design a *very* simple aerobic reactor that will convert 90% of the influent organics into CO2 and H2O." ] }, { "cell_type": "code", "execution_count": 2, "id": "0023c5f7", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:25.617709Z", "iopub.status.busy": "2026-05-31T12:13:25.616522Z", "iopub.status.idle": "2026-05-31T12:13:25.622653Z", "shell.execute_reply": "2026-05-31T12:13:25.621565Z" } }, "outputs": [], "source": [ "# By convention, class names use CapitalizedWords (CamelCase).\n", "# This is the simplest possible AerobicReactor:\n", "class AerobicReactor1(qs.SanUnit):\n", " def __init__(self, ID='', ins=None, outs=(), thermo=None, init_with='WasteStream'):\n", " qs.SanUnit.__init__(self, ID, ins, outs, thermo, init_with)\n", " \n", " def _run(self):\n", " pass\n", " \n", " def _design(self):\n", " pass\n", " \n", " def _cost(self):\n", " pass" ] }, { "cell_type": "code", "execution_count": 3, "id": "b7b698f2", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:25.625942Z", "iopub.status.busy": "2026-05-31T12:13:25.625406Z", "iopub.status.idle": "2026-05-31T12:13:25.912117Z", "shell.execute_reply": "2026-05-31T12:13:25.911103Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CompiledComponents([\n", " S_H2, S_CH4, S_CH3OH, S_Ac, \n", " S_Prop, S_F, S_U_Inf, S_U_E, \n", " C_B_Subst, C_B_BAP, C_B_UAP, C_U_Inf, \n", " X_B_Subst, X_OHO_PHA, X_GAO_PHA, X_PAO_PHA,\n", " X_GAO_Gly, X_PAO_Gly, X_OHO, X_AOO, \n", " X_NOO, X_AMO, X_PAO, X_MEOLO, \n", " X_FO, X_ACO, X_HMO, X_PRO, \n", " X_U_Inf, X_U_OHO_E, X_U_PAO_E, X_Ig_ISS, \n", " X_MgCO3, X_CaCO3, X_MAP, X_HAP, \n", " X_HDP, X_FePO4, X_AlPO4, X_AlOH, \n", " X_FeOH, X_PAO_PP_Lo, X_PAO_PP_Hi, S_NH4, \n", " S_NO2, S_NO3, S_PO4, S_K, \n", " S_Ca, S_Mg, S_CO3, S_N2, \n", " S_O2, S_CAT, S_AN, H2O, \n", " O2, CO2, \n", "])\n" ] } ], "source": [ "# The default components are indeed useful!\n", "cmps_default = qs.Components.load_default()\n", "kwargs = {'particle_size': 'Dissolved gas',\n", " 'degradability': 'Undegradable',\n", " 'organic': False}\n", "O2 = qs.Component('O2', search_ID='O2', **kwargs)\n", "CO2 = qs.Component('CO2', search_ID='CO2', **kwargs)\n", "cmps = qs.Components([*cmps_default, O2, CO2])\n", "cmps.compile(ignore_inaccurate_molar_weight=True) # some components have inaccurate molar weight, but we don't care about that here\n", "qs.set_thermo(cmps)\n", "cmps.show()" ] }, { "cell_type": "code", "execution_count": 4, "id": "5d83a6b8", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:25.915134Z", "iopub.status.busy": "2026-05-31T12:13:25.915134Z", "iopub.status.idle": "2026-05-31T12:13:26.404387Z", "shell.execute_reply": "2026-05-31T12:13:26.403372Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "WasteStream: ws\n", "phase: 'l', T: 298.15 K, P: 101325 Pa\n", "flow (g/hr): S_CH3OH 500\n", " H2O 1e+06\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ " WasteStream-specific properties:\n", " pH : 7.0\n", " Alkalinity : 2.5 mmol/L\n", " COD : 498.2 mg/L\n", " BOD : 357.2 mg/L\n", " TC : 124.7 mg/L\n", " TOC : 124.7 mg/L\n", " Component concentrations (mg/L):\n", " S_CH3OH 498.2\n", " H2O 996432.8\n" ] } ], "source": [ "# Now make a fake waste stream with these components\n", "ws = qs.WasteStream('ws', H2O=1000, S_CH3OH=0.5, units='kg/hr')\n", "ws.show()" ] }, { "cell_type": "code", "execution_count": 5, "id": "cec15554", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:26.406388Z", "iopub.status.busy": "2026-05-31T12:13:26.406388Z", "iopub.status.idle": "2026-05-31T12:13:26.872712Z", "shell.execute_reply": "2026-05-31T12:13:26.872712Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "AerobicReactor1: R1\n", "ins...\n", "[0] ws\n", "phase: 'l', T: 298.15 K, P: 101325 Pa\n", "flow (g/hr): S_CH3OH 500\n", " H2O 1e+06\n", " WasteStream-specific properties:\n", " pH : 7.0\n", " Alkalinity : 2.5 mmol/L\n", " COD : 498.2 mg/L\n", " BOD : 357.2 mg/L\n", " TC : 124.7 mg/L\n", " TOC : 124.7 mg/L\n", "outs...\n", "[0] ws1\n", "phase: 'l', T: 298.15 K, P: 101325 Pa\n", "flow: 0\n", " WasteStream-specific properties: None for empty waste streams\n" ] } ], "source": [ "R1 = AerobicReactor1('R1', ins=ws)\n", "R1.simulate()\n", "R1.show()" ] }, { "cell_type": "markdown", "id": "23342c0b", "metadata": {}, "source": [ "OK, with these simple setups, we can \"sort of\" see something, but without the methods above we aren't really doing anything useful, so let's try to implement those methods" ] }, { "cell_type": "code", "execution_count": 6, "id": "0bc1eaeb", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:26.872712Z", "iopub.status.busy": "2026-05-31T12:13:26.872712Z", "iopub.status.idle": "2026-05-31T12:13:26.886737Z", "shell.execute_reply": "2026-05-31T12:13:26.886737Z" } }, "outputs": [], "source": [ "class AerobicReactor2(qs.SanUnit):\n", " def __init__(self, ID='', ins=None, outs=(), thermo=None, init_with='WasteStream',\n", " conversion=0.9, # default conversion to be 0.9,\n", " aeration_rate=5, # assume we need 5 g/L of O2 pumped into the system\n", " HRT=5, # hydraulic residence time being 5 hours\n", " ):\n", " # Some standard codes you need to include for all subclasses of `SanUnit`\n", " qs.SanUnit.__init__(self, ID, ins, outs, thermo, init_with)\n", " # These are the unique attribures of `AerobicReactor`\n", " self.conversion = conversion\n", " self.aeration_rate = aeration_rate\n", " self.HRT = HRT\n", " \n", " # Assume a bare module factor of 2\n", " self.F_BM = {'Tank': 2}\n", "\n", " \n", " # Assume we'll have two influents - the waste stream and O2,\n", " # as well as two effluents - treated waste stream and the generated CO2\n", " _N_ins = 2\n", " _N_outs = 2\n", " \n", " def _run(self):\n", " # This is equivalent to\n", " # inf=self.ins[0]\n", " # o2=self.ins[1]\n", " inf, o2 = self.ins\n", " \n", " eff, co2 = self.outs\n", " o2.phase = co2.phase = 'g'\n", " \n", " # Firstly let's calculate how much O2 we need,\n", " # g/L (kg/m3) * m3/hr = kg/hr\n", " o2_needed = self.aeration_rate * self.F_vol_in\n", " o2.imass['O2'] = o2_needed # `imass` in kg/hr\n", " \n", " # Mix the influent streams\n", " eff.mix_from(self.ins)\n", " \n", " # O2 gas turned into dissolved O2\n", " eff.imass['S_O2'] = eff.imass['O2']\n", " eff.imass['O2'] = 0\n", " \n", " # Then we will want convert the organics,\n", " # for demo purpose let's make it very simple,\n", " # assume that we know ahead of time that\n", " # we will only have `S_CH3OH`\n", " # so reaction will be\n", " # CH3OH + 1.5 O2 -> CO2 + 2H2O\n", " # with the conversion defined by the user\n", " x = self.conversion\n", " converted_meoh = x * inf.imol['S_CH3OH']\n", " consumed_o2 = 1.5 * converted_meoh\n", " generated_co2 = converted_meoh\n", " generated_h2o = 2 * converted_meoh\n", " eff.imol['S_CH3OH'] -= converted_meoh\n", " eff.imol['S_O2'] -= consumed_o2\n", " eff.imol['H2O'] += generated_h2o\n", " co2.imol['CO2'] = generated_co2\n", " # Assume 5 wt% of MeOH is turned into biomass\n", " eff.imass['X_OHO'] = 0.05 * inf.imass['S_CH3OH']\n", " \n", " \n", " # We can (or seems more straightfoward to) move this into\n", " # the `_design` method, but since these units won't change\n", " # putting it here will save some simulation time\n", " _units = {\n", " 'Volume': 'm3',\n", " 'Diameter': 'm',\n", " 'Height': 'm',\n", " 'Stainless steel': 'kg'\n", " }\n", " \n", " # As for the design, let's assume we will have a \n", " # cylinder with a height-to-diameter ratio of 2:1\n", " def _design(self):\n", " D = self.design_results\n", " tot_vol = self.outs[0].F_vol*self.HRT\n", " rx_vol = tot_vol / 0.8 # assume 80% working volume\n", " # You can certainly do `import math; math.pi`\n", " dia = (2*rx_vol/3.14)**(1/3)\n", " D['Volume'] = rx_vol\n", " D['Diameter'] = dia\n", " D['Height'] = H = 2 * dia\n", " \n", " # Assume the tank has a thickness of 3 cm,\n", " # we'll need the cover, but not the bottom\n", " ss = 3.14*(dia**2)*H + 3.14/4*(dia**2)\n", " # Assume the density is 7,500 kg/m3\n", " D['Stainless steel'] = ss * 7500\n", " \n", " # Let's assume that the reactor is\n", " # made of stainless steel with a price of $3/kg\n", " def _cost(self):\n", " self.baseline_purchase_costs['Tank'] = \\\n", " 3 * self.design_results['Stainless steel']\n", " # Assume the electricity usage is proportional to the\n", " # volumetric flow rate\n", " self.power_utility.consumption = 0.1 * self.outs[0].F_vol\n", " \n", " \n", " # Now it's a proper use of property,\n", " # see the text enclosed in the pair of triple quotes?\n", " # That's the documentation (e.g., the helpful prompt\n", " # that will show up when users do\n", " # `?AerobicReactor.conversion`)\n", " @property\n", " def conversion(self):\n", " '''[float] Conversion of the organic matters in this reactor.'''\n", " return self._conversion\n", " @conversion.setter\n", " def conversion(self, i):\n", " if not 0 <= i <= 1:\n", " # Include the specific values in the error messgae\n", " # will often help the users (and many times you) in debugging\n", " raise AttributeError('`conversion` must be within [0, 1], '\n", " f'the provided value {i} is outside this range.')\n", " self._conversion = i" ] }, { "cell_type": "code", "execution_count": 7, "id": "012e5232", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:26.886737Z", "iopub.status.busy": "2026-05-31T12:13:26.886737Z", "iopub.status.idle": "2026-05-31T12:13:26.892626Z", "shell.execute_reply": "2026-05-31T12:13:26.892626Z" } }, "outputs": [], "source": [ "# Let's set up this unit again\n", "R2 = AerobicReactor2('R2', ins=(ws.copy(), 'o2'), outs=('eff', 'co2'))" ] }, { "cell_type": "code", "execution_count": 8, "id": "39606d8f", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:26.892626Z", "iopub.status.busy": "2026-05-31T12:13:26.892626Z", "iopub.status.idle": "2026-05-31T12:13:26.940770Z", "shell.execute_reply": "2026-05-31T12:13:26.940770Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Aerobic Reactor2 Units R2\n", "Electricity Power kW 0.101\n", " Cost USD/hr 0.00789\n", "Design Volume m3 6.3\n", " Diameter m 1.59\n", " Height m 3.18\n", " Stainless steel kg 2.04e+05\n", "Purchase cost Tank USD 6.12e+05\n", "Total purchase cost USD 6.12e+05\n", "Utility cost USD/hr 0.00789\n" ] } ], "source": [ "# Voila!\n", "R2.simulate()\n", "print(R2.results())" ] }, { "cell_type": "code", "execution_count": 9, "id": "aa291b21", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:26.944297Z", "iopub.status.busy": "2026-05-31T12:13:26.940770Z", "iopub.status.idle": "2026-05-31T12:13:27.835046Z", "shell.execute_reply": "2026-05-31T12:13:27.834033Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "AerobicReactor2: R2\n", "ins...\n", "[0] ws2\n", "phase: 'l', T: 298.15 K, P: 101325 Pa\n", "flow (g/hr): S_CH3OH 500\n", " H2O 1e+06\n", " WasteStream-specific properties:\n", " pH : 7.0\n", " Alkalinity : 2.5 mmol/L\n", " COD : 498.2 mg/L\n", " BOD : 357.2 mg/L\n", " TC : 124.7 mg/L\n", " TOC : 124.7 mg/L\n", "[1] o2\n", "phase: 'g', T: 298.15 K, P: 101325 Pa\n", "flow: 5.02e+03 g/hr O2\n", " WasteStream-specific properties: None for non-liquid waste streams\n", "outs...\n", "[0] eff\n", "phase: 'l', T: 296.83 K, P: 101325 Pa\n", "flow (g/hr): S_CH3OH 50\n", " X_OHO 25\n", " S_O2 4.34e+03\n", " H2O 1e+06\n", " WasteStream-specific properties:\n", " pH : 33.5\n", " Alkalinity : 2.5 mmol/L\n", " COD : 74.4 mg/L\n", " BOD : 49.6 mg/L\n", " TC : 21.5 mg/L\n", " TOC : 21.5 mg/L\n", " TN : 1.7 mg/L\n", " TP : 0.5 mg/L\n", " TK : 0.1 mg/L\n", " TSS : 19.3 mg/L\n", "[1] co2\n", "phase: 'g', T: 298.15 K, P: 101325 Pa\n", "flow: 618 g/hr CO2\n", " WasteStream-specific properties: None for non-liquid waste streams\n" ] } ], "source": [ "R2.show()" ] }, { "cell_type": "code", "execution_count": 10, "id": "b28cd9ae", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:27.838046Z", "iopub.status.busy": "2026-05-31T12:13:27.838046Z", "iopub.status.idle": "2026-05-31T12:13:28.376550Z", "shell.execute_reply": "2026-05-31T12:13:28.376550Z" }, "tags": [ "raises-exception" ] }, "outputs": [ { "ename": "AttributeError", "evalue": "`conversion` must be within [0, 1], the provided value 1.1 is outside this range.", "output_type": "error", "traceback": [ "\u001b[31m---------------------------------------------------------------------------\u001b[39m", "\u001b[31mAttributeError\u001b[39m Traceback (most recent call last)", "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[10]\u001b[39m\u001b[32m, line 2\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[38;5;66;03m# Trying to put an unrealistic value will show our helpful message\u001b[39;00m\n\u001b[32m----> \u001b[39m\u001b[32m2\u001b[39m R2.conversion = \u001b[32m1.1\u001b[39m\n", "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[6]\u001b[39m\u001b[32m, line 116\u001b[39m, in \u001b[36mAerobicReactor2.conversion\u001b[39m\u001b[34m(self, i)\u001b[39m\n\u001b[32m 112\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m conversion(self, i):\n\u001b[32m 113\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28;01mnot\u001b[39;00m \u001b[32m0\u001b[39m <= i <= \u001b[32m1\u001b[39m:\n\u001b[32m 114\u001b[39m \u001b[38;5;66;03m# Include the specific values in the error messgae\u001b[39;00m\n\u001b[32m 115\u001b[39m \u001b[38;5;66;03m# will often help the users (and many times you) in debugging\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m116\u001b[39m raise AttributeError('`conversion` must be within [0, 1], '\n\u001b[32m 117\u001b[39m f'the provided value {i} is outside this range.')\n\u001b[32m 118\u001b[39m self._conversion = i\n", "\u001b[31mAttributeError\u001b[39m: `conversion` must be within [0, 1], the provided value 1.1 is outside this range." ] } ], "source": [ "# Trying to put an unrealistic value will show our helpful message\n", "R2.conversion = 1.1" ] }, { "cell_type": "markdown", "id": "6ca407d3", "metadata": {}, "source": [ "## 3. Other convenient features \n", "We've done a good job in making the `AerobicReactor` class, but there are many helpful features that will make our lives much easier" ] }, { "cell_type": "markdown", "id": "62d8abf1", "metadata": {}, "source": [ "### 3.1. Reactions\n", "Here's what we did for the reaction of MeOH and O2 to CO2 and H2O:\n", "```python\n", "converted_meoh = x * inf.imol['S_CH3OH']\n", "consumed_o2 = 1.5 * converted_meoh\n", "generated_co2 = converted_meoh\n", "generated_h2o = 2 * converted_meoh\n", "eff.imol['S_CH3OH'] -= converted_meoh\n", "eff.imol['S_O2'] -= consumed_o2\n", "eff.imol['H2O'] += generated_h2o\n", "co2.imol['CO2'] = generated_co2\n", "```" ] }, { "cell_type": "markdown", "id": "e1e2d827", "metadata": {}, "source": [ "For reactions like this, we can actually use `Reaction` to do it in a much more convenient way:\n", "```python\n", "from qsdsan import Reaction as Rxn\n", "# reaction definition reactant conversion\n", "aerobic_rxn = Rxn('S_CH3OH + 1.5 O2 -> CO2 + 2 H2O', 'S_CH3OH', self.conversion)\n", "```\n", "\n", "If we have multiple reactions, we can use `qs.ParallelReaction` (if all reactions happen at once) or `qs.SeriesReaction` (if these reactions happen in sequence), and we can use `qs.ReactionSystem` to compile multiple `qs.Reaction`, `qs.ParallelReaction`, and `qs.SeriesReaction` together.\n", "\n", "These classes are inherited from BioSTEAM. For more detailed instructions, refer to [BioSTEAM's documentation](https://biosteam.readthedocs.io/en/latest/tutorial/Stoichiometric_reactions.html)." ] }, { "cell_type": "markdown", "id": "a6109a6e", "metadata": {}, "source": [ "### 3.2. `cost` decorator\n", "If we want to scale the cost of some equipment base on certain variables (e.g., scale the capital cost and electricity of a pump based on the flow rate, we can use the `cost` decorator (usage of decorator starts with the `@` symbol, again recall the `property` decorator).\n", "\n", "For the demo purpose, let's assume that we need a pump for the aeration that will be scaled based on the mass flow rate of needed O2\n", "```python\n", "from qsdsan.utils import cost\n", "\n", "@cost('O2 flow rate', # the variable that the equipment is scaled on\n", " 'O2 pump', # name of the equipment\n", " CEPCI=522, # reference-year cost index (qsdsan accepts CEPCI=; BioSTEAM calls it CE)\n", " S=40000, # value of the scaling basis\n", " cost=22500, # cost of the equipment when the variable is at the basis value\n", " n=0.8, # exponential scaling factor\n", " kW=75, # electricity usage\n", " N=1, # number of this equipment (will be defaulted to 1 if not given)\n", " BM=2.3) # bare module\n", "class AerobicReactor(qs.SanUnit):\n", " ...\n", "\n", " # Note that in the unit, you'll need to define what 'O2 flow rate' is\n", " # in the `design_results` dict and its unit in the `_units` dict\n", " _units = {\n", " ...,\n", " 'O2 flow rate': 'kg/hr',\n", " }\n", " \n", " def _design(self):\n", " ...\n", " self.design_results['O2 flow rate'] = self.outs[1].F_mass\n", "```\n", "\n", "The scaling equations are (`ub` is the upper bound):\n", "\n", "$$New\\ cost = N \\cdot cost \\bigg(\\frac{CEPCI_{new}}{CEPCI}\\bigg) \\bigg(\\frac{S_{new}}{N \\cdot S}\\bigg)^{n}$$\n", " \n", "$$Electricity\\ rate = kW \\bigg(\\frac{S_{new}}{S}\\bigg)$$\n", "\n", "$$N = ceil \\bigg( \\frac{S_{new}}{ub} \\bigg)$$" ] }, { "cell_type": "markdown", "id": "decorator-aside", "metadata": {}, "source": [ "
\n", "Python Aside: decorators and decorator factories (click to expand)\n", "\n", "A **decorator** is a callable that takes a function (or class) and returns a (usually modified) replacement. The `@deco` syntax above a definition is shorthand:\n", "\n", "```python\n", "@deco\n", "def f():\n", " ...\n", "\n", "# is equivalent to:\n", "def f():\n", " ...\n", "f = deco(f)\n", "```\n", "\n", "Decorators you have already met: `@property` (turns a method into a getter), `@classmethod`, `@staticmethod`.\n", "\n", "**Decorator factories** are decorators that take arguments. `@cost('O2 flow rate', ...)` is not the decorator itself — it is a *call* that *returns* the decorator. The two-step desugaring is:\n", "\n", "```python\n", "@cost('O2 flow rate', 'O2 pump', CEPCI=522, ...)\n", "class AerobicReactor3(qs.SanUnit):\n", " ...\n", "\n", "# is equivalent to:\n", "class AerobicReactor3(qs.SanUnit):\n", " ...\n", "the_decorator = cost('O2 flow rate', 'O2 pump', CEPCI=522, ...) # call returns the decorator\n", "AerobicReactor3 = the_decorator(AerobicReactor3) # apply it\n", "```\n", "\n", "So when you see `@something(...)` with parentheses, the parentheses signal a factory; the *result* of that call is what wraps your function or class. You will meet several more QSDsan-specific decorator factories later — for example `@process.dynamic_parameter(symbol='y_B', params={...})` and `@process.kinetics(parameters={...})` in the [Process tutorial](https://qsdsan.readthedocs.io/en/latest/tutorials/10_Process.html), and `@unit.add_specification(run=True)` later in this tutorial.\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "5f652e9b", "metadata": {}, "source": [ "
\n", "\n", "**Note:** You can actually add the unit in the `@` expression, e.g., \n", "```python\n", "@cost('O2 flow rate', ..., units='kg/hr')\n", "```\n", "But if later you define `_units` in the class definition by using \n", "```python\n", "_units = {\n", " 'Reactor volume': 'm3',\n", " 'Diameter': 'm',\n", " 'Height': 'm',\n", " 'Stainless steel': 'kg'\n", "}\n", "```\n", "You'll throw away the previous definition.\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "cepci_intro", "metadata": {}, "source": [ "**What is CEPCI?** The Chemical Engineering Plant Cost Index (CEPCI) tracks how the cost of building chemical process equipment changes over time (equipment, materials, labor, and so on). Cost correlations like the one above are anchored to a specific reference year, recorded by the `CEPCI` argument (here `CEPCI=522`). To restate a cost in another year's dollars, it is scaled by $CEPCI_{new}/CEPCI$, where $CEPCI_{new}$ is the index for the target year (the $\\frac{CEPCI_{new}}{CEPCI}$ term in the equation above).\n", "\n", "The live $CEPCI_{new}$ used for this scaling is `qs.CEPCI`; set it to put your cost estimates in a chosen year's dollars. `QSDsan` provides the published index values by year in `qs.CEPCI_by_year`, so you can do, e.g., `qs.CEPCI = qs.CEPCI_by_year[2023]`.\n", "\n", "
\n", "\n", "**Note.** BioSTEAM abbreviates this index as `CE` (the `@cost` argument). For consistency with `qs.CEPCI`/`qs.CEPCI_by_year`, the `@cost` decorator imported from `qsdsan.utils` also accepts `CEPCI=` as an alias, so `@cost(..., CEPCI=522)` is equivalent to `@cost(..., CE=522)`. Both work; we use the `CEPCI` spelling here.\n", "\n", "
" ] }, { "cell_type": "code", "execution_count": 11, "id": "0571529f", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:28.376550Z", "iopub.status.busy": "2026-05-31T12:13:28.376550Z", "iopub.status.idle": "2026-05-31T12:13:28.386152Z", "shell.execute_reply": "2026-05-31T12:13:28.385624Z" } }, "outputs": [ { "data": { "text/plain": [ "567.5" ] }, "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# The current index used for cost scaling\n", "qs.CEPCI" ] }, { "cell_type": "code", "execution_count": 12, "id": "50cd1b9f", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:28.388224Z", "iopub.status.busy": "2026-05-31T12:13:28.387190Z", "iopub.status.idle": "2026-05-31T12:13:28.395893Z", "shell.execute_reply": "2026-05-31T12:13:28.395893Z" } }, "outputs": [ { "data": { "text/plain": [ "{1980: 261,\n", " 1981: 297,\n", " 1982: 314,\n", " 1983: 317,\n", " 1984: 323,\n", " 1985: 325,\n", " 1986: 318,\n", " 1987: 324,\n", " 1988: 343,\n", " 1989: 355,\n", " 1990: 357.6,\n", " 1991: 361.3,\n", " 1992: 358.2,\n", " 1993: 359.2,\n", " 1994: 368.1,\n", " 1995: 381.1,\n", " 1996: 381.7,\n", " 1997: 386.5,\n", " 1998: 389.5,\n", " 1999: 390.6,\n", " 2000: 394.1,\n", " 2001: 394.3,\n", " 2002: 395.6,\n", " 2003: 402.0,\n", " 2004: 444.2,\n", " 2005: 468.2,\n", " 2006: 499.6,\n", " 2007: 525.4,\n", " 2008: 575.4,\n", " 2009: 521.9,\n", " 2010: 550.8,\n", " 2011: 585.7,\n", " 2012: 584.6,\n", " 2013: 567.3,\n", " 2014: 576.1,\n", " 2015: 556.8,\n", " 2016: 541.7,\n", " 2017: 567.5,\n", " 2018: 603.1,\n", " 2019: 607.5,\n", " 2020: 596.2,\n", " 2021: 708.8,\n", " 2022: 816.0,\n", " 2023: 797.9}" ] }, "execution_count": 12, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# If you want to look up CEPCI by year\n", "qs.CEPCI_by_year" ] }, { "cell_type": "code", "execution_count": null, "id": "04ba5577", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:28.395893Z", "iopub.status.busy": "2026-05-31T12:13:28.395893Z", "iopub.status.idle": "2026-05-31T12:13:28.411632Z", "shell.execute_reply": "2026-05-31T12:13:28.411124Z" } }, "outputs": [], "source": [ "# Let's use `Reaction` and `cost` decorator here\n", "from qsdsan import Reaction as Rxn\n", "from qsdsan.utils import cost\n", "@cost('O2 flow rate', 'O2 pump', CEPCI=522,\n", " S=40000, cost=22500, n=0.8, kW=75, BM=2.3)\n", "class AerobicReactor3(qs.SanUnit):\n", " def __init__(self, ID='', ins=None, outs=(), thermo=None, init_with='WasteStream',\n", " conversion=0.9, aeration_rate=3, HRT=5):\n", " qs.SanUnit.__init__(self, ID, ins, outs, thermo, init_with)\n", " self.conversion = conversion\n", " self.aeration_rate = aeration_rate\n", " self.HRT = HRT\n", " self.F_BM = {'Tank': 2}\n", " self.aerobic_rxn = Rxn('S_CH3OH + 1.5 S_O2 -> CO2 + 2 H2O', 'S_CH3OH', conversion)\n", "\n", " _N_ins = 2\n", " _N_outs = 2\n", " \n", " def _run(self):\n", " inf, o2 = self.ins\n", " eff, co2 = self.outs\n", " o2.phase = co2.phase = 'g'\n", " \n", " o2_needed = self.aeration_rate * self.F_vol_in\n", " o2.imass['O2'] = o2_needed\n", " \n", " eff.mix_from(self.ins)\n", " eff.imass['S_O2'] = eff.imass['O2']\n", " eff.imass['O2'] = 0\n", " \n", " # Sync the reaction's conversion with the (possibly updated) `conversion`\n", " # attribute so changing it later (e.g., from a specification) takes effect\n", " self.aerobic_rxn.X = self.conversion\n", " self.aerobic_rxn(eff.mol)\n", "\n", " eff.imass['X_OHO'] = 0.05 * inf.imass['S_CH3OH']\n", " eff.imass['S_CH3OH'] -= eff.imass['X_OHO']\n", " \n", " _units = {\n", " 'Volume': 'm3',\n", " 'Diameter': 'm',\n", " 'Height': 'm',\n", " 'Stainless steel': 'kg',\n", " 'O2 flow rate': 'kg/hr'\n", " }\n", " \n", " def _design(self):\n", " D = self.design_results\n", " tot_vol = self.outs[0].F_vol*self.HRT\n", " rx_vol = tot_vol / 0.8\n", " dia = (2*rx_vol/3.14)**(1/3)\n", " D['Volume'] = rx_vol\n", " D['Diameter'] = dia\n", " D['Height'] = H = 2 * dia\n", "\n", " ss = 3.14*(dia**2)*H + 3.14/4*(dia**2)\n", " D['Stainless steel'] = ss * 7500\n", "\n", " D['O2 flow rate'] = self.outs[1].F_mass\n", " \n", " \n", " def _cost(self):\n", " self.baseline_purchase_costs['Tank'] = \\\n", " 3 * self.design_results['Stainless steel']\n", " self.power_utility.consumption = 0.1 * self.outs[0].F_vol\n", "\n", " \n", " @property\n", " def conversion(self):\n", " '''[float] Conversion of the organic matters in this reactor.'''\n", " return self._conversion\n", " @conversion.setter\n", " def conversion(self, i):\n", " if not 0 <= i <= 1:\n", " raise AttributeError('`conversion` must be within [0, 1], '\n", " f'the provided value {i} is outside this range.')\n", " self._conversion = i" ] }, { "cell_type": "code", "execution_count": 14, "id": "98398512", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:28.416431Z", "iopub.status.busy": "2026-05-31T12:13:28.416431Z", "iopub.status.idle": "2026-05-31T12:13:28.432845Z", "shell.execute_reply": "2026-05-31T12:13:28.430433Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Aerobic Reactor3 Units R3\n", "Electricity Power kW 0.101\n", " Cost USD/hr 0.00787\n", "Design Volume m3 6.29\n", " Diameter m 1.59\n", " Height m 3.18\n", " Stainless steel kg 2.04e+05\n", " O2 flow rate kg/hr 0\n", "Purchase cost Tank USD 6.11e+05\n", "Total purchase cost USD 6.11e+05\n", "Utility cost USD/hr 0.00787\n" ] } ], "source": [ "# Check out the results again\n", "R3 = AerobicReactor3('R3', ins=(ws.copy(), 'o2_3'), outs=('eff_3', 'co2_3'))\n", "R3.simulate()\n", "print(R3.results())" ] }, { "cell_type": "code", "execution_count": 15, "id": "ad59c156", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:28.435933Z", "iopub.status.busy": "2026-05-31T12:13:28.434932Z", "iopub.status.idle": "2026-05-31T12:13:29.312644Z", "shell.execute_reply": "2026-05-31T12:13:29.312127Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "AerobicReactor3: R3\n", "ins...\n", "[0] ws3\n", "phase: 'l', T: 298.15 K, P: 101325 Pa\n", "flow (g/hr): S_CH3OH 500\n", " H2O 1e+06\n", " WasteStream-specific properties:\n", " pH : 7.0\n", " Alkalinity : 2.5 mmol/L\n", " COD : 498.2 mg/L\n", " BOD : 357.2 mg/L\n", " TC : 124.7 mg/L\n", " TOC : 124.7 mg/L\n", "[1] o2_3\n", "phase: 'g', T: 298.15 K, P: 101325 Pa\n", "flow: 3.01e+03 g/hr O2\n", " WasteStream-specific properties: None for non-liquid waste streams\n", "outs...\n", "[0] eff_3\n", "phase: 'l', T: 297.35 K, P: 101325 Pa\n", "flow (g/hr): S_CH3OH 25\n", " X_OHO 25\n", " S_O2 2.34e+03\n", " H2O 1e+06\n", " CO2 618\n", " WasteStream-specific properties:\n", " pH : 23.0\n", " Alkalinity : 2.5 mmol/L\n", " COD : 49.7 mg/L\n", " BOD : 31.9 mg/L\n", " TC : 182.8 mg/L\n", " TOC : 15.3 mg/L\n", " TN : 1.7 mg/L\n", " TP : 0.5 mg/L\n", " TK : 0.1 mg/L\n", " TSS : 19.3 mg/L\n", "[1] co2_3\n", "phase: 'g', T: 298.15 K, P: 101325 Pa\n", "flow: 0\n", " WasteStream-specific properties: None for non-liquid waste streams\n" ] } ], "source": [ "R3.show()" ] }, { "cell_type": "markdown", "id": "3868691a", "metadata": {}, "source": [ "**More `cost` arguments.** A few arguments beyond the ones above are handy:\n", "\n", "- `lb` / `ub` (lower/upper size bounds) and `N` (number of parallel units). If the scaling basis exceeds `ub`, the equipment is split into `N = ceil(S_new / ub)` identical parallel units, each sized within bounds, rather than extrapolating one oversized unit past its valid range. (This is the `N` in the cost equation above.)\n", "- `lifetime` sets a replacement interval (years) for that cost item, used by TEA/LCA.\n", "- `f` takes a custom cost function `f(S) -> cost` for correlations that are not a simple power law; when given, it overrides the `cost`/`n` power-law form.\n", "\n", "You can also **stack** multiple `@cost` decorators on one class: each adds its own item to `baseline_purchase_costs` (and its own electricity via `kW`), so a unit can declare a pump, a blower, and a tank as separate scaled cost items." ] }, { "cell_type": "markdown", "id": "adv33_aux_md", "metadata": {}, "source": [ "### 3.3. Auxiliary units \n", "Sometimes a unit's design includes another whole unit operation, for example a tank that needs a heat exchanger to warm its contents, or a reactor that needs a pump. Rather than re-deriving that unit's design and cost by hand, you can make it an *auxiliary unit*: a full `SanUnit` (or BioSTEAM `Unit`) owned by the parent, whose design, cost, and utilities are computed by the auxiliary itself and then rolled up into the parent automatically.\n", "\n", "To set one up: list the attribute name in the class attribute `auxiliary_unit_names`, build the auxiliary unit in `__init__` and bind it to that name, then simulate it inside the parent's `_design`. Below, a `HeatedTank` owns an `HXutility` heat exchanger that heats the influent to a target temperature." ] }, { "cell_type": "code", "execution_count": null, "id": "adv33_aux_code", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:29.316570Z", "iopub.status.busy": "2026-05-31T12:13:29.315410Z", "iopub.status.idle": "2026-05-31T12:13:29.391660Z", "shell.execute_reply": "2026-05-31T12:13:29.390129Z" } }, "outputs": [], "source": [ "from qsdsan import SanUnit\n", "from qsdsan.unit_operations import HXutility\n", "\n", "class HeatedTank(qs.SanUnit):\n", " _N_ins = 1\n", " _N_outs = 1\n", " # declare the auxiliary slot as a class attribute\n", " auxiliary_unit_names = ('heat_exchanger',)\n", "\n", " def __init__(self, ID='', ins=None, outs=(), thermo=None,\n", " init_with='WasteStream', T=320.15):\n", " qs.SanUnit.__init__(self, ID, ins, outs, thermo, init_with)\n", " self.T = T\n", " self.F_BM['Tank'] = 2\n", " # build the auxiliary heat exchanger and bind it to the declared name\n", " # note that the name of the auxiliary unit must match the one declared in `auxiliary_unit_names`\n", " self.heat_exchanger = HXutility(self.ID+'_hx', ins=self.ins[0].copy(), T=T)\n", "\n", " def _run(self):\n", " self.outs[0].copy_like(self.ins[0])\n", " self.outs[0].T = self.T\n", "\n", " def _design(self):\n", " hx = self.heat_exchanger\n", " hx.ins[0].copy_like(self.ins[0])\n", " hx.T = self.T\n", " hx.simulate() # the auxiliary designs and costs itself\n", "\n", " def _cost(self):\n", " self.baseline_purchase_costs['Tank'] = 1e4" ] }, { "cell_type": "code", "execution_count": 17, "id": "adv33_aux_run", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:29.394660Z", "iopub.status.busy": "2026-05-31T12:13:29.394660Z", "iopub.status.idle": "2026-05-31T12:13:29.514518Z", "shell.execute_reply": "2026-05-31T12:13:29.513512Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "auxiliary units:" ] }, { "name": "stdout", "output_type": "stream", "text": [ " ['HT_hx']\n", "tank only: 10,000 USD\n", "heat exchanger: 3,275 USD\n", "unit purchase: 13,275 USD (tank + auxiliary)\n", "unit utility: 0.595 USD/hr (from the auxiliary's heat duty)\n" ] } ], "source": [ "HT = HeatedTank('HT', ins=ws.copy())\n", "HT.simulate()\n", "print('auxiliary units:', [u.ID for u in HT.auxiliary_units])\n", "print(f\"tank only: {HT.baseline_purchase_costs['Tank']:,.0f} USD\")\n", "print(f\"heat exchanger: {HT.heat_exchanger.purchase_cost:,.0f} USD\")\n", "print(f\"unit purchase: {HT.purchase_cost:,.0f} USD (tank + auxiliary)\")\n", "print(f\"unit utility: {HT.utility_cost:.3f} USD/hr (from the auxiliary's heat duty)\")" ] }, { "cell_type": "code", "execution_count": 18, "id": "adv33_aux_results", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:29.517522Z", "iopub.status.busy": "2026-05-31T12:13:29.517522Z", "iopub.status.idle": "2026-05-31T12:13:29.527028Z", "shell.execute_reply": "2026-05-31T12:13:29.526523Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Heated tank Units HT\n", "Low pressure steam Duty kJ/hr 9.68e+04\n", " Flow kmol/hr 2.5\n", " Cost USD/hr 0.595\n", "Purchase cost Tank USD 1e+04\n", " Heat exchanger - Double pipe USD 3.27e+03\n", "Total purchase cost USD 1.33e+04\n", "Utility cost USD/hr 0.595\n" ] } ], "source": [ "print(HT.results())" ] }, { "cell_type": "markdown", "id": "adv33_aux_outro", "metadata": {}, "source": [ "The auxiliary's purchase cost is folded into the parent's `purchase_cost` (and `installed_cost`), and its heat-exchanger steam duty shows up in the parent's `results()` and `utility_cost`. You did not have to write any heat-exchanger design or cost code; the `HXutility` did it." ] }, { "cell_type": "markdown", "id": "13745b54", "metadata": {}, "source": [ "### 3.4. `Equipment`\n", "If the same piece of equipment shows up across many of your units, factor it into an `Equipment` subclass. Each `Equipment` subclass implements its own `_design` and `_cost`; your unit then calls `add_equipment_design()` inside its `_design` and `add_equipment_cost()` inside its `_cost` to fold the equipment's design results and costs into the unit. For a worked example, see the [ElectrochemicalCell](https://qsdsan.readthedocs.io/en/latest/api/unit_operations/static/ElectrochemicalCell.html) unit and the [equipment source code](https://github.com/QSD-Group/QSDsan/tree/main/qsdsan/equipments)." ] }, { "cell_type": "markdown", "id": "4a69361f", "metadata": {}, "source": [ "An auxiliary unit (section 3.3) and an `Equipment` differ in granularity: an auxiliary unit is a full owned `Unit` that designs and costs itself, while an `Equipment` is a lighter costed sub-component folded in through the `add_equipment_design`/`add_equipment_cost` hooks." ] }, { "cell_type": "markdown", "id": "cea5f33d", "metadata": {}, "source": [ "### 3.5. Choosing the stream class with `init_with`\n", "Every subclass above passes `init_with` straight through to `SanUnit.__init__`. It sets which stream class each port uses (`'WasteStream'` by default; also `'SanStream'` or `'Stream'`, or a `dict` for per-port control). See [4. SanUnit (basic)](https://qsdsan.readthedocs.io/en/latest/tutorials/4_SanUnit_basic.html) for the full string/dict forms and runnable examples." ] }, { "cell_type": "markdown", "id": "adv35constr", "metadata": {}, "source": [ "### 3.6. Construction and transportation (for LCA)\n", "Every `SanUnit` can carry `construction` and `transportation` inventories that feed life cycle assessment: `Construction` and `Transportation` objects that record quantities (e.g., kg of concrete, km of truck transport). For a custom unit that always needs certain materials, declare them as a class attribute via `_construction_specs` (resolved lazily when an `LCA` is created), or set `unit.construction` / `unit.transportation` on the instance. The full workflow, including the `ImpactItem`s these link to, is covered in [8. LCA](https://qsdsan.readthedocs.io/en/latest/tutorials/8_LCA.html)." ] }, { "cell_type": "markdown", "id": "adv36md", "metadata": {}, "source": [ "### 3.7. Specifications\n", "A *specification* is a function attached to a unit that runs during simulation. Use it to adjust a unit, or drive it toward a target, without writing a whole subclass. Add one with `add_specification` (often as a decorator); pass `run=True` to also call the unit's `_run` afterward." ] }, { "cell_type": "code", "execution_count": 19, "id": "adv36code", "metadata": { "execution": { "iopub.execute_input": "2026-05-31T12:13:29.530429Z", "iopub.status.busy": "2026-05-31T12:13:29.530429Z", "iopub.status.idle": "2026-05-31T12:13:29.631280Z", "shell.execute_reply": "2026-05-31T12:13:29.630773Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "No spec: conversion 0.9, effluent COD 49.7 mg/L\n", "With spec: conversion 0.95, effluent COD 24.6 mg/L\n" ] } ], "source": [ "# Reuse the AerobicReactor3 class from above, but tune its conversion on the fly\n", "U_spec = AerobicReactor3('U_spec', ins=(ws.copy(), 'o2_spec'), outs=('eff_spec', 'co2_spec'))\n", "U_spec.simulate() # default conversion (0.9)\n", "print(f'No spec: conversion {U_spec.conversion}, effluent COD {U_spec.outs[0].COD:.1f} mg/L')\n", "\n", "@U_spec.add_specification(run=True) # run=True: also run the unit after the spec\n", "def boost_conversion():\n", " # use a higher conversion when the influent is more concentrated\n", " U_spec.conversion = 0.95 if U_spec.ins[0].COD > 400 else 0.85\n", "\n", "U_spec.simulate()\n", "print(f'With spec: conversion {U_spec.conversion}, effluent COD {U_spec.outs[0].COD:.1f} mg/L')" ] }, { "cell_type": "markdown", "id": "96f82d1b", "metadata": {}, "source": [ "The spec sets a higher `conversion` for the concentrated influent, and the effluent COD drops accordingly between the two runs above. The `run=True` flag makes sure the change is implemented for this round of simulation: after the spec function sets `conversion`, the unit's `_run` is called again, so the new value propagates to the outlets (and then to `_design`/`_cost`) within the same `simulate()`. With the default `run=False` the spec is still called, but the unit's mass balance is not re-evaluated, so a parameter the spec changed would not reach the outlets until the next `_run`.\n", "\n", "
\n", "\n", "**Degrees of freedom.** A specification is one free hand on the unit. To pin an outlet (or any result) to a target value, you free exactly one adjustable parameter for the spec to set: one parameter per target. Two independent targets need two parameters, and asking for more targets than the unit has free parameters is over-constrained, with no consistent solution. The same idea scales up to whole systems, covered in [System tutorial section 3](6_System.ipynb#3.-Process-specifications).\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "nav-footer-5_sanunit_advanced", "metadata": {}, "source": [ "\n", "\n", "---\n", "\n", "↑ Back to top\n" ] } ], "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 }