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

"""Mathematical and geometric logic for GUI view management."""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import numpy.typing as npt
from matplotlib.path import Path as MplPath

from buffalo_panel.post import FieldPoints2D
from buffalo_panel.post.internal.results import PanelSolution2D


[docs] @dataclass(slots=True) class ViewState: """Single source of truth for a plot viewport.""" xlim: tuple[float, float] = (0.0, 1.0) ylim: tuple[float, float] = (0.0, 1.0) force_home: bool = True
[docs] @dataclass(slots=True) class SurfaceProbeSample: """Interpolated upper and lower surface values at a single x-location.""" x: float upper_value: float lower_value: float
[docs] def get_default_view_limits( geometry_x: npt.NDArray[np.float64], geometry_y: npt.NDArray[np.float64], ) -> tuple[tuple[float, float], tuple[float, float]]: """Return default plot limits centered on the geometry.""" if len(geometry_x) == 0: return (0.0, 1.0), (0.0, 1.0) x_min, x_max = np.min(geometry_x), np.max(geometry_x) y_min, y_max = np.min(geometry_y), np.max(geometry_y) x_mid = (x_min + x_max) / 2.0 y_mid = (y_min + y_max) / 2.0 chord = max(x_max - x_min, 1.0e-6) return (x_mid - chord, x_mid + chord), ( y_mid - 0.5 * chord, y_mid + 0.5 * chord, )
[docs] def expand_limits_to_aspect( xlim: tuple[float, float], ylim: tuple[float, float], axes_width: float, axes_height: float, ) -> tuple[tuple[float, float], tuple[float, float]]: """ Expand limits to match a physical aspect ratio. This ensures that 'equal' aspect scaling doesn't cause unexpected clipping or missing streamlines when the window is resized. """ if axes_width <= 0 or axes_height <= 0: return xlim, ylim ax_aspect = axes_height / axes_width dx = xlim[1] - xlim[0] dy = ylim[1] - ylim[0] if dy / dx < ax_aspect: target_dy = dx * ax_aspect mid_y = (ylim[0] + ylim[1]) / 2 return xlim, (mid_y - target_dy / 2, mid_y + target_dy / 2) target_dx = dy / ax_aspect mid_x = (xlim[0] + xlim[1]) / 2 return (mid_x - target_dx / 2, mid_x + target_dx / 2), ylim
[docs] def is_outside_body( geometry_x: npt.NDArray[np.float64], geometry_y: npt.NDArray[np.float64], x_field: npt.NDArray[np.float64], y_field: npt.NDArray[np.float64], ) -> npt.NDArray[np.bool_]: """Identify field points that are outside the geometry polygon.""" polygon = MplPath(np.column_stack((geometry_x, geometry_y))) points = np.column_stack((x_field.ravel(), y_field.ravel())) radius = _polygon_radius(geometry_x, geometry_y) inside = np.asarray( polygon.contains_points(points, radius=radius), dtype=np.bool_, ) return np.logical_not(inside.reshape(x_field.shape))
[docs] def get_field_surface_exclusion_radius( xlim: tuple[float, float], ylim: tuple[float, float], resolution: int, ) -> float: """Return a surface-exclusion radius based on the current grid spacing.""" if resolution <= 1: return 0.0 dx = (xlim[1] - xlim[0]) / float(resolution - 1) dy = (ylim[1] - ylim[0]) / float(resolution - 1) return float(np.maximum(np.hypot(dx, dy), 0.005))
[docs] def is_far_from_geometry_panels( geometry_x: npt.NDArray[np.float64], geometry_y: npt.NDArray[np.float64], x_field: npt.NDArray[np.float64], y_field: npt.NDArray[np.float64], *, min_distance: float, ) -> npt.NDArray[np.bool_]: """Identify field points that are at least ``min_distance`` from panels.""" if min_distance <= 0.0: return np.ones_like(x_field, dtype=np.bool_) point_x = x_field.ravel() point_y = y_field.ravel() min_distance_sq: npt.NDArray[np.float64] = np.full( point_x.shape, np.inf, dtype=np.float64, ) segment_x0 = geometry_x segment_y0 = geometry_y segment_x1 = np.roll(geometry_x, -1) segment_y1 = np.roll(geometry_y, -1) for x0, y0, x1, y1 in zip( segment_x0, segment_y0, segment_x1, segment_y1, strict=True, ): dx = float(x1 - x0) dy = float(y1 - y0) length_sq = dx * dx + dy * dy if length_sq <= 0.0: distance_sq = np.asarray( (point_x - x0) ** 2 + (point_y - y0) ** 2, dtype=np.float64, ) else: projection = ((point_x - x0) * dx + (point_y - y0) * dy) / length_sq projection = np.clip(projection, 0.0, 1.0) closest_x = x0 + projection * dx closest_y = y0 + projection * dy distance_sq = np.asarray( (point_x - closest_x) ** 2 + (point_y - closest_y) ** 2, dtype=np.float64, ) min_distance_sq = np.asarray( np.minimum(min_distance_sq, distance_sq), dtype=np.float64, ) return (min_distance_sq >= min_distance**2).reshape(x_field.shape)
[docs] def generate_field_grid( xlim: tuple[float, float], ylim: tuple[float, float], resolution: int = 150, ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: """Generate a Cartesian mesh for field evaluation.""" x_grid = np.linspace(xlim[0], xlim[1], resolution) y_grid = np.linspace(ylim[0], ylim[1], resolution) # Cast to Any to avoid meshgrid return type ambiguities in some numpy # versions. return np.meshgrid(x_grid, y_grid) # type: ignore
[docs] def get_adaptive_field_resolution( default_xlim: tuple[float, float], default_ylim: tuple[float, float], xlim: tuple[float, float], ylim: tuple[float, float], *, base_resolution: int = 150, max_resolution: int = 450, ) -> int: """Scale field-grid resolution up when the current view is zoomed in.""" default_dx = max(default_xlim[1] - default_xlim[0], 1.0e-12) default_dy = max(default_ylim[1] - default_ylim[0], 1.0e-12) dx = max(xlim[1] - xlim[0], 1.0e-12) dy = max(ylim[1] - ylim[0], 1.0e-12) zoom_factor = max(default_dx / dx, default_dy / dy, 1.0) scaled_resolution = int(np.ceil(base_resolution * np.sqrt(zoom_factor))) return min(max_resolution, max(base_resolution, scaled_resolution))
[docs] def interpolate_surface_nodes( x_panels: npt.NDArray[np.float64], y_panels: npt.NDArray[np.float64], qty_panels: npt.NDArray[np.float64], x_nodes: npt.NDArray[np.float64], y_nodes: npt.NDArray[np.float64], ) -> npt.NDArray[np.float64]: """Interpolate panel-centered quantities onto boundary nodes.""" s_full = np.cumsum( np.sqrt( np.diff(x_panels, prepend=x_panels[0]) ** 2 + np.diff(y_panels, prepend=y_panels[0]) ** 2 ) ) # If x_panels represents panel nodes (length N+1) and qty_panels are panel # values (length N), we use the start of each panel for the xp argument. # Otherwise assume x_panels are the locations where qty_panels live. s_xp = s_full[:-1] if len(x_panels) == len(qty_panels) + 1 else s_full s_nodes = np.cumsum( np.sqrt( np.diff(x_nodes, prepend=x_nodes[0]) ** 2 + np.diff(y_nodes, prepend=y_nodes[0]) ** 2 ) ) return np.interp(s_nodes, s_xp, qty_panels)
[docs] def sample_surface_quantity_at_x( surface_x: npt.NDArray[np.float64], surface_quantity: npt.NDArray[np.float64], x_coord: float, ) -> SurfaceProbeSample | None: """Interpolate upper and lower surface values at one x-location.""" x_min = float(np.min(surface_x)) x_max = float(np.max(surface_x)) if x_coord < x_min or x_coord > x_max: return None mid = surface_x.size // 2 lower_x = surface_x[:mid] lower_quantity = surface_quantity[:mid] upper_x = surface_x[mid:] upper_quantity = surface_quantity[mid:] lower_order = np.argsort(lower_x) upper_order = np.argsort(upper_x) lower_value = float( np.interp( x_coord, lower_x[lower_order], lower_quantity[lower_order], ) ) upper_value = float( np.interp( x_coord, upper_x[upper_order], upper_quantity[upper_order], ) ) return SurfaceProbeSample( x=x_coord, upper_value=upper_value, lower_value=lower_value, )
[docs] def build_flow_probe_lines( results: PanelSolution2D, *, x_coord: float, y_coord: float, mode: str, ) -> list[str]: """Return the tooltip lines for one flow-field probe location.""" geom = results.geometry # TODO: This needs to be scaled by simulation reference length lines = [f"x: {x_coord:.4f}, y: {y_coord:.4f}"] if point_is_inside_body(geom.x, geom.y, x_coord=x_coord, y_coord=y_coord): return lines target = FieldPoints2D.from_inputs( np.array([x_coord], dtype=np.float64), np.array([y_coord], dtype=np.float64), ) if mode == "Streamlines": u_val, v_val = results.velocity_at(target) u_inf = results.freestream.speed lines.append( rf"$u/U_\infty$: {u_val[0] / u_inf:.4f}, " rf"$v/U_\infty$: {v_val[0] / u_inf:.4f}" ) else: cp_val = results.pressure_coefficient_at(target) lines.append(f"$c_p$: {cp_val[0]:.4f}") return lines
[docs] def point_is_inside_body( geometry_x: npt.NDArray[np.float64], geometry_y: npt.NDArray[np.float64], *, x_coord: float, y_coord: float, ) -> bool: """Return whether a point lies inside or on the body boundary.""" polygon = MplPath(np.column_stack((geometry_x, geometry_y))) return bool( polygon.contains_point( (x_coord, y_coord), radius=_polygon_radius(geometry_x, geometry_y), ) )
def _polygon_radius( geometry_x: npt.NDArray[np.float64], geometry_y: npt.NDArray[np.float64], ) -> float: span = max( float(np.ptp(geometry_x)), float(np.ptp(geometry_y)), 1.0, ) return 1.0e-12 * span