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
@@ -0,0 +1,1124 @@
1
+ # RCAIDE_GUI/tabs/mission/mission.py
2
+
3
+ # RCAIDE imports
4
+ import RCAIDE
5
+
6
+ # RCAIDE-GUI imports
7
+ from tabs.mission.widgets import MissionSegmentWidget
8
+ from tabs.mission.widgets import MissionAnalysisWidget
9
+ from tabs import TabWidget
10
+ from tabs.aircraft_configs.aircraft_configs import AircraftConfigsWidget
11
+ import rcaide_io
12
+
13
+
14
+ # ------------------------------------------------------------------------------
15
+ # RCAIDE-format → GUI-format conversion helpers
16
+ # ------------------------------------------------------------------------------
17
+
18
+ def _get_nested_raw(d, path):
19
+ """Navigate a dotted attribute path in a nested dict.
20
+
21
+ Returns the raw value (which may still be a [value, units_index] pair),
22
+ or [False, 0] when the path cannot be resolved.
23
+ """
24
+ for part in path.split('.'):
25
+ if isinstance(d, dict):
26
+ d = d.get(part, None)
27
+ else:
28
+ return [False, 0]
29
+ if d is None:
30
+ return [False, 0]
31
+ return d if isinstance(d, (list, tuple)) else [d, 0]
32
+
33
+
34
+ def _rcaide_seg_to_gui(seg_dict):
35
+ """Convert one RCAIDE native segment dict (with unit-arg pairs) to GUI form data.
36
+
37
+ The RCAIDE format uses [value, units_index] for every leaf; the GUI's
38
+ DataEntryWidget.load_data expects the same pairs keyed by display label.
39
+ """
40
+ from tabs.mission.widgets.mission_segment_helper import (
41
+ segment_rcaide_classes, segment_data_fields,
42
+ )
43
+ from tabs.mission.widgets.flight_controls_widget import FlightControlsWidget
44
+
45
+ # Reverse-map __type__ string → (top_dropdown_index, sub_name)
46
+ type_str = seg_dict.get("__type__", "")
47
+ top_idx, sub_name = 0, ""
48
+ for ti, sub_dict in enumerate(segment_rcaide_classes):
49
+ for sn, cls in sub_dict.items():
50
+ if f"{cls.__module__}.{cls.__qualname__}" == type_str:
51
+ top_idx, sub_name = ti, sn
52
+ break
53
+ if sub_name:
54
+ break
55
+
56
+ # control_points is stored as [16, 0] — extract the scalar
57
+ cp_raw = seg_dict.get("control_points", [16, 0])
58
+ cp = int(cp_raw[0]) if isinstance(cp_raw, (list, tuple)) else int(cp_raw)
59
+
60
+ # Degrees-of-freedom flags
61
+ dof_labels = [
62
+ ("Forces in X axis", "flight_dynamics.force_x"),
63
+ ("Moments about X axis", "flight_dynamics.moment_x"),
64
+ ("Forces in Y axis", "flight_dynamics.force_y"),
65
+ ("Moments about Y axis", "flight_dynamics.moment_y"),
66
+ ("Forces in Z axis", "flight_dynamics.force_z"),
67
+ ("Moments about Z axis", "flight_dynamics.moment_z"),
68
+ ]
69
+ flight_forces = {
70
+ label: _get_nested_raw(seg_dict, path)
71
+ for label, path in dof_labels
72
+ }
73
+
74
+ # Flight-control active flags
75
+ flight_controls = {}
76
+ for group_fields in FlightControlsWidget.fields.values():
77
+ for label, _, rcaide_path in group_fields:
78
+ flight_controls[label] = _get_nested_raw(seg_dict, rcaide_path)
79
+
80
+ # Assigned propulsors for throttle — stored as [[[name1, name2]], 0] after unit-arg wrapping
81
+ try:
82
+ acv_raw = seg_dict.get("assigned_control_variables", {})
83
+ throttle_raw = acv_raw.get("throttle", {}) if isinstance(acv_raw, dict) else {}
84
+ ap_raw = throttle_raw.get("assigned_propulsors", []) if isinstance(throttle_raw, dict) else []
85
+ if isinstance(ap_raw, list) and len(ap_raw) == 2 and isinstance(ap_raw[1], int) and not isinstance(ap_raw[1], bool):
86
+ ap_raw = ap_raw[0] # strip unit-arg wrapper → [[name1, name2]]
87
+ if ap_raw and isinstance(ap_raw[0], list):
88
+ assigned_propulsors = ap_raw[0]
89
+ elif ap_raw and isinstance(ap_raw[0], str):
90
+ assigned_propulsors = list(ap_raw)
91
+ else:
92
+ assigned_propulsors = []
93
+ except Exception:
94
+ assigned_propulsors = []
95
+ flight_controls["assigned_propulsors"] = assigned_propulsors
96
+
97
+ gui_data = {
98
+ "Segment Name": seg_dict.get("tag", ""),
99
+ "top dropdown": top_idx,
100
+ "nested dropdown": sub_name,
101
+ "config": seg_dict.get("config_tag", ""),
102
+ "Control Points": cp,
103
+ "Solver": "root",
104
+ "flight forces": flight_forces,
105
+ "flight controls": flight_controls,
106
+ }
107
+
108
+ # Subsegment-specific parameter values (altitude, speed, etc.)
109
+ if sub_name and top_idx < len(segment_data_fields):
110
+ for label, _, rcaide_label in segment_data_fields[top_idx].get(sub_name, []):
111
+ gui_data[label] = seg_dict.get(rcaide_label, [0, 0])
112
+
113
+ return gui_data
114
+
115
+
116
+ def _extract_gui_segments(mission_data):
117
+ """Return a flat list of GUI-format segment dicts from mission_data.
118
+
119
+ Handles both formats:
120
+ - Old GUI format: list where each item has a "Segment Name" key
121
+ - New RCAIDE format: list of mission objects each containing a "segments" list
122
+ """
123
+ if not mission_data:
124
+ return []
125
+
126
+ first = mission_data[0] if mission_data else {}
127
+
128
+ # Old GUI format — items are already GUI dicts
129
+ if isinstance(first, dict) and "Segment Name" in first:
130
+ return list(mission_data)
131
+
132
+ # New RCAIDE format — extract and convert each segment from every mission
133
+ result = []
134
+ for mission_entry in mission_data:
135
+ if not isinstance(mission_entry, dict):
136
+ continue
137
+ for seg_dict in mission_entry.get("segments", []):
138
+ if isinstance(seg_dict, dict):
139
+ result.append(_rcaide_seg_to_gui(seg_dict))
140
+ return result
141
+
142
+ # PyQt imports
143
+ from PyQt6.QtCore import Qt, QTimer, QPointF
144
+ from PyQt6.QtGui import QColor, QPainter, QPen, QBrush, QFont, QPainterPath
145
+ from PyQt6.QtWidgets import (
146
+ QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QGroupBox, QLabel, QLineEdit,
147
+ QPushButton, QTreeWidget, QTreeWidgetItem, QScrollArea, QFrame, QTableWidget,
148
+ QTableWidgetItem, QHeaderView, QSizePolicy, QAbstractItemView
149
+ )
150
+
151
+ # ------------------------------------------------------------------------------
152
+ # Mission Profile Widget
153
+ # ------------------------------------------------------------------------------
154
+ class MissionProfileWidget(QWidget):
155
+ def __init__(self, parent=None):
156
+ super().__init__(parent)
157
+ # Constrain the profile card so it remains readable in compact layouts.
158
+ self.setMinimumHeight(220)
159
+ self.setMaximumHeight(320)
160
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
161
+ # Each phase entry is stored as (display_name, normalized_phase_type).
162
+ self.phases = []
163
+ # 0..1 progress used to animate the moving marker along the profile.
164
+ self._progress = 0.0
165
+ self._timer = QTimer(self)
166
+ self._timer.timeout.connect(self._advance_animation)
167
+ # 25 FPS-ish update cadence for lightweight motion.
168
+ self._timer.start(40)
169
+ self.setAutoFillBackground(False)
170
+
171
+ def set_phases(self, names):
172
+ normalized = []
173
+ for item in (names or []):
174
+ if isinstance(item, tuple) and len(item) == 2:
175
+ label, ptype = item
176
+ label_text = str(label).title()
177
+ # Re-classify using both type + label to avoid name-only misclassification.
178
+ normalized.append((label_text, self._phase_type(f"{ptype} {label_text}")))
179
+ else:
180
+ text = str(item).title()
181
+ normalized.append((text, self._phase_type(text)))
182
+ self.phases = normalized
183
+ # Restart marker animation whenever phases are regenerated.
184
+ self._progress = 0.0
185
+ self.update()
186
+
187
+ def _advance_animation(self):
188
+ if not self.phases or len(self.phases) < 2:
189
+ return
190
+ self._progress += 0.005
191
+ if self._progress > 1.0:
192
+ self._progress = 0.0
193
+ self.update()
194
+
195
+ @staticmethod
196
+ def _phase_type(name):
197
+ n = str(name).lower().replace("_", " ")
198
+ # Prefer explicit phase prefix from overview tuples (stable and deterministic).
199
+ if n.startswith("vertical climb"):
200
+ return "vertical_climb"
201
+ if n.startswith("vertical descent"):
202
+ return "vertical_descent"
203
+ if n.startswith("vertical hover"):
204
+ return "vertical_hover"
205
+ if n.startswith("single point"):
206
+ return "single_point"
207
+ if n.startswith("ground"):
208
+ return "ground"
209
+ if n.startswith("descent"):
210
+ return "descent"
211
+ if n.startswith("climb"):
212
+ return "climb"
213
+ if n.startswith("cruise"):
214
+ return "cruise"
215
+ if n.startswith("takeoff") or n.startswith("taxi"):
216
+ return "takeoff"
217
+ if "vertical" in n and "climb" in n:
218
+ return "vertical_climb"
219
+ if "vertical" in n and ("descent" in n or "descend" in n):
220
+ return "vertical_descent"
221
+ if "vertical" in n and "hover" in n:
222
+ return "vertical_hover"
223
+ if "single point" in n:
224
+ return "single_point"
225
+ if "ground" in n:
226
+ return "ground"
227
+ if "descent" in n or n.startswith("des"):
228
+ return "descent"
229
+ if "climb" in n:
230
+ return "climb"
231
+ if "cruise" in n:
232
+ return "cruise"
233
+ if "landing" in n or "land" in n or "approach" in n:
234
+ return "landing"
235
+ return "other"
236
+
237
+ def _phase_color(self, ptype):
238
+ colors = {
239
+ "takeoff": "#4caf50",
240
+ "climb": "#81c784",
241
+ "cruise": "#64b5f6",
242
+ "descent": "#ffb74d",
243
+ "landing": "#e57373",
244
+ "ground": "#8d6e63",
245
+ "single_point": "#ab47bc",
246
+ "vertical_climb": "#26a69a",
247
+ "vertical_descent": "#26a69a",
248
+ "vertical_hover": "#26a69a",
249
+ }
250
+ return QColor(colors.get(ptype, "#b0bec5"))
251
+
252
+ def paintEvent(self, event):
253
+ painter = QPainter(self)
254
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
255
+ outer = self.rect().adjusted(16, 14, -16, -14)
256
+ # Keep vertical/horizontal proportions consistent for a less flattened profile.
257
+ plot_h = int(outer.height() * 0.72)
258
+ rect = outer.adjusted(0, 0, 0, -(outer.height() - plot_h))
259
+ painter.fillRect(self.rect(), QColor("#050a12"))
260
+
261
+ painter.setPen(QPen(QColor("#1f2933"), 1, Qt.PenStyle.DotLine))
262
+ for i in range(1, 4):
263
+ y = rect.top() + i * rect.height() / 4
264
+ painter.drawLine(int(rect.left()), int(y), int(rect.right()), int(y))
265
+
266
+ if not self.phases:
267
+ painter.setPen(QColor("#8c9aa8"))
268
+ painter.setFont(QFont("Segoe UI", 10))
269
+ painter.drawText(
270
+ self.rect(),
271
+ Qt.AlignmentFlag.AlignCenter,
272
+ "Mission profile will appear here once you add segments.",
273
+ )
274
+ return
275
+
276
+ # Build a normalized altitude trace (0..1-ish in plot space) from phase types.
277
+ n = len(self.phases)
278
+ alt = []
279
+ cur = 0.15
280
+ for _, ptype in self.phases:
281
+ if ptype in ("takeoff", "climb"):
282
+ cur += 0.18
283
+ elif ptype == "vertical_climb":
284
+ cur += 0.24
285
+ elif ptype == "cruise":
286
+ cur += 0.0
287
+ elif ptype in ("descent", "landing"):
288
+ cur -= 0.18
289
+ elif ptype == "vertical_descent":
290
+ cur -= 0.24
291
+ elif ptype == "ground":
292
+ cur = 0.10
293
+ elif ptype in ("single_point", "vertical_hover"):
294
+ cur += 0.0
295
+ else:
296
+ cur += 0.05
297
+ cur = max(0.08, min(0.85, cur))
298
+ alt.append(cur)
299
+ # Seed the first point so the first segment starts from a realistic state.
300
+ first_ptype = self.phases[0][1] if self.phases else "other"
301
+ if first_ptype in ("takeoff", "climb"):
302
+ start_alt = 0.08
303
+ elif first_ptype == "vertical_climb":
304
+ # Ensure the first vertical climb draws as a visible segment, not a point.
305
+ start_alt = 0.10
306
+ elif first_ptype in ("descent", "landing", "vertical_descent"):
307
+ start_alt = 0.78
308
+ elif first_ptype == "ground":
309
+ start_alt = 0.10
310
+ elif first_ptype in ("single_point", "vertical_hover"):
311
+ start_alt = alt[0] if alt else 0.45
312
+ elif alt:
313
+ start_alt = alt[0]
314
+ else:
315
+ start_alt = 0.08
316
+ alt.insert(0, start_alt)
317
+
318
+ # Uniform x-spacing across segments.
319
+ dx = rect.width() / max(n, 1)
320
+ xs = [rect.left() + i * dx for i in range(n + 1)]
321
+
322
+ # Fixed runway-level y used for all ground segments.
323
+ ground_y = rect.bottom() - 0.10 * rect.height()
324
+ painter.setFont(QFont("Segoe UI", 8))
325
+ for i, (name, ptype) in enumerate(self.phases):
326
+ color = self._phase_color(ptype)
327
+ x1, x2 = xs[i], xs[i + 1]
328
+ y1 = rect.bottom() - alt[i] * rect.height()
329
+ y2 = rect.bottom() - alt[i + 1] * rect.height()
330
+ painter.setPen(QPen(color, 2.0))
331
+ cx = 0.5 * (x1 + x2)
332
+ if ptype == "ground":
333
+ # Ground is always shown as a constant-altitude line near runway level.
334
+ y1 = ground_y
335
+ y2 = ground_y
336
+ painter.drawLine(QPointF(x1, y1), QPointF(x2, y2))
337
+ elif ptype in ("vertical_climb", "vertical_descent", "vertical_hover"):
338
+ # Vertical phases are represented by vertical motion in the same x-slot.
339
+ painter.drawLine(QPointF(cx, y1), QPointF(cx, y2))
340
+ elif ptype == "single_point":
341
+ # Single-point phases are event markers instead of trajectory segments.
342
+ py = y2
343
+ painter.setBrush(QBrush(color))
344
+ painter.drawEllipse(QPointF(cx, py), 4.5, 4.5)
345
+ painter.setBrush(Qt.BrushStyle.NoBrush)
346
+ else:
347
+ seg_path = QPainterPath()
348
+ seg_path.moveTo(x1, y1)
349
+ seg_path.lineTo(x2, y2)
350
+ painter.drawPath(seg_path)
351
+
352
+ label_y = max(min(y1, y2) - 16, rect.top() + 4)
353
+ painter.setPen(QColor("#e8f2ff"))
354
+ painter.drawText(
355
+ int(cx - 55),
356
+ int(label_y),
357
+ 110,
358
+ 18,
359
+ Qt.AlignmentFlag.AlignCenter,
360
+ name,
361
+ )
362
+ painter.setPen(QPen(QColor("#3f5670"), 1.2))
363
+ painter.drawLine(
364
+ int(rect.left()),
365
+ int(rect.bottom()),
366
+ int(rect.right()),
367
+ int(rect.bottom()),
368
+ )
369
+
370
+ # Animate a marker over the currently active segment.
371
+ total_segments = n
372
+ t = self._progress * total_segments
373
+ idx = int(t)
374
+ frac = t - idx
375
+ if idx >= total_segments:
376
+ idx = total_segments - 1
377
+ frac = 1.0
378
+
379
+ x1, x2 = xs[idx], xs[idx + 1]
380
+ seg_ptype = self.phases[idx][1]
381
+ y1 = rect.bottom() - alt[idx] * rect.height()
382
+ y2 = rect.bottom() - alt[idx + 1] * rect.height()
383
+ # Keep the animated dot aligned with each segment's custom drawing rule.
384
+ if seg_ptype == "ground":
385
+ mx = x1 + (x2 - x1) * frac
386
+ my = ground_y
387
+ elif seg_ptype in ("vertical_climb", "vertical_descent", "vertical_hover"):
388
+ mx = 0.5 * (x1 + x2)
389
+ my = y1 + (y2 - y1) * frac
390
+ elif seg_ptype == "single_point":
391
+ mx = 0.5 * (x1 + x2)
392
+ my = y2
393
+ else:
394
+ mx = x1 + (x2 - x1) * frac
395
+ my = y1 + (y2 - y1) * frac
396
+ painter.setBrush(QBrush(QColor("#ffffff")))
397
+ painter.setPen(Qt.PenStyle.NoPen)
398
+ painter.drawEllipse(QPointF(mx, my), 4, 4)
399
+ painter.setPen(QPen(QColor("#64b5f6"), 1.2))
400
+ painter.setBrush(Qt.BrushStyle.NoBrush)
401
+ painter.drawEllipse(QPointF(mx, my), 7, 7)
402
+
403
+ # ============================================================
404
+ # Mission Summary Table
405
+ # ============================================================
406
+ class MissionSummaryTable(QTableWidget):
407
+ def __init__(self, mission_widget):
408
+ super().__init__(0, 6)
409
+ # Parent mission widget reference for reading segment state and UI navigation.
410
+ self.mission_widget = mission_widget
411
+
412
+ # Table columns summarizing each segment's key mission/solver metadata.
413
+ self.horizontalHeader().setMinimumSectionSize(56)
414
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
415
+ self.setHorizontalHeaderLabels(
416
+ [
417
+ "Segment",
418
+ "Type",
419
+ "Config",
420
+ "Ctrl Pts",
421
+ "Unknowns",
422
+ "Residuals",
423
+ ]
424
+ )
425
+ self.horizontalHeader().setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
426
+ self.horizontalHeader().setMinimumSectionSize(72)
427
+ self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
428
+ self.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
429
+ self.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
430
+ self.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
431
+ self.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
432
+ self.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
433
+ self.verticalHeader().setVisible(False)
434
+ self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
435
+ self.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
436
+ self.setWordWrap(False)
437
+ self.setTextElideMode(Qt.TextElideMode.ElideRight)
438
+ self.verticalHeader().setDefaultSectionSize(28)
439
+ self.setHorizontalScrollMode(QTableWidget.ScrollMode.ScrollPerPixel)
440
+ self.setVerticalScrollMode(QTableWidget.ScrollMode.ScrollPerPixel)
441
+ # Keep visual style consistent with the Mission tab theme.
442
+ self.setStyleSheet(
443
+ """
444
+ QTableWidget {
445
+ background-color:#060b13;
446
+ color:#e5f0ff;
447
+ border:1px solid #2d3a4e;
448
+ font-size:13px;
449
+ gridline-color:#23334b;
450
+ }
451
+ QHeaderView::section {
452
+ background-color:#111b2d;
453
+ color:#a8c6ff;
454
+ font-weight:600;
455
+ padding:3px;
456
+ }
457
+ QTableWidget::item:selected { background-color:#1b2a44; }
458
+ """
459
+ )
460
+ # Clicking a row focuses/open the corresponding segment detail panel.
461
+ self.cellClicked.connect(self._row_clicked)
462
+
463
+ @staticmethod
464
+ def _count_active(data):
465
+ # Count active boolean-style entries stored as `(enabled, value)` tuples.
466
+ if not isinstance(data, dict):
467
+ return 0
468
+ total = 0
469
+ for value in data.values():
470
+ if isinstance(value, tuple) and value and bool(value[0]):
471
+ total += 1
472
+ return total
473
+
474
+ @staticmethod
475
+ def _norm(name):
476
+ # Normalize config names for robust dictionary/list matching.
477
+ return str(name or "").strip().lower().replace(" ", "_")
478
+
479
+ def update_table(self):
480
+ # Rebuild the full table from current segment widgets.
481
+ segs = self.mission_widget.segment_widgets
482
+ if not segs and self.mission_widget.tree.topLevelItemCount() > 0:
483
+ # Tree exists but detail widgets are not yet available (loading/rebuild state).
484
+ self.clearSpans()
485
+ self.clearContents()
486
+ self.setRowCount(self.mission_widget.tree.topLevelItemCount())
487
+ for r in range(self.mission_widget.tree.topLevelItemCount()):
488
+ name = self.mission_widget.tree.topLevelItem(r).text(0)
489
+ values_row = [name, "-", "-", "-", "-", "-"]
490
+ for c, val in enumerate(values_row):
491
+ item = QTableWidgetItem(str(val))
492
+ item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
493
+ self.setItem(r, c, item)
494
+ row_h = self.rowHeight(0) if self.rowCount() else 26
495
+ visible_rows = max(4, min(self.rowCount(), 8))
496
+ self.setMinimumHeight(self.horizontalHeader().height() + (row_h * visible_rows) + 10)
497
+ return
498
+
499
+ if not segs:
500
+ # Explicit empty state before any segments are added.
501
+ self.clearContents()
502
+ self.setRowCount(1)
503
+ item = QTableWidgetItem("No segments added yet.")
504
+ item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
505
+ self.setItem(0, 0, item)
506
+ self.setSpan(0, 0, 1, 6)
507
+ row_h = max(self.rowHeight(0), 26)
508
+ self.setMinimumHeight(self.horizontalHeader().height() + (row_h * 4) + 10)
509
+ return
510
+
511
+ rows = []
512
+ for idx, seg in enumerate(segs):
513
+ # Resolve display name with fallback to tree text and generated default.
514
+ name = seg.segment_name_input.text().strip() if hasattr(seg, "segment_name_input") else ""
515
+ if not name and idx < self.mission_widget.tree.topLevelItemCount():
516
+ name = self.mission_widget.tree.topLevelItem(idx).text(0)
517
+ if not name:
518
+ name = f"Segment {idx + 1}"
519
+ sub_type = seg.nested_dropdown.currentText() if hasattr(seg, "nested_dropdown") else "Custom"
520
+ if sub_type == "Constant Speed/Constant Altitude":
521
+ sub_type = "Const Spd/Alt"
522
+ # Resolve config from selector text first, then index-based fallbacks.
523
+ config_name = ""
524
+ if hasattr(seg, "config_selector"):
525
+ config_name = seg.config_selector.currentText().strip()
526
+ if not config_name:
527
+ cfg_idx = seg.config_selector.currentIndex()
528
+ if cfg_idx >= 0:
529
+ config_name = seg.config_selector.itemText(cfg_idx).strip()
530
+ if not config_name:
531
+ cfgs = getattr(values, "rcaide_configs", None)
532
+ if isinstance(cfgs, dict) and cfg_idx >= 0:
533
+ keys = list(cfgs.keys())
534
+ if cfg_idx < len(keys):
535
+ config_name = str(keys[cfg_idx]).strip()
536
+ if not config_name and hasattr(seg, "_default_config_key"):
537
+ try:
538
+ # Final fallback: infer config from segment semantics.
539
+ cfg_key = seg._default_config_key(
540
+ seg.top_dropdown.currentText() if hasattr(seg, "top_dropdown") else "",
541
+ seg.nested_dropdown.currentText() if hasattr(seg, "nested_dropdown") else "",
542
+ name,
543
+ )
544
+ cfgs = getattr(values, "rcaide_configs", None)
545
+ if isinstance(cfgs, dict):
546
+ for key in cfgs.keys():
547
+ if self._norm(key) == self._norm(cfg_key):
548
+ config_name = str(key)
549
+ break
550
+ except Exception:
551
+ pass
552
+ if not config_name:
553
+ config_name = "Base"
554
+ cp = seg.ctrl_points.text().strip() if hasattr(seg, "ctrl_points") else "16"
555
+ # Unknowns/residuals are active-control and active-force counts.
556
+ controls_data = {}
557
+ if hasattr(seg, "flight_controls_widget") and seg.flight_controls_widget:
558
+ try:
559
+ controls_data = seg.flight_controls_widget.get_data()
560
+ except Exception:
561
+ controls_data = {}
562
+ forces_data = {}
563
+ if hasattr(seg, "dof_entry_widget") and seg.dof_entry_widget:
564
+ try:
565
+ forces_data = seg.dof_entry_widget.get_values()
566
+ except Exception:
567
+ forces_data = {}
568
+ unknowns = self._count_active(controls_data)
569
+ residuals = self._count_active(forces_data)
570
+ # Keep takeoff/ground rows grouped first for quick scan.
571
+ is_takeoff = "takeoff" in name.lower() or "ground" in str(sub_type).lower()
572
+ rows.append((is_takeoff, idx, name, sub_type, config_name, cp, unknowns, residuals))
573
+
574
+ rows.sort(key=lambda r: (not r[0], r[1]))
575
+ self.clearSpans()
576
+ self.clearContents()
577
+ self.setRowCount(len(rows))
578
+
579
+ for r, (_, seg_idx, name, sub_type, config_name, cp, unknowns, residuals) in enumerate(rows):
580
+ values_row = [name, sub_type, config_name, cp, unknowns, residuals]
581
+ for c, val in enumerate(values_row):
582
+ item = QTableWidgetItem(str(val))
583
+ item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
584
+ item.setToolTip(str(val))
585
+ if c == 0:
586
+ item.setData(Qt.ItemDataRole.UserRole, seg_idx)
587
+ self.setItem(r, c, item)
588
+
589
+ self.resizeRowsToContents()
590
+ row_h = self.rowHeight(0) if rows else 26
591
+ visible_rows = max(4, min(len(rows), 8))
592
+ self.setMinimumHeight(self.horizontalHeader().height() + (row_h * visible_rows) + 10)
593
+
594
+ def _row_clicked(self, row, col):
595
+ # Navigate from summary row to the matching collapsible details panel.
596
+ item = self.item(row, 0)
597
+ if item is None:
598
+ return
599
+ seg_index = item.data(Qt.ItemDataRole.UserRole)
600
+ if seg_index is None:
601
+ return
602
+
603
+ panel_index = int(seg_index) * 2
604
+ layout = self.mission_widget.details_layout
605
+ if panel_index < layout.count():
606
+ widget = layout.itemAt(panel_index).widget()
607
+ if hasattr(widget, "toggle") and not getattr(widget, "is_open", True):
608
+ widget.toggle()
609
+ self.mission_widget.details_scroll.ensureWidgetVisible(widget)
610
+
611
+ # ============================================================
612
+ # Collapsible Panel wrapper
613
+ # ============================================================
614
+ class CollapsiblePanel(QWidget):
615
+ """A small panel with a header button that toggles visibility of content.
616
+ - title: display title for the header button
617
+ - content_widget: the QWidget that will be shown/hidden when toggled
618
+ """
619
+
620
+ def __init__(self, title: str, content_widget: QWidget):
621
+ super().__init__()
622
+
623
+ # Track whether the panel is currently expanded
624
+ self.is_open = True
625
+
626
+ # The widget that will be shown or hidden
627
+ self.content = content_widget
628
+
629
+ # Button that acts as the collapsible panel header
630
+ self.toggle_btn = QPushButton(f"▼ {title}")
631
+ self.toggle_btn.setCheckable(True)
632
+ self.toggle_btn.setChecked(True)
633
+
634
+ # Connect button click to expand/collapse behavior
635
+ self.toggle_btn.clicked.connect(self.toggle)
636
+
637
+ # Visual styling for the header button
638
+ # (kept as a raw stylesheet string to preserve look-and-feel)
639
+ self.toggle_btn.setStyleSheet("""
640
+ QPushButton {
641
+ background-color: #0d1522;
642
+ border: 1px solid #2d3a4e;
643
+ border-radius: 6px;
644
+ padding: 6px;
645
+ color: #e5f0ff;
646
+ text-align: left;
647
+ font-weight: 600;
648
+ font-size: 13px;
649
+ }
650
+ QPushButton:checked { background-color: #162234; }
651
+ """)
652
+
653
+ # Vertical layout: header button on top, content below
654
+ layout = QVBoxLayout(self)
655
+ layout.setContentsMargins(0, 0, 0, 0)
656
+ layout.addWidget(self.toggle_btn)
657
+ layout.addWidget(self.content)
658
+
659
+ def toggle(self):
660
+ """Flip the panel open/closed and update the header arrow."""
661
+ # Toggle open/closed state
662
+ self.is_open = not self.is_open
663
+
664
+ # Show or hide the content widget
665
+ self.content.setVisible(self.is_open)
666
+
667
+ # Update arrow icon in the header text (preserve the existing title)
668
+ self.toggle_btn.setText(
669
+ f"{'▼' if self.is_open else '►'} {self.toggle_btn.text()[2:]}"
670
+ )
671
+
672
+ # ===================================
673
+ # Mission Widget (Main Tab)
674
+ # ===================================
675
+ class MissionWidget(TabWidget):
676
+ """Main mission tab widget used in the application's tab view.
677
+ Includes:
678
+ - Allowing the user to create and manage mission segments
679
+ - Displaying segment details in collapsible panels
680
+ - Saving/loading mission data to/from the global `values` object
681
+ - Interacting with RCAIDE mission objects when saving
682
+ """
683
+
684
+ def __init__(self, shared_analysis_widget=None):
685
+ super().__init__()
686
+
687
+
688
+ # List of MissionSegmentWidget instances currently in the mission
689
+ self.segment_widgets = []
690
+ # Indices of segments that have been disabled by the user
691
+ self.disabled_segments = set()
692
+
693
+ main_layout = QVBoxLayout(self)
694
+
695
+ left_col = QWidget()
696
+ left_v = QVBoxLayout(left_col)
697
+ left_v.setContentsMargins(0, 0, 0, 0)
698
+ left_v.setSpacing(8)
699
+
700
+ right_col = QWidget()
701
+ right_v = QVBoxLayout(right_col)
702
+ right_v.setContentsMargins(0, 0, 0, 0)
703
+ right_v.setSpacing(8)
704
+
705
+ segments_box = QGroupBox("Mission Setup")
706
+ segments_v = QVBoxLayout(segments_box)
707
+
708
+ # Small notice label
709
+ self.segment_notice = QLabel("")
710
+ self.segment_notice.setVisible(False)
711
+ self.segment_notice.setStyleSheet("""
712
+ QLabel {
713
+ color:#8fd3ff;
714
+ background:#0d223a;
715
+ border:1px solid #1f6feb;
716
+ border-radius:4px;
717
+ padding:4px 8px;
718
+ }
719
+ """)
720
+ segments_v.addWidget(self.segment_notice)
721
+
722
+ # Row: mission name input
723
+ name_row = QHBoxLayout()
724
+ name_label = QLabel("Segment Name:")
725
+ name_label.setStyleSheet("color:#dbe7ff;")
726
+ self.mission_name_input = QLineEdit()
727
+ self.mission_name_input.setPlaceholderText("Enter mission name...")
728
+ name_row.addWidget(name_label)
729
+ name_row.addWidget(self.mission_name_input)
730
+ segments_v.addLayout(name_row)
731
+
732
+ # Row: action buttons (add segment, save data)
733
+ btn_row = QHBoxLayout()
734
+ self.add_segment_btn = QPushButton("➕ Add Segment")
735
+ self.save_btn = QPushButton("💾 Save All Data")
736
+ for b in (self.add_segment_btn, self.save_btn):
737
+ b.setStyleSheet("background-color:#141b29; color:#e5f0ff;")
738
+
739
+ # Wire button clicks to handler methods (no logic change)
740
+ self.add_segment_btn.clicked.connect(self.add_segment)
741
+ self.save_btn.clicked.connect(self.save_all_data)
742
+
743
+ btn_row.addWidget(self.add_segment_btn)
744
+ btn_row.addWidget(self.save_btn)
745
+ segments_v.addLayout(btn_row)
746
+
747
+ # Tree widget to list mission segments and provide checkboxes
748
+ self.tree = QTreeWidget()
749
+ self.tree.setHeaderLabels(["Mission Segments"])
750
+ self.tree.setRootIsDecorated(False)
751
+ self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
752
+ segments_v.addWidget(self.tree)
753
+
754
+ # Action row: disable/enable segments and clear selected
755
+ action_row = QHBoxLayout()
756
+ self.disable_btn = QPushButton("Disable / Enable")
757
+ self.clear_btn = QPushButton("🗑 Clear Selected")
758
+ self.clear_btn.setStyleSheet(
759
+ "background:#2a1e1e; color:#ffb4b4; border:1px solid #5c2b2b;"
760
+ )
761
+
762
+ action_row.addWidget(self.disable_btn)
763
+ action_row.addStretch()
764
+ action_row.addWidget(self.clear_btn)
765
+ segments_v.addLayout(action_row)
766
+
767
+ overview_box = QGroupBox("Mission Overview")
768
+ overview_v = QVBoxLayout(overview_box)
769
+ self.profile_widget = MissionProfileWidget()
770
+ self.summary_table = MissionSummaryTable(self)
771
+ overview_v.addWidget(self.profile_widget)
772
+ overview_v.addWidget(self.summary_table)
773
+ overview_v.setStretch(0, 3)
774
+ overview_v.setStretch(1, 2)
775
+ left_v.addWidget(overview_box)
776
+
777
+
778
+ details_box = QGroupBox("Segment Details")
779
+ details_v = QVBoxLayout(details_box)
780
+
781
+ self.details_scroll = QScrollArea()
782
+ self.details_scroll.setWidgetResizable(True)
783
+ details_container = QWidget()
784
+ self.details_layout = QVBoxLayout(details_container)
785
+ self.details_scroll.setWidget(details_container)
786
+
787
+ details_v.addWidget(self.details_scroll)
788
+ right_v.addWidget(segments_box, 0)
789
+ right_v.addWidget(details_box, 1)
790
+
791
+ left_col.setMinimumWidth(540)
792
+ left_col.setMaximumWidth(780)
793
+ right_col.setMinimumWidth(760)
794
+
795
+ content_splitter = QSplitter(Qt.Orientation.Horizontal)
796
+ content_splitter.setChildrenCollapsible(False)
797
+ content_splitter.addWidget(left_col)
798
+ content_splitter.addWidget(right_col)
799
+ content_splitter.setStretchFactor(0, 1)
800
+ content_splitter.setStretchFactor(1, 3)
801
+ content_splitter.setHandleWidth(10)
802
+ content_splitter.setSizes([640, 1120])
803
+
804
+ main_layout.addWidget(content_splitter)
805
+
806
+ self.disable_btn.clicked.connect(self.disable_enable_selected)
807
+ self.clear_btn.clicked.connect(self.clear_selected_segments)
808
+ self.refresh_mission_overview()
809
+
810
+ # ====================================================
811
+ # Utilities
812
+ # ====================================================
813
+ def _notify(self, text):
814
+ """Briefly display a message in the segment notice label"""
815
+ self.segment_notice.setText(text)
816
+ self.segment_notice.setVisible(True)
817
+ QTimer.singleShot(2000, lambda: self.segment_notice.setVisible(False))
818
+
819
+ def _checked_indices(self):
820
+ """Return a list of indices of checked (selected) segments in the tree."""
821
+ return [
822
+ i for i in range(self.tree.topLevelItemCount())
823
+ if self.tree.topLevelItem(i).checkState(0) == Qt.CheckState.Checked
824
+ ]
825
+
826
+ def _selected_indices(self):
827
+ """Return sorted indices for highlighted rows in the segment tree."""
828
+ indices = []
829
+ for item in self.tree.selectedItems():
830
+ idx = self.tree.indexOfTopLevelItem(item)
831
+ if idx >= 0:
832
+ indices.append(idx)
833
+ return sorted(set(indices))
834
+
835
+ def _panel_style(self, disabled):
836
+ """Return stylesheet string for a segment panel; visually indicates disabled state."""
837
+ return f"""
838
+ #segmentPanel {{
839
+ background:{'#1a1f2a' if disabled else '#141b29'};
840
+ border:1px solid #2d3a4e;
841
+ border-radius:6px;
842
+ padding:6px;
843
+ margin:4px 0;
844
+ opacity:{'0.45' if disabled else '1.0'};
845
+ }}
846
+ """
847
+
848
+ def _wire_segment_summary_signals(self, seg):
849
+ if hasattr(seg, "segment_name_input"):
850
+ seg.segment_name_input.textChanged.connect(self.refresh_mission_overview)
851
+ if hasattr(seg, "nested_dropdown"):
852
+ seg.nested_dropdown.currentIndexChanged.connect(self.refresh_mission_overview)
853
+ if hasattr(seg, "top_dropdown"):
854
+ seg.top_dropdown.currentIndexChanged.connect(self.refresh_mission_overview)
855
+ if hasattr(seg, "config_selector"):
856
+ seg.config_selector.currentIndexChanged.connect(self.refresh_mission_overview)
857
+ if hasattr(seg, "ctrl_points"):
858
+ seg.ctrl_points.textChanged.connect(self.refresh_mission_overview)
859
+
860
+ def refresh_mission_overview(self):
861
+ phases = []
862
+ for idx, seg in enumerate(self.segment_widgets):
863
+ if idx in self.disabled_segments:
864
+ continue
865
+
866
+ name = ""
867
+ if hasattr(seg, "segment_name_input"):
868
+ name = seg.segment_name_input.text().strip()
869
+ if not name and idx < self.tree.topLevelItemCount():
870
+ name = self.tree.topLevelItem(idx).text(0).strip()
871
+ if not name and hasattr(seg, "nested_dropdown"):
872
+ name = seg.nested_dropdown.currentText().strip()
873
+ ptype = ""
874
+ if hasattr(seg, "top_dropdown"):
875
+ ptype = seg.top_dropdown.currentText().strip().lower()
876
+ sub_text = ""
877
+ if hasattr(seg, "nested_dropdown"):
878
+ sub_text = seg.nested_dropdown.currentText().strip().lower()
879
+ if ptype == "vertical flight" and sub_text:
880
+ ptype = f"vertical {sub_text}"
881
+ elif ptype == "ground" and sub_text:
882
+ ptype = f"ground {sub_text}"
883
+ elif ptype == "single_point":
884
+ ptype = "single_point"
885
+ elif not ptype and sub_text:
886
+ ptype = sub_text
887
+ phases.append((name or f"Segment {idx + 1}", ptype or "other"))
888
+
889
+ self.profile_widget.set_phases(phases)
890
+ self.summary_table.update_table()
891
+ # ====================================================
892
+ # Disable / Enable
893
+ # ====================================================
894
+
895
+ def disable_enable_selected(self):
896
+ """Toggle enabled/disabled state for all checked segments."""
897
+ indices = self._checked_indices()
898
+ # If user selects nothing, show a warning and return
899
+ if not indices:
900
+ self._notify("⚠ No segments selected")
901
+ return
902
+
903
+ messages = []
904
+
905
+ for idx in indices:
906
+ seg = self.segment_widgets[idx]
907
+ item = self.tree.topLevelItem(idx)
908
+ name = item.text(0)
909
+ # Each segment has a corresponding panel + separator line; panel is at idx*2
910
+ panel = self.details_layout.itemAt(idx * 2).widget()
911
+
912
+ if idx in self.disabled_segments:
913
+ # Enable
914
+ seg.setDisabled(False)
915
+ self.disabled_segments.remove(idx)
916
+ panel.setStyleSheet(self._panel_style(False))
917
+ messages.append(f"✅ '{name}' enabled")
918
+ else:
919
+ # Disable
920
+ seg.setDisabled(True)
921
+ self.disabled_segments.add(idx)
922
+ panel.setStyleSheet(self._panel_style(True))
923
+ messages.append(f"⛔ '{name}' disabled")
924
+
925
+ # Show last action message (enable/disable)
926
+ self._notify(messages[-1])
927
+ self.refresh_mission_overview()
928
+
929
+ # ===============================
930
+ # Clear Selected Segments
931
+ # ===============================
932
+ def clear_selected_segments(self):
933
+ """Remove highlighted segments from UI and internal lists."""
934
+ # Remove from highest index to avoid reindex issues
935
+ indices = sorted(self._selected_indices(), reverse=True)
936
+ if not indices:
937
+ self._notify("⚠ No segments selected")
938
+ return
939
+
940
+ messages = []
941
+
942
+ for idx in indices:
943
+ item = self.tree.topLevelItem(idx)
944
+ name = item.text(0)
945
+
946
+ # Remove from tree and internal segment list
947
+ self.tree.takeTopLevelItem(idx)
948
+ self.segment_widgets.pop(idx)
949
+ self.disabled_segments = {
950
+ i - 1 if i > idx else i
951
+ for i in self.disabled_segments
952
+ if i != idx
953
+ }
954
+
955
+ # Each segment consists of a panel and a horizontal line => remove both
956
+ for _ in range(2):
957
+ it = self.details_layout.takeAt(idx * 2)
958
+ if it and it.widget():
959
+ it.widget().deleteLater()
960
+
961
+ messages.append(f"🗑 '{name}' cleared")
962
+
963
+ # Show last cleared segment
964
+ self._notify(messages[-1])
965
+ self.refresh_mission_overview()
966
+
967
+ # ====================================================
968
+ # Add Segment
969
+ # ====================================================
970
+ def add_segment(self):
971
+ """Create a new MissionSegmentWidget and add it to the UI and internal state."""
972
+ seg = MissionSegmentWidget()
973
+ # Default to a simple cruise segment for quick GUI validation
974
+ #try:
975
+ #seg.top_dropdown.setCurrentIndex(1) # Cruise
976
+ #seg.populate_nested_dropdown(seg.top_dropdown.currentIndex())
977
+ #seg.nested_dropdown.setCurrentText("Constant Speed/Constant Altitude")
978
+ #seg.segment_name_input.setText("cruise")
979
+ ##if hasattr(seg, "_apply_defaults"):
980
+ ##seg._apply_defaults()
981
+ #except Exception:
982
+ #pass
983
+
984
+ self.segment_widgets.append(seg)
985
+ self._wire_segment_summary_signals(seg)
986
+
987
+ # If user provided a mission name in the input, use it; otherwise fall back
988
+ name = self.mission_name_input.text().strip()
989
+ if not name:
990
+ name = f"Segment {len(self.segment_widgets)}"
991
+
992
+ # Wrap the segment in a collapsible panel and add to details layout
993
+ panel = CollapsiblePanel(name.title(), seg)
994
+ panel.setObjectName("segmentPanel")
995
+ panel.setStyleSheet(self._panel_style(False))
996
+ self.details_layout.addWidget(panel)
997
+
998
+ # Add a visually separating horizontal line
999
+ line = QFrame()
1000
+ line.setFrameShape(QFrame.Shape.HLine)
1001
+ line.setStyleSheet("color:#2b3648; margin:2px 0;")
1002
+ self.details_layout.addWidget(line)
1003
+
1004
+ # Add the segment to the list tree (with an unchecked checkbox)
1005
+ item = QTreeWidgetItem([name.title()])
1006
+ item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
1007
+ item.setCheckState(0, Qt.CheckState.Checked)
1008
+ self.tree.addTopLevelItem(item)
1009
+
1010
+ # Clear the input and notify the user
1011
+ self.mission_name_input.clear()
1012
+ self.refresh_mission_overview()
1013
+ self._notify(f"✓ Added '{name.title()}'")
1014
+
1015
+ def save_all_data(self):
1016
+ """
1017
+ Save mission data and build the RCAIDE mission object.
1018
+ This function must not build or modify aircraft configurations.
1019
+ """
1020
+ import rcaide_io
1021
+
1022
+ # Reset stored mission data before saving
1023
+ rcaide_io.mission_data = []
1024
+
1025
+ # Save analyses that were already defined by the user
1026
+
1027
+ # Collect data from each enabled mission segment
1028
+ for idx, seg in enumerate(self.segment_widgets):
1029
+ if idx in self.disabled_segments:
1030
+ continue
1031
+
1032
+ # Save only the form state here; building RCAIDE segments requires analyses and happens later.
1033
+ seg_data = seg.get_form_data()
1034
+ seg_data["Segment Name"] = self.tree.topLevelItem(idx).text(0)
1035
+ rcaide_io.mission_data.append(seg_data)
1036
+
1037
+ # Create the RCAIDE mission using existing data only
1038
+ # Defer mission build until analyses are saved
1039
+
1040
+ segment_names = [
1041
+ segment.get("Segment Name", f"Segment {index + 1}")
1042
+ for index, segment in enumerate(rcaide_io.mission_data)
1043
+ ]
1044
+ print("\n[Mission Data Saved]")
1045
+ print(f"Active segments: {len(rcaide_io.mission_data)}")
1046
+ print(f"Names: {', '.join(segment_names) if segment_names else 'None'}")
1047
+
1048
+ # Notify the user that the mission was saved
1049
+ self._notify("Mission data saved")
1050
+ self.refresh_mission_overview()
1051
+
1052
+
1053
+ def create_rcaide_mission(self):
1054
+ # Create an empty sequential mission container
1055
+ rcaide_mission = RCAIDE.Framework.Mission.Sequential_Segments()
1056
+ rcaide_mission.tag = self.mission_name_input.text()
1057
+
1058
+ # Add each enabled segment to the mission
1059
+ for idx, seg in enumerate(self.segment_widgets):
1060
+ if idx in self.disabled_segments:
1061
+ continue
1062
+
1063
+ _, rcaide_segment = seg.get_data()
1064
+
1065
+ # Ensure analyses exist before assigning them to a segment
1066
+ if not rcaide_io.rcaide_analyses:
1067
+ raise RuntimeError("No RCAIDE analyses available")
1068
+
1069
+ # If the segment has no analyses (should be rare), assign a fallback
1070
+ if not getattr(rcaide_segment, "analyses", None):
1071
+ rcaide_segment.analyses = next(iter(rcaide_io.rcaide_analyses.values()))
1072
+
1073
+ # Append the segment to the mission sequence
1074
+ rcaide_mission.append_segment(rcaide_segment)
1075
+
1076
+ return rcaide_mission
1077
+
1078
+ # ====================================================
1079
+ # Load Mission
1080
+ # ====================================================
1081
+
1082
+ def load_from_values(self):
1083
+ """Populate the UI from `rcaide_io.mission_data` previously saved."""
1084
+ # Reset UI and internal lists
1085
+ self.tree.clear()
1086
+ self.segment_widgets = []
1087
+ self.disabled_segments.clear()
1088
+
1089
+ # Clear existing widgets in the details layout
1090
+ for i in reversed(range(self.details_layout.count())):
1091
+ w = self.details_layout.itemAt(i).widget()
1092
+ if w:
1093
+ w.deleteLater()
1094
+
1095
+ # Recreate segments from saved data (handles both old GUI and new RCAIDE formats)
1096
+ for seg_data in _extract_gui_segments(rcaide_io.mission_data):
1097
+ seg = MissionSegmentWidget()
1098
+ seg.load_data(seg_data)
1099
+ self.segment_widgets.append(seg)
1100
+ self._wire_segment_summary_signals(seg)
1101
+
1102
+ name = seg_data.get("Segment Name", "Segment")
1103
+ panel = CollapsiblePanel(name.title(), seg)
1104
+ panel.setObjectName("segmentPanel")
1105
+ panel.setStyleSheet(self._panel_style(False))
1106
+ self.details_layout.addWidget(panel)
1107
+
1108
+ line = QFrame()
1109
+ line.setFrameShape(QFrame.Shape.HLine)
1110
+ line.setStyleSheet("color:#2b3648; margin:2px 0;")
1111
+ self.details_layout.addWidget(line)
1112
+
1113
+ item = QTreeWidgetItem([name.title()])
1114
+ item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
1115
+ item.setCheckState(0, Qt.CheckState.Checked)
1116
+ self.tree.addTopLevelItem(item)
1117
+
1118
+ # Defer mission build until analyses are saved to avoid load-time errors.
1119
+ self.refresh_mission_overview()
1120
+
1121
+
1122
+ def get_widget() -> QWidget:
1123
+ """Factory helper used to create a MissionWidget instance for the tab system."""
1124
+ return MissionWidget()