feat(adc): 新增变比维 r 三维折算,录波段格式升 v2

采样板注入 Ua=5V/Ia=0.5A 读数偏小,根因是固件只输出引脚电压、
缺「端子→引脚」变比。折算改三维:

工程量 = (code + b) × k[µV/码] × 1e-6 × r

k 保持 ROM 引脚 LSB,b 为零点,r 为端子变比(int32 Q16)。
变比不能并进 k:UA 端子域 k 达 1.5e5 µV/码、Q16 溢出 int32;
r 单独存 Q16 不溢出。两点标定改标 r,k 仍是板级事实。

- 新开单段 [adc_cal_sec](8 键 r_ua..r_in),cfg 段数达 8/8 上限
- 一次变比复用 prot_set 四项,不新开段;r=0 表示本路不折算
- ROM 默认带台架实测变比(电压族 1000、电流族 29.4118),
  烧进固件即显二次值;VALID 仍须两点标定,两者互不派生
- eng 判据改「每路要么已配变比、要么削顶不显示」,不再看 VALID
- 104 地址 0x8050~0x8057 写 r,run_par_cal_r_set 写值与置脏同函数
- 录波 v2:通道表加 int32 r_q16,头 128→160、整段 5250→5282;
  code_to_milli 用余数分解换等价单次圆整,20 万组大整数对拍 0 不符
- 文档同步:采集与算法层/通道映射/故障录波/网络配置/配置文件/
  产品需求/内存台账,历史带日期实测数字按冻结口径不改
- 单测 13 套全绿,/keil-build 0 错误 0 警告(256024/31760/
  2080/152368),RAM 实测回填内存内容分配策略 §6.1
