diff --git a/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp b/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp index 93a29c9..de6548e 100644 --- a/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp +++ b/data_acq_and_processing/common_cpp/ipc/include/shared_types.hpp @@ -38,6 +38,13 @@ struct SweepTraceBlock { std::vector s11{}; // Complex S21 samples for matching frequency points. std::vector 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 { diff --git a/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp b/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp index fa45072..244e7ac 100644 --- a/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp +++ b/data_acq_and_processing/common_cpp/ipc/src/shared_types.cpp @@ -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(); collection.capture_end_ns = reader.read(); + if (reader.remaining_bytes() == 0U) { + return collection; + } + + const auto trace_time_count = reader.read(); + 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(); + trace.capture_end_ns = reader.read(); + } + return collection; } diff --git a/data_acq_and_processing/preprocessing/calibration_master/src/calibration_master.cpp b/data_acq_and_processing/preprocessing/calibration_master/src/calibration_master.cpp index f9ae484..0e5e126 100644 --- a/data_acq_and_processing/preprocessing/calibration_master/src/calibration_master.cpp +++ b/data_acq_and_processing/preprocessing/calibration_master/src/calibration_master.cpp @@ -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; } diff --git a/data_acq_and_processing/preprocessing/reference_master/src/reference_master.cpp b/data_acq_and_processing/preprocessing/reference_master/src/reference_master.cpp index afea007..accad52 100644 --- a/data_acq_and_processing/preprocessing/reference_master/src/reference_master.cpp +++ b/data_acq_and_processing/preprocessing/reference_master/src/reference_master.cpp @@ -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; } diff --git a/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp b/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp index 8a44b3c..76661ba 100644 --- a/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp +++ b/data_acq_and_processing/sweep_orchestrator/src/sweep_orchestrator.cpp @@ -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(sweep_points) * kBytesPerPoint); + constexpr std::size_t kTraceTrailerBytes = 16U; + const std::size_t per_trace = + kTraceHeaderBytes + (static_cast(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)); } diff --git a/python_app/hardware_full/multi_device_service.py b/python_app/hardware_full/multi_device_service.py index 7d10ba7..8d80dca 100644 --- a/python_app/hardware_full/multi_device_service.py +++ b/python_app/hardware_full/multi_device_service.py @@ -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 diff --git a/python_app/hardware_full/sn9000_service.py b/python_app/hardware_full/sn9000_service.py index d14e612..2da22b3 100644 --- a/python_app/hardware_full/sn9000_service.py +++ b/python_app/hardware_full/sn9000_service.py @@ -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 diff --git a/python_app/hardware_full/switched_matrix_radar_service.py b/python_app/hardware_full/switched_matrix_radar_service.py index 5b4437c..22ff2ad 100644 --- a/python_app/hardware_full/switched_matrix_radar_service.py +++ b/python_app/hardware_full/switched_matrix_radar_service.py @@ -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 ] diff --git a/python_app/models/dataset_model.py b/python_app/models/dataset_model.py index 9154144..b3a593e 100644 --- a/python_app/models/dataset_model.py +++ b/python_app/models/dataset_model.py @@ -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) diff --git a/python_app/orchestration/shm/decoder.py b/python_app/orchestration/shm/decoder.py index cfc3e6b..5ab3622 100644 --- a/python_app/orchestration/shm/decoder.py +++ b/python_app/orchestration/shm/decoder.py @@ -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( diff --git a/python_app/scripts/convert_snapshot_to_vna_history.py b/python_app/scripts/convert_snapshot_to_vna_history.py index 21449a9..828d992 100644 --- a/python_app/scripts/convert_snapshot_to_vna_history.py +++ b/python_app/scripts/convert_snapshot_to_vna_history.py @@ -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( { diff --git a/python_app/scripts/kamil_adc_raw_producer.py b/python_app/scripts/kamil_adc_raw_producer.py index c4280f9..b3b25dd 100644 --- a/python_app/scripts/kamil_adc_raw_producer.py +++ b/python_app/scripts/kamil_adc_raw_producer.py @@ -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 diff --git a/python_app/storage/npz/serialize.py b/python_app/storage/npz/serialize.py index 65848af..8f727b0 100644 --- a/python_app/storage/npz/serialize.py +++ b/python_app/storage/npz/serialize.py @@ -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(" bytes int(collection.capture_end_ns), ) ) + buffer.extend(struct.pack(" 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( { diff --git a/python_app/tests/test_shm_ipc.py b/python_app/tests/test_shm_ipc.py index 10f92f2..89f4a31 100644 --- a/python_app/tests/test_shm_ipc.py +++ b/python_app/tests/test_shm_ipc.py @@ -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(" None: diff --git a/python_app/tests/test_switched_matrix_capture.py b/python_app/tests/test_switched_matrix_capture.py index 49c030f..71fbf38 100644 --- a/python_app/tests/test_switched_matrix_capture.py +++ b/python_app/tests/test_switched_matrix_capture.py @@ -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.""" diff --git a/python_app/workflows/multi_radar_capture_workflow.py b/python_app/workflows/multi_radar_capture_workflow.py index 3a9cec5..db03007 100644 --- a/python_app/workflows/multi_radar_capture_workflow.py +++ b/python_app/workflows/multi_radar_capture_workflow.py @@ -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) diff --git a/python_app/workflows/sequential_capture_workflow.py b/python_app/workflows/sequential_capture_workflow.py index ad3f53a..bdd67f2 100644 --- a/python_app/workflows/sequential_capture_workflow.py +++ b/python_app/workflows/sequential_capture_workflow.py @@ -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, )