some webUI fixes
This commit is contained in:
@@ -124,6 +124,8 @@ class AppWindow(
|
|||||||
self._project_root = project_root
|
self._project_root = project_root
|
||||||
self._root_profile_path = project_root / "run_config.json"
|
self._root_profile_path = project_root / "run_config.json"
|
||||||
self._active_profile_path = self._root_profile_path
|
self._active_profile_path = self._root_profile_path
|
||||||
|
# Run-config profiles the web UI can browse and load (it has no file dialog).
|
||||||
|
self._run_configs_dir = project_root / "run_configs"
|
||||||
self._pending_startup_log_entries: list[tuple[str, str, str | None]] = []
|
self._pending_startup_log_entries: list[tuple[str, str, str | None]] = []
|
||||||
# Guards closeEvent against re-entrant teardown (e.g. a second signal).
|
# Guards closeEvent against re-entrant teardown (e.g. a second signal).
|
||||||
self._closing = False
|
self._closing = False
|
||||||
|
|||||||
@@ -298,11 +298,23 @@ class AppWindowLiveProcessingMixin:
|
|||||||
``_on_processing_live_settings_changed`` handler — one writer, one redraw —
|
``_on_processing_live_settings_changed`` handler — one writer, one redraw —
|
||||||
so the browser and the desktop never desync and ``processing_live.json`` has
|
so the browser and the desktop never desync and ``processing_live.json`` has
|
||||||
exactly one author. The per-widget change signals are neutralized by a
|
exactly one author. The per-widget change signals are neutralized by a
|
||||||
suppression flag so the final handler runs once instead of dozens of times;
|
suppression flag so the final handler runs once instead of dozens of times.
|
||||||
a history command keeps its own server-managed sequence-bump path.
|
|
||||||
|
A destructive history command is not a settings edit: it routes to the very
|
||||||
|
same handler the desktop's "Clear History" / "Remove Last" button uses, so the
|
||||||
|
browser drains the ring readers, empties the runtime histories, clears the
|
||||||
|
render caches and the processor's replay state, and redraws — rather than only
|
||||||
|
nudging the processor while the on-screen history stays put.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
history_command = str(fields.get("history_command", "none"))
|
history_command = str(fields.get("history_command", "none"))
|
||||||
|
if history_command == "clear_all":
|
||||||
|
self._clear_all_runtime_history()
|
||||||
|
return
|
||||||
|
if history_command == "remove_last":
|
||||||
|
self._remove_last_runtime_history()
|
||||||
|
return
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
name: value
|
name: value
|
||||||
for name, value in fields.items()
|
for name, value in fields.items()
|
||||||
@@ -310,10 +322,7 @@ class AppWindowLiveProcessingMixin:
|
|||||||
}
|
}
|
||||||
# Log only field names (not values) so remote edits are traceable
|
# Log only field names (not values) so remote edits are traceable
|
||||||
# without recording arbitrary client-supplied payloads.
|
# without recording arbitrary client-supplied payloads.
|
||||||
self._log_debug(
|
self._log_debug(f"Applying web live settings: fields={sorted(settings)}.")
|
||||||
f"Applying web live settings: fields={sorted(settings)}, "
|
|
||||||
f"history_command={history_command}."
|
|
||||||
)
|
|
||||||
self._suppress_live_settings_handler = True
|
self._suppress_live_settings_handler = True
|
||||||
try:
|
try:
|
||||||
# processor_mode first: dual-sourced gpr_* fields route to the gpr or
|
# processor_mode first: dual-sourced gpr_* fields route to the gpr or
|
||||||
@@ -324,9 +333,6 @@ class AppWindowLiveProcessingMixin:
|
|||||||
_set_web_live_field(self, name, value)
|
_set_web_live_field(self, name, value)
|
||||||
finally:
|
finally:
|
||||||
self._suppress_live_settings_handler = False
|
self._suppress_live_settings_handler = False
|
||||||
|
|
||||||
if history_command in {"clear_all", "remove_last"}:
|
|
||||||
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
|
|
||||||
self._on_processing_live_settings_changed()
|
self._on_processing_live_settings_changed()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self._show_exception("Failed to apply web live settings", exc)
|
self._show_exception("Failed to apply web live settings", exc)
|
||||||
|
|||||||
@@ -172,32 +172,39 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self._show_exception("Failed to save config profile", exc)
|
self._show_exception("Failed to save config profile", exc)
|
||||||
|
|
||||||
def _load_config_from_dialog(self) -> None:
|
def _ensure_ready_to_load_config(self) -> bool:
|
||||||
"""Load full GUI profile from a user-selected JSON file."""
|
"""Return whether a config load may proceed, reporting the blocker if not.
|
||||||
|
|
||||||
|
Loading rebuilds every widget and resizes history, so it is refused while a
|
||||||
|
capture sequence or the pipeline is active — the operator must stop first.
|
||||||
|
Shared by the desktop dialog and the web loader so both honor the same rule.
|
||||||
|
"""
|
||||||
if self._capture_session is not None:
|
if self._capture_session is not None:
|
||||||
self._show_error(
|
self._show_error(
|
||||||
"Cannot load config during active capture sequence",
|
"Cannot load config during active capture sequence",
|
||||||
details=self._capture_state_details(),
|
details=self._capture_state_details(),
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
if self._supervisor.is_running():
|
if self._supervisor.is_running():
|
||||||
self._show_error(
|
self._show_error(
|
||||||
"Stop all pipeline processes before loading a config profile",
|
"Stop all pipeline processes before loading a config profile",
|
||||||
details=self._process_state_details(),
|
details=self._process_state_details(),
|
||||||
)
|
)
|
||||||
return
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
selected_path, _selected_filter = QFileDialog.getOpenFileName(
|
def _load_config_from_path(self, config_path: Path) -> None:
|
||||||
self,
|
"""Load a full GUI profile from ``config_path`` and apply it to the UI.
|
||||||
"Load Config Profile",
|
|
||||||
str(self._active_profile_path),
|
|
||||||
"JSON Files (*.json);;All Files (*)",
|
|
||||||
)
|
|
||||||
if not selected_path:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
This is the single authoritative load path: the desktop "Load Config" dialog
|
||||||
|
and the web config picker both funnel here, differing only in how they obtain
|
||||||
|
the path. The pipeline is left untouched; live settings reach a running
|
||||||
|
data_processor immediately while stable settings stage for the next Start.
|
||||||
|
"""
|
||||||
|
if not self._ensure_ready_to_load_config():
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
normalized_path = self._normalize_profile_path(Path(selected_path))
|
normalized_path = self._normalize_profile_path(config_path)
|
||||||
profile = GuiProfileModel.load_from_path(normalized_path)
|
profile = GuiProfileModel.load_from_path(normalized_path)
|
||||||
processor_running = self._supervisor.is_processor_running()
|
processor_running = self._supervisor.is_processor_running()
|
||||||
self._apply_loaded_profile(profile, normalized_path)
|
self._apply_loaded_profile(profile, normalized_path)
|
||||||
@@ -217,6 +224,20 @@ class AppWindowConfigProfileIOMixin:
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self._show_exception("Failed to load config profile", exc)
|
self._show_exception("Failed to load config profile", exc)
|
||||||
|
|
||||||
|
def _load_config_from_dialog(self) -> None:
|
||||||
|
"""Load a full GUI profile from a user-selected JSON file (desktop button)."""
|
||||||
|
if not self._ensure_ready_to_load_config():
|
||||||
|
return
|
||||||
|
selected_path, _selected_filter = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"Load Config Profile",
|
||||||
|
str(self._active_profile_path),
|
||||||
|
"JSON Files (*.json);;All Files (*)",
|
||||||
|
)
|
||||||
|
if not selected_path:
|
||||||
|
return
|
||||||
|
self._load_config_from_path(Path(selected_path))
|
||||||
|
|
||||||
def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None:
|
def _apply_loaded_profile(self, profile: GuiProfileModel, profile_path: Path) -> None:
|
||||||
"""Apply an already parsed profile to GUI state without restarting the pipeline.
|
"""Apply an already parsed profile to GUI state without restarting the pipeline.
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,27 @@ import time
|
|||||||
|
|
||||||
from PyQt6.QtWidgets import QFileDialog
|
from PyQt6.QtWidgets import QFileDialog
|
||||||
from python_app.gui.runtime.history import record_result_history, remove_last_aligned_histories
|
from python_app.gui.runtime.history import record_result_history, remove_last_aligned_histories
|
||||||
|
from python_app.storage.npz.paths import radar_config_filename_prefix
|
||||||
|
|
||||||
|
|
||||||
class AppWindowSnapshotMixin:
|
class AppWindowSnapshotMixin:
|
||||||
"""Saves runtime data snapshots and maintains ring-reader freshness."""
|
"""Saves runtime data snapshots and maintains ring-reader freshness."""
|
||||||
|
|
||||||
|
def _radar_config_name_prefix(self) -> str:
|
||||||
|
"""Filename prefix encoding the current radar setup (model, sweep span, points, power).
|
||||||
|
|
||||||
|
Prepended to every saved dataset name so the file records the configuration it was
|
||||||
|
captured with — identical for desktop and web saves, which share these handlers.
|
||||||
|
"""
|
||||||
|
radar = self._build_config().radar
|
||||||
|
return radar_config_filename_prefix(
|
||||||
|
radar.model,
|
||||||
|
radar.sweep.start_hz,
|
||||||
|
radar.sweep.stop_hz,
|
||||||
|
radar.sweep.points,
|
||||||
|
radar.sweep.power_dbm,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _snapshot_config_profile_path(snapshot_dir: Path) -> Path:
|
def _snapshot_config_profile_path(snapshot_dir: Path) -> Path:
|
||||||
"""Return companion config-profile path inside a saved snapshot directory."""
|
"""Return companion config-profile path inside a saved snapshot directory."""
|
||||||
@@ -41,6 +57,7 @@ class AppWindowSnapshotMixin:
|
|||||||
list(self._pre_history),
|
list(self._pre_history),
|
||||||
list(self._result_history),
|
list(self._result_history),
|
||||||
last_n,
|
last_n,
|
||||||
|
name_prefix=self._radar_config_name_prefix(),
|
||||||
)
|
)
|
||||||
config_profile_path = self._snapshot_config_profile_path(snapshot_dir)
|
config_profile_path = self._snapshot_config_profile_path(snapshot_dir)
|
||||||
try:
|
try:
|
||||||
@@ -94,6 +111,7 @@ class AppWindowSnapshotMixin:
|
|||||||
last_n,
|
last_n,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
primary_stage="preprocessed",
|
primary_stage="preprocessed",
|
||||||
|
name_prefix=self._radar_config_name_prefix(),
|
||||||
)
|
)
|
||||||
output_stem = str(summary.get("output_stem", "")).strip()
|
output_stem = str(summary.get("output_stem", "")).strip()
|
||||||
if not output_stem:
|
if not output_stem:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import base64
|
|||||||
import contextlib
|
import contextlib
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
from PyQt6.QtCore import QBuffer, QIODevice, QObject, pyqtSignal
|
||||||
|
|
||||||
@@ -36,6 +37,26 @@ _GRAB_INTERVAL_S = 0.2
|
|||||||
_LIVE_FIELD_NAMES = web_apply_field_names()
|
_LIVE_FIELD_NAMES = web_apply_field_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_run_config_path(run_configs_dir: Path, name: str) -> Path | None:
|
||||||
|
"""Resolve a client-supplied config name to a file strictly inside ``run_configs_dir``.
|
||||||
|
|
||||||
|
The web sends only a bare file name; this is the trust boundary. The name must be a
|
||||||
|
plain ``.json`` file living directly in the directory — ``name != Path(name).name``
|
||||||
|
rejects any separator or ``..`` traversal. Returns the path, or ``None`` if invalid.
|
||||||
|
"""
|
||||||
|
if not name or name != Path(name).name or not name.lower().endswith(".json"):
|
||||||
|
return None
|
||||||
|
candidate = run_configs_dir / name
|
||||||
|
return candidate if candidate.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _list_run_config_names(run_configs_dir: Path) -> list[str]:
|
||||||
|
"""Return the sorted ``.json`` file names directly inside ``run_configs_dir``."""
|
||||||
|
if not run_configs_dir.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(entry.name for entry in run_configs_dir.glob("*.json") if entry.is_file())
|
||||||
|
|
||||||
|
|
||||||
class AppWindowWebController(QObject):
|
class AppWindowWebController(QObject):
|
||||||
"""Qt bridge satisfying the web contract: streams the plot, forwards controls.
|
"""Qt bridge satisfying the web contract: streams the plot, forwards controls.
|
||||||
|
|
||||||
@@ -50,9 +71,12 @@ class AppWindowWebController(QObject):
|
|||||||
single_capture_requested = pyqtSignal()
|
single_capture_requested = pyqtSignal()
|
||||||
capture_requested = pyqtSignal()
|
capture_requested = pyqtSignal()
|
||||||
apply_settings_requested = pyqtSignal(dict)
|
apply_settings_requested = pyqtSignal(dict)
|
||||||
|
load_config_requested = pyqtSignal(str)
|
||||||
|
save_dataset_requested = pyqtSignal(str, str)
|
||||||
|
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, run_configs_dir: Path, parent: QObject | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self._run_configs_dir = run_configs_dir
|
||||||
self._status: dict = {}
|
self._status: dict = {}
|
||||||
self._live_settings: list = []
|
self._live_settings: list = []
|
||||||
self._frame: dict | None = None
|
self._frame: dict | None = None
|
||||||
@@ -74,6 +98,10 @@ class AppWindowWebController(QObject):
|
|||||||
def current_live_settings(self) -> list:
|
def current_live_settings(self) -> list:
|
||||||
return list(self._live_settings)
|
return list(self._live_settings)
|
||||||
|
|
||||||
|
def list_configs(self) -> list[str]:
|
||||||
|
"""Return the run-config file names available to load (a plain directory scan)."""
|
||||||
|
return _list_run_config_names(self._run_configs_dir)
|
||||||
|
|
||||||
def peek_frame(self) -> dict | None:
|
def peek_frame(self) -> dict | None:
|
||||||
"""Return the most recent rendered-plot frame (or None before the first)."""
|
"""Return the most recent rendered-plot frame (or None before the first)."""
|
||||||
return self._frame
|
return self._frame
|
||||||
@@ -99,6 +127,21 @@ class AppWindowWebController(QObject):
|
|||||||
self.apply_settings_requested.emit(dict(fields))
|
self.apply_settings_requested.emit(dict(fields))
|
||||||
return self.current_live_settings()
|
return self.current_live_settings()
|
||||||
|
|
||||||
|
def load_config(self, name: str) -> None:
|
||||||
|
"""Request loading the run-config named ``name`` (the desktop "Load Config" action).
|
||||||
|
|
||||||
|
Validates the name against the directory here — on the web thread — so an invalid
|
||||||
|
or unsafe name fails the HTTP request immediately instead of silently doing nothing
|
||||||
|
on the Qt side; the actual load runs through the queued signal.
|
||||||
|
"""
|
||||||
|
if _safe_run_config_path(self._run_configs_dir, name) is None:
|
||||||
|
raise ValueError(f"Unknown run config: {name}")
|
||||||
|
self.load_config_requested.emit(name)
|
||||||
|
|
||||||
|
def save_dataset(self, path: str, name: str) -> None:
|
||||||
|
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
|
||||||
|
self.save_dataset_requested.emit(path, name)
|
||||||
|
|
||||||
|
|
||||||
class AppWindowWebMixin:
|
class AppWindowWebMixin:
|
||||||
"""Start/stop the embedded web server and feed it the live plot + settings."""
|
"""Start/stop the embedded web server and feed it the live plot + settings."""
|
||||||
@@ -118,12 +161,17 @@ class AppWindowWebMixin:
|
|||||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||||
self.resize(*_HEADLESS_PLOT_SIZE)
|
self.resize(*_HEADLESS_PLOT_SIZE)
|
||||||
|
|
||||||
controller = AppWindowWebController(parent=self)
|
# The web picker browses this directory; ensure it exists on fresh deploys.
|
||||||
|
self._run_configs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
controller = AppWindowWebController(self._run_configs_dir, parent=self)
|
||||||
controller.start_requested.connect(self._start_run)
|
controller.start_requested.connect(self._start_run)
|
||||||
controller.stop_requested.connect(self._stop_run)
|
controller.stop_requested.connect(self._stop_run)
|
||||||
controller.single_capture_requested.connect(self._start_single_capture)
|
controller.single_capture_requested.connect(self._start_single_capture)
|
||||||
controller.capture_requested.connect(self._capture_tmp_reference)
|
controller.capture_requested.connect(self._capture_tmp_reference)
|
||||||
controller.apply_settings_requested.connect(self._apply_web_live_settings)
|
controller.apply_settings_requested.connect(self._apply_web_live_settings)
|
||||||
|
controller.load_config_requested.connect(self._load_web_config)
|
||||||
|
controller.save_dataset_requested.connect(self._save_web_dataset)
|
||||||
|
|
||||||
self._web_controller = controller
|
self._web_controller = controller
|
||||||
self._web_update_snapshot() # seed snapshots before the first request
|
self._web_update_snapshot() # seed snapshots before the first request
|
||||||
@@ -137,6 +185,32 @@ class AppWindowWebMixin:
|
|||||||
self._web_controller = None
|
self._web_controller = None
|
||||||
self._web_server = None
|
self._web_server = None
|
||||||
|
|
||||||
|
def _load_web_config(self, name: str) -> None:
|
||||||
|
"""Load a run config chosen in the browser through the shared desktop load path.
|
||||||
|
|
||||||
|
Runs on the Qt main thread (queued from the web thread). The name is re-resolved
|
||||||
|
against the directory here as the authoritative trust boundary before any file
|
||||||
|
access, then handed to the same loader the desktop "Load Config" button uses.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
path is ignored to avoid wiping the configured destination. The save itself — and
|
||||||
|
the radar-config filename prefix — is the unchanged desktop action.
|
||||||
|
"""
|
||||||
|
if path.strip():
|
||||||
|
self._save_path_input.setText(path)
|
||||||
|
self._save_name_input.setText(name)
|
||||||
|
self._save_snapshot()
|
||||||
|
|
||||||
def _web_update_snapshot(self) -> None:
|
def _web_update_snapshot(self) -> None:
|
||||||
"""Refresh the snapshots the bridge serves (called on the Qt poll tick).
|
"""Refresh the snapshots the bridge serves (called on the Qt poll tick).
|
||||||
|
|
||||||
@@ -152,6 +226,17 @@ class AppWindowWebMixin:
|
|||||||
"running": self._supervisor.is_running(),
|
"running": self._supervisor.is_running(),
|
||||||
"processor_running": self._supervisor.is_processor_running(),
|
"processor_running": self._supervisor.is_processor_running(),
|
||||||
"ring_name": self._defaults_config.rings.results.name,
|
"ring_name": self._defaults_config.rings.results.name,
|
||||||
|
# File name of the currently loaded config profile, so the web picker
|
||||||
|
# can show what is active (the desktop tracks the full path).
|
||||||
|
"active_config": self._active_profile_path.name,
|
||||||
|
# Current save path/name, so the web fields can prefill the desktop values.
|
||||||
|
"save_path": self._save_path_input.text(),
|
||||||
|
"save_name": self._save_name_input.text(),
|
||||||
|
# Per-stage capture counts, identical to the desktop history
|
||||||
|
# label (raw -> preprocessed -> results), mirrored to the browser.
|
||||||
|
"raw_count": len(self._raw_history),
|
||||||
|
"preprocessed_count": len(self._pre_history),
|
||||||
|
"result_count": len(self._result_history),
|
||||||
},
|
},
|
||||||
live_settings=self._web_settings_schema(),
|
live_settings=self._web_settings_schema(),
|
||||||
frame=self._web_grab_frame_if_due(),
|
frame=self._web_grab_frame_if_due(),
|
||||||
|
|||||||
@@ -33,6 +33,43 @@ def radar_key_from_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def radar_config_filename_prefix(
|
||||||
|
model_name: str,
|
||||||
|
sweep_start_hz: float,
|
||||||
|
sweep_stop_hz: float,
|
||||||
|
sweep_points: int,
|
||||||
|
power_dbm: float,
|
||||||
|
) -> str:
|
||||||
|
"""Build a readable, filesystem-safe prefix describing the radar acquisition setup.
|
||||||
|
|
||||||
|
Encodes the radar model, sweep frequency span, point count, and power so a saved
|
||||||
|
dataset's name carries the configuration it was captured with. ADC-mode radars have
|
||||||
|
no fixed sweep, so the point count is reported as ``adc``.
|
||||||
|
"""
|
||||||
|
points_token = "adc" if model_name.strip().lower() == "kamil_adc" else f"{int(sweep_points)}pts"
|
||||||
|
tokens = (
|
||||||
|
model_name,
|
||||||
|
_format_frequency_span(sweep_start_hz, sweep_stop_hz),
|
||||||
|
points_token,
|
||||||
|
f"{power_dbm:g}dBm",
|
||||||
|
)
|
||||||
|
return sanitize_path_component("_".join(tokens))
|
||||||
|
|
||||||
|
|
||||||
|
def _format_frequency_span(start_hz: float, stop_hz: float) -> str:
|
||||||
|
"""Format a frequency span compactly, both bounds sharing the larger bound's unit."""
|
||||||
|
unit, scale = _frequency_unit(max(start_hz, stop_hz))
|
||||||
|
return f"{start_hz / scale:g}-{stop_hz / scale:g}{unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def _frequency_unit(hz: float) -> tuple[str, float]:
|
||||||
|
"""Pick the largest unit (GHz/MHz/kHz/Hz) that keeps the magnitude at or above one."""
|
||||||
|
for unit, scale in (("GHz", 1e9), ("MHz", 1e6), ("kHz", 1e3)):
|
||||||
|
if hz >= scale:
|
||||||
|
return unit, scale
|
||||||
|
return "Hz", 1.0
|
||||||
|
|
||||||
|
|
||||||
def collection_dir_name(index: int, collection_id: int, monotonic_ns: int) -> str:
|
def collection_dir_name(index: int, collection_id: int, monotonic_ns: int) -> str:
|
||||||
"""Build canonical collection directory name for snapshot stages."""
|
"""Build canonical collection directory name for snapshot stages."""
|
||||||
return f"{index:04d}_id{int(collection_id)}_ns{int(monotonic_ns)}"
|
return f"{index:04d}_id{int(collection_id)}_ns{int(monotonic_ns)}"
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ from python_app.storage.store_api import StoreApi
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_snapshot_stem(name: str, name_prefix: str) -> str:
|
||||||
|
"""Compose a sanitized snapshot stem from an optional prefix and the operator's name.
|
||||||
|
|
||||||
|
The name falls back to a UTC timestamp when blank, so a save is always uniquely named.
|
||||||
|
"""
|
||||||
|
base = name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S")
|
||||||
|
return sanitize_path_component(f"{name_prefix}_{base}" if name_prefix else base)
|
||||||
|
|
||||||
|
|
||||||
class NpzStore(StoreApi):
|
class NpzStore(StoreApi):
|
||||||
"""Persist preprocess sets and runtime snapshots using NumPy files."""
|
"""Persist preprocess sets and runtime snapshots using NumPy files."""
|
||||||
|
|
||||||
@@ -193,13 +202,14 @@ class NpzStore(StoreApi):
|
|||||||
preprocessed_history: list[SweepCollection],
|
preprocessed_history: list[SweepCollection],
|
||||||
result_history: list[ResultCollection],
|
result_history: list[ResultCollection],
|
||||||
last_n: int,
|
last_n: int,
|
||||||
|
*,
|
||||||
|
name_prefix: str = "",
|
||||||
) -> tuple[Path, dict[str, Any]]:
|
) -> tuple[Path, dict[str, Any]]:
|
||||||
"""Save historical runtime collections in NumPy tree format."""
|
"""Save historical runtime collections in NumPy tree format."""
|
||||||
if last_n <= 0:
|
if last_n <= 0:
|
||||||
raise ValueError("last_n must be > 0")
|
raise ValueError("last_n must be > 0")
|
||||||
|
|
||||||
snapshot_stem = snapshot_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S")
|
snapshot_stem = _compose_snapshot_stem(snapshot_name, name_prefix)
|
||||||
snapshot_stem = sanitize_path_component(snapshot_stem)
|
|
||||||
|
|
||||||
output_root_dir.mkdir(parents=True, exist_ok=True)
|
output_root_dir.mkdir(parents=True, exist_ok=True)
|
||||||
snapshot_dir = output_root_dir / snapshot_stem
|
snapshot_dir = output_root_dir / snapshot_stem
|
||||||
@@ -324,13 +334,13 @@ class NpzStore(StoreApi):
|
|||||||
*,
|
*,
|
||||||
channel: str = "s21",
|
channel: str = "s21",
|
||||||
primary_stage: str = "preprocessed",
|
primary_stage: str = "preprocessed",
|
||||||
|
name_prefix: str = "",
|
||||||
) -> tuple[list[Path], dict[str, Any]]:
|
) -> tuple[list[Path], dict[str, Any]]:
|
||||||
"""Save one runtime VNA-history JSON per available combo."""
|
"""Save one runtime VNA-history JSON per available combo."""
|
||||||
if last_n <= 0:
|
if last_n <= 0:
|
||||||
raise ValueError("last_n must be > 0")
|
raise ValueError("last_n must be > 0")
|
||||||
|
|
||||||
output_stem = output_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S")
|
output_stem = _compose_snapshot_stem(output_name, name_prefix)
|
||||||
output_stem = sanitize_path_component(output_stem)
|
|
||||||
output_root_dir.mkdir(parents=True, exist_ok=True)
|
output_root_dir.mkdir(parents=True, exist_ok=True)
|
||||||
output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
|
output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from python_app.gui.controllers.app_window_web_mixin import ( # noqa: E402
|
|||||||
AppWindowWebMixin,
|
AppWindowWebMixin,
|
||||||
)
|
)
|
||||||
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402
|
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData # noqa: E402
|
||||||
|
from python_app.storage.npz.paths import radar_config_filename_prefix # noqa: E402
|
||||||
from python_app.storage.npz.store import NpzStore # noqa: E402
|
from python_app.storage.npz.store import NpzStore # noqa: E402
|
||||||
from python_app.storage.npz.vna_history_json import ( # noqa: E402
|
from python_app.storage.npz.vna_history_json import ( # noqa: E402
|
||||||
_complex_to_points,
|
_complex_to_points,
|
||||||
@@ -84,6 +85,29 @@ class NpzStoreRoundTripTest(unittest.TestCase):
|
|||||||
self.store.load_set("calibration", "radar1", "absent")
|
self.store.load_set("calibration", "radar1", "absent")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Radar-config filename prefix
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class RadarConfigPrefixTest(unittest.TestCase):
|
||||||
|
def test_encodes_model_span_points_power(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
radar_config_filename_prefix("librevna", 1e9, 6e9, 201, -10.0),
|
||||||
|
"librevna_1-6GHz_201pts_-10dBm",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_megahertz_span_keeps_fractional_values(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
radar_config_filename_prefix("librevna", 1.5e6, 6e6, 51, -3.5),
|
||||||
|
"librevna_1.5-6MHz_51pts_-3.5dBm",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_adc_model_reports_adc_point_count(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
radar_config_filename_prefix("kamil_adc", 1e9, 6e9, 1, 0.0),
|
||||||
|
"kamil_adc_1-6GHz_adc_0dBm",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# vna_history export
|
# vna_history export
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -121,7 +145,12 @@ class VnaHistoryTest(unittest.TestCase):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
class WebControllerTest(unittest.TestCase):
|
class WebControllerTest(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.controller = AppWindowWebController()
|
self._cfg_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self._cfg_dir.cleanup)
|
||||||
|
self.configs_dir = Path(self._cfg_dir.name)
|
||||||
|
(self.configs_dir / "alpha.json").write_text("{}", encoding="utf-8")
|
||||||
|
(self.configs_dir / "beta.json").write_text("{}", encoding="utf-8")
|
||||||
|
self.controller = AppWindowWebController(self.configs_dir)
|
||||||
self.addCleanup(self.controller.deleteLater)
|
self.addCleanup(self.controller.deleteLater)
|
||||||
|
|
||||||
def test_rejects_unknown_field(self) -> None:
|
def test_rejects_unknown_field(self) -> None:
|
||||||
@@ -160,6 +189,25 @@ class WebControllerTest(unittest.TestCase):
|
|||||||
self.controller.capture_tmp_reference()
|
self.controller.capture_tmp_reference()
|
||||||
self.assertEqual(fired, ["start", "stop", "single", "capture"])
|
self.assertEqual(fired, ["start", "stop", "single", "capture"])
|
||||||
|
|
||||||
|
def test_lists_configs_sorted_and_load_emits_signal(self) -> None:
|
||||||
|
self.assertEqual(self.controller.list_configs(), ["alpha.json", "beta.json"])
|
||||||
|
requested: list[str] = []
|
||||||
|
self.controller.load_config_requested.connect(requested.append)
|
||||||
|
self.controller.load_config("beta.json")
|
||||||
|
self.assertEqual(requested, ["beta.json"])
|
||||||
|
|
||||||
|
def test_load_config_rejects_unknown_or_unsafe_name(self) -> None:
|
||||||
|
# Missing file, traversal, sub-path, and non-json are all refused (HTTP 400 upstream).
|
||||||
|
for bad in ("ghost.json", "../escape.json", "sub/alpha.json", "alpha.txt"):
|
||||||
|
with self.assertRaisesRegex(ValueError, "Unknown run config"):
|
||||||
|
self.controller.load_config(bad)
|
||||||
|
|
||||||
|
def test_save_dataset_forwards_path_and_name(self) -> None:
|
||||||
|
received: list[tuple[str, str]] = []
|
||||||
|
self.controller.save_dataset_requested.connect(lambda p, n: received.append((p, n)))
|
||||||
|
self.controller.save_dataset("/tmp/out", "run1")
|
||||||
|
self.assertEqual(received, [("/tmp/out", "run1")])
|
||||||
|
|
||||||
|
|
||||||
class WebPortTest(unittest.TestCase):
|
class WebPortTest(unittest.TestCase):
|
||||||
def _port_with_env(self, value: str | None) -> int:
|
def _port_with_env(self, value: str | None) -> int:
|
||||||
|
|||||||
@@ -32,9 +32,18 @@ class WebController(Protocol):
|
|||||||
def apply_live_settings(self, fields: dict) -> list:
|
def apply_live_settings(self, fields: dict) -> list:
|
||||||
"""Apply live processor settings; returns the current settings schema."""
|
"""Apply live processor settings; returns the current settings schema."""
|
||||||
|
|
||||||
|
def load_config(self, name: str) -> None:
|
||||||
|
"""Load a run-config profile by file name (the desktop "Load Config" button)."""
|
||||||
|
|
||||||
|
def save_dataset(self, path: str, name: str) -> None:
|
||||||
|
"""Save the runtime dataset to ``path``/``name`` (the desktop "Save Dataset" button)."""
|
||||||
|
|
||||||
def current_live_settings(self) -> list:
|
def current_live_settings(self) -> list:
|
||||||
"""Return the live-settings schema (built from the Qt widgets)."""
|
"""Return the live-settings schema (built from the Qt widgets)."""
|
||||||
|
|
||||||
|
def list_configs(self) -> list[str]:
|
||||||
|
"""Return the run-config file names available to load."""
|
||||||
|
|
||||||
def status(self) -> dict:
|
def status(self) -> dict:
|
||||||
"""Return a snapshot of pipeline/run state."""
|
"""Return a snapshot of pipeline/run state."""
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,35 @@ async def post_tmp_reference(request: Request) -> dict:
|
|||||||
return controller.status()
|
return controller.status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/configs")
|
||||||
|
async def get_configs(request: Request) -> dict:
|
||||||
|
return {"names": _controller(request).list_configs()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/load_config")
|
||||||
|
async def post_load_config(request: Request, name: str = Body(..., embed=True)) -> dict:
|
||||||
|
logger.info("Web UI request: load config %r", name)
|
||||||
|
controller = _controller(request)
|
||||||
|
try:
|
||||||
|
controller.load_config(name)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning("Web UI rejected config load: %s", exc)
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return controller.status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/save_dataset")
|
||||||
|
async def post_save_dataset(
|
||||||
|
request: Request,
|
||||||
|
path: str = Body("", embed=True),
|
||||||
|
name: str = Body("", embed=True),
|
||||||
|
) -> dict:
|
||||||
|
logger.info("Web UI request: save dataset")
|
||||||
|
controller = _controller(request)
|
||||||
|
controller.save_dataset(path, name)
|
||||||
|
return controller.status()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/live_settings")
|
@router.get("/api/live_settings")
|
||||||
async def get_live_settings(request: Request) -> list:
|
async def get_live_settings(request: Request) -> list:
|
||||||
return _controller(request).current_live_settings()
|
return _controller(request).current_live_settings()
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const btnStop = document.getElementById("btn-stop");
|
|||||||
const btnTmpRef = document.getElementById("btn-tmp-ref");
|
const btnTmpRef = document.getElementById("btn-tmp-ref");
|
||||||
const btnApply = document.getElementById("btn-apply");
|
const btnApply = document.getElementById("btn-apply");
|
||||||
const btnResetHistory = document.getElementById("btn-reset-history");
|
const btnResetHistory = document.getElementById("btn-reset-history");
|
||||||
|
const btnLoadConfig = document.getElementById("btn-load-config");
|
||||||
|
const configSelect = document.getElementById("config-select");
|
||||||
|
const configActiveEl = document.getElementById("config-active");
|
||||||
|
const savePathInput = document.getElementById("save-path");
|
||||||
|
const saveNameInput = document.getElementById("save-name");
|
||||||
|
const btnSaveDataset = document.getElementById("btn-save-dataset");
|
||||||
|
|
||||||
const settingsToggle = document.getElementById("settings-toggle");
|
const settingsToggle = document.getElementById("settings-toggle");
|
||||||
const sidePanel = document.querySelector(".side-panel");
|
const sidePanel = document.querySelector(".side-panel");
|
||||||
@@ -24,12 +30,15 @@ const settingsNote = document.getElementById("settings-note");
|
|||||||
const runningEl = document.getElementById("stat-running");
|
const runningEl = document.getElementById("stat-running");
|
||||||
const processorEl = document.getElementById("stat-processor");
|
const processorEl = document.getElementById("stat-processor");
|
||||||
const ringEl = document.getElementById("stat-ring");
|
const ringEl = document.getElementById("stat-ring");
|
||||||
|
const scansEl = document.getElementById("stat-scans");
|
||||||
const staleEl = document.getElementById("stat-stale");
|
const staleEl = document.getElementById("stat-stale");
|
||||||
const toastEl = document.getElementById("toast");
|
const toastEl = document.getElementById("toast");
|
||||||
|
|
||||||
/* ---- state ------------------------------------------------------- */
|
/* ---- state ------------------------------------------------------- */
|
||||||
let pendingPng = null; // newest PNG (base64), shown on the next animation frame
|
let pendingPng = null; // newest PNG (base64), shown on the next animation frame
|
||||||
let lastFrameTs = 0; // performance.now() of the last received frame
|
let lastFrameTs = 0; // performance.now() of the last received frame
|
||||||
|
let pipelineRunning = false; // gates the config loader (loading requires a stopped pipeline)
|
||||||
|
let saveFieldsPrefilled = false; // seed the save path/name fields once, then leave the operator's edits
|
||||||
|
|
||||||
/* ---- helpers ----------------------------------------------------- */
|
/* ---- helpers ----------------------------------------------------- */
|
||||||
function toast(message, isError) {
|
function toast(message, isError) {
|
||||||
@@ -245,6 +254,63 @@ async function loadSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- config profile --------------------------------------------- */
|
||||||
|
// Loading a config does exactly what the desktop "Load Config" button does. The file
|
||||||
|
// list comes from the server's run_configs/ directory (the browser has no file access),
|
||||||
|
// and loading is gated to a stopped pipeline — mirroring the desktop precondition.
|
||||||
|
function updateLoadButtonState() {
|
||||||
|
btnLoadConfig.disabled = pipelineRunning || configSelect.options.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfigList() {
|
||||||
|
try {
|
||||||
|
const { names = [] } = await api("/api/configs");
|
||||||
|
const previous = configSelect.value;
|
||||||
|
configSelect.innerHTML = "";
|
||||||
|
for (const name of names) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = name;
|
||||||
|
opt.textContent = name;
|
||||||
|
configSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (names.includes(previous)) configSelect.value = previous; // keep the operator's choice
|
||||||
|
updateLoadButtonState();
|
||||||
|
} catch (err) {
|
||||||
|
toast("Could not load config list: " + err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnLoadConfig.addEventListener("click", async () => {
|
||||||
|
const name = configSelect.value;
|
||||||
|
if (!name) return;
|
||||||
|
btnLoadConfig.disabled = true;
|
||||||
|
try {
|
||||||
|
await api("/api/load_config", { name });
|
||||||
|
toast(`Config loaded: ${name}`); // the new settings schema arrives via the live status push
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
} finally {
|
||||||
|
updateLoadButtonState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- save dataset ----------------------------------------------- */
|
||||||
|
// Saves to the path in the field, mirroring the desktop "Save Dataset" button exactly.
|
||||||
|
btnSaveDataset.addEventListener("click", async () => {
|
||||||
|
btnSaveDataset.disabled = true;
|
||||||
|
try {
|
||||||
|
await api("/api/save_dataset", {
|
||||||
|
path: savePathInput.value.trim(),
|
||||||
|
name: saveNameInput.value.trim(),
|
||||||
|
});
|
||||||
|
toast("Save requested"); // a radar-config prefix is prepended to the name server-side
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
} finally {
|
||||||
|
btnSaveDataset.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/* ---- status ------------------------------------------------------ */
|
/* ---- status ------------------------------------------------------ */
|
||||||
function setStat(el, label, value, cls) {
|
function setStat(el, label, value, cls) {
|
||||||
el.className = "stat" + (cls ? " " + cls : "");
|
el.className = "stat" + (cls ? " " + cls : "");
|
||||||
@@ -253,13 +319,26 @@ function setStat(el, label, value, cls) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyStatus(s) {
|
function applyStatus(s) {
|
||||||
if ("running" in s)
|
if ("running" in s) {
|
||||||
setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off");
|
setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off");
|
||||||
|
pipelineRunning = !!s.running;
|
||||||
|
updateLoadButtonState();
|
||||||
|
}
|
||||||
|
if ("active_config" in s)
|
||||||
|
configActiveEl.textContent = s.active_config ? `current: ${s.active_config}` : "";
|
||||||
|
if ("save_path" in s && !saveFieldsPrefilled) {
|
||||||
|
savePathInput.value = s.save_path || ""; // seed once; don't clobber later edits
|
||||||
|
saveNameInput.value = s.save_name || "";
|
||||||
|
saveFieldsPrefilled = true;
|
||||||
|
}
|
||||||
if ("processor_running" in s)
|
if ("processor_running" in s)
|
||||||
setStat(processorEl, "processor", s.processor_running ? "yes" : "no",
|
setStat(processorEl, "processor", s.processor_running ? "yes" : "no",
|
||||||
s.processor_running ? "ok" : "off");
|
s.processor_running ? "ok" : "off");
|
||||||
if ("ring_name" in s) setStat(ringEl, "ring", s.ring_name || "—",
|
if ("ring_name" in s) setStat(ringEl, "ring", s.ring_name || "—",
|
||||||
s.ring_name ? "" : "off");
|
s.ring_name ? "" : "off");
|
||||||
|
if ("raw_count" in s)
|
||||||
|
setStat(scansEl, "scans",
|
||||||
|
`raw ${s.raw_count} / preprocessed ${s.preprocessed_count} / results ${s.result_count}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- frame rendering (latest-wins; the frame IS the Qt plot image) - */
|
/* ---- frame rendering (latest-wins; the frame IS the Qt plot image) - */
|
||||||
@@ -305,6 +384,7 @@ async function init() {
|
|||||||
settingsNote.textContent = "Status unavailable: " + err.message;
|
settingsNote.textContent = "Status unavailable: " + err.message;
|
||||||
}
|
}
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
await loadConfigList();
|
||||||
connectWs();
|
connectWs();
|
||||||
}
|
}
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -28,6 +28,26 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="side-panel">
|
<aside class="side-panel">
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-title">Config profile</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<select id="config-select" class="config-control" aria-label="Run config"></select>
|
||||||
|
<button id="btn-load-config" class="btn">Load</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-active" id="config-active"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-title">Save dataset</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<input id="save-path" class="config-control" type="text" placeholder="Save path" aria-label="Save path" />
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<input id="save-name" class="config-control" type="text" placeholder="Name (optional)" aria-label="Save name" />
|
||||||
|
<button id="btn-save-dataset" class="btn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel-head" id="settings-toggle">
|
<div class="panel-head" id="settings-toggle">
|
||||||
<span class="panel-title">Processor settings</span>
|
<span class="panel-title">Processor settings</span>
|
||||||
<span class="chevron" id="settings-chevron">▾</span>
|
<span class="chevron" id="settings-chevron">▾</span>
|
||||||
@@ -47,6 +67,7 @@
|
|||||||
<span class="stat" id="stat-running">running: —</span>
|
<span class="stat" id="stat-running">running: —</span>
|
||||||
<span class="stat" id="stat-processor">processor: —</span>
|
<span class="stat" id="stat-processor">processor: —</span>
|
||||||
<span class="stat" id="stat-ring">ring: —</span>
|
<span class="stat" id="stat-ring">ring: —</span>
|
||||||
|
<span class="stat" id="stat-scans">scans: —</span>
|
||||||
<span class="stat" id="stat-stale">live</span>
|
<span class="stat" id="stat-stale">live</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,35 @@ body {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
/* Config profile picker (top of the side panel) */
|
||||||
|
.config-section {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
.config-title { font-weight: 600; color: var(--status); margin-bottom: 8px; }
|
||||||
|
.config-row { display: flex; gap: 8px; }
|
||||||
|
.config-row + .config-row { margin-top: 8px; }
|
||||||
|
.config-control {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.config-control:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.config-active {
|
||||||
|
margin-top: 6px;
|
||||||
|
min-height: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-head {
|
.panel-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
{
|
||||||
|
"radar": {
|
||||||
|
"model": "librevna",
|
||||||
|
"serial": "",
|
||||||
|
"remote_host": "127.0.0.1",
|
||||||
|
"remote_port": 50209,
|
||||||
|
"driver_mode": "mock",
|
||||||
|
"mock_signal_hz": 5000000.0,
|
||||||
|
"visa_library": "",
|
||||||
|
"multi_device": {
|
||||||
|
"slave_serials": [],
|
||||||
|
"force_external_reference": false,
|
||||||
|
"recovery_attempts": 3
|
||||||
|
},
|
||||||
|
"kamil_adc": {
|
||||||
|
"project_dir": "",
|
||||||
|
"executable_path": "",
|
||||||
|
"tty_path": "",
|
||||||
|
"args": [],
|
||||||
|
"env": {},
|
||||||
|
"startup_timeout_s": 5.0,
|
||||||
|
"sweep_timeout_s": 5.0,
|
||||||
|
"stop_timeout_s": 2.0
|
||||||
|
},
|
||||||
|
"laser_control": {
|
||||||
|
"enabled": false,
|
||||||
|
"port": "",
|
||||||
|
"mode": "manual",
|
||||||
|
"pi_coeff1_p": 2560,
|
||||||
|
"pi_coeff1_i": 128,
|
||||||
|
"pi_coeff2_p": 2560,
|
||||||
|
"pi_coeff2_i": 128,
|
||||||
|
"manual": {
|
||||||
|
"temp1": 25.0,
|
||||||
|
"temp2": 25.0,
|
||||||
|
"current1": 30.0,
|
||||||
|
"current2": 30.0
|
||||||
|
},
|
||||||
|
"variation": {
|
||||||
|
"variation_type": "CHANGE_CURRENT_LD1",
|
||||||
|
"static_temp1": 25.0,
|
||||||
|
"static_temp2": 25.0,
|
||||||
|
"static_current1": 30.0,
|
||||||
|
"static_current2": 30.0,
|
||||||
|
"min_value": 30.0,
|
||||||
|
"max_value": 35.0,
|
||||||
|
"step": 0.1,
|
||||||
|
"time_step": 20,
|
||||||
|
"delay_time": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sweep": {
|
||||||
|
"start_hz": 1000000.0,
|
||||||
|
"stop_hz": 6000000000.0,
|
||||||
|
"if_bandwidth_hz": 50000.0,
|
||||||
|
"stimulus_power_dbm": -10.0,
|
||||||
|
"points": 201
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"switches": {
|
||||||
|
"port1": {
|
||||||
|
"name": "port1",
|
||||||
|
"driver_mode": "mock",
|
||||||
|
"driver": "h7992",
|
||||||
|
"radar_port": 1,
|
||||||
|
"positions": 2,
|
||||||
|
"default_position": 0,
|
||||||
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
|
"pin_a": 17,
|
||||||
|
"pin_b": 27,
|
||||||
|
"invert_logic": false
|
||||||
|
},
|
||||||
|
"port2": {
|
||||||
|
"name": "port2",
|
||||||
|
"driver_mode": "mock",
|
||||||
|
"driver": "h7992",
|
||||||
|
"radar_port": 2,
|
||||||
|
"positions": 4,
|
||||||
|
"default_position": 0,
|
||||||
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
|
"pin_a": 22,
|
||||||
|
"pin_b": 23,
|
||||||
|
"invert_logic": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"control_button": {
|
||||||
|
"enabled": false,
|
||||||
|
"gpio_chip": "/dev/gpiochip0",
|
||||||
|
"pin": -1,
|
||||||
|
"active_low": true,
|
||||||
|
"bias": "",
|
||||||
|
"debounce_ms": 50,
|
||||||
|
"action": "capture_tmp_reference"
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"settling_ms": 0,
|
||||||
|
"idle_sleep_ms": 2,
|
||||||
|
"continuous": true,
|
||||||
|
"processing_live_config_path": "python_app/runtime/processing_live.json",
|
||||||
|
"locator_server": {
|
||||||
|
"device_id": 3,
|
||||||
|
"protocol_version": 1,
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"port": 8888,
|
||||||
|
"max_payload_bytes": 65536,
|
||||||
|
"client_queue_size": 32,
|
||||||
|
"logger_name": "locator_runtime"
|
||||||
|
},
|
||||||
|
"combos": [
|
||||||
|
{
|
||||||
|
"input": 0,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 1,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 2,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 3,
|
||||||
|
"output": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 0,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 1,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 2,
|
||||||
|
"output": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input": 3,
|
||||||
|
"output": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"preprocess": {
|
||||||
|
"s21": {
|
||||||
|
"calibration": {
|
||||||
|
"set_name": "smoke_cal",
|
||||||
|
"bundle_path": ""
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"set_name": "smoke_ref",
|
||||||
|
"bundle_path": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"s11": {
|
||||||
|
"calibration": {
|
||||||
|
"open": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
},
|
||||||
|
"short": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
},
|
||||||
|
"load": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"set_name": "",
|
||||||
|
"bundle_path": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notch": {
|
||||||
|
"enabled": true,
|
||||||
|
"bands_hz": [],
|
||||||
|
"taper_width_hz": 40000000.0,
|
||||||
|
"taper_type": "cosine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gpr": {
|
||||||
|
"relative_permittivity": 1.0,
|
||||||
|
"tx_geometry": [
|
||||||
|
{
|
||||||
|
"output_pos": 0,
|
||||||
|
"x_m": 0.905,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"output_pos": 1,
|
||||||
|
"x_m": -0.905,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rx_geometry": [
|
||||||
|
{
|
||||||
|
"input_pos": 0,
|
||||||
|
"x_m": -0.18,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 1,
|
||||||
|
"x_m": 0.485,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 2,
|
||||||
|
"x_m": -0.49,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"input_pos": 3,
|
||||||
|
"x_m": 0.185,
|
||||||
|
"y_m": 0.0,
|
||||||
|
"z_m": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rings": {
|
||||||
|
"raw": {
|
||||||
|
"name": "/radar_raw_simulator",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"raw_tap": {
|
||||||
|
"name": "/radar_raw_tap_simulator",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"preprocessed": {
|
||||||
|
"name": "/radar_preprocessed_simulator",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"preprocessed_tap": {
|
||||||
|
"name": "/radar_preprocessed_tap_simulator",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"name": "/radar_results_simulator",
|
||||||
|
"capacity": 50,
|
||||||
|
"slot_size_bytes": 2097152
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gui": {
|
||||||
|
"version": 1,
|
||||||
|
"switches": {
|
||||||
|
"combo_mode": "text",
|
||||||
|
"combos_text": "0:0,1:0,2:0,3:0,0:1,1:1,2:1,3:1",
|
||||||
|
"single_input": "0",
|
||||||
|
"single_output": "0"
|
||||||
|
},
|
||||||
|
"processing": {
|
||||||
|
"selected_mode": "gpr",
|
||||||
|
"pass_through": {
|
||||||
|
"show_magnitude": true,
|
||||||
|
"show_phase": false,
|
||||||
|
"combo_filter": "",
|
||||||
|
"fixed_y_enabled": false,
|
||||||
|
"y_min_db": -100.0,
|
||||||
|
"y_max_db": 0.0
|
||||||
|
},
|
||||||
|
"bscan": {
|
||||||
|
"axis": "abs",
|
||||||
|
"cut_m": 0.0,
|
||||||
|
"max_depth_m": 3.0,
|
||||||
|
"gain": 1.0,
|
||||||
|
"start_freq_mhz": 100.0,
|
||||||
|
"stop_freq_mhz": 6000.0,
|
||||||
|
"subtract_mean_ascan": false
|
||||||
|
},
|
||||||
|
"gpr": {
|
||||||
|
"input_positions": "0,1,2,3",
|
||||||
|
"output_positions": "0,1",
|
||||||
|
"min_depth_m": 2.0,
|
||||||
|
"max_depth_m": 14.0,
|
||||||
|
"range_comp_power": 0.28,
|
||||||
|
"angle_comp_power": 0.1,
|
||||||
|
"score_mode": "combined",
|
||||||
|
"max_detected_objects_to_draw": 5,
|
||||||
|
"draw_top_m_objects": 2,
|
||||||
|
"start_freq_mhz": 3000.0,
|
||||||
|
"stop_freq_mhz": 6000.0,
|
||||||
|
"background_subtract_enabled": true,
|
||||||
|
"background_mean_count": 10,
|
||||||
|
"remove_sidelobe_objects_enabled": false,
|
||||||
|
"imaging_plane_y_m": 0.0,
|
||||||
|
"render_mode": "heatmap",
|
||||||
|
"min_visible_score": 0.0,
|
||||||
|
"visible_x_min_m": -2.0,
|
||||||
|
"visible_x_max_m": 2.0,
|
||||||
|
"visible_z_min_m": 0.0,
|
||||||
|
"visible_z_max_m": 14.0
|
||||||
|
},
|
||||||
|
"legacy_gpr": {
|
||||||
|
"mode": "point",
|
||||||
|
"input_positions": "0,1,2,3",
|
||||||
|
"output_positions": "0,1",
|
||||||
|
"min_depth_m": 2.0,
|
||||||
|
"max_depth_m": 14.0,
|
||||||
|
"comp_power": 0.2,
|
||||||
|
"start_freq_mhz": 3000.0,
|
||||||
|
"stop_freq_mhz": 6000.0,
|
||||||
|
"speed_m_s": 0.0,
|
||||||
|
"ignore_socket_speed_enabled": false,
|
||||||
|
"look_angle_deg": 0.0,
|
||||||
|
"apply_freq_phase_correction": true,
|
||||||
|
"reference_mode": "frame_center",
|
||||||
|
"snr_thresh": 4.5,
|
||||||
|
"snr_comp_max": 25.0,
|
||||||
|
"background_subtract_enabled": true,
|
||||||
|
"background_mean_count": 10,
|
||||||
|
"render_mode": "heatmap",
|
||||||
|
"min_visible_pair_count": 1,
|
||||||
|
"visible_x_min_m": -2.0,
|
||||||
|
"visible_x_max_m": 2.0,
|
||||||
|
"visible_z_min_m": 0.0,
|
||||||
|
"visible_z_max_m": 14.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data_actions": {
|
||||||
|
"save_count": 10,
|
||||||
|
"save_path": "python_app/data/snapshots",
|
||||||
|
"save_name": "snapshot_simulator"
|
||||||
|
},
|
||||||
|
"preprocess_dialog": {
|
||||||
|
"set_name": "smoke_cal",
|
||||||
|
"radar_config_dir": "",
|
||||||
|
"use_all_radar_configs": false,
|
||||||
|
"median_sweep_count": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user