upde 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- upde/__init__.py +48 -0
- upde/chemistry.py +586 -0
- upde/equations.py +751 -0
- upde/upde.py +2447 -0
- upde-1.0.0.dist-info/METADATA +947 -0
- upde-1.0.0.dist-info/RECORD +7 -0
- upde-1.0.0.dist-info/WHEEL +4 -0
upde/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# uPDE — method-of-lines PDE solver
|
|
2
|
+
# https://doi.org/10.1016/j.softx.2026.102715
|
|
3
|
+
|
|
4
|
+
__version__ = "1.0.0"
|
|
5
|
+
__author__ = "Tony Saad"
|
|
6
|
+
__email__ = "tony.saad@utah.edu"
|
|
7
|
+
__license__ = "MIT"
|
|
8
|
+
|
|
9
|
+
# Core engine
|
|
10
|
+
from .upde import (
|
|
11
|
+
PDE,
|
|
12
|
+
PDESystem,
|
|
13
|
+
PDEUnsteadySolution,
|
|
14
|
+
PDESteadySolution,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Pre-built equation factories
|
|
18
|
+
from .equations import (
|
|
19
|
+
NamedPDESystem,
|
|
20
|
+
HeatEquation,
|
|
21
|
+
AdvectionDiffusion,
|
|
22
|
+
Burgers,
|
|
23
|
+
ConservationLaw,
|
|
24
|
+
ReactionDiffusion,
|
|
25
|
+
WaveEquation,
|
|
26
|
+
GrayScott,
|
|
27
|
+
NavierStokes2D,
|
|
28
|
+
MixtureFraction,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
# Core
|
|
33
|
+
"PDE",
|
|
34
|
+
"PDESystem",
|
|
35
|
+
"PDEUnsteadySolution",
|
|
36
|
+
"PDESteadySolution",
|
|
37
|
+
# Factories
|
|
38
|
+
"NamedPDESystem",
|
|
39
|
+
"HeatEquation",
|
|
40
|
+
"AdvectionDiffusion",
|
|
41
|
+
"Burgers",
|
|
42
|
+
"ConservationLaw",
|
|
43
|
+
"ReactionDiffusion",
|
|
44
|
+
"WaveEquation",
|
|
45
|
+
"GrayScott",
|
|
46
|
+
"NavierStokes2D",
|
|
47
|
+
"MixtureFraction",
|
|
48
|
+
]
|
upde/chemistry.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
# (c) 2026 Tony Saad <tony.saad@utah.edu>
|
|
2
|
+
# University of Utah, Department of Chemical Engineering
|
|
3
|
+
#
|
|
4
|
+
# This file is part of uPDE.
|
|
5
|
+
#
|
|
6
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
# in the Software without restriction, including without limitation the rights
|
|
9
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
# furnished to do so, subject to the following conditions:
|
|
12
|
+
#
|
|
13
|
+
# The above copyright notice and this permission notice shall be included in
|
|
14
|
+
# all copies or substantial portions of the Software.
|
|
15
|
+
#
|
|
16
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
22
|
+
# THE SOFTWARE.
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
chemistry.py
|
|
26
|
+
============
|
|
27
|
+
Flamelet-based thermochemistry for uPDE mixture-fraction transport.
|
|
28
|
+
|
|
29
|
+
The central object is :class:`FlameletTable`, which maps mixture fraction
|
|
30
|
+
Z ∈ [0, 1] to temperature T and species mass fractions Y_k via
|
|
31
|
+
pre-integrated 1-D counterflow flame profiles. At solve time the table
|
|
32
|
+
is just a numpy interpolation call — zero additional stiffness is added
|
|
33
|
+
to the PDE system.
|
|
34
|
+
|
|
35
|
+
Construction paths
|
|
36
|
+
------------------
|
|
37
|
+
1. **Analytic Burke-Schumann** (no external dependencies)::
|
|
38
|
+
|
|
39
|
+
table = FlameletTable.burke_schumann(
|
|
40
|
+
fuel='CH4', Z_st=0.055,
|
|
41
|
+
T_fuel=300.0, T_ox=300.0, T_ad=2230.0
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
2. **From saved file** (numpy .npz, no Cantera needed at solve time)::
|
|
45
|
+
|
|
46
|
+
table = FlameletTable.from_file('ch4_air_flamelet.npz')
|
|
47
|
+
|
|
48
|
+
3. **From raw arrays** (user supplies their own data)::
|
|
49
|
+
|
|
50
|
+
table = FlameletTable(Z_grid, T, species={'CH4': Y_CH4, 'O2': Y_O2})
|
|
51
|
+
|
|
52
|
+
4. **From Cantera** (requires ``pip install cantera``; generates a true
|
|
53
|
+
counterflow diffusion flame and extracts the Z-T-Y profiles)::
|
|
54
|
+
|
|
55
|
+
table = FlameletTable.from_cantera(
|
|
56
|
+
mechanism='gri30.yaml',
|
|
57
|
+
fuel='CH4',
|
|
58
|
+
oxidizer='O2:0.21,N2:0.79',
|
|
59
|
+
T_fuel=300.0, T_ox=300.0,
|
|
60
|
+
pressure=101325.0,
|
|
61
|
+
n_points=200,
|
|
62
|
+
)
|
|
63
|
+
table.save('ch4_air_flamelet.npz')
|
|
64
|
+
|
|
65
|
+
Usage at solve time
|
|
66
|
+
-------------------
|
|
67
|
+
All accessors accept any numpy array of shape matching the spatial grid::
|
|
68
|
+
|
|
69
|
+
T_field = table.T(sol.Z[:, -1]) # 1-D: shape (nx,)
|
|
70
|
+
T_field = table.T(sol.Z[:, :, -1]) # 2-D: shape (nx, ny)
|
|
71
|
+
Y_CH4 = table.Y('CH4', sol.Z[:, -1])
|
|
72
|
+
rho_field = table.rho(sol.Z[:, -1], P=101325.0)
|
|
73
|
+
|
|
74
|
+
Z values are automatically clipped to [0, 1] before interpolation.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
import numpy as np
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# FlameletTable
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
class FlameletTable:
|
|
85
|
+
"""
|
|
86
|
+
Pre-tabulated flamelet thermochemistry: Z → T, Y_k.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
Z_grid : 1-D array, shape (n,), values in [0, 1], monotonically increasing
|
|
91
|
+
T : 1-D array, shape (n,) — temperature [K]
|
|
92
|
+
species : dict[str, 1-D array] — {name: Y_k array of shape (n,)}
|
|
93
|
+
MW : dict[str, float] or None — molecular weights [kg/kmol];
|
|
94
|
+
used only by :meth:`rho`. If None, density is unavailable.
|
|
95
|
+
|
|
96
|
+
All arrays are copied on construction so the table is immutable after
|
|
97
|
+
creation.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
# Default molecular weights [kg/kmol] for common combustion species
|
|
101
|
+
_MW_DEFAULT = {
|
|
102
|
+
'CH4': 16.04,
|
|
103
|
+
'O2': 32.00,
|
|
104
|
+
'CO2': 44.01,
|
|
105
|
+
'H2O': 18.02,
|
|
106
|
+
'N2': 28.01,
|
|
107
|
+
'CO': 28.01,
|
|
108
|
+
'H2': 2.02,
|
|
109
|
+
'OH': 17.01,
|
|
110
|
+
'NO': 30.01,
|
|
111
|
+
'AR': 39.95,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def __init__(self, Z_grid, T, species, MW=None):
|
|
115
|
+
Z_grid = np.asarray(Z_grid, dtype=float)
|
|
116
|
+
T = np.asarray(T, dtype=float)
|
|
117
|
+
if Z_grid.ndim != 1 or T.ndim != 1:
|
|
118
|
+
raise ValueError("Z_grid and T must be 1-D arrays.")
|
|
119
|
+
if Z_grid.shape != T.shape:
|
|
120
|
+
raise ValueError("Z_grid and T must have the same length.")
|
|
121
|
+
if not np.all(np.diff(Z_grid) > 0):
|
|
122
|
+
raise ValueError("Z_grid must be strictly increasing.")
|
|
123
|
+
if Z_grid[0] < 0 or Z_grid[-1] > 1:
|
|
124
|
+
raise ValueError("Z_grid values must lie in [0, 1].")
|
|
125
|
+
|
|
126
|
+
self._Z = Z_grid.copy()
|
|
127
|
+
self._T = T.copy()
|
|
128
|
+
self._Y = {k: np.asarray(v, dtype=float).copy()
|
|
129
|
+
for k, v in species.items()}
|
|
130
|
+
for k, v in self._Y.items():
|
|
131
|
+
if v.shape != self._Z.shape:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"Species '{k}' array shape {v.shape} != Z_grid shape {self._Z.shape}."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Molecular weights: merge user-supplied with defaults
|
|
137
|
+
self._MW = dict(self._MW_DEFAULT)
|
|
138
|
+
if MW is not None:
|
|
139
|
+
self._MW.update(MW)
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Accessors
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def species(self):
|
|
147
|
+
"""List of species names stored in the table."""
|
|
148
|
+
return list(self._Y.keys())
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def Z_grid(self):
|
|
152
|
+
"""The mixture-fraction grid, shape (n,)."""
|
|
153
|
+
return self._Z.copy()
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def Z_st(self):
|
|
157
|
+
"""
|
|
158
|
+
Stoichiometric mixture fraction: Z where T is maximum.
|
|
159
|
+
Returns the Z value of peak temperature.
|
|
160
|
+
"""
|
|
161
|
+
return float(self._Z[np.argmax(self._T)])
|
|
162
|
+
|
|
163
|
+
def T(self, Z):
|
|
164
|
+
"""
|
|
165
|
+
Interpolate temperature [K] at mixture fraction Z.
|
|
166
|
+
|
|
167
|
+
Parameters
|
|
168
|
+
----------
|
|
169
|
+
Z : array-like — mixture fraction, clipped to [0, 1]
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
ndarray, same shape as Z
|
|
174
|
+
"""
|
|
175
|
+
Z = np.clip(np.asarray(Z, dtype=float), self._Z[0], self._Z[-1])
|
|
176
|
+
return np.interp(Z, self._Z, self._T)
|
|
177
|
+
|
|
178
|
+
def Y(self, species_name, Z):
|
|
179
|
+
"""
|
|
180
|
+
Interpolate species mass fraction Y_k at mixture fraction Z.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
species_name : str — must be in self.species
|
|
185
|
+
Z : array-like — mixture fraction, clipped to [0, 1]
|
|
186
|
+
|
|
187
|
+
Returns
|
|
188
|
+
-------
|
|
189
|
+
ndarray, same shape as Z
|
|
190
|
+
"""
|
|
191
|
+
if species_name not in self._Y:
|
|
192
|
+
raise KeyError(
|
|
193
|
+
f"Species '{species_name}' not in table. "
|
|
194
|
+
f"Available: {self.species}"
|
|
195
|
+
)
|
|
196
|
+
Z = np.clip(np.asarray(Z, dtype=float), self._Z[0], self._Z[-1])
|
|
197
|
+
return np.interp(Z, self._Z, self._Y[species_name])
|
|
198
|
+
|
|
199
|
+
def rho(self, Z, P=101325.0, R_u=8314.0):
|
|
200
|
+
"""
|
|
201
|
+
Mixture density [kg/m³] from ideal-gas law: ρ = P * W_mix / (R_u * T).
|
|
202
|
+
|
|
203
|
+
Requires molecular weights to be available (either via MW_DEFAULT
|
|
204
|
+
or the MW argument to the constructor) for all species in the table.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
Z : array-like — mixture fraction
|
|
209
|
+
P : float — pressure [Pa] (default 1 atm)
|
|
210
|
+
R_u : float — universal gas constant [J/kmol/K]
|
|
211
|
+
|
|
212
|
+
Returns
|
|
213
|
+
-------
|
|
214
|
+
ndarray, same shape as Z
|
|
215
|
+
"""
|
|
216
|
+
Z = np.clip(np.asarray(Z, dtype=float), self._Z[0], self._Z[-1])
|
|
217
|
+
T = self.T(Z)
|
|
218
|
+
|
|
219
|
+
# mean molecular weight: W_mix = 1 / sum(Y_k / W_k)
|
|
220
|
+
inv_W = np.zeros_like(Z)
|
|
221
|
+
for k, Y_arr in self._Y.items():
|
|
222
|
+
if k not in self._MW:
|
|
223
|
+
raise KeyError(
|
|
224
|
+
f"Molecular weight for species '{k}' not available. "
|
|
225
|
+
f"Pass MW={{'{k}': value}} to the constructor."
|
|
226
|
+
)
|
|
227
|
+
inv_W += np.interp(Z, self._Z, Y_arr) / self._MW[k]
|
|
228
|
+
W_mix = 1.0 / np.where(inv_W > 0, inv_W, 1e-30)
|
|
229
|
+
return P * W_mix / (R_u * T)
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
# Serialisation
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def save(self, path):
|
|
236
|
+
"""
|
|
237
|
+
Save the table to a numpy .npz file.
|
|
238
|
+
|
|
239
|
+
Parameters
|
|
240
|
+
----------
|
|
241
|
+
path : str — file path (the .npz extension is added if absent)
|
|
242
|
+
"""
|
|
243
|
+
arrays = {'Z_grid': self._Z, 'T': self._T}
|
|
244
|
+
for k, v in self._Y.items():
|
|
245
|
+
arrays[f'Y_{k}'] = v
|
|
246
|
+
np.savez(path, **arrays)
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def from_file(cls, path):
|
|
250
|
+
"""
|
|
251
|
+
Load a FlameletTable from a .npz file saved by :meth:`save`.
|
|
252
|
+
|
|
253
|
+
Parameters
|
|
254
|
+
----------
|
|
255
|
+
path : str — path to .npz file
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
FlameletTable
|
|
260
|
+
"""
|
|
261
|
+
data = np.load(path)
|
|
262
|
+
Z_grid = data['Z_grid']
|
|
263
|
+
T = data['T']
|
|
264
|
+
species = {k[2:]: data[k] for k in data.files
|
|
265
|
+
if k.startswith('Y_')}
|
|
266
|
+
return cls(Z_grid, T, species)
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
# Analytic table: Burke-Schumann equilibrium flame
|
|
270
|
+
# ------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
@classmethod
|
|
273
|
+
def burke_schumann(cls,
|
|
274
|
+
fuel='CH4',
|
|
275
|
+
Z_st=0.055,
|
|
276
|
+
T_fuel=300.0,
|
|
277
|
+
T_ox=300.0,
|
|
278
|
+
T_ad=2230.0,
|
|
279
|
+
n_points=500):
|
|
280
|
+
"""
|
|
281
|
+
Analytic Burke-Schumann (infinitely fast chemistry) flamelet table
|
|
282
|
+
for methane-air combustion. No external dependencies required.
|
|
283
|
+
|
|
284
|
+
The temperature profile is piecewise linear in Z, peaking at T_ad
|
|
285
|
+
at the stoichiometric mixture fraction Z_st. Species mass fractions
|
|
286
|
+
follow piecewise linear profiles consistent with complete combustion:
|
|
287
|
+
|
|
288
|
+
CH4 + 2 O2 → CO2 + 2 H2O
|
|
289
|
+
|
|
290
|
+
Nitrogen is treated as an inert diluent in air (Y_N2 = 0.767 on
|
|
291
|
+
the oxidizer side, diluted linearly with Z).
|
|
292
|
+
|
|
293
|
+
Parameters
|
|
294
|
+
----------
|
|
295
|
+
fuel : str — fuel label (stored as metadata; only 'CH4'
|
|
296
|
+
stoichiometry is implemented)
|
|
297
|
+
Z_st : float — stoichiometric mixture fraction (default 0.055
|
|
298
|
+
for CH4/air at 300 K, 1 atm)
|
|
299
|
+
T_fuel : float — fuel-stream temperature [K] (default 300 K)
|
|
300
|
+
T_ox : float — oxidizer-stream temperature [K] (default 300 K)
|
|
301
|
+
T_ad : float — adiabatic flame temperature [K] (default 2230 K)
|
|
302
|
+
n_points : int — number of Z points in the table
|
|
303
|
+
|
|
304
|
+
Returns
|
|
305
|
+
-------
|
|
306
|
+
FlameletTable
|
|
307
|
+
|
|
308
|
+
Notes
|
|
309
|
+
-----
|
|
310
|
+
The Burke-Schumann solution is exact for one-step infinitely-fast
|
|
311
|
+
chemistry. It places the "flame" as an infinitely thin sheet at
|
|
312
|
+
Z = Z_st. Real flames are broader due to finite-rate chemistry and
|
|
313
|
+
differential diffusion; use :meth:`from_cantera` for quantitative work.
|
|
314
|
+
|
|
315
|
+
Default Z_st = 0.055 corresponds to stoichiometric CH4/air
|
|
316
|
+
(mass-based: 1 kg CH4 needs ~17.2 kg air, Z_st = 1/18.2 ≈ 0.055).
|
|
317
|
+
"""
|
|
318
|
+
Z = np.linspace(0.0, 1.0, n_points)
|
|
319
|
+
|
|
320
|
+
# --- Temperature: piecewise linear, peak at Z_st ---
|
|
321
|
+
T = np.where(
|
|
322
|
+
Z <= Z_st,
|
|
323
|
+
T_ox + (T_ad - T_ox) * (Z / Z_st),
|
|
324
|
+
T_ad + (T_fuel - T_ad) * ((Z - Z_st) / (1.0 - Z_st)),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# --- Species mass fractions (CH4/air, complete combustion) ---
|
|
328
|
+
# Stoichiometry: CH4 + 2 O2 → CO2 + 2 H2O
|
|
329
|
+
# MW: CH4=16.04, O2=32.00, CO2=44.01, H2O=18.02, N2=28.01
|
|
330
|
+
# Air: Y_O2=0.233, Y_N2=0.767 (mass fractions)
|
|
331
|
+
#
|
|
332
|
+
# Fuel side (Z=1): Y_CH4=1, all others=0
|
|
333
|
+
# Ox side (Z=0): Y_O2=0.233, Y_N2=0.767, all others=0
|
|
334
|
+
#
|
|
335
|
+
# Lean side (Z < Z_st): CH4 completely consumed
|
|
336
|
+
# Y_CH4 = 0
|
|
337
|
+
# Y_O2 = 0.233*(1-Z) - (32/16.04)*Z (O2 consumed by CH4)
|
|
338
|
+
# clamped to 0 at Z=Z_st
|
|
339
|
+
# Y_CO2 = (44.01/16.04)*Z (CO2 produced)
|
|
340
|
+
# Y_H2O = (2*18.02/16.04)*Z (H2O produced)
|
|
341
|
+
# Y_N2 = 0.767*(1-Z)
|
|
342
|
+
#
|
|
343
|
+
# Rich side (Z > Z_st): O2 completely consumed
|
|
344
|
+
# Y_CH4 = Z - Z_st*(16.04/(2*32.00))*(1-Z)/(1-Z_st) [approx]
|
|
345
|
+
# simpler: linear from 0 at Z_st to 1 at Z=1
|
|
346
|
+
# Y_O2 = 0
|
|
347
|
+
# Y_CO2 and Y_H2O follow O2-limited production
|
|
348
|
+
|
|
349
|
+
nu_O2 = 2.0 * 32.00 / 16.04 # kg O2 per kg CH4
|
|
350
|
+
nu_CO2 = 44.01 / 16.04 # kg CO2 per kg CH4
|
|
351
|
+
nu_H2O = 2.0 * 18.02 / 16.04 # kg H2O per kg CH4
|
|
352
|
+
|
|
353
|
+
Y_O2_air = 0.233
|
|
354
|
+
Y_N2_air = 0.767
|
|
355
|
+
|
|
356
|
+
# --- Lean side (Z <= Z_st) ---
|
|
357
|
+
Z_lean = np.minimum(Z, Z_st)
|
|
358
|
+
Y_CH4_lean = np.zeros(n_points)
|
|
359
|
+
Y_O2_lean = np.maximum(Y_O2_air * (1.0 - Z) - nu_O2 * Z, 0.0)
|
|
360
|
+
Y_CO2_lean = nu_CO2 * Z_lean
|
|
361
|
+
Y_H2O_lean = nu_H2O * Z_lean
|
|
362
|
+
Y_N2_lean = Y_N2_air * (1.0 - Z)
|
|
363
|
+
|
|
364
|
+
# --- Rich side (Z > Z_st) ---
|
|
365
|
+
# Available O2 at the stoichiometric point
|
|
366
|
+
Z_rich = np.maximum(Z, Z_st)
|
|
367
|
+
# Fraction of fuel burned = limited by available O2
|
|
368
|
+
# At Z_st all O2 is consumed: Y_O2 = 0 for Z >= Z_st
|
|
369
|
+
Z_hat = (Z - Z_st) / (1.0 - Z_st + 1e-30) # 0 at Z_st, 1 at Z=1
|
|
370
|
+
Y_CH4_rich = Z_hat # linear 0→1
|
|
371
|
+
Y_O2_rich = np.zeros(n_points)
|
|
372
|
+
# CO2 and H2O produced are limited by O2 available at Z_st
|
|
373
|
+
# Amount burned at Z_st point scales with oxidiser available in mixture
|
|
374
|
+
Y_CO2_rich = nu_CO2 * Z_st * (1.0 - Z_hat)
|
|
375
|
+
Y_H2O_rich = nu_H2O * Z_st * (1.0 - Z_hat)
|
|
376
|
+
Y_N2_rich = Y_N2_air * (1.0 - Z_rich)
|
|
377
|
+
|
|
378
|
+
# --- Blend lean/rich with step at Z_st ---
|
|
379
|
+
lean = Z <= Z_st
|
|
380
|
+
Y_CH4 = np.where(lean, Y_CH4_lean, Y_CH4_rich)
|
|
381
|
+
Y_O2 = np.where(lean, Y_O2_lean, Y_O2_rich)
|
|
382
|
+
Y_CO2 = np.where(lean, Y_CO2_lean, Y_CO2_rich)
|
|
383
|
+
Y_H2O = np.where(lean, Y_H2O_lean, Y_H2O_rich)
|
|
384
|
+
Y_N2 = np.where(lean, Y_N2_lean, Y_N2_rich)
|
|
385
|
+
|
|
386
|
+
# Normalise so sum of Y_k = 1 everywhere (enforce conservation)
|
|
387
|
+
Y_sum = Y_CH4 + Y_O2 + Y_CO2 + Y_H2O + Y_N2
|
|
388
|
+
Y_CH4 /= Y_sum
|
|
389
|
+
Y_O2 /= Y_sum
|
|
390
|
+
Y_CO2 /= Y_sum
|
|
391
|
+
Y_H2O /= Y_sum
|
|
392
|
+
Y_N2 /= Y_sum
|
|
393
|
+
|
|
394
|
+
species = {
|
|
395
|
+
'CH4': Y_CH4,
|
|
396
|
+
'O2': Y_O2,
|
|
397
|
+
'CO2': Y_CO2,
|
|
398
|
+
'H2O': Y_H2O,
|
|
399
|
+
'N2': Y_N2,
|
|
400
|
+
}
|
|
401
|
+
return cls(Z, T, species)
|
|
402
|
+
|
|
403
|
+
# ------------------------------------------------------------------
|
|
404
|
+
# Cantera-based table generation (optional dependency)
|
|
405
|
+
# ------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
@classmethod
|
|
408
|
+
def from_cantera(cls,
|
|
409
|
+
mechanism='gri30.yaml',
|
|
410
|
+
fuel='CH4',
|
|
411
|
+
oxidizer='O2:0.21,N2:0.79',
|
|
412
|
+
T_fuel=300.0,
|
|
413
|
+
T_ox=300.0,
|
|
414
|
+
pressure=101325.0,
|
|
415
|
+
width=0.02,
|
|
416
|
+
n_points=200,
|
|
417
|
+
loglevel=0):
|
|
418
|
+
"""
|
|
419
|
+
Generate a FlameletTable from a Cantera counterflow diffusion flame.
|
|
420
|
+
|
|
421
|
+
Requires ``pip install cantera``. The flame is solved on a 1-D domain
|
|
422
|
+
with fuel on the right and oxidiser on the left; the mixture fraction
|
|
423
|
+
is computed at every grid point using Bilger's definition, and the
|
|
424
|
+
$Z$-$T$-$Y_k$ profiles are extracted, sorted, and interpolated onto
|
|
425
|
+
a uniform Z grid.
|
|
426
|
+
|
|
427
|
+
Parameters
|
|
428
|
+
----------
|
|
429
|
+
mechanism : str — Cantera mechanism file, e.g. 'gri30.yaml'
|
|
430
|
+
fuel : str — fuel species name, e.g. 'CH4'
|
|
431
|
+
oxidizer : str — oxidizer composition, e.g. 'O2:0.21,N2:0.79'
|
|
432
|
+
T_fuel : float — fuel-stream temperature [K]
|
|
433
|
+
T_ox : float — oxidizer-stream temperature [K]
|
|
434
|
+
pressure : float — pressure [Pa]
|
|
435
|
+
width : float — domain width [m] (default 2 cm)
|
|
436
|
+
n_points : int — number of Z points in the output table
|
|
437
|
+
loglevel : int — Cantera verbosity (0 = silent)
|
|
438
|
+
|
|
439
|
+
Returns
|
|
440
|
+
-------
|
|
441
|
+
FlameletTable
|
|
442
|
+
|
|
443
|
+
Raises
|
|
444
|
+
------
|
|
445
|
+
ImportError
|
|
446
|
+
If Cantera is not installed.
|
|
447
|
+
|
|
448
|
+
Notes
|
|
449
|
+
-----
|
|
450
|
+
The mixture fraction is computed via Bilger's definition:
|
|
451
|
+
|
|
452
|
+
Z = (β - β_ox) / (β_fuel - β_ox)
|
|
453
|
+
|
|
454
|
+
where β = 2*Z_C/W_C + 0.5*Z_H/W_H - Z_O/W_O (elemental mass
|
|
455
|
+
fractions divided by atomic weights).
|
|
456
|
+
|
|
457
|
+
Example
|
|
458
|
+
-------
|
|
459
|
+
table = FlameletTable.from_cantera(
|
|
460
|
+
mechanism='gri30.yaml',
|
|
461
|
+
fuel='CH4',
|
|
462
|
+
oxidizer='O2:0.21,N2:0.79',
|
|
463
|
+
)
|
|
464
|
+
table.save('ch4_air_gri30.npz')
|
|
465
|
+
"""
|
|
466
|
+
try:
|
|
467
|
+
import cantera as ct
|
|
468
|
+
except ImportError:
|
|
469
|
+
raise ImportError(
|
|
470
|
+
"Cantera is required for FlameletTable.from_cantera(). "
|
|
471
|
+
"Install it with: pip install cantera\n"
|
|
472
|
+
"Alternatively, use FlameletTable.burke_schumann() for an "
|
|
473
|
+
"analytic approximation that needs no extra dependencies."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# --- Set up fuel and oxidizer streams ---
|
|
477
|
+
gas = ct.Solution(mechanism)
|
|
478
|
+
|
|
479
|
+
gas.TPX = T_fuel, pressure, f'{fuel}:1.0'
|
|
480
|
+
gas_fuel = gas.X.copy()
|
|
481
|
+
|
|
482
|
+
gas.TPX = T_ox, pressure, oxidizer
|
|
483
|
+
gas_ox = gas.X.copy()
|
|
484
|
+
|
|
485
|
+
# --- Solve counterflow diffusion flame ---
|
|
486
|
+
f = ct.CounterflowDiffusionFlame(gas, width=width)
|
|
487
|
+
f.fuel_inlet.mdot = 0.24 # kg/m²/s — typical value
|
|
488
|
+
f.fuel_inlet.X = gas_fuel
|
|
489
|
+
f.fuel_inlet.T = T_fuel
|
|
490
|
+
f.oxidizer_inlet.mdot = 0.72
|
|
491
|
+
f.oxidizer_inlet.X = gas_ox
|
|
492
|
+
f.oxidizer_inlet.T = T_ox
|
|
493
|
+
f.set_refine_criteria(ratio=3, slope=0.1, curve=0.2, prune=0.02)
|
|
494
|
+
f.solve(loglevel=loglevel, auto=True)
|
|
495
|
+
|
|
496
|
+
# --- Compute Bilger mixture fraction ---
|
|
497
|
+
# Atomic weights [kg/kmol]
|
|
498
|
+
W_C, W_H, W_O = 12.011, 1.008, 15.999
|
|
499
|
+
|
|
500
|
+
# Elemental mass fractions at each grid point
|
|
501
|
+
Z_C = np.array([
|
|
502
|
+
sum(gas.atomic_weight('C') * gas.n_atoms(sp, 'C') * f.Y[gas.species_index(sp), :]
|
|
503
|
+
for sp in gas.species_names)
|
|
504
|
+
]).squeeze() if 'C' in [e for e in gas.element_names] else np.zeros(len(f.grid))
|
|
505
|
+
|
|
506
|
+
# Simpler: use Cantera's built-in mixture fraction if available (ct >= 3.0)
|
|
507
|
+
try:
|
|
508
|
+
Z_bilger = f.mixture_fraction(fuel, oxidizer)
|
|
509
|
+
except AttributeError:
|
|
510
|
+
# Fallback: compute manually for C-H-O system
|
|
511
|
+
Y_all = f.Y # shape (n_species, n_points)
|
|
512
|
+
beta = np.zeros(Y_all.shape[1])
|
|
513
|
+
for sp_idx, sp_name in enumerate(gas.species_names):
|
|
514
|
+
sp = gas.species(sp_idx)
|
|
515
|
+
n_C = sp.composition.get('C', 0)
|
|
516
|
+
n_H = sp.composition.get('H', 0)
|
|
517
|
+
n_O = sp.composition.get('O', 0)
|
|
518
|
+
MW = gas.molecular_weights[sp_idx]
|
|
519
|
+
beta += Y_all[sp_idx] * (
|
|
520
|
+
2.0 * n_C / (W_C * MW) * MW +
|
|
521
|
+
0.5 * n_H / (W_H * MW) * MW -
|
|
522
|
+
1.0 * n_O / (W_O * MW) * MW
|
|
523
|
+
) / MW * gas.molecular_weights[sp_idx]
|
|
524
|
+
|
|
525
|
+
# Recompute at pure fuel and pure oxidizer
|
|
526
|
+
gas.TPX = T_fuel, pressure, f'{fuel}:1.0'
|
|
527
|
+
beta_fuel = sum(
|
|
528
|
+
gas.Y[gas.species_index(sp)] * (
|
|
529
|
+
2.0 * gas.species(gas.species_index(sp)).composition.get('C', 0) / W_C +
|
|
530
|
+
0.5 * gas.species(gas.species_index(sp)).composition.get('H', 0) / W_H -
|
|
531
|
+
1.0 * gas.species(gas.species_index(sp)).composition.get('O', 0) / W_O
|
|
532
|
+
)
|
|
533
|
+
for sp in gas.species_names
|
|
534
|
+
)
|
|
535
|
+
gas.TPX = T_ox, pressure, oxidizer
|
|
536
|
+
beta_ox = sum(
|
|
537
|
+
gas.Y[gas.species_index(sp)] * (
|
|
538
|
+
2.0 * gas.species(gas.species_index(sp)).composition.get('C', 0) / W_C +
|
|
539
|
+
0.5 * gas.species(gas.species_index(sp)).composition.get('H', 0) / W_H -
|
|
540
|
+
1.0 * gas.species(gas.species_index(sp)).composition.get('O', 0) / W_O
|
|
541
|
+
)
|
|
542
|
+
for sp in gas.species_names
|
|
543
|
+
)
|
|
544
|
+
Z_bilger = (beta - beta_ox) / (beta_fuel - beta_ox + 1e-30)
|
|
545
|
+
|
|
546
|
+
Z_bilger = np.clip(Z_bilger, 0.0, 1.0)
|
|
547
|
+
T_flame = f.T
|
|
548
|
+
Y_flame = f.Y # shape (n_species, n_points)
|
|
549
|
+
|
|
550
|
+
# --- Sort by Z (flame profile is not monotone in Z in general) ---
|
|
551
|
+
order = np.argsort(Z_bilger)
|
|
552
|
+
Z_sorted = Z_bilger[order]
|
|
553
|
+
T_sorted = T_flame[order]
|
|
554
|
+
Y_sorted = Y_flame[:, order]
|
|
555
|
+
|
|
556
|
+
# Remove duplicate Z values (keep last = highest T at stoich)
|
|
557
|
+
_, unique_idx = np.unique(Z_sorted, return_index=True)
|
|
558
|
+
Z_sorted = Z_sorted[unique_idx]
|
|
559
|
+
T_sorted = T_sorted[unique_idx]
|
|
560
|
+
Y_sorted = Y_sorted[:, unique_idx]
|
|
561
|
+
|
|
562
|
+
# --- Interpolate onto uniform Z grid ---
|
|
563
|
+
Z_uniform = np.linspace(0.0, 1.0, n_points)
|
|
564
|
+
T_out = np.interp(Z_uniform, Z_sorted, T_sorted)
|
|
565
|
+
species = {}
|
|
566
|
+
for sp_idx, sp_name in enumerate(gas.species_names):
|
|
567
|
+
Y_sp = np.interp(Z_uniform, Z_sorted, Y_sorted[sp_idx])
|
|
568
|
+
if np.any(Y_sp > 1e-10): # skip trace species
|
|
569
|
+
species[sp_name] = Y_sp
|
|
570
|
+
|
|
571
|
+
MW = {sp: gas.molecular_weights[gas.species_index(sp)]
|
|
572
|
+
for sp in species}
|
|
573
|
+
|
|
574
|
+
return cls(Z_uniform, T_out, species, MW=MW)
|
|
575
|
+
|
|
576
|
+
# ------------------------------------------------------------------
|
|
577
|
+
# Repr
|
|
578
|
+
# ------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
def __repr__(self):
|
|
581
|
+
return (
|
|
582
|
+
f"FlameletTable(n={len(self._Z)}, "
|
|
583
|
+
f"Z=[{self._Z[0]:.3f},{self._Z[-1]:.3f}], "
|
|
584
|
+
f"T=[{self._T[0]:.0f},{self._T.max():.0f}]K, "
|
|
585
|
+
f"species={self.species})"
|
|
586
|
+
)
|