Rework radar system
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"""Acquisition identity shared unchanged by every pipeline stage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
|
||||
class CollectionIdentity(Protocol):
|
||||
@property
|
||||
def collection_id(self) -> int: ...
|
||||
|
||||
@property
|
||||
def capture_start_ns(self) -> int: ...
|
||||
|
||||
|
||||
TCollection = TypeVar("TCollection", bound=CollectionIdentity)
|
||||
|
||||
|
||||
def collection_key(collection: CollectionIdentity) -> tuple[int, int]:
|
||||
return collection.collection_id, collection.capture_start_ns
|
||||
|
||||
|
||||
def index_collections(collections: Iterable[TCollection]) -> dict[tuple[int, int], TCollection]:
|
||||
return {collection_key(collection): collection for collection in collections}
|
||||
|
||||
|
||||
def history_collection_removed(
|
||||
collection: CollectionIdentity, clear_before_ns: int, removed: Sequence[Mapping[str, int]],
|
||||
) -> bool:
|
||||
"""Apply a capture-time cutoff and exact acquisition deletions to late results."""
|
||||
return (bool(clear_before_ns) and collection.capture_start_ns <= clear_before_ns) or any(
|
||||
collection_key(collection) == (target["collection_id"], target["capture_start_ns"])
|
||||
for target in removed
|
||||
)
|
||||
@@ -44,7 +44,6 @@ class TraceData:
|
||||
|
||||
combo: ComboKey
|
||||
frequency_hz: np.ndarray
|
||||
s11: np.ndarray
|
||||
s21: np.ndarray
|
||||
capture_start_ns: int = 0
|
||||
capture_end_ns: int = 0
|
||||
@@ -93,5 +92,9 @@ class ResultCollection:
|
||||
# Wall-clock nanoseconds spent by the data_processor on `process_collection`
|
||||
# for this collection. Zero means the producer did not report a measurement.
|
||||
processing_duration_ns: int = 0
|
||||
settings_revision: int = 0
|
||||
capture_start_ns: int = 0
|
||||
capture_end_ns: int = 0
|
||||
speed_m_s: float = 0.0
|
||||
collection_payloads: list[ResultPayload] = field(default_factory=list)
|
||||
blocks: list[ResultBlock] = field(default_factory=list)
|
||||
|
||||
@@ -1,585 +1,78 @@
|
||||
"""Encoding and decoding logic for :mod:`python_app.models.gui_profile_schema`."""
|
||||
"""Strict profile decoding driven by the typed field declarations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from dataclasses import asdict, fields, is_dataclass
|
||||
from typing import Any
|
||||
|
||||
from python_app.models.json_fields import finite_number, json_object as _as_dict
|
||||
from python_app.models.gui_profile_schema import (
|
||||
GuiBscanStateModel,
|
||||
GuiDataActionsStateModel,
|
||||
GuiGprStateModel,
|
||||
GuiLegacyGprStateModel,
|
||||
GuiPassThroughStateModel,
|
||||
GuiPreprocessDialogStateModel,
|
||||
GuiProcessingStateModel,
|
||||
GuiProfileModel,
|
||||
GuiSar3dStateModel,
|
||||
GuiStateModel,
|
||||
GuiSwitchStateModel,
|
||||
)
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from python_app.models.gui_profile_schema import GuiProfileModel, GuiStateModel, Setting
|
||||
from python_app.models.json_fields import finite_number, json_object
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
|
||||
def _optional_string(object_payload: dict[str, Any], key: str, fallback: str, context: str) -> str:
|
||||
"""Return optional string field with type validation."""
|
||||
raw_value = object_payload.get(key, fallback)
|
||||
if not isinstance(raw_value, str):
|
||||
raise ValueError(f"{context}.{key} must be a JSON string")
|
||||
return raw_value
|
||||
|
||||
|
||||
def _optional_bool(object_payload: dict[str, Any], key: str, fallback: bool, context: str) -> bool:
|
||||
"""Return optional boolean field with type validation."""
|
||||
raw_value = object_payload.get(key, fallback)
|
||||
if not isinstance(raw_value, bool):
|
||||
raise ValueError(f"{context}.{key} must be a JSON bool")
|
||||
return raw_value
|
||||
|
||||
|
||||
def _optional_int(object_payload: dict[str, Any], key: str, fallback: int, context: str) -> int:
|
||||
"""Return optional integer field with type validation."""
|
||||
raw_value = object_payload.get(key, fallback)
|
||||
if isinstance(raw_value, bool) or not isinstance(raw_value, int):
|
||||
raise ValueError(f"{context}.{key} must be a JSON integer")
|
||||
return int(raw_value)
|
||||
|
||||
|
||||
def _optional_float(object_payload: dict[str, Any], key: str, fallback: float, context: str) -> float:
|
||||
"""Return optional numeric field with type validation."""
|
||||
return finite_number(object_payload.get(key, fallback), f"{context}.{key}")
|
||||
|
||||
|
||||
def _legacy_gpr_mode_from_algorithm(value: str) -> str | None:
|
||||
"""Translate the short-lived nested GPR algorithm field into legacy mode."""
|
||||
if value == "legacy_point":
|
||||
return "point"
|
||||
if value == "legacy_extended":
|
||||
return "extended"
|
||||
return None
|
||||
def _decode_state(model: object, payload: dict[str, Any], context: str, *, check_constraints: bool = True) -> None:
|
||||
"""Read known fields recursively; unknown fields do not survive re-encoding."""
|
||||
if not is_dataclass(model) or isinstance(model, type):
|
||||
raise TypeError("GUI state must be a dataclass instance")
|
||||
for member in fields(model):
|
||||
default = getattr(model, member.name)
|
||||
value = payload.get(member.name, default)
|
||||
name = f"{context}.{member.name}"
|
||||
if is_dataclass(default):
|
||||
_decode_state(default, json_object(payload.get(member.name), name), name, check_constraints=check_constraints)
|
||||
continue
|
||||
if isinstance(default, float):
|
||||
value = finite_number(value, name)
|
||||
elif type(value) is not type(default):
|
||||
raise ValueError(f"{name} must be a JSON {type(default).__name__}")
|
||||
spec = member.metadata.get("setting")
|
||||
if check_constraints and isinstance(spec, Setting):
|
||||
if spec.choices and value not in spec.choices:
|
||||
raise ValueError(f"{name} must be one of: {', '.join(spec.choices)}")
|
||||
if spec.minimum is not None:
|
||||
if value < spec.minimum or (spec.exclusive_minimum and value == spec.minimum):
|
||||
operator = ">" if spec.exclusive_minimum else ">="
|
||||
raise ValueError(f"{name} must be {operator} {spec.minimum:g}")
|
||||
if spec.maximum is not None and value > spec.maximum:
|
||||
raise ValueError(f"{name} must be <= {spec.maximum:g}")
|
||||
setattr(model, member.name, value)
|
||||
|
||||
|
||||
def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
|
||||
"""Decode JSON-like payload into :class:`GuiProfileModel`."""
|
||||
profile = GuiProfileModel(run_config=RunConfigModel.from_dict(payload), gui=None)
|
||||
gui_payload = payload.get("gui")
|
||||
if gui_payload is None:
|
||||
logger.debug("Decoded GUI profile without a 'gui' section; UI state left unset")
|
||||
"""Read the pipeline config and optional GUI state, preserving old GPR profiles."""
|
||||
profile = GuiProfileModel(run_config=RunConfigModel.from_dict(payload))
|
||||
if payload.get("gui") is None:
|
||||
return profile
|
||||
|
||||
gui_object = _as_dict(gui_payload, "gui")
|
||||
gui_object = json_object(payload["gui"], "gui")
|
||||
processing = json_object(gui_object.get("processing"), "gui.processing")
|
||||
old_gpr = json_object(processing.get("gpr"), "gui.processing.gpr")
|
||||
gui = GuiStateModel()
|
||||
gui.version = _optional_int(gui_object, "version", gui.version, "gui")
|
||||
|
||||
# Older profiles kept both algorithms under gpr. Root mode and the short-lived
|
||||
# algorithm selector choose the legacy page; explicit legacy values win.
|
||||
root_gpr = payload.get("gpr")
|
||||
root_mode = root_gpr.get("mode") if isinstance(root_gpr, dict) else None
|
||||
algorithm_mode = {"legacy_point": "point", "legacy_extended": "extended"}.get(
|
||||
str(old_gpr.get("algorithm", "")))
|
||||
if root_mode in ("point", "extended"):
|
||||
gui.processing.legacy_gpr.mode = root_mode
|
||||
if algorithm_mode is not None:
|
||||
gui.processing.legacy_gpr.mode = algorithm_mode
|
||||
_decode_state(gui.processing.legacy_gpr, old_gpr, "gui.processing.gpr", check_constraints=False)
|
||||
_decode_state(gui, gui_object, "gui")
|
||||
if gui.version != 1:
|
||||
raise ValueError(f"Unsupported gui.version: {gui.version}")
|
||||
|
||||
switches_object = _as_dict(gui_object.get("switches"), "gui.switches")
|
||||
gui.switches = GuiSwitchStateModel(
|
||||
combo_mode=_optional_string(switches_object, "combo_mode", gui.switches.combo_mode, "gui.switches"),
|
||||
combos_text=_optional_string(switches_object, "combos_text", gui.switches.combos_text, "gui.switches"),
|
||||
single_input=_optional_string(switches_object, "single_input", gui.switches.single_input, "gui.switches"),
|
||||
single_output=_optional_string(
|
||||
switches_object,
|
||||
"single_output",
|
||||
gui.switches.single_output,
|
||||
"gui.switches",
|
||||
),
|
||||
)
|
||||
if gui.switches.combo_mode not in {"text", "single"}:
|
||||
raise ValueError("gui.switches.combo_mode must be either 'text' or 'single'")
|
||||
|
||||
processing_object = _as_dict(gui_object.get("processing"), "gui.processing")
|
||||
pass_through_object = _as_dict(processing_object.get("pass_through"), "gui.processing.pass_through")
|
||||
bscan_object = _as_dict(processing_object.get("bscan"), "gui.processing.bscan")
|
||||
gpr_object = _as_dict(processing_object.get("gpr"), "gui.processing.gpr")
|
||||
legacy_gpr_object = _as_dict(processing_object.get("legacy_gpr"), "gui.processing.legacy_gpr")
|
||||
sar3d_object = _as_dict(processing_object.get("sar3d"), "gui.processing.sar3d")
|
||||
root_gpr_object = payload.get("gpr")
|
||||
|
||||
selected_mode = _optional_string(
|
||||
processing_object,
|
||||
"selected_mode",
|
||||
gui.processing.selected_mode,
|
||||
"gui.processing",
|
||||
)
|
||||
legacy_mode_default = gui.processing.legacy_gpr.mode
|
||||
if isinstance(root_gpr_object, dict):
|
||||
if root_gpr_object.get("mode") == "point":
|
||||
legacy_mode_default = "point"
|
||||
elif root_gpr_object.get("mode") == "extended":
|
||||
legacy_mode_default = "extended"
|
||||
legacy_algorithm_mode = _legacy_gpr_mode_from_algorithm(str(gpr_object.get("algorithm", "")))
|
||||
if legacy_algorithm_mode is not None:
|
||||
legacy_mode_default = legacy_algorithm_mode
|
||||
has_legacy_root_gpr_mode = (
|
||||
isinstance(root_gpr_object, dict)
|
||||
and root_gpr_object.get("mode") in {"point", "extended"}
|
||||
)
|
||||
if selected_mode == "gpr" and (legacy_algorithm_mode is not None or has_legacy_root_gpr_mode):
|
||||
logger.debug("Migrating legacy GPR profile: rewriting selected_mode 'gpr' -> 'legacy_gpr'")
|
||||
selected_mode = "legacy_gpr"
|
||||
|
||||
gpr_context = "gui.processing.gpr"
|
||||
legacy_gpr_context = "gui.processing.legacy_gpr"
|
||||
|
||||
def legacy_string(key: str, fallback: str) -> str:
|
||||
"""Read a legacy field, falling back to the old nested gpr object."""
|
||||
return _optional_string(
|
||||
legacy_gpr_object,
|
||||
key,
|
||||
_optional_string(gpr_object, key, fallback, gpr_context),
|
||||
legacy_gpr_context,
|
||||
)
|
||||
|
||||
def legacy_bool(key: str, fallback: bool) -> bool:
|
||||
"""Read a legacy bool, falling back to the old nested gpr object."""
|
||||
return _optional_bool(
|
||||
legacy_gpr_object,
|
||||
key,
|
||||
_optional_bool(gpr_object, key, fallback, gpr_context),
|
||||
legacy_gpr_context,
|
||||
)
|
||||
|
||||
def legacy_int(key: str, fallback: int) -> int:
|
||||
"""Read a legacy integer, falling back to the old nested gpr object."""
|
||||
return _optional_int(
|
||||
legacy_gpr_object,
|
||||
key,
|
||||
_optional_int(gpr_object, key, fallback, gpr_context),
|
||||
legacy_gpr_context,
|
||||
)
|
||||
|
||||
def legacy_float(key: str, fallback: float) -> float:
|
||||
"""Read a legacy float, falling back to the old nested gpr object."""
|
||||
return _optional_float(
|
||||
legacy_gpr_object,
|
||||
key,
|
||||
_optional_float(gpr_object, key, fallback, gpr_context),
|
||||
legacy_gpr_context,
|
||||
)
|
||||
|
||||
sar3d_context = "gui.processing.sar3d"
|
||||
sar3d_defaults = GuiSar3dStateModel()
|
||||
|
||||
def sar3d_string(key: str) -> str:
|
||||
return _optional_string(sar3d_object, key, getattr(sar3d_defaults, key), sar3d_context)
|
||||
|
||||
def sar3d_bool(key: str) -> bool:
|
||||
return _optional_bool(sar3d_object, key, getattr(sar3d_defaults, key), sar3d_context)
|
||||
|
||||
def sar3d_int(key: str) -> int:
|
||||
return _optional_int(sar3d_object, key, getattr(sar3d_defaults, key), sar3d_context)
|
||||
|
||||
def sar3d_float(key: str) -> float:
|
||||
return _optional_float(sar3d_object, key, getattr(sar3d_defaults, key), sar3d_context)
|
||||
|
||||
gui.processing = GuiProcessingStateModel(
|
||||
selected_mode=selected_mode,
|
||||
pass_through=GuiPassThroughStateModel(
|
||||
show_magnitude=_optional_bool(
|
||||
pass_through_object,
|
||||
"show_magnitude",
|
||||
gui.processing.pass_through.show_magnitude,
|
||||
"gui.processing.pass_through",
|
||||
),
|
||||
show_phase=_optional_bool(
|
||||
pass_through_object,
|
||||
"show_phase",
|
||||
gui.processing.pass_through.show_phase,
|
||||
"gui.processing.pass_through",
|
||||
),
|
||||
unwrap_phase=_optional_bool(
|
||||
pass_through_object,
|
||||
"unwrap_phase",
|
||||
gui.processing.pass_through.unwrap_phase,
|
||||
"gui.processing.pass_through",
|
||||
),
|
||||
combo_filter=_optional_string(
|
||||
pass_through_object,
|
||||
"combo_filter",
|
||||
gui.processing.pass_through.combo_filter,
|
||||
"gui.processing.pass_through",
|
||||
),
|
||||
fixed_y_enabled=_optional_bool(
|
||||
pass_through_object,
|
||||
"fixed_y_enabled",
|
||||
gui.processing.pass_through.fixed_y_enabled,
|
||||
"gui.processing.pass_through",
|
||||
),
|
||||
y_min_db=_optional_float(
|
||||
pass_through_object,
|
||||
"y_min_db",
|
||||
gui.processing.pass_through.y_min_db,
|
||||
"gui.processing.pass_through",
|
||||
),
|
||||
y_max_db=_optional_float(
|
||||
pass_through_object,
|
||||
"y_max_db",
|
||||
gui.processing.pass_through.y_max_db,
|
||||
"gui.processing.pass_through",
|
||||
),
|
||||
),
|
||||
bscan=GuiBscanStateModel(
|
||||
axis=_optional_string(bscan_object, "axis", gui.processing.bscan.axis, "gui.processing.bscan"),
|
||||
cut_m=_optional_float(bscan_object, "cut_m", gui.processing.bscan.cut_m, "gui.processing.bscan"),
|
||||
max_depth_m=_optional_float(
|
||||
bscan_object,
|
||||
"max_depth_m",
|
||||
gui.processing.bscan.max_depth_m,
|
||||
"gui.processing.bscan",
|
||||
),
|
||||
gain=_optional_float(bscan_object, "gain", gui.processing.bscan.gain, "gui.processing.bscan"),
|
||||
start_freq_mhz=_optional_float(
|
||||
bscan_object,
|
||||
"start_freq_mhz",
|
||||
gui.processing.bscan.start_freq_mhz,
|
||||
"gui.processing.bscan",
|
||||
),
|
||||
stop_freq_mhz=_optional_float(
|
||||
bscan_object,
|
||||
"stop_freq_mhz",
|
||||
gui.processing.bscan.stop_freq_mhz,
|
||||
"gui.processing.bscan",
|
||||
),
|
||||
subtract_mean_ascan=_optional_bool(
|
||||
bscan_object,
|
||||
"subtract_mean_ascan",
|
||||
gui.processing.bscan.subtract_mean_ascan,
|
||||
"gui.processing.bscan",
|
||||
),
|
||||
history_window_scans=_optional_int(
|
||||
bscan_object,
|
||||
"history_window_scans",
|
||||
gui.processing.bscan.history_window_scans,
|
||||
"gui.processing.bscan",
|
||||
),
|
||||
),
|
||||
gpr=GuiGprStateModel(
|
||||
input_positions=_optional_string(
|
||||
gpr_object,
|
||||
"input_positions",
|
||||
gui.processing.gpr.input_positions,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
output_positions=_optional_string(
|
||||
gpr_object,
|
||||
"output_positions",
|
||||
gui.processing.gpr.output_positions,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
min_depth_m=_optional_float(
|
||||
gpr_object,
|
||||
"min_depth_m",
|
||||
gui.processing.gpr.min_depth_m,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
max_depth_m=_optional_float(
|
||||
gpr_object,
|
||||
"max_depth_m",
|
||||
gui.processing.gpr.max_depth_m,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
range_comp_power=_optional_float(
|
||||
gpr_object,
|
||||
"range_comp_power",
|
||||
gui.processing.gpr.range_comp_power,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
object_min_frac=_optional_float(
|
||||
gpr_object,
|
||||
"object_min_frac",
|
||||
gui.processing.gpr.object_min_frac,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
score_mode=_optional_string(
|
||||
gpr_object,
|
||||
"score_mode",
|
||||
gui.processing.gpr.score_mode,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
motion_mode=_optional_string(
|
||||
gpr_object,
|
||||
"motion_mode",
|
||||
gui.processing.gpr.motion_mode,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
look_angle_deg=_optional_float(
|
||||
gpr_object,
|
||||
"look_angle_deg",
|
||||
gui.processing.gpr.look_angle_deg,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
direction_sign=_optional_float(
|
||||
gpr_object,
|
||||
"direction_sign",
|
||||
gui.processing.gpr.direction_sign,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
speed_m_s=_optional_float(
|
||||
gpr_object,
|
||||
"speed_m_s",
|
||||
gui.processing.gpr.speed_m_s,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
ignore_socket_speed_enabled=_optional_bool(
|
||||
gpr_object,
|
||||
"ignore_socket_speed_enabled",
|
||||
gui.processing.gpr.ignore_socket_speed_enabled,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
max_detected_objects_to_draw=_optional_int(
|
||||
gpr_object,
|
||||
"max_detected_objects_to_draw",
|
||||
gui.processing.gpr.max_detected_objects_to_draw,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
draw_top_m_objects=_optional_int(
|
||||
gpr_object,
|
||||
"draw_top_m_objects",
|
||||
gui.processing.gpr.draw_top_m_objects,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
object_approach_min_frames=_optional_int(
|
||||
gpr_object,
|
||||
"object_approach_min_frames",
|
||||
gui.processing.gpr.object_approach_min_frames,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
start_freq_mhz=_optional_float(
|
||||
gpr_object,
|
||||
"start_freq_mhz",
|
||||
gui.processing.gpr.start_freq_mhz,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
stop_freq_mhz=_optional_float(
|
||||
gpr_object,
|
||||
"stop_freq_mhz",
|
||||
gui.processing.gpr.stop_freq_mhz,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
background_subtract_enabled=_optional_bool(
|
||||
gpr_object,
|
||||
"background_subtract_enabled",
|
||||
gui.processing.gpr.background_subtract_enabled,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
background_mean_count=_optional_int(
|
||||
gpr_object,
|
||||
"background_mean_count",
|
||||
gui.processing.gpr.background_mean_count,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
remove_sidelobe_objects_enabled=_optional_bool(
|
||||
gpr_object,
|
||||
"remove_sidelobe_objects_enabled",
|
||||
gui.processing.gpr.remove_sidelobe_objects_enabled,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
imaging_plane_y_m=_optional_float(
|
||||
gpr_object,
|
||||
"imaging_plane_y_m",
|
||||
gui.processing.gpr.imaging_plane_y_m,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
render_mode=_optional_string(
|
||||
gpr_object,
|
||||
"render_mode",
|
||||
gui.processing.gpr.render_mode,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
visible_x_min_m=_optional_float(
|
||||
gpr_object,
|
||||
"visible_x_min_m",
|
||||
gui.processing.gpr.visible_x_min_m,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
visible_x_max_m=_optional_float(
|
||||
gpr_object,
|
||||
"visible_x_max_m",
|
||||
gui.processing.gpr.visible_x_max_m,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
visible_z_min_m=_optional_float(
|
||||
gpr_object,
|
||||
"visible_z_min_m",
|
||||
gui.processing.gpr.visible_z_min_m,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
visible_z_max_m=_optional_float(
|
||||
gpr_object,
|
||||
"visible_z_max_m",
|
||||
gui.processing.gpr.visible_z_max_m,
|
||||
"gui.processing.gpr",
|
||||
),
|
||||
),
|
||||
legacy_gpr=GuiLegacyGprStateModel(
|
||||
mode=legacy_string("mode", legacy_mode_default),
|
||||
input_positions=legacy_string("input_positions", gui.processing.legacy_gpr.input_positions),
|
||||
output_positions=legacy_string("output_positions", gui.processing.legacy_gpr.output_positions),
|
||||
min_depth_m=legacy_float("min_depth_m", gui.processing.legacy_gpr.min_depth_m),
|
||||
max_depth_m=legacy_float("max_depth_m", gui.processing.legacy_gpr.max_depth_m),
|
||||
comp_power=legacy_float("comp_power", gui.processing.legacy_gpr.comp_power),
|
||||
start_freq_mhz=legacy_float("start_freq_mhz", gui.processing.legacy_gpr.start_freq_mhz),
|
||||
stop_freq_mhz=legacy_float("stop_freq_mhz", gui.processing.legacy_gpr.stop_freq_mhz),
|
||||
speed_m_s=legacy_float("speed_m_s", gui.processing.legacy_gpr.speed_m_s),
|
||||
ignore_socket_speed_enabled=legacy_bool(
|
||||
"ignore_socket_speed_enabled",
|
||||
gui.processing.legacy_gpr.ignore_socket_speed_enabled,
|
||||
),
|
||||
look_angle_deg=legacy_float("look_angle_deg", gui.processing.legacy_gpr.look_angle_deg),
|
||||
apply_freq_phase_correction=legacy_bool(
|
||||
"apply_freq_phase_correction",
|
||||
gui.processing.legacy_gpr.apply_freq_phase_correction,
|
||||
),
|
||||
reference_mode=legacy_string(
|
||||
"reference_mode",
|
||||
gui.processing.legacy_gpr.reference_mode,
|
||||
),
|
||||
snr_thresh=legacy_float("snr_thresh", gui.processing.legacy_gpr.snr_thresh),
|
||||
snr_comp_max=legacy_float("snr_comp_max", gui.processing.legacy_gpr.snr_comp_max),
|
||||
background_subtract_enabled=legacy_bool(
|
||||
"background_subtract_enabled",
|
||||
gui.processing.legacy_gpr.background_subtract_enabled,
|
||||
),
|
||||
background_mean_count=legacy_int(
|
||||
"background_mean_count",
|
||||
gui.processing.legacy_gpr.background_mean_count,
|
||||
),
|
||||
render_mode=legacy_string("render_mode", gui.processing.legacy_gpr.render_mode),
|
||||
min_visible_pair_count=legacy_int(
|
||||
"min_visible_pair_count",
|
||||
gui.processing.legacy_gpr.min_visible_pair_count,
|
||||
),
|
||||
visible_x_min_m=legacy_float("visible_x_min_m", gui.processing.legacy_gpr.visible_x_min_m),
|
||||
visible_x_max_m=legacy_float("visible_x_max_m", gui.processing.legacy_gpr.visible_x_max_m),
|
||||
visible_z_min_m=legacy_float("visible_z_min_m", gui.processing.legacy_gpr.visible_z_min_m),
|
||||
visible_z_max_m=legacy_float("visible_z_max_m", gui.processing.legacy_gpr.visible_z_max_m),
|
||||
),
|
||||
sar3d=GuiSar3dStateModel(
|
||||
input_positions=sar3d_string("input_positions"),
|
||||
output_positions=sar3d_string("output_positions"),
|
||||
min_depth_m=sar3d_float("min_depth_m"),
|
||||
max_depth_m=sar3d_float("max_depth_m"),
|
||||
start_freq_mhz=sar3d_float("start_freq_mhz"),
|
||||
stop_freq_mhz=sar3d_float("stop_freq_mhz"),
|
||||
speed_m_s=sar3d_float("speed_m_s"),
|
||||
ignore_socket_speed_enabled=sar3d_bool("ignore_socket_speed_enabled"),
|
||||
look_angle_deg=sar3d_float("look_angle_deg"),
|
||||
frame_radius=sar3d_int("frame_radius"),
|
||||
voxel_m=sar3d_float("voxel_m"),
|
||||
bp_ground_depth_m=sar3d_float("bp_ground_depth_m"),
|
||||
comp_range_power=sar3d_float("comp_range_power"),
|
||||
use_compensation=sar3d_bool("use_compensation"),
|
||||
normalize_each_frame=sar3d_bool("normalize_each_frame"),
|
||||
),
|
||||
)
|
||||
if gui.processing.selected_mode not in {"pass_through", "bscan", "gpr", "legacy_gpr", "sar3d"}:
|
||||
raise ValueError(
|
||||
"gui.processing.selected_mode must be one of: pass_through, bscan, gpr, legacy_gpr, sar3d"
|
||||
)
|
||||
if gui.processing.bscan.axis not in {"abs", "real", "phase"}:
|
||||
raise ValueError("gui.processing.bscan.axis must be one of: abs, real, phase")
|
||||
if gui.processing.gpr.render_mode not in {"heatmap", "objects_only"}:
|
||||
raise ValueError("gui.processing.gpr.render_mode must be one of: heatmap, objects_only")
|
||||
if gui.processing.gpr.score_mode not in {"peak", "combined"}:
|
||||
raise ValueError("gui.processing.gpr.score_mode must be one of: peak, combined")
|
||||
if gui.processing.gpr.motion_mode not in {"int_minus", "int_focus"}:
|
||||
raise ValueError("gui.processing.gpr.motion_mode must be one of: int_minus, int_focus")
|
||||
if gui.processing.legacy_gpr.mode not in {"point", "extended"}:
|
||||
raise ValueError("gui.processing.legacy_gpr.mode must be one of: point, extended")
|
||||
if gui.processing.legacy_gpr.render_mode not in {"heatmap", "objects_only"}:
|
||||
raise ValueError("gui.processing.legacy_gpr.render_mode must be one of: heatmap, objects_only")
|
||||
if gui.processing.legacy_gpr.reference_mode not in {"frame_center", "first_tx_event"}:
|
||||
raise ValueError(
|
||||
"gui.processing.legacy_gpr.reference_mode must be one of: frame_center, first_tx_event"
|
||||
)
|
||||
if gui.processing.gpr.range_comp_power < 0.0:
|
||||
raise ValueError("gui.processing.gpr.range_comp_power must be >= 0")
|
||||
if not 0.0 <= gui.processing.gpr.object_min_frac <= 1.0:
|
||||
raise ValueError("gui.processing.gpr.object_min_frac must be within [0, 1]")
|
||||
if gui.processing.gpr.max_detected_objects_to_draw < 0:
|
||||
raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0")
|
||||
if gui.processing.gpr.draw_top_m_objects < 0:
|
||||
raise ValueError("gui.processing.gpr.draw_top_m_objects must be >= 0")
|
||||
if gui.processing.gpr.object_approach_min_frames < 1:
|
||||
raise ValueError("gui.processing.gpr.object_approach_min_frames must be >= 1")
|
||||
if gui.processing.legacy_gpr.comp_power < 0.0:
|
||||
raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0")
|
||||
if gui.processing.legacy_gpr.snr_thresh < 0.0:
|
||||
raise ValueError("gui.processing.legacy_gpr.snr_thresh must be >= 0")
|
||||
if gui.processing.legacy_gpr.snr_comp_max < 0.0:
|
||||
raise ValueError("gui.processing.legacy_gpr.snr_comp_max must be >= 0")
|
||||
if gui.processing.legacy_gpr.min_visible_pair_count < 1:
|
||||
raise ValueError("gui.processing.legacy_gpr.min_visible_pair_count must be >= 1")
|
||||
if gui.processing.sar3d.frame_radius < 0:
|
||||
raise ValueError("gui.processing.sar3d.frame_radius must be >= 0")
|
||||
if not gui.processing.sar3d.voxel_m > 0.0:
|
||||
raise ValueError("gui.processing.sar3d.voxel_m must be > 0")
|
||||
|
||||
data_actions_object = _as_dict(gui_object.get("data_actions"), "gui.data_actions")
|
||||
gui.data_actions = GuiDataActionsStateModel(
|
||||
save_path=_optional_string(
|
||||
data_actions_object,
|
||||
"save_path",
|
||||
gui.data_actions.save_path,
|
||||
"gui.data_actions",
|
||||
),
|
||||
save_name=_optional_string(
|
||||
data_actions_object,
|
||||
"save_name",
|
||||
gui.data_actions.save_name,
|
||||
"gui.data_actions",
|
||||
),
|
||||
record_count=_optional_int(
|
||||
data_actions_object,
|
||||
"record_count",
|
||||
gui.data_actions.record_count,
|
||||
"gui.data_actions",
|
||||
),
|
||||
)
|
||||
|
||||
preprocess_dialog_object = _as_dict(gui_object.get("preprocess_dialog"), "gui.preprocess_dialog")
|
||||
gui.preprocess_dialog = GuiPreprocessDialogStateModel(
|
||||
set_name=_optional_string(
|
||||
preprocess_dialog_object,
|
||||
"set_name",
|
||||
gui.preprocess_dialog.set_name,
|
||||
"gui.preprocess_dialog",
|
||||
),
|
||||
radar_config_dir=_optional_string(
|
||||
preprocess_dialog_object,
|
||||
"radar_config_dir",
|
||||
gui.preprocess_dialog.radar_config_dir,
|
||||
"gui.preprocess_dialog",
|
||||
),
|
||||
use_all_radar_configs=_optional_bool(
|
||||
preprocess_dialog_object,
|
||||
"use_all_radar_configs",
|
||||
gui.preprocess_dialog.use_all_radar_configs,
|
||||
"gui.preprocess_dialog",
|
||||
),
|
||||
median_sweep_count=max(
|
||||
1,
|
||||
_optional_int(
|
||||
preprocess_dialog_object,
|
||||
"median_sweep_count",
|
||||
gui.preprocess_dialog.median_sweep_count,
|
||||
"gui.preprocess_dialog",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if gui.processing.selected_mode == "gpr" and (
|
||||
algorithm_mode is not None or root_mode in ("point", "extended")
|
||||
):
|
||||
gui.processing.selected_mode = "legacy_gpr"
|
||||
gui.preprocess_dialog.median_sweep_count = max(1, gui.preprocess_dialog.median_sweep_count)
|
||||
profile.gui = gui
|
||||
logger.debug("Decoded GUI profile: selected_mode=%s", gui.processing.selected_mode)
|
||||
return profile
|
||||
|
||||
|
||||
def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
|
||||
"""Encode :class:`GuiProfileModel` into JSON-serializable dictionary."""
|
||||
"""Encode the canonical run config and every declared GUI field."""
|
||||
payload = model.run_config.to_dict()
|
||||
payload["gui"] = dataclasses.asdict(model.gui if model.gui is not None else GuiStateModel())
|
||||
payload["gui"] = asdict(model.gui if model.gui is not None else GuiStateModel())
|
||||
return payload
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Facade module for GUI profile schema and codec helpers."""
|
||||
|
||||
from python_app.models.gui_profile_codec import gui_profile_from_dict, gui_profile_to_dict
|
||||
from python_app.models.gui_profile_schema import (
|
||||
GuiBscanStateModel,
|
||||
GuiDataActionsStateModel,
|
||||
GuiGprStateModel,
|
||||
GuiLegacyGprStateModel,
|
||||
GuiPassThroughStateModel,
|
||||
GuiPreprocessDialogStateModel,
|
||||
GuiProcessingStateModel,
|
||||
GuiProfileModel,
|
||||
GuiSar3dStateModel,
|
||||
GuiStateModel,
|
||||
GuiSwitchStateModel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GuiBscanStateModel",
|
||||
"GuiDataActionsStateModel",
|
||||
"GuiGprStateModel",
|
||||
"GuiLegacyGprStateModel",
|
||||
"GuiPassThroughStateModel",
|
||||
"GuiPreprocessDialogStateModel",
|
||||
"GuiProcessingStateModel",
|
||||
"GuiProfileModel",
|
||||
"GuiSar3dStateModel",
|
||||
"GuiStateModel",
|
||||
"GuiSwitchStateModel",
|
||||
"gui_profile_from_dict",
|
||||
"gui_profile_to_dict",
|
||||
]
|
||||
@@ -7,120 +7,163 @@ from dataclasses import dataclass, field
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Protocol
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.models.run_config_schema import RunConfigModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
Scalar = str | int | float | bool
|
||||
_ScalarT = TypeVar("_ScalarT", str, int, float, bool)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Setting:
|
||||
"""Qt-free binding and validation metadata for one persistent GUI field."""
|
||||
|
||||
widget: str = ""
|
||||
live: str = ""
|
||||
web: str = ""
|
||||
group: str = ""
|
||||
choices: tuple[str, ...] = ()
|
||||
minimum: float | None = None
|
||||
maximum: float | None = None
|
||||
exclusive_minimum: bool = False
|
||||
|
||||
|
||||
def setting(
|
||||
default: _ScalarT, widget: str = "", *, live: str = "", web: str = "",
|
||||
group: str = "", choices: tuple[str, ...] = (), minimum: float | None = None,
|
||||
maximum: float | None = None, exclusive_minimum: bool = False,
|
||||
) -> _ScalarT:
|
||||
"""Declare a typed dataclass field and its single profile/live/web binding."""
|
||||
return field(default=default, metadata={"setting": Setting(
|
||||
widget, live, web or live, group, choices, minimum, maximum, exclusive_minimum,
|
||||
)})
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GuiSwitchStateModel:
|
||||
"""UI-only state for switch selection controls."""
|
||||
|
||||
combo_mode: str = "text"
|
||||
combos_text: str = ""
|
||||
single_input: str = "0"
|
||||
single_output: str = "0"
|
||||
combo_mode: str = setting("text", choices=('text', 'single'))
|
||||
combos_text: str = setting("", '_combos_text')
|
||||
single_input: str = setting("0", '_single_combo_input')
|
||||
single_output: str = setting("0", '_single_combo_output')
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GuiPassThroughStateModel:
|
||||
"""UI-only defaults for pass-through rendering."""
|
||||
|
||||
show_magnitude: bool = True
|
||||
show_phase: bool = True
|
||||
unwrap_phase: bool = False
|
||||
combo_filter: str = ""
|
||||
fixed_y_enabled: bool = False
|
||||
y_min_db: float = -100.0
|
||||
y_max_db: float = 0.0
|
||||
show_magnitude: bool = setting(True, '_show_magnitude_checkbox')
|
||||
show_phase: bool = setting(True, '_show_phase_checkbox')
|
||||
unwrap_phase: bool = setting(False, '_unwrap_phase_checkbox')
|
||||
combo_filter: str = setting("", '_pass_through_combo_filter_input', web='combo_filter', group='Display')
|
||||
fixed_y_enabled: bool = setting(False, '_pass_through_fixed_y_enabled', web='pass_through_fixed_y_enabled', group='Pass-through')
|
||||
y_min_db: float = setting(-100.0, '_pass_through_y_min_db', web='pass_through_y_min_db', group='Pass-through')
|
||||
y_max_db: float = setting(0.0, '_pass_through_y_max_db', web='pass_through_y_max_db', group='Pass-through')
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GuiBscanStateModel:
|
||||
"""UI-only defaults for B-scan live settings."""
|
||||
|
||||
axis: str = "abs"
|
||||
cut_m: float = 0.0
|
||||
max_depth_m: float = 8.0
|
||||
gain: float = 1.0
|
||||
start_freq_mhz: float = 100.0
|
||||
stop_freq_mhz: float = 8800.0
|
||||
subtract_mean_ascan: bool = False
|
||||
axis: str = setting("abs", '_bscan_axis', live='bscan_axis', group='B-scan', choices=('abs', 'real', 'phase'))
|
||||
cut_m: float = setting(0.824, '_bscan_cut_m', live='bscan_cut_m', group='B-scan')
|
||||
max_depth_m: float = setting(1.0, '_bscan_max_depth_m', live='bscan_max_depth_m', group='B-scan')
|
||||
gain: float = setting(1.0, '_bscan_gain', live='bscan_gain', group='B-scan')
|
||||
start_freq_mhz: float = setting(100.0, '_bscan_start_freq_mhz', live='bscan_start_freq_mhz', group='B-scan')
|
||||
stop_freq_mhz: float = setting(8800.0, '_bscan_stop_freq_mhz', live='bscan_stop_freq_mhz', group='B-scan')
|
||||
subtract_mean_ascan: bool = setting(False, '_bscan_subtract_mean_ascan', web='subtract_mean_ascan', group='Display')
|
||||
# How many past sweeps the B-scan heatmap renders once acquisition is stopped.
|
||||
# While running the window stays at the C++ replay window (see
|
||||
# `_cpp_bscan_replay_window_for_config`); this only widens the stopped-mode view.
|
||||
history_window_scans: int = 50
|
||||
history_window_scans: int = setting(50, '_bscan_history_window')
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GuiGprStateModel:
|
||||
"""UI-only defaults for coherent backprojection GPR live settings."""
|
||||
|
||||
input_positions: str = ""
|
||||
output_positions: str = ""
|
||||
min_depth_m: float = 2.0
|
||||
max_depth_m: float = 14.0
|
||||
range_comp_power: float = 0.1
|
||||
# BP object-detection stop level, fraction of the global peak (Horns_motion_3libre.py
|
||||
# BP_OBJECT_MIN_FRAC). Angle compensation and permittivity are fixed for coherent BP
|
||||
# (Python 0.3 block), so they are not exposed here.
|
||||
object_min_frac: float = 0.7
|
||||
score_mode: str = "combined"
|
||||
motion_mode: str = "int_minus"
|
||||
input_positions: str = setting("", '_gpr_input_positions_input', live='gpr_input_positions', group='Geometry & depth')
|
||||
output_positions: str = setting("", '_gpr_output_positions_input', live='gpr_output_positions', group='Geometry & depth')
|
||||
min_depth_m: float = setting(2.0, '_gpr_min_depth_m', live='gpr_min_depth_m', group='Geometry & depth')
|
||||
max_depth_m: float = setting(14.0, '_gpr_max_depth_m', live='gpr_max_depth_m', group='Geometry & depth')
|
||||
range_comp_power: float = setting(0.1, '_gpr_range_comp_power', live='gpr_range_comp_power', group='Imaging', minimum=0)
|
||||
# BP object-detection stop level, fraction of the global peak. Angle compensation and
|
||||
# permittivity are fixed for coherent BP, so they are not exposed here.
|
||||
object_min_frac: float = setting(0.7, '_gpr_object_min_frac', live='gpr_object_min_frac', group='Detection', minimum=0, maximum=1)
|
||||
score_mode: str = setting("combined", '_gpr_score_mode', live='gpr_score_mode', group='Imaging', choices=('peak', 'combined'))
|
||||
motion_mode: str = setting("int_minus", '_gpr_motion_mode', live='gpr_motion_mode', group='Motion', choices=('int_minus', 'int_focus'))
|
||||
# Intra-sweep motion-correction inputs. Sweep time is derived from acquisition
|
||||
# metadata; speed comes from the socket unless `ignore_socket_speed_enabled`,
|
||||
# in which case `speed_m_s` from here is used.
|
||||
look_angle_deg: float = 0.0
|
||||
direction_sign: float = 1.0
|
||||
speed_m_s: float = 0.0
|
||||
ignore_socket_speed_enabled: bool = False
|
||||
max_detected_objects_to_draw: int = 5
|
||||
draw_top_m_objects: int = 2
|
||||
look_angle_deg: float = setting(0.0, '_gpr_look_angle_deg', live='gpr_look_angle_deg', group='Motion')
|
||||
direction_sign: float = setting(1.0, '_gpr_direction_sign', live='gpr_direction_sign', group='Motion')
|
||||
speed_m_s: float = setting(0.0, '_gpr_speed_m_s', live='gpr_speed_m_s', group='Motion')
|
||||
ignore_socket_speed_enabled: bool = setting(False, '_gpr_ignore_socket_speed_enabled', live='ignore_socket_speed', group='Motion')
|
||||
max_detected_objects_to_draw: int = setting(
|
||||
5, '_gpr_max_detected_objects_to_draw', live='gpr_max_detected_objects_to_draw', group='Detection', minimum=0,
|
||||
)
|
||||
draw_top_m_objects: int = setting(2, '_gpr_draw_top_m_objects', live='gpr_draw_top_m_objects', group='Detection', minimum=0)
|
||||
# Cross-frame approach filter: show an object only after it persists as a
|
||||
# motion-consistent track this many consecutive frames (<= 1 disables it).
|
||||
object_approach_min_frames: int = 3
|
||||
start_freq_mhz: float = 3000.0
|
||||
stop_freq_mhz: float = 6000.0
|
||||
background_subtract_enabled: bool = True
|
||||
background_mean_count: int = 10
|
||||
remove_sidelobe_objects_enabled: bool = True
|
||||
imaging_plane_y_m: float = 0.0
|
||||
render_mode: str = "heatmap"
|
||||
visible_x_min_m: float = -2.0
|
||||
visible_x_max_m: float = 2.0
|
||||
visible_z_min_m: float = 0.0
|
||||
visible_z_max_m: float = 14.0
|
||||
object_approach_min_frames: int = setting(
|
||||
3, '_gpr_object_approach_min_frames', live='gpr_object_approach_min_frames', group='Detection', minimum=1,
|
||||
)
|
||||
start_freq_mhz: float = setting(3000.0, '_gpr_start_freq_mhz', live='gpr_start_freq_mhz', group='Geometry & depth')
|
||||
stop_freq_mhz: float = setting(6000.0, '_gpr_stop_freq_mhz', live='gpr_stop_freq_mhz', group='Geometry & depth')
|
||||
background_subtract_enabled: bool = setting(True, '_gpr_background_subtract_enabled', live='gpr_background_subtract_enabled', group='Imaging')
|
||||
background_mean_count: int = setting(10, '_gpr_background_mean_count', live='gpr_background_mean_count', group='Imaging')
|
||||
remove_sidelobe_objects_enabled: bool = setting(
|
||||
True, '_gpr_remove_sidelobe_objects_enabled', live='gpr_remove_sidelobe_objects_enabled', group='Imaging',
|
||||
)
|
||||
imaging_plane_y_m: float = setting(0.0, '_gpr_imaging_plane_y_m', live='gpr_imaging_plane_y_m', group='Geometry & depth')
|
||||
render_mode: str = setting("heatmap", '_gpr_render_mode', web='render_mode', group='Display', choices=('heatmap', 'objects_only'))
|
||||
visible_x_min_m: float = setting(-2.0, '_gpr_visible_x_min_m', live='gpr_visible_x_min_m', group='Visible window')
|
||||
visible_x_max_m: float = setting(2.0, '_gpr_visible_x_max_m', live='gpr_visible_x_max_m', group='Visible window')
|
||||
visible_z_min_m: float = setting(0.0, '_gpr_visible_z_min_m', live='gpr_visible_z_min_m', group='Visible window')
|
||||
visible_z_max_m: float = setting(14.0, '_gpr_visible_z_max_m', live='gpr_visible_z_max_m', group='Visible window')
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GuiLegacyGprStateModel:
|
||||
"""UI-only defaults for legacy point/extended GPR live settings."""
|
||||
|
||||
mode: str = "point"
|
||||
input_positions: str = ""
|
||||
output_positions: str = ""
|
||||
min_depth_m: float = 2.0
|
||||
max_depth_m: float = 14.0
|
||||
comp_power: float = 0.2
|
||||
start_freq_mhz: float = 3000.0
|
||||
stop_freq_mhz: float = 6000.0
|
||||
speed_m_s: float = 0.0
|
||||
ignore_socket_speed_enabled: bool = False
|
||||
look_angle_deg: float = 0.0
|
||||
apply_freq_phase_correction: bool = True
|
||||
reference_mode: str = "frame_center"
|
||||
snr_thresh: float = 4.5
|
||||
snr_comp_max: float = 25.0
|
||||
background_subtract_enabled: bool = True
|
||||
background_mean_count: int = 10
|
||||
render_mode: str = "heatmap"
|
||||
min_visible_pair_count: int = 1
|
||||
visible_x_min_m: float = -2.0
|
||||
visible_x_max_m: float = 2.0
|
||||
visible_z_min_m: float = 0.0
|
||||
visible_z_max_m: float = 14.0
|
||||
mode: str = setting("point", '_legacy_gpr_config_mode', live='legacy_gpr_mode', group='Mode', choices=('point', 'extended'))
|
||||
input_positions: str = setting("", '_legacy_gpr_input_positions_input', live='gpr_input_positions', group='Geometry & depth')
|
||||
output_positions: str = setting("", '_legacy_gpr_output_positions_input', live='gpr_output_positions', group='Geometry & depth')
|
||||
min_depth_m: float = setting(2.0, '_legacy_gpr_min_depth_m', live='gpr_min_depth_m', group='Geometry & depth')
|
||||
max_depth_m: float = setting(14.0, '_legacy_gpr_max_depth_m', live='gpr_max_depth_m', group='Geometry & depth')
|
||||
comp_power: float = setting(0.2, '_legacy_gpr_comp_power', live='gpr_comp_power', group='Detection', minimum=0)
|
||||
start_freq_mhz: float = setting(3000.0, '_legacy_gpr_start_freq_mhz', live='gpr_start_freq_mhz', group='Geometry & depth')
|
||||
stop_freq_mhz: float = setting(6000.0, '_legacy_gpr_stop_freq_mhz', live='gpr_stop_freq_mhz', group='Geometry & depth')
|
||||
speed_m_s: float = setting(0.0, '_legacy_gpr_speed_m_s', live='gpr_speed_m_s', group='Motion')
|
||||
ignore_socket_speed_enabled: bool = setting(False, '_legacy_gpr_ignore_socket_speed_enabled', live='ignore_socket_speed', group='Motion')
|
||||
look_angle_deg: float = setting(0.0, '_legacy_gpr_look_angle_deg', live='gpr_look_angle_deg', group='Motion')
|
||||
apply_freq_phase_correction: bool = setting(
|
||||
True, '_legacy_gpr_apply_freq_phase_correction', live='gpr_apply_freq_phase_correction', group='Motion',
|
||||
)
|
||||
reference_mode: str = setting(
|
||||
"frame_center", '_legacy_gpr_reference_mode', live='gpr_reference_mode', group='Motion', choices=('frame_center', 'first_tx_event'),
|
||||
)
|
||||
snr_thresh: float = setting(4.5, '_legacy_gpr_snr_thresh', live='gpr_snr_thresh', group='Detection', minimum=0)
|
||||
snr_comp_max: float = setting(25.0, '_legacy_gpr_snr_comp_max', live='gpr_snr_comp_max', group='Detection', minimum=0)
|
||||
background_subtract_enabled: bool = setting(
|
||||
True, '_legacy_gpr_background_subtract_enabled', live='gpr_background_subtract_enabled', group='Imaging',
|
||||
)
|
||||
background_mean_count: int = setting(10, '_legacy_gpr_background_mean_count', live='gpr_background_mean_count', group='Imaging')
|
||||
render_mode: str = setting("heatmap", '_legacy_gpr_render_mode', web='render_mode', group='Display', choices=('heatmap', 'objects_only'))
|
||||
min_visible_pair_count: int = setting(
|
||||
1, '_legacy_gpr_min_visible_pair_count', live='legacy_gpr_min_visible_pair_count', group='Detection', minimum=1,
|
||||
)
|
||||
visible_x_min_m: float = setting(-2.0, '_legacy_gpr_visible_x_min_m', live='gpr_visible_x_min_m', group='Visible window')
|
||||
visible_x_max_m: float = setting(2.0, '_legacy_gpr_visible_x_max_m', live='gpr_visible_x_max_m', group='Visible window')
|
||||
visible_z_min_m: float = setting(0.0, '_legacy_gpr_visible_z_min_m', live='gpr_visible_z_min_m', group='Visible window')
|
||||
visible_z_max_m: float = setting(14.0, '_legacy_gpr_visible_z_max_m', live='gpr_visible_z_max_m', group='Visible window')
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -131,28 +174,30 @@ class GuiSar3dStateModel:
|
||||
names the GUI shows: INPUT_IDX, F_START, MIN_DEPTH, LOOK_ANGLE_DEG, ...
|
||||
"""
|
||||
|
||||
input_positions: str = ""
|
||||
output_positions: str = ""
|
||||
min_depth_m: float = 2.0
|
||||
max_depth_m: float = 8.0
|
||||
start_freq_mhz: float = 1500.0
|
||||
stop_freq_mhz: float = 6000.0
|
||||
speed_m_s: float = 0.0
|
||||
ignore_socket_speed_enabled: bool = False
|
||||
look_angle_deg: float = 19.5
|
||||
frame_radius: int = 0
|
||||
voxel_m: float = 0.02
|
||||
bp_ground_depth_m: float = 1.25
|
||||
comp_range_power: float = 0.3
|
||||
use_compensation: bool = True
|
||||
normalize_each_frame: bool = True
|
||||
input_positions: str = setting("", '_sar3d_input_positions_input', live='gpr_input_positions', group='Geometry & depth')
|
||||
output_positions: str = setting("", '_sar3d_output_positions_input', live='gpr_output_positions', group='Geometry & depth')
|
||||
min_depth_m: float = setting(2.0, '_sar3d_min_depth_m', live='gpr_min_depth_m', group='Geometry & depth')
|
||||
max_depth_m: float = setting(8.0, '_sar3d_max_depth_m', live='gpr_max_depth_m', group='Geometry & depth')
|
||||
start_freq_mhz: float = setting(1500.0, '_sar3d_start_freq_mhz', live='gpr_start_freq_mhz', group='Geometry & depth')
|
||||
stop_freq_mhz: float = setting(6000.0, '_sar3d_stop_freq_mhz', live='gpr_stop_freq_mhz', group='Geometry & depth')
|
||||
speed_m_s: float = setting(0.0, '_sar3d_speed_m_s', live='gpr_speed_m_s', group='Motion')
|
||||
ignore_socket_speed_enabled: bool = setting(False, '_sar3d_ignore_socket_speed_enabled', live='ignore_socket_speed', group='Motion')
|
||||
look_angle_deg: float = setting(19.5, '_sar3d_look_angle_deg', live='gpr_look_angle_deg', group='Motion')
|
||||
frame_radius: int = setting(0, '_sar3d_frame_radius', live='sar3d_frame_radius', group='3D SAR', minimum=0)
|
||||
voxel_m: float = setting(0.02, '_sar3d_voxel_m', live='sar3d_voxel_m', group='3D SAR', minimum=0, exclusive_minimum=True)
|
||||
bp_ground_depth_m: float = setting(1.25, '_sar3d_bp_ground_depth_m', live='sar3d_bp_ground_depth_m', group='3D SAR')
|
||||
comp_range_power: float = setting(0.3, '_sar3d_comp_range_power', live='sar3d_comp_range_power', group='3D SAR')
|
||||
use_compensation: bool = setting(True, '_sar3d_use_compensation', live='sar3d_use_compensation', group='3D SAR')
|
||||
normalize_each_frame: bool = setting(True, '_sar3d_normalize_each_frame', live='sar3d_normalize_each_frame', group='3D SAR')
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GuiProcessingStateModel:
|
||||
"""UI-only processing-section defaults."""
|
||||
|
||||
selected_mode: str = "pass_through"
|
||||
selected_mode: str = setting(
|
||||
"pass_through", '_processing_mode', live='processor_mode', group='Processor', choices=('pass_through', 'bscan', 'gpr', 'legacy_gpr', 'sar3d'),
|
||||
)
|
||||
pass_through: GuiPassThroughStateModel = field(default_factory=GuiPassThroughStateModel)
|
||||
bscan: GuiBscanStateModel = field(default_factory=GuiBscanStateModel)
|
||||
gpr: GuiGprStateModel = field(default_factory=GuiGprStateModel)
|
||||
@@ -164,21 +209,21 @@ class GuiProcessingStateModel:
|
||||
class GuiDataActionsStateModel:
|
||||
"""UI-only defaults for snapshot/export controls."""
|
||||
|
||||
save_path: str = ""
|
||||
save_name: str = "snapshot_manual"
|
||||
save_path: str = setting("", '_save_path_input')
|
||||
save_name: str = setting("snapshot_manual", '_save_name_input')
|
||||
# How many freshly acquired measurements the "Start + Record" action writes to
|
||||
# disk before it stops recording (acquisition keeps running).
|
||||
record_count: int = 100
|
||||
record_count: int = setting(100, '_record_count')
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GuiPreprocessDialogStateModel:
|
||||
"""UI-only defaults for preprocessing dialog controls."""
|
||||
|
||||
set_name: str = "set_001"
|
||||
radar_config_dir: str = ""
|
||||
use_all_radar_configs: bool = False
|
||||
median_sweep_count: int = 5
|
||||
set_name: str = setting("set_001")
|
||||
radar_config_dir: str = setting("")
|
||||
use_all_radar_configs: bool = setting(False)
|
||||
median_sweep_count: int = setting(5)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -227,3 +272,10 @@ class GuiProfileModel:
|
||||
from python_app.models.gui_profile_codec import gui_profile_to_dict
|
||||
|
||||
return gui_profile_to_dict(self)
|
||||
|
||||
|
||||
class ProfileWriter(Protocol):
|
||||
"""Publish the current profile, optionally using the acquisition provenance."""
|
||||
|
||||
def __call__(self, path: Path, /, *, allow_overwrite: bool = True,
|
||||
dataset: bool = False) -> GuiProfileModel: ...
|
||||
|
||||
@@ -79,7 +79,7 @@ def _load_string_dict(payload: dict[str, Any], key: str, context: str) -> dict[s
|
||||
return values
|
||||
|
||||
|
||||
def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
def run_config_from_dict(payload: object) -> RunConfigModel:
|
||||
"""Decode JSON-like payload into :class:`RunConfigModel`."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Run config root must be a JSON object")
|
||||
@@ -331,24 +331,6 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
model.preprocess.s21.object_calibration,
|
||||
)
|
||||
|
||||
s11_preprocess_payload = _as_dict(preprocess_payload.get("s11"), "preprocess.s11")
|
||||
s11_calibration_payload = _as_dict(s11_preprocess_payload.get("calibration"), "preprocess.s11.calibration")
|
||||
_load_preprocess_asset(
|
||||
_as_dict(s11_calibration_payload.get("open"), "preprocess.s11.calibration.open"),
|
||||
model.preprocess.s11.calibration.open,
|
||||
)
|
||||
_load_preprocess_asset(
|
||||
_as_dict(s11_calibration_payload.get("short"), "preprocess.s11.calibration.short"),
|
||||
model.preprocess.s11.calibration.short,
|
||||
)
|
||||
_load_preprocess_asset(
|
||||
_as_dict(s11_calibration_payload.get("load"), "preprocess.s11.calibration.load"),
|
||||
model.preprocess.s11.calibration.load,
|
||||
)
|
||||
_load_preprocess_asset(
|
||||
_as_dict(s11_preprocess_payload.get("reference"), "preprocess.s11.reference"),
|
||||
model.preprocess.s11.reference,
|
||||
)
|
||||
notch_payload = _as_dict(preprocess_payload.get("notch"), "preprocess.notch")
|
||||
model.preprocess.notch = PreprocessNotchModel(
|
||||
enabled=_read_bool(notch_payload, "enabled", model.preprocess.notch.enabled),
|
||||
@@ -521,26 +503,22 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"port1": {
|
||||
"name": model.output_switch.name,
|
||||
"driver_mode": model.output_switch.driver_mode,
|
||||
"driver": model.output_switch.driver,
|
||||
"radar_port": model.output_switch.radar_port,
|
||||
"positions": model.output_switch.positions,
|
||||
"default_position": model.output_switch.default_position,
|
||||
"gpio_chip": model.output_switch.gpio_chip,
|
||||
"pin_a": model.output_switch.pin_a,
|
||||
"pin_b": model.output_switch.pin_b,
|
||||
"invert_logic": model.output_switch.invert_logic,
|
||||
},
|
||||
"port2": {
|
||||
"name": model.input_switch.name,
|
||||
"driver_mode": model.input_switch.driver_mode,
|
||||
"driver": model.input_switch.driver,
|
||||
"radar_port": model.input_switch.radar_port,
|
||||
"positions": model.input_switch.positions,
|
||||
"default_position": model.input_switch.default_position,
|
||||
"gpio_chip": model.input_switch.gpio_chip,
|
||||
"pin_a": model.input_switch.pin_a,
|
||||
"pin_b": model.input_switch.pin_b,
|
||||
"invert_logic": model.input_switch.invert_logic,
|
||||
},
|
||||
},
|
||||
"control_button": {
|
||||
@@ -586,26 +564,6 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"bundle_path": model.preprocess.s21.object_calibration.bundle_path,
|
||||
},
|
||||
},
|
||||
"s11": {
|
||||
"calibration": {
|
||||
"open": {
|
||||
"set_name": model.preprocess.s11.calibration.open.set_name,
|
||||
"bundle_path": model.preprocess.s11.calibration.open.bundle_path,
|
||||
},
|
||||
"short": {
|
||||
"set_name": model.preprocess.s11.calibration.short.set_name,
|
||||
"bundle_path": model.preprocess.s11.calibration.short.bundle_path,
|
||||
},
|
||||
"load": {
|
||||
"set_name": model.preprocess.s11.calibration.load.set_name,
|
||||
"bundle_path": model.preprocess.s11.calibration.load.bundle_path,
|
||||
},
|
||||
},
|
||||
"reference": {
|
||||
"set_name": model.preprocess.s11.reference.set_name,
|
||||
"bundle_path": model.preprocess.s11.reference.bundle_path,
|
||||
},
|
||||
},
|
||||
"notch": {
|
||||
"enabled": model.preprocess.notch.enabled,
|
||||
"bands_hz": [[low_hz, high_hz] for low_hz, high_hz in model.preprocess.notch.bands_hz],
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Facade module for run configuration schema, codec, and validation helpers."""
|
||||
|
||||
from python_app.models.run_config_codec import run_config_from_dict, run_config_to_dict
|
||||
from python_app.models.run_config_schema import (
|
||||
ComboModel,
|
||||
GprModel,
|
||||
GprRxGeometryModel,
|
||||
GprTxGeometryModel,
|
||||
KamilAdcBandModel,
|
||||
KamilAdcModel,
|
||||
KamilAdcPhaseCalibrationModel,
|
||||
LaserControlModel,
|
||||
LaserManualModeModel,
|
||||
LaserVariationModeModel,
|
||||
LocatorServerRuntimeModel,
|
||||
PreprocessAssetModel,
|
||||
PreprocessNotchModel,
|
||||
PreprocessModel,
|
||||
RadarMultiDeviceModel,
|
||||
RadarModel,
|
||||
RadarSweepModel,
|
||||
RingEndpointModel,
|
||||
RingsModel,
|
||||
RunConfigModel,
|
||||
RuntimeModel,
|
||||
S11CalibrationModel,
|
||||
S11PreprocessModel,
|
||||
S21PreprocessModel,
|
||||
SwitchModel,
|
||||
)
|
||||
from python_app.models.run_config_validation import (
|
||||
load_ring_payload,
|
||||
load_switch_payload,
|
||||
parse_combos_from_text,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ComboModel",
|
||||
"GprModel",
|
||||
"GprRxGeometryModel",
|
||||
"GprTxGeometryModel",
|
||||
"KamilAdcBandModel",
|
||||
"KamilAdcModel",
|
||||
"KamilAdcPhaseCalibrationModel",
|
||||
"LaserControlModel",
|
||||
"LaserManualModeModel",
|
||||
"LaserVariationModeModel",
|
||||
"LocatorServerRuntimeModel",
|
||||
"PreprocessAssetModel",
|
||||
"PreprocessNotchModel",
|
||||
"PreprocessModel",
|
||||
"RadarMultiDeviceModel",
|
||||
"RadarModel",
|
||||
"RadarSweepModel",
|
||||
"RingEndpointModel",
|
||||
"RingsModel",
|
||||
"RunConfigModel",
|
||||
"RuntimeModel",
|
||||
"S11CalibrationModel",
|
||||
"S11PreprocessModel",
|
||||
"S21PreprocessModel",
|
||||
"SwitchModel",
|
||||
"load_ring_payload",
|
||||
"load_switch_payload",
|
||||
"parse_combos_from_text",
|
||||
"run_config_from_dict",
|
||||
"run_config_to_dict",
|
||||
]
|
||||
@@ -9,6 +9,8 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from python_app.file_io import sanitize_path_component
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_COMBOS = 4096
|
||||
|
||||
@@ -186,14 +188,12 @@ class SwitchModel:
|
||||
|
||||
name: str
|
||||
driver_mode: str = "mock"
|
||||
driver: str = ""
|
||||
radar_port: int = 0
|
||||
positions: int = 1
|
||||
default_position: int = 0
|
||||
gpio_chip: str = ""
|
||||
pin_a: int = -1
|
||||
pin_b: int = -1
|
||||
invert_logic: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -278,23 +278,6 @@ class S21PreprocessModel:
|
||||
object_calibration: PreprocessAssetModel = field(default_factory=PreprocessAssetModel)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class S11CalibrationModel:
|
||||
"""One-port S11 OSL calibration assets."""
|
||||
|
||||
open: PreprocessAssetModel = field(default_factory=PreprocessAssetModel)
|
||||
short: PreprocessAssetModel = field(default_factory=PreprocessAssetModel)
|
||||
load: PreprocessAssetModel = field(default_factory=PreprocessAssetModel)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class S11PreprocessModel:
|
||||
"""One-port S11 preprocessing assets."""
|
||||
|
||||
calibration: S11CalibrationModel = field(default_factory=S11CalibrationModel)
|
||||
reference: PreprocessAssetModel = field(default_factory=PreprocessAssetModel)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PreprocessNotchModel:
|
||||
"""Optional frequency-domain notch filter applied after calibration and reference subtraction."""
|
||||
@@ -310,7 +293,6 @@ class PreprocessModel:
|
||||
"""Selected preprocessing artifacts for live acquisition."""
|
||||
|
||||
s21: S21PreprocessModel = field(default_factory=S21PreprocessModel)
|
||||
s11: S11PreprocessModel = field(default_factory=S11PreprocessModel)
|
||||
notch: PreprocessNotchModel = field(default_factory=PreprocessNotchModel)
|
||||
|
||||
|
||||
@@ -402,11 +384,6 @@ class RunConfigModel:
|
||||
"""Return whether this config targets synchronized multi-device acquisition."""
|
||||
return self.radar.model == self.LIBREVNA_MULTI_MODEL
|
||||
|
||||
@property
|
||||
def is_matrix_radar(self) -> bool:
|
||||
"""Return whether this config acquires the full virtual switch matrix per sweep."""
|
||||
return self.is_multi_device
|
||||
|
||||
@property
|
||||
def matrix_output_switch_positions(self) -> int:
|
||||
"""Physical positions of the real switch on the master stimulus path."""
|
||||
@@ -449,7 +426,21 @@ class RunConfigModel:
|
||||
"""Return whether this config targets the external Kamil ADC acquisition path."""
|
||||
return self.radar.model == self.KAMIL_ADC_MODEL
|
||||
|
||||
def radar_key_extra_parts(self) -> list[str]:
|
||||
def radar_key(self) -> str:
|
||||
"""Stable hardware/sweep identity used to find saved preprocess sets."""
|
||||
radar, sweep = self.radar, self.radar.sweep
|
||||
serials = [radar.serial or "no_serial"]
|
||||
serials.extend(value.strip() or "no_serial" for value in self._radar_key_extra_parts())
|
||||
serial = "_".join(sanitize_path_component(value) for value in serials)
|
||||
points = "adc" if self.is_kamil_adc else str(int(sweep.points))
|
||||
return (
|
||||
f"{radar.model}_{serial}"
|
||||
f"_st{_format_float_for_key(sweep.start_hz)}_sp{_format_float_for_key(sweep.stop_hz)}"
|
||||
f"_p{points}_if{_format_float_for_key(sweep.if_bandwidth_hz)}"
|
||||
f"_pw{_format_float_for_key(sweep.power_dbm)}"
|
||||
)
|
||||
|
||||
def _radar_key_extra_parts(self) -> list[str]:
|
||||
"""Return model-specific identity parts that affect captured data."""
|
||||
if self.is_multi_device:
|
||||
return list(self.radar.multi_device.slave_serials)
|
||||
@@ -497,7 +488,7 @@ class RunConfigModel:
|
||||
|
||||
def apply_device_model_constraints(self) -> None:
|
||||
"""Apply only required wire-format constraints for the selected device model."""
|
||||
if not self.is_matrix_radar:
|
||||
if not self.is_multi_device:
|
||||
return
|
||||
self._apply_matrix_virtual_switches()
|
||||
self.combos = self.build_runtime_combos()
|
||||
@@ -509,21 +500,19 @@ class RunConfigModel:
|
||||
|
||||
self.output_switch.name = self.output_switch.name or "virtual_output"
|
||||
self.output_switch.driver_mode = "mock"
|
||||
self.output_switch.driver = self.output_switch.driver or "h7992"
|
||||
self.output_switch.radar_port = 1
|
||||
self.output_switch.positions = out_physical * self.matrix_virtual_output_positions
|
||||
self.output_switch.default_position = 0
|
||||
|
||||
self.input_switch.name = self.input_switch.name or "virtual_input"
|
||||
self.input_switch.driver_mode = "mock"
|
||||
self.input_switch.driver = self.input_switch.driver or "h7992"
|
||||
self.input_switch.radar_port = 2
|
||||
self.input_switch.positions = in_physical * self.MULTI_DEVICE_INPUT_POSITIONS
|
||||
self.input_switch.default_position = 0
|
||||
|
||||
def ensure_combos(self) -> None:
|
||||
"""Populate combos with full matrix when no explicit run combos are set."""
|
||||
if self.is_matrix_radar:
|
||||
if self.is_multi_device:
|
||||
self.apply_device_model_constraints()
|
||||
return
|
||||
if self.combos:
|
||||
@@ -531,7 +520,7 @@ class RunConfigModel:
|
||||
self.combos = self.build_full_combos(self.input_switch.positions, self.output_switch.positions)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> RunConfigModel:
|
||||
def from_dict(cls, payload: object) -> RunConfigModel:
|
||||
"""Build model from JSON-like payload using codec layer."""
|
||||
from python_app.models.run_config_codec import run_config_from_dict
|
||||
|
||||
@@ -558,3 +547,11 @@ class RunConfigModel:
|
||||
from python_app.models.run_config_codec import run_config_to_dict
|
||||
|
||||
return run_config_to_dict(self)
|
||||
|
||||
|
||||
def _format_float_for_key(value: float) -> str:
|
||||
"""Format float compactly for deterministic key serialization."""
|
||||
integer = int(round(value))
|
||||
if abs(value - float(integer)) < 1e-6:
|
||||
return str(integer)
|
||||
return f"{value:.6f}".rstrip("0").rstrip(".")
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from python_app.models.json_fields import (
|
||||
finite_number,
|
||||
read_bool as _require_bool, read_int as _require_int, read_str as _require_str,
|
||||
)
|
||||
from python_app.models.run_config_schema import (
|
||||
@@ -14,12 +18,24 @@ from python_app.models.run_config_schema import (
|
||||
ComboModel,
|
||||
ControlButtonModel,
|
||||
GprModel,
|
||||
GprRxGeometryModel,
|
||||
GprTxGeometryModel,
|
||||
RadarSweepModel,
|
||||
RingEndpointModel,
|
||||
RunConfigModel,
|
||||
SwitchModel,
|
||||
)
|
||||
|
||||
POSITION_CSV_PATTERN = r"\s*(?:[0-9]+\s*(?:,\s*[0-9]+\s*)*,?\s*)?"
|
||||
|
||||
|
||||
def parse_position_csv(value: str) -> list[int]:
|
||||
"""Non-negative positions; empty means all, a trailing comma permits editing."""
|
||||
if re.fullmatch(POSITION_CSV_PATTERN, value) is None:
|
||||
raise ValueError("Positions must be non-negative integers separated by commas")
|
||||
return [int(token) for token in value.split(",") if token.strip()]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Wire-format bounds shared with the C++ pipeline. The ring header stores the
|
||||
@@ -37,14 +53,16 @@ def load_switch_payload(
|
||||
# Scalar reads reject array/object JSON types as ValueError (not TypeError).
|
||||
target.name = _require_str(payload, "name", target.name)
|
||||
target.driver_mode = _require_str(payload, "driver_mode", target.driver_mode)
|
||||
target.driver = _require_str(payload, "driver", target.driver)
|
||||
if "driver" in payload and payload["driver"] != "h7992":
|
||||
raise ValueError(f"switches.{target.name}.driver: only H7992 is supported; check the switch and its two control pins")
|
||||
if "invert_logic" in payload and payload["invert_logic"] is not False:
|
||||
raise ValueError(f"switches.{target.name}.invert_logic is unsupported by H7992")
|
||||
target.radar_port = _require_int(payload, "radar_port", target.radar_port)
|
||||
target.positions = _require_int(payload, "positions", target.positions)
|
||||
target.default_position = _require_int(payload, "default_position", target.default_position)
|
||||
target.gpio_chip = _require_str(payload, "gpio_chip", target.gpio_chip)
|
||||
target.pin_a = _require_int(payload, "pin_a", target.pin_a)
|
||||
target.pin_b = _require_int(payload, "pin_b", target.pin_b)
|
||||
target.invert_logic = _require_bool(payload, "invert_logic", target.invert_logic)
|
||||
|
||||
|
||||
def load_control_button_payload(
|
||||
@@ -130,27 +148,22 @@ def validate_acquisition_config(config: RunConfigModel) -> None:
|
||||
context = f"switches.{switch.name or switch.radar_port}"
|
||||
if switch.driver_mode not in ("mock", "native"):
|
||||
raise ValueError(f"{context}.driver_mode must be mock or native")
|
||||
if switch.driver not in ("h7992", "hmc349a"):
|
||||
raise ValueError(f"{context}.driver must be h7992 or hmc349a")
|
||||
if switch.radar_port not in (1, 2):
|
||||
raise ValueError(f"{context}.radar_port must be 1 or 2")
|
||||
if not 0 <= switch.default_position < switch.positions:
|
||||
raise ValueError(f"{context}.default_position must be within switch positions")
|
||||
positions, switch_mode = switch.positions, switch.driver_mode
|
||||
if config.is_matrix_radar and physical_positions > 1:
|
||||
if config.is_multi_device and physical_positions > 1:
|
||||
positions, switch_mode = physical_positions, mode
|
||||
if switch_mode == "native":
|
||||
limit = 4 if switch.driver == "h7992" else 2
|
||||
if not 1 <= positions <= limit:
|
||||
raise ValueError(f"{context}: native {switch.driver} supports 1..{limit} positions")
|
||||
if not switch.gpio_chip or switch.pin_a < 0 or (
|
||||
switch.driver == "h7992" and (switch.pin_b < 0 or switch.pin_b == switch.pin_a)
|
||||
):
|
||||
if not 1 <= positions <= 4:
|
||||
raise ValueError(f"{context}: native H7992 supports 1..4 positions")
|
||||
if not switch.gpio_chip or switch.pin_a < 0 or switch.pin_b < 0 or switch.pin_b == switch.pin_a:
|
||||
raise ValueError(f"{context}: GPIO chip and distinct nonnegative control pins are required")
|
||||
|
||||
points = config.radar.kamil_adc.band.points if config.is_kamil_adc else config.radar.sweep.points
|
||||
# RAW/PPR header, each combo's frequency/S11/S21 arrays and capture windows.
|
||||
minimum = 44 + len(config.combos) * (28 + 20 * points)
|
||||
# RAW3/PRP3 header, frequency/S21 arrays and capture windows.
|
||||
minimum = 44 + len(config.combos) * (28 + 12 * points)
|
||||
for name in ("raw", "raw_tap", "preprocessed", "preprocessed_tap"):
|
||||
ring = getattr(config.rings, name)
|
||||
if name.endswith("_tap") and not ring.name:
|
||||
@@ -185,6 +198,34 @@ def validate_sweep_model(sweep: RadarSweepModel) -> None:
|
||||
raise ValueError("radar.sweep.stop_hz must be > radar.sweep.start_hz")
|
||||
|
||||
|
||||
def normalize_antenna_geometry(
|
||||
entries: Iterable[GprTxGeometryModel | GprRxGeometryModel | dict],
|
||||
position_field: str,
|
||||
position_count: int,
|
||||
) -> list[dict]:
|
||||
"""Validate model/web geometry and supply zero for omitted coordinates."""
|
||||
context = "gpr.tx_geometry" if position_field == "output_pos" else "gpr.rx_geometry"
|
||||
records, positions = [], set()
|
||||
for entry in entries:
|
||||
record = asdict(entry) if isinstance(entry, (GprTxGeometryModel, GprRxGeometryModel)) else entry
|
||||
if not isinstance(record, dict):
|
||||
raise ValueError(f"{context} entries must be records")
|
||||
position = record.get(position_field)
|
||||
if isinstance(position, bool) or not isinstance(position, int):
|
||||
raise ValueError(f"{context} {position_field} must be an integer")
|
||||
if not 0 <= position < position_count:
|
||||
raise ValueError(f"{context} {position_field} is out of range [0, {position_count})")
|
||||
if position in positions:
|
||||
raise ValueError(f"{context} contains duplicate {position_field}: {position}")
|
||||
positions.add(position)
|
||||
coordinates = {key: finite_number(record.get(key, 0.0), f"{context}.{key}")
|
||||
for key in ("x_m", "y_m", "z_m")}
|
||||
if any(abs(value) > 3.4e38 for value in coordinates.values()):
|
||||
raise ValueError(f"{context} coordinates must fit finite float32 numbers")
|
||||
records.append({position_field: position, **coordinates})
|
||||
return records
|
||||
|
||||
|
||||
def validate_gpr_model(
|
||||
gpr: GprModel,
|
||||
*,
|
||||
@@ -206,31 +247,8 @@ def validate_gpr_model(
|
||||
if not math.isfinite(gpr.relative_permittivity) or float(gpr.relative_permittivity) <= 0.0:
|
||||
raise ValueError("gpr.relative_permittivity must be > 0")
|
||||
|
||||
seen_output_positions: set[int] = set()
|
||||
for entry in gpr.tx_geometry:
|
||||
if any(not math.isfinite(value) or abs(value) > 3.4e38 for value in (entry.x_m, entry.y_m, entry.z_m)):
|
||||
raise ValueError("gpr.tx_geometry coordinates must be finite numbers")
|
||||
output_pos = entry.output_pos
|
||||
if isinstance(output_pos, bool) or not isinstance(output_pos, int):
|
||||
raise ValueError("gpr.tx_geometry output_pos must be an integer")
|
||||
if output_pos < 0 or output_pos >= int(output_switch_positions):
|
||||
raise ValueError("gpr.tx_geometry output_pos is out of range")
|
||||
if output_pos in seen_output_positions:
|
||||
raise ValueError("gpr.tx_geometry contains duplicate output_pos")
|
||||
seen_output_positions.add(output_pos)
|
||||
|
||||
seen_input_positions: set[int] = set()
|
||||
for entry in gpr.rx_geometry:
|
||||
if any(not math.isfinite(value) or abs(value) > 3.4e38 for value in (entry.x_m, entry.y_m, entry.z_m)):
|
||||
raise ValueError("gpr.rx_geometry coordinates must be finite numbers")
|
||||
input_pos = entry.input_pos
|
||||
if isinstance(input_pos, bool) or not isinstance(input_pos, int):
|
||||
raise ValueError("gpr.rx_geometry input_pos must be an integer")
|
||||
if input_pos < 0 or input_pos >= int(input_switch_positions):
|
||||
raise ValueError("gpr.rx_geometry input_pos is out of range")
|
||||
if input_pos in seen_input_positions:
|
||||
raise ValueError("gpr.rx_geometry contains duplicate input_pos")
|
||||
seen_input_positions.add(input_pos)
|
||||
normalize_antenna_geometry(gpr.tx_geometry, "output_pos", output_switch_positions)
|
||||
normalize_antenna_geometry(gpr.rx_geometry, "input_pos", input_switch_positions)
|
||||
|
||||
|
||||
def validate_combos(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Resolved profile bindings, derived from the GUI dataclass field declarations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, fields, is_dataclass
|
||||
from typing import Iterator
|
||||
|
||||
from python_app.models.gui_profile_schema import GuiStateModel, Scalar, Setting
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BoundSetting:
|
||||
"""A declaration resolved to its persistent field and processing page."""
|
||||
|
||||
path: tuple[str, ...]
|
||||
spec: Setting
|
||||
mode: str | None
|
||||
|
||||
def read(self, gui: GuiStateModel) -> Scalar:
|
||||
value: object = gui
|
||||
for name in self.path:
|
||||
value = getattr(value, name)
|
||||
if not isinstance(value, (str, int, float)):
|
||||
raise TypeError(f"Setting {'.'.join(self.path)} must be a scalar")
|
||||
return value
|
||||
|
||||
def write(self, gui: GuiStateModel, value: Scalar) -> None:
|
||||
owner = gui
|
||||
for name in self.path[:-1]:
|
||||
owner = getattr(owner, name)
|
||||
setattr(owner, self.path[-1], value)
|
||||
|
||||
|
||||
def _settings(model: object, path: tuple[str, ...] = ()) -> Iterator[BoundSetting]:
|
||||
if not is_dataclass(model) or isinstance(model, type):
|
||||
return
|
||||
for member in fields(model):
|
||||
member_path = (*path, member.name)
|
||||
spec = member.metadata.get("setting")
|
||||
if isinstance(spec, Setting):
|
||||
mode = member_path[1] if member_path[0] == "processing" and len(member_path) == 3 else None
|
||||
yield BoundSetting(member_path, spec, mode)
|
||||
else:
|
||||
yield from _settings(getattr(model, member.name), member_path)
|
||||
|
||||
|
||||
GUI_FIELDS = tuple(_settings(GuiStateModel()))
|
||||
|
||||
|
||||
def active_live_fields(mode: str) -> tuple[BoundSetting, ...]:
|
||||
"""Use the selected page for shared fields; retain other fields' own values."""
|
||||
selected: dict[str, BoundSetting] = {}
|
||||
for binding in GUI_FIELDS:
|
||||
name = binding.spec.live
|
||||
if name and (name not in selected or binding.mode == mode):
|
||||
selected[name] = binding
|
||||
return tuple(selected.values())
|
||||
|
||||
|
||||
def active_web_fields(mode: str) -> tuple[BoundSetting, ...]:
|
||||
"""Expose only settings belonging to the current processing page."""
|
||||
return tuple(binding for binding in GUI_FIELDS
|
||||
if binding.spec.web and binding.mode in (None, mode))
|
||||
Reference in New Issue
Block a user