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,1219 @@
1
+ # type: ignore[arg-type, reportUnknownArgumentType]
2
+ from __future__ import annotations
3
+
4
+ from multiprocessing import Value
5
+ import stat
6
+ from turtle import down
7
+ from typing import Dict, List, Union, Optional
8
+ import json
9
+
10
+ import numpy as np
11
+ import numpy.typing as npt
12
+ import matplotlib.pyplot as plt
13
+
14
+ from cantera.composite import Solution
15
+ from scipy.interpolate import interp1d
16
+ from scipy.optimize import minimize_scalar, fmin_slsqp
17
+
18
+ # --- Project-local imports
19
+ from .bladerow import BladeRow, interpolate_streamline_quantities
20
+ from .enums import RowType, LossType
21
+ from .outlet import OutletType
22
+ from .loss.turbine import TD2
23
+ from .passage import Passage
24
+ from .inlet import Inlet
25
+ from .outlet import Outlet
26
+ from .turbine_math import (
27
+ inlet_calc,
28
+ rotor_calc,
29
+ stator_calc,
30
+ compute_power,
31
+ compute_gas_constants,
32
+ compute_reynolds,
33
+ )
34
+ from .flow_math import compute_massflow, compute_streamline_areas, compute_power
35
+ from .solve_radeq import adjust_streamlines, radeq
36
+ from pyturbo.helper import line2D, convert_to_ndarray
37
+
38
+
39
+ class TurbineSpool:
40
+ """Used with turbines
41
+
42
+ This class (formerly named *Spool*) encapsulates both the generic geometry/plotting
43
+ utilities from the original base spool and the turbine-solving logic that lived
44
+ in the turbine-specific spool implementation.
45
+
46
+ Notes on differences vs. the two-class design:
47
+ - `field(default_factory=...)` was previously used on a non-dataclass attribute
48
+ (`t_streamline`). Here it's handled in `__init__` to avoid a silent bug.
49
+ - `fluid` defaults to `Solution('air.yaml')` if not provided.
50
+ - All turbine-specific methods (initialize/solve/massflow balancing/etc.) are
51
+ preserved here. If you ever add a *CompressorSpool* in the future, consider
52
+ splitting turbine/compressor behaviors behind a strategy/solver object.
53
+ """
54
+
55
+ # Class-level defaults (avoid mutable defaults here!)
56
+ rows: List[BladeRow]
57
+ massflow: float
58
+ rpm: float
59
+
60
+ # Types/attributes documented for linters; values set in __init__
61
+ passage: Passage
62
+ t_streamline: npt.NDArray
63
+ num_streamlines: int
64
+
65
+ _fluid: Solution
66
+ _adjust_streamlines: bool
67
+
68
+ def __init__(
69
+ self,
70
+ passage: Passage,
71
+ massflow: float,
72
+ inlet: Inlet,
73
+ outlet: Outlet,
74
+ rows: List[BladeRow],
75
+ num_streamlines: int = 3,
76
+ fluid: Optional[Solution] = None,
77
+ rpm: float = -1,
78
+ ) -> None:
79
+ """Initialize a (turbine) spool
80
+
81
+ Args:
82
+ passage: Passage defining hub and shroud
83
+ massflow: massflow at spool inlet
84
+ inlet: Inlet object
85
+ outlet: Outlet object
86
+ rows: Blade rows between inlet and outlet (stators/rotors only)
87
+ num_streamlines: number of streamlines used through the meridional passage
88
+ fluid: cantera gas solution; defaults to air.yaml if None
89
+ rpm: RPM for the entire spool. Individual rows can override later.
90
+ """
91
+ self.passage = passage
92
+ self.massflow = massflow
93
+ self.num_streamlines = num_streamlines
94
+ self._fluid = fluid
95
+ self.rpm = rpm
96
+
97
+ self.inlet = inlet
98
+ self.outlet = outlet
99
+ if self.outlet.outlet_type != OutletType.static_pressure:
100
+ assert "Outlet needs to be statically defined for turbine calculation"
101
+ self.rows = rows
102
+ self.t_streamline = np.zeros((10,), dtype=float)
103
+ self._adjust_streamlines = True
104
+ self.convergence_history: List[Dict] = []
105
+
106
+ # Assign IDs, RPMs, and axial chords where appropriate
107
+ for i, br in enumerate(self._all_rows()):
108
+ br.id = i
109
+ if not isinstance(br, (Inlet, Outlet)):
110
+ br.rpm = rpm
111
+ br.axial_chord = br.hub_location * self.passage.hub_length
112
+
113
+ # Propagate initial fluid to rows
114
+ if self._fluid is not None:
115
+ for br in self._all_rows():
116
+ br.fluid = self._fluid
117
+
118
+ def _all_rows(self) -> List[BladeRow]:
119
+ """Convenience to iterate inlet + interior rows + outlet."""
120
+ return [self.inlet, *self.rows, self.outlet]
121
+
122
+ @property
123
+ def blade_rows(self) -> List[BladeRow]:
124
+ """Backwards-compatible combined row list."""
125
+ return self._all_rows()
126
+
127
+ # ------------------------------
128
+ # Properties
129
+ # ------------------------------
130
+ @property
131
+ def fluid(self) -> Optional[Solution]:
132
+ return self._fluid
133
+
134
+ @fluid.setter
135
+ def fluid(self, newFluid: Solution) -> None:
136
+ """Change the gas used in the spool and cascade to rows."""
137
+ self._fluid = newFluid
138
+ for br in self.blade_rows:
139
+ br.fluid = self._fluid
140
+
141
+ @property
142
+ def adjust_streamlines(self) -> bool:
143
+ return self._adjust_streamlines
144
+
145
+ @adjust_streamlines.setter
146
+ def adjust_streamlines(self, val: bool) -> None:
147
+ self._adjust_streamlines = val
148
+
149
+ # ------------------------------
150
+ # Row utilities
151
+ # ------------------------------
152
+ def set_blade_row_rpm(self, index: int, rpm: float) -> None:
153
+ self.rows[index].rpm = rpm
154
+
155
+ def set_blade_row_type(self, blade_row_index: int, rowType: RowType) -> None:
156
+ self.rows[blade_row_index].row_type = rowType
157
+
158
+ def set_blade_row_exit_angles(
159
+ self,
160
+ radius: Dict[int, List[float]],
161
+ beta: Dict[int, List[float]],
162
+ IsSupersonic: bool = False,
163
+ ) -> None:
164
+ """Set intended exit flow angles for rows (useful when geometry is fixed)."""
165
+ for k, v in radius.items():
166
+ self.rows[k].radii_geom = v
167
+ for k, v in beta.items():
168
+ self.rows[k].beta_geom = v
169
+ self.rows[k].beta_fixed = True
170
+ for br in self._all_rows():
171
+ br.solution_type = "supersonic" if IsSupersonic else "subsonic"
172
+
173
+ # ------------------------------
174
+ # Streamline setup/geometry
175
+ # ------------------------------
176
+ def initialize_streamlines(self) -> None:
177
+ """Initialize streamline storage per row and compute curvature."""
178
+ for row in self._all_rows():
179
+ row.phi = np.zeros((self.num_streamlines,))
180
+ row.rm = np.zeros((self.num_streamlines,))
181
+ row.r = np.zeros((self.num_streamlines,))
182
+ row.m = np.zeros((self.num_streamlines,))
183
+
184
+ t_radial = np.array([0.5]) if self.num_streamlines == 1 else np.linspace(0, 1, self.num_streamlines)
185
+ self.calculate_streamline_curvature(row, t_radial)
186
+ if self.num_streamlines == 1:
187
+ area = self.passage.get_area(row.hub_location)
188
+ row.total_area = area
189
+ row.area = np.array([area])
190
+
191
+ # Ensure a loss model exists on blade rows
192
+ if not isinstance(row, (Inlet, Outlet)) and row.loss_function is None:
193
+ row.loss_function = TD2()
194
+
195
+ # With radii known, couple blade geometry (pitch/chord/stagger) if specified
196
+ for row in self._all_rows():
197
+ if isinstance(row, BladeRow) and row.row_type not in (RowType.Inlet, RowType.Outlet):
198
+ try:
199
+ row.synchronize_blade_geometry()
200
+ except Exception:
201
+ pass
202
+
203
+ def calculate_streamline_curvature(
204
+ self, row: BladeRow, t_hub_shroud: Union[List[float], npt.NDArray]
205
+ ) -> None:
206
+ """Calculates the streamline curvature
207
+
208
+ Args:
209
+ row (BladeRow): current blade row
210
+ t_radial (Union[List[float], npt.NDArray]): percent along line from hub to shroud
211
+ """
212
+ for i, tr in enumerate(t_hub_shroud):
213
+ t_s, x_s, r_s = self.passage.get_streamline(tr)
214
+ phi, rm, r = self.passage.streamline_curvature(x_s, r_s)
215
+ row.phi[i] = float(interp1d(t_s, phi)(row.hub_location))
216
+ row.rm[i] = float(interp1d(t_s, rm)(row.hub_location))
217
+ row.r[i] = float(interp1d(t_s, r)(row.hub_location))
218
+ row.m[i] = float(
219
+ interp1d(t_s, self.passage.get_m(tr, resolution=len(t_s)))(row.hub_location)
220
+ )
221
+ # Back-compute pitch_to_chord if blade count is specified and chord is nonzero
222
+ chord = np.asarray(row.chord, dtype=float)
223
+ mean_chord = float(np.mean(chord)) if chord.size else 0.0
224
+ if row.num_blades and mean_chord != 0:
225
+ mean_r = float(np.mean(row.r))
226
+ pitch = 2 * np.pi * mean_r / row.num_blades
227
+ row.pitch_to_chord = pitch / mean_chord
228
+
229
+ def solve_for_static_pressure(self,upstream:BladeRow,row:BladeRow):
230
+ """Solve for static pressure at blade row exit using isentropic flow relations.
231
+
232
+ Uses massflow-area-Mach number relation to find static pressure from known
233
+ total conditions. Attempts both subsonic and supersonic solutions and selects
234
+ the subsonic solution.
235
+
236
+ Args:
237
+ upstream: Upstream blade row providing inlet conditions
238
+ row: Current blade row where static pressure is being solved
239
+
240
+ Returns:
241
+ None. Updates row.M, row.T, and row.P in-place.
242
+ """
243
+ if row.row_type == RowType.Stator:
244
+ b = row.total_area * row.P0 / np.sqrt(row.T0) * np.sqrt(row.gamma/row.R)
245
+ else:
246
+ b = row.total_area * row.P0R / np.sqrt(row.T0R) * np.sqrt(row.gamma/row.R)
247
+
248
+ solve_for_M = upstream.total_massflow / b
249
+ fun = lambda M : np.abs(solve_for_M - M*(1+(row.gamma-1)/2 * M**2) ** (-(row.gamma+1)/(2*(row.gamma-1))))
250
+ M_subsonic = minimize_scalar(fun,0.1, bounds=[0,1])
251
+ M_supersonic = minimize_scalar(fun,1.5, bounds=[1,5])
252
+ row.M = M_subsonic
253
+ if row.row_type == RowType.Stator:
254
+ row.T = row.T0/IsenT(M_subsonic,row.gamma)
255
+ else:
256
+ row.T = row.T0R/IsenT(M_subsonic,row.gamma)
257
+ a = np.sqrt(row.T*row.gamma*row.R)
258
+ row.P = row.total_massflow * row.R*row.T / (row.total_area * row.M * a)
259
+ # When total conditions are defined we calculate static pressure
260
+ if row.row_type == RowType.Stator:
261
+ row.P = upstream.P0 - (upstream.P0 - row.P0) / row.Yp
262
+ else:
263
+ row.P = upstream.P0R - (upstream.P0R - row.P0R) / row.Yp
264
+ # ------------------------------
265
+ # initialization/solve
266
+ # ------------------------------
267
+ def initialize(self) -> None:
268
+ """Initialize massflow and thermodynamic state through rows (turbines)."""
269
+ blade_rows = self._all_rows()
270
+ Is_static_defined = (self.outlet.outlet_type == OutletType.static_pressure) or (self.outlet.outlet_type == OutletType.massflow_static_pressure)
271
+
272
+ # Inlet
273
+ W0 = self.massflow
274
+ inlet = self.inlet
275
+ if self.fluid:
276
+ inlet.__initialize_fluid__(self.fluid) # type: ignore[arg-type]
277
+ elif inlet.gamma is not None:
278
+ inlet.__initialize_fluid__(R=inlet.R, gamma=inlet.gamma, Cp=inlet.Cp) # type: ignore[call-arg]
279
+ elif blade_rows[1].gamma is not None:
280
+ inlet.__initialize_fluid__( # type: ignore[call-arg]
281
+ R=blade_rows[1].R,
282
+ gamma=blade_rows[1].gamma,
283
+ Cp=blade_rows[1].Cp,
284
+ )
285
+
286
+ inlet.total_massflow = W0
287
+ inlet.total_massflow_no_coolant = W0
288
+ inlet.massflow = np.array([W0]) if self.num_streamlines == 1 else np.linspace(0, 1, self.num_streamlines) * W0
289
+
290
+ inlet.__interpolate_quantities__(self.num_streamlines) # type: ignore[attr-defined]
291
+ inlet.__initialize_velocity__(self.passage, self.num_streamlines) # type: ignore[attr-defined]
292
+ interpolate_streamline_quantities(inlet, self.passage, self.num_streamlines)
293
+
294
+ inlet_calc(inlet)
295
+
296
+ for i,row in enumerate(blade_rows):
297
+ interpolate_streamline_quantities(row, self.passage, self.num_streamlines)
298
+
299
+ outlet = self.outlet
300
+ for j in range(self.num_streamlines):
301
+ percents = np.zeros(shape=(len(blade_rows) - 2)) + 0.3
302
+ percents[-1] = 1
303
+ if Is_static_defined:
304
+ Ps_range = step_pressures(percents=percents, inletP0=inlet.P0[j], outletP=outlet.P[j])
305
+ for i in range(1, len(blade_rows) - 1):
306
+ blade_rows[i].P[j] = Ps_range[i - 1]
307
+ else:
308
+ P0_range = step_pressures(percents=percents, inletP0=inlet.P0[j], outletP=outlet.P0[j])
309
+ for i in range(1, len(blade_rows) - 1):
310
+ if blade_rows[i].row_type == RowType.Stator:
311
+ blade_rows[i].P0[j] = P0_range[i - 1]
312
+ else:
313
+ blade_rows[i].P0R[j] = P0_range[i - 1]
314
+
315
+ # Pass T0, P0 to downstream rows
316
+ for i in range(1, len(blade_rows) - 1):
317
+ upstream = blade_rows[i - 1]
318
+ downstream = blade_rows[i + 1] if i + 1 < len(blade_rows) else None
319
+
320
+ row = blade_rows[i]
321
+ if row.coolant is not None:
322
+ T0c = row.coolant.T0
323
+ P0c = row.coolant.P0
324
+ W0c = row.coolant.massflow_percentage * self.massflow
325
+ Cpc = row.coolant.Cp
326
+ else:
327
+ T0c = 100
328
+ P0c = 0
329
+ W0c = 0
330
+ Cpc = 0
331
+
332
+ # Adjust for Coolant
333
+ T0 = (W0 * upstream.Cp * upstream.T0 + W0c * Cpc * T0c) / (Cpc * W0c + upstream.Cp * W0)
334
+ # P0 = (W0 * upstream.Cp * upstream.P0 + W0c * Cpc * P0c) / (Cpc * W0c + upstream.Cp * W0)
335
+ Cp = (W0 * upstream.Cp + W0c * Cpc) / (W0c + W0) if (W0c + W0) != 0 else upstream.Cp
336
+ # Adjust for power
337
+ if row.row_type == RowType.Rotor:
338
+ T0 = T0 - row.power / (Cp * (W0 + W0c))
339
+
340
+ W0 += W0c
341
+ row.T0 = T0
342
+ # row.P0 = P0
343
+ row.Cp = Cp
344
+ row.total_massflow = W0
345
+ row.massflow = np.array([row.total_massflow]) if self.num_streamlines == 1 else np.linspace(0, 1, self.num_streamlines) * row.total_massflow
346
+
347
+ # Pass gas constants
348
+ row.rho = upstream.rho
349
+ row.gamma = upstream.gamma
350
+ row.R = upstream.R
351
+
352
+ if row.loss_function.loss_type == LossType.Pressure:
353
+ row.Yp = row.loss_function(row, upstream)
354
+ elif row.loss_function.loss_type == LossType.Enthalpy:
355
+ row.Yp = 0
356
+
357
+ if row.row_type == RowType.Stator:
358
+ stator_calc(row, upstream, downstream,True,Is_static_defined) # type: ignore[arg-type]
359
+ compute_massflow(row)
360
+ elif row.row_type == RowType.Rotor:
361
+ rotor_calc(row, upstream,True,Is_static_defined)
362
+ compute_massflow(row)
363
+ compute_power(row, upstream)
364
+
365
+ def solve(self) -> None:
366
+ """Solve for exit angles/pressures to satisfy chosen massflow constraint."""
367
+ self.initialize_streamlines()
368
+ self.initialize()
369
+
370
+ if self.outlet.outlet_type == OutletType.massflow_static_pressure:
371
+ print("Using angle matching mode: blade exit angles will be adjusted to match specified massflow")
372
+ self._angle_match()
373
+ else:
374
+ print("Using pressure balance mode: blade exit angles are fixed, static pressures will be adjusted")
375
+ self._balance_pressure()
376
+
377
+
378
+ def total_power(self) -> float:
379
+ """Return total turbine power extracted (sum over rotor rows)."""
380
+ total = 0.0
381
+ for row in self._all_rows():
382
+ if getattr(row, "row_type", None) == RowType.Rotor:
383
+ total += float(getattr(row, "power", 0.0) or 0.0)
384
+ return total
385
+
386
+ def solve_massflow_for_power(self, target_power: float, massflow_guess: Optional[float] = None, tol_rel: float = 1e-3, max_iter: int = 8, relax: float = 0.7, bounds: tuple[float, float] = (1e-6, 1e9)) -> tuple[float, float]:
387
+ """Power-driven closure: iterate inlet massflow to hit a target turbine power.
388
+
389
+ This uses a simple algebraic update (no additional nested optimizer):
390
+ mdot_next = mdot_current * (P_target / P_current)
391
+
392
+ The inner flow solution still uses the existing pressure-balance method to
393
+ maintain a consistent massflow between rows for the current guess.
394
+
395
+ Args:
396
+ target_power: Desired turbine power [W]. Use a positive value for power extracted.
397
+ massflow_guess: Optional starting guess for inlet massflow [kg/s]. Defaults to `self.massflow`.
398
+ tol_rel: Relative tolerance on power error.
399
+ max_iter: Maximum outer iterations.
400
+ relax: Under-relaxation factor (0–1) for massflow updates.
401
+ bounds: (lower, upper) bounds for massflow during updates.
402
+
403
+ Returns:
404
+ Tuple of (achieved_massflow_kg_s, achieved_power_W).
405
+ """
406
+ target = float(target_power)
407
+ if target <= 0:
408
+ raise ValueError("target_power must be positive for turbine power-based solve.")
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
+ mdot = float(self.massflow if massflow_guess is None else massflow_guess)
415
+ mdot = float(np.clip(mdot, lower, upper))
416
+
417
+ # Temporarily store original outlet type and ensure pressure balance mode
418
+ prev_outlet_type = self.outlet.outlet_type
419
+ prev_massflow = getattr(self.outlet, 'total_massflow', None)
420
+ self.outlet.outlet_type = OutletType.static_pressure
421
+
422
+ try:
423
+ for _ in range(max_iter):
424
+ # Important: prevent a previous computed `row.power` from being treated as an input
425
+ # in `initialize()` when power is not a design target.
426
+ for r in self.rows:
427
+ if r.row_type == RowType.Rotor:
428
+ r.power = 0.0
429
+ r.power_mean = 0.0
430
+
431
+ self.massflow = mdot
432
+ self.solve()
433
+
434
+ achieved_power = self.total_power()
435
+ achieved_mdot = float(getattr(self._all_rows()[1], "total_massflow_no_coolant", mdot) or mdot)
436
+
437
+ if achieved_power <= 0 or not np.isfinite(achieved_power):
438
+ raise ValueError(f"Non-physical power encountered during solve (power={achieved_power}).")
439
+
440
+ err_rel = abs(achieved_power - target) / target
441
+ if err_rel <= tol_rel:
442
+ return achieved_mdot, achieved_power
443
+
444
+ mdot_update = achieved_mdot * (target / achieved_power)
445
+ mdot = float(np.clip(relax * mdot_update + (1.0 - relax) * achieved_mdot, lower, upper))
446
+
447
+ return float(getattr(self._all_rows()[1], "total_massflow_no_coolant", self.massflow) or self.massflow), self.total_power()
448
+ finally:
449
+ self.outlet.outlet_type = prev_outlet_type
450
+ if prev_massflow is not None:
451
+ self.outlet.total_massflow = prev_massflow
452
+
453
+ # ------------------------------
454
+ # Massflow matching/balancing
455
+ # ------------------------------
456
+ def _angle_match(self) -> None:
457
+ """Match massflow between streamtubes by tweaking exit angles."""
458
+ rows = self._all_rows()
459
+ massflow_target = np.linspace(0,rows[-1].total_massflow,self.num_streamlines)
460
+
461
+ self.convergence_history = [] # Reset convergence history
462
+ past_err = -100.0
463
+ loop_iter = 0
464
+ err = 1e-3
465
+
466
+ print("Looping to converge massflow (angle matching)")
467
+ while (np.abs((err - past_err) / err) > 0.05) and (loop_iter < 10):
468
+ for i in range(1,len(rows)-1):
469
+ upstream = rows[i - 1] if i > 0 else rows[i]
470
+ downstream = rows[i + 1] if i < len(rows) - 1 else None
471
+
472
+ # Use custom massflow target if defined, otherwise use default
473
+ if rows[i].massflow_target is not None:
474
+ current_massflow_target = rows[i].massflow_target
475
+ else:
476
+ current_massflow_target = massflow_target
477
+
478
+ if rows[i].row_type == RowType.Stator:
479
+ bounds = [0, 80]
480
+ elif rows[i].row_type == RowType.Rotor:
481
+ bounds = [-80, 0]
482
+ else:
483
+ bounds = [0, 0]
484
+
485
+ for j in range(1, self.num_streamlines):
486
+ res = minimize_scalar(
487
+ massflow_loss_function,
488
+ bounds=bounds,
489
+ args=(j, rows[i], upstream, current_massflow_target[j], downstream),
490
+ options={'xatol': 1e-4},
491
+ method="bounded",
492
+ )
493
+ if rows[i].row_type == RowType.Rotor:
494
+ rows[i].beta2[j] = np.radians(res.x)
495
+ rows[i].beta2[0] = 1 / (len(rows[i].beta2) - 1) * rows[i].beta2[1:].sum()
496
+ elif rows[i].row_type == RowType.Stator:
497
+ rows[i].alpha2[j] = np.radians(res.x)
498
+ rows[i].alpha2[0] = 1 / (len(rows[i].alpha2) - 1) * rows[i].alpha2[1:].sum()
499
+ compute_gas_constants(upstream, self.fluid)
500
+ compute_gas_constants(rows[i], self.fluid)
501
+
502
+ # Adjust inlet to match massflow found at first blade row
503
+ target = rows[1].total_massflow_no_coolant
504
+ self.inlet.massflow = np.array([target]) if self.num_streamlines == 1 else (np.linspace(0, 1, self.num_streamlines) * target)
505
+ self.inlet.total_massflow_no_coolant = rows[1].total_massflow_no_coolant
506
+ self.inlet.total_massflow = rows[1].total_massflow_no_coolant
507
+ self.inlet.calculated_massflow = self.inlet.total_massflow_no_coolant
508
+ inlet_calc(self.inlet)
509
+
510
+ if self.adjust_streamlines:
511
+ adjust_streamlines(rows, self.passage)
512
+
513
+ # Track convergence history
514
+ past_err = err
515
+ err = self.__massflow_std__(rows[1:-1])
516
+ loop_iter += 1
517
+
518
+ self.convergence_history.append({
519
+ 'iteration': loop_iter,
520
+ 'massflow_std': float(err),
521
+ 'massflow_change': float(abs(err - past_err)),
522
+ 'relative_change': float(abs((err - past_err) / max(err, 1e-6))),
523
+ 'massflow': float(rows[1].total_massflow_no_coolant)
524
+ })
525
+ print(f"Angle match iteration {loop_iter}, massflow std: {err:.6f}")
526
+
527
+ compute_reynolds(rows, self.passage)
528
+
529
+ @staticmethod
530
+ def __massflow_std__(blade_rows: List[BladeRow]) -> float:
531
+ """Calculate massflow standard deviation across blade rows.
532
+
533
+ Computes the standard deviation of total massflow (without coolant) across
534
+ all blade rows. Used as a convergence criterion for pressure balance and
535
+ angle matching iterations. Warns if deviation exceeds 1.0 kg/s.
536
+
537
+ Args:
538
+ blade_rows: List of all blade rows (inlet, stators, rotors, outlet)
539
+
540
+ Returns:
541
+ float: Two times the standard deviation of massflow [kg/s]
542
+ """
543
+ total_massflow = []
544
+ massflow_stage = []
545
+ stage_ids = list({row.stage_id for row in blade_rows if row.stage_id >= 0})
546
+
547
+ for row in blade_rows:
548
+ total_massflow.append(row.total_massflow_no_coolant)
549
+ sign = 1
550
+ for s in stage_ids:
551
+ for r in blade_rows:
552
+ if r.stage_id == s and r.row_type == RowType.Rotor:
553
+ massflow_stage.append(sign * r.total_massflow_no_coolant)
554
+ sign *= -1
555
+ if len(stage_ids) % 2 == 1 and massflow_stage:
556
+ massflow_stage.append(massflow_stage[-1] * sign)
557
+ deviation = np.std(total_massflow) * 2
558
+ if deviation > 1.0:
559
+ print("high massflow deviation detected")
560
+ return np.std(total_massflow) * 2
561
+
562
+ def _balance_pressure(self) -> None:
563
+ """Balance massflow between rows using radial equilibrium."""
564
+ rows = self._all_rows()
565
+ past_err = -100.0
566
+ loop_iter = 0
567
+ err = 1e-3
568
+ self.convergence_history = [] # Reset convergence history
569
+
570
+ def balance_loop(
571
+ x0: List[float],
572
+ rows: List[BladeRow],
573
+ P0: List[float],
574
+ P_or_P0: List[float],
575
+ ) -> float:
576
+ """Runs through the calclulation and outputs the standard deviation of massflow
577
+
578
+ Args:
579
+ x0 (List[float]): Array of percent breakdown (P0 to P) or (P0 to P0_exit)
580
+ rows (List[BladeRow]): _description_
581
+ P0 (npt.NDArray): _description_
582
+ P_or_P0 (npt.NDArray): _description_
583
+
584
+ Returns:
585
+ float: _description_
586
+ """
587
+ nonlocal err, past_err, loop_iter
588
+ static_defined = (self.outlet.outlet_type == OutletType.static_pressure)
589
+ P_exit = P_or_P0
590
+ for j in range(self.num_streamlines):
591
+ Ps_guess = step_pressures(x0, P0[j], P_exit[j])
592
+ for i in range(1, len(rows) - 2):
593
+ rows[i].P[j] = float(Ps_guess[i - 1])
594
+ rows[-2].P[:] = P_exit[-1]
595
+
596
+ # Loop through massflow calculation for all rows
597
+ for i in range(1, len(rows) - 1):
598
+ row = rows[i]
599
+ upstream = rows[i - 1] if i > 0 else rows[i]
600
+ downstream = rows[i + 1]
601
+
602
+ if row.row_type == RowType.Inlet:
603
+ row.Yp = 0
604
+ else:
605
+ if row.loss_function.loss_type == LossType.Pressure: # type: ignore[union-attr]
606
+ row.Yp = row.loss_function(row, upstream) # type: ignore[assignment]
607
+ for _ in range(2):
608
+ if row.row_type == RowType.Rotor:
609
+ rotor_calc(row, upstream,
610
+ calculate_vm=True,outlet_type=OutletType.static_pressure if static_defined else OutletType.total_pressure)
611
+ if self.num_streamlines > 1:
612
+ row = radeq(row, upstream, downstream)
613
+ compute_gas_constants(row, self.fluid)
614
+ rotor_calc(row, upstream,
615
+ calculate_vm=False,outlet_type=OutletType.static_pressure if static_defined else OutletType.total_pressure)
616
+ elif row.row_type == RowType.Stator:
617
+ stator_calc(row, upstream, downstream,
618
+ calculate_vm=True,outlet_type=OutletType.static_pressure if static_defined else OutletType.total_pressure)
619
+ if self.num_streamlines > 1:
620
+ row = radeq(row, upstream, downstream)
621
+ compute_gas_constants(row, self.fluid)
622
+ stator_calc(row, upstream, downstream,
623
+ calculate_vm=False,outlet_type=OutletType.static_pressure if static_defined else OutletType.total_pressure)
624
+ compute_gas_constants(row, self.fluid)
625
+ compute_massflow(row)
626
+ compute_power(row, upstream)
627
+
628
+ elif row.loss_function.loss_type == LossType.Enthalpy:
629
+ if row.row_type == RowType.Rotor:
630
+ row.Yp = 0
631
+ rotor_calc(row,upstream,calculate_vm=True)
632
+ eta_total = float(row.loss_function(row,upstream))
633
+
634
+ def find_yp(Yp,row,upstream):
635
+ row.Yp = Yp
636
+ rotor_calc(row,upstream,calculate_vm=True)
637
+ row = radeq(row,upstream)
638
+ compute_gas_constants(row,self.fluid)
639
+ rotor_calc(row,upstream,calculate_vm=False)
640
+ return abs(row.eta_total - eta_total)
641
+
642
+ res = minimize_scalar(find_yp,bounds=[0,0.6],args=(row,upstream))
643
+ row.Yp = res.x
644
+ elif row.row_type == RowType.Stator:
645
+ row.Yp = 0
646
+ stator_calc(row,upstream,downstream,calculate_vm=True)
647
+ if self.num_streamlines > 1:
648
+ row = radeq(row,upstream)
649
+ compute_gas_constants(row,self.fluid)
650
+ stator_calc(row,upstream,downstream,calculate_vm=False)
651
+ compute_gas_constants(row,self.fluid)
652
+ compute_massflow(row)
653
+ compute_power(row,upstream)
654
+ print(x0)
655
+
656
+ past_err = err
657
+ err = self.__massflow_std__(rows[1:-1])
658
+ loop_iter += 1
659
+
660
+ # Store convergence history
661
+ self.convergence_history.append({
662
+ 'iteration': loop_iter,
663
+ 'massflow_std': float(err),
664
+ 'massflow_change': float(abs(err - past_err)),
665
+ 'relative_change': float(abs((err - past_err) / max(err, 1e-6))),
666
+ 'massflow': float(rows[1].total_massflow_no_coolant)
667
+ })
668
+
669
+ return self.__massflow_std__(rows[1:-1])
670
+
671
+ pressure_ratio_ranges: List[tuple] = []
672
+ pressure_ratio_guess: List[float] = []
673
+ for i in range(1, len(rows) - 2):
674
+ bounds = tuple(float(v) for v in rows[i].inlet_to_outlet_pratio)
675
+ pressure_ratio_ranges.append(bounds)
676
+ pressure_ratio_guess.append(float(np.mean(bounds)))
677
+
678
+ if self.outlet.outlet_type != OutletType.static_pressure:
679
+ raise ValueError("For turbine calculations, please define outlet using init_static")
680
+
681
+ print("Looping to converge massflow")
682
+ while (np.abs((err - past_err) / err) > 0.05) and (loop_iter < 10):
683
+ if len(pressure_ratio_ranges) == 1: # Single stage, use minimize scalar
684
+ x = minimize_scalar(
685
+ fun=balance_loop,
686
+ args=(rows, self.inlet.P0, self.outlet.P),
687
+ bounds=pressure_ratio_ranges[0],
688
+ tol=1e-4,
689
+ method="bounded")
690
+ print(x)
691
+ else: # Multiple stages, use slsqp
692
+ x = fmin_slsqp(
693
+ func=balance_loop,
694
+ args=(rows, self.inlet.P0, self.outlet.P),
695
+ bounds=pressure_ratio_ranges,
696
+ x0=pressure_ratio_guess,
697
+ epsilon=1e-4,
698
+ iter=200)
699
+ pressure_ratio_guess = x.tolist()
700
+
701
+ # Adjust inlet to match massflow found at first blade row
702
+ target = rows[1].total_massflow_no_coolant
703
+ self.inlet.massflow = np.array([target]) if self.num_streamlines == 1 else (np.linspace(0, 1, self.num_streamlines) * target)
704
+ self.inlet.total_massflow_no_coolant = rows[1].total_massflow_no_coolant
705
+ self.inlet.total_massflow = rows[1].total_massflow_no_coolant
706
+ self.inlet.calculated_massflow = self.inlet.total_massflow_no_coolant
707
+ inlet_calc(self.inlet)
708
+
709
+ if self.adjust_streamlines:
710
+ adjust_streamlines(rows[:-1], self.passage)
711
+
712
+ self.outlet.transfer_quantities(rows[-2]) # outlet
713
+ self.outlet.P = self.outlet.get_static_pressure(self.outlet.percent_hub_shroud)
714
+
715
+
716
+ compute_reynolds(rows, self.passage)
717
+
718
+ # ------------------------------
719
+ # Export / Plotting
720
+ # ------------------------------
721
+ def export_properties(self, filename: str = "turbine_spool.json") -> None:
722
+ """Export turbine spool properties and blade row data to JSON file.
723
+
724
+ Exports comprehensive turbine design data including blade row properties,
725
+ streamline coordinates, efficiency metrics, degree of reaction, stage loading,
726
+ and power calculations for each stage. Useful for post-processing and result
727
+ archiving.
728
+
729
+ Args:
730
+ filename: Output JSON file path (default: "turbine_spool.json")
731
+
732
+ Returns:
733
+ None. Writes JSON file to specified path.
734
+
735
+ Example:
736
+ >>> spool.export_properties("eee_hpt_results.json")
737
+ """
738
+ blade_rows = self._all_rows()
739
+ blade_rows_out = []
740
+ degree_of_reaction = []
741
+ total_total_efficiency = []
742
+ total_static_efficiency = []
743
+ stage_loading = []
744
+ euler_power = []
745
+ enthalpy_power = []
746
+ x_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
747
+ r_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
748
+ massflow = []
749
+
750
+ for indx, row in enumerate(blade_rows):
751
+ blade_rows_out.append(row.to_dict())
752
+ if row.row_type == RowType.Rotor:
753
+ degree_of_reaction.append(
754
+ (
755
+ (blade_rows[indx - 1].P - row.P)
756
+ / (blade_rows[indx - 2].P - row.P)
757
+ ).mean()
758
+ )
759
+ total_total_efficiency.append(row.eta_total)
760
+ total_static_efficiency.append(row.eta_static)
761
+ stage_loading.append(row.stage_loading)
762
+ euler_power.append(row.euler_power)
763
+ enthalpy_power.append(row.power)
764
+ if row.row_type not in (RowType.Inlet, RowType.Outlet):
765
+ massflow.append(row.massflow[-1])
766
+
767
+ for j, p in enumerate(row.percent_hub_shroud):
768
+ t, x, r = self.passage.get_streamline(p)
769
+ x_streamline[j, indx] = float(interp1d(t, x)(row.percent_hub))
770
+ r_streamline[j, indx] = float(interp1d(t, r)(row.percent_hub))
771
+
772
+ Pratio_Total_Total = np.mean(self.inlet.P0 / blade_rows[-2].P0)
773
+ Pratio_Total_Static = np.mean(self.inlet.P0 / blade_rows[-2].P)
774
+ # Use scalarized inlet conditions to avoid shape mismatches with per-row massflow
775
+ flow_fn_massflow = float(np.mean(massflow)) if massflow else 0.0
776
+ FlowFunction = flow_fn_massflow * np.sqrt(self.inlet.T0.mean()) * float(np.mean(self.inlet.P0)) / 1000
777
+ CorrectedSpeed = self.rpm * np.pi / 30 / np.sqrt(self.inlet.T0.mean())
778
+ EnergyFunction = (
779
+ (self.inlet.T0 - blade_rows[-2].T0)
780
+ * 0.5
781
+ * (self.inlet.Cp + blade_rows[-2].Cp)
782
+ / self.inlet.T0
783
+ )
784
+ EnergyFunction = np.mean(EnergyFunction)
785
+
786
+ # English-unit conversions
787
+ massflow_kg_s = float(np.mean(massflow)) if massflow else 0.0
788
+ massflow_lbm_s = massflow_kg_s / 0.45359237
789
+ euler_power_hp = [p / 745.7 for p in euler_power]
790
+ enthalpy_power_hp = [p / 745.7 for p in enthalpy_power]
791
+
792
+ data = {
793
+ "blade_rows": blade_rows_out,
794
+ "massflow": massflow_kg_s,
795
+ "massflow_lbm_s": massflow_lbm_s,
796
+ "rpm": self.rpm,
797
+ "r_streamline": r_streamline.tolist(),
798
+ "x_streamline": x_streamline.tolist(),
799
+ "num_streamlines": self.num_streamlines,
800
+ "euler_power": euler_power,
801
+ "euler_power_hp": euler_power_hp,
802
+ "enthalpy_power": enthalpy_power,
803
+ "enthalpy_power_hp": enthalpy_power_hp,
804
+ "total-total_efficiency": total_total_efficiency,
805
+ "total-static_efficiency": total_static_efficiency,
806
+ "stage_loading": stage_loading,
807
+ "degree_of_reaction": degree_of_reaction,
808
+ "Pratio_Total_Total": float(Pratio_Total_Total),
809
+ "Pratio_Total_Static": float(Pratio_Total_Static),
810
+ "FlowFunction": float(FlowFunction),
811
+ "CorrectedSpeed": float(CorrectedSpeed),
812
+ "EnergyFunction": float(EnergyFunction),
813
+ "units": {
814
+ "massflow": {"metric": "kg/s", "english": "lbm/s"},
815
+ "rpm": {"metric": "rpm", "english": "rpm"},
816
+ "euler_power": {"metric": "W", "english": "hp"},
817
+ "enthalpy_power": {"metric": "W", "english": "hp"},
818
+ "Pratio_Total_Total": {"metric": "—", "english": "—"},
819
+ "Pratio_Total_Static": {"metric": "—", "english": "—"},
820
+ "FlowFunction": {"metric": "kg/s·K^0.5·Pa", "english": "lbm/s·R^0.5·psf"},
821
+ "CorrectedSpeed": {"metric": "rad/s·K^-0.5", "english": "rad/s·R^-0.5"},
822
+ "EnergyFunction": {"metric": "—", "english": "—"},
823
+ },
824
+ }
825
+
826
+ class NumpyEncoder(json.JSONEncoder):
827
+ def default(self, obj): # type: ignore[override]
828
+ if isinstance(obj, np.ndarray):
829
+ return obj.tolist()
830
+ return super().default(obj)
831
+
832
+ with open(filename, "w", encoding="utf-8") as f:
833
+ json.dump(data, f, indent=4, cls=NumpyEncoder, ensure_ascii=False)
834
+
835
+ def plot(self) -> None:
836
+ """Plot hub/shroud and streamlines with improved labels and formatting."""
837
+ blade_rows = self._all_rows()
838
+ fig, ax = plt.subplots(1, 1, figsize=(16, 8), dpi=150)
839
+
840
+ # Plot hub and shroud with thicker lines
841
+ ax.plot(
842
+ self.passage.xhub_pts,
843
+ self.passage.rhub_pts,
844
+ label="Hub",
845
+ linestyle="solid",
846
+ linewidth=3,
847
+ color="black",
848
+ zorder=10
849
+ )
850
+ ax.plot(
851
+ self.passage.xshroud_pts,
852
+ self.passage.rshroud_pts,
853
+ label="Shroud",
854
+ linestyle="solid",
855
+ linewidth=3,
856
+ color="black",
857
+ zorder=10
858
+ )
859
+
860
+ hub_length = np.sum(
861
+ np.sqrt(np.diff(self.passage.xhub_pts) ** 2 + np.diff(self.passage.rhub_pts) ** 2)
862
+ )
863
+
864
+ # Prepare streamline data
865
+ x_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
866
+ r_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
867
+ for i in range(len(blade_rows)):
868
+ x_streamline[:, i] = blade_rows[i].x
869
+ r_streamline[:, i] = blade_rows[i].r
870
+
871
+ # Plot streamlines connecting blade rows
872
+ for i in range(1, len(blade_rows) - 1):
873
+ ax.plot(x_streamline[:, i], r_streamline[:, i],
874
+ linestyle="--", linewidth=1.2, color="gray", alpha=0.6, zorder=1)
875
+
876
+ # Track label positions to avoid overlaps
877
+ label_positions = []
878
+
879
+ for i, row in enumerate(blade_rows):
880
+ # Plot blade row exit locations
881
+ ax.plot(row.x, row.r, linestyle="none", marker="o",
882
+ markersize=6, color="red", alpha=0.7, zorder=5)
883
+
884
+ # Label inlet
885
+ if row.row_type == RowType.Inlet:
886
+ x_pos = row.x.mean()
887
+ r_pos = row.r.mean()
888
+ ax.axvline(x=x_pos, color='green', linestyle=':', linewidth=2, alpha=0.7, zorder=2)
889
+ ax.text(x_pos, self.passage.rshroud_pts.max() * 1.05, 'INLET',
890
+ fontsize=12, fontweight='bold', ha='center', va='bottom',
891
+ bbox=dict(boxstyle='round,pad=0.5', facecolor='lightgreen', alpha=0.7))
892
+ label_positions.append((x_pos, 'INLET'))
893
+
894
+ # Plot blade rows with proper labels
895
+ elif row.row_type in [RowType.Stator, RowType.Rotor]:
896
+ if i > 0:
897
+ upstream = blade_rows[i - 1]
898
+ if upstream.row_type == RowType.Inlet:
899
+ cut_line1, _, _ = self.passage.get_cutting_line(
900
+ (row.hub_location * hub_length + (0.5 * row.blade_to_blade_gap * row.axial_chord) - row.axial_chord)
901
+ / hub_length
902
+ )
903
+ else:
904
+ cut_line1, _, _ = self.passage.get_cutting_line(
905
+ (upstream.hub_location * hub_length) / hub_length
906
+ )
907
+ cut_line2, _, _ = self.passage.get_cutting_line(
908
+ (row.hub_location * hub_length - (0.5 * row.blade_to_blade_gap * row.axial_chord)) / hub_length
909
+ )
910
+
911
+ # Plot blade leading and trailing edges
912
+ if row.row_type == RowType.Stator:
913
+ color = 'purple'
914
+ label = f'Stator {row.stage_id + 1}'
915
+ else:
916
+ color = 'brown'
917
+ label = f'Rotor {row.stage_id + 1}'
918
+
919
+ x1, r1 = cut_line1.get_point(np.linspace(0, 1, 10))
920
+ ax.plot(x1, r1, color=color, linewidth=2.5, alpha=0.8, zorder=3)
921
+ x2, r2 = cut_line2.get_point(np.linspace(0, 1, 10))
922
+ ax.plot(x2, r2, color=color, linewidth=2.5, alpha=0.8, zorder=3)
923
+
924
+ # Mark exit location with vertical line
925
+ x_exit = row.x.mean()
926
+ ax.axvline(x=x_exit, color=color, linestyle='--',
927
+ linewidth=1.5, alpha=0.5, zorder=2)
928
+
929
+ # Add exit label at top
930
+ ax.text(x_exit, self.passage.rshroud_pts.max() * 1.02, f'{label} Exit',
931
+ fontsize=10, ha='center', va='bottom', rotation=0,
932
+ color=color, fontweight='bold')
933
+
934
+ # Label outlet
935
+ elif row.row_type == RowType.Outlet:
936
+ x_pos = row.x.mean()
937
+ ax.axvline(x=x_pos, color='blue', linestyle=':', linewidth=2, alpha=0.7, zorder=2)
938
+ ax.text(x_pos, self.passage.rshroud_pts.max() * 1.05, 'OUTLET',
939
+ fontsize=12, fontweight='bold', ha='center', va='bottom',
940
+ bbox=dict(boxstyle='round,pad=0.5', facecolor='lightblue', alpha=0.7))
941
+
942
+ # Formatting
943
+ ax.set_xlabel('Axial Distance [m]', fontsize=13, fontweight='bold')
944
+ ax.set_ylabel('Radial Distance [m]', fontsize=13, fontweight='bold')
945
+ ax.set_title(f'Meridional View - {self.num_streamlines} Streamlines',
946
+ fontsize=14, fontweight='bold', pad=40)
947
+ ax.grid(True, alpha=0.3, linestyle=':', linewidth=0.5)
948
+ ax.legend(loc='upper left', fontsize=11, framealpha=0.9)
949
+ ax.set_aspect('equal', adjustable='box')
950
+
951
+ plt.tight_layout()
952
+ plt.savefig("Meridional.png", transparent=False, dpi=200, bbox_inches='tight')
953
+ plt.show()
954
+
955
+ def plot_velocity_triangles(self) -> None:
956
+ """Plot velocity triangles for each blade row with improved styling and annotations."""
957
+ blade_rows = self._all_rows()
958
+
959
+ # Define arrow properties for different velocity types
960
+ prop_V = dict(arrowstyle="-|>,head_width=0.5,head_length=1.0",
961
+ shrinkA=0, shrinkB=0, color='blue', lw=2.5)
962
+ prop_W = dict(arrowstyle="-|>,head_width=0.5,head_length=1.0",
963
+ shrinkA=0, shrinkB=0, color='red', lw=2.5)
964
+ prop_U = dict(arrowstyle="-|>,head_width=0.5,head_length=1.0",
965
+ shrinkA=0, shrinkB=0, color='green', lw=2.5)
966
+ prop_component = dict(arrowstyle="-|>,head_width=0.4,head_length=0.8",
967
+ shrinkA=0, shrinkB=0, color='gray', lw=1.5, linestyle='--')
968
+
969
+ for j in range(self.num_streamlines):
970
+ x_start = 0.0
971
+ y_max = 0.0
972
+ y_min = 0.0
973
+
974
+ fig, ax = plt.subplots(1, 1, figsize=(14, 8), dpi=150)
975
+
976
+ for i in range(1, len(blade_rows) - 1):
977
+ row = blade_rows[i]
978
+ x_end = x_start + row.Vm[j]
979
+ dx = x_end - x_start
980
+
981
+ Vt = row.Vt[j]
982
+ Wt = row.Wt[j]
983
+ U = row.U[j]
984
+ Vm = row.Vm[j]
985
+
986
+ y_max = max(y_max, Vt, Wt, U + Wt, U + Vt)
987
+ y_min = min(y_min, Vt, Wt, 0)
988
+
989
+ # Draw absolute velocity V (blue)
990
+ ax.annotate("", xy=(x_end, Vt), xytext=(x_start, 0), arrowprops=prop_V, zorder=5)
991
+ v_mag = np.sqrt(Vm**2 + Vt**2)
992
+ ax.text((x_start + x_end) / 2, Vt / 2 + np.sign(Vt) * 15,
993
+ f"V={v_mag:.1f}", fontsize=12, fontweight='bold',
994
+ ha='center', color='blue',
995
+ bbox=dict(boxstyle='round,pad=0.3', facecolor='lightblue', alpha=0.7))
996
+
997
+ # Draw relative velocity W (red)
998
+ ax.annotate("", xy=(x_end, Wt), xytext=(x_start, 0), arrowprops=prop_W, zorder=5)
999
+ w_mag = np.sqrt(Vm**2 + Wt**2)
1000
+ ax.text((x_start + x_end) / 2, Wt / 2 - np.sign(Wt) * 15,
1001
+ f"W={w_mag:.1f}", fontsize=12, fontweight='bold',
1002
+ ha='center', color='red',
1003
+ bbox=dict(boxstyle='round,pad=0.3', facecolor='lightcoral', alpha=0.7))
1004
+
1005
+ # Draw velocity components and U
1006
+ if abs(Vt) > abs(Wt):
1007
+ # Draw Wt component
1008
+ ax.annotate("", xy=(x_end, Wt), xytext=(x_end, 0), arrowprops=prop_component, zorder=3)
1009
+ ax.text(x_end + dx * 0.08, Wt / 2, f"Wt={Wt:.1f}",
1010
+ fontsize=10, ha='left', color='gray')
1011
+
1012
+ # Draw U (blade speed)
1013
+ ax.annotate("", xy=(x_end, U + Wt), xytext=(x_end, Wt), arrowprops=prop_U, zorder=4)
1014
+ ax.text(x_end + dx * 0.08, (Wt + U + Wt) / 2, f"U={U:.1f}",
1015
+ fontsize=11, ha='left', fontweight='bold', color='green',
1016
+ bbox=dict(boxstyle='round,pad=0.3', facecolor='lightgreen', alpha=0.7))
1017
+ else:
1018
+ # Draw Vt component
1019
+ ax.annotate("", xy=(x_end, Vt), xytext=(x_end, 0), arrowprops=prop_component, zorder=3)
1020
+ ax.text(x_end + dx * 0.08, Vt / 2, f"Vt={Vt:.1f}",
1021
+ fontsize=10, ha='left', color='gray')
1022
+
1023
+ # Draw U (blade speed)
1024
+ ax.annotate("", xy=(x_end, Wt), xytext=(x_end, Vt), arrowprops=prop_U, zorder=4)
1025
+ ax.text(x_end + dx * 0.08, (Vt + Wt) / 2, f"U={U:.1f}",
1026
+ fontsize=11, ha='left', fontweight='bold', color='green',
1027
+ bbox=dict(boxstyle='round,pad=0.3', facecolor='lightgreen', alpha=0.7))
1028
+
1029
+ # Draw Vm component (dashed horizontal)
1030
+ ax.plot([x_start, x_end], [0, 0], 'k--', linewidth=1.5, alpha=0.5, zorder=2)
1031
+ ax.text((x_start + x_end) / 2, -5, f"Vm={Vm:.1f}",
1032
+ fontsize=10, ha='center', va='top', color='black')
1033
+
1034
+ # Add blade row label
1035
+ label_y = y_min - (y_max - y_min) * 0.15 if Vt > 0 else y_max + (y_max - y_min) * 0.15
1036
+ stage_label = f"{row.row_type.name} {row.stage_id + 1}"
1037
+ ax.text((x_start + x_end) / 2, label_y, stage_label,
1038
+ fontsize=13, ha='center', fontweight='bold',
1039
+ bbox=dict(boxstyle='round,pad=0.5',
1040
+ facecolor='lightyellow' if row.row_type == RowType.Stator else 'lightcoral',
1041
+ edgecolor='black', linewidth=2))
1042
+
1043
+ # Add separation line between blade rows
1044
+ if i < len(blade_rows) - 2:
1045
+ ax.axvline(x=x_end, color='gray', linestyle=':', linewidth=1, alpha=0.5, zorder=1)
1046
+
1047
+ x_start = x_end
1048
+
1049
+ # Formatting
1050
+ margin = (y_max - y_min) * 0.2
1051
+ ax.set_ylim([y_min - margin, y_max + margin])
1052
+ ax.set_xlim([0, x_end * 1.1])
1053
+
1054
+ ax.set_ylabel('Tangential Velocity [m/s]', fontsize=13, fontweight='bold')
1055
+ ax.set_xlabel('Meridional Velocity Vm [m/s]', fontsize=13, fontweight='bold')
1056
+ ax.set_title(f'Velocity Triangles - Streamline {j} (r={blade_rows[1].r[j]:.4f} m)',
1057
+ fontsize=14, fontweight='bold', pad=20)
1058
+
1059
+ ax.grid(True, alpha=0.3, linestyle=':', linewidth=0.5)
1060
+ ax.axhline(y=0, color='black', linewidth=1.5, zorder=2)
1061
+
1062
+ # Add legend
1063
+ from matplotlib.patches import FancyArrow
1064
+ legend_elements = [
1065
+ plt.Line2D([0], [0], color='blue', linewidth=2.5, label='V (Absolute Velocity)'),
1066
+ plt.Line2D([0], [0], color='red', linewidth=2.5, label='W (Relative Velocity)'),
1067
+ plt.Line2D([0], [0], color='green', linewidth=2.5, label='U (Blade Speed)')
1068
+ ]
1069
+ ax.legend(handles=legend_elements, loc='upper right', fontsize=10, framealpha=0.9)
1070
+
1071
+ plt.tight_layout()
1072
+ plt.savefig(f"streamline_{j:04d}.png", transparent=False, dpi=200, bbox_inches='tight')
1073
+ plt.close()
1074
+
1075
+ def save_convergence_history(self, filename: str = "convergence_history.jsonl") -> None:
1076
+ """Save convergence history to JSONL file.
1077
+
1078
+ Writes the convergence history collected during solve() to a JSON Lines file,
1079
+ where each line is a JSON object representing one iteration.
1080
+
1081
+ Args:
1082
+ filename: Output JSONL file path (default: "convergence_history.jsonl")
1083
+
1084
+ Returns:
1085
+ None. Writes JSONL file to specified path.
1086
+
1087
+ Example:
1088
+ >>> spool.solve()
1089
+ >>> spool.save_convergence_history("turbine_convergence.jsonl")
1090
+ """
1091
+ import json
1092
+ from pathlib import Path
1093
+
1094
+ output_path = Path(filename)
1095
+ with open(output_path, 'w') as f:
1096
+ for entry in self.convergence_history:
1097
+ f.write(json.dumps(entry) + '\n')
1098
+ print(f"Convergence history saved to {output_path}")
1099
+
1100
+ def plot_convergence(self, save_to_file: Optional[Union[bool, str]] = None) -> None:
1101
+ """Plot convergence history showing massflow error vs iteration.
1102
+
1103
+ Displays a semi-log plot of the massflow standard deviation error across
1104
+ iterations. If convergence history is empty, warns user.
1105
+
1106
+ Args:
1107
+ save_to_file: If True, saves to "convergence.png". If string, saves to that filename.
1108
+ If None/False, displays plot without saving.
1109
+
1110
+ Returns:
1111
+ None. Either displays plot or saves to file.
1112
+
1113
+ Example:
1114
+ >>> spool.solve()
1115
+ >>> spool.plot_convergence() # Display plot
1116
+ >>> spool.plot_convergence(save_to_file=True) # Save to convergence.png
1117
+ >>> spool.plot_convergence(save_to_file="my_convergence.png") # Save to custom file
1118
+ """
1119
+ if not self.convergence_history:
1120
+ print("Warning: No convergence history available. Run solve() first.")
1121
+ return
1122
+
1123
+ iterations = [entry['iteration'] for entry in self.convergence_history]
1124
+ massflow_std = [entry['massflow_std'] for entry in self.convergence_history]
1125
+ relative_change = [entry['relative_change'] for entry in self.convergence_history]
1126
+
1127
+ fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))
1128
+
1129
+ # Plot massflow std deviation
1130
+ ax1.semilogy(iterations, massflow_std, 'o-', linewidth=2, markersize=8)
1131
+ ax1.set_xlabel('Iteration', fontsize=16)
1132
+ ax1.set_ylabel('2× Massflow Std Dev [kg/s]', fontsize=16)
1133
+ ax1.set_title('Convergence History: Massflow Standard Deviation', fontsize=14, fontweight='bold')
1134
+ ax1.grid(True, alpha=0.3)
1135
+
1136
+ # Plot relative change
1137
+ ax2.semilogy(iterations, relative_change, 's-', color='orange', linewidth=2, markersize=8)
1138
+ ax2.set_xlabel('Iteration', fontsize=16)
1139
+ ax2.set_ylabel(r'Massflow Residual $\left|\frac{err_{n-1} - err_n}{err_n}\right|$', fontsize=16)
1140
+ ax2.set_title('Convergence History: Relative Error Change', fontsize=14, fontweight='bold')
1141
+ ax2.grid(True, alpha=0.3)
1142
+
1143
+ plt.tight_layout()
1144
+
1145
+ if save_to_file:
1146
+ filename = "convergence.png" if save_to_file is True else str(save_to_file)
1147
+ plt.savefig(filename, dpi=150, bbox_inches='tight')
1148
+ print(f"Convergence plot saved to {filename}")
1149
+ else:
1150
+ plt.show()
1151
+
1152
+
1153
+ # ------------------------------
1154
+ # Helper functions (kept module-level)
1155
+ # ------------------------------
1156
+ def massflow_loss_function(
1157
+ exit_angle: float,
1158
+ index: int,
1159
+ row: BladeRow,
1160
+ upstream: BladeRow,
1161
+ massflow_target:float,
1162
+ downstream: Optional[BladeRow] = None,
1163
+ fluid: Optional[Solution] = None
1164
+ ) -> float:
1165
+ if row.row_type == RowType.Inlet:
1166
+ row.Yp = 0
1167
+ else:
1168
+ if row.loss_function.loss_type == LossType.Pressure: # type: ignore[union-attr]
1169
+ row.Yp = row.loss_function(row, upstream) # type: ignore[assignment]
1170
+ if row.row_type == RowType.Rotor:
1171
+ row.beta2[index] = np.radians(exit_angle)
1172
+ rotor_calc(row, upstream)
1173
+ elif row.row_type == RowType.Stator:
1174
+ row.alpha2[index] = np.radians(exit_angle)
1175
+ stator_calc(row, upstream, downstream)
1176
+ compute_gas_constants(upstream, fluid)
1177
+ compute_gas_constants(row, fluid)
1178
+ elif row.loss_function.loss_type == LossType.Enthalpy: # type: ignore[union-attr]
1179
+ if row.row_type == RowType.Rotor:
1180
+ row.Yp = 0
1181
+ row.beta2[index] = np.radians(exit_angle)
1182
+ rotor_calc(row, upstream)
1183
+ T0_drop = row.loss_function(row, upstream) # type: ignore[arg-type]
1184
+ T0_target = row.T0.mean() - T0_drop
1185
+
1186
+ def find_yp(Yp):
1187
+ row.Yp = Yp
1188
+ rotor_calc(row, upstream)
1189
+ compute_gas_constants(upstream, fluid)
1190
+ compute_gas_constants(row, fluid)
1191
+ return abs(row.T0.mean() - T0_target)
1192
+
1193
+ res = minimize_scalar(find_yp, bounds=[0, 0.6], method="bounded")
1194
+ row.Yp = res.x
1195
+ elif row.row_type == RowType.Stator:
1196
+ row.Yp = 0
1197
+ row.alpha2[index] = np.radians(exit_angle)
1198
+ stator_calc(row, upstream, downstream)
1199
+ compute_gas_constants(upstream, fluid)
1200
+ compute_gas_constants(row, fluid)
1201
+
1202
+ compute_massflow(row)
1203
+ compute_power(row, upstream)
1204
+
1205
+ if row.row_type != RowType.Inlet:
1206
+ T03_is = upstream.T0 * (row.P0 / upstream.P0) ** ((row.gamma - 1) / row.gamma)
1207
+ row.eta_total = (upstream.T0.mean() - row.T0.mean()) / (upstream.T0.mean() - T03_is.mean())
1208
+
1209
+ return float(np.abs(massflow_target - row.massflow[index]))
1210
+
1211
+
1212
+ def step_pressures(percents: List[float], inletP0: float, outletP: float) -> npt.NDArray:
1213
+ """Map a list of percents [0..1] to each row's outlet static pressure."""
1214
+ percents_arr = convert_to_ndarray(percents)
1215
+ Ps = np.zeros((len(percents_arr),))
1216
+ for i in range(len(percents_arr)):
1217
+ Ps[i] = float(interp1d((0, 1), (inletP0, outletP))(percents_arr[i]))
1218
+ inletP0 = Ps[i]
1219
+ return Ps