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:
@@ -129,19 +129,37 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
|
|||||||
|
|
||||||
if (preprocessed_ring_.pop(bytes)) {
|
if (preprocessed_ring_.pop(bytes)) {
|
||||||
auto preprocessed = ipc::deserialize_preprocessed_collection(bytes);
|
auto preprocessed = ipc::deserialize_preprocessed_collection(bytes);
|
||||||
preprocessed_history.push_back(std::move(preprocessed));
|
// Sweeps the GUI feeds back to recompute its B-scan keep their original
|
||||||
while (preprocessed_history.size() > history_limit) {
|
// timestamps, so they are no newer than the newest retained sweep. They
|
||||||
preprocessed_history.erase(preprocessed_history.begin());
|
// are processed like any other but stay out of the history, which must
|
||||||
|
// remain the acquisition in order (later sweeps take their aperture and
|
||||||
|
// background from it), and away from the locator, which reports the
|
||||||
|
// current scene.
|
||||||
|
const bool fed_back = !preprocessed_history.empty() &&
|
||||||
|
preprocessed.monotonic_ns <= preprocessed_history.back().monotonic_ns;
|
||||||
|
if (!fed_back) {
|
||||||
|
preprocessed_history.push_back(std::move(preprocessed));
|
||||||
|
while (preprocessed_history.size() > history_limit) {
|
||||||
|
preprocessed_history.erase(preprocessed_history.begin());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const auto& current = fed_back ? preprocessed : preprocessed_history.back();
|
||||||
|
const auto earlier = std::partition_point(
|
||||||
|
preprocessed_history.begin(),
|
||||||
|
preprocessed_history.end(),
|
||||||
|
[&](const ipc::PreprocessedCollection& retained) { return retained.monotonic_ns < current.monotonic_ns; }
|
||||||
|
);
|
||||||
|
|
||||||
const auto result_collection = process_collection(
|
const auto result_collection = process_collection(
|
||||||
preprocessed_history.back(),
|
current,
|
||||||
std::span<const ipc::PreprocessedCollection>(preprocessed_history.data(), preprocessed_history.size() - 1U),
|
std::span<const ipc::PreprocessedCollection>(preprocessed_history.begin(), earlier),
|
||||||
processor,
|
processor,
|
||||||
live_config
|
live_config
|
||||||
);
|
);
|
||||||
publish_result_collection(result_collection, results_ring_);
|
publish_result_collection(result_collection, results_ring_);
|
||||||
publish_locator(result_collection, live_config);
|
if (!fed_back) {
|
||||||
|
publish_locator(result_collection, live_config);
|
||||||
|
}
|
||||||
consecutive_errors = 0; // made progress; reset the escalation counter
|
consecutive_errors = 0; // made progress; reset the escalation counter
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,9 +514,16 @@ struct Oblique {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
struct Sar3dProcessor::State {
|
struct Sar3dProcessor::State {
|
||||||
// A-scans by collection id for `frames_key`; null marks a collection that
|
// A sweep is its id with its timestamp: ids start again from 1 on every run,
|
||||||
// could not be processed, so its error is reported once and not retried.
|
// while the processor and this cache outlive runs.
|
||||||
std::map<std::uint64_t, std::shared_ptr<const Frame>> frames{};
|
using SweepKey = std::pair<std::uint64_t, std::uint64_t>;
|
||||||
|
[[nodiscard]] static auto sweep_key(const ipc::PreprocessedCollection& collection) -> SweepKey {
|
||||||
|
return {collection.collection_id, collection.monotonic_ns};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A-scans by sweep for `frames_key`; null marks a sweep that could not be
|
||||||
|
// processed, so its error is reported once and not retried.
|
||||||
|
std::map<SweepKey, std::shared_ptr<const Frame>> frames{};
|
||||||
AscanKey frames_key{};
|
AscanKey frames_key{};
|
||||||
std::unique_ptr<sar3d::Gpu> gpu{};
|
std::unique_ptr<sar3d::Gpu> gpu{};
|
||||||
std::string gpu_error{};
|
std::string gpu_error{};
|
||||||
@@ -525,7 +532,7 @@ struct Sar3dProcessor::State {
|
|||||||
|
|
||||||
auto frame(const ipc::PreprocessedCollection& collection, const Settings& settings, sar3d::Gpu& gpu)
|
auto frame(const ipc::PreprocessedCollection& collection, const Settings& settings, sar3d::Gpu& gpu)
|
||||||
-> std::shared_ptr<const Frame> {
|
-> std::shared_ptr<const Frame> {
|
||||||
if (const auto found = frames.find(collection.collection_id); found != frames.end()) {
|
if (const auto found = frames.find(sweep_key(collection)); found != frames.end()) {
|
||||||
return found->second;
|
return found->second;
|
||||||
}
|
}
|
||||||
std::shared_ptr<const Frame> frame{};
|
std::shared_ptr<const Frame> frame{};
|
||||||
@@ -534,7 +541,7 @@ struct Sar3dProcessor::State {
|
|||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
report_error(error.what());
|
report_error(error.what());
|
||||||
}
|
}
|
||||||
frames.emplace(collection.collection_id, frame);
|
frames.emplace(sweep_key(collection), frame);
|
||||||
return frame;
|
return frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,10 +579,10 @@ struct Sar3dProcessor::State {
|
|||||||
// the newest one, so the image trails the input by r frames. Collections
|
// the newest one, so the image trails the input by r frames. Collections
|
||||||
// that left the aperture are dropped from the cache.
|
// that left the aperture are dropped from the cache.
|
||||||
std::vector<std::shared_ptr<const Frame>> aperture{};
|
std::vector<std::shared_ptr<const Frame>> aperture{};
|
||||||
std::map<std::uint64_t, std::shared_ptr<const Frame>> kept{{collection.collection_id, newest}};
|
std::map<SweepKey, std::shared_ptr<const Frame>> kept{{sweep_key(collection), newest}};
|
||||||
for (const auto& member : previous.last(std::min(previous.size(), 2U * settings.frame_radius))) {
|
for (const auto& member : previous.last(std::min(previous.size(), 2U * settings.frame_radius))) {
|
||||||
auto member_frame = frame(member, settings, gpu);
|
auto member_frame = frame(member, settings, gpu);
|
||||||
kept.emplace(member.collection_id, member_frame);
|
kept.emplace(sweep_key(member), member_frame);
|
||||||
const bool same_axis = member_frame != nullptr && member_frame->samples == newest->samples &&
|
const bool same_axis = member_frame != nullptr && member_frame->samples == newest->samples &&
|
||||||
member_frame->dt_s == newest->dt_s;
|
member_frame->dt_s == newest->dt_s;
|
||||||
if (same_axis) {
|
if (same_axis) {
|
||||||
|
|||||||
@@ -305,15 +305,20 @@ class AppWindowLiveProcessingMixin:
|
|||||||
*,
|
*,
|
||||||
history_command: str = "none",
|
history_command: str = "none",
|
||||||
bump_history_seq: bool = False,
|
bump_history_seq: bool = False,
|
||||||
reprocess_current_result: bool = True,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Persist current live processing config to runtime JSON file."""
|
"""Persist current live processing config to runtime JSON file.
|
||||||
|
|
||||||
|
Every change of the file makes the processor reprocess its retained sweeps,
|
||||||
|
except while the GUI recomputes the B-scan itself by feeding its own, longer
|
||||||
|
history back (AppWindowBscanReplayMixin): the processor's replay would then
|
||||||
|
only duplicate that work and race it for the results ring.
|
||||||
|
"""
|
||||||
if bump_history_seq:
|
if bump_history_seq:
|
||||||
self._history_command_seq += 1
|
self._history_command_seq += 1
|
||||||
self._live_config_writer.write(
|
self._live_config_writer.write(
|
||||||
self._live_processing_config(
|
self._live_processing_config(
|
||||||
history_command=history_command,
|
history_command=history_command,
|
||||||
reprocess_current_result=reprocess_current_result,
|
reprocess_current_result=not self._can_reprocess_history(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -380,13 +385,15 @@ class AppWindowLiveProcessingMixin:
|
|||||||
|
|
||||||
self._write_live_processing_config()
|
self._write_live_processing_config()
|
||||||
if current_mode == "bscan":
|
if current_mode == "bscan":
|
||||||
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
if self._can_reprocess_history():
|
||||||
|
# Recompute every column on screen from the GUI's history. Debounced,
|
||||||
|
# so dragging a spin box sends one burst instead of one per step.
|
||||||
|
self._schedule_bscan_history_reprocess()
|
||||||
|
else:
|
||||||
|
# The processor reprocesses its own newest sweeps; take them in.
|
||||||
|
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
|
||||||
self._sync_bscan_history_from_results()
|
self._sync_bscan_history_from_results()
|
||||||
self._draw_bscan_heatmap_from_history()
|
self._draw_bscan_heatmap_from_history()
|
||||||
# The processor only refreshed its own newest sweeps; queue a full
|
|
||||||
# recompute of everything on screen. Debounced, so dragging a spin box
|
|
||||||
# sends one burst instead of one per step.
|
|
||||||
self._schedule_bscan_history_reprocess()
|
|
||||||
elif self._is_gpr_like_processing_mode(current_mode):
|
elif self._is_gpr_like_processing_mode(current_mode):
|
||||||
latest = self._drain_results_until_quiet(timeout_s=0.8, poll_s=0.02)
|
latest = self._drain_results_until_quiet(timeout_s=0.8, poll_s=0.02)
|
||||||
collection = latest
|
collection = latest
|
||||||
|
|||||||
@@ -597,8 +597,11 @@ class AppWindowPipelineMixin:
|
|||||||
if collection is None:
|
if collection is None:
|
||||||
break
|
break
|
||||||
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
|
self._pipeline_metrics.record("processing", int(collection.processing_duration_ns))
|
||||||
record_result_history(self._result_history, collection)
|
# Only a new sweep is a new measurement. Any other result is the processor
|
||||||
self._record_collection("results", collection)
|
# reprocessing retained sweeps: recording it would duplicate a measurement
|
||||||
|
# or put one out of order.
|
||||||
|
if record_result_history(self._result_history, collection):
|
||||||
|
self._record_collection("results", collection)
|
||||||
latest = collection
|
latest = collection
|
||||||
return latest
|
return latest
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ class AppWindowBscanPlotMixin:
|
|||||||
self._sync_bscan_history_from_results()
|
self._sync_bscan_history_from_results()
|
||||||
return self._draw_bscan_heatmap_from_history()
|
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:
|
def _configure_bscan_plot_axes(self) -> None:
|
||||||
"""Apply persistent B-scan plot axis labels and base view settings."""
|
"""Apply persistent B-scan plot axis labels and base view settings."""
|
||||||
plot = self._bscan_plot
|
plot = self._bscan_plot
|
||||||
@@ -326,8 +336,8 @@ class AppWindowBscanPlotMixin:
|
|||||||
|
|
||||||
A completed replay is the better source: it is exactly `window` long and every
|
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
|
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
|
not: it can also hold results of sweeps the GUI never received preprocessed,
|
||||||
out of order, leaving the deque unsorted for the rest of the session.
|
which were not re-fed and keep their old settings.
|
||||||
"""
|
"""
|
||||||
replayed = getattr(self, "_bscan_replay_results", None)
|
replayed = getattr(self, "_bscan_replay_results", None)
|
||||||
if replayed:
|
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
|
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
|
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.
|
`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
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
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.orchestration.shm import ShmRingWriter
|
||||||
from python_app.storage.npz.serialize import PREPROC_MAGIC, serialize_trace_collection
|
from python_app.storage.npz.serialize import PREPROC_MAGIC, serialize_trace_collection
|
||||||
|
|
||||||
@@ -51,16 +54,12 @@ class AppWindowBscanReplayMixin:
|
|||||||
|
|
||||||
window = self._bscan_display_window_scans()
|
window = self._bscan_display_window_scans()
|
||||||
tail = list(self._pre_history)[-window:]
|
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
|
# `_pre_history` and `_result_history` are fed by two different rings, each
|
||||||
# dropping independently when the producer outruns the GUI. A low overlap means
|
# 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
|
# many replayed results are of sweeps the result history never held.
|
||||||
# are appended rather than replacing the columns already on screen.
|
result_keys = {collection_key(collection) for collection in self._result_history}
|
||||||
result_keys = {
|
overlap = sum(1 for key in keys if key in 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
|
|
||||||
)
|
|
||||||
self._log_debug(
|
self._log_debug(
|
||||||
f"B-scan replay starting: window={window}, preprocessed history="
|
f"B-scan replay starting: window={window}, preprocessed history="
|
||||||
f"{len(self._pre_history)}, result history={len(self._result_history)}, "
|
f"{len(self._pre_history)}, result history={len(self._result_history)}, "
|
||||||
@@ -73,11 +72,6 @@ class AppWindowBscanReplayMixin:
|
|||||||
if writer is None:
|
if writer is None:
|
||||||
return False
|
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
|
# 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
|
# 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
|
# feeds the pipeline metrics. Restart in `finally`: losing the ring poll on an
|
||||||
@@ -85,8 +79,8 @@ class AppWindowBscanReplayMixin:
|
|||||||
self._timer.stop()
|
self._timer.stop()
|
||||||
self._bscan_replay_active = True
|
self._bscan_replay_active = True
|
||||||
sent = 0
|
sent = 0
|
||||||
received = 0
|
replayed_keys = set(keys)
|
||||||
replayed: list = []
|
replayed: dict[tuple[int, int], ResultCollection] = {}
|
||||||
try:
|
try:
|
||||||
for start in range(0, len(tail), _REPLAY_CHUNK_SIZE):
|
for start in range(0, len(tail), _REPLAY_CHUNK_SIZE):
|
||||||
chunk = tail[start : start + _REPLAY_CHUNK_SIZE]
|
chunk = tail[start : start + _REPLAY_CHUNK_SIZE]
|
||||||
@@ -107,31 +101,24 @@ class AppWindowBscanReplayMixin:
|
|||||||
break
|
break
|
||||||
pushed += 1
|
pushed += 1
|
||||||
sent += pushed
|
sent += pushed
|
||||||
got = self._collect_replayed_results(
|
if not self._collect_replayed_results(
|
||||||
expected=pushed, timeout_s=_REPLAY_CHUNK_TIMEOUT_S, into=replayed
|
keys[start : start + pushed], replayed_keys, replayed, timeout_s=_REPLAY_CHUNK_TIMEOUT_S
|
||||||
)
|
):
|
||||||
received += got
|
# The processor did not answer in time; keep going, but say which
|
||||||
if got < pushed:
|
# chunk so a systematic stall is visible.
|
||||||
# A short chunk means the processor did not answer in time; keep
|
self._log_debug(f"B-scan replay chunk at offset {start} came back incomplete.")
|
||||||
# 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 pushed != len(chunk):
|
if pushed != len(chunk):
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
self._bscan_replay_active = False
|
self._bscan_replay_active = False
|
||||||
self._timer.start()
|
self._timer.start()
|
||||||
|
|
||||||
# Render straight from what came back rather than from `_result_history`.
|
# 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
|
||||||
# The two histories are fed by different rings that drop independently, so the
|
# independently, so the latter can also hold results of sweeps the GUI never
|
||||||
# sweeps we re-sent only partly overlap the results already on record. The
|
# received preprocessed, which were not re-fed and keep their old settings.
|
||||||
# non-overlapping ones get appended to the deque even though their ids are old,
|
self._bscan_replay_results = [replayed[key] for key in keys if key in replayed]
|
||||||
# so its newest `window` entries are a mix of freshly and stale-processed
|
received = sum(1 for key in keys[:sent] if key in replayed)
|
||||||
# frames. What came back is by construction the right count and uniformly
|
|
||||||
# processed, so use it directly.
|
|
||||||
self._bscan_replay_results = replayed
|
|
||||||
|
|
||||||
# Replayed collections keep their original ids and timestamps, so the render
|
# 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
|
# 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
|
return self._result_reader is not None
|
||||||
|
|
||||||
def _collect_replayed_results(
|
def _collect_replayed_results(
|
||||||
self, *, expected: int, timeout_s: float, into: list | None = None
|
self,
|
||||||
) -> int:
|
waiting_for: list[tuple[int, int]],
|
||||||
"""Pop `expected` replayed results, recording them into runtime history.
|
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
|
Every result goes into the runtime history, deliberately not through
|
||||||
metrics and the disk recorder, and a replay is neither new acquisition nor
|
`_read_all_results`: that path also feeds the pipeline metrics and the disk
|
||||||
something that should be written to disk a second time.
|
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
|
||||||
`into` also receives them in arrival order, which is what the B-scan renders —
|
go into `into` by sweep, the latest winning, so whatever else the ring delivers
|
||||||
see `_reprocess_history_through_processor` for why the runtime history alone is
|
meanwhile, and in whichever order, cannot get into the image or reorder it.
|
||||||
not a usable source afterwards.
|
|
||||||
"""
|
"""
|
||||||
if expected <= 0 or self._result_reader is None:
|
if self._result_reader is None:
|
||||||
return 0
|
return False
|
||||||
|
|
||||||
deadline = time.monotonic() + timeout_s
|
deadline = time.monotonic() + timeout_s
|
||||||
received = 0
|
while any(key not in into for key in waiting_for) and time.monotonic() < deadline:
|
||||||
while received < expected and time.monotonic() < deadline:
|
|
||||||
collection = self._result_reader.pop_result_collection()
|
collection = self._result_reader.pop_result_collection()
|
||||||
if collection is None:
|
if collection is None:
|
||||||
self._pump_events_during_drain(0.005)
|
self._pump_events_during_drain(0.005)
|
||||||
continue
|
continue
|
||||||
record_result_history(self._result_history, collection)
|
record_result_history(self._result_history, collection)
|
||||||
if into is not None:
|
key = collection_key(collection)
|
||||||
into.append(collection)
|
if key in replayed_keys:
|
||||||
received += 1
|
into[key] = collection
|
||||||
return received
|
return all(key in into for key in waiting_for)
|
||||||
|
|
||||||
def _ensure_replay_ring_writer(self) -> ShmRingWriter | None:
|
def _ensure_replay_ring_writer(self) -> ShmRingWriter | None:
|
||||||
"""Return a writer attached to the processor's input ring, creating it lazily.
|
"""Return a writer attached to the processor's input ring, creating it lazily.
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ def build_switch_group(owner) -> QGroupBox:
|
|||||||
|
|
||||||
owner._run_combos_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("text"))
|
owner._run_combos_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("text"))
|
||||||
owner._single_combo_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("single"))
|
owner._single_combo_select_button.clicked.connect(lambda: owner._set_combo_selection_mode("single"))
|
||||||
owner._single_combo_output.editingFinished.connect(owner._on_processing_live_settings_changed)
|
# The live processing config has no combo: picking another one only changes which
|
||||||
owner._single_combo_input.editingFinished.connect(owner._on_processing_live_settings_changed)
|
# B-scan is shown (and, outside matrix mode, the combo of the next run).
|
||||||
|
owner._single_combo_output.editingFinished.connect(owner._on_bscan_display_combo_changed)
|
||||||
|
owner._single_combo_input.editingFinished.connect(owner._on_bscan_display_combo_changed)
|
||||||
owner._set_combo_selection_mode(str(switch_defaults.combo_mode))
|
owner._set_combo_selection_mode(str(switch_defaults.combo_mode))
|
||||||
|
|
||||||
return group
|
return group
|
||||||
|
|||||||
@@ -12,21 +12,48 @@ from python_app.orchestration.preprocess_assets import PREPROCESS_ASSET_KEYS, pr
|
|||||||
THistoryCollection = TypeVar("THistoryCollection", SweepCollection, ResultCollection)
|
THistoryCollection = TypeVar("THistoryCollection", SweepCollection, ResultCollection)
|
||||||
|
|
||||||
|
|
||||||
|
def collection_key(collection: SweepCollection | ResultCollection) -> tuple[int, int]:
|
||||||
|
"""Identity of a sweep through the pipeline: its result keeps the same id and timestamp.
|
||||||
|
|
||||||
|
`monotonic_ns` is stamped by `data_preprocessor` on the system monotonic clock, so
|
||||||
|
it also orders sweeps by acquisition, across runs too (ids restart from 1).
|
||||||
|
"""
|
||||||
|
return int(collection.collection_id), int(collection.monotonic_ns)
|
||||||
|
|
||||||
|
|
||||||
def record_result_history(
|
def record_result_history(
|
||||||
result_history: deque[ResultCollection],
|
result_history: deque[ResultCollection],
|
||||||
collection: ResultCollection,
|
collection: ResultCollection,
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""Append a new result, or replace the existing entry with the same stable key."""
|
"""Record a result, keeping the history in acquisition order; return whether it is new.
|
||||||
for index in range(len(result_history) - 1, -1, -1):
|
|
||||||
existing = result_history[index]
|
|
||||||
if (
|
|
||||||
existing.collection_id == collection.collection_id
|
|
||||||
and existing.monotonic_ns == collection.monotonic_ns
|
|
||||||
):
|
|
||||||
result_history[index] = collection
|
|
||||||
return
|
|
||||||
|
|
||||||
result_history.append(collection)
|
A result of a sweep already present replaces it (the sweep was reprocessed).
|
||||||
|
Any other result goes after the newest older one: normally at the end, as the
|
||||||
|
result of a new sweep, but the processor reprocessing its retained sweeps also
|
||||||
|
delivers sweeps whose first result the GUI missed, and appending those would
|
||||||
|
show them out of order.
|
||||||
|
"""
|
||||||
|
key = collection_key(collection)
|
||||||
|
position = len(result_history)
|
||||||
|
while position > 0:
|
||||||
|
existing = result_history[position - 1]
|
||||||
|
if collection_key(existing) == key:
|
||||||
|
result_history[position - 1] = collection
|
||||||
|
return False
|
||||||
|
if existing.monotonic_ns < collection.monotonic_ns:
|
||||||
|
break
|
||||||
|
position -= 1
|
||||||
|
|
||||||
|
if position == len(result_history):
|
||||||
|
result_history.append(collection)
|
||||||
|
return True
|
||||||
|
if len(result_history) == result_history.maxlen:
|
||||||
|
if position == 0:
|
||||||
|
return False # older than everything kept, so it would be the first to go
|
||||||
|
result_history.popleft()
|
||||||
|
position -= 1
|
||||||
|
result_history.insert(position, collection)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def remove_last_aligned_histories(
|
def remove_last_aligned_histories(
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|||||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
from python_app.gui.app_window import AppWindow # noqa: E402
|
from python_app.gui.app_window import AppWindow # noqa: E402
|
||||||
|
from collections import deque # noqa: E402
|
||||||
|
|
||||||
from python_app.gui.runtime.history import record_result_history # noqa: E402
|
from python_app.gui.runtime.history import record_result_history # noqa: E402
|
||||||
from python_app.models.dataset_model import ( # noqa: E402
|
from python_app.models.dataset_model import ( # noqa: E402
|
||||||
ComboKey,
|
ComboKey,
|
||||||
@@ -167,5 +169,61 @@ class BscanDisplayWindowTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 50)
|
self.assertEqual(len(self.w._bscan_history_by_combo[(0, 0)]), 50)
|
||||||
|
|
||||||
|
|
||||||
|
class ResultHistoryOrderTest(unittest.TestCase):
|
||||||
|
"""The result history stays in acquisition order whatever order results arrive in.
|
||||||
|
|
||||||
|
The processor reprocessing its retained sweeps also delivers sweeps whose first
|
||||||
|
result the GUI missed (the results ring overwrites unread slots). Appended, such a
|
||||||
|
sweep showed up as the newest B-scan column, and was recorded to disk once more.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ids(history: deque[ResultCollection]) -> list[int]:
|
||||||
|
return [int(collection.collection_id) for collection in history]
|
||||||
|
|
||||||
|
def test_a_new_sweep_is_appended(self) -> None:
|
||||||
|
history = deque(_bscan_result(cid) for cid in (1, 2))
|
||||||
|
self.assertTrue(record_result_history(history, _bscan_result(3)))
|
||||||
|
self.assertEqual(self._ids(history), [1, 2, 3])
|
||||||
|
|
||||||
|
def test_a_late_result_takes_its_place_in_acquisition_order(self) -> None:
|
||||||
|
history = deque(_bscan_result(cid) for cid in (1, 2, 4, 5))
|
||||||
|
self.assertFalse(record_result_history(history, _bscan_result(3)))
|
||||||
|
self.assertEqual(self._ids(history), [1, 2, 3, 4, 5])
|
||||||
|
|
||||||
|
def test_a_reprocessed_result_replaces_its_entry(self) -> None:
|
||||||
|
history = deque(_bscan_result(cid) for cid in (1, 2, 3))
|
||||||
|
reprocessed = _bscan_result(2)
|
||||||
|
self.assertFalse(record_result_history(history, reprocessed))
|
||||||
|
self.assertEqual(self._ids(history), [1, 2, 3])
|
||||||
|
self.assertIs(history[1], reprocessed)
|
||||||
|
|
||||||
|
def test_a_full_history_drops_its_oldest_entry(self) -> None:
|
||||||
|
history = deque((_bscan_result(cid) for cid in (1, 2, 4, 5)), maxlen=4)
|
||||||
|
record_result_history(history, _bscan_result(3))
|
||||||
|
self.assertEqual(self._ids(history), [2, 3, 4, 5])
|
||||||
|
record_result_history(history, _bscan_result(1)) # older than all it keeps
|
||||||
|
self.assertEqual(self._ids(history), [2, 3, 4, 5])
|
||||||
|
record_result_history(history, _bscan_result(6))
|
||||||
|
self.assertEqual(self._ids(history), [3, 4, 5, 6])
|
||||||
|
|
||||||
|
def test_only_new_sweeps_are_recorded_to_disk(self) -> None:
|
||||||
|
window = _window
|
||||||
|
arrivals = [_bscan_result(cid) for cid in (2, 3, 2, 1)] # 2 reprocessed, 1 late
|
||||||
|
reader = type("Reader", (), {"pop_result_collection": lambda _self: arrivals.pop(0) if arrivals else None})()
|
||||||
|
recorded: list[int] = []
|
||||||
|
window._result_reader = reader
|
||||||
|
self.addCleanup(setattr, window, "_result_reader", None)
|
||||||
|
window._record_collection = lambda stage, collection: recorded.append(int(collection.collection_id))
|
||||||
|
self.addCleanup(delattr, window, "_record_collection")
|
||||||
|
window._result_history.clear()
|
||||||
|
self.addCleanup(window._result_history.clear)
|
||||||
|
|
||||||
|
window._read_all_results()
|
||||||
|
|
||||||
|
self.assertEqual(recorded, [2, 3])
|
||||||
|
self.assertEqual(self._ids(window._result_history), [1, 2, 3])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
from dataclasses import replace
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import unittest
|
import unittest
|
||||||
@@ -24,6 +26,7 @@ from PyQt6.QtCore import QSignalBlocker # noqa: E402
|
|||||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||||
|
|
||||||
from python_app.gui.app_window import AppWindow # 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
|
from python_app.models.dataset_model import ( # noqa: E402
|
||||||
ComboKey,
|
ComboKey,
|
||||||
ResultBlock,
|
ResultBlock,
|
||||||
@@ -161,6 +164,35 @@ class PreprocessedWireFormatTest(unittest.TestCase):
|
|||||||
self.assertEqual(survived[-1], 200 + capacity + 3)
|
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:
|
class _IdleResultReader:
|
||||||
"""Stands in for a connected results reader that simply has nothing to hand out."""
|
"""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)
|
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:
|
def test_discarding_replay_falls_back_to_runtime_history(self) -> None:
|
||||||
self.w._bscan_replay_results = [_bscan_result(1)]
|
self.w._bscan_replay_results = [_bscan_result(1)]
|
||||||
self.w._discard_bscan_replay_results()
|
self.w._discard_bscan_replay_results()
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""The processor's history stays the acquisition, in order, when the GUI feeds sweeps back.
|
||||||
|
|
||||||
|
To recompute the whole B-scan the GUI pushes its retained sweeps into the processor's
|
||||||
|
input ring again (AppWindowBscanReplayMixin). The processor used to append them to its
|
||||||
|
own history as if they were new, so its next replay published every sweep two or three
|
||||||
|
times and out of order, and the B-scan came out with its columns rotated.
|
||||||
|
|
||||||
|
Runs the real `data_processor` on private rings; skipped when it has not been built.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
|
||||||
|
from python_app.orchestration.live_processing_config import ProcessingLiveConfig, ProcessingLiveConfigWriter
|
||||||
|
from python_app.orchestration.shm import ShmRingWriter
|
||||||
|
from python_app.orchestration.shm.ring_reader import ShmRingReader
|
||||||
|
from python_app.storage.npz.serialize import PREPROC_MAGIC, serialize_trace_collection
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
_PROCESSOR = _ROOT / "build/bin/data_processor"
|
||||||
|
_SWEEPS = 5
|
||||||
|
_TIMEOUT_S = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _sweep(collection_id: int, monotonic_ns: int) -> SweepCollection:
|
||||||
|
points = 64
|
||||||
|
trace = TraceData(
|
||||||
|
combo=ComboKey(input=0, output=0),
|
||||||
|
frequency_hz=np.linspace(1e9, 6e9, points, dtype=np.float32),
|
||||||
|
s11=np.zeros(points, dtype=np.complex64),
|
||||||
|
s21=np.full(points, collection_id, dtype=np.complex64),
|
||||||
|
)
|
||||||
|
return SweepCollection(collection_id=collection_id, monotonic_ns=monotonic_ns, traces=[trace])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(_PROCESSOR.exists(), "data_processor is not built (run make)")
|
||||||
|
class FedBackSweepsTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
workdir = Path(tempfile.mkdtemp(prefix="processor_history_"))
|
||||||
|
config = json.loads((_ROOT / "run_config.json").read_text(encoding="utf-8"))
|
||||||
|
for name, ring in config["rings"].items():
|
||||||
|
ring["name"] = f"/test_processor_history_{os.getpid()}_{name}"
|
||||||
|
config["run"]["combos"] = [{"input": 0, "output": 0}]
|
||||||
|
config["run"]["processing_live_config_path"] = str(workdir / "processing_live.json")
|
||||||
|
config["run"]["locator_server"]["enabled"] = False
|
||||||
|
config_path = workdir / "run_config.json"
|
||||||
|
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||||
|
self._live = ProcessingLiveConfig(processor_mode="bscan")
|
||||||
|
self._live_writer = ProcessingLiveConfigWriter(workdir / "processing_live.json")
|
||||||
|
self._live_writer.write(self._live)
|
||||||
|
|
||||||
|
rings = config["rings"]
|
||||||
|
shm_files = [Path("/dev/shm" + ring["name"]) for ring in rings.values()]
|
||||||
|
processor = subprocess.Popen(
|
||||||
|
[str(_PROCESSOR), "--config", str(config_path)],
|
||||||
|
cwd=_ROOT,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def cleanup() -> None:
|
||||||
|
processor.terminate()
|
||||||
|
processor.wait(_TIMEOUT_S)
|
||||||
|
for path in shm_files:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
for path in workdir.iterdir():
|
||||||
|
path.unlink()
|
||||||
|
workdir.rmdir()
|
||||||
|
|
||||||
|
self.addCleanup(cleanup)
|
||||||
|
# The processor creates both rings at start-up, the results ring last.
|
||||||
|
self._results = ShmRingReader(rings["results"]["name"], open_timeout_s=_TIMEOUT_S)
|
||||||
|
self.addCleanup(self._results.close)
|
||||||
|
ring = rings["preprocessed"]
|
||||||
|
self._input = ShmRingWriter(ring["name"], int(ring["capacity"]), int(ring["slot_size_bytes"]))
|
||||||
|
self.addCleanup(self._input.close)
|
||||||
|
|
||||||
|
def _push(self, sweeps: list[SweepCollection]) -> None:
|
||||||
|
for sweep in sweeps:
|
||||||
|
self.assertTrue(self._input.push(serialize_trace_collection(sweep, PREPROC_MAGIC)))
|
||||||
|
|
||||||
|
def _published_ids(self, count: int) -> list[int]:
|
||||||
|
"""Ids of the next results: `count` of them plus any that follow shortly after."""
|
||||||
|
ids: list[int] = []
|
||||||
|
deadline = time.monotonic() + _TIMEOUT_S
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
collection = self._results.pop_result_collection()
|
||||||
|
if collection is None:
|
||||||
|
time.sleep(0.01)
|
||||||
|
continue
|
||||||
|
ids.append(int(collection.collection_id))
|
||||||
|
if len(ids) == count:
|
||||||
|
deadline = min(deadline, time.monotonic() + 0.3)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
def test_fed_back_sweeps_are_processed_but_not_retained(self) -> None:
|
||||||
|
start_ns = time.monotonic_ns()
|
||||||
|
sweeps = [_sweep(index, start_ns + index * 1_000_000) for index in range(1, _SWEEPS + 1)]
|
||||||
|
expected = [sweep.collection_id for sweep in sweeps]
|
||||||
|
self._push(sweeps)
|
||||||
|
self.assertEqual(self._published_ids(_SWEEPS), expected)
|
||||||
|
|
||||||
|
# The GUI feeds them back: each is processed again under its own id.
|
||||||
|
self._push(sweeps)
|
||||||
|
self.assertEqual(self._published_ids(_SWEEPS), expected)
|
||||||
|
|
||||||
|
# A settings change makes the processor replay its history: each sweep once, in order.
|
||||||
|
self._live.bscan_gain = 2.0
|
||||||
|
self._live_writer.write(self._live)
|
||||||
|
self.assertEqual(self._published_ids(_SWEEPS), expected)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user