Skip to content

Commit 0c4950e

Browse files
committed
Add process block example notebooks: CSTR, heat exchanger, flash drum and distillation
1 parent e91891f commit 0c4950e

3 files changed

Lines changed: 444 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# Continuous Stirred-Tank Reactor\n",
8+
"\n",
9+
"Simulating the startup transient of an exothermic first-order reaction in a cooled CSTR, showing the dynamic interaction between concentration decay and temperature rise."
10+
]
11+
},
12+
{
13+
"cell_type": "markdown",
14+
"metadata": {},
15+
"source": [
16+
"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",
17+
"\n",
18+
"$$\\frac{dC_A}{dt} = \\frac{C_{A,\\text{in}} - C_A}{\\tau} - k(T)\\, C_A$$\n",
19+
"\n",
20+
"$$\\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",
21+
"\n",
22+
"where $k(T) = k_0 \\exp(-E_a / RT)$ and $\\tau = V/F$ is the residence time."
23+
]
24+
},
25+
{
26+
"cell_type": "code",
27+
"execution_count": null,
28+
"metadata": {},
29+
"outputs": [],
30+
"source": [
31+
"import matplotlib.pyplot as plt\n",
32+
"\n",
33+
"from pathsim import Simulation, Connection\n",
34+
"from pathsim.blocks import Source, Scope\n",
35+
"\n",
36+
"from pathsim_chem.process import CSTR"
37+
]
38+
},
39+
{
40+
"cell_type": "markdown",
41+
"metadata": {},
42+
"source": [
43+
"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."
44+
]
45+
},
46+
{
47+
"cell_type": "code",
48+
"execution_count": null,
49+
"metadata": {},
50+
"outputs": [],
51+
"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)"
52+
},
53+
{
54+
"cell_type": "markdown",
55+
"metadata": {},
56+
"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."
57+
},
58+
{
59+
"cell_type": "code",
60+
"execution_count": null,
61+
"metadata": {},
62+
"outputs": [],
63+
"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)"
64+
},
65+
{
66+
"cell_type": "code",
67+
"execution_count": null,
68+
"metadata": {},
69+
"outputs": [],
70+
"source": [
71+
"time, signals = scp.read()\n",
72+
"\n",
73+
"fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))\n",
74+
"\n",
75+
"ax1.plot(time, signals[0])\n",
76+
"ax1.set_xlabel(\"Time [s]\")\n",
77+
"ax1.set_ylabel(\"Concentration [mol/m³]\")\n",
78+
"ax1.set_title(\"Outlet Concentration\")\n",
79+
"ax1.grid(True, alpha=0.3)\n",
80+
"\n",
81+
"ax2.plot(time, signals[1])\n",
82+
"ax2.set_xlabel(\"Time [s]\")\n",
83+
"ax2.set_ylabel(\"Temperature [K]\")\n",
84+
"ax2.set_title(\"Reactor Temperature\")\n",
85+
"ax2.grid(True, alpha=0.3)\n",
86+
"\n",
87+
"plt.tight_layout()\n",
88+
"plt.show()"
89+
]
90+
},
91+
{
92+
"cell_type": "markdown",
93+
"metadata": {},
94+
"source": [
95+
"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."
96+
]
97+
}
98+
],
99+
"metadata": {
100+
"kernelspec": {
101+
"display_name": "Python 3",
102+
"language": "python",
103+
"name": "python3"
104+
},
105+
"language_info": {
106+
"name": "python",
107+
"version": "3.11.0"
108+
}
109+
},
110+
"nbformat": 4,
111+
"nbformat_minor": 4
112+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# Flash Drum and Distillation Column\n",
8+
"\n",
9+
"Simulating two fundamental separation processes: an isothermal binary flash drum and a multi-tray distillation column built from individual `DistillationTray` blocks wired in series."
10+
]
11+
},
12+
{
13+
"cell_type": "markdown",
14+
"metadata": {},
15+
"source": [
16+
"## Part 1: Isothermal Flash Drum\n",
17+
"\n",
18+
"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",
19+
"\n",
20+
"$$K_i = \\frac{P^\\text{sat}_i(T)}{P}$$\n",
21+
"\n",
22+
"The Rachford-Rice equation determines the vapor fraction $\\beta$, from which the vapor ($y_i$) and liquid ($x_i$) compositions follow."
23+
]
24+
},
25+
{
26+
"cell_type": "code",
27+
"execution_count": null,
28+
"metadata": {},
29+
"outputs": [],
30+
"source": [
31+
"import matplotlib.pyplot as plt\n",
32+
"\n",
33+
"from pathsim import Simulation, Connection\n",
34+
"from pathsim.blocks import Source, Scope\n",
35+
"\n",
36+
"from pathsim_chem.process import FlashDrum, DistillationTray"
37+
]
38+
},
39+
{
40+
"cell_type": "markdown",
41+
"metadata": {},
42+
"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."
43+
},
44+
{
45+
"cell_type": "code",
46+
"execution_count": null,
47+
"metadata": {},
48+
"outputs": [],
49+
"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)"
50+
},
51+
{
52+
"cell_type": "code",
53+
"execution_count": null,
54+
"metadata": {},
55+
"outputs": [],
56+
"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()"
57+
},
58+
{
59+
"cell_type": "markdown",
60+
"metadata": {},
61+
"source": [
62+
"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."
63+
]
64+
},
65+
{
66+
"cell_type": "markdown",
67+
"metadata": {},
68+
"source": [
69+
"## Part 2: Distillation Column (5 Trays)\n",
70+
"\n",
71+
"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",
72+
"\n",
73+
"$$y = \\frac{\\alpha\\, x}{1 + (\\alpha - 1)\\, x}$$\n",
74+
"\n",
75+
"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."
76+
]
77+
},
78+
{
79+
"cell_type": "code",
80+
"execution_count": null,
81+
"metadata": {},
82+
"outputs": [],
83+
"source": [
84+
"# Column parameters\n",
85+
"N_trays = 5\n",
86+
"alpha = 2.5 # relative volatility\n",
87+
"L = 5.0 # liquid flow rate [mol/s]\n",
88+
"V = 5.0 # vapor flow rate [mol/s]\n",
89+
"x_feed = 0.5 # feed composition (light component)\n",
90+
"\n",
91+
"# Create tray blocks (all start at x = 0.5)\n",
92+
"trays = [DistillationTray(M=1.0, alpha=alpha, x0=0.5) for _ in range(N_trays)]\n",
93+
"\n",
94+
"# Liquid feed from condenser (enters tray 0 from above)\n",
95+
"L_src = Source(func=lambda t: L)\n",
96+
"x_top = Source(func=lambda t: 0.95) # reflux composition (nearly pure light)\n",
97+
"\n",
98+
"# Vapor feed from reboiler (enters tray N-1 from below)\n",
99+
"V_src = Source(func=lambda t: V)\n",
100+
"y_bot = Source(func=lambda t: 0.05) # reboiler vapor (nearly pure heavy)\n",
101+
"\n",
102+
"# Record composition on each tray\n",
103+
"scp = Scope(labels=[f\"Tray {i+1}\" for i in range(N_trays)])"
104+
]
105+
},
106+
{
107+
"cell_type": "markdown",
108+
"metadata": {},
109+
"source": [
110+
"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."
111+
]
112+
},
113+
{
114+
"cell_type": "code",
115+
"execution_count": null,
116+
"metadata": {},
117+
"outputs": [],
118+
"source": [
119+
"connections = []\n",
120+
"\n",
121+
"# Top tray (0): liquid from reflux\n",
122+
"connections.append(Connection(L_src, trays[0])) # L_in -> port 0\n",
123+
"connections.append(Connection(x_top, trays[0][1])) # x_in -> port 1\n",
124+
"\n",
125+
"# Bottom tray (N-1): vapor from reboiler\n",
126+
"connections.append(Connection(V_src, trays[-1][2])) # V_in -> port 2\n",
127+
"connections.append(Connection(y_bot, trays[-1][3])) # y_in -> port 3\n",
128+
"\n",
129+
"# Inter-tray connections\n",
130+
"for i in range(N_trays - 1):\n",
131+
" # Liquid flows down: tray i -> tray i+1\n",
132+
" connections.append(Connection(trays[i], trays[i+1])) # L_out -> L_in\n",
133+
" connections.append(Connection(trays[i][1], trays[i+1][1])) # x_out -> x_in\n",
134+
"\n",
135+
" # Vapor flows up: tray i+1 -> tray i\n",
136+
" connections.append(Connection(trays[i+1][2], trays[i][2])) # V_out -> V_in\n",
137+
" connections.append(Connection(trays[i+1][3], trays[i][3])) # y_out -> y_in\n",
138+
"\n",
139+
"# Connect each tray's liquid composition to scope\n",
140+
"for i, tray in enumerate(trays):\n",
141+
" connections.append(Connection(tray[1], scp[i])) # x_out -> scope\n",
142+
"\n",
143+
"sim = Simulation(\n",
144+
" blocks=[L_src, x_top, V_src, y_bot, *trays, scp],\n",
145+
" connections=connections,\n",
146+
" dt=0.05,\n",
147+
")\n",
148+
"\n",
149+
"sim.run(30)"
150+
]
151+
},
152+
{
153+
"cell_type": "code",
154+
"execution_count": null,
155+
"metadata": {},
156+
"outputs": [],
157+
"source": [
158+
"time, signals = scp.read()\n",
159+
"\n",
160+
"fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))\n",
161+
"\n",
162+
"# Dynamic tray composition evolution\n",
163+
"for i in range(N_trays):\n",
164+
" ax1.plot(time, signals[i], label=f\"Tray {i+1}\")\n",
165+
"ax1.set_xlabel(\"Time [s]\")\n",
166+
"ax1.set_ylabel(r\"$x$ (light component)\")\n",
167+
"ax1.set_title(\"Tray Compositions Over Time\")\n",
168+
"ax1.legend()\n",
169+
"ax1.grid(True, alpha=0.3)\n",
170+
"\n",
171+
"# Steady-state composition profile\n",
172+
"x_profile = [tray.engine.state[0] for tray in trays]\n",
173+
"tray_nums = list(range(1, N_trays + 1))\n",
174+
"\n",
175+
"ax2.plot(tray_nums, x_profile, \"o-\", markersize=8)\n",
176+
"ax2.set_xlabel(\"Tray number (top to bottom)\")\n",
177+
"ax2.set_ylabel(r\"$x$ (light component)\")\n",
178+
"ax2.set_title(\"Composition Profile (Steady State)\")\n",
179+
"ax2.grid(True, alpha=0.3)\n",
180+
"\n",
181+
"plt.tight_layout()\n",
182+
"plt.show()"
183+
]
184+
},
185+
{
186+
"cell_type": "markdown",
187+
"metadata": {},
188+
"source": [
189+
"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."
190+
]
191+
}
192+
],
193+
"metadata": {
194+
"kernelspec": {
195+
"display_name": "Python 3",
196+
"language": "python",
197+
"name": "python3"
198+
},
199+
"language_info": {
200+
"name": "python",
201+
"version": "3.11.0"
202+
}
203+
},
204+
"nbformat": 4,
205+
"nbformat_minor": 4
206+
}

0 commit comments

Comments
 (0)