Skip to content

Commit 921b10f

Browse files
committed
Expose per-cell temperatures as output ports on HeatExchanger and PFR
1 parent ab7223f commit 921b10f

5 files changed

Lines changed: 49 additions & 26 deletions

File tree

docs/source/examples/heat_exchanger.ipynb

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,30 +52,26 @@
5252
{
5353
"cell_type": "markdown",
5454
"metadata": {},
55-
"source": [
56-
"Feed hot water at 370 K and cold water at 290 K. Record the outlet temperatures over time."
57-
]
55+
"source": "Feed hot water at 370 K and cold water at 290 K. Record the outlet temperatures over time and the per-cell temperature profiles for spatial analysis."
5856
},
5957
{
6058
"cell_type": "code",
6159
"execution_count": null,
6260
"metadata": {},
6361
"outputs": [],
64-
"source": "T_h_in = Source(func=lambda t: 370.0) # hot inlet [K]\nT_c_in = Source(func=lambda t: 290.0) # cold inlet [K]\n\nscp = Scope(labels=[\"T_h_out [K]\", \"T_c_out [K]\"])\n\nsim = Simulation(\n blocks=[T_h_in, T_c_in, hx, scp],\n connections=[\n Connection(T_h_in, hx), # T_h_in -> port 0\n Connection(T_c_in, hx[1]), # T_c_in -> port 1\n Connection(hx, scp), # T_h_out -> scope port 0\n Connection(hx[1], scp[1]), # T_c_out -> scope port 1\n ],\n dt=0.02,\n)\n\nsim.run(200)"
62+
"source": "N = 10 # must match N_cells\n\nT_h_in = Source(func=lambda t: 370.0) # hot inlet [K]\nT_c_in = Source(func=lambda t: 290.0) # cold inlet [K]\n\nscp_out = Scope(labels=[\"T_h_out [K]\", \"T_c_out [K]\"])\nscp_hot = Scope(labels=[f\"T_h_{i+1}\" for i in range(N)])\nscp_cold = Scope(labels=[f\"T_c_{i+1}\" for i in range(N)])\n\nconnections = [\n Connection(T_h_in, hx), # T_h_in -> port 0\n Connection(T_c_in, hx[1]), # T_c_in -> port 1\n Connection(hx, scp_out), # T_h_out -> outlet scope\n Connection(hx[1], scp_out[1]), # T_c_out -> outlet scope\n]\n\n# Connect per-cell temperatures to profile scopes\nfor i in range(N):\n connections.append(Connection(hx[2 + i], scp_hot[i])) # T_h_i\n connections.append(Connection(hx[2 + N + i], scp_cold[i])) # T_c_i\n\nsim = Simulation(\n blocks=[T_h_in, T_c_in, hx, scp_out, scp_hot, scp_cold],\n connections=connections,\n dt=0.02,\n)\n\nsim.run(200)"
6563
},
6664
{
6765
"cell_type": "code",
6866
"execution_count": null,
6967
"metadata": {},
7068
"outputs": [],
71-
"source": "time, signals = scp.read()\n\nfig, ax = plt.subplots(figsize=(8, 5))\n\nax.plot(time, signals[0], label=\"Hot outlet\")\nax.plot(time, signals[1], label=\"Cold outlet\")\nax.axhline(370, color=\"gray\", ls=\"--\", alpha=0.4, label=\"Hot inlet\")\nax.axhline(290, color=\"gray\", ls=\"-.\", alpha=0.4, label=\"Cold inlet\")\nax.set_xlabel(\"Time [s]\")\nax.set_ylabel(\"Temperature [K]\")\nax.set_title(\"Heat Exchanger Outlet Temperatures\")\nax.legend()\nax.grid(True, alpha=0.3)\n\nplt.tight_layout()\nplt.show()"
69+
"source": "time, out = scp_out.read()\n_, hot_profile = scp_hot.read()\n_, cold_profile = scp_cold.read()\n\nfig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))\n\n# Outlet temperatures over time\nax1.plot(time, out[0], label=\"Hot outlet\")\nax1.plot(time, out[1], label=\"Cold outlet\")\nax1.axhline(370, color=\"gray\", ls=\"--\", alpha=0.4, label=\"Hot inlet\")\nax1.axhline(290, color=\"gray\", ls=\"-.\", alpha=0.4, label=\"Cold inlet\")\nax1.set_xlabel(\"Time [s]\")\nax1.set_ylabel(\"Temperature [K]\")\nax1.set_title(\"Outlet Temperatures\")\nax1.legend()\nax1.grid(True, alpha=0.3)\n\n# Spatial temperature profile at steady state\ncells = np.arange(1, N + 1)\nT_h_ss = [hot_profile[i][-1] for i in range(N)]\nT_c_ss = [cold_profile[i][-1] for i in range(N)]\n\nax2.plot(cells, T_h_ss, \"o-\", color=\"tab:red\", label=\"Hot stream\")\nax2.plot(cells, T_c_ss, \"s-\", color=\"tab:blue\", label=\"Cold stream\")\nax2.set_xlabel(\"Cell number (hot flow direction →)\")\nax2.set_ylabel(\"Temperature [K]\")\nax2.set_title(\"Spatial Profile (Steady State)\")\nax2.legend()\nax2.grid(True, alpha=0.3)\n\nplt.tight_layout()\nplt.show()"
7270
},
7371
{
7472
"cell_type": "markdown",
7573
"metadata": {},
76-
"source": [
77-
"At startup, both sides are cold. The hot fluid quickly warms the exchanger from the inlet side while the cold fluid absorbs heat. At steady state, the counter-current arrangement creates the characteristic temperature cross — the cold outlet approaches the hot inlet and vice versa, achieving high thermal efficiency."
78-
]
74+
"source": "At startup, both sides are cold. The hot fluid quickly warms the exchanger from the inlet side while the cold fluid absorbs heat. At steady state, the counter-current arrangement creates the characteristic temperature cross — the cold outlet approaches the hot inlet and vice versa. The spatial profile shows the classic counter-current pattern where both streams decrease along the hot flow direction."
7975
}
8076
],
8177
"metadata": {

src/pathsim_chem/process/heat_exchanger.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,6 @@ class HeatExchanger(DynamicalSystem):
7272
"T_c_in": 1,
7373
}
7474

