added GPR

This commit is contained in:
Ayzen
2026-03-19 19:29:08 +03:00
parent bdefe3f581
commit 9581730e41
39 changed files with 3830 additions and 201 deletions
+12
View File
@@ -104,6 +104,18 @@ class AppWindow(
self._bscan_depth_axis_by_combo = {}
self._bscan_history_floor_collection_id = 0
self._bscan_render_signature = None
self._gpr_lookup_table = None
self._gpr_image_item = None
self._gpr_tx_item = None
self._gpr_rx_item = None
self._gpr_points_item = None
self._gpr_region_centers_item = None
self._gpr_point_labels = []
self._gpr_region_center_labels = []
self._gpr_region_mask_items = []
self._gpr_region_contours = []
self._gpr_geometry_signature = None
self._gpr_selected_geometry = None
self._phase_viewbox = None
self._history_run_signature = None
self._radar_limits: dict[str, float | int] | None = None
@@ -4,7 +4,8 @@ from __future__ import annotations
from python_app.gui.runtime.history import remove_last_aligned_histories
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.models.run_config_model import ComboModel, RunConfigModel
from python_app.models.run_config_model import ComboModel, GprRxGeometryModel, GprTxGeometryModel, RunConfigModel
from python_app.models.run_config_validation import validate_gpr_model
from python_app.orchestration.config_writer import parse_combos_from_text
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
from python_app.storage.npz_store import radar_key_from_config
@@ -13,6 +14,58 @@ from python_app.storage.npz_store import radar_key_from_config
class AppWindowConfigMixin:
"""Builds runtime config models from current UI state."""
@staticmethod
def _parse_csv_int_list(text: str) -> list[int]:
"""Parse comma-separated integer selection list."""
cleaned = text.strip()
if not cleaned:
return []
values: list[int] = []
for part in cleaned.split(","):
token = part.strip()
if not token:
continue
values.append(int(token))
return values
@staticmethod
def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]:
"""Parse line-based Tx geometry editor text."""
entries: list[GprTxGeometryModel] = []
for line_number, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.strip()
if not line:
continue
parts = line.split()
if len(parts) != 2:
raise ValueError(f"Invalid Tx geometry line {line_number}: expected `output_pos x_m`")
entries.append(
GprTxGeometryModel(
output_pos=int(parts[0]),
x_m=float(parts[1]),
)
)
return entries
@staticmethod
def _parse_gpr_rx_geometry_text(text: str) -> list[GprRxGeometryModel]:
"""Parse line-based Rx geometry editor text."""
entries: list[GprRxGeometryModel] = []
for line_number, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.strip()
if not line:
continue
parts = line.split()
if len(parts) != 2:
raise ValueError(f"Invalid Rx geometry line {line_number}: expected `input_pos x_m`")
entries.append(
GprRxGeometryModel(
input_pos=int(parts[0]),
x_m=float(parts[1]),
)
)
return entries
def _save_current_config(self) -> None:
"""Persist currently selected GUI settings into root run_config.json."""
try:
@@ -68,6 +121,15 @@ class AppWindowConfigMixin:
config.preprocess.calibration_set = self._selected_calibration_set
config.preprocess.reference_set = self._selected_reference_set
config.gpr.mode = self._gpr_config_mode.currentText()
config.gpr.relative_permittivity = float(self._gpr_relative_permittivity.value())
config.gpr.tx_geometry = self._parse_gpr_tx_geometry_text(self._gpr_tx_geometry_input.toPlainText())
config.gpr.rx_geometry = self._parse_gpr_rx_geometry_text(self._gpr_rx_geometry_input.toPlainText())
validate_gpr_model(
config.gpr,
input_switch_positions=config.input_switch.positions,
output_switch_positions=config.output_switch.positions,
)
return config
def _radar_key(self, config: RunConfigModel) -> str:
@@ -85,16 +147,31 @@ class AppWindowConfigMixin:
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()
self._sync_gpr_frequency_limits_with_radar()
y_min_db = float(self._pass_through_y_min_db.value())
y_max_db = float(self._pass_through_y_max_db.value())
return ProcessingLiveConfig(
processor_mode=self._processing_mode.currentText(),
gain_db=float(self._processing_gain_db.value()),
phase_deg=float(self._processing_phase_deg.value()),
pass_through_fixed_y_enabled=bool(self._pass_through_fixed_y_enabled.isChecked()),
pass_through_y_min_db=min(y_min_db, y_max_db),
pass_through_y_max_db=max(y_min_db, y_max_db),
bscan_axis=self._bscan_axis.currentText(),
bscan_cut_m=float(self._bscan_cut_m.value()),
bscan_max_depth_m=float(self._bscan_max_depth_m.value()),
bscan_gain=float(self._bscan_gain.value()),
bscan_start_freq_mhz=float(self._bscan_start_freq_mhz.value()),
bscan_stop_freq_mhz=float(self._bscan_stop_freq_mhz.value()),
gpr_input_positions=self._parse_csv_int_list(self._gpr_input_positions_input.text()),
gpr_output_positions=self._parse_csv_int_list(self._gpr_output_positions_input.text()),
gpr_min_depth_m=float(self._gpr_min_depth_m.value()),
gpr_max_depth_m=float(self._gpr_max_depth_m.value()),
gpr_comp_power=float(self._gpr_comp_power.value()),
gpr_start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
gpr_stop_freq_mhz=float(self._gpr_stop_freq_mhz.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),
history_command=str(history_command),
)
@@ -109,10 +186,18 @@ class AppWindowConfigMixin:
"""Handle live-processing setting changes and trigger redraw when needed."""
try:
self._write_live_processing_config()
if self._processing_mode.currentText() == "bscan":
current_mode = self._processing_mode.currentText()
if current_mode == "bscan":
self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01)
self._sync_bscan_history_from_results()
self._draw_bscan_heatmap_from_history()
elif current_mode == "gpr":
self._drain_results_until_quiet(timeout_s=0.35, poll_s=0.01)
if self._result_history:
if not self._draw_results(self._result_history[-1]):
self._clear_gpr_plot()
else:
self._clear_gpr_plot()
elif self._result_history:
self._draw_results(self._result_history[-1])
except Exception as exc: # noqa: BLE001
@@ -123,6 +208,7 @@ class AppWindowConfigMixin:
mode_to_page = {
"pass_through": 0,
"bscan": 1,
"gpr": 2,
}
self._set_plot_mode(mode)
self._processing_mode_pages.setCurrentIndex(mode_to_page.get(mode, 0))
@@ -134,17 +220,31 @@ class AppWindowConfigMixin:
def _on_bscan_clear_history_clicked(self) -> None:
"""Permanently clear all runtime histories, ring backlogs, and B-scan cache."""
self._apply_bscan_history_deletion(remove_last_only=False)
self._apply_history_mode_deletion(mode_label="B-scan", remove_last_only=False)
def _on_bscan_remove_last_sweep_clicked(self) -> None:
"""Permanently delete the latest sweep from runtime histories and rings."""
self._apply_bscan_history_deletion(remove_last_only=True)
self._apply_history_mode_deletion(mode_label="B-scan", remove_last_only=True)
def _apply_bscan_history_deletion(self, *, remove_last_only: bool) -> None:
"""Apply destructive B-scan history deletion via C++ processor history commands."""
def _on_gpr_clear_history_clicked(self) -> None:
"""Permanently clear all runtime histories, ring backlogs, and GPR cache."""
self._apply_history_mode_deletion(mode_label="GPR", remove_last_only=False)
def _on_gpr_remove_last_measurement_clicked(self) -> None:
"""Permanently delete the latest measurement from runtime histories and rings."""
self._apply_history_mode_deletion(mode_label="GPR", remove_last_only=True)
def _clear_history_mode_caches(self) -> None:
"""Drop mode-specific cached render state."""
self._clear_bscan_plot_history()
if hasattr(self, "_gpr_plot"):
self._clear_gpr_plot()
def _apply_history_mode_deletion(self, *, mode_label: str, remove_last_only: bool) -> None:
"""Apply destructive history deletion via C++ processor history commands."""
resume_acquisition = self._supervisor.is_running()
history_command = "remove_last" if remove_last_only else "clear_all"
action = "last sweep removed" if remove_last_only else "history fully cleared"
action = "last measurement removed" if remove_last_only else "history fully cleared"
dropped_results = 0
try:
@@ -169,7 +269,7 @@ class AppWindowConfigMixin:
retained_pre=retained_pre,
retained_result=retained_result,
)
self._clear_bscan_plot_history()
self._clear_history_mode_caches()
self._write_live_processing_config(history_command=history_command, bump_history_seq=True)
@@ -180,9 +280,9 @@ class AppWindowConfigMixin:
self._redraw_after_history_deletion()
if resume_acquisition:
self._start_run()
self._log(f"B-scan {action}; dropped pending results={dropped_results}")
self._log(f"{mode_label} {action}; dropped pending results={dropped_results}")
except Exception as exc: # noqa: BLE001
self._show_error(f"Failed to delete B-scan history: {exc}")
self._show_error(f"Failed to delete {mode_label} history: {exc}")
def _redraw_after_history_deletion(self) -> None:
"""Refresh plot immediately after destructive history deletion."""
@@ -192,6 +292,11 @@ class AppWindowConfigMixin:
if not self._draw_bscan_heatmap_from_history():
self._bscan_plot.clear()
return
if self._processing_mode.currentText() == "gpr":
if self._result_history and self._draw_results(self._result_history[-1]):
return
self._clear_gpr_plot()
return
if self._result_history:
self._draw_results(self._result_history[-1])
return
@@ -208,8 +313,8 @@ class AppWindowConfigMixin:
self._on_processing_live_settings_changed()
def _on_radar_sweep_limits_changed(self) -> None:
"""Clamp B-scan frequency bounds after sweep start/stop edits."""
if self._sync_bscan_frequency_limits_with_radar():
"""Clamp processing frequency bounds after sweep start/stop edits."""
if self._sync_processing_frequency_limits_with_radar():
self._on_processing_live_settings_changed()
def _refresh_radar_limits_from_device(self) -> bool:
@@ -301,7 +406,7 @@ class AppWindowConfigMixin:
or prev_power != self._power_input.text().strip()
)
self._sync_bscan_frequency_limits_with_radar()
self._sync_processing_frequency_limits_with_radar()
return changed
@staticmethod
@@ -326,16 +431,16 @@ class AppWindowConfigMixin:
widget.setText(str(value))
return value
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
"""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",
)
def _sync_processing_frequency_limits_with_radar(self) -> bool:
"""Synchronize all processing frequency widgets with radar sweep bounds."""
bscan_changed = self._sync_bscan_frequency_limits_with_radar()
gpr_changed = self._sync_gpr_frequency_limits_with_radar()
return bscan_changed or gpr_changed
def _sync_frequency_spinboxes_with_radar(self, widget_names: tuple[str, ...]) -> bool:
"""Synchronize one or more MHz spin boxes with current radar sweep bounds."""
required_widgets = ("_start_hz_input", "_stop_hz_input", *widget_names)
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:
@@ -348,28 +453,41 @@ class AppWindowConfigMixin:
radar_max_mhz = max(radar_start_hz, radar_stop_hz) / 1_000_000.0
changed = False
for widget in (self._bscan_start_freq_mhz, self._bscan_stop_freq_mhz):
widgets = [getattr(self, widget_name) for widget_name in widget_names]
for widget in widgets:
if widget.minimum() != radar_min_mhz or widget.maximum() != radar_max_mhz:
changed = True
widget.blockSignals(True)
widget.setRange(radar_min_mhz, radar_max_mhz)
widget.blockSignals(False)
clamped_start_mhz = min(max(self._bscan_start_freq_mhz.value(), radar_min_mhz), radar_max_mhz)
clamped_stop_mhz = min(max(self._bscan_stop_freq_mhz.value(), radar_min_mhz), radar_max_mhz)
if clamped_start_mhz != self._bscan_start_freq_mhz.value():
changed = True
self._bscan_start_freq_mhz.blockSignals(True)
self._bscan_start_freq_mhz.setValue(clamped_start_mhz)
self._bscan_start_freq_mhz.blockSignals(False)
if clamped_stop_mhz != self._bscan_stop_freq_mhz.value():
changed = True
self._bscan_stop_freq_mhz.blockSignals(True)
self._bscan_stop_freq_mhz.setValue(clamped_stop_mhz)
self._bscan_stop_freq_mhz.blockSignals(False)
for widget in widgets:
clamped_value = min(max(widget.value(), radar_min_mhz), radar_max_mhz)
if clamped_value != widget.value():
changed = True
widget.blockSignals(True)
widget.setValue(clamped_value)
widget.blockSignals(False)
return changed
def _sync_bscan_frequency_limits_with_radar(self) -> bool:
"""Synchronize B-scan start/stop MHz widget ranges with radar sweep bounds."""
return self._sync_frequency_spinboxes_with_radar(
(
"_bscan_start_freq_mhz",
"_bscan_stop_freq_mhz",
)
)
def _sync_gpr_frequency_limits_with_radar(self) -> bool:
"""Synchronize GPR start/stop MHz widget ranges with radar sweep bounds."""
return self._sync_frequency_spinboxes_with_radar(
(
"_gpr_start_freq_mhz",
"_gpr_stop_freq_mhz",
)
)
@staticmethod
def _switches_are_effectively_static(config: RunConfigModel) -> bool:
"""Return `True` when switch setup effectively yields one fixed combo."""
@@ -353,7 +353,7 @@ class AppWindowPipelineMixin:
"""Reset runtime history and B-scan caches."""
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
self._bscan_history_floor_collection_id = 0
self._clear_bscan_plot_history()
self._clear_history_mode_caches()
self._update_history_indicator()
def _replace_runtime_history(
@@ -385,4 +385,8 @@ class AppWindowPipelineMixin:
def _validate_processing_mode_constraints(self, config: RunConfigModel) -> None:
"""Validate processing-mode constraints for run start."""
validate_processing_mode_constraints(self._processing_mode.currentText(), config)
validate_processing_mode_constraints(
self._processing_mode.currentText(),
config,
self._live_processing_config(),
)
@@ -36,6 +36,8 @@ class AppWindowPlotMixin:
"""Draw collection based on currently selected processing mode."""
if self._processing_mode.currentText() == "bscan":
return self._draw_bscan_heatmap(collection)
if self._processing_mode.currentText() == "gpr":
return self._draw_gpr_map(collection)
return self._draw_trace_lines(collection)
def _show_magnitude_curves(self) -> bool:
@@ -46,9 +48,24 @@ class AppWindowPlotMixin:
"""Return whether phase curves should be rendered."""
return self._show_phase_checkbox.isChecked()
def _pass_through_fixed_y_range(self) -> tuple[bool, float, float]:
"""Return normalized magnitude Y-range override for pass-through mode."""
y_min = float(self._pass_through_y_min_db.value())
y_max = float(self._pass_through_y_max_db.value())
return bool(self._pass_through_fixed_y_enabled.isChecked()), min(y_min, y_max), max(y_min, y_max)
def _configure_pass_through_magnitude_axis(self, plot: pg.PlotWidget) -> None:
"""Apply pass-through magnitude-axis autorange or fixed Y window."""
fixed_y_enabled, y_min, y_max = self._pass_through_fixed_y_range()
view_box = plot.getViewBox()
view_box.invertY(False)
view_box.enableAutoRange(x=True, y=not fixed_y_enabled)
if fixed_y_enabled:
plot.setYRange(y_min, y_max, padding=0.0)
def _on_trace_visibility_changed(self, *_args) -> None:
"""Redraw pass-through traces when magnitude/phase toggles changed."""
if self._processing_mode.currentText() == "bscan":
if self._processing_mode.currentText() in {"bscan", "gpr"}:
return
if self._result_history:
self._draw_results(self._result_history[-1])
@@ -98,8 +115,7 @@ class AppWindowPlotMixin:
if show_magnitude:
mag_item = magnitude_plot.getPlotItem()
magnitude_plot.getViewBox().invertY(False)
magnitude_plot.getViewBox().enableAutoRange(x=True, y=True)
self._configure_pass_through_magnitude_axis(magnitude_plot)
mag_item.showAxis("left", show=True)
mag_item.showAxis("bottom", show=not show_phase)
magnitude_plot.setLabel("left", "Magnitude", units="dB")
@@ -219,6 +235,8 @@ class AppWindowPlotMixin:
phase_plot.setXRange(x_min, x_max, padding=0.02)
if show_phase:
phase_plot.setYRange(-180.0, 180.0, padding=0.02)
if show_magnitude:
self._configure_pass_through_magnitude_axis(magnitude_plot)
return has_data
@staticmethod
@@ -481,8 +499,290 @@ class AppWindowPlotMixin:
"""Hide right axis and clear phase overlay when phase is not rendered."""
self._clear_phase_overlay()
def _clear_gpr_plot(self) -> None:
"""Clear latest GPR plot surface."""
if not hasattr(self, "_gpr_plot"):
return
self._clear_gpr_point_labels()
self._clear_gpr_region_labels()
self._clear_gpr_region_masks()
if self._gpr_image_item is not None:
self._gpr_image_item.hide()
if self._gpr_tx_item is not None:
self._gpr_tx_item.setData(x=[], y=[])
self._gpr_tx_item.hide()
if self._gpr_rx_item is not None:
self._gpr_rx_item.setData(x=[], y=[])
self._gpr_rx_item.hide()
if self._gpr_points_item is not None:
self._gpr_points_item.setData(x=[], y=[])
self._gpr_points_item.hide()
if self._gpr_region_centers_item is not None:
self._gpr_region_centers_item.setData(x=[], y=[])
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
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")
view_box = plot.getViewBox()
view_box.invertY(True)
view_box.enableAutoRange(x=False, y=False)
if self._gpr_lookup_table is None:
self._gpr_lookup_table = self._build_lut(["#081c15", "#1b4332", "#ffd166", "#f94144"])
self._gpr_image_item = pg.ImageItem(axisOrder="row-major")
self._gpr_image_item.setZValue(0)
self._gpr_image_item.hide()
plot.addItem(self._gpr_image_item)
self._gpr_tx_item = pg.ScatterPlotItem()
self._gpr_tx_item.setZValue(20)
self._gpr_tx_item.hide()
plot.addItem(self._gpr_tx_item)
self._gpr_rx_item = pg.ScatterPlotItem()
self._gpr_rx_item.setZValue(20)
self._gpr_rx_item.hide()
plot.addItem(self._gpr_rx_item)
self._gpr_points_item = pg.ScatterPlotItem()
self._gpr_points_item.setZValue(30)
self._gpr_points_item.hide()
plot.addItem(self._gpr_points_item)
self._gpr_region_centers_item = pg.ScatterPlotItem()
self._gpr_region_centers_item.setZValue(30)
self._gpr_region_centers_item.hide()
plot.addItem(self._gpr_region_centers_item)
def _clear_gpr_point_labels(self) -> None:
"""Remove dynamic point-score labels from GPR plot."""
for item in self._gpr_point_labels:
try:
self._gpr_plot.removeItem(item)
except Exception: # noqa: BLE001
pass
self._gpr_point_labels.clear()
def _clear_gpr_region_labels(self) -> None:
"""Remove dynamic region labels from GPR plot."""
for item in self._gpr_region_center_labels:
try:
self._gpr_plot.removeItem(item)
except Exception: # noqa: BLE001
pass
self._gpr_region_center_labels.clear()
def _clear_gpr_region_masks(self) -> None:
"""Remove dynamic region contour carriers from GPR plot."""
for item in self._gpr_region_mask_items:
try:
self._gpr_plot.removeItem(item)
except Exception: # noqa: BLE001
pass
self._gpr_region_mask_items.clear()
self._gpr_region_contours.clear()
@staticmethod
def _collection_payload_by_name(collection: ResultCollection, name: str, kind: int | None = None):
"""Return first collection payload matching name and optional kind."""
for payload in collection.collection_payloads:
if payload.processing_name != name:
continue
if kind is not None and int(payload.kind) != int(kind):
continue
return payload
return None
@staticmethod
def _collection_payloads_by_prefix(collection: ResultCollection, prefix: str, kind: int | None = None):
"""Return collection payloads matching processing-name prefix."""
payloads = []
for payload in collection.collection_payloads:
if not str(payload.processing_name).startswith(prefix):
continue
if kind is not None and int(payload.kind) != int(kind):
continue
payloads.append(payload)
return payloads
def _selected_gpr_geometry(self) -> tuple[np.ndarray, np.ndarray]:
"""Resolve selected Tx/Rx geometry arrays for current GPR selection."""
requested_inputs = tuple(self._parse_csv_int_list(self._gpr_input_positions_input.text()))
requested_outputs = tuple(self._parse_csv_int_list(self._gpr_output_positions_input.text()))
signature = (
self._gpr_tx_geometry_input.toPlainText(),
self._gpr_rx_geometry_input.toPlainText(),
requested_inputs,
requested_outputs,
)
if signature == self._gpr_geometry_signature and self._gpr_selected_geometry is not None:
return self._gpr_selected_geometry
tx_entries = self._parse_gpr_tx_geometry_text(signature[0])
rx_entries = self._parse_gpr_rx_geometry_text(signature[1])
requested_input_set = set(requested_inputs)
requested_output_set = set(requested_outputs)
rx_entries = sorted(rx_entries, key=lambda entry: int(entry.input_pos))
tx_entries = sorted(tx_entries, key=lambda entry: int(entry.output_pos))
if requested_input_set:
rx_entries = [entry for entry in rx_entries if int(entry.input_pos) in requested_input_set]
if requested_output_set:
tx_entries = [entry for entry in tx_entries if int(entry.output_pos) in requested_output_set]
x_tx = np.asarray([float(entry.x_m) for entry in tx_entries], dtype=np.float32)
x_rx = np.asarray([float(entry.x_m) for entry in rx_entries], dtype=np.float32)
self._gpr_geometry_signature = signature
self._gpr_selected_geometry = (x_tx, x_rx)
return self._gpr_selected_geometry
def _draw_gpr_map(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:
self._clear_gpr_plot()
return False
image = np.asarray(accumulator_payload.image, dtype=np.float32)
x_axis = np.asarray(accumulator_payload.image_x_axis, dtype=np.float32)
y_axis = np.asarray(accumulator_payload.image_y_axis, dtype=np.float32)
if image.ndim != 2 or image.size == 0 or x_axis.size == 0 or y_axis.size == 0:
self._clear_gpr_plot()
return False
x_min = float(x_axis[0])
x_max = float(x_axis[-1])
y_min = float(y_axis[0])
y_max = float(y_axis[-1])
rect = QRectF(x_min, y_min, max(x_max - x_min, 1e-6), max(y_max - y_min, 1e-6))
plot = self._gpr_plot
plot.setUpdatesEnabled(False)
try:
self._ensure_gpr_plot_items()
self._clear_gpr_point_labels()
self._clear_gpr_region_labels()
self._clear_gpr_region_masks()
self._gpr_image_item.setImage(image, autoLevels=False)
self._gpr_image_item.setRect(rect)
self._gpr_image_item.setLookupTable(self._gpr_lookup_table)
self._gpr_image_item.setLevels((float(np.min(image)), float(np.max(image) + 1e-6)))
self._gpr_image_item.show()
plot.setXRange(x_min, x_max, padding=0.02)
plot.setYRange(y_min, 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()
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:
points = np.asarray(points_payload.table, dtype=np.float32)
self._gpr_points_item.setData(
x=points[:, 0],
y=points[:, 1],
symbol="d",
size=11,
brush=pg.mkBrush("#ffffff"),
pen=pg.mkPen("#111111", width=1.1),
)
self._gpr_points_item.show()
for x_value, y_value, score in points:
label = pg.TextItem(text=f"{float(score):.0f}", color="#ffffff", anchor=(0.0, 1.0))
label.setZValue(40)
label.setPos(float(x_value), float(y_value))
plot.addItem(label)
self._gpr_point_labels.append(label)
else:
self._gpr_points_item.setData(x=[], y=[])
self._gpr_points_item.hide()
region_centers_payload = self._collection_payload_by_name(collection, "gpr_region_centers", kind=4)
if region_centers_payload is not None and np.asarray(region_centers_payload.table).size > 0:
centers = np.asarray(region_centers_payload.table, dtype=np.float32)
self._gpr_region_centers_item.setData(
x=centers[:, 0],
y=centers[:, 1],
symbol="o",
size=10,
brush=pg.mkBrush("#80ed99"),
pen=pg.mkPen("#081c15", width=1.1),
)
self._gpr_region_centers_item.show()
for row in centers:
label = pg.TextItem(text=f"{float(row[2]):.0f}", color="#d8f3dc", anchor=(0.0, 1.0))
label.setZValue(40)
label.setPos(float(row[0]), float(row[1]))
plot.addItem(label)
self._gpr_region_center_labels.append(label)
else:
self._gpr_region_centers_item.setData(x=[], y=[])
self._gpr_region_centers_item.hide()
for payload in self._collection_payloads_by_prefix(collection, "gpr_region_mask_", kind=3):
mask = np.asarray(payload.image, dtype=np.float32)
if mask.ndim != 2 or mask.size == 0:
continue
mask_image = pg.ImageItem(axisOrder="row-major")
mask_image.setZValue(5)
mask_image.setImage(mask, autoLevels=False)
mask_image.setRect(rect)
mask_image.setOpacity(0.0)
plot.addItem(mask_image)
contour = pg.IsocurveItem(data=mask, level=0.5, pen=pg.mkPen("#4cc9f0", width=1.3))
contour.setParentItem(mask_image)
self._gpr_region_mask_items.append(mask_image)
self._gpr_region_contours.append(contour)
plot.setTitle(f"GPR {self._gpr_config_mode.currentText()}")
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:
return True
for block in collection.blocks:
for payload in block.payloads:
if payload.kind == 1 and payload.trace.size > 0:
@@ -503,8 +803,7 @@ class AppWindowPlotMixin:
return
if show_magnitude:
magnitude_plot.getViewBox().invertY(False)
magnitude_plot.getViewBox().enableAutoRange(x=True, y=True)
self._configure_pass_through_magnitude_axis(magnitude_plot)
magnitude_plot.getPlotItem().showAxis("bottom", show=not show_phase)
magnitude_plot.setLabel("left", "Magnitude", units="dB")
magnitude_plot.setTitle(title)
@@ -548,5 +847,6 @@ class AppWindowPlotMixin:
x_max = float(np.max(trace.frequency_hz))
if show_magnitude:
magnitude_plot.setXRange(x_min, x_max, padding=0.02)
self._configure_pass_through_magnitude_axis(magnitude_plot)
if show_phase:
phase_plot.setXRange(x_min, x_max, padding=0.02)
@@ -111,7 +111,7 @@ class AppWindowSnapshotMixin:
self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[])
self._bscan_history_floor_collection_id = 0
self._clear_bscan_plot_history()
self._clear_history_mode_caches()
# Clear processor-side replay cache so newly rendered B-scan starts clean.
self._write_live_processing_config(history_command="clear_all", bump_history_seq=True)
@@ -2,6 +2,7 @@
Layout is intentionally split into two independent plot surfaces:
- single `PlotWidget` for B-scan heatmap rendering;
- single `PlotWidget` for GPR accumulator/annotation rendering;
- stacked magnitude/phase `PlotWidget`s for pass-through traces.
`_set_plot_mode()` switches between these surfaces via `QStackedWidget`.
@@ -26,6 +27,7 @@ import pyqtgraph as pg
from python_app.gui.controllers.sections import (
build_data_actions_group,
build_gpr_config_group,
build_hardware_actions_group,
build_pipeline_group,
build_preprocess_summary_group,
@@ -72,6 +74,7 @@ class AppWindowUiMixin:
# We create both upfront and only switch active page at runtime.
self._plot_stack = QStackedWidget(root)
self._build_bscan_plot_page()
self._build_gpr_plot_page()
self._build_trace_plot_page()
# Default view on startup is pass-through traces.
@@ -121,6 +124,12 @@ class AppWindowUiMixin:
self._plot_stack.addWidget(self._trace_plots_container)
def _build_gpr_plot_page(self) -> None:
"""Create GPR page in plot stack."""
self._gpr_plot = pg.PlotWidget(background="#0f141c")
self._gpr_plot.showGrid(x=True, y=True, alpha=0.2)
self._plot_stack.addWidget(self._gpr_plot)
def _build_settings_toggle(self, root_layout: QHBoxLayout) -> None:
"""Create narrow button used to collapse or show settings panel."""
self._settings_toggle_button = QPushButton("<")
@@ -182,6 +191,7 @@ class AppWindowUiMixin:
build_data_actions_group(self),
build_preprocess_summary_group(self),
build_processing_group(self),
build_gpr_config_group(self),
build_radar_group(self),
build_switch_group(self),
]
@@ -205,12 +215,16 @@ class AppWindowUiMixin:
def _set_plot_mode(self, mode: str) -> None:
"""Switch visible plot page according to processing mode.
`bscan` -> show `self._bscan_plot` (single heatmap surface)
otherwise -> show `self._trace_plots_container` (magnitude + phase)
`bscan` -> show `self._bscan_plot`
`gpr` -> show `self._gpr_plot`
otherwise -> show `self._trace_plots_container`
"""
if mode == "bscan":
self._plot_stack.setCurrentWidget(self._bscan_plot)
return
if mode == "gpr":
self._plot_stack.setCurrentWidget(self._gpr_plot)
return
self._plot_stack.setCurrentWidget(self._trace_plots_container)
@staticmethod
@@ -1,6 +1,7 @@
"""Composable UI section builders used by AppWindow UI mixin."""
from python_app.gui.controllers.sections.data_actions_section import build_data_actions_group
from python_app.gui.controllers.sections.gpr_config_section import build_gpr_config_group
from python_app.gui.controllers.sections.hardware_actions_section import build_hardware_actions_group
from python_app.gui.controllers.sections.pipeline_section import build_pipeline_group
from python_app.gui.controllers.sections.preprocess_summary_section import build_preprocess_summary_group
@@ -10,6 +11,7 @@ from python_app.gui.controllers.sections.switch_section import build_switch_grou
__all__ = [
"build_data_actions_group",
"build_gpr_config_group",
"build_hardware_actions_group",
"build_pipeline_group",
"build_preprocess_summary_group",
@@ -0,0 +1,53 @@
"""Builder for stable GPR configuration section."""
from __future__ import annotations
from PyQt6.QtWidgets import QComboBox, QDoubleSpinBox, QFormLayout, QGroupBox, QPlainTextEdit
def _format_tx_geometry(owner) -> str:
"""Render Tx geometry defaults into editable line-based text."""
return "\n".join(
f"{int(entry.output_pos)} {float(entry.x_m):g}"
for entry in owner._defaults_config.gpr.tx_geometry
)
def _format_rx_geometry(owner) -> str:
"""Render Rx geometry defaults into editable line-based text."""
return "\n".join(
f"{int(entry.input_pos)} {float(entry.x_m):g}"
for entry in owner._defaults_config.gpr.rx_geometry
)
def build_gpr_config_group(owner) -> QGroupBox:
"""Create stable GPR config controls backed by run_config.json."""
group = QGroupBox("GPR Config")
form = QFormLayout(group)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
defaults = owner._defaults_config.gpr
owner._gpr_config_mode = QComboBox()
owner._gpr_config_mode.addItems(["point", "extended"])
owner._set_combo_current_text(owner._gpr_config_mode, defaults.mode)
owner._gpr_relative_permittivity = QDoubleSpinBox()
owner._gpr_relative_permittivity.setDecimals(4)
owner._gpr_relative_permittivity.setRange(0.0001, 1000.0)
owner._gpr_relative_permittivity.setSingleStep(0.05)
owner._gpr_relative_permittivity.setValue(float(defaults.relative_permittivity))
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_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)
form.addRow("Mode", owner._gpr_config_mode)
form.addRow("Relative Permittivity", owner._gpr_relative_permittivity)
form.addRow("Tx Geometry", owner._gpr_tx_geometry_input)
form.addRow("Rx Geometry", owner._gpr_rx_geometry_input)
return group
@@ -9,21 +9,39 @@ from PyQt6.QtWidgets import (
QFormLayout,
QGroupBox,
QHBoxLayout,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
QStackedWidget,
QWidget,
)
def _default_gpr_input_positions(owner) -> str:
"""Build default live input-position selection from stable GPR config."""
geometry_values = {int(entry.input_pos) for entry in owner._defaults_config.gpr.rx_geometry}
combo_values = {int(combo.input) for combo in owner._defaults_config.combos}
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
return ",".join(str(value) for value in values)
def _default_gpr_output_positions(owner) -> str:
"""Build default live output-position selection from stable GPR config."""
geometry_values = {int(entry.output_pos) for entry in owner._defaults_config.gpr.tx_geometry}
combo_values = {int(combo.output) for combo in owner._defaults_config.combos}
values = sorted(geometry_values & combo_values) or sorted(geometry_values)
return ",".join(str(value) for value in values)
def build_processing_group(owner) -> QGroupBox:
"""Create processing mode section with pass-through and B-scan pages."""
"""Create processing mode section with pass-through, B-scan, and GPR pages."""
group = QGroupBox("Processing")
form = QFormLayout(group)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
owner._processing_mode = QComboBox()
owner._processing_mode.addItems(["pass_through", "bscan"])
owner._processing_mode.addItems(["pass_through", "bscan", "gpr"])
owner._processing_mode_pages = QStackedWidget(group)
owner._processing_mode_pages.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
@@ -51,10 +69,35 @@ def build_processing_group(owner) -> QGroupBox:
owner._show_phase_checkbox = QCheckBox("Show phase")
owner._show_phase_checkbox.setChecked(True)
owner._pass_through_fixed_y_enabled = QCheckBox("Fix magnitude Y range")
owner._pass_through_fixed_y_enabled.setChecked(False)
owner._pass_through_y_min_db = QDoubleSpinBox()
owner._pass_through_y_min_db.setDecimals(1)
owner._pass_through_y_min_db.setRange(-240.0, 240.0)
owner._pass_through_y_min_db.setSingleStep(1.0)
owner._pass_through_y_min_db.setValue(-100.0)
owner._pass_through_y_max_db = QDoubleSpinBox()
owner._pass_through_y_max_db.setDecimals(1)
owner._pass_through_y_max_db.setRange(-240.0, 240.0)
owner._pass_through_y_max_db.setSingleStep(1.0)
owner._pass_through_y_max_db.setValue(0.0)
def sync_pass_through_y_controls() -> None:
enabled = owner._pass_through_fixed_y_enabled.isChecked()
owner._pass_through_y_min_db.setEnabled(enabled)
owner._pass_through_y_max_db.setEnabled(enabled)
sync_pass_through_y_controls()
pass_through_form.addRow("Gain dB (live)", owner._processing_gain_db)
pass_through_form.addRow("Phase deg (live)", owner._processing_phase_deg)
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)
owner._processing_mode_pages.addWidget(pass_through_page)
bscan_page = QWidget(owner._processing_mode_pages)
@@ -115,11 +158,86 @@ def build_processing_group(owner) -> QGroupBox:
bscan_form.addRow(bscan_actions)
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)
owner._gpr_input_positions_input = QLineEdit(_default_gpr_input_positions(owner))
owner._gpr_input_positions_input.setPlaceholderText("0,1,2")
owner._gpr_output_positions_input = QLineEdit(_default_gpr_output_positions(owner))
owner._gpr_output_positions_input.setPlaceholderText("0,1")
owner._gpr_min_depth_m = QDoubleSpinBox()
owner._gpr_min_depth_m.setDecimals(2)
owner._gpr_min_depth_m.setRange(0.0, 50.0)
owner._gpr_min_depth_m.setSingleStep(0.1)
owner._gpr_min_depth_m.setValue(2.0)
owner._gpr_max_depth_m = QDoubleSpinBox()
owner._gpr_max_depth_m.setDecimals(2)
owner._gpr_max_depth_m.setRange(0.1, 50.0)
owner._gpr_max_depth_m.setSingleStep(0.1)
owner._gpr_max_depth_m.setValue(14.0)
owner._gpr_comp_power = QDoubleSpinBox()
owner._gpr_comp_power.setDecimals(3)
owner._gpr_comp_power.setRange(0.0, 5.0)
owner._gpr_comp_power.setSingleStep(0.05)
owner._gpr_comp_power.setValue(0.2)
owner._gpr_start_freq_mhz = QDoubleSpinBox()
owner._gpr_start_freq_mhz.setDecimals(1)
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0)
owner._gpr_start_freq_mhz.setSingleStep(10.0)
owner._gpr_start_freq_mhz.setValue(3000.0)
owner._gpr_stop_freq_mhz = QDoubleSpinBox()
owner._gpr_stop_freq_mhz.setDecimals(1)
owner._gpr_stop_freq_mhz.setRange(100.0, 8800.0)
owner._gpr_stop_freq_mhz.setSingleStep(10.0)
owner._gpr_stop_freq_mhz.setValue(6000.0)
owner._gpr_background_subtract_enabled = QCheckBox("Subtract mean of previous collections")
owner._gpr_background_subtract_enabled.setChecked(True)
owner._gpr_background_mean_count = QSpinBox()
owner._gpr_background_mean_count.setRange(0, 10_000)
owner._gpr_background_mean_count.setValue(10)
owner._gpr_clear_history_button = QPushButton("Clear GPR History")
owner._gpr_clear_history_button.clicked.connect(owner._on_gpr_clear_history_clicked)
owner._gpr_remove_last_button = QPushButton("Remove Last Measurement")
owner._gpr_remove_last_button.clicked.connect(owner._on_gpr_remove_last_measurement_clicked)
gpr_actions = QWidget(owner._processing_mode_pages)
gpr_actions_layout = QHBoxLayout(gpr_actions)
gpr_actions_layout.setContentsMargins(0, 0, 0, 0)
gpr_actions_layout.setSpacing(8)
gpr_actions_layout.addWidget(owner._gpr_remove_last_button)
gpr_actions_layout.addWidget(owner._gpr_clear_history_button)
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(owner._gpr_background_subtract_enabled)
gpr_form.addRow("Mean count", owner._gpr_background_mean_count)
gpr_form.addRow(gpr_actions)
owner._processing_mode_pages.addWidget(gpr_page)
owner._processing_mode.currentTextChanged.connect(owner._on_processing_mode_changed)
owner._processing_gain_db.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._processing_phase_deg.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._show_magnitude_checkbox.toggled.connect(owner._on_trace_visibility_changed)
owner._show_phase_checkbox.toggled.connect(owner._on_trace_visibility_changed)
owner._pass_through_fixed_y_enabled.toggled.connect(sync_pass_through_y_controls)
owner._pass_through_fixed_y_enabled.toggled.connect(owner._on_processing_live_settings_changed)
owner._pass_through_y_min_db.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._pass_through_y_max_db.valueChanged.connect(owner._on_processing_live_settings_changed)
form.addRow("Mode", owner._processing_mode)
form.addRow(owner._processing_mode_pages)
@@ -129,6 +247,15 @@ def build_processing_group(owner) -> QGroupBox:
owner._bscan_gain.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._bscan_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._bscan_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_input_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
owner._gpr_output_positions_input.editingFinished.connect(owner._on_processing_live_settings_changed)
owner._gpr_min_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_stop_freq_mhz.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._on_processing_mode_changed(owner._processing_mode.currentText())
return group
+41 -7
View File
@@ -3,19 +3,53 @@
from __future__ import annotations
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.live_processing_config import ProcessingLiveConfig
def validate_processing_mode_constraints(processing_mode: str, config: RunConfigModel) -> None:
def validate_processing_mode_constraints(
processing_mode: str,
config: RunConfigModel,
live_config: ProcessingLiveConfig,
) -> None:
"""Validate mode-specific constraints for current run configuration."""
if processing_mode != "bscan":
if processing_mode == "bscan":
any_native_switch = config.input_switch.driver_mode == "native" or config.output_switch.driver_mode == "native"
if not any_native_switch:
return
combo_count = len({(int(combo.input), int(combo.output)) for combo in config.combos})
if combo_count != 1:
raise RuntimeError(
f"B-scan with native switches requires exactly one run combo (now {combo_count})"
)
return
any_native_switch = config.input_switch.driver_mode == "native" or config.output_switch.driver_mode == "native"
if not any_native_switch:
if processing_mode != "gpr":
return
combo_count = len({(int(combo.input), int(combo.output)) for combo in config.combos})
if combo_count != 1:
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:
raise RuntimeError("GPR requires non-empty Tx/Rx geometry in run_config")
requested_inputs = sorted({int(value) for value in live_config.gpr_input_positions})
requested_outputs = sorted({int(value) for value in live_config.gpr_output_positions})
selected_inputs = requested_inputs or available_inputs
selected_outputs = requested_outputs or available_outputs
missing_inputs = [value for value in selected_inputs if value not in available_inputs]
missing_outputs = [value for value in selected_outputs if value not in available_outputs]
if missing_inputs:
raise RuntimeError(f"GPR input positions are missing from geometry config: {missing_inputs}")
if missing_outputs:
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(
f"B-scan with native switches requires exactly one run combo (now {combo_count})"
"GPR run combos do not cover selected input/output positions: "
f"{missing_combos}"
)
+22 -2
View File
@@ -7,6 +7,21 @@ from dataclasses import dataclass, field
import numpy as np
def _empty_f32_array() -> np.ndarray:
"""Return empty float32 array used by payload defaults."""
return np.array([], dtype=np.float32)
def _empty_c64_array() -> np.ndarray:
"""Return empty complex64 array used by payload defaults."""
return np.array([], dtype=np.complex64)
def _empty_f32_matrix() -> np.ndarray:
"""Return empty 2D float32 matrix used by payload defaults."""
return np.zeros((0, 0), dtype=np.float32)
@dataclass(frozen=True, slots=True)
class ComboKey:
"""Switch combination key: input position + output position."""
@@ -39,9 +54,13 @@ class ResultPayload:
processing_name: str
kind: int
frequency_hz: np.ndarray
trace: np.ndarray
frequency_hz: np.ndarray = field(default_factory=_empty_f32_array)
trace: np.ndarray = field(default_factory=_empty_c64_array)
scalar_value: float = 0.0
image_x_axis: np.ndarray = field(default_factory=_empty_f32_array)
image_y_axis: np.ndarray = field(default_factory=_empty_f32_array)
image: np.ndarray = field(default_factory=_empty_f32_matrix)
table: np.ndarray = field(default_factory=_empty_f32_matrix)
@dataclass(slots=True)
@@ -58,4 +77,5 @@ class ResultCollection:
collection_id: int
monotonic_ns: int
collection_payloads: list[ResultPayload] = field(default_factory=list)
blocks: list[ResultBlock] = field(default_factory=list)
+58 -2
View File
@@ -4,8 +4,13 @@ from __future__ import annotations
from typing import Any
from python_app.models.run_config_schema import ComboModel, RunConfigModel
from python_app.models.run_config_validation import load_ring_payload, load_switch_payload
from python_app.models.run_config_schema import (
ComboModel,
GprRxGeometryModel,
GprTxGeometryModel,
RunConfigModel,
)
from python_app.models.run_config_validation import load_ring_payload, load_switch_payload, validate_gpr_model
def _as_dict(value: Any, context: str) -> dict[str, Any]:
@@ -29,6 +34,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
port2_payload = _as_dict(switches_payload.get("port2"), "switches.port2")
run_payload = _as_dict(payload.get("run"), "run")
preprocess_payload = _as_dict(payload.get("preprocess"), "preprocess")
gpr_payload = _as_dict(payload.get("gpr"), "gpr")
rings_payload = _as_dict(payload.get("rings"), "rings")
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")
@@ -68,6 +74,38 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
preprocess_payload.get("reference_bundle_path", model.preprocess.reference_bundle_path)
)
model.gpr.mode = str(gpr_payload.get("mode", model.gpr.mode))
model.gpr.relative_permittivity = float(
gpr_payload.get("relative_permittivity", model.gpr.relative_permittivity)
)
model.gpr.tx_geometry = []
tx_geometry_payload = gpr_payload.get("tx_geometry", [])
if isinstance(tx_geometry_payload, list):
for entry in tx_geometry_payload:
entry_payload = _as_dict(entry, "gpr.tx_geometry[]")
model.gpr.tx_geometry.append(
GprTxGeometryModel(
output_pos=int(entry_payload.get("output_pos", 0)),
x_m=float(entry_payload.get("x_m", 0.0)),
)
)
model.gpr.rx_geometry = []
rx_geometry_payload = gpr_payload.get("rx_geometry", [])
if isinstance(rx_geometry_payload, list):
for entry in rx_geometry_payload:
entry_payload = _as_dict(entry, "gpr.rx_geometry[]")
model.gpr.rx_geometry.append(
GprRxGeometryModel(
input_pos=int(entry_payload.get("input_pos", 0)),
x_m=float(entry_payload.get("x_m", 0.0)),
)
)
validate_gpr_model(
model.gpr,
input_switch_positions=model.input_switch.positions,
output_switch_positions=model.output_switch.positions,
)
load_ring_payload(raw_ring_payload, model.rings.raw)
load_ring_payload(raw_tap_ring_payload, model.rings.raw_tap)
load_ring_payload(pre_ring_payload, model.rings.preprocessed)
@@ -145,6 +183,24 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"calibration_bundle_path": model.preprocess.calibration_bundle_path,
"reference_bundle_path": model.preprocess.reference_bundle_path,
},
"gpr": {
"mode": model.gpr.mode,
"relative_permittivity": model.gpr.relative_permittivity,
"tx_geometry": [
{
"output_pos": entry.output_pos,
"x_m": entry.x_m,
}
for entry in model.gpr.tx_geometry
],
"rx_geometry": [
{
"input_pos": entry.input_pos,
"x_m": entry.x_m,
}
for entry in model.gpr.rx_geometry
],
},
"rings": {
"raw": {
"name": model.rings.raw.name,
+6
View File
@@ -3,6 +3,9 @@
from python_app.models.run_config_codec import run_config_from_dict, run_config_to_dict
from python_app.models.run_config_schema import (
ComboModel,
GprModel,
GprRxGeometryModel,
GprTxGeometryModel,
PreprocessModel,
RadarModel,
RadarSweepModel,
@@ -20,6 +23,9 @@ from python_app.models.run_config_validation import (
__all__ = [
"ComboModel",
"GprModel",
"GprRxGeometryModel",
"GprTxGeometryModel",
"PreprocessModel",
"RadarModel",
"RadarSweepModel",
+27
View File
@@ -95,6 +95,32 @@ class PreprocessModel:
reference_bundle_path: str = ""
@dataclass(slots=True)
class GprTxGeometryModel:
"""One transmitter geometry record keyed by output switch position."""
output_pos: int = 0
x_m: float = 0.0
@dataclass(slots=True)
class GprRxGeometryModel:
"""One receiver geometry record keyed by input switch position."""
input_pos: int = 0
x_m: float = 0.0
@dataclass(slots=True)
class GprModel:
"""Stable GPR configuration saved in run_config.json."""
mode: str = "point"
relative_permittivity: float = 1.0
tx_geometry: list[GprTxGeometryModel] = field(default_factory=list)
rx_geometry: list[GprRxGeometryModel] = field(default_factory=list)
@dataclass(slots=True)
class RunConfigModel:
"""Top-level runtime config model consumed by C++ processes and GUI."""
@@ -105,6 +131,7 @@ class RunConfigModel:
rings: RingsModel = field(default_factory=RingsModel)
runtime: RuntimeModel = field(default_factory=RuntimeModel)
preprocess: PreprocessModel = field(default_factory=PreprocessModel)
gpr: GprModel = field(default_factory=GprModel)
combos: list[ComboModel] = field(default_factory=list)
@staticmethod
+32 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from python_app.models.run_config_schema import ComboModel, RingEndpointModel, SwitchModel
from python_app.models.run_config_schema import ComboModel, GprModel, RingEndpointModel, SwitchModel
def load_switch_payload(
payload: dict[str, Any],
@@ -30,6 +30,37 @@ def load_ring_payload(payload: dict[str, Any], target: RingEndpointModel) -> Non
target.slot_size_bytes = int(payload.get("slot_size_bytes", target.slot_size_bytes))
def validate_gpr_model(
gpr: GprModel,
*,
input_switch_positions: int,
output_switch_positions: int,
) -> None:
"""Validate stable GPR config against current switch dimensions."""
if gpr.mode not in {"point", "extended"}:
raise ValueError("gpr.mode must be either 'point' or 'extended'")
if float(gpr.relative_permittivity) <= 0.0:
raise ValueError("gpr.relative_permittivity must be > 0")
seen_output_positions: set[int] = set()
for entry in gpr.tx_geometry:
output_pos = int(entry.output_pos)
if output_pos < 0 or output_pos >= int(output_switch_positions):
raise ValueError("gpr.tx_geometry output_pos is out of range")
if output_pos in seen_output_positions:
raise ValueError("gpr.tx_geometry contains duplicate output_pos")
seen_output_positions.add(output_pos)
seen_input_positions: set[int] = set()
for entry in gpr.rx_geometry:
input_pos = int(entry.input_pos)
if input_pos < 0 or input_pos >= int(input_switch_positions):
raise ValueError("gpr.rx_geometry input_pos is out of range")
if input_pos in seen_input_positions:
raise ValueError("gpr.rx_geometry contains duplicate input_pos")
seen_input_positions.add(input_pos)
def parse_combos_from_text(text: str) -> list[ComboModel]:
"""Parse UI combos string in `input:output,input:output` format."""
cleaned = text.strip()
@@ -14,27 +14,62 @@ class ProcessingLiveConfig:
processor_mode: str = "pass_through"
gain_db: float = 0.0
phase_deg: float = 0.0
pass_through_fixed_y_enabled: bool = False
pass_through_y_min_db: float = -100.0
pass_through_y_max_db: float = 0.0
bscan_axis: str = "abs"
bscan_cut_m: float = 0.824
bscan_max_depth_m: float = 1.0
bscan_gain: float = 1.0
bscan_start_freq_mhz: float = 100.0
bscan_stop_freq_mhz: float = 8800.0
gpr_input_positions: list[int] | None = None
gpr_output_positions: list[int] | None = None
gpr_min_depth_m: float = 2.0
gpr_max_depth_m: float = 14.0
gpr_comp_power: float = 0.2
gpr_start_freq_mhz: float = 3000.0
gpr_stop_freq_mhz: float = 6000.0
gpr_background_subtract_enabled: bool = True
gpr_background_mean_count: int = 10
history_command_seq: int = 0
history_command: str = "none"
def to_dict(self) -> dict[str, float | str | int]:
def __post_init__(self) -> None:
"""Normalize optional list fields to concrete integer lists."""
if self.gpr_input_positions is None:
self.gpr_input_positions = []
else:
self.gpr_input_positions = [int(value) for value in self.gpr_input_positions]
if self.gpr_output_positions is None:
self.gpr_output_positions = []
else:
self.gpr_output_positions = [int(value) for value in self.gpr_output_positions]
def to_dict(self) -> dict[str, object]:
"""Convert live config to JSON-serializable dictionary."""
return {
"processor_mode": str(self.processor_mode),
"gain_db": float(self.gain_db),
"phase_deg": float(self.phase_deg),
"pass_through_fixed_y_enabled": bool(self.pass_through_fixed_y_enabled),
"pass_through_y_min_db": float(self.pass_through_y_min_db),
"pass_through_y_max_db": float(self.pass_through_y_max_db),
"bscan_axis": str(self.bscan_axis),
"bscan_cut_m": float(self.bscan_cut_m),
"bscan_max_depth_m": float(self.bscan_max_depth_m),
"bscan_gain": float(self.bscan_gain),
"bscan_start_freq_mhz": float(self.bscan_start_freq_mhz),
"bscan_stop_freq_mhz": float(self.bscan_stop_freq_mhz),
"gpr_input_positions": [int(value) for value in self.gpr_input_positions],
"gpr_output_positions": [int(value) for value in self.gpr_output_positions],
"gpr_min_depth_m": float(self.gpr_min_depth_m),
"gpr_max_depth_m": float(self.gpr_max_depth_m),
"gpr_comp_power": float(self.gpr_comp_power),
"gpr_start_freq_mhz": float(self.gpr_start_freq_mhz),
"gpr_stop_freq_mhz": float(self.gpr_stop_freq_mhz),
"gpr_background_subtract_enabled": bool(self.gpr_background_subtract_enabled),
"gpr_background_mean_count": int(self.gpr_background_mean_count),
"history_command_seq": int(self.history_command_seq),
"history_command": str(self.history_command),
}
+68 -31
View File
@@ -56,6 +56,62 @@ def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollect
def decode_result_collection(payload: bytes) -> ResultCollection:
"""Decode one processed result collection from binary payload."""
def read_payload(cursor: ByteCursor) -> ResultPayload:
"""Decode one result payload from stream."""
kind = cursor.read_u8()
name_size = cursor.read_u16()
name = cursor.read_bytes(name_size).decode("utf-8")
if kind == 1:
point_count = cursor.read_u32()
freq = np.frombuffer(cursor.read_bytes(point_count * 4), dtype="<f4").astype(np.float32, copy=False)
interleaved = np.frombuffer(cursor.read_bytes(point_count * 8), dtype="<f4")
trace = (interleaved[0::2] + 1j * interleaved[1::2]).astype(np.complex64, copy=False)
return ResultPayload(
processing_name=name,
kind=kind,
frequency_hz=freq,
trace=trace,
)
if kind == 2:
return ResultPayload(
processing_name=name,
kind=kind,
scalar_value=cursor.read_f32(),
)
if kind == 3:
x_count = cursor.read_u32()
y_count = cursor.read_u32()
image_x_axis = np.frombuffer(cursor.read_bytes(x_count * 4), dtype="<f4").astype(np.float32, copy=False)
image_y_axis = np.frombuffer(cursor.read_bytes(y_count * 4), dtype="<f4").astype(np.float32, copy=False)
value_count = x_count * y_count
image_values = np.frombuffer(cursor.read_bytes(value_count * 4), dtype="<f4").astype(np.float32, copy=False)
image = image_values.reshape((y_count, x_count)) if value_count > 0 else np.zeros((0, 0), dtype=np.float32)
return ResultPayload(
processing_name=name,
kind=kind,
image_x_axis=image_x_axis,
image_y_axis=image_y_axis,
image=image,
)
if kind == 4:
table_columns = cursor.read_u32()
table_rows = cursor.read_u32()
value_count = table_columns * table_rows
table_values = np.frombuffer(cursor.read_bytes(value_count * 4), dtype="<f4").astype(np.float32, copy=False)
table = (
table_values.reshape((table_rows, table_columns))
if value_count > 0 and table_columns > 0
else np.zeros((0, 0), dtype=np.float32)
)
return ResultPayload(
processing_name=name,
kind=kind,
table=table,
)
raise ValueError(f"Unsupported result payload kind: {kind}")
cursor = ByteCursor(payload)
magic = cursor.read_u32()
if magic != RESULT_MAGIC:
@@ -63,8 +119,13 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
collection_id = cursor.read_u64()
monotonic_ns = cursor.read_u64()
collection_payload_count = cursor.read_u32()
block_count = cursor.read_u32()
collection_payloads: list[ResultPayload] = []
for _ in range(collection_payload_count):
collection_payloads.append(read_payload(cursor))
blocks: list[ResultBlock] = []
for _ in range(block_count):
input_pos = cursor.read_u32()
@@ -73,36 +134,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
payloads: list[ResultPayload] = []
for _ in range(payload_count):
kind = cursor.read_u8()
name_size = cursor.read_u16()
name = cursor.read_bytes(name_size).decode("utf-8")
if kind == 1:
point_count = cursor.read_u32()
freq = np.frombuffer(cursor.read_bytes(point_count * 4), dtype="<f4").astype(np.float32, copy=False)
interleaved = np.frombuffer(cursor.read_bytes(point_count * 8), dtype="<f4")
trace = (interleaved[0::2] + 1j * interleaved[1::2]).astype(np.complex64, copy=False)
payloads.append(
ResultPayload(
processing_name=name,
kind=kind,
frequency_hz=freq,
trace=trace,
)
)
elif kind == 2:
scalar_value = cursor.read_f32()
payloads.append(
ResultPayload(
processing_name=name,
kind=kind,
frequency_hz=np.array([], dtype=np.float32),
trace=np.array([], dtype=np.complex64),
scalar_value=scalar_value,
)
)
else:
raise ValueError(f"Unsupported result payload kind: {kind}")
payloads.append(read_payload(cursor))
blocks.append(
ResultBlock(
@@ -111,4 +143,9 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
)
)
return ResultCollection(collection_id=collection_id, monotonic_ns=monotonic_ns, blocks=blocks)
return ResultCollection(
collection_id=collection_id,
monotonic_ns=monotonic_ns,
collection_payloads=collection_payloads,
blocks=blocks,
)
+48 -16
View File
@@ -28,13 +28,13 @@
"port2": {
"name": "port2",
"driver_mode": "mock",
"driver": "hmc349a",
"driver": "h7992",
"radar_port": 2,
"positions": 2,
"positions": 4,
"default_position": 0,
"gpio_chip": "/dev/gpiochip0",
"pin_a": 22,
"pin_b": -1,
"pin_b": 23,
"invert_logic": false
}
},
@@ -53,20 +53,12 @@
"output": 0
},
{
"input": 0,
"output": 1
"input": 2,
"output": 0
},
{
"input": 1,
"output": 1
},
{
"input": 0,
"output": 2
},
{
"input": 1,
"output": 2
"input": 3,
"output": 0
},
{
"input": 0,
@@ -75,6 +67,14 @@
{
"input": 1,
"output": 3
},
{
"input": 2,
"output": 3
},
{
"input": 3,
"output": 3
}
]
},
@@ -84,6 +84,38 @@
"calibration_bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/calibration_bundle.bin",
"reference_bundle_path": "/home/europa/Documents/radar_system/python_app/runtime/reference_bundle.bin"
},
"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_smoke_1703912_791574940686872",
@@ -111,4 +143,4 @@
"slot_size_bytes": 2097152
}
}
}
}
@@ -0,0 +1,384 @@
"""Convert manual LibreVNA S11/S21 CSV captures in prog_libre to vna history JSON."""
from __future__ import annotations
import argparse
import csv
from datetime import datetime, timezone
import json
from pathlib import Path
from typing import Any
import numpy as np
def _real_imag_keys(trace_prefix: str) -> tuple[str, str]:
return f"{trace_prefix}_Real", f"{trace_prefix}_Imaginary"
def _load_complex_trace(csv_path: Path, trace_prefix: str) -> tuple[np.ndarray, np.ndarray]:
real_key, imag_key = _real_imag_keys(trace_prefix)
frequencies: list[float] = []
values: list[complex] = []
with csv_path.open(encoding="utf-8", newline="") as handle:
reader = csv.DictReader(handle)
for row in reader:
frequencies.append(float(row["Frequency"]))
values.append(complex(float(row[real_key]), float(row[imag_key])))
frequency_hz = np.asarray(frequencies, dtype=np.float64)
trace = np.asarray(values, dtype=np.complex128)
if frequency_hz.size == 0 or trace.size == 0:
raise ValueError(f"CSV has no points: {csv_path}")
if frequency_hz.shape != trace.shape:
raise ValueError(f"Frequency/trace size mismatch: {csv_path}")
return frequency_hz, trace
def _solve_one_port_osl(
open_trace: np.ndarray,
short_trace: np.ndarray,
load_trace: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Solve ideal OSL one-port calibration coefficients."""
directivity = load_trace
open_delta = open_trace - directivity
short_delta = short_trace - directivity
denom = open_delta - short_delta
source_match = np.zeros_like(directivity)
reflection_tracking = np.ones_like(directivity)
stable_mask = np.abs(denom) > 1e-18
source_match[stable_mask] = (open_delta[stable_mask] + short_delta[stable_mask]) / denom[stable_mask]
reflection_tracking[stable_mask] = open_delta[stable_mask] * (1.0 - source_match[stable_mask])
return directivity, source_match, reflection_tracking
def _apply_one_port_osl(
measured_trace: np.ndarray,
directivity: np.ndarray,
source_match: np.ndarray,
reflection_tracking: np.ndarray,
) -> np.ndarray:
numerator = measured_trace - directivity
denominator = reflection_tracking + (source_match * numerator)
corrected = np.array(numerator, copy=True)
stable_mask = np.abs(denominator) > 1e-18
corrected[stable_mask] = numerator[stable_mask] / denominator[stable_mask]
return corrected
def _apply_through_calibration(measured_trace: np.ndarray, through_trace: np.ndarray) -> np.ndarray:
corrected = np.array(measured_trace, copy=True)
stable_mask = np.abs(through_trace) > 1e-18
corrected[stable_mask] = measured_trace[stable_mask] / through_trace[stable_mask]
return corrected
def _complex_to_points(values: np.ndarray) -> list[list[float]]:
return [[float(value.real), float(value.imag)] for value in values]
def _scan_file_sort_key(csv_path: Path) -> tuple[int, str]:
stem = csv_path.stem
return (int(stem), stem) if stem.isdigit() else (10**9, stem)
def _load_scan_series(folder: Path, trace_prefix: str) -> tuple[np.ndarray, list[tuple[str, np.ndarray]]]:
scan_paths = [
path
for path in sorted(folder.glob("*.csv"), key=_scan_file_sort_key)
if path.stem.isdigit()
]
if not scan_paths:
raise FileNotFoundError(f"No numbered scan CSV files found in {folder}")
base_frequency_hz: np.ndarray | None = None
scans: list[tuple[str, np.ndarray]] = []
for path in scan_paths:
frequency_hz, trace = _load_complex_trace(path, trace_prefix)
if base_frequency_hz is None:
base_frequency_hz = frequency_hz
elif not np.allclose(base_frequency_hz, frequency_hz, rtol=0.0, atol=1e-6):
raise ValueError(f"Frequency axis mismatch in {path}")
scans.append((path.name, trace))
assert base_frequency_hz is not None
return base_frequency_hz, scans
def _require_matching_frequency_axis(label: str, left: np.ndarray, right: np.ndarray) -> None:
if not np.allclose(left, right, rtol=0.0, atol=1e-6):
raise ValueError(f"{label} frequency axes do not match")
def _build_history_payload(
*,
source_dir: Path,
mode: str,
frequency_hz: np.ndarray,
sweep_scans: list[tuple[str, np.ndarray]],
calibrated_scans: list[tuple[str, np.ndarray]],
reference_trace: np.ndarray,
primary_stage: str,
raw_record_count: int,
preprocessed_record_count: int,
) -> dict[str, Any]:
if len(sweep_scans) != len(calibrated_scans):
raise ValueError("Sweep/calibrated scan counts do not match")
sweep_history: list[dict[str, Any]] = []
reference_points = _complex_to_points(reference_trace)
for index, ((scan_name, sweep_trace), (cal_name, calibrated_trace)) in enumerate(
zip(sweep_scans, calibrated_scans, strict=True)
):
if scan_name != cal_name:
raise ValueError(f"Scan ordering mismatch: {scan_name} vs {cal_name}")
sweep_history.append(
{
"timestamp": float(index),
"sweep_points": _complex_to_points(sweep_trace),
"calibrated_points": _complex_to_points(calibrated_trace),
"reference_points": reference_points,
"vna_config": {
"mode": mode,
"start_freq": float(frequency_hz[0]),
"stop_freq": float(frequency_hz[-1]),
"points": int(frequency_hz.size),
},
}
)
return {
"format": "vna-system-history-v1",
"converter": "python_app/scripts/convert_prog_libre_manual_to_vna_history.py",
"converted_at_utc": datetime.now(timezone.utc).isoformat(),
"source_snapshot_dir": str(source_dir.resolve()),
"input_index": 0,
"output_index": 0,
"primary_stage": primary_stage,
"raw_record_count": int(raw_record_count),
"preprocessed_record_count": int(preprocessed_record_count),
"sweep_history": sweep_history,
}
def _write_payload(output_path: Path, payload: dict[str, Any]) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _rmse(left: np.ndarray, right: np.ndarray) -> float:
return float(np.sqrt(np.mean(np.abs(left - right) ** 2)))
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Convert manual LibreVNA S11/S21 CSV captures in prog_libre to vna history JSON.",
)
parser.add_argument(
"--calibration-dir",
type=Path,
default=Path("prog_libre/calibration"),
help="Directory with calibration CSV files.",
)
parser.add_argument(
"--raw-dir",
type=Path,
default=Path("prog_libre/1-6000mhz_no-calibrated_libre"),
help="Directory with uncalibrated scan CSV files and ref.csv.",
)
parser.add_argument(
"--calibrated-dir",
type=Path,
default=Path("prog_libre/1-6000mhz_calibrated_libre"),
help="Directory with already calibrated scan CSV files and ref.csv.",
)
parser.add_argument(
"--raw-output",
"--s11-raw-output",
dest="s11_raw_output",
type=Path,
default=Path("prog_libre/1-6000mhz_no-calibrated_libre_s11_osl_p1_vna_bscan_history.json"),
help="Output JSON for uncalibrated S11 scans after applying OSL calibration.",
)
parser.add_argument(
"--calibrated-output",
"--s11-calibrated-output",
dest="s11_calibrated_output",
type=Path,
default=Path("prog_libre/1-6000mhz_calibrated_libre_s11_passthrough_vna_bscan_history.json"),
help="Output JSON for already calibrated S11 scans without extra calibration.",
)
parser.add_argument(
"--s21-raw-output",
dest="s21_raw_output",
type=Path,
default=Path("prog_libre/1-6000mhz_no-calibrated_libre_s21_through_vna_bscan_history.json"),
help="Output JSON for uncalibrated S21 scans after applying through calibration.",
)
parser.add_argument(
"--s21-calibrated-output",
dest="s21_calibrated_output",
type=Path,
default=Path("prog_libre/1-6000mhz_calibrated_libre_s21_passthrough_vna_bscan_history.json"),
help="Output JSON for already calibrated S21 scans without extra calibration.",
)
return parser
def main() -> None:
args = _build_parser().parse_args()
calibration_dir = args.calibration_dir.expanduser().resolve()
raw_dir = args.raw_dir.expanduser().resolve()
calibrated_dir = args.calibrated_dir.expanduser().resolve()
s11_raw_output = args.s11_raw_output.expanduser().resolve()
s11_calibrated_output = args.s11_calibrated_output.expanduser().resolve()
s21_raw_output = args.s21_raw_output.expanduser().resolve()
s21_calibrated_output = args.s21_calibrated_output.expanduser().resolve()
s11_cal_frequency_hz, open_trace = _load_complex_trace(calibration_dir / "open_rfc18_p1.csv", "S11")
short_frequency_hz, short_trace = _load_complex_trace(calibration_dir / "short_rfc18_p1.csv", "S11")
load_frequency_hz, load_trace = _load_complex_trace(calibration_dir / "load_rfc18_p1.csv", "S11")
_require_matching_frequency_axis("S11 calibration", s11_cal_frequency_hz, short_frequency_hz)
_require_matching_frequency_axis("S11 calibration", s11_cal_frequency_hz, load_frequency_hz)
directivity, source_match, reflection_tracking = _solve_one_port_osl(open_trace, short_trace, load_trace)
s11_raw_frequency_hz, s11_raw_scans = _load_scan_series(raw_dir, "S11")
s11_calibrated_frequency_hz, s11_passthrough_scans = _load_scan_series(calibrated_dir, "S11")
_require_matching_frequency_axis("S11 raw vs calibration", s11_raw_frequency_hz, s11_cal_frequency_hz)
_require_matching_frequency_axis("S11 calibrated vs calibration", s11_calibrated_frequency_hz, s11_cal_frequency_hz)
s11_raw_reference_frequency_hz, s11_raw_reference = _load_complex_trace(raw_dir / "ref.csv", "S11")
s11_calibrated_reference_frequency_hz, s11_calibrated_reference = _load_complex_trace(calibrated_dir / "ref.csv", "S11")
_require_matching_frequency_axis("S11 raw reference vs calibration", s11_raw_reference_frequency_hz, s11_cal_frequency_hz)
_require_matching_frequency_axis(
"S11 calibrated reference vs calibration",
s11_calibrated_reference_frequency_hz,
s11_cal_frequency_hz,
)
s11_corrected_scans = [
(
scan_name,
_apply_one_port_osl(trace, directivity, source_match, reflection_tracking),
)
for scan_name, trace in s11_raw_scans
]
s11_corrected_reference = _apply_one_port_osl(s11_raw_reference, directivity, source_match, reflection_tracking)
s11_raw_payload = _build_history_payload(
source_dir=raw_dir,
mode="s11",
frequency_hz=s11_raw_frequency_hz,
sweep_scans=s11_raw_scans,
calibrated_scans=s11_corrected_scans,
reference_trace=s11_corrected_reference,
primary_stage="raw",
raw_record_count=len(s11_raw_scans),
preprocessed_record_count=len(s11_corrected_scans),
)
s11_calibrated_payload = _build_history_payload(
source_dir=calibrated_dir,
mode="s11",
frequency_hz=s11_calibrated_frequency_hz,
sweep_scans=s11_passthrough_scans,
calibrated_scans=s11_passthrough_scans,
reference_trace=s11_calibrated_reference,
primary_stage="preprocessed",
raw_record_count=0,
preprocessed_record_count=len(s11_passthrough_scans),
)
s21_cal_frequency_hz, through_trace = _load_complex_trace(calibration_dir / "through21_rfc18.csv", "S21")
s21_raw_frequency_hz, s21_raw_scans = _load_scan_series(raw_dir, "S21")
s21_calibrated_frequency_hz, s21_passthrough_scans = _load_scan_series(calibrated_dir, "S21")
_require_matching_frequency_axis("S21 raw vs calibration", s21_raw_frequency_hz, s21_cal_frequency_hz)
_require_matching_frequency_axis("S21 calibrated vs calibration", s21_calibrated_frequency_hz, s21_cal_frequency_hz)
s21_raw_reference_frequency_hz, s21_raw_reference = _load_complex_trace(raw_dir / "ref.csv", "S21")
s21_calibrated_reference_frequency_hz, s21_calibrated_reference = _load_complex_trace(calibrated_dir / "ref.csv", "S21")
_require_matching_frequency_axis("S21 raw reference vs calibration", s21_raw_reference_frequency_hz, s21_cal_frequency_hz)
_require_matching_frequency_axis(
"S21 calibrated reference vs calibration",
s21_calibrated_reference_frequency_hz,
s21_cal_frequency_hz,
)
s21_corrected_scans = [
(
scan_name,
_apply_through_calibration(trace, through_trace),
)
for scan_name, trace in s21_raw_scans
]
s21_corrected_reference = _apply_through_calibration(s21_raw_reference, through_trace)
s21_raw_payload = _build_history_payload(
source_dir=raw_dir,
mode="s21",
frequency_hz=s21_raw_frequency_hz,
sweep_scans=s21_raw_scans,
calibrated_scans=s21_corrected_scans,
reference_trace=s21_corrected_reference,
primary_stage="raw",
raw_record_count=len(s21_raw_scans),
preprocessed_record_count=len(s21_corrected_scans),
)
s21_calibrated_payload = _build_history_payload(
source_dir=calibrated_dir,
mode="s21",
frequency_hz=s21_calibrated_frequency_hz,
sweep_scans=s21_passthrough_scans,
calibrated_scans=s21_passthrough_scans,
reference_trace=s21_calibrated_reference,
primary_stage="preprocessed",
raw_record_count=0,
preprocessed_record_count=len(s21_passthrough_scans),
)
_write_payload(s11_raw_output, s11_raw_payload)
_write_payload(s11_calibrated_output, s11_calibrated_payload)
_write_payload(s21_raw_output, s21_raw_payload)
_write_payload(s21_calibrated_output, s21_calibrated_payload)
s11_rmse_values = [
_rmse(corrected_trace, passthrough_trace)
for (_, corrected_trace), (_, passthrough_trace) in zip(s11_corrected_scans, s11_passthrough_scans, strict=True)
]
s21_rmse_values = [
_rmse(corrected_trace, passthrough_trace)
for (_, corrected_trace), (_, passthrough_trace) in zip(s21_corrected_scans, s21_passthrough_scans, strict=True)
]
s11_reference_rmse = _rmse(s11_corrected_reference, s11_calibrated_reference)
s21_reference_rmse = _rmse(s21_corrected_reference, s21_calibrated_reference)
print(
"Converted manual prog_libre captures to vna history JSON:\n"
f" S11 raw output: {s11_raw_output}\n"
f" S11 calibrated output: {s11_calibrated_output}\n"
f" S21 raw output: {s21_raw_output}\n"
f" S21 calibrated output: {s21_calibrated_output}\n"
f" sweep count: {len(s11_raw_scans)}\n"
f" points per sweep: {s11_raw_frequency_hz.size}\n"
f" S11 calibration: p1 ideal OSL\n"
f" S11 mean sweep RMSE vs provided calibrated folder: {float(np.mean(s11_rmse_values)):.6f}\n"
f" S11 max sweep RMSE vs provided calibrated folder: {float(np.max(s11_rmse_values)):.6f}\n"
f" S11 reference RMSE vs provided calibrated ref: {s11_reference_rmse:.6f}\n"
f" S21 calibration: through21 complex division\n"
f" S21 mean sweep RMSE vs provided calibrated folder: {float(np.mean(s21_rmse_values)):.6f}\n"
f" S21 max sweep RMSE vs provided calibrated folder: {float(np.max(s21_rmse_values)):.6f}\n"
f" S21 reference RMSE vs provided calibrated ref: {s21_reference_rmse:.6f}"
)
if __name__ == "__main__":
main()
+65 -25
View File
@@ -39,39 +39,79 @@ def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes
def serialize_result_collection(collection: ResultCollection) -> bytes:
"""Serialize one processed collection with result blocks/payloads."""
def serialize_payload(buffer: bytearray, payload) -> None:
"""Append one payload in ring-compatible result format."""
name_bytes = payload.processing_name.encode("utf-8")
if len(name_bytes) > 0xFFFF:
raise ValueError("processing_name is too long")
buffer.extend(struct.pack("<BH", payload.kind, len(name_bytes)))
buffer.extend(name_bytes)
if payload.kind == 1:
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
trace = np.asarray(payload.trace, dtype=np.complex64)
if freq.size != trace.size:
raise ValueError("Result trace frequency and values sizes must match")
buffer.extend(struct.pack("<I", int(freq.size)))
buffer.extend(freq.astype("<f4", copy=False).tobytes())
interleaved = np.empty(freq.size * 2, dtype="<f4")
interleaved[0::2] = trace.real.astype("<f4", copy=False)
interleaved[1::2] = trace.imag.astype("<f4", copy=False)
buffer.extend(interleaved.tobytes())
return
if payload.kind == 2:
buffer.extend(struct.pack("<f", float(payload.scalar_value)))
return
if payload.kind == 3:
image_x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
image_y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
image = np.asarray(payload.image, dtype=np.float32)
if image.ndim != 2:
raise ValueError("Result image payload must be a 2D matrix")
if image.shape != (image_y_axis.size, image_x_axis.size):
raise ValueError("Result image axis sizes must match image matrix shape")
buffer.extend(struct.pack("<II", int(image_x_axis.size), int(image_y_axis.size)))
buffer.extend(image_x_axis.astype("<f4", copy=False).tobytes())
buffer.extend(image_y_axis.astype("<f4", copy=False).tobytes())
buffer.extend(image.astype("<f4", copy=False).ravel(order="C").tobytes())
return
if payload.kind == 4:
table = np.asarray(payload.table, dtype=np.float32)
if table.ndim != 2:
raise ValueError("Result table payload must be a 2D matrix")
buffer.extend(struct.pack("<II", int(table.shape[1]), int(table.shape[0])))
buffer.extend(table.astype("<f4", copy=False).ravel(order="C").tobytes())
return
raise ValueError(f"Unsupported payload kind: {payload.kind}")
buffer = bytearray()
buffer.extend(
struct.pack("<IQQI", RESULT_MAGIC, collection.collection_id, collection.monotonic_ns, len(collection.blocks))
struct.pack(
"<IQQII",
RESULT_MAGIC,
collection.collection_id,
collection.monotonic_ns,
len(collection.collection_payloads),
len(collection.blocks),
)
)
for payload in collection.collection_payloads:
serialize_payload(buffer, payload)
for block in collection.blocks:
buffer.extend(struct.pack("<II", block.combo.input_pos, block.combo.output_pos))
buffer.extend(struct.pack("<I", len(block.payloads)))
for payload in block.payloads:
name_bytes = payload.processing_name.encode("utf-8")
if len(name_bytes) > 0xFFFF:
raise ValueError("processing_name is too long")
buffer.extend(struct.pack("<BH", payload.kind, len(name_bytes)))
buffer.extend(name_bytes)
if payload.kind == 1:
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
trace = np.asarray(payload.trace, dtype=np.complex64)
if freq.size != trace.size:
raise ValueError("Result trace frequency and values sizes must match")
buffer.extend(struct.pack("<I", int(freq.size)))
buffer.extend(freq.astype("<f4", copy=False).tobytes())
interleaved = np.empty(freq.size * 2, dtype="<f4")
interleaved[0::2] = trace.real.astype("<f4", copy=False)
interleaved[1::2] = trace.imag.astype("<f4", copy=False)
buffer.extend(interleaved.tobytes())
elif payload.kind == 2:
buffer.extend(struct.pack("<f", float(payload.scalar_value)))
else:
raise ValueError(f"Unsupported payload kind: {payload.kind}")
serialize_payload(buffer, payload)
return bytes(buffer)
+93
View File
@@ -137,6 +137,7 @@ def save_result_history_binary(stage_dir: Path, history: list[ResultCollection])
{
"collection_id": collection.collection_id,
"monotonic_ns": collection.monotonic_ns,
"collection_payload_count": len(collection.collection_payloads),
"block_count": len(collection.blocks),
},
indent=2,
@@ -190,6 +191,66 @@ def save_result_history_numpy(stage_dir: Path, history: list[ResultCollection])
collection_dir = stage_dir / collection_dir_name(index, collection.collection_id, collection.monotonic_ns)
collection_dir.mkdir(parents=True, exist_ok=False)
collection_payload_meta: list[dict[str, int | str | float]] = []
for payload_index, payload in enumerate(collection.collection_payloads):
safe_name = sanitize_path_component(payload.processing_name or "processor")
base_name = f"collection_{payload_index:03d}_{safe_name}_kind{payload.kind}"
if payload.kind == 1:
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
trace = np.asarray(payload.trace, dtype=np.complex64)
np.save(collection_dir / f"{base_name}_freq.npy", freq)
np.save(collection_dir / f"{base_name}_trace.npy", trace)
collection_payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"points": int(freq.size),
"freq_file": f"{base_name}_freq.npy",
"trace_file": f"{base_name}_trace.npy",
}
)
elif payload.kind == 2:
scalar = np.asarray([float(payload.scalar_value)], dtype=np.float32)
np.save(collection_dir / f"{base_name}_scalar.npy", scalar)
collection_payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"scalar_file": f"{base_name}_scalar.npy",
"scalar_value": float(payload.scalar_value),
}
)
elif payload.kind == 3:
image_x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
image_y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
image = np.asarray(payload.image, dtype=np.float32)
np.save(collection_dir / f"{base_name}_x_axis.npy", image_x_axis)
np.save(collection_dir / f"{base_name}_y_axis.npy", image_y_axis)
np.save(collection_dir / f"{base_name}_image.npy", image)
collection_payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"x_points": int(image_x_axis.size),
"y_points": int(image_y_axis.size),
"x_axis_file": f"{base_name}_x_axis.npy",
"y_axis_file": f"{base_name}_y_axis.npy",
"image_file": f"{base_name}_image.npy",
}
)
elif payload.kind == 4:
table = np.asarray(payload.table, dtype=np.float32)
np.save(collection_dir / f"{base_name}_table.npy", table)
collection_payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"rows": int(table.shape[0]) if table.ndim == 2 else 0,
"columns": int(table.shape[1]) if table.ndim == 2 else 0,
"table_file": f"{base_name}_table.npy",
}
)
blocks_meta: list[dict[str, int | str | list[dict[str, int | str | float]]]] = []
for block_index, block in enumerate(collection.blocks):
block_dir = collection_dir / f"block_{block_index:03d}_i{block.combo.input_pos}_o{block.combo.output_pos}"
@@ -224,6 +285,36 @@ def save_result_history_numpy(stage_dir: Path, history: list[ResultCollection])
"scalar_value": float(payload.scalar_value),
}
)
elif payload.kind == 3:
image_x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
image_y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
image = np.asarray(payload.image, dtype=np.float32)
np.save(block_dir / f"{base_name}_x_axis.npy", image_x_axis)
np.save(block_dir / f"{base_name}_y_axis.npy", image_y_axis)
np.save(block_dir / f"{base_name}_image.npy", image)
payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"x_points": int(image_x_axis.size),
"y_points": int(image_y_axis.size),
"x_axis_file": f"{base_name}_x_axis.npy",
"y_axis_file": f"{base_name}_y_axis.npy",
"image_file": f"{base_name}_image.npy",
}
)
elif payload.kind == 4:
table = np.asarray(payload.table, dtype=np.float32)
np.save(block_dir / f"{base_name}_table.npy", table)
payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"rows": int(table.shape[0]) if table.ndim == 2 else 0,
"columns": int(table.shape[1]) if table.ndim == 2 else 0,
"table_file": f"{base_name}_table.npy",
}
)
blocks_meta.append(
{
@@ -240,6 +331,8 @@ def save_result_history_numpy(stage_dir: Path, history: list[ResultCollection])
{
"collection_id": int(collection.collection_id),
"monotonic_ns": int(collection.monotonic_ns),
"collection_payload_count": len(collection.collection_payloads),
"collection_payloads": collection_payload_meta,
"block_count": len(collection.blocks),
"blocks": blocks_meta,
},