Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 
 
 

433 righe
18 KiB

  1. # -*- coding: utf-8 -*-
  2. """
  3. .bin 波形文件 -> 时频曲线查看/出图工具(PLSR 项目用)
  4. 数据格式: 逻辑分析仪导出的单通道数字采样,每字节 1 个采样点;
  5. 电平 >= 阈值(默认128) 判为高(脉冲),否则为低。
  6. --reverse 时先把高低电平对调,再按同样规则找脉冲。
  7. 频率定义: 每脉冲频率 = 采样率 / 脉冲周期;
  8. 周期 = 本脉冲高电平起点 -> 下一脉冲高电平起点;
  9. 仅使用实际检测到的相邻上升沿,不猜测末脉冲周期。
  10. 时间定义: 脉冲时间 = 高电平中点(与低电平/空闲段无关)。
  11. 用法:
  12. python bin_to_time_freq1.py <bin文件> [采样率Hz] [选项]
  13. 示例:
  14. python bin_to_time_freq1.py "Document/PLSR_document/波形/10段.bin" 6250000
  15. python bin_to_time_freq1.py xxx.bin 6250000 --ymax=12000
  16. python bin_to_time_freq1.py xxx.bin 6250000 --line
  17. python bin_to_time_freq1.py xxx.bin 6250000 --reverse
  18. python bin_to_time_freq1.py xxx.bin 6250000 --d1 --d2
  19. python bin_to_time_freq1.py xxx.bin 6250000 --d1 --dwindow-ms=10
  20. python bin_to_time_freq1.py xxx.bin 6250000 --d1 --d2 --d1-window-ms=5 --d2-window-ms=15
  21. python bin_to_time_freq1.py xxx.bin 6250000 --save=out.png
  22. python bin_to_time_freq1.py xxx.bin 6250000 --selftest
  23. 选项:
  24. --thresh=128 电平阈值(默认128)
  25. --ymax=12000 频率轴上限(默认自适应)
  26. --line 将脉冲点连成折线(默认只画散点)
  27. --reverse 高低电平对调后再算频率点(低电平当脉冲)
  28. --d1 另开一张图:频率对时间的一阶导数 df/dt(红)
  29. --d2 另开一张图:频率对时间的二阶导数 d²f/dt²(黑)
  30. --dwindow-ms=5 一、二阶导数的公共拟合窗口,单位 ms(默认5)
  31. --d1-window-ms 原函数 -> 一阶导数的拟合窗口(默认继承 --dwindow-ms)
  32. --d2-window-ms 一阶导数 -> 二阶导数的拟合窗口(默认继承 --dwindow-ms)
  33. --save=路径 保存 PNG 后退出(不弹窗)
  34. --selftest 仅打印统计,不画图
  35. --help, -h 显示这份中文帮助后退出
  36. 交互(弹窗模式):
  37. 滚轮 缩放 X 轴(向上放大/向下缩小,以鼠标位置为中心)
  38. Ctrl+滚轮 缩放 Y 轴(以鼠标 Y 位置为中心)
  39. 左键拖拽 双向平移(上下左右跟随鼠标)
  40. 双击 复位到全图
  41. 鼠标悬停 高亮最近的点并显示其脉冲序号、时间与频率
  42. 依赖: pip install numpy matplotlib
  43. """
  44. import sys
  45. import os
  46. import numpy as np
  47. try:
  48. import matplotlib
  49. matplotlib.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']
  50. matplotlib.rcParams['axes.unicode_minus'] = False
  51. except Exception:
  52. pass
  53. def load_time_freq(path, fs, threshold=128, reverse=False):
  54. """读取 bin,返回 (t_sec, freq_hz, hi_width_sec, meta)。
  55. t_sec 以第一个脉冲为 0 时刻;hi_width 为每脉冲高电平宽度。
  56. reverse=True 时先把高低电平对调,再按高电平找脉冲。"""
  57. data = np.fromfile(path, dtype=np.uint8)
  58. if data.size == 0:
  59. raise ValueError("文件为空: %s" % path)
  60. if fs <= 0:
  61. raise ValueError("采样率必须大于0")
  62. if not 0 <= threshold <= 255:
  63. raise ValueError("阈值必须在0到255之间")
  64. samples = (data >= threshold).astype(np.int8)
  65. if reverse:
  66. samples = 1 - samples
  67. # run 级压缩
  68. changes = np.flatnonzero(np.diff(samples) != 0) + 1
  69. starts = np.concatenate(([0], changes))
  70. ends = np.concatenate((changes, [len(samples)]))
  71. runs = np.column_stack((starts, ends, samples[starts]))
  72. # 每个高电平 run 的起点都是一个上升沿。如果采集从高电平开始,
  73. # 第一个 run 的真实上升沿在文件外,不能用来测周期。
  74. hi = np.flatnonzero(runs[:, 2] == 1)
  75. if hi.size and runs[hi[0], 0] == 0:
  76. hi = hi[1:]
  77. if hi.size < 2:
  78. raise ValueError("至少需要两个完整的上升沿才能计算频率: %s" % path)
  79. t_start = runs[hi, 0].astype(np.int64)
  80. hi_width = (runs[hi, 1] - runs[hi, 0]).astype(np.int64)
  81. # 只用相邻实测上升沿求周期,不根据占空比猜测末脉冲频率。
  82. period = np.diff(t_start)
  83. freq = float(fs) / np.maximum(period, 1)
  84. # 时间:高电平中点,去起始偏移
  85. t_sec = (t_start[:-1] + hi_width[:-1] / 2.0) / fs
  86. t_sec = t_sec - t_sec[0]
  87. meta = {
  88. 'total_samples': int(len(samples)),
  89. 'duration': len(samples) / fs,
  90. 'pulses': int(len(freq)),
  91. 'detected_pulses': int(len(t_start)),
  92. 'offset_ms': t_start[0] / fs * 1000,
  93. }
  94. return t_sec, freq, hi_width[:-1] / fs, meta
  95. def local_linear_derivative(t_sec, values, window_s=5e-3, min_points=7):
  96. """对非等间隔时间点做局部线性拟合,返回 d(values)/dt。
  97. 直接对相邻单周期频率做差分会放大整数采样点带来的量化抖动;
  98. 局部最小二乘斜率使用真实时间间隔,并对这种抖动做平均。
  99. """
  100. t = np.asarray(t_sec, dtype=np.float64)
  101. y = np.asarray(values, dtype=np.float64)
  102. if t.ndim != 1 or y.ndim != 1 or len(t) != len(y):
  103. raise ValueError("t_sec 和 values 必须是等长一维数组")
  104. if len(t) < 2:
  105. raise ValueError("至少需要两个点才能计算导数")
  106. if window_s <= 0:
  107. raise ValueError("导数拟合窗口必须大于0")
  108. if np.any(np.diff(t) <= 0):
  109. raise ValueError("时间点必须严格递增")
  110. n = len(t)
  111. min_points = min(n, max(2, int(min_points)))
  112. half_window = window_s / 2.0
  113. left = np.searchsorted(t, t - half_window, side='left')
  114. right = np.searchsorted(t, t + half_window, side='right')
  115. # 低频段在固定时间窗口内可能点数太少,至少补足 min_points 个点。
  116. idx = np.arange(n)
  117. fallback_left = np.clip(idx - min_points // 2, 0, n - min_points)
  118. fallback_right = fallback_left + min_points
  119. too_few = (right - left) < min_points
  120. left[too_few] = fallback_left[too_few]
  121. right[too_few] = fallback_right[too_few]
  122. # 前缀和使每个窗口的最小二乘斜率可以 O(1) 计算。
  123. # 先把时间原点移到数据中心,降低长时采集时前缀和相减的精度损失。
  124. x = t - (t[0] + t[-1]) / 2.0
  125. sx = np.concatenate(([0.0], np.cumsum(x)))
  126. sy = np.concatenate(([0.0], np.cumsum(y)))
  127. sxx = np.concatenate(([0.0], np.cumsum(x * x)))
  128. sxy = np.concatenate(([0.0], np.cumsum(x * y)))
  129. count = (right - left).astype(np.float64)
  130. sum_x = sx[right] - sx[left]
  131. sum_y = sy[right] - sy[left]
  132. sum_xx = sxx[right] - sxx[left]
  133. sum_xy = sxy[right] - sxy[left]
  134. denominator = count * sum_xx - sum_x * sum_x
  135. numerator = count * sum_xy - sum_x * sum_y
  136. derivative = np.empty(n, dtype=np.float64)
  137. good = np.abs(denominator) > np.finfo(np.float64).eps
  138. derivative[good] = numerator[good] / denominator[good]
  139. derivative[~good] = np.gradient(y, t)[~good]
  140. return derivative
  141. def selftest(path, fs, thresh, reverse=False):
  142. import statistics
  143. t, freq, hi_w, meta = load_time_freq(path, fs, thresh, reverse)
  144. print("文件: %s" % path)
  145. print("总采样: %d (%.3f s @ %.2f MS/s)" % (meta['total_samples'], meta['duration'], fs / 1e6))
  146. print("起始采集偏移: %.1f ms" % meta['offset_ms'])
  147. print("检测到脉冲: %d,频率测量点: %d" % (
  148. meta['detected_pulses'], meta['pulses']))
  149. if meta['pulses']:
  150. print("频率 min=%.0f max=%.0f 中位=%.0f Hz" % (
  151. freq.min(), freq.max(), statistics.median(freq)))
  152. print("末测量点: t=%.3f ms, 高电平 %.3f ms, %.0f Hz" % (
  153. t[-1] * 1000, hi_w[-1] * 1000, freq[-1]))
  154. print("波形活动时长: %.1f ms" % ((t[-1] + hi_w[-1] / 2) * 1000))
  155. def plot_show(path, fs, thresh, ymax, save_path=None, draw_line=False,
  156. reverse=False, draw_d1=False, draw_d2=False,
  157. d1_window_ms=5.0, d2_window_ms=None):
  158. t, freq, hi_w, meta = load_time_freq(path, fs, thresh, reverse)
  159. import matplotlib.pyplot as plt
  160. from matplotlib.ticker import MaxNLocator
  161. draw_d1 = draw_d1 and (len(freq) >= 2)
  162. draw_d2 = draw_d2 and (len(freq) >= 3)
  163. fig, ax = plt.subplots(figsize=(15, 7))
  164. fig.subplots_adjust(bottom=0.10, top=0.92)
  165. # 全脉冲散点:复用 artist,重绘时按可见范围抽稀(大文件流畅)
  166. MAX_VISIBLE_POINTS = 5000 # 可见范围内最多绘制的点数,超过则等间隔抽稀
  167. (scatter,) = ax.plot([], [], '.', ms=1.5, color='C0', alpha=0.5, zorder=2)
  168. line = None
  169. if draw_line:
  170. (line,) = ax.plot([], [], '-', lw=0.9, color='C0', alpha=0.85, zorder=1)
  171. tx_ms = t * 1000
  172. x_full = tx_ms
  173. y_full = freq
  174. ax.set_xlim(0.0, (t[-1] + hi_w[-1]) * 1000 * 1.02)
  175. ax.set_ylim(0, ymax if ymax > 0 else freq.max() * 1.08)
  176. title_extra = ",高低对调" if reverse else ""
  177. ax.set_title("%s 时频曲线(检测到脉冲 %d,频率测量点 %d,活动时长 %.0f ms%s)" % (
  178. os.path.basename(path), meta['detected_pulses'], meta['pulses'],
  179. (t[-1] + hi_w[-1] / 2) * 1000, title_extra))
  180. ax.set_xlabel("时间 (ms)")
  181. ax.set_ylabel("频率 (Hz)")
  182. # 更密的刻度
  183. ax.xaxis.set_major_locator(MaxNLocator(nbins=20))
  184. ax.yaxis.set_major_locator(MaxNLocator(nbins=15))
  185. ax.grid(True, which='major', alpha=0.35)
  186. ax.grid(True, which='minor', alpha=0.15)
  187. ax.minorticks_on()
  188. fig_der = None
  189. if draw_d1 or draw_d2:
  190. f64 = freq.astype(np.float64)
  191. if d2_window_ms is None:
  192. d2_window_ms = d1_window_ms
  193. d1_full = local_linear_derivative(t, f64, d1_window_ms * 1e-3)
  194. d2_full = (local_linear_derivative(t, d1_full, d2_window_ms * 1e-3)
  195. if draw_d2 else None)
  196. plot_count = int(draw_d1) + int(draw_d2)
  197. fig_der, axes_der = plt.subplots(plot_count, 1, figsize=(15, 4.5 * plot_count),
  198. sharex=True, squeeze=False)
  199. fig_der.subplots_adjust(bottom=0.14, top=0.90)
  200. row = 0
  201. if draw_d1:
  202. ax_der = axes_der[row, 0]
  203. ax_der.plot(x_full, d1_full, '-', lw=0.9, color='red', label='df/dt')
  204. ax_der.set_ylabel("一阶导数 (Hz/s)")
  205. ax_der.legend(loc='upper right')
  206. row += 1
  207. if draw_d2:
  208. ax_der = axes_der[row, 0]
  209. ax_der.plot(x_full, d2_full, '-', lw=0.9, color='black', label='d²f/dt²')
  210. ax_der.set_ylabel("二阶导数 (Hz/s²)")
  211. ax_der.legend(loc='upper right')
  212. for ax_der in axes_der[:, 0]:
  213. ax_der.set_xlim(0.0, (t[-1] + hi_w[-1]) * 1000 * 1.02)
  214. ax_der.grid(True, which='major', alpha=0.35)
  215. ax_der.minorticks_on()
  216. axes_der[-1, 0].set_xlabel("时间 (ms)")
  217. axes_der[0, 0].set_title(
  218. "时频曲线导数(一阶窗口 %.3g ms / 二阶窗口 %.3g ms)" %
  219. (d1_window_ms, d2_window_ms))
  220. if save_path:
  221. scatter.set_data(x_full, y_full)
  222. if line is not None:
  223. line.set_data(x_full, y_full)
  224. root, ext = os.path.splitext(save_path)
  225. fig.savefig(save_path, dpi=130)
  226. print("已保存: %s" % save_path)
  227. if fig_der is not None:
  228. der_path = root + "_d" + ext
  229. fig_der.savefig(der_path, dpi=130)
  230. print("已保存: %s" % der_path)
  231. plt.close(fig)
  232. if fig_der is not None:
  233. plt.close(fig_der)
  234. return
  235. # 交互:滚轮缩放 X(Ctrl+滚轮缩放 Y)/ 左键双向拖拽平移 / 双击复位 / 悬停高亮
  236. state = {'press_x': None, 'press_y': None,
  237. 'press_xlim': None, 'press_ylim': None}
  238. # 悬停高亮:一个红点标记 + 一个带框文本
  239. (hl_marker,) = ax.plot([], [], 'o', ms=9, mfc='red', mec='white',
  240. mew=1.0, zorder=5, visible=False)
  241. hl_text = ax.text(0, 0, '', fontsize=10, color='black',
  242. bbox=dict(boxstyle='round,pad=0.3', fc='yellow', ec='red', alpha=0.9),
  243. zorder=6, visible=False)
  244. hover_last = {'idx': -1, 'visible': False}
  245. # 可见范围内抽稀绘制散点(大文件性能优化)
  246. def update_scatter():
  247. x0, x1 = ax.get_xlim()
  248. mask = (x_full >= x0) & (x_full <= x1)
  249. n_vis = int(np.count_nonzero(mask))
  250. if n_vis > MAX_VISIBLE_POINTS:
  251. # 等间隔抽稀:取 n_vis 中的 MAX_VISIBLE_POINTS 个
  252. step = (n_vis + MAX_VISIBLE_POINTS - 1) // MAX_VISIBLE_POINTS
  253. idx = np.flatnonzero(mask)[::step]
  254. else:
  255. idx = np.flatnonzero(mask)
  256. scatter.set_data(x_full[idx], y_full[idx])
  257. if line is not None:
  258. line.set_data(x_full[idx], y_full[idx])
  259. def on_scroll(event):
  260. if event.inaxes is not ax or event.xdata is None:
  261. return
  262. zoom_in = event.button == 'up'
  263. factor = 1.0 / 1.5 if zoom_in else 1.5
  264. if event.key in ('control', 'ctrl'):
  265. # Ctrl+滚轮:缩放 Y 轴,以鼠标 Y 位置为锚点(锚点数据点不动)
  266. y0, y1 = ax.get_ylim()
  267. cy = event.ydata
  268. n0 = cy - (cy - y0) * factor
  269. n1 = cy + (y1 - cy) * factor
  270. if n1 - n0 < 1.0:
  271. return
  272. ax.set_ylim(n0, n1)
  273. else:
  274. # 普通滚轮:缩放 X 轴,以鼠标 X 位置为锚点(锚点数据点不动)
  275. x0, x1 = ax.get_xlim()
  276. n0 = event.xdata - (event.xdata - x0) * factor
  277. n1 = event.xdata + (x1 - event.xdata) * factor
  278. if n1 - n0 < 1e-6:
  279. return
  280. ax.set_xlim(n0, n1)
  281. update_scatter()
  282. fig.canvas.draw_idle()
  283. def on_press(event):
  284. if event.inaxes is ax and event.button == 1:
  285. state['press_x'] = event.xdata
  286. state['press_y'] = event.ydata
  287. state['press_xlim'] = ax.get_xlim()
  288. state['press_ylim'] = ax.get_ylim()
  289. def on_motion(event):
  290. if event.inaxes is not ax or event.xdata is None:
  291. return
  292. if state['press_x'] is not None:
  293. # 拖拽平移:X/Y 双向跟随鼠标
  294. x0, x1 = state['press_xlim']
  295. y0, y1 = state['press_ylim']
  296. dx = event.xdata - state['press_x']
  297. dy = event.ydata - state['press_y']
  298. ax.set_xlim(x0 - dx, x1 - dx)
  299. ax.set_ylim(y0 - dy, y1 - dy)
  300. update_scatter()
  301. fig.canvas.draw_idle()
  302. return
  303. # 悬停:找可见范围内距鼠标最近的脉冲点(屏幕像素距离 < 20px 才高亮)
  304. x0, x1 = ax.get_xlim()
  305. mask = (x_full >= x0) & (x_full <= x1)
  306. if not np.any(mask):
  307. if hover_last['visible']:
  308. hl_marker.set_visible(False)
  309. hl_text.set_visible(False)
  310. fig.canvas.draw_idle()
  311. hover_last['visible'] = False
  312. return
  313. px, py = ax.transData.transform(np.column_stack([x_full[mask], y_full[mask]])).T
  314. dist = np.hypot(px - event.x, py - event.y)
  315. k = int(np.argmin(dist))
  316. if dist[k] <= 20.0:
  317. idx = int(np.flatnonzero(mask)[k])
  318. hl_marker.set_data([x_full[idx]], [y_full[idx]])
  319. hl_text.set_text("脉冲 #%d\nt = %.3f ms\nf = %.0f Hz"
  320. % (idx + 1, x_full[idx], y_full[idx]))
  321. hl_text.set_position((x_full[idx] + (x1 - x0) * 0.01,
  322. y_full[idx] + (ax.get_ylim()[1] - ax.get_ylim()[0]) * 0.02))
  323. hl_marker.set_visible(True)
  324. hl_text.set_visible(True)
  325. if hover_last['idx'] != idx or not hover_last['visible']:
  326. fig.canvas.draw_idle()
  327. hover_last['idx'] = idx
  328. hover_last['visible'] = True
  329. else:
  330. if hover_last['visible']:
  331. hl_marker.set_visible(False)
  332. hl_text.set_visible(False)
  333. fig.canvas.draw_idle()
  334. hover_last['visible'] = False
  335. def on_release(event):
  336. state['press_x'] = None
  337. def on_double(event):
  338. if event.dblclick:
  339. ax.set_xlim(0.0, (t[-1] + hi_w[-1]) * 1000 * 1.02)
  340. ax.set_ylim(0, ymax if ymax > 0 else freq.max() * 1.08)
  341. update_scatter()
  342. fig.canvas.draw_idle()
  343. update_scatter()
  344. fig.canvas.mpl_connect('scroll_event', on_scroll)
  345. fig.canvas.mpl_connect('button_press_event', on_press)
  346. fig.canvas.mpl_connect('button_release_event', on_release)
  347. fig.canvas.mpl_connect('motion_notify_event', on_motion)
  348. fig.canvas.mpl_connect('button_press_event', on_double)
  349. print("打开窗口:滚轮缩放X / Ctrl+滚轮缩放Y / 左键双向拖拽平移 / 双击复位")
  350. print("检测到脉冲 %d,频率测量点 %d,活动时长 %.1f ms" % (
  351. meta['detected_pulses'], meta['pulses'],
  352. (t[-1] + hi_w[-1] / 2) * 1000))
  353. plt.show()
  354. def main():
  355. args = [a for a in sys.argv[1:] if not a.startswith('--')]
  356. opts = {a.split('=', 1)[0]: (a.split('=', 1)[1] if '=' in a else True)
  357. for a in sys.argv[1:] if a.startswith('--')}
  358. if len(args) < 1 or '--help' in opts or '-h' in sys.argv[1:]:
  359. print(__doc__)
  360. sys.exit(0)
  361. path = args[0]
  362. fs = float(args[1]) if len(args) > 1 else 6.25e6
  363. thresh = int(opts.get('--thresh', 128))
  364. ymax = float(opts.get('--ymax', 0))
  365. save_path = opts.get('--save', '')
  366. draw_line = '--line' in opts
  367. reverse = '--reverse' in opts
  368. draw_d1 = '--d1' in opts
  369. draw_d2 = '--d2' in opts
  370. derivative_window_ms = float(opts.get('--dwindow-ms', 5.0))
  371. d1_window_ms = float(opts.get('--d1-window-ms', derivative_window_ms))
  372. d2_window_ms = float(opts.get('--d2-window-ms', derivative_window_ms))
  373. if not os.path.isfile(path):
  374. print("错误:文件不存在 - %s" % path)
  375. sys.exit(1)
  376. if '--selftest' in opts:
  377. selftest(path, fs, thresh, reverse)
  378. else:
  379. plot_show(path, fs, thresh, ymax, save_path or None, draw_line,
  380. reverse, draw_d1, draw_d2, d1_window_ms, d2_window_ms)
  381. if __name__ == '__main__':
  382. main()