Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ FieldView leverages `QtPy` to support **PySide6**, **PyQt6**, and **PyQt5**, pro
* **SvgLayer**: Renders SVG backgrounds for context (e.g., floor plans, maps).
* **Minimal Dependencies**: Core functionality relies only on `numpy`, `scipy`, and `qtpy`.

## Performance

FieldView's `FastRBFInterpolator` is designed for real-time rendering. By precomputing the interpolation matrix, it achieves significant speedups during the rendering phase.

![Benchmark Plot](benchmark_plot.png)

**Benchmark Results (100 points, 200x200 grid, k=30, NumPy 2.1.0, Apple M1)**

| Method | Time per Frame | Speedup |
| :--- | :--- | :--- |
| **Scipy RBFInterpolator** | ~1616 ms | 1x |
| **FastRBFInterpolator (Predict)** | **~1.2 ms** | **~1322x** |

*Note: FastRBF requires a one-time setup cost (~1.8s in this test), but subsequent frames are rendered in real-time.*

## Installation

Install FieldView with your preferred Qt binding:
Expand Down
Binary file added benchmark_plot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dev = [
"ruff",
"mypy",
"pandas-stubs",
"matplotlib"
]

[tool.mypy]
Expand Down
80 changes: 80 additions & 0 deletions scripts/benchmark_interpolation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import time
import numpy as np
from scipy.interpolate import RBFInterpolator
from fieldview.utils.interpolation import FastRBFInterpolator
import platform
import json


def benchmark():
print("Benchmarking Interpolation Performance")
print(f"Python: {platform.python_version()}")
print(f"NumPy: {np.__version__}")

# Setup data
n_points = 100
n_grid = 200 # 100x100 = 40,000 query points

rng = np.random.default_rng(42)
points = rng.random((n_points, 2)) * 100
values = np.sin(points[:, 0] * 0.1) + np.cos(points[:, 1] * 0.1)

# Create grid
x = np.linspace(0, 100, n_grid)
y = np.linspace(0, 100, n_grid)
xx, yy = np.meshgrid(x, y)
query_points = np.column_stack((xx.ravel(), yy.ravel()))

print(
f"Data: {n_points} points, {n_grid}x{n_grid} grid ({len(query_points)} query points)"
)
print("-" * 60)

results = {
"numpy_version": np.__version__,
"n_points": n_points,
"n_grid": n_grid,
}

# FastRBFInterpolator (Local k=30)
print("Running FastRBFInterpolator (Local k=30)...")
neighbors = 30

# Scipy Baseline (Local)
start_time = time.perf_counter()
rbf = RBFInterpolator(
points, values, neighbors=neighbors, kernel="linear", epsilon=1.0
)
_ = rbf(query_points)
scipy_local_time = time.perf_counter() - start_time
results["scipy_local_time"] = scipy_local_time
print(f"Scipy RBFInterpolator (k={neighbors}): {scipy_local_time:.4f} sec")

# FastRBF
start_setup = time.perf_counter()
fast_rbf = FastRBFInterpolator(neighbors=neighbors, kernel="linear")
fast_rbf.fit(points, query_points)
setup_time = time.perf_counter() - start_setup

start_predict = time.perf_counter()
_ = fast_rbf.predict(values)
predict_time = time.perf_counter() - start_predict

fast_total = setup_time + predict_time
results["fast_rbf_setup_time"] = setup_time
results["fast_rbf_predict_time"] = predict_time
results["fast_rbf_total_time"] = fast_total

print(
f"FastRBFInterpolator (Total): {fast_total:.4f} sec (Setup: {setup_time:.4f}, Predict: {predict_time:.4f})"
)

# Save results
filename = f"benchmark_results_{np.__version__}.json"
with open(filename, "w") as f:
json.dump(results, f, indent=4)
print(f"Results saved to {filename}")


if __name__ == "__main__":
benchmark()
113 changes: 113 additions & 0 deletions scripts/plot_benchmark.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import json
import matplotlib.pyplot as plt
import numpy as np
import glob


