RCAIDE-GUI 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. common_widgets/__init__.py +6 -0
  2. common_widgets/animated_toggle.py +141 -0
  3. common_widgets/color.py +17 -0
  4. common_widgets/data_entry_widget.py +337 -0
  5. common_widgets/image_widget.py +21 -0
  6. common_widgets/unit_picker_widget.py +48 -0
  7. main.py +168 -0
  8. rcaide_gui-1.0.0.dist-info/METADATA +96 -0
  9. rcaide_gui-1.0.0.dist-info/RECORD +170 -0
  10. rcaide_gui-1.0.0.dist-info/WHEEL +5 -0
  11. rcaide_gui-1.0.0.dist-info/entry_points.txt +2 -0
  12. rcaide_gui-1.0.0.dist-info/top_level.txt +5 -0
  13. rcaide_io.py +818 -0
  14. tabs/__init__.py +10 -0
  15. tabs/aircraft_configs/__init__.py +3 -0
  16. tabs/aircraft_configs/aircraft_configs.py +301 -0
  17. tabs/analysis/__init__.py +4 -0
  18. tabs/analysis/analysis.py +92 -0
  19. tabs/analysis/widgets/__init__.py +13 -0
  20. tabs/analysis/widgets/aeroacoustics_widget.py +158 -0
  21. tabs/analysis/widgets/aerodynamics_widget.py +108 -0
  22. tabs/analysis/widgets/analysis_data_widget.py +29 -0
  23. tabs/analysis/widgets/atmosphere_widget.py +49 -0
  24. tabs/analysis/widgets/costs_widget.py +35 -0
  25. tabs/analysis/widgets/energy_widget.py +32 -0
  26. tabs/analysis/widgets/geometry_widget.py +55 -0
  27. tabs/analysis/widgets/planets_widget.py +34 -0
  28. tabs/analysis/widgets/propulsion_widget.py +53 -0
  29. tabs/analysis/widgets/stability_widget.py +34 -0
  30. tabs/analysis/widgets/weights_widget.py +94 -0
  31. tabs/geometry/__init__.py +6 -0
  32. tabs/geometry/aircraft_configs.py +232 -0
  33. tabs/geometry/frames/__init__.py +15 -0
  34. tabs/geometry/frames/booms/__init__.py +5 -0
  35. tabs/geometry/frames/booms/boom_frame.py +238 -0
  36. tabs/geometry/frames/cargo_bays/__init__.py +5 -0
  37. tabs/geometry/frames/cargo_bays/cargo_bay_frame.py +159 -0
  38. tabs/geometry/frames/default_frame.py +23 -0
  39. tabs/geometry/frames/energy_network/__init__.py +2 -0
  40. tabs/geometry/frames/energy_network/turbofan_network/__init__.py +3 -0
  41. tabs/geometry/frames/energy_network/turbofan_network/frames/__init__.py +1 -0
  42. tabs/geometry/frames/energy_network/turbofan_network/widgets/__init__.py +1 -0
  43. tabs/geometry/frames/fuselages/__init__.py +5 -0
  44. tabs/geometry/frames/fuselages/fuselage_frame.py +294 -0
  45. tabs/geometry/frames/geometry_frame.py +91 -0
  46. tabs/geometry/frames/landing_gears/__init__.py +5 -0
  47. tabs/geometry/frames/landing_gears/landing_gear_frame.py +189 -0
  48. tabs/geometry/frames/nacelles/__init__.py +1 -0
  49. tabs/geometry/frames/powertrain/__init__.py +6 -0
  50. tabs/geometry/frames/powertrain/converters/__init__.py +5 -0
  51. tabs/geometry/frames/powertrain/converters/converter_frame.py +137 -0
  52. tabs/geometry/frames/powertrain/distributors/__init__.py +5 -0
  53. tabs/geometry/frames/powertrain/distributors/distributor_frame.py +143 -0
  54. tabs/geometry/frames/powertrain/nacelles/__init__.py +5 -0
  55. tabs/geometry/frames/powertrain/nacelles/nacelle_frame.py +283 -0
  56. tabs/geometry/frames/powertrain/powertrain_frame.py +259 -0
  57. tabs/geometry/frames/powertrain/propulsors/__init__.py +5 -0
  58. tabs/geometry/frames/powertrain/propulsors/propulsor_frame.py +146 -0
  59. tabs/geometry/frames/powertrain/sources/__init__.py +6 -0
  60. tabs/geometry/frames/powertrain/sources/energy_source_frame.py +147 -0
  61. tabs/geometry/frames/vehicle_frame.py +171 -0
  62. tabs/geometry/frames/wings/__init__.py +5 -0
  63. tabs/geometry/frames/wings/wings_frame.py +457 -0
  64. tabs/geometry/geometry.py +513 -0
  65. tabs/geometry/widgets/__init__.py +12 -0
  66. tabs/geometry/widgets/booms/__init__.py +6 -0
  67. tabs/geometry/widgets/booms/boom_section_widget.py +109 -0
  68. tabs/geometry/widgets/cargo_bays/__init__.py +1 -0
  69. tabs/geometry/widgets/fuselages/__init__.py +8 -0
  70. tabs/geometry/widgets/fuselages/cabin_class_widget.py +188 -0
  71. tabs/geometry/widgets/fuselages/cabin_widget.py +217 -0
  72. tabs/geometry/widgets/fuselages/fuselage_section_widget.py +190 -0
  73. tabs/geometry/widgets/geometry_data_widget.py +25 -0
  74. tabs/geometry/widgets/landing_gears/__init__.py +6 -0
  75. tabs/geometry/widgets/nacelles/__init__.py +1 -0
  76. tabs/geometry/widgets/powertrain/__init__.py +9 -0
  77. tabs/geometry/widgets/powertrain/converters/__init__.py +5 -0
  78. tabs/geometry/widgets/powertrain/converters/turboelectric_generator_widget.py +74 -0
  79. tabs/geometry/widgets/powertrain/distributors/__init__.py +5 -0
  80. tabs/geometry/widgets/powertrain/distributors/fuel_line_widget.py +81 -0
  81. tabs/geometry/widgets/powertrain/modulators/__init__.py +5 -0
  82. tabs/geometry/widgets/powertrain/modulators/esc_widget.py +58 -0
  83. tabs/geometry/widgets/powertrain/nacelles/__init__.py +5 -0
  84. tabs/geometry/widgets/powertrain/nacelles/nacelle_section_widget.py +115 -0
  85. tabs/geometry/widgets/powertrain/powertrain_connector_widget.py +136 -0
  86. tabs/geometry/widgets/powertrain/powertrain_widget.py +206 -0
  87. tabs/geometry/widgets/powertrain/propulsors/__init__.py +5 -0
  88. tabs/geometry/widgets/powertrain/propulsors/turbofan_widget.py +462 -0
  89. tabs/geometry/widgets/powertrain/sources/__init__.py +6 -0
  90. tabs/geometry/widgets/powertrain/sources/fuel_tank_widget.py +103 -0
  91. tabs/geometry/widgets/powertrain/sources/source_selector_widget.py +35 -0
  92. tabs/geometry/widgets/wings/__init__.py +7 -0
  93. tabs/geometry/widgets/wings/wing_cs_widget.py +198 -0
  94. tabs/geometry/widgets/wings/wing_section_widget.py +298 -0
  95. tabs/home/__init__.py +3 -0
  96. tabs/home/home.py +585 -0
  97. tabs/mission/__init__.py +4 -0
  98. tabs/mission/mission.py +1124 -0
  99. tabs/mission/widgets/__init__.py +3 -0
  100. tabs/mission/widgets/flight_controls_widget.py +186 -0
  101. tabs/mission/widgets/mission_analysis_widget.py +166 -0
  102. tabs/mission/widgets/mission_segment_helper.py +358 -0
  103. tabs/mission/widgets/mission_segment_widget.py +358 -0
  104. tabs/solve/__init__.py +4 -0
  105. tabs/solve/plots/__init__.py +12 -0
  106. tabs/solve/plots/aeroacoustics/__init__.py +6 -0
  107. tabs/solve/plots/aeroacoustics/plot_2D_noise_contour.py +184 -0
  108. tabs/solve/plots/aeroacoustics/plot_3D_noise_contour.py +219 -0
  109. tabs/solve/plots/aeroacoustics/plot_noise_certification_contour.py +115 -0
  110. tabs/solve/plots/aeroacoustics/plot_noise_level.py +118 -0
  111. tabs/solve/plots/aerodynamics/__init__.py +10 -0
  112. tabs/solve/plots/aerodynamics/plot_aerodynamic_coefficients.py +156 -0
  113. tabs/solve/plots/aerodynamics/plot_aerodynamic_forces.py +155 -0
  114. tabs/solve/plots/aerodynamics/plot_aircraft_aerodynamics.py +136 -0
  115. tabs/solve/plots/aerodynamics/plot_drag_components.py +157 -0
  116. tabs/solve/plots/aerodynamics/plot_lift_distribution.py +107 -0
  117. tabs/solve/plots/aerodynamics/plot_rotor_conditions.py +255 -0
  118. tabs/solve/plots/aerodynamics/plot_rotor_disc_performance.py +144 -0
  119. tabs/solve/plots/aerodynamics/plot_rotor_performance.py +124 -0
  120. tabs/solve/plots/common/__init__.py +4 -0
  121. tabs/solve/plots/common/plot_style.py +71 -0
  122. tabs/solve/plots/common/set_axes.py +55 -0
  123. tabs/solve/plots/create_plot_widgets.py +34 -0
  124. tabs/solve/plots/emissions/__init__.py +3 -0
  125. tabs/solve/plots/emissions/plot_emissions.py +167 -0
  126. tabs/solve/plots/energy/__init__.py +12 -0
  127. tabs/solve/plots/energy/plot_altitude_sfc_weight.py +134 -0
  128. tabs/solve/plots/energy/plot_battery_cell_conditions.py +154 -0
  129. tabs/solve/plots/energy/plot_battery_degradation.py +157 -0
  130. tabs/solve/plots/energy/plot_battery_module_C_rates.py +139 -0
  131. tabs/solve/plots/energy/plot_battery_module_conditions.py +157 -0
  132. tabs/solve/plots/energy/plot_battery_pack_conditions.py +164 -0
  133. tabs/solve/plots/energy/plot_battery_ragone_diagram.py +97 -0
  134. tabs/solve/plots/energy/plot_battery_temperature.py +136 -0
  135. tabs/solve/plots/energy/plot_electric_propulsor_efficiencies.py +136 -0
  136. tabs/solve/plots/energy/plot_propulsor_throttles.py +119 -0
  137. tabs/solve/plots/mission/__init__.py +5 -0
  138. tabs/solve/plots/mission/plot_aircraft_velocities.py +145 -0
  139. tabs/solve/plots/mission/plot_flight_conditions.py +236 -0
  140. tabs/solve/plots/mission/plot_flight_trajectory.py +172 -0
  141. tabs/solve/plots/stability/__init__.py +5 -0
  142. tabs/solve/plots/stability/plot_flight_forces_and_moments.py +163 -0
  143. tabs/solve/plots/stability/plot_lateral_stability.py +151 -0
  144. tabs/solve/plots/stability/plot_longitudinal_stability.py +173 -0
  145. tabs/solve/plots/thermal_management/__init__.py +7 -0
  146. tabs/solve/plots/thermal_management/plot_air_cooled_conditions.py +152 -0
  147. tabs/solve/plots/thermal_management/plot_cross_flow_heat_exchanger_conditions.py +185 -0
  148. tabs/solve/plots/thermal_management/plot_reservoir_conditions.py +141 -0
  149. tabs/solve/plots/thermal_management/plot_thermal_management_performance.py +105 -0
  150. tabs/solve/plots/thermal_management/plot_wavy_channel_conditions.py +161 -0
  151. tabs/solve/plots/weights/__init__.py +4 -0
  152. tabs/solve/plots/weights/plot_load_diagram.py +163 -0
  153. tabs/solve/plots/weights/plot_weight_breakdown.py +164 -0
  154. tabs/solve/solve.py +1498 -0
  155. tabs/style_sheet.py +114 -0
  156. tabs/tab_widget.py +14 -0
  157. tabs/visualize_geometry/__init__.py +4 -0
  158. tabs/visualize_geometry/core_3d_viewer.py +253 -0
  159. tabs/visualize_geometry/features/__init__.py +10 -0
  160. tabs/visualize_geometry/features/axes_gizmo.py +173 -0
  161. tabs/visualize_geometry/features/background.py +161 -0
  162. tabs/visualize_geometry/features/blueprint.py +685 -0
  163. tabs/visualize_geometry/features/camera.py +364 -0
  164. tabs/visualize_geometry/features/drag_aircraft.py +219 -0
  165. tabs/visualize_geometry/features/grid.py +194 -0
  166. tabs/visualize_geometry/features/measurement.py +367 -0
  167. tabs/visualize_geometry/features/screenshot.py +91 -0
  168. tabs/visualize_geometry/geometry_helper_functions.py +3 -0
  169. tabs/visualize_geometry/visualize_geometry.py +731 -0
  170. utilities.py +257 -0
