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.
+[](https://github.com/donghoonpark/FieldView/actions/workflows/quality.yml)
+[](https://github.com/donghoonpark/FieldView/actions/workflows/tests.yml)
+[](https://github.com/astral-sh/ruff)
+[](https://mypy-lang.org/)
-
+**FieldView** is a high-performance Python library for 2D data visualization, built on top of the Qt framework. It is designed to efficiently render irregular data points using heatmaps, markers, and text labels.
+
+FieldView leverages `QtPy` to support **PySide6**, **PyQt6**, and **PyQt5**, providing a flexible and robust solution for integrating advanced visualizations into Python desktop applications.
+
+
## 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
```
-
-
## 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"