MF4 ##CA 全字段/三维 + cc_type=8 区间枚举 + 稠密包络带线宽双向加粗

一、##CA 数组通道:从「按魔数猜」改成「按记录容量校验」
- 头字段全部按块长度做边界检查后读取:+0 ca_type / +1 ca_storage /
  +2 ndim(U16) / +4 flags(U32) / +8 ca_byte_offset_base(U16) /
  +10 ca_inval_bit_pos(U16) / +16 dim_size[](U64,步长 8),
  其后才是**可选**的 cycle_count[](U32)。
  ⚠️ 实测 CANape 写出的文件没有 cycle_count(块长度恰好到 dim_size 就结束),
  盲读会把下一个块头当数字(实测得到 1481909027)。
- 展开条件(任何一条不满足就原样保留,只显示元素 0,绝不猜):
  ca_storage == 0;ca_type <= 2(0 数组 / 1 缩放轴 / 2 区间轴,三者物理布局相同);
  cn_bit_count ∈ [1, 64];cycle_count 每维 <= 1(元素不被拆到多条记录);
  bit_offset + 元素数 × 位宽 <= cg_data_bytes × 8(真正的完整性校验)。
  规模护栏:元素数 <= 16384 且 元素数 × 记录数 <= 32M 单元格
  (取代原来的 count > 1024 魔数)。
