Refactor radar system

This commit is contained in:
Ayzen
2026-10-05 16:46:28 +03:00
parent 515049d2ed
commit 8f097dfa2e
179 changed files with 8212 additions and 6676 deletions
+170 -258
View File
@@ -4,18 +4,69 @@ from __future__ import annotations
import json
import logging
from contextlib import contextmanager
from pathlib import Path
from typing import Any, TypeVar
import shutil
import tempfile
from typing import Any, Iterator
import numpy as np
from python_app.models.dataset_model import ResultCollection, SweepCollection
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
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection, validate_trace_collection
logger = logging.getLogger(__name__)
TCollection = TypeVar("TCollection")
@contextmanager
def atomic_snapshot_directory(destination: Path) -> Iterator[Path]:
"""Publish a complete export directory; discard staging files after a failed write."""
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))
try:
yield staging
if destination.exists():
raise FileExistsError(f"Snapshot directory already exists: {destination}")
staging.rename(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(
@@ -23,89 +74,60 @@ def select_aligned_histories(
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 with `results` as preferred anchor stage."""
raw_index, raw_pos = _index_by_collection_sequence(raw_history)
pre_index, pre_pos = _index_by_collection_sequence(preprocessed_history)
result_index, result_pos = _index_by_collection_sequence(result_history)
if result_index:
ordered_result_keys = sorted(result_index, key=lambda key: result_pos[key])
selected_keys = ordered_result_keys[-last_n:]
selected_raw = [raw_index[key] for key in selected_keys if key in raw_index]
selected_pre = [pre_index[key] for key in selected_keys if key in pre_index]
selected_results = [result_index[key] for key in selected_keys]
raw_missing = len(selected_keys) - len(selected_raw)
pre_missing = len(selected_keys) - len(selected_pre)
"""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 (
selected_raw,
selected_pre,
selected_results,
{
"selection_mode": "result_tail_with_optional_alignment",
"anchor_stage": "results",
"selected_collection_ids": [int(key[0]) for key in selected_keys],
"aligned_key_count": len(selected_keys),
"raw_missing_count": raw_missing,
"preprocessed_missing_count": pre_missing,
},
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},
)
if raw_index:
ordered_raw_keys = sorted(raw_index, key=lambda key: raw_pos[key])
selected_keys = ordered_raw_keys[-last_n:]
selected_raw = [raw_index[key] for key in selected_keys]
selected_pre = [pre_index[key] for key in selected_keys if key in pre_index]
selected_results = [result_index[key] for key in selected_keys if key in result_index]
pre_missing = len(selected_keys) - len(selected_pre)
result_missing = len(selected_keys) - len(selected_results)
return (
selected_raw,
selected_pre,
selected_results,
{
"selection_mode": "raw_tail_with_optional_alignment",
"anchor_stage": "raw",
"selected_collection_ids": [int(key[0]) for key in selected_keys],
"aligned_key_count": len(selected_keys),
"preprocessed_missing_count": pre_missing,
"result_missing_count": result_missing,
},
)
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"
if pre_index:
ordered_pre_keys = sorted(pre_index, key=lambda key: pre_pos[key])
selected_keys = ordered_pre_keys[-last_n:]
selected_raw = [raw_index[key] for key in selected_keys if key in raw_index]
selected_pre = [pre_index[key] for key in selected_keys]
selected_results = [result_index[key] for key in selected_keys if key in result_index]
raw_missing = len(selected_keys) - len(selected_raw)
result_missing = len(selected_keys) - len(selected_results)
return (
selected_raw,
selected_pre,
selected_results,
{
"selection_mode": "preprocessed_tail_with_optional_alignment",
"anchor_stage": "preprocessed",
"selected_collection_ids": [int(key[0]) for key in selected_keys],
"aligned_key_count": len(selected_keys),
"raw_missing_count": raw_missing,
"result_missing_count": result_missing,
},
)
return (
raw_history[-last_n:],
preprocessed_history[-last_n:],
result_history[-last_n:],
{
"selection_mode": "independent_tail",
"anchor_stage": "none",
"selected_collection_ids": [],
"aligned_key_count": 0,
},
)
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:
@@ -155,6 +177,7 @@ def save_result_history_binary(stage_dir: Path, history: list[ResultCollection])
{
"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),
},
@@ -173,6 +196,8 @@ def save_trace_history_numpy(
streaming recorder can append successive chunks into the same stage directory
without colliding or restarting the index.
"""
for collection in history:
validate_trace_collection(collection)
stage_dir.mkdir(parents=True, exist_ok=True)
logger.debug("Writing %d NumPy trace collection(s) to %s", len(history), stage_dir)
for index, collection in enumerate(history, start=index_offset):
@@ -219,197 +244,84 @@ def save_trace_history_numpy(
)
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."""
meta: dict[str, Any] = {"kind": int(payload.kind), "name": payload.processing_name}
def save_array(field: str, array: np.ndarray) -> None:
filename = f"{base_name}_{field}.npy"
np.save(directory / filename, array, allow_pickle=False)
meta[f"{field}_file"] = filename
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")
save_array("freq", freq)
save_array("trace", trace)
meta["points"] = int(freq.size)
elif payload.kind == 2:
save_array("scalar", np.asarray([payload.scalar_value], dtype=np.float32))
meta["scalar_value"] = float(payload.scalar_value)
elif payload.kind == 3:
x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
image = np.asarray(payload.image, dtype=np.float32)
if x_axis.ndim != 1 or y_axis.ndim != 1 or image.shape != (y_axis.size, x_axis.size):
raise ValueError("Result image axis sizes must match its 2D matrix shape")
save_array("x_axis", x_axis)
save_array("y_axis", y_axis)
save_array("image", image)
meta.update(x_points=int(x_axis.size), y_points=int(y_axis.size))
elif 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")
save_array("table", table)
meta.update(rows=int(table.shape[0]), columns=int(table.shape[1]))
else:
raise ValueError(f"Unsupported payload kind: {payload.kind}")
return meta
def save_result_history_numpy(
stage_dir: Path, history: list[ResultCollection], *, index_offset: int = 0
) -> None:
"""Write processed result collections as a NumPy directory tree.
``index_offset`` continues the per-collection directory numbering across calls (see
:func:`save_trace_history_numpy`) so streamed chunks append cleanly.
"""
"""Write result payloads, preserving per-stage indices across streaming chunks."""
stage_dir.mkdir(parents=True, exist_ok=True)
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: list[dict[str, int | str | float]] = []
collection_payload_meta = []
for payload_index, payload in enumerate(collection.collection_payloads):
safe_name = sanitize_path_component(payload.processing_name or "processor")
base_name = f"collection_{payload_index:03d}_{safe_name}_kind{payload.kind}"
if payload.kind == 1:
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
trace = np.asarray(payload.trace, dtype=np.complex64)
np.save(collection_dir / f"{base_name}_freq.npy", freq)
np.save(collection_dir / f"{base_name}_trace.npy", trace)
collection_payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"points": int(freq.size),
"freq_file": f"{base_name}_freq.npy",
"trace_file": f"{base_name}_trace.npy",
}
)
elif payload.kind == 2:
scalar = np.asarray([float(payload.scalar_value)], dtype=np.float32)
np.save(collection_dir / f"{base_name}_scalar.npy", scalar)
collection_payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"scalar_file": f"{base_name}_scalar.npy",
"scalar_value": float(payload.scalar_value),
}
)
elif 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)
np.save(collection_dir / f"{base_name}_x_axis.npy", image_x_axis)
np.save(collection_dir / f"{base_name}_y_axis.npy", image_y_axis)
np.save(collection_dir / f"{base_name}_image.npy", image)
collection_payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"x_points": int(image_x_axis.size),
"y_points": int(image_y_axis.size),
"x_axis_file": f"{base_name}_x_axis.npy",
"y_axis_file": f"{base_name}_y_axis.npy",
"image_file": f"{base_name}_image.npy",
}
)
elif payload.kind == 4:
table = np.asarray(payload.table, dtype=np.float32)
np.save(collection_dir / f"{base_name}_table.npy", table)
collection_payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"rows": int(table.shape[0]) if table.ndim == 2 else 0,
"columns": int(table.shape[1]) if table.ndim == 2 else 0,
"table_file": f"{base_name}_table.npy",
}
)
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: list[dict[str, int | str | list[dict[str, int | str | float]]]] = []
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: list[dict[str, int | str | float]] = []
payload_meta = []
for payload_index, payload in enumerate(block.payloads):
safe_name = sanitize_path_component(payload.processing_name or "processor")
base_name = f"{payload_index:03d}_{safe_name}_kind{payload.kind}"
if payload.kind == 1:
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
trace = np.asarray(payload.trace, dtype=np.complex64)
np.save(block_dir / f"{base_name}_freq.npy", freq)
np.save(block_dir / f"{base_name}_trace.npy", trace)
payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"points": int(freq.size),
"freq_file": f"{base_name}_freq.npy",
"trace_file": f"{base_name}_trace.npy",
}
)
elif payload.kind == 2:
scalar = np.asarray([float(payload.scalar_value)], dtype=np.float32)
np.save(block_dir / f"{base_name}_scalar.npy", scalar)
payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"scalar_file": f"{base_name}_scalar.npy",
"scalar_value": float(payload.scalar_value),
}
)
elif 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)
np.save(block_dir / f"{base_name}_x_axis.npy", image_x_axis)
np.save(block_dir / f"{base_name}_y_axis.npy", image_y_axis)
np.save(block_dir / f"{base_name}_image.npy", image)
payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"x_points": int(image_x_axis.size),
"y_points": int(image_y_axis.size),
"x_axis_file": f"{base_name}_x_axis.npy",
"y_axis_file": f"{base_name}_y_axis.npy",
"image_file": f"{base_name}_image.npy",
}
)
elif payload.kind == 4:
table = np.asarray(payload.table, dtype=np.float32)
np.save(block_dir / f"{base_name}_table.npy", table)
payload_meta.append(
{
"kind": int(payload.kind),
"name": payload.processing_name,
"rows": int(table.shape[0]) if table.ndim == 2 else 0,
"columns": int(table.shape[1]) if table.ndim == 2 else 0,
"table_file": f"{base_name}_table.npy",
}
)
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,
}
)
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),
"collection_payload_count": len(collection.collection_payloads),
"collection_payloads": collection_payload_meta,
"block_count": len(collection.blocks),
"blocks": blocks_meta,
},
indent=2,
),
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",
)
def validate_snapshot_name(snapshot_name: str) -> str:
"""Normalize snapshot directory stem."""
return sanitize_path_component(snapshot_name)
def _index_by_collection_sequence(
history: list[TCollection],
) -> tuple[dict[tuple[int, int], TCollection], dict[tuple[int, int], int]]:
"""Index collections by latest-first `(collection_id, occurrence)` preserving history position.
Occurrence is counted from the tail (newest item is occurrence `0` for its
collection id). This avoids cross-run misalignment when collection ids are
reused after restarts and one stage keeps longer history than another.
"""
indexed: dict[tuple[int, int], TCollection] = {}
positions: dict[tuple[int, int], int] = {}
seen_count_from_tail_by_id: dict[int, int] = {}
# Walk from newest to oldest so occurrence=0 always means the most recent
# instance for the given collection id.
for index in range(len(history) - 1, -1, -1):
item = history[index]
collection_id = int(getattr(item, "collection_id"))
occurrence = seen_count_from_tail_by_id.get(collection_id, 0)
key = (collection_id, occurrence)
indexed[key] = item
positions[key] = index
seen_count_from_tail_by_id[collection_id] = occurrence + 1
return indexed, positions