diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..911b52e --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,37 @@ +name: Quality + +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 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 + + - name: Set up Python + run: uv python install + + - name: Install dependencies + run: uv sync --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/.github/workflows/tests.yml b/.github/workflows/tests.yml index 237a0fc..f5dc4b1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,45 +1,70 @@ -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: ${{ 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] - python-version: ["3.10", "3.12"] - qt-lib: [pyside6, pyqt6, pyqt5] + qt-binding: [pyside6, pyqt6, pyqt5] + numpy-version: ['1.24.4', '1.26.4', '2.1.0'] 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 system dependencies (Ubuntu) + 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 uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install 3.10 + + - 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 }}" == "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 + fi + + uv pip install pytest pytest-qt pandas + + echo "Installed Qt Binding Version:" + uv pip show ${{ matrix.qt-binding }} + + + + - name: Run Tests + # Set QT_API environment variable based on binding + env: + QT_API: ${{ matrix.qt-binding }} + run: | + if ( "$RUNNER_OS" == "Linux" ); then + xvfb-run -a uv run --no-sync pytest + else + uv run --no-sync pytest + fi 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/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..64cceb6 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,89 @@ # 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. +[![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/) -Quick Start +**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. + +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..1fe143d 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -1,30 +1,83 @@ 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 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__), '..'))) +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 +87,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 +123,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 +144,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 +233,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 +265,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 +284,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 +309,34 @@ 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.setRenderHint(QPainter.RenderHint.Antialiasing) + self.view.setDragMode(QGraphicsView.DragMode.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() + 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) @@ -266,26 +354,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 +383,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 +407,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 +449,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 +457,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 +485,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 +506,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 +526,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 +565,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 +729,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 +737,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 +755,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 +778,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,15 +795,22 @@ 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) 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] @@ -571,13 +824,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 +875,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..80602fb 100644 --- a/examples/heatmap_demo.py +++ b/examples/heatmap_demo.py @@ -2,55 +2,75 @@ import os import numpy as np import pandas as pd -from qtpy.QtWidgets import ( - QApplication, - QMainWindow, - QGraphicsView, - QGraphicsScene, - QWidget, - QVBoxLayout, - QHBoxLayout, - 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__), '..'))) +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.view.setRenderHint(QPainter.RenderHint.Antialiasing) + self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) self.setCentralWidget(self.view) - + # Controls (Dock) self.setup_controls() @@ -66,49 +86,51 @@ 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) - + # 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 +150,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) + self.addDockWidget(Qt.DockWidgetArea.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 +188,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 +200,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 +219,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 +239,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 +273,10 @@ 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 + # QPainter is already imported at top level + app = QApplication(sys.argv) window = HeatmapDemo() window.show() diff --git a/examples/heatmap_poc.py b/examples/heatmap_poc.py index 847bb65..0c697d7 100644 --- a/examples/heatmap_poc.py +++ b/examples/heatmap_poc.py @@ -1,75 +1,106 @@ -```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 typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtWidgets import ( + QApplication, + QWidget, + QVBoxLayout, + QPushButton, + QLabel, + ) + from PySide6.QtGui import QPainter, QColor, QImage + from PySide6.QtCore import Qt, QTimer, QPoint +else: + from qtpy.QtWidgets import ( + QApplication, + QWidget, + QVBoxLayout, + QPushButton, + QLabel, + ) + from qtpy.QtGui import QPainter, QColor, QImage + 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 +111,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 +188,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 +284,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..336b567 100644 --- a/examples/quick_start.py +++ b/examples/quick_start.py @@ -1,16 +1,21 @@ 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 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 +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 +24,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..1e4591a 100644 --- a/examples/us_map_utils.py +++ b/examples/us_map_utils.py @@ -3,35 +3,49 @@ 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 TYPE_CHECKING, Dict, Tuple, List -def parse_svg_path_to_qpainterpath(d_str): +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: """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 +55,16 @@ 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 +72,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 +93,110 @@ 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.setJoinStyle(Qt.RoundJoin) - stroker.setCapStyle(Qt.RoundCap) - + stroker.setWidth(1.0) # Small overlap to seal cracks + stroker.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + stroker.setCapStyle(Qt.PenCapStyle.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 +204,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..9424f29 100644 --- a/fieldview/__init__.py +++ b/fieldview/__init__.py @@ -0,0 +1,39 @@ +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..989a707 100644 --- a/fieldview/core/data_container.py +++ b/fieldview/core/data_container.py @@ -1,11 +1,18 @@ 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): """ Manages the core data (points and values) for the FieldView library. Emits signals when data changes. """ + dataChanged = Signal() def __init__(self): @@ -29,7 +36,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 +51,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 +68,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 +91,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 +114,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 +140,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 +154,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..9419344 100644 --- a/fieldview/layers/data_layer.py +++ b/fieldview/layers/data_layer.py @@ -1,18 +1,25 @@ -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 + 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 +66,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 +105,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..395e599 100644 --- a/fieldview/layers/heatmap_layer.py +++ b/fieldview/layers/heatmap_layer.py @@ -1,20 +1,37 @@ 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 - -QualityLevel = Literal['very low', 'low', 'medium', 'high', 'very high', 'adaptive'] - +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 -from fieldview.rendering.colormaps import get_colormap +QualityLevel = Literal["very low", "low", "medium", "high", "very high", "adaptive"] +KernelType = Literal[ + "thin_plate_spline", + "linear", + "cubic", + "quintic", + "gaussian", + "multiquadric", + "inverse_multiquadric", + "", +] + # 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 +39,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 +103,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 +146,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 +224,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 +243,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 +254,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 +274,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 +320,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 +328,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 +336,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 +364,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 +396,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,44 +456,50 @@ 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() - 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() 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..78677c4 100644 --- a/fieldview/layers/layer.py +++ b/fieldview/layers/layer.py @@ -1,14 +1,28 @@ -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): """ 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 @@ -18,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 f7b8a67..12f9c25 100644 --- a/fieldview/layers/pin_layer.py +++ b/fieldview/layers/pin_layer.py @@ -1,15 +1,26 @@ -from qtpy.QtGui import QPixmap, QPainter, QColor, QPen, QBrush -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): """ 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): @@ -19,19 +30,24 @@ 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: 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 + 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 2028c57..b6ab456 100644 --- a/fieldview/layers/svg_layer.py +++ b/fieldview/layers/svg_layer.py @@ -1,12 +1,24 @@ -from fieldview.utils.qt_compat import QGraphicsSvgItem -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): """ Layer for rendering an SVG file. """ + def __init__(self, parent=None): super().__init__(parent) self._renderer = QSvgRenderer() @@ -54,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 67ceef8..2883e6f 100644 --- a/fieldview/layers/text_layer.py +++ b/fieldview/layers/text_layer.py @@ -1,23 +1,37 @@ -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, 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, Tuple 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 +39,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 +108,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 +121,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 +181,74 @@ 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) - + + if chosen_rect is not None: + 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 +269,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 +279,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..37ea84b 100644 --- a/fieldview/rendering/colormaps.py +++ b/fieldview/rendering/colormaps.py @@ -1,45 +1,53 @@ -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 + 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 +55,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 +136,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..4d59c85 100644 --- a/fieldview/ui/color_range_widget.py +++ b/fieldview/ui/color_range_widget.py @@ -1,7 +1,25 @@ -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 typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QDoubleSpinBox, + ) + from PySide6.QtCore import Signal +else: + from qtpy.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QDoubleSpinBox, + ) + from qtpy.QtCore import Signal from fieldview.rendering.colormaps import get_colormap diff --git a/fieldview/ui/data_table.py b/fieldview/ui/data_table.py index 76af705..518a13c 100644 --- a/fieldview/ui/data_table.py +++ b/fieldview/ui/data_table.py @@ -1,63 +1,105 @@ 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 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 + 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) - 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: - if not index.isValid(): return None + 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() - + 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: Union["QModelIndex", "QPersistentModelIndex"], + 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 +116,31 @@ 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: + def flags( + self, index: Union["QModelIndex", "QPersistentModelIndex"] + ) -> 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 +152,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,11 +186,11 @@ 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): - 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 new file mode 100644 index 0000000..336b300 --- /dev/null +++ b/fieldview/ui/field_view.py @@ -0,0 +1,92 @@ +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 +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.RenderHint.Antialiasing) + self.setDragMode(QGraphicsView.DragMode.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..eb0d7d7 100644 --- a/fieldview/utils/qt_compat.py +++ b/fieldview/utils/qt_compat.py @@ -1,5 +1,13 @@ -try: - from qtpy.QtSvgWidgets import QGraphicsSvgItem -except ImportError: - # PyQt5 compatibility - from qtpy.QtSvg import QGraphicsSvgItem +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..7a36c6f 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,10 @@ 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 f03e205..c280def 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,4 +40,23 @@ dev = [ "twine", "aiohttp", "PySide6-QtAds", + "pyside6", + "pyside6-stubs", + "ruff", + "mypy", + "pandas-stubs", ] + +[tool.mypy] +ignore_missing_imports = true +check_untyped_defs = true + +[[tool.mypy.overrides]] +module = [ + "scipy.*", + "PySide6QtAds.*", +] +ignore_missing_imports = true +ignore_errors = true + + diff --git a/scripts/capture_screenshot.py b/scripts/capture_screenshot.py index 1d2d07d..fb3513b 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,121 @@ 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 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 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.GlobalColor.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..2ac769c 100644 --- a/tests/test_grid_manager.py +++ b/tests/test_grid_manager.py @@ -1,58 +1,65 @@ -import pytest import numpy as np from qtpy.QtGui import QPolygonF -from qtpy.QtCore import QPointF, 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 + 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..5db3cc6 100644 --- a/tests/test_heatmap_layer.py +++ b/tests/test_heatmap_layer.py @@ -1,10 +1,17 @@ import pytest import numpy as np -from qtpy.QtCore import QTimer, QPointF, QRectF -from qtpy.QtGui import QPolygonF, 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 + def test_heatmap_initialization(qtbot): dc = DataContainer() layer = HeatmapLayer(dc) @@ -13,63 +20,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 +105,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) - 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..99547c8 100644 --- a/tests/test_misc_layers.py +++ b/tests/test_misc_layers.py @@ -1,20 +1,22 @@ -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 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 + 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 +50,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..0fa3eee 100644 --- a/tests/test_text_layer.py +++ b/tests/test_text_layer.py @@ -1,33 +1,41 @@ -import pytest -from qtpy.QtCore import QPointF, QRectF -from qtpy.QtGui import QFont, QColor +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 + 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 +44,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 +64,53 @@ def record_get_text(idx, value, label): recorded_indices.append(idx) return "recorded" - layer._get_text = record_get_text - layer.paint(painter, None, None) + monkeypatch.setattr(layer, "_get_text", record_get_text) + option = QStyleOptionGraphicsItem() + layer.paint(painter, option, 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..98cbcc0 100644 --- a/uv.lock +++ b/uv.lock @@ -447,11 +447,16 @@ 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" }, { name = "pyside6-qtads" }, + { name = "pyside6-stubs" }, { name = "pytest" }, { name = "pytest-qt" }, + { name = "ruff" }, { name = "twine" }, ] @@ -470,10 +475,15 @@ provides-extras = ["pyside6", "pyqt6", "pyqt5"] dev = [ { name = "aiohttp" }, { name = "build" }, + { 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" }, { name = "twine" }, ] @@ -703,6 +713,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 +954,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 +1165,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" @@ -1356,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" @@ -1493,6 +1666,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 +1815,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"