- 元素名按 dim_size 从末维向前取余,1/2/3 维分别得到 [i] / [i][j] / [i][j][k]。
- MF4-Record.MF4 仍是 283 个信号(25 个 ##CA 全部通过新的容量校验,最大 7x9=63)。

二、cc_type = 8(值 -> 文本,区间表)补齐
- cc_val[] 交错存放 (下限, 上限),cc_ref[4 + i] 是第 i 段文本(最后一个引用是默认文本,不使用);
  cc_val_count 必须是偶数(奇数说明表残缺,整表丢弃);下限 > 上限时交换。
- SignalEnumText 新增 rangeMin / rangeMax / rangeTexts,并拆出 HasExact() / HasRanges();
  Find() 先查精确表(cc_type = 7)再查区间表(cc_type = 8,闭区间)。
- 界面没有改动:两种枚举共用同一出口,只在**光标取值标注**追加 " = <文本>",
  常规状态零变化。

三、修复:稠密包络带下改变线宽时竖直沿不变粗
- 根因:包络带每列只有 1 个 x 坐标,旧实现只在 top/bottom 各外扩半个线宽
  => 水平段会变粗,但方波竖直沿(上升/下降段)的横向宽度恒为 1 个像素列。
- 修法:EmitEnvelopeBand 新增 halfWidth 形参,先对上下包络做横向窗口膨胀
  (top 取窗口最小、bottom 取窗口最大,半径 ceil(halfWidth / 列距)),再纵向各外扩;
  膨胀只在同一段内生效,不会跨 NaN 断点连线。

验证(全离屏,不占用鼠标/键盘/屏幕)
- .preflight/convcheck 18/18:新增 3 维命名与取值、元素放不进记录时必须拒绝展开、
  cc_type = 8 的命中/未命中。
- 新增 .preflight/round5check 5/5:合成 20 万采样数字信号(必然走包络带),
  在网格中间行测量竖直沿横向像素宽度:线宽 1px -> 6px,10px -> 30px。
- A/B:临时把横向膨胀半径置 0(等价旧行为)=> 1px -> 2px、10px -> 2px 且如实 FAIL。
- 回归:check 137/137、editcheck 20/20、round3check 31/31、round4check 34/34;
  真实 MF4 信号数 283/8/18/24 不变;make release / make debug 零警告;
  TargetCheck.exe 808,448 字节(789.5 KiB)。

文档:Documents/agent_mem.md 同步 §10 变更日志、§11.2 第 165-170 条硬约束与文末本轮小节
(含「6 位有效数字」的口径说明)。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-20 14:29:49 +08:00
1 parent b315201598
commit 30f853a361
5 files changed
+460 -45

No files matched your search

+217 -21
View File
@@ -12,6 +12,7 @@
#include <functional>
#include <limits>
#include <string>
#include <utility>
#include <vector>
namespace jdi
@@ -61,7 +62,21 @@ namespace jdi
TAB = 5, /**< 值→值查表(**不插值**,取阶梯值)。 */
TAB_RANGE = 6, /**< 值区间→值(`[min,max] → phys`,区间外不换算)。 */
VALUE_TO_TEXT = 7, /**< 值 → 枚举文本(`cc_val[i]` ↔ `cc_ref[i]` 文本)。 */
VALUE_RANGE_TO_TEXT = 8 /**< 值区间 → 文本(未支持,按恒等处理)。 */
VALUE_RANGE_TO_TEXT = 8 /**< 值区间 → 文本(`cc_val` 交错 `(下限,上限)` ↔ `cc_ref[i]` 文本)。 */
};
/**
* @brief `##CA` 的数组类型(`ca_type`)。
*
* 三者**物理布局相同**(`ca_storage == 0` 时元素在本记录内按位宽连续排布,
* 实测 CANape 写出的 25 个 `##CA` 块全部如此),差别只在语义:
* `0` 普通数组 / `1` 缩放轴(特性曲线的断点) / `2` 区间轴(map 的一维断点)。
*/
enum class ArrayKind : int
{
ARRAY = 0, /**< 普通数值数组。 */
SCALING_AXIS = 1, /**< 缩放轴(断点表)。 */
INTERVAL_AXIS = 2 /**< 区间轴(每维断点成对给出)。 */
};
/**
@@ -381,13 +396,29 @@ namespace jdi
* 查表类(4/5)为交错的 `(raw, phys)` 点对,区间表(6)为 `(min, max, phys)` 三元组。
*/
std::vector<double> conversionValues;
SignalEnumText enumText; /**< 值 → 文本枚举表(`cc_type == 7`,可能为空)。 */
/** 值 → 文本枚举表(`cc_type == 7` 精确值 / `cc_type == 8` 值区间,可能为空)。 */
SignalEnumText enumText;
/** @brief `##CA` 数组元素总数(0 = 普通标量通道)。 */
std::size_t arrayElements = 0;
/** @brief `##CA` 存储方式(0 = 元素连续存放在本记录内)。 */
int arrayStorage = 0;
/** @brief `##CA` 数组类型(`ca_type`,见 `ArrayKind`)。 */
int arrayType = 0;
/** @brief `##CA` 各维长度(仅用于生成 `name[i][j]` 这样的名字)。 */
std::vector<std::size_t> arrayDims;
/**
* @brief `##CA` 各维的周期数(`ca_cycle_count`);空 = 文件没写这个字段。
*
* 规范里 `ca_cycle_count[i] > 1` 表示该维的元素被**拆到多条记录**里,
* 这时单个记录内并不含全部元素 ⇒ 不能按「记录内连续」展开(见 `ExpandArrayChannel`)。
* ⚠️ 实测文件里这个字段**不存在**(`##CA` 块长度恰好到 `dim_size[]` 就结束),
* 因此读取前必须先按块长度做边界检查,不能盲读(盲读会拿到下一个块头当作数字)。
*/
std::vector<std::size_t> arrayCycles;
/** @brief `##CA` 的元素起始字节基址(`ca_byte_offset_base`,仅用于一致性校验与诊断)。 */
std::size_t arrayByteOffsetBase = 0;
/** @brief `##CA` 的无效位位置(`ca_inval_bit_pos`,仅记录用于诊断)。 */
std::size_t arrayInvalidBitPos = 0;
/** 数值有效性:只有整数与浮点通道可以绘制。 */
bool numeric = false;
};
@@ -989,35 +1020,125 @@ namespace jdi
}
}
break;
case ConversionType::VALUE_RANGE_TO_TEXT:
// 值区间 → 文本:`cc_val[]` 交错存放 `(下限, 上限)`,`cc_ref[4 + i]` 是第 i 段的文本
//(最后一个引用是「默认文本」,这里不使用)。
// ⚠️ 区间**成对**出现在一个 1-D 表里:周期描述的是**一维断点表**的相邻输出值
//(例如 0–10 → 'A'、10–20 → 'B'),故取相交的第一个区间。
// ⚠️ 段数必须是 `cc_val_count / 2`(奇数个参数说明表是残缺的,整张表丢弃)。
if (valCount >= 2 && (valCount % 2) == 0)
{
const std::uint64_t ccLinkCount = file.LinkCount(cc);
const std::size_t ranges = static_cast<std::size_t>(valCount / 2);
for (std::size_t index = 0; index < ranges; ++index)
{
const std::uint64_t textLink = 4u + index;
if (textLink >= ccLinkCount)
{
break;
}
std::wstring text = file.TextBlock(file.Link(cc, textLink));
if (text.empty())
{
continue; // 没有文本的区间不进入枚举表
}
double lower = file.F64(ccData + 24u + 16u * index);
double upper = file.F64(ccData + 24u + 16u * index + 8u);
if (!std::isfinite(lower) || !std::isfinite(upper))
{
continue;
}
if (lower > upper)
{
std::swap(lower, upper);
}
spec.enumText.rangeMin.push_back(lower);
spec.enumText.rangeMax.push_back(upper);
spec.enumText.rangeTexts.push_back(std::move(text));
}
if (!spec.enumText.HasRanges())
{
spec.enumText = SignalEnumText();
}
}
break;
default:
break;
}
}
// `##CA`(通道数组):一个通道装一整条曲线/矩阵(标定的特性曲线、map)。
// 数据段布局(实测 CANape 写出的文件):type(1) + storage(1) + ndim(U16) + flags(U32)
// + [8..15 保留] + dim_size[](U64 × ndim)。
// 数据段布局(实测 CANape 写出的文件):
// +0 ca_type(U8) / +1 ca_storage(U8) / +2 ndim(U16) / +4 flags(U32)
// +8 ca_byte_offset_base(U16) / +10 ca_inval_bit_pos(U16)
// +16 dim_size[](U64 × ndim) 然后紧接 cycle_count[](U32 × ndim,**可缺省**)
// ⚠️ `cycle_count` 在实测文件里**不存在**(块长度到 `dim_size[]` 就结束)。
// 必须按块长度做边界检查,否则会把下一个块头当成数字读进来(实测得到 1481909027)。
const std::uint64_t ca = (linkCount > 1) ? file.Link(cn, 1) : 0;
if (ca != 0 && file.IsBlock(ca, "##CA"))
{
const std::uint64_t caData = file.DataOffset(ca);
spec.arrayStorage = file.U8(caData + 1);
const std::uint16_t rank = file.U16(caData + 2);
const std::uint64_t caBlockLength = file.BlockLength(ca);
const std::uint64_t caUsable =
(caBlockLength > (caData - ca)) ? (caBlockLength - (caData - ca)) : 0;
spec.arrayType = (caUsable >= 1) ? static_cast<int>(file.U8(caData + 0)) : 0;
spec.arrayStorage = (caUsable >= 2) ? static_cast<int>(file.U8(caData + 1)) : 0;
const std::uint16_t rank = (caUsable >= 4) ? file.U16(caData + 2) : 0;
if (caUsable >= 12)
{
spec.arrayByteOffsetBase = file.U16(caData + 8);
spec.arrayInvalidBitPos = file.U16(caData + 10);
}
std::size_t total = (rank > 0) ? 1 : 0;
bool dimsOk = (rank > 0);
spec.arrayDims.clear();
spec.arrayCycles.clear();
for (std::uint16_t axis = 0; axis < rank && axis < 8; ++axis)
{
if (16u + 8u * (static_cast<std::uint64_t>(axis) + 1u) > caUsable)
{
dimsOk = false; // 维度表被截断:不做任何猜测
break;
}
const std::size_t dim =
static_cast<std::size_t>(file.U64(caData + 16u + 8u * axis));
if (dim == 0)
{
total = 0;
dimsOk = false;
break;
}
spec.arrayDims.push_back(dim);
total *= dim;
// 元素总数太大时不再累乘(只用它判断「是否可展开」)。
if (total <= (1u << 24))
{
total *= dim;
if (total > (1u << 24))
{
total = (1u << 24) + 1u;
}
}
}
spec.arrayElements = total;
// `ca_cycle_count[]`(可缺省):只有真的读得到才记录。
bool cyclesPresent = (rank > 0);
for (std::uint16_t axis = 0; axis < rank && axis < 8; ++axis)
{
const std::uint64_t cycleOffset =
16u + 8u * static_cast<std::uint64_t>(rank) + 4u * static_cast<std::uint64_t>(axis);
if (cycleOffset + 4u > caUsable)
{
cyclesPresent = false;
break;
}
spec.arrayCycles.push_back(static_cast<std::size_t>(file.U32(caData + cycleOffset)));
}
if (!cyclesPresent || spec.arrayCycles.size() != spec.arrayDims.size())
{
spec.arrayCycles.clear(); // 缺省 = 每维 1 个周期(整个数组在本记录内)
}
spec.arrayElements = dimsOk ? total : 0;
}
// 通道类型 2 = 虚拟主通道(CANape 的时间轴),同样可以取值。
@@ -1042,20 +1163,81 @@ namespace jdi
*(实测:`WIN_Motor_StallCurrentVolBrk` 的 9 个 `uint16` 就是从记录 +8 起连续 18 字节)。
* 展开后每个元素都是普通标量信号,因此自动获得光标取值、枚举、图例等全部能力。
*
* ⚠️ 仅处理 `storage == 0`(记录内连续);`storage != 0`(元素分散在不同记录/
* 不同通道组/不同数据组)保持原样(只显示元素 0),避免解析出错值。
* ⚠️ 元素总数超过 `kMaxArrayElements` 时同样保持原样(防止巨型数组把信号列表撑爆)。
* 展开的**必要条件**(任何一条不满足就原样保留,只显示元素 0,绝不猜):
* 1. `ca_storage == 0`:元素在本记录内连续存放;
* 2. `ca_type` ∈ {0 数组, 1 缩放轴, 2 区间轴}:三者物理布局相同(实测 25 个 `##CA` 块全部如此),
* 未知类型(> 2)不展开;
* 3. `cn_bit_count` ∈ [1, 64]:位宽是算元素步进的**唯一依据**,为 0 时不知道元素之间隔多少位
* (VLSD 通道的 `bit_count` 常写 0,那类数据在 `##SD` 里,与"记录内连续"完全不是一套逻辑);
* 4. `ca_cycle_count` 缺失或每一维都为 1:否则元素被拆到**多条记录**里,单个记录内不含全部元素;
* 5. 元素能放进本记录:`bit_offset + 元素数 × 位宽 ≤ 记录位数`(这是真正的完整性校验,
* 替代了「猜一个元素数上限」);
* 6. 规模护栏(防止一个巨型数组把信号列表/网格撑爆):
* 元素数 ≤ `kMaxArrayElements`,且 `元素数 × 记录数` ≤ `kMaxArrayCells`。
*
* @param [IN,OUT] spec 已解析的通道方案(不展开时原样平移进 `out`)。
* @param [IN] cycles 该通道组的记录条数(`cg_cycle_count`,用于规模护栏)。
* @param [IN] recordBytes 单条记录字节数(`cg_data_bytes`,0 = 未知则跳过第 5 条校验)。
* @param [OUT] out 展开后的通道列表。
*/
void ExpandArrayChannel(ChannelSpec&& spec, std::vector<ChannelSpec>& out)
void ExpandArrayChannel(ChannelSpec&& spec, std::uint64_t cycles, std::size_t recordBytes,
std::vector<ChannelSpec>& out)
{
constexpr std::size_t kMaxArrayElements = 1024;
/** 单个通道最多展开成多少条信号。 */
constexpr std::size_t kMaxArrayElements = 16384;
/** 元素总数 × 记录数的上限(≈ 生成的数据点数 × 8 字节的内存/绘制代价)。 */
constexpr std::size_t kMaxArrayCells = 32u << 20;
const std::size_t count = spec.arrayElements;
if (count <= 1 || spec.arrayStorage != 0 || count > kMaxArrayElements || spec.bitCount == 0)
bool expandable = (count > 1);
if (expandable && spec.arrayStorage != 0)
{
expandable = false; // 元素不在本记录内(跨通道/跨通道组)
}
if (expandable && (spec.arrayType < 0 || spec.arrayType > 2))
{
expandable = false; // 未知 `ca_type`
}
if (expandable && (spec.bitCount == 0 || spec.bitCount > 64))
{
expandable = false; // 位宽未知
}
if (expandable)
{
for (const std::size_t perAxis : spec.arrayCycles)
{
if (perAxis > 1)
{
expandable = false; // 元素被拆到多条记录
break;
}
}
}
if (expandable && count > kMaxArrayElements)
{
expandable = false;
}
if (expandable && cycles > 1 && count > kMaxArrayCells / static_cast<std::size_t>(cycles))
{
expandable = false;
}
if (expandable && recordBytes > 0)
{
const std::uint64_t needBits = static_cast<std::uint64_t>(spec.bitOffset) +
static_cast<std::uint64_t>(count) *
static_cast<std::uint64_t>(spec.bitCount);
if (needBits > static_cast<std::uint64_t>(recordBytes) * 8u)
{
expandable = false; // 放不进本记录 ⇒ 元素位置不符合「记录内连续」
}
}
if (!expandable)
{
spec.arrayElements = 0;
out.push_back(std::move(spec));
return;
}
const std::size_t strideBits = spec.bitCount;
const std::vector<std::size_t> dims = spec.arrayDims;
for (std::size_t index = 0; index < count; ++index)
@@ -1063,11 +1245,24 @@ namespace jdi
ChannelSpec element = spec; // 复制:换算表 / 枚举表 / 数据类型全部沿用
element.arrayElements = 0;
element.bitOffset = spec.bitOffset + index * strideBits;
// 维度后缀:按 `dim_size[]` 从末维向前取余,因此 1/2/3/… 维都得到 `[i][j][k]`。
std::wstring suffix;
if (dims.size() == 2 && dims[1] > 0)
if (dims.size() >= 2)
{
suffix = L"[" + std::to_wstring(index / dims[1]) + L"][" +
std::to_wstring(index % dims[1]) + L"]";
std::vector<std::size_t> axisIndex(dims.size(), 0);
std::size_t remainder = index;
for (std::size_t axis = dims.size(); axis-- > 0;)
{
const std::size_t size = dims[axis];
axisIndex[axis] = (size > 0) ? (remainder % size) : 0;
remainder = (size > 0) ? (remainder / size) : 0;
}
for (const std::size_t value : axisIndex)
{
suffix += L"[";
suffix += std::to_wstring(value);
suffix += L"]";
}
}
else
{
@@ -1646,6 +1841,7 @@ namespace jdi
const std::uint32_t cgDataBytes = file.U32(cgData + 24);
// 解析通道链。
// `##CA` 数组通道就地展开:需要 `cg_data_bytes`(单个记录容量)与 `cycles`(规模护栏)。
std::vector<ChannelSpec> channels;
WalkChain(file, file.Link(cg, 1), "##CN", kMaxChannelsPerGroup,
[&](std::uint64_t cn)
@@ -1653,9 +1849,9 @@ namespace jdi
ChannelSpec spec;
if (ParseChannel(file, cn, spec))
{
// `##CA` 数组通道在这里就地展开成多个标量通道
//(一个通道装整条曲线时,旧实现只能看到元素 0)。
ExpandArrayChannel(std::move(spec), channels);
// 一个通道装整条曲线时,旧实现只能看到元素 0。
ExpandArrayChannel(std::move(spec), cycles, static_cast<std::size_t>(cgDataBytes),
channels);
}
return true;
});