added capture time for every sweep
This commit is contained in:
@@ -38,6 +38,13 @@ struct SweepTraceBlock {
|
||||
std::vector<Complex32> s11{};
|
||||
// Complex S21 samples for matching frequency points.
|
||||
std::vector<Complex32> s21{};
|
||||
// Monotonic window in which THIS trace's sweep was measured, excluding the
|
||||
// switch drive and settling that preceded it. In a switched matrix the
|
||||
// collection is assembled combo by combo over many milliseconds, so the
|
||||
// collection-level window says nothing about when an individual combo was
|
||||
// measured. Zero on both is a valid "unmeasured" sentinel.
|
||||
std::uint64_t capture_start_ns = 0;
|
||||
std::uint64_t capture_end_ns = 0;
|
||||
};
|
||||
|
||||
struct RawSweepCollection {
|
||||
|
||||
@@ -172,6 +172,10 @@ void require_count_fits(std::uint32_t count, std::size_t min_bytes_each, BinaryR
|
||||
return trace;
|
||||
}
|
||||
|
||||
// The capture windows live in a TRAILER after the trace blocks rather than inside
|
||||
// them, so a reader built before they existed still decodes every trace and simply
|
||||
// stops early. The trailer grows the same way: collection window first, then the
|
||||
// per-trace window table (one pair per trace, in trace order).
|
||||
void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const RawSweepCollection& collection) {
|
||||
writer.write(magic);
|
||||
writer.write(collection.collection_id);
|
||||
@@ -184,6 +188,12 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw
|
||||
|
||||
writer.write(collection.capture_start_ns);
|
||||
writer.write(collection.capture_end_ns);
|
||||
|
||||
writer.write(checked_count_to_u32(collection.traces.size(), "Trace capture window count"));
|
||||
for (const auto& trace : collection.traces) {
|
||||
writer.write(trace.capture_start_ns);
|
||||
writer.write(trace.capture_end_ns);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] auto read_trace_collection(BinaryReader& reader, std::uint32_t expected_magic) -> RawSweepCollection {
|
||||
@@ -204,16 +214,31 @@ void write_trace_collection(BinaryWriter& writer, std::uint32_t magic, const Raw
|
||||
collection.traces.push_back(read_trace_block(reader));
|
||||
}
|
||||
|
||||
// Each trailer stage is optional: a payload from an older producer stops after
|
||||
// the trace blocks (or after the collection window) and leaves the rest zeroed.
|
||||
if (reader.remaining_bytes() == 0U) {
|
||||
return collection;
|
||||
}
|
||||
if (reader.remaining_bytes() != (sizeof(std::uint64_t) * 2U)) {
|
||||
throw std::runtime_error("Unexpected trailing bytes in trace collection");
|
||||
if (reader.remaining_bytes() < (sizeof(std::uint64_t) * 2U)) {
|
||||
throw std::runtime_error("Truncated capture window in trace collection");
|
||||
}
|
||||
|
||||
collection.capture_start_ns = reader.read<std::uint64_t>();
|
||||
collection.capture_end_ns = reader.read<std::uint64_t>();
|
||||
|
||||
if (reader.remaining_bytes() == 0U) {
|
||||
return collection;
|
||||
}
|
||||
|
||||
const auto trace_time_count = reader.read<std::uint32_t>();
|
||||
if (trace_time_count != collection.traces.size()) {
|
||||
throw std::runtime_error("Per-trace capture window count does not match trace count");
|
||||
}
|
||||
for (auto& trace : collection.traces) {
|
||||
trace.capture_start_ns = reader.read<std::uint64_t>();
|
||||
trace.capture_end_ns = reader.read<std::uint64_t>();
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ auto CalibrationMaster::apply_to_trace(const ipc::SweepTraceBlock& measured_trac
|
||||
output.frequency_hz = measured_trace.frequency_hz;
|
||||
output.s21 = apply_s21(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s21);
|
||||
output.s11 = apply_s11(measured_trace.combo, measured_trace.frequency_hz, measured_trace.s11);
|
||||
// Calibration reshapes the samples, not when they were measured.
|
||||
output.capture_start_ns = measured_trace.capture_start_ns;
|
||||
output.capture_end_ns = measured_trace.capture_end_ns;
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,9 @@ auto ReferenceMaster::apply_to_trace(const ipc::SweepTraceBlock& calibrated_trac
|
||||
output.frequency_hz = calibrated_trace.frequency_hz;
|
||||
output.s21 = apply_s21(calibrated_trace.combo, calibrated_trace.frequency_hz, calibrated_trace.s21);
|
||||
output.s11 = apply_s11(calibrated_trace.combo, calibrated_trace.frequency_hz, calibrated_trace.s11);
|
||||
// Reference subtraction reshapes the samples, not when they were measured.
|
||||
output.capture_start_ns = calibrated_trace.capture_start_ns;
|
||||
output.capture_end_ns = calibrated_trace.capture_end_ns;
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -147,14 +147,18 @@ class DriverLifecycleGuard {
|
||||
// Worst-case serialized size of a collection given the configured combo count and sweep
|
||||
// point count, using the trace wire format (see ipc::write_trace_collection/write_trace_block):
|
||||
// collection header: magic(4) + collection_id(8) + monotonic_ns(8) + trace_count(4)
|
||||
// + capture_start_ns(8) + capture_end_ns(8) = 40 bytes
|
||||
// + capture_start_ns(8) + capture_end_ns(8)
|
||||
// + trace capture window count(4) = 44 bytes
|
||||
// per trace block: input_pos(4) + output_pos(4) + point_count(4) = 12 bytes
|
||||
// + per point: frequency(4) + s11(8) + s21(8) = 20 bytes
|
||||
// + trailer: capture_start_ns(8) + capture_end_ns(8) = 16 bytes
|
||||
[[nodiscard]] auto worst_case_serialized_bytes(std::size_t combo_count, std::uint32_t sweep_points) -> std::size_t {
|
||||
constexpr std::size_t kCollectionHeaderBytes = 40U;
|
||||
constexpr std::size_t kCollectionHeaderBytes = 44U;
|
||||
constexpr std::size_t kTraceHeaderBytes = 12U;
|
||||
constexpr std::size_t kBytesPerPoint = 20U;
|
||||
const std::size_t per_trace = kTraceHeaderBytes + (static_cast<std::size_t>(sweep_points) * kBytesPerPoint);
|
||||
constexpr std::size_t kTraceTrailerBytes = 16U;
|
||||
const std::size_t per_trace =
|
||||
kTraceHeaderBytes + (static_cast<std::size_t>(sweep_points) * kBytesPerPoint) + kTraceTrailerBytes;
|
||||
return kCollectionHeaderBytes + (combo_count * per_trace);
|
||||
}
|
||||
|
||||
@@ -290,7 +294,12 @@ auto SweepOrchestrator::acquire_one_collection(
|
||||
// Production drivers ignore this; mock drivers use it to give every
|
||||
// (input, output) pair its own synthetic response.
|
||||
radar_driver_.set_active_combo(combo);
|
||||
// Stamp around the sweep only: the switch drive and settling above belong to
|
||||
// neither the previous combo nor this one, so excluding them keeps the window
|
||||
// an honest "when was this combo actually measured".
|
||||
const auto sweep_start_ns = ipc::current_monotonic_ns();
|
||||
auto sweep = radar_driver_.acquire_sweep();
|
||||
const auto sweep_end_ns = ipc::current_monotonic_ns();
|
||||
validate_sweep(sweep);
|
||||
|
||||
ipc::SweepTraceBlock trace{};
|
||||
@@ -298,6 +307,8 @@ auto SweepOrchestrator::acquire_one_collection(
|
||||
trace.frequency_hz = std::move(sweep.frequency_hz);
|
||||
trace.s11 = std::move(sweep.s11);
|
||||
trace.s21 = std::move(sweep.s21);
|
||||
trace.capture_start_ns = sweep_start_ns;
|
||||
trace.capture_end_ns = sweep_end_ns;
|
||||
collection.traces.push_back(std::move(trace));
|
||||
}
|
||||
|
||||
|
||||
@@ -332,10 +332,15 @@ class MultiDeviceLibreVnaService:
|
||||
assert self._sweep_configuration is not None
|
||||
|
||||
self._controller.configure_continuous_sweep(self._sweep_configuration)
|
||||
# Bound the sweep itself rather than reusing `capture_start_ns`: the latter
|
||||
# is taken before any retry/recovery, so it would overstate how long the
|
||||
# traces below took to measure.
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
result = self._controller.collect_running_sweep_cycles(
|
||||
1,
|
||||
datapoint_timeout_seconds=LIBREVNA_NATIVE_SWEEP_TIMEOUT_SECONDS,
|
||||
)
|
||||
sweep_end_ns = time.monotonic_ns()
|
||||
normalized_s_parameters = {
|
||||
str(name).lower(): np.asarray(values, dtype=np.complex64)
|
||||
for name, values in result.s_parameters.items()
|
||||
@@ -356,6 +361,11 @@ class MultiDeviceLibreVnaService:
|
||||
frequency_hz=frequencies,
|
||||
s11=reflection,
|
||||
s21=self._required_s_parameter(normalized_s_parameters, s_parameter_name),
|
||||
# Every combo comes out of the same synchronized cycle, so
|
||||
# they all share one window — no combo was measured earlier
|
||||
# or later than another here.
|
||||
capture_start_ns=sweep_start_ns,
|
||||
capture_end_ns=sweep_end_ns,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -368,6 +378,7 @@ class MultiDeviceLibreVnaService:
|
||||
|
||||
def _acquire_mock_collection(self, collection_id: int, capture_start_ns: int) -> SweepCollection:
|
||||
assert self._sweep_configuration is not None
|
||||
mock_sweep_start_ns = time.monotonic_ns()
|
||||
points = int(self._sweep_configuration.points)
|
||||
frequencies = np.linspace(
|
||||
self._sweep_configuration.start_hz,
|
||||
@@ -393,6 +404,8 @@ class MultiDeviceLibreVnaService:
|
||||
frequency_hz=frequencies,
|
||||
s11=s11,
|
||||
s21=s21,
|
||||
capture_start_ns=mock_sweep_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
)
|
||||
self._mock_phase += 0.05
|
||||
|
||||
@@ -183,7 +183,11 @@ class Sn9000Service:
|
||||
capture_start_ns = time.monotonic_ns()
|
||||
|
||||
s_parameters = self._query_sweep_s_parameters(points)
|
||||
traces = self._assemble_traces(s_parameters)
|
||||
traces = self._assemble_traces(
|
||||
s_parameters,
|
||||
sweep_start_ns=capture_start_ns,
|
||||
sweep_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
return SweepCollection(
|
||||
collection_id=int(collection_id),
|
||||
@@ -256,7 +260,13 @@ class Sn9000Service:
|
||||
def _uses_pyvisa_py_backend(self) -> bool:
|
||||
return self.visa_library == "@py" or self.visa_library.endswith("@py")
|
||||
|
||||
def _assemble_traces(self, s_parameters: dict[str, np.ndarray]) -> list[TraceData]:
|
||||
def _assemble_traces(
|
||||
self,
|
||||
s_parameters: dict[str, np.ndarray],
|
||||
*,
|
||||
sweep_start_ns: int,
|
||||
sweep_end_ns: int,
|
||||
) -> list[TraceData]:
|
||||
frequency_hz = self._require_frequency_axis()
|
||||
traces: list[TraceData] = []
|
||||
for output_position, output_port in enumerate(_OUTPUT_PORT_BY_INDEX):
|
||||
@@ -269,6 +279,10 @@ class Sn9000Service:
|
||||
frequency_hz=frequency_hz,
|
||||
s11=reflection,
|
||||
s21=transmission,
|
||||
# One triggered sweep produces every port pair at once, so
|
||||
# all combos share the sweep's window.
|
||||
capture_start_ns=int(sweep_start_ns),
|
||||
capture_end_ns=int(sweep_end_ns),
|
||||
)
|
||||
)
|
||||
return traces
|
||||
|
||||
@@ -140,7 +140,13 @@ class SwitchedMatrixRadarService:
|
||||
)
|
||||
|
||||
def _acquire_step_traces(self, out_k: int, in_k: int, collection_id: int) -> list[TraceData]:
|
||||
"""Drive both switches to one step, settle, and collect its widened traces."""
|
||||
"""Drive both switches to one step, settle, and collect its widened traces.
|
||||
|
||||
Every returned trace carries the monotonic window of the inner collection
|
||||
that produced it, so a consumer can tell when each combo of a switched
|
||||
matrix was really measured instead of only when the whole cycle began and
|
||||
ended. The switch drive and settling are deliberately outside the window.
|
||||
"""
|
||||
step_start_ns = time.monotonic_ns()
|
||||
if self.output_switch is not None:
|
||||
self.output_switch.switch_to(out_k)
|
||||
@@ -178,6 +184,11 @@ class SwitchedMatrixRadarService:
|
||||
input=in_k * self.inner_input_positions + int(trace.combo.input),
|
||||
output=out_k * self.inner_output_positions + int(trace.combo.output),
|
||||
),
|
||||
# Keep the inner service's own per-trace window when it reports one
|
||||
# (it knows its internal port order better than this step does);
|
||||
# otherwise fall back to the window of this inner collection.
|
||||
capture_start_ns=int(trace.capture_start_ns) or settled_ns,
|
||||
capture_end_ns=int(trace.capture_end_ns) or inner_end_ns,
|
||||
)
|
||||
for trace in sub.traces
|
||||
]
|
||||
|
||||
@@ -32,12 +32,22 @@ class ComboKey:
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TraceData:
|
||||
"""One frequency-domain trace set for a specific switch combination."""
|
||||
"""One frequency-domain trace set for a specific switch combination.
|
||||
|
||||
``capture_start_ns``/``capture_end_ns`` bound the monotonic window in which
|
||||
THIS trace's sweep was measured, excluding the switch drive and settling that
|
||||
preceded it. In switched modes a collection is assembled combo by combo over
|
||||
many milliseconds, so the collection-level window says nothing about when any
|
||||
individual combo was measured — these do. Zero on both means the producer did
|
||||
not report per-trace timing.
|
||||
"""
|
||||
|
||||
combo: ComboKey
|
||||
frequency_hz: np.ndarray
|
||||
s11: np.ndarray
|
||||
s21: np.ndarray
|
||||
capture_start_ns: int = 0
|
||||
capture_end_ns: int = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -54,12 +54,27 @@ def decode_trace_collection(payload: bytes, expected_magic: int) -> SweepCollect
|
||||
)
|
||||
)
|
||||
|
||||
# Optional trailer, written after the trace blocks by newer producers: the
|
||||
# collection capture window, then a per-trace window table. Both stages are
|
||||
# optional so payloads from an older producer still decode (the timestamps
|
||||
# simply stay zero).
|
||||
capture_start_ns = 0
|
||||
capture_end_ns = 0
|
||||
if cursor.remaining_bytes() == 16:
|
||||
if cursor.remaining_bytes() != 0:
|
||||
if cursor.remaining_bytes() < 16:
|
||||
raise ValueError("Truncated capture window in trace collection")
|
||||
capture_start_ns = cursor.read_u64()
|
||||
capture_end_ns = cursor.read_u64()
|
||||
elif cursor.remaining_bytes() != 0:
|
||||
|
||||
if cursor.remaining_bytes() != 0:
|
||||
trace_time_count = cursor.read_u32()
|
||||
if trace_time_count != len(traces):
|
||||
raise ValueError("Per-trace capture window count does not match trace count")
|
||||
for trace in traces:
|
||||
trace.capture_start_ns = cursor.read_u64()
|
||||
trace.capture_end_ns = cursor.read_u64()
|
||||
|
||||
if cursor.remaining_bytes() != 0:
|
||||
raise ValueError("Unexpected trailing bytes in trace collection")
|
||||
|
||||
return SweepCollection(
|
||||
|
||||
@@ -23,6 +23,9 @@ class TraceRecord:
|
||||
stage_index: int
|
||||
frequency_hz: np.ndarray
|
||||
samples: np.ndarray
|
||||
# End of this trace's own sweep, from the snapshot's per-trace metadata; 0 for a
|
||||
# snapshot recorded before per-trace timing existed.
|
||||
capture_end_ns: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -128,6 +131,7 @@ def _load_stage_records(
|
||||
stage_index=_parse_stage_index(collection_dir.name, fallback_idx),
|
||||
frequency_hz=frequency_hz,
|
||||
samples=samples,
|
||||
capture_end_ns=int(trace_meta.get("capture_end_ns", 0)),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -214,7 +218,15 @@ def _build_sweep_history(
|
||||
|
||||
start_freq_hz = float(base.frequency_hz[0])
|
||||
stop_freq_hz = float(base.frequency_hz[-1])
|
||||
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0 if base.monotonic_ns > 0 else float(fallback_index)
|
||||
# Prefer this 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)
|
||||
|
||||
history.append(
|
||||
{
|
||||
|
||||
@@ -184,12 +184,16 @@ def main() -> int:
|
||||
if collector_driven:
|
||||
# The collector already switched and tagged the sweep; just
|
||||
# read the clean capture for this combination.
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
sweep = radar.acquire(combo=(combo.input, combo.output))
|
||||
else:
|
||||
output_switch.switch_to(combo.output)
|
||||
input_switch.switch_to(combo.input)
|
||||
if config.runtime.settling_ms > 0:
|
||||
time.sleep(config.runtime.settling_ms / 1000.0)
|
||||
# Stamped after switching and settling so the window covers
|
||||
# the sweep alone, not the dead time before it.
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
sweep = radar.acquire()
|
||||
traces.append(
|
||||
TraceData(
|
||||
@@ -197,6 +201,8 @@ def main() -> int:
|
||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||
capture_start_ns=sweep_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
|
||||
|
||||
@@ -22,7 +22,14 @@ def _write_interleaved_complex(buffer: bytearray, values: np.ndarray) -> None:
|
||||
|
||||
|
||||
def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes:
|
||||
"""Serialize one raw/preprocessed trace collection into ring-compatible binary format."""
|
||||
"""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`
|
||||
and ``read_trace_collection`` in ``common_cpp/ipc/src/shared_types.cpp``.
|
||||
"""
|
||||
buffer = bytearray()
|
||||
buffer.extend(struct.pack("<IQQI", magic, collection.collection_id, collection.monotonic_ns, len(collection.traces)))
|
||||
|
||||
@@ -48,6 +55,11 @@ def serialize_trace_collection(collection: SweepCollection, magic: int) -> bytes
|
||||
int(collection.capture_end_ns),
|
||||
)
|
||||
)
|
||||
buffer.extend(struct.pack("<I", len(collection.traces)))
|
||||
for trace in collection.traces:
|
||||
buffer.extend(
|
||||
struct.pack("<QQ", int(trace.capture_start_ns), int(trace.capture_end_ns))
|
||||
)
|
||||
return bytes(buffer)
|
||||
|
||||
|
||||
|
||||
@@ -124,6 +124,17 @@ def save_trace_history_binary(stage_dir: Path, history: list[SweepCollection], m
|
||||
"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,
|
||||
),
|
||||
@@ -182,6 +193,10 @@ def save_trace_history_numpy(
|
||||
"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",
|
||||
|
||||
@@ -117,6 +117,8 @@ class NpzStore(StoreApi):
|
||||
{
|
||||
"input": trace.combo.input,
|
||||
"output": trace.combo.output,
|
||||
"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,
|
||||
@@ -177,6 +179,8 @@ class NpzStore(StoreApi):
|
||||
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)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ class TraceRecord:
|
||||
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.
|
||||
capture_end_ns: int = 0
|
||||
|
||||
|
||||
def _normalize_channel(channel: str) -> str:
|
||||
@@ -85,6 +89,7 @@ def _build_stage_records(
|
||||
stage_index=int(stage_index),
|
||||
frequency_hz=frequency_hz,
|
||||
samples=samples,
|
||||
capture_end_ns=int(trace.capture_end_ns),
|
||||
)
|
||||
)
|
||||
return records
|
||||
@@ -137,7 +142,15 @@ def _build_sweep_history(
|
||||
|
||||
start_freq_hz = float(base.frequency_hz[0])
|
||||
stop_freq_hz = float(base.frequency_hz[-1])
|
||||
timestamp_sec = float(base.monotonic_ns) / 1_000_000_000.0 if base.monotonic_ns > 0 else float(fallback_index)
|
||||
# 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)
|
||||
|
||||
history.append(
|
||||
{
|
||||
|
||||
@@ -39,12 +39,19 @@ from python_app.orchestration.shm.ring_writer import ShmRingWriter
|
||||
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection
|
||||
|
||||
|
||||
def _trace(in_pos: int, out_pos: int, n: int) -> TraceData:
|
||||
def _trace(in_pos: int, out_pos: int, n: int, *, capture_ns: tuple[int, int] = (0, 0)) -> TraceData:
|
||||
"""Build a trace with float32-exact data so round-trips compare exactly."""
|
||||
freq = np.arange(n, dtype=np.float32) + 1.0
|
||||
s11 = (np.arange(n, dtype=np.float32) + 0.5j * np.arange(n, dtype=np.float32)).astype(np.complex64)
|
||||
s21 = (-np.arange(n, dtype=np.float32) + 2.0j * np.arange(n, dtype=np.float32)).astype(np.complex64)
|
||||
return TraceData(combo=ComboKey(input=in_pos, output=out_pos), frequency_hz=freq, s11=s11, s21=s21)
|
||||
return TraceData(
|
||||
combo=ComboKey(input=in_pos, output=out_pos),
|
||||
frequency_hz=freq,
|
||||
s11=s11,
|
||||
s21=s21,
|
||||
capture_start_ns=capture_ns[0],
|
||||
capture_end_ns=capture_ns[1],
|
||||
)
|
||||
|
||||
|
||||
class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
@@ -52,7 +59,7 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
collection = SweepCollection(
|
||||
collection_id=7,
|
||||
monotonic_ns=123,
|
||||
traces=[_trace(0, 0, 4), _trace(3, 1, 2)],
|
||||
traces=[_trace(0, 0, 4, capture_ns=(11, 13)), _trace(3, 1, 2, capture_ns=(15, 19))],
|
||||
capture_start_ns=10,
|
||||
capture_end_ns=20,
|
||||
)
|
||||
@@ -66,6 +73,10 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
self.assertTrue(np.array_equal(got.frequency_hz, original.frequency_hz))
|
||||
self.assertTrue(np.array_equal(got.s11, original.s11))
|
||||
self.assertTrue(np.array_equal(got.s21, original.s21))
|
||||
self.assertEqual(
|
||||
(got.capture_start_ns, got.capture_end_ns),
|
||||
(original.capture_start_ns, original.capture_end_ns),
|
||||
)
|
||||
|
||||
def test_raw_round_trips(self) -> None:
|
||||
self._assert_round_trips(RAW_MAGIC)
|
||||
@@ -78,6 +89,34 @@ class TraceCollectionRoundTripTest(unittest.TestCase):
|
||||
decoded = decode_trace_collection(serialize_trace_collection(collection, RAW_MAGIC), RAW_MAGIC)
|
||||
self.assertEqual(decoded.traces, [])
|
||||
|
||||
def test_payload_without_per_trace_window_table_still_decodes(self) -> None:
|
||||
# A producer built before per-trace timing stops after the collection
|
||||
# window; its traces must still decode, with the timestamps left at zero.
|
||||
collection = SweepCollection(
|
||||
collection_id=4,
|
||||
monotonic_ns=5,
|
||||
traces=[_trace(1, 0, 3, capture_ns=(7, 9))],
|
||||
capture_start_ns=6,
|
||||
capture_end_ns=10,
|
||||
)
|
||||
full = serialize_trace_collection(collection, RAW_MAGIC)
|
||||
legacy = full[: -(4 + 16 * len(collection.traces))]
|
||||
|
||||
decoded = decode_trace_collection(legacy, RAW_MAGIC)
|
||||
self.assertEqual((decoded.capture_start_ns, decoded.capture_end_ns), (6, 10))
|
||||
self.assertEqual(len(decoded.traces), 1)
|
||||
self.assertEqual((decoded.traces[0].capture_start_ns, decoded.traces[0].capture_end_ns), (0, 0))
|
||||
|
||||
def test_per_trace_window_count_mismatch_is_rejected(self) -> None:
|
||||
collection = SweepCollection(
|
||||
collection_id=4, monotonic_ns=5, traces=[_trace(1, 0, 3, capture_ns=(7, 9))]
|
||||
)
|
||||
payload = serialize_trace_collection(collection, RAW_MAGIC)
|
||||
# Overwrite the window-table count (u32 before the single 16-byte pair).
|
||||
corrupt = payload[:-20] + struct.pack("<I", 2) + payload[-16:]
|
||||
with self.assertRaises(ValueError):
|
||||
decode_trace_collection(corrupt, RAW_MAGIC)
|
||||
|
||||
|
||||
class ResultCollectionRoundTripTest(unittest.TestCase):
|
||||
def test_all_payload_kinds_round_trip(self) -> None:
|
||||
|
||||
@@ -140,6 +140,36 @@ class SwitchedMatrixComboAcquisitionTest(unittest.TestCase):
|
||||
expected_combos,
|
||||
)
|
||||
|
||||
def test_each_switch_step_stamps_its_traces_with_its_own_capture_window(self) -> None:
|
||||
# The whole point of per-trace timing: three switch steps are measured one
|
||||
# after another, so their traces must NOT all share the collection window.
|
||||
service, _inner, _input_switch = _switched_service(input_steps=3)
|
||||
|
||||
collection = service.acquire_collection(collection_id=7)
|
||||
|
||||
windows_by_step: dict[int, set[tuple[int, int]]] = {}
|
||||
for trace in collection.traces:
|
||||
step = int(trace.combo.input) // _INNER_INPUTS
|
||||
windows_by_step.setdefault(step, set()).add(
|
||||
(int(trace.capture_start_ns), int(trace.capture_end_ns))
|
||||
)
|
||||
|
||||
self.assertEqual(sorted(windows_by_step), [0, 1, 2])
|
||||
for step, windows in windows_by_step.items():
|
||||
self.assertEqual(len(windows), 1, f"step {step} traces disagree on their window")
|
||||
start_ns, end_ns = next(iter(windows))
|
||||
self.assertGreater(start_ns, 0)
|
||||
self.assertGreaterEqual(end_ns, start_ns)
|
||||
# Each step's window sits inside the collection's.
|
||||
self.assertGreaterEqual(start_ns, collection.capture_start_ns)
|
||||
self.assertLessEqual(end_ns, collection.capture_end_ns)
|
||||
|
||||
# Steps are strictly ordered in time — the whole reason the collection-level
|
||||
# window cannot stand in for a per-combo timestamp.
|
||||
step_starts = [next(iter(windows_by_step[step]))[0] for step in sorted(windows_by_step)]
|
||||
self.assertEqual(step_starts, sorted(step_starts))
|
||||
self.assertGreater(len(set(step_starts)), 1)
|
||||
|
||||
|
||||
class ManualComboCaptureUsesTargetedAcquisitionTest(unittest.TestCase):
|
||||
"""The per-combo capture session must not sweep the full widened matrix."""
|
||||
|
||||
@@ -232,6 +232,7 @@ class MultiRadarSequentialCaptureSession:
|
||||
time.sleep(self._base_config.runtime.settling_ms / 1000.0)
|
||||
sweep_traces: list[TraceData] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
sweep = self._radar.acquire()
|
||||
sweep_traces.append(
|
||||
TraceData(
|
||||
@@ -239,6 +240,8 @@ class MultiRadarSequentialCaptureSession:
|
||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||
capture_start_ns=sweep_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
)
|
||||
trace = combine_traces_via_median(sweep_traces)
|
||||
|
||||
@@ -202,6 +202,7 @@ class SequentialCaptureSession:
|
||||
|
||||
sweep_traces: list[TraceData] = []
|
||||
for _ in range(self._median_sweep_count):
|
||||
sweep_start_ns = time.monotonic_ns()
|
||||
sweep = self._radar.acquire()
|
||||
sweep_traces.append(
|
||||
TraceData(
|
||||
@@ -209,6 +210,8 @@ class SequentialCaptureSession:
|
||||
frequency_hz=np.asarray(sweep.x, dtype=np.float32),
|
||||
s11=np.asarray(sweep.trace("s11"), dtype=np.complex64),
|
||||
s21=np.asarray(sweep.trace("s21"), dtype=np.complex64),
|
||||
capture_start_ns=sweep_start_ns,
|
||||
capture_end_ns=time.monotonic_ns(),
|
||||
)
|
||||
)
|
||||
trace = combine_traces_via_median(sweep_traces)
|
||||
@@ -385,11 +388,16 @@ def combine_traces_via_median(traces: list[TraceData]) -> TraceData:
|
||||
s21_median = (
|
||||
np.median(s21_stack.real, axis=0) + 1j * np.median(s21_stack.imag, axis=0)
|
||||
).astype(np.complex64)
|
||||
# The median is built from every input sweep, so its window spans all of them.
|
||||
capture_starts = [int(t.capture_start_ns) for t in traces if int(t.capture_start_ns) > 0]
|
||||
capture_ends = [int(t.capture_end_ns) for t in traces if int(t.capture_end_ns) > 0]
|
||||
return TraceData(
|
||||
combo=ComboKey(input=int(combo.input), output=int(combo.output)),
|
||||
frequency_hz=np.asarray(first.frequency_hz, dtype=np.float32),
|
||||
s11=s11_median,
|
||||
s21=s21_median,
|
||||
capture_start_ns=min(capture_starts) if capture_starts else 0,
|
||||
capture_end_ns=max(capture_ends) if capture_ends else 0,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user