turbo-design 1.3.8__py3-none-any.whl → 1.3.10__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.
Files changed (48) hide show
  1. {turbo_design-1.3.8.dist-info → turbo_design-1.3.10.dist-info}/METADATA +2 -1
  2. turbo_design-1.3.10.dist-info/RECORD +46 -0
  3. {turbo_design-1.3.8.dist-info → turbo_design-1.3.10.dist-info}/WHEEL +1 -1
  4. turbodesign/__init__.py +57 -4
  5. turbodesign/agf.py +346 -0
  6. turbodesign/arrayfuncs.py +31 -1
  7. turbodesign/bladerow.py +238 -155
  8. turbodesign/compressor_math.py +386 -0
  9. turbodesign/compressor_spool.py +941 -0
  10. turbodesign/coolant.py +18 -6
  11. turbodesign/deviation/__init__.py +5 -0
  12. turbodesign/deviation/axial_compressor.py +3 -0
  13. turbodesign/deviation/carter_deviation.py +79 -0
  14. turbodesign/deviation/deviation_base.py +20 -0
  15. turbodesign/deviation/fixed_deviation.py +42 -0
  16. turbodesign/enums.py +5 -6
  17. turbodesign/flow_math.py +158 -0
  18. turbodesign/inlet.py +126 -56
  19. turbodesign/isentropic.py +59 -15
  20. turbodesign/loss/__init__.py +3 -1
  21. turbodesign/loss/compressor/OTAC_README.md +39 -0
  22. turbodesign/loss/compressor/__init__.py +54 -0
  23. turbodesign/loss/compressor/diffusion.py +61 -0
  24. turbodesign/loss/compressor/lieblein.py +1 -0
  25. turbodesign/loss/compressor/otac.py +799 -0
  26. turbodesign/loss/compressor/references/schobeiri-2012-shock-loss-model-for-transonic-and-supersonic-axial-compressors-with-curved-blades.pdf +0 -0
  27. turbodesign/loss/fixedpolytropic.py +27 -0
  28. turbodesign/loss/fixedpressureloss.py +30 -0
  29. turbodesign/loss/losstype.py +2 -30
  30. turbodesign/loss/turbine/TD2.py +25 -29
  31. turbodesign/loss/turbine/__init__.py +0 -1
  32. turbodesign/loss/turbine/ainleymathieson.py +6 -5
  33. turbodesign/loss/turbine/craigcox.py +6 -5
  34. turbodesign/loss/turbine/fixedefficiency.py +8 -7
  35. turbodesign/loss/turbine/kackerokapuu.py +7 -5
  36. turbodesign/loss/turbine/traupel.py +17 -16
  37. turbodesign/outlet.py +81 -22
  38. turbodesign/passage.py +98 -63
  39. turbodesign/row_factory.py +129 -0
  40. turbodesign/solve_radeq.py +9 -10
  41. turbodesign/{td_math.py → turbine_math.py} +144 -185
  42. turbodesign/turbine_spool.py +1219 -0
  43. turbo_design-1.3.8.dist-info/RECORD +0 -33
  44. turbodesign/compressorspool.py +0 -60
  45. turbodesign/loss/turbine/fixedpressureloss.py +0 -25
  46. turbodesign/rotor.py +0 -38
  47. turbodesign/spool.py +0 -317
  48. turbodesign/turbinespool.py +0 -543
@@ -0,0 +1,799 @@
1
+ """OTAC compressor loss model translations (best effort).
2
+
3
+ This module ports the OTAC ``*.int`` compressor loss models into the current
4
+ Python API. Geometry/flow mappings assume ``upstream`` is ``FL_IR`` and ``row``
5
+ is ``FL_OR``. Many correlations require design parameters (e.g., blade counts,
6
+ clearances); these are exposed as constructor arguments with pragmatic defaults.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import warnings
12
+ import numpy as np
13
+ import numpy.typing as npt
14
+
15
+ from ...bladerow import BladeRow
16
+ from ...enums import LossType
17
+ from ..losstype import LossBaseClass
18
+
19
+
20
+ def _mean(val, default: float = 0.0) -> float:
21
+ try:
22
+ arr = np.asarray(val)
23
+ return float(np.mean(arr)) if arr.size else default
24
+ except Exception:
25
+ return default
26
+
27
+
28
+ def _mag(val) -> float:
29
+ arr = np.asarray(val)
30
+ return float(np.linalg.norm(arr))
31
+
32
+
33
+ def _span(row: BladeRow) -> float:
34
+ r = np.asarray(row.r)
35
+ return float(np.max(r) - np.min(r))
36
+
37
+
38
+ def _hub(row: BladeRow) -> float:
39
+ return float(np.min(np.asarray(row.r)))
40
+
41
+
42
+ def _tip(row: BladeRow) -> float:
43
+ return float(np.max(np.asarray(row.r)))
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # High-level placeholders for axial/turbine/NASA correlations
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class AxialCompressorAungier(LossBaseClass):
52
+ """Aungier axial-compressor pressure-loss (omega)."""
53
+
54
+ def __init__(self):
55
+ super().__init__(LossType.Pressure)
56
+
57
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
58
+ Vm1 = _mean(getattr(upstream, "Vm", upstream.V))
59
+ Vm2 = _mean(getattr(row, "Vm", row.V))
60
+ Vt1 = _mean(getattr(upstream, "Vt", upstream.V))
61
+ Vt2 = _mean(getattr(row, "Vt", row.V))
62
+ M2 = _mean(getattr(row, "M", 0.0))
63
+
64
+ Vm1 = max(Vm1, 1e-6)
65
+ turning = abs(np.arctan2(Vt2, Vm2) - np.arctan2(Vt1, Vm1))
66
+
67
+ Df = abs(Vt2 - Vt1) / Vm1 + max(0.0, 1 - Vm2 / Vm1)
68
+ Deq = Df # stand-in for equivalent diffusion factor
69
+
70
+ omega_prof = 0.04 + 0.6 * Df**2 + 0.2 * max(0.0, Deq - 0.5)
71
+ omega_sec = 0.01 * (turning**2)
72
+ tip_clearance = float(getattr(row, "tip_clearance", 0.0))
73
+ omega_tip = 0.02 * tip_clearance
74
+ omega_shock = 0.0
75
+ if M2 > 0.9:
76
+ omega_shock = 0.02 * (M2 - 0.9) ** 2
77
+
78
+ omega = omega_prof + omega_sec + omega_tip + omega_shock
79
+ omega = np.maximum(omega, 0.0)
80
+ return np.full_like(row.r, omega, dtype=float)
81
+
82
+
83
+ class AxialCompressorEntropy(LossBaseClass):
84
+ """Koch-Smith axial-compressor loss (entropy-based)."""
85
+
86
+ def __init__(self):
87
+ super().__init__(LossType.Entropy)
88
+
89
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
90
+ Vm1 = _mean(getattr(upstream, "Vm", upstream.V))
91
+ Vm2 = _mean(getattr(row, "Vm", row.V))
92
+ Vt1 = _mean(getattr(upstream, "Vt", upstream.V))
93
+ Vt2 = _mean(getattr(row, "Vt", row.V))
94
+ M2 = _mean(getattr(row, "M", 0.0))
95
+
96
+ Vm1 = max(Vm1, 1e-6)
97
+ turning = abs(np.arctan2(Vt2, Vm2) - np.arctan2(Vt1, Vm1))
98
+ Df = abs(Vt2 - Vt1) / Vm1 + max(0.0, 1 - Vm2 / Vm1)
99
+
100
+ ds_over_cp = 0.03 + 0.5 * Df**2 + 0.1 * (turning**2)
101
+ if M2 > 1.0:
102
+ ds_over_cp += 0.05 * (M2 - 1.0) ** 2
103
+
104
+ return np.full_like(row.r, ds_over_cp, dtype=float)
105
+
106
+
107
+ class AxialCompressorWrightMiller(LossBaseClass):
108
+ """Wright-Miller axial compressor loss (pressure coefficient)."""
109
+
110
+ def __init__(self):
111
+ super().__init__(LossType.Pressure)
112
+
113
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
114
+ beta1 = _mean(np.degrees(getattr(row, "beta1", upstream.beta2)))
115
+ beta2 = _mean(np.degrees(getattr(row, "beta2", row.beta2)))
116
+ turning = abs(beta2 - beta1)
117
+ V_ratio = _mean(getattr(row, "V", row.V)) / max(_mean(getattr(upstream, "V", upstream.V)), 1e-6)
118
+ omega = abs(np.tan(np.radians(beta1)) - np.tan(np.radians(beta2)))
119
+ omega *= (0.5 + 0.5 * V_ratio) * (1 + turning / 90.0)
120
+ omega = np.maximum(omega, 0.0)
121
+ return np.full_like(row.r, omega, dtype=float)
122
+
123
+
124
+ class AxialTurbineAinleyMathiesonOTAC(LossBaseClass):
125
+ """Placeholder for OTAC axial turbine Ainley-Mathieson."""
126
+
127
+ def __init__(self):
128
+ super().__init__(LossType.Pressure)
129
+
130
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
131
+ warnings.warn("AxialTurbineAinleyMathiesonOTAC not yet translated; returning zeros.", stacklevel=2)
132
+ return np.zeros_like(row.r)
133
+
134
+
135
+ class AxialTurbineKackerOkapuuOTAC(LossBaseClass):
136
+ """Placeholder for OTAC axial turbine Kacker-Okapuu."""
137
+
138
+ def __init__(self):
139
+ super().__init__(LossType.Pressure)
140
+
141
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
142
+ warnings.warn("AxialTurbineKackerOkapuuOTAC not yet translated; returning zeros.", stacklevel=2)
143
+ return np.zeros_like(row.r)
144
+
145
+
146
+ class NASA23B20(LossBaseClass):
147
+ """NASA23B/20 compressor loss correlation (simplified profile + incidence)."""
148
+
149
+ def __init__(self):
150
+ super().__init__(LossType.Pressure)
151
+
152
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
153
+ beta1 = _mean(np.degrees(upstream.beta2))
154
+ beta2 = _mean(np.degrees(row.beta2))
155
+ turning = abs(beta2 - beta1)
156
+ Vm1 = _mean(getattr(upstream, "Vm", upstream.V))
157
+ Vm2 = _mean(getattr(row, "Vm", row.V))
158
+ Vm1 = max(Vm1, 1e-6)
159
+ DF = abs(_mean(getattr(row, "Vt", row.V)) - _mean(getattr(upstream, "Vt", upstream.V))) / Vm1 + max(
160
+ 0.0, 1 - Vm2 / Vm1
161
+ )
162
+ omega_min = 0.06 + 0.4 * DF**2
163
+ i_ml = 1.5
164
+ incidence = abs(beta1 - i_ml)
165
+ omega = omega_min * (1 + (incidence / 15) ** 2) * (1 + turning / 120)
166
+ return np.full_like(row.r, omega, dtype=float)
167
+
168
+
169
+ class NASA74A(LossBaseClass):
170
+ """NASA 74A 5-stage axial compressor loss (simplified)."""
171
+
172
+ def __init__(self):
173
+ super().__init__(LossType.Pressure)
174
+
175
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
176
+ Vm1 = _mean(getattr(upstream, "Vm", upstream.V))
177
+ Vm2 = _mean(getattr(row, "Vm", row.V))
178
+ Vm1 = max(Vm1, 1e-6)
179
+ Vt1 = _mean(getattr(upstream, "Vt", upstream.V))
180
+ Vt2 = _mean(getattr(row, "Vt", row.V))
181
+ DF = abs(Vt2 - Vt1) / Vm1 + max(0.0, 1 - Vm2 / Vm1)
182
+ if len(np.asarray(row.r)) > 1:
183
+ pct_rad = np.linspace(0, 1, len(row.r))
184
+ else:
185
+ pct_rad = np.array([0.5])
186
+ base = 0.02 + 0.08 * DF**2
187
+ radial_factor = 1 + 0.3 * pct_rad
188
+ omega = base * radial_factor
189
+ return np.array(omega, dtype=float)
190
+
191
+
192
+ class RadialInput(LossBaseClass):
193
+ """Radial input helper (no additional loss)."""
194
+
195
+ def __init__(self):
196
+ super().__init__(LossType.Pressure)
197
+
198
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
199
+ return np.zeros_like(row.r)
200
+
201
+
202
+ class DiffuserVanelessStanitz(LossBaseClass):
203
+ """Stanitz vaneless diffuser loss (pressure)."""
204
+
205
+ def __init__(self):
206
+ super().__init__(LossType.Enthalpy)
207
+
208
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
209
+ r_in = _tip(upstream)
210
+ r_out = _tip(row)
211
+ length = max(r_out - r_in, 1e-6)
212
+ Dh = 2 * _span(row) if _span(row) > 0 else 1.0
213
+ Re = abs(_mean(row.V)) * Dh * _mean(getattr(row, "rho", 1.0)) / max(getattr(row, "mu", 1.0), 1e-6)
214
+ Cf = 0.026 / (Re ** 0.2) if Re > 0 else 0.0
215
+ loss = Cf * length / Dh * (_mean(row.V) ** 2) / 2
216
+ loss = np.maximum(loss, 0.0)
217
+ return np.full_like(row.r, loss, dtype=float)
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Impeller correlations (translated)
222
+ # ---------------------------------------------------------------------------
223
+
224
+
225
+ class ImpellerBladeLoadingAungier(LossBaseClass):
226
+ """Aungier blade loading loss."""
227
+
228
+ def __init__(self, number_of_blades: int = 12, splitter_le: float = 0.0, loading_coefficient: float = 1.0):
229
+ super().__init__(LossType.Enthalpy)
230
+ self.number_of_blades = number_of_blades
231
+ self.splitter_le = splitter_le
232
+ self.loading_coefficient = loading_coefficient
233
+
234
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
235
+ mean_blades = self.number_of_blades / 2 + (self.number_of_blades / 2) * (1 - self.splitter_le)
236
+ radius_exit = _tip(row)
237
+ radius_inlet = _hub(upstream)
238
+ lb = float(getattr(row, "chord", radius_exit - radius_inlet))
239
+
240
+ dVrel = 2 * np.pi * (2 * radius_exit) * _mean(row.U) * self.loading_coefficient / (mean_blades * lb)
241
+ dh_blade = dVrel**2 / 48.0
242
+
243
+ kbar = (_mean(row.phi) - _mean(upstream.phi)) / max(lb, 1e-6)
244
+ bbar = ((radius_exit - radius_inlet) + _span(row)) / 2.0
245
+ Vrelbar = (_mag(getattr(upstream, "W", upstream.V)) + _mag(getattr(row, "W", row.V))) / 2.0
246
+ dh_hub_shroud = (kbar * bbar * Vrelbar) ** 2 / 12.0
247
+
248
+ loss = dh_blade + dh_hub_shroud
249
+ loss = np.maximum(loss, 0.0)
250
+ return np.full_like(row.r, loss, dtype=float)
251
+
252
+
253
+ class ImpellerBladeLoadingCoppage(LossBaseClass):
254
+ """Coppage blade loading loss."""
255
+
256
+ def __init__(
257
+ self,
258
+ number_of_blades: int = 12,
259
+ splitter_le: float = 0.0,
260
+ loading_coefficient: float = 1.0,
261
+ surge_vrel: float = 1.0,
262
+ ):
263
+ super().__init__(LossType.Enthalpy)
264
+ self.number_of_blades = number_of_blades
265
+ self.splitter_le = splitter_le
266
+ self.loading_coefficient = loading_coefficient
267
+ self.surge_vrel = surge_vrel
268
+
269
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
270
+ Vrel_out = _mag(getattr(row, "W", row.V))
271
+ Kbl = 0.75 if self.splitter_le == 0 else 0.6
272
+ r_tip_in = _tip(upstream)
273
+ r_exit = _tip(row)
274
+ denom = (self.surge_vrel / max(Vrel_out, 1e-6)) * (
275
+ (self.number_of_blades / np.pi) * (1 - r_tip_in / max(r_exit, 1e-6)) + 2 * r_tip_in / max(r_exit, 1e-6)
276
+ )
277
+ Df = 1 - Vrel_out / max(self.surge_vrel, 1e-6) + Kbl * self.loading_coefficient / max(denom, 1e-6)
278
+ dh = 0.05 * Df**2 * (_mean(row.U) ** 2)
279
+ dh = np.maximum(dh, 0.0)
280
+ return np.full_like(row.r, dh, dtype=float)
281
+
282
+
283
+ class ImpellerClearanceJansen(LossBaseClass):
284
+ """Jansen clearance loss."""
285
+
286
+ def __init__(
287
+ self,
288
+ number_of_blades: int = 12,
289
+ tip_clearance_axial: float = 0.0,
290
+ exit_blade_height: float | None = None,
291
+ loss_modifier: float = 1.0,
292
+ ):
293
+ super().__init__(LossType.Enthalpy)
294
+ self.number_of_blades = number_of_blades
295
+ self.tip_clearance_axial = tip_clearance_axial
296
+ self.exit_blade_height = exit_blade_height
297
+ self.loss_modifier = loss_modifier
298
+
299
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
300
+ h_exit = self.exit_blade_height if self.exit_blade_height is not None else _span(row)
301
+ Vtheta = _mean(getattr(row, "Vt", row.V))
302
+ Vm = _mean(getattr(row, "Vm", row.V))
303
+ r_tip_in = _tip(upstream)
304
+ r_hub_in = _hub(upstream)
305
+ r_exit = _tip(row)
306
+ ratio = abs(r_exit - r_tip_in)
307
+ rho_ratio = _mean(getattr(row, "rho", 1.0)) / max(_mean(getattr(upstream, "rho", 1.0)), 1e-6)
308
+ inner = (4 * np.pi) / (h_exit * self.number_of_blades) * (
309
+ (r_tip_in**2 - r_hub_in**2) / (ratio * (1 + rho_ratio))
310
+ ) * Vtheta * Vm
311
+ dh = 0.6 * (self.tip_clearance_axial / max(h_exit, 1e-6)) * Vtheta * np.sqrt(max(inner, 0.0))
312
+ dh *= self.loss_modifier
313
+ dh = np.maximum(dh, 0.0)
314
+ return np.full_like(row.r, dh, dtype=float)
315
+
316
+
317
+ class ImpellerDiscFrictionDaily(LossBaseClass):
318
+ """Daily & Nece disc friction loss."""
319
+
320
+ def __init__(self, bf_gap: float | None = None, loss_modifier: float = 1.0):
321
+ super().__init__(LossType.Enthalpy)
322
+ self.bf_gap = bf_gap
323
+ self.loss_modifier = loss_modifier
324
+
325
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
326
+ radius_exit = _tip(row)
327
+ span = _span(row)
328
+ gap = self.bf_gap if self.bf_gap is not None else float(getattr(row, "tip_clearance", 0.0) * span)
329
+ gap = gap if gap > 0 else 1e-5
330
+
331
+ rho_avg = _mean([getattr(upstream, "rho", 0.0), getattr(row, "rho", 0.0)], 1.0)
332
+ mu = float(getattr(row, "mu", 0.0) or getattr(upstream, "mu", 0.0) or 1.0)
333
+
334
+ U = np.asarray(getattr(row, "U", row.omega * row.r))
335
+ W_in = _mag(getattr(upstream, "W", upstream.V))
336
+ if W_in == 0:
337
+ W_in = _mag(getattr(upstream, "V", 1e-6))
338
+
339
+ Re = np.abs(U) * radius_exit * rho_avg / mu
340
+ Re = np.maximum(Re, 1e-6)
341
+
342
+ f_df = np.where(
343
+ Re < 3e5,
344
+ 3.7 * (gap / radius_exit) ** 0.1 / np.sqrt(Re),
345
+ 0.102 * (gap / radius_exit) ** 0.1 / (Re ** 0.2),
346
+ )
347
+
348
+ dh_disc = f_df * rho_avg * (radius_exit**2) * (U**3) / (4 * W_in)
349
+ loss = self.loss_modifier * dh_disc
350
+
351
+ cp = _mean([row.Cp, upstream.Cp], row.Cp)
352
+ ht_delta = cp * (np.asarray(row.T0) - np.asarray(upstream.T0))
353
+ cap = 0.25 * ht_delta
354
+ loss = np.minimum(loss, cap)
355
+ loss = np.maximum(loss, 0.0)
356
+
357
+ return np.array(loss, dtype=float)
358
+
359
+
360
+ class ImpellerIncidenceAungier(LossBaseClass):
361
+ """Aungier incidence loss."""
362
+
363
+ def __init__(self, blade_inlet_angle_deg: float | None = None, loss_modifier: float = 1.0):
364
+ super().__init__(LossType.Enthalpy)
365
+ self.blade_inlet_angle_deg = blade_inlet_angle_deg
366
+ self.loss_modifier = loss_modifier
367
+
368
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
369
+ r_tip = _tip(upstream)
370
+ r_hub = _hub(upstream)
371
+ omega = float(getattr(upstream, "omega", 0.0))
372
+ V = _mag(upstream.V)
373
+ Vm = _mean(getattr(upstream, "Vm", upstream.V))
374
+ alpha = _mean(np.degrees(upstream.alpha1))
375
+
376
+ U_tip = omega * r_tip
377
+ U_hub = omega * r_hub
378
+ Vtheta_tip_rel = U_tip - _mean(getattr(upstream, "Vt", 0.0))
379
+ Vtheta_hub_rel = U_hub - _mean(getattr(upstream, "Vt", 0.0))
380
+ Vrel_tip = np.hypot(V * np.cos(np.radians(alpha)), Vtheta_tip_rel)
381
+ Vrel_hub = np.hypot(V * np.cos(np.radians(alpha)), Vtheta_hub_rel)
382
+
383
+ blade_beta = self.blade_inlet_angle_deg
384
+ if blade_beta is None:
385
+ beta_metal = getattr(upstream, "beta1_metal", None)
386
+ blade_beta = _mean(beta_metal if beta_metal is not None else np.degrees(upstream.beta1))
387
+
388
+ target = abs(Vm / max(np.cos(np.radians(blade_beta)), 1e-6))
389
+
390
+ dh_hub = 0.4 * (Vrel_hub - target) ** 2
391
+ dh_tip = 0.4 * (Vrel_tip - target) ** 2
392
+ dh_mean = 0.4 * (_mag(getattr(upstream, "W", upstream.V)) - target) ** 2
393
+ dh_inc = (dh_hub + dh_tip + 10 * dh_mean) / 12.0
394
+ dh_inc = np.maximum(dh_inc * self.loss_modifier, 0.0)
395
+ return np.full_like(row.r, dh_inc, dtype=float)
396
+
397
+
398
+ class ImpellerIncidenceConrad(LossBaseClass):
399
+ """Conrad incidence loss."""
400
+
401
+ def __init__(
402
+ self,
403
+ leading_edge_thickness: float | None = None,
404
+ number_of_blades: int = 12,
405
+ blade_inlet_angle_deg: float | None = None,
406
+ radius_tip_inlet: float | None = None,
407
+ radius_hub_inlet: float | None = None,
408
+ f_incidence: float = 1.0,
409
+ loss_modifier_inc: float = 1.0,
410
+ ):
411
+ super().__init__(LossType.Enthalpy)
412
+ self.leading_edge_thickness = leading_edge_thickness
413
+ self.number_of_blades = number_of_blades
414
+ self.blade_inlet_angle_deg = blade_inlet_angle_deg
415
+ self.radius_tip_inlet = radius_tip_inlet
416
+ self.radius_hub_inlet = radius_hub_inlet
417
+ self.f_incidence = f_incidence
418
+ self.loss_modifier_inc = loss_modifier_inc
419
+
420
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
421
+ r_tip = self.radius_tip_inlet if self.radius_tip_inlet is not None else _tip(upstream)
422
+ r_hub = self.radius_hub_inlet if self.radius_hub_inlet is not None else _hub(upstream)
423
+ le_thickness = self.leading_edge_thickness
424
+ if le_thickness is None:
425
+ chord = float(getattr(row, "chord", 0.0) or 1.0)
426
+ le_thickness = 0.02 * chord
427
+
428
+ blade_inlet_angle = self.blade_inlet_angle_deg
429
+ if blade_inlet_angle is None:
430
+ beta_metal = getattr(upstream, "beta1_metal", None)
431
+ blade_inlet_angle = _mean(beta_metal if beta_metal is not None else np.degrees(upstream.beta1))
432
+
433
+ area = float(np.pi * (r_tip**2 - r_hub**2))
434
+ beta_opt_rad = np.arctan(
435
+ area
436
+ / (area - le_thickness * (r_tip - r_hub) * self.number_of_blades / 2.0)
437
+ * np.tan(np.radians(blade_inlet_angle))
438
+ )
439
+ beta_opt_deg = np.degrees(beta_opt_rad)
440
+
441
+ beta_in_deg = _mean(np.degrees(upstream.beta1))
442
+ Vrel = _mag(getattr(upstream, "W", upstream.V))
443
+ if Vrel == 0:
444
+ Vrel = _mag(upstream.V)
445
+
446
+ Wui = Vrel * np.sin(np.radians(abs(beta_in_deg - beta_opt_deg)))
447
+ dh_incidence = self.f_incidence * 0.5 * Wui**2
448
+
449
+ loss = self.loss_modifier_inc * dh_incidence
450
+ loss = np.maximum(loss, 0.0)
451
+ return np.full_like(row.r, loss, dtype=float)
452
+
453
+
454
+ class ImpellerLeakageAungier(LossBaseClass):
455
+ """Aungier tip-leakage loss."""
456
+
457
+ def __init__(
458
+ self,
459
+ number_of_blades: int = 12,
460
+ splitter_le: float = 0.0,
461
+ seal_clearance: float | None = None,
462
+ loading_coefficient: float = 1.0,
463
+ loss_modifier: float = 1.0,
464
+ ):
465
+ super().__init__(LossType.Enthalpy)
466
+ self.number_of_blades = number_of_blades
467
+ self.splitter_le = splitter_le
468
+ self.seal_clearance = seal_clearance
469
+ self.loading_coefficient = loading_coefficient
470
+ self.loss_modifier = loss_modifier
471
+
472
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
473
+ mean_blades = self.number_of_blades / 2 + (self.number_of_blades / 2) * (1 - self.splitter_le)
474
+ r_exit = _tip(row)
475
+ r_in = _hub(upstream)
476
+ r_tip_in = _tip(upstream)
477
+ bwidth = _span(row)
478
+ rbar = (r_in + r_exit) / 2.0
479
+ bbar = ((r_tip_in - r_in) + bwidth) / 2.0
480
+ lb = float(getattr(row, "chord", r_exit - r_in))
481
+
482
+ Vtheta_out = _mean(getattr(row, "Vt", row.V))
483
+ Vtheta_in = _mean(getattr(upstream, "Vt", upstream.V))
484
+ rho_out = _mean(getattr(row, "rho", 1.0))
485
+ deltaP = rho_out * abs(r_exit * Vtheta_out - r_in * Vtheta_in) / max(lb, 1e-6)
486
+ seal_gap = self.seal_clearance if self.seal_clearance is not None else float(getattr(row, "tip_clearance", 0.0) * bwidth)
487
+
488
+ Ucl = 0.816 * np.sqrt(max(2 * deltaP / max(rho_out, 1e-6), 0.0))
489
+ mdot_cl = rho_out * mean_blades * seal_gap * lb * Ucl
490
+ W_out = _mag(getattr(row, "W", row.V))
491
+ U_out = _mean(row.U)
492
+ dh_leakage = (mdot_cl * Ucl) / max(2 * W_out * max(U_out, 1e-6), 1e-6) * U_out**2
493
+
494
+ loss = self.loss_modifier * dh_leakage
495
+ loss = np.maximum(loss, 0.0)
496
+ return np.full_like(row.r, loss, dtype=float)
497
+
498
+
499
+ class ImpellerMixingAungier(LossBaseClass):
500
+ """Aungier & Dean mixing loss."""
501
+
502
+ def __init__(
503
+ self,
504
+ number_of_blades: int = 12,
505
+ splitter_le: float = 0.0,
506
+ loading_coefficient: float = 1.0,
507
+ area_exit_factor: float = 0.9,
508
+ loss_modifier: float = 1.0,
509
+ ):
510
+ super().__init__(LossType.Enthalpy)
511
+ self.number_of_blades = number_of_blades
512
+ self.splitter_le = splitter_le
513
+ self.loading_coefficient = loading_coefficient
514
+ self.area_exit_factor = area_exit_factor
515
+ self.loss_modifier = loss_modifier
516
+
517
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
518
+ mean_blades = self.number_of_blades / 2 + (self.number_of_blades / 2) * (1 - self.splitter_le)
519
+ r_exit = _tip(row)
520
+ lb = float(getattr(row, "chord", r_exit - _hub(upstream)))
521
+ dVrel = 2 * np.pi * (2 * r_exit) * _mean(row.U) * self.loading_coefficient / (mean_blades * lb)
522
+ Vrel_max = (_mag(getattr(upstream, "W", upstream.V)) + _mag(getattr(row, "W", row.V)) + dVrel) / 2
523
+ Vrel_out = _mag(getattr(row, "W", row.V))
524
+ Deq = Vrel_max / max(Vrel_out, 1e-6)
525
+
526
+ if Deq <= 2:
527
+ Vrel_sep = Vrel_out
528
+ else:
529
+ Vrel_sep = Vrel_out * Deq / 2
530
+
531
+ Vm = _mean(getattr(row, "Vm", row.V))
532
+ Vtheta_rel = _mean(getattr(row, "Wt", row.Vt if hasattr(row, "Vt") else row.V))
533
+ area_exit = float(np.pi * (r_exit**2 - _hub(row) ** 2))
534
+ Vrel_out_eff = np.hypot(Vm * area_exit * self.area_exit_factor / max(np.pi * _span(row) * r_exit * 2, 1e-6), Vtheta_rel)
535
+
536
+ dh_mix = 0.5 * (Vrel_sep - Vrel_out_eff) ** 2
537
+ cp = _mean([row.Cp, upstream.Cp], row.Cp)
538
+ cap = 0.3 * cp * (_mean(row.T0) - _mean(upstream.T0))
539
+ dh_mix = np.clip(dh_mix, 0.0, cap)
540
+
541
+ loss = self.loss_modifier * dh_mix
542
+ return np.full_like(row.r, loss, dtype=float)
543
+
544
+
545
+ class ImpellerMixingJohnston(LossBaseClass):
546
+ """Johnston & Dean mixing loss."""
547
+
548
+ def __init__(self, bstar: float = 1.0, loss_modifier: float = 1.0):
549
+ super().__init__(LossType.Enthalpy)
550
+ self.bstar = bstar
551
+ self.loss_modifier = loss_modifier
552
+
553
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
554
+ omega = float(getattr(upstream, "omega", 0.0))
555
+ r_tip = _tip(upstream)
556
+ Vtheta = _mean(getattr(upstream, "Vt", upstream.V))
557
+ V = _mag(upstream.V)
558
+ alpha = _mean(np.degrees(upstream.alpha1))
559
+ Vtheta_tip_rel = omega * r_tip - Vtheta
560
+ Vrel_tip = np.hypot(V * np.cos(np.radians(alpha)), Vtheta_tip_rel)
561
+ Vrel_out = _mag(getattr(row, "W", row.V))
562
+ wake = 1 - (1 / 0.45) * (Vrel_out / max(Vrel_tip, 1e-6))
563
+ wake = np.clip(wake, 0.0, 0.99)
564
+
565
+ Vtheta_out = _mean(getattr(row, "Vt", row.V))
566
+ Vm_out = _mean(getattr(row, "Vm", row.V))
567
+ coeff = 1.0 / (1 + (Vtheta_out / max(Vm_out, 1e-6)) ** 2)
568
+ dh = coeff * ((1 - wake - self.bstar) / max(1 - wake, 1e-6)) ** 2 * (_mag(row.V) ** 2 / 2)
569
+ dh = np.maximum(dh, 0.0) * self.loss_modifier
570
+ return np.full_like(row.r, dh, dtype=float)
571
+
572
+
573
+ class ImpellerPrescribed(LossBaseClass):
574
+ """Conrad incidence loss (prescribed percentage of enthalpy rise)."""
575
+
576
+ def __init__(self, loss_pct: float = 0.0):
577
+ super().__init__(LossType.Enthalpy)
578
+ self.loss_pct = loss_pct
579
+
580
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
581
+ cp = _mean([row.Cp, upstream.Cp], row.Cp)
582
+ ht_in = cp * np.asarray(upstream.T0)
583
+ ht_out = cp * np.asarray(row.T0)
584
+ dh = ht_out - ht_in
585
+ loss = self.loss_pct * dh
586
+ loss = np.maximum(loss, 0) # clamp negative due to numerical noise
587
+ return np.array(loss, dtype=float)
588
+
589
+
590
+ class ImpellerRecirculationAungier(LossBaseClass):
591
+ """Aungier recirculation loss."""
592
+
593
+ def __init__(
594
+ self,
595
+ number_of_blades: int = 12,
596
+ splitter_le: float = 0.0,
597
+ loading_coefficient: float = 1.0,
598
+ lb: float | None = None,
599
+ loss_modifier: float = 1.0,
600
+ ):
601
+ super().__init__(LossType.Enthalpy)
602
+ self.number_of_blades = number_of_blades
603
+ self.splitter_le = splitter_le
604
+ self.loading_coefficient = loading_coefficient
605
+ self.lb = lb
606
+ self.loss_modifier = loss_modifier
607
+
608
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
609
+ mean_blades = self.number_of_blades / 2 + (self.number_of_blades / 2) * (1 - self.splitter_le)
610
+ r_exit = _tip(row)
611
+ lb = self.lb if self.lb is not None else float(getattr(row, "chord", r_exit - _hub(upstream)))
612
+ dVrel = 2 * np.pi * (2 * r_exit) * _mean(row.U) * self.loading_coefficient / (mean_blades * lb)
613
+ Vrel_max = (_mag(getattr(upstream, "W", upstream.V)) + _mag(getattr(row, "W", row.V)) + dVrel) / 2
614
+ Deq = Vrel_max / max(_mag(getattr(row, "W", row.V)), 1e-6)
615
+
616
+ Vtheta_rel = _mean(getattr(row, "Wt", row.Vt if hasattr(row, "Vt") else row.V))
617
+ Vm = _mean(getattr(row, "Vm", row.V))
618
+ beta = np.degrees(np.arctan2(Vtheta_rel, max(Vm, 1e-6)))
619
+ dh = (Deq / 2 - 1) * (abs(Vtheta_rel) / max(Vm, 1e-6) - 2 * (1 / max(np.tan(np.radians(beta)), 1e-6))) * (
620
+ _mean(row.U) ** 2
621
+ )
622
+ cp = _mean([row.Cp, upstream.Cp], row.Cp)
623
+ cap = 0.25 * cp * (_mean(row.T0) - _mean(upstream.T0))
624
+ dh = np.clip(dh, 0.0, cap) * self.loss_modifier
625
+ return np.full_like(row.r, dh, dtype=float)
626
+
627
+
628
+ class ImpellerRecirculationOh(LossBaseClass):
629
+ """Oh recirculation loss."""
630
+
631
+ def __init__(
632
+ self,
633
+ splitter_le: float = 0.0,
634
+ loading_coefficient: float = 1.0,
635
+ number_of_blades: int = 12,
636
+ radius_tip_inlet: float | None = None,
637
+ radius_exit: float | None = None,
638
+ surge_vrel: float = 1.0,
639
+ loss_modifier: float = 1.0,
640
+ ):
641
+ super().__init__(LossType.Enthalpy)
642
+ self.splitter_le = splitter_le
643
+ self.loading_coefficient = loading_coefficient
644
+ self.number_of_blades = number_of_blades
645
+ self.radius_tip_inlet = radius_tip_inlet
646
+ self.radius_exit = radius_exit
647
+ self.surge_vrel = surge_vrel
648
+ self.loss_modifier = loss_modifier
649
+
650
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
651
+ r_tip_in = self.radius_tip_inlet if self.radius_tip_inlet is not None else _tip(upstream)
652
+ r_exit = self.radius_exit if self.radius_exit is not None else _tip(row)
653
+ omega = float(getattr(upstream, "omega", 0.0))
654
+ Vtheta = _mean(getattr(upstream, "Vt", upstream.V))
655
+ alpha = _mean(np.degrees(upstream.alpha1))
656
+ V = _mag(upstream.V)
657
+
658
+ U_tip = omega * r_tip_in
659
+ Vtheta_tip_rel = U_tip - Vtheta
660
+ Vrel_tip = np.hypot(V * np.cos(np.radians(alpha)), Vtheta_tip_rel)
661
+
662
+ Kbl = 0.75 if self.splitter_le == 0 else 0.6
663
+ Df = 1 - _mag(getattr(row, "W", row.V)) / max(self.surge_vrel, 1e-6) + Kbl * self.loading_coefficient / max(
664
+ (self.surge_vrel / max(_mag(getattr(row, "W", row.V)), 1e-6))
665
+ * ((self.number_of_blades / np.pi) * (1 - r_tip_in / max(r_exit, 1e-6)) + 2 * r_tip_in / max(r_exit, 1e-6)),
666
+ 1e-6,
667
+ )
668
+
669
+ dh = 8e-5 * np.sinh(3.5 * (np.radians(_mean(row.alpha2)) ** 3)) * Df**2 * (_mean(row.U) ** 2)
670
+ cp = _mean([row.Cp, upstream.Cp], row.Cp)
671
+ cap = 0.5 * cp * (_mean(row.T0) - _mean(upstream.T0))
672
+ dh = np.clip(dh * self.loss_modifier, 0.0, cap)
673
+ return np.full_like(row.r, dh, dtype=float)
674
+
675
+
676
+ class ImpellerSkinFrictionCoppage(LossBaseClass):
677
+ """Coppage skin friction loss (simplified)."""
678
+
679
+ def __init__(
680
+ self,
681
+ number_of_blades: int = 12,
682
+ roughness: float = 1e-5,
683
+ loss_modifier: float = 1.0,
684
+ splitters: bool = False,
685
+ ):
686
+ super().__init__(LossType.Enthalpy)
687
+ self.number_of_blades = number_of_blades
688
+ self.roughness = roughness
689
+ self.loss_modifier = loss_modifier
690
+ self.splitters = splitters
691
+
692
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
693
+ r_exit = _tip(row)
694
+ bwidth = _span(row)
695
+ lambda_ratio = _hub(upstream) / max(_tip(upstream), 1e-6)
696
+ Dh = 2 * bwidth if bwidth > 0 else 1.0
697
+ U = _mean(row.U)
698
+ mu = float(getattr(row, "mu", 0.0) or getattr(upstream, "mu", 0.0) or 1.0)
699
+ rho = _mean(getattr(row, "rho", 1.0))
700
+ Re = abs(U) * Dh * rho / mu
701
+ Re = max(Re, 1e3)
702
+ Cf = 0.26 / (Re ** 0.25)
703
+ if self.roughness > 0:
704
+ Cf += 0.11 * (self.roughness / max(Dh, 1e-6)) ** 0.25
705
+ Wbar = 0.125 * (
706
+ _mag(upstream.V)
707
+ + _mag(row.V)
708
+ + _mag(getattr(upstream, "W", upstream.V))
709
+ + 2 * _mag(getattr(row, "W", row.V))
710
+ + 3 * _mag(getattr(row, "W", row.V))
711
+ )
712
+ Ksf = 7.0 if self.splitters else 5.6
713
+ dh = Ksf * Cf * (Wbar**2) / 2
714
+ dh = np.maximum(dh * self.loss_modifier, 0.0)
715
+ return np.full_like(row.r, dh, dtype=float)
716
+
717
+
718
+ class ImpellerSkinFrictionJansen(LossBaseClass):
719
+ """Jansen skin friction loss (simplified Casey/Colebrook form)."""
720
+
721
+ def __init__(self, roughness: float = 1e-5, loss_modifier: float = 1.0):
722
+ super().__init__(LossType.Enthalpy)
723
+ self.roughness = roughness
724
+ self.loss_modifier = loss_modifier
725
+
726
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
727
+ bwidth = _span(row)
728
+ Dh = 2 * bwidth if bwidth > 0 else 1.0
729
+ U = _mean(row.U)
730
+ mu = float(getattr(row, "mu", 0.0) or getattr(upstream, "mu", 0.0) or 1.0)
731
+ rho = _mean(getattr(row, "rho", 1.0))
732
+ Re = abs(U) * Dh * rho / mu
733
+ Re = max(Re, 1e3)
734
+ # Swamee-Jain approximation for Colebrook
735
+ Cf = 0.25 / (np.log10(self.roughness / (3.7 * Dh) + 5.74 / (Re**0.9)) ** 2)
736
+ Wbar = _mag(getattr(row, "W", row.V))
737
+ dh = Cf * (Wbar**2) / 2
738
+ dh = np.maximum(dh * self.loss_modifier, 0.0)
739
+ return np.full_like(row.r, dh, dtype=float)
740
+
741
+
742
+ class ImpellerVarious(LossBaseClass):
743
+ """Aggregate loss using multiple sub-correlations."""
744
+
745
+ def __init__(self):
746
+ super().__init__(LossType.Enthalpy)
747
+ # Compose key submodels with default parameters
748
+ self.blade_loading = ImpellerBladeLoadingCoppage()
749
+ self.clearance = ImpellerClearanceJansen()
750
+ self.mixing = ImpellerMixingJohnston()
751
+ self.disc_friction = ImpellerDiscFrictionDaily()
752
+ self.leakage = ImpellerLeakageAungier()
753
+ self.recirculation = ImpellerRecirculationOh()
754
+ self.incidence = ImpellerIncidenceConrad()
755
+ self.skin_friction = ImpellerSkinFrictionJansen()
756
+
757
+ def __call__(self, row: BladeRow, upstream: BladeRow) -> npt.NDArray:
758
+ components = [
759
+ self.blade_loading(row, upstream),
760
+ self.clearance(row, upstream),
761
+ self.mixing(row, upstream),
762
+ self.disc_friction(row, upstream),
763
+ self.leakage(row, upstream),
764
+ self.recirculation(row, upstream),
765
+ self.incidence(row, upstream),
766
+ self.skin_friction(row, upstream),
767
+ ]
768
+ total = np.zeros_like(row.r, dtype=float)
769
+ for comp in components:
770
+ total = total + np.asarray(comp, dtype=float)
771
+ return total
772
+
773
+
774
+ __all__ = [
775
+ "AxialCompressorAungier",
776
+ "AxialCompressorEntropy",
777
+ "AxialCompressorWrightMiller",
778
+ "AxialTurbineAinleyMathiesonOTAC",
779
+ "AxialTurbineKackerOkapuuOTAC",
780
+ "DiffuserVanelessStanitz",
781
+ "ImpellerBladeLoadingAungier",
782
+ "ImpellerBladeLoadingCoppage",
783
+ "ImpellerClearanceJansen",
784
+ "ImpellerDiscFrictionDaily",
785
+ "ImpellerIncidenceAungier",
786
+ "ImpellerIncidenceConrad",
787
+ "ImpellerLeakageAungier",
788
+ "ImpellerMixingAungier",
789
+ "ImpellerMixingJohnston",
790
+ "ImpellerPrescribed",
791
+ "ImpellerRecirculationAungier",
792
+ "ImpellerRecirculationOh",
793
+ "ImpellerSkinFrictionCoppage",
794
+ "ImpellerSkinFrictionJansen",
795
+ "ImpellerVarious",
796
+ "NASA23B20",
797
+ "NASA74A",
798
+ "RadialInput",
799
+ ]