69 Commits

Author SHA1 Message Date
awe
16b08403e9 abibis 2026-06-09 20:59:51 +03:00
awe
05f8a8942b fix range 2026-06-09 19:42:40 +03:00
awe
52575e8312 fix method 2026-06-09 19:07:27 +03:00
awe
41274b6e39 fix first point 2026-06-09 19:00:40 +03:00
awe
1fd0f05d66 fix second graph 2026-06-09 18:56:40 +03:00
awe
8104ba6581 new method 2026-06-09 18:54:24 +03:00
awe
fe775f8ff1 fix range 2026-06-09 18:51:29 +03:00
awe
cbd0e40897 fix method 2026-06-09 18:48:45 +03:00
awe
f6dd20cdc9 range for phase diff 2026-06-09 18:47:17 +03:00
awe
0fa36a82b3 fix 2026-06-09 18:34:27 +03:00
awe
6b5dbb524e fix y 2026-06-09 18:32:26 +03:00
awe
09e6d5c566 fix graph range 2026-06-09 18:29:38 +03:00
awe
ec7b01f861 fix 2026-06-09 18:18:57 +03:00
awe
509e487464 fix 2026-06-09 18:13:01 +03:00
awe
4bc19e3db5 legacy parcer impl 2026-06-09 17:59:03 +03:00
awe
712bc16571 1 sweep per plot 2026-06-09 17:53:04 +03:00
awe
83c83262d4 fix 2026-06-09 17:50:19 +03:00
awe
66f57a9144 fix_S 2026-06-09 16:43:59 +03:00
awe
be5aaceca1 fix 2026-06-09 16:32:46 +03:00
awe
16fa20c8ee fix 2026-06-09 16:28:54 +03:00
awe
0032a74d0b fix 2026-06-09 16:20:33 +03:00
awe
3bb972b8d5 new graph 2026-06-09 16:16:05 +03:00
awe
d968545617 new test 2026-06-09 16:11:45 +03:00
awe
b0135f4a37 fix 2026-06-08 18:40:20 +03:00
awe
ed89a66bf2 fix x range 2026-06-08 18:36:14 +03:00
awe
6cc7a72087 fix x range 2026-06-08 18:31:24 +03:00
awe
29b621e591 fix 2026-06-08 18:15:46 +03:00
awe
3f64960542 new graphs 2026-06-08 16:03:54 +03:00
awe
8e5057aef6 amplitude for switch 2026-05-29 18:07:37 +03:00
awe
446a5c774e format 2026-05-29 17:51:28 +03:00
awe
a1a4f4f418 fix range 2026-05-29 17:31:16 +03:00
awe
08dc6b3a1f ch1 ch2 new 2026-05-29 17:15:32 +03:00
awe
5591e80c53 fix carry 2026-05-05 09:29:29 +03:00
awe
d437ab1642 fix 2026-05-04 23:55:25 +03:00
awe
16146eda1e fix 2026-05-04 21:26:08 +03:00
awe
3b5af19c6f fix 2026-04-30 18:02:28 +03:00
awe
d0809e6710 fix 2026-04-30 17:58:38 +03:00
awe
f9bca446b2 db format 2026-04-29 19:44:47 +03:00
awe
a136a6dbf9 fix 2026-04-29 19:31:15 +03:00
awe
1d807d0afc fix update 2026-04-29 19:08:17 +03:00
awe
ecd05568c6 fix warning 2026-04-29 18:27:00 +03:00
awe
9ff97bf737 test new variant 2026-04-28 19:32:10 +03:00
awe
ffb7dc3f25 fix upd speed waterflow 2026-04-27 19:07:01 +03:00
awe
75bc502fe1 fix ui 2026-04-27 18:28:56 +03:00
awe
c40df97085 ampl parser 2026-04-15 19:09:11 +03:00
awe
3cb3d1c31a voltage range 2026-04-14 20:39:44 +03:00
awe
d170fc11e5 fix 2026-04-14 19:59:48 +03:00
awe
2a65b7a92a fix freq range 2026-04-14 19:48:37 +03:00
awe
5aa4da9beb complex calib add 2026-04-14 19:47:28 +03:00
awe
cbd76cfd54 thinking fft 2026-04-13 14:15:56 +03:00
awe
70e18fa300 fix 2026-04-10 22:39:50 +03:00
awe
992ba88480 phase graph 2026-04-10 22:34:36 +03:00
awe
d0d2f5a59e low freq filter 2026-04-10 22:17:08 +03:00
awe
17540c3b11 fft new mode 2026-04-10 22:08:43 +03:00
awe
93823b9798 fix chan swap 2026-04-10 21:13:54 +03:00
awe
44a89b8da3 ch1 / ch2 add to pic 2026-04-10 21:02:14 +03:00
awe
0874a8aaf6 sqrt add 2026-04-10 20:43:11 +03:00
awe
fac0add45d new complex for --bin 2026-04-10 20:20:16 +03:00
awe
eee1039099 fix parser 2026-04-10 19:56:43 +03:00
3cd29c60d6 fix st 2026-04-10 18:01:43 +03:00
awe
934ca33d58 giga fix 2026-04-10 16:20:48 +03:00
awe
9aac162320 fix 2026-04-10 14:46:58 +03:00
awe
4dbedb48bc fix 2026-04-09 19:56:38 +03:00
awe
08823404c0 new logging 2026-04-09 19:47:30 +03:00
awe
bc48b9d432 try to speed up 2026-04-09 19:35:07 +03:00
awe
afd8538900 try new synchro method 2026-04-09 19:05:58 +03:00
awe
339cb85dce new e502 adc 2026-04-09 18:43:50 +03:00
awe
5152314f21 check 2026-03-26 20:01:56 +03:00
awe
64e66933e4 new adc 2026-03-25 18:54:59 +03:00
19 changed files with 6305 additions and 444 deletions

View File

@ -109,13 +109,23 @@ Legacy binary:
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --bin
```
`--bin` понимает mixed 8-байтный поток:
- `0x000A,step,ch1_i16,ch2_i16` для CH1/CH2 из `kamil_adc` (`tty:/tmp/ttyADC_data`)
- `0x00A3,step,ch1_i16,ch2_i16` и `0x00A4,step,ch1_i16,ch2_i16` для DO1 LOW/HIGH tagged fast-tty
- `0x001A,step,data_i16,0x0000` для логарифмического детектора
Для `0x000A` сырая кривая строится как `ch1^2 + ch2^2`, а FFT рассчитывается от комплексного сигнала `ch1 + i*ch2`.
Для `0x00A3/0x00A4` tagged-режим определяется автоматически: LOW/HIGH отображаются раздельно в raw/aux/phase, а waterfall/FFT/B-scan скрываются.
Для `0x001A` signed `data_i16` сначала переводится в В, затем raw отображается как `V`, а FFT рассчитывается от `exp(V)`.
Параметр `--tty-range-v` применяется к обоим типам `--bin`-данных.
Logscale binary с парой `int32`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --logscale
```
Logscale binary `16-bit x2`:
Complex binary `16-bit x2`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --parser_16_bit_x2
@ -127,6 +137,12 @@ Logscale binary `16-bit x2`:
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --parser_test
```
Комплексный ASCII-поток `step real imag`:
```bash
.venv/bin/python -m rfg_adc_plotter.main /dev/ttyACM0 --parser_complex_ascii
```
## Локальная проверка через replay_pty
Если есть лог-файл захвата, его можно воспроизвести как виртуальный последовательный порт.
@ -164,6 +180,12 @@ ssh 192.148.0.148 'ls -l /dev/ttyACM0'
Если на удаленной машине есть доступ к потоку, удобнее сохранять его в файл и уже этот файл гонять локально через `replay_pty.py`.
Для локального `tty`-потока из `kamil_adc` используйте:
```bash
.venv/bin/python -m rfg_adc_plotter.main /tmp/ttyADC_data --bin
```
## Проверка и тесты
Синтаксическая проверка:

View File

@ -55,6 +55,11 @@ def build_parser() -> argparse.ArgumentParser:
default="pg",
help="Совместимый флаг. Поддерживаются только auto и pg; mpl удален.",
)
parser.add_argument(
"--opengl",
action="store_true",
help="Включить OpenGL-ускорение для PyQtGraph. По умолчанию используется CPU-отрисовка.",
)
parser.add_argument(
"--norm-type",
choices=["projector", "simple"],
@ -66,14 +71,29 @@ def build_parser() -> argparse.ArgumentParser:
dest="bin_mode",
action="store_true",
help=(
"Бинарный протокол: старт свипа 0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A; "
"точки step,uint32(hi16,lo16),0x000A"
"8-байтный бинарный протокол: либо legacy старт "
"0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A и точки step,uint32(hi16,lo16),0x000A, "
"либо mixed поток 0x000A,step,ch1_i16,ch2_i16; "
"0x00A3/0x00A4,step,ch1_i16,ch2_i16 (DO1 LOW/HIGH tagged); "
"и 0x001A,step,data_i16,0x0000. "
"Для 0x000A: после парсинга int16 переводятся в В, "
"сырая кривая = ch1^2+ch2^2 (В^2), FFT вход = ch1+i*ch2 (В). "
"Для 0x00A3/0x00A4: auto-detect tagged режим с раздельным отображением LOW/HIGH в raw. "
"Для 0x001A: code_i16 переводится в В, raw = V, FFT вход = exp(V)"
),
)
parser.add_argument(
"--tty-range-v",
type=float,
default=5.0,
help=(
"Полный диапазон для пересчета tty int16 в напряжение ±V "
"(для --bin 0x000A CH1/CH2 и 0x001A log-detector, по умолчанию 5.0)"
),
)
parser.add_argument(
"--logscale",
action="store_true",
default=True,
help=(
"Новый бинарный протокол: точка несет пару int32 (avg_1, avg_2), "
"а свип считается как |10**(avg_1*0.001) - 10**(avg_2*0.001)|"
@ -83,18 +103,26 @@ def build_parser() -> argparse.ArgumentParser:
"--parser_16_bit_x2",
action="store_true",
help=(
"Бинарный logscale-протокол c парой int16 (avg_1, avg_2): "
"старт 0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A; точка step,avg1_lo16,avg2_lo16,0xFFFF"
"Бинарный complex-протокол c парой int16 (Re, Im): "
"старт 0xFFFF,0xFFFF,0xFFFF,(CH<<8)|0x0A; точка step,re_lo16,im_lo16,0xFFFF"
),
)
parser.add_argument(
"--parser_test",
action="store_true",
help=(
"Тестовый парсер для формата 16-bit x2: "
"Тестовый парсер для complex-формата 16-bit x2: "
"одиночный 0xFFFF завершает точку, серия 0xFFFF начинает новый свип"
),
)
parser.add_argument(
"--parser_complex_ascii",
action="store_true",
help=(
"ASCII-поток из трех чисел на строку: step real imag. "
"Новый свип определяется по сбросу/повтору step, FFT строится по комплексным данным"
),
)
parser.add_argument(
"--calibrate",
action="store_true",

View File

@ -1,11 +1,14 @@
"""Shared constants for sweep parsing and visualization."""
WF_WIDTH = 1000
FFT_LEN = 1024
FFT_LEN = 2048
BACKGROUND_MEDIAN_SWEEPS = 64
SWEEP_FREQ_MIN_GHZ = 3.3
SWEEP_FREQ_MAX_GHZ = 14.3
SWEEP_FREQ_MAX_GHZ = 6.3
PHASE_FREQ_MIN_GHZ = 2.046
PHASE_FREQ_MAX_GHZ = 5.612
LOG_BASE = 10.0
LOG_SCALER = 0.001

File diff suppressed because it is too large Load Diff

View File

@ -5,12 +5,22 @@ from __future__ import annotations
import math
import time
from collections import deque
from typing import List, Optional, Sequence, Set
from typing import Dict, List, Optional, Sequence, Set
import numpy as np
from rfg_adc_plotter.constants import DATA_INVERSION_THRESHOLD, LOG_BASE, LOG_EXP_LIMIT, LOG_POSTSCALER, LOG_SCALER
from rfg_adc_plotter.types import ParserEvent, PointEvent, StartEvent, SweepAuxCurves, SweepInfo, SweepPacket
from rfg_adc_plotter.types import (
BatchPointEvent,
Do1Level,
ParserEvent,
PointEvent,
SignalKind,
StartEvent,
SweepAuxCurves,
SweepInfo,
SweepPacket,
)
def u32_to_i32(value: int) -> int:
@ -32,6 +42,13 @@ def log_pair_to_sweep(avg_1: int, avg_2: int) -> float:
return abs(value_1 - value_2) * LOG_POSTSCALER
def tty_ch_pair_to_sweep(ch_1: int, ch_2: int) -> float:
"""Reduce a raw CH1/CH2 TTY point to power-like scalar ``ch1^2 + ch2^2``."""
ch_1_i = int(ch_1)
ch_2_i = int(ch_2)
return float((ch_1_i * ch_1_i) + (ch_2_i * ch_2_i))
class AsciiSweepParser:
"""Incremental parser for ASCII sweep streams."""
@ -82,16 +99,336 @@ class AsciiSweepParser:
return events
class LegacyBinaryParser:
"""Byte-resynchronizing parser for legacy 8-byte binary records."""
class ComplexAsciiSweepParser:
"""Incremental parser for ASCII ``step real imag`` streams."""
def __init__(self):
self._buf = bytearray()
self._last_step: Optional[int] = None
self._seen_points = False
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
events: List[ParserEvent] = []
while True:
nl = self._buf.find(b"\n")
if nl == -1:
break
line = bytes(self._buf[:nl])
del self._buf[: nl + 1]
if line.endswith(b"\r"):
line = line[:-1]
if not line:
continue
if line.lower().startswith(b"sweep_start"):
self._last_step = None
self._seen_points = False
events.append(StartEvent())
continue
parts = line.split()
if len(parts) < 3:
continue
try:
step = int(parts[0], 10)
real = float(parts[1])
imag = float(parts[2])
except Exception:
continue
if step < 0 or (not math.isfinite(real)) or (not math.isfinite(imag)):
continue
if self._seen_points and self._last_step is not None and step <= self._last_step:
events.append(StartEvent())
self._seen_points = True
self._last_step = step
events.append(
PointEvent(
ch=0,
x=step,
y=float(abs(complex(real, imag))),
aux=(float(real), float(imag)),
)
)
return events
class LegacyBinaryParser:
"""Byte-resynchronizing parser for supported 8-byte binary record formats."""
def __init__(self, *, batch_events: bool = False):
self._buf = bytearray()
self._last_step: Optional[int] = None
self._seen_points = False
self._mode: Optional[str] = None
self._current_signal_kind: Optional[SignalKind] = None
self._batch_events = bool(batch_events)
self._last_tagged_step_by_level: Dict[Do1Level, Optional[int]] = {
"low": None,
"high": None,
}
def _reset_tagged_steps(self) -> None:
self._last_tagged_step_by_level["low"] = None
self._last_tagged_step_by_level["high"] = None
@staticmethod
def _u16_at(buf: bytearray, offset: int) -> int:
return int(buf[offset]) | (int(buf[offset + 1]) << 8)
def _emit_legacy_start(self, events: List[ParserEvent], ch: int) -> None:
self._mode = "legacy"
self._last_step = None
self._seen_points = False
self._current_signal_kind = None
self._reset_tagged_steps()
events.append(StartEvent(ch=int(ch)))
def _emit_bin_start(self, events: List[ParserEvent], signal_kind: SignalKind) -> None:
self._mode = "bin"
self._last_step = None
self._seen_points = False
self._current_signal_kind = signal_kind
self._reset_tagged_steps()
events.append(StartEvent(ch=0, signal_kind=signal_kind))
def _emit_tty_start(self, events: List[ParserEvent]) -> None:
self._emit_bin_start(events, signal_kind="bin_iq")
def _emit_legacy_point(self, events: List[ParserEvent], step: int, value_word_hi: int, value_word_lo: int, ch: int) -> None:
self._mode = "legacy"
self._current_signal_kind = None
self._reset_tagged_steps()
if self._seen_points and self._last_step is not None and step <= self._last_step:
events.append(StartEvent(ch=int(ch)))
self._seen_points = True
self._last_step = int(step)
value = u32_to_i32((int(value_word_hi) << 16) | int(value_word_lo))
events.append(PointEvent(ch=int(ch), x=int(step), y=float(value)))
def _prepare_bin_point(
self,
events: List[ParserEvent],
step: int,
signal_kind: SignalKind,
do1_level: Optional[Do1Level] = None,
) -> None:
self._mode = "bin"
if self._current_signal_kind != signal_kind:
if self._seen_points:
events.append(StartEvent(ch=0, signal_kind=signal_kind))
self._last_step = None
self._seen_points = False
self._current_signal_kind = signal_kind
self._reset_tagged_steps()
if signal_kind == "bin_iq_do1_tagged":
level: Do1Level = "high" if do1_level == "high" else "low"
last_level_step = self._last_tagged_step_by_level[level]
if self._seen_points and last_level_step is not None and step <= last_level_step:
events.append(StartEvent(ch=0, signal_kind=signal_kind))
self._last_step = None
self._seen_points = False
self._reset_tagged_steps()
self._seen_points = True
self._last_tagged_step_by_level[level] = int(step)
self._last_step = int(step)
return
if self._seen_points and self._last_step is not None and step <= self._last_step:
events.append(StartEvent(ch=0, signal_kind=signal_kind))
self._last_step = None
self._seen_points = False
self._seen_points = True
self._last_step = int(step)
self._reset_tagged_steps()
def _emit_tty_point(self, events: List[ParserEvent], step: int, ch_1_word: int, ch_2_word: int) -> None:
self._prepare_bin_point(events, step=int(step), signal_kind="bin_iq")
ch_1 = u16_to_i16(int(ch_1_word))
ch_2 = u16_to_i16(int(ch_2_word))
events.append(
PointEvent(
ch=0,
x=int(step),
y=tty_ch_pair_to_sweep(ch_1, ch_2),
aux=(float(ch_1), float(ch_2)),
signal_kind="bin_iq",
)
)
def _emit_tty_tagged_point(
self,
events: List[ParserEvent],
step: int,
ch_1_word: int,
ch_2_word: int,
do1_level: Do1Level,
) -> None:
self._prepare_bin_point(
events,
step=int(step),
signal_kind="bin_iq_do1_tagged",
do1_level=do1_level,
)
ch_1 = u16_to_i16(int(ch_1_word))
ch_2 = u16_to_i16(int(ch_2_word))
events.append(
PointEvent(
ch=0,
x=int(step),
y=tty_ch_pair_to_sweep(ch_1, ch_2),
aux=(float(ch_1), float(ch_2)),
signal_kind="bin_iq_do1_tagged",
do1_level=do1_level,
)
)
def _emit_secondary_point(
self,
events: List[ParserEvent],
step: int,
ch_1_word: int,
ch_2_word: int,
) -> None:
self._mode = "bin"
self._current_signal_kind = self._current_signal_kind or "bin_iq"
ch_1 = u16_to_i16(int(ch_1_word))
ch_2 = u16_to_i16(int(ch_2_word))
events.append(
PointEvent(
ch=0,
x=int(step),
y=0.0,
aux=(float(ch_1), float(ch_2)),
signal_kind="bin_iq",
is_secondary=True,
)
)
def _emit_logdet_point(self, events: List[ParserEvent], step: int, value_word: int) -> None:
self._prepare_bin_point(events, step=int(step), signal_kind="bin_logdet")
value = u16_to_i16(int(value_word))
events.append(
PointEvent(
ch=0,
x=int(step),
y=float(value),
signal_kind="bin_logdet",
)
)
def _try_emit_tty_batch(self, events: List[ParserEvent], *, require_not_legacy: bool) -> bool:
if not self._batch_events or len(self._buf) < 8:
return False
block_count = len(self._buf) // 8
if block_count <= 0:
return False
raw = np.frombuffer(self._buf, dtype=np.uint8, count=block_count * 8).reshape(-1, 8)
words = np.frombuffer(self._buf, dtype="<u2", count=block_count * 4).reshape(-1, 4)
w0 = words[:, 0]
w1 = words[:, 1]
is_tty_point = (w0 == 0x000A) & (w1 != 0xFFFF)
is_sec_point = (w0 == 0x00A8) & (w1 != 0xFFFF)
is_legacy_point = (raw[:, 6] == 0x0A) & (w0 != 0xFFFF)
valid = is_tty_point | is_sec_point
if require_not_legacy:
valid = valid & (~is_legacy_point)
if valid.size <= 0 or not bool(valid[0]):
return False
invalid_idx = np.nonzero(~valid)[0]
valid_count = int(invalid_idx[0]) if invalid_idx.size > 0 else int(valid.size)
if valid_count <= 0:
return False
primary_mask = is_tty_point[:valid_count]
secondary_mask = is_sec_point[:valid_count]
primary_indices = np.nonzero(primary_mask)[0]
if primary_indices.size <= 0:
# No primary records in this block — cannot batch
return False
primary_steps = words[:valid_count, 1][primary_mask].astype(np.int64, copy=True)
if self._current_signal_kind != "bin_iq":
if self._seen_points:
events.append(StartEvent(ch=0, signal_kind="bin_iq"))
self._last_step = None
self._seen_points = False
self._current_signal_kind = "bin_iq"
self._reset_tagged_steps()
if self._seen_points and self._last_step is not None and primary_steps[0] <= int(self._last_step):
events.append(StartEvent(ch=0, signal_kind="bin_iq"))
self._last_step = None
self._seen_points = False
self._reset_tagged_steps()
reset_idx = np.nonzero(np.diff(primary_steps) <= 0)[0]
if reset_idx.size > 0:
reset_primary_pos = int(reset_idx[0] + 1)
if reset_primary_pos < primary_indices.size:
take_count = int(primary_indices[reset_primary_pos])
else:
take_count = valid_count
else:
take_count = valid_count
if take_count <= 0:
return False
primary_mask_take = is_tty_point[:take_count]
secondary_mask_take = is_sec_point[:take_count]
batch_words = words[:take_count].copy()
del raw
del words
del w0
del w1
del self._buf[: take_count * 8]
if np.any(primary_mask_take):
p_words = batch_words[primary_mask_take]
p_xs = p_words[:, 1].astype(np.int64, copy=False)
p_ch1 = p_words[:, 2].astype(np.uint16, copy=False).view(np.int16)
p_ch2 = p_words[:, 3].astype(np.uint16, copy=False).view(np.int16)
p_ch1_i64 = p_ch1.astype(np.int64)
p_ch2_i64 = p_ch2.astype(np.int64)
p_ys = ((p_ch1_i64 * p_ch1_i64) + (p_ch2_i64 * p_ch2_i64)).astype(np.float32)
self._mode = "bin"
self._seen_points = True
self._last_step = int(p_xs[-1])
self._current_signal_kind = "bin_iq"
self._reset_tagged_steps()
events.append(
BatchPointEvent(
ch=0,
xs=p_xs,
ys=p_ys,
aux=(p_ch1.astype(np.float32), p_ch2.astype(np.float32)),
signal_kind="bin_iq",
)
)
if np.any(secondary_mask_take):
s_words = batch_words[secondary_mask_take]
s_xs = s_words[:, 1].astype(np.int64, copy=False)
s_ch1 = s_words[:, 2].astype(np.uint16, copy=False).view(np.int16)
s_ch2 = s_words[:, 3].astype(np.uint16, copy=False).view(np.int16)
events.append(
BatchPointEvent(
ch=0,
xs=s_xs,
ys=np.zeros(s_xs.size, dtype=np.float32),
aux=(s_ch1.astype(np.float32), s_ch2.astype(np.float32)),
signal_kind="bin_iq",
is_secondary=True,
)
)
return True
def feed(self, data: bytes) -> List[ParserEvent]:
if data:
self._buf += data
@ -100,16 +437,171 @@ class LegacyBinaryParser:
w0 = self._u16_at(self._buf, 0)
w1 = self._u16_at(self._buf, 2)
w2 = self._u16_at(self._buf, 4)
if w0 == 0xFFFF and w1 == 0xFFFF and w2 == 0xFFFF and self._buf[6] == 0x0A:
events.append(StartEvent(ch=int(self._buf[7])))
w3 = self._u16_at(self._buf, 6)
is_legacy_start = (w0 == 0xFFFF and w1 == 0xFFFF and w2 == 0xFFFF and self._buf[6] == 0x0A)
is_tty_start = (w0 == 0x000A and w1 == 0xFFFF and w2 == 0xFFFF and w3 == 0xFFFF)
is_legacy_point = (self._buf[6] == 0x0A and w0 != 0xFFFF)
is_tty_point = (w0 == 0x000A and w1 != 0xFFFF)
is_tty_tagged_low_point = (w0 == 0x00A3 and w1 != 0xFFFF)
is_tty_tagged_high_point = (w0 == 0x00A4 and w1 != 0xFFFF)
is_logdet_point = (w0 == 0x001A and w3 == 0x0000)
is_secondary_point = (w0 == 0x00A8 and w1 != 0xFFFF)
if is_legacy_start:
self._emit_legacy_start(events, ch=int(self._buf[7]))
del self._buf[:8]
continue
if self._buf[6] == 0x0A:
ch = int(self._buf[7])
value = u32_to_i32((w1 << 16) | w2)
events.append(PointEvent(ch=ch, x=int(w0), y=float(value)))
if is_tty_start:
self._emit_tty_start(events)
del self._buf[:8]
continue
if is_logdet_point:
self._emit_logdet_point(events, step=int(w1), value_word=int(w2))
del self._buf[:8]
continue
if self._mode == "legacy":
if (is_tty_point or is_secondary_point) and (not is_legacy_point) and self._try_emit_tty_batch(events, require_not_legacy=True):
continue
if is_legacy_point:
self._emit_legacy_point(
events,
step=int(w0),
value_word_hi=int(w1),
value_word_lo=int(w2),
ch=int(self._buf[7]),
)
del self._buf[:8]
continue
if is_tty_point and (not is_legacy_point):
self._emit_tty_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3))
del self._buf[:8]
continue
if is_tty_tagged_low_point and (not is_legacy_point):
self._emit_tty_tagged_point(
events,
step=int(w1),
ch_1_word=int(w2),
ch_2_word=int(w3),
do1_level="low",
)
del self._buf[:8]
continue
if is_tty_tagged_high_point and (not is_legacy_point):
self._emit_tty_tagged_point(
events,
step=int(w1),
ch_1_word=int(w2),
ch_2_word=int(w3),
do1_level="high",
)
del self._buf[:8]
continue
if is_secondary_point and (not is_legacy_point):
self._emit_secondary_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3))
del self._buf[:8]
continue
del self._buf[:1]
continue
if self._mode == "bin":
if (is_tty_point or is_secondary_point) and self._try_emit_tty_batch(events, require_not_legacy=False):
continue
if is_tty_point:
self._emit_tty_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3))
del self._buf[:8]
continue
if is_tty_tagged_low_point:
self._emit_tty_tagged_point(
events,
step=int(w1),
ch_1_word=int(w2),
ch_2_word=int(w3),
do1_level="low",
)
del self._buf[:8]
continue
if is_tty_tagged_high_point:
self._emit_tty_tagged_point(
events,
step=int(w1),
ch_1_word=int(w2),
ch_2_word=int(w3),
do1_level="high",
)
del self._buf[:8]
continue
if is_secondary_point:
self._emit_secondary_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3))
del self._buf[:8]
continue
if is_legacy_point and (not is_tty_point):
self._emit_legacy_point(
events,
step=int(w0),
value_word_hi=int(w1),
value_word_lo=int(w2),
ch=int(self._buf[7]),
)
del self._buf[:8]
continue
del self._buf[:1]
continue
# Mode is still unknown. Accept only unambiguous point shapes to avoid
# jumping between tty and legacy interpretations on coincidental bytes.
if (is_tty_point or is_secondary_point) and (not is_legacy_point):
if self._try_emit_tty_batch(events, require_not_legacy=True):
continue
if is_secondary_point:
self._emit_secondary_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3))
del self._buf[:8]
continue
self._emit_tty_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3))
del self._buf[:8]
continue
if is_tty_tagged_low_point and (not is_legacy_point):
self._emit_tty_tagged_point(
events,
step=int(w1),
ch_1_word=int(w2),
ch_2_word=int(w3),
do1_level="low",
)
del self._buf[:8]
continue
if is_tty_tagged_high_point and (not is_legacy_point):
self._emit_tty_tagged_point(
events,
step=int(w1),
ch_1_word=int(w2),
ch_2_word=int(w3),
do1_level="high",
)
del self._buf[:8]
continue
if is_secondary_point and (not is_legacy_point):
self._emit_secondary_point(events, step=int(w1), ch_1_word=int(w2), ch_2_word=int(w3))
del self._buf[:8]
continue
if is_legacy_point and (not is_tty_point):
self._emit_legacy_point(
events,
step=int(w0),
value_word_hi=int(w1),
value_word_lo=int(w2),
ch=int(self._buf[7]),
)
del self._buf[:8]
continue
del self._buf[:1]
return events
@ -119,6 +611,8 @@ class LogScaleBinaryParser32:
def __init__(self):
self._buf = bytearray()
self._last_step: Optional[int] = None
self._seen_points = False
@staticmethod
def _u16_at(buf: bytearray, offset: int) -> int:
@ -131,11 +625,17 @@ class LogScaleBinaryParser32:
while len(self._buf) >= 12:
words = [self._u16_at(self._buf, idx * 2) for idx in range(6)]
if words[0:5] == [0xFFFF] * 5 and (words[5] & 0x00FF) == 0x000A:
self._last_step = None
self._seen_points = False
events.append(StartEvent(ch=int((words[5] >> 8) & 0x00FF)))
del self._buf[:12]
continue
if (words[5] & 0x00FF) == 0x000A and words[0] != 0xFFFF:
ch = int((words[5] >> 8) & 0x00FF)
if self._seen_points and self._last_step is not None and words[0] <= self._last_step:
events.append(StartEvent(ch=ch))
self._seen_points = True
self._last_step = int(words[0])
avg_1 = u32_to_i32((words[1] << 16) | words[2])
avg_2 = u32_to_i32((words[3] << 16) | words[4])
events.append(
@ -158,6 +658,8 @@ class LogScale16BitX2BinaryParser:
def __init__(self):
self._buf = bytearray()
self._current_channel = 0
self._last_step: Optional[int] = None
self._seen_points = False
@staticmethod
def _u16_at(buf: bytearray, offset: int) -> int:
@ -171,18 +673,24 @@ class LogScale16BitX2BinaryParser:
words = [self._u16_at(self._buf, idx * 2) for idx in range(4)]
if words[0:3] == [0xFFFF, 0xFFFF, 0xFFFF] and (words[3] & 0x00FF) == 0x000A:
self._current_channel = int((words[3] >> 8) & 0x00FF)
self._last_step = None
self._seen_points = False
events.append(StartEvent(ch=self._current_channel))
del self._buf[:8]
continue
if words[3] == 0xFFFF and words[0] != 0xFFFF:
avg_1 = u16_to_i16(words[1])
avg_2 = u16_to_i16(words[2])
if self._seen_points and self._last_step is not None and words[0] <= self._last_step:
events.append(StartEvent(ch=self._current_channel))
self._seen_points = True
self._last_step = int(words[0])
real = u16_to_i16(words[1])
imag = u16_to_i16(words[2])
events.append(
PointEvent(
ch=self._current_channel,
x=int(words[0]),
y=log_pair_to_sweep(avg_1, avg_2),
aux=(float(avg_1), float(avg_2)),
y=float(abs(complex(real, imag))),
aux=(float(real), float(imag)),
)
)
del self._buf[:8]
@ -212,14 +720,14 @@ class ParserTestStreamParser:
return None
if self._expected_step is not None and step < self._expected_step:
return None
avg_1 = u16_to_i16(int(self._point_buf[1]))
avg_2 = u16_to_i16(int(self._point_buf[2]))
real = u16_to_i16(int(self._point_buf[1]))
imag = u16_to_i16(int(self._point_buf[2]))
self._expected_step = step + 1
return PointEvent(
ch=self._current_channel,
x=step,
y=log_pair_to_sweep(avg_1, avg_2),
aux=(float(avg_1), float(avg_2)),
y=float(abs(complex(real, imag))),
aux=(float(real), float(imag)),
)
def feed(self, data: bytes) -> List[ParserEvent]:
@ -298,15 +806,45 @@ class SweepAssembler:
self._ys: list[float] = []
self._aux_1: list[float] = []
self._aux_2: list[float] = []
self._tagged_low_xs: list[int] = []
self._tagged_low_ys: list[float] = []
self._tagged_low_aux_1: list[float] = []
self._tagged_low_aux_2: list[float] = []
self._tagged_high_xs: list[int] = []
self._tagged_high_ys: list[float] = []
self._tagged_high_aux_1: list[float] = []
self._tagged_high_aux_2: list[float] = []
self._secondary_xs: list[int] = []
self._secondary_aux_1: list[float] = []
self._secondary_aux_2: list[float] = []
self._cur_channel: Optional[int] = None
self._cur_signal_kind: Optional[SignalKind] = None
self._cur_channels: set[int] = set()
def _reset_secondary_current(self) -> None:
self._secondary_xs.clear()
self._secondary_aux_1.clear()
self._secondary_aux_2.clear()
def _reset_tagged_current(self) -> None:
self._tagged_low_xs.clear()
self._tagged_low_ys.clear()
self._tagged_low_aux_1.clear()
self._tagged_low_aux_2.clear()
self._tagged_high_xs.clear()
self._tagged_high_ys.clear()
self._tagged_high_aux_1.clear()
self._tagged_high_aux_2.clear()
def _reset_current(self) -> None:
self._xs.clear()
self._ys.clear()
self._aux_1.clear()
self._aux_2.clear()
self._reset_tagged_current()
self._reset_secondary_current()
self._cur_channel = None
self._cur_signal_kind = None
self._cur_channels.clear()
def _scatter(self, xs: Sequence[int], values: Sequence[float], width: int) -> np.ndarray:
@ -339,24 +877,167 @@ class SweepAssembler:
if last_idx < series.size - 1:
series[last_idx + 1 :] = series[last_idx]
@staticmethod
def _nanmean_pair(primary: np.ndarray, secondary: np.ndarray) -> np.ndarray:
width = min(primary.size, secondary.size)
if width <= 0:
return np.zeros((0,), dtype=np.float32)
first = np.asarray(primary[:width], dtype=np.float32)
second = np.asarray(secondary[:width], dtype=np.float32)
out = np.full((width,), np.nan, dtype=np.float32)
first_valid = np.isfinite(first)
second_valid = np.isfinite(second)
both_valid = first_valid & second_valid
only_first = first_valid & (~second_valid)
only_second = second_valid & (~first_valid)
out[only_first] = first[only_first]
out[only_second] = second[only_second]
out[both_valid] = (first[both_valid] + second[both_valid]) * 0.5
return out
def _has_current_points(self) -> bool:
return bool(self._xs or self._tagged_low_xs or self._tagged_high_xs)
def _consume_batch(self, event: BatchPointEvent) -> Optional[SweepPacket]:
xs_arr = np.asarray(event.xs, dtype=np.int64).reshape(-1)
ys_arr = np.asarray(event.ys, dtype=np.float32).reshape(-1)
width = min(xs_arr.size, ys_arr.size)
if width <= 0:
return None
point_ch = int(event.ch)
point_signal_kind = event.signal_kind
packet: Optional[SweepPacket] = None
if self._cur_channel is None:
self._cur_channel = point_ch
elif point_ch != self._cur_channel:
if self._has_current_points():
packet = self.finalize_current()
self._reset_current()
self._cur_channel = point_ch
if self._cur_signal_kind != point_signal_kind:
if self._has_current_points():
packet = self.finalize_current()
self._reset_current()
self._cur_channel = point_ch
self._cur_signal_kind = point_signal_kind
self._cur_channels.add(point_ch)
xs = xs_arr[:width]
ys = ys_arr[:width]
self._xs.extend(xs.tolist())
self._ys.extend(ys.tolist())
if self._cur_signal_kind == "bin_iq_do1_tagged":
level = "high" if event.do1_level == "high" else "low"
if level == "low":
self._tagged_low_xs.extend(xs.tolist())
self._tagged_low_ys.extend(ys.tolist())
else:
self._tagged_high_xs.extend(xs.tolist())
self._tagged_high_ys.extend(ys.tolist())
if event.aux is not None:
try:
aux_1, aux_2 = event.aux
aux_1_arr = np.asarray(aux_1, dtype=np.float32).reshape(-1)
aux_2_arr = np.asarray(aux_2, dtype=np.float32).reshape(-1)
aux_width = min(width, aux_1_arr.size, aux_2_arr.size)
except Exception:
aux_width = 0
if aux_width > 0:
if self._cur_signal_kind == "bin_iq_do1_tagged":
if event.do1_level == "high":
self._tagged_high_aux_1.extend(aux_1_arr[:aux_width].tolist())
self._tagged_high_aux_2.extend(aux_2_arr[:aux_width].tolist())
else:
self._tagged_low_aux_1.extend(aux_1_arr[:aux_width].tolist())
self._tagged_low_aux_2.extend(aux_2_arr[:aux_width].tolist())
else:
self._aux_1.extend(aux_1_arr[:aux_width].tolist())
self._aux_2.extend(aux_2_arr[:aux_width].tolist())
return packet
def _consume_secondary_point(self, event: PointEvent) -> None:
self._secondary_xs.append(int(event.x))
if event.aux is not None:
self._secondary_aux_1.append(float(event.aux[0]))
self._secondary_aux_2.append(float(event.aux[1]))
def _consume_secondary_batch(self, event: BatchPointEvent) -> None:
xs_arr = np.asarray(event.xs, dtype=np.int64).reshape(-1)
width = xs_arr.size
if width <= 0:
return
self._secondary_xs.extend(xs_arr.tolist())
if event.aux is not None:
try:
aux_1, aux_2 = event.aux
aux_1_arr = np.asarray(aux_1, dtype=np.float32).reshape(-1)
aux_2_arr = np.asarray(aux_2, dtype=np.float32).reshape(-1)
aux_width = min(width, aux_1_arr.size, aux_2_arr.size)
except Exception:
aux_width = 0
if aux_width > 0:
self._secondary_aux_1.extend(aux_1_arr[:aux_width].tolist())
self._secondary_aux_2.extend(aux_2_arr[:aux_width].tolist())
def consume(self, event: ParserEvent) -> Optional[SweepPacket]:
if isinstance(event, StartEvent):
packet = self.finalize_current()
self._reset_current()
if event.ch is not None:
self._cur_channel = int(event.ch)
self._cur_channels.add(int(event.ch))
self._cur_signal_kind = event.signal_kind
return packet
if isinstance(event, BatchPointEvent):
if event.is_secondary:
self._consume_secondary_batch(event)
return None
return self._consume_batch(event)
if isinstance(event, PointEvent) and event.is_secondary:
self._consume_secondary_point(event)
return None
point_ch = int(event.ch)
point_signal_kind = event.signal_kind
packet: Optional[SweepPacket] = None
if self._cur_channel is None:
self._cur_channel = int(event.ch)
self._cur_channels.add(int(event.ch))
self._cur_channel = point_ch
elif point_ch != self._cur_channel:
if self._xs:
# Never mix channels in a single sweep packet: otherwise
# identical step indexes can overwrite each other.
packet = self.finalize_current()
self._reset_current()
self._cur_channel = point_ch
if self._cur_signal_kind != point_signal_kind:
if self._xs:
packet = self.finalize_current()
self._reset_current()
self._cur_channel = point_ch
self._cur_signal_kind = point_signal_kind
self._cur_channels.add(point_ch)
self._xs.append(int(event.x))
self._ys.append(float(event.y))
if event.aux is not None:
if self._cur_signal_kind == "bin_iq_do1_tagged":
level = "high" if event.do1_level == "high" else "low"
if level == "low":
self._tagged_low_xs.append(int(event.x))
self._tagged_low_ys.append(float(event.y))
if event.aux is not None:
self._tagged_low_aux_1.append(float(event.aux[0]))
self._tagged_low_aux_2.append(float(event.aux[1]))
else:
self._tagged_high_xs.append(int(event.x))
self._tagged_high_ys.append(float(event.y))
if event.aux is not None:
self._tagged_high_aux_1.append(float(event.aux[0]))
self._tagged_high_aux_2.append(float(event.aux[1]))
elif event.aux is not None:
self._aux_1.append(float(event.aux[0]))
self._aux_2.append(float(event.aux[1]))
return None
return packet
def finalize_current(self) -> Optional[SweepPacket]:
if not self._xs:
@ -368,13 +1049,37 @@ class SweepAssembler:
self._max_width = max(self._max_width, width)
target_width = self._max_width if self._fancy else width
sweep = self._scatter(self._xs, self._ys, target_width)
aux_curves: SweepAuxCurves = None
if self._aux_1 and self._aux_2 and len(self._aux_1) == len(self._xs):
aux_curves = (
self._scatter(self._xs, self._aux_1, target_width),
self._scatter(self._xs, self._aux_2, target_width),
)
do1_tagged_payload = None
if self._cur_signal_kind == "bin_iq_do1_tagged":
raw_low = self._scatter(self._tagged_low_xs, self._tagged_low_ys, target_width)
raw_high = self._scatter(self._tagged_high_xs, self._tagged_high_ys, target_width)
sweep = self._nanmean_pair(raw_low, raw_high)
aux_low = None
if self._tagged_low_aux_1 and self._tagged_low_aux_2 and len(self._tagged_low_aux_1) == len(self._tagged_low_xs):
aux_low = (
self._scatter(self._tagged_low_xs, self._tagged_low_aux_1, target_width),
self._scatter(self._tagged_low_xs, self._tagged_low_aux_2, target_width),
)
aux_high = None
if self._tagged_high_aux_1 and self._tagged_high_aux_2 and len(self._tagged_high_aux_1) == len(self._tagged_high_xs):
aux_high = (
self._scatter(self._tagged_high_xs, self._tagged_high_aux_1, target_width),
self._scatter(self._tagged_high_xs, self._tagged_high_aux_2, target_width),
)
do1_tagged_payload = {
"raw_low": raw_low,
"raw_high": raw_high,
"aux_low": aux_low,
"aux_high": aux_high,
}
else:
sweep = self._scatter(self._xs, self._ys, target_width)
if self._aux_1 and self._aux_2 and len(self._aux_1) == len(self._xs):
aux_curves = (
self._scatter(self._xs, self._aux_1, target_width),
self._scatter(self._xs, self._aux_2, target_width),
)
n_valid_cur = int(np.count_nonzero(np.isfinite(sweep)))
@ -417,6 +1122,7 @@ class SweepAssembler:
"sweep": self._sweep_idx,
"ch": ch_primary,
"chs": ch_list,
"signal_kind": self._cur_signal_kind,
"n_valid": n_valid,
"min": vmin,
"max": vmax,
@ -424,4 +1130,16 @@ class SweepAssembler:
"std": std,
"dt_ms": dt_ms,
}
if do1_tagged_payload is not None:
info["_do1_tagged_payload"] = do1_tagged_payload
if (
self._secondary_xs
and self._secondary_aux_1
and self._secondary_aux_2
and len(self._secondary_aux_1) == len(self._secondary_xs)
):
info["_secondary_payload"] = {
"ch1": self._scatter(self._secondary_xs, self._secondary_aux_1, target_width),
"ch2": self._scatter(self._secondary_xs, self._secondary_aux_2, target_width),
}
return (sweep, info, aux_curves)

View File

@ -10,13 +10,96 @@ from queue import Full, Queue
from rfg_adc_plotter.io.serial_source import SerialChunkReader, SerialLineSource
from rfg_adc_plotter.io.sweep_parser_core import (
AsciiSweepParser,
ComplexAsciiSweepParser,
LegacyBinaryParser,
LogScale16BitX2BinaryParser,
LogScaleBinaryParser32,
ParserTestStreamParser,
SweepAssembler,
)
from rfg_adc_plotter.types import SweepPacket
from rfg_adc_plotter.types import ParserEvent, PointEvent, StartEvent, SweepPacket
_PARSER_16_BIT_X2_PROBE_BYTES = 64 * 1024
_LEGACY_STREAM_MIN_RECORDS = 32
_LEGACY_STREAM_MIN_MATCH_RATIO = 0.95
_TTY_STREAM_MIN_MATCH_RATIO = 0.60
_DEBUG_FRAME_LOG_EVERY = 10
_NO_INPUT_WARN_INTERVAL_S = 5.0
_NO_PACKET_WARN_INTERVAL_S = 5.0
_NO_PACKET_HINT_AFTER_S = 10.0
def _u16le_at(data: bytes, offset: int) -> int:
return int(data[offset]) | (int(data[offset + 1]) << 8)
def _looks_like_legacy_8byte_stream(data: bytes) -> bool:
"""Heuristically detect supported 8-byte binary streams on an arbitrary byte offset."""
buf = bytes(data)
for offset in range(8):
blocks = (len(buf) - offset) // 8
if blocks < _LEGACY_STREAM_MIN_RECORDS:
continue
min_matches = max(_LEGACY_STREAM_MIN_RECORDS, int(blocks * _LEGACY_STREAM_MIN_MATCH_RATIO))
matched_steps_legacy: list[int] = []
matched_steps_tty: list[int] = []
matched_steps_logdet: list[int] = []
for block_idx in range(blocks):
base = offset + (block_idx * 8)
if (_u16le_at(buf, base + 6) & 0x00FF) != 0x000A:
w0 = _u16le_at(buf, base)
w1 = _u16le_at(buf, base + 2)
w3 = _u16le_at(buf, base + 6)
if w0 in {0x000A, 0x00A3, 0x00A4} and w1 != 0xFFFF:
matched_steps_tty.append(w1)
elif w0 == 0x001A and w3 == 0x0000:
matched_steps_logdet.append(w1)
continue
matched_steps_legacy.append(_u16le_at(buf, base))
if len(matched_steps_legacy) >= min_matches:
monotonic_or_reset = 0
for prev_step, next_step in zip(matched_steps_legacy, matched_steps_legacy[1:]):
if next_step == (prev_step + 1) or next_step <= prev_step:
monotonic_or_reset += 1
if monotonic_or_reset >= max(4, len(matched_steps_legacy) - 4):
return True
tty_min_matches = max(_LEGACY_STREAM_MIN_RECORDS, int(blocks * _TTY_STREAM_MIN_MATCH_RATIO))
if len(matched_steps_tty) >= tty_min_matches:
monotonic_or_reset = 0
for prev_step, next_step in zip(matched_steps_tty, matched_steps_tty[1:]):
if next_step == (prev_step + 1) or next_step <= 2:
monotonic_or_reset += 1
if monotonic_or_reset >= max(4, len(matched_steps_tty) - 4):
return True
if len(matched_steps_logdet) >= tty_min_matches:
monotonic_or_reset = 0
for prev_step, next_step in zip(matched_steps_logdet, matched_steps_logdet[1:]):
if next_step == (prev_step + 1) or next_step <= 2:
monotonic_or_reset += 1
if monotonic_or_reset >= max(4, len(matched_steps_logdet) - 4):
return True
return False
def _is_valid_parser_16_bit_x2_probe(events: list[ParserEvent]) -> bool:
"""Accept only plausible complex streams and ignore resync noise."""
point_steps: list[int] = []
for event in events:
if isinstance(event, PointEvent):
point_steps.append(int(event.x))
if len(point_steps) < 3:
return False
monotonic_or_small_reset = 0
for prev_step, next_step in zip(point_steps, point_steps[1:]):
if next_step == (prev_step + 1) or next_step <= 2:
monotonic_or_small_reset += 1
return monotonic_or_small_reset >= max(2, len(point_steps) - 3)
class SweepReader(threading.Thread):
@ -33,20 +116,40 @@ class SweepReader(threading.Thread):
logscale: bool = False,
parser_16_bit_x2: bool = False,
parser_test: bool = False,
parser_complex_ascii: bool = False,
):
super().__init__(daemon=True)
self._port_path = port_path
self._baud = int(baud)
self._queue = out_queue
self._stop = stop_event
self._stop_event = stop_event
self._fancy = bool(fancy)
self._bin_mode = bool(bin_mode)
self._logscale = bool(logscale)
self._parser_16_bit_x2 = bool(parser_16_bit_x2)
self._parser_test = bool(parser_test)
self._parser_complex_ascii = bool(parser_complex_ascii)
self._src: SerialLineSource | None = None
self._frames_read = 0
self._frames_dropped = 0
self._started_at = time.perf_counter()
def _resolve_parser_mode_label(self) -> str:
if self._parser_complex_ascii:
return "complex_ascii"
if self._parser_test:
return "parser_test_16x2"
if self._parser_16_bit_x2:
return "parser_16_bit_x2"
if self._logscale:
return "logscale_32"
if self._bin_mode:
return "legacy_8byte"
return "ascii"
def _build_parser(self):
if self._parser_complex_ascii:
return ComplexAsciiSweepParser(), SweepAssembler(fancy=self._fancy, apply_inversion=False)
if self._parser_test:
return ParserTestStreamParser(), SweepAssembler(fancy=self._fancy, apply_inversion=False)
if self._parser_16_bit_x2:
@ -54,43 +157,221 @@ class SweepReader(threading.Thread):
if self._logscale:
return LogScaleBinaryParser32(), SweepAssembler(fancy=self._fancy, apply_inversion=False)
if self._bin_mode:
return LegacyBinaryParser(), SweepAssembler(fancy=self._fancy, apply_inversion=True)
return LegacyBinaryParser(batch_events=True), SweepAssembler(fancy=self._fancy, apply_inversion=True)
return AsciiSweepParser(), SweepAssembler(fancy=self._fancy, apply_inversion=True)
@staticmethod
def _consume_events(assembler: SweepAssembler, events) -> list[SweepPacket]:
packets: list[SweepPacket] = []
for event in events:
packet = assembler.consume(event)
if packet is not None:
packets.append(packet)
return packets
def _probe_parser_16_bit_x2(self, chunk_reader: SerialChunkReader):
parser = LogScale16BitX2BinaryParser()
probe_buf = bytearray()
probe_events: list[ParserEvent] = []
probe_started_at = time.perf_counter()
while not self._stop_event.is_set() and len(probe_buf) < _PARSER_16_BIT_X2_PROBE_BYTES:
data = chunk_reader.read_available()
if not data:
time.sleep(0.0005)
continue
probe_buf += data
probe_events.extend(parser.feed(data))
if _is_valid_parser_16_bit_x2_probe(probe_events):
assembler = SweepAssembler(fancy=self._fancy, apply_inversion=False)
probe_packets = self._consume_events(assembler, probe_events)
n_points = int(sum(1 for event in probe_events if isinstance(event, PointEvent)))
n_starts = int(sum(1 for event in probe_events if isinstance(event, StartEvent)))
probe_ms = (time.perf_counter() - probe_started_at) * 1000.0
sys.stderr.write(
"[info] parser_16_bit_x2 probe: bytes:%d events:%d points:%d starts:%d parser:16x2 elapsed_ms:%.1f\n"
% (
len(probe_buf),
len(probe_events),
n_points,
n_starts,
probe_ms,
)
)
return parser, assembler, probe_packets
probe_looks_legacy = bool(probe_buf) and _looks_like_legacy_8byte_stream(bytes(probe_buf))
n_points = int(sum(1 for event in probe_events if isinstance(event, PointEvent)))
n_starts = int(sum(1 for event in probe_events if isinstance(event, StartEvent)))
probe_ms = (time.perf_counter() - probe_started_at) * 1000.0
if probe_looks_legacy:
sys.stderr.write(
"[info] parser_16_bit_x2 probe: bytes:%d events:%d points:%d starts:%d parser:legacy(fallback) elapsed_ms:%.1f\n"
% (
len(probe_buf),
len(probe_events),
n_points,
n_starts,
probe_ms,
)
)
sys.stderr.write("[info] parser_16_bit_x2: fallback -> legacy\n")
parser = LegacyBinaryParser(batch_events=True)
assembler = SweepAssembler(fancy=self._fancy, apply_inversion=True)
probe_packets = self._consume_events(assembler, parser.feed(bytes(probe_buf)))
return parser, assembler, probe_packets
sys.stderr.write(
"[warn] parser_16_bit_x2 probe inconclusive: bytes:%d events:%d points:%d starts:%d parser:16x2 elapsed_ms:%.1f\n"
% (
len(probe_buf),
len(probe_events),
n_points,
n_starts,
probe_ms,
)
)
sys.stderr.write(
"[hint] parser_16_bit_x2: if source is 8-byte tty CH1/CH2 stream "
"(0x000A/0x00A3/0x00A4,step,ch1,ch2), try --bin\n"
)
assembler = SweepAssembler(fancy=self._fancy, apply_inversion=False)
return parser, assembler, []
def _enqueue(self, packet: SweepPacket) -> None:
dropped = False
try:
self._queue.put_nowait(packet)
except Full:
try:
_ = self._queue.get_nowait()
dropped = True
except Exception:
pass
try:
self._queue.put_nowait(packet)
except Exception:
pass
if dropped:
self._frames_dropped += 1
self._frames_read += 1
if self._frames_read % _DEBUG_FRAME_LOG_EVERY == 0:
sweep, info, _aux = packet
try:
queue_size = self._queue.qsize()
except Exception:
queue_size = -1
elapsed_s = max(time.perf_counter() - self._started_at, 1e-9)
frames_per_sec = float(self._frames_read) / elapsed_s
sweep_idx = info.get("sweep") if isinstance(info, dict) else None
channel = info.get("ch") if isinstance(info, dict) else None
sys.stderr.write(
"[debug] reader frames:%d rate:%.2f/s last_sweep:%s ch:%s width:%d queue:%d dropped:%d\n"
% (
self._frames_read,
frames_per_sec,
str(sweep_idx),
str(channel),
int(getattr(sweep, "size", 0)),
int(queue_size),
self._frames_dropped,
)
)
def run(self) -> None:
try:
self._src = SerialLineSource(self._port_path, self._baud, timeout=1.0)
queue_cap = int(getattr(self._queue, "maxsize", -1))
sys.stderr.write(f"[info] Открыл порт {self._port_path} ({self._src._using})\n")
sys.stderr.write(
"[info] reader start: parser:%s fancy:%d queue_max:%d source:%s\n"
% (
self._resolve_parser_mode_label(),
int(self._fancy),
queue_cap,
getattr(self._src, "_using", "unknown"),
)
)
except Exception as exc:
sys.stderr.write(f"[error] {exc}\n")
return
parser, assembler = self._build_parser()
try:
chunk_reader = SerialChunkReader(self._src)
while not self._stop.is_set():
if self._parser_16_bit_x2:
parser, assembler, pending_packets = self._probe_parser_16_bit_x2(chunk_reader)
else:
parser, assembler = self._build_parser()
pending_packets = []
for packet in pending_packets:
self._enqueue(packet)
loop_started_at = time.perf_counter()
last_input_at = loop_started_at
last_packet_at = loop_started_at if self._frames_read > 0 else loop_started_at
last_no_input_warn_at = loop_started_at
last_no_packet_warn_at = loop_started_at
parser_hint_emitted = False
while not self._stop_event.is_set():
data = chunk_reader.read_available()
now_s = time.perf_counter()
if not data:
input_idle_s = now_s - last_input_at
if (
input_idle_s >= _NO_INPUT_WARN_INTERVAL_S
and (now_s - last_no_input_warn_at) >= _NO_INPUT_WARN_INTERVAL_S
):
sys.stderr.write(
"[warn] reader no input bytes for %.1fs on %s (parser:%s)\n"
% (
input_idle_s,
self._port_path,
self._resolve_parser_mode_label(),
)
)
last_no_input_warn_at = now_s
packets_idle_s = now_s - last_packet_at
if (
packets_idle_s >= _NO_PACKET_WARN_INTERVAL_S
and (now_s - last_no_packet_warn_at) >= _NO_PACKET_WARN_INTERVAL_S
):
try:
queue_size = self._queue.qsize()
except Exception:
queue_size = -1
sys.stderr.write(
"[warn] reader no sweep packets for %.1fs (input_idle:%.1fs queue:%d parser:%s)\n"
% (
packets_idle_s,
input_idle_s,
int(queue_size),
self._resolve_parser_mode_label(),
)
)
last_no_packet_warn_at = now_s
if (
self._parser_16_bit_x2
and (not parser_hint_emitted)
and (now_s - self._started_at) >= _NO_PACKET_HINT_AFTER_S
):
sys.stderr.write(
"[hint] parser_16_bit_x2 still has no sweeps; "
"if source is tty CH1/CH2 (0x000A/0x00A3/0x00A4), rerun with --bin\n"
)
parser_hint_emitted = True
time.sleep(0.0005)
continue
for event in parser.feed(data):
packet = assembler.consume(event)
if packet is not None:
self._enqueue(packet)
last_input_at = now_s
packets = self._consume_events(assembler, parser.feed(data))
if packets:
last_packet_at = now_s
for packet in packets:
self._enqueue(packet)
packet = assembler.finalize_current()
if packet is not None:
self._enqueue(packet)

View File

@ -8,16 +8,20 @@ from rfg_adc_plotter.processing.background import (
)
from rfg_adc_plotter.processing.calibration import (
build_calib_envelope,
build_complex_calibration_curve,
calibrate_freqs,
get_calibration_base,
get_calibration_coeffs,
load_calib_envelope,
load_complex_calibration,
recalculate_calibration_c,
save_calib_envelope,
save_complex_calibration,
set_calibration_base_value,
)
from rfg_adc_plotter.processing.fft import (
compute_distance_axis,
compute_fft_complex_row,
compute_fft_mag_row,
compute_fft_row,
fft_mag_to_db,
@ -29,6 +33,8 @@ from rfg_adc_plotter.processing.formatting import (
)
from rfg_adc_plotter.processing.normalization import (
build_calib_envelopes,
fit_complex_calibration_to_width,
normalize_by_complex_calibration,
normalize_by_envelope,
normalize_by_calib,
)
@ -41,9 +47,11 @@ from rfg_adc_plotter.processing.peaks import (
__all__ = [
"build_calib_envelopes",
"build_calib_envelope",
"build_complex_calibration_curve",
"calibrate_freqs",
"compute_auto_ylim",
"compute_distance_axis",
"compute_fft_complex_row",
"compute_fft_mag_row",
"compute_fft_row",
"fft_mag_to_db",
@ -53,13 +61,17 @@ __all__ = [
"get_calibration_base",
"get_calibration_coeffs",
"load_calib_envelope",
"load_complex_calibration",
"load_fft_background",
"fit_complex_calibration_to_width",
"normalize_by_complex_calibration",
"normalize_by_envelope",
"normalize_by_calib",
"parse_spec_clip",
"recalculate_calibration_c",
"rolling_median_ref",
"save_calib_envelope",
"save_complex_calibration",
"save_fft_background",
"set_calibration_base_value",
"subtract_fft_background",

View File

@ -3,11 +3,16 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping
from typing import Any, Mapping, Optional
import numpy as np
from rfg_adc_plotter.constants import SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
from rfg_adc_plotter.constants import (
PHASE_FREQ_MAX_GHZ,
PHASE_FREQ_MIN_GHZ,
SWEEP_FREQ_MAX_GHZ,
SWEEP_FREQ_MIN_GHZ,
)
from rfg_adc_plotter.processing.normalization import build_calib_envelopes
from rfg_adc_plotter.types import SweepData
@ -65,14 +70,23 @@ def set_calibration_base_value(index: int, value: float) -> np.ndarray:
def calibrate_freqs(sweep: Mapping[str, Any]) -> SweepData:
"""Return a sweep copy with calibrated and resampled frequency axis."""
freqs = np.asarray(sweep["F"], dtype=np.float64).copy()
values = np.asarray(sweep["I"], dtype=np.float64).copy()
values_in = np.asarray(sweep["I"]).reshape(-1)
values = np.asarray(
values_in,
dtype=np.complex128 if np.iscomplexobj(values_in) else np.float64,
).copy()
coeffs = np.asarray(CALIBRATION_C, dtype=np.float64)
if freqs.size > 0:
freqs = coeffs[0] + coeffs[1] * freqs + coeffs[2] * (freqs * freqs)
if freqs.size >= 2:
freqs_cal = np.linspace(float(freqs[0]), float(freqs[-1]), freqs.size, dtype=np.float64)
values_cal = np.interp(freqs_cal, freqs, values).astype(np.float64)
if np.iscomplexobj(values):
values_real = np.interp(freqs_cal, freqs, values.real.astype(np.float64, copy=False))
values_imag = np.interp(freqs_cal, freqs, values.imag.astype(np.float64, copy=False))
values_cal = (values_real + (1j * values_imag)).astype(np.complex64)
else:
values_cal = np.interp(freqs_cal, freqs, values).astype(np.float64)
else:
freqs_cal = freqs.copy()
values_cal = values.copy()
@ -83,6 +97,30 @@ def calibrate_freqs(sweep: Mapping[str, Any]) -> SweepData:
}
def compute_freqs_from_ref_phase(
ref_ch1: np.ndarray,
ref_ch2: np.ndarray,
f_min_ghz: float = PHASE_FREQ_MIN_GHZ,
f_max_ghz: float = PHASE_FREQ_MAX_GHZ,
) -> Optional[np.ndarray]:
"""Compute frequency axis from unwrapped phase of a reference IQ signal.
Assumes phase depends linearly on frequency: the first sample maps to
*f_min_ghz* and the last sample maps to *f_max_ghz*.
"""
ch1 = np.asarray(ref_ch1, dtype=np.float64).reshape(-1)
ch2 = np.asarray(ref_ch2, dtype=np.float64).reshape(-1)
w = min(ch1.size, ch2.size)
if w < 2:
return None
phase = np.unwrap(np.arctan2(ch2[:w], ch1[:w]))
phi0, phi1 = float(phase[0]), float(phase[-1])
if abs(phi1 - phi0) < 1e-12:
return None
freqs = f_min_ghz + (phase - phi0) / (phi1 - phi0) * (f_max_ghz - f_min_ghz)
return freqs
def build_calib_envelope(sweep: np.ndarray) -> np.ndarray:
"""Build the active calibration envelope from a raw sweep."""
values = np.asarray(sweep, dtype=np.float32).reshape(-1)
@ -92,6 +130,17 @@ def build_calib_envelope(sweep: np.ndarray) -> np.ndarray:
return np.asarray(upper, dtype=np.float32)
def build_complex_calibration_curve(ch1: np.ndarray, ch2: np.ndarray) -> np.ndarray:
"""Build a complex calibration curve as ``ch1 + 1j*ch2``."""
ch1_arr = np.asarray(ch1, dtype=np.float32).reshape(-1)
ch2_arr = np.asarray(ch2, dtype=np.float32).reshape(-1)
width = min(ch1_arr.size, ch2_arr.size)
if width <= 0:
raise ValueError("Complex calibration source is empty")
curve = ch1_arr[:width].astype(np.complex64) + (1j * ch2_arr[:width].astype(np.complex64))
return validate_complex_calibration_curve(curve)
def validate_calib_envelope(envelope: np.ndarray) -> np.ndarray:
"""Validate a saved calibration envelope payload."""
values = np.asarray(envelope, dtype=np.float32).reshape(-1)
@ -102,6 +151,16 @@ def validate_calib_envelope(envelope: np.ndarray) -> np.ndarray:
return values
def validate_complex_calibration_curve(curve: np.ndarray) -> np.ndarray:
"""Validate a saved complex calibration payload."""
values = np.asarray(curve).reshape(-1)
if values.size == 0:
raise ValueError("Complex calibration curve is empty")
if not np.issubdtype(values.dtype, np.number):
raise ValueError("Complex calibration curve must be numeric")
return np.asarray(values, dtype=np.complex64)
def _normalize_calib_path(path: str | Path) -> Path:
out = Path(path).expanduser()
if out.suffix.lower() != ".npy":
@ -122,3 +181,18 @@ def load_calib_envelope(path: str | Path) -> np.ndarray:
normalized_path = _normalize_calib_path(path)
loaded = np.load(normalized_path, allow_pickle=False)
return validate_calib_envelope(loaded)
def save_complex_calibration(path: str | Path, curve: np.ndarray) -> str:
"""Persist a complex calibration curve as a .npy file and return the final path."""
normalized_path = _normalize_calib_path(path)
values = validate_complex_calibration_curve(curve)
np.save(normalized_path, values.astype(np.complex64, copy=False))
return str(normalized_path)
def load_complex_calibration(path: str | Path) -> np.ndarray:
"""Load and validate a complex calibration curve from a .npy file."""
normalized_path = _normalize_calib_path(path)
loaded = np.load(normalized_path, allow_pickle=False)
return validate_complex_calibration_curve(loaded)

View File

@ -24,6 +24,185 @@ def _finite_freq_bounds(freqs: Optional[np.ndarray]) -> Optional[Tuple[float, fl
return f_min, f_max
def _coerce_sweep_array(sweep: np.ndarray) -> np.ndarray:
values = np.asarray(sweep).reshape(-1)
if np.iscomplexobj(values):
return np.asarray(values, dtype=np.complex64)
return np.asarray(values, dtype=np.float32)
def _interp_signal(x_uniform: np.ndarray, x_known: np.ndarray, y_known: np.ndarray) -> np.ndarray:
if np.iscomplexobj(y_known):
real = np.interp(x_uniform, x_known, np.asarray(y_known.real, dtype=np.float64))
imag = np.interp(x_uniform, x_known, np.asarray(y_known.imag, dtype=np.float64))
return (real + (1j * imag)).astype(np.complex64)
return np.interp(x_uniform, x_known, np.asarray(y_known, dtype=np.float64)).astype(np.float32)
def _fit_complex_bins(values: np.ndarray, bins: int) -> np.ndarray:
arr = np.asarray(values, dtype=np.complex64).reshape(-1)
if bins <= 0:
return np.zeros((0,), dtype=np.complex64)
if arr.size == bins:
return arr
out = np.full((bins,), np.nan + 0j, dtype=np.complex64)
take = min(arr.size, bins)
out[:take] = arr[:take]
return out
def _extract_positive_exact_band(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
) -> Optional[Tuple[np.ndarray, np.ndarray, float, float]]:
"""Return sorted positive band data and exact-grid parameters."""
if freqs is None:
return None
sweep_arr = _coerce_sweep_array(sweep)
freq_arr = np.asarray(freqs, dtype=np.float64).reshape(-1)
take = min(int(sweep_arr.size), int(freq_arr.size))
if take <= 1:
return None
sweep_seg = sweep_arr[:take]
freq_seg = freq_arr[:take]
valid = np.isfinite(freq_seg) & np.isfinite(sweep_seg) & (freq_seg > 0.0)
if int(np.count_nonzero(valid)) < 2:
return None
freq_band = np.asarray(freq_seg[valid], dtype=np.float64)
sweep_band = np.asarray(sweep_seg[valid])
order = np.argsort(freq_band, kind="mergesort")
freq_band = freq_band[order]
sweep_band = sweep_band[order]
n_band = int(freq_band.size)
if n_band <= 1:
return None
f_min = float(freq_band[0])
f_max = float(freq_band[-1])
if (not np.isfinite(f_min)) or (not np.isfinite(f_max)) or f_max <= f_min:
return None
df_ghz = float((f_max - f_min) / max(1, n_band - 1))
if (not np.isfinite(df_ghz)) or df_ghz <= 0.0:
return None
return freq_band, sweep_band, f_max, df_ghz
def _positive_exact_shift_size(f_max: float, df_ghz: float) -> int:
if (not np.isfinite(f_max)) or (not np.isfinite(df_ghz)) or f_max <= 0.0 or df_ghz <= 0.0:
return 0
return int(np.arange(-f_max, f_max + (0.5 * df_ghz), df_ghz, dtype=np.float64).size)
def _resolve_positive_exact_band_size(
f_min: float,
f_max: float,
n_band: int,
max_shift_len: Optional[int],
) -> int:
if n_band <= 2:
return max(2, int(n_band))
if max_shift_len is None:
return int(n_band)
limit = int(max_shift_len)
if limit <= 1:
return max(2, int(n_band))
span = float(f_max - f_min)
if (not np.isfinite(span)) or span <= 0.0:
return int(n_band)
df_current = float(span / max(1, int(n_band) - 1))
if _positive_exact_shift_size(f_max, df_current) <= limit:
return int(n_band)
denom = max(2.0 * f_max, 1e-12)
approx = int(np.floor(1.0 + ((float(limit - 1) * span) / denom)))
target = min(int(n_band), max(2, approx))
while target > 2:
df_try = float(span / max(1, target - 1))
if _positive_exact_shift_size(f_max, df_try) <= limit:
break
target -= 1
return max(2, target)
def _normalize_positive_exact_band(
freq_band: np.ndarray,
sweep_band: np.ndarray,
*,
max_shift_len: Optional[int] = None,
) -> Optional[Tuple[np.ndarray, np.ndarray, float, float]]:
freq_arr = np.asarray(freq_band, dtype=np.float64).reshape(-1)
sweep_arr = np.asarray(sweep_band).reshape(-1)
width = min(int(freq_arr.size), int(sweep_arr.size))
if width <= 1:
return None
freq_arr = freq_arr[:width]
sweep_arr = sweep_arr[:width]
f_min = float(freq_arr[0])
f_max = float(freq_arr[-1])
if (not np.isfinite(f_min)) or (not np.isfinite(f_max)) or f_max <= f_min:
return None
target_band = _resolve_positive_exact_band_size(f_min, f_max, int(freq_arr.size), max_shift_len)
if target_band < int(freq_arr.size):
target_freqs = np.linspace(f_min, f_max, target_band, dtype=np.float64)
target_sweep = _interp_signal(target_freqs, freq_arr, sweep_arr)
freq_arr = target_freqs
sweep_arr = np.asarray(target_sweep).reshape(-1)
n_band = int(freq_arr.size)
if n_band <= 1:
return None
df_ghz = float((f_max - f_min) / max(1, n_band - 1))
if (not np.isfinite(df_ghz)) or df_ghz <= 0.0:
return None
return freq_arr, sweep_arr, f_max, df_ghz
def _resolve_positive_only_exact_geometry(
freqs: Optional[np.ndarray],
*,
max_shift_len: Optional[int] = None,
) -> Optional[Tuple[int, float]]:
"""Return (N_shift, df_hz) for the exact centered positive-only mode."""
if freqs is None:
return None
freq_arr = np.asarray(freqs, dtype=np.float64).reshape(-1)
finite = np.asarray(freq_arr[np.isfinite(freq_arr) & (freq_arr > 0.0)], dtype=np.float64)
if finite.size < 2:
return None
finite.sort(kind="mergesort")
f_min = float(finite[0])
f_max = float(finite[-1])
if (not np.isfinite(f_min)) or (not np.isfinite(f_max)) or f_max <= f_min:
return None
n_band = int(finite.size)
target_band = _resolve_positive_exact_band_size(f_min, f_max, n_band, max_shift_len)
n_band = max(2, min(n_band, target_band))
df_ghz = float((f_max - f_min) / max(1, n_band - 1))
if (not np.isfinite(df_ghz)) or df_ghz <= 0.0:
return None
n_shift = _positive_exact_shift_size(f_max, df_ghz)
if n_shift <= 1:
return None
return int(n_shift), float(df_ghz * 1e9)
def prepare_fft_segment(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
@ -34,8 +213,10 @@ def prepare_fft_segment(
if take_fft <= 0:
return None
sweep_seg = np.asarray(sweep[:take_fft], dtype=np.float32)
fallback = np.nan_to_num(sweep_seg, nan=0.0).astype(np.float32, copy=False)
sweep_arr = _coerce_sweep_array(sweep)
sweep_seg = sweep_arr[:take_fft]
fallback_dtype = np.complex64 if np.iscomplexobj(sweep_seg) else np.float32
fallback = np.nan_to_num(sweep_seg, nan=0.0).astype(fallback_dtype, copy=False)
if freqs is None:
return fallback, take_fft
@ -59,7 +240,7 @@ def prepare_fft_segment(
return fallback, take_fft
x_uniform = np.linspace(float(x_unique[0]), float(x_unique[-1]), take_fft, dtype=np.float64)
resampled = np.interp(x_uniform, x_unique, y_unique).astype(np.float32)
resampled = _interp_signal(x_uniform, x_unique, y_unique)
return resampled, take_fft
@ -94,18 +275,20 @@ def build_symmetric_ifft_spectrum(
fft_seg, take_fft = prepared
if take_fft != band_len:
fft_seg = np.asarray(fft_seg[:band_len], dtype=np.float32)
fft_dtype = np.complex64 if np.iscomplexobj(fft_seg) else np.float32
fft_seg = np.asarray(fft_seg[:band_len], dtype=fft_dtype)
if fft_seg.size < band_len:
padded = np.zeros((band_len,), dtype=np.float32)
padded = np.zeros((band_len,), dtype=fft_dtype)
padded[: fft_seg.size] = fft_seg
fft_seg = padded
window = np.hanning(band_len).astype(np.float32)
band = np.nan_to_num(fft_seg, nan=0.0).astype(np.float32, copy=False) * window
band_dtype = np.complex64 if np.iscomplexobj(fft_seg) else np.float32
band = np.nan_to_num(fft_seg, nan=0.0).astype(band_dtype, copy=False) * window
spectrum = np.zeros((int(fft_len),), dtype=np.float32)
spectrum = np.zeros((int(fft_len),), dtype=band_dtype)
spectrum[pos_idx] = band
spectrum[neg_idx] = band[::-1]
spectrum[neg_idx] = np.conj(band[::-1]) if np.iscomplexobj(band) else band[::-1]
return spectrum
@ -137,20 +320,56 @@ def build_positive_only_centered_ifft_spectrum(
fft_seg, take_fft = prepared
if take_fft != band_len:
fft_seg = np.asarray(fft_seg[:band_len], dtype=np.float32)
fft_dtype = np.complex64 if np.iscomplexobj(fft_seg) else np.float32
fft_seg = np.asarray(fft_seg[:band_len], dtype=fft_dtype)
if fft_seg.size < band_len:
padded = np.zeros((band_len,), dtype=np.float32)
padded = np.zeros((band_len,), dtype=fft_dtype)
padded[: fft_seg.size] = fft_seg
fft_seg = padded
window = np.hanning(band_len).astype(np.float32)
band = np.nan_to_num(fft_seg, nan=0.0).astype(np.float32, copy=False) * window
band_dtype = np.complex64 if np.iscomplexobj(fft_seg) else np.float32
band = np.nan_to_num(fft_seg, nan=0.0).astype(band_dtype, copy=False) * window
spectrum = np.zeros((int(fft_len),), dtype=np.float32)
spectrum = np.zeros((int(fft_len),), dtype=band_dtype)
spectrum[pos_idx] = band
return spectrum
def build_positive_only_exact_centered_ifft_spectrum(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
*,
max_shift_len: Optional[int] = None,
) -> Optional[np.ndarray]:
"""Build centered spectrum exactly as zeros[-f_max..+f_min) + measured positive band."""
prepared = _extract_positive_exact_band(sweep, freqs)
if prepared is None:
return None
freq_band, sweep_band, _f_max, _df_ghz = prepared
normalized = _normalize_positive_exact_band(
freq_band,
sweep_band,
max_shift_len=max_shift_len,
)
if normalized is None:
return None
freq_band, sweep_band, f_max, df_ghz = normalized
f_shift = np.arange(-f_max, f_max + (0.5 * df_ghz), df_ghz, dtype=np.float64)
if f_shift.size <= 1:
return None
band_dtype = np.complex64 if np.iscomplexobj(sweep_band) else np.float32
band = np.nan_to_num(np.asarray(sweep_band, dtype=band_dtype), nan=0.0)
spectrum = np.zeros((int(f_shift.size),), dtype=band_dtype)
idx = np.round((freq_band - f_shift[0]) / df_ghz).astype(np.int64)
idx = np.clip(idx, 0, spectrum.size - 1)
spectrum[idx] = band
return spectrum
def fft_mag_to_db(mag: np.ndarray) -> np.ndarray:
"""Convert magnitude to dB with safe zero handling."""
mag_arr = np.asarray(mag, dtype=np.float32)
@ -158,24 +377,21 @@ def fft_mag_to_db(mag: np.ndarray) -> np.ndarray:
return (20.0 * np.log10(safe_mag + 1e-9)).astype(np.float32, copy=False)
def _compute_fft_mag_row_direct(
def _compute_fft_complex_row_direct(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
bins: int,
) -> np.ndarray:
prepared = prepare_fft_segment(sweep, freqs, fft_len=FFT_LEN)
if prepared is None:
return np.full((bins,), np.nan, dtype=np.float32)
return np.full((bins,), np.nan + 0j, dtype=np.complex64)
fft_seg, take_fft = prepared
fft_in = np.zeros((FFT_LEN,), dtype=np.float32)
fft_in = np.zeros((FFT_LEN,), dtype=np.complex64)
window = np.hanning(take_fft).astype(np.float32)
fft_in[:take_fft] = fft_seg * window
fft_in[:take_fft] = np.asarray(fft_seg, dtype=np.complex64) * window
spec = np.fft.ifft(fft_in)
mag = np.abs(spec).astype(np.float32)
if mag.shape[0] != bins:
mag = mag[:bins]
return mag
return _fit_complex_bins(spec, bins)
def _normalize_fft_mode(mode: str | None, symmetric: Optional[bool]) -> str:
@ -188,9 +404,44 @@ def _normalize_fft_mode(mode: str | None, symmetric: Optional[bool]) -> str:
return "symmetric"
if normalized in {"positive_only", "positive-centered", "positive_centered", "zero_left"}:
return "positive_only"
if normalized in {"positive_only_exact", "positive-centered-exact", "positive_centered_exact", "zero_left_exact"}:
return "positive_only_exact"
raise ValueError(f"Unsupported FFT mode: {mode!r}")
def compute_fft_complex_row(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
bins: int,
*,
mode: str = "symmetric",
symmetric: Optional[bool] = None,
) -> np.ndarray:
"""Compute a complex FFT/IFFT row on the distance axis."""
if bins <= 0:
return np.zeros((0,), dtype=np.complex64)
fft_mode = _normalize_fft_mode(mode, symmetric)
if fft_mode == "direct":
return _compute_fft_complex_row_direct(sweep, freqs, bins)
if fft_mode == "positive_only":
spectrum_centered = build_positive_only_centered_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
elif fft_mode == "positive_only_exact":
spectrum_centered = build_positive_only_exact_centered_ifft_spectrum(
sweep,
freqs,
max_shift_len=bins,
)
else:
spectrum_centered = build_symmetric_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
if spectrum_centered is None:
return np.full((bins,), np.nan + 0j, dtype=np.complex64)
spec = np.fft.ifft(np.fft.ifftshift(np.asarray(spectrum_centered, dtype=np.complex64)))
return _fit_complex_bins(spec, bins)
def compute_fft_mag_row(
sweep: np.ndarray,
freqs: Optional[np.ndarray],
@ -200,25 +451,8 @@ def compute_fft_mag_row(
symmetric: Optional[bool] = None,
) -> np.ndarray:
"""Compute a linear FFT magnitude row."""
if bins <= 0:
return np.zeros((0,), dtype=np.float32)
fft_mode = _normalize_fft_mode(mode, symmetric)
if fft_mode == "direct":
return _compute_fft_mag_row_direct(sweep, freqs, bins)
if fft_mode == "positive_only":
spectrum_centered = build_positive_only_centered_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
else:
spectrum_centered = build_symmetric_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
if spectrum_centered is None:
return np.full((bins,), np.nan, dtype=np.float32)
spec = np.fft.ifft(np.fft.ifftshift(spectrum_centered))
mag = np.abs(spec).astype(np.float32)
if mag.shape[0] != bins:
mag = mag[:bins]
return mag
complex_row = compute_fft_complex_row(sweep, freqs, bins, mode=mode, symmetric=symmetric)
return np.abs(complex_row).astype(np.float32, copy=False)
def compute_fft_row(
@ -244,6 +478,16 @@ def compute_distance_axis(
if bins <= 0:
return np.zeros((0,), dtype=np.float64)
fft_mode = _normalize_fft_mode(mode, symmetric)
if fft_mode == "positive_only_exact":
geometry = _resolve_positive_only_exact_geometry(freqs, max_shift_len=bins)
if geometry is None:
return np.arange(bins, dtype=np.float64)
n_shift, df_hz = geometry
if (not np.isfinite(df_hz)) or df_hz <= 0.0 or n_shift <= 0:
return np.arange(bins, dtype=np.float64)
step_m = C_M_S / (2.0 * float(n_shift) * df_hz)
return np.arange(bins, dtype=np.float64) * step_m
if fft_mode in {"symmetric", "positive_only"}:
bounds = _finite_freq_bounds(freqs)
if bounds is None:

View File

@ -148,20 +148,77 @@ def resample_envelope(envelope: np.ndarray, width: int) -> np.ndarray:
return np.interp(x_dst, x_src[finite], values[finite]).astype(np.float32)
def fit_complex_calibration_to_width(calib: np.ndarray, width: int) -> np.ndarray:
"""Fit a complex calibration curve to the signal width via trim/pad with ones."""
target_width = int(width)
if target_width <= 0:
return np.zeros((0,), dtype=np.complex64)
values = np.asarray(calib, dtype=np.complex64).reshape(-1)
if values.size <= 0:
return np.ones((target_width,), dtype=np.complex64)
if values.size == target_width:
return values.astype(np.complex64, copy=True)
if values.size > target_width:
return np.asarray(values[:target_width], dtype=np.complex64)
out = np.ones((target_width,), dtype=np.complex64)
out[: values.size] = values
return out
def normalize_by_complex_calibration(
signal: np.ndarray,
calib: np.ndarray,
eps: float = 1e-9,
) -> np.ndarray:
"""Normalize complex signal by a complex calibration curve with zero protection."""
sig_arr = np.asarray(signal, dtype=np.complex64).reshape(-1)
if sig_arr.size <= 0:
return sig_arr.copy()
calib_fit = fit_complex_calibration_to_width(calib, sig_arr.size)
eps_abs = max(abs(float(eps)), 1e-12)
denom = np.asarray(calib_fit, dtype=np.complex64).copy()
safe_denom = (
np.isfinite(denom.real)
& np.isfinite(denom.imag)
& (np.abs(denom) >= eps_abs)
)
if np.any(~safe_denom):
denom[~safe_denom] = np.complex64(1.0 + 0.0j)
out = np.full(sig_arr.shape, np.nan + 0j, dtype=np.complex64)
valid_sig = np.isfinite(sig_arr.real) & np.isfinite(sig_arr.imag)
if np.any(valid_sig):
with np.errstate(divide="ignore", invalid="ignore"):
out[valid_sig] = sig_arr[valid_sig] / denom[valid_sig]
out_real = np.nan_to_num(out.real, nan=np.nan, posinf=np.nan, neginf=np.nan)
out_imag = np.nan_to_num(out.imag, nan=np.nan, posinf=np.nan, neginf=np.nan)
return (out_real + (1j * out_imag)).astype(np.complex64, copy=False)
def normalize_by_envelope(raw: np.ndarray, envelope: np.ndarray) -> np.ndarray:
"""Normalize a sweep by an envelope with safe resampling and zero protection."""
raw_arr = np.asarray(raw, dtype=np.float32).reshape(-1)
raw_in = np.asarray(raw).reshape(-1)
raw_dtype = np.complex64 if np.iscomplexobj(raw_in) else np.float32
raw_arr = np.asarray(raw_in, dtype=raw_dtype).reshape(-1)
if raw_arr.size == 0:
return raw_arr.copy()
env = resample_envelope(envelope, raw_arr.size)
out = np.full_like(raw_arr, np.nan, dtype=np.float32)
out = np.full(raw_arr.shape, np.nan + 0j if np.iscomplexobj(raw_arr) else np.nan, dtype=raw_dtype)
den_eps = np.float32(1e-9)
valid = np.isfinite(raw_arr) & np.isfinite(env)
if np.any(valid):
with np.errstate(divide="ignore", invalid="ignore"):
denom = env[valid] + np.where(env[valid] >= 0.0, den_eps, -den_eps)
out[valid] = raw_arr[valid] / denom
if np.iscomplexobj(out):
out_real = np.nan_to_num(out.real, nan=np.nan, posinf=np.nan, neginf=np.nan)
out_imag = np.nan_to_num(out.imag, nan=np.nan, posinf=np.nan, neginf=np.nan)
return (out_real + (1j * out_imag)).astype(np.complex64, copy=False)
return np.nan_to_num(out, nan=np.nan, posinf=np.nan, neginf=np.nan)

View File

@ -7,7 +7,7 @@ from typing import Optional
import numpy as np
from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ, WF_WIDTH
from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
from rfg_adc_plotter.processing.fft import compute_distance_axis, compute_fft_mag_row, fft_mag_to_db
@ -23,6 +23,7 @@ class RingBuffer:
self.ring: Optional[np.ndarray] = None
self.ring_time: Optional[np.ndarray] = None
self.ring_fft: Optional[np.ndarray] = None
self.ring_fft_input: Optional[np.ndarray] = None
self.x_shared: Optional[np.ndarray] = None
self.distance_axis: Optional[np.ndarray] = None
self.last_fft_mag: Optional[np.ndarray] = None
@ -30,6 +31,9 @@ class RingBuffer:
self.last_freqs: Optional[np.ndarray] = None
self.y_min_fft: Optional[float] = None
self.y_max_fft: Optional[float] = None
self.last_push_valid_points = 0
self.last_push_fft_valid = False
self.last_push_axis_valid = False
@property
def is_ready(self) -> bool:
@ -46,6 +50,7 @@ class RingBuffer:
self.ring = None
self.ring_time = None
self.ring_fft = None
self.ring_fft_input = None
self.x_shared = None
self.distance_axis = None
self.last_fft_mag = None
@ -53,23 +58,60 @@ class RingBuffer:
self.last_freqs = None
self.y_min_fft = None
self.y_max_fft = None
self.last_push_valid_points = 0
self.last_push_fft_valid = False
self.last_push_axis_valid = False
def _promote_fft_cache(self, fft_mag: np.ndarray) -> bool:
fft_mag_arr = np.asarray(fft_mag, dtype=np.float32).reshape(-1)
if fft_mag_arr.size <= 0:
self.last_push_fft_valid = False
return False
fft_db = fft_mag_to_db(fft_mag_arr)
finite_db = fft_db[np.isfinite(fft_db)]
if finite_db.size <= 0:
self.last_push_fft_valid = False
return False
self.last_fft_mag = fft_mag_arr.copy()
self.last_fft_db = fft_db
fr_min = float(np.min(finite_db))
fr_max = float(np.max(finite_db))
self.y_min_fft = fr_min if self.y_min_fft is None else min(self.y_min_fft, fr_min)
self.y_max_fft = fr_max if self.y_max_fft is None else max(self.y_max_fft, fr_max)
self.last_push_fft_valid = True
return True
def _promote_distance_axis(self, axis: np.ndarray) -> bool:
axis_arr = np.asarray(axis, dtype=np.float64).reshape(-1)
if axis_arr.size <= 0 or not np.all(np.isfinite(axis_arr)):
self.last_push_axis_valid = False
return False
self.distance_axis = axis_arr.copy()
self.last_push_axis_valid = True
return True
def ensure_init(self, sweep_width: int) -> bool:
"""Allocate or resize buffers. Returns True when geometry changed."""
target_width = max(int(sweep_width), int(WF_WIDTH))
target_width = max(1, int(sweep_width))
changed = False
if self.ring is None or self.ring_time is None or self.ring_fft is None:
self.width = target_width
self.ring = np.full((self.max_sweeps, self.width), np.nan, dtype=np.float32)
self.ring_time = np.full((self.max_sweeps,), np.nan, dtype=np.float64)
self.ring_fft = np.full((self.max_sweeps, self.fft_bins), np.nan, dtype=np.float32)
self.ring_fft_input = np.full((self.max_sweeps, self.width), np.nan + 0j, dtype=np.complex64)
self.head = 0
changed = True
elif target_width != self.width:
new_ring = np.full((self.max_sweeps, target_width), np.nan, dtype=np.float32)
new_fft_input = np.full((self.max_sweeps, target_width), np.nan + 0j, dtype=np.complex64)
take = min(self.width, target_width)
new_ring[:, :take] = self.ring[:, :take]
if self.ring_fft_input is not None:
new_fft_input[:, :take] = self.ring_fft_input[:, :take]
self.ring = new_ring
self.ring_fft_input = new_fft_input
self.width = target_width
changed = True
@ -92,7 +134,9 @@ class RingBuffer:
normalized_mode = "symmetric"
if normalized_mode in {"positive-centered", "positive_centered", "zero_left"}:
normalized_mode = "positive_only"
if normalized_mode not in {"direct", "symmetric", "positive_only"}:
if normalized_mode in {"positive-centered-exact", "positive_centered_exact", "zero_left_exact"}:
normalized_mode = "positive_only_exact"
if normalized_mode not in {"direct", "symmetric", "positive_only", "positive_only_exact"}:
raise ValueError(f"Unsupported FFT mode: {mode!r}")
if normalized_mode == self.fft_mode:
return False
@ -100,35 +144,44 @@ class RingBuffer:
self.fft_mode = normalized_mode
self.y_min_fft = None
self.y_max_fft = None
self.last_push_fft_valid = False
self.last_push_axis_valid = False
if self.ring is None or self.ring_fft is None:
return True
self.ring_fft.fill(np.nan)
for row_idx in range(self.ring.shape[0]):
sweep_row = self.ring[row_idx]
if not np.any(np.isfinite(sweep_row)):
fft_source_row = self.ring_fft_input[row_idx] if self.ring_fft_input is not None else self.ring[row_idx]
if not np.any(np.isfinite(fft_source_row)):
continue
finite_idx = np.flatnonzero(np.isfinite(fft_source_row))
if finite_idx.size <= 0:
continue
row_width = int(finite_idx[-1]) + 1
fft_source = fft_source_row[:row_width]
freqs = self.last_freqs[:row_width] if self.last_freqs is not None and self.last_freqs.size >= row_width else self.last_freqs
fft_mag = compute_fft_mag_row(
sweep_row,
self.last_freqs,
fft_source,
freqs,
self.fft_bins,
mode=self.fft_mode,
)
self.ring_fft[row_idx, :] = fft_mag
if self.last_freqs is not None:
self.distance_axis = compute_distance_axis(
self.last_freqs,
self.fft_bins,
mode=self.fft_mode,
self._promote_distance_axis(
compute_distance_axis(
self.last_freqs,
self.fft_bins,
mode=self.fft_mode,
)
)
last_idx = (self.head - 1) % self.max_sweeps
if self.ring_fft.shape[0] > 0:
last_fft = self.ring_fft[last_idx]
self.last_fft_mag = np.asarray(last_fft, dtype=np.float32).copy()
self.last_fft_db = fft_mag_to_db(last_fft)
self._promote_fft_cache(last_fft)
finite = self.ring_fft[np.isfinite(self.ring_fft)]
if finite.size > 0:
finite_db = fft_mag_to_db(finite.astype(np.float32, copy=False))
@ -140,34 +193,39 @@ class RingBuffer:
"""Backward-compatible wrapper for the old two-state FFT switch."""
return self.set_fft_mode("symmetric" if enabled else "direct")
def push(self, sweep: np.ndarray, freqs: Optional[np.ndarray] = None) -> None:
def push(
self,
sweep: np.ndarray,
freqs: Optional[np.ndarray] = None,
*,
fft_input: Optional[np.ndarray] = None,
) -> None:
"""Push a processed sweep and refresh raw/FFT buffers."""
if sweep is None or sweep.size == 0:
return
self.ensure_init(int(sweep.size))
if self.ring is None or self.ring_time is None or self.ring_fft is None:
if self.ring is None or self.ring_time is None or self.ring_fft is None or self.ring_fft_input is None:
return
row = np.full((self.width,), np.nan, dtype=np.float32)
take = min(self.width, int(sweep.size))
row[:take] = np.asarray(sweep[:take], dtype=np.float32)
self.last_push_valid_points = int(np.count_nonzero(np.isfinite(row[:take])))
self.ring[self.head, :] = row
self.ring_time[self.head] = time.time()
if freqs is not None:
self.last_freqs = np.asarray(freqs, dtype=np.float64).copy()
fft_mag = compute_fft_mag_row(sweep, freqs, self.fft_bins, mode=self.fft_mode)
fft_source = np.asarray(fft_input if fft_input is not None else sweep).reshape(-1)
fft_row = np.full((self.width,), np.nan + 0j, dtype=np.complex64)
fft_take = min(self.width, int(fft_source.size))
fft_row[:fft_take] = np.asarray(fft_source[:fft_take], dtype=np.complex64)
self.ring_fft_input[self.head, :] = fft_row
fft_mag = compute_fft_mag_row(fft_source, freqs, self.fft_bins, mode=self.fft_mode)
self.ring_fft[self.head, :] = fft_mag
self.last_fft_mag = np.asarray(fft_mag, dtype=np.float32).copy()
self.last_fft_db = fft_mag_to_db(fft_mag)
if self.last_fft_db.size > 0:
fr_min = float(np.nanmin(self.last_fft_db))
fr_max = float(np.nanmax(self.last_fft_db))
self.y_min_fft = fr_min if self.y_min_fft is None else min(self.y_min_fft, fr_min)
self.y_max_fft = fr_max if self.y_max_fft is None else max(self.y_max_fft, fr_max)
self.distance_axis = compute_distance_axis(freqs, self.fft_bins, mode=self.fft_mode)
self._promote_fft_cache(fft_mag)
self._promote_distance_axis(compute_distance_axis(freqs, self.fft_bins, mode=self.fft_mode))
self.head = (self.head + 1) % self.max_sweeps
def get_display_raw(self) -> np.ndarray:
@ -176,6 +234,21 @@ class RingBuffer:
base = self.ring if self.head == 0 else np.roll(self.ring, -self.head, axis=0)
return base.T
def get_display_raw_decimated(self, max_points: int) -> np.ndarray:
"""Return a display-oriented raw waterfall with optional frequency decimation."""
if self.ring is None:
return np.zeros((1, 1), dtype=np.float32)
limit = int(max_points)
if limit <= 0 or self.width <= limit:
return self.get_display_raw()
row_order = np.arange(self.ring.shape[0], dtype=np.int64)
if self.head:
row_order = np.roll(row_order, -self.head)
col_idx = np.linspace(0, self.width - 1, limit, dtype=np.int64)
return self.ring[np.ix_(row_order, col_idx)].T
def get_display_fft_linear(self) -> np.ndarray:
if self.ring_fft is None:
return np.zeros((1, 1), dtype=np.float32)

View File

@ -20,17 +20,43 @@ class RuntimeState:
range_max_ghz: float = 0.0
full_current_freqs: Optional[np.ndarray] = None
full_current_sweep_raw: Optional[np.ndarray] = None
full_current_sweep_codes: Optional[np.ndarray] = None
full_current_fft_source: Optional[np.ndarray] = None
full_current_aux_curves: SweepAuxCurves = None
full_current_aux_curves_codes: SweepAuxCurves = None
full_do1_tagged_raw_low: Optional[np.ndarray] = None
full_do1_tagged_raw_high: Optional[np.ndarray] = None
full_do1_tagged_aux_low: SweepAuxCurves = None
full_do1_tagged_aux_high: SweepAuxCurves = None
full_do1_tagged_aux_low_codes: SweepAuxCurves = None
full_do1_tagged_aux_high_codes: SweepAuxCurves = None
full_secondary_ch1: Optional[np.ndarray] = None
full_secondary_ch2: Optional[np.ndarray] = None
full_secondary_magnitude: Optional[np.ndarray] = None
full_secondary_phase: Optional[np.ndarray] = None
current_freqs: Optional[np.ndarray] = None
current_distances: Optional[np.ndarray] = None
current_sweep_raw: Optional[np.ndarray] = None
current_fft_source: Optional[np.ndarray] = None
current_fft_input: Optional[np.ndarray] = None
current_fft_complex: Optional[np.ndarray] = None
current_aux_curves: SweepAuxCurves = None
current_do1_tagged_raw_low: Optional[np.ndarray] = None
current_do1_tagged_raw_high: Optional[np.ndarray] = None
current_do1_tagged_aux_low: SweepAuxCurves = None
current_do1_tagged_aux_high: SweepAuxCurves = None
current_secondary_ch1: Optional[np.ndarray] = None
current_secondary_ch2: Optional[np.ndarray] = None
current_secondary_magnitude: Optional[np.ndarray] = None
current_secondary_phase: Optional[np.ndarray] = None
current_sweep_norm: Optional[np.ndarray] = None
current_fft_mag: Optional[np.ndarray] = None
current_fft_db: Optional[np.ndarray] = None
last_calib_sweep: Optional[np.ndarray] = None
calib_envelope: Optional[np.ndarray] = None
calib_file_path: Optional[str] = None
complex_calib_curve: Optional[np.ndarray] = None
complex_calib_file_path: Optional[str] = None
background_buffer: BackgroundMedianBuffer = field(
default_factory=lambda: BackgroundMedianBuffer(BACKGROUND_MEDIAN_SWEEPS)
)

View File

@ -3,12 +3,14 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, TypeAlias, Union
from typing import Any, Dict, Literal, Optional, Tuple, TypeAlias, Union
import numpy as np
Number = Union[int, float]
SignalKind = Literal["bin_iq", "bin_logdet", "bin_iq_do1_tagged"]
Do1Level = Literal["low", "high"]
SweepInfo = Dict[str, Any]
SweepData = Dict[str, np.ndarray]
SweepAuxCurves = Optional[Tuple[np.ndarray, np.ndarray]]
@ -18,6 +20,7 @@ SweepPacket = Tuple[np.ndarray, SweepInfo, SweepAuxCurves]
@dataclass(frozen=True)
class StartEvent:
ch: Optional[int] = None
signal_kind: Optional[SignalKind] = None
@dataclass(frozen=True)
@ -26,6 +29,20 @@ class PointEvent:
x: int
y: float
aux: Optional[Tuple[float, float]] = None
signal_kind: Optional[SignalKind] = None
do1_level: Optional[Do1Level] = None
is_secondary: bool = False
ParserEvent: TypeAlias = Union[StartEvent, PointEvent]
@dataclass(frozen=True)
class BatchPointEvent:
ch: int
xs: np.ndarray
ys: np.ndarray
aux: Optional[Tuple[np.ndarray, np.ndarray]] = None
signal_kind: Optional[SignalKind] = None
do1_level: Optional[Do1Level] = None
is_secondary: bool = False
ParserEvent: TypeAlias = Union[StartEvent, PointEvent, BatchPointEvent]

376
rfg_vna_viewer.py Normal file
View File

@ -0,0 +1,376 @@
#!/usr/bin/env python3
"""Standalone VNA-style viewer for dual-channel ADC data.
Reads raw binary from /tmp/ttyADC_data, parses 8-byte records with
markers 0x000A (main) and 0x00A8 (reference), performs complex
normalization and plots amplitude, phase, and FFT in real time.
Uses the project's existing LegacyBinaryParser + SweepAssembler for
reliable sweep boundary detection.
"""
import os
import signal
import sys
from collections import deque
import numpy as np
import pyqtgraph as pg
from pyqtgraph.Qt import QtCore, QtWidgets
from rfg_adc_plotter.io.sweep_parser_core import LegacyBinaryParser, SweepAssembler
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
F_START_HZ = 2.046e9
F_STOP_HZ = 5.612e9
BW_HZ = F_STOP_HZ - F_START_HZ # 3.566 GHz
TTY_SCALE = 5.0 / 32767.0 # int16 code → volts (±5 V ADC range)
C_M_S = 299_792_458.0
FFT_LEN = 2048
TIMER_MS = 50 # GUI refresh period
READ_CHUNK = 65536
MIN_SWEEP_POINTS = 400
DATA_PATH = "/tmp/ttyADC_data"
# ---------------------------------------------------------------------------
# DataReader
# ---------------------------------------------------------------------------
class DataReader:
"""Non-blocking raw reader for /tmp/ttyADC_data (PTY-safe)."""
def __init__(self, path=DATA_PATH):
import termios, tty
self._fd = os.open(path, os.O_RDONLY | os.O_NOCTTY)
try:
tty.setraw(self._fd) # disable line discipline processing
except termios.error:
pass # not a TTY — fine, skip
os.set_blocking(self._fd, False)
def read_available(self):
chunks = []
while True:
try:
data = os.read(self._fd, READ_CHUNK)
if not data:
break
chunks.append(data)
except BlockingIOError:
break
return b"".join(chunks)
def close(self):
os.close(self._fd)
# ---------------------------------------------------------------------------
# Sweep extraction helper
# ---------------------------------------------------------------------------
def extract_sweep(packet):
"""Extract main/ref ch1/ch2 arrays from a SweepPacket.
Returns dict with main_ch1, main_ch2, ref_ch1, ref_ch2, num_points
or None if data is incomplete.
"""
sweep_arr, info, aux = packet
# Main channel ch1/ch2 from aux
if aux is None:
return None
main_ch1 = np.asarray(aux[0], dtype=np.float64)
main_ch2 = np.asarray(aux[1], dtype=np.float64)
# Secondary (ref) channel from info payload
sec = info.get("_secondary_payload")
if sec is None:
return None
ref_ch1 = np.asarray(sec["ch1"], dtype=np.float64)
ref_ch2 = np.asarray(sec["ch2"], dtype=np.float64)
# Keep only points present in both channels
valid = np.isfinite(main_ch1) & np.isfinite(ref_ch1)
nvalid = int(valid.sum())
if nvalid < MIN_SWEEP_POINTS:
return None
return {
"main_ch1": main_ch1[valid],
"main_ch2": main_ch2[valid],
"ref_ch1": ref_ch1[valid],
"ref_ch2": ref_ch2[valid],
"num_points": nvalid,
}
# ---------------------------------------------------------------------------
# Signal processing
# ---------------------------------------------------------------------------
def process_reference(ref_ch1, ref_ch2):
"""Process reference channel: amplitude and phase.
Returns (amplitude, phase).
"""
ch1_v = ref_ch1 * TTY_SCALE
ch2_v = ref_ch2 * TTY_SCALE
amplitude = np.sqrt(ch1_v ** 2 + ch2_v ** 2)
phase = np.unwrap(np.arctan2(ch2_v, ch1_v))
return amplitude, phase
def process_main(main_ch1, main_ch2, ref_amplitude, ref_phase_aligned):
"""Normalize main channel by reference.
Returns (main_amp, ref_amp, norm_ch1, norm_ch2, amp_norm, phase_norm, fft_mag, fft_dist).
"""
ch1_v = main_ch1 * TTY_SCALE
ch2_v = main_ch2 * TTY_SCALE
z_main = ch1_v + 1j * ch2_v
main_amp = np.abs(z_main)
# Normalize by ref amplitude and subtract ref phase; skip where ref < epsilon
EPS = 1e-12
mask = ref_amplitude > EPS
ch1_norm = np.where(mask, ch1_v / ref_amplitude, ch1_v)
ch2_norm = np.where(mask, ch2_v / ref_amplitude, ch2_v)
phase_corr = np.where(mask, ref_phase_aligned, 0.0)
z_norm = (ch1_norm + 1j * ch2_norm) * np.exp(-1j * phase_corr)
norm_ch1 = np.real(z_norm)
norm_ch2 = np.imag(z_norm)
amp_norm = np.abs(z_norm)
phase_norm = np.unwrap(np.angle(z_norm))
# FFT → distance domain
n = len(z_norm)
window = np.hanning(n)
spectrum = np.fft.fft(z_norm * window, n=FFT_LEN)
fft_mag = np.abs(spectrum[: FFT_LEN // 2])
df_hz = BW_HZ / max(1, n - 1)
dist_step = C_M_S / (2.0 * FFT_LEN * df_hz)
fft_dist = np.arange(FFT_LEN // 2) * dist_step
return main_amp, ref_amplitude, norm_ch1, norm_ch2, amp_norm, phase_norm, fft_mag, fft_dist
# ---------------------------------------------------------------------------
# GUI
# ---------------------------------------------------------------------------
def build_gui():
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv)
win = pg.GraphicsLayoutWidget(show=True, title="RFG VNA Viewer")
win.resize(1200, 800)
# Row 0: raw amplitudes of both channels
p_raw = win.addPlot(row=0, col=0, title="Амплитуды каналов")
p_raw.showGrid(x=True, y=True, alpha=0.3)
p_raw.setLabel("bottom", "Частота", units="ГГц")
p_raw.setLabel("left", "Амплитуда", units="В")
p_raw.addLegend(offset=(10, 10))
c_main_amp = p_raw.plot(pen=pg.mkPen((80, 120, 255), width=1), name="Main (0a00)")
c_ref_amp = p_raw.plot(pen=pg.mkPen((255, 80, 80), width=1), name="Ref (a800)")
# Row 1: normalized CH1 / CH2 (Re/Im of main/ref)
p_ch = win.addPlot(row=1, col=0, title="Main нормированный: CH1 (Re), CH2 (Im)")
p_ch.showGrid(x=True, y=True, alpha=0.3)
p_ch.setLabel("bottom", "Частота", units="ГГц")
p_ch.setLabel("left", "Значение")
p_ch.setXLink(p_raw)
p_ch.addLegend(offset=(10, 10))
c_norm_ch1 = p_ch.plot(pen=pg.mkPen((80, 120, 255), width=1), name="CH1 (Re)")
c_norm_ch2 = p_ch.plot(pen=pg.mkPen((255, 80, 80), width=1), name="CH2 (Im)")
# Row 2: normalized amplitude
p_norm = win.addPlot(row=2, col=0, title="Нормированная амплитуда |S|")
p_norm.showGrid(x=True, y=True, alpha=0.3)
p_norm.setLabel("bottom", "Частота", units="ГГц")
p_norm.setLabel("left", "Амплитуда")
p_norm.setXLink(p_raw)
c_norm_amp = p_norm.plot(pen=pg.mkPen((80, 120, 255), width=1))
# Row 3: normalized phase + linear fit + deviation (right axis)
p_ph = win.addPlot(row=3, col=0, title="Нормированная фаза arg(S)")
p_ph.showGrid(x=True, y=True, alpha=0.3)
p_ph.setLabel("bottom", "Частота", units="ГГц")
p_ph.setLabel("left", "Фаза", units="рад")
p_ph.setXLink(p_raw)
p_ph.addLegend(offset=(10, 10))
c_ph = p_ph.plot(pen=pg.mkPen((230, 180, 40), width=1), name="Фаза")
c_ph_line = p_ph.plot(pen=pg.mkPen((180, 180, 180), width=1, style=QtCore.Qt.DashLine), name="Лин. прибл.")
# Secondary Y axis (right) for deviation, fixed -1..1
vb_dev = pg.ViewBox()
p_ph.showAxis("right")
p_ph.scene().addItem(vb_dev)
p_ph.getAxis("right").linkToView(vb_dev)
vb_dev.setXLink(p_ph)
p_ph.setLabel("right", "Отклонение", units="рад")
p_ph.getAxis("right").setPen(pg.mkPen((255, 100, 200)))
c_ph_dev = pg.PlotCurveItem(pen=pg.mkPen((255, 100, 200), width=1), name="Отклонение")
vb_dev.addItem(c_ph_dev)
vb_dev.setYRange(-3, 3)
vb_dev.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False)
# Keep secondary ViewBox geometry in sync
def sync_views():
vb_dev.setGeometry(p_ph.vb.sceneBoundingRect())
vb_dev.linkedViewChanged(p_ph.vb, vb_dev.XAxis)
p_ph.vb.sigResized.connect(sync_views)
# Row 4: FFT distance
p_fft = win.addPlot(row=4, col=0, title="FFT — расстояние")
p_fft.showGrid(x=True, y=True, alpha=0.3)
p_fft.setLabel("bottom", "Расстояние", units="м")
p_fft.setLabel("left", "Магнитуда", units="дБ")
c_fft = p_fft.plot(pen=pg.mkPen((60, 200, 80), width=1))
plots = [p_raw, p_ch, p_norm, p_ph, p_fft]
curves = (c_main_amp, c_ref_amp, c_norm_ch1, c_norm_ch2, c_norm_amp, c_ph, c_ph_line, c_ph_dev, c_fft)
return app, win, curves, plots
# ---------------------------------------------------------------------------
# Update loop
# ---------------------------------------------------------------------------
def make_update(reader, parser, assembler, curves, plots):
c_main_amp, c_ref_amp, c_norm_ch1, c_norm_ch2, c_norm_amp, c_ph, c_ph_line, c_ph_dev, c_fft = curves
state = {"ref_phase_first": None, "ref_phi0": None, "ref_phi1": None, "axes_locked": False}
queue = deque(maxlen=64)
def update():
# Read → parse → assemble → queue
data = reader.read_available()
if data:
events = parser.feed(data)
for ev in events:
packet = assembler.consume(ev)
if packet is not None:
sw = extract_sweep(packet)
if sw is not None:
queue.append(sw)
# Draw exactly one sweep per tick
if not queue:
return
sweep = queue.popleft()
n = sweep["num_points"]
print(f"[VNA] queue={len(queue)} points={n} "
f"main=[{sweep['main_ch1'].min():.0f}..{sweep['main_ch1'].max():.0f}] "
f"ref=[{sweep['ref_ch1'].min():.0f}..{sweep['ref_ch1'].max():.0f}]")
if n < 2:
return
ref_amp, ref_phase = process_reference(
sweep["ref_ch1"], sweep["ref_ch2"]
)
# Capture phi0, phi1 from first sweep only
if state["ref_phi0"] is None:
state["ref_phi0"] = ref_phase[0]
state["ref_phi1"] = ref_phase[-1]
# Compute frequency axis from reference signal phase (linear phase-freq model)
phi0, phi1 = state["ref_phi0"], state["ref_phi1"]
freqs_ghz = (F_START_HZ / 1e9) + (ref_phase - phi0) / (phi1 - phi0) * (BW_HZ / 1e9)
freqs_hz = freqs_ghz * 1e9
# Fix reference phase from the first sweep
if state["ref_phase_first"] is None:
state["ref_phase_first"] = ref_phase[0]
main_amp, ref_amplitude, norm_ch1, norm_ch2, norm_amp, phase, fft_mag, fft_dist = process_main(
sweep["main_ch1"], sweep["main_ch2"], ref_amp, state["ref_phase_first"]
)
c_main_amp.setData(freqs_ghz, main_amp)
c_ref_amp.setData(freqs_ghz, ref_amplitude)
c_norm_ch1.setData(freqs_ghz, norm_ch1)
c_norm_ch2.setData(freqs_ghz, norm_ch2)
c_norm_amp.setData(freqs_ghz, norm_amp)
c_ph.setData(freqs_ghz, phase)
# Linear fit (line through first and last point) + deviation
line = np.linspace(phase[0], phase[-1], len(phase))
deviation = phase - line
c_ph_line.setData(freqs_ghz, line)
c_ph_dev.setData(freqs_ghz, deviation)
fft_db = 20.0 * np.log10(fft_mag + 1e-12)
c_fft.setData(fft_dist, fft_db)
# Lock axes after first sweep — compute ranges from data, then freeze
if not state["axes_locked"]:
state["axes_locked"] = True
p_raw, p_ch, p_norm, p_ph, p_fft = plots
fx0, fx1 = freqs_ghz[0], freqs_ghz[-1]
# Row 0: raw amplitudes
y_all = np.concatenate([main_amp, ref_amplitude])
p_raw.disableAutoRange()
p_raw.setXRange(fx0, fx1, padding=0)
p_raw.setYRange(float(y_all.min()), float(y_all.max()), padding=0.05)
# Row 1: normalized ch1/ch2
y_all = np.concatenate([norm_ch1, norm_ch2])
p_ch.disableAutoRange()
p_ch.setXRange(fx0, fx1, padding=0)
p_ch.setYRange(float(y_all.min()), float(y_all.max()), padding=0.05)
# Row 2: normalized amplitude
p_norm.disableAutoRange()
p_norm.setXRange(fx0, fx1, padding=0)
p_norm.setYRange(float(norm_amp.min()), float(norm_amp.max()), padding=0.05)
# Row 3: phase + line (deviation is on separate right axis, fixed -1..1)
y_all = np.concatenate([phase, line])
p_ph.disableAutoRange()
p_ph.setXRange(fx0, fx1, padding=0)
p_ph.setYRange(float(y_all.min()), float(y_all.max()), padding=0.05)
# Row 4: FFT
p_fft.disableAutoRange()
p_fft.setXRange(float(fft_dist[0]), float(fft_dist[-1]), padding=0)
p_fft.setYRange(float(fft_db.min()), float(fft_db.max()), padding=0.05)
return update
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
path = sys.argv[1] if len(sys.argv) > 1 else DATA_PATH
reader = DataReader(path)
parser = LegacyBinaryParser(batch_events=True)
assembler = SweepAssembler(fancy=False, apply_inversion=False)
app, win, curves, plots = build_gui()
update = make_update(reader, parser, assembler, curves, plots)
timer = QtCore.QTimer()
timer.timeout.connect(update)
timer.start(TIMER_MS)
# Allow Ctrl+C to work inside Qt event loop
signal.signal(signal.SIGINT, lambda *_: app.quit())
kick = QtCore.QTimer()
kick.start(200)
kick.timeout.connect(lambda: None)
try:
sys.exit(app.exec_())
finally:
reader.close()
if __name__ == "__main__":
main()

View File

@ -5,6 +5,8 @@ import sys
import unittest
from pathlib import Path
from rfg_adc_plotter.cli import build_parser
ROOT = Path(__file__).resolve().parents[1]
@ -20,6 +22,17 @@ def _run(*args: str) -> subprocess.CompletedProcess[str]:
class CliTests(unittest.TestCase):
def test_logscale_and_opengl_are_opt_in(self):
args = build_parser().parse_args(["/dev/null"])
self.assertFalse(args.logscale)
self.assertFalse(args.opengl)
self.assertAlmostEqual(float(args.tty_range_v), 5.0, places=6)
args_log = build_parser().parse_args(["/dev/null", "--logscale", "--opengl", "--tty-range-v", "2.5"])
self.assertTrue(args_log.logscale)
self.assertTrue(args_log.opengl)
self.assertAlmostEqual(float(args_log.tty_range_v), 2.5, places=6)
def test_wrapper_help_works(self):
proc = _run("RFG_ADC_dataplotter.py", "--help")
self.assertEqual(proc.returncode, 0)
@ -31,6 +44,9 @@ class CliTests(unittest.TestCase):
self.assertEqual(proc.returncode, 0)
self.assertIn("usage:", proc.stdout)
self.assertIn("--parser_16_bit_x2", proc.stdout)
self.assertIn("--parser_complex_ascii", proc.stdout)
self.assertIn("--opengl", proc.stdout)
self.assertIn("0x00A3/0x00A4", proc.stdout)
def test_backend_mpl_reports_removal(self):
proc = _run("-m", "rfg_adc_plotter.main", "/dev/null", "--backend", "mpl")

View File

@ -5,19 +5,49 @@ import tempfile
import numpy as np
import unittest
from rfg_adc_plotter.constants import FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
from rfg_adc_plotter.constants import C_M_S, FFT_LEN, SWEEP_FREQ_MAX_GHZ, SWEEP_FREQ_MIN_GHZ
from rfg_adc_plotter.gui.pyqtgraph_backend import (
apply_distance_cut_to_axis,
apply_working_range,
apply_working_range_to_aux_curves,
build_logdet_voltage_fft_input,
build_main_window_layout,
coalesce_packets_for_ui,
compute_background_subtracted_bscan_levels,
compute_aux_phase_curve,
compute_do1_tagged_aggregate,
compute_do1_tagged_phase_curves,
convert_tty_i16_to_voltage,
decimate_bscan_rows_for_display,
decimate_curve_for_display,
display_distance_axis,
display_distance_axis_for_mode,
display_distance_value,
display_distance_value_for_mode,
fft_bscan_image_to_db,
is_short_sweep,
resolve_axis_bounds,
resolve_bscan_refresh_stride,
resolve_heavy_refresh_stride,
resolve_initial_window_size,
resolve_distance_cut_start,
update_expected_sweep_width,
sanitize_curve_data_for_display,
sanitize_image_for_display,
set_image_rect_if_ready,
resolve_visible_fft_curves,
resolve_visible_aux_curves,
resolve_visible_do1_tagged_aux_curves,
)
from rfg_adc_plotter.processing.calibration import (
build_calib_envelope,
build_complex_calibration_curve,
calibrate_freqs,
load_calib_envelope,
load_complex_calibration,
recalculate_calibration_c,
save_calib_envelope,
save_complex_calibration,
)
from rfg_adc_plotter.processing.background import (
load_fft_background,
@ -25,16 +55,20 @@ from rfg_adc_plotter.processing.background import (
subtract_fft_background,
)
from rfg_adc_plotter.processing.fft import (
build_positive_only_exact_centered_ifft_spectrum,
build_positive_only_centered_ifft_spectrum,
build_symmetric_ifft_spectrum,
compute_distance_axis,
compute_fft_complex_row,
compute_fft_mag_row,
compute_fft_row,
fft_mag_to_db,
)
from rfg_adc_plotter.processing.normalization import (
build_calib_envelopes,
fit_complex_calibration_to_width,
normalize_by_calib,
normalize_by_complex_calibration,
normalize_by_envelope,
resample_envelope,
)
@ -42,6 +76,40 @@ from rfg_adc_plotter.processing.peaks import find_peak_width_markers, find_top_p
class ProcessingTests(unittest.TestCase):
def test_convert_tty_i16_to_voltage_maps_and_clips_full_range(self):
codes = np.asarray([-32768.0, -16384.0, 0.0, 16384.0, 32767.0], dtype=np.float32)
volts = convert_tty_i16_to_voltage(codes, 5.0)
self.assertEqual(volts.shape, codes.shape)
self.assertAlmostEqual(float(volts[0]), -5.0, places=6)
self.assertAlmostEqual(float(volts[2]), 0.0, places=6)
self.assertAlmostEqual(float(volts[-1]), 5.0, places=6)
self.assertTrue(np.all(volts >= -5.0))
self.assertTrue(np.all(volts <= 5.0))
def test_build_logdet_voltage_fft_input_converts_codes_and_exponentiates(self):
codes = np.asarray([-32768.0, 0.0, 32767.0], dtype=np.float32)
volts, fft_input = build_logdet_voltage_fft_input(codes, 5.0)
self.assertEqual(volts.shape, codes.shape)
self.assertEqual(fft_input.shape, codes.shape)
self.assertAlmostEqual(float(volts[0]), -5.0, places=6)
self.assertAlmostEqual(float(volts[1]), 0.0, places=6)
self.assertAlmostEqual(float(volts[2]), 5.0, places=6)
self.assertTrue(np.allclose(fft_input, np.exp(volts.astype(np.float32))))
def test_build_logdet_voltage_fft_input_clips_exp_argument_and_respects_range(self):
codes = np.asarray([32767.0], dtype=np.float32)
volts_5, fft_5 = build_logdet_voltage_fft_input(codes, 5.0, exp_input_limit=2.0)
volts_10, fft_10 = build_logdet_voltage_fft_input(codes, 10.0, exp_input_limit=2.0)
self.assertAlmostEqual(float(volts_5[0]), 5.0, places=6)
self.assertAlmostEqual(float(volts_10[0]), 10.0, places=6)
self.assertAlmostEqual(float(fft_5[0]), float(np.exp(np.float32(2.0))), places=5)
self.assertAlmostEqual(float(fft_10[0]), float(np.exp(np.float32(2.0))), places=5)
self.assertTrue(np.isfinite(fft_5[0]))
self.assertTrue(np.isfinite(fft_10[0]))
def test_recalculate_calibration_preserves_requested_edges(self):
coeffs = recalculate_calibration_c(np.asarray([0.0, 1.0, 0.025], dtype=np.float64), 3.3, 14.3)
y0 = coeffs[0] + coeffs[1] * 3.3 + coeffs[2] * (3.3 ** 2)
@ -56,6 +124,18 @@ class ProcessingTests(unittest.TestCase):
self.assertEqual(calibrated["I"].shape, (32,))
self.assertTrue(np.all(np.diff(calibrated["F"]) >= 0.0))
def test_calibrate_freqs_keeps_complex_payload(self):
sweep = {
"F": np.linspace(3.3, 14.3, 32),
"I": np.exp(1j * np.linspace(0.0, np.pi, 32)).astype(np.complex64),
}
calibrated = calibrate_freqs(sweep)
self.assertEqual(calibrated["F"].shape, (32,))
self.assertEqual(calibrated["I"].shape, (32,))
self.assertTrue(np.iscomplexobj(calibrated["I"]))
self.assertTrue(np.all(np.isfinite(calibrated["I"])))
def test_normalizers_and_envelopes_return_finite_ranges(self):
calib = (np.sin(np.linspace(0.0, 4.0 * np.pi, 64)) * 5.0).astype(np.float32)
raw = calib * 0.75
@ -105,6 +185,15 @@ class ProcessingTests(unittest.TestCase):
self.assertAlmostEqual(float(normalized[1]), 2.0, places=5)
self.assertAlmostEqual(float(normalized[2]), -3.0, places=5)
def test_normalize_by_envelope_supports_complex_input(self):
raw = np.asarray([1.0 + 1.0j, 2.0 - 2.0j], dtype=np.complex64)
envelope = np.asarray([1.0, 2.0], dtype=np.float32)
normalized = normalize_by_envelope(raw, envelope)
self.assertTrue(np.iscomplexobj(normalized))
self.assertTrue(np.all(np.isfinite(normalized)))
self.assertTrue(np.allclose(normalized, np.asarray([1.0 + 1.0j, 1.0 - 1.0j], dtype=np.complex64)))
def test_load_calib_envelope_rejects_empty_payload(self):
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "empty.npy")
@ -112,6 +201,46 @@ class ProcessingTests(unittest.TestCase):
with self.assertRaises(ValueError):
load_calib_envelope(path)
def test_complex_calibration_curve_roundtrip(self):
ch1 = np.asarray([1.0, 2.0, 3.0], dtype=np.float32)
ch2 = np.asarray([0.5, -1.0, 4.0], dtype=np.float32)
curve = build_complex_calibration_curve(ch1, ch2)
expected = np.asarray([1.0 + 0.5j, 2.0 - 1.0j, 3.0 + 4.0j], dtype=np.complex64)
self.assertTrue(np.iscomplexobj(curve))
self.assertTrue(np.allclose(curve, expected))
with tempfile.TemporaryDirectory() as tmp_dir:
path = os.path.join(tmp_dir, "complex_calibration")
saved_path = save_complex_calibration(path, curve)
loaded = load_complex_calibration(saved_path)
self.assertTrue(saved_path.endswith(".npy"))
self.assertEqual(loaded.dtype, np.complex64)
self.assertTrue(np.allclose(loaded, expected))
def test_fit_complex_calibration_to_width_pads_or_trims(self):
calib = np.asarray([1.0 + 1.0j, 2.0 + 2.0j], dtype=np.complex64)
padded = fit_complex_calibration_to_width(calib, 4)
trimmed = fit_complex_calibration_to_width(
np.asarray([1.0 + 1.0j, 2.0 + 2.0j, 3.0 + 3.0j], dtype=np.complex64),
2,
)
self.assertEqual(padded.shape, (4,))
self.assertTrue(np.allclose(padded, np.asarray([1.0 + 1.0j, 2.0 + 2.0j, 1.0 + 0.0j, 1.0 + 0.0j], dtype=np.complex64)))
self.assertEqual(trimmed.shape, (2,))
self.assertTrue(np.allclose(trimmed, np.asarray([1.0 + 1.0j, 2.0 + 2.0j], dtype=np.complex64)))
def test_normalize_by_complex_calibration_handles_zero_and_length_mismatch(self):
signal = np.asarray([2.0 + 2.0j, 4.0 + 0.0j, 3.0 + 3.0j], dtype=np.complex64)
calib = np.asarray([1.0 + 1.0j, 0.0 + 0.0j], dtype=np.complex64)
normalized = normalize_by_complex_calibration(signal, calib)
expected = np.asarray([2.0 + 0.0j, 4.0 + 0.0j, 3.0 + 3.0j], dtype=np.complex64)
self.assertTrue(np.iscomplexobj(normalized))
self.assertTrue(np.all(np.isfinite(normalized)))
self.assertTrue(np.allclose(normalized, expected))
def test_fft_background_roundtrip_and_rejects_non_1d_payload(self):
background = np.asarray([0.5, 1.5, 2.5], dtype=np.float32)
with tempfile.TemporaryDirectory() as tmp_dir:
@ -182,6 +311,293 @@ class ProcessingTests(unittest.TestCase):
self.assertTrue(np.allclose(visible[0], aux[0]))
self.assertTrue(np.allclose(visible[1], aux[1]))
def test_compute_aux_phase_curve_returns_atan2_of_aux_channels(self):
aux = (
np.asarray([1.0, 1.0, -1.0, 0.0], dtype=np.float32),
np.asarray([0.0, 1.0, 1.0, 1.0], dtype=np.float32),
)
phase = compute_aux_phase_curve(aux)
self.assertIsNotNone(phase)
expected = np.asarray([0.0, np.pi / 4.0, 3.0 * np.pi / 4.0, np.pi / 2.0], dtype=np.float32)
self.assertEqual(phase.shape, expected.shape)
self.assertTrue(np.allclose(phase, expected, atol=1e-6))
def test_compute_do1_tagged_aggregate_nanmean_merges_low_and_high(self):
low = np.asarray([1.0, np.nan, 5.0, np.nan], dtype=np.float32)
high = np.asarray([3.0, 7.0, np.nan, np.nan], dtype=np.float32)
merged = compute_do1_tagged_aggregate(low, high)
self.assertIsNotNone(merged)
self.assertTrue(np.allclose(merged[:3], np.asarray([2.0, 7.0, 5.0], dtype=np.float32), equal_nan=True))
self.assertTrue(np.isnan(merged[3]))
def test_resolve_visible_do1_tagged_aux_curves_obeys_checkbox_state(self):
aux_low = (
np.asarray([1.0, 2.0], dtype=np.float32),
np.asarray([3.0, 4.0], dtype=np.float32),
)
aux_high = (
np.asarray([5.0, 6.0], dtype=np.float32),
np.asarray([7.0, 8.0], dtype=np.float32),
)
hidden_low, hidden_high = resolve_visible_do1_tagged_aux_curves(aux_low, aux_high, enabled=False)
self.assertIsNone(hidden_low)
self.assertIsNone(hidden_high)
visible_low, visible_high = resolve_visible_do1_tagged_aux_curves(aux_low, aux_high, enabled=True)
self.assertIsNotNone(visible_low)
self.assertIsNotNone(visible_high)
self.assertTrue(np.allclose(visible_low[0], aux_low[0]))
self.assertTrue(np.allclose(visible_high[1], aux_high[1]))
def test_compute_do1_tagged_phase_curves_returns_two_independent_series(self):
aux_low = (
np.asarray([1.0, 1.0], dtype=np.float32),
np.asarray([0.0, 1.0], dtype=np.float32),
)
aux_high = (
np.asarray([1.0, -1.0], dtype=np.float32),
np.asarray([1.0, 1.0], dtype=np.float32),
)
phase_low, phase_high = compute_do1_tagged_phase_curves(aux_low, aux_high)
self.assertIsNotNone(phase_low)
self.assertIsNotNone(phase_high)
self.assertTrue(np.allclose(phase_low, np.asarray([0.0, np.pi / 4.0], dtype=np.float32), atol=1e-6))
self.assertTrue(np.allclose(phase_high, np.asarray([np.pi / 4.0, 3.0 * np.pi / 4.0], dtype=np.float32), atol=1e-6))
def test_decimate_curve_for_display_preserves_small_series(self):
xs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ys = np.linspace(-1.0, 1.0, 64, dtype=np.float32)
decimated_x, decimated_y = decimate_curve_for_display(xs, ys, max_points=128)
self.assertTrue(np.allclose(decimated_x, xs))
self.assertTrue(np.allclose(decimated_y, ys))
def test_decimate_curve_for_display_limits_points_and_keeps_endpoints(self):
xs = np.linspace(3.3, 14.3, 10000, dtype=np.float64)
ys = np.sin(np.linspace(0.0, 12.0 * np.pi, 10000)).astype(np.float32)
decimated_x, decimated_y = decimate_curve_for_display(xs, ys, max_points=512)
self.assertLessEqual(decimated_x.size, 512)
self.assertEqual(decimated_x.shape, decimated_y.shape)
self.assertAlmostEqual(float(decimated_x[0]), float(xs[0]), places=12)
self.assertAlmostEqual(float(decimated_x[-1]), float(xs[-1]), places=12)
self.assertAlmostEqual(float(decimated_y[0]), float(ys[0]), places=6)
self.assertAlmostEqual(float(decimated_y[-1]), float(ys[-1]), places=6)
def test_coalesce_packets_for_ui_keeps_newest_packets(self):
packets = [
(np.asarray([float(idx)], dtype=np.float32), {"sweep": idx}, None)
for idx in range(12)
]
kept, skipped = coalesce_packets_for_ui(packets, max_packets=8, backlog_packets=12)
self.assertEqual(skipped, 10)
self.assertEqual(len(kept), 2)
self.assertEqual(int(kept[0][1]["sweep"]), 10)
self.assertEqual(int(kept[1][1]["sweep"]), 11)
def test_coalesce_packets_for_ui_never_returns_empty_for_non_empty_input(self):
packets = [
(np.asarray([1.0], dtype=np.float32), {"sweep": 1}, None),
]
kept, skipped = coalesce_packets_for_ui(packets, max_packets=0)
self.assertEqual(skipped, 0)
self.assertEqual(len(kept), 1)
self.assertEqual(int(kept[0][1]["sweep"]), 1)
def test_coalesce_packets_for_ui_switches_to_latest_only_on_large_backlog(self):
packets = [
(np.asarray([float(idx)], dtype=np.float32), {"sweep": idx}, None)
for idx in range(40)
]
kept, skipped = coalesce_packets_for_ui(packets, max_packets=8, backlog_packets=40)
self.assertEqual(skipped, 39)
self.assertEqual(len(kept), 1)
self.assertEqual(int(kept[0][1]["sweep"]), 39)
def test_resolve_heavy_refresh_stride_increases_with_backlog(self):
self.assertEqual(resolve_heavy_refresh_stride(0, max_packets=8), 1)
self.assertEqual(resolve_heavy_refresh_stride(8, max_packets=8), 2)
self.assertEqual(resolve_heavy_refresh_stride(16, max_packets=8), 4)
def test_resolve_bscan_refresh_stride_limits_suppression(self):
self.assertEqual(resolve_bscan_refresh_stride(0, max_packets=8), 1)
self.assertEqual(resolve_bscan_refresh_stride(8, max_packets=8), 1)
self.assertEqual(resolve_bscan_refresh_stride(16, max_packets=8), 2)
def test_decimate_bscan_rows_for_display_keeps_shape_consistent(self):
axis = np.linspace(0.0, 1.0, 10, dtype=np.float64)
data = np.arange(50, dtype=np.float32).reshape(10, 5)
dec_axis, dec_data = decimate_bscan_rows_for_display(axis, data, max_points=4)
self.assertEqual(dec_data.shape, (4, 5))
self.assertIsNotNone(dec_axis)
self.assertEqual(dec_axis.shape, (4,))
self.assertAlmostEqual(float(dec_axis[0]), 0.0, places=12)
self.assertAlmostEqual(float(dec_axis[-1]), 1.0, places=12)
def test_decimate_bscan_rows_for_display_handles_missing_axis(self):
data = np.arange(32, dtype=np.float32).reshape(8, 4)
dec_axis, dec_data = decimate_bscan_rows_for_display(None, data, max_points=3)
self.assertIsNone(dec_axis)
self.assertEqual(dec_data.shape, (3, 4))
def test_bscan_background_profile_tracks_decimated_rows(self):
rows = (FFT_LEN // 2) + 1
axis = np.linspace(0.0, 10.0, rows, dtype=np.float64)
background = np.linspace(1.0, 2.0, rows, dtype=np.float32)
residual = np.linspace(0.1, 0.4, rows, dtype=np.float32)
data = background[:, None] + residual[:, None]
dec_axis, dec_data, row_idx = decimate_bscan_rows_for_display(
axis,
data,
max_points=512,
return_indices=True,
)
dec_background = background[row_idx]
subtracted = subtract_fft_background(dec_data, dec_background)
self.assertEqual(dec_axis.shape, (512,))
self.assertEqual(dec_data.shape, (512, 1))
self.assertEqual(row_idx.shape, (512,))
self.assertTrue(np.allclose(subtracted[:, 0], residual[row_idx], atol=1e-6))
def test_update_expected_sweep_width_initializes_from_first_valid_sweep(self):
self.assertEqual(update_expected_sweep_width(0, 411), 411)
def test_update_expected_sweep_width_ignores_tiny_and_short_outliers(self):
expected = update_expected_sweep_width(0, 411)
self.assertEqual(update_expected_sweep_width(expected, 4), 411)
self.assertEqual(update_expected_sweep_width(expected, 180), 411)
def test_update_expected_sweep_width_applies_ema_for_normal_sweeps(self):
self.assertEqual(update_expected_sweep_width(411, 420), 412)
def test_is_short_sweep_compares_against_dynamic_expected_width(self):
self.assertFalse(is_short_sweep(411, 411))
self.assertTrue(is_short_sweep(180, 411))
def test_sanitize_curve_data_for_display_rejects_fully_nonfinite_series(self):
xs, ys = sanitize_curve_data_for_display(
np.asarray([np.nan, np.nan], dtype=np.float64),
np.asarray([np.nan, np.nan], dtype=np.float32),
)
self.assertEqual(xs.shape, (0,))
self.assertEqual(ys.shape, (0,))
def test_sanitize_image_for_display_rejects_fully_nonfinite_frame(self):
data = sanitize_image_for_display(np.full((4, 4), np.nan, dtype=np.float32))
self.assertIsNone(data)
def test_set_image_rect_if_ready_skips_uninitialized_image(self):
class _DummyImageItem:
def __init__(self):
self.calls = 0
def width(self):
return None
def height(self):
return None
def setRect(self, *_args):
self.calls += 1
image_item = _DummyImageItem()
applied = set_image_rect_if_ready(image_item, 0.0, 0.0, 10.0, 1.0)
self.assertFalse(applied)
self.assertEqual(image_item.calls, 0)
def test_resolve_axis_bounds_rejects_nonfinite_ranges(self):
bounds = resolve_axis_bounds(np.asarray([np.nan, np.inf], dtype=np.float64))
self.assertIsNone(bounds)
def test_resolve_distance_cut_start_interpolates_with_percent(self):
axis = np.asarray([0.0, 1.0, 2.0, 3.0], dtype=np.float64)
cut_start = resolve_distance_cut_start(axis, 50.0)
self.assertIsNotNone(cut_start)
self.assertAlmostEqual(float(cut_start), 1.5, places=6)
def test_apply_distance_cut_to_axis_keeps_farthest_point_for_extreme_cut(self):
axis = np.asarray([0.0, 1.0, 2.0, 3.0], dtype=np.float64)
cut_axis, keep_mask = apply_distance_cut_to_axis(axis, 10.0)
self.assertEqual(cut_axis.shape, (1,))
self.assertEqual(keep_mask.shape, axis.shape)
self.assertTrue(bool(keep_mask[-1]))
self.assertAlmostEqual(float(cut_axis[0]), 3.0, places=6)
def test_display_distance_axis_zero_is_at_nine_meters(self):
axis = np.asarray([0.0, 4.5, 9.0], dtype=np.float64)
display_axis = display_distance_axis(axis)
np.testing.assert_allclose(display_axis, np.asarray([9.0, 4.5, 0.0], dtype=np.float64))
self.assertAlmostEqual(display_distance_value(9.0), 0.0, places=12)
def test_display_distance_axis_transform_only_for_positive_only_exact_mode(self):
axis = np.asarray([0.0, 4.5, 9.0], dtype=np.float64)
np.testing.assert_allclose(display_distance_axis_for_mode(axis, "symmetric"), axis)
np.testing.assert_allclose(
display_distance_axis_for_mode(axis, "positive_only_exact"),
np.asarray([9.0, 4.5, 0.0], dtype=np.float64),
)
self.assertAlmostEqual(display_distance_value_for_mode(9.0, "symmetric"), 9.0, places=12)
self.assertAlmostEqual(display_distance_value_for_mode(9.0, "positive_only_exact"), 0.0, places=12)
def test_resolve_initial_window_size_stays_within_small_screen(self):
width, height = resolve_initial_window_size(800, 480)
self.assertLessEqual(width, 800)
self.assertLessEqual(height, 480)
self.assertGreaterEqual(width, 640)
self.assertGreaterEqual(height, 420)
def test_build_main_window_layout_uses_splitter_and_scroll_area(self):
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
try:
from PyQt5 import QtCore, QtWidgets
except Exception as exc: # pragma: no cover - environment-dependent
self.skipTest(f"Qt unavailable: {exc}")
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
main_window = QtWidgets.QWidget()
try:
_layout, splitter, _plot_layout, settings_widget, settings_layout, settings_scroll = build_main_window_layout(
QtCore,
QtWidgets,
main_window,
)
self.assertIsInstance(splitter, QtWidgets.QSplitter)
self.assertIsInstance(settings_scroll, QtWidgets.QScrollArea)
self.assertIs(settings_scroll.widget(), settings_widget)
self.assertIsInstance(settings_layout, QtWidgets.QVBoxLayout)
finally:
main_window.close()
def test_background_subtracted_bscan_levels_ignore_zero_floor(self):
disp_fft_lin = np.zeros((4, 8), dtype=np.float32)
disp_fft_lin[1, 2:6] = np.asarray([0.05, 0.1, 0.5, 2.0], dtype=np.float32)
@ -206,6 +622,17 @@ class ProcessingTests(unittest.TestCase):
self.assertIsNone(levels)
def test_fft_bscan_image_to_db_converts_linear_magnitudes(self):
linear = np.asarray([[1.0, 10.0], [0.0, 100.0]], dtype=np.float32)
displayed = fft_bscan_image_to_db(linear)
self.assertIsNotNone(displayed)
self.assertTrue(np.allclose(displayed, fft_mag_to_db(linear)))
self.assertAlmostEqual(float(displayed[0, 0]), 0.0, places=5)
self.assertGreater(float(displayed[1, 0]), -181.0)
self.assertLess(float(displayed[1, 0]), -179.0)
def test_fft_helpers_return_expected_shapes(self):
sweep = np.sin(np.linspace(0.0, 4.0 * np.pi, 128)).astype(np.float32)
freqs = np.linspace(3.3, 14.3, 128, dtype=np.float64)
@ -247,6 +674,147 @@ class ProcessingTests(unittest.TestCase):
self.assertTrue(np.allclose(spectrum[zero_mask], 0.0))
self.assertTrue(np.any(np.abs(spectrum[pos_idx]) > 0.0))
def test_positive_only_exact_spectrum_uses_direct_index_insertion_without_window(self):
sweep = np.asarray([1.0, 2.0, 3.0], dtype=np.float32)
freqs = np.asarray([4.0, 5.0, 6.0], dtype=np.float64)
spectrum = build_positive_only_exact_centered_ifft_spectrum(sweep, freqs)
self.assertIsNotNone(spectrum)
df = (6.0 - 4.0) / 2.0
f_shift = np.arange(-6.0, 6.0 + (0.5 * df), df, dtype=np.float64)
idx = np.round((freqs - f_shift[0]) / df).astype(np.int64)
zero_mask = (f_shift > -6.0) & (f_shift < 4.0)
self.assertEqual(int(spectrum.size), int(f_shift.size))
self.assertTrue(np.allclose(spectrum[zero_mask], 0.0))
self.assertTrue(np.allclose(spectrum[idx], sweep))
def test_complex_symmetric_ifft_spectrum_uses_conjugate_mirror(self):
sweep = np.exp(1j * np.linspace(0.0, np.pi, 128)).astype(np.complex64)
freqs = np.linspace(4.0, 10.0, 128, dtype=np.float64)
spectrum = build_symmetric_ifft_spectrum(sweep, freqs, fft_len=FFT_LEN)
self.assertIsNotNone(spectrum)
freq_axis = np.linspace(-10.0, 10.0, FFT_LEN, dtype=np.float64)
neg_idx_all = np.flatnonzero(freq_axis <= (-4.0))
pos_idx_all = np.flatnonzero(freq_axis >= 4.0)
band_len = int(min(neg_idx_all.size, pos_idx_all.size))
neg_idx = neg_idx_all[:band_len]
pos_idx = pos_idx_all[-band_len:]
self.assertTrue(np.iscomplexobj(spectrum))
self.assertTrue(np.allclose(spectrum[neg_idx], np.conj(spectrum[pos_idx][::-1])))
def test_compute_fft_helpers_accept_complex_input(self):
sweep = np.exp(1j * np.linspace(0.0, 2.0 * np.pi, 128)).astype(np.complex64)
freqs = np.linspace(3.3, 14.3, 128, dtype=np.float64)
complex_row = compute_fft_complex_row(sweep, freqs, 513, mode="positive_only")
mag = compute_fft_mag_row(sweep, freqs, 513, mode="positive_only")
row = compute_fft_row(sweep, freqs, 513, mode="positive_only")
self.assertEqual(complex_row.shape, (513,))
self.assertTrue(np.iscomplexobj(complex_row))
self.assertEqual(mag.shape, (513,))
self.assertEqual(row.shape, (513,))
self.assertTrue(np.allclose(mag, np.abs(complex_row), equal_nan=True))
self.assertTrue(np.any(np.isfinite(mag)))
self.assertTrue(np.any(np.isfinite(row)))
def test_compute_fft_complex_row_positive_only_exact_matches_manual_ifftshift_ifft(self):
sweep = np.asarray([1.0 + 1.0j, 2.0 + 0.0j, 3.0 - 1.0j], dtype=np.complex64)
freqs = np.asarray([4.0, 5.0, 6.0], dtype=np.float64)
bins = 16
row = compute_fft_complex_row(sweep, freqs, bins, mode="positive_only_exact")
df = (6.0 - 4.0) / 2.0
f_shift = np.arange(-6.0, 6.0 + (0.5 * df), df, dtype=np.float64)
manual_shift = np.zeros((f_shift.size,), dtype=np.complex64)
idx = np.round((freqs - f_shift[0]) / df).astype(np.int64)
manual_shift[idx] = sweep
manual_ifft = np.fft.ifft(np.fft.ifftshift(manual_shift))
expected = np.full((bins,), np.nan + 0j, dtype=np.complex64)
expected[: manual_ifft.size] = np.asarray(manual_ifft, dtype=np.complex64)
self.assertEqual(row.shape, (bins,))
self.assertTrue(np.allclose(row, expected, equal_nan=True))
def test_positive_only_exact_distance_axis_uses_exact_grid_geometry(self):
freqs = np.asarray([4.0, 5.0, 6.0], dtype=np.float64)
bins = 8
axis = compute_distance_axis(freqs, bins, mode="positive_only_exact")
# With a small bins budget the exact-mode grid is downsampled so
# internal IFFT length does not exceed visible bins.
df_hz = 2e9
n_shift = int(np.arange(-6.0, 6.0 + 1.0, 2.0, dtype=np.float64).size)
expected_step = C_M_S / (2.0 * n_shift * df_hz)
expected = np.arange(bins, dtype=np.float64) * expected_step
self.assertEqual(axis.shape, (bins,))
self.assertTrue(np.allclose(axis, expected))
def test_positive_only_exact_mode_remains_stable_when_input_points_double(self):
bins = FFT_LEN // 2 + 1
tau_s = 45e-9
freqs_400 = np.linspace(3.3, 14.3, 400, dtype=np.float64)
freqs_800 = np.linspace(3.3, 14.3, 800, dtype=np.float64)
sweep_400 = np.exp(-1j * 2.0 * np.pi * freqs_400 * 1e9 * tau_s).astype(np.complex64)
sweep_800 = np.exp(-1j * 2.0 * np.pi * freqs_800 * 1e9 * tau_s).astype(np.complex64)
mag_400 = compute_fft_mag_row(sweep_400, freqs_400, bins, mode="positive_only_exact")
mag_800 = compute_fft_mag_row(sweep_800, freqs_800, bins, mode="positive_only_exact")
self.assertEqual(mag_400.shape, mag_800.shape)
finite = np.isfinite(mag_400) & np.isfinite(mag_800)
self.assertGreater(int(np.count_nonzero(finite)), int(0.95 * bins))
idx_400 = int(np.nanargmax(mag_400))
idx_800 = int(np.nanargmax(mag_800))
peak_400 = float(np.nanmax(mag_400))
peak_800 = float(np.nanmax(mag_800))
self.assertLess(abs(idx_400 - idx_800), 64)
self.assertGreater(idx_400, 8)
self.assertGreater(idx_800, 8)
self.assertLess(idx_400, bins - 8)
self.assertLess(idx_800, bins - 8)
self.assertGreater(peak_400, 0.05)
self.assertGreater(peak_800, 0.05)
def test_resolve_visible_fft_curves_handles_complex_mode(self):
complex_row = np.asarray([1.0 + 2.0j, -3.0 + 4.0j], dtype=np.complex64)
mag = np.abs(complex_row).astype(np.float32)
abs_curve, real_curve, imag_curve = resolve_visible_fft_curves(
complex_row,
mag,
complex_mode=True,
show_abs=True,
show_real=False,
show_imag=True,
)
self.assertTrue(np.allclose(abs_curve, mag))
self.assertIsNone(real_curve)
self.assertTrue(np.allclose(imag_curve, np.asarray([2.0, 4.0], dtype=np.float32)))
def test_resolve_visible_fft_curves_preserves_legacy_abs_mode(self):
mag = np.asarray([1.0, 2.0, 3.0], dtype=np.float32)
abs_curve, real_curve, imag_curve = resolve_visible_fft_curves(
None,
mag,
complex_mode=False,
show_abs=True,
show_real=True,
show_imag=True,
)
self.assertTrue(np.allclose(abs_curve, mag))
self.assertIsNone(real_curve)
self.assertIsNone(imag_curve)
def test_symmetric_distance_axis_uses_windowed_frequency_bounds(self):
freqs = np.linspace(4.0, 10.0, 128, dtype=np.float64)
axis = compute_distance_axis(freqs, 513, mode="symmetric")

View File

@ -2,7 +2,10 @@ from __future__ import annotations
import numpy as np
import unittest
import warnings
from unittest.mock import patch
from rfg_adc_plotter.processing.fft import compute_fft_mag_row
from rfg_adc_plotter.state.ring_buffer import RingBuffer
@ -17,7 +20,9 @@ class RingBufferTests(unittest.TestCase):
self.assertIsNotNone(ring.distance_axis)
self.assertIsNotNone(ring.get_last_fft_linear())
self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.width, 64)
self.assertEqual(ring.ring.shape[0], 4)
self.assertEqual(ring.ring.shape[1], 64)
self.assertEqual(ring.ring_fft.shape, (4, ring.fft_bins))
def test_ring_buffer_reallocates_when_sweep_width_grows(self):
@ -29,6 +34,14 @@ class RingBufferTests(unittest.TestCase):
self.assertIsNotNone(ring.ring)
self.assertEqual(ring.ring.shape, (3, ring.width))
def test_ring_buffer_reallocates_when_sweep_width_shrinks(self):
ring = RingBuffer(max_sweeps=3)
ring.push(np.ones((2048,), dtype=np.float32), np.linspace(3.3, 14.3, 2048))
ring.push(np.ones((256,), dtype=np.float32), np.linspace(3.3, 14.3, 256))
self.assertEqual(ring.width, 256)
self.assertIsNotNone(ring.ring)
self.assertEqual(ring.ring.shape, (3, 256))
def test_ring_buffer_tracks_latest_fft_and_display_arrays(self):
ring = RingBuffer(max_sweeps=2)
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), np.linspace(3.3, 14.3, 64))
@ -40,6 +53,22 @@ class RingBufferTests(unittest.TestCase):
self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.last_fft_db.shape, (ring.fft_bins,))
def test_ring_buffer_can_return_decimated_display_raw(self):
ring = RingBuffer(max_sweeps=3)
sweep_a = np.linspace(0.0, 1.0, 4096, dtype=np.float32)
sweep_b = np.linspace(1.0, 2.0, 4096, dtype=np.float32)
sweep_c = np.linspace(2.0, 3.0, 4096, dtype=np.float32)
freqs = np.linspace(3.3, 14.3, 4096, dtype=np.float64)
ring.push(sweep_a, freqs)
ring.push(sweep_b, freqs)
ring.push(sweep_c, freqs)
raw = ring.get_display_raw_decimated(256)
self.assertEqual(raw.shape, (256, 3))
self.assertAlmostEqual(float(raw[0, -1]), float(sweep_c[0]), places=6)
self.assertAlmostEqual(float(raw[-1, -1]), float(sweep_c[-1]), places=6)
def test_ring_buffer_can_switch_fft_mode_and_rebuild_fft_rows(self):
ring = RingBuffer(max_sweeps=2)
sweep = np.linspace(0.0, 1.0, 64, dtype=np.float32)
@ -72,6 +101,34 @@ class RingBufferTests(unittest.TestCase):
self.assertEqual(ring.last_fft_db.shape, (ring.fft_bins,))
self.assertIsNotNone(ring.distance_axis)
def test_ring_buffer_can_switch_to_positive_only_exact_fft_mode(self):
ring = RingBuffer(max_sweeps=2)
sweep = np.linspace(0.0, 1.0, 64, dtype=np.float32)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ring.push(sweep, freqs)
changed = ring.set_fft_mode("positive_only_exact")
self.assertTrue(changed)
self.assertEqual(ring.fft_mode, "positive_only_exact")
self.assertIsNotNone(ring.last_fft_db)
self.assertEqual(ring.last_fft_db.shape, (ring.fft_bins,))
self.assertIsNotNone(ring.distance_axis)
def test_ring_buffer_rebuilds_fft_from_complex_input(self):
ring = RingBuffer(max_sweeps=2)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
complex_input = np.exp(1j * np.linspace(0.0, 2.0 * np.pi, 64)).astype(np.complex64)
display_sweep = np.abs(complex_input).astype(np.float32)
ring.push(display_sweep, freqs, fft_input=complex_input)
ring.set_fft_mode("direct")
expected = compute_fft_mag_row(complex_input, freqs, ring.fft_bins, mode="direct")
self.assertTrue(np.allclose(ring.get_last_fft_linear(), expected))
self.assertFalse(np.iscomplexobj(ring.get_display_fft_linear()))
self.assertTrue(np.allclose(ring.get_display_raw()[: display_sweep.size, -1], display_sweep))
def test_ring_buffer_reset_clears_cached_history(self):
ring = RingBuffer(max_sweeps=2)
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), np.linspace(4.0, 10.0, 64))
@ -85,6 +142,45 @@ class RingBufferTests(unittest.TestCase):
self.assertEqual(ring.width, 0)
self.assertEqual(ring.head, 0)
def test_ring_buffer_push_ignores_all_nan_fft_without_runtime_warning(self):
ring = RingBuffer(max_sweeps=2)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), freqs)
fft_before = ring.last_fft_db.copy()
y_min_before = ring.y_min_fft
y_max_before = ring.y_max_fft
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
with patch(
"rfg_adc_plotter.state.ring_buffer.compute_fft_mag_row",
return_value=np.full((ring.fft_bins,), np.nan, dtype=np.float32),
):
ring.push(np.linspace(1.0, 2.0, 64, dtype=np.float32), freqs)
self.assertFalse(ring.last_push_fft_valid)
self.assertTrue(np.allclose(ring.last_fft_db, fft_before))
self.assertEqual(ring.y_min_fft, y_min_before)
self.assertEqual(ring.y_max_fft, y_max_before)
def test_ring_buffer_set_fft_mode_ignores_all_nan_rebuild_without_runtime_warning(self):
ring = RingBuffer(max_sweeps=2)
freqs = np.linspace(3.3, 14.3, 64, dtype=np.float64)
ring.push(np.linspace(0.0, 1.0, 64, dtype=np.float32), freqs)
fft_before = ring.last_fft_db.copy()
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
with patch(
"rfg_adc_plotter.state.ring_buffer.compute_fft_mag_row",
return_value=np.full((ring.fft_bins,), np.nan, dtype=np.float32),
):
ring.set_fft_mode("direct")
self.assertFalse(ring.last_push_fft_valid)
self.assertTrue(np.allclose(ring.last_fft_db, fft_before))
self.assertEqual(ring.fft_mode, "direct")
if __name__ == "__main__":
unittest.main()

View File

@ -3,8 +3,12 @@ from __future__ import annotations
import math
import unittest
import numpy as np
from rfg_adc_plotter.io.sweep_parser_core import (
AsciiSweepParser,
BatchPointEvent,
ComplexAsciiSweepParser,
LegacyBinaryParser,
LogScale16BitX2BinaryParser,
LogScaleBinaryParser32,
@ -71,6 +75,51 @@ def _pack_log16_point(step: int, avg1: int, avg2: int) -> bytes:
)
def _pack_tty_start() -> bytes:
return b"".join([_u16le(0x000A), _u16le(0xFFFF), _u16le(0xFFFF), _u16le(0xFFFF)])
def _pack_tty_point(step: int, ch1: int, ch2: int) -> bytes:
return b"".join(
[
_u16le(0x000A),
_u16le(step),
_u16le(ch1),
_u16le(ch2),
]
)
def _pack_tty_tagged_point(marker_word0: int, step: int, ch1: int, ch2: int) -> bytes:
return b"".join(
[
_u16le(marker_word0),
_u16le(step),
_u16le(ch1),
_u16le(ch2),
]
)
def _pack_tty_tagged_low_point(step: int, ch1: int, ch2: int) -> bytes:
return _pack_tty_tagged_point(0x00A3, step, ch1, ch2)
def _pack_tty_tagged_high_point(step: int, ch1: int, ch2: int) -> bytes:
return _pack_tty_tagged_point(0x00A4, step, ch1, ch2)
def _pack_logdet_point(step: int, value: int) -> bytes:
return b"".join(
[
_u16le(0x001A),
_u16le(step),
_u16le(value),
_u16le(0x0000),
]
)
class SweepParserCoreTests(unittest.TestCase):
def test_ascii_parser_emits_start_and_points(self):
parser = AsciiSweepParser()
@ -96,6 +145,281 @@ class SweepParserCoreTests(unittest.TestCase):
self.assertEqual(events[1].x, 1)
self.assertEqual(events[1].y, -2.0)
def test_legacy_binary_parser_detects_new_sweep_on_step_reset(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_legacy_point(3, 1, -2),
_pack_legacy_point(3, 2, -3),
_pack_legacy_point(3, 1, -4),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], PointEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], StartEvent)
self.assertEqual(events[2].ch, 3)
self.assertIsInstance(events[3], PointEvent)
self.assertEqual(events[3].x, 1)
self.assertEqual(events[3].y, -4.0)
def test_legacy_binary_parser_accepts_tty_ch1_ch2_stream(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 100, 90),
_pack_tty_point(2, 120, 95),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 0)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].x, 1)
self.assertEqual(events[1].y, 18100.0)
self.assertEqual(events[1].aux, (100.0, 90.0))
self.assertEqual(events[1].signal_kind, "bin_iq")
self.assertIsInstance(events[2], PointEvent)
self.assertEqual(events[2].x, 2)
self.assertEqual(events[2].y, 23425.0)
self.assertEqual(events[2].aux, (120.0, 95.0))
self.assertEqual(events[2].signal_kind, "bin_iq")
def test_legacy_binary_parser_batch_mode_emits_tty_batch_event(self):
parser = LegacyBinaryParser(batch_events=True)
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 100, 90),
_pack_tty_point(2, 120, 95),
]
)
events = parser.feed(stream)
self.assertEqual(len(events), 2)
self.assertIsInstance(events[0], StartEvent)
self.assertIsInstance(events[1], BatchPointEvent)
self.assertTrue(np.array_equal(events[1].xs, np.asarray([1, 2], dtype=np.int64)))
self.assertTrue(np.allclose(events[1].ys, np.asarray([18100.0, 23425.0], dtype=np.float32)))
self.assertIsNotNone(events[1].aux)
self.assertTrue(np.allclose(events[1].aux[0], np.asarray([100.0, 120.0], dtype=np.float32)))
self.assertTrue(np.allclose(events[1].aux[1], np.asarray([90.0, 95.0], dtype=np.float32)))
self.assertEqual(events[1].signal_kind, "bin_iq")
def test_sweep_assembler_consumes_tty_batch_event(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
packet = assembler.consume(
BatchPointEvent(
ch=0,
xs=np.asarray([1, 2], dtype=np.int64),
ys=np.asarray([18100.0, 23425.0], dtype=np.float32),
aux=(
np.asarray([100.0, 120.0], dtype=np.float32),
np.asarray([90.0, 95.0], dtype=np.float32),
),
signal_kind="bin_iq",
)
)
self.assertIsNone(packet)
sweep, info, aux = assembler.finalize_current()
self.assertEqual(info["signal_kind"], "bin_iq")
self.assertEqual(sweep.shape[0], 3)
self.assertAlmostEqual(float(sweep[1]), 18100.0, places=6)
self.assertAlmostEqual(float(sweep[2]), 23425.0, places=6)
self.assertIsNotNone(aux)
self.assertAlmostEqual(float(aux[0][1]), 100.0, places=6)
self.assertAlmostEqual(float(aux[1][2]), 95.0, places=6)
def test_legacy_binary_parser_detects_new_tty_sweep_on_step_reset(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 100, 90),
_pack_tty_point(2, 110, 95),
_pack_tty_point(1, 120, 80),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], PointEvent)
self.assertIsInstance(events[3], StartEvent)
self.assertEqual(events[3].ch, 0)
self.assertIsInstance(events[4], PointEvent)
self.assertEqual(events[4].x, 1)
self.assertEqual(events[4].aux, (120.0, 80.0))
self.assertEqual(events[4].signal_kind, "bin_iq")
def test_legacy_binary_parser_accepts_tty_do1_tagged_stream(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_tagged_low_point(1, 100, 90),
_pack_tty_tagged_high_point(1, 120, 95),
]
)
events = parser.feed(stream)
self.assertEqual(len(events), 3)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].signal_kind, "bin_iq")
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].signal_kind, "bin_iq_do1_tagged")
self.assertEqual(events[1].do1_level, "low")
self.assertEqual(events[1].x, 1)
self.assertEqual(events[1].aux, (100.0, 90.0))
self.assertIsInstance(events[2], PointEvent)
self.assertEqual(events[2].signal_kind, "bin_iq_do1_tagged")
self.assertEqual(events[2].do1_level, "high")
self.assertEqual(events[2].x, 1)
self.assertEqual(events[2].aux, (120.0, 95.0))
def test_legacy_binary_parser_keeps_same_step_for_different_do1_levels_in_one_sweep(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_tagged_low_point(1, 100, 90),
_pack_tty_tagged_high_point(1, 120, 95),
_pack_tty_tagged_low_point(2, 130, 80),
_pack_tty_tagged_high_point(2, 140, 75),
]
)
events = parser.feed(stream)
start_events = [event for event in events if isinstance(event, StartEvent)]
self.assertEqual(len(start_events), 1)
self.assertEqual(start_events[0].signal_kind, "bin_iq")
point_levels = [event.do1_level for event in events if isinstance(event, PointEvent)]
self.assertEqual(point_levels, ["low", "high", "low", "high"])
def test_legacy_binary_parser_resets_tagged_stream_only_on_same_level_step_reset(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_tagged_low_point(1, 100, 90),
_pack_tty_tagged_high_point(1, 120, 95),
_pack_tty_tagged_low_point(2, 130, 80),
_pack_tty_tagged_high_point(2, 140, 75),
_pack_tty_tagged_low_point(1, 110, 85),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], PointEvent)
self.assertIsInstance(events[3], PointEvent)
self.assertIsInstance(events[4], PointEvent)
self.assertIsInstance(events[5], StartEvent)
self.assertEqual(events[5].signal_kind, "bin_iq_do1_tagged")
self.assertIsInstance(events[6], PointEvent)
self.assertEqual(events[6].do1_level, "low")
self.assertEqual(events[6].x, 1)
def test_legacy_binary_parser_tty_mode_does_not_flip_to_legacy_on_ch2_low_byte_0x0a(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 100, 0x040A), # low byte is 0x0A: used to be misparsed as legacy
_pack_tty_point(2, 120, 0x0410),
]
)
events = parser.feed(stream)
self.assertEqual(len(events), 3)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].ch, 0)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 0)
self.assertEqual(events[1].x, 1)
self.assertEqual(events[1].aux, (100.0, 1034.0))
self.assertEqual(events[1].y, 1079156.0)
self.assertIsInstance(events[2], PointEvent)
self.assertEqual(events[2].ch, 0)
self.assertEqual(events[2].x, 2)
self.assertEqual(events[2].aux, (120.0, 1040.0))
self.assertEqual(events[2].y, 1096000.0)
def test_legacy_binary_parser_accepts_logdet_stream(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_logdet_point(1, 0x0F77),
_pack_logdet_point(2, 0xF234),
]
)
events = parser.feed(stream)
self.assertEqual(len(events), 2)
self.assertIsInstance(events[0], PointEvent)
self.assertEqual(events[0].x, 1)
self.assertEqual(events[0].y, 3959.0)
self.assertIsNone(events[0].aux)
self.assertEqual(events[0].signal_kind, "bin_logdet")
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].x, 2)
self.assertEqual(events[1].y, -3532.0)
self.assertEqual(events[1].signal_kind, "bin_logdet")
def test_legacy_binary_parser_splits_packet_on_bin_signal_kind_change(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 100, 90),
_pack_tty_point(2, 110, 95),
_pack_logdet_point(3, 0x0F77),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertEqual(events[0].signal_kind, "bin_iq")
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].signal_kind, "bin_iq")
self.assertIsInstance(events[2], PointEvent)
self.assertEqual(events[2].signal_kind, "bin_iq")
self.assertIsInstance(events[3], StartEvent)
self.assertEqual(events[3].signal_kind, "bin_logdet")
self.assertIsInstance(events[4], PointEvent)
self.assertEqual(events[4].x, 3)
self.assertEqual(events[4].signal_kind, "bin_logdet")
def test_complex_ascii_parser_detects_new_sweep_on_step_reset(self):
parser = ComplexAsciiSweepParser()
events = parser.feed(b"0 3 4\n1 5 12\n0 8 15\n")
self.assertIsInstance(events[0], PointEvent)
self.assertEqual(events[0].x, 0)
self.assertEqual(events[0].y, 5.0)
self.assertEqual(events[0].aux, (3.0, 4.0))
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].y, 13.0)
self.assertIsInstance(events[2], StartEvent)
self.assertIsInstance(events[3], PointEvent)
self.assertEqual(events[3].aux, (8.0, 15.0))
def test_logscale_32_parser_keeps_channel_and_aux_values(self):
parser = LogScaleBinaryParser32()
stream = _pack_log_start(5) + _pack_log_point(7, 1500, 700, ch=5)
@ -108,6 +432,24 @@ class SweepParserCoreTests(unittest.TestCase):
self.assertAlmostEqual(events[1].y, log_pair_to_sweep(1500, 700), places=6)
self.assertEqual(events[1].aux, (1500.0, 700.0))
def test_logscale_32_parser_detects_new_sweep_on_step_reset(self):
parser = LogScaleBinaryParser32()
stream = b"".join(
[
_pack_log_point(1, 1500, 700, ch=5),
_pack_log_point(2, 1400, 650, ch=5),
_pack_log_point(1, 1300, 600, ch=5),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], PointEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], StartEvent)
self.assertEqual(events[2].ch, 5)
self.assertIsInstance(events[3], PointEvent)
self.assertEqual(events[3].x, 1)
self.assertAlmostEqual(events[3].y, log_pair_to_sweep(1300, 600), places=6)
def test_log_pair_to_sweep_is_order_independent(self):
self.assertAlmostEqual(log_pair_to_sweep(1500, 700), log_pair_to_sweep(700, 1500), places=6)
@ -119,8 +461,29 @@ class SweepParserCoreTests(unittest.TestCase):
self.assertEqual(events[0].ch, 2)
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 2)
self.assertAlmostEqual(events[1].y, math.hypot(100.0, 90.0), places=6)
self.assertEqual(events[1].aux, (100.0, 90.0))
def test_logscale_16bit_parser_detects_new_sweep_on_step_reset(self):
parser = LogScale16BitX2BinaryParser()
stream = b"".join(
[
_pack_log16_start(2),
_pack_log16_point(1, 100, 90),
_pack_log16_point(2, 110, 95),
_pack_log16_point(1, 120, 80),
]
)
events = parser.feed(stream)
self.assertIsInstance(events[0], StartEvent)
self.assertIsInstance(events[1], PointEvent)
self.assertIsInstance(events[2], PointEvent)
self.assertIsInstance(events[3], StartEvent)
self.assertEqual(events[3].ch, 2)
self.assertIsInstance(events[4], PointEvent)
self.assertEqual(events[4].x, 1)
self.assertAlmostEqual(events[4].y, math.hypot(120.0, 80.0), places=6)
def test_parser_test_stream_parser_recovers_point_after_single_separator(self):
parser = ParserTestStreamParser()
stream = b"".join(
@ -140,20 +503,199 @@ class SweepParserCoreTests(unittest.TestCase):
self.assertIsInstance(events[1], PointEvent)
self.assertEqual(events[1].ch, 4)
self.assertEqual(events[1].x, 1)
self.assertTrue(math.isfinite(events[1].y))
self.assertAlmostEqual(events[1].y, math.hypot(100.0, 90.0), places=6)
self.assertEqual(events[1].aux, (100.0, 90.0))
def test_sweep_assembler_builds_aux_curves_without_inversion(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
self.assertIsNone(assembler.consume(StartEvent(ch=1)))
assembler.consume(PointEvent(ch=1, x=1, y=10.0, aux=(100.0, 90.0)))
assembler.consume(PointEvent(ch=1, x=2, y=20.0, aux=(110.0, 95.0)))
self.assertIsNone(assembler.consume(StartEvent(ch=1, signal_kind="bin_iq")))
assembler.consume(PointEvent(ch=1, x=1, y=10.0, aux=(100.0, 90.0), signal_kind="bin_iq"))
assembler.consume(PointEvent(ch=1, x=2, y=20.0, aux=(110.0, 95.0), signal_kind="bin_iq"))
sweep, info, aux = assembler.finalize_current()
self.assertEqual(sweep.shape[0], 3)
self.assertEqual(info["ch"], 1)
self.assertEqual(info["signal_kind"], "bin_iq")
self.assertIsNotNone(aux)
self.assertEqual(aux[0][1], 100.0)
self.assertEqual(aux[1][2], 95.0)
def test_sweep_assembler_builds_tagged_payload_and_nanmean_aggregate(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
self.assertIsNone(assembler.consume(StartEvent(ch=0, signal_kind="bin_iq_do1_tagged")))
assembler.consume(
PointEvent(
ch=0,
x=1,
y=10.0,
aux=(100.0, 90.0),
signal_kind="bin_iq_do1_tagged",
do1_level="low",
)
)
assembler.consume(
PointEvent(
ch=0,
x=1,
y=20.0,
aux=(120.0, 95.0),
signal_kind="bin_iq_do1_tagged",
do1_level="high",
)
)
sweep, info, aux = assembler.finalize_current()
self.assertIsNone(aux)
self.assertEqual(info["signal_kind"], "bin_iq_do1_tagged")
self.assertAlmostEqual(float(sweep[1]), 15.0, places=6)
payload = info.get("_do1_tagged_payload")
self.assertIsInstance(payload, dict)
self.assertIn("raw_low", payload)
self.assertIn("raw_high", payload)
self.assertIn("aux_low", payload)
self.assertIn("aux_high", payload)
def test_sweep_assembler_splits_packet_on_channel_switch(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
self.assertIsNone(assembler.consume(PointEvent(ch=1, x=1, y=10.0)))
packet = assembler.consume(PointEvent(ch=2, x=1, y=20.0))
self.assertIsNotNone(packet)
sweep_1, info_1, aux_1 = packet
self.assertIsNone(aux_1)
self.assertEqual(info_1["ch"], 1)
self.assertEqual(info_1["chs"], [1])
self.assertAlmostEqual(float(sweep_1[1]), 10.0, places=6)
sweep_2, info_2, aux_2 = assembler.finalize_current()
self.assertIsNone(aux_2)
self.assertEqual(info_2["ch"], 2)
self.assertEqual(info_2["chs"], [2])
self.assertAlmostEqual(float(sweep_2[1]), 20.0, places=6)
def test_sweep_assembler_splits_packet_on_signal_kind_switch(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
self.assertIsNone(assembler.consume(PointEvent(ch=0, x=1, y=10.0, signal_kind="bin_iq")))
packet = assembler.consume(PointEvent(ch=0, x=1, y=20.0, signal_kind="bin_logdet"))
self.assertIsNotNone(packet)
sweep_1, info_1, aux_1 = packet
self.assertIsNone(aux_1)
self.assertEqual(info_1["signal_kind"], "bin_iq")
self.assertAlmostEqual(float(sweep_1[1]), 10.0, places=6)
sweep_2, info_2, aux_2 = assembler.finalize_current()
self.assertIsNone(aux_2)
self.assertEqual(info_2["signal_kind"], "bin_logdet")
self.assertAlmostEqual(float(sweep_2[1]), 20.0, places=6)
def test_legacy_binary_parser_accepts_secondary_0xa8_stream(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 5, 0),
_pack_tty_tagged_point(0x00A8, 1, 0xFFCD, 0xFFDC),
_pack_tty_point(2, 0xFFF8, 1),
_pack_tty_tagged_point(0x00A8, 2, 0xFFCE, 0xFFE9),
]
)
events = parser.feed(stream)
start_events = [e for e in events if isinstance(e, StartEvent)]
self.assertEqual(len(start_events), 1)
point_events = [e for e in events if isinstance(e, PointEvent)]
primary = [e for e in point_events if not e.is_secondary]
secondary = [e for e in point_events if e.is_secondary]
self.assertEqual(len(primary), 2)
self.assertEqual(len(secondary), 2)
self.assertEqual(secondary[0].x, 1)
self.assertEqual(secondary[0].aux, (-51.0, -36.0))
self.assertTrue(secondary[0].is_secondary)
self.assertEqual(secondary[1].x, 2)
self.assertEqual(secondary[1].aux, (-50.0, -23.0))
def test_secondary_0xa8_does_not_trigger_sweep_reset(self):
parser = LegacyBinaryParser()
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 5, 0),
_pack_tty_tagged_point(0x00A8, 1, 100, 200),
_pack_tty_point(2, 6, 0),
_pack_tty_tagged_point(0x00A8, 2, 110, 210),
]
)
events = parser.feed(stream)
start_events = [e for e in events if isinstance(e, StartEvent)]
self.assertEqual(len(start_events), 1)
def test_legacy_binary_parser_batch_handles_interleaved_secondary(self):
parser = LegacyBinaryParser(batch_events=True)
stream = b"".join(
[
_pack_tty_start(),
_pack_tty_point(1, 5, 0),
_pack_tty_tagged_point(0x00A8, 1, 100, 200),
_pack_tty_point(2, 6, 0),
_pack_tty_tagged_point(0x00A8, 2, 110, 210),
]
)
events = parser.feed(stream)
batch_events = [e for e in events if isinstance(e, BatchPointEvent)]
primary_batches = [e for e in batch_events if not e.is_secondary]
secondary_batches = [e for e in batch_events if e.is_secondary]
self.assertTrue(len(primary_batches) >= 1)
self.assertTrue(len(secondary_batches) >= 1)
pb = primary_batches[0]
self.assertTrue(np.array_equal(pb.xs, np.array([1, 2], dtype=np.int64)))
self.assertFalse(pb.is_secondary)
sb = secondary_batches[0]
self.assertTrue(np.array_equal(sb.xs, np.array([1, 2], dtype=np.int64)))
self.assertTrue(sb.is_secondary)
def test_sweep_assembler_packages_secondary_payload(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
assembler.consume(StartEvent(ch=0, signal_kind="bin_iq"))
assembler.consume(PointEvent(ch=0, x=1, y=25.0, aux=(5.0, 0.0), signal_kind="bin_iq"))
assembler.consume(
PointEvent(ch=0, x=1, y=0.0, aux=(-51.0, -36.0), signal_kind="bin_iq", is_secondary=True)
)
assembler.consume(PointEvent(ch=0, x=2, y=65.0, aux=(-8.0, 1.0), signal_kind="bin_iq"))
assembler.consume(
PointEvent(ch=0, x=2, y=0.0, aux=(-50.0, -23.0), signal_kind="bin_iq", is_secondary=True)
)
sweep, info, aux = assembler.finalize_current()
self.assertEqual(info["signal_kind"], "bin_iq")
self.assertAlmostEqual(float(sweep[1]), 25.0, places=6)
self.assertAlmostEqual(float(sweep[2]), 65.0, places=6)
payload = info.get("_secondary_payload")
self.assertIsNotNone(payload)
self.assertIn("ch1", payload)
self.assertIn("ch2", payload)
self.assertAlmostEqual(float(payload["ch1"][1]), -51.0, places=6)
self.assertAlmostEqual(float(payload["ch2"][1]), -36.0, places=6)
self.assertAlmostEqual(float(payload["ch1"][2]), -50.0, places=6)
self.assertAlmostEqual(float(payload["ch2"][2]), -23.0, places=6)
def test_sweep_assembler_secondary_absent_when_no_0xa8_data(self):
assembler = SweepAssembler(fancy=False, apply_inversion=False)
assembler.consume(StartEvent(ch=0, signal_kind="bin_iq"))
assembler.consume(PointEvent(ch=0, x=1, y=25.0, aux=(5.0, 0.0), signal_kind="bin_iq"))
assembler.consume(PointEvent(ch=0, x=2, y=65.0, aux=(-8.0, 1.0), signal_kind="bin_iq"))
sweep, info, aux = assembler.finalize_current()
self.assertNotIn("_secondary_payload", info)
if __name__ == "__main__":
unittest.main()

304
tests/test_sweep_reader.py Normal file
View File

@ -0,0 +1,304 @@
from __future__ import annotations
import contextlib
import io
import threading
import time
import unittest
from queue import Queue
from unittest.mock import patch
from rfg_adc_plotter.io import sweep_reader as sweep_reader_module
from rfg_adc_plotter.io.sweep_reader import SweepReader, _PARSER_16_BIT_X2_PROBE_BYTES
def _u16le(word: int) -> bytes:
value = int(word) & 0xFFFF
return bytes((value & 0xFF, (value >> 8) & 0xFF))
def _pack_legacy_point(ch: int, step: int, value_i32: int) -> bytes:
value = int(value_i32) & 0xFFFF_FFFF
return b"".join(
[
_u16le(step),
_u16le((value >> 16) & 0xFFFF),
_u16le(value & 0xFFFF),
bytes((0x0A, int(ch) & 0xFF)),
]
)
def _pack_log16_start(ch: int) -> bytes:
return b"\xff\xff" * 3 + bytes((0x0A, int(ch) & 0xFF))
def _pack_log16_point(step: int, real: int, imag: int) -> bytes:
return b"".join(
[
_u16le(step),
_u16le(real),
_u16le(imag),
_u16le(0xFFFF),
]
)
def _pack_tty_start() -> bytes:
return b"".join(
[
_u16le(0x000A),
_u16le(0xFFFF),
_u16le(0xFFFF),
_u16le(0xFFFF),
]
)
def _pack_tty_point(step: int, ch1: int, ch2: int) -> bytes:
return b"".join(
[
_u16le(0x000A),
_u16le(step),
_u16le(ch1),
_u16le(ch2),
]
)
def _pack_tty_tagged_point(marker_word0: int, step: int, ch1: int, ch2: int) -> bytes:
return b"".join(
[
_u16le(marker_word0),
_u16le(step),
_u16le(ch1),
_u16le(ch2),
]
)
def _pack_tty_tagged_low(step: int, ch1: int, ch2: int) -> bytes:
return _pack_tty_tagged_point(0x00A3, step, ch1, ch2)
def _pack_tty_tagged_high(step: int, ch1: int, ch2: int) -> bytes:
return _pack_tty_tagged_point(0x00A4, step, ch1, ch2)
def _pack_logdet_point(step: int, value: int) -> bytes:
return b"".join(
[
_u16le(0x001A),
_u16le(step),
_u16le(value),
_u16le(0x0000),
]
)
def _chunk_bytes(data: bytes, size: int = 4096) -> list[bytes]:
return [data[idx : idx + size] for idx in range(0, len(data), size)]
class _FakeSerialLineSource:
def __init__(self, path: str, baud: int, timeout: float = 1.0):
self.path = path
self.baud = baud
self.timeout = timeout
self._using = "fake"
def close(self) -> None:
pass
class _FakeChunkReader:
payload_chunks: list[bytes] = []
def __init__(self, src):
self._src = src
self._chunks = list(type(self).payload_chunks)
def read_available(self) -> bytes:
if self._chunks:
return self._chunks.pop(0)
return b""
class SweepReaderTests(unittest.TestCase):
def _start_reader(self, payload: bytes, **reader_kwargs):
queue: Queue = Queue()
stop_event = threading.Event()
stderr = io.StringIO()
_FakeChunkReader.payload_chunks = _chunk_bytes(payload)
reader = SweepReader(
"/tmp/fake-tty",
115200,
queue,
stop_event,
**reader_kwargs,
)
stack = contextlib.ExitStack()
stack.enter_context(patch.object(sweep_reader_module, "SerialLineSource", _FakeSerialLineSource))
stack.enter_context(patch.object(sweep_reader_module, "SerialChunkReader", _FakeChunkReader))
stack.enter_context(contextlib.redirect_stderr(stderr))
reader.start()
return stack, reader, queue, stop_event, stderr
def test_parser_16_bit_x2_falls_back_to_legacy_stream(self):
payload = bytearray()
while len(payload) < (_PARSER_16_BIT_X2_PROBE_BYTES + 24):
payload += _pack_legacy_point(3, 1, -2)
payload += _pack_legacy_point(3, 2, -3)
payload += _pack_legacy_point(3, 1, -4)
stack, reader, queue, stop_event, stderr = self._start_reader(bytes(payload), parser_16_bit_x2=True)
try:
sweep, info, aux = queue.get(timeout=2.0)
self.assertEqual(info["ch"], 3)
self.assertIsNone(aux)
self.assertGreaterEqual(sweep.shape[0], 3)
self.assertIn("fallback -> legacy", stderr.getvalue())
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_parser_16_bit_x2_falls_back_to_tty_ch1_ch2_stream(self):
payload = bytearray()
while len(payload) < (_PARSER_16_BIT_X2_PROBE_BYTES + 24):
payload += _pack_tty_start()
payload += _pack_tty_point(1, 100, 90)
payload += _pack_tty_point(2, 120, 95)
payload += _pack_tty_point(1, 80, 70)
stack, reader, queue, stop_event, stderr = self._start_reader(bytes(payload), parser_16_bit_x2=True)
try:
sweep, info, aux = queue.get(timeout=2.0)
self.assertEqual(info["ch"], 0)
self.assertIsNotNone(aux)
self.assertGreaterEqual(sweep.shape[0], 3)
self.assertAlmostEqual(float(sweep[1]), 18100.0, places=6)
self.assertAlmostEqual(float(sweep[2]), 23425.0, places=6)
self.assertIn("fallback -> legacy", stderr.getvalue())
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_parser_16_bit_x2_falls_back_to_tty_do1_tagged_stream(self):
payload = bytearray()
while len(payload) < (_PARSER_16_BIT_X2_PROBE_BYTES + 40):
payload += _pack_tty_start()
payload += _pack_tty_tagged_low(1, 100, 90)
payload += _pack_tty_tagged_high(1, 120, 95)
payload += _pack_tty_tagged_low(2, 110, 80)
payload += _pack_tty_tagged_high(2, 130, 70)
payload += _pack_tty_tagged_low(1, 105, 85)
stack, reader, queue, stop_event, stderr = self._start_reader(bytes(payload), parser_16_bit_x2=True)
try:
sweep, info, aux = queue.get(timeout=2.0)
self.assertEqual(info["signal_kind"], "bin_iq_do1_tagged")
self.assertIsNone(aux)
self.assertIn("_do1_tagged_payload", info)
self.assertGreaterEqual(sweep.shape[0], 2)
self.assertIn("fallback -> legacy", stderr.getvalue())
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_parser_16_bit_x2_keeps_true_complex_stream(self):
payload = b"".join(
[
_pack_log16_start(2),
_pack_log16_point(1, 3, 4),
_pack_log16_point(2, 5, 12),
_pack_log16_point(1, 8, 15),
]
)
stack, reader, queue, stop_event, stderr = self._start_reader(payload, parser_16_bit_x2=True)
try:
sweep, info, aux = queue.get(timeout=1.0)
self.assertEqual(info["ch"], 2)
self.assertIsNotNone(aux)
self.assertAlmostEqual(float(sweep[1]), 5.0, places=6)
self.assertAlmostEqual(float(sweep[2]), 13.0, places=6)
self.assertNotIn("fallback -> legacy", stderr.getvalue())
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_parser_16_bit_x2_falls_back_to_logdet_1a00_stream(self):
payload = bytearray()
while len(payload) < (_PARSER_16_BIT_X2_PROBE_BYTES + 24):
payload += _pack_logdet_point(1, 0x0F77)
payload += _pack_logdet_point(2, 0x0FCB)
payload += _pack_logdet_point(1, 0x0F88)
stack, reader, queue, stop_event, stderr = self._start_reader(bytes(payload), parser_16_bit_x2=True)
try:
sweep, info, aux = queue.get(timeout=2.0)
self.assertEqual(info["signal_kind"], "bin_logdet")
self.assertIsNone(aux)
self.assertGreaterEqual(sweep.shape[0], 3)
self.assertAlmostEqual(float(sweep[1]), 3959.0, places=6)
self.assertIn("fallback -> legacy", stderr.getvalue())
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_parser_16_bit_x2_probe_inconclusive_logs_hint(self):
payload = b"\x00" * (_PARSER_16_BIT_X2_PROBE_BYTES + 128)
stack, reader, queue, stop_event, stderr = self._start_reader(payload, parser_16_bit_x2=True)
try:
deadline = time.time() + 1.5
logs = ""
while time.time() < deadline:
logs = stderr.getvalue()
if "probe inconclusive" in logs:
break
time.sleep(0.02)
self.assertTrue(queue.empty())
self.assertIn("probe inconclusive", logs)
self.assertIn("try --bin", logs)
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_reader_logs_no_input_warning_when_source_is_idle(self):
with patch.object(sweep_reader_module, "_NO_INPUT_WARN_INTERVAL_S", 0.02), patch.object(
sweep_reader_module, "_NO_PACKET_WARN_INTERVAL_S", 0.02
):
stack, reader, _queue, stop_event, stderr = self._start_reader(b"", parser_16_bit_x2=False)
try:
time.sleep(0.12)
logs = stderr.getvalue()
self.assertIn("no input bytes", logs)
self.assertIn("no sweep packets", logs)
finally:
stop_event.set()
reader.join(timeout=1.0)
stack.close()
def test_reader_join_does_not_raise_when_stopped(self):
stack, reader, _queue, stop_event, _stderr = self._start_reader(b"", parser_16_bit_x2=True)
try:
time.sleep(0.01)
stop_event.set()
reader.join(timeout=1.0)
self.assertFalse(reader.is_alive())
finally:
stop_event.set()
if reader.is_alive():
reader.join(timeout=1.0)
stack.close()
if __name__ == "__main__":
unittest.main()