diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5ea9704..e90158eb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -381,6 +381,21 @@ repos: files: ^(App/algo/(adc_cal|prot_elm|prot_set|phasor|sample_ring|freq)\.(c|h)|App/task/(adc_task|protect_task)\.c|test/host/prot_dim/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false + # 7h4) 遥测快照层(L2 发布侧,「数据可信」卡 8,道D)主机侧单元测试(2026-10-25 加) + # 背景:卡 8 的 DoD 一半是并发与结构类判据——双区切换无撕裂(撕裂窗 + # 200 ms 量级,台架人工比对观测不到)、seq 只增、§4.5 布局与字段偏移 + # (卡 9/UI 直接按整体拷贝消费,破布局 = 破协议)、CLIPPED 品质汇总 + # (削顶位图要等卡 12 标定才真实出现)。主机侧以 #include 原样纳入 + # meas/phasor/sample_ring 生产源,合成 9 通道帧序列一次性钉死; + # 读数与注入源比对类判据仍归台架(实测采样类)。files 覆盖被测源、 + # 同链核(phasor/sample_ring)、编排方 adc_task 与 harness 本身。 + - id: meas-unit-test + name: 遥测快照层单测(主机侧) + entry: python test/host/meas/run_unit_test.py + language: system + files: ^(App/algo/meas\.(c|h)|App/algo/(phasor|sample_ring)\.(c|h)|App/task/adc_task\.c|test/host/meas/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + # 7i) 保护定值模型(P1 / L5-1 载体)主机侧单元测试(2026-10-11 加) # 定值是保护动作的"输入宪法":范围校验、整表装载 all-or-nothing、 diff --git a/App/algo/README.md b/App/algo/README.md index 5801bed6..b5ca6b52 100644 --- a/App/algo/README.md +++ b/App/algo/README.md @@ -12,6 +12,7 @@ | `adc_input.h` / `.c` | L1 | 统一语义取数:两片转换器的码值按语义(`UA…IN` / 温度)暴露,并消费 STM32 ADC1 的 DMA 乒乓缓冲 | `adc_input_init()` `adc_input_poll()` `adc_input_raw()` | L1-2 / L1-3 / L1-4 | | `adc_health.h` / `.c` | L1 | 采样有效性判定:吃计数快照,产出静默 / 丢帧 / 单调性 / 错误增量故障位(判定与告警编排分离);L5-2 残余的码值贴轨观察器同层 | `adc_health_check()` `adc_health_flags()` `adc_health_rail_watch()` | L1-6 / L5-2(残余) | | `adc_cal.h` / `.c` | L1.5 | 通道标定折算(「数据可信」卡 2/卡 4):工程量 =(code+b)×k,Q16 系数/零点两段落盘 + 三态 flags + 合法带哨兵 + 两点公式 | `adc_cal_apply()` `adc_cal_flags()` `adc_cal_set()` | L1-5(M4 半边) | +| `meas.h` / `.c` | L2 发布 | 遥测快照(「数据可信」卡 8,M5 地基):200 ms 基波有效值 + 品质快照,双区无锁读,全仓唯一发布者 = adcTask | `meas_tick()` `meas_get()` `meas_seq()` | M5(卡 9/11 与 UI 是读者) | | `sample_ring.h` / `.c` | L2 输入 | 按语义通道存最近 2 周波(40 点),供相量层"以当前时刻为终止"取窗 | `sample_ring_push_frame()` `sample_ring_window()` | L2 输入(为 L2-6 供数) | | `phasor.h` / `.c` | L2 | 全周傅氏:一周波 20 点 DFT → 基波 / 谐波相量(有效值口径),双区原子切换供无锁读 | `phasor_run()` `phasor_get()` `phasor_calc()` | L2-1 / L2-2 / L2-3 / L2-6 / L2-7 | | `freq.h` / `.c` | L2 派生量 | 频率测量:相邻两拍基波相量共轭乘积取角 + 自适应窗滑动平均 + 假值钳制 + 幅值门(UA→IA 源回退) | `freq_run()` `freq_get()` `freq_valid()` | L3-5 测量侧(测频范围 → `docs/算法/继电保护功能复现规划.md` §4.2) | @@ -19,7 +20,8 @@ | `prot_elm.h` / `.c` | L3 | 保护元件框架:通用"越限 + 返回系数滞回 + 定时限"核 + 数据驱动实例表(过流三段 / 过负荷 / 过压 / 欠压 / 零序过压 / 过频 / 低频) | `prot_elm_run()` `prot_elm_eval()` `prot_elm_active_mask()` | L3-1 / L3-3 / L3-4 / L3-5 / L3-6(软件侧) | 依赖(自下而上、无环):`adc_input.h`(纯枚举 + 声明)← `adc_cal`(另依赖 `cfg_file.h` 接口, -纯头文件级)← `sample_ring` ← `phasor` ← `freq`; +纯头文件级)← `sample_ring` ← `phasor` ← `freq`;`meas` ← `sample_ring` + `phasor` +(仅 `phasor_calc` 纯核;频率/品质由调用方注入,**不链** `freq`/`adc_cal`); `prot_set` **独立**;`prot_elm` ← `prot_set` + `phasor` + `freq`。 `adc_health` 只吃调用方喂进来的计数 / 码值,不反向依赖 L1 实现(L5-2 贴轨门限由调用方 从 `adc_cal_rail_ths()` 取,本层仍零算法依赖)。 @@ -33,7 +35,7 @@ `adc_input_poll()` → `adc_input_raw()` ×9 → `adc_cal_apply()` ×9(L1.5 折算,现口径 = ADC 引脚电压 V)+ `adc_health_rail_watch()` ×9(L5-2,码值域)→ `sample_ring_push_frame()` → `phasor_run()` → `freq_run()` → 消费者(`adcTask` 心跳与 L1 健康判定;`protectTask` 每 20 ms 取相量 / 频率跑 -`prot_elm_run()`)。节拍 = TIM3 1 kHz 更新事件,两片转换器同源触发。 +`prot_elm_run()`;**每 200 ms** `meas_tick()` 发布遥测快照供 iec104Task / uiTask 读,卡 8)。节拍 = TIM3 1 kHz 更新事件,两片转换器同源触发。 完整数据流图与初始化落点 → `docs/算法/采集与算法层设计说明.md` §2。 ## 3. 逐文件要点(只留"改代码时必须知道"的;机制与理由见设计文档) @@ -105,11 +107,28 @@ ADC 引脚电压 V)+ `adc_health_rail_watch()` ×9(L5-2,码值域)→ `s - cfg 两段 `[adc_cal]` / `[adc_cal_ofs]` **不挂回调**(RAM 副本即生效值,引擎 load/poll 原地改写,逐点现读);注册在 `app_main_init()` 内核前完成,必须先于 `cfg_file_load`。 +### 3.9 `meas.c` / `.h` — 遥测快照发布(L2 发布侧,「数据可信」卡 8) +- **唯一发布者 = adcTask**:`phasor_run()`/`freq_run()` 同拍、200 ms **tick 经过**分频 + 调 `meas_tick()`(非拍计数——idle/skip 拍拉长名义节拍)。docs/任务规划/数据可信.md §8 + 风险表"快照双写"行 = 评审判据:全仓 `meas_tick` 调用点数须为 1;iec104Task / + uiTask 只读。 +- 值口径(批 2 卡 8 A-lite 裁决):**基波有效值瞬时透传**,与 phasor 使用区 + **同拍同核逐位相等**(单测钉死);**不做** 4 点平均 / 谐波方和根——按 + docs/任务规划/数据可信.md §4.5"上送节拍非平均窗,再平均引入滞后";谐波 k-bin 轨 + (docs/算法/继电保护功能复现规划.md L2-8)扩展只许结构尾部追加, + docs/任务规划/数据可信.md §4.5 字段序 = 协议(布局由单测按偏移钉死,72 B × 双区)。 +- 并发 = phasor 双区 + 单字节索引翻转(L2-7 同型,无锁);seq 只增供消费者判换帧。 +- 品质注入:削顶位图唯一源 = `adc_cal_flags()`(口径 docs/任务规划/数据可信.md §4.2 + 三态表;由 adc_task 编排注入,本层不链 `adc_cal`);TEMP = 最新一拍原始值透传 + + `NT`(「数据可信」决策 5);`MEAS_QDS_*` 低字节与 104 QDS 线上字节同值 + (本层**不** include 协议层头——分层方向)。 +- 104 死区迁移 / 频率点绑定 = 卡 9/卡 11(道D 后续分支);读数与注入源比对 = **待台架实测**。 + ## 4. 构建与测试 ### 4.1 挂载与模拟器边界 -- 八个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`;`../App/algo` 已在 `` 内。 -- 本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路):八个文件全部登记在 +- 九个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`;`../App/algo` 已在 `` 内。 +- 本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路):九个文件全部登记在 `scripts/check_registration.py` 的 `SIM_EXEMPT`(附理由),`registration-check` 钩子反向校验(N-23)。 ### 4.2 主机侧单测(L0.5)——生产源文件以 `#include` 原样纳入 @@ -123,6 +142,7 @@ ADC 引脚电压 V)+ `adc_health_rail_watch()` ×9(L5-2,码值域)→ `s | 元件框架 | `& '.venv\Scripts\python.exe' test/host/prot_elm/run_unit_test.py` | `prot-elm-unit-test` | | 频率测量 | `& '.venv\Scripts\python.exe' test/host/freq/run_unit_test.py` | `freq-unit-test` | | 通道标定(含真实 cfg_file 引擎集成) | `& '.venv\Scripts\python.exe' test/host/adc_cal/run_unit_test.py` | `adc-cal-unit-test` | +| 遥测快照(布局钉死/双区无撕裂/品质汇总) | `& '.venv\Scripts\python.exe' test/host/meas/run_unit_test.py` | `meas-unit-test` | 改本目录任一 `.c/.h`,提交时对应钩子自动跑。**用例清单与判据数字的属主**是各 `test/host/<模块>/` 与 `docs/测试说明/` 下的单测说明,本文不复制(三处抄数字必然打架)。 diff --git a/App/algo/meas.c b/App/algo/meas.c new file mode 100644 index 00000000..1059d315 --- /dev/null +++ b/App/algo/meas.c @@ -0,0 +1,170 @@ +/* + * 模块名称:遥测快照发布层(L2 派生·发布侧,「数据可信」卡 8) + * 模块功能:实现见 meas.h——200 ms 基波有效值 + 品质快照,双区无锁读。 + * 本文件纯算法:输入 = sample_ring 取窗 + 调用方注入(频率/削顶位图), + * 无 RTOS / 无寄存器 / 无日志(「数据可信」层内禁令:本目录不写日志)。 + * 双区不变量(docs/任务规划/数据可信.md §4.5,L2-7 同型,先例 = App/algo/ + * phasor.c 的双区注释):写侧只碰**非使用区**,全部字段填完后一次 + * volatile 单字节 store 翻转使用区;读者先取索引再整体拷贝 → 拷贝期 + * 间写者不可能改读者所在区(两帧间隔 200 ms 远大于 72 B 拷贝)。 + * 单核顺序执行,无需内存屏障;volatile 阻编译器把字段写入挪到翻转后。 + * 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU) + * 作者:DTU 项目组 + * 创建日期:2026-10-25(道D 卡 8) + * 修改记录: + * v1.0 2026-10-25 DTU 项目组 创建:200 ms 遥测快照 + 双区无锁读 + */ + +#include "meas.h" + +#include + +#include "phasor.h" /* phasor_calc():基波 DFT 纯函数核(与使用区同核) */ +#include "sample_ring.h" /* sample_ring_window():以本拍为终止的一周波窗 */ + +/* ===== 双区(计算区 / 使用区),口径同 App/algo/phasor.c 的 L2-7 ===== */ +static meas_snap_t s_snap[2]; +static volatile uint8_t s_snap_use; +static uint8_t s_snap_ready; /* 使用区是否已产出过有效结果 */ +static uint32_t s_snap_seq; /* 只增帧号(写者单写者,读者原子单字读) */ + +/* + * 函数功能:清零一份快照(含 rsv 与全部数组元素) + * 入口参数:p - 目标快照 + * 返 回 值:无 + * 限定条件:p 非空 + * 函数说明:静态辅助;逐字段清零(不依赖 memset,行为与本目录 phasor_zero 同型)。 + */ +static void meas_zero(meas_snap_t *p) { + uint8_t i; + + p->seq = 0u; + p->tick_ms = 0u; + p->valid = 0u; + p->rsv[0] = 0u; + p->rsv[1] = 0u; + p->rsv[2] = 0u; + p->freq_valid = 0u; + p->freq_hz = 0.0f; + for (i = 0u; i < (uint8_t)ADC_SEM_COUNT; i++) { + p->yc[i] = 0.0f; + p->qds[i] = 0u; + } +} + +/* + * 函数功能:遥测快照层初始化 + * 入口参数:无 + * 返 回 值:0 - 成功 + * 限定条件:内核启动前或任务上下文皆可;幂等,可重复调用 + * 函数说明:清双区 + 索引/就绪/序号复位。init 后到首次发布前,meas_get() 恒 + * 返 -2(out 清零)、meas_seq() 恒返 0。 + */ +int meas_init(void) { + meas_zero(&s_snap[0]); + meas_zero(&s_snap[1]); + s_snap_use = 0u; + s_snap_ready = 0u; + s_snap_seq = 0u; + return 0; +} + +/* + * 函数功能:一拍遥测发布(被调即发布,节拍把关在调用方) + * 入口参数:tick_ms - 本拍时基(毫秒) uint32_t + * freq_valid_in - 频率读数新鲜度(freq_valid() 值) uint8_t + * freq_hz_in - 频率读数(freq_last() 值) float + * clip_mask - 削顶通道位图(bit s = 语义通道 s,来源唯一 = + * adc_cal_flags(),编排方注入) uint32_t + * 返 回 值:无 + * 限定条件:仅 adcTask 上下文、全仓唯一调用点(meas.h 发布纪律);须在 + * phasor_run() 之后调用(本拍窗含最新点,与使用区同窗同核) + * 函数说明:电气 8 路(语义序中 TEMP 之前的全部通道,映射表属主 = + * docs/硬件平台/ADC通道映射说明.md)逐路一周波基波 DFT;任一路取窗 + * 不足(开机未满一周波)→ 该路 IV|NT + 整帧 valid=0,但照常发布 + * (品质节拍性刷新,见 meas_tick 头注)。TEMP 只取最新一点透传 + + * NT(「数据可信」决策 5)。品质值不因 IV 清零——"值在场但不可信" + * 与"值缺失"分开,消费者按 qds 裁决。 + */ +void meas_tick(uint32_t tick_ms, uint8_t freq_valid_in, float freq_hz_in, + uint32_t clip_mask) { + meas_snap_t *z = &s_snap[1u - s_snap_use]; /* 计算区 = 非使用区 */ + float win[PHASOR_N]; + phasor_t p; + uint8_t all_ok = 1u; + uint8_t s; + + meas_zero(z); + z->seq = s_snap_seq + 1u; + z->tick_ms = tick_ms; + + for (s = 0u; s < (uint8_t)ADC_SEM_TEMP; s++) { + if (sample_ring_window((adc_semantic_t)s, (uint8_t)PHASOR_N, win) == 0) { + phasor_calc(win, (uint8_t)PHASOR_N, 1u, &p); + z->yc[s] = p.fAmSQ; /* 有效值口径(系数已折算,同 phasor 层) */ + if ((clip_mask & (1uL << s)) != 0u) { + z->qds[s] |= (uint16_t)MEAS_QDS_CLIPPED; + } + } else { + /* 本路窗不足:值缺位(yc 保持 0)+ IV|NT;整帧判不可信 */ + z->qds[s] = (uint16_t)(MEAS_QDS_IV | MEAS_QDS_NT); + all_ok = 0u; + } + } + + /* TEMP:非交流量语义(「数据可信」决策 5),最新一点原始帧值透传 + NT; + * clip_mask 位 8 不参与(adc_cal_flags(TEMP) 恒 0,无削顶语义) */ + { + float tv = 0.0f; + + if (sample_ring_window(ADC_SEM_TEMP, 1u, &tv) == 0) { + z->yc[ADC_SEM_TEMP] = tv; + z->qds[ADC_SEM_TEMP] = (uint16_t)MEAS_QDS_NT; + } else { + z->qds[ADC_SEM_TEMP] = (uint16_t)(MEAS_QDS_IV | MEAS_QDS_NT); + } + } + + z->valid = all_ok; + z->freq_valid = (freq_valid_in != 0u) ? 1u : 0u; + z->freq_hz = freq_hz_in; + + /* 全部字段就绪后才翻索引 + 置就绪(顺序即协议,勿交换) */ + s_snap_seq = z->seq; + s_snap_use = (uint8_t)(1u - s_snap_use); + s_snap_ready = 1u; +} + +/* + * 函数功能:读当前使用区快照(无锁整体拷贝) + * 入口参数:out - 输出快照;失败路径同样清零填充 + * 返 回 值:0 - 成功;-1 - 参数错;-2 - 从未发布过 int + * 限定条件:任意上下文;无(读 volatile 索引 + 普通拷贝) + * 函数说明:先取索引再索引拷贝,避免拷贝中途索引被翻转导致"半旧半新"。 + * 返回 0 时帧内部自洽(同一发布拍的 seq/tick_ms/yc/qds 成套)。 + */ +int meas_get(meas_snap_t *out) { + uint8_t z; + + if (out == NULL) { + return -1; + } + if (s_snap_ready == 0u) { + meas_zero(out); + return -2; + } + z = s_snap_use; + *out = s_snap[z]; + return 0; +} + +/* + * 函数功能:读已发布帧数(只增) + * 入口参数:无 + * 返 回 值:seq,0 = 从未发布(单字读,原子) uint32_t + * 限定条件:任意上下文 + * 函数说明:轻量"是否换帧"探针,正式取数以 meas_get() 为准。 + */ +uint32_t meas_seq(void) { + return s_snap_seq; +} diff --git a/App/algo/meas.h b/App/algo/meas.h new file mode 100644 index 00000000..c3d0886d --- /dev/null +++ b/App/algo/meas.h @@ -0,0 +1,136 @@ +/* + * 模块名称:遥测快照发布层(L2 派生·发布侧,「数据可信」卡 8) + * 模块功能:每 **200 ms** 从 L2 层取一次基波有效值 + 品质,产出一份全仓唯一的 + * 遥测快照(meas_snap_t,布局权威 = docs/任务规划/数据可信.md §4.5), + * 让 104 与 UI **共用同一份上游**——"什么时候算新值、品质怎么判"只此 + * 一处决定(M5 遥测链的地基;104 改读快照属卡 9,UI 侧 ui_data_feed 属 + * 「UI 扩展」波次,两者都只是本快照的读者)。 + * 值口径(批 2 卡 8 的 A-lite 裁决,2026-10-25):**基波有效值瞬时透传** + * (同拍 phasor 使用区口径,系数已在 L1.5 折算进帧值——现单位 = ADC + * 引脚电压 V,二次值语义等 adc_cal_flags() 出 VALID,见 + * docs/算法/采集与算法层设计说明.md §10 红线③);**不做** 4 点平均 / + * 谐波方和根——docs/任务规划/数据可信.md §4.5 明示"200 ms 是上送节拍 + * 不是平均窗,再平均引入滞后";谐波 k-bin 轨(docs/算法/继电保护功能 + * 复现规划.md L2-8)将来扩展时只许在本结构**尾部追加**或留在发布侧 + * 内部状态,既有字段序不得变更。 + * TEMP 通道(「数据可信」决策 5:不参与线性标定):yc = 最新一拍原始 + * 帧值透传,qds 恒置 NT——值在场但非交流量语义,UI 按「数据可信」§4.2 + * 三态表显示占位符,104 无对应点。 + * 并发(docs/任务规划/数据可信.md §4.5 原文口径):沿用 phasor 的 + * **双区 + 单字节活跃索引翻转**(L2-7 同型,不是新机制)——写侧填满 + * 计算区后一次 store 翻转,读者先取索引再整体拷贝,全程无锁; + * seq 只增,消费者据此判"是否换帧"。 + * 本层**纯算法**:不碰寄存器、不碰 RTOS、不自己取数(窗自 sample_ring、 + * 频率/品质由调用方注入)→ 主机侧免桩单测 test/host/meas/(L0.5)。 + * 发布纪律:唯一发布者 = adcTask(在 phasor_run()/freq_run() 之后 200 ms 分频 + * 调 meas_tick())。评审判据 = 全仓 meas_tick 调用点数 == 1(「数据可信」 + * §8 风险表"快照双写"行);其余任务/模块**只读**。 + * 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU) + * 作者:DTU 项目组 + * 创建日期:2026-10-25(道D 卡 8) + * 修改记录: + * v1.0 2026-10-25 DTU 项目组 创建:200 ms 遥测快照 + 双区无锁读 + */ + +#ifndef MEAS_H +#define MEAS_H + +#include + +#include "adc_input.h" /* adc_semantic_t / ADC_SEM_COUNT:yc/qds 下标 = 语义通道 */ + +#ifdef __cplusplus +extern "C" { +#endif + +/* 上送节拍(ms):adc_task 按本宏对 1 kHz 拍做**经过时间**分频(非拍计数—— + * idle/skip 拍会把拍计数口径拉得比名义值长),落点见 App/task/adc_task.c */ +#define MEAS_PERIOD_MS 200u + +/* 每点品质位(uint16)。**低字节与线上 104 QDS 字节同值**(OV/NT/IV,数值 + * 口径同 App/protocol/iec104/iec104_asdu.h 的 IEC104_QDS_*)——但本层**不得** + * include 协议层头(分层方向:算法层在协议层之下,反向依赖禁止),故在此自 + * 定义同值常量;卡 9 的 104 侧消费直接截低字节上送,一致性由本目录单测与 + * test/host/iec104/ 既有断言双向钉住。高字节为 meas 域扩展位(线上不直接 + * 出现,由消费者映射): */ +#define MEAS_QDS_OV (0x0001u) /* 溢出(留位,卡 8 不产出——判据待运行参数化) */ +#define MEAS_QDS_NT (0x0020u) /* 非当前值:未就绪 / 语义未处理(TEMP) */ +#define MEAS_QDS_IV (0x0080u) /* 无效:该路取窗失败,无有效读数 */ +#define MEAS_QDS_CLIPPED (0x0100u) /* 硬件削顶(adc_cal_flags 位图直通, + * 「数据可信」§4.2 三态表;AC 量不可信, + * 消费者应映射为品质无效,如卡 9 的 104 IV) */ + +/* 遥测快照(docs/任务规划/数据可信.md §4.5 权威布局;实测 72 B,2×72 ≈ 144 B + * 双区 = 卡 8 DoD 预算口径,由 test/host/meas/ 的布局钉死用例锁字段偏移—— + * 改字段 = 破协议,须同步卡 9 / UI) */ +typedef struct { + uint32_t seq; /* 只增;消费者据此判断是否换帧 */ + uint32_t tick_ms; /* 发布拍时基(osKernelGetTickCount 原值) */ + uint8_t valid; /* 整帧有效:本拍 8 电气路取窗全部成功 */ + uint8_t rsv[3]; /* 对齐填充,恒 0 */ + float yc[ADC_SEM_COUNT]; /* 工程量(引脚电压 V 域;二次值等 VALID 标定) */ + uint16_t qds[ADC_SEM_COUNT]; /* 每点品质 MEAS_QDS_*(值本身不因品质清零) */ + uint8_t freq_valid; /* 频率读数新鲜度(freq_valid() 注入) */ + float freq_hz; /* 频率读数(freq_last() 注入,未收敛为 0) */ +} meas_snap_t; + +/* + * 函数功能:遥测快照层初始化(清双区 / 索引 / 序号 / 就绪标志) + * 入口参数:无 + * 返 回 值:0 - 成功 int + * 限定条件:内核启动前或任务上下文皆可(纯 .bss 状态,无 RTOS 依赖); + * 本仓落点 = adc_task_init()(每开机一次,幂等可重复调) + * 函数说明:init 后 meas_get() 返 -2、meas_seq() 返 0,直到首次 meas_tick() + * 发布——消费者据 -2 走"未就绪"分支,不读半初始化数据。 + */ +int meas_init(void); + +/* + * 函数功能:一拍遥测发布(200 ms 分频由调用方把关,本函数被调即发布一帧) + * 入口参数:tick_ms - 本拍时基(毫秒) uint32_t + * freq_valid_in - 频率读数新鲜度(freq_valid() 值) uint8_t + * freq_hz_in - 频率读数(freq_last() 值;从未收敛为 0.0) float + * clip_mask - 削顶通道位图(bit s = 语义通道 s;来源唯一 = + * adc_cal_flags() 的 ADC_CAL_F_CLIPPED,编排方注入, + * 本层不链 adc_cal——可用性属主在折算侧) uint32_t + * 返 回 值:无 + * 限定条件:**仅 adcTask 上下文、全仓唯一调用点**(发布纪律见文件头); + * 须在 sample_ring_push_frame()/phasor_run() 之后调用,本拍窗才含最新点 + * 函数说明:8 电气路逐路取"以本拍为终止的一周波"做基波 DFT(复用 phasor_calc + * 纯函数核,与 phasor 使用区同窗同核 → 同拍同值,见单测钉死), + * 任一路窗不足 → 该路 yc=0 + qds=IV|NT、整帧 valid=0(不产出半新半旧 + * 混合帧;与 phasor_run 的差异:phasor 宁可不切区,meas **照常发布** + * 让品质位可见——消费者需要节拍性的品质刷新,seq 亦随之只增)。 + * TEMP = 最新一点透传 + NT(见文件头)。末尾单字节翻转使用区, + * 读者无锁。 + */ +void meas_tick(uint32_t tick_ms, uint8_t freq_valid_in, float freq_hz_in, + uint32_t clip_mask); + +/* + * 函数功能:读当前使用区快照(无锁整体拷贝) + * 入口参数:out - 输出快照;失败(含 -2 未就绪)时**清零填充**,调用方可安全读 + * 返 回 值:0 - 成功;-1 - 参数错;-2 - 从未发布过(out 已清零,seq=0) int + * 限定条件:任意上下文(任务级读者:iec104Task / uiTask / protectTask) + * 函数说明:先取一次使用区索引再索引拷贝——拷贝期间写者最多改"**非使用区**", + * 本帧无撕裂(双区不变量,同 phasor_get 口径;写者两帧间隔 200 ms + * 远大于 72 B 拷贝耗时,读者被压超 200 ms 的竞态窗口按 §4.5 设计接受)。 + * 判"新不新"用 seq:与上次不同则换帧。 + */ +int meas_get(meas_snap_t *out); + +/* + * 函数功能:读已发布帧数(只增) + * 入口参数:无 + * 返 回 值:seq(0 = 从未发布;单字 32 位读,Cortex-M 原子) uint32_t + * 限定条件:任意上下文 + * 函数说明:轻量观测入口(104 周期上送可先比 seq 省 72 B 拷贝); + * 与 meas_get 之间无原子性配对要求(读者以 get 返回的 seq 为准)。 + */ +uint32_t meas_seq(void); + +#ifdef __cplusplus +} +#endif + +#endif /* MEAS_H */ diff --git a/App/task/adc_task.c b/App/task/adc_task.c index 7d6ae24f..36814874 100644 --- a/App/task/adc_task.c +++ b/App/task/adc_task.c @@ -92,6 +92,12 @@ * 门限 adc_cal_rail_ths,码值域与标定正交),锁存边沿 → * DBG_ERROR + 每开机一次 SYSLOG;心跳增 cal: 行(mV 定点 * + valid/clipped 位图 + rail 锁存),台架注入口径见该行判读 + * v3.2 2026-10-25 DTU 项目组 L2 遥测快照发布(「数据可信」卡 8,道D,M5):新模块 + * App/algo/meas.c——本任务为唯一发布者,新帧拍内 + * freq_run() 之后按 200 ms **tick 经过**分频调 + * meas_tick()(全仓调用点 = 1,评审判据);品质注入源 = + * adc_cal_flags() 削顶位图,频率 = 同拍 freq 读数;心跳新增 + * meas: 行(seq/v/ua·un·ia_mv/f/fv),读数比对类判据待台架实测 */ #include "adc_task.h" @@ -103,6 +109,7 @@ #include "freq.h" /* L2 派生量:freq_run()/freq_get()(测频,B8) */ #include "adc_health.h" /* L1-6:采样有效性校验(静默/丢帧/单调性/错误增量/贴轨) */ #include "adc_cal.h" /* L1.5:通道标定折算(「数据可信」卡 2;flags 供 cal 心跳行) */ +#include "meas.h" /* L2 发布侧:遥测快照 meas_tick()(「数据可信」卡 8,道D) */ #include "tpafe5160.h" #include "task_watchdog.h" #include "console_log.h" @@ -137,6 +144,11 @@ static uint32_t s_health_logged; static uint32_t s_rail_edge_latched; static uint8_t s_rail_syslogged; +/* 遥测快照发布闸门(「数据可信」卡 8):上次发布拍时基。分频用 **tick 经过** + * 对比 MEAS_PERIOD_MS,非拍计数——idle/skip 拍会把拍计数口径拉得超过名义 + * 200 ms(与 v2.6 修"重复采样污染 DFT 窗"同族误差,勿"优化"成拍计数) */ +static uint32_t s_meas_ms; + /* 消费者就绪门:由 adcTask 自己在进入等待前置 1;单写者 = 本任务,ISR 只读。 * ⚠ 为什么必须有这道门:TIM3 在 app_main_init()(Src/main.c:140)里就 @@ -265,7 +277,9 @@ int adc_task_init(void) { s_frames_skip = 0u; s_rail_edge_latched = 0u; s_rail_syslogged = 0u; + s_meas_ms = 0u; adc_health_init(); /* 内含 L5-2 贴轨观察器复位(rail_init) */ + (void)meas_init(); /* 遥测快照双区/序号复位(幂等;发布状态属本任务,init 无日志) */ return 0; } @@ -375,6 +389,24 @@ void adc_task_func(void *arg) { /* L2 派生量测频(B8):同拍紧随 phasor_run——本拍使用区已 * 切换,freq 读的就是本拍相量(1 ms 同上下文,无竞态) */ freq_run(); + + /* L2 遥测快照发布(「数据可信」卡 8,道D,M5):200 ms tick 经过分频。 + * 唯一发布者 = 本任务(全仓 meas_tick 调用点数 = 1 为评审判据, + * 「数据可信」§8 风险表"快照双写"行);只在**压环成功的真新帧拍**发布 + * ——idle/skip 拍压环未推进,发布 = 重复旧窗。品质注入唯一源 = + * adc_cal_flags()(§4.2 三态表;纯读,每 200 ms 才跑一遍)。 */ + if ((uint32_t)(now - s_meas_ms) >= MEAS_PERIOD_MS) { + uint32_t cm = 0u; + uint8_t m; + + s_meas_ms = now; + for (m = 0u; m < (uint8_t)ADC_SEM_COUNT; m++) { + if ((adc_cal_flags((adc_semantic_t)m) & ADC_CAL_F_CLIPPED) != 0u) { + cm |= (1uL << m); + } + } + meas_tick(now, freq_valid(), freq_last(), cm); + } } else { s_frames_skip++; } @@ -525,6 +557,26 @@ void adc_task_func(void *arg) { (unsigned long)adc_health_rail_sticky(), (unsigned long)s_rail_edge_latched); } + + /* M5 遥测快照台架观测面(定点打印,MicroLIB 无 %f): + * seq = 发布帧数(10 s 心跳间隔内应 +500±,不涨 = 发布闸门停) + * v = 整帧有效;ua/un/ia = 基波有效值毫伏(未标定域 = 引脚电压; + * 本行是 200 ms 快照轨,cal: 行是当拍瞬时码折算——同量级不同 + * 相位,勿互为判据) + * f/fv = 频率读数与新鲜度(x0.01) + * 读数与注入源比对类判据 = **待台架实测**(实测采样类跳过口径) */ + { + meas_snap_t ms; + + (void)meas_get(&ms); /* 未就绪(-2) = 清零帧,以下打印全 0 */ + DBG_INFO("meas: seq=%lu v=%u ua_mv=%ld un_mv=%ld ia_mv=%ld f=%ld fv=%u", + (unsigned long)ms.seq, (unsigned)ms.valid, + (long)(ms.yc[ADC_SEM_UA] * 1000.0f), + (long)(ms.yc[ADC_SEM_UN] * 1000.0f), + (long)(ms.yc[ADC_SEM_IA] * 1000.0f), + (long)(ms.freq_hz * 100.0f), + (unsigned)ms.freq_valid); + } } report_at = now + ADC_TASK_REPORT_MS; } diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index f2f7e73f..3ff74875 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -1017,6 +1017,11 @@ 1 ..\App\algo\adc_cal.c + + meas.c + 1 + ..\App\algo\meas.c + prot_set.c 1 diff --git a/scripts/check_registration.py b/scripts/check_registration.py index a41330b2..3589179a 100644 --- a/scripts/check_registration.py +++ b/scripts/check_registration.py @@ -95,6 +95,7 @@ SIM_EXEMPT = [ ("App/log/soe_store.c", "SOE 落盘 MCU 胶水(队列+FatFs 单写者,B9),模拟器无消费路径"), ("App/algo/sample_ring.c", "L2 环形缓冲纯算法,模拟器无消费路径"), ("App/algo/adc_cal.c", "L1.5 标定折算纯算法 + cfg 段注册(消费者 = MCU 侧 adcTask 每拍;模拟器不跑采样任务),模拟器无消费路径"), + ("App/algo/meas.c", "L2 遥测快照发布纯算法(唯一发布者 = MCU 侧 adcTask 200 ms 分频;104/UI 侧消费属卡 9/UI 波次另行接线,模拟器无消费路径;L0.5 单测 test/host/meas 覆盖),模拟器无消费路径"), ("App/app/app_main.c", "固件启动/就绪屏障,MCU 专属"), ("App/app/ui_data_feed.c", "UI 快照装配器(依赖 phasor/prot_set/iec104 端口常量等固件侧数据源);模拟器不跑装配链,快照由 simulator/main.c 直接发布固定帧,模拟器无消费路径"), ("App/fs/vol_lock.c", "FreeRTOS 互斥量卷锁,MCU 专属"), diff --git a/scripts/id_registry.json b/scripts/id_registry.json index b287c99b..0a764167 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1774,6 +1774,28 @@ "owner_note": "B 层口径(用户裁定 2026-10-21,同 TC-HOST-ADCHEALTH/TC-HOST-ADCCAL 先例;2026-10-25 道F 批 2 建族):属主登记处 = 单测代码目录,不逐号补文档", "cursor_note": "游标按建族时实测最大号收录(2026-10-25):越界即视为未登记的冒号牌" }, + { + "name": "TC-HOST-MEAS", + "class": "TC", + "title": "主机侧单测用例号:遥测快照层(test/host/meas,道D 卡8)", + "pattern": "\\bTC-MEAS-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/meas/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-MEAS-011", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(用户裁定 2026-10-21,同 TC-HOST-ADCHEALTH/TC-HOST-ADCCAL 先例;2026-10-25 道D 卡8 建族):属主登记处 = 单测代码目录,不逐号补文档", + "cursor_note": "游标按建族时实测最大号收录(2026-10-25):越界即视为未登记的冒号牌" + }, { "name": "TC-HOST-IEC104", "class": "TC", diff --git a/test/host/meas/meas_test.c b/test/host/meas/meas_test.c new file mode 100644 index 00000000..18e9c9cc --- /dev/null +++ b/test/host/meas/meas_test.c @@ -0,0 +1,308 @@ +/* + * 模块名称:遥测快照发布层(L2 派生·发布侧)主机侧单元测试 + * 被测对象:真实源文件(#include 原样纳入,测生产代码而非副本): + * App/algo/meas.c + App/algo/phasor.c + App/algo/sample_ring.c + * 三源均为纯算法(无寄存器/RTOS/文件依赖)→ **无需任何桩**。 + * + * 为什么要主机侧(而不是只等台架):卡 8 的判据一半是"并发与结构"类—— + * 双区切换无撕裂、seq 只增、布局与 §4.5 一致、品质汇总正确——台架上要么不可 + * 观测(撕裂窗 200 ms 量级,人工比对读不到)、要么无法注入(削顶位图由卡 12 + * 标定后才真实出现);主机侧可合成任意幅值/相位的 9 通道帧序列把它们钉死。 + * 读数与注入源比对类判据 = 待台架实测(本套件不覆盖,见「数据可信」卡 8)。 + * + * 覆盖点(TC-MEAS-xxx;属主登记处 = 本目录,族 TC-HOST-MEAS): + * TC-MEAS-001 init 语义:seq=0 / get=-2 且清零填充 / 发布后可见 / init 幂等复位 + * TC-MEAS-002 布局钉死(docs/任务规划/数据可信.md §4.5):sizeof=72 + 八字段偏移 + * TC-MEAS-003 窗不足发布:seq=1 照常发布,电气 8 路 + TEMP 全 IV|NT,valid=0 + * TC-MEAS-004 基波有效值透传:50 Hz 整周波窗 yc = A/√2(≤0.1 %),零幅路=0 且品质 0 + * TC-MEAS-005 同拍同值(§4.5"从相量使用区取"口径钉死):yc[UA/UN/IA] 与 + * phasor_get(基波实例).fAmSQ 逐位相等 + * TC-MEAS-006 品质汇总(卡 8 DoD 原句"任一点 CLIPPED → 该点 qds 置位"): + * mask=0xE6 → UB/UC/IB/IC/IN 置 CLIPPED 且值不清零;UA 不置; + * mask 位 8 对 TEMP 无效(TEMP 恒 NT) + * TC-MEAS-007 seq 递增 + 最新帧:50 连发,get 恒为最新(seq/tick_ms/幅值三元组 + * 互洽 → 双区切换无撕裂、无陈旧读) + * TC-MEAS-008 频率注入透传:fv/f 原样进快照;fv=0 时 f 仍保持(读数保持语义在 + * freq 层,快照只搬运) + * TC-MEAS-009 TEMP 透传最新一点:yc[TEMP] = 最近一拍原始帧值(非均值非滞后) + * TC-MEAS-010 采样环回绕连续:60+60 拍穿环深 40 后读数恒定(≤0.1 %) + * TC-MEAS-011 get 参数守卫:NULL → -1 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 创建日期:2026-10-25(道D 卡 8) + */ + +#include +#include +#include +#include + +#define PI 3.14159265358979323846 +#define W50 (2.0 * PI * 50.0 / 1000.0) /* 50 Hz @ 1 kHz:0.1π/拍(20 点/周波) */ +#define SQRT2 1.41421356237309505 + +/* ===================== 断言 ===================== */ +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" +#include "meas.c" + +/* ===================== 工具 ===================== */ +static double s_ph = 0.0; /* 全局正弦相位(拍推进) */ + +/* 各电气路取 sin(ph + s*0.7) 错相,避免 9 路同值互相掩盖;TEMP = 拍序号 */ +static void push_beats(int n, double amp) { + float f[ADC_SEM_COUNT]; + int i; + int s; + + for (i = 0; i < n; i++) { + for (s = 0; s < (int)ADC_SEM_TEMP; s++) { + f[s] = (float)(amp * sin(s_ph + (double)s * 0.7)); + } + f[ADC_SEM_TEMP] = (float)(1000.0 + s_ph * 100.0); + s_ph += W50; + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + } +} + +/* 每开机一段用例的干净起点:环 + 相量(内含环复位)+ 快照全复位 */ +static void t_reset(void) { + (void)phasor_init(); + (void)meas_init(); + s_ph = 0.0; +} + +static int rms_close(float got, double want) { + double tol = (want > 1.0 ? want : 1.0) * 0.001; /* 0.1 % 容差 */ + return (fabs((double)got - want) <= tol) ? 1 : 0; +} + +static int all_iv_nt(const meas_snap_t *s, int upto) { + int i; + for (i = 0; i < upto; i++) { + if (s->qds[i] != (uint16_t)(MEAS_QDS_IV | MEAS_QDS_NT)) { + return 0; + } + } + return 1; +} + +/* ===================== TC ===================== */ +static void tc_001_init(void) { + meas_snap_t s; + + t_reset(); + CHECK(meas_seq() == 0u, "001a init: seq=0"); + CHECK(meas_get(&s) == -2, "001b init: get=-2"); + CHECK(s.seq == 0u && s.tick_ms == 0u && s.valid == 0u && + s.freq_valid == 0u && s.freq_hz == 0.0f, "001c init: -2 时 out 清零填充"); + { + int i; int z = 1; + for (i = 0; i < (int)ADC_SEM_COUNT; i++) { + if ((s.yc[i] != 0.0f) || (s.qds[i] != 0u)) { z = 0; } + } + CHECK(z == 1, "001d init: yc/qds 全清零"); + } + push_beats(20, 100.0); + meas_tick(200u, 0u, 0.0f, 0u); + CHECK(meas_seq() == 1u && meas_get(&s) == 0, "001e 发布后可读"); + (void)meas_init(); + CHECK(meas_seq() == 0u && meas_get(&s) == -2 && s.seq == 0u, + "001f init 幂等:复位序号/就绪/双区"); +} + +static void tc_002_layout(void) { + CHECK(sizeof(meas_snap_t) == 72u, "002a sizeof=72B(双区 2x72=144B 卡面预算)"); + CHECK(offsetof(meas_snap_t, seq) == 0u, "002b off seq=0"); + CHECK(offsetof(meas_snap_t, tick_ms) == 4u, "002c off tick_ms=4"); + CHECK(offsetof(meas_snap_t, valid) == 8u, "002d off valid=8"); + CHECK(offsetof(meas_snap_t, rsv) == 9u, "002e off rsv=9"); + CHECK(offsetof(meas_snap_t, yc) == 12u, "002f off yc=12"); + CHECK(offsetof(meas_snap_t, qds) == 48u, "002g off qds=48"); + CHECK(offsetof(meas_snap_t, freq_valid) == 66u, "002h off freq_valid=66"); + CHECK(offsetof(meas_snap_t, freq_hz) == 68u, "002i off freq_hz=68"); +} + +static void tc_003_warmup(void) { + meas_snap_t s; + + t_reset(); + meas_tick(200u, 1u, 49.9f, 0xE6u); + CHECK(meas_get(&s) == 0, "003a 窗不足照常发布(品质节拍性刷新)"); + CHECK(s.seq == 1u && s.tick_ms == 200u, "003b seq/tick_ms 落戳"); + CHECK(s.valid == 0u, "003c 整帧 valid=0"); + CHECK(all_iv_nt(&s, (int)ADC_SEM_COUNT), "003d 电气 8 + TEMP 全 IV|NT"); + { + int i; int z = 1; + for (i = 0; i < (int)ADC_SEM_COUNT; i++) { + if (s.yc[i] != 0.0f) { z = 0; } + } + CHECK(z == 1, "003e IV 路值位保持 0(不产伪值)"); + } + CHECK(s.freq_valid == 1u && s.freq_hz == 49.9f, + "003f 频率注入与窗不足解耦(新鲜度独立)"); +} + +static void tc_004_rms(void) { + meas_snap_t s; + int i; + + t_reset(); + push_beats(19, 100.0); + meas_tick(190u, 0u, 0.0f, 0u); + CHECK(meas_get(&s) == 0 && s.valid == 0u, "004a 19 拍窗不足:不产出值"); + push_beats(1, 100.0); + meas_tick(200u, 0u, 0.0f, 0u); + CHECK(meas_get(&s) == 0 && s.valid == 1u, "004b 满 20 拍:整帧 valid=1"); + for (i = 0; i < (int)ADC_SEM_TEMP; i++) { + CHECK(rms_close(s.yc[i], 100.0 / SQRT2), "004c 各路 yc = A/sqrt2(有效值口径)"); + CHECK(s.qds[i] == 0u, "004d 正常路品质 0"); + } + CHECK(s.qds[ADC_SEM_TEMP] == (uint16_t)MEAS_QDS_NT, "004e TEMP = NT(非交流量语义)"); +} + +static void tc_005_use_zone_equiv(void) { + meas_snap_t s; + phasor_t p; + uint8_t k; + + t_reset(); + push_beats(20, 80.0); + phasor_run(); + meas_tick(400u, 0u, 0.0f, 0u); + CHECK(meas_get(&s) == 0, "005a 快照可读"); + for (k = 0u; k < phasor_inst_count(); k++) { + const phasor_inst_t *d = phasor_inst(k); + if ((d != NULL) && (d->harm == 1u) && (phasor_get(k, &p) == 0)) { + CHECK(s.yc[d->sem] == p.fAmSQ, + "005b yc[基波路] 与使用区 fAmSQ 逐位相等(同拍同核)"); + } + } +} + +static void tc_006_clip_quality(void) { + meas_snap_t s; + uint8_t clip[5] = { ADC_SEM_UB, ADC_SEM_UC, ADC_SEM_IB, ADC_SEM_IC, ADC_SEM_IN }; + int i; + + t_reset(); + push_beats(20, 60.0); + meas_tick(200u, 0u, 0.0f, 0xE6u); /* 0xE6 = bit1/2/5/6/7(同 cal: 行 cl 位图) */ + CHECK(meas_get(&s) == 0, "006a 可读"); + for (i = 0; i < 5; i++) { + CHECK((s.qds[clip[i]] & (uint16_t)MEAS_QDS_CLIPPED) != 0u, + "006b 削顶路 qds 置 CLIPPED"); + CHECK(s.yc[clip[i]] > 30.0f, "006c 削顶路值不清零(监视语义保留)"); + CHECK(s.qds[clip[i]] == (uint16_t)MEAS_QDS_CLIPPED, + "006d 削顶路仅 CLIPPED(未叠 IV/NT)"); + } + CHECK(s.qds[ADC_SEM_UA] == 0u, "006e UA 不受位图外影响"); + CHECK(s.qds[ADC_SEM_UN] == 0u && s.qds[ADC_SEM_IA] == 0u, "006f UN/IA 干净"); + CHECK(s.valid == 1u, "006g CLIPPED 不拉低整帧 valid(品质在点级)"); + meas_tick(400u, 0u, 0.0f, 0x1E6u); /* bit8(TEMP)应被忽略 */ + CHECK(meas_get(&s) == 0 && s.qds[ADC_SEM_TEMP] == (uint16_t)MEAS_QDS_NT, + "006h TEMP 品质语义独立:mask 位 8 无效"); +} + +static void tc_007_seq_latest(void) { + meas_snap_t s; + int n; + + t_reset(); + push_beats(20, 50.0); /* 先攒窗,之后每轮再推 20 拍换幅值 */ + for (n = 1; n <= 50; n++) { + push_beats(20, (double)n * 10.0); + meas_tick((uint32_t)n * 1000u, 0u, 0.0f, 0u); + CHECK(meas_get(&s) == 0 && s.seq == (uint32_t)n, + "007a seq 逐拍 +1 无丢帧"); + CHECK(s.tick_ms == (uint32_t)n * 1000u, + "007b tick_ms 与 seq 同戳(读到的帧自洽)"); + CHECK(rms_close(s.yc[ADC_SEM_UA], (double)n * 10.0 / SQRT2), + "007c 幅值与 seq 匹配(无陈旧区误读)"); + } + CHECK(meas_seq() == 50u, "007d meas_seq 与 get 一致收敛"); +} + +static void tc_008_freq(void) { + meas_snap_t s; + + t_reset(); + push_beats(20, 100.0); + meas_tick(300u, 1u, 50.125f, 0u); + CHECK(meas_get(&s) == 0 && s.freq_valid == 1u && s.freq_hz == 50.125f, + "008a 有效频率透传"); + meas_tick(500u, 0u, 49.875f, 0u); + CHECK(meas_get(&s) == 0 && s.freq_valid == 0u && s.freq_hz == 49.875f, + "008b fv=0 时读数仍搬运(保持语义在 freq 层)"); +} + +static void tc_009_temp_passthrough(void) { + meas_snap_t s; + + t_reset(); + push_beats(20, 100.0); + meas_tick(200u, 0u, 0.0f, 0u); + CHECK(meas_get(&s) == 0, "009a 可读"); + { + float t1 = s.yc[ADC_SEM_TEMP]; + CHECK(t1 > 0.0f && s.qds[ADC_SEM_TEMP] == (uint16_t)MEAS_QDS_NT, + "009b TEMP 值在场 + NT"); + push_beats(21, 100.0); + meas_tick(400u, 0u, 0.0f, 0u); + CHECK(meas_get(&s) == 0 && s.yc[ADC_SEM_TEMP] != t1, + "009c TEMP = 最新一点(非均值、非首点)"); + CHECK(rms_close(s.yc[ADC_SEM_UA], 100.0 / SQRT2), + "009d TEMP 换值不影响电气路"); + } +} + +static void tc_010_ring_wrap(void) { + meas_snap_t s1; + meas_snap_t s2; + + t_reset(); + push_beats(60, 100.0); /* 穿环深 40 一次 */ + meas_tick(600u, 0u, 0.0f, 0u); + CHECK(meas_get(&s1) == 0 && s1.valid == 1u, "010a 首次回绕后发布正常"); + push_beats(60, 100.0); /* 再穿一次 */ + meas_tick(1200u, 0u, 0.0f, 0u); + CHECK(meas_get(&s2) == 0 && s2.valid == 1u, "010b 二次回绕后发布正常"); + CHECK(rms_close(s1.yc[ADC_SEM_UA], 100.0 / SQRT2) && + rms_close(s2.yc[ADC_SEM_UA], 100.0 / SQRT2), + "010c 回绕前后读数恒定(≤0.1 %)"); + CHECK(s2.seq == s1.seq + 1u, "010d 回绕不影响 seq"); +} + +static void tc_011_get_guard(void) { + t_reset(); + CHECK(meas_get(NULL) == -1, "011a NULL -> -1"); +} + +int main(void) { + printf("=== Meas Layer Tests ===\n"); + + tc_001_init(); + tc_002_layout(); + tc_003_warmup(); + tc_004_rms(); + tc_005_use_zone_equiv(); + tc_006_clip_quality(); + tc_007_seq_latest(); + tc_008_freq(); + tc_009_temp_passthrough(); + tc_010_ring_wrap(); + tc_011_get_guard(); + + printf("\n=== Meas Layer Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/meas/run_unit_test.py b/test/host/meas/run_unit_test.py new file mode 100644 index 00000000..f7289e94 --- /dev/null +++ b/test/host/meas/run_unit_test.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""遥测快照层(L2 发布侧,meas)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/meas/meas_test.c(它以 #include 纳入 + **生产源文件** App/algo/meas.c + phasor.c + sample_ring.c),在 PC 上离线 + 跑 200 ms 遥测快照的:§4.5 布局钉死 / seq 只增 / 双区切换无撕裂 / + 基波有效值与 phasor 使用区同拍同值 / 品质汇总(CLIPPED 位图)/ TEMP 透传 / + 采样环回绕连续性,失败即非 0 退出。零硬件、零烧录、毫秒级。 + 工具链探测 / 编译运行全部委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只等台架): + 卡 8(docs/任务规划/数据可信.md)判据一半是并发与结构类——双区撕裂窗在 + 台架上不可观测,削顶品质要等卡 12 标定才真实出现;读数与注入源比对类 + 判据仍归台架(实测采样类,本套件不覆盖)。 + +本层为纯算法:无寄存器、无 RTOS、无文件/外设依赖,**不需要任何桩目录**。 + +用法(仓库根目录): + python test/host/meas/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 +设计依据:docs/任务规划/数据可信.md 卡 8(道D,§4.5 快照布局); +创建日期:2026-10-25 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 无桩目录:meas.c / phasor.c / sample_ring.c 仅依赖 stdint/stddef/math 与 +# App/algo 内的头(adc_input.h 为纯枚举 + 声明,可主机编译)。 +BUILD = TestBuild( + name="Meas Layer Tests", + src=os.path.join(HERE, "meas_test.c"), + include_dirs=[ + os.path.join(ROOT, "App", "algo"), + HERE, + ], + summary_marker="=== Meas Layer Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD))