Files

327 lines
12 KiB
Python
Raw Permalink Normal View History

#!/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())