diff --git a/HostComputer/__pycache__/bin_to_time_freq.cpython-314.pyc b/HostComputer/__pycache__/bin_to_time_freq.cpython-314.pyc index d31ac38..5189aee 100644 Binary files a/HostComputer/__pycache__/bin_to_time_freq.cpython-314.pyc and b/HostComputer/__pycache__/bin_to_time_freq.cpython-314.pyc differ diff --git a/HostComputer/bin_to_time_freq.py b/HostComputer/bin_to_time_freq.py index 4f26e4f..eecbece 100644 --- a/HostComputer/bin_to_time_freq.py +++ b/HostComputer/bin_to_time_freq.py @@ -4,21 +4,25 @@ 数据格式: 逻辑分析仪导出的单通道数字采样,每字节 1 个采样点; 电平 >= 阈值(默认128) 判为高(脉冲),否则为低。 -频率定义: 每脉冲频率 = 采样率 / 脉冲周期; - 周期 = 本脉冲高电平起点 -> 下一脉冲高电平起点; - 最后一个脉冲无下一脉冲,按高电平宽度 x2 近似(50% 占空比)。 -时间定义: 脉冲时间 = 高电平中点(与低电平/空闲段无关)。 +频率定义: 用连续 N 个脉冲的上升沿间隔估计频率; + 频率点放在 N 周期测量窗口中间; + 频率 = N * 采样率 /(第 N 个后续上升沿 - 当前上升沿); +时间定义: 频率点时间为测量窗口中点;高电平宽度取中心脉冲。 用法: python bin_to_time_freq.py [采样率Hz] [选项] + 未填写采样率时默认使用 3125000 Hz。 示例: - python bin_to_time_freq.py "Document/PLSR_document/波形/10段.bin" 6250000 - python bin_to_time_freq.py xxx.bin 6250000 --ymax=12000 - python bin_to_time_freq.py xxx.bin 6250000 --save=out.png - python bin_to_time_freq.py xxx.bin 6250000 --selftest + python bin_to_time_freq.py "Document/PLSR_document/波形/10段.bin" 3125000 + python bin_to_time_freq.py xxx.bin 3125000 --ymax=12000 + python bin_to_time_freq.py xxx.bin 3125000 --save=out.png + python bin_to_time_freq.py xxx.bin 3125000 --selftest + python bin_to_time_freq.py xxx.bin 3125000 --reverse --cycles=8 选项: --thresh=128 电平阈值(默认128) + --cycles=8 用连续多少个周期估计频率(默认8;1为逐周期) + --reverse 反相电平后再检测脉冲(低电平作为脉冲高电平) --ymax=12000 频率轴上限(默认自适应) --save=路径 保存 PNG 后退出(不弹窗) --selftest 仅打印统计,不画图 @@ -28,7 +32,7 @@ Ctrl+滚轮 缩放 Y 轴(以鼠标 Y 位置为中心) 左键拖拽 双向平移(上下左右跟随鼠标) 双击 复位到全图 - 鼠标悬停 高亮最近的点并显示其脉冲序号、时间与频率 + 鼠标悬停 高亮最近的点并显示其测量点序号、时间与频率 依赖: pip install numpy matplotlib """ @@ -46,13 +50,17 @@ except Exception: pass -def load_time_freq(path, fs, threshold=128): +def load_time_freq(path, fs, threshold=128, cycles=8, reverse=False): """读取 bin,返回 (t_sec, freq_hz, hi_width_sec, meta)。 - t_sec 以第一个脉冲为 0 时刻;hi_width 为每脉冲高电平宽度。""" + t_sec 以第一个脉冲高电平中点为 0 时刻;hi_width 为中心脉冲高电平宽度。""" data = np.fromfile(path, dtype=np.uint8) if data.size == 0: raise ValueError("文件为空: %s" % path) + if not 0 <= threshold <= 255: + raise ValueError("阈值必须在0到255之间") samples = (data >= threshold).astype(np.int8) + if reverse: + samples = 1 - samples # run 级压缩 changes = np.flatnonzero(np.diff(samples) != 0) + 1 @@ -60,50 +68,106 @@ def load_time_freq(path, fs, threshold=128): ends = np.concatenate((changes, [len(samples)])) runs = np.column_stack((starts, ends, samples[starts])) - # 脉冲 = 高电平 run,且其后紧跟低电平 run + # 每个高电平 run 的起点都是一个可用于测周期的上升沿。 + # 若采集从高电平开始,第一个高电平起点不是被观测到的上升沿,丢弃它。 hi = np.flatnonzero(runs[:, 2] == 1) - hi = hi[hi + 1 < len(runs)] - lo_ok = runs[hi + 1, 2] == 0 - hi = hi[lo_ok] - - t_start = runs[hi, 0].astype(np.int64) - hi_width = (runs[hi, 1] - runs[hi, 0]).astype(np.int64) - - # 周期:高到高;末脉冲按 2x 高电平宽度近似 - next_start = np.append(t_start[1:], [t_start[-1] + 2 * hi_width[-1]]) - period = next_start - t_start - freq = fs / np.maximum(period, 1) - - # 时间:高电平中点,去起始偏移 - t_sec = (t_start + hi_width / 2.0) / fs - t_sec = t_sec - t_sec[0] + if hi.size and runs[hi[0], 0] == 0: + hi = hi[1:] + + if hi.size == 0: + raise ValueError("未检测到上升沿脉冲: %s" % path) + + # 保留浮点上升沿位置:在原始电平相邻采样点之间线性插值, + # 再配合多周期测量,显著降低整数采样点带来的量化抖动。 + rise_idx = runs[hi, 0].astype(np.int64) + t_start = rise_idx.astype(np.float64) + valid = rise_idx > 0 + if np.any(valid): + y0 = data[rise_idx[valid] - 1].astype(np.float64) + y1 = data[rise_idx[valid]].astype(np.float64) + delta = y1 - y0 + frac = np.zeros_like(y0) + np.divide(float(threshold) - y0, delta, out=frac, where=delta != 0) + t_start[valid] = rise_idx[valid] - 1.0 + np.clip(frac, 0.0, 1.0) + + hi_width = (runs[hi, 1] - runs[hi, 0]).astype(np.float64) + + if fs <= 0: + raise ValueError("采样率必须大于0") + try: + cycles = int(cycles) + except (TypeError, ValueError): + raise ValueError("cycles 必须是正整数") + if cycles < 1: + raise ValueError("cycles 必须是正整数") + if len(t_start) < 2: + raise ValueError("脉冲不足两个,无法计算频率") + + # 用下降沿的阈值 crossing 计算高电平宽度,时间单位仍为采样点。 + # 文件末尾若停在高电平,频率仍可用该上升沿作为前一窗口终点; + # 该脉冲的宽度只取到文件末尾,不用于猜测频率。 + has_fall = np.zeros(len(hi), dtype=bool) + adjacent = (hi + 1) < len(runs) + has_fall[adjacent] = runs[hi[adjacent] + 1, 2] == 0 + fall_idx = np.where(has_fall, runs[hi, 1], len(data)).astype(np.int64) + fall_edge = fall_idx.astype(np.float64) + valid = has_fall & (fall_idx > 0) & (fall_idx < len(data)) + if np.any(valid): + y0 = data[fall_idx[valid] - 1].astype(np.float64) + y1 = data[fall_idx[valid]].astype(np.float64) + delta = y1 - y0 + frac = np.zeros_like(y0) + np.divide(float(threshold) - y0, delta, out=frac, where=delta != 0) + fall_edge[valid] = fall_idx[valid] - 1.0 + np.clip(frac, 0.0, 1.0) + hi_width = np.maximum(fall_edge - t_start, 0.0) + pulse_mid = t_start + hi_width / 2.0 + + # 单周期只看到整数采样点,周期接近半个采样点时会严重跳变。 + # 跨 cycles 个周期计算总时长,再换算回单周期频率,可显著抑制量化抖动。 + cycles = min(cycles, len(t_start) - 1) + left = np.arange(len(t_start) - cycles, dtype=np.int64) + right = left + cycles + period = t_start[right] - t_start[left] + freq = cycles * float(fs) / np.maximum(period, np.finfo(np.float64).eps) + + # 频率点放在测量窗口的时间中心;宽度取中心附近脉冲,仅用于兼容绘图接口。 + t_center = (t_start[left] + t_start[right]) / 2.0 + center_idx = np.minimum(left + cycles // 2, len(hi_width) - 1) + hi_width_out = hi_width[center_idx] + # 时间原点仍为第一个脉冲的高电平中点;多周期测量点自然位于窗口中心。 + t_sec = (t_center - pulse_mid[0]) / fs meta = { 'total_samples': int(len(samples)), 'duration': len(samples) / fs, 'pulses': int(len(freq)), + 'detected_pulses': int(len(t_start)), + 'cycles': int(cycles), 'offset_ms': t_start[0] / fs * 1000, + 'activity_duration': max(0.0, (fall_edge[-1] - pulse_mid[0]) / fs), } - return t_sec, freq, hi_width / fs, meta + return t_sec, freq, hi_width_out / fs, meta -def selftest(path, fs, thresh): +def selftest(path, fs, thresh, cycles, reverse=False): import statistics - t, freq, hi_w, meta = load_time_freq(path, fs, thresh) + t, freq, hi_w, meta = load_time_freq(path, fs, thresh, cycles, reverse) print("文件: %s" % path) print("总采样: %d (%.3f s @ %.2f MS/s)" % (meta['total_samples'], meta['duration'], fs / 1e6)) print("起始采集偏移: %.1f ms" % meta['offset_ms']) - print("脉冲总数: %d" % meta['pulses']) + print("检测到脉冲: %d,频率测量点: %d" % ( + meta['detected_pulses'], meta['pulses'])) if meta['pulses']: print("频率 min=%.0f max=%.0f 中位=%.0f Hz" % ( freq.min(), freq.max(), statistics.median(freq))) - print("末脉冲: t=%.3f ms, 高电平 %.3f ms, %.0f Hz" % ( + print("末测量点: t=%.3f ms, 高电平 %.3f ms, %.0f Hz" % ( t[-1] * 1000, hi_w[-1] * 1000, freq[-1])) - print("波形活动时长: %.1f ms" % ((t[-1] + hi_w[-1] / 2) * 1000)) + print("估计周期窗口: %d 个周期" % meta['cycles']) + print("波形活动时长: %.1f ms" % (meta['activity_duration'] * 1000)) -def plot_show(path, fs, thresh, ymax, save_path=None): - t, freq, hi_w, meta = load_time_freq(path, fs, thresh) +def plot_show(path, fs, thresh, ymax, cycles, reverse, save_path=None): + t, freq, hi_w, meta = load_time_freq(path, fs, thresh, cycles, reverse) import matplotlib.pyplot as plt from matplotlib.ticker import MaxNLocator @@ -117,11 +181,11 @@ def plot_show(path, fs, thresh, ymax, save_path=None): tx_ms = t * 1000 x_full = tx_ms y_full = freq - ax.set_xlim(0.0, (t[-1] + hi_w[-1]) * 1000 * 1.02) + ax.set_xlim(0.0, meta['activity_duration'] * 1000 * 1.02) ax.set_ylim(0, ymax if ymax > 0 else freq.max() * 1.08) - ax.set_title("%s 时频曲线(%d 脉冲,活动时长 %.0f ms)" % ( - os.path.basename(path), meta['pulses'], - (t[-1] + hi_w[-1] / 2) * 1000)) + ax.set_title("%s 时频曲线(%d 个测量点,%d 周期平均,活动时长 %.0f ms)" % ( + os.path.basename(path), meta['pulses'], meta['cycles'], + meta['activity_duration'] * 1000)) ax.set_xlabel("时间 (ms)") ax.set_ylabel("频率 (Hz)") # 更密的刻度 @@ -224,7 +288,7 @@ def plot_show(path, fs, thresh, ymax, save_path=None): if dist[k] <= 20.0: idx = int(np.flatnonzero(mask)[k]) hl_marker.set_data([x_full[idx]], [y_full[idx]]) - hl_text.set_text("脉冲 #%d\nt = %.3f ms\nf = %.0f Hz" + hl_text.set_text("测量点 #%d\nt = %.3f ms\nf = %.0f Hz" % (idx + 1, x_full[idx], y_full[idx])) hl_text.set_position((x_full[idx] + (x1 - x0) * 0.01, y_full[idx] + (ax.get_ylim()[1] - ax.get_ylim()[0]) * 0.02)) @@ -246,7 +310,7 @@ def plot_show(path, fs, thresh, ymax, save_path=None): def on_double(event): if event.dblclick: - ax.set_xlim(0.0, (t[-1] + hi_w[-1]) * 1000 * 1.02) + ax.set_xlim(0.0, meta['activity_duration'] * 1000 * 1.02) ax.set_ylim(0, ymax if ymax > 0 else freq.max() * 1.08) update_scatter() fig.canvas.draw_idle() @@ -259,8 +323,8 @@ def plot_show(path, fs, thresh, ymax, save_path=None): fig.canvas.mpl_connect('button_press_event', on_double) print("打开窗口:滚轮缩放X / Ctrl+滚轮缩放Y / 左键双向拖拽平移 / 双击复位") - print("脉冲 %d 个,活动时长 %.1f ms" % ( - meta['pulses'], (t[-1] + hi_w[-1] / 2) * 1000)) + print("测量点 %d 个,使用 %d 周期平均,活动时长 %.1f ms" % ( + meta['pulses'], meta['cycles'], meta['activity_duration'] * 1000)) plt.show() @@ -269,13 +333,15 @@ def main(): opts = {a.split('=', 1)[0]: (a.split('=', 1)[1] if '=' in a else True) for a in sys.argv[1:] if a.startswith('--')} - if len(args) < 1 or '--help' in opts or '-h' in opts: + if len(args) < 1 or '--help' in opts or '-h' in sys.argv[1:]: print(__doc__) sys.exit(0) path = args[0] - fs = float(args[1]) if len(args) > 1 else 6.25e6 + fs = float(args[1]) if len(args) > 1 else 3.125e6 thresh = int(opts.get('--thresh', 128)) + cycles = int(opts.get('--cycles', 8)) + reverse = '--reverse' in opts ymax = float(opts.get('--ymax', 0)) save_path = opts.get('--save', '') @@ -284,9 +350,9 @@ def main(): sys.exit(1) if '--selftest' in opts: - selftest(path, fs, thresh) + selftest(path, fs, thresh, cycles, reverse) else: - plot_show(path, fs, thresh, ymax, save_path or None) + plot_show(path, fs, thresh, ymax, cycles, reverse, save_path or None) if __name__ == '__main__': diff --git a/HostComputer/bin_to_time_freq1.py b/HostComputer/bin_to_time_freq1.py new file mode 100644 index 0000000..4f26e4f --- /dev/null +++ b/HostComputer/bin_to_time_freq1.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- +""" +.bin 波形文件 -> 时频曲线查看/出图工具(PLSR 项目用) + +数据格式: 逻辑分析仪导出的单通道数字采样,每字节 1 个采样点; + 电平 >= 阈值(默认128) 判为高(脉冲),否则为低。 +频率定义: 每脉冲频率 = 采样率 / 脉冲周期; + 周期 = 本脉冲高电平起点 -> 下一脉冲高电平起点; + 最后一个脉冲无下一脉冲,按高电平宽度 x2 近似(50% 占空比)。 +时间定义: 脉冲时间 = 高电平中点(与低电平/空闲段无关)。 + +用法: + python bin_to_time_freq.py [采样率Hz] [选项] +示例: + python bin_to_time_freq.py "Document/PLSR_document/波形/10段.bin" 6250000 + python bin_to_time_freq.py xxx.bin 6250000 --ymax=12000 + python bin_to_time_freq.py xxx.bin 6250000 --save=out.png + python bin_to_time_freq.py xxx.bin 6250000 --selftest + +选项: + --thresh=128 电平阈值(默认128) + --ymax=12000 频率轴上限(默认自适应) + --save=路径 保存 PNG 后退出(不弹窗) + --selftest 仅打印统计,不画图 + +交互(弹窗模式): + 滚轮 缩放 X 轴(向上放大/向下缩小,以鼠标位置为中心) + Ctrl+滚轮 缩放 Y 轴(以鼠标 Y 位置为中心) + 左键拖拽 双向平移(上下左右跟随鼠标) + 双击 复位到全图 + 鼠标悬停 高亮最近的点并显示其脉冲序号、时间与频率 + +依赖: pip install numpy matplotlib +""" + +import sys +import os + +import numpy as np + +try: + import matplotlib + matplotlib.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei'] + matplotlib.rcParams['axes.unicode_minus'] = False +except Exception: + pass + + +def load_time_freq(path, fs, threshold=128): + """读取 bin,返回 (t_sec, freq_hz, hi_width_sec, meta)。 + t_sec 以第一个脉冲为 0 时刻;hi_width 为每脉冲高电平宽度。""" + data = np.fromfile(path, dtype=np.uint8) + if data.size == 0: + raise ValueError("文件为空: %s" % path) + samples = (data >= threshold).astype(np.int8) + + # run 级压缩 + changes = np.flatnonzero(np.diff(samples) != 0) + 1 + starts = np.concatenate(([0], changes)) + ends = np.concatenate((changes, [len(samples)])) + runs = np.column_stack((starts, ends, samples[starts])) + + # 脉冲 = 高电平 run,且其后紧跟低电平 run + hi = np.flatnonzero(runs[:, 2] == 1) + hi = hi[hi + 1 < len(runs)] + lo_ok = runs[hi + 1, 2] == 0 + hi = hi[lo_ok] + + t_start = runs[hi, 0].astype(np.int64) + hi_width = (runs[hi, 1] - runs[hi, 0]).astype(np.int64) + + # 周期:高到高;末脉冲按 2x 高电平宽度近似 + next_start = np.append(t_start[1:], [t_start[-1] + 2 * hi_width[-1]]) + period = next_start - t_start + freq = fs / np.maximum(period, 1) + + # 时间:高电平中点,去起始偏移 + t_sec = (t_start + hi_width / 2.0) / fs + t_sec = t_sec - t_sec[0] + + meta = { + 'total_samples': int(len(samples)), + 'duration': len(samples) / fs, + 'pulses': int(len(freq)), + 'offset_ms': t_start[0] / fs * 1000, + } + return t_sec, freq, hi_width / fs, meta + + +def selftest(path, fs, thresh): + import statistics + t, freq, hi_w, meta = load_time_freq(path, fs, thresh) + print("文件: %s" % path) + print("总采样: %d (%.3f s @ %.2f MS/s)" % (meta['total_samples'], meta['duration'], fs / 1e6)) + print("起始采集偏移: %.1f ms" % meta['offset_ms']) + print("脉冲总数: %d" % meta['pulses']) + if meta['pulses']: + print("频率 min=%.0f max=%.0f 中位=%.0f Hz" % ( + freq.min(), freq.max(), statistics.median(freq))) + print("末脉冲: t=%.3f ms, 高电平 %.3f ms, %.0f Hz" % ( + t[-1] * 1000, hi_w[-1] * 1000, freq[-1])) + print("波形活动时长: %.1f ms" % ((t[-1] + hi_w[-1] / 2) * 1000)) + + +def plot_show(path, fs, thresh, ymax, save_path=None): + t, freq, hi_w, meta = load_time_freq(path, fs, thresh) + + import matplotlib.pyplot as plt + from matplotlib.ticker import MaxNLocator + + fig, ax = plt.subplots(figsize=(15, 7)) + fig.subplots_adjust(bottom=0.10, top=0.92) + + # 全脉冲散点:复用 artist,重绘时按可见范围抽稀(大文件流畅) + MAX_VISIBLE_POINTS = 5000 # 可见范围内最多绘制的点数,超过则等间隔抽稀 + (scatter,) = ax.plot([], [], '.', ms=1.5, color='C0', alpha=0.5, zorder=1) + tx_ms = t * 1000 + x_full = tx_ms + y_full = freq + ax.set_xlim(0.0, (t[-1] + hi_w[-1]) * 1000 * 1.02) + ax.set_ylim(0, ymax if ymax > 0 else freq.max() * 1.08) + ax.set_title("%s 时频曲线(%d 脉冲,活动时长 %.0f ms)" % ( + os.path.basename(path), meta['pulses'], + (t[-1] + hi_w[-1] / 2) * 1000)) + ax.set_xlabel("时间 (ms)") + ax.set_ylabel("频率 (Hz)") + # 更密的刻度 + ax.xaxis.set_major_locator(MaxNLocator(nbins=20)) + ax.yaxis.set_major_locator(MaxNLocator(nbins=15)) + ax.grid(True, which='major', alpha=0.35) + ax.grid(True, which='minor', alpha=0.15) + ax.minorticks_on() + + if save_path: + fig.savefig(save_path, dpi=130) + print("已保存: %s" % save_path) + plt.close(fig) + return + + # 交互:滚轮缩放 X(Ctrl+滚轮缩放 Y)/ 左键双向拖拽平移 / 双击复位 / 悬停高亮 + state = {'press_x': None, 'press_y': None, + 'press_xlim': None, 'press_ylim': None} + + # 悬停高亮:一个红点标记 + 一个带框文本 + (hl_marker,) = ax.plot([], [], 'o', ms=9, mfc='red', mec='white', + mew=1.0, zorder=5, visible=False) + hl_text = ax.text(0, 0, '', fontsize=10, color='black', + bbox=dict(boxstyle='round,pad=0.3', fc='yellow', ec='red', alpha=0.9), + zorder=6, visible=False) + hover_last = {'idx': -1, 'visible': False} + + # 可见范围内抽稀绘制散点(大文件性能优化) + def update_scatter(): + x0, x1 = ax.get_xlim() + mask = (x_full >= x0) & (x_full <= x1) + n_vis = int(np.count_nonzero(mask)) + if n_vis > MAX_VISIBLE_POINTS: + # 等间隔抽稀:取 n_vis 中的 MAX_VISIBLE_POINTS 个 + step = (n_vis + MAX_VISIBLE_POINTS - 1) // MAX_VISIBLE_POINTS + idx = np.flatnonzero(mask)[::step] + else: + idx = np.flatnonzero(mask) + scatter.set_data(x_full[idx], y_full[idx]) + + def on_scroll(event): + if event.inaxes is not ax or event.xdata is None: + return + zoom_in = event.button == 'up' + factor = 1.0 / 1.5 if zoom_in else 1.5 + if event.key in ('control', 'ctrl'): + # Ctrl+滚轮:缩放 Y 轴,以鼠标 Y 位置为锚点(锚点数据点不动) + y0, y1 = ax.get_ylim() + cy = event.ydata + n0 = cy - (cy - y0) * factor + n1 = cy + (y1 - cy) * factor + if n1 - n0 < 1.0: + return + ax.set_ylim(n0, n1) + else: + # 普通滚轮:缩放 X 轴,以鼠标 X 位置为锚点(锚点数据点不动) + x0, x1 = ax.get_xlim() + n0 = event.xdata - (event.xdata - x0) * factor + n1 = event.xdata + (x1 - event.xdata) * factor + if n1 - n0 < 1e-6: + return + ax.set_xlim(n0, n1) + update_scatter() + fig.canvas.draw_idle() + + def on_press(event): + if event.inaxes is ax and event.button == 1: + state['press_x'] = event.xdata + state['press_y'] = event.ydata + state['press_xlim'] = ax.get_xlim() + state['press_ylim'] = ax.get_ylim() + + def on_motion(event): + if event.inaxes is not ax or event.xdata is None: + return + if state['press_x'] is not None: + # 拖拽平移:X/Y 双向跟随鼠标 + x0, x1 = state['press_xlim'] + y0, y1 = state['press_ylim'] + dx = event.xdata - state['press_x'] + dy = event.ydata - state['press_y'] + ax.set_xlim(x0 - dx, x1 - dx) + ax.set_ylim(y0 - dy, y1 - dy) + update_scatter() + fig.canvas.draw_idle() + return + # 悬停:找可见范围内距鼠标最近的脉冲点(屏幕像素距离 < 20px 才高亮) + x0, x1 = ax.get_xlim() + mask = (x_full >= x0) & (x_full <= x1) + if not np.any(mask): + if hover_last['visible']: + hl_marker.set_visible(False) + hl_text.set_visible(False) + fig.canvas.draw_idle() + hover_last['visible'] = False + return + px, py = ax.transData.transform(np.column_stack([x_full[mask], y_full[mask]])).T + dist = np.hypot(px - event.x, py - event.y) + k = int(np.argmin(dist)) + if dist[k] <= 20.0: + idx = int(np.flatnonzero(mask)[k]) + hl_marker.set_data([x_full[idx]], [y_full[idx]]) + hl_text.set_text("脉冲 #%d\nt = %.3f ms\nf = %.0f Hz" + % (idx + 1, x_full[idx], y_full[idx])) + hl_text.set_position((x_full[idx] + (x1 - x0) * 0.01, + y_full[idx] + (ax.get_ylim()[1] - ax.get_ylim()[0]) * 0.02)) + hl_marker.set_visible(True) + hl_text.set_visible(True) + if hover_last['idx'] != idx or not hover_last['visible']: + fig.canvas.draw_idle() + hover_last['idx'] = idx + hover_last['visible'] = True + else: + if hover_last['visible']: + hl_marker.set_visible(False) + hl_text.set_visible(False) + fig.canvas.draw_idle() + hover_last['visible'] = False + + def on_release(event): + state['press_x'] = None + + def on_double(event): + if event.dblclick: + ax.set_xlim(0.0, (t[-1] + hi_w[-1]) * 1000 * 1.02) + ax.set_ylim(0, ymax if ymax > 0 else freq.max() * 1.08) + update_scatter() + fig.canvas.draw_idle() + + update_scatter() + fig.canvas.mpl_connect('scroll_event', on_scroll) + fig.canvas.mpl_connect('button_press_event', on_press) + fig.canvas.mpl_connect('button_release_event', on_release) + fig.canvas.mpl_connect('motion_notify_event', on_motion) + fig.canvas.mpl_connect('button_press_event', on_double) + + print("打开窗口:滚轮缩放X / Ctrl+滚轮缩放Y / 左键双向拖拽平移 / 双击复位") + print("脉冲 %d 个,活动时长 %.1f ms" % ( + meta['pulses'], (t[-1] + hi_w[-1] / 2) * 1000)) + plt.show() + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith('--')] + opts = {a.split('=', 1)[0]: (a.split('=', 1)[1] if '=' in a else True) + for a in sys.argv[1:] if a.startswith('--')} + + if len(args) < 1 or '--help' in opts or '-h' in opts: + print(__doc__) + sys.exit(0) + + path = args[0] + fs = float(args[1]) if len(args) > 1 else 6.25e6 + thresh = int(opts.get('--thresh', 128)) + ymax = float(opts.get('--ymax', 0)) + save_path = opts.get('--save', '') + + if not os.path.isfile(path): + print("错误:文件不存在 - %s" % path) + sys.exit(1) + + if '--selftest' in opts: + selftest(path, fs, thresh) + else: + plot_show(path, fs, thresh, ymax, save_path or None) + + +if __name__ == '__main__': + main() diff --git a/PLSR/Src/plsr.c b/PLSR/Src/plsr.c index 6307ab1..0d7b0af 100644 --- a/PLSR/Src/plsr.c +++ b/PLSR/Src/plsr.c @@ -124,9 +124,13 @@ typedef struct typedef struct { PLSR_PLATFORM_TIMER_SETTING setting; + //这一档已经量化好的定时器参数,发波直接用 uint32_t requestedFrequencyHz; + //公式算出来的 Hz(量化前),给诊断/对照,硬件不拿它发波 uint32_t repeatCount; + //这一档连续发几个脉冲 uint8_t startsNextSegment; + //1 = 这项是下一段的第一拍(handoff),0 = 还在本段 } PLSR_PROFILE_ENTRY; typedef struct @@ -1995,6 +1999,35 @@ static uint32_t PlsrProfileRunLimit(uint32_t frequencyHz) return (limit > 0xFFFFFFFFULL) ? 0xFFFFFFFFUL : (uint32_t)limit; } +/******************************************************************************* + * 函数名称: PlsrShortProfileTakeRunLimited + * 功能描述: 从短轮廓中切出一项 counted run,写入 entry。 + * 若规划器一次生成过长的同频项,则按上限将其拆成可入队的短 run。 + * 输入参数: profile - 短轮廓对象,含规划上下文、已输出脉冲数、 + * 尚未切完的同频项 + * maximumRepeats - 本项允许的最大脉冲数;QueueFill 还债时传入 + * 当前欠债脉冲数 + * 输出参数: entry - 切出的队列节点 + * setting : 量化后的定时器参数 + * requestedFrequencyHz : 规划请求频率 + * repeatCount : 本 run 连续脉冲数 + * startsNextSegment : 本函数恒写 0 + * 返 回 值: 1 - 切出成功,entry 有效,profile 账本已推进 + * 0 - 失败,entry 无效。失败原因包括: + * 本段脉冲已全部切出、入口指针非法、maximumRepeats 为 0、 + * 规划器无法再生成、或裁切后脉冲数为 0 + * 注意事项: 1. 本项脉冲数取下列三者的最小值: + * 规划器剩余同频脉冲、本段尚未切出的脉冲、maximumRepeats。 + * 2. maximumRepeats 还会被 PlsrProfileRunLimit() 再次压缩, + * 使单次硬件 run 不超过约 2 ms(PLSR_COUNTED_RUN_MAX_TIME_US)。 + * 3. pendingRepeats 未耗尽时不调用规划器,继续切上一档同频项。 + * 4. 还债路径由调用方传入欠债作为 maximumRepeats,并自行决定 + * 不入队;本函数仍会填写 entry 并推进 nextPeriod。 + * 5. 段边界标志 startsNextSegment 不在本函数置位,由 + * counted-handoff 路径单独盖章。 + * 6. 本段切尽时将 profile->active 清 0;generatorComplete + * 仍由 QueueFill 在欠债还清后置位。 + ******************************************************************************/ static uint8_t PlsrShortProfileTakeRunLimited( PLSR_SHORT_PROFILE *profile, PLSR_PROFILE_ENTRY *entry, @@ -2002,30 +2035,36 @@ static uint8_t PlsrShortProfileTakeRunLimited( { uint32_t repeatCount; + /* 本段已切完、出口无效、或允许脉冲数为 0,无法再切。 */ if ((profile->nextPeriod >= profile->pulseCount) || (entry == NULL) || (maximumRepeats == 0UL)) { return 0U; } + + /* 上一档同频项已切完,向规划器再取一档。 */ if (profile->pendingRepeats == 0UL) { - if (PlsrPlannerGenerate(&profile->planner, - &profile->pendingItem, 1U) == 0U) + if (PlsrPlannerGenerate(&profile->planner, &profile->pendingItem, 1U) == 0U) { profile->active = 0U; return 0U; } profile->pendingRepeats = profile->pendingItem.repeatCount; } + { uint32_t runLimit = PlsrProfileRunLimit( profile->pendingItem.setting.actualFrequencyHz); + /* 限制单次 run 时长,避免在线改频阻塞在超长匀速之后。 */ if (maximumRepeats > runLimit) { maximumRepeats = runLimit; } } + + /* 本项脉冲数 = min(本档剩余, 本段剩余, 调用方上限)。 */ repeatCount = profile->pendingRepeats; if (repeatCount > profile->pulseCount - profile->nextPeriod) { @@ -2040,11 +2079,13 @@ static uint8_t PlsrShortProfileTakeRunLimited( profile->active = 0U; return 0U; } + entry->setting = profile->pendingItem.setting; entry->requestedFrequencyHz = profile->pendingItem.requestedFrequencyHz; entry->repeatCount = repeatCount; - entry->startsNextSegment = 0U; + entry->startsNextSegment = 0U; /* 段边界不由本函数标记 */ + profile->pendingRepeats -= repeatCount; profile->nextPeriod += repeatCount; profile->lastRampFrequencyHz = entry->setting.actualFrequencyHz; @@ -2052,6 +2093,7 @@ static uint8_t PlsrShortProfileTakeRunLimited( { profile->active = 0U; } + return 1U; } @@ -3125,23 +3167,23 @@ static void PlsrProfileRecordProducerCycles(uint32_t startCycles) static uint8_t PlsrProfileQueueFill(uint16_t targetCount, uint16_t *itemBudget) { - PLSR_SHORT_PROFILE candidate; - PLSR_PROFILE_ENTRY entry; + PLSR_SHORT_PROFILE candidate;//规划账本的工作副本。在临界区外算,算完再写回 + PLSR_PROFILE_ENTRY entry;//这一圈要入队的那一项 uint32_t generation; - uint32_t producerEpoch; - uint32_t criticalState; - uint32_t underrunDebt; + uint32_t producerEpoch;//拷出去时记下的版本,写回来要对得上 + uint32_t criticalState;//开关中断用 + uint32_t underrunDebt;//硬件已经「借用」了多少还没规划的脉冲 uint32_t queueReadIndex; uint32_t queueWriteIndex; - uint32_t queueCount; + uint32_t queueCount;//当前深度 uint32_t currentGeneration; uint32_t currentProducerEpoch; - PLSR_PROFILE_QUEUE *queue; + PLSR_PROFILE_QUEUE *queue;//队列指针 uint8_t queueBank; - uint8_t currentBank; + uint8_t currentBank;//当前活队列 uint8_t queueActive; uint8_t generatorComplete; - uint8_t payingUnderrunDebt; + uint8_t payingUnderrunDebt;//这一圈是还债还是真入 #if defined(PLSR_DEBUG_TIMING) && (PLSR_DEBUG_TIMING != 0) \ && !defined(PLSR_HOST_TEST) uint32_t startCycles; @@ -3149,47 +3191,53 @@ static uint8_t PlsrProfileQueueFill(uint16_t targetCount, if (itemBudget == NULL) { + //没有预算指针,没法扣次数,当失败 return 0U; } if ((targetCount == 0U) || (targetCount > PLSR_PROFILE_QUEUE_CAPACITY)) { + //非法目标就按整队列来。正常调用是 400/800,走不到这里 targetCount = PLSR_PROFILE_QUEUE_CAPACITY; } while (1) { - criticalState = PlsrPlatformEnterCritical(); - queueBank = PlsrProfileQueueBank; - queue = &PlsrProfileQueues[queueBank]; - queueActive = queue->active; - generatorComplete = queue->generatorComplete; - queueWriteIndex = queue->writeIndex; - queueReadIndex = queue->readIndex; - queueCount = queueWriteIndex - queueReadIndex; - if ((queueActive == 0U) - || (generatorComplete != 0U) - || (queueCount >= targetCount) - || (*itemBudget == 0U)) - { + criticalState = PlsrPlatformEnterCritical();//进入临界区 + queueBank = PlsrProfileQueueBank;//现在活着的那一套队列是 0 还是 1。先记下来 + queue = &PlsrProfileQueues[queueBank];//拿到这套队列的指针。后面 queue->xxx 都是这套 + queueActive = queue->active;//这套还在给当前运动供货吗。段停完/作废会变成 0 + generatorComplete = queue->generatorComplete;//规划器是不是已经把这段所有脉冲都吐完了 + queueWriteIndex = queue->writeIndex;//任务下一次要写的位置(累计值,不是 0~1023 的下标) + queueReadIndex = queue->readIndex;//IRQ 下一次要读的位置 + queueCount = queueWriteIndex - queueReadIndex;//当前有多少格。无符号减法,环形也能得到深度 + if ((queueActive == 0U)//不供货了,不用再算 + || (generatorComplete != 0U)//这段已经吐完,不用再算 + || (queueCount >= targetCount)//已经够深(调用方一般是 800 或 400) + || (*itemBudget == 0U))//这一拍允许新算的次数用光了 + + { + //关闭临界区 PlsrPlatformExitCritical(criticalState); return 1U; } + /*先扣一次「本拍还能算几项」。 + 哪怕后面这项被扔掉,次数也花掉了,防止一拍里死循环*/ (*itemBudget)--; - generation = queue->generation; - producerEpoch = queue->producerEpoch; - underrunDebt = queue->underrunDebtPulses; - payingUnderrunDebt = (underrunDebt != 0UL) ? 1U : 0U; + generation = queue->generation;//记下现在的世代。改频、借债会 generation++ + producerEpoch = queue->producerEpoch;//记下队列现在的世代。改频、借债会 generation++ + underrunDebt = queue->underrunDebtPulses;//硬件已经用末频「预支」了多少脉冲,规划还没跟上 + payingUnderrunDebt = (underrunDebt != 0UL) ? 1U : 0U;//这一圈是 还债 还是 入队。有债=1 PlsrCopyShortProfile(&candidate, &queue->producerProfile); + //把规划账本拷到本地 candidate。等会儿在门外改它,不能边算边让 IRQ 看到半成品 PlsrPlatformExitCritical(criticalState); #if defined(PLSR_DEBUG_TIMING) && (PLSR_DEBUG_TIMING != 0) \ && !defined(PLSR_HOST_TEST) startCycles = PlsrProfileTimingNow(); #endif - if (PlsrShortProfileTakeRunLimited( - &candidate, &entry, - (payingUnderrunDebt != 0U) - ? underrunDebt : 0xFFFFFFFFUL) == 0U) + if (PlsrShortProfileTakeRunLimited( &candidate, &entry, + (payingUnderrunDebt != 0U) ? underrunDebt : 0xFFFFFFFFUL) == 0U) + { return 0U; } @@ -6027,20 +6075,20 @@ static uint8_t PlsrServiceCountedExecutor(void) void PlsrPoll1ms(void) { - uint8_t extLevel; - uint8_t extEdge; - uint8_t activeSegmentNumber; - uint8_t applyDynamicFrequency = 0U; - uint8_t pulseDirReplanRequested = 0U; - uint8_t pulseDirReplanFailed = 0U; - PLSR_SEGMENT_CONFIG *activeSegment; - uint32_t criticalState; - uint32_t newTargetHz; - uint32_t pulseDirReplanPulses = 0UL; - uint32_t pollEpoch; - uint16_t fillBudget = PLSR_PROFILE_REFILL_BUDGET; + uint8_t extLevel;//这一拍读到的 EXT 引脚电平。后面才用,这段没用 + uint8_t extEdge;//这一拍有没有上升沿。后面才用 + uint8_t activeSegmentNumber;//当前段号的快照 + uint8_t applyDynamicFrequency = 0U;//要不要改目标频率。0 = 先当不用改 + uint8_t pulseDirReplanRequested = 0U;//要不要对 PULSE/DIR 整段重规划。0 = 不要 + uint8_t pulseDirReplanFailed = 0U;//重规划失败标志。先清零 + PLSR_SEGMENT_CONFIG *activeSegment;//指向当前段配置。后面才赋值 + uint32_t criticalState;//进临界区前的中断状态,退出时原样恢复。这段还没进临界区 + uint32_t newTargetHz;//改频后的新目标。后面才用 + uint32_t pulseDirReplanPulses = 0UL;//重规划还剩多少脉冲。先 0 + uint32_t pollEpoch;//这一拍看到的段世代号,防止用过期段的 EXT/改频 + uint16_t fillBudget = PLSR_PROFILE_REFILL_BUDGET;//这段会用。 本拍最多往队列里新算多少项。宏是 200 PLSR_PLATFORM_SERVICE_RESULT pulseDirReplanResult = - PLSR_PLATFORM_SERVICE_READY; + PLSR_PLATFORM_SERVICE_READY;//重规划结果,先当成功 if (PlsrInitialized == 0U) { @@ -6049,53 +6097,75 @@ void PlsrPoll1ms(void) if (PlsrPollCommandMailbox() != 0U) { + /*看邮箱有没有 START / STOP / CLEAR。 + 有命令:当场执行(启动、停止、清位置)。 + 返回 非 0:这拍到此结束(常见是 STOP 认为后面不用再 poll)。 + 返回 0:没命令,或 STOP 说还可以继续 → 往下走。*/ return; } /* 段边界事件必须在任何计数同步之前消费:快照是 IRQ 边界时刻的 硬件计数,晚消费会把下一 run 的脉冲算进旧段。 */ - PlsrExecServiceCountedEvents(); + PlsrExecServiceCountedEvents();//把 IRQ 丢进边界事件环的东西在任务里消化:切段、记账。只做账,不算频率 if (PlsrTimerErrorPending != 0U) { + /*IRQ 或平台已经锁存了定时器故障。 + 进错误态,停后续。故障了还 Fill 会越帮越忙*/ PlsrEnterError(PLSR_ERROR_TIMER); return; } - PlsrPollPositionCheckpoint(); + PlsrPollPositionCheckpoint();//该把位置/配置写到掉电保存就写。和发波、队列无关,夹在中间是因为 1ms 顺手做 if (PlsrServiceCountedExecutor() != 0U) { + /*给正在按队列发波 的执行器做服务:用硬件计数更新剩余脉冲和位置; + 若 整段已经发完(硬件 completion),清队列、置 BoundaryPending,返回 1。 + 还在跑 → 返回 0,if 不进。 + 已经发完 → 进大括号*/ if (PlsrBoundaryPending != 0U) { PlsrHandleBoundary(0U); + //有段边界待处理:接下一段、等待、完成发送等。参数 0 = 不是从脉冲 IRQ 里来的 } - PlsrPollPersistenceDelay(); - return; + PlsrPollPersistenceDelay();//掉电保存的延时到了就存 + return;//这段波已经结束,本拍不要再补队列。Fill 是给还在跑的段用的 } - if (PlsrProfileQueueCount() <= PLSR_PROFILE_LOW_WATER) + if (PlsrProfileQueueCount() <= PLSR_PROFILE_LOW_WATER)//当前队列还剩多少 格子 { + //浅到 ≤100,本拍预算从 200 改成 400。急救,多炒一倍菜。 + //没浅,仍是 200 fillBudget = PLSR_PROFILE_STARTUP_BUDGET; } if (((PlsrExecutor.mode == PLSR_EXEC_STEP_TABLE) || (PlsrExecutor.mode == PLSR_EXEC_STREAM) - || (PlsrExecutor.mode == PLSR_EXEC_AB_LEGACY)) - && (PlsrProfileQueue.active != 0U) - && (PlsrProfileQueue.generatorComplete == 0U) - && (PlsrProfileQueueFill(PLSR_PROFILE_REFILL_TARGET, - &fillBudget) == 0U)) + || (PlsrExecutor.mode == PLSR_EXEC_AB_LEGACY))//这三种才会用 profile 队列。IDLE 不补 + && (PlsrProfileQueue.active != 0U)//当前 bank 还在给这段供货。段停完后 active 会被清掉 + && (PlsrProfileQueue.generatorComplete == 0U)//本段规划还没把所有脉冲吐完。吐完了第一次 Fill 没意义 + && (PlsrProfileQueueFill(PLSR_PROFILE_REFILL_TARGET,&fillBudget) == 0U)) + /*去算、去塞,直到格子到 800 或预算用完。返回 0 = Generate/入队失败*/ + { PlsrEnterError(PLSR_ERROR_TIMER); - return; + return;//活队列补失败,当定时器/规划故障停机 } if (((PlsrExecutor.mode == PLSR_EXEC_STEP_TABLE) - || (PlsrExecutor.mode == PLSR_EXEC_STREAM)) + || (PlsrExecutor.mode == PLSR_EXEC_STREAM))//这里 没有 AB_LEGACY。PULSE/DIR 的 counted 下一段才走这条 && ((PlsrStageCountedHandoff() == 0U) + /*本段已经 generatorComplete 时,把下一段预热的第一/第二 run 接到队尾。 + 还不到接的时候它返回 1(成功但空操作)。 + 返回 0 才是真失败(队列塞不下、handoff 非法等*/ || (PlsrProfileQueueFill(PLSR_PROFILE_REFILL_TARGET, &fillBudget) == 0U))) + /*handoff 成功或空操作之后,再用 剩下的预算 再填一次。 + handoff 刚占了格子,或第一次没填满。 + + 两个子条件用 或:handoff 失败 或者 第二次 Fill 失败,都进错误。 + 短路:handoff 已经失败,第二次 Fill 不会跑。*/ { - PlsrEnterError(PLSR_ERROR_TIMER); + PlsrEnterError(PLSR_ERROR_TIMER);//接不上下一段或再补失败,同样停 return; } diff --git a/PLSR/Src/plsr_planner.c b/PLSR/Src/plsr_planner.c index 8f60348..4e7c744 100644 --- a/PLSR/Src/plsr_planner.c +++ b/PLSR/Src/plsr_planner.c @@ -697,10 +697,10 @@ PLSR_PLANNER_STATUS PlsrPlannerBegin(PLSR_PLANNER_CONTEXT *context, return (context->clipped != 0U) ? PLSR_PLANNER_CLIPPED : PLSR_PLANNER_OK; } - -uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context, - PLSR_STREAM_ITEM *output, - uint16_t capacity) +//返回值:实际写了几项(合并后的项数,不是脉冲数)。0 = 没吐出任何东西 +uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context,//规划账本:三段脉冲、已经吐了多少、斜坡面积指针 + PLSR_STREAM_ITEM *output,//输出数组,调用方准备好的格子 + uint16_t capacity)//这一次最多往 output 里写几项 { PLSR_PLATFORM_TIMER_SETTING setting; uint32_t requestedFrequencyHz; @@ -712,18 +712,16 @@ uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context, { return 0U; } - while ((produced < capacity) - && (context->generatedPulses < context->block.pulseBudget)) - { - if (PlsrPlannerTakeStep(context, &setting, &requestedFrequencyHz, - &repeatCount) == 0U) + while ((produced < capacity)&& (context->generatedPulses < context->block.pulseBudget)) + { + if (PlsrPlannerTakeStep(context, &setting, &requestedFrequencyHz, &repeatCount) == 0U) + //本圈 TakeStep 算出的量化后 PSC/ARR/actualHz,先放栈上 { context->active = 0U; break; } if ((produced != 0U) - && (PlsrPlannerSameSetting(&output[produced - 1U].setting, - &setting) != 0U) + && (PlsrPlannerSameSetting(&output[produced - 1U].setting,&setting) != 0U) && (output[produced - 1U].requestedFrequencyHz == requestedFrequencyHz) && (output[produced - 1U].repeatCount diff --git a/PLSR/Src/plsr_planner.h b/PLSR/Src/plsr_planner.h index e521e71..b35608a 100644 --- a/PLSR/Src/plsr_planner.h +++ b/PLSR/Src/plsr_planner.h @@ -35,8 +35,8 @@ typedef struct typedef struct { PLSR_PLATFORM_TIMER_SETTING setting; - uint32_t requestedFrequencyHz; - uint32_t repeatCount; + uint32_t requestedFrequencyHz;//用于诊断的频率 + uint32_t repeatCount;//这一档连续多少个脉冲。斜坡多半 1;匀速可以是剩下的全部(例如 5000) } PLSR_STREAM_ITEM; typedef enum @@ -65,34 +65,34 @@ typedef struct typedef struct { - PLSR_MOTION_BLOCK block; - uint64_t phasePulses; - uint32_t startHz; - uint32_t peakHz; - uint32_t endHz; - uint32_t entryPulses; - uint32_t steadyPulses; - uint32_t exitPulses; - uint32_t generatedPulses; - uint32_t rampRelativePulse; - uint32_t rampPulseCount; - uint32_t rampFromHz; - uint32_t rampToHz; - uint32_t rampDurationMs; - uint32_t lastRampHz; - uint64_t rampTotalAreaQ32; - uint64_t rampAreaStepQ32; - uint64_t rampTargetAreaQ32; - uint64_t rampBoundaryQ32; - uint64_t rampActualTimeQ32; - uint64_t rampLastPhaseStepQ32; - uint64_t rampFirstBoundaryQ32; - uint64_t rampSecondBoundaryQ32; - uint32_t rampAreaRemainder; - uint32_t rampRemainderAccumulator; - uint8_t rampKind; - uint8_t clipped; - uint8_t active; + PLSR_MOTION_BLOCK block; // 本块输入:起/巡/终速度、预算、加减速时间、曲线、通道 + uint64_t phasePulses; // 起点相位,已实际输出的脉冲累计,冷启动为 0 + uint32_t startHz; // 实际起始频率(appliedHz,0 抬成 1) + uint32_t peakHz; // 实际峰值,预算不足时低于 cruise + uint32_t endHz; // 实际终点:停止速度、carry 或 clip 后可达值 + uint32_t entryPulses; // 加速段脉冲数 + uint32_t steadyPulses; // 匀速段脉冲数,三角轮廓为 0 + uint32_t exitPulses; // 减速段脉冲数 + uint32_t generatedPulses; // 已交给调用方的脉冲总数(按脉冲计,非项数) + uint32_t rampRelativePulse; // 当前斜坡内已走脉冲序号,0 为斜坡第一项 + uint32_t rampPulseCount; // 当前斜坡总脉冲数 + uint32_t rampFromHz; // 当前斜坡起点频率 + uint32_t rampToHz; // 当前斜坡终点频率 + uint32_t rampDurationMs; // 当前斜坡折算持续时间(ms) + uint32_t lastRampHz; // 上一斜坡脉冲量化后的实际频率,禁止加减速倒退 + uint64_t rampTotalAreaQ32; // 当前斜坡 x=0~1 的总面积(Q32) + uint64_t rampAreaStepQ32; // 每个脉冲的面积整数商:totalArea / N + uint64_t rampTargetAreaQ32; // 到当前脉冲结束必须达到的累计面积 + uint64_t rampBoundaryQ32; // 上一脉冲结束时的归一化时间边界 x + uint64_t rampActualTimeQ32; // 已按量化后 actualHz 走过的归一化时间(闭环) + uint64_t rampLastPhaseStepQ32; // 上一脉冲相位步长,供后续预测边界 + uint64_t rampFirstBoundaryQ32; // 第 1 个斜坡脉冲的精确边界(二分) + uint64_t rampSecondBoundaryQ32; // 第 2 个斜坡脉冲的精确边界(二分),其后改预测 + uint32_t rampAreaRemainder; // totalArea % N,余数往各脉冲面积上摊 + uint32_t rampRemainderAccumulator; // 摊余数累加器,满 N 则目标面积 +1 + uint8_t rampKind; // 0 无斜坡/匀速,1 加速,2 减速 + uint8_t clipped; // 预算不足已裁峰/终点,结果仍可执行 + uint8_t active; // 是否还可 Generate,吐完或失败清 0 } PLSR_PLANNER_CONTEXT; PLSR_PLANNER_STATUS PlsrPlannerBegin(PLSR_PLANNER_CONTEXT *context,