diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a2efc1f..b876579 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: Tests on: push: - branches: [main, master, develop] + branches: [main, master, develop, 'feature/**'] pull_request: branches: [main, master, develop] @@ -23,6 +23,16 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Install system dependencies (Ubuntu) + if: runner.os == 'Linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + libgl1 libglx-mesa0 libegl1 libegl-mesa0 \ + libxcb-xinerama0 libxcb-icccm4 libxcb-image0 \ + libxcb-render-util0 libxkbcommon-x11-0 \ + x11-utils xvfb + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -30,10 +40,16 @@ jobs: pip install pytest pytest-cov - name: Run tests + env: + QT_QPA_PLATFORM: offscreen + DISPLAY: ":99" run: | python -m pytest tests/ -v --tb=short - name: Run tests with coverage + env: + QT_QPA_PLATFORM: offscreen + DISPLAY: ":99" run: | python -m pytest tests/ --cov=src --cov-report=xml --cov-report=term-missing diff --git a/README.md b/README.md index a1e9da0..ce4716e 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,21 @@ PyDebFlow implements a **two-phase (solid + fluid) shallow water model** with ad --- +## ๐Ÿ†• What's New in v0.2.0 + +| Feature | Description | +|---------|-------------| +| **๐Ÿ‘‘ Crown Zone** | Interactive release zone with lat/lon coordinate entry, polygon drawing, and single-click Remove | +| **๐Ÿ“ Cross-Section Profile** | RAMMS-style elevation + flow profile along any transect; x-axis at 0.1 m resolution; zoom/pan + save | +| **๐Ÿ“ˆ Hydrograph** | Multi-point flow height, velocity & discharge over time on the same graph; up to 8 concurrent monitor points | +| **๐Ÿ“Š Statistics Tab** | Descriptive, spatial, temporal, phase, and hypothesis-test (Shapiro-Wilk, KS) output; exportable as CSV/TXT | +| **๐ŸŽš๏ธ 3D Time Slider** | Drag to any simulation timestep in the interactive PyVista viewer | +| **๐Ÿ” Zoom / Pan** | Scroll-wheel zoom on terrain maps; matplotlib NavigationToolbar on profile & hydrograph | +| **๐Ÿ’พ Save Plots** | One-click export of profile and hydrograph as PNG / PDF / SVG | +| **๐Ÿ“ Polygon Crown** | Draw arbitrary polygons on the DEM via GUI or define via `--release-polygon` in the CLI | + +--- + ## โœจ Features ### ๐Ÿงฎ Numerical Solver @@ -124,9 +139,14 @@ PyDebFlow implements a **two-phase (solid + fluid) shallow water model** with ad ### ๐Ÿ–ฅ๏ธ User Interface - **Modern PyQt6 GUI** - Dark-themed professional desktop application +- **๐Ÿ‘‘ Crown Zone Tab** - Interactive terrain map with lat/lon coordinate entry, pont and polygon marking, and Remove button +- **๐Ÿ“ Cross-Section Profile** - Draw transects and view elevation + flow profile at 0.1 m resolution +- **๐Ÿ“ˆ Hydrograph** - Multi-point discharge and flow-height time series, all on one configurable graph +- **๐Ÿ“Š Statistics** - Full descriptive, spatial, temporal, and phase statistics with hypothesis tests; export to CSV/TXT +- **๐ŸŽš๏ธ 3D Time Slider** - Scrub through every simulation frame in the PyVista viewer +- **๐Ÿ” Zoom/Pan/Save** - Zoom & pan on all plots; one-click PNG/PDF/SVG export - **Parameter Presets** - Quick setup for debris/snow/lahar scenarios - **Background Simulation** - Non-blocking threaded execution -- **Interactive Controls** - Load DEM, configure, run, visualize, export ### ๐Ÿ“ฆ Distribution @@ -310,6 +330,7 @@ python run_simulation.py [OPTIONS] | `--release-col J` | Release zone center column | Auto | | `--release-radius N` | Radius in grid cells | 10 | | `--release-height M` | Initial height in meters | 5.0 | +| `--release-polygon VERTS` | Comma-separated vertices `r1,c1,r2,c2,...` for polygon zone | โ€” | #### Visualization Options @@ -617,7 +638,9 @@ PyDebFlow/ โ”‚ โ”‚ โ””โ”€โ”€ plot_utils.py # Matplotlib plotting โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ gui/ # Graphical interface -โ”‚ โ””โ”€โ”€ main_window.py # PyQt6 main window +โ”‚ โ”œโ”€โ”€ main_window.py # PyQt6 main window +โ”‚ โ”œโ”€โ”€ release_zone_widget.py # CrownWidget (interactive release zone) +โ”‚ โ””โ”€โ”€ analysis_widgets.py # CrossSectionWidget, HydrographWidget, StatisticsWidget โ”‚ โ”œโ”€โ”€ scripts/ # CLI helper scripts โ”‚ โ”œโ”€โ”€ install.sh/.bat # Installation scripts diff --git a/debris_flow.mp4 b/debris_flow.mp4 deleted file mode 100644 index ed81c4c..0000000 Binary files a/debris_flow.mp4 and /dev/null differ diff --git a/src/__init__.py b/src/__init__.py index ed6f40d..da31554 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,6 +1,6 @@ """ -PyDebFlow - Advanced Two-Phase Mass Flow Simulation Software -============================================================= +PyDebFlow v0.2.0 - Advanced Two-Phase Mass Flow Simulation Software +=================================================================== An open-source simulation tool for debris flows, avalanches, and lahars. Inspired by r.avaflow and RAMMS. @@ -16,7 +16,8 @@ For more information, see: https://github.com/ankitdutta428/PyDebFlow """ -__version__ = "0.1.0" +__version__ = "0.2.0" + __author__ = "Ankit Dutta" __email__ = "ankitdutta428@gmail.com" __license__ = "AGPL-3.0" diff --git a/src/core/terrain.py b/src/core/terrain.py index 4f29e64..693c76c 100644 --- a/src/core/terrain.py +++ b/src/core/terrain.py @@ -32,13 +32,19 @@ class Terrain: """ def __init__(self, elevation: np.ndarray, cell_size: float = 10.0, - x_origin: float = 0.0, y_origin: float = 0.0): + x_origin: float = 0.0, y_origin: float = 0.0, + is_geographic: bool = False, cell_size_deg: float = 0.0, + mean_lat: float = 0.0): """Initialize terrain from elevation array.""" self.elevation = np.nan_to_num(elevation.astype(np.float64), nan=0.0) self.original_elevation = self.elevation.copy() - self.cell_size = cell_size - self.x_origin = x_origin - self.y_origin = y_origin + self.cell_size = cell_size # metres + self.x_origin = x_origin # lon / easting of left edge + self.y_origin = y_origin # lat / northing of bottom edge + # Georeferencing helpers + self.is_geographic = is_geographic # True if CRS is lat/lon degrees + self.cell_size_deg = cell_size_deg # original degree cell size (if geographic) + self.mean_lat = mean_lat # mean latitude for lonโ†’metre scale self.rows, self.cols = self.elevation.shape self._compute_slope_aspect() @@ -179,18 +185,15 @@ def from_geotiff(cls, filepath: str) -> 'Terrain': y_origin = src.bounds.bottom # Detect geographic coordinates (degrees) vs projected (meters) - # If cell_size < 0.01, it's likely in degrees (lat/lon) is_geographic = cell_size < 0.01 + cell_size_deg = 0.0 + mean_lat = 0.0 if is_geographic: - # Convert degrees to approximate meters - # At equator: 1 degree โ‰ˆ 111,320 meters - # Adjust for latitude using mean latitude from bounds + cell_size_deg = cell_size mean_lat = (src.bounds.top + src.bounds.bottom) / 2 meters_per_degree_lat = 111320.0 meters_per_degree_lon = 111320.0 * np.cos(np.radians(mean_lat)) - - # Use average of lat/lon scale cell_size_meters = cell_size * (meters_per_degree_lat + meters_per_degree_lon) / 2 print(f"Loaded GeoTIFF: {data.shape[0]}x{data.shape[1]} cells") @@ -199,14 +202,16 @@ def from_geotiff(cls, filepath: str) -> 'Terrain': print(f" Mean latitude: {mean_lat:.4f}ยฐ") print(f" Converted cell size: {cell_size_meters:.2f} m") print(f"Elevation range: {np.nanmin(data):.1f} to {np.nanmax(data):.1f} m") - cell_size = cell_size_meters else: print(f"Loaded GeoTIFF: {data.shape[0]}x{data.shape[1]} cells") print(f"Cell size: {cell_size}m") print(f"Elevation range: {np.nanmin(data):.1f} to {np.nanmax(data):.1f} m") - return cls(data, cell_size, x_origin, y_origin) + return cls(data, cell_size, x_origin, y_origin, + is_geographic=is_geographic, + cell_size_deg=cell_size_deg, + mean_lat=mean_lat) except ImportError: raise ImportError("rasterio required for GeoTIFF. Install: pip install rasterio") diff --git a/src/gui/analysis_widgets.py b/src/gui/analysis_widgets.py index 3ab0e9e..39a84ad 100644 --- a/src/gui/analysis_widgets.py +++ b/src/gui/analysis_widgets.py @@ -1,37 +1,53 @@ """ -Analysis Widgets for PyDebFlow. +Analysis Widgets for PyDebFlow v0.2.0. -Provides RAMMS-style post-simulation analysis tools: -- Cross-Section Profile: draw a line on terrain, see elevation + flow profile -- Hydrograph: click a point, see flow height / discharge over time +RAMMS-style post-simulation analysis tools: +- CrossSectionWidget : draw a transect, view elevation + flow profile, zoom/save +- HydrographWidget : multi-point hydrograph (height, velocity, discharge), save +- StatisticsWidget : descriptive, spatial, temporal statistics + save CSV/TXT """ import numpy as np from typing import Optional, List, Tuple +from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QSlider, QPushButton, QSpinBox, QSplitter + QSlider, QPushButton, QSpinBox, QDoubleSpinBox, + QSplitter, QFileDialog, QTextEdit, QScrollArea, + QGroupBox, QGridLayout, QMessageBox ) -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QThread, pyqtSignal from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qt import NavigationToolbar2QT as NavToolbar from matplotlib.figure import Figure +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Colour cycle for multi-point overlays +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +_COLOURS = ['#ff4444', '#44aaff', '#ffcc00', '#44ff88', + '#ff88cc', '#88ffcc', '#cc88ff', '#ff8844'] + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# CrossSectionWidget +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ class CrossSectionWidget(QWidget): """ RAMMS-style cross-section profile viewer. - Click two points on the terrain map to define a transect line. - The lower plot shows terrain elevation + flow height along that line, - with a time slider to scrub through simulation timesteps. + โ€ข Click two points on the terrain map for a transect. + โ€ข Profile x-axis sampled every 0.1 m (capped at 5000 pts). + โ€ข Time slider to scrub through timesteps. + โ€ข Zoom/Pan toolbar + Save Plot button. """ def __init__(self, parent=None): super().__init__(parent) self.terrain = None - self.outputs = None # [(time, FlowState), ...] + self.outputs = None self._pt1 = None self._pt2 = None self._current_frame = 0 @@ -42,226 +58,231 @@ def _setup_ui(self): layout.setSpacing(4) layout.setContentsMargins(4, 2, 4, 2) - # Instructions + # Info bar self.info_label = QLabel("Click two points on the terrain to define a cross-section line") self.info_label.setStyleSheet("color: #aaa; font-size: 11px;") layout.addWidget(self.info_label) - # Splitter: top = terrain map, bottom = profile + # Splitter: terrain map + profile splitter = QSplitter(Qt.Orientation.Vertical) - # โ”€โ”€ Top: Terrain Map โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ Terrain map โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ self.map_figure = Figure(figsize=(5, 3), dpi=100, facecolor='#16213e') self.map_canvas = FigureCanvas(self.map_figure) self.map_ax = self.map_figure.add_subplot(111) self.map_ax.set_facecolor('#1a1a2e') - self.map_ax.set_title("Click two points for cross-section", color='#e8e8e8', fontsize=9) self._style_ax(self.map_ax) - self.map_figure.tight_layout() + self.map_figure.tight_layout(pad=0.5) self.map_canvas.mpl_connect('button_press_event', self._on_map_click) + self.map_canvas.mpl_connect('scroll_event', self._on_map_scroll) splitter.addWidget(self.map_canvas) - # โ”€โ”€ Bottom: Profile Plot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ Profile plot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + profile_container = QWidget() + pc_layout = QVBoxLayout(profile_container) + pc_layout.setContentsMargins(0, 0, 0, 0) + pc_layout.setSpacing(2) + self.profile_figure = Figure(figsize=(5, 2.5), dpi=100, facecolor='#16213e') self.profile_canvas = FigureCanvas(self.profile_figure) self.profile_ax = self.profile_figure.add_subplot(111) self.profile_ax.set_facecolor('#1a1a2e') - self.profile_ax.set_title("Cross-Section Profile", color='#e8e8e8', fontsize=9) self._style_ax(self.profile_ax) - self.profile_figure.tight_layout() - splitter.addWidget(self.profile_canvas) - + self.profile_figure.tight_layout(pad=0.5) + pc_layout.addWidget(self.profile_canvas, stretch=1) + + # Toolbar + Save + tb_row = QHBoxLayout() + nav = NavToolbar(self.profile_canvas, profile_container) + nav.setStyleSheet("background: #1a1a2e; color: #ccc;") + tb_row.addWidget(nav) + btn_save = QPushButton("๐Ÿ’พ Save Plot") + btn_save.setFixedWidth(90) + btn_save.clicked.connect(self._save_profile) + tb_row.addWidget(btn_save) + pc_layout.addLayout(tb_row) + + splitter.addWidget(profile_container) splitter.setStretchFactor(0, 3) splitter.setStretchFactor(1, 2) layout.addWidget(splitter, stretch=1) - # โ”€โ”€ Time Slider โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Time slider row slider_row = QHBoxLayout() slider_row.addWidget(QLabel("Time:")) self.time_slider = QSlider(Qt.Orientation.Horizontal) self.time_slider.setMinimum(0) self.time_slider.setMaximum(0) - self.time_slider.setValue(0) - self.time_slider.valueChanged.connect(self._on_slider_changed) + self.time_slider.valueChanged.connect(self._on_slider) slider_row.addWidget(self.time_slider, stretch=1) self.time_label = QLabel("t = 0.0 s") - self.time_label.setFixedWidth(100) + self.time_label.setFixedWidth(90) slider_row.addWidget(self.time_label) - - self.btn_reset = QPushButton("๐Ÿ”„ Reset Line") - self.btn_reset.setFixedWidth(90) - self.btn_reset.clicked.connect(self._reset_line) - slider_row.addWidget(self.btn_reset) - + btn_reset = QPushButton("๐Ÿ”„ Reset") + btn_reset.setFixedWidth(72) + btn_reset.clicked.connect(self._reset_line) + slider_row.addWidget(btn_reset) layout.addLayout(slider_row) def set_data(self, terrain, outputs): - """Load terrain and simulation outputs.""" self.terrain = terrain self.outputs = outputs self._pt1 = None self._pt2 = None self._current_frame = 0 - if outputs: self.time_slider.setMaximum(len(outputs) - 1) self.time_slider.setValue(0) - self._draw_map() - # โ”€โ”€ Drawing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ Drawing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _draw_map(self): - """Draw the terrain map with cross-section line.""" self.map_ax.clear() if self.terrain is None: self.map_canvas.draw_idle() return - - hillshade = self.terrain.get_hillshade() - self.map_ax.imshow(hillshade, cmap='gray', origin='upper', aspect='equal') - - # Show flow overlay for current frame + hs = self.terrain.get_hillshade() + self.map_ax.imshow(hs, cmap='gray', origin='upper', aspect='equal') if self.outputs: - t, state = self.outputs[self._current_frame] + _, state = self.outputs[self._current_frame] h = state.h_solid + state.h_fluid masked = np.ma.masked_where(h < 0.01, h) self.map_ax.imshow(masked, cmap='YlOrRd', alpha=0.5, origin='upper', aspect='equal') - - # Draw points and line - if self._pt1 is not None: - r, c = self._pt1 - self.map_ax.plot(c, r, 'o', color='#00ff88', markersize=8, markeredgecolor='white', markeredgewidth=1.5) - if self._pt2 is not None: - r, c = self._pt2 - self.map_ax.plot(c, r, 'o', color='#00ff88', markersize=8, markeredgecolor='white', markeredgewidth=1.5) + for pt in [self._pt1, self._pt2]: + if pt is not None: + self.map_ax.plot(pt[1], pt[0], 'o', color='#00ff88', markersize=8, + markeredgecolor='white', markeredgewidth=1.5) if self._pt1 is not None and self._pt2 is not None: - self.map_ax.plot( - [self._pt1[1], self._pt2[1]], [self._pt1[0], self._pt2[0]], - '--', color='#00ff88', linewidth=2 - ) - + self.map_ax.plot([self._pt1[1], self._pt2[1]], [self._pt1[0], self._pt2[0]], + '--', color='#00ff88', linewidth=2) self.map_ax.set_title("Terrain โ€” click two points for cross-section", color='#e8e8e8', fontsize=9) self._style_ax(self.map_ax) - self.map_figure.tight_layout() + self.map_figure.tight_layout(pad=0.5) self.map_canvas.draw_idle() def _draw_profile(self): - """Draw the cross-section profile.""" self.profile_ax.clear() - if self._pt1 is None or self._pt2 is None or self.terrain is None: self.profile_ax.set_title("Select two points on the map above", color='#e8e8e8', fontsize=9) self._style_ax(self.profile_ax) - self.profile_figure.tight_layout() + self.profile_figure.tight_layout(pad=0.5) self.profile_canvas.draw_idle() return - # Sample along the line - n_samples = 200 r1, c1 = self._pt1 r2, c2 = self._pt2 - rows_line = np.linspace(r1, r2, n_samples) - cols_line = np.linspace(c1, c2, n_samples) + cs = self.terrain.cell_size + total_dist = np.sqrt(((r2 - r1) * cs) ** 2 + ((c2 - c1) * cs) ** 2) - # Distance along line in meters - dist = np.sqrt( - ((rows_line - r1) * self.terrain.cell_size) ** 2 + - ((cols_line - c1) * self.terrain.cell_size) ** 2 - ) + # x-axis at 0.1 m resolution, capped at 5000 samples + n_samples = max(2, min(int(total_dist / 0.1), 5000)) + rows_l = np.linspace(r1, r2, n_samples) + cols_l = np.linspace(c1, c2, n_samples) + dist = np.linspace(0, total_dist, n_samples) - # Sample terrain elevation - ri = np.clip(np.round(rows_line).astype(int), 0, self.terrain.rows - 1) - ci = np.clip(np.round(cols_line).astype(int), 0, self.terrain.cols - 1) + ri = np.clip(np.round(rows_l).astype(int), 0, self.terrain.rows - 1) + ci = np.clip(np.round(cols_l).astype(int), 0, self.terrain.cols - 1) elev = self.terrain.elevation[ri, ci] - # Plot terrain - self.profile_ax.fill_between(dist, elev.min() - 5, elev, color='#8B7355', alpha=0.4, label='_nolegend_') + self.profile_ax.fill_between(dist, elev.min() - 5, elev, color='#8B7355', alpha=0.35) self.profile_ax.plot(dist, elev, '-', color='#8B7355', linewidth=2, label='Terrain') - # Plot flow at current timestep if self.outputs: t, state = self.outputs[self._current_frame] h_total = (state.h_solid + state.h_fluid)[ri, ci] - flow_surface = elev + h_total - + flow_surf = elev + h_total mask = h_total > 0.01 if mask.any(): - self.profile_ax.fill_between( - dist, elev, flow_surface, - where=mask, color='#ff4444', alpha=0.5, label='_nolegend_' - ) - self.profile_ax.plot(dist, flow_surface, '-', color='#ff4444', linewidth=1.5, label=f'Flow (t={t:.1f}s)') + self.profile_ax.fill_between(dist, elev, flow_surf, where=mask, + color='#ff4444', alpha=0.45) + self.profile_ax.plot(dist[mask], flow_surf[mask], '-', + color='#ff4444', linewidth=1.5, label=f'Flow (t={t:.1f}s)') self.profile_ax.set_xlabel("Distance (m)", color='#aaa', fontsize=8) self.profile_ax.set_ylabel("Elevation (m)", color='#aaa', fontsize=8) - self.profile_ax.set_title("Cross-Section Profile", color='#e8e8e8', fontsize=9) - self.profile_ax.legend(loc='upper right', fontsize=7, facecolor='#2a2a4a', edgecolor='#555', labelcolor='#ddd') + self.profile_ax.set_title(f"Cross-Section Profile [{total_dist:.0f} m transect, ~{n_samples} pts]", + color='#e8e8e8', fontsize=9) + self.profile_ax.legend(loc='upper right', fontsize=7, + facecolor='#2a2a4a', edgecolor='#555', labelcolor='#ddd') self._style_ax(self.profile_ax) - self.profile_figure.tight_layout() + self.profile_figure.tight_layout(pad=0.5) self.profile_canvas.draw_idle() - # โ”€โ”€ Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _on_map_click(self, event): if self.terrain is None or event.inaxes != self.map_ax: return - col = int(round(event.xdata)) - row = int(round(event.ydata)) - row = max(0, min(row, self.terrain.rows - 1)) - col = max(0, min(col, self.terrain.cols - 1)) - + col = max(0, min(int(round(event.xdata)), self.terrain.cols - 1)) + row = max(0, min(int(round(event.ydata)), self.terrain.rows - 1)) if self._pt1 is None: self._pt1 = (row, col) self.info_label.setText(f"Point A: ({row}, {col}) โ€” click second point") elif self._pt2 is None: self._pt2 = (row, col) - length = np.sqrt( - ((self._pt2[0] - self._pt1[0]) * self.terrain.cell_size) ** 2 + - ((self._pt2[1] - self._pt1[1]) * self.terrain.cell_size) ** 2 - ) + cs = self.terrain.cell_size + length = np.sqrt(((self._pt2[0]-self._pt1[0])*cs)**2 + ((self._pt2[1]-self._pt1[1])*cs)**2) self.info_label.setText(f"A({self._pt1[0]},{self._pt1[1]}) โ†’ B({self._pt2[0]},{self._pt2[1]}) | {length:.0f} m") else: - # Reset and start new line - self._pt1 = (row, col) - self._pt2 = None + self._pt1 = (row, col); self._pt2 = None self.info_label.setText(f"Point A: ({row}, {col}) โ€” click second point") + self._draw_map(); self._draw_profile() - self._draw_map() - self._draw_profile() + def _on_map_scroll(self, event): + if event.inaxes != self.map_ax: + return + f = 0.85 if event.button == 'up' else 1.15 + xl, yl = self.map_ax.get_xlim(), self.map_ax.get_ylim() + xm, ym = event.xdata, event.ydata + self.map_ax.set_xlim([xm + (x - xm)*f for x in xl]) + self.map_ax.set_ylim([ym + (y - ym)*f for y in yl]) + self.map_canvas.draw_idle() - def _on_slider_changed(self, value): + def _on_slider(self, value): self._current_frame = value if self.outputs and value < len(self.outputs): - t = self.outputs[value][0] - self.time_label.setText(f"t = {t:.1f} s") - self._draw_map() - self._draw_profile() + self.time_label.setText(f"t = {self.outputs[value][0]:.1f} s") + self._draw_map(); self._draw_profile() def _reset_line(self): - self._pt1 = None - self._pt2 = None + self._pt1 = None; self._pt2 = None self.info_label.setText("Click two points on the terrain to define a cross-section line") - self._draw_map() - self._draw_profile() + self._draw_map(); self._draw_profile() + + def _save_profile(self): + path, fmt = QFileDialog.getSaveFileName( + self, "Save Profile Plot", "profile.png", + "PNG (*.png);;PDF (*.pdf);;SVG (*.svg)" + ) + if path: + self.profile_figure.savefig(path, dpi=150, bbox_inches='tight', + facecolor=self.profile_figure.get_facecolor()) def _style_ax(self, ax): ax.tick_params(colors='#888', labelsize=7) - for spine in ax.spines.values(): - spine.set_color('#3d3d5c') + for sp in ax.spines.values(): + sp.set_color('#3d3d5c') +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# HydrographWidget +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + class HydrographWidget(QWidget): """ - Hydrograph viewer โ€” click a point on the terrain to see - flow height, velocity, and discharge over time at that location. + Multi-point hydrograph viewer. + + โ€ข Click or type coordinates to add monitor points. + โ€ข All points plotted on the same graph with distinct colours. + โ€ข Zoom/Pan toolbar + Save Plot button. """ def __init__(self, parent=None): super().__init__(parent) self.terrain = None self.outputs = None - self._monitor_point = None + self._monitor_points: List[Tuple[int, int]] = [] self._setup_ui() def _setup_ui(self): @@ -270,208 +291,512 @@ def _setup_ui(self): layout.setContentsMargins(4, 2, 4, 2) # Info - self.info_label = QLabel("Click a point on the terrain to see hydrograph") + self.info_label = QLabel("Click a point on the terrain or enter coordinates below") self.info_label.setStyleSheet("color: #aaa; font-size: 11px;") layout.addWidget(self.info_label) - # Manual entry row - entry_row = QHBoxLayout() - entry_row.addWidget(QLabel("Row:")) - self.row_spin = QSpinBox() - self.row_spin.setRange(0, 9999) - self.row_spin.setFixedWidth(70) - entry_row.addWidget(self.row_spin) - entry_row.addWidget(QLabel("Col:")) - self.col_spin = QSpinBox() - self.col_spin.setRange(0, 9999) - self.col_spin.setFixedWidth(70) - entry_row.addWidget(self.col_spin) + # Coordinate entry row + entry = QHBoxLayout() + entry.addWidget(QLabel("Row:")) + self.row_spin = QSpinBox(); self.row_spin.setRange(0, 9999); self.row_spin.setFixedWidth(70) + entry.addWidget(self.row_spin) + entry.addWidget(QLabel("Col:")) + self.col_spin = QSpinBox(); self.col_spin.setRange(0, 9999); self.col_spin.setFixedWidth(70) + entry.addWidget(self.col_spin) + self.btn_set = QPushButton("๐Ÿ“ Set Point") self.btn_set.setFixedWidth(90) - self.btn_set.clicked.connect(self._on_manual_set) - entry_row.addWidget(self.btn_set) - entry_row.addStretch() - layout.addLayout(entry_row) + self.btn_set.setToolTip("Replace all points with this one") + self.btn_set.clicked.connect(self._on_set_point) + entry.addWidget(self.btn_set) + + self.btn_add = QPushButton("โž• Add to Graph") + self.btn_add.setFixedWidth(105) + self.btn_add.setToolTip("Add this point to the current graph (keep others)") + self.btn_add.clicked.connect(self._on_add_point) + entry.addWidget(self.btn_add) + + self.btn_clear = QPushButton("๐Ÿ—‘๏ธ Clear All") + self.btn_clear.setFixedWidth(85) + self.btn_clear.clicked.connect(self._clear_all_points) + entry.addWidget(self.btn_clear) - # Splitter: top = terrain, bottom = hydrograph plots + entry.addStretch() + layout.addLayout(entry) + + # Splitter: terrain + hydrograph splitter = QSplitter(Qt.Orientation.Vertical) - # โ”€โ”€ Top: Terrain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ Terrain map โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ self.map_figure = Figure(figsize=(5, 2.5), dpi=100, facecolor='#16213e') self.map_canvas = FigureCanvas(self.map_figure) self.map_ax = self.map_figure.add_subplot(111) self.map_ax.set_facecolor('#1a1a2e') self._style_ax(self.map_ax) - self.map_figure.tight_layout() + self.map_figure.tight_layout(pad=0.5) self.map_canvas.mpl_connect('button_press_event', self._on_map_click) + self.map_canvas.mpl_connect('scroll_event', self._on_map_scroll) splitter.addWidget(self.map_canvas) - # โ”€โ”€ Bottom: Hydrograph Plots โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - self.hydro_figure = Figure(figsize=(5, 3), dpi=100, facecolor='#16213e') + # โ”€โ”€ Hydrograph plots โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + hydro_container = QWidget() + hc_layout = QVBoxLayout(hydro_container) + hc_layout.setContentsMargins(0, 0, 0, 0) + hc_layout.setSpacing(2) + + self.hydro_figure = Figure(figsize=(5, 3.5), dpi=100, facecolor='#16213e') self.hydro_canvas = FigureCanvas(self.hydro_figure) self.ax_height = self.hydro_figure.add_subplot(211) self.ax_vel = self.hydro_figure.add_subplot(212) for ax in [self.ax_height, self.ax_vel]: ax.set_facecolor('#1a1a2e') self._style_ax(ax) - self.hydro_figure.tight_layout() - splitter.addWidget(self.hydro_canvas) - + self.hydro_figure.tight_layout(pad=0.8) + hc_layout.addWidget(self.hydro_canvas, stretch=1) + + # Toolbar + Save + tb_row2 = QHBoxLayout() + nav2 = NavToolbar(self.hydro_canvas, hydro_container) + nav2.setStyleSheet("background: #1a1a2e; color: #ccc;") + tb_row2.addWidget(nav2) + btn_save2 = QPushButton("๐Ÿ’พ Save Plot") + btn_save2.setFixedWidth(90) + btn_save2.clicked.connect(self._save_hydro) + tb_row2.addWidget(btn_save2) + hc_layout.addLayout(tb_row2) + + splitter.addWidget(hydro_container) splitter.setStretchFactor(0, 2) splitter.setStretchFactor(1, 3) layout.addWidget(splitter, stretch=1) def set_data(self, terrain, outputs): - """Load terrain and simulation outputs.""" self.terrain = terrain self.outputs = outputs - self._monitor_point = None - + self._monitor_points = [] if terrain: self.row_spin.setRange(0, terrain.rows - 1) self.col_spin.setRange(0, terrain.cols - 1) self.row_spin.setValue(terrain.rows // 2) self.col_spin.setValue(terrain.cols // 2) - self._draw_map() self._draw_hydrograph() - # โ”€โ”€ Drawing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ Drawing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _draw_map(self): self.map_ax.clear() if self.terrain is None: self.map_canvas.draw_idle() return - - hillshade = self.terrain.get_hillshade() - self.map_ax.imshow(hillshade, cmap='gray', origin='upper', aspect='equal') - - # Show max flow extent + hs = self.terrain.get_hillshade() + self.map_ax.imshow(hs, cmap='gray', origin='upper', aspect='equal') if self.outputs: max_h = np.zeros((self.terrain.rows, self.terrain.cols)) - for _, state in self.outputs: - max_h = np.maximum(max_h, state.h_solid + state.h_fluid) + for _, s in self.outputs: + max_h = np.maximum(max_h, s.h_solid + s.h_fluid) masked = np.ma.masked_where(max_h < 0.01, max_h) self.map_ax.imshow(masked, cmap='YlOrRd', alpha=0.4, origin='upper', aspect='equal') - - # Draw monitor point - if self._monitor_point is not None: - r, c = self._monitor_point - self.map_ax.plot(c, r, 's', color='#00ccff', markersize=10, - markeredgecolor='white', markeredgewidth=2) - self.map_ax.annotate( - f'({r},{c})', (c, r), color='#00ccff', fontsize=8, - xytext=(8, -8), textcoords='offset points' - ) - - self.map_ax.set_title("Click to place monitor point", color='#e8e8e8', fontsize=9) + for idx, (r, c) in enumerate(self._monitor_points): + colour = _COLOURS[idx % len(_COLOURS)] + self.map_ax.plot(c, r, 's', color=colour, markersize=9, + markeredgecolor='white', markeredgewidth=1.5) + self.map_ax.annotate(str(idx + 1), (c, r), color='white', fontsize=7, + ha='center', va='center', fontweight='bold') + self.map_ax.set_title("Click to add monitor point", color='#e8e8e8', fontsize=9) self._style_ax(self.map_ax) - self.map_figure.tight_layout() + self.map_figure.tight_layout(pad=0.5) self.map_canvas.draw_idle() def _draw_hydrograph(self): self.ax_height.clear() self.ax_vel.clear() - - if self._monitor_point is None or not self.outputs: - self.ax_height.set_title("Select a monitor point", color='#e8e8e8', fontsize=9) - self.ax_vel.set_title("", color='#e8e8e8', fontsize=9) + if not self._monitor_points or not self.outputs: + self.ax_height.set_title("Select one or more monitor points", color='#e8e8e8', fontsize=9) for ax in [self.ax_height, self.ax_vel]: self._style_ax(ax) - self.hydro_figure.tight_layout() + self.hydro_figure.tight_layout(pad=0.8) self.hydro_canvas.draw_idle() return - r, c = self._monitor_point - times = [] - h_solid_series = [] - h_fluid_series = [] - h_total_series = [] - speed_s_series = [] - speed_f_series = [] - discharge_series = [] - - for t, state in self.outputs: - times.append(t) - hs = state.h_solid[r, c] - hf = state.h_fluid[r, c] - h_solid_series.append(hs) - h_fluid_series.append(hf) - h_total_series.append(hs + hf) - - vs = np.sqrt(state.u_solid[r, c]**2 + state.v_solid[r, c]**2) - vf = np.sqrt(state.u_fluid[r, c]**2 + state.v_fluid[r, c]**2) - speed_s_series.append(vs) - speed_f_series.append(vf) - - # Discharge = h * v * cell_width (per unit width) - v_avg = (hs * vs + hf * vf) / max(hs + hf, 1e-6) - discharge_series.append((hs + hf) * v_avg * self.terrain.cell_size) - - times = np.array(times) - - # โ”€โ”€ Flow Height โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - self.ax_height.fill_between(times, 0, h_solid_series, color='#cc6633', alpha=0.6, label='Solid') - self.ax_height.fill_between(times, h_solid_series, h_total_series, color='#3399cc', alpha=0.6, label='Fluid') - self.ax_height.plot(times, h_total_series, '-', color='white', linewidth=1, label='Total') - self.ax_height.set_ylabel("Height (m)", color='#aaa', fontsize=8) - self.ax_height.set_title(f"Hydrograph at ({r}, {c})", color='#e8e8e8', fontsize=9) - self.ax_height.legend(loc='upper right', fontsize=6, facecolor='#2a2a4a', edgecolor='#555', labelcolor='#ddd') + times = np.array([t for t, _ in self.outputs]) + + for idx, (r, c) in enumerate(self._monitor_points): + colour = _COLOURS[idx % len(_COLOURS)] + label = f"Pt{idx+1} ({r},{c})" + + h_s = np.array([st.h_solid[r, c] for _, st in self.outputs]) + h_f = np.array([st.h_fluid[r, c] for _, st in self.outputs]) + h_tot = h_s + h_f + vs = np.array([np.sqrt(st.u_solid[r,c]**2 + st.v_solid[r,c]**2) for _, st in self.outputs]) + vf = np.array([np.sqrt(st.u_fluid[r,c]**2 + st.v_fluid[r,c]**2) for _, st in self.outputs]) + v_avg = np.where(h_tot > 1e-6, (h_s*vs + h_f*vf) / h_tot, 0.0) + discharge = h_tot * v_avg * self.terrain.cell_size + + self.ax_height.plot(times, h_tot, '-', color=colour, linewidth=1.5, label=label) + self.ax_vel.plot(times, v_avg, '-', color=colour, linewidth=1.5, label=label) + self.ax_vel.plot(times, discharge, '--', color=colour, linewidth=1, alpha=0.6) + + self.ax_height.set_ylabel("Flow Height (m)", color='#aaa', fontsize=8) + self.ax_height.set_title("Hydrograph โ€” Total Flow Height", color='#e8e8e8', fontsize=9) + self.ax_height.legend(loc='upper right', fontsize=6, + facecolor='#2a2a4a', edgecolor='#555', labelcolor='#ddd') self._style_ax(self.ax_height) - # โ”€โ”€ Velocity / Discharge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - ax2 = self.ax_vel - ax2.plot(times, speed_s_series, '-', color='#cc6633', linewidth=1.2, label='v_solid') - ax2.plot(times, speed_f_series, '-', color='#3399cc', linewidth=1.2, label='v_fluid') - ax2.set_ylabel("Velocity (m/s)", color='#aaa', fontsize=8) - ax2.set_xlabel("Time (s)", color='#aaa', fontsize=8) - - # Discharge on secondary axis - ax2r = ax2.twinx() - ax2r.plot(times, discharge_series, '--', color='#ffcc00', linewidth=1, alpha=0.7, label='Q') - ax2r.set_ylabel("Discharge (mยณ/s)", color='#ffcc00', fontsize=8) - ax2r.tick_params(axis='y', colors='#ffcc00', labelsize=7) - ax2r.spines['right'].set_color('#ffcc00') - - # Combined legend - lines1, labels1 = ax2.get_legend_handles_labels() - lines2, labels2 = ax2r.get_legend_handles_labels() - ax2.legend(lines1 + lines2, labels1 + labels2, - loc='upper right', fontsize=6, facecolor='#2a2a4a', edgecolor='#555', labelcolor='#ddd') - self._style_ax(ax2) - - self.hydro_figure.tight_layout() + self.ax_vel.set_ylabel("Velocity (m/s) / Discharge (mยณ/s, dashed)", color='#aaa', fontsize=8) + self.ax_vel.set_xlabel("Time (s)", color='#aaa', fontsize=8) + self.ax_vel.legend(loc='upper right', fontsize=6, + facecolor='#2a2a4a', edgecolor='#555', labelcolor='#ddd') + self._style_ax(self.ax_vel) + + self.hydro_figure.tight_layout(pad=0.8) self.hydro_canvas.draw_idle() - # โ”€โ”€ Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # โ”€โ”€ Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _on_map_click(self, event): if self.terrain is None or event.inaxes != self.map_ax: return - col = int(round(event.xdata)) - row = int(round(event.ydata)) - row = max(0, min(row, self.terrain.rows - 1)) - col = max(0, min(col, self.terrain.cols - 1)) - - self._monitor_point = (row, col) - self.row_spin.setValue(row) - self.col_spin.setValue(col) - self.info_label.setText(f"Monitor point: ({row}, {col})") - self._draw_map() - self._draw_hydrograph() + col = max(0, min(int(round(event.xdata)), self.terrain.cols - 1)) + row = max(0, min(int(round(event.ydata)), self.terrain.rows - 1)) + self._add_monitor_point(row, col) - def _on_manual_set(self): + def _on_map_scroll(self, event): + if event.inaxes != self.map_ax: + return + f = 0.85 if event.button == 'up' else 1.15 + xl, yl = self.map_ax.get_xlim(), self.map_ax.get_ylim() + xm, ym = event.xdata, event.ydata + self.map_ax.set_xlim([xm + (x - xm)*f for x in xl]) + self.map_ax.set_ylim([ym + (y - ym)*f for y in yl]) + self.map_canvas.draw_idle() + + def _on_set_point(self): if self.terrain is None: return - row = self.row_spin.value() - col = self.col_spin.value() - self._monitor_point = (row, col) - self.info_label.setText(f"Monitor point: ({row}, {col})") - self._draw_map() - self._draw_hydrograph() + self._monitor_points = [] + self._add_monitor_point(self.row_spin.value(), self.col_spin.value()) + + def _on_add_point(self): + if self.terrain is None: + return + self._add_monitor_point(self.row_spin.value(), self.col_spin.value()) + + def _add_monitor_point(self, row: int, col: int): + self._monitor_points.append((row, col)) + self.row_spin.setValue(row); self.col_spin.setValue(col) + n = len(self._monitor_points) + self.info_label.setText(f"{n} monitor point{'s' if n>1 else ''} active") + self._draw_map(); self._draw_hydrograph() + + def _clear_all_points(self): + self._monitor_points = [] + self.info_label.setText("Click a point on the terrain or enter coordinates below") + self._draw_map(); self._draw_hydrograph() + + def _save_hydro(self): + path, _ = QFileDialog.getSaveFileName( + self, "Save Hydrograph", "hydrograph.png", + "PNG (*.png);;PDF (*.pdf);;SVG (*.svg)" + ) + if path: + self.hydro_figure.savefig(path, dpi=150, bbox_inches='tight', + facecolor=self.hydro_figure.get_facecolor()) def _style_ax(self, ax): ax.tick_params(colors='#888', labelsize=7) - for spine in ax.spines.values(): - spine.set_color('#3d3d5c') + for sp in ax.spines.values(): + sp.set_color('#3d3d5c') + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# StatisticsWidget +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class StatisticsWidget(QWidget): + """ + Post-simulation statistical analysis panel. + + Sections: + โ€ข Descriptive โ€” flow height distribution + โ€ข Spatial โ€” area, perimeter, centroid, bounding box + โ€ข Temporal โ€” peak time, rise time, recession time + โ€ข Phase โ€” solid/fluid fractions, peak discharge + โ€ข Tests โ€” Shapiro-Wilk normality, KS vs uniform + + Results displayed as formatted text + exportable as CSV or TXT. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.terrain = None + self.outputs = None + self._stats: dict = {} + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(4) + layout.setContentsMargins(4, 4, 4, 4) + + # Button row + btn_row = QHBoxLayout() + self.btn_compute = QPushButton("๐Ÿ”„ Compute Statistics") + self.btn_compute.clicked.connect(self._compute_and_display) + btn_row.addWidget(self.btn_compute) + btn_save_csv = QPushButton("๐Ÿ’พ Save CSV") + btn_save_csv.clicked.connect(lambda: self._save_stats('csv')) + btn_row.addWidget(btn_save_csv) + btn_save_txt = QPushButton("๐Ÿ’พ Save TXT") + btn_save_txt.clicked.connect(lambda: self._save_stats('txt')) + btn_row.addWidget(btn_save_txt) + btn_row.addStretch() + layout.addLayout(btn_row) + + # Output text area + self.text_out = QTextEdit() + self.text_out.setReadOnly(True) + self.text_out.setFont(__import__('PyQt6.QtGui', fromlist=['QFont']).QFont("Consolas", 9)) + self.text_out.setStyleSheet( + "QTextEdit { background: #1a1a2e; color: #d0d0d0; border: 1px solid #3d3d5c; }" + ) + self.text_out.setPlaceholderText("Run a simulation, then click 'Compute Statistics'.") + layout.addWidget(self.text_out, stretch=1) + + def set_data(self, terrain, outputs): + self.terrain = terrain + self.outputs = outputs + self._stats = {} + self.text_out.setPlaceholderText("Data loaded โ€” click 'Compute Statistics'.") + # Auto compute + self._compute_and_display() + + def _compute_and_display(self): + if self.terrain is None or not self.outputs: + self.text_out.setPlainText("No simulation data available.") + return + try: + self._stats = self._compute_stats() + self.text_out.setPlainText(self._format_stats(self._stats)) + except Exception as e: + self.text_out.setPlainText(f"Error computing statistics:\n{e}") + + # โ”€โ”€ Core computation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _compute_stats(self) -> dict: + from scipy import stats as sp_stats + + terrain = self.terrain + outputs = self.outputs + cs = terrain.cell_size + + # Build max-height array and time series + max_h = np.zeros((terrain.rows, terrain.cols)) + times = np.array([t for t, _ in outputs]) + peak_h_time_series = np.array([ + (s.h_solid + s.h_fluid).max() for _, s in outputs + ]) + + for _, s in outputs: + max_h = np.maximum(max_h, s.h_solid + s.h_fluid) + + active = max_h[max_h > 0.01] + flow_mask = max_h > 0.01 + + # โ”€โ”€ Descriptive โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + desc = {} + if active.size > 0: + desc['count'] = int(active.size) + desc['mean'] = float(np.mean(active)) + desc['std'] = float(np.std(active)) + desc['min'] = float(active.min()) + desc['max'] = float(active.max()) + for p in [5, 25, 50, 75, 95]: + desc[f'p{p}'] = float(np.percentile(active, p)) + from scipy.stats import skew, kurtosis + desc['skewness'] = float(skew(active)) + desc['kurtosis'] = float(kurtosis(active)) + else: + desc = {k: 0.0 for k in ['count','mean','std','min','max', + 'p5','p25','p50','p75','p95','skewness','kurtosis']} + + # โ”€โ”€ Spatial โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + spatial = {} + spatial['flow_area_m2'] = float(flow_mask.sum() * cs ** 2) + spatial['flow_area_km2'] = spatial['flow_area_m2'] / 1e6 + + # Perimeter (count boundary cells ร— cell_size) + from scipy.ndimage import binary_erosion + interior = binary_erosion(flow_mask) + boundary = flow_mask & ~interior + spatial['perimeter_m'] = float(boundary.sum() * cs) + + # Centroid + if flow_mask.any(): + coords = np.argwhere(flow_mask) + cr, cc = float(coords[:, 0].mean()), float(coords[:, 1].mean()) + spatial['centroid_row'] = cr + spatial['centroid_col'] = cc + if terrain.is_geographic and terrain.cell_size_deg > 0: + lat_top = terrain.y_origin + terrain.rows * terrain.cell_size_deg + spatial['centroid_lat'] = lat_top - cr * terrain.cell_size_deg + spatial['centroid_lon'] = terrain.x_origin + cc * terrain.cell_size_deg + rmin, rmax = int(coords[:,0].min()), int(coords[:,0].max()) + cmin, cmax = int(coords[:,1].min()), int(coords[:,1].max()) + spatial['bbox_rows'] = f"{rmin}โ€“{rmax}" + spatial['bbox_cols'] = f"{cmin}โ€“{cmax}" + spatial['bbox_h_km'] = (rmax - rmin) * cs / 1000 + spatial['bbox_w_km'] = (cmax - cmin) * cs / 1000 + else: + spatial['centroid_row'] = 0.0; spatial['centroid_col'] = 0.0 + spatial['centroid_lat'] = 0.0; spatial['centroid_lon'] = 0.0 + spatial['bbox_rows'] = 'โ€”'; spatial['bbox_cols'] = 'โ€”' + spatial['bbox_h_km'] = 0.0; spatial['bbox_w_km'] = 0.0 + + # โ”€โ”€ Temporal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + temporal = {} + temporal['duration_s'] = float(times[-1] - times[0]) if len(times) > 1 else 0.0 + if peak_h_time_series.max() > 0: + peak_idx = int(np.argmax(peak_h_time_series)) + temporal['peak_height_m'] = float(peak_h_time_series[peak_idx]) + temporal['peak_time_s'] = float(times[peak_idx]) + # Rise time: 0โ†’90% of peak + thresh90 = 0.9 * temporal['peak_height_m'] + above = np.where(peak_h_time_series >= thresh90)[0] + temporal['rise_time_s'] = float(times[above[0]]) if above.size else float(times[peak_idx]) + # Recession: time from peak to half-peak + half_peak = 0.5 * temporal['peak_height_m'] + after_peak = peak_h_time_series[peak_idx:] + below_half = np.where(after_peak <= half_peak)[0] + temporal['recession_time_s'] = float(times[peak_idx + below_half[0]]) if below_half.size else float(times[-1]) + else: + temporal['peak_height_m'] = 0.0; temporal['peak_time_s'] = 0.0 + temporal['rise_time_s'] = 0.0; temporal['recession_time_s'] = 0.0 + + # โ”€โ”€ Phase โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + phase = {} + solid_fracs = [] + discharges = [] + for _, s in outputs: + h_tot = s.h_solid + s.h_fluid + active_mask = h_tot > 0.01 + if active_mask.any(): + sf = s.h_solid[active_mask] / h_tot[active_mask] + solid_fracs.append(sf.mean()) + v_avg = np.where(h_tot > 1e-6, + (s.h_solid * np.sqrt(s.u_solid**2 + s.v_solid**2) + + s.h_fluid * np.sqrt(s.u_fluid**2 + s.v_fluid**2)) / h_tot, 0.0) + discharges.append((h_tot * v_avg * cs)[active_mask].max()) + if solid_fracs: + phase['mean_solid_fraction'] = float(np.mean(solid_fracs)) + phase['mean_fluid_fraction'] = 1.0 - phase['mean_solid_fraction'] + phase['peak_discharge_m3s'] = float(max(discharges)) + else: + phase['mean_solid_fraction'] = 0.0 + phase['mean_fluid_fraction'] = 0.0 + phase['peak_discharge_m3s'] = 0.0 + + # โ”€โ”€ Statistical Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + tests = {} + if active.size >= 3: + sw_stat, sw_p = sp_stats.shapiro(active[:5000]) # cap for speed + tests['shapiro_wilk_stat'] = float(sw_stat) + tests['shapiro_wilk_p'] = float(sw_p) + tests['shapiro_wilk_normal'] = sw_p > 0.05 + + ks_stat, ks_p = sp_stats.kstest(active, 'uniform', + args=(active.min(), active.max() - active.min())) + tests['ks_uniform_stat'] = float(ks_stat) + tests['ks_uniform_p'] = float(ks_p) + else: + tests = {'shapiro_wilk_stat': 0.0, 'shapiro_wilk_p': 0.0, + 'shapiro_wilk_normal': False, + 'ks_uniform_stat': 0.0, 'ks_uniform_p': 0.0} + + return { + 'descriptive': desc, 'spatial': spatial, + 'temporal': temporal, 'phase': phase, 'tests': tests, + 'n_frames': len(outputs), 'n_active_cells': int(flow_mask.sum()) + } + + def _format_stats(self, s: dict) -> str: + lines = [] + lines.append("โ•" * 60) + lines.append(" PyDebFlow v0.2.0 โ€” Statistical Analysis") + lines.append("โ•" * 60) + + # Descriptive + d = s['descriptive'] + lines.append("\n๐Ÿ“Š DESCRIPTIVE STATISTICS (Max Flow Height)") + lines.append(f" Frames analysed : {s['n_frames']}") + lines.append(f" Active cells : {s['n_active_cells']}") + lines.append(f" Mean : {d['mean']:.4f} m") + lines.append(f" Std Dev : {d['std']:.4f} m") + lines.append(f" Min / Max : {d['min']:.4f} / {d['max']:.4f} m") + lines.append(f" Percentiles (P5/P25/P50/P75/P95):") + lines.append(f" {d['p5']:.3f} / {d['p25']:.3f} / {d['p50']:.3f} / {d['p75']:.3f} / {d['p95']:.3f} m") + lines.append(f" Skewness : {d['skewness']:.4f}") + lines.append(f" Kurtosis : {d['kurtosis']:.4f}") + + # Spatial + sp = s['spatial'] + lines.append("\n๐Ÿ—บ๏ธ SPATIAL STATISTICS") + lines.append(f" Flow Area : {sp['flow_area_m2']:,.1f} mยฒ ({sp['flow_area_km2']:.4f} kmยฒ)") + lines.append(f" Perimeter : {sp['perimeter_m']:,.1f} m") + lines.append(f" Centroid (row,col): ({sp['centroid_row']:.1f}, {sp['centroid_col']:.1f})") + if self.terrain and self.terrain.is_geographic: + lines.append(f" Centroid (lat,lon): ({sp.get('centroid_lat',0):.5f}ยฐ, {sp.get('centroid_lon',0):.5f}ยฐ)") + lines.append(f" Bounding Box Rows: {sp['bbox_rows']}") + lines.append(f" Bounding Box Cols: {sp['bbox_cols']}") + lines.append(f" Extent H ร— W : {sp['bbox_h_km']:.3f} km ร— {sp['bbox_w_km']:.3f} km") + + # Temporal + t = s['temporal'] + lines.append("\nโฑ๏ธ TEMPORAL STATISTICS") + lines.append(f" Simulation Duration : {t['duration_s']:.1f} s") + lines.append(f" Peak Height : {t['peak_height_m']:.4f} m at t = {t['peak_time_s']:.1f} s") + lines.append(f" Rise Time (โ†’90%) : {t['rise_time_s']:.1f} s") + lines.append(f" Recession Time (โ†’50%): {t['recession_time_s']:.1f} s") + + # Phase + ph = s['phase'] + lines.append("\nโš—๏ธ PHASE STATISTICS") + lines.append(f" Mean Solid Fraction : {ph['mean_solid_fraction']:.4f} ({ph['mean_solid_fraction']*100:.1f}%)") + lines.append(f" Mean Fluid Fraction : {ph['mean_fluid_fraction']:.4f} ({ph['mean_fluid_fraction']*100:.1f}%)") + lines.append(f" Peak Discharge : {ph['peak_discharge_m3s']:.4f} mยณ/s") + + # Tests + ts = s['tests'] + lines.append("\n๐Ÿ”ฌ STATISTICAL TESTS") + sw_result = "โœ… Likely normal" if ts.get('shapiro_wilk_normal') else "โŒ Non-normal" + lines.append(f" Shapiro-Wilk (normality):") + lines.append(f" W = {ts['shapiro_wilk_stat']:.6f}, p = {ts['shapiro_wilk_p']:.6f} โ†’ {sw_result}") + lines.append(f" KS Test (vs Uniform):") + lines.append(f" D = {ts['ks_uniform_stat']:.6f}, p = {ts['ks_uniform_p']:.6f}") + + lines.append("\n" + "โ•" * 60) + return "\n".join(lines) + + def _save_stats(self, fmt: str): + if not self._stats: + QMessageBox.warning(self, "No Data", "Compute statistics first.") + return + if fmt == 'csv': + path, _ = QFileDialog.getSaveFileName(self, "Save Statistics", "statistics.csv", "CSV (*.csv)") + if path: + self._save_as_csv(path) + else: + path, _ = QFileDialog.getSaveFileName(self, "Save Statistics", "statistics.txt", "Text (*.txt)") + if path: + Path(path).write_text(self._format_stats(self._stats), encoding='utf-8') + + def _save_as_csv(self, path: str): + rows = [] + rows.append("Section,Key,Value") + for section, vals in self._stats.items(): + if isinstance(vals, dict): + for k, v in vals.items(): + rows.append(f"{section},{k},{v}") + else: + rows.append(f"summary,{section},{vals}") + Path(path).write_text("\n".join(rows), encoding='utf-8') diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 07dba0b..e309677 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -290,11 +290,11 @@ def _create_viz_panel(self) -> QWidget: self.viz_tabs.addTab(info_widget, "โ„น๏ธ Info") - # Release Zone tab - from src.gui.release_zone_widget import ReleaseZoneWidget - self.release_zone_widget = ReleaseZoneWidget() + # Crown (Release Zone) tab + from src.gui.release_zone_widget import CrownWidget + self.release_zone_widget = CrownWidget() self.release_zone_widget.release_zone_changed.connect(self._on_release_zone_changed) - self.viz_tabs.addTab(self.release_zone_widget, "๐ŸŽฏ Release Zone") + self.viz_tabs.addTab(self.release_zone_widget, "๐Ÿ‘‘ Crown") # Log tab self.log_text = QTextEdit() @@ -312,7 +312,7 @@ def _create_viz_panel(self) -> QWidget: self.viz_tabs.addTab(results_widget, "๐Ÿ“Š Results") # Cross-Section Profile tab - from src.gui.analysis_widgets import CrossSectionWidget, HydrographWidget + from src.gui.analysis_widgets import CrossSectionWidget, HydrographWidget, StatisticsWidget self.cross_section_widget = CrossSectionWidget() self.viz_tabs.addTab(self.cross_section_widget, "๐Ÿ“ Profile") @@ -320,6 +320,10 @@ def _create_viz_panel(self) -> QWidget: self.hydrograph_widget = HydrographWidget() self.viz_tabs.addTab(self.hydrograph_widget, "๐Ÿ“ˆ Hydrograph") + # Statistics tab + self.statistics_widget = StatisticsWidget() + self.viz_tabs.addTab(self.statistics_widget, "๐Ÿ“Š Statistics") + layout.addWidget(self.viz_tabs) return panel @@ -737,6 +741,7 @@ def _on_finished(self, outputs): # Feed data to analysis widgets self.cross_section_widget.set_data(self.terrain, outputs) self.hydrograph_widget.set_data(self.terrain, outputs) + self.statistics_widget.set_data(self.terrain, outputs) self.viz_tabs.setCurrentIndex(2) # Results tab diff --git a/src/gui/release_zone_widget.py b/src/gui/release_zone_widget.py index c17e326..31044c2 100644 --- a/src/gui/release_zone_widget.py +++ b/src/gui/release_zone_widget.py @@ -1,20 +1,21 @@ """ -Release Zone Interactive Widget for PyDebFlow. +Crown Zone (Release/Initiation Area) Interactive Widget for PyDebFlow. Provides an interactive matplotlib canvas embedded in PyQt6 for marking -release zones on loaded terrain. Supports: -- Point mode: Click to place a circular release zone +the crown (initiation) zone on loaded terrain. Supports: +- Point mode: Click to place a circular crown zone - Polygon mode: Click to add vertices, right-click to close -- Manual coordinate entry via spinboxes +- Manual entry via Latitude / Longitude (or Row/Col for projected DEMs) +- Remove current marker """ import numpy as np -from typing import Optional, List, Tuple +from typing import Optional, Tuple from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QSpinBox, QDoubleSpinBox, - QGroupBox, QButtonGroup, QRadioButton, QMessageBox + QButtonGroup, QRadioButton, QMessageBox ) from PyQt6.QtCore import pyqtSignal, Qt @@ -22,411 +23,424 @@ from matplotlib.figure import Figure -class ReleaseZoneWidget(QWidget): +class CrownWidget(QWidget): """ - Interactive widget for marking release zones on terrain. - - Embeds a matplotlib canvas showing the terrain hillshade/elevation - and allows users to mark point or polygon release zones interactively. - + Interactive widget for marking the crown (initiation) zone on terrain. + + Shows terrain hillshade and lets users mark point or polygon crown zones. + Coordinate entry uses Latitude/Longitude for geographic DEMs, or + Row/Column for projected DEMs. + Signals: - release_zone_changed(np.ndarray): Emitted when release zone is updated. - The array is the release height field (same shape as terrain). + release_zone_changed(np.ndarray | None): Emitted when zone updates. """ - - release_zone_changed = pyqtSignal(object) # np.ndarray - + + release_zone_changed = pyqtSignal(object) # np.ndarray or None + def __init__(self, parent=None): super().__init__(parent) - + self.terrain = None self.release_zone = None - + # Mode state - self._mode = 'point' # 'point' or 'polygon' - - # Point mode state + self._mode = 'point' + + # Point mode self._point_marker = None # (row, col) - - # Polygon mode state - self._polygon_vertices = [] # list of (row, col) + + # Polygon mode + self._polygon_vertices = [] self._polygon_closed = False - - # Plot artists for overlay - self._overlay_img = None - self._marker_artists = [] - self._polygon_line = None - self._polygon_fill = None - + self._setup_ui() self._connect_signals() - + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # UI + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _setup_ui(self): - """Build the widget UI.""" layout = QVBoxLayout(self) layout.setSpacing(4) layout.setContentsMargins(5, 2, 5, 2) - - # โ”€โ”€ Top Row: Mode + Parameters (compact horizontal) โ”€โ”€ + + # โ”€โ”€ Row 1: Mode + Parameters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ top_row = QHBoxLayout() - top_row.setSpacing(12) - - # Mode radios + top_row.setSpacing(10) + self.btn_group = QButtonGroup(self) - self.radio_point = QRadioButton("๐ŸŽฏ Point") self.radio_point.setChecked(True) - self.radio_point.setToolTip("Click on terrain to place a circular release zone") + self.radio_point.setToolTip("Click terrain to place a circular crown zone") self.btn_group.addButton(self.radio_point) top_row.addWidget(self.radio_point) - + self.radio_polygon = QRadioButton("๐Ÿ“ Polygon") - self.radio_polygon.setToolTip("Click to add vertices. Right-click to close polygon.") + self.radio_polygon.setToolTip("Click to add vertices. Right-click to close.") self.btn_group.addButton(self.radio_polygon) top_row.addWidget(self.radio_polygon) - - top_row.addSpacing(10) - - # Height + + top_row.addSpacing(8) + top_row.addWidget(QLabel("H:")) self.height_spin = QDoubleSpinBox() self.height_spin.setRange(0.5, 100.0) self.height_spin.setValue(5.0) self.height_spin.setSingleStep(0.5) self.height_spin.setSuffix(" m") - self.height_spin.setFixedWidth(90) + self.height_spin.setFixedWidth(88) top_row.addWidget(self.height_spin) - - # Radius + top_row.addWidget(QLabel("R:")) self.radius_spin = QSpinBox() self.radius_spin.setRange(1, 50) self.radius_spin.setValue(10) - self.radius_spin.setToolTip("Radius for point mode") - self.radius_spin.setFixedWidth(60) + self.radius_spin.setToolTip("Radius in grid cells (point mode only)") + self.radius_spin.setFixedWidth(58) top_row.addWidget(self.radius_spin) - + top_row.addStretch() layout.addLayout(top_row) - - # โ”€โ”€ Second Row: Manual Coordinate Entry (compact) โ”€โ”€โ”€โ”€ - manual_row = QHBoxLayout() - manual_row.setSpacing(6) - - manual_row.addWidget(QLabel("Row:")) - self.row_spin = QSpinBox() - self.row_spin.setRange(0, 9999) - self.row_spin.setValue(0) - self.row_spin.setFixedWidth(70) - manual_row.addWidget(self.row_spin) - - manual_row.addWidget(QLabel("Col:")) - self.col_spin = QSpinBox() - self.col_spin.setRange(0, 9999) - self.col_spin.setValue(0) - self.col_spin.setFixedWidth(70) - manual_row.addWidget(self.col_spin) - - self.btn_add_point = QPushButton("โž• Add") - self.btn_add_point.setToolTip( - "Point mode: sets release center. Polygon mode: adds a vertex." - ) - self.btn_add_point.setFixedWidth(70) - manual_row.addWidget(self.btn_add_point) - - self.btn_close_polygon = QPushButton("โœ… Close") - self.btn_close_polygon.setToolTip("Close polygon and compute release zone") - self.btn_close_polygon.setEnabled(False) - self.btn_close_polygon.setFixedWidth(70) - manual_row.addWidget(self.btn_close_polygon) - - manual_row.addStretch() - + + # โ”€โ”€ Row 2: Coordinate Entry (Lat/Lon or Row/Col) โ”€โ”€ + coord_row = QHBoxLayout() + coord_row.setSpacing(6) + + # Lat / Row label + spinbox + self.lat_label = QLabel("Lat:") + coord_row.addWidget(self.lat_label) + self.lat_spin = QDoubleSpinBox() + self.lat_spin.setDecimals(6) + self.lat_spin.setRange(-90.0, 90.0) + self.lat_spin.setValue(0.0) + self.lat_spin.setFixedWidth(100) + coord_row.addWidget(self.lat_spin) + + # Lon / Col label + spinbox + self.lon_label = QLabel("Lon:") + coord_row.addWidget(self.lon_label) + self.lon_spin = QDoubleSpinBox() + self.lon_spin.setDecimals(6) + self.lon_spin.setRange(-180.0, 180.0) + self.lon_spin.setValue(0.0) + self.lon_spin.setFixedWidth(100) + coord_row.addWidget(self.lon_spin) + + self.btn_add = QPushButton("โž• Add") + self.btn_add.setToolTip("Point: set crown center. Polygon: add vertex.") + self.btn_add.setFixedWidth(68) + coord_row.addWidget(self.btn_add) + + self.btn_close_poly = QPushButton("โœ… Close") + self.btn_close_poly.setToolTip("Close polygon and compute crown zone") + self.btn_close_poly.setEnabled(False) + self.btn_close_poly.setFixedWidth(68) + coord_row.addWidget(self.btn_close_poly) + + self.btn_remove = QPushButton("๐Ÿ—‘๏ธ Remove") + self.btn_remove.setToolTip("Delete current crown marker") + self.btn_remove.setFixedWidth(80) + coord_row.addWidget(self.btn_remove) + + coord_row.addStretch() + self.status_label = QLabel("No terrain loaded") self.status_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - manual_row.addWidget(self.status_label) - - layout.addLayout(manual_row) - - # โ”€โ”€ Matplotlib Canvas (takes all remaining space) โ”€โ”€โ”€โ”€ + coord_row.addWidget(self.status_label) + + layout.addLayout(coord_row) + + # โ”€โ”€ Matplotlib Canvas โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ self.figure = Figure(figsize=(6, 5), dpi=100, facecolor='#16213e') self.canvas = FigureCanvas(self.figure) self.canvas.setMinimumHeight(350) self.ax = self.figure.add_subplot(111) + self._init_ax() + self.figure.tight_layout(pad=0.5) + layout.addWidget(self.canvas, stretch=1) + + def _init_ax(self): self.ax.set_facecolor('#1a1a2e') self.ax.set_title("Load terrain to begin", color='#e8e8e8', fontsize=10) - self.ax.tick_params(colors='#888888', labelsize=8) - for spine in self.ax.spines.values(): - spine.set_color('#3d3d5c') - self.figure.tight_layout() - layout.addWidget(self.canvas, stretch=1) - + self._style_ax(self.ax) + def _connect_signals(self): - """Wire up signals.""" self.radio_point.toggled.connect(self._on_mode_changed) self.radio_polygon.toggled.connect(self._on_mode_changed) - self.btn_add_point.clicked.connect(self._on_manual_add) - self.btn_close_polygon.clicked.connect(self._on_close_polygon) + self.btn_add.clicked.connect(self._on_manual_add) + self.btn_close_poly.clicked.connect(self._on_close_polygon) + self.btn_remove.clicked.connect(self.clear_zone) self.canvas.mpl_connect('button_press_event', self._on_canvas_click) - - # โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - + self.canvas.mpl_connect('scroll_event', self._on_scroll) + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Public API + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def set_terrain(self, terrain): - """ - Set the terrain to display. - - Args: - terrain: A Terrain instance with elevation, rows, cols, cell_size. - """ self.terrain = terrain self.release_zone = None self._point_marker = None self._polygon_vertices = [] self._polygon_closed = False - - # Update spinbox ranges - self.row_spin.setRange(0, terrain.rows - 1) - self.col_spin.setRange(0, terrain.cols - 1) - self.row_spin.setValue(terrain.rows // 5) - self.col_spin.setValue(terrain.cols // 2) - + + # Update coordinate spinbox ranges / labels + if terrain.is_geographic: + self.lat_label.setText("Lat:") + self.lon_label.setText("Lon:") + # lat/lon bounds: y_origin = bottom, x_origin = left + lat_min = terrain.y_origin + lat_max = terrain.y_origin + terrain.rows * terrain.cell_size_deg + lon_min = terrain.x_origin + lon_max = terrain.x_origin + terrain.cols * terrain.cell_size_deg + self.lat_spin.setRange(lat_min, lat_max) + self.lon_spin.setRange(lon_min, lon_max) + # Default to centre + self.lat_spin.setValue((lat_min + lat_max) / 2) + self.lon_spin.setValue((lon_min + lon_max) / 2) + self.lat_spin.setDecimals(6) + self.lon_spin.setDecimals(6) + else: + self.lat_label.setText("Row:") + self.lon_label.setText("Col:") + self.lat_spin.setRange(0, terrain.rows - 1) + self.lon_spin.setRange(0, terrain.cols - 1) + self.lat_spin.setValue(terrain.rows // 5) + self.lon_spin.setValue(terrain.cols // 2) + self.lat_spin.setDecimals(0) + self.lon_spin.setDecimals(0) + self._draw_terrain() - self.status_label.setText("Click on terrain to mark release zone") - + self.status_label.setText("Click terrain to mark crown zone") + def get_release_zone(self) -> Optional[np.ndarray]: - """Return the current release zone array, or None if not set.""" return self.release_zone - + def clear_zone(self): - """Clear the current release zone.""" + """Remove current crown marker.""" self.release_zone = None self._point_marker = None self._polygon_vertices = [] self._polygon_closed = False - self.btn_close_polygon.setEnabled(False) - + self.btn_close_poly.setEnabled(False) if self.terrain is not None: self._draw_terrain() self.status_label.setText("Cleared โ€” click to mark new zone") - self.release_zone_changed.emit(None) - - # โ”€โ”€ Internal: Drawing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Coord conversion helpers + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _latlon_to_rowcol(self, lat: float, lon: float) -> Tuple[int, int]: + """Convert geographic lat/lon to grid row/col.""" + t = self.terrain + if t.is_geographic and t.cell_size_deg > 0: + # row 0 = top of raster (max lat), row increases downward + lat_top = t.y_origin + t.rows * t.cell_size_deg + row = int((lat_top - lat) / t.cell_size_deg) + col = int((lon - t.x_origin) / t.cell_size_deg) + else: + row = int(round(lat)) # lat_spin used as row + col = int(round(lon)) + row = max(0, min(row, t.rows - 1)) + col = max(0, min(col, t.cols - 1)) + return row, col + + def _rowcol_to_latlon(self, row: int, col: int) -> Tuple[float, float]: + """Convert grid row/col to geographic lat/lon (or row/col if projected).""" + t = self.terrain + if t.is_geographic and t.cell_size_deg > 0: + lat_top = t.y_origin + t.rows * t.cell_size_deg + lat = lat_top - row * t.cell_size_deg + lon = t.x_origin + col * t.cell_size_deg + else: + lat, lon = float(row), float(col) + return lat, lon + + def _update_coord_spinboxes(self, row: int, col: int): + lat, lon = self._rowcol_to_latlon(row, col) + self.lat_spin.blockSignals(True) + self.lon_spin.blockSignals(True) + self.lat_spin.setValue(lat) + self.lon_spin.setValue(lon) + self.lat_spin.blockSignals(False) + self.lon_spin.blockSignals(False) + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Drawing + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _draw_terrain(self): - """Redraw the terrain base image.""" self.ax.clear() - if self.terrain is None: self.ax.set_title("No terrain loaded", color='#e8e8e8', fontsize=10) self.canvas.draw_idle() return - - # Show hillshade + hillshade = self.terrain.get_hillshade() - self.ax.imshow( - hillshade, cmap='gray', origin='upper', - aspect='equal', interpolation='bilinear' - ) - - # Overlay elevation contours - self.ax.contour( - self.terrain.elevation, levels=15, - colors='#00d9ff', linewidths=0.3, alpha=0.4 - ) - - self.ax.set_title("Terrain โ€” Click to mark release zone", color='#e8e8e8', fontsize=10) + self.ax.imshow(hillshade, cmap='gray', origin='upper', aspect='equal', interpolation='bilinear') + self.ax.contour(self.terrain.elevation, levels=15, colors='#00d9ff', linewidths=0.3, alpha=0.4) + + self.ax.set_title("Crown Zone โ€” Click to mark initiation area", color='#e8e8e8', fontsize=10) self.ax.set_xlabel("Column (j)", color='#888', fontsize=8) self.ax.set_ylabel("Row (i)", color='#888', fontsize=8) - self.ax.tick_params(colors='#888888', labelsize=7) - for spine in self.ax.spines.values(): - spine.set_color('#3d3d5c') - - # Redraw overlays if any + self._style_ax(self.ax) + self._draw_overlays() - - self.figure.tight_layout() + self.figure.tight_layout(pad=0.5) self.canvas.draw_idle() - + def _draw_overlays(self): - """Draw release zone overlays on top of terrain.""" - # Draw release zone heatmap + # Release zone heatmap if self.release_zone is not None: masked = np.ma.masked_where(self.release_zone < 0.01, self.release_zone) - self.ax.imshow( - masked, cmap='hot', alpha=0.55, - origin='upper', aspect='equal', interpolation='bilinear' - ) - - # Draw point marker + self.ax.imshow(masked, cmap='hot', alpha=0.55, origin='upper', aspect='equal', interpolation='bilinear') + + # Point marker if self._point_marker is not None: r, c = self._point_marker - radius = self.radius_spin.value() - circle = plt_Circle( - (c, r), radius, - fill=False, edgecolor='#00ff88', linewidth=2, linestyle='--' - ) + from matplotlib.patches import Circle + circle = Circle((c, r), self.radius_spin.value(), + fill=False, edgecolor='#00ff88', linewidth=2, linestyle='--') self.ax.add_patch(circle) self.ax.plot(c, r, 'x', color='#00ff88', markersize=10, markeredgewidth=2) - - # Draw polygon vertices/edges + + # Polygon vertices/edges if self._polygon_vertices: verts = self._polygon_vertices rows = [v[0] for v in verts] cols = [v[1] for v in verts] - - # Plot vertices - self.ax.plot(cols, rows, 'o', color='#ff6b6b', markersize=6, markeredgecolor='white', markeredgewidth=1) - - # Plot edges + self.ax.plot(cols, rows, 'o', color='#ff6b6b', markersize=6, + markeredgecolor='white', markeredgewidth=1) if len(verts) > 1: - plot_cols = cols + ([cols[0]] if self._polygon_closed else []) - plot_rows = rows + ([rows[0]] if self._polygon_closed else []) - self.ax.plot(plot_cols, plot_rows, '-', color='#ff6b6b', linewidth=1.5) - - # Fill if closed + pc = cols + ([cols[0]] if self._polygon_closed else []) + pr = rows + ([rows[0]] if self._polygon_closed else []) + self.ax.plot(pc, pr, '-', color='#ff6b6b', linewidth=1.5) if self._polygon_closed and len(verts) >= 3: - from matplotlib.patches import Polygon as MplPolygon + from matplotlib.patches import Polygon as MplPoly xy = np.array([[c, r] for r, c in verts]) - poly = MplPolygon( - xy, closed=True, - facecolor='#ff6b6b', alpha=0.2, - edgecolor='#ff6b6b', linewidth=2 - ) - self.ax.add_patch(poly) - - # โ”€โ”€ Internal: Event Handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - + self.ax.add_patch(MplPoly(xy, closed=True, facecolor='#ff6b6b', alpha=0.2, + edgecolor='#ff6b6b', linewidth=2)) + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Event Handlers + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _on_mode_changed(self, checked): - """Handle mode radio button toggle.""" if not checked: return - - old_mode = self._mode + old = self._mode self._mode = 'point' if self.radio_point.isChecked() else 'polygon' - - if old_mode != self._mode: - # Clear current markers when switching modes + if old != self._mode: self._point_marker = None self._polygon_vertices = [] self._polygon_closed = False - self.btn_close_polygon.setEnabled(False) - - if self._mode == 'point': - self.radius_spin.setEnabled(True) - self.status_label.setText("Point mode โ€” click to place release center") - else: - self.radius_spin.setEnabled(False) - self.status_label.setText("Polygon mode โ€” click to add vertices, right-click to close") - + self.btn_close_poly.setEnabled(False) + self.radius_spin.setEnabled(self._mode == 'point') if self.terrain is not None: self._draw_terrain() - + def _on_canvas_click(self, event): - """Handle mouse click on the matplotlib canvas.""" - if self.terrain is None: + if self.terrain is None or event.inaxes != self.ax: return - if event.inaxes != self.ax: - return - col = int(round(event.xdata)) row = int(round(event.ydata)) - - # Clamp to terrain bounds row = max(0, min(row, self.terrain.rows - 1)) col = max(0, min(col, self.terrain.cols - 1)) - + if self._mode == 'point': self._place_point(row, col) - elif self._mode == 'polygon': - if event.button == 3: # Right-click closes polygon + else: + if event.button == 3: self._close_polygon() else: - self._add_polygon_vertex(row, col) - + self._add_vertex(row, col) + + def _on_scroll(self, event): + """Scroll-wheel zoom on the terrain canvas.""" + if event.inaxes != self.ax: + return + factor = 0.85 if event.button == 'up' else 1.15 + xlim = self.ax.get_xlim() + ylim = self.ax.get_ylim() + xm, ym = event.xdata, event.ydata + self.ax.set_xlim([xm + (x - xm) * factor for x in xlim]) + self.ax.set_ylim([ym + (y - ym) * factor for y in ylim]) + self.canvas.draw_idle() + def _on_manual_add(self): - """Handle manual 'Add Point' button click.""" if self.terrain is None: QMessageBox.warning(self, "No Terrain", "Load a DEM first.") return - - row = self.row_spin.value() - col = self.col_spin.value() - + row, col = self._latlon_to_rowcol(self.lat_spin.value(), self.lon_spin.value()) if self._mode == 'point': self._place_point(row, col) else: - self._add_polygon_vertex(row, col) - + self._add_vertex(row, col) + def _on_close_polygon(self): - """Handle 'Close Polygon' button click.""" self._close_polygon() - - # โ”€โ”€ Internal: Release Zone Logic โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Crown Zone Logic + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _place_point(self, row: int, col: int): - """Place a point release zone at (row, col).""" self._point_marker = (row, col) - height = self.height_spin.value() radius = self.radius_spin.value() - self.release_zone = self.terrain.create_release_zone( - center_i=row, center_j=col, - radius=radius, height=height + center_i=row, center_j=col, radius=radius, height=height ) - - self.status_label.setText(f"Point at ({row}, {col}) โ€” r={radius}, h={height:.1f}m") + self._update_coord_spinboxes(row, col) + lat, lon = self._rowcol_to_latlon(row, col) + if self.terrain.is_geographic: + self.status_label.setText(f"Crown: ({lat:.5f}ยฐ, {lon:.5f}ยฐ) r={radius}") + else: + self.status_label.setText(f"Crown: ({row}, {col}) r={radius}") self._draw_terrain() self.release_zone_changed.emit(self.release_zone) - - def _add_polygon_vertex(self, row: int, col: int): - """Add a vertex to the polygon being drawn.""" + + def _add_vertex(self, row: int, col: int): if self._polygon_closed: - # Start a new polygon self._polygon_vertices = [] self._polygon_closed = False self.release_zone = None - self._polygon_vertices.append((row, col)) n = len(self._polygon_vertices) - - self.btn_close_polygon.setEnabled(n >= 3) + self.btn_close_poly.setEnabled(n >= 3) + need = max(0, 3 - n) self.status_label.setText( - f"Polygon: {n} vertices โ€” " - + ("right-click or Close to finish" if n >= 3 else f"need {3 - n} more") + f"Polygon: {n} vertices" + (f" โ€” right-click or Close" if n >= 3 else f" โ€” need {need} more") ) - self._draw_terrain() - + def _close_polygon(self): - """Close the current polygon and compute the release zone.""" if len(self._polygon_vertices) < 3: - QMessageBox.warning( - self, "Not Enough Vertices", - "A polygon needs at least 3 vertices." - ) + QMessageBox.warning(self, "Not Enough Vertices", "A polygon needs at least 3 vertices.") return - self._polygon_closed = True height = self.height_spin.value() - self.release_zone = self.terrain.create_polygon_release_zone( - vertices=self._polygon_vertices, - height=height, - smooth=True + vertices=self._polygon_vertices, height=height, smooth=True ) - n = len(self._polygon_vertices) - self.status_label.setText(f"Polygon ({n} vertices) โ€” h={height:.1f}m โœ“") - self.btn_close_polygon.setEnabled(False) - + self.status_label.setText(f"Polygon ({n} vertices) h={height:.1f}m โœ“") + self.btn_close_poly.setEnabled(False) self._draw_terrain() self.release_zone_changed.emit(self.release_zone) + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Helpers + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _style_ax(self, ax): + ax.tick_params(colors='#888888', labelsize=7) + for spine in ax.spines.values(): + spine.set_color('#3d3d5c') + -# Helper import for drawing circles (deferred to avoid import-time cost) -def plt_Circle(center, radius, **kwargs): - """Create a matplotlib Circle patch.""" - from matplotlib.patches import Circle - return Circle(center, radius, **kwargs) +# Backward-compatibility alias +ReleaseZoneWidget = CrownWidget diff --git a/tests/test_library.py b/tests/test_library.py index 53822da..2633f45 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -26,7 +26,7 @@ def test_version_accessible(self): """Test that version info is accessible.""" import src as pydebflow assert hasattr(pydebflow, '__version__') - assert pydebflow.__version__ == "0.1.0" + assert pydebflow.__version__ == "0.2.0" def test_author_info(self): """Test that author info is accessible.""" @@ -38,7 +38,7 @@ def test_author_info(self): def test_get_version_function(self): """Test the get_version() helper function.""" import src as pydebflow - assert pydebflow.get_version() == "0.1.0" + assert pydebflow.get_version() == "0.2.0" class TestCoreImports: diff --git a/tests/test_v020.py b/tests/test_v020.py new file mode 100644 index 0000000..064a0bf --- /dev/null +++ b/tests/test_v020.py @@ -0,0 +1,329 @@ +""" +Tests for PyDebFlow v0.2.0 features. + +Covers: +- Crown widget lat/lon conversion +- Profile x-axis 0.1 m resolution +- Multi-point hydrograph +- StatisticsWidget calculations +- Save-plot creates file +""" + +import numpy as np +import pytest +import os +import tempfile +from pathlib import Path + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Helpers +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def make_terrain(rows=50, cols=60, cell_size=10.0, is_geographic=False): + """Create a simple synthetic Terrain for testing.""" + from src.core.terrain import Terrain + elevation = np.zeros((rows, cols)) + # Simple slope + for i in range(rows): + elevation[i, :] = (rows - i) * 5.0 # 5 m/cell slope + if is_geographic: + return Terrain(elevation, cell_size=cell_size, + x_origin=78.0, y_origin=30.0, + is_geographic=True, cell_size_deg=0.0001, mean_lat=30.05) + return Terrain(elevation, cell_size=cell_size) + + +def make_outputs(terrain, n_frames=20): + """Create synthetic simulation outputs [(time, FlowState), ...].""" + from src.core.flow_model import FlowState + outputs = [] + for i in range(n_frames): + t = float(i) + state = FlowState.zeros((terrain.rows, terrain.cols)) + # Place a decaying pulse in top-left quadrant + r0, c0 = terrain.rows // 4, terrain.cols // 4 + height = max(0.0, 3.0 - 0.15 * i) + for dr in range(-5, 6): + for dc in range(-5, 6): + r, c = r0 + dr, c0 + dc + if 0 <= r < terrain.rows and 0 <= c < terrain.cols: + d = np.sqrt(dr**2 + dc**2) + if d <= 5: + h = height * (1 - (d / 5) ** 2) + state.h_solid[r, c] = h * 0.65 + state.h_fluid[r, c] = h * 0.35 + state.u_solid[r, c] = 1.0 + state.u_fluid[r, c] = 0.8 + outputs.append((t, state)) + return outputs + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# 1. Crown Widget โ€” lat/lon โ†” row/col conversion +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestCrownLatLon: + """Test Terrain geo-coord helpers used by CrownWidget.""" + + def setup_method(self): + self.terrain = make_terrain(rows=100, cols=120, is_geographic=True) + + def test_terrain_is_geographic_flag(self): + assert self.terrain.is_geographic is True + + def test_terrain_cell_size_deg_stored(self): + assert self.terrain.cell_size_deg == pytest.approx(0.0001) + + def test_rowcol_to_latlon_top_left(self): + """Row 0, Col 0 should be at the top-left (max lat, min lon).""" + t = self.terrain + lat_top = t.y_origin + t.rows * t.cell_size_deg + lat = lat_top - 0 * t.cell_size_deg + lon = t.x_origin + 0 * t.cell_size_deg + assert lat == pytest.approx(lat_top, abs=1e-9) + assert lon == pytest.approx(t.x_origin, abs=1e-9) + + def test_rowcol_to_latlon_bottom_right(self): + t = self.terrain + row, col = t.rows - 1, t.cols - 1 + lat_top = t.y_origin + t.rows * t.cell_size_deg + expected_lat = lat_top - row * t.cell_size_deg + expected_lon = t.x_origin + col * t.cell_size_deg + got_lat = lat_top - row * t.cell_size_deg + got_lon = t.x_origin + col * t.cell_size_deg + assert got_lat == pytest.approx(expected_lat, rel=1e-6) + assert got_lon == pytest.approx(expected_lon, rel=1e-6) + + def test_latlon_roundtrip(self): + """Row/col โ†’ lat/lon โ†’ row/col should be identity.""" + t = self.terrain + for (row, col) in [(0, 0), (25, 30), (99, 119), (50, 60)]: + lat_top = t.y_origin + t.rows * t.cell_size_deg + lat = lat_top - row * t.cell_size_deg + lon = t.x_origin + col * t.cell_size_deg + # Back to row/col โ€” use round() to match CrownWidget._latlon_to_rowcol + row_back = int(round((lat_top - lat) / t.cell_size_deg)) + col_back = int(round((lon - t.x_origin) / t.cell_size_deg)) + assert row_back == row, f"Row mismatch for ({row},{col}): got {row_back}" + assert col_back == col, f"Col mismatch for ({row},{col}): got {col_back}" + + def test_projected_terrain_no_geographic(self): + t = make_terrain(is_geographic=False) + assert t.is_geographic is False + assert t.cell_size_deg == 0.0 + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# 2. Profile x-axis resolution +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestProfileResolution: + """Profile should sample every 0.1 m (capped at 5000).""" + + def _sample_count(self, dist_m): + """Replicate the n_samples logic from CrossSectionWidget.""" + return max(2, min(int(dist_m / 0.1), 5000)) + + def test_short_transect_10m(self): + # 10 m โ†’ 100 pts + assert self._sample_count(10.0) == 100 + + def test_long_transect_2km(self): + # 2000 m โ†’ 5000 (capped) + assert self._sample_count(2000.0) == 5000 + + def test_very_short_transect(self): + # 0.05 m โ†’ clamped to 2 + assert self._sample_count(0.05) == 2 + + def test_exactly_500m(self): + assert self._sample_count(500.0) == 5000 + + def test_resolution_spacing(self): + """Each sample should be ~0.1 m apart for short transects.""" + dist = 50.0 + n = self._sample_count(dist) + spacing = dist / max(n - 1, 1) + assert spacing == pytest.approx(0.1, abs=0.001) + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# 3. Multi-point hydrograph +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestMultiPointHydrograph: + """HydrographWidget stores multiple monitor points and draws them all.""" + + def setup_method(self): + self.terrain = make_terrain() + self.outputs = make_outputs(self.terrain) + + def test_add_multiple_points(self): + from src.gui.analysis_widgets import HydrographWidget + pytest.importorskip("PyQt6") + from PyQt6.QtWidgets import QApplication + import sys + app = QApplication.instance() or QApplication(sys.argv) + + w = HydrographWidget() + w.set_data(self.terrain, self.outputs) + assert w._monitor_points == [] + + w._add_monitor_point(5, 10) + w._add_monitor_point(10, 20) + w._add_monitor_point(15, 30) + assert len(w._monitor_points) == 3 + assert (5, 10) in w._monitor_points + assert (15, 30) in w._monitor_points + + def test_clear_all_points(self): + from src.gui.analysis_widgets import HydrographWidget + pytest.importorskip("PyQt6") + from PyQt6.QtWidgets import QApplication + import sys + app = QApplication.instance() or QApplication(sys.argv) + + w = HydrographWidget() + w.set_data(self.terrain, self.outputs) + w._add_monitor_point(5, 10) + w._add_monitor_point(10, 20) + assert len(w._monitor_points) == 2 + w._clear_all_points() + assert w._monitor_points == [] + + def test_hydrograph_series_count(self): + """Number of plotted lines == number of monitor points.""" + from src.gui.analysis_widgets import HydrographWidget + pytest.importorskip("PyQt6") + from PyQt6.QtWidgets import QApplication + import sys + app = QApplication.instance() or QApplication(sys.argv) + + w = HydrographWidget() + w.set_data(self.terrain, self.outputs) + w._add_monitor_point(5, 10) + w._add_monitor_point(10, 20) + w._draw_hydrograph() + # Each monitor point adds 1 line to ax_height (h_total) + assert len(w.ax_height.lines) == 2 + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# 4. StatisticsWidget calculations (headless / no Qt required) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestStatisticsCalculations: + """Test StatisticsWidget._compute_stats() without GUI.""" + + def setup_method(self): + self.terrain = make_terrain() + self.outputs = make_outputs(self.terrain, n_frames=20) + + def _get_stats(self): + from src.gui.analysis_widgets import StatisticsWidget + pytest.importorskip("PyQt6") + from PyQt6.QtWidgets import QApplication + import sys + app = QApplication.instance() or QApplication(sys.argv) + w = StatisticsWidget() + w.terrain = self.terrain + w.outputs = self.outputs + return w._compute_stats() + + def test_stats_structure(self): + s = self._get_stats() + assert 'descriptive' in s + assert 'spatial' in s + assert 'temporal' in s + assert 'phase' in s + assert 'tests' in s + + def test_descriptive_keys(self): + d = self._get_stats()['descriptive'] + for key in ['mean', 'std', 'min', 'max', 'p5', 'p50', 'p95', 'skewness', 'kurtosis']: + assert key in d, f"Missing key: {key}" + + def test_mean_is_positive(self): + d = self._get_stats()['descriptive'] + assert d['mean'] > 0.0 + + def test_spatial_area_positive(self): + sp = self._get_stats()['spatial'] + assert sp['flow_area_m2'] > 0.0 + + def test_temporal_peak_time_in_range(self): + t = self._get_stats()['temporal'] + times = [fr[0] for fr in self.outputs] + assert times[0] <= t['peak_time_s'] <= times[-1] + + def test_phase_fractions_sum_to_one(self): + ph = self._get_stats()['phase'] + assert ph['mean_solid_fraction'] + ph['mean_fluid_fraction'] == pytest.approx(1.0, abs=1e-6) + + def test_shapiro_wilk_keys(self): + ts = self._get_stats()['tests'] + assert 'shapiro_wilk_stat' in ts + assert 'shapiro_wilk_p' in ts + assert 0.0 <= ts['shapiro_wilk_stat'] <= 1.0 + + def test_ks_test_keys(self): + ts = self._get_stats()['tests'] + assert 'ks_uniform_stat' in ts + assert 0.0 <= ts['ks_uniform_stat'] <= 1.0 + + def test_n_frames_correct(self): + s = self._get_stats() + assert s['n_frames'] == 20 + + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# 5. Save-plot creates file +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestSavePlot: + """Verify that figure.savefig() creates a file.""" + + def test_profile_figure_saves_png(self): + from matplotlib.figure import Figure + fig = Figure(facecolor='#16213e') + ax = fig.add_subplot(111) + ax.plot([0, 1, 2], [1, 4, 2]) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "profile_test.png") + fig.savefig(path, dpi=72) + assert os.path.exists(path) + assert os.path.getsize(path) > 0 + + def test_hydro_figure_saves_pdf(self): + from matplotlib.figure import Figure + fig = Figure(facecolor='#16213e') + ax = fig.add_subplot(111) + ax.plot([0, 1], [0, 1]) + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "hydro_test.pdf") + fig.savefig(path) + assert os.path.exists(path) + + def test_stats_csv_written(self): + """StatisticsWidget._save_as_csv writes valid CSV.""" + from src.gui.analysis_widgets import StatisticsWidget + pytest.importorskip("PyQt6") + from PyQt6.QtWidgets import QApplication + import sys + app = QApplication.instance() or QApplication(sys.argv) + + terrain = make_terrain() + outputs = make_outputs(terrain, n_frames=10) + w = StatisticsWidget() + w.terrain = terrain + w.outputs = outputs + w._stats = w._compute_stats() + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "stats.csv") + w._save_as_csv(path) + content = Path(path).read_text() + assert "Section,Key,Value" in content + assert "descriptive" in content + assert "spatial" in content