327 lines
12 KiB
Python
327 lines
12 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
"""Приём кадров AD7766 по UART.
|
|||
|
|
|
|||
|
|
Пишет два файла:
|
|||
|
|
<out>.raw все байты из порта, до разбора -- отладка канала
|
|||
|
|
<out>.bin только отсчёты, 3 байта BE -- читается read_24bit_bin()
|
|||
|
|
из F:\\PyPrj\\Signal_analayzer
|
|||
|
|
|
|||
|
|
Кадр с несошедшейся CRC отбрасывается, пропуск восстанавливается по FRAME_CNT
|
|||
|
|
и добивается нулями, чтобы не сдвинулась ось времени.
|
|||
|
|
Формат кадра -- docs/design.md.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
|
|||
|
|
import serial
|
|||
|
|
from serial.tools import list_ports
|
|||
|
|
|
|||
|
|
SYNC = b"\xA5\x5A"
|
|||
|
|
SAMPLES_PER_FRAME = 50
|
|||
|
|
PAYLOAD_LEN = 2 + 1 + SAMPLES_PER_FRAME * 3 # FRAME_CNT + STATUS + DATA
|
|||
|
|
FRAME_LEN = len(SYNC) + PAYLOAD_LEN + 2 # + CRC16
|
|||
|
|
FS_DEFAULT = 12500.0
|
|||
|
|
V_REF = 5.0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_crc_table():
|
|||
|
|
tbl = []
|
|||
|
|
for b in range(256):
|
|||
|
|
c = b << 8
|
|||
|
|
for _ in range(8):
|
|||
|
|
c = ((c << 1) ^ 0x1021) & 0xFFFF if c & 0x8000 else (c << 1) & 0xFFFF
|
|||
|
|
tbl.append(c)
|
|||
|
|
return tbl
|
|||
|
|
|
|||
|
|
|
|||
|
|
_CRC_TBL = _make_crc_table()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def crc16_ccitt(data: bytes) -> int:
|
|||
|
|
"""CRC-16/CCITT, poly 0x1021, init 0xFFFF, MSB-first -- как в ПЛИС."""
|
|||
|
|
crc = 0xFFFF
|
|||
|
|
for byte in data:
|
|||
|
|
crc = ((crc << 8) & 0xFFFF) ^ _CRC_TBL[(crc >> 8) ^ byte]
|
|||
|
|
return crc
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Stats:
|
|||
|
|
def __init__(self):
|
|||
|
|
self.frames_ok = 0
|
|||
|
|
self.crc_errors = 0
|
|||
|
|
self.resyncs = 0
|
|||
|
|
self.padded_frames = 0
|
|||
|
|
self.status_flags = 0
|
|||
|
|
self.samples = 0
|
|||
|
|
self.code_min = None
|
|||
|
|
self.code_max = None
|
|||
|
|
self.code_sum = 0
|
|||
|
|
self.code_sqsum = 0
|
|||
|
|
self.bits_or = 0 # какие биты хоть раз были 1
|
|||
|
|
self.bits_and = 0xFFFFFF # какие биты всегда были 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def decode_samples(data: bytes):
|
|||
|
|
"""150 байт -> 50 знаковых отсчётов."""
|
|||
|
|
out = []
|
|||
|
|
for i in range(0, len(data), 3):
|
|||
|
|
v = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]
|
|||
|
|
if v & 0x800000:
|
|||
|
|
v -= 0x1000000
|
|||
|
|
out.append(v)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def open_port(port, baud):
|
|||
|
|
ser = serial.Serial(port, baud, timeout=0.1)
|
|||
|
|
try:
|
|||
|
|
# Буфер COM-порта в Windows по умолчанию около 4 кБ -- это всего 100 мс
|
|||
|
|
# потока, любая заминка в разборе стоит пачки байт
|
|||
|
|
ser.set_buffer_size(rx_size=1 << 20, tx_size=1 << 12)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
ser.reset_input_buffer()
|
|||
|
|
return ser
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_dump(port, baud, duration, out_base):
|
|||
|
|
"""Только приём в файл, без разбора. Показывает, теряет ли байты сам канал."""
|
|||
|
|
raw_path = out_base + ".raw"
|
|||
|
|
total = 0
|
|||
|
|
slowest = 0.0
|
|||
|
|
with open_port(port, baud) as ser, open(raw_path, "wb") as fraw:
|
|||
|
|
t0 = time.time()
|
|||
|
|
t_end = t0 + duration
|
|||
|
|
while time.time() < t_end:
|
|||
|
|
t1 = time.perf_counter()
|
|||
|
|
chunk = ser.read(4096)
|
|||
|
|
slowest = max(slowest, time.perf_counter() - t1)
|
|||
|
|
if chunk:
|
|||
|
|
fraw.write(chunk)
|
|||
|
|
total += len(chunk)
|
|||
|
|
elapsed = time.time() - t0
|
|||
|
|
|
|||
|
|
rate = total / elapsed
|
|||
|
|
expect = FRAME_LEN * FS_DEFAULT / SAMPLES_PER_FRAME
|
|||
|
|
print(f"принято : {total} байт за {elapsed:.2f} с")
|
|||
|
|
print(f"скорость : {rate / 1000:.1f} кБ/с")
|
|||
|
|
print(f"ожидалось : {expect / 1000:.1f} кБ/с ({FRAME_LEN} байт/кадр, "
|
|||
|
|
f"{FS_DEFAULT / SAMPLES_PER_FRAME:.0f} кадров/с)")
|
|||
|
|
print(f"дошло : {100.0 * rate / expect:.1f}%")
|
|||
|
|
print(f"дольше всего ждали чтение: {slowest * 1000:.0f} мс")
|
|||
|
|
if rate < expect * 0.95:
|
|||
|
|
print("\nБайты теряются в канале, разбор тут ни при чём: "
|
|||
|
|
"мост, драйвер или скорость порта.")
|
|||
|
|
else:
|
|||
|
|
print("\nКанал отдаёт весь поток. Значит потери возникают при разборе.")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FrameParser:
|
|||
|
|
"""Разбор потока кадров. Единственное место, где описан формат, --
|
|||
|
|
им пользуются и запись, и живой просмотр.
|
|||
|
|
|
|||
|
|
feed() возвращает список (frame_cnt, status, data, gap): data -- 150 байт
|
|||
|
|
отсчётов, gap -- сколько кадров потерялось перед этим.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
self.buf = bytearray()
|
|||
|
|
self.expected_cnt = None
|
|||
|
|
self.frames_ok = 0
|
|||
|
|
self.crc_errors = 0
|
|||
|
|
self.resyncs = 0
|
|||
|
|
self.padded_frames = 0
|
|||
|
|
self.status_flags = 0
|
|||
|
|
|
|||
|
|
def feed(self, chunk):
|
|||
|
|
out = []
|
|||
|
|
self.buf += chunk
|
|||
|
|
while True:
|
|||
|
|
idx = self.buf.find(SYNC)
|
|||
|
|
if idx < 0:
|
|||
|
|
# Оставляем хвост, в котором SYNC мог разорваться между чтениями
|
|||
|
|
if len(self.buf) > 1:
|
|||
|
|
del self.buf[:-1]
|
|||
|
|
break
|
|||
|
|
if idx > 0:
|
|||
|
|
self.resyncs += 1
|
|||
|
|
del self.buf[:idx]
|
|||
|
|
if len(self.buf) < FRAME_LEN:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
frame = bytes(self.buf[:FRAME_LEN])
|
|||
|
|
payload = frame[2:2 + PAYLOAD_LEN]
|
|||
|
|
crc_rx = (frame[-2] << 8) | frame[-1]
|
|||
|
|
|
|||
|
|
if crc16_ccitt(payload) != crc_rx:
|
|||
|
|
self.crc_errors += 1
|
|||
|
|
# SYNC мог оказаться внутри данных: сдвигаемся и ищем дальше
|
|||
|
|
del self.buf[:2]
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
del self.buf[:FRAME_LEN]
|
|||
|
|
|
|||
|
|
cnt = (payload[0] << 8) | payload[1]
|
|||
|
|
status = payload[2]
|
|||
|
|
self.status_flags |= status
|
|||
|
|
|
|||
|
|
gap = 0
|
|||
|
|
if self.expected_cnt is not None and cnt != self.expected_cnt:
|
|||
|
|
gap = (cnt - self.expected_cnt) & 0xFFFF
|
|||
|
|
self.padded_frames += gap
|
|||
|
|
self.expected_cnt = (cnt + 1) & 0xFFFF
|
|||
|
|
self.frames_ok += 1
|
|||
|
|
out.append((cnt, status, payload[3:], gap))
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run(port, baud, duration, out_base, quiet):
|
|||
|
|
raw_path = out_base + ".raw"
|
|||
|
|
bin_path = out_base + ".bin"
|
|||
|
|
log_path = out_base + ".log"
|
|||
|
|
|
|||
|
|
st = Stats()
|
|||
|
|
log_lines = []
|
|||
|
|
|
|||
|
|
def log(msg):
|
|||
|
|
log_lines.append(msg)
|
|||
|
|
if not quiet:
|
|||
|
|
print(msg)
|
|||
|
|
|
|||
|
|
log(f"порт {port}, {baud} бод, длительность {duration} с")
|
|||
|
|
|
|||
|
|
parser = FrameParser()
|
|||
|
|
t_end = time.time() + duration
|
|||
|
|
gaps_printed = 0
|
|||
|
|
|
|||
|
|
with open_port(port, baud) as ser, \
|
|||
|
|
open(raw_path, "wb") as fraw, open(bin_path, "wb") as fbin:
|
|||
|
|
while time.time() < t_end:
|
|||
|
|
chunk = ser.read(4096)
|
|||
|
|
if not chunk:
|
|||
|
|
continue
|
|||
|
|
fraw.write(chunk)
|
|||
|
|
|
|||
|
|
for cnt, status, data, gap in parser.feed(chunk):
|
|||
|
|
if gap:
|
|||
|
|
fbin.write(b"\x00" * (gap * SAMPLES_PER_FRAME * 3))
|
|||
|
|
st.samples += gap * SAMPLES_PER_FRAME
|
|||
|
|
# Печатаем только первые сообщения: вывод в консоль медленный
|
|||
|
|
# и на длинной записи сам становится причиной потерь
|
|||
|
|
msg = (f"пропуск {gap} кадр(ов) перед FRAME_CNT={cnt}, "
|
|||
|
|
f"добито {gap * SAMPLES_PER_FRAME} нулей")
|
|||
|
|
log_lines.append(msg)
|
|||
|
|
if gaps_printed < 10 and not quiet:
|
|||
|
|
print(msg)
|
|||
|
|
gaps_printed += 1
|
|||
|
|
|
|||
|
|
fbin.write(data)
|
|||
|
|
st.samples += SAMPLES_PER_FRAME
|
|||
|
|
|
|||
|
|
for v in decode_samples(data):
|
|||
|
|
st.code_sum += v
|
|||
|
|
st.code_sqsum += v * v
|
|||
|
|
st.bits_or |= v & 0xFFFFFF
|
|||
|
|
st.bits_and &= v & 0xFFFFFF
|
|||
|
|
if st.code_min is None or v < st.code_min:
|
|||
|
|
st.code_min = v
|
|||
|
|
if st.code_max is None or v > st.code_max:
|
|||
|
|
st.code_max = v
|
|||
|
|
|
|||
|
|
st.frames_ok = parser.frames_ok
|
|||
|
|
st.crc_errors = parser.crc_errors
|
|||
|
|
st.resyncs = parser.resyncs
|
|||
|
|
st.padded_frames = parser.padded_frames
|
|||
|
|
st.status_flags = parser.status_flags
|
|||
|
|
|
|||
|
|
log("")
|
|||
|
|
log(f"кадров принято : {st.frames_ok}")
|
|||
|
|
log(f"ошибок CRC : {st.crc_errors}")
|
|||
|
|
log(f"ресинхронизаций : {st.resyncs}")
|
|||
|
|
log(f"кадров добито нулями: {st.padded_frames}")
|
|||
|
|
log(f"отсчётов в .bin : {st.samples} ({st.samples / FS_DEFAULT:.3f} с при {FS_DEFAULT:.0f} Гц)")
|
|||
|
|
|
|||
|
|
if st.status_flags & 0x01:
|
|||
|
|
log("STATUS: было переполнение FIFO в ПЛИС")
|
|||
|
|
if st.status_flags & 0x02:
|
|||
|
|
log("STATUS: был срыв чтения по DRDY")
|
|||
|
|
if st.status_flags == 0 and st.frames_ok:
|
|||
|
|
log("STATUS: чисто")
|
|||
|
|
|
|||
|
|
if st.frames_ok:
|
|||
|
|
scale = V_REF / (2 ** 23 - 1)
|
|||
|
|
n = st.frames_ok * SAMPLES_PER_FRAME
|
|||
|
|
mean = st.code_sum / n
|
|||
|
|
var = max(st.code_sqsum / n - mean * mean, 0.0)
|
|||
|
|
sigma = var ** 0.5
|
|||
|
|
log("")
|
|||
|
|
log(f"код min/max/среднее : {st.code_min} / {st.code_max} / {mean:.1f}")
|
|||
|
|
log(f"вольт min/max/сред. : {st.code_min * scale:+.6f} / "
|
|||
|
|
f"{st.code_max * scale:+.6f} / {mean * scale:+.6f}")
|
|||
|
|
log(f"размах : {(st.code_max - st.code_min) * scale:.6f} В "
|
|||
|
|
f"(полная шкала {2 * V_REF:.1f} В)")
|
|||
|
|
log(f"СКО шума : {sigma:.1f} код ({sigma * scale * 1e6:.2f} мкВ)")
|
|||
|
|
|
|||
|
|
# Постоянные старшие биты -- норма: при малом размахе сигнала меняются
|
|||
|
|
# только младшие. Тревожно обратное: залипшие младшие биты, которые
|
|||
|
|
# обязан расшевелить хотя бы собственный шум АЦП.
|
|||
|
|
frozen = ((~st.bits_or) | st.bits_and) & 0xFFFFFF
|
|||
|
|
low_frozen = frozen & 0x0F
|
|||
|
|
if frozen == 0xFFFFFF:
|
|||
|
|
log("биты : НИ ОДИН из 24 не менялся — тракт отдаёт константу")
|
|||
|
|
elif low_frozen:
|
|||
|
|
log("биты : ВНИМАНИЕ, не менялись младшие " + ", ".join(
|
|||
|
|
str(i) for i in range(3, -1, -1) if low_frozen >> i & 1) +
|
|||
|
|
" — похоже на сбой чтения, а не на сигнал")
|
|||
|
|
else:
|
|||
|
|
top = 0
|
|||
|
|
for i in range(23, -1, -1):
|
|||
|
|
if frozen >> i & 1:
|
|||
|
|
top += 1
|
|||
|
|
else:
|
|||
|
|
break
|
|||
|
|
if top:
|
|||
|
|
log(f"биты : младшие {24 - top} переключались, "
|
|||
|
|
f"старшие {top} постоянны — норма при малом размахе")
|
|||
|
|
else:
|
|||
|
|
log("биты : все 24 переключались")
|
|||
|
|
|
|||
|
|
with open(log_path, "w", encoding="utf-8") as flog:
|
|||
|
|
flog.write("\n".join(log_lines) + "\n")
|
|||
|
|
|
|||
|
|
return 1 if (st.crc_errors or st.status_flags or not st.frames_ok) else 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|||
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|||
|
|
ap.add_argument("--port", help="например COM5")
|
|||
|
|
ap.add_argument("--baud", type=int, default=921600)
|
|||
|
|
ap.add_argument("--seconds", type=float, default=10.0)
|
|||
|
|
ap.add_argument("--out", default="capture", help="базовое имя файлов без расширения")
|
|||
|
|
ap.add_argument("--quiet", action="store_true")
|
|||
|
|
ap.add_argument("--list", action="store_true", help="показать доступные порты")
|
|||
|
|
ap.add_argument("--dump", action="store_true",
|
|||
|
|
help="только приём в .raw без разбора: проверка канала")
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
|
|||
|
|
if args.list:
|
|||
|
|
for p in list_ports.comports():
|
|||
|
|
print(f"{p.device} {p.description}")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
if not args.port:
|
|||
|
|
ap.error("нужен --port (список портов: --list)")
|
|||
|
|
|
|||
|
|
if args.dump:
|
|||
|
|
return run_dump(args.port, args.baud, args.seconds, args.out)
|
|||
|
|
|
|||
|
|
return run(args.port, args.baud, args.seconds, args.out, args.quiet)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|