Rework radar system

This commit is contained in:
Ayzen
2026-10-06 12:32:26 +03:00
parent 552cdc688a
commit 5bb86b7165
299 changed files with 13537 additions and 27763 deletions
-301
View File
@@ -1,301 +0,0 @@
"""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", ""))
s11_file = str(trace_meta.get("s11_file", ""))
s21_file = str(trace_meta.get("s21_file", ""))
freq = np.load(collection_dir / freq_file)
s11 = np.load(collection_dir / s11_file)
s21 = np.load(collection_dir / s21_file)
if freq.shape != s11.shape:
raise ValueError(f"Shape mismatch freq/s11 in {collection_dir}")
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(s11)).all()
and np.isfinite(np.imag(s11)).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."""
config_profile_path = snapshot_dir / "config_profile.json"
if config_profile_path.exists():
print(f"Snapshot: {snapshot_dir}\nconfig_profile={config_profile_path.name}")
else:
print(f"Snapshot: {snapshot_dir}")
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()
@@ -1,251 +0,0 @@
#!/usr/bin/env python3
"""Convert legacy preprocess set storage into the current two-channel format."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
from typing import Any
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.storage.npz_store import NpzStore
LEGACY_KIND_MAP: dict[str, str] = {
"calibration": "s21_calibration",
"reference": "s21_reference",
"s21_calibration": "s21_calibration",
"s21_reference": "s21_reference",
"s11_open": "s11_open",
"s11_short": "s11_short",
"s11_load": "s11_load",
"s11_reference": "s11_reference",
}
S21_ONLY_TARGET_KINDS = {"s21_calibration", "s21_reference"}
def _build_parser() -> argparse.ArgumentParser:
"""Return the argument parser for the legacy preprocess-set conversion CLI."""
parser = argparse.ArgumentParser(
description=(
"Convert old preprocess-set storage from a legacy python_app/data tree into the "
"current format required by the new GUI/runtime."
)
)
parser.add_argument(
"source_data_dir",
type=Path,
help="Path to legacy python_app/data directory from the old project copy.",
)
parser.add_argument(
"output_data_dir",
type=Path,
help="Destination directory where converted sets will be written in the new format.",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Allow overwriting already converted destination sets.",
)
return parser
def _load_json(path: Path) -> dict[str, Any]:
"""Read a JSON file and return its top-level object, rejecting non-object roots."""
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 _read_combo_position(combo_payload: dict[str, Any], *, primary_key: str, alias_key: str) -> int:
"""Return a switch position from a combo record, accepting the primary or alias key."""
if primary_key in combo_payload:
return int(combo_payload[primary_key])
if alias_key in combo_payload:
return int(combo_payload[alias_key])
raise KeyError(f"Missing combo position field: {primary_key}/{alias_key}")
def _combo_suffix(input_pos: int, output_pos: int) -> str:
"""Return the per-combo array-name suffix (e.g. ``i0_o1``) used in legacy NPZ keys."""
return f"i{input_pos}_o{output_pos}"
def _load_array(arrays: Any, key: str, *, dtype: np.dtype[Any], label: str) -> np.ndarray:
"""Return a flattened array of the given dtype from an NPZ mapping, by key."""
if key not in arrays:
raise KeyError(f"Missing {label} array '{key}' in NPZ archive")
return np.asarray(arrays[key], dtype=dtype).reshape(-1)
def _load_legacy_collection(meta_path: Path, npz_path: Path, *, target_kind: str) -> SweepCollection:
"""Build a SweepCollection from a legacy meta/NPZ pair for the given target kind.
Reads per-combo frequency, S21 and (when present) S11 arrays. For S21-only target
kinds a missing S11 is filled with zeros; for any other kind a missing S11 is an error.
"""
meta = _load_json(meta_path)
combos_payload = meta.get("combos")
if not isinstance(combos_payload, list):
raise ValueError(f"Expected 'combos' list in {meta_path}")
with np.load(npz_path) as arrays:
traces: list[TraceData] = []
for combo_payload in combos_payload:
if not isinstance(combo_payload, dict):
raise ValueError(f"Expected combo object in {meta_path}")
input_pos = _read_combo_position(combo_payload, primary_key="input", alias_key="input_pos")
output_pos = _read_combo_position(combo_payload, primary_key="output", alias_key="output_pos")
suffix = _combo_suffix(input_pos, output_pos)
freq_key = str(combo_payload.get("freq_key") or f"freq_{suffix}")
s21_key = str(combo_payload.get("s21_key") or f"s21_{suffix}")
s11_key = str(combo_payload.get("s11_key") or f"s11_{suffix}")
frequency_hz = _load_array(arrays, freq_key, dtype=np.float32, label="frequency")
s21 = _load_array(arrays, s21_key, dtype=np.complex64, label="S21")
if frequency_hz.shape != s21.shape:
raise ValueError(f"Frequency/S21 shape mismatch in {npz_path}: {frequency_hz.shape} vs {s21.shape}")
if s11_key in arrays:
s11 = _load_array(arrays, s11_key, dtype=np.complex64, label="S11")
elif target_kind in S21_ONLY_TARGET_KINDS:
s11 = np.zeros_like(s21, dtype=np.complex64)
else:
raise KeyError(
f"Missing S11 array '{s11_key}' in {npz_path}; "
f"cannot convert target kind '{target_kind}' without real S11 data"
)
if frequency_hz.shape != s11.shape:
raise ValueError(f"Frequency/S11 shape mismatch in {npz_path}: {frequency_hz.shape} vs {s11.shape}")
traces.append(
TraceData(
combo=ComboKey(input=input_pos, output=output_pos),
frequency_hz=frequency_hz,
s11=s11,
s21=s21,
)
)
return SweepCollection(
collection_id=int(meta.get("collection_id", 0)),
monotonic_ns=int(meta.get("monotonic_ns", 0)),
traces=traces,
)
def _convert_one_set(
store: NpzStore,
*,
output_root: Path,
source_kind: str,
target_kind: str,
radar_key: str,
meta_path: Path,
overwrite: bool,
) -> None:
"""Convert a single legacy set (meta + NPZ) and save it under the target kind.
Raises if the companion NPZ is missing, or if the destination already exists and
``overwrite`` is False.
"""
set_name = meta_path.stem
npz_path = meta_path.with_suffix(".npz")
if not npz_path.exists():
raise FileNotFoundError(f"Missing NPZ archive for set '{set_name}': {npz_path}")
target_dir = output_root / target_kind / radar_key
target_json = target_dir / f"{set_name}.json"
target_npz = target_dir / f"{set_name}.npz"
if not overwrite and (target_json.exists() or target_npz.exists()):
raise FileExistsError(f"Destination set already exists: {target_dir / set_name}")
collection = _load_legacy_collection(meta_path, npz_path, target_kind=target_kind)
store.save_set(target_kind, radar_key, set_name, collection)
print(
f"[converted] {source_kind}/{radar_key}/{set_name} -> "
f"{target_kind}/{radar_key}/{set_name} (traces={len(collection.traces)})"
)
def main() -> int:
"""Walk the legacy data tree, convert every recognized set, and print a summary.
Returns 0 on full success, 1 if any set failed to convert, or 2 if no convertible
sets were found.
"""
parser = _build_parser()
args = parser.parse_args()
source_root = args.source_data_dir.expanduser().resolve()
output_root = args.output_data_dir.expanduser().resolve()
if not source_root.exists():
raise FileNotFoundError(f"Source data directory does not exist: {source_root}")
if source_root == output_root:
raise ValueError("Source and output directories must be different")
store = NpzStore(output_root)
converted_count = 0
skipped_kind_count = 0
error_messages: list[str] = []
for source_kind_dir in sorted(path for path in source_root.iterdir() if path.is_dir()):
source_kind = source_kind_dir.name
target_kind = LEGACY_KIND_MAP.get(source_kind)
if target_kind is None:
skipped_kind_count += 1
print(f"[skip-kind] {source_kind_dir}")
continue
for radar_key_dir in sorted(path for path in source_kind_dir.iterdir() if path.is_dir()):
radar_key = radar_key_dir.name
for meta_path in sorted(radar_key_dir.glob("*.json")):
try:
_convert_one_set(
store,
output_root=output_root,
source_kind=source_kind,
target_kind=target_kind,
radar_key=radar_key,
meta_path=meta_path,
overwrite=bool(args.overwrite),
)
converted_count += 1
except Exception as exc: # noqa: BLE001
error_messages.append(f"{meta_path}: {exc}")
print(f"[error] {meta_path}: {exc}")
print(
"\nConversion summary:\n"
f" source root: {source_root}\n"
f" output root: {output_root}\n"
f" converted sets: {converted_count}\n"
f" skipped kinds: {skipped_kind_count}\n"
f" errors: {len(error_messages)}"
)
if error_messages:
return 1
if converted_count == 0:
print("No convertible preprocess sets were found.")
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,409 +0,0 @@
"""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
from pathlib import Path
from typing import Any
import numpy as np
from python_app.file_io import atomic_write_json
def _real_imag_keys(trace_prefix: str) -> tuple[str, str]:
"""Return the LibreVNA CSV column names for a trace's real and imaginary parts."""
return f"{trace_prefix}_Real", f"{trace_prefix}_Imaginary"
def _load_complex_trace(csv_path: Path, trace_prefix: str) -> tuple[np.ndarray, np.ndarray]:
"""Load one LibreVNA CSV and return its (frequency_hz, complex trace) arrays."""
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:
"""Apply OSL one-port error correction to a measured S11 trace and return it."""
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:
"""Normalize a measured S21 trace by the through reference and return it."""
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 complex samples as ``[real, imag]`` pairs for JSON serialization."""
return [[float(value.real), float(value.imag)] for value in values]
def _scan_file_sort_key(csv_path: Path) -> tuple[int, str]:
"""Return a sort key ordering numeric scan filenames first, by integer value."""
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]]]:
"""Load every numbered scan CSV in a folder and return (frequency_hz, named traces).
All scans must share a common frequency axis; a mismatch raises ValueError.
"""
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:
"""Raise ValueError (tagged with ``label``) unless two frequency axes match within tolerance."""
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]:
"""Assemble the vna-system history payload from paired sweep and calibrated scans.
Pairs each sweep scan with its calibrated counterpart by order (names must match),
attaching the shared reference trace and sweep config to every history entry.
"""
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:
"""Write a payload as indented UTF-8 JSON, creating parent directories as needed."""
atomic_write_json(output_path, payload, durable=True)
def _rmse(left: np.ndarray, right: np.ndarray) -> float:
"""Return the root-mean-square magnitude difference between two complex traces."""
return float(np.sqrt(np.mean(np.abs(left - right) ** 2)))
def _build_parser() -> argparse.ArgumentParser:
"""Return the argument parser for the prog_libre-to-vna-history conversion CLI."""
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:
"""Calibrate and convert the prog_libre S11/S21 CSV captures into history JSON files.
Builds OSL coefficients for S11 and a through reference for S21, applies them to the
uncalibrated scans, writes raw and pre-calibrated history files for both channels, and
prints RMSE diagnostics against the supplied calibrated folder.
"""
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()
@@ -1,310 +0,0 @@
"""Convert radar_system runtime snapshots into vna_system sweep-history JSON."""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import numpy as np
from python_app.file_io import atomic_write_json
from python_app.storage.npz.vna_history_json import TraceRecord, _build_sweep_history, _normalize_channel
@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]:
"""Read a JSON file and return its top-level object, rejecting non-object roots."""
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 collection subdirectories in numeric recording order (empty if absent)."""
if not stage_dir.exists():
return []
return sorted(
(path for path in stage_dir.iterdir() if path.is_dir()),
key=lambda path: (_parse_stage_index(path.name, 0), path.name),
)
def _parse_stage_index(name: str, fallback: int) -> int:
"""Return the leading numeric prefix of a collection dir name, or ``fallback``."""
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:
"""Return the trace record matching the given input/output switch indices, or 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,
channel: str,
) -> list[TraceRecord]:
"""Load TraceRecords for one stage and one switch combo, for the given channel.
Skips collections without a matching trace or array files; raises on shape mismatch
or non-finite samples.
"""
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", ""))
samples_file = str(trace_meta.get(f"{channel}_file", ""))
if not freq_file or not samples_file:
continue
frequency_hz = np.asarray(np.load(collection_dir / freq_file), dtype=np.float64).reshape(-1)
samples = np.asarray(np.load(collection_dir / samples_file), dtype=np.complex128).reshape(-1)
if frequency_hz.shape != samples.shape:
raise ValueError(f"Shape mismatch in {collection_dir}: freq{frequency_hz.shape} vs {channel}{samples.shape}")
if frequency_hz.size == 0:
continue
if not (
np.isfinite(frequency_hz).all()
and np.isfinite(np.real(samples)).all()
and np.isfinite(np.imag(samples)).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,
samples=samples,
capture_end_ns=int(trace_meta.get("capture_end_ns", 0)),
capture_start_ns=int(trace_meta.get("capture_start_ns", 0)),
)
)
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 _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:
"""Return the argument parser for the snapshot-to-vna-history conversion CLI."""
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(
"--channel",
choices=("s21", "s11"),
default="s21",
help="Trace channel to export into sweep/calibrated points.",
)
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:
"""Convert one snapshot's chosen channel/combo into a vna-system history JSON file.
Loads raw and preprocessed traces, builds the sweep history (optionally trimmed to the
last N sweeps), writes the output JSON, and prints a summary plus any stage-alignment
warning.
"""
parser = _build_parser()
args = parser.parse_args()
channel = _normalize_channel(args.channel)
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,
channel=channel,
)
preprocessed_records = _load_stage_records(
snapshot_dir,
"preprocessed",
input_index=args.input_index,
output_index=args.output_index,
channel=channel,
)
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,
channel=channel,
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)
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),
"channel": channel,
"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
atomic_write_json(output_path, payload, durable=True)
print(
"Converted snapshot to vna_system history JSON:\n"
f" input snapshot: {snapshot_dir}\n"
f" output file: {output_path}\n"
f" channel: {channel}\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()
@@ -1,94 +0,0 @@
"""CLI to create neutral S21 preprocess sets for a profile (headless).
Mirrors the GUI's "Create neutral sets" button: builds calibration = 1+0j and
reference = 0 collections for every combo of the profile and saves them under
the profile's radar key, so the data_preprocessor becomes a bit-exact
pass-through. Needed for replay profiles whose recorded data is already
preprocessed (see run_config_examples/run_config_replay_vna.example.json).
Usage:
python -m python_app.scripts.create_neutral_preprocess_sets \
--config run_config_examples/run_config_replay_vna.example.json \
[--set-name neutral_replay] [--force]
"""
from __future__ import annotations
import argparse
import logging
from pathlib import Path
from python_app.models.run_config_model import RunConfigModel
from python_app.storage.npz_store import NpzStore, radar_key_from_config
from python_app.workflows.kamil_adc_neutral_preprocess import (
build_neutral_s21_sets,
supports_neutral_preprocess_sets,
)
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def main() -> int:
"""Create and save the neutral S21 calibration/reference pair."""
parser = argparse.ArgumentParser(description="Create neutral S21 preprocess sets")
parser.add_argument("--config", required=True, type=Path, help="Path to a run_config profile")
parser.add_argument("--set-name", default="neutral_replay", help="Name for both saved sets")
parser.add_argument(
"--force", action="store_true", help="Overwrite the sets if they already exist"
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
config = RunConfigModel.load_from_path(args.config)
config.ensure_combos()
if not supports_neutral_preprocess_sets(config):
raise RuntimeError("Neutral S21 sets are not supported for this radar configuration")
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,
extra_serials=config.radar_key_extra_parts() or None,
)
store = NpzStore(PROJECT_ROOT / "python_app/data")
kinds = ("s21_calibration", "s21_reference")
existing = [
kind
for kind in kinds
if args.set_name in store.list_sets(kind, radar_key)
]
if existing and not args.force:
if len(existing) != len(kinds):
missing = ", ".join(kind for kind in kinds if kind not in existing)
raise RuntimeError(
f"Neutral set pair '{args.set_name}' is incomplete: missing {missing}; "
"use --force to replace both sets."
)
logger.info(
"Sets '%s' already exist for %s (radar_key=%s); nothing to do (use --force to overwrite).",
args.set_name, ", ".join(existing), radar_key,
)
return 0
calibration, reference = build_neutral_s21_sets(config)
store.save_sets(radar_key, args.set_name, dict(zip(kinds, (calibration, reference))))
logger.info(
"Neutral S21 sets saved: set=%s, radar_key=%s, combos=%d, points=%d",
args.set_name,
radar_key,
len(calibration.traces),
int(calibration.traces[0].frequency_hz.size),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,349 +0,0 @@
"""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.single_radar_service import create_single_radar_service
from python_app.models.run_config_model import RunConfigModel
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_payload = json.loads(config_path.read_text(encoding="utf-8"))
config = RunConfigModel.from_dict(config_payload)
if config.radar.driver_mode != "native":
return "Radar pre-configuration skipped (mock mode)."
if config.is_matrix_radar:
return "Radar pre-configuration skipped (matrix radar producer config)."
radar_service = None
try:
radar_service = create_single_radar_service(config)
radar_service.open()
radar_service.configure(config.radar.sweep)
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:
if radar_service is not None:
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}, output={trace.combo.output}"
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())
-649
View File
@@ -1,649 +0,0 @@
"""Calibrate the Kamil ADC reference-phase frequency law from live sweeps.
Captures many raw sweeps from the collector and reports — and optionally writes
back — the calibration the runtime uses:
* ``phase0_rad`` / ``phase1_rad`` — the unwrapped reference phase at the sweep
start and stop, taken as the MEDIAN over all captured sweeps. ``freq0_hz`` /
``freq1_hz`` (the known sweep endpoints) come from config and are kept as-is.
* ``band.points`` — recommended as the median number of usable points landing
inside ``[band.start_hz, band.stop_hz]``, so the fixed output grid matches the
native density rather than inflating it.
It also reports how reliably the configured band is covered (sweeps that do not
span it are rejected at runtime) and how many points the crop discards.
The collector is launched with the ``do8_freq_ref`` arguments regardless of what
the on-disk config says, so the reference channel is always present. With
``--apply`` the config is migrated to that collector/args and the calibrated
anchors + recommended point count are written back.
Run on the Pi, e.g.::
.venv/bin/python -m python_app.scripts.kamil_adc_calibrate \
--config run_config_kamil_adc.pi.json --sweeps 200 --apply
"""
from __future__ import annotations
import argparse
from contextlib import suppress
import json
import logging
from pathlib import Path
import shutil
import statistics
import subprocess
import time
import numpy as np
from python_app.hardware_full.kamil_adc import KamilAdcService, apply_kamil_adc_laser_control
from python_app.hardware_full.kamil_adc.processing import (
KamilAdcProcessingParams,
KamilAdcSweepProcessor,
)
from python_app.models.run_config_model import RunConfigModel
logger = logging.getLogger("kamil_adc_calibrate")
# The collector arguments that enable the DI8 reference overlay (mirrors the
# kamil example config / run_do8_freq_ref.sh). Forced on so calibration always
# sees the reference channel even if the on-disk config predates it.
DO8_FREQ_REF_ARGS = [
"profile:phase",
"clock:internal",
"internal_ref_hz:2000000",
"start:di_syn2_rise",
"stop:di_syn2_fall",
"sample_clock_hz:max",
"range:2",
"duration_ms:100",
"packet_limit:0",
"do1_toggle_per_frame",
"do1_pair_subtract_avg",
"do8_freq_ref",
"do8_cycle_period:8",
]
COLLECTOR_PATH = "build/bin/kamil_adc_collector"
# Hardware the daemon / a prior collector may be holding, and how to free it.
# Mirrors what start.sh does before an interactive launch (stop the daemon, kill
# an orphaned collector) so this tool can grab the L-Card E-502 + lasers too.
RADAR_SERVICE_NAME = "radar.service"
COLLECTOR_PROCESS_PATTERN = "kamil_adc_collector"
# The E-502 is not reacquirable the instant it is released; wait before opening.
_DEVICE_SETTLE_SECONDS = 8.0
def _release_radar_hardware(*, settle_seconds: float = _DEVICE_SETTLE_SECONDS) -> None:
"""Free the L-Card E-502 + lasers before we open our own collector.
Mirrors ``start.sh`` on an interactive launch: stop the headless
``radar.service`` daemon (if active) so it releases the hardware, then kill any
orphaned ``kamil_adc_collector`` a prior (e.g. SSH-killed) run left holding the
device. Both steps are best-effort and non-fatal — the collector's own
open-retry covers the residual settle time — this just removes the usual reason
it never frees up. Passwordless ``sudo systemctl stop radar.service`` is
provisioned in ``/etc/sudoers.d/radar``.
"""
freed = False
systemctl = shutil.which("systemctl")
if systemctl is not None:
is_active = subprocess.run(
[systemctl, "is-active", "--quiet", RADAR_SERVICE_NAME],
check=False,
).returncode == 0
if is_active:
logger.info("Stopping %s so it releases the radar hardware...", RADAR_SERVICE_NAME)
stopped = subprocess.run(
["sudo", systemctl, "stop", RADAR_SERVICE_NAME], check=False
).returncode == 0
if stopped:
freed = True
else:
logger.warning(
"Could not stop %s (need passwordless sudo?); continuing anyway",
RADAR_SERVICE_NAME,
)
pkill = shutil.which("pkill")
if pkill is not None:
# pkill returns 0 when it matched & signalled at least one process.
if subprocess.run(
[pkill, "-9", "-f", COLLECTOR_PROCESS_PATTERN], check=False
).returncode == 0:
logger.info("Killed orphaned %s process(es) holding the device", COLLECTOR_PROCESS_PATTERN)
freed = True
if freed:
logger.info("Waiting %.0fs for the L-Card E-502 to settle...", settle_seconds)
time.sleep(settle_seconds)
def _open_with_retry(service: KamilAdcService, *, attempts: int = 4, delay_s: float = 8.0) -> None:
"""Open the collector, retrying the transient E-502 device-busy after a close.
The L-Card device is not always reacquirable immediately after a previous
collector released it; a short backoff lets it settle before the next try.
"""
for attempt in range(1, attempts + 1):
try:
service.open()
return
except Exception as exc: # noqa: BLE001 — device-busy is expected and retried
logger.warning("Collector open failed (attempt %d/%d): %s", attempt, attempts, exc)
with suppress(Exception):
service.close()
if attempt < attempts:
time.sleep(delay_s)
raise RuntimeError(f"Collector did not open after {attempts} attempts")
def _capture_reference_phases(service: KamilAdcService, *, warmup: int, sweeps: int) -> list[np.ndarray]:
"""Capture `sweeps` reference-phase arrays after discarding `warmup` sweeps."""
for index in range(warmup):
service.read_raw_sweep()
if index == 0:
logger.info("Warming up (%d sweeps) while the sweep settles...", warmup)
phases: list[np.ndarray] = []
for index in range(sweeps):
raw = service.read_raw_sweep()
if raw.reference.size >= 2:
phases.append(np.unwrap(np.angle(raw.reference.astype(np.complex128))))
if (index + 1) % 50 == 0:
logger.info("Captured %d/%d sweeps", index + 1, sweeps)
return phases
def _summarize(values: np.ndarray) -> str:
"""Compact min / median / max summary for a 1-D array."""
return f"min={np.min(values):.6g} median={np.median(values):.6g} max={np.max(values):.6g}"
def _analyze(phases: list[np.ndarray], config: RunConfigModel) -> dict:
"""Derive the calibration and band diagnostics from captured phase arrays."""
kamil = config.radar.kamil_adc
freq0, freq1 = kamil.phase_calibration.freq0_hz, kamil.phase_calibration.freq1_hz
band_start, band_stop = kamil.band.start_hz, kamil.band.stop_hz
phase0 = float(statistics.median(float(phase[0]) for phase in phases))
phase1 = float(statistics.median(float(phase[-1]) for phase in phases))
if phase1 == phase0:
raise RuntimeError("Degenerate calibration: median start and stop phases are equal")
slope = (freq1 - freq0) / (phase1 - phase0)
total_points = np.array([phase.size for phase in phases], dtype=np.float64)
f_starts, f_stops, in_band_counts, covers = [], [], [], []
for phase in phases:
freqs = freq0 + (phase - phase0) * slope
lo, hi = float(np.min(freqs)), float(np.max(freqs))
f_starts.append(lo)
f_stops.append(hi)
in_band_counts.append(int(np.count_nonzero((freqs >= band_start) & (freqs <= band_stop))))
covers.append(lo <= band_start and hi >= band_stop)
in_band = np.array(in_band_counts, dtype=np.float64)
f_start = np.array(f_starts)
f_stop = np.array(f_stops)
recommended_points = int(round(float(np.median(in_band))))
# A band that ~95% of sweeps satisfy on each edge: start at the 95th percentile
# of per-sweep start frequencies, stop at the 5th percentile of stop frequencies.
rec_band_start = float(np.quantile(f_start, 0.95))
rec_band_stop = float(np.quantile(f_stop, 0.05))
rec_coverage = float(np.mean((f_start <= rec_band_start) & (f_stop >= rec_band_stop)))
return {
"sweeps": len(phases),
"phase0_rad": phase0,
"phase1_rad": phase1,
"phase_span_rad": phase1 - phase0,
"phase0_mad_rad": float(np.median(np.abs([float(p[0]) - phase0 for p in phases]))),
"phase1_mad_rad": float(np.median(np.abs([float(p[-1]) - phase1 for p in phases]))),
"freq0_hz": freq0,
"freq1_hz": freq1,
"band_start_hz": band_start,
"band_stop_hz": band_stop,
"total_points_median": float(np.median(total_points)),
"in_band_points_median": float(np.median(in_band)),
"recommended_points": recommended_points,
"cropped_fraction": 1.0 - float(np.median(in_band)) / float(np.median(total_points)),
"coverage_fraction": float(np.mean(covers)),
"lower_ok_fraction": float(np.mean(f_start <= band_start)),
"upper_ok_fraction": float(np.mean(f_stop >= band_stop)),
"rec_band_start_hz": rec_band_start,
"rec_band_stop_hz": rec_band_stop,
"rec_coverage_fraction": rec_coverage,
"f_start": f_start,
"f_stop": f_stop,
}
def _report(result: dict) -> None:
"""Print a human-readable calibration report."""
print("\n" + "=" * 72)
print(f"Kamil ADC calibration over {result['sweeps']} sweeps")
print("=" * 72)
print(
f"phase0_rad = {result['phase0_rad']:.6f} (median start phase, MAD "
f"{result['phase0_mad_rad']:.4f}) -> {result['freq0_hz'] / 1e9:.4f} GHz"
)
print(
f"phase1_rad = {result['phase1_rad']:.6f} (median stop phase, MAD "
f"{result['phase1_mad_rad']:.4f}) -> {result['freq1_hz'] / 1e9:.4f} GHz"
)
print(f"phase span = {result['phase_span_rad']:.4f} rad")
print(
f"points/sweep: total median={result['total_points_median']:.0f}, "
f"in-band median={result['in_band_points_median']:.0f}"
)
print(f"recommended band.points = {result['recommended_points']}")
print(
f"band [{result['band_start_hz'] / 1e9:.3f}, {result['band_stop_hz'] / 1e9:.3f}] GHz: "
f"covered by {result['coverage_fraction'] * 100:.1f}% of sweeps "
f"(start<=lo: {result['lower_ok_fraction'] * 100:.1f}%, stop>=hi: {result['upper_ok_fraction'] * 100:.1f}%), "
f"{result['cropped_fraction'] * 100:.1f}% of points cropped"
)
print(f"per-sweep start freq (GHz): {_summarize(result['f_start'] / 1e9)}")
print(f"per-sweep stop freq (GHz): {_summarize(result['f_stop'] / 1e9)}")
print(
f"suggested band for ~95%/edge: [{result['rec_band_start_hz'] / 1e9:.3f}, "
f"{result['rec_band_stop_hz'] / 1e9:.3f}] GHz -> covers "
f"{result['rec_coverage_fraction'] * 100:.1f}% of sweeps"
)
if result["coverage_fraction"] < 0.95:
print(
"WARNING: many sweeps do not cover the configured band and would be rejected; "
"consider the suggested band above (or longer laser settling if sweeps are partial)."
)
print("=" * 72 + "\n")
_TWO_PI = 2.0 * np.pi
def _capture_raw_sweeps(
service: KamilAdcService, *, warmup: int, sweeps: int
) -> list[tuple[np.ndarray, np.ndarray, np.ndarray]]:
"""Capture full ``(steps, main, reference)`` arrays after discarding `warmup`.
Unlike :func:`_capture_reference_phases`, this keeps the complete complex main
and reference samples (and their step indices) so the diagnosis can study the
raw phase, amplitude, and the pass-through behaviour — not just the endpoints.
"""
for index in range(warmup):
service.read_raw_sweep()
if index == 0:
logger.info("Warming up (%d sweeps) while the sweep settles...", warmup)
captured: list[tuple[np.ndarray, np.ndarray, np.ndarray]] = []
for index in range(sweeps):
raw = service.read_raw_sweep()
if raw.reference.size >= 2 and raw.main.size == raw.reference.size:
captured.append(
(
np.asarray(raw.steps),
raw.main.astype(np.complex128),
raw.reference.astype(np.complex128),
)
)
if (index + 1) % 50 == 0:
logger.info("Captured %d/%d sweeps", index + 1, sweeps)
return captured
def _diagnose(
sweeps: list[tuple[np.ndarray, np.ndarray, np.ndarray]], config: RunConfigModel
) -> dict:
"""Characterise how the reference phase / frequency axis moves across sweeps.
The reference arm has a fixed electrical delay, so each sweep's unwrapped phase
is a straight ramp; the sweep start frequency wandering shifts that ramp
vertically (the *physical float*). When ``np.angle(ref[0])`` crosses the +/-pi
cut, ``np.unwrap`` re-anchors the whole ramp one 2*pi turn away (the *spurious
branch wrap*) — a ~``2*pi*slope`` jump of the entire frequency axis that wrecks
the band mapping for that sweep.
This separates the two: it reconstructs the anchor phase per sweep, unwraps it
*across* sweeps (the candidate fix), and reports how that removes discrete 2*pi
steps while leaving the slow physical float intact. It also runs the live
pass-through (``KamilAdcSweepProcessor``) to flag which sweeps actually come out
distorted, and correlates those with the detected branch wraps.
"""
kamil = config.radar.kamil_adc
cal = kamil.phase_calibration
phase0, phase1 = float(cal.phase0_rad), float(cal.phase1_rad)
freq0, freq1 = float(cal.freq0_hz), float(cal.freq1_hz)
slope = (freq1 - freq0) / (phase1 - phase0) # Hz per rad
band_start, band_stop = float(kamil.band.start_hz), float(kamil.band.stop_hz)
n = len(sweeps)
anchor_phase = np.empty(n) # unwrap(angle(ref))[0] == angle(ref[0]) in (-pi, pi]
stop_phase = np.empty(n) # unwrap(angle(ref))[-1]
span_rad = np.empty(n) # stop_phase - anchor_phase (fixed-delay invariant)
n_points = np.empty(n, dtype=np.int64)
f_lo = np.empty(n) # min reconstructed frequency (config calibration)
f_hi = np.empty(n) # max reconstructed frequency
ref_amp_med = np.empty(n)
for i, (_steps, _main, reference) in enumerate(sweeps):
phase = np.unwrap(np.angle(reference))
anchor_phase[i] = phase[0]
stop_phase[i] = phase[-1]
span_rad[i] = phase[-1] - phase[0]
n_points[i] = reference.size
freqs = freq0 + (phase - phase0) * slope
f_lo[i] = float(np.min(freqs))
f_hi[i] = float(np.max(freqs))
ref_amp_med[i] = float(np.median(np.abs(reference)))
# --- Candidate fix: unwrap the anchor phase ACROSS the sweep-time axis ------
# The physical float moves the anchor smoothly; a branch wrap injects a +/-2*pi
# step. Unwrapping along sweep index removes the discrete steps and keeps the
# slow float. branch_turns is the integer turns each sweep was wrapped by.
anchor_unwrapped = np.unwrap(anchor_phase)
branch_turns = np.round((anchor_unwrapped - anchor_phase) / _TWO_PI).astype(np.int64)
axis_shift = branch_turns * _TWO_PI * slope # Hz the whole axis was displaced
f_lo_corr = f_lo + axis_shift
f_hi_corr = f_hi + axis_shift
# Branch wraps: consecutive anchor steps above pi are spurious 2*pi jumps.
anchor_step = np.diff(anchor_phase)
wrap_indices = np.nonzero(np.abs(anchor_step) > np.pi)[0] + 1 # sweep index after wrap
covers_raw = (f_lo <= band_start) & (f_hi >= band_stop)
covers_corr = (f_lo_corr <= band_start) & (f_hi_corr >= band_stop)
passthrough = _passthrough_flatness(sweeps, kamil)
return {
"sweeps": n,
"slope_hz_per_rad": slope,
"axis_2pi_shift_hz": _TWO_PI * slope,
"band_start_hz": band_start,
"band_stop_hz": band_stop,
"anchor_phase": anchor_phase,
"anchor_unwrapped": anchor_unwrapped,
"branch_turns": branch_turns,
"n_branch_wraps": int(wrap_indices.size),
"wrap_indices": wrap_indices,
"span_rad": span_rad,
"n_points": n_points,
"f_lo": f_lo,
"f_hi": f_hi,
"f_lo_corr": f_lo_corr,
"f_hi_corr": f_hi_corr,
"axis_shift_hz": axis_shift,
"ref_amp_med": ref_amp_med,
"coverage_raw": float(np.mean(covers_raw)),
"coverage_corr": float(np.mean(covers_corr)),
# Largest sweep-to-sweep step of the *unwrapped* anchor: the fix is only
# valid if this stays below pi (otherwise the cross-sweep unwrap is itself
# ambiguous). Reported so we can confirm the float really is slow.
"max_unwrapped_anchor_step_rad": float(np.max(np.abs(np.diff(anchor_unwrapped))))
if n > 1
else 0.0,
**passthrough,
}
def _passthrough_flatness(
sweeps: list[tuple[np.ndarray, np.ndarray, np.ndarray]], kamil
) -> dict:
"""Run the live processor and measure how flat each pass-through trace is.
Builds the same :class:`KamilAdcSweepProcessor` the runtime uses, processes
every sweep onto the fixed grid, then measures each trace's deviation from a
robust (median) reference trace. A branch-wrapped sweep lands on a frequency
axis offset by ~one 2*pi turn, so after resampling it beats against the
reference — showing up as a large phase-residual std. This is the observable
symptom ("constant offset + oscillations") tied back to the raw-phase analysis.
"""
processor = KamilAdcSweepProcessor(KamilAdcProcessingParams.from_kamil_model(kamil))
traces = [processor.process(main, reference) for _steps, main, reference in sweeps]
passed = np.array([trace is not None for trace in traces])
n = len(traces)
phase_residual_std = np.full(n, np.nan)
amp_residual_std = np.full(n, np.nan)
stack = np.array([trace for trace in traces if trace is not None], dtype=np.complex128)
if stack.shape[0] >= 3:
# Robust per-grid-point reference: median of real/imag over passing sweeps.
reference_trace = np.median(stack.real, axis=0) + 1j * np.median(stack.imag, axis=0)
safe_reference = np.where(np.abs(reference_trace) < 1e-12, 1.0 + 0j, reference_trace)
passed_index = 0
for i, trace in enumerate(traces):
if trace is None:
continue
ratio = trace.astype(np.complex128) / safe_reference
phase_residual_std[i] = float(np.std(np.angle(ratio)))
amp_residual_std[i] = float(np.std(np.abs(ratio)))
passed_index += 1
return {
"passthrough_passed_fraction": float(np.mean(passed)),
"passthrough_phase_residual_std": phase_residual_std,
"passthrough_amp_residual_std": amp_residual_std,
}
def _report_diagnosis(result: dict) -> None:
"""Print the detailed reference-phase / frequency-axis diagnosis."""
n = result["sweeps"]
print("\n" + "=" * 72)
print(f"Kamil ADC reference-phase DIAGNOSIS over {n} sweeps")
print("=" * 72)
print(
f"calibration slope = {result['slope_hz_per_rad'] / 1e6:.4f} MHz/rad\n"
f"one 2*pi branch wrap = {result['axis_2pi_shift_hz'] / 1e6:.2f} MHz axis shift"
)
print("-" * 72)
print(
f"anchor phase angle(ref[0]): {_summarize(result['anchor_phase'])} rad\n"
f"phase span (stop - start) : {_summarize(result['span_rad'])} rad "
f"(std {np.std(result['span_rad']):.4f} -> fixed-delay {'OK' if np.std(result['span_rad']) < 1.0 else 'SUSPECT'})\n"
f"points per sweep : {_summarize(result['n_points'].astype(float))}"
)
print("-" * 72)
print(
f"branch wraps detected : {result['n_branch_wraps']} "
f"(at sweep indices {result['wrap_indices'].tolist()})"
)
print(f"branch turns range : {_summarize(result['branch_turns'].astype(float))} turns")
print(
f"max sweep-to-sweep step of UNWRAPPED anchor = "
f"{result['max_unwrapped_anchor_step_rad']:.4f} rad "
f"({'< pi: cross-sweep unwrap is unambiguous' if result['max_unwrapped_anchor_step_rad'] < np.pi else '>= pi: AMBIGUOUS, fix may misstep'})"
)
print("-" * 72)
print("reconstructed band edges (GHz), config calibration:")
print(f" raw start {_summarize(result['f_lo'] / 1e9)} stop {_summarize(result['f_hi'] / 1e9)}")
print(f" corrected start {_summarize(result['f_lo_corr'] / 1e9)} stop {_summarize(result['f_hi_corr'] / 1e9)}")
print(
f"band [{result['band_start_hz'] / 1e9:.3f}, {result['band_stop_hz'] / 1e9:.3f}] GHz covered: "
f"raw {result['coverage_raw'] * 100:.1f}% -> corrected {result['coverage_corr'] * 100:.1f}%"
)
print("-" * 72)
phase_res = result["passthrough_phase_residual_std"]
finite = phase_res[np.isfinite(phase_res)]
print(f"pass-through: {result['passthrough_passed_fraction'] * 100:.1f}% of sweeps covered the band")
if finite.size:
print(f" phase-residual std vs median trace: {_summarize(finite)} rad")
# Sweeps whose pass-through trace is far from flat (the visible glitches).
bad = np.nonzero(phase_res > (np.median(finite) + 5.0 * (np.std(finite) + 1e-9)))[0]
print(f" distorted sweeps (>5 sigma residual): {bad.tolist()}")
print(f" do they coincide with branch wraps? wraps at {result['wrap_indices'].tolist()}")
print("-" * 72)
print(
"HOW TO READ THIS:\n"
" * phase span std small -> fixed reference delay confirmed (model holds).\n"
" * 'branch wraps' are the suspected glitches: angle(ref[0]) crossing +/-pi.\n"
" * If the UNWRAPPED-anchor max step stays < pi, the sweep-to-sweep float is\n"
" slow enough that a CROSS-SWEEP unwrap can separate the real drift from the\n"
" spurious 2*pi wrap. That is the candidate fix.\n"
" * Expect: 'corrected' band edges move CONTINUOUSLY (no ~157 MHz steps) while\n"
" 'raw' jumps by one wrap at each detected wrap index, and the distorted\n"
" pass-through sweeps line up with those wrap indices."
)
print("=" * 72 + "\n")
def _dump_diagnosis(
path: Path,
result: dict,
sweeps: list[tuple[np.ndarray, np.ndarray, np.ndarray]],
) -> None:
"""Save per-sweep metrics and the full raw sweeps to an ``.npz`` for offline study."""
arrays = {key: value for key, value in result.items() if isinstance(value, np.ndarray)}
arrays["raw_steps"] = np.array([steps for steps, _m, _r in sweeps], dtype=object)
arrays["raw_main"] = np.array([main for _s, main, _r in sweeps], dtype=object)
arrays["raw_reference"] = np.array([reference for _s, _m, reference in sweeps], dtype=object)
np.savez(path, **arrays)
print(f"Saved diagnosis data ({len(sweeps)} sweeps) to {path}")
def _plot_diagnosis(path: Path, result: dict) -> None:
"""Render the key diagnostic plots to a PNG (no-op if matplotlib is missing)."""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except Exception as exc: # noqa: BLE001 — plotting is optional
logger.warning("Plotting skipped (matplotlib unavailable: %s)", exc)
return
sweep_index = np.arange(result["sweeps"])
fig, axes = plt.subplots(3, 1, figsize=(11, 12), sharex=True)
axes[0].plot(sweep_index, result["anchor_phase"], ".", label="angle(ref[0]) raw (wrapped)")
axes[0].plot(sweep_index, result["anchor_unwrapped"], "-", label="unwrapped across sweeps (fix)")
for wrap in result["wrap_indices"]:
axes[0].axvline(wrap, color="r", alpha=0.3)
axes[0].set_ylabel("anchor phase [rad]")
axes[0].legend(loc="best")
axes[0].set_title("Reference anchor phase: raw wraps vs cross-sweep unwrap")
axes[1].plot(sweep_index, result["f_lo"] / 1e9, ".", label="start raw")
axes[1].plot(sweep_index, result["f_lo_corr"] / 1e9, "-", label="start corrected")
axes[1].plot(sweep_index, result["f_hi"] / 1e9, ".", label="stop raw")
axes[1].plot(sweep_index, result["f_hi_corr"] / 1e9, "-", label="stop corrected")
axes[1].axhline(result["band_start_hz"] / 1e9, color="k", ls="--", alpha=0.5)
axes[1].axhline(result["band_stop_hz"] / 1e9, color="k", ls="--", alpha=0.5)
axes[1].set_ylabel("reconstructed band edge [GHz]")
axes[1].legend(loc="best")
axes[2].plot(sweep_index, result["passthrough_phase_residual_std"], ".", label="phase residual std")
for wrap in result["wrap_indices"]:
axes[2].axvline(wrap, color="r", alpha=0.3, label="_branch wrap")
axes[2].set_ylabel("pass-through residual [rad]")
axes[2].set_xlabel("sweep index")
axes[2].legend(loc="best")
fig.tight_layout()
fig.savefig(path, dpi=110)
plt.close(fig)
print(f"Saved diagnosis plot to {path}")
def _apply(config_path: Path, result: dict) -> None:
"""Write the migrated collector args + calibrated anchors + points to the config."""
payload = json.loads(config_path.read_text(encoding="utf-8"))
kamil = payload.setdefault("radar", {}).setdefault("kamil_adc", {})
kamil["executable_path"] = COLLECTOR_PATH
kamil["args"] = list(DO8_FREQ_REF_ARGS)
kamil["phase_calibration"] = {
"phase0_rad": result["phase0_rad"],
"freq0_hz": result["freq0_hz"],
"phase1_rad": result["phase1_rad"],
"freq1_hz": result["freq1_hz"],
}
kamil["band"] = {
"start_hz": result["band_start_hz"],
"stop_hz": result["band_stop_hz"],
"points": result["recommended_points"],
}
config_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(f"Applied calibration to {config_path}")
def main() -> int:
parser = argparse.ArgumentParser(description="Calibrate Kamil ADC reference phase -> frequency")
parser.add_argument("--config", required=True, type=Path, help="Path to the kamil_adc run config")
parser.add_argument("--sweeps", type=int, default=200, help="Sweeps to average (default 200)")
parser.add_argument("--warmup", type=int, default=10, help="Sweeps to discard first (default 10)")
parser.add_argument("--apply", action="store_true", help="Write the calibration back to --config")
parser.add_argument("--no-laser", action="store_true", help="Skip laser setup (already running)")
parser.add_argument(
"--no-release",
action="store_true",
help="Do not stop radar.service / kill orphaned collectors before opening",
)
parser.add_argument(
"--diagnose",
action="store_true",
help="Investigate reference-phase branch wraps instead of calibrating",
)
parser.add_argument(
"--dump", type=Path, default=None, help="(--diagnose) save raw sweeps + metrics to this .npz"
)
parser.add_argument(
"--plot", type=Path, default=None, help="(--diagnose) render diagnostic plots to this .png"
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
config = RunConfigModel.load_from_path(args.config)
if not config.is_kamil_adc:
raise SystemExit("Config is not a kamil_adc profile")
# Force the reference-producing collector regardless of the on-disk config.
config.radar.kamil_adc.executable_path = COLLECTOR_PATH
config.radar.kamil_adc.args = list(DO8_FREQ_REF_ARGS)
# Free the device the headless daemon / an orphaned collector may be holding,
# so opening our own collector does not fail with E-502 device-busy.
if not args.no_release:
_release_radar_hardware()
if not args.no_laser:
logger.info("Applying laser control...")
apply_kamil_adc_laser_control(config)
service = KamilAdcService(config)
logger.info("Opening collector: %s", " ".join(service.command))
_open_with_retry(service)
if args.diagnose:
try:
sweeps = _capture_raw_sweeps(service, warmup=args.warmup, sweeps=args.sweeps)
finally:
service.close()
if len(sweeps) < max(2, args.sweeps // 2):
raise SystemExit(f"Only {len(sweeps)} usable sweeps captured; check the reference signal")
result = _diagnose(sweeps, config)
_report_diagnosis(result)
if args.dump is not None:
_dump_diagnosis(args.dump, result, sweeps)
if args.plot is not None:
_plot_diagnosis(args.plot, result)
return 0
try:
phases = _capture_reference_phases(service, warmup=args.warmup, sweeps=args.sweeps)
finally:
service.close()
if len(phases) < max(2, args.sweeps // 2):
raise SystemExit(f"Only {len(phases)} usable sweeps captured; check the reference signal")
result = _analyze(phases, config)
_report(result)
if args.apply:
_apply(args.config, result)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+20 -134
View File
@@ -2,36 +2,21 @@
from __future__ import annotations
import argparse
from contextlib import ExitStack, suppress
from contextlib import ExitStack
import logging
from pathlib import Path
import signal
import threading
import time
import numpy as np
from python_app.hardware_full.kamil_adc import KamilAdcService
from python_app.hardware_full.switch_service import SwitchService
from python_app.hardware_full.switch_service import SwitchService, select_and_settle
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.shm import ShmRingWriter
from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection
from python_app.models.run_config_schema import RunConfigModel
from python_app.scripts.producer_common import RawPublisher, close_devices, load_producer_config, retry_open
logger = logging.getLogger(__name__)
# The producer waits for the Kamil ADC collector forever: a device/collector that
# is absent at boot or disappears mid-run must never kill the producer, only make
# it wait. Reconnect uses a capped exponential backoff so a long absence does not
# busy-spin, and every wait is interruptible by SIGINT/SIGTERM (stop_requested) for
# a prompt clean exit. Mirrors matrix_raw_producer._open_radar_with_retry.
_OPEN_RETRY_MIN_S = 1.0
_OPEN_RETRY_MAX_S = 10.0
# Throttle open-failure logging during a long wait so a permanently absent device
# does not flood the process log: log the first failure, then every Nth attempt.
_OPEN_RETRY_LOG_EVERY = 30
def _open_radar_with_retry(
config: RunConfigModel,
@@ -40,33 +25,10 @@ def _open_radar_with_retry(
output_switch: SwitchService | None,
stop_requested: threading.Event,
) -> bool:
"""Open+configure the radar and both switches, retrying forever until stop.
"""Reopen the ADC and Python-owned switches, cleaning partial opens before retry."""
close_devices(input_switch, output_switch, radar)
Used for both the initial open and every in-loop reconnect, so a collector
that is absent at boot or disappears mid-run never kills the producer — it just
waits. Any partially-opened components are closed before each retry so a
relaunched collector starts clean. Returns ``True`` once everything is open, or
``False`` if a stop was requested before the device became available. Backoff is
capped and every wait is interruptible by SIGTERM.
``input_switch``/``output_switch`` are ``None`` in switch-aware mode, where the
collector owns the GPIO lines and the producer must not open them.
"""
# Tear down any prior open first: open()/switch.open() are idempotent no-ops
# while still "open", so a mid-run reconnect must close them to force a fresh
# collector relaunch and TTY re-attach.
if input_switch is not None:
with suppress(Exception):
input_switch.close()
if output_switch is not None:
with suppress(Exception):
output_switch.close()
with suppress(Exception):
radar.close()
attempt = 0
delay = _OPEN_RETRY_MIN_S
while not stop_requested.is_set():
def attempt() -> KamilAdcService:
try:
radar.open(stop_event=stop_requested)
radar.configure(config.radar.sweep)
@@ -74,82 +36,28 @@ def _open_radar_with_retry(
output_switch.open()
if input_switch is not None:
input_switch.open()
except Exception as exc: # noqa: BLE001 — waiting for the device is the point
# Drop any partial open (collector process, TTY reader, switches)
# before the next attempt so the relaunch starts from a clean state.
if input_switch is not None:
with suppress(Exception):
input_switch.close()
if output_switch is not None:
with suppress(Exception):
output_switch.close()
with suppress(Exception):
radar.close()
attempt += 1
if attempt == 1 or attempt % _OPEN_RETRY_LOG_EVERY == 0:
logger.warning(
"Kamil ADC not available (attempt %d); retrying every up to %.0fs "
"until the device is present: %s",
attempt,
_OPEN_RETRY_MAX_S,
exc,
)
if stop_requested.wait(delay):
return False
delay = min(delay * 2.0, _OPEN_RETRY_MAX_S)
continue
except Exception:
close_devices(input_switch, output_switch, radar)
raise
return radar
if attempt > 0:
logger.info("Kamil ADC opened after %d attempt(s).", attempt + 1)
return True
logger.debug("Stop requested before the Kamil ADC became available.")
return False
return retry_open(attempt, stop_requested, "Kamil ADC") is not None
def main() -> int:
"""Run producer process until config or signal requests exit."""
parser = argparse.ArgumentParser(description="Publish Kamil ADC raw sweeps to SHM rings")
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
stop_requested = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_requested.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
config = RunConfigModel.load_from_path(args.config)
config_path, config, stop_requested = load_producer_config("Publish Kamil ADC raw sweeps to SHM rings")
if not config.is_kamil_adc:
raise RuntimeError("kamil_adc_raw_producer requires radar.model='kamil_adc'")
config.ensure_combos()
logger.info(
"Kamil ADC raw producer starting: config=%s, combos=%d, continuous=%s",
args.config, len(config.combos), config.runtime.continuous,
config_path, len(config.combos), config.runtime.continuous,
)
radar = input_switch = output_switch = None
cleanup = ExitStack()
try:
raw_writer = ShmRingWriter(
config.rings.raw.name,
config.rings.raw.capacity,
config.rings.raw.slot_size_bytes,
)
cleanup.callback(raw_writer.close)
raw_tap_writer = ShmRingWriter(
config.rings.raw_tap.name,
config.rings.raw_tap.capacity,
config.rings.raw_tap.slot_size_bytes,
)
cleanup.callback(raw_tap_writer.close)
logger.debug(
"Opened SHM ring writers: raw=%s, raw_tap=%s",
config.rings.raw.name, config.rings.raw_tap.name,
)
publisher = RawPublisher(config.rings, cleanup)
# Switch-aware mode: with native switches the collector drives the RF switches
# itself, in the hardware gap between sweeps, and tags each sweep with its combo
# — no sweep lost at a switch boundary. The producer then only reads tagged
@@ -162,7 +70,7 @@ def main() -> int:
)
radar = KamilAdcService(
config,
switch_config_path=str(args.config) if collector_driven else None,
switch_config_path=str(config_path) if collector_driven else None,
)
input_switch = None if collector_driven else SwitchService.from_model(config.input_switch)
output_switch = None if collector_driven else SwitchService.from_model(config.output_switch)
@@ -175,7 +83,6 @@ def main() -> int:
return 0 # asked to stop before a device became available
collection_id = 1
publish_failures = 0
while not stop_requested.is_set():
collection_start = time.monotonic()
capture_start_ns = time.monotonic_ns()
@@ -193,9 +100,8 @@ def main() -> int:
else:
if input_switch is None or output_switch is None:
raise RuntimeError("Producer-driven switches are not initialized")
output_switch.switch_to(combo.output)
input_switch.switch_to(combo.input)
if config.runtime.settling_ms > 0 and stop_requested.wait(config.runtime.settling_ms / 1000.0):
if not select_and_settle(output_switch, input_switch, combo.output, combo.input,
config.runtime.settling_ms, wait=stop_requested.wait):
break
# Stamped after switching and settling so the window covers
# the sweep alone, not the dead time before it.
@@ -205,7 +111,6 @@ def main() -> int:
TraceData(
combo=ComboKey(input=combo.input, output=combo.output),
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
capture_start_ns=sweep_start_ns,
capture_end_ns=time.monotonic_ns(),
@@ -244,18 +149,7 @@ def main() -> int:
capture_start_ns=capture_start_ns,
capture_end_ns=time.monotonic_ns(),
)
payload = serialize_trace_collection(collection, RAW_MAGIC)
if raw_writer.push(payload):
raw_tap_writer.push(payload) # best-effort GUI tap; never fatal
else:
# Oversized payload vs the ring slot is a persistent config error, not
# a device fault: log (throttled) and skip rather than killing the producer.
publish_failures += 1
if publish_failures == 1 or publish_failures % 100 == 0:
logger.error(
"Raw payload %d B exceeds ring slot %d B; dropping collection %d (drops=%d)",
len(payload), raw_writer.slot_size_bytes, collection_id, publish_failures,
)
publisher.publish(collection)
if not config.runtime.continuous:
break
@@ -264,15 +158,7 @@ def main() -> int:
logger.info("Kamil ADC collection %d acquired in %.3f s", collection_id, collection_duration_s)
collection_id += 1
finally:
if output_switch is not None:
with suppress(Exception):
output_switch.close()
if input_switch is not None:
with suppress(Exception):
input_switch.close()
if radar is not None:
with suppress(Exception):
radar.close()
close_devices(output_switch, input_switch, radar)
cleanup.close()
logger.info("Kamil ADC raw producer stopped")
+1 -1
View File
@@ -36,7 +36,7 @@ from python_app.hardware_full.laser_control.monitoring import (
ReadingWriter,
resolve_period_s,
)
from python_app.models.run_config_model import RunConfigModel
from python_app.models.run_config_schema import RunConfigModel
logger = logging.getLogger("laser_temp_monitor")
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
"""Standalone LibreVNA generator sweep driven by a local Python config."""
from __future__ import annotations
import logging
import sys
from pathlib import Path
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.generator_sweep.config import resolve_generator_sweep_config
from python_app.scripts.librevna_generator_sweep_config import CONFIG
def main() -> int:
"""Load local generator config and execute the sweep."""
resolved = resolve_generator_sweep_config(CONFIG)
logging.basicConfig(
level=resolved.log_level,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
from python_app.generator_sweep.runner import GeneratorSweepRunner
runner = GeneratorSweepRunner(CONFIG)
runner.run()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,31 +0,0 @@
"""Local editable config for ``librevna_generator_sweep.py``."""
from __future__ import annotations
from python_app.generator_sweep.config import GeneratorSweepConfig
CONFIG = GeneratorSweepConfig(
log_level="INFO", # DEBUG, INFO, WARNING, ERROR, or CRITICAL.
serial=None, # None or exact serial string, for example "206930A15532".
strict_protocol_version=14, # Exact value for the connected device: 14.
start_hz=100_000_000.0, # Script constraint: > 0. Device reports frequency range 0.0 .. 6_000_000_000.0 Hz.
stop_hz=6_000_000_000.0, # Script constraint: >= start_hz. Device reports frequency range 0.0 .. 6_000_000_000.0 Hz.
step_hz=0, # > 0 to use step mode, or 0 to disable and use points mode.
points=501, # 0 to disable, or >= 2 to use points mode; if start_hz == stop_hz then only 1 is allowed.
hold_time_ms=50.0, # >= 0 ms. Float values like 0.5 are allowed.
loop=True, # True or False.
port=1, # Only 1 or 2.
power_dbm=-10.0, # Device reports source power range -40.0 .. 0.0 dBm.
amplitude_correction=False, # True = apply source amplitude calibration, False = use raw generator level.
settle_timeout_ms=1_000, # > 0 ms.
status_poll_ms=10.0, # > 0 ms. Float values like 0.2 are allowed.
post_lock_delay_us=0, # >= 0 us.
gpio_chip="/dev/gpiochip0", # Non-empty Linux GPIO chip path.
sweep_start_pin=5, # BCM GPIO, >= 0, must differ from curr_step_pin and pwm_pin.
curr_step_pin=6, # BCM GPIO, >= 0, must differ from sweep_start_pin and pwm_pin.
curr_step_initial_level=0, # Only 0 or 1.
pwm_pin=12, # Only 12, 13, 18, or 19 on Raspberry Pi 5.
pwm_frequency_hz=2_000_000, # > 0 Hz.
pwm_duty_cycle=0.5, # Range: 0.0 < value <= 1.0.
)
-172
View File
@@ -1,172 +0,0 @@
"""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,
*,
s11_scale: float,
s11_phase_offset: 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)
reflected_phase = (phase * 0.6) + s11_phase_offset
s11 = (s11_scale * (np.cos(reflected_phase) + 1j * np.sin(reflected_phase))).astype(np.complex64)
s21 = value_scale * (np.cos(phase) + 1j * np.sin(phase)).astype(np.complex64)
traces.append(
TraceData(
combo=ComboKey(input=combo.input, output=combo.output),
frequency_hz=frequency_hz,
s11=s11,
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,
extra_serials=config.radar_key_extra_parts() or None,
)
s21_calibration_set = build_synthetic_collection(config, value_scale=1.0, s11_scale=0.15, s11_phase_offset=0.4)
s21_reference_set = build_synthetic_collection(config, value_scale=0.3, s11_scale=0.08, s11_phase_offset=0.9)
s11_open_set = build_synthetic_collection(config, value_scale=0.85, s11_scale=0.95, s11_phase_offset=0.1)
s11_short_set = build_synthetic_collection(config, value_scale=0.85, s11_scale=0.95, s11_phase_offset=3.2)
s11_load_set = build_synthetic_collection(config, value_scale=0.85, s11_scale=0.05, s11_phase_offset=1.4)
s11_reference_set = build_synthetic_collection(config, value_scale=0.3, s11_scale=0.18, s11_phase_offset=1.1)
store.save_set("s21_calibration", radar_key, "smoke_cal", s21_calibration_set)
store.save_set("s21_reference", radar_key, "smoke_ref", s21_reference_set)
store.save_set("s11_open", radar_key, "smoke_open", s11_open_set)
store.save_set("s11_short", radar_key, "smoke_short", s11_short_set)
store.save_set("s11_load", radar_key, "smoke_load", s11_load_set)
store.save_set("s11_reference", radar_key, "smoke_s11_ref", s11_reference_set)
config.preprocess.s21.calibration.set_name = "smoke_cal"
config.preprocess.s21.reference.set_name = "smoke_ref"
config.preprocess.s11.calibration.open.set_name = "smoke_open"
config.preprocess.s11.calibration.short.set_name = "smoke_short"
config.preprocess.s11.calibration.load.set_name = "smoke_load"
config.preprocess.s11.reference.set_name = "smoke_s11_ref"
config_writer.prepare_preprocess_bundles(store, radar_key, config)
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())
+22 -114
View File
@@ -1,142 +1,64 @@
"""Raw producer for native matrix radars and the shared single/multi mock scene."""
"""Publish LibreVNA single-device, matrix, or simulated collections to SHM."""
from __future__ import annotations
import argparse
import logging
from contextlib import ExitStack, suppress
from pathlib import Path
import signal
from contextlib import ExitStack
import threading
import time
from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_acquisition_service
from python_app.logging_setup import coerce_level
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.shm import ShmRingWriter
from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection
from python_app.hardware_full.matrix_radar_service import MatrixRadarService, create_matrix_radar_service
from python_app.models.run_config_schema import RunConfigModel
from python_app.scripts.producer_common import RawPublisher, close_devices, load_producer_config, retry_open
logger = logging.getLogger(__name__)
# The producer waits for the matrix radar forever: a device that is absent at boot
# or disappears mid-run must never kill the producer, only make it wait. Reconnect
# uses a capped exponential backoff so a long absence does not busy-spin, and every
# wait is interruptible by SIGINT/SIGTERM (stop_requested) for a prompt clean exit.
_OPEN_RETRY_MIN_S = 1.0
_OPEN_RETRY_MAX_S = 10.0
# Throttle open-failure logging during a long wait so a permanently absent device
# does not flood the process log: log the first failure, then every Nth attempt.
_OPEN_RETRY_LOG_EVERY = 30
def _open_radar_with_retry(
config: RunConfigModel,
previous: MatrixRadarService | None,
stop_requested: threading.Event,
) -> MatrixRadarService | None:
"""Open+configure the matrix radar, retrying forever until success or stop.
"""Recreate a failed LibreVNA service and wait interruptibly for its return."""
close_devices(previous)
Used for both the initial open and every in-loop reconnect, so a device that is
absent at boot or disappears mid-run never kills the producer — it just waits.
Returns the opened service, or ``None`` if a stop was requested before any device
became available. Backoff is capped and every wait is interruptible by SIGTERM.
"""
if previous is not None:
with suppress(Exception):
previous.close()
attempt = 0
delay = _OPEN_RETRY_MIN_S
while not stop_requested.is_set():
radar = create_acquisition_service(config)
def attempt() -> MatrixRadarService:
radar = create_matrix_radar_service(config)
try:
radar.open()
radar.configure(config.radar.sweep)
except Exception as exc: # noqa: BLE001 — waiting for the device is the point
with suppress(Exception):
radar.close() # drop any partial open before the next attempt
attempt += 1
if attempt == 1 or attempt % _OPEN_RETRY_LOG_EVERY == 0:
logger.warning(
"Matrix radar not available (attempt %d); retrying every up to %.0fs "
"until the device is present: %s",
attempt,
_OPEN_RETRY_MAX_S,
exc,
)
if stop_requested.wait(delay):
with suppress(Exception):
radar.close()
return None
delay = min(delay * 2.0, _OPEN_RETRY_MAX_S)
continue
if attempt > 0:
logger.info("Matrix radar opened after %d attempt(s).", attempt + 1)
except Exception:
close_devices(radar)
raise
return radar
logger.debug("Stop requested before any matrix radar became available.")
return None
return retry_open(attempt, stop_requested, "LibreVNA radar")
def main() -> int:
"""Run producer process until config or signal requests exit."""
parser = argparse.ArgumentParser(description="Publish matrix-radar raw sweeps to SHM rings")
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
stop_requested = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_requested.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
config = RunConfigModel.load_from_path(args.config)
config.apply_device_model_constraints()
# Honor the configured verbosity so the DEBUG switch/sweep timing trace can be
# turned on from the profile instead of requiring a code edit. basicConfig above
# only installed the handler; the package logger owns the level.
logging.getLogger("python_app").setLevel(coerce_level(config.logging.level))
config_path, config, stop_requested = load_producer_config("Publish LibreVNA raw sweeps to SHM rings")
logger.info(
"Matrix radar raw producer starting: config=%s, model=%s, continuous=%s",
args.config, config.radar.model, config.runtime.continuous,
"LibreVNA radar raw producer starting: config=%s, model=%s, continuous=%s",
config_path, config.radar.model, config.runtime.continuous,
)
radar: MatrixRadarService | None = None
cleanup = ExitStack()
try:
raw_writer = ShmRingWriter(
config.rings.raw.name,
config.rings.raw.capacity,
config.rings.raw.slot_size_bytes,
)
cleanup.callback(raw_writer.close)
raw_tap_writer = ShmRingWriter(
config.rings.raw_tap.name,
config.rings.raw_tap.capacity,
config.rings.raw_tap.slot_size_bytes,
)
cleanup.callback(raw_tap_writer.close)
logger.debug(
"Opened SHM ring writers: raw=%s, raw_tap=%s",
config.rings.raw.name, config.rings.raw_tap.name,
)
publisher = RawPublisher(config.rings, cleanup)
radar = _open_radar_with_retry(config, previous=None, stop_requested=stop_requested)
if radar is None:
return 0 # asked to stop before a device became available
collection_id = 1
publish_failures = 0
while not stop_requested.is_set():
collection_start = time.monotonic()
try:
collection = radar.acquire_collection(collection_id=collection_id)
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
logger.warning(
"Matrix radar acquisition failed; reconnecting and waiting for the device: %s",
"LibreVNA radar acquisition failed; reconnecting and waiting for the device: %s",
exc,
exc_info=True,
)
@@ -145,36 +67,22 @@ def main() -> int:
break # stop requested while waiting to reconnect
continue
payload = serialize_trace_collection(collection, RAW_MAGIC)
if raw_writer.push(payload):
raw_tap_writer.push(payload) # best-effort GUI tap; never fatal
else:
# An oversized payload vs the ring slot is a persistent config error,
# not a device fault: log it (throttled) and skip the collection rather
# than letting a RuntimeError escape the loop and kill the producer.
publish_failures += 1
if publish_failures == 1 or publish_failures % 100 == 0:
logger.error(
"Raw payload %d B exceeds ring slot %d B; dropping collection %d (drops=%d)",
len(payload), raw_writer.slot_size_bytes, collection_id, publish_failures,
)
publisher.publish(collection)
if not config.runtime.continuous:
break
collection_duration_s = time.monotonic() - collection_start
if collection_id == 1 or collection_id % 20 == 0 or collection_duration_s > 2.0:
logger.info(
"matrix radar collection %d acquired in %.3f s",
"LibreVNA radar collection %d acquired in %.3f s",
collection_id,
collection_duration_s,
)
collection_id += 1
finally:
if radar is not None:
with suppress(Exception):
radar.close()
close_devices(radar)
cleanup.close()
logger.info("matrix radar raw producer stopped")
logger.info("LibreVNA radar raw producer stopped")
return 0
@@ -41,7 +41,7 @@ from python_app.hardware_full.librevna_multi_device_driver.models import (
)
from python_app.hardware_full.matrix_radar_service import create_matrix_radar_service
from python_app.models.dataset_model import SweepCollection
from python_app.models.run_config_model import RunConfigModel
from python_app.models.run_config_schema import RunConfigModel
logger = logging.getLogger(__name__)
@@ -117,7 +117,7 @@ def main() -> int:
config = RunConfigModel.load_from_path(args.config)
config.apply_device_model_constraints()
if not config.is_matrix_radar:
if not config.is_multi_device:
raise SystemExit("нужен матричный radar.model (librevna_multi)")
substages = config.matrix_stimulus_switch_positions
outputs = config.output_switch.positions
+105
View File
@@ -0,0 +1,105 @@
"""Shared CLI, interruptible device retry, and raw-ring publication."""
from __future__ import annotations
import argparse
from collections.abc import Callable
from contextlib import ExitStack, suppress
import logging
from pathlib import Path
import signal
import threading
from typing import Protocol, TypeVar
from python_app.logging_setup import coerce_level
from python_app.models.dataset_model import SweepCollection
from python_app.models.run_config_schema import RingEndpointModel, RingsModel, RunConfigModel
from python_app.models.run_config_validation import validate_acquisition_config
from python_app.orchestration.shm.decoder import RAW_MAGIC
from python_app.orchestration.shm.ring_writer import ShmRingWriter
from python_app.storage.npz.serialize import serialize_trace_collection
logger = logging.getLogger(__name__)
T = TypeVar("T")
def load_producer_config(description: str) -> tuple[Path, RunConfigModel, threading.Event]:
parser = argparse.ArgumentParser(description=description)
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
path = parser.parse_args().config
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
stop_requested = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_requested.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
config = RunConfigModel.load_from_path(path)
config.ensure_combos()
validate_acquisition_config(config)
logging.getLogger("python_app").setLevel(coerce_level(config.logging.level))
return path, config, stop_requested
class _Closable(Protocol):
def close(self) -> None: ...
def close_devices(*devices: _Closable | None) -> None:
"""Attempt every hardware cleanup even if one disconnected device raises."""
for device in devices:
if device is not None:
with suppress(Exception):
device.close()
def retry_open(
attempt: Callable[[], T], stop_requested: threading.Event, device_name: str,
) -> T | None:
"""Retry an attempt that cleans up on failure, with capped, interruptible backoff."""
failures = 0
delay = 1.0
while not stop_requested.is_set():
try:
device = attempt()
except Exception as exc:
failures += 1
if failures == 1 or failures % 30 == 0:
logger.warning("%s unavailable (attempt %d); retrying until present: %s", device_name, failures, exc)
if stop_requested.wait(delay):
break
delay = min(delay * 2, 10.0)
else:
if failures:
logger.info("%s opened after %d attempt(s)", device_name, failures + 1)
return device
return None
class RawPublisher:
"""Publish one validated frame to raw and, when enabled, its independent GUI tap."""
def __init__(self, rings: RingsModel, cleanup: ExitStack) -> None:
def open_writer(endpoint: RingEndpointModel) -> ShmRingWriter:
writer = ShmRingWriter(endpoint.name, endpoint.capacity, endpoint.slot_size_bytes)
cleanup.callback(writer.close)
return writer
self._raw = open_writer(rings.raw)
self._tap = open_writer(rings.raw_tap) if rings.raw_tap.name else None
self._dropped = 0
def publish(self, collection: SweepCollection) -> bool:
payload = serialize_trace_collection(collection, RAW_MAGIC, require_capture_window=True)
if not self._raw.push(payload):
self._dropped += 1
if self._dropped == 1 or self._dropped % 100 == 0:
logger.error(
"Raw payload %d B exceeds ring slot %d B; dropping collection %d (drops=%d)",
len(payload), self._raw.slot_size_bytes, collection.collection_id, self._dropped,
)
return False
if self._tap is not None:
self._tap.push(payload) # independent latest-wins tap; never backpressure raw
return True
+19 -46
View File
@@ -19,24 +19,20 @@ never see time jump backwards. ``collection_id`` increases forever.
from __future__ import annotations
import argparse
from contextlib import ExitStack
import json
import logging
from pathlib import Path
import re
import signal
import statistics
import threading
import time
import numpy as np
from python_app.logging_setup import coerce_level
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import RunConfigModel
from python_app.orchestration.shm import ShmRingWriter
from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collection
from python_app.orchestration.shm.decoder import RAW_MAGIC
from python_app.scripts.producer_common import RawPublisher, load_producer_config
from python_app.storage.npz.serialize import serialize_trace_collection
logger = logging.getLogger(__name__)
@@ -105,10 +101,8 @@ def _load_dataset(dataset_dir: Path, combos: list[ComboKey], expected_points: in
)
s21 = np.load(s21_path, allow_pickle=False).astype(np.complex64, copy=False)
freq = np.load(freq_path, allow_pickle=False).astype(np.float32, copy=False)
s11_path = folder / f"i{combo.input}_o{combo.output}_s11.npy"
s11 = np.load(s11_path, allow_pickle=False).astype(np.complex64, copy=False) if s11_path.is_file() else np.zeros_like(s21)
if freq.ndim != 1 or s21.shape != freq.shape or s11.shape != freq.shape:
raise RuntimeError(f"Snapshot {name}: frequency, S11 and S21 must be equally sized 1D arrays")
if freq.ndim != 1 or s21.shape != freq.shape:
raise RuntimeError(f"Snapshot {name}: frequency and S21 must be equally sized 1D arrays")
if expected_points and s21.size != expected_points:
raise RuntimeError(
f"{s21_path}: {s21.size} points, but radar.sweep.points = "
@@ -119,7 +113,6 @@ def _load_dataset(dataset_dir: Path, combos: list[ComboKey], expected_points: in
TraceData(
combo=combo,
frequency_hz=freq,
s11=s11,
s21=s21,
capture_start_ns=window[0],
capture_end_ns=window[1],
@@ -157,7 +150,6 @@ def _build_collection(
TraceData(
combo=source.combo,
frequency_hz=source.frequency_hz,
s11=source.s11,
s21=source.s21,
capture_start_ns=capture[0],
capture_end_ns=capture[1],
@@ -174,22 +166,7 @@ def _build_collection(
def main() -> int:
"""Replay the configured dataset into the raw SHM rings until stopped."""
parser = argparse.ArgumentParser(description="Replay a recorded dataset to raw SHM rings")
parser.add_argument("--config", required=True, type=Path, help="Path to run_config.json")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
stop_requested = threading.Event()
def request_stop(_signum: int, _frame: object) -> None:
stop_requested.set()
signal.signal(signal.SIGINT, request_stop)
signal.signal(signal.SIGTERM, request_stop)
config = RunConfigModel.load_from_path(args.config)
config.apply_device_model_constraints()
logging.getLogger("python_app").setLevel(coerce_level(config.logging.level))
_, config, stop_requested = load_producer_config("Replay a recorded dataset to raw SHM rings")
replay = config.radar.replay
if not replay.dataset_dir:
@@ -211,23 +188,15 @@ def main() -> int:
cleanup = ExitStack()
try:
raw_writer = ShmRingWriter(
config.rings.raw.name,
config.rings.raw.capacity,
config.rings.raw.slot_size_bytes,
)
cleanup.callback(raw_writer.close)
raw_tap_writer = ShmRingWriter(
config.rings.raw_tap.name,
config.rings.raw_tap.capacity,
config.rings.raw_tap.slot_size_bytes,
)
cleanup.callback(raw_tap_writer.close)
publisher = RawPublisher(config.rings, cleanup)
payload_size = len(serialize_trace_collection(
_build_collection(dataset, 0, 1, time.monotonic_ns()), RAW_MAGIC
_build_collection(dataset, 0, 1, time.monotonic_ns()), RAW_MAGIC, require_capture_window=True,
))
for ring in (config.rings.raw, config.rings.raw_tap):
for name in ("raw", "raw_tap", "preprocessed", "preprocessed_tap"):
ring = getattr(config.rings, name)
if name.endswith("_tap") and not ring.name:
continue
if ring.slot_size_bytes < payload_size:
raise ValueError(f"Replay requires {payload_size} bytes per slot; ring {ring.name} has {ring.slot_size_bytes}")
@@ -257,10 +226,14 @@ def main() -> int:
break
collection = _build_collection(dataset, index, collection_id, frame_ns)
payload = serialize_trace_collection(collection, RAW_MAGIC)
if not raw_writer.push(payload):
# Acquisition identity uses real monotonic time, even at accelerated
# replay rates. Keep the recorded duration for legacy GPR and the
# virtual per-trace timeline for SAR aperture/motion reconstruction.
duration_ns = collection.capture_end_ns - collection.capture_start_ns
collection.capture_start_ns = time.monotonic_ns()
collection.capture_end_ns = collection.capture_start_ns + duration_ns
if not publisher.publish(collection):
raise RuntimeError(f"Replay raw ring rejected collection {collection_id}")
raw_tap_writer.push(payload)
if collection_id == 1 or collection_id % 50 == 0:
logger.info(
"replay collection %d published (snapshot %d/%d, cycle %d)",
-72
View File
@@ -1,72 +0,0 @@
"""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())