new kamil adc
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
"""Wire protocol for the Kamil ADC collector's TTY stream.
|
||||
|
||||
The collector publishes a stream of 8-byte little-endian frames, four 16-bit
|
||||
words each: ``[marker, step, ch1, ch2]`` (``ch1``/``ch2`` signed). Three frame
|
||||
kinds appear in ``do8_freq_ref`` mode:
|
||||
|
||||
* **Sweep boundary** — ``marker, 0xFFFF, 0xFFFF, 0xFFFF``. Delimits sweeps.
|
||||
* **Main point** — ``0x000A, step, I, Q``. One complex main sample at ``step``.
|
||||
* **Reference point** — ``0x00A8, step, I, Q``. The reference sample paired with
|
||||
the main sample of the same ``step`` (emitted only where the DI8 loopback
|
||||
settled, so reference points are sparser than main points).
|
||||
|
||||
:class:`KamilAdcStreamParser` consumes raw bytes incrementally and yields one
|
||||
:class:`RawSweep` per completed sweep. Main and reference points are aligned by
|
||||
step index; only steps carrying *both* survive (a step needs its reference for
|
||||
the frequency axis and its main for the signal). Anything other than the three
|
||||
known frame kinds is a protocol violation and raises — the reader fails fast and
|
||||
lets the supervisor relaunch a clean collector rather than silently resyncing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Wire-format constants.
|
||||
FRAME_BYTES = 8
|
||||
MAIN_MARKER = 0x000A
|
||||
REFERENCE_MARKER = 0x00A8
|
||||
_BOUNDARY_STEP = 0xFFFF
|
||||
|
||||
# marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q.
|
||||
_FRAME = struct.Struct("<HHhh")
|
||||
# The last three words of a boundary frame are all 0xFFFF; a real step is
|
||||
# 1..0xFFFE, so this tail unambiguously marks a sweep boundary regardless of the
|
||||
# (profile-dependent) start marker.
|
||||
_BOUNDARY_TAIL = b"\xff\xff\xff\xff\xff\xff"
|
||||
# Frame-alignment anchor: the phase-profile sweep-boundary frame.
|
||||
_START_FRAME = struct.pack("<HHHH", MAIN_MARKER, 0xFFFF, 0xFFFF, 0xFFFF)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RawSweep:
|
||||
"""One sweep's main and reference samples, aligned and ordered by step.
|
||||
|
||||
``steps`` is strictly ascending; ``main`` and ``reference`` are the complex
|
||||
samples at those steps. All three arrays share the same length.
|
||||
"""
|
||||
|
||||
steps: np.ndarray
|
||||
main: np.ndarray
|
||||
reference: np.ndarray
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return int(self.steps.size)
|
||||
|
||||
|
||||
class KamilAdcStreamParser:
|
||||
"""Incremental, frame-aligning parser turning the TTY byte stream into sweeps.
|
||||
|
||||
Usage: call :meth:`feed` with each chunk of bytes; it returns the list of
|
||||
sweeps completed by that chunk (usually zero or one). The parser is stateful
|
||||
but holds no I/O and is cheap to unit-test.
|
||||
"""
|
||||
|
||||
__slots__ = ("_buffer", "_aligned", "_main", "_reference")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
self._aligned = False
|
||||
self._main: dict[int, complex] = {}
|
||||
self._reference: dict[int, complex] = {}
|
||||
|
||||
def feed(self, data: bytes) -> list[RawSweep]:
|
||||
"""Append ``data`` and return any sweeps completed by it."""
|
||||
self._buffer.extend(data)
|
||||
if not self._aligned and not self._align():
|
||||
return []
|
||||
|
||||
sweeps: list[RawSweep] = []
|
||||
buffer = self._buffer
|
||||
while len(buffer) >= FRAME_BYTES:
|
||||
frame = bytes(buffer[:FRAME_BYTES])
|
||||
del buffer[:FRAME_BYTES]
|
||||
if frame[2:] == _BOUNDARY_TAIL:
|
||||
sweep = self._take_sweep()
|
||||
if sweep is not None:
|
||||
sweeps.append(sweep)
|
||||
continue
|
||||
marker, step, real, imag = _FRAME.unpack(frame)
|
||||
if marker == MAIN_MARKER:
|
||||
self._main[step] = complex(real, imag)
|
||||
elif marker == REFERENCE_MARKER:
|
||||
self._reference[step] = complex(real, imag)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}"
|
||||
)
|
||||
return sweeps
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop all buffered state (e.g. after the collector is relaunched)."""
|
||||
self._buffer.clear()
|
||||
self._aligned = False
|
||||
self._main.clear()
|
||||
self._reference.clear()
|
||||
|
||||
def _align(self) -> bool:
|
||||
"""Discard pre-roll up to and including the first sweep boundary.
|
||||
|
||||
Returns ``True`` once frame-aligned. Keeps a short tail so a boundary
|
||||
split across two feeds can still be found on the next chunk.
|
||||
"""
|
||||
index = self._buffer.find(_START_FRAME)
|
||||
if index < 0:
|
||||
if len(self._buffer) >= FRAME_BYTES:
|
||||
del self._buffer[: -(FRAME_BYTES - 1)]
|
||||
return False
|
||||
del self._buffer[: index + FRAME_BYTES]
|
||||
self._main.clear()
|
||||
self._reference.clear()
|
||||
self._aligned = True
|
||||
return True
|
||||
|
||||
def _take_sweep(self) -> RawSweep | None:
|
||||
"""Assemble the buffered points into a sweep and reset for the next one."""
|
||||
shared = sorted(self._main.keys() & self._reference.keys())
|
||||
main = self._main
|
||||
reference = self._reference
|
||||
self._main = {}
|
||||
self._reference = {}
|
||||
if not shared:
|
||||
return None
|
||||
return RawSweep(
|
||||
steps=np.asarray(shared, dtype=np.int32),
|
||||
main=np.asarray([main[step] for step in shared], dtype=np.complex64),
|
||||
reference=np.asarray([reference[step] for step in shared], dtype=np.complex64),
|
||||
)
|
||||
Reference in New Issue
Block a user