#!/usr/bin/env python3 """正点原子逻辑分析仪脉冲频率轨迹查看器。""" from __future__ import annotations import bisect import math import queue import threading import tkinter as tk from pathlib import Path from tkinter import filedialog, messagebox, ttk from logic_waveform import ( RATE_UNITS, FrequencyCurve, WaveformChannel, WaveformData, build_frequency_curves, load_waveform, parse_marker_times_ms, ) from parse_logic_csv import CsvParseError CHANNEL_COLORS = ( "#2563eb", "#dc2626", "#16a34a", "#ea580c", "#7c3aed", "#0891b2", "#c026d3", "#4d7c0f", ) EDGE_MODES = { "自动": "auto", "上升沿": "rising", "下降沿": "falling", } class LogicWaveformApp(tk.Tk): """读取逻辑分析仪文件并显示频率随时间的变化。""" def __init__(self) -> None: super().__init__() self.title("正点原子逻辑分析仪脉冲频率轨迹查看器") self.geometry("1180x760") self.minsize(880, 580) self.input_var = tk.StringVar() self.sample_rate_var = tk.StringVar(value="20") self.sample_rate_unit_var = tk.StringVar(value="MHz") self.bin_channel_count_var = tk.StringVar(value="16") self.channel_var = tk.StringVar(value="自动") self.edge_var = tk.StringVar(value="自动") self.marker_var = tk.StringVar(value="100") self.status_var = tk.StringVar(value="请选择正点原子导出的 CSV 或 BIN 文件") self.file_info_var = tk.StringVar(value="尚未读取频率轨迹") self.cursor_var = tk.StringVar(value="光标:-") self.marker_info_var = tk.StringVar(value="关键节点:-") self.result_queue: queue.Queue[tuple[str, object]] = queue.Queue() self.waveform_data: WaveformData | None = None self.visible_channels: list[WaveformChannel] = [] self.frequency_curves: list[FrequencyCurve] = [] self.marker_times: list[float] = [0.1] self.frequency_origin = 0.0 self.view_start = 0.0 self.view_end = 1.0 self.drag_state: tuple[int, float, float] | None = None self.redraw_job: str | None = None self.load_button: ttk.Button self.canvas: tk.Canvas self._build_widgets() def _build_widgets(self) -> None: """创建文件参数区和频率曲线显示区。""" self.columnconfigure(0, weight=1) self.rowconfigure(3, weight=1) header = ttk.Frame(self, padding=(16, 14, 16, 8)) header.grid(row=0, column=0, sticky="ew") header.columnconfigure(0, weight=1) ttk.Label( header, text="脉冲频率轨迹查看器", font=("Microsoft YaHei UI", 15, "bold"), ).grid(row=0, column=0, sticky="w") ttk.Label(header, textvariable=self.status_var).grid( row=0, column=1, sticky="e" ) controls = ttk.Frame(self, padding=(16, 0, 16, 8)) controls.grid(row=1, column=0, sticky="ew") controls.columnconfigure(1, weight=1) ttk.Label(controls, text="波形文件").grid(row=0, column=0, sticky="w") ttk.Entry(controls, textvariable=self.input_var).grid( row=0, column=1, sticky="ew", padx=(8, 8) ) ttk.Button(controls, text="选择文件", command=self._choose_input).grid( row=0, column=2, padx=(0, 8) ) self.load_button = ttk.Button( controls, text="读取并显示", command=self._start_load ) self.load_button.grid(row=0, column=3) options = ttk.Frame(self, padding=(16, 0, 16, 10)) options.grid(row=2, column=0, sticky="ew") ttk.Label(options, text="BIN采样率").grid(row=0, column=0, sticky="w") ttk.Entry(options, textvariable=self.sample_rate_var, width=9).grid( row=0, column=1, padx=(8, 4) ) ttk.Combobox( options, textvariable=self.sample_rate_unit_var, values=("Hz", "kHz", "MHz", "GHz"), state="readonly", width=6, ).grid(row=0, column=2, padx=(0, 18)) ttk.Label(options, text="BIN通道数").grid(row=0, column=3, sticky="w") ttk.Combobox( options, textvariable=self.bin_channel_count_var, values=("8", "16"), state="readonly", width=5, ).grid(row=0, column=4, padx=(8, 18)) ttk.Label(options, text="显示通道").grid(row=0, column=5, sticky="w") ttk.Entry(options, textvariable=self.channel_var, width=14).grid( row=0, column=6, padx=(8, 8) ) ttk.Label(options, text="测量边沿").grid(row=1, column=0, sticky="w", pady=(8, 0)) edge_box = ttk.Combobox( options, textvariable=self.edge_var, values=tuple(EDGE_MODES), state="readonly", width=8, ) edge_box.grid(row=1, column=1, sticky="w", padx=(8, 18), pady=(8, 0)) edge_box.bind("<>", lambda _event: self._apply_plot_settings()) ttk.Label(options, text="关键时间(ms,逗号分隔,最多10个)").grid( row=1, column=3, sticky="w", pady=(8, 0) ) marker_entry = ttk.Entry(options, textvariable=self.marker_var, width=28) marker_entry.grid(row=1, column=4, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 0)) marker_entry.bind("", lambda _event: self._apply_plot_settings()) ttk.Button(options, text="更新曲线", command=self._apply_plot_settings).grid( row=1, column=6, sticky="w", pady=(8, 0) ) viewer = ttk.Frame(self, padding=(16, 0, 16, 10)) viewer.grid(row=3, column=0, sticky="nsew") viewer.columnconfigure(0, weight=1) viewer.rowconfigure(2, weight=1) info_bar = ttk.Frame(viewer) info_bar.grid(row=0, column=0, sticky="ew", pady=(0, 6)) info_bar.columnconfigure(0, weight=1) ttk.Label(info_bar, textvariable=self.file_info_var).grid( row=0, column=0, sticky="w" ) ttk.Label(info_bar, textvariable=self.cursor_var).grid( row=0, column=1, sticky="e" ) toolbar = ttk.Frame(viewer) toolbar.grid(row=1, column=0, sticky="ew", pady=(0, 6)) toolbar.columnconfigure(3, weight=1) ttk.Button(toolbar, text="放大", command=lambda: self._zoom_view(0.6)).grid( row=0, column=0 ) ttk.Button(toolbar, text="缩小", command=lambda: self._zoom_view(1.6)).grid( row=0, column=1, padx=(6, 0) ) ttk.Button(toolbar, text="适应窗口", command=self._fit_view).grid( row=0, column=2, padx=(6, 0) ) ttk.Label( toolbar, textvariable=self.marker_info_var, wraplength=800, ).grid( row=1, column=0, columnspan=4, sticky="w", pady=(6, 0) ) self.canvas = tk.Canvas( viewer, background="#ffffff", highlightthickness=1, highlightbackground="#aeb4bc", cursor="crosshair", ) self.canvas.grid(row=2, column=0, sticky="nsew") self.canvas.bind("", lambda _event: self._schedule_redraw()) self.canvas.bind("", self._on_mouse_wheel) self.canvas.bind("", self._start_drag) self.canvas.bind("", self._drag_view) self.canvas.bind("", self._end_drag) self.canvas.bind("", self._show_cursor) self.canvas.bind("", self._hide_cursor) def _choose_input(self) -> None: """选择 CSV 或 BIN 文件并立即读取。""" path = filedialog.askopenfilename( title="选择正点原子逻辑分析仪波形文件", filetypes=( ("逻辑分析仪文件", "*.csv *.bin"), ("CSV 文件", "*.csv"), ("BIN 文件", "*.bin"), ("所有文件", "*.*"), ), ) if not path: return self.input_var.set(path) self._start_load() def _get_bin_sample_rate(self) -> float: """读取界面中的 BIN 采样率。""" try: value = float(self.sample_rate_var.get().strip()) except ValueError as exc: raise CsvParseError("BIN 采样率必须是数字") from exc if value <= 0: raise CsvParseError("BIN 采样率必须大于 0") return value * RATE_UNITS[self.sample_rate_unit_var.get().lower()] def _start_load(self) -> None: """校验参数并在后台读取波形文件。""" input_name = self.input_var.get().strip() if not input_name: messagebox.showwarning("未选择文件", "请先选择 CSV 或 BIN 文件。") return path = Path(input_name) if not path.is_file(): messagebox.showerror("文件不存在", f"找不到文件:\n{path}") return try: sample_rate = self._get_bin_sample_rate() channel_count = int(self.bin_channel_count_var.get()) parse_marker_times_ms(self.marker_var.get()) except (ValueError, CsvParseError) as exc: messagebox.showerror("参数错误", str(exc)) return self.load_button.configure(state="disabled") self.status_var.set("正在读取并计算频率……") self.file_info_var.set(f"正在读取:{path.name}") worker = threading.Thread( target=self._load_worker, args=(path, sample_rate, channel_count, self.channel_var.get()), daemon=True, ) worker.start() self.after(100, self._check_load_result) def _load_worker( self, path: Path, sample_rate: float, channel_count: int, channel_text: str, ) -> None: """在线程中解析较大的 BIN 文件。""" try: result = load_waveform(path, sample_rate, channel_count, channel_text) self.result_queue.put(("ok", result)) except (OSError, CsvParseError) as exc: self.result_queue.put(("error", str(exc))) def _check_load_result(self) -> None: """接收后台解析结果并生成频率曲线。""" try: result_type, result = self.result_queue.get_nowait() except queue.Empty: self.after(100, self._check_load_result) return self.load_button.configure(state="normal") if result_type == "error": self.status_var.set("读取失败") self.file_info_var.set("波形文件读取失败") messagebox.showerror("读取失败", str(result)) return data, channels = result # type: ignore[misc] self.waveform_data = data self.visible_channels = channels try: self._rebuild_frequency_curves() except CsvParseError as exc: self.status_var.set("频率计算失败") messagebox.showerror("频率计算失败", str(exc)) return self.file_info_var.set(self._build_file_info(data)) self.status_var.set(f"已生成 {len(self.frequency_curves)} 条频率曲线") def _apply_plot_settings(self) -> None: """应用测量边沿和关键时间设置。""" if self.waveform_data is None: return try: self._rebuild_frequency_curves() except CsvParseError as exc: messagebox.showerror("曲线设置错误", str(exc)) def _rebuild_frequency_curves(self) -> None: """按当前边沿设置重新计算频率轨迹。""" edge_mode = EDGE_MODES[self.edge_var.get()] self.marker_times = parse_marker_times_ms(self.marker_var.get()) self.frequency_curves = build_frequency_curves( self.visible_channels, edge_mode ) self.frequency_origin = min(curve.start_time for curve in self.frequency_curves) self.view_start = self.frequency_origin self.view_end = max(curve.end_time for curve in self.frequency_curves) if self.view_end <= self.view_start: self.view_end = self.view_start + 1e-6 self._update_marker_summary() self.cursor_var.set("光标:-") self._schedule_redraw() @staticmethod def _build_file_info(data: WaveformData) -> str: """生成文件采样信息。""" parts = [data.source_type] if data.sample_rate: parts.append(f"采样率 {format_frequency(data.sample_rate)}") if data.sample_count is not None: parts.append(f"采样点 {data.sample_count:,}") if data.source_type == "CSV": parts.append(f"记录行 {data.record_count:,}") return " | ".join(parts) def _update_marker_summary(self) -> None: """在工具栏显示所有关键节点的测量结果。""" if not self.marker_times or not self.frequency_curves: self.marker_info_var.set("关键节点:-") return summaries = [] for marker in self.marker_times: target_time = self.frequency_origin + marker values = [] for curve in self.frequency_curves: if target_time > curve.end_time: values.append(f"{curve.channel_name} 超出范围") continue measured_time, frequency = curve.frequency_at(target_time) measured_ms = (measured_time - self.frequency_origin) * 1_000.0 values.append( f"{curve.channel_name} {format_frequency(frequency)}" f"@{measured_ms:.3f}ms" ) summaries.append(f"{marker * 1000:g}ms:" + " | ".join(values)) self.marker_info_var.set(" ".join(summaries)) def _plot_bounds(self) -> tuple[float, float, float, float]: """返回频率曲线绘图区边界。""" width = max(1, self.canvas.winfo_width()) height = max(1, self.canvas.winfo_height()) return 92.0, 35.0, max(93.0, width - 24.0), max(36.0, height - 58.0) def _schedule_redraw(self) -> None: """合并连续的重绘请求。""" if self.redraw_job is None: self.redraw_job = self.after_idle(self._draw_frequency_chart) def _draw_frequency_chart(self) -> None: """绘制频率随时间变化的曲线。""" self.redraw_job = None self.canvas.delete("all") if not self.frequency_curves: self.canvas.create_text( self.canvas.winfo_width() / 2, self.canvas.winfo_height() / 2, text="请选择包含脉冲的 CSV 或 BIN 文件", fill="#6b7280", font=("Microsoft YaHei UI", 12), ) return left, top, right, bottom = self._plot_bounds() plot_width = right - left plot_height = bottom - top time_span = max(self.view_end - self.view_start, 1e-15) maximum_frequency = nice_frequency_max(self._visible_max_frequency()) # 绘制横向频率网格和纵轴刻度。 for tick in range(9): ratio = tick / 8.0 y = bottom - ratio * plot_height frequency = ratio * maximum_frequency self.canvas.create_line(left, y, right, y, fill="#e2e5e9", width=1) self.canvas.create_text( left - 9, y, anchor="e", text=format_hz_tick(frequency), fill="#4b5563", font=("Consolas", 9), ) # 绘制时间网格,横轴从第一个有效脉冲边沿开始计时。 time_scale, time_unit = choose_time_unit(time_span) for tick in range(11): ratio = tick / 10.0 x = left + ratio * plot_width time_value = self.view_start + ratio * time_span relative_time = (time_value - self.frequency_origin) * time_scale self.canvas.create_line(x, top, x, bottom, fill="#edf0f3", width=1) self.canvas.create_text( x, bottom + 18, text=format_tick(relative_time), fill="#4b5563", font=("Consolas", 9), ) self.canvas.create_text( 20, (top + bottom) / 2, text="脉冲频率 (Hz)", angle=90, fill="#111827", font=("Microsoft YaHei UI", 10), ) self.canvas.create_text( (left + right) / 2, bottom + 42, text=f"脉冲开始后的时间 ({time_unit})", fill="#111827", font=("Microsoft YaHei UI", 10), ) for curve_number, curve in enumerate(self.frequency_curves): color = CHANNEL_COLORS[curve.channel_index % len(CHANNEL_COLORS)] self._draw_curve( curve, left, right, top, bottom, maximum_frequency, color ) self.canvas.create_line( left + curve_number * 190, 16, left + 20 + curve_number * 190, 16, fill=color, width=3, ) self.canvas.create_text( left + 26 + curve_number * 190, 16, anchor="w", text=f"{curve.channel_name}({curve.edge_name})", fill="#1f2937", font=("Microsoft YaHei UI", 9, "bold"), ) self._draw_markers(left, right, top, bottom, maximum_frequency) self.canvas.create_line(left, top, left, bottom, fill="#4b5563", width=1) self.canvas.create_line(left, bottom, right, bottom, fill="#4b5563", width=1) def _visible_max_frequency(self) -> float: """读取当前时间窗口中的最大频率。""" maximum = 0.0 for curve in self.frequency_curves: start = max(0, bisect.bisect_left(curve.times, self.view_start) - 1) end = min(len(curve.times), bisect.bisect_right(curve.times, self.view_end) + 1) if start < end: maximum = max(maximum, max(curve.frequencies[start:end])) return max(maximum, 1.0) def _draw_curve( self, curve: FrequencyCurve, left: float, right: float, top: float, bottom: float, maximum_frequency: float, color: str, ) -> None: """绘制一条频率轨迹。""" start = max(0, bisect.bisect_left(curve.times, self.view_start) - 1) end = min(len(curve.times), bisect.bisect_right(curve.times, self.view_end) + 1) if end - start < 2: return time_span = self.view_end - self.view_start plot_width = right - left plot_height = bottom - top indexes = list(range(start, end)) maximum_points = max(100, int(plot_width * 3)) if len(indexes) > maximum_points: indexes = decimate_curve_indexes( curve.frequencies, start, end, maximum_points ) coordinates: list[float] = [] for index in indexes: x = left + (curve.times[index] - self.view_start) / time_span * plot_width y = bottom - curve.frequencies[index] / maximum_frequency * plot_height coordinates.extend((x, y)) self.canvas.create_line(*coordinates, fill=color, width=2, smooth=False) def _draw_markers( self, left: float, right: float, top: float, bottom: float, maximum_frequency: float, ) -> None: """标出用户设置的关键时间和对应频率。""" time_span = self.view_end - self.view_start plot_height = bottom - top for marker in self.marker_times: target_time = self.frequency_origin + marker if not self.view_start <= target_time <= self.view_end: continue x = left + (target_time - self.view_start) / time_span * (right - left) self.canvas.create_line( x, top, x, bottom, fill="#d97706", width=1, dash=(5, 4) ) self.canvas.create_text( x + 5, top + 4, anchor="nw", text=f"{marker * 1000:g} ms", fill="#92400e", font=("Microsoft YaHei UI", 9, "bold"), ) for curve_number, curve in enumerate(self.frequency_curves): if target_time > curve.end_time: continue measured_time, frequency = curve.frequency_at(target_time) point_x = left + (measured_time - self.view_start) / time_span * (right - left) point_y = bottom - frequency / maximum_frequency * plot_height color = CHANNEL_COLORS[curve.channel_index % len(CHANNEL_COLORS)] self.canvas.create_oval( point_x - 4, point_y - 4, point_x + 4, point_y + 4, fill="#ffffff", outline=color, width=2, ) self.canvas.create_text( point_x + 7, point_y - 7 - curve_number * 17, anchor="sw", text=format_frequency(frequency), fill=color, font=("Microsoft YaHei UI", 9, "bold"), ) def _full_time_range(self) -> tuple[float, float]: """返回所有频率曲线的完整时间范围。""" start = self.frequency_origin end = max(curve.end_time for curve in self.frequency_curves) return start, max(end, start + 1e-15) def _fit_view(self) -> None: """恢复频率曲线的完整时间范围。""" if not self.frequency_curves: return self.view_start, self.view_end = self._full_time_range() self._schedule_redraw() def _zoom_view(self, factor: float, anchor_x: float | None = None) -> None: """以鼠标位置或窗口中心为基准缩放时间轴。""" if not self.frequency_curves: return left, _top, right, _bottom = self._plot_bounds() ratio = 0.5 if anchor_x is not None and right > left: ratio = min(1.0, max(0.0, (anchor_x - left) / (right - left))) old_span = self.view_end - self.view_start full_start, full_end = self._full_time_range() full_span = full_end - full_start minimum_span = max(full_span / 1_000_000_000.0, 1e-12) new_span = min(full_span, max(minimum_span, old_span * factor)) anchor_time = self.view_start + old_span * ratio new_start = anchor_time - new_span * ratio self._set_view_range(new_start, new_start + new_span) def _set_view_range(self, start: float, end: float) -> None: """限制时间窗口不能移出频率曲线范围。""" if not self.frequency_curves: return full_start, full_end = self._full_time_range() span = min(end - start, full_end - full_start) if start < full_start: start = full_start if start + span > full_end: start = full_end - span self.view_start = start self.view_end = start + span self._schedule_redraw() def _on_mouse_wheel(self, event: tk.Event) -> None: """使用鼠标滚轮缩放时间轴。""" factor = 0.75 if event.delta > 0 else 1.35 self._zoom_view(factor, float(event.x)) def _start_drag(self, event: tk.Event) -> None: """记录拖动开始位置。""" if self.frequency_curves: self.drag_state = (event.x, self.view_start, self.view_end) def _drag_view(self, event: tk.Event) -> None: """按鼠标水平位移平移时间轴。""" if self.drag_state is None: return start_x, original_start, original_end = self.drag_state left, _top, right, _bottom = self._plot_bounds() if right <= left: return time_shift = -(event.x - start_x) / (right - left) * ( original_end - original_start ) self._set_view_range(original_start + time_shift, original_end + time_shift) def _end_drag(self, _event: tk.Event) -> None: """结束鼠标拖动。""" self.drag_state = None def _show_cursor(self, event: tk.Event) -> None: """显示鼠标时间位置和各通道的实测频率。""" if not self.frequency_curves: return left, top, right, bottom = self._plot_bounds() if not left <= event.x <= right or not top <= event.y <= bottom: self._hide_cursor(event) return ratio = (event.x - left) / (right - left) time_value = self.view_start + ratio * (self.view_end - self.view_start) relative_time = time_value - self.frequency_origin values = [] for curve in self.frequency_curves: _measured_time, frequency = curve.frequency_at(time_value) values.append(f"{curve.channel_name} {format_frequency(frequency)}") self.canvas.delete("cursor") self.canvas.create_line( event.x, top, event.x, bottom, fill="#374151", dash=(3, 3), tags="cursor", ) self.cursor_var.set( f"光标 {format_duration(relative_time)}:" + " | ".join(values) ) def _hide_cursor(self, _event: tk.Event | None = None) -> None: """清除鼠标光标线。""" self.canvas.delete("cursor") self.cursor_var.set("光标:-") def nice_frequency_max(value: float) -> float: """把纵轴最大频率向上取为 1、2、5、10 的整倍数。""" if value <= 0: return 1.0 exponent = 10 ** math.floor(math.log10(value)) fraction = value / exponent for candidate in (1.0, 2.0, 5.0, 10.0): if fraction <= candidate: return candidate * exponent return 10.0 * exponent def decimate_curve_indexes( frequencies: list[float], start: int, end: int, maximum_points: int ) -> list[int]: """抽取曲线点,同时保留每个时间桶内的最高值和最低值。""" point_count = end - start if point_count <= maximum_points: return list(range(start, end)) bucket_count = max(1, maximum_points // 2) indexes = [start] for bucket in range(bucket_count): bucket_start = start + int(bucket * point_count / bucket_count) bucket_end = start + int((bucket + 1) * point_count / bucket_count) bucket_end = min(end, max(bucket_start + 1, bucket_end)) minimum_index = min( range(bucket_start, bucket_end), key=frequencies.__getitem__ ) maximum_index = max( range(bucket_start, bucket_end), key=frequencies.__getitem__ ) indexes.extend(sorted((minimum_index, maximum_index))) indexes.append(end - 1) return sorted(set(indexes)) def choose_time_unit(span: float) -> tuple[float, str]: """根据当前时间范围选择合适的横轴单位。""" if span >= 1.0: return 1.0, "s" if span >= 0.001: return 1_000.0, "ms" if span >= 0.000001: return 1_000_000.0, "us" return 1_000_000_000.0, "ns" def format_tick(value: float) -> str: """格式化时间轴刻度。""" absolute = abs(value) if absolute >= 1000: return f"{value:.0f}" if absolute >= 10: return f"{value:.2f}" return f"{value:.3f}" def format_hz_tick(frequency: float) -> str: """以 Hz 为单位格式化纵轴刻度。""" if frequency >= 100: return f"{frequency:.0f}" if frequency >= 10: return f"{frequency:.1f}" return f"{frequency:.2f}" def format_frequency(frequency: float) -> str: """使用 Hz、kHz、MHz 或 GHz 显示测量结果。""" if frequency >= 1_000_000_000: return f"{frequency / 1_000_000_000:.4g} GHz" if frequency >= 1_000_000: return f"{frequency / 1_000_000:.4g} MHz" if frequency >= 1_000: return f"{frequency / 1_000:.4g} kHz" return f"{frequency:.4g} Hz" def format_duration(duration: float) -> str: """使用合适单位显示时间长度。""" if duration >= 1.0: return f"{duration:.6g} s" if duration >= 0.001: return f"{duration * 1_000:.6g} ms" if duration >= 0.000001: return f"{duration * 1_000_000:.6g} us" return f"{duration * 1_000_000_000:.6g} ns" def main() -> None: """启动脉冲频率轨迹查看器。""" app = LogicWaveformApp() app.mainloop() if __name__ == "__main__": main()