fix(cfg): 配置热重载吞段缺陷修复 + 两点标定工具链与台架实测落档

修复运行期改配置被打回出厂值的缺陷:cfg_file_poll() 按段循环复用同一
份读缓冲,而 s_parse() 通过 s_split_line()/s_key_value() 就地改写缓冲
(把换行与等号换成 NUL)。第一个段解析完,缓冲里已没有键值行,后续 7
个段静默保持出厂值;随后 run_par_store_save() 又把这份出厂值写回
config.ini,用户的标定值被永久覆盖。开机路径 cfg_file_load() 只解析一
轮,所以该缺陷长期不可见,只在运行中改配置时发作。

- s_parse() 改为非破坏性:入参 const char *,逐行 memcpy 到栈缓冲
  (CFG_LINE_TEST_MAX + 1)后再交给会破坏缓冲的解析子函数;超长行跳
  过(保存侧已拒绝落盘超长受管键行)。
- 新增 TC-CONFIG-063:同时改两段内容,断言第二个注册段也重载成功、
  含负值 INT 键存活,把「热重载吞段」钉成机器面。

新增两点标定台架工具链(test/host/):
- cal_two_point.py:整数算术镜像 adc_cal_two_point(),支持单路模式与
  从执行卡 CALDATA 表批量求解(--from-doc),带合法带与零点自检拦截。
- cal_write.py:复用 iec104_master 作库,经 104 定值段写 r 后写 b 并
  读回校验,是所有写入的唯一入口。

adc_task 的 meas: 心跳行精度提到 4 位小数,标定读数需要。

文档:新增《采样数据校正全流程》(端到端导航)与《ADC 两点标定执行
流程》(台架执行卡,含 CALDATA 可填表);ADC通道映射说明新增 §7.3 台
架实测结果(UA 554.687914、IA 29.634259,出厂默认值保持不变);配置
文件设计说明新增解析器契约 v1.19;采集与算法层设计说明 §13 系列与
README 索引同步。

台架实测:UA r=554.687914(出厂 1000.0 偏大 1.80 倍,根因是接线分压
使 PT 一次侧只拿到一半电压,实测与参数算定 606.06 只差 8.5%)、IA
r=29.634259(与出厂 29.4118 只差 0.76%,反证链路未算错)。UN 按用户
裁定本轮不标定,保护闭锁(vl=0x11)是预期行为,未伪造 VALID。

