diff --git a/docs/README.md b/docs/README.md index 7cc7d007..c450567f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -125,6 +125,8 @@ docs/ | [`看门狗单元测试说明.md`](测试说明/看门狗单元测试说明.md) | Task Watchdog 判定状态机(位图 / 掩码 / 节流 / tick 回绕),39 用例 | | [`系统时钟单元测试说明.md`](测试说明/系统时钟单元测试说明.md) | sys_clock 时间换算 / 星期 Zeller / 30 天惰性再基准化 / RTC 故障降级,100 断言点 | | [`配置文件单元测试说明.md`](测试说明/配置文件单元测试说明.md) | cfg_file INI 解析 / 值编解码 / load-save-poll 契约 / 超长守卫,139 断言点(含抓出的外科手术保存死码 P0) | +| [`ADC输入层单元测试说明.md`](测试说明/ADC输入层单元测试说明.md) | adc_input 语义通道映射 / ADC1 顺序扫描(**Rank↔通道判据**)/ poll 契约 / HAL 故障路径,70 用例 | +| [`相量层单元测试说明.md`](测试说明/相量层单元测试说明.md) | phasor 全周傅氏系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换,57 用例 | **模块测试指南**(动手写测试前先读) diff --git a/docs/测试说明/ADC输入层单元测试说明.md b/docs/测试说明/ADC输入层单元测试说明.md new file mode 100644 index 00000000..39b60909 --- /dev/null +++ b/docs/测试说明/ADC输入层单元测试说明.md @@ -0,0 +1,265 @@ +# ADC 输入层单元测试说明(主机侧) + +> **模块**:ADC Input Layer(L1 采集与预处理,`App/algo/adc_input.c`) +> **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 PC 上离线验证语义通道映射、 +> STM32 ADC1 顺序扫描、TPAFE 快照消费与取数守卫,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 +> **状态**:✅ 已落地(70 用例,`/W4` 0 告警,已挂 pre-commit 钩子) +> **日期**:2026-10-10 +> **关联文档**: +> [`../../AGENTS.md`](../../AGENTS.md)(平台约束 · CubeMX 生成区铁律)、 +> [`测试开发标准流程.md`](测试开发标准流程.md)(测试分层与门禁总览)、 +> [`配置文件单元测试说明.md`](配置文件单元测试说明.md)(同层"强符号桩"范本)、 +> [`相量层单元测试说明.md`](相量层单元测试说明.md)(同批落地的 L2 姊妹篇)、 +> [`../硬件平台/ADC通道映射说明.md`](../硬件平台/ADC通道映射说明.md)(**语义映射权威口径**)、 +> [`../算法/继电保护功能复现规划.md`](../算法/继电保护功能复现规划.md)(L1-3 / L1-4 设计依据)、 +> [`../问题记录/错误码传播审计.md`](../问题记录/错误码传播审计.md)(E-44 / E-58 快照语义) + +--- + +## 1. 概述 + +### 1.1 为什么单独做主机侧单测 + +`adc_input.c` 把两片转换器的原始码值按**语义**统一暴露,其正确性的核心是三者一致: + +| 半边 | 内容 | 可测性 | +|---|---|---| +| **纯逻辑**(被测) | ① 语义通道映射表(L1-4):`UA/UB/UC/UN/IA/IB/IC/IN/TEMP` → `(来源, 下标)`;② STM32 ADC1 9 通道软件触发顺序扫描(L1-3):**Rank 顺序 ↔ ADC1 通道号 ↔ 语义**;③ 取数守卫与 poll 契约;④ 扫描状态码 / 失败计数 | 纯查表 + 状态机,与硬件无关 → **可在 PC 上位穷举** | +| **硬件副作用** | `HAL_ADC_Start` / `PollForConversion` / `GetValue` / `Stop`、`tpafe5160_snapshot()`(临界区) | 均为**外部符号** → 主机侧给同名强符号桩即可 | + +板端套件(`test/board/`)**无法验证本层的核心判据**: + +- **通道映射**:板端**无信号源**(见 `docs/硬件平台/ADC通道映射说明.md` §8),无法注入已知电压去 + 验证"读到的是哪只脚"。而映射表一旦写错(本项目 V0.1 有 13/17 路被置换),读到的会是 + **另一只脚**或**恒为陈旧值**,且**不会有任何报错**——这正是本层最危险的失效模式。 +- **HAL 故障路径**:`HAL_ADC_Start` 失败、转换轮询超时(2 ms)在真机上极难稳定复现。 + +主机侧的做法:给**每个 Rank 注入互不相同的码值**(第 i 个 Rank 读到 `100+i`),把 +"Rank 顺序 ↔ ADC1 通道号 ↔ 语义"整条链路**逐通道钉死**;故障路径用可注入桩一次覆盖。 + +> ⚠ 本单测**只覆盖 L1-3 / L1-4**。L1-5(通道标定)本层不做,故取到的是**未标定原始码值**; +> L1-1 / L1-2(周期触发与 TPAFE 快照链路)由 `adc_task` + TPAFE 驱动承担,板端套件覆盖。 + +### 1.2 被测对象与桩(**不改生产代码**) + +`adc_input.c` 的全部平台依赖都是**外部符号**,故测试单元只需提供**同名强符号桩**, +**生产代码零改动**(无任何 `#ifdef` 测试钩子)。 + +``` +test/host/adc_input/adc_input_test.c + ├── #include "adc.h" → stub/adc.h (HAL_ADC_* 原型 + hadc1 + ADC_CHANNEL_0..15) + ├── #include "tpafe5160.h" → stub/tpafe5160.h (仅 tpafe5160_snapshot 原型) + └── #include "adc_input.c" ← 生产源文件(App/algo/),原样纳入 +``` + +> **`adc_input.h` 用真实头**:它只依赖 ``,不拖 HAL,故接口契约原汁。 +> **`adc.h` / `tpafe5160.h` 必须用桩**:真实 `Inc/adc.h` 经 `main.h` 拖入整套 HAL; +> 真实 `Drivers/BSP/TPAFE5160/tpafe5160.h` 拖入 GPIO 宏——主机侧无法编译。 +> **桩目录必须排在 `App/algo` 之前**(`include_dirs` 顺序敏感,见 `run_unit_test.py`)。 + +| 平台接口 | MCU 端 | 主机侧桩 | +|---|---|---| +| `hadc1` | `MX_ADC1_Init()` 配置的 ADC1 句柄 | 不透明占位对象(被测代码只透传指针,从不读字段) | +| `HAL_ADC_Start` | 软件触发一次 9 通道扫描 | 返回 `s_start_rc`(可注入 `HAL_ERROR`);复位轮询/取数计数器 | +| `HAL_ADC_PollForConversion` | 逐 Rank 轮询 EOC(2 ms 超时) | 第 `s_poll_fail_at` 次(0 基)返回 `HAL_ERROR`;`-1` = 永不失败 | +| `HAL_ADC_GetValue` | 读 DR | 返回 `s_poll_vals[s_rank_idx]`(默认 `100 + rank`) | +| `HAL_ADC_Stop` | 停止转换 | 计次 `s_stop_calls` | +| `tpafe5160_snapshot` | 临界区原子快照最新一帧 | 返回 `s_snapshot_rc`(1/0/-1);`rc==1` 时从 `s_fake_tpafe[]` 拷贝;记录 `count` 与 `dst` | + +桩里 `ADC_CHANNEL_0..15` 的**宏值等于通道号**(F4 HAL 即如此),故 `(uint8_t)ADC_CHANNEL_14 == 14` +与真机一致——这是"映射判据"能成立的前提。 + +### 1.3 测试环境 + +| 依赖 | 版本 / 说明 | +|---|---| +| 编译器 | 本机 MSVC(VS2019/2022/2026 任一),由 `run_unit_test.py` 自动探测 | +| Python | 仓库 `.venv` 或任意 python3(**纯标准库**) | +| 硬件 | 无(不需要开发板 / 串口 / ST-LINK / 信号源) | + +工具链探测 / `INCLUDE`-`LIB` 拼接 / 编译运行 / 退出码判定全部委托通用 harness +[`../../test/host/common/unit_test_harness.py`](../../test/host/common/unit_test_harness.py), +本模块 `run_unit_test.py` 只**声明** `TestBuild`(源文件 / 桩目录 / 汇总行标记)。 + +### 1.4 运行方式 + +```powershell +# 仓库根目录(统一用 .venv) +& '.venv\Scripts\python.exe' test/host/adc_input/run_unit_test.py +``` + +退出码:`0`=PASS / `1`=测试失败 / `2`=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +### 1.5 回归接入 + +| 项 | 说明 | +|---|---| +| **触发范围** | `App/algo/adc_input.c` / `adc_input.h`,或 `test/host/adc_input/**`、`test/host/common/unit_test_harness.py` 变动 | +| **门禁层级** | **L0.5 层**(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑 | +| **pre-commit 钩子** | `adc-input-unit-test`(本地钩子,`.pre-commit-config.yaml` §7e) | +| **汇总行** | `=== ADC Input Tests: 70/70 PASSED (failed=0) ===` | +| **与板端测试的关系** | 互补:本单测保证**映射 / 契约 / 故障路径**正确;板端保证 **HAL 配置(Rank 顺序、采样时间、引脚)** 与真机一致 | + +--- + +## 2. 测试用例 + +> 命名 `TC-AI-NNN`(AI = Adc Input;主机侧单测自定前缀,与板端套件缩写表互不影响)。 +> 全部在 `adc_input_test.c` 内,主机侧运行。被测源文件被 `#include` 纳入同一编译单元, +> 故**静态函数(`map_check` / `stm32_scan`)亦可直接调用**(白盒)。 + +### 2.1 初始化与复位(`adc_input_init`) + +| 用例 | 断言 | 验证的设计点 | +|---|---|---| +| **TC-AI-001** | 前置:注入 `HAL_ADC_Start` 失败 → poll 后失败计数 = 1 | 统计可见性(前置构造) | +| **TC-AI-002** | 前置:注入 TPAFE 有效帧 → poll 后 `UA` 可取数且值正确 | 取数通路(前置构造) | +| **TC-AI-003** | `adc_input_init()` 返回 0 | 映射表自检通过 | +| **TC-AI-004** | init 后 `adc_input_stm32_rc() == ADC_INPUT_RC_NOT_RUN` | 状态码复位 | +| **TC-AI-005** | init 后 `adc_input_stm32_errs() == 0` | 失败计数归零 | +| **TC-AI-006** | init 后 TPAFE / STM32 **两侧** `adc_input_raw()` 均返回 -2 | 有效标志被清(不会取到上一轮陈旧值) | + +### 2.2 语义映射表(`adc_input_desc`) + +| 用例 | 断言(语义 → 来源 / 下标) | 权威口径 | +|---|---|---| +| **TC-AI-010** | `UA` → TPAFE `buf[6]` | `docs/硬件平台/ADC通道映射说明.md` | +| **TC-AI-011** | `UB` → STM32 `CH14` | 同上 | +| **TC-AI-012** | `UC` → STM32 `CH3` | 同上 | +| **TC-AI-013** | `UN` → TPAFE `buf[5]` | 同上 | +| **TC-AI-014** | `IA` → TPAFE `buf[2]` | 同上 | +| **TC-AI-015** | `IB` → STM32 `CH6` | 同上 | +| **TC-AI-016** | `IC` → STM32 `CH1` | 同上 | +| **TC-AI-017** | `IN` → STM32 `CH2` | 同上 | +| **TC-AI-018** | `TEMP` → STM32 `CH5`(V_ZL2) | 同上 | +| **TC-AI-019** | `adc_input_desc(ADC_SEM_COUNT)` → `NULL` | 越界防御 | +| **TC-AI-01A** | `adc_input_desc((adc_semantic_t)-1)` → `NULL` | 无符号比较兜底 | + +### 2.3 映射表自检(`map_check`,静态) + +| 用例 | 断言 | 验证的设计点 | +|---|---|---| +| **TC-AI-020** | `map_check() == 0` | 真实映射表自洽:TPAFE 下标 < 8;每个 STM32 映射通道都出现在扫描 Rank 内 | + +> 反向路径(表不自洽 → -1)**结构上不可达**,见 §4.1。 + +### 2.4 STM32 ADC1 顺序扫描(`stm32_scan`,静态) + +| 用例 | 断言 | 验证的设计点 | +|---|---|---| +| **TC-AI-021** | 全通道成功 → 返回 0 | 正常路径 | +| **TC-AI-022** | `adc_input_stm32_rc() == RC_OK` | 状态码 | +| **TC-AI-023** | 成功不计入失败计数 | 统计语义 | +| **TC-AI-024** | `HAL_ADC_Start` / `HAL_ADC_Stop` 各一次 | 外设生命周期成对 | +| **TC-AI-025** | 轮询 9 次、取数 9 次(Rank1..9) | 扫描通道数 | +| **TC-AI-026** | ⭐ **Rank → 通道号映射**:`UB` = CH14 / rank9 = 108 | **本层核心判据**(板端无法验) | +| **TC-AI-027** | `UC` = CH3 / rank4 = 103 | 同上 | +| **TC-AI-028** | `IB` = CH6 / rank7 = 106 | 同上 | +| **TC-AI-029** | `IC` = CH1 / rank2 = 101 | 同上 | +| **TC-AI-02A** | `IN` = CH2 / rank3 = 102 | 同上 | +| **TC-AI-02B** | `TEMP` = CH5 / rank6 = 105 | 同上 | +| **TC-AI-02C** | 扫描后 `UA`(TPAFE)仍为 -2 | 两来源互相独立 | +| **TC-AI-030** | `HAL_ADC_Start` 返回 `HAL_ERROR` → 返回 -1 | 启动失败 | +| **TC-AI-031** | 状态码 = `RC_START` | 故障可观测 | +| **TC-AI-032** | 失败计数 +1 | 故障可观测 | +| **TC-AI-033** | 启动失败**不**调用 `Stop` | 不对外设做多余操作 | +| **TC-AI-034** | 启动失败后 STM32 侧仍无有效数据 | 不产出半帧 | +| **TC-AI-035** | 第 5 个 Rank 轮询超时 → 返回 -2 | 超时路径 | +| **TC-AI-036** | 状态码 = `RC_TIMEOUT` | 故障可观测 | +| **TC-AI-037** | 超时计入失败计数 | 故障可观测 | +| **TC-AI-038** | 超时前已 `Start` → 调 `Stop` 收尾 | 资源不泄漏 | +| **TC-AI-039** | 超时后**立即退出**(只轮询 5 次,不继续扫完) | 不长时间阻塞 1 ms 保护节拍 | +| **TC-AI-039b** | 超时后 STM32 侧无有效数据 | 不产出半帧 | +| **TC-AI-03A** | 再失败一次 → 计数 = 2 | 跨次单调累计 | + +### 2.5 取数与参数守卫(`adc_input_raw`) + +| 用例 | 断言 | 验证的设计点 | +|---|---|---| +| **TC-AI-040** | `out == NULL` → -1 | 参数防御 | +| **TC-AI-041** | `ch == ADC_SEM_COUNT` → -1 | 越界防御 | +| **TC-AI-042** | `ch == (adc_semantic_t)-1` → -1 | 负数兜底 | +| **TC-AI-043** | 参数错时**不写** `*out` | 无副作用 | +| **TC-AI-044** | TPAFE 无有效帧 → -2 | 有效标志语义 | +| **TC-AI-045** | STM32 无有效扫描 → -2 | 有效标志语义 | +| **TC-AI-049** | `UA` → `buf[6]` 的值(注入 3006) | 按**下标**取数(非按枚举序) | +| **TC-AI-04A** | `UN` → `buf[5]` 的值(注入 2005) | 同上 | +| **TC-AI-04B** | `IA` → `buf[2]` 的值(注入 **-1234**) | 16 位补码负值原样保留(不截断 / 不取绝对值) | + +### 2.6 采集一拍(`adc_input_poll`) + +| 用例 | 断言 | 验证的设计点 | +|---|---|---| +| **TC-AI-046** | 快照返回 1 → `poll()` 返回 1 | 返回码透传 | +| **TC-AI-047** | 快照被请求 **8** 通道 | 与 `ADC_INPUT_TPAFE_CH_NUM` 一致 | +| **TC-AI-048** | 快照目的缓冲非空 | 不传 NULL 给驱动 | +| **TC-AI-050** | 快照返回 0(无新帧)→ `poll()` 返回 0 | 无新帧语义 | +| **TC-AI-051** | 无新帧时 TPAFE 侧无效 | 不置有效标志 | +| **TC-AI-052** | 同拍 STM32 扫描**照常执行**(`rc == RC_OK`) | 两侧独立 | +| **TC-AI-053** | 快照返回 -1(参数错)→ `poll()` 返回 -1 | 错误透传 | +| **TC-AI-054** | 参数错时 TPAFE 侧无效 | 不置有效标志 | +| **TC-AI-055** | 已有有效帧后再"无新帧" → **保持**上次有效值 | 有效标志只置不清 | +| **TC-AI-056** | STM32 失败**不影响** `poll()` 返回值(仍为 1) | 两侧解耦 | +| **TC-AI-057** | STM32 失败不影响 TPAFE 取数 | 同上 | +| **TC-AI-058** | STM32 失败另由 `adc_input_stm32_rc()` 暴露 | 故障通道分离 | + +### 2.7 状态码与失败计数观测 + +| 用例 | 断言 | +|---|---| +| **TC-AI-060** | init 后未扫描 → `RC_NOT_RUN(-3)` | +| **TC-AI-061** | init 后失败计数 = 0 | +| **TC-AI-062** | 一次成功后 `rc == OK` 且 `errs` 不变 | +| **TC-AI-063** | 两次失败累计 `errs == 2` | +| **TC-AI-064** | `rc` = **最近一次**(`RC_TIMEOUT`) | +| **TC-AI-065** | 恢复成功后 `rc == OK` | +| **TC-AI-066** | `errs` **单调不回退**(不因成功回落) | + +**当前结果**:70/70 PASSED(`failed=0`)。 + +--- + +## 3. 如何扩展用例 + +1. 在 `adc_input_test.c` 加一个 `static void tc_xxx(void)`; +2. 用例开头调 `fakes_reset()`(把全部桩复位到"健康"初值 + 复位观测计数); +3. 用 `s_start_rc` / `s_poll_fail_at` / `s_poll_vals[]` 注入 HAL 行为; + 用 `s_snapshot_rc` / `s_fake_tpafe[]` 注入 TPAFE 帧; +4. `CHECK(cond, "描述")` 断言;在 `main()` 里注册(并同步更新本文档 §2 与变更记录)。 + +> ⚠ `fakes_reset()` 必须在新用例开头调用:桩计数器(`s_poll_calls` / `s_rank_idx` / +> `s_start_calls` / `s_stop_calls` / `s_snapshot_calls`)跨用例共享,不复位会互相污染。 +> +> ⚠ 模块静态态(`s_stm32_raw` / `s_tpafe_raw` / `s_*_valid` / `s_stm32_rc` / `s_stm32_errs`) +> 由 `adc_input_init()` 复位;**每个用例开头都应调一次 `adc_input_init()`**(等价"刚上电")。 + +--- + +## 4. 已知边界与设计取舍 + +### 4.1 `map_check()` 反向路径结构上不可达 + +`map_check()` 的失败分支要求"映射表指向 TPAFE 下标 ≥8,或 STM32 通道号不在扫描 Rank 内"。 +但 `k_adc_map[]` 与 `k_stm32_rank_ch[]` 都是**编译期 `static const`**,没有任何运行时输入 +能使其不自洽(曾尝试用 `#define` 重定向表名,会与生产文件内的定义撞成重复定义)。 +因此本单测**只覆盖正向路径**(`map_check() == 0`);反向路径的等效保障是 +`adc_input_init()` 的返回值——它在开机时自检并留痕(见 `app_main.c`)。 + +### 4.2 本层不做标定(L1-5 待办) + +`adc_input_raw()` 返回的是**未标定原始码值**(TPAFE = 16 位补码,STM32 = 0..4095)。 +一次系数 / 零漂标定属 **L1-5,尚未实现**,故本单测不对"物理量换算"做任何断言。 + +### 4.3 不覆盖 V0.1 的硬件缺陷(N-18) + +V0.1 上 8 路电气量分属两片转换器且**无共同采样触发**(N-18),本层只做**逐通道独立监视**。 +"跨转换器相量合成"(线 / 负序 / 零序)不得在本层或 L2 上做——属 L2-5 且 V0.1 受限 +(见 `docs/算法/继电保护功能复现规划.md` §1.6)。 + +--- + +## 5. 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-10-10 | 创建;70 用例全绿(`/W4` 0 告警);覆盖 `adc_input.c` 全部 8 个函数(6 公开 + 2 静态);挂 pre-commit 钩子 `adc-input-unit-test` | diff --git a/docs/测试说明/相量层单元测试说明.md b/docs/测试说明/相量层单元测试说明.md new file mode 100644 index 00000000..0f4e2eee --- /dev/null +++ b/docs/测试说明/相量层单元测试说明.md @@ -0,0 +1,210 @@ +# 相量层单元测试说明(主机侧) + +> **模块**:Phasor Layer(L2 相量计算,`App/algo/phasor.c` + `App/algo/sample_ring.c`) +> **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 PC 上离线验证全周傅氏相量计算与 +> 采样环缓冲,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 +> **状态**:✅ 已落地(57 用例,`/W4` 0 告警,已挂 pre-commit 钩子) +> **日期**:2026-10-10 +> **关联文档**: +> [`../../AGENTS.md`](../../AGENTS.md)(平台约束)、 +> [`测试开发标准流程.md`](测试开发标准流程.md)(测试分层与门禁总览)、 +> [`ADC输入层单元测试说明.md`](ADC输入层单元测试说明.md)(同批落地的 L1 姊妹篇)、 +> [`../算法/傅氏算法详解.md`](../算法/傅氏算法详解.md)(**系数约定权威口径** §2.3)、 +> [`../算法/继电保护功能复现规划.md`](../算法/继电保护功能复现规划.md)(§3.2 L2 设计点 / §10.2 术语表)、 +> [`../硬件平台/ADC通道映射说明.md`](../硬件平台/ADC通道映射说明.md)(L1 输入口径 / N-18 约束) + +--- + +## 1. 概述 + +### 1.1 为什么单独做主机侧单测 + +`App/algo/` 是本项目**首个纯算法层**(L1/L2 落点)——**无寄存器、无 RTOS、无文件 / 外设依赖**, +因此天然适合离线穷举。板端套件(`test/board/`)**无法验证本层的核心判据**: + +- 板端**无信号源**,无法注入已知**幅值 / 相位 / 频率**的标准波形,因而无法验证 + "读数 = 设定值"这一根本判据(同 L1-3 的替代口径,见 `docs/硬件平台/ADC通道映射说明.md` §8); +- 谐波抑制、频率敏感度、滑动窗恒定性、双区无撕裂等特性在板上**无稳定激励源**可造。 + +主机侧可**合成任意波形**(含直流、任意次谐波、频偏、任意初相),把上述特性一次性钉死, +且**不占 MCU 的 RAM**(本项目 RAM 预算见 `docs/内存管理/RAM使用分析与优化方案.md`)。 + +### 1.2 被测对象(**无需任何桩**) + +两个源文件都是纯算法,测试单元直接 `#include` 即可,**没有桩目录**: + +``` +test/host/phasor/phasor_test.c + ├── #include "sample_ring.c" ← 生产源文件(App/algo/),原样纳入(采样环,L2 输入) + └── #include "phasor.c" ← 生产源文件(App/algo/),原样纳入(相量计算,L2 核心) +``` + +`include_dirs` 只需 `App/algo` + 本目录。源文件被纳入同一编译单元,故**静态量亦可直接观测** +(白盒):`s_zone[2][N]`、`s_use_zone`、`s_ready`、`k_fft_sin` / `k_fft_cos`、`phasor_zero()`。 + +### 1.3 关键约定:系数表已预乘 √2/N("直接即有效值") + +权威口径 `docs/算法/傅氏算法详解.md` §2.3:系数为 +`sin(2πk/N)·(√2/N)`、`cos(2πk/N)·(√2/N)`(N = 20)。因此 `fRe = a₁/√2`、`fIm = b₁/√2`,于是 + +- **`fAmSQ = √(fRe² + fIm²)` 直接就是有效值(RMS)**,无需再除 √2; +- `fAm = √2 · fAmSQ` 是正弦峰值。 + +判据是归一化恒等式 **`Σsin² = Σcos² = 1/N`**(`TC-PH-001/002` 逐项校验,防转录错)。 +术语权威见 `docs/算法/继电保护功能复现规划.md` §10.2: +`{fRe, fIm, fAm, fAmSQ}` = 实部 / 虚部 / **幅值** / **有效值**。 + +> 采样参数:N = 20 点/周波 @ 50 Hz → 1000 Hz,保护节拍 1 ms(与 L1 采样节拍同源)。 +> 系数表为**离线生成的 float 常量**(运行期不调 `sinf` / `cosf`),故主机侧与 MCU 侧 +> 使用**逐位相同**的系数——这是本单测结论能外推到真机的前提。 + +### 1.4 测试环境 + +| 依赖 | 版本 / 说明 | +|---|---| +| 编译器 | 本机 MSVC(VS2019/2022/2026 任一),由 `run_unit_test.py` 自动探测 | +| Python | 仓库 `.venv` 或任意 python3(**纯标准库**) | +| 硬件 | 无(不需要开发板 / 串口 / ST-LINK / 信号源) | + +工具链探测 / `INCLUDE`-`LIB` 拼接 / 编译运行 / 退出码判定全部委托通用 harness +[`../../test/host/common/unit_test_harness.py`](../../test/host/common/unit_test_harness.py), +本模块 `run_unit_test.py` 只**声明** `TestBuild`(源文件 / 汇总行标记)。 + +### 1.5 运行方式 + +```powershell +# 仓库根目录(统一用 .venv) +& '.venv\Scripts\python.exe' test/host/phasor/run_unit_test.py +``` + +退出码:`0`=PASS / `1`=测试失败 / `2`=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +### 1.6 回归接入 + +| 项 | 说明 | +|---|---| +| **触发范围** | `App/algo/phasor.c` / `phasor.h`、`App/algo/sample_ring.c` / `sample_ring.h`,或 `test/host/phasor/**`、`test/host/common/unit_test_harness.py` 变动 | +| **门禁层级** | **L0.5 层**(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑 | +| **pre-commit 钩子** | `phasor-unit-test`(本地钩子,`.pre-commit-config.yaml` §7d) | +| **汇总行** | `=== Phasor Tests: 57/57 PASSED (failed=0) ===` | +| **与板端测试的关系** | 互补:本单测保证**算法正确性**;板端保证 **1 kHz 采样节拍与 TPAFE 快照链路**(L1-1 / L1-2)正常 | + +--- + +## 2. 测试用例 + +> 命名 `TC-PH-NNN`(PH = PHasor)。用例内的输出标签使用 `L2-x` 前缀,对应 +> `docs/算法/继电保护功能复现规划.md` §3.2 的设计点编号。部分用例直接观测静态量(白盒)。 + +### 2.1 L2-1 全周傅氏系数表 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-001** | 逐项:`sin[k] == sin(2πk/N)·(√2/N)`、`cos[k] == cos(2πk/N)·(√2/N)`(k = 0..19) | L2-1 防转录错 | +| **TC-PH-002** | 归一化 `Σsin² == Σcos² == 1/N`;精确零点 `sin@0,10`、`cos@5,15`;`sin[5] == √2/N` | L2-1 判据基础 | + +### 2.2 L2-3 / L2-4 基波幅值 / 有效值 / 相位 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-003** | 纯基波:`fAmSQ == A/√2`(**有效值**)、`fAm == √2·fAmSQ`(幅值),误差 ≤ 1% | L2-3 | +| **TC-PH-004** | `atan2(fIm, fRe)` == 注入相角(6 组幅值 / 相位) | L2-4 | + +### 2.3 直流与谐波抑制 / 任意次谐波提取 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-005** | 直流 + 2 / 3 / 5 次谐波被**完全滤除**(基波读数不变,误差 ≤ 1%,5 例) | L2-1 | +| **TC-PH-006** | 任意次谐波提取(**索引步进 = 谐波次数**):k = 1 / 2 / 5 从混合信号中提取;纯基波下 2 / 5 次分量 ≈ 0 | L2-1 / L1-9 入口 | + +### 2.4 频率敏感度 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-007** | 幅值误差:50 Hz ≤ 0.1%、±1 Hz ≤ 1.5%、±2 Hz ≤ 3%(**最坏相位**) | L2-3 / L1-7 依据 | + +### 2.5 L2 输入采样环(`sample_ring.c`) + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-010** | `init` 清填充与帧计数;未攒满取窗 → -2;入环后取窗**按时序**(老 → 新);通道之间**互相独立**;填充量饱和于 `DEPTH` 但帧计数**不**饱和;回绕后取窗 == 最后 N 个值(有序);参数守卫:`n=0` / `n>DEPTH` / 越界通道 / `NULL out` / `NULL frame` / 错帧长 均拒绝 | L2 输入 | + +### 2.6 L2-2 实例层 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-020** | 实例数 == 5;实例表可取 `name`;越界实例 → `NULL`;未攒满一周波 `phasor_get()` → -2;越界 index / `NULL out` → -1;攒满 20 帧后 `get()` == 0 且实例 0 的 RMS == `A/√2`;谐波实例(`C04-IA-F2` / `C05-IA-F5`)在纯基波下 ≈ 0;**逐实例复核"实例窗 == 独立 `phasor_calc()` 结果"** | L2-2 | + +### 2.7 L2-6 滑动窗 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-021** | 连续滑动 20 拍,基波读数**恒定**(误差 ≤ 1%) | L2-6 | + +### 2.8 L2-7 双区指针原子交换 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-022** | 使用区逐次产出**交替** 0 → 1 → 0 → 1;**连测 60 拍**:每拍 `get()` == 当前环窗的独立重算(幅值每 20 拍在 100 / 10 之间切换 → 读到陈旧区会立刻不相等);扫描后结果仍可读 | L2-7 | + +### 2.9 `phasor_zero` / `phasor_init` 清零与幂等 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-030** | 直接白盒调 `phasor_zero()` → 四字段(含 `fAm` / `fAmSQ`)全清零 | 静态辅助 | +| **TC-PH-031** | 先把双区写脏 → `phasor_init()` 后**双区全零** | init 清零 | +| **TC-PH-032** | `phasor_init()` 后使用区复位为 0 | init 复位 | +| **TC-PH-033** | `phasor_init()` 后就绪标志清(`phasor_get()` → -2) | init 复位 | +| **TC-PH-034** | 重复 `phasor_init()` 幂等 | 可重复调用 | + +**当前结果**:57/57 PASSED(`failed=0`)。 + +--- + +## 3. 如何扩展用例 + +1. 加一个 `static void tc_xxx(void)`;用 `gen_sine()` 造正弦窗、`frame_at()` 造整帧(喂实例层); +2. 驱动实例层三步:`sample_ring_push_frame()` → `phasor_run()` → `phasor_get()`; +3. `CHECK(cond, "描述")` 断言;在 `main()` 里注册(并同步更新本文档 §2 与变更记录)。 + +> ⚠ 实例层是**全局态**(双区 + 采样环),跨用例必须先 `phasor_init()`——它会同时 +> `sample_ring_init()`,等价于"刚上电"。否则上一用例的环内容会串进来。 + +--- + +## 4. 已知边界与设计取舍 + +### 4.1 采样环用"暴力重算",不做迭代 + +每来一个新点,`phasor_run()` 对 20 点**整体重算**(滑动窗)。迭代法(加减首尾项)会累积 +浮点误差且难以保证与"整体重算"逐位一致;暴力法的代价是每拍 `N × 实例数` 次乘加 + 取模, +在 1 ms 节拍下仍有余量。本单测的 `TC-PH-022`(每拍 `get()` == 独立重算)正是这条取舍的 +**回归防线**——一旦有人改成迭代法,逐位一致性断言会立刻失败。 + +### 4.2 主机侧合成波形**不代表真实硬件** + +`frame_at()` 给 9 路语义通道灌的是**同值**波形,仅为驱动 L2 本身。V0.1 上各通道**不同刻** +(N-18:TPAFE 与 STM32 无共同采样触发),跨通道相量运算在本平台上不成立——本单测**不**做 +任何跨通道合成断言(L2-5 受限,见 `docs/算法/继电保护功能复现规划.md` §1.6)。 + +### 4.3 与规划表的一处待对齐 + +`docs/算法/继电保护功能复现规划.md` §3.2 的 L2-2 行仍写作"**4 个实例**:C01 基波 I/U、 +C02 零序基波、C03 二次谐波、C04 五次谐波",而落地实现是 **5 个实例** +(`C01-IA-F1` / `C02-UA-F1` / `C03-UN-F1` / `C04-IA-F2` / `C05-IA-F5`)。 +本单测按**实现**断言(实例数 == 5);规划表待同步。 + +### 4.4 实例表用"数据驱动"而非硬编码 + +`k_inst[]` 每项为 `{name, sem, harm}`,`phasor_run()` 遍历该表取窗并计算。因此新增一个 +相量实例(如 L2-8 的遥测谐波轨)**只需在表里加一行**,本单测的"实例数 == 5"与 +"逐实例复核"两条断言会**自动扩展到新实例**——但需同步更新该常量断言。 + +--- + +## 5. 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-10-10 | 创建;52 用例(TC-PH-001..022)全绿;覆盖系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 实例层 / 滑动窗 / 双区交换 | +| 2026-10-10 | 补 TC-PH-030..034(`phasor_zero` 四字段清零 + `phasor_init` 双区清零 / 使用区与就绪标志复位 / 幂等)→ 57 用例;函数级覆盖审计 `App/algo` 三源共 20 函数、0 未覆盖;挂 pre-commit 钩子 `phasor-unit-test` |