Refactor radar system
This commit is contained in:
+182
-63
@@ -6,25 +6,31 @@ from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Any
|
||||
from zipfile import BadZipFile
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection, TraceData
|
||||
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
|
||||
from python_app.storage.npz.serialize import PREPROC_MAGIC, RAW_MAGIC, 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,
|
||||
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"
|
||||
|
||||
|
||||
def _compose_snapshot_stem(name: str, name_prefix: str) -> str:
|
||||
@@ -37,7 +43,7 @@ def _compose_snapshot_stem(name: str, name_prefix: str) -> str:
|
||||
|
||||
|
||||
class SnapshotStreamWriter:
|
||||
"""Append aligned raw/preprocessed/result collections to a snapshot dir in chunks.
|
||||
"""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,
|
||||
@@ -69,12 +75,32 @@ class SnapshotStreamWriter:
|
||||
preprocessed: list[SweepCollection],
|
||||
results: list[ResultCollection],
|
||||
) -> None:
|
||||
"""Write one chunk of (already aligned) collections, continuing each stage's index."""
|
||||
save_trace_history_numpy(self._dir / "raw", raw, index_offset=self._raw_written)
|
||||
save_trace_history_numpy(
|
||||
self._dir / "preprocessed", preprocessed, index_offset=self._preprocessed_written
|
||||
)
|
||||
save_result_history_numpy(self._dir / "results", results, index_offset=self._result_written)
|
||||
"""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)
|
||||
@@ -96,13 +122,15 @@ class NpzStore(StoreApi):
|
||||
|
||||
def save_set(self, kind: str, radar_key: str, set_name: str, collection: SweepCollection) -> None:
|
||||
"""Persist named preprocess set as NPZ and metadata JSON."""
|
||||
self._validate_set_name(set_name)
|
||||
validate_trace_collection(collection, require_finite=True)
|
||||
set_dir = self._set_dir(kind, radar_key)
|
||||
set_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
npz_path = set_dir / f"{set_name}.npz"
|
||||
meta_path = set_dir / f"{set_name}.json"
|
||||
|
||||
payload: dict[str, np.ndarray] = {}
|
||||
payload: dict[str, Any] = {}
|
||||
combo_records: list[dict[str, str | int]] = []
|
||||
|
||||
for trace in collection.traces:
|
||||
@@ -133,67 +161,137 @@ class NpzStore(StoreApi):
|
||||
"combos": combo_records,
|
||||
}
|
||||
|
||||
# Write both files to temporary paths first, then atomically rename so a
|
||||
# crash never leaves an .npz without its meta (or vice versa).
|
||||
npz_tmp = npz_path.with_name(npz_path.name + ".tmp")
|
||||
meta_tmp = meta_path.with_name(meta_path.name + ".tmp")
|
||||
# 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)
|
||||
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
|
||||
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(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
meta_tmp.write_text(metadata_json, encoding="utf-8")
|
||||
npz_tmp.replace(npz_path)
|
||||
archive_replaced = True
|
||||
meta_tmp.replace(meta_path)
|
||||
except BaseException:
|
||||
logger.exception("Failed to save set %s/%s/%s; rolling back temp files", kind, radar_key, set_name)
|
||||
for tmp_path in (npz_tmp, meta_tmp):
|
||||
if archive_replaced:
|
||||
if backup_path is None:
|
||||
npz_path.unlink(missing_ok=True)
|
||||
else:
|
||||
try:
|
||||
backup_path.replace(npz_path)
|
||||
except OSError:
|
||||
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
|
||||
raise
|
||||
finally:
|
||||
for tmp_path in (npz_tmp, meta_tmp, backup_path):
|
||||
if tmp_path is None:
|
||||
continue
|
||||
with suppress(OSError):
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
logger.info(
|
||||
"Saved set %s/%s/%s (%d traces) to %s", kind, radar_key, set_name, len(combo_records), npz_path
|
||||
)
|
||||
|
||||
def save_sets(self, radar_key: str, set_name: str, collections: dict[str, SweepCollection]) -> None:
|
||||
"""Save related sets together, restoring their previous files if any save fails."""
|
||||
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))
|
||||
backups: dict[Path, Path | None] = {}
|
||||
retain_backups = False
|
||||
try:
|
||||
for kind in collections:
|
||||
set_dir = self._set_dir(kind, radar_key)
|
||||
for extension in ("npz", "json"):
|
||||
destination = set_dir / f"{set_name}.{extension}"
|
||||
backup = backup_dir / f"{kind}.{extension}" if destination.exists() else None
|
||||
if backup is not None:
|
||||
os.link(destination, backup)
|
||||
backups[destination] = backup
|
||||
try:
|
||||
for kind, collection in collections.items():
|
||||
self.save_set(kind, radar_key, set_name, collection)
|
||||
except BaseException as save_error:
|
||||
retain_backups = True # preserve recovery files if rollback itself is interrupted
|
||||
rollback_failed = False
|
||||
for destination, backup in backups.items():
|
||||
try:
|
||||
if backup is None:
|
||||
destination.unlink(missing_ok=True)
|
||||
else:
|
||||
backup.replace(destination)
|
||||
except OSError:
|
||||
rollback_failed = True
|
||||
logger.exception("Failed to restore previous set file %s", destination)
|
||||
retain_backups = rollback_failed
|
||||
if retain_backups:
|
||||
raise RuntimeError(
|
||||
f"Saving set group '{set_name}' failed and rollback was incomplete; "
|
||||
f"previous files are retained at {backup_dir}"
|
||||
) from save_error
|
||||
raise
|
||||
finally:
|
||||
if not retain_backups:
|
||||
with suppress(OSError):
|
||||
shutil.rmtree(backup_dir)
|
||||
|
||||
def load_set(self, kind: str, radar_key: str, set_name: str) -> SweepCollection:
|
||||
"""Load named preprocess set from NPZ representation."""
|
||||
self._validate_set_name(set_name)
|
||||
set_dir = self._set_dir(kind, radar_key)
|
||||
npz_path = set_dir / f"{set_name}.npz"
|
||||
meta_path = set_dir / f"{set_name}.json"
|
||||
|
||||
if not npz_path.exists() or not meta_path.exists():
|
||||
if not npz_path.exists():
|
||||
logger.error("Missing set files for %s/%s/%s", kind, radar_key, set_name)
|
||||
raise FileNotFoundError(f"Missing set files for {kind}/{radar_key}/{set_name}")
|
||||
|
||||
set_label = f"{kind}/{radar_key}/{set_name}"
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
arrays = np.load(npz_path, allow_pickle=False)
|
||||
|
||||
traces: list[TraceData] = []
|
||||
for combo in meta["combos"]:
|
||||
freq = 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)
|
||||
traces.append(
|
||||
TraceData(
|
||||
combo=ComboKey(input=int(combo["input"]), output=int(combo["output"])),
|
||||
frequency_hz=freq,
|
||||
s11=s11,
|
||||
s21=s21,
|
||||
capture_start_ns=int(combo.get("capture_start_ns", 0)),
|
||||
capture_end_ns=int(combo.get("capture_end_ns", 0)),
|
||||
)
|
||||
with np.load(npz_path, allow_pickle=False) as arrays:
|
||||
meta = json.loads(
|
||||
str(arrays[_SET_METADATA_KEY].item())
|
||||
if _SET_METADATA_KEY in arrays
|
||||
else meta_path.read_text(encoding="utf-8")
|
||||
)
|
||||
if not isinstance(meta, dict):
|
||||
raise ValueError("Set metadata must be a JSON object")
|
||||
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),
|
||||
)
|
||||
)
|
||||
|
||||
collection = SweepCollection(
|
||||
collection_id=int(meta["collection_id"]),
|
||||
monotonic_ns=int(meta["monotonic_ns"]),
|
||||
collection_id=meta["collection_id"],
|
||||
monotonic_ns=meta["monotonic_ns"],
|
||||
traces=traces,
|
||||
capture_start_ns=int(meta.get("capture_start_ns", 0)),
|
||||
capture_end_ns=int(meta.get("capture_end_ns", 0)),
|
||||
capture_start_ns=meta.get("capture_start_ns", 0),
|
||||
capture_end_ns=meta.get("capture_end_ns", 0),
|
||||
)
|
||||
validate_trace_collection(collection, require_finite=True)
|
||||
logger.debug("Loaded set %s (%d traces)", set_label, len(traces))
|
||||
return collection
|
||||
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
||||
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
|
||||
|
||||
@@ -215,7 +313,12 @@ class NpzStore(StoreApi):
|
||||
"""Export named set as binary collection bundle for C++ preprocessing stage."""
|
||||
collection = self.load_set(kind, radar_key, set_name)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(serialize_trace_collection(collection, RAW_MAGIC))
|
||||
temporary = self._temporary_file(output_path)
|
||||
try:
|
||||
temporary.write_bytes(serialize_trace_collection(collection, RAW_MAGIC))
|
||||
temporary.replace(output_path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
logger.info("Exported set %s/%s/%s bundle to %s", kind, radar_key, set_name, output_path)
|
||||
return output_path
|
||||
|
||||
@@ -234,11 +337,10 @@ class NpzStore(StoreApi):
|
||||
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}"
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
save_trace_history_binary(snapshot_dir / "raw", raw_history[-last_n:], RAW_MAGIC)
|
||||
save_trace_history_binary(snapshot_dir / "preprocessed", preprocessed_history[-last_n:], PREPROC_MAGIC)
|
||||
save_result_history_binary(snapshot_dir / "results", result_history[-last_n:])
|
||||
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
|
||||
|
||||
@@ -281,6 +383,7 @@ class NpzStore(StoreApi):
|
||||
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:
|
||||
@@ -292,19 +395,15 @@ class NpzStore(StoreApi):
|
||||
snapshot_dir = output_root_dir / snapshot_stem
|
||||
if snapshot_dir.exists():
|
||||
raise FileExistsError(f"Snapshot directory already exists: {snapshot_dir}")
|
||||
snapshot_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories(
|
||||
raw_history,
|
||||
preprocessed_history,
|
||||
result_history,
|
||||
last_n,
|
||||
include_unaligned=include_unaligned,
|
||||
)
|
||||
|
||||
save_trace_history_numpy(snapshot_dir / "raw", selected_raw)
|
||||
save_trace_history_numpy(snapshot_dir / "preprocessed", selected_preprocessed)
|
||||
save_result_history_numpy(snapshot_dir / "results", selected_results)
|
||||
|
||||
if selection_summary.get("anchor_stage") == "results":
|
||||
expected = min(int(last_n), len(result_history))
|
||||
if len(selected_results) != expected:
|
||||
@@ -313,6 +412,11 @@ class NpzStore(StoreApi):
|
||||
f"expected={expected}, selected={len(selected_results)}"
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -341,6 +445,7 @@ class NpzStore(StoreApi):
|
||||
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:
|
||||
@@ -351,16 +456,14 @@ class NpzStore(StoreApi):
|
||||
|
||||
output_root_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_dir = self._vna_json_output_dir(output_root_dir, output_stem)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / f"{output_stem}_vna_bscan_history.json"
|
||||
if output_path.exists():
|
||||
raise FileExistsError(f"Output JSON file already exists: {output_path}")
|
||||
|
||||
selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories(
|
||||
raw_history,
|
||||
preprocessed_history,
|
||||
result_history,
|
||||
last_n,
|
||||
include_unaligned=include_unaligned,
|
||||
)
|
||||
payload = build_vna_history_payload(
|
||||
selected_raw,
|
||||
@@ -371,10 +474,8 @@ class NpzStore(StoreApi):
|
||||
channel=channel,
|
||||
primary_stage=primary_stage,
|
||||
)
|
||||
output_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
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)
|
||||
@@ -412,6 +513,7 @@ class NpzStore(StoreApi):
|
||||
channel: str = "s21",
|
||||
primary_stage: str = "preprocessed",
|
||||
name_prefix: str = "",
|
||||
include_unaligned: bool = False,
|
||||
) -> tuple[list[Path], dict[str, Any]]:
|
||||
"""Save one runtime VNA-history JSON per available combo."""
|
||||
if last_n <= 0:
|
||||
@@ -420,13 +522,13 @@ class NpzStore(StoreApi):
|
||||
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)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
selected_raw, selected_preprocessed, selected_results, selection_summary = select_aligned_histories(
|
||||
raw_history,
|
||||
preprocessed_history,
|
||||
result_history,
|
||||
last_n,
|
||||
include_unaligned=include_unaligned,
|
||||
)
|
||||
|
||||
combos = sorted(
|
||||
@@ -461,11 +563,11 @@ class NpzStore(StoreApi):
|
||||
)
|
||||
output_paths.append(output_path)
|
||||
|
||||
for output_path, payload in zip(output_paths, payloads, strict=True):
|
||||
output_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with atomic_snapshot_directory(output_dir) as staging:
|
||||
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"
|
||||
)
|
||||
|
||||
summary: dict[str, Any] = dict(selection_summary)
|
||||
summary["raw_count"] = len(selected_raw)
|
||||
@@ -486,8 +588,25 @@ class NpzStore(StoreApi):
|
||||
|
||||
def _set_dir(self, kind: str, radar_key: str) -> Path:
|
||||
"""Return directory for set kind and radar key."""
|
||||
self._validate_set_name(kind)
|
||||
self._validate_set_name(radar_key)
|
||||
return self._root_dir / kind / radar_key
|
||||
|
||||
@staticmethod
|
||||
def _validate_set_name(value: str) -> None:
|
||||
"""Keep caller-provided set components inside their storage directory."""
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user