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
+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())