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