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
+1 -13
View File
@@ -1,13 +1 @@
"""NPZ-based dataset storage implementation and serialization helpers."""
from python_app.storage.npz.paths import radar_key_from_config
from python_app.storage.npz.serialize import PREPROC_MAGIC, RAW_MAGIC, RESULT_MAGIC
from python_app.storage.npz.store import NpzStore
__all__ = [
"NpzStore",
"PREPROC_MAGIC",
"RAW_MAGIC",
"RESULT_MAGIC",
"radar_key_from_config",
]
"""NPZ-based dataset storage."""
+1 -44
View File
@@ -2,35 +2,7 @@
from __future__ import annotations
import re
from collections.abc import Sequence
def radar_key_from_config(
model_name: str,
serial: str,
sweep_start_hz: float,
sweep_stop_hz: float,
sweep_points: int,
ifbw_hz: float,
power_dbm: float,
extra_serials: Sequence[str] | None = None,
) -> str:
"""Build deterministic key for calibration/reference set lookup."""
serial_parts = [serial or "no_serial"]
if extra_serials:
serial_parts.extend(str(value).strip() or "no_serial" for value in extra_serials)
serial_part = "_".join(sanitize_path_component(value) for value in serial_parts)
start_token = format_float_for_key(sweep_start_hz)
stop_token = format_float_for_key(sweep_stop_hz)
points_token = "adc" if model_name.strip().lower() == "kamil_adc" else str(int(sweep_points))
ifbw_token = format_float_for_key(ifbw_hz)
power_token = format_float_for_key(power_dbm)
return (
f"{model_name}_{serial_part}"
f"_st{start_token}_sp{stop_token}"
f"_p{points_token}_if{ifbw_token}_pw{power_token}"
)
from python_app.file_io import sanitize_path_component
def radar_config_filename_prefix(
@@ -73,18 +45,3 @@ def _frequency_unit(hz: float) -> tuple[str, float]:
def collection_dir_name(index: int, collection_id: int, monotonic_ns: int) -> str:
"""Build canonical collection directory name for snapshot stages."""
return f"{index:04d}_id{int(collection_id)}_ns{int(monotonic_ns)}"
def sanitize_path_component(name: str) -> str:
"""Convert arbitrary string into filesystem-safe component."""
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name.strip())
cleaned = cleaned.strip("._")
return cleaned or "snapshot"
def format_float_for_key(value: float) -> str:
"""Format float compactly for deterministic key serialization."""
integer = int(round(value))
if abs(value - float(integer)) < 1e-6:
return str(integer)
return f"{value:.6f}".rstrip("0").rstrip(".")
+17 -106
View File
@@ -1,4 +1,4 @@
"""Binary payload serialization for sweep/result collections."""
"""Binary payload serialization for raw/preprocessed collections."""
from __future__ import annotations
@@ -7,25 +7,17 @@ from numbers import Integral
import numpy as np
from python_app.models.dataset_model import ResultCollection, SweepCollection
RAW_MAGIC = 0x32574152
PREPROC_MAGIC = 0x32525050
RESULT_MAGIC = 0x324C5352 # RSL2: adds processing_duration_ns after monotonic_ns
from python_app.models.dataset_model import SweepCollection
def _write_interleaved_complex(buffer: bytearray, values: np.ndarray) -> None:
"""Append complex64 array as interleaved float32 real/imag pairs."""
interleaved = np.empty(values.size * 2, dtype="<f4")
interleaved[0::2] = values.real.astype("<f4", copy=False)
interleaved[1::2] = values.imag.astype("<f4", copy=False)
buffer.extend(interleaved.tobytes())
def validate_trace_collection(collection: SweepCollection, *, require_finite: bool = False) -> None:
def validate_trace_collection(
collection: SweepCollection, *, require_finite: bool = False, require_capture_window: bool = False,
) -> None:
"""Reject ambiguous combinations and malformed traces before saving any files."""
for field in ("collection_id", "monotonic_ns", "capture_start_ns", "capture_end_ns"):
_validate_u64(getattr(collection, field), field)
if require_capture_window and not 0 < collection.capture_start_ns <= collection.capture_end_ns:
raise ValueError("Raw collection requires a positive, ordered capture window")
if require_finite and not collection.traces:
raise ValueError("Preprocess set must contain at least one trace")
seen: set[tuple[int, int]] = set()
@@ -43,11 +35,10 @@ def validate_trace_collection(collection: SweepCollection, *, require_finite: bo
seen.add(combo)
arrays = (
np.asarray(trace.frequency_hz, dtype=np.float32),
np.asarray(trace.s11, dtype=np.complex64),
np.asarray(trace.s21, dtype=np.complex64),
)
if any(array.ndim != 1 for array in arrays) or len({array.shape for array in arrays}) != 1:
raise ValueError(f"Trace {combo} frequency, S11 and S21 must be equally sized 1D arrays")
raise ValueError(f"Trace {combo} frequency and S21 must be equally sized 1D arrays")
if require_finite and (not arrays[0].size or any(not np.isfinite(array).all() for array in arrays)):
raise ValueError(f"Preprocess trace {combo} must contain finite, nonempty arrays")
@@ -57,28 +48,28 @@ def _validate_u64(value: int, field: str) -> None:
raise ValueError(f"{field} must be an unsigned 64-bit integer")
def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes:
def serialize_trace_collection(
collection: SweepCollection, magic: int, *, require_capture_window: bool = False,
) -> bytes:
"""Serialize one raw/preprocessed trace collection into ring-compatible binary format.
The trailer is appended after the trace blocks so older readers, which stop at
the last block, still decode the traces: first the collection capture window,
then a per-trace window table (one ``(start_ns, end_ns)`` pair per trace, in
trace order). See :func:`python_app.orchestration.shm.decoder.decode_trace_collection`
Each trace stores its frequency array followed by its complex S21 array, using
12 bytes per point. The trailer contains the collection capture window and a
per-trace window table (one ``(start_ns, end_ns)`` pair per trace, in trace
order). See :func:`python_app.orchestration.shm.decoder.decode_trace_collection`
and ``read_trace_collection`` in ``common_cpp/ipc/src/shared_types.cpp``.
"""
validate_trace_collection(collection)
validate_trace_collection(collection, require_capture_window=require_capture_window)
buffer = bytearray()
buffer.extend(struct.pack("<IQQI", magic, collection.collection_id, collection.monotonic_ns, len(collection.traces)))
for trace in collection.traces:
freq = np.asarray(trace.frequency_hz, dtype=np.float32)
s11 = np.asarray(trace.s11, dtype=np.complex64)
s21 = np.asarray(trace.s21, dtype=np.complex64)
buffer.extend(struct.pack("<III", trace.combo.input, trace.combo.output, int(freq.size)))
buffer.extend(freq.astype("<f4", copy=False).tobytes())
_write_interleaved_complex(buffer, s11)
_write_interleaved_complex(buffer, s21)
buffer.extend(s21.astype("<c8", copy=False).tobytes())
buffer.extend(
struct.pack(
@@ -93,83 +84,3 @@ def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes
struct.pack("<QQ", int(trace.capture_start_ns), int(trace.capture_end_ns))
)
return bytes(buffer)
def serialize_result_collection(collection: ResultCollection) -> bytes:
"""Serialize one processed collection with result blocks/payloads."""
def serialize_payload(buffer: bytearray, payload) -> None:
"""Append one payload in ring-compatible result format."""
name_bytes = payload.processing_name.encode("utf-8")
if len(name_bytes) > 0xFFFF:
raise ValueError("processing_name is too long")
buffer.extend(struct.pack("<BH", payload.kind, len(name_bytes)))
buffer.extend(name_bytes)
if payload.kind == 1:
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
trace = np.asarray(payload.trace, dtype=np.complex64)
if freq.ndim != 1 or trace.ndim != 1 or freq.shape != trace.shape:
raise ValueError("Result trace frequency and values must be equally sized 1D arrays")
buffer.extend(struct.pack("<I", int(freq.size)))
buffer.extend(freq.astype("<f4", copy=False).tobytes())
_write_interleaved_complex(buffer, trace)
return
if payload.kind == 2:
buffer.extend(struct.pack("<f", float(payload.scalar_value)))
return
if payload.kind == 3:
image_x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
image_y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
image = np.asarray(payload.image, dtype=np.float32)
if image_x_axis.ndim != 1 or image_y_axis.ndim != 1 or image.shape != (image_y_axis.size, image_x_axis.size):
raise ValueError("Result image axis sizes must match image matrix shape")
buffer.extend(struct.pack("<II", int(image_x_axis.size), int(image_y_axis.size)))
buffer.extend(image_x_axis.astype("<f4", copy=False).tobytes())
buffer.extend(image_y_axis.astype("<f4", copy=False).tobytes())
buffer.extend(image.astype("<f4", copy=False).ravel(order="C").tobytes())
return
if payload.kind == 4:
table = np.asarray(payload.table, dtype=np.float32)
if table.ndim != 2:
raise ValueError("Result table payload must be a 2D matrix")
if table.shape[0] and not table.shape[1]:
raise ValueError("Result table with rows must have at least one column")
buffer.extend(struct.pack("<II", int(table.shape[1]), int(table.shape[0])))
buffer.extend(table.astype("<f4", copy=False).ravel(order="C").tobytes())
return
raise ValueError(f"Unsupported payload kind: {payload.kind}")
for field in ("collection_id", "monotonic_ns", "processing_duration_ns"):
_validate_u64(getattr(collection, field), field)
buffer = bytearray()
buffer.extend(
struct.pack(
"<IQQQII",
RESULT_MAGIC,
collection.collection_id,
collection.monotonic_ns,
int(collection.processing_duration_ns),
len(collection.collection_payloads),
len(collection.blocks),
)
)
for payload in collection.collection_payloads:
serialize_payload(buffer, payload)
for block in collection.blocks:
buffer.extend(struct.pack("<II", block.combo.input, block.combo.output))
buffer.extend(struct.pack("<I", len(block.payloads)))
for payload in block.payloads:
serialize_payload(buffer, payload)
return bytes(buffer)
+96 -225
View File
@@ -1,20 +1,23 @@
"""Snapshot selection and filesystem writers for runtime collection histories."""
"""Atomic NumPy collection writers and processing settings for runtime snapshots."""
from __future__ import annotations
import json
import logging
from contextlib import contextmanager
from numbers import Integral
from pathlib import Path
import shutil
import tempfile
from uuid import uuid4
from typing import Any, Iterator
import numpy as np
from python_app.file_io import atomic_write_json, rename_no_replace
from python_app.models.dataset_model import ResultCollection, ResultPayload, SweepCollection
from python_app.storage.npz.paths import collection_dir_name, sanitize_path_component
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection, validate_trace_collection
from python_app.storage.npz.paths import collection_dir_name
from python_app.file_io import sanitize_path_component
from python_app.storage.npz.serialize import validate_trace_collection
logger = logging.getLogger(__name__)
@@ -25,166 +28,34 @@ def atomic_snapshot_directory(destination: Path) -> Iterator[Path]:
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
raise FileExistsError(f"Snapshot directory already exists: {destination}")
staging = Path(tempfile.mkdtemp(prefix=f".{destination.name}.", dir=destination.parent))
staging = destination.with_name(f".{destination.name}.{uuid4().hex}")
staging.mkdir()
try:
yield staging
if destination.exists():
raise FileExistsError(f"Snapshot directory already exists: {destination}")
staging.rename(destination)
rename_no_replace(staging, destination)
finally:
if staging.exists():
shutil.rmtree(staging)
def _matching_collection(
history: list[SweepCollection], anchor: SweepCollection | ResultCollection,
used: set[int], *, same_timestamp: bool,
) -> SweepCollection | None:
"""Match preprocessing identities exactly and raw sweeps by their capture window.
Producers stamp raw/preprocessed collections at different instants. Their capture
window is copied unchanged; results keep the preprocessed timestamp. Old files
without timing fall back to the latest preceding collection with the same id.
"""
candidates = [
(index, item) for index, item in enumerate(history)
if index not in used and int(item.collection_id) == int(anchor.collection_id)
]
timestamp = int(anchor.monotonic_ns)
start_ns = int(getattr(anchor, "capture_start_ns", 0))
end_ns = int(getattr(anchor, "capture_end_ns", 0))
if same_timestamp and timestamp:
candidates = [(index, item) for index, item in candidates if int(item.monotonic_ns) == timestamp]
elif start_ns or end_ns:
candidates = [
(index, item) for index, item in candidates
if (int(item.capture_start_ns), int(item.capture_end_ns)) == (start_ns, end_ns)
]
elif timestamp:
candidates = [(index, item) for index, item in candidates if int(item.monotonic_ns) <= timestamp]
if not candidates:
return None
index, item = max(candidates, key=lambda pair: (int(pair[1].monotonic_ns), pair[0]))
used.add(index)
return item
def select_aligned_histories(
raw_history: list[SweepCollection],
preprocessed_history: list[SweepCollection],
result_history: list[ResultCollection],
last_n: int,
*,
include_unaligned: bool = False,
) -> tuple[list[SweepCollection], list[SweepCollection], list[ResultCollection], dict[str, Any]]:
"""Select aligned tails, or retain every stage independently for complete exports."""
if last_n <= 0:
raise ValueError("last_n must be > 0")
if include_unaligned:
return (
raw_history[-last_n:], preprocessed_history[-last_n:], result_history[-last_n:],
{"selection_mode": "independent_tail", "anchor_stage": "none",
"selected_collection_ids": [int(item.collection_id) for item in result_history[-last_n:]],
"aligned_key_count": 0},
)
selected_raw: list[SweepCollection] = []
selected_pre: list[SweepCollection] = []
selected_results = result_history[-last_n:]
used_raw: set[int] = set()
used_pre: set[int] = set()
if selected_results:
anchors = selected_results
anchor_stage = "results"
for result in reversed(selected_results):
pre = _matching_collection(preprocessed_history, result, used_pre, same_timestamp=True)
raw = _matching_collection(raw_history, pre or result, used_raw, same_timestamp=False)
if pre is not None:
selected_pre.append(pre)
if raw is not None:
selected_raw.append(raw)
selected_pre.reverse()
selected_raw.reverse()
elif preprocessed_history:
selected_pre = preprocessed_history[-last_n:]
anchors = selected_pre
anchor_stage = "preprocessed"
for pre in reversed(selected_pre):
raw = _matching_collection(raw_history, pre, used_raw, same_timestamp=False)
if raw is not None:
selected_raw.append(raw)
selected_raw.reverse()
else:
selected_raw = raw_history[-last_n:]
anchors = selected_raw
anchor_stage = "raw" if anchors else "none"
return selected_raw, selected_pre, selected_results, {
"selection_mode": f"{anchor_stage}_tail_with_optional_alignment",
"anchor_stage": anchor_stage,
"selected_collection_ids": [int(item.collection_id) for item in anchors],
"aligned_key_count": len(anchors),
"raw_missing_count": len(anchors) - len(selected_raw),
"preprocessed_missing_count": len(anchors) - len(selected_pre),
"result_missing_count": len(anchors) - len(selected_results),
}
def save_trace_history_binary(stage_dir: Path, history: list[SweepCollection], magic: int) -> None:
"""Write binary trace history with lightweight metadata sidecars."""
stage_dir.mkdir(parents=True, exist_ok=True)
logger.debug("Writing %d binary trace collection(s) to %s", len(history), stage_dir)
for index, collection in enumerate(history):
binary_path = stage_dir / f"{index:04d}.bin"
metadata_path = stage_dir / f"{index:04d}.json"
binary_path.write_bytes(serialize_trace_collection(collection, magic))
metadata_path.write_text(
json.dumps(
{
"collection_id": collection.collection_id,
"monotonic_ns": collection.monotonic_ns,
"capture_start_ns": int(collection.capture_start_ns),
"capture_end_ns": int(collection.capture_end_ns),
"trace_count": len(collection.traces),
# Also in the .bin trailer; repeated here so per-combo timing is
# readable without decoding the binary payload.
"traces": [
{
"input": int(trace.combo.input),
"output": int(trace.combo.output),
"capture_start_ns": int(trace.capture_start_ns),
"capture_end_ns": int(trace.capture_end_ns),
}
for trace in collection.traces
],
},
indent=2,
),
encoding="utf-8",
)
def save_result_history_binary(stage_dir: Path, history: list[ResultCollection]) -> None:
"""Write binary processed-result history with metadata sidecars."""
stage_dir.mkdir(parents=True, exist_ok=True)
logger.debug("Writing %d binary result collection(s) to %s", len(history), stage_dir)
for index, collection in enumerate(history):
binary_path = stage_dir / f"{index:04d}.bin"
metadata_path = stage_dir / f"{index:04d}.json"
binary_path.write_bytes(serialize_result_collection(collection))
metadata_path.write_text(
json.dumps(
{
"collection_id": collection.collection_id,
"monotonic_ns": collection.monotonic_ns,
"processing_duration_ns": int(collection.processing_duration_ns),
"collection_payload_count": len(collection.collection_payloads),
"block_count": len(collection.blocks),
},
indent=2,
),
encoding="utf-8",
)
def save_processing_settings(directory: Path, settings: dict[int, dict] | None) -> None:
"""Merge immutable revision settings before publishing results that refer to them."""
if not settings:
return
path = directory / "processing_settings.json"
saved = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
if not isinstance(saved, dict):
raise ValueError("Saved processing settings must be a JSON object")
updated = dict(saved)
for revision, value in settings.items():
if isinstance(revision, bool) or not isinstance(revision, Integral) or not 0 <= revision < 1 << 64 or not isinstance(value, dict):
raise ValueError("Processing settings require uint64 revision keys and JSON objects")
key = str(revision)
if key in saved and saved[key] != value:
raise ValueError(f"Processing revision {revision} already has different settings")
updated[key] = value
if updated != saved:
atomic_write_json(path, updated, durable=True)
def save_trace_history_numpy(
@@ -202,47 +73,43 @@ def save_trace_history_numpy(
logger.debug("Writing %d NumPy trace collection(s) to %s", len(history), stage_dir)
for index, collection in enumerate(history, start=index_offset):
collection_dir = stage_dir / collection_dir_name(index, collection.collection_id, collection.monotonic_ns)
collection_dir.mkdir(parents=True, exist_ok=False)
with atomic_snapshot_directory(collection_dir) as collection_dir:
traces_meta: list[dict[str, int | str]] = []
for trace in collection.traces:
tag = f"i{trace.combo.input}_o{trace.combo.output}"
freq = np.asarray(trace.frequency_hz, dtype=np.float32)
s21 = np.asarray(trace.s21, dtype=np.complex64)
np.save(collection_dir / f"{tag}_freq.npy", freq)
np.save(collection_dir / f"{tag}_s21.npy", s21)
traces_meta.append(
{
"input": int(trace.combo.input),
"output": int(trace.combo.output),
"points": int(freq.size),
# When each combo was measured, which in a switched matrix is
# spread across the collection window rather than aligned with it.
"capture_start_ns": int(trace.capture_start_ns),
"capture_end_ns": int(trace.capture_end_ns),
"freq_file": f"{tag}_freq.npy",
"s21_file": f"{tag}_s21.npy",
}
)
traces_meta: list[dict[str, int | str]] = []
for trace in collection.traces:
tag = f"i{trace.combo.input}_o{trace.combo.output}"
freq = np.asarray(trace.frequency_hz, dtype=np.float32)
s11 = np.asarray(trace.s11, dtype=np.complex64)
s21 = np.asarray(trace.s21, dtype=np.complex64)
np.save(collection_dir / f"{tag}_freq.npy", freq)
np.save(collection_dir / f"{tag}_s11.npy", s11)
np.save(collection_dir / f"{tag}_s21.npy", s21)
traces_meta.append(
{
"input": int(trace.combo.input),
"output": int(trace.combo.output),
"points": int(freq.size),
# When each combo was measured, which in a switched matrix is
# spread across the collection window rather than aligned with it.
"capture_start_ns": int(trace.capture_start_ns),
"capture_end_ns": int(trace.capture_end_ns),
"freq_file": f"{tag}_freq.npy",
"s11_file": f"{tag}_s11.npy",
"s21_file": f"{tag}_s21.npy",
}
(collection_dir / "meta.json").write_text(
json.dumps(
{
"collection_id": int(collection.collection_id),
"monotonic_ns": int(collection.monotonic_ns),
"capture_start_ns": int(collection.capture_start_ns),
"capture_end_ns": int(collection.capture_end_ns),
"trace_count": len(collection.traces),
"traces": traces_meta,
},
indent=2,
),
encoding="utf-8",
)
(collection_dir / "meta.json").write_text(
json.dumps(
{
"collection_id": int(collection.collection_id),
"monotonic_ns": int(collection.monotonic_ns),
"capture_start_ns": int(collection.capture_start_ns),
"capture_end_ns": int(collection.capture_end_ns),
"trace_count": len(collection.traces),
"traces": traces_meta,
},
indent=2,
),
encoding="utf-8",
)
def _save_result_payload(directory: Path, base_name: str, payload: ResultPayload) -> dict[str, Any]:
"""Persist every supported payload kind identically at collection and combo scope."""
@@ -293,35 +160,39 @@ def save_result_history_numpy(
logger.debug("Writing %d NumPy result collection(s) to %s", len(history), stage_dir)
for index, collection in enumerate(history, start=index_offset):
collection_dir = stage_dir / collection_dir_name(index, collection.collection_id, collection.monotonic_ns)
collection_dir.mkdir(parents=True, exist_ok=False)
collection_payload_meta = []
for payload_index, payload in enumerate(collection.collection_payloads):
name = sanitize_path_component(payload.processing_name or "processor")
base_name = f"collection_{payload_index:03d}_{name}_kind{payload.kind}"
collection_payload_meta.append(_save_result_payload(collection_dir, base_name, payload))
blocks_meta = []
for block_index, block in enumerate(collection.blocks):
block_dir = collection_dir / f"block_{block_index:03d}_i{block.combo.input}_o{block.combo.output}"
block_dir.mkdir(parents=True, exist_ok=False)
payload_meta = []
for payload_index, payload in enumerate(block.payloads):
with atomic_snapshot_directory(collection_dir) as collection_dir:
collection_payload_meta = []
for payload_index, payload in enumerate(collection.collection_payloads):
name = sanitize_path_component(payload.processing_name or "processor")
base_name = f"{payload_index:03d}_{name}_kind{payload.kind}"
payload_meta.append(_save_result_payload(block_dir, base_name, payload))
blocks_meta.append({
"input": int(block.combo.input), "output": int(block.combo.output),
"payload_count": len(block.payloads), "dir": block_dir.name, "payloads": payload_meta,
})
base_name = f"collection_{payload_index:03d}_{name}_kind{payload.kind}"
collection_payload_meta.append(_save_result_payload(collection_dir, base_name, payload))
(collection_dir / "meta.json").write_text(
json.dumps({
"collection_id": int(collection.collection_id),
"monotonic_ns": int(collection.monotonic_ns),
"processing_duration_ns": int(collection.processing_duration_ns),
"collection_payload_count": len(collection.collection_payloads),
"collection_payloads": collection_payload_meta,
"block_count": len(collection.blocks), "blocks": blocks_meta,
}, indent=2),
encoding="utf-8",
)
blocks_meta = []
for block_index, block in enumerate(collection.blocks):
block_dir = collection_dir / f"block_{block_index:03d}_i{block.combo.input}_o{block.combo.output}"
block_dir.mkdir(parents=True, exist_ok=False)
payload_meta = []
for payload_index, payload in enumerate(block.payloads):
name = sanitize_path_component(payload.processing_name or "processor")
base_name = f"{payload_index:03d}_{name}_kind{payload.kind}"
payload_meta.append(_save_result_payload(block_dir, base_name, payload))
blocks_meta.append({
"input": int(block.combo.input), "output": int(block.combo.output),
"payload_count": len(block.payloads), "dir": block_dir.name, "payloads": payload_meta,
})
(collection_dir / "meta.json").write_text(
json.dumps({
"collection_id": int(collection.collection_id),
"monotonic_ns": int(collection.monotonic_ns),
"processing_duration_ns": int(collection.processing_duration_ns),
"settings_revision": int(collection.settings_revision),
"capture_start_ns": int(collection.capture_start_ns),
"capture_end_ns": int(collection.capture_end_ns),
"speed_m_s": float(collection.speed_m_s),
"collection_payload_count": len(collection.collection_payloads),
"collection_payloads": collection_payload_meta,
"block_count": len(collection.blocks), "blocks": blocks_meta,
}, indent=2),
encoding="utf-8",
)
+109 -315
View File
@@ -1,4 +1,4 @@
"""Concrete :class:`StoreApi` implementation backed by NPZ files."""
"""Preprocess sets and datasets backed by NPZ files."""
from __future__ import annotations
@@ -9,26 +9,24 @@ import logging
import os
from pathlib import Path
import shutil
import tempfile
from typing import Any
from uuid import uuid4
from zipfile import BadZipFile
import numpy as np
from python_app.file_io import make_directory, rename_no_replace, sync_directory, temporary_file
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection, TraceData
from python_app.models.run_config_model import RunConfigModel
from python_app.storage.npz.paths import radar_key_from_config, sanitize_path_component
from python_app.storage.npz.serialize import PREPROC_MAGIC, RAW_MAGIC, serialize_trace_collection, validate_trace_collection
from python_app.file_io import sanitize_path_component
from python_app.orchestration.shm.decoder import RAW_MAGIC
from python_app.storage.npz.serialize import serialize_trace_collection, validate_trace_collection
from python_app.storage.npz.snapshot_numpy import (
save_result_history_binary,
save_result_history_numpy,
save_trace_history_binary,
save_trace_history_numpy,
select_aligned_histories,
save_processing_settings,
atomic_snapshot_directory,
)
from python_app.storage.npz.vna_history_json import build_vna_history_payload
from python_app.storage.store_api import StoreApi
logger = logging.getLogger(__name__)
_SET_METADATA_KEY = "metadata_json"
@@ -44,22 +42,11 @@ def _compose_snapshot_stem(name: str, name_prefix: str) -> str:
class SnapshotStreamWriter:
"""Append received raw/preprocessed/result collections to a snapshot dir in chunks.
A streaming alternative to :meth:`NpzStore.save_runtime_snapshot_numpy` for long
recordings: the caller flushes small chunks as measurements arrive and frees them,
so memory stays flat instead of buffering every measurement. The on-disk layout is
identical (``raw``/``preprocessed``/``results`` subtrees), and per-collection
directory indices continue across chunks via a running offset per stage.
"""
__slots__ = ("_dir", "_raw_written", "_preprocessed_written", "_result_written")
"""Publish complete collections incrementally, retaining them if a later write fails."""
def __init__(self, snapshot_dir: Path) -> None:
self._dir = snapshot_dir
self._raw_written = 0
self._preprocessed_written = 0
self._result_written = 0
self._counts = {"raw": 0, "preprocessed": 0, "results": 0}
@property
def directory(self) -> Path:
@@ -67,66 +54,50 @@ class SnapshotStreamWriter:
@property
def result_count(self) -> int:
"""Number of result collections written to disk so far."""
return self._result_written
return self._counts["results"]
def append(
self,
raw: list[SweepCollection],
preprocessed: list[SweepCollection],
results: list[ResultCollection],
*, processing_settings: dict[int, dict] | None = None,
) -> None:
"""Write a chunk without requiring equal stage lengths, continuing each index."""
# Stage a whole chunk before publishing complete collection directories. A
# failed write leaves earlier chunks intact and the next retry uses the same indices.
chunk_dir = Path(tempfile.mkdtemp(prefix=".chunk-", dir=self._dir))
published: list[Path] = []
try:
save_trace_history_numpy(chunk_dir / "raw", raw, index_offset=self._raw_written)
save_trace_history_numpy(
chunk_dir / "preprocessed", preprocessed, index_offset=self._preprocessed_written
)
save_result_history_numpy(chunk_dir / "results", results, index_offset=self._result_written)
for stage in ("raw", "preprocessed", "results"):
stage_dir = self._dir / stage
stage_dir.mkdir(exist_ok=True)
for source in sorted((chunk_dir / stage).iterdir()):
destination = stage_dir / source.name
if destination.exists():
raise FileExistsError(f"Collection directory already exists: {destination}")
source.rename(destination)
published.append(destination)
except BaseException:
for destination in published:
shutil.rmtree(destination)
raise
finally:
shutil.rmtree(chunk_dir)
self._raw_written += len(raw)
self._preprocessed_written += len(preprocessed)
self._result_written += len(results)
"""Append a chunk; after failure, only the unwritten collections may be retried."""
save_processing_settings(self._dir, processing_settings)
for stage, history in (("raw", raw), ("preprocessed", preprocessed)):
stage_dir = self._dir / stage
stage_dir.mkdir(exist_ok=True)
for collection in history:
save_trace_history_numpy(stage_dir, [collection], index_offset=self._counts[stage])
self._counts[stage] += 1
stage_dir = self._dir / "results"
stage_dir.mkdir(exist_ok=True)
for result in results:
save_result_history_numpy(stage_dir, [result], index_offset=self.result_count)
self._counts["results"] += 1
class NpzStore(StoreApi):
class NpzStore:
"""Persist preprocess sets and runtime snapshots using NumPy files."""
def __init__(self, root_dir: Path) -> None:
"""Create store rooted at `root_dir`."""
self._root_dir = root_dir
self._root_dir.mkdir(parents=True, exist_ok=True)
make_directory(self._root_dir, durable=True)
logger.debug("NpzStore rooted at %s", self._root_dir)
@staticmethod
def _vna_json_output_dir(output_root_dir: Path, output_stem: str) -> Path:
"""Return per-export directory for VNA history JSON files."""
return output_root_dir / output_stem
def save_set(self, kind: str, radar_key: str, set_name: str, collection: SweepCollection) -> None:
"""Persist named preprocess set as NPZ and metadata JSON."""
def save_set(
self, kind: str, radar_key: str, set_name: str, collection: SweepCollection,
*, metadata: dict[str, Any] | None = None,
) -> None:
"""Persist a named set with optional JSON metadata embedded in the NPZ."""
self._validate_set_name(set_name)
validate_trace_collection(collection, require_finite=True)
if metadata is not None and not isinstance(metadata, dict):
raise ValueError("Set user metadata must be a JSON object")
set_dir = self._set_dir(kind, radar_key)
set_dir.mkdir(parents=True, exist_ok=True)
make_directory(set_dir, durable=True)
npz_path = set_dir / f"{set_name}.npz"
meta_path = set_dir / f"{set_name}.json"
@@ -137,10 +108,8 @@ class NpzStore(StoreApi):
for trace in collection.traces:
suffix = f"i{trace.combo.input}_o{trace.combo.output}"
freq_key = f"freq_{suffix}"
s11_key = f"s11_{suffix}"
s21_key = f"s21_{suffix}"
payload[freq_key] = np.asarray(trace.frequency_hz, dtype=np.float32)
payload[s11_key] = np.asarray(trace.s11, dtype=np.complex64)
payload[s21_key] = np.asarray(trace.s21, dtype=np.complex64)
combo_records.append(
{
@@ -149,7 +118,6 @@ class NpzStore(StoreApi):
"capture_start_ns": int(trace.capture_start_ns),
"capture_end_ns": int(trace.capture_end_ns),
"freq_key": freq_key,
"s11_key": s11_key,
"s21_key": s21_key,
}
)
@@ -160,24 +128,30 @@ class NpzStore(StoreApi):
"capture_start_ns": int(collection.capture_start_ns),
"capture_end_ns": int(collection.capture_end_ns),
"combos": combo_records,
"metadata": {} if metadata is None else metadata,
}
# Metadata in the NPZ makes a single atomic replacement authoritative. The
# JSON remains a readable sidecar and legacy NPZ/JSON pairs remain loadable.
metadata_json = json.dumps(meta, indent=2)
metadata_json = json.dumps(meta, indent=2, allow_nan=False)
payload[_SET_METADATA_KEY] = np.asarray(metadata_json)
npz_tmp = meta_tmp = backup_path = None
archive_replaced = False
try:
npz_tmp = self._temporary_file(npz_path)
meta_tmp = self._temporary_file(meta_path)
backup_path = self._temporary_file(npz_path) if npz_path.exists() else None
npz_tmp = temporary_file(npz_path)
meta_tmp = temporary_file(meta_path)
backup_path = temporary_file(npz_path) if npz_path.exists() else None
if backup_path is not None:
backup_path.unlink()
os.link(npz_path, backup_path)
with npz_tmp.open("wb") as npz_file:
np.savez(npz_file, **payload)
meta_tmp.write_text(metadata_json, encoding="utf-8")
npz_file.flush()
os.fsync(npz_file.fileno())
with meta_tmp.open("w", encoding="utf-8") as meta_file:
meta_file.write(metadata_json)
meta_file.flush()
os.fsync(meta_file.fileno())
npz_tmp.replace(npz_path)
archive_replaced = True
meta_tmp.replace(meta_path)
@@ -193,6 +167,7 @@ class NpzStore(StoreApi):
logger.exception("Set rollback failed; previous archive retained at %s", backup_path)
backup_path = None # retain the recovery copy rather than deleting it below
raise
sync_directory(set_dir)
raise
finally:
for tmp_path in (npz_tmp, meta_tmp, backup_path):
@@ -200,6 +175,7 @@ class NpzStore(StoreApi):
continue
with suppress(OSError):
tmp_path.unlink(missing_ok=True)
sync_directory(set_dir)
logger.info(
"Saved set %s/%s/%s (%d traces) to %s", kind, radar_key, set_name, len(combo_records), npz_path
)
@@ -209,7 +185,8 @@ class NpzStore(StoreApi):
self._validate_set_name(set_name)
for collection in collections.values():
validate_trace_collection(collection, require_finite=True)
backup_dir = Path(tempfile.mkdtemp(prefix=".sets-", dir=self._root_dir))
backup_dir = self._root_dir / f".sets-{uuid4().hex}"
backup_dir.mkdir()
backups: dict[Path, Path | None] = {}
retain_backups = False
try:
@@ -236,6 +213,12 @@ class NpzStore(StoreApi):
except OSError:
rollback_failed = True
logger.exception("Failed to restore previous set file %s", destination)
for directory in {path.parent for path in backups if path.parent.exists()}:
try:
sync_directory(directory)
except OSError:
rollback_failed = True
logger.exception("Failed to sync restored set directory %s", directory)
retain_backups = rollback_failed
if retain_backups:
raise RuntimeError(
@@ -250,6 +233,12 @@ class NpzStore(StoreApi):
def load_set(self, kind: str, radar_key: str, set_name: str) -> SweepCollection:
"""Load named preprocess set from NPZ representation."""
return self.load_set_with_metadata(kind, radar_key, set_name)[0]
def load_set_with_metadata(
self, kind: str, radar_key: str, set_name: str,
) -> tuple[SweepCollection, dict[str, Any]]:
"""Load a named set and its user metadata; older sets have empty metadata."""
self._validate_set_name(set_name)
set_dir = self._set_dir(kind, radar_key)
npz_path = set_dir / f"{set_name}.npz"
@@ -269,13 +258,16 @@ class NpzStore(StoreApi):
)
if not isinstance(meta, dict):
raise ValueError("Set metadata must be a JSON object")
metadata = meta.get("metadata", {})
if not isinstance(metadata, dict):
raise ValueError("Set user metadata must be a JSON object")
json.dumps(metadata, allow_nan=False)
traces: list[TraceData] = []
for combo in meta["combos"]:
traces.append(
TraceData(
combo=ComboKey(input=combo["input"], output=combo["output"]),
frequency_hz=np.asarray(arrays[combo["freq_key"]], dtype=np.float32),
s11=np.asarray(arrays[combo["s11_key"]], dtype=np.complex64),
s21=np.asarray(arrays[combo["s21_key"]], dtype=np.complex64),
capture_start_ns=combo.get("capture_start_ns", 0),
capture_end_ns=combo.get("capture_end_ns", 0),
@@ -291,7 +283,7 @@ class NpzStore(StoreApi):
)
validate_trace_collection(collection, require_finite=True)
logger.debug("Loaded set %s (%d traces)", set_label, len(traces))
return collection
return collection, metadata
except (OSError, BadZipFile, EOFError, KeyError, TypeError, ValueError) as exc:
logger.exception("Corrupted preprocess set %s", set_label)
raise RuntimeError(f"Corrupted preprocess set {set_label}: {exc}") from exc
@@ -301,34 +293,10 @@ class NpzStore(StoreApi):
set_dir = self._set_dir(kind, radar_key)
if not set_dir.exists():
return []
if kind == "s21_object_calibration":
return sorted(path.name for path in set_dir.iterdir()
if not path.name.startswith(".") and (path / "cal_meta.json").is_file())
return sorted(path.stem for path in set_dir.glob("*.npz"))
def save_object_calibration(self, radar_key: str, set_name: str, calibration: dict[str, Any]) -> None:
"""Save an object calibration in its shared archive format without overwriting a set."""
from python_app.workflows.mimo_calibration import save_calibration
self._validate_set_name(set_name)
destination = self._set_dir("s21_object_calibration", radar_key) / set_name
if destination.exists():
raise FileExistsError(f"Object calibration already exists: {set_name}")
save_calibration(calibration, destination)
def load_object_calibration(self, radar_key: str, set_name: str) -> dict[str, Any]:
"""Load and validate a saved object calibration, retaining all estimation metadata."""
from python_app.workflows.mimo_calibration import load_calibration
self._validate_set_name(set_name)
return load_calibration(self._set_dir("s21_object_calibration", radar_key) / set_name)
def has_combo_coverage(self, kind: str, radar_key: str, set_name: str, combos: list[ComboKey]) -> bool:
"""Validate that named set covers all required switch combinations."""
if kind == "s21_object_calibration":
calibration = self.load_object_calibration(radar_key, set_name)
return all(combo.input in calibration["rx_input_pos"] and combo.output in calibration["tx_output_pos"]
for combo in combos)
collection = self.load_set(kind, radar_key, set_name)
existing = {(trace.combo.input, trace.combo.output) for trace in collection.traces}
required = {(combo.input, combo.output) for combo in combos}
@@ -339,36 +307,11 @@ class NpzStore(StoreApi):
collection = self.load_set(kind, radar_key, set_name)
return self._export_collection_bundle(collection, output_path)
def export_object_calibration_bundle(
self, radar_key: str, set_name: str, output_path: Path, config: RunConfigModel,
) -> Path:
"""Resample pair multipliers on the through-calibration grids used by the acquisition."""
from python_app.workflows.mimo_calibration import calibration_on_grid, validate_for_config
calibration = self.load_object_calibration(radar_key, set_name)
validate_for_config(calibration, config.to_dict())
tx_indices = {int(port): index for index, port in enumerate(calibration["tx_output_pos"])}
rx_indices = {int(port): index for index, port in enumerate(calibration["rx_input_pos"])}
source = self.load_set("s21_calibration", radar_key, config.preprocess.s21.calibration.set_name)
by_combo = {trace.combo: trace for trace in source.traces}
grids: dict[bytes, np.ndarray] = {}
traces: list[TraceData] = []
for combo in config.combos:
key = ComboKey(input=combo.input, output=combo.output)
trace = by_combo[key]
frequency = trace.frequency_hz
grid_key = frequency.tobytes()
if grid_key not in grids:
grids[grid_key] = calibration_on_grid(calibration, frequency)
correction = grids[grid_key][tx_indices[key.output], rx_indices[key.input]]
traces.append(TraceData(key, frequency, np.zeros(frequency.size, np.complex64), correction))
return self._export_collection_bundle(SweepCollection(0, 0, traces), output_path)
def _export_collection_bundle(self, collection: SweepCollection, output_path: Path) -> Path:
"""Publish a complete binary trace bundle using one atomic replacement."""
validate_trace_collection(collection, require_finite=True)
output_path.parent.mkdir(parents=True, exist_ok=True)
temporary = self._temporary_file(output_path)
temporary = temporary_file(output_path)
try:
temporary.write_bytes(serialize_trace_collection(collection, RAW_MAGIC))
temporary.replace(output_path)
@@ -377,28 +320,6 @@ class NpzStore(StoreApi):
logger.info("Exported %d traces to bundle %s", len(collection.traces), output_path)
return output_path
def save_runtime_snapshot(
self,
output_dir: Path,
raw_history: list[SweepCollection],
preprocessed_history: list[SweepCollection],
result_history: list[ResultCollection],
last_n: int,
) -> Path:
"""Save historical runtime collections using binary on-disk format."""
if last_n <= 0:
raise ValueError("last_n must be > 0")
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
snapshot_dir = output_dir / f"snapshot_{timestamp}"
with atomic_snapshot_directory(snapshot_dir) as staging:
save_trace_history_binary(staging / "raw", raw_history[-last_n:], RAW_MAGIC)
save_trace_history_binary(staging / "preprocessed", preprocessed_history[-last_n:], PREPROC_MAGIC)
save_result_history_binary(staging / "results", result_history[-last_n:])
logger.info("Saved binary runtime snapshot (last_n=%d) to %s", last_n, snapshot_dir)
return snapshot_dir
def snapshot_directory(
self, output_root_dir: Path, snapshot_name: str, *, name_prefix: str = ""
) -> Path:
@@ -435,126 +356,19 @@ class NpzStore(StoreApi):
raw_history: list[SweepCollection],
preprocessed_history: list[SweepCollection],
result_history: list[ResultCollection],
last_n: int,
*,
name_prefix: str = "",
include_unaligned: bool = False,
) -> tuple[Path, dict[str, Any]]:
"""Save historical runtime collections in NumPy tree format."""
if last_n <= 0:
raise ValueError("last_n must be > 0")
snapshot_stem = _compose_snapshot_stem(snapshot_name, name_prefix)
output_root_dir.mkdir(parents=True, exist_ok=True)
snapshot_dir = output_root_dir / snapshot_stem
if snapshot_dir.exists():
raise FileExistsError(f"Snapshot directory already exists: {snapshot_dir}")
selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories(
raw_history,
preprocessed_history,
result_history,
last_n,
include_unaligned=include_unaligned,
)
if selection_summary.get("anchor_stage") == "results":
expected = min(int(last_n), len(result_history))
if len(selected_results) != expected:
raise RuntimeError(
"Snapshot results selection invariant failed: "
f"expected={expected}, selected={len(selected_results)}"
)
processing_settings: dict[int, dict] | None = None,
) -> Path:
"""Save exactly the supplied collections and their processing revisions."""
snapshot_dir = self.snapshot_directory(output_root_dir, snapshot_name, name_prefix=name_prefix)
with atomic_snapshot_directory(snapshot_dir) as staging:
save_trace_history_numpy(staging / "raw", selected_raw)
save_trace_history_numpy(staging / "preprocessed", selected_preprocessed)
save_result_history_numpy(staging / "results", selected_results)
selection_summary["raw_count"] = len(selected_raw)
selection_summary["preprocessed_count"] = len(selected_preprocessed)
selection_summary["result_count"] = len(selected_results)
selection_summary["snapshot_stem"] = snapshot_stem
selection_summary["snapshot_dir"] = str(snapshot_dir)
logger.info(
"Saved NumPy runtime snapshot to %s (raw=%d preprocessed=%d results=%d, anchor=%s)",
snapshot_dir,
len(selected_raw),
len(selected_preprocessed),
len(selected_results),
selection_summary.get("anchor_stage"),
)
return snapshot_dir, selection_summary
def save_runtime_vna_history_json(
self,
output_root_dir: Path,
output_name: str,
raw_history: list[SweepCollection],
preprocessed_history: list[SweepCollection],
result_history: list[ResultCollection],
last_n: int,
*,
input_index: int = 0,
output_index: int = 0,
channel: str = "s21",
primary_stage: str = "preprocessed",
include_unaligned: bool = False,
) -> tuple[Path, dict[str, Any]]:
"""Save runtime history as vna_system-compatible JSON file."""
if last_n <= 0:
raise ValueError("last_n must be > 0")
output_stem = output_name.strip() or datetime.now(timezone.utc).strftime("snapshot_%Y%m%d_%H%M%S")
output_stem = sanitize_path_component(output_stem)
output_root_dir.mkdir(parents=True, exist_ok=True)
output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
output_path = output_dir / f"{output_stem}_vna_bscan_history.json"
selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories(
raw_history,
preprocessed_history,
result_history,
last_n,
include_unaligned=include_unaligned,
)
payload = build_vna_history_payload(
selected_raw,
selected_preprocessed,
selected_results,
input_index=input_index,
output_index=output_index,
channel=channel,
primary_stage=primary_stage,
)
with atomic_snapshot_directory(output_dir) as staging:
(staging / output_path.name).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
summary: dict[str, Any] = dict(selection_summary)
summary["raw_count"] = len(selected_raw)
summary["preprocessed_count"] = len(selected_preprocessed)
summary["result_count"] = len(selected_results)
summary["raw_record_count"] = int(payload.get("raw_record_count", 0))
summary["preprocessed_record_count"] = int(payload.get("preprocessed_record_count", 0))
summary["sweep_count"] = len(payload.get("sweep_history", []))
summary["input_index"] = int(input_index)
summary["output_index"] = int(output_index)
summary["channel"] = str(channel)
summary["primary_stage"] = str(primary_stage)
summary["output_stem"] = output_stem
summary["output_dir"] = str(output_dir)
summary["output_path"] = str(output_path)
logger.info(
"Saved VNA history JSON to %s (input=%d output=%d channel=%s sweeps=%d)",
output_path,
int(input_index),
int(output_index),
channel,
summary["sweep_count"],
)
return output_path, summary
save_processing_settings(staging, processing_settings)
save_trace_history_numpy(staging / "raw", raw_history)
save_trace_history_numpy(staging / "preprocessed", preprocessed_history)
save_result_history_numpy(staging / "results", result_history)
logger.info("Saved NumPy runtime snapshot to %s", snapshot_dir)
return snapshot_dir
def save_runtime_vna_history_json_batch(
self,
@@ -563,33 +377,19 @@ class NpzStore(StoreApi):
raw_history: list[SweepCollection],
preprocessed_history: list[SweepCollection],
result_history: list[ResultCollection],
last_n: int,
*,
channel: str = "s21",
primary_stage: str = "preprocessed",
name_prefix: str = "",
include_unaligned: bool = False,
) -> tuple[list[Path], dict[str, Any]]:
) -> list[Path]:
"""Save one runtime VNA-history JSON per available combo."""
if last_n <= 0:
raise ValueError("last_n must be > 0")
output_stem = _compose_snapshot_stem(output_name, name_prefix)
output_root_dir.mkdir(parents=True, exist_ok=True)
output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories(
raw_history,
preprocessed_history,
result_history,
last_n,
include_unaligned=include_unaligned,
)
output_dir = output_root_dir / output_stem
combos = sorted(
{
(int(trace.combo.input), int(trace.combo.output))
for collection in [*selected_raw, *selected_preprocessed]
for collection in [*raw_history, *preprocessed_history]
for trace in collection.traces
}
)
@@ -601,45 +401,51 @@ class NpzStore(StoreApi):
payloads: list[dict[str, Any]] = []
for input_index, output_index in combos:
output_path = output_dir / (
f"{output_stem}_i{input_index}_o{output_index}_{channel}_vna_bscan_history.json"
f"{output_stem}_i{input_index}_o{output_index}_s21_vna_bscan_history.json"
)
if output_path.exists():
raise FileExistsError(f"Output JSON file already exists: {output_path}")
payloads.append(
build_vna_history_payload(
selected_raw,
selected_preprocessed,
selected_results,
raw_history,
preprocessed_history,
result_history,
input_index=input_index,
output_index=output_index,
channel=channel,
primary_stage=primary_stage,
)
)
output_paths.append(output_path)
with atomic_snapshot_directory(output_dir) as staging:
output_existed = output_dir.exists()
output_dir.mkdir(exist_ok=True)
published: list[Path] = []
try:
# Publish complete files without overwriting a concurrent export.
for output_path, payload in zip(output_paths, payloads, strict=True):
(staging / output_path.name).write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
temporary = temporary_file(output_path)
try:
with temporary.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2, allow_nan=False)
handle.flush()
os.fsync(handle.fileno())
rename_no_replace(temporary, output_path)
published.append(output_path)
finally:
temporary.unlink(missing_ok=True)
sync_directory(output_dir)
except BaseException:
for path in published:
path.unlink()
if not output_existed:
with suppress(OSError):
output_dir.rmdir()
raise
summary: dict[str, Any] = dict(selection_summary)
summary["raw_count"] = len(selected_raw)
summary["preprocessed_count"] = len(selected_preprocessed)
summary["result_count"] = len(selected_results)
summary["preprocessed_record_count"] = int(sum(int(payload.get("preprocessed_record_count", 0)) for payload in payloads))
summary["combo_count"] = len(combos)
summary["combos"] = [list(combo) for combo in combos]
summary["channel"] = str(channel)
summary["primary_stage"] = str(primary_stage)
summary["output_stem"] = output_stem
summary["output_dir"] = str(output_dir)
summary["output_paths"] = [str(path) for path in output_paths]
logger.info(
"Saved %d VNA history JSON file(s) to %s (channel=%s)", len(output_paths), output_dir, channel
"Saved %d S21 VNA history JSON file(s) to %s", len(output_paths), output_dir
)
return output_paths, summary
return output_paths
def _set_dir(self, kind: str, radar_key: str) -> Path:
"""Return directory for set kind and radar key."""
@@ -653,15 +459,6 @@ class NpzStore(StoreApi):
if not value or value in {".", ".."} or Path(value).name != value or "\\" in value:
raise ValueError(f"Invalid set path component: {value!r}")
@staticmethod
def _temporary_file(destination: Path) -> Path:
"""Create a unique temporary file beside its atomic-replacement destination."""
with tempfile.NamedTemporaryFile(
dir=destination.parent, prefix=f".{destination.name}.", suffix=".tmp", delete=False
) as handle:
path = Path(handle.name)
return path
def preview_png_dir(self, kind: str, set_name: str, radar_key: str) -> Path:
"""Return directory for preview PNGs of one set/radar variant.
@@ -671,6 +468,3 @@ class NpzStore(StoreApi):
filesystem-safe by construction.
"""
return self._root_dir / "preview_png" / kind / sanitize_path_component(set_name) / radar_key
__all__ = ["NpzStore", "radar_key_from_config"]
+109 -97
View File
@@ -5,11 +5,14 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import logging
import json
from pathlib import Path
from typing import Any
import numpy as np
from python_app.models.dataset_model import ResultCollection, SweepCollection, TraceData
from python_app.models.collection_identity import index_collections
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection, TraceData
logger = logging.getLogger(__name__)
@@ -18,30 +21,15 @@ logger = logging.getLogger(__name__)
class TraceRecord:
"""One raw/preprocessed trace selected for vna_history export."""
stage: str
collection_id: int
monotonic_ns: int
stage_index: int
frequency_hz: np.ndarray
samples: np.ndarray
# End of this trace's own sweep, or 0 when the producer reported no per-trace
# timing. Preferred over the collection timestamp for the exported sweep time:
# in a switched matrix each combo is measured at a different instant.
# Collection window identifies the acquisition across pipeline stages.
capture_end_ns: int = 0
capture_start_ns: int = 0
def _normalize_channel(channel: str) -> str:
normalized = str(channel).strip().lower()
if normalized not in {"s21", "s11"}:
raise ValueError("channel must be either 's21' or 's11'")
return normalized
def _select_trace_samples(trace: TraceData, channel: str) -> np.ndarray:
if channel == "s11":
return np.asarray(trace.s11, dtype=np.complex128).reshape(-1)
return np.asarray(trace.s21, dtype=np.complex128).reshape(-1)
trace_capture_end_ns: int = 0
def _pick_trace(collection: SweepCollection, input_index: int, output_index: int) -> TraceData | None:
@@ -57,7 +45,6 @@ def _build_stage_records(
*,
input_index: int,
output_index: int,
channel: str,
) -> list[TraceRecord]:
records: list[TraceRecord] = []
for stage_index, collection in enumerate(history):
@@ -66,11 +53,11 @@ def _build_stage_records(
continue
frequency_hz = np.asarray(trace.frequency_hz, dtype=np.float64).reshape(-1)
samples = _select_trace_samples(trace, channel)
samples = np.asarray(trace.s21, dtype=np.complex128).reshape(-1)
if frequency_hz.shape != samples.shape:
raise ValueError(
f"Shape mismatch in {stage} stage for collection_id={collection.collection_id}: "
f"freq{frequency_hz.shape} vs {channel}{samples.shape}"
f"freq{frequency_hz.shape} vs S21{samples.shape}"
)
if frequency_hz.size == 0:
continue
@@ -83,14 +70,14 @@ def _build_stage_records(
records.append(
TraceRecord(
stage=stage,
collection_id=int(collection.collection_id),
monotonic_ns=int(collection.monotonic_ns),
stage_index=int(stage_index),
frequency_hz=frequency_hz,
samples=samples,
capture_end_ns=int(trace.capture_end_ns),
capture_start_ns=int(trace.capture_start_ns),
capture_end_ns=int(collection.capture_end_ns),
capture_start_ns=int(collection.capture_start_ns),
trace_capture_end_ns=int(trace.capture_end_ns),
)
)
return records
@@ -104,69 +91,59 @@ def _build_sweep_history(
raw_records: list[TraceRecord],
preprocessed_records: list[TraceRecord],
*,
channel: str,
primary_stage: str,
) -> list[dict[str, Any]]:
# Runtime identities are exact. Only archives with no collection window need
# the old monotonic/index fallback; never merge their repeated IDs in a dict.
raw_index = index_collections(record for record in raw_records if record.capture_start_ns)
pre_index = index_collections(record for record in preprocessed_records if record.capture_start_ns)
pairs = [(raw_index.get(key), pre_index.get(key)) for key in raw_index.keys() | pre_index.keys()]
primary = preprocessed_records if primary_stage == "preprocessed" else raw_records
secondary = raw_records if primary_stage == "preprocessed" else preprocessed_records
used: set[int] = set()
pairs: list[tuple[TraceRecord, TraceRecord | None]] = []
secondary = [record for record in secondary if not record.capture_start_ns]
for base in primary:
candidates = [
(index, record) for index, record in enumerate(secondary)
if index not in used and record.collection_id == base.collection_id
]
if base.capture_start_ns or base.capture_end_ns:
candidates = [
(index, record) for index, record in candidates
if (record.capture_start_ns, record.capture_end_ns) == (base.capture_start_ns, base.capture_end_ns)
]
elif base.monotonic_ns:
# Raw is produced before preprocessing; never pair it to a newer run.
candidates = [
(index, record) for index, record in candidates
if (record.monotonic_ns <= base.monotonic_ns if primary_stage == "preprocessed"
else record.monotonic_ns >= base.monotonic_ns)
]
match = None
if candidates:
index, match = min(candidates, key=lambda pair: abs(pair[1].monotonic_ns - base.monotonic_ns))
used.add(index)
pairs.append((base, match))
# A tap can deliver a sweep while another stage drops it or is still processing.
# Preserve those records as well instead of silently discarding the extra stage.
pairs.extend((record, None) for index, record in enumerate(secondary) if index not in used)
pairs.sort(key=lambda pair: (pair[0].monotonic_ns, pair[0].stage_index))
history: list[dict[str, Any]] = []
for fallback_index, (base, match) in enumerate(pairs):
raw = base if base.stage == "raw" else match
pre = base if base.stage == "preprocessed" else match
sweep_source = raw or pre
calibrated_source = pre or raw
if sweep_source is None or calibrated_source is None:
if base.capture_start_ns:
continue
candidates = [record for record in secondary if record.collection_id == base.collection_id and (
not base.monotonic_ns or
(record.monotonic_ns <= base.monotonic_ns if primary_stage == "preprocessed"
else record.monotonic_ns >= base.monotonic_ns)
)]
match = min(candidates, key=lambda record: (
abs(record.monotonic_ns - base.monotonic_ns), abs(record.stage_index - base.stage_index),
)) if candidates else None
if match is not None:
secondary = [record for record in secondary if record is not match]
pairs.append((match, base) if primary_stage == "preprocessed" else (base, match))
pairs.extend((record, None) if primary_stage == "preprocessed" else (None, record) for record in secondary)
def source(pair: tuple[TraceRecord | None, TraceRecord | None]) -> TraceRecord:
raw, pre = pair
base = (pre or raw) if primary_stage == "preprocessed" else (raw or pre)
assert base is not None
return base
pairs.sort(key=lambda pair: (source(pair).capture_start_ns or source(pair).monotonic_ns,
source(pair).stage_index))
history: list[dict[str, Any]] = []
for fallback_index, (raw, pre) in enumerate(pairs):
base = source((raw, pre))
start_freq_hz = float(base.frequency_hz[0])
stop_freq_hz = float(base.frequency_hz[-1])
# Prefer the exported trace's own sweep time: with a switching matrix the
# combos of one collection are measured milliseconds apart, so the
# collection timestamp misplaces every combo but the last.
if base.capture_end_ns > 0:
timestamp_sec = float(base.capture_end_ns) / 1_000_000_000.0
elif base.monotonic_ns > 0:
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0
else:
timestamp_sec = float(fallback_index)
timestamp_ns = base.trace_capture_end_ns or base.capture_end_ns or base.monotonic_ns
timestamp_sec = timestamp_ns / 1_000_000_000.0 if timestamp_ns else float(fallback_index)
history.append(
{
"timestamp": timestamp_sec,
"sweep_points": _complex_to_points(sweep_source.samples),
"calibrated_points": _complex_to_points(calibrated_source.samples),
"sweep_points": _complex_to_points(raw.samples) if raw is not None else [],
"calibrated_points": _complex_to_points(pre.samples) if pre is not None else [],
"reference_points": [],
"vna_config": {
"mode": channel,
"mode": "s21",
"start_freq": start_freq_hz,
"stop_freq": stop_freq_hz,
"points": int(base.frequency_hz.size),
@@ -180,30 +157,18 @@ def _stage_alignment_warning(pre_history: list[SweepCollection], result_history:
if not pre_history or not result_history:
return None
common_size = min(len(pre_history), len(result_history))
if common_size <= 0:
def keys(history: list[SweepCollection] | list[ResultCollection]) -> set[tuple[int, int, int]]:
return {(item.collection_id, item.capture_start_ns or item.monotonic_ns,
-1 if item.capture_start_ns else index) for index, item in enumerate(history)}
pre_keys, result_keys = keys(pre_history), keys(result_history)
missing_results = len(pre_keys - result_keys)
missing_pre = len(result_keys - pre_keys)
if not missing_results and not missing_pre:
return None
mismatches = 0
first_mismatch: tuple[int, SweepCollection, ResultCollection] | None = None
for index in range(common_size):
pre = pre_history[index]
result = result_history[index]
if int(pre.collection_id) != int(result.collection_id) or int(pre.monotonic_ns) != int(result.monotonic_ns):
mismatches += 1
if first_mismatch is None:
first_mismatch = (index, pre, result)
if mismatches == 0 or first_mismatch is None:
return None
index, pre, result = first_mismatch
return (
"WARNING: snapshot stages are not fully aligned (preprocessed vs results). "
f"Mismatches={mismatches}/{common_size}. "
f"First mismatch at index={index}: "
f"pre=(id={int(pre.collection_id)},ns={int(pre.monotonic_ns)}) vs "
f"results=(id={int(result.collection_id)},ns={int(result.monotonic_ns)}). "
f"Sweeps without results={missing_results}; results without preprocessed sweeps={missing_pre}. "
"Export uses preprocessed traces; loaded view in vna_system may differ from "
"radar_system on-screen replayed results."
)
@@ -216,11 +181,9 @@ def build_vna_history_payload(
*,
input_index: int,
output_index: int,
channel: str = "s21",
primary_stage: str = "preprocessed",
) -> dict[str, Any]:
"""Build vna_system-compatible history JSON payload from runtime histories."""
channel = _normalize_channel(channel)
if primary_stage not in {"preprocessed", "raw"}:
raise ValueError("primary_stage must be either 'preprocessed' or 'raw'")
@@ -229,14 +192,12 @@ def build_vna_history_payload(
raw_history,
input_index=input_index,
output_index=output_index,
channel=channel,
)
preprocessed_records = _build_stage_records(
"preprocessed",
preprocessed_history,
input_index=input_index,
output_index=output_index,
channel=channel,
)
if not raw_records and not preprocessed_records:
raise ValueError(
@@ -247,7 +208,6 @@ def build_vna_history_payload(
sweep_history = _build_sweep_history(
raw_records,
preprocessed_records,
channel=channel,
primary_stage=primary_stage,
)
if not sweep_history:
@@ -255,12 +215,12 @@ def build_vna_history_payload(
payload: dict[str, Any] = {
"format": "vna-system-history-v1",
"converter": "python_app/scripts/convert_snapshot_to_vna_history.py",
"converter": "python_app/storage/npz/vna_history_json.py",
"converted_at_utc": datetime.now(timezone.utc).isoformat(),
"source_snapshot_dir": "<runtime_history>",
"input_index": int(input_index),
"output_index": int(output_index),
"channel": channel,
"channel": "s21",
"primary_stage": str(primary_stage),
"raw_record_count": len(raw_records),
"preprocessed_record_count": len(preprocessed_records),
@@ -285,3 +245,55 @@ def build_vna_history_payload(
len(sweep_history),
)
return payload
def collection_directories(stage_dir: Path) -> list[Path]:
"""Order a snapshot stage numerically, including indices beyond four digits."""
def order(path: Path) -> tuple[int, str]:
prefix = path.name.split("_", 1)[0]
return (int(prefix) if prefix.isdigit() else 0, path.name)
return sorted((path for path in stage_dir.iterdir() if path.is_dir()), key=order) if stage_dir.exists() else []
def load_vna_history_snapshot(
snapshot_dir: Path, *, input_index: int, output_index: int, primary_stage: str = "preprocessed",
) -> dict[str, Any]:
"""Load snapshot identities and selected S21 arrays, then use the runtime exporter."""
if not snapshot_dir.is_dir():
raise FileNotFoundError(f"Snapshot directory not found: {snapshot_dir}")
histories: dict[str, list[SweepCollection]] = {"raw": [], "preprocessed": []}
results: list[ResultCollection] = []
for stage in ("raw", "preprocessed", "results"):
for directory in collection_directories(snapshot_dir / stage):
meta_path = directory / "meta.json"
if not meta_path.exists():
continue
meta = json.loads(meta_path.read_text(encoding="utf-8"))
if not isinstance(meta, dict):
raise ValueError(f"JSON root must be object: {meta_path}")
cid, ns = int(meta.get("collection_id", -1)), int(meta.get("monotonic_ns", 0))
start, end = int(meta.get("capture_start_ns", 0)), int(meta.get("capture_end_ns", 0))
if stage == "results":
results.append(ResultCollection(cid, ns, capture_start_ns=start, capture_end_ns=end))
continue
collection = SweepCollection(cid, ns, capture_start_ns=start, capture_end_ns=end)
for trace in meta.get("traces", []):
if (int(trace.get("input", -1)), int(trace.get("output", -1))) != (input_index, output_index):
continue
if not trace.get("freq_file") or not trace.get("s21_file"):
continue
collection.traces.append(TraceData(
ComboKey(input_index, output_index), np.load(directory / trace["freq_file"]),
np.load(directory / trace["s21_file"]),
capture_start_ns=int(trace.get("capture_start_ns", 0)),
capture_end_ns=int(trace.get("capture_end_ns", 0)),
))
break
histories[stage].append(collection)
payload = build_vna_history_payload(
histories["raw"], histories["preprocessed"], results,
input_index=input_index, output_index=output_index, primary_stage=primary_stage,
)
payload["source_snapshot_dir"] = str(snapshot_dir)
return payload
-21
View File
@@ -1,21 +0,0 @@
"""Facade exports for NPZ-based storage implementation."""
from python_app.storage.npz.paths import (
collection_dir_name as _collection_dir_name,
format_float_for_key as _format_float_for_key,
radar_key_from_config,
sanitize_path_component as _sanitize_path_component,
)
from python_app.storage.npz.serialize import PREPROC_MAGIC, RAW_MAGIC, RESULT_MAGIC
from python_app.storage.npz.store import NpzStore
__all__ = [
"NpzStore",
"PREPROC_MAGIC",
"RAW_MAGIC",
"RESULT_MAGIC",
"_collection_dir_name",
"_format_float_for_key",
"_sanitize_path_component",
"radar_key_from_config",
]
-37
View File
@@ -1,37 +0,0 @@
"""Abstract storage API for preprocess sets."""
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from python_app.models.dataset_model import ComboKey, SweepCollection
class StoreApi(ABC):
"""Storage contract used by workflows and GUI code."""
@abstractmethod
def save_set(self, kind: str, radar_key: str, set_name: str, collection: SweepCollection) -> None:
"""Persist a named set."""
raise NotImplementedError
@abstractmethod
def load_set(self, kind: str, radar_key: str, set_name: str) -> SweepCollection:
"""Load a named set."""
raise NotImplementedError
@abstractmethod
def list_sets(self, kind: str, radar_key: str) -> list[str]:
"""List set names for kind/radar key."""
raise NotImplementedError
@abstractmethod
def has_combo_coverage(self, kind: str, radar_key: str, set_name: str, combos: list[ComboKey]) -> bool:
"""Check whether stored set covers requested switch combinations."""
raise NotImplementedError
@abstractmethod
def export_set_bundle(self, kind: str, radar_key: str, set_name: str, output_path: Path) -> Path:
"""Export set into binary bundle file for C++ preprocessing."""
raise NotImplementedError