new project structure
This commit is contained in:
0
rfg_adc_plotter/io/__init__.py
Normal file
0
rfg_adc_plotter/io/__init__.py
Normal file
181
rfg_adc_plotter/io/serial_source.py
Normal file
181
rfg_adc_plotter/io/serial_source.py
Normal file
@ -0,0 +1,181 @@
|
||||
"""Источники последовательного ввода: обёртки над pyserial и raw TTY."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def try_open_pyserial(path: str, baud: int, timeout: float):
|
||||
try:
|
||||
import serial # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
ser = serial.Serial(path, baudrate=baud, timeout=timeout)
|
||||
return ser
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class FDReader:
|
||||
"""Простой враппер чтения строк из файлового дескриптора TTY."""
|
||||
|
||||
def __init__(self, fd: int):
|
||||
self._fd = fd
|
||||
raw = os.fdopen(fd, "rb", closefd=False)
|
||||
self._file = raw
|
||||
self._buf = io.BufferedReader(raw, buffer_size=65536)
|
||||
|
||||
def fileno(self) -> int:
|
||||
return self._fd
|
||||
|
||||
def readline(self) -> bytes:
|
||||
return self._buf.readline()
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self._buf.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def open_raw_tty(path: str, baud: int) -> Optional[FDReader]:
|
||||
"""Открыть TTY без pyserial и настроить порт через termios.
|
||||
|
||||
Возвращает FDReader или None при ошибке.
|
||||
"""
|
||||
try:
|
||||
import termios
|
||||
import tty
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOCTTY)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
attrs = termios.tcgetattr(fd)
|
||||
tty.setraw(fd)
|
||||
|
||||
baud_map = {
|
||||
9600: termios.B9600,
|
||||
19200: termios.B19200,
|
||||
38400: termios.B38400,
|
||||
57600: termios.B57600,
|
||||
115200: termios.B115200,
|
||||
230400: getattr(termios, "B230400", None),
|
||||
460800: getattr(termios, "B460800", None),
|
||||
}
|
||||
b = baud_map.get(baud) or termios.B115200
|
||||
|
||||
attrs[4] = b # ispeed
|
||||
attrs[5] = b # ospeed
|
||||
|
||||
# VMIN=1, VTIME=0 — блокирующее чтение по байту
|
||||
cc = attrs[6]
|
||||
cc[termios.VMIN] = 1
|
||||
cc[termios.VTIME] = 0
|
||||
attrs[6] = cc
|
||||
|
||||
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
return FDReader(fd)
|
||||
|
||||
|
||||
class SerialLineSource:
|
||||
"""Единый интерфейс для чтения строк из порта (pyserial или raw TTY)."""
|
||||
|
||||
def __init__(self, path: str, baud: int, timeout: float = 1.0):
|
||||
self._pyserial = try_open_pyserial(path, baud, timeout)
|
||||
self._fdreader = None
|
||||
self._using = "pyserial" if self._pyserial is not None else "raw"
|
||||
if self._pyserial is None:
|
||||
self._fdreader = open_raw_tty(path, baud)
|
||||
if self._fdreader is None:
|
||||
msg = f"Не удалось открыть порт '{path}' (pyserial и raw TTY не сработали)"
|
||||
if sys.platform.startswith("win"):
|
||||
msg += ". На Windows нужен pyserial: pip install pyserial"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def readline(self) -> bytes:
|
||||
if self._pyserial is not None:
|
||||
try:
|
||||
return self._pyserial.readline()
|
||||
except Exception:
|
||||
return b""
|
||||
else:
|
||||
try:
|
||||
return self._fdreader.readline() # type: ignore[union-attr]
|
||||
except Exception:
|
||||
return b""
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
if self._pyserial is not None:
|
||||
self._pyserial.close()
|
||||
elif self._fdreader is not None:
|
||||
self._fdreader.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class SerialChunkReader:
|
||||
"""Быстрое неблокирующее чтение чанков из serial/raw TTY для максимального дренажа буфера."""
|
||||
|
||||
def __init__(self, src: SerialLineSource):
|
||||
self._src = src
|
||||
self._ser = src._pyserial
|
||||
self._fd: Optional[int] = None
|
||||
if self._ser is not None:
|
||||
try:
|
||||
self._ser.timeout = 0
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
self._fd = src._fdreader.fileno() # type: ignore[union-attr]
|
||||
try:
|
||||
os.set_blocking(self._fd, False)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
self._fd = None
|
||||
|
||||
def read_available(self) -> bytes:
|
||||
"""Вернёт доступные байты (b"" если данных нет)."""
|
||||
if self._ser is not None:
|
||||
try:
|
||||
n = int(getattr(self._ser, "in_waiting", 0))
|
||||
except Exception:
|
||||
n = 0
|
||||
if n > 0:
|
||||
try:
|
||||
return self._ser.read(n)
|
||||
except Exception:
|
||||
return b""
|
||||
return b""
|
||||
if self._fd is None:
|
||||
return b""
|
||||
out = bytearray()
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(self._fd, 65536)
|
||||
if not chunk:
|
||||
break
|
||||
out += chunk
|
||||
if len(chunk) < 65536:
|
||||
break
|
||||
except BlockingIOError:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
return bytes(out)
|
||||
217
rfg_adc_plotter/io/sweep_reader.py
Normal file
217
rfg_adc_plotter/io/sweep_reader.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""Фоновый поток чтения и парсинга свипов из последовательного порта."""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from queue import Full, Queue
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from rfg_adc_plotter.constants import DATA_INVERSION_THRESHOLD
|
||||
from rfg_adc_plotter.io.serial_source import SerialChunkReader, SerialLineSource
|
||||
from rfg_adc_plotter.types import SweepInfo, SweepPacket
|
||||
|
||||
|
||||
class SweepReader(threading.Thread):
|
||||
"""Фоновый поток: читает строки, формирует завершённые свипы и кладёт в очередь."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port_path: str,
|
||||
baud: int,
|
||||
out_queue: "Queue[SweepPacket]",
|
||||
stop_event: threading.Event,
|
||||
fancy: bool = False,
|
||||
):
|
||||
super().__init__(daemon=True)
|
||||
self._port_path = port_path
|
||||
self._baud = baud
|
||||
self._q = out_queue
|
||||
self._stop = stop_event
|
||||
self._src: Optional[SerialLineSource] = None
|
||||
self._fancy = bool(fancy)
|
||||
self._max_width: int = 0
|
||||
self._sweep_idx: int = 0
|
||||
self._last_sweep_ts: Optional[float] = None
|
||||
self._n_valid_hist = deque()
|
||||
|
||||
def _finalize_current(self, xs, ys, channels: Optional[set]):
|
||||
if not xs:
|
||||
return
|
||||
ch_list = sorted(channels) if channels else [0]
|
||||
ch_primary = ch_list[0] if ch_list else 0
|
||||
max_x = max(xs)
|
||||
width = max_x + 1
|
||||
self._max_width = max(self._max_width, width)
|
||||
target_width = self._max_width if self._fancy else width
|
||||
|
||||
sweep = np.full((target_width,), np.nan, dtype=np.float32)
|
||||
try:
|
||||
idx = np.asarray(xs, dtype=np.int64)
|
||||
vals = np.asarray(ys, dtype=np.float32)
|
||||
sweep[idx] = vals
|
||||
except Exception:
|
||||
for x, y in zip(xs, ys):
|
||||
if 0 <= x < target_width:
|
||||
sweep[x] = float(y)
|
||||
|
||||
finite_pre = np.isfinite(sweep)
|
||||
n_valid_cur = int(np.count_nonzero(finite_pre))
|
||||
|
||||
if self._fancy:
|
||||
try:
|
||||
known = ~np.isnan(sweep)
|
||||
if np.any(known):
|
||||
known_idx = np.nonzero(known)[0]
|
||||
for i0, i1 in zip(known_idx[:-1], known_idx[1:]):
|
||||
if i1 - i0 > 1:
|
||||
avg = (sweep[i0] + sweep[i1]) * 0.5
|
||||
sweep[i0 + 1 : i1] = avg
|
||||
first_idx = int(known_idx[0])
|
||||
last_idx = int(known_idx[-1])
|
||||
if first_idx > 0:
|
||||
sweep[:first_idx] = sweep[first_idx]
|
||||
if last_idx < sweep.size - 1:
|
||||
sweep[last_idx + 1 :] = sweep[last_idx]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
m = float(np.nanmean(sweep))
|
||||
if np.isfinite(m) and m < DATA_INVERSION_THRESHOLD:
|
||||
sweep *= -1.0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._sweep_idx += 1
|
||||
if len(ch_list) > 1:
|
||||
sys.stderr.write(
|
||||
f"[warn] Sweep {self._sweep_idx}: изменялся номер канала: {ch_list}\n"
|
||||
)
|
||||
now = time.time()
|
||||
if self._last_sweep_ts is None:
|
||||
dt_ms = float("nan")
|
||||
else:
|
||||
dt_ms = (now - self._last_sweep_ts) * 1000.0
|
||||
self._last_sweep_ts = now
|
||||
self._n_valid_hist.append((now, n_valid_cur))
|
||||
while self._n_valid_hist and (now - self._n_valid_hist[0][0]) > 1.0:
|
||||
self._n_valid_hist.popleft()
|
||||
if self._n_valid_hist:
|
||||
n_valid = float(sum(v for _t, v in self._n_valid_hist) / len(self._n_valid_hist))
|
||||
else:
|
||||
n_valid = float(n_valid_cur)
|
||||
|
||||
if n_valid_cur > 0:
|
||||
vmin = float(np.nanmin(sweep))
|
||||
vmax = float(np.nanmax(sweep))
|
||||
mean = float(np.nanmean(sweep))
|
||||
std = float(np.nanstd(sweep))
|
||||
else:
|
||||
vmin = vmax = mean = std = float("nan")
|
||||
info: SweepInfo = {
|
||||
"sweep": self._sweep_idx,
|
||||
"ch": ch_primary,
|
||||
"chs": ch_list,
|
||||
"n_valid": n_valid,
|
||||
"min": vmin,
|
||||
"max": vmax,
|
||||
"mean": mean,
|
||||
"std": std,
|
||||
"dt_ms": dt_ms,
|
||||
}
|
||||
|
||||
try:
|
||||
self._q.put_nowait((sweep, info))
|
||||
except Full:
|
||||
try:
|
||||
_ = self._q.get_nowait()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._q.put_nowait((sweep, info))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
xs: list = []
|
||||
ys: list = []
|
||||
cur_channel: Optional[int] = None
|
||||
cur_channels: set = set()
|
||||
|
||||
try:
|
||||
self._src = SerialLineSource(self._port_path, self._baud, timeout=1.0)
|
||||
sys.stderr.write(f"[info] Открыл порт {self._port_path} ({self._src._using})\n")
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"[error] {e}\n")
|
||||
return
|
||||
|
||||
try:
|
||||
chunk_reader = SerialChunkReader(self._src)
|
||||
buf = bytearray()
|
||||
while not self._stop.is_set():
|
||||
data = chunk_reader.read_available()
|
||||
if data:
|
||||
buf += data
|
||||
else:
|
||||
time.sleep(0.0005)
|
||||
continue
|
||||
|
||||
while True:
|
||||
nl = buf.find(b"\n")
|
||||
if nl == -1:
|
||||
break
|
||||
line = bytes(buf[:nl])
|
||||
del buf[: nl + 1]
|
||||
if line.endswith(b"\r"):
|
||||
line = line[:-1]
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith(b"Sweep_start"):
|
||||
self._finalize_current(xs, ys, cur_channels)
|
||||
xs.clear()
|
||||
ys.clear()
|
||||
cur_channel = None
|
||||
cur_channels.clear()
|
||||
continue
|
||||
|
||||
if len(line) >= 3:
|
||||
parts = line.split()
|
||||
if len(parts) >= 3 and (parts[0].lower() == b"s" or parts[0].lower().startswith(b"s")):
|
||||
try:
|
||||
if parts[0].lower() == b"s":
|
||||
if len(parts) >= 4:
|
||||
ch = int(parts[1], 10)
|
||||
x = int(parts[2], 10)
|
||||
y = int(parts[3], 10)
|
||||
else:
|
||||
ch = 0
|
||||
x = int(parts[1], 10)
|
||||
y = int(parts[2], 10)
|
||||
else:
|
||||
ch = int(parts[0][1:], 10)
|
||||
x = int(parts[1], 10)
|
||||
y = int(parts[2], 10)
|
||||
except Exception:
|
||||
continue
|
||||
if cur_channel is None:
|
||||
cur_channel = ch
|
||||
cur_channels.add(ch)
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
|
||||
if len(buf) > 1_000_000:
|
||||
del buf[:-262144]
|
||||
finally:
|
||||
try:
|
||||
self._finalize_current(xs, ys, cur_channels)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if self._src is not None:
|
||||
self._src.close()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user