Files
radar_system/python_app/gui/controllers/app_window_pipeline_mixin.py
T
2026-03-19 19:29:08 +03:00

393 lines
16 KiB
Python

"""Pipeline runtime lifecycle mixin for the main GUI window."""
from __future__ import annotations
import time
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
from python_app.gui.runtime.history import build_run_history_signature, record_result_history
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.shm_reader import ShmRingReader
class AppWindowPipelineMixin:
"""Controls start/stop, readers, and periodic polling of pipeline rings."""
def _start_single_capture(self) -> None:
"""Start acquisition in single-capture mode."""
self._start_run(single_capture=True)
def _start_run(self, *, single_capture: bool = False) -> None:
"""Start pipeline processes and ring readers."""
if self._capture_session is not None:
self._show_error("Cannot start pipeline during active capture sequence")
return
if self._supervisor.is_running():
self._show_error("Pipeline is already running")
return
try:
processor_was_running = self._supervisor.is_processor_running()
if not processor_was_running:
self._reset_runtime_history()
config = self._build_config()
self._validate_processing_mode_constraints(config)
run_signature = self._build_run_history_signature(config)
radar_key = self._radar_key(config)
if not config.preprocess.calibration_set or not config.preprocess.reference_set:
raise RuntimeError("Select calibration and reference sets in Preprocessing Panel before Start")
combo_keys = [ComboKey(input_pos=combo.input, output_pos=combo.output) for combo in config.combos]
if not self._store.has_combo_coverage(
"calibration", radar_key, config.preprocess.calibration_set, combo_keys
):
raise RuntimeError("Selected calibration set does not cover requested run combos")
if not self._store.has_combo_coverage(
"reference", radar_key, config.preprocess.reference_set, combo_keys
):
raise RuntimeError("Selected reference set does not cover requested run combos")
calibration_bundle, reference_bundle = self._config_writer.prepare_bundles(
self._store,
radar_key,
config.preprocess.calibration_set,
config.preprocess.reference_set,
)
config.preprocess.calibration_bundle_path = str(calibration_bundle)
config.preprocess.reference_bundle_path = str(reference_bundle)
config.runtime.continuous = not single_capture
if not single_capture:
self._prepare_radar_for_native_acquisition(config)
config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json")
if not single_capture:
should_reset_history = (
self._history_run_signature is not None and self._history_run_signature != run_signature
)
if should_reset_history:
self._reset_runtime_history()
self._log("History reset because run settings changed")
self._history_run_signature = run_signature
self._supervisor.start(config_path)
self._close_readers()
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name)
self._result_reader = ShmRingReader(config.rings.results.name)
self._single_capture_active = single_capture
self._single_capture_start_ns = None
self._single_capture_seen_raw = False
self._single_capture_target_collection_id = None
# 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()
if single_capture:
self._status_label.setText("Status: single capture running")
self._log("Single capture started")
else:
self._status_label.setText("Status: running")
self._log("Pipeline started")
except Exception as exc: # noqa: BLE001
self._single_capture_active = False
self._single_capture_start_ns = None
self._stop_all_processes()
self._show_error(f"Failed to start pipeline: {exc}")
def _apply_radar_settings(self) -> None:
"""Apply current radar settings by preconfiguring native device."""
if self._capture_session is not None:
self._show_error("Finish or abort capture sequence before applying radar settings")
return
was_running = self._supervisor.is_running()
if was_running:
self._stop_run()
try:
if self._radar_mode.currentText() == "native":
self._refresh_radar_limits_from_device()
config = self._build_config()
self._prepare_radar_for_native_acquisition(config)
self._log("Radar settings applied")
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to apply radar settings: {exc}")
finally:
if was_running:
self._start_run()
def _prepare_radar_for_native_acquisition(self, config: RunConfigModel) -> None:
"""Preconfigure native LibreVNA using current sweep settings."""
if config.radar.driver_mode != "native":
self._log("Radar pre-configuration skipped (mock mode)")
return
radar_service = LibreVnaService(serial=config.radar.serial or None)
if not radar_service.driver_available:
raise RuntimeError("LibreVNA Python driver is not available for native pre-configuration")
try:
radar_service.open()
radar_service.configure(config.radar.sweep)
finally:
radar_service.close()
self._log("Radar pre-configured via Python driver")
def _stop_run(self) -> None:
"""Stop acquisition-side processes and close readers as needed."""
was_running = self._supervisor.is_running()
if was_running:
self._supervisor.stop_orchestrator()
self._drain_rings_until_quiet(timeout_s=0.35, poll_s=0.02)
self._supervisor.stop_preprocessor()
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
else:
self._supervisor.stop()
self._drain_rings_once_for_history()
keep_results_reader = self._supervisor.is_processor_running()
self._close_readers(keep_results=keep_results_reader)
self._single_capture_active = False
self._single_capture_start_ns = None
self._single_capture_seen_raw = False
self._single_capture_target_collection_id = None
self._update_history_indicator()
self._status_label.setText("Status: idle")
if was_running:
if keep_results_reader:
self._log("Acquisition stopped (data_processor kept running)")
else:
self._log("Pipeline stopped")
def _stop_all_processes(self) -> None:
"""Stop all managed pipeline processes and close all readers."""
was_running = self._supervisor.is_running() or self._supervisor.is_processor_running()
self._supervisor.stop_all()
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
self._close_readers(keep_results=False)
self._single_capture_active = False
self._single_capture_start_ns = None
self._single_capture_seen_raw = False
self._single_capture_target_collection_id = None
self._update_history_indicator()
self._status_label.setText("Status: idle")
if was_running:
self._log("All pipeline processes stopped")
def _close_readers(self, *, keep_results: bool = False) -> None:
"""Close active ring readers."""
if self._raw_reader is not None:
self._raw_reader.close()
self._raw_reader = None
if self._pre_reader is not None:
self._pre_reader.close()
self._pre_reader = None
if not keep_results and self._result_reader is not None:
self._result_reader.close()
self._result_reader = None
def _poll_rings(self) -> None:
"""Poll readers, ingest history, and trigger rendering."""
for report in self._supervisor.collect_crash_reports():
self._status_label.setText("Status: error")
self._log(report)
try:
if self._raw_reader is not None:
self._read_all_raw()
self._read_all_preprocessed()
result_latest = self._read_all_results() if self._result_reader is not None else None
self._update_history_indicator()
if self._single_capture_active:
if self._finish_single_capture_if_ready():
return
return
self._draw_preferred_collection(result_latest=result_latest)
except Exception as exc: # noqa: BLE001
self._log(f"Reader error: {exc}")
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 self._single_capture_start_ns is None:
return False
if not self._single_capture_seen_raw:
return False
target_result = self._find_single_capture_target_result()
if target_result is None:
return False
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
latest: SweepCollection | None = None
for _ in range(self._max_pop_per_poll):
collection = self._raw_reader.pop_raw_collection()
if collection is None:
break
self._raw_history.append(collection)
latest = collection
if self._single_capture_active and self._single_capture_start_ns is not None:
if collection.monotonic_ns >= self._single_capture_start_ns:
self._single_capture_seen_raw = True
if self._single_capture_target_collection_id is None:
self._single_capture_target_collection_id = collection.collection_id
return latest
def _read_all_preprocessed(self) -> None:
"""Read available preprocessed collections from preprocessed ring."""
if self._pre_reader is None:
return
for _ in range(self._max_pop_per_poll):
collection = self._pre_reader.pop_preprocessed_collection()
if collection is None:
break
self._pre_history.append(collection)
def _read_all_results(self) -> ResultCollection | None:
"""Read available result collections from results ring."""
assert self._result_reader is not None
latest: ResultCollection | None = None
for _ in range(self._max_pop_per_poll):
collection = self._result_reader.pop_result_collection()
if collection is None:
break
if record_result_history(self._result_history, collection):
latest = collection
return latest
def _drain_rings_once_for_history(self) -> None:
"""Perform one non-blocking read pass to extend histories."""
if self._raw_reader is not None:
self._read_all_raw()
self._read_all_preprocessed()
if self._result_reader is not None:
self._read_all_results()
def _drain_rings_until_quiet(self, *, timeout_s: float, poll_s: float) -> None:
"""Drain rings until history sizes stabilize or timeout expires."""
deadline = time.monotonic() + timeout_s
stable_rounds = 0
previous = (
len(self._raw_history),
len(self._pre_history),
len(self._result_history),
)
while time.monotonic() < deadline and stable_rounds < 2:
self._drain_rings_once_for_history()
current = (
len(self._raw_history),
len(self._pre_history),
len(self._result_history),
)
if current == previous:
stable_rounds += 1
else:
stable_rounds = 0
previous = current
time.sleep(poll_s)
def _drain_results_until_quiet(self, *, timeout_s: float, poll_s: float) -> None:
"""Drain only results ring until size stabilizes or timeout expires."""
if self._result_reader is None:
return
deadline = time.monotonic() + timeout_s
stable_rounds = 0
while time.monotonic() < deadline and stable_rounds < 2:
latest = self._read_all_results()
if latest is None:
stable_rounds += 1
else:
stable_rounds = 0
time.sleep(poll_s)
def _update_history_indicator(self) -> None:
"""Update UI label with current history buffer sizes."""
self._history_label.setText(
f"History: raw={len(self._raw_history)}, "
f"preprocessed={len(self._pre_history)}, "
f"results={len(self._result_history)}"
)
def _reset_runtime_history(self) -> None:
"""Reset runtime history and B-scan caches."""
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
self._bscan_history_floor_collection_id = 0
self._clear_history_mode_caches()
self._update_history_indicator()
def _replace_runtime_history(
self,
*,
retained_raw: list[SweepCollection],
retained_pre: list[SweepCollection],
retained_result: list[ResultCollection],
) -> None:
"""Replace history deques with provided retained tails."""
raw_tail = retained_raw[-self._raw_history.maxlen :] if self._raw_history.maxlen is not None else retained_raw
pre_tail = retained_pre[-self._pre_history.maxlen :] if self._pre_history.maxlen is not None else retained_pre
result_tail = (
retained_result[-self._result_history.maxlen :]
if self._result_history.maxlen is not None
else retained_result
)
self._raw_history.clear()
self._pre_history.clear()
self._result_history.clear()
self._raw_history.extend(raw_tail)
self._pre_history.extend(pre_tail)
self._result_history.extend(result_tail)
def _build_run_history_signature(self, config: RunConfigModel) -> tuple[object, ...]:
"""Build signature used to decide when history should be reset."""
return build_run_history_signature(config)
def _validate_processing_mode_constraints(self, config: RunConfigModel) -> None:
"""Validate processing-mode constraints for run start."""
validate_processing_mode_constraints(
self._processing_mode.currentText(),
config,
self._live_processing_config(),
)