Refactor radar system
This commit is contained in:
@@ -7,6 +7,7 @@ import time
|
||||
from PyQt6.QtCore import QCoreApplication, QEventLoop
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.gui.controllers.app_window_plot.sar3d_plot_mixin import sar3d_status
|
||||
from python_app.gui.runtime.history import (
|
||||
build_processor_run_signature,
|
||||
build_run_history_signature,
|
||||
@@ -16,6 +17,7 @@ from python_app.hardware_full.kamil_adc import apply_kamil_adc_laser_control
|
||||
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.models.run_config_validation import validate_acquisition_config
|
||||
from python_app.orchestration.preprocess_assets import (
|
||||
PREPROCESS_ASSET_SPECS,
|
||||
REQUIRED_PREPROCESS_ASSET_KEYS,
|
||||
@@ -57,6 +59,11 @@ class AppWindowPipelineMixin:
|
||||
|
||||
def _start_run(self, *, single_capture: bool = False) -> None:
|
||||
"""Start pipeline processes and ring readers."""
|
||||
if getattr(self, "_closing", False) or getattr(self, "_close_after_stop", False):
|
||||
return
|
||||
if getattr(self, "_stopping_run", False):
|
||||
self._show_error("Wait for pipeline shutdown before starting again")
|
||||
return
|
||||
if self._capture_session is not None:
|
||||
self._show_error(
|
||||
"Cannot start pipeline during active capture sequence",
|
||||
@@ -76,15 +83,20 @@ class AppWindowPipelineMixin:
|
||||
return
|
||||
self._log("Stopping continuous pipeline before single capture")
|
||||
self._stop_run()
|
||||
if getattr(self, "_closing", False):
|
||||
return
|
||||
|
||||
try:
|
||||
processor_was_running = self._supervisor.is_processor_running()
|
||||
config = self._build_config()
|
||||
validate_acquisition_config(config)
|
||||
run_signature = self._build_run_history_signature(config)
|
||||
processor_signature = self._build_processor_run_signature(config)
|
||||
if self._processor_requires_restart(processor_signature):
|
||||
self._log("Restarting data_processor because stable run settings changed")
|
||||
self._stop_all_processes()
|
||||
if getattr(self, "_closing", False):
|
||||
return
|
||||
processor_was_running = False
|
||||
if not processor_was_running:
|
||||
self._reset_runtime_history()
|
||||
@@ -207,13 +219,17 @@ class AppWindowPipelineMixin:
|
||||
return
|
||||
|
||||
was_running = self._supervisor.is_running()
|
||||
resume_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
processor_only_running = self._supervisor.is_processor_running() and not was_running
|
||||
if was_running:
|
||||
self._stop_run()
|
||||
if not self._pipeline_resume_allowed(resume_seq):
|
||||
return
|
||||
|
||||
try:
|
||||
config = self._build_config()
|
||||
if config.radar.driver_mode == "native":
|
||||
validate_acquisition_config(config)
|
||||
if config.radar.driver_mode == "native" and not config.radar.replay.dataset_dir:
|
||||
self._refresh_radar_limits_from_device()
|
||||
processor_signature = self._build_processor_run_signature(config)
|
||||
if processor_only_running and self._processor_requires_restart(processor_signature):
|
||||
@@ -241,13 +257,14 @@ class AppWindowPipelineMixin:
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._show_exception("Failed to apply radar settings", exc)
|
||||
finally:
|
||||
if was_running:
|
||||
if was_running and self._pipeline_resume_allowed(resume_seq):
|
||||
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.replay.dataset_dir:
|
||||
self._log("Radar pre-configuration skipped (dataset replay)")
|
||||
return
|
||||
if config.radar.driver_mode != "native":
|
||||
self._log("Radar pre-configuration skipped (mock mode)")
|
||||
return
|
||||
@@ -265,9 +282,6 @@ class AppWindowPipelineMixin:
|
||||
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)
|
||||
@@ -278,49 +292,77 @@ class AppWindowPipelineMixin:
|
||||
|
||||
def _stop_run(self) -> None:
|
||||
"""Stop acquisition-side processes and close readers as needed."""
|
||||
self._pipeline_should_run = False # an explicit stop disables crash auto-restart
|
||||
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()
|
||||
# Flush any in-progress disk recording so its buffered (not-yet-full) chunk is
|
||||
# written rather than lost; the drains above already captured the last data.
|
||||
self._finalize_recording_on_stop()
|
||||
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")
|
||||
self._stop_request_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
self._resume_pipeline_after_capture = False
|
||||
self._stop_pipeline(keep_processor=True)
|
||||
|
||||
def _stop_all_processes(self) -> None:
|
||||
"""Stop all managed pipeline processes and close all readers."""
|
||||
self._pipeline_should_run = False # an explicit stop disables crash auto-restart
|
||||
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")
|
||||
self._stop_pipeline(keep_processor=False)
|
||||
|
||||
def _stop_pipeline(self, *, keep_processor: bool) -> None:
|
||||
"""Share shutdown/drain/finalization, protecting event-loop re-entry."""
|
||||
self._pipeline_should_run = False
|
||||
if getattr(self, "_stopping_run", False):
|
||||
self._stop_all_requested |= not keep_processor
|
||||
return
|
||||
self._stopping_run = True
|
||||
self._stop_all_requested = not keep_processor
|
||||
try:
|
||||
drain_error = None
|
||||
try:
|
||||
was_running = self._supervisor.is_running()
|
||||
if keep_processor and 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)
|
||||
elif keep_processor:
|
||||
self._supervisor.stop()
|
||||
self._drain_rings_once_for_history()
|
||||
if self._stop_all_requested:
|
||||
self._supervisor.stop_all()
|
||||
self._drain_rings_until_quiet(timeout_s=0.25, poll_s=0.02)
|
||||
except Exception as exc: # noqa: BLE001 - corrupt data must not prevent Stop
|
||||
drain_error = exc
|
||||
self._stop_request_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
if self._stop_all_requested:
|
||||
self._supervisor.stop_all()
|
||||
else:
|
||||
self._supervisor.stop()
|
||||
if self._stop_all_requested:
|
||||
self._processor_run_signature = None
|
||||
self._finalize_recording_on_stop()
|
||||
keep_results = not self._stop_all_requested and self._supervisor.is_processor_running()
|
||||
result_ring_name = getattr(self._result_reader, "_ring_name", None) if drain_error else None
|
||||
self._close_readers(keep_results=keep_results and drain_error is None)
|
||||
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")
|
||||
self._log("Acquisition stopped (data_processor kept running)" if keep_results else "Pipeline stopped")
|
||||
if drain_error is not None:
|
||||
self._status_label.setText("Status: error")
|
||||
self._show_exception("Pipeline stopped, but final ring data could not be read", drain_error)
|
||||
if keep_results and result_ring_name:
|
||||
self._result_reader = ShmRingReader(result_ring_name)
|
||||
except Exception as exc: # noqa: BLE001 - this is also a direct Qt button slot
|
||||
self._stop_request_seq = getattr(self, "_stop_request_seq", 0) + 1
|
||||
self._status_label.setText("Status: error")
|
||||
self._show_exception("Failed to stop pipeline", exc)
|
||||
finally:
|
||||
self._stopping_run = False
|
||||
if getattr(self, "_close_after_stop", False):
|
||||
self._close_after_stop = False
|
||||
self.close()
|
||||
|
||||
def _pipeline_resume_allowed(self, stop_seq: int) -> bool:
|
||||
"""A Stop or Close received during a temporary pause cancels its restart."""
|
||||
return not getattr(self, "_closing", False) and not getattr(self, "_close_after_stop", False) and (
|
||||
getattr(self, "_stop_request_seq", 0) == stop_seq
|
||||
)
|
||||
|
||||
def _close_readers(self, *, keep_results: bool = False) -> None:
|
||||
"""Close active ring readers; keep the results reader when ``keep_results``."""
|
||||
@@ -342,6 +384,8 @@ class AppWindowPipelineMixin:
|
||||
|
||||
def _poll_rings(self) -> None:
|
||||
"""Poll readers, ingest history, and trigger rendering."""
|
||||
if getattr(self, "_closing", False) or getattr(self, "_stopping_run", False):
|
||||
return
|
||||
self._web_update_snapshot() # guarded internally; never raises
|
||||
self._handle_process_exit_reports()
|
||||
|
||||
@@ -366,12 +410,10 @@ class AppWindowPipelineMixin:
|
||||
# crash-storm budget (it only caps consecutive crash-restarts).
|
||||
self._pipeline_restart_count = 0
|
||||
render_started_ns = time.monotonic_ns()
|
||||
self._draw_preferred_collection(result_latest=result_latest)
|
||||
self._draw_results(result_latest)
|
||||
self._pipeline_metrics.record(
|
||||
"rendering", time.monotonic_ns() - render_started_ns
|
||||
)
|
||||
else:
|
||||
self._draw_preferred_collection(result_latest=None)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._handle_reader_poll_error(exc)
|
||||
|
||||
@@ -395,7 +437,12 @@ class AppWindowPipelineMixin:
|
||||
self._log_exception("Failed to collect process exit reports", exc, level="ERROR")
|
||||
return
|
||||
|
||||
if unexpected and getattr(self, "_pipeline_should_run", False):
|
||||
if getattr(self, "_pipeline_should_run", False) and (
|
||||
unexpected
|
||||
or not {"data_processor", "data_preprocessor", "sweep_orchestrator"}.issubset(self._supervisor.pids())
|
||||
):
|
||||
# Exit reports are consumed once. A missing child must keep triggering
|
||||
# recovery after a back-off delay or a failed start with no reports left.
|
||||
self._recover_pipeline_after_crash()
|
||||
|
||||
def _recover_pipeline_after_crash(self) -> None:
|
||||
@@ -441,7 +488,7 @@ class AppWindowPipelineMixin:
|
||||
self._drop_pending_ring_payloads(include_results=True)
|
||||
self._status_label.setText("Status: running")
|
||||
self._log("Pipeline auto-restarted after crash.")
|
||||
except Exception as exc: # noqa: BLE001 - retry on the next crash signal
|
||||
except Exception as exc: # noqa: BLE001 - retry on subsequent polls
|
||||
self._log_exception("Pipeline auto-restart failed; will retry", exc, level="ERROR")
|
||||
|
||||
def _check_single_capture_deadline(self) -> None:
|
||||
@@ -529,8 +576,15 @@ class AppWindowPipelineMixin:
|
||||
return False
|
||||
|
||||
self._draw_results(target_result)
|
||||
self._log("Single capture completed")
|
||||
status = sar3d_status(target_result) if self._processing_mode.currentText() == "sar3d" else None
|
||||
self._stop_run()
|
||||
if status is not None and status[0] in (1, 2, 3):
|
||||
state = {1: "no signal in selected range", 2: "error", 3: "warning"}[status[0]]
|
||||
self._status_label.setText(f"Status: {state}")
|
||||
log = self._log_error if status[0] == 2 else self._log_warning
|
||||
log(f"Single capture: SAR3D {state}: {status[1]}")
|
||||
else:
|
||||
self._log("Single capture completed")
|
||||
return True
|
||||
|
||||
def _find_single_capture_target_result(self) -> ResultCollection | None:
|
||||
@@ -551,12 +605,12 @@ class AppWindowPipelineMixin:
|
||||
|
||||
return None
|
||||
|
||||
def _read_all_raw(self) -> SweepCollection | None:
|
||||
def _read_all_raw(self, *, max_collections: int | None = None) -> SweepCollection | None:
|
||||
"""Read available raw collections from raw ring."""
|
||||
if self._raw_reader is None:
|
||||
raise RuntimeError("Raw ring reader is not initialised")
|
||||
latest: SweepCollection | None = None
|
||||
for _ in range(self._max_pop_per_poll):
|
||||
for _ in range(self._max_pop_per_poll if max_collections is None else max_collections):
|
||||
collection = self._raw_reader.pop_raw_collection()
|
||||
if collection is None:
|
||||
break
|
||||
@@ -575,24 +629,27 @@ class AppWindowPipelineMixin:
|
||||
self._single_capture_target_collection_id = collection.collection_id
|
||||
return latest
|
||||
|
||||
def _read_all_preprocessed(self) -> None:
|
||||
def _read_all_preprocessed(self, *, max_collections: int | None = None) -> SweepCollection | None:
|
||||
"""Read available preprocessed collections from preprocessed ring."""
|
||||
if self._pre_reader is None:
|
||||
return
|
||||
|
||||
for _ in range(self._max_pop_per_poll):
|
||||
latest: SweepCollection | None = None
|
||||
for _ in range(self._max_pop_per_poll if max_collections is None else max_collections):
|
||||
collection = self._pre_reader.pop_preprocessed_collection()
|
||||
if collection is None:
|
||||
break
|
||||
self._pre_history.append(collection)
|
||||
self._record_collection("preprocessed", collection)
|
||||
latest = collection
|
||||
return latest
|
||||
|
||||
def _read_all_results(self) -> ResultCollection | None:
|
||||
def _read_all_results(self, *, max_collections: int | None = None) -> ResultCollection | None:
|
||||
"""Read available result collections from results ring."""
|
||||
if self._result_reader is None:
|
||||
raise RuntimeError("Result ring reader is not initialised")
|
||||
latest: ResultCollection | None = None
|
||||
for _ in range(self._max_pop_per_poll):
|
||||
for _ in range(self._max_pop_per_poll if max_collections is None else max_collections):
|
||||
collection = self._result_reader.pop_result_collection()
|
||||
if collection is None:
|
||||
break
|
||||
@@ -627,36 +684,19 @@ class AppWindowPipelineMixin:
|
||||
# residual in tiny slices so we neither busy-spin nor block too long.
|
||||
time.sleep(min(0.002, max(0.0, deadline - time.monotonic())))
|
||||
|
||||
def _drain_rings_once_for_history(self) -> None:
|
||||
def _drain_rings_once_for_history(self) -> bool:
|
||||
"""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()
|
||||
raw = self._read_all_raw() if self._raw_reader is not None else None
|
||||
preprocessed = self._read_all_preprocessed()
|
||||
result = self._read_all_results() if self._result_reader is not None else None
|
||||
return raw is not None or preprocessed is not None or result is not None
|
||||
|
||||
def _drain_rings_until_quiet(self, *, timeout_s: float, poll_s: float) -> None:
|
||||
"""Drain rings until history sizes stabilize or timeout expires."""
|
||||
"""Drain rings until readers are quiet or timeout expires, even at full history capacity."""
|
||||
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
|
||||
stable_rounds = 0 if self._drain_rings_once_for_history() else stable_rounds + 1
|
||||
# Event-loop-friendly wait so timers/signals keep firing during drain.
|
||||
self._pump_events_during_drain(poll_s)
|
||||
|
||||
@@ -734,4 +774,3 @@ class AppWindowPipelineMixin:
|
||||
config,
|
||||
self._live_processing_config(),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user