turbo-design 1.3.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: turbo-design
3
- Version: 1.3.9
3
+ Version: 1.3.10
4
4
  Summary: TurboDesign is a library used to design turbines and compressors using radial equilibrium.
5
5
  Author: Paht Juangphanich
6
6
  Author-email: paht.juangphanich@nasa.gov
@@ -1,10 +1,10 @@
1
1
  turbodesign/__init__.py,sha256=VcHpQZK84kYvhgStDWg559mq78Npr9aw2qIH1YzwXls,2798
2
2
  turbodesign/agf.py,sha256=3JIPPJ-Jlsq-SPFwbHBaKh5Ul40qKlFQB5EHkPhymTE,11745
3
3
  turbodesign/arrayfuncs.py,sha256=wU_dMJfbk3U_X4p9gUXrtlIIYkIDInQE0DmM3B94ykc,1587
4
- turbodesign/bladerow.py,sha256=NEv9VQchC8cyDCoqwbFjR1PlEdi5JdHiatsbjCkPSaQ,29243
4
+ turbodesign/bladerow.py,sha256=-oNSom76-1GTa92L0U8OcF5rktOYG2lxRJLeuA1RQWE,29351
5
5
  turbodesign/cantera_gas/co2.yaml,sha256=M2o_RzxV9B9rDkgkXJC-l3voKraFZguTZuKKt4F7S_c,887
6
- turbodesign/compressor_math.py,sha256=FT0_Hha98op9HmgzenbrcbCeDmAZ14OC2tZV6HezyJE,17557
7
- turbodesign/compressor_spool.py,sha256=7Z0SP70wDhtfX5YCCyb99IQjpQf400tByk6ATQ5yOO4,35817
6
+ turbodesign/compressor_math.py,sha256=418YK0glktohpXWO_9ivHHafouGlevSshmWTr_8UMec,18223
7
+ turbodesign/compressor_spool.py,sha256=8CHGx4Y1rficQA37R5v1QB2c9kdnEwNP6YV0LCT6tSY,40400
8
8
  turbodesign/coolant.py,sha256=z5ypCSVw4OaQq41a62cgZM4O7z6_JG8GepLPU3o38iA,596
9
9
  turbodesign/deviation/__init__.py,sha256=RBKMk1rtC2glLpZBaGWLnD8fI6Vlme21eCD_qjvAgP0,208
10
10
  turbodesign/deviation/axial_compressor.py,sha256=hGPF0-qej7qMOW-VPhqsO46BwASO_3i4CD5Omp1_Lzk,77
@@ -12,7 +12,7 @@ turbodesign/deviation/carter_deviation.py,sha256=SduHA5NHXbZmJSC_dSgiRdjzuErRsA7
12
12
  turbodesign/deviation/deviation_base.py,sha256=UMgIS7KRnd26R6HPpVpGuGQYTUCVj06_gRitGTMgs30,699
13
13
  turbodesign/deviation/fixed_deviation.py,sha256=JsKV40Xz4Uno4-it1a30wXJa3iyt0Wkj8mBnNA7AMIE,1630
14
14
  turbodesign/enums.py,sha256=blP0A5xS1yi54Pqvy9ujMdzTSWHou13nQc_STqqZSos,1019
15
- turbodesign/flow_math.py,sha256=HHqox2-9mFb8MqSC0n2o3Q5GpC81zmYsMJQOX8BJ43E,7200
15
+ turbodesign/flow_math.py,sha256=-qIGdjrGlMea0bCBcuihaUG-jdtC1WtIcuo6iKUotEg,7141
16
16
  turbodesign/inlet.py,sha256=HIJCE3c3y8G-ESvIqX7m7kiqEae688MNesOZGzYzaAM,11602
17
17
  turbodesign/isentropic.py,sha256=PcC8G463-zWzHcUmwgebZp-NDS_mxivufnvrQILqra8,3889
18
18
  turbodesign/loss/__init__.py,sha256=eABmmypEC-Nny0ctAiH-av_7E4-JK8aeUycFURgaJEE,150
@@ -39,8 +39,8 @@ turbodesign/radeq.py,sha256=LEqYxZZocCZyVPhY09MNFTnAkEXM740-SR6QxXW8Ssg,6522
39
39
  turbodesign/row_factory.py,sha256=3q97xK6edrS8GERXw_Y6vrvpRqiNOZARnw4JThzegBs,4892
40
40
  turbodesign/solve_radeq.py,sha256=NqPjeVnWtI9ULd7f8wtm_g9QhCz8ZuAiXruxsJpmdGM,1948
41
41
  turbodesign/stage.py,sha256=UP45sDKDLsAkO_WfDWJ6kqXU7cYKh_4QO01QZnSN1oQ,166
