turbo-design 1.3.9__tar.gz → 1.3.10__tar.gz
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.9 → turbo_design-1.3.10}/PKG-INFO +1 -1
- {turbo_design-1.3.9 → turbo_design-1.3.10}/pyproject.toml +1 -1
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/bladerow.py +1 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/compressor_math.py +32 -20
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/compressor_spool.py +107 -3
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/flow_math.py +2 -3
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/turbine_math.py +21 -13
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/turbine_spool.py +338 -103
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/__init__.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/agf.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/arrayfuncs.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/cantera_gas/co2.yaml +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/coolant.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/deviation/__init__.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/deviation/axial_compressor.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/deviation/carter_deviation.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/deviation/deviation_base.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/deviation/fixed_deviation.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/enums.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/inlet.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/isentropic.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/__init__.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/compressor/OTAC_README.md +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/compressor/__init__.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/compressor/diffusion.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/compressor/lieblein.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/compressor/otac.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/compressor/references/schobeiri-2012-shock-loss-model-for-transonic-and-supersonic-axial-compressors-with-curved-blades.pdf +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/fixedpolytropic.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/fixedpressureloss.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/losstype.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/turbine/TD2.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/turbine/__init__.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/turbine/ainleymathieson.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/turbine/craigcox.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/turbine/fixedefficiency.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/turbine/kackerokapuu.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/loss/turbine/traupel.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/lossinterp.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/outlet.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/passage.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/radeq.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/row_factory.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/solve_radeq.py +0 -0
- {turbo_design-1.3.9 → turbo_design-1.3.10}/turbodesign/stage.py +0 -0
|
@@ -38,6 +38,7 @@ class BladeRow:
|
|
|
38
38
|
total_massflow: float = 0 # Massflow spool + all upstream cooling flow [kg/s]
|
|
39
39
|
massflow: npt.NDArray = field(default_factory=lambda: np.array([0])) # Massflow per radii
|
|
40
40
|
total_massflow_no_coolant: float = 0 # Inlet massflow
|
|
41
|
+
massflow_target: Optional[npt.NDArray] = None # Custom massflow distribution for angle matching [kg/s]
|
|
41
42
|
# ----------------------------------
|
|
42
43
|
|
|
43
44
|
# Streamline Properties
|
|
@@ -244,11 +244,12 @@ def rotor_calc(
|
|
|
244
244
|
deviation_rad = np.radians(deviation_val)
|
|
245
245
|
|
|
246
246
|
P0R_local = upstream.P0R - row.Yp * (upstream.P0R - upstream.P)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
P_local = P0R_local / IsenP(M_rel, row.gamma)
|
|
247
|
+
# Use rothalpy conservation: I = Cp*T0R - U^2/2 = const across rotor
|
|
250
248
|
U_local = row.omega * row.r
|
|
251
|
-
|
|
249
|
+
T0R_local = (upstream_rothalpy + 0.5 * U_local ** 2) / row.Cp
|
|
250
|
+
|
|
251
|
+
P_local = P0R_local / IsenP(M_rel, row.gamma)
|
|
252
|
+
|
|
252
253
|
P0R_P = P0R_local / P_local
|
|
253
254
|
T0R_T = P0R_P ** ((row.gamma - 1) / row.gamma)
|
|
254
255
|
T_local = T0R_local / T0R_T
|
|
@@ -351,24 +352,35 @@ def rotor_calc(
|
|
|
351
352
|
solve_massflow_for_current_loss()
|
|
352
353
|
else:
|
|
353
354
|
solve_massflow_for_current_loss()
|
|
354
|
-
else: # We know Vm,
|
|
355
|
+
else: # We know Vm from radeq, beta2 from blade angle, T0R from rothalpy
|
|
355
356
|
deviation_func = getattr(row, "deviation_function", None)
|
|
356
357
|
deviation_val = deviation_func(row, upstream) if callable(deviation_func) else 0.0
|
|
357
358
|
deviation_rad = np.radians(deviation_val)
|
|
358
359
|
beta2_eff = row.beta2 + deviation_rad
|
|
359
|
-
|
|
360
|
-
row.
|
|
361
|
-
row.
|
|
362
|
-
row.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
row.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
row.
|
|
369
|
-
|
|
370
|
-
row.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
360
|
+
|
|
361
|
+
row.U = row.omega * row.r
|
|
362
|
+
row.T0R = (upstream_rothalpy + 0.5 * row.U ** 2) / row.Cp
|
|
363
|
+
row.P0R = upstream.P0R - row.Yp * (upstream.P0R - upstream.P)
|
|
364
|
+
|
|
365
|
+
row.Vr = row.Vm * np.sin(row.phi)
|
|
366
|
+
row.Vx = row.Vm * np.cos(row.phi)
|
|
367
|
+
|
|
368
|
+
# Compute W from velocity triangle (geometric closure)
|
|
369
|
+
row.W = row.Vm / np.cos(beta2_eff)
|
|
370
|
+
row.Wt = row.W * np.sin(beta2_eff)
|
|
371
|
+
row.Vt = row.Wt + row.U
|
|
372
|
+
|
|
373
|
+
row.alpha2 = np.arctan2(row.Vt, row.Vm)
|
|
374
|
+
row.V = np.sqrt(row.Vm ** 2 * (1 + np.tan(row.alpha2) ** 2))
|
|
375
|
+
|
|
376
|
+
# Update T from energy conservation in rotating frame
|
|
377
|
+
row.T = row.T0R - row.W ** 2 / (2 * row.Cp)
|
|
378
|
+
|
|
379
|
+
row.M = row.V / np.sqrt(row.gamma * row.R * row.T)
|
|
380
|
+
|
|
381
|
+
# Compute T0 first, then derive P0 from P0R to keep the velocity triangle consistent.
|
|
382
|
+
# P0/P0R = (T0/T0R)^(gamma/(gamma-1)) always holds since both reference the same static state.
|
|
383
|
+
row.M_rel = row.W / np.sqrt(row.gamma * row.R * row.T)
|
|
384
|
+
row.T0 = row.T + row.V ** 2 / (2 * row.Cp)
|
|
385
|
+
row.P0 = row.P0R * (row.T0 / row.T0R) ** (row.gamma / (row.gamma - 1))
|
|
374
386
|
compute_gas_constants(row)
|
|
@@ -99,6 +99,7 @@ class CompressorSpool:
|
|
|
99
99
|
# Previously this used dataclasses.field on a non-dataclass; do it explicitly
|
|
100
100
|
self.t_streamline = np.zeros((10,), dtype=float)
|
|
101
101
|
self._adjust_streamlines = True
|
|
102
|
+
self.convergence_history: List[Dict] = []
|
|
102
103
|
|
|
103
104
|
# Assign IDs, RPMs, and axial chords where appropriate
|
|
104
105
|
for i, br in enumerate(self._all_rows()):
|
|
@@ -437,6 +438,7 @@ class CompressorSpool:
|
|
|
437
438
|
loop_iter = 0
|
|
438
439
|
max_iter = 10
|
|
439
440
|
prev_err = 1e9
|
|
441
|
+
self.convergence_history = [] # Reset convergence history
|
|
440
442
|
while loop_iter < max_iter:
|
|
441
443
|
for i in range(1, len(rows) - 1):
|
|
442
444
|
row = rows[i]
|
|
@@ -482,6 +484,15 @@ class CompressorSpool:
|
|
|
482
484
|
loop_iter += 1
|
|
483
485
|
print(f"Loop {loop_iter} massflow convergence error:{err}")
|
|
484
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
|
+
|
|
485
496
|
denom = max(err, 1e-6)
|
|
486
497
|
if abs((err - prev_err) / denom) <= 0.05:
|
|
487
498
|
break
|
|
@@ -505,7 +516,10 @@ class CompressorSpool:
|
|
|
505
516
|
def _angle_match(self) -> None:
|
|
506
517
|
"""Match massflow between streamtubes by tweaking exit angles."""
|
|
507
518
|
blade_rows = self._all_rows()
|
|
508
|
-
|
|
519
|
+
self.convergence_history = [] # Reset convergence history
|
|
520
|
+
prev_err = 1e9
|
|
521
|
+
|
|
522
|
+
for iter_num in range(3):
|
|
509
523
|
for i, row in enumerate(blade_rows):
|
|
510
524
|
# Only adjust blade rows; skip inlet/outlet and other utility rows
|
|
511
525
|
if row.row_type not in (RowType.Rotor, RowType.Stator):
|
|
@@ -526,7 +540,7 @@ class CompressorSpool:
|
|
|
526
540
|
match_massflow_objective,
|
|
527
541
|
bounds=bounds,
|
|
528
542
|
args=(j, row, upstream, downstream, self.fluid),
|
|
529
|
-
|
|
543
|
+
options={'xatol': 1e-3},
|
|
530
544
|
method="bounded",
|
|
531
545
|
)
|
|
532
546
|
if row.row_type == RowType.Rotor:
|
|
@@ -540,8 +554,19 @@ class CompressorSpool:
|
|
|
540
554
|
compute_massflow(row)
|
|
541
555
|
compute_power(row, upstream, is_compressor=True)
|
|
542
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
|
+
|
|
543
569
|
|
|
544
|
-
|
|
545
570
|
# ------------------------------
|
|
546
571
|
# Export / Plotting
|
|
547
572
|
# ------------------------------
|
|
@@ -791,6 +816,85 @@ class CompressorSpool:
|
|
|
791
816
|
plt.title(f"Velocity Triangles for Streamline {j}")
|
|
792
817
|
plt.savefig(f"streamline_{j:04d}.png", transparent=False, dpi=150)
|
|
793
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
|
+
|
|
794
898
|
|
|
795
899
|
def outlet_pressure(percents: List[float], inletP0: float, outletP: float) -> npt.NDArray:
|
|
796
900
|
"""Linearly interpolate total pressure values along the spool."""
|
|
@@ -132,8 +132,7 @@ def compute_power(row: BladeRow, upstream: BladeRow | None = None, downstream: B
|
|
|
132
132
|
row.P0_ratio = P0_ratio_actual
|
|
133
133
|
setattr(row, "P0_ratio_actual", float(P0_ratio_actual))
|
|
134
134
|
row.T_is = ref.T0 * (1 / P0_P) ** ((row.gamma - 1) / row.gamma)
|
|
135
|
-
|
|
136
|
-
row.T0_is = row.T_is * (1 + (row.gamma - 1) / 2 * (row.V / a) ** 2)
|
|
135
|
+
row.T0_is = ref.T0 * (row.P0 / ref.P0) ** ((row.gamma - 1) / row.gamma)
|
|
137
136
|
|
|
138
137
|
comp_mode = is_compressor
|
|
139
138
|
if comp_mode is None:
|
|
@@ -156,4 +155,4 @@ def compute_power(row: BladeRow, upstream: BladeRow | None = None, downstream: B
|
|
|
156
155
|
if is_compressor:
|
|
157
156
|
row.stage_loading *= -1 # Stage_loading will be negative
|
|
158
157
|
row.euler_power = mdot * (ref.U * ref.Vt - row.U * row.Vt).mean()
|
|
159
|
-
row.flow_coefficient = float(np.mean(row.Vm
|
|
158
|
+
row.flow_coefficient = abs(float(np.mean(row.Vm / row.U)))
|
|
@@ -21,7 +21,7 @@ def compute_reynolds(rows:List[BladeRow],passage:Passage):
|
|
|
21
21
|
passage (Passage): Passage
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
|
-
for i in range(1,len(rows)):
|
|
24
|
+
for i in range(1,len(rows)-1):
|
|
25
25
|
row = rows[i]
|
|
26
26
|
xr = passage.get_xr_slice(0.5,(rows[i-1].location,row.percent_hub))
|
|
27
27
|
dx = np.diff(xr[:,0])
|
|
@@ -36,7 +36,7 @@ def compute_reynolds(rows:List[BladeRow],passage:Passage):
|
|
|
36
36
|
V = row.V.mean()
|
|
37
37
|
rho = row.rho.mean()
|
|
38
38
|
mu = row.mu
|
|
39
|
-
row.Reynolds =
|
|
39
|
+
row.Reynolds = row.axial_chord*V*rho/mu
|
|
40
40
|
row.mprime = mp
|
|
41
41
|
row.axial_chord = max(c,1E-12) # Axial chord
|
|
42
42
|
# row.num_blades = int(2*np.pi*row.r.mean() / row.pitch_to_chord * row.axial_chord)
|
|
@@ -236,7 +236,7 @@ def rotor_calc(row:BladeRow,upstream:BladeRow,calculate_vm:bool=True,outlet_type
|
|
|
236
236
|
T0_coolant = 0
|
|
237
237
|
if row.coolant is not None:
|
|
238
238
|
T0_coolant = T0_coolant_weighted_average(row)
|
|
239
|
-
row.T0R =
|
|
239
|
+
row.T0R = (upstream_rothalpy + 0.5*row.U**2)/row.Cp - T0_coolant
|
|
240
240
|
P0R_P = row.P0R / row.P
|
|
241
241
|
T0R_T = P0R_P**((row.gamma-1)/row.gamma)
|
|
242
242
|
row.T = (row.T0R/T0R_T) # Exit static temperature
|
|
@@ -254,8 +254,8 @@ def rotor_calc(row:BladeRow,upstream:BladeRow,calculate_vm:bool=True,outlet_type
|
|
|
254
254
|
reason += "; Yp > 0.3 This could be a problem with the loss model;"
|
|
255
255
|
_log_rotor_failure(reason)
|
|
256
256
|
raise ValueError(f'nan detected')
|
|
257
|
-
row.Vr = row.W*np.sin(row.phi)
|
|
258
257
|
row.Vm = row.W*np.cos(row.beta2)
|
|
258
|
+
row.Vr = row.Vm*np.sin(row.phi)
|
|
259
259
|
row.Wt = row.W*np.sin(row.beta2)
|
|
260
260
|
row.Vx = row.Vm*np.cos(row.phi)
|
|
261
261
|
row.Vt = row.Wt + row.U
|
|
@@ -264,25 +264,33 @@ def rotor_calc(row:BladeRow,upstream:BladeRow,calculate_vm:bool=True,outlet_type
|
|
|
264
264
|
row.Vm = np.sqrt(row.Vx**2+row.Vr**2)
|
|
265
265
|
row.T0 = row.T + row.V**2/(2*row.Cp)
|
|
266
266
|
row.alpha2 = np.arctan2(row.Vt,row.Vm)
|
|
267
|
-
else: # We know Vm,
|
|
267
|
+
else: # We know Vm from radeq, beta2 from blade angle, T0R from rothalpy
|
|
268
268
|
row.Vr = row.Vm*np.sin(row.phi)
|
|
269
269
|
row.Vx = row.Vm*np.cos(row.phi)
|
|
270
|
-
|
|
271
|
-
|
|
270
|
+
|
|
271
|
+
# Compute W from velocity triangle (not thermodynamics) to close the triangle
|
|
272
|
+
row.W = row.Vm / np.cos(row.beta2)
|
|
272
273
|
row.Wt = row.W*np.sin(row.beta2)
|
|
273
|
-
row.U = row.omega * row.r
|
|
274
|
+
row.U = row.omega * row.r
|
|
274
275
|
row.Vt = row.Wt + row.U
|
|
275
|
-
|
|
276
|
+
|
|
276
277
|
row.alpha2 = np.arctan2(row.Vt,row.Vm)
|
|
277
278
|
row.V = np.sqrt(row.Vm**2*(1+np.tan(row.alpha2)**2))
|
|
278
|
-
|
|
279
|
+
|
|
280
|
+
# Update T from energy conservation in rotating frame
|
|
281
|
+
row.T = row.T0R - row.W**2 / (2*row.Cp)
|
|
282
|
+
|
|
279
283
|
row.M = row.V/np.sqrt(row.gamma*row.R*row.T)
|
|
280
|
-
|
|
281
|
-
|
|
284
|
+
|
|
285
|
+
# Compute T0 first, then derive P0 from P0R to keep the velocity triangle consistent.
|
|
286
|
+
# P0/P0R = (T0/T0R)^(gamma/(gamma-1)) always holds since both reference the same static state.
|
|
287
|
+
# Using P0R (from the loss model) avoids dependence on the boundary-condition P, which may
|
|
288
|
+
# not be consistent with T after radeq adjusts Vm.
|
|
289
|
+
row.T0 = row.T + row.V**2/(2*row.Cp)
|
|
290
|
+
row.P0 = row.P0R * (row.T0 / row.T0R) ** (row.gamma / (row.gamma - 1))
|
|
282
291
|
row.P0_P = (row.P0_stator_inlet/row.P).mean()
|
|
283
292
|
|
|
284
293
|
row.M_rel = row.W/np.sqrt(row.gamma*row.R*row.T)
|
|
285
|
-
row.T0 = row.T+row.V**2/(2*row.Cp)
|
|
286
294
|
|
|
287
295
|
def inlet_calc(row:BladeRow):
|
|
288
296
|
"""Calculates the conditions for the Inlet
|
|
@@ -91,7 +91,7 @@ class TurbineSpool:
|
|
|
91
91
|
self.passage = passage
|
|
92
92
|
self.massflow = massflow
|
|
93
93
|
self.num_streamlines = num_streamlines
|
|
94
|
-
self._fluid = fluid
|
|
94
|
+
self._fluid = fluid
|
|
95
95
|
self.rpm = rpm
|
|
96
96
|
|
|
97
97
|
self.inlet = inlet
|
|
@@ -101,6 +101,7 @@ class TurbineSpool:
|
|
|
101
101
|
self.rows = rows
|
|
102
102
|
self.t_streamline = np.zeros((10,), dtype=float)
|
|
103
103
|
self._adjust_streamlines = True
|
|
104
|
+
self.convergence_history: List[Dict] = []
|
|
104
105
|
|
|
105
106
|
# Assign IDs, RPMs, and axial chords where appropriate
|
|
106
107
|
for i, br in enumerate(self._all_rows()):
|
|
@@ -110,8 +111,9 @@ class TurbineSpool:
|
|
|
110
111
|
br.axial_chord = br.hub_location * self.passage.hub_length
|
|
111
112
|
|
|
112
113
|
# Propagate initial fluid to rows
|
|
113
|
-
|
|
114
|
-
br
|
|
114
|
+
if self._fluid is not None:
|
|
115
|
+
for br in self._all_rows():
|
|
116
|
+
br.fluid = self._fluid
|
|
115
117
|
|
|
116
118
|
def _all_rows(self) -> List[BladeRow]:
|
|
117
119
|
"""Convenience to iterate inlet + interior rows + outlet."""
|
|
@@ -272,7 +274,9 @@ class TurbineSpool:
|
|
|
272
274
|
inlet = self.inlet
|
|
273
275
|
if self.fluid:
|
|
274
276
|
inlet.__initialize_fluid__(self.fluid) # type: ignore[arg-type]
|
|
275
|
-
|
|
277
|
+
elif inlet.gamma is not None:
|
|
278
|
+
inlet.__initialize_fluid__(R=inlet.R, gamma=inlet.gamma, Cp=inlet.Cp) # type: ignore[call-arg]
|
|
279
|
+
elif blade_rows[1].gamma is not None:
|
|
276
280
|
inlet.__initialize_fluid__( # type: ignore[call-arg]
|
|
277
281
|
R=blade_rows[1].R,
|
|
278
282
|
gamma=blade_rows[1].gamma,
|
|
@@ -453,11 +457,24 @@ class TurbineSpool:
|
|
|
453
457
|
"""Match massflow between streamtubes by tweaking exit angles."""
|
|
454
458
|
rows = self._all_rows()
|
|
455
459
|
massflow_target = np.linspace(0,rows[-1].total_massflow,self.num_streamlines)
|
|
456
|
-
|
|
460
|
+
|
|
461
|
+
self.convergence_history = [] # Reset convergence history
|
|
462
|
+
past_err = -100.0
|
|
463
|
+
loop_iter = 0
|
|
464
|
+
err = 1e-3
|
|
465
|
+
|
|
466
|
+
print("Looping to converge massflow (angle matching)")
|
|
467
|
+
while (np.abs((err - past_err) / err) > 0.05) and (loop_iter < 10):
|
|
457
468
|
for i in range(1,len(rows)-1):
|
|
458
469
|
upstream = rows[i - 1] if i > 0 else rows[i]
|
|
459
470
|
downstream = rows[i + 1] if i < len(rows) - 1 else None
|
|
460
471
|
|
|
472
|
+
# Use custom massflow target if defined, otherwise use default
|
|
473
|
+
if rows[i].massflow_target is not None:
|
|
474
|
+
current_massflow_target = rows[i].massflow_target
|
|
475
|
+
else:
|
|
476
|
+
current_massflow_target = massflow_target
|
|
477
|
+
|
|
461
478
|
if rows[i].row_type == RowType.Stator:
|
|
462
479
|
bounds = [0, 80]
|
|
463
480
|
elif rows[i].row_type == RowType.Rotor:
|
|
@@ -469,8 +486,8 @@ class TurbineSpool:
|
|
|
469
486
|
res = minimize_scalar(
|
|
470
487
|
massflow_loss_function,
|
|
471
488
|
bounds=bounds,
|
|
472
|
-
args=(j, rows[i], upstream,
|
|
473
|
-
|
|
489
|
+
args=(j, rows[i], upstream, current_massflow_target[j], downstream),
|
|
490
|
+
options={'xatol': 1e-4},
|
|
474
491
|
method="bounded",
|
|
475
492
|
)
|
|
476
493
|
if rows[i].row_type == RowType.Rotor:
|
|
@@ -481,9 +498,32 @@ class TurbineSpool:
|
|
|
481
498
|
rows[i].alpha2[0] = 1 / (len(rows[i].alpha2) - 1) * rows[i].alpha2[1:].sum()
|
|
482
499
|
compute_gas_constants(upstream, self.fluid)
|
|
483
500
|
compute_gas_constants(rows[i], self.fluid)
|
|
484
|
-
|
|
501
|
+
|
|
502
|
+
# Adjust inlet to match massflow found at first blade row
|
|
503
|
+
target = rows[1].total_massflow_no_coolant
|
|
504
|
+
self.inlet.massflow = np.array([target]) if self.num_streamlines == 1 else (np.linspace(0, 1, self.num_streamlines) * target)
|
|
505
|
+
self.inlet.total_massflow_no_coolant = rows[1].total_massflow_no_coolant
|
|
506
|
+
self.inlet.total_massflow = rows[1].total_massflow_no_coolant
|
|
507
|
+
self.inlet.calculated_massflow = self.inlet.total_massflow_no_coolant
|
|
508
|
+
inlet_calc(self.inlet)
|
|
509
|
+
|
|
485
510
|
if self.adjust_streamlines:
|
|
486
511
|
adjust_streamlines(rows, self.passage)
|
|
512
|
+
|
|
513
|
+
# Track convergence history
|
|
514
|
+
past_err = err
|
|
515
|
+
err = self.__massflow_std__(rows[1:-1])
|
|
516
|
+
loop_iter += 1
|
|
517
|
+
|
|
518
|
+
self.convergence_history.append({
|
|
519
|
+
'iteration': loop_iter,
|
|
520
|
+
'massflow_std': float(err),
|
|
521
|
+
'massflow_change': float(abs(err - past_err)),
|
|
522
|
+
'relative_change': float(abs((err - past_err) / max(err, 1e-6))),
|
|
523
|
+
'massflow': float(rows[1].total_massflow_no_coolant)
|
|
524
|
+
})
|
|
525
|
+
print(f"Angle match iteration {loop_iter}, massflow std: {err:.6f}")
|
|
526
|
+
|
|
487
527
|
compute_reynolds(rows, self.passage)
|
|
488
528
|
|
|
489
529
|
@staticmethod
|
|
@@ -522,14 +562,18 @@ class TurbineSpool:
|
|
|
522
562
|
def _balance_pressure(self) -> None:
|
|
523
563
|
"""Balance massflow between rows using radial equilibrium."""
|
|
524
564
|
rows = self._all_rows()
|
|
525
|
-
|
|
565
|
+
past_err = -100.0
|
|
566
|
+
loop_iter = 0
|
|
567
|
+
err = 1e-3
|
|
568
|
+
self.convergence_history = [] # Reset convergence history
|
|
569
|
+
|
|
526
570
|
def balance_loop(
|
|
527
571
|
x0: List[float],
|
|
528
572
|
rows: List[BladeRow],
|
|
529
573
|
P0: List[float],
|
|
530
574
|
P_or_P0: List[float],
|
|
531
575
|
) -> float:
|
|
532
|
-
"""Runs through the calclulation and outputs the standard deviation of massflow
|
|
576
|
+
"""Runs through the calclulation and outputs the standard deviation of massflow
|
|
533
577
|
|
|
534
578
|
Args:
|
|
535
579
|
x0 (List[float]): Array of percent breakdown (P0 to P) or (P0 to P0_exit)
|
|
@@ -540,6 +584,7 @@ class TurbineSpool:
|
|
|
540
584
|
Returns:
|
|
541
585
|
float: _description_
|
|
542
586
|
"""
|
|
587
|
+
nonlocal err, past_err, loop_iter
|
|
543
588
|
static_defined = (self.outlet.outlet_type == OutletType.static_pressure)
|
|
544
589
|
P_exit = P_or_P0
|
|
545
590
|
for j in range(self.num_streamlines):
|
|
@@ -607,6 +652,20 @@ class TurbineSpool:
|
|
|
607
652
|
compute_massflow(row)
|
|
608
653
|
compute_power(row,upstream)
|
|
609
654
|
print(x0)
|
|
655
|
+
|
|
656
|
+
past_err = err
|
|
657
|
+
err = self.__massflow_std__(rows[1:-1])
|
|
658
|
+
loop_iter += 1
|
|
659
|
+
|
|
660
|
+
# Store convergence history
|
|
661
|
+
self.convergence_history.append({
|
|
662
|
+
'iteration': loop_iter,
|
|
663
|
+
'massflow_std': float(err),
|
|
664
|
+
'massflow_change': float(abs(err - past_err)),
|
|
665
|
+
'relative_change': float(abs((err - past_err) / max(err, 1e-6))),
|
|
666
|
+
'massflow': float(rows[1].total_massflow_no_coolant)
|
|
667
|
+
})
|
|
668
|
+
|
|
610
669
|
return self.__massflow_std__(rows[1:-1])
|
|
611
670
|
|
|
612
671
|
pressure_ratio_ranges: List[tuple] = []
|
|
@@ -620,9 +679,6 @@ class TurbineSpool:
|
|
|
620
679
|
raise ValueError("For turbine calculations, please define outlet using init_static")
|
|
621
680
|
|
|
622
681
|
print("Looping to converge massflow")
|
|
623
|
-
past_err = -100.0
|
|
624
|
-
loop_iter = 0
|
|
625
|
-
err = 1e-3
|
|
626
682
|
while (np.abs((err - past_err) / err) > 0.05) and (loop_iter < 10):
|
|
627
683
|
if len(pressure_ratio_ranges) == 1: # Single stage, use minimize scalar
|
|
628
684
|
x = minimize_scalar(
|
|
@@ -656,10 +712,6 @@ class TurbineSpool:
|
|
|
656
712
|
self.outlet.transfer_quantities(rows[-2]) # outlet
|
|
657
713
|
self.outlet.P = self.outlet.get_static_pressure(self.outlet.percent_hub_shroud)
|
|
658
714
|
|
|
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
715
|
|
|
664
716
|
compute_reynolds(rows, self.passage)
|
|
665
717
|
|
|
@@ -744,10 +796,6 @@ class TurbineSpool:
|
|
|
744
796
|
"rpm": self.rpm,
|
|
745
797
|
"r_streamline": r_streamline.tolist(),
|
|
746
798
|
"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
799
|
"num_streamlines": self.num_streamlines,
|
|
752
800
|
"euler_power": euler_power,
|
|
753
801
|
"euler_power_hp": euler_power_hp,
|
|
@@ -781,136 +829,325 @@ class TurbineSpool:
|
|
|
781
829
|
return obj.tolist()
|
|
782
830
|
return super().default(obj)
|
|
783
831
|
|
|
784
|
-
with open(filename, "w") as f:
|
|
785
|
-
json.dump(data, f, indent=4, cls=NumpyEncoder)
|
|
832
|
+
with open(filename, "w", encoding="utf-8") as f:
|
|
833
|
+
json.dump(data, f, indent=4, cls=NumpyEncoder, ensure_ascii=False)
|
|
786
834
|
|
|
787
835
|
def plot(self) -> None:
|
|
788
|
-
"""Plot hub/shroud and streamlines."""
|
|
836
|
+
"""Plot hub/shroud and streamlines with improved labels and formatting."""
|
|
789
837
|
blade_rows = self._all_rows()
|
|
790
|
-
plt.
|
|
791
|
-
|
|
838
|
+
fig, ax = plt.subplots(1, 1, figsize=(16, 8), dpi=150)
|
|
839
|
+
|
|
840
|
+
# Plot hub and shroud with thicker lines
|
|
841
|
+
ax.plot(
|
|
792
842
|
self.passage.xhub_pts,
|
|
793
843
|
self.passage.rhub_pts,
|
|
794
|
-
label="
|
|
844
|
+
label="Hub",
|
|
795
845
|
linestyle="solid",
|
|
796
|
-
linewidth=
|
|
846
|
+
linewidth=3,
|
|
797
847
|
color="black",
|
|
848
|
+
zorder=10
|
|
798
849
|
)
|
|
799
|
-
|
|
850
|
+
ax.plot(
|
|
800
851
|
self.passage.xshroud_pts,
|
|
801
852
|
self.passage.rshroud_pts,
|
|
802
|
-
label="
|
|
853
|
+
label="Shroud",
|
|
803
854
|
linestyle="solid",
|
|
804
|
-
linewidth=
|
|
855
|
+
linewidth=3,
|
|
805
856
|
color="black",
|
|
857
|
+
zorder=10
|
|
806
858
|
)
|
|
807
859
|
|
|
808
860
|
hub_length = np.sum(
|
|
809
861
|
np.sqrt(np.diff(self.passage.xhub_pts) ** 2 + np.diff(self.passage.rhub_pts) ** 2)
|
|
810
862
|
)
|
|
863
|
+
|
|
864
|
+
# Prepare streamline data
|
|
811
865
|
x_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
|
|
812
866
|
r_streamline = np.zeros((self.num_streamlines, len(blade_rows)))
|
|
813
867
|
for i in range(len(blade_rows)):
|
|
814
868
|
x_streamline[:, i] = blade_rows[i].x
|
|
815
869
|
r_streamline[:, i] = blade_rows[i].r
|
|
816
870
|
|
|
871
|
+
# Plot streamlines connecting blade rows
|
|
817
872
|
for i in range(1, len(blade_rows) - 1):
|
|
818
|
-
|
|
873
|
+
ax.plot(x_streamline[:, i], r_streamline[:, i],
|
|
874
|
+
linestyle="--", linewidth=1.2, color="gray", alpha=0.6, zorder=1)
|
|
819
875
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
plt.plot(x_streamline[:, i], r_streamline[:, i], "or")
|
|
876
|
+
# Track label positions to avoid overlaps
|
|
877
|
+
label_positions = []
|
|
823
878
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
879
|
+
for i, row in enumerate(blade_rows):
|
|
880
|
+
# Plot blade row exit locations
|
|
881
|
+
ax.plot(row.x, row.r, linestyle="none", marker="o",
|
|
882
|
+
markersize=6, color="red", alpha=0.7, zorder=5)
|
|
883
|
+
|
|
884
|
+
# Label inlet
|
|
885
|
+
if row.row_type == RowType.Inlet:
|
|
886
|
+
x_pos = row.x.mean()
|
|
887
|
+
r_pos = row.r.mean()
|
|
888
|
+
ax.axvline(x=x_pos, color='green', linestyle=':', linewidth=2, alpha=0.7, zorder=2)
|
|
889
|
+
ax.text(x_pos, self.passage.rshroud_pts.max() * 1.05, 'INLET',
|
|
890
|
+
fontsize=12, fontweight='bold', ha='center', va='bottom',
|
|
891
|
+
bbox=dict(boxstyle='round,pad=0.5', facecolor='lightgreen', alpha=0.7))
|
|
892
|
+
label_positions.append((x_pos, 'INLET'))
|
|
893
|
+
|
|
894
|
+
# Plot blade rows with proper labels
|
|
895
|
+
elif row.row_type in [RowType.Stator, RowType.Rotor]:
|
|
896
|
+
if i > 0:
|
|
897
|
+
upstream = blade_rows[i - 1]
|
|
898
|
+
if upstream.row_type == RowType.Inlet:
|
|
899
|
+
cut_line1, _, _ = self.passage.get_cutting_line(
|
|
900
|
+
(row.hub_location * hub_length + (0.5 * row.blade_to_blade_gap * row.axial_chord) - row.axial_chord)
|
|
901
|
+
/ hub_length
|
|
902
|
+
)
|
|
903
|
+
else:
|
|
904
|
+
cut_line1, _, _ = self.passage.get_cutting_line(
|
|
905
|
+
(upstream.hub_location * hub_length) / hub_length
|
|
906
|
+
)
|
|
907
|
+
cut_line2, _, _ = self.passage.get_cutting_line(
|
|
908
|
+
(row.hub_location * hub_length - (0.5 * row.blade_to_blade_gap * row.axial_chord)) / hub_length
|
|
836
909
|
)
|
|
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
910
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
911
|
+
# Plot blade leading and trailing edges
|
|
912
|
+
if row.row_type == RowType.Stator:
|
|
913
|
+
color = 'purple'
|
|
914
|
+
label = f'Stator {row.stage_id + 1}'
|
|
915
|
+
else:
|
|
916
|
+
color = 'brown'
|
|
917
|
+
label = f'Rotor {row.stage_id + 1}'
|
|
918
|
+
|
|
919
|
+
x1, r1 = cut_line1.get_point(np.linspace(0, 1, 10))
|
|
920
|
+
ax.plot(x1, r1, color=color, linewidth=2.5, alpha=0.8, zorder=3)
|
|
921
|
+
x2, r2 = cut_line2.get_point(np.linspace(0, 1, 10))
|
|
922
|
+
ax.plot(x2, r2, color=color, linewidth=2.5, alpha=0.8, zorder=3)
|
|
923
|
+
|
|
924
|
+
# Mark exit location with vertical line
|
|
925
|
+
x_exit = row.x.mean()
|
|
926
|
+
ax.axvline(x=x_exit, color=color, linestyle='--',
|
|
927
|
+
linewidth=1.5, alpha=0.5, zorder=2)
|
|
928
|
+
|
|
929
|
+
# Add exit label at top
|
|
930
|
+
ax.text(x_exit, self.passage.rshroud_pts.max() * 1.02, f'{label} Exit',
|
|
931
|
+
fontsize=10, ha='center', va='bottom', rotation=0,
|
|
932
|
+
color=color, fontweight='bold')
|
|
933
|
+
|
|
934
|
+
# Label outlet
|
|
935
|
+
elif row.row_type == RowType.Outlet:
|
|
936
|
+
x_pos = row.x.mean()
|
|
937
|
+
ax.axvline(x=x_pos, color='blue', linestyle=':', linewidth=2, alpha=0.7, zorder=2)
|
|
938
|
+
ax.text(x_pos, self.passage.rshroud_pts.max() * 1.05, 'OUTLET',
|
|
939
|
+
fontsize=12, fontweight='bold', ha='center', va='bottom',
|
|
940
|
+
bbox=dict(boxstyle='round,pad=0.5', facecolor='lightblue', alpha=0.7))
|
|
941
|
+
|
|
942
|
+
# Formatting
|
|
943
|
+
ax.set_xlabel('Axial Distance [m]', fontsize=13, fontweight='bold')
|
|
944
|
+
ax.set_ylabel('Radial Distance [m]', fontsize=13, fontweight='bold')
|
|
945
|
+
ax.set_title(f'Meridional View - {self.num_streamlines} Streamlines',
|
|
946
|
+
fontsize=14, fontweight='bold', pad=40)
|
|
947
|
+
ax.grid(True, alpha=0.3, linestyle=':', linewidth=0.5)
|
|
948
|
+
ax.legend(loc='upper left', fontsize=11, framealpha=0.9)
|
|
949
|
+
ax.set_aspect('equal', adjustable='box')
|
|
950
|
+
|
|
951
|
+
plt.tight_layout()
|
|
952
|
+
plt.savefig("Meridional.png", transparent=False, dpi=200, bbox_inches='tight')
|
|
860
953
|
plt.show()
|
|
861
954
|
|
|
862
955
|
def plot_velocity_triangles(self) -> None:
|
|
863
|
-
"""Plot velocity triangles for each blade row
|
|
864
|
-
"""
|
|
956
|
+
"""Plot velocity triangles for each blade row with improved styling and annotations."""
|
|
865
957
|
blade_rows = self._all_rows()
|
|
866
|
-
|
|
958
|
+
|
|
959
|
+
# Define arrow properties for different velocity types
|
|
960
|
+
prop_V = dict(arrowstyle="-|>,head_width=0.5,head_length=1.0",
|
|
961
|
+
shrinkA=0, shrinkB=0, color='blue', lw=2.5)
|
|
962
|
+
prop_W = dict(arrowstyle="-|>,head_width=0.5,head_length=1.0",
|
|
963
|
+
shrinkA=0, shrinkB=0, color='red', lw=2.5)
|
|
964
|
+
prop_U = dict(arrowstyle="-|>,head_width=0.5,head_length=1.0",
|
|
965
|
+
shrinkA=0, shrinkB=0, color='green', lw=2.5)
|
|
966
|
+
prop_component = dict(arrowstyle="-|>,head_width=0.4,head_length=0.8",
|
|
967
|
+
shrinkA=0, shrinkB=0, color='gray', lw=1.5, linestyle='--')
|
|
867
968
|
|
|
868
969
|
for j in range(self.num_streamlines):
|
|
869
970
|
x_start = 0.0
|
|
870
971
|
y_max = 0.0
|
|
871
972
|
y_min = 0.0
|
|
872
|
-
|
|
973
|
+
|
|
974
|
+
fig, ax = plt.subplots(1, 1, figsize=(14, 8), dpi=150)
|
|
975
|
+
|
|
873
976
|
for i in range(1, len(blade_rows) - 1):
|
|
874
977
|
row = blade_rows[i]
|
|
875
|
-
x_end = x_start + row.Vm
|
|
978
|
+
x_end = x_start + row.Vm[j]
|
|
876
979
|
dx = x_end - x_start
|
|
877
980
|
|
|
878
981
|
Vt = row.Vt[j]
|
|
879
982
|
Wt = row.Wt[j]
|
|
880
983
|
U = row.U[j]
|
|
984
|
+
Vm = row.Vm[j]
|
|
985
|
+
|
|
986
|
+
y_max = max(y_max, Vt, Wt, U + Wt, U + Vt)
|
|
987
|
+
y_min = min(y_min, Vt, Wt, 0)
|
|
988
|
+
|
|
989
|
+
# Draw absolute velocity V (blue)
|
|
990
|
+
ax.annotate("", xy=(x_end, Vt), xytext=(x_start, 0), arrowprops=prop_V, zorder=5)
|
|
991
|
+
v_mag = np.sqrt(Vm**2 + Vt**2)
|
|
992
|
+
ax.text((x_start + x_end) / 2, Vt / 2 + np.sign(Vt) * 15,
|
|
993
|
+
f"V={v_mag:.1f}", fontsize=12, fontweight='bold',
|
|
994
|
+
ha='center', color='blue',
|
|
995
|
+
bbox=dict(boxstyle='round,pad=0.3', facecolor='lightblue', alpha=0.7))
|
|
996
|
+
|
|
997
|
+
# Draw relative velocity W (red)
|
|
998
|
+
ax.annotate("", xy=(x_end, Wt), xytext=(x_start, 0), arrowprops=prop_W, zorder=5)
|
|
999
|
+
w_mag = np.sqrt(Vm**2 + Wt**2)
|
|
1000
|
+
ax.text((x_start + x_end) / 2, Wt / 2 - np.sign(Wt) * 15,
|
|
1001
|
+
f"W={w_mag:.1f}", fontsize=12, fontweight='bold',
|
|
1002
|
+
ha='center', color='red',
|
|
1003
|
+
bbox=dict(boxstyle='round,pad=0.3', facecolor='lightcoral', alpha=0.7))
|
|
1004
|
+
|
|
1005
|
+
# Draw velocity components and U
|
|
1006
|
+
if abs(Vt) > abs(Wt):
|
|
1007
|
+
# Draw Wt component
|
|
1008
|
+
ax.annotate("", xy=(x_end, Wt), xytext=(x_end, 0), arrowprops=prop_component, zorder=3)
|
|
1009
|
+
ax.text(x_end + dx * 0.08, Wt / 2, f"Wt={Wt:.1f}",
|
|
1010
|
+
fontsize=10, ha='left', color='gray')
|
|
1011
|
+
|
|
1012
|
+
# Draw U (blade speed)
|
|
1013
|
+
ax.annotate("", xy=(x_end, U + Wt), xytext=(x_end, Wt), arrowprops=prop_U, zorder=4)
|
|
1014
|
+
ax.text(x_end + dx * 0.08, (Wt + U + Wt) / 2, f"U={U:.1f}",
|
|
1015
|
+
fontsize=11, ha='left', fontweight='bold', color='green',
|
|
1016
|
+
bbox=dict(boxstyle='round,pad=0.3', facecolor='lightgreen', alpha=0.7))
|
|
1017
|
+
else:
|
|
1018
|
+
# Draw Vt component
|
|
1019
|
+
ax.annotate("", xy=(x_end, Vt), xytext=(x_end, 0), arrowprops=prop_component, zorder=3)
|
|
1020
|
+
ax.text(x_end + dx * 0.08, Vt / 2, f"Vt={Vt:.1f}",
|
|
1021
|
+
fontsize=10, ha='left', color='gray')
|
|
1022
|
+
|
|
1023
|
+
# Draw U (blade speed)
|
|
1024
|
+
ax.annotate("", xy=(x_end, Wt), xytext=(x_end, Vt), arrowprops=prop_U, zorder=4)
|
|
1025
|
+
ax.text(x_end + dx * 0.08, (Vt + Wt) / 2, f"U={U:.1f}",
|
|
1026
|
+
fontsize=11, ha='left', fontweight='bold', color='green',
|
|
1027
|
+
bbox=dict(boxstyle='round,pad=0.3', facecolor='lightgreen', alpha=0.7))
|
|
1028
|
+
|
|
1029
|
+
# Draw Vm component (dashed horizontal)
|
|
1030
|
+
ax.plot([x_start, x_end], [0, 0], 'k--', linewidth=1.5, alpha=0.5, zorder=2)
|
|
1031
|
+
ax.text((x_start + x_end) / 2, -5, f"Vm={Vm:.1f}",
|
|
1032
|
+
fontsize=10, ha='center', va='top', color='black')
|
|
1033
|
+
|
|
1034
|
+
# Add blade row label
|
|
1035
|
+
label_y = y_min - (y_max - y_min) * 0.15 if Vt > 0 else y_max + (y_max - y_min) * 0.15
|
|
1036
|
+
stage_label = f"{row.row_type.name} {row.stage_id + 1}"
|
|
1037
|
+
ax.text((x_start + x_end) / 2, label_y, stage_label,
|
|
1038
|
+
fontsize=13, ha='center', fontweight='bold',
|
|
1039
|
+
bbox=dict(boxstyle='round,pad=0.5',
|
|
1040
|
+
facecolor='lightyellow' if row.row_type == RowType.Stator else 'lightcoral',
|
|
1041
|
+
edgecolor='black', linewidth=2))
|
|
1042
|
+
|
|
1043
|
+
# Add separation line between blade rows
|
|
1044
|
+
if i < len(blade_rows) - 2:
|
|
1045
|
+
ax.axvline(x=x_end, color='gray', linestyle=':', linewidth=1, alpha=0.5, zorder=1)
|
|
1046
|
+
|
|
1047
|
+
x_start = x_end
|
|
1048
|
+
|
|
1049
|
+
# Formatting
|
|
1050
|
+
margin = (y_max - y_min) * 0.2
|
|
1051
|
+
ax.set_ylim([y_min - margin, y_max + margin])
|
|
1052
|
+
ax.set_xlim([0, x_end * 1.1])
|
|
1053
|
+
|
|
1054
|
+
ax.set_ylabel('Tangential Velocity [m/s]', fontsize=13, fontweight='bold')
|
|
1055
|
+
ax.set_xlabel('Meridional Velocity Vm [m/s]', fontsize=13, fontweight='bold')
|
|
1056
|
+
ax.set_title(f'Velocity Triangles - Streamline {j} (r={blade_rows[1].r[j]:.4f} m)',
|
|
1057
|
+
fontsize=14, fontweight='bold', pad=20)
|
|
1058
|
+
|
|
1059
|
+
ax.grid(True, alpha=0.3, linestyle=':', linewidth=0.5)
|
|
1060
|
+
ax.axhline(y=0, color='black', linewidth=1.5, zorder=2)
|
|
1061
|
+
|
|
1062
|
+
# Add legend
|
|
1063
|
+
from matplotlib.patches import FancyArrow
|
|
1064
|
+
legend_elements = [
|
|
1065
|
+
plt.Line2D([0], [0], color='blue', linewidth=2.5, label='V (Absolute Velocity)'),
|
|
1066
|
+
plt.Line2D([0], [0], color='red', linewidth=2.5, label='W (Relative Velocity)'),
|
|
1067
|
+
plt.Line2D([0], [0], color='green', linewidth=2.5, label='U (Blade Speed)')
|
|
1068
|
+
]
|
|
1069
|
+
ax.legend(handles=legend_elements, loc='upper right', fontsize=10, framealpha=0.9)
|
|
1070
|
+
|
|
1071
|
+
plt.tight_layout()
|
|
1072
|
+
plt.savefig(f"streamline_{j:04d}.png", transparent=False, dpi=200, bbox_inches='tight')
|
|
1073
|
+
plt.close()
|
|
1074
|
+
|
|
1075
|
+
def save_convergence_history(self, filename: str = "convergence_history.jsonl") -> None:
|
|
1076
|
+
"""Save convergence history to JSONL file.
|
|
1077
|
+
|
|
1078
|
+
Writes the convergence history collected during solve() to a JSON Lines file,
|
|
1079
|
+
where each line is a JSON object representing one iteration.
|
|
1080
|
+
|
|
1081
|
+
Args:
|
|
1082
|
+
filename: Output JSONL file path (default: "convergence_history.jsonl")
|
|
1083
|
+
|
|
1084
|
+
Returns:
|
|
1085
|
+
None. Writes JSONL file to specified path.
|
|
881
1086
|
|
|
882
|
-
|
|
883
|
-
|
|
1087
|
+
Example:
|
|
1088
|
+
>>> spool.solve()
|
|
1089
|
+
>>> spool.save_convergence_history("turbine_convergence.jsonl")
|
|
1090
|
+
"""
|
|
1091
|
+
import json
|
|
1092
|
+
from pathlib import Path
|
|
884
1093
|
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1094
|
+
output_path = Path(filename)
|
|
1095
|
+
with open(output_path, 'w') as f:
|
|
1096
|
+
for entry in self.convergence_history:
|
|
1097
|
+
f.write(json.dumps(entry) + '\n')
|
|
1098
|
+
print(f"Convergence history saved to {output_path}")
|
|
888
1099
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
plt.text((x_start + x_end) / 2, Wt / 2 * 1.1, "W", fontdict={"fontsize": "xx-large"})
|
|
1100
|
+
def plot_convergence(self, save_to_file: Optional[Union[bool, str]] = None) -> None:
|
|
1101
|
+
"""Plot convergence history showing massflow error vs iteration.
|
|
892
1102
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
plt.text(x_end + dx * 0.1, Wt / 2, "Wt", fontdict={"fontsize": "xx-large"})
|
|
1103
|
+
Displays a semi-log plot of the massflow standard deviation error across
|
|
1104
|
+
iterations. If convergence history is empty, warns user.
|
|
896
1105
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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"})
|
|
1106
|
+
Args:
|
|
1107
|
+
save_to_file: If True, saves to "convergence.png". If string, saves to that filename.
|
|
1108
|
+
If None/False, displays plot without saving.
|
|
902
1109
|
|
|
903
|
-
|
|
904
|
-
|
|
1110
|
+
Returns:
|
|
1111
|
+
None. Either displays plot or saves to file.
|
|
905
1112
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1113
|
+
Example:
|
|
1114
|
+
>>> spool.solve()
|
|
1115
|
+
>>> spool.plot_convergence() # Display plot
|
|
1116
|
+
>>> spool.plot_convergence(save_to_file=True) # Save to convergence.png
|
|
1117
|
+
>>> spool.plot_convergence(save_to_file="my_convergence.png") # Save to custom file
|
|
1118
|
+
"""
|
|
1119
|
+
if not self.convergence_history:
|
|
1120
|
+
print("Warning: No convergence history available. Run solve() first.")
|
|
1121
|
+
return
|
|
1122
|
+
|
|
1123
|
+
iterations = [entry['iteration'] for entry in self.convergence_history]
|
|
1124
|
+
massflow_std = [entry['massflow_std'] for entry in self.convergence_history]
|
|
1125
|
+
relative_change = [entry['relative_change'] for entry in self.convergence_history]
|
|
1126
|
+
|
|
1127
|
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))
|
|
1128
|
+
|
|
1129
|
+
# Plot massflow std deviation
|
|
1130
|
+
ax1.semilogy(iterations, massflow_std, 'o-', linewidth=2, markersize=8)
|
|
1131
|
+
ax1.set_xlabel('Iteration', fontsize=16)
|
|
1132
|
+
ax1.set_ylabel('2× Massflow Std Dev [kg/s]', fontsize=16)
|
|
1133
|
+
ax1.set_title('Convergence History: Massflow Standard Deviation', fontsize=14, fontweight='bold')
|
|
1134
|
+
ax1.grid(True, alpha=0.3)
|
|
1135
|
+
|
|
1136
|
+
# Plot relative change
|
|
1137
|
+
ax2.semilogy(iterations, relative_change, 's-', color='orange', linewidth=2, markersize=8)
|
|
1138
|
+
ax2.set_xlabel('Iteration', fontsize=16)
|
|
1139
|
+
ax2.set_ylabel(r'Massflow Residual $\left|\frac{err_{n-1} - err_n}{err_n}\right|$', fontsize=16)
|
|
1140
|
+
ax2.set_title('Convergence History: Relative Error Change', fontsize=14, fontweight='bold')
|
|
1141
|
+
ax2.grid(True, alpha=0.3)
|
|
1142
|
+
|
|
1143
|
+
plt.tight_layout()
|
|
1144
|
+
|
|
1145
|
+
if save_to_file:
|
|
1146
|
+
filename = "convergence.png" if save_to_file is True else str(save_to_file)
|
|
1147
|
+
plt.savefig(filename, dpi=150, bbox_inches='tight')
|
|
1148
|
+
print(f"Convergence plot saved to {filename}")
|
|
1149
|
+
else:
|
|
1150
|
+
plt.show()
|
|
914
1151
|
|
|
915
1152
|
|
|
916
1153
|
# ------------------------------
|
|
@@ -966,9 +1203,7 @@ def massflow_loss_function(
|
|
|
966
1203
|
compute_power(row, upstream)
|
|
967
1204
|
|
|
968
1205
|
if row.row_type != RowType.Inlet:
|
|
969
|
-
|
|
970
|
-
a = np.sqrt(row.gamma * row.R * T3_is)
|
|
971
|
-
T03_is = T3_is * (1 + (row.gamma - 1) / 2 * (row.V / a) ** 2)
|
|
1206
|
+
T03_is = upstream.T0 * (row.P0 / upstream.P0) ** ((row.gamma - 1) / row.gamma)
|
|
972
1207
|
row.eta_total = (upstream.T0.mean() - row.T0.mean()) / (upstream.T0.mean() - T03_is.mean())
|
|
973
1208
|
|
|
974
1209
|
return float(np.abs(massflow_target - row.massflow[index]))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|