|
- #!/usr/bin/env python3
- """读取正点原子逻辑分析仪导出的 CSV 和 BIN 波形数据。"""
-
- from __future__ import annotations
-
- import array
- import bisect
- import csv
- import io
- import re
- import sys
- from dataclasses import dataclass
- from pathlib import Path
- from typing import Iterable
-
- from parse_logic_csv import CsvParseError, detect_delimiter, read_text, remove_comment_lines
-
-
- RATE_UNITS = {
- "hz": 1.0,
- "khz": 1_000.0,
- "mhz": 1_000_000.0,
- "ghz": 1_000_000_000.0,
- }
- MAX_BIN_TRANSITIONS = 2_000_000
- MAX_MARKER_COUNT = 10
-
-
- @dataclass
- class WaveformChannel:
- """保存一个数字通道的初始电平和跳变点。"""
-
- index: int
- name: str
- times: list[float]
- levels: list[int]
-
- @property
- def transition_count(self) -> int:
- """返回真实跳变次数,不包含初始点。"""
- return max(0, len(self.times) - 1)
-
- def average_frequency(self) -> float | None:
- """使用相邻上升沿估算平均频率。"""
- rising_times = [
- time_value
- for time_value, level in zip(self.times[1:], self.levels[1:])
- if level == 1
- ]
- if len(rising_times) < 2:
- return None
- duration = rising_times[-1] - rising_times[0]
- if duration <= 0:
- return None
- return (len(rising_times) - 1) / duration
-
-
- @dataclass
- class WaveformData:
- """保存一个波形文件的公共信息。"""
-
- path: Path
- source_type: str
- sample_rate: float | None
- sample_count: int | None
- record_count: int
- start_time: float
- end_time: float
- channels: list[WaveformChannel]
-
- @property
- def duration(self) -> float:
- """返回当前文件的可见时间长度。"""
- return max(0.0, self.end_time - self.start_time)
-
-
- @dataclass
- class FrequencyCurve:
- """保存由同方向脉冲边沿计算出的频率曲线。"""
-
- channel_index: int
- channel_name: str
- edge_name: str
- times: list[float]
- frequencies: list[float]
-
- @property
- def start_time(self) -> float:
- """返回曲线第一个有效脉冲边沿时间。"""
- return self.times[0]
-
- @property
- def end_time(self) -> float:
- """返回曲线最后一个有效脉冲边沿时间。"""
- return self.times[-1]
-
- def frequency_at(self, time_value: float) -> tuple[float, float]:
- """返回指定时间之后最近一个完整周期的时间和频率。"""
- index = bisect.bisect_left(self.times, time_value)
- if index >= len(self.times):
- index = len(self.times) - 1
- return self.times[index], self.frequencies[index]
-
-
- def build_frequency_curve(
- channel: WaveformChannel, edge_mode: str = "auto"
- ) -> FrequencyCurve:
- """使用相邻同方向边沿的周期计算瞬时脉冲频率。"""
- if edge_mode not in ("auto", "rising", "falling"):
- raise CsvParseError(f"不支持的测量边沿:{edge_mode}")
- if len(channel.times) < 3:
- raise CsvParseError(f"{channel.name} 的脉冲边沿不足,无法计算频率")
-
- # times[0] 只是文件中的初始电平状态,不能当成真实边沿。
- transitions = list(zip(channel.times[1:], channel.levels[1:]))
- if edge_mode == "auto":
- selected_level = transitions[0][1]
- else:
- selected_level = 1 if edge_mode == "rising" else 0
- edge_name = "上升沿" if selected_level == 1 else "下降沿"
- edge_times = [time_value for time_value, level in transitions if level == selected_level]
-
- # 自动边沿不足时再尝试另一种边沿,避免文件从脉冲中间开始造成误判。
- if len(edge_times) < 2 and edge_mode == "auto":
- selected_level = 1 - selected_level
- edge_name = "上升沿" if selected_level == 1 else "下降沿"
- edge_times = [
- time_value for time_value, level in transitions if level == selected_level
- ]
- if len(edge_times) < 2:
- raise CsvParseError(f"{channel.name} 的{edge_name}不足,无法计算频率")
-
- frequencies: list[float] = []
- valid_edge_times: list[float] = [edge_times[0]]
- for index in range(1, len(edge_times)):
- period = edge_times[index] - edge_times[index - 1]
- if period <= 0:
- continue
- frequencies.append(1.0 / period)
- valid_edge_times.append(edge_times[index])
- if not frequencies:
- raise CsvParseError(f"{channel.name} 没有有效脉冲周期")
-
- # 起点还没有完整周期,使用第一个完整周期的频率补齐曲线起点。
- curve_frequencies = [frequencies[0], *frequencies]
- return FrequencyCurve(
- channel_index=channel.index,
- channel_name=channel.name,
- edge_name=edge_name,
- times=valid_edge_times,
- frequencies=curve_frequencies,
- )
-
-
- def build_frequency_curves(
- channels: Iterable[WaveformChannel], edge_mode: str = "auto"
- ) -> list[FrequencyCurve]:
- """为所有具有足够脉冲边沿的通道生成频率曲线。"""
- curves: list[FrequencyCurve] = []
- for channel in channels:
- try:
- curves.append(build_frequency_curve(channel, edge_mode))
- except CsvParseError:
- continue
- if not curves:
- raise CsvParseError("所选通道没有足够的脉冲边沿,无法生成频率曲线")
- return curves
-
-
- def parse_marker_times_ms(text: str) -> list[float]:
- """解析最多十个毫秒关键时间,并转换为秒。"""
- if not text.strip():
- return []
- markers: set[float] = set()
- for part in re.split(r"[,,\s]+", text.strip()):
- if not part:
- continue
- try:
- milliseconds = float(part)
- except ValueError as exc:
- raise CsvParseError(f"无法识别关键时间:{part!r}") from exc
- if milliseconds < 0:
- raise CsvParseError("关键时间不能小于 0 ms")
- markers.add(milliseconds / 1_000.0)
- if len(markers) > MAX_MARKER_COUNT:
- raise CsvParseError(f"关键时间最多设置 {MAX_MARKER_COUNT} 个")
- return sorted(markers)
-
-
- def parse_engineering_number(text: str) -> float:
- """解析 20 MHz、500 kHz 等带单位的数值。"""
- match = re.fullmatch(
- r"\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmMgG]?[hH][zZ])?\s*", text
- )
- if not match:
- raise CsvParseError(f"无法识别采样率:{text!r}")
- number = float(match.group(1))
- unit = (match.group(2) or "Hz").lower()
- return number * RATE_UNITS[unit]
-
-
- def parse_scaled_count(text: str) -> int | None:
- """解析 125.351087 M 形式的采样点数量。"""
- match = re.fullmatch(r"\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmMgG])?\s*", text)
- if not match:
- return None
- scales = {"": 1.0, "k": 1_000.0, "m": 1_000_000.0, "g": 1_000_000_000.0}
- return int(float(match.group(1)) * scales[(match.group(2) or "").lower()])
-
-
- def parse_metadata(text: str) -> tuple[float | None, int | None]:
- """读取 CSV 注释中的采样率和采样数量。"""
- sample_rate: float | None = None
- sample_count: int | None = None
- for line in text.splitlines():
- stripped = line.strip()
- if not stripped.startswith(";"):
- continue
- content = stripped[1:].strip()
- key, separator, value = content.partition(":")
- if not separator:
- continue
- key = key.strip().lower()
- value = value.strip()
- if key == "sample rate":
- sample_rate = parse_engineering_number(value)
- elif key == "sample count":
- sample_count = parse_scaled_count(value)
- return sample_rate, sample_count
-
-
- def find_numeric_time_column(header: list[str], rows: list[list[str]]) -> int:
- """优先选择正点原子 CSV 中的 Time(s) 数字时间列。"""
- lowered = [name.strip().lower() for name in header]
- preferred_names = ("time(s)", "time (s)", "time", "timestamp", "t", "时间")
- for name in preferred_names:
- if name in lowered:
- return lowered.index(name)
-
- # 没有标准名称时,选择前几行都能转换成浮点数的列。
- for column in range(len(header)):
- valid = True
- for row in rows[:20]:
- if column >= len(row):
- valid = False
- break
- try:
- float(row[column].strip().lstrip("'"))
- except ValueError:
- valid = False
- break
- if valid:
- return column
- raise CsvParseError("找不到可用的数字时间列")
-
-
- def find_channel_columns(
- header: list[str], rows: list[list[str]], time_index: int
- ) -> list[tuple[int, int, str]]:
- """识别 Channel 0、CH1、D2 等数字通道列。"""
- channels: list[tuple[int, int, str]] = []
- used_indexes: set[int] = set()
- patterns = (
- re.compile(r"^channel\s*[_-]?\s*(\d+)$", re.IGNORECASE),
- re.compile(r"^(?:ch|d)\s*[_-]?\s*(\d+)$", re.IGNORECASE),
- )
-
- for column, name in enumerate(header):
- if column == time_index:
- continue
- channel_index: int | None = None
- for pattern in patterns:
- match = pattern.fullmatch(name.strip())
- if match:
- channel_index = int(match.group(1))
- break
- if channel_index is None or channel_index in used_indexes:
- continue
- channels.append((column, channel_index, name.strip()))
- used_indexes.add(channel_index)
-
- if channels:
- return sorted(channels, key=lambda item: item[1])
-
- # 兼容只有“时间,电平”两列的简单文件。
- for column, name in enumerate(header):
- if column == time_index:
- continue
- valid = True
- for row in rows[:100]:
- if column >= len(row) or row[column].strip() not in ("0", "1"):
- valid = False
- break
- if valid:
- channels.append((column, len(channels), name.strip() or f"Channel {len(channels)}"))
- if not channels:
- raise CsvParseError("CSV 中没有找到高低电平通道")
- return channels
-
-
- def load_csv_waveform(path: Path, encoding: str = "auto") -> WaveformData:
- """读取 CSV 中的全部数字通道并保存跳变点。"""
- text = read_text(path, encoding)
- sample_rate, sample_count = parse_metadata(text)
- clean_text = remove_comment_lines(text)
- if not clean_text:
- raise CsvParseError("CSV 中没有有效数据")
-
- delimiter = detect_delimiter(clean_text)
- rows = list(csv.reader(io.StringIO(clean_text), delimiter=delimiter))
- if len(rows) < 2:
- raise CsvParseError("CSV 缺少表头或采样数据")
-
- header = [name.strip() for name in rows[0]]
- data_rows = [row for row in rows[1:] if row]
- time_index = find_numeric_time_column(header, data_rows)
- channel_columns = find_channel_columns(header, data_rows, time_index)
- channel_map = {
- column: WaveformChannel(index, name, [], [])
- for column, index, name in channel_columns
- }
-
- first_time: float | None = None
- last_time: float | None = None
- previous_levels: dict[int, int] = {}
- valid_rows = 0
- for line_number, row in enumerate(data_rows, start=2):
- needed_column = max([time_index, *channel_map.keys()])
- if len(row) <= needed_column:
- raise CsvParseError(f"CSV 第 {line_number} 行列数不足")
- try:
- time_value = float(row[time_index].strip().lstrip("'"))
- except ValueError as exc:
- raise CsvParseError(f"CSV 第 {line_number} 行时间不是数字") from exc
- if last_time is not None and time_value < last_time:
- raise CsvParseError(f"CSV 第 {line_number} 行时间顺序错误")
-
- if first_time is None:
- first_time = time_value
- last_time = time_value
- valid_rows += 1
- for column, channel in channel_map.items():
- level_text = row[column].strip()
- if level_text not in ("0", "1"):
- raise CsvParseError(
- f"CSV 第 {line_number} 行的 {channel.name} 不是 0 或 1"
- )
- level = int(level_text)
- if column not in previous_levels or previous_levels[column] != level:
- channel.times.append(time_value)
- channel.levels.append(level)
- previous_levels[column] = level
-
- if first_time is None or last_time is None:
- raise CsvParseError("CSV 中没有有效采样点")
- return WaveformData(
- path=path,
- source_type="CSV",
- sample_rate=sample_rate,
- sample_count=sample_count,
- record_count=valid_rows,
- start_time=first_time,
- end_time=last_time,
- channels=list(channel_map.values()),
- )
-
-
- def parse_channel_selection(text: str, channel_count: int) -> list[int] | None:
- """解析自动、4、0,1,4、0-7 等通道选择写法。"""
- stripped = text.strip().lower()
- if not stripped or stripped in ("自动", "auto", "all", "全部"):
- return None
-
- selected: set[int] = set()
- for part in re.split(r"[,,\s]+", stripped):
- if not part:
- continue
- if "-" in part:
- start_text, end_text = part.split("-", 1)
- if not start_text.isdigit() or not end_text.isdigit():
- raise CsvParseError(f"无法识别通道范围:{part}")
- start = int(start_text)
- end = int(end_text)
- if start > end:
- start, end = end, start
- selected.update(range(start, end + 1))
- elif part.isdigit():
- selected.add(int(part))
- else:
- match = re.fullmatch(r"(?:channel|ch|d)\s*(\d+)", part)
- if not match:
- raise CsvParseError(f"无法识别通道:{part}")
- selected.add(int(match.group(1)))
-
- invalid = [index for index in sorted(selected) if not 0 <= index < channel_count]
- if invalid:
- raise CsvParseError(f"通道超出 0~{channel_count - 1} 范围:{invalid}")
- return sorted(selected)
-
-
- def _sample_values(data: bytes, sample_width: int) -> Iterable[int]:
- """把原始字节按每个采样点 1 或 2 字节解释。"""
- if sample_width == 1:
- return data
- values = array.array("H")
- values.frombytes(data)
- if sys.byteorder != "little":
- values.byteswap()
- return values
-
-
- def load_bin_waveform(
- path: Path,
- sample_rate: float,
- channel_count: int,
- selected_indexes: list[int] | None = None,
- ) -> WaveformData:
- """按无文件头的原始采样格式读取 BIN。"""
- if sample_rate <= 0:
- raise CsvParseError("BIN 采样率必须大于 0")
- if not 1 <= channel_count <= 16:
- raise CsvParseError("BIN 通道数只支持 1~16")
- sample_width = 1 if channel_count <= 8 else 2
- data = path.read_bytes()
- if not data:
- raise CsvParseError("BIN 文件为空")
- if len(data) % sample_width != 0:
- raise CsvParseError(f"BIN 文件长度不能按 {sample_width} 字节采样点整除")
-
- values = _sample_values(data, sample_width)
- del data
- sample_count = len(values) # type: ignore[arg-type]
- selected = selected_indexes or list(range(channel_count))
- selected_mask = sum(1 << index for index in selected)
- first_value = values[0] # type: ignore[index]
- channel_map = {
- index: WaveformChannel(
- index=index,
- name=f"Channel {index}",
- times=[0.0],
- levels=[(first_value >> index) & 1],
- )
- for index in selected
- }
-
- previous_value = first_value
- transition_total = 0
- for sample_index in range(1, sample_count):
- value = values[sample_index] # type: ignore[index]
- changed = (value ^ previous_value) & selected_mask
- while changed:
- lowest_bit = changed & -changed
- channel_index = lowest_bit.bit_length() - 1
- channel = channel_map[channel_index]
- channel.times.append(sample_index / sample_rate)
- channel.levels.append((value >> channel_index) & 1)
- transition_total += 1
- if transition_total > MAX_BIN_TRANSITIONS:
- raise CsvParseError(
- "BIN 跳变点过多,请在“显示通道”中只填写需要查看的通道"
- )
- changed ^= lowest_bit
- previous_value = value
-
- end_time = 0.0 if sample_count <= 1 else (sample_count - 1) / sample_rate
- return WaveformData(
- path=path,
- source_type="BIN",
- sample_rate=sample_rate,
- sample_count=sample_count,
- record_count=sample_count,
- start_time=0.0,
- end_time=end_time,
- channels=list(channel_map.values()),
- )
-
-
- def load_waveform(
- path: Path,
- bin_sample_rate: float,
- bin_channel_count: int,
- channel_text: str,
- ) -> tuple[WaveformData, list[WaveformChannel]]:
- """根据扩展名读取文件,并返回需要显示的通道。"""
- suffix = path.suffix.lower()
- if suffix == ".csv":
- data = load_csv_waveform(path)
- selected_indexes = parse_channel_selection(channel_text, len(data.channels))
- if selected_indexes is None:
- visible = [channel for channel in data.channels if channel.transition_count > 0]
- if not visible:
- visible = data.channels[:8]
- else:
- selected_set = set(selected_indexes)
- visible = [channel for channel in data.channels if channel.index in selected_set]
- elif suffix == ".bin":
- selected_indexes = parse_channel_selection(channel_text, bin_channel_count)
- data = load_bin_waveform(
- path,
- sample_rate=bin_sample_rate,
- channel_count=bin_channel_count,
- selected_indexes=selected_indexes,
- )
- if selected_indexes is None:
- visible = [channel for channel in data.channels if channel.transition_count > 0]
- if not visible:
- visible = data.channels[:8]
- else:
- visible = data.channels
- else:
- raise CsvParseError("只支持正点原子导出的 CSV 或 BIN 文件")
-
- if not visible:
- raise CsvParseError("所选通道没有可显示的数据")
- return data, visible
|