init commit

This commit is contained in:
Ayzen
2026-03-05 14:42:33 +03:00
commit fd4618b20d
964 changed files with 325114 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Utility scripts for smoke checks, inspection, and manual diagnostics."""
+298
View File
@@ -0,0 +1,298 @@
"""Validate and visualize numpy-directory runtime snapshots."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import numpy as np
def _load_json(path: Path) -> dict[str, Any]:
"""Load JSON object from file path."""
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"JSON root must be object: {path}")
return payload
def _collection_dirs(stage_dir: Path) -> list[Path]:
"""Return sorted list of collection directories for one stage."""
if not stage_dir.exists():
return []
return sorted([path for path in stage_dir.iterdir() if path.is_dir()], key=lambda path: path.name)
def _first_trace_from_raw_or_pre(collection_dir: Path) -> tuple[np.ndarray, np.ndarray, str] | None:
"""Return first trace payload from raw/preprocessed collection."""
traces = _all_traces_from_raw_or_pre(collection_dir)
if not traces:
return None
return traces[0]
def _all_traces_from_raw_or_pre(collection_dir: Path) -> list[tuple[np.ndarray, np.ndarray, str]]:
"""Load all traces from raw/preprocessed collection directory."""
meta = _load_json(collection_dir / "meta.json")
traces = meta.get("traces", [])
if not isinstance(traces, list):
raise ValueError(f"Invalid traces in {collection_dir / 'meta.json'}")
all_traces: list[tuple[np.ndarray, np.ndarray, str]] = []
for trace_meta in traces:
if not isinstance(trace_meta, dict):
raise ValueError(f"Invalid trace record in {collection_dir / 'meta.json'}")
freq_file = str(trace_meta.get("freq_file", ""))
s21_file = str(trace_meta.get("s21_file", ""))
freq = np.load(collection_dir / freq_file)
s21 = np.load(collection_dir / s21_file)
if freq.shape != s21.shape:
raise ValueError(f"Shape mismatch freq/s21 in {collection_dir}")
if not (np.isfinite(freq).all() and np.isfinite(np.real(s21)).all() and np.isfinite(np.imag(s21)).all()):
raise ValueError(f"Non-finite values in {collection_dir}")
label = f"i{int(trace_meta.get('input', 0))}_o{int(trace_meta.get('output', 0))}"
all_traces.append((np.asarray(freq, dtype=np.float64), np.asarray(s21, dtype=np.complex128), label))
return all_traces
def _first_trace_from_results(collection_dir: Path) -> tuple[np.ndarray, np.ndarray, str] | None:
"""Return first trace payload from results collection."""
traces = _all_traces_from_results(collection_dir)
if not traces:
return None
return traces[0]
def _all_traces_from_results(collection_dir: Path) -> list[tuple[np.ndarray, np.ndarray, str]]:
"""Load all trace-like payloads from results collection directory."""
meta = _load_json(collection_dir / "meta.json")
blocks = meta.get("blocks", [])
if not isinstance(blocks, list):
raise ValueError(f"Invalid blocks in {collection_dir / 'meta.json'}")
all_traces: list[tuple[np.ndarray, np.ndarray, str]] = []
for block in blocks:
if not isinstance(block, dict):
continue
block_dir_name = str(block.get("dir", ""))
block_dir = collection_dir / block_dir_name
payloads = block.get("payloads", [])
if not isinstance(payloads, list):
continue
for payload in payloads:
if not isinstance(payload, dict):
continue
kind = int(payload.get("kind", 0))
if kind != 1:
continue
freq_file = str(payload.get("freq_file", ""))
trace_file = str(payload.get("trace_file", ""))
freq = np.load(block_dir / freq_file)
trace = np.load(block_dir / trace_file)
if freq.shape != trace.shape:
raise ValueError(f"Shape mismatch freq/trace in {block_dir}")
if not (
np.isfinite(freq).all()
and np.isfinite(np.real(trace)).all()
and np.isfinite(np.imag(trace)).all()
):
raise ValueError(f"Non-finite values in {block_dir}")
label = (
f"i{int(block.get('input', 0))}_o{int(block.get('output', 0))}_"
f"{str(payload.get('name', 'processor'))}"
)
all_traces.append((np.asarray(freq, dtype=np.float64), np.asarray(trace, dtype=np.complex128), label))
return all_traces
def _validate_stage(stage_dir: Path, stage: str) -> tuple[list[int], list[tuple[np.ndarray, np.ndarray, str]]]:
"""Validate one stage directory and collect representative traces."""
collection_ids: list[int] = []
traces: list[tuple[np.ndarray, np.ndarray, str]] = []
for collection_dir in _collection_dirs(stage_dir):
meta = _load_json(collection_dir / "meta.json")
collection_ids.append(int(meta.get("collection_id", -1)))
if stage in {"raw", "preprocessed"}:
trace = _first_trace_from_raw_or_pre(collection_dir)
else:
trace = _first_trace_from_results(collection_dir)
if trace is not None:
traces.append(trace)
return collection_ids, traces
def _compare_two(
name: str,
first: tuple[np.ndarray, np.ndarray, str],
second: tuple[np.ndarray, np.ndarray, str],
) -> None:
"""Print numerical difference metrics for two traces."""
freq_a, data_a, label_a = first
freq_b, data_b, label_b = second
same_shape = freq_a.shape == freq_b.shape == data_a.shape == data_b.shape
if not same_shape:
print(f"[{name}] different shapes: {freq_a.shape}/{freq_b.shape} {data_a.shape}/{data_b.shape}")
return
are_equal = np.array_equal(data_a, data_b)
diff = data_a - data_b
max_abs_diff = float(np.max(np.abs(diff)))
l2_diff = float(np.linalg.norm(diff))
print(
f"[{name}] compare first two traces: {label_a} vs {label_b}, "
f"equal={are_equal}, max_abs_diff={max_abs_diff:.6g}, l2_diff={l2_diff:.6g}"
)
def _plot_two(
stage: str,
first: tuple[np.ndarray, np.ndarray, str],
second: tuple[np.ndarray, np.ndarray, str],
output_dir: Path,
) -> None:
"""Plot magnitude comparison for two traces."""
try:
import matplotlib.pyplot as plt
except Exception as exc: # noqa: BLE001
print(f"[{stage}] matplotlib is not available, plot skipped: {exc}")
return
output_dir.mkdir(parents=True, exist_ok=True)
freq_a, data_a, label_a = first
freq_b, data_b, label_b = second
if freq_a.shape != freq_b.shape or data_a.shape != data_b.shape:
print(f"[{stage}] shapes differ, plot skipped")
return
y_a = 20.0 * np.log10(np.maximum(np.abs(data_a), 1e-12))
y_b = 20.0 * np.log10(np.maximum(np.abs(data_b), 1e-12))
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(freq_a, y_a, linewidth=1.4, label=f"collection#1 {label_a}")
ax.plot(freq_b, y_b, linewidth=1.4, label=f"collection#2 {label_b}")
ax.set_title(f"{stage}: first two collections")
ax.set_xlabel("X axis")
ax.set_ylabel("Magnitude dB")
ax.grid(True, alpha=0.3)
ax.legend()
fig.tight_layout()
png_path = output_dir / f"{stage}_first_two.png"
fig.savefig(png_path, dpi=150)
plt.close(fig)
print(f"[{stage}] plot saved: {png_path}")
def _plot_all_states_for_one_collection(stage: str, collection_dir: Path, output_dir: Path) -> None:
"""Plot all switch-state traces available in one collection."""
try:
import matplotlib.pyplot as plt
except Exception as exc: # noqa: BLE001
print(f"[{stage}] matplotlib is not available, all-states plot skipped: {exc}")
return
if stage in {"raw", "preprocessed"}:
traces = _all_traces_from_raw_or_pre(collection_dir)
else:
traces = _all_traces_from_results(collection_dir)
if not traces:
print(f"[{stage}] no trace payloads found in {collection_dir.name}, all-states plot skipped")
return
output_dir.mkdir(parents=True, exist_ok=True)
meta = _load_json(collection_dir / "meta.json")
collection_id = int(meta.get("collection_id", -1))
fig, ax = plt.subplots(figsize=(11, 5))
for freq, data, label in traces:
y = 20.0 * np.log10(np.maximum(np.abs(data), 1e-12))
ax.plot(freq, y, linewidth=1.2, label=label)
ax.set_title(f"{stage}: all switch states in one collection (id={collection_id})")
ax.set_xlabel("X axis")
ax.set_ylabel("Magnitude dB")
ax.grid(True, alpha=0.3)
ax.legend(fontsize=8, ncol=2)
fig.tight_layout()
png_path = output_dir / f"{stage}_all_states_one_collection.png"
fig.savefig(png_path, dpi=150)
plt.close(fig)
print(f"[{stage}] all-states plot saved: {png_path} (traces={len(traces)})")
def _run(snapshot_dir: Path, output_dir: Path) -> None:
"""Execute snapshot validation and plotting workflow."""
manifest_path = snapshot_dir / "manifest.json"
if manifest_path.exists():
manifest = _load_json(manifest_path)
print(
f"Snapshot: {snapshot_dir}\n"
f"selection_mode={manifest.get('selection_mode')} "
f"raw={manifest.get('raw_collections')} "
f"pre={manifest.get('preprocessed_collections')} "
f"res={manifest.get('result_collections')}"
)
else:
print(f"Snapshot: {snapshot_dir} (manifest.json is missing)")
stages = ("raw", "preprocessed", "results")
for stage in stages:
stage_dir = snapshot_dir / stage
collection_dirs = _collection_dirs(stage_dir)
collection_ids, traces = _validate_stage(stage_dir, stage)
duplicate_id_count = len(collection_ids) - len(set(collection_ids))
print(
f"[{stage}] collections={len(collection_ids)}, "
f"duplicate_ids={duplicate_id_count}, "
f"trace_samples={len(traces)}"
)
if len(traces) >= 2:
_compare_two(stage, traces[0], traces[1])
_plot_two(stage, traces[0], traces[1], output_dir)
else:
print(f"[{stage}] not enough trace-like collections to compare/plot (need >= 2)")
if collection_dirs:
_plot_all_states_for_one_collection(stage, collection_dirs[-1], output_dir)
else:
print(f"[{stage}] no collections for all-states plot")
def main() -> None:
"""CLI entrypoint."""
parser = argparse.ArgumentParser(description="Validate and visualize runtime numpy snapshot collections.")
parser.add_argument(
"snapshot_dir",
type=Path,
help="Path to snapshot directory (contains raw/preprocessed/results).",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Directory for output plots (default: <snapshot_dir>/inspection_plots).",
)
args = parser.parse_args()
snapshot_dir = args.snapshot_dir.expanduser().resolve()
if not snapshot_dir.exists():
raise FileNotFoundError(f"Snapshot directory not found: {snapshot_dir}")
output_dir = (
args.output_dir.expanduser().resolve()
if args.output_dir is not None
else snapshot_dir / "inspection_plots"
)
_run(snapshot_dir, output_dir)
if __name__ == "__main__":
main()
@@ -0,0 +1,353 @@
"""Convert radar_system runtime snapshots into vna_system sweep-history JSON."""
from __future__ import annotations
import argparse
import json
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import numpy as np
@dataclass(frozen=True)
class TraceRecord:
"""One raw/preprocessed trace extracted from a snapshot collection directory."""
stage: str
collection_id: int
monotonic_ns: int
stage_index: int
frequency_hz: np.ndarray
s21: np.ndarray
@dataclass(frozen=True)
class CollectionRef:
"""Minimal collection identity descriptor for stage-alignment diagnostics."""
stage_index: int
collection_id: int
monotonic_ns: int
def _load_json(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"JSON root must be object: {path}")
return payload
def _collection_dirs(stage_dir: Path) -> list[Path]:
if not stage_dir.exists():
return []
return sorted([path for path in stage_dir.iterdir() if path.is_dir()], key=lambda path: path.name)
def _parse_stage_index(name: str, fallback: int) -> int:
prefix = name.split("_", 1)[0]
return int(prefix) if prefix.isdigit() else fallback
def _pick_trace_meta(meta: dict[str, Any], input_index: int, output_index: int) -> dict[str, Any] | None:
traces = meta.get("traces", [])
if not isinstance(traces, list):
return None
for trace in traces:
if not isinstance(trace, dict):
continue
if int(trace.get("input", -1)) == input_index and int(trace.get("output", -1)) == output_index:
return trace
return None
def _load_stage_records(
snapshot_dir: Path,
stage: str,
*,
input_index: int,
output_index: int,
) -> list[TraceRecord]:
stage_dir = snapshot_dir / stage
records: list[TraceRecord] = []
for fallback_idx, collection_dir in enumerate(_collection_dirs(stage_dir)):
meta_path = collection_dir / "meta.json"
if not meta_path.exists():
continue
meta = _load_json(meta_path)
trace_meta = _pick_trace_meta(meta, input_index, output_index)
if trace_meta is None:
continue
freq_file = str(trace_meta.get("freq_file", ""))
s21_file = str(trace_meta.get("s21_file", ""))
if not freq_file or not s21_file:
continue
frequency_hz = np.asarray(np.load(collection_dir / freq_file), dtype=np.float64).reshape(-1)
s21 = np.asarray(np.load(collection_dir / s21_file), dtype=np.complex128).reshape(-1)
if frequency_hz.shape != s21.shape:
raise ValueError(f"Shape mismatch in {collection_dir}: 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 {collection_dir}")
records.append(
TraceRecord(
stage=stage,
collection_id=int(meta.get("collection_id", -1)),
monotonic_ns=int(meta.get("monotonic_ns", 0)),
stage_index=_parse_stage_index(collection_dir.name, fallback_idx),
frequency_hz=frequency_hz,
s21=s21,
)
)
return records
def _load_stage_refs(snapshot_dir: Path, stage: str) -> list[CollectionRef]:
"""Load `(index, collection_id, monotonic_ns)` for one snapshot stage."""
stage_dir = snapshot_dir / stage
refs: list[CollectionRef] = []
for fallback_idx, collection_dir in enumerate(_collection_dirs(stage_dir)):
meta_path = collection_dir / "meta.json"
if not meta_path.exists():
continue
meta = _load_json(meta_path)
refs.append(
CollectionRef(
stage_index=_parse_stage_index(collection_dir.name, fallback_idx),
collection_id=int(meta.get("collection_id", -1)),
monotonic_ns=int(meta.get("monotonic_ns", 0)),
)
)
return refs
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(v.real), float(v.imag)] for v 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
# vna_system uses calibrated_data if present, otherwise sweep_data.
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_refs: list[CollectionRef], result_refs: list[CollectionRef]) -> str | None:
"""Return warning text when preprocessed/results stages are not identity-aligned."""
if not pre_refs or not result_refs:
return None
pre_by_index = {ref.stage_index: ref for ref in pre_refs}
result_by_index = {ref.stage_index: ref for ref in result_refs}
common_indices = sorted(set(pre_by_index) & set(result_by_index))
if not common_indices:
return None
mismatches = 0
first_mismatch: tuple[int, CollectionRef, CollectionRef] | None = None
for index in common_indices:
pre = pre_by_index[index]
result = result_by_index[index]
if pre.collection_id != result.collection_id or pre.monotonic_ns != result.monotonic_ns:
mismatches += 1
if first_mismatch is None:
first_mismatch = (index, pre, result)
if mismatches == 0:
return None
assert first_mismatch is not None
idx, pre, result = first_mismatch
return (
"WARNING: snapshot stages are not fully aligned (preprocessed vs results). "
f"Mismatches={mismatches}/{len(common_indices)}. "
f"First mismatch at index={idx}: "
f"pre=(id={pre.collection_id},ns={pre.monotonic_ns}) vs "
f"results=(id={result.collection_id},ns={result.monotonic_ns}). "
"Export uses preprocessed traces; loaded view in vna_system may differ from "
"radar_system on-screen replayed results."
)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Convert radar_system snapshot (numpy-directory-v1) to a vna_system-compatible "
"history JSON file with `sweep_history`."
)
)
parser.add_argument("snapshot_dir", type=Path, help="Path to snapshot directory containing raw/preprocessed/results.")
parser.add_argument(
"-o",
"--output",
type=Path,
default=None,
help="Output JSON path (default: <snapshot_dir>/vna_bscan_history.json).",
)
parser.add_argument("--input", dest="input_index", type=int, default=0, help="Input switch index to export.")
parser.add_argument("--output-index", dest="output_index", type=int, default=0, help="Output switch index to export.")
parser.add_argument(
"--primary-stage",
choices=("preprocessed", "raw"),
default="preprocessed",
help="Stage order to drive collection selection/alignment.",
)
parser.add_argument(
"--last-n",
type=int,
default=0,
help="Keep only the last N sweeps in output (0 means all available).",
)
return parser
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
snapshot_dir = args.snapshot_dir.expanduser().resolve()
if not snapshot_dir.exists():
raise FileNotFoundError(f"Snapshot directory not found: {snapshot_dir}")
output_path = (
args.output.expanduser().resolve()
if args.output is not None
else snapshot_dir / "vna_bscan_history.json"
)
raw_records = _load_stage_records(
snapshot_dir,
"raw",
input_index=args.input_index,
output_index=args.output_index,
)
preprocessed_records = _load_stage_records(
snapshot_dir,
"preprocessed",
input_index=args.input_index,
output_index=args.output_index,
)
if not raw_records and not preprocessed_records:
raise ValueError(
"No matching raw/preprocessed traces were found in snapshot "
f"for input={args.input_index}, output={args.output_index}."
)
sweep_history = _build_sweep_history(raw_records, preprocessed_records, primary_stage=args.primary_stage)
if args.last_n > 0:
sweep_history = sweep_history[-args.last_n :]
if not sweep_history:
raise ValueError("Conversion produced empty sweep_history.")
pre_refs = _load_stage_refs(snapshot_dir, "preprocessed")
result_refs = _load_stage_refs(snapshot_dir, "results")
alignment_warning = _stage_alignment_warning(pre_refs, result_refs)
manifest_path = snapshot_dir / "manifest.json"
manifest = _load_json(manifest_path) if manifest_path.exists() else {}
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": str(snapshot_dir),
"input_index": int(args.input_index),
"output_index": int(args.output_index),
"primary_stage": args.primary_stage,
"raw_record_count": len(raw_records),
"preprocessed_record_count": len(preprocessed_records),
"sweep_history": sweep_history,
}
if alignment_warning is not None:
payload["alignment_warning"] = alignment_warning
if manifest:
payload["snapshot_manifest"] = manifest
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(
"Converted snapshot to vna_system history JSON:\n"
f" input snapshot: {snapshot_dir}\n"
f" output file: {output_path}\n"
f" sweeps written: {len(sweep_history)}\n"
f" raw records: {len(raw_records)}\n"
f" pre records: {len(preprocessed_records)}"
)
if alignment_warning is not None:
print(f"[convert-warning] {alignment_warning}")
if __name__ == "__main__":
main()
@@ -0,0 +1,360 @@
"""Standalone GUI utility for inspecting raw orchestrator ring output."""
from __future__ import annotations
import argparse
import ctypes
import ctypes.util
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import time
import numpy as np
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QLabel, QMainWindow, QVBoxLayout, QWidget
import pyqtgraph as pg
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from python_app.hardware_full.librevna_service import LibreVnaService
from python_app.models.run_config_model import RadarSweepModel
from python_app.orchestration.shm_reader import ShmRingReader
def _shm_unlink(name: str) -> None:
"""Best-effort unlink for POSIX shared-memory object."""
libc_name = ctypes.util.find_library("c")
if libc_name is None:
return
libc = ctypes.CDLL(libc_name, use_errno=True)
libc.shm_unlink.argtypes = [ctypes.c_char_p]
libc.shm_unlink.restype = ctypes.c_int
result = libc.shm_unlink(name.encode("utf-8"))
if result == 0:
return
err = ctypes.get_errno()
if err != 2: # ENOENT
raise OSError(err, f"shm_unlink failed for {name}")
def _read_raw_ring_name(config_path: Path) -> str:
"""Extract raw ring name from run config."""
config = json.loads(config_path.read_text(encoding="utf-8"))
ring_name = config["rings"]["raw"]["name"]
if not isinstance(ring_name, str) or not ring_name.startswith("/"):
raise RuntimeError("Config rings.raw.name must be a POSIX shm name starting with '/'")
return ring_name
def _read_native_summary(config_path: Path) -> str:
"""Build short summary of radar/switch driver modes."""
config = json.loads(config_path.read_text(encoding="utf-8"))
radar_mode = config["radar"]["driver_mode"]
switches = config["switches"]
if "port1" in switches and "port2" in switches:
port1_mode = switches["port1"]["driver_mode"]
port2_mode = switches["port2"]["driver_mode"]
else:
port1_mode = switches["output"]["driver_mode"]
port2_mode = switches["input"]["driver_mode"]
return f"radar={radar_mode}, port1={port1_mode}, port2={port2_mode}"
def _prepare_radar_if_needed(config_path: Path, *, strict: bool) -> str | None:
"""Preconfigure native radar through Python service when requested."""
config = json.loads(config_path.read_text(encoding="utf-8"))
radar = config["radar"]
if radar["driver_mode"] != "native":
return "Radar pre-configuration skipped (mock mode)."
sweep = radar["sweep"]
sweep_model = RadarSweepModel(
start_hz=float(sweep["start_hz"]),
stop_hz=float(sweep["stop_hz"]),
points=int(sweep["points"]),
if_bandwidth_hz=float(sweep["if_bandwidth_hz"]),
power_dbm=float(sweep.get("stimulus_power_dbm", -10.0)),
)
radar_service = LibreVnaService(serial=radar.get("serial") or None)
if not radar_service.driver_available:
message = "LibreVNA Python driver is unavailable: skipping pre-configuration"
if strict:
raise RuntimeError(message)
return message
try:
radar_service.open()
radar_service.configure(sweep_model)
return "Radar pre-configuration completed."
except Exception as exc:
message = f"Radar pre-configuration failed ({exc})"
if strict:
raise
return f"{message}. Continuing with native C++ configuration."
finally:
radar_service.close()
class RawOrchestratorViewer(QMainWindow):
"""Qt window that runs sweep_orchestrator and plots raw collections."""
def __init__(self, config_path: Path, reset_ring: bool, prepare_radar: bool, strict_prepare: bool) -> None:
"""Initialize viewer, optionally prepare radar, and start polling."""
super().__init__()
self._config_path = config_path
self._raw_ring_name = _read_raw_ring_name(config_path)
self._orchestrator_process: subprocess.Popen[str] | None = None
self._raw_reader: ShmRingReader | None = None
if reset_ring:
_shm_unlink(self._raw_ring_name)
self._build_ui()
if prepare_radar:
self._status.setText("Preparing radar...")
prepare_status = _prepare_radar_if_needed(self._config_path, strict=strict_prepare)
if prepare_status is not None:
self._status.setText(prepare_status)
self._start_orchestrator()
self._open_reader_or_fail()
self._timer = QTimer(self)
self._timer.setInterval(60)
self._timer.timeout.connect(self._poll)
self._timer.start()
def _build_ui(self) -> None:
"""Build viewer widgets and raw trace plot."""
self.setWindowTitle("Raw Sweep Viewer (orchestrator)")
root = QWidget(self)
self.setCentralWidget(root)
layout = QVBoxLayout(root)
mode_summary = _read_native_summary(self._config_path)
self._status = QLabel(f"Starting... ({mode_summary})")
layout.addWidget(self._status)
self._plot = pg.PlotWidget(background="#101418")
self._plot.showGrid(x=True, y=True, alpha=0.2)
self._plot.setLabel("bottom", "Frequency", units="Hz")
self._plot.setLabel("left", "Magnitude", units="dB")
layout.addWidget(self._plot)
self.resize(1400, 900)
def _start_orchestrator(self) -> None:
"""Start sweep orchestrator subprocess."""
binary = PROJECT_ROOT / "build/bin/sweep_orchestrator"
if not binary.exists():
raise RuntimeError(f"Missing binary: {binary}. Build first with 'make -j4'")
command = [str(binary), "--config", str(self._config_path)]
self._orchestrator_process = subprocess.Popen(
command,
cwd=PROJECT_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
def _open_reader_or_fail(self) -> None:
"""Wait for raw ring readiness and open `ShmRingReader`."""
if self._orchestrator_process is None:
raise RuntimeError("Orchestrator process is not started")
shm_path = Path("/dev/shm") / self._raw_ring_name[1:]
deadline = time.monotonic() + 5.0
last_reader_error: str | None = None
while time.monotonic() < deadline:
return_code = self._orchestrator_process.poll()
if return_code is not None:
details = self._read_process_output(self._orchestrator_process)
raise RuntimeError(f"sweep_orchestrator exited with code {return_code}: {details}")
if shm_path.exists():
try:
self._raw_reader = ShmRingReader(self._raw_ring_name)
self._status.setText(f"Running: ring={self._raw_ring_name}")
return
except RuntimeError as exc:
last_reader_error = str(exc)
time.sleep(0.05)
if last_reader_error is not None:
raise RuntimeError(
f"Timed out waiting for raw ring header readiness: {self._raw_ring_name}; "
f"last error: {last_reader_error}"
)
raise RuntimeError(f"Timed out waiting for raw ring file: {shm_path}")
def _poll(self) -> None:
"""Poll subprocess state and draw latest available raw collection."""
if self._orchestrator_process is None:
return
return_code = self._orchestrator_process.poll()
if return_code is not None:
details = self._read_process_output(self._orchestrator_process)
self._status.setText(f"Error: sweep_orchestrator exited ({return_code})")
raise RuntimeError(f"sweep_orchestrator exited with code {return_code}: {details}")
if self._raw_reader is None:
return
latest = None
for _ in range(16):
collection = self._raw_reader.pop_raw_collection()
if collection is None:
break
latest = collection
if latest is not None:
self._draw_collection(latest)
def _draw_collection(self, collection) -> None:
"""Render all traces from one raw collection."""
self._plot.clear()
palette = [
"#4cc9f0",
"#f72585",
"#b8f2e6",
"#ffd166",
"#90be6d",
"#ff595e",
"#6a4c93",
"#1982c4",
"#ff9f1c",
"#2ec4b6",
"#e71d36",
"#a0c4ff",
]
for idx, trace in enumerate(collection.traces):
magnitude_db = 20.0 * np.log10(np.maximum(np.abs(trace.s21), 1e-12))
label = f"input={trace.combo.input_pos}, output={trace.combo.output_pos}"
self._plot.plot(
trace.frequency_hz,
magnitude_db,
pen=pg.mkPen(palette[idx % len(palette)], width=1.6),
name=label,
)
self._status.setText(
f"Running: collection_id={collection.collection_id}, "
f"traces={len(collection.traces)}, ring={self._raw_ring_name}"
)
@staticmethod
def _read_process_output(process: subprocess.Popen[str]) -> str:
"""Collect process stdout/stderr text for diagnostics."""
stdout = ""
stderr = ""
if process.stdout is not None:
stdout = process.stdout.read().strip()
if process.stderr is not None:
stderr = process.stderr.read().strip()
if stderr and stdout:
return f"{stderr}\nstdout:\n{stdout}"
if stderr:
return stderr
if stdout:
return f"stdout:\n{stdout}"
return "no output"
def closeEvent(self, event) -> None: # noqa: N802
"""Stop subprocess and close reader before window destruction."""
self._shutdown()
super().closeEvent(event)
def _shutdown(self) -> None:
"""Close reader and terminate subprocess."""
if self._raw_reader is not None:
self._raw_reader.close()
self._raw_reader = None
process = self._orchestrator_process
self._orchestrator_process = None
if process is None:
return
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=1.0)
def main() -> int:
"""CLI entrypoint for raw orchestrator viewer."""
parser = argparse.ArgumentParser(description="Run sweep_orchestrator and plot raw sweep collections")
parser.add_argument(
"--config",
type=Path,
default=PROJECT_ROOT / "run_config.json",
help="Path to run config JSON",
)
parser.add_argument(
"--no-reset-ring",
action="store_true",
help="Do not unlink existing raw ring name before starting",
)
parser.add_argument(
"--skip-radar-prepare",
action="store_true",
help="Skip Python pre-configuration of native radar before orchestrator start",
)
parser.add_argument(
"--strict-radar-prepare",
action="store_true",
help="Fail immediately if Python pre-configuration cannot run",
)
args = parser.parse_args()
config_path = args.config.resolve()
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
if os.geteuid() == 0 and os.environ.get("SUDO_USER"):
print(
"Warning: running GUI test via sudo can break Qt DBus/session integration. "
"Prefer regular user with USB/GPIO permissions."
)
app = QApplication(sys.argv)
viewer = RawOrchestratorViewer(
config_path=config_path,
reset_ring=not args.no_reset_ring,
prepare_radar=not args.skip_radar_prepare,
strict_prepare=args.strict_radar_prepare,
)
viewer.show()
def _sig_handler(_signum, _frame):
"""Close viewer gracefully on process signals."""
viewer.close()
signal.signal(signal.SIGINT, _sig_handler)
signal.signal(signal.SIGTERM, _sig_handler)
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())
+152
View File
@@ -0,0 +1,152 @@
"""Manual smoke scenario for end-to-end pipeline check without GUI."""
from __future__ import annotations
import argparse
import ctypes
import ctypes.util
import os
from pathlib import Path
import sys
import time
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.config_writer import ConfigWriter
from python_app.orchestration.process_supervisor import ProcessSupervisor
from python_app.orchestration.shm_reader import ShmRingReader
from python_app.storage.npz_store import NpzStore, radar_key_from_config
def _make_unique_ring_name(prefix: str) -> str:
"""Build unique POSIX SHM ring name."""
stamp = time.monotonic_ns()
return f"/{prefix}_{os.getpid()}_{stamp}"
def _shm_unlink(name: str) -> None:
"""Best-effort unlink for POSIX shared-memory object."""
libc_name = ctypes.util.find_library("c")
if libc_name is None:
return
libc = ctypes.CDLL(libc_name, use_errno=True)
libc.shm_unlink.argtypes = [ctypes.c_char_p]
libc.shm_unlink.restype = ctypes.c_int
result = libc.shm_unlink(name.encode("utf-8"))
if result == 0:
return
err = ctypes.get_errno()
if err != 2: # ENOENT
raise OSError(err, f"shm_unlink failed for {name}")
def build_synthetic_collection(config: RunConfigModel, value_scale: float) -> SweepCollection:
"""Build synthetic sweep collection for all configured switch combos."""
traces: list[TraceData] = []
combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
for combo in combos:
frequency_hz = np.linspace(
config.radar.sweep.start_hz,
config.radar.sweep.stop_hz,
config.radar.sweep.points,
dtype=np.float32,
)
phase = np.linspace(0.0, np.pi * 2.0, config.radar.sweep.points, dtype=np.float32)
s21 = value_scale * (np.cos(phase) + 1j * np.sin(phase)).astype(np.complex64)
traces.append(
TraceData(
combo=ComboKey(input_pos=combo.input, output_pos=combo.output),
frequency_hz=frequency_hz,
s21=s21,
)
)
return SweepCollection(collection_id=1, monotonic_ns=time.monotonic_ns(), traces=traces)
def main() -> int:
"""Run manual smoke-test pipeline scenario."""
parser = argparse.ArgumentParser()
parser.add_argument("--duration", type=float, default=3.0)
args = parser.parse_args()
project_root = PROJECT_ROOT
store = NpzStore(project_root / "python_app/data")
config_writer = ConfigWriter(project_root / "python_app/runtime")
supervisor = ProcessSupervisor(project_root)
config = RunConfigModel.load_from_path(project_root / "run_config.json")
config.radar.driver_mode = "mock"
config.input_switch.driver_mode = "mock"
config.output_switch.driver_mode = "mock"
config.combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
config.rings.raw.name = _make_unique_ring_name("radar_raw_smoke")
config.rings.raw_tap.name = _make_unique_ring_name("radar_raw_tap_smoke")
config.rings.preprocessed.name = _make_unique_ring_name("radar_preprocessed_smoke")
config.rings.preprocessed_tap.name = _make_unique_ring_name("radar_preprocessed_tap_smoke")
config.rings.results.name = _make_unique_ring_name("radar_results_smoke")
radar_key = radar_key_from_config(
model_name=config.radar.model,
serial=config.radar.serial,
sweep_start_hz=config.radar.sweep.start_hz,
sweep_stop_hz=config.radar.sweep.stop_hz,
sweep_points=config.radar.sweep.points,
ifbw_hz=config.radar.sweep.if_bandwidth_hz,
power_dbm=config.radar.sweep.power_dbm,
)
calibration_set = build_synthetic_collection(config, value_scale=1.0)
reference_set = build_synthetic_collection(config, value_scale=0.3)
store.save_set("calibration", radar_key, "smoke_cal", calibration_set)
store.save_set("reference", radar_key, "smoke_ref", reference_set)
calibration_bundle, reference_bundle = config_writer.prepare_bundles(store, radar_key, "smoke_cal", "smoke_ref")
config.preprocess.calibration_set = "smoke_cal"
config.preprocess.reference_set = "smoke_ref"
config.preprocess.calibration_bundle_path = str(calibration_bundle)
config.preprocess.reference_bundle_path = str(reference_bundle)
config_path = config_writer.write(config, project_root / "python_app/runtime/run_config_smoke.json")
result_reader: ShmRingReader | None = None
try:
supervisor.start(config_path)
result_reader = ShmRingReader(config.rings.results.name)
deadline = time.monotonic() + args.duration
received = 0
while time.monotonic() < deadline:
result = result_reader.pop_result_collection() if result_reader is not None else None
if result is not None:
received += 1
time.sleep(0.02)
print(f"Received result collections: {received}")
finally:
supervisor.stop_all()
if result_reader is not None:
result_reader.close()
_shm_unlink(config.rings.raw.name)
_shm_unlink(config.rings.raw_tap.name)
_shm_unlink(config.rings.preprocessed.name)
_shm_unlink(config.rings.preprocessed_tap.name)
_shm_unlink(config.rings.results.name)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+72
View File
@@ -0,0 +1,72 @@
"""Minimal GUI tool for direct LibreVNA raw acquisition checks."""
from __future__ import annotations
import argparse
from pathlib import Path
import signal
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from PyQt6.QtWidgets import QApplication
from python_app.scripts.hardware_raw_orchestrator_test import RawOrchestratorViewer
def main() -> int:
"""Run standalone raw-viewer GUI against a selected run config."""
parser = argparse.ArgumentParser(
description="Intermediate test: native VNA acquisition with mock switch drivers"
)
parser.add_argument(
"--config",
type=Path,
default=PROJECT_ROOT / "run_config.json",
help="Path to run config JSON",
)
parser.add_argument(
"--no-reset-ring",
action="store_true",
help="Do not unlink existing raw ring name before starting",
)
parser.add_argument(
"--skip-radar-prepare",
action="store_true",
help="Skip Python pre-configuration of native radar before orchestrator start",
)
parser.add_argument(
"--strict-radar-prepare",
action="store_true",
help="Fail immediately if Python pre-configuration cannot run",
)
args = parser.parse_args()
config_path = args.config.resolve()
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
app = QApplication(sys.argv)
viewer = RawOrchestratorViewer(
config_path=config_path,
reset_ring=not args.no_reset_ring,
prepare_radar=not args.skip_radar_prepare,
strict_prepare=args.strict_radar_prepare,
)
viewer.setWindowTitle("Raw Sweep Viewer (VNA native + mock switches)")
viewer.show()
def _sig_handler(_signum, _frame):
"""Close viewer gracefully on process signals."""
viewer.close()
signal.signal(signal.SIGINT, _sig_handler)
signal.signal(signal.SIGTERM, _sig_handler)
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())