diff --git a/c/set-protocol/include/set_plot.h b/c/set-protocol/include/set_plot.h index ed91f87..ded4ca3 100644 --- a/c/set-protocol/include/set_plot.h +++ b/c/set-protocol/include/set_plot.h @@ -31,7 +31,8 @@ enum set_plot_operation { PCAN_ABI_API uint32_t set_plot_abi_version(void); /** Evaluates one operation. Returns output count, or 0 for invalid arguments. * TRANSFORM rejects malformed viewports; invalid gesture values return the - * unchanged viewport. Zoom is restricted to 1..128, with focus anchoring. + * unchanged viewport. Zoom is restricted to 1..10000000 on X and 1..128 on Y, + * with focus anchoring. * Screen fractions increase downwards; set inverted=1 for the Y value axis. */ PCAN_ABI_API size_t set_plot_eval(uint32_t operation, const double *input, diff --git a/c/set-protocol/include/set_signal.h b/c/set-protocol/include/set_signal.h index 5626c97..cf2a768 100644 --- a/c/set-protocol/include/set_signal.h +++ b/c/set-protocol/include/set_signal.h @@ -13,6 +13,7 @@ extern "C" { #endif enum { SET_SIGNAL_POLYNOMIAL, SET_SIGNAL_LINEAR, SET_SIGNAL_PCHIP, SET_SIGNAL_SPLINE }; /* Caller-owned workspace: at least 14*count+128 doubles. No heap or globals. + * Bounds: 2..100000 input points, 2..1000000 output points. * Inputs may be unsorted; duplicate times are averaged. No extrapolation. * meta[0..2] = original count, unique count, RMSE at original measurements. * endpoint=1 includes the last time; 0 samples a cyclic period [first,last). diff --git a/c/set-protocol/src/set_plot.c b/c/set-protocol/src/set_plot.c index 37e17ea..2bd8b0d 100644 --- a/c/set-protocol/src/set_plot.c +++ b/c/set-protocol/src/set_plot.c @@ -7,6 +7,9 @@ #include "set_plot.h" #include +/* Allow sample-level X navigation in multi-million-point recordings. */ +#define MIN_VIEW_WIDTH (1.0 / 10000000) + static double clamp(double v, double lo, double hi) { return fmin(hi, fmax(lo, v)); } static int finite_values(const double *v, size_t n) { size_t i; @@ -23,12 +26,12 @@ size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t cap < (op == SET_PLOT_TRANSFORM || op == SET_PLOT_LIMITS ? 4U : 1U)) return 0; if (op == SET_PLOT_TRANSFORM) { double w, h, fx, fy; - if (!finite_values(v, 4) || v[2] < 1.0/128 || v[2] > 1 || + if (!finite_values(v, 4) || v[2] < MIN_VIEW_WIDTH || v[2] > 1 || v[3] < 1.0/128 || v[3] > 1 || v[0] < 0 || v[1] < 0 || v[0] + v[2] > 1.000000000001 || v[1] + v[3] > 1.000000000001) return 0; out[0] = v[0]; out[1] = v[1]; out[2] = v[2]; out[3] = v[3]; if (!finite_values(v + 4, 6) || v[4] <= 0 || v[5] <= 0) return 4; - w = clamp(v[2] / v[4], 1.0/128, 1); + w = clamp(v[2] / v[4], MIN_VIEW_WIDTH, 1); h = clamp(v[3] / v[5], 1.0/128, 1); fx = clamp(v[8], 0, 1); fy = clamp(v[9], 0, 1); out[0] = clamp(v[0] + (v[2] - w) * fx - v[6] * w, 0, 1 - w); diff --git a/c/set-protocol/src/set_signal.c b/c/set-protocol/src/set_signal.c index 45b9b7c..96a0533 100644 --- a/c/set-protocol/src/set_signal.c +++ b/c/set-protocol/src/set_signal.c @@ -40,7 +40,7 @@ int set_signal_reconstruct(const double *input_x,const double *input_y,size_t co double coef[6]={0},r[6][6]={{0}},origin,span,scale=1,rmse=0; size_t i,j,k,n=0; unsigned pass; if(!input_x||!input_y||!out_x||!out_y||!meta||!work || count<2||count>100000 - ||output_count<2||output_count>10000||work_count<14*count+128 + ||output_count<2||output_count>1000000||work_count<14*count+128 ||method<0||method>3||(endpoint!=0&&endpoint!=1)) return 1; pairs=(point *)work; x=work+2*count; y=x+count; d=y+count; h=d+count; s=h+count; q=s+count; column=q+6*count; diff --git a/c/set-protocol/tests/fixtures/plot-v1.json b/c/set-protocol/tests/fixtures/plot-v1.json index a06e11f..62ed262 100644 --- a/c/set-protocol/tests/fixtures/plot-v1.json +++ b/c/set-protocol/tests/fixtures/plot-v1.json @@ -4,7 +4,7 @@ {"name":"zoom_x_anchor","op":0,"input":[0,0,1,1,2,1,0,0,0.25,0.5],"output":[0.125,0,0.5,1]}, {"name":"zoom_y_anchor","op":0,"input":[0,0,1,1,1,4,0,0,0.5,0.75],"output":[0,0.5625,1,0.25]}, {"name":"pan_clamp","op":0,"input":[0.25,0.25,0.5,0.5,1,1,20,-20,0.5,0.5],"output":[0,0.5,0.5,0.5]}, - {"name":"zoom_limit","op":0,"input":[0,0,1,1,1000,1000,0,0,0.5,0.5],"output":[0.49609375,0.49609375,0.0078125,0.0078125]}, + {"name":"zoom_limit","op":0,"input":[0,0,1,1,1000,1000,0,0,0.5,0.5],"output":[0.4995,0.49609375,0.001,0.0078125]}, {"name":"ignore_invalid_zoom","op":0,"input":[0.25,0.25,0.5,0.5,0,1,0,0,0.5,0.5],"output":[0.25,0.25,0.5,0.5]}, {"name":"horizontal_spread","op":1,"input":[100,2,8],"output":[1]}, {"name":"vertical_contract","op":1,"input":[2,-100,8],"output":[2]}, diff --git a/python/set_devices/can_log_trends.py b/python/set_devices/can_log_trends.py index 6ba5cbc..27d752d 100644 --- a/python/set_devices/can_log_trends.py +++ b/python/set_devices/can_log_trends.py @@ -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 = {} diff --git a/python/set_devices/plot_processing.py b/python/set_devices/plot_processing.py index ef4fa9a..c128c39 100644 --- a/python/set_devices/plot_processing.py +++ b/python/set_devices/plot_processing.py @@ -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) diff --git a/python/set_devices/qt_ports/plot_dual_axis.py b/python/set_devices/qt_ports/plot_dual_axis.py index e1606f5..f57dfc7 100644 --- a/python/set_devices/qt_ports/plot_dual_axis.py +++ b/python/set_devices/qt_ports/plot_dual_axis.py @@ -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) diff --git a/python/set_devices/qt_ports/plot_processing.py b/python/set_devices/qt_ports/plot_processing.py index 9fb49ca..a328bd2 100644 --- a/python/set_devices/qt_ports/plot_processing.py +++ b/python/set_devices/qt_ports/plot_processing.py @@ -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) diff --git a/python/set_devices/signal_reconstruction.py b/python/set_devices/signal_reconstruction.py index 470c031..cbaadc0 100644 --- a/python/set_devices/signal_reconstruction.py +++ b/python/set_devices/signal_reconstruction.py @@ -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") diff --git a/python/set_devices/trend_limits.py b/python/set_devices/trend_limits.py new file mode 100644 index 0000000..569d515 --- /dev/null +++ b/python/set_devices/trend_limits.py @@ -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 diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py index 142b547..11f14f1 100644 --- a/python/tests/test_plot.py +++ b/python/tests/test_plot.py @@ -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)) diff --git a/python/tests/test_plot_processing.py b/python/tests/test_plot_processing.py index 37cd575..ac83b7a 100644 --- a/python/tests/test_plot_processing.py +++ b/python/tests/test_plot_processing.py @@ -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) diff --git a/python/tests/test_plot_processing_qt.py b/python/tests/test_plot_processing_qt.py index b0194e8..9d3e50d 100644 --- a/python/tests/test_plot_processing_qt.py +++ b/python/tests/test_plot_processing_qt.py @@ -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()