42
- turbodesign/turbine_math.py,sha256=oNOyL5ecp1PLLnRuZFZI7fbN0rPAJGSQniaDKDslEkc,15935
43
- turbodesign/turbine_spool.py,sha256=zG6NQTTm0syFlLuuaQSSWUAEgH7SQ1vI6T8JXtuwx-k,43692
44
- turbo_design-1.3.9.dist-info/METADATA,sha256=ldJWFMHorhV-P1PyUaxjyaVR3UK1RJJljPx2exT8TZA,808
45
- turbo_design-1.3.9.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
46
- turbo_design-1.3.9.dist-info/RECORD,,
42
+ turbodesign/turbine_math.py,sha256=iLS079qZ_WqZMg6oNyFweLNmY2lRMyRgRu6hnr4ZkFo,16432
43
+ turbodesign/turbine_spool.py,sha256=CX7jtaAyAaM0DIrbfoHUY2_N84zUM1rTB_-4qdilu0Q,55193
44
+ turbo_design-1.3.10.dist-info/METADATA,sha256=T5RuRmwqCbsCiVWr05iv115L6f7TO4H5U3qkYtpxg2A,809
45
+ turbo_design-1.3.10.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
46
+ turbo_design-1.3.10.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.3.0
2
+ Generator: poetry-core 2.3.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
turbodesign/bladerow.py CHANGED
@@ -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
- T0R_local = upstream.T0R
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, P0, T0
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
- row.Vr = row.Vm*np.sin(row.phi)
360
- row.Vx = row.Vm*np.cos(row.phi)
361
- row.W = np.sqrt(2*row.Cp*(row.T0R-row.T))
362
- row.Wt = row.W*np.sin(beta2_eff)
363
- row.Vt = row.Wt+row.U
364
-
365
- row.alpha2 = np.arctan2(row.Vt,row.Vm)
366
- row.V = np.sqrt(row.Vm**2*(1+np.tan(row.alpha2)**2))
367
-
368
- row.M = row.V/np.sqrt(row.gamma*row.R*row.T)
369
- T0_T = (1+(row.gamma-1)/2 * row.M**2)
370
- row.P0 = row.P * T0_T**(row.gamma/(row.gamma-1))
371
-
372
- row.M_rel = row.W/np.sqrt(row.gamma*row.R*row.T)
373
- row.T0 = row.T+row.V**2/(2*row.Cp)
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
- for _ in range(3):
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
- tol=1e-3,
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."""
turbodesign/flow_math.py CHANGED
@@ -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
- a = np.sqrt(row.gamma * row.R * row.T_is)
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) / max(row.U.mean(), 1e-9))
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 = c*V*rho/mu
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 = upstream.T0R - T0_coolant # (upstream_rothalpy + 0.5*row.U**2)/row.Cp # - T0_coolant_weighted_average(row)
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, P0, T0
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
- row.W = np.sqrt(2*row.Cp*(row.T0R-row.T))
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
- T0_T = (1+(row.gamma-1)/2 * row.M**2)
281
- row.P0 = row.P * T0_T**(row.gamma/(row.gamma-1))
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 if fluid is not None else Solution("air.yaml")
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
- for br in self._all_rows():
114
- br.fluid = self._fluid
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
- else:
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
- for _ in range(3):
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, massflow_target[j], downstream),
473
- tol=1e-4,
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.figure(num=1, clear=True, dpi=150, figsize=(15, 10))
791
- plt.plot(
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="hub",
844
+ label="Hub",
795
845
  linestyle="solid",
796
- linewidth=2,
846
+ linewidth=3,
797
847
  color="black",
848
+ zorder=10
798
849
  )
799
- plt.plot(
850
+ ax.plot(
800
851
  self.passage.xshroud_pts,
801
852
  self.passage.rshroud_pts,
802
- label="shroud",
853
+ label="Shroud",
803
854
  linestyle="solid",
804
- linewidth=2,
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
- plt.plot(x_streamline[:, i], r_streamline[:, i], "--b", linewidth=1.5)
873
+ ax.plot(x_streamline[:, i], r_streamline[:, i],
874
+ linestyle="--", linewidth=1.2, color="gray", alpha=0.6, zorder=1)
819
875
 
820
- for i, row in enumerate(blade_rows):
821
- plt.plot(row.x, row.r, linestyle="dashed", linewidth=1.5, color="blue", alpha=0.4)
822
- plt.plot(x_streamline[:, i], r_streamline[:, i], "or")
876
+ # Track label positions to avoid overlaps
877
+ label_positions = []
823
878
 
824
- if i == 0:
825
- pass
826
- else:
827
- upstream = blade_rows[i - 1]
828
- if upstream.row_type == RowType.Inlet:
829
- cut_line1, _, _ = self.passage.get_cutting_line(
830
- (row.hub_location * hub_length + (0.5 * row.blade_to_blade_gap * row.axial_chord) - row.axial_chord)
831
- / hub_length
832
- )
833
- else:
834
- cut_line1, _, _ = self.passage.get_cutting_line(
835
- (upstream.hub_location * hub_length) / hub_length
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
- if row.row_type == RowType.Stator:
842
- x1, r1 = cut_line1.get_point(np.linspace(0, 1, 10))
843
- plt.plot(x1, r1, "m")
844
- x2, r2 = cut_line2.get_point(np.linspace(0, 1, 10))
845
- plt.plot(x2, r2, "m")
846
- x_text = (x1 + x2) / 2
847
- r_text = (r1 + r2) / 2
848
- plt.text(x_text.mean(), r_text.mean(), "Stator", fontdict={"fontsize": "xx-large"})
849
- elif row.row_type == RowType.Rotor:
850
- x1, r1 = cut_line1.get_point(np.linspace(0, 1, 10))
851
- plt.plot(x1, r1, color="brown")
852
- x2, r2 = cut_line2.get_point(np.linspace(0, 1, 10))
853
- plt.plot(x2, r2, color="brown")
854
- x_text = (x1 + x2) / 2
855
- r_text = (r1 + r2) / 2
856
- plt.text(x_text.mean(), r_text.mean(), "Rotor", fontdict={"fontsize": "xx-large"})
857
-
858
- plt.axis("scaled")
859
- plt.savefig("Meridional.png", transparent=False, dpi=150)
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 (turbines).
864
- """
956
+ """Plot velocity triangles for each blade row with improved styling and annotations."""
865
957
  blade_rows = self._all_rows()