tabs/solve/solve.py ADDED
@@ -0,0 +1,1498 @@
1
+ # RCAIDE_GUI/tabs/solve/solve.py
2
+ #
3
+ # Created: Oct 2024, Laboratory for Electric Aircraft Design and Sustainabiltiy
4
+
5
+ # ----------------------------------------------------------------------------------------------------------------------
6
+ # IMPORT
7
+ # ----------------------------------------------------------------------------------------------------------------------
8
+ # RCAIDE GUI Imports
9
+ import RCAIDE
10
+ import rcaide_io
11
+ from tabs.mission.widgets.mission_analysis_widget import MissionAnalysisWidget
12
+ from tabs.mission.widgets.mission_segment_widget import MissionSegmentWidget
13
+ from tabs.mission.mission import _extract_gui_segments
14
+ from tabs.aircraft_configs.aircraft_configs import build_rcaide_configs_from_geometry
15
+
16
+ # PtQT imports
17
+ from PyQt6.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QTreeWidget, QPushButton, QTreeWidgetItem, QHeaderView, QLabel, QScrollArea, QProgressDialog, QMessageBox
18
+ from PyQt6.QtCore import Qt, QSize, QObject, QThread, QTimer, pyqtSignal
19
+ import pyqtgraph as pg
20
+
21
+ # numpy imports
22
+ import numpy as np
23
+ import re
24
+ import traceback
25
+ import os
26
+ from datetime import datetime
27
+ import matplotlib.colors as mcolors
28
+
29
+ # Parula colormap (MATLAB default) — defined from its known anchor points.
30
+ _PARULA_CMAP = mcolors.LinearSegmentedColormap.from_list("parula", [
31
+ [0.2081, 0.1663, 0.5292],
32
+ [0.0196, 0.4061, 0.8754],
33
+ [0.0762, 0.6631, 0.7859],
34
+ [0.3672, 0.7897, 0.3832],
35
+ [0.9764, 0.8431, 0.1255],
36
+ ])
37
+
38
+ # gui imports
39
+ from tabs import TabWidget
40
+ from .plots.create_plot_widgets import create_plot_widgets
41
+
42
+ class _SolveWorker(QObject):
43
+ finished = pyqtSignal(object, str)
44
+ failed = pyqtSignal(str)
45
+
46
+ def __init__(self, mission):
47
+ super().__init__()
48
+ self._mission = mission
49
+
50
+ def run(self):
51
+ try:
52
+ # Let solver output stream directly to terminal so native progress bar rendering is preserved.
53
+ results = self._mission.evaluate()
54
+ self.finished.emit(results, "")
55
+ except Exception:
56
+ self.failed.emit(traceback.format_exc())
57
+
58
+ # ----------------------------------------------------------------------------------------------------------------------
59
+ # SolveWidget
60
+ # ----------------------------------------------------------------------------------------------------------------------
61
+ class SolveWidget(TabWidget):
62
+ # Maps each tree option label to the corresponding SolveWidget renderer method.
63
+ _PLOT_OPTION_RENDERERS = {
64
+ "Plot Aircraft Velocities": "_render_aircraft_velocities",
65
+ "Plot Aerodynamic Coefficients": "_render_aerodynamic_coefficients_pg",
66
+ "Plot Aerodynamic Forces": "_render_aerodynamic_forces_pg",
67
+ "Plot Drag Components": "_render_drag_components_pg",
68
+ "Plot Lift Distribution": "_render_lift_distribution_pg",
69
+ "Plot Rotor Conditions": "_render_rotor_conditions_pg",
70
+ "Plot Altitude SFC Weight": "_render_altitude_sfc_weight_pg",
71
+ "Plot Propulsor Throttles": "_render_propulsor_throttles_pg",
72
+ "Plot Flight Conditions": "_render_flight_conditions_pg",
73
+ "Plot Flight Trajectory": "_render_flight_trajectory_pg",
74
+ "Plot Flight Forces and Moments": "_render_flight_forces_moments_pg",
75
+ "Plot Longitudinal Stability": "_render_longitudinal_stability_pg",
76
+ "Plot Lateral Stability": "_render_lateral_stability_pg",
77
+ }
78
+ # Aliases for options that intentionally reuse an existing renderer.
79
+ _PLOT_OPTION_ALIASES = {
80
+ "Plot Rotor Disc Inflow": "Plot Rotor Conditions",
81
+ "Plot Rotor Disc Performance": "Plot Rotor Conditions",
82
+ "Plot Rotor Performance": "Plot Rotor Conditions",
83
+ "Plot Disc and Power Loading": "Plot Rotor Conditions",
84
+ "Plot Fuel Consumption": "Plot Altitude SFC Weight",
85
+ }
86
+ # Drag-coefficient components plotted in the drag breakdown chart.
87
+ _DRAG_COMPONENTS = (
88
+ ("CDpar", ("parasite", "total")),
89
+ ("CDind", ("induced", "total")),
90
+ ("CDcomp", ("compressible", "total")),
91
+ ("CDmisc", ("miscellaneous", "total")),
92
+ ("CDwave", ("wave", "total")),
93
+ ("CDcool", ("cooling", "total")),
94
+ ("CDform", ("form", "total")),
95
+ ("CD", ("total",)),
96
+ )
97
+
98
+ def __init__(self):
99
+ super(SolveWidget, self).__init__()
100
+
101
+ base_layout = QHBoxLayout()
102
+ tree_layout = QVBoxLayout()
103
+ main_layout = QVBoxLayout()
104
+
105
+ # Create and add a label to the main_layout
106
+ status_label = QLabel("Mission Plots")
107
+ status_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
108
+ status_label.setFixedHeight(24)
109
+ status_label.setStyleSheet("""
110
+ QLabel {
111
+ color: #9fb8ff;
112
+ background: transparent;
113
+ padding-left: 6px;
114
+ font-size: 18px;
115
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
116
+ }
117
+ """)
118
+
119
+ main_layout.addWidget(status_label)
120
+
121
+ solve_button = QPushButton("Simulate Mission")
122
+ solve_button.setFixedHeight(36)
123
+ solve_button.setCursor(Qt.CursorShape.PointingHandCursor)
124
+ # Custom state used by background solve execution + loading dialog.
125
+ self.solve_button = solve_button
126
+ self.loading_dialog = None
127
+ self._solve_thread = None
128
+ self._solve_worker = None
129
+ self._is_rendering_plots = False
130
+ self._plot_render_timer = QTimer(self)
131
+ self._plot_render_timer.setSingleShot(True)
132
+ self._plot_render_timer.timeout.connect(self._render_from_latest_results)
133
+ self._last_skipped_signature = None
134
+
135
+ solve_button.setStyleSheet("""
136
+ QPushButton {
137
+ /* Bold blue surface */
138
+ background-color: qlineargradient(
139
+ x1:0, y1:0, x2:0, y2:1,
140
+ stop:0 #1b4f8a,
141
+ stop:1 #133a66
142
+ );
143
+
144
+ border: 1.6px solid #5fb0ff;
145
+ border-radius: 10px;
146
+
147
+ /* Inner glow + depth */
148
+ box-shadow:
149
+ inset 0 0 0 1px rgba(255, 255, 255, 0.10),
150
+ inset 0 8px 14px rgba(255, 255, 255, 0.08),
151
+ 0 0 0 1px rgba(95, 176, 255, 0.35);
152
+
153
+ padding: 7px 20px;
154
+
155
+ /* Text */
156
+ color: #d9ecff;
157
+ font-size: 13.5px;
158
+ font-weight: 700;
159
+ letter-spacing: 0.35px;
160
+ }
161
+
162
+ /* Hover = high confidence */
163
+ QPushButton:hover {
164
+ background-color: qlineargradient(
165
+ x1:0, y1:0, x2:0, y2:1,
166
+ stop:0 #2a6fb5,
167
+ stop:1 #1b4f8a
168
+ );
169
+
170
+ border-color: #8ccaff;
171
+ color: #ffffff;
172
+
173
+ box-shadow:
174
+ inset 0 0 0 1px rgba(255, 255, 255, 0.18),
175
+ 0 0 8px rgba(95, 176, 255, 0.45);
176
+ }
177
+
178
+ /* Pressed = command issued */
179
+ QPushButton:pressed {
180
+ background-color: #0f2e4d;
181
+ border-color: #4da3ff;
182
+
183
+ box-shadow:
184
+ inset 0 3px 8px rgba(0, 0, 0, 0.55);
185
+ }
186
+ """)
187
+
188
+ solve_button.clicked.connect(self.run_solve)
189
+
190
+ # Create a scroll area for the plot widgets
191
+ scroll_area = QScrollArea()
192
+ scroll_area.setWidgetResizable(True)
193
+ scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
194
+ # scroll_area.setFixedSize(1500, 900) # Set a designated scroll area size
195
+
196
+ # Create a container widget for the plots
197
+ plot_container = QWidget()
198
+ plot_layout = QVBoxLayout(plot_container)
199
+ plot_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
200
+ self.plot_layout = plot_layout
201
+ self._dynamic_plot_widgets = []
202
+
203
+ plot_size = QSize(620, 400) # Slightly narrower to avoid lateral scrolling
204
+ show_legend = True
205
+ create_plot_widgets(self,plot_layout,plot_size,show_legend)
206
+ self._base_plot_widgets = [
207
+ self.aircraft_TAS_plot,
208
+ self.aircraft_EAS_plot,
209
+ self.aircraft_Mach_plot,
210
+ self.aircraft_CAS_plot,
211
+ ]
212
+
213
+ scroll_area.setWidget(plot_container)
214
+
215
+ # Add the scroll area to the main_layout
216
+ main_layout.addWidget(scroll_area)
217
+
218
+ # Tree layout (on the left)
219
+ self.tree = QTreeWidget()
220
+ self.init_tree()
221
+ tree_layout.addWidget(solve_button)
222
+ tree_layout.addWidget(self.tree)
223
+
224
+ # Add layouts to the base_layout
225
+ base_layout.addLayout(tree_layout, 3)
226
+ base_layout.addLayout(main_layout, 7)
227
+ self.setLayout(base_layout)
228
+
229
+ def init_tree(self):
230
+ # Two columns: option name + check state.
231
+ self.tree.setColumnCount(2)
232
+ self.tree.setHeaderLabels(["Plot Options", "Enabled"])
233
+
234
+ # Size first column to fit option text.
235
+ header = self.tree.header()
236
+ assert header is not None
237
+ header.setSectionResizeMode(
238
+ 0, QHeaderView.ResizeMode.ResizeToContents)
239
+
240
+ # Add category rows and child plot options.
241
+ for category, options in self.plot_options.items():
242
+ category_item = QTreeWidgetItem([category])
243
+ self.tree.addTopLevelItem(category_item)
244
+ category_item.setExpanded(True)
245
+
246
+ for option in options:
247
+ option_item = QTreeWidgetItem([option])
248
+ option_item.setCheckState(
249
+ 1, Qt.CheckState.Checked) # Initially checked
250
+
251
+ category_item.addChild(option_item)
252
+
253
+ def run_solve(self):
254
+ # Use local imports to avoid extra startup/circular import issues.
255
+
256
+ # Read mission built in Mission tab.
257
+ mission = getattr(rcaide_io, "rcaide_mission", None)
258
+
259
+ # Read saved aircraft configs, or build them from geometry if missing.
260
+ # rcaide_configs is a Config.Container (not a plain dict), so only rebuild
261
+ # when it is genuinely absent or empty.
262
+ configs = getattr(rcaide_io, "rcaide_configs", None)
263
+ if not configs:
264
+ rcaide_io.rcaide_configs = build_rcaide_configs_from_geometry()
265
+ configs = rcaide_io.rcaide_configs
266
+
267
+ # If mission is missing or has no segments, rebuild it from saved mission_data.
268
+ if mission is None or not getattr(mission, "segments", []):
269
+ if rcaide_io.mission_data:
270
+ # Ensure analyses exist before rebuilding segments.
271
+ if not getattr(rcaide_io, "rcaide_analyses", None):
272
+ MissionAnalysisWidget().save_analyses()
273
+
274
+ # Recreate mission and append each saved segment.
275
+ mission = RCAIDE.Framework.Mission.Sequential_Segments()
276
+ for seg_data in _extract_gui_segments(rcaide_io.mission_data):
277
+ seg = MissionSegmentWidget()
278
+ seg.load_data(seg_data)
279
+ _, rcaide_segment = seg.get_data()
280
+ mission.append_segment(rcaide_segment)
281
+
282
+ # Save rebuilt mission back to shared state.
283
+ rcaide_io.rcaide_mission = mission
284
+ else:
285
+ QMessageBox.critical(
286
+ self, "No Mission",
287
+ "No mission segments found. Build and save a mission first."
288
+ )
289
+ return
290
+
291
+ # Ignore click if a solve is already running.
292
+ if self._solve_thread is not None and self._solve_thread.isRunning():
293
+ return
294
+
295
+ # Start solve with loading popup.
296
+ print("Commencing Mission Simulation")
297
+ self._set_loading_state(True)
298
+ self._start_solve_worker(mission)
299
+
300
+ def _start_solve_worker(self, mission):
301
+ # Create worker thread so UI does not freeze during solve.
302
+ self._solve_thread = QThread(self)
303
+ self._solve_worker = _SolveWorker(mission)
304
+ self._solve_worker.moveToThread(self._solve_thread)
305
+
306
+ # Wire start/success/failure/cleanup signals.
307
+ self._solve_thread.started.connect(self._solve_worker.run)
308
+ self._solve_worker.finished.connect(self._on_solve_finished)
309
+ self._solve_worker.failed.connect(self._on_solve_failed)
310
+ self._solve_worker.finished.connect(self._solve_thread.quit)
311
+ self._solve_worker.failed.connect(self._solve_thread.quit)
312
+ self._solve_thread.finished.connect(self._cleanup_solve_worker)
313
+ self._solve_thread.start()
314
+
315
+ def _cleanup_solve_worker(self):
316
+ # Safely release worker objects after thread exits.
317
+ if self._solve_worker is not None:
318
+ self._solve_worker.deleteLater()
319
+ self._solve_worker = None
320
+ if self._solve_thread is not None:
321
+ self._solve_thread.deleteLater()
322
+ self._solve_thread = None
323
+
324
+ def _set_loading_state(self, is_loading):
325
+ # Lock solve controls while mission is running.
326
+ self.solve_button.setEnabled(not is_loading)
327
+ self.tree.setEnabled(not is_loading)
328
+
329
+ if is_loading:
330
+ # Show modal loading popup.
331
+ dialog = QProgressDialog("Running mission simulation...", "", 0, 0, self)
332
+ dialog.setWindowTitle("Simulating Mission")
333
+ dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
334
+ dialog.setCancelButton(None)
335
+ dialog.setMinimumDuration(0)
336
+ dialog.setAutoClose(False)
337
+ dialog.setAutoReset(False)
338
+ dialog.show()
339
+ self.loading_dialog = dialog
340
+ return
341
+
342
+ if self.loading_dialog is not None:
343
+ # Hide loading popup when solve completes/fails.
344
+ self.loading_dialog.close()
345
+ self.loading_dialog.deleteLater()
346
+ self.loading_dialog = None
347
+
348
+ def _on_solve_finished(self, results, output):
349
+ import rcaide_io
350
+ # Parse and print solver warnings (if any).
351
+ warnings = _summarize_solve_output(output)
352
+ if warnings and not _warnings_already_reported(output):
353
+ print("Mission completed with solver warnings:")
354
+ for warning in warnings:
355
+ print(f"- {warning}")
356
+
357
+ # Store results and refresh plots.
358
+ print("Completed Mission Simulation")
359
+ rcaide_io.rcaide_results = results
360
+ self.render_solve_plots(results)
361
+ self._set_loading_state(False)
362
+
363
+ def _on_solve_failed(self, error_message):
364
+ # Restore UI state and surface error details.
365
+ self._set_loading_state(False)
366
+ print("Mission simulation failed.")
367
+ print(error_message)
368
+ QMessageBox.critical(self, "Mission Simulation Failed", error_message)
369
+
370
+ def render_solve_plots(self, results):
371
+ # Main render entry point: rebuild plots from current checked options.
372
+ if self._is_rendering_plots:
373
+ return
374
+ self._is_rendering_plots = True
375
+ self.setUpdatesEnabled(False)
376
+ try:
377
+ self.clear_plot_widgets()
378
+ self.clear_dynamic_plot_widgets()
379
+ for widget in self._base_plot_widgets:
380
+ widget.setVisible(False)
381
+ plot_parameters = self._build_plot_parameters(results)
382
+
383
+ rendered = set()
384
+ skipped = []
385
+ for option in self._collect_checked_plot_options():
386
+ key, renderer = self._resolve_plot_renderer(option)
387
+ if key in rendered:
388
+ continue
389
+ # Option exists in the tree but no renderer is wired for it.
390
+ if renderer is None:
391
+ skipped.append(f"{option}: no renderer")
392
+ continue
393
+ start_idx = len(self._dynamic_plot_widgets)
394
+ try:
395
+ # Each renderer can raise RuntimeError when its required
396
+ # result fields are missing. We treat that as a per-plot
397
+ # skip so the rest of the selected plots still render.
398
+ renderer(results, plot_parameters)
399
+ self._remove_empty_dynamic_plots(start_idx)
400
+ rendered.add(key)
401
+ except Exception as exc:
402
+ # Roll back widgets created by this renderer and record
403
+ # the reason so the user knows why this plot was skipped.
404
+ self._clear_dynamic_widgets_from(start_idx)
405
+ skipped.append(f"{option}: {exc}")
406
+
407
+ self._log_skipped_plot_entries(skipped)
408
+ if hasattr(self, "apply_plot_settings"):
409
+ self.apply_plot_settings()
410
+ finally:
411
+ self.setUpdatesEnabled(True)
412
+ self._is_rendering_plots = False
413
+
414
+ def clear_plot_widgets(self):
415
+ # Clear data items from all plot widgets (keeps widgets alive).
416
+ for widget in self.findChildren(pg.PlotWidget):
417
+ widget.clear()
418
+
419
+ def _collect_checked_plot_options(self):
420
+ # Return a list of plot options that are currently checked.
421
+ checked_options = []
422
+ for i in range(self.tree.topLevelItemCount()):
423
+ category_item = self.tree.topLevelItem(i)
424
+ # Skip invalid category rows.
425
+ if category_item is None:
426
+ continue
427
+ for j in range(category_item.childCount()):
428
+ option_item = category_item.child(j)
429
+ # Keep only checked child options.
430
+ if option_item is not None and option_item.checkState(1) == Qt.CheckState.Checked:
431
+ checked_options.append(option_item.text(0))
432
+ return checked_options
433
+
434
+ def _resolve_plot_renderer(self, option):
435
+ # Map a tree option name to the renderer method that draws it.
436
+ key = self._PLOT_OPTION_ALIASES.get(option, option)
437
+ method_name = self._PLOT_OPTION_RENDERERS.get(key)
438
+ # No renderer is registered for this option.
439
+ if method_name is None:
440
+ return key, None
441
+ return key, getattr(self, method_name, None)
442
+
443
+ def _clear_dynamic_widgets_from(self, start_idx):
444
+ # Remove dynamic plots created after a given index.
445
+ to_remove = self._dynamic_plot_widgets[start_idx:]
446
+ self._dynamic_plot_widgets = self._dynamic_plot_widgets[:start_idx]
447
+ self._delete_plot_widgets(to_remove)
448
+
449
+ def _log_skipped_plot_entries(self, skipped):
450
+ # Print skipped-plot reasons, but only when the list changes.
451
+ if skipped:
452
+ signature = tuple(sorted(skipped))
453
+ # Avoid printing the same skip messages over and over.
454
+ if signature != self._last_skipped_signature:
455
+ print("Some selected plots were skipped:")
456
+ for entry in skipped[:8]:
457
+ print(f"- {entry}")
458
+ self._last_skipped_signature = signature
459
+ else:
460
+ # Clear last signature when nothing is skipped.
461
+ self._last_skipped_signature = None
462
+
463
+ def _schedule_plot_render(self):
464
+ # Debounce rapid checkbox changes into one redraw.
465
+ self._plot_render_timer.start(80)
466
+
467
+ def _render_from_latest_results(self):
468
+ # Rebuild plots from the latest saved mission results.
469
+ import rcaide_io
470
+ results = getattr(rcaide_io, "rcaide_results", None)
471
+ # Only render when results exist.
472
+ if results is not None:
473
+ self.render_solve_plots(results)
474
+
475
+ def clear_dynamic_plot_widgets(self):
476
+ # Delete all dynamic plots from the layout.
477
+ self._delete_plot_widgets(self._dynamic_plot_widgets)
478
+ self._dynamic_plot_widgets = []
479
+
480
+ def _delete_plot_widgets(self, widgets):
481
+ # Remove widgets from layout and free them safely.
482
+ for widget in widgets:
483
+ self.plot_layout.removeWidget(widget)
484
+ widget.setParent(None)
485
+ widget.deleteLater()
486
+
487
+ def _new_plot_widget(self, title, y_label, x_label="Time (min)", show_legend=True):
488
+ # Create one new plot widget and add it to the dynamic list.
489
+ widget = pg.PlotWidget()
490
+ widget.setFixedSize(QSize(620, 380))
491
+ widget.setBackground("#0e141b")
492
+ plot_item = widget.getPlotItem()
493
+ plot_item.showGrid(x=True, y=True, alpha=0.15)
494
+ for axis_name in ("left", "bottom"):
495
+ axis = plot_item.getAxis(axis_name)
496
+ axis.setPen(pg.mkPen("#4da3ff"))
497
+ axis.setTextPen(pg.mkPen("#9fb8ff"))
498
+ plot_item.getViewBox().setBorder(pg.mkPen("#1f2a36"))
499
+ widget.setLabel("left", y_label, color="white", size="18px")
500
+ widget.setLabel("bottom", x_label, color="white", size="18px")
501
+ widget.setTitle(title, color="#9fb8ff", size="14pt")
502
+ # Add legend only when legend toggle is enabled.
503
+ if hasattr(self, "legend_check") and self.legend_check.isChecked():
504
+ legend = widget.addLegend(offset=(10, 10))
505
+ # Style legend when it was created.
506
+ if legend is not None:
507
+ legend.setBrush(pg.mkBrush(8, 12, 18, 180))
508
+ legend.setPen(pg.mkPen(120, 150, 210, 140))
509
+ self._position_plot_legend(widget)
510
+ self.plot_layout.addWidget(widget, alignment=Qt.AlignmentFlag.AlignHCenter)
511
+ self._dynamic_plot_widgets.append(widget)
512
+ return widget
513
+
514
+ def _position_plot_legend(self, plot_widget):
515
+ # Keep legend fixed in the top-right corner.
516
+ legend = plot_widget.plotItem.legend
517
+ # Nothing to place if legend is missing.
518
+ if legend is None:
519
+ return
520
+ try:
521
+ legend.setParentItem(plot_widget.getPlotItem().getViewBox())
522
+ legend.anchor(itemPos=(1, 0), parentPos=(1, 0), offset=(-10, 10))
523
+ legend.setBrush(pg.mkBrush(8, 12, 18, 180))
524
+ legend.setPen(pg.mkPen(120, 150, 210, 140))
525
+ except Exception:
526
+ # Ignore legend anchor/styling errors to avoid breaking plotting.
527
+ pass
528
+
529
+ def _segment_style(self, i, plot_parameters):
530
+ # Build color and marker style for one mission segment.
531
+ rgba = plot_parameters.line_colors[i] * 255.0
532
+ line_color = (int(rgba[0]), int(rgba[1]), int(rgba[2]))
533
+ pen = pg.mkPen(color=line_color, width=plot_parameters.line_width)
534
+ symbol = plot_parameters.markers[0] if plot_parameters.markers else 'o'
535
+ return pen, line_color, symbol
536
+
537
+ def _has_attr_chain(self, obj, chain):
538
+ # Check whether all nested attributes exist on an object.
539
+ cur = obj
540
+ for name in chain:
541
+ # Stop early when any part of the path is missing.
542
+ if not hasattr(cur, name):
543
+ return False
544
+ cur = getattr(cur, name)
545
+ return True
546
+
547
+ def _units(self):
548
+ # Return shared RCAIDE unit conversions.
549
+ from RCAIDE.Framework.Core import Units
550
+ return Units
551
+
552
+ def _plot_time_series(self, widget, results, plot_parameters, y_fn):
553
+ # Plot one time-series curve per mission segment.
554
+ for segment, time, pen, brush, symbol, tag in self._iter_segments_with_style(results, plot_parameters):
555
+ y = np.asarray(y_fn(segment)).reshape(-1)
556
+ widget.plot(time, y, pen=pen, symbol=symbol, symbolBrush=brush, symbolSize=plot_parameters.marker_size, name=tag)
557
+
558
+ def _iter_segments_with_style(self, results, plot_parameters):
559
+ # Yield segment data plus plotting style and label.
560
+ U = self._units()
561
+ for i, segment in enumerate(results.segments):
562
+ time = segment.conditions.frames.inertial.time[:, 0] / U.min
563
+ pen, brush, symbol = self._segment_style(i, plot_parameters)
564
+ yield segment, time, pen, brush, symbol, segment.tag.replace("_", " ")
565
+
566
+ def _render_time_series_group(self, results, plot_parameters, specs, x_label="Time (min)"):
567
+ # Create and fill a group of time-series plots.
568
+ for title, y_label, y_fn, show_legend in specs:
569
+ widget = self._new_plot_widget(title, y_label, x_label=x_label, show_legend=show_legend)
570
+ self._plot_time_series(widget, results, plot_parameters, y_fn)
571
+
572
+ def _render_aircraft_velocities(self, results, plot_parameters):
573
+ # Fill the base aircraft-velocity plots.
574
+ from .plots.mission import plot_aircraft_velocities
575
+ for widget in self._base_plot_widgets:
576
+ widget.setVisible(True)
577
+ plot_aircraft_velocities(self, results, plot_parameters)
578
+
579
+ def _render_aerodynamic_coefficients_pg(self, results, plot_parameters):
580
+ # Plot AoA, L/D, CL, and CD.
581
+ U = self._units()
582
+ self._render_time_series_group(results, plot_parameters, [
583
+ ("Aerodynamic Coefficients: AoA", "AoA (deg)", lambda s: s.conditions.aerodynamics.angles.alpha[:, 0] / U.deg, True),
584
+ ("Aerodynamic Coefficients: L/D", "L/D", lambda s: s.conditions.aerodynamics.coefficients.lift.total[:, 0] / np.clip(s.conditions.aerodynamics.coefficients.drag.total[:, 0], 1e-9, None), False),
585
+ ("Aerodynamic Coefficients: CL", "CL", lambda s: s.conditions.aerodynamics.coefficients.lift.total[:, 0], False),
586
+ ("Aerodynamic Coefficients: CD", "CD", lambda s: s.conditions.aerodynamics.coefficients.drag.total[:, 0], False),
587
+ ])
588
+
589
+ def _render_aerodynamic_forces_pg(self, results, plot_parameters):
590
+ # Plot aerodynamic power, thrust, lift, and drag.
591
+ self._render_time_series_group(results, plot_parameters, [
592
+ ("Aerodynamic Forces: Power", "Power (MW)", lambda s: s.conditions.energy.power[:, 0] / 1e6, True),
593
+ ("Aerodynamic Forces: Thrust", "Thrust (kN)", lambda s: s.conditions.frames.body.thrust_force_vector[:, 0] / 1000.0, False),
594
+ ("Aerodynamic Forces: Lift", "Lift (kN)", lambda s: -s.conditions.frames.wind.force_vector[:, 2] / 1000.0, False),
595
+ ("Aerodynamic Forces: Drag", "Drag (kN)", lambda s: -s.conditions.frames.wind.force_vector[:, 0] / 1000.0, False),
596
+ ])
597
+
598
+ def _render_drag_components_pg(self, results, plot_parameters):
599
+ # Plot drag breakdown components.
600
+ U = self._units()
601
+ widget = self._new_plot_widget("Drag Components", "Drag Coefficient", show_legend=True)
602
+ for i, segment in enumerate(results.segments):
603
+ time = segment.conditions.frames.inertial.time[:, 0] / U.min
604
+ drag = segment.conditions.aerodynamics.coefficients.drag
605
+ cd_total = np.asarray(drag.total[:, 0]).reshape(-1)
606
+ pen, brush, symbol = self._segment_style(i, plot_parameters)
607
+ for name, chain in self._DRAG_COMPONENTS:
608
+ value = drag
609
+ try:
610
+ for key in chain:
611
+ value = getattr(value, key)
612
+ arr_np = np.asarray(value)
613
+ arr = arr_np[:, 0].reshape(-1) if arr_np.ndim > 1 else arr_np.reshape(-1)
614
+ except Exception:
615
+ # Missing drag component: plot zeros for that component.
616
+ arr = np.zeros_like(cd_total)
617
+ label = name if i == 0 else None
618
+ widget.plot(time, arr, pen=pen, symbol=symbol, symbolBrush=brush, symbolSize=plot_parameters.marker_size, name=label)
619
+
620
+ def _render_lift_distribution_pg(self, results, plot_parameters):
621
+ # Plot final spanwise lift distribution for each segment.
622
+ if not self._has_attr_chain(
623
+ results.segments[0].conditions,
624
+ ["aerodynamics", "coefficients", "lift", "inviscid", "spanwise"]
625
+ ):
626
+ # This plot needs spanwise lift data; skip when not available.
627
+ raise RuntimeError("no spanwise lift data")
628
+ widget = self._new_plot_widget("Lift Distribution", "Spanwise Lift Coefficient", "Span Index", show_legend=True)
629
+ for i, segment in enumerate(results.segments):
630
+ spanwise = np.asarray(segment.conditions.aerodynamics.coefficients.lift.inviscid.spanwise)
631
+ y = np.asarray(spanwise[-1]).reshape(-1)
632
+ x = np.arange(y.size)
633
+ pen, brush, symbol = self._segment_style(i, plot_parameters)
634
+ tag = segment.tag.replace("_", " ")
635
+ widget.plot(x, y, pen=pen, symbol=symbol, symbolBrush=brush, symbolSize=plot_parameters.marker_size, name=tag)
636
+
637
+ def _render_rotor_conditions_pg(self, results, plot_parameters):
638
+ # Plot rotor disc loading and power loading.
639
+ U = self._units()
640
+ seg0 = results.segments[0]
641
+ if not self._has_attr_chain(seg0.conditions, ["energy", "converters"]):
642
+ # This plot needs converter data; skip when not available.
643
+ raise RuntimeError("no rotor converter data")
644
+ dl = self._new_plot_widget("Rotor Conditions: Disc Loading", "Disc Loading", show_legend=True)
645
+ pl = self._new_plot_widget("Rotor Conditions: Power Loading", "Power Loading", show_legend=False)
646
+ for i, segment in enumerate(results.segments):
647
+ converters = segment.conditions.energy.converters
648
+ if hasattr(converters, "keys"):
649
+ tag = next(iter(converters.keys()))
650
+ conv = converters[tag]
651
+ else:
652
+ tags = [name for name in dir(converters) if not name.startswith("_")]
653
+ tag = tags[0]
654
+ conv = getattr(converters, tag)
655
+ if not hasattr(conv, "disc_loading") or not hasattr(conv, "power_loading"):
656
+ # Skip when converter metrics are incomplete.
657
+ raise RuntimeError("no disc/power loading data")
658
+ time = segment.conditions.frames.inertial.time[:, 0] / U.min
659
+ pen, brush, symbol = self._segment_style(i, plot_parameters)
660
+ name = segment.tag.replace("_", " ")
661
+ dl.plot(time, np.asarray(conv.disc_loading[:, 0]).reshape(-1), pen=pen, symbol=symbol, symbolBrush=brush, symbolSize=plot_parameters.marker_size, name=name)
662
+ pl.plot(time, np.asarray(conv.power_loading[:, 0]).reshape(-1), pen=pen, symbol=symbol, symbolBrush=brush, symbolSize=plot_parameters.marker_size, name=name)
663
+
664
+ def _render_altitude_sfc_weight_pg(self, results, plot_parameters):
665
+ # Plot weight, fuel burn, SFC, and fuel flow.
666
+ U = self._units()
667
+ seg0 = results.segments[0]
668
+ if not self._has_attr_chain(seg0.conditions, ["weights", "total_mass"]):
669
+ # Skip when weight history is missing.
670
+ raise RuntimeError("no total_mass data")
671
+ if not self._has_attr_chain(seg0.conditions, ["weights", "vehicle_mass_rate"]):
672
+ # Skip when mass-rate (fuel flow) is missing.
673
+ raise RuntimeError("no vehicle_mass_rate data")
674
+ if not self._has_attr_chain(seg0.conditions, ["energy", "cumulative_fuel_consumption"]):
675
+ # Skip when cumulative fuel-burn data is missing.
676
+ raise RuntimeError("no cumulative_fuel_consumption data")
677
+ self._render_time_series_group(results, plot_parameters, [
678
+ ("Weight", "Weight (lbf)", lambda s: (s.conditions.weights.total_mass[:, 0] * 9.81) / U.lbf, True),
679
+ ("Fuel Burn", "Fuel Burn (lb)", lambda s: s.conditions.energy.cumulative_fuel_consumption[:, 0] / U.lb, False),
680
+ ("SFC", "SFC", lambda s: ((s.conditions.weights.vehicle_mass_rate[:, 0] / U.lb) / np.clip(np.abs(s.conditions.frames.body.thrust_force_vector[:, 0]) / U.lbf, 1e-9, None)), False),
681
+ ("Fuel Flow", "mdot (lb/s)", lambda s: s.conditions.weights.vehicle_mass_rate[:, 0] / U.lb, False),
682
+ ])
683
+
684
+ def _render_propulsor_throttles_pg(self, results, plot_parameters):
685
+ # Plot throttle traces for each propulsor.
686
+ U = self._units()
687
+ widget = self._new_plot_widget("Propulsor Throttles", "Throttle", show_legend=True)
688
+ for i, segment in enumerate(results.segments):
689
+ time = segment.conditions.frames.inertial.time[:, 0] / U.min
690
+ pen, brush, symbol = self._segment_style(i, plot_parameters)
691
+ for prop_tag, prop_data in segment.conditions.energy.propulsors.items():
692
+ y = np.asarray(prop_data.throttle[:, 0]).reshape(-1)
693
+ label = f"{segment.tag.replace('_', ' ')}: {prop_tag}"
694
+ widget.plot(time, y, pen=pen, symbol=symbol, symbolBrush=brush, symbolSize=plot_parameters.marker_size, name=label)
695
+
696
+ def _render_flight_conditions_pg(self, results, plot_parameters):
697
+ # Plot altitude, airspeed, and range.
698
+ U = self._units()
699
+ self._render_time_series_group(results, plot_parameters, [
700
+ ("Flight Conditions: Altitude", "Altitude (ft)", lambda s: s.conditions.freestream.altitude[:, 0] / U.feet, True),
701
+ ("Flight Conditions: Airspeed", "Airspeed (mph)", lambda s: s.conditions.freestream.velocity[:, 0] / U["mph"], False),
702
+ ("Flight Conditions: Range", "Range (nmi)", lambda s: s.conditions.frames.inertial.aircraft_range[:, 0] / U.nmi, False),
703
+ ])
704
+
705
+ def _render_flight_trajectory_pg(self, results, plot_parameters):
706
+ # Plot trajectory as range-time, XY, and altitude-time.
707
+ U = self._units()
708
+ tr = self._new_plot_widget("Flight Trajectory: Range vs Time", "Range (nmi)", show_legend=True)
709
+ xy = self._new_plot_widget("Flight Trajectory: Y vs X", "Y", "X", show_legend=False)
710
+ alt = self._new_plot_widget("Flight Trajectory: Altitude", "Altitude (m)", show_legend=False)
711
+ for segment, time, pen, brush, symbol, tag in self._iter_segments_with_style(results, plot_parameters):
712
+ rng = segment.conditions.frames.inertial.aircraft_range[:, 0] / U.nmi
713
+ x = segment.conditions.frames.inertial.position_vector[:, 0]
714
+ y = segment.conditions.frames.inertial.position_vector[:, 1]
715
+ z = -segment.conditions.frames.inertial.position_vector[:, 2]
716
+ tr.plot(time, np.asarray(rng).reshape(-1), pen=pen, symbol=symbol, symbolBrush=brush, symbolSize=plot_parameters.marker_size, name=tag)
717
+ xy.plot(np.asarray(x).reshape(-1), np.asarray(y).reshape(-1), pen=pen, symbol=symbol, symbolBrush=brush, symbolSize=plot_parameters.marker_size, name=tag)
718
+ alt.plot(time, np.asarray(z).reshape(-1), pen=pen, symbol=symbol, symbolBrush=brush, symbolSize=plot_parameters.marker_size, name=tag)
719
+
720
+ def _render_flight_forces_moments_pg(self, results, plot_parameters):
721
+ # Plot wind-axis forces and moments.
722
+ self._render_time_series_group(results, plot_parameters, [
723
+ ("X Force", "X (N)", lambda s: s.conditions.frames.wind.total_force_vector[:, 0], True),
724
+ ("Y Force", "Y (N)", lambda s: s.conditions.frames.wind.total_force_vector[:, 1], False),
725
+ ("Z Force", "Z (N)", lambda s: s.conditions.frames.wind.total_force_vector[:, 2], False),
726
+ ("Roll Moment", "L", lambda s: s.conditions.frames.wind.total_moment_vector[:, 0], False),
727
+ ("Pitch Moment", "M", lambda s: s.conditions.frames.wind.total_moment_vector[:, 1], False),
728
+ ("Yaw Moment", "N", lambda s: s.conditions.frames.wind.total_moment_vector[:, 2], False),
729
+ ])
730
+
731
+ def _render_longitudinal_stability_pg(self, results, plot_parameters):
732
+ # Plot longitudinal stability metrics.
733
+ U = self._units()
734
+ self._render_time_series_group(results, plot_parameters, [
735
+ ("Longitudinal: Cm", "Cm", lambda s: s.conditions.static_stability.coefficients.M[:, 0], True),
736
+ ("Longitudinal: Static Margin", "SM", lambda s: s.conditions.static_stability.static_margin[:, 0], False),
737
+ ("Longitudinal: Elevator", "delta_e (deg)", lambda s: s.conditions.control_surfaces.elevator.deflection[:, 0] / U.deg, False),
738
+ ])
739
+
740
+ def _render_lateral_stability_pg(self, results, plot_parameters):
741
+ # Plot lateral-directional stability metrics.
742
+ U = self._units()
743
+ self._render_time_series_group(results, plot_parameters, [
744
+ ("Lateral: Bank Angle", "phi (deg)", lambda s: -s.conditions.aerodynamics.angles.phi[:, 0] / U.deg, True),
745
+ ("Lateral: Aileron", "delta_a (deg)", lambda s: s.conditions.control_surfaces.aileron.deflection[:, 0] / U.deg, False),
746
+ ("Lateral: Rudder", "delta_r (deg)", lambda s: s.conditions.control_surfaces.rudder.deflection[:, 0] / U.deg, False),
747
+ ])
748
+
749
+ def _remove_empty_dynamic_plots(self, start_index=0):
750
+ # Remove new dynamic plots that ended up with no curves.
751
+ kept = []
752
+ removed = []
753
+ for idx, widget in enumerate(self._dynamic_plot_widgets):
754
+ # Keep old plots and any plot that has data items.
755
+ if idx < start_index or widget.listDataItems():
756
+ kept.append(widget)
757
+ else:
758
+ removed.append(widget)
759
+ self._dynamic_plot_widgets = kept
760
+ self._delete_plot_widgets(removed)
761
+
762
+ def _build_plot_parameters(self, results):
763
+ from RCAIDE.Framework.Core import Data
764
+ plot_parameters = Data()
765
+ plot_parameters.line_width = 5
766
+ plot_parameters.line_style = '-'
767
+ n = max(len(results.segments), 1)
768
+ plot_parameters.line_colors = np.array([_PARULA_CMAP(i / max(n - 1, 1)) for i in range(n)])
769
+ plot_parameters.marker_size = 8
770
+ plot_parameters.legend_font_size = 12
771
+ plot_parameters.axis_font_size = 14
772
+ plot_parameters.title_font_size = 18
773
+ plot_parameters.styles = {"color": "white", "font-size": "18px"}
774
+ plot_parameters.markers = ['o']
775
+ plot_parameters.color = 'white'
776
+ plot_parameters.show_grid = True
777
+ plot_parameters.save_figure = False
778
+ return plot_parameters
779
+
780
+ plot_options = {
781
+ "Aerodynamics": [
782
+ "Plot Aerodynamic Coefficients",
783
+ "Plot Aerodynamic Forces",
784
+ "Plot Drag Components",
785
+ "Plot Lift Distribution",
786
+ "Plot Rotor Disc Inflow",
787
+ "Plot Rotor Disc Performance",
788
+ "Plot Rotor Performance",
789
+ "Plot Disc and Power Loading",
790
+ "Plot Rotor Conditions",
791
+ ],
792
+ "Energy": [
793
+ "Plot Fuel Consumption",
794
+ "Plot Altitude SFC Weight",
795
+ "Plot Propulsor Throttles",
796
+ ],
797
+ "Mission": [
798
+ "Plot Aircraft Velocities",
799
+ "Plot Flight Conditions",
800
+ "Plot Flight Trajectory",
801
+ ],
802
+ "Stability": [
803
+ "Plot Flight Forces and Moments",
804
+ "Plot Longitudinal Stability",
805
+ "Plot Lateral Stability",
806
+ ],
807
+ }
808
+
809
+ def _summarize_solve_output(output):
810
+ # Return empty list if solver produced no output
811
+ if not output:
812
+ return []
813
+
814
+ # Store extracted warning messages
815
+ warnings = []
816
+
817
+ # Split solver output into individual lines
818
+ lines = output.splitlines()
819
+ idx = 0
820
+
821
+ # Iterate through all output lines
822
+ while idx < len(lines):
823
+ line = lines[idx].strip()
824
+
825
+ # Capture non-converged segment warnings
826
+ if "Segment did not converge" in line:
827
+ warnings.append(line)
828
+ idx += 1
829
+ continue
830
+
831
+ # Capture multi-line error messages
832
+ if line.startswith("Error Message:"):
833
+ idx += 1
834
+ msg_lines = []
835
+
836
+ # Collect error message details until solver moves on
837
+ while idx < len(lines):
838
+ nxt = lines[idx].strip()
839
+ if not nxt or nxt.startswith("Solving"):
840
+ break
841
+ msg_lines.append(nxt)
842
+ idx += 1
843
+
844
+ # Store the full error message as one warning
845
+ if msg_lines:
846
+ warnings.append("Error: " + " ".join(msg_lines))
847
+ continue
848
+
849
+ # Move to the next line
850
+ idx += 1
851
+
852
+ # Return all detected solver warnings
853
+ return warnings
854
+
855
+
856
+ def _warnings_already_reported(output):
857
+ # Return False if there is no solver output
858
+ if not output:
859
+ return False
860
+
861
+ # Check if warnings already appeared in solver output
862
+ return (
863
+ "Segment did not converge" in output
864
+ or "Error Message:" in output
865
+ or "Error:" in output
866
+ )
867
+
868
+ # ---------------------------------------
869
+ # SolveWidget Theming and Layout Polish
870
+ # ---------------------------------------
871
+ # --- Apply dark theme to SolveWidget ---
872
+ def _apply_solve_theme(self):
873
+ self.setStyleSheet("""
874
+ QWidget {
875
+ background-color: #0e141b;
876
+ color: #d6e1ff;
877
+ font-family: "Segoe UI", "Inter", sans-serif;
878
+ font-size: 12px;
879
+ }
880
+
881
+ QLabel {
882
+ color: #d6e1ff;
883
+ }
884
+ QPushButton {
885
+ background-color: #141c26;
886
+ border: 1px solid #223044;
887
+ border-radius: 6px;
888
+ padding: 6px 12px;
889
+ color: #9fb8ff;
890
+ }
891
+
892
+ QPushButton:hover {
893
+ background-color: #1b2635;
894
+ border-color: #4da3ff;
895
+ }
896
+
897
+ QPushButton:pressed {
898
+ background-color: #223044;
899
+ }
900
+
901
+ QTreeWidget {
902
+ background-color: #10161d;
903
+ border: 1px solid #1f2a36;
904
+ border-radius: 6px;
905
+ }
906
+
907
+ QTreeWidget::item {
908
+ padding: 6px;
909
+ }
910
+
911
+ QTreeWidget::item:selected {
912
+ background-color: #1c2633;
913
+ color: #4da3ff;
914
+ }
915
+
916
+ QHeaderView::section {
917
+ background-color: #0e141b;
918
+ color: #9fb8ff;
919
+ border: none;
920
+ padding: 6px;
921
+ }
922
+
923
+ QScrollArea {
924
+ border: none;
925
+ }
926
+
927
+ QCheckBox {
928
+ spacing: 8px;
929
+ }
930
+
931
+ QComboBox, QDoubleSpinBox {
932
+ background-color: #141c26;
933
+ border: 1px solid #223044;
934
+ border-radius: 4px;
935
+ padding: 4px;
936
+ }
937
+ """)
938
+
939
+ # --- Layout Polish ---
940
+ def _polish_solve_layout(self):
941
+ layout = self.layout()
942
+ if layout is None:
943
+ return
944
+
945
+ layout.setContentsMargins(14, 14, 14, 14)
946
+ layout.setSpacing(14)
947
+
948
+ # Left column (tree + solve button)
949
+ if layout.count() >= 1:
950
+ left = layout.itemAt(0).layout()
951
+ if left:
952
+ left.setSpacing(10)
953
+
954
+ # Center column (plots)
955
+ if layout.count() >= 2:
956
+ center = layout.itemAt(1).layout()
957
+ if center:
958
+ center.setSpacing(12)
959
+
960
+ # Right column (settings, if present)
961
+ if layout.count() >= 3:
962
+ right = layout.itemAt(2).layout()
963
+ if right:
964
+ right.setSpacing(12)
965
+
966
+ # --------------------------------------------------------------------------------------------------
967
+ # Graph Coloring and Styling
968
+ # --------------------------------------------------------------------------------------------------
969
+ def _apply_solve_graph_skin(self):
970
+ """
971
+ Apply a dark theme and styling to all pyqtgraph PlotWidgets within the SolveWidget.
972
+ This includes background color, grid lines, axis colors, and frame styling.
973
+ """
974
+
975
+ for attr_name in dir(self):
976
+ widget = getattr(self, attr_name, None)
977
+
978
+ # Only affect graph containers (not plot logic or data)
979
+ if not isinstance(widget, pg.PlotWidget):
980
+ continue
981
+
982
+ # Dark graph canvas
983
+ widget.setBackground("#0e141b")
984
+ plot_item = widget.getPlotItem()
985
+
986
+ # Subtle grid for readability
987
+ plot_item.showGrid(x=True, y=True, alpha=0.15)
988
+
989
+ # Axis line + label coloring
990
+ for axis_name in ("left", "bottom"):
991
+ axis = plot_item.getAxis(axis_name)
992
+ axis.setPen(pg.mkPen("#4da3ff"))
993
+ axis.setTextPen(pg.mkPen("#9fb8ff"))
994
+
995
+ # Frame around the graph viewport
996
+ plot_item.getViewBox().setBorder(pg.mkPen("#1f2a36"))
997
+
998
+ # ==================================================================================================
999
+ # Plot Settings (Appearance, Save, Visibility)
1000
+ # ==================================================================================================
1001
+ from PyQt6.QtWidgets import (
1002
+ QWidget, QVBoxLayout, QLabel, QPushButton,
1003
+ QCheckBox, QComboBox, QDoubleSpinBox,
1004
+ QFileDialog, QColorDialog
1005
+ )
1006
+ from PyQt6.QtCore import Qt
1007
+ import pyqtgraph as pg
1008
+ import pyqtgraph.exporters as pg_exporters
1009
+
1010
+ def init_plot_settings_panel(self):
1011
+
1012
+ # Main vertical layout for the settings panel
1013
+ layout = QVBoxLayout()
1014
+ layout.setSpacing(8)
1015
+
1016
+ # Helper function to create bold section labels
1017
+ def header(text):
1018
+ lbl = QLabel(text)
1019
+ lbl.setStyleSheet("font-weight: bold; color: white;")
1020
+ return lbl
1021
+
1022
+ # Line appearance section
1023
+ layout.addWidget(header("Line Appearance"))
1024
+
1025
+ # Control for adjusting line width
1026
+ layout.addWidget(QLabel("Line Width"))
1027
+ self.line_width_spin = QDoubleSpinBox()
1028
+ self.line_width_spin.setRange(0.5, 10.0)
1029
+ self.line_width_spin.setValue(2.0)
1030
+ layout.addWidget(self.line_width_spin)
1031
+
1032
+ # Control for selecting line style (solid, dashed, dotted)
1033
+ layout.addWidget(QLabel("Line Style"))
1034
+ self.line_style_combo = QComboBox()
1035
+ self.line_style_combo.addItems(["Solid", "Dashed", "Dotted"])
1036
+ layout.addWidget(self.line_style_combo)
1037
+
1038
+ # Button to open a color picker for the line color
1039
+ self.line_color_button = QPushButton("Select Line Color")
1040
+ layout.addWidget(self.line_color_button)
1041
+
1042
+ # Marker controls section
1043
+ layout.addWidget(header("Markers"))
1044
+
1045
+ # Toggle to show or hide markers on the lines
1046
+ self.marker_check = QCheckBox("Show Markers")
1047
+ self.marker_check.setChecked(True)
1048
+ layout.addWidget(self.marker_check)
1049
+
1050
+ # Dropdown to select marker symbol style
1051
+ layout.addWidget(QLabel("Marker Style"))
1052
+ self.marker_style_combo = QComboBox()
1053
+ self.marker_style_combo.addItems(['o', 's', '^', 'd', 'x', '+', '*'])
1054
+ layout.addWidget(self.marker_style_combo)
1055
+
1056
+ # Control for adjusting marker size
1057
+ layout.addWidget(QLabel("Marker Size"))
1058
+ self.marker_size_spin = QDoubleSpinBox()
1059
+ self.marker_size_spin.setRange(3, 20)
1060
+ self.marker_size_spin.setValue(8)
1061
+ layout.addWidget(self.marker_size_spin)
1062
+
1063
+ # Axis-related controls
1064
+ layout.addWidget(header("Axes"))
1065
+
1066
+ # Toggle to automatically scale axes based on data
1067
+ self.autoscale_check = QCheckBox("Autoscale Axes")
1068
+ self.autoscale_check.setChecked(True)
1069
+ layout.addWidget(self.autoscale_check)
1070
+
1071
+ # Control for axis label and tick font size
1072
+ layout.addWidget(QLabel("Axis Font Size"))
1073
+ self.axis_font_spin = QDoubleSpinBox()
1074
+ self.axis_font_spin.setRange(8, 24)
1075
+ self.axis_font_spin.setValue(14)
1076
+ layout.addWidget(self.axis_font_spin)
1077
+
1078
+ # Grid and legend controls
1079
+ layout.addWidget(header("Grid / Legend"))
1080
+
1081
+ # Toggle to show or hide the grid
1082
+ self.grid_check = QCheckBox("Show Grid")
1083
+ self.grid_check.setChecked(True)
1084
+ layout.addWidget(self.grid_check)
1085
+
1086
+ # Button to open a color picker for the grid color
1087
+ self.grid_color_button = QPushButton("Select Grid Color")
1088
+ layout.addWidget(self.grid_color_button)
1089
+
1090
+ # Toggle to show or hide the legend
1091
+ self.legend_check = QCheckBox("Show Legend")
1092
+ self.legend_check.setChecked(True)
1093
+ layout.addWidget(self.legend_check)
1094
+
1095
+ # Export section
1096
+ layout.addWidget(header("Export"))
1097
+
1098
+ # Button to save the currently visible plot(s)
1099
+ self.save_plot_button = QPushButton("Save Plots")
1100
+ self.save_plot_button.clicked.connect(self.save_current_plot)
1101
+ layout.addWidget(self.save_plot_button)
1102
+
1103
+ # Push everything up and keep the panel compact
1104
+ layout.addStretch()
1105
+
1106
+ # Attach the layout to the settings panel widget
1107
+ self.settings_panel.setLayout(layout)
1108
+
1109
+ # Default values used when applying plot settings
1110
+ self.selected_line_color = None
1111
+ self.selected_grid_color = (150, 150, 150)
1112
+
1113
+ # Wire UI changes to re-apply plot appearance settings
1114
+ self.line_width_spin.valueChanged.connect(self.apply_plot_settings)
1115
+ self.marker_size_spin.valueChanged.connect(self.apply_plot_settings)
1116
+ self.axis_font_spin.valueChanged.connect(self.apply_plot_settings)
1117
+
1118
+ self.line_style_combo.currentIndexChanged.connect(self.apply_plot_settings)
1119
+ self.marker_style_combo.currentIndexChanged.connect(self.apply_plot_settings)
1120
+
1121
+ self.marker_check.stateChanged.connect(self.apply_plot_settings)
1122
+ self.autoscale_check.stateChanged.connect(self.apply_plot_settings)
1123
+ self.grid_check.stateChanged.connect(self.apply_plot_settings)
1124
+ self.legend_check.stateChanged.connect(self.apply_plot_settings)
1125
+
1126
+ # Open color picker dialogs for line and grid colors
1127
+ self.line_color_button.clicked.connect(self.select_line_color)
1128
+ self.grid_color_button.clicked.connect(self.select_grid_color)
1129
+
1130
+ # --- Line Color Selection ---
1131
+ def select_line_color(self):
1132
+ # Open a color picker dialog for selecting the line color
1133
+ color = QColorDialog.getColor()
1134
+
1135
+ # Only apply the color if the user selected a valid one
1136
+ if color.isValid():
1137
+ # Store the selected line color (as a hex string)
1138
+ self.selected_line_color = color.name()
1139
+
1140
+ # Re-apply plot appearance settings using the new color
1141
+ self.apply_plot_settings()
1142
+
1143
+ # --- Grid Color Selection ---
1144
+ def select_grid_color(self):
1145
+ # Open a color picker dialog for selecting the grid color
1146
+ color = QColorDialog.getColor()
1147
+
1148
+ # Only apply the color if the user selected a valid one
1149
+ if color.isValid():
1150
+ # Store the selected grid color as an RGB tuple
1151
+ self.selected_grid_color = color.getRgb()[:3]
1152
+
1153
+ # Re-apply plot appearance settings using the new color
1154
+ self.apply_plot_settings()
1155
+
1156
+ # --------------------------------------------------------------------------------------------------
1157
+ # Apply Plot Settings
1158
+ # --------------------------------------------------------------------------------------------------
1159
+ def apply_plot_settings(self):
1160
+
1161
+ # Collect all plot widgets (base + dynamic + fallback attribute scan)
1162
+ plots = []
1163
+ if hasattr(self, "_base_plot_widgets"):
1164
+ plots.extend([p for p in self._base_plot_widgets if isinstance(p, pg.PlotWidget)])
1165
+ if hasattr(self, "_dynamic_plot_widgets"):
1166
+ plots.extend([p for p in self._dynamic_plot_widgets if isinstance(p, pg.PlotWidget)])
1167
+ plots.extend([
1168
+ getattr(self, name, None) for name in dir(self)
1169
+ if isinstance(getattr(self, name, None), pg.PlotWidget)
1170
+ ])
1171
+
1172
+ # De-duplicate while preserving order.
1173
+ seen = set()
1174
+ unique_plots = []
1175
+ for plot in plots:
1176
+ pid = id(plot)
1177
+ if pid in seen:
1178
+ continue
1179
+ seen.add(pid)
1180
+ unique_plots.append(plot)
1181
+
1182
+ for plot in unique_plots:
1183
+
1184
+ # Show or hide grid lines based on the checkbox state
1185
+ plot.showGrid(
1186
+ x=self.grid_check.isChecked(),
1187
+ y=self.grid_check.isChecked(),
1188
+ alpha=0.3
1189
+ )
1190
+
1191
+ # Update axis line color to match selected grid color
1192
+ plot.getAxis("bottom").setPen(self.selected_grid_color)
1193
+ plot.getAxis("left").setPen(self.selected_grid_color)
1194
+
1195
+ # Autoscale axes to fit the data when enabled
1196
+ if self.autoscale_check.isChecked():
1197
+ viewbox = plot.getPlotItem().getViewBox()
1198
+ viewbox.enableAutoRange(x=True, y=True)
1199
+ viewbox.autoRange()
1200
+
1201
+ # Update axis tick label font size
1202
+ font = pg.QtGui.QFont()
1203
+ font.setPointSizeF(self.axis_font_spin.value())
1204
+ plot.getAxis("bottom").setTickFont(font)
1205
+ plot.getAxis("left").setTickFont(font)
1206
+
1207
+ # Show or hide the legend
1208
+ if self.legend_check.isChecked():
1209
+ if not plot.plotItem.legend:
1210
+ plot.addLegend()
1211
+ if hasattr(self, "_position_plot_legend"):
1212
+ self._position_plot_legend(plot)
1213
+ plot.plotItem.legend.show()
1214
+ else:
1215
+ if plot.plotItem.legend:
1216
+ plot.plotItem.legend.hide()
1217
+
1218
+ # Apply line and marker settings to each curve in the plot
1219
+ for curve in plot.listDataItems():
1220
+
1221
+ # Get the existing pen for the curve
1222
+ old_pen = curve.opts["pen"]
1223
+
1224
+ # Determine line style from dropdown selection
1225
+ style = self.line_style_combo.currentText()
1226
+ if style == "Dashed":
1227
+ pen_style = Qt.PenStyle.DashLine
1228
+ elif style == "Dotted":
1229
+ pen_style = Qt.PenStyle.DotLine
1230
+ else:
1231
+ pen_style = Qt.PenStyle.SolidLine
1232
+
1233
+ # Use selected color if provided, otherwise keep existing color
1234
+ color = self.selected_line_color or old_pen.color()
1235
+
1236
+ # Create a new pen with updated style and width
1237
+ new_pen = pg.mkPen(
1238
+ color=color,
1239
+ width=self.line_width_spin.value(),
1240
+ style=pen_style
1241
+ )
1242
+
1243
+ # Sanitize marker symbol first so setPen can't fail on stale invalid symbols.
1244
+ if self.marker_check.isChecked():
1245
+ symbol_map = {
1246
+ '^': 't1',
1247
+ 'v': 't',
1248
+ '<': 't3',
1249
+ '>': 't2',
1250
+ # pyqtgraph uses "star" (not "*") for star markers.
1251
+ '*': 'star',
1252
+ }
1253
+ valid_symbols = {'o', 's', 't', 't1', 't2', 't3', 'd', '+', 'x', 'p', 'h', 'star', '|', '_'}
1254
+ selected = self.marker_style_combo.currentText()
1255
+ marker_symbol = symbol_map.get(selected, selected)
1256
+ if marker_symbol not in valid_symbols:
1257
+ marker_symbol = 'o'
1258
+ curve.setSymbol(marker_symbol)
1259
+ curve.setSymbolSize(self.marker_size_spin.value())
1260
+ curve.setSymbolBrush(new_pen.color())
1261
+ curve.setSymbolPen(new_pen)
1262
+ else:
1263
+ curve.setSymbol(None)
1264
+
1265
+ # Apply the new pen to the curve
1266
+ curve.setPen(new_pen)
1267
+
1268
+ # --------------------------------------------------------------------------------------------------
1269
+ # Save Visible Plot
1270
+ # --------------------------------------------------------------------------------------------------
1271
+ def save_current_plot(self):
1272
+ from PyQt6.QtWidgets import QFileDialog, QApplication
1273
+
1274
+ def _plot_has_real_data(plot_widget):
1275
+ # Export only plots that actually contain finite data points.
1276
+ for curve in plot_widget.listDataItems():
1277
+ try:
1278
+ x_data, y_data = curve.getData()
1279
+ except Exception:
1280
+ continue
1281
+ if x_data is None or y_data is None:
1282
+ continue
1283
+ x = np.asarray(x_data).reshape(-1)
1284
+ y = np.asarray(y_data).reshape(-1)
1285
+ if x.size == 0 or y.size == 0:
1286
+ continue
1287
+ if np.isfinite(x).any() and np.isfinite(y).any():
1288
+ return True
1289
+ return False
1290
+
1291
+ # Collect visible plots in display order.
1292
+ plots = []
1293
+ if hasattr(self, "_base_plot_widgets"):
1294
+ plots.extend([p for p in self._base_plot_widgets if isinstance(p, pg.PlotWidget)])
1295
+ if hasattr(self, "_dynamic_plot_widgets"):
1296
+ plots.extend([p for p in self._dynamic_plot_widgets if isinstance(p, pg.PlotWidget)])
1297
+ plots = [p for p in plots if p.isVisible() and _plot_has_real_data(p)]
1298
+
1299
+ if not plots:
1300
+ QMessageBox.information(self, "Save Plots", "No visible plots with data to save.")
1301
+ return
1302
+
1303
+ # Choose parent directory where a new plots folder will be created.
1304
+ parent_dir = QFileDialog.getExistingDirectory(
1305
+ self,
1306
+ "Choose Folder to Save Mission Plots",
1307
+ os.getcwd(),
1308
+ )
1309
+ if not parent_dir:
1310
+ return
1311
+
1312
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
1313
+ export_dir = os.path.join(parent_dir, f"Mission Plots {timestamp}")
1314
+ os.makedirs(export_dir, exist_ok=True)
1315
+
1316
+ def _sanitize_name(text):
1317
+ text = re.sub(r"<[^>]*>", "", str(text)).strip()
1318
+ text = re.sub(r"[^A-Za-z0-9._ -]", "_", text)
1319
+ text = re.sub(r"\s+", "_", text).strip("_")
1320
+ return text or "plot"
1321
+
1322
+ for idx, plot in enumerate(plots, start=1):
1323
+ plot_item = plot.getPlotItem()
1324
+ if plot_item is None:
1325
+ continue
1326
+
1327
+ left_axis = plot_item.getAxis("left")
1328
+ bottom_axis = plot_item.getAxis("bottom")
1329
+
1330
+ # Preserve current styling so we can restore after exporting.
1331
+ old_bg = plot.backgroundBrush()
1332
+ old_bottom_pen = bottom_axis.pen()
1333
+ old_left_pen = left_axis.pen()
1334
+ old_bottom_text_pen = bottom_axis.textPen()
1335
+ old_left_text_pen = left_axis.textPen()
1336
+ old_border = plot_item.getViewBox().border
1337
+ old_left_show_values = left_axis.style.get("showValues", True)
1338
+ old_bottom_show_values = bottom_axis.style.get("showValues", True)
1339
+ old_left_width = left_axis.width()
1340
+
1341
+ # Ensure axes/labels remain visible on white export background.
1342
+ plot.setBackground("white")
1343
+ bottom_axis.setPen(pg.mkPen("black"))
1344
+ left_axis.setPen(pg.mkPen("black"))
1345
+ bottom_axis.setTextPen(pg.mkPen("black"))
1346
+ left_axis.setTextPen(pg.mkPen("black"))
1347
+ # Force tick value labels to render and reserve width on the left axis.
1348
+ left_axis.setStyle(showValues=True, autoExpandTextSpace=True)
1349
+ bottom_axis.setStyle(showValues=True, autoExpandTextSpace=True)
1350
+ left_axis.setWidth(max(int(old_left_width or 0), 75))
1351
+ plot_item.getViewBox().setBorder(pg.mkPen("black"))
1352
+
1353
+ # Ensure very light curves are visible on white export background.
1354
+ curve_state = []
1355
+ for curve in plot.listDataItems():
1356
+ old_pen = curve.opts.get("pen")
1357
+ old_symbol_pen = curve.opts.get("symbolPen")
1358
+ old_symbol_brush = curve.opts.get("symbolBrush")
1359
+ curve_state.append((curve, old_pen, old_symbol_pen, old_symbol_brush))
1360
+
1361
+ pen_color = old_pen.color() if old_pen is not None else None
1362
+ if pen_color is not None and pen_color.lightness() > 220:
1363
+ export_pen = pg.mkPen("black", width=old_pen.widthF() if hasattr(old_pen, "widthF") else 2)
1364
+ curve.setPen(export_pen)
1365
+ if curve.opts.get("symbol") is not None:
1366
+ curve.setSymbolPen(export_pen)
1367
+ curve.setSymbolBrush(pg.mkBrush("black"))
1368
+
1369
+ plot.repaint()
1370
+ QApplication.processEvents()
1371
+
1372
+ title = plot_item.titleLabel.text if plot_item.titleLabel else ""
1373
+ base_name = _sanitize_name(title) if title else f"plot_{idx:02d}"
1374
+ file_path = os.path.join(export_dir, f"{idx:02d}_{base_name}.png")
1375
+
1376
+ # Capture exactly what the user sees in the plot widget.
1377
+ plot.grab().save(file_path, "PNG")
1378
+
1379
+ # Restore UI styling.
1380
+ plot.setBackground(old_bg)
1381
+ bottom_axis.setPen(old_bottom_pen)
1382
+ left_axis.setPen(old_left_pen)
1383
+ bottom_axis.setTextPen(old_bottom_text_pen)
1384
+ left_axis.setTextPen(old_left_text_pen)
1385
+ left_axis.setStyle(showValues=old_left_show_values, autoExpandTextSpace=True)
1386
+ bottom_axis.setStyle(showValues=old_bottom_show_values, autoExpandTextSpace=True)
1387
+ if old_left_width:
1388
+ left_axis.setWidth(old_left_width)
1389
+ plot_item.getViewBox().setBorder(old_border if old_border is not None else pg.mkPen(None))
1390
+ for curve, old_pen, old_symbol_pen, old_symbol_brush in curve_state:
1391
+ if old_pen is not None:
1392
+ curve.setPen(old_pen)
1393
+ curve.setSymbolPen(old_symbol_pen)
1394
+ curve.setSymbolBrush(old_symbol_brush)
1395
+
1396
+ QMessageBox.information(self, "Save Plots", f"Saved {len(plots)} plots to:\n{export_dir}")
1397
+
1398
+ # --------------------------------------------------------------------------------------------------
1399
+ # Plot Visibility Toggle (Tree)
1400
+ # --------------------------------------------------------------------------------------------------
1401
+ def toggle_plot_visibility(self, item, column):
1402
+
1403
+ # Ignore category items; only act on leaf (actual plot) items
1404
+ if item.childCount() > 0:
1405
+ return
1406
+
1407
+ # Re-render selected plots based on current tree state (debounced).
1408
+ import rcaide_io
1409
+ results = getattr(rcaide_io, "rcaide_results", None)
1410
+ if results is not None and hasattr(self, "_schedule_plot_render"):
1411
+ self._schedule_plot_render()
1412
+
1413
+ def init_plot_options_panel(self):
1414
+ # Create the container widget for plot options
1415
+ panel = QWidget()
1416
+
1417
+ # Vertical layout for the panel
1418
+ layout = QVBoxLayout(panel)
1419
+ layout.setContentsMargins(8, 8, 8, 8)
1420
+ layout.setSpacing(8)
1421
+
1422
+ # Panel title
1423
+ title = QLabel("Plot Options")
1424
+ title.setStyleSheet("font-weight: bold; color: white;")
1425
+ layout.addWidget(title)
1426
+
1427
+ # Add the plot visibility tree if it exists
1428
+ if hasattr(self, "tree"):
1429
+ layout.addWidget(self.tree)
1430
+
1431
+ # Push content to the top
1432
+ layout.addStretch()
1433
+
1434
+ return panel
1435
+
1436
+ #------------------------------------------------
1437
+ # Patch SolveWidget to integrate new features
1438
+ #------------------------------------------------
1439
+ # Prevents "stacked patch" recursion if the module is reloaded/imported twice.
1440
+ if not getattr(SolveWidget, "_LEADS_PATCHED", False):
1441
+ SolveWidget._LEADS_PATCHED = True
1442
+ _SOLVEWIDGET_BASE_INIT = SolveWidget.__init__
1443
+
1444
+ def _solvewidget_init(self, *args, **kwargs):
1445
+ # Run the original SolveWidget initialization
1446
+ _SOLVEWIDGET_BASE_INIT(self, *args, **kwargs)
1447
+
1448
+ # Apply Solve tab UI theme + spacing + plot skin
1449
+ _apply_solve_theme(self)
1450
+ _polish_solve_layout(self)
1451
+ _apply_solve_graph_skin(self)
1452
+
1453
+ # Create the plot settings panel (fixed width)
1454
+ self.settings_panel = QWidget()
1455
+ self.settings_panel.setFixedWidth(280)
1456
+
1457
+ # Add the settings panel as a 3rd column in the root layout
1458
+ layout = self.layout()
1459
+ if layout is not None:
1460
+ layout.addWidget(self.settings_panel)
1461
+
1462
+ # Reorder columns so: [settings_panel | plots | plot_options(tree)]
1463
+ if layout.count() >= 3:
1464
+ left_item = layout.takeAt(0) # tree
1465
+ center_item = layout.takeAt(0) # graphs
1466
+ right_item = layout.takeAt(0) # settings
1467
+
1468
+ layout.addItem(right_item) # settings on left
1469
+ layout.addItem(center_item) # graphs in middle
1470
+ layout.addItem(left_item) # plot options tree on right
1471
+
1472
+ # Middle expands; sides stay compact
1473
+ layout.setStretch(0, 1)
1474
+ layout.setStretch(1, 4)
1475
+ layout.setStretch(2, 2)
1476
+
1477
+ # Build the settings UI into the panel
1478
+ init_plot_settings_panel(self)
1479
+
1480
+ # Wire the tree checkbox to visibility toggles
1481
+ if hasattr(self, "tree"):
1482
+ self.tree.itemChanged.connect(self.toggle_plot_visibility)
1483
+
1484
+ # Replace SolveWidget.__init__ with the extended version
1485
+ SolveWidget.__init__ = _solvewidget_init
1486
+
1487
+ # Attach helper methods to SolveWidget
1488
+ SolveWidget.apply_plot_settings = apply_plot_settings
1489
+ SolveWidget.save_current_plot = save_current_plot
1490
+ SolveWidget.toggle_plot_visibility = toggle_plot_visibility
1491
+ SolveWidget.select_line_color = select_line_color
1492
+ SolveWidget.select_grid_color = select_grid_color
1493
+ SolveWidget.init_plot_options_panel = init_plot_options_panel # optional utility
1494
+
1495
+ def get_widget() -> QWidget:
1496
+ # Factory used by the tab system to construct the SolveWidget
1497
+ return SolveWidget()
1498
+