Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 
 
 
 

766 строки
28 KiB

  1. #!/usr/bin/env python3
  2. """正点原子逻辑分析仪脉冲频率轨迹查看器。"""
  3. from __future__ import annotations
  4. import bisect
  5. import math
  6. import queue
  7. import threading
  8. import tkinter as tk
  9. from pathlib import Path
  10. from tkinter import filedialog, messagebox, ttk
  11. from logic_waveform import (
  12. RATE_UNITS,
  13. FrequencyCurve,
  14. WaveformChannel,
  15. WaveformData,
  16. build_frequency_curves,
  17. load_waveform,
  18. parse_marker_times_ms,
  19. )
  20. from parse_logic_csv import CsvParseError
  21. CHANNEL_COLORS = (
  22. "#2563eb",
  23. "#dc2626",
  24. "#16a34a",
  25. "#ea580c",
  26. "#7c3aed",
  27. "#0891b2",
  28. "#c026d3",
  29. "#4d7c0f",
  30. )
  31. EDGE_MODES = {
  32. "自动": "auto",
  33. "上升沿": "rising",
  34. "下降沿": "falling",
  35. }
  36. class LogicWaveformApp(tk.Tk):
  37. """读取逻辑分析仪文件并显示频率随时间的变化。"""
  38. def __init__(self) -> None:
  39. super().__init__()
  40. self.title("正点原子逻辑分析仪脉冲频率轨迹查看器")
  41. self.geometry("1180x760")
  42. self.minsize(880, 580)
  43. self.input_var = tk.StringVar()
  44. self.sample_rate_var = tk.StringVar(value="20")
  45. self.sample_rate_unit_var = tk.StringVar(value="MHz")
  46. self.bin_channel_count_var = tk.StringVar(value="16")
  47. self.channel_var = tk.StringVar(value="自动")
  48. self.edge_var = tk.StringVar(value="自动")
  49. self.marker_var = tk.StringVar(value="100")
  50. self.status_var = tk.StringVar(value="请选择正点原子导出的 CSV 或 BIN 文件")
  51. self.file_info_var = tk.StringVar(value="尚未读取频率轨迹")
  52. self.cursor_var = tk.StringVar(value="光标:-")
  53. self.marker_info_var = tk.StringVar(value="关键节点:-")
  54. self.result_queue: queue.Queue[tuple[str, object]] = queue.Queue()
  55. self.waveform_data: WaveformData | None = None
  56. self.visible_channels: list[WaveformChannel] = []
  57. self.frequency_curves: list[FrequencyCurve] = []
  58. self.marker_times: list[float] = [0.1]
  59. self.frequency_origin = 0.0
  60. self.view_start = 0.0
  61. self.view_end = 1.0
  62. self.drag_state: tuple[int, float, float] | None = None
  63. self.redraw_job: str | None = None
  64. self.load_button: ttk.Button
  65. self.canvas: tk.Canvas
  66. self._build_widgets()
  67. def _build_widgets(self) -> None:
  68. """创建文件参数区和频率曲线显示区。"""
  69. self.columnconfigure(0, weight=1)
  70. self.rowconfigure(3, weight=1)
  71. header = ttk.Frame(self, padding=(16, 14, 16, 8))
  72. header.grid(row=0, column=0, sticky="ew")
  73. header.columnconfigure(0, weight=1)
  74. ttk.Label(
  75. header,
  76. text="脉冲频率轨迹查看器",
  77. font=("Microsoft YaHei UI", 15, "bold"),
  78. ).grid(row=0, column=0, sticky="w")
  79. ttk.Label(header, textvariable=self.status_var).grid(
  80. row=0, column=1, sticky="e"
  81. )
  82. controls = ttk.Frame(self, padding=(16, 0, 16, 8))
  83. controls.grid(row=1, column=0, sticky="ew")
  84. controls.columnconfigure(1, weight=1)
  85. ttk.Label(controls, text="波形文件").grid(row=0, column=0, sticky="w")
  86. ttk.Entry(controls, textvariable=self.input_var).grid(
  87. row=0, column=1, sticky="ew", padx=(8, 8)
  88. )
  89. ttk.Button(controls, text="选择文件", command=self._choose_input).grid(
  90. row=0, column=2, padx=(0, 8)
  91. )
  92. self.load_button = ttk.Button(
  93. controls, text="读取并显示", command=self._start_load
  94. )
  95. self.load_button.grid(row=0, column=3)
  96. options = ttk.Frame(self, padding=(16, 0, 16, 10))
  97. options.grid(row=2, column=0, sticky="ew")
  98. ttk.Label(options, text="BIN采样率").grid(row=0, column=0, sticky="w")
  99. ttk.Entry(options, textvariable=self.sample_rate_var, width=9).grid(
  100. row=0, column=1, padx=(8, 4)
  101. )
  102. ttk.Combobox(
  103. options,
  104. textvariable=self.sample_rate_unit_var,
  105. values=("Hz", "kHz", "MHz", "GHz"),
  106. state="readonly",
  107. width=6,
  108. ).grid(row=0, column=2, padx=(0, 18))
  109. ttk.Label(options, text="BIN通道数").grid(row=0, column=3, sticky="w")
  110. ttk.Combobox(
  111. options,
  112. textvariable=self.bin_channel_count_var,
  113. values=("8", "16"),
  114. state="readonly",
  115. width=5,
  116. ).grid(row=0, column=4, padx=(8, 18))
  117. ttk.Label(options, text="显示通道").grid(row=0, column=5, sticky="w")
  118. ttk.Entry(options, textvariable=self.channel_var, width=14).grid(
  119. row=0, column=6, padx=(8, 8)
  120. )
  121. ttk.Label(options, text="测量边沿").grid(row=1, column=0, sticky="w", pady=(8, 0))
  122. edge_box = ttk.Combobox(
  123. options,
  124. textvariable=self.edge_var,
  125. values=tuple(EDGE_MODES),
  126. state="readonly",
  127. width=8,
  128. )
  129. edge_box.grid(row=1, column=1, sticky="w", padx=(8, 18), pady=(8, 0))
  130. edge_box.bind("<<ComboboxSelected>>", lambda _event: self._apply_plot_settings())
  131. ttk.Label(options, text="关键时间(ms,逗号分隔,最多10个)").grid(
  132. row=1, column=3, sticky="w", pady=(8, 0)
  133. )
  134. marker_entry = ttk.Entry(options, textvariable=self.marker_var, width=28)
  135. marker_entry.grid(row=1, column=4, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 0))
  136. marker_entry.bind("<Return>", lambda _event: self._apply_plot_settings())
  137. ttk.Button(options, text="更新曲线", command=self._apply_plot_settings).grid(
  138. row=1, column=6, sticky="w", pady=(8, 0)
  139. )
  140. viewer = ttk.Frame(self, padding=(16, 0, 16, 10))
  141. viewer.grid(row=3, column=0, sticky="nsew")
  142. viewer.columnconfigure(0, weight=1)
  143. viewer.rowconfigure(2, weight=1)
  144. info_bar = ttk.Frame(viewer)
  145. info_bar.grid(row=0, column=0, sticky="ew", pady=(0, 6))
  146. info_bar.columnconfigure(0, weight=1)
  147. ttk.Label(info_bar, textvariable=self.file_info_var).grid(
  148. row=0, column=0, sticky="w"
  149. )
  150. ttk.Label(info_bar, textvariable=self.cursor_var).grid(
  151. row=0, column=1, sticky="e"
  152. )
  153. toolbar = ttk.Frame(viewer)
  154. toolbar.grid(row=1, column=0, sticky="ew", pady=(0, 6))
  155. toolbar.columnconfigure(3, weight=1)
  156. ttk.Button(toolbar, text="放大", command=lambda: self._zoom_view(0.6)).grid(
  157. row=0, column=0
  158. )
  159. ttk.Button(toolbar, text="缩小", command=lambda: self._zoom_view(1.6)).grid(
  160. row=0, column=1, padx=(6, 0)
  161. )
  162. ttk.Button(toolbar, text="适应窗口", command=self._fit_view).grid(
  163. row=0, column=2, padx=(6, 0)
  164. )
  165. ttk.Label(
  166. toolbar,
  167. textvariable=self.marker_info_var,
  168. wraplength=800,
  169. ).grid(
  170. row=1, column=0, columnspan=4, sticky="w", pady=(6, 0)
  171. )
  172. self.canvas = tk.Canvas(
  173. viewer,
  174. background="#ffffff",
  175. highlightthickness=1,
  176. highlightbackground="#aeb4bc",
  177. cursor="crosshair",
  178. )
  179. self.canvas.grid(row=2, column=0, sticky="nsew")
  180. self.canvas.bind("<Configure>", lambda _event: self._schedule_redraw())
  181. self.canvas.bind("<MouseWheel>", self._on_mouse_wheel)
  182. self.canvas.bind("<ButtonPress-1>", self._start_drag)
  183. self.canvas.bind("<B1-Motion>", self._drag_view)
  184. self.canvas.bind("<ButtonRelease-1>", self._end_drag)
  185. self.canvas.bind("<Motion>", self._show_cursor)
  186. self.canvas.bind("<Leave>", self._hide_cursor)
  187. def _choose_input(self) -> None:
  188. """选择 CSV 或 BIN 文件并立即读取。"""
  189. path = filedialog.askopenfilename(
  190. title="选择正点原子逻辑分析仪波形文件",
  191. filetypes=(
  192. ("逻辑分析仪文件", "*.csv *.bin"),
  193. ("CSV 文件", "*.csv"),
  194. ("BIN 文件", "*.bin"),
  195. ("所有文件", "*.*"),
  196. ),
  197. )
  198. if not path:
  199. return
  200. self.input_var.set(path)
  201. self._start_load()
  202. def _get_bin_sample_rate(self) -> float:
  203. """读取界面中的 BIN 采样率。"""
  204. try:
  205. value = float(self.sample_rate_var.get().strip())
  206. except ValueError as exc:
  207. raise CsvParseError("BIN 采样率必须是数字") from exc
  208. if value <= 0:
  209. raise CsvParseError("BIN 采样率必须大于 0")
  210. return value * RATE_UNITS[self.sample_rate_unit_var.get().lower()]
  211. def _start_load(self) -> None:
  212. """校验参数并在后台读取波形文件。"""
  213. input_name = self.input_var.get().strip()
  214. if not input_name:
  215. messagebox.showwarning("未选择文件", "请先选择 CSV 或 BIN 文件。")
  216. return
  217. path = Path(input_name)
  218. if not path.is_file():
  219. messagebox.showerror("文件不存在", f"找不到文件:\n{path}")
  220. return
  221. try:
  222. sample_rate = self._get_bin_sample_rate()
  223. channel_count = int(self.bin_channel_count_var.get())
  224. parse_marker_times_ms(self.marker_var.get())
  225. except (ValueError, CsvParseError) as exc:
  226. messagebox.showerror("参数错误", str(exc))
  227. return
  228. self.load_button.configure(state="disabled")
  229. self.status_var.set("正在读取并计算频率……")
  230. self.file_info_var.set(f"正在读取:{path.name}")
  231. worker = threading.Thread(
  232. target=self._load_worker,
  233. args=(path, sample_rate, channel_count, self.channel_var.get()),
  234. daemon=True,
  235. )
  236. worker.start()
  237. self.after(100, self._check_load_result)
  238. def _load_worker(
  239. self,
  240. path: Path,
  241. sample_rate: float,
  242. channel_count: int,
  243. channel_text: str,
  244. ) -> None:
  245. """在线程中解析较大的 BIN 文件。"""
  246. try:
  247. result = load_waveform(path, sample_rate, channel_count, channel_text)
  248. self.result_queue.put(("ok", result))
  249. except (OSError, CsvParseError) as exc:
  250. self.result_queue.put(("error", str(exc)))
  251. def _check_load_result(self) -> None:
  252. """接收后台解析结果并生成频率曲线。"""
  253. try:
  254. result_type, result = self.result_queue.get_nowait()
  255. except queue.Empty:
  256. self.after(100, self._check_load_result)
  257. return
  258. self.load_button.configure(state="normal")
  259. if result_type == "error":
  260. self.status_var.set("读取失败")
  261. self.file_info_var.set("波形文件读取失败")
  262. messagebox.showerror("读取失败", str(result))
  263. return
  264. data, channels = result # type: ignore[misc]
  265. self.waveform_data = data
  266. self.visible_channels = channels
  267. try:
  268. self._rebuild_frequency_curves()
  269. except CsvParseError as exc:
  270. self.status_var.set("频率计算失败")
  271. messagebox.showerror("频率计算失败", str(exc))
  272. return
  273. self.file_info_var.set(self._build_file_info(data))
  274. self.status_var.set(f"已生成 {len(self.frequency_curves)} 条频率曲线")
  275. def _apply_plot_settings(self) -> None:
  276. """应用测量边沿和关键时间设置。"""
  277. if self.waveform_data is None:
  278. return
  279. try:
  280. self._rebuild_frequency_curves()
  281. except CsvParseError as exc:
  282. messagebox.showerror("曲线设置错误", str(exc))
  283. def _rebuild_frequency_curves(self) -> None:
  284. """按当前边沿设置重新计算频率轨迹。"""
  285. edge_mode = EDGE_MODES[self.edge_var.get()]
  286. self.marker_times = parse_marker_times_ms(self.marker_var.get())
  287. self.frequency_curves = build_frequency_curves(
  288. self.visible_channels, edge_mode
  289. )
  290. self.frequency_origin = min(curve.start_time for curve in self.frequency_curves)
  291. self.view_start = self.frequency_origin
  292. self.view_end = max(curve.end_time for curve in self.frequency_curves)
  293. if self.view_end <= self.view_start:
  294. self.view_end = self.view_start + 1e-6
  295. self._update_marker_summary()
  296. self.cursor_var.set("光标:-")
  297. self._schedule_redraw()
  298. @staticmethod
  299. def _build_file_info(data: WaveformData) -> str:
  300. """生成文件采样信息。"""
  301. parts = [data.source_type]
  302. if data.sample_rate:
  303. parts.append(f"采样率 {format_frequency(data.sample_rate)}")
  304. if data.sample_count is not None:
  305. parts.append(f"采样点 {data.sample_count:,}")
  306. if data.source_type == "CSV":
  307. parts.append(f"记录行 {data.record_count:,}")
  308. return " | ".join(parts)
  309. def _update_marker_summary(self) -> None:
  310. """在工具栏显示所有关键节点的测量结果。"""
  311. if not self.marker_times or not self.frequency_curves:
  312. self.marker_info_var.set("关键节点:-")
  313. return
  314. summaries = []
  315. for marker in self.marker_times:
  316. target_time = self.frequency_origin + marker
  317. values = []
  318. for curve in self.frequency_curves:
  319. if target_time > curve.end_time:
  320. values.append(f"{curve.channel_name} 超出范围")
  321. continue
  322. measured_time, frequency = curve.frequency_at(target_time)
  323. measured_ms = (measured_time - self.frequency_origin) * 1_000.0
  324. values.append(
  325. f"{curve.channel_name} {format_frequency(frequency)}"
  326. f"@{measured_ms:.3f}ms"
  327. )
  328. summaries.append(f"{marker * 1000:g}ms:" + " | ".join(values))
  329. self.marker_info_var.set(" ".join(summaries))
  330. def _plot_bounds(self) -> tuple[float, float, float, float]:
  331. """返回频率曲线绘图区边界。"""
  332. width = max(1, self.canvas.winfo_width())
  333. height = max(1, self.canvas.winfo_height())
  334. return 92.0, 35.0, max(93.0, width - 24.0), max(36.0, height - 58.0)
  335. def _schedule_redraw(self) -> None:
  336. """合并连续的重绘请求。"""
  337. if self.redraw_job is None:
  338. self.redraw_job = self.after_idle(self._draw_frequency_chart)
  339. def _draw_frequency_chart(self) -> None:
  340. """绘制频率随时间变化的曲线。"""
  341. self.redraw_job = None
  342. self.canvas.delete("all")
  343. if not self.frequency_curves:
  344. self.canvas.create_text(
  345. self.canvas.winfo_width() / 2,
  346. self.canvas.winfo_height() / 2,
  347. text="请选择包含脉冲的 CSV 或 BIN 文件",
  348. fill="#6b7280",
  349. font=("Microsoft YaHei UI", 12),
  350. )
  351. return
  352. left, top, right, bottom = self._plot_bounds()
  353. plot_width = right - left
  354. plot_height = bottom - top
  355. time_span = max(self.view_end - self.view_start, 1e-15)
  356. maximum_frequency = nice_frequency_max(self._visible_max_frequency())
  357. # 绘制横向频率网格和纵轴刻度。
  358. for tick in range(9):
  359. ratio = tick / 8.0
  360. y = bottom - ratio * plot_height
  361. frequency = ratio * maximum_frequency
  362. self.canvas.create_line(left, y, right, y, fill="#e2e5e9", width=1)
  363. self.canvas.create_text(
  364. left - 9,
  365. y,
  366. anchor="e",
  367. text=format_hz_tick(frequency),
  368. fill="#4b5563",
  369. font=("Consolas", 9),
  370. )
  371. # 绘制时间网格,横轴从第一个有效脉冲边沿开始计时。
  372. time_scale, time_unit = choose_time_unit(time_span)
  373. for tick in range(11):
  374. ratio = tick / 10.0
  375. x = left + ratio * plot_width
  376. time_value = self.view_start + ratio * time_span
  377. relative_time = (time_value - self.frequency_origin) * time_scale
  378. self.canvas.create_line(x, top, x, bottom, fill="#edf0f3", width=1)
  379. self.canvas.create_text(
  380. x,
  381. bottom + 18,
  382. text=format_tick(relative_time),
  383. fill="#4b5563",
  384. font=("Consolas", 9),
  385. )
  386. self.canvas.create_text(
  387. 20,
  388. (top + bottom) / 2,
  389. text="脉冲频率 (Hz)",
  390. angle=90,
  391. fill="#111827",
  392. font=("Microsoft YaHei UI", 10),
  393. )
  394. self.canvas.create_text(
  395. (left + right) / 2,
  396. bottom + 42,
  397. text=f"脉冲开始后的时间 ({time_unit})",
  398. fill="#111827",
  399. font=("Microsoft YaHei UI", 10),
  400. )
  401. for curve_number, curve in enumerate(self.frequency_curves):
  402. color = CHANNEL_COLORS[curve.channel_index % len(CHANNEL_COLORS)]
  403. self._draw_curve(
  404. curve, left, right, top, bottom, maximum_frequency, color
  405. )
  406. self.canvas.create_line(
  407. left + curve_number * 190,
  408. 16,
  409. left + 20 + curve_number * 190,
  410. 16,
  411. fill=color,
  412. width=3,
  413. )
  414. self.canvas.create_text(
  415. left + 26 + curve_number * 190,
  416. 16,
  417. anchor="w",
  418. text=f"{curve.channel_name}({curve.edge_name})",
  419. fill="#1f2937",
  420. font=("Microsoft YaHei UI", 9, "bold"),
  421. )
  422. self._draw_markers(left, right, top, bottom, maximum_frequency)
  423. self.canvas.create_line(left, top, left, bottom, fill="#4b5563", width=1)
  424. self.canvas.create_line(left, bottom, right, bottom, fill="#4b5563", width=1)
  425. def _visible_max_frequency(self) -> float:
  426. """读取当前时间窗口中的最大频率。"""
  427. maximum = 0.0
  428. for curve in self.frequency_curves:
  429. start = max(0, bisect.bisect_left(curve.times, self.view_start) - 1)
  430. end = min(len(curve.times), bisect.bisect_right(curve.times, self.view_end) + 1)
  431. if start < end:
  432. maximum = max(maximum, max(curve.frequencies[start:end]))
  433. return max(maximum, 1.0)
  434. def _draw_curve(
  435. self,
  436. curve: FrequencyCurve,
  437. left: float,
  438. right: float,
  439. top: float,
  440. bottom: float,
  441. maximum_frequency: float,
  442. color: str,
  443. ) -> None:
  444. """绘制一条频率轨迹。"""
  445. start = max(0, bisect.bisect_left(curve.times, self.view_start) - 1)
  446. end = min(len(curve.times), bisect.bisect_right(curve.times, self.view_end) + 1)
  447. if end - start < 2:
  448. return
  449. time_span = self.view_end - self.view_start
  450. plot_width = right - left
  451. plot_height = bottom - top
  452. indexes = list(range(start, end))
  453. maximum_points = max(100, int(plot_width * 3))
  454. if len(indexes) > maximum_points:
  455. indexes = decimate_curve_indexes(
  456. curve.frequencies, start, end, maximum_points
  457. )
  458. coordinates: list[float] = []
  459. for index in indexes:
  460. x = left + (curve.times[index] - self.view_start) / time_span * plot_width
  461. y = bottom - curve.frequencies[index] / maximum_frequency * plot_height
  462. coordinates.extend((x, y))
  463. self.canvas.create_line(*coordinates, fill=color, width=2, smooth=False)
  464. def _draw_markers(
  465. self,
  466. left: float,
  467. right: float,
  468. top: float,
  469. bottom: float,
  470. maximum_frequency: float,
  471. ) -> None:
  472. """标出用户设置的关键时间和对应频率。"""
  473. time_span = self.view_end - self.view_start
  474. plot_height = bottom - top
  475. for marker in self.marker_times:
  476. target_time = self.frequency_origin + marker
  477. if not self.view_start <= target_time <= self.view_end:
  478. continue
  479. x = left + (target_time - self.view_start) / time_span * (right - left)
  480. self.canvas.create_line(
  481. x, top, x, bottom, fill="#d97706", width=1, dash=(5, 4)
  482. )
  483. self.canvas.create_text(
  484. x + 5,
  485. top + 4,
  486. anchor="nw",
  487. text=f"{marker * 1000:g} ms",
  488. fill="#92400e",
  489. font=("Microsoft YaHei UI", 9, "bold"),
  490. )
  491. for curve_number, curve in enumerate(self.frequency_curves):
  492. if target_time > curve.end_time:
  493. continue
  494. measured_time, frequency = curve.frequency_at(target_time)
  495. point_x = left + (measured_time - self.view_start) / time_span * (right - left)
  496. point_y = bottom - frequency / maximum_frequency * plot_height
  497. color = CHANNEL_COLORS[curve.channel_index % len(CHANNEL_COLORS)]
  498. self.canvas.create_oval(
  499. point_x - 4,
  500. point_y - 4,
  501. point_x + 4,
  502. point_y + 4,
  503. fill="#ffffff",
  504. outline=color,
  505. width=2,
  506. )
  507. self.canvas.create_text(
  508. point_x + 7,
  509. point_y - 7 - curve_number * 17,
  510. anchor="sw",
  511. text=format_frequency(frequency),
  512. fill=color,
  513. font=("Microsoft YaHei UI", 9, "bold"),
  514. )
  515. def _full_time_range(self) -> tuple[float, float]:
  516. """返回所有频率曲线的完整时间范围。"""
  517. start = self.frequency_origin
  518. end = max(curve.end_time for curve in self.frequency_curves)
  519. return start, max(end, start + 1e-15)
  520. def _fit_view(self) -> None:
  521. """恢复频率曲线的完整时间范围。"""
  522. if not self.frequency_curves:
  523. return
  524. self.view_start, self.view_end = self._full_time_range()
  525. self._schedule_redraw()
  526. def _zoom_view(self, factor: float, anchor_x: float | None = None) -> None:
  527. """以鼠标位置或窗口中心为基准缩放时间轴。"""
  528. if not self.frequency_curves:
  529. return
  530. left, _top, right, _bottom = self._plot_bounds()
  531. ratio = 0.5
  532. if anchor_x is not None and right > left:
  533. ratio = min(1.0, max(0.0, (anchor_x - left) / (right - left)))
  534. old_span = self.view_end - self.view_start
  535. full_start, full_end = self._full_time_range()
  536. full_span = full_end - full_start
  537. minimum_span = max(full_span / 1_000_000_000.0, 1e-12)
  538. new_span = min(full_span, max(minimum_span, old_span * factor))
  539. anchor_time = self.view_start + old_span * ratio
  540. new_start = anchor_time - new_span * ratio
  541. self._set_view_range(new_start, new_start + new_span)
  542. def _set_view_range(self, start: float, end: float) -> None:
  543. """限制时间窗口不能移出频率曲线范围。"""
  544. if not self.frequency_curves:
  545. return
  546. full_start, full_end = self._full_time_range()
  547. span = min(end - start, full_end - full_start)
  548. if start < full_start:
  549. start = full_start
  550. if start + span > full_end:
  551. start = full_end - span
  552. self.view_start = start
  553. self.view_end = start + span
  554. self._schedule_redraw()
  555. def _on_mouse_wheel(self, event: tk.Event) -> None:
  556. """使用鼠标滚轮缩放时间轴。"""
  557. factor = 0.75 if event.delta > 0 else 1.35
  558. self._zoom_view(factor, float(event.x))
  559. def _start_drag(self, event: tk.Event) -> None:
  560. """记录拖动开始位置。"""
  561. if self.frequency_curves:
  562. self.drag_state = (event.x, self.view_start, self.view_end)
  563. def _drag_view(self, event: tk.Event) -> None:
  564. """按鼠标水平位移平移时间轴。"""
  565. if self.drag_state is None:
  566. return
  567. start_x, original_start, original_end = self.drag_state
  568. left, _top, right, _bottom = self._plot_bounds()
  569. if right <= left:
  570. return
  571. time_shift = -(event.x - start_x) / (right - left) * (
  572. original_end - original_start
  573. )
  574. self._set_view_range(original_start + time_shift, original_end + time_shift)
  575. def _end_drag(self, _event: tk.Event) -> None:
  576. """结束鼠标拖动。"""
  577. self.drag_state = None
  578. def _show_cursor(self, event: tk.Event) -> None:
  579. """显示鼠标时间位置和各通道的实测频率。"""
  580. if not self.frequency_curves:
  581. return
  582. left, top, right, bottom = self._plot_bounds()
  583. if not left <= event.x <= right or not top <= event.y <= bottom:
  584. self._hide_cursor(event)
  585. return
  586. ratio = (event.x - left) / (right - left)
  587. time_value = self.view_start + ratio * (self.view_end - self.view_start)
  588. relative_time = time_value - self.frequency_origin
  589. values = []
  590. for curve in self.frequency_curves:
  591. _measured_time, frequency = curve.frequency_at(time_value)
  592. values.append(f"{curve.channel_name} {format_frequency(frequency)}")
  593. self.canvas.delete("cursor")
  594. self.canvas.create_line(
  595. event.x,
  596. top,
  597. event.x,
  598. bottom,
  599. fill="#374151",
  600. dash=(3, 3),
  601. tags="cursor",
  602. )
  603. self.cursor_var.set(
  604. f"光标 {format_duration(relative_time)}:" + " | ".join(values)
  605. )
  606. def _hide_cursor(self, _event: tk.Event | None = None) -> None:
  607. """清除鼠标光标线。"""
  608. self.canvas.delete("cursor")
  609. self.cursor_var.set("光标:-")
  610. def nice_frequency_max(value: float) -> float:
  611. """把纵轴最大频率向上取为 1、2、5、10 的整倍数。"""
  612. if value <= 0:
  613. return 1.0
  614. exponent = 10 ** math.floor(math.log10(value))
  615. fraction = value / exponent
  616. for candidate in (1.0, 2.0, 5.0, 10.0):
  617. if fraction <= candidate:
  618. return candidate * exponent
  619. return 10.0 * exponent
  620. def decimate_curve_indexes(
  621. frequencies: list[float], start: int, end: int, maximum_points: int
  622. ) -> list[int]:
  623. """抽取曲线点,同时保留每个时间桶内的最高值和最低值。"""
  624. point_count = end - start
  625. if point_count <= maximum_points:
  626. return list(range(start, end))
  627. bucket_count = max(1, maximum_points // 2)
  628. indexes = [start]
  629. for bucket in range(bucket_count):
  630. bucket_start = start + int(bucket * point_count / bucket_count)
  631. bucket_end = start + int((bucket + 1) * point_count / bucket_count)
  632. bucket_end = min(end, max(bucket_start + 1, bucket_end))
  633. minimum_index = min(
  634. range(bucket_start, bucket_end), key=frequencies.__getitem__
  635. )
  636. maximum_index = max(
  637. range(bucket_start, bucket_end), key=frequencies.__getitem__
  638. )
  639. indexes.extend(sorted((minimum_index, maximum_index)))
  640. indexes.append(end - 1)
  641. return sorted(set(indexes))
  642. def choose_time_unit(span: float) -> tuple[float, str]:
  643. """根据当前时间范围选择合适的横轴单位。"""
  644. if span >= 1.0:
  645. return 1.0, "s"
  646. if span >= 0.001:
  647. return 1_000.0, "ms"
  648. if span >= 0.000001:
  649. return 1_000_000.0, "us"
  650. return 1_000_000_000.0, "ns"
  651. def format_tick(value: float) -> str:
  652. """格式化时间轴刻度。"""
  653. absolute = abs(value)
  654. if absolute >= 1000:
  655. return f"{value:.0f}"
  656. if absolute >= 10:
  657. return f"{value:.2f}"
  658. return f"{value:.3f}"
  659. def format_hz_tick(frequency: float) -> str:
  660. """以 Hz 为单位格式化纵轴刻度。"""
  661. if frequency >= 100:
  662. return f"{frequency:.0f}"
  663. if frequency >= 10:
  664. return f"{frequency:.1f}"
  665. return f"{frequency:.2f}"
  666. def format_frequency(frequency: float) -> str:
  667. """使用 Hz、kHz、MHz 或 GHz 显示测量结果。"""
  668. if frequency >= 1_000_000_000:
  669. return f"{frequency / 1_000_000_000:.4g} GHz"
  670. if frequency >= 1_000_000:
  671. return f"{frequency / 1_000_000:.4g} MHz"
  672. if frequency >= 1_000:
  673. return f"{frequency / 1_000:.4g} kHz"
  674. return f"{frequency:.4g} Hz"
  675. def format_duration(duration: float) -> str:
  676. """使用合适单位显示时间长度。"""
  677. if duration >= 1.0:
  678. return f"{duration:.6g} s"
  679. if duration >= 0.001:
  680. return f"{duration * 1_000:.6g} ms"
  681. if duration >= 0.000001:
  682. return f"{duration * 1_000_000:.6g} us"
  683. return f"{duration * 1_000_000_000:.6g} ns"
  684. def main() -> None:
  685. """启动脉冲频率轨迹查看器。"""
  686. app = LogicWaveformApp()
  687. app.mainloop()
  688. if __name__ == "__main__":
  689. main()