def plot_results():
files = glob.glob("benchmark_results_*.json")
if not files:
print("No benchmark result files found.")
return

data = []
for f in files:
with open(f, "r") as fp:
data.append(json.load(fp))

# Sort by numpy version
data.sort(key=lambda x: x["numpy_version"])

versions = [f"numpy=={d['numpy_version']}" for d in data]
scipy_times = [d["scipy_local_time"] * 1000 for d in data]
fast_setup_times = [d["fast_rbf_setup_time"] * 1000 for d in data]
fast_predict_times = [d["fast_rbf_predict_time"] * 1000 for d in data]

x = np.arange(len(versions))
width = 0.15

with plt.xkcd():
fig, ax = plt.subplots(figsize=(12, 7))

# Plot 3 bars
rects1 = ax.bar(
x - width, scipy_times, width, label="Scipy RBF (Total)", color="tab:blue"
)
rects2 = ax.bar(
x,
fast_setup_times,
width,
label="FastRBF (Setup, one time)",
color="tab:gray",
)
rects3 = ax.bar(
x + width,
fast_predict_times,
width,
label="FastRBF (Predict)",
color="tab:orange",
)

ax.set_ylabel("Time (ms)")
ax.set_title(
"Interpolation Performance Comparison\n(Grid: 200x200, Neighbors: 30, Machine: Apple M1)",
fontsize=14,
)
ax.set_xticks(x)
ax.set_xticklabels(versions)
ax.legend()
ax.set_yscale("log")

def autolabel(rects):
for rect in rects:
height = rect.get_height()
ax.annotate(
f"{height:.1f}ms",
xy=(rect.get_x() + rect.get_width() / 2, height),
xytext=(0, 3),
textcoords="offset points",
ha="center",
va="bottom",
fontsize=12,
)

autolabel(rects1)
autolabel(rects2)
autolabel(rects3)

# Add speedup annotation between Scipy and FastRBF Predict
for i, (s, f) in enumerate(zip(scipy_times, fast_predict_times)):
speedup = s / f
# Position the text above the Predict bar, but high enough
ax.text(
i + width,
f * 1.5,
f"{speedup:.1f}x\nSpeedup",
ha="center",
va="bottom",
fontweight="bold",
color="tab:red",
)

plt.tight_layout()
plt.savefig("benchmark_plot.png")
print("Plot saved to benchmark_plot.png")

# Also print a summary table
print("\nSummary Table (ms):")
print(
f"{'Version':<15} | {'Scipy (ms)':<12} | {'Setup (ms)':<12} | {'Predict (ms)':<15} | {'Speedup':<10}"
)
print("-" * 75)
for d in data:
s = d["scipy_local_time"] * 1000
setup = d["fast_rbf_setup_time"] * 1000
pred = d["fast_rbf_predict_time"] * 1000
speedup = s / pred
print(
f"numpy=={d['numpy_version']:<8} | {s:<12.2f} | {setup:<12.2f} | {pred:<15.4f} | {speedup:.1f}x"
)


if __name__ == "__main__":
plot_results()
107 changes: 107 additions & 0 deletions tests/test_data_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import pytest
import numpy as np
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from PySide6.QtCore import Qt
else:
from qtpy.QtCore import Qt

from fieldview.core.data_container import DataContainer
from fieldview.ui.data_table import PointTableModel, DataTable


@pytest.fixture
def data_container():
dc = DataContainer()
points = np.array([[10, 20], [30, 40], [50, 60]])
values = np.array([1.0, 2.0, 3.0])
labels = ["A", "B", "C"]
dc.set_data(points, values, labels)
return dc


def test_model_row_column_count(data_container):
model = PointTableModel(data_container)
assert model.rowCount() == 3
assert model.columnCount() == 6


def test_model_data_display_role(data_container):
model = PointTableModel(data_container)
# Col 2 is X, Col 3 is Y, Col 4 is Value, Col 5 is Label
index_x = model.index(0, 2)
assert model.data(index_x, Qt.ItemDataRole.DisplayRole) == "10.00"

index_val = model.index(1, 4)
assert model.data(index_val, Qt.ItemDataRole.DisplayRole) == "2.00"

index_label = model.index(2, 5)
assert model.data(index_label, Qt.ItemDataRole.DisplayRole) == "C"


def test_model_data_check_state_role(data_container):
model = PointTableModel(data_container)
# Col 0 is Highlight, Col 1 is Exclude
index_highlight = model.index(0, 0)
assert (
model.data(index_highlight, Qt.ItemDataRole.CheckStateRole)
== Qt.CheckState.Unchecked
)

# Manually add to highlighted set to verify
model._highlighted_indices.add(0)
assert (
model.data(index_highlight, Qt.ItemDataRole.CheckStateRole)
== Qt.CheckState.Checked
)


def test_model_set_data_edit(data_container):
model = PointTableModel(data_container)
index_val = model.index(0, 4)

# Update value
assert model.setData(index_val, "99.9", Qt.ItemDataRole.EditRole)
assert data_container.values[0] == 99.9

# Verify dataChanged signal (optional, but good practice)
# Here we just check the underlying data updated


def test_model_set_data_check_state(data_container):
model = PointTableModel(data_container)
index_exclude = model.index(0, 1)

# Check
assert model.setData(
index_exclude, Qt.CheckState.Checked.value, Qt.ItemDataRole.CheckStateRole
)
assert 0 in model._excluded_indices

# Uncheck
assert model.setData(
index_exclude, Qt.CheckState.Unchecked.value, Qt.ItemDataRole.CheckStateRole
)
assert 0 not in model._excluded_indices


def test_model_flags(data_container):
model = PointTableModel(data_container)

# Checkable column
index_check = model.index(0, 0)
flags = model.flags(index_check)
assert flags & Qt.ItemFlag.ItemIsUserCheckable

# Editable column
index_edit = model.index(0, 2)
flags = model.flags(index_edit)
assert flags & Qt.ItemFlag.ItemIsEditable


def test_data_table_init(qtbot, data_container):
table = DataTable(data_container)
qtbot.addWidget(table)
assert table.model() is not None
assert isinstance(table.table_model, PointTableModel)
61 changes: 61 additions & 0 deletions tests/test_field_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import numpy as np
from typing import TYPE_CHECKING

if TYPE_CHECKING:
pass
else:
pass

from fieldview.ui.field_view import FieldView
from fieldview.layers.heatmap_layer import HeatmapLayer
from fieldview.layers.pin_layer import PinLayer


def test_field_view_init(qtbot):
view = FieldView()
qtbot.addWidget(view)
assert view.scene() is not None


def test_field_view_set_data(qtbot):
view = FieldView()
qtbot.addWidget(view)

points = np.array([[0, 0], [10, 10]])
values = np.array([1, 2])
view.set_data(points, values)

assert len(view.data_container.points) == 2
assert len(view.data_container.values) == 2


def test_field_view_add_layers(qtbot):
view = FieldView()
qtbot.addWidget(view)

# Heatmap
heatmap = view.add_heatmap_layer()
assert isinstance(heatmap, HeatmapLayer)
assert heatmap in view.scene().items()
assert view.layers["heatmap"] == heatmap

# Pin
pin = view.add_pin_layer()
assert isinstance(pin, PinLayer)
assert pin in view.scene().items()
assert view.layers["pin"] == pin


def test_field_view_fit_to_scene(qtbot):
view = FieldView()
qtbot.addWidget(view)
view.resize(400, 300)

# Add some content
points = np.array([[0, 0], [100, 100]])
values = np.array([0, 1])
view.set_data(points, values)
view.add_pin_layer()

# Should not raise
view.fit_to_scene()
Loading
Loading