diff --git a/python_app/gui/controllers/app_window_config_mixin.py b/python_app/gui/controllers/app_window_config_mixin.py index 5a264fa..36a3991 100644 --- a/python_app/gui/controllers/app_window_config_mixin.py +++ b/python_app/gui/controllers/app_window_config_mixin.py @@ -2,6 +2,7 @@ from __future__ import annotations +from python_app.gui.runtime.history import remove_last_aligned_histories from python_app.hardware_full.librevna_service import LibreVnaService from python_app.models.run_config_model import ComboModel, RunConfigModel from python_app.orchestration.config_writer import parse_combos_from_text @@ -154,10 +155,20 @@ class AppWindowConfigMixin: dropped_results = self._result_reader.drop_all() if remove_last_only: - if self._result_history: - self._result_history.pop() + retained_raw, retained_pre, retained_result = remove_last_aligned_histories( + list(self._raw_history), + list(self._pre_history), + list(self._result_history), + ) else: - self._result_history.clear() + retained_raw = [] + retained_pre = [] + retained_result = [] + self._replace_runtime_history( + retained_raw=retained_raw, + retained_pre=retained_pre, + retained_result=retained_result, + ) self._clear_bscan_plot_history() self._write_live_processing_config(history_command=history_command, bump_history_seq=True) diff --git a/python_app/gui/controllers/app_window_pipeline_mixin.py b/python_app/gui/controllers/app_window_pipeline_mixin.py index 8050468..5e3a0e1 100644 --- a/python_app/gui/controllers/app_window_pipeline_mixin.py +++ b/python_app/gui/controllers/app_window_pipeline_mixin.py @@ -86,7 +86,9 @@ class AppWindowPipelineMixin: self._single_capture_seen_raw = False self._single_capture_target_collection_id = None - self._drop_pending_ring_payloads(include_results=not single_capture) + # Always drop unread payloads for all stages so single-capture starts + # from a clean boundary and does not retain stale results-only tail. + self._drop_pending_ring_payloads(include_results=True) if single_capture: self._single_capture_start_ns = time.monotonic_ns() @@ -208,7 +210,7 @@ class AppWindowPipelineMixin: self._update_history_indicator() if self._single_capture_active: - if self._finish_single_capture_if_ready(result_latest): + if self._finish_single_capture_if_ready(): return return @@ -216,31 +218,41 @@ class AppWindowPipelineMixin: except Exception as exc: # noqa: BLE001 self._log(f"Reader error: {exc}") - def _finish_single_capture_if_ready(self, result_latest: ResultCollection | None) -> bool: - """Finalize single capture when new result matching start criteria is available.""" + def _finish_single_capture_if_ready(self) -> bool: + """Finalize single capture when the exact target result becomes available.""" if not self._single_capture_active: return False - if result_latest is None: - return False if self._single_capture_start_ns is None: return False if not self._single_capture_seen_raw: return False - if ( - self._single_capture_target_collection_id is not None - and result_latest.collection_id < self._single_capture_target_collection_id - ): - return False - if result_latest.monotonic_ns < self._single_capture_start_ns: - return False - if not self._result_collection_has_trace(result_latest): + target_result = self._find_single_capture_target_result() + if target_result is None: return False - self._draw_results(result_latest) + self._draw_results(target_result) self._log("Single capture completed") self._stop_run() return True + def _find_single_capture_target_result(self) -> ResultCollection | None: + """Return the exact result collection corresponding to the captured target raw sweep.""" + if self._single_capture_target_collection_id is None: + return None + if self._single_capture_start_ns is None: + return None + + for collection in reversed(self._result_history): + if collection.collection_id != self._single_capture_target_collection_id: + continue + if collection.monotonic_ns < self._single_capture_start_ns: + continue + if not self._result_collection_has_trace(collection): + continue + return collection + + return None + def _read_all_raw(self) -> SweepCollection | None: """Read available raw collections from raw ring.""" assert self._raw_reader is not None @@ -277,14 +289,10 @@ class AppWindowPipelineMixin: collection = self._result_reader.pop_result_collection() if collection is None: break - if self._record_result_history(collection): + if record_result_history(self._result_history, collection): latest = collection return latest - def _record_result_history(self, collection: ResultCollection) -> bool: - """Merge collection into result history preserving de-dup semantics.""" - return record_result_history(self._result_history, collection) - def _drain_rings_once_for_history(self) -> None: """Perform one non-blocking read pass to extend histories.""" if self._raw_reader is not None: diff --git a/python_app/gui/controllers/app_window_snapshot_mixin.py b/python_app/gui/controllers/app_window_snapshot_mixin.py index a72bafa..85d8b65 100644 --- a/python_app/gui/controllers/app_window_snapshot_mixin.py +++ b/python_app/gui/controllers/app_window_snapshot_mixin.py @@ -6,6 +6,7 @@ from pathlib import Path import time from PyQt6.QtWidgets import QFileDialog +from python_app.gui.runtime.history import record_result_history class AppWindowSnapshotMixin: @@ -179,7 +180,7 @@ class AppWindowSnapshotMixin: collection = self._result_reader.pop_result_collection() if collection is None: break - self._record_result_history(collection) + record_result_history(self._result_history, collection) progress = True if progress: diff --git a/python_app/gui/runtime/history.py b/python_app/gui/runtime/history.py index 799af97..e4790ae 100644 --- a/python_app/gui/runtime/history.py +++ b/python_app/gui/runtime/history.py @@ -3,10 +3,13 @@ from __future__ import annotations from collections import deque +from typing import TypeVar -from python_app.models.dataset_model import ResultCollection +from python_app.models.dataset_model import ResultCollection, SweepCollection from python_app.models.run_config_model import RunConfigModel +THistoryCollection = TypeVar("THistoryCollection", SweepCollection, ResultCollection) + def record_result_history( result_history: deque[ResultCollection], @@ -26,6 +29,35 @@ def record_result_history( return True +def remove_last_aligned_histories( + raw_history: list[SweepCollection], + preprocessed_history: list[SweepCollection], + result_history: list[ResultCollection], +) -> tuple[list[SweepCollection], list[SweepCollection], list[ResultCollection]]: + """Remove the newest aligned history entry using results as preferred anchor.""" + retained_raw = list(raw_history) + retained_preprocessed = list(preprocessed_history) + retained_results = list(result_history) + + if retained_results: + target_key = _tail_occurrence_key(retained_results, len(retained_results) - 1) + retained_results.pop() + _remove_by_tail_occurrence_key(retained_raw, target_key) + _remove_by_tail_occurrence_key(retained_preprocessed, target_key) + return retained_raw, retained_preprocessed, retained_results + + if retained_preprocessed: + target_key = _tail_occurrence_key(retained_preprocessed, len(retained_preprocessed) - 1) + retained_preprocessed.pop() + _remove_by_tail_occurrence_key(retained_raw, target_key) + return retained_raw, retained_preprocessed, retained_results + + if retained_raw: + retained_raw.pop() + + return retained_raw, retained_preprocessed, retained_results + + def build_run_history_signature( config: RunConfigModel, ) -> tuple[object, ...]: @@ -51,3 +83,27 @@ def build_run_history_signature( str(config.preprocess.reference_set), combos_signature, ) + + +def _tail_occurrence_key(history: list[THistoryCollection], index: int) -> tuple[int, int]: + """Return `(collection_id, occurrence_from_tail)` for the item at `index`.""" + collection_id = int(history[index].collection_id) + occurrence_from_tail = 0 + for cursor in range(len(history) - 1, index, -1): + if int(history[cursor].collection_id) == collection_id: + occurrence_from_tail += 1 + return collection_id, occurrence_from_tail + + +def _remove_by_tail_occurrence_key(history: list[THistoryCollection], key: tuple[int, int]) -> bool: + """Remove newest matching entry identified by `(collection_id, occurrence_from_tail)`.""" + collection_id, target_occurrence = key + occurrence_from_tail = 0 + for index in range(len(history) - 1, -1, -1): + if int(history[index].collection_id) != collection_id: + continue + if occurrence_from_tail == target_occurrence: + history.pop(index) + return True + occurrence_from_tail += 1 + return False