keep sweeps in acquisition order through history replays

The processor appended sweeps the GUI fed back to its own history, so its next replay
published them twice and out of order, and the B-scan came out with rotated columns.
Fed-back sweeps now stay out of that history, results are kept in timestamp order,
picking the displayed combo only redraws and only new sweeps are recorded to disk.
This commit is contained in:
Ayzen
2026-10-02 16:05:32 +03:00
parent 45861f4047
commit d283ecfdf1
11 changed files with 421 additions and 91 deletions
+84
View File
@@ -13,6 +13,8 @@ The guard tests pin the other half: the GUI must never write into that ring whil
from __future__ import annotations
from contextlib import suppress
from dataclasses import replace
import json
import os
from pathlib import Path
import unittest
@@ -24,6 +26,7 @@ from PyQt6.QtCore import QSignalBlocker # noqa: E402
from PyQt6.QtWidgets import QApplication # noqa: E402
from python_app.gui.app_window import AppWindow # noqa: E402
from python_app.gui.runtime.history import collection_key # noqa: E402
from python_app.models.dataset_model import ( # noqa: E402
ComboKey,
ResultBlock,
@@ -161,6 +164,35 @@ class PreprocessedWireFormatTest(unittest.TestCase):
self.assertEqual(survived[-1], 200 + capacity + 3)
def _result_of(sweep: SweepCollection) -> ResultCollection:
"""The processor's result of a sweep: it keeps the sweep's id and timestamp."""
return replace(_bscan_result(sweep.collection_id), monotonic_ns=sweep.monotonic_ns)
class _FakeProcessor:
"""Both ends of the processor during a feed-back.
Each pushed sweep comes back as a result, after whatever the results ring already
held when the feed-back started.
"""
slot_size_bytes = 1 << 20
def __init__(self, sweeps: list[SweepCollection], already_published: list[ResultCollection]) -> None:
self._unprocessed = list(sweeps)
self._published = list(already_published)
def push(self, _payload: bytes) -> bool:
self._published.append(_result_of(self._unprocessed.pop(0)))
return True
def pop_result_collection(self) -> ResultCollection | None:
return self._published.pop(0) if self._published else None
def close(self) -> None:
return None
class _IdleResultReader:
"""Stands in for a connected results reader that simply has nothing to hand out."""
@@ -245,6 +277,58 @@ class ReplayGuardTest(unittest.TestCase):
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), window)
def test_feed_back_renders_its_sweeps_in_acquisition_order(self) -> None:
"""Regression: after switching the displayed combo the columns came out rotated.
The processor was still replaying its own sweeps when the GUI fed its history
back, and the GUI took the first results to arrive for its own, in arrival order.
"""
sweeps = [_sweep(cid) for cid in range(1, 11)]
self.w._pre_history.clear()
self.w._pre_history.extend(sweeps)
self.addCleanup(self.w._pre_history.clear)
self.w._result_history.clear()
self.addCleanup(self.w._result_history.clear)
with QSignalBlocker(self.w._bscan_history_window):
self.w._bscan_history_window.setValue(len(sweeps))
# Already in the results ring: the tail of the processor's own replay and a
# result of a sweep that is not being fed back.
processor = _FakeProcessor(sweeps, [_result_of(sweep) for sweep in sweeps[6:]] + [_bscan_result(999)])
self.w._replay_ring_writer = processor
self.addCleanup(setattr, self.w, "_replay_ring_writer", None)
self.w._result_reader = processor
self.addCleanup(self.w._discard_bscan_replay_results)
self.assertTrue(self.w._reprocess_history_through_processor())
self.assertEqual(
[collection_key(collection) for collection in self.w._bscan_replay_results],
[collection_key(sweep) for sweep in sweeps],
)
self.assertIn(999, [collection.collection_id for collection in self.w._result_history])
def test_changing_the_displayed_combo_recomputes_nothing(self) -> None:
# Every combo's B-scan is cached already; recomputing raced the processor (above).
writes: list[dict] = []
self.w._write_live_processing_config = lambda **kwargs: writes.append(kwargs)
self.addCleanup(delattr, self.w, "_write_live_processing_config")
self.w._single_combo_output.editingFinished.emit()
self.assertEqual(writes, [])
self.assertFalse(self.w._bscan_reprocess_timer.isActive())
def test_processor_reprocesses_only_while_the_gui_cannot(self) -> None:
# Its own replay would duplicate the feed-back and race it for the results ring.
def written_flag() -> bool:
self.w._write_live_processing_config()
config = json.loads(self.w._live_config_writer.path.read_text(encoding="utf-8"))
return bool(config["reprocess_current_result"])
self.assertFalse(written_flag())
self.w._supervisor.is_running = lambda: True
self.assertTrue(written_flag())
def test_discarding_replay_falls_back_to_runtime_history(self) -> None:
self.w._bscan_replay_results = [_bscan_result(1)]
self.w._discard_bscan_replay_results()