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
@@ -202,6 +202,16 @@ class AppWindowBscanPlotMixin:
self._sync_bscan_history_from_results()
return self._draw_bscan_heatmap_from_history()
def _on_bscan_display_combo_changed(self) -> None:
"""Show the selected combo's B-scan; the histories of all combos are already cached."""
if self._processing_mode.currentText() != "bscan":
return
try:
self._sync_bscan_history_from_results()
self._draw_bscan_heatmap_from_history()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to redraw the B-scan", exc)
def _configure_bscan_plot_axes(self) -> None:
"""Apply persistent B-scan plot axis labels and base view settings."""
plot = self._bscan_plot
@@ -326,8 +336,8 @@ class AppWindowBscanPlotMixin:
A completed replay is the better source: it is exactly `window` long and every
entry went through the processor with the same settings. The runtime history is
not usable right after one, because results whose ids it never held are appended
out of order, leaving the deque unsorted for the rest of the session.
not: it can also hold results of sweeps the GUI never received preprocessed,
which were not re-fed and keep their old settings.
"""
replayed = getattr(self, "_bscan_replay_results", None)
if replayed:
@@ -10,13 +10,16 @@ to what the processor consumes: `data_preprocessor` pushes the very same seriali
buffer into both the working ring and the tap the GUI reads. So instead of making the
processor hoard sweeps, we hand its own raw material back to it and let the ordinary
`pop -> process -> publish` path recompute every column with the current settings.
The re-fed sweeps keep their timestamps, by which the processor tells them from new
ones and keeps them out of its own history.
"""
from __future__ import annotations
import time
from python_app.gui.runtime.history import record_result_history
from python_app.gui.runtime.history import collection_key, record_result_history
from python_app.models.dataset_model import ResultCollection
from python_app.orchestration.shm import ShmRingWriter
from python_app.storage.npz.serialize import PREPROC_MAGIC, serialize_trace_collection
@@ -51,16 +54,12 @@ class AppWindowBscanReplayMixin:
window = self._bscan_display_window_scans()
tail = list(self._pre_history)[-window:]
keys = [collection_key(collection) for collection in tail]
# `_pre_history` and `_result_history` are fed by two different rings, each
# dropping independently when the producer outruns the GUI. A low overlap means
# the replayed results arrive under ids the result history never held, so they
# are appended rather than replacing the columns already on screen.
result_keys = {
(int(c.collection_id), int(c.monotonic_ns)) for c in self._result_history
}
overlap = sum(
1 for c in tail if (int(c.collection_id), int(c.monotonic_ns)) in result_keys
)
# many replayed results are of sweeps the result history never held.
result_keys = {collection_key(collection) for collection in self._result_history}
overlap = sum(1 for key in keys if key in result_keys)
self._log_debug(
f"B-scan replay starting: window={window}, preprocessed history="
f"{len(self._pre_history)}, result history={len(self._result_history)}, "
@@ -73,11 +72,6 @@ class AppWindowBscanReplayMixin:
if writer is None:
return False
# The processor replays its own retained sweeps on every live-config revision
# bump. We are about to send the same collections (and more), so suppress it
# rather than let it publish the newest 50 twice.
self._write_live_processing_config(reprocess_current_result=False)
# Our own event pumping would otherwise let the poll timer fire and consume the
# replayed results through `_read_all_results`, which records them to disk and
# feeds the pipeline metrics. Restart in `finally`: losing the ring poll on an
@@ -85,8 +79,8 @@ class AppWindowBscanReplayMixin:
self._timer.stop()
self._bscan_replay_active = True
sent = 0
received = 0
replayed: list = []
replayed_keys = set(keys)
replayed: dict[tuple[int, int], ResultCollection] = {}
try:
for start in range(0, len(tail), _REPLAY_CHUNK_SIZE):
chunk = tail[start : start + _REPLAY_CHUNK_SIZE]
@@ -107,31 +101,24 @@ class AppWindowBscanReplayMixin:
break
pushed += 1
sent += pushed
got = self._collect_replayed_results(
expected=pushed, timeout_s=_REPLAY_CHUNK_TIMEOUT_S, into=replayed
)
received += got
if got < pushed:
# A short chunk means the processor did not answer in time; keep
# going, but say which one so a systematic stall is visible.
self._log_debug(
f"B-scan replay chunk at offset {start}: pushed={pushed}, recovered={got}."
)
if not self._collect_replayed_results(
keys[start : start + pushed], replayed_keys, replayed, timeout_s=_REPLAY_CHUNK_TIMEOUT_S
):
# The processor did not answer in time; keep going, but say which
# chunk so a systematic stall is visible.
self._log_debug(f"B-scan replay chunk at offset {start} came back incomplete.")
if pushed != len(chunk):
break
finally:
self._bscan_replay_active = False
self._timer.start()
# Render straight from what came back rather than from `_result_history`.
#
# The two histories are fed by different rings that drop independently, so the
# sweeps we re-sent only partly overlap the results already on record. The
# non-overlapping ones get appended to the deque even though their ids are old,
# so its newest `window` entries are a mix of freshly and stale-processed
# frames. What came back is by construction the right count and uniformly
# processed, so use it directly.
self._bscan_replay_results = replayed
# Render exactly the re-fed sweeps, in acquisition order, rather than the newest
# `window` of `_result_history`: the two histories are fed by rings that drop
# independently, so the latter can also hold results of sweeps the GUI never
# received preprocessed, which were not re-fed and keep their old settings.
self._bscan_replay_results = [replayed[key] for key in keys if key in replayed]
received = sum(1 for key in keys[:sent] if key in replayed)
# Replayed collections keep their original ids and timestamps, so the render
# signature is unchanged even though the payload values are not. Drop it or the
@@ -162,33 +149,36 @@ class AppWindowBscanReplayMixin:
return self._result_reader is not None
def _collect_replayed_results(
self, *, expected: int, timeout_s: float, into: list | None = None
) -> int:
"""Pop `expected` replayed results, recording them into runtime history.
self,
waiting_for: list[tuple[int, int]],
replayed_keys: set[tuple[int, int]],
into: dict[tuple[int, int], ResultCollection],
*,
timeout_s: float,
) -> bool:
"""Pop results until those of the sweeps `waiting_for` are back; return whether they are.
Deliberately bypasses `_read_all_results`: that path also feeds the pipeline
metrics and the disk recorder, and a replay is neither new acquisition nor
something that should be written to disk a second time.
`into` also receives them in arrival order, which is what the B-scan renders —
see `_reprocess_history_through_processor` for why the runtime history alone is
not a usable source afterwards.
Every result goes into the runtime history, deliberately not through
`_read_all_results`: that path also feeds the pipeline metrics and the disk
recorder, and a replay is neither new acquisition nor something that should be
written to disk a second time. Results of re-fed sweeps (`replayed_keys`) also
go into `into` by sweep, the latest winning, so whatever else the ring delivers
meanwhile, and in whichever order, cannot get into the image or reorder it.
"""
if expected <= 0 or self._result_reader is None:
return 0
if self._result_reader is None:
return False
deadline = time.monotonic() + timeout_s
received = 0
while received < expected and time.monotonic() < deadline:
while any(key not in into for key in waiting_for) and time.monotonic() < deadline:
collection = self._result_reader.pop_result_collection()
if collection is None:
self._pump_events_during_drain(0.005)
continue
record_result_history(self._result_history, collection)
if into is not None:
into.append(collection)
received += 1
return received
key = collection_key(collection)
if key in replayed_keys:
into[key] = collection
return all(key in into for key in waiting_for)
def _ensure_replay_ring_writer(self) -> ShmRingWriter | None:
"""Return a writer attached to the processor's input ring, creating it lazily.