From 9c745f304ef6427ed43ce1df26107a50fd1bb7be Mon Sep 17 00:00:00 2001 From: Ayzen Date: Thu, 5 Mar 2026 15:53:56 +0300 Subject: [PATCH] added json save button and remove history button. Added logging to save process --- .../librevna_minimal_driver_lifecycle.cpp | 9 +- .../controllers/app_window_config_mixin.py | 1 + .../controllers/app_window_snapshot_mixin.py | 120 ++++++++- .../sections/data_actions_section.py | 23 ++ .../sections/processing_section.py | 2 +- python_app/storage/npz/snapshot_numpy.py | 67 ++++- python_app/storage/npz/store.py | 71 ++++++ python_app/storage/npz/vna_history_json.py | 231 ++++++++++++++++++ 8 files changed, 505 insertions(+), 19 deletions(-) create mode 100644 python_app/storage/npz/vna_history_json.py diff --git a/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp b/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp index 7e0cabe..f932407 100644 --- a/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp +++ b/data_acq_and_processing/sweep_orchestrator/device_drivers/radar/librevna/librevna_minimal_driver_lifecycle.cpp @@ -17,6 +17,7 @@ namespace detail = radar::drivers::librevna::detail; namespace { constexpr std::uint32_t kNativeAcquireMaxAttempts = 3U; +constexpr auto kNativeSweepResponseTimeout = std::chrono::milliseconds(1500); [[nodiscard]] auto is_retryable_native_acquire_error(std::string_view message) -> bool { constexpr std::array kRetryableSubstrings = { @@ -151,13 +152,7 @@ auto LibreVnaMinimalDriver::acquire_native() -> SweepTrace { std::vector received(settings_.sweep.points, 0U); std::uint32_t received_count = 0; - const auto ifbw_hz = std::max(settings_.sweep.if_bandwidth_hz, 1.0F); - const auto estimated_sweep_ms = static_cast( - std::ceil((1'000.0 * static_cast(settings_.sweep.points)) / static_cast(ifbw_hz)) - ); - // Keep generous timeout margin on slower hosts. - const auto timeout_ms = std::max(20'000ULL, estimated_sweep_ms * 8ULL + 1'000ULL); - const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + const auto deadline = std::chrono::steady_clock::now() + kNativeSweepResponseTimeout; while (received_count < settings_.sweep.points) { NativePacket packet{}; diff --git a/python_app/gui/controllers/app_window_config_mixin.py b/python_app/gui/controllers/app_window_config_mixin.py index 4fac590..1c82d9d 100644 --- a/python_app/gui/controllers/app_window_config_mixin.py +++ b/python_app/gui/controllers/app_window_config_mixin.py @@ -185,6 +185,7 @@ class AppWindowConfigMixin: self._draw_results(self._result_history[-1]) return self._plot.clear() + self._clear_trace_plots() def _on_radar_identity_changed(self, *_args) -> None: """Refresh device limits when radar identity/mode changes.""" diff --git a/python_app/gui/controllers/app_window_snapshot_mixin.py b/python_app/gui/controllers/app_window_snapshot_mixin.py index 07bbea3..a72bafa 100644 --- a/python_app/gui/controllers/app_window_snapshot_mixin.py +++ b/python_app/gui/controllers/app_window_snapshot_mixin.py @@ -36,11 +36,105 @@ class AppWindowSnapshotMixin: f"(raw={summary.get('raw_count', 0)}, " f"preprocessed={summary.get('preprocessed_count', 0)}, " f"results={summary.get('result_count', 0)}, " - f"mode={summary.get('selection_mode', 'unknown')})" + f"mode={summary.get('selection_mode', 'unknown')}, " + f"anchor={summary.get('anchor_stage', 'unknown')}, " + f"aligned_keys={summary.get('aligned_key_count', 0)}, " + f"raw_missing={summary.get('raw_missing_count', 0)}, " + f"pre_missing={summary.get('preprocessed_missing_count', 0)}, " + f"result_missing={summary.get('result_missing_count', 0)}, " + f"requested_last_n={last_n})" ) except Exception as exc: # noqa: BLE001 self._show_error(f"Failed to save snapshot: {exc}") + def _save_vna_history_json(self) -> None: + """Save runtime history as vna_system-compatible JSON file.""" + self._drain_runtime_rings_for_snapshot() + + if not self._raw_history and not self._pre_history and not self._result_history: + self._show_error("No runtime data is available for save") + return + + try: + last_n = int(self._save_count.value()) + input_index = int(self._vna_json_input_index.value()) + output_index = int(self._vna_json_output_index.value()) + output_root = Path(self._save_path_input.text().strip()).expanduser() + output_name = self._save_name_input.text().strip() + output_path, summary = self._store.save_runtime_vna_history_json( + output_root, + output_name, + list(self._raw_history), + list(self._pre_history), + list(self._result_history), + last_n, + input_index=input_index, + output_index=output_index, + primary_stage="preprocessed", + ) + self._log( + f"Saved VNA history JSON: {output_path} " + f"(sweeps={summary.get('sweep_count', 0)}, " + f"raw_records={summary.get('raw_record_count', 0)}, " + f"preprocessed_records={summary.get('preprocessed_record_count', 0)}, " + f"raw={summary.get('raw_count', 0)}, " + f"preprocessed={summary.get('preprocessed_count', 0)}, " + f"results={summary.get('result_count', 0)}, " + f"mode={summary.get('selection_mode', 'unknown')}, " + f"anchor={summary.get('anchor_stage', 'unknown')}, " + f"input={input_index}, " + f"output={output_index}, " + f"requested_last_n={last_n})" + ) + except Exception as exc: # noqa: BLE001 + self._show_error(f"Failed to save VNA history JSON: {exc}") + + def _clear_all_runtime_history(self) -> None: + """Clear all runtime histories, ring backlogs, and processor replay state.""" + if self._capture_session is not None: + self._show_error("Cannot clear runtime history during active capture sequence") + return + + resume_acquisition = self._supervisor.is_running() + dropped_raw = 0 + dropped_pre = 0 + dropped_results = 0 + + try: + if resume_acquisition: + self._stop_run() + + dropped_raw = self._raw_reader.drop_all() if self._raw_reader is not None else 0 + dropped_pre = self._pre_reader.drop_all() if self._pre_reader is not None else 0 + dropped_results = self._result_reader.drop_all() if self._result_reader is not None else 0 + + self._replace_runtime_history(retained_raw=[], retained_pre=[], retained_result=[]) + self._bscan_history_floor_collection_id = 0 + self._clear_bscan_plot_history() + + # 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) + + if self._supervisor.is_processor_running(): + self._drain_results_until_quiet(timeout_s=0.25, poll_s=0.01) + if self._result_reader is not None: + dropped_results += self._result_reader.drop_all() + self._result_history.clear() + + self._update_history_indicator() + self._redraw_after_history_deletion() + self._log( + "Runtime history fully cleared: " + f"dropped raw={dropped_raw}, " + f"preprocessed={dropped_pre}, " + f"results={dropped_results}" + ) + + if resume_acquisition: + self._start_run() + except Exception as exc: # noqa: BLE001 + self._show_error(f"Failed to clear runtime history: {exc}") + def _browse_save_path(self) -> None: """Open directory picker for snapshot output path.""" selected = QFileDialog.getExistingDirectory( @@ -59,7 +153,7 @@ class AppWindowSnapshotMixin: start_raw_count = len(self._raw_history) start_pre_count = len(self._pre_history) start_result_count = len(self._result_history) - deadline = time.monotonic() + 0.8 + deadline = time.monotonic() + 1.2 while True: progress = False @@ -91,13 +185,29 @@ class AppWindowSnapshotMixin: if progress: continue - missing_raw = self._raw_reader is not None and len(self._raw_history) == start_raw_count - missing_pre = self._pre_reader is not None and len(self._pre_history) == start_pre_count - got_results = len(self._result_history) > start_result_count + raw_count = len(self._raw_history) + pre_count = len(self._pre_history) + result_count = len(self._result_history) + + missing_raw = self._raw_reader is not None and raw_count == start_raw_count + missing_pre = self._pre_reader is not None and pre_count == start_pre_count + missing_results = self._result_reader is not None and result_count == start_result_count + got_results = result_count > start_result_count + got_raw_or_pre = raw_count > start_raw_count or pre_count > start_pre_count if got_results and (missing_raw or missing_pre) and time.monotonic() < deadline: time.sleep(0.01) continue + if got_raw_or_pre and missing_results and time.monotonic() < deadline: + time.sleep(0.01) + continue + if ( + self._result_reader is not None + and max(raw_count, pre_count) > result_count + and time.monotonic() < deadline + ): + time.sleep(0.01) + continue break except Exception as exc: # noqa: BLE001 self._log(f"Snapshot drain warning: {exc}") diff --git a/python_app/gui/controllers/sections/data_actions_section.py b/python_app/gui/controllers/sections/data_actions_section.py index 7844de7..cc28608 100644 --- a/python_app/gui/controllers/sections/data_actions_section.py +++ b/python_app/gui/controllers/sections/data_actions_section.py @@ -16,17 +16,40 @@ def build_data_actions_group(owner) -> QGroupBox: save_button = QPushButton("Save Numpy Snapshot") save_button.clicked.connect(owner._save_snapshot) + save_vna_json_button = QPushButton("Save VNA History JSON") + save_vna_json_button.clicked.connect(owner._save_vna_history_json) + clear_history_button = QPushButton("Clear ALL Runtime History") + clear_history_button.clicked.connect(owner._clear_all_runtime_history) owner._save_count = QSpinBox() owner._save_count.setMinimum(1) owner._save_count.setMaximum(10_000) owner._save_count.setValue(10) + owner._vna_json_input_index = QSpinBox() + owner._vna_json_input_index.setMinimum(0) + owner._vna_json_input_index.setMaximum(65_535) + owner._vna_json_input_index.setValue(0) + owner._vna_json_output_index = QSpinBox() + owner._vna_json_output_index.setMinimum(0) + owner._vna_json_output_index.setMaximum(65_535) + owner._vna_json_output_index.setValue(0) save_row.addWidget(save_button) + save_row.addWidget(save_vna_json_button) + save_row.addWidget(clear_history_button) save_row.addWidget(QLabel("Last N")) save_row.addWidget(owner._save_count) save_row.addStretch(1) layout.addLayout(save_row) + json_row = QHBoxLayout() + json_row.setSpacing(8) + json_row.addWidget(QLabel("JSON input")) + json_row.addWidget(owner._vna_json_input_index) + json_row.addWidget(QLabel("output")) + json_row.addWidget(owner._vna_json_output_index) + json_row.addStretch(1) + layout.addLayout(json_row) + path_row = QHBoxLayout() path_row.setSpacing(8) owner._save_path_input = QLineEdit(str(owner._project_root / "python_app/data/snapshots")) diff --git a/python_app/gui/controllers/sections/processing_section.py b/python_app/gui/controllers/sections/processing_section.py index d57c08e..984d94b 100644 --- a/python_app/gui/controllers/sections/processing_section.py +++ b/python_app/gui/controllers/sections/processing_section.py @@ -73,7 +73,7 @@ def build_processing_group(owner) -> QGroupBox: owner._bscan_max_depth_m = QDoubleSpinBox() owner._bscan_max_depth_m.setDecimals(1) - owner._bscan_max_depth_m.setRange(0.1, 5.0) + owner._bscan_max_depth_m.setRange(0.1, 20.0) owner._bscan_max_depth_m.setSingleStep(0.1) owner._bscan_max_depth_m.setValue(1.0) diff --git a/python_app/storage/npz/snapshot_numpy.py b/python_app/storage/npz/snapshot_numpy.py index ce34b17..f3e27c9 100644 --- a/python_app/storage/npz/snapshot_numpy.py +++ b/python_app/storage/npz/snapshot_numpy.py @@ -21,21 +21,74 @@ def select_aligned_histories( result_history: list[ResultCollection], last_n: int, ) -> tuple[list[SweepCollection], list[SweepCollection], list[ResultCollection], dict[str, Any]]: - """Select history tails prioritizing currently displayed processed results.""" - raw_index, _ = _index_by_collection_sequence(raw_history) - pre_index, _ = _index_by_collection_sequence(preprocessed_history) + """Select aligned tails with `results` as preferred anchor stage.""" + raw_index, raw_pos = _index_by_collection_sequence(raw_history) + pre_index, pre_pos = _index_by_collection_sequence(preprocessed_history) result_index, result_pos = _index_by_collection_sequence(result_history) if result_index: ordered_result_keys = sorted(result_index, key=lambda key: result_pos[key]) selected_keys = ordered_result_keys[-last_n:] + selected_raw = [raw_index[key] for key in selected_keys if key in raw_index] + selected_pre = [pre_index[key] for key in selected_keys if key in pre_index] + selected_results = [result_index[key] for key in selected_keys] + raw_missing = len(selected_keys) - len(selected_raw) + pre_missing = len(selected_keys) - len(selected_pre) return ( - [raw_index[key] for key in selected_keys if key in raw_index], - [pre_index[key] for key in selected_keys if key in pre_index], - [result_index[key] for key in selected_keys], + selected_raw, + selected_pre, + selected_results, { "selection_mode": "result_tail_with_optional_alignment", + "anchor_stage": "results", "selected_collection_ids": [int(key[0]) for key in selected_keys], + "aligned_key_count": len(selected_keys), + "raw_missing_count": raw_missing, + "preprocessed_missing_count": pre_missing, + }, + ) + + if raw_index: + ordered_raw_keys = sorted(raw_index, key=lambda key: raw_pos[key]) + selected_keys = ordered_raw_keys[-last_n:] + selected_raw = [raw_index[key] for key in selected_keys] + selected_pre = [pre_index[key] for key in selected_keys if key in pre_index] + selected_results = [result_index[key] for key in selected_keys if key in result_index] + pre_missing = len(selected_keys) - len(selected_pre) + result_missing = len(selected_keys) - len(selected_results) + return ( + selected_raw, + selected_pre, + selected_results, + { + "selection_mode": "raw_tail_with_optional_alignment", + "anchor_stage": "raw", + "selected_collection_ids": [int(key[0]) for key in selected_keys], + "aligned_key_count": len(selected_keys), + "preprocessed_missing_count": pre_missing, + "result_missing_count": result_missing, + }, + ) + + if pre_index: + ordered_pre_keys = sorted(pre_index, key=lambda key: pre_pos[key]) + selected_keys = ordered_pre_keys[-last_n:] + selected_raw = [raw_index[key] for key in selected_keys if key in raw_index] + selected_pre = [pre_index[key] for key in selected_keys] + selected_results = [result_index[key] for key in selected_keys if key in result_index] + raw_missing = len(selected_keys) - len(selected_raw) + result_missing = len(selected_keys) - len(selected_results) + return ( + selected_raw, + selected_pre, + selected_results, + { + "selection_mode": "preprocessed_tail_with_optional_alignment", + "anchor_stage": "preprocessed", + "selected_collection_ids": [int(key[0]) for key in selected_keys], + "aligned_key_count": len(selected_keys), + "raw_missing_count": raw_missing, + "result_missing_count": result_missing, }, ) @@ -45,7 +98,9 @@ def select_aligned_histories( result_history[-last_n:], { "selection_mode": "independent_tail", + "anchor_stage": "none", "selected_collection_ids": [], + "aligned_key_count": 0, }, ) diff --git a/python_app/storage/npz/store.py b/python_app/storage/npz/store.py index b132c45..2064bd3 100644 --- a/python_app/storage/npz/store.py +++ b/python_app/storage/npz/store.py @@ -19,6 +19,7 @@ from python_app.storage.npz.snapshot_numpy import ( save_trace_history_numpy, select_aligned_histories, ) +from python_app.storage.npz.vna_history_json import build_vna_history_payload from python_app.storage.store_api import StoreApi @@ -176,7 +177,12 @@ class NpzStore(StoreApi): { "format": "numpy-directory-v1", "selection_mode": selection_summary["selection_mode"], + "anchor_stage": selection_summary.get("anchor_stage", "unknown"), "selected_collection_ids": selection_summary["selected_collection_ids"], + "aligned_key_count": int(selection_summary.get("aligned_key_count", 0)), + "raw_missing_count": int(selection_summary.get("raw_missing_count", 0)), + "preprocessed_missing_count": int(selection_summary.get("preprocessed_missing_count", 0)), + "result_missing_count": int(selection_summary.get("result_missing_count", 0)), "raw_collections": len(selected_raw), "preprocessed_collections": len(selected_preprocessed), "result_collections": len(selected_results), @@ -190,12 +196,77 @@ class NpzStore(StoreApi): encoding="utf-8", ) + if selection_summary.get("anchor_stage") == "results": + expected = min(int(last_n), len(result_history)) + if len(selected_results) != expected: + raise RuntimeError( + "Snapshot results selection invariant failed: " + f"expected={expected}, selected={len(selected_results)}" + ) + selection_summary["raw_count"] = len(selected_raw) selection_summary["preprocessed_count"] = len(selected_preprocessed) selection_summary["result_count"] = len(selected_results) selection_summary["snapshot_dir"] = str(snapshot_dir) return snapshot_dir, selection_summary + def save_runtime_vna_history_json( + self, + output_root_dir: Path, + output_name: str, + raw_history: list[SweepCollection], + preprocessed_history: list[SweepCollection], + result_history: list[ResultCollection], + last_n: int, + *, + input_index: int = 0, + output_index: int = 0, + primary_stage: str = "preprocessed", + ) -> tuple[Path, dict[str, Any]]: + """Save runtime history as vna_system-compatible JSON file.""" + if last_n <= 0: + raise ValueError("last_n must be > 0") + + output_stem = output_name.strip() or datetime.utcnow().strftime("snapshot_%Y%m%d_%H%M%S") + output_stem = sanitize_path_component(output_stem) + + output_root_dir.mkdir(parents=True, exist_ok=True) + output_path = output_root_dir / f"{output_stem}_vna_bscan_history.json" + if output_path.exists(): + raise FileExistsError(f"Output JSON file already exists: {output_path}") + + selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories( + raw_history, + preprocessed_history, + result_history, + last_n, + ) + payload = build_vna_history_payload( + selected_raw, + selected_preprocessed, + selected_results, + input_index=input_index, + output_index=output_index, + primary_stage=primary_stage, + ) + output_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + summary: dict[str, Any] = dict(selection_summary) + summary["raw_count"] = len(selected_raw) + summary["preprocessed_count"] = len(selected_preprocessed) + summary["result_count"] = len(selected_results) + summary["raw_record_count"] = int(payload.get("raw_record_count", 0)) + summary["preprocessed_record_count"] = int(payload.get("preprocessed_record_count", 0)) + summary["sweep_count"] = len(payload.get("sweep_history", [])) + summary["input_index"] = int(input_index) + summary["output_index"] = int(output_index) + summary["primary_stage"] = str(primary_stage) + summary["output_path"] = str(output_path) + return output_path, summary + def _set_dir(self, kind: str, radar_key: str) -> Path: """Return directory for set kind and radar key.""" return self._root_dir / kind / radar_key diff --git a/python_app/storage/npz/vna_history_json.py b/python_app/storage/npz/vna_history_json.py new file mode 100644 index 0000000..ed9c1fb --- /dev/null +++ b/python_app/storage/npz/vna_history_json.py @@ -0,0 +1,231 @@ +"""Builders for vna_system-compatible sweep-history JSON payloads.""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +import numpy as np + +from python_app.models.dataset_model import ResultCollection, SweepCollection, TraceData + + +@dataclass(frozen=True) +class TraceRecord: + """One raw/preprocessed trace selected for vna_history export.""" + + stage: str + collection_id: int + monotonic_ns: int + stage_index: int + frequency_hz: np.ndarray + s21: np.ndarray + + +def _pick_trace(collection: SweepCollection, input_index: int, output_index: int) -> TraceData | None: + for trace in collection.traces: + if int(trace.combo.input_pos) == int(input_index) and int(trace.combo.output_pos) == int(output_index): + return trace + return None + + +def _build_stage_records( + stage: str, + history: list[SweepCollection], + *, + input_index: int, + output_index: int, +) -> list[TraceRecord]: + records: list[TraceRecord] = [] + for stage_index, collection in enumerate(history): + trace = _pick_trace(collection, input_index, output_index) + if trace is None: + continue + + frequency_hz = np.asarray(trace.frequency_hz, dtype=np.float64).reshape(-1) + s21 = np.asarray(trace.s21, dtype=np.complex128).reshape(-1) + if frequency_hz.shape != s21.shape: + raise ValueError( + f"Shape mismatch in {stage} stage for collection_id={collection.collection_id}: " + f"freq{frequency_hz.shape} vs s21{s21.shape}" + ) + if frequency_hz.size == 0: + continue + if not ( + np.isfinite(frequency_hz).all() + and np.isfinite(np.real(s21)).all() + and np.isfinite(np.imag(s21)).all() + ): + raise ValueError(f"Non-finite values in {stage} stage for collection_id={collection.collection_id}") + + records.append( + TraceRecord( + stage=stage, + collection_id=int(collection.collection_id), + monotonic_ns=int(collection.monotonic_ns), + stage_index=int(stage_index), + frequency_hz=frequency_hz, + s21=s21, + ) + ) + return records + + +def _index_by_collection_occurrence(records: list[TraceRecord]) -> tuple[dict[tuple[int, int], TraceRecord], list[tuple[int, int]]]: + counters: defaultdict[int, int] = defaultdict(int) + record_map: dict[tuple[int, int], TraceRecord] = {} + order: list[tuple[int, int]] = [] + for record in records: + occurrence = counters[record.collection_id] + counters[record.collection_id] += 1 + key = (record.collection_id, occurrence) + record_map[key] = record + order.append(key) + return record_map, order + + +def _complex_to_points(values: np.ndarray) -> list[list[float]]: + return [[float(value.real), float(value.imag)] for value in values] + + +def _build_sweep_history( + raw_records: list[TraceRecord], + preprocessed_records: list[TraceRecord], + *, + primary_stage: str, +) -> list[dict[str, Any]]: + raw_map, raw_order = _index_by_collection_occurrence(raw_records) + pre_map, pre_order = _index_by_collection_occurrence(preprocessed_records) + + if primary_stage == "preprocessed": + primary_order = pre_order or raw_order + else: + primary_order = raw_order or pre_order + + history: list[dict[str, Any]] = [] + for fallback_index, key in enumerate(primary_order): + raw = raw_map.get(key) + pre = pre_map.get(key) + base = pre or raw + if base is None: + continue + + sweep_source = raw or pre + calibrated_source = pre or raw + if sweep_source is None or calibrated_source is None: + continue + + start_freq_hz = float(base.frequency_hz[0]) + stop_freq_hz = float(base.frequency_hz[-1]) + timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0 if base.monotonic_ns > 0 else float(fallback_index) + + history.append( + { + "timestamp": timestamp_sec, + "sweep_points": _complex_to_points(sweep_source.s21), + "calibrated_points": _complex_to_points(calibrated_source.s21), + "reference_points": [], + "vna_config": { + "mode": "s11", + "start_freq": start_freq_hz, + "stop_freq": stop_freq_hz, + "points": int(base.frequency_hz.size), + }, + } + ) + return history + + +def _stage_alignment_warning(pre_history: list[SweepCollection], result_history: list[ResultCollection]) -> str | None: + if not pre_history or not result_history: + return None + + common_size = min(len(pre_history), len(result_history)) + if common_size <= 0: + return None + + mismatches = 0 + first_mismatch: tuple[int, SweepCollection, ResultCollection] | None = None + for index in range(common_size): + pre = pre_history[index] + result = result_history[index] + if int(pre.collection_id) != int(result.collection_id) or int(pre.monotonic_ns) != int(result.monotonic_ns): + mismatches += 1 + if first_mismatch is None: + first_mismatch = (index, pre, result) + + if mismatches == 0 or first_mismatch is None: + return None + + index, pre, result = first_mismatch + return ( + "WARNING: snapshot stages are not fully aligned (preprocessed vs results). " + f"Mismatches={mismatches}/{common_size}. " + f"First mismatch at index={index}: " + f"pre=(id={int(pre.collection_id)},ns={int(pre.monotonic_ns)}) vs " + f"results=(id={int(result.collection_id)},ns={int(result.monotonic_ns)}). " + "Export uses preprocessed traces; loaded view in vna_system may differ from " + "radar_system on-screen replayed results." + ) + + +def build_vna_history_payload( + raw_history: list[SweepCollection], + preprocessed_history: list[SweepCollection], + result_history: list[ResultCollection], + *, + input_index: int, + output_index: int, + primary_stage: str = "preprocessed", +) -> dict[str, Any]: + """Build vna_system-compatible history JSON payload from runtime histories.""" + if primary_stage not in {"preprocessed", "raw"}: + raise ValueError("primary_stage must be either 'preprocessed' or 'raw'") + + raw_records = _build_stage_records( + "raw", + raw_history, + input_index=input_index, + output_index=output_index, + ) + preprocessed_records = _build_stage_records( + "preprocessed", + preprocessed_history, + input_index=input_index, + output_index=output_index, + ) + if not raw_records and not preprocessed_records: + raise ValueError( + "No matching raw/preprocessed traces were found in runtime history " + f"for input={input_index}, output={output_index}." + ) + + sweep_history = _build_sweep_history( + raw_records, + preprocessed_records, + primary_stage=primary_stage, + ) + if not sweep_history: + raise ValueError("Conversion produced empty sweep_history.") + + payload: dict[str, Any] = { + "format": "vna-system-history-v1", + "converter": "python_app/scripts/convert_snapshot_to_vna_history.py", + "converted_at_utc": datetime.now(timezone.utc).isoformat(), + "source_snapshot_dir": "", + "input_index": int(input_index), + "output_index": int(output_index), + "primary_stage": str(primary_stage), + "raw_record_count": len(raw_records), + "preprocessed_record_count": len(preprocessed_records), + "sweep_history": sweep_history, + } + + alignment_warning = _stage_alignment_warning(preprocessed_history, result_history) + if alignment_warning is not None: + payload["alignment_warning"] = alignment_warning + + return payload +