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.
- common_widgets/__init__.py +6 -0
- common_widgets/animated_toggle.py +141 -0
- common_widgets/color.py +17 -0
- common_widgets/data_entry_widget.py +337 -0
- common_widgets/image_widget.py +21 -0
- common_widgets/unit_picker_widget.py +48 -0
- main.py +168 -0
- rcaide_gui-1.0.0.dist-info/METADATA +96 -0
- rcaide_gui-1.0.0.dist-info/RECORD +170 -0
- rcaide_gui-1.0.0.dist-info/WHEEL +5 -0
- rcaide_gui-1.0.0.dist-info/entry_points.txt +2 -0
- rcaide_gui-1.0.0.dist-info/top_level.txt +5 -0
- rcaide_io.py +818 -0
- tabs/__init__.py +10 -0
- tabs/aircraft_configs/__init__.py +3 -0
- tabs/aircraft_configs/aircraft_configs.py +301 -0
- tabs/analysis/__init__.py +4 -0
- tabs/analysis/analysis.py +92 -0
- tabs/analysis/widgets/__init__.py +13 -0
- tabs/analysis/widgets/aeroacoustics_widget.py +158 -0
- tabs/analysis/widgets/aerodynamics_widget.py +108 -0
- tabs/analysis/widgets/analysis_data_widget.py +29 -0
- tabs/analysis/widgets/atmosphere_widget.py +49 -0
- tabs/analysis/widgets/costs_widget.py +35 -0
- tabs/analysis/widgets/energy_widget.py +32 -0
- tabs/analysis/widgets/geometry_widget.py +55 -0
- tabs/analysis/widgets/planets_widget.py +34 -0
- tabs/analysis/widgets/propulsion_widget.py +53 -0
- tabs/analysis/widgets/stability_widget.py +34 -0
- tabs/analysis/widgets/weights_widget.py +94 -0
- tabs/geometry/__init__.py +6 -0
- tabs/geometry/aircraft_configs.py +232 -0
- tabs/geometry/frames/__init__.py +15 -0
- tabs/geometry/frames/booms/__init__.py +5 -0
- tabs/geometry/frames/booms/boom_frame.py +238 -0
- tabs/geometry/frames/cargo_bays/__init__.py +5 -0
- tabs/geometry/frames/cargo_bays/cargo_bay_frame.py +159 -0
- tabs/geometry/frames/default_frame.py +23 -0
- tabs/geometry/frames/energy_network/__init__.py +2 -0
- tabs/geometry/frames/energy_network/turbofan_network/__init__.py +3 -0
- tabs/geometry/frames/energy_network/turbofan_network/frames/__init__.py +1 -0
- tabs/geometry/frames/energy_network/turbofan_network/widgets/__init__.py +1 -0
- tabs/geometry/frames/fuselages/__init__.py +5 -0
- tabs/geometry/frames/fuselages/fuselage_frame.py +294 -0
- tabs/geometry/frames/geometry_frame.py +91 -0
- tabs/geometry/frames/landing_gears/__init__.py +5 -0
- tabs/geometry/frames/landing_gears/landing_gear_frame.py +189 -0
- tabs/geometry/frames/nacelles/__init__.py +1 -0
- tabs/geometry/frames/powertrain/__init__.py +6 -0
- tabs/geometry/frames/powertrain/converters/__init__.py +5 -0
- tabs/geometry/frames/powertrain/converters/converter_frame.py +137 -0
- tabs/geometry/frames/powertrain/distributors/__init__.py +5 -0
- tabs/geometry/frames/powertrain/distributors/distributor_frame.py +143 -0
- tabs/geometry/frames/powertrain/nacelles/__init__.py +5 -0
- tabs/geometry/frames/powertrain/nacelles/nacelle_frame.py +283 -0
- tabs/geometry/frames/powertrain/powertrain_frame.py +259 -0
- tabs/geometry/frames/powertrain/propulsors/__init__.py +5 -0
- tabs/geometry/frames/powertrain/propulsors/propulsor_frame.py +146 -0
- tabs/geometry/frames/powertrain/sources/__init__.py +6 -0
- tabs/geometry/frames/powertrain/sources/energy_source_frame.py +147 -0
- tabs/geometry/frames/vehicle_frame.py +171 -0
- tabs/geometry/frames/wings/__init__.py +5 -0
- tabs/geometry/frames/wings/wings_frame.py +457 -0
- tabs/geometry/geometry.py +513 -0
- tabs/geometry/widgets/__init__.py +12 -0
- tabs/geometry/widgets/booms/__init__.py +6 -0
- tabs/geometry/widgets/booms/boom_section_widget.py +109 -0
- tabs/geometry/widgets/cargo_bays/__init__.py +1 -0
- tabs/geometry/widgets/fuselages/__init__.py +8 -0
- tabs/geometry/widgets/fuselages/cabin_class_widget.py +188 -0
- tabs/geometry/widgets/fuselages/cabin_widget.py +217 -0
- tabs/geometry/widgets/fuselages/fuselage_section_widget.py +190 -0
- tabs/geometry/widgets/geometry_data_widget.py +25 -0
- tabs/geometry/widgets/landing_gears/__init__.py +6 -0
- tabs/geometry/widgets/nacelles/__init__.py +1 -0
- tabs/geometry/widgets/powertrain/__init__.py +9 -0
- tabs/geometry/widgets/powertrain/converters/__init__.py +5 -0
- tabs/geometry/widgets/powertrain/converters/turboelectric_generator_widget.py +74 -0
- tabs/geometry/widgets/powertrain/distributors/__init__.py +5 -0
- tabs/geometry/widgets/powertrain/distributors/fuel_line_widget.py +81 -0
- tabs/geometry/widgets/powertrain/modulators/__init__.py +5 -0
- tabs/geometry/widgets/powertrain/modulators/esc_widget.py +58 -0
- tabs/geometry/widgets/powertrain/nacelles/__init__.py +5 -0
- tabs/geometry/widgets/powertrain/nacelles/nacelle_section_widget.py +115 -0
- tabs/geometry/widgets/powertrain/powertrain_connector_widget.py +136 -0
- tabs/geometry/widgets/powertrain/powertrain_widget.py +206 -0
- tabs/geometry/widgets/powertrain/propulsors/__init__.py +5 -0
- tabs/geometry/widgets/powertrain/propulsors/turbofan_widget.py +462 -0
- tabs/geometry/widgets/powertrain/sources/__init__.py +6 -0
- tabs/geometry/widgets/powertrain/sources/fuel_tank_widget.py +103 -0
- tabs/geometry/widgets/powertrain/sources/source_selector_widget.py +35 -0
- tabs/geometry/widgets/wings/__init__.py +7 -0
- tabs/geometry/widgets/wings/wing_cs_widget.py +198 -0
- tabs/geometry/widgets/wings/wing_section_widget.py +298 -0
- tabs/home/__init__.py +3 -0
- tabs/home/home.py +585 -0
- tabs/mission/__init__.py +4 -0
- tabs/mission/mission.py +1124 -0
- tabs/mission/widgets/__init__.py +3 -0
- tabs/mission/widgets/flight_controls_widget.py +186 -0
- tabs/mission/widgets/mission_analysis_widget.py +166 -0
- tabs/mission/widgets/mission_segment_helper.py +358 -0
- tabs/mission/widgets/mission_segment_widget.py +358 -0
- tabs/solve/__init__.py +4 -0
- tabs/solve/plots/__init__.py +12 -0
- tabs/solve/plots/aeroacoustics/__init__.py +6 -0
- tabs/solve/plots/aeroacoustics/plot_2D_noise_contour.py +184 -0
- tabs/solve/plots/aeroacoustics/plot_3D_noise_contour.py +219 -0
- tabs/solve/plots/aeroacoustics/plot_noise_certification_contour.py +115 -0
- tabs/solve/plots/aeroacoustics/plot_noise_level.py +118 -0
- tabs/solve/plots/aerodynamics/__init__.py +10 -0
- tabs/solve/plots/aerodynamics/plot_aerodynamic_coefficients.py +156 -0
- tabs/solve/plots/aerodynamics/plot_aerodynamic_forces.py +155 -0
- tabs/solve/plots/aerodynamics/plot_aircraft_aerodynamics.py +136 -0
- tabs/solve/plots/aerodynamics/plot_drag_components.py +157 -0
- tabs/solve/plots/aerodynamics/plot_lift_distribution.py +107 -0
- tabs/solve/plots/aerodynamics/plot_rotor_conditions.py +255 -0
- tabs/solve/plots/aerodynamics/plot_rotor_disc_performance.py +144 -0
- tabs/solve/plots/aerodynamics/plot_rotor_performance.py +124 -0
- tabs/solve/plots/common/__init__.py +4 -0
- tabs/solve/plots/common/plot_style.py +71 -0
- tabs/solve/plots/common/set_axes.py +55 -0
- tabs/solve/plots/create_plot_widgets.py +34 -0
- tabs/solve/plots/emissions/__init__.py +3 -0
- tabs/solve/plots/emissions/plot_emissions.py +167 -0
- tabs/solve/plots/energy/__init__.py +12 -0
- tabs/solve/plots/energy/plot_altitude_sfc_weight.py +134 -0
- tabs/solve/plots/energy/plot_battery_cell_conditions.py +154 -0
- tabs/solve/plots/energy/plot_battery_degradation.py +157 -0
- tabs/solve/plots/energy/plot_battery_module_C_rates.py +139 -0
- tabs/solve/plots/energy/plot_battery_module_conditions.py +157 -0
- tabs/solve/plots/energy/plot_battery_pack_conditions.py +164 -0
- tabs/solve/plots/energy/plot_battery_ragone_diagram.py +97 -0
- tabs/solve/plots/energy/plot_battery_temperature.py +136 -0
- tabs/solve/plots/energy/plot_electric_propulsor_efficiencies.py +136 -0
- tabs/solve/plots/energy/plot_propulsor_throttles.py +119 -0
- tabs/solve/plots/mission/__init__.py +5 -0
- tabs/solve/plots/mission/plot_aircraft_velocities.py +145 -0
- tabs/solve/plots/mission/plot_flight_conditions.py +236 -0
- tabs/solve/plots/mission/plot_flight_trajectory.py +172 -0
- tabs/solve/plots/stability/__init__.py +5 -0
- tabs/solve/plots/stability/plot_flight_forces_and_moments.py +163 -0
- tabs/solve/plots/stability/plot_lateral_stability.py +151 -0
- tabs/solve/plots/stability/plot_longitudinal_stability.py +173 -0
- tabs/solve/plots/thermal_management/__init__.py +7 -0
- tabs/solve/plots/thermal_management/plot_air_cooled_conditions.py +152 -0
- tabs/solve/plots/thermal_management/plot_cross_flow_heat_exchanger_conditions.py +185 -0
- tabs/solve/plots/thermal_management/plot_reservoir_conditions.py +141 -0
- tabs/solve/plots/thermal_management/plot_thermal_management_performance.py +105 -0
- tabs/solve/plots/thermal_management/plot_wavy_channel_conditions.py +161 -0
- tabs/solve/plots/weights/__init__.py +4 -0
- tabs/solve/plots/weights/plot_load_diagram.py +163 -0
- tabs/solve/plots/weights/plot_weight_breakdown.py +164 -0
- tabs/solve/solve.py +1498 -0
- tabs/style_sheet.py +114 -0
- tabs/tab_widget.py +14 -0
- tabs/visualize_geometry/__init__.py +4 -0
- tabs/visualize_geometry/core_3d_viewer.py +253 -0
- tabs/visualize_geometry/features/__init__.py +10 -0
- tabs/visualize_geometry/features/axes_gizmo.py +173 -0
- tabs/visualize_geometry/features/background.py +161 -0
- tabs/visualize_geometry/features/blueprint.py +685 -0
- tabs/visualize_geometry/features/camera.py +364 -0
- tabs/visualize_geometry/features/drag_aircraft.py +219 -0
- tabs/visualize_geometry/features/grid.py +194 -0
- tabs/visualize_geometry/features/measurement.py +367 -0
- tabs/visualize_geometry/features/screenshot.py +91 -0
- tabs/visualize_geometry/geometry_helper_functions.py +3 -0
- tabs/visualize_geometry/visualize_geometry.py +731 -0
- 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
|
+
|