Add shared waveform processing, generation and firmware image support

This commit is contained in:
2026-09-27 01:45:08 +03:00
parent 2ad29e7ffd
commit cc22c803d1
36 changed files with 2591 additions and 61 deletions

View File

@@ -0,0 +1,87 @@
"""Portable processing contract: no Qt or application imports."""
import csv
import io
import math
import unittest
from dataclasses import replace
from set_devices.plot_processing import Axis, Series, Snapshot, prepare, process, write_csv
from set_devices.signal_reconstruction import METHODS
class PlotProcessingTests(unittest.TestCase):
def test_snapshot_copies_mutable_samples_and_excludes_hidden_and_digital(self):
points = [[0, 0], [1, 1]]
channel = Series("a", "Analog", points)
snapshot = Snapshot([channel, Series("hidden", "Hidden", points, visible=False),
Series("bit", "Bit", points, discrete=True)])
points[0][1] = 9
points.append([2, 3])
self.assertEqual(((0., 0.), (1., 1.)), prepare(snapshot, "a").series.points)
self.assertEqual((channel,), snapshot.analogs)
self.assertIsNone(prepare(snapshot, "hidden"))
self.assertIsNone(prepare(snapshot, "bit"))
self.assertIsNone(prepare(replace(snapshot, blocked_reason="Pause capture"), "a"))
with self.assertRaises(ValueError):
Snapshot([channel, channel])
def test_all_methods_use_same_contract_and_keep_source(self):
source = Series("v", "Voltage", [(100, 5), (300, 9)], y_unit="V")
snapshot = Snapshot([source], Axis("Time", "ms"), "scope")
for method in METHODS:
with self.subTest(method=method):
request = prepare(snapshot, "v", method, 5, 1)
curve = process(request)
self.assertEqual([100, 150, 200, 250, 300], [x for x, y in curve.points])
for (_, value), expected in zip(curve.points, [5, 6, 7, 8, 9]):
self.assertAlmostEqual(expected, value)
self.assertEqual((2, 2), (curve.input_count, curve.unique_count))
self.assertEqual(request, curve.request)
self.assertEqual(((100., 5.), (300., 9.)), source.points)
def test_window_is_inclusive_and_never_extrapolates(self):
snapshot = Snapshot([Series("a", "A", [(0, 0), (1, 2), (2, 4), (3, 6)])],
x_range=(.5, 2))
curve = process(prepare(snapshot, "a", "linear", 3))
self.assertEqual(((1., 2.), (1.5, 3.), (2., 4.)), curve.points)
self.assertEqual(2, curve.input_count)
with self.assertRaises(ValueError):
process(prepare(replace(snapshot, x_range=(.5, .9)), "a"))
def test_sparse_spline_and_noisy_polynomial_work_in_shared_pipeline(self):
sparse = Snapshot([Series("a", "Sine", [(i * math.pi / 4, math.sin(i * math.pi / 4))
for i in range(9)])])
curve = process(prepare(sparse, "a", "spline", 201))
self.assertLess(max(abs(y - math.sin(x)) for x, y in curve.points), .002)
noisy = Snapshot([Series("a", "Ramp", [(i, 2 * i + (1 if i % 2 else -1)) for i in range(11)])])
curve = process(prepare(noisy, "a", "polynomial", 21, 1))
self.assertLess(max(abs(y - 2 * x) for x, y in curve.points), .1)
self.assertGreater(curve.rmse, .9)
def test_csv_respects_explicit_axis_domain_and_units(self):
for axis, start, expected in ((Axis("Time", "ms"), 1000, "1000.0"),
(Axis("Frequency", "Hz"), 1.8e12, "1800000000000.0"),
(Axis("Time", "ms", "unix_ms"), 1000, "1970-01-01T00:00:01.000000Z")):
with self.subTest(axis=axis):
snapshot = Snapshot([Series("v", "Voltage", [(start, 0), (start + 1000, 1)], y_unit="V")], axis)
output = io.StringIO()
write_csv(process(prepare(snapshot, "v", "linear", 3)), output)
rows = list(csv.reader(io.StringIO(output.getvalue())))
self.assertEqual(expected, rows[1][0])
self.assertTrue(rows[0][1].endswith("[V]"))
self.assertEqual("timestamp" if axis.encoding == "unix_ms" else f"{axis.label} [{axis.unit}]", rows[0][0])
def test_request_tracks_selected_channel_units_source_and_window(self):
channel = Series("a", "A", [(0, 0), (1, 1)])
other = Series("b", "B", [(0, 3), (1, 4)])
snapshot = Snapshot([channel, other], source="file1")
request = prepare(snapshot, "a")
self.assertEqual(request, prepare(replace(snapshot, series=[channel, replace(other, points=[(0, 9)])]), "a"))
for changed in (replace(snapshot, source="file2"), replace(snapshot, axis=Axis("Frequency", "Hz")),
replace(snapshot, x_range=(0, 1)),
replace(snapshot, series=[replace(channel, y_unit="V")])):
self.assertNotEqual(request, prepare(changed, "a"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,93 @@
"""Reusable Qt adapter tests, runnable with PySide2 or PySide6, without a GUI app repo."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import time
import unittest
from dataclasses import replace
from unittest.mock import patch
from set_devices.plot_processing import Axis, Series, Snapshot, process
from set_devices.qt_ports.plot_processing import SignalProcessingPanel, PlotProcessingAttachment, QWidget
try:
from PySide6.QtWidgets import QApplication
except ImportError:
from PySide2.QtWidgets import QApplication
class ProcessingQtTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def setUp(self):
self.panel = SignalProcessingPanel()
self.snapshot = Snapshot([Series("a", "A", [(0, 0), (1, 1), (2, 0)])])
self.panel.set_snapshot(self.snapshot)
def tearDown(self):
self.panel.close()
def calculate(self):
self.panel.calculate()
deadline = time.monotonic() + 5
while self.panel._job is not None and time.monotonic() < deadline:
self.app.processEvents()
time.sleep(.002)
self.assertIsNone(self.panel._job)
self.assertIsNotNone(self.panel.curve, self.panel.status.text())
def test_all_methods_are_available_and_parameter_changes_clear_result(self):
for method in ("polynomial", "linear", "pchip", "spline"):
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.assertIsNone(self.panel.curve)
self.assertFalse(self.panel.export_button.isEnabled())
def test_source_units_and_blocking_invalidate_result(self):
for changed in (replace(self.snapshot, axis=Axis("Frequency", "Hz")),
replace(self.snapshot, source="new file"),
replace(self.snapshot, x_range=(0, 1)),
replace(self.snapshot, blocked_reason="FFT")):
self.panel.set_snapshot(self.snapshot)
self.calculate()
self.panel.set_snapshot(changed)
self.assertIsNone(self.panel.curve)
self.assertFalse(self.panel.export_button.isEnabled())
self.assertFalse(self.panel.apply_button.isEnabled())
def test_late_worker_result_is_rejected_even_after_source_returns(self):
# Capture, but do not schedule, the real worker. Deliver its answer after
# the source changes away and back to the same numerical values.
with patch("set_devices.qt_ports.plot_processing.QThreadPool"):
self.panel.calculate()
job = self.panel._job
self.panel.set_snapshot(replace(self.snapshot, source="another"))
self.panel.set_snapshot(self.snapshot)
self.panel._finished(job.signature, process(job.signature), "")
self.assertIsNone(self.panel.curve)
self.assertFalse(self.panel.export_button.isEnabled())
def test_attachment_is_lazy_and_coalesces_source_notifications(self):
widget = QWidget()
calls = []
attachment = PlotProcessingAttachment(widget, lambda: calls.append(1) or self.snapshot, widget.update)
try:
for _ in range(10):
attachment.source_changed()
self.app.processEvents()
self.assertEqual([], calls)
self.assertIsNone(attachment.panel)
attachment.open()
self.assertEqual([1], calls)
for _ in range(10):
attachment.source_changed()
self.app.processEvents()
self.assertEqual([1, 1], calls)
finally:
widget.close()
if __name__ == "__main__":
unittest.main()