"""Live processing settings model and atomic JSON writer.""" from __future__ import annotations from dataclasses import asdict, dataclass import logging from pathlib import Path from python_app.file_io import atomic_write_json logger = logging.getLogger(__name__) @dataclass(slots=True) class ProcessingLiveConfig: """Runtime-adjustable processing parameters shared with data processor.""" processor_mode: str = "pass_through" pass_through_channel: str = "s21" pass_through_fixed_y_enabled: bool = False pass_through_y_min_db: float = -100.0 pass_through_y_max_db: float = 0.0 bscan_axis: str = "abs" bscan_channel: str = "s21" bscan_cut_m: float = 0.0 bscan_max_depth_m: float = 8.0 bscan_gain: float = 1.0 bscan_start_freq_mhz: float = 100.0 bscan_stop_freq_mhz: float = 8800.0 legacy_gpr_mode: str = "point" relative_permittivity: float | None = None tx_geometry: list[dict] | None = None rx_geometry: list[dict] | None = None gpr_input_positions: list[int] | None = None gpr_output_positions: list[int] | None = None gpr_min_depth_m: float = 2.0 gpr_max_depth_m: float = 14.0 gpr_range_comp_power: float = 0.1 gpr_comp_power: float = 0.2 gpr_object_min_frac: float = 0.7 gpr_score_mode: str = "combined" # Backprojection intra-sweep speed-correction mode: "int_minus" (full # correction) or "int_focus" (focusing residual only). Mirrors Python # Horns_motion_3libre.py MOTION_CORRECTION_MODE. gpr_motion_mode: str = "int_minus" gpr_max_detected_objects_to_draw: int = 5 gpr_draw_top_m_objects: int = 2 gpr_object_approach_min_frames: int = 3 gpr_speed_m_s: float = 0.0 gpr_look_angle_deg: float = 0.0 # Motion-model knobs for the legacy GPR pipeline. `direction_sign` flips # which way later events appear deeper (+1) vs shallower (-1) along Z. # `apply_freq_phase_correction` enables intra-sweep frequency-domain phase # compensation that fires *before* the IFFT — needed when the radar moves # appreciably during one sweep. gpr_direction_sign: float = 1.0 gpr_apply_freq_phase_correction: bool = True # Anchor for the motion model's per-event `dt_ref`: 'frame_center' (default) # or 'first_tx_event'. Mirrors Python `MOTION_CONFIG.reference_mode`. gpr_reference_mode: str = "frame_center" gpr_snr_thresh: float = 4.5 gpr_snr_comp_max: float = 25.0 gpr_start_freq_mhz: float = 3000.0 gpr_stop_freq_mhz: float = 6000.0 gpr_background_subtract_enabled: bool = True gpr_background_mean_count: int = 10 gpr_remove_sidelobe_objects_enabled: bool = True gpr_imaging_plane_y_m: float = 0.0 # Locator filter parameter consumed by the C++ TCP locator server. Coherent BP # objects are already finalized in the processor (no score threshold); only legacy # GPR still thresholds, on a pair count. legacy_gpr_min_visible_pair_count: float = 0.0 # Visible X/Z window (metres). The locator and the desktop plot both clip # detected objects to this window, so the socket broadcasts only what is shown. gpr_visible_x_min_m: float = -2.0 gpr_visible_x_max_m: float = 2.0 gpr_visible_z_min_m: float = 0.0 gpr_visible_z_max_m: float = 14.0 # 3D SAR (sar3d processor) parameters, named after the reference notebook # Multi_antenna_3D_clean_v1.ipynb (SAR_FRAME_RADIUS, BP_GROUND_DEPTH_M, ...). # The band, depth gate, antenna selection, speed and look angle are the # shared gpr_* fields. sar3d_frame_radius: int = 0 sar3d_voxel_m: float = 0.02 # SAR_VOXEL_M, one edge for X, Y and Z sar3d_bp_ground_depth_m: float = 1.25 sar3d_comp_range_power: float = 0.3 sar3d_use_compensation: bool = True sar3d_normalize_each_frame: bool = True # When true, the C++ data_processor ignores socket-supplied vlc updates # and keeps using `gpr_speed_m_s` from this file. ignore_socket_speed: bool = False reprocess_current_result: bool = True history_command_seq: int = 0 history_command: str = "none" settings_revision: int = 0 def __post_init__(self) -> None: """Normalize optional list fields to concrete integer lists.""" if self.gpr_input_positions is None: self.gpr_input_positions = [] else: self.gpr_input_positions = [int(value) for value in self.gpr_input_positions] if self.gpr_output_positions is None: self.gpr_output_positions = [] else: self.gpr_output_positions = [int(value) for value in self.gpr_output_positions] def to_dict(self) -> dict[str, object]: """Convert live config to JSON-serializable dictionary.""" optional_geometry = {"relative_permittivity", "tx_geometry", "rx_geometry"} return { name: value for name, value in asdict(self).items() if name not in optional_geometry or value is not None } class ProcessingLiveConfigWriter: """Atomic writer for processing live-config file.""" def __init__(self, config_path: Path) -> None: """Create writer targeting `config_path`.""" self._config_path = config_path self._config_path.parent.mkdir(parents=True, exist_ok=True) @property def path(self) -> Path: """Return destination config path.""" return self._config_path def write(self, config: ProcessingLiveConfig) -> Path: """Atomically write config by temp-file replace.""" # Hot path: rewritten on every live knob change, so keep this at DEBUG. logger.debug( "Writing live processing config (mode=%s) to %s", config.processor_mode, self._config_path, ) return atomic_write_json(self._config_path, config.to_dict())