new GPR parameters and some UI fixes

This commit is contained in:
Ayzen
2026-04-07 12:55:26 +03:00
parent 4b78c2808d
commit 202993325d
27 changed files with 1494 additions and 168 deletions
@@ -36,6 +36,8 @@ struct ProcessingLiveConfig {
float gpr_stop_freq_mhz = 6000.0F;
float gpr_speed_m_s = 0.0F;
float gpr_look_angle_deg = 0.0F;
float gpr_snr_thresh = 4.5F;
float gpr_snr_comp_max = 25.0F;
bool gpr_background_subtract_enabled = true;
std::uint32_t gpr_background_mean_count = 10U;
std::uint64_t history_command_seq = 0;
@@ -211,6 +211,18 @@ using Json = nlohmann::json;
}
config.gpr_look_angle_deg = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_snr_thresh"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_snr_thresh must be number");
}
config.gpr_snr_thresh = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_snr_comp_max"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_snr_comp_max must be number");
}
config.gpr_snr_comp_max = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_background_subtract_enabled"); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error("processing.gpr_background_subtract_enabled must be bool");
@@ -20,8 +20,6 @@ constexpr double kPi = 3.14159265358979323846;
constexpr double kSpeedOfLightMetersPerSec = 299'792'458.0;
constexpr double kAccumulatorXMarginM = 2.0;
constexpr double kAccumulatorZMinM = 0.20;
constexpr double kSnrThresh = 4.5;
constexpr double kSnrCompMax = 25.0;
constexpr std::size_t kGridWidth = 300U;
constexpr std::size_t kGridHeight = 300U;
constexpr double kGaussianSigma = 3.0;
@@ -1197,6 +1195,8 @@ auto GprProcessor::process_collection(
kSpeedOfLightMetersPerSec / std::sqrt(std::max(1e-6, static_cast<double>(run_config.gpr.relative_permittivity)));
const double start_hz = static_cast<double>(live_config.gpr_start_freq_mhz) * 1'000'000.0;
const double stop_hz = static_cast<double>(live_config.gpr_stop_freq_mhz) * 1'000'000.0;
const double snr_thresh = std::max(0.0, static_cast<double>(live_config.gpr_snr_thresh));
const double snr_comp_max = std::max(0.0, static_cast<double>(live_config.gpr_snr_comp_max));
std::unordered_map<PairKey, AscanResult> ascans_by_pair{};
double bandwidth_hz = 0.0;
@@ -1248,7 +1248,8 @@ auto GprProcessor::process_collection(
4.0,
std::floor(((velocity_mps / (2.0 * bandwidth_hz)) / z_step) * 0.7)
));
const auto peak_indices = find_peak_indices(ascan.amplitude, min_index, max_index, noise * kSnrThresh, min_distance);
const auto peak_indices =
find_peak_indices(ascan.amplitude, min_index, max_index, noise * snr_thresh, min_distance);
auto& peaks = peaks_by_pair[key];
peaks.reserve(peak_indices.size());
for (const auto peak_index : peak_indices) {
@@ -1259,7 +1260,7 @@ auto GprProcessor::process_collection(
attenuation / attenuation_at_depth(tx_index, rx_index, 3.0, selection.x_tx, selection.x_rx);
const double snr_comp = std::min(
snr_raw / (std::pow(attenuation_norm, static_cast<double>(live_config.gpr_comp_power)) + 1e-12),
kSnrCompMax
snr_comp_max
);
peaks.push_back(PeakRecord{
.z_app = z_app,
+3
View File
@@ -123,6 +123,7 @@ class AppWindow(
key: str(preprocess_asset_model(self._defaults_config, key).set_name)
for key in VISIBLE_PREPROCESS_ASSET_KEYS
}
self._selected_preprocess_radar_key = self._radar_key(self._defaults_config)
def _init_capture_state(self) -> None:
"""Initialize one-shot capture and sequence-control flags."""
@@ -161,6 +162,8 @@ class AppWindow(
self._gpr_selected_geometry = None
self._phase_viewbox = None
self._history_run_signature = None
self._processor_run_signature = None
self._active_processing_mode = "pass_through"
self._radar_limits: dict[str, float | int] | None = None
def _history_limit_from_config(self) -> int:
@@ -11,6 +11,7 @@ from PyQt6.QtCore import QSignalBlocker
from PyQt6.QtWidgets import QFileDialog
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
from python_app.models.gui_profile_model import (
GuiBscanStateModel,
GuiDataActionsStateModel,
@@ -37,6 +38,14 @@ from python_app.storage.npz_store import radar_key_from_config
class AppWindowConfigMixin:
"""Builds runtime config models from current UI state."""
def _validate_processing_mode_selection(self, mode: str) -> None:
"""Validate requested processing mode against current stable/live GUI state."""
validate_processing_mode_constraints(
mode,
self._build_config(),
self._live_processing_config(),
)
@staticmethod
def _parse_csv_int_list(text: str) -> list[int]:
"""Parse comma-separated integer selection list."""
@@ -117,6 +126,16 @@ class AppWindowConfigMixin:
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
return ",".join(str(value) for value in values)
@staticmethod
def _default_gpr_visible_x_bounds_from_config(config: RunConfigModel) -> tuple[float, float]:
"""Build default visible X-range for GPR object-only rendering."""
x_values = [float(entry.x_m) for entry in config.gpr.tx_geometry]
x_values.extend(float(entry.x_m) for entry in config.gpr.rx_geometry)
if not x_values:
return (-2.0, 2.0)
margin_m = 2.0
return (min(x_values) - margin_m, max(x_values) + margin_m)
@staticmethod
def _history_limit_for_config(config: RunConfigModel) -> int:
"""Return unified GUI history limit derived from config ring capacities."""
@@ -133,6 +152,7 @@ class AppWindowConfigMixin:
"""Build fallback GUI-only defaults for a stable run config."""
default_combo = config.combos[0] if config.combos else ComboModel(input=0, output=0)
default_mode = "single" if len(config.combos) == 1 else "text"
default_gpr_x_min_m, default_gpr_x_max_m = self._default_gpr_visible_x_bounds_from_config(config)
return GuiStateModel(
switches=GuiSwitchStateModel(
combo_mode=default_mode,
@@ -167,8 +187,16 @@ class AppWindowConfigMixin:
stop_freq_mhz=6000.0,
speed_m_s=0.0,
look_angle_deg=0.0,
snr_thresh=4.5,
snr_comp_max=25.0,
background_subtract_enabled=True,
background_mean_count=10,
render_mode="heatmap",
min_visible_pair_count=1,
visible_x_min_m=default_gpr_x_min_m,
visible_x_max_m=default_gpr_x_max_m,
visible_z_min_m=0.0,
visible_z_max_m=14.0,
),
),
data_actions=GuiDataActionsStateModel(
@@ -221,8 +249,16 @@ class AppWindowConfigMixin:
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
speed_m_s=float(self._gpr_speed_m_s.value()),
look_angle_deg=float(self._gpr_look_angle_deg.value()),
snr_thresh=float(self._gpr_snr_thresh.value()),
snr_comp_max=float(self._gpr_snr_comp_max.value()),
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
background_mean_count=int(self._gpr_background_mean_count.value()),
render_mode=self._gpr_render_mode.currentText(),
min_visible_pair_count=int(self._gpr_min_visible_pair_count.value()),
visible_x_min_m=float(self._gpr_visible_x_min_m.value()),
visible_x_max_m=float(self._gpr_visible_x_max_m.value()),
visible_z_min_m=float(self._gpr_visible_z_min_m.value()),
visible_z_max_m=float(self._gpr_visible_z_max_m.value()),
),
),
data_actions=GuiDataActionsStateModel(
@@ -404,8 +440,16 @@ class AppWindowConfigMixin:
self._gpr_stop_freq_mhz,
self._gpr_speed_m_s,
self._gpr_look_angle_deg,
self._gpr_snr_thresh,
self._gpr_snr_comp_max,
self._gpr_background_subtract_enabled,
self._gpr_background_mean_count,
self._gpr_render_mode,
self._gpr_min_visible_pair_count,
self._gpr_visible_x_min_m,
self._gpr_visible_x_max_m,
self._gpr_visible_z_min_m,
self._gpr_visible_z_max_m,
self._save_count,
self._save_path_input,
self._save_name_input,
@@ -466,10 +510,18 @@ class AppWindowConfigMixin:
self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
self._gpr_speed_m_s.setValue(float(gui_state.processing.gpr.speed_m_s))
self._gpr_look_angle_deg.setValue(float(gui_state.processing.gpr.look_angle_deg))
self._gpr_snr_thresh.setValue(float(gui_state.processing.gpr.snr_thresh))
self._gpr_snr_comp_max.setValue(float(gui_state.processing.gpr.snr_comp_max))
self._gpr_background_subtract_enabled.setChecked(
bool(gui_state.processing.gpr.background_subtract_enabled)
)
self._gpr_background_mean_count.setValue(int(gui_state.processing.gpr.background_mean_count))
self._set_combo_current_text(self._gpr_render_mode, gui_state.processing.gpr.render_mode)
self._gpr_min_visible_pair_count.setValue(int(gui_state.processing.gpr.min_visible_pair_count))
self._gpr_visible_x_min_m.setValue(float(gui_state.processing.gpr.visible_x_min_m))
self._gpr_visible_x_max_m.setValue(float(gui_state.processing.gpr.visible_x_max_m))
self._gpr_visible_z_min_m.setValue(float(gui_state.processing.gpr.visible_z_min_m))
self._gpr_visible_z_max_m.setValue(float(gui_state.processing.gpr.visible_z_max_m))
self._save_count.setValue(int(gui_state.data_actions.save_count))
self._save_path_input.setText(str(gui_state.data_actions.save_path))
@@ -478,6 +530,7 @@ class AppWindowConfigMixin:
self._defaults_config = config
self._gui_defaults = gui_state
self._selected_preprocess_sets = selected_preprocess_sets
self._selected_preprocess_radar_key = self._radar_key(config)
self._preprocess_set_name = str(gui_state.preprocess_dialog.set_name)
self._apply_history_limit_from_config(config)
self._gpr_geometry_signature = None
@@ -552,6 +605,18 @@ class AppWindowConfigMixin:
power_dbm=config.radar.sweep.power_dbm,
)
def _radar_key_from_ui(self) -> str:
"""Build current radar key directly from radar widgets only."""
return radar_key_from_config(
model_name=self._defaults_config.radar.model,
serial=self._serial_input.text().strip(),
sweep_start_hz=float(self._start_hz_input.text().strip()),
sweep_stop_hz=float(self._stop_hz_input.text().strip()),
sweep_points=int(self._points_input.text().strip()),
ifbw_hz=float(self._ifbw_input.text().strip()),
power_dbm=float(self._power_input.text().strip()),
)
def _live_processing_config(self, *, history_command: str = "none") -> ProcessingLiveConfig:
"""Build live processing config from current processing widgets."""
self._sync_bscan_frequency_limits_with_radar()
@@ -580,6 +645,8 @@ class AppWindowConfigMixin:
gpr_stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
gpr_speed_m_s=float(self._gpr_speed_m_s.value()),
gpr_look_angle_deg=float(self._gpr_look_angle_deg.value()),
gpr_snr_thresh=float(self._gpr_snr_thresh.value()),
gpr_snr_comp_max=float(self._gpr_snr_comp_max.value()),
gpr_background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
gpr_background_mean_count=int(self._gpr_background_mean_count.value()),
history_command_seq=int(self._history_command_seq),
@@ -615,8 +682,33 @@ class AppWindowConfigMixin:
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update live processing settings", exc)
def _on_gpr_visual_settings_changed(self, *_args) -> None:
"""Redraw current GPR result using updated GUI-only render settings."""
if self._processing_mode.currentText() != "gpr":
return
try:
if self._result_history and self._draw_results(self._result_history[-1]):
return
self._clear_gpr_plot()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to update GPR render settings", exc)
def _on_processing_mode_changed(self, mode: str) -> None:
"""Switch processing parameter page and refresh corresponding visualization."""
previous_mode = getattr(self, "_active_processing_mode", "pass_through")
if mode != previous_mode:
try:
self._validate_processing_mode_selection(mode)
except Exception as exc: # noqa: BLE001
with QSignalBlocker(self._processing_mode):
self._set_combo_current_text(self._processing_mode, previous_mode)
self._show_error(
f"Cannot switch processing mode to {mode}",
details=str(exc) or type(exc).__name__,
)
return
self._active_processing_mode = mode
mode_to_page = {
"pass_through": 0,
"bscan": 1,
@@ -655,8 +747,12 @@ class AppWindowConfigMixin:
f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, "
f"speed={self._gpr_speed_m_s.value():g} m/s, "
f"look_angle={self._gpr_look_angle_deg.value():g} deg, "
f"snr_thresh={self._gpr_snr_thresh.value():g}, "
f"snr_comp_max={self._gpr_snr_comp_max.value():g}, "
f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
f"mean_count={self._gpr_background_mean_count.value()})"
f"mean_count={self._gpr_background_mean_count.value()}, "
f"render_mode={self._gpr_render_mode.currentText()}, "
f"min_pairs={self._gpr_min_visible_pair_count.value()})"
)
def _clear_history_mode_caches(self) -> None:
@@ -665,6 +761,7 @@ class AppWindowConfigMixin:
self._clear_bscan_plot_history()
if hasattr(self, "_bscan_plot"):
self._bscan_plot.clear()
self._configure_bscan_plot_axes()
self._clear_trace_plots()
if hasattr(self, "_gpr_plot"):
self._clear_gpr_plot()
@@ -676,6 +773,7 @@ class AppWindowConfigMixin:
self._sync_bscan_history_from_results()
if not self._draw_bscan_heatmap_from_history():
self._bscan_plot.clear()
self._configure_bscan_plot_axes()
return
if self._processing_mode.currentText() == "gpr":
if self._result_history and self._draw_results(self._result_history[-1]):
@@ -686,10 +784,12 @@ class AppWindowConfigMixin:
self._draw_results(self._result_history[-1])
return
self._bscan_plot.clear()
self._configure_bscan_plot_axes()
self._clear_trace_plots()
def _on_radar_identity_changed(self, *_args) -> None:
"""Refresh device limits when radar identity/mode changes."""
self._reset_preprocess_selection_after_radar_key_change()
if self._radar_mode.currentText() != "native":
self._apply_radar_limits_to_ui(None)
return
@@ -699,6 +799,7 @@ class AppWindowConfigMixin:
def _on_radar_sweep_limits_changed(self) -> None:
"""Clamp processing frequency bounds after sweep start/stop edits."""
self._reset_preprocess_selection_after_radar_key_change()
if self._sync_processing_frequency_limits_with_radar():
self._on_processing_live_settings_changed()
@@ -21,6 +21,10 @@ from python_app.orchestration.shm_reader import ShmRingReader
class AppWindowPipelineMixin:
"""Controls start/stop, readers, and periodic polling of pipeline rings."""
def _processor_requires_restart(self, run_signature: tuple[object, ...]) -> bool:
"""Return whether alive `data_processor` was started with different stable run settings."""
return self._supervisor.is_processor_running() and self._processor_run_signature != run_signature
def _start_single_capture(self) -> None:
"""Start acquisition in single-capture mode."""
self._start_run(single_capture=True)
@@ -40,11 +44,15 @@ class AppWindowPipelineMixin:
try:
processor_was_running = self._supervisor.is_processor_running()
config = self._build_config()
run_signature = self._build_run_history_signature(config)
if self._processor_requires_restart(run_signature):
self._log("Restarting data_processor because stable run settings changed")
self._stop_all_processes()
processor_was_running = False
if not processor_was_running:
self._reset_runtime_history()
self._validate_processing_mode_constraints(config)
run_signature = self._build_run_history_signature(config)
radar_key = self._radar_key(config)
missing_assets = [
@@ -102,6 +110,7 @@ class AppWindowPipelineMixin:
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name)
self._result_reader = ShmRingReader(config.rings.results.name)
self._processor_run_signature = run_signature
self._single_capture_active = single_capture
self._single_capture_start_ns = None
self._single_capture_seen_raw = False
@@ -138,6 +147,7 @@ class AppWindowPipelineMixin:
return
was_running = self._supervisor.is_running()
processor_only_running = self._supervisor.is_processor_running() and not was_running
if was_running:
self._stop_run()
@@ -145,6 +155,15 @@ class AppWindowPipelineMixin:
if self._radar_mode.currentText() == "native":
self._refresh_radar_limits_from_device()
config = self._build_config()
run_signature = self._build_run_history_signature(config)
if processor_only_running and self._processor_requires_restart(run_signature):
self._stop_all_processes()
self._reset_runtime_history()
self._history_run_signature = None
self._log(
"Stable run settings changed; stopped data_processor because cached history no longer "
"matches the current config"
)
self._prepare_radar_for_native_acquisition(config)
self._log(
"Radar settings applied: "
@@ -213,6 +232,7 @@ class AppWindowPipelineMixin:
self._single_capture_start_ns = None
self._single_capture_seen_raw = False
self._single_capture_target_collection_id = None
self._processor_run_signature = None
self._update_history_indicator()
self._status_label.setText("Status: idle")
if was_running:
@@ -359,6 +359,249 @@ class AppWindowPlotMixin:
self._sync_bscan_history_from_results()
return self._draw_bscan_heatmap_from_history()
def _configure_bscan_plot_axes(self) -> None:
"""Apply persistent B-scan plot axis labels and base view settings."""
plot = self._bscan_plot
plot_item = plot.getPlotItem()
plot_item.showAxis("left", show=True)
plot_item.showAxis("bottom", show=True)
plot.setLabel("bottom", "Sweep #")
plot.setLabel("left", "Range", units="m")
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=False, y=False)
def _draw_bscan_heatmap_from_history(self) -> bool:
"""Render B-scan heatmap from currently cached history arrays."""
display_key = self._pick_bscan_display_key()
@@ -391,13 +634,7 @@ class AppWindowPlotMixin:
image_item.setLevels(self._bscan_levels(sweeps, axis_mode))
self._bscan_plot.clear()
view_box = self._bscan_plot.getViewBox()
view_box.invertY(True)
view_box.enableAutoRange(x=False, y=False)
self._bscan_plot.getPlotItem().showAxis("left", show=True)
self._bscan_plot.getPlotItem().showAxis("bottom", show=True)
self._bscan_plot.setLabel("bottom", "Sweep #")
self._bscan_plot.setLabel("left", "Depth", units="m")
self._configure_bscan_plot_axes()
self._bscan_plot.addItem(image_item)
self._bscan_plot.setXRange(x_min, x_max, padding=0.02)
self._bscan_plot.setYRange(depth_min, depth_max, padding=0.02)
@@ -538,6 +775,7 @@ class AppWindowPlotMixin:
"""Clear latest GPR plot surface."""
if not hasattr(self, "_gpr_plot"):
return
self._configure_gpr_plot_axes()
self._clear_gpr_point_labels()
self._clear_gpr_region_labels()
self._clear_gpr_region_masks()
@@ -557,23 +795,27 @@ class AppWindowPlotMixin:
self._gpr_region_centers_item.hide()
self._gpr_plot.setTitle(f"GPR {self._gpr_config_mode.currentText()}")
def _ensure_gpr_plot_items(self) -> None:
"""Create persistent GPR plot items once and reuse them on redraw."""
if self._gpr_image_item is not None:
return
def _configure_gpr_plot_axes(self) -> None:
"""Apply persistent GPR plot axis labels and base view settings."""
plot = self._gpr_plot
plot_item = plot.getPlotItem()
plot_item.showAxis("left", show=True)
plot_item.showAxis("bottom", show=True)
plot_item.setClipToView(True)
plot.setLabel("bottom", "X", units="m")
plot.setLabel("left", "Depth", units="m")
plot.setLabel("left", "Range", units="m")
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=False, y=False)
def _ensure_gpr_plot_items(self) -> None:
"""Create persistent GPR plot items once and reuse them on redraw."""
if self._gpr_image_item is not None:
return
plot = self._gpr_plot
self._configure_gpr_plot_axes()
if self._gpr_lookup_table is None:
self._gpr_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
@@ -694,6 +936,12 @@ class AppWindowPlotMixin:
return self._gpr_selected_geometry
def _draw_gpr_map(self, collection: ResultCollection) -> bool:
"""Draw latest collection-level GPR plot according to current render mode."""
if self._gpr_render_mode.currentText() == "objects_only":
return self._draw_gpr_objects_only(collection)
return self._draw_gpr_heatmap(collection)
def _draw_gpr_heatmap(self, collection: ResultCollection) -> bool:
"""Draw latest collection-level GPR accumulator and annotations."""
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
if accumulator_payload is None:
@@ -729,36 +977,9 @@ class AppWindowPlotMixin:
self._gpr_image_item.show()
plot.setXRange(x_min, x_max, padding=0.02)
plot.setYRange(min(0.0, y_min), y_max, padding=0.02)
plot.setYRange(self._gpr_display_y_min(y_min, y_max), y_max, padding=0.02)
x_tx, x_rx = self._selected_gpr_geometry()
if x_tx.size > 0:
self._gpr_tx_item.setData(
x=x_tx,
y=np.zeros_like(x_tx),
symbol="t",
size=13,
brush=pg.mkBrush("#ff595e"),
pen=pg.mkPen("#ffca3a", width=1.0),
)
self._gpr_tx_item.show()
else:
self._gpr_tx_item.setData(x=[], y=[])
self._gpr_tx_item.hide()
if x_rx.size > 0:
self._gpr_rx_item.setData(
x=x_rx,
y=np.zeros_like(x_rx),
symbol="t1",
size=13,
brush=pg.mkBrush("#4cc9f0"),
pen=pg.mkPen("#e0fbfc", width=1.0),
)
self._gpr_rx_item.show()
else:
self._gpr_rx_item.setData(x=[], y=[])
self._gpr_rx_item.hide()
self._draw_gpr_geometry_markers()
points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4)
if points_payload is not None and np.asarray(points_payload.table).size > 0:
@@ -824,6 +1045,242 @@ class AppWindowPlotMixin:
plot.setUpdatesEnabled(True)
return True
@staticmethod
def _normalized_display_range(start: float, stop: float, *, minimum_span: float = 0.1) -> tuple[float, float]:
"""Return ordered display bounds with a non-zero span."""
lower = min(float(start), float(stop))
upper = max(float(start), float(stop))
if upper - lower >= minimum_span:
return lower, upper
center = 0.5 * (lower + upper)
half_span = 0.5 * minimum_span
return center - half_span, center + half_span
def _gpr_visible_object_bounds(self) -> tuple[float, float, float, float]:
"""Return normalized object-only visible X/Z bounds from GUI controls."""
x_min, x_max = self._normalized_display_range(
float(self._gpr_visible_x_min_m.value()),
float(self._gpr_visible_x_max_m.value()),
minimum_span=0.1,
)
z_min, z_max = self._normalized_display_range(
float(self._gpr_visible_z_min_m.value()),
float(self._gpr_visible_z_max_m.value()),
minimum_span=0.1,
)
return x_min, x_max, z_min, z_max
@staticmethod
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
"""Return lower GPR display bound with a small negative margin for antenna markers."""
lower = min(0.0, float(z_min))
span = max(float(z_max) - float(z_min), 1e-6)
marker_margin = max(span * 0.03, 0.06)
return lower - marker_margin
def _draw_gpr_geometry_markers(self) -> None:
"""Render selected Tx/Rx geometry markers on current GPR plot."""
x_tx, x_rx = self._selected_gpr_geometry()
if x_tx.size > 0:
self._gpr_tx_item.setData(
x=x_tx,
y=np.zeros_like(x_tx),
symbol="t",
size=13,
brush=pg.mkBrush("#ff595e"),
pen=pg.mkPen("#ffca3a", width=1.0),
)
self._gpr_tx_item.show()
else:
self._gpr_tx_item.setData(x=[], y=[])
self._gpr_tx_item.hide()
if x_rx.size > 0:
self._gpr_rx_item.setData(
x=x_rx,
y=np.zeros_like(x_rx),
symbol="t1",
size=13,
brush=pg.mkBrush("#4cc9f0"),
pen=pg.mkPen("#e0fbfc", width=1.0),
)
self._gpr_rx_item.show()
else:
self._gpr_rx_item.setData(x=[], y=[])
self._gpr_rx_item.hide()
@staticmethod
def _format_gpr_object_label(x_m: float, z_m: float, pair_count: float) -> str:
"""Format object-only annotation text with pair count and coordinates."""
return f"{int(round(pair_count))} | x={x_m:.1f} | z={z_m:.1f}"
@staticmethod
def _expanded_scene_rect(rect: QRectF, *, padding_px: float = 4.0) -> QRectF:
"""Return scene rect padded to keep labels visually separated."""
return rect.adjusted(-padding_px, -padding_px, padding_px, padding_px)
@staticmethod
def _scene_rect_intersects_any(rect: QRectF, occupied_rects: list[QRectF]) -> bool:
"""Return whether candidate label rect intersects any already placed label."""
return any(rect.intersects(occupied_rect) for occupied_rect in occupied_rects)
@staticmethod
def _gpr_object_label_candidates(
x_m: float,
z_m: float,
*,
x_span: float,
z_span: float,
) -> list[tuple[float, float, tuple[float, float]]]:
"""Return candidate label placements around one object."""
x_offset = max(x_span * 0.015, 0.02)
z_offset = max(z_span * 0.02, 0.02)
return [
(x_m + x_offset, z_m - z_offset, (0.0, 1.0)),
(x_m + x_offset, z_m + z_offset, (0.0, 0.0)),
(x_m - x_offset, z_m - z_offset, (1.0, 1.0)),
(x_m - x_offset, z_m + z_offset, (1.0, 0.0)),
(x_m, z_m - (z_offset * 1.35), (0.5, 1.0)),
(x_m, z_m + (z_offset * 1.35), (0.5, 0.0)),
(x_m + (x_offset * 2.2), z_m - (z_offset * 1.5), (0.0, 1.0)),
(x_m + (x_offset * 2.2), z_m + (z_offset * 1.5), (0.0, 0.0)),
(x_m - (x_offset * 2.2), z_m - (z_offset * 1.5), (1.0, 1.0)),
(x_m - (x_offset * 2.2), z_m + (z_offset * 1.5), (1.0, 0.0)),
]
def _place_gpr_object_label(
self,
*,
label: pg.TextItem,
x_m: float,
z_m: float,
x_span: float,
z_span: float,
occupied_scene_rects: list[QRectF],
) -> None:
"""Place one object label using the first non-overlapping candidate position."""
last_rect: QRectF | None = None
for label_x, label_z, anchor in self._gpr_object_label_candidates(
x_m,
z_m,
x_span=x_span,
z_span=z_span,
):
label.setAnchor(anchor)
label.setPos(label_x, label_z)
candidate_rect = self._expanded_scene_rect(label.sceneBoundingRect())
last_rect = candidate_rect
if not self._scene_rect_intersects_any(candidate_rect, occupied_scene_rects):
occupied_scene_rects.append(candidate_rect)
return
if last_rect is not None:
occupied_scene_rects.append(last_rect)
def _gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
"""Return object rows as `[x_m, z_m, pair_count]` from current GPR result payload."""
points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4)
if points_payload is not None:
points = np.asarray(points_payload.table, dtype=np.float32)
if points.ndim == 2 and points.shape[1] >= 3:
return points[:, :3]
centers_payload = self._collection_payload_by_name(collection, "gpr_region_centers", kind=4)
if centers_payload is not None:
centers = np.asarray(centers_payload.table, dtype=np.float32)
if centers.ndim == 2 and centers.shape[1] >= 3:
return centers[:, :3]
return np.zeros((0, 3), dtype=np.float32)
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
"""Return object rows filtered by minimum pair count and visible X/Z bounds."""
rows = self._gpr_object_rows(collection)
if rows.size == 0:
return rows
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
min_pair_count = float(self._gpr_min_visible_pair_count.value())
finite_mask = np.all(np.isfinite(rows[:, :3]), axis=1)
visible_mask = (
finite_mask
& (rows[:, 2] >= min_pair_count)
& (rows[:, 0] >= x_min)
& (rows[:, 0] <= x_max)
& (rows[:, 1] >= z_min)
& (rows[:, 1] <= z_max)
)
return rows[visible_mask]
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
"""Draw only detected GPR objects inside configured X/Z bounds."""
accumulator_payload = self._collection_payload_by_name(collection, "gpr_accumulator", kind=3)
object_rows = self._filtered_gpr_object_rows(collection)
if accumulator_payload is None and object_rows.size == 0:
self._clear_gpr_plot()
return False
x_min, x_max, z_min, z_max = self._gpr_visible_object_bounds()
plot = self._gpr_plot
plot.setUpdatesEnabled(False)
try:
self._ensure_gpr_plot_items()
plot.getViewBox().invertY(False)
self._clear_gpr_point_labels()
self._clear_gpr_region_labels()
self._clear_gpr_region_masks()
self._gpr_image_item.hide()
self._draw_gpr_geometry_markers()
self._gpr_region_centers_item.setData(x=[], y=[])
self._gpr_region_centers_item.hide()
if object_rows.size > 0:
self._gpr_points_item.setData(
x=object_rows[:, 0],
y=object_rows[:, 1],
symbol="o",
size=18,
brush=pg.mkBrush("#ff4d4f"),
pen=pg.mkPen("#ff4d4f", width=1.6),
)
self._gpr_points_item.show()
occupied_scene_rects: list[QRectF] = []
x_span = x_max - x_min
z_span = z_max - z_min
for x_value, z_value, pair_count in object_rows:
label = pg.TextItem(
text=self._format_gpr_object_label(
float(x_value),
float(z_value),
float(pair_count),
),
color="#ffd6d9",
anchor=(0.0, 1.0),
)
label.setZValue(40)
plot.addItem(label)
self._place_gpr_object_label(
label=label,
x_m=float(x_value),
z_m=float(z_value),
x_span=x_span,
z_span=z_span,
occupied_scene_rects=occupied_scene_rects,
)
self._gpr_point_labels.append(label)
else:
self._gpr_points_item.setData(x=[], y=[])
self._gpr_points_item.hide()
plot.setXRange(x_min, x_max, padding=0.0)
plot.setYRange(self._gpr_display_y_min(z_min, z_max), z_max, padding=0.0)
plot.setTitle(f"GPR {self._gpr_config_mode.currentText()} Objects Only")
finally:
plot.setUpdatesEnabled(True)
return True
def _result_collection_has_trace(self, collection: ResultCollection) -> bool:
"""Return `True` when collection contains at least one trace payload."""
if collection.collection_payloads:
@@ -15,6 +15,67 @@ from python_app.workflows.sequential_capture_workflow import SequentialCaptureSe
class AppWindowPreprocessMixin:
"""Handles preprocess set management and sequential capture workflows."""
@staticmethod
def _capture_log_entries_for_session(session: SequentialCaptureSession) -> list[str]:
"""Build capture-log rows from current session traces."""
display_name = preprocess_asset_display_name(session.kind)
total_count = session.state().total_count
entries: list[str] = []
for index, trace in enumerate(session.captured_traces(), start=1):
entries.append(
f"{display_name}: {index}/{total_count} | "
f"input={trace.combo.input_pos} output={trace.combo.output_pos}"
)
return entries
def _available_preprocess_sets_for_radar_key(self, radar_key: str) -> dict[str, list[str]]:
"""Load available preprocess-set names for one radar key."""
return {
key: self._store.list_sets(PREPROCESS_ASSET_SPECS[key].set_kind, radar_key)
for key in VISIBLE_PREPROCESS_ASSET_KEYS
}
def _reset_preprocess_selection_after_radar_key_change(self) -> None:
"""Clear selected preprocess sets when radar-key-defining settings change."""
try:
radar_key = self._radar_key_from_ui()
except Exception:
return
previous_radar_key = getattr(self, "_selected_preprocess_radar_key", radar_key)
if radar_key == previous_radar_key:
return
self._selected_preprocess_radar_key = radar_key
cleared = {
key: value
for key, value in self._selected_preprocess_sets.items()
if value
}
if not cleared:
if self._preprocess_dialog is not None:
self._refresh_sets()
return
self._selected_preprocess_sets = {
key: ""
for key in VISIBLE_PREPROCESS_ASSET_KEYS
}
self._refresh_preprocess_summary_labels()
if self._preprocess_dialog is not None:
self._preprocess_dialog.set_selected_sets(self._selected_preprocess_sets, emit_signal=False)
self._refresh_sets()
cleared_summary = ", ".join(
f"{preprocess_asset_display_name(key)}={value}"
for key, value in cleared.items()
)
self._log(
"Preprocess selection reset after radar settings changed: "
f"{previous_radar_key} -> {radar_key}; cleared {cleared_summary}"
)
def _open_preprocess_panel(self) -> None:
"""Open preprocessing dialog and refresh available sets."""
dialog = self._ensure_preprocess_dialog()
@@ -42,6 +103,9 @@ class AppWindowPreprocessMixin:
dialog.selection_changed.connect(self._on_preprocess_selection_changed)
dialog.start_sequence_requested.connect(self._start_capture_sequence)
dialog.capture_next_requested.connect(self._capture_next_combo)
dialog.capture_all_requested.connect(self._capture_all_remaining)
dialog.undo_last_requested.connect(self._undo_last_capture)
dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence)
dialog.abort_sequence_requested.connect(self._abort_capture_sequence)
self._update_capture_dialog_state()
return dialog
@@ -72,14 +136,11 @@ class AppWindowPreprocessMixin:
def _refresh_sets(self) -> None:
"""Refresh preprocess set lists for current radar key."""
config = self._build_config()
radar_key = self._radar_key(config)
radar_key = self._radar_key_from_ui()
self._selected_preprocess_radar_key = radar_key
dialog = self._ensure_preprocess_dialog()
available_sets = {
key: self._store.list_sets(PREPROCESS_ASSET_SPECS[key].set_kind, radar_key)
for key in VISIBLE_PREPROCESS_ASSET_KEYS
}
available_sets = self._available_preprocess_sets_for_radar_key(radar_key)
dialog.set_available_sets(available_sets)
unavailable_selections: list[str] = []
@@ -137,7 +198,9 @@ class AppWindowPreprocessMixin:
self._capture_session = session
dialog.clear_capture_log()
dialog.reset_preview()
dialog.set_status(f"{display_name} sequence started")
self._clear_trace_plots()
self._update_capture_dialog_state()
self._log(
f"{display_name} sequence started: set={set_name}, radar_key={radar_key}, "
@@ -155,10 +218,44 @@ class AppWindowPreprocessMixin:
self._show_error("No active capture sequence")
return
dialog = self._ensure_preprocess_dialog()
try:
trace = session.capture_current_combo()
self._record_preprocess_capture(session, trace)
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to capture preprocess combo", exc)
self._abort_capture_sequence()
def _capture_all_remaining(self) -> None:
"""Capture all remaining combos for the active preprocess session."""
session = self._capture_session
if session is None:
self._show_error("No active capture sequence")
return
if session.is_complete():
self._show_error(
"Capture sequence is already complete",
details=self._capture_state_details(),
)
return
display_name = preprocess_asset_display_name(session.kind)
dialog = self._ensure_preprocess_dialog()
dialog.set_status(f"{display_name} batch capture started")
self._log(
f"{display_name} batch capture started: remaining="
f"{session.state().total_count - session.state().captured_count}"
)
try:
while not session.is_complete():
trace = session.capture_current_combo()
self._record_preprocess_capture(session, trace)
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to capture preprocess combo", exc)
self._abort_capture_sequence()
def _record_preprocess_capture(self, session: SequentialCaptureSession, trace) -> None:
"""Update UI, preview, and logs after one successful preprocess capture."""
dialog = self._ensure_preprocess_dialog()
state = session.state()
display_name = preprocess_asset_display_name(session.kind)
channel = preprocess_asset_channel(session.kind)
@@ -170,16 +267,72 @@ class AppWindowPreprocessMixin:
input_pos=trace.combo.input_pos,
output_pos=trace.combo.output_pos,
)
dialog.draw_last_trace(trace, title=f"{display_name} captured", channel=channel)
self._draw_single_trace(trace, title=f"{display_name} last trace", channel=channel)
self._log(
f"{display_name} capture: {state.captured_count}/{state.total_count} | "
f"input={trace.combo.input_pos} output={trace.combo.output_pos}"
)
self._update_capture_dialog_state()
if session.is_complete():
dialog.set_status(f"{display_name} sequence complete. Review captures or save the set.")
self._log(
f"{display_name} sequence capture complete: "
f"{state.captured_count}/{state.total_count}; waiting for save or undo"
)
def _undo_last_capture(self) -> None:
"""Remove the most recently captured combo and rewind capture cursor."""
session = self._capture_session
if session is None:
self._show_error("No active capture sequence")
return
dialog = self._ensure_preprocess_dialog()
try:
removed_trace = session.undo_last_capture()
state = session.state()
display_name = preprocess_asset_display_name(session.kind)
channel = preprocess_asset_channel(session.kind)
dialog.set_capture_log_entries(self._capture_log_entries_for_session(session))
last_trace = session.last_captured_trace()
if last_trace is None:
dialog.reset_preview()
dialog.set_status(f"{display_name} last capture removed. No captured combos remain.")
self._clear_trace_plots()
else:
dialog.draw_last_trace(last_trace, title=f"{display_name} last trace", channel=channel)
dialog.set_status(f"{display_name} last capture removed. Ready to recapture.")
self._draw_single_trace(last_trace, title=f"{display_name} last trace", channel=channel)
self._update_capture_dialog_state()
self._log(
f"{display_name} undo last capture: removed input={removed_trace.combo.input_pos} "
f"output={removed_trace.combo.output_pos}; remaining={state.captured_count}/{state.total_count}"
)
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to undo last preprocess capture", exc)
def _finalize_capture_sequence(self) -> None:
"""Persist completed capture session into preprocess-set storage."""
session = self._capture_session
if session is None:
self._show_error("No active capture sequence")
return
if not session.is_complete():
self._show_error(
"Capture sequence is not complete",
details=(
f"Captured {session.state().captured_count}/{session.state().total_count} combos. "
"Finish the remaining captures before saving."
),
)
return
dialog = self._ensure_preprocess_dialog()
try:
radar_key, collection = session.finalize(self._store)
set_name = session.set_name
kind = session.kind
@@ -191,11 +344,8 @@ class AppWindowPreprocessMixin:
dialog.set_status(f"{display_name} set saved: {set_name} ({len(collection.traces)} traces)")
self._log(f"{display_name} sequence completed and saved: set={set_name}, key={radar_key}")
self._resume_pipeline_if_needed()
else:
self._update_capture_dialog_state()
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to capture preprocess combo", exc)
self._abort_capture_sequence()
self._show_exception("Failed to save preprocess set", exc)
def _abort_capture_sequence(self, *, resume_pipeline: bool = True) -> None:
"""Abort active capture session and optionally resume pipeline."""
@@ -222,6 +372,9 @@ class AppWindowPreprocessMixin:
total_count=0,
next_input=None,
next_output=None,
can_undo=False,
can_finalize=False,
can_capture_all=False,
)
return
@@ -238,6 +391,9 @@ class AppWindowPreprocessMixin:
total_count=state.total_count,
next_input=next_input,
next_output=next_output,
can_undo=state.can_undo,
can_finalize=state.is_complete,
can_capture_all=(not state.is_complete and state.current_combo is not None),
)
def _cleanup_capture_session(self) -> None:
@@ -77,7 +77,7 @@ class AppWindowUiMixin:
# Default view on startup is pass-through traces.
self._plot_stack.setCurrentWidget(self._trace_plots_container)
root_layout.addWidget(self._plot_stack, stretch=12)
root_layout.addWidget(self._plot_stack, stretch=11)
@staticmethod
def _create_plot_widget(*, background: str) -> pg.PlotWidget:
@@ -89,6 +89,7 @@ class AppWindowUiMixin:
# B-scan surface: one PlotWidget used as canvas for ImageItem heatmap.
self._bscan_plot = self._create_plot_widget(background="#0f141c")
self._bscan_plot.showGrid(x=True, y=True, alpha=0.2)
self._configure_bscan_plot_axes()
self._plot_stack.addWidget(self._bscan_plot)
def _build_trace_plot_page(self) -> None:
@@ -131,6 +132,7 @@ class AppWindowUiMixin:
"""Create GPR page in plot stack."""
self._gpr_plot = self._create_plot_widget(background="#0f141c")
self._gpr_plot.showGrid(x=True, y=True, alpha=0.2)
self._configure_gpr_plot_axes()
self._plot_stack.addWidget(self._gpr_plot)
def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None:
@@ -142,7 +144,7 @@ class AppWindowUiMixin:
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."""
"""Build right settings panel with controls, history summary, and log."""
self._settings_panel = QWidget(root)
self._settings_panel.setMinimumWidth(610)
right_layout = QVBoxLayout(self._settings_panel)
@@ -151,6 +153,31 @@ class AppWindowUiMixin:
# Build log early so `_show_error()` can append text even during
# subsequent group construction if something fails.
self._log_panel = self._build_log_panel()
right_layout.addWidget(build_primary_actions_group(self), stretch=0)
right_layout.addWidget(self._build_settings_scroll(), stretch=1)
self._status_label = QLabel("Status: idle", self._settings_panel)
self._status_label.setObjectName("statusLabel")
self._status_label.hide()
self._history_label = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel)
self._history_label.setObjectName("hintLabel")
right_layout.addWidget(self._history_label)
right_layout.addWidget(self._log_panel, stretch=0)
root_layout.addWidget(self._settings_panel, stretch=7)
def _build_log_panel(self) -> QWidget:
"""Build runtime log block with collapsible log body."""
self._log_panel_title = QLabel("Runtime Log", self._settings_panel)
self._log_panel_title.setObjectName("hintLabel")
self._log_toggle_button = QPushButton(self._settings_panel)
self._log_toggle_button.setObjectName("sectionToggleButton")
self._log_toggle_button.clicked.connect(lambda: self._toggle_log_panel())
self._log_box = QTextEdit(self._settings_panel)
self._log_box.setObjectName("runtimeLogBox")
self._log_box.setReadOnly(True)
@@ -158,19 +185,24 @@ class AppWindowUiMixin:
self._log_box.setMinimumHeight(170)
self._log_box.document().setMaximumBlockCount(1200)
right_layout.addWidget(build_primary_actions_group(self), stretch=0)
right_layout.addWidget(self._build_settings_scroll(), stretch=1)
panel = QWidget(self._settings_panel)
panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0)
panel_layout.setSpacing(6)
self._status_label = QLabel("Status: idle", self._settings_panel)
self._status_label.setObjectName("statusLabel")
right_layout.addWidget(self._status_label)
header_row = QWidget(panel)
header_layout = QHBoxLayout(header_row)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(8)
header_layout.addWidget(self._log_panel_title)
header_layout.addStretch(1)
header_layout.addWidget(self._log_toggle_button)
self._history_label = QLabel("History: raw=0, preprocessed=0, results=0", self._settings_panel)
self._history_label.setObjectName("hintLabel")
right_layout.addWidget(self._history_label)
panel_layout.addWidget(header_row)
panel_layout.addWidget(self._log_box)
right_layout.addWidget(self._log_box, stretch=0)
root_layout.addWidget(self._settings_panel, stretch=7)
self._toggle_log_panel(visible=True)
return panel
def _build_settings_scroll(self) -> QScrollArea:
"""Build scroll area with all control groups in display order."""
@@ -178,7 +210,7 @@ class AppWindowUiMixin:
# 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.setContentsMargins(0, 0, 12, 0)
controls_layout.setSpacing(10)
for group in self._build_control_groups():
controls_layout.addWidget(group)
@@ -193,8 +225,8 @@ class AppWindowUiMixin:
def _build_control_groups(self) -> list[QGroupBox]:
"""Create all settings groups in top-to-bottom order."""
return [
build_switch_group(self),
build_data_actions_group(self),
build_switch_group(self),
build_preprocess_summary_group(self),
build_processing_group(self),
build_radar_group(self),
@@ -216,6 +248,19 @@ class AppWindowUiMixin:
self._settings_toggle_button.setText("<")
self._settings_toggle_button.setToolTip("Show settings panel")
def _toggle_log_panel(self, *, visible: bool | None = None) -> None:
"""Toggle runtime log body visibility or force a specific state."""
if visible is None:
visible = not self._log_box.isVisible()
self._log_box.setVisible(visible)
if visible:
self._log_toggle_button.setText("Collapse")
self._log_toggle_button.setToolTip("Hide runtime log entries")
else:
self._log_toggle_button.setText("Expand")
self._log_toggle_button.setToolTip("Show runtime log entries")
def _set_plot_mode(self, mode: str) -> None:
"""Switch visible plot page according to processing mode.
@@ -31,9 +31,6 @@ def build_data_actions_group(owner) -> QGroupBox:
remove_last_button = QPushButton("Remove Last Measurement")
remove_last_button.clicked.connect(owner._remove_last_runtime_history)
remove_last_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
clear_history_button = QPushButton("Clear Runtime History")
clear_history_button.clicked.connect(owner._clear_all_runtime_history)
clear_history_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
owner._save_count = QSpinBox()
owner._save_count.setMinimum(1)
owner._save_count.setMaximum(10_000)
@@ -44,8 +41,7 @@ def build_data_actions_group(owner) -> QGroupBox:
button_grid.setVerticalSpacing(8)
button_grid.addWidget(save_button, 0, 0)
button_grid.addWidget(save_vna_json_button, 0, 1)
button_grid.addWidget(remove_last_button, 1, 0)
button_grid.addWidget(clear_history_button, 1, 1)
button_grid.addWidget(remove_last_button, 1, 0, 1, 2)
button_grid.setColumnStretch(0, 1)
button_grid.setColumnStretch(1, 1)
layout.addLayout(button_grid)
@@ -0,0 +1,60 @@
"""Shared layout helpers for section builders."""
from __future__ import annotations
from typing import TypeAlias
from PyQt6.QtWidgets import QFormLayout, QHBoxLayout, QWidget
FormLabel: TypeAlias = str | QWidget
FormRow: TypeAlias = QWidget | tuple[FormLabel, QWidget]
def build_two_column_form_widget(
parent: QWidget,
rows: list[FormRow],
*,
split_index: int | None = None,
) -> QWidget:
"""Build one widget containing two side-by-side form columns."""
panel = QWidget(parent)
panel_layout = QHBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0)
panel_layout.setSpacing(12)
left_column = QWidget(panel)
left_form = _create_form_layout(left_column)
right_column = QWidget(panel)
right_form = _create_form_layout(right_column)
panel_layout.addWidget(left_column, stretch=1)
panel_layout.addWidget(right_column, stretch=1)
actual_split_index = (len(rows) + 1) // 2 if split_index is None else split_index
actual_split_index = max(0, min(actual_split_index, len(rows)))
for row in rows[:actual_split_index]:
_add_form_row(left_form, row)
for row in rows[actual_split_index:]:
_add_form_row(right_form, row)
return panel
def _create_form_layout(parent: QWidget) -> QFormLayout:
"""Create a compact growing form layout for one column."""
form = QFormLayout(parent)
form.setContentsMargins(0, 0, 0, 0)
form.setHorizontalSpacing(10)
form.setVerticalSpacing(6)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
return form
def _add_form_row(form: QFormLayout, row: FormRow) -> None:
"""Append one form row, with or without an explicit label."""
if isinstance(row, tuple):
label, field = row
form.addRow(label, field)
return
form.addRow(row)
@@ -2,20 +2,25 @@
from __future__ import annotations
from PyQt6.QtWidgets import QFormLayout, QGroupBox, QLabel
from PyQt6.QtWidgets import QGroupBox, QLabel, QVBoxLayout
from python_app.gui.controllers.sections.layout_helpers import build_two_column_form_widget
from python_app.orchestration.preprocess_assets import VISIBLE_PREPROCESS_ASSET_KEYS, preprocess_asset_display_name
def build_preprocess_summary_group(owner) -> QGroupBox:
"""Create selected preprocess-set summary section."""
group = QGroupBox("Selected Preprocess Sets")
form = QFormLayout(group)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
layout = QVBoxLayout(group)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(8)
owner._selected_preprocess_labels = {}
rows = []
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
label = QLabel("<not selected>")
owner._selected_preprocess_labels[key] = label
form.addRow(preprocess_asset_display_name(key), label)
rows.append((preprocess_asset_display_name(key), label))
layout.addWidget(build_two_column_form_widget(group, rows, split_index=(len(rows) + 1) // 2))
return group
@@ -45,7 +45,11 @@ def build_primary_actions_group(owner) -> QGroupBox:
preprocess_button = _expanding_button("Preprocessing")
preprocess_button.clicked.connect(owner._open_preprocess_panel)
layout.addWidget(preprocess_button, 2, 0, 1, 3)
layout.addWidget(preprocess_button, 2, 0)
clear_history_button = _expanding_button("Clear Runtime History")
clear_history_button.clicked.connect(owner._clear_all_runtime_history)
layout.addWidget(clear_history_button, 2, 1, 1, 2)
layout.setColumnStretch(0, 1)
layout.setColumnStretch(1, 1)
@@ -13,9 +13,13 @@ from PyQt6.QtWidgets import (
QSizePolicy,
QSpinBox,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from python_app.gui.controllers.sections.layout_helpers import FormRow, build_two_column_form_widget
def _format_tx_geometry(owner) -> str:
"""Render Tx geometry defaults into editable line-based text."""
return "\n".join(
@@ -32,6 +36,17 @@ def _format_rx_geometry(owner) -> str:
)
def _build_processing_mode_page(parent: QStackedWidget, rows: list[FormRow], *, split_index: int | None = None) -> QWidget:
"""Create one processing-mode page with a two-column form body."""
page = QWidget(parent)
page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
page_layout = QVBoxLayout(page)
page_layout.setContentsMargins(0, 0, 0, 0)
page_layout.setSpacing(0)
page_layout.addWidget(build_two_column_form_widget(page, rows, split_index=split_index))
return page
def build_processing_group(owner) -> QGroupBox:
"""Create processing mode section with pass-through, B-scan, and GPR pages."""
group = QGroupBox("Processing")
@@ -49,11 +64,6 @@ def build_processing_group(owner) -> QGroupBox:
owner._processing_mode_pages = QStackedWidget(group)
owner._processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
pass_through_page = QWidget(owner._processing_mode_pages)
pass_through_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
pass_through_form = QFormLayout(pass_through_page)
pass_through_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
owner._show_magnitude_checkbox = QCheckBox("Show magnitude")
owner._show_magnitude_checkbox.setChecked(bool(pass_defaults.show_magnitude))
@@ -77,18 +87,19 @@ def build_processing_group(owner) -> QGroupBox:
owner._sync_pass_through_y_controls()
pass_through_form.addRow(owner._show_magnitude_checkbox)
pass_through_form.addRow(owner._show_phase_checkbox)
pass_through_form.addRow(owner._pass_through_fixed_y_enabled)
pass_through_form.addRow("Y min dB", owner._pass_through_y_min_db)
pass_through_form.addRow("Y max dB", owner._pass_through_y_max_db)
pass_through_page = _build_processing_mode_page(
owner._processing_mode_pages,
[
owner._show_magnitude_checkbox,
owner._show_phase_checkbox,
owner._pass_through_fixed_y_enabled,
("Y min dB", owner._pass_through_y_min_db),
("Y max dB", owner._pass_through_y_max_db),
],
split_index=3,
)
owner._processing_mode_pages.addWidget(pass_through_page)
bscan_page = QWidget(owner._processing_mode_pages)
bscan_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
bscan_form = QFormLayout(bscan_page)
bscan_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
owner._bscan_axis = QComboBox()
owner._bscan_axis.addItems(["abs", "real", "phase"])
owner._set_combo_current_text(owner._bscan_axis, bscan_defaults.axis)
@@ -123,18 +134,20 @@ def build_processing_group(owner) -> QGroupBox:
owner._bscan_stop_freq_mhz.setSingleStep(10.0)
owner._bscan_stop_freq_mhz.setValue(float(bscan_defaults.stop_freq_mhz))
bscan_form.addRow("Axis", owner._bscan_axis)
bscan_form.addRow("Cut m", owner._bscan_cut_m)
bscan_form.addRow("Max depth m", owner._bscan_max_depth_m)
bscan_form.addRow("Gain", owner._bscan_gain)
bscan_form.addRow("Start MHz", owner._bscan_start_freq_mhz)
bscan_form.addRow("Stop MHz", owner._bscan_stop_freq_mhz)
bscan_page = _build_processing_mode_page(
owner._processing_mode_pages,
[
("Axis", owner._bscan_axis),
("Cut m", owner._bscan_cut_m),
("Max depth m", owner._bscan_max_depth_m),
("Gain", owner._bscan_gain),
("Start MHz", owner._bscan_start_freq_mhz),
("Stop MHz", owner._bscan_stop_freq_mhz),
],
split_index=3,
)
owner._processing_mode_pages.addWidget(bscan_page)
gpr_page = QWidget(owner._processing_mode_pages)
gpr_page.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
gpr_form = QFormLayout(gpr_page)
gpr_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
gpr_defaults = owner._defaults_config.gpr
owner._gpr_config_mode = QComboBox()
@@ -149,11 +162,11 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_tx_geometry_input = QPlainTextEdit(_format_tx_geometry(owner))
owner._gpr_tx_geometry_input.setPlaceholderText("output_pos x_m")
owner._gpr_tx_geometry_input.setMinimumHeight(88)
owner._gpr_tx_geometry_input.setFixedHeight(78)
owner._gpr_rx_geometry_input = QPlainTextEdit(_format_rx_geometry(owner))
owner._gpr_rx_geometry_input.setPlaceholderText("input_pos x_m")
owner._gpr_rx_geometry_input.setMinimumHeight(120)
owner._gpr_rx_geometry_input.setFixedHeight(78)
owner._gpr_input_positions_input = QLineEdit(str(gpr_live_defaults.input_positions))
owner._gpr_input_positions_input.setPlaceholderText("0,1,2")
@@ -203,6 +216,18 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_look_angle_deg.setSingleStep(0.1)
owner._gpr_look_angle_deg.setValue(float(gpr_live_defaults.look_angle_deg))
owner._gpr_snr_thresh = QDoubleSpinBox()
owner._gpr_snr_thresh.setDecimals(2)
owner._gpr_snr_thresh.setRange(0.0, 1_000.0)
owner._gpr_snr_thresh.setSingleStep(0.1)
owner._gpr_snr_thresh.setValue(float(gpr_live_defaults.snr_thresh))
owner._gpr_snr_comp_max = QDoubleSpinBox()
owner._gpr_snr_comp_max.setDecimals(2)
owner._gpr_snr_comp_max.setRange(0.0, 1_000.0)
owner._gpr_snr_comp_max.setSingleStep(0.5)
owner._gpr_snr_comp_max.setValue(float(gpr_live_defaults.snr_comp_max))
owner._gpr_background_subtract_enabled = QCheckBox("Subtract mean of previous collections")
owner._gpr_background_subtract_enabled.setChecked(bool(gpr_live_defaults.background_subtract_enabled))
@@ -210,21 +235,67 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_background_mean_count.setRange(0, 10_000)
owner._gpr_background_mean_count.setValue(int(gpr_live_defaults.background_mean_count))
gpr_form.addRow("Config mode", owner._gpr_config_mode)
gpr_form.addRow("Relative permittivity", owner._gpr_relative_permittivity)
gpr_form.addRow("Tx geometry", owner._gpr_tx_geometry_input)
gpr_form.addRow("Rx geometry", owner._gpr_rx_geometry_input)
gpr_form.addRow("Input positions", owner._gpr_input_positions_input)
gpr_form.addRow("Output positions", owner._gpr_output_positions_input)
gpr_form.addRow("Min depth m", owner._gpr_min_depth_m)
gpr_form.addRow("Max depth m", owner._gpr_max_depth_m)
gpr_form.addRow("Comp power", owner._gpr_comp_power)
gpr_form.addRow("Start MHz", owner._gpr_start_freq_mhz)
gpr_form.addRow("Stop MHz", owner._gpr_stop_freq_mhz)
gpr_form.addRow("Speed m/s", owner._gpr_speed_m_s)
gpr_form.addRow("Look angle deg", owner._gpr_look_angle_deg)
gpr_form.addRow(owner._gpr_background_subtract_enabled)
gpr_form.addRow("Mean count", owner._gpr_background_mean_count)
owner._gpr_render_mode = QComboBox()
owner._gpr_render_mode.addItems(["heatmap", "objects_only"])
owner._set_combo_current_text(owner._gpr_render_mode, gpr_live_defaults.render_mode)
owner._gpr_min_visible_pair_count = QSpinBox()
owner._gpr_min_visible_pair_count.setRange(1, 10_000)
owner._gpr_min_visible_pair_count.setValue(int(gpr_live_defaults.min_visible_pair_count))
owner._gpr_visible_x_min_m = QDoubleSpinBox()
owner._gpr_visible_x_min_m.setDecimals(2)
owner._gpr_visible_x_min_m.setRange(-100.0, 100.0)
owner._gpr_visible_x_min_m.setSingleStep(0.1)
owner._gpr_visible_x_min_m.setValue(float(gpr_live_defaults.visible_x_min_m))
owner._gpr_visible_x_max_m = QDoubleSpinBox()
owner._gpr_visible_x_max_m.setDecimals(2)
owner._gpr_visible_x_max_m.setRange(-100.0, 100.0)
owner._gpr_visible_x_max_m.setSingleStep(0.1)
owner._gpr_visible_x_max_m.setValue(float(gpr_live_defaults.visible_x_max_m))
owner._gpr_visible_z_min_m = QDoubleSpinBox()
owner._gpr_visible_z_min_m.setDecimals(2)
owner._gpr_visible_z_min_m.setRange(0.0, 100.0)
owner._gpr_visible_z_min_m.setSingleStep(0.1)
owner._gpr_visible_z_min_m.setValue(float(gpr_live_defaults.visible_z_min_m))
owner._gpr_visible_z_max_m = QDoubleSpinBox()
owner._gpr_visible_z_max_m.setDecimals(2)
owner._gpr_visible_z_max_m.setRange(0.0, 100.0)
owner._gpr_visible_z_max_m.setSingleStep(0.1)
owner._gpr_visible_z_max_m.setValue(float(gpr_live_defaults.visible_z_max_m))
gpr_page = _build_processing_mode_page(
owner._processing_mode_pages,
[
("Config mode", owner._gpr_config_mode),
("Relative permittivity", owner._gpr_relative_permittivity),
("Input positions", owner._gpr_input_positions_input),
("Output positions", owner._gpr_output_positions_input),
("Min depth m", owner._gpr_min_depth_m),
("Max depth m", owner._gpr_max_depth_m),
("Comp power", owner._gpr_comp_power),
("SNR thresh", owner._gpr_snr_thresh),
("SNR comp max", owner._gpr_snr_comp_max),
("Speed m/s", owner._gpr_speed_m_s),
("Render mode", owner._gpr_render_mode),
("Min visible pairs", owner._gpr_min_visible_pair_count),
("Tx geometry", owner._gpr_tx_geometry_input),
("Rx geometry", owner._gpr_rx_geometry_input),
("Start MHz", owner._gpr_start_freq_mhz),
("Stop MHz", owner._gpr_stop_freq_mhz),
("Look angle deg", owner._gpr_look_angle_deg),
("Visible X min m", owner._gpr_visible_x_min_m),
("Visible X max m", owner._gpr_visible_x_max_m),
("Visible Z min m", owner._gpr_visible_z_min_m),
("Visible Z max m", owner._gpr_visible_z_max_m),
owner._gpr_background_subtract_enabled,
("Mean count", owner._gpr_background_mean_count),
],
split_index=11,
)
owner._processing_mode_pages.addWidget(gpr_page)
owner._processing_mode.currentTextChanged.connect(owner._on_processing_mode_changed)
@@ -252,8 +323,16 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_speed_m_s.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_look_angle_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_snr_thresh.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_snr_comp_max.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed)
owner._gpr_background_mean_count.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed)
owner._gpr_min_visible_pair_count.valueChanged.connect(owner._on_gpr_visual_settings_changed)
owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_visual_settings_changed)
owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_visual_settings_changed)
owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_visual_settings_changed)
owner._gpr_visible_z_max_m.valueChanged.connect(owner._on_gpr_visual_settings_changed)
owner._on_processing_mode_changed(owner._processing_mode.currentText())
return group
@@ -2,14 +2,17 @@
from __future__ import annotations
from PyQt6.QtWidgets import QComboBox, QFormLayout, QGroupBox, QLabel, QLineEdit
from PyQt6.QtWidgets import QComboBox, QGroupBox, QLabel, QLineEdit, QVBoxLayout
from python_app.gui.controllers.sections.layout_helpers import build_two_column_form_widget
def build_radar_group(owner) -> QGroupBox:
"""Create radar settings controls and labels."""
group = QGroupBox("Radar")
form = QFormLayout(group)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
layout = QVBoxLayout(group)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(8)
defaults = owner._defaults_config.radar
owner._serial_input = QLineEdit(defaults.serial)
@@ -30,6 +33,9 @@ def build_radar_group(owner) -> QGroupBox:
owner._radar_mode.currentTextChanged.connect(owner._on_radar_identity_changed)
owner._start_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
owner._stop_hz_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
owner._points_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
owner._ifbw_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
owner._power_input.editingFinished.connect(owner._on_radar_sweep_limits_changed)
owner._radar_start_label = QLabel("Start Hz")
owner._radar_stop_label = QLabel("Stop Hz")
@@ -40,10 +46,18 @@ def build_radar_group(owner) -> QGroupBox:
owner._radar_limits_hint = QLabel("Mock mode: device limits are not applied.")
owner._radar_limits_hint.setObjectName("hintLabel")
form.addRow(owner._radar_start_label, owner._start_hz_input)
form.addRow(owner._radar_stop_label, owner._stop_hz_input)
form.addRow(owner._radar_points_label, owner._points_input)
form.addRow(owner._radar_ifbw_label, owner._ifbw_input)
form.addRow(owner._radar_power_label, owner._power_input)
form.addRow(owner._radar_limits_hint)
layout.addWidget(
build_two_column_form_widget(
group,
[
(owner._radar_start_label, owner._start_hz_input),
(owner._radar_stop_label, owner._stop_hz_input),
(owner._radar_points_label, owner._points_input),
(owner._radar_ifbw_label, owner._ifbw_input),
(owner._radar_power_label, owner._power_input),
],
split_index=3,
)
)
layout.addWidget(owner._radar_limits_hint)
return group
+48 -4
View File
@@ -36,6 +36,9 @@ class PreprocessDialog(QDialog):
selection_changed = pyqtSignal()
start_sequence_requested = pyqtSignal(str)
capture_next_requested = pyqtSignal()
capture_all_requested = pyqtSignal()
undo_last_requested = pyqtSignal()
finalize_sequence_requested = pyqtSignal()
abort_sequence_requested = pyqtSignal()
def __init__(self, parent=None) -> None:
@@ -97,6 +100,8 @@ class PreprocessDialog(QDialog):
for key in keys:
combo = QComboBox(group)
combo.setPlaceholderText("<not selected>")
combo.setCurrentIndex(-1)
combo.currentTextChanged.connect(self._emit_selection_changed)
self._set_combos[key] = combo
form.addRow(self._asset_row_label(key), combo)
@@ -142,12 +147,27 @@ class PreprocessDialog(QDialog):
self._capture_next_button.clicked.connect(self.capture_next_requested.emit)
self._capture_next_button.setEnabled(False)
self._capture_all_button = QPushButton("Capture All Remaining", parent)
self._capture_all_button.clicked.connect(self.capture_all_requested.emit)
self._capture_all_button.setEnabled(False)
self._undo_last_button = QPushButton("Undo Last Capture", parent)
self._undo_last_button.clicked.connect(self.undo_last_requested.emit)
self._undo_last_button.setEnabled(False)
self._save_sequence_button = QPushButton("Save Captured Set", parent)
self._save_sequence_button.clicked.connect(self.finalize_sequence_requested.emit)
self._save_sequence_button.setEnabled(False)
self._abort_button = QPushButton("Abort Sequence", parent)
self._abort_button.clicked.connect(self.abort_sequence_requested.emit)
self._abort_button.setEnabled(False)
layout = QHBoxLayout()
layout.addWidget(self._capture_next_button)
layout.addWidget(self._capture_all_button)
layout.addWidget(self._undo_last_button)
layout.addWidget(self._save_sequence_button)
layout.addWidget(self._abort_button)
layout.addStretch(1)
return layout
@@ -189,6 +209,10 @@ class PreprocessDialog(QDialog):
"""Clear capture history text box."""
self._capture_log.clear()
def set_capture_log_entries(self, entries: list[str]) -> None:
"""Replace capture history text with provided rows."""
self._capture_log.setPlainText("\n".join(entries))
def append_capture_log_entry(
self,
*,
@@ -212,6 +236,9 @@ class PreprocessDialog(QDialog):
total_count: int,
next_input: int | None,
next_output: int | None,
can_undo: bool,
can_finalize: bool,
can_capture_all: bool,
) -> None:
"""Update sequence progress/status widgets."""
if kind is None:
@@ -219,20 +246,27 @@ class PreprocessDialog(QDialog):
self._progress_label.setText("0 / 0")
self._combo_label.setText("<none>")
self._capture_next_button.setEnabled(False)
self._capture_all_button.setEnabled(False)
self._undo_last_button.setEnabled(False)
self._save_sequence_button.setEnabled(False)
self._abort_button.setEnabled(False)
self._capture_all_button.setText("Capture All Remaining")
return
active_label = preprocess_asset_display_name(kind) if kind in PREPROCESS_ASSET_SPECS else kind
self._active_kind_label.setText(active_label)
self._progress_label.setText(f"{captured_count} / {total_count}")
self._undo_last_button.setEnabled(bool(can_undo))
self._save_sequence_button.setEnabled(bool(can_finalize))
self._capture_all_button.setEnabled(bool(can_capture_all))
self._abort_button.setEnabled(True)
self._capture_all_button.setText("Capture All Remaining")
if next_input is None or next_output is None:
self._combo_label.setText("<complete>")
self._capture_next_button.setEnabled(False)
self._abort_button.setEnabled(True)
else:
self._combo_label.setText(f"input={next_input}, output={next_output}")
self._capture_next_button.setEnabled(True)
self._abort_button.setEnabled(True)
def set_available_sets(self, available_sets: dict[str, list[str]]) -> None:
"""Replace combo-box choices for all preprocess assets."""
@@ -245,10 +279,11 @@ class PreprocessDialog(QDialog):
"""Apply selected set names to all comboboxes and optionally emit update."""
for key in VISIBLE_PREPROCESS_ASSET_KEYS:
selected_value = selected_sets.get(key, "")
if not selected_value:
continue
combo = self._set_combos[key]
with QSignalBlocker(combo):
if not selected_value:
combo.setCurrentIndex(-1)
continue
index = combo.findText(selected_value)
if index < 0:
combo.addItem(selected_value)
@@ -261,6 +296,14 @@ class PreprocessDialog(QDialog):
"""Set short human-readable status line."""
self._status_label.setText(message)
def reset_preview(self) -> None:
"""Clear preview surface and restore default empty-state text when possible."""
if self._preview_plot is not None:
self._preview_plot.clear()
self._preview_plot.setTitle("")
if self._preview_placeholder is not None:
self._preview_placeholder.setText("Preview will appear after the first successful capture.")
def draw_last_trace(self, trace: TraceData, title: str, *, channel: str) -> None:
"""Draw the latest captured sweep trace for the requested channel in dB scale."""
samples = trace.s11 if channel == "s11" else trace.s21
@@ -328,6 +371,7 @@ class PreprocessDialog(QDialog):
combo.clear()
combo.addItems(names)
if not current_text:
combo.setCurrentIndex(-1)
return
index = combo.findText(current_text)
if index < 0:
+4 -1
View File
@@ -27,6 +27,10 @@ def validate_processing_mode_constraints(
if processing_mode != "gpr":
return
configured_combos = {(int(combo.input), int(combo.output)) for combo in config.combos}
if len(configured_combos) < 2:
raise RuntimeError(f"GPR requires at least 2 distinct run combos (now {len(configured_combos)})")
available_inputs = sorted({int(entry.input_pos) for entry in config.gpr.rx_geometry})
available_outputs = sorted({int(entry.output_pos) for entry in config.gpr.tx_geometry})
if not available_inputs or not available_outputs:
@@ -46,7 +50,6 @@ def validate_processing_mode_constraints(
raise RuntimeError(f"GPR output positions are missing from geometry config: {missing_outputs}")
required_combos = {(int(input_pos), int(output_pos)) for input_pos in selected_inputs for output_pos in selected_outputs}
configured_combos = {(int(combo.input), int(combo.output)) for combo in config.combos}
missing_combos = sorted(required_combos - configured_combos)
if missing_combos:
raise RuntimeError(
+6
View File
@@ -71,6 +71,12 @@ QPushButton#settingsToggleButton {
font-weight: 700;
}
QPushButton#sectionToggleButton {
min-width: 84px;
padding: 5px 10px;
border-radius: 7px;
}
QLineEdit,
QPlainTextEdit,
QTextEdit,
+64
View File
@@ -208,6 +208,18 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.processing.gpr.look_angle_deg,
"gui.processing.gpr",
),
snr_thresh=_optional_float(
gpr_object,
"snr_thresh",
gui.processing.gpr.snr_thresh,
"gui.processing.gpr",
),
snr_comp_max=_optional_float(
gpr_object,
"snr_comp_max",
gui.processing.gpr.snr_comp_max,
"gui.processing.gpr",
),
background_subtract_enabled=_optional_bool(
gpr_object,
"background_subtract_enabled",
@@ -220,12 +232,56 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.processing.gpr.background_mean_count,
"gui.processing.gpr",
),
render_mode=_optional_string(
gpr_object,
"render_mode",
gui.processing.gpr.render_mode,
"gui.processing.gpr",
),
min_visible_pair_count=_optional_int(
gpr_object,
"min_visible_pair_count",
gui.processing.gpr.min_visible_pair_count,
"gui.processing.gpr",
),
visible_x_min_m=_optional_float(
gpr_object,
"visible_x_min_m",
gui.processing.gpr.visible_x_min_m,
"gui.processing.gpr",
),
visible_x_max_m=_optional_float(
gpr_object,
"visible_x_max_m",
gui.processing.gpr.visible_x_max_m,
"gui.processing.gpr",
),
visible_z_min_m=_optional_float(
gpr_object,
"visible_z_min_m",
gui.processing.gpr.visible_z_min_m,
"gui.processing.gpr",
),
visible_z_max_m=_optional_float(
gpr_object,
"visible_z_max_m",
gui.processing.gpr.visible_z_max_m,
"gui.processing.gpr",
),
),
)
if gui.processing.selected_mode not in {"pass_through", "bscan", "gpr"}:
raise ValueError("gui.processing.selected_mode must be one of: pass_through, bscan, gpr")
if gui.processing.bscan.axis not in {"abs", "real", "phase"}:
raise ValueError("gui.processing.bscan.axis must be one of: abs, real, phase")
if gui.processing.gpr.render_mode not in {"heatmap", "objects_only"}:
raise ValueError("gui.processing.gpr.render_mode must be one of: heatmap, objects_only")
if gui.processing.gpr.snr_thresh < 0.0:
raise ValueError("gui.processing.gpr.snr_thresh must be >= 0")
if gui.processing.gpr.snr_comp_max < 0.0:
raise ValueError("gui.processing.gpr.snr_comp_max must be >= 0")
if gui.processing.gpr.min_visible_pair_count < 1:
raise ValueError("gui.processing.gpr.min_visible_pair_count must be >= 1")
data_actions_object = _as_dict(gui_object.get("data_actions"), "gui.data_actions")
gui.data_actions = GuiDataActionsStateModel(
@@ -302,8 +358,16 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"stop_freq_mhz": gui.processing.gpr.stop_freq_mhz,
"speed_m_s": gui.processing.gpr.speed_m_s,
"look_angle_deg": gui.processing.gpr.look_angle_deg,
"snr_thresh": gui.processing.gpr.snr_thresh,
"snr_comp_max": gui.processing.gpr.snr_comp_max,
"background_subtract_enabled": gui.processing.gpr.background_subtract_enabled,
"background_mean_count": gui.processing.gpr.background_mean_count,
"render_mode": gui.processing.gpr.render_mode,
"min_visible_pair_count": gui.processing.gpr.min_visible_pair_count,
"visible_x_min_m": gui.processing.gpr.visible_x_min_m,
"visible_x_max_m": gui.processing.gpr.visible_x_max_m,
"visible_z_min_m": gui.processing.gpr.visible_z_min_m,
"visible_z_max_m": gui.processing.gpr.visible_z_max_m,
},
},
"data_actions": {
+8
View File
@@ -57,8 +57,16 @@ class GuiGprStateModel:
stop_freq_mhz: float = 6000.0
speed_m_s: float = 0.0
look_angle_deg: float = 0.0
snr_thresh: float = 4.5
snr_comp_max: float = 25.0
background_subtract_enabled: bool = True
background_mean_count: int = 10
render_mode: str = "heatmap"
min_visible_pair_count: int = 1
visible_x_min_m: float = -2.0
visible_x_max_m: float = 2.0
visible_z_min_m: float = 0.0
visible_z_max_m: float = 14.0
@dataclass(slots=True)
@@ -32,6 +32,8 @@ class ProcessingLiveConfig:
gpr_stop_freq_mhz: float = 6000.0
gpr_speed_m_s: float = 0.0
gpr_look_angle_deg: float = 0.0
gpr_snr_thresh: float = 4.5
gpr_snr_comp_max: float = 25.0
gpr_background_subtract_enabled: bool = True
gpr_background_mean_count: int = 10
history_command_seq: int = 0
@@ -72,6 +74,8 @@ class ProcessingLiveConfig:
"gpr_stop_freq_mhz": float(self.gpr_stop_freq_mhz),
"gpr_speed_m_s": float(self.gpr_speed_m_s),
"gpr_look_angle_deg": float(self.gpr_look_angle_deg),
"gpr_snr_thresh": float(self.gpr_snr_thresh),
"gpr_snr_comp_max": float(self.gpr_snr_comp_max),
"gpr_background_subtract_enabled": bool(self.gpr_background_subtract_enabled),
"gpr_background_mean_count": int(self.gpr_background_mean_count),
"history_command_seq": int(self.history_command_seq),
@@ -0,0 +1,3 @@
{
"last_profile_path": "/home/europa/Documents/radar_system/run_config.json"
}
+35
View File
@@ -0,0 +1,35 @@
{
"processor_mode": "gpr",
"pass_through_channel": "s21",
"pass_through_fixed_y_enabled": false,
"pass_through_y_min_db": -100.0,
"pass_through_y_max_db": 0.0,
"bscan_axis": "abs",
"bscan_channel": "s21",
"bscan_cut_m": 0.824,
"bscan_max_depth_m": 1.0,
"bscan_gain": 1.0,
"bscan_start_freq_mhz": 100.0,
"bscan_stop_freq_mhz": 6000.0,
"gpr_input_positions": [
0,
1,
2,
3
],
"gpr_output_positions": [
0,
3
],
"gpr_min_depth_m": 2.0,
"gpr_max_depth_m": 14.0,
"gpr_comp_power": 0.2,
"gpr_start_freq_mhz": 3000.0,
"gpr_stop_freq_mhz": 6000.0,
"gpr_speed_m_s": 0.0,
"gpr_look_angle_deg": 0.0,
"gpr_background_subtract_enabled": false,
"gpr_background_mean_count": 10,
"history_command_seq": 0,
"history_command": "none"
}
+172
View File
@@ -0,0 +1,172 @@
{
"radar": {
"model": "librevna",
"serial": "",
"driver_mode": "native",
"mock_signal_hz": 5000000.0,
"sweep": {
"start_hz": 1000000.0,
"stop_hz": 6000000000.0,
"points": 201,
"if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0
}
},
"switches": {
"port1": {
"name": "port1",
"driver_mode": "native",
"driver": "h7992",
"radar_port": 1,
"positions": 4,
"default_position": 0,
"gpio_chip": "/dev/gpiochip0",
"pin_a": 17,
"pin_b": 27,
"invert_logic": false
},
"port2": {
"name": "port2",
"driver_mode": "native",
"driver": "h7992",
"radar_port": 2,
"positions": 4,
"default_position": 0,
"gpio_chip": "/dev/gpiochip0",
"pin_a": 22,
"pin_b": 23,
"invert_logic": false
}
},
"run": {
"settling_ms": 0,
"idle_sleep_ms": 2,
"continuous": false,
"processing_live_config_path": "/home/europa/Documents/radar_system/python_app/runtime/processing_live.json",
"combos": [
{
"input": 0,
"output": 0
},
{
"input": 1,
"output": 0
},
{
"input": 2,
"output": 0
},
{
"input": 3,
"output": 0
},
{
"input": 0,
"output": 3
},
{
"input": 1,
"output": 3
},
{
"input": 2,
"output": 3
},
{
"input": 3,
"output": 3
}
]
},
"preprocess": {
"s21": {
"calibration": {
"set_name": "smoke_cal",
"bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/s21_calibration_bundle.bin"
},
"reference": {
"set_name": "smoke_ref",
"bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/s21_reference_bundle.bin"
}
},
"s11": {
"calibration": {
"open": {
"set_name": "",
"bundle_path": ""
},
"short": {
"set_name": "",
"bundle_path": ""
},
"load": {
"set_name": "",
"bundle_path": ""
}
},
"reference": {
"set_name": "",
"bundle_path": ""
}
}
},
"gpr": {
"mode": "point",
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
"x_m": 0.905
},
{
"output_pos": 3,
"x_m": -0.905
}
],
"rx_geometry": [
{
"input_pos": 0,
"x_m": -0.18
},
{
"input_pos": 1,
"x_m": 0.485
},
{
"input_pos": 2,
"x_m": -0.49
},
{
"input_pos": 3,
"x_m": 0.185
}
]
},
"rings": {
"raw": {
"name": "/radar_raw",
"capacity": 50,
"slot_size_bytes": 2097152
},
"raw_tap": {
"name": "/radar_raw_tap",
"capacity": 50,
"slot_size_bytes": 2097152
},
"preprocessed": {
"name": "/radar_preprocessed",
"capacity": 50,
"slot_size_bytes": 2097152
},
"preprocessed_tap": {
"name": "/radar_preprocessed_tap",
"capacity": 50,
"slot_size_bytes": 2097152
},
"results": {
"name": "/radar_results",
"capacity": 50,
"slot_size_bytes": 2097152
}
}
}
Binary file not shown.
Binary file not shown.
@@ -24,6 +24,8 @@ class SequentialCaptureState:
captured_count: int
total_count: int
current_combo: ComboModel | None
can_undo: bool
is_complete: bool
class SequentialCaptureSession:
@@ -112,6 +114,8 @@ class SequentialCaptureSession:
captured_count=len(self._traces),
total_count=len(self._combos),
current_combo=current_combo,
can_undo=bool(self._traces),
is_complete=self.is_complete(),
)
def capture_current_combo(self) -> TraceData:
@@ -138,6 +142,34 @@ class SequentialCaptureSession:
self._next_index += 1
return trace
def undo_last_capture(self) -> TraceData:
"""Remove the most recently captured trace and rewind cursor by one combo."""
if not self._opened:
raise RuntimeError("Capture session is not opened")
if not self._traces or self._next_index <= 0:
raise RuntimeError("No captured combo is available to undo")
expected_combo = self._combos[self._next_index - 1]
removed_trace = self._traces[-1]
if (
int(removed_trace.combo.input_pos) != int(expected_combo.input)
or int(removed_trace.combo.output_pos) != int(expected_combo.output)
):
raise RuntimeError("Capture session state is inconsistent; last trace does not match rewind combo")
self._next_index -= 1
self._traces.pop()
return removed_trace
def last_captured_trace(self) -> TraceData | None:
"""Return the most recently captured trace, if any."""
if not self._traces:
return None
return self._traces[-1]
def captured_traces(self) -> list[TraceData]:
"""Return captured traces in acquisition order."""
return list(self._traces)
def is_complete(self) -> bool:
"""Return `True` when all combos were captured."""
return self._next_index >= len(self._combos)