From 3762c03bb4dce53944caab620282d07309452a59 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 17:39:37 +0800 Subject: [PATCH] =?UTF-8?q?L2=20=E7=9B=B8=E9=87=8F=E5=B1=82=EF=BC=9A?= =?UTF-8?q?=E9=87=87=E6=A0=B7=E7=8E=AF=E7=BC=93=E5=86=B2=20+=20=E5=85=A8?= =?UTF-8?q?=E5=91=A8=E5=82=85=E6=B0=8F=20+=20=E4=B8=BB=E6=9C=BA=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=EF=BC=88=E5=8F=B0=E6=9E=B6=E9=97=B4=E6=AD=87=E5=A4=8D?= =?UTF-8?q?=E4=BD=8D=E5=BE=85=E6=9F=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增(均已通过主机侧单测与构建,**台架存在未定位的间歇性回归,见下**): - App/algo/sample_ring.c/h:按语义通道的采样环缓冲(2 周波 = 40 点), 整帧压入 + 帧计数,供 L2 按窗取数。 - App/algo/phasor.c/h:全周傅氏相量层,覆盖规划 §3.2 的 L2-1/2/3/6/7—— 系数表按 docs/算法/傅氏算法详解.md §2.3 预乘 √2/N(fAmSQ 直接即有效值); 5 个数据驱动实例(IA/UA/UN 基波 + IA 二/五次谐波);暴力滑动窗; 计算区/使用区双区原子切换。 - test/host/phasor/:主机侧单测(52/52 PASSED,无需桩)——系数表逐项复核、 幅值/有效值/相位、直流与谐波抑制、任意次谐波提取、频率敏感性(±2Hz≤3%)、 环缓冲时序与回绕、实例层与双区交换。 - 集成:adc_task 每拍压帧 + 重算(v2.4);app_main 调 phasor_init(); Keil 工程挂 phasor.c/sample_ring.c(App/algo 已在 IncludePath)。 验证: - 主机单测 52/52 PASSED。 - @build 0 错误 0 警告;内存快照已刷新(RW_IRAM1 89,320→90,936 B)。 - 台架:L2 链路数据正常(ring=10001、skip=0、回读一致、相量日志可见), 但 10 次运行中 3 次出现 UI 任务看门狗停喂(mask=0x67 缺 TASK_WD_UI) → IWDG 复位;改动前基线 6 次运行 0 次。属**未定位的间歇性回归**。 未完成:定位并修复该间歇性复位;L2 文档状态与登记簿条目尚未按结论更新。 --- App/algo/phasor.c | 205 ++++++++++ App/algo/phasor.h | 119 ++++++ App/algo/sample_ring.c | 124 ++++++ App/algo/sample_ring.h | 88 +++++ App/app/app_main.c | 8 + App/task/adc_task.c | 106 ++++- MDK-ARM/STM32F407-Demo.uvprojx | 10 + docs/内存管理/RAM使用分析与优化方案.md | 6 +- test/host/phasor/phasor_test.c | 511 +++++++++++++++++++++++++ test/host/phasor/run_unit_test.py | 57 +++ 10 files changed, 1222 insertions(+), 12 deletions(-) create mode 100644 App/algo/phasor.c create mode 100644 App/algo/phasor.h create mode 100644 App/algo/sample_ring.c create mode 100644 App/algo/sample_ring.h create mode 100644 test/host/phasor/phasor_test.c create mode 100644 test/host/phasor/run_unit_test.py diff --git a/App/algo/phasor.c b/App/algo/phasor.c new file mode 100644 index 00000000..696f9e0a --- /dev/null +++ b/App/algo/phasor.c @@ -0,0 +1,205 @@ +/* + * 模块名称:相量计算层(L2 相量计算) + * 模块功能:全周傅氏算法(单频 DFT)——系数表预乘 √2/N,fAmSQ 直接即有效值。 + * 覆盖 L2-1 系数表 / L2-2 实例 / L2-3 有效值 / L2-6 滑动窗 / L2-7 双区交换。 + * 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU) + * 作者:王建锋 + * 创建日期:2026-10-10 + * 修改记录: + * v1.0 2026-10-10 王建锋 创建:L2-1/2/3/6/7 + */ + +#include "phasor.h" + +#include +#include + +#include "sample_ring.h" + +/* ===== L2-1 全周傅氏系数表 ===== + * g_fFFTSin20[k] = sin(2πk/N)·(√2/N) + * g_fFFTCos20[k] = cos(2πk/N)·(√2/N) N = PHASOR_N = 20 + * 权威口径:docs/算法/傅氏算法详解.md §2.3("把 √2 藏进系数表")。 + * 本表为**离线生成**的 float 常量(不在运行期调 sinf/cosf):确定、无 libm 精度依赖。 + * 主机侧单测 test/host/phasor/ 会**逐项按上式复核本表**(防转录错), + * 并以合成正弦做端到端复核(读数误差 ≤ 1%)。 + * 精确零点(sin(π)=0、cos(π/2)=0)直接写 0.0f,不保留浮点噪声。 */ +static const float k_fft_sin[PHASOR_N] = { + +0.000000000e+00f, +2.185080200e-02f, +4.156269506e-02f, +5.720613897e-02f, +6.724984944e-02f, + +7.071068138e-02f, +6.724984944e-02f, +5.720613897e-02f, +4.156269506e-02f, +2.185080200e-02f, + +0.000000000e+00f, -2.185080200e-02f, -4.156269506e-02f, -5.720613897e-02f, -6.724984944e-02f, + -7.071068138e-02f, -6.724984944e-02f, -5.720613897e-02f, -4.156269506e-02f, -2.185080200e-02f, +}; + +static const float k_fft_cos[PHASOR_N] = { + +7.071068138e-02f, +6.724984944e-02f, +5.720613897e-02f, +4.156269506e-02f, +2.185080200e-02f, + +0.000000000e+00f, -2.185080200e-02f, -4.156269506e-02f, -5.720613897e-02f, -6.724984944e-02f, + -7.071068138e-02f, -6.724984944e-02f, -5.720613897e-02f, -4.156269506e-02f, -2.185080200e-02f, + +0.000000000e+00f, +2.185080200e-02f, +4.156269506e-02f, +5.720613897e-02f, +6.724984944e-02f, +}; + +/* √2:幅值 = √2 × 有效值(与系数表的 √2 折算互逆) */ +#define PHASOR_SQRT2 1.4142135623730951f + +/* ===== L2-2 相量实例表 ===== + * 参考项目为 **4 个实例**(C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波, + * `APP/CLS100_Dsp/InstCalCell.c`)。本项目按 V0.1 **有效通道**(`UA`/`UN`/`IA`, + * 见规划 §1.6:`UB/UC/IB/IC/IN` 无中点偏置 → 削顶不可用)展开——C01 的"I/U" + * 拆为两行(IA 电流 / UA 电压),并补出 C04 的五次谐波轨(L1-9)。 + * 新增实例只需在表尾加一行,其余代码自动适配。 */ +static const phasor_inst_t k_inst[] = { + { "C01-IA-F1", ADC_SEM_IA, 1u }, /* A 相电流基波(L3-1 过流 / L3-3 过负荷) */ + { "C02-UA-F1", ADC_SEM_UA, 1u }, /* A 相电压基波(L3-4 过压/欠压、L3-5 频率) */ + { "C03-UN-F1", ADC_SEM_UN, 1u }, /* 零序电压基波(L3-6 零序过压) */ + { "C04-IA-F2", ADC_SEM_IA, 2u }, /* 二次谐波(L3-7 涌流制动) */ + { "C05-IA-F5", ADC_SEM_IA, 5u }, /* 五次谐波(L1-9 选线) */ +}; + +#define PHASOR_INST_N (sizeof(k_inst) / sizeof(k_inst[0])) + +/* ===== L2-7 双区(计算区 / 使用区)===== + * 本拍结果先写入"计算区",全部实例写完后**一次性**切换 s_use_zone + * (单字节 store,Cortex-M 上原子),消费侧读"使用区" → 无锁、无撕裂。 + * s_use_zone 声明 volatile:阻止编译器把结果写入挪到切换之后 + * (本平台单核顺序执行,无需额外内存屏障)。 + * 今日唯一消费者是 adcTask 自身(同拍读写、实际无竞争),本机制为后续 + * 独立保护任务(L3)预留。 */ +static phasor_t s_zone[2][PHASOR_INST_N]; +static volatile uint8_t s_use_zone; +static uint8_t s_ready; /* 使用区是否已产出过有效结果 */ + +/* + * 函数功能:清零一个相量 + * 入口参数:p - 目标相量 + * 返 回 值:无 + * 限定条件:p 非空 + * 函数说明:静态辅助。 + */ +static void phasor_zero(phasor_t *p) { + p->fRe = 0.0f; + p->fIm = 0.0f; + p->fAm = 0.0f; + p->fAmSQ = 0.0f; +} + +/* + * 函数功能:对 n 点窗口算 harm 次相量(L2-1 系数表 + L2-3 有效值) + * 入口参数:x - 采样窗(按时间序,旧 → 新) + * n - 窗长(必须等于 PHASOR_N) + * harm - 谐波次数(>=1) + * out - 输出相量 + * 返 回 值:无 + * 限定条件:无 + * 函数说明:纯函数。索引步进 = 谐波次数,故同一张表可算任意次谐波。 + */ +void phasor_calc(const float *x, uint8_t n, uint8_t harm, phasor_t *out) { + float re = 0.0f; + float im = 0.0f; + uint8_t i; + + if ((x == NULL) || (out == NULL) || (n != (uint8_t)PHASOR_N) || (harm == 0u)) { + return; + } + + for (i = 0u; i < n; i++) { + uint8_t idx = (uint8_t)(((unsigned int)i * harm) % n); + re += x[i] * k_fft_sin[idx]; + im += x[i] * k_fft_cos[idx]; + } + + out->fRe = re; + out->fIm = im; + out->fAmSQ = sqrtf((re * re) + (im * im)); /* 系数已折算 √2/N → 直接即有效值 */ + out->fAm = out->fAmSQ * PHASOR_SQRT2; /* 幅值 = √2 × 有效值 */ +} + +/* + * 函数功能:相量层初始化 + * 入口参数:无 + * 返 回 值:0 - 成功 + * 限定条件:内核启动前调用 + * 函数说明:清双区结果与有效标志,并复位采样环缓冲;可重复调用(幂等)。 + */ +int phasor_init(void) { + uint8_t i; + + for (i = 0u; i < (uint8_t)PHASOR_INST_N; i++) { + phasor_zero(&s_zone[0][i]); + phasor_zero(&s_zone[1][i]); + } + s_use_zone = 0u; + s_ready = 0u; + sample_ring_init(); + return 0; +} + +/* + * 函数功能:取相量实例数(L2-2) + * 入口参数:无 + * 返 回 值:实例个数 + * 限定条件:无 + * 函数说明:供遍历 / 日志打印。 + */ +uint8_t phasor_inst_count(void) { + return (uint8_t)PHASOR_INST_N; +} + +/* + * 函数功能:取相量实例描述 + * 入口参数:idx - 实例下标 + * 返 回 值:指向静态实例表的指针;idx 越界返回 NULL + * 限定条件:无 + * 函数说明:返回静态存储地址,调用方不得修改。 + */ +const phasor_inst_t *phasor_inst(uint8_t idx) { + if (idx >= (uint8_t)PHASOR_INST_N) { + return NULL; + } + return &k_inst[idx]; +} + +/* + * 函数功能:重算全部实例 + 双区原子切换(L2-6 / L2-7) + * 入口参数:无 + * 返 回 值:无 + * 限定条件:仅任务上下文调用;须先 sample_ring_push_frame() 压入本拍数据 + * 函数说明:任一实例数据不足一周波 → 本拍整体不产出(不切换使用区), + * 避免出现"部分新部分旧"的混合结果。 + */ +void phasor_run(void) { + float win[PHASOR_N]; + uint8_t w = (uint8_t)(1u - s_use_zone); /* 计算区 = 非使用区 */ + uint8_t i; + + for (i = 0u; i < (uint8_t)PHASOR_INST_N; i++) { + if (sample_ring_window(k_inst[i].sem, (uint8_t)PHASOR_N, win) != 0) { + return; /* 尚未攒满一周波:本拍不产出 */ + } + phasor_calc(win, (uint8_t)PHASOR_N, k_inst[i].harm, &s_zone[w][i]); + } + + s_use_zone = w; /* 原子切换(单字节 store) */ + s_ready = 1u; +} + +/* + * 函数功能:读实例结果(使用区,无锁) + * 入口参数:idx - 实例下标 + * out - 输出相量 + * 返 回 值:0 - 成功;-1 - 参数错;-2 - 尚无有效结果 + * 限定条件:无 + * 函数说明:先取一次使用区下标再索引,避免读写期间下标变化。 + */ +int phasor_get(uint8_t idx, phasor_t *out) { + uint8_t z; + + if ((out == NULL) || (idx >= (uint8_t)PHASOR_INST_N)) { + return -1; + } + if (s_ready == 0u) { + return -2; + } + z = s_use_zone; + *out = s_zone[z][idx]; + return 0; +} diff --git a/App/algo/phasor.h b/App/algo/phasor.h new file mode 100644 index 00000000..cb5d63cb --- /dev/null +++ b/App/algo/phasor.h @@ -0,0 +1,119 @@ +/* + * 模块名称:相量计算层(L2 相量计算) + * 模块功能:全周傅氏(全周波傅里叶)算法——对一周波 20 点采样做单频 DFT, + * 产出基波/谐波相量。系数表**预乘 √2/N**(口径见 + * `docs/算法/傅氏算法详解.md` §2.3),故 `fAmSQ = √(fRe²+fIm²)` + * **直接就是有效值**,无需再除 √2。 + * 覆盖规划 §3.2:L2-1 系数表 / L2-2 实例 / L2-3 有效值输出 / + * L2-6 每 1 ms 滑动窗 / L2-7 双区指针原子交换。 + * ⚠ 本层**纯算法**:不碰寄存器、不碰任务调度、不自己取数(输入由 + * `sample_ring` 提供)→ 主机侧可单测(L0.5,落实登记簿 A-4)。 + * 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU) + * 作者:王建锋 + * 创建日期:2026-10-10 + * 修改记录: + * v1.0 2026-10-10 王建锋 创建:L2-1/2/3/6/7(基波 + 谐波相量) + */ + +#ifndef PHASOR_H +#define PHASOR_H + +#include + +#include "adc_input.h" /* adc_semantic_t:实例按语义通道绑定 */ + +#ifdef __cplusplus +extern "C" { +#endif + +/* 每周波采样点数(1000 Hz @ 50 Hz,见 docs/子系统设计/ADC采样节拍设计说明.md) */ +#define PHASOR_N 20U + +/* 相量结果(**有效值口径**)。字段名沿用参考系统 `{fRe, fIm, fAm, fAmSQ}` + * (规划 §10.2 术语表:实部/虚部/幅值/有效值): + * fRe - 实部 = a1/√2(有效值相量实部) + * fIm - 虚部 = b1/√2(有效值相量虚部) + * fAm - 幅值 = √2·√(fRe²+fIm²)(正弦峰值 A1) + * fAmSQ - 有效值 = √(fRe²+fIm²)(系数已折算 √2/N,故直接即有效值) + * 相位(L2-4)按需由 `atan2(fIm, fRe)` 导出,不常驻本结构。 */ +typedef struct { + float fRe; + float fIm; + float fAm; + float fAmSQ; +} phasor_t; + +/* 相量实例(L2-2):**数据驱动**——新增实例只需在实例表尾加一行 */ +typedef struct { + const char *name; /* 实例名(日志 / UI 用) */ + adc_semantic_t sem; /* 语义通道 */ + uint8_t harm; /* 谐波次数(1=基波,2=二次,5=五次…) */ +} phasor_inst_t; + +/* + * 函数功能:相量层初始化 + * 入口参数:无 + * 返 回 值:0 - 成功 + * 限定条件:内核启动前调用(无 RTOS 依赖) + * 函数说明:清双区结果与有效标志,并复位采样环缓冲;可重复调用(幂等)。 + */ +int phasor_init(void); + +/* + * 函数功能:取相量实例数(L2-2) + * 入口参数:无 + * 返 回 值:实例个数 + * 限定条件:无 + * 函数说明:供遍历 / 日志打印。 + */ +uint8_t phasor_inst_count(void); + +/* + * 函数功能:取相量实例描述 + * 入口参数:idx - 实例下标 + * 返 回 值:指向静态实例表的指针;idx 越界返回 NULL + * 限定条件:无 + * 函数说明:返回静态存储地址,调用方不得修改。 + */ +const phasor_inst_t *phasor_inst(uint8_t idx); + +/* + * 函数功能:重算全部实例并从计算区原子切换到使用区(L2-6 滑动窗 + L2-7 双区交换) + * 入口参数:无 + * 返 回 值:无 + * 限定条件:仅任务上下文调用;须先经 `sample_ring_push_frame()` 压入本拍数据 + * 函数说明:以"当前时刻为终止的前一周波"为窗,**20 点全部重算**(暴力法, + * 无累积误差)。**任一实例数据不足一周波则本拍整体不产出**(不切换 + * 使用区),避免部分新部分旧的混合结果。每拍更新,故相量刷新周期 = 1 ms。 + */ +void phasor_run(void); + +/* + * 函数功能:读实例结果(使用区,无锁) + * 入口参数:idx - 实例下标 + * out - 输出相量 + * 返 回 值:0 - 成功;-1 - 参数错;-2 - 尚无有效结果(未攒满一周波) + * 限定条件:无 + * 函数说明:读的是"使用区",与正在写的"计算区"物理隔离 → 无撕裂、无锁。 + */ +int phasor_get(uint8_t idx, phasor_t *out); + +/* + * 函数功能:对 n 点窗口算 harm 次相量(L2-1 系数表 + L2-3 有效值) + * 入口参数:x - 采样窗(按时间序,旧 → 新;长度须 = PHASOR_N) + * n - 窗长(必须等于 PHASOR_N) + * harm - 谐波次数(>=1) + * out - 输出相量 + * 返 回 值:无(参数非法时 out 保持不动) + * 限定条件:无 + * 函数说明:**纯函数**(无状态),可脱离采样环独立调用,便于单测与复用。 + * 索引步进 = 谐波次数即可取任意次谐波(sin 以 2π 为周期, + * 故 `sin(2π·((n·h) mod N)/N) ≡ sin(2π·n·h/N)`)。 + */ +void phasor_calc(const float *x, uint8_t n, uint8_t harm, phasor_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* PHASOR_H */ diff --git a/App/algo/sample_ring.c b/App/algo/sample_ring.c new file mode 100644 index 00000000..ce56fd7c --- /dev/null +++ b/App/algo/sample_ring.c @@ -0,0 +1,124 @@ +/* + * 模块名称:采样环缓冲(L2 相量计算的输入) + * 模块功能:按语义通道保存最近 2 个周波(40 点)采样值,供 L2 相量计算按窗取数。 + * 适用平台:STM32F407ZGTx(纯算法,无寄存器/无 RTOS 依赖) + * 作者:王建锋 + * 创建日期:2026-10-10 + * 修改记录: + * v1.0 2026-10-10 王建锋 创建:L2 输入环缓冲(20 点/周波 × 2 周波) + */ + +#include "sample_ring.h" + +#include + +/* 采样环:[语义通道][点]。按语义通道索引(0..ADC_SEM_COUNT-1), + * 故消费侧"按语义取数",不依赖任何物理通道号或转换器归属。 */ +static float s_ring[ADC_SEM_COUNT][SAMPLE_RING_DEPTH]; +/* 写指针:下一个待写入的点下标(0..SAMPLE_RING_DEPTH-1) */ +static uint16_t s_head; +/* 已累积点数(饱和于 SAMPLE_RING_DEPTH) */ +static uint16_t s_filled; +/* 累计入环帧数(不饱和,供心跳/丢帧观测) */ +static uint32_t s_frames; + +/* + * 函数功能:采样环缓冲初始化 + * 入口参数:无 + * 返 回 值:无 + * 限定条件:无 + * 函数说明:清空全部通道历史与计数;可重复调用(幂等)。 + */ +void sample_ring_init(void) { + uint8_t c; + uint16_t i; + + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { + for (i = 0u; i < SAMPLE_RING_DEPTH; i++) { + s_ring[c][i] = 0.0f; + } + } + s_head = 0u; + s_filled = 0u; + s_frames = 0u; +} + +/* + * 函数功能:压入一拍(一帧)全部语义通道的采样值 + * 入口参数:values - 按语义通道顺序排列的一帧采样值(长度须 = ADC_SEM_COUNT) + * n - 实际长度(必须等于 ADC_SEM_COUNT) + * 返 回 值:0 - 成功;-1 - 参数错 + * 限定条件:同一帧内各通道须同刻(同族量同刻性由上游 L1 负责) + * 函数说明:整帧写入完毕后**统一**推进写指针——单帧内不存在"半新半旧", + * 消费侧取到的窗永远落在整帧边界上(无帧内撕裂)。 + */ +int sample_ring_push_frame(const float *values, uint8_t n) { + uint8_t c; + + if ((values == NULL) || (n != (uint8_t)ADC_SEM_COUNT)) { + return -1; + } + + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { + s_ring[c][s_head] = values[c]; + } + + s_head = (uint16_t)((s_head + 1u) % SAMPLE_RING_DEPTH); + if (s_filled < SAMPLE_RING_DEPTH) { + s_filled++; + } + s_frames++; + return 0; +} + +/* + * 函数功能:取指定语义通道最近 n 点的窗(按时间序,旧 → 新) + * 入口参数:ch - 语义通道 + * n - 取点数(1..SAMPLE_RING_DEPTH) + * out - 输出缓冲(长度须 >= n) + * 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n + * 限定条件:无 + * 函数说明:out[n-1] 为最新一点。最老点下标 = head - n(模 DEPTH)。 + */ +int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out) { + uint16_t start; + uint16_t i; + + if ((out == NULL) || ((unsigned int)ch >= (unsigned int)ADC_SEM_COUNT)) { + return -1; + } + if ((n == 0u) || (n > SAMPLE_RING_DEPTH)) { + return -1; + } + if (s_filled < (uint16_t)n) { + return -2; + } + + start = (uint16_t)((s_head + SAMPLE_RING_DEPTH - (uint16_t)n) % SAMPLE_RING_DEPTH); + for (i = 0u; i < (uint16_t)n; i++) { + out[i] = s_ring[ch][(uint16_t)((start + i) % SAMPLE_RING_DEPTH)]; + } + return 0; +} + +/* + * 函数功能:读已累积点数(饱和于 SAMPLE_RING_DEPTH) + * 入口参数:无 + * 返 回 值:已累积点数(0..SAMPLE_RING_DEPTH) + * 限定条件:无 + * 函数说明:供上层判断"数据是否够一周波"。 + */ +uint16_t sample_ring_filled(void) { + return s_filled; +} + +/* + * 函数功能:读累计入环帧数(不饱和) + * 入口参数:无 + * 返 回 值:累计入环帧数 + * 限定条件:无 + * 函数说明:供心跳日志 / 丢帧观测(L1-6 判据之一)。 + */ +uint32_t sample_ring_frames(void) { + return s_frames; +} diff --git a/App/algo/sample_ring.h b/App/algo/sample_ring.h new file mode 100644 index 00000000..53f92110 --- /dev/null +++ b/App/algo/sample_ring.h @@ -0,0 +1,88 @@ +/* + * 模块名称:采样环缓冲(L2 相量计算的输入) + * 模块功能:按**语义通道**保存最近若干周波的采样值,供 L2 相量计算按窗取数。 + * V0.1 上 8 路电气量分属两片转换器(硬件缺陷 N-18),本层只做"按时序 + * 存/取",**不做任何跨通道运算**——同刻性由上游(L1)保证,本层不假设。 + * 容量取 **2 个周波(40 点)**,与参考系统 `CN_BUFSAM_CYCLE = 2` + * (规划 §10.1)一致,为后续加长数据窗(1.5~2 周波,抗衰减直流, + * 见 `傅氏算法详解.md` §9 Q4)预留空间。 + * 适用平台:STM32F407ZGTx(**纯算法**:不碰寄存器、不碰 RTOS → 可主机侧单测 L0.5) + * 作者:王建锋 + * 创建日期:2026-10-10 + * 修改记录: + * v1.0 2026-10-10 王建锋 创建:L2 输入环缓冲(20 点/周波 × 2 周波) + */ + +#ifndef SAMPLE_RING_H +#define SAMPLE_RING_H + +#include + +#include "adc_input.h" /* adc_semantic_t / ADC_SEM_COUNT:本层按语义通道存取 */ + +#ifdef __cplusplus +extern "C" { +#endif + +/* 每周波采样点数(1000 Hz @ 50 Hz,见 docs/子系统设计/ADC采样节拍设计说明.md) */ +#define SAMPLE_RING_CYCLE_N 20U +/* 环容量(周波数)——与参考系统 CN_BUFSAM_CYCLE 一致 */ +#define SAMPLE_RING_CYCLES 2U +/* 环深度(点):一周波点数 × 周波数 */ +#define SAMPLE_RING_DEPTH (SAMPLE_RING_CYCLE_N * SAMPLE_RING_CYCLES) + +/* + * 函数功能:采样环缓冲初始化 + * 入口参数:无 + * 返 回 值:无 + * 限定条件:内核启动前调用(无 RTOS 依赖) + * 函数说明:清空全部通道历史与计数;可重复调用(幂等)。 + */ +void sample_ring_init(void); + +/* + * 函数功能:压入一拍(一帧)全部语义通道的采样值 + * 入口参数:values - 按语义通道顺序排列的一帧采样值(浮点工程量,长度须 = ADC_SEM_COUNT) + * n - 实际长度(必须等于 ADC_SEM_COUNT,否则拒收) + * 返 回 值:0 - 成功;-1 - 参数错(空指针或长度不符) + * 限定条件:调用方保证同一帧内各通道**同刻**(同族量同刻性由上游 L1 负责) + * 函数说明:整帧一次性写入,写完统一推进写指针——单帧内不可能出现"半新半旧", + * 即天然无帧内撕裂。消费侧读到的窗永远是**整帧**边界。 + */ +int sample_ring_push_frame(const float *values, uint8_t n); + +/* + * 函数功能:取指定语义通道最近 n 点的窗(按时间序,旧 → 新) + * 入口参数:ch - 语义通道 + * n - 取点数(1..SAMPLE_RING_DEPTH) + * out - 输出缓冲(长度须 >= n) + * 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n + * 限定条件:无 + * 函数说明:out[n-1] 为最新一点。相量层以本函数取"以当前时刻为终止的一周波"。 + */ +int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out); + +/* + * 函数功能:读已累积点数(饱和于 SAMPLE_RING_DEPTH) + * 入口参数:无 + * 返 回 值:已累积点数(0..SAMPLE_RING_DEPTH) + * 限定条件:无 + * 函数说明:供上层判断"数据是否够一周波"。 + */ +uint16_t sample_ring_filled(void); + +/* + * 函数功能:读累计入环帧数(**不饱和**,供心跳/丢帧观测) + * 入口参数:无 + * 返 回 值:累计入环帧数(32 位,1 kHz 下约 49.7 天回绕) + * 限定条件:无 + * 函数说明:与 L1 的 `tpafe5160_frame_seq()` 对照可判丢帧(L1-6 采样有效性校验 + * 的判据之一,本层只提供计数,不做判定)。 + */ +uint32_t sample_ring_frames(void); + +#ifdef __cplusplus +} +#endif + +#endif /* SAMPLE_RING_H */ diff --git a/App/app/app_main.c b/App/app/app_main.c index b7cbc48c..39e54c47 100644 --- a/App/app/app_main.c +++ b/App/app/app_main.c @@ -24,6 +24,7 @@ #include "gd5f2gq5ue.h" #include "tpafe5160.h" #include "adc_input.h" /* L1 采集层:adc_input_init()(语义映射 + STM32 ADC1 扫描) */ +#include "phasor.h" /* L2 相量层:phasor_init()(系数表 + 实例 + 双区交换) */ #include "sys_log.h" #include "task_watchdog.h" /* task_wd_seg_report():round 8 复位不丢"进段打点"上报 */ @@ -194,6 +195,13 @@ int app_main_init(void) { DBG_INFO("ADC input layer init: OK"); } + /* 4c) 相量层(L2)初始化:清双区结果与有效标志 + 复位采样环缓冲。 + * 纯算法层(无外设 / 无 RTOS 依赖,见 docs/算法/继电保护功能复现规划.md + * §3.2),须在启动采样节拍(步骤 6)之前。幂等、当前无失败路径 + * (返回值保留供后续自检扩展)。 */ + (void)phasor_init(); + DBG_INFO("phasor layer init: OK (inst=%u)", (unsigned)phasor_inst_count()); + /* 5) SD2506 RTC 诊断信息 */ { /* 读取 ID */ diff --git a/App/task/adc_task.c b/App/task/adc_task.c index d96f7a40..1043448b 100644 --- a/App/task/adc_task.c +++ b/App/task/adc_task.c @@ -1,7 +1,9 @@ /* * 模块名称:ADC Sampling Task - * 模块功能:ADC 采集消费——TPAFE5160 快照 + STM32 ADC1 顺序扫描(经 L1 层 - * App/algo/adc_input.c 按语义取数)+ 帧统计(节拍由 TIM6 硬件定时器提供) + * 模块功能:ADC 采集消费 + L2 相量计算——TPAFE5160 快照 + STM32 ADC1 顺序扫描 + * (经 L1 层 App/algo/adc_input.c 按语义取数)→ 压入采样环 + * (App/algo/sample_ring.c)→ 全周傅氏相量重算(App/algo/phasor.c) + * + 帧统计(节拍由 TIM6 硬件定时器提供) * 状态说明:**B1 已落地(2026-10-10)**——本任务从空桩转为真实消费者: * 用 tpafe5160_snapshot() 原子取数(AGENTS 铁律),统计帧数/无新帧次数, * 并纳入看门狗 required_mask(TASK_WD_ADC)。 @@ -35,11 +37,22 @@ * v2.3 2026-10-10 王建锋 L1-3/L1-4:取数改走 App/algo/adc_input.c(语义映射 + * STM32 ADC1 9 通道扫描);首帧日志补全 8 路电气量 + 温度, * 心跳日志增 adc1_rc / adc1_err 观测 STM32 侧健康度。 + * v2.4 2026-10-10 王建锋 L2:接入相量层——每拍把 9 路语义值压入采样环 + * (sample_ring)并重算全部相量实例(phasor_run); + * 攒满一周波后**首拍即落一次相量日志**(开机证据,不等 + * 10 s 心跳),心跳日志增 ring / skip 计数与各实例相量 + * (Re/Im/有效值,×0.1 定点打印,因 MicroLIB 的 snprintf + * 无 %f)。 + * ⚠ L1-5 通道标定未实现(缺标准源)→ 暂以**原始码值** + * 充作工程量(系数 = 1),故相量读数只证明**链路通**, + * 不可作幅值精度判据(同 L1-3 的替代口径)。 */ #include "adc_task.h" #include "cmsis_os.h" #include "adc_input.h" /* L1 采集层:adc_input_poll()/adc_input_raw()(按语义取数) */ +#include "sample_ring.h" /* L2 输入:sample_ring_push_frame()(采样环缓冲) */ +#include "phasor.h" /* L2 相量层:phasor_run()/phasor_get()(全周傅氏) */ #include "tpafe5160.h" #include "task_watchdog.h" #include "console_log.h" @@ -56,6 +69,34 @@ * 原始码值缓冲由 L1 层 App/algo/adc_input.c 持有(消费侧按语义取数,不写死下标) */ static uint32_t s_frames_consumed; static uint32_t s_frames_idle; +/* 因通道尚未首次成功而**整拍跳过入环**的次数(不向相量窗注入 0 点) */ +static uint32_t s_frames_skip; + +/* + * 函数功能:打印全部相量实例(×0.1 定点) + * 入口参数:tag - 前缀标记("first" 首次产出 / "hb" 周期心跳) + * 返 回 值:无 + * 限定条件:仅任务上下文;调用方须保证 dbg_console_writer_ready() 为真 + * 函数说明:MicroLIB 的 snprintf **不支持 %f** → 以 ×0.1 定点整数打印 + * (打印值 = 实际值 × 10,分辨率 0.1)。逐实例数据驱动输出, + * 实例表加行即自动覆盖。⚠ L1-5 标定未实现 → 读数单位是**原始码值**。 + */ +static void log_phasors(const char *tag) { + uint8_t k; + + for (k = 0u; k < phasor_inst_count(); k++) { + const phasor_inst_t *d = phasor_inst(k); + phasor_t p; + + if ((d != NULL) && (phasor_get(k, &p) == 0)) { + DBG_INFO("phasor(%s) %s: Re=%ld Im=%ld rms=%ld (x0.1)", + tag, d->name, + (long)(p.fRe * 10.0f), + (long)(p.fIm * 10.0f), + (long)(p.fAmSQ * 10.0f)); + } + } +} /* * 函数功能:ADC 任务初始化 @@ -67,6 +108,7 @@ static uint32_t s_frames_idle; int adc_task_init(void) { s_frames_consumed = 0u; s_frames_idle = 0u; + s_frames_skip = 0u; DBG_INFO("adc_task_init"); return 0; } @@ -85,6 +127,7 @@ void adc_task_func(void *arg) { uint32_t next = osKernelGetTickCount(); uint32_t report_at = next + ADC_TASK_REPORT_MS; uint8_t first_logged = 0u; + uint8_t first_phasor_logged = 0u; (void)arg; @@ -103,7 +146,36 @@ void adc_task_func(void *arg) { /* r == -1:参数错。调用点 count 为常量,不可达;不计入 idle 以区分异常 */ } - /* 2) 首帧码值上报:**写者就绪后**才落日志。写者未注册时 con_append 走 + /* 2) L2 输入 + 相量重算:把本拍 9 路语义值转浮点帧压入采样环,再重算全部 + * 相量实例(L2-6 滑动窗:每拍以"当前时刻为终止的前一周波"整窗重算)。 + * 任一通道读不到(尚未首次成功)→ 本拍**整帧跳过**:不向窗内注入 0 点 + * (单个 0 点会显著污染 20 点窗),并计入 skip 观测。 */ + { + float frame[ADC_SEM_COUNT]; + uint8_t s; + uint8_t all_ok = 1u; + + for (s = 0u; s < (uint8_t)ADC_SEM_COUNT; s++) { + int32_t code = 0; + + if (adc_input_raw((adc_semantic_t)s, &code) != 0) { + all_ok = 0u; + break; + } + /* ⚠ L1-5 通道标定尚未实现(缺标准源)→ 暂以**原始码值**充作工程量 + * (系数 = 1)。标定落地后此处改为 工程量 = (code + b) × k(定点 2^10)。 */ + frame[s] = (float)code; + } + + if (all_ok != 0u) { + (void)sample_ring_push_frame(frame, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + } else { + s_frames_skip++; + } + } + + /* 3) 首帧码值上报:**写者就绪后**才落日志。写者未注册时 con_append 走 * 无锁直发路径,与 defaultTask 的存储初始化日志会字节级交错 * (2026-10-10 台架实测);此时挂起上报,就绪后补报最近一帧。 * 8 路电气量 + 温度全部经 adc_input_raw() **按语义**取数(L1-4), @@ -129,29 +201,45 @@ void adc_task_func(void *arg) { (long)ia, (long)ib, (long)ic, (long)in, (long)temp); } - /* 3) 看门狗心跳(本任务已纳入 required_mask) */ + /* 4) 首拍相量上报:攒满一周波(20 ms)后立即落一次,作为 L2 链路的开机证据。 + * 不依赖 10 s 心跳 → 台架 @test 的 settle 窗口内即可见(否则会被静默判据 + * 截断,见 docs/测试说明/自动化测试流程.md 的 settle 口径)。 */ + if ((first_phasor_logged == 0u) && (dbg_console_writer_ready() != 0u)) { + phasor_t p; + + if (phasor_get(0u, &p) == 0) { + first_phasor_logged = 1u; + log_phasors("first"); + } + } + + /* 5) 看门狗心跳(本任务已纳入 required_mask) */ task_wd_beat(TASK_WD_ADC); - /* 4) 周期心跳日志:帧统计 + 驱动侧 FRSTDATA 错误计数。 - * 写者就绪前抑制(同 2) 的交错理由);到点无论是否落日志都推进下个上报点 */ + /* 6) 周期心跳日志:帧统计 + 驱动侧 FRSTDATA 错误计数 + L2 相量。 + * 写者就绪前抑制(同 3) 的交错理由);到点无论是否落日志都推进下个上报点 */ { uint32_t now = osKernelGetTickCount(); if ((int32_t)(now - report_at) >= 0) { if (dbg_console_writer_ready() != 0u) { - DBG_INFO("frames=%lu idle=%lu seq=%lu frst_err=%u adc1_rc=%d adc1_err=%lu", + DBG_INFO("frames=%lu idle=%lu skip=%lu seq=%lu frst_err=%u adc1_rc=%d adc1_err=%lu ring=%lu", (unsigned long)s_frames_consumed, (unsigned long)s_frames_idle, + (unsigned long)s_frames_skip, (unsigned long)tpafe5160_frame_seq(), (unsigned)tpafe5160_frstdata_err(), adc_input_stm32_rc(), - (unsigned long)adc_input_stm32_errs()); + (unsigned long)adc_input_stm32_errs(), + (unsigned long)sample_ring_frames()); + + log_phasors("hb"); } report_at = now + ADC_TASK_REPORT_MS; } } - /* 5) 等下一个节拍(固定周期;落后则立即补齐,不累积漂移) */ + /* 7) 等下一个节拍(固定周期;落后则立即补齐,不累积漂移) */ next += ADC_TASK_BEAT_MS; (void)osDelayUntil(next); } diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index 3d78c155..6ed5360b 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -942,6 +942,16 @@ 1 ..\App\algo\adc_input.c + + sample_ring.c + 1 + ..\App\algo\sample_ring.c + + + phasor.c + 1 + ..\App\algo\phasor.c + diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 94963a88..524a4ebd 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 269,940 B (263.61 KB) | 1,048,576 B (1024 KB) | 25.7% | -| RW_IRAM1 | 89,320 B (87.23 KB) | 131,072 B (128 KB) | 68.1% | +| ER_IROM1 | 271,440 B (265.08 KB) | 1,048,576 B (1024 KB) | 25.9% | +| RW_IRAM1 | 90,936 B (88.80 KB) | 131,072 B (128 KB) | 69.4% | | RW_CCM | 49,184 B (48.03 KB) | 65,280 B (64 KB) | 75.3% | -| **Flash (ROM 合计)** | **270,712 B (264.4 KB)** | 1,048,576 B (1024 KB) | **25.8%** | +| **Flash (ROM 合计)** | **272,228 B (265.8 KB)** | 1,048,576 B (1024 KB) | **26.0%** | 各区的容量与常量来源: diff --git a/test/host/phasor/phasor_test.c b/test/host/phasor/phasor_test.c new file mode 100644 index 00000000..f32b028e --- /dev/null +++ b/test/host/phasor/phasor_test.c @@ -0,0 +1,511 @@ +/* + * 模块名称:相量层(L2)主机侧单元测试 + * 模块功能:在 PC 上对 App/algo/phasor.c + App/algo/sample_ring.c 做离线验证。 + * + * 被测对象:真实源文件(以 #include 原样纳入,保证测的是生产代码而非副本): + * ../../../App/algo/phasor.c + * ../../../App/algo/sample_ring.c + * 本层为**纯算法**(无寄存器、无 RTOS、无文件/外设依赖),故**无需任何桩**。 + * + * 为什么要主机侧单测(而不是只用板端套件): + * 1. 相量算法是纯数学变换,与硬件无关;板端**无信号源**,无法注入已知幅值/相位/ + * 频率的标准波形,因而无法验证"读数 = 设定值"这一核心判据(同 L1-3 的替代口径)。 + * 主机侧可合成任意波形,把幅值精度/谐波抑制/频率敏感性一次性钉死。 + * 2. 可精确构造边界:恰好一周波 / 不足一周波 / 环形回绕 / 任意次谐波 / 频偏。 + * 3. 不占 MCU 的 RAM,毫秒级完成。 + * + * 覆盖点(对应 docs/算法/继电保护功能复现规划.md §3.2): + * TC-PH-001 L2-1 系数表逐项等于 sin(2πk/N)·(√2/N) / cos(2πk/N)·(√2/N)(防转录错) + * TC-PH-002 L2-1 系数表精确零点(sin@0,10 / cos@5,15)与归一化 Σcoe² = 1/N + * TC-PH-003 L2-3 纯基波:fAmSQ = A/√2(有效值),fAm = √2·fAmSQ(幅值) + * TC-PH-004 L2-3/L2-4 实部虚部与相位:atan2(fIm,fRe) = 注入相角 + * TC-PH-005 直流 + 2/3/5 次谐波被完全滤除(基波读数不变) + * TC-PH-006 任意次谐波提取(索引步进 = 谐波次数):k=2 / k=5 + * TC-PH-007 频率偏离 50 Hz 的幅值误差(±1 Hz ≤ 1.5%,±2 Hz ≤ 3%,见 §9 Q3) + * TC-PH-010 采样环:入环/取窗时序、回绕、饱和、帧计数、参数守卫 + * TC-PH-020 L2-2 实例层:实例数/实例表/未攒满一周波不产出 + * TC-PH-021 L2-6 滑动窗:连续滑动 20 拍基波读数恒定 + * TC-PH-022 L2-7 双区交换:使用区交替 + get 返回**最后一次** run 的结果(非陈旧) + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-10 + */ + +#include +#include +#include + +#define PH_PI 3.14159265358979323846 +/* 采样率(Hz):1000 Hz @ 50 Hz → 20 点/周波 */ +#define PH_FS 1000.0 +#define PH_F0 50.0 + +/* ===================== 断言 ===================== */ +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* ===================== 被测代码 ===================== */ +/* 原样纳入生产源文件(纯算法,无需桩) */ +#include "sample_ring.c" +#include "phasor.c" + +/* ===================== 工具 ===================== */ + +/* + * 函数功能:生成一段正弦窗 + * 入口参数:x - 输出(长度 >= n) + * n - 点数 + * amp - 正弦峰值 + * f - 频率(Hz) + * phase - 初相(度) + * 返 回 值:无 + * 限定条件:无 + * 函数说明:以 t = i/PH_FS 采样,即"以当前时刻为终止的前 n 点窗"。 + */ +static void gen_sine(float *x, uint8_t n, double amp, double f, double phase) { + uint8_t i; + + for (i = 0u; i < n; i++) { + x[i] = (float)(amp * sin(2.0 * PH_PI * f * (double)i / PH_FS + phase * PH_PI / 180.0)); + } +} + +/* + * 函数功能:按时刻 t 构造一整帧(9 路语义通道同值) + * 入口参数:f - 输出帧(长度 = ADC_SEM_COUNT) + * t - 采样序号(t/PH_FS 秒) + * amp - 正弦峰值 + * phase - 初相(度) + * 返 回 值:无 + * 限定条件:无 + * 函数说明:仅用于驱动实例层/采样环;**不代表真实硬件**(V0.1 上各通道不同刻, + * 此处只为验证相量层本身)。 + */ +static void frame_at(float *f, uint32_t t, double amp, double phase) { + uint8_t c; + double v = amp * sin(2.0 * PH_PI * PH_F0 * (double)t / PH_FS + phase * PH_PI / 180.0); + + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { + f[c] = (float)v; + } +} + +/* ===================== TC-PH-001/002:L2-1 系数表 ===================== */ + +static void tc_table(void) { + const double scale = sqrt(2.0) / (double)PHASOR_N; + double sum_sin = 0.0, sum_cos = 0.0; + int ok_sin = 1, ok_cos = 1, ok_norm = 1; + uint8_t k; + + for (k = 0u; k < (uint8_t)PHASOR_N; k++) { + double arg = 2.0 * PH_PI * (double)k / (double)PHASOR_N; + double es = sin(arg) * scale; + double ec = cos(arg) * scale; + + if (fabs((double)k_fft_sin[k] - es) > 1e-7) { ok_sin = 0; } + if (fabs((double)k_fft_cos[k] - ec) > 1e-7) { ok_cos = 0; } + + /* 归一化:sin²+cos² = (√2/N)² = 2/N² */ + if (fabs((double)k_fft_sin[k] * (double)k_fft_sin[k] + + (double)k_fft_cos[k] * (double)k_fft_cos[k] - 2.0 / (PHASOR_N * PHASOR_N)) > 1e-9) { + ok_norm = 0; + } + sum_sin += (double)k_fft_sin[k] * (double)k_fft_sin[k]; + sum_cos += (double)k_fft_cos[k] * (double)k_fft_cos[k]; + } + + CHECK(ok_sin, "L2-1 coeff sin[k] == sin(2*pi*k/N)*(sqrt2/N) for all k"); + CHECK(ok_cos, "L2-1 coeff cos[k] == cos(2*pi*k/N)*(sqrt2/N) for all k"); + CHECK(ok_norm, "L2-1 per-k sin^2+cos^2 == 2/N^2 (normalization)"); + /* Σsin² = Σcos² = (√2/N)²·(N/2) = 1/N:这就是"fAmSQ 直接是有效值"的根 */ + CHECK(fabs(sum_sin - 1.0 / PHASOR_N) < 1e-7, "L2-1 sum(sin^2) == 1/N (=> RMS, not amplitude)"); + CHECK(fabs(sum_cos - 1.0 / PHASOR_N) < 1e-7, "L2-1 sum(cos^2) == 1/N (=> RMS, not amplitude)"); + + CHECK((k_fft_sin[0] == 0.0f) && (k_fft_sin[10] == 0.0f), "L2-1 sin exact zeros at k=0,10"); + CHECK((k_fft_cos[5] == 0.0f) && (k_fft_cos[15] == 0.0f), "L2-1 cos exact zeros at k=5,15"); + CHECK(fabs((double)k_fft_sin[5] - sqrt(2.0) / PHASOR_N) < 1e-7, + "L2-1 sin[5] == sqrt(2)/N (peak coefficient)"); +} + +/* ===================== TC-PH-003/004:基波幅值 / 有效值 / 相位 ===================== */ + +static void tc_fundamental(void) { + static const struct { double amp; double ph; } k_case[] = { + { 57.7, 0.0 }, + { 57.7, 30.0 }, + { 57.7, -45.0 }, + { 100.0, 90.0 }, + { 1.0, 0.0 }, + { 32767.0, 137.0 }, + }; + const unsigned int ncase = (unsigned int)(sizeof(k_case) / sizeof(k_case[0])); + unsigned int i; + int ok_rms = 1, ok_amp = 1, ok_ph = 1; + float x[PHASOR_N]; + + for (i = 0u; i < ncase; i++) { + phasor_t p; + double exp_rms = k_case[i].amp / sqrt(2.0); + double ph_meas; + + gen_sine(x, (uint8_t)PHASOR_N, k_case[i].amp, PH_F0, k_case[i].ph); + phasor_calc(x, (uint8_t)PHASOR_N, 1u, &p); + + if (fabs((double)p.fAmSQ - exp_rms) / exp_rms > 0.01) { ok_rms = 0; } + if (fabs((double)p.fAm - exp_rms * sqrt(2.0)) / (exp_rms * sqrt(2.0)) > 0.01) { ok_amp = 0; } + + ph_meas = atan2((double)p.fIm, (double)p.fRe) * 180.0 / PH_PI; + if (fabs(ph_meas - k_case[i].ph) > 0.5) { + printf(" (amp=%.1f ph=%.1f -> measured %.4f)\n", + k_case[i].amp, k_case[i].ph, ph_meas); + ok_ph = 0; + } + } + + CHECK(ok_rms, "L2-3 pure fundamental: fAmSQ == A/sqrt(2) (RMS) within 1%"); + CHECK(ok_amp, "L2-3 fAm == sqrt(2)*fAmSQ (amplitude) within 1%"); + CHECK(ok_ph, "L2-4/L2-3 atan2(fIm,fRe) == injected phase (6 amp/phase cases)"); +} + +/* ===================== TC-PH-005:直流与谐波被滤除 ===================== */ + +static void tc_filtering(void) { + const double amp = 57.7, exp_rms = amp / sqrt(2.0); + static const struct { double dc, h2, h3, h5; const char *name; } k_case[] = { + { 0.0, 0.0, 0.0, 0.0, "pure" }, + { 1.0, 0.0, 0.0, 0.0, "with 1V DC" }, + { 0.0, 0.0, 30.0, 0.0, "with 30% 3rd harmonic" }, + { 0.0, 0.0, 0.0, 11.5, "with 20% 5th harmonic" }, + { 1.0, 20.0, 15.0, 11.5, "DC + 2nd + 3rd + 5th (doc 6 case)" }, + }; + const unsigned int ncase = (unsigned int)(sizeof(k_case) / sizeof(k_case[0])); + unsigned int i; + int ok = 1; + float x[PHASOR_N]; + + for (i = 0u; i < ncase; i++) { + phasor_t p; + uint8_t k; + double err; + + for (k = 0u; k < (uint8_t)PHASOR_N; k++) { + double arg = 2.0 * PH_PI * (double)k / (double)PHASOR_N; + x[k] = (float)(amp * sin(arg + 30.0 * PH_PI / 180.0) + + k_case[i].dc + + k_case[i].h2 * sin(2.0 * arg) + + k_case[i].h3 * sin(3.0 * arg) + + k_case[i].h5 * sin(5.0 * arg)); + } + + phasor_calc(x, (uint8_t)PHASOR_N, 1u, &p); + err = fabs((double)p.fAmSQ - exp_rms) / exp_rms; + if (err > 0.01) { + printf(" (%s -> fAmSQ=%.4f expect=%.4f err=%.3f%%)\n", + k_case[i].name, (double)p.fAmSQ, exp_rms, err * 100.0); + ok = 0; + } + } + + CHECK(ok, "L2-3 DC + 2nd/3rd/5th harmonics fully rejected (5 cases, err <= 1%)"); +} + +/* ===================== TC-PH-006:任意次谐波提取 ===================== */ + +static void tc_harmonic(void) { + /* 基波 100 + 二次 25 + 五次 12(峰值);三根谱线应各自独立读出 */ + static const struct { uint8_t harm; double amp; } k_case[] = { + { 1u, 100.0 }, + { 2u, 25.0 }, + { 5u, 12.0 }, + }; + const unsigned int ncase = (unsigned int)(sizeof(k_case) / sizeof(k_case[0])); + unsigned int i; + int ok = 1; + float x[PHASOR_N]; + uint8_t k; + + for (k = 0u; k < (uint8_t)PHASOR_N; k++) { + double arg = 2.0 * PH_PI * (double)k / (double)PHASOR_N; + x[k] = (float)(100.0 * sin(arg) + 25.0 * sin(2.0 * arg) + 12.0 * sin(5.0 * arg)); + } + + for (i = 0u; i < ncase; i++) { + phasor_t p; + double exp_rms = k_case[i].amp / sqrt(2.0); + + phasor_calc(x, (uint8_t)PHASOR_N, k_case[i].harm, &p); + if (fabs((double)p.fAmSQ - exp_rms) / exp_rms > 0.01) { + printf(" (harm=%u -> fAmSQ=%.4f expect=%.4f)\n", + (unsigned)k_case[i].harm, (double)p.fAmSQ, exp_rms); + ok = 0; + } + } + + CHECK(ok, "L2-1 harmonic extraction k=1/2/5 from a mixed signal (index step = order)"); + + /* 纯基波时谐波轨应≈0(选择性) */ + gen_sine(x, (uint8_t)PHASOR_N, 100.0, PH_F0, 0.0); + { + phasor_t p2, p5; + phasor_calc(x, (uint8_t)PHASOR_N, 2u, &p2); + phasor_calc(x, (uint8_t)PHASOR_N, 5u, &p5); + CHECK(((double)p2.fAmSQ < 1e-3) && ((double)p5.fAmSQ < 1e-3), + "L2-1 pure fundamental leaves 2nd/5th harmonic bins ~0"); + } +} + +/* ===================== TC-PH-007:频率敏感性 ===================== */ + +static void tc_freqdev(void) { + const double amp = 57.7, exp_rms = amp / sqrt(2.0); + /* 最坏相角下的幅值误差(实测:±1 Hz ≤1.1%,±2 Hz ≤2.33%;口径见 §9 Q3) */ + static const struct { double f; double limit_pct; } k_case[] = { + { 50.0, 0.10 }, + { 49.0, 1.50 }, + { 51.0, 1.50 }, + { 48.0, 3.00 }, + { 52.0, 3.00 }, + }; + const unsigned int ncase = (unsigned int)(sizeof(k_case) / sizeof(k_case[0])); + unsigned int i; + int ok = 1; + float x[PHASOR_N]; + + for (i = 0u; i < ncase; i++) { + double worst = 0.0; + int ph; + + for (ph = 0; ph < 360; ph += 15) { + phasor_t p; + double err; + + gen_sine(x, (uint8_t)PHASOR_N, amp, k_case[i].f, (double)ph); + phasor_calc(x, (uint8_t)PHASOR_N, 1u, &p); + err = fabs((double)p.fAmSQ - exp_rms) / exp_rms * 100.0; + if (err > worst) { worst = err; } + } + + printf(" (f=%5.1f Hz worst err over phase = %.2f%%, limit %.2f%%)\n", + k_case[i].f, worst, k_case[i].limit_pct); + if (worst > k_case[i].limit_pct) { ok = 0; } + } + + CHECK(ok, "L2-3 frequency sensitivity: 50Hz<=0.1%, +-1Hz<=1.5%, +-2Hz<=3% (worst phase)"); +} + +/* ===================== TC-PH-010:采样环 ===================== */ + +static void tc_ring(void) { + float f[ADC_SEM_COUNT]; + float w[SAMPLE_RING_DEPTH]; + uint8_t c; + int i; + int ok; + + sample_ring_init(); + CHECK(sample_ring_filled() == 0u, "L2-in ring: init clears fill"); + CHECK(sample_ring_frames() == 0u, "L2-in ring: init clears frame counter"); + CHECK(sample_ring_window(ADC_SEM_UA, 20u, w) == -2, "L2-in ring: window before data -> -2"); + + /* 前 3 帧:UA = 100/200/300 */ + ok = 1; + for (i = 0; i < 3; i++) { + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { f[c] = 0.0f; } + f[ADC_SEM_UA] = (float)(100 * (i + 1)); + if (sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT) != 0) { ok = 0; } + } + CHECK(ok, "L2-in ring: 3 frames pushed OK"); + CHECK(sample_ring_filled() == 3u, "L2-in ring: filled == 3"); + CHECK((sample_ring_window(ADC_SEM_UA, 3u, w) == 0) + && (w[0] == 100.0f) && (w[1] == 200.0f) && (w[2] == 300.0f), + "L2-in ring: window is chronological (old -> new)"); + /* 通道隔离:其它语义通道不应被 UA 的写入污染 */ + CHECK((sample_ring_window(ADC_SEM_IB, 3u, w) == 0) + && (w[0] == 0.0f) && (w[1] == 0.0f) && (w[2] == 0.0f), + "L2-in ring: channels are independent"); + + /* 推到 45 帧(> DEPTH=40)→ 回绕 */ + for (i = 3; i < 45; i++) { + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { f[c] = 0.0f; } + f[ADC_SEM_UA] = (float)i; + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + } + CHECK(sample_ring_filled() == SAMPLE_RING_DEPTH, "L2-in ring: fill saturates at DEPTH"); + CHECK(sample_ring_frames() == 45u, "L2-in ring: frame counter is NOT saturated"); + + ok = (sample_ring_window(ADC_SEM_UA, 20u, w) == 0); + for (i = 0; i < 20; i++) { if (w[i] != (float)(25 + i)) { ok = 0; } } + CHECK(ok, "L2-in ring: wrapped window == last 20 values, in order"); + + ok = (sample_ring_window(ADC_SEM_UA, (uint8_t)SAMPLE_RING_DEPTH, w) == 0); + for (i = 0; i < (int)SAMPLE_RING_DEPTH; i++) { if (w[i] != (float)(5 + i)) { ok = 0; } } + CHECK(ok, "L2-in ring: full-depth window == last DEPTH values, in order"); + + CHECK(sample_ring_window(ADC_SEM_UA, 0u, w) == -1, "L2-in ring: n=0 rejected"); + CHECK(sample_ring_window(ADC_SEM_UA, (uint8_t)(SAMPLE_RING_DEPTH + 1u), w) == -1, + "L2-in ring: n > DEPTH rejected"); + CHECK(sample_ring_window((adc_semantic_t)ADC_SEM_COUNT, 20u, w) == -1, + "L2-in ring: out-of-range channel rejected"); + CHECK(sample_ring_window(ADC_SEM_UA, 20u, NULL) == -1, "L2-in ring: NULL out rejected"); + CHECK(sample_ring_push_frame(NULL, (uint8_t)ADC_SEM_COUNT) == -1, "L2-in ring: NULL frame rejected"); + CHECK(sample_ring_push_frame(f, 3u) == -1, "L2-in ring: wrong frame length rejected"); +} + +/* ===================== TC-PH-020/021/022:实例层 + 滑动窗 + 双区 ===================== */ + +static void tc_instances(void) { + const double amp = 100.0, exp_rms = amp / sqrt(2.0); + float f[ADC_SEM_COUNT]; + phasor_t p; + uint32_t t; + int ok; + uint8_t i; + + CHECK(phasor_inst_count() == 5u, "L2-2 instance count == 5"); + CHECK((phasor_inst(0u) != NULL) && (phasor_inst(0u)->name != NULL), "L2-2 instance name available"); + CHECK(phasor_inst((uint8_t)phasor_inst_count()) == NULL, "L2-2 out-of-range instance -> NULL"); + + phasor_init(); + CHECK(s_use_zone == 0u, "L2-7 use zone starts at 0"); + CHECK(phasor_get(0u, &p) == -2, "L2-2 no result before a full cycle"); + CHECK(phasor_get((uint8_t)phasor_inst_count(), &p) == -1, "L2-2 out-of-range index -> -1"); + CHECK(phasor_get(0u, NULL) == -1, "L2-2 NULL out -> -1"); + + /* 喂 19 帧(不足一周波)→ 仍不产出 */ + for (t = 0u; t < 19u; t++) { + frame_at(f, t, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + } + CHECK(phasor_get(0u, &p) == -2, "L2-2 still no result after 19 frames"); + CHECK(s_use_zone == 0u, "L2-7 use zone unchanged while not producing"); + + /* 第 20 帧 → 攒满一周波,产出 */ + frame_at(f, 19u, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + CHECK(phasor_get(0u, &p) == 0, "L2-2 result available after 20 frames"); + CHECK(s_use_zone == 1u, "L2-7 use zone toggles to 1 after first production"); + CHECK(fabs((double)p.fAmSQ - exp_rms) / exp_rms < 0.01, + "L2-2/L2-3 instance 0 (C01-IA-F1) RMS == A/sqrt(2)"); + { + phasor_t p2, p5; + int ok_sel = 1; + if (phasor_get(3u, &p2) != 0) { ok_sel = 0; } + if (phasor_get(4u, &p5) != 0) { ok_sel = 0; } + CHECK(ok_sel && ((double)p2.fAmSQ < 1e-3) && ((double)p5.fAmSQ < 1e-3), + "L2-2 harmonic instances (C04-IA-F2 / C05-IA-F5) ~0 on pure fundamental"); + } + + /* L2-6 滑动窗:继续推 20 帧,每拍重算,基波读数应恒定 */ + ok = 1; + for (t = 20u; t < 40u; t++) { + frame_at(f, t, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + if (phasor_get(0u, &p) != 0) { ok = 0; } + else if (fabs((double)p.fAmSQ - exp_rms) / exp_rms > 0.01) { ok = 0; } + } + CHECK(ok, "L2-6 sliding window: RMS constant across 20 consecutive slides"); + + /* 实例表一致性:逐实例复核"实例窗 == 独立 phasor_calc 结果" */ + ok = 1; + for (i = 0u; i < phasor_inst_count(); i++) { + const phasor_inst_t *d = phasor_inst(i); + float win[PHASOR_N]; + phasor_t ref, got; + + if (d == NULL) { ok = 0; break; } + if (sample_ring_window(d->sem, (uint8_t)PHASOR_N, win) != 0) { ok = 0; break; } + phasor_calc(win, (uint8_t)PHASOR_N, d->harm, &ref); + if (phasor_get(i, &got) != 0) { ok = 0; break; } + if ((ref.fRe != got.fRe) || (ref.fIm != got.fIm) || (ref.fAmSQ != got.fAmSQ)) { ok = 0; break; } + } + CHECK(ok, "L2-2/L2-6 instance results == phasor_calc() on the same ring window"); +} + +/* ===================== TC-PH-022:L2-7 双区指针原子交换 ===================== */ + +static void tc_dualzone(void) { + const double amp = 100.0; + float f[ADC_SEM_COUNT]; + phasor_t p; + uint32_t t; + int ok; + + /* 1) 使用区逐次产出交替(0 -> 1 -> 0 -> 1) */ + phasor_init(); + for (t = 0u; t < 20u; t++) { + frame_at(f, t, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + } + CHECK(s_use_zone == 1u, "L2-7 use zone == 1 after 1st production"); + + frame_at(f, 20u, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + CHECK(s_use_zone == 0u, "L2-7 use zone == 0 after 2nd production"); + + frame_at(f, 21u, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + CHECK(s_use_zone == 1u, "L2-7 use zone == 1 after 3rd production"); + + /* 2) 每拍产出后 get() 必须等于"当前环窗的独立重算"。 + * 幅值每 20 拍切换(100 <-> 10),故"读到陈旧区"会立刻不相等—— + * 连测 60 拍,覆盖两个区的来回切换,比单次比较强得多。 */ + phasor_init(); + ok = 1; + for (t = 0u; t < 60u; t++) { + float win[PHASOR_N]; + phasor_t ref, got; + double a = (((t / 20u) % 2u) == 0u) ? amp : (amp / 10.0); + + frame_at(f, t, a, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + + if (sample_ring_window(ADC_SEM_IA, (uint8_t)PHASOR_N, win) != 0) { + continue; /* 尚未攒满一周波:本拍按契约不产出,无需比较 */ + } + phasor_calc(win, (uint8_t)PHASOR_N, 1u, &ref); + if (phasor_get(0u, &got) != 0) { ok = 0; break; } + if ((ref.fRe != got.fRe) || (ref.fIm != got.fIm) || (ref.fAmSQ != got.fAmSQ)) { ok = 0; break; } + } + CHECK(ok, "L2-7 dual-zone: get() == fresh recompute of the live window, all 60 beats"); + CHECK(phasor_get(0u, &p) == 0, "L2-7 result still readable after the sweep"); +} + +int main(void) { + printf("=== Phasor Tests ===\n"); + + printf("-- L2-1 coefficient table --\n"); + tc_table(); + printf("-- L2-3/L2-4 fundamental amplitude/RMS/phase --\n"); + tc_fundamental(); + printf("-- DC/harmonic rejection --\n"); + tc_filtering(); + printf("-- arbitrary harmonic extraction --\n"); + tc_harmonic(); + printf("-- frequency sensitivity --\n"); + tc_freqdev(); + printf("-- L2 input sample ring --\n"); + tc_ring(); + printf("-- L2-2/L2-6/L2-7 instance layer --\n"); + tc_instances(); + printf("-- L2-7 dual-zone swap --\n"); + tc_dualzone(); + + printf("\n=== Phasor Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/phasor/run_unit_test.py b/test/host/phasor/run_unit_test.py new file mode 100644 index 00000000..a916aca0 --- /dev/null +++ b/test/host/phasor/run_unit_test.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""相量层(L2)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/phasor/phasor_test.c(它以 #include 纳入**生产源文件** + App/algo/phasor.c 与 App/algo/sample_ring.c),在 PC 上离线跑完整的相量算法验证: + 系数表逐项复核 / 幅值-有效值-相位 / 直流与谐波抑制 / 任意次谐波提取 / + 频率敏感性 / 采样环时序与回绕 / 实例层与双区交换,失败即非 0 退出。 + 零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么要主机侧单测(而不是只用板端套件): + 相量算法是纯数学变换,与硬件无关。**板端无信号源**,无法注入已知幅值/相位/频率 + 的标准波形,因而无法验证"读数 = 设定有效值"这一 L2-3 核心判据(与 L1-3 的替代口径 + 同源)。主机侧可合成任意波形并精确构造边界(恰好一周波 / 不足一周波 / 环形回绕 / + 任意次谐波 / 频偏),且不占 MCU 的 RAM。 + +本层为**纯算法**:无寄存器、无 RTOS、无文件/外设依赖,故**不需要任何桩目录**。 + +用法(仓库根目录): + python test/host/phasor/run_unit_test.py + 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/phasor/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/算法/继电保护功能复现规划.md §3.2(L2-1/2/3/4/6/7); + docs/算法/傅氏算法详解.md §2.3(系数口径); + docs/测试说明/测试开发标准流程.md §4.2(L0.5 主机侧单测)。 +创建日期:2026-10-10 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 无桩目录:被测代码为纯算法(phasor.c / sample_ring.c 仅依赖 math.h 与 App/algo 内的头)。 +# App/algo 提供 phasor.h / sample_ring.h / adc_input.h(后者为纯枚举 + 声明,可主机编译)。 +BUILD = TestBuild( + name="Phasor Tests", + src=os.path.join(HERE, "phasor_test.c"), + include_dirs=[ + os.path.join(ROOT, "App", "algo"), + HERE, + ], + summary_marker="=== Phasor Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD))