L1-6 采样有效性校验:adc_health 判定层 + 告警编排 + 31 用例单测 + 台架闭环

新增 App/algo/adc_health.c/h(纯逻辑判定层,零平台依赖免桩单测):
- 静默:两侧帧序号连续 >=100ms 无前进(电平位可自愈,flags() 为 L5-4 闭锁预留)
- 丢帧:TPAFE 序号一次前进 >1 计 tgap(与 STM32 侧 adc1_gap 对称,边沿事件)
- 单调性:序号倒退锁存位;tick/计数差值法 (int32_t)(a-b) 回绕安全
- 错误增量:FRSTDATA / HAL 错误 / STM32 停摆计数增量,事件位与锁存位编号对齐,
  消费者按位图做"每类每次开机留痕一次"编排

adc_task.c v2.9 告警编排(判定与编排分离):严重事件/电平上升沿 DBG_ERROR +
SYSLOG_SELFTEST(每类每次开机一次防刷屏),下降沿 DBG_INFO 恢复;gap 不告警
(消费者超拍是已知现象,告警会淹没真故障);心跳行增 tgap 观测字段。

主机单测 test/host/adc_health/ 31 用例全绿(100ms 门限两侧 / 边沿语义 /
倒退锁存 / tick 回绕 / NULL 守卫 / init 幂等),登记 adc-health-unit-test 钩子。

台架 @test PASS:零 [ERR](健康不误报)、tgap=1->2 与 adc1_gap 同步可见、
frames 1000.0 Hz;证据 boot_log_data/20261011_prod_adc_health_boot_log.txt。

