Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
@@ -0,0 +1,762 @@
"""Own pipeline processes, shared ingestion, recovery, and exclusive operations."""
from __future__ import annotations
from collections.abc import Callable
from contextlib import AbstractContextManager, nullcontext
import logging
import os
from pathlib import Path
import time
from PyQt6.QtCore import QCoreApplication, QEventLoop, QTimer
from python_app.gui.controllers.bscan_replay import BscanReplay
from python_app.gui.controllers.history_controller import HistoryController
from python_app.gui.controllers.sections.views import PrimaryView
from python_app.gui.controllers.plot_controller import PlotController
from python_app.gui.controllers.recording_controller import RecordingController
from python_app.gui.controllers.renderers.sar3d import SAR3D_STATES, sar3d_status
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
from python_app.gui.runtime.history import build_processor_run_signature, build_run_history_signature
from python_app.gui.runtime.pipeline_pause import PipelinePause
from python_app.gui.settings_defaults import env_enabled
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.collection_identity import collection_key, index_collections
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
from python_app.models.run_config_schema import RunConfigModel
from python_app.models.run_config_validation import validate_acquisition_config
from python_app.orchestration.config_writer import ConfigWriter
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
from python_app.orchestration.pipeline_metrics import PipelineMetrics
from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_NAMES, REQUIRED_PREPROCESS_ASSET_KEYS,
preprocess_asset_model, runtime_preprocess_asset_keys,
)
from python_app.orchestration.process_supervisor import ProcessSupervisor
from python_app.orchestration.restart_policy import RestartPolicy
from python_app.orchestration.shm.ring_reader import ShmRingReader
from python_app.storage.npz.store import NpzStore
from python_app.workflows.object_calibration import validate_object_set
logger = logging.getLogger(__name__)
Readers = tuple[ShmRingReader | None, ShmRingReader | None, ShmRingReader | None]
class PipelineController:
_READER_ERROR_RELOG_EVERY = 200
_READER_ERROR_RECONNECT_AT = 40
_READER_ERROR_STOP_AT = 400
_RESTART_POLICY = RestartPolicy(min_interval_s=3.0, max_interval_s=60.0)
SINGLE_CAPTURE_TIMEOUT_S = 300.0
def __init__(
self, project_root: Path, store: NpzStore, config_writer: ConfigWriter,
history: HistoryController, plots: PlotController, recording: RecordingController,
*, build_config: Callable[[], RunConfigModel], live_config: Callable[[], ProcessingLiveConfig],
publish_live: Callable[[], None], capture_guard: Callable[[], str | None],
refresh_radar_limits: Callable[[], bool], status: Callable[[str], None],
history_changed: Callable[[], None], poll_ui: Callable[[], None],
show_exception: Callable[[str, Exception], None], deferred_close: Callable[[], object],
metrics_every: int | None = None,
event_scope: Callable[[], AbstractContextManager[object]] = nullcontext,
) -> None:
self._project_root, self._store, self._config_writer = project_root, store, config_writer
self.history, self.plots, self.recording = history, plots, recording
self._build_config, self._live_config, self.publish_live = build_config, live_config, publish_live
self._capture_guard, self._refresh_radar_limits = capture_guard, refresh_radar_limits
self._status, self.history_changed, self._poll_ui = status, history_changed, poll_ui
self._show_exception, self._deferred_close = show_exception, deferred_close
self._event_scope = event_scope
self.supervisor = ProcessSupervisor(project_root)
self.metrics = PipelineMetrics(report_every=self._metrics_every() if metrics_every is None else metrics_every,
log_sink=logger.info)
self.timer = QTimer()
self.timer.setInterval(50)
self.timer.timeout.connect(self.poll)
self.replay = BscanReplay(history, self.reprocess_history, self._warning, logger.debug)
self._warnings: set[str] = set()
self._raw_reader: ShmRingReader | None = None
self._pre_reader: ShmRingReader | None = None
self._result_reader: ShmRingReader | None = None
self.active_config: RunConfigModel | None = None
self.active_config_path: Path | None = None
self.stop_generation = 0
self.should_run = False
self._pause: PipelinePause | None = None
self._stopping = self._closing = self._close_deferred = self._stop_all_requested = False
self._processor_signature: tuple[object, ...] | None = None
self.single_capture_active = False
self._single_capture_start_ns: int | None = None
self._single_capture_target_key: tuple[int, int] | None = None
self.max_pop_per_poll, self.max_pop_per_snapshot_drain = 256, 4096
self._last_reader_error_signature: tuple[str, str] | None = None
self._reader_error_repeat_count = self._restart_count = 0
self._last_restart_s = 0.0
self._ui_errors: set[tuple[str, str, str]] = set()
def bind_actions(self, view: PrimaryView) -> None:
view.start.clicked.connect(lambda: self.start())
view.single_capture.clicked.connect(lambda: self.start(single_capture=True))
view.stop.clicked.connect(self.stop)
view.apply_radar.clicked.connect(self.apply_radar)
@staticmethod
def _metrics_every() -> int:
raw = os.environ.get("RADAR_SYSTEM_METRICS_REPORT_EVERY", "").strip()
try:
value = int(raw) if raw else 50
if value >= 1:
return value
except ValueError:
pass
logger.warning("Ignoring invalid RADAR_SYSTEM_METRICS_REPORT_EVERY=%r; using default 50.", raw)
return 50
@property
def running(self) -> bool:
return self.supervisor.is_running()
@property
def processor_running(self) -> bool:
return self.supervisor.is_processor_running()
@property
def acquisition_healthy(self) -> bool:
return {"acquisition", "data_preprocessor"}.issubset(self.supervisor.pids())
@property
def paused(self) -> bool:
return self._pause is not None
@property
def stopping(self) -> bool:
return self._stopping
@property
def closing(self) -> bool:
return self._closing
@property
def readers(self) -> Readers:
return self._raw_reader, self._pre_reader, self._result_reader
def _warning(self, text: str, *, details: str | None = None, once_key: str | None = None) -> None:
if once_key is not None:
if once_key in self._warnings:
return
self._warnings.add(once_key)
logger.warning("%s%s", text, f"\n{details}" if details else "")
def can_resume(self, generation: int) -> bool:
return not self._closing and self.stop_generation == generation
def operation_cancelled(self) -> None:
if not self._closing:
logger.info("Pipeline operation cancelled by Stop")
def schedule_startup(self) -> None:
if not env_enabled("RADAR_SYSTEM_AUTO_START"):
return
generation = self.stop_generation
def start() -> None:
if not self.can_resume(generation):
return
started = self.start()
if not started and env_enabled("RADAR_SYSTEM_HEADLESS") and self.can_resume(generation):
logger.error("Headless auto-start did not bring the pipeline up")
app = QCoreApplication.instance()
if app is not None:
app.exit(1)
def apply_then_start() -> None:
if self.can_resume(generation):
self.apply_radar()
QTimer.singleShot(1000, start)
logger.info("Auto-start requested by launcher")
QTimer.singleShot(500, apply_then_start if env_enabled("RADAR_SYSTEM_AUTO_APPLY_RADAR") else start)
def pause(self) -> PipelinePause:
if self.paused or self._stopping or self._closing:
raise RuntimeError("Wait for the current pipeline operation to finish")
generation, resume = self.stop_generation, self.should_run
def release(requested: bool) -> None:
if self._pause is operation:
self._pause = None
if requested and resume and self.can_resume(generation):
if not self.start() and self.can_resume(generation):
self.should_run = True
self._last_restart_s = time.monotonic()
self._restart_count += 1
logger.error("Pipeline resume failed; automatic restart will retry")
operation = PipelinePause(lambda: self._pause is operation and self.can_resume(generation), release)
# Ownership precedes shutdown: drain pumps Qt and may receive Stop or Close.
self._pause = operation
try:
error = self.stop_pipeline(keep_processor=True)
if error is not None:
raise error
if not operation.valid:
raise InterruptedError("Pipeline operation cancelled")
return operation
except BaseException:
operation.finish(resume=False)
raise
def _validate_assets(self, config: RunConfigModel) -> None:
missing = [PREPROCESS_ASSET_NAMES[key] for key in REQUIRED_PREPROCESS_ASSET_KEYS
if not preprocess_asset_model(config, key).set_name]
if missing:
raise RuntimeError("Select all required preprocess sets in Preprocessing Panel before Start: " + ", ".join(missing))
radar_key = config.radar_key()
combos = [ComboKey(input=combo.input, output=combo.output) for combo in config.combos]
keys = runtime_preprocess_asset_keys(config)
logger.info("Active preprocess assets for run: %s", "; ".join(
f"{PREPROCESS_ASSET_NAMES[key]}={preprocess_asset_model(config, key).set_name}" for key in keys))
for key in keys:
asset = preprocess_asset_model(config, key)
if not self._store.has_combo_coverage(key, radar_key, asset.set_name, combos):
raise RuntimeError(f"Selected {PREPROCESS_ASSET_NAMES[key]} set does not cover requested run combos")
name = config.preprocess.s21.object_calibration.set_name
if name:
collection, metadata = self._store.load_set_with_metadata("s21_object_calibration", radar_key, name)
through = self._store.load_set("s21_calibration", radar_key, config.preprocess.s21.calibration.set_name)
validate_object_set(collection, metadata, config.to_dict(), radar_key, through)
def start(self, *, single_capture: bool = False, config_override: RunConfigModel | None = None,
pause: PipelinePause | None = None) -> bool:
if self._closing:
return False
generation = self.stop_generation
starting = False
try:
if self._stopping:
raise RuntimeError("Wait for pipeline shutdown before starting again")
if config_override is None or pause is None:
reason = self._capture_guard()
if reason:
raise RuntimeError(reason)
if self._pause is not pause or (pause is not None and not pause.valid):
raise RuntimeError("Wait for the current pipeline operation to finish")
config = config_override.clone() if config_override is not None else self._build_config()
validate_acquisition_config(config)
if config_override is None:
live = self._live_config()
validate_processing_mode_constraints(live.processor_mode, config, live)
self._validate_assets(config)
run_signature, processor_signature = build_run_history_signature(config), build_processor_run_signature(config)
if self.running:
if not single_capture:
raise RuntimeError("Pipeline is already running")
starting = True
logger.info("Stopping continuous pipeline before single capture")
self.stop_pipeline(keep_processor=True)
if not self.can_resume(generation):
return False
starting = True
processor_was_running = self.processor_running
if self._processor_requires_restart(processor_signature):
logger.info("Restarting data_processor because stable run settings changed")
self.stop_all()
if not self.can_resume(generation):
return False
processor_was_running = False
self._config_writer.prepare_preprocess_bundles(self._store, config.radar_key(), config)
if not processor_was_running:
self.reset_history()
config.runtime.continuous = not single_capture
config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json")
logger.info("Starting pipeline: mode=%s, config=%s, combos=%d, radar_key=%s",
"single_capture" if single_capture else "continuous", config_path, len(config.combos), config.radar_key())
if self.history.run_signature is not None and self.history.run_signature != run_signature:
if processor_was_running:
self.reset_history()
self.history.apply_deletion("clear_all")
self.publish_live()
logger.info("History reset because run settings changed")
self.history.run_signature = run_signature
# Timestamp before spawn; a fast one-shot producer can publish inside start().
started_ns = time.monotonic_ns() if single_capture else None
self.replay.close()
self.discard_replay()
if not self.can_resume(generation) or (pause is not None and not pause.valid):
return False
self.supervisor.start(config_path, allow_clean_acquisition_exit=single_capture)
self._open_readers(config)
self._processor_signature = processor_signature
self.single_capture_active, self._single_capture_start_ns = single_capture, started_ns
self._single_capture_target_key = None
if not single_capture:
self.drop_pending()
self._last_reader_error_signature, self._reader_error_repeat_count = None, 0
self.active_config, self.active_config_path = config, config_path
self.should_run, self._restart_count = not single_capture and pause is None, 0
self._status("Status: single capture running" if single_capture else "Status: running")
logger.info("Pipeline started; managed processes: %s", self.supervisor.pids())
return self.can_resume(generation) and self.running
except Exception as exc:
# Refused actions must not stop another operation or an existing run.
if starting:
self.stop_all()
self._show_exception("Failed to start pipeline", exc)
return False
def _processor_requires_restart(self, signature: tuple[object, ...]) -> bool:
return self.processor_running and self._processor_signature != signature
def apply_radar(self) -> None:
try:
reason = self._capture_guard()
if reason:
raise RuntimeError(reason)
config = self._build_config()
validate_acquisition_config(config)
processor_only = self.processor_running and not self.running
with self.pause() as operation:
if config.radar.driver_mode == "native" and not config.radar.replay.dataset_dir:
self._refresh_radar_limits()
if not operation.valid:
return
if processor_only and self._processor_requires_restart(build_processor_run_signature(config)):
self.stop_all()
if not operation.valid:
return
self.reset_history()
self.history.run_signature = None
self._prepare_radar(config)
logger.info("Radar settings applied: start=%g Hz, stop=%g Hz, points=%s, ifbw=%g Hz, power=%g dBm",
config.radar.sweep.start_hz, config.radar.sweep.stop_hz,
"from Kamil ADC stream" if config.is_kamil_adc else config.radar.sweep.points,
config.radar.sweep.if_bandwidth_hz, config.radar.sweep.power_dbm)
except InterruptedError:
self.operation_cancelled()
except Exception as exc:
self._show_exception("Failed to apply radar settings", exc)
@staticmethod
def _prepare_radar(config: RunConfigModel) -> None:
if config.radar.replay.dataset_dir or config.radar.driver_mode != "native":
logger.info("Radar pre-configuration skipped (replay or mock mode)")
elif config.is_multi_device:
logger.info("Multi-device raw producer will configure all LibreVNA devices")
elif config.is_kamil_adc:
applied = apply_kamil_adc_laser_control(config)
logger.info("Kamil ADC laser_control %s", "applied via Apply Radar" if applied else "disabled")
else:
service = create_single_radar_service(config)
try:
service.open()
service.configure(config.radar.sweep)
finally:
service.close()
logger.info("Radar pre-configured via Python driver: model=%s", config.radar.model)
def stop(self) -> None:
self.stop_generation += 1
self.stop_pipeline(keep_processor=True)
def stop_all(self) -> None:
self.stop_pipeline(keep_processor=False)
def stop_pipeline(self, *, keep_processor: bool) -> Exception | None:
self.should_run = False
self.replay.close()
if self._stopping:
self._stop_all_requested |= not keep_processor
return
self._stopping, self._stop_all_requested = True, not keep_processor
failure: tuple[str, Exception] | None = None
try:
drain_error: Exception | None = None
try:
if keep_processor and self.running:
self.supervisor.stop_acquisition()
self.drain_rings(timeout_s=0.35, poll_s=0.02)
self.supervisor.stop_preprocessor()
self.drain_rings(timeout_s=0.25, poll_s=0.02)
elif keep_processor:
self.supervisor.stop()
self.drain_once()
if self._stop_all_requested:
self.supervisor.stop_all()
self.drain_rings(timeout_s=0.25, poll_s=0.02)
except Exception as exc:
drain_error = exc
self.stop_generation += 1
(self.supervisor.stop_all if self._stop_all_requested else self.supervisor.stop)()
if self._stop_all_requested:
self._processor_signature = None
self.recording.finish(reason="pipeline pause" if self.paused else "pipeline closed" if self._closing else "pipeline stopped")
keep_results = not self._stop_all_requested and self.processor_running
ring_name = self._result_reader.name if self._result_reader is not None else None
self._close_readers(keep_results=keep_results and drain_error is None)
self.single_capture_active, self._single_capture_start_ns, self._single_capture_target_key = False, None, None
self.history_changed()
self._status("Status: idle")
logger.info("Acquisition stopped (data_processor kept running)" if keep_results else "Pipeline stopped")
if drain_error is not None:
self._status("Status: error")
failure = "Pipeline stopped, but final ring data could not be read", drain_error
if keep_results and ring_name and not self._closing:
self._result_reader = ShmRingReader(ring_name)
except Exception as exc:
self.stop_generation += 1
self._status("Status: error")
failure = "Failed to stop pipeline", exc
finally:
self._stopping = False
if self._close_deferred:
self._close_deferred = False
self._deferred_close()
if failure is not None:
if self.paused or self._closing:
logger.error(failure[0], exc_info=failure[1])
else:
self._show_exception(*failure)
return failure[1]
return None
def begin_close(self) -> bool:
if not self._closing:
self.stop_generation += 1
self._closing = True
self.timer.stop()
self.replay.close()
if self._stopping:
self._stop_all_requested = self._close_deferred = True
return False
return True
def close(self) -> None:
if not self.begin_close():
return
if self.recording.active:
self.stop_pipeline(keep_processor=True)
self.stop_all()
def _close_readers(self, *, keep_results: bool = False) -> None:
raw, pre, result = self.readers
self._raw_reader = self._pre_reader = None
if not keep_results:
self._result_reader = None
for reader in (raw, pre, None if keep_results else result):
if reader is not None:
reader.close()
def _open_readers(self, config: RunConfigModel) -> None:
self._close_readers()
self._raw_reader = ShmRingReader(config.rings.raw_tap.name) if config.rings.raw_tap.name else None
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name) if config.rings.preprocessed_tap.name else None
self._result_reader = ShmRingReader(config.rings.results.name)
def poll(self) -> None:
if self._closing or self._stopping:
return
self._poll_ui()
self._handle_process_exit_reports()
try:
latest = self.read_raw(), self.read_preprocessed(), self.read_results()
except Exception as exc:
self._handle_reader_poll_error(exc)
return
if any(collection is not None for collection in latest):
self._last_reader_error_signature, self._reader_error_repeat_count = None, 0
for action in (self.recording.poll, self.history_changed):
try:
action()
except Exception as exc:
self._report_ui_error("Runtime display/recording", exc)
try:
if self.single_capture_active:
try:
self._finish_single_capture_if_ready()
finally:
if self.single_capture_active:
self._check_single_capture_deadline()
elif latest[2] is not None:
self._restart_count = 0
started = time.monotonic_ns()
self.plots.draw(latest[2])
self.metrics.record("rendering", time.monotonic_ns() - started)
except Exception as exc:
self._report_ui_error("Result display", exc)
def _report_ui_error(self, context: str, exc: Exception) -> None:
self._status(f"Status: {context.lower()} error (acquisition continues)")
signature = context, type(exc).__name__, str(exc)
if signature not in self._ui_errors:
self._ui_errors.add(signature)
logger.error("%s failed; acquisition continues", context, exc_info=exc)
def _handle_process_exit_reports(self) -> None:
unexpected = False
try:
for report in self.supervisor.collect_exit_reports():
if report.level == "INFO":
logger.info(report.format())
else:
self._status("Status: error")
logger.error(report.format())
unexpected = True
if not self.paused and self.should_run and (unexpected or not
{"data_processor", "data_preprocessor", "acquisition"}.issubset(self.supervisor.pids())):
self._recover_pipeline_after_crash()
except Exception:
logger.exception("Failed to collect process exit reports")
def _recover_pipeline_after_crash(self) -> None:
if self.paused or self._closing:
return
now = time.monotonic()
if not self._RESTART_POLICY.should_restart_now(now_s=now, last_restart_s=self._last_restart_s,
consecutive_failures=self._restart_count):
return
self._last_restart_s, self._restart_count = now, self._restart_count + 1
config, path = self.active_config, self.active_config_path
if config is None or path is None:
self.should_run = False
logger.error("Cannot auto-restart pipeline: no active run configuration recorded.")
return
logger.error("Pipeline process exited unexpectedly; restarting (attempt %d, next back-off %.0fs).",
self._restart_count, self._RESTART_POLICY.backoff_for(self._restart_count))
try:
# No event-pumping drain in recovery: polling must not re-enter this restart.
self.supervisor.stop_all()
self._close_readers()
self.supervisor.start(path, allow_clean_acquisition_exit=False)
self._open_readers(config)
self._processor_signature = build_processor_run_signature(config)
self.drop_pending()
self._status("Status: running")
logger.info("Pipeline auto-restarted after crash.")
except Exception:
logger.exception("Pipeline auto-restart failed; will retry")
def _handle_reader_poll_error(self, exc: Exception) -> None:
signature = type(exc).__name__, str(exc)
self._status("Status: error")
self._reader_error_repeat_count += 1
if self._last_reader_error_signature != signature:
self._last_reader_error_signature = signature
logger.error("Reader poll failed", exc_info=exc)
repeats = self._reader_error_repeat_count
if repeats % self._READER_ERROR_RELOG_EVERY == 0:
logger.error("Reader poll still failing (repeat #%d)", repeats, exc_info=exc)
if repeats >= self._READER_ERROR_STOP_AT:
logger.error("Stopping pipeline after %d consecutive reader poll failures", repeats)
self._last_reader_error_signature, self._reader_error_repeat_count = None, 0
self.stop_all()
elif repeats == self._READER_ERROR_RECONNECT_AT:
try:
self._raw_reader = self._reconnect(self._raw_reader)
self._pre_reader = self._reconnect(self._pre_reader)
self._result_reader = self._reconnect(self._result_reader)
logger.info("Ring readers reconnected after repeated poll failures")
except Exception:
logger.exception("Ring reader reconnect failed")
@staticmethod
def _reconnect(reader: ShmRingReader | None) -> ShmRingReader | None:
if reader is None:
return None
name = reader.name
reader.close()
return ShmRingReader(name)
def _finish_single_capture_if_ready(self) -> bool:
if not self.single_capture_active or self._single_capture_target_key is None:
return False
result = index_collections(self.history.results).get(self._single_capture_target_key)
if result is None or not self.plots.has_output(result):
return False
try:
self.plots.draw(result)
except Exception as exc:
self._report_ui_error("Result display", exc)
status = sar3d_status(result) if self.plots.stack.currentWidget() is self.plots.sar3d.widget else None
self.stop()
if status is not None and status[0] != 0:
state = SAR3D_STATES[status[0]]
self._status(f"Status: {state}")
(logger.error if status[0] == 2 else logger.warning)("Single capture: SAR3D %s: %s", state, status[1])
else:
logger.info("Single capture completed")
return True
def _check_single_capture_deadline(self) -> None:
start = self._single_capture_start_ns
if start is not None and time.monotonic_ns() - start > self.SINGLE_CAPTURE_TIMEOUT_S * 1e9:
logger.error("Single capture timed out after %.0fs with no result; stopping.", self.SINGLE_CAPTURE_TIMEOUT_S)
self.stop()
def read_raw(self, *, max_collections: int | None = None) -> SweepCollection | None:
latest = None
if self._raw_reader is not None:
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
self.history.raw.append(collection)
self.recording.record("raw", collection)
self.metrics.record("acquisition", collection.capture_end_ns - collection.capture_start_ns)
self._select_single_target(collection)
latest = collection
return latest
def read_preprocessed(self, *, max_collections: int | None = None) -> SweepCollection | None:
latest = None
if self._pre_reader is not 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.history.preprocessed.append(collection)
self.recording.record("preprocessed", collection)
latest = collection
return latest
def read_results(self, *, max_collections: int | None = None) -> ResultCollection | None:
latest = None
if self._result_reader is not None:
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
if self.history.is_removed(collection):
continue
if self._raw_reader is None:
self._select_single_target(collection)
self.metrics.record("processing", collection.processing_duration_ns)
if self.history.record_result(collection):
self.recording.record("results", collection)
latest = collection
return latest
def _select_single_target(self, collection: SweepCollection | ResultCollection) -> None:
if (self.single_capture_active and self._single_capture_target_key is None
and self._single_capture_start_ns is not None and collection.capture_start_ns >= self._single_capture_start_ns):
self._single_capture_target_key = collection_key(collection)
def pump_events(self, pause_s: float) -> None:
app = QCoreApplication.instance()
if app is None:
time.sleep(pause_s)
return
deadline = time.monotonic() + pause_s
while (remaining_ms := int((deadline - time.monotonic()) * 1000)) > 0:
with self._event_scope():
app.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, remaining_ms)
time.sleep(min(0.002, max(0.0, deadline - time.monotonic())))
def drain_once(self) -> bool:
return any(collection is not None for collection in (self.read_raw(), self.read_preprocessed(), self.read_results()))
def drain_rings(self, *, timeout_s: float, poll_s: float) -> None:
deadline, stable = time.monotonic() + timeout_s, 0
while time.monotonic() < deadline and stable < 2:
stable = 0 if self.drain_once() else stable + 1
self.pump_events(poll_s)
def drain_results(self, *, timeout_s: float, poll_s: float) -> ResultCollection | None:
deadline, stable = time.monotonic() + timeout_s, 0
latest_seen = None
while self._result_reader is not None and time.monotonic() < deadline and (latest_seen is None or stable < 2):
latest = self.read_results()
if latest is not None:
latest_seen, stable = latest, 0
elif latest_seen is not None:
stable += 1
self.pump_events(poll_s)
return latest_seen
def drain_snapshot(self) -> None:
initial_readers = self.readers
if all(reader is None for reader in initial_readers):
return
try:
observed = [False, False, False]
deadline = time.monotonic() + 1.2
while time.monotonic() < deadline and not self._closing:
latest = (self.read_raw(max_collections=self.max_pop_per_snapshot_drain),
self.read_preprocessed(max_collections=self.max_pop_per_snapshot_drain),
self.read_results(max_collections=self.max_pop_per_snapshot_drain))
observed = [seen or value is not None for seen, value in zip(observed, latest)]
if any(value is not None for value in latest):
continue
missing = any(reader is not None and not seen for reader, seen in zip(initial_readers, observed))
awaiting = self._result_reader is not None and max(len(self.history.raw), len(self.history.preprocessed)) > len(self.history.results)
if (any(observed) and missing) or awaiting:
self.pump_events(0.01)
else:
break
except Exception:
logger.warning("Snapshot drain warning", exc_info=True)
def drop_pending(self, *, include_results: bool = True) -> tuple[int, int, int]:
raw, pre, result = self.readers
dropped = (raw.drop_all() if raw is not None else 0, pre.drop_all() if pre is not None else 0,
result.drop_all() if include_results and result is not None else 0)
if any(dropped):
logger.info("Ring reset: raw=%d, preprocessed=%d, results=%d", *dropped)
return dropped
def settle_deleted_results(self) -> None:
if self.processor_running:
self.drain_results(timeout_s=0.35, poll_s=0.01)
if self._result_reader is not None:
self._result_reader.drop_all()
def reset_history(self) -> None:
self.history.raw.clear()
self.history.preprocessed.clear()
self.history.results.clear()
self.discard_replay()
self.plots.clear()
self.history_changed()
def preprocessing_changed(self, *, restart_processor: bool = False) -> None:
self.history.run_signature = None
if restart_processor:
self._processor_signature = None
try:
if self.processor_running:
self.history.apply_deletion("clear_all")
self.publish_live()
finally:
self.reset_history()
def can_reprocess_history(self, *, mode: str | None = None) -> bool:
bscan = mode == "bscan" if mode is not None else self.plots.stack.currentWidget() is self.plots.bscan.widget
return (not self._closing and bscan
and bool(self.history.preprocessed) and not self.running and self.processor_running
and self._result_reader is not None)
def schedule_replay(self) -> None:
self.replay.schedule(self.can_reprocess_history())
def discard_replay(self) -> None:
self.replay.discard()
self.plots.bscan.signature = None
def reprocess_history(self) -> None:
if self.replay.active or not self.can_reprocess_history():
return
config, reader, generation = self.active_config, self._result_reader, self.stop_generation
polling = self.timer.isActive()
self.timer.stop()
try:
if self.replay.run(window=self.plots.window_scans(), revision=self.plots.required_revision or 0,
ring=config.rings.preprocessed if config is not None else None, reader=reader,
pump_events=self.pump_events, continue_replay=lambda: self.can_resume(generation)
and not self._stopping and self.can_reprocess_history() and self._result_reader is reader):
self.plots.redraw()
except Exception as exc:
self._show_exception("Failed to recompute the B-scan", exc)
finally:
if polling and not self._closing:
self.timer.start()