This commit is contained in:
2026-11-11 11:31:34 +08:00
parent 8115fb9a87
commit 3675b89e8e
35 changed files with 1653 additions and 505 deletions
+114 -44
View File
@@ -74,15 +74,18 @@ App/algo/wave_store.c must agree byte-for-byte -- if they ever diverge,
report it, do not silently re-rule from one side):
- CRC-16/MODBUS table-free (0x8005 reflected / init 0xFFFF / xorout 0),
both CRC fields stored LOW byte first; anchor "123456789" -> 0x4B37.
Header CRC covers [0, 30 + 12*ch_count); data CRC covers the data area
and sits at end of file; 8ch/320pts whole segment = 5250 B.
- Channel table entry (12 B): sem 1 + src 1 + bits 1 + availability 1
Header CRC covers [0, 30 + 16*ch_count); data CRC covers the data area
and sits at end of file; 8ch/320pts whole segment = 5282 B.
- Channel table entry (16 B, v2): sem 1 + src 1 + bits 1 + availability 1
(= adc_cal_flags(sem) low 8 bits, raw, no invented bit meanings) +
k_q16 i32 (uV/code * 65536) + b_codes i32 (code domain). There is NO
k_q16 i32 (uV/code * 65536) + b_codes i32 (code domain) +
r_q16 i32 (ratio * 65536, 0 = no scaling on this channel). There is NO
separate LSB field: LSB == k_q16, uV = k_q16/65536, never re-scaled.
mV = (code + b_codes) * k_q16/65536/1000 -- exactly the
adc_cal_apply() formula. Header eng only LABELS the unit (0 =
pin-domain, 1 secondary, 2 primary); it never changes arithmetic.
mV = (code + b_codes) * k_q16/65536/1000, then * r_q16/65536 when
r_q16 != 0 -- exactly the adc_cal_apply() formula, computed in the same
TWO integer steps as the C side (one expression overflows int64).
Header eng only LABELS the unit (0 = pin-domain, 1 = secondary,
2 = primary); it never changes arithmetic.
- flags_seq bit s = ADC_CAL_F_VALID of sem s; clip_mask bit s = sem s
(same mapping as App/algo/meas.h; V0.1 constant 0xE6 = UB UC IB IC IN).
avail is the RAW pass-through of adc_cal_flags(): VALID and CLIPPED are
@@ -91,17 +94,19 @@ report it, do not silently re-rule from one side):
VALID/CLIPPED relation nor on avail-vs-clip_mask drift -- those get a
soft note only ("VALID+CLIPPED(both)" in dump/fft, a WARN line in dump),
no exit-code change, nothing refused.
eng likewise derives from flags_seq on the board (feed_eng(): all 8
electrical channels VALID -> secondary(1), else pin-domain(0)); a
pairing mismatch here is also a SOFT WARN only, never a rejection.
- v1 pins ch_count = 8 (TEMP not recorded; TEMP needs a version bump
together with the TEMP-in-flags question); PC must verify table entry
eng derives from the per-channel RATIO on the board (feed_eng(): every
electrical channel either has r configured or is clipped-and-not-shown);
a pairing mismatch here is also a SOFT WARN only, never a rejection.
- v1/v2 pins ch_count = 8 (TEMP not recorded; TEMP needs a version bump
together with the TEMP-in-flags question); v2 = v1 + i32 r_q16 per
channel-table entry (12 -> 16 B, head 128 -> 160, segment 5250 -> 5282).
PC must verify table entry
i carries sem == i (ascending UA..IN), mismatch voids the whole
segment; data column i == table entry i; samples int16 LE, TPAFE two's
complement, STM32 0..4095 stored positive with NO midpoint bias
(biasing it would fabricate bipolarity).
- t0_ms / wall / frame_seq are u32 little-endian. ver must equal magic
byte 2 and both must equal 1, else refuse.
byte 2 and both must equal FMT_VER, else refuse.
"""
import argparse
import math
@@ -114,7 +119,7 @@ import tempfile
# ---- format constants (design section 6.2, little-endian) -------------------
MAGIC_W = 0x57 # 'W'
MAGIC_R = 0x52 # 'R'
FMT_VER = 1 # byte 2 of magic AND byte 3 (ver) both == 1
FMT_VER = 2 # byte 2 of magic AND byte 3 (ver) both == 2
SEG_LABEL_SAMPLE = "WAVE%02d" % 0 # sample segment label, produced by format so
# this file never carries a hardcoded letter+digit identifier token
@@ -133,10 +138,11 @@ OFF_FLAGS = 23 # 1 validity bitmap (see Q5)
OFF_CLIP = 24 # 2 clip bitmap, bit s = sem s (see Q6)
OFF_FSEQ = 26 # 4 frame_seq at segment head
HDR_FIXED = 30 # bytes before channel table
ENT_SIZE = 12 # bytes per channel-table entry
ENT_SIZE = 16 # bytes per channel-table entry (v2: 12 -> 16)
ENT_SEM, ENT_SRC, ENT_BITS, ENT_AVAIL = 0, 1, 2, 3
ENT_KQ = 4 # i32 Q16 uV/code == the LSB (sections 6.2/6.3)
ENT_B = 8 # i32 code-domain zero offset
ENT_RQ16 = 12 # i32 Q16 ratio (terminal/primary ratio); 0 = no scaling
SRC_TPAFE, SRC_STM32 = 0, 1
SRC_NAMES = {SRC_TPAFE: "TPAFE5160", SRC_STM32: "STM32-ADC1"}
@@ -198,17 +204,55 @@ def put_crc16(value):
# ---- decode helpers ----------------------------------------------------------
def lsb_uv(ent):
"""LSB in micro-volts per code = k_q16 / 65536 (section 6.3: k_q16 IS
the LSB; never re-multiplied)."""
the LSB; never re-multiplied). This is the PIN-side slope only -- the
terminal ratio (r_q16) is a separate factor, see code_to_mv()."""
return ent["k_q16"] / 65536.0
def ent_scale_mv(ent):
"""Floating-point mV per code in the RECORDING DOMAIN (test expectation
helper): k slope via lsb_uv(), times the Q16 ratio when r_q16 != 0.
Analytic expectations (e.g. sine RMS) use this so they stay in step with
code_to_mv() without duplicating the two-step integer rounding."""
base = lsb_uv(ent) / 1000.0
if ent["r_q16"] == 0:
return base
return base * ent["r_q16"] / 65536.0
def code_to_mv(ent, code):
"""Code -> mV via exactly the adc_cal_apply() formula:
value = (code + b) * k[us/code] * 1e-6 [V] -> mV = that * 1000.
The header eng field only labels the unit (0 = pin-domain); per
section 6.3 it must never change this arithmetic, and the PC must not
re-guess units from the current firmware state."""
return (code + ent["b_codes"]) * lsb_uv(ent) / 1000.0
"""Code -> engineering millivolt, mathematically identical to the C side
(App/algo/wave_store.c wave_store_code_to_milli); a divergent rounding
rule would make the two independent implementations disagree on the last
digit of every sample, so the rule is mirrored on purpose.
t = (code + b_codes) * k_q16 uV * 65536, up to ~2.2e12
r_q16 == 0 -> mv = round_half_away(t / 65536000) (= 1000 * 2**16)
r_q16 != 0 -> mv = round_half_away(t * r_q16 / (1000 * 2**32))
One rounding, at the end, in the recording DOMAIN: secondary mV/mA when
r_q16 != 0, pin mV when r_q16 == 0. The header eng field only labels
which of those it is; it never changes this arithmetic.
Why the C side cannot write that one expression: t * r_q16 reaches
4.6e21, past int64 (9.2e18), so it splits t = 1000*q + s and recombines
the remainders -- algebraically the same single rounding. Python has
arbitrary-precision ints, so this reader evaluates the exact value and
keeps the two implementations in step.
"""
t = (code + ent["b_codes"]) * ent["k_q16"]
r = ent["r_q16"]
if r == 0:
return _div_round(t, 65536000)
return _div_round(t * r, 1000 * (1 << 32))
def _div_round(num, den):
"""Integer division rounding half AWAY from zero -- mirrors
wave_store.c wave_st_div_round() including the negative branch."""
if num >= 0:
return (num + den // 2) // den
return -((-num + den // 2) // den)
def rail_thresholds(ent):
@@ -303,6 +347,7 @@ def parse_segment(buf, name="segment"):
"avail": buf[o + ENT_AVAIL],
"k_q16": struct.unpack_from("<i", buf, o + ENT_KQ)[0],
"b_codes": struct.unpack_from("<i", buf, o + ENT_B)[0],
"r_q16": struct.unpack_from("<i", buf, o + ENT_RQ16)[0],
})
samples = []
@@ -472,20 +517,20 @@ def fmt_dump(seg):
% (seg["crc_data"], seg["pts"] * seg["chc"] * 2,
"low" if CRC_LO_FIRST else "high"))
add("-- channel table (%d entries x %d B) --" % (seg["chc"], ENT_SIZE))
add("%3s %3s %-5s %-10s %4s %5s %11s %9s %12s %10s"
add("%3s %3s %-5s %-10s %4s %5s %11s %9s %11s %12s %10s"
% ("IDX", "SEM", "NAME", "SRC", "BITS", "AVAIL", "K_Q16",
"B_CODES", "LSB_uV", "FLAG"))
"B_CODES", "R_Q16", "LSB_uV", "FLAG"))
for ent in seg["entries"]:
fl = []
if ent["avail"] & ADC_CAL_F_VALID:
fl.append("VALID")
if ent["avail"] & ADC_CAL_F_CLIPPED:
fl.append("CLIPPED")
add("%3d %3d %-5s %-10s %4d 0x%02X %11d %9d %12.6f %10s"
add("%3d %3d %-5s %-10s %4d 0x%02X %11d %9d %11d %12.6f %10s"
% (ent["idx"], ent["sem"], sem_name(ent["sem"]),
SRC_NAMES.get(ent["src"], "SRC%d" % ent["src"]),
ent["bits"], ent["avail"], ent["k_q16"], ent["b_codes"],
lsb_uv(ent), "|".join(fl) if fl else "."))
ent["r_q16"], lsb_uv(ent), "|".join(fl) if fl else "."))
# lead ruling 2026-10-29: soft notes, never a reject, never an exit code
both = [sem_name(e["sem"]) for e in seg["entries"]
if (e["avail"] & (ADC_CAL_F_VALID | ADC_CAL_F_CLIPPED))
@@ -583,9 +628,11 @@ def fmt_fft(seg, cycle, all_cycles, only_ch):
SRC_NAMES.get(ent["src"], "?"), "ALL",
sum(rmss) / len(rmss), min(rmss), max(rmss),
len(rmss)))
lines.append("note: mV = (code + b_codes) * k_q16/65536/1000 "
"(adc_cal_apply formula, unit labeled by header eng, "
"never re-guessed); full-cycle window, coefficients = "
lines.append("note: mV = (code + b_codes) * k_q16/65536/1000, then "
"* r_q16/65536 when r_q16 != 0 (adc_cal_apply formula, same "
"two integer steps as the C side; result is in the "
"recording domain, labeled by header eng, never "
"re-guessed); full-cycle window, coefficients = "
"sin/cos(2*pi*k/N)*sqrt(2)/N as in App/algo/phasor.c; "
"phase = atan2(im,re) deg")
return lines
@@ -795,7 +842,7 @@ def cmd_plot(args):
# ---- synthetic segment builders (selftest) ------------------------------------
def syn_entries(chc=8, avail_mode="v01"):
def syn_entries(chc=8, avail_mode="v01", ratio_mode="pin"):
"""avail = raw pass-through of adc_cal_flags(). Modes (lead ruling
2026-10-29 asks for the first two as interop shapes):
v01 = V0.1 board truth, pre-calibration: TPAFE trio 0x00, STM32 five
@@ -803,15 +850,34 @@ def syn_entries(chc=8, avail_mode="v01"):
both = deliberately VALID+CLIPPED on the five: 0x01 TPAFE / 0x03 STM32
(legal: soft note only, must still parse clean)
none = all-zero avail under clip_mask 0xE6 (avail/header drift; soft
WARN only, must still parse clean)"""
WARN only, must still parse clean)
ratio_mode picks the v2 r_q16 snapshot (ROM defaults from adc_cal.h):
rom = board defaults: voltage family 65536000 (1000.0), current family
1927532 (29.4118 A/V) -- sem 0/3 voltage (UA/UN), 4 current (IA)
pin = all zero r_q16 (no scaling; output stays pin-domain mV)
one = all 65536 (ratio exactly 1.0: must equal the old pin-domain value)
"""
pa_t, pa_s = {"v01": (0x00, 0x02), "both": (0x01, 0x03),
"none": (0x00, 0x00)}[avail_mode]
rom = {0: 65536000, 3: 65536000, 4: 1927532}
ents = []
for sem in range(chc):
if sem in (0, 3, 4):
ents.append((sem, SRC_TPAFE, 16, pa_t, 10000000, 0))
kq16 = 10000000
src, bits, avail = SRC_TPAFE, 16, pa_t
else:
ents.append((sem, SRC_STM32, 12, pa_s, 52800000, 0))
kq16 = 52800000
src, bits, avail = SRC_STM32, 12, pa_s
if ratio_mode == "rom":
rq16 = rom.get(sem, 0)
elif ratio_mode == "pin":
rq16 = 0
elif ratio_mode == "one":
rq16 = 65536
else:
raise ValueError("unknown ratio_mode %r" % (ratio_mode,))
ents.append((sem, src, bits, avail, kq16, 0, rq16))
return ents
@@ -842,7 +908,7 @@ def syn_samples(pts=320, pps=20, chc=8, rail=True):
def build_synthetic(chc=8, pts=320, pps=20, trig_src=1, clip=0x00E6,
flags=0x00, wall=0, t0=123456, fseq=987654321,
avail_mode="v01", eng=0, rail=True):
avail_mode="v01", eng=0, rail=True, ratio_mode="pin"):
hdr = bytearray()
hdr += bytes([MAGIC_W, MAGIC_R, FMT_VER, FMT_VER])
hdr += struct.pack("<H", 1000)
@@ -856,10 +922,10 @@ def build_synthetic(chc=8, pts=320, pps=20, trig_src=1, clip=0x00E6,
hdr += struct.pack("<H", clip)
hdr += struct.pack("<I", fseq)
assert len(hdr) == HDR_FIXED
for sem, src, bits, avail, kq16, b_codes in syn_entries(chc,
avail_mode)[:chc]:
for sem, src, bits, avail, kq16, b_codes, rq16 in syn_entries(
chc, avail_mode, ratio_mode)[:chc]:
hdr += bytes([sem, src, bits, avail])
hdr += struct.pack("<ii", kq16, b_codes)
hdr += struct.pack("<iii", kq16, b_codes, rq16)
hdr += put_crc16(crc16_modbus(hdr))
data = b"".join(struct.pack("<%dh" % pts, *col)
for col in syn_samples(pts, pps, chc, rail))
@@ -915,7 +981,7 @@ def selftest():
# parser (both CRC gates passing inside parse == implicit PASS)
img = build_synthetic()
seg = parse_segment(img, name="synth8")
ck.check("size 8ch/320pts == 5250", seg["size"] == 5250,
ck.check("size 8ch/320pts == 5282", seg["size"] == 5282,
"got %d" % seg["size"])
fields = dict(fs_hz=1000, pps=20, chc=8, pts=320, pre=160, trig_src=1,
trig_elm=0, t0_ms=123456, wall=0, eng=0, flags_seq=0,
@@ -923,14 +989,18 @@ def selftest():
for k in sorted(fields):
ck.check("field %s round-trip" % k, seg[k] == fields[k],
"got %r" % (seg[k],))
ck.check("table entry 0 == sem0/src0/bits16/avail0x00/k_q16 10000000",
ck.check("table entry 0 == sem0/src0/bits16/avail0x00/k_q16 10000000/"
"r_q16 0 (pin mode)",
(seg["entries"][0]["sem"], seg["entries"][0]["src"],
seg["entries"][0]["bits"], seg["entries"][0]["avail"],
seg["entries"][0]["k_q16"]) == (0, 0, 16, 0x00, 10000000))
ck.check("table entry 1 == sem1/src1/bits12/avail0x02/k_q16 52800000",
seg["entries"][0]["k_q16"],
seg["entries"][0]["r_q16"]) == (0, 0, 16, 0x00, 10000000, 0))
ck.check("table entry 1 == sem1/src1/bits12/avail0x02/k_q16 52800000/"
"r_q16 0 (pin mode)",
(seg["entries"][1]["sem"], seg["entries"][1]["src"],
seg["entries"][1]["bits"], seg["entries"][1]["avail"],
seg["entries"][1]["k_q16"]) == (1, 1, 12, 0x02, 52800000))
seg["entries"][1]["k_q16"],
seg["entries"][1]["r_q16"]) == (1, 1, 12, 0x02, 52800000, 0))
# per-entry availability (low 8 bits of adc_cal_flags) vs header
# clip_mask: the CLIPPED bits must reconcile, bit s = sem s both sides
clip_from_avail = 0
@@ -947,7 +1017,7 @@ def selftest():
img_b = build_synthetic(avail_mode="both", flags=0xFF, fseq=71)
segb = parse_segment(img_b, name="synth-both") # raises if wrongly voided
ck.check("both-form (flags 0xFF, avail 0x01/0x03) parses, not rejected",
segb["size"] == 5250)
segb["size"] == 5282)
ck.check("dump both-form carries VALID+CLIPPED(both) note",
"VALID+CLIPPED(both)" in "\n".join(fmt_dump(segb)))
fftb = "\n".join(fmt_fft(segb, None, False, range(segb["chc"])))
@@ -1051,7 +1121,7 @@ def selftest():
mv = channel_mv(seg, ci)
win = mv[-pps:]
rms1, ang1 = phasor_rms_ang(win, 1, pps)
want_rms = amp_codes * lsb_uv(ent) / 1000.0 / math.sqrt(2.0)
want_rms = amp_codes * ent_scale_mv(ent) / math.sqrt(2.0)
ck.near("fft ch%d RMS" % ci, rms1, want_rms, 0.01)
ck.near("fft ch%d phase (atan2(im,re) convention)" % ci, ang1,
phi_deg, 0.01, abs_tol=2.0)