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,941 @@
1
+ # type: ignore[arg-type, reportUnknownArgumentType]
2
+ from __future__ import annotations
3
+ from turtle import down, up
4
+ from typing import Dict, List, Union, Optional, Tuple
5
+ import json
6
+
7
+ import numpy as np
8
+ import numpy.typing as npt
9
+ import matplotlib.pyplot as plt
10
+
11
+ from cantera.composite import Solution
12
+ from scipy.interpolate import interp1d
13
+ from scipy.optimize import minimize_scalar
14
+
15
+ # --- Project-local imports
16
+ from .bladerow import BladeRow, interpolate_streamline_quantities
17
+ from .enums import RowType, LossType
18
+ from .loss.turbine import TD2
19
+ from .passage import Passage
20
+ from .inlet import Inlet
21
+ from .outlet import Outlet, OutletType
22
+ from .compressor_math import rotor_calc, stator_calc, polytropic_efficiency
23
+ from .flow_math import compute_massflow, compute_streamline_areas, compute_power
24
+ from .turbine_math import (
25
+ inlet_calc,
26
+ compute_gas_constants,
27
+ compute_reynolds,
28
+ )
29
+ from .solve_radeq import adjust_streamlines, radeq
30
+ from pyturbo.helper import convert_to_ndarray
31
+
32
+ # Default fraction of the stator-to-stator pressure rise attributed to a rotor
33
+ DEFAULT_ROTOR_PRESSURE_FRACTION = 0.5
34
+
35
+ class CompressorSpool:
36
+ """Used to design compressors
37
+
38
+ This class (formerly named *Spool*) encapsulates both the generic geometry/plotting
39
+ utilities from the original base spool and the turbine-solving logic that lived
40
+ in the turbine-specific spool implementation.
41
+
42
+ Notes on differences vs. the two-class design:
43
+ - `field(default_factory=...)` was previously used on a non-dataclass attribute
44
+ (`t_streamline`). Here it's handled in `__init__` to avoid a silent bug.
45
+ - `fluid` defaults to `Solution('air.yaml')` if not provided.
46
+ - All turbine-specific methods (initialize/solve/massflow balancing/etc.) are
47
+ preserved here. If you ever add a *CompressorSpool* in the future, consider
48
+ splitting turbine/compressor behaviors behind a strategy/solver object.
49
+ """
50
+
51
+ # Class-level defaults (avoid mutable defaults here!)
52
+ rows: List[BladeRow]
53
+ massflow: float
54
+ rpm: float
55
+
56
+ # Types/attributes documented for linters; values set in __init__
57
+ passage: Passage
58
+ t_streamline: npt.NDArray
59
+ num_streamlines: int
60
+
61
+ _fluid: Solution
62
+ _adjust_streamlines: bool
63
+
64
+ def __init__(
65
+ self,
66
+ passage: Passage,
67
+ massflow: float,
68
+ inlet: Inlet,
69
+ outlet: Outlet,
70
+ rows: List[BladeRow],
71
+ num_streamlines: int = 3,
72
+ fluid: Optional[Solution] = None,
73
+ rpm: float = -1,
74
+ rotor_pressure_fraction: float = DEFAULT_ROTOR_PRESSURE_FRACTION,
75
+ ) -> None:
76
+ """Initialize a compressor spool
77
+
78
+ Args:
79
+ passage: Passage defining hub and shroud
80
+ massflow: massflow at spool inlet
81
+ inlet: Inlet object
82
+ outlet: Outlet object
83
+ rows: List of blade rows between inlet and outlet
84
+ num_streamlines: number of streamlines used through the meridional passage
85
+ fluid: cantera gas solution; defaults to air.yaml if None
86
+ rpm: RPM for the entire spool. Individual rows can override later.
87
+ rotor_pressure_fraction: Fraction of total pressure rise in rotors (0.0 to 1.0)
88
+ """
89
+ self.passage = passage
90
+ self.massflow = massflow
91
+ self.inlet = inlet
92
+ self.outlet = outlet
93
+ self.rows = rows
94
+ self.num_streamlines = num_streamlines
95
+ self._fluid = fluid if fluid is not None else Solution("air.yaml")
96
+ self.rpm = rpm
97
+ self.rotor_pressure_fraction = float(np.clip(rotor_pressure_fraction, 0.0, 1.0))
98
+
99
+ # Previously this used dataclasses.field on a non-dataclass; do it explicitly
100
+ self.t_streamline = np.zeros((10,), dtype=float)
101
+ self._adjust_streamlines = True
102
+ self.convergence_history: List[Dict] = []
103
+
104
+ # Assign IDs, RPMs, and axial chords where appropriate
105
+ for i, br in enumerate(self._all_rows()):
106
+ br.id = i
107
+ if not isinstance(br, (Outlet)):
108
+ br.rpm = rpm
109
+ br.axial_chord = br.hub_location * self.passage.hub_length
110
+ # Freeze any configured P0_ratio targets for later use (diagnostics may overwrite P0_ratio).
111
+ if getattr(br, "P0_ratio_target", 0.0) == 0 and getattr(br, "P0_ratio", 0.0) != 0:
112
+ br.P0_ratio_target = br.P0_ratio
113
+ if isinstance(br, BladeRow) and br.row_type == RowType.Rotor:
114
+ setattr(br, "rotor_pressure_fraction", getattr(br, "rotor_pressure_fraction", self.rotor_pressure_fraction))
115
+
116
+ # Propagate initial fluid to rows
117
+ for br in self._all_rows():
118
+ br.fluid = self._fluid
119
+
120
+ def _all_rows(self) -> List[BladeRow]:
121
+ """Convenience to iterate inlet + interior rows + outlet."""
122
+ return [self.inlet, *self.rows, self.outlet]
123
+
124
+ @property
125
+ def blade_rows(self) -> List[BladeRow]:
126
+ """Backwards-compatible combined row list."""
127
+ return self._all_rows()
128
+
129
+ def set_rotor_pressure_fraction(self, value: float) -> None:
130
+ """Update default pressure split fraction for all rotor rows."""
131
+ self.rotor_pressure_fraction = float(np.clip(value, 0.0, 1.0))
132
+ for row in self.rows:
133
+ if row.row_type == RowType.Rotor:
134
+ setattr(row, "rotor_pressure_fraction", self.rotor_pressure_fraction)
135
+
136
+ # ------------------------------
137
+ # Properties
138
+ # ------------------------------
139
+ @property
140
+ def fluid(self) -> Optional[Solution]:
141
+ return self._fluid
142
+
143
+ @fluid.setter
144
+ def fluid(self, newFluid: Solution) -> None:
145
+ """Change the gas used in the spool and cascade to rows."""
146
+ self._fluid = newFluid
147
+ for br in self._all_rows():
148
+ br.fluid = self._fluid
149
+
150
+ @property
151
+ def adjust_streamlines(self) -> bool:
152
+ return self._adjust_streamlines
153
+
154
+ @adjust_streamlines.setter
155
+ def adjust_streamlines(self, val: bool) -> None:
156
+ self._adjust_streamlines = val
157
+
158
+ # ------------------------------
159
+ # Row utilities
160
+ # ------------------------------
161
+ def set_blade_row_rpm(self, index: int, rpm: float) -> None:
162
+ self.rows[index].rpm = rpm
163
+
164
+ def set_blade_row_type(self, blade_row_index: int, rowType: RowType) -> None:
165
+ self.rows[blade_row_index].row_type = rowType
166
+
167
+ def set_blade_row_exit_angles(
168
+ self,
169
+ radius: Dict[int, List[float]],
170
+ beta: Dict[int, List[float]],
171
+ IsSupersonic: bool = False,
172
+ ) -> None:
173
+ """Set intended exit flow angles for rows (useful when geometry is fixed)."""
174
+ for k, v in radius.items():
175
+ self.rows[k].radii_geom = v
176
+ for k, v in beta.items():
177
+ self.rows[k].beta_geom = v
178
+ self.rows[k].beta_fixed = True
179
+ for br in self._all_rows():
180
+ br.solution_type = "supersonic" if IsSupersonic else "subsonic"
181
+
182
+ # ------------------------------
183
+ # Streamline setup/geometry
184
+ # ------------------------------
185
+ def initialize_streamlines(self) -> None:
186
+ """Initialize streamline storage per row and compute curvature."""
187
+ for row in self._all_rows():
188
+ row.phi = np.zeros((self.num_streamlines,))
189
+ row.rm = np.zeros((self.num_streamlines,))
190
+ row.r = np.zeros((self.num_streamlines,))
191
+ row.m = np.zeros((self.num_streamlines,))
192
+
193
+ t_radial = np.array([0.5]) if self.num_streamlines == 1 else np.linspace(0, 1, self.num_streamlines)
194
+ self.calculate_streamline_curvature(row, t_radial)
195
+
196
+ if self.num_streamlines == 1:
197
+ area = self.passage.get_area(row.hub_location)
198
+ row.total_area = area
199
+ row.area = np.array([area])
200
+
201
+ # Ensure a loss model exists on blade rows
202
+ if not isinstance(row, (Inlet, Outlet)) and row.loss_function is None:
203
+ row.loss_function = TD2()
204
+
205
+ # With radii known, couple blade geometry (pitch/chord/stagger) if specified
206
+ for row in self._all_rows():
207
+ if isinstance(row, BladeRow) and row.row_type not in (RowType.Inlet, RowType.Outlet):
208
+ try:
209
+ row.synchronize_blade_geometry()
210
+ except Exception:
211
+ pass
212
+
213
+ def calculate_streamline_curvature(
214
+ self, row: BladeRow, t_radial: Union[List[float], npt.NDArray]
215
+ ) -> None:
216
+ """Interpolate passage curvature metrics onto a blade row.
217
+
218
+ Args:
219
+ row: BladeRow to populate with phi, rm, r, and m along streamlines.
220
+ t_radial: Parametric hub-to-shroud locations (0–1) at which to sample curvature.
221
+ """
222
+ for i, tr in enumerate(t_radial):
223
+ t_s, x_s, r_s = self.passage.get_streamline(tr)
224
+ phi, rm, r = self.passage.streamline_curvature(x_s, r_s)
225
+ row.phi[i] = float(interp1d(t_s, phi)(row.hub_location))
226
+ row.rm[i] = float(interp1d(t_s, rm)(row.hub_location))
227
+ row.r[i] = float(interp1d(t_s, r)(row.hub_location))
228
+ row.m[i] = float(
229
+ interp1d(t_s, self.passage.get_m(tr, resolution=len(t_s)))(row.hub_location)
230
+ )
231
+ chord = np.asarray(row.chord, dtype=float)
232
+ mean_chord = float(np.mean(chord)) if chord.size else 0.0
233
+ if row.num_blades and mean_chord != 0:
234
+ mean_r = float(np.mean(row.r))
235
+ pitch = 2 * np.pi * mean_r / row.num_blades
236
+ row.pitch_to_chord = pitch / mean_chord
237
+
238
+ # ------------------------------
239
+ # initialization/solve
240
+ # ------------------------------
241
+ def initialize(self) -> None:
242
+ """Initialize massflow and thermodynamic state through rows (compressor).
243
+
244
+ Sets inlet totals, interpolates geometry, propagates gas properties, and
245
+ runs per-row calcs to seed the solver.
246
+ """
247
+ rows = self._all_rows()
248
+
249
+ # Inlet
250
+ W0 = self.massflow
251
+ inlet: Inlet = self.inlet
252
+ if self.fluid:
253
+ inlet.__initialize_fluid__(self.fluid) # type: ignore[arg-type]
254
+ else:
255
+ inlet.__initialize_fluid__( # type: ignore[call-arg]
256
+ R=rows[1].R,
257
+ gamma=rows[1].gamma,
258
+ Cp=rows[1].Cp,
259
+ )
260
+
261
+ inlet.total_massflow = W0
262
+ inlet.total_massflow_no_coolant = W0
263
+ inlet.massflow = np.array([W0]) if self.num_streamlines == 1 else np.linspace(0, 1, self.num_streamlines) * W0
264
+
265
+ inlet.__interpolate_quantities__(self.num_streamlines) # type: ignore[attr-defined]
266
+ inlet.__initialize_velocity__(self.passage, self.num_streamlines) # type: ignore[attr-defined]
267
+ interpolate_streamline_quantities(inlet, self.passage, self.num_streamlines)
268
+
269
+ compute_gas_constants(inlet, self.fluid)
270
+ inlet_calc(inlet)
271
+
272
+ for row in rows:
273
+ interpolate_streamline_quantities(row, self.passage, self.num_streamlines)
274
+
275
+ # Pass T0, P0 to downstream rows
276
+ for i in range(1, len(rows) - 1):
277
+ upstream = rows[i - 1]
278
+ downstream = rows[i + 1] if i + 1 < len(rows) else None
279
+
280
+ row = rows[i]
281
+ if row.coolant is not None:
282
+ T0c = row.coolant.T0
283
+ P0c = row.coolant.P0
284
+ W0c = row.coolant.massflow_percentage * self.massflow
285
+ Cpc = row.coolant.Cp
286
+ else:
287
+ T0c = 100
288
+ P0c = 0
289
+ W0c = 0
290
+ Cpc = 0
291
+
292
+ T0 = upstream.T0
293
+ P0 = upstream.P0
294
+ Cp = upstream.Cp
295
+
296
+ T0 = (W0 * Cp * T0 + W0c * Cpc * T0c) / (Cpc * W0c + Cp * W0)
297
+ P0 = (W0 * Cp * P0 + W0c * Cpc * P0c) / (Cpc * W0c + Cp * W0)
298
+ Cp = (W0 * Cp + W0c * Cpc) / (W0c + W0) if (W0c + W0) != 0 else Cp
299
+
300
+ if row.row_type == RowType.Stator:
301
+ T0 = upstream.T0
302
+ else:
303
+ T0 = upstream.T0 - row.power / (Cp * (W0 + W0c))
304
+
305
+ W0 += W0c
306
+ row.T0 = T0
307
+ row.P0 = P0
308
+ row.Cp = Cp
309
+ row.total_massflow = W0
310
+ row.massflow = np.array([row.total_massflow]) if self.num_streamlines == 1 else np.linspace(0, 1, self.num_streamlines) * row.total_massflow
311
+
312
+ # Pass gas constants
313
+ row.rho = upstream.rho
314
+ row.gamma = upstream.gamma
315
+ row.R = upstream.R
316
+
317
+ total_area, streamline_area = compute_streamline_areas(row)
318
+ row.total_area = total_area
319
+ row.area = streamline_area
320
+ if row.row_type == RowType.Stator or row.row_type == RowType.IGV:
321
+ if row.row_type == RowType.IGV:
322
+ row.P0_is = upstream.P0
323
+ stator_calc(row, upstream, calculate_vm=True) # type: ignore[arg-type]
324
+ elif row.row_type == RowType.Rotor:
325
+ # Align rotor ideal P0 target with downstream stator if provided (stage-level target)
326
+ if downstream and downstream.row_type == RowType.Stator:
327
+ downstream.P0 = upstream.P0*downstream.P0_ratio
328
+ downstream.Yp = downstream.loss_function(row, upstream)
329
+ downstream.P0_is = downstream.P0 + downstream.Yp * (upstream.P0-upstream.P)
330
+ row.P0_ratio = downstream.P0_ratio
331
+ else:
332
+ row.P0 = row.P0_ratio * upstream.P0
333
+ rotor_calc(row, upstream,calculate_vm=True)
334
+ compute_power(row, upstream, is_compressor=True)
335
+
336
+ def solve(self) -> None:
337
+ """Run streamline initialization and solve the compressor flow field.
338
+
339
+ The solution method is determined by the outlet configuration:
340
+ - If outlet.outlet_type is massflow_static_pressure: use angle matching
341
+ - Otherwise: use pressure balance
342
+ """
343
+ self.initialize_streamlines()
344
+ self.initialize()
345
+
346
+ if self.outlet.outlet_type == OutletType.massflow_static_pressure:
347
+ print("Using angle matching mode: blade exit angles will be adjusted to match specified massflow")
348
+ self._angle_match()
349
+ else:
350
+ print("Using pressure balance mode: blade exit angles are fixed, total pressures will be adjusted")
351
+ self.balance_pressure()
352
+
353
+ def solve_angle_match(self) -> None:
354
+ """Explicit angle-matching solve by temporarily setting outlet type."""
355
+ prev_type = self.outlet.outlet_type
356
+ prev_massflow = getattr(self.outlet, 'total_massflow', None)
357
+ try:
358
+ if prev_massflow is None:
359
+ self.outlet.total_massflow = self.massflow
360
+ self.outlet.outlet_type = OutletType.massflow_static_pressure
361
+ self.solve()
362
+ finally:
363
+ self.outlet.outlet_type = prev_type
364
+ if prev_massflow is None and hasattr(self.outlet, 'total_massflow'):
365
+ delattr(self.outlet, 'total_massflow')
366
+
367
+ def solve_balance_pressure(self) -> None:
368
+ """Explicit pressure-balance solve by temporarily setting outlet type."""
369
+ prev_type = self.outlet.outlet_type
370
+ try:
371
+ self.outlet.outlet_type = OutletType.total_pressure
372
+ self.solve()
373
+ finally:
374
+ self.outlet.outlet_type = prev_type
375
+
376
+ def overall_pressure_ratio(self) -> float:
377
+ """Compute overall total pressure ratio (inlet to last internal row)."""
378
+ rows = self._all_rows()
379
+ if len(rows) < 2:
380
+ return 1.0
381
+ return float(np.mean(np.mean(rows[-2].P0 / self.inlet.P0) ))
382
+
383
+ def overall_polytropic_efficiency(self) -> float:
384
+ """Compute overall polytropic efficiency from inlet to last internal row."""
385
+ rows = self._all_rows()
386
+ if len(rows) < 2:
387
+ return 0.0
388
+ pi = float(np.mean(rows[-2].P0) / np.mean(self.inlet.P0))
389
+ tau = float(np.mean(rows[-2].T0)/np.mean(self.inlet.T0))
390
+ gamma = float(np.mean(self.inlet.gamma)) if hasattr(self.inlet, "gamma") else 1.4
391
+ if tau <= 0 or abs(np.log(tau)) < 1e-12 or pi <= 1.0:
392
+ return 0.0
393
+ return ((gamma - 1.0) / gamma) * np.log(pi) / np.log(tau)
394
+
395
+ def solve_massflow_for_pressure_ratio(self, target_pr: float, bounds: tuple[float, float], meanline: bool = False) -> tuple[float, float]:
396
+ """Solve inlet massflow to hit a target overall total-pressure ratio.
397
+
398
+ Args:
399
+ target_pr: desired overall P0 ratio (inlet / last internal row).
400
+ bounds: (lower, upper) bounds for massflow during search.
401
+ meanline: if True, force a single streamline and disable streamline adjustment.
402
+
403
+ Returns:
404
+ Tuple of (converged massflow, achieved pressure ratio).
405
+ """
406
+ if meanline:
407
+ self.num_streamlines = 1
408
+ self._adjust_streamlines = False
409
+
410
+ lower, upper = bounds
411
+ if lower <= 0 or upper <= 0 or lower >= upper:
412
+ raise ValueError("Massflow bounds must be positive and (lower < upper).")
413
+
414
+ def objective(mdot: float) -> float:
415
+ self.massflow = mdot
416
+ self.solve()
417
+ achieved = self.overall_pressure_ratio()
418
+ return (achieved - target_pr) ** 2
419
+
420
+ res = minimize_scalar(objective, bounds=bounds, method="bounded")
421
+ self.massflow = float(res.x)
422
+ self.solve()
423
+ achieved = self.overall_pressure_ratio()
424
+ return self.massflow, achieved
425
+
426
+ def balance_pressure(self) -> None:
427
+ """Balance Pressure assumes we know:
428
+ 1. The blade angles
429
+ 2. Total Pressure Ratio
430
+ 3. Massflow
431
+
432
+ We find the static pressures in between the blade rows such that massflow is balanced.
433
+ Implemented by marching rows (compressor mode) without guessing pressure ratios.
434
+ """
435
+ rows = self._all_rows()
436
+
437
+ print("Looping to converge massflow (compressor)")
438
+ loop_iter = 0
439
+ max_iter = 10
440
+ prev_err = 1e9
441
+ self.convergence_history = [] # Reset convergence history
442
+ while loop_iter < max_iter:
443
+ for i in range(1, len(rows) - 1):
444
+ row = rows[i]
445
+ upstream = rows[i - 1]
446
+ downstream = rows[i + 1] if i + 1 < len(rows) else None
447
+
448
+ if row.row_type == RowType.Inlet:
449
+ row.Yp = 0
450
+ continue
451
+
452
+ if row.row_type == RowType.Rotor:
453
+ rotor_calc(row, upstream, calculate_vm=True)
454
+ if self.num_streamlines > 1:
455
+ row = radeq(row, upstream, downstream)
456
+ compute_gas_constants(row, self.fluid)
457
+ rotor_calc(row, upstream, calculate_vm=False)
458
+ elif row.row_type == RowType.Stator or row.row_type == RowType.IGV:
459
+ if row.row_type == RowType.IGV:
460
+ row.P0_is = upstream.P0
461
+ stator_calc(row, upstream, calculate_vm=True)
462
+ if self.num_streamlines > 1:
463
+ row = radeq(row, upstream, downstream)
464
+ compute_gas_constants(row, self.fluid)
465
+ stator_calc(row, upstream, calculate_vm=False)
466
+
467
+ compute_gas_constants(row, self.fluid)
468
+ compute_power(row, upstream, is_compressor=True)
469
+
470
+ target = rows[1].total_massflow_no_coolant
471
+ self.inlet.massflow = np.array([target]) if self.num_streamlines == 1 else np.linspace(0, 1, self.num_streamlines) * target
472
+ self.inlet.total_massflow_no_coolant = rows[1].total_massflow_no_coolant
473
+ self.inlet.total_massflow = rows[1].total_massflow_no_coolant
474
+ self.inlet.calculated_massflow = self.inlet.total_massflow_no_coolant
475
+ inlet_calc(self.inlet)
476
+
477
+ # if self.adjust_streamlines:
478
+ # adjust_streamlines(rows[:-1], self.passage, np.linspace(0, 1, self.num_streamlines))
479
+
480
+ self.outlet.transfer_quantities(rows[-2])
481
+ self.outlet.P = self.outlet.get_static_pressure(self.outlet.percent_hub_shroud)
482
+
483
+ err = self._massflow_std(rows[1:-1])
484
+ loop_iter += 1
485
+ print(f"Loop {loop_iter} massflow convergence error:{err}")
486
+
487
+ # Store convergence history
488
+ self.convergence_history.append({
489
+ 'iteration': loop_iter,
490
+ 'massflow_std': float(err),
491
+ 'massflow_change': float(abs(err - prev_err)),
492
+ 'relative_change': float(abs((err - prev_err) / max(err, 1e-6))),
493
+ 'massflow': float(rows[1].total_massflow_no_coolant)
494
+ })
495
+
496
+ denom = max(err, 1e-6)
497
+ if abs((err - prev_err) / denom) <= 0.05:
498
+ break
499
+ prev_err = err
500
+
501
+ compute_reynolds(rows, self.passage)
502
+
503
+ @staticmethod
504
+ def _massflow_std(blade_rows: List[BladeRow]) -> float:
505
+ """Compute standard deviation of massflow across rows for diagnostics."""
506
+ totals = []
507
+ for row in blade_rows:
508
+ if hasattr(row, "total_massflow_no_coolant"):
509
+ totals.append(row.total_massflow_no_coolant)
510
+ elif len(getattr(row, "massflow", [])) > 0:
511
+ totals.append(row.massflow[-1])
512
+ return float(np.std(totals)) if totals else 0.0
513
+ # ------------------------------
514
+ # Massflow / angle matching
515
+ # ------------------------------
516
+ def _angle_match(self) -> None:
517
+ """Match massflow between streamtubes by tweaking exit angles."""
518
+ blade_rows = self._all_rows()
519
+ self.convergence_history = [] # Reset convergence history
520
+ prev_err = 1e9
521
+
522
+ for iter_num in range(3):
523
+ for i, row in enumerate(blade_rows):
524
+ # Only adjust blade rows; skip inlet/outlet and other utility rows
525
+ if row.row_type not in (RowType.Rotor, RowType.Stator):
526
+ continue
527
+
528
+ upstream = blade_rows[i - 1] if i > 0 else blade_rows[i]
529
+ downstream = blade_rows[i + 1] if i < len(blade_rows) - 1 else None
530
+
531
+ if row.row_type == RowType.Stator:
532
+ bounds = [0, 80]
533
+ elif row.row_type == RowType.Rotor:
534
+ bounds = [-80, 0]
535
+ else:
536
+ bounds = [0, 0]
537
+
538
+ for j in range(1, self.num_streamlines):
539
+ res = minimize_scalar(
540
+ match_massflow_objective,
541
+ bounds=bounds,
542
+ args=(j, row, upstream, downstream, self.fluid),
543
+ options={'xatol': 1e-3},
544
+ method="bounded",
545
+ )
546
+ if row.row_type == RowType.Rotor:
547
+ row.beta2[j] = np.radians(res.x)
548
+ row.beta2[0] = 1 / (len(row.beta2) - 1) * row.beta2[1:].sum()
549
+ elif row.row_type == RowType.Stator:
550
+ row.alpha2[j] = np.radians(res.x)
551
+ row.alpha2[0] = 1 / (len(row.alpha2) - 1) * row.alpha2[1:].sum()
552
+ compute_gas_constants(upstream, self.fluid)
553
+ compute_gas_constants(row, self.fluid)
554
+ compute_massflow(row)
555
+ compute_power(row, upstream, is_compressor=True)
556
+
557
+ # Track convergence history
558
+ err = self._massflow_std(blade_rows[1:-1])
559
+ self.convergence_history.append({
560
+ 'iteration': iter_num + 1,
561
+ 'massflow_std': float(err),
562
+ 'massflow_change': float(abs(err - prev_err)),
563
+ 'relative_change': float(abs((err - prev_err) / max(err, 1e-6))),
564
+ 'massflow': float(blade_rows[1].total_massflow_no_coolant)
565
+ })
566
+ prev_err = err
567
+ print(f"Angle match iteration {iter_num + 1}, massflow std: {err:.6f}")
568
+
569
+
570
+ # ------------------------------
571
+ # Export / Plotting
572
+ # ------------------------------
573
+ def export_properties(self, filename: str = "compressor_spool.json") -> None:
574
+ """Export compressor spool properties and blade row data to JSON file.
575
+
576
+ Exports comprehensive compressor design data including blade row properties,
577
+ streamline coordinates, efficiency metrics, pressure ratios, stage loading,
578
+ and power calculations for each stage. Useful for post-processing and result
579
+ archiving.
580
+
581
+ Args:
582
+ filename: Output JSON file path (default: "compressor_spool.json")
583
+
584
+ Returns:
585
+ None. Writes JSON file to specified path.
586
+
587
+ Example:
588
+ >>> spool.export_properties("r35_compressor_results.json")
589
+ """
590
+ blade_rows = self._all_rows()
591
+ blade_rows_out = []
592
+ degree_of_reaction = []
593
+ total_total_efficiency = []
594
+ total_static_efficiency = []
595
+ stage_loading = []
596
+ euler_power = []
597
+ enthalpy_power = []
598
+ x_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
599
+ r_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
600
+ massflow = []
601
+
602
+ for indx, row in enumerate(blade_rows):
603
+ blade_rows_out.append(row.to_dict())
604
+ if row.row_type == RowType.Rotor:
605
+ degree_of_reaction.append(
606
+ (
607
+ (blade_rows[indx - 1].P - row.P)
608
+ / (blade_rows[indx - 2].P - row.P)
609
+ ).mean()
610
+ )
611
+ total_total_efficiency.append(row.eta_total)
612
+ total_static_efficiency.append(row.eta_static)
613
+ stage_loading.append(row.stage_loading)
614
+ euler_power.append(row.euler_power)
615
+ enthalpy_power.append(row.power)
616
+ if row.row_type not in (RowType.Inlet, RowType.Outlet):
617
+ massflow.append(row.massflow[-1])
618
+
619
+ for j, p in enumerate(row.percent_hub_shroud):
620
+ t, x, r = self.passage.get_streamline(p)
621
+ x_streamline[j, indx] = float(interp1d(t, x)(row.percent_hub))
622
+ r_streamline[j, indx] = float(interp1d(t, r)(row.percent_hub))
623
+
624
+ Pratio_Total_Total = np.mean(self.inlet.P0 / blade_rows[-2].P0)
625
+ Pratio_Total_Static = np.mean(self.inlet.P0 / blade_rows[-2].P)
626
+ flow_fn_massflow = float(np.mean(massflow)) if massflow else 0.0
627
+ FlowFunction = flow_fn_massflow * np.sqrt(self.inlet.T0.mean()) * float(np.mean(self.inlet.P0)) / 1000
628
+ CorrectedSpeed = self.rpm * np.pi / 30 / np.sqrt(self.inlet.T0.mean())
629
+ EnergyFunction = (
630
+ (self.inlet.T0 - blade_rows[-2].T0)
631
+ * 0.5
632
+ * (self.inlet.Cp + blade_rows[-2].Cp)
633
+ / self.inlet.T0
634
+ )
635
+ EnergyFunction = np.mean(EnergyFunction)
636
+
637
+ # English-unit conversions
638
+ massflow_kg_s = float(np.mean(massflow)) if massflow else 0.0
639
+ massflow_lbm_s = massflow_kg_s / 0.45359237
640
+ euler_power_hp = [p / 745.7 for p in euler_power]
641
+ enthalpy_power_hp = [p / 745.7 for p in enthalpy_power]
642
+
643
+ data = {
644
+ "blade_rows": blade_rows_out,
645
+ "massflow": massflow_kg_s,
646
+ "massflow_lbm_s": massflow_lbm_s,
647
+ "rpm": self.rpm,
648
+ "r_streamline": r_streamline.tolist(),
649
+ "x_streamline": x_streamline.tolist(),
650
+ "rhub": self.passage.rhub_pts.tolist(),
651
+ "rshroud": self.passage.rshroud_pts.tolist(),
652
+ "xhub": self.passage.xhub_pts.tolist(),
653
+ "xshroud": self.passage.xshroud_pts.tolist(),
654
+ "num_streamlines": self.num_streamlines,
655
+ "euler_power": euler_power,
656
+ "euler_power_hp": euler_power_hp,
657
+ "enthalpy_power": enthalpy_power,
658
+ "enthalpy_power_hp": enthalpy_power_hp,
659
+ "total-total_efficiency": total_total_efficiency,
660
+ "total-static_efficiency": total_static_efficiency,
661
+ "stage_loading": stage_loading,
662
+ "degree_of_reaction": degree_of_reaction,
663
+ "Pratio_Total_Total": float(Pratio_Total_Total),
664
+ "Pratio_Total_Static": float(Pratio_Total_Static),
665
+ "FlowFunction": float(FlowFunction),
666
+ "CorrectedSpeed": float(CorrectedSpeed),
667
+ "EnergyFunction": float(EnergyFunction),
668
+ "eta_polytropic_overall": float(self.overall_polytropic_efficiency()),
669
+ "units": {
670
+ "massflow": {"metric": "kg/s", "english": "lbm/s"},
671
+ "rpm": {"metric": "rpm", "english": "rpm"},
672
+ "euler_power": {"metric": "W", "english": "hp"},
673
+ "enthalpy_power": {"metric": "W", "english": "hp"},
674
+ "Pratio_Total_Total": {"metric": "—", "english": "—"},
675
+ "Pratio_Total_Static": {"metric": "—", "english": "—"},
676
+ "FlowFunction": {"metric": "kg/s·K^0.5·Pa", "english": "lbm/s·R^0.5·psf"},
677
+ "CorrectedSpeed": {"metric": "rad/s·K^-0.5", "english": "rad/s·R^-0.5"},
678
+ "EnergyFunction": {"metric": "—", "english": "—"},
679
+ },
680
+ }
681
+
682
+ class NumpyEncoder(json.JSONEncoder):
683
+ def default(self, obj): # type: ignore[override]
684
+ if isinstance(obj, np.ndarray):
685
+ return obj.tolist()
686
+ return super().default(obj)
687
+
688
+ with open(filename, "w") as f:
689
+ json.dump(data, f, indent=4, cls=NumpyEncoder)
690
+
691
+ def plot(self) -> None:
692
+ """Plot hub/shroud and streamlines."""
693
+ blade_rows = self._all_rows()
694
+ plt.figure(num=1, clear=True, dpi=150, figsize=(15, 10))
695
+ plt.plot(
696
+ self.passage.xhub_pts,
697
+ self.passage.rhub_pts,
698
+ label="hub",
699
+ linestyle="solid",
700
+ linewidth=2,
701
+ color="black",
702
+ )
703
+ plt.plot(
704
+ self.passage.xshroud_pts,
705
+ self.passage.rshroud_pts,
706
+ label="shroud",
707
+ linestyle="solid",
708
+ linewidth=2,
709
+ color="black",
710
+ )
711
+
712
+ hub_length = np.sum(
713
+ np.sqrt(np.diff(self.passage.xhub_pts) ** 2 + np.diff(self.passage.rhub_pts) ** 2)
714
+ )
715
+ x_streamline = np.zeros((self.num_streamlines, len(self.blade_rows)))
716
+ r_streamline = np.zeros((self.num_streamlines, len(self.blade_rows)))
717
+ for i in range(len(blade_rows)):
718
+ x_streamline[:, i] = blade_rows[i].x
719
+ r_streamline[:, i] = blade_rows[i].r
720
+
721
+ for i in range(1, len(blade_rows) - 1):
722
+ plt.plot(x_streamline[:, i], r_streamline[:, i], "--b", linewidth=1.5)
723
+
724
+ for i, row in enumerate(blade_rows):
725
+ plt.plot(row.x, row.r, linestyle="dashed", linewidth=1.5, color="blue", alpha=0.4)
726
+ plt.plot(x_streamline[:, i], r_streamline[:, i], "or")
727
+
728
+ if i == 0:
729
+ pass
730
+ else:
731
+ upstream = blade_rows[i - 1]
732
+ if upstream.row_type == RowType.Inlet:
733
+ cut_line1, _, _ = self.passage.get_cutting_line(
734
+ (row.hub_location * hub_length + (0.5 * row.blade_to_blade_gap * row.axial_chord) - row.axial_chord)
735
+ / hub_length
736
+ )
737
+ else:
738
+ cut_line1, _, _ = self.passage.get_cutting_line(
739
+ (upstream.hub_location * hub_length) / hub_length
740
+ )
741
+ cut_line2, _, _ = self.passage.get_cutting_line(
742
+ (row.hub_location * hub_length - (0.5 * row.blade_to_blade_gap * row.axial_chord)) / hub_length
743
+ )
744
+
745
+ if row.row_type == RowType.Stator:
746
+ x1, r1 = cut_line1.get_point(np.linspace(0, 1, 10))
747
+ plt.plot(x1, r1, "m")
748
+ x2, r2 = cut_line2.get_point(np.linspace(0, 1, 10))
749
+ plt.plot(x2, r2, "m")
750
+ x_text = (x1 + x2) / 2
751
+ r_text = (r1 + r2) / 2
752
+ plt.text(x_text.mean(), r_text.mean(), "Stator", fontdict={"fontsize": "xx-large"})
753
+ elif row.row_type == RowType.Rotor:
754
+ x1, r1 = cut_line1.get_point(np.linspace(0, 1, 10))
755
+ plt.plot(x1, r1, color="brown")
756
+ x2, r2 = cut_line2.get_point(np.linspace(0, 1, 10))
757
+ plt.plot(x2, r2, color="brown")
758
+ x_text = (x1 + x2) / 2
759
+ r_text = (r1 + r2) / 2
760
+ plt.text(x_text.mean(), r_text.mean(), "Rotor", fontdict={"fontsize": "xx-large"})
761
+
762
+ plt.axis("scaled")
763
+ plt.savefig("Meridional.png", transparent=False, dpi=150)
764
+ plt.show()
765
+
766
+ def plot_velocity_triangles(self) -> None:
767
+ """Plot velocity triangles for each blade row (turbines).
768
+ """
769
+ blade_rows = self._all_rows()
770
+ prop = dict(arrowstyle="-|>,head_width=0.4,head_length=0.8", shrinkA=0, shrinkB=0)
771
+
772
+ for j in range(self.num_streamlines):
773
+ x_start = 0.0
774
+ y_max = 0.0
775
+ y_min = 0.0
776
+ plt.figure(num=1, clear=True)
777
+ for i in range(1, len(blade_rows) - 1):
778
+ row = blade_rows[i]
779
+ x_end = x_start + row.Vm.mean()
780
+ dx = x_end - x_start
781
+
782
+ Vt = row.Vt[j]
783
+ Wt = row.Wt[j]
784
+ U = row.U[j]
785
+
786
+ y_max = max(y_max, Vt, Wt)
787
+ y_min = min(y_min, Vt, Wt)
788
+
789
+ # V
790
+ plt.annotate("", xy=(x_end, Vt), xytext=(x_start, 0), arrowprops=prop)
791
+ plt.text((x_start + x_end) / 2, Vt / 2 * 1.1, "V", fontdict={"fontsize": "xx-large"})
792
+
793
+ # W
794
+ plt.annotate("", xy=(x_end, Wt), xytext=(x_start, 0), arrowprops=prop)
795
+ plt.text((x_start + x_end) / 2, Wt / 2 * 1.1, "W", fontdict={"fontsize": "xx-large"})
796
+
797
+ if abs(Vt) > abs(Wt):
798
+ plt.annotate("", xy=(x_end, Wt), xytext=(x_end, 0), arrowprops=prop) # Wt
799
+ plt.text(x_end + dx * 0.1, Wt / 2, "Wt", fontdict={"fontsize": "xx-large"})
800
+
801
+ plt.annotate("", xy=(x_end, U + Wt), xytext=(x_end, Wt), arrowprops=prop) # U
802
+ plt.text(x_end + dx * 0.1, (Wt + U) / 2, "U", fontdict={"fontsize": "xx-large"})
803
+ else:
804
+ plt.annotate("", xy=(x_end, Vt), xytext=(x_end, 0), arrowprops=prop) # Vt
805
+ plt.text(x_end + dx * 0.1, Vt / 2, "Vt", fontdict={"fontsize": "xx-large"})
806
+
807
+ plt.annotate("", xy=(x_end, Wt + U), xytext=(x_end, Wt), arrowprops=prop) # U
808
+ plt.text(x_end + dx * 0.1, Wt + U / 2, "U", fontdict={"fontsize": "xx-large"})
809
+
810
+ y = y_min if -np.sign(Vt) > 0 else y_max
811
+ plt.text((x_start + x_end) / 2, -np.sign(Vt) * y * 0.95, row.row_type.name, fontdict={"fontsize": "xx-large"})
812
+ x_start += row.Vm[j]
813
+ plt.axis([0, x_end + dx, y_min, y_max])
814
+ plt.ylabel("Tangental Velocity [m/s]")
815
+ plt.xlabel("Vm [m/s]")
816
+ plt.title(f"Velocity Triangles for Streamline {j}")
817
+ plt.savefig(f"streamline_{j:04d}.png", transparent=False, dpi=150)
818
+
819
+ def save_convergence_history(self, filename: str = "convergence_history.jsonl") -> None:
820
+ """Save convergence history to JSONL file.
821
+
822
+ Writes the convergence history collected during solve() to a JSON Lines file,
823
+ where each line is a JSON object representing one iteration.
824
+
825
+ Args:
826
+ filename: Output JSONL file path (default: "convergence_history.jsonl")
827
+
828
+ Returns:
829
+ None. Writes JSONL file to specified path.
830
+
831
+ Example:
832
+ >>> spool.solve()
833
+ >>> spool.save_convergence_history("compressor_convergence.jsonl")
834
+ """
835
+ import json
836
+ from pathlib import Path
837
+
838
+ output_path = Path(filename)
839
+ with open(output_path, 'w') as f:
840
+ for entry in self.convergence_history:
841
+ f.write(json.dumps(entry) + '\n')
842
+ print(f"Convergence history saved to {output_path}")
843
+
844
+ def plot_convergence(self, save_to_file: Optional[Union[bool, str]] = None) -> None:
845
+ """Plot convergence history showing massflow error vs iteration.
846
+
847
+ Displays a semi-log plot of the massflow standard deviation error across
848
+ iterations. If convergence history is empty, warns user.
849
+
850
+ Args:
851
+ save_to_file: If True, saves to "convergence.png". If string, saves to that filename.
852
+ If None/False, displays plot without saving.
853
+
854
+ Returns:
855
+ None. Either displays plot or saves to file.
856
+
857
+ Example:
858
+ >>> spool.solve()
859
+ >>> spool.plot_convergence() # Display plot
860
+ >>> spool.plot_convergence(save_to_file=True) # Save to convergence.png
861
+ >>> spool.plot_convergence(save_to_file="my_convergence.png") # Save to custom file
862
+ """
863
+ if not self.convergence_history:
864
+ print("Warning: No convergence history available. Run solve() first.")
865
+ return
866
+
867
+ iterations = [entry['iteration'] for entry in self.convergence_history]
868
+ massflow_std = [entry['massflow_std'] for entry in self.convergence_history]
869
+ relative_change = [entry['relative_change'] for entry in self.convergence_history]
870
+
871
+ fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))
872
+
873
+ # Plot massflow std deviation
874
+ ax1.semilogy(iterations, massflow_std, 'o-', linewidth=2, markersize=8)
875
+ ax1.set_xlabel('Iteration', fontsize=12)
876
+ ax1.set_ylabel('Massflow Std Dev [kg/s]', fontsize=12)
877
+ ax1.set_title('Convergence History: Massflow Standard Deviation', fontsize=14, fontweight='bold')
878
+ ax1.grid(True, alpha=0.3)
879
+
880
+ # Plot relative change
881
+ ax2.semilogy(iterations, relative_change, 's-', color='orange', linewidth=2, markersize=8)
882
+ ax2.set_xlabel('Iteration', fontsize=12)
883
+ ax2.set_ylabel('Relative Change', fontsize=12)
884
+ ax2.set_title('Convergence History: Relative Change', fontsize=14, fontweight='bold')
885
+ ax2.axhline(y=0.05, color='r', linestyle='--', label='Convergence Threshold (0.05)')
886
+ ax2.legend()
887
+ ax2.grid(True, alpha=0.3)
888
+
889
+ plt.tight_layout()
890
+
891
+ if save_to_file:
892
+ filename = "convergence.png" if save_to_file is True else str(save_to_file)
893
+ plt.savefig(filename, dpi=150, bbox_inches='tight')
894
+ print(f"Convergence plot saved to {filename}")
895
+ else:
896
+ plt.show()
897
+
898
+
899
+ def outlet_pressure(percents: List[float], inletP0: float, outletP: float) -> npt.NDArray:
900
+ """Linearly interpolate total pressure values along the spool."""
901
+ percents_arr = convert_to_ndarray(percents)
902
+ return inletP0 + (outletP - inletP0) * percents_arr
903
+
904
+
905
+ def match_massflow_objective(exit_angle: float, index: int, row: BladeRow, upstream: BladeRow, downstream: Optional[BladeRow] = None, fluid: Optional[Solution] = None) -> float:
906
+ """Objective for adjusting exit angle to match a target massflow slice."""
907
+ if row.row_type not in (RowType.Rotor, RowType.Stator):
908
+ return 0.0
909
+
910
+ lt = getattr(row, "loss_function", None)
911
+ loss_type = getattr(lt, "loss_type", None)
912
+
913
+ if loss_type == LossType.Pressure and callable(lt):
914
+ row.Yp = lt(row, upstream) # type: ignore[arg-type]
915
+
916
+ if row.row_type == RowType.Rotor:
917
+ row.beta2[index] = np.radians(exit_angle)
918
+ rotor_calc(row, upstream)
919
+ elif row.row_type == RowType.Stator:
920
+ row.alpha2[index] = np.radians(exit_angle)
921
+ stator_calc(row, upstream)
922
+
923
+ if fluid is not None:
924
+ compute_gas_constants(upstream, fluid)
925
+ compute_gas_constants(row, fluid)
926
+
927
+ compute_massflow(row)
928
+ compute_power(row, upstream)
929
+
930
+ # drive radial distribution of massflow linearly by index using upstream total as target
931
+ target_total = None
932
+ for candidate in ("total_massflow_no_coolant", "total_massflow"):
933
+ val = getattr(upstream, candidate, None)
934
+ if val is not None and val != 0:
935
+ target_total = val
936
+ break
937
+ if target_total is None:
938
+ target_total = row.total_massflow if getattr(row, "total_massflow", 0) != 0 else row.massflow[-1]
939
+
940
+ target = target_total * index / max(len(row.massflow) - 1, 1)
941
+ return float(np.abs(target - row.massflow[index]))