同时入库 .dsh/roles 角色定义(原被 .gitignore 挡住)。
This commit is contained in:
2026-11-13 01:35:58 +08:00
parent 18c087500a
commit 1355dbe1b1
29 changed files with 2470 additions and 201 deletions
+2
View File
@@ -31,6 +31,8 @@
| `ch395f_reuse_host.py` | Socket 复用/中止形态 PC 对端(bench 配套) |
| `ftp_auth_bench.py` | FTP 鉴权/锁定台架腿 |
| `iec104_master.py` | **IEC 60870-5-104 主站客户端(台架复测/保护定值持久化)**:连设备 :2404(工厂默认,`--port` 可指别的)跑 STARTDT → 总召(actcon/四组背景扫描/actterm)→ 经 C_SE 两段写临时压低 OC1 定值确定性触发 YX16 变位(COT=3)→ 恢复 → YK11 两段式 → C_RD 读回;**保护定值持久化模式 `--b7-pre/--b7-post/--b7-mark/--b7-check`**(中间 ST-LINK 复位):YK12 两段投退→保护退出/联动、定值与压板态跨复位保持(含非默认 7.5 往返);纯标准库,输出纯 ASCII 可直接重定向归档为台架证据(判据 exit 0/1/2) |
| `cal_two_point.py` | **两点标定离线计算器**:`--from-doc` 直接读执行卡 §3 `CALDATA` 标记表批量解所有已填路(空行跳过、合并 `WRITE-ALL` 命令);单路 `--sem/--lo-mv/--hi-mv/--known-*` 亦可——镜像 `adc_cal_two_point()` 整数算术(已对拍 `test/host/adc_cal/` 单测 TC-ADCCAL-015 合成参数零偏差);执行卡 `docs/测试说明/专项验证/ADC两点标定执行流程.md` §3;exit 0 可写 / 2 越带拒绝或未填 |
| `cal_write.py` | **两点标定写入器**(库层复用 `iec104_master.py`):C_SE 两段写下发定值段 `0x8050~`(变比 r)/`0x8048~`(零点 b,落笔置 VALID),C_RD 读回复核;每路先 r 后 b,输出纯 ASCII 可归档;执行卡 `docs/测试说明/专项验证/ADC两点标定执行流程.md` §4;exit 0 全过 / 1 有失败 / 2 连接或协议错误 |
### 时间 / 时延
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""两点标定离线计算器:镜像 App/algo/adc_cal.c adc_cal_two_point() 的整数算术。
台架执行卡:docs/测试说明/专项验证/ADC两点标定执行流程.md §3;
公式与量化误差属主:docs/硬件平台/ADC通道映射说明.md §7.2;
精度目标拍板前置:docs/算法/采集与算法层设计说明.md §13.13。
读数来源(见执行卡 §2):交流工况读串口 `meas:` 行 ua_mv/un_mv/ia_mv(= 端子域显示
×1000;IA 一路该字段实为 mA);直流工况直接读 `adc:` 行码值并自行换算。
用法(两点量级 = 用户裁定 2026-11-12 安全优先,执行卡 §2):
python test/host/cal_two_point.py --sem ua --lo-mv 5003 --hi-mv 50018 \
--known-lo 5 --known-hi 50 [--zero-mv 4]
python test/host/cal_two_point.py --sem ia --lo-mv 502 --hi-mv 2005 \
--known-lo 0.5 --known-hi 2 [--zero-mv 3]
批量模式:把读数填进执行卡 §3 的标记表(<!-- CALDATA:BEGIN/END --> 之间),
一条命令解所有已填路并给合并写入命令:
python test/host/cal_two_point.py --from-doc \
docs/测试说明/专项验证/ADC两点标定执行流程.md
表列序:路(ua/un/ia) | 零点 meas | 低点 meas | 高点 meas | 已知低点 | 已知高点;
空单元格=该路未填(跳过并提示),零点列可空。
--sem ua/un:电压族(r_old=1000.0 V/V,--known 填伏);ia:电流族
(r_old=29.4118 A/V,--known 填安;µV 域按 µA 同域处理)。
三路都是 TPAFE 源,k_q16 恒取 ROM 值 ADC_CAL_K_Q16_TPAFE。
输出纯 ASCII。exit 0 = 解出可写入;2 = 求解器拒绝(越带/方向错,先排查勿写)。
"""
import argparse
import re
import sys
# 语义槽位(App/algo/adc_input.h 枚举序:UA0/UB1/UC2/UN3/IA4/IB5/IC6/IN7)
SEM = {
"ua": dict(slot=0, r_old_q16=65536000), # 电压族 TPAFE
"un": dict(slot=3, r_old_q16=65536000), # 电压族 TPAFE
"ia": dict(slot=4, r_old_q16=1927532), # 电流族 TPAFE
}
K_Q16 = 10000000 # ADC_CAL_K_Q16_TPAFE(UA/UN/IA 三路同值)
B_MAX = 8192 # ADC_CAL_B_MAX
R_MIN = 65536 # ADC_CAL_R_MIN(=1.0)
R_MAX = 2147483647 # ADC_CAL_R_MAX(Q16 上界)
V_MAX = 1000000000 # ADC_CAL_V_MAX(±1000 域,严格不等号)
# 工程合法带:run_par_cal_r_set() 粗守 [1.0, 32767.99](App/algo/run_par.c),
# 求解侧同带,防「两点太近 / 没信号」解出天文数字直接写进设备。
R_MIN_F = R_MIN / 65536.0
R_MAX_F = 32767.99
def to_uv(known):
uv = int(round(known * 1.0e6))
if uv <= -V_MAX or uv >= V_MAX:
raise ValueError("known value outside +/-1000 domain (V or A)")
return uv
def two_point(c_lo, c_hi, v_lo, v_hi, k_q16):
"""adc_cal_two_point() 的整数镜像(<<16、+half、截断除逐字一致)。"""
dc = c_hi - c_lo
dv = v_hi - v_lo
if dc <= 0 or dv <= 0:
return -1, 0, 0 # 两点必须同向递增
t = ((dv << 16) + (k_q16 >> 1)) // k_q16 # = dc * r
rq = ((t << 16) + (dc >> 1)) // dc # = r * 65536
if rq < R_MIN or rq > 2147483647:
return -1, 0, 0 # 变比越带:多半把 mV 当 V 填了
tl = ((v_lo << 16) + (k_q16 >> 1)) // k_q16
bb = ((tl << 16) // rq) - c_lo
if bb > B_MAX or bb < -B_MAX:
return -2, 0, 0 # 零点越 ±8192:先查前端,不用系数凑
return 0, rq, bb
def parse_cal_table(path):
"""提取执行卡里 <!-- CALDATA:BEGIN --> ~ <!-- CALDATA:END --> 的 markdown 表。
返回 [(sem, [零点, 低点, 高点, 已知低, 已知高])](原始单元格字符串);
标记不存在返回 None。表头行与分隔行按首列是否 ua/un/ia 自然跳过。
"""
with open(path, encoding="utf-8") as f:
text = f.read()
m = re.search(r"<!--\s*CALDATA:BEGIN\s*-->(.*?)<!--\s*CALDATA:END\s*-->",
text, re.S)
if m is None:
return None
rows = []
for line in m.group(1).splitlines():
line = line.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 6 or cells[0].lower() not in SEM:
continue
rows.append((cells[0].lower(), cells[1:6]))
return rows
def cell_num(s):
if s in ("", "-", "—"):
return None
return float(s)
def solve_and_print(sem, zero, lo, hi, knlo, knhi):
"""解一路并打印判读;成功返回 (sem, slot, r_new, b),拒绝返回 None。
直接在 meas 域比较(`meas:` 行已是乘过当前 r 的端子域读数,单位 mV/mA,
低于 1 个码值时转码值会全 round 到 0,两点差为 0 无法求解)。
公式:r_new = r_old × (known_hi - known_lo) × 1000 / (meas_hi - meas_lo)
—— 读数与注入成正比,比例偏差全部由 r 吸收,故新 r = 旧 r × 真值比/读数比。
b 由零点估计:b ≈ -zero_mv / (k_V × r_old × 1000)(code 域;零点是在旧 r
下读的,故用 r_old 换算回码值,与 r 的新旧无关)。
"""
ch = SEM[sem]
dm = hi - lo
dk = knhi - knlo
if dm <= 0 or dk <= 0:
print("sem=%s slot=%d REJECT: hi>lo required (meas_hi=%.3f, known_hi=%s)" %
(sem, ch["slot"], hi, knhi))
return None
r_old = ch["r_old_q16"] / 65536.0
r_new = r_old * dk * 1000.0 / dm
if r_new < R_MIN_F or r_new > R_MAX_F:
print("sem=%s slot=%d REJECT: r_new=%.3f out of band [%.2f, %.2f] "
"- two points too close or no signal (meas span=%.3f)" %
(sem, ch["slot"], r_new, R_MIN_F, R_MAX_F, dm))
return None
dev = (r_new / r_old - 1.0) * 100.0
# b:零点读数 → code 域偏移
k_V = K_Q16 / 65536.0 * 1e-6 # V/code
b = 0
if zero is not None:
b = -zero / (k_V * r_old * 1000.0)
b = int(round(b))
if abs(b) > B_MAX:
print("sem=%s slot=%d REJECT: b=%d out of band +/-%d codes "
"(zero reading off - check wiring/offset)" %
(sem, ch["slot"], b, B_MAX))
return None
rq = int(round(r_new * 65536.0))
print("sem=%s slot=%d meas_lo=%.3f meas_hi=%.3f known_lo=%s known_hi=%s" %
(sem, ch["slot"], lo, hi, knlo, knhi))
print("OK r_q16=%d r=%.6f b=%d codes (r_old=%.6f dev=%+.3f%%)" %
(rq, r_new, b, r_old, dev))
if abs(dev) > 50.0:
print("WARN |dev|>50%%: suspect unit conversion or wiring, do not "
"write yet")
if zero is not None:
print("ZERO CHECK: zero_mv=%.3f -> b=%d (should be small)" % (zero, b))
return (sem, ch["slot"], r_new, b)
def run_from_doc(path):
rows = parse_cal_table(path)
if rows is None:
print("NO-MARKER: no <!-- CALDATA:BEGIN/END --> table in %s" % path)
return 2
if not rows:
print("EMPTY: CALDATA table has no ua/un/ia rows")
return 2
done = []
failed = 0
for sem, cells in rows:
try:
zero, lo, hi, knlo, knhi = [cell_num(c) for c in cells]
except ValueError:
print("sem=%s bad-number cell %r: keep plain digits" % (sem, cells))
failed += 1
continue
if lo is None or hi is None or knlo is None or knhi is None:
print("sem=%s INCOMPLETE: need low/high meas + known-lo/known-hi "
"(zero optional); skipped" % sem)
failed += 1
continue
try:
res = solve_and_print(sem, zero, lo, hi, knlo, knhi)
except ValueError as e:
print("sem=%s REJECT: %s" % (sem, e))
res = None
if res is None:
failed += 1
else:
done.append(res)
if done:
parts = ["--r 0x%04X=%.6f" % (0x8050 + s, r) for _, s, r, _ in done]
parts += ["--b 0x%04X=%d" % (0x8048 + s, b) for _, s, _, b in done]
print("WRITE-ALL: test/host/cal_write.py " + " ".join(parts))
return 2 if (failed or not done) else 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--sem", choices=sorted(SEM), default=None)
ap.add_argument("--lo-mv", type=float, default=None)
ap.add_argument("--hi-mv", type=float, default=None)
ap.add_argument("--known-lo", type=float, default=None)
ap.add_argument("--known-hi", type=float, default=None)
ap.add_argument("--zero-mv", type=float, default=None)
ap.add_argument("--from-doc", default=None,
help="read CALDATA-marked table from execution doc")
a = ap.parse_args()
if a.from_doc is not None:
return run_from_doc(a.from_doc)
missing = [n for n, v in (("--sem", a.sem), ("--lo-mv", a.lo_mv),
("--hi-mv", a.hi_mv), ("--known-lo", a.known_lo),
("--known-hi", a.known_hi)) if v is None]
if missing:
ap.error("need %s (or use --from-doc)" % ",".join(missing))
res = solve_and_print(a.sem, a.zero_mv, a.lo_mv, a.hi_mv,
a.known_lo, a.known_hi)
if res is None:
return 2
_, slot, r_new, b = res
print("WRITE: test/host/cal_write.py --r 0x%04X=%.6f --b 0x%04X=%d" %
(0x8050 + slot, r_new, 0x8048 + slot, b))
return 0
if __name__ == "__main__":
sys.exit(main())
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""两点标定写入器:C_SE_NC_1 两段写下发 r/b,C_RD 读回复核。
复用台架已验收主站库 test/host/iec104_master.py(不重写协议层)。
地址(App/algo/run_par.c run_par_104_write):r = 0x8050+slot,
b = 0x8048+slot;slot UA0/UN3/IA4。执行卡:
docs/测试说明/专项验证/ADC两点标定执行流程.md §4。
用法:
python test/host/cal_write.py --r 0x8050=995.99 --b 0x8048=-1 \
--read 0x8050,0x8048 --expect 995.99,-1
python test/host/cal_write.py --read 0x8050,0x8054 --expect 995.99,29.52
写入顺序刻意**先 r 后 b**:b 走 adc_cal_set() 置 VALID 位,而 VALID 要求
r 已在带——先让 r 就位,语义干净。--b 必须是整数码值。
PN(否定确认)= 执行段拒绝:看串口 `[ERR] set ch=... rejected` 行。
输出纯 ASCII,可直接重定向归档为台架证据;exit 0 全过 / 1 有失败 / 2 连接或协议错误。
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import bench_defaults # noqa: E402
import iec104_master as m104 # noqa: E402
def parse_pair(text):
addr_s, val_s = text.split("=", 1)
return int(addr_s, 0), float(val_s)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ip", default=bench_defaults.DEVICE_IP)
ap.add_argument("--port", type=int, default=2404)
ap.add_argument("--r", action="append", default=[],
help="0xADDR=FLOAT ratio write (repeatable, issued first)")
ap.add_argument("--b", action="append", default=[],
help="0xADDR=INT zero-code write (repeatable, sets VALID)")
ap.add_argument("--read", default="",
help="comma-separated IOA list, C_RD readback")
ap.add_argument("--expect", default="",
help="comma-separated expected values, same order as --read")
a = ap.parse_args()
reads = [x.strip() for x in a.read.split(",") if x.strip()]
expects = [float(x) for x in a.expect.split(",")] if a.expect else []
if not a.r and not a.b and not reads:
ap.error("need at least one --r / --b / --read")
ck = m104.Checks()
try:
m = m104.Master(a.ip, a.port)
except Exception as exc:
print("CONNECT FAIL: %r" % (exc,))
return 2
try:
m._send(m104.u_frame(m104.U_STARTDT_ACT), "U STARTDT act")
ck.add("startdt_con", m.wait_event(
lambda e: e[0] == "startdt_con", 5.0) is not None)
for text in a.r: # 1) ratio first
addr, val = parse_pair(text)
m104.se_write(m, ck, addr, val, "r_%04X" % addr)
for text in a.b: # 2) zero + VALID
addr, val = parse_pair(text)
if abs(val - round(val)) > 1e-6:
print("WARN b=%s not an integer code, truncated to %.0f"
% (text, round(val)))
val = float(round(val))
m104.se_write(m, ck, addr, val, "b_%04X" % addr)
for i, addr_s in enumerate(reads):
want = expects[i] if i < len(expects) else 0.0
m104.rd_set(m, ck, int(addr_s, 0), want, "rd_%s" % addr_s)
finally:
m.close()
bad = [n for n, ok, _ in ck.items if not ok]
print("SUMMARY %s total=%d fail=%d" %
("PASS" if not bad else "FAIL", len(ck.items), len(bad)))
if bad:
print("FAILED: %s" % ", ".join(bad))
return 0 if not bad else 1
if __name__ == "__main__":
sys.exit(main())
+27 -1
View File
@@ -69,7 +69,9 @@
* TC-CONFIG-061 同尺寸全 0 旧映射块(长度/令牌双检全过)-> 内容判据拦下:
* HELD RAM 保旧、视图刷新后自愈应用 + 回调
* TC-CONFIG-062 保存链遇同尺寸全 0 旧视图 -> 拒存 -1,文件字节/写次数不动,
* 视图恢复后同一保存重试成功落盘
* 视图恢复后同一保存重试成功落盘
* TC-CONFIG-063 多段热重载:同一读缓冲按段各解析一轮,**每个段都必须生效**
* (s_parse 非破坏性;只断言首段会漏掉"后续段静默回默认"缺陷)
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
@@ -1281,6 +1283,30 @@ static void tc_view_content(void) {
CHECK(cfg_file_save() == 0, "save succeeds once view converges");
CHECK(strstr(vfs_get(), "u8=9") != NULL, "dirty value persisted on retry");
}
printf("[TC-CONFIG-063] multi-section reload: every section sees the same "
"non-destructive buffer\n");
{
/* 台架 2026-11-12 实证:热重载对同一份读缓冲**按段各解析一轮**,旧
* s_parse 原地把 '=' / '\n' 改写成 NUL —— 第一个段解析完,后面所有段
* 再拿到的文本里已无键值行 → 静默全体回出厂默认;随后落盘又把出厂值
* 写回文件,永久覆盖用户整定(现象:104 写入当场读得回、约 8 s 后被打
* 回,而开机 load 始终正确,因为 load 只解析一轮)。
* 本用例把**两个段同时改**并要求两段都生效——只断言第一个段是抓不到
* 这个缺陷的(既有 poll 用例都只断言 net 段,故长期绿)。 */
vfs_reset();
register_std();
vfs_put("[net]\r\nu8=5\r\n[sys]\r\nbaud=9600\r\nofs8=-7\r\nofs16=-9\r\n");
cfg_file_poll(); /* 建基线 */
vfs_put("[net]\r\nu8=88\r\n[sys]\r\nbaud=115200\r\nofs8=-7\r\nofs16=-9\r\n");
cfg_file_poll(); /* settle 1 */
cfg_file_poll(); /* settle 2 -> 重载 */
CHECK(s_net_data.u8 == 88u, "first registered section reloaded");
CHECK(s_sys_data.baud == 115200u,
"later registered section reloaded too (buffer not consumed)");
CHECK(s_sys_data.ofs8 == -7, "signed INT key in later section survived");
CHECK(s_sys_data.ofs16 == -9, "second signed INT key in later section survived");
}
}
int main(void) {