little refactoring done

This commit is contained in:
Ayzen
2026-03-10 17:33:25 +03:00
parent 9c745f304e
commit 1f715ce22c
10 changed files with 520 additions and 245 deletions
+71 -23
View File
@@ -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:
+111 -79
View File
@@ -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."""
+87 -63
View File
@@ -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}"
) )
+24 -26
View File
@@ -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 -4
View File
@@ -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",
+5 -3
View File
@@ -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
View File
@@ -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
} }
} }