"""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)