"""
GUI Wrapper for Buffalo Panel workflow.
Provides YAML configuration editing and interactive post-processing.
"""
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownArgumentType=false
# pyright: reportUnknownVariableType=false
# pyright: reportUnknownLambdaType=false
# mypy: disable-error-code="no-untyped-call,attr-defined,call-arg,arg-type,misc"
from __future__ import annotations
import logging
import sys
from typing import Any, cast
import numpy as np
from matplotlib.backends.backend_qt import (
NavigationToolbar2QT as NavigationToolbar,
)
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from PyQt6 import QtCore, QtGui, QtWidgets
from buffalo_panel.app.gui.internal.config_templates import (
body_preview_coordinates,
build_default_body_mapping,
build_initial_case_mapping,
)
from buffalo_panel.app.gui.internal.models import (
METADATA_ROLE,
SCHEMA_NODE_ROLE,
SimulationModel,
TypeDelegate,
)
from buffalo_panel.app.gui.internal.tree_helpers import (
convert_angle_fields,
model_to_data,
sync_airfoil_definition_rows,
)
from buffalo_panel.app.gui.internal.utils import (
latex_pgf_export_style,
)
from buffalo_panel.app.gui.internal.view_logic import (
ViewState,
build_flow_probe_lines,
get_default_view_limits,
sample_surface_quantity_at_x,
)
from buffalo_panel.app.gui.internal.viewers import (
FlowViewer,
GeometryViewer,
MplCanvas,
SurfaceViewer,
)
from buffalo_panel.config import (
AirfoilBody2DSpec,
PanelCaseSpec,
load_panel_case,
panel_case_from_mapping,
panel_case_to_mapping,
save_panel_case,
solve_panel_case_artifact,
)
from buffalo_panel.config.internal.schema import schema_tree
from buffalo_panel.post import (
SolvedCaseArtifact,
load_solved_case_artifact,
save_solved_case_artifact,
solution_from_artifact,
)
from buffalo_panel.post.internal.results import PanelSolution2D
LOGGER = logging.getLogger(__name__)
_FLOW_VIEW_DEBOUNCE_MS = 90
_PREVIEW_VIEW_DEBOUNCE_MS = 50
[docs]
class Panel2dGUI(QtWidgets.QMainWindow):
"""
Main window for the Buffalo Panel interactive research suite.
This GUI provides a tree-based editor for case configuration and
interactive plotting for surface and field results.
"""
def __init__(self) -> None: # noqa: PLR0915
"""
Initialize the main window and set up UI components.
This configures the central splitter layout, the tree-based
configuration editor, the integrated results table, and the three-tab
visualization area (Geometry, Surface, and Flow Field).
It also initializes the underlying schema model and event timers for
debounced rendering.
"""
super().__init__()
self.setWindowTitle("Buffalo Panel - Interactive Research Suite")
self.resize(1200, 800)
# Central Widget & Layout
main_widget = QtWidgets.QWidget()
self.setCentralWidget(main_widget)
main_layout = QtWidgets.QHBoxLayout(main_widget)
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal)
# Left Panel: Container for Config and Results
left_container = QtWidgets.QWidget()
left_layout = QtWidgets.QVBoxLayout(left_container)
self.splitter.addWidget(left_container)
# State for interactive updates
self._last_artifact: SolvedCaseArtifact | None = None
self._last_results: PanelSolution2D | None = None
self._is_rendering: bool = False
self._streamline_density: float = 1.8
self._flow_mode: str = "Streamlines"
# View State Management
self._flow_view_state = ViewState()
self._home_reset_required: set[MplCanvas] = set()
self.density_slider: QtWidgets.QSlider | None = None
self.flow_mode_cb: QtWidgets.QComboBox | None = None
self.surface_quantity_cb: QtWidgets.QComboBox | None = None
self.current_spec: PanelCaseSpec | None = None
self._tooltips_enabled: bool = True
self._preview_flow_arrow: Any | None = None
self._preview_flow_ref_line: Any | None = None
self._preview_flow_label: Any | None = None
self._flow_callback_ids: tuple[int | None, int | None] = (None, None)
self._preview_callback_ids: tuple[int | None, int | None] = (
None,
None,
)
# Timer to debounce view changes (zoom/pan)
self._view_timer = QtCore.QTimer()
self._view_timer.setSingleShot(True)
self._view_timer.timeout.connect(self._on_view_timer_timeout)
# Timer to debounce preview view changes
self._preview_view_timer = QtCore.QTimer()
self._preview_view_timer.setSingleShot(True)
self._preview_view_timer.timeout.connect(self._on_preview_timer_timeout)
# Left Panel: YAML Editor
config_group = QtWidgets.QGroupBox("Configuration")
config_layout = QtWidgets.QVBoxLayout(config_group)
self.tree_view = QtWidgets.QTreeView()
config_layout.addWidget(self.tree_view)
btn_layout = QtWidgets.QHBoxLayout()
load_btn = QtWidgets.QPushButton("Load Config")
load_btn.clicked.connect(self.load_configuration)
btn_layout.addWidget(load_btn)
save_btn = QtWidgets.QPushButton("Save Config")
save_btn.clicked.connect(self.save_configuration)
btn_layout.addWidget(save_btn)
load_result_btn = QtWidgets.QPushButton("Load Result")
load_result_btn.clicked.connect(self.load_solution_artifact)
btn_layout.addWidget(load_result_btn)
save_result_btn = QtWidgets.QPushButton("Save Result")
save_result_btn.clicked.connect(self.save_solution_artifact)
btn_layout.addWidget(save_result_btn)
run_btn = QtWidgets.QPushButton("Run Simulation")
run_btn.clicked.connect(self.run_simulation)
btn_layout.addWidget(run_btn)
config_layout.addLayout(btn_layout)
left_layout.addWidget(config_group, 3)
# Integrated Results Table
results_group = QtWidgets.QGroupBox("Integrated Results")
results_vbox = QtWidgets.QVBoxLayout(results_group)
self.results_table = QtWidgets.QTableWidget(0, 2)
self.results_table.setHorizontalHeaderLabels(["Quantity", "Value"])
horizon_header = self.results_table.horizontalHeader()
if horizon_header is not None:
horizon_header.setSectionResizeMode(
QtWidgets.QHeaderView.ResizeMode.Stretch
)
self.results_table.setEditTriggers(
QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers
)
# Add copy capability
copy_action = QtGui.QAction("Copy", self.results_table)
copy_action.setShortcut(QtGui.QKeySequence.StandardKey.Copy)
copy_action.triggered.connect(self._copy_table_selection)
self.results_table.addAction(copy_action)
self.results_table.setContextMenuPolicy(
QtCore.Qt.ContextMenuPolicy.ActionsContextMenu
)
results_vbox.addWidget(self.results_table)
left_layout.addWidget(results_group, 1)
# Right Panel: Visualization Tabs
self.tabs = QtWidgets.QTabWidget()
self.preview_canvas = GeometryViewer(self)
self.cp_canvas = SurfaceViewer(self)
self.flow_canvas = FlowViewer(self)
self.tabs.addTab(
self._create_plot_page(self.preview_canvas), "Geometry"
)
self.tabs.addTab(self._create_plot_page(self.cp_canvas), "Surface")
self.tabs.addTab(self._create_plot_page(self.flow_canvas), "Flow Field")
self.splitter.addWidget(self.tabs)
self.splitter.setStretchFactor(0, 1)
self.splitter.setStretchFactor(1, 2)
main_layout.addWidget(self.splitter)
# Initial Data
initial_yaml = build_initial_case_mapping()
self.current_spec = panel_case_from_mapping(initial_yaml)
data = panel_case_to_mapping(self.current_spec)
self.model = SimulationModel(data)
self._set_tree_model(self.model)
self.tree_view.setItemDelegateForColumn(1, TypeDelegate(self.tree_view))
# Mark canvases for initial Home view establishment
self._home_reset_required.update([
self.preview_canvas,
self.cp_canvas,
self.flow_canvas,
])
# Start non-preview plots in a hidden state until a simulation is run.
self.cp_canvas.ax.set_visible(False)
self.flow_canvas.ax.set_visible(False)
self.tree_view.setContextMenuPolicy(
QtCore.Qt.ContextMenuPolicy.CustomContextMenu
)
self.tree_view.customContextMenuRequested.connect(
self._show_context_menu
)
self._update_geometry_preview()
self._setup_flow_callbacks()
self._setup_preview_callbacks()
# Enable context menu for CP and Flow canvases to toggle tooltips
for canvas in [self.cp_canvas, self.flow_canvas]:
canvas.setContextMenuPolicy(
QtCore.Qt.ContextMenuPolicy.CustomContextMenu
)
canvas.customContextMenuRequested.connect(
self._show_plot_context_menu
)
def _setup_preview_callbacks(self) -> None:
"""
Register callbacks for the geometry preview canvas.
This connects to axes limit changes (zoom/pan) to ensure the freestream
indicator is repositioned correctly.
"""
self._disconnect_axes_callbacks(
self.preview_canvas.ax,
self._preview_callback_ids,
)
xlim_callback_id = self.preview_canvas.ax.callbacks.connect(
"xlim_changed", lambda _: self._on_preview_view_changed()
)
ylim_callback_id = self.preview_canvas.ax.callbacks.connect(
"ylim_changed", lambda _: self._on_preview_view_changed()
)
self._preview_callback_ids = (
int(xlim_callback_id),
int(ylim_callback_id),
)
def _setup_flow_callbacks(self) -> None:
"""
Register callbacks for flow field canvas.
This connects to axes limit changes (zoom/pan) to trigger debounced
recalculation of the flow field streamlines or contours.
"""
self._disconnect_axes_callbacks(
self.flow_canvas.ax,
self._flow_callback_ids,
)
xlim_callback_id = self.flow_canvas.ax.callbacks.connect(
"xlim_changed", lambda _: self._on_view_changed()
)
ylim_callback_id = self.flow_canvas.ax.callbacks.connect(
"ylim_changed", lambda _: self._on_view_changed()
)
self._flow_callback_ids = (
int(xlim_callback_id),
int(ylim_callback_id),
)
@staticmethod
def _disconnect_axes_callbacks(
axes: Any,
callback_ids: tuple[int | None, int | None],
) -> None:
"""Disconnect old axes-limit callbacks before re-registering them."""
for callback_id in callback_ids:
if callback_id is not None:
axes.callbacks.disconnect(callback_id)
def _on_preview_timer_timeout(self) -> None:
"""
Update the freestream indicator once the view has settled.
This is called by the preview view timer to redraw the freestream arrow
and labels in a fixed relative position within the current viewport.
"""
if self.current_spec is not None and not self._is_rendering:
self._is_rendering = True
try:
# GeometryViewer handles freestream arrow placement internally.
self._update_geometry_preview()
finally:
self._is_rendering = False
def _on_preview_view_changed(self) -> None:
"""
Update the freestream indicator when the preview viewport changes.
Starts a debounce timer to avoid excessive redrawing during rapid
panning or zooming in the geometry preview tab.
"""
if self.current_spec is not None and not self._is_rendering:
self._preview_view_timer.start(_PREVIEW_VIEW_DEBOUNCE_MS)
def _create_plot_page(self, canvas: MplCanvas) -> QtWidgets.QWidget:
"""
Create a widget containing a plot canvas and its toolbar.
Parameters
----------
canvas : MplCanvas
The canvas to be wrapped.
Returns
-------
QtWidgets.QWidget
A page widget for the tab container.
"""
page = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(page)
toolbar = NavigationToolbar(canvas, self)
# Store toolbar reference to manage navigation stack (Home button)
canvas.toolbar = toolbar
# Connect mouse motion for point probe updates
canvas.mpl_connect("motion_notify_event", self._on_mouse_move)
ctrl_layout = QtWidgets.QHBoxLayout()
reset_btn = QtWidgets.QPushButton("Reset View")
reset_btn.clicked.connect(lambda: self._handle_reset_view(canvas))
ctrl_layout.addWidget(reset_btn)
# Add a density slider if this is the flow field page
if canvas == self.flow_canvas:
ctrl_layout.addSpacing(20)
self.density_label = QtWidgets.QLabel("Streamline Density:")
ctrl_layout.addWidget(self.density_label)
self.density_slider = QtWidgets.QSlider(
QtCore.Qt.Orientation.Horizontal
)
self.density_slider.setRange(5, 50) # Scale of 0.5 to 5.0
self.density_slider.setValue(int(self._streamline_density * 10))
self.density_slider.setFixedWidth(150)
self.density_slider.valueChanged.connect(self._on_density_changed)
ctrl_layout.addWidget(self.density_slider)
ctrl_layout.addSpacing(20)
ctrl_layout.addWidget(QtWidgets.QLabel("Mode:"))
self.flow_mode_cb = QtWidgets.QComboBox()
self.flow_mode_cb.addItems(["Streamlines", "Pressure Contours"])
self.flow_mode_cb.currentTextChanged.connect(
self._on_flow_mode_changed
)
ctrl_layout.addWidget(self.flow_mode_cb)
if canvas == self.cp_canvas:
ctrl_layout.addSpacing(20)
ctrl_layout.addWidget(QtWidgets.QLabel("Quantity:"))
self.surface_quantity_cb = QtWidgets.QComboBox()
self.surface_quantity_cb.addItems([
"Pressure Coefficient",
"Speed",
"Panel Lift Coefficient",
])
self.surface_quantity_cb.currentTextChanged.connect(
self._on_surface_quantity_changed
)
ctrl_layout.addWidget(self.surface_quantity_cb)
ctrl_layout.addStretch()
export_btn = QtWidgets.QPushButton("Export PGF...")
export_btn.clicked.connect(lambda: self._export_canvas_as_pgf(canvas))
ctrl_layout.addWidget(export_btn)
layout.addWidget(toolbar)
layout.addWidget(canvas)
layout.addLayout(ctrl_layout)
return page
def _set_tree_model(self, model: SimulationModel) -> None:
"""Bind a fresh configuration model to the tree view."""
self.model = model
self.tree_view.setModel(model)
model.itemChanged.connect(self._on_item_changed)
self.tree_view.expandAll()
header = self.tree_view.header()
if header is not None:
header.setSectionResizeMode(
0, QtWidgets.QHeaderView.ResizeMode.Interactive
)
header.setSectionResizeMode(
1, QtWidgets.QHeaderView.ResizeMode.Stretch
)
self.tree_view.resizeColumnToContents(0)
selection_model = self.tree_view.selectionModel()
if selection_model is not None:
selection_model.selectionChanged.connect(
self._on_tree_selection_changed
)
def _get_dict_from_model(
self, parent: QtGui.QStandardItem | None = None
) -> dict[str, Any]:
"""Return the current configuration tree as nested Python data."""
if parent is None:
parent = self.model.invisibleRootItem()
data = model_to_data(parent, METADATA_ROLE)
return cast(dict[str, Any], data)
[docs]
def load_configuration(self) -> None:
"""
Load a YAML configuration file into the tree view.
Opens a file dialog, parses the selected YAML file into a
``PanelCaseSpec``, and populates the model and geometry preview.
"""
path, _ = QtWidgets.QFileDialog.getOpenFileName(
self,
"Load Configuration",
"",
"YAML Files (*.yaml *.yml);;All Files (*)",
)
if not path:
return
try:
self.current_spec = load_panel_case(path)
data = panel_case_to_mapping(self.current_spec)
self._set_tree_model(SimulationModel(data))
self._home_reset_required.add(self.preview_canvas)
self._update_geometry_preview()
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Load Error", str(e))
[docs]
def save_configuration(self) -> None:
"""
Save the current tree view configuration to a YAML file.
Opens a file dialog and serializes the current tree model into the
specified YAML file.
"""
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
"Save Configuration",
"",
"YAML Files (*.yaml *.yml);;All Files (*)",
)
if not path:
return
try:
config_dict = self._get_dict_from_model()
self.current_spec = panel_case_from_mapping(config_dict)
self._home_reset_required.add(self.preview_canvas)
save_panel_case(self.current_spec, path)
QtWidgets.QMessageBox.information(
self, "Save Success", f"Configuration saved to {path}"
)
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Save Error", str(e))
[docs]
def load_solution_artifact(self) -> None:
"""
Load one solved-case artifact into the post-processing views.
Opens a file dialog, parses the selected solved artifact, reconstructs
a ``PanelSolution2D`` through the shared factory path, and updates the
result plots.
"""
path, _ = QtWidgets.QFileDialog.getOpenFileName(
self,
"Load Solved Artifact",
"",
"YAML Files (*.yaml *.yml);;All Files (*)",
)
if not path:
return
try:
artifact = load_solved_case_artifact(path)
self._last_artifact = artifact
self._home_reset_required.update([
self.cp_canvas,
self.flow_canvas,
])
self.update_plots(solution_from_artifact(artifact))
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Load Error", str(e))
def _suggest_artifact_path(self) -> str:
"""Return a suggested save path for one solved-case artifact."""
case_name = (
self.current_spec.case.name
if self.current_spec is not None
else "panel2d_case"
)
safe_case_name = case_name.replace(" ", "_")
return f"{safe_case_name}.solution.yaml"
[docs]
def save_solution_artifact(self) -> None:
"""
Save the current solved artifact to one YAML file.
Opens a file dialog and writes the most recently run or loaded solved
artifact to the selected location.
"""
if self._last_artifact is None:
QtWidgets.QMessageBox.warning(
self,
"Save Result",
"No solved result is available to save.",
)
return
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
"Save Solved Artifact",
self._suggest_artifact_path(),
"YAML Files (*.yaml *.yml);;All Files (*)",
)
if not path:
return
try:
save_solved_case_artifact(self._last_artifact, path)
QtWidgets.QMessageBox.information(
self,
"Save Success",
f"Solved artifact saved to {path}",
)
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Save Error", str(e))
[docs]
def run_simulation(self) -> None:
"""Run the simulation.
Extract the configuration from the tree, solve the system, and update
visualizations.
"""
config_dict = self._get_dict_from_model()
try:
self.current_spec = panel_case_from_mapping(config_dict)
self._home_reset_required.update([
self.preview_canvas,
self.cp_canvas,
self.flow_canvas,
])
artifact = solve_panel_case_artifact(self.current_spec)
self._last_artifact = artifact
self.update_plots(solution_from_artifact(artifact))
save_path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
"Save Solved Artifact",
self._suggest_artifact_path(),
"YAML Files (*.yaml *.yml);;All Files (*)",
)
if save_path:
save_solved_case_artifact(artifact, save_path)
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Simulation Error", str(e))
def _on_view_changed(self) -> None:
"""
Response for when the axes limits change (zoom/pan).
Starts the view timer to debounce flow field recalculations. This
ensures that streamlines are only re-evaluated once the user has
finished interacting with the view.
"""
if self._last_results is not None and not self._is_rendering:
# Debounce view changes: wait for interaction to settle
self._view_timer.start(_FLOW_VIEW_DEBOUNCE_MS)
def _on_mouse_move(self, event: Any) -> None:
"""
Handle mouse movement.
This method updates the point probe display and CP tooltip when there
is a mouse movement event.
"""
if (
event.inaxes is None
or event.xdata is None
or event.ydata is None
or not self._tooltips_enabled
):
if hasattr(self, "_cp_cursor_line"):
self._cp_cursor_line.set_visible(False)
self._cp_cursor_markers.set_visible(False)
self._cp_cursor_text.set_visible(False)
self.cp_canvas.draw()
if hasattr(self, "_flow_cursor_markers"):
self._flow_cursor_markers.set_visible(False)
self._flow_cursor_text.set_visible(False)
self.flow_canvas.draw()
return
x, y = float(event.xdata), float(event.ydata)
# Interactive Tooltip for Pressure Coefficient tab
if event.inaxes == self.cp_canvas.ax and self._last_results is not None:
self._update_surface_cursor(x)
self.cp_canvas.draw()
elif (
hasattr(self, "_cp_cursor_line")
and self._cp_cursor_line.get_visible()
):
self._hide_surface_cursor()
self.cp_canvas.draw()
# Interactive Tooltip for Flow Field tab
if (
event.inaxes == self.flow_canvas.ax
and self._last_results is not None
):
try:
lines = build_flow_probe_lines(
self._last_results,
x_coord=x,
y_coord=y,
mode=self._flow_mode,
)
# Adjust alignment dynamically based on cursor position relative
# to axes limits to prevent occlusion by the colorbar or
# clipping at plot edges.
xlim = event.inaxes.get_xlim()
ylim = event.inaxes.get_ylim()
ha = "right" if x > (xlim[0] + xlim[1]) / 2 else "left"
va = "top" if y > (ylim[0] + ylim[1]) / 2 else "bottom"
self._flow_cursor_text.set_horizontalalignment(ha)
self._flow_cursor_text.set_verticalalignment(va)
self._flow_cursor_markers.set_visible(True)
self._flow_cursor_markers.set_data([x], [y])
self._flow_cursor_text.set_text("\n".join(lines))
self._flow_cursor_text.set_position((x, y))
self._flow_cursor_text.set_visible(True)
self.flow_canvas.draw()
except Exception:
self._flow_cursor_markers.set_visible(False)
self._flow_cursor_text.set_visible(False)
self.flow_canvas.draw()
elif (
hasattr(self, "_flow_cursor_markers")
and self._flow_cursor_markers.get_visible()
):
self._flow_cursor_markers.set_visible(False)
self._flow_cursor_text.set_visible(False)
self.flow_canvas.draw()
def _update_geometry_preview(self) -> None:
"""
Render a preview of the geometry and freestream direction.
This reads the current tree state and updates the 'Geometry' tab
canvas. It is called dynamically during configuration edits to provide
immediate visual feedback on sampling, placement, and freestream
orientation.
"""
config_dict = self._get_dict_from_model()
try:
# Attempt to build spec to get processed coordinates/units
spec = panel_case_from_mapping(config_dict)
self.current_spec = spec
except Exception:
# Silently ignore validation errors while editing
return
self._is_rendering = True
try:
bodies_list = []
for body_spec in spec.geometry.bodies:
try:
xf, yf = body_preview_coordinates(spec, body_spec)
bodies_list.append({"id": body_spec.id, "x": xf, "y": yf})
except Exception:
LOGGER.exception(
"Failed to build preview coordinates for body '%s'.",
body_spec.id,
)
continue
self.preview_canvas.render(
bodies_data=bodies_list,
alpha=spec.freestream.alpha,
units={"length": spec.units.length},
)
self.preview_canvas.ax.format_coord = ( # type: ignore[method-assign]
lambda x, y: ""
if self._tooltips_enabled
else f"x={x:.4f}, y={y:.4f}"
)
if self.preview_canvas in self._home_reset_required:
# Update Matplotlib "Home" to match the live preview.
toolbar = getattr(self.preview_canvas, "toolbar", None)
if toolbar:
toolbar.update()
self._home_reset_required.discard(self.preview_canvas)
self._setup_preview_callbacks()
finally:
self._is_rendering = False
def _add_body(self, parent_item: QtGui.QStandardItem) -> None:
"""
Add a new body definition to the geometry list.
Prompts for a body ID and adds a new default ``AirfoilBody2DSpec``
structure to the 'bodies' list in the model.
Parameters
----------
parent_item : QtGui.QStandardItem
The 'bodies' list item in the tree model.
"""
name, ok = QtWidgets.QInputDialog.getText(self, "Add Body", "Body ID:")
if not ok or not name:
return
root = self.model.invisibleRootItem()
if root is None:
return
body_data = build_default_body_mapping(body_id=name)
list_key = f"[{name}]" # Use the body ID as the display label
key_item = QtGui.QStandardItem(list_key)
key_item.setData(list_key, QtCore.Qt.ItemDataRole.UserRole)
key_item.setEditable(False)
key_item.setData(schema_tree(AirfoilBody2DSpec), SCHEMA_NODE_ROLE)
parent_item.appendRow(key_item)
self.model.blockSignals(True)
self.model.populate(body_data, key_item, schema_tree(AirfoilBody2DSpec))
self.model.blockSignals(False)
self.tree_view.expand(key_item.index())
self._update_geometry_preview()
def _remove_body(self, parent_item: QtGui.QStandardItem, row: int) -> None:
"""
Remove a body definition and re-index siblings.
Removes the specified row from the 'bodies' list and updates the
list index labels (e.g., [0], [1], [2]) for consistency.
Parameters
----------
parent_item : QtGui.QStandardItem
The 'bodies' list item.
row : int
The row index to remove.
"""
parent_item.removeRow(row)
# Re-index remaining children to maintain valid list keys [0], [1]...
for i in range(parent_item.rowCount()):
child = parent_item.child(i, 0)
if child is None:
continue
new_key = f"[{i}]"
child.setData(new_key, QtCore.Qt.ItemDataRole.UserRole)
self._update_geometry_preview()
def _convert_angles(self, to_rad: bool) -> None:
"""
Traverse the tree and convert all angle fields (alpha, rotation).
Parameters
----------
to_rad : bool
True if converting to radians, False for degrees.
"""
self.model.blockSignals(True)
convert_angle_fields(self.model.invisibleRootItem(), to_rad=to_rad)
self.model.blockSignals(False)
def _on_tree_selection_changed(self) -> None:
"""
Update the status bar with help text for the selected item.
Retrieves field metadata for the selected tree node and displays
short help and value constraints in the window's status bar.
"""
index = self.tree_view.currentIndex()
if not index.isValid():
return
# Always get metadata from column 0
meta = index.siblingAtColumn(0).data(METADATA_ROLE)
if meta:
# For status bar, use the short_help and constraints on one line
help_text = meta.get("short_help", "")
limits: list[str] = []
if (m := meta.get("minimum")) is not None:
limits.append(f"min: {m}")
if (m := meta.get("maximum")) is not None:
limits.append(f"max: {m}")
msg = str(help_text)
if limits:
msg = f"{msg} ({', '.join(limits)})".strip()
status_bar = self.statusBar()
if status_bar is not None:
status_bar.showMessage(msg)
def _on_view_timer_timeout(self) -> None:
"""
Recalculate flow field once view has settled.
This is triggered by the view debounce timer and calls the rendering
logic to update streamlines or contours for the new viewport zoom
or pan state.
"""
if self._last_results is not None and not self._is_rendering:
self._render_flow_field(self._last_results)
def _show_context_menu(self, position: QtCore.QPoint) -> None:
"""
Show context menu for tree items.
Parameters
----------
position : QtCore.QPoint
The position of the context menu request.
"""
index = self.tree_view.indexAt(position)
if not index.isValid():
return
# Ensure we are looking at column 0 for context menu logic
if index.column() != 0:
index = index.siblingAtColumn(0)
item = self.model.itemFromIndex(index)
if item is None:
return
key = item.data(QtCore.Qt.ItemDataRole.UserRole)
menu = QtWidgets.QMenu(self)
if key == "airfoils":
add_action = menu.addAction("Add Airfoil")
if add_action is not None:
add_action.triggered.connect(lambda: self._add_airfoil(item))
if key == "bodies":
add_action = menu.addAction("Add Body")
if add_action is not None:
add_action.triggered.connect(lambda: self._add_body(item))
parent = item.parent()
if parent:
parent_key = parent.data(QtCore.Qt.ItemDataRole.UserRole)
meta = parent.data(METADATA_ROLE)
min_items = (
cast(int, meta.get("min_items", 0))
if isinstance(meta, dict)
else 0
)
if parent_key == "airfoils":
if parent.rowCount() > min_items:
remove_action = menu.addAction(
f"Remove Airfoil '{item.text()}'"
)
if remove_action is not None:
remove_action.triggered.connect(
lambda: self._remove_airfoil(parent, item.row())
)
elif parent_key == "bodies" and parent.rowCount() > min_items:
remove_action = menu.addAction(f"Remove Body '{item.text()}'")
if remove_action is not None:
remove_action.triggered.connect(
lambda: self._remove_body(parent, item.row())
)
if not menu.isEmpty():
viewport = self.tree_view.viewport()
if viewport is not None:
menu.exec(viewport.mapToGlobal(position))
def _add_airfoil(self, parent_item: QtGui.QStandardItem) -> None:
"""
Add a new airfoil definition.
Prompts for an airfoil name and adds a new default NACA 4-digit
entry to the 'airfoils' mapping in the model.
Parameters
----------
parent_item : QtGui.QStandardItem
The 'airfoils' mapping item.
"""
name, ok = QtWidgets.QInputDialog.getText(
self, "Add Airfoil", "Airfoil Name:"
)
if not ok or not name:
return
key_item = QtGui.QStandardItem(name)
key_item.setData(name, QtCore.Qt.ItemDataRole.UserRole)
key_item.setEditable(True)
type_key = QtGui.QStandardItem("type")
type_key.setData("type", QtCore.Qt.ItemDataRole.UserRole)
type_key.setEditable(False)
type_val = QtGui.QStandardItem("naca4")
key_item.appendRow([type_key, type_val])
parent_item.appendRow(key_item)
# Initialize sub-items
self._on_item_changed(type_val)
self.tree_view.expand(key_item.index())
self._update_geometry_preview()
def _remove_airfoil(
self, parent_item: QtGui.QStandardItem, row: int
) -> None:
"""
Remove an airfoil definition.
Removes the specified row from the 'airfoils' mapping and updates the
geometry preview.
Parameters
----------
parent_item : QtGui.QStandardItem
The 'airfoils' mapping item.
row : int
The row index to remove.
"""
parent_item.removeRow(row)
self._update_geometry_preview()
def _copy_table_selection(self) -> None:
"""Copy the selected items in the results table to the clipboard."""
items = self.results_table.selectedItems()
if not items:
return
# Sort items by row then column to ensure logical order when pasting
# multiple values into a document or spreadsheet.
items.sort(key=lambda x: (x.row(), x.column()))
rows: dict[int, list[str]] = {}
for item in items:
if item.row() not in rows:
rows[item.row()] = []
rows[item.row()].append(item.text())
# Join columns with tabs and rows with newlines for standard data
# formatting
text = "\n".join("\t".join(r) for r in rows.values())
clipboard = QtWidgets.QApplication.clipboard()
if clipboard is not None:
clipboard.setText(text)
def _on_density_changed(self, value: int) -> None:
"""
Update the streamline density and refresh the plot.
Parameters
----------
value : int
The slider value (expected to be 10x the density).
"""
self._streamline_density = value / 10.0
if self._last_results is not None and not self._is_rendering:
self._render_flow_field(self._last_results)
def _on_flow_mode_changed(self, mode: str) -> None:
"""
Update the visualization mode for the flow field.
Parameters
----------
mode : str
The selected mode ('Streamlines' or 'Pressure Contours').
"""
self._flow_mode = mode
if hasattr(self, "density_label") and self.density_label:
self.density_label.setText(
"Streamline Density:"
if mode == "Streamlines"
else "Contour Density:"
)
if self._last_results is not None and not self._is_rendering:
self._render_flow_field(self._last_results)
def _on_surface_quantity_changed(self, quantity: str) -> None:
"""
Update the surface plot based on the selected quantity.
Parameters
----------
quantity : str
The selected display quantity.
"""
if self._last_results is not None and not self._is_rendering:
self._render_cp_plot(self._last_results)
def _handle_reset_view(self, canvas: MplCanvas) -> None:
"""
Reset the zoom/pan of the provided canvas to the default airfoil view.
Parameters
----------
canvas : MplCanvas
The canvas whose view should be reset.
"""
if canvas == self.flow_canvas:
self._streamline_density = 1.8
self._flow_mode = "Streamlines"
if self.density_slider is not None:
self.density_slider.blockSignals(True)
self.density_slider.setValue(int(self._streamline_density * 10))
self.density_slider.setEnabled(True)
self.density_slider.blockSignals(False)
if self.flow_mode_cb is not None:
self.flow_mode_cb.blockSignals(True)
self.flow_mode_cb.setCurrentText("Streamlines")
self.flow_mode_cb.blockSignals(False)
self._home_reset_required.add(canvas)
if canvas == self.preview_canvas:
self._update_geometry_preview()
return
if self._last_results is not None:
# Re-render so the Home state is rebuilt from the default limits.
if canvas == self.cp_canvas:
self._render_cp_plot(self._last_results)
elif canvas == self.flow_canvas:
self._render_flow_field(self._last_results)
# viewers don't have clear_and_reset, we use ax directly or render empty
elif canvas == self.flow_canvas:
canvas.ax.set_visible(False)
canvas.draw_idle()
[docs]
def update_plots(self, results: PanelSolution2D | None) -> None:
"""
Update the surface and field plots with simulation results.
Parameters
----------
results : PanelSolution2D | None
The solution results object containing geometry and field data.
"""
if results is not None:
self._last_results = results
if self._last_results is None:
return
self._sync_surface_quantity_options(self._last_results)
self._render_cp_plot(self._last_results)
self._render_flow_field(self._last_results)
self._render_integrated_results(self._last_results)
@staticmethod
def _surface_quantity_options(
results: PanelSolution2D,
) -> list[str]:
"""Return the surface quantities available for one solution."""
options: list[str] = []
surface = results.surface
if getattr(
surface,
"supports_surface_pressure",
getattr(surface, "cp", None) is not None,
):
options.append("Pressure Coefficient")
if getattr(
surface,
"supports_surface_velocity",
(
getattr(surface, "tangent_velocity", None) is not None
and getattr(surface, "normal_velocity", None) is not None
),
):
options.append("Speed")
if getattr(surface, "panel_lift_coefficient", None) is not None:
options.append("Panel Lift Coefficient")
return options or ["Pressure Coefficient"]
def _sync_surface_quantity_options(
self,
results: PanelSolution2D,
) -> None:
"""Update the quantity selector to match solution capabilities."""
if self.surface_quantity_cb is None:
return
options = self._surface_quantity_options(results)
current = self.surface_quantity_cb.currentText()
if self.surface_quantity_cb.count() == len(options) and all(
self.surface_quantity_cb.itemText(i) == option
for i, option in enumerate(options)
):
if current not in options:
self.surface_quantity_cb.setCurrentText(options[0])
return
self.surface_quantity_cb.blockSignals(True)
self.surface_quantity_cb.clear()
self.surface_quantity_cb.addItems(options)
self.surface_quantity_cb.setCurrentText(
current if current in options else options[0]
)
self.surface_quantity_cb.blockSignals(False)
def _selected_surface_quantity(
self,
results: PanelSolution2D,
) -> str:
"""Return the active surface quantity after capability filtering."""
if self.surface_quantity_cb is None:
return self._surface_quantity_options(results)[0]
current = self.surface_quantity_cb.currentText()
options = self._surface_quantity_options(results)
return current if current in options else options[0]
def _render_cp_plot(self, results: PanelSolution2D) -> None:
"""
Render the surface quantity distribution.
Parameters
----------
results : PanelSolution2D
Simulation results object.
"""
quantity = self._selected_surface_quantity(results)
if quantity == "Pressure Coefficient":
if not results.surface.supports_surface_pressure:
return
y_data = results.surface.cp
label = "$c_p$"
invert_y = True
elif quantity == "Speed":
y_data = results.surface.speed
label = "Speed"
invert_y = False
else:
if results.surface.panel_lift_coefficient is None:
return
y_data = results.surface.panel_lift_coefficient
label = "Panel $\\Delta c_l$"
invert_y = False
self.cp_canvas.render(
results.surface.x,
y_data,
label,
invert_y=invert_y,
)
self._init_surface_cursor_artists(self.cp_canvas.ax)
self.cp_canvas.ax.format_coord = ( # type: ignore[method-assign]
lambda x, y: ""
if self._tooltips_enabled
else f"x={x:.4f}, y={y:.4f}"
)
if self.cp_canvas in self._home_reset_required:
# Update the Matplotlib "Home" position to fit the Cp data
toolbar = getattr(self.cp_canvas, "toolbar", None)
if toolbar:
toolbar.update()
self._home_reset_required.discard(self.cp_canvas)
self.cp_canvas.draw()
def _init_surface_cursor_artists(self, ax: Any) -> None:
"""Create the cursor artists used by the surface plot tooltip."""
self._cp_cursor_line = ax.axvline(
0, color="gray", linestyle="--", alpha=0.5, visible=False
)
(self._cp_cursor_markers,) = ax.plot(
[], [], "ro", markersize=6, visible=False, zorder=5
)
self._cp_cursor_text = ax.text(
0,
0,
"",
bbox={"facecolor": "white", "alpha": 0.8, "edgecolor": "gray"},
fontsize=9,
verticalalignment="center",
visible=False,
zorder=6,
)
def _hide_surface_cursor(self) -> None:
"""Hide the surface-plot cursor artists."""
self._cp_cursor_line.set_visible(False)
self._cp_cursor_markers.set_visible(False)
self._cp_cursor_text.set_visible(False)
def _update_surface_cursor(self, x_coord: float) -> None:
"""Update the surface-plot tooltip for one x-location."""
if self._last_results is None:
return
self._cp_cursor_line.set_visible(True)
self._cp_cursor_line.set_xdata([x_coord, x_coord])
quantity = self._selected_surface_quantity(self._last_results)
surface_x = self._last_results.surface.x
if quantity == "Panel Lift Coefficient":
self._update_panel_lift_cursor(surface_x, x_coord)
return
self._update_absolute_surface_cursor(surface_x, x_coord, quantity)
def _update_panel_lift_cursor(
self,
surface_x: Any,
x_coord: float,
) -> None:
"""Update the cursor for a panel lift distribution plot."""
if self._last_results is None:
return
lift_distribution = self._last_results.surface.panel_lift_coefficient
if lift_distribution is None:
self._cp_cursor_markers.set_visible(False)
self._cp_cursor_text.set_visible(False)
return
order = np.argsort(surface_x)
value = float(
np.interp(x_coord, surface_x[order], lift_distribution[order])
)
self._cp_cursor_markers.set_visible(True)
self._cp_cursor_markers.set_data([x_coord], [value])
self._cp_cursor_text.set_horizontalalignment("left")
self._cp_cursor_text.set_text(
"\n".join([
f"x: {x_coord:.4f}",
f"Panel $\\Delta c_l$: {value:.4f}",
])
)
self._cp_cursor_text.set_position((x_coord, value))
self._cp_cursor_text.set_visible(True)
def _update_absolute_surface_cursor(
self,
surface_x: Any,
x_coord: float,
quantity: str,
) -> None:
"""Update the cursor for Cp or speed surface plots."""
if self._last_results is None:
return
is_cp = quantity == "Pressure Coefficient"
label = "$c_p$" if is_cp else "Speed"
delta_sign = 1 if is_cp else -1
quantity_values = (
self._last_results.surface.cp
if is_cp
else self._last_results.surface.speed
)
sample = sample_surface_quantity_at_x(
surface_x,
quantity_values,
x_coord,
)
if sample is None:
self._cp_cursor_markers.set_visible(False)
self._cp_cursor_text.set_visible(False)
return
x_min = float(np.min(surface_x))
x_max = float(np.max(surface_x))
qty_up = sample.upper_value
qty_lo = sample.lower_value
self._cp_cursor_markers.set_visible(True)
self._cp_cursor_markers.set_data([x_coord, x_coord], [qty_up, qty_lo])
ha = "right" if x_coord > (x_min + x_max) / 2 else "left"
self._cp_cursor_text.set_horizontalalignment(ha)
lines = [
f"x: {x_coord:.4f}",
f"Upper {label}: {qty_up:.4f}",
f"Lower {label}: {qty_lo:.4f}",
rf"$\Delta$ {label}: {delta_sign * (qty_lo - qty_up):.4f}",
]
self._cp_cursor_text.set_text("\n".join(lines))
self._cp_cursor_text.set_position((x_coord, (qty_up + qty_lo) / 2))
self._cp_cursor_text.set_visible(True)
def _init_flow_cursor_artists(self, ax: Any) -> None:
"""Create the cursor artists used by the flow plot tooltip."""
(self._flow_cursor_markers,) = ax.plot(
[], [], "ro", markersize=6, visible=False, zorder=20
)
self._flow_cursor_text = ax.text(
0,
0,
"",
bbox={"facecolor": "white", "alpha": 0.8, "edgecolor": "gray"},
fontsize=9,
verticalalignment="bottom",
horizontalalignment="left",
visible=False,
zorder=21,
)
def _render_flow_field(self, results: PanelSolution2D) -> None:
"""
Render streamlines based on current axes limits.
Parameters
----------
results : PanelSolution2D
Simulation results object.
"""
self._is_rendering = True
try:
# 1. Update internal ViewState based on current canvas limits
ax = self.flow_canvas.ax
self._flow_view_state.xlim = (
float(ax.get_xlim()[0]),
float(ax.get_xlim()[1]),
)
self._flow_view_state.ylim = (
float(ax.get_ylim()[0]),
float(ax.get_ylim()[1]),
)
geom = results.geometry
if self.flow_canvas in self._home_reset_required:
xl, yl = get_default_view_limits(geom.x, geom.y)
self._flow_view_state.xlim, self._flow_view_state.ylim = xl, yl
self._flow_view_state.force_home = True
# 2. Delegate render to FlowViewer
self.flow_canvas.render(
results=results,
mode=self._flow_mode,
density=self._streamline_density,
view_state=self._flow_view_state,
)
self._init_flow_cursor_artists(self.flow_canvas.ax)
self.flow_canvas.ax.format_coord = ( # type: ignore[method-assign]
lambda x, y: ""
if self._tooltips_enabled
else f"x={x:.4f}, y={y:.4f}"
)
if self.flow_canvas in self._home_reset_required:
toolbar = getattr(self.flow_canvas, "toolbar", None)
if toolbar:
toolbar.update()
self._home_reset_required.discard(self.flow_canvas)
self.flow_canvas.draw()
self._setup_flow_callbacks()
finally:
self._is_rendering = False
def _render_integrated_results(self, results: PanelSolution2D) -> None:
"""
Update the integrated results table.
Parameters
----------
results : PanelSolution2D
Simulation results object.
"""
has_surface_pressure = getattr(
results.surface,
"supports_surface_pressure",
True,
)
load_label = "Pressure" if has_surface_pressure else "Panel Sum"
quantities = {
f"Lift Coefficient ({load_label})": results.integrated.cl_pressure,
"Lift Coefficient (Circulation)": results.integrated.cl_circulation,
f"Drag Coefficient ({load_label})": results.integrated.cd_pressure,
(
f"Moment Coefficient ({load_label})"
): results.integrated.cm_pressure,
}
self.results_table.setRowCount(len(quantities))
for i, (name, val) in enumerate(quantities.items()):
name_item = QtWidgets.QTableWidgetItem(name)
val_item = QtWidgets.QTableWidgetItem(f"{val:.6e}")
# Ensure items are selectable but not editable
flags = (
QtCore.Qt.ItemFlag.ItemIsEnabled
| QtCore.Qt.ItemFlag.ItemIsSelectable
)
name_item.setFlags(flags)
val_item.setFlags(flags)
self.results_table.setItem(i, 0, name_item)
self.results_table.setItem(i, 1, val_item)
def _on_item_changed(self, item: QtGui.QStandardItem) -> None:
"""
Handle tree item changes for dynamic updates and unit conversions.
Parameters
----------
item : QtGui.QStandardItem
The item in the model that was modified.
"""
if item.column() == 0:
# Handle renaming of user-defined keys (e.g. children of 'airfoils')
parent = item.parent()
if (
parent
and parent.data(QtCore.Qt.ItemDataRole.UserRole) == "airfoils"
):
# Update the key data used for dict reconstruction
item.setData(item.text(), QtCore.Qt.ItemDataRole.UserRole)
self._update_geometry_preview()
return
if item.column() != 1:
return
key_index = item.index().siblingAtColumn(0)
key_item = self.model.itemFromIndex(key_index)
if key_item is None:
return
key = key_item.data(QtCore.Qt.ItemDataRole.UserRole)
parent_item = key_item.parent()
p_key = (
parent_item.data(QtCore.Qt.ItemDataRole.UserRole)
if parent_item
else None
)
# Handle unit conversion for angles
if key == "angle" and p_key == "units":
self._convert_angles(item.text() == "rad")
if key == "type":
airfoil_item = parent_item
if airfoil_item and airfoil_item.parent():
item_parent = airfoil_item.parent()
if (
item_parent is not None
and item_parent.data(QtCore.Qt.ItemDataRole.UserRole)
== "airfoils"
):
self.model.blockSignals(True)
sync_airfoil_definition_rows(airfoil_item, item.text())
self.model.blockSignals(False)
self.tree_view.expandAll()
self.tree_view.resizeColumnToContents(0)
self._update_geometry_preview()
def _show_plot_context_menu(self, position: QtCore.QPoint) -> None:
"""Show context menu for plot canvases to toggle tooltips."""
canvas = self.sender()
if not isinstance(canvas, FigureCanvas):
return
menu = QtWidgets.QMenu(self)
action = menu.addAction("Enable Tooltips")
if action is None:
return
action.setCheckable(True)
action.setChecked(self._tooltips_enabled)
def toggle() -> None:
self._tooltips_enabled = action.isChecked()
# If disabling, force immediate hide
if not self._tooltips_enabled:
if hasattr(self, "_cp_cursor_line"):
self._cp_cursor_line.set_visible(False)
self._cp_cursor_markers.set_visible(False)
self._cp_cursor_text.set_visible(False)
self.cp_canvas.draw()
if hasattr(self, "_flow_cursor_markers"):
self._flow_cursor_markers.set_visible(False)
self._flow_cursor_text.set_visible(False)
self.flow_canvas.draw()
action.triggered.connect(toggle)
menu.exec(canvas.mapToGlobal(position))
def _export_canvas_as_pgf(self, canvas: MplCanvas) -> None:
"""
Export a plot canvas to a PGF file with user-specified dimensions.
Parameters
----------
canvas : MplCanvas
The canvas to export.
"""
if canvas == self.preview_canvas:
if self.current_spec is None:
QtWidgets.QMessageBox.warning(
self, "Save PGF", "No geometry available to save."
)
return
elif self._last_results is None:
QtWidgets.QMessageBox.warning(
self, "Save PGF", "No results available to save."
)
return
width, ok = QtWidgets.QInputDialog.getDouble(
self, "Save as PGF", "Width [inches]:", 6.0, 0.1, 20.0, 2
)
if not ok:
return
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self, "Save as PGF", "", "PGF Files (*.pgf)"
)
if not path:
return
fig = canvas.fig
original_size = fig.get_size_inches()
original_width = float(original_size[0])
original_height = float(original_size[1])
try:
ax = canvas.ax
bbox = ax.get_window_extent()
aspect = bbox.height / bbox.width
# Hide interactive probe elements if visible before saving
if canvas == self.cp_canvas:
if (
hasattr(self, "_cp_cursor_line")
and self._cp_cursor_line.get_visible()
):
self._cp_cursor_line.set_visible(False)
self._cp_cursor_markers.set_visible(False)
self._cp_cursor_text.set_visible(False)
self.cp_canvas.draw()
elif (
canvas == self.flow_canvas
and hasattr(self, "_flow_cursor_markers")
and self._flow_cursor_markers.get_visible()
):
self._flow_cursor_markers.set_visible(False)
self._flow_cursor_text.set_visible(False)
self.flow_canvas.draw()
fig.set_size_inches(width, width * aspect)
# Save using the pgf backend
with latex_pgf_export_style(fig):
fig.savefig(path, format="pgf", bbox_inches="tight")
QtWidgets.QMessageBox.information(
self, "Save PGF", f"Successfully saved to:\n{path}"
)
except Exception as e:
QtWidgets.QMessageBox.critical(
self, "Save Error", f"Failed to save PGF:\n{e!s}"
)
finally:
# Restore original figure size for the GUI
fig.set_size_inches(original_width, original_height)
if canvas == self.flow_canvas and self._last_results is not None:
# Force a full re-render of flow to ensure it's not blank
self._render_flow_field(self._last_results)
canvas.draw()
def main() -> None:
"""Entry point for GUI application."""
# Silence matplotlib aspect ratio adjustment warnings in the console
logging.getLogger("matplotlib").setLevel(logging.ERROR)
app = QtWidgets.QApplication(sys.argv)
gui = Panel2dGUI()
gui.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()