Source code for buffalo_panel.app.gui.panel2d_gui

"""
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()