added capture time for every sweep

This commit is contained in:
Ayzen
2026-09-03 17:30:19 +03:00
parent 8a52431bd3
commit 7997abe2d9
20 changed files with 271 additions and 17 deletions
@@ -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
+16 -2
View File
@@ -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
]
+11 -1
View File
@@ -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)
+17 -2
View File
@@ -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
+13 -1
View File
@@ -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)
+15
View File
@@ -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",
+4
View File
@@ -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)),
)
)
+14 -1
View File
@@ -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(
{
+42 -3
View File
@@ -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,
)