866
- prop = dict(arrowstyle="-|>,head_width=0.4,head_length=0.8", shrinkA=0, shrinkB=0)
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
- plt.figure(num=1, clear=True)
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.mean()
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
- y_max = max(y_max, Vt, Wt)
883
- y_min = min(y_min, Vt, Wt)
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
- # V
886
- plt.annotate("", xy=(x_end, Vt), xytext=(x_start, 0), arrowprops=prop)
887
- plt.text((x_start + x_end) / 2, Vt / 2 * 1.1, "V", fontdict={"fontsize": "xx-large"})
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
- # W
890
- plt.annotate("", xy=(x_end, Wt), xytext=(x_start, 0), arrowprops=prop)
891
- plt.text((x_start + x_end) / 2, Wt / 2 * 1.1, "W", fontdict={"fontsize": "xx-large"})
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
- if abs(Vt) > abs(Wt):
894
- plt.annotate("", xy=(x_end, Wt), xytext=(x_end, 0), arrowprops=prop) # Wt
895
- plt.text(x_end + dx * 0.1, Wt / 2, "Wt", fontdict={"fontsize": "xx-large"})
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
- plt.annotate("", xy=(x_end, U + Wt), xytext=(x_end, Wt), arrowprops=prop) # U
898
- plt.text(x_end + dx * 0.1, (Wt + U) / 2, "U", fontdict={"fontsize": "xx-large"})
899
- else:
900
- plt.annotate("", xy=(x_end, Vt), xytext=(x_end, 0), arrowprops=prop) # Vt
901
- plt.text(x_end + dx * 0.1, Vt / 2, "Vt", fontdict={"fontsize": "xx-large"})
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
- plt.annotate("", xy=(x_end, Wt + U), xytext=(x_end, Wt), arrowprops=prop) # U
904
- plt.text(x_end + dx * 0.1, Wt + U / 2, "U", fontdict={"fontsize": "xx-large"})
1110
+ Returns:
1111
+ None. Either displays plot or saves to file.
905
1112
 
906
- y = y_min if -np.sign(Vt) > 0 else y_max
907
- plt.text((x_start + x_end) / 2, -np.sign(Vt) * y * 0.95, row.row_type.name, fontdict={"fontsize": "xx-large"})
908
- x_start += row.Vm[j]
909
- plt.axis([0, x_end + dx, y_min, y_max])
910
- plt.ylabel("Tangental Velocity [m/s]")
911
- plt.xlabel("Vm [m/s]")
912
- plt.title(f"Velocity Triangles for Streamline {j}")
913
- plt.savefig(f"streamline_{j:04d}.png", transparent=False, dpi=150)
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
- T3_is = upstream.T0 * (1 / row.P0_P) ** ((row.gamma - 1) / row.gamma)
970
- a = np.sqrt(row.gamma * row.R * T3_is)
971
- T03_is = T3_is * (1 + (row.gamma - 1) / 2 * (row.V / a) ** 2)
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]))