turbo-design 1.3.7__py3-none-any.whl → 1.3.9__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.
Potentially problematic release.
This version of turbo-design might be problematic. Click here for more details.
- {turbo_design-1.3.7.dist-info → turbo_design-1.3.9.dist-info}/METADATA +2 -1
- turbo_design-1.3.9.dist-info/RECORD +46 -0
- {turbo_design-1.3.7.dist-info → turbo_design-1.3.9.dist-info}/WHEEL +1 -1
- turbodesign/__init__.py +57 -4
- turbodesign/agf.py +346 -0
- turbodesign/arrayfuncs.py +31 -1
- turbodesign/bladerow.py +237 -155
- turbodesign/compressor_math.py +374 -0
- turbodesign/compressor_spool.py +837 -0
- turbodesign/coolant.py +18 -6
- turbodesign/deviation/__init__.py +5 -0
- turbodesign/deviation/axial_compressor.py +3 -0
- turbodesign/deviation/carter_deviation.py +79 -0
- turbodesign/deviation/deviation_base.py +20 -0
- turbodesign/deviation/fixed_deviation.py +42 -0
- turbodesign/enums.py +5 -6
- turbodesign/flow_math.py +159 -0
- turbodesign/inlet.py +126 -56
- turbodesign/isentropic.py +59 -15
- turbodesign/loss/__init__.py +3 -1
- turbodesign/loss/compressor/OTAC_README.md +39 -0
- turbodesign/loss/compressor/__init__.py +54 -0
- turbodesign/loss/compressor/diffusion.py +61 -0
- turbodesign/loss/compressor/lieblein.py +1 -0
- turbodesign/loss/compressor/otac.py +799 -0
- turbodesign/loss/compressor/references/schobeiri-2012-shock-loss-model-for-transonic-and-supersonic-axial-compressors-with-curved-blades.pdf +0 -0
- turbodesign/loss/fixedpolytropic.py +27 -0
- turbodesign/loss/fixedpressureloss.py +30 -0
- turbodesign/loss/losstype.py +2 -30
- turbodesign/loss/turbine/TD2.py +25 -29
- turbodesign/loss/turbine/__init__.py +0 -1
- turbodesign/loss/turbine/ainleymathieson.py +6 -5
- turbodesign/loss/turbine/craigcox.py +6 -5
- turbodesign/loss/turbine/fixedefficiency.py +8 -7
- turbodesign/loss/turbine/kackerokapuu.py +7 -5
- turbodesign/loss/turbine/traupel.py +17 -16
- turbodesign/outlet.py +81 -22
- turbodesign/passage.py +98 -63
- turbodesign/radeq.py +3 -2
- turbodesign/row_factory.py +129 -0
- turbodesign/solve_radeq.py +9 -10
- turbodesign/{td_math.py → turbine_math.py} +125 -175
- turbodesign/turbine_spool.py +984 -0
- turbo_design-1.3.7.dist-info/RECORD +0 -33
- turbodesign/compressorspool.py +0 -60
- turbodesign/loss/turbine/fixedpressureloss.py +0 -25
- turbodesign/rotor.py +0 -38
- turbodesign/spool.py +0 -317
- turbodesign/turbinespool.py +0 -543
|
@@ -0,0 +1,984 @@
|
|
|
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 if fluid is not None else Solution("air.yaml")
|
|
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
|
+
|
|
105
|
+
# Assign IDs, RPMs, and axial chords where appropriate
|
|
106
|
+
for i, br in enumerate(self._all_rows()):
|
|
107
|
+
br.id = i
|
|
108
|
+
if not isinstance(br, (Inlet, Outlet)):
|
|
109
|
+
br.rpm = rpm
|
|
110
|
+
br.axial_chord = br.hub_location * self.passage.hub_length
|
|
111
|
+
|
|
112
|
+
# Propagate initial fluid to rows
|
|
113
|
+
for br in self._all_rows():
|
|
114
|
+
br.fluid = self._fluid
|
|
115
|
+
|
|
116
|
+
def _all_rows(self) -> List[BladeRow]:
|
|
117
|
+
"""Convenience to iterate inlet + interior rows + outlet."""
|
|
118
|
+
return [self.inlet, *self.rows, self.outlet]
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def blade_rows(self) -> List[BladeRow]:
|
|
122
|
+
"""Backwards-compatible combined row list."""
|
|
123
|
+
return self._all_rows()
|
|
124
|
+
|
|
125
|
+
# ------------------------------
|
|
126
|
+
# Properties
|
|
127
|
+
# ------------------------------
|
|
128
|
+
@property
|
|
129
|
+
def fluid(self) -> Optional[Solution]:
|
|
130
|
+
return self._fluid
|
|
131
|
+
|
|
132
|
+
@fluid.setter
|
|
133
|
+
def fluid(self, newFluid: Solution) -> None:
|
|
134
|
+
"""Change the gas used in the spool and cascade to rows."""
|
|
135
|
+
self._fluid = newFluid
|
|
136
|
+
for br in self.blade_rows:
|
|
137
|
+
br.fluid = self._fluid
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def adjust_streamlines(self) -> bool:
|
|
141
|
+
return self._adjust_streamlines
|
|
142
|
+
|
|
143
|
+
@adjust_streamlines.setter
|
|
144
|
+
def adjust_streamlines(self, val: bool) -> None:
|
|
145
|
+
self._adjust_streamlines = val
|
|
146
|
+
|
|
147
|
+
# ------------------------------
|
|
148
|
+
# Row utilities
|
|
149
|
+
# ------------------------------
|
|
150
|
+
def set_blade_row_rpm(self, index: int, rpm: float) -> None:
|
|
151
|
+
self.rows[index].rpm = rpm
|
|
152
|
+
|
|
153
|
+
def set_blade_row_type(self, blade_row_index: int, rowType: RowType) -> None:
|
|
154
|
+
self.rows[blade_row_index].row_type = rowType
|
|
155
|
+
|
|
156
|
+
def set_blade_row_exit_angles(
|
|
157
|
+
self,
|
|
158
|
+
radius: Dict[int, List[float]],
|
|
159
|
+
beta: Dict[int, List[float]],
|
|
160
|
+
IsSupersonic: bool = False,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Set intended exit flow angles for rows (useful when geometry is fixed)."""
|
|
163
|
+
for k, v in radius.items():
|
|
164
|
+
self.rows[k].radii_geom = v
|
|
165
|
+
for k, v in beta.items():
|
|
166
|
+
self.rows[k].beta_geom = v
|
|
167
|
+
self.rows[k].beta_fixed = True
|
|
168
|
+
for br in self._all_rows():
|
|
169
|
+
br.solution_type = "supersonic" if IsSupersonic else "subsonic"
|
|
170
|
+
|
|
171
|
+
# ------------------------------
|
|
172
|
+
# Streamline setup/geometry
|
|
173
|
+
# ------------------------------
|
|
174
|
+
def initialize_streamlines(self) -> None:
|
|
175
|
+
"""Initialize streamline storage per row and compute curvature."""
|
|
176
|
+
for row in self._all_rows():
|
|
177
|
+
row.phi = np.zeros((self.num_streamlines,))
|
|
178
|
+
row.rm = np.zeros((self.num_streamlines,))
|
|
179
|
+
row.r = np.zeros((self.num_streamlines,))
|
|
180
|
+
row.m = np.zeros((self.num_streamlines,))
|
|
181
|
+
|
|
182
|
+
t_radial = np.array([0.5]) if self.num_streamlines == 1 else np.linspace(0, 1, self.num_streamlines)
|
|
183
|
+
self.calculate_streamline_curvature(row, t_radial)
|
|
184
|
+
if self.num_streamlines == 1:
|
|
185
|
+
area = self.passage.get_area(row.hub_location)
|
|
186
|
+
row.total_area = area
|
|
187
|
+
row.area = np.array([area])
|
|
188
|
+
|
|
189
|
+
# Ensure a loss model exists on blade rows
|
|
190
|
+
if not isinstance(row, (Inlet, Outlet)) and row.loss_function is None:
|
|
191
|
+
row.loss_function = TD2()
|
|
192
|
+
|
|
193
|
+
# With radii known, couple blade geometry (pitch/chord/stagger) if specified
|
|
194
|
+
for row in self._all_rows():
|
|
195
|
+
if isinstance(row, BladeRow) and row.row_type not in (RowType.Inlet, RowType.Outlet):
|
|
196
|
+
try:
|
|
197
|
+
row.synchronize_blade_geometry()
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
def calculate_streamline_curvature(
|
|
202
|
+
self, row: BladeRow, t_hub_shroud: Union[List[float], npt.NDArray]
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Calculates the streamline curvature
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
row (BladeRow): current blade row
|
|
208
|
+
t_radial (Union[List[float], npt.NDArray]): percent along line from hub to shroud
|
|
209
|
+
"""
|
|
210
|
+
for i, tr in enumerate(t_hub_shroud):
|
|
211
|
+
t_s, x_s, r_s = self.passage.get_streamline(tr)
|
|
212
|
+
phi, rm, r = self.passage.streamline_curvature(x_s, r_s)
|
|
213
|
+
row.phi[i] = float(interp1d(t_s, phi)(row.hub_location))
|
|
214
|
+
row.rm[i] = float(interp1d(t_s, rm)(row.hub_location))
|
|
215
|
+
row.r[i] = float(interp1d(t_s, r)(row.hub_location))
|
|
216
|
+
row.m[i] = float(
|
|
217
|
+
interp1d(t_s, self.passage.get_m(tr, resolution=len(t_s)))(row.hub_location)
|
|
218
|
+
)
|
|
219
|
+
# Back-compute pitch_to_chord if blade count is specified and chord is nonzero
|
|
220
|
+
chord = np.asarray(row.chord, dtype=float)
|
|
221
|
+
mean_chord = float(np.mean(chord)) if chord.size else 0.0
|
|
222
|
+
if row.num_blades and mean_chord != 0:
|
|
223
|
+
mean_r = float(np.mean(row.r))
|
|
224
|
+
pitch = 2 * np.pi * mean_r / row.num_blades
|
|
225
|
+
row.pitch_to_chord = pitch / mean_chord
|
|
226
|
+
|
|
227
|
+
def solve_for_static_pressure(self,upstream:BladeRow,row:BladeRow):
|
|
228
|
+
"""Solve for static pressure at blade row exit using isentropic flow relations.
|
|
229
|
+
|
|
230
|
+
Uses massflow-area-Mach number relation to find static pressure from known
|
|
231
|
+
total conditions. Attempts both subsonic and supersonic solutions and selects
|
|
232
|
+
the subsonic solution.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
upstream: Upstream blade row providing inlet conditions
|
|
236
|
+
row: Current blade row where static pressure is being solved
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
None. Updates row.M, row.T, and row.P in-place.
|
|
240
|
+
"""
|
|
241
|
+
if row.row_type == RowType.Stator:
|
|
242
|
+
b = row.total_area * row.P0 / np.sqrt(row.T0) * np.sqrt(row.gamma/row.R)
|
|
243
|
+
else:
|
|
244
|
+
b = row.total_area * row.P0R / np.sqrt(row.T0R) * np.sqrt(row.gamma/row.R)
|
|
245
|
+
|
|
246
|
+
solve_for_M = upstream.total_massflow / b
|
|
247
|
+
fun = lambda M : np.abs(solve_for_M - M*(1+(row.gamma-1)/2 * M**2) ** (-(row.gamma+1)/(2*(row.gamma-1))))
|
|
248
|
+
M_subsonic = minimize_scalar(fun,0.1, bounds=[0,1])
|
|
249
|
+
M_supersonic = minimize_scalar(fun,1.5, bounds=[1,5])
|
|
250
|
+
row.M = M_subsonic
|
|
251
|
+
if row.row_type == RowType.Stator:
|
|
252
|
+
row.T = row.T0/IsenT(M_subsonic,row.gamma)
|
|
253
|
+
else:
|
|
254
|
+
row.T = row.T0R/IsenT(M_subsonic,row.gamma)
|
|
255
|
+
a = np.sqrt(row.T*row.gamma*row.R)
|
|
256
|
+
row.P = row.total_massflow * row.R*row.T / (row.total_area * row.M * a)
|
|
257
|
+
# When total conditions are defined we calculate static pressure
|
|
258
|
+
if row.row_type == RowType.Stator:
|
|
259
|
+
row.P = upstream.P0 - (upstream.P0 - row.P0) / row.Yp
|
|
260
|
+
else:
|
|
261
|
+
row.P = upstream.P0R - (upstream.P0R - row.P0R) / row.Yp
|
|
262
|
+
# ------------------------------
|
|
263
|
+
# initialization/solve
|
|
264
|
+
# ------------------------------
|
|
265
|
+
def initialize(self) -> None:
|
|
266
|
+
"""Initialize massflow and thermodynamic state through rows (turbines)."""
|
|
267
|
+
blade_rows = self._all_rows()
|
|
268
|
+
Is_static_defined = (self.outlet.outlet_type == OutletType.static_pressure) or (self.outlet.outlet_type == OutletType.massflow_static_pressure)
|
|
269
|
+
|
|
270
|
+
# Inlet
|
|
271
|
+
W0 = self.massflow
|
|
272
|
+
inlet = self.inlet
|
|
273
|
+
if self.fluid:
|
|
274
|
+
inlet.__initialize_fluid__(self.fluid) # type: ignore[arg-type]
|
|
275
|
+
else:
|
|
276
|
+
inlet.__initialize_fluid__( # type: ignore[call-arg]
|
|
277
|
+
R=blade_rows[1].R,
|
|
278
|
+
gamma=blade_rows[1].gamma,
|
|
279
|
+
Cp=blade_rows[1].Cp,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
inlet.total_massflow = W0
|
|
283
|
+
inlet.total_massflow_no_coolant = W0
|
|
284
|
+
inlet.massflow = np.array([W0]) if self.num_streamlines == 1 else np.linspace(0, 1, self.num_streamlines) * W0
|
|
285
|
+
|
|
286
|
+
inlet.__interpolate_quantities__(self.num_streamlines) # type: ignore[attr-defined]
|
|
287
|
+
inlet.__initialize_velocity__(self.passage, self.num_streamlines) # type: ignore[attr-defined]
|
|
288
|
+
interpolate_streamline_quantities(inlet, self.passage, self.num_streamlines)
|
|
289
|
+
|
|
290
|
+
inlet_calc(inlet)
|
|
291
|
+
|
|
292
|
+
for i,row in enumerate(blade_rows):
|
|
293
|
+
interpolate_streamline_quantities(row, self.passage, self.num_streamlines)
|
|
294
|
+
|
|
295
|
+
outlet = self.outlet
|
|
296
|
+
for j in range(self.num_streamlines):
|
|
297
|
+
percents = np.zeros(shape=(len(blade_rows) - 2)) + 0.3
|
|
298
|
+
percents[-1] = 1
|
|
299
|
+
if Is_static_defined:
|
|
300
|
+
Ps_range = step_pressures(percents=percents, inletP0=inlet.P0[j], outletP=outlet.P[j])
|
|
301
|
+
for i in range(1, len(blade_rows) - 1):
|
|
302
|
+
blade_rows[i].P[j] = Ps_range[i - 1]
|
|
303
|
+
else:
|
|
304
|
+
P0_range = step_pressures(percents=percents, inletP0=inlet.P0[j], outletP=outlet.P0[j])
|
|
305
|
+
for i in range(1, len(blade_rows) - 1):
|
|
306
|
+
if blade_rows[i].row_type == RowType.Stator:
|
|
307
|
+
blade_rows[i].P0[j] = P0_range[i - 1]
|
|
308
|
+
else:
|
|
309
|
+
blade_rows[i].P0R[j] = P0_range[i - 1]
|
|
310
|
+
|
|
311
|
+
# Pass T0, P0 to downstream rows
|
|
312
|
+
for i in range(1, len(blade_rows) - 1):
|
|
313
|
+
upstream = blade_rows[i - 1]
|
|
314
|
+
downstream = blade_rows[i + 1] if i + 1 < len(blade_rows) else None
|
|
315
|
+
|
|
316
|
+
row = blade_rows[i]
|
|
317
|
+
if row.coolant is not None:
|
|
318
|
+
T0c = row.coolant.T0
|
|
319
|
+
P0c = row.coolant.P0
|
|
320
|
+
W0c = row.coolant.massflow_percentage * self.massflow
|
|
321
|
+
Cpc = row.coolant.Cp
|
|
322
|
+
else:
|
|
323
|
+
T0c = 100
|
|
324
|
+
P0c = 0
|
|
325
|
+
W0c = 0
|
|
326
|
+
Cpc = 0
|
|
327
|
+
|
|
328
|
+
# Adjust for Coolant
|
|
329
|
+
T0 = (W0 * upstream.Cp * upstream.T0 + W0c * Cpc * T0c) / (Cpc * W0c + upstream.Cp * W0)
|
|
330
|
+
# P0 = (W0 * upstream.Cp * upstream.P0 + W0c * Cpc * P0c) / (Cpc * W0c + upstream.Cp * W0)
|
|
331
|
+
Cp = (W0 * upstream.Cp + W0c * Cpc) / (W0c + W0) if (W0c + W0) != 0 else upstream.Cp
|
|
332
|
+
# Adjust for power
|
|
333
|
+
if row.row_type == RowType.Rotor:
|
|
334
|
+
T0 = T0 - row.power / (Cp * (W0 + W0c))
|
|
335
|
+
|
|
336
|
+
W0 += W0c
|
|
337
|
+
row.T0 = T0
|
|
338
|
+
# row.P0 = P0
|
|
339
|
+
row.Cp = Cp
|
|
340
|
+
row.total_massflow = W0
|
|
341
|
+
row.massflow = np.array([row.total_massflow]) if self.num_streamlines == 1 else np.linspace(0, 1, self.num_streamlines) * row.total_massflow
|
|
342
|
+
|
|
343
|
+
# Pass gas constants
|
|
344
|
+
row.rho = upstream.rho
|
|
345
|
+
row.gamma = upstream.gamma
|
|
346
|
+
row.R = upstream.R
|
|
347
|
+
|
|
348
|
+
if row.loss_function.loss_type == LossType.Pressure:
|
|
349
|
+
row.Yp = row.loss_function(row, upstream)
|
|
350
|
+
elif row.loss_function.loss_type == LossType.Enthalpy:
|
|
351
|
+
row.Yp = 0
|
|
352
|
+
|
|
353
|
+
if row.row_type == RowType.Stator:
|
|
354
|
+
stator_calc(row, upstream, downstream,True,Is_static_defined) # type: ignore[arg-type]
|
|
355
|
+
compute_massflow(row)
|
|
356
|
+
elif row.row_type == RowType.Rotor:
|
|
357
|
+
rotor_calc(row, upstream,True,Is_static_defined)
|
|
358
|
+
compute_massflow(row)
|
|
359
|
+
compute_power(row, upstream)
|
|
360
|
+
|
|
361
|
+
def solve(self) -> None:
|
|
362
|
+
"""Solve for exit angles/pressures to satisfy chosen massflow constraint."""
|
|
363
|
+
self.initialize_streamlines()
|
|
364
|
+
self.initialize()
|
|
365
|
+
|
|
366
|
+
if self.outlet.outlet_type == OutletType.massflow_static_pressure:
|
|
367
|
+
print("Using angle matching mode: blade exit angles will be adjusted to match specified massflow")
|
|
368
|
+
self._angle_match()
|
|
369
|
+
else:
|
|
370
|
+
print("Using pressure balance mode: blade exit angles are fixed, static pressures will be adjusted")
|
|
371
|
+
self._balance_pressure()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def total_power(self) -> float:
|
|
375
|
+
"""Return total turbine power extracted (sum over rotor rows)."""
|
|
376
|
+
total = 0.0
|
|
377
|
+
for row in self._all_rows():
|
|
378
|
+
if getattr(row, "row_type", None) == RowType.Rotor:
|
|
379
|
+
total += float(getattr(row, "power", 0.0) or 0.0)
|
|
380
|
+
return total
|
|
381
|
+
|
|
382
|
+
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]:
|
|
383
|
+
"""Power-driven closure: iterate inlet massflow to hit a target turbine power.
|
|
384
|
+
|
|
385
|
+
This uses a simple algebraic update (no additional nested optimizer):
|
|
386
|
+
mdot_next = mdot_current * (P_target / P_current)
|
|
387
|
+
|
|
388
|
+
The inner flow solution still uses the existing pressure-balance method to
|
|
389
|
+
maintain a consistent massflow between rows for the current guess.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
target_power: Desired turbine power [W]. Use a positive value for power extracted.
|
|
393
|
+
massflow_guess: Optional starting guess for inlet massflow [kg/s]. Defaults to `self.massflow`.
|
|
394
|
+
tol_rel: Relative tolerance on power error.
|
|
395
|
+
max_iter: Maximum outer iterations.
|
|
396
|
+
relax: Under-relaxation factor (0–1) for massflow updates.
|
|
397
|
+
bounds: (lower, upper) bounds for massflow during updates.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Tuple of (achieved_massflow_kg_s, achieved_power_W).
|
|
401
|
+
"""
|
|
402
|
+
target = float(target_power)
|
|
403
|
+
if target <= 0:
|
|
404
|
+
raise ValueError("target_power must be positive for turbine power-based solve.")
|
|
405
|
+
|
|
406
|
+
lower, upper = bounds
|
|
407
|
+
if lower <= 0 or upper <= 0 or lower >= upper:
|
|
408
|
+
raise ValueError("Massflow bounds must be positive and (lower < upper).")
|
|
409
|
+
|
|
410
|
+
mdot = float(self.massflow if massflow_guess is None else massflow_guess)
|
|
411
|
+
mdot = float(np.clip(mdot, lower, upper))
|
|
412
|
+
|
|
413
|
+
# Temporarily store original outlet type and ensure pressure balance mode
|
|
414
|
+
prev_outlet_type = self.outlet.outlet_type
|
|
415
|
+
prev_massflow = getattr(self.outlet, 'total_massflow', None)
|
|
416
|
+
self.outlet.outlet_type = OutletType.static_pressure
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
for _ in range(max_iter):
|
|
420
|
+
# Important: prevent a previous computed `row.power` from being treated as an input
|
|
421
|
+
# in `initialize()` when power is not a design target.
|
|
422
|
+
for r in self.rows:
|
|
423
|
+
if r.row_type == RowType.Rotor:
|
|
424
|
+
r.power = 0.0
|
|
425
|
+
r.power_mean = 0.0
|
|
426
|
+
|
|
427
|
+
self.massflow = mdot
|
|
428
|
+
self.solve()
|
|
429
|
+
|
|
430
|
+
achieved_power = self.total_power()
|
|
431
|
+
achieved_mdot = float(getattr(self._all_rows()[1], "total_massflow_no_coolant", mdot) or mdot)
|
|
432
|
+
|
|
433
|
+
if achieved_power <= 0 or not np.isfinite(achieved_power):
|
|
434
|
+
raise ValueError(f"Non-physical power encountered during solve (power={achieved_power}).")
|
|
435
|
+
|
|
436
|
+
err_rel = abs(achieved_power - target) / target
|
|
437
|
+
if err_rel <= tol_rel:
|
|
438
|
+
return achieved_mdot, achieved_power
|
|
439
|
+
|
|
440
|
+
mdot_update = achieved_mdot * (target / achieved_power)
|
|
441
|
+
mdot = float(np.clip(relax * mdot_update + (1.0 - relax) * achieved_mdot, lower, upper))
|
|
442
|
+
|
|
443
|
+
return float(getattr(self._all_rows()[1], "total_massflow_no_coolant", self.massflow) or self.massflow), self.total_power()
|
|
444
|
+
finally:
|
|
445
|
+
self.outlet.outlet_type = prev_outlet_type
|
|
446
|
+
if prev_massflow is not None:
|
|
447
|
+
self.outlet.total_massflow = prev_massflow
|
|
448
|
+
|
|
449
|
+
# ------------------------------
|
|
450
|
+
# Massflow matching/balancing
|
|
451
|
+
# ------------------------------
|
|
452
|
+
def _angle_match(self) -> None:
|
|
453
|
+
"""Match massflow between streamtubes by tweaking exit angles."""
|
|
454
|
+
rows = self._all_rows()
|
|
455
|
+
massflow_target = np.linspace(0,rows[-1].total_massflow,self.num_streamlines)
|
|
456
|
+
for _ in range(3):
|
|
457
|
+
for i in range(1,len(rows)-1):
|
|
458
|
+
upstream = rows[i - 1] if i > 0 else rows[i]
|
|
459
|
+
downstream = rows[i + 1] if i < len(rows) - 1 else None
|
|
460
|
+
|
|
461
|
+
if rows[i].row_type == RowType.Stator:
|
|
462
|
+
bounds = [0, 80]
|
|
463
|
+
elif rows[i].row_type == RowType.Rotor:
|
|
464
|
+
bounds = [-80, 0]
|
|
465
|
+
else:
|
|
466
|
+
bounds = [0, 0]
|
|
467
|
+
|
|
468
|
+
for j in range(1, self.num_streamlines):
|
|
469
|
+
res = minimize_scalar(
|
|
470
|
+
massflow_loss_function,
|
|
471
|
+
bounds=bounds,
|
|
472
|
+
args=(j, rows[i], upstream, massflow_target[j], downstream),
|
|
473
|
+
tol=1e-4,
|
|
474
|
+
method="bounded",
|
|
475
|
+
)
|
|
476
|
+
if rows[i].row_type == RowType.Rotor:
|
|
477
|
+
rows[i].beta2[j] = np.radians(res.x)
|
|
478
|
+
rows[i].beta2[0] = 1 / (len(rows[i].beta2) - 1) * rows[i].beta2[1:].sum()
|
|
479
|
+
elif rows[i].row_type == RowType.Stator:
|
|
480
|
+
rows[i].alpha2[j] = np.radians(res.x)
|
|
481
|
+
rows[i].alpha2[0] = 1 / (len(rows[i].alpha2) - 1) * rows[i].alpha2[1:].sum()
|
|
482
|
+
compute_gas_constants(upstream, self.fluid)
|
|
483
|
+
compute_gas_constants(rows[i], self.fluid)
|
|
484
|
+
|
|
485
|
+
if self.adjust_streamlines:
|
|
486
|
+
adjust_streamlines(rows, self.passage)
|
|
487
|
+
compute_reynolds(rows, self.passage)
|
|
488
|
+
|
|
489
|
+
@staticmethod
|
|
490
|
+
def __massflow_std__(blade_rows: List[BladeRow]) -> float:
|
|
491
|
+
"""Calculate massflow standard deviation across blade rows.
|
|
492
|
+
|
|
493
|
+
Computes the standard deviation of total massflow (without coolant) across
|
|
494
|
+
all blade rows. Used as a convergence criterion for pressure balance and
|
|
495
|
+
angle matching iterations. Warns if deviation exceeds 1.0 kg/s.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
blade_rows: List of all blade rows (inlet, stators, rotors, outlet)
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
float: Two times the standard deviation of massflow [kg/s]
|
|
502
|
+
"""
|
|
503
|
+
total_massflow = []
|
|
504
|
+
massflow_stage = []
|
|
505
|
+
stage_ids = list({row.stage_id for row in blade_rows if row.stage_id >= 0})
|
|
506
|
+
|
|
507
|
+
for row in blade_rows:
|
|
508
|
+
total_massflow.append(row.total_massflow_no_coolant)
|
|
509
|
+
sign = 1
|
|
510
|
+
for s in stage_ids:
|
|
511
|
+
for r in blade_rows:
|
|
512
|
+
if r.stage_id == s and r.row_type == RowType.Rotor:
|
|
513
|
+
massflow_stage.append(sign * r.total_massflow_no_coolant)
|
|
514
|
+
sign *= -1
|
|
515
|
+
if len(stage_ids) % 2 == 1 and massflow_stage:
|
|
516
|
+
massflow_stage.append(massflow_stage[-1] * sign)
|
|
517
|
+
deviation = np.std(total_massflow) * 2
|
|
518
|
+
if deviation > 1.0:
|
|
519
|
+
print("high massflow deviation detected")
|
|
520
|
+
return np.std(total_massflow) * 2
|
|
521
|
+
|
|
522
|
+
def _balance_pressure(self) -> None:
|
|
523
|
+
"""Balance massflow between rows using radial equilibrium."""
|
|
524
|
+
rows = self._all_rows()
|
|
525
|
+
|
|
526
|
+
def balance_loop(
|
|
527
|
+
x0: List[float],
|
|
528
|
+
rows: List[BladeRow],
|
|
529
|
+
P0: List[float],
|
|
530
|
+
P_or_P0: List[float],
|
|
531
|
+
) -> float:
|
|
532
|
+
"""Runs through the calclulation and outputs the standard deviation of massflow
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
x0 (List[float]): Array of percent breakdown (P0 to P) or (P0 to P0_exit)
|
|
536
|
+
rows (List[BladeRow]): _description_
|
|
537
|
+
P0 (npt.NDArray): _description_
|
|
538
|
+
P_or_P0 (npt.NDArray): _description_
|
|
539
|
+
|
|
540
|
+
Returns:
|
|
541
|
+
float: _description_
|
|
542
|
+
"""
|
|
543
|
+
static_defined = (self.outlet.outlet_type == OutletType.static_pressure)
|
|
544
|
+
P_exit = P_or_P0
|
|
545
|
+
for j in range(self.num_streamlines):
|
|
546
|
+
Ps_guess = step_pressures(x0, P0[j], P_exit[j])
|
|
547
|
+
for i in range(1, len(rows) - 2):
|
|
548
|
+
rows[i].P[j] = float(Ps_guess[i - 1])
|
|
549
|
+
rows[-2].P[:] = P_exit[-1]
|
|
550
|
+
|
|
551
|
+
# Loop through massflow calculation for all rows
|
|
552
|
+
for i in range(1, len(rows) - 1):
|
|
553
|
+
row = rows[i]
|
|
554
|
+
upstream = rows[i - 1] if i > 0 else rows[i]
|
|
555
|
+
downstream = rows[i + 1]
|
|
556
|
+
|
|
557
|
+
if row.row_type == RowType.Inlet:
|
|
558
|
+
row.Yp = 0
|
|
559
|
+
else:
|
|
560
|
+
if row.loss_function.loss_type == LossType.Pressure: # type: ignore[union-attr]
|
|
561
|
+
row.Yp = row.loss_function(row, upstream) # type: ignore[assignment]
|
|
562
|
+
for _ in range(2):
|
|
563
|
+
if row.row_type == RowType.Rotor:
|
|
564
|
+
rotor_calc(row, upstream,
|
|
565
|
+
calculate_vm=True,outlet_type=OutletType.static_pressure if static_defined else OutletType.total_pressure)
|
|
566
|
+
if self.num_streamlines > 1:
|
|
567
|
+
row = radeq(row, upstream, downstream)
|
|
568
|
+
compute_gas_constants(row, self.fluid)
|
|
569
|
+
rotor_calc(row, upstream,
|
|
570
|
+
calculate_vm=False,outlet_type=OutletType.static_pressure if static_defined else OutletType.total_pressure)
|
|
571
|
+
elif row.row_type == RowType.Stator:
|
|
572
|
+
stator_calc(row, upstream, downstream,
|
|
573
|
+
calculate_vm=True,outlet_type=OutletType.static_pressure if static_defined else OutletType.total_pressure)
|
|
574
|
+
if self.num_streamlines > 1:
|
|
575
|
+
row = radeq(row, upstream, downstream)
|
|
576
|
+
compute_gas_constants(row, self.fluid)
|
|
577
|
+
stator_calc(row, upstream, downstream,
|
|
578
|
+
calculate_vm=False,outlet_type=OutletType.static_pressure if static_defined else OutletType.total_pressure)
|
|
579
|
+
compute_gas_constants(row, self.fluid)
|
|
580
|
+
compute_massflow(row)
|
|
581
|
+
compute_power(row, upstream)
|
|
582
|
+
|
|
583
|
+
elif row.loss_function.loss_type == LossType.Enthalpy:
|
|
584
|
+
if row.row_type == RowType.Rotor:
|
|
585
|
+
row.Yp = 0
|
|
586
|
+
rotor_calc(row,upstream,calculate_vm=True)
|
|
587
|
+
eta_total = float(row.loss_function(row,upstream))
|
|
588
|
+
|
|
589
|
+
def find_yp(Yp,row,upstream):
|
|
590
|
+
row.Yp = Yp
|
|
591
|
+
rotor_calc(row,upstream,calculate_vm=True)
|
|
592
|
+
row = radeq(row,upstream)
|
|
593
|
+
compute_gas_constants(row,self.fluid)
|
|
594
|
+
rotor_calc(row,upstream,calculate_vm=False)
|
|
595
|
+
return abs(row.eta_total - eta_total)
|
|
596
|
+
|
|
597
|
+
res = minimize_scalar(find_yp,bounds=[0,0.6],args=(row,upstream))
|
|
598
|
+
row.Yp = res.x
|
|
599
|
+
elif row.row_type == RowType.Stator:
|
|
600
|
+
row.Yp = 0
|
|
601
|
+
stator_calc(row,upstream,downstream,calculate_vm=True)
|
|
602
|
+
if self.num_streamlines > 1:
|
|
603
|
+
row = radeq(row,upstream)
|
|
604
|
+
compute_gas_constants(row,self.fluid)
|
|
605
|
+
stator_calc(row,upstream,downstream,calculate_vm=False)
|
|
606
|
+
compute_gas_constants(row,self.fluid)
|
|
607
|
+
compute_massflow(row)
|
|
608
|
+
compute_power(row,upstream)
|
|
609
|
+
print(x0)
|
|
610
|
+
return self.__massflow_std__(rows[1:-1])
|
|
611
|
+
|
|
612
|
+
pressure_ratio_ranges: List[tuple] = []
|
|
613
|
+
pressure_ratio_guess: List[float] = []
|
|
614
|
+
for i in range(1, len(rows) - 2):
|
|
615
|
+
bounds = tuple(float(v) for v in rows[i].inlet_to_outlet_pratio)
|
|
616
|
+
pressure_ratio_ranges.append(bounds)
|
|
617
|
+
pressure_ratio_guess.append(float(np.mean(bounds)))
|
|
618
|
+
|
|
619
|
+
if self.outlet.outlet_type != OutletType.static_pressure:
|
|
620
|
+
raise ValueError("For turbine calculations, please define outlet using init_static")
|
|
621
|
+
|
|
622
|
+
print("Looping to converge massflow")
|
|
623
|
+
past_err = -100.0
|
|
624
|
+
loop_iter = 0
|
|
625
|
+
err = 1e-3
|
|
626
|
+
while (np.abs((err - past_err) / err) > 0.05) and (loop_iter < 10):
|
|
627
|
+
if len(pressure_ratio_ranges) == 1: # Single stage, use minimize scalar
|
|
628
|
+
x = minimize_scalar(
|
|
629
|
+
fun=balance_loop,
|
|
630
|
+
args=(rows, self.inlet.P0, self.outlet.P),
|
|
631
|
+
bounds=pressure_ratio_ranges[0],
|
|
632
|
+
tol=1e-4,
|
|
633
|
+
method="bounded")
|
|
634
|
+
print(x)
|
|
635
|
+
else: # Multiple stages, use slsqp
|
|
636
|
+
x = fmin_slsqp(
|
|
637
|
+
func=balance_loop,
|
|
638
|
+
args=(rows, self.inlet.P0, self.outlet.P),
|
|
639
|
+
bounds=pressure_ratio_ranges,
|
|
640
|
+
x0=pressure_ratio_guess,
|
|
641
|
+
epsilon=1e-4,
|
|
642
|
+
iter=200)
|
|
643
|
+
pressure_ratio_guess = x.tolist()
|
|
644
|
+
|
|
645
|
+
# Adjust inlet to match massflow found at first blade row
|
|
646
|
+
target = rows[1].total_massflow_no_coolant
|
|
647
|
+
self.inlet.massflow = np.array([target]) if self.num_streamlines == 1 else (np.linspace(0, 1, self.num_streamlines) * target)
|
|
648
|
+
self.inlet.total_massflow_no_coolant = rows[1].total_massflow_no_coolant
|
|
649
|
+
self.inlet.total_massflow = rows[1].total_massflow_no_coolant
|
|
650
|
+
self.inlet.calculated_massflow = self.inlet.total_massflow_no_coolant
|
|
651
|
+
inlet_calc(self.inlet)
|
|
652
|
+
|
|
653
|
+
if self.adjust_streamlines:
|
|
654
|
+
adjust_streamlines(rows[:-1], self.passage)
|
|
655
|
+
|
|
656
|
+
self.outlet.transfer_quantities(rows[-2]) # outlet
|
|
657
|
+
self.outlet.P = self.outlet.get_static_pressure(self.outlet.percent_hub_shroud)
|
|
658
|
+
|
|
659
|
+
past_err = err
|
|
660
|
+
err = self.__massflow_std__(rows)
|
|
661
|
+
loop_iter += 1
|
|
662
|
+
print(f"Loop {loop_iter} massflow convergenced error:{err}")
|
|
663
|
+
|
|
664
|
+
compute_reynolds(rows, self.passage)
|
|
665
|
+
|
|
666
|
+
# ------------------------------
|
|
667
|
+
# Export / Plotting
|
|
668
|
+
# ------------------------------
|
|
669
|
+
def export_properties(self, filename: str = "turbine_spool.json") -> None:
|
|
670
|
+
"""Export turbine spool properties and blade row data to JSON file.
|
|
671
|
+
|
|
672
|
+
Exports comprehensive turbine design data including blade row properties,
|
|
673
|
+
streamline coordinates, efficiency metrics, degree of reaction, stage loading,
|
|
674
|
+
and power calculations for each stage. Useful for post-processing and result
|
|
675
|
+
archiving.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
filename: Output JSON file path (default: "turbine_spool.json")
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
None. Writes JSON file to specified path.
|
|
682
|
+
|
|
683
|
+
Example:
|
|
684
|
+
>>> spool.export_properties("eee_hpt_results.json")
|
|
685
|
+
"""
|
|
686
|
+
blade_rows = self._all_rows()
|
|
687
|
+
blade_rows_out = []
|
|
688
|
+
degree_of_reaction = []
|
|
689
|
+
total_total_efficiency = []
|
|
690
|
+
total_static_efficiency = []
|
|
691
|
+
stage_loading = []
|
|
692
|
+
euler_power = []
|
|
693
|
+
enthalpy_power = []
|
|
694
|
+
x_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
|
|
695
|
+
r_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
|
|
696
|
+
massflow = []
|
|
697
|
+
|
|
698
|
+
for indx, row in enumerate(blade_rows):
|
|
699
|
+
blade_rows_out.append(row.to_dict())
|
|
700
|
+
if row.row_type == RowType.Rotor:
|
|
701
|
+
degree_of_reaction.append(
|
|
702
|
+
(
|
|
703
|
+
(blade_rows[indx - 1].P - row.P)
|
|
704
|
+
/ (blade_rows[indx - 2].P - row.P)
|
|
705
|
+
).mean()
|
|
706
|
+
)
|
|
707
|
+
total_total_efficiency.append(row.eta_total)
|
|
708
|
+
total_static_efficiency.append(row.eta_static)
|
|
709
|
+
stage_loading.append(row.stage_loading)
|
|
710
|
+
euler_power.append(row.euler_power)
|
|
711
|
+
enthalpy_power.append(row.power)
|
|
712
|
+
if row.row_type not in (RowType.Inlet, RowType.Outlet):
|
|
713
|
+
massflow.append(row.massflow[-1])
|
|
714
|
+
|
|
715
|
+
for j, p in enumerate(row.percent_hub_shroud):
|
|
716
|
+
t, x, r = self.passage.get_streamline(p)
|
|
717
|
+
x_streamline[j, indx] = float(interp1d(t, x)(row.percent_hub))
|
|
718
|
+
r_streamline[j, indx] = float(interp1d(t, r)(row.percent_hub))
|
|
719
|
+
|
|
720
|
+
Pratio_Total_Total = np.mean(self.inlet.P0 / blade_rows[-2].P0)
|
|
721
|
+
Pratio_Total_Static = np.mean(self.inlet.P0 / blade_rows[-2].P)
|
|
722
|
+
# Use scalarized inlet conditions to avoid shape mismatches with per-row massflow
|
|
723
|
+
flow_fn_massflow = float(np.mean(massflow)) if massflow else 0.0
|
|
724
|
+
FlowFunction = flow_fn_massflow * np.sqrt(self.inlet.T0.mean()) * float(np.mean(self.inlet.P0)) / 1000
|
|
725
|
+
CorrectedSpeed = self.rpm * np.pi / 30 / np.sqrt(self.inlet.T0.mean())
|
|
726
|
+
EnergyFunction = (
|
|
727
|
+
(self.inlet.T0 - blade_rows[-2].T0)
|
|
728
|
+
* 0.5
|
|
729
|
+
* (self.inlet.Cp + blade_rows[-2].Cp)
|
|
730
|
+
/ self.inlet.T0
|
|
731
|
+
)
|
|
732
|
+
EnergyFunction = np.mean(EnergyFunction)
|
|
733
|
+
|
|
734
|
+
# English-unit conversions
|
|
735
|
+
massflow_kg_s = float(np.mean(massflow)) if massflow else 0.0
|
|
736
|
+
massflow_lbm_s = massflow_kg_s / 0.45359237
|
|
737
|
+
euler_power_hp = [p / 745.7 for p in euler_power]
|
|
738
|
+
enthalpy_power_hp = [p / 745.7 for p in enthalpy_power]
|
|
739
|
+
|
|
740
|
+
data = {
|
|
741
|
+
"blade_rows": blade_rows_out,
|
|
742
|
+
"massflow": massflow_kg_s,
|
|
743
|
+
"massflow_lbm_s": massflow_lbm_s,
|
|
744
|
+
"rpm": self.rpm,
|
|
745
|
+
"r_streamline": r_streamline.tolist(),
|
|
746
|
+
"x_streamline": x_streamline.tolist(),
|
|
747
|
+
"rhub": self.passage.rhub_pts.tolist(),
|
|
748
|
+
"rshroud": self.passage.rshroud_pts.tolist(),
|
|
749
|
+
"xhub": self.passage.xhub_pts.tolist(),
|
|
750
|
+
"xshroud": self.passage.xshroud_pts.tolist(),
|
|
751
|
+
"num_streamlines": self.num_streamlines,
|
|
752
|
+
"euler_power": euler_power,
|
|
753
|
+
"euler_power_hp": euler_power_hp,
|
|
754
|
+
"enthalpy_power": enthalpy_power,
|
|
755
|
+
"enthalpy_power_hp": enthalpy_power_hp,
|
|
756
|
+
"total-total_efficiency": total_total_efficiency,
|
|
757
|
+
"total-static_efficiency": total_static_efficiency,
|
|
758
|
+
"stage_loading": stage_loading,
|
|
759
|
+
"degree_of_reaction": degree_of_reaction,
|
|
760
|
+
"Pratio_Total_Total": float(Pratio_Total_Total),
|
|
761
|
+
"Pratio_Total_Static": float(Pratio_Total_Static),
|
|
762
|
+
"FlowFunction": float(FlowFunction),
|
|
763
|
+
"CorrectedSpeed": float(CorrectedSpeed),
|
|
764
|
+
"EnergyFunction": float(EnergyFunction),
|
|
765
|
+
"units": {
|
|
766
|
+
"massflow": {"metric": "kg/s", "english": "lbm/s"},
|
|
767
|
+
"rpm": {"metric": "rpm", "english": "rpm"},
|
|
768
|
+
"euler_power": {"metric": "W", "english": "hp"},
|
|
769
|
+
"enthalpy_power": {"metric": "W", "english": "hp"},
|
|
770
|
+
"Pratio_Total_Total": {"metric": "—", "english": "—"},
|
|
771
|
+
"Pratio_Total_Static": {"metric": "—", "english": "—"},
|
|
772
|
+
"FlowFunction": {"metric": "kg/s·K^0.5·Pa", "english": "lbm/s·R^0.5·psf"},
|
|
773
|
+
"CorrectedSpeed": {"metric": "rad/s·K^-0.5", "english": "rad/s·R^-0.5"},
|
|
774
|
+
"EnergyFunction": {"metric": "—", "english": "—"},
|
|
775
|
+
},
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
class NumpyEncoder(json.JSONEncoder):
|
|
779
|
+
def default(self, obj): # type: ignore[override]
|
|
780
|
+
if isinstance(obj, np.ndarray):
|
|
781
|
+
return obj.tolist()
|
|
782
|
+
return super().default(obj)
|
|
783
|
+
|
|
784
|
+
with open(filename, "w") as f:
|
|
785
|
+
json.dump(data, f, indent=4, cls=NumpyEncoder)
|
|
786
|
+
|
|
787
|
+
def plot(self) -> None:
|
|
788
|
+
"""Plot hub/shroud and streamlines."""
|
|
789
|
+
blade_rows = self._all_rows()
|
|
790
|
+
plt.figure(num=1, clear=True, dpi=150, figsize=(15, 10))
|
|
791
|
+
plt.plot(
|
|
792
|
+
self.passage.xhub_pts,
|
|
793
|
+
self.passage.rhub_pts,
|
|
794
|
+
label="hub",
|
|
795
|
+
linestyle="solid",
|
|
796
|
+
linewidth=2,
|
|
797
|
+
color="black",
|
|
798
|
+
)
|
|
799
|
+
plt.plot(
|
|
800
|
+
self.passage.xshroud_pts,
|
|
801
|
+
self.passage.rshroud_pts,
|
|
802
|
+
label="shroud",
|
|
803
|
+
linestyle="solid",
|
|
804
|
+
linewidth=2,
|
|
805
|
+
color="black",
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
hub_length = np.sum(
|
|
809
|
+
np.sqrt(np.diff(self.passage.xhub_pts) ** 2 + np.diff(self.passage.rhub_pts) ** 2)
|
|
810
|
+
)
|
|
811
|
+
x_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
|
|
812
|
+
r_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
|
|
813
|
+
for i in range(len(blade_rows)):
|
|
814
|
+
x_streamline[:, i] = blade_rows[i].x
|
|
815
|
+
r_streamline[:, i] = blade_rows[i].r
|
|
816
|
+
|
|
817
|
+
for i in range(1, len(blade_rows) - 1):
|
|
818
|
+
plt.plot(x_streamline[:, i], r_streamline[:, i], "--b", linewidth=1.5)
|
|
819
|
+
|
|
820
|
+
for i, row in enumerate(blade_rows):
|
|
821
|
+
plt.plot(row.x, row.r, linestyle="dashed", linewidth=1.5, color="blue", alpha=0.4)
|
|
822
|
+
plt.plot(x_streamline[:, i], r_streamline[:, i], "or")
|
|
823
|
+
|
|
824
|
+
if i == 0:
|
|
825
|
+
pass
|
|
826
|
+
else:
|
|
827
|
+
upstream = blade_rows[i - 1]
|
|
828
|
+
if upstream.row_type == RowType.Inlet:
|
|
829
|
+
cut_line1, _, _ = self.passage.get_cutting_line(
|
|
830
|
+
(row.hub_location * hub_length + (0.5 * row.blade_to_blade_gap * row.axial_chord) - row.axial_chord)
|
|
831
|
+
/ hub_length
|
|
832
|
+
)
|
|
833
|
+
else:
|
|
834
|
+
cut_line1, _, _ = self.passage.get_cutting_line(
|
|
835
|
+
(upstream.hub_location * hub_length) / hub_length
|
|
836
|
+
)
|
|
837
|
+
cut_line2, _, _ = self.passage.get_cutting_line(
|
|
838
|
+
(row.hub_location * hub_length - (0.5 * row.blade_to_blade_gap * row.axial_chord)) / hub_length
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
if row.row_type == RowType.Stator:
|
|
842
|
+
x1, r1 = cut_line1.get_point(np.linspace(0, 1, 10))
|
|
843
|
+
plt.plot(x1, r1, "m")
|
|
844
|
+
x2, r2 = cut_line2.get_point(np.linspace(0, 1, 10))
|
|
845
|
+
plt.plot(x2, r2, "m")
|
|
846
|
+
x_text = (x1 + x2) / 2
|
|
847
|
+
r_text = (r1 + r2) / 2
|
|
848
|
+
plt.text(x_text.mean(), r_text.mean(), "Stator", fontdict={"fontsize": "xx-large"})
|
|
849
|
+
elif row.row_type == RowType.Rotor:
|
|
850
|
+
x1, r1 = cut_line1.get_point(np.linspace(0, 1, 10))
|
|
851
|
+
plt.plot(x1, r1, color="brown")
|
|
852
|
+
x2, r2 = cut_line2.get_point(np.linspace(0, 1, 10))
|
|
853
|
+
plt.plot(x2, r2, color="brown")
|
|
854
|
+
x_text = (x1 + x2) / 2
|
|
855
|
+
r_text = (r1 + r2) / 2
|
|
856
|
+
plt.text(x_text.mean(), r_text.mean(), "Rotor", fontdict={"fontsize": "xx-large"})
|
|
857
|
+
|
|
858
|
+
plt.axis("scaled")
|
|
859
|
+
plt.savefig("Meridional.png", transparent=False, dpi=150)
|
|
860
|
+
plt.show()
|
|
861
|
+
|
|
862
|
+
def plot_velocity_triangles(self) -> None:
|
|
863
|
+
"""Plot velocity triangles for each blade row (turbines).
|
|
864
|
+
"""
|
|
865
|
+
blade_rows = self._all_rows()
|
|
866
|
+
prop = dict(arrowstyle="-|>,head_width=0.4,head_length=0.8", shrinkA=0, shrinkB=0)
|
|
867
|
+
|
|
868
|
+
for j in range(self.num_streamlines):
|
|
869
|
+
x_start = 0.0
|
|
870
|
+
y_max = 0.0
|
|
871
|
+
y_min = 0.0
|
|
872
|
+
plt.figure(num=1, clear=True)
|
|
873
|
+
for i in range(1, len(blade_rows) - 1):
|
|
874
|
+
row = blade_rows[i]
|
|
875
|
+
x_end = x_start + row.Vm.mean()
|
|
876
|
+
dx = x_end - x_start
|
|
877
|
+
|
|
878
|
+
Vt = row.Vt[j]
|
|
879
|
+
Wt = row.Wt[j]
|
|
880
|
+
U = row.U[j]
|
|
881
|
+
|
|
882
|
+
y_max = max(y_max, Vt, Wt)
|
|
883
|
+
y_min = min(y_min, Vt, Wt)
|
|
884
|
+
|
|
885
|
+
# V
|
|
886
|
+
plt.annotate("", xy=(x_end, Vt), xytext=(x_start, 0), arrowprops=prop)
|
|
887
|
+
plt.text((x_start + x_end) / 2, Vt / 2 * 1.1, "V", fontdict={"fontsize": "xx-large"})
|
|
888
|
+
|
|
889
|
+
# W
|
|
890
|
+
plt.annotate("", xy=(x_end, Wt), xytext=(x_start, 0), arrowprops=prop)
|
|
891
|
+
plt.text((x_start + x_end) / 2, Wt / 2 * 1.1, "W", fontdict={"fontsize": "xx-large"})
|
|
892
|
+
|
|
893
|
+
if abs(Vt) > abs(Wt):
|
|
894
|
+
plt.annotate("", xy=(x_end, Wt), xytext=(x_end, 0), arrowprops=prop) # Wt
|
|
895
|
+
plt.text(x_end + dx * 0.1, Wt / 2, "Wt", fontdict={"fontsize": "xx-large"})
|
|
896
|
+
|
|
897
|
+
plt.annotate("", xy=(x_end, U + Wt), xytext=(x_end, Wt), arrowprops=prop) # U
|
|
898
|
+
plt.text(x_end + dx * 0.1, (Wt + U) / 2, "U", fontdict={"fontsize": "xx-large"})
|
|
899
|
+
else:
|
|
900
|
+
plt.annotate("", xy=(x_end, Vt), xytext=(x_end, 0), arrowprops=prop) # Vt
|
|
901
|
+
plt.text(x_end + dx * 0.1, Vt / 2, "Vt", fontdict={"fontsize": "xx-large"})
|
|
902
|
+
|
|
903
|
+
plt.annotate("", xy=(x_end, Wt + U), xytext=(x_end, Wt), arrowprops=prop) # U
|
|
904
|
+
plt.text(x_end + dx * 0.1, Wt + U / 2, "U", fontdict={"fontsize": "xx-large"})
|
|
905
|
+
|
|
906
|
+
y = y_min if -np.sign(Vt) > 0 else y_max
|
|
907
|
+
plt.text((x_start + x_end) / 2, -np.sign(Vt) * y * 0.95, row.row_type.name, fontdict={"fontsize": "xx-large"})
|
|
908
|
+
x_start += row.Vm[j]
|
|
909
|
+
plt.axis([0, x_end + dx, y_min, y_max])
|
|
910
|
+
plt.ylabel("Tangental Velocity [m/s]")
|
|
911
|
+
plt.xlabel("Vm [m/s]")
|
|
912
|
+
plt.title(f"Velocity Triangles for Streamline {j}")
|
|
913
|
+
plt.savefig(f"streamline_{j:04d}.png", transparent=False, dpi=150)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
# ------------------------------
|
|
917
|
+
# Helper functions (kept module-level)
|
|
918
|
+
# ------------------------------
|
|
919
|
+
def massflow_loss_function(
|
|
920
|
+
exit_angle: float,
|
|
921
|
+
index: int,
|
|
922
|
+
row: BladeRow,
|
|
923
|
+
upstream: BladeRow,
|
|
924
|
+
massflow_target:float,
|
|
925
|
+
downstream: Optional[BladeRow] = None,
|
|
926
|
+
fluid: Optional[Solution] = None
|
|
927
|
+
) -> float:
|
|
928
|
+
if row.row_type == RowType.Inlet:
|
|
929
|
+
row.Yp = 0
|
|
930
|
+
else:
|
|
931
|
+
if row.loss_function.loss_type == LossType.Pressure: # type: ignore[union-attr]
|
|
932
|
+
row.Yp = row.loss_function(row, upstream) # type: ignore[assignment]
|
|
933
|
+
if row.row_type == RowType.Rotor:
|
|
934
|
+
row.beta2[index] = np.radians(exit_angle)
|
|
935
|
+
rotor_calc(row, upstream)
|
|
936
|
+
elif row.row_type == RowType.Stator:
|
|
937
|
+
row.alpha2[index] = np.radians(exit_angle)
|
|
938
|
+
stator_calc(row, upstream, downstream)
|
|
939
|
+
compute_gas_constants(upstream, fluid)
|
|
940
|
+
compute_gas_constants(row, fluid)
|
|
941
|
+
elif row.loss_function.loss_type == LossType.Enthalpy: # type: ignore[union-attr]
|
|
942
|
+
if row.row_type == RowType.Rotor:
|
|
943
|
+
row.Yp = 0
|
|
944
|
+
row.beta2[index] = np.radians(exit_angle)
|
|
945
|
+
rotor_calc(row, upstream)
|
|
946
|
+
T0_drop = row.loss_function(row, upstream) # type: ignore[arg-type]
|
|
947
|
+
T0_target = row.T0.mean() - T0_drop
|
|
948
|
+
|
|
949
|
+
def find_yp(Yp):
|
|
950
|
+
row.Yp = Yp
|
|
951
|
+
rotor_calc(row, upstream)
|
|
952
|
+
compute_gas_constants(upstream, fluid)
|
|
953
|
+
compute_gas_constants(row, fluid)
|
|
954
|
+
return abs(row.T0.mean() - T0_target)
|
|
955
|
+
|
|
956
|
+
res = minimize_scalar(find_yp, bounds=[0, 0.6], method="bounded")
|
|
957
|
+
row.Yp = res.x
|
|
958
|
+
elif row.row_type == RowType.Stator:
|
|
959
|
+
row.Yp = 0
|
|
960
|
+
row.alpha2[index] = np.radians(exit_angle)
|
|
961
|
+
stator_calc(row, upstream, downstream)
|
|
962
|
+
compute_gas_constants(upstream, fluid)
|
|
963
|
+
compute_gas_constants(row, fluid)
|
|
964
|
+
|
|
965
|
+
compute_massflow(row)
|
|
966
|
+
compute_power(row, upstream)
|
|
967
|
+
|
|
968
|
+
if row.row_type != RowType.Inlet:
|
|
969
|
+
T3_is = upstream.T0 * (1 / row.P0_P) ** ((row.gamma - 1) / row.gamma)
|
|
970
|
+
a = np.sqrt(row.gamma * row.R * T3_is)
|
|
971
|
+
T03_is = T3_is * (1 + (row.gamma - 1) / 2 * (row.V / a) ** 2)
|
|
972
|
+
row.eta_total = (upstream.T0.mean() - row.T0.mean()) / (upstream.T0.mean() - T03_is.mean())
|
|
973
|
+
|
|
974
|
+
return float(np.abs(massflow_target - row.massflow[index]))
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def step_pressures(percents: List[float], inletP0: float, outletP: float) -> npt.NDArray:
|
|
978
|
+
"""Map a list of percents [0..1] to each row's outlet static pressure."""
|
|
979
|
+
percents_arr = convert_to_ndarray(percents)
|
|
980
|
+
Ps = np.zeros((len(percents_arr),))
|
|
981
|
+
for i in range(len(percents_arr)):
|
|
982
|
+
Ps[i] = float(interp1d((0, 1), (inletP0, outletP))(percents_arr[i]))
|
|
983
|
+
inletP0 = Ps[i]
|
|
984
|
+
return Ps
|