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 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
+ )