Files
STM32F4-Base/docs/算法/采集与算法层设计说明.md
T
wandering1 728d60675f feat(UI/采集/104): 通讯设置页 + 开机账本 + adc_cal 可诊断化——三车道同树一次构建入库
三条道在同一棵树上交付,故合并成**一次权威构建**(@build 0 错 0 警,Code=235236 RO=26308 RW=1572
ZI=136924)后同笔入库;台账已按该构建回填(`ram_baseline.py --update` + `--write`、`chec
k_mem_numbers.py
 --write`),凭证与实际 .map 同源。归属如下:

· **通讯设置页**(道G task-9):新增 `Drivers/BSP/UI/ui_page_comm.{c,h}`(通道六注入,与死
区页同骨架同纪
  律),`#page=comm` 绑定使 bound **21 → 22**、内容页黄金 **47 → 53**(旧 47 页字节零变化,
新增六态含
  WAIT/OK 两张);`iec104_comm` 脏位 + store_save(**无事件位**)挂 defaultTask 同点位
轮询;`port_104` 由
  宏改走 getter、`IEC104_SERV_PORT` 删除;字体 +听/监(185 字全覆)。**实机链已闭环**:FTP 外改
[comm]
  的 ca/port → 复位 → 串口出现 `section 'comm' (2 keys)` 与 `i104: listening o
n 9004` → 还原 2404,全程无
  FAIL ⇒「重启即生效 + 端口真值」不是推断是实测。上限核对 6/8 段、2/16 键,超限现象由 TC-CONFIG-056/057
  钉死(注册 -1 + `comm init: FAIL` 锚 + getter 回 ROM),**不抬宏**。
· **开机账本**(道F task-12):新增 `App/util/boot_stats.{c,h}`——上电次数落 SD2506 用户
 SRAM(magic +
  小端计数 + 版本 + 异或校验),一次上电 = 1 读 + ≤1 写,**零 NAND、零卷锁、零配置段**;四态互斥
  UNKNOWN / FRESH / CONTINUED / RESTARTED(BLF=1 才算电池域掉过电),**读失败绝不回写**;
文案口径「自 RTC
  电池周期起」,**禁写终身次数**。复位原因优先级链从 `app_main.c` 的 if-else 收进一份真源(串口与屏幕同
  源);104 四个只增不清计数 + `iec104_serv_get_stats()` 留在 serv、未塞进 link(会被 open
/close 洗掉)。
  运行时长按用户裁定 = **只报本次开机以来**,零新增代码。新套件 `test/host/boot_stats` 21/21,五处注入
  **全部有牙**(含「漂一字节被校验和挡下而不是报 256 倍错账」)。
· **adc_cal 可诊断化**(道E task-43):三个 volatile 计数 + `adc_cal_get_stats()`
/ `adc_cal_note_persisted()`
  (水位**盖写非自增**,因为一次 save 覆盖整档,数 save 会留假未落盘);「差额 × 脏位」三态语义唯一落在
  `adc_cal.h`,其中「差额 > 0 且脏位恒 0」= 绕过语义 setter 的静默丢失;TC-ADCCAL-019~023。

三条**必须留档的自纠**(都是执行道自己报的,我不替他们润色):
① stdint 那条断链是**宿主套件抓到、通讯道先修**,采集道承认自己是重复添加者并删掉自己那份;
② 「exit 2 不是证据,断言变红才是」——采集道第一版反证注入了未声明标识符导致编译错,差点当成功;
③ 套件汇总行原写 `s_total/s_total`,失败时会打印「21/21 PASSED (failed=1)」这种自相矛盾的行,已改。

另修:`check_registration.py` 的 `SIM_EXEMPT` 加一行豁免 `App/util/boot_stats.c
`(依赖 I2C 与 RCC 位掩码,
MCU 专属;核过 `sys_clock.c`/`sha256.c` 同形态先例),注册反向校验 exit 1 → 0。发号 TC-IEC1
04-034、
TC-UIDATA-029 已实抬游标(`TC-IEC104-033→034`、`TC-UIDATA-026→029`)。

