- 新增混流解析函数 `parse_mixed_stream()`,支持标准包与光泵包交替出现的二进制文件 - 新增光泵磁力仪模拟器 `OpticMagSimulatorThread`,以可配置频率发送BCD帧 - 新增光泵磁场显示标签页,支持文件模式与实时模式下的磁场曲线绘制 - 新增 `flash_counter` 模块,用于NOFX模式下文件序号持久化 ♻️ refactor(data_storage): 重构数据存储目录与文件命名策略 - 会话目录改为按GPS日期命名(YYYYMMDD),无GPS时使用NOFX目录 - 文件命名改为基于GPS时间(HHMMSS)或Flash计数器(C开头),消除序号文件依赖 - 移除 `config_manager` 依赖和 `PARAM.TXT` 序号文件读写逻辑 - 新增 `GPS_ParseGPRMC()` 解析函数,提取日期字段用于目录命名 🐛 fix(ltc2508_driver): 修复DMA启动失败时资源泄漏问题 - SPI2/SPI3 DMA启动失败时主动停止已启动的DMA,防止资源泄漏 - 将SPI1的dummy发送缓冲区改为static,确保DMA传输期间数据有效 🐛 fix(rs485_driver): 修复DMA发送缓冲区可能被覆盖的问题 - 引入静态发送缓冲区 `s_tx_buf`,隔离调用方数据与DMA传输,防止TIM2 ISR覆盖 ♻️ refactor(ATEMParse): 重构数据结构定义与解析逻辑 - 移除硬编码的V1/V2 dtype,改为动态构建 `make_dtype()` 函数 - 新增数据格式选项(GPS经纬度/光泵),支持灵活配置 - 新增文件模式下的分页浏览功能(滑块/上一页/下一页) - 优化实时模式下的光泵数据显示与表格列过滤
1096 lines
43 KiB
Python
1096 lines
43 KiB
Python
import sys
|
||
import time
|
||
import struct
|
||
import numpy as np
|
||
import pandas as pd
|
||
import serial
|
||
import serial.tools.list_ports
|
||
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||
QHBoxLayout, QPushButton, QFileDialog, QTableView,
|
||
QLabel, QRadioButton, QButtonGroup, QTabWidget,
|
||
QMessageBox, QHeaderView, QCheckBox, QComboBox, QSplitter,
|
||
QSlider)
|
||
from PyQt6.QtCore import Qt, QAbstractTableModel, QThread, pyqtSignal, QTimer
|
||
import pyqtgraph as pg
|
||
|
||
# ==========================================
|
||
# 1. 数据结构定义
|
||
# ==========================================
|
||
|
||
# 同步头(小端序字节序列)
|
||
MARKER_STANDARD = b'\xff\xff\xff\xff' # 0xFFFFFFFF - 标准包(无光泵)
|
||
MARKER_OPTIC = b'\xfe\xff\xff\xff' # 0xFFFFFFFE - 扩展包(含光泵)
|
||
|
||
def make_dtype(is_raw: bool, has_gps_pos: bool, has_optic: bool) -> np.dtype:
|
||
"""动态构建 numpy dtype,精确匹配固件 __attribute__((packed)) 结构体布局。
|
||
|
||
固件包结构:
|
||
start_byte (u32) | adc1/corr_x | adc2/corr_y | adc3/corr_z
|
||
| gps_time (u32) [| lat f32 | lon f32 | alt f32] [| optical_mag u32]
|
||
"""
|
||
fields = [('start_byte', '<u4')]
|
||
if is_raw:
|
||
fields += [('adc1', '<i4'), ('adc2', '<i4'), ('adc3', '<i4')]
|
||
else:
|
||
fields += [('corr_x', '<f4'), ('corr_y', '<f4'), ('corr_z', '<f4')]
|
||
fields.append(('gps_time', '<u4'))
|
||
if has_gps_pos:
|
||
fields += [('gps_latitude', '<f4'), ('gps_longitude', '<f4'), ('gps_altitude', '<f4')]
|
||
if has_optic:
|
||
fields.append(('optical_mag', '<u4'))
|
||
return np.dtype(fields)
|
||
|
||
|
||
MARKER_STD_INT = 0xFFFFFFFF
|
||
MARKER_OPTIC_INT = 0xFFFFFFFE
|
||
|
||
|
||
def parse_mixed_stream(raw_bytes: bytes, is_raw: bool, has_gps_pos: bool):
|
||
"""逐包解析混流文件(标准包 0xFFFFFFFF 和光泵包 0xFFFFFFFE 交替出现)。
|
||
|
||
固定大小 np.fromfile 无法处理两种包长不同的混流;本函数按同步头逐包识别。
|
||
返回 (df_std, df_optic),各自可能为空 DataFrame。
|
||
"""
|
||
dtype_std = make_dtype(is_raw, has_gps_pos, False)
|
||
dtype_optic = make_dtype(is_raw, has_gps_pos, True)
|
||
std_size = dtype_std.itemsize
|
||
optic_size = dtype_optic.itemsize
|
||
|
||
std_bufs = []
|
||
optic_bufs = []
|
||
i = 0
|
||
n = len(raw_bytes)
|
||
|
||
while i <= n - 4:
|
||
m = struct.unpack_from('<I', raw_bytes, i)[0]
|
||
if m == MARKER_STD_INT and i + std_size <= n:
|
||
std_bufs.append(raw_bytes[i : i + std_size])
|
||
i += std_size
|
||
elif m == MARKER_OPTIC_INT and i + optic_size <= n:
|
||
optic_bufs.append(raw_bytes[i : i + optic_size])
|
||
i += optic_size
|
||
else:
|
||
i += 1 # 重新同步
|
||
|
||
df_std = (pd.DataFrame(np.frombuffer(b''.join(std_bufs), dtype=dtype_std))
|
||
if std_bufs else pd.DataFrame())
|
||
df_optic = (pd.DataFrame(np.frombuffer(b''.join(optic_bufs), dtype=dtype_optic))
|
||
if optic_bufs else pd.DataFrame())
|
||
return df_std, df_optic
|
||
|
||
|
||
# ==========================================
|
||
# 2. 高性能表格模型
|
||
# ==========================================
|
||
class BigDataModel(QAbstractTableModel):
|
||
def __init__(self, data):
|
||
super().__init__()
|
||
self._data = data
|
||
|
||
def rowCount(self, parent=None):
|
||
return self._data.shape[0]
|
||
|
||
def columnCount(self, parent=None):
|
||
return self._data.shape[1]
|
||
|
||
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
||
if index.isValid() and role == Qt.ItemDataRole.DisplayRole:
|
||
val = self._data.iloc[index.row(), index.column()]
|
||
if isinstance(val, (float, np.floating)):
|
||
return f"{val:.6f}"
|
||
return str(val)
|
||
return None
|
||
|
||
def headerData(self, col, orientation, role):
|
||
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
|
||
return self._data.columns[col]
|
||
return None
|
||
|
||
|
||
# ==========================================
|
||
# 3. 后台线程:串口数据接收
|
||
# ==========================================
|
||
class SerialReaderThread(QThread):
|
||
data_received = pyqtSignal(np.ndarray) # 标准包
|
||
optic_received = pyqtSignal(np.ndarray) # 光泵包(混流时使用)
|
||
error_occurred = pyqtSignal(str)
|
||
|
||
def __init__(self, port, baudrate, dtype_std, dtype_optic=None):
|
||
"""dtype_optic 不为 None 时启用混流解析,同时发射 data_received 和 optic_received。"""
|
||
super().__init__()
|
||
self.port = port
|
||
self.baudrate = baudrate
|
||
self.dtype_std = dtype_std
|
||
self.dtype_optic = dtype_optic
|
||
self.is_running = False
|
||
self.serial_port = None
|
||
|
||
def run(self):
|
||
self.is_running = True
|
||
buffer = bytearray()
|
||
mixed = self.dtype_optic is not None
|
||
std_size = self.dtype_std.itemsize
|
||
optic_size = self.dtype_optic.itemsize if mixed else 0
|
||
|
||
try:
|
||
self.serial_port = serial.Serial(self.port, self.baudrate, timeout=1)
|
||
|
||
while self.is_running:
|
||
if self.serial_port and self.serial_port.in_waiting > 0:
|
||
buffer.extend(self.serial_port.read(self.serial_port.in_waiting))
|
||
|
||
std_pkts = []
|
||
optic_pkts = []
|
||
|
||
while len(buffer) >= 4:
|
||
m = struct.unpack_from('<I', buffer, 0)[0]
|
||
|
||
if m == MARKER_STD_INT and len(buffer) >= std_size:
|
||
std_pkts.append(bytes(buffer[:std_size]))
|
||
del buffer[:std_size]
|
||
elif mixed and m == MARKER_OPTIC_INT and len(buffer) >= optic_size:
|
||
optic_pkts.append(bytes(buffer[:optic_size]))
|
||
del buffer[:optic_size]
|
||
elif m in (MARKER_STD_INT, MARKER_OPTIC_INT):
|
||
break # 包还没收完,等下次
|
||
else:
|
||
del buffer[0] # 重新同步
|
||
|
||
if std_pkts:
|
||
self.data_received.emit(
|
||
np.frombuffer(b''.join(std_pkts), dtype=self.dtype_std))
|
||
if optic_pkts:
|
||
self.optic_received.emit(
|
||
np.frombuffer(b''.join(optic_pkts), dtype=self.dtype_optic))
|
||
|
||
except Exception as e:
|
||
if self.is_running:
|
||
self.error_occurred.emit(str(e))
|
||
finally:
|
||
self.stop()
|
||
|
||
def stop(self):
|
||
self.is_running = False
|
||
if self.serial_port:
|
||
try:
|
||
self.serial_port.cancel_read()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self.serial_port.is_open:
|
||
self.serial_port.close()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
self.serial_port = None
|
||
|
||
|
||
# ==========================================
|
||
# 4. 后台线程:光泵磁力仪数据模拟输出
|
||
# ==========================================
|
||
|
||
def _encode_optic_frame(value_thousandths_nT: int) -> bytes:
|
||
"""将 0.001 nT 为单位的整数编码为固件期望的 BCD 帧。
|
||
|
||
帧格式: 0x5A + 10字节BCD(每字节低nibble为一位十进制数字,高位在前)+ 0xA5
|
||
固件仅读取前9位,第10位被丢弃。
|
||
"""
|
||
value = max(0, min(999_999_999, value_thousandths_nT))
|
||
# 提取10位十进制数字(高位在前)
|
||
digits = [(value // (10 ** (9 - i))) % 10 for i in range(10)]
|
||
bcd_bytes = bytes([d & 0x0F for d in digits])
|
||
return bytes([0x5A]) + bcd_bytes + bytes([0xA5])
|
||
|
||
|
||
class OpticMagSimulatorThread(QThread):
|
||
"""以指定频率向串口发送模拟光泵磁力仪 BCD 帧。
|
||
|
||
模拟量:基础值 + 慢漂移(正弦,周期60s,幅值±100 nT)+ 白噪声(±0.5 nT)
|
||
"""
|
||
error_occurred = pyqtSignal(str)
|
||
value_updated = pyqtSignal(float) # 当前模拟值 (nT),用于UI显示
|
||
|
||
def __init__(self, port, baudrate, base_nT=50000.0, rate_hz=10):
|
||
super().__init__()
|
||
self.port = port
|
||
self.baudrate = baudrate
|
||
self.base_nT = base_nT
|
||
self.rate_hz = rate_hz
|
||
self.is_running = False
|
||
self.serial_port = None
|
||
|
||
def run(self):
|
||
self.is_running = True
|
||
interval = 1.0 / self.rate_hz
|
||
t0 = time.perf_counter()
|
||
|
||
try:
|
||
self.serial_port = serial.Serial(self.port, self.baudrate, timeout=1)
|
||
|
||
while self.is_running:
|
||
t = time.perf_counter() - t0
|
||
drift = 100.0 * np.sin(2 * np.pi * t / 60.0)
|
||
noise = np.random.uniform(-0.5, 0.5)
|
||
value_nT = self.base_nT + drift + noise
|
||
|
||
value_thousandths = int(round(value_nT * 1000))
|
||
frame = _encode_optic_frame(value_thousandths)
|
||
|
||
if self.serial_port and self.serial_port.is_open:
|
||
self.serial_port.write(frame)
|
||
self.value_updated.emit(value_nT)
|
||
|
||
next_tick = t0 + (t // interval + 1) * interval
|
||
sleep_time = next_tick - time.perf_counter()
|
||
if sleep_time > 0:
|
||
time.sleep(sleep_time)
|
||
|
||
except Exception as e:
|
||
if self.is_running:
|
||
self.error_occurred.emit(str(e))
|
||
finally:
|
||
self.stop()
|
||
|
||
def stop(self):
|
||
self.is_running = False
|
||
if self.serial_port:
|
||
try:
|
||
self.serial_port.cancel_write()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self.serial_port.is_open:
|
||
self.serial_port.close()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
self.serial_port = None
|
||
|
||
|
||
# ==========================================
|
||
# 5. 后台线程:GPS 动态模拟输出 (10Hz)
|
||
# ==========================================
|
||
class GPSSimulatorThread(QThread):
|
||
error_occurred = pyqtSignal(str)
|
||
|
||
def __init__(self, port, baudrate):
|
||
super().__init__()
|
||
self.port = port
|
||
self.baudrate = baudrate
|
||
self.is_running = False
|
||
self.serial_port = None
|
||
|
||
def get_nmea_checksum(self, sentence):
|
||
calc_cksum = 0
|
||
for char in sentence:
|
||
calc_cksum ^= ord(char)
|
||
return f"{calc_cksum:02X}"
|
||
|
||
def run(self):
|
||
self.is_running = True
|
||
try:
|
||
self.serial_port = serial.Serial(self.port, self.baudrate, timeout=1)
|
||
|
||
current_lat = 39.9042
|
||
current_lon = 116.3972
|
||
current_alt = 43.0
|
||
|
||
lat_step = 0.00001
|
||
lon_step = 0.00001
|
||
alt_step = 0.05
|
||
|
||
while self.is_running:
|
||
start_time = time.perf_counter()
|
||
|
||
now = time.time()
|
||
gm_time = time.gmtime(now)
|
||
ms = int((now % 1) * 1000)
|
||
time_str = time.strftime("%H%M%S", gm_time) + f".{ms:03d}"
|
||
date_str = time.strftime("%d%m%y", gm_time)
|
||
|
||
current_lat += lat_step
|
||
current_lon += lon_step
|
||
current_alt += alt_step
|
||
|
||
lat_deg = int(abs(current_lat))
|
||
lat_min = (abs(current_lat) - lat_deg) * 60
|
||
lat_str = f"{lat_deg:02d}{lat_min:07.4f}"
|
||
lat_dir = "N" if current_lat >= 0 else "S"
|
||
|
||
lon_deg = int(abs(current_lon))
|
||
lon_min = (abs(current_lon) - lon_deg) * 60
|
||
lon_str = f"{lon_deg:03d}{lon_min:07.4f}"
|
||
lon_dir = "E" if current_lon >= 0 else "W"
|
||
|
||
speed_knots = "19.4"
|
||
course_true = "45.0"
|
||
|
||
gga_core = f"GPGGA,{time_str},{lat_str},{lat_dir},{lon_str},{lon_dir},1,08,1.0,{current_alt:.1f},M,0.0,M,,"
|
||
rmc_core = f"GPRMC,{time_str},A,{lat_str},{lat_dir},{lon_str},{lon_dir},{speed_knots},{course_true},{date_str},0.0,E"
|
||
|
||
gga_sentence = f"${gga_core}*{self.get_nmea_checksum(gga_core)}\r\n"
|
||
rmc_sentence = f"${rmc_core}*{self.get_nmea_checksum(rmc_core)}\r\n"
|
||
|
||
if self.serial_port and self.serial_port.is_open:
|
||
self.serial_port.write(gga_sentence.encode('ascii'))
|
||
self.serial_port.write(rmc_sentence.encode('ascii'))
|
||
|
||
elapsed = time.perf_counter() - start_time
|
||
sleep_time = 0.1 - elapsed
|
||
if sleep_time > 0:
|
||
time.sleep(sleep_time)
|
||
|
||
except Exception as e:
|
||
if self.is_running:
|
||
self.error_occurred.emit(str(e))
|
||
finally:
|
||
self.stop()
|
||
|
||
def stop(self):
|
||
self.is_running = False
|
||
if self.serial_port:
|
||
try:
|
||
self.serial_port.cancel_write()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self.serial_port.is_open:
|
||
self.serial_port.close()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
self.serial_port = None
|
||
|
||
|
||
# ==========================================
|
||
# 5. 主窗口
|
||
# ==========================================
|
||
class DataAnalyzerUI(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("ATEM 数据分析工具")
|
||
self.resize(1300, 900)
|
||
|
||
self.df = pd.DataFrame()
|
||
self.df_optic = pd.DataFrame()
|
||
self.live_data_list = []
|
||
self.live_optic_list = []
|
||
self.is_live_mode = False
|
||
self.curves = []
|
||
self.optic_curve = None
|
||
|
||
self.last_fps_time = 0
|
||
self.frame_count = 0
|
||
self.last_packet_count = 0
|
||
self.current_packet_count = 0
|
||
|
||
self.view_offset = 0
|
||
|
||
self.is_sim_running = False
|
||
self.sim_thread = None
|
||
self.is_optic_sim_running = False
|
||
self.optic_sim_thread = None
|
||
|
||
pg.setConfigOptions(antialias=False)
|
||
self.init_ui()
|
||
|
||
self.plot_timer = QTimer()
|
||
self.plot_timer.timeout.connect(self.update_live_plot)
|
||
|
||
def init_ui(self):
|
||
main_widget = QWidget()
|
||
self.setCentralWidget(main_widget)
|
||
layout = QVBoxLayout(main_widget)
|
||
|
||
# --- 第一排工具栏:格式与模式选择 ---
|
||
top_bar1 = QHBoxLayout()
|
||
|
||
self.mode_group = QButtonGroup(self)
|
||
self.rb_raw = QRadioButton("原始数据 (Raw)")
|
||
self.rb_corr = QRadioButton("校正数据 (Corr)")
|
||
self.rb_raw.setChecked(True)
|
||
self.mode_group.addButton(self.rb_raw)
|
||
self.mode_group.addButton(self.rb_corr)
|
||
|
||
# 格式选项:GPS经纬度 / 光泵
|
||
self.chk_gps_pos = QCheckBox("含GPS经纬度 (lat/lon/alt)")
|
||
self.chk_gps_pos.setChecked(True)
|
||
self.chk_optic = QCheckBox("含光泵数据 (optical mag)")
|
||
self.chk_optic.setChecked(False)
|
||
self.chk_optic.stateChanged.connect(self._on_optic_toggled)
|
||
|
||
btn_load = QPushButton("打开文件")
|
||
btn_load.clicked.connect(self.load_file)
|
||
btn_export = QPushButton("导出CSV")
|
||
btn_export.clicked.connect(self.export_csv)
|
||
|
||
self.chk_mouse_mode = QCheckBox("鼠标框选放大")
|
||
self.chk_mouse_mode.stateChanged.connect(self.toggle_mouse_mode)
|
||
|
||
btn_autoscale = QPushButton("复位视图")
|
||
btn_autoscale.clicked.connect(self.reset_view)
|
||
|
||
top_bar1.addWidget(QLabel("模式:"))
|
||
top_bar1.addWidget(self.rb_raw)
|
||
top_bar1.addWidget(self.rb_corr)
|
||
top_bar1.addSpacing(15)
|
||
top_bar1.addWidget(QLabel("格式:"))
|
||
top_bar1.addWidget(self.chk_gps_pos)
|
||
top_bar1.addWidget(self.chk_optic)
|
||
top_bar1.addSpacing(15)
|
||
top_bar1.addWidget(btn_load)
|
||
top_bar1.addWidget(btn_export)
|
||
top_bar1.addStretch()
|
||
top_bar1.addWidget(self.chk_mouse_mode)
|
||
top_bar1.addWidget(btn_autoscale)
|
||
|
||
# --- 第二排工具栏:接收串口 ---
|
||
top_bar2 = QHBoxLayout()
|
||
self.cb_ports = QComboBox()
|
||
self.cb_baudrate = QComboBox()
|
||
self.cb_baudrate.addItems(["9600", "115200", "230400", "460800", "921600", "2000000"])
|
||
self.cb_baudrate.setCurrentText("2000000")
|
||
btn_refresh_ports = QPushButton("刷新端口")
|
||
btn_refresh_ports.clicked.connect(self.refresh_ports)
|
||
self.btn_toggle_serial = QPushButton("打开接收串口")
|
||
self.btn_toggle_serial.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
|
||
self.btn_toggle_serial.clicked.connect(self.toggle_serial)
|
||
|
||
top_bar2.addWidget(QLabel("接收串口:"))
|
||
top_bar2.addWidget(self.cb_ports)
|
||
top_bar2.addWidget(btn_refresh_ports)
|
||
top_bar2.addWidget(QLabel("波特率:"))
|
||
top_bar2.addWidget(self.cb_baudrate)
|
||
top_bar2.addWidget(self.btn_toggle_serial)
|
||
top_bar2.addStretch()
|
||
|
||
# --- 第三排工具栏:GPS 模拟器 ---
|
||
top_bar3 = QHBoxLayout()
|
||
self.cb_sim_ports = QComboBox()
|
||
self.cb_sim_baudrate = QComboBox()
|
||
self.cb_sim_baudrate.addItems(["9600", "115200", "230400", "460800", "921600"])
|
||
self.cb_sim_baudrate.setCurrentText("115200")
|
||
self.btn_toggle_sim = QPushButton("开启GPS动态模拟 (10Hz)")
|
||
self.btn_toggle_sim.setStyleSheet("background-color: #FF9800; color: white; font-weight: bold;")
|
||
self.btn_toggle_sim.clicked.connect(self.toggle_gps_sim)
|
||
|
||
top_bar3.addWidget(QLabel("输出串口:"))
|
||
top_bar3.addWidget(self.cb_sim_ports)
|
||
top_bar3.addWidget(QLabel("波特率:"))
|
||
top_bar3.addWidget(self.cb_sim_baudrate)
|
||
top_bar3.addWidget(self.btn_toggle_sim)
|
||
top_bar3.addStretch()
|
||
|
||
# --- 第四排工具栏:光泵模拟器 ---
|
||
top_bar4 = QHBoxLayout()
|
||
self.cb_optic_ports = QComboBox()
|
||
self.cb_optic_baudrate = QComboBox()
|
||
self.cb_optic_baudrate.addItems(["9600", "115200", "230400", "460800", "921600"])
|
||
self.cb_optic_baudrate.setCurrentText("115200")
|
||
|
||
self.sb_optic_base = QComboBox() # 用ComboBox做快速基础值选择
|
||
self.sb_optic_base.setEditable(True)
|
||
self.sb_optic_base.addItems(["50000", "55000", "60000", "65000"])
|
||
self.sb_optic_base.setCurrentText("50000")
|
||
self.sb_optic_base.setFixedWidth(80)
|
||
|
||
self.cb_optic_rate = QComboBox()
|
||
self.cb_optic_rate.addItems(["1", "5", "10", "20", "50"])
|
||
self.cb_optic_rate.setCurrentText("10")
|
||
self.cb_optic_rate.setFixedWidth(55)
|
||
|
||
self.btn_toggle_optic_sim = QPushButton("开启光泵模拟")
|
||
self.btn_toggle_optic_sim.setStyleSheet("background-color: #673AB7; color: white; font-weight: bold;")
|
||
self.btn_toggle_optic_sim.clicked.connect(self.toggle_optic_sim)
|
||
|
||
self.lbl_optic_sim_val = QLabel("-- nT")
|
||
self.lbl_optic_sim_val.setStyleSheet("color: #673AB7; font-weight: bold; min-width: 120px;")
|
||
|
||
top_bar4.addWidget(QLabel("光泵输出串口:"))
|
||
top_bar4.addWidget(self.cb_optic_ports)
|
||
top_bar4.addWidget(QLabel("波特率:"))
|
||
top_bar4.addWidget(self.cb_optic_baudrate)
|
||
top_bar4.addWidget(QLabel("基础值(nT):"))
|
||
top_bar4.addWidget(self.sb_optic_base)
|
||
top_bar4.addWidget(QLabel("频率(Hz):"))
|
||
top_bar4.addWidget(self.cb_optic_rate)
|
||
top_bar4.addWidget(self.btn_toggle_optic_sim)
|
||
top_bar4.addWidget(self.lbl_optic_sim_val)
|
||
top_bar4.addStretch()
|
||
|
||
layout.addLayout(top_bar1)
|
||
layout.addLayout(top_bar2)
|
||
layout.addLayout(top_bar3)
|
||
layout.addLayout(top_bar4)
|
||
self.refresh_ports()
|
||
|
||
# --- 监控栏 ---
|
||
info_layout = QHBoxLayout()
|
||
self.lbl_info = QLabel("请加载文件或打开串口接收数据...")
|
||
self.lbl_info.setStyleSheet("color: blue; font-weight: bold;")
|
||
self.lbl_fps = QLabel("绘图帧率: -- FPS | 接收率: -- 包/秒")
|
||
self.lbl_fps.setStyleSheet("color: #E91E63; font-weight: bold;")
|
||
info_layout.addWidget(self.lbl_info)
|
||
info_layout.addStretch()
|
||
info_layout.addWidget(self.lbl_fps)
|
||
layout.addLayout(info_layout)
|
||
|
||
# --- 视图窗口控制栏(仅文件模式有效)---
|
||
view_bar = QHBoxLayout()
|
||
self.cb_page_size = QComboBox()
|
||
self.cb_page_size.addItems(["1000", "5000", "10000", "50000", "全部"])
|
||
self.cb_page_size.setCurrentText("5000")
|
||
self.cb_page_size.setFixedWidth(75)
|
||
self.cb_page_size.currentTextChanged.connect(self._on_page_size_changed)
|
||
|
||
self.btn_prev_page = QPushButton("◀ 上一页")
|
||
self.btn_prev_page.setFixedWidth(80)
|
||
self.btn_prev_page.clicked.connect(self._prev_page)
|
||
self.btn_next_page = QPushButton("下一页 ▶")
|
||
self.btn_next_page.setFixedWidth(80)
|
||
self.btn_next_page.clicked.connect(self._next_page)
|
||
|
||
self.sld_view = QSlider(Qt.Orientation.Horizontal)
|
||
self.sld_view.setMinimum(0)
|
||
self.sld_view.setMaximum(0)
|
||
self.sld_view.setValue(0)
|
||
self.sld_view.valueChanged.connect(self._on_slider_changed)
|
||
|
||
self.lbl_view_range = QLabel("无数据")
|
||
self.lbl_view_range.setMinimumWidth(220)
|
||
|
||
view_bar.addWidget(QLabel("每页点数:"))
|
||
view_bar.addWidget(self.cb_page_size)
|
||
view_bar.addWidget(self.btn_prev_page)
|
||
view_bar.addWidget(self.sld_view)
|
||
view_bar.addWidget(self.btn_next_page)
|
||
view_bar.addWidget(self.lbl_view_range)
|
||
view_bar.addStretch()
|
||
layout.addLayout(view_bar)
|
||
|
||
# ==========================================
|
||
# 多标签内容区域
|
||
# ==========================================
|
||
self.tabs = QTabWidget()
|
||
layout.addWidget(self.tabs)
|
||
|
||
# [Tab 1] 主波形图(ADC / 校正值)
|
||
self.plot_widget = pg.PlotWidget()
|
||
self.plot_widget.setBackground('w')
|
||
self.plot_widget.showGrid(x=True, y=True, alpha=0.3)
|
||
self.plot_widget.addLegend()
|
||
self.plot_widget.setLabel('bottom', 'Data Points (Index)')
|
||
self.plot_widget.setLabel('left', 'Value')
|
||
self.vb = self.plot_widget.plotItem.vb
|
||
self.tabs.addTab(self.plot_widget, "波形图 (Plot)")
|
||
|
||
# [Tab 2] 光泵磁场(仅含光泵时有意义)
|
||
self.optic_plot_widget = pg.PlotWidget()
|
||
self.optic_plot_widget.setBackground('w')
|
||
self.optic_plot_widget.showGrid(x=True, y=True, alpha=0.3)
|
||
self.optic_plot_widget.addLegend()
|
||
self.optic_plot_widget.setLabel('bottom', 'Data Points (Index)')
|
||
self.optic_plot_widget.setLabel('left', 'Magnetic Field (nT)')
|
||
self.optic_curve = self.optic_plot_widget.plot(
|
||
pen=pg.mkPen(color='#9C27B0', width=1.5), name='光泵磁场 (nT)')
|
||
self.tabs.addTab(self.optic_plot_widget, "光泵磁场 (Optic Mag)")
|
||
|
||
# [Tab 3] GPS 轨迹与海拔视图
|
||
traj_container = QWidget()
|
||
traj_layout = QVBoxLayout(traj_container)
|
||
traj_splitter = QSplitter(Qt.Orientation.Vertical)
|
||
|
||
self.traj_plot = pg.PlotWidget(title="实时轨迹 (经度 vs 纬度)")
|
||
self.traj_plot.setBackground('w')
|
||
self.traj_plot.showGrid(x=True, y=True, alpha=0.5)
|
||
self.traj_plot.setLabel('bottom', 'Longitude (经度)')
|
||
self.traj_plot.setLabel('left', 'Latitude (纬度)')
|
||
self.traj_curve = self.traj_plot.plot(pen=pg.mkPen('b', width=2), symbol='o', symbolSize=3, symbolBrush='b')
|
||
|
||
self.alt_plot = pg.PlotWidget(title="海拔高度 (Altitude)")
|
||
self.alt_plot.setBackground('w')
|
||
self.alt_plot.showGrid(x=True, y=True, alpha=0.5)
|
||
self.alt_plot.setLabel('bottom', 'Data Points (Index)')
|
||
self.alt_plot.setLabel('left', 'Altitude (m)')
|
||
self.alt_curve = self.alt_plot.plot(pen=pg.mkPen('g', width=2))
|
||
|
||
traj_splitter.addWidget(self.traj_plot)
|
||
traj_splitter.addWidget(self.alt_plot)
|
||
traj_layout.addWidget(traj_splitter)
|
||
self.tabs.addTab(traj_container, "轨迹与海拔 (Trajectory)")
|
||
|
||
# [Tab 4] 数据表格
|
||
self.table_view = QTableView()
|
||
self.table_view.setAlternatingRowColors(True)
|
||
self.table_view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||
self.tabs.addTab(self.table_view, "数据表 (Table)")
|
||
|
||
self._update_optic_tab_visibility()
|
||
|
||
def _on_optic_toggled(self):
|
||
self._update_optic_tab_visibility()
|
||
|
||
def _update_optic_tab_visibility(self):
|
||
# Tab 1 是光泵标签页
|
||
tab_idx = 1
|
||
if self.chk_optic.isChecked():
|
||
self.tabs.setTabEnabled(tab_idx, True)
|
||
self.tabs.setTabText(tab_idx, "光泵磁场 (Optic Mag)")
|
||
else:
|
||
self.tabs.setTabEnabled(tab_idx, False)
|
||
self.tabs.setTabText(tab_idx, "光泵磁场 (未启用)")
|
||
|
||
def _format_info(self):
|
||
has_pos = self.chk_gps_pos.isChecked()
|
||
has_optic = self.chk_optic.isChecked()
|
||
mode = "Raw" if self.rb_raw.isChecked() else "Corr"
|
||
parts = [mode]
|
||
if has_pos:
|
||
parts.append("含GPS经纬度")
|
||
if has_optic:
|
||
parts.append("含光泵")
|
||
return " | ".join(parts)
|
||
|
||
def get_current_dtype(self):
|
||
return make_dtype(
|
||
is_raw=self.rb_raw.isChecked(),
|
||
has_gps_pos=self.chk_gps_pos.isChecked(),
|
||
has_optic=self.chk_optic.isChecked()
|
||
)
|
||
|
||
def get_start_marker(self):
|
||
return MARKER_OPTIC if self.chk_optic.isChecked() else MARKER_STANDARD
|
||
|
||
def get_column_config(self):
|
||
is_raw = self.rb_raw.isChecked()
|
||
has_gps_pos = self.chk_gps_pos.isChecked()
|
||
has_optic = self.chk_optic.isChecked()
|
||
|
||
if is_raw:
|
||
data_cols = ['adc1', 'adc2', 'adc3']
|
||
labels = ['ADC 1', 'ADC 2', 'ADC 3']
|
||
else:
|
||
data_cols = ['corr_x', 'corr_y', 'corr_z']
|
||
labels = ['X Axis', 'Y Axis', 'Z Axis']
|
||
|
||
cols = data_cols + ['gps_time']
|
||
if has_gps_pos:
|
||
cols += ['gps_latitude', 'gps_longitude', 'gps_altitude']
|
||
if has_optic:
|
||
cols.append('optical_mag')
|
||
|
||
return cols, data_cols, labels
|
||
|
||
def _get_page_size(self):
|
||
txt = self.cb_page_size.currentText()
|
||
if txt == "全部":
|
||
return len(self.df) if not self.df.empty else 0
|
||
return int(txt)
|
||
|
||
def _update_view_slider(self):
|
||
total = len(self.df)
|
||
page = self._get_page_size()
|
||
max_offset = max(0, total - page)
|
||
self.sld_view.blockSignals(True)
|
||
self.sld_view.setMaximum(max_offset)
|
||
self.sld_view.setSingleStep(max(1, page // 10))
|
||
self.sld_view.setPageStep(page)
|
||
self.view_offset = min(self.view_offset, max_offset)
|
||
self.sld_view.setValue(self.view_offset)
|
||
self.sld_view.blockSignals(False)
|
||
self._update_view_label(total, page)
|
||
|
||
def _update_view_label(self, total, page):
|
||
if total == 0:
|
||
self.lbl_view_range.setText("无数据")
|
||
return
|
||
start = self.view_offset
|
||
end = min(start + page, total)
|
||
self.lbl_view_range.setText(f"显示 {start} – {end} / 共 {total} 点")
|
||
|
||
def _on_slider_changed(self, value):
|
||
self.view_offset = value
|
||
total = len(self.df)
|
||
page = self._get_page_size()
|
||
self._update_view_label(total, page)
|
||
if not self.df.empty and not self.is_live_mode:
|
||
_, data_cols, labels = self.get_column_config()
|
||
self.plot_static_data(data_cols, labels)
|
||
|
||
def _on_page_size_changed(self):
|
||
self.view_offset = 0
|
||
if not self.df.empty and not self.is_live_mode:
|
||
self._update_view_slider()
|
||
_, data_cols, labels = self.get_column_config()
|
||
self.plot_static_data(data_cols, labels)
|
||
|
||
def _prev_page(self):
|
||
page = self._get_page_size()
|
||
self.sld_view.setValue(max(0, self.view_offset - page))
|
||
|
||
def _next_page(self):
|
||
page = self._get_page_size()
|
||
self.sld_view.setValue(min(self.sld_view.maximum(), self.view_offset + page))
|
||
|
||
def load_file(self):
|
||
if self.is_live_mode:
|
||
QMessageBox.warning(self, "警告", "请先关闭串口后再加载文件。")
|
||
return
|
||
|
||
file_name, _ = QFileDialog.getOpenFileName(self, "选择文件", "", "Data (*.dat);;All (*)")
|
||
if not file_name: return
|
||
|
||
try:
|
||
is_raw = self.rb_raw.isChecked()
|
||
has_gps_pos = self.chk_gps_pos.isChecked()
|
||
has_optic = self.chk_optic.isChecked()
|
||
|
||
raw_bytes = open(file_name, 'rb').read()
|
||
if len(raw_bytes) == 0:
|
||
QMessageBox.warning(self, "提示", "文件为空。")
|
||
return
|
||
|
||
if has_optic:
|
||
# 混流解析:文件中可能同时存在标准包和光泵包
|
||
self.df, self.df_optic = parse_mixed_stream(raw_bytes, is_raw, has_gps_pos)
|
||
else:
|
||
# 纯标准包,直接 frombuffer
|
||
dtype = make_dtype(is_raw, has_gps_pos, False)
|
||
arr = np.frombuffer(raw_bytes, dtype=dtype)
|
||
self.df = pd.DataFrame(arr)
|
||
self.df_optic = pd.DataFrame()
|
||
|
||
if self.df.empty and self.df_optic.empty:
|
||
QMessageBox.warning(self, "提示", "未能解析到有效数据包,请检查格式选项。")
|
||
return
|
||
|
||
cols, data_cols, labels = self.get_column_config()
|
||
std_size = make_dtype(is_raw, has_gps_pos, False).itemsize
|
||
optic_size = make_dtype(is_raw, has_gps_pos, True).itemsize
|
||
self.lbl_info.setText(
|
||
f"文件加载成功 | 标准包: {len(self.df)} 行 ({std_size}B)"
|
||
+ (f" | 光泵包: {len(self.df_optic)} 行 ({optic_size}B)" if has_optic else "")
|
||
+ f" | 格式: {self._format_info()}"
|
||
)
|
||
self.lbl_fps.setText("绘图帧率: -- FPS | 接收率: -- 包/秒")
|
||
self.view_offset = 0
|
||
self._update_view_slider()
|
||
self.refresh_table(cols)
|
||
self.plot_static_data(data_cols, labels)
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "解析错误", str(e))
|
||
|
||
def refresh_table(self, cols):
|
||
valid_cols = [c for c in cols if c in self.df.columns]
|
||
display_df = self.df[valid_cols] if not self.df.empty else pd.DataFrame(columns=valid_cols)
|
||
self.model = BigDataModel(display_df)
|
||
self.table_view.setModel(self.model)
|
||
self.table_view.resizeColumnsToContents()
|
||
|
||
def plot_static_data(self, data_cols, labels):
|
||
page = self._get_page_size()
|
||
start = self.view_offset
|
||
end = min(start + page, len(self.df)) if page > 0 else len(self.df)
|
||
df_view = self.df.iloc[start:end]
|
||
|
||
# 1. 主波形图
|
||
self.plot_widget.clear()
|
||
self.curves.clear()
|
||
colors = ['#FF0000', '#00AA00', '#0000FF']
|
||
x_data = np.arange(start, start + len(df_view))
|
||
|
||
for i, col in enumerate(data_cols):
|
||
y_data = df_view[col].values
|
||
curve = pg.PlotCurveItem(x=x_data, y=y_data, pen=pg.mkPen(color=colors[i], width=1.5),
|
||
name=labels[i], skipFiniteCheck=True, autoDownsample=True, clipToView=True)
|
||
self.plot_widget.addItem(curve)
|
||
|
||
self.plot_widget.setLabel('bottom', 'Data Points (Index)')
|
||
self.plot_widget.autoRange()
|
||
|
||
# 2. 光泵磁场图(按比例切片 df_optic)
|
||
if self.chk_optic.isChecked() and not self.df_optic.empty and 'optical_mag' in self.df_optic.columns:
|
||
total_std = max(len(self.df), 1)
|
||
total_optic = len(self.df_optic)
|
||
o_start = int(start * total_optic / total_std)
|
||
o_end = int(end * total_optic / total_std)
|
||
optic_nT = self.df_optic['optical_mag'].values[o_start:o_end] / 1000.0
|
||
self.optic_curve.setData(x=np.arange(o_start, o_start + len(optic_nT)), y=optic_nT)
|
||
self.optic_plot_widget.autoRange()
|
||
else:
|
||
self.optic_curve.setData([], [])
|
||
|
||
# 3. 轨迹(显示全部)和海拔(随窗口切片)
|
||
if self.chk_gps_pos.isChecked() and 'gps_longitude' in self.df.columns:
|
||
lons_all = self.df['gps_longitude'].values
|
||
lats_all = self.df['gps_latitude'].values
|
||
valid_idx = (lons_all != 0.0) & (lats_all != 0.0)
|
||
self.traj_curve.setData(lons_all[valid_idx], lats_all[valid_idx]) if np.any(valid_idx) else self.traj_curve.setData(lons_all, lats_all)
|
||
self.traj_plot.autoRange()
|
||
|
||
alts_view = df_view['gps_altitude'].values if 'gps_altitude' in df_view.columns else np.zeros(len(df_view))
|
||
self.alt_curve.setData(x=x_data, y=alts_view)
|
||
self.alt_plot.autoRange()
|
||
else:
|
||
self.traj_curve.setData([], [])
|
||
self.alt_curve.setData([])
|
||
|
||
def refresh_ports(self):
|
||
self.cb_ports.clear()
|
||
self.cb_sim_ports.clear()
|
||
self.cb_optic_ports.clear()
|
||
ports = serial.tools.list_ports.comports()
|
||
for p in ports:
|
||
port_name = f"{p.device} - {p.description}"
|
||
self.cb_ports.addItem(port_name, p.device)
|
||
self.cb_sim_ports.addItem(port_name, p.device)
|
||
self.cb_optic_ports.addItem(port_name, p.device)
|
||
|
||
def _set_format_controls_enabled(self, enabled: bool):
|
||
self.rb_raw.setEnabled(enabled)
|
||
self.rb_corr.setEnabled(enabled)
|
||
self.chk_gps_pos.setEnabled(enabled)
|
||
self.chk_optic.setEnabled(enabled)
|
||
|
||
def toggle_serial(self):
|
||
if not self.is_live_mode:
|
||
port = self.cb_ports.currentData()
|
||
if not port:
|
||
QMessageBox.warning(self, "提示", "未找到有效串口")
|
||
return
|
||
|
||
baud = int(self.cb_baudrate.currentText())
|
||
is_raw = self.rb_raw.isChecked()
|
||
has_gps_pos = self.chk_gps_pos.isChecked()
|
||
has_optic = self.chk_optic.isChecked()
|
||
dtype_std = make_dtype(is_raw, has_gps_pos, False)
|
||
dtype_optic = make_dtype(is_raw, has_gps_pos, True) if has_optic else None
|
||
|
||
self.df = pd.DataFrame()
|
||
self.df_optic = pd.DataFrame()
|
||
self.live_data_list = []
|
||
self.live_optic_list = []
|
||
|
||
self.plot_widget.clear()
|
||
self.curves.clear()
|
||
self.optic_curve.setData([], [])
|
||
self.traj_curve.setData([], [])
|
||
self.alt_curve.setData([])
|
||
|
||
self.plot_widget.setLabel('bottom', 'Latest Points (Index)')
|
||
|
||
colors = ['#FF0000', '#00AA00', '#0000FF']
|
||
_, _, labels = self.get_column_config()
|
||
for i in range(len(labels)):
|
||
curve = pg.PlotCurveItem(pen=pg.mkPen(color=colors[i], width=1.5), name=labels[i])
|
||
self.plot_widget.addItem(curve)
|
||
self.curves.append(curve)
|
||
|
||
self.current_packet_count = 0
|
||
self.last_packet_count = 0
|
||
self.frame_count = 0
|
||
self.last_fps_time = time.perf_counter()
|
||
self.lbl_fps.setText("绘图帧率: 计算中... | 接收率: 计算中...")
|
||
|
||
self.serial_thread = SerialReaderThread(port, baud, dtype_std, dtype_optic)
|
||
self.serial_thread.data_received.connect(self.on_live_data_received)
|
||
self.serial_thread.optic_received.connect(self.on_live_optic_received)
|
||
self.serial_thread.error_occurred.connect(self.on_serial_error)
|
||
self.serial_thread.start()
|
||
|
||
self.plot_timer.start(50)
|
||
|
||
self.is_live_mode = True
|
||
self.btn_toggle_serial.setText("关闭串口停止接收")
|
||
self.btn_toggle_serial.setStyleSheet("background-color: #f44336; color: white; font-weight: bold;")
|
||
self._set_format_controls_enabled(False)
|
||
|
||
else:
|
||
self.serial_thread.stop()
|
||
self.serial_thread.wait()
|
||
self.plot_timer.stop()
|
||
|
||
self.is_live_mode = False
|
||
self.btn_toggle_serial.setText("打开接收串口")
|
||
self.btn_toggle_serial.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
|
||
self.lbl_fps.setText("绘图帧率: -- FPS | 接收率: -- 包/秒")
|
||
self._set_format_controls_enabled(True)
|
||
|
||
if self.live_data_list:
|
||
self.df = pd.DataFrame(np.concatenate(self.live_data_list))
|
||
if self.live_optic_list:
|
||
self.df_optic = pd.DataFrame(np.concatenate(self.live_optic_list))
|
||
cols, _, _ = self.get_column_config()
|
||
self.refresh_table(cols)
|
||
self.lbl_info.setText(
|
||
f"串口采集完毕。标准包: {len(self.df)} 行"
|
||
+ (f" | 光泵包: {len(self.df_optic)} 行" if not self.df_optic.empty else "")
|
||
)
|
||
|
||
def on_live_data_received(self, data_array):
|
||
self.live_data_list.append(data_array)
|
||
self.current_packet_count += len(data_array)
|
||
self.lbl_info.setText(f"正在接收数据... 标准包: {self.current_packet_count} 帧 | 格式: {self._format_info()}")
|
||
|
||
def on_live_optic_received(self, data_array):
|
||
self.live_optic_list.append(data_array)
|
||
|
||
def update_live_plot(self):
|
||
self.frame_count += 1
|
||
current_time = time.perf_counter()
|
||
elapsed = current_time - self.last_fps_time
|
||
|
||
if elapsed >= 1.0:
|
||
fps = self.frame_count / elapsed
|
||
pps = (self.current_packet_count - self.last_packet_count) / elapsed
|
||
self.lbl_fps.setText(f"绘图帧率: {fps:.1f} FPS | 接收率: {pps:.0f} 包/秒")
|
||
self.last_fps_time = current_time
|
||
self.frame_count = 0
|
||
self.last_packet_count = self.current_packet_count
|
||
|
||
if not self.live_data_list:
|
||
return
|
||
|
||
MAX_POINTS = 5000
|
||
recent_chunks = []
|
||
point_count = 0
|
||
for arr in reversed(self.live_data_list):
|
||
recent_chunks.append(arr)
|
||
point_count += len(arr)
|
||
if point_count >= MAX_POINTS:
|
||
break
|
||
|
||
recent_data = np.concatenate(recent_chunks[::-1])
|
||
if len(recent_data) > MAX_POINTS:
|
||
recent_data = recent_data[-MAX_POINTS:]
|
||
|
||
# 主波形图
|
||
_, data_cols, _ = self.get_column_config()
|
||
for i, col in enumerate(data_cols):
|
||
self.curves[i].setData(recent_data[col])
|
||
|
||
# 光泵磁场(实时,来自独立的 live_optic_list)
|
||
if self.chk_optic.isChecked() and self.live_optic_list:
|
||
MAX_OPTIC = 500
|
||
recent_optic = np.concatenate(
|
||
list(reversed(self.live_optic_list))[:10][::-1])[-MAX_OPTIC:]
|
||
self.optic_curve.setData(recent_optic['optical_mag'] / 1000.0)
|
||
|
||
# 轨迹和海拔(实时)
|
||
if self.chk_gps_pos.isChecked() and 'gps_longitude' in recent_data.dtype.names:
|
||
lons = recent_data['gps_longitude']
|
||
lats = recent_data['gps_latitude']
|
||
alts = recent_data['gps_altitude'] if 'gps_altitude' in recent_data.dtype.names else np.zeros_like(lons)
|
||
|
||
valid_idx = (lons != 0.0) & (lats != 0.0)
|
||
self.traj_curve.setData(lons[valid_idx], lats[valid_idx]) if np.any(valid_idx) else self.traj_curve.setData(lons, lats)
|
||
self.alt_curve.setData(alts)
|
||
|
||
def on_serial_error(self, err_msg):
|
||
self.toggle_serial()
|
||
QMessageBox.critical(self, "接收串口错误", f"发生错误:\n{err_msg}")
|
||
|
||
def toggle_gps_sim(self):
|
||
if not self.is_sim_running:
|
||
port = self.cb_sim_ports.currentData()
|
||
if not port:
|
||
QMessageBox.warning(self, "提示", "未找到有效的输出串口")
|
||
return
|
||
|
||
if self.is_live_mode and port == self.cb_ports.currentData():
|
||
reply = QMessageBox.question(self, "警告",
|
||
"模拟输出端口与当前接收端口相同,可能会导致端口冲突。确定要继续吗?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||
if reply == QMessageBox.StandardButton.No: return
|
||
|
||
baud = int(self.cb_sim_baudrate.currentText())
|
||
self.sim_thread = GPSSimulatorThread(port, baud)
|
||
self.sim_thread.error_occurred.connect(self.on_sim_error)
|
||
self.sim_thread.start()
|
||
|
||
self.is_sim_running = True
|
||
self.btn_toggle_sim.setText("关闭GPS动态模拟")
|
||
self.btn_toggle_sim.setStyleSheet("background-color: #f44336; color: white; font-weight: bold;")
|
||
else:
|
||
self.sim_thread.stop()
|
||
self.sim_thread.wait()
|
||
|
||
self.is_sim_running = False
|
||
self.btn_toggle_sim.setText("开启GPS动态模拟 (10Hz)")
|
||
self.btn_toggle_sim.setStyleSheet("background-color: #FF9800; color: white; font-weight: bold;")
|
||
|
||
def on_sim_error(self, err_msg):
|
||
self.toggle_gps_sim()
|
||
QMessageBox.critical(self, "输出串口错误", f"模拟器串口发生错误:\n{err_msg}")
|
||
|
||
def toggle_optic_sim(self):
|
||
if not self.is_optic_sim_running:
|
||
port = self.cb_optic_ports.currentData()
|
||
if not port:
|
||
QMessageBox.warning(self, "提示", "未找到有效的光泵输出串口")
|
||
return
|
||
|
||
try:
|
||
base_nT = float(self.sb_optic_base.currentText())
|
||
except ValueError:
|
||
QMessageBox.warning(self, "提示", "基础值请输入有效数字(单位 nT)")
|
||
return
|
||
|
||
rate_hz = int(self.cb_optic_rate.currentText())
|
||
baud = int(self.cb_optic_baudrate.currentText())
|
||
|
||
self.optic_sim_thread = OpticMagSimulatorThread(port, baud, base_nT, rate_hz)
|
||
self.optic_sim_thread.error_occurred.connect(self.on_optic_sim_error)
|
||
self.optic_sim_thread.value_updated.connect(
|
||
lambda v: self.lbl_optic_sim_val.setText(f"{v:.3f} nT"))
|
||
self.optic_sim_thread.start()
|
||
|
||
self.is_optic_sim_running = True
|
||
self.btn_toggle_optic_sim.setText("关闭光泵模拟")
|
||
self.btn_toggle_optic_sim.setStyleSheet("background-color: #f44336; color: white; font-weight: bold;")
|
||
self.sb_optic_base.setEnabled(False)
|
||
self.cb_optic_rate.setEnabled(False)
|
||
else:
|
||
self.optic_sim_thread.stop()
|
||
self.optic_sim_thread.wait()
|
||
|
||
self.is_optic_sim_running = False
|
||
self.btn_toggle_optic_sim.setText("开启光泵模拟")
|
||
self.btn_toggle_optic_sim.setStyleSheet("background-color: #673AB7; color: white; font-weight: bold;")
|
||
self.lbl_optic_sim_val.setText("-- nT")
|
||
self.sb_optic_base.setEnabled(True)
|
||
self.cb_optic_rate.setEnabled(True)
|
||
|
||
def on_optic_sim_error(self, err_msg):
|
||
self.toggle_optic_sim()
|
||
QMessageBox.critical(self, "光泵串口错误", f"光泵模拟器串口发生错误:\n{err_msg}")
|
||
|
||
def toggle_mouse_mode(self, state):
|
||
mode = self.vb.RectMode if state == 2 else self.vb.PanMode
|
||
self.vb.setMouseMode(mode)
|
||
self.traj_plot.plotItem.vb.setMouseMode(mode)
|
||
self.alt_plot.plotItem.vb.setMouseMode(mode)
|
||
|
||
def reset_view(self):
|
||
self.plot_widget.autoRange()
|
||
self.optic_plot_widget.autoRange()
|
||
self.traj_plot.autoRange()
|
||
self.alt_plot.autoRange()
|
||
|
||
def export_csv(self):
|
||
if self.df.empty: return
|
||
path, _ = QFileDialog.getSaveFileName(self, "保存", "export.csv", "CSV (*.csv)")
|
||
if path:
|
||
cols, _, _ = self.get_column_config()
|
||
valid_cols = [c for c in cols if c in self.df.columns]
|
||
self.df[valid_cols].to_csv(path, index=False)
|
||
QMessageBox.information(self, "完成", "导出成功")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app = QApplication(sys.argv)
|
||
w = DataAnalyzerUI()
|
||
w.show()
|
||
sys.exit(app.exec())
|