Files
radar_system/python_app/gui/controllers/app_window_pipeline_mixin.py
T
2026-04-29 16:32:39 +03:00

499 lines
22 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.single_radar_service import create_single_radar_service
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.gpr_locator import collection_has_gpr_payloads
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 _processor_requires_restart(self, run_signature: tuple[object, ...]) -> bool:
"""Return whether alive `data_processor` was started with different stable run settings."""
return self._supervisor.is_processor_running() and self._processor_run_signature != run_signature
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()
run_signature = self._build_run_history_signature(config)
if self._processor_requires_restart(run_signature):
self._log("Restarting data_processor because stable run settings changed")
self._stop_all_processes()
processor_was_running = False
if not processor_was_running:
self._reset_runtime_history()
self._validate_processing_mode_constraints(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._processor_run_signature = run_signature
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()
processor_only_running = self._supervisor.is_processor_running() and not was_running
if was_running:
self._stop_run()
try:
config = self._build_config()
if config.radar.driver_mode == "native":
self._refresh_radar_limits_from_device()
run_signature = self._build_run_history_signature(config)
if processor_only_running and self._processor_requires_restart(run_signature):
self._stop_all_processes()
self._reset_runtime_history()
self._history_run_signature = None
self._log(
"Stable run settings changed; stopped data_processor because cached history no longer "
"matches the current 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 single-radar hardware using current sweep settings."""
if config.radar.model == RunConfigModel.COMPACT_M_K209_MODEL and config.radar.driver_mode != "native":
raise RuntimeError("Compact-M K209 requires radar.driver_mode='native'")
if config.radar.driver_mode != "native":
self._log("Radar pre-configuration skipped (mock mode)")
return
if config.is_multi_device:
self._log("Multi-device raw producer will configure all LibreVNA devices")
return
radar_service = create_single_radar_service(config)
if not getattr(radar_service, "driver_available", True):
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(f"Radar pre-configured via Python driver: model={config.radar.model}")
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._processor_run_signature = 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:
self._drain_locator_speed_updates()
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
if (
self._processing_mode.currentText() == "gpr"
and collection_has_gpr_payloads(collection)
):
self._publish_locator_snapshot_from_collection(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(),
)
def _drain_locator_speed_updates(self) -> None:
"""Apply queued speed updates received by the embedded locator server."""
latest_speed = self._locator_service.drain_speed_updates()
if latest_speed is None:
return
self._apply_external_gpr_speed_update(0.0) # TODO: remove temporary stub and apply real locator client speed.
def _publish_locator_snapshot_from_collection(self, collection: ResultCollection) -> None:
"""Publish one locator snapshot from a GPR result collection."""
self._locator_service.publish_collection(
collection,
float(self._gpr_min_visible_pair_count.value()),
visible_bounds=self._gpr_visible_object_bounds(),
)
def _publish_locator_snapshot_from_latest_result(self) -> None:
"""Publish current locator-visible snapshot from latest cached GPR result."""
if self._processing_mode.currentText() != "gpr":
self._locator_service.publish_empty()
return
if not self._result_history:
self._locator_service.publish_empty()
return
latest = self._result_history[-1]
if not collection_has_gpr_payloads(latest):
self._locator_service.publish_empty()
return
self._publish_locator_snapshot_from_collection(latest)