Source code for buffalo_panel.app.gui.internal.viewers

"""Specialized Matplotlib viewers for Buffalo Panel GUI."""
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownVariableType=false
# pyright: reportUnknownArgumentType=false

from __future__ import annotations

from contextlib import suppress
from typing import Any, cast

import matplotlib.tri as mtri
import numpy as np
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from mpl_toolkits.axes_grid1 import (  # type: ignore[import-untyped]
    make_axes_locatable,
)
from PyQt6 import QtWidgets

from buffalo_panel.app.gui.internal.view_logic import (
    ViewState,
    expand_limits_to_aspect,
    generate_field_grid,
    get_adaptive_field_resolution,
    get_default_view_limits,
    get_field_surface_exclusion_radius,
    interpolate_surface_nodes,
    is_far_from_geometry_panels,
    is_outside_body,
)
from buffalo_panel.post import FieldPoints2D
from buffalo_panel.type_aliases import FloatArray


[docs] class MplCanvas(FigureCanvas): """Base interactive canvas for aerodynamic plots.""" def __init__( self, parent: QtWidgets.QWidget | None = None, width: float = 5, height: float = 4, dpi: int = 100, ) -> None: self.fig = Figure(figsize=(width, height), dpi=dpi) self.ax = self.fig.add_subplot(111) super().__init__(self.fig) # type: ignore[no-untyped-call] self.setParent(parent) self.ax.set_aspect("equal", adjustable="datalim") self.ax.grid(True, linestyle="--", alpha=0.6) self.toolbar: Any | None = None
[docs] class BaseViewer(MplCanvas): """Base class for viewers with common rendering lifecycle.""" def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) self.view_state = ViewState()
[docs] def prepare_axes(self) -> None: """Clear and standardize axes before rendering.""" self.ax.clear() self.ax.set_autoscale_on(False) self.ax.set_aspect("equal", adjustable="datalim") self.ax.grid(True, linestyle="--", alpha=0.6)
[docs] def apply_view(self) -> None: """Sync the physical axes with the internal ViewState.""" self.ax.set_xlim(self.view_state.xlim) self.ax.set_ylim(self.view_state.ylim) # Force layout update to ensure aspect ratio is honored self.fig.canvas.draw() self.flush_events() # type: ignore[no-untyped-call]
[docs] class GeometryViewer(BaseViewer): """Handles body previews and freestream indicators.""" def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) self._arrow: Any = None self._ref_line: Any = None self._label: Any = None
[docs] def render( self, bodies_data: list[dict[str, Any]], alpha: float, units: dict[str, str], ) -> None: """Render the geometry preview and freestream indicator.""" self.ax.clear() self.ax.set_visible(True) self.ax.set_autoscale_on(True) all_x, all_y = [], [] for body in bodies_data: x, y = body["x"], body["y"] self.ax.plot(x, y, label=body["id"], linewidth=1.5) self.ax.fill(x, y, alpha=0.15) all_x.extend(x) all_y.extend(y) self.ax.set_xlabel(f"x [{units.get('length', 'm')}]") self.ax.set_ylabel(f"y [{units.get('length', 'm')}]") self.ax.set_aspect("equal", adjustable="datalim") self.ax.grid(True, linestyle="--", alpha=0.6) if all_x: self.ax.legend(loc="upper right") self.ax.relim() self.ax.autoscale_view() self.ax.apply_aspect() self.ax.set_autoscale_on(False) self._draw_freestream_arrow(alpha) self.draw() # type: ignore[no-untyped-call] self.flush_events() # type: ignore[no-untyped-call]
def _draw_freestream_arrow(self, alpha: float) -> None: with suppress(Exception): for artist in [self._arrow, self._ref_line, self._label]: if artist: artist.remove() xlim, ylim = self.ax.get_xlim(), self.ax.get_ylim() dx, dy = xlim[1] - xlim[0], ylim[1] - ylim[0] arrow_l = 0.2 * dx x_tail, y_pos = xlim[0] + 0.05 * dx, ylim[0] + 0.05 * dy rad = np.deg2rad(alpha) u, v = np.cos(rad), np.sin(rad) self._arrow = self.ax.quiver( x_tail, y_pos, u, v, width=0.005, scale=1.0 / arrow_l, scale_units="xy", zorder=15, ) self._label = self.ax.text( x_tail + 0.5 * arrow_l * u, y_pos + 0.5 * arrow_l * v + 0.02 * dy, r"$U_\infty$", fontsize=14, ha="center", zorder=16, ) (self._ref_line,) = self.ax.plot( [x_tail, x_tail + arrow_l], [y_pos, y_pos], color="gray", linestyle="--", zorder=14, )
[docs] class SurfaceViewer(BaseViewer): """Handles surface distributions and interactive cursors.""" def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) self.ax.set_aspect("auto")
[docs] def render( self, x: Any, y: Any, label: str, invert_y: bool = False ) -> None: """Render a one-dimensional surface quantity distribution.""" self.ax.clear() self.ax.set_visible(True) self.ax.set_aspect("auto") self.ax.plot(x, y, "b-o", markersize=3) self.ax.set_xlabel("$x/c$") self.ax.set_ylabel(label) if invert_y: self.ax.invert_yaxis() self.ax.grid(True, linestyle="--", alpha=0.6) # Pad to match FlowViewer width divider = make_axes_locatable(self.ax) divider.append_axes("right", size="5%", pad=0.1).set_visible(False) self.draw() # type: ignore[no-untyped-call] self.flush_events() # type: ignore[no-untyped-call]
[docs] class FlowViewer(BaseViewer): """Handles streamlines and pressure contours with ViewState awareness.""" def __init__(self, parent: QtWidgets.QWidget | None = None) -> None: super().__init__(parent) self.colorbar: Any | None = None self._colorbar_axes: Any | None = None
[docs] def render( self, results: Any, mode: str, density: float, view_state: ViewState ) -> None: """Render the active flow-field view for the current viewport.""" self.view_state = view_state ax = self.ax # 1. Clean up and layout sync if self.colorbar: self.colorbar.remove() self.colorbar = None for other_ax in self.fig.axes[:]: if other_ax is not ax: self.fig.delaxes(other_ax) self._colorbar_axes = None ax.clear() ax.set_visible(True) ax.set_autoscale_on(True) ax.set_aspect("equal", adjustable="datalim") self.fig.canvas.draw() cax = self._create_colorbar_axes() if mode != "Streamlines" else None # 2. Limit Calculation xlim, ylim = view_state.xlim, view_state.ylim bbox = ax.get_window_extent() xlim, ylim = expand_limits_to_aspect( xlim, ylim, bbox.width, bbox.height ) ax.set_xlim(xlim) ax.set_ylim(ylim) ax.set_autoscale_on(False) # 3. Grid Generation geom = results.geometry default_xlim, default_ylim = get_default_view_limits(geom.x, geom.y) resolution = get_adaptive_field_resolution( default_xlim, default_ylim, xlim, ylim, ) x_field, y_field = generate_field_grid( xlim, ylim, resolution=resolution, ) outside = is_outside_body(geom.x, geom.y, x_field, y_field) panel_clear = is_far_from_geometry_panels( geom.x, geom.y, x_field, y_field, min_distance=get_field_surface_exclusion_radius( xlim, ylim, resolution, ), ) outside = np.logical_and(outside, panel_clear) # 4. Mode-specific drawing if mode == "Streamlines": self._draw_streamlines( results, geom, x_field, y_field, outside, density ) else: self._draw_contours( results, geom, x_field, y_field, outside, density, cax ) # 5. Body overlay ax.fill( geom.x, geom.y, facecolor="lightgray", edgecolor="black", linewidth=1, zorder=10, ) ax.set_xlabel("$x/c$") ax.set_ylabel("$y/c$") ax.grid(True, linestyle="--", alpha=0.6) self.draw() # type: ignore[no-untyped-call] self.flush_events() # type: ignore[no-untyped-call]
def _create_colorbar_axes(self) -> Any: """Create a colorbar axes that does not resize the main plot.""" position = self.ax.get_position() pad = 0.012 width = 0.025 cax = self.fig.add_axes(( position.x1 + pad, position.y0, width, position.height, )) self._colorbar_axes = cax return cax def _draw_streamlines( self, results: Any, geom: Any, x_f: Any, y_f: Any, out: Any, dens: float ) -> None: pts = FieldPoints2D.from_inputs(x_f[out], y_f[out]) u_out, v_out = results.velocity_at(pts) tangent_velocity = getattr(results.surface, "tangent_velocity", None) normal_velocity = getattr(results.surface, "normal_velocity", None) supports_surface_velocity = getattr( results.surface, "supports_surface_velocity", tangent_velocity is not None and normal_velocity is not None, ) x_all = np.asarray(x_f[out], dtype=np.float64) y_all = np.asarray(y_f[out], dtype=np.float64) u_all = np.asarray(u_out, dtype=np.float64) v_all = np.asarray(v_out, dtype=np.float64) if supports_surface_velocity: u_s = tangent_velocity * geom.s_x + normal_velocity * geom.n_x v_s = tangent_velocity * geom.s_y + normal_velocity * geom.n_y x_all = np.concatenate([x_all, results.surface.x]) y_all = np.concatenate([y_all, results.surface.y]) u_all = np.concatenate([u_all, u_s]) v_all = np.concatenate([v_all, v_s]) tri = mtri.Triangulation(x_all, y_all) u_full = mtri.LinearTriInterpolator(tri, u_all)(x_f, y_f) v_full = mtri.LinearTriInterpolator(tri, v_all)(x_f, y_f) self.ax.streamplot( x_f, y_f, u_full, v_full, color="cornflowerblue", density=dens, linewidth=1, ) def _draw_contours( self, results: Any, geom: Any, x_f: Any, y_f: Any, out: Any, dens: float, cax: Any, ) -> None: pts = FieldPoints2D.from_inputs(x_f[out], y_f[out]) cp_out = results.pressure_coefficient_at(pts) surface_cp = getattr(results.surface, "cp", None) supports_surface_pressure = getattr( results.surface, "supports_surface_pressure", surface_cp is not None, ) x_all = np.asarray(x_f[out], dtype=np.float64) y_all = np.asarray(y_f[out], dtype=np.float64) cp_all = np.asarray(cp_out, dtype=np.float64) if supports_surface_pressure: surface_cp_array = cast(FloatArray, surface_cp) cp_nodes = interpolate_surface_nodes( results.surface.x, results.surface.y, surface_cp_array, geom.x, geom.y, ) x_all = np.concatenate([x_all, results.surface.x, geom.x]) y_all = np.concatenate([y_all, results.surface.y, geom.y]) cp_all = np.concatenate([cp_all, surface_cp_array, cp_nodes]) finite_cp = cp_all[np.isfinite(cp_all)] if finite_cp.size == 0: return vmin = np.floor(np.min(finite_cp) * 10.0) / 10.0 vmax = np.ceil(np.max(finite_cp) * 10.0) / 10.0 if np.isclose(vmin, vmax): vmin -= 0.1 vmax += 0.1 n_levels = int(dens * 10) cont = self.ax.tricontourf( x_all, y_all, cp_all, levels=np.linspace(vmin, vmax, n_levels + 1), cmap="RdBu_r", ) self.colorbar = self.fig.colorbar(cont, cax=cax) self.colorbar.set_label("$c_p$")