Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions docs/source/examples/cstr_reaction.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Continuous Stirred-Tank Reactor\n",
"\n",
"Simulating the startup transient of an exothermic first-order reaction in a cooled CSTR, showing the dynamic interaction between concentration decay and temperature rise."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The CSTR model couples a material balance with an energy balance. For a first-order irreversible reaction $A \\to \\text{products}$ with Arrhenius kinetics:\n",
"\n",
"$$\\frac{dC_A}{dt} = \\frac{C_{A,\\text{in}} - C_A}{\\tau} - k(T)\\, C_A$$\n",
"\n",
"$$\\frac{dT}{dt} = \\frac{T_\\text{in} - T}{\\tau} + \\frac{(-\\Delta H_\\text{rxn})}{\\rho\\, C_p}\\, k(T)\\, C_A + \\frac{UA}{\\rho\\, C_p\\, V}\\,(T_c - T)$$\n",
"\n",
"where $k(T) = k_0 \\exp(-E_a / RT)$ and $\\tau = V/F$ is the residence time."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import matplotlib.pyplot as plt\n",
"\n",
"from pathsim import Simulation, Connection\n",
"from pathsim.blocks import Source, Scope\n",
"\n",
"from pathsim_chem.process import CSTR"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Configure the reactor with parameters typical of an exothermic liquid-phase reaction. The reactor starts empty ($C_A = 0$) at ambient temperature and is fed with a concentrated stream."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "cstr = CSTR(\n V=1.0, # reactor volume [m³]\n F=0.05, # volumetric flow rate [m³/s] -> tau = 20 s\n k0=1e6, # pre-exponential factor [1/s]\n Ea=40000.0, # activation energy [J/mol]\n n=1.0, # first-order reaction\n dH_rxn=-40000.0, # exothermic [J/mol]\n rho=1000.0, # density [kg/m³]\n Cp=4184.0, # heat capacity [J/(kg·K)]\n UA=800.0, # cooling jacket [W/K]\n C_A0=0.0, # start empty\n T0=300.0, # initial temperature [K]\n)"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "Feed a constant concentration of 1000 mol/m³ at 320 K, with the coolant held at 290 K. A `Scope` records both the outlet concentration and temperature."
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "# Constant feed and coolant conditions\nC_feed = Source(func=lambda t: 1000.0) # feed concentration [mol/m³]\nT_feed = Source(func=lambda t: 320.0) # feed temperature [K]\nT_cool = Source(func=lambda t: 290.0) # coolant temperature [K]\n\nscp = Scope(labels=[\"C_A [mol/m³]\", \"T [K]\"])\n\nsim = Simulation(\n blocks=[C_feed, T_feed, T_cool, cstr, scp],\n connections=[\n Connection(C_feed, cstr), # C_in -> port 0\n Connection(T_feed, cstr[1]), # T_in -> port 1\n Connection(T_cool, cstr[2]), # T_c -> port 2\n Connection(cstr, scp), # C_out -> scope port 0\n Connection(cstr[1], scp[1]), # T_out -> scope port 1\n ],\n dt=0.1,\n)\n\nsim.run(200)"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"time, signals = scp.read()\n",
"\n",
"fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))\n",
"\n",
"ax1.plot(time, signals[0])\n",
"ax1.set_xlabel(\"Time [s]\")\n",
"ax1.set_ylabel(\"Concentration [mol/m³]\")\n",
"ax1.set_title(\"Outlet Concentration\")\n",
"ax1.grid(True, alpha=0.3)\n",
"\n",
"ax2.plot(time, signals[1])\n",
"ax2.set_xlabel(\"Time [s]\")\n",
"ax2.set_ylabel(\"Temperature [K]\")\n",
"ax2.set_title(\"Reactor Temperature\")\n",
"ax2.grid(True, alpha=0.3)\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The reactor starts cold and empty. As fresh feed enters, concentration rises initially but then the Arrhenius kinetics kick in — the exothermic reaction heats the fluid, which accelerates the rate, consuming more reactant. The cooling jacket prevents thermal runaway and the system settles to a steady state where reaction rate balances the feed."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11.0"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
206 changes: 206 additions & 0 deletions docs/source/examples/flash_distillation.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Flash Drum and Distillation Column\n",
"\n",
"Simulating two fundamental separation processes: an isothermal binary flash drum and a multi-tray distillation column built from individual `DistillationTray` blocks wired in series."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Part 1: Isothermal Flash Drum\n",
"\n",
"A flash drum separates a liquid feed into vapor and liquid streams using vapor-liquid equilibrium (VLE). The drum uses Raoult's law with Antoine correlations to compute K-values:\n",
"\n",
"$$K_i = \\frac{P^\\text{sat}_i(T)}{P}$$\n",
"\n",
"The Rachford-Rice equation determines the vapor fraction $\\beta$, from which the vapor ($y_i$) and liquid ($x_i$) compositions follow."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import matplotlib.pyplot as plt\n",
"\n",
"from pathsim import Simulation, Connection\n",
"from pathsim.blocks import Source, Scope\n",
"\n",
"from pathsim_chem.process import FlashDrum, DistillationTray"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": "Configure a flash drum for a benzene-toluene mixture (default Antoine coefficients). Feed an equimolar mixture at 1 atm and sweep temperature from 340 K to 400 K. This range covers the bubble point (~365 K) and dew point (~380 K) of the mixture, so we can observe the transition from all-liquid to two-phase to all-vapor."
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "flash = FlashDrum(holdup=100.0) # default benzene/toluene Antoine params\n\n# Feed: 10 mol/s, equimolar (z1 = 0.5), 1 atm\nF_src = Source(func=lambda t: 10.0)\nz_src = Source(func=lambda t: 0.5)\nT_src = Source(func=lambda t: 340.0 + t * 0.5) # ramp 340 -> 400 K\nP_src = Source(func=lambda t: 101325.0)\n\nscp = Scope(labels=[\"V_rate\", \"L_rate\", \"y_1 (benzene)\", \"x_1 (benzene)\"])\n\nsim = Simulation(\n blocks=[F_src, z_src, T_src, P_src, flash, scp],\n connections=[\n Connection(F_src, flash), # F -> port 0\n Connection(z_src, flash[1]), # z_1 -> port 1\n Connection(T_src, flash[2]), # T -> port 2\n Connection(P_src, flash[3]), # P -> port 3\n Connection(flash, scp), # V_rate -> scope 0\n Connection(flash[1], scp[1]), # L_rate -> scope 1\n Connection(flash[2], scp[2]), # y_1 -> scope 2\n Connection(flash[3], scp[3]), # x_1 -> scope 3\n ],\n dt=0.5,\n)\n\nsim.run(120)"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "time, signals = scp.read()\nT_sweep = 340.0 + time * 0.5\n\nfig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))\n\nax1.plot(T_sweep, signals[0], label=\"Vapor rate\")\nax1.plot(T_sweep, signals[1], label=\"Liquid rate\")\nax1.set_xlabel(\"Temperature [K]\")\nax1.set_ylabel(\"Flow rate [mol/s]\")\nax1.set_title(\"Flash Drum Flow Rates\")\nax1.legend()\nax1.grid(True, alpha=0.3)\n\nax2.plot(T_sweep, signals[2], label=r\"$y_1$ (vapor)\")\nax2.plot(T_sweep, signals[3], label=r\"$x_1$ (liquid)\")\nax2.axhline(0.5, color=\"gray\", ls=\"--\", alpha=0.4, label=\"Feed\")\nax2.set_xlabel(\"Temperature [K]\")\nax2.set_ylabel(\"Mole fraction (benzene)\")\nax2.set_title(\"VLE Compositions\")\nax2.legend()\nax2.grid(True, alpha=0.3)\n\nplt.tight_layout()\nplt.show()"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"As temperature increases, more liquid vaporizes (higher vapor rate). The vapor is enriched in the lighter component (benzene), while the liquid becomes richer in toluene — the classic VLE separation."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Part 2: Distillation Column (5 Trays)\n",
"\n",
"A distillation column is built by wiring multiple `DistillationTray` blocks in series. Each tray enforces vapor-liquid equilibrium with a constant relative volatility $\\alpha$:\n",
"\n",
"$$y = \\frac{\\alpha\\, x}{1 + (\\alpha - 1)\\, x}$$\n",
"\n",
"Under constant molar overflow (CMO), liquid flows down and vapor flows up with constant rates $L$ and $V$. We feed the column at the middle tray."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Column parameters\n",
"N_trays = 5\n",
"alpha = 2.5 # relative volatility\n",
"L = 5.0 # liquid flow rate [mol/s]\n",
"V = 5.0 # vapor flow rate [mol/s]\n",
"x_feed = 0.5 # feed composition (light component)\n",
"\n",
"# Create tray blocks (all start at x = 0.5)\n",
"trays = [DistillationTray(M=1.0, alpha=alpha, x0=0.5) for _ in range(N_trays)]\n",
"\n",
"# Liquid feed from condenser (enters tray 0 from above)\n",
"L_src = Source(func=lambda t: L)\n",
"x_top = Source(func=lambda t: 0.95) # reflux composition (nearly pure light)\n",
"\n",
"# Vapor feed from reboiler (enters tray N-1 from below)\n",
"V_src = Source(func=lambda t: V)\n",
"y_bot = Source(func=lambda t: 0.05) # reboiler vapor (nearly pure heavy)\n",
"\n",
"# Record composition on each tray\n",
"scp = Scope(labels=[f\"Tray {i+1}\" for i in range(N_trays)])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Wire the trays: liquid cascades downward (tray $i$ liquid output $\\to$ tray $i+1$ liquid input), vapor rises upward (tray $i$ vapor output $\\to$ tray $i-1$ vapor input). External feeds enter the top and bottom."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"connections = []\n",
"\n",
"# Top tray (0): liquid from reflux\n",
"connections.append(Connection(L_src, trays[0])) # L_in -> port 0\n",
"connections.append(Connection(x_top, trays[0][1])) # x_in -> port 1\n",
"\n",
"# Bottom tray (N-1): vapor from reboiler\n",
"connections.append(Connection(V_src, trays[-1][2])) # V_in -> port 2\n",
"connections.append(Connection(y_bot, trays[-1][3])) # y_in -> port 3\n",
"\n",
"# Inter-tray connections\n",
"for i in range(N_trays - 1):\n",
" # Liquid flows down: tray i -> tray i+1\n",
" connections.append(Connection(trays[i], trays[i+1])) # L_out -> L_in\n",
" connections.append(Connection(trays[i][1], trays[i+1][1])) # x_out -> x_in\n",
"\n",
" # Vapor flows up: tray i+1 -> tray i\n",
" connections.append(Connection(trays[i+1][2], trays[i][2])) # V_out -> V_in\n",
" connections.append(Connection(trays[i+1][3], trays[i][3])) # y_out -> y_in\n",
"\n",
"# Connect each tray's liquid composition to scope\n",
"for i, tray in enumerate(trays):\n",
" connections.append(Connection(tray[1], scp[i])) # x_out -> scope\n",
"\n",
"sim = Simulation(\n",
" blocks=[L_src, x_top, V_src, y_bot, *trays, scp],\n",
" connections=connections,\n",
" dt=0.05,\n",
")\n",
"\n",
"sim.run(30)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"time, signals = scp.read()\n",
"\n",
"fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))\n",
"\n",
"# Dynamic tray composition evolution\n",
"for i in range(N_trays):\n",
" ax1.plot(time, signals[i], label=f\"Tray {i+1}\")\n",
"ax1.set_xlabel(\"Time [s]\")\n",
"ax1.set_ylabel(r\"$x$ (light component)\")\n",
"ax1.set_title(\"Tray Compositions Over Time\")\n",
"ax1.legend()\n",
"ax1.grid(True, alpha=0.3)\n",
"\n",
"# Steady-state composition profile\n",
"x_profile = [tray.engine.state[0] for tray in trays]\n",
"tray_nums = list(range(1, N_trays + 1))\n",
"\n",
"ax2.plot(tray_nums, x_profile, \"o-\", markersize=8)\n",
"ax2.set_xlabel(\"Tray number (top to bottom)\")\n",
"ax2.set_ylabel(r\"$x$ (light component)\")\n",
"ax2.set_title(\"Composition Profile (Steady State)\")\n",
"ax2.grid(True, alpha=0.3)\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The column separates the light and heavy components across its trays. The top tray is enriched in the light component (high $x$) while the bottom tray is depleted. The steady-state composition profile shows the characteristic staircase that a McCabe-Thiele diagram would predict for this relative volatility."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11.0"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
Loading