Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
+35
View File
@@ -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
)
+4 -1
View File
@@ -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)
+57 -564
View File
@@ -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
-32
View File
@@ -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",
]
+148 -96
View File
@@ -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: ...
+1 -43
View File
@@ -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],
-68
View File
@@ -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",
]
+28 -31
View File
@@ -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(".")
+56 -38
View File
@@ -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(
+63
View File
@@ -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))