75-
output_port_labels = {
76-
"T_h_out": 0,
77-
"T_c_out": 1,
78-
}
79-
8075
def __init__(self, N_cells=5, F_h=0.1, F_c=0.1, V_h=0.5, V_c=0.5,
8176
UA=500.0, rho_h=1000.0, Cp_h=4184.0, rho_c=1000.0, Cp_c=4184.0,
8277
T_h0=370.0, T_c0=300.0):
@@ -107,6 +102,12 @@ def __init__(self, N_cells=5, F_h=0.1, F_c=0.1, V_h=0.5, V_c=0.5,
107102

108103
N = self.N_cells
109104

105+
# dynamic output port labels: outlets + per-cell profiles
106+
self.output_port_labels = {"T_h_out": 0, "T_c_out": 1}
107+
for i in range(N):
108+
self.output_port_labels[f"T_h_{i+1}"] = 2 + i
109+
self.output_port_labels[f"T_c_{i+1}"] = 2 + N + i
110+
110111
# initial state: interleaved [T_h1, T_c1, T_h2, T_c2, ...]
111112
x0 = np.empty(2 * N)
112113
x0[0::2] = T_h0
@@ -192,9 +193,16 @@ def _jc_d(x, u, t):
192193

193194
return J
194195

195-
# output: hot outlet = last cell hot, cold outlet = first cell cold
196+
# output: outlets + per-cell profiles
197+
# [T_h_out, T_c_out, T_h_1..T_h_N, T_c_1..T_c_N]
196198
def _fn_a(x, u, t):
197-
return np.array([x[2*(self.N_cells - 1)], x[1]])
199+
N = self.N_cells
200+
out = np.empty(2 + 2 * N)
201+
out[0] = x[2 * (N - 1)] # T_h_out
202+
out[1] = x[1] # T_c_out
203+
out[2:N + 2] = x[0::2] # hot profile
204+
out[N + 2:] = x[1::2] # cold profile
205+
return out
198206

199207
super().__init__(
200208
func_dyn=_fn_d,

src/pathsim_chem/process/pfr.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,6 @@ class PFR(DynamicalSystem):
7676
"T_in": 1,
7777
}
7878

79-
output_port_labels = {
80-
"C_out": 0,
81-
"T_out": 1,
82-
}
83-
8479
def __init__(self, N_cells=5, V=1.0, F=0.1, k0=1e6, Ea=50000.0, n=1.0,
8580
dH_rxn=-50000.0, rho=1000.0, Cp=4184.0,
8681
C0=0.0, T0=300.0):
@@ -110,6 +105,12 @@ def __init__(self, N_cells=5, V=1.0, F=0.1, k0=1e6, Ea=50000.0, n=1.0,
110105

111106
N = self.N_cells
112107

108+
# dynamic output port labels: outlets + per-cell profiles
109+
self.output_port_labels = {"C_out": 0, "T_out": 1}
110+
for i in range(N):
111+
self.output_port_labels[f"C_{i+1}"] = 2 + i
112+
self.output_port_labels[f"T_{i+1}"] = 2 + N + i
113+
113114
# initial state: interleaved [C_1, T_1, C_2, T_2, ...]
114115
x0 = np.empty(2 * N)
115116
x0[0::2] = C0
@@ -196,10 +197,16 @@ def _jc_d(x, u, t):
196197

197198
return J
198199

199-
# output: last cell values
200+
# output: outlets + per-cell profiles
201+
# [C_out, T_out, C_1..C_N, T_1..T_N]
200202
def _fn_a(x, u, t):
201203
N = self.N_cells
202-
return np.array([x[2*(N-1)], x[2*(N-1) + 1]])
204+
out = np.empty(2 + 2 * N)
205+
out[0] = x[2 * (N - 1)] # C_out
206+
out[1] = x[2 * (N - 1) + 1] # T_out
207+
out[2:N + 2] = x[0::2] # concentration profile
208+
out[N + 2:] = x[1::2] # temperature profile
209+
return out
203210

204211
super().__init__(
205212
func_dyn=_fn_d,

tests/process/test_heat_exchanger.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,14 @@ def test_port_labels(self):
6060
"""Test port label definitions."""
6161
self.assertEqual(HeatExchanger.input_port_labels["T_h_in"], 0)
6262
self.assertEqual(HeatExchanger.input_port_labels["T_c_in"], 1)
63-
self.assertEqual(HeatExchanger.output_port_labels["T_h_out"], 0)
64-
self.assertEqual(HeatExchanger.output_port_labels["T_c_out"], 1)
63+
hx = HeatExchanger(N_cells=5)
64+
self.assertEqual(hx.output_port_labels["T_h_out"], 0)
65+
self.assertEqual(hx.output_port_labels["T_c_out"], 1)
66+
# per-cell ports
67+
self.assertEqual(hx.output_port_labels["T_h_1"], 2)
68+
self.assertEqual(hx.output_port_labels["T_c_1"], 7)
69+
self.assertEqual(hx.output_port_labels["T_h_5"], 6)
70+
self.assertEqual(hx.output_port_labels["T_c_5"], 11)
6571

6672
def test_state_size(self):
6773
"""Test that state vector has correct size."""

tests/process/test_pfr.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,14 @@ def test_port_labels(self):
6060
"""Test port label definitions."""
6161
self.assertEqual(PFR.input_port_labels["C_in"], 0)
6262
self.assertEqual(PFR.input_port_labels["T_in"], 1)
63-
self.assertEqual(PFR.output_port_labels["C_out"], 0)
64-
self.assertEqual(PFR.output_port_labels["T_out"], 1)
63+
pfr = PFR(N_cells=5)
64+
self.assertEqual(pfr.output_port_labels["C_out"], 0)
65+
self.assertEqual(pfr.output_port_labels["T_out"], 1)
66+
# per-cell ports
67+
self.assertEqual(pfr.output_port_labels["C_1"], 2)
68+
self.assertEqual(pfr.output_port_labels["T_1"], 7)
69+
self.assertEqual(pfr.output_port_labels["C_5"], 6)
70+
self.assertEqual(pfr.output_port_labels["T_5"], 11)
6571

6672
def test_state_size(self):
6773
"""Test that state vector has correct size."""

0 commit comments

Comments
 (0)