From b3a872f45961390a60893f74e06313b475173b18 Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 14:18:28 +0900 Subject: [PATCH 01/11] adopted mypy, ruff for code quality, rewrote readme, update license --- .github/workflows/ci.yml | 32 ++ LICENSE | 21 -- README.md | 151 +++----- examples/__init__.py | 0 examples/demo.py | 548 ++++++++++++++++++++--------- examples/generate_data.py | 15 +- examples/heatmap_demo.py | 81 +++-- examples/heatmap_poc.py | 170 +++++---- examples/quick_start.py | 123 +++---- examples/us_map_utils.py | 130 ++++--- fieldview/__init__.py | 40 +++ fieldview/core/data_container.py | 36 +- fieldview/layers/data_layer.py | 28 +- fieldview/layers/heatmap_layer.py | 231 +++++++----- fieldview/layers/layer.py | 6 +- fieldview/layers/pin_layer.py | 12 +- fieldview/layers/svg_layer.py | 3 +- fieldview/layers/text_layer.py | 144 +++++--- fieldview/rendering/colormaps.py | 109 ++++-- fieldview/ui/color_range_widget.py | 7 +- fieldview/ui/data_table.py | 100 ++++-- fieldview/ui/field_view.py | 85 +++++ fieldview/utils/grid_manager.py | 44 ++- fieldview/utils/interpolation.py | 121 ++++--- fieldview/utils/qt_compat.py | 4 +- pyproject.toml | 18 + scripts/capture_screenshot.py | 74 ++-- scripts/fetch_nws_weather.py | 137 ++++---- tests/test_color_range_widget.py | 2 - tests/test_data_container.py | 36 +- tests/test_data_layer.py | 24 +- tests/test_grid_manager.py | 34 +- tests/test_heatmap_layer.py | 32 +- tests/test_interpolation.py | 28 +- tests/test_misc_layers.py | 16 +- tests/test_text_layer.py | 46 +-- uv.lock | 191 ++++++++++ 37 files changed, 1837 insertions(+), 1042 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 LICENSE create mode 100644 examples/__init__.py create mode 100644 fieldview/ui/field_view.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c9a33bc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + quality: + name: Code Quality + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Lint with Ruff + run: uv run ruff check . + + - name: Check formatting with Ruff + run: uv run ruff format --check . + + - name: Type check with Mypy + run: uv run mypy . diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 0164060..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 FieldView Team - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index c658981..2bccb48 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,84 @@ # FieldView -**FieldView** is a high-performance Python + Qt library for 2D data visualization, specifically designed for handling irregular data points. It uses `QtPy` to support **PySide6**, **PyQt6**, and **PyQt5**. It provides a robust rendering engine for heatmaps, markers, and text labels with minimal external dependencies. +**FieldView** is a high-performance Python library for 2D data visualization, built on top of the Qt framework. It is designed to efficiently render irregular data points using heatmaps, markers, and text labels. -Quick Start +FieldView leverages `QtPy` to support **PySide6**, **PyQt6**, and **PyQt5**, providing a flexible and robust solution for integrating advanced visualizations into Python desktop applications. + +FieldView Demo ## Key Features -* **Fast Heatmap Rendering**: Hybrid RBF (Radial Basis Function) interpolation for high-quality visualization with real-time performance optimization. -* **Irregular Data Support**: Native handling of non-grid data points. -* **Polygon Masking**: Support for arbitrary boundary shapes (Polygon, Circle, Rectangle) to clip heatmaps. -* **Layer System**: Modular architecture with support for: - * **HeatmapLayer**: Color-based data visualization. - * **ValueLayer/LabelLayer**: Text rendering with collision avoidance. - * **PinLayer**: Marker placement. - * **SvgLayer**: Background floor plans or overlays. -* **Minimal Dependencies**: Built on `numpy`, `scipy`, and `qtpy`. +* **High-Performance Heatmaps**: Utilizes hybrid RBF (Radial Basis Function) interpolation for smooth, high-quality visualization of scattered data. +* **Irregular Data Handling**: Natively supports non-grid data points without requiring pre-processing. +* **Flexible Masking**: Supports arbitrary boundary shapes (Polygon, Circle, Rectangle) for precise clipping. +* **Modular Layer System**: + * **HeatmapLayer**: Renders interpolated data with customizable colormaps. + * **ValueLayer / LabelLayer**: Displays text with automatic collision avoidance. + * **PinLayer**: Visualizes data points with markers. + * **SvgLayer**: Renders SVG backgrounds for context (e.g., floor plans, maps). +* **Minimal Dependencies**: Core functionality relies only on `numpy`, `scipy`, and `qtpy`. ## Installation +Install FieldView with your preferred Qt binding: + ```bash -pip install fieldview[pyside6] # Install with PySide6 +pip install fieldview[pyside6] # Recommended # OR -pip install fieldview[pyqt6] # Install with PyQt6 +pip install fieldview[pyqt6] # OR -pip install fieldview[pyqt5] # Install with PyQt5 - +pip install fieldview[pyqt5] ``` -*Note: Requires Python 3.10+* +*Requires Python 3.10+* ## Quick Start -Here is a minimal example to get a heatmap up and running: +FieldView provides a high-level `FieldView` widget for easy integration. ```python import sys -import os import numpy as np -from qtpy.QtWidgets import QApplication, QGraphicsView, QGraphicsScene -from qtpy.QtGui import QPolygonF -from qtpy.QtCore import Qt, QPointF -from fieldview.core.data_container import DataContainer -from fieldview.layers.heatmap_layer import HeatmapLayer -from fieldview.layers.text_layer import ValueLayer -from fieldview.layers.svg_layer import SvgLayer -from fieldview.layers.pin_layer import PinLayer +from qtpy.QtWidgets import QApplication +from fieldview import FieldView app = QApplication(sys.argv) -# 1. Setup Data -data = DataContainer() -np.random.seed(44) - -# Define rooms (x1, y1, x2, y2) -rooms = [ - (-450, -250, -150, 250), # Master Bed - (-150, -250, 150, 250), # Living - (150, 0, 300, 250), # Bed 2 - (300, -100, 450, 250) # Bed 4 -] - -points = [] -values = [] - -for i in range(20): - room = rooms[np.random.randint(len(rooms))] - x1, y1, x2, y2 = room - margin = 20 - x = np.random.uniform(x1 + margin, x2 - margin) - y = np.random.uniform(y1 + margin, y2 - margin) - points.append([x, y]) - values.append(np.random.rand() * 100) - -data.set_data(np.array(points), np.array(values)) - -# 2. Create Scene & Layers -scene = QGraphicsScene() - -# SVG Layer (Background) -# Assuming floorplan_apartment.svg exists in current dir or provide path -svg_layer = SvgLayer() -svg_layer.load_svg("examples/floorplan_apartment.svg") -svg_layer.setZValue(0) -scene.addItem(svg_layer) - -# Heatmap Layer -heatmap = HeatmapLayer(data) -heatmap.setOpacity(0.6) -heatmap.setZValue(1) - -# Define custom boundary polygon for the apartment -polygon = QPolygonF([ - QPointF(-450, -330), QPointF(-300, -330), QPointF(-300, -250), - QPointF(-150, -250), QPointF(-150, -300), QPointF(150, -300), - QPointF(150, -250), QPointF(450, -250), QPointF(450, 250), - QPointF(-450, 250) -]) -heatmap.set_boundary_shape(polygon) - -scene.addItem(heatmap) - -# Pin Layer -pin_layer = PinLayer(data) -pin_layer.setZValue(2) -scene.addItem(pin_layer) - -# Value Layer -values_layer = ValueLayer(data) -values_layer.setZValue(3) -scene.addItem(values_layer) - -# 3. Setup View -view = QGraphicsView(scene) +# 1. Prepare Data +points = np.random.rand(20, 2) * 400 +values = np.random.rand(20) * 100 + +# 2. Create FieldView +view = FieldView() view.resize(800, 600) -view.show() +view.set_data(points, values) -# Ensure content is visible -scene.setSceneRect(scene.itemsBoundingRect()) -view.fitInView(scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) +# 3. Add Layers +view.add_heatmap_layer(opacity=0.6) +view.add_pin_layer() +view.add_value_layer() + +# 4. Show +view.show() +view.fit_to_scene() sys.exit(app.exec()) ``` -≈ -## Running the Demo +## Examples -To see all features in action, including the property editor and real-time interaction: +To explore the full capabilities, including the property inspector and real-time updates, run the included demo: ```bash -# Clone the repository -git clone https://github.com/yourusername/fieldview.git -cd fieldview - -# Run the demo using uv (recommended) +# Using uv (recommended) uv run examples/demo.py ``` -Demo - ## License -MIT License +This project is licensed under a hybrid model depending on the Qt binding used: + +* **LGPLv3**: When used with **PySide6**. +* **GPLv3**: When used with **PyQt6** or **PyQt5**. + +Please ensure compliance with the license of the chosen Qt binding. diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/demo.py b/examples/demo.py index ee6b7b8..9e27b25 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -1,30 +1,40 @@ import sys import os -# Force single-threaded execution for BLAS libraries to avoid overhead on small matrices os.environ["OMP_NUM_THREADS"] = "1" os.environ["OPENBLAS_NUM_THREADS"] = "1" os.environ["MKL_NUM_THREADS"] = "1" os.environ["VECLIB_MAXIMUM_THREADS"] = "1" os.environ["NUMEXPR_NUM_THREADS"] = "1" -import numpy as np -import pandas as pd -from qtpy.QtWidgets import (QApplication, QGraphicsView, QGraphicsScene, QMainWindow, - QDockWidget, QWidget, QVBoxLayout, QHBoxLayout, - QGroupBox, QFormLayout, QSpinBox, QDoubleSpinBox, - QComboBox, QCheckBox, QPushButton, QTableWidget, QTableView, - QTableWidgetItem, QHeaderView, QFileDialog, QLabel, QTreeWidget, - QTreeWidgetItem, QGraphicsEllipseItem, QGraphicsLineItem, QAbstractItemView, - QLineEdit, QColorDialog) -from qtpy.QtGui import QPainter, QBrush, QPen, QColor, QPolygonF, QAction, QIcon, QPixmap, QStandardItemModel, QStandardItem, QFont, QPainterPath -from qtpy.QtCore import Qt, QTimer, QPointF, QRectF, QAbstractTableModel, QModelIndex, Signal - -# Import QtAds -import PySide6QtAds as ads +from qtpy.QtWidgets import ( + QApplication, + QGraphicsView, + QGraphicsScene, + QMainWindow, + QWidget, + QVBoxLayout, + QHBoxLayout, + QSpinBox, + QDoubleSpinBox, + QComboBox, + QCheckBox, + QPushButton, + QHeaderView, + QLabel, + QTreeWidget, + QTreeWidgetItem, + QGraphicsEllipseItem, + QGraphicsLineItem, + QAbstractItemView, + QLineEdit, + QColorDialog, +) +from qtpy.QtGui import QPainter, QBrush, QPen, QColor, QPolygonF, QPainterPath +from qtpy.QtCore import Qt, QTimer, QPointF, QRectF # Add project root to sys.path -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from fieldview.core.data_container import DataContainer from fieldview.layers.heatmap_layer import HeatmapLayer @@ -34,11 +44,20 @@ from fieldview.rendering.colormaps import COLORMAPS from fieldview.ui import ColorRangeControl from fieldview.ui.data_table import DataTable -from fieldview.ui.data_table import DataTable -from examples.us_map_utils import get_state_data, get_us_boundary, load_weather_data, generate_us_dataset +from examples.us_map_utils import ( + get_state_data, + get_us_boundary, + load_weather_data, + generate_us_dataset, +) +# Import QtAds +import PySide6QtAds as ads + +import numpy as np # --- Property Browser Components --- + class PropertyBrowser(QTreeWidget): def __init__(self): super().__init__() @@ -61,10 +80,20 @@ def add_group(self, name): item.setFont(0, font) return item - def add_float_property(self, parent, name, value, setter, min_val=0.0, max_val=1.0, step=0.1, decimals=2): + def add_float_property( + self, + parent, + name, + value, + setter, + min_val=0.0, + max_val=1.0, + step=0.1, + decimals=2, + ): item = QTreeWidgetItem(parent) item.setText(0, name) - + spin = QDoubleSpinBox() spin.setRange(min_val, max_val) spin.setSingleStep(step) @@ -72,84 +101,88 @@ def add_float_property(self, parent, name, value, setter, min_val=0.0, max_val=1 spin.setValue(value) spin.setFrame(False) spin.valueChanged.connect(setter) - + self.setItemWidget(item, 1, spin) return item - def add_int_property(self, parent, name, value, setter, min_val=0, max_val=100, step=1): + def add_int_property( + self, parent, name, value, setter, min_val=0, max_val=100, step=1 + ): item = QTreeWidgetItem(parent) item.setText(0, name) - + spin = QSpinBox() spin.setRange(min_val, max_val) spin.setSingleStep(step) spin.setValue(value) spin.setFrame(False) spin.valueChanged.connect(setter) - + self.setItemWidget(item, 1, spin) return item def add_bool_property(self, parent, name, value, setter): item = QTreeWidgetItem(parent) item.setText(0, name) - + check = QCheckBox() check.setChecked(value) check.toggled.connect(setter) - + widget = QWidget() layout = QHBoxLayout(widget) - layout.setContentsMargins(0,0,0,0) + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(check) layout.addStretch() - + self.setItemWidget(item, 1, widget) return item def add_enum_property(self, parent, name, value, options, setter): item = QTreeWidgetItem(parent) item.setText(0, name) - + combo = QComboBox() combo.addItems(options) if value in options: combo.setCurrentText(value) combo.setFrame(False) combo.currentTextChanged.connect(setter) - + self.setItemWidget(item, 1, combo) return item - + def add_string_property(self, parent, name, value, setter): item = QTreeWidgetItem(parent) item.setText(0, name) - + edit = QLineEdit(value) edit.setFrame(False) edit.textChanged.connect(setter) - + self.setItemWidget(item, 1, edit) return item def add_color_property(self, parent, name, value, setter): item = QTreeWidgetItem(parent) item.setText(0, name) - + btn = QPushButton() btn.setFlat(True) - + def update_btn_color(color): - btn.setStyleSheet(f"background-color: {color.name()}; border: 1px solid gray;") - + btn.setStyleSheet( + f"background-color: {color.name()}; border: 1px solid gray;" + ) + update_btn_color(value) - + def pick_color(): color = QColorDialog.getColor(value, None, f"Select {name}") if color.isValid(): update_btn_color(color) setter(color) - + btn.clicked.connect(pick_color) self.setItemWidget(item, 1, btn) return item @@ -157,17 +190,17 @@ def pick_color(): def add_action_property(self, parent, name, button_text, callback): item = QTreeWidgetItem(parent) item.setText(0, name) - + btn = QPushButton(button_text) btn.clicked.connect(callback) - + self.setItemWidget(item, 1, btn) return item - # --- Polygon Editor Helpers (Reused) --- + class PolygonHandle(QGraphicsEllipseItem): def __init__(self, index, x, y, move_callback, remove_callback): super().__init__(-5, -5, 10, 10) @@ -189,7 +222,9 @@ def mousePressEvent(self, event): if event.button() == Qt.MouseButton.RightButton: self.remove_callback(self.index) event.accept() - else: super().mousePressEvent(event) + else: + super().mousePressEvent(event) + class PolygonEdge(QGraphicsLineItem): def __init__(self, index, p1, p2, add_callback): @@ -206,21 +241,24 @@ def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.add_callback(self.index, event.scenePos()) event.accept() - else: super().mousePressEvent(event) + else: + super().mousePressEvent(event) + # --- Main Application --- + class DemoApp(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("FieldView Demo (QtAds)") self.resize(1600, 900) - + # 1. Setup Core self.data_container = DataContainer() self.scene = QGraphicsScene() self.scene.setBackgroundBrush(QColor(30, 30, 30)) - + # 2. Setup Layers self.setup_layers() @@ -228,27 +266,37 @@ def __init__(self): # 3. Setup Dock Manager self.dock_manager = ads.CDockManager(self) - + # 4. Setup View Dock self.view = QGraphicsView(self.scene) self.view.setRenderHint(QPainter.Antialiasing) self.view.setDragMode(QGraphicsView.ScrollHandDrag) self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) - + self.dock_view = ads.CDockWidget("Viewport") self.dock_view.setWidget(self.view) - self.dock_manager.addDockWidget(ads.DockWidgetArea.CenterDockWidgetArea, self.dock_view) - + self.dock_manager.addDockWidget( + ads.DockWidgetArea.CenterDockWidgetArea, self.dock_view + ) + # 5. Initialize Polygon State self.polygon_handles = [] self.polygon_edges = [] - self.heatmap_polygon = QPolygonF([ - QPointF(-450, -330), QPointF(-300, -330), QPointF(-300, -250), - QPointF(-150, -250), QPointF(-150, -300), QPointF(150, -300), - QPointF(150, -250), QPointF(450, -250), QPointF(450, 250), - QPointF(-450, 250) - ]) + self.heatmap_polygon = QPolygonF( + [ + QPointF(-450, -330), + QPointF(-300, -330), + QPointF(-300, -250), + QPointF(-150, -250), + QPointF(-150, -300), + QPointF(150, -300), + QPointF(150, -250), + QPointF(450, -250), + QPointF(450, 250), + QPointF(-450, 250), + ] + ) self.update_heatmap_polygon() self.toggle_polygon_handles(False) @@ -266,26 +314,26 @@ def __init__(self): def setup_layers(self): # SVG self.svg_layer = SvgLayer() - svg_path = os.path.join(os.path.dirname(__file__), 'us_map.svg') + svg_path = os.path.join(os.path.dirname(__file__), "us_map.svg") self.svg_layer.load_svg(svg_path) self.scene.addItem(self.svg_layer) - + # Heatmap self.heatmap_layer = HeatmapLayer(self.data_container) self.heatmap_layer.setOpacity(0.6) self.scene.addItem(self.heatmap_layer) - + # Pins self.pin_layer = PinLayer(self.data_container) # self.create_dummy_pin() # Removed dummy pin self.scene.addItem(self.pin_layer) - + # Values self.value_layer = ValueLayer(self.data_container) self.value_layer.decimal_places = 1 self.value_layer.suffix = "°C" self.scene.addItem(self.value_layer) - + # Labels self.label_layer = LabelLayer(self.data_container) self.label_layer.setVisible(False) @@ -295,21 +343,23 @@ def setup_properties_dock(self): self.dock_props = ads.CDockWidget("Inspector") widget = QWidget() layout = QVBoxLayout(widget) - + # Render Time Label self.lbl_render_time = QLabel("Render Time: 0.00 ms") self.lbl_render_time.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.lbl_render_time) - + self.heatmap_layer.renderingFinished.connect(self.update_render_time) # Property Browser self.props = PropertyBrowser() layout.addWidget(self.props) - + self.dock_props.setWidget(widget) - self.dock_manager.addDockWidget(ads.DockWidgetArea.RightDockWidgetArea, self.dock_props) - + self.dock_manager.addDockWidget( + ads.DockWidgetArea.RightDockWidgetArea, self.dock_props + ) + self.populate_all_properties() def setup_data_dock(self): @@ -317,37 +367,41 @@ def setup_data_dock(self): widget = QWidget() layout = QVBoxLayout(widget) layout.setContentsMargins(0, 0, 0, 0) - + self.table_view = DataTable(self.data_container) self.table_model = self.table_view.table_model self.table_model.dataChanged.connect(self.on_table_changed) - + layout.addWidget(self.table_view) - + btn_layout = QHBoxLayout() btn_add = QPushButton("Add Random") btn_add.clicked.connect(self.add_point) btn_layout.addWidget(btn_add) - + btn_del = QPushButton("Delete Selected") btn_del.clicked.connect(self.delete_selected_points) btn_layout.addWidget(btn_del) - + btn_regen = QPushButton("Regenerate") btn_regen.clicked.connect(self.generate_data) btn_layout.addWidget(btn_regen) - + layout.addLayout(btn_layout) - + self.dock_data.setWidget(widget) # Split Inspector vertically, putting Data Points at the bottom - self.dock_manager.addDockWidget(ads.DockWidgetArea.BottomDockWidgetArea, self.dock_data, self.dock_props.dockAreaWidget()) + self.dock_manager.addDockWidget( + ads.DockWidgetArea.BottomDockWidgetArea, + self.dock_data, + self.dock_props.dockAreaWidget(), + ) def setup_simulation_dock(self): self.dock_sim = ads.CDockWidget("Simulation") widget = QWidget() layout = QVBoxLayout(widget) - + hbox_sim = QHBoxLayout() hbox_sim.addWidget(QLabel("Interval (ms):")) self.spin_interval = QSpinBox() @@ -355,7 +409,7 @@ def setup_simulation_dock(self): self.spin_interval.setValue(50) hbox_sim.addWidget(self.spin_interval) layout.addLayout(hbox_sim) - + hbox_noise = QHBoxLayout() hbox_noise.addWidget(QLabel("Noise Amt:")) self.spin_noise = QDoubleSpinBox() @@ -363,17 +417,21 @@ def setup_simulation_dock(self): self.spin_noise.setValue(5.0) hbox_noise.addWidget(self.spin_noise) layout.addLayout(hbox_noise) - + self.btn_sim = QPushButton("Start Noise") self.btn_sim.setCheckable(True) self.btn_sim.toggled.connect(self.toggle_simulation) layout.addWidget(self.btn_sim) - + layout.addStretch() - + self.dock_sim.setWidget(widget) # Tabify with Data Points - self.dock_manager.addDockWidget(ads.DockWidgetArea.CenterDockWidgetArea, self.dock_sim, self.dock_data.dockAreaWidget()) + self.dock_manager.addDockWidget( + ads.DockWidgetArea.CenterDockWidgetArea, + self.dock_sim, + self.dock_data.dockAreaWidget(), + ) def setup_color_dock(self): self.dock_color = ads.CDockWidget("Color Range") @@ -387,12 +445,16 @@ def setup_color_dock(self): btn_auto_range = QPushButton("Auto (Data Min/Max)") btn_auto_range.clicked.connect(self.reset_auto_color_range) layout.addWidget(btn_auto_range) - + layout.addStretch() self.dock_color.setWidget(widget) # Tabify with Data Points - self.dock_manager.addDockWidget(ads.DockWidgetArea.CenterDockWidgetArea, self.dock_color, self.dock_data.dockAreaWidget()) + self.dock_manager.addDockWidget( + ads.DockWidgetArea.CenterDockWidgetArea, + self.dock_color, + self.dock_data.dockAreaWidget(), + ) def populate_all_properties(self): self.props.clear_properties() @@ -404,8 +466,19 @@ def populate_all_properties(self): def populate_heatmap_properties(self): root = self.props.add_group("Heatmap Layer") - self.props.add_bool_property(root, "Visible", self.heatmap_layer.isVisible(), self.heatmap_layer.setVisible) - self.props.add_float_property(root, "Opacity", self.heatmap_layer.opacity(), self.heatmap_layer.setOpacity, step=0.05) + self.props.add_bool_property( + root, + "Visible", + self.heatmap_layer.isVisible(), + self.heatmap_layer.setVisible, + ) + self.props.add_float_property( + root, + "Opacity", + self.heatmap_layer.opacity(), + self.heatmap_layer.setOpacity, + step=0.05, + ) self.props.add_enum_property( root, "Colormap", @@ -413,17 +486,37 @@ def populate_heatmap_properties(self): list(COLORMAPS.keys()), self._set_heatmap_colormap, ) - - self.props.add_enum_property(root, "Quality", self.heatmap_layer.quality.title(), ["Very Low", "Low", "Medium", "High", "Very High", "Adaptive"], - lambda q: setattr(self.heatmap_layer, 'quality', q)) - - self.props.add_int_property(root, "Neighbors", self.heatmap_layer.neighbors, - lambda n: setattr(self.heatmap_layer, 'neighbors', n), min_val=1, max_val=100) - self.props.add_enum_property(root, "Boundary Shape", "Custom Polygon", ["Custom Polygon", "Rectangle", "Circle"], - self.change_boundary_shape) - self.props.add_bool_property(root, "Edit Polygon", self.polygon_handles[0].isVisible() if self.polygon_handles else False, - self.toggle_polygon_handles) + self.props.add_enum_property( + root, + "Quality", + self.heatmap_layer.quality.title(), + ["Very Low", "Low", "Medium", "High", "Very High", "Adaptive"], + lambda q: setattr(self.heatmap_layer, "quality", q), + ) + + self.props.add_int_property( + root, + "Neighbors", + self.heatmap_layer.neighbors, + lambda n: setattr(self.heatmap_layer, "neighbors", n), + min_val=1, + max_val=100, + ) + + self.props.add_enum_property( + root, + "Boundary Shape", + "Custom Polygon", + ["Custom Polygon", "Rectangle", "Circle"], + self.change_boundary_shape, + ) + self.props.add_bool_property( + root, + "Edit Polygon", + self.polygon_handles[0].isVisible() if self.polygon_handles else False, + self.toggle_polygon_handles, + ) def _set_heatmap_colormap(self, name: str): self.heatmap_layer.colormap = name @@ -432,52 +525,156 @@ def _set_heatmap_colormap(self, name: str): def populate_value_properties(self): root = self.props.add_group("Value Layer") - self.props.add_bool_property(root, "Visible", self.value_layer.isVisible(), self.value_layer.setVisible) - self.props.add_float_property(root, "Opacity", self.value_layer.opacity(), self.value_layer.setOpacity, step=0.05) - self.props.add_int_property(root, "Font Size", self.value_layer.font.pixelSize(), - lambda s: self.set_layer_font_size(self.value_layer, s), min_val=6, max_val=72) - self.props.add_int_property(root, "Decimals", self.value_layer.decimal_places, - lambda d: setattr(self.value_layer, 'decimal_places', d), min_val=0, max_val=5) - - self.props.add_string_property(root, "Prefix", self.value_layer.prefix, lambda s: setattr(self.value_layer, 'prefix', s)) - self.props.add_string_property(root, "Suffix", self.value_layer.suffix, lambda s: setattr(self.value_layer, 'suffix', s)) - - self.props.add_color_property(root, "Highlight Color", self.value_layer.highlight_color, - lambda c: setattr(self.value_layer, 'highlight_color', c)) - - self.props.add_bool_property(root, "Avoid Collisions", self.value_layer.collision_avoidance_enabled, - lambda b: setattr(self.value_layer, 'collision_avoidance_enabled', b)) - self.props.add_float_property(root, "Offset Factor", self.value_layer.collision_offset_factor, - lambda f: setattr(self.value_layer, 'collision_offset_factor', f), min_val=0.5, max_val=2.0) + self.props.add_bool_property( + root, "Visible", self.value_layer.isVisible(), self.value_layer.setVisible + ) + self.props.add_float_property( + root, + "Opacity", + self.value_layer.opacity(), + self.value_layer.setOpacity, + step=0.05, + ) + self.props.add_int_property( + root, + "Font Size", + self.value_layer.font.pixelSize(), + lambda s: self.set_layer_font_size(self.value_layer, s), + min_val=6, + max_val=72, + ) + self.props.add_int_property( + root, + "Decimals", + self.value_layer.decimal_places, + lambda d: setattr(self.value_layer, "decimal_places", d), + min_val=0, + max_val=5, + ) + + self.props.add_string_property( + root, + "Prefix", + self.value_layer.prefix, + lambda s: setattr(self.value_layer, "prefix", s), + ) + self.props.add_string_property( + root, + "Suffix", + self.value_layer.suffix, + lambda s: setattr(self.value_layer, "suffix", s), + ) + + self.props.add_color_property( + root, + "Highlight Color", + self.value_layer.highlight_color, + lambda c: setattr(self.value_layer, "highlight_color", c), + ) + + self.props.add_bool_property( + root, + "Avoid Collisions", + self.value_layer.collision_avoidance_enabled, + lambda b: setattr(self.value_layer, "collision_avoidance_enabled", b), + ) + self.props.add_float_property( + root, + "Offset Factor", + self.value_layer.collision_offset_factor, + lambda f: setattr(self.value_layer, "collision_offset_factor", f), + min_val=0.5, + max_val=2.0, + ) def populate_label_properties(self): root = self.props.add_group("Label Layer") - self.props.add_bool_property(root, "Visible", self.label_layer.isVisible(), self.label_layer.setVisible) - self.props.add_float_property(root, "Opacity", self.label_layer.opacity(), self.label_layer.setOpacity, step=0.05) - self.props.add_int_property(root, "Font Size", self.label_layer.font.pixelSize(), - lambda s: self.set_layer_font_size(self.label_layer, s), min_val=6, max_val=72) - - self.props.add_color_property(root, "Highlight Color", self.label_layer.highlight_color, - lambda c: setattr(self.label_layer, 'highlight_color', c)) - - self.props.add_bool_property(root, "Avoid Collisions", self.label_layer.collision_avoidance_enabled, - lambda b: setattr(self.label_layer, 'collision_avoidance_enabled', b)) - self.props.add_float_property(root, "Offset Factor", self.label_layer.collision_offset_factor, - lambda f: setattr(self.label_layer, 'collision_offset_factor', f), min_val=0.5, max_val=2.0) + self.props.add_bool_property( + root, "Visible", self.label_layer.isVisible(), self.label_layer.setVisible + ) + self.props.add_float_property( + root, + "Opacity", + self.label_layer.opacity(), + self.label_layer.setOpacity, + step=0.05, + ) + self.props.add_int_property( + root, + "Font Size", + self.label_layer.font.pixelSize(), + lambda s: self.set_layer_font_size(self.label_layer, s), + min_val=6, + max_val=72, + ) + + self.props.add_color_property( + root, + "Highlight Color", + self.label_layer.highlight_color, + lambda c: setattr(self.label_layer, "highlight_color", c), + ) + + self.props.add_bool_property( + root, + "Avoid Collisions", + self.label_layer.collision_avoidance_enabled, + lambda b: setattr(self.label_layer, "collision_avoidance_enabled", b), + ) + self.props.add_float_property( + root, + "Offset Factor", + self.label_layer.collision_offset_factor, + lambda f: setattr(self.label_layer, "collision_offset_factor", f), + min_val=0.5, + max_val=2.0, + ) def populate_pin_properties(self): root = self.props.add_group("Pin Layer") - self.props.add_bool_property(root, "Visible", self.pin_layer.isVisible(), self.pin_layer.setVisible) - self.props.add_float_property(root, "Opacity", self.pin_layer.opacity(), self.pin_layer.setOpacity, step=0.05) + self.props.add_bool_property( + root, "Visible", self.pin_layer.isVisible(), self.pin_layer.setVisible + ) + self.props.add_float_property( + root, + "Opacity", + self.pin_layer.opacity(), + self.pin_layer.setOpacity, + step=0.05, + ) def populate_svg_properties(self): root = self.props.add_group("SVG Layer") - self.props.add_bool_property(root, "Visible", self.svg_layer.isVisible(), self.svg_layer.setVisible) - self.props.add_float_property(root, "Opacity", self.svg_layer.opacity(), self.svg_layer.setOpacity, step=0.05) - self.props.add_float_property(root, "Origin X", self.svg_layer.origin.x(), - lambda x: self.set_svg_origin(x=x), min_val=-1000, max_val=1000, step=1.0, decimals=1) - self.props.add_float_property(root, "Origin Y", self.svg_layer.origin.y(), - lambda y: self.set_svg_origin(y=y), min_val=-1000, max_val=1000, step=1.0, decimals=1) + self.props.add_bool_property( + root, "Visible", self.svg_layer.isVisible(), self.svg_layer.setVisible + ) + self.props.add_float_property( + root, + "Opacity", + self.svg_layer.opacity(), + self.svg_layer.setOpacity, + step=0.05, + ) + self.props.add_float_property( + root, + "Origin X", + self.svg_layer.origin.x(), + lambda x: self.set_svg_origin(x=x), + min_val=-1000, + max_val=1000, + step=1.0, + decimals=1, + ) + self.props.add_float_property( + root, + "Origin Y", + self.svg_layer.origin.y(), + lambda y: self.set_svg_origin(y=y), + min_val=-1000, + max_val=1000, + step=1.0, + decimals=1, + ) def set_layer_font_size(self, layer, size): font = layer.font @@ -492,8 +689,6 @@ def set_svg_origin(self, x=None, y=None): # --- Logic Methods (Reused) --- - - def generate_data(self): # Regenerate data (reload weather or generate random if needed) # For now, just re-initialize which reloads everything @@ -502,10 +697,14 @@ def generate_data(self): def add_point(self): # Re-use room definitions rooms = [ - (-450, -250, -150, 250), (-450, -330, -300, -250), - (-150, -250, 150, 250), (-150, -300, 150, -250), - (150, -250, 300, 0), (150, 0, 300, 250), - (300, -250, 450, -100), (300, -100, 450, 250) + (-450, -250, -150, 250), + (-450, -330, -300, -250), + (-150, -250, 150, 250), + (-150, -300, 150, -250), + (150, -250, 300, 0), + (150, 0, 300, 250), + (300, -250, 450, -100), + (300, -100, 450, 250), ] room = rooms[np.random.randint(len(rooms))] x1, y1, x2, y2 = room @@ -516,8 +715,12 @@ def add_point(self): self.data_container.add_points([[x, y]], [value], ["New"]) def delete_selected_points(self): - rows = sorted(set(index.row() for index in self.table_view.selectedIndexes()), reverse=True) - if rows: self.data_container.remove_points(rows) + rows = sorted( + set(index.row() for index in self.table_view.selectedIndexes()), + reverse=True, + ) + if rows: + self.data_container.remove_points(rows) def on_table_changed(self, topLeft, bottomRight, roles): if Qt.ItemDataRole.CheckStateRole in roles: @@ -535,13 +738,16 @@ def update_heatmap_polygon(self): self.update_polygon_handles() def update_polygon_handles(self): - for h in self.polygon_handles: self.scene.removeItem(h) + for h in self.polygon_handles: + self.scene.removeItem(h) self.polygon_handles.clear() - for e in self.polygon_edges: self.scene.removeItem(e) + for e in self.polygon_edges: + self.scene.removeItem(e) self.polygon_edges.clear() - + count = self.heatmap_polygon.count() - if count == 0: return + if count == 0: + return for i in range(count): p1 = self.heatmap_polygon.at(i) @@ -549,10 +755,12 @@ def update_polygon_handles(self): edge = PolygonEdge(i, p1, p2, self.on_polygon_point_added) self.scene.addItem(edge) self.polygon_edges.append(edge) - + for i in range(count): pt = self.heatmap_polygon.at(i) - handle = PolygonHandle(i, pt.x(), pt.y(), self.on_handle_moved, self.on_polygon_point_removed) + handle = PolygonHandle( + i, pt.x(), pt.y(), self.on_handle_moved, self.on_polygon_point_removed + ) self.scene.addItem(handle) self.polygon_handles.append(handle) @@ -571,13 +779,16 @@ def on_polygon_point_added(self, index, pos): self.update_heatmap_polygon() def on_polygon_point_removed(self, index): - if self.heatmap_polygon.count() <= 3: return + if self.heatmap_polygon.count() <= 3: + return self.heatmap_polygon.remove(index) self.update_heatmap_polygon() def toggle_polygon_handles(self, visible): - for h in self.polygon_handles: h.setVisible(visible) - for e in self.polygon_edges: e.setVisible(visible) + for h in self.polygon_handles: + h.setVisible(visible) + for e in self.polygon_edges: + e.setVisible(visible) def change_boundary_shape(self, shape_name): if shape_name == "Custom Polygon": @@ -619,68 +830,71 @@ def _update_color_range_from_data(self): color_max = float(values.max()) self.color_range_control.set_range(color_min, color_max, emit_signal=False) - + def wheelEvent(self, event): factor = 1.1 - if event.angleDelta().y() < 0: factor = 1.0 / factor + if event.angleDelta().y() < 0: + factor = 1.0 / factor self.view.scale(factor, factor) def update_render_time(self, duration_ms, grid_size=0): - self.lbl_render_time.setText(f"Render Time: {duration_ms:.2f} ms (Grid: {grid_size})") - + self.lbl_render_time.setText( + f"Render Time: {duration_ms:.2f} ms (Grid: {grid_size})" + ) + def toggle_simulation(self, checked): if checked: self.btn_sim.setText("Stop Noise") - if not hasattr(self, 'sim_timer'): + if not hasattr(self, "sim_timer"): self.sim_timer = QTimer() self.sim_timer.timeout.connect(self.apply_noise) self.sim_timer.setInterval(self.spin_interval.value()) self.sim_timer.start() else: self.btn_sim.setText("Start Noise") - if hasattr(self, 'sim_timer'): + if hasattr(self, "sim_timer"): self.sim_timer.stop() - + def apply_noise(self): amount = self.spin_noise.value() points = self.data_container.points values = self.data_container.values - + # Add noise to values noise = (np.random.rand(len(values)) - 0.5) * amount new_values = values + noise - + # Clamp values to 0-100 for sanity new_values = np.clip(new_values, 0, 100) - + self.data_container.set_data(points, new_values, self.data_container.labels) def init_data(self): # Load US Map Data - svg_file = os.path.join(os.path.dirname(__file__), 'us_map.svg') + svg_file = os.path.join(os.path.dirname(__file__), "us_map.svg") state_paths, centroids = get_state_data(svg_file) - + # Setup Heatmap Boundary us_boundary = get_us_boundary(state_paths) self.heatmap_layer.set_boundary_shape(us_boundary) - + # Load Weather Data weather_data = load_weather_data() - + # Generate Dataset points, values = generate_us_dataset(centroids, weather_data) - + self.data_container.set_data(points, values) - + # Configure Heatmap Defaults for US Map self.heatmap_layer.neighbors = 100 - self.heatmap_layer.kernel = "linear" # Robust for map boundaries - self.heatmap_layer.colormap = "jet" + self.heatmap_layer.kernel = "" self.heatmap_layer.setOpacity(0.6) - + # Update Inspector # (Ideally we would update the property browser values here to match) + if __name__ == "__main__": app = QApplication(sys.argv) window = DemoApp() diff --git a/examples/generate_data.py b/examples/generate_data.py index 1e69cc0..cc3ce1b 100644 --- a/examples/generate_data.py +++ b/examples/generate_data.py @@ -1,6 +1,6 @@ import numpy as np import pandas as pd -import os + def generate_dummy_data(filename="dummy_data.csv", n_points=50, radius=150): """ @@ -9,23 +9,20 @@ def generate_dummy_data(filename="dummy_data.csv", n_points=50, radius=150): # Generate random points in square range [-150, 150] x = (np.random.rand(n_points) - 0.5) * 2 * radius y = (np.random.rand(n_points) - 0.5) * 2 * radius - + # Generate values with a gradient (Left -> Right) normalized_x = (x + radius) / (2 * radius) base_values = normalized_x * 80 noise = (np.random.rand(n_points) - 0.5) * 20 values = base_values + noise values = np.clip(values, 0, 100) - - df = pd.DataFrame({ - 'x': x, - 'y': y, - 'value': values - }) - + + df = pd.DataFrame({"x": x, "y": y, "value": values}) + df.to_csv(filename, index=False) print(f"Generated {n_points} points to {filename}") return df + if __name__ == "__main__": generate_dummy_data() diff --git a/examples/heatmap_demo.py b/examples/heatmap_demo.py index 07e3ec6..66442f5 100644 --- a/examples/heatmap_demo.py +++ b/examples/heatmap_demo.py @@ -9,7 +9,6 @@ QGraphicsScene, QWidget, QVBoxLayout, - QHBoxLayout, QPushButton, QLabel, QFileDialog, @@ -21,36 +20,37 @@ from qtpy.QtCore import Qt, QPointF # Add project root to sys.path to import fieldview modules -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from fieldview.core.data_container import DataContainer from fieldview.layers.heatmap_layer import HeatmapLayer from fieldview.ui import ColorRangeControl from examples.generate_data import generate_dummy_data + class HeatmapDemo(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("FieldView Heatmap Demo") self.resize(1000, 800) - + # Core Components self.data_container = DataContainer() self.scene = QGraphicsScene() self.heatmap_layer = HeatmapLayer(self.data_container) self._using_auto_range = True - + # Setup Scene self.scene.addItem(self.heatmap_layer) self.scene.setBackgroundBrush(Qt.GlobalColor.black) - + # View self.view = QGraphicsView(self.scene) self.view.setRenderHint(QPainter.Antialiasing) self.view.setDragMode(QGraphicsView.ScrollHandDrag) self.setCentralWidget(self.view) - + # Controls (Dock) self.setup_controls() @@ -67,48 +67,48 @@ def __init__(self): def setup_controls(self): dock = QDockWidget("Controls", self) dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) - + widget = QWidget() layout = QVBoxLayout(widget) - + # Data Actions group_data = QGroupBox("Data Actions") layout_data = QVBoxLayout(group_data) - + btn_load = QPushButton("Load CSV") btn_load.clicked.connect(self.load_csv) layout_data.addWidget(btn_load) - + btn_gen = QPushButton("Generate Random") btn_gen.clicked.connect(self.generate_random_data) layout_data.addWidget(btn_gen) - + btn_add = QPushButton("Add Random Point") btn_add.clicked.connect(self.add_random_point) layout_data.addWidget(btn_add) - + btn_remove = QPushButton("Remove Random Point") btn_remove.clicked.connect(self.remove_random_point) layout_data.addWidget(btn_remove) - + btn_clear = QPushButton("Clear Data") btn_clear.clicked.connect(self.data_container.clear) layout_data.addWidget(btn_clear) - + layout.addWidget(group_data) - + # Layer Actions group_layer = QGroupBox("Layer Actions") layout_layer = QVBoxLayout(group_layer) - + lbl_shape = QLabel("Boundary Shape:") layout_layer.addWidget(lbl_shape) - + combo_shape = QComboBox() combo_shape.addItems(["Square", "Circle", "Triangle", "Hexagon"]) combo_shape.currentTextChanged.connect(self.update_shape) layout_layer.addWidget(combo_shape) - + btn_exclude = QPushButton("Toggle Exclusion (Random)") btn_exclude.clicked.connect(self.toggle_exclusion) layout_layer.addWidget(btn_exclude) @@ -128,30 +128,36 @@ def setup_controls(self): layout_color.addWidget(btn_auto_range) layout.addWidget(group_color) - + layout.addStretch() dock.setWidget(widget) self.addDockWidget(Qt.RightDockWidgetArea, dock) def load_csv(self): - filename, _ = QFileDialog.getOpenFileName(self, "Open CSV", "", "CSV Files (*.csv)") + filename, _ = QFileDialog.getOpenFileName( + self, "Open CSV", "", "CSV Files (*.csv)" + ) if filename: try: df = pd.read_csv(filename) - if 'x' in df.columns and 'y' in df.columns and 'value' in df.columns: - points = df[['x', 'y']].values - values = df['value'].values + if "x" in df.columns and "y" in df.columns and "value" in df.columns: + points = df[["x", "y"]].values + values = df["value"].values self.data_container.set_data(points, values) - self.status_label.setText(f"Loaded {len(points)} points from {os.path.basename(filename)}") + self.status_label.setText( + f"Loaded {len(points)} points from {os.path.basename(filename)}" + ) else: - self.status_label.setText("Error: CSV must have x, y, value columns") + self.status_label.setText( + "Error: CSV must have x, y, value columns" + ) except Exception as e: self.status_label.setText(f"Error loading CSV: {e}") def generate_random_data(self): df = generate_dummy_data(n_points=50) - points = df[['x', 'y']].values - values = df['value'].values + points = df[["x", "y"]].values + values = df["value"].values self.data_container.set_data(points, values) def add_random_point(self): @@ -160,7 +166,7 @@ def add_random_point(self): x = (np.random.rand() - 0.5) * 2 * radius y = (np.random.rand() - 0.5) * 2 * radius value = np.random.rand() * 100 - + self.data_container.add_points([[x, y]], [value]) def remove_random_point(self): @@ -172,7 +178,7 @@ def remove_random_point(self): def update_shape(self, shape_name): radius = 150 polygon = QPolygonF() - + if shape_name == "Square": polygon.append(QPointF(-radius, -radius)) polygon.append(QPointF(radius, -radius)) @@ -191,14 +197,15 @@ def update_shape(self, shape_name): for i in range(6): theta = 2 * np.pi * i / 6 polygon.append(QPointF(radius * np.cos(theta), radius * np.sin(theta))) - + self.heatmap_layer.set_boundary_shape(polygon) self.status_label.setText(f"Shape set to {shape_name}") def toggle_exclusion(self): count = len(self.data_container.points) - if count == 0: return - + if count == 0: + return + idx = np.random.randint(0, count) if idx in self.heatmap_layer.excluded_indices: self.heatmap_layer.remove_excluded_index(idx) @@ -210,7 +217,9 @@ def toggle_exclusion(self): def apply_color_range(self, color_min, color_max): self._using_auto_range = False self.heatmap_layer.set_color_range(color_min, color_max) - self.status_label.setText(f"Color range set to [{color_min:.3f}, {color_max:.3f}]") + self.status_label.setText( + f"Color range set to [{color_min:.3f}, {color_max:.3f}]" + ) def reset_auto_color_range(self): self._using_auto_range = True @@ -242,8 +251,12 @@ def update_status(self): count = len(self.data_container.points) self.status_label.setText(f"Data Points: {count}") + if __name__ == "__main__": - from qtpy.QtGui import QPainter # Import here to avoid circular dependency issues if any + from qtpy.QtGui import ( + QPainter, + ) # Import here to avoid circular dependency issues if any + app = QApplication(sys.argv) window = HeatmapDemo() window.show() diff --git a/examples/heatmap_poc.py b/examples/heatmap_poc.py index 847bb65..52c84b4 100644 --- a/examples/heatmap_poc.py +++ b/examples/heatmap_poc.py @@ -1,75 +1,93 @@ -```python import sys import time import numpy as np from scipy.interpolate import RBFInterpolator, LinearNDInterpolator from scipy.spatial import cKDTree -from qtpy.QtWidgets import QApplication, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem -from qtpy.QtGui import QImage, QPixmap, QColor, QPainter -from qtpy.QtCore import Qt, QTimer, QPointF, QPoint +from qtpy.QtWidgets import ( + QApplication, + QWidget, + QVBoxLayout, + QPushButton, + QLabel, +) +from qtpy.QtGui import QImage, QColor, QPainter +from qtpy.QtCore import Qt, QTimer, QPoint + def generate_data(n_points=25, radius=150): """Generates random data points within a circle.""" # Random radius and angle r = radius * np.sqrt(np.random.rand(n_points)) theta = np.random.rand(n_points) * 2 * np.pi - + x = r * np.cos(theta) y = r * np.sin(theta) - + # Generate values with a gradient (Left -> Right) # Normalize x from [-radius, radius] to [0, 1] roughly normalized_x = (x + radius) / (2 * radius) - + # Base gradient value (0 to 80) base_values = normalized_x * 80 - + # Add some random noise (-10 to 10) noise = (np.random.rand(n_points) - 0.5) * 20 - + values = base_values + noise - + # Clip values to 0-100 range values = np.clip(values, 0, 100) - + return np.column_stack((x, y)), values + def get_boundary_points(data_points, radius=150): """Generates ghost points on the boundary using adaptive sampling.""" # 1. Calculate Average Nearest Neighbor Distance (ANND) tree = cKDTree(data_points) - distances, _ = tree.query(data_points, k=2) # k=2 because the first neighbor is the point itself + distances, _ = tree.query( + data_points, k=2 + ) # k=2 because the first neighbor is the point itself avg_dist = np.mean(distances[:, 1]) - + # 2. Determine target segment length (1.0 ~ 1.5x ANND) target_segment_length = avg_dist * 1.2 - + # 3. Calculate circumference and number of points needed circumference = 2 * np.pi * radius n_boundary_points = int(np.ceil(circumference / target_segment_length)) - + # 4. Generate points theta = np.linspace(0, 2 * np.pi, n_boundary_points, endpoint=False) x = radius * np.cos(theta) y = radius * np.sin(theta) - + return np.column_stack((x, y)) -def interpolate(data_points, data_values, boundary_points, radius=150, grid_size=300, method='rbf', neighbors=30): + +def interpolate( + data_points, + data_values, + boundary_points, + radius=150, + grid_size=300, + method="rbf", + neighbors=30, +): """Performs interpolation using data points + ghost points.""" start_time = time.perf_counter() - + # Calculate values for boundary points using IDW of 2 nearest data points tree = cKDTree(data_points) # Query 2 nearest neighbors dists, indices = tree.query(boundary_points, k=2) - + boundary_values = [] for i in range(len(boundary_points)): d1, d2 = dists[i] idx1, idx2 = indices[i] v1, v2 = data_values[idx1], data_values[idx2] - + # Avoid division by zero if d1 < 1e-9: val = v1 @@ -80,72 +98,75 @@ def interpolate(data_points, data_values, boundary_points, radius=150, grid_size w2 = 1.0 / d2 val = (w1 * v1 + w2 * v2) / (w1 + w2) boundary_values.append(val) - - boundary_values = np.array(boundary_values) + + boundary_values_arr = np.array(boundary_values) # Combine data and ghost points all_points = np.vstack((data_points, boundary_points)) - all_values = np.concatenate((data_values, boundary_values)) - + all_values = np.concatenate((data_values, boundary_values_arr)) + # Create grid x = np.linspace(-radius, radius, grid_size) y = np.linspace(-radius, radius, grid_size) X, Y = np.meshgrid(x, y) - + try: - if method == 'linear': + if method == "linear": # Linear Interpolation (Fast) interp = LinearNDInterpolator(all_points, all_values, fill_value=np.nan) Z = interp(X, Y) - else: # method == 'rbf' + else: # method == 'rbf' # RBF Interpolation (High Quality) # Flatten grid for RBFInterpolator grid_points = np.column_stack((X.ravel(), Y.ravel())) - interp = RBFInterpolator(all_points, all_values, neighbors=neighbors, kernel='thin_plate_spline') + interp = RBFInterpolator( + all_points, all_values, neighbors=neighbors, kernel="thin_plate_spline" + ) Z_flat = interp(grid_points) Z = Z_flat.reshape(grid_size, grid_size) - + except Exception as e: print(f"Interpolation failed: {e}") return np.zeros((grid_size, grid_size)), 0.0 - + # Mask out points outside the circle mask = (X**2 + Y**2) > radius**2 Z[mask] = np.nan - + end_time = time.perf_counter() elapsed_ms = (end_time - start_time) * 1000 return Z, elapsed_ms + class HeatmapWidget(QWidget): def __init__(self, radius=150): super().__init__() self.radius = radius - self.setFixedSize(radius * 2 + 20, radius * 2 + 60) # Extra space for button - + self.setFixedSize(radius * 2 + 20, radius * 2 + 60) # Extra space for button + self.Z = None - self.data_points = None - self.boundary_points = None - self.data_values = None - + self.data_points = [] + self.boundary_points = [] + self.data_values = [] + # Timer for High Quality update self.hq_timer = QTimer(self) self.hq_timer.setSingleShot(True) - self.hq_timer.setInterval(300) # 300ms + self.hq_timer.setInterval(300) # 300ms self.hq_timer.timeout.connect(self.perform_hq_update) - + # Layout layout = QVBoxLayout(self) layout.addStretch() - + self.btn_regen = QPushButton("Regenerate Data (Click me!)") self.btn_regen.clicked.connect(self.regenerate_data) layout.addWidget(self.btn_regen) - + self.lbl_status = QLabel("Ready") self.lbl_status.setStyleSheet("color: white;") layout.addWidget(self.lbl_status) - + # Initial data self.regenerate_data() @@ -154,69 +175,82 @@ def regenerate_data(self): n_points = 25 self.data_points, self.data_values = generate_data(n_points, self.radius) self.boundary_points = get_boundary_points(self.data_points, self.radius) - + # 2. Perform Fast Update (Linear) - self.perform_update(method='linear', neighbors=None, quality_label="Fast (Linear)") - + self.perform_update( + method="linear", neighbors=None, quality_label="Fast (Linear)" + ) + # 3. Schedule High Quality Update self.hq_timer.start() - + def perform_hq_update(self): # Perform High Quality Update (RBF, neighbors=30) - self.perform_update(method='rbf', neighbors=25, quality_label="High Quality (RBF k=30)") + self.perform_update( + method="rbf", neighbors=25, quality_label="High Quality (RBF k=30)" + ) def perform_update(self, method, neighbors, quality_label): self.lbl_status.setText(f"Rendering: {quality_label}...") - QApplication.processEvents() # Force UI update - - self.Z, elapsed_ms = interpolate(self.data_points, self.data_values, self.boundary_points, self.radius, method=method, neighbors=neighbors) - + QApplication.processEvents() # Force UI update + + self.Z, elapsed_ms = interpolate( + self.data_points, + self.data_values, + self.boundary_points, + self.radius, + method=method, + neighbors=neighbors, + ) + status_text = f"Mode: {quality_label} | Time: {elapsed_ms:.2f} ms" print(status_text) self.lbl_status.setText(status_text) - self.update() # Trigger paintEvent + self.update() # Trigger paintEvent def paintEvent(self, event): - if self.Z is None: return - + if self.Z is None: + return + painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - + # Convert Z to QImage height, width = self.Z.shape image = QImage(width, height, QImage.Format.Format_ARGB32) - + # Simple colormap (Blue -> Red) # Normalize Z to 0-255 Z_norm = np.nan_to_num(self.Z, nan=-1) max_val = np.nanmax(Z_norm) - if max_val == 0: max_val = 1 - + if max_val == 0: + max_val = 1 + for y in range(height): for x in range(width): val = Z_norm[y, x] - if val == -1: # Masked - color = QColor(0, 0, 0, 0) # Transparent + if val == -1: # Masked + color = QColor(0, 0, 0, 0) # Transparent else: ratio = val / max_val # Clamp ratio to 0.0 - 1.0 ratio = max(0.0, min(1.0, ratio)) - + # Simple heatmap: Blue(0) -> Red(1) r = int(255 * ratio) b = int(255 * (1 - ratio)) color = QColor(r, 0, b, 255) - + image.setPixelColor(x, y, color) - + # Draw image centered painter.drawImage(10, 10, image) - + # Draw circle border painter.setPen(Qt.GlobalColor.white) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawEllipse(10, 10, self.radius * 2, self.radius * 2) - + # Helper to transform coordinates def to_screen(x, y): sx = x + self.radius + 10 @@ -237,15 +271,17 @@ def to_screen(x, y): pt = to_screen(px, py) painter.drawEllipse(pt, 4, 4) + def main(): app = QApplication(sys.argv) - + widget = HeatmapWidget(radius=150) widget.setWindowTitle("FieldView Heatmap POC (Dynamic Quality)") widget.setStyleSheet("background-color: #222; color: white;") widget.show() - + sys.exit(app.exec()) + if __name__ == "__main__": main() diff --git a/examples/quick_start.py b/examples/quick_start.py index 686cb4b..14f1b06 100644 --- a/examples/quick_start.py +++ b/examples/quick_start.py @@ -1,16 +1,16 @@ import sys import os -import numpy as np -from qtpy.QtWidgets import QApplication, QGraphicsView, QGraphicsScene -from qtpy.QtGui import QPolygonF -from qtpy.QtCore import Qt, QPointF - -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from fieldview.core.data_container import DataContainer -from fieldview.layers.heatmap_layer import HeatmapLayer -from fieldview.layers.text_layer import ValueLayer -from fieldview.layers.svg_layer import SvgLayer -from fieldview.layers.pin_layer import PinLayer +from qtpy.QtWidgets import QApplication + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +from fieldview import FieldView +from examples.us_map_utils import ( + get_state_data, + get_us_boundary, + load_weather_data, + generate_us_dataset, +) + def run(): # Check if QApplication already exists (for testing/script usage) @@ -19,82 +19,47 @@ def run(): app = QApplication(sys.argv) # 1. Setup Data - data = DataContainer() - # Use fixed seed for consistent screenshot - np.random.seed(41) - - # Define rooms (x1, y1, x2, y2) - rooms = [ - (-450, -250, -150, 250), # Master Bed - (-150, -250, 150, 250), # Living - (150, 0, 300, 250), # Bed 2 - (300, -100, 450, 250) # Bed 4 - ] - - points = [] - values = [] - - for i in range(20): - room = rooms[np.random.randint(len(rooms))] - x1, y1, x2, y2 = room - margin = 20 - x = np.random.uniform(x1 + margin, x2 - margin) - y = np.random.uniform(y1 + margin, y2 - margin) - points.append([x, y]) - values.append(np.random.rand() * 100) - - data.set_data(np.array(points), np.array(values)) - - # 2. Create Scene & Layers - scene = QGraphicsScene() - + # Load US Map Data + svg_file = os.path.join(os.path.dirname(__file__), "us_map.svg") + state_paths, centroids = get_state_data(svg_file) + + # Load Weather Data + weather_data = load_weather_data() + + # Generate Dataset + points, values = generate_us_dataset(centroids, weather_data) + + # 2. Create FieldView + view = FieldView() + view.resize(1200, 800) + view.set_data(points, values) + + # 3. Add Layers + # SVG Layer (Background) - svg_path = os.path.join(os.path.dirname(__file__), 'floorplan_apartment.svg') - svg_layer = SvgLayer() - svg_layer.load_svg(svg_path) - svg_layer.setZValue(0) - scene.addItem(svg_layer) - + view.add_svg_layer(svg_file) + # Heatmap Layer (Data Visualization) - heatmap = HeatmapLayer(data) - heatmap.setOpacity(0.6) - heatmap.setZValue(1) - - # Define custom boundary polygon for the apartment - polygon = QPolygonF([ - QPointF(-450, -330), QPointF(-300, -330), QPointF(-300, -250), - QPointF(-150, -250), QPointF(-150, -300), QPointF(150, -300), - QPointF(150, -250), QPointF(450, -250), QPointF(450, 250), - QPointF(-450, 250) - ]) - heatmap.set_boundary_shape(polygon) - - scene.addItem(heatmap) - + heatmap = view.add_heatmap_layer(opacity=0.6) + + # Setup Heatmap Boundary + us_boundary = get_us_boundary(state_paths) + heatmap.set_boundary_shape(us_boundary) + heatmap.neighbors = 100 # Adjust for US map scale + # Pin Layer (Markers) - pin_layer = PinLayer(data) - pin_layer.setZValue(2) - scene.addItem(pin_layer) - + view.add_pin_layer() + # Value Layer (Labels) - values_layer = ValueLayer(data) - values_layer.update_layer() - values_layer.setZValue(3) - scene.addItem(values_layer) - - # 3. Setup View - view = QGraphicsView() - view.setScene(scene) - view.resize(600, 600) - - # Ensure the content is visible + view.add_value_layer() + + # 4. Show View view.show() - # Fit in view after showing - scene.setSceneRect(scene.itemsBoundingRect()) - view.fitInView(scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + view.fit_to_scene() return app, view + if __name__ == "__main__": app, view = run() sys.exit(app.exec()) diff --git a/examples/us_map_utils.py b/examples/us_map_utils.py index af3a707..b93dafa 100644 --- a/examples/us_map_utils.py +++ b/examples/us_map_utils.py @@ -5,33 +5,42 @@ import xml.etree.ElementTree as ET from qtpy.QtGui import QPainterPath, QPolygonF, QPainterPathStroker from qtpy.QtCore import Qt +from typing import Dict, Tuple, List -def parse_svg_path_to_qpainterpath(d_str): + +def parse_svg_path_to_qpainterpath(d_str: str) -> QPainterPath: """Parses a simple SVG path string (M, L, C, Z) into a QPainterPath.""" path = QPainterPath() - + # Regex to tokenize commands and numbers - tokens = re.findall(r'([a-zA-Z])|([-+]?\d*\.?\d+)', d_str) - + tokens = re.findall(r"([a-zA-Z])|([-+]?\d*\.?\d+)", d_str) + current_cmd = None - args = [] - + args: List[float] = [] + def flush_command(): nonlocal current_cmd, args - if current_cmd == 'M': + if current_cmd == "M": for i in range(0, len(args), 2): - path.moveTo(args[i], args[i+1]) - elif current_cmd == 'L': + path.moveTo(args[i], args[i + 1]) + elif current_cmd == "L": for i in range(0, len(args), 2): - path.lineTo(args[i], args[i+1]) - elif current_cmd == 'C': + path.lineTo(args[i], args[i + 1]) + elif current_cmd == "C": for i in range(0, len(args), 6): - path.cubicTo(args[i], args[i+1], args[i+2], args[i+3], args[i+4], args[i+5]) - elif current_cmd == 'Z': + path.cubicTo( + args[i], + args[i + 1], + args[i + 2], + args[i + 3], + args[i + 4], + args[i + 5], + ) + elif current_cmd == "Z": path.closeSubpath() - elif current_cmd == 'z': + elif current_cmd == "z": path.closeSubpath() - + args = [] for cmd, num in tokens: @@ -41,13 +50,14 @@ def flush_command(): current_cmd = cmd else: args.append(float(num)) - + if current_cmd: flush_command() - + return path -def get_state_data(svg_path): + +def get_state_data(svg_path: str) -> Tuple[Dict[str, QPainterPath], Dict[str, Tuple[float, float]]]: """Parses the US map SVG to get state paths and centroids.""" if not os.path.exists(svg_path): print(f"Error: {svg_path} not found") @@ -55,20 +65,20 @@ def get_state_data(svg_path): tree = ET.parse(svg_path) root = tree.getroot() - - state_paths = {} - centroids = {} - + + state_paths: Dict[str, QPainterPath] = {} + centroids: Dict[str, Tuple[float, float]] = {} + # Helper to process a path element def process_path(element, state_id): - d = element.get('d') + d = element.get("d") if d: qpath = parse_svg_path_to_qpainterpath(d) if state_id in state_paths: state_paths[state_id].addPath(qpath) else: state_paths[state_id] = qpath - + # Update centroid (simplified: average of bounding rect center) rect = qpath.boundingRect() centroids[state_id] = (rect.center().x(), rect.center().y()) @@ -76,104 +86,108 @@ def process_path(element, state_id): # Iterate over all paths and groups # Direct paths for path in root.findall(".//{http://www.w3.org/2000/svg}path"): - state_id = path.get('id') - if state_id and len(state_id) == 2: # Simple check for state codes + state_id = path.get("id") + if state_id and len(state_id) == 2: # Simple check for state codes process_path(path, state_id) - + # Groups (like MI, with multiple islands/peninsulas) for g in root.findall(".//{http://www.w3.org/2000/svg}g"): - state_id = g.get('id') + state_id = g.get("id") if state_id and len(state_id) == 2: for path in g.findall(".//{http://www.w3.org/2000/svg}path"): process_path(path, state_id) - + return state_paths, centroids -def get_us_boundary(state_paths): + +def get_us_boundary(state_paths: Dict[str, QPainterPath]) -> QPainterPath: """Creates a simplified, merged US boundary path from state paths.""" us_boundary = QPainterPath() for qpath in state_paths.values(): us_boundary.addPath(qpath) - + # 0. Merge all state paths into a single outline to remove internal borders/gaps stroker = QPainterPathStroker() - stroker.setWidth(1.0) # Small overlap to seal cracks + stroker.setWidth(1.0) # Small overlap to seal cracks stroker.setJoinStyle(Qt.RoundJoin) stroker.setCapStyle(Qt.RoundCap) - + stroke_path = stroker.createStroke(us_boundary) us_boundary = us_boundary.united(stroke_path) us_boundary = us_boundary.simplified() - + # 1. Convert to subpath polygons to handle islands sub_polygons = us_boundary.toSubpathPolygons() - + # 2. Filter out small islands (e.g., < 500 sq pixels) min_area = 500.0 kept_polygons = [] - + for poly in sub_polygons: brect = poly.boundingRect() - area = brect.width() * brect.height() - + area = brect.width() * brect.height() + if area > min_area: kept_polygons.append(poly) - + # 3. Simplify and Reconstruct final_boundary = QPainterPath() - + for poly in kept_polygons: simplified_points = [] if poly.count() > 0: last_pt = poly.at(0) simplified_points.append(last_pt) - min_dist_sq = 5.0 * 5.0 # 5 pixel minimum distance - + min_dist_sq = 5.0 * 5.0 # 5 pixel minimum distance + for i in range(1, poly.count()): pt = poly.at(i) dx = pt.x() - last_pt.x() dy = pt.y() - last_pt.y() - if (dx*dx + dy*dy) > min_dist_sq: + if (dx * dx + dy * dy) > min_dist_sq: simplified_points.append(pt) last_pt = pt - + # Close the loop if needed if len(simplified_points) > 2: - d_start = (simplified_points[-1].x() - simplified_points[0].x())**2 + \ - (simplified_points[-1].y() - simplified_points[0].y())**2 - if d_start > min_dist_sq: - simplified_points.append(simplified_points[0]) + d_start = ( + simplified_points[-1].x() - simplified_points[0].x() + ) ** 2 + (simplified_points[-1].y() - simplified_points[0].y()) ** 2 + if d_start > min_dist_sq: + simplified_points.append(simplified_points[0]) if len(simplified_points) >= 3: final_boundary.addPolygon(QPolygonF(simplified_points)) - + return final_boundary -def load_weather_data(): + +def load_weather_data() -> Dict[str, float]: """Loads weather data from JSON file.""" - weather_data_path = os.path.join(os.path.dirname(__file__), 'us_weather_data.json') + weather_data_path = os.path.join(os.path.dirname(__file__), "us_weather_data.json") real_weather_data = {} if os.path.exists(weather_data_path): try: - with open(weather_data_path, 'r') as f: + with open(weather_data_path, "r") as f: real_weather_data = json.load(f) print(f"Loaded real weather data for {len(real_weather_data)} states.") except Exception as e: print(f"Failed to load weather data: {e}") return real_weather_data -def generate_us_dataset(centroids, weather_data): + +def generate_us_dataset(centroids: Dict[str, Tuple[float, float]], weather_data: Dict[str, float]) -> Tuple[np.ndarray, np.ndarray]: """Generates points and values arrays for the US map.""" points = [] values = [] - + for state_id, centroid in centroids.items(): # Exclude Alaska and Hawaii from the main map - if state_id in ['AK', 'HI']: + if state_id in ["AK", "HI"]: continue points.append([centroid[0], centroid[1]]) - + # Use real data if available, otherwise random if state_id in weather_data: val = weather_data[state_id] @@ -181,7 +195,7 @@ def generate_us_dataset(centroids, weather_data): # Fallback for missing states normalized_y = centroid[1] / 600.0 val = 30.0 * normalized_y - 5.0 + np.random.uniform(-5, 5) - + values.append(val) - + return np.array(points), np.array(values) diff --git a/fieldview/__init__.py b/fieldview/__init__.py index e69de29..f64a931 100644 --- a/fieldview/__init__.py +++ b/fieldview/__init__.py @@ -0,0 +1,40 @@ +import os +import platform +import importlib.metadata + + +def _configure_threads(): + """ + Configures thread environment variables for specific conditions. + Sets single-threaded execution for BLAS libraries on ARM CPUs with Numpy < 2 + to avoid overhead on small matrices. + """ + try: + # Check for ARM CPU + machine = platform.machine().lower() + is_arm = "arm" in machine or "aarch64" in machine + + # Check Numpy version + numpy_version = importlib.metadata.version("numpy") + is_numpy_old = int(numpy_version.split(".")[0]) < 2 + + if is_arm and is_numpy_old: + os.environ["OMP_NUM_THREADS"] = "1" + os.environ["OPENBLAS_NUM_THREADS"] = "1" + os.environ["MKL_NUM_THREADS"] = "1" + os.environ["VECLIB_MAXIMUM_THREADS"] = "1" + os.environ["NUMEXPR_NUM_THREADS"] = "1" + + print( + "Due to the issue with numpy < 2, the performance may be affected on ARM devices." + ) + + except Exception: + # Fail silently if metadata check fails or other issues occur + pass + + +_configure_threads() + +from .ui.field_view import FieldView as FieldView # noqa: E402 + diff --git a/fieldview/core/data_container.py b/fieldview/core/data_container.py index 639e090..38cc502 100644 --- a/fieldview/core/data_container.py +++ b/fieldview/core/data_container.py @@ -1,11 +1,13 @@ import numpy as np from qtpy.QtCore import QObject, Signal + class DataContainer(QObject): """ Manages the core data (points and values) for the FieldView library. Emits signals when data changes. """ + dataChanged = Signal() def __init__(self): @@ -29,7 +31,7 @@ def labels(self): def set_data(self, points, values, labels=None): """ Sets the data points, values, and optional labels. - + Args: points (np.ndarray): Nx2 array of (x, y) coordinates. values (np.ndarray): N array of values. @@ -44,7 +46,7 @@ def set_data(self, points, values, labels=None): raise ValueError("Values must be a 1D array.") if len(points) != len(values): raise ValueError("Points and values must have the same length.") - + if labels is None: labels = [""] * len(points) elif len(labels) != len(points): @@ -61,10 +63,10 @@ def add_points(self, points, values, labels=None): """ points = np.array(points) values = np.array(values) - + if len(points) == 0: return - + if labels is None: labels = [""] * len(points) elif len(labels) != len(points): @@ -84,20 +86,20 @@ def update_point(self, index, value=None, point=None, label=None): """ if index < 0 or index >= len(self._points): raise IndexError("Point index out of range.") - + changed = False if value is not None: self._values[index] = value changed = True - + if point is not None: self._points[index] = point changed = True - + if label is not None: self._labels[index] = label changed = True - + if changed: self.dataChanged.emit() @@ -107,18 +109,18 @@ def remove_points(self, indices): """ if len(indices) == 0: return - + # Sort indices in descending order to avoid shifting issues if we were popping, # but numpy delete handles it. For list, we need to be careful. # It's better to create a mask or rebuild the list. - + mask = np.ones(len(self._points), dtype=bool) mask[indices] = False - + self._points = self._points[mask] self._values = self._values[mask] self._labels = [self._labels[i] for i in range(len(self._labels)) if mask[i]] - + self.dataChanged.emit() def clear(self): @@ -133,11 +135,11 @@ def clear(self): def get_closest_point(self, x, y, threshold=None): """ Finds the index of the closest point to (x, y). - + Args: x, y: Coordinates. threshold: Optional maximum distance. If closest point is further, returns None. - + Returns: int: Index of the closest point, or None. """ @@ -147,12 +149,12 @@ def get_closest_point(self, x, y, threshold=None): # Calculate squared distances diff = self._points - np.array([x, y]) dist_sq = np.sum(diff**2, axis=1) - + min_idx = np.argmin(dist_sq) min_dist_sq = dist_sq[min_idx] - + if threshold is not None: if min_dist_sq > threshold**2: return None - + return min_idx diff --git a/fieldview/layers/data_layer.py b/fieldview/layers/data_layer.py index 6bf935d..4905bda 100644 --- a/fieldview/layers/data_layer.py +++ b/fieldview/layers/data_layer.py @@ -3,16 +3,18 @@ from fieldview.layers.layer import Layer from fieldview.core.data_container import DataContainer + class DataLayer(Layer): """ Base class for layers that visualize data from a DataContainer. Handles data change signals and excluded indices. """ + def __init__(self, data_container: DataContainer, parent=None): super().__init__(parent) self._data_container = data_container - self._excluded_indices = set() - + self._excluded_indices: set[int] = set() + # Connect signal self._data_container.dataChanged.connect(self.on_data_changed) # Initial update @@ -59,22 +61,30 @@ def get_valid_indices(self): """ if not self._excluded_indices: return list(range(len(self._data_container.points))) - return [i for i in range(len(self._data_container.points)) if i not in self._excluded_indices] + return [ + i + for i in range(len(self._data_container.points)) + if i not in self._excluded_indices + ] def _update_bounding_rect(self): points = self._data_container.points if len(points) == 0: return - + min_x = np.min(points[:, 0]) max_x = np.max(points[:, 0]) min_y = np.min(points[:, 1]) max_y = np.max(points[:, 1]) - + # Add padding (e.g., for icons or text) padding = 50 - rect = QRectF(min_x - padding, min_y - padding, - max_x - min_x + 2*padding, max_y - min_y + 2*padding) + rect = QRectF( + min_x - padding, + min_y - padding, + max_x - min_x + 2 * padding, + max_y - min_y + 2 * padding, + ) self.set_bounding_rect(rect) def get_valid_data(self): @@ -90,8 +100,8 @@ def get_valid_data(self): # Create a mask for valid indices mask = self.get_valid_indices() - + # Filter labels (list) valid_labels = [labels[i] for i in mask] - + return points[mask], values[mask], valid_labels diff --git a/fieldview/layers/heatmap_layer.py b/fieldview/layers/heatmap_layer.py index 67af99c..79dc271 100644 --- a/fieldview/layers/heatmap_layer.py +++ b/fieldview/layers/heatmap_layer.py @@ -1,20 +1,30 @@ import numpy as np import time from fieldview.utils.grid_manager import InterpolatorCache -from qtpy.QtGui import QImage, QPainter, QColor, QPolygonF, QPainterPath -from qtpy.QtCore import Qt, QTimer, QRectF, QPointF, Signal -from typing import Optional, Tuple, List, Literal, Union -from fieldview.rendering.colormaps import ColormapName +from qtpy.QtGui import QImage, QPainter, QPolygonF, QPainterPath +from qtpy.QtCore import QTimer, QRectF, Signal +from typing import Optional, Literal, Union +from fieldview.rendering.colormaps import ColormapName, get_colormap +from fieldview.layers.data_layer import DataLayer -QualityLevel = Literal['very low', 'low', 'medium', 'high', 'very high', 'adaptive'] +QualityLevel = Literal["very low", "low", "medium", "high", "very high", "adaptive"] +KernelType = Literal[ + "thin_plate_spline", + "linear", + "cubic", + "quintic", + "gaussian", + "multiquadric", + "inverse_multiquadric", + "", +] -from fieldview.layers.data_layer import DataLayer -from fieldview.rendering.colormaps import get_colormap # Tiered grid sizes to prevent cache thrashing TIERS = [50, 100, 150, 200, 250, 300, 400, 500] + class HeatmapLayer(DataLayer): """ Layer for rendering a heatmap from data points. @@ -22,45 +32,46 @@ class HeatmapLayer(DataLayer): and dynamic quality adjustment. Supports arbitrary polygon boundaries. """ - renderingFinished = Signal(float, int) # Duration in ms, Grid Size + + renderingFinished = Signal(float, int) # Duration in ms, Grid Size def __init__(self, data_container, parent=None): super().__init__(data_container, parent) - + # Configuration - self._boundary_shape = QPolygonF() # Default empty - self._auto_boundary = True # Default to auto-fit data - self._preview_grid_size = 50 # Fast update size - self._idle_grid_size = 150 # HQ update size + self._boundary_shape = QPolygonF() # Default empty + self._auto_boundary = True # Default to auto-fit data + self._preview_grid_size = 50 # Fast update size + self._idle_grid_size = 150 # HQ update size self._is_adaptive = False self._neighbors = 30 - self._target_render_time = 100 # Default High (100ms) - self._hq_delay = 300 # ms + self._target_render_time = 100.0 # Default High (100ms) + self._hq_delay = 300 # ms self._colormap = get_colormap("viridis") - self._kernel = 'thin_plate_spline' + self._kernel: KernelType = "thin_plate_spline" self._color_min = None self._color_max = None - + # Initialize with empty shape, will be set by on_data_changed if data exists # or user can set it manually. - + # State self._cached_image = None self._heatmap_rect = QRectF() self._is_hq_pending = False - + # Timer for High Quality update self._hq_timer = QTimer() self._hq_timer.setSingleShot(True) self._hq_timer.setInterval(self._hq_delay) self._hq_timer.timeout.connect(self._perform_hq_update) - + # Interpolators self._interpolator_cache = InterpolatorCache() - + # Cache Keys self._last_grid_shape = None - + # Initial update self.on_data_changed() @@ -85,7 +96,9 @@ def color_max(self): def color_range(self): return self._color_min, self._color_max - def set_color_range(self, color_min: Optional[float] = None, color_max: Optional[float] = None): + def set_color_range( + self, color_min: Optional[float] = None, color_max: Optional[float] = None + ): """ Sets explicit normalization bounds for the heatmap colors. @@ -126,58 +139,75 @@ def neighbors(self, value): self.on_data_changed() @property - def kernel(self): + def kernel(self) -> KernelType: return self._kernel @kernel.setter - def kernel(self, value: str): - if value not in ["thin_plate_spline", "linear", "cubic", "quintic", "gaussian", "multiquadric", "inverse_multiquadric"]: + def kernel(self, value: KernelType): + if value == "": + value = "thin_plate_spline" + + valid_kernels = [ + "thin_plate_spline", + "linear", + "cubic", + "quintic", + "gaussian", + "multiquadric", + "inverse_multiquadric", + ] + if value not in valid_kernels: raise ValueError(f"Invalid kernel: {value}") self._kernel = value self.on_data_changed() @property def quality(self) -> QualityLevel: - if self._is_adaptive: return 'adaptive' - if self._idle_grid_size <= 50: return 'very low' - if self._idle_grid_size <= 100: return 'low' - if self._idle_grid_size <= 150: return 'medium' - if self._idle_grid_size <= 300: return 'high' - return 'very high' + if self._is_adaptive: + return "adaptive" + if self._idle_grid_size <= 50: + return "very low" + if self._idle_grid_size <= 100: + return "low" + if self._idle_grid_size <= 150: + return "medium" + if self._idle_grid_size <= 300: + return "high" + return "very high" @quality.setter def quality(self, value: Union[QualityLevel, str, int]): if isinstance(value, str): value = value.lower() - - if value == 'very low': + + if value == "very low": self._is_adaptive = False self._preview_grid_size = 30 self._idle_grid_size = 50 - elif value in ['low', 0]: + elif value in ["low", 0]: self._is_adaptive = False self._preview_grid_size = 50 self._idle_grid_size = 100 - elif value in ['medium', 1]: + elif value in ["medium", 1]: self._is_adaptive = False self._preview_grid_size = 75 self._idle_grid_size = 150 - elif value in ['high', 2]: + elif value in ["high", 2]: self._is_adaptive = False self._preview_grid_size = 100 self._idle_grid_size = 300 - elif value == 'very high': + elif value == "very high": self._is_adaptive = False self._preview_grid_size = 150 self._idle_grid_size = 500 - elif value == 'adaptive': + elif value == "adaptive": self._is_adaptive = True # Start with Medium self._preview_grid_size = 75 self._idle_grid_size = 150 else: print(f"Warning: Invalid quality '{value}'. Ignoring.") - + self.on_data_changed() def set_boundary_shape(self, shape: Union[QPolygonF, QRectF, QPainterPath]): @@ -187,7 +217,7 @@ def set_boundary_shape(self, shape: Union[QPolygonF, QRectF, QPainterPath]): Disables auto-boundary mode. """ self._auto_boundary = False - + if isinstance(shape, QRectF): self._boundary_shape = QPolygonF(shape) elif isinstance(shape, QPainterPath): @@ -206,7 +236,7 @@ def on_data_changed(self): Override to trigger fast update and schedule HQ update. """ # Check if initialized - if not hasattr(self, '_idle_grid_size'): + if not hasattr(self, "_idle_grid_size"): return # Auto-boundary logic @@ -217,15 +247,19 @@ def on_data_changed(self): max_x = np.max(points[:, 0]) min_y = np.min(points[:, 1]) max_y = np.max(points[:, 1]) - + # Add some padding (e.g. 10%) width = max_x - min_x height = max_y - min_y padding_x = max(10, width * 0.1) padding_y = max(10, height * 0.1) - - rect = QRectF(min_x - padding_x, min_y - padding_y, - width + 2*padding_x, height + 2*padding_y) + + rect = QRectF( + min_x - padding_x, + min_y - padding_y, + width + 2 * padding_x, + height + 2 * padding_y, + ) self._boundary_shape = QPolygonF(rect) self.set_bounding_rect(rect) else: @@ -233,27 +267,33 @@ def on_data_changed(self): self.set_bounding_rect(QRectF()) # 1. Cancel any pending HQ update - if hasattr(self, '_hq_timer'): + if hasattr(self, "_hq_timer"): self._hq_timer.stop() - + # 2. Perform Fast Update (Low-Res RBF) # Use 1/10th of the grid size for speed (e.g., 30x30 instead of 300x300) # 2. Perform Fast Update (Preview Quality) - self._generate_heatmap(method='rbf', neighbors=self._neighbors, grid_size=self._preview_grid_size) + self._generate_heatmap( + method="rbf", neighbors=self._neighbors, grid_size=self._preview_grid_size + ) self.update() - + # 3. Schedule High Quality Update - if hasattr(self, '_hq_timer'): + if hasattr(self, "_hq_timer"): self._hq_timer.start() def _perform_hq_update(self): """ Slot for HQ timer timeout. Performs RBF interpolation. """ - self._generate_heatmap(method='rbf', neighbors=self._neighbors, grid_size=self._idle_grid_size) + self._generate_heatmap( + method="rbf", neighbors=self._neighbors, grid_size=self._idle_grid_size + ) self.update() - def _generate_heatmap(self, method: str = 'rbf', neighbors: int = 30, grid_size: Optional[int] = None): + def _generate_heatmap( + self, method: str = "rbf", neighbors: int = 30, grid_size: Optional[int] = None + ): """ Generates the heatmap image using cached interpolators. """ @@ -273,7 +313,7 @@ def _generate_heatmap(self, method: str = 'rbf', neighbors: int = 30, grid_size: rbf_interp, boundary_gen = self._interpolator_cache.get_interpolator( grid_size, points, self._boundary_shape, neighbors, kernel=self._kernel ) - + # We need to reconstruct the expanded grid size for reshaping # This logic must match what's inside InterpolatorCache # Ideally InterpolatorCache should return this info, but for now we re-calculate @@ -281,7 +321,7 @@ def _generate_heatmap(self, method: str = 'rbf', neighbors: int = 30, grid_size: # Let's re-calculate for safety as it's cheap. expanded_grid_size = grid_size + 2 self._last_grid_shape = (expanded_grid_size, expanded_grid_size) - + # Also need to update self._heatmap_rect for drawing rect = self._boundary_shape.boundingRect() dx = rect.width() / grid_size @@ -289,23 +329,23 @@ def _generate_heatmap(self, method: str = 'rbf', neighbors: int = 30, grid_size: self._heatmap_rect = rect.adjusted(-dx, -dy, dx, dy) # --- Fast Update Phase (Values Only) --- - + # 1. Get Boundary Values boundary_values = boundary_gen.transform(values) - + # 2. Combine Values if len(boundary_values) > 0: all_values = np.concatenate((values, boundary_values)) else: all_values = values - + # 3. Predict Z_flat = rbf_interp.predict(all_values) - + if Z_flat is None: self._cached_image = None return - + Z = Z_flat.reshape(self._last_grid_shape) # 4. Convert to QImage @@ -317,23 +357,31 @@ def _generate_heatmap(self, method: str = 'rbf', neighbors: int = 30, grid_size: self.renderingFinished.emit(duration_ms, grid_size) # 5. Adaptive Quality Adjustment - if self._is_adaptive and grid_size == self._idle_grid_size and self._target_render_time > 0 and duration_ms > 0: + if ( + self._is_adaptive + and grid_size == self._idle_grid_size + and self._target_render_time > 0 + and duration_ms > 0 + ): # Calculate ideal grid size based on target time # time ~ grid^2 => grid ~ sqrt(time) ratio = self._target_render_time / duration_ms ideal_grid = int(self._idle_grid_size * np.sqrt(ratio)) - + # Find closest tier # We prefer a tier that is slightly lower or equal to ideal to be safe on performance # or just closest. Let's pick closest tier <= ideal_grid to ensure we meet target time. - + # Find closest tier index current_tier_idx = 0 try: current_tier_idx = TIERS.index(self._idle_grid_size) except ValueError: # If current size is not in tiers, find closest - current_tier_idx = min(range(len(TIERS)), key=lambda i: abs(TIERS[i] - self._idle_grid_size)) + current_tier_idx = min( + range(len(TIERS)), + key=lambda i: abs(TIERS[i] - self._idle_grid_size), + ) target_tier_idx = 0 for i, tier in enumerate(TIERS): @@ -341,46 +389,54 @@ def _generate_heatmap(self, method: str = 'rbf', neighbors: int = 30, grid_size: target_tier_idx = i else: break - + # Constrain to max 1 step change if target_tier_idx > current_tier_idx: target_tier_idx = current_tier_idx + 1 elif target_tier_idx < current_tier_idx: target_tier_idx = current_tier_idx - 1 - + # Clamp index target_tier_idx = max(0, min(len(TIERS) - 1, target_tier_idx)) - + new_tier = TIERS[target_tier_idx] - + # Only update if changed if new_tier != self._idle_grid_size: self._idle_grid_size = new_tier - + # Update preview size (approx 1/3 of idle, min 30) self._preview_grid_size = max(30, int(self._idle_grid_size / 3)) - - print(f"[Adaptive] Render: {duration_ms:.1f}ms, Target: {self._target_render_time}ms -> New Idle: {self._idle_grid_size}, Preview: {self._preview_grid_size}") - + print( + f"[Adaptive] Render: {duration_ms:.1f}ms, Target: {self._target_render_time}ms -> New Idle: {self._idle_grid_size}, Preview: {self._preview_grid_size}" + ) def _array_to_qimage(self, Z: np.ndarray) -> QImage: """ Converts 2D array Z to QImage using vectorized operations. """ height, width = Z.shape - + # 1. Normalize Z to 0-255 indices Z_norm = np.nan_to_num(Z, nan=-1) # Mask for transparent pixels - mask = (Z_norm == -1) + mask = Z_norm == -1 valid_values = Z_norm[~mask] # Determine normalization bounds if valid_values.size > 0: - min_val = self._color_min if self._color_min is not None else float(np.nanmin(valid_values)) - max_val = self._color_max if self._color_max is not None else float(np.nanmax(valid_values)) + min_val = ( + self._color_min + if self._color_min is not None + else float(np.nanmin(valid_values)) + ) + max_val = ( + self._color_max + if self._color_max is not None + else float(np.nanmax(valid_values)) + ) else: min_val = self._color_min if self._color_min is not None else 0.0 max_val = self._color_max if self._color_max is not None else 1.0 @@ -393,31 +449,33 @@ def _array_to_qimage(self, Z: np.ndarray) -> QImage: # Map to 0-255 indices indices = (normalized * 255).astype(np.uint8) - + # 2. Get LUT lut = self._colormap.get_lut(256) - + # 3. Map indices to ARGB values # lut is (256,) uint32 # buffer will be (height, width) uint32 buffer = lut[indices] - + # 4. Apply Transparency # Set alpha to 0 for masked pixels # 0x00FFFFFF mask clears Alpha channel, but we want 0x00000000 for full transparency buffer[mask] = 0x00000000 - + # 5. Create QImage from buffer # We need to ensure the buffer is contiguous and kept alive # QImage(uchar *data, int width, int height, Format format) # We can use memoryview - + # Make sure buffer is C-contiguous - if not buffer.flags['C_CONTIGUOUS']: + if not buffer.flags["C_CONTIGUOUS"]: buffer = np.ascontiguousarray(buffer) - - image = QImage(buffer.data, width, height, width * 4, QImage.Format.Format_ARGB32) - + + image = QImage( + buffer.data, width, height, width * 4, QImage.Format.Format_ARGB32 + ) + # We must copy the image data because QImage doesn't own the buffer # and 'buffer' might be garbage collected after this function returns. return image.copy() @@ -428,9 +486,8 @@ def paint(self, painter, option, widget): path = QPainterPath() path.addPolygon(self._boundary_shape) painter.setClipPath(path) - + # Draw the expanded heatmap # Enable smooth transformation for upscaling low-res images painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True) painter.drawImage(self._heatmap_rect, self._cached_image) - diff --git a/fieldview/layers/layer.py b/fieldview/layers/layer.py index ec8189e..b610cc6 100644 --- a/fieldview/layers/layer.py +++ b/fieldview/layers/layer.py @@ -1,14 +1,18 @@ from qtpy.QtWidgets import QGraphicsObject from qtpy.QtCore import QRectF + class Layer(QGraphicsObject): """ Abstract base class for all visual layers in FieldView. Inherits from QGraphicsObject to support signals and integration with QGraphicsScene. """ + def __init__(self, parent=None): super().__init__(parent) - self._bounding_rect = QRectF(0, 0, 300, 300) # Default size, should be updated by subclasses + self._bounding_rect = QRectF( + 0, 0, 300, 300 + ) # Default size, should be updated by subclasses def boundingRect(self): return self._bounding_rect diff --git a/fieldview/layers/pin_layer.py b/fieldview/layers/pin_layer.py index f7b8a67..1aac37c 100644 --- a/fieldview/layers/pin_layer.py +++ b/fieldview/layers/pin_layer.py @@ -1,15 +1,17 @@ -from qtpy.QtGui import QPixmap, QPainter, QColor, QPen, QBrush +from qtpy.QtGui import QPixmap from qtpy.QtCore import QRectF, QPointF, Qt from fieldview.layers.data_layer import DataLayer + class PinLayer(DataLayer): """ Layer for rendering icons (pins) at data points. """ + def __init__(self, data_container, parent=None): super().__init__(data_container, parent) self._icon = None - self._icon_size = 24 # Default size + self._icon_size = 24 # Default size @property def icon(self): @@ -21,17 +23,17 @@ def set_icon(self, icon: QPixmap): def paint(self, painter, option, widget): points, _, _ = self.get_valid_data() - + if self._icon: w = self._icon.width() h = self._icon.height() for x, y in points: - target_rect = QRectF(x - w/2, y - h/2, w, h) + target_rect = QRectF(x - w / 2, y - h / 2, w, h) painter.drawPixmap(target_rect.toRect(), self._icon) else: # Default: Black dot painter.setBrush(Qt.GlobalColor.black) painter.setPen(Qt.NoPen) - r = 3 # Radius + r = 3 # Radius for x, y in points: painter.drawEllipse(QPointF(x, y), r, r) diff --git a/fieldview/layers/svg_layer.py b/fieldview/layers/svg_layer.py index 2028c57..20dc7d8 100644 --- a/fieldview/layers/svg_layer.py +++ b/fieldview/layers/svg_layer.py @@ -1,12 +1,13 @@ -from fieldview.utils.qt_compat import QGraphicsSvgItem from qtpy.QtSvg import QSvgRenderer from qtpy.QtCore import QRectF, QPointF from fieldview.layers.layer import Layer + class SvgLayer(Layer): """ Layer for rendering an SVG file. """ + def __init__(self, parent=None): super().__init__(parent) self._renderer = QSvgRenderer() diff --git a/fieldview/layers/text_layer.py b/fieldview/layers/text_layer.py index 67ceef8..2cea810 100644 --- a/fieldview/layers/text_layer.py +++ b/fieldview/layers/text_layer.py @@ -3,21 +3,29 @@ from qtpy.QtWidgets import QStyleOptionGraphicsItem, QWidget import os import numpy as np -from typing import Optional, List, Dict, Set, Union, Tuple +from typing import Optional, List, Dict, Set, Union from fieldview.layers.data_layer import DataLayer + class TextLayer(DataLayer): """ Abstract base class for text-based layers. Handles font, opacity, and highlighting. """ + def __init__(self, data_container, parent: Optional[DataLayer] = None): super().__init__(data_container, parent) - + # Load embedded font - font_path = os.path.join(os.path.dirname(__file__), '..', 'resources', 'fonts', 'JetBrainsMono-Regular.ttf') + font_path = os.path.join( + os.path.dirname(__file__), + "..", + "resources", + "fonts", + "JetBrainsMono-Regular.ttf", + ) font_path = os.path.abspath(font_path) - + font_id = QFontDatabase.addApplicationFont(font_path) if font_id != -1: font_family = QFontDatabase.applicationFontFamilies(font_id)[0] @@ -25,19 +33,21 @@ def __init__(self, data_container, parent: Optional[DataLayer] = None): else: # Fallback self._font = QFont("JetBrains Mono") - if not QFontDatabase.families(QFontDatabase.WritingSystem.Latin).count("JetBrains Mono"): - self._font.setStyleHint(QFont.StyleHint.Monospace) - + if not QFontDatabase.families(QFontDatabase.WritingSystem.Latin).count( + "JetBrains Mono" + ): + self._font.setStyleHint(QFont.StyleHint.Monospace) + self._font.setPixelSize(12) - + self._text_color = QColor(Qt.GlobalColor.white) - self._bg_color = QColor(0, 0, 0, 180) # Semi-transparent black + self._bg_color = QColor(0, 0, 0, 180) # Semi-transparent black self._highlight_color = QColor(Qt.GlobalColor.yellow) - self._highlighted_indices = set() - + self._highlighted_indices: set[int] = set() + self._collision_avoidance_enabled = True - self._collision_offset_factor = 0.6 # Default 60% - self._cached_layout = None + self._collision_offset_factor = 0.6 # Default 60% + self._cached_layout: Optional[Dict[int, QRectF]] = None @property def font(self) -> QFont: @@ -92,7 +102,12 @@ def update_layer(self): self._cached_layout = None super().update_layer() - def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Optional[QWidget] = None): + def paint( + self, + painter: QPainter, + option: QStyleOptionGraphicsItem, + widget: Optional[QWidget] = None, + ): points, values, labels = self.get_valid_data() valid_indices = self.get_valid_indices() @@ -100,40 +115,59 @@ def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: Opt metrics = painter.fontMetrics() if self._cached_layout is None: - self._cached_layout = self._calculate_layout(points, values, labels, metrics, valid_indices) + self._cached_layout = self._calculate_layout( + points, values, labels, metrics, valid_indices + ) - value_lookup = dict(zip(valid_indices, values)) - label_lookup = dict(zip(valid_indices, labels)) + value_lookup = dict(zip(valid_indices, values)) if values is not None else {} + label_lookup = dict(zip(valid_indices, labels)) if labels is not None else {} for i, rect in self._cached_layout.items(): value = value_lookup.get(i) label = label_lookup.get(i, "") text = self._get_text(i, value, label) - if not text: continue - + if not text: + continue + # Determine background color - bg_color = self._highlight_color if i in self._highlighted_indices else self._bg_color - + bg_color = ( + self._highlight_color + if i in self._highlighted_indices + else self._bg_color + ) + # Draw background painter.fillRect(rect, bg_color) - + # Draw text - painter.setPen(self._text_color if i not in self._highlighted_indices else Qt.GlobalColor.black) + painter.setPen( + self._text_color + if i not in self._highlighted_indices + else Qt.GlobalColor.black + ) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) - def _calculate_layout(self, points: np.ndarray, values: np.ndarray, labels: List[str], metrics: QFontMetrics, indices: List[int]) -> Dict[int, QRectF]: - layout = {} # index -> QRectF - placed_rects = [] + def _calculate_layout( + self, + points: np.ndarray, + values: np.ndarray, + labels: List[str], + metrics: QFontMetrics, + indices: List[int], + ) -> Dict[int, QRectF]: + layout = {} # index -> QRectF + placed_rects: List[QRectF] = [] for i, (x, y), value, label in zip(indices, points, values, labels): text = self._get_text(i, value, label) - if not text: continue - + if not text: + continue + rect = metrics.boundingRect(text) # Add padding rect.adjust(-2, -2, 2, 2) w, h = rect.width(), rect.height() - + # Calculate offset distance based on factor # Factor 0.6 means move 60% of dimension. # Center is 0 offset. @@ -141,71 +175,73 @@ def _calculate_layout(self, points: np.ndarray, values: np.ndarray, labels: List # Bottom: y + h * factor # Left: x - w * factor # Right: x + w * factor - + factor = self._collision_offset_factor - + candidates = [ - QPointF(x, y), # Center - QPointF(x, y - h * factor), # Top - QPointF(x, y + h * factor), # Bottom - QPointF(x - w * factor, y), # Left - QPointF(x + w * factor, y) # Right + QPointF(x, y), # Center + QPointF(x, y - h * factor), # Top + QPointF(x, y + h * factor), # Bottom + QPointF(x - w * factor, y), # Left + QPointF(x + w * factor, y), # Right ] - + chosen_rect = None - + if self._collision_avoidance_enabled: best_rect = None - min_cost = float('inf') - + min_cost = float("inf") + for center in candidates: candidate_rect = QRectF(rect) candidate_rect.moveCenter(center) - + # Calculate cost (total intersection area) cost = 0.0 for placed in placed_rects: intersection = candidate_rect.intersected(placed) if not intersection.isEmpty(): cost += intersection.width() * intersection.height() - + # If perfect placement found, take it immediately if cost == 0: best_rect = candidate_rect break - + # Otherwise keep track of the best so far if cost < min_cost: min_cost = cost best_rect = candidate_rect - + chosen_rect = best_rect else: # Just Center chosen_rect = QRectF(rect) chosen_rect.moveCenter(QPointF(x, y)) - + layout[i] = chosen_rect placed_rects.append(chosen_rect) - + return layout - def _get_text(self, index: int, value: float, label: str) -> str: + def _get_text(self, index: int, value: Optional[float], label: str) -> str: """ Abstract method to get text for a point. """ raise NotImplementedError + class ValueLayer(TextLayer): """ Renders numerical values. """ + def __init__(self, data_container, parent: Optional[DataLayer] = None): super().__init__(data_container, parent) self._decimal_places = 2 self._suffix = "" - self._postfix = "" # Same as suffix? antigravity.md says both. Let's assume prefix/suffix or just suffix. - # antigravity.md says "Can add suffix, postfix". Maybe prefix/suffix? + self._postfix = "" # Same as suffix? antigravity.md says both. Let's assume prefix/suffix or just suffix. + # antigravity.md says "Can add suffix, postfix". Maybe prefix/suffix? # Let's implement prefix and suffix. self._prefix = "" @@ -226,7 +262,7 @@ def suffix(self) -> str: def suffix(self, value: str): self._suffix = value self.update_layer() - + @property def prefix(self) -> str: return self._prefix @@ -236,12 +272,16 @@ def prefix(self, value: str): self._prefix = value self.update_layer() - def _get_text(self, index: int, value: float, label: str) -> str: + def _get_text(self, index: int, value: Optional[float], label: str) -> str: + if value is None: + return "" return f"{self._prefix}{value:.{self._decimal_places}f}{self._suffix}" + class LabelLayer(TextLayer): """ Renders text labels. """ - def _get_text(self, index: int, value: float, label: str) -> str: + + def _get_text(self, index: int, value: Optional[float], label: str) -> str: return str(label) diff --git a/fieldview/rendering/colormaps.py b/fieldview/rendering/colormaps.py index a6fb279..86b443c 100644 --- a/fieldview/rendering/colormaps.py +++ b/fieldview/rendering/colormaps.py @@ -1,45 +1,48 @@ from qtpy.QtGui import QColor import numpy as np -from typing import List, Tuple, Dict, Literal, Union +from typing import List, Tuple, Dict, Literal, Union, Optional ColormapName = Literal["viridis", "plasma", "inferno", "magma", "coolwarm", "jet"] + class Colormap: """ Simple colormap implementation using linear interpolation of stops. """ + def __init__(self, name: str, stops: List[Tuple[float, str]]): """ Args: name (str): Name of the colormap. - stops (list): List of (position, color_hex) tuples. + stops (list): List of (position, color_hex) tuples. Position is 0.0 to 1.0. """ self.name: str = name self.stops: List[Tuple[float, str]] = sorted(stops, key=lambda x: x[0]) - + self._lut: Optional[np.ndarray] = None + def map(self, value: float) -> QColor: """ Maps a value (0.0 to 1.0) to a QColor. """ value = max(0.0, min(1.0, value)) - + # Find segment for i in range(len(self.stops) - 1): p1, c1_hex = self.stops[i] - p2, c2_hex = self.stops[i+1] - + p2, c2_hex = self.stops[i + 1] + if p1 <= value <= p2: t = (value - p1) / (p2 - p1) if p2 > p1 else 0 c1 = QColor(c1_hex) c2 = QColor(c2_hex) - - r = int(c1.red() * (1-t) + c2.red() * t) - g = int(c1.green() * (1-t) + c2.green() * t) - b = int(c1.blue() * (1-t) + c2.blue() * t) - + + r = int(c1.red() * (1 - t) + c2.red() * t) + g = int(c1.green() * (1 - t) + c2.green() * t) + b = int(c1.blue() * (1 - t) + c2.blue() * t) + return QColor(r, g, b) - + # Fallback (should cover 0.0 to 1.0 if stops are correct) return QColor(self.stops[-1][1]) @@ -47,45 +50,80 @@ def get_lut(self, size: int = 256) -> np.ndarray: """ Returns a numpy array of shape (size,) containing uint32 ARGB values. """ - if hasattr(self, '_lut') and len(self._lut) == size: + if self._lut is not None and len(self._lut) == size: return self._lut - + lut = np.zeros(size, dtype=np.uint32) - + for i in range(size): val = i / (size - 1) color = self.map(val) # ARGB32 format: 0xAARRGGBB # QColor.rgba() returns 0xAARRGGBB (unsigned int) lut[i] = color.rgba() - + self._lut = lut return lut + # Standard Colormaps (Approximations) -VIRIDIS = Colormap("viridis", [ - (0.0, "#440154"), (0.25, "#3b528b"), (0.5, "#21918c"), (0.75, "#5ec962"), (1.0, "#fde725") -]) +VIRIDIS = Colormap( + "viridis", + [ + (0.0, "#440154"), + (0.25, "#3b528b"), + (0.5, "#21918c"), + (0.75, "#5ec962"), + (1.0, "#fde725"), + ], +) -PLASMA = Colormap("plasma", [ - (0.0, "#0d0887"), (0.25, "#7e03a8"), (0.5, "#cc4778"), (0.75, "#f89540"), (1.0, "#f0f921") -]) +PLASMA = Colormap( + "plasma", + [ + (0.0, "#0d0887"), + (0.25, "#7e03a8"), + (0.5, "#cc4778"), + (0.75, "#f89540"), + (1.0, "#f0f921"), + ], +) -INFERNO = Colormap("inferno", [ - (0.0, "#000004"), (0.25, "#57106e"), (0.5, "#bb3754"), (0.75, "#f98e09"), (1.0, "#fcffa4") -]) +INFERNO = Colormap( + "inferno", + [ + (0.0, "#000004"), + (0.25, "#57106e"), + (0.5, "#bb3754"), + (0.75, "#f98e09"), + (1.0, "#fcffa4"), + ], +) -MAGMA = Colormap("magma", [ - (0.0, "#000004"), (0.25, "#51127c"), (0.5, "#b73779"), (0.75, "#fc8961"), (1.0, "#fcfdbf") -]) +MAGMA = Colormap( + "magma", + [ + (0.0, "#000004"), + (0.25, "#51127c"), + (0.5, "#b73779"), + (0.75, "#fc8961"), + (1.0, "#fcfdbf"), + ], +) -COOLWARM = Colormap("coolwarm", [ - (0.0, "#3b4cc0"), (0.5, "#dddddd"), (1.0, "#b40426") -]) +COOLWARM = Colormap("coolwarm", [(0.0, "#3b4cc0"), (0.5, "#dddddd"), (1.0, "#b40426")]) -JET = Colormap("jet", [ - (0.0, "#000080"), (0.125, "#0000ff"), (0.375, "#00ffff"), (0.625, "#ffff00"), (0.875, "#ff0000"), (1.0, "#800000") -]) +JET = Colormap( + "jet", + [ + (0.0, "#000080"), + (0.125, "#0000ff"), + (0.375, "#00ffff"), + (0.625, "#ffff00"), + (0.875, "#ff0000"), + (1.0, "#800000"), + ], +) COLORMAPS: Dict[str, Colormap] = { "viridis": VIRIDIS, @@ -93,8 +131,9 @@ def get_lut(self, size: int = 256) -> np.ndarray: "inferno": INFERNO, "magma": MAGMA, "coolwarm": COOLWARM, - "jet": JET + "jet": JET, } + def get_colormap(name: Union[ColormapName, str]) -> Colormap: return COLORMAPS.get(name.lower(), VIRIDIS) diff --git a/fieldview/ui/color_range_widget.py b/fieldview/ui/color_range_widget.py index 7fada0a..c638a51 100644 --- a/fieldview/ui/color_range_widget.py +++ b/fieldview/ui/color_range_widget.py @@ -1,7 +1,6 @@ -from qtpy.QtCore import Qt, Signal -from qtpy.QtGui import QPainter, QLinearGradient, QColor, QBrush, QPen -from qtpy.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QDoubleSpinBox, QSlider, QComboBox) +from qtpy.QtCore import Signal +from qtpy.QtGui import QPainter, QLinearGradient, QColor +from qtpy.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox from fieldview.rendering.colormaps import get_colormap diff --git a/fieldview/ui/data_table.py b/fieldview/ui/data_table.py index 76af705..4eb37fd 100644 --- a/fieldview/ui/data_table.py +++ b/fieldview/ui/data_table.py @@ -1,21 +1,22 @@ from qtpy.QtWidgets import QTableView, QHeaderView, QMenu, QAction -from qtpy.QtCore import Qt, QAbstractTableModel, QModelIndex, Signal -from qtpy.QtGui import QCursor -from typing import List, Set, Optional, Any +from qtpy.QtCore import Qt, QAbstractTableModel, QModelIndex +from typing import List, Set, Any from fieldview.core.data_container import DataContainer + class PointTableModel(QAbstractTableModel): """ Table model for DataContainer points. Supports editing and column visibility toggling. """ + def __init__(self, data_container: DataContainer): super().__init__() self._data_container = data_container self._data_container.dataChanged.connect(self._handle_data_changed) self._highlighted_indices: Set[int] = set() self._excluded_indices: Set[int] = set() - + self._headers = ["Highlight", "Exclude", "X", "Y", "Value", "Label"] self._visible_columns = [True] * len(self._headers) @@ -26,38 +27,58 @@ def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: return len(self._headers) def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not index.isValid(): return None + if not index.isValid(): + return None row, col = index.row(), index.column() - + if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.EditRole: - if col == 2: return f"{self._data_container.points[row][0]:.2f}" - if col == 3: return f"{self._data_container.points[row][1]:.2f}" - if col == 4: return f"{self._data_container.values[row]:.2f}" - if col == 5: return self._data_container.labels[row] + if col == 2: + return f"{self._data_container.points[row][0]:.2f}" + if col == 3: + return f"{self._data_container.points[row][1]:.2f}" + if col == 4: + return f"{self._data_container.values[row]:.2f}" + if col == 5: + return self._data_container.labels[row] if role == Qt.ItemDataRole.CheckStateRole: if col == 0: - return Qt.CheckState.Checked if row in self._highlighted_indices else Qt.CheckState.Unchecked + return ( + Qt.CheckState.Checked + if row in self._highlighted_indices + else Qt.CheckState.Unchecked + ) if col == 1: - return Qt.CheckState.Checked if row in self._excluded_indices else Qt.CheckState.Unchecked + return ( + Qt.CheckState.Checked + if row in self._excluded_indices + else Qt.CheckState.Unchecked + ) return None - def setData(self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole) -> bool: - if not index.isValid(): return False + def setData( + self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + if not index.isValid(): + return False row, col = index.row(), index.column() - + if role == Qt.ItemDataRole.CheckStateRole: if col == 0: - if value == Qt.CheckState.Checked.value: self._highlighted_indices.add(row) - else: self._highlighted_indices.discard(row) + if value == Qt.CheckState.Checked.value: + self._highlighted_indices.add(row) + else: + self._highlighted_indices.discard(row) elif col == 1: - if value == Qt.CheckState.Checked.value: self._excluded_indices.add(row) - else: self._excluded_indices.discard(row) + if value == Qt.CheckState.Checked.value: + self._excluded_indices.add(row) + else: + self._excluded_indices.discard(row) else: return False self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole]) return True - + if role == Qt.ItemDataRole.EditRole: try: if col == 2: @@ -74,18 +95,29 @@ def setData(self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.Ed elif col == 5: self._data_container.update_point(row, label=str(value)) return True - except ValueError: return False + except ValueError: + return False return False - def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: + if ( + role == Qt.ItemDataRole.DisplayRole + and orientation == Qt.Orientation.Horizontal + ): return self._headers[section] return None def flags(self, index: QModelIndex) -> Qt.ItemFlag: flags = super().flags(index) - if index.column() in (0, 1): flags |= Qt.ItemFlag.ItemIsUserCheckable - else: flags |= Qt.ItemFlag.ItemIsEditable + if index.column() in (0, 1): + flags |= Qt.ItemFlag.ItemIsUserCheckable + else: + flags |= Qt.ItemFlag.ItemIsEditable return flags def get_highlighted_indices(self) -> List[int]: @@ -97,26 +129,32 @@ def get_excluded_indices(self) -> List[int]: def _handle_data_changed(self): self.layoutChanged.emit() + class DataTable(QTableView): """ Custom TableView with context menu for column visibility. """ + def __init__(self, data_container: DataContainer, parent=None): super().__init__(parent) self._model = PointTableModel(data_container) self.setModel(self._model) - + self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self.horizontalHeader().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.horizontalHeader().customContextMenuRequested.connect(self._show_header_menu) - + self.horizontalHeader().setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu + ) + self.horizontalHeader().customContextMenuRequested.connect( + self._show_header_menu + ) + self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) self.setAlternatingRowColors(True) def _show_header_menu(self, pos): menu = QMenu(self) header = self.horizontalHeader() - + for i in range(self._model.columnCount()): col_name = self._model.headerData(i, Qt.Orientation.Horizontal) action = QAction(col_name, menu) @@ -125,7 +163,7 @@ def _show_header_menu(self, pos): action.setData(i) action.triggered.connect(self._toggle_column) menu.addAction(action) - + menu.exec(header.mapToGlobal(pos)) def _toggle_column(self): diff --git a/fieldview/ui/field_view.py b/fieldview/ui/field_view.py new file mode 100644 index 0000000..4042a26 --- /dev/null +++ b/fieldview/ui/field_view.py @@ -0,0 +1,85 @@ +from qtpy.QtWidgets import QGraphicsView, QGraphicsScene +from qtpy.QtCore import Qt +from qtpy.QtGui import QColor, QPainter + +from fieldview.core.data_container import DataContainer +from fieldview.layers.heatmap_layer import HeatmapLayer +from fieldview.layers.text_layer import ValueLayer, LabelLayer +from fieldview.layers.svg_layer import SvgLayer +from fieldview.layers.pin_layer import PinLayer + + +class FieldView(QGraphicsView): + """ + A high-level widget for visualizing field data. + Integrates QGraphicsView, QGraphicsScene, and DataContainer. + """ + + def __init__(self, parent=None): + super().__init__(parent) + + # Core components + self.scene = QGraphicsScene(self) + self.setScene(self.scene) + self.data_container = DataContainer() + + # View settings + self.setRenderHint(QPainter.Antialiasing) + self.setDragMode(QGraphicsView.ScrollHandDrag) + self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + self.setBackgroundBrush(QColor(30, 30, 30)) + + # Keep track of layers + self.layers = {} + + def set_data(self, points, values, labels=None): + """Sets the data for the visualization.""" + self.data_container.set_data(points, values, labels) + + def add_heatmap_layer(self, opacity=0.6, z_value=0): + """Adds a heatmap layer.""" + layer = HeatmapLayer(self.data_container) + layer.setOpacity(opacity) + layer.setZValue(z_value) + self.scene.addItem(layer) + self.layers["heatmap"] = layer + return layer + + def add_svg_layer(self, file_path, z_value=-1): + """Adds an SVG background layer.""" + layer = SvgLayer() + layer.load_svg(file_path) + layer.setZValue(z_value) + self.scene.addItem(layer) + self.layers["svg"] = layer + return layer + + def add_pin_layer(self, z_value=10): + """Adds a pin layer for data points.""" + layer = PinLayer(self.data_container) + layer.setZValue(z_value) + self.scene.addItem(layer) + self.layers["pin"] = layer + return layer + + def add_value_layer(self, z_value=5): + """Adds a layer displaying value text.""" + layer = ValueLayer(self.data_container) + layer.setZValue(z_value) + self.scene.addItem(layer) + self.layers["value"] = layer + return layer + + def add_label_layer(self, z_value=5): + """Adds a layer displaying label text.""" + layer = LabelLayer(self.data_container) + layer.setZValue(z_value) + self.scene.addItem(layer) + self.layers["label"] = layer + return layer + + def fit_to_scene(self): + """Fits the view to the scene content.""" + self.scene.setSceneRect(self.scene.itemsBoundingRect()) + self.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) diff --git a/fieldview/utils/grid_manager.py b/fieldview/utils/grid_manager.py index 77b266f..e2e8346 100644 --- a/fieldview/utils/grid_manager.py +++ b/fieldview/utils/grid_manager.py @@ -1,18 +1,27 @@ import numpy as np from fieldview.utils.interpolation import FastRBFInterpolator, BoundaryPointGenerator + class InterpolatorCache: """ Manages cached interpolators to avoid re-fitting when geometry hasn't changed. Supports LRU-style eviction to keep memory usage in check. """ + def __init__(self, max_size=5): - self._cache = {} # Key: (grid_size, points_hash, boundary_hash), Value: FastRBFInterpolator - self._access_order = [] # List of keys, most recent last + self._cache = {} # Key: (grid_size, points_hash, boundary_hash), Value: FastRBFInterpolator + self._access_order = [] # List of keys, most recent last self._max_size = max_size self._boundary_gen = BoundaryPointGenerator() - def get_interpolator(self, grid_size, points, boundary_shape, neighbors=30, kernel='thin_plate_spline'): + def get_interpolator( + self, + grid_size, + points, + boundary_shape, + neighbors=30, + kernel="thin_plate_spline", + ): """ Returns a fitted FastRBFInterpolator. If a matching interpolator exists in cache, returns it. @@ -25,17 +34,24 @@ def get_interpolator(self, grid_size, points, boundary_shape, neighbors=30, kern boundary_hash = 0 else: rect = boundary_shape.boundingRect() - boundary_hash = hash((boundary_shape.count(), boundary_shape.at(0).x(), rect.center().x(), rect.center().y())) - + boundary_hash = hash( + ( + boundary_shape.count(), + boundary_shape.at(0).x(), + rect.center().x(), + rect.center().y(), + ) + ) + key = (grid_size, points_hash, boundary_hash, neighbors, kernel) - + # 2. Check Cache if key in self._cache: # Move to end (most recently used) self._access_order.remove(key) self._access_order.append(key) return self._cache[key], self._boundary_gen - + # 3. Fit New Interpolator # We need to fit the boundary generator first to get all source points # Note: Boundary generator is shared/reused because it's cheap to fit (just KDTree) @@ -45,12 +61,12 @@ def get_interpolator(self, grid_size, points, boundary_shape, neighbors=30, kern # For simplicity, let's just re-fit the shared one. It's fast. self._boundary_gen.fit(points, boundary_shape) boundary_points = self._boundary_gen.get_boundary_points() - + if len(boundary_points) > 0: all_source_points = np.vstack((points, boundary_points)) else: all_source_points = points - + # Create Grid rect = boundary_shape.boundingRect() dx = rect.width() / grid_size @@ -58,23 +74,23 @@ def get_interpolator(self, grid_size, points, boundary_shape, neighbors=30, kern # Expand by 1 pixel expanded_rect = rect.adjusted(-dx, -dy, dx, dy) expanded_grid_size = grid_size + 2 - + x = np.linspace(expanded_rect.left(), expanded_rect.right(), expanded_grid_size) y = np.linspace(expanded_rect.top(), expanded_rect.bottom(), expanded_grid_size) X, Y = np.meshgrid(x, y) grid_points = np.column_stack((X.ravel(), Y.ravel())) - + # Fit RBF rbf = FastRBFInterpolator(neighbors=neighbors, kernel=kernel) rbf.fit(all_source_points, grid_points) - + # 4. Update Cache if len(self._cache) >= self._max_size: # Evict oldest oldest_key = self._access_order.pop(0) del self._cache[oldest_key] - + self._cache[key] = rbf self._access_order.append(key) - + return rbf, self._boundary_gen diff --git a/fieldview/utils/interpolation.py b/fieldview/utils/interpolation.py index 36129c7..e63c9db 100644 --- a/fieldview/utils/interpolation.py +++ b/fieldview/utils/interpolation.py @@ -1,22 +1,37 @@ import numpy as np from scipy.interpolate import RBFInterpolator from scipy.spatial import cKDTree -from typing import Optional, Tuple, List, Literal, Union +from typing import Optional, Literal + +KernelType = Literal[ + "thin_plate_spline", + "linear", + "cubic", + "quintic", + "gaussian", + "multiquadric", + "inverse_multiquadric", +] -KernelType = Literal["thin_plate_spline", "linear", "cubic", "quintic", "gaussian", "multiquadric", "inverse_multiquadric"] class BoundaryPointGenerator: """ Generates boundary points and computes their values using IDW. Pre-computes weights for fast updates. """ + def __init__(self): self._boundary_points = None self._idw_indices = None self._idw_weights = None self._is_fitted = False - def fit(self, points: np.ndarray, boundary_shape, target_segment_length: Optional[float] = None): + def fit( + self, + points: np.ndarray, + boundary_shape, + target_segment_length: Optional[float] = None, + ): """ Generates boundary points and computes IDW weights. """ @@ -25,8 +40,10 @@ def fit(self, points: np.ndarray, boundary_shape, target_segment_length: Optiona return # 1. Generate Boundary Points - self._boundary_points = self._generate_points(points, boundary_shape, target_segment_length) - + self._boundary_points = self._generate_points( + points, boundary_shape, target_segment_length + ) + if len(self._boundary_points) == 0: self._is_fitted = False return @@ -34,16 +51,16 @@ def fit(self, points: np.ndarray, boundary_shape, target_segment_length: Optiona # 2. Compute IDW Weights tree = cKDTree(points) dists, indices = tree.query(self._boundary_points, k=2) - + # Avoid division by zero dists = np.maximum(dists, 1e-9) weights = 1.0 / dists - + # Normalize weights row_sums = weights.sum(axis=1)[:, np.newaxis] self._idw_weights = weights / row_sums self._idw_indices = indices - + self._is_fitted = True def transform(self, values: np.ndarray) -> np.ndarray: @@ -56,115 +73,123 @@ def transform(self, values: np.ndarray) -> np.ndarray: # Get values of nearest neighbors # values[indices] shape: (n_boundary, 2) neighbor_values = values[self._idw_indices] - + # Weighted sum: sum(weights * values, axis=1) # weights shape: (n_boundary, 2) boundary_values = np.sum(self._idw_weights * neighbor_values, axis=1) - + return boundary_values def get_boundary_points(self) -> np.ndarray: - if not self._is_fitted: + if not self._is_fitted or self._boundary_points is None: return np.empty((0, 2)) return self._boundary_points - def _generate_points(self, points: np.ndarray, boundary_shape, target_segment_length: Optional[float] = None) -> np.ndarray: + def _generate_points( + self, + points: np.ndarray, + boundary_shape, + target_segment_length: Optional[float] = None, + ) -> np.ndarray: # Adaptive Sampling if target_segment_length is None: tree = cKDTree(points) distances, _ = tree.query(points, k=2) avg_dist = np.mean(distances[:, 1]) target_segment_length = avg_dist * 1.2 - if target_segment_length <= 0: target_segment_length = 10.0 - + if target_segment_length <= 0: + target_segment_length = 10.0 + boundary_points_list = [] - + # Iterate through polygon edges poly_points = [boundary_shape.at(i) for i in range(boundary_shape.count())] if boundary_shape.isClosed(): - if poly_points[0] != poly_points[-1]: - poly_points.append(poly_points[0]) + if poly_points[0] != poly_points[-1]: + poly_points.append(poly_points[0]) else: - poly_points.append(poly_points[0]) # Close the loop - + poly_points.append(poly_points[0]) # Close the loop + for i in range(len(poly_points) - 1): p1 = np.array([poly_points[i].x(), poly_points[i].y()]) - p2 = np.array([poly_points[i+1].x(), poly_points[i+1].y()]) - + p2 = np.array([poly_points[i + 1].x(), poly_points[i + 1].y()]) + segment_len = np.linalg.norm(p2 - p1) n_segments = int(np.ceil(segment_len / target_segment_length)) n_segments = max(1, n_segments) - + for j in range(n_segments): t = j / n_segments pt = p1 + t * (p2 - p1) boundary_points_list.append(pt) - + # Filter points # 1. Remove duplicates and close points within boundary points # 2. Remove points too close to existing data points - + if not boundary_points_list: - return np.empty((0, 2)) - + return np.empty((0, 2)) + boundary_points = np.array(boundary_points_list) - + # 1. Filter boundary points against themselves # We want to keep points that are at least 'min_spacing' apart # Increased to 15.0 to prevent high density of boundary points from dominating # the local neighborhood (especially with low 'neighbors' count), which causes # TPS artifacts/overfitting around isolated data points like FL. min_spacing = 15.0 - + # Sort by x coordinate to make the filtering somewhat deterministic/efficient # or just use a simple greedy approach - kept_indices = [] + if len(boundary_points) > 0: # Use cKDTree for efficient radius search # But we need a greedy selection. # Simple approach: Iterate and skip if close to already kept # For speed, we can use a simple grid or just brute force if N is small (<2000) # Or use cKDTree.query_ball_point on the *kept* points. - + # Let's use a simple mask approach with cKDTree # This is iterative and might be slow for huge N, but N is ~1000. - + # Faster approach: `scipy.spatial.KDTree` doesn't support "select subset with min dist" directly. # We can use `query_pairs` to find all pairs < r, then remove one of them. - + tree = cKDTree(boundary_points) pairs = tree.query_pairs(r=min_spacing) - + drop_indices = set() for i, j in pairs: if i in drop_indices or j in drop_indices: continue # Drop the one with higher index (arbitrary) drop_indices.add(j) - + mask = np.ones(len(boundary_points), dtype=bool) mask[list(drop_indices)] = False boundary_points = boundary_points[mask] - + # 2. Remove points close to data points if len(points) > 0: tree = cKDTree(points) # Find boundary points that are within a small radius of any data point - radius = 5.0 # Increased from 1.0 to 5.0 + radius = 5.0 # Increased from 1.0 to 5.0 indices_to_remove = tree.query_ball_point(boundary_points, radius) - + # indices_to_remove is a list of lists of neighbors mask = np.array([len(x) == 0 for x in indices_to_remove], dtype=bool) boundary_points = boundary_points[mask] - + return boundary_points + class FastRBFInterpolator: """ RBF Interpolator that pre-computes the interpolation matrix. Prediction is a simple matrix multiplication: Z = L @ values """ - def __init__(self, neighbors: int = 30, kernel: KernelType = 'thin_plate_spline'): + + def __init__(self, neighbors: int = 30, kernel: KernelType = "thin_plate_spline"): self.neighbors = neighbors self.kernel = kernel self._matrix = None @@ -182,28 +207,30 @@ def fit(self, source_points: np.ndarray, grid_points: np.ndarray): # RBFInterpolator(y) internally solves A @ w = y, then predicts Z = B @ w # So Z = B @ (A^-1 @ y) = (B @ A^-1) @ y # L = B @ A^-1 - + # However, scipy's RBFInterpolator doesn't expose A^-1 directly easily for all kernels/modes. # But we can compute L by passing the identity matrix as 'y'. # If y = I (identity), then the output is exactly column j corresponds to the response to a unit impulse at source j. # This is exactly the matrix L. - + n_source = len(source_points) - + # Create identity matrix as values # Shape: (n_source, n_source) identity = np.eye(n_source) - + try: # Fit RBF with identity matrix # Neighbors logic is handled by scipy - interp = RBFInterpolator(source_points, identity, neighbors=self.neighbors, kernel=self.kernel) - + interp = RBFInterpolator( + source_points, identity, neighbors=self.neighbors, kernel=self.kernel + ) + # Predict on grid points # Output shape: (n_grid, n_source) self._matrix = interp(grid_points) self._is_fitted = True - + except Exception as e: print(f"FastRBF fit failed: {e}") self._is_fitted = False @@ -216,7 +243,7 @@ def predict(self, values: np.ndarray) -> Optional[np.ndarray]: """ if not self._is_fitted: return None - + # Z = L @ values # (n_grid, n_source) @ (n_source,) -> (n_grid,) return self._matrix @ values diff --git a/fieldview/utils/qt_compat.py b/fieldview/utils/qt_compat.py index 18e7ec0..82cb32d 100644 --- a/fieldview/utils/qt_compat.py +++ b/fieldview/utils/qt_compat.py @@ -1,5 +1,5 @@ try: - from qtpy.QtSvgWidgets import QGraphicsSvgItem + from qtpy.QtSvgWidgets import QGraphicsSvgItem as QGraphicsSvgItem except ImportError: # PyQt5 compatibility - from qtpy.QtSvg import QGraphicsSvgItem + pass diff --git a/pyproject.toml b/pyproject.toml index f03e205..39c0fea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,4 +40,22 @@ dev = [ "twine", "aiohttp", "PySide6-QtAds", + "ruff", + "mypy", + "pandas-stubs", ] + +[tool.mypy] +ignore_missing_imports = true +check_untyped_defs = true + +[[tool.mypy.overrides]] +module = [ + "qtpy", + "qtpy.*", + "scipy.*", + "PySide6QtAds.*", +] +ignore_missing_imports = true +ignore_errors = true +follow_imports = "skip" diff --git a/scripts/capture_screenshot.py b/scripts/capture_screenshot.py index 1d2d07d..4a8f238 100644 --- a/scripts/capture_screenshot.py +++ b/scripts/capture_screenshot.py @@ -1,9 +1,6 @@ import sys import os import numpy as np -import json -import xml.etree.ElementTree as ET -import re os.environ["OMP_NUM_THREADS"] = "1" os.environ["OPENBLAS_NUM_THREADS"] = "1" @@ -16,105 +13,112 @@ os.environ["QT_API"] = "pyside6" # Add project root to path BEFORE importing fieldview or qtpy -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from qtpy.QtWidgets import QApplication, QGraphicsView, QGraphicsScene, QGraphicsItem -from qtpy.QtGui import QImage, QPainter, QColor, QFont, QPainterPath, QPolygonF, QPainterPathStroker -from qtpy.QtCore import Qt, QTimer, QRectF, QPointF +from qtpy.QtWidgets import QApplication, QGraphicsView, QGraphicsScene +from qtpy.QtGui import QImage, QPainter, QColor +from qtpy.QtCore import Qt, QTimer from fieldview.core.data_container import DataContainer from fieldview.layers.heatmap_layer import HeatmapLayer from fieldview.layers.text_layer import ValueLayer from fieldview.layers.svg_layer import SvgLayer -from examples.us_map_utils import get_state_data, get_us_boundary, load_weather_data, generate_us_dataset +from examples.us_map_utils import ( + get_state_data, + get_us_boundary, + load_weather_data, + generate_us_dataset, +) + def capture(): app = QApplication(sys.argv) - - svg_file = os.path.join(os.path.dirname(__file__), '..', 'examples', 'us_map.svg') + + svg_file = os.path.join(os.path.dirname(__file__), "..", "examples", "us_map.svg") if not os.path.exists(svg_file): print(f"Error: {svg_file} not found") return # 1. Parse SVG for Geometry state_paths, centroids = get_state_data(svg_file) - + # Create combined boundary path for heatmap us_boundary = get_us_boundary(state_paths) - + # 2. Setup Data dc = DataContainer() - + # Load Weather Data weather_data = load_weather_data() - + # Generate Dataset points, values = generate_us_dataset(centroids, weather_data) - + dc.set_data(points, values) - + # 3. Setup Scene scene = QGraphicsScene(0, 0, 959, 593) - scene.setBackgroundBrush(Qt.white) # Solid white background by user request - + scene.setBackgroundBrush(Qt.white) # Solid white background by user request + # 4. Layers # SVG Map svg_layer = SvgLayer() svg_layer.load_svg(svg_file) - + scene.addItem(svg_layer) - + # Heatmap heatmap = HeatmapLayer(dc) - heatmap.setOpacity(0.8) # Lowered from 0.6 by user request - heatmap.colormap = "jet" # Changed from default (viridis) to jet by user request - heatmap.neighbors = 100 # Global interpolation for smooth gradients - heatmap.kernel = "thin_plate_spline" + heatmap.setOpacity(0.8) # Lowered from 0.6 by user request + heatmap.colormap = "jet" # Changed from default (viridis) to jet by user request + heatmap.neighbors = 100 # Global interpolation for smooth gradients + heatmap.kernel = "thin_plate_spline" heatmap.quality = "low" # Set the exact US boundary heatmap.set_boundary_shape(us_boundary) scene.addItem(heatmap) - + # Values values_layer = ValueLayer(dc) values_layer.suffix = "°C" values_layer.font.setPixelSize(14) values_layer.font.setBold(True) values_layer.highlight_color = QColor("orange") - + # Highlight highest temp max_idx = np.argmax(values) values_layer.set_highlighted_indices([int(max_idx)]) scene.addItem(values_layer) - + # 5. View view = QGraphicsView(scene) view.resize(959, 593) - + # Set scene rect to match SVG (959x593) scene.setSceneRect(0, 0, 959, 593) - + # 6. Capture - assets_dir = os.path.join(os.path.dirname(__file__), '..', 'assets') + assets_dir = os.path.join(os.path.dirname(__file__), "..", "assets") os.makedirs(assets_dir, exist_ok=True) - output_path = os.path.join(assets_dir, 'us_map_demo.png') - + output_path = os.path.join(assets_dir, "us_map_demo.png") + def take_screenshot(): print("Rendering...") image = QImage(959, 593, QImage.Format.Format_ARGB32) image.fill(QColor(30, 30, 30)) - + painter = QPainter(image) scene.render(painter) painter.end() - + image.save(output_path) print(f"Saved to {output_path}") app.quit() - + QTimer.singleShot(2000, take_screenshot) sys.exit(app.exec()) + if __name__ == "__main__": capture() diff --git a/scripts/fetch_nws_weather.py b/scripts/fetch_nws_weather.py index 1217e4a..abf85cb 100644 --- a/scripts/fetch_nws_weather.py +++ b/scripts/fetch_nws_weather.py @@ -2,66 +2,66 @@ import aiohttp import json import os -import time # Coordinates for US State Capitals (Lat, Lon) # Excluding AK and HI as per project context STATE_CAPITALS_COORDS = { - "AL": (32.3777, -86.3006), # Montgomery - "AZ": (33.4484, -112.0740), # Phoenix - "AR": (34.7465, -92.2896), # Little Rock - "CA": (38.5816, -121.4944), # Sacramento - "CO": (39.7392, -104.9903), # Denver - "CT": (41.7637, -72.6851), # Hartford - "DE": (39.1582, -75.5244), # Dover - "FL": (30.4383, -84.2807), # Tallahassee - "GA": (33.7490, -84.3880), # Atlanta - "ID": (43.6150, -116.2023), # Boise - "IL": (39.7817, -89.6501), # Springfield - "IN": (39.7684, -86.1581), # Indianapolis - "IA": (41.5868, -93.6250), # Des Moines - "KS": (39.0473, -95.6752), # Topeka - "KY": (38.2009, -84.8733), # Frankfort - "LA": (30.4515, -91.1871), # Baton Rouge - "ME": (44.3106, -69.7795), # Augusta - "MD": (38.9784, -76.4922), # Annapolis - "MA": (42.3601, -71.0589), # Boston - "MI": (42.7325, -84.5555), # Lansing - "MN": (44.9537, -93.0900), # Saint Paul - "MS": (32.2988, -90.1848), # Jackson - "MO": (38.5767, -92.1735), # Jefferson City - "MT": (46.5891, -112.0391), # Helena - "NE": (40.8136, -96.7026), # Lincoln - "NV": (39.1638, -119.7674), # Carson City - "NH": (43.2081, -71.5375), # Concord - "NJ": (40.2206, -74.7597), # Trenton - "NM": (35.6870, -105.9378), # Santa Fe - "NY": (42.6526, -73.7562), # Albany - "NC": (35.7796, -78.6382), # Raleigh - "ND": (46.8083, -100.7837), # Bismarck - "OH": (39.9612, -82.9988), # Columbus - "OK": (35.4676, -97.5164), # Oklahoma City - "OR": (44.9429, -123.0351), # Salem - "PA": (40.2732, -76.8867), # Harrisburg - "RI": (41.8240, -71.4128), # Providence - "SC": (34.0007, -81.0348), # Columbia - "SD": (44.3668, -100.3538), # Pierre - "TN": (36.1627, -86.7816), # Nashville - "TX": (30.2672, -97.7431), # Austin - "UT": (40.7608, -111.8910), # Salt Lake City - "VT": (44.2601, -72.5754), # Montpelier - "VA": (37.5407, -77.4360), # Richmond - "WA": (47.0379, -122.9007), # Olympia - "WV": (38.3498, -81.6326), # Charleston - "WI": (43.0731, -89.4012), # Madison - "WY": (41.1399, -104.8202) # Cheyenne + "AL": (32.3777, -86.3006), # Montgomery + "AZ": (33.4484, -112.0740), # Phoenix + "AR": (34.7465, -92.2896), # Little Rock + "CA": (38.5816, -121.4944), # Sacramento + "CO": (39.7392, -104.9903), # Denver + "CT": (41.7637, -72.6851), # Hartford + "DE": (39.1582, -75.5244), # Dover + "FL": (30.4383, -84.2807), # Tallahassee + "GA": (33.7490, -84.3880), # Atlanta + "ID": (43.6150, -116.2023), # Boise + "IL": (39.7817, -89.6501), # Springfield + "IN": (39.7684, -86.1581), # Indianapolis + "IA": (41.5868, -93.6250), # Des Moines + "KS": (39.0473, -95.6752), # Topeka + "KY": (38.2009, -84.8733), # Frankfort + "LA": (30.4515, -91.1871), # Baton Rouge + "ME": (44.3106, -69.7795), # Augusta + "MD": (38.9784, -76.4922), # Annapolis + "MA": (42.3601, -71.0589), # Boston + "MI": (42.7325, -84.5555), # Lansing + "MN": (44.9537, -93.0900), # Saint Paul + "MS": (32.2988, -90.1848), # Jackson + "MO": (38.5767, -92.1735), # Jefferson City + "MT": (46.5891, -112.0391), # Helena + "NE": (40.8136, -96.7026), # Lincoln + "NV": (39.1638, -119.7674), # Carson City + "NH": (43.2081, -71.5375), # Concord + "NJ": (40.2206, -74.7597), # Trenton + "NM": (35.6870, -105.9378), # Santa Fe + "NY": (42.6526, -73.7562), # Albany + "NC": (35.7796, -78.6382), # Raleigh + "ND": (46.8083, -100.7837), # Bismarck + "OH": (39.9612, -82.9988), # Columbus + "OK": (35.4676, -97.5164), # Oklahoma City + "OR": (44.9429, -123.0351), # Salem + "PA": (40.2732, -76.8867), # Harrisburg + "RI": (41.8240, -71.4128), # Providence + "SC": (34.0007, -81.0348), # Columbia + "SD": (44.3668, -100.3538), # Pierre + "TN": (36.1627, -86.7816), # Nashville + "TX": (30.2672, -97.7431), # Austin + "UT": (40.7608, -111.8910), # Salt Lake City + "VT": (44.2601, -72.5754), # Montpelier + "VA": (37.5407, -77.4360), # Richmond + "WA": (47.0379, -122.9007), # Olympia + "WV": (38.3498, -81.6326), # Charleston + "WI": (43.0731, -89.4012), # Madison + "WY": (41.1399, -104.8202), # Cheyenne } HEADERS = { "User-Agent": "(fieldview-app, contact@example.com)", - "Accept": "application/geo+json" + "Accept": "application/geo+json", } + async def fetch_url(session, url): async with session.get(url, headers=HEADERS) as response: if response.status == 200: @@ -70,34 +70,35 @@ async def fetch_url(session, url): # print(f"Error fetching {url}: {response.status}") return None + async def fetch_state_weather(session, state_code, lat, lon): try: # 1. Get Point Metadata points_url = f"https://api.weather.gov/points/{lat},{lon}" point_data = await fetch_url(session, points_url) - + if not point_data: return state_code, None # 2. Get Observation Stations - stations_url = point_data['properties']['observationStations'] + stations_url = point_data["properties"]["observationStations"] stations_data = await fetch_url(session, stations_url) - - if not stations_data or not stations_data.get('features'): + + if not stations_data or not stations_data.get("features"): return state_code, None # Use the first station - station_id = stations_data['features'][0]['properties']['stationIdentifier'] - + station_id = stations_data["features"][0]["properties"]["stationIdentifier"] + # 3. Get Latest Observation obs_url = f"https://api.weather.gov/stations/{station_id}/observations/latest" obs_data = await fetch_url(session, obs_url) - + if not obs_data: return state_code, None - - temp_c = obs_data['properties']['temperature']['value'] - + + temp_c = obs_data["properties"]["temperature"]["value"] + if temp_c is not None: print(f"{state_code}: {temp_c}°C (Station: {station_id})") return state_code, temp_c @@ -109,28 +110,32 @@ async def fetch_state_weather(session, state_code, lat, lon): print(f"Exception fetching data for {state_code}: {e}") return state_code, None + async def main(): weather_data = {} print("Fetching weather data from NWS API (Async)...") - + async with aiohttp.ClientSession() as session: tasks = [] for state_code, (lat, lon) in STATE_CAPITALS_COORDS.items(): tasks.append(fetch_state_weather(session, state_code, lat, lon)) - + results = await asyncio.gather(*tasks) - + for state_code, temp in results: if temp is not None: weather_data[state_code] = temp # Save to JSON - output_path = os.path.join(os.path.dirname(__file__), '..', 'examples', 'us_weather_data.json') - with open(output_path, 'w') as f: + output_path = os.path.join( + os.path.dirname(__file__), "..", "examples", "us_weather_data.json" + ) + with open(output_path, "w") as f: json.dump(weather_data, f, indent=4) - + print(f"\nWeather data saved to {output_path}") print(f"Successfully fetched data for {len(weather_data)} states.") + if __name__ == "__main__": asyncio.run(main()) diff --git a/tests/test_color_range_widget.py b/tests/test_color_range_widget.py index bed332f..c7c2e30 100644 --- a/tests/test_color_range_widget.py +++ b/tests/test_color_range_widget.py @@ -1,5 +1,3 @@ -import pytest - from fieldview.ui import ColorRangeControl diff --git a/tests/test_data_container.py b/tests/test_data_container.py index c3f312d..a758f40 100644 --- a/tests/test_data_container.py +++ b/tests/test_data_container.py @@ -1,73 +1,79 @@ -import pytest import numpy as np from fieldview.core.data_container import DataContainer + def test_initialization(): dc = DataContainer() assert len(dc.points) == 0 assert len(dc.values) == 0 + def test_set_data(qtbot): dc = DataContainer() points = [[0, 0], [1, 1]] values = [10, 20] labels = ["A", "B"] - + with qtbot.waitSignal(dc.dataChanged): dc.set_data(points, values, labels) - + assert len(dc.points) == 2 assert np.array_equal(dc.values, np.array([10, 20])) assert dc.labels == ["A", "B"] + def test_add_points(qtbot): dc = DataContainer() dc.set_data([[0, 0]], [10], ["A"]) - + with qtbot.waitSignal(dc.dataChanged): dc.add_points([[1, 1]], [20], ["B"]) - + assert len(dc.points) == 2 assert dc.values[1] == 20 assert dc.labels[1] == "B" + def test_update_point(qtbot): dc = DataContainer() dc.set_data([[0, 0]], [10], ["A"]) - + with qtbot.waitSignal(dc.dataChanged): dc.update_point(0, value=50, label="C") - + assert dc.values[0] == 50 assert dc.labels[0] == "C" + def test_remove_points(qtbot): dc = DataContainer() dc.set_data([[0, 0], [1, 1], [2, 2]], [10, 20, 30], ["A", "B", "C"]) - + with qtbot.waitSignal(dc.dataChanged): dc.remove_points([1]) - + assert len(dc.points) == 2 - assert dc.values[1] == 30 # The old index 2 is now index 1 + assert dc.values[1] == 30 # The old index 2 is now index 1 assert dc.labels[1] == "C" + def test_get_closest_point(): dc = DataContainer() dc.set_data([[0, 0], [10, 10]], [1, 2]) - + idx = dc.get_closest_point(1, 1) assert idx == 0 - + idx = dc.get_closest_point(9, 9) assert idx == 1 - - idx = dc.get_closest_point(5, 5) # Equidistant (approx), returns one of them + + idx = dc.get_closest_point(5, 5) # Equidistant (approx), returns one of them assert idx is not None + def test_get_closest_point_threshold(): dc = DataContainer() dc.set_data([[0, 0]], [1]) - + idx = dc.get_closest_point(100, 100, threshold=10) assert idx is None diff --git a/tests/test_data_layer.py b/tests/test_data_layer.py index 7c9444b..c8f1305 100644 --- a/tests/test_data_layer.py +++ b/tests/test_data_layer.py @@ -1,36 +1,39 @@ -import pytest from fieldview.core.data_container import DataContainer from fieldview.layers.data_layer import DataLayer + def test_datalayer_initialization(qtbot): dc = DataContainer() layer = DataLayer(dc) assert layer.data_container == dc assert len(layer.excluded_indices) == 0 -def test_datalayer_update_on_data_change(qtbot): + +def test_datalayer_update_on_data_change(qtbot, monkeypatch): dc = DataContainer() layer = DataLayer(dc) - + # Mock update_layer to verify it's called called = False + def mock_update(): nonlocal called called = True - - layer.update_layer = mock_update - + + monkeypatch.setattr(layer, "update_layer", mock_update) + dc.set_data([[0, 0]], [10]) assert called + def test_excluded_indices(): dc = DataContainer() dc.set_data([[0, 0], [1, 1], [2, 2]], [10, 20, 30], ["A", "B", "C"]) layer = DataLayer(dc) - + layer.set_excluded_indices([1]) assert layer.excluded_indices == {1} - + points, values, labels = layer.get_valid_data() assert len(points) == 2 assert values[0] == 10 @@ -38,12 +41,13 @@ def test_excluded_indices(): assert labels[0] == "A" assert labels[1] == "C" + def test_add_remove_excluded_index(): dc = DataContainer() layer = DataLayer(dc) - + layer.add_excluded_index(5) assert 5 in layer.excluded_indices - + layer.remove_excluded_index(5) assert 5 not in layer.excluded_indices diff --git a/tests/test_grid_manager.py b/tests/test_grid_manager.py index 14d4d3d..06d967d 100644 --- a/tests/test_grid_manager.py +++ b/tests/test_grid_manager.py @@ -1,58 +1,60 @@ -import pytest import numpy as np from qtpy.QtGui import QPolygonF -from qtpy.QtCore import QPointF, QRectF +from qtpy.QtCore import QRectF from fieldview.utils.grid_manager import InterpolatorCache + def test_interpolator_cache_caching(): cache = InterpolatorCache(max_size=2) - + # Setup dummy data # Setup dummy data - Use enough points to avoid singular matrix with TPS rng = np.random.default_rng(42) points = rng.random((10, 2)) * 10 boundary = QPolygonF(QRectF(0, 0, 10, 10)) grid_size = 10 - + # 1. First Get - Should fit new interp1, _ = cache.get_interpolator(grid_size, points, boundary) assert interp1._is_fitted - + # 2. Second Get (Same args) - Should return same instance interp2, _ = cache.get_interpolator(grid_size, points, boundary) assert interp1 is interp2 - + # 3. Change Grid Size - Should return new instance interp3, _ = cache.get_interpolator(20, points, boundary) assert interp3 is not interp1 - + # 4. Change Points - Should return new instance points2 = rng.random((10, 2)) * 10 interp4, _ = cache.get_interpolator(grid_size, points2, boundary) assert interp4 is not interp1 + def test_interpolator_cache_eviction(): cache = InterpolatorCache(max_size=2) - + rng = np.random.default_rng(42) points = rng.random((10, 2)) * 10 boundary = QPolygonF(QRectF(0, 0, 10, 10)) - + # Fill cache - i1, _ = cache.get_interpolator(10, points, boundary) # Cache: [10] - i2, _ = cache.get_interpolator(20, points, boundary) # Cache: [10, 20] - + i1, _ = cache.get_interpolator(10, points, boundary) # Cache: [10] + i2, _ = cache.get_interpolator(20, points, boundary) # Cache: [10, 20] + # Access 10 again to make it recent - cache.get_interpolator(10, points, boundary) # Cache: [20, 10] - + cache.get_interpolator(10, points, boundary) # Cache: [20, 10] + # Add new one, should evict 20 (LRU) - i3, _ = cache.get_interpolator(30, points, boundary) # Cache: [10, 30] - + i3, _ = cache.get_interpolator(30, points, boundary) # Cache: [10, 30] + # Check if 20 is gone (by checking internal cache, though implementation detail) # Or by checking if getting 20 returns a NEW instance i2_new, _ = cache.get_interpolator(20, points, boundary) assert i2_new is not i2 + if __name__ == "__main__": test_interpolator_cache_caching() test_interpolator_cache_eviction() diff --git a/tests/test_heatmap_layer.py b/tests/test_heatmap_layer.py index 481f4da..4e2719a 100644 --- a/tests/test_heatmap_layer.py +++ b/tests/test_heatmap_layer.py @@ -1,10 +1,11 @@ import pytest import numpy as np -from qtpy.QtCore import QTimer, QPointF, QRectF -from qtpy.QtGui import QPolygonF, QPainterPath, QColor +from qtpy.QtCore import QRectF +from qtpy.QtGui import QPainterPath, QColor from fieldview.core.data_container import DataContainer from fieldview.layers.heatmap_layer import HeatmapLayer + def test_heatmap_initialization(qtbot): dc = DataContainer() layer = HeatmapLayer(dc) @@ -13,63 +14,68 @@ def test_heatmap_initialization(qtbot): assert rect.width() == 0 assert rect.height() == 0 + def test_heatmap_update(qtbot): dc = DataContainer() layer = HeatmapLayer(dc) - + # Add enough points for interpolation points = np.random.rand(10, 2) * 100 values = np.random.rand(10) * 100 - + with qtbot.waitSignal(dc.dataChanged): dc.set_data(points, values) - + # Check if image is generated (Fast update) assert layer._cached_image is not None - + # Wait for HQ update - qtbot.wait(400) # Wait > 300ms + qtbot.wait(400) # Wait > 300ms assert layer._cached_image is not None # _generate_boundary_points was removed and refactored into BoundaryPointGenerator # This logic is now tested in test_interpolation.py or implicitly via rendering pass + def test_not_enough_points(qtbot): dc = DataContainer() layer = HeatmapLayer(dc) - + dc.set_data([[0, 0]], [10]) assert layer._cached_image is None + def test_rendering_signal(qtbot): dc = DataContainer() layer = HeatmapLayer(dc) - + # Connect signal with qtbot.waitSignal(layer.renderingFinished, timeout=1000) as blocker: points = np.random.rand(10, 2) * 100 values = np.random.rand(10) * 100 dc.set_data(points, values) - + assert isinstance(blocker.args[0], float) assert blocker.args[0] >= 0 + def test_boundary_shape_types(qtbot): dc = DataContainer() layer = HeatmapLayer(dc) - + # Test QRectF rect = QRectF(0, 0, 100, 100) layer.set_boundary_shape(rect) assert not layer._boundary_shape.isEmpty() - + # Test QPainterPath path = QPainterPath() path.addEllipse(0, 0, 100, 100) layer.set_boundary_shape(path) assert not layer._boundary_shape.isEmpty() + def test_colormap_property(qtbot): dc = DataContainer() layer = HeatmapLayer(dc) @@ -93,7 +99,7 @@ def test_heatmap_color_range_manual_and_auto(qtbot): lut = layer._colormap.get_lut(256) expected_low = np.clip((0.0 - 2.0) / (8.0 - 2.0), 0.0, 1.0) - expected_high = np.clip((10.0 - 2.0) / (8.0 - 2.0), 0.0, 1.0) + low_color = image.pixelColor(0, 0) mid_color = image.pixelColor(1, 0) diff --git a/tests/test_interpolation.py b/tests/test_interpolation.py index 2a6b8f2..ca596d8 100644 --- a/tests/test_interpolation.py +++ b/tests/test_interpolation.py @@ -1,54 +1,60 @@ -import pytest import numpy as np from scipy.interpolate import RBFInterpolator from fieldview.utils.interpolation import FastRBFInterpolator + def test_fast_rbf_correctness(): # 1. Setup Data # Random source points rng = np.random.default_rng(42) source_points = rng.random((20, 2)) * 100 values = rng.random(20) * 10 - + # Grid points x = np.linspace(0, 100, 10) y = np.linspace(0, 100, 10) X, Y = np.meshgrid(x, y) grid_points = np.column_stack((X.ravel(), Y.ravel())) - + # 2. Scipy RBF - scipy_rbf = RBFInterpolator(source_points, values, neighbors=10, kernel='thin_plate_spline') + scipy_rbf = RBFInterpolator( + source_points, values, neighbors=10, kernel="thin_plate_spline" + ) scipy_result = scipy_rbf(grid_points) - + # 3. Fast RBF - fast_rbf = FastRBFInterpolator(neighbors=10, kernel='thin_plate_spline') + fast_rbf = FastRBFInterpolator(neighbors=10, kernel="thin_plate_spline") fast_rbf.fit(source_points, grid_points) fast_result = fast_rbf.predict(values) - + # 4. Compare # Should be very close, allowing for small float errors + assert fast_result is not None np.testing.assert_allclose(fast_result, scipy_result, rtol=1e-5, atol=1e-5) + def test_fast_rbf_caching_speed(): # 1. Setup Data rng = np.random.default_rng(42) source_points = rng.random((50, 2)) * 100 grid_points = rng.random((1000, 2)) * 100 - + fast_rbf = FastRBFInterpolator(neighbors=20) fast_rbf.fit(source_points, grid_points) - + # 2. Measure Prediction Time import time + start = time.perf_counter() for _ in range(100): values = rng.random(50) _ = fast_rbf.predict(values) duration = time.perf_counter() - start - + # Just ensure it runs reasonably fast (not a strict assertion, but sanity check) print(f"100 predictions took {duration:.4f}s") - assert duration < 1.0 # Should be very fast + assert duration < 1.0 # Should be very fast + if __name__ == "__main__": test_fast_rbf_correctness() diff --git a/tests/test_misc_layers.py b/tests/test_misc_layers.py index 464aa0c..2ea2fc2 100644 --- a/tests/test_misc_layers.py +++ b/tests/test_misc_layers.py @@ -1,20 +1,17 @@ -import pytest -import os -from qtpy.QtCore import QPointF, QRectF -from qtpy.QtGui import QPixmap, QPainter, QColor -from fieldview.utils.qt_compat import QGraphicsSvgItem +from qtpy.QtGui import QPixmap from fieldview.core.data_container import DataContainer from fieldview.layers.svg_layer import SvgLayer from fieldview.layers.pin_layer import PinLayer + def test_svg_layer(qtbot, tmp_path): layer = SvgLayer() - + # Create a dummy SVG file svg_content = b'' svg_file = tmp_path / "test.svg" svg_file.write_bytes(svg_content) - + layer.load_svg(str(svg_file)) assert layer._renderer.isValid() @@ -48,13 +45,14 @@ def test_svg_layer_origin_absolute(qtbot): assert updated.top() == base_rect.top() - 3 assert updated.size() == base_rect.size() + def test_pin_layer(qtbot): dc = DataContainer() dc.set_data([[0, 0]], [10]) layer = PinLayer(dc) - + # Create a dummy pixmap pixmap = QPixmap(10, 10) layer.set_icon(pixmap) - + assert layer.icon == pixmap diff --git a/tests/test_text_layer.py b/tests/test_text_layer.py index 0c269b8..8abdfed 100644 --- a/tests/test_text_layer.py +++ b/tests/test_text_layer.py @@ -1,33 +1,34 @@ -import pytest -from qtpy.QtCore import QPointF, QRectF -from qtpy.QtGui import QFont, QColor +from qtpy.QtGui import QFont from fieldview.core.data_container import DataContainer from fieldview.layers.text_layer import ValueLayer, LabelLayer + def test_value_layer(qtbot): dc = DataContainer() dc.set_data([[0, 0]], [10.12345]) layer = ValueLayer(dc) - + # Default 2 decimals assert layer._get_text(0, 10.12345, "") == "10.12" - + layer.decimal_places = 3 assert layer._get_text(0, 10.12345, "") == "10.123" - + layer.suffix = " m" assert layer._get_text(0, 10.12345, "") == "10.123 m" - + layer.prefix = "Val: " assert layer._get_text(0, 10.12345, "") == "Val: 10.123 m" + def test_label_layer(qtbot): dc = DataContainer() dc.set_data([[0, 0]], [10], ["Test Label"]) layer = LabelLayer(dc) - + assert layer._get_text(0, 10, "Test Label") == "Test Label" + def test_highlighting(qtbot): dc = DataContainer() layer = ValueLayer(dc) @@ -36,7 +37,7 @@ def test_highlighting(qtbot): assert layer.highlighted_indices == {0, 2} -def test_highlighting_uses_original_indices_with_exclusions(qtbot): +def test_highlighting_uses_original_indices_with_exclusions(qtbot, monkeypatch): dc = DataContainer() dc.set_data([[0, 0], [1, 1], [2, 2]], [10, 20, 30], ["A", "B", "C"]) layer = ValueLayer(dc) @@ -56,47 +57,52 @@ def record_get_text(idx, value, label): recorded_indices.append(idx) return "recorded" - layer._get_text = record_get_text + monkeypatch.setattr(layer, "_get_text", record_get_text) layer.paint(painter, None, None) painter.end() assert set(recorded_indices) == {0, 2} assert 2 in recorded_indices + def test_collision_avoidance(qtbot): dc = DataContainer() # Two points very close to each other dc.set_data([[0, 0], [1, 1]], [10, 20], ["A", "B"]) layer = LabelLayer(dc) layer.collision_avoidance_enabled = True - layer.collision_offset_factor = 1.5 # Increase offset to ensure separation - + layer.collision_offset_factor = 1.5 # Increase offset to ensure separation + # Mock font metrics or use real one # We need to trigger paint or calculate_layout manually # Since calculate_layout is internal, we can access it for testing - - from qtpy.QtGui import QFontMetrics, QPainter, QImage - + + from qtpy.QtGui import QPainter, QImage + # Create a dummy painter to get metrics img = QImage(100, 100, QImage.Format.Format_ARGB32) painter = QPainter(img) painter.setFont(layer.font) metrics = painter.fontMetrics() painter.end() - + points, values, labels = layer.get_valid_data() indices = layer.get_valid_indices() layout = layer._calculate_layout(points, values, labels, metrics, indices) - + rect0 = layout[0] rect1 = layout[1] - + # They should not intersect assert not rect0.intersects(rect1) + def test_font_loading(qtbot): dc = DataContainer() layer = LabelLayer(dc) - + assert layer.font is not None - assert layer.font.family() in ["JetBrains Mono", "Monospace"] or layer.font.styleHint() == QFont.StyleHint.Monospace + assert ( + layer.font.family() in ["JetBrains Mono", "Monospace"] + or layer.font.styleHint() == QFont.StyleHint.Monospace + ) diff --git a/uv.lock b/uv.lock index e41b2c0..d92b719 100644 --- a/uv.lock +++ b/uv.lock @@ -447,11 +447,14 @@ pyside6 = [ dev = [ { name = "aiohttp" }, { name = "build" }, + { name = "mypy" }, { name = "pandas", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "pandas-stubs" }, { name = "pyside6-qtads" }, { name = "pytest" }, { name = "pytest-qt" }, + { name = "ruff" }, { name = "twine" }, ] @@ -470,10 +473,13 @@ provides-extras = ["pyside6", "pyqt6", "pyqt5"] dev = [ { name = "aiohttp" }, { name = "build" }, + { name = "mypy" }, { name = "pandas", specifier = ">=2.0.0" }, + { name = "pandas-stubs" }, { name = "pyside6-qtads" }, { name = "pytest", specifier = ">=9.0.1" }, { name = "pytest-qt", specifier = ">=4.5.0" }, + { name = "ruff" }, { name = "twine" }, ] @@ -703,6 +709,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "librt" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/c1/75805ae4a8222682ef085c6346e6552c54e36612a79ee62b39f638893e81/librt-0.7.0.tar.gz", hash = "sha256:ec5235ce0f0ab7f3006c5ea9b673d2168030911b7d3a73f751a809e12c5ae54f", size = 68713, upload-time = "2025-12-05T21:16:51.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/c8/13a90a28e62d75759e366bbe9012095db51793e8727ef73865944278ebe8/librt-0.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f5771fd63fe30dfbc94ac08eb6f590fb74964d90aba14c06ac94ed40cbff9f99", size = 54705, upload-time = "2025-12-05T21:15:19.386Z" }, + { url = "https://files.pythonhosted.org/packages/64/e8/230ba4bf2485d04accb792287c2a65a3ca2acfa6e733ea1f90a0b3e72934/librt-0.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:388f794cd52ed4692ec0e3b00a07a502ef879bac90fa21f6e0035422c7b117c8", size = 56662, upload-time = "2025-12-05T21:15:20.821Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/00de5cb27c2c6694cd492b66c98ba4d6dce4edab5e56475ac43490245f17/librt-0.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f312a192534cf162306a9f00f6d5d6f432f9f8d07f9f726111de477cec8d3ddf", size = 161045, upload-time = "2025-12-05T21:15:22.128Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/2cc370d531ec57dc9ed4a1caea41cf8813e6d0f53aba753076a38f015cae/librt-0.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37c9133c69adcf6229e3aecea56d1c77a79dd00f5d65e7f28c500590b4edcf4b", size = 169534, upload-time = "2025-12-05T21:15:23.681Z" }, + { url = "https://files.pythonhosted.org/packages/37/54/5dea5cdbed974a1ce5631d49609e6b778f6fc1fcee1b99ec3266c48bc339/librt-0.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211a312a9ab2226ecdb509087bc6a0d0f9d8550565a0d1b848576b9119c69cda", size = 183277, upload-time = "2025-12-05T21:15:25.11Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/01e7b09637d4e1a739dd5841983e3f987db8dfe98f080eb4d88495348629/librt-0.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e3f975f62352ee20a0b1071532bf91e77097a541ab6f68e8cdfc56e708bed11", size = 179048, upload-time = "2025-12-05T21:15:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/38/60bcb51ee371bbf8adb12df8ecc97dd9c22b5cbcfd1a59499b53ba5e1a2d/librt-0.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:99d86939d3f5be4734ff3d87923002b816e047fbe35eca731ada5ec1871afc01", size = 173519, upload-time = "2025-12-05T21:15:28.021Z" }, + { url = "https://files.pythonhosted.org/packages/0f/60/6ef0ca043bb08b71c6056a0e9b645afd463ac545f006de36ba7f47509a81/librt-0.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d7884f93a210e465c793023185672816d0e94a748fd8728fb7f5cb4a7e457da7", size = 193591, upload-time = "2025-12-05T21:15:29.067Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/9479d1b069be5fb76d8bb0c7626e775808cd07579f947f6b277ea67ae517/librt-0.7.0-cp310-cp310-win32.whl", hash = "sha256:2c23b7ab197ee9ed29cd0b61ac1e24e4483f24612f4626833877e19b28f95935", size = 47204, upload-time = "2025-12-05T21:15:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5e/ba6eb6c04b2f21a14e4ea42f713a9363b9177c452f17bf6c4e6c7ec280ed/librt-0.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:f8054546544f70cd27cb5e0a73c8de271c9dcc664741399acd584134310e312a", size = 54372, upload-time = "2025-12-05T21:15:31.48Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/1d2655df9f464f66f26ac6c78c0408dd28047f2cfbf3fcecde2c606f7557/librt-0.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d89460a3a0dc0a6621c17be4eb84747b80a2e68e8da1b8cc6c2d8fc0a642b50e", size = 54709, upload-time = "2025-12-05T21:15:32.484Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5b/1ec78e0d823f92120dbe35cc1f18260f49d6458d2c4f1a099f2b3134e6a3/librt-0.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96715093db6f983ca9c7d8a4e36b450d7c989c3b07839bb7bc3b8be12cf601af", size = 56664, upload-time = "2025-12-05T21:15:33.45Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6f3cf97d96ad0fd29123f6e7dacc7a215f4bb194fb6cc859c3ed76351935/librt-0.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eab63367bdb304e87d108cfd078b0d9bfa62f4fe3e5daf9afc5e159676cac15b", size = 161703, upload-time = "2025-12-05T21:15:34.69Z" }, + { url = "https://files.pythonhosted.org/packages/ef/0c/1ea76257fe7bf9686bdd6e85cc92f9e912749e657e31006e12c9d54384a8/librt-0.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1aa6eb96952cadb861b8fc5a41832349935a5a4bd1478b8425c023ece98af72c", size = 171040, upload-time = "2025-12-05T21:15:35.75Z" }, + { url = "https://files.pythonhosted.org/packages/a3/65/c7c138989a50a50781eb86a751018391a865826fc7cd0291b818ca82933c/librt-0.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e20cb95262897eea692eced3398f7be6647d38244c1fa8480c0e48337aac0080", size = 184719, upload-time = "2025-12-05T21:15:37.575Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f4/179e89101e9b8232aa4beebf6267a64b046e2b47021fe752a205394e888f/librt-0.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0ce1f5863839c85c8e7e1467dd939d4af5e59bab8852852a9d8b7a9dbcdcaf2a", size = 180733, upload-time = "2025-12-05T21:15:39.06Z" }, + { url = "https://files.pythonhosted.org/packages/60/68/5faf6e1328cc6a2c5bef52d359426a15d3d1299d644fa7d2a62bf9bb2389/librt-0.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:97d3b787e78e8cc1b14513747cc677d3390493871394e3da9ac50dec99e2dc43", size = 174565, upload-time = "2025-12-05T21:15:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ca/e6fb045c1a543f3d2b0246a8130ae86ee60dfbfa3ecd39d8daaa5d1aaf3f/librt-0.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:75c787db17786f5a732a1eaf09b04d2c43f8931efe0876e594b8be77e603a2e1", size = 195246, upload-time = "2025-12-05T21:15:41.593Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ed/521f8c6d5c48a95026554bc07a8d08aafb1bb9851a00c8dbb402c6dd60fa/librt-0.7.0-cp311-cp311-win32.whl", hash = "sha256:88011c66ef4053807e45158cce6c79f8f1a12d533b9a918a062273c57f8846b6", size = 47517, upload-time = "2025-12-05T21:15:43.112Z" }, + { url = "https://files.pythonhosted.org/packages/1e/70/f48c348f20076a7384628f0c22ab5197ac213e2d772c0eefa3fb7ebc6c2f/librt-0.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:e92323133242ff29eec97538f5d1421e8b96abb3212a07b9c6cea514dd58ddba", size = 54700, upload-time = "2025-12-05T21:15:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/e7562d5510952913e868510e2bc566d447d6764f093a2d551e979b94022e/librt-0.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:938050cb83c54cbd636e3b68df8dee488740f7de557b6d3dc77998b825d544b1", size = 48148, upload-time = "2025-12-05T21:15:45.099Z" }, + { url = "https://files.pythonhosted.org/packages/b3/57/9d29e940cfc1059002567da1819b9f03a27629c56c2fee9fe5d575657bb6/librt-0.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15875129cce2377bd703557314b81c4e7bfc63fdcd8247b0c5bf7dc34a8d61b5", size = 55688, upload-time = "2025-12-05T21:15:46.135Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b5/dcce7d9184b595bd4604ef6074d7be231840752b1c6e884703926c65721e/librt-0.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:90119009b757b3a611aba38e9ee163b49864825572325e2eec0080c42fc8bb69", size = 57133, upload-time = "2025-12-05T21:15:47.126Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2d/b5898cc516f2cae67e8e22733e272289caaa4628a1cef028114e60b90d2f/librt-0.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f86024966f5bd4f962cbd54a4ad5d0e435fd3686f7edcd78c5aa84bb9427fa16", size = 165337, upload-time = "2025-12-05T21:15:48.116Z" }, + { url = "https://files.pythonhosted.org/packages/36/bd/4cdf77f76704a72dd8835425eb75ba22f58999bc06a0034754bada4cafb7/librt-0.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c16a988ef540b6dba0be057c343ff7489c95080348b70b6a1fa527128cf386b", size = 174236, upload-time = "2025-12-05T21:15:49.265Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fb/de6615674c35b3632fb32871f8e0fd02070b5f14df946cee8bf75246383d/librt-0.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e7a4dcb2419b766a034a62d28708a11e92d790aa6faa74913e587ccc4c2fc55", size = 189020, upload-time = "2025-12-05T21:15:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/83/90/fbebb6a3d347c1bda00796404f3dcc938cb443cca5473e3bce0d7886c020/librt-0.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbe9364d5b25f1fce27acaf695205a89ba2f3d79c668b03bde7315ba4b088b60", size = 183984, upload-time = "2025-12-05T21:15:51.787Z" }, + { url = "https://files.pythonhosted.org/packages/d1/31/496914f6fafbefcc35bc944c77f8aeeaef97f763656d8c031a78708cb7db/librt-0.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a531d4ae278713495768030ff02fc687cc174be1bf55f5084303d470e170ba7e", size = 177600, upload-time = "2025-12-05T21:15:52.881Z" }, + { url = "https://files.pythonhosted.org/packages/f7/9d/826b645f8bc5a132dae46726279a0bf31e244e6ae57e68baf7428dec9c63/librt-0.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4e5b64996f1f116b6ba9597a8ff9f098c240926abbd024d1bc8e2605b46f7590", size = 199284, upload-time = "2025-12-05T21:15:54.043Z" }, + { url = "https://files.pythonhosted.org/packages/1a/53/ede35873c7bf3aef1bfbb77f9d431933cfc1b2b3d7a2cb780ead4cc5a2ef/librt-0.7.0-cp312-cp312-win32.whl", hash = "sha256:fffb19b11f49c516b9cc4935e5ae01b07dfaf77b61f951c55ac9f51d3e9304aa", size = 47883, upload-time = "2025-12-05T21:15:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/e8/94/6bcf7a5f11d8e2026b855cec13a359de5f58c4f664dc112ed13c28a3e92c/librt-0.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:a914759833137621c8fab73ecc0701921689f7bd29bbc34fd9cadbc6057a5261", size = 54976, upload-time = "2025-12-05T21:15:56.592Z" }, + { url = "https://files.pythonhosted.org/packages/83/59/d8ac0b6c2a65c081f44764292e0fa6698958ad1b3adeaf67bce6284eba9b/librt-0.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:3cd85f9b52300cc0a748a72d8eba2f7998f03e1dfb44b8db6e2ca344f175e1a9", size = 48342, upload-time = "2025-12-05T21:15:57.627Z" }, + { url = "https://files.pythonhosted.org/packages/40/7d/226ae5e6b9872f89b81964fb8ac0ff831d96dee5215bc3084237e359b318/librt-0.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12753c83c2e29c7bb28627bbada0cfcf19e8225c6da98eb7c590b27743115298", size = 55755, upload-time = "2025-12-05T21:15:58.818Z" }, + { url = "https://files.pythonhosted.org/packages/15/f8/7d4317c85c1670bfa446b7b4dcc4bee24901238bf4b0384635139c48a99e/librt-0.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:654a2a2e6325fc4906200156c98e5ef898011d4ee998f8b4277d96356920703a", size = 57165, upload-time = "2025-12-05T21:15:59.858Z" }, + { url = "https://files.pythonhosted.org/packages/55/4f/10caa3a2002b884b965a6de1c090de8424d068239f4593c1de4e2e51d983/librt-0.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6ef7654f79590bef5cc2256ffc2e9d8fccf55752f70a45e26aaac74237ab8552", size = 165841, upload-time = "2025-12-05T21:16:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7a/b4b805d3c56c223609c2571e6e49eb675f1f0045c1ff8780d7b64e7c5c96/librt-0.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a0d0c70418e0c37c040a3acace252a21e25751f3fa96084facf24783d24fd5d", size = 174826, upload-time = "2025-12-05T21:16:02.296Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/7f3f4556fb42dd41e0d0e984386276609017fdf1584061c39512b05adea2/librt-0.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d601771f291cd28aaefe115b0c3105d36fdd7d0d0abcc23bb17714c17b370bb", size = 189614, upload-time = "2025-12-05T21:16:03.453Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/f73cd799f61f4d94a2bd5de55d2dbc1899ec7dbf80e0c21afbbda4a46ae4/librt-0.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49bf5cb376e120db09c2ab56fde3ce4d3933f496d74c749948964e11d1c7ada6", size = 184583, upload-time = "2025-12-05T21:16:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fb/2194f087c45a50d1e619f4387683071a7d1bae798d9eb15c87481e0ee49c/librt-0.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:350385b5f8d3f71686b4aa2181d654f01de50a0e4b11eb20fa36f5b00dc5c440", size = 178269, upload-time = "2025-12-05T21:16:05.618Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fc/4044ddbe1bcd619b103c1e8452745f3508afb853804ad10049e77fef6a2f/librt-0.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:43028b50350caf3f27168d7a5f824d23e3300f20eb2bcb99fe03f14568dad0fc", size = 199854, upload-time = "2025-12-05T21:16:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4e/0096f33d0dafe1007ee127d989513076e4f6a1a7fc7a4e45da4351fd09a4/librt-0.7.0-cp313-cp313-win32.whl", hash = "sha256:263cc4beae054d088292471434af6fc710eed357161f0d45c1783830cb5332b2", size = 47937, upload-time = "2025-12-05T21:16:07.932Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/5ff8f481a9634f0c4683ccd673d623e21ea24cb20a36b4fcd32ba1a33a30/librt-0.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:e95d45bfa4f207a9117ae7fb60c5cb0308eb77a924151a0b9a7d2fb70d8aec14", size = 54968, upload-time = "2025-12-05T21:16:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/6d/98/bd2acb50228e5677d1a2d1c4a81867ccd01a4fb60b08665de8d1fdfbe222/librt-0.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:2471e23a12599761e2f052a84dd359ba1d2b34d018d2d8039aa0f8865ee7a563", size = 48356, upload-time = "2025-12-05T21:16:10.379Z" }, + { url = "https://files.pythonhosted.org/packages/f7/00/9c76a4bae4c20b70c74d2b6f05e144884edc3c20f662674a58b6c1b32531/librt-0.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ca62bc77d6e2f1ece0e141c28e2778ff79f1ca50f7824a2d6237abe9397997f5", size = 55176, upload-time = "2025-12-05T21:16:11.411Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c3/d718f2fefd00a9904859c814020c7ac6b43cf2e8bd8d069f1a6d456ab002/librt-0.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba50f3f01eac1066409988a7b5dcf741a474917bdef0a645ed21525f2dae0fca", size = 56887, upload-time = "2025-12-05T21:16:12.803Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/e7047d3cfe7bd0991a1565c7bcc869d4bcb43efd19cceb7d16ade3d2ec4f/librt-0.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:912f87f7059bd07644c675a499fff1bc3d39aea324dc4a818bf1fb163ac11fe6", size = 163711, upload-time = "2025-12-05T21:16:13.834Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b1/17cf9cd3cabedb19b7d37210f868e07c1574b519ca9bec699c380b8e0a5e/librt-0.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c18415a23b465fc379a4a3e6e71c28f3263a111d6a0811c53b1d50ca9e1d7642", size = 172472, upload-time = "2025-12-05T21:16:15.201Z" }, + { url = "https://files.pythonhosted.org/packages/db/1f/85f3a5ab630b3708e7a4ed8564948b6c5a24f7c993b0b7edfa12a0a61c80/librt-0.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89563b5aaada1750e106d0b04953b147c07ac07507e79252413a7e2d59153990", size = 186806, upload-time = "2025-12-05T21:16:16.323Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a2/f39aea188b58e5df7f41cda2c39dd70a3b11fd9c2a870bfddeda8862d560/librt-0.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:51d899c7460cb30e68f7e83f4d68915127a8c7eaada7657702287e4c542f88d4", size = 181819, upload-time = "2025-12-05T21:16:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/a7a8368e2f9b10a2d1bf5e38f55e556ab04edb17fcf67b4a70aafff23b98/librt-0.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da5edaf3c650fa9955d7343d1e057fdfc1adb3484621847331d8f01c84de70cc", size = 175601, upload-time = "2025-12-05T21:16:18.964Z" }, + { url = "https://files.pythonhosted.org/packages/64/e2/177d28ac3bd194b9aa3e09fe46322f17fbc4653407e5aea1186c8840cddc/librt-0.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3c10fad1468457b2d13d824b7cde8946a4caa76f18fe127c7e549d1730ab271", size = 196498, upload-time = "2025-12-05T21:16:20.401Z" }, + { url = "https://files.pythonhosted.org/packages/22/9d/c39b6c8d107182f2fb02d016b5c5a411bfc707d9e123ba014e0e93fa6a75/librt-0.7.0-cp314-cp314-win32.whl", hash = "sha256:46293b0541a04909581084781aaa0c0c56d2b430a551717de2535e564f569127", size = 44681, upload-time = "2025-12-05T21:16:21.609Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/d765af577101dd06788e796c67b68c217689022fea820398abb0a75a1146/librt-0.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:369cf96ba818af4d14a95ce4d00f163cfa64d800ebb5a0f54556b9cb4346d97b", size = 51690, upload-time = "2025-12-05T21:16:22.641Z" }, + { url = "https://files.pythonhosted.org/packages/9c/53/6efce2008f26772dc3a5e3d522fff9b3c2168083cb005a7448ea9ba546be/librt-0.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ee41eff32c0d1c08f50c32cdd2c2314366cea3912074b68db95df8cc4015eab3", size = 44664, upload-time = "2025-12-05T21:16:23.645Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/39f743d4863f08ee54bdfc424d5cf076d2ad692c49baf66c270881a8279f/librt-0.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fcfe89d3bb67df63e2cb1e00a379bbc73720b43a4b8dd94ac4ca87ef32ec0f4d", size = 57348, upload-time = "2025-12-05T21:16:24.684Z" }, + { url = "https://files.pythonhosted.org/packages/96/99/999986f0a1f641e32a1356b529c53f49050f51b56e4040b73111638bdde3/librt-0.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4ce4baf7f74a5eb676a9688cf31ec8f25835cf84a3f129b781bde55daf267cf1", size = 59219, upload-time = "2025-12-05T21:16:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/2f/15/129fe95e827731a3acfc6c01f048d4de136fcc9d9a6508502bae915d08bc/librt-0.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bcd71a7ab212ca325013f968d06b72bb5ff83fb190dd582aa010e9c939a67050", size = 183861, upload-time = "2025-12-05T21:16:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/e0/cd/5c6fff81814e22284707af7b450e7517765d1c9ab5dadd7c4121542cdb93/librt-0.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1ed0aa6c0d97697559200f64bbf1c5f04767631d8494b2ace593f0a9353d63b", size = 194593, upload-time = "2025-12-05T21:16:27.905Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e7/444d93cca2291f9b6e6d7d5cc8f6735a744386f77d1aae7f6d092cb2209f/librt-0.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d2af9c598b2cb88e3d0afcd5caca0fdbb322a93c9043d7c7fad758b0375a5263", size = 206761, upload-time = "2025-12-05T21:16:29.032Z" }, + { url = "https://files.pythonhosted.org/packages/bc/24/9fe3f433874a5ff885ae2130cc118d646b781fd51fcfbc22d6f096eb626b/librt-0.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed2f2d991efb60218502b1a32f666cebb33deb904a176e8c36fcc8f7061f49b9", size = 203211, upload-time = "2025-12-05T21:16:30.229Z" }, + { url = "https://files.pythonhosted.org/packages/00/b3/000f57d8b6e844e7626304eb473703458cce87e029125b75de8a46f99f89/librt-0.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fee181b2f73c14d1f80380b91945305919e409748bc386008fe56e23e9b0652", size = 196708, upload-time = "2025-12-05T21:16:31.715Z" }, + { url = "https://files.pythonhosted.org/packages/76/fe/cd9d70b94ee946b1790e56cf91f72fbe54994bb2464d5c48de4a874da983/librt-0.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:867c904b6748dfa212f9de8f27537f1e51f9cc7a51474a3bdafe136d00608e45", size = 217213, upload-time = "2025-12-05T21:16:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/7b/63/979d5ac66be9e3972df374f6ece65966fa1e899277c3c91be659aa047ab4/librt-0.7.0-cp314-cp314t-win32.whl", hash = "sha256:af5ab2c4cf132cedba4359551c4f05ef2da00229aaae13e3f8a337171bb700d9", size = 45588, upload-time = "2025-12-05T21:16:34.136Z" }, + { url = "https://files.pythonhosted.org/packages/27/ad/808aeb6f24dd27864640234b2d6e12b495aaa50ecfdf064a35706ecdd85e/librt-0.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7ab208a759db0b607c785b8970d51ad101ebec7de4b13fbedafc4207508df85", size = 53003, upload-time = "2025-12-05T21:16:35.198Z" }, + { url = "https://files.pythonhosted.org/packages/0d/7c/2df7561c95d8703c5ce8a1c5ef98cd2e7ded4ccf6b215d5fa097e30c453c/librt-0.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506fd319530866802f9e63f28e3822e24a38dcf1814b5b6f54690bfdb55ee947", size = 45649, upload-time = "2025-12-05T21:16:36.238Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -871,6 +950,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, ] +[[package]] +name = "mypy" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/b5/b58cdc25fadd424552804bf410855d52324183112aa004f0732c5f6324cf/mypy-1.19.0.tar.gz", hash = "sha256:f6b874ca77f733222641e5c46e4711648c4037ea13646fd0cdc814c2eaec2528", size = 3579025, upload-time = "2025-11-28T15:49:01.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/8f/55fb488c2b7dabd76e3f30c10f7ab0f6190c1fcbc3e97b1e588ec625bbe2/mypy-1.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6148ede033982a8c5ca1143de34c71836a09f105068aaa8b7d5edab2b053e6c8", size = 13093239, upload-time = "2025-11-28T15:45:11.342Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/278beea978456c56b3262266274f335c3ba5ff2c8108b3b31bec1ffa4c1d/mypy-1.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a9ac09e52bb0f7fb912f5d2a783345c72441a08ef56ce3e17c1752af36340a39", size = 12156128, upload-time = "2025-11-28T15:46:02.566Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/e06f951902e136ff74fd7a4dc4ef9d884faeb2f8eb9c49461235714f079f/mypy-1.19.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f7254c15ab3f8ed68f8e8f5cbe88757848df793e31c36aaa4d4f9783fd08ab", size = 12753508, upload-time = "2025-11-28T15:44:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/67/5a/d035c534ad86e09cee274d53cf0fd769c0b29ca6ed5b32e205be3c06878c/mypy-1.19.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318ba74f75899b0e78b847d8c50821e4c9637c79d9a59680fc1259f29338cb3e", size = 13507553, upload-time = "2025-11-28T15:44:39.26Z" }, + { url = "https://files.pythonhosted.org/packages/6a/17/c4a5498e00071ef29e483a01558b285d086825b61cf1fb2629fbdd019d94/mypy-1.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf7d84f497f78b682edd407f14a7b6e1a2212b433eedb054e2081380b7395aa3", size = 13792898, upload-time = "2025-11-28T15:44:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/67/f6/bb542422b3ee4399ae1cdc463300d2d91515ab834c6233f2fd1d52fa21e0/mypy-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:c3385246593ac2b97f155a0e9639be906e73534630f663747c71908dfbf26134", size = 10048835, upload-time = "2025-11-28T15:48:15.744Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d2/010fb171ae5ac4a01cc34fbacd7544531e5ace95c35ca166dd8fd1b901d0/mypy-1.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a31e4c28e8ddb042c84c5e977e28a21195d086aaffaf08b016b78e19c9ef8106", size = 13010563, upload-time = "2025-11-28T15:48:23.975Z" }, + { url = "https://files.pythonhosted.org/packages/41/6b/63f095c9f1ce584fdeb595d663d49e0980c735a1d2004720ccec252c5d47/mypy-1.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34ec1ac66d31644f194b7c163d7f8b8434f1b49719d403a5d26c87fff7e913f7", size = 12077037, upload-time = "2025-11-28T15:47:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/6cb93d289038d809023ec20eb0b48bbb1d80af40511fa077da78af6ff7c7/mypy-1.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb64b0ba5980466a0f3f9990d1c582bcab8db12e29815ecb57f1408d99b4bff7", size = 12680255, upload-time = "2025-11-28T15:46:57.628Z" }, + { url = "https://files.pythonhosted.org/packages/99/db/d217815705987d2cbace2edd9100926196d6f85bcb9b5af05058d6e3c8ad/mypy-1.19.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:120cffe120cca5c23c03c77f84abc0c14c5d2e03736f6c312480020082f1994b", size = 13421472, upload-time = "2025-11-28T15:47:59.655Z" }, + { url = "https://files.pythonhosted.org/packages/4e/51/d2beaca7c497944b07594f3f8aad8d2f0e8fc53677059848ae5d6f4d193e/mypy-1.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7a500ab5c444268a70565e374fc803972bfd1f09545b13418a5174e29883dab7", size = 13651823, upload-time = "2025-11-28T15:45:29.318Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/7883dcf7644db3b69490f37b51029e0870aac4a7ad34d09ceae709a3df44/mypy-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:c14a98bc63fd867530e8ec82f217dae29d0550c86e70debc9667fff1ec83284e", size = 10049077, upload-time = "2025-11-28T15:45:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/11/7e/1afa8fb188b876abeaa14460dc4983f909aaacaa4bf5718c00b2c7e0b3d5/mypy-1.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0fb3115cb8fa7c5f887c8a8d81ccdcb94cff334684980d847e5a62e926910e1d", size = 13207728, upload-time = "2025-11-28T15:46:26.463Z" }, + { url = "https://files.pythonhosted.org/packages/b2/13/f103d04962bcbefb1644f5ccb235998b32c337d6c13145ea390b9da47f3e/mypy-1.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3e19e3b897562276bb331074d64c076dbdd3e79213f36eed4e592272dabd760", size = 12202945, upload-time = "2025-11-28T15:48:49.143Z" }, + { url = "https://files.pythonhosted.org/packages/e4/93/a86a5608f74a22284a8ccea8592f6e270b61f95b8588951110ad797c2ddd/mypy-1.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9d491295825182fba01b6ffe2c6fe4e5a49dbf4e2bb4d1217b6ced3b4797bc6", size = 12718673, upload-time = "2025-11-28T15:47:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/3d/58/cf08fff9ced0423b858f2a7495001fda28dc058136818ee9dffc31534ea9/mypy-1.19.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6016c52ab209919b46169651b362068f632efcd5eb8ef9d1735f6f86da7853b2", size = 13608336, upload-time = "2025-11-28T15:48:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/64/ed/9c509105c5a6d4b73bb08733102a3ea62c25bc02c51bca85e3134bf912d3/mypy-1.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f188dcf16483b3e59f9278c4ed939ec0254aa8a60e8fc100648d9ab5ee95a431", size = 13833174, upload-time = "2025-11-28T15:45:48.091Z" }, + { url = "https://files.pythonhosted.org/packages/cd/71/01939b66e35c6f8cb3e6fdf0b657f0fd24de2f8ba5e523625c8e72328208/mypy-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:0e3c3d1e1d62e678c339e7ade72746a9e0325de42cd2cccc51616c7b2ed1a018", size = 10112208, upload-time = "2025-11-28T15:46:41.702Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0d/a1357e6bb49e37ce26fcf7e3cc55679ce9f4ebee0cd8b6ee3a0e301a9210/mypy-1.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7686ed65dbabd24d20066f3115018d2dce030d8fa9db01aa9f0a59b6813e9f9e", size = 13191993, upload-time = "2025-11-28T15:47:22.336Z" }, + { url = "https://files.pythonhosted.org/packages/5d/75/8e5d492a879ec4490e6ba664b5154e48c46c85b5ac9785792a5ec6a4d58f/mypy-1.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd4a985b2e32f23bead72e2fb4bbe5d6aceee176be471243bd831d5b2644672d", size = 12174411, upload-time = "2025-11-28T15:44:55.492Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/ad5dcee9bfe226e8eaba777e9d9d251c292650130f0450a280aec3485370/mypy-1.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc51a5b864f73a3a182584b1ac75c404396a17eced54341629d8bdcb644a5bba", size = 12727751, upload-time = "2025-11-28T15:44:14.169Z" }, + { url = "https://files.pythonhosted.org/packages/77/06/b6b8994ce07405f6039701f4b66e9d23f499d0b41c6dd46ec28f96d57ec3/mypy-1.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37af5166f9475872034b56c5efdcf65ee25394e9e1d172907b84577120714364", size = 13593323, upload-time = "2025-11-28T15:46:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/68/b1/126e274484cccdf099a8e328d4fda1c7bdb98a5e888fa6010b00e1bbf330/mypy-1.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:510c014b722308c9bd377993bcbf9a07d7e0692e5fa8fc70e639c1eb19fc6bee", size = 13818032, upload-time = "2025-11-28T15:46:18.286Z" }, + { url = "https://files.pythonhosted.org/packages/f8/56/53a8f70f562dfc466c766469133a8a4909f6c0012d83993143f2a9d48d2d/mypy-1.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:cabbee74f29aa9cd3b444ec2f1e4fa5a9d0d746ce7567a6a609e224429781f53", size = 10120644, upload-time = "2025-11-28T15:47:43.99Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f4/7751f32f56916f7f8c229fe902cbdba3e4dd3f3ea9e8b872be97e7fc546d/mypy-1.19.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f2e36bed3c6d9b5f35d28b63ca4b727cb0228e480826ffc8953d1892ddc8999d", size = 13185236, upload-time = "2025-11-28T15:45:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/35/31/871a9531f09e78e8d145032355890384f8a5b38c95a2c7732d226b93242e/mypy-1.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a18d8abdda14035c5718acb748faec09571432811af129bf0d9e7b2d6699bf18", size = 12213902, upload-time = "2025-11-28T15:46:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/58/b8/af221910dd40eeefa2077a59107e611550167b9994693fc5926a0b0f87c0/mypy-1.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75e60aca3723a23511948539b0d7ed514dda194bc3755eae0bfc7a6b4887aa7", size = 12738600, upload-time = "2025-11-28T15:44:22.521Z" }, + { url = "https://files.pythonhosted.org/packages/11/9f/c39e89a3e319c1d9c734dedec1183b2cc3aefbab066ec611619002abb932/mypy-1.19.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f44f2ae3c58421ee05fe609160343c25f70e3967f6e32792b5a78006a9d850f", size = 13592639, upload-time = "2025-11-28T15:48:08.55Z" }, + { url = "https://files.pythonhosted.org/packages/97/6d/ffaf5f01f5e284d9033de1267e6c1b8f3783f2cf784465378a86122e884b/mypy-1.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63ea6a00e4bd6822adbfc75b02ab3653a17c02c4347f5bb0cf1d5b9df3a05835", size = 13799132, upload-time = "2025-11-28T15:47:06.032Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b0/c33921e73aaa0106224e5a34822411bea38046188eb781637f5a5b07e269/mypy-1.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ad925b14a0bb99821ff6f734553294aa6a3440a8cb082fe1f5b84dfb662afb1", size = 10269832, upload-time = "2025-11-28T15:47:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/09/0e/fe228ed5aeab470c6f4eb82481837fadb642a5aa95cc8215fd2214822c10/mypy-1.19.0-py3-none-any.whl", hash = "sha256:0c01c99d626380752e527d5ce8e69ffbba2046eb8a060db0329690849cf9b6f9", size = 2469714, upload-time = "2025-11-28T15:45:33.22Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "nh3" version = "0.3.2" @@ -1027,6 +1161,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] +[[package]] +name = "pandas-stubs" +version = "2.3.3.251201" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "types-pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/a6/491b2af2cb3ee232765a73fb273a44cc1ac33b154f7745b2df2ee1dc4d01/pandas_stubs-2.3.3.251201.tar.gz", hash = "sha256:7a980f4f08cff2a6d7e4c6d6d26f4c5fcdb82a6f6531489b2f75c81567fe4536", size = 107787, upload-time = "2025-12-01T18:29:22.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/68/78a3c253f146254b8e2c19f4a4768f272e12ef11001d9b45ec7b165db054/pandas_stubs-2.3.3.251201-py3-none-any.whl", hash = "sha256:eb5c9b6138bd8492fd74a47b09c9497341a278fcfbc8633ea4b35b230ebf4be5", size = 164638, upload-time = "2025-12-01T18:29:21.006Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1493,6 +1649,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "ruff" +version = "0.14.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" }, + { url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" }, + { url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" }, + { url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" }, + { url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" }, + { url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" }, + { url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" }, + { url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" }, + { url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" }, + { url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" }, + { url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" }, + { url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890, upload-time = "2025-12-04T15:06:11.668Z" }, + { url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826, upload-time = "2025-12-04T15:06:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" }, +] + [[package]] name = "scipy" version = "1.10.0" @@ -1616,6 +1798,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, ] +[[package]] +name = "types-pytz" +version = "2025.2.0.20251108" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/ff/c047ddc68c803b46470a357454ef76f4acd8c1088f5cc4891cdd909bfcf6/types_pytz-2025.2.0.20251108.tar.gz", hash = "sha256:fca87917836ae843f07129567b74c1929f1870610681b4c92cb86a3df5817bdb", size = 10961, upload-time = "2025-11-08T02:55:57.001Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c1/56ef16bf5dcd255155cc736d276efa6ae0a5c26fd685e28f0412a4013c01/types_pytz-2025.2.0.20251108-py3-none-any.whl", hash = "sha256:0f1c9792cab4eb0e46c52f8845c8f77cf1e313cb3d68bf826aa867fe4717d91c", size = 10116, upload-time = "2025-11-08T02:55:56.194Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 8e115d039ddfb171501f0e0001dd5fbabfa8a4cb Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 15:51:45 +0900 Subject: [PATCH 02/11] fixed all mypy errors --- examples/demo.py | 129 +++++++++++++++++++---------- examples/heatmap_demo.py | 66 ++++++++++----- examples/heatmap_poc.py | 37 +++++++-- examples/quick_start.py | 7 +- examples/us_map_utils.py | 23 +++-- fieldview/core/data_container.py | 7 +- fieldview/layers/data_layer.py | 7 +- fieldview/layers/heatmap_layer.py | 22 +++-- fieldview/layers/layer.py | 21 ++++- fieldview/layers/pin_layer.py | 22 ++++- fieldview/layers/svg_layer.py | 22 ++++- fieldview/layers/text_layer.py | 19 +++-- fieldview/rendering/colormaps.py | 9 +- fieldview/ui/color_range_widget.py | 22 ++++- fieldview/ui/data_table.py | 39 +++++++-- fieldview/ui/field_view.py | 35 ++++---- fieldview/utils/qt_compat.py | 18 ++-- main.py | 6 +- pyproject.toml | 7 +- scripts/capture_screenshot.py | 17 +++- tests/test_grid_manager.py | 7 +- tests/test_heatmap_layer.py | 10 ++- tests/test_misc_layers.py | 7 +- tests/test_text_layer.py | 12 ++- uv.lock | 17 ++++ 25 files changed, 439 insertions(+), 149 deletions(-) diff --git a/examples/demo.py b/examples/demo.py index 9e27b25..1fe143d 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -7,31 +7,74 @@ os.environ["VECLIB_MAXIMUM_THREADS"] = "1" os.environ["NUMEXPR_NUM_THREADS"] = "1" -from qtpy.QtWidgets import ( - QApplication, - QGraphicsView, - QGraphicsScene, - QMainWindow, - QWidget, - QVBoxLayout, - QHBoxLayout, - QSpinBox, - QDoubleSpinBox, - QComboBox, - QCheckBox, - QPushButton, - QHeaderView, - QLabel, - QTreeWidget, - QTreeWidgetItem, - QGraphicsEllipseItem, - QGraphicsLineItem, - QAbstractItemView, - QLineEdit, - QColorDialog, -) -from qtpy.QtGui import QPainter, QBrush, QPen, QColor, QPolygonF, QPainterPath -from qtpy.QtCore import Qt, QTimer, QPointF, QRectF +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QVBoxLayout, + QHBoxLayout, + QSpinBox, + QDoubleSpinBox, + QComboBox, + QCheckBox, + QPushButton, + QHeaderView, + QLabel, + QTreeWidget, + QTreeWidgetItem, + QGraphicsEllipseItem, + QGraphicsLineItem, + QAbstractItemView, + QLineEdit, + QColorDialog, + QGraphicsView, + QGraphicsScene, + ) + from PySide6.QtGui import ( + QPainter, + QBrush, + QPen, + QColor, + QPolygonF, + QPainterPath, + ) + from PySide6.QtCore import QTimer, QPointF, QRectF, Qt +else: + from qtpy.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QVBoxLayout, + QHBoxLayout, + QSpinBox, + QDoubleSpinBox, + QComboBox, + QCheckBox, + QPushButton, + QHeaderView, + QLabel, + QTreeWidget, + QTreeWidgetItem, + QGraphicsEllipseItem, + QGraphicsLineItem, + QAbstractItemView, + QLineEdit, + QColorDialog, + QGraphicsView, + QGraphicsScene, + ) + from qtpy.QtGui import ( + QPainter, + QBrush, + QPen, + QColor, + QPolygonF, + QPainterPath, + ) + from qtpy.QtCore import QTimer, QPointF, QRectF, Qt # Add project root to sys.path sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -269,8 +312,8 @@ def __init__(self): # 4. Setup View Dock self.view = QGraphicsView(self.scene) - self.view.setRenderHint(QPainter.Antialiasing) - self.view.setDragMode(QGraphicsView.ScrollHandDrag) + self.view.setRenderHint(QPainter.RenderHint.Antialiasing) + self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) @@ -283,20 +326,17 @@ def __init__(self): # 5. Initialize Polygon State self.polygon_handles = [] self.polygon_edges = [] - self.heatmap_polygon = QPolygonF( - [ - QPointF(-450, -330), - QPointF(-300, -330), - QPointF(-300, -250), - QPointF(-150, -250), - QPointF(-150, -300), - QPointF(150, -300), - QPointF(150, -250), - QPointF(450, -250), - QPointF(450, 250), - QPointF(-450, 250), - ] - ) + self.heatmap_polygon = QPolygonF() + self.heatmap_polygon.append(QPointF(-450, -330)) + self.heatmap_polygon.append(QPointF(-300, -330)) + self.heatmap_polygon.append(QPointF(-300, -250)) + self.heatmap_polygon.append(QPointF(-150, -250)) + self.heatmap_polygon.append(QPointF(-150, -300)) + self.heatmap_polygon.append(QPointF(150, -300)) + self.heatmap_polygon.append(QPointF(150, -250)) + self.heatmap_polygon.append(QPointF(450, -250)) + self.heatmap_polygon.append(QPointF(450, 250)) + self.heatmap_polygon.append(QPointF(-450, 250)) self.update_heatmap_polygon() self.toggle_polygon_handles(False) @@ -765,7 +805,12 @@ def update_polygon_handles(self): self.polygon_handles.append(handle) def on_handle_moved(self, index, new_pos): - self.heatmap_polygon[index] = new_pos + # QPolygonF modification workaround for PySide6 + points = [ + self.heatmap_polygon.at(i) for i in range(self.heatmap_polygon.count()) + ] + points[index] = new_pos + self.heatmap_polygon = QPolygonF(points) self.heatmap_layer.set_boundary_shape(self.heatmap_polygon) count = self.heatmap_polygon.count() edge1 = self.polygon_edges[index] diff --git a/examples/heatmap_demo.py b/examples/heatmap_demo.py index 66442f5..80602fb 100644 --- a/examples/heatmap_demo.py +++ b/examples/heatmap_demo.py @@ -2,22 +2,42 @@ import os import numpy as np import pandas as pd -from qtpy.QtWidgets import ( - QApplication, - QMainWindow, - QGraphicsView, - QGraphicsScene, - QWidget, - QVBoxLayout, - QPushButton, - QLabel, - QFileDialog, - QDockWidget, - QGroupBox, - QComboBox, -) -from qtpy.QtGui import QPainter, QPolygonF -from qtpy.QtCore import Qt, QPointF +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtWidgets import ( + QApplication, + QMainWindow, + QGraphicsView, + QGraphicsScene, + QWidget, + QVBoxLayout, + QPushButton, + QLabel, + QFileDialog, + QDockWidget, + QGroupBox, + QComboBox, + ) + from PySide6.QtGui import QPainter, QPolygonF + from PySide6.QtCore import Qt, QPointF +else: + from qtpy.QtWidgets import ( + QApplication, + QMainWindow, + QGraphicsView, + QGraphicsScene, + QWidget, + QVBoxLayout, + QPushButton, + QLabel, + QFileDialog, + QDockWidget, + QGroupBox, + QComboBox, + ) + from qtpy.QtGui import QPainter, QPolygonF + from qtpy.QtCore import Qt, QPointF # Add project root to sys.path to import fieldview modules sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -47,8 +67,8 @@ def __init__(self): # View self.view = QGraphicsView(self.scene) - self.view.setRenderHint(QPainter.Antialiasing) - self.view.setDragMode(QGraphicsView.ScrollHandDrag) + self.view.setRenderHint(QPainter.RenderHint.Antialiasing) + self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) self.setCentralWidget(self.view) # Controls (Dock) @@ -66,7 +86,9 @@ def __init__(self): def setup_controls(self): dock = QDockWidget("Controls", self) - dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) + dock.setAllowedAreas( + Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea + ) widget = QWidget() layout = QVBoxLayout(widget) @@ -131,7 +153,7 @@ def setup_controls(self): layout.addStretch() dock.setWidget(widget) - self.addDockWidget(Qt.RightDockWidgetArea, dock) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) def load_csv(self): filename, _ = QFileDialog.getOpenFileName( @@ -253,9 +275,7 @@ def update_status(self): if __name__ == "__main__": - from qtpy.QtGui import ( - QPainter, - ) # Import here to avoid circular dependency issues if any + # QPainter is already imported at top level app = QApplication(sys.argv) window = HeatmapDemo() diff --git a/examples/heatmap_poc.py b/examples/heatmap_poc.py index 52c84b4..c6c518c 100644 --- a/examples/heatmap_poc.py +++ b/examples/heatmap_poc.py @@ -3,15 +3,34 @@ import numpy as np from scipy.interpolate import RBFInterpolator, LinearNDInterpolator from scipy.spatial import cKDTree -from qtpy.QtWidgets import ( - QApplication, - QWidget, - QVBoxLayout, - QPushButton, - QLabel, -) -from qtpy.QtGui import QImage, QColor, QPainter -from qtpy.QtCore import Qt, QTimer, QPoint +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtWidgets import ( + QApplication, + QMainWindow, + QGraphicsView, + QGraphicsScene, + QWidget, + QVBoxLayout, + QPushButton, + QLabel, + ) + from PySide6.QtGui import QPainter, QColor, QPolygonF, QImage + from PySide6.QtCore import Qt, QTimer, QPoint +else: + from qtpy.QtWidgets import ( + QApplication, + QMainWindow, + QGraphicsView, + QGraphicsScene, + QWidget, + QVBoxLayout, + QPushButton, + QLabel, + ) + from qtpy.QtGui import QPainter, QColor, QPolygonF, QImage + from qtpy.QtCore import Qt, QTimer, QPoint def generate_data(n_points=25, radius=150): diff --git a/examples/quick_start.py b/examples/quick_start.py index 14f1b06..336b567 100644 --- a/examples/quick_start.py +++ b/examples/quick_start.py @@ -1,6 +1,11 @@ import sys import os -from qtpy.QtWidgets import QApplication +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtWidgets import QApplication +else: + from qtpy.QtWidgets import QApplication sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from fieldview import FieldView diff --git a/examples/us_map_utils.py b/examples/us_map_utils.py index b93dafa..1e4591a 100644 --- a/examples/us_map_utils.py +++ b/examples/us_map_utils.py @@ -3,9 +3,14 @@ import json import numpy as np import xml.etree.ElementTree as ET -from qtpy.QtGui import QPainterPath, QPolygonF, QPainterPathStroker -from qtpy.QtCore import Qt -from typing import Dict, Tuple, List +from typing import TYPE_CHECKING, Dict, Tuple, List + +if TYPE_CHECKING: + from PySide6.QtGui import QPainterPath, QPolygonF, QPainterPathStroker + from PySide6.QtCore import Qt +else: + from qtpy.QtGui import QPainterPath, QPolygonF, QPainterPathStroker + from qtpy.QtCore import Qt def parse_svg_path_to_qpainterpath(d_str: str) -> QPainterPath: @@ -57,7 +62,9 @@ def flush_command(): return path -def get_state_data(svg_path: str) -> Tuple[Dict[str, QPainterPath], Dict[str, Tuple[float, float]]]: +def get_state_data( + svg_path: str, +) -> Tuple[Dict[str, QPainterPath], Dict[str, Tuple[float, float]]]: """Parses the US map SVG to get state paths and centroids.""" if not os.path.exists(svg_path): print(f"Error: {svg_path} not found") @@ -109,8 +116,8 @@ def get_us_boundary(state_paths: Dict[str, QPainterPath]) -> QPainterPath: # 0. Merge all state paths into a single outline to remove internal borders/gaps stroker = QPainterPathStroker() stroker.setWidth(1.0) # Small overlap to seal cracks - stroker.setJoinStyle(Qt.RoundJoin) - stroker.setCapStyle(Qt.RoundCap) + stroker.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + stroker.setCapStyle(Qt.PenCapStyle.RoundCap) stroke_path = stroker.createStroke(us_boundary) us_boundary = us_boundary.united(stroke_path) @@ -176,7 +183,9 @@ def load_weather_data() -> Dict[str, float]: return real_weather_data -def generate_us_dataset(centroids: Dict[str, Tuple[float, float]], weather_data: Dict[str, float]) -> Tuple[np.ndarray, np.ndarray]: +def generate_us_dataset( + centroids: Dict[str, Tuple[float, float]], weather_data: Dict[str, float] +) -> Tuple[np.ndarray, np.ndarray]: """Generates points and values arrays for the US map.""" points = [] values = [] diff --git a/fieldview/core/data_container.py b/fieldview/core/data_container.py index 38cc502..989a707 100644 --- a/fieldview/core/data_container.py +++ b/fieldview/core/data_container.py @@ -1,5 +1,10 @@ import numpy as np -from qtpy.QtCore import QObject, Signal +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtCore import QObject, Signal +else: + from qtpy.QtCore import QObject, Signal class DataContainer(QObject): diff --git a/fieldview/layers/data_layer.py b/fieldview/layers/data_layer.py index 4905bda..9419344 100644 --- a/fieldview/layers/data_layer.py +++ b/fieldview/layers/data_layer.py @@ -1,4 +1,9 @@ -from qtpy.QtCore import QRectF +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtCore import QRectF +else: + from qtpy.QtCore import QRectF import numpy as np from fieldview.layers.layer import Layer from fieldview.core.data_container import DataContainer diff --git a/fieldview/layers/heatmap_layer.py b/fieldview/layers/heatmap_layer.py index 79dc271..395e599 100644 --- a/fieldview/layers/heatmap_layer.py +++ b/fieldview/layers/heatmap_layer.py @@ -1,9 +1,17 @@ import numpy as np import time from fieldview.utils.grid_manager import InterpolatorCache -from qtpy.QtGui import QImage, QPainter, QPolygonF, QPainterPath -from qtpy.QtCore import QTimer, QRectF, Signal -from typing import Optional, Literal, Union +from typing import TYPE_CHECKING, Optional, Literal, Union + +if TYPE_CHECKING: + from PySide6.QtGui import QImage, QPainter, QPolygonF, QPainterPath + from PySide6.QtCore import QTimer, QRectF, Signal + from PySide6.QtWidgets import QStyleOptionGraphicsItem, QWidget +else: + from qtpy.QtGui import QImage, QPainter, QPolygonF, QPainterPath + from qtpy.QtCore import QTimer, QRectF, Signal + from qtpy.QtWidgets import QStyleOptionGraphicsItem, QWidget + from fieldview.rendering.colormaps import ColormapName, get_colormap from fieldview.layers.data_layer import DataLayer @@ -20,7 +28,6 @@ ] - # Tiered grid sizes to prevent cache thrashing TIERS = [50, 100, 150, 200, 250, 300, 400, 500] @@ -480,7 +487,12 @@ def _array_to_qimage(self, Z: np.ndarray) -> QImage: # and 'buffer' might be garbage collected after this function returns. return image.copy() - def paint(self, painter, option, widget): + def paint( + self, + painter: "QPainter", + option: "QStyleOptionGraphicsItem", + widget: Optional["QWidget"] = None, + ) -> None: if self._cached_image: # Clip to polygon path = QPainterPath() diff --git a/fieldview/layers/layer.py b/fieldview/layers/layer.py index b610cc6..78677c4 100644 --- a/fieldview/layers/layer.py +++ b/fieldview/layers/layer.py @@ -1,5 +1,15 @@ -from qtpy.QtWidgets import QGraphicsObject -from qtpy.QtCore import QRectF +from qtpy.QtWidgets import QGraphicsObject, QStyleOptionGraphicsItem, QWidget +from qtpy.QtGui import QPainter +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from PySide6.QtCore import QRectF + from PySide6.QtWidgets import QStyleOptionGraphicsItem, QWidget + from PySide6.QtGui import QPainter +else: + from qtpy.QtCore import QRectF + from qtpy.QtWidgets import QStyleOptionGraphicsItem, QWidget + from qtpy.QtGui import QPainter class Layer(QGraphicsObject): @@ -22,7 +32,12 @@ def set_bounding_rect(self, rect): self.prepareGeometryChange() self.update() - def paint(self, painter, option, widget): + def paint( + self, + painter: "QPainter", + option: "QStyleOptionGraphicsItem", + widget: Optional["QWidget"] = None, + ) -> None: """ Default paint implementation. Subclasses should override this. """ diff --git a/fieldview/layers/pin_layer.py b/fieldview/layers/pin_layer.py index 1aac37c..12f9c25 100644 --- a/fieldview/layers/pin_layer.py +++ b/fieldview/layers/pin_layer.py @@ -1,6 +1,15 @@ -from qtpy.QtGui import QPixmap -from qtpy.QtCore import QRectF, QPointF, Qt +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtGui import QPixmap, QPainter + from PySide6.QtCore import QRectF, QPointF, Qt + from PySide6.QtWidgets import QStyleOptionGraphicsItem, QWidget +else: + from qtpy.QtGui import QPixmap, QPainter + from qtpy.QtCore import QRectF, QPointF, Qt + from qtpy.QtWidgets import QStyleOptionGraphicsItem, QWidget from fieldview.layers.data_layer import DataLayer +from typing import Optional class PinLayer(DataLayer): @@ -21,7 +30,12 @@ def set_icon(self, icon: QPixmap): self._icon = icon self.update_layer() - def paint(self, painter, option, widget): + def paint( + self, + painter: "QPainter", + option: "QStyleOptionGraphicsItem", + widget: Optional["QWidget"] = None, + ) -> None: points, _, _ = self.get_valid_data() if self._icon: @@ -33,7 +47,7 @@ def paint(self, painter, option, widget): else: # Default: Black dot painter.setBrush(Qt.GlobalColor.black) - painter.setPen(Qt.NoPen) + painter.setPen(Qt.PenStyle.NoPen) r = 3 # Radius for x, y in points: painter.drawEllipse(QPointF(x, y), r, r) diff --git a/fieldview/layers/svg_layer.py b/fieldview/layers/svg_layer.py index 20dc7d8..b6ab456 100644 --- a/fieldview/layers/svg_layer.py +++ b/fieldview/layers/svg_layer.py @@ -1,6 +1,17 @@ -from qtpy.QtSvg import QSvgRenderer -from qtpy.QtCore import QRectF, QPointF +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtSvg import QSvgRenderer + from PySide6.QtCore import QRectF, QPointF + from PySide6.QtGui import QPainter + from PySide6.QtWidgets import QStyleOptionGraphicsItem, QWidget +else: + from qtpy.QtSvg import QSvgRenderer + from qtpy.QtCore import QRectF, QPointF + from qtpy.QtGui import QPainter + from qtpy.QtWidgets import QStyleOptionGraphicsItem, QWidget from fieldview.layers.layer import Layer +from typing import Optional class SvgLayer(Layer): @@ -55,7 +66,12 @@ def load_svg(self, path): else: print(f"Failed to load SVG: {path}") - def paint(self, painter, option, widget): + def paint( + self, + painter: "QPainter", + option: "QStyleOptionGraphicsItem", + widget: Optional["QWidget"] = None, + ) -> None: if self._renderer.isValid(): # Render SVG to fit the bounding rect self._renderer.render(painter, self.boundingRect()) diff --git a/fieldview/layers/text_layer.py b/fieldview/layers/text_layer.py index 2cea810..3813e7c 100644 --- a/fieldview/layers/text_layer.py +++ b/fieldview/layers/text_layer.py @@ -1,9 +1,15 @@ -from qtpy.QtGui import QPainter, QColor, QFont, QFontMetrics, QFontDatabase -from qtpy.QtCore import Qt, QRectF, QPointF -from qtpy.QtWidgets import QStyleOptionGraphicsItem, QWidget +from typing import TYPE_CHECKING, Optional, List, Dict, Tuple, Set, Union + +if TYPE_CHECKING: + from PySide6.QtGui import QPainter, QColor, QFont, QFontMetrics, QFontDatabase + from PySide6.QtCore import Qt, QRectF, QPointF + from PySide6.QtWidgets import QStyleOptionGraphicsItem, QWidget +else: + from qtpy.QtGui import QPainter, QColor, QFont, QFontMetrics, QFontDatabase + from qtpy.QtCore import Qt, QRectF, QPointF + from qtpy.QtWidgets import QStyleOptionGraphicsItem, QWidget import os import numpy as np -from typing import Optional, List, Dict, Set, Union from fieldview.layers.data_layer import DataLayer @@ -219,8 +225,9 @@ def _calculate_layout( chosen_rect = QRectF(rect) chosen_rect.moveCenter(QPointF(x, y)) - layout[i] = chosen_rect - placed_rects.append(chosen_rect) + if chosen_rect is not None: + layout[i] = chosen_rect + placed_rects.append(chosen_rect) return layout diff --git a/fieldview/rendering/colormaps.py b/fieldview/rendering/colormaps.py index 86b443c..37ea84b 100644 --- a/fieldview/rendering/colormaps.py +++ b/fieldview/rendering/colormaps.py @@ -1,6 +1,11 @@ -from qtpy.QtGui import QColor +from typing import TYPE_CHECKING, List, Tuple, Dict, Literal, Union, Optional + +if TYPE_CHECKING: + from PySide6.QtGui import QColor +else: + from qtpy.QtGui import QColor import numpy as np -from typing import List, Tuple, Dict, Literal, Union, Optional + ColormapName = Literal["viridis", "plasma", "inferno", "magma", "coolwarm", "jet"] diff --git a/fieldview/ui/color_range_widget.py b/fieldview/ui/color_range_widget.py index c638a51..7816fac 100644 --- a/fieldview/ui/color_range_widget.py +++ b/fieldview/ui/color_range_widget.py @@ -1,6 +1,26 @@ + from qtpy.QtCore import Signal from qtpy.QtGui import QPainter, QLinearGradient, QColor -from qtpy.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QDoubleSpinBox, + ) + from PySide6.QtCore import Signal, Qt +else: + from qtpy.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QDoubleSpinBox, + ) + from qtpy.QtCore import Signal, Qt from fieldview.rendering.colormaps import get_colormap diff --git a/fieldview/ui/data_table.py b/fieldview/ui/data_table.py index 4eb37fd..518a13c 100644 --- a/fieldview/ui/data_table.py +++ b/fieldview/ui/data_table.py @@ -1,6 +1,16 @@ from qtpy.QtWidgets import QTableView, QHeaderView, QMenu, QAction -from qtpy.QtCore import Qt, QAbstractTableModel, QModelIndex -from typing import List, Set, Any +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtCore import ( + Qt, + QAbstractTableModel, + QModelIndex, + QPersistentModelIndex, + ) +else: + from qtpy.QtCore import Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex +from typing import List, Set, Any, Union from fieldview.core.data_container import DataContainer @@ -20,13 +30,21 @@ def __init__(self, data_container: DataContainer): self._headers = ["Highlight", "Exclude", "X", "Y", "Value", "Label"] self._visible_columns = [True] * len(self._headers) - def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + def rowCount( + self, parent: Union["QModelIndex", "QPersistentModelIndex"] = QModelIndex() + ) -> int: return len(self._data_container.points) - def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + def columnCount( + self, parent: Union["QModelIndex", "QPersistentModelIndex"] = QModelIndex() + ) -> int: return len(self._headers) - def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + def data( + self, + index: Union["QModelIndex", "QPersistentModelIndex"], + role: int = Qt.ItemDataRole.DisplayRole, + ) -> Any: if not index.isValid(): return None row, col = index.row(), index.column() @@ -57,7 +75,10 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A return None def setData( - self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole + self, + index: Union["QModelIndex", "QPersistentModelIndex"], + value: Any, + role: int = Qt.ItemDataRole.EditRole, ) -> bool: if not index.isValid(): return False @@ -112,7 +133,9 @@ def headerData( return self._headers[section] return None - def flags(self, index: QModelIndex) -> Qt.ItemFlag: + def flags( + self, index: Union["QModelIndex", "QPersistentModelIndex"] + ) -> Qt.ItemFlag: flags = super().flags(index) if index.column() in (0, 1): flags |= Qt.ItemFlag.ItemIsUserCheckable @@ -167,7 +190,7 @@ def _show_header_menu(self, pos): menu.exec(header.mapToGlobal(pos)) def _toggle_column(self): - action = self.sender() + action: QAction = self.sender() # type: ignore col_idx = action.data() if action.isChecked(): self.showColumn(col_idx) diff --git a/fieldview/ui/field_view.py b/fieldview/ui/field_view.py index 4042a26..336b300 100644 --- a/fieldview/ui/field_view.py +++ b/fieldview/ui/field_view.py @@ -1,6 +1,13 @@ -from qtpy.QtWidgets import QGraphicsView, QGraphicsScene -from qtpy.QtCore import Qt -from qtpy.QtGui import QColor, QPainter +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtWidgets import QGraphicsView, QGraphicsScene + from PySide6.QtCore import Qt + from PySide6.QtGui import QColor, QPainter +else: + from qtpy.QtWidgets import QGraphicsView, QGraphicsScene + from qtpy.QtCore import Qt + from qtpy.QtGui import QColor, QPainter from fieldview.core.data_container import DataContainer from fieldview.layers.heatmap_layer import HeatmapLayer @@ -19,13 +26,13 @@ def __init__(self, parent=None): super().__init__(parent) # Core components - self.scene = QGraphicsScene(self) - self.setScene(self.scene) + self._scene = QGraphicsScene(self) + self.setScene(self._scene) self.data_container = DataContainer() # View settings - self.setRenderHint(QPainter.Antialiasing) - self.setDragMode(QGraphicsView.ScrollHandDrag) + self.setRenderHint(QPainter.RenderHint.Antialiasing) + self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) self.setBackgroundBrush(QColor(30, 30, 30)) @@ -42,7 +49,7 @@ def add_heatmap_layer(self, opacity=0.6, z_value=0): layer = HeatmapLayer(self.data_container) layer.setOpacity(opacity) layer.setZValue(z_value) - self.scene.addItem(layer) + self._scene.addItem(layer) self.layers["heatmap"] = layer return layer @@ -51,7 +58,7 @@ def add_svg_layer(self, file_path, z_value=-1): layer = SvgLayer() layer.load_svg(file_path) layer.setZValue(z_value) - self.scene.addItem(layer) + self._scene.addItem(layer) self.layers["svg"] = layer return layer @@ -59,7 +66,7 @@ def add_pin_layer(self, z_value=10): """Adds a pin layer for data points.""" layer = PinLayer(self.data_container) layer.setZValue(z_value) - self.scene.addItem(layer) + self._scene.addItem(layer) self.layers["pin"] = layer return layer @@ -67,7 +74,7 @@ def add_value_layer(self, z_value=5): """Adds a layer displaying value text.""" layer = ValueLayer(self.data_container) layer.setZValue(z_value) - self.scene.addItem(layer) + self._scene.addItem(layer) self.layers["value"] = layer return layer @@ -75,11 +82,11 @@ def add_label_layer(self, z_value=5): """Adds a layer displaying label text.""" layer = LabelLayer(self.data_container) layer.setZValue(z_value) - self.scene.addItem(layer) + self._scene.addItem(layer) self.layers["label"] = layer return layer def fit_to_scene(self): """Fits the view to the scene content.""" - self.scene.setSceneRect(self.scene.itemsBoundingRect()) - self.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + self._scene.setSceneRect(self._scene.itemsBoundingRect()) + self.fitInView(self._scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) diff --git a/fieldview/utils/qt_compat.py b/fieldview/utils/qt_compat.py index 82cb32d..eb0d7d7 100644 --- a/fieldview/utils/qt_compat.py +++ b/fieldview/utils/qt_compat.py @@ -1,5 +1,13 @@ -try: - from qtpy.QtSvgWidgets import QGraphicsSvgItem as QGraphicsSvgItem -except ImportError: - # PyQt5 compatibility - pass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtSvgWidgets import QGraphicsSvgItem +else: + try: + from qtpy.QtSvgWidgets import QGraphicsSvgItem + except ImportError: + # Fallback for older Qt versions or different bindings + try: + from qtpy.QtSvg import QGraphicsSvgItem as QGraphicsSvgItem + except ImportError: + pass diff --git a/main.py b/main.py index a06f6d9..9e083bb 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,9 @@ if __name__ == "__main__": - from qtpy.QtWidgets import QApplication + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from PySide6.QtWidgets import QApplication + else: + from qtpy.QtWidgets import QApplication from examples.demo import DemoApp app = QApplication([]) diff --git a/pyproject.toml b/pyproject.toml index 39c0fea..c280def 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,8 @@ dev = [ "twine", "aiohttp", "PySide6-QtAds", + "pyside6", + "pyside6-stubs", "ruff", "mypy", "pandas-stubs", @@ -51,11 +53,10 @@ check_untyped_defs = true [[tool.mypy.overrides]] module = [ - "qtpy", - "qtpy.*", "scipy.*", "PySide6QtAds.*", ] ignore_missing_imports = true ignore_errors = true -follow_imports = "skip" + + diff --git a/scripts/capture_screenshot.py b/scripts/capture_screenshot.py index 4a8f238..fb3513b 100644 --- a/scripts/capture_screenshot.py +++ b/scripts/capture_screenshot.py @@ -15,9 +15,16 @@ # Add project root to path BEFORE importing fieldview or qtpy sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from qtpy.QtWidgets import QApplication, QGraphicsView, QGraphicsScene -from qtpy.QtGui import QImage, QPainter, QColor -from qtpy.QtCore import Qt, QTimer +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtWidgets import QApplication, QGraphicsView, QGraphicsScene + from PySide6.QtGui import QImage, QPainter, QColor + from PySide6.QtCore import QTimer, Qt +else: + from qtpy.QtWidgets import QApplication, QGraphicsView, QGraphicsScene + from qtpy.QtGui import QImage, QPainter, QColor + from qtpy.QtCore import QTimer, Qt from fieldview.core.data_container import DataContainer from fieldview.layers.heatmap_layer import HeatmapLayer @@ -59,7 +66,9 @@ def capture(): # 3. Setup Scene scene = QGraphicsScene(0, 0, 959, 593) - scene.setBackgroundBrush(Qt.white) # Solid white background by user request + scene.setBackgroundBrush( + Qt.GlobalColor.white + ) # Solid white background by user request # 4. Layers # SVG Map diff --git a/tests/test_grid_manager.py b/tests/test_grid_manager.py index 06d967d..2ac769c 100644 --- a/tests/test_grid_manager.py +++ b/tests/test_grid_manager.py @@ -1,6 +1,11 @@ import numpy as np from qtpy.QtGui import QPolygonF -from qtpy.QtCore import QRectF +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtCore import QRectF +else: + from qtpy.QtCore import QRectF from fieldview.utils.grid_manager import InterpolatorCache diff --git a/tests/test_heatmap_layer.py b/tests/test_heatmap_layer.py index 4e2719a..c46e608 100644 --- a/tests/test_heatmap_layer.py +++ b/tests/test_heatmap_layer.py @@ -1,7 +1,13 @@ import pytest import numpy as np -from qtpy.QtCore import QRectF -from qtpy.QtGui import QPainterPath, QColor +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtCore import QRectF + from PySide6.QtGui import QPainterPath, QColor +else: + from qtpy.QtCore import QRectF + from qtpy.QtGui import QPainterPath, QColor from fieldview.core.data_container import DataContainer from fieldview.layers.heatmap_layer import HeatmapLayer diff --git a/tests/test_misc_layers.py b/tests/test_misc_layers.py index 2ea2fc2..99547c8 100644 --- a/tests/test_misc_layers.py +++ b/tests/test_misc_layers.py @@ -1,4 +1,9 @@ -from qtpy.QtGui import QPixmap +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtGui import QPixmap +else: + from qtpy.QtGui import QPixmap from fieldview.core.data_container import DataContainer from fieldview.layers.svg_layer import SvgLayer from fieldview.layers.pin_layer import PinLayer diff --git a/tests/test_text_layer.py b/tests/test_text_layer.py index 8abdfed..0fa3eee 100644 --- a/tests/test_text_layer.py +++ b/tests/test_text_layer.py @@ -1,4 +1,11 @@ -from qtpy.QtGui import QFont +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtGui import QFont + from PySide6.QtWidgets import QStyleOptionGraphicsItem +else: + from qtpy.QtGui import QFont + from qtpy.QtWidgets import QStyleOptionGraphicsItem from fieldview.core.data_container import DataContainer from fieldview.layers.text_layer import ValueLayer, LabelLayer @@ -58,7 +65,8 @@ def record_get_text(idx, value, label): return "recorded" monkeypatch.setattr(layer, "_get_text", record_get_text) - layer.paint(painter, None, None) + option = QStyleOptionGraphicsItem() + layer.paint(painter, option, None) painter.end() assert set(recorded_indices) == {0, 2} diff --git a/uv.lock b/uv.lock index d92b719..98cbcc0 100644 --- a/uv.lock +++ b/uv.lock @@ -451,7 +451,9 @@ dev = [ { name = "pandas", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "pandas-stubs" }, + { name = "pyside6" }, { name = "pyside6-qtads" }, + { name = "pyside6-stubs" }, { name = "pytest" }, { name = "pytest-qt" }, { name = "ruff" }, @@ -476,7 +478,9 @@ dev = [ { name = "mypy" }, { name = "pandas", specifier = ">=2.0.0" }, { name = "pandas-stubs" }, + { name = "pyside6" }, { name = "pyside6-qtads" }, + { name = "pyside6-stubs" }, { name = "pytest", specifier = ">=9.0.1" }, { name = "pytest-qt", specifier = ">=4.5.0" }, { name = "ruff" }, @@ -1512,6 +1516,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/20/1749225bd14071335608ff4bba485e0b66db7cccf08d8e21a04eecb8ed74/PySide6_QtAds-4.1.0.2-cp38-abi3-win_amd64.whl", hash = "sha256:c84ddbf19bbc7c8cd4af6d50d0a96b9711933be134fe6131604591d7a8711f33", size = 453184, upload-time = "2023-11-15T17:17:37.006Z" }, ] +[[package]] +name = "pyside6-stubs" +version = "6.7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy" }, + { name = "pyside6" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/2b/d2a3fdb212475682b11e8b6330f2cada60dcbbaf151f73f86844b11d1db6/pyside6_stubs-6.7.3.0.tar.gz", hash = "sha256:db8def2bb9c74091bfa2a4df7610d3ea344ecd00ebd4283995eeb5fda8383603", size = 509931, upload-time = "2025-03-04T05:49:15.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/2e/3488fa3baca56ff494b9502424675f5b14b11e966eb74d5adaf1811cd651/pyside6_stubs-6.7.3.0-py3-none-any.whl", hash = "sha256:7a2ef0d7486939f240e745829f80887ec3eab280e52c9930d64b48b3de95042a", size = 550616, upload-time = "2025-03-04T05:49:13.154Z" }, +] + [[package]] name = "pytest" version = "9.0.1" From df1051ce06dc48153c4d360dbb726c0d81cb0ba7 Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 15:53:49 +0900 Subject: [PATCH 03/11] fixed ruff check --- examples/heatmap_poc.py | 10 ++-------- fieldview/layers/text_layer.py | 2 +- fieldview/ui/color_range_widget.py | 5 ++--- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/examples/heatmap_poc.py b/examples/heatmap_poc.py index c6c518c..0c697d7 100644 --- a/examples/heatmap_poc.py +++ b/examples/heatmap_poc.py @@ -8,28 +8,22 @@ if TYPE_CHECKING: from PySide6.QtWidgets import ( QApplication, - QMainWindow, - QGraphicsView, - QGraphicsScene, QWidget, QVBoxLayout, QPushButton, QLabel, ) - from PySide6.QtGui import QPainter, QColor, QPolygonF, QImage + from PySide6.QtGui import QPainter, QColor, QImage from PySide6.QtCore import Qt, QTimer, QPoint else: from qtpy.QtWidgets import ( QApplication, - QMainWindow, - QGraphicsView, - QGraphicsScene, QWidget, QVBoxLayout, QPushButton, QLabel, ) - from qtpy.QtGui import QPainter, QColor, QPolygonF, QImage + from qtpy.QtGui import QPainter, QColor, QImage from qtpy.QtCore import Qt, QTimer, QPoint diff --git a/fieldview/layers/text_layer.py b/fieldview/layers/text_layer.py index 3813e7c..2883e6f 100644 --- a/fieldview/layers/text_layer.py +++ b/fieldview/layers/text_layer.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Optional, List, Dict, Tuple, Set, Union +from typing import TYPE_CHECKING, Optional, List, Dict, Set, Union if TYPE_CHECKING: from PySide6.QtGui import QPainter, QColor, QFont, QFontMetrics, QFontDatabase diff --git a/fieldview/ui/color_range_widget.py b/fieldview/ui/color_range_widget.py index 7816fac..4d59c85 100644 --- a/fieldview/ui/color_range_widget.py +++ b/fieldview/ui/color_range_widget.py @@ -1,4 +1,3 @@ - from qtpy.QtCore import Signal from qtpy.QtGui import QPainter, QLinearGradient, QColor from typing import TYPE_CHECKING @@ -11,7 +10,7 @@ QLabel, QDoubleSpinBox, ) - from PySide6.QtCore import Signal, Qt + from PySide6.QtCore import Signal else: from qtpy.QtWidgets import ( QWidget, @@ -20,7 +19,7 @@ QLabel, QDoubleSpinBox, ) - from qtpy.QtCore import Signal, Qt + from qtpy.QtCore import Signal from fieldview.rendering.colormaps import get_colormap From b753e47fff7446efd46bd24c1dbe149c63d66b7b Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 16:22:19 +0900 Subject: [PATCH 04/11] added ruff and mypy check badge --- .github/workflows/{ci.yml => quality.yml} | 2 +- .github/workflows/tests.yml | 50 ++++++++--------------- .gitignore | 2 + README.md | 5 +++ 4 files changed, 26 insertions(+), 33 deletions(-) rename .github/workflows/{ci.yml => quality.yml} (97%) diff --git a/.github/workflows/ci.yml b/.github/workflows/quality.yml similarity index 97% rename from .github/workflows/ci.yml rename to .github/workflows/quality.yml index c9a33bc..8282659 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/quality.yml @@ -1,4 +1,4 @@ -name: CI +name: Quality on: push: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 237a0fc..135fde8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,45 +1,31 @@ -name: CI Tests +name: Tests on: push: - branches: [ master ] + branches: [ main, master ] pull_request: - branches: [ master ] + branches: [ main, master ] jobs: - tests: - name: ${{ matrix.os }} / ${{ matrix.qt-lib }} / Py ${{ matrix.python-version }} + test: + name: Test on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.10", "3.12"] - qt-lib: [pyside6, pyqt6, pyqt5] steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - - name: Install system dependencies (Linux) - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 x11-utils - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install ".[${{ matrix.qt-lib }}]" - python -m pip install pytest pytest-qt - - - name: Run tests - env: - QT_QPA_PLATFORM: offscreen - run: | - pytest + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run Tests + run: uv run pytest diff --git a/.gitignore b/.gitignore index 9e02282..53be12b 100644 --- a/.gitignore +++ b/.gitignore @@ -156,3 +156,5 @@ cython_debug/ # Generated demo files dummy_data.csv + +.ruff_cache \ No newline at end of file diff --git a/README.md b/README.md index 2bccb48..64cceb6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # FieldView +[![Quality](https://github.com/donghoonpark/FieldView/actions/workflows/quality.yml/badge.svg)](https://github.com/donghoonpark/FieldView/actions/workflows/quality.yml) +[![Tests](https://github.com/donghoonpark/FieldView/actions/workflows/tests.yml/badge.svg)](https://github.com/donghoonpark/FieldView/actions/workflows/tests.yml) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) + **FieldView** is a high-performance Python library for 2D data visualization, built on top of the Qt framework. It is designed to efficiently render irregular data points using heatmaps, markers, and text labels. FieldView leverages `QtPy` to support **PySide6**, **PyQt6**, and **PyQt5**, providing a flexible and robust solution for integrating advanced visualizations into Python desktop applications. From 79fd401fed8c75e521034ca66a53631a4ed59c35 Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 16:24:41 +0900 Subject: [PATCH 05/11] fix ruff format and ci step --- .github/workflows/quality.yml | 5 +++++ .github/workflows/tests.yml | 6 ++++++ fieldview/__init__.py | 1 - main.py | 1 + tests/test_heatmap_layer.py | 1 - 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 8282659..89a6a35 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -12,6 +12,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libopengl0 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils - name: Install uv uses: astral-sh/setup-uv@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 135fde8..137709e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,6 +17,12 @@ jobs: steps: - uses: actions/checkout@v4 + + - name: Install system dependencies (Ubuntu) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libopengl0 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils - name: Install uv uses: astral-sh/setup-uv@v4 diff --git a/fieldview/__init__.py b/fieldview/__init__.py index f64a931..9424f29 100644 --- a/fieldview/__init__.py +++ b/fieldview/__init__.py @@ -37,4 +37,3 @@ def _configure_threads(): _configure_threads() from .ui.field_view import FieldView as FieldView # noqa: E402 - diff --git a/main.py b/main.py index 9e083bb..7a36c6f 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ if __name__ == "__main__": from typing import TYPE_CHECKING + if TYPE_CHECKING: from PySide6.QtWidgets import QApplication else: diff --git a/tests/test_heatmap_layer.py b/tests/test_heatmap_layer.py index c46e608..5db3cc6 100644 --- a/tests/test_heatmap_layer.py +++ b/tests/test_heatmap_layer.py @@ -106,7 +106,6 @@ def test_heatmap_color_range_manual_and_auto(qtbot): lut = layer._colormap.get_lut(256) expected_low = np.clip((0.0 - 2.0) / (8.0 - 2.0), 0.0, 1.0) - low_color = image.pixelColor(0, 0) mid_color = image.pixelColor(1, 0) high_color = image.pixelColor(0, 1) From e1f87aa33c74787a9aacbc2de0265e320c670510 Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 16:30:32 +0900 Subject: [PATCH 06/11] removed pyqt install in mypy check --- .github/workflows/quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 89a6a35..911b52e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -25,7 +25,7 @@ jobs: run: uv python install - name: Install dependencies - run: uv sync --all-extras --dev + run: uv sync --dev - name: Lint with Ruff run: uv run ruff check . From ef363f7769c3f0fe4de438e2be3ad8027b466f1b Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 16:35:47 +0900 Subject: [PATCH 07/11] freeze qt version for python bindings and test misc numpy versions --- .github/workflows/tests.yml | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 137709e..7fd17ad 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,12 +8,14 @@ on: jobs: test: - name: Test on ${{ matrix.os }} + name: ${{ matrix.os }} / ${{ matrix.qt-binding }} / numpy-${{ matrix.numpy-version }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + qt-binding: [pyside6, pyqt6, pyqt5] + numpy-version: ['1.24.4', '1.26.4', '2.1.0'] steps: - uses: actions/checkout@v4 @@ -28,10 +30,29 @@ jobs: uses: astral-sh/setup-uv@v4 - name: Set up Python - run: uv python install + run: uv python install 3.10 - - name: Install dependencies - run: uv sync --all-extras --dev + - name: Create Virtual Environment + run: uv venv + + - name: Install Base Dependencies + run: uv pip install . + + - name: Install Matrix Dependencies + shell: bash + run: | + uv pip install "numpy==${{ matrix.numpy-version }}" + + if [[ "${{ matrix.qt-binding }}" == "pyqt5" ]]; then + uv pip install pyqt5 + else + uv pip install "${{ matrix.qt-binding }}==6.8.1" + fi + + uv pip install pytest pytest-qt pandas - name: Run Tests + # Set QT_API environment variable based on binding + env: + QT_API: ${{ matrix.qt-binding }} run: uv run pytest From ed66f274136c5a4096fb351d49de48905d4e5f42 Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 18:56:30 +0900 Subject: [PATCH 08/11] update test using qt 6.8.1 --- .github/workflows/tests.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7fd17ad..96e71a5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,13 +43,20 @@ jobs: run: | uv pip install "numpy==${{ matrix.numpy-version }}" - if [[ "${{ matrix.qt-binding }}" == "pyqt5" ]]; then + if [[ "${{ matrix.qt-binding }}" == "pyside6" ]]; then + uv pip install "pyside6==6.8.1" + elif [[ "${{ matrix.qt-binding }}" == "pyqt6" ]]; then + uv pip install "pyqt6==6.8.1" + elif [[ "${{ matrix.qt-binding }}" == "pyqt5" ]]; then uv pip install pyqt5 - else - uv pip install "${{ matrix.qt-binding }}==6.8.1" fi uv pip install pytest pytest-qt pandas + + echo "Installed Qt Binding Version:" + uv pip show ${{ matrix.qt-binding }} + + uv pip install pytest pytest-qt pandas - name: Run Tests # Set QT_API environment variable based on binding From 6a3423c1e3e05fdf1cce88ac943a886d5f35ab8b Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 19:02:36 +0900 Subject: [PATCH 09/11] fix backend environment --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 96e71a5..c47c834 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,10 +56,10 @@ jobs: echo "Installed Qt Binding Version:" uv pip show ${{ matrix.qt-binding }} - uv pip install pytest pytest-qt pandas + - name: Run Tests # Set QT_API environment variable based on binding env: QT_API: ${{ matrix.qt-binding }} - run: uv run pytest + run: uv run --no-sync pytest From 52f2ecef5dffd3264ee00c26947dcc7baab73913 Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 19:07:37 +0900 Subject: [PATCH 10/11] updated linux dependency --- .github/workflows/tests.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c47c834..0449183 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libegl1 libopengl0 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils + sudo apt-get install -y libegl1 libopengl0 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils xvfb - name: Install uv uses: astral-sh/setup-uv@v4 @@ -62,4 +62,9 @@ jobs: # Set QT_API environment variable based on binding env: QT_API: ${{ matrix.qt-binding }} - run: uv run --no-sync pytest + run: | + if [ "$RUNNER_OS" == "Linux" ]; then + xvfb-run -a uv run --no-sync pytest + else + uv run --no-sync pytest + fi From 251b99ac8f8b0d4e70f61be2ff2a81d23ebc5441 Mon Sep 17 00:00:00 2001 From: Donghoon Park Date: Sat, 6 Dec 2025 19:17:02 +0900 Subject: [PATCH 11/11] updated dependency again --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0449183..f5dc4b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,8 +24,8 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libegl1 libopengl0 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils xvfb - + sudo apt-get install -y libegl1 libxkbcommon-x11-0 libdbus-1-3 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 x11-utils + - name: Install uv uses: astral-sh/setup-uv@v4 @@ -63,7 +63,7 @@ jobs: env: QT_API: ${{ matrix.qt-binding }} run: | - if [ "$RUNNER_OS" == "Linux" ]; then + if ( "$RUNNER_OS" == "Linux" ); then xvfb-run -a uv run --no-sync pytest else uv run --no-sync pytest