Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
@@ -2,17 +2,21 @@
from __future__ import annotations
from contextlib import ExitStack
import math
import time
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QLineEdit,
QPlainTextEdit,
QSpinBox,
)
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
from python_app.gui.antenna_geometry_editor import AntennaGeometryEditor
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
_GPR_MODES = ("gpr", "legacy_gpr")
@@ -56,6 +60,9 @@ _WEB_LIVE_SCHEMA = [
("bscan_start_freq_mhz", "B-scan", ("bscan",), _attr("_bscan_start_freq_mhz")),
("bscan_stop_freq_mhz", "B-scan", ("bscan",), _attr("_bscan_stop_freq_mhz")),
("legacy_gpr_mode", "Mode", ("legacy_gpr",), _attr("_legacy_gpr_config_mode")),
("relative_permittivity", "Geometry & medium", ("legacy_gpr",), _attr("_gpr_relative_permittivity")),
("tx_geometry", "Geometry & medium", _GPR_LIKE_MODES, _attr("_gpr_tx_geometry_input")),
("rx_geometry", "Geometry & medium", _GPR_LIKE_MODES, _attr("_gpr_rx_geometry_input")),
("gpr_input_positions", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_input_positions_input", {"legacy_gpr": "_legacy_gpr_input_positions_input", "sar3d": "_sar3d_input_positions_input"})),
("gpr_output_positions", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_output_positions_input", {"legacy_gpr": "_legacy_gpr_output_positions_input", "sar3d": "_sar3d_output_positions_input"})),
("gpr_min_depth_m", "Geometry & depth", _GPR_LIKE_MODES, _dual("_gpr_min_depth_m", {"legacy_gpr": "_legacy_gpr_min_depth_m", "sar3d": "_sar3d_min_depth_m"})),
@@ -107,6 +114,7 @@ _NON_WIDGET_LIVE_FIELDS = frozenset({
"reprocess_current_result",
"history_command",
"history_command_seq",
"settings_revision",
})
# Display toggles: GUI-only rendering choices (kept in the GUI profile, not the live
@@ -118,21 +126,10 @@ _WEB_DISPLAY_SCHEMA = [
("combo_filter", "Display", ("pass_through",), _attr("_pass_through_combo_filter_input")),
]
# Stable run_config fields (GPR medium and antenna geometry). They live in run_config and
# take effect only when the pipeline (re)starts — not hot-reloaded — so the web marks them
# "applies on Start" and editing them just updates the widget for the next start.
_WEB_STABLE_SCHEMA = [
# Coherent BP fixes the medium to eps_r = 1 (Horns_motion_3libre.py), so relative
# permittivity is a legacy-GPR-only knob; BP ignores it.
("relative_permittivity", "Geometry & medium", ("legacy_gpr",), _attr("_gpr_relative_permittivity")),
("tx_geometry", "Geometry & medium", _GPR_LIKE_MODES, _attr("_gpr_tx_geometry_input")),
("rx_geometry", "Geometry & medium", _GPR_LIKE_MODES, _attr("_gpr_rx_geometry_input")),
]
# Every field the web form renders and may write back, across all three categories.
# Every field the web form renders and may write back.
_WEB_FORM_GETTERS = {
field: getter
for source in (_WEB_LIVE_SCHEMA, _WEB_DISPLAY_SCHEMA, _WEB_STABLE_SCHEMA)
for source in (_WEB_LIVE_SCHEMA, _WEB_DISPLAY_SCHEMA)
for field, _group, _modes, getter in source
}
@@ -157,8 +154,9 @@ def _web_field_schema(field: str, group: str, widget) -> dict | None:
return {**base, "kind": "float", "value": float(widget.value()),
"min": float(widget.minimum()), "max": float(widget.maximum()),
"step": float(widget.singleStep()), "decimals": int(widget.decimals())}
if isinstance(widget, QPlainTextEdit):
return {**base, "kind": "textarea", "value": widget.toPlainText()}
if isinstance(widget, AntennaGeometryEditor):
return {**base, "kind": "geometry", "value": widget.records(),
"position_field": widget.position_field, "position_count": widget.position_count}
if isinstance(widget, QLineEdit):
return {**base, "kind": "text", "value": widget.text()}
return None
@@ -180,48 +178,47 @@ def _read_live_widget_value(widget):
return float(widget.value())
if isinstance(widget, QLineEdit):
return widget.text()
if isinstance(widget, AntennaGeometryEditor):
return widget.records()
raise TypeError(f"Unsupported live-settings widget: {type(widget).__name__}")
def _build_web_settings_schema(window) -> list[dict]:
"""Build the settings schema for the active mode straight from the Qt widgets.
Covers all three widget categories — live settings, GUI display toggles, and stable
run_config fields — so the web form shows exactly what the desktop processing panel
does. Stable fields are tagged ``applies_on_start`` (they take effect on the next run).
Covers live settings and GUI display toggles, matching the desktop panel.
"""
mode = window._processing_mode.currentText()
schema: list[dict] = []
for source, extra in (
(_WEB_LIVE_SCHEMA, {}),
(_WEB_DISPLAY_SCHEMA, {}),
(_WEB_STABLE_SCHEMA, {"applies_on_start": True}),
):
for source in (_WEB_LIVE_SCHEMA, _WEB_DISPLAY_SCHEMA):
for field, group, modes, getter in source:
if modes is not None and mode not in modes:
continue
entry = _web_field_schema(field, group, getter(window))
if entry is not None:
schema.append({**entry, **extra})
schema.append(entry)
return schema
def _set_web_live_field(window, field: str, value) -> None:
"""Write one web value into the desktop widget that feeds ``field`` (any category)."""
getter = _WEB_FORM_GETTERS.get(field)
if getter is None:
return
widget = getter(window)
def _set_widget_value(window, widget, value) -> None:
"""Validate and write a web value, also used when rolling back a rejected batch."""
if isinstance(widget, QComboBox):
if widget.findText(str(value)) < 0:
raise ValueError(f"Unknown setting option: {value}")
window._set_combo_current_text(widget, str(value))
elif isinstance(widget, QCheckBox):
widget.setChecked(bool(value))
elif isinstance(widget, QSpinBox):
widget.setValue(int(float(value)))
elif isinstance(widget, QDoubleSpinBox):
widget.setValue(float(value))
elif isinstance(widget, QPlainTextEdit):
widget.setPlainText(str(value))
if not isinstance(value, bool):
raise ValueError("Checkbox value must be true or false")
widget.setChecked(value)
elif isinstance(widget, (QSpinBox, QDoubleSpinBox)):
numeric = float(value)
if not math.isfinite(numeric) or not widget.minimum() <= numeric <= widget.maximum():
raise ValueError("Numeric setting is outside its allowed range")
if isinstance(widget, QSpinBox) and numeric != int(numeric):
raise ValueError("Setting requires an integer")
widget.setValue(int(numeric) if isinstance(widget, QSpinBox) else numeric)
elif isinstance(widget, AntennaGeometryEditor):
widget.set_records(value)
elif isinstance(widget, QLineEdit):
widget.setText(",".join(str(int(v)) for v in value) if isinstance(value, list) else str(value))
@@ -297,6 +294,7 @@ class AppWindowLiveProcessingMixin:
reprocess_current_result=bool(reprocess_current_result),
history_command=str(history_command),
history_command_seq=int(self._history_command_seq),
settings_revision=getattr(self, "_processing_settings_revision", 0),
**values,
)
@@ -315,12 +313,15 @@ class AppWindowLiveProcessingMixin:
"""
if bump_history_seq:
self._history_command_seq += 1
self._processing_settings_revision = time.monotonic_ns()
self._live_config_writer.write(
self._live_processing_config(
history_command=history_command,
reprocess_current_result=not self._can_reprocess_history(),
)
)
if getattr(self, "_await_processing_revision", False):
self._required_processing_revision = self._processing_settings_revision
def _apply_web_live_settings(self, fields: dict) -> None:
"""Apply web-requested live settings through the SAME path as a desktop edit.
@@ -339,6 +340,10 @@ class AppWindowLiveProcessingMixin:
"""
try:
history_command = str(fields.get("history_command", "none"))
if history_command not in {"none", "clear_all", "remove_last"}:
raise ValueError("Unknown history command")
if history_command != "none" and set(fields) != {"history_command"}:
raise ValueError("History commands must be sent separately from settings")
if history_command == "clear_all":
self._clear_all_runtime_history()
return
@@ -356,15 +361,30 @@ class AppWindowLiveProcessingMixin:
self._log_debug(f"Applying web live settings: fields={sorted(settings)}.")
self._suppress_live_settings_handler = True
try:
# processor_mode first: dual-sourced gpr_* fields route to the gpr or
# legacy_gpr widget based on the active mode.
if "processor_mode" in settings:
_set_web_live_field(self, "processor_mode", settings.pop("processor_mode"))
for name, value in settings.items():
_set_web_live_field(self, name, value)
with ExitStack() as blockers:
snapshots = []
try:
# Set the mode under a blocker before resolving per-mode fields.
ordered = sorted(settings, key=lambda name: name != "processor_mode")
for name in ordered:
widget = _WEB_FORM_GETTERS[name](self)
blockers.enter_context(QSignalBlocker(widget))
snapshots.append((widget, _read_live_widget_value(widget)))
_set_widget_value(self, widget, settings[name])
self._validate_processing_mode_selection(self._processing_mode.currentText())
except Exception:
for widget, value in reversed(snapshots):
_set_widget_value(self, widget, value)
raise
finally:
self._suppress_live_settings_handler = False
self._on_processing_live_settings_changed()
self._sync_socket_speed_controls()
if {"tx_geometry", "rx_geometry", "relative_permittivity"} & settings.keys():
self._await_processing_revision = True
if "processor_mode" in settings:
self._on_processing_mode_changed(self._processing_mode.currentText())
else:
self._on_processing_live_settings_changed()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to apply web live settings", exc)
@@ -372,6 +392,12 @@ class AppWindowLiveProcessingMixin:
"""Return the live-settings schema for the active mode (read from widgets)."""
return _build_web_settings_schema(self)
def _on_gpr_geometry_changed(self) -> None:
if getattr(self, "_suppress_live_settings_handler", False):
return
self._await_processing_revision = True
self._on_processing_live_settings_changed()
def _on_processing_live_settings_changed(self, *_args) -> None:
"""Handle live-processing setting changes and trigger redraw when needed."""
# Web-apply sets many widgets at once; their individual change signals are
@@ -399,11 +425,12 @@ class AppWindowLiveProcessingMixin:
collection = latest
if collection is None and self._result_history:
collection = self._result_history[-1]
if collection is None or not self._draw_results(collection):
if current_mode == "sar3d":
self._clear_sar3d_plot()
else:
self._clear_gpr_plot()
if current_mode == "sar3d":
self._clear_sar3d_plot()
if collection is not None:
self._draw_results(collection)
elif collection is None or not self._draw_results(collection):
self._clear_gpr_plot()
elif self._result_history:
self._draw_results(self._result_history[-1])
else:
@@ -589,9 +616,9 @@ class AppWindowLiveProcessingMixin:
self._clear_gpr_plot()
return
if self._processing_mode.currentText() == "sar3d":
if self._result_history and self._draw_results(self._result_history[-1]):
return
self._clear_sar3d_plot()
if self._result_history:
self._draw_results(self._result_history[-1])
return
if self._result_history:
self._draw_results(self._result_history[-1])
@@ -10,6 +10,8 @@ from pathlib import Path
from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWidgets import QFileDialog
from python_app.file_io import atomic_write_json
from python_app.gui.controllers.app_window_config.state_builders import GUI_SAVE_HISTORY_LIMIT
from python_app.models.gui_profile_model import GuiProfileModel
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.preprocess_assets import (
@@ -27,8 +29,7 @@ class AppWindowConfigProfileIOMixin:
raise FileExistsError(f"Config profile output already exists: {output_path}")
profile = self._build_gui_profile()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(profile.to_dict(), indent=2), encoding="utf-8")
atomic_write_json(output_path, profile.to_dict(), durable=True)
return profile
def _set_combo_selection_mode(self, mode: str) -> None:
@@ -42,6 +43,7 @@ class AppWindowConfigProfileIOMixin:
self._single_combo_input.setEnabled(True)
self._run_combos_select_button.setEnabled(False)
self._single_combo_select_button.setEnabled(False)
self._next_combo_button.setEnabled(True)
return
text_selected = mode != "single"
@@ -52,6 +54,32 @@ class AppWindowConfigProfileIOMixin:
self._single_combo_input.setEnabled(not text_selected)
self._run_combos_select_button.setEnabled(True)
self._single_combo_select_button.setEnabled(True)
self._next_combo_button.setEnabled(not text_selected)
def _select_display_combo(self, input_pos: int, output_pos: int) -> None:
"""Select an available pair and redraw its cached B-scan immediately."""
key = (int(input_pos), int(output_pos))
if key not in self._available_display_combo_keys():
raise ValueError(f"Unknown channel combination: {key[0]}:{key[1]}")
self._single_combo_input.setText(str(key[0]))
self._single_combo_output.setText(str(key[1]))
self._on_bscan_display_combo_changed()
def _select_next_combo(self) -> None:
"""Advance output first, then input, wrapping after the last configured pair."""
available = self._available_display_combo_keys()
if not available:
return
try:
current = (int(self._single_combo_input.text()), int(self._single_combo_output.text()))
index = available.index(current) + 1
except ValueError:
index = 0
self._select_display_combo(*available[index % len(available)])
def _available_display_combo_keys(self) -> list[tuple[int, int]]:
"""Return configured pairs ordered by input, then output for display navigation."""
return sorted((combo.input, combo.output) for combo in self._defaults_config.build_runtime_combos())
def _is_matrix_radar_model_selected(self) -> bool:
"""Return whether the loaded config targets a matrix-mode radar acquisition."""
@@ -124,11 +152,10 @@ class AppWindowConfigProfileIOMixin:
C++ replay window still follows ring capacities, since it bounds how much
of the history the processor can re-publish coherently.
"""
save_history_limit = self._save_history_limit_for_config(config)
bscan_cpp_replay_window = self._cpp_bscan_replay_window_for_config(config)
self._raw_history = deque(self._raw_history, maxlen=save_history_limit)
self._pre_history = deque(self._pre_history, maxlen=save_history_limit)
self._result_history = deque(self._result_history, maxlen=save_history_limit)
self._raw_history = deque(self._raw_history, maxlen=GUI_SAVE_HISTORY_LIMIT)
self._pre_history = deque(self._pre_history, maxlen=GUI_SAVE_HISTORY_LIMIT)
self._result_history = deque(self._result_history, maxlen=GUI_SAVE_HISTORY_LIMIT)
self._bscan_cpp_replay_window = bscan_cpp_replay_window
self._clear_bscan_plot_history()
@@ -351,7 +378,6 @@ class AppWindowConfigProfileIOMixin:
self._sar3d_comp_range_power,
self._sar3d_use_compensation,
self._sar3d_normalize_each_frame,
self._save_count,
self._record_count,
self._save_path_input,
self._save_name_input,
@@ -457,18 +483,10 @@ class AppWindowConfigProfileIOMixin:
self._bscan_history_window.setValue(int(gui_state.processing.bscan.history_window_scans))
self._gpr_relative_permittivity.setValue(float(config.gpr.relative_permittivity))
self._gpr_tx_geometry_input.setPlainText(
"\n".join(
f"{int(entry.output_pos)} {float(entry.x_m):g}"
for entry in config.gpr.tx_geometry
)
)
self._gpr_rx_geometry_input.setPlainText(
"\n".join(
f"{int(entry.input_pos)} {float(entry.x_m):g}"
for entry in config.gpr.rx_geometry
)
)
self._gpr_tx_geometry_input.set_position_count(config.output_switch.positions)
self._gpr_rx_geometry_input.set_position_count(config.input_switch.positions)
self._gpr_tx_geometry_input.set_records(config.gpr.tx_geometry)
self._gpr_rx_geometry_input.set_records(config.gpr.rx_geometry)
self._gpr_input_positions_input.setText(str(gui_state.processing.gpr.input_positions))
self._gpr_output_positions_input.setText(str(gui_state.processing.gpr.output_positions))
self._gpr_min_depth_m.setValue(float(gui_state.processing.gpr.min_depth_m))
@@ -555,7 +573,6 @@ class AppWindowConfigProfileIOMixin:
self._sar3d_normalize_each_frame.setChecked(bool(sar3d_state.normalize_each_frame))
self._sync_socket_speed_controls()
self._save_count.setValue(int(gui_state.data_actions.save_count))
self._record_count.setValue(int(gui_state.data_actions.record_count))
self._save_path_input.setText(str(gui_state.data_actions.save_path))
self._save_name_input.setText(str(gui_state.data_actions.save_name))
@@ -590,6 +607,7 @@ class AppWindowConfigProfileIOMixin:
self._apply_initial_radar_limits()
if self._preprocess_dialog is not None:
self._refresh_sets()
self._await_processing_revision = True
self._on_processing_mode_changed(gui_state.processing.selected_mode)
self._update_history_indicator()
self._remember_active_profile_path(profile_path)
@@ -28,9 +28,6 @@ class AppWindowRadarLimitsMixin:
else:
radar_service = create_single_radar_service(config)
if isinstance(radar_service, LibreVnaService) and not radar_service.driver_available:
raise RuntimeError("LibreVNA Python driver is not available for device limits query")
limits = radar_service.read_device_limits()
return self._apply_radar_limits_to_ui(limits)
@@ -81,52 +81,6 @@ class AppWindowConfigStateBuildersMixin:
env[key] = value
return env
@staticmethod
def _parse_geometry_line_coordinates(parts: list[str]) -> tuple[float, float, float]:
"""Parse 1/2/3 trailing coordinate fields into (x, y, z); missing axes default to 0."""
x_m = float(parts[0])
y_m = float(parts[1]) if len(parts) >= 2 else 0.0
z_m = float(parts[2]) if len(parts) >= 3 else 0.0
return x_m, y_m, z_m
@staticmethod
def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]:
"""Parse line-based Tx geometry editor text: `output_pos x_m [y_m] [z_m]`."""
entries: list[GprTxGeometryModel] = []
for line_number, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.strip()
if not line:
continue
parts = line.split()
if len(parts) < 2 or len(parts) > 4:
raise ValueError(
f"Invalid Tx geometry line {line_number}: expected `output_pos x_m [y_m] [z_m]`"
)
x_m, y_m, z_m = AppWindowConfigStateBuildersMixin._parse_geometry_line_coordinates(parts[1:])
entries.append(
GprTxGeometryModel(output_pos=int(parts[0]), x_m=x_m, y_m=y_m, z_m=z_m)
)
return entries
@staticmethod
def _parse_gpr_rx_geometry_text(text: str) -> list[GprRxGeometryModel]:
"""Parse line-based Rx geometry editor text: `input_pos x_m [y_m] [z_m]`."""
entries: list[GprRxGeometryModel] = []
for line_number, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.strip()
if not line:
continue
parts = line.split()
if len(parts) < 2 or len(parts) > 4:
raise ValueError(
f"Invalid Rx geometry line {line_number}: expected `input_pos x_m [y_m] [z_m]`"
)
x_m, y_m, z_m = AppWindowConfigStateBuildersMixin._parse_geometry_line_coordinates(parts[1:])
entries.append(
GprRxGeometryModel(input_pos=int(parts[0]), x_m=x_m, y_m=y_m, z_m=z_m)
)
return entries
@staticmethod
def _format_combos_text_from_config(config: RunConfigModel) -> str:
"""Render configured combos for UI text editor, keeping full matrix as empty."""
@@ -192,18 +146,6 @@ class AppWindowConfigStateBuildersMixin:
),
)
@staticmethod
def _save_history_limit_for_config(config: RunConfigModel) -> int:
"""Return maxlen for GUI snapshot-save deques.
Independent of ring capacities — see :data:`GUI_SAVE_HISTORY_LIMIT`.
The `config` argument is kept for symmetry with
:meth:`_cpp_bscan_replay_window_for_config` and possible future per-profile
overrides.
"""
del config
return GUI_SAVE_HISTORY_LIMIT
def _default_gui_state_for_config(self, config: RunConfigModel) -> GuiStateModel:
"""Build fallback GUI-only defaults for a stable run config."""
default_combo = config.combos[0] if config.combos else ComboModel(input=0, output=0)
@@ -217,74 +159,17 @@ class AppWindowConfigStateBuildersMixin:
single_output=str(int(default_combo.output)),
),
processing=GuiProcessingStateModel(
selected_mode="pass_through",
pass_through=GuiPassThroughStateModel(
show_magnitude=True,
show_phase=True,
unwrap_phase=False,
combo_filter="",
fixed_y_enabled=False,
y_min_db=-100.0,
y_max_db=0.0,
),
bscan=GuiBscanStateModel(
axis="abs",
cut_m=0.824,
max_depth_m=1.0,
gain=1.0,
start_freq_mhz=100.0,
stop_freq_mhz=8800.0,
subtract_mean_ascan=False,
),
gpr=GuiGprStateModel(
input_positions=self._default_gpr_input_positions_from_config(config),
output_positions=self._default_gpr_output_positions_from_config(config),
min_depth_m=2.0,
max_depth_m=14.0,
range_comp_power=0.1,
object_min_frac=0.7,
score_mode="combined",
motion_mode="int_minus",
look_angle_deg=0.0,
direction_sign=1.0,
speed_m_s=0.0,
ignore_socket_speed_enabled=False,
max_detected_objects_to_draw=5,
draw_top_m_objects=2,
object_approach_min_frames=3,
start_freq_mhz=3000.0,
stop_freq_mhz=6000.0,
background_subtract_enabled=True,
background_mean_count=10,
remove_sidelobe_objects_enabled=True,
imaging_plane_y_m=0.0,
render_mode="heatmap",
visible_x_min_m=default_gpr_x_min_m,
visible_x_max_m=default_gpr_x_max_m,
visible_z_min_m=0.0,
visible_z_max_m=14.0,
),
legacy_gpr=GuiLegacyGprStateModel(
input_positions=self._default_gpr_input_positions_from_config(config),
output_positions=self._default_gpr_output_positions_from_config(config),
min_depth_m=2.0,
max_depth_m=14.0,
comp_power=0.2,
start_freq_mhz=3000.0,
stop_freq_mhz=6000.0,
speed_m_s=0.0,
ignore_socket_speed_enabled=False,
look_angle_deg=0.0,
snr_thresh=4.5,
snr_comp_max=25.0,
background_subtract_enabled=True,
background_mean_count=10,
render_mode="heatmap",
min_visible_pair_count=1,
visible_x_min_m=default_gpr_x_min_m,
visible_x_max_m=default_gpr_x_max_m,
visible_z_min_m=0.0,
visible_z_max_m=14.0,
),
sar3d=GuiSar3dStateModel(
input_positions=self._default_gpr_input_positions_from_config(config),
@@ -292,15 +177,7 @@ class AppWindowConfigStateBuildersMixin:
),
),
data_actions=GuiDataActionsStateModel(
save_count=10,
save_path=str(self._project_root / "python_app/data/snapshots"),
save_name="snapshot_manual",
record_count=100,
),
preprocess_dialog=GuiPreprocessDialogStateModel(
set_name="set_001",
radar_config_dir="",
use_all_radar_configs=False,
),
)
@@ -442,7 +319,6 @@ class AppWindowConfigStateBuildersMixin:
),
),
data_actions=GuiDataActionsStateModel(
save_count=int(self._save_count.value()),
save_path=self._save_path_input.text().strip(),
save_name=self._save_name_input.text().strip(),
record_count=int(self._record_count.value()),
@@ -502,8 +378,8 @@ class AppWindowConfigStateBuildersMixin:
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
preprocess_asset_model(config, key).set_name = self._selected_preprocess_sets.get(key, "")
config.gpr.relative_permittivity = float(self._gpr_relative_permittivity.value())
config.gpr.tx_geometry = self._parse_gpr_tx_geometry_text(self._gpr_tx_geometry_input.toPlainText())
config.gpr.rx_geometry = self._parse_gpr_rx_geometry_text(self._gpr_rx_geometry_input.toPlainText())
config.gpr.tx_geometry = [GprTxGeometryModel(**entry) for entry in self._gpr_tx_geometry_input.records()]
config.gpr.rx_geometry = [GprRxGeometryModel(**entry) for entry in self._gpr_rx_geometry_input.records()]
validate_gpr_model(
config.gpr,
input_switch_positions=config.input_switch.positions,