Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
@@ -44,17 +44,42 @@ class _WebActionCall:
a request/response so failures reach the browser.
"""
__slots__ = ("done", "error")
__slots__ = ("done", "error", "_lock", "_started")
def __init__(self) -> None:
self.done = threading.Event()
self.error: str | None = None
self._lock = threading.Lock()
self._started = False
def begin(self) -> bool:
with self._lock:
if self.done.is_set() or self._started:
return False
self._started = True
return True
def finish(self, error: str | None = None) -> None:
with self._lock:
if not self.done.is_set():
self.error = error
self.done.set()
def cancel(self, error: str, *, queued_only: bool = True) -> bool:
with self._lock:
if self.done.is_set():
return True
if queued_only and self._started:
return False
self.error = error
self.done.set()
return True
# Headless has no shown window, so give the offscreen window a usable size for the
# grabbed plot. In GUI mode the user's real (shown) window size is used as-is.
_HEADLESS_PLOT_SIZE = (1600, 900)
# Grab/encode the plot at most this often (the plot only changes per result/redraw).
_GRAB_INTERVAL_S = 0.2
# Field names a web client may apply: the form's live/display/stable fields plus the
# Field names a web client may apply: the form's live/display fields plus the
# history command. Derived from the single schema so it can never drift from the form.
_LIVE_FIELD_NAMES = web_apply_field_names()
@@ -89,8 +114,7 @@ class AppWindowWebController(QObject):
"""
# Control signals carry a trailing _WebActionCall the GUI slot fills in, so the web
# thread can block on the real outcome. apply_settings stays fire-and-forget: it is
# validated up front and returns the live schema, not a pass/fail.
# thread can block on the real outcome and receive the updated state.
start_requested = pyqtSignal(object)
stop_requested = pyqtSignal(object)
remove_last_requested = pyqtSignal(object)
@@ -99,7 +123,9 @@ class AppWindowWebController(QObject):
start_recording_requested = pyqtSignal(str, str, int, object)
load_config_requested = pyqtSignal(str, object)
save_dataset_requested = pyqtSignal(str, str, object)
apply_settings_requested = pyqtSignal(dict)
select_combo_requested = pyqtSignal(int, int, object)
next_combo_requested = pyqtSignal(object)
apply_settings_requested = pyqtSignal(dict, object)
def __init__(self, run_configs_dir: Path, parent: QObject | None = None) -> None:
super().__init__(parent)
@@ -107,10 +133,13 @@ class AppWindowWebController(QObject):
self._status: dict = {}
self._live_settings: list = []
self._frame: dict | None = None
self._pending_lock = threading.Lock()
self._pending_calls: set[_WebActionCall] = set()
self._closed = False
# -- Snapshot refresh (Qt main thread) -----------------------------------
def update_snapshot(self, *, status: dict, live_settings: dict, frame: dict | None) -> None:
def update_snapshot(self, *, status: dict, live_settings: list, frame: dict | None) -> None:
"""Replace the served snapshots; ``frame`` only when a new one was grabbed."""
self._status = status
self._live_settings = live_settings
@@ -143,11 +172,28 @@ class AppWindowWebController(QObject):
action surfaced an error or did not finish within the timeout.
"""
call = _WebActionCall()
signal.emit(*args, call)
if not call.done.wait(_WEB_ACTION_TIMEOUT_S):
raise WebActionError("The desktop did not complete the action in time")
if call.error is not None:
raise WebActionError(call.error)
with self._pending_lock:
if self._closed:
raise WebActionError("The application is closing")
self._pending_calls.add(call)
try:
signal.emit(*args, call)
if not call.done.wait(_WEB_ACTION_TIMEOUT_S):
if not call.cancel("The desktop did not start the action in time; the request was cancelled"):
raise WebActionError("The desktop action is still running; check its status before retrying")
if call.error is not None:
raise WebActionError(call.error)
finally:
with self._pending_lock:
self._pending_calls.discard(call)
def shutdown(self) -> None:
"""Release waiting HTTP workers and reject queued controls during teardown."""
with self._pending_lock:
self._closed = True
pending = tuple(self._pending_calls)
for call in pending:
call.cancel("The application is closing", queued_only=False)
def start(self) -> None:
self._dispatch(self.start_requested)
@@ -166,13 +212,15 @@ class AppWindowWebController(QObject):
def start_recording(self, path: str, name: str, count: int) -> None:
"""Arm a run + disk recording of the next ``count`` measurements (the desktop button)."""
self._dispatch(self.start_recording_requested, path, name, int(count))
if type(count) is not int or count < 1:
raise ValueError("Recording count must be a positive integer")
self._dispatch(self.start_recording_requested, path, name, count)
def apply_live_settings(self, fields: dict) -> dict:
def apply_live_settings(self, fields: dict) -> list:
unknown = set(fields) - _LIVE_FIELD_NAMES
if unknown:
raise ValueError(f"Unknown live-settings fields: {', '.join(sorted(unknown))}")
self.apply_settings_requested.emit(dict(fields))
self._dispatch(self.apply_settings_requested, dict(fields))
return self.current_live_settings()
def load_config(self, name: str) -> None:
@@ -187,9 +235,19 @@ class AppWindowWebController(QObject):
self._dispatch(self.load_config_requested, name)
def save_dataset(self, path: str, name: str) -> None:
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
"""Save the last 1000 runtime measurements to ``path``/``name``."""
self._dispatch(self.save_dataset_requested, path, name)
def select_combo(self, input_pos: int, output_pos: int) -> None:
"""Select the matrix B-scan channel through the desktop display controls."""
if any(type(pos) is not int or pos < 0 for pos in (input_pos, output_pos)):
raise ValueError("Channel positions must be nonnegative integers")
self._dispatch(self.select_combo_requested, input_pos, output_pos)
def next_combo(self) -> None:
"""Advance the matrix B-scan display to the next configured channel pair."""
self._dispatch(self.next_combo_requested)
class AppWindowWebMixin:
"""Start/stop the embedded web server and feed it the live plot + settings."""
@@ -245,7 +303,15 @@ class AppWindowWebMixin:
controller.save_dataset_requested.connect(
lambda path, name, call: self._run_web_action(call, self._save_web_dataset, path, name)
)
controller.apply_settings_requested.connect(self._apply_web_live_settings)
controller.select_combo_requested.connect(
lambda inp, out, call: self._run_web_action(call, self._select_display_combo, inp, out)
)
controller.next_combo_requested.connect(
lambda call: self._run_web_action(call, self._select_next_combo)
)
controller.apply_settings_requested.connect(
lambda fields, call: self._run_web_action(call, self._apply_web_live_settings, fields)
)
self._web_controller = controller
self._web_update_snapshot() # seed snapshots before the first request
@@ -268,17 +334,24 @@ class AppWindowWebMixin:
modal error dialog pumping the event loop in GUI mode — saves and restores the
capture slot, so each action only sees its own first error.
"""
if getattr(self, "_closing", False):
call.cancel("The application is closing", queued_only=False)
return
if not call.begin():
return
previous_capture = self._web_action_error_capture
capture: list[str] = []
self._web_action_error_capture = capture
error = None
try:
action(*args)
call.error = capture[0] if capture else None
error = capture[0] if capture else None
self._web_update_snapshot()
except Exception as exc: # noqa: BLE001 - handlers self-report; this is a backstop
call.error = self._exception_summary(exc)
error = self._exception_summary(exc)
finally:
self._web_action_error_capture = previous_capture
call.done.set()
call.finish(error)
def _load_web_config(self, name: str) -> None:
"""Load a run config chosen in the browser through the shared desktop load path.
@@ -289,12 +362,11 @@ class AppWindowWebMixin:
"""
config_path = _safe_run_config_path(self._run_configs_dir, name)
if config_path is None:
self._log_warning(f"Ignored web request to load unknown run config: {name}")
return
raise ValueError(f"Unknown run config: {name}")
self._load_config_from_path(config_path)
def _save_web_dataset(self, path: str, name: str) -> None:
"""Save the dataset from the browser via the same handler as the desktop button.
"""Save the last 1000 measurements via the shared desktop handler.
The web fields are remote editors of the shared save-path/name widgets: the name
is mirrored as-is (blank is valid — it yields a timestamped file), while a blank
@@ -313,11 +385,15 @@ class AppWindowWebMixin:
path keeps the configured destination), the record count is applied to the shared
spinbox, then the unchanged desktop arming action runs.
"""
if type(count) is not int or not self._record_count.minimum() <= count <= self._record_count.maximum():
raise ValueError(
f"Recording count must be an integer from {self._record_count.minimum()} "
f"to {self._record_count.maximum()}"
)
if path.strip():
self._save_path_input.setText(path)
self._save_name_input.setText(name)
if count >= 1:
self._record_count.setValue(min(count, self._record_count.maximum()))
self._record_count.setValue(count)
self._start_run_with_recording()
def _web_update_snapshot(self) -> None:
@@ -349,6 +425,16 @@ class AppWindowWebMixin:
"raw_count": len(self._raw_history),
"preprocessed_count": len(self._pre_history),
"result_count": len(self._result_history),
"sar3d_status": self._sar3d_status_label.text()
if self._processing_mode.currentText() == "sar3d" else "",
"display_combo": {
"enabled": self._is_matrix_radar_model_selected()
and self._processing_mode.currentText() == "bscan",
"input": self._single_combo_input.text(),
"output": self._single_combo_output.text(),
"options": self._available_display_combo_keys()
if self._is_matrix_radar_model_selected() else [],
},
},
live_settings=self._web_settings_schema(),
frame=self._web_grab_frame_if_due(),
@@ -377,8 +463,10 @@ class AppWindowWebMixin:
}
def _grab_plot_png_b64(self) -> str | None:
"""Render the currently-visible plot page to a base64 PNG (exactly as shown)."""
"""Render the active plots; SAR3D status is sent separately as web text."""
widget = self._plot_stack.currentWidget()
if widget is self._sar3d_plots_container:
widget = self._sar3d_plot_grid
if widget is None or widget.width() <= 0 or widget.height() <= 0:
return None
pixmap = widget.grab()
@@ -391,6 +479,9 @@ class AppWindowWebMixin:
def _shutdown_web_ui(self) -> None:
"""Stop the server (and its broadcaster) during teardown."""
controller = getattr(self, "_web_controller", None)
if controller is not None:
controller.shutdown()
server = getattr(self, "_web_server", None)
if server is not None:
with contextlib.suppress(Exception):