STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
wall_conduction.py
Go to the documentation of this file.
1"""One-dimensional transient conduction through a layered wall with a receding hot face.
2
3Backward Euler on a graded grid (fine at the hot face), layer interfaces on nodes, back face
4adiabatic -- the conservative bound when the backing is not declared. The hot-face flux is a
5function of the surface temperature, closed exactly each step: the interior is linear in the
6surface temperature, so the surface balance is a scalar root.
7
8Two recession modes:
9 * ablating (liner): the surface cannot exceed ``T_ablation``; once it gets there the flux
10 it cannot conduct away consumes material at ``H_surface`` J/kg (Landau problem);
11 * chemical (graphite): recession is the carbon mass flux the surface chemistry returns at
12 the solved surface temperature, not an energy balance.
13
14A receding face is handled by remapping the profile onto the shortened wall each step.
15
16A layer may carry ``cp_of(T)``: its heat capacity is then taken at each cell's temperature at the
17start of every step (lagged one step, as backward Euler does with a property). Without it the
18wall is exactly the constant-property model.
19"""
20
21from __future__ import annotations
22
23from dataclasses import dataclass, field
24from typing import Callable, List, Optional, Tuple
25
26import numpy as np
27from scipy.optimize import brentq
28
29
30@dataclass
31class Layer:
32 thickness: float
33 k: float
34 rho: float
35 cp: float
36 name: str = ""
37 cp_of: Optional[Callable[[np.ndarray], np.ndarray]] = None
38
39
40def _graded(L: float, n: int, first: float) -> np.ndarray:
41 """n+1 node positions on [0, L], geometric from a first cell of ``first`` (capped uniform)."""
42 if n < 1:
43 return np.array([0.0, L])
44 if first * n >= L:
45 return np.linspace(0.0, L, n + 1)
46 hi = 2.0
47 f = lambda r: first * (r ** n - 1.0) / (r - 1.0) - L
48 while f(hi) < 0:
49 hi *= 2.0
50 r = brentq(f, 1.0 + 1e-12, hi)
51 cells = first * r ** np.arange(n)
52 x = np.concatenate([[0.0], np.cumsum(cells)])
53 x[-1] = L
54 return x
55
56
57def _thomas(a: np.ndarray, b: np.ndarray, c: np.ndarray, d: np.ndarray) -> np.ndarray:
58 n = len(b)
59 cp = np.empty(n)
60 dp = np.empty(n)
61 cp[0] = c[0] / b[0]
62 dp[0] = d[0] / b[0]
63 for i in range(1, n):
64 m = b[i] - a[i] * cp[i - 1]
65 cp[i] = c[i] / m if i < n - 1 else 0.0
66 dp[i] = (d[i] - a[i] * dp[i - 1]) / m
67 x = np.empty(n)
68 x[-1] = dp[-1]
69 for i in range(n - 2, -1, -1):
70 x[i] = dp[i] - cp[i] * x[i + 1]
71 return x
72
73
74@dataclass
76 """Temperature field through ``layers`` (layers[0] at the hot face)."""
77 layers: List[Layer]
78 T_init: float
79 n_first: int = 80
80 n_other: int = 20
81 first_cell: float = 5e-6
82 receded: float = 0.0
83 y: np.ndarray = field(init=False)
84 T: np.ndarray = field(init=False)
85 T_surface_peak: float = field(init=False)
86 T_back_peak: float = field(init=False)
87
88 def __post_init__(self):
89 self._build_grid()
90 self.T = np.full_like(self.y, float(self.T_init))
91 self.T_surface_peak = float(self.T_init)
92 self.T_back_peak = float(self.T_init)
93
94 # -- grid -------------------------------------------------------------
95 def _build_grid(self):
96 segs, props = [], []
97 x0 = 0.0
98 for i, lay in enumerate(self.layers):
99 t = lay.thickness - (self.receded if i == 0 else 0.0)
100 if i == 0 and t <= 0:
101 t = 1e-9
102 n = self.n_first if i == 0 else self.n_other
103 first = self.first_cell if i == 0 else t / n
104 xs = _graded(t, n, first) + x0
105 segs.append(xs if i == 0 else xs[1:])
106 props.append((len(xs) - 1, lay))
107 x0 += t
108 self.y = np.concatenate(segs) # distance from the hot face
109 k_cell, rc_cell, owner = [], [], []
110 for n, lay in props:
111 k_cell += [lay.k] * n
112 rc_cell += [lay.rho * lay.cp] * n
113 owner += [lay] * n
114 self._k = np.array(k_cell)
115 self._rc = np.array(rc_cell)
116 self._varying = [(np.array([o is lay for o in owner]), lay)
117 for lay in self.layers if lay.cp_of is not None]
118 dx = np.diff(self.y)
119 self._dx = dx
120 self._G = self._k / dx # cell conductance
121 self._set_capacity()
122
123 def _set_capacity(self):
124 """Node heat capacity per area from the cell rho*cp (cp(T) layers at the cell mean T)."""
125 if self._varying and getattr(self, "T", None) is not None and len(self.T) == len(self.y):
126 Tc = 0.5 * (self.T[:-1] + self.T[1:])
127 for mask, lay in self._varying:
128 self._rc[mask] = lay.rho * lay.cp_of(Tc[mask])
129 C = np.zeros_like(self.y)
130 C[:-1] += 0.5 * self._rc * self._dx
131 C[1:] += 0.5 * self._rc * self._dx
132 self._C = C # node heat capacity per area
133
134 @property
135 def thickness_first(self) -> float:
136 return max(self.layers[0].thickness - self.receded, 0.0)
137
138 def _remap(self, dx_recede: float):
139 """Shorten layer 0 by dx_recede and carry the profile (measured from the back face)."""
140 L_old = self.y[-1]
141 z_old = L_old - self.y
142 T_old = self.T.copy()
143 self.receded += dx_recede
144 self._build_grid()
145 z_new = self.y[-1] - self.y
146 self.T = np.interp(z_new, z_old[::-1], T_old[::-1])
147
148 # -- one implicit step --------------------------------------------------
149 def _interior(self, dt: float, s: float) -> np.ndarray:
150 """Nodes 1..N at the new time for surface temperature s (Dirichlet at node 0)."""
151 G, C, T = self._G, self._C, self.T
152 n = len(T) - 1
153 a = np.zeros(n)
154 b = np.zeros(n)
155 c = np.zeros(n)
156 d = C[1:] / dt * T[1:]
157 for j in range(n):
158 i = j + 1
159 gl = G[i - 1]
160 gr = G[i] if i < len(G) else 0.0
161 a[j] = -gl
162 c[j] = -gr if i < len(G) else 0.0
163 b[j] = C[i] / dt + gl + gr
164 d[0] += G[0] * s
165 a[0] = 0.0
166 return _thomas(a, b, c, d)
167
168 def step(self, dt: float, q_in: Callable[[float], float],
169 T_ablation: Optional[float] = None, H_surface: Optional[float] = None,
170 rho_surface: Optional[float] = None,
171 chemical_mass_flux: Optional[Callable[[float], float]] = None,
172 T_bracket: Tuple[float, float] = (150.0, 4500.0)) -> dict:
173 """Advance by dt. Returns the surface temperature, the recession this step [m] and
174 the surface mass flux [kg/(m^2 s)]."""
175 if self._varying:
176 self._set_capacity()
177 G0, C0, T0 = self._G[0], self._C[0], self.T[0]
178 lin0 = self._interior(dt, 0.0)
179 lin1 = self._interior(dt, 1.0)
180 slope1 = lin1[0] - lin0[0]
181
182 def resid(s):
183 T1 = lin0[0] + slope1 * s
184 return q_in(s) - C0 * (s - T0) / dt - G0 * (s - T1)
185
186 lo, hi = T_bracket
187 rlo, rhi = resid(lo), resid(hi)
188 if rlo <= 0:
189 s = lo
190 elif rhi >= 0:
191 s = hi
192 else:
193 s = brentq(resid, lo, hi, xtol=1e-6)
194 mass_flux = 0.0
195 if T_ablation is not None and s > T_ablation:
196 s = float(T_ablation)
197 excess = resid(s)
198 mass_flux = max(excess, 0.0) / H_surface
199 elif chemical_mass_flux is not None:
200 mass_flux = max(chemical_mass_flux(s), 0.0)
201 interior = self._interior(dt, s)
202 self.T = np.concatenate([[s], interior])
203 recede = 0.0
204 if mass_flux > 0 and rho_surface:
205 recede = min(mass_flux / rho_surface * dt, max(self.thickness_first - 1e-6, 0.0))
206 if recede > 0:
207 self._remap(recede)
208 self.T[0] = s # the new face is the receding surface
209 self.T_surface_peak = max(self.T_surface_peak, float(self.T[0]))
210 self.T_back_peak = max(self.T_back_peak, float(self.T[-1]))
211 return {"T_surface": float(s), "recession": float(recede), "mass_flux": float(mass_flux)}
212
213 def advance(self, dt: float, n_sub: int, **kw) -> dict:
214 """dt split into n_sub implicit steps; returns the last surface state and totals."""
215 total_recession = 0.0
216 out = {"T_surface": float(self.T[0]), "recession": 0.0, "mass_flux": 0.0}
217 for _ in range(max(n_sub, 1)):
218 out = self.step(dt / max(n_sub, 1), **kw)
219 total_recession += out["recession"]
220 out["recession"] = total_recession
221 return out
222
223 # -- read-outs ------------------------------------------------------------
224 def depth_of_isotherm(self, T_iso: float) -> float:
225 """Distance below the current hot face to the first node cooler than T_iso [m]."""
226 above = self.T >= T_iso
227 if not above[0]:
228 return 0.0
229 idx = np.argmin(above)
230 if above.all():
231 return float(self.y[-1])
232 i = idx - 1
233 f = (self.T[i] - T_iso) / max(self.T[i] - self.T[idx], 1e-12)
234 return float(self.y[i] + f * (self.y[idx] - self.y[i]))
235
236 def interface_temperature(self, index: int) -> float:
237 """Temperature at the back of layers[index] (the bondline for index 0)."""
238 z = sum(l.thickness for l in self.layers[: index + 1]) - self.receded
239 return float(np.interp(z, self.y, self.T))
240
241 @property
242 def T_back(self) -> float:
243 return float(self.T[-1])
244
245 def soak(self, duration: float, dt: float = 0.05) -> dict:
246 """Continue with an adiabatic hot face after shutdown; peak back-face and bondline
247 temperatures reached while the stored heat redistributes."""
248 peak_back, peak_bond = self.T_back, self.interface_temperature(0)
249 t = 0.0
250 while t < duration - 1e-12:
251 h = min(dt, duration - t)
252 self.step(h, q_in=lambda s: 0.0)
253 peak_back = max(peak_back, self.T_back)
254 peak_bond = max(peak_bond, self.interface_temperature(0))
255 t += h
256 return {"T_back_peak": float(peak_back), "T_bondline_peak": float(peak_bond)}
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
dict soak(self, float duration, float dt=0.05)
dict advance(self, float dt, int n_sub, **kw)
np.ndarray _interior(self, float dt, float s)
dict step(self, float dt, Callable[[float], float] q_in, Optional[float] T_ablation=None, Optional[float] H_surface=None, Optional[float] rho_surface=None, Optional[Callable[[float], float]] chemical_mass_flux=None, Tuple[float, float] T_bracket=(150.0, 4500.0))
np.ndarray _thomas(np.ndarray a, np.ndarray b, np.ndarray c, np.ndarray d)
np.ndarray _graded(float L, int n, float first)