{ "cells": [ { "cell_type": "markdown", "id": "bb796f8d", "metadata": {}, "source": [ "# Modeling Notes & Pitfalls \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://qsdsan.readthedocs.io/en/latest/AUTHORS.html)\n", "\n", "- **Learning objectives.** After this tutorial, you will be able to:\n", "\n", " - Recognize common QSDsan modeling surprises by their symptoms.\n", " - Diagnose whether a surprising result is a misconfiguration, a unit-design signal, or expected platform behavior.\n", " - Apply fix patterns across streams and components, unit design, and TEA/LCA.\n", "\n", "- **Prerequisites:**\n", " - [2. Component](2_Component.ipynb)\n", " - [3. WasteStream](3_WasteStream.ipynb)\n", " - [4. SanUnit (basic)](4_SanUnit_basic.ipynb)\n", " - [6. System](6_System.ipynb)\n", " - Section 2.3 references [11. Dynamic Simulation](11_Dynamic_Simulation.ipynb)\n", " - Section 3 references [7. TEA](7_TEA.ipynb) and [8. LCA](8_LCA.ipynb).\n", "\n", "- **Covered topics:**\n", "\n", " - 1. Streams and components\n", " - 2. Unit design and simulation\n", " - 3. TEA and LCA\n", "\n", "
\n", "\n", "Looking for install or environment errors? See the FAQ Common Errors page.\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "bbc9da86", "metadata": {}, "source": [ "This tutorial documents practical notes and common pitfalls a user may run into when using QSDsan. Each entry below follows the same shape: **What you see**, **Why** it happens, and the **Fix**. The code cells use minimal toy components and streams so each entry runs on its own." ] }, { "cell_type": "markdown", "id": "076b21d5", "metadata": {}, "source": [ "\n", "\n", "## Setup\n", "\n", "Import `QSDsan` and confirm the installed version." ] }, { "cell_type": "code", "execution_count": 1, "id": "087e4377", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:42.919665Z", "iopub.status.busy": "2026-05-30T20:34:42.919665Z", "iopub.status.idle": "2026-05-30T20:34:58.773984Z", "shell.execute_reply": "2026-05-30T20:34:58.773984Z" } }, "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": "9af257c5", "metadata": {}, "source": [ "## 1. Streams and components " ] }, { "cell_type": "markdown", "id": "84c5e938", "metadata": {}, "source": [ "### 1.1. WasteStream vs. SanStream vs. Stream " ] }, { "cell_type": "markdown", "id": "25a36a17", "metadata": {}, "source": [ "**What you see.** A unit configured for wastewater (e.g., a clarifier) is fed a plain `qs.Stream`, and the unit's outlet drops the solids and concentration fields that downstream WW-aware code depends on." ] }, { "cell_type": "code", "execution_count": 2, "id": "c09d2798", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:58.773984Z", "iopub.status.busy": "2026-05-30T20:34:58.773984Z", "iopub.status.idle": "2026-05-30T20:34:58.984536Z", "shell.execute_reply": "2026-05-30T20:34:58.984536Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Stream" ] }, { "name": "stdout", "output_type": "stream", "text": [ "\n", "has TSS attribute: False\n" ] } ], "source": [ "import qsdsan as qs\n", "\n", "cmps = qs.Components.load_default()\n", "qs.set_thermo(cmps)\n", "\n", "plain = qs.Stream('plain', H2O=1000, units='kg/hr')\n", "print(type(plain).__name__)\n", "print('has TSS attribute:', hasattr(plain, 'get_TSS'))" ] }, { "cell_type": "markdown", "id": "f19a9664", "metadata": {}, "source": [ "**Why.** `Stream` is the BioSTEAM base type for mass/energy flows. `SanStream` adds construction/transportation/impact bookkeeping for LCA. `WasteStream` adds the component-aggregation properties (`TSS`, `COD`, `BOD`, and so on) used by every WW characterization workflow. Mixing types is not a hard error; it silently drops the richer attributes when a stream falls back to the base class." ] }, { "cell_type": "markdown", "id": "0f3f3a24", "metadata": {}, "source": [ "**Fix.** Use `WasteStream` for anything that participates in WW characterization; `SanStream` if you only need LCA bookkeeping without WW properties; `Stream` only for utility or heat-medium flows where neither matters." ] }, { "cell_type": "code", "execution_count": 3, "id": "59afd1bc", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:58.987557Z", "iopub.status.busy": "2026-05-30T20:34:58.987557Z", "iopub.status.idle": "2026-05-30T20:34:59.016733Z", "shell.execute_reply": "2026-05-30T20:34:59.015726Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "WasteStream\n", "TSS: 387.1 mg/L\n" ] } ], "source": [ "ww = qs.WasteStream('ww', H2O=1000, X_OHO=0.5, units='kg/hr')\n", "print(type(ww).__name__)\n", "print(f'TSS: {ww.get_TSS():.1f} mg/L')" ] }, { "cell_type": "markdown", "id": "2db20bad", "metadata": {}, "source": [ "### 1.2. Component is not Chemical " ] }, { "cell_type": "markdown", "id": "4c91e48a", "metadata": {}, "source": [ "**What you see.** A user builds a plain [Thermosteam Chemical](https://biosteam.readthedocs.io/en/latest/API/thermosteam/Chemical.html) and assumes it behaves like a `Component`, then finds the WW-classification attributes (`degradability`, `i_COD`, and the rest) that QSDsan characterization relies on are simply not there." ] }, { "cell_type": "code", "execution_count": 4, "id": "948f9576", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.019765Z", "iopub.status.busy": "2026-05-30T20:34:59.019155Z", "iopub.status.idle": "2026-05-30T20:34:59.025948Z", "shell.execute_reply": "2026-05-30T20:34:59.025948Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "type: Chemical\n", "Chemical has degradability? False\n", "Chemical has i_COD? False\n" ] } ], "source": [ "from qsdsan import Component\n", "from thermosteam import Chemical\n", "\n", "glucose_chem = Chemical('Glucose')\n", "print('type:', type(glucose_chem).__name__)\n", "print('Chemical has degradability?', hasattr(glucose_chem, 'degradability'))\n", "print('Chemical has i_COD?', hasattr(glucose_chem, 'i_COD'))" ] }, { "cell_type": "markdown", "id": "071505d6", "metadata": {}, "source": [ "**Why.** `Component` subclasses Thermosteam's `Chemical` and adds the WW-classification attributes (`particle_size`, `degradability`, `organic`, `i_COD`, and so on) that QSDsan's characterization and process models read. A plain `Chemical` carries the thermodynamics but none of that bookkeeping, so those attributes are absent and anything downstream that expects them fails." ] }, { "cell_type": "markdown", "id": "3b47da50", "metadata": {}, "source": [ "**Fix.** Build a `Component` (not a `Chemical`) for anything that participates in WW workflows, and give it the classification attributes at construction. The thermo properties you already know from `Chemical` (such as `Tb`) are still there." ] }, { "cell_type": "code", "execution_count": 5, "id": "e9372870", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.025948Z", "iopub.status.busy": "2026-05-30T20:34:59.025948Z", "iopub.status.idle": "2026-05-30T20:34:59.035912Z", "shell.execute_reply": "2026-05-30T20:34:59.035912Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "type: Component\n", "degradability: Readily\n", "i_COD: 1.066\n", "Tb (inherited from Chemical): 844.68 K\n" ] } ], "source": [ "glucose = Component('Glucose', search_ID='Glucose',\n", " particle_size='Soluble',\n", " degradability='Readily',\n", " organic=True)\n", "print('type:', type(glucose).__name__)\n", "print('degradability:', glucose.degradability)\n", "print(f'i_COD: {glucose.i_COD:.3f}')\n", "print(f'Tb (inherited from Chemical): {glucose.Tb} K')" ] }, { "cell_type": "markdown", "id": "2eaacac4", "metadata": {}, "source": [ "### 1.3. Stream IDs and the registry " ] }, { "cell_type": "markdown", "id": "1eec215e", "metadata": {}, "source": [ "**What you see.** Re-running a cell that creates a stream prints a `replaced ... in registry` warning, and a later `System` resolves a stream by ID and pulls the wrong one." ] }, { "cell_type": "code", "execution_count": 6, "id": "13213200", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.038919Z", "iopub.status.busy": "2026-05-30T20:34:59.037919Z", "iopub.status.idle": "2026-05-30T20:34:59.239170Z", "shell.execute_reply": "2026-05-30T20:34:59.238166Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "2000.0\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ "C:\\Users\\Yalin\\Documents\\Coding\\QSDsan-platform\\.venv\\Lib\\site-packages\\thermosteam\\_stream.py:407: RuntimeWarning: has been replaced in registry\n", " self._register(ID)\n" ] } ], "source": [ "import qsdsan as qs\n", "\n", "cmps = qs.Components.load_default()\n", "qs.set_thermo(cmps)\n", "\n", "feed = qs.WasteStream('feed', H2O=1000)\n", "feed = qs.WasteStream('feed', H2O=2000) # warns: replaced in registry\n", "print(qs.main_flowsheet.stream.feed.F_mass)" ] }, { "cell_type": "markdown", "id": "c64ec743", "metadata": {}, "source": [ "**Why.** Streams (and units) register themselves by ID in the flowsheet registry. Reusing an ID overwrites the prior entry, so anything that resolved the old object by ID is now stale. Auto-IDs (`ws1`, `ws2`, ...) do not create replacement warnings, but they may change from simulation to simulation and drift from the variable name a reader sees (e.g., `ws3`'s real ID might be `ws5`)." ] }, { "cell_type": "code", "execution_count": 7, "id": "17b9542a", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.241171Z", "iopub.status.busy": "2026-05-30T20:34:59.240173Z", "iopub.status.idle": "2026-05-30T20:34:59.245173Z", "shell.execute_reply": "2026-05-30T20:34:59.245173Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "variable \"feed\" -> registry ID 'ws1'\n", "variable \"feed\" -> registry ID 'ws2'\n", "variable \"feed\" -> registry ID 'ws3'\n", "variable \"feed\" -> registry ID 'ws4'\n" ] } ], "source": [ "# Omit the ID and QSDsan assigns one automatically (ws1, ws2, ...).\n", "qs.main_flowsheet.set_flowsheet('auto_id_demo')\n", "\n", "feed = qs.WasteStream(H2O=1000)\n", "print(f'variable \"feed\" -> registry ID {feed.ID!r}')\n", "\n", "# Re-building the stream (for example, by re-running the cell) bumps the global\n", "# counter, so the same variable lands under a different ID each time:\n", "for _ in range(3):\n", " feed = qs.WasteStream(H2O=1000)\n", " print(f'variable \"feed\" -> registry ID {feed.ID!r}')" ] }, { "cell_type": "markdown", "id": "42149d41", "metadata": {}, "source": [ "**Fix.** For streams you would like to track, pass an explicit ID that matches the variable name. To overwrite intentionally, `del` the old one first or call `qs.main_flowsheet.stream.clear()`." ] }, { "cell_type": "code", "execution_count": 8, "id": "bc71b040", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.247239Z", "iopub.status.busy": "2026-05-30T20:34:59.247239Z", "iopub.status.idle": "2026-05-30T20:34:59.251635Z", "shell.execute_reply": "2026-05-30T20:34:59.250626Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "feed mass: 2000.0\n" ] } ], "source": [ "qs.main_flowsheet.stream.clear()\n", "feed = qs.WasteStream('feed', H2O=2000)\n", "print(f\"feed mass: {qs.main_flowsheet.stream.feed.F_mass}\")" ] }, { "cell_type": "markdown", "id": "3722bde3", "metadata": {}, "source": [ "## 2. Unit design and simulation " ] }, { "cell_type": "markdown", "id": "8c2535a5", "metadata": {}, "source": [ "### 2.1. Absurd geometry overlooked " ] }, { "cell_type": "markdown", "id": "ec85be6a", "metadata": {}, "source": [ "**What you see.** Very high costs and/or environmental impacts associated with the construction of a reactor. The user assumes the design code is buggy." ] }, { "cell_type": "code", "execution_count": 9, "id": "a61d39a2", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.253632Z", "iopub.status.busy": "2026-05-30T20:34:59.252633Z", "iopub.status.idle": "2026-05-30T20:34:59.257496Z", "shell.execute_reply": "2026-05-30T20:34:59.257496Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "{'note': 'HRT=200 h is outside the valid range [0.5, 24]'}\n" ] } ], "source": [ "# Toy CSTR sized far outside its intended HRT range. A real qs unit would do\n", "# this inside _design using its own validated correlations; the toy keeps the\n", "# point self-contained.\n", "class ToyCSTR:\n", " _design_HRT_range = (0.5, 24) # hours\n", " def __init__(self, HRT):\n", " self.HRT = HRT\n", " def _design(self):\n", " lo, hi = self._design_HRT_range\n", " if not (lo <= self.HRT <= hi):\n", " return {'note': f'HRT={self.HRT} h is outside the valid range [{lo}, {hi}]'}\n", " return {'D_m': 4.0, 'H_m': 8.0}\n", "\n", "print(ToyCSTR(HRT=200)._design())" ] }, { "cell_type": "markdown", "id": "4faff611", "metadata": {}, "source": [ "**Why.** If you are sure that the design code is correct, then check the geometry. A reactor sized for a normal volumetric loading may have a \"pancake\" shape with very wide diameter but very shallow height (or the reverse). The absurd geometry is the model's way of flagging an unphysical operating point." ] }, { "cell_type": "markdown", "id": "7941ad6b", "metadata": {}, "source": [ "**Fix.** When a unit returns weird dimensions, check the unit's docstring or the design references it cites for the valid operating range. If you have a legitimate reason to operate outside that range, either subclass the unit and extend `_design` with a custom correlation, or insert a second stage so each unit stays inside its window." ] }, { "cell_type": "code", "execution_count": 10, "id": "a17bc770", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.259502Z", "iopub.status.busy": "2026-05-30T20:34:59.259502Z", "iopub.status.idle": "2026-05-30T20:34:59.263547Z", "shell.execute_reply": "2026-05-30T20:34:59.262539Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "{'D_m': 4.0, 'H_m': 8.0}\n" ] } ], "source": [ "print(ToyCSTR(HRT=12)._design())" ] }, { "cell_type": "markdown", "id": "84e5f549", "metadata": {}, "source": [ "### 2.2. Recycle convergence with biokinetic models " ] }, { "cell_type": "markdown", "id": "30965653", "metadata": {}, "source": [ "**What you see.** A `System` with an ASM-based bioreactor and a sludge recycle loop times out or returns a recycle convergence error at tight tolerance." ] }, { "cell_type": "code", "execution_count": 11, "id": "648e078b", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.266035Z", "iopub.status.busy": "2026-05-30T20:34:59.265547Z", "iopub.status.idle": "2026-05-30T20:34:59.269429Z", "shell.execute_reply": "2026-05-30T20:34:59.269118Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "symptom: tight tolerance + a far-from-solution initial guess => stall\n", "a real reproducer is a 2-unit bioreactor + clarifier recycle loop\n" ] } ], "source": [ "# Conceptual demonstration of the symptom (kept dependency-free so the\n", "# tutorial runs fast). The point is the convergence behavior, not the numbers.\n", "print(\"symptom: tight tolerance + a far-from-solution initial guess => stall\")\n", "print(\"a real reproducer is a 2-unit bioreactor + clarifier recycle loop\")" ] }, { "cell_type": "markdown", "id": "e38eb009", "metadata": {}, "source": [ "**Why.** Biokinetic stoichiometry is stiff: small composition changes in the recycle loop produce large kinetic responses, which in turn produce large composition swings. Aitken/Wegstein acceleration helps near the solution but can amplify oscillations when the initial guess is far off. The default tolerance may be tighter than necessary for many wastewater analyses." ] }, { "cell_type": "markdown", "id": "991c25a6", "metadata": {}, "source": [ "**Fix.**\n", "\n", "1. Loosen the System's `molar_tolerance` and `temperature_tolerance` first (a one-line change; a larger number is looser).\n", "2. Improve the initial guess for the recycle stream (set realistic concentrations explicitly).\n", "3. Only if the first two fail, increase `maxiter` (the maximum number of iterations).\n", "\n", "See [6. System](6_System.ipynb#5.-Controlling-recycle-convergence) for the full convergence-control surface." ] }, { "cell_type": "code", "execution_count": 12, "id": "c7a9333f", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.269429Z", "iopub.status.busy": "2026-05-30T20:34:59.269429Z", "iopub.status.idle": "2026-05-30T20:34:59.275791Z", "shell.execute_reply": "2026-05-30T20:34:59.275791Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "# defaults: molar_tolerance = 1.0 kmol/hr, temperature_tolerance = 0.1 K\n", "sys.molar_tolerance = 5.0 # looser than the default\n", "sys.temperature_tolerance = 0.5 # looser than the default\n", "# then sys.simulate(); raise sys.maxiter only if it still will not converge\n" ] } ], "source": [ "print(\"# defaults: molar_tolerance = 1.0 kmol/hr, temperature_tolerance = 0.1 K\")\n", "print(\"sys.molar_tolerance = 5.0 # looser than the default\")\n", "print(\"sys.temperature_tolerance = 0.5 # looser than the default\")\n", "print(\"# then sys.simulate(); raise sys.maxiter only if it still will not converge\")" ] }, { "cell_type": "markdown", "id": "7dc6bfab", "metadata": {}, "source": [ "### 2.3. Dynamic simulation not updating states " ] }, { "cell_type": "markdown", "id": "821d24a9", "metadata": {}, "source": [ "**What you see.** A dynamic simulation runs to completion, but every state stays at its initial value. The user assumes the ODE solver is broken." ] }, { "cell_type": "code", "execution_count": 13, "id": "48969fa1", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.275791Z", "iopub.status.busy": "2026-05-30T20:34:59.275791Z", "iopub.status.idle": "2026-05-30T20:34:59.282146Z", "shell.execute_reply": "2026-05-30T20:34:59.282146Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "symptom: simulate() runs, but the state never moves\n", "inspect: did the subclass override _init_state? did _compile_ODE wire dstate?\n" ] } ], "source": [ "# Conceptual demonstration of the symptom.\n", "print(\"symptom: simulate() runs, but the state never moves\")\n", "print(\"inspect: did the subclass override _init_state? did _compile_ODE wire dstate?\")" ] }, { "cell_type": "markdown", "id": "105b10cb", "metadata": {}, "source": [ "**Why.** Dynamic `SanUnit` subclasses must override `_init_state` to populate `self._state` from `ins`, and `_compile_ODE` to populate `self._dstate` from `self._state` and `ins`. If `_init_state` is missing, the state starts as zeros (or whatever the base class set); if `_compile_ODE` is missing, `dstate` is zero, so the state never moves. `nbsphinx_allow_errors` does not help here, because the simulation does not error, it just does not move." ] }, { "cell_type": "markdown", "id": "f8274ad1", "metadata": {}, "source": [ "**Fix.** Implement both methods in any dynamic subclass, and cross-check `state` and `dstate` at `t=0` before running the full simulation. See [11. Dynamic Simulation](11_Dynamic_Simulation.ipynb) for the full state/dstate contract." ] }, { "cell_type": "code", "execution_count": 14, "id": "f36fe48a", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.285375Z", "iopub.status.busy": "2026-05-30T20:34:59.284375Z", "iopub.status.idle": "2026-05-30T20:34:59.288392Z", "shell.execute_reply": "2026-05-30T20:34:59.288392Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "verify before t-stepping:\n", "u._init_state(); print(u._state)\n", "u._compile_ODE(); u._ode(t=0, y=u._state); print(u._dstate)\n" ] } ], "source": [ "print(\"verify before t-stepping:\")\n", "print(\"u._init_state(); print(u._state)\")\n", "print(\"u._compile_ODE(); u._ode(t=0, y=u._state); print(u._dstate)\")" ] }, { "cell_type": "markdown", "id": "84840b94", "metadata": {}, "source": [ "### 2.4. simulate does more than _run " ] }, { "cell_type": "markdown", "id": "ac107f6f", "metadata": {}, "source": [ "**What you see.** A parameter sweep over a kinetic constant changes the unit's outlet concentrations from cycle to cycle, but `installed_cost` is the same every cycle. The user assumes the cost correlation ignores composition." ] }, { "cell_type": "code", "execution_count": 15, "id": "f1ac52bc", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.290397Z", "iopub.status.busy": "2026-05-30T20:34:59.290397Z", "iopub.status.idle": "2026-05-30T20:34:59.294692Z", "shell.execute_reply": "2026-05-30T20:34:59.293686Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "symptom: outlet streams change across sweep iterations, installed_cost does not\n" ] } ], "source": [ "print(\"symptom: outlet streams change across sweep iterations, installed_cost does not\")" ] }, { "cell_type": "markdown", "id": "c6734942", "metadata": {}, "source": [ "**Why.** The sweep is calling `unit._run()` (or assigning into the unit's parameters and then reading outlets) without invoking `_design` and `_cost`. `unit.simulate()` calls `_run`, then `_summary`, which itself calls `_design` and `_cost`. Otherwise the cost results from the previous full simulation stay cached." ] }, { "cell_type": "markdown", "id": "6b2383a7", "metadata": {}, "source": [ "**Fix.** Always go through `unit.simulate()` (or `system.simulate()`) when downstream code reads design or cost results. See [Putting them together with simulate](5_SanUnit_advanced.ipynb#Putting-them-together-with-simulate) in tutorial 5 for the full call graph." ] }, { "cell_type": "code", "execution_count": 16, "id": "98475299", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.295701Z", "iopub.status.busy": "2026-05-30T20:34:59.295701Z", "iopub.status.idle": "2026-05-30T20:34:59.299854Z", "shell.execute_reply": "2026-05-30T20:34:59.299854Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "for k in k_range:\n", " unit.k = k\n", " unit.simulate() # not just unit._run()\n", " record(unit.installed_cost)\n" ] } ], "source": [ "print(\"for k in k_range:\")\n", "print(\" unit.k = k\")\n", "print(\" unit.simulate() # not just unit._run()\")\n", "print(\" record(unit.installed_cost)\")" ] }, { "cell_type": "markdown", "id": "e7b37931", "metadata": {}, "source": [ "## 3. TEA and LCA " ] }, { "cell_type": "markdown", "id": "febc2782", "metadata": {}, "source": [ "### 3.1. CEPCI year drifts costs silently " ] }, { "cell_type": "markdown", "id": "b8e0b619", "metadata": {}, "source": [ "**What you see.** A benchmark unit's installed cost prints about 20% off from a published reference, or you suddenly notice a large change in cost metrics despite no recent updates." ] }, { "cell_type": "code", "execution_count": 17, "id": "e2a2e52f", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.303144Z", "iopub.status.busy": "2026-05-30T20:34:59.301860Z", "iopub.status.idle": "2026-05-30T20:34:59.306533Z", "shell.execute_reply": "2026-05-30T20:34:59.306533Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "qs.CEPCI = 567.5\n", "CEPCI[2023] = 797.9\n", "CEPCI[2017] = 567.5\n", "ratio = 1.406\n" ] } ], "source": [ "import qsdsan as qs\n", "\n", "print(f'qs.CEPCI = {qs.CEPCI}')\n", "print(f'CEPCI[2023] = {qs.CEPCI_by_year[2023]}')\n", "print(f'CEPCI[2017] = {qs.CEPCI_by_year[2017]}')\n", "print(f'ratio = {qs.CEPCI_by_year[2023] / qs.CEPCI_by_year[2017]:.3f}')" ] }, { "cell_type": "markdown", "id": "78ced493", "metadata": {}, "source": [ "**Why.** `qs.CEPCI` is module-level state set once at import. If a unit's purchase-cost correlation was developed for a different year than the current `qs.CEPCI`, costs are scaled by the ratio of those two indices. When a user runs several analyses targeting different reference years without setting `qs.CEPCI` explicitly, costs silently drift." ] }, { "cell_type": "markdown", "id": "6ea6f173", "metadata": {}, "source": [ "**Fix.** Set `qs.CEPCI` deliberately at the top of every analysis, and assert it immediately afterward so a misconfiguration fails loudly:" ] }, { "cell_type": "code", "execution_count": 18, "id": "c77655c9", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.309540Z", "iopub.status.busy": "2026-05-30T20:34:59.308540Z", "iopub.status.idle": "2026-05-30T20:34:59.313554Z", "shell.execute_reply": "2026-05-30T20:34:59.312539Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "CEPCI locked to 797.9 (year 2023)\n" ] } ], "source": [ "target = qs.CEPCI_by_year[2023]\n", "qs.CEPCI = target\n", "assert qs.CEPCI == target\n", "print(f'CEPCI locked to {qs.CEPCI} (year 2023)')" ] }, { "cell_type": "markdown", "id": "d8f8bc10", "metadata": {}, "source": [ "### 3.2. Purchase vs. installed vs. total capital cost " ] }, { "cell_type": "markdown", "id": "f5d0a9a5", "metadata": {}, "source": [ "**What you see.** Three numbers from one unit (`unit.purchase_cost`, `unit.installed_cost`, and the contribution to `TEA.installed_equipment_cost`) do not tally with the user's hand calculation." ] }, { "cell_type": "code", "execution_count": 19, "id": "a86dc931", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.315558Z", "iopub.status.busy": "2026-05-30T20:34:59.315558Z", "iopub.status.idle": "2026-05-30T20:34:59.319900Z", "shell.execute_reply": "2026-05-30T20:34:59.319900Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "purchase_cost: cost of the equipment 'as bought' (FOB).\n", "installed_cost: purchase_cost * F_BM (bare-module factor).\n", "TEA.installed_equipment_cost: sum of installed_cost across units.\n" ] } ], "source": [ "print(\"purchase_cost: cost of the equipment 'as bought' (FOB).\")\n", "print(\"installed_cost: purchase_cost * F_BM (bare-module factor).\")\n", "print(\"TEA.installed_equipment_cost: sum of installed_cost across units.\")" ] }, { "cell_type": "markdown", "id": "224f9e73", "metadata": {}, "source": [ "**Why.** `_cost` populates `purchase_cost`; the bare-module factor `F_BM` (per unit type, often 2 to 3) inflates it to `installed_cost`; the TEA aggregator may add further capital markups on top (see the TEA tutorial). Most \"CAPEX does not add up\" reports come from comparing the wrong two fields." ] }, { "cell_type": "markdown", "id": "ca82aa2c", "metadata": {}, "source": [ "**Fix.** Match the level: cite `purchase_cost` for raw-equipment comparisons, `installed_cost` for delivered-and-installed, and the TEA aggregate for total capital investment. Each is correct at its own level. See [7. TEA](7_TEA.ipynb) for the full capital build-up." ] }, { "cell_type": "code", "execution_count": 20, "id": "a4f1136e", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.319900Z", "iopub.status.busy": "2026-05-30T20:34:59.319900Z", "iopub.status.idle": "2026-05-30T20:34:59.326137Z", "shell.execute_reply": "2026-05-30T20:34:59.326137Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "for unit in sys.units:\n", " print(unit.ID, unit.purchase_cost, unit.installed_cost, unit.F_BM)\n" ] } ], "source": [ "print(\"for unit in sys.units:\")\n", "print(\" print(unit.ID, unit.purchase_cost, unit.installed_cost, unit.F_BM)\")" ] }, { "cell_type": "markdown", "id": "69ee26e0", "metadata": {}, "source": [ "### 3.3. Static values for varying inventory quantities " ] }, { "cell_type": "markdown", "id": "668ebb46", "metadata": {}, "source": [ "**What you see.** An LCA item whose quantity depends on the simulation (for example, electricity use) is given a fixed number computed at baseline. Later runs that change the system, such as each sample in an uncertainty or sensitivity analysis, produce different electricity use, but the LCA keeps reporting the baseline impact." ] }, { "cell_type": "code", "execution_count": 21, "id": "a3d393ee", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.326137Z", "iopub.status.busy": "2026-05-30T20:34:59.326137Z", "iopub.status.idle": "2026-05-30T20:34:59.533889Z", "shell.execute_reply": "2026-05-30T20:34:59.532883Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "baseline power: 0.916 kW" ] }, { "name": "stdout", "output_type": "stream", "text": [ "\n", "baseline GWP: 56,150 kg CO2-eq\n", "after change: 1.973 kW (electricity roughly doubled)\n", "LCA still says: 56,150 kg CO2-eq <- unchanged!\n" ] } ], "source": [ "import qsdsan as qs\n", "from qsdsan.utils import create_example_system\n", "\n", "qs.main_flowsheet.set_flowsheet('lca_inventory_demo') # isolate this entry\n", "sys = create_example_system()\n", "sys.simulate()\n", "lifetime = 10\n", "\n", "GWP = qs.ImpactIndicator('GlobalWarming', alias='GWP', unit='kg CO2-eq')\n", "e_item = qs.ImpactItem('e_item', 'kWh', GWP=0.7) # 0.7 kg CO2-eq per kWh\n", "\n", "# Freeze the electricity use computed at this baseline (a plain number).\n", "power_kWh = sys.power_utility.rate * 24 * 365 * lifetime\n", "lca = qs.LCA(system=sys, lifetime=lifetime, simulate_system=False,\n", " indicators=(GWP,), e_item=power_kWh)\n", "print(f'baseline power: {sys.power_utility.rate:.3f} kW')\n", "print(f'baseline GWP: {lca.get_total_impacts()[GWP.ID]:,.0f} kg CO2-eq')\n", "\n", "# Change a parameter (as a UA/SA sample would) and re-simulate.\n", "sys.flowsheet.stream.salt_water.F_mass *= 2\n", "sys.simulate()\n", "print(f'after change: {sys.power_utility.rate:.3f} kW (electricity roughly doubled)')\n", "print(f'LCA still says: {lca.get_total_impacts()[GWP.ID]:,.0f} kg CO2-eq <- unchanged!')" ] }, { "cell_type": "markdown", "id": "25d6c85c", "metadata": {}, "source": [ "**Why.** `LCA` stores each \"other item\" quantity as a function and re-evaluates it every time results are requested. If you pass a plain number, it is frozen as a constant (`lambda: value`), so it never reflects later changes to the system. The stream and construction impacts, which are read from the live system, do update, so the electricity term silently falls out of sync with the rest of the inventory, and every UA/SA sample reuses the baseline electricity." ] }, { "cell_type": "markdown", "id": "d706aaae", "metadata": {}, "source": [ "**Fix.** Pass a callable (or a `(callable, unit)` tuple) instead of a number, so the quantity is recomputed from the current system state each time impacts are requested. Anything that varies with the simulation (electricity, chemical dosing, emissions) should be supplied this way. See [8. LCA](8_LCA.ipynb) for the full inventory surface." ] }, { "cell_type": "code", "execution_count": 22, "id": "19ebb64a", "metadata": { "execution": { "iopub.execute_input": "2026-05-30T20:34:59.536890Z", "iopub.status.busy": "2026-05-30T20:34:59.535892Z", "iopub.status.idle": "2026-05-30T20:34:59.542888Z", "shell.execute_reply": "2026-05-30T20:34:59.542888Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "doubled-flow GWP: 120,974 kg CO2-eq\n", "reverted GWP: 80,776 kg CO2-eq <- tracks the change\n" ] } ], "source": [ "# Pass a callable so the quantity is recomputed from the current system each time.\n", "lca_fixed = qs.LCA(system=sys, lifetime=lifetime, simulate_system=False,\n", " indicators=(GWP,),\n", " e_item=lambda: sys.power_utility.rate*24*365*lifetime)\n", "print(f'doubled-flow GWP: {lca_fixed.get_total_impacts()[GWP.ID]:,.0f} kg CO2-eq')\n", "\n", "# Revert the change and the callable tracks it back down.\n", "sys.flowsheet.stream.salt_water.F_mass /= 2\n", "sys.simulate()\n", "print(f'reverted GWP: {lca_fixed.get_total_impacts()[GWP.ID]:,.0f} kg CO2-eq <- tracks the change')" ] }, { "cell_type": "markdown", "id": "84c1a1d0", "metadata": {}, "source": [ "\n", "\n", "---\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 }