142 lines
5.3 KiB
Python
142 lines
5.3 KiB
Python
"""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),
|
|
)
|