435 lines
18 KiB
Python
435 lines
18 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.preprocess_assets import (
|
|
PREPROCESS_ASSET_SPECS,
|
|
REQUIRED_PREPROCESS_ASSET_KEYS,
|
|
preprocess_asset_model,
|
|
runtime_preprocess_asset_keys,
|
|
)
|
|
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",
|
|
details=self._capture_state_details(),
|
|
)
|
|
return
|
|
if self._supervisor.is_running():
|
|
self._show_error("Pipeline is already running", details=self._process_state_details())
|
|
return
|
|
|
|
try:
|
|
processor_was_running = self._supervisor.is_processor_running()
|
|
config = self._build_config()
|
|
if not processor_was_running:
|
|
self._reset_runtime_history()
|
|
|
|
self._validate_processing_mode_constraints(config)
|
|
run_signature = self._build_run_history_signature(config)
|
|
radar_key = self._radar_key(config)
|
|
|
|
missing_assets = [
|
|
PREPROCESS_ASSET_SPECS[key].display_name
|
|
for key in REQUIRED_PREPROCESS_ASSET_KEYS
|
|
if not preprocess_asset_model(config, key).set_name
|
|
]
|
|
if missing_assets:
|
|
raise RuntimeError(
|
|
"Select all required preprocess sets in Preprocessing Panel before Start: "
|
|
+ ", ".join(missing_assets)
|
|
)
|
|
|
|
combo_keys = [ComboKey(input_pos=combo.input, output_pos=combo.output) for combo in config.combos]
|
|
active_preprocess_keys = runtime_preprocess_asset_keys(config)
|
|
preprocess_summary = "; ".join(
|
|
f"{PREPROCESS_ASSET_SPECS[key].display_name}={preprocess_asset_model(config, key).set_name}"
|
|
for key in active_preprocess_keys
|
|
)
|
|
self._log(f"Active preprocess assets for run: {preprocess_summary}")
|
|
|
|
for key in active_preprocess_keys:
|
|
spec = PREPROCESS_ASSET_SPECS[key]
|
|
asset = preprocess_asset_model(config, key)
|
|
if not self._store.has_combo_coverage(spec.set_kind, radar_key, asset.set_name, combo_keys):
|
|
raise RuntimeError(f"Selected {spec.display_name} set does not cover requested run combos")
|
|
|
|
self._config_writer.prepare_preprocess_bundles(self._store, radar_key, config)
|
|
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")
|
|
combo_preview = ", ".join(f"in{combo.input}/out{combo.output}" for combo in config.combos[:6])
|
|
if len(config.combos) > 6:
|
|
combo_preview += ", ..."
|
|
self._log(
|
|
f"Starting pipeline: mode={'single_capture' if single_capture else 'continuous'}, "
|
|
f"config={config_path}, combos={len(config.combos)}"
|
|
f"{', ' + combo_preview if combo_preview else ''}, radar_key={radar_key}"
|
|
)
|
|
|
|
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, allow_clean_orchestrator_exit=single_capture)
|
|
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)
|
|
self._last_reader_error_signature = None
|
|
if single_capture:
|
|
self._single_capture_start_ns = time.monotonic_ns()
|
|
|
|
pid_map = self._supervisor.pids()
|
|
pid_text = ", ".join(f"{name}={pid}" for name, pid in sorted(pid_map.items())) or "none"
|
|
if single_capture:
|
|
self._status_label.setText("Status: single capture running")
|
|
self._log(f"Single capture started; managed processes: {pid_text}")
|
|
else:
|
|
self._status_label.setText("Status: running")
|
|
self._log(f"Pipeline started; managed processes: {pid_text}")
|
|
except Exception as exc: # noqa: BLE001
|
|
self._single_capture_active = False
|
|
self._single_capture_start_ns = None
|
|
self._stop_all_processes()
|
|
self._show_exception("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",
|
|
details=self._capture_state_details(),
|
|
)
|
|
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: "
|
|
f"start={config.radar.sweep.start_hz:g} Hz, "
|
|
f"stop={config.radar.sweep.stop_hz:g} Hz, "
|
|
f"points={config.radar.sweep.points}, "
|
|
f"ifbw={config.radar.sweep.if_bandwidth_hz:g} Hz, "
|
|
f"power={config.radar.sweep.power_dbm:g} dBm"
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
self._show_exception("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_exit_reports():
|
|
if report.level == "INFO":
|
|
self._log(report.format())
|
|
continue
|
|
self._status_label.setText("Status: error")
|
|
self._log_error(report.format())
|
|
|
|
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()
|
|
self._last_reader_error_signature = None
|
|
|
|
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
|
|
signature = (type(exc).__name__, str(exc))
|
|
if self._last_reader_error_signature == signature:
|
|
return
|
|
self._last_reader_error_signature = signature
|
|
self._log_exception("Reader poll failed", exc, level="ERROR")
|
|
|
|
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(),
|
|
)
|