抬栈:adc_health_report->syslog_post 控制台回显尾使 adcTask 静态最坏链
832->1080 B(512 words 余 968 B < 1024 B ISR 底线)-> 512->640 words,
复测余 1480 B / 57%,堆 min_ever 7152->6640 B(-512 B 符合预算)。
AGENTS/README 任务表、RAM 方案 §2.3、规划文档 §3.1 L1-6 已同步置完成。
This commit is contained in:
2026-10-11 01:28:20 +08:00
parent 6c8f82ec54
commit 13a4e61c27
15 changed files with 1035 additions and 27 deletions
+374
View File
@@ -0,0 +1,374 @@
/*
* 模块名称:ADC 采样有效性校验(L1-6)主机侧单元测试
* 模块功能:在 PC 上对 App/algo/adc_health.c 做离线验证。
*
* 被测对象:真实源文件(以 #include 原样纳入,保证测的是生产代码而非副本):
* ../../../App/algo/adc_health.c
* 本层为**纯逻辑**(仅 stdint.h),**无需任何桩**。
*
* 为什么要主机侧单测(而不是只用板端闭环):
* 1. 静默/倒退/错误增量在健康台架上**不可复现**(复现 = 拔芯片/改驱动),
* 板端只能证明"健康时不误报",证不了"故障时必报";
* 2. 边界(恰好 100ms 门限 / tick 回绕 / 序号倒退后再前进 / 事件只发一拍)
* 只有合成计数序列能精确构造;
* 3. 不占 MCU 的 RAM,毫秒级完成。
*
* 覆盖点(对应 docs/算法/继电保护功能复现规划.md §3.1 L1-6):
* TC-AH-001 init + 首拍只建基线(不误报)
* TC-AH-002 正常 1 kHz 推进 2000 拍:零故障零事件(健康不误报)
* TC-AH-003 TPAFE 静默:99ms 不报 / 100ms 报 / 恢复清除电平位、锁存位留痕
* TC-AH-004 STM32 静默:TPAFE 正常时只报 STM32 侧
* TC-AH-005 TPAFE 丢帧:d=3 → tgap+=2、事件仅当拍(边沿语义)
* TC-AH-006 STM32 gap 计数增量 → 事件(不入锁存:超拍非故障)
* TC-AH-007 序号倒退 → SEQ_BACKWARD 锁存(前进后仍留痕)
* TC-AH-008 FRSTDATA / STM32_ERR / HW_ERR 增量 → 事件位与锁存位编号一致
* TC-AH-009 tick 回绕:跨 0xFFFFFFFF 的正常推进不误报静默;回绕后冻结仍报
* TC-AH-010 NULL 守卫:in=NULL 跳过;out=NULL 只更新内部状态
* TC-AH-011 init 幂等:故障后 re-init 全清
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-11
*/
#include <stdint.h>
#include <stdio.h>
/* ===================== 断言 ===================== */
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 "adc_health.c"
/* ===================== 工具 ===================== */
/* 构造一份"健康"计数快照模板:两侧序号同拍号 seq,错误计数全 0 */
static adc_health_in_t mk_in(uint32_t seq, uint32_t now_ms) {
adc_health_in_t in;
in.tpafe_seq = seq;
in.tpafe_frst_err = 0u;
in.stm32_seq = seq;
in.stm32_errs = 0u;
in.stm32_gaps = 0u;
in.stm32_hw_errs = 0u;
in.now_ms = now_ms;
return in;
}
/* ===================== TC ===================== */
/* TC-AH-001:init + 首拍只建基线 */
static void tc_prime(void) {
adc_health_in_t in;
adc_health_status_t st;
adc_health_init();
in = mk_in(12345u, 1000u); /* 开机时序号已非 0 */
adc_health_check(&in, &st);
CHECK((st.flags == 0u) && (st.events == 0u) && (st.sticky == 0u) &&
(st.tpafe_gaps == 0u),
"TC-AH-001 first check only primes: no flags/events/sticky");
/* 第二拍序号未动但仅 1ms(< 门限)→ 仍不报 */
in.now_ms = 1001u;
adc_health_check(&in, &st);
CHECK(st.flags == 0u, "TC-AH-001 1ms without advance is not a stall");
}
/* TC-AH-002:正常 1 kHz 推进 2000 拍零误报 */
static void tc_healthy(void) {
adc_health_status_t st;
adc_health_in_t in;
uint32_t seq = 0u, now = 0u;
int i, ok = 1;
adc_health_init();
for (i = 0; i < 2000; i++) {
in = mk_in(seq, now);
adc_health_check(&in, &st);
if ((st.flags != 0u) || (st.events != 0u) || (st.sticky != 0u)) {
ok = 0;
}
seq++;
now++;
}
CHECK(ok, "TC-AH-002 healthy 1kHz sweep 2000 beats: zero flags/events");
}
/* TC-AH-003:TPAFE 静默 99/100ms 门限 + 恢复留痕 */
static void tc_tpafe_stall(void) {
adc_health_status_t st;
adc_health_in_t in;
uint32_t seq = 500u, now = 500u;
int i;
adc_health_init();
/* 先跑 10 拍健康节拍建立"前进中"基线 */
for (i = 0; i < 10; i++) {
in = mk_in(seq, now);
adc_health_check(&in, &st);
seq++; now++;
}
/* 仅 TPAFE 冻结(STM32 继续走):前 100 拍(elapsed 0..99ms)不报。
* 注意第 1 拍会消费掉 healthy 循环末尾"未消费的那次 +1",
* 故静默计时从 now=510 起算,第 i 拍 elapsed = i。 */
for (i = 0; i < 100; i++) {
in = mk_in(seq, now);
in.stm32_seq = seq + (uint32_t)i + 1u; /* STM32 侧照常前进 */
adc_health_check(&in, &st);
now++;
}
CHECK((st.flags & ADC_HF_TPAFE_STALL) == 0u,
"TC-AH-003 no TPAFE stall before 100ms");
/* 第 100ms:报 */
in = mk_in(seq, now);
in.stm32_seq = seq + 100u;
adc_health_check(&in, &st);
CHECK((st.flags & ADC_HF_TPAFE_STALL) != 0u,
"TC-AH-003 TPAFE stall at 100ms");
CHECK((st.sticky & ADC_HF_TPAFE_STALL) != 0u,
"TC-AH-003 stall latched into sticky");
CHECK(st.tpafe_stall_ms >= ADC_HEALTH_STALL_MS,
"TC-AH-003 stall_ms reported >= threshold");
/* 恢复:TPAFE 序号重新前进 → 电平位清除、sticky 留痕 */
in = mk_in(seq + 1u, now + 1u);
in.stm32_seq = seq + 101u;
adc_health_check(&in, &st);
CHECK((st.flags & ADC_HF_TPAFE_STALL) == 0u,
"TC-AH-003 stall level clears on recovery");
CHECK((adc_health_sticky() & ADC_HF_TPAFE_STALL) != 0u,
"TC-AH-003 sticky keeps the fault trace after recovery");
}
/* TC-AH-004:STM32 静默(TPAFE 正常) */
static void tc_stm32_stall(void) {
adc_health_status_t st;
adc_health_in_t in;
uint32_t seq = 100u, now = 100u;
int i;
adc_health_init();
for (i = 0; i < 10; i++) {
in = mk_in(seq, now);
adc_health_check(&in, &st);
seq++; now++;
}
/* STM32 冻结(TPAFE 继续走)。⚠ 第 1 拍消费 healthy 循环末尾的"未消费 +1"
* 并把静默计时起点重置为该拍 now → 第 i 拍 elapsed = i,需 101 拍才到 100ms */
for (i = 0; i < 101; i++) {
in = mk_in(seq + (uint32_t)i + 1u, now + (uint32_t)i + 1u);
in.stm32_seq = seq; /* 冻结在旧值 */
adc_health_check(&in, &st);
}
CHECK((st.flags & ADC_HF_STM32_STALL) != 0u,
"TC-AH-004 STM32 stall at 100ms");
CHECK((st.flags & ADC_HF_TPAFE_STALL) == 0u,
"TC-AH-004 healthy TPAFE not falsely stalled");
}
/* TC-AH-005:TPAFE 丢帧计数 + 边沿语义 */
static void tc_tpafe_gap(void) {
adc_health_status_t st;
adc_health_in_t in;
adc_health_init();
in = mk_in(10u, 10u);
adc_health_check(&in, &st); /* 基线 */
in.tpafe_seq = 13u; in.stm32_seq = 11u; in.now_ms = 11u; /* TPAFE d=3 */
adc_health_check(&in, &st);
CHECK((st.events & ADC_HE_TPAFE_GAP) != 0u,
"TC-AH-005 gap event on d>1");
CHECK(st.tpafe_gaps == 2u, "TC-AH-005 d=3 accumulates 2 lost points");
in.tpafe_seq = 14u; in.stm32_seq = 12u; in.now_ms = 12u; /* 恢复正常 d=1 */
adc_health_check(&in, &st);
CHECK(st.events == 0u, "TC-AH-005 gap event is edge-only (next beat clean)");
CHECK(st.tpafe_gaps == 2u, "TC-AH-005 gap counter is cumulative");
}
/* TC-AH-006:STM32 gap 计数增量 → 事件、不入锁存 */
static void tc_stm32_gap(void) {
adc_health_status_t st;
adc_health_in_t in;
adc_health_init();
in = mk_in(1u, 1u);
adc_health_check(&in, &st);
in.stm32_gaps = 1u; in.tpafe_seq = 2u; in.stm32_seq = 2u; in.now_ms = 2u;
adc_health_check(&in, &st);
CHECK((st.events & ADC_HE_STM32_GAP) != 0u,
"TC-AH-006 STM32 gap increment raises event");
CHECK((st.sticky & (ADC_HF_FRSTDATA | ADC_HF_STM32_ERR |
ADC_HF_HW_ERR)) == 0u,
"TC-AH-006 gap does NOT latch sticky (overrun is not a fault)");
}
/* TC-AH-007:序号倒退 → 锁存 */
static void tc_backward(void) {
adc_health_status_t st;
adc_health_in_t in;
uint32_t s, t;
int i;
adc_health_init();
in = mk_in(1000u, 100u);
adc_health_check(&in, &st);
in.tpafe_seq = 995u; in.stm32_seq = 995u; in.now_ms = 101u; /* 倒退 */
adc_health_check(&in, &st);
CHECK((st.flags & ADC_HF_SEQ_BACKWARD) != 0u,
"TC-AH-007 backward seq raises latched flag");
CHECK((st.sticky & ADC_HF_SEQ_BACKWARD) != 0u,
"TC-AH-007 backward latched into sticky");
/* 恢复正常推进 50 拍:SEQ_BACKWARD 不清(锁存语义) */
s = 996u; t = 102u;
for (i = 0; i < 50; i++) {
in = mk_in(s, t);
adc_health_check(&in, &st);
s++; t++;
}
CHECK((adc_health_flags() & ADC_HF_SEQ_BACKWARD) != 0u,
"TC-AH-007 SEQ_BACKWARD persists after normal advance");
}
/* TC-AH-008:三类错误计数增量 → 事件位与锁存位编号一致 */
static void tc_err_counters(void) {
adc_health_status_t st;
adc_health_in_t in;
adc_health_init();
in = mk_in(1u, 1u);
adc_health_check(&in, &st);
in.tpafe_seq = 2u; in.stm32_seq = 2u; in.now_ms = 2u;
in.tpafe_frst_err = 1u;
adc_health_check(&in, &st);
CHECK(((st.events & ADC_HE_FRSTDATA) != 0u) &&
((st.sticky & ADC_HF_FRSTDATA) != 0u),
"TC-AH-008 FRSTDATA increment: event + sticky (same bit number)");
in.tpafe_seq = 3u; in.stm32_seq = 3u; in.now_ms = 3u;
in.stm32_errs = 1u;
adc_health_check(&in, &st);
CHECK(((st.events & ADC_HE_STM32_ERR) != 0u) &&
((st.sticky & ADC_HF_STM32_ERR) != 0u),
"TC-AH-008 STM32 err increment: event + sticky (same bit number)");
in.tpafe_seq = 4u; in.stm32_seq = 4u; in.now_ms = 4u;
in.stm32_hw_errs = 1u;
adc_health_check(&in, &st);
CHECK(((st.events & ADC_HE_HW_ERR) != 0u) &&
((st.sticky & ADC_HF_HW_ERR) != 0u),
"TC-AH-008 HAL err increment: event + sticky (same bit number)");
/* 计数不再变化 → 事件不再重复触发 */
in.tpafe_seq = 5u; in.stm32_seq = 5u; in.now_ms = 5u;
adc_health_check(&in, &st);
CHECK(st.events == 0u, "TC-AH-008 steady error counters raise no repeat events");
}
/* TC-AH-009:tick 回绕安全 */
static void tc_tick_wrap(void) {
adc_health_status_t st;
adc_health_in_t in;
uint32_t seq = 70000u, now = 0xFFFFFF00u;
int i, ok = 1;
adc_health_init();
/* 跨 0xFFFFFFFF 正常推进 512 拍:不得误报静默 */
for (i = 0; i < 512; i++) {
in = mk_in(seq, now);
adc_health_check(&in, &st);
if (st.flags != 0u) {
ok = 0;
}
seq++; now++; /* uint32 自然回绕 */
}
CHECK(ok, "TC-AH-009 tick wrap sweep: no false stall");
/* 回绕后两侧同时冻结。⚠ 第 1 拍消费 sweep 末尾的"未消费 +1"并重置计时起点
* → 第 i 拍 elapsed = i,需 101 拍才到 100ms */
for (i = 0; i < 101; i++) {
in = mk_in(seq, now);
adc_health_check(&in, &st);
now++;
}
CHECK(((st.flags & ADC_HF_TPAFE_STALL) != 0u) &&
((st.flags & ADC_HF_STM32_STALL) != 0u),
"TC-AH-009 stall still detected after tick wrap");
}
/* TC-AH-010:NULL 守卫 */
static void tc_null_guard(void) {
adc_health_status_t st;
adc_health_in_t in;
int i;
adc_health_init();
in = mk_in(1u, 1u);
adc_health_check(&in, &st);
st.flags = 0xA5A5A5A5u;
adc_health_check(NULL, &st);
CHECK(st.flags == 0xA5A5A5A5u, "TC-AH-010 in=NULL leaves out untouched");
/* out=NULL:内部状态照常更新(两侧冻结 101 拍 → 静默位应出现) */
for (i = 0; i < 101; i++) {
in = mk_in(1u, (uint32_t)(2 + i));
adc_health_check(&in, NULL);
}
CHECK((adc_health_flags() & ADC_HF_TPAFE_STALL) != 0u,
"TC-AH-010 out=NULL still updates internal state");
}
/* TC-AH-011:init 幂等 / 故障后全清 */
static void tc_reinit(void) {
adc_health_status_t st;
adc_health_in_t in;
int i;
adc_health_init();
in = mk_in(1u, 1u);
adc_health_check(&in, &st);
for (i = 0; i < 150; i++) { /* 制造双侧静默 */
in = mk_in(1u, (uint32_t)(2 + i));
adc_health_check(&in, &st);
}
CHECK(st.sticky != 0u, "TC-AH-011 pre: sticky non-zero before re-init");
adc_health_init();
CHECK((adc_health_flags() == 0u) && (adc_health_sticky() == 0u) &&
(adc_health_tpafe_gaps() == 0u),
"TC-AH-011 re-init clears flags/sticky/gaps");
in = mk_in(9u, 9u); /* 序号跳变也无妨:首拍只建基线 */
adc_health_check(&in, &st);
CHECK((st.flags == 0u) && (st.events == 0u),
"TC-AH-011 re-prime after re-init is clean");
}
int main(void) {
printf("=== ADC Health Tests ===\n");
printf("-- prime / healthy --\n");
tc_prime();
tc_healthy();
printf("-- stall (TPAFE / STM32) --\n");
tc_tpafe_stall();
tc_stm32_stall();
printf("-- gap / backward / err counters --\n");
tc_tpafe_gap();
tc_stm32_gap();
tc_backward();
tc_err_counters();
printf("-- tick wrap / guards / re-init --\n");
tc_tick_wrap();
tc_null_guard();
tc_reinit();
printf("\n=== ADC Health Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ADC 采样有效性校验(L1-6)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/adc_health/adc_health_test.c(它以 #include 纳入
**生产源文件** App/algo/adc_health.c),在 PC 上离线跑完整的判定逻辑验证:
首拍建基线 / 健康不误报 / 静默 100ms 门限与恢复留痕 / 丢帧边沿语义 /
序号倒退锁存 / 三类错误计数增量 / tick 回绕 / NULL 守卫 / init 幂等,
失败即非 0 退出。零硬件、零烧录、毫秒级。
工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness
`test/host/common/unit_test_harness.py`(本文件只做声明)。
为什么要主机侧单测(而不是只用板端闭环):
静默/倒退/错误增量在健康台架上不可复现(复现 = 拔芯片/改驱动)——板端只能
证明"健康时不误报",证不了"故障时必报";而 100ms 门限、tick 回绕、事件
边沿语义这些边界只有合成计数序列能精确构造。
本层为**纯逻辑**:无寄存器、无 RTOS、无文件/外设依赖,故**不需要任何桩目录**。
用法(仓库根目录):
python test/host/adc_health/run_unit_test.py
或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/adc_health/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
设计依据:docs/算法/继电保护功能复现规划.md §3.1(L1-6);
docs/测试说明/测试开发标准流程.md §4.2(L0.5 主机侧单测)。
创建日期:2026-10-11
"""
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
# 无桩目录:被测代码为纯逻辑(adc_health.c 仅依赖 stdint.h 与自身头)。
BUILD = TestBuild(
name="ADC Health Tests",
src=os.path.join(HERE, "adc_health_test.c"),
include_dirs=[
os.path.join(ROOT, "App", "algo"),
HERE,
],
summary_marker="=== ADC Health Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))