Уточнить обработку плотных трендов и восстановление сигналов

This commit is contained in:
2026-10-12 00:48:15 +03:00
parent 20454ff7fa
commit bbb4bc2b9d
14 changed files with 91 additions and 14 deletions
+2 -1
View File
@@ -10,6 +10,7 @@ from dataclasses import dataclass
from protocan.legacycan import PROJECTS, LegacyCanFormat, decode
from .can485_board import BoardFrame, SdLogImport, parse_sd_log
from .trend_limits import MAX_TOTAL_POINTS
@dataclass
@@ -63,7 +64,7 @@ def _values(frame: BoardFrame):
def read_can_log(lines, *, reference_timestamp: float, capacity: int,
max_total_points: int = 1_000_000,
max_total_points: int = MAX_TOTAL_POINTS,
frame_limit: int = 100_000) -> tuple[list[CanLogChannel], SdLogImport]:
imported = parse_sd_log(lines, reference_timestamp, limit=frame_limit)
channels = {}
+26 -2
View File
@@ -9,11 +9,35 @@ are separate objects, never channels in the acquisition or measurement model.
"""
from __future__ import annotations
import csv
from bisect import bisect_left, bisect_right
from operator import itemgetter
from dataclasses import dataclass
from datetime import datetime, timezone
from .signal_reconstruction import METHODS, reconstruct
class FrozenPoints(tuple):
"""Normalized immutable samples, reusable across processing requests."""
def __new__(cls, points):
if isinstance(points, cls):
return points
result = super().__new__(cls, ((float(x), float(y)) for x, y in points))
result.ordered = all(result[i - 1][0] <= result[i][0] for i in range(1, len(result)))
return result
def between(self, left, right):
if self.ordered:
start = bisect_left(self, left, key=itemgetter(0))
stop = bisect_right(self, right, key=itemgetter(0))
if start == 0 and stop == len(self):
return self
result = tuple.__new__(type(self), self[start:stop])
result.ordered = True
return result
return FrozenPoints((x, y) for x, y in self if left <= x <= right)
@dataclass(frozen=True)
class Axis:
label: str = "Время"
@@ -36,7 +60,7 @@ class Series:
def __post_init__(self):
# A source may reuse mutable buffers immediately after publication.
object.__setattr__(self, "points", tuple((float(x), float(y)) for x, y in self.points))
object.__setattr__(self, "points", FrozenPoints(self.points))
@dataclass(frozen=True)
@@ -103,7 +127,7 @@ def prepare(snapshot, key, method="pchip", output_count=1000, degree=2):
if snapshot.x_range is not None:
left, right = snapshot.x_range
selected = Series(selected.key, selected.label,
tuple((x, y) for x, y in selected.points if left <= x <= right),
selected.points.between(left, right),
y_unit=selected.y_unit)
return Request(selected, snapshot.axis, snapshot.source, method, output_count, degree, snapshot.x_range)
@@ -11,6 +11,9 @@ from protocan.plot import Bounds, Viewport
def small_amplitude(values, threshold=30):
extent = getattr(values, "finite_extent", None)
if extent is not None:
return max(abs(extent[0]), abs(extent[1])) <= threshold
magnitude = max((abs(v) for v in values if math.isfinite(v)), default=None)
return magnitude is not None and magnitude <= threshold
@@ -52,7 +55,8 @@ class DualAxisPlot:
for series in self._series:
if series.visible and not series.discrete:
groups[series.key in self.right_axis_keys].extend(
v for v in series.values if math.isfinite(v))
getattr(series.values, "finite_extent", None) or
(v for v in series.values if math.isfinite(v)))
ranges = []
for values in groups:
low, high = (min(values), max(values)) if values else (0, 1)
@@ -21,7 +21,7 @@ except ImportError:
QVBoxLayout, QWidget,
)
from set_devices.signal_reconstruction import METHODS
from set_devices.signal_reconstruction import MAX_OUTPUT_POINTS, METHODS
from set_devices.plot_processing import Snapshot, prepare, process, write_csv
RESULT_COLORS = ("#FF70D0", "#50D5FF", "#FFD166", "#83E377", "#B99AFF", "#FF9870")
@@ -88,8 +88,8 @@ class SignalProcessingPanel(QWidget):
row.addWidget(self.degree)
row.addWidget(QLabel("Точек результата"))
self.count = QSpinBox()
self.count.setRange(2, 10000)
self.count.setValue(1000)
self.count.setRange(2, MAX_OUTPUT_POINTS)
self.count.setValue(10000)
row.addWidget(self.count)
self.apply_button = QPushButton("Рассчитать")
self.apply_button.clicked.connect(self.calculate)
+4 -2
View File
@@ -9,6 +9,8 @@ from dataclasses import dataclass
from functools import lru_cache
from protocan.native import NativeProtocol
MAX_OUTPUT_POINTS = 1_000_000
METHODS = {"polynomial": "Аппроксимация: полином МНК", "linear": "Интерполяция: линейная",
"pchip": "Интерполяция: PCHIP", "spline": "Восстановление: кубический сплайн"}
@@ -35,8 +37,8 @@ def library():
raise ValueError("Пересоберите SETProtocol с set_signal.c и set_wavegen.c") from error
def reconstruct(points, method="pchip", output_count=1000, degree=2, *, endpoint=True, with_model=False):
if method not in METHODS or type(output_count) is not int or not 2 <= output_count <= 10000:
raise ValueError("Неизвестный метод или число выходных точек вне 2…10000")
if method not in METHODS or type(output_count) is not int or not 2 <= output_count <= MAX_OUTPUT_POINTS:
raise ValueError(f"Неизвестный метод или число выходных точек вне 2…{MAX_OUTPUT_POINTS}")
count = len(points)
if not 2 <= count <= 100000 or type(degree) is not int or not 1 <= degree <= 5:
raise ValueError("Нужно 2…100000 исходных точек, степень 1…5")
+5
View File
@@ -0,0 +1,5 @@
"""Shared limits for host-side trend history buffers."""
DEFAULT_CAPACITY = 100_000
MAX_CAPACITY = 5_000_000
MAX_TOTAL_POINTS = 10_000_000
+15
View File
@@ -53,6 +53,21 @@ class PlotTests(unittest.TestCase):
crossed = markers.move(Marker.A, 1900).move(Marker.B, 1100)
self.assertEqual(-800, self.core.delta(crossed.a, crossed.b))
def test_deep_x_zoom_preserves_anchor_and_allows_pan_and_zoom_out(self):
view = Viewport()
for _ in range(65):
view = view.transform(self.core, zoom_x=1.25, focus_x=.3)
self.assertAlmostEqual(.3, view.x + .3 * view.width)
self.assertLess(view.width * 5_000_000, 3)
moved = view.transform(self.core, pan_x=.2)
self.assertLess(moved.x, view.x)
self.assertEqual(view.width, moved.width)
expanded = moved.transform(self.core, zoom_x=.8)
self.assertGreater(expanded.width, moved.width)
limited = view.transform(self.core, zoom_x=1e10)
self.assertEqual(1e-7, limited.width)
self.assertEqual(limited, limited.transform(self.core, zoom_x=2))
def test_invalid_numeric_inputs_do_not_escape_to_painter(self):
for value in (math.nan, math.inf, -math.inf):
self.assertEqual(Viewport(), Viewport().transform(self.core, zoom_x=value))
+14
View File
@@ -21,6 +21,20 @@ from set_devices.signal_reconstruction import METHODS
class PlotProcessingTests(unittest.TestCase):
def test_million_output_points_for_all_methods(self):
snapshot = Snapshot([Series("a", "A", [(0, 1), (1, 3), (2, 5)])])
for method in METHODS:
with self.subTest(method=method):
curve = process(prepare(snapshot, "a", method, 1_000_000, 1))
self.assertEqual(1_000_000, len(curve.points))
self.assertEqual((0., 2.), (curve.points[0][0], curve.points[-1][0]))
for x, y in curve.points[::10000]:
self.assertAlmostEqual(1 + 2 * x, y)
self.assertAlmostEqual(5., curve.points[-1][1])
self.assertTrue(curve.pieces)
with self.assertRaises(ValueError):
process(prepare(snapshot, "a", "linear", 1_000_001))
def test_snapshot_copies_mutable_samples_and_excludes_hidden_and_digital(self):
points = [[0, 0], [1, 1]]
channel = Series("a", "Analog", points)
+7
View File
@@ -56,6 +56,7 @@ class ProcessingQtTests(unittest.TestCase):
self.panel.method.setCurrentIndex(self.panel.method.findData(method))
self.calculate()
self.assertEqual(method, self.panel.curve.request.method)
self.panel.count.setValue(self.panel.count.value() + 1)
self.assertEqual(index + 1, len(self.panel.curves))
self.assertTrue(self.panel.export_button.isEnabled())
@@ -67,6 +68,12 @@ class ProcessingQtTests(unittest.TestCase):
self.assertEqual((), self.panel.curves)
self.assertIsNone(self.panel.curve)
def test_large_output_count_reaches_worker(self):
self.assertEqual(10000, self.panel.count.value())
self.panel.count.setValue(1_000_000)
self.calculate()
self.assertEqual(1_000_000, len(self.panel.curve.points))
def test_repeated_request_does_not_duplicate_curve(self):
self.calculate()
self.calculate()