Files
STM32F4-Base/docs/算法/采集与算法层设计说明.md
T
super_agent_bot 72b4b0943c docs: clear task and layer shorthand IDs
Replace task/batch shorthand IDs and residual layer labels in
human-facing docs with semantic phrases. Add the new cleanup
policy to docs/编号规范.md and update the entry docs.

Machine keys (TC/CODE-RULE/MENU-STATE/TRAP/FR), hardware
designators/pins/MAC, protocol/version numbers and frozen
historical evidence are intentionally kept.

Docs only; no code behavior change. Verified with
check_doc_links.py --all and git diff --check.
2026-11-02 03:33:51 +08:00

41 KiB
Raw Blame History

采集与算法层设计说明(App/algo/:语义取数与有效性 → 全周傅氏相量 → 保护判据)

模块名称:自研采集与算法层,代码在 App/algo/(七个 .c,全部登记进 Keil 工程)。 文档定位:现状设计说明——本层当前如何组织、每个模块的契约与关键机制、为什么这样设计、 边界与代价。目录级索引(文件清单、构建与测试命令、新增模块动作)在 App/algo/README.md, 本文不重复其内容;本文不写历史演进、门禁复测记录与修订日志(需要时查 git 历史)。 状态:语义取数与有效性 → 全周傅氏相量 → 保护判据主线已闭环到板端消费者(protectTask,20 ms 判据节拍); 标定折算层 App/algo/adc_cal 已于 2026-10-25 落地(标定折算与落盘整定,docs/任务规划/数据可信.md §4.1, 引脚电压量纲 + 系数落盘 + 未标定闭锁);幅值精度判据仍待标准源两点注入复核(两点标定,docs/任务规划/数据可信.md §5)。 相关文档: 分层与功能项(语义取数与有效性~动作留痕)的唯一属主登记处:docs/算法/继电保护功能复现规划.md §2.1(分层)、§3(功能清单)、§4.2(精度)、§7(路线图) docs/硬件平台/ADC通道映射说明.md(语义映射唯一权威口径) docs/子系统设计/ADC采样节拍设计说明.md(TIM3 1 kHz 同源节拍、ADC1+DMA 乒乓缓冲、adcTask 唤醒) docs/算法/傅氏算法详解.md §2.3(有效值口径怎么来的) docs/测试说明/单元测试/相量层单元测试说明.md、docs/测试说明/单元测试/ADC输入层单元测试说明.md(主机单测细则)

目录


1. 定位与边界

  • 原则:算法与 I/O、与调度分离。 本层只做两件事——把原始码值变成语义通道的"数"、把采样序列 变成相量/频率/判据状态。不建任务、不等事件、不碰寄存器(唯一例外 adc_input.c,它必须驱动 ADC1)、不做出口动作。
  • 这样分离换到什么:sample_ring / phasor / adc_health / prot_set / prot_elm / freq 零平台依赖(只用 stdint.h / math.h / stddef.h 与本目录头),因此可被主机侧 单测以 #include 原样纳入、离线全覆盖(落实 docs/任务规划/暂缓-RS485.md「采集链路测试可验证性约束」),不需要真机就能钉死判据。
  • 上下文约束:*_init() 一律无 RTOS 依赖、幂等,由 app_main_init() 在内核启动前调用; adc_input_poll() / phasor_run() / freq_run() 仅任务上下文(快照走临界区)。
  • 消费者在 App/task/:adcTask 采集并压环、protectTask(保护任务)跑 prot_elm_run(); 新算法一律落在 App/algo/,勿塞进 App/task/(任务只做消费与编排)。

2. 一拍的数据流与初始化落点

一拍 = TIM3 的一个 1 kHz 更新事件(节拍与消费者唤醒同源,见 docs/子系统设计/ADC采样节拍设计说明.md §1)。

flowchart TD
    TIM3["TIM3 更新事件 1 kHz<br/>ISR: 脉冲 CONVST + 置唤醒标志<br/>同一事件经 TRGO 触发 ADC1"]
    TIM3 --> TPAFE["TPAFE5160 转换<br/>BUSY EXTI 收帧"]
    TIM3 --> ADC["STM32 ADC1 一轮 9 通道<br/>DMA2 循环模式自动搬运"]
    ADC --> PP["乒乓双缓冲<br/>半传输 / 整传输回调推进完成计数"]
    TIM3 -. "唤醒 adcTask" .-> TASK
    TPAFE --> POLL
    PP --> POLL
    TASK["adcTask(事件驱动唤醒,非定时轮询)"] --> POLL["adc_input_poll()<br/>TPAFE 原子快照 + 消费刚完成的半区"]
    POLL --> RAW["adc_input_raw(ch) × 9<br/>按语义取原始码值"]
    RAW --> RING["sample_ring_push_frame()<br/>整帧入环"]
    RING --> RUN["phasor_run()<br/>逐实例重算 + 双区切换"]
    RUN --> FREQ["freq_run()<br/>共轭乘积相位增量 + 自适应窗均值"]
    RUN --> GET["phasor_get(idx)"]
    FREQ --> FGET["freq_get() / freq_valid()"]
    GET --> CONS["消费者:adcTask 心跳日志"]
    FGET --> PCONS["消费者:prot_elm_run() 的 OF / UF 判据(B8)"]
落点 做什么
app_main_init()(内核启动前) adc_input_init()(映射自检 + 挂 DMA 缓冲)→ phasor_init()(内部连带 sample_ring_init())→ freq_init() → prot_set_init() → prot_elm_init()
adcTask(每拍) adc_input_poll() → adc_input_raw() ×9 → sample_ring_push_frame() → phasor_run() → freq_run() → 心跳与健康判定 adc_health_check()
protectTask(20 ms) 取 phasor_get() rms 与 freq_get() → prot_elm_run();「自检失败闭锁」采样故障闭锁、 定值校验和巡检

prot_elm_init() 须在 phasor_init() 之后(它按名前缀解析相量实例索引);prot_set 独立 (只依赖 stdint.h),可在任意点初始化。


3. 语义取数与有效性语义取数 adc_input.c

解决的问题:V0.1 上 8 路电气量被两片转换器分片(硬件缺陷双转换器无共同采样触发),且网名 AINn ≠ 板级 ADCn ≠ 转换器通道号(17 路里 13 路置换)。消费侧若直接索引驱动缓冲,改版即全线错位。 本层把"哪一路"收进一张表 k_adc_map[],上层按语义取数、不写死下标。

API 契约 违约后果
adc_input_init() 清缓冲与统计 + 映射自检(每个 STM32 语义通道必须落在扫描 Rank 序列内)+ HAL_ADC_Start_DMA() 挂载乒乓缓冲(只挂一次);-1 = 映射指向从不扫描的通道,-2 = DMA 挂载失败 映射错位是静默的:该路恒读上一个陈旧值,不报错
adc_input_poll() 采一拍:tpafe5160_snapshot() 原子快照(8 通道)+ stm32_take() 消费刚完成的那半区(按完成计数奇偶推半区,零忙等) 仅任务上下文(快照是临界区);在 ISR 里调会破坏 tpafe5160_snapshot() 的"仅任务上下文"约定
adc_input_raw(ch, &out) 按语义读原始码值(TPAFE 16 位补码 / STM32 0..4095);-2 = 该来源尚无有效数据 消费侧取数唯一入口;须先成功 poll()
adc_input_desc(ch) 语义 → 物理通道描述(来源 + 下标 / 通道号),供自检注入与 UI 通道序 返回静态地址,勿改
adc_input_stm32_rc() / _seq() / _errs() / _gaps() / _hw_errs() 最近一拍状态码 / DMA 整帧完成计数 / 停摆(连续 ≥2 拍无新帧)/ 丢帧点数(Σ 每次前进的超出部分,与 tgap 同单位)/ HAL 错误回调 四类成因不同,勿混读:对应心跳字段 adc1_rc / adc1_seq / adc1_err / adc1_gap / adc1_hw(判读见 docs/子系统设计/ADC采样节拍设计说明.md §8)

要点

  • 映射表内容的唯一权威口径是 docs/硬件平台/ADC通道映射说明.md:UA/UN/IA → TPAFE buf[6]/[5]/[2];UB/UC/IB/IC/IN/TEMP → ADC1 CH14/3/6/1/2/5。 V0.2 改版只改这张表的内容,本层 API 与上层语义不动。
  • STM32 侧是顺序扫描(Rank1..8 = CH0..CH7、Rank9 = CH14,与 MX_ADC1_Init() 严格一致), 9 个通道不是同一时刻(一轮 ≈67 µs);由 TIM3 TRGO 硬件触发 + DMA 自动搬运( docs/子系统设计/ADC采样节拍设计说明.md §4)。STM32 侧数据固定滞后 1 拍(1 ms)——与 TPAFE 侧本来就没有同刻性(双转换器无共同采样触发),无新增误差。
  • 开机映射自检挡住"映射到一个从不扫描的通道"这类静默错(启动日志 ADC input layer init: OK)。
  • 本文件是本层唯一允许出现 HAL 调用的地方;新增模块若不需要外设,请保持 sample_ring / phasor 那种零依赖形态,才能免桩单测。

4. 语义取数与有效性判定 adc_health.c

解决的问题:驱动侧与 语义取数与有效性只有"观测计数"(tpafe5160_frame_seq() / tpafe5160_frstdata_err()、adc_input_stm32_*()),没有判定——芯片静默、消费者超拍丢帧、 序号倒退这些故障原本只能靠人读心跳日志发现。本层把判定收进纯逻辑,告警编排留在消费者 (adc_task.c 的 adc_health_report())→ 判定可主机免桩单测。

API 契约
adc_health_init() 清状态;首拍 check() 只建基线、不判定
adc_health_check(&in, &out) 吃"计数快照"做一次判定(每拍调,常数次整数运算);差值法 (int32_t)(now - prev) → 计数与 tick 回绕都安全
adc_health_flags() 电平故障位:TPAFE_STALL / STM32_STALL(≥100 ms 无前进,可自愈)/ SEQ_BACKWARD(锁存)——为「自检失败闭锁」闭锁供数
adc_health_sticky() 锁存位:FRSTDATA / STM32_ERR / HW_ERR 的增量痕迹,自愈后仍留痕
adc_health_tpafe_gaps() TPAFE 累计丢帧点数(序号前进 >1 的超出部分),与 STM32 侧 adc_input_stm32_gaps() 同单位(心跳字段 tgap / adc1_gap 可直接对比)

要点

  • 三类语义严格分开:电平位(stall,可自愈)/ 锁存位(出现过必留痕)/ 边沿事件位 (仅当拍有效)。ADC_HE_* 与同名 ADC_HF_* 编号对齐,消费者直接用事件掩码对"已留痕位图" 做"每类每次开机一次 SYSLOG_SELFTEST"的编排。
  • 静默 ≠ 丢帧:前进量 d = 0 才是静默候选;d > 1 只说明消费者没跟上、生产者还活着—— 两者处置完全不同,判据不许混。
  • gap 不告警:消费者超拍(心跳拍 ≈2 ms)是已知现象,adc1_gap / tgap 在 10 s 心跳里可见; 若按事件告警会把真故障淹掉。
  • 告警策略(在 adc_task.c,不在本层):严重事件与电平上升沿 DBG_ERROR + SYSLOG_SELFTEST(每类每次开机一次,防刷屏写满 selftest.log);下降沿 DBG_INFO ("恢复"必须可见,否则只看到一次错误无从判断是否还在)。

深读:本模块的判据推导(100 ms 门限的三条依据)、位号对齐的收益与代价、闭锁与兜底的责任 划分、观测盲区、验证分工与现场判读手册 → docs/算法/采样有效性校验设计说明.md。


5. 全周傅氏相量输入 sample_ring.c

5.1 为什么需要这一层

一句话:上游每 1 ms 只交"本拍那一帧",下游相量要的是"以当前时刻为终止的一整周波", 中间那 19 个历史点没有任何一层替它保留——本层就是补这个缺口的地方。

三个缺口,任一个单独存在都能让相量算不出来或算不准:

缺口 上游给什么 下游要什么 不缓冲的后果
点数 每拍 1 帧(9 路语义值) 一周波 20 点(PHASOR_N) 只有 1 点 → 全周傅氏退化成瞬时值,谐波抑制完全失效
历史 覆盖写,新值顶掉旧值 以当前时刻为终点的滑窗 每拍都要重算,但每拍只拿到当前点 → 窗永远凑不齐
同源 —— phasor / freq / meas 必须看同一个窗 各存一份历史 → 窗边界差一拍,相位差与频率跟着漂

上游每拍只交最新一帧、下游要一整周波:采样环补的正是中间那 19 个历史点

两种"更省事"的替代方案,都否决了:

  • 在 phasor.c 里自己存:freq 要相邻两拍相量、meas 要同源快照,三个模块各存一份 → 同一时刻存在三个"窗",一旦谁跳过一拍就没有单一真值可对,排查时分不清是采集问题还是缓存问题。
  • 在 adc_task.c 里存一个数组:等于把环、窗、覆盖这些算法语义塞进任务层,既破坏 "任务只消费 / 编排"的分层,又让这段逻辑失去主机侧单测能力(adc_task.c 依赖 FreeRTOS 与 HAL, 不能原样 #include)。

结论:"保存历史"必须是一个自己的、无依赖的、单一所有者的层——这就是本模块存在的理由。

5.2 设计思想

五条,每条都对着一个具体的失败模式:

  1. 按语义通道索引,不认物理通道:第一维是 adc_semantic_t(ADC_SEM_UA 等),不是 AINn、 不是 ADCn、也不是转换器通道号。V0.1 上 8 路电气量分属两片转换器、网表名与板级名有 13 路置换 (见第 3 节),本层若认物理号,改版即全线错位。
  2. 整帧原子推进:push_frame() 先把 9 路一次写齐,最后才动 s_head。消费侧看到的窗 因此永远落在整帧边界上——单帧内不可能"半新半旧"。
  3. 只存不算:本层不校正、不插值、不做任何跨通道运算。同刻性(8 路电气量是否同一时刻) 归上游 adc_input 负责,本层既不保证也不假设。这条边界换来的是可脱离硬件单测: 纯算法、不碰寄存器、不碰 RTOS,生产 .c 能原样 #include 进主机侧套件。
  4. 环形覆盖写,运行期不分配:容量固定 40 格、静态数组,无 malloc、无 memmove。 写满后新值直接覆盖最老值——相量只需要最近一周波,更远的历史没有保留价值。
  5. 容量取 2 周波而不是 1 周波:1 周波够算全周傅氏,多留 1 周波是给 1.5~2 周波长窗 (抗衰减直流分量)预留的余量,与参考系统 CN_BUFSAM_CYCLE = 2 同口径。改容量只动一个宏, 但要同步 docs/内存管理/RAM预算台账.md(容量翻倍 = 1440 B 变 2880 B,见第 11 节)。

写指针指「下一格」、窗永远紧贴它身后:40 格里哪 20 格是本窗

5.3 环内布局与取窗(start 为什么这么算)

s_head 是下一个待写入的位置,不是最后写入的位置——这是全部推导的起点。 最后写入的一点是 s_head - 1,于是"以最新点为终点的 n 点窗"= 下标 [s_head-n, s_head-1], 窗首(最老点)就是 s_head - n,落到 [0, DEPTH - 1] 要取模。

取一个"head 不够减"的例子走一遍:head = 5、n = 20——最新点在 4,沿环往回退 19 步落在 25, 途中跨过 0 与 39。直接写 5 - 20 会得到 −15,压根不在环上。

head 不够减时直接减会走到负数:沿环往回退 19 步跨过 0,加一圈 DEPTH 才回到 25

代码里写成 (s_head + SAMPLE_RING_DEPTH - n) % SAMPLE_RING_DEPTH 而不是 (s_head - n) % DEPTH, 是因为后者有两个独立的坑:

  • 16 位无符号下溢后再取模不等价:s_head < n 时减法先回绕成 65536-(n-s_head) = 65521, 而 65536 不是 40 的倍数,(x mod 65536) mod 40 ≠ x mod 40。同一个例子:正确窗首是 25, 回绕后 65521 % 40 = 1 → 整窗错位,读到的是环尾附近。
  • 有符号负取模:两个 uint16_t 先提升成 int,5 - 20 = -15;C99 的 % 向零截断, (-15) % 40 == -15(不是 25),再转回无符号就是一次越界读——不报错、不 HardFault。

先加一个 DEPTH 同时解决两件事:DEPTH ≡ 0 (mod DEPTH),加它不改变同余类、数学值不变; 而 s_head + DEPTH - n ∈ [0, 2·DEPTH - 2],恒非负、远小于 65535,两种整数提升规则下都不下溢。

边界自检:n == DEPTH 时 start == s_head——环满时「下一拍要覆盖的那格」就是最老点; 未写满时 s_head == s_filled,故 start >= 0,窗一定不跨环尾。 App/algo/sample_ring.c 里只留结论与指向本节的指针,推导以本节为准。

5.4 API 契约

API 契约
sample_ring_push_frame(values, n) 整帧压入,长度必须 = ADC_SEM_COUNT
sample_ring_window(ch, n, out) 取该语义通道最近 n 点,按时间序旧 → 新(out[n-1] 最新);-2 = 点数不足
sample_ring_filled() 已累积点数(饱和于 40)——"够不够一周波"的判据
sample_ring_frames() 累计入环帧数(不饱和,1 kHz 下约 49.7 天回绕)——与 tpafe5160_frame_seq() 对照即判丢帧

5.5 要点与禁令

  • 整帧写入后才统一推进写指针 → 单帧内不可能"半新半旧",消费侧取到的窗永远落在整帧边界。
  • 本层只做按时序存 / 取,不做任何跨通道运算:同刻性由上游 语义取数与有效性负责,本层既不保证也不假设。 判定也不在本层(本层只出计数)。
  • 某通道本拍取数失败 → 整拍跳过入环(现 adc_task.c 的 s_frames_skip 即此), 切勿补 0 点——单个 0 点会显著污染 20 点 DFT 窗。
  • 读者与写者必须同任务:全仓唯一写者是 adcTask 的压环语句,读者(phasor_run / meas_tick) 与之同拍串行,故本层不加锁、不用 volatile、不关中断。要把读者搬到别的任务,必须先在 本层补双区 / 序号快照——sample_ring_window() 读 s_head 后逐点拷贝,跨任务读活缓冲会取到撕裂的窗。

6. 全周傅氏相量 phasor.c 相量计算

算法:全周傅氏(单频 DFT),N = PHASOR_N = 20(1 kHz @ 50 Hz)。系数表 k_fft_sin / k_fft_cos 离线生成并预乘 √2/N(口径推导见 docs/算法/傅氏算法详解.md §2.3),故 fAmSQ = √(fRe²+fIm²) 直接就是有效值, fAm = √2·fAmSQ 为峰值。运行期不调 sinf/cosf(省算力,也让结果可逐点复算验证)。

API 契约
phasor_init() 双区清零 + 使用区/就绪复位 + sample_ring_init()(等价"刚上电");幂等、内核前可调
phasor_inst_count() / phasor_inst(idx) 遍历实例表 {name, sem, harm};返回静态地址勿改,越界 NULL
phasor_run() 逐实例取 20 点窗 → 重算 → 写计算区 → 全部实例写完后一次性切换使用区;仅任务上下文,须先 push_frame()
phasor_get(idx, &out) 读使用区结果;-2 = 未攒满一周波
phasor_calc(x, n, harm, &out) 对给定窗口算 harm 次相量——纯函数,可脱离采样环独立单测

要点

  • 实例表数据驱动(k_inst[],现 5 行):C01-IA-F1(过流 / 过负荷)、C02-UA-F1 (过压 / 欠压 / 频率)、C03-UN-F1(零序过压)、C04-IA-F2(二次谐波制动)、 C05-IA-F5(五次谐波选线)。新增实例只在表尾加一行,遍历 / 日志 / 单测自动适配。
  • 双区(计算区 / 使用区):结果先写非使用区,全部实例写完后单字节 volatile store 切换 s_use_zone(Cortex-M 上原子)→ 读者无锁、无撕裂。写者是 adcTask(同拍),读者跨两个 上下文:adcTask 心跳与 protectTask 判据(prot_elm_run() → phasor_get())——无锁读 的收益正在被使用,勿改成加锁、也勿取消双区。
  • 任一实例数据不足一周波 → 本拍整体不产出(不切换使用区):宁可整体不刷新,也不给出 "部分新部分旧"的混合结果。
  • 每拍 20 点全部重算(暴力法)。不用"加尾项去头项"的增量式:增量递推会累积误差, 暴力法每次都是干净的一周波。代价 = 每拍 20 × 实例数次乘加,相量刷新周期 1 ms。
  • 相位(「相位输出」)不常驻结构体:需要时自行 atan2f(fIm, fRe)(φ 约定 = 窗首初相,见 §7)。

7. 全周傅氏相量的派生量:频率测量 freq.c

解决的问题:过频 / 低频(docs/算法/继电保护功能复现规划.md §3.3 「过频 / 低频」)需要系统频率读数, 而 全周傅氏相量只出相量。本层把"相邻两拍基波相量的相位差"变成"频率",并解决非整周波窗长的 DFT 泄漏纹波 ——这是决定精度的关键问题。

API 契约
freq_init() 清增量环 / 计数 / 读数 / 相位基准 / 有效标志;幂等,内核前可调
freq_run() 每拍步进:选源(UA → IA 过幅值门)→ 共轭乘积 P = Z(m)·conj(Z(m-1)) 取角 → 假值钳制 → 自适应窗均值。仅 adcTask 上下文,须在 phasor_run() 之后同拍调
freq_get(out) 0 有读数(可能是保持值)/ -1 参数错 / -2 从未收敛(写 0)
freq_valid() "本拍新鲜":幅值门 OK 且已收敛。保护消费口径 = freq_get() == 0 && freq_valid() == 1
freq_last() 最近有效读数;从未收敛返回 0.0

7.1 为什么是"相位微分 + 自适应窗"

  • 免解缠绕:arg(Z_now · conj(Z_prev)) 天然落在 (-π, π],无需维护累积相位。 本层相量约定 φ = atan2(fIm, fRe) = 窗首初相,窗每拍前滑一点 → 纯音下每拍角增大 2πf/Fs;4555 Hz 段 |Δφ| ≈ 0.280.35 rad,距 ±π 裕度巨大。
  • 定长窗为什么不够(自适应窗的理由):20 点矩形窗在非整周波时混入负序泄漏项 (45 / 55 Hz 处 |S₂/S₁| ≈ 35 %),相量端点轨迹成椭圆 → 逐拍相位差带上周期 δ(φ) 纹波,纹波周期 = fs/(2f) ≈ 9.111.1 拍。定长平均不整除纹波周期,残差留在均值里: 扫频最坏 0.317 Hz(40 拍算术平均)、0.197 Hz(60 拍复数和的 Kay 法——交叉项的和角不随 K² 衰减,勿再照这条路优化)。改 K_eff = round(8·Fs / (2·f̂))(4555 Hz 段 7389 拍) 使窗长恰为纹波周期的整数倍 → 纹波相消,扫频最坏降到 0.0101 Hz。
  • 假值钳制带取 35~65 Hz,而不是系统合理带:单拍相位差本身带 ±5 Hz 纹波,钳制带贴边会 非对称丢弃纹波半周 → 均值偏置(实测 45 Hz 点 +0.16 Hz,已修)。3565 Hz 宽于系统合理带 40.560.5 Hz(参考 CN_HERTZ_SOFTFR_MIN/MAX,见 docs/算法/继电保护功能复现规划.md §10.1),仍能拦住建"窗滑 2 拍"造成的 ≈2f(90~110 Hz) 与相位跳变假值;系统合理带由 OF / UF 的定值范围在消费侧把关,不在这里重复把关。
  • 幅值门 FREQ_AMP_MIN = 50.0(2026-10-25 下游常数按工程量重定后语义为二次值 V:50 V = 0.5×Un, 数值沿旧 50.0 属巧合,量纲依据 App/algo/prot_set.c 头部量纲基准块):低于门视为 无有效波形(浮空 / PT 断线时 rms ≈ 0,相位无意义)→ 停止更新、valid 清零、读数保持, 台架浮空因此不发散;信号刚消失的过渡段(≤20 ms 残窗)读数漂移有界 ≤0.3 Hz。
  • 源优先级 UA(C02) → IA(C01),按名前缀解析、每拍动态回退(台架只注电流也能测频); 源切换即废相位基准重建——UA 与 IA 之间没有共同相位基准(双转换器无共同采样触发),跨源相位差无意义。

7.2 精度判据(J1~J6,与 test/host/freq/freq_test.c 文件头同口径)

判据 条件 门槛 当前实测最坏
J1 扫频 纯音 45.00~55.00 Hz,0.5 Hz 步进(21 点) ≤ 0.02 Hz 0.0101 Hz @55.0 Hz
J2 噪声 幅值 1000 码、噪声 rms = 1 % 幅值 ≤ 0.10 Hz 0.0160 Hz
J3 启动收敛 首个有效相量 → 首个有效读数 ≤ 40 ms 20 ms
J4 频率阶跃 50 → 51 Hz 进入 ±0.05 Hz 并保持 ≤ 120 ms 98 ms(稳态误差 0.0053 Hz)
J5 零幅 / 浮空 rms < 幅值门 valid = 0、不发散、保持漂移 ≤ 0.3 Hz 漂移 0.13 Hz,无 NaN
J6 假值钳制 幅值 + 频率双跳变拍 60 ms 内回到 ±0.05 Hz 0.0000 Hz

附加鲁棒性(同一套件):直流偏置 8000 码 → 0.0018 Hz;3 / 5 次谐波 20 % / 10 % → 0.0065 Hz; UA 无幅值落 IA → 0.0020 Hz。

J4 的 120 ms 是"精度优先于响应"的显式取舍:自适应窗(M = 8 ≈ 4 个泄漏纹波周期 ≈ 4 个信号 周波)换来 0.0101 Hz 量级的精度,代价是频率骤变后的窗冲刷(实测 98 ms)。OF / UF 定时限默认 1000 ms → 测频响应占保护总动作时间 < 10 %,暂态假值另有 J6 兜底。若将来要压响应时间, 必须同时重估精度:缩短窗就是把精度退回 0.2~0.3 Hz 档,不是免费的。


8. 保护判据输入:保护定值模型 prot_set.c

解决的问题:保护判据的"输入宪法"。参考系统把定值放 EEPROM + 常量表做校验和自检;本层做 RAM 模型 + 持久化纯逻辑(blob 序列化 / 解析,文件胶住在 App/cfg/prot_set_store.c), 把编号 / 通讯地址 / 范围 / 默认值 / 校验和闭环钉死。

API 契约 为什么
prot_set_init() 装默认值 + 封存校验和;幂等 内核前可调
prot_set_load_raw(vals) 整表装载,逐项范围校验 all-or-nothing:任一项越界则整表不装载,prot_set_bad_mask() 指认越界项;影子区 + 单字交换 → 读侧永不见中间态
prot_set_set(id, q10) 单项写入(范围校验,批处理路径) 不自动重封存——批量改完必须 prot_set_seal(),否则 selfcheck 判"非法改写"
prot_set_set_sealed(id, q10) 原子写 + 封存:影子区改好重封后单字交换 自检永不见中间态(收口 保护任务 发现的竞态窗口);置脏标志
prot_set_dirty_peek() / _take() / _mark() 脏标志三件套(落盘握手) peek = 通知 iec104Task;take = 属主落盘前消费;mark = 写失败回置重试
prot_set_store_serialize() / _parse() 持久化 blob:PSET + 版本 + 值区 + sum32 正 / 反码对 parse 整表原子:magic / 版本 / 长度 / 校验对 / 范围任一失败 = 一字不动
prot_set_get_f() / get_ctl() 工程量浮点 / 投退读取 单字读原子
prot_set_selfcheck() 重算 sum32 与封存正 / 反码比对,失配 → 闭锁(「定值校验和」) seqlock 式复核:提交时撞见改写 = 本轮跳过而不误锁;prot_set_seal() 是解锁的唯一合法路径

要点

  • Q10 定点(×1024)与参考系统 2^10 折算口径一致;元数据表(地址 / 范围 / 默认)是 ROM 常量,校验和只覆盖 RAM 值区。
  • 通讯地址逐项对照参考 Comm/Pub/Const_CommSetPara.c 的 g_tCommSetParaTab(基址 0x8820);单测 TC-PROTSET-006 钉死"地址不重复、min ≤ def ≤ max、投退项 0..1"。
  • ⚠ 默认值与范围是拟定值(参考 EEPROM 出厂表不在本仓库),待对照修订——进度注见 docs/算法/继电保护功能复现规划.md §5.4。
  • OF / UF 八项尾追加(测频与遥测快照,32 → 40 项):PS_OF_EN / OF_HZ / OF_DLY / UF_EN / UF_HZ / UF_DLY / RET_OF / RET_UF——编号不回收,枚举序 = 数组序 = 校验和覆盖序,故只许尾部追加。 这八项的逐项通讯地址不在本仓库(参考表只示"含低压 / 低频"类目)→ 全部按 本地项 comm_addr = 0 登记(104 C_SE 不可寻址),待对照补。
  • 频率返回系数独立:RET_OF = 0.99 / RET_UF = 1.01(返回门槛 = 定值 × 系数)—— 频率滞回带与电气量的 0.95 / 1.05 不同量级,勿共用一个返回系数。
  • 换代语义(保护定值持久化 → 测频与遥测快照):项数变 → blob 144 → 176 B、PROT_SET_BLOB_VER 1 → 2。旧档(ver = 1 / 32 项)在新固件 parse 时被版本 / 长度闸拒绝 → 回落出厂默认,prot_set_store_load() 返回 2 = 换代(打 INFO,不是 CORRUPT、不记自检),-1 才是真损坏。 升级路径不得报成损坏(判读细节见 §12.3)。
  • bad_mask 随项数扩为 uint64_t(bit i = 第 i 项越界);消费方目前只有主机单测, 板端闭锁走 prot_set_locked()。

9. 保护判据元件框架 prot_elm.c

解决的问题:参考系统 89 个 PRS_*.c 各自实现"滞回 + 定时限"——本框架把这段公共核收敛成 一个通用核 + 数据驱动实例表(与 phasor 的 k_inst 同一模式):新增元件 = 表加一行 + 定值加几项。

API 契约
prot_elm_init() 复位状态 + 按名前缀(C01/C02/C03)解析相量实例索引;须在 phasor_init() 之后;src = FREQ 行无相量绑定(pidx = 0xFF)
prot_elm_eval(now_ms, meas) 纯判据:定值 RAM + 测量数组 → 全元件状态机;主机单测入口;NULL 测量按 0
prot_elm_run(now_ms) 适配层:按实例表 src 组测量(0 取 phasor_get() rms,1 取 freq_get())再 eval
prot_elm_state() / prot_elm_active_mask() 状态快照 / 动作位图,供 出口与记录与心跳日志
prot_elm_request_reset() 跨上下文复归入口(104 YK11 落请求,eval 头部消费)

要点

  • 滞回:已启动用返回门槛(定值 × 返回系数),未启动用动作门槛;过量 / 欠量方向由实例表 over 位统一处理(UV / UF 是欠量实例)。
  • 定时限用差值法 (uint32_t)(int32_t)(now - t0) >= dly → tick 回绕安全;dly = 0 = 启动同拍即动作。返回是即时的(参考系统带 10 ms 返回延时,接出口硬件时再补)。
  • 闭锁联动:prot_set_locked()(「定值校验和」)或该元件投退 = 0 → 状态整体复位、不再判据。
  • 数据缺失时的两种处理刻意不同(这是本层最容易改错的一处):
    • 相量类任一未就绪 → 整拍跳过(不给判据喂陈旧值);
    • 频率类无有效读数 → 按方向填"必不启动"值(过量填 0、欠量填 1e30 → 滞回比较恒假 → 元件复位),不整拍跳过——频率源缺失不得饿死电流 / 电压类元件。
  • V0.1 实例表(受双转换器无共同采样触发约束):OC1 / OC2 / OC3 / OL(IA)、OV / UV(UA)、3U0 (UN 外部通道)、OF / UF(freq 测频,测频与遥测快照)。零序过流(IN 走 STM32)故意不在表中 ——跨转换器不构成同刻矢量。
  • OF / UF 的出口可见性:两元件未单独映射 104 遥信点(送检点表口径见 docs/算法/继电保护功能复现规划.md §7),动作目前只经 YX15 保护总聚合可见 (iec104_pts 的 fault-total 掩码含 OC / 3U0;OF / UF 置总位但无独立分点)。 文档与代码都必须按此口径,勿写"独立遥信"。
  • 板端接线:App/task/protect_task.c(protectTask,20 ms)消费 prot_elm_run() + 自检失败闭锁 + 定值校验和巡检;判据结果经 104 遥信上送(iec104_pts YX15~23 / 31) → docs/算法/继电保护功能复现规划.md §7。实例数变化会同时改变启动锚点 elm= 数字与 prot_elm_active_mask() 位宽(测频与遥测快照:7 → 9 实例),串口锚点表若含该数字须同步核对。

10. 关键约定与禁令

  1. 语义映射只有一处权威:docs/硬件平台/ADC通道映射说明.md。取数一律经 adc_input_raw(), 禁止在消费侧直接索引驱动缓冲或写死下标。
  2. 禁止跨转换器运算(V0.1 硬件缺陷双转换器无共同采样触发):UA/UN/IA 在 TPAFE、UB/UC/IB/IC/IN 在 STM32 ADC1,两片转换器没有共同采样触发 → 无同一时刻性、相位不确定。故本层不做跨转换器的 相位 / 功率 / 功率因数 / 负序零序类矢量运算;8 路全接 TPAFE(V0.2 硬件)后方可解禁。
  3. 标定折算已落地、精度复核未做(2026-10-25 App/algo/adc_cal;标准源两点注入见 docs/任务规划/数据可信.md §5(两点标定):帧值现 = 引脚侧工程量(ROM 默认系数 = 转换器 LSB 事实值), 前端分压读法判别与端子变比仍待台架——精度结论前不得把幅值当计量证据。
  4. 本层无副作用外溢:不建任务、不等事件、不写日志(日志由调用侧打)、不碰寄存器 (例外只有 adc_input.c 驱动 ADC1)。
  5. 上下文纪律:内核启动前只允许调 *_init();adc_input_poll() / phasor_run() / freq_run() / prot_elm_run() 只在各自任务里调。
  6. 表只许尾部追加:k_inst[](相量实例)、prot_elm 实例表、定值元数据表—— 枚举序 = 数组序 = 校验和覆盖序,插入或重排等于改校验和与 104 点表。

10.1 标定折算 adc_cal:谁写 RAM、谁落盘、什么时机(道G task-43)

这一小节存在的理由很具体:此前全仓没有任何一处写清"k/b 改完为什么会丢",连写卡片的人都猜错过一次 (以为 cfg_file_poll() 的 2 s 轮询能兜住未落盘的改动——方向恰好相反)。三条事实链,每条可复算:

问题 唯一答案 代码落点
谁是 k/b 的真源 [adc_cal] / [adc_cal_ofs] 两段的引擎 RAM 副本(App/algo/adc_cal.c),别处不得再存一份系数 s_kcfg / s_bcfg;App/algo/run_par.h 头注释「不另存一份系数」
谁能写 RAM 只有 run_par 的两支语义 setter run_par_cal_k_set() / run_par_cal_b_set()——"写值 + 置脏"成对发生(task-7 落地,lead 裁②消除两份置脏逻辑);adc_cal_set() 本身只写 RAM、不置脏、不落盘、不返回"稍后会存" App/algo/run_par.c 语义 setter 内 adc_cal_set() 之后才 s_dirty = 1u
谁落盘、什么时机 只有 defaultTask(配置引擎属主)的 run_par_store_save():iec104Task 主循环 run_par_dirty_peek() → 置 SYS_EVT_RUN_PAR_SAVE → 属主 cfg_file_save() 整档外科保存 → 成功清脏,失败留脏下轮重试 App/algo/run_par.c 落盘函数;本模块自身零文件 IO

cfg_file_poll() 不是保存路径:它的方向是盘 → RAM 的重载(外部改档 → 沉降去抖 → 重载,见 App/cfg/cfg_file.h 同族契约与 docs/子系统设计/配置文件设计说明.md)。拿它"兜住"未落盘的改值, 等于把 FTP 侧的旧档倒灌回 RAM,比不兜更坏。任何"改了就等于存了"的意图式陈述一律按本表改写 (docs/代码规范/pre-commit使用说明.md §2.5 第二条)。

"改了没落盘"现在是可诊断事实(第②项;先例是 App/fs/vol_lock.h 的观测计数设计:只计数、 不取锁、不在调用点打日志、不打 [ERR])。三个观测计数的定义只在 App/algo/adc_cal.h 一处, 本文只讲怎么读:

set_ok − persisted_set_ok run_par_dirty_peek() 定性
0 0 盘与 RAM 齐(或压根没改过)——健康
>0 1 改了、还没到落盘时机——正常在途
>0 0 改了且永远不会落盘:有写者绕过语义 setter 直调 adc_cal_set()

persisted_set_ok 是水位而不是计数:run_par_store_save() 成功路径上盖写为当时的 set_ok (一次 save 覆盖整档、可能对应多次逐通道整定,自增会留下"假未落盘";回归锁 = TC-ADCCAL-021)。 两个盲区写在这里,别当成"看得见全部":① 本层刻意不挂 cfg_file 回调,盘 → RAM 的覆盖不产生任何计数, 未落盘的改值被一次重载无声吞掉照不到;② 三个数之间没有共同时刻,定性只按上表成对读, 需要时刻请在消费者侧自盖并冠 obs_ 前缀(先例:喂狗点单写者 latch)。


11. 资源足迹(改容量前先看这里)

位置 量 说明
sample_ring.c s_ring[9][40](float) 1440 B 周波数 / 通道数一改就成倍变化
phasor.c s_zone[2][5](phasor_t) 160 B 随实例数线性
freq.c 相位增量环 fq_d[96] + 状态 ≈ 520 B 随 FREQ_AVG_TAPS 变化
prot_set.c s_val[2][40] + 封存 / uint64_t 位图 / 闭锁 336 B 项数 32 → 40(测频与遥测快照)后的量
prot_elm.c s_rt[9](16 B / 实例) 144 B 实例 7 → 9(测频与遥测快照)后的量
adc_input.c s_stm32_raw[16] + s_tpafe_raw[8] 48 B —
adc_input.c s_stm32_dma[2×9](uint16_t) 36 B 必须在 RW_IRAM1(DMA2 不可达 CCM)→ docs/子系统设计/ADC采样节拍设计说明.md §4.3
adc_health.c 内部状态 ≈ 40 B —
adc_cal.c 三个观测计数(volatile uint32_t) 12 B 只增不清零、不参与判定;定义与读法见 §10.1
运行期栈 phasor_run() 的 win[20] 80 B / 帧 抬高 PHASOR_N 直接抬栈
运行期栈 freq_run() 局部 ≈ 30 B / 帧 无大局部数组

改 SAMPLE_RING_CYCLES / 实例数 / ADC_SEM_COUNT / FREQ_AVG_TAPS / 栈之前后各跑一次 scripts/stack_report.py,新增缓冲跑 scripts/ram_report.py(口径见 mem-budget skill); adcTask 静态最坏链与余量的台账在 docs/内存管理/RAM使用分析与优化方案.md §2.3,本表不代记台账。


12. 验证与判读

12.1 主机侧单测(生产源文件以 #include 原样纳入,不改产品代码)

模块 套件钉死的东西 细则
phasor / sample_ring 系数表逐项复核、幅值与相位口径、谐波抑制与提取、频偏、采样环时序、双区交换 docs/测试说明/单元测试/相量层单元测试说明.md
adc_input 逐 Rank 注入互异码值钉死映射、HAL 启动失败与"本拍无新帧"分支、乒乓半区推导、gap / stall 分开计数 docs/测试说明/单元测试/ADC输入层单元测试说明.md
adc_health 100 ms 静默门限两侧、丢帧边沿、序号倒退锁存、错误增量、tick 回绕、NULL 守卫、init 幂等 test/host/adc_health/
prot_set 范围校验、all-or-nothing 装载、封存-自检-闭锁、Q10 往返、元数据表自检、blob 往返与损坏拒绝、换代拒绝、uint64_t bad_mask test/host/prot_set/
prot_elm 滞回与定时限、欠量方向、投退、闭锁联动、时基回绕、位图聚合、测频接线与"必不启动值" test/host/prot_elm/
freq J1~J6(§7.2)+ 直流与谐波抑制 + UA→IA 源回退 + 守卫与幂等 test/host/freq/
adc_cal / run_par Q16 无舍入与合法带含端点、坏文件/坏段/坏零点回退、真 cfg 引擎往返、未标定闭锁单点,以及 §10.1 那三态观测计数(含"一次 save 覆盖多次整定"的回归锁) test/host/adc_cal/、test/host/run_par/(用例清单在两个套件文件头,本文不复制条数)

命令与对应 pre-commit 钩子名见 App/algo/README.md §4(钩子清单的属主是 .pre-commit-config.yaml,用例清单的属主是各 test/host/<模块>/ 与上表细则文档——本文不复制数字, 免得三处打架)。

12.2 板端闭环

/keil-build 0 错误 0 警告 → /serial-boot-test 看 [ADC_TASK] 首拍与心跳:phasor(...) 行、freq(...) 行 (f=5000 valid=1,×0.01 定点,MicroLIB 无 %f)、ring / skip / adc1_* / tgap 计数;健康判据 = 零 [ERR](adc_health_report() 的告警全部以 [ERR] 开头 → 误报会被闭环 直接抓住)。浮空工况应为 valid = 0 且读数为保持值。

12.3 定值档"换代"与"损坏"是两件事(易误判)

  • prot_set_store_load() 返回 2 = 换代(旧固件 / 旧项数的档被版本闸拒绝)→ 只打 INFO prot set blob version change, defaults in use,不记自检;
  • 返回负值 = 真损坏 → [ERR] prot set blob CORRUPT + 自检留痕。

升级固件后首启看到 "version change" 一行属预期,不要当故障排查;反过来,把真损坏读成 换代等于放过 NAND 数据事故——分流依据是返回码,不是日志文本。


13. 相关文档

  • 目录索引与构建 / 测试入口:App/algo/README.md
  • 「采样有效性校验」判定层深设计(判据推导 / 盲区 / 验证分工):docs/算法/采样有效性校验设计说明.md
  • 分层与功能项清单(语义取数与有效性~动作留痕的属主登记处):docs/算法/继电保护功能复现规划.md
  • 通道映射权威口径:docs/硬件平台/ADC通道映射说明.md
  • 采样节拍与 DMA 搬运:docs/子系统设计/ADC采样节拍设计说明.md
  • 傅氏算法与有效值口径:docs/算法/傅氏算法详解.md §2.3
  • 主机单测细则:docs/测试说明/单元测试/相量层单元测试说明.md、docs/测试说明/单元测试/ADC输入层单元测试说明.md
  • RAM 与栈台账:docs/内存管理/RAM使用分析与优化方案.md §2.3