little refactoring done
This commit is contained in:
@@ -1,4 +1,8 @@
|
|||||||
"""Main GUI window composed from focused mixins."""
|
"""Main GUI composition root.
|
||||||
|
|
||||||
|
This module wires UI/controller mixins together and owns application-level
|
||||||
|
state shared across them (runtime services, readers, history buffers, timer).
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -35,28 +39,50 @@ class AppWindow(
|
|||||||
AppWindowSnapshotMixin,
|
AppWindowSnapshotMixin,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
):
|
):
|
||||||
"""Top-level application window coordinating UI and acquisition runtime."""
|
"""Top-level window coordinating GUI state and acquisition runtime."""
|
||||||
|
|
||||||
def __init__(self, project_root: Path) -> None:
|
def __init__(self, project_root: Path) -> None:
|
||||||
"""Initialize application state, services, UI, and polling timer."""
|
"""Initialize all app subsystems in deterministic order."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
self._init_paths_and_defaults(project_root)
|
||||||
|
self._init_runtime_services()
|
||||||
|
self._init_reader_handles()
|
||||||
|
self._init_preprocess_state()
|
||||||
|
self._init_capture_state()
|
||||||
|
self._init_history_state()
|
||||||
|
self._init_runtime_limits()
|
||||||
|
self._init_polling_timer()
|
||||||
|
self._bootstrap_ui_runtime()
|
||||||
|
|
||||||
|
def _init_paths_and_defaults(self, project_root: Path) -> None:
|
||||||
|
"""Initialize project paths and baseline run configuration."""
|
||||||
self._project_root = project_root
|
self._project_root = project_root
|
||||||
self._defaults_config_path = project_root / "run_config.json"
|
self._defaults_config_path = project_root / "run_config.json"
|
||||||
self._defaults_config = RunConfigModel.load_from_path(self._defaults_config_path)
|
self._defaults_config = RunConfigModel.load_from_path(self._defaults_config_path)
|
||||||
|
|
||||||
self._store = NpzStore(project_root / "python_app/data")
|
def _init_runtime_services(self) -> None:
|
||||||
self._config_writer = ConfigWriter(project_root / "python_app/runtime")
|
"""Initialize long-lived service objects used by mixins."""
|
||||||
self._supervisor = ProcessSupervisor(project_root)
|
runtime_dir = self._project_root / "python_app/runtime"
|
||||||
self._live_config_writer = ProcessingLiveConfigWriter(project_root / "python_app/runtime/processing_live.json")
|
self._store = NpzStore(self._project_root / "python_app/data")
|
||||||
|
self._config_writer = ConfigWriter(runtime_dir)
|
||||||
|
self._supervisor = ProcessSupervisor(self._project_root)
|
||||||
|
self._live_config_writer = ProcessingLiveConfigWriter(runtime_dir / "processing_live.json")
|
||||||
|
|
||||||
|
def _init_reader_handles(self) -> None:
|
||||||
|
"""Initialize SHM readers as detached (not connected) handles."""
|
||||||
self._raw_reader: ShmRingReader | None = None
|
self._raw_reader: ShmRingReader | None = None
|
||||||
self._pre_reader: ShmRingReader | None = None
|
self._pre_reader: ShmRingReader | None = None
|
||||||
self._result_reader: ShmRingReader | None = None
|
self._result_reader: ShmRingReader | None = None
|
||||||
|
|
||||||
|
def _init_preprocess_state(self) -> None:
|
||||||
|
"""Initialize preprocessing dialog and selected set names."""
|
||||||
self._preprocess_dialog: PreprocessDialog | None = None
|
self._preprocess_dialog: PreprocessDialog | None = None
|
||||||
self._selected_calibration_set = str(self._defaults_config.preprocess.calibration_set)
|
self._selected_calibration_set = str(self._defaults_config.preprocess.calibration_set)
|
||||||
self._selected_reference_set = str(self._defaults_config.preprocess.reference_set)
|
self._selected_reference_set = str(self._defaults_config.preprocess.reference_set)
|
||||||
|
|
||||||
|
def _init_capture_state(self) -> None:
|
||||||
|
"""Initialize one-shot capture and sequence-control flags."""
|
||||||
self._capture_session: SequentialCaptureSession | None = None
|
self._capture_session: SequentialCaptureSession | None = None
|
||||||
self._resume_pipeline_after_capture = False
|
self._resume_pipeline_after_capture = False
|
||||||
self._single_capture_active = False
|
self._single_capture_active = False
|
||||||
@@ -64,19 +90,16 @@ class AppWindow(
|
|||||||
self._single_capture_seen_raw = False
|
self._single_capture_seen_raw = False
|
||||||
self._single_capture_target_collection_id: int | None = None
|
self._single_capture_target_collection_id: int | None = None
|
||||||
|
|
||||||
self._raw_history: deque[SweepCollection] = deque(maxlen=512)
|
def _init_history_state(self) -> None:
|
||||||
self._pre_history: deque[SweepCollection] = deque(maxlen=512)
|
"""Initialize runtime history buffers and render-cache state."""
|
||||||
result_history_limit = max(
|
history_limit = self._history_limit_from_config()
|
||||||
1,
|
self._raw_history: deque[SweepCollection] = deque(maxlen=history_limit)
|
||||||
min(
|
self._pre_history: deque[SweepCollection] = deque(maxlen=history_limit)
|
||||||
int(self._defaults_config.rings.preprocessed.capacity),
|
self._result_history: deque[ResultCollection] = deque(maxlen=history_limit)
|
||||||
int(self._defaults_config.rings.results.capacity),
|
|
||||||
50,
|
# Sequence id must survive GUI restarts so history commands stay monotonic.
|
||||||
),
|
|
||||||
)
|
|
||||||
self._result_history: deque[ResultCollection] = deque(maxlen=result_history_limit)
|
|
||||||
self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path)
|
self._history_command_seq = self._load_history_command_seq(self._live_config_writer.path)
|
||||||
self._bscan_history_limit = result_history_limit
|
self._bscan_history_limit = history_limit
|
||||||
self._bscan_history_by_combo = {}
|
self._bscan_history_by_combo = {}
|
||||||
self._bscan_depth_axis_by_combo = {}
|
self._bscan_depth_axis_by_combo = {}
|
||||||
self._bscan_history_floor_collection_id = 0
|
self._bscan_history_floor_collection_id = 0
|
||||||
@@ -85,22 +108,43 @@ class AppWindow(
|
|||||||
self._history_run_signature = None
|
self._history_run_signature = None
|
||||||
self._radar_limits: dict[str, float | int] | None = None
|
self._radar_limits: dict[str, float | int] | None = None
|
||||||
|
|
||||||
|
def _history_limit_from_config(self) -> int:
|
||||||
|
"""Return unified GUI history limit derived from configured ring capacities."""
|
||||||
|
return max(
|
||||||
|
1,
|
||||||
|
min(
|
||||||
|
int(self._defaults_config.rings.raw_tap.capacity),
|
||||||
|
int(self._defaults_config.rings.preprocessed_tap.capacity),
|
||||||
|
int(self._defaults_config.rings.results.capacity),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _init_runtime_limits(self) -> None:
|
||||||
|
"""Initialize read/drain loop limits used by polling and snapshot code."""
|
||||||
self._max_pop_per_poll = 256
|
self._max_pop_per_poll = 256
|
||||||
self._max_pop_per_snapshot_drain = 4096
|
self._max_pop_per_snapshot_drain = 4096
|
||||||
|
|
||||||
|
def _init_polling_timer(self) -> None:
|
||||||
|
"""Create periodic timer that polls SHM rings for new data."""
|
||||||
self._timer = QTimer(self)
|
self._timer = QTimer(self)
|
||||||
self._timer.setInterval(50)
|
self._timer.setInterval(50)
|
||||||
self._timer.timeout.connect(self._poll_rings)
|
self._timer.timeout.connect(self._poll_rings)
|
||||||
|
|
||||||
|
def _bootstrap_ui_runtime(self) -> None:
|
||||||
|
"""Build UI and apply initial runtime-bound state after widgets exist."""
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._refresh_preprocess_summary_labels()
|
self._refresh_preprocess_summary_labels()
|
||||||
if self._radar_mode.currentText() == "native":
|
self._apply_initial_radar_limits()
|
||||||
self._refresh_radar_limits_from_device()
|
|
||||||
else:
|
|
||||||
self._apply_radar_limits_to_ui(None)
|
|
||||||
self._write_live_processing_config()
|
self._write_live_processing_config()
|
||||||
self._timer.start()
|
self._timer.start()
|
||||||
|
|
||||||
|
def _apply_initial_radar_limits(self) -> None:
|
||||||
|
"""Apply startup radar-limits strategy according to selected radar mode."""
|
||||||
|
if self._radar_mode.currentText() == "native":
|
||||||
|
self._refresh_radar_limits_from_device()
|
||||||
|
return
|
||||||
|
self._apply_radar_limits_to_ui(None)
|
||||||
|
|
||||||
def _log(self, text: str) -> None:
|
def _log(self, text: str) -> None:
|
||||||
"""Append a line to the runtime log panel."""
|
"""Append a line to the runtime log panel."""
|
||||||
self._log_box.appendPlainText(text)
|
self._log_box.appendPlainText(text)
|
||||||
@@ -111,6 +155,7 @@ class AppWindow(
|
|||||||
try:
|
try:
|
||||||
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
|
# Missing or malformed file should not block startup.
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
raw_value = payload.get("history_command_seq", 0)
|
raw_value = payload.get("history_command_seq", 0)
|
||||||
@@ -129,8 +174,11 @@ class AppWindow(
|
|||||||
"""Ensure workers and dialogs are closed before window destruction."""
|
"""Ensure workers and dialogs are closed before window destruction."""
|
||||||
try:
|
try:
|
||||||
self._resume_pipeline_after_capture = False
|
self._resume_pipeline_after_capture = False
|
||||||
|
# 1) Abort active capture first (releases exclusive hardware resources).
|
||||||
self._abort_capture_sequence(resume_pipeline=False)
|
self._abort_capture_sequence(resume_pipeline=False)
|
||||||
|
# 2) Stop all managed processes/readers.
|
||||||
self._stop_all_processes()
|
self._stop_all_processes()
|
||||||
|
# 3) Close auxiliary dialog windows.
|
||||||
if self._preprocess_dialog is not None:
|
if self._preprocess_dialog is not None:
|
||||||
self._preprocess_dialog.close()
|
self._preprocess_dialog.close()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -179,12 +179,12 @@ class AppWindowConfigMixin:
|
|||||||
if self._result_history:
|
if self._result_history:
|
||||||
self._sync_bscan_history_from_results()
|
self._sync_bscan_history_from_results()
|
||||||
if not self._draw_bscan_heatmap_from_history():
|
if not self._draw_bscan_heatmap_from_history():
|
||||||
self._plot.clear()
|
self._bscan_plot.clear()
|
||||||
return
|
return
|
||||||
if self._result_history:
|
if self._result_history:
|
||||||
self._draw_results(self._result_history[-1])
|
self._draw_results(self._result_history[-1])
|
||||||
return
|
return
|
||||||
self._plot.clear()
|
self._bscan_plot.clear()
|
||||||
self._clear_trace_plots()
|
self._clear_trace_plots()
|
||||||
|
|
||||||
def _on_radar_identity_changed(self, *_args) -> None:
|
def _on_radar_identity_changed(self, *_args) -> None:
|
||||||
@@ -317,6 +317,16 @@ class AppWindowConfigMixin:
|
|||||||
|
|
||||||
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
|
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
|
||||||
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
|
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
|
||||||
|
required_widgets = (
|
||||||
|
"_start_hz_input",
|
||||||
|
"_stop_hz_input",
|
||||||
|
"_bscan_start_freq_mhz",
|
||||||
|
"_bscan_stop_freq_mhz",
|
||||||
|
)
|
||||||
|
if not all(hasattr(self, widget_name) for widget_name in required_widgets):
|
||||||
|
# Processing callbacks can fire while UI groups are still being built.
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
radar_start_hz = float(self._start_hz_input.text().strip())
|
radar_start_hz = float(self._start_hz_input.text().strip())
|
||||||
radar_stop_hz = float(self._stop_hz_input.text().strip())
|
radar_stop_hz = float(self._stop_hz_input.text().strip())
|
||||||
|
|||||||
@@ -59,6 +59,29 @@ class AppWindowPlotMixin:
|
|||||||
"""Clear pass-through magnitude and phase plots."""
|
"""Clear pass-through magnitude and phase plots."""
|
||||||
self._trace_magnitude_plot.clear()
|
self._trace_magnitude_plot.clear()
|
||||||
self._trace_phase_plot.clear()
|
self._trace_phase_plot.clear()
|
||||||
|
self._clear_trace_legends()
|
||||||
|
self._trace_magnitude_curves.clear()
|
||||||
|
self._trace_phase_curves.clear()
|
||||||
|
|
||||||
|
def _clear_trace_legends(self) -> None:
|
||||||
|
"""Remove trace plot legends to avoid stale combo-color mappings."""
|
||||||
|
mag_legend = self._trace_magnitude_legend
|
||||||
|
if mag_legend is not None:
|
||||||
|
try:
|
||||||
|
self._trace_magnitude_plot.getPlotItem().removeItem(mag_legend)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self._trace_magnitude_legend = None
|
||||||
|
self._trace_magnitude_legend_combo_keys.clear()
|
||||||
|
|
||||||
|
phase_legend = self._trace_phase_legend
|
||||||
|
if phase_legend is not None:
|
||||||
|
try:
|
||||||
|
self._trace_phase_plot.getPlotItem().removeItem(phase_legend)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self._trace_phase_legend = None
|
||||||
|
self._trace_phase_legend_combo_keys.clear()
|
||||||
|
|
||||||
def _draw_trace_lines(self, collection: ResultCollection) -> bool:
|
def _draw_trace_lines(self, collection: ResultCollection) -> bool:
|
||||||
"""Draw result payload traces as stacked magnitude/phase plots."""
|
"""Draw result payload traces as stacked magnitude/phase plots."""
|
||||||
@@ -69,8 +92,8 @@ class AppWindowPlotMixin:
|
|||||||
|
|
||||||
magnitude_plot.setVisible(show_magnitude)
|
magnitude_plot.setVisible(show_magnitude)
|
||||||
phase_plot.setVisible(show_phase)
|
phase_plot.setVisible(show_phase)
|
||||||
self._clear_trace_plots()
|
|
||||||
if not show_magnitude and not show_phase:
|
if not show_magnitude and not show_phase:
|
||||||
|
self._clear_trace_plots()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if show_magnitude:
|
if show_magnitude:
|
||||||
@@ -103,44 +126,90 @@ class AppWindowPlotMixin:
|
|||||||
"#1982c4",
|
"#1982c4",
|
||||||
]
|
]
|
||||||
|
|
||||||
color_index = 0
|
combo_colors: dict[tuple[int, int], str] = {}
|
||||||
|
legend_source_magnitude: dict[tuple[int, int], pg.PlotCurveItem] = {}
|
||||||
|
legend_source_phase: dict[tuple[int, int], pg.PlotCurveItem] = {}
|
||||||
|
active_magnitude_keys: set[tuple[int, int, int, str]] = set()
|
||||||
|
active_phase_keys: set[tuple[int, int, int, str]] = set()
|
||||||
has_data = False
|
has_data = False
|
||||||
x_min = np.inf
|
x_min = np.inf
|
||||||
x_max = -np.inf
|
x_max = -np.inf
|
||||||
for block in collection.blocks:
|
for block in collection.blocks:
|
||||||
for payload in block.payloads:
|
combo_key = (int(block.combo.input_pos), int(block.combo.output_pos))
|
||||||
|
if combo_key not in combo_colors:
|
||||||
|
combo_colors[combo_key] = palette[len(combo_colors) % len(palette)]
|
||||||
|
color = combo_colors[combo_key]
|
||||||
|
combo_label = f"in{combo_key[0]}/out{combo_key[1]}"
|
||||||
|
|
||||||
|
for payload_index, payload in enumerate(block.payloads):
|
||||||
if payload.kind != 1 or payload.trace.size == 0:
|
if payload.kind != 1 or payload.trace.size == 0:
|
||||||
continue
|
continue
|
||||||
if payload.frequency_hz.size == 0 or payload.frequency_hz.size != payload.trace.size:
|
if payload.frequency_hz.size == 0 or payload.frequency_hz.size != payload.trace.size:
|
||||||
continue
|
continue
|
||||||
|
curve_key = (
|
||||||
|
combo_key[0],
|
||||||
|
combo_key[1],
|
||||||
|
int(payload_index),
|
||||||
|
str(payload.processing_name),
|
||||||
|
)
|
||||||
|
|
||||||
local_x_min = float(np.min(payload.frequency_hz))
|
local_x_min = float(np.min(payload.frequency_hz))
|
||||||
local_x_max = float(np.max(payload.frequency_hz))
|
local_x_max = float(np.max(payload.frequency_hz))
|
||||||
x_min = min(x_min, local_x_min)
|
x_min = min(x_min, local_x_min)
|
||||||
x_max = max(x_max, local_x_max)
|
x_max = max(x_max, local_x_max)
|
||||||
color = palette[color_index % len(palette)]
|
|
||||||
|
|
||||||
if show_magnitude:
|
if show_magnitude:
|
||||||
magnitude_values = 20.0 * np.log10(np.maximum(np.abs(payload.trace), 1e-12))
|
magnitude_values = 20.0 * np.log10(np.maximum(np.abs(payload.trace), 1e-12))
|
||||||
magnitude_curve = pg.PlotCurveItem(
|
active_magnitude_keys.add(curve_key)
|
||||||
payload.frequency_hz,
|
magnitude_curve = self._trace_magnitude_curves.get(curve_key)
|
||||||
magnitude_values,
|
if magnitude_curve is None:
|
||||||
pen=pg.mkPen(color, width=1.4),
|
magnitude_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.4))
|
||||||
)
|
self._trace_magnitude_curves[curve_key] = magnitude_curve
|
||||||
magnitude_plot.addItem(magnitude_curve)
|
magnitude_plot.addItem(magnitude_curve)
|
||||||
|
else:
|
||||||
|
magnitude_curve.setPen(pg.mkPen(color, width=1.4))
|
||||||
|
magnitude_curve.setData(payload.frequency_hz, magnitude_values)
|
||||||
|
legend_source_magnitude.setdefault(combo_key, magnitude_curve)
|
||||||
has_data = True
|
has_data = True
|
||||||
|
|
||||||
if show_phase:
|
if show_phase:
|
||||||
phase_values = np.degrees(np.angle(payload.trace))
|
active_phase_keys.add(curve_key)
|
||||||
phase_curve = pg.PlotCurveItem(
|
phase_curve = self._trace_phase_curves.get(curve_key)
|
||||||
payload.frequency_hz,
|
if phase_curve is None:
|
||||||
phase_values,
|
phase_curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=1.2))
|
||||||
pen=pg.mkPen(color, width=1.2, style=Qt.PenStyle.DashLine),
|
self._trace_phase_curves[curve_key] = phase_curve
|
||||||
)
|
|
||||||
phase_plot.addItem(phase_curve)
|
phase_plot.addItem(phase_curve)
|
||||||
|
else:
|
||||||
|
phase_curve.setPen(pg.mkPen(color, width=1.2))
|
||||||
|
phase_x, phase_values = self._phase_display_arrays(payload.frequency_hz, payload.trace)
|
||||||
|
phase_curve.setData(phase_x, phase_values)
|
||||||
|
legend_source_phase.setdefault(combo_key, phase_curve)
|
||||||
has_data = True
|
has_data = True
|
||||||
|
|
||||||
color_index += 1
|
if show_magnitude:
|
||||||
|
self._remove_inactive_trace_curves(
|
||||||
|
plot=magnitude_plot,
|
||||||
|
cache=self._trace_magnitude_curves,
|
||||||
|
active_keys=active_magnitude_keys,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._remove_all_trace_curves(plot=magnitude_plot, cache=self._trace_magnitude_curves)
|
||||||
|
|
||||||
|
if show_phase:
|
||||||
|
self._remove_inactive_trace_curves(
|
||||||
|
plot=phase_plot,
|
||||||
|
cache=self._trace_phase_curves,
|
||||||
|
active_keys=active_phase_keys,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._remove_all_trace_curves(plot=phase_plot, cache=self._trace_phase_curves)
|
||||||
|
|
||||||
|
self._sync_trace_legends(
|
||||||
|
show_magnitude=show_magnitude,
|
||||||
|
show_phase=show_phase,
|
||||||
|
magnitude_sources=legend_source_magnitude,
|
||||||
|
phase_sources=legend_source_phase,
|
||||||
|
)
|
||||||
|
|
||||||
if has_data:
|
if has_data:
|
||||||
if np.isfinite(x_min) and np.isfinite(x_max):
|
if np.isfinite(x_min) and np.isfinite(x_max):
|
||||||
@@ -152,6 +221,105 @@ class AppWindowPlotMixin:
|
|||||||
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||||
return has_data
|
return has_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_inactive_trace_curves(
|
||||||
|
*,
|
||||||
|
plot: pg.PlotWidget,
|
||||||
|
cache: dict[tuple[int, int, int, str], pg.PlotCurveItem],
|
||||||
|
active_keys: set[tuple[int, int, int, str]],
|
||||||
|
) -> None:
|
||||||
|
"""Delete curve items no longer present in latest result collection."""
|
||||||
|
for key in list(cache.keys()):
|
||||||
|
if key in active_keys:
|
||||||
|
continue
|
||||||
|
curve = cache.pop(key)
|
||||||
|
plot.removeItem(curve)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_all_trace_curves(
|
||||||
|
*,
|
||||||
|
plot: pg.PlotWidget,
|
||||||
|
cache: dict[tuple[int, int, int, str], pg.PlotCurveItem],
|
||||||
|
) -> None:
|
||||||
|
"""Delete all cached curves from selected plot."""
|
||||||
|
for curve in cache.values():
|
||||||
|
plot.removeItem(curve)
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
def _phase_display_arrays(self, frequency_hz: np.ndarray, trace: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Return phase display arrays with decimation for faster rendering."""
|
||||||
|
max_points = int(getattr(self, "_trace_phase_render_max_points", 1200))
|
||||||
|
if max_points > 0 and trace.size > max_points:
|
||||||
|
step = max(1, int(np.ceil(trace.size / max_points)))
|
||||||
|
frequency_hz = frequency_hz[::step]
|
||||||
|
trace = trace[::step]
|
||||||
|
phase_values = np.arctan2(trace.imag, trace.real) * (180.0 / np.pi)
|
||||||
|
return frequency_hz, phase_values
|
||||||
|
|
||||||
|
def _sync_trace_legends(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
show_magnitude: bool,
|
||||||
|
show_phase: bool,
|
||||||
|
magnitude_sources: dict[tuple[int, int], pg.PlotCurveItem],
|
||||||
|
phase_sources: dict[tuple[int, int], pg.PlotCurveItem],
|
||||||
|
) -> None:
|
||||||
|
"""Rebuild legends only when active combo set changes."""
|
||||||
|
self._sync_single_trace_legend(
|
||||||
|
show=show_magnitude,
|
||||||
|
plot=self._trace_magnitude_plot,
|
||||||
|
legend_attr="_trace_magnitude_legend",
|
||||||
|
legend_keys_attr="_trace_magnitude_legend_combo_keys",
|
||||||
|
sources=magnitude_sources,
|
||||||
|
)
|
||||||
|
self._sync_single_trace_legend(
|
||||||
|
show=show_phase,
|
||||||
|
plot=self._trace_phase_plot,
|
||||||
|
legend_attr="_trace_phase_legend",
|
||||||
|
legend_keys_attr="_trace_phase_legend_combo_keys",
|
||||||
|
sources=phase_sources,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync_single_trace_legend(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
show: bool,
|
||||||
|
plot: pg.PlotWidget,
|
||||||
|
legend_attr: str,
|
||||||
|
legend_keys_attr: str,
|
||||||
|
sources: dict[tuple[int, int], pg.PlotCurveItem],
|
||||||
|
) -> None:
|
||||||
|
"""Rebuild one legend from provided combo->curve mapping when needed."""
|
||||||
|
legend = getattr(self, legend_attr)
|
||||||
|
existing_keys = getattr(self, legend_keys_attr)
|
||||||
|
active_keys = set(sources.keys())
|
||||||
|
if not show or not active_keys:
|
||||||
|
if legend is not None:
|
||||||
|
try:
|
||||||
|
plot.getPlotItem().removeItem(legend)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
setattr(self, legend_attr, None)
|
||||||
|
existing_keys.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
if legend is not None and existing_keys == active_keys:
|
||||||
|
return
|
||||||
|
|
||||||
|
if legend is not None:
|
||||||
|
try:
|
||||||
|
plot.getPlotItem().removeItem(legend)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
legend = plot.addLegend(offset=(8, 8))
|
||||||
|
for combo_key in sorted(active_keys):
|
||||||
|
curve = sources[combo_key]
|
||||||
|
legend.addItem(curve, f"in{combo_key[0]}/out{combo_key[1]}")
|
||||||
|
setattr(self, legend_attr, legend)
|
||||||
|
existing_keys.clear()
|
||||||
|
existing_keys.update(active_keys)
|
||||||
|
|
||||||
def _draw_bscan_heatmap(self, _collection: ResultCollection) -> bool:
|
def _draw_bscan_heatmap(self, _collection: ResultCollection) -> bool:
|
||||||
"""Draw B-scan image rebuilt from processed result history."""
|
"""Draw B-scan image rebuilt from processed result history."""
|
||||||
self._disable_phase_axis()
|
self._disable_phase_axis()
|
||||||
@@ -189,18 +357,18 @@ class AppWindowPlotMixin:
|
|||||||
image_item.setLookupTable(self._bscan_lookup_table(axis_mode))
|
image_item.setLookupTable(self._bscan_lookup_table(axis_mode))
|
||||||
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
|
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
|
||||||
|
|
||||||
self._plot.clear()
|
self._bscan_plot.clear()
|
||||||
view_box = self._plot.getViewBox()
|
view_box = self._bscan_plot.getViewBox()
|
||||||
view_box.invertY(True)
|
view_box.invertY(True)
|
||||||
view_box.enableAutoRange(x=False, y=False)
|
view_box.enableAutoRange(x=False, y=False)
|
||||||
self._plot.getPlotItem().showAxis("left", show=True)
|
self._bscan_plot.getPlotItem().showAxis("left", show=True)
|
||||||
self._plot.getPlotItem().showAxis("bottom", show=True)
|
self._bscan_plot.getPlotItem().showAxis("bottom", show=True)
|
||||||
self._plot.setLabel("bottom", "Sweep #")
|
self._bscan_plot.setLabel("bottom", "Sweep #")
|
||||||
self._plot.setLabel("left", "Depth", units="m")
|
self._bscan_plot.setLabel("left", "Depth", units="m")
|
||||||
self._plot.addItem(image_item)
|
self._bscan_plot.addItem(image_item)
|
||||||
self._plot.setXRange(x_min, x_max, padding=0.02)
|
self._bscan_plot.setXRange(x_min, x_max, padding=0.02)
|
||||||
self._plot.setYRange(depth_min, depth_max, padding=0.02)
|
self._bscan_plot.setYRange(depth_min, depth_max, padding=0.02)
|
||||||
self._plot.setTitle(f"B-scan in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}")
|
self._bscan_plot.setTitle(f"B-scan in{display_key[0]}/out{display_key[1]} | sweeps={sweep_count}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _sync_bscan_history_from_results(self) -> None:
|
def _sync_bscan_history_from_results(self) -> None:
|
||||||
@@ -284,7 +452,7 @@ class AppWindowPlotMixin:
|
|||||||
|
|
||||||
def _ensure_phase_view_box(self) -> pg.ViewBox:
|
def _ensure_phase_view_box(self) -> pg.ViewBox:
|
||||||
"""Create or return secondary right-axis ViewBox for phase curves."""
|
"""Create or return secondary right-axis ViewBox for phase curves."""
|
||||||
plot_item = self._plot.getPlotItem()
|
plot_item = self._bscan_plot.getPlotItem()
|
||||||
phase_view_box = self._phase_viewbox
|
phase_view_box = self._phase_viewbox
|
||||||
if phase_view_box is None:
|
if phase_view_box is None:
|
||||||
phase_view_box = pg.ViewBox()
|
phase_view_box = pg.ViewBox()
|
||||||
@@ -301,7 +469,7 @@ class AppWindowPlotMixin:
|
|||||||
phase_view_box = self._phase_viewbox
|
phase_view_box = self._phase_viewbox
|
||||||
if phase_view_box is None:
|
if phase_view_box is None:
|
||||||
return
|
return
|
||||||
plot_item = self._plot.getPlotItem()
|
plot_item = self._bscan_plot.getPlotItem()
|
||||||
phase_view_box.setGeometry(plot_item.vb.sceneBoundingRect())
|
phase_view_box.setGeometry(plot_item.vb.sceneBoundingRect())
|
||||||
phase_view_box.linkedViewChanged(plot_item.vb, phase_view_box.XAxis)
|
phase_view_box.linkedViewChanged(plot_item.vb, phase_view_box.XAxis)
|
||||||
|
|
||||||
@@ -358,6 +526,9 @@ class AppWindowPlotMixin:
|
|||||||
pen=pg.mkPen("#ffd166", width=1.8),
|
pen=pg.mkPen("#ffd166", width=1.8),
|
||||||
)
|
)
|
||||||
magnitude_plot.addItem(magnitude_curve)
|
magnitude_plot.addItem(magnitude_curve)
|
||||||
|
self._trace_magnitude_curves[
|
||||||
|
(int(trace.combo.input_pos), int(trace.combo.output_pos), 0, "__single_trace__")
|
||||||
|
] = magnitude_curve
|
||||||
|
|
||||||
if show_phase:
|
if show_phase:
|
||||||
phase_deg = np.degrees(np.angle(trace.s21))
|
phase_deg = np.degrees(np.angle(trace.s21))
|
||||||
@@ -367,6 +538,9 @@ class AppWindowPlotMixin:
|
|||||||
pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine),
|
pen=pg.mkPen("#80ed99", width=1.4, style=Qt.PenStyle.DashLine),
|
||||||
)
|
)
|
||||||
phase_plot.addItem(phase_curve)
|
phase_plot.addItem(phase_curve)
|
||||||
|
self._trace_phase_curves[
|
||||||
|
(int(trace.combo.input_pos), int(trace.combo.output_pos), 0, "__single_trace__")
|
||||||
|
] = phase_curve
|
||||||
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
|
||||||
|
|
||||||
if np.size(trace.frequency_hz) > 1:
|
if np.size(trace.frequency_hz) > 1:
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
"""UI construction mixin for the main radar control window."""
|
"""UI construction mixin for the main radar control window.
|
||||||
|
|
||||||
|
Layout is intentionally split into two independent plot surfaces:
|
||||||
|
- single `PlotWidget` for B-scan heatmap rendering;
|
||||||
|
- stacked magnitude/phase `PlotWidget`s for pass-through traces.
|
||||||
|
|
||||||
|
`_set_plot_mode()` switches between these surfaces via `QStackedWidget`.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -29,10 +36,21 @@ from python_app.gui.controllers.sections import (
|
|||||||
|
|
||||||
|
|
||||||
class AppWindowUiMixin:
|
class AppWindowUiMixin:
|
||||||
"""Builds and wires all static UI widgets."""
|
"""Build and wire all static widgets used by :class:`AppWindow`.
|
||||||
|
|
||||||
|
This mixin only creates UI objects and connects lightweight UI signals.
|
||||||
|
Runtime logic (pipeline start/stop, polling, drawing, snapshot saving)
|
||||||
|
is implemented in other mixins.
|
||||||
|
"""
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
"""Build main window widgets, plot area, and settings panel."""
|
"""Build complete main-window layout.
|
||||||
|
|
||||||
|
High-level structure:
|
||||||
|
1. Left: plot stack (`B-scan` page + `trace magnitude/phase` page).
|
||||||
|
2. Middle: narrow toggle button to collapse/expand settings.
|
||||||
|
3. Right: scrollable settings panel + status/history + runtime log.
|
||||||
|
"""
|
||||||
self.setWindowTitle("Radar System Control")
|
self.setWindowTitle("Radar System Control")
|
||||||
root = QWidget(self)
|
root = QWidget(self)
|
||||||
self.setCentralWidget(root)
|
self.setCentralWidget(root)
|
||||||
@@ -41,15 +59,37 @@ class AppWindowUiMixin:
|
|||||||
layout.setContentsMargins(12, 12, 12, 12)
|
layout.setContentsMargins(12, 12, 12, 12)
|
||||||
layout.setSpacing(14)
|
layout.setSpacing(14)
|
||||||
|
|
||||||
|
self._build_plot_area(layout, root)
|
||||||
|
self._build_settings_toggle(layout)
|
||||||
|
self._build_settings_panel(layout, root)
|
||||||
|
|
||||||
|
self._toggle_settings_panel(visible=True)
|
||||||
|
self.resize(1650, 940)
|
||||||
|
|
||||||
|
def _build_plot_area(self, root_layout: QHBoxLayout, root: QWidget) -> None:
|
||||||
|
"""Build left plot area with stacked B-scan and trace pages."""
|
||||||
|
# Plot stack holds mutually exclusive visualization surfaces.
|
||||||
|
# We create both upfront and only switch active page at runtime.
|
||||||
self._plot_stack = QStackedWidget(root)
|
self._plot_stack = QStackedWidget(root)
|
||||||
|
self._build_bscan_plot_page()
|
||||||
|
self._build_trace_plot_page()
|
||||||
|
|
||||||
self._plot = pg.PlotWidget(background="#0f141c")
|
# Default view on startup is pass-through traces.
|
||||||
self._plot.showGrid(x=True, y=True, alpha=0.2)
|
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
||||||
self._plot.setLabel("bottom", "Frequency", units="Hz")
|
root_layout.addWidget(self._plot_stack, stretch=11)
|
||||||
self._plot.setLabel("left", "Magnitude", units="dB")
|
|
||||||
self._plot_stack.addWidget(self._plot)
|
|
||||||
|
|
||||||
self._trace_plots_container = QWidget(root)
|
def _build_bscan_plot_page(self) -> None:
|
||||||
|
"""Create B-scan page in plot stack."""
|
||||||
|
# B-scan surface: one PlotWidget used as canvas for ImageItem heatmap.
|
||||||
|
self._bscan_plot = pg.PlotWidget(background="#0f141c")
|
||||||
|
self._bscan_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||||
|
self._plot_stack.addWidget(self._bscan_plot)
|
||||||
|
|
||||||
|
def _build_trace_plot_page(self) -> None:
|
||||||
|
"""Create pass-through page with magnitude and phase plots."""
|
||||||
|
# Pass-through surface: container with two synchronized line plots.
|
||||||
|
# Upper plot shows magnitude, lower plot shows phase.
|
||||||
|
self._trace_plots_container = QWidget()
|
||||||
trace_layout = QVBoxLayout(self._trace_plots_container)
|
trace_layout = QVBoxLayout(self._trace_plots_container)
|
||||||
trace_layout.setContentsMargins(0, 0, 0, 0)
|
trace_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
trace_layout.setSpacing(6)
|
trace_layout.setSpacing(6)
|
||||||
@@ -70,53 +110,40 @@ class AppWindowUiMixin:
|
|||||||
self._trace_phase_plot.getPlotItem().setClipToView(True)
|
self._trace_phase_plot.getPlotItem().setClipToView(True)
|
||||||
trace_layout.addWidget(self._trace_phase_plot, stretch=1)
|
trace_layout.addWidget(self._trace_phase_plot, stretch=1)
|
||||||
|
|
||||||
self._plot_stack.addWidget(self._trace_plots_container)
|
# Legends are created lazily only when trace mode draws line series.
|
||||||
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
self._trace_magnitude_legend = None
|
||||||
layout.addWidget(self._plot_stack, stretch=11)
|
self._trace_phase_legend = None
|
||||||
|
self._trace_magnitude_legend_combo_keys = set()
|
||||||
|
self._trace_phase_legend_combo_keys = set()
|
||||||
|
self._trace_magnitude_curves = {}
|
||||||
|
self._trace_phase_curves = {}
|
||||||
|
self._trace_phase_render_max_points = 400
|
||||||
|
|
||||||
|
self._plot_stack.addWidget(self._trace_plots_container)
|
||||||
|
|
||||||
|
def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None:
|
||||||
|
"""Create narrow button used to collapse or show settings panel."""
|
||||||
self._settings_toggle_button = QPushButton("<")
|
self._settings_toggle_button = QPushButton("<")
|
||||||
self._settings_toggle_button.setObjectName("settingsToggleButton")
|
self._settings_toggle_button.setObjectName("settingsToggleButton")
|
||||||
self._settings_toggle_button.setFixedWidth(26)
|
self._settings_toggle_button.setFixedWidth(26)
|
||||||
self._settings_toggle_button.clicked.connect(self._toggle_settings_panel)
|
self._settings_toggle_button.clicked.connect(lambda: self._toggle_settings_panel())
|
||||||
layout.addWidget(self._settings_toggle_button, stretch=0)
|
root_layout.addWidget(self._settings_toggle_button, stretch=0)
|
||||||
|
|
||||||
|
def _build_settings_panel(self, root_layout: QHBoxLayout, root: QWidget) -> None:
|
||||||
|
"""Build right settings panel with controls, status labels, and log."""
|
||||||
self._settings_panel = QWidget(root)
|
self._settings_panel = QWidget(root)
|
||||||
self._settings_panel.setMinimumWidth(659)
|
self._settings_panel.setMinimumWidth(659)
|
||||||
right_layout = QVBoxLayout(self._settings_panel)
|
right_layout = QVBoxLayout(self._settings_panel)
|
||||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
right_layout.setSpacing(10)
|
right_layout.setSpacing(10)
|
||||||
|
|
||||||
# Build log widget early so error handlers can safely write during UI construction.
|
# Build log early so `_show_error()` can append text even during
|
||||||
|
# subsequent group construction if something fails.
|
||||||
self._log_box = QPlainTextEdit(self._settings_panel)
|
self._log_box = QPlainTextEdit(self._settings_panel)
|
||||||
self._log_box.setReadOnly(True)
|
self._log_box.setReadOnly(True)
|
||||||
self._log_box.setMinimumHeight(170)
|
self._log_box.setMinimumHeight(170)
|
||||||
|
|
||||||
pipeline_group = self._build_pipeline_group()
|
right_layout.addWidget(self._build_settings_scroll(), stretch=1)
|
||||||
hardware_actions_group = self._build_hardware_actions_group()
|
|
||||||
data_actions_group = self._build_data_actions_group()
|
|
||||||
preprocess_summary_group = self._build_preprocess_summary_group()
|
|
||||||
radar_group = self._build_radar_group()
|
|
||||||
processing_group = self._build_processing_group()
|
|
||||||
switch_group = self._build_switch_group()
|
|
||||||
|
|
||||||
controls = QWidget(self._settings_panel)
|
|
||||||
controls_layout = QVBoxLayout(controls)
|
|
||||||
controls_layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
controls_layout.setSpacing(10)
|
|
||||||
controls_layout.addWidget(pipeline_group)
|
|
||||||
controls_layout.addWidget(hardware_actions_group)
|
|
||||||
controls_layout.addWidget(data_actions_group)
|
|
||||||
controls_layout.addWidget(preprocess_summary_group)
|
|
||||||
controls_layout.addWidget(processing_group)
|
|
||||||
controls_layout.addWidget(radar_group)
|
|
||||||
controls_layout.addWidget(switch_group)
|
|
||||||
controls_layout.addStretch(1)
|
|
||||||
|
|
||||||
scroll = QScrollArea(self._settings_panel)
|
|
||||||
scroll.setWidgetResizable(True)
|
|
||||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
|
||||||
scroll.setWidget(controls)
|
|
||||||
right_layout.addWidget(scroll, stretch=1)
|
|
||||||
|
|
||||||
self._status_label = QLabel("Status: idle", self._settings_panel)
|
self._status_label = QLabel("Status: idle", self._settings_panel)
|
||||||
self._status_label.setObjectName("statusLabel")
|
self._status_label.setObjectName("statusLabel")
|
||||||
@@ -127,17 +154,46 @@ class AppWindowUiMixin:
|
|||||||
right_layout.addWidget(self._history_label)
|
right_layout.addWidget(self._history_label)
|
||||||
|
|
||||||
right_layout.addWidget(self._log_box, stretch=0)
|
right_layout.addWidget(self._log_box, stretch=0)
|
||||||
|
root_layout.addWidget(self._settings_panel, stretch=8)
|
||||||
|
|
||||||
layout.addWidget(self._settings_panel, stretch=8)
|
def _build_settings_scroll(self) -> QScrollArea:
|
||||||
self._set_settings_panel_visible(True)
|
"""Build scroll area with all control groups in display order."""
|
||||||
self.resize(1650, 940)
|
# All control groups are placed into a scroll area so right panel
|
||||||
|
# remains usable on smaller screens and with future extra controls.
|
||||||
|
controls = QWidget(self._settings_panel)
|
||||||
|
controls_layout = QVBoxLayout(controls)
|
||||||
|
controls_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
controls_layout.setSpacing(10)
|
||||||
|
for group in self._build_control_groups():
|
||||||
|
controls_layout.addWidget(group)
|
||||||
|
controls_layout.addStretch(1)
|
||||||
|
|
||||||
def _toggle_settings_panel(self) -> None:
|
scroll = QScrollArea(self._settings_panel)
|
||||||
"""Toggle settings panel visibility."""
|
scroll.setWidgetResizable(True)
|
||||||
self._set_settings_panel_visible(not self._settings_panel.isVisible())
|
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
scroll.setWidget(controls)
|
||||||
|
return scroll
|
||||||
|
|
||||||
def _set_settings_panel_visible(self, visible: bool) -> None:
|
def _build_control_groups(self) -> list[QGroupBox]:
|
||||||
"""Set settings panel visibility and update toggle button glyph."""
|
"""Create all settings groups in top-to-bottom order."""
|
||||||
|
return [
|
||||||
|
build_pipeline_group(self),
|
||||||
|
build_hardware_actions_group(self),
|
||||||
|
build_data_actions_group(self),
|
||||||
|
build_preprocess_summary_group(self),
|
||||||
|
build_processing_group(self),
|
||||||
|
build_radar_group(self),
|
||||||
|
build_switch_group(self),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _toggle_settings_panel(self, *, visible: bool | None = None) -> None:
|
||||||
|
"""Toggle settings panel visibility or force a specific state.
|
||||||
|
|
||||||
|
When `visible` is `None`, state is toggled.
|
||||||
|
When `visible` is set, panel visibility is forced to that value.
|
||||||
|
"""
|
||||||
|
if visible is None:
|
||||||
|
visible = not self._settings_panel.isVisible()
|
||||||
self._settings_panel.setVisible(visible)
|
self._settings_panel.setVisible(visible)
|
||||||
if visible:
|
if visible:
|
||||||
self._settings_toggle_button.setText(">")
|
self._settings_toggle_button.setText(">")
|
||||||
@@ -147,40 +203,16 @@ class AppWindowUiMixin:
|
|||||||
self._settings_toggle_button.setToolTip("Show settings panel")
|
self._settings_toggle_button.setToolTip("Show settings panel")
|
||||||
|
|
||||||
def _set_plot_mode(self, mode: str) -> None:
|
def _set_plot_mode(self, mode: str) -> None:
|
||||||
"""Switch visible plot surface based on processing mode."""
|
"""Switch visible plot page according to processing mode.
|
||||||
|
|
||||||
|
`bscan` -> show `self._bscan_plot` (single heatmap surface)
|
||||||
|
otherwise -> show `self._trace_plots_container` (magnitude + phase)
|
||||||
|
"""
|
||||||
if mode == "bscan":
|
if mode == "bscan":
|
||||||
self._plot_stack.setCurrentWidget(self._plot)
|
self._plot_stack.setCurrentWidget(self._bscan_plot)
|
||||||
return
|
return
|
||||||
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
self._plot_stack.setCurrentWidget(self._trace_plots_container)
|
||||||
|
|
||||||
def _build_pipeline_group(self) -> QGroupBox:
|
|
||||||
"""Build pipeline controls section."""
|
|
||||||
return build_pipeline_group(self)
|
|
||||||
|
|
||||||
def _build_hardware_actions_group(self) -> QGroupBox:
|
|
||||||
"""Build hardware actions section."""
|
|
||||||
return build_hardware_actions_group(self)
|
|
||||||
|
|
||||||
def _build_data_actions_group(self) -> QGroupBox:
|
|
||||||
"""Build data actions section."""
|
|
||||||
return build_data_actions_group(self)
|
|
||||||
|
|
||||||
def _build_preprocess_summary_group(self) -> QGroupBox:
|
|
||||||
"""Build selected preprocess sets summary section."""
|
|
||||||
return build_preprocess_summary_group(self)
|
|
||||||
|
|
||||||
def _build_processing_group(self) -> QGroupBox:
|
|
||||||
"""Build processing mode section."""
|
|
||||||
return build_processing_group(self)
|
|
||||||
|
|
||||||
def _build_radar_group(self) -> QGroupBox:
|
|
||||||
"""Build radar settings section."""
|
|
||||||
return build_radar_group(self)
|
|
||||||
|
|
||||||
def _build_switch_group(self) -> QGroupBox:
|
|
||||||
"""Build switch settings section."""
|
|
||||||
return build_switch_group(self)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _set_combo_current_text(combo: QComboBox, value: str) -> None:
|
def _set_combo_current_text(combo: QComboBox, value: str) -> None:
|
||||||
"""Select combo item by text, appending it when missing."""
|
"""Select combo item by text, appending it when missing."""
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from PyQt6.QtCore import pyqtSignal
|
|||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
QFormLayout,
|
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
@@ -23,7 +22,13 @@ from python_app.models.dataset_model import TraceData
|
|||||||
|
|
||||||
|
|
||||||
class PreprocessDialog(QDialog):
|
class PreprocessDialog(QDialog):
|
||||||
"""Standalone dialog for preprocessing capture workflows."""
|
"""Standalone dialog for preprocessing capture workflows.
|
||||||
|
|
||||||
|
The dialog combines three concerns:
|
||||||
|
1. Set selection (calibration/reference).
|
||||||
|
2. Sequential capture controls for filling all N*M combinations.
|
||||||
|
3. Quick preview of the last captured trace.
|
||||||
|
"""
|
||||||
|
|
||||||
refresh_requested = pyqtSignal()
|
refresh_requested = pyqtSignal()
|
||||||
selection_changed = pyqtSignal(str, str)
|
selection_changed = pyqtSignal(str, str)
|
||||||
@@ -32,64 +37,97 @@ class PreprocessDialog(QDialog):
|
|||||||
abort_sequence_requested = pyqtSignal()
|
abort_sequence_requested = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, parent=None) -> None:
|
def __init__(self, parent=None) -> None:
|
||||||
"""Create dialog and build all widgets."""
|
"""Initialize window metadata and compose dialog UI."""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Preprocessing Setup")
|
self._init_window()
|
||||||
self.resize(1040, 760)
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
|
|
||||||
|
def _init_window(self) -> None:
|
||||||
|
"""Set static window properties."""
|
||||||
|
self.setWindowTitle("Preprocessing Setup")
|
||||||
|
self.resize(1040, 760)
|
||||||
|
|
||||||
def _build_ui(self) -> None:
|
def _build_ui(self) -> None:
|
||||||
"""Build dialog layout, controls, and preview plot."""
|
"""Build root dialog layout and all sections."""
|
||||||
layout = QVBoxLayout(self)
|
root_layout = QVBoxLayout(self)
|
||||||
|
root_layout.addWidget(self._build_sets_group())
|
||||||
|
root_layout.addWidget(self._build_sequence_group())
|
||||||
|
self._build_status_line(root_layout)
|
||||||
|
self._build_preview_plot(root_layout)
|
||||||
|
|
||||||
sets_group = QGroupBox("Calibration / Reference Sets", self)
|
def _build_sets_group(self) -> QGroupBox:
|
||||||
sets_layout = QGridLayout(sets_group)
|
"""Build set-management controls used for preprocessing snapshots."""
|
||||||
|
group = QGroupBox("Calibration / Reference Sets", self)
|
||||||
|
layout = QGridLayout(group)
|
||||||
|
|
||||||
self._set_name_input = QLineEdit("set_001", sets_group)
|
self._set_name_input = QLineEdit("set_001", group)
|
||||||
self._calibration_combo = QComboBox(sets_group)
|
self._calibration_combo = QComboBox(group)
|
||||||
self._reference_combo = QComboBox(sets_group)
|
self._reference_combo = QComboBox(group)
|
||||||
|
|
||||||
refresh_button = QPushButton("Refresh Sets", sets_group)
|
refresh_button = QPushButton("Refresh Sets", group)
|
||||||
refresh_button.clicked.connect(self.refresh_requested.emit)
|
refresh_button.clicked.connect(self.refresh_requested.emit)
|
||||||
|
|
||||||
self._calibration_combo.currentTextChanged.connect(self._emit_selection_changed)
|
self._calibration_combo.currentTextChanged.connect(self._emit_selection_changed)
|
||||||
self._reference_combo.currentTextChanged.connect(self._emit_selection_changed)
|
self._reference_combo.currentTextChanged.connect(self._emit_selection_changed)
|
||||||
|
|
||||||
sets_layout.addWidget(QLabel("Set name"), 0, 0)
|
layout.addWidget(QLabel("Set name"), 0, 0)
|
||||||
sets_layout.addWidget(self._set_name_input, 0, 1)
|
layout.addWidget(self._set_name_input, 0, 1)
|
||||||
sets_layout.addWidget(refresh_button, 0, 2)
|
layout.addWidget(refresh_button, 0, 2)
|
||||||
|
|
||||||
sets_layout.addWidget(QLabel("Calibration set"), 1, 0)
|
layout.addWidget(QLabel("Calibration set"), 1, 0)
|
||||||
sets_layout.addWidget(self._calibration_combo, 1, 1, 1, 2)
|
layout.addWidget(self._calibration_combo, 1, 1, 1, 2)
|
||||||
|
|
||||||
sets_layout.addWidget(QLabel("Reference set"), 2, 0)
|
layout.addWidget(QLabel("Reference set"), 2, 0)
|
||||||
sets_layout.addWidget(self._reference_combo, 2, 1, 1, 2)
|
layout.addWidget(self._reference_combo, 2, 1, 1, 2)
|
||||||
|
return group
|
||||||
|
|
||||||
layout.addWidget(sets_group)
|
def _build_sequence_group(self) -> QGroupBox:
|
||||||
|
"""Build sequential-capture controls and capture log."""
|
||||||
|
group = QGroupBox("Sequential Capture (Fill Full N*M)", self)
|
||||||
|
layout = QGridLayout(group)
|
||||||
|
|
||||||
sequence_group = QGroupBox("Sequential Capture (Fill Full N*M)", self)
|
self._active_kind_label = QLabel("<none>", group)
|
||||||
sequence_layout = QGridLayout(sequence_group)
|
self._progress_label = QLabel("0 / 0", group)
|
||||||
|
self._combo_label = QLabel("<none>", group)
|
||||||
|
|
||||||
self._active_kind_label = QLabel("<none>", sequence_group)
|
self._tx_antenna_label_input = QLineEdit(group)
|
||||||
self._progress_label = QLabel("0 / 0", sequence_group)
|
self._rx_antenna_label_input = QLineEdit(group)
|
||||||
self._combo_label = QLabel("<none>", sequence_group)
|
|
||||||
|
|
||||||
self._tx_antenna_label_input = QLineEdit(sequence_group)
|
|
||||||
self._rx_antenna_label_input = QLineEdit(sequence_group)
|
|
||||||
self._tx_antenna_label_input.setPlaceholderText("e.g. TX_A")
|
self._tx_antenna_label_input.setPlaceholderText("e.g. TX_A")
|
||||||
self._rx_antenna_label_input.setPlaceholderText("e.g. RX_B")
|
self._rx_antenna_label_input.setPlaceholderText("e.g. RX_B")
|
||||||
|
|
||||||
start_calibration_button = QPushButton("Start Calibration Sequence", sequence_group)
|
button_row = self._build_sequence_button_row(group)
|
||||||
|
|
||||||
|
layout.addWidget(QLabel("Active type"), 0, 0)
|
||||||
|
layout.addWidget(self._active_kind_label, 0, 1)
|
||||||
|
layout.addWidget(QLabel("Progress"), 1, 0)
|
||||||
|
layout.addWidget(self._progress_label, 1, 1)
|
||||||
|
layout.addWidget(QLabel("Current combo"), 2, 0)
|
||||||
|
layout.addWidget(self._combo_label, 2, 1)
|
||||||
|
layout.addWidget(QLabel("TX antenna label"), 3, 0)
|
||||||
|
layout.addWidget(self._tx_antenna_label_input, 3, 1)
|
||||||
|
layout.addWidget(QLabel("RX antenna label"), 4, 0)
|
||||||
|
layout.addWidget(self._rx_antenna_label_input, 4, 1)
|
||||||
|
layout.addLayout(button_row, 5, 0, 1, 2)
|
||||||
|
|
||||||
|
self._capture_log = QPlainTextEdit(group)
|
||||||
|
self._capture_log.setReadOnly(True)
|
||||||
|
self._capture_log.setPlaceholderText("Capture history per combo")
|
||||||
|
layout.addWidget(self._capture_log, 6, 0, 1, 2)
|
||||||
|
return group
|
||||||
|
|
||||||
|
def _build_sequence_button_row(self, parent: QGroupBox) -> QHBoxLayout:
|
||||||
|
"""Build action buttons for sequence flow control."""
|
||||||
|
start_calibration_button = QPushButton("Start Calibration Sequence", parent)
|
||||||
start_calibration_button.clicked.connect(lambda: self.start_sequence_requested.emit("calibration"))
|
start_calibration_button.clicked.connect(lambda: self.start_sequence_requested.emit("calibration"))
|
||||||
|
|
||||||
start_reference_button = QPushButton("Start Reference Sequence", sequence_group)
|
start_reference_button = QPushButton("Start Reference Sequence", parent)
|
||||||
start_reference_button.clicked.connect(lambda: self.start_sequence_requested.emit("reference"))
|
start_reference_button.clicked.connect(lambda: self.start_sequence_requested.emit("reference"))
|
||||||
|
|
||||||
self._capture_next_button = QPushButton("Capture Current Combo", sequence_group)
|
self._capture_next_button = QPushButton("Capture Current Combo", parent)
|
||||||
self._capture_next_button.clicked.connect(self.capture_next_requested.emit)
|
self._capture_next_button.clicked.connect(self.capture_next_requested.emit)
|
||||||
self._capture_next_button.setEnabled(False)
|
self._capture_next_button.setEnabled(False)
|
||||||
|
|
||||||
self._abort_button = QPushButton("Abort Sequence", sequence_group)
|
self._abort_button = QPushButton("Abort Sequence", parent)
|
||||||
self._abort_button.clicked.connect(self.abort_sequence_requested.emit)
|
self._abort_button.clicked.connect(self.abort_sequence_requested.emit)
|
||||||
self._abort_button.setEnabled(False)
|
self._abort_button.setEnabled(False)
|
||||||
|
|
||||||
@@ -98,34 +136,20 @@ class PreprocessDialog(QDialog):
|
|||||||
button_row.addWidget(start_reference_button)
|
button_row.addWidget(start_reference_button)
|
||||||
button_row.addWidget(self._capture_next_button)
|
button_row.addWidget(self._capture_next_button)
|
||||||
button_row.addWidget(self._abort_button)
|
button_row.addWidget(self._abort_button)
|
||||||
|
return button_row
|
||||||
|
|
||||||
sequence_layout.addWidget(QLabel("Active type"), 0, 0)
|
def _build_status_line(self, root_layout: QVBoxLayout) -> None:
|
||||||
sequence_layout.addWidget(self._active_kind_label, 0, 1)
|
"""Build one-line status output for dialog operations."""
|
||||||
sequence_layout.addWidget(QLabel("Progress"), 1, 0)
|
self._status_label = QLabel("Ready", self)
|
||||||
sequence_layout.addWidget(self._progress_label, 1, 1)
|
root_layout.addWidget(self._status_label)
|
||||||
sequence_layout.addWidget(QLabel("Current combo"), 2, 0)
|
|
||||||
sequence_layout.addWidget(self._combo_label, 2, 1)
|
|
||||||
sequence_layout.addWidget(QLabel("TX antenna label"), 3, 0)
|
|
||||||
sequence_layout.addWidget(self._tx_antenna_label_input, 3, 1)
|
|
||||||
sequence_layout.addWidget(QLabel("RX antenna label"), 4, 0)
|
|
||||||
sequence_layout.addWidget(self._rx_antenna_label_input, 4, 1)
|
|
||||||
sequence_layout.addLayout(button_row, 5, 0, 1, 2)
|
|
||||||
|
|
||||||
self._capture_log = QPlainTextEdit(sequence_group)
|
def _build_preview_plot(self, root_layout: QVBoxLayout) -> None:
|
||||||
self._capture_log.setReadOnly(True)
|
"""Build trace preview plot used after each successful capture."""
|
||||||
self._capture_log.setPlaceholderText("Capture history per combo")
|
self._preview_plot = pg.PlotWidget(background="#101418")
|
||||||
sequence_layout.addWidget(self._capture_log, 6, 0, 1, 2)
|
self._preview_plot.showGrid(x=True, y=True, alpha=0.2)
|
||||||
|
self._preview_plot.setLabel("bottom", "Frequency", units="Hz")
|
||||||
layout.addWidget(sequence_group)
|
self._preview_plot.setLabel("left", "Magnitude", units="dB")
|
||||||
|
root_layout.addWidget(self._preview_plot, stretch=1)
|
||||||
self._status = QLabel("Ready", self)
|
|
||||||
layout.addWidget(self._status)
|
|
||||||
|
|
||||||
self._plot = pg.PlotWidget(background="#101418")
|
|
||||||
self._plot.showGrid(x=True, y=True, alpha=0.2)
|
|
||||||
self._plot.setLabel("bottom", "Frequency", units="Hz")
|
|
||||||
self._plot.setLabel("left", "Magnitude", units="dB")
|
|
||||||
layout.addWidget(self._plot, stretch=1)
|
|
||||||
|
|
||||||
def set_name(self) -> str:
|
def set_name(self) -> str:
|
||||||
"""Return requested target set name."""
|
"""Return requested target set name."""
|
||||||
@@ -220,19 +244,19 @@ class PreprocessDialog(QDialog):
|
|||||||
|
|
||||||
def set_status(self, message: str) -> None:
|
def set_status(self, message: str) -> None:
|
||||||
"""Set short human-readable status line."""
|
"""Set short human-readable status line."""
|
||||||
self._status.setText(message)
|
self._status_label.setText(message)
|
||||||
|
|
||||||
def draw_last_trace(self, trace: TraceData, title: str) -> None:
|
def draw_last_trace(self, trace: TraceData, title: str) -> None:
|
||||||
"""Draw the latest captured sweep trace in dB scale."""
|
"""Draw the latest captured sweep trace in dB scale."""
|
||||||
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12))
|
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12))
|
||||||
self._plot.clear()
|
self._preview_plot.clear()
|
||||||
self._plot.plot(
|
self._preview_plot.plot(
|
||||||
trace.frequency_hz,
|
trace.frequency_hz,
|
||||||
magnitude_db,
|
magnitude_db,
|
||||||
pen=pg.mkPen("#4cc9f0", width=1.8),
|
pen=pg.mkPen("#4cc9f0", width=1.8),
|
||||||
)
|
)
|
||||||
combo = trace.combo
|
combo = trace.combo
|
||||||
self._status.setText(
|
self._status_label.setText(
|
||||||
f"{title}: input={combo.input_pos}, output={combo.output_pos}, points={trace.frequency_hz.size}"
|
f"{title}: input={combo.input_pos}, output={combo.output_pos}, points={trace.frequency_hz.size}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,19 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from python_app.models.run_config_schema import ComboModel, RunConfigModel
|
from python_app.models.run_config_schema import ComboModel, RunConfigModel
|
||||||
from python_app.models.run_config_validation import as_dict, load_ring_payload, load_switch_payload
|
from python_app.models.run_config_validation import load_ring_payload, load_switch_payload
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dict(value: Any, context: str) -> dict[str, Any]:
|
||||||
|
"""Validate payload node is object-like, treating missing values as empty object."""
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(f"{context} must be a JSON object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||||
@@ -15,19 +22,19 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
# Schema carries only minimal-safe fallbacks; operational defaults live in run_config.json.
|
# Schema carries only minimal-safe fallbacks; operational defaults live in run_config.json.
|
||||||
model = RunConfigModel()
|
model = RunConfigModel()
|
||||||
|
|
||||||
radar_payload = as_dict(payload.get("radar"), "radar")
|
radar_payload = _as_dict(payload.get("radar"), "radar")
|
||||||
sweep_payload = as_dict(radar_payload.get("sweep"), "radar.sweep")
|
sweep_payload = _as_dict(radar_payload.get("sweep"), "radar.sweep")
|
||||||
switches_payload = as_dict(payload.get("switches"), "switches")
|
switches_payload = _as_dict(payload.get("switches"), "switches")
|
||||||
port1_payload = as_dict(switches_payload.get("port1"), "switches.port1")
|
port1_payload = _as_dict(switches_payload.get("port1"), "switches.port1")
|
||||||
port2_payload = as_dict(switches_payload.get("port2"), "switches.port2")
|
port2_payload = _as_dict(switches_payload.get("port2"), "switches.port2")
|
||||||
run_payload = as_dict(payload.get("run"), "run")
|
run_payload = _as_dict(payload.get("run"), "run")
|
||||||
preprocess_payload = as_dict(payload.get("preprocess"), "preprocess")
|
preprocess_payload = _as_dict(payload.get("preprocess"), "preprocess")
|
||||||
rings_payload = as_dict(payload.get("rings"), "rings")
|
rings_payload = _as_dict(payload.get("rings"), "rings")
|
||||||
raw_ring_payload = as_dict(rings_payload.get("raw"), "rings.raw")
|
raw_ring_payload = _as_dict(rings_payload.get("raw"), "rings.raw")
|
||||||
raw_tap_ring_payload = as_dict(rings_payload.get("raw_tap"), "rings.raw_tap")
|
raw_tap_ring_payload = _as_dict(rings_payload.get("raw_tap"), "rings.raw_tap")
|
||||||
pre_ring_payload = as_dict(rings_payload.get("preprocessed"), "rings.preprocessed")
|
pre_ring_payload = _as_dict(rings_payload.get("preprocessed"), "rings.preprocessed")
|
||||||
pre_tap_ring_payload = as_dict(rings_payload.get("preprocessed_tap"), "rings.preprocessed_tap")
|
pre_tap_ring_payload = _as_dict(rings_payload.get("preprocessed_tap"), "rings.preprocessed_tap")
|
||||||
result_ring_payload = as_dict(rings_payload.get("results"), "rings.results")
|
result_ring_payload = _as_dict(rings_payload.get("results"), "rings.results")
|
||||||
|
|
||||||
model.radar.model = str(radar_payload.get("model", model.radar.model))
|
model.radar.model = str(radar_payload.get("model", model.radar.model))
|
||||||
model.radar.serial = str(radar_payload.get("serial", model.radar.serial))
|
model.radar.serial = str(radar_payload.get("serial", model.radar.serial))
|
||||||
@@ -71,7 +78,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
model.combos = []
|
model.combos = []
|
||||||
if isinstance(combos_payload, list):
|
if isinstance(combos_payload, list):
|
||||||
for combo in combos_payload:
|
for combo in combos_payload:
|
||||||
combo_payload = as_dict(combo, "run.combos[]")
|
combo_payload = _as_dict(combo, "run.combos[]")
|
||||||
model.combos.append(
|
model.combos.append(
|
||||||
ComboModel(
|
ComboModel(
|
||||||
input=int(combo_payload.get("input", 0)),
|
input=int(combo_payload.get("input", 0)),
|
||||||
@@ -82,15 +89,6 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
|||||||
model.ensure_combos()
|
model.ensure_combos()
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
def load_run_config(path: Path) -> RunConfigModel:
|
|
||||||
"""Load JSON config from path and decode into :class:`RunConfigModel`."""
|
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise ValueError(f"Config root must be JSON object: {path}")
|
|
||||||
return run_config_from_dict(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||||
"""Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure."""
|
"""Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure."""
|
||||||
model.ensure_combos()
|
model.ensure_combos()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Facade module for run configuration schema, codec, and validation helpers."""
|
"""Facade module for run configuration schema, codec, and validation helpers."""
|
||||||
|
|
||||||
from python_app.models.run_config_codec import load_run_config, run_config_from_dict, run_config_to_dict
|
from python_app.models.run_config_codec import run_config_from_dict, run_config_to_dict
|
||||||
from python_app.models.run_config_schema import (
|
from python_app.models.run_config_schema import (
|
||||||
ComboModel,
|
ComboModel,
|
||||||
PreprocessModel,
|
PreprocessModel,
|
||||||
@@ -13,7 +13,6 @@ from python_app.models.run_config_schema import (
|
|||||||
SwitchModel,
|
SwitchModel,
|
||||||
)
|
)
|
||||||
from python_app.models.run_config_validation import (
|
from python_app.models.run_config_validation import (
|
||||||
as_dict,
|
|
||||||
load_ring_payload,
|
load_ring_payload,
|
||||||
load_switch_payload,
|
load_switch_payload,
|
||||||
parse_combos_from_text,
|
parse_combos_from_text,
|
||||||
@@ -29,9 +28,7 @@ __all__ = [
|
|||||||
"RunConfigModel",
|
"RunConfigModel",
|
||||||
"RuntimeModel",
|
"RuntimeModel",
|
||||||
"SwitchModel",
|
"SwitchModel",
|
||||||
"as_dict",
|
|
||||||
"load_ring_payload",
|
"load_ring_payload",
|
||||||
"load_run_config",
|
|
||||||
"load_switch_payload",
|
"load_switch_payload",
|
||||||
"parse_combos_from_text",
|
"parse_combos_from_text",
|
||||||
"run_config_from_dict",
|
"run_config_from_dict",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -131,9 +132,10 @@ class RunConfigModel:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def load_from_path(cls, path: Path) -> RunConfigModel:
|
def load_from_path(cls, path: Path) -> RunConfigModel:
|
||||||
"""Load JSON file from disk and decode into model."""
|
"""Load JSON file from disk and decode into model."""
|
||||||
from python_app.models.run_config_codec import load_run_config
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
return load_run_config(path)
|
raise ValueError(f"Config root must be JSON object: {path}")
|
||||||
|
return cls.from_dict(payload)
|
||||||
|
|
||||||
def clone(self) -> RunConfigModel:
|
def clone(self) -> RunConfigModel:
|
||||||
"""Create deep copy through codec round-trip."""
|
"""Create deep copy through codec round-trip."""
|
||||||
|
|||||||
@@ -6,16 +6,6 @@ from typing import Any
|
|||||||
|
|
||||||
from python_app.models.run_config_schema import ComboModel, RingEndpointModel, SwitchModel
|
from python_app.models.run_config_schema import ComboModel, RingEndpointModel, SwitchModel
|
||||||
|
|
||||||
|
|
||||||
def as_dict(value: Any, context: str) -> dict[str, Any]:
|
|
||||||
"""Validate that a payload node is a JSON object and return it."""
|
|
||||||
if value is None:
|
|
||||||
return {}
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError(f"{context} must be a JSON object")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def load_switch_payload(
|
def load_switch_payload(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
target: SwitchModel,
|
target: SwitchModel,
|
||||||
|
|||||||
+5
-5
@@ -57,27 +57,27 @@
|
|||||||
"rings": {
|
"rings": {
|
||||||
"raw": {
|
"raw": {
|
||||||
"name": "/radar_raw",
|
"name": "/radar_raw",
|
||||||
"capacity": 64,
|
"capacity": 50,
|
||||||
"slot_size_bytes": 2097152
|
"slot_size_bytes": 2097152
|
||||||
},
|
},
|
||||||
"raw_tap": {
|
"raw_tap": {
|
||||||
"name": "/radar_raw_tap",
|
"name": "/radar_raw_tap",
|
||||||
"capacity": 64,
|
"capacity": 50,
|
||||||
"slot_size_bytes": 2097152
|
"slot_size_bytes": 2097152
|
||||||
},
|
},
|
||||||
"preprocessed": {
|
"preprocessed": {
|
||||||
"name": "/radar_preprocessed",
|
"name": "/radar_preprocessed",
|
||||||
"capacity": 64,
|
"capacity": 50,
|
||||||
"slot_size_bytes": 2097152
|
"slot_size_bytes": 2097152
|
||||||
},
|
},
|
||||||
"preprocessed_tap": {
|
"preprocessed_tap": {
|
||||||
"name": "/radar_preprocessed_tap",
|
"name": "/radar_preprocessed_tap",
|
||||||
"capacity": 64,
|
"capacity": 50,
|
||||||
"slot_size_bytes": 2097152
|
"slot_size_bytes": 2097152
|
||||||
},
|
},
|
||||||
"results": {
|
"results": {
|
||||||
"name": "/radar_results",
|
"name": "/radar_results",
|
||||||
"capacity": 64,
|
"capacity": 50,
|
||||||
"slot_size_bytes": 2097152
|
"slot_size_bytes": 2097152
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user