"""Live processing settings model and atomic JSON writer.""" from __future__ import annotations from dataclasses import dataclass import json from pathlib import Path @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.824 bscan_max_depth_m: float = 1.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" 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.28 gpr_angle_comp_power: float = 0.10 gpr_comp_power: float = 0.2 gpr_score_mode: str = "combined" gpr_max_detected_objects_to_draw: int = 5 gpr_draw_top_m_objects: int = 2 gpr_speed_m_s: float = 0.0 gpr_look_angle_deg: float = 0.0 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 parameters consumed by the C++ TCP locator server. gpr_min_visible_score: float = 0.0 legacy_gpr_min_visible_pair_count: float = 0.0 # 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" 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.""" return { "processor_mode": str(self.processor_mode), "pass_through_channel": str(self.pass_through_channel), "pass_through_fixed_y_enabled": bool(self.pass_through_fixed_y_enabled), "pass_through_y_min_db": float(self.pass_through_y_min_db), "pass_through_y_max_db": float(self.pass_through_y_max_db), "bscan_axis": str(self.bscan_axis), "bscan_channel": str(self.bscan_channel), "bscan_cut_m": float(self.bscan_cut_m), "bscan_max_depth_m": float(self.bscan_max_depth_m), "bscan_gain": float(self.bscan_gain), "bscan_start_freq_mhz": float(self.bscan_start_freq_mhz), "bscan_stop_freq_mhz": float(self.bscan_stop_freq_mhz), "legacy_gpr_mode": str(self.legacy_gpr_mode), "gpr_input_positions": [int(value) for value in self.gpr_input_positions], "gpr_output_positions": [int(value) for value in self.gpr_output_positions], "gpr_min_depth_m": float(self.gpr_min_depth_m), "gpr_max_depth_m": float(self.gpr_max_depth_m), "gpr_range_comp_power": float(self.gpr_range_comp_power), "gpr_angle_comp_power": float(self.gpr_angle_comp_power), "gpr_comp_power": float(self.gpr_comp_power), "gpr_score_mode": str(self.gpr_score_mode), "gpr_max_detected_objects_to_draw": int(self.gpr_max_detected_objects_to_draw), "gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects), "gpr_speed_m_s": float(self.gpr_speed_m_s), "gpr_look_angle_deg": float(self.gpr_look_angle_deg), "gpr_snr_thresh": float(self.gpr_snr_thresh), "gpr_snr_comp_max": float(self.gpr_snr_comp_max), "gpr_start_freq_mhz": float(self.gpr_start_freq_mhz), "gpr_stop_freq_mhz": float(self.gpr_stop_freq_mhz), "gpr_background_subtract_enabled": bool(self.gpr_background_subtract_enabled), "gpr_background_mean_count": int(self.gpr_background_mean_count), "gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled), "gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m), "gpr_min_visible_score": float(self.gpr_min_visible_score), "legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count), "ignore_socket_speed": bool(self.ignore_socket_speed), "reprocess_current_result": bool(self.reprocess_current_result), "history_command_seq": int(self.history_command_seq), "history_command": str(self.history_command), } 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.""" temp_path = self._config_path.with_suffix(self._config_path.suffix + ".tmp") temp_path.write_text(json.dumps(config.to_dict(), indent=2), encoding="utf-8") temp_path.replace(self._config_path) return self._config_path