门禁:id-check --strict 与 plain 均 rc=0、doc-links --all rc=0、注册校验 0、cppche
ck / coding-standard /
bom-drift 0;iec104 179/179、ui_data 295/295、boot_stats 21/21、adc_cal 11
7/117、run_par 67/67;菜单 8 状态
池 5368 不变、内容页 53/53。**已知遗留**:内容页数 47 → 53 的全仓文本同步尚未做(另卡跟踪),本笔只保
证代码与黄金一致;面板注键真机那一腿缺 CLI,交台架道补。
2026-10-27 11:29:23 +08:00

456 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 采集与算法层设计说明(`App/algo/`:L1 → L2 → L3)
> **模块名称**:自研采集与算法层,代码在 `App/algo/`(七个 `.c`,全部登记进 Keil 工程)。
> **文档定位**:**现状设计说明**——本层当前如何组织、每个模块的契约与关键机制、为什么这样设计、
> 边界与代价。**目录级索引(文件清单、构建与测试命令、新增模块动作)在 `App/algo/README.md`**,
> 本文不重复其内容;本文不写历史演进、门禁复测记录与修订日志(需要时查 git 历史)。
> **状态**:L1 → L2 → L3 主线已闭环到板端消费者(`protectTask`,20 ms 判据节拍);
> 标定折算层 `App/algo/adc_cal` 已于 2026-10-25 落地(`docs/任务规划/数据可信.md` 卡 2/4,
> 引脚电压量纲 + 系数落盘 + 未标定闭锁);**幅值精度判据**仍待标准源两点注入复核(卡 12)。
> **相关文档**:
> 分层与功能项(L1~L5)的唯一属主登记处:`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. 定位与边界](#1-定位与边界)
- [2. 一拍的数据流与初始化落点](#2-一拍的数据流与初始化落点)
- [3. L1 语义取数 `adc_input.c`](#3-l1-语义取数-adcinputc)
- [4. L1 有效性判定 `adc_health.c`](#4-l1-有效性判定-adchealthc)
- [5. L2 输入 `sample_ring.c`](#5-l2-输入-sampleringc)
- [6. L2 `phasor.c` 相量计算](#6-l2-phasorc-相量计算)
- [7. L2 派生量:频率测量 `freq.c`](#7-l2-派生量频率测量-freqc)
- [8. L3 输入:保护定值模型 `prot_set.c`](#8-l3-输入保护定值模型-protsetc)
- [9. L3 保护元件框架 `prot_elm.c`](#9-l3-保护元件框架-protelmc)
- [10. 关键约定与禁令](#10-关键约定与禁令)
- [11. 资源足迹(改容量前先看这里)](#11-资源足迹改容量前先看这里)
- [12. 验证与判读](#12-验证与判读)
- [13. 相关文档](#13-相关文档)
---
## 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/任务规划/测试与架构专项.md`「采集链路测试可验证性约束」),不需要真机就能钉死判据。
- **上下文约束**:`*_init()` 一律无 RTOS 依赖、幂等,由 `app_main_init()` 在**内核启动前**调用;
`adc_input_poll()` / `phasor_run()` / `freq_run()` **仅任务上下文**(快照走临界区)。
- **消费者在 `App/task/`**:`adcTask` 采集并压环、`protectTask`(B5)跑 `prot_elm_run()`;
新算法一律落在 `App/algo/`,**勿塞进 `App/task/`**(任务只做消费与编排)。
---
## 2. 一拍的数据流与初始化落点
一拍 = TIM3 的一个 1 kHz 更新事件(节拍与消费者唤醒同源,见
`docs/子系统设计/ADC采样节拍设计说明.md` §1)。
```mermaid
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. L1 语义取数 `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. L1 有效性判定 `adc_health.c`
**解决的问题**:驱动侧与 L1 只有"观测计数"(`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. L2 输入 `sample_ring.c`
**解决的问题**:相量计算要"以当前时刻为终止的一周波",而 L1 每次只给最新一帧。本层按**语义通道**
存最近 `SAMPLE_RING_CYCLES = 2` 个周波(`SAMPLE_RING_DEPTH = 40` 点),与参考系统
`CN_BUFSAM_CYCLE = 2` 一致,并为后续加长数据窗(1.5~2 周波抗衰减直流)预留空间。
| 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()` 对照即判丢帧 |
**要点**
- **整帧写入后才统一推进写指针** → 单帧内不可能"半新半旧",消费侧取到的窗永远落在整帧边界。
- 本层**只做按时序存 / 取,不做任何跨通道运算**:同刻性由上游 L1 负责,本层既不保证也不假设。
判定也不在本层(本层只出计数)。
- 某通道本拍取数失败 → **整拍跳过入环**(现 `adc_task.c` 的 `s_frames_skip` 即此),
**切勿补 0 点**——单个 0 点会显著污染 20 点 DFT 窗。
---
## 6. L2 `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. L2 派生量:频率测量 `freq.c`
**解决的问题**:过频 / 低频(`docs/算法/继电保护功能复现规划.md` §3.3 「过频 / 低频」)需要系统频率读数,
而 L2 只出相量。本层把"相邻两拍基波相量的相位差"变成"频率",并解决**非整周波窗长的 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`;45~55 Hz 段 |Δφ| ≈ 0.28~0.35 rad,距 ±π 裕度巨大。
- **定长窗为什么不够(自适应窗的理由)**:20 点矩形窗在非整周波时混入**负序泄漏项**
(45 / 55 Hz 处 |S₂/S₁| ≈ 3~5 %),相量端点轨迹成椭圆 → 逐拍相位差带上周期 `δ(φ)`
纹波,纹波周期 = `fs/(2f)` ≈ 9.1~11.1 拍。**定长平均不整除纹波周期**,残差留在均值里:
扫频最坏 0.317 Hz(40 拍算术平均)、0.197 Hz(60 拍复数和的 Kay 法——交叉项的和角不随
`K²` 衰减,**勿再照这条路优化**)。改 `K_eff = round(8·Fs / (2·f̂))`(45~55 Hz 段 73~89 拍)
使窗长恰为纹波周期的整数倍 → 纹波相消,扫频最坏降到 **0.0101 Hz**。
- **假值钳制带取 35~65 Hz,而不是系统合理带**:单拍相位差本身带 ±5 Hz 纹波,钳制带贴边会
**非对称丢弃纹波半周** → 均值偏置(实测 45 Hz 点 +0.16 Hz,已修)。35~65 Hz 宽于系统合理带
40.5~60.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 卡 6 语义重定为**二次值 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. L3 输入:保护定值模型 `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)` | **原子写 + 封存**:影子区改好重封后单字交换 | 自检永不见中间态(收口 B5 发现的竞态窗口);置脏标志 |
| `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 八项尾追加**(B8,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 不同量级,**勿共用一个返回系数**。
- **换代语义**(B7 → B8):项数变 → 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. L3 保护元件框架 `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()` | 状态快照 / 动作位图,供 L4 出口与心跳日志 |
| `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` 测频,B8)。**零序过流(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()` 位宽(B8: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` 卡 12):帧值现 = 引脚侧工程量(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(B8)后的量 |
| `prot_elm.c` `s_rt[9]`(16 B / 实例) | 144 B | 实例 7 → 9(B8)后的量 |
| `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 板端闭环
`@build` 0 错误 0 警告 → `@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`
- 分层与功能项清单(L1~L5 的属主登记处):`docs/算法/继电保护功能复现规划.md`
- 通道映射权威口径:`docs/硬件平台/ADC通道映射说明.md`
- 采样节拍与 DMA 搬运:`docs/子系统设计/ADC采样节拍设计说明.md`
- 傅氏算法与有效值口径:`docs/算法/傅氏算法详解.md` §2.3
- 主机单测细则:`docs/测试说明/相量层单元测试说明.md`、`docs/测试说明/ADC输入层单元测试说明.md`
- RAM 与栈台账:`docs/内存管理/RAM使用分析与优化方案.md` §2.3