From 181f95cc3a8c84082fe0934ac8eb6ef0861e3660 Mon Sep 17 00:00:00 2001 From: "DeepSeek-V4.1-Flash" <> Date: Tue, 20 Oct 2026 01:56:47 +0800 Subject: [PATCH] =?UTF-8?q?MF4=20=E8=A1=A5=20##SD/##SI/VLSD=20+=20?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7=E6=9E=9A=E4=B8=BE=E6=96=87=E6=9C=AC=20enumTe?= =?UTF-8?q?xt=20+=20.mat=20=E6=94=B9=E9=9A=8F=E6=9C=BA=E8=AF=BB=EF=BC=88?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E8=BF=9B=E5=BA=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 修复上轮记录的两个已知问题 - MatLoader: LoadPlainObject 不再为拿 dims 调 ReadDataset(整块解码第二次), 改用 ReadDatasetInfo;普通 v7.3 大矩阵的时间/内存不再翻倍。 - MdfFile::TextBlock 注释与实现对齐:真的支持 ##MD(UTF-8 XML,无长度前缀)。 2) 信号枚举信息(以 TestData/NEW-MF4.mf4 为目标) - 新增 SignalEnumText(DataTable.hpp):values[] + texts[] + Empty()/Find(), 命名用 enumText —— 它描述"取值代表哪个状态",不是单位。 - DataTable::columnEnumText 与 columns 同下标;SignalData::enumText 由 BuildSignalGroup 移动赋值;其它格式(CSV/.mat)保持为空。 - MF4 cc_type = 7:cc_val[i](F64,数据段 +24)↔ cc_ref[4 + i](##TX/##MD 文本), 最后一个引用是默认文本,不使用。 - 界面只在**光标取值标注**追加 " = <枚举文本>"(等号两侧各一个空格、无尖括号), 查表用采样原值(ApplyDrawTransform 之前);常规状态显示一个像素都不变。 - 实测:NEW-MF4.mf4 8 信号 / 4 条带枚举(32+5+32+5),MF4-Record.MF4 另有 2 种(12/32)。 3) MF4 支持范围 - ##SI 采样索引:只有索引 ≥2 条且全部 < 周期数才生效(逐记录有效性位图)。 实测部分工具给每个通道挂只含常量 513/4 的 ##SI,必须忽略,否则整列变 NaN。 - ##SD + VLSD(cn_type == 1):##SD 数据段是「UINT32 长度 + 字节」样本序列, 记录里放 64 位偏移;AppendSignalData 递归展开 SD/DZ/DL/HL(单通道上限 512MB), DecodeVlsdValue 先按直接偏移、失败再按 >>1 回退,取样本第一个元素。 - CN 的 cn_data / md_unit / md_comment 下标按链接总数反推:8 链接(MDF 4.0 风格) = 换算[4]/数据[5]/单位[6]/注释[7],9 链接(4.1)= [5]/[6]/[7]/[8]。 实测 Simulink 导出的“4.10”仍写 8 链接,不能写死 Link(cn,4/5)。 - 主通道:cn_sync_type 1/2/3(时间/角度/距离)→ 名字 t/time/timestamp/zeit/时间 (含 t[...])→ 第一个数值通道兜底;cg_flags 第 2 位(远程主通道)跳过 sync 判定。 - 记录长度优先 cg_data_bytes(CG 数据段 +24);旧实现把 U8(cgData) 当 cg_record_id_len,其实是 cg_flags 低字节(总线事件位)。 - 进度:先走 DG 链得到真实组数(不再写死 64),hasBytes = true,bytesRead 取 MdfFile::ReadAt 累计(夹到文件大小);4MB 分块读取检查取消。 - ##DZ:数据段前有 28 字节头(original_type/zip_type/param/original_size/zip_size), 旧实现整段当 zlib 流是错的;只支持纯 deflate(zip_type = 0)。 - 一个信号都没有时,错误信息附带「布局无法解析 / 缺少时间通道 / 空数据组」计数。 4) .mat:内存映射 → 随机读 + 元数据块缓存(消除 SEH 风险) - MatHdf5::Open 改为 CreateFileW(FILE_FLAG_SEQUENTIAL_SCAN) + ReadFile; 删除 MappedBytes,改为 ReadAt / Cached(16 × 64KB LRU + 上次命中槽)/ Tag / ReadData / ReadCString。I/O 失败只会变成 false,不再触发页访问异常。 - 数据读取:连续布局按 4MB 切片;分块布局按需 + 64KB 对齐。 固定 4MB 预读在交错存放的分块数据集上会把读盘量放大到 2.2 倍(2874MB/1293MB), 改后 1301MB。 - 关键提速:dims=[N]、chunk=[32768,1] 归一化后 rank=2、每行 1 字节,逐行算坐标 是 12.9 亿次迭代;新增「rank == 2 且 chunkDims[1] == dims[1]」快路径(整块一次 sink)。BIG_MAT.mat(1.29GB / 5 × 16,142,471):21.5s → 7.9s,私有内存 +741MB。 - 进度:Open 立刻上报「0 / 总大小」,之后每读一块回调 ReadProgress(bytesRead,total) → MatLoader 映射到 0.02–0.98 并单调夹紧(字节进度与逐流解码取较大值),收尾 1.0。 遮罩层百分比不再一上来就 100%;取消会中止(StreamDataset 返回 false),不留 NaN 空洞。 - 连续布局数据不足(文件被截断)现在明确失败,不再静默截短。 验证(全部离屏:无可见窗口、不注入鼠标键盘屏幕) - 新增 .preflight/perf/loadercheck(数据层)32/32: NEW-MF4 8 信号 / 4 条枚举 / 枚举文本逐条核对 / 9396 与 94 行; MF4-Record 124 信号 / 3 轴 / 4 条枚举;data-sample.mat 4 组 8 信号 / 行数; BIG_MAT.mat 5 信号 × 16,142,471 + 进度(total 已知、ratio 单调、bytes 递增、 起点 ≤0.05、终值 1.0、更新 ≥10 次)。 - 新增 .preflight/perf/enumui(真实 Application + SW_HIDE)12/12: 只显示 gVR_WinOpenReq[0],光标落在枚举采样点上 —— 标签宽度 265px; 临时清空 enumText 后 25px;差值 240px 与 " = WinOpenReqAtDrvr_Idle" 的 UiMeasureTextWidth 完全一致;恢复后宽度回到 265px。 - 回归:datacheck 69/69、matcheck 21/21、matui 15/15(loading bytes 达到总大小)、 verify 49/49。 - make release / make debug 零警告。Build/Release/TargetCheck.exe = 820,224 字节 (上轮 785,920,+34KB)。(Target.exe 正在被用户运行的程序占用,链接步骤未覆盖它。) --- Components/PlotView/Src/PlotViewPaint.cpp | 12 + Documents/agent_mem.md | 86 ++- Utilities/Inc/DataModel.hpp | 8 + Utilities/Inc/DataTable.hpp | 63 ++ Utilities/Inc/MatH5.hpp | 115 ++- Utilities/Inc/Mf4Loader.hpp | 22 +- Utilities/Src/DataModel.cpp | 6 + Utilities/Src/MatH5.cpp | 393 ++++++++-- Utilities/Src/MatLoader.cpp | 78 +- Utilities/Src/Mf4Loader.cpp | 836 ++++++++++++++++++---- 10 files changed, 1328 insertions(+), 291 deletions(-) diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 1308730..07e93c6 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -4913,6 +4913,9 @@ namespace jdi { continue; } + // 枚举文本按**采样值原值**查找(下面会叠加系数/绝对值/镜像等界面变换, + // 那些变换只影响画出来的数值,不改变「这个采样值代表哪个状态」)。 + const double enumLookupValue = value; // 取值读数必须与画出来的波形一致:同样套用系数/绝对值/镜像。 value = ApplyDrawTransform(*resolved.style, value); @@ -4948,6 +4951,15 @@ namespace jdi } } + // 信号带「值 → 文本」枚举表时(如 MDF4 `cc_type = 7`),在数值之后追加 + // ` = <枚举文本>` 尾缀(等号两侧各一个空格、不用尖括号)。 + // ⚠️ 常规状态(未启用光标)不显示任何枚举文本,观感与以前完全一致。 + if (const std::wstring* enumText = resolved.data->enumText.Find(enumLookupValue)) + { + text += L" = "; + text += *enumText; + } + const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale; const float textHeight = 17.0f * m_scale; // 左右越界时三角贴在视界内侧、尖端朝外;数值标注必须放在三角的**内侧** diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index fc3aafc..a0f326e 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -3001,6 +3001,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-19 | DeepSeek-V4.1-Flash(**本机**) | **`.mat` 流式解码(内存映射 + 逐 chunk 填充)+ MDF4 `##DL/##DZ/##HL` 数据块 + 光标数据点被时间面板遮住改三角(详见 §12 末尾「2026-10-19 第四轮」与 §11.2 第 154–159 条)**。① **`.mat`**:`MatHdf5::Open` 由“整文件 `fread` 进 `vector`”改为 **`MapViewOfFile`**(`MappedBytes` 只读视图,解析代码零改动);新增 `ReadDatasetInfo` + `StreamDataset(ByteSink)`(连续布局 4MB 切片、分块布局逐 chunk inflate)与 `ReadDoubleStream`(SDI `#data#` 字节流 → double,7 字节尾巴处理非 8 对齐 chunk);`ReadNumbers` 改为流式转换;`MatLoader` 先收集信号清单再按(分组, 时间流)合并成 `TableBlock`,时间列按内容去重、数据列 `std::move`,进度改成 `0–0.02 映射 / 0.02–0.98 逐流解码`。实测 `BIG_MAT.mat`(1.29GB / 5×16.14M):**117s → 12.5s**、私有内存 **+1234MB → +740MB**(数据本身 739MB)。② **MDF4**:`CollectDataSegments` 递归展开 `##DL`/`##HL`,识别 `##DT`/`##DV`/`##DZ`;`ReadRecordSegments` 支持跨段拼接与压缩段 inflate;压缩段记录长度改用通道位宽推算。`Problem-MF4.mf4` 由 **4 → 8 个信号**(与 Simulink SDI 一致)。③ **光标标记**:`MarkerCoveredByCursorLabel` + `CoverHiddenMarker`,圆点被底部时间读数/Δt 面板盖住时改为向下三角(原始信号与派生曲线两处)。验证:新增离屏 `.preflight/round4check` **34/34**;**A/B**(禁用 `##DL` ⇒ 2 项失败、禁用 `CoverHiddenMarker` ⇒ 3 项失败);回归 `round3check` 31/31、`editcheck` 20/20、`check` 137/137;`make release`/`make debug` 零警告。 | +| 2026-10-20 | DeepSeek-V4.1-Flash(**本机**) | **MF4 `##SD`/`##SI`/VLSD + 信号枚举文本 `enumText`(光标尾缀)+ `.mat` 由内存映射改为随机读(详见 §12 末尾「2026-10-20」)**。① **§2.6 两处**:`LoadPlainObject` 不再 `ReadDataset` 二次解码(改用 `ReadDatasetInfo` 取维度);`MdfFile::TextBlock` 真正支持 `##MD`(UTF-8 XML)。② **枚举**:新增 `SignalEnumText`(`values[]`/`texts[]`/`Find`)挂在 `DataTable::columnEnumText` 与 `SignalData::enumText`;MF4 `cc_type = 7` 按 `cc_val[i] ↔ cc_ref[4+i]` 解析。界面只在**光标取值标注**追加 ` = <枚举文本>`,常规状态零变化。`NEW-MF4.mf4` 实测 8 信号 / 4 条带枚举(32+5+32+5 项)。③ **MF4**:`##SI` 采样索引(≥2 条且全部 < 周期数才生效;实测工具会给每个通道挂只含常量 513 的 `##SI`,必须忽略)、`##SD` + VLSD(`UINT32` 长度前缀 + 64 位偏移,直接偏移优先、`>>1` 回退)、CN 链接下标按链接总数反推(4.0 的 8 链接 / 4.1 的 9 链接)、主通道接受 `sync_type` 1/2/3 + `cg_flags` 远程主通道、记录长度优先 `cg_data_bytes`、进度分母改真实 DG 数并上报字节、`##DZ` 28 字节头修正。④ **`.mat`**:删除 `MappedBytes`,改 `ReadFile` 随机读 + 16×64KB 元数据 LRU + 按需数据读取(固定 4MB 预读会把读盘量放大 2.2 倍 ⇒ 改后 1.0 倍);新增 rank=2 连续 chunk 快路径(12.9 亿次逐行迭代 ⇒ 每块一次回调),`BIG_MAT.mat` **21.5s → 7.9s**、私有 +741MB;进度按真实 `bytesRead / totalBytes` 推进(不再一上来 100%),取消会中止而不是留下 NaN 空洞。验证:新增离屏 `loadercheck` **32/32**、`enumui` **12/12**;回归 `datacheck` 69/69、`matcheck` 21/21、`matui` 15/15、`verify` 49/49;`make release`/`make debug` 零警告;`Target.exe` 785,920 → **820,224 字节**。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -6646,4 +6648,86 @@ DG3 data=0 cycle=0 (空组,一个字符串通道) (表中文本的前缀 `WinOpenReqAtDrvr_` 已省略;`gVR_WinOpenReq[1]` 的 CC 内容完全相同。) ⚠️ 目前 `ParseChannel` 只认 `cc_type ∈ {0,1}`,所以这两个通道在界面上显示为原始整数; 要显示标签需要按 `cc_type == 7` 读 `links[4..4+valCount-1]` 并与 `cc_val[]` 一一对应, -再随信号传递到界面(本轮按要求**未做代码改动**)。 \ No newline at end of file +再随信号传递到界面(本轮按要求**未做代码改动**)。 + +--- + +## 本轮(2026-10-20,本机):MF4 `##SD`/`##SI`/VLSD + 枚举文本 + `.mat` 改为随机读 + +> 用户需求:① 修复 §2.6 的两个已知问题;② 为信号增加**枚举信息**(准确命名 `enumText`,不是 +> 单位),以 `TestData/NEW-MF4.mf4` 为目标;常规状态显示不变,**只有启用光标时**在数值后追加 +> ` = <枚举文本>`(等号两侧各一个空格、无尖括号);③ MF4 支持 `##SD`/`##SI`、VLSD、 +> `cg_flags` 主通道判定、进度分母;④ `.mat` 由内存映射改为「随机读 + 元数据块缓存」, +> 流式读取并动态更新「已读/总大小」,在可能范围内加速;⑤ 离屏验证后提交,不占用鼠标键盘屏幕。 +> +> 验证(全部离屏,无可见窗口、无注入输入): +> * 新增 `.preflight/perf/loadercheck.exe`(数据层)**32/32**; +> * 新增 `.preflight/perf/enumui.exe`(真实 `Application` + `SW_HIDE` + 光标标签宽度)**12/12**; +> * 回归 `datacheck` 69/69、`matcheck` 21/21、`matui` 15/15、`verify` 49/49; +> * `make release` / `make debug` 零警告;`Target.exe` 785,920 → **820,224 字节**(+34KB)。 + +### A. §2.6 两个已知问题 + +1. **`LoadPlainObject` 解码两遍**:改为先 `ReadDatasetInfo`(只取 `dims`)再 `ReadNumbers`, + 不再为了维度去调用 `ReadDataset`(那会把整块数据解码第二次)。 +2. **`MdfFile::TextBlock` 注释与实现不符**:现在真的支持 `##MD`(数据段是 UTF-8 XML, + 没有长度前缀 ⇒ 直接转宽字符串)。`##TX` 的两种写法(长度前缀 / CANape 裸文本)保持不变。 + +### B. 枚举信息(`enumText`) + +- 数据层新增 `struct SignalEnumText { values[]; texts[]; Empty(); Find(value); }` + (定义在 `DataTable.hpp`)。**命名刻意用 `enumText`**:它描述「取值代表哪个状态」, + 不是单位、不是注释。`DataTable::columnEnumText` 与 `columns` 同下标; + `SignalData::enumText` 由 `BuildSignalGroup` 移动赋值。 +- MF4 侧:`cc_type = 7` 时按 `cc_val_count` 读 `cc_val[]`(F64,从数据段 +24 开始)与 + `cc_ref[4 + i]`(`##TX`/`##MD` 文本);最后一个引用是「默认文本」,不使用。 + 文本为空的取值不进入枚举表(保持 `values/texts` 对齐)。 +- 界面:**只有光标取值标注**追加 ` = <枚举文本>`(`PlotViewPaint.cpp` 里 `text += L" = "`)。 + 常规状态(未启用光标)一个像素都不变;枚举查表用**采样原值**(在 `ApplyDrawTransform` 之前), + 避免系数/镜像把状态查丢。 +- `TestData/NEW-MF4.mf4` 实测:8 信号,4 条带枚举 + (`gVR_WinOpenReq[0/1]` 32 项、`gVP_WindowLearnSt[0/1]` 5 项);`MF4-Record.MF4` 也有 4 条 + (`gVP_ActrStopReazon[0]` 12 项、`gV_WinPosnSts[0]` 32 项)。 + +### C. MF4:`##SD` / `##SI` / VLSD / 主通道 / 进度 + +- **`##SI`(采样索引)**:只有当索引表 **≥2 条且全部 < 周期数**时才生效(逐记录有效性位图, + 其余行保持 NaN)。⚠️ 实测 `MF4-Record.MF4` / `NEW-MF4.mf4` 里每个通道都挂了一个只含 + **1 个值(513 / 4)** 的 `##SI`——那显然不是采样下标,必须忽略,否则整列会被清成 NaN。 +- **`##SD` + VLSD(`cn_type == 1`)**:`##SD` 数据段是「`UINT32` 长度 + 字节」的样本序列; + 记录在该通道 `cn_byte_offset` 处放 **64 位偏移**。实现 `AppendSignalData`(递归展开 + `##SD`/`##DZ`/`##DL`/`##HL`,单通道上限 512MB)与 `DecodeVlsdValue` + (直接偏移优先,失败再按 `>>1` 回退;取样本的第一个元素作为采样值)。 +- **`cn_data` / `cn_md_unit` / `cn_md_comment` 的链接下标按链接总数反推**: + 8 链接(MDF 4.0 风格)= 换算[4]/数据[5]/单位[6]/注释[7];9 链接(4.1)= [5]/[6]/[7]/[8]。 + ⚠️ 实测 Simulink 导出的「4.10」文件仍写 8 链接 ⇒ 不能写死 `Link(cn, 4/5)`。 +- **主通道判定**:`cn_sync_type` = 1/2/3 → 名字(`t`/`time`/`timestamp`/`zeit`/`时间`, + 含 `t[...]`)→ 第一个数值通道兜底。`cg_flags` 第 2 位(远程主通道)时跳过 sync 判定。 +- **记录长度**:优先 `cg_data_bytes`(CG 数据段 +24),其次「载荷 ÷ 周期数」,最后按位宽推算。 + ⚠️ 旧实现把 `U8(cgData)` 当 `cg_record_id_len`,那其实是 `cg_flags` 的低字节(总线事件位)。 +- **进度**:先走一遍 DG 链得到**真实组数**(不再写死 64),`hasBytes = true`, + `bytesRead` 来自 `MdfFile::ReadAt` 的累计(上报时夹到文件大小);4MB 分块读取时检查取消。 +- **`##DZ` 修正**:数据段前面有 28 字节头(`original_type`/`zip_type`/`param`/ + `original_size`/`zip_size`),旧实现把整段当 zlib 流是错的。只支持纯 deflate(`zip_type = 0`)。 +- 失败诊断:没有任何可绘制通道时,错误信息附带「布局无法解析 / 缺少时间通道 / 空数据组」计数。 + +### D. `.mat`:内存映射 → 随机读 + 元数据缓存 + +- `MatHdf5::Open` 改为 `CreateFileW(FILE_FLAG_SEQUENTIAL_SCAN)` + `ReadFile` 随机读; + `MappedBytes` 删除,改为 `ReadAt` / `Cached` / `Tag` / `ReadData`。 + **彻底消除 SEH 风险**(I/O 失败只会变成 `false`)。 +- 元数据:16 × 64KB 块缓存(LRU + 上次命中槽快路径),覆盖对象头 / B-tree / 本地堆; + 成员名用 `ReadCString`(跨块边界安全)。 +- 数据:连续布局按 4MB 切片;分块布局**按需读取 + 64KB 对齐**, + ⚠️ 固定 4MB 预读在交错存放的分块数据集上会把读盘量放大到文件大小的 2.2 倍 + (实测 2874MB / 1293MB)⇒ 改后 1301MB。 +- **关键提速**:`dims=[N]`、chunk `[32768,1]` 归一化后 rank=2、每行 1 字节, + 逐行算坐标是 **12.9 亿次迭代**(约占总时间一半以上)。新增 + 「rank == 2 且 `chunkDims[1] == dims[1]`」快路径:整块一次 `sink`。 + 实测 `BIG_MAT.mat`(1.29GB / 5 信号 × 16,142,471):**21.5s → 7.9s**,私有内存 **+741MB** + (最终列数据 739MB,与映射版一致)。 +- **进度**:`Open` 立刻上报「0 / 总大小」;之后 `ReadData`/切片每读一块回调 + `ReadProgress(bytesRead, total)` → `MatLoader` 映射到 0.02–0.98,并进行**单调夹紧** + (字节进度与逐流解码进度取较大值),收尾 1.0。遮罩层百分比不再「一上来就 100%」。 + 取消会置 `m_readCancelled` 并让 `StreamDataset` 立即失败(不会留下 NaN 空洞)。 +- 连续布局数据不足(文件被截断)现在**明确失败**,不再静默截短。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index fa0d4d4..332e2bb 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -393,6 +393,14 @@ namespace jdi { std::wstring name; /**< 信号名称(加载时取列名 / 通道名;用户可在界面上重命名)。 */ std::size_t axisIndex = 0; /**< 所属时间轴在分组内的下标。 */ + /** + * @brief 取值 → 文本的枚举表(源格式自带时才有,例如 MDF4 `cc_type = 7`)。 + * + * ⚠️ 这**不是**单位、也不是注释:它只回答「这个数值代表什么状态」。 + * 空表示该信号没有枚举信息,界面必须与以前完全一致(不加任何尾缀)。 + * 启用光标时,取值标注才在数值之后追加 ` = <枚举文本>`。 + */ + SignalEnumText enumText; std::vector values; /**< 采样值,缺失处为 NaN。 */ double minValue = 0.0; /**< 有限采样值最小值。 */ double maxValue = 0.0; /**< 有限采样值最大值。 */ diff --git a/Utilities/Inc/DataTable.hpp b/Utilities/Inc/DataTable.hpp index a0388f4..774b29e 100644 --- a/Utilities/Inc/DataTable.hpp +++ b/Utilities/Inc/DataTable.hpp @@ -1,6 +1,7 @@ #ifndef __DATA_TABLE_HPP__ #define __DATA_TABLE_HPP__ +#include #include #include #include @@ -9,6 +10,61 @@ namespace jdi { + /** + * @brief 一列数值的「值 → 文本」枚举表(如 ASAM MDF v4 的 `cc_type = 7` 换算)。 + * + * 它描述的是**信号本身的取值含义**(例如 `0 = Off`、`1 = On`), + * 与单位、注释、物理量纲无关,因此这里刻意命名为 `enumText` 而不是 `unit` / `label`。 + * + * 空表(`texts.empty()`)表示该列没有枚举信息,界面必须保持与以前完全一致的显示。 + */ + struct SignalEnumText + { + /** @brief 枚举取值(与采样列同一量纲,通常是整数)。 */ + std::vector values; + + /** @brief 与 `values` 一一对应的显示文本。 */ + std::vector texts; + + /** @brief 是否没有任何枚举信息。 */ + bool Empty() const { return values.empty() || texts.size() != values.size(); } + + /** + * @brief 按取值查找枚举文本。 + * @param [IN] value 取值(物理值,未叠加界面「调整」变换)。 + * @return 命中返回对应文本指针,未命中返回 nullptr。 + */ + const std::wstring* Find(double value) const + { + if (Empty() || !(value == value)) // NaN 永远不命中 + { + return nullptr; + } + for (std::size_t index = 0; index < values.size(); ++index) + { + if (values[index] == value) + { + return &texts[index]; + } + } + // 采样值按 double 保存,整数枚举在任意位宽下都是精确可表示的, + // 但个别工具会写 `1.0000001` 这类带噪声的换算表:再做一次四舍五入匹配。 + const double rounded = std::nearbyint(value); + if (rounded == value) + { + return nullptr; + } + for (std::size_t index = 0; index < values.size(); ++index) + { + if (std::nearbyint(values[index]) == rounded) + { + return &texts[index]; + } + } + return nullptr; + } + }; + /** * @brief 内存中的**列优先**数据表(与具体文件格式无关)。 * @@ -25,6 +81,13 @@ namespace jdi { std::vector headers; /**< 列名(已去除引号与首尾空白)。 */ std::vector> columns; /**< 列优先数值矩阵:columns[列][行],缺失处为 NaN。 */ + /** + * @brief 与 `columns` 同下标的「值 → 文本」枚举表(没有枚举信息的列为空)。 + * + * 加载器只在源格式自带枚举文本(如 MDF4 `cc_type = 7`)时填充; + * 它不是单位、不是注释,界面只在光标读数处以尾缀形式展示。 + */ + std::vector columnEnumText; std::size_t rowCount = 0; /**< 有效数据行数。 */ wchar_t delimiter = L','; /**< 实际使用的分隔符(仅文本格式有意义)。 */ unsigned long long sourceBytes = 0; /**< 源文件字节数(用于按比例预留容量)。 */ diff --git a/Utilities/Inc/MatH5.hpp b/Utilities/Inc/MatH5.hpp index 1af1b88..e50a69b 100644 --- a/Utilities/Inc/MatH5.hpp +++ b/Utilities/Inc/MatH5.hpp @@ -97,29 +97,48 @@ namespace jdi using OpenProgress = std::function; /** - * @brief 打开文件(**内存映射**,不把整个文件读进堆内存)。 + * @brief 打开文件(**随机读取**,不映射、不把整个文件读进堆内存)。 * - * ⚠️ 早期版本把整个文件一次读入 `std::vector`(注释里假设“`.mat` 通常几十 MB”), - * 但 SDI 导出的录波文件可能是 **GB 级**:整文件读入 + 分块解码 + 再拷成 double 会同时 - * 驻留“文件 + 未压缩字节 + double”三份内存。现改为: - * * 映射文件(只占虚拟地址空间,按页面需求驻留); - * * 分块数据集逐 chunk 解压并**直接写入**自持有的 double 列(见 `ReadDoubleStream`); - * * 进度回调在映射完成后立即报告一次(字节已全部可访问),后续解码阶段由 `MatLoader` 驱动。 + * 早期版本把整个文件一次读入 `std::vector`,后来改成整文件内存映射; + * 但映射在「文件被截断 / 网络共享掉线 / 移动介质拔出」时会触发页访问异常, + * 而这类异常属于 SEH,GCC/MinGW 下的 `try/catch` **捕获不到**,只能让进程崩溃。 + * + * 因此这里改为 SEC2 式的「`ReadFile` 随机读 + 元数据块缓存」: + * * 所有读取都经过 `ReadAt`,I/O 失败只会变成 `false`(上层给出错误提示); + * * 对象头 / B-tree / 本地堆等热点元数据走 64KB 块缓存(LRU); + * * 数据段按 4MB 切片顺序读,并对小 chunk 做顺序预读。 + * 解析代码本身与映射版完全一致。 * * @param [IN] path 文件路径。 * @param [OUT] error 失败原因。 - * @param [IN] progress 读取进度回调(可为空)。 + * @param [IN] progress 打开阶段的进度回调(可为空,只在上报总大小时调用一次)。 * @return 成功返回 true;用户取消时也返回 false。 */ bool Open(const std::wstring& path, std::wstring& error, const OpenProgress& progress = OpenProgress()); void Close(); - /** @brief 析构时释放映射(否则栈上对象的映射会留到进程退出)。 */ + /** @brief 析构时关闭文件句柄。 */ ~MatHdf5() { Close(); } - /** @brief 文件字节数(映射长度)。 */ - std::uint64_t FileBytes() const { return static_cast(m_data.size()); } + /** @brief 文件字节数。 */ + std::uint64_t FileBytes() const { return m_size; } + + /** @brief 已从文件实际读取的字节数(用于「已读 / 总大小」进度)。 */ + std::uint64_t BytesRead() const { return m_bytesRead; } + + /** + * @brief 流式解码期间的字节进度回调。 + * + * 每读一个块(或每 4MB 切片)调用一次;返回 false 表示请求取消。 + */ + using ReadProgress = std::function; + + /** @brief 设置流式解码期间的字节进度回调(可为空)。 */ + void SetReadProgress(const ReadProgress& progress) const { m_readProgress = progress; } + + /** @brief 流式解码是否已被进度回调取消。 */ + bool Cancelled() const { return m_readCancelled; } /** @brief 根分组地址。 */ std::uint64_t Root() const { return m_root; } @@ -192,6 +211,38 @@ namespace jdi std::uint64_t Addr(std::uint64_t offset) const; std::uint64_t Resolve(std::uint64_t address) const; + /** @brief 随机读取一段字节(不经过缓存;累加已读字节数)。 */ + bool ReadAt(std::uint64_t offset, void* dst, std::size_t bytes) const; + + /** + * @brief 取一段**元数据**的只读指针(经 64KB 块缓存)。 + * + * ⚠️ 返回的指针只在下次调用前有效(缓存块可能被替换),调用方必须立即用完。 + * @return 成功返回指针;越界或读失败返回 nullptr。 + */ + const std::uint8_t* Cached(std::uint64_t offset, std::size_t bytes) const; + + /** @brief 判断 `offset` 处的 4 字节块标识是否等于 `id`(`id` 为 4 字符)。 */ + bool Tag(std::uint64_t offset, const char* id) const; + + /** + * @brief 读取一个 NUL 结尾的短字符串(本地堆里的成员名)。 + * + * 名字很短但可能跨越 64KB 缓存块边界,因此按块边界截断、必要时继续向后读。 + */ + void ReadCString(std::uint64_t offset, std::string& out) const; + + /** @brief 丢失顺序预读缓冲(随机跳转后调用)。 */ + void DropReadAhead() const; + + /** + * @brief 从文件读一段**数据**(带顺序预读,供小 chunk 连续读取)。 + * + * 若请求落在当前预读窗口内直接返回指针;否则从 `offset` 起预读最多 4MB。 + * @return 成功返回指针(仅在下次调用前有效),否则 nullptr。 + */ + const std::uint8_t* ReadData(std::uint64_t offset, std::size_t bytes) const; + void ReadMessages(std::uint64_t address, std::vector& messages) const; bool ParseType(const std::uint8_t* data, std::size_t size, std::size_t offset, Type& out, std::size_t& consumed) const; @@ -200,31 +251,29 @@ namespace jdi void WalkSymbolNode(std::uint64_t node, std::uint64_t heapData, std::vector>& out) const; - /** - * @brief 内存映射的文件视图(代替“整个文件读进 `vector`”)。 - * - * ⚠️ 行为与 `std::vector` 的**只读子集**一致(`size/data/begin/end/[]`), - * 因此所有“按偏移取字节”的解析代码一行都不用改;但**虚拟地址不等于物理内存**: - * 只有真正被解析到的页才驻留,GB 级文件不会再先吃掉一整个文件大小的私有内存。 - */ - struct MappedBytes + /** @brief 元数据缓存槽(固定槽数,最近最少使用优先淘汰)。 */ + struct CacheSlot { - const std::uint8_t* pointer = nullptr; - std::size_t length = 0; - const std::uint8_t* data() const { return pointer; } - std::size_t size() const { return length; } - const std::uint8_t& operator[](std::size_t index) const { return pointer[index]; } - const std::uint8_t* begin() const { return pointer; } - const std::uint8_t* end() const { return pointer + length; } - void clear() - { - pointer = nullptr; - length = 0; - } + std::uint64_t base = 0; + std::uint32_t length = 0; + std::uint64_t stamp = 0; + std::vector bytes; }; - MappedBytes m_data; /**< 文件映射视图(只读;语义同字节数组)。 */ + void* m_fileHandle = nullptr; /**< `HANDLE`(避免头文件引入 windows.h)。 */ - void* m_mapHandle = nullptr; + std::uint64_t m_size = 0; /**< 文件字节数。 */ + mutable std::uint64_t m_bytesRead = 0; /**< 已实际读取的字节数(进度上报用)。 */ + mutable std::vector m_cache; /**< 元数据块缓存。 */ + mutable std::uint64_t m_stamp = 0; /**< 缓存访问计数。 */ + mutable std::size_t m_lastSlot = 0; /**< 上次命中的槽(连续标量读取的快路径)。 */ + mutable std::vector m_readAhead; /**< 顺序预读缓冲。 */ + mutable std::uint64_t m_readAheadBase = 0; + mutable std::uint32_t m_readAheadLength = 0; + mutable bool m_readAheadValid = false; + mutable std::vector m_slice; /**< 连续布局切片缓冲(复用容量)。 */ + mutable std::vector m_chunk; /**< 大于预读窗口的分块缓冲(复用容量)。 */ + mutable ReadProgress m_readProgress; /**< 流式解码进度回调(可空)。 */ + mutable bool m_readCancelled = false; /**< 进度回调请求取消。 */ std::uint64_t m_base = 0; std::uint64_t m_root = 0; int m_offsetSize = 8; diff --git a/Utilities/Inc/Mf4Loader.hpp b/Utilities/Inc/Mf4Loader.hpp index e7b5547..f8823db 100644 --- a/Utilities/Inc/Mf4Loader.hpp +++ b/Utilities/Inc/Mf4Loader.hpp @@ -12,20 +12,32 @@ namespace jdi * @brief 读取并解析一个 ASAM MDF v4(`.mf4` / `.mdf`)测量文件。 * * 只解析**绘制需要的那部分**:头块版本、数据组(DG)→ 通道组(CG)→ 通道(CN)/换算(CC), - * 以及每个通道组的原始记录;字符串/字节数组通道、压缩数据块(DZ/HZ)与 MIME 通道一律跳过。 + * 以及每个通道组的原始记录;字符串/字节数组通道、MIME 通道一律跳过。 * * **时间轴**:MDF 的每个通道组(CG)自带一个时间基准。本加载器把每个 CG 输出为一个 - * 时间轴块(`TableBlock`):块的时间列取自该组的**时间主通道**(`cn_sync_type == 1`, - * 退回名为 `time`/`t` 的通道,再退回第一个数值通道),其余数值通道作为该块的信号列。 + * 时间轴块(`TableBlock`):块的时间列取自该组的**主通道** + *(`cn_sync_type` = 1 时间 / 2 角度 / 3 距离,退回名为 `time`/`t`/`timestamp` 的通道, + * 再退回第一个数值通道),其余数值通道作为该块的信号列。 * 内容完全相同的时间轴在数据模型层会被合并(见 `BuildSignalGroup`)。 + * `cg_flags` 的「远程主通道」位会跳过第一步,避免把普通信号误当时间轴。 + * + * **数据块**:`##DT` / `##DV` / `##DZ`(zlib + 转置压缩之外的纯 deflate)/ + * `##DL`(数据列表)/ `##HL`(头部列表),支持记录跨段拼接。 + * + * **VLSD(`cn_type == 1`)**:记录里是 64 位偏移,样本在 `##SD`(`UINT32` 长度 + 数据)。 + * 本项目取样本的第一个元素作为该采样点的取值。 + * + * **采样索引(`##SI`)**:仅当索引表至少两条且全部落在记录范围内时生效 + *(否则忽略——实测部分工具会给每个通道挂一个只含常量 513 的 `##SI`)。 * * 支持的通道编码:无符号/有符号整数(1–64 位,任意位偏移)与 IEEE-754 浮点(32/64 位)。 - * 支持的换算:无换算、线性(`phys = c0 + c1 * raw`)。其它换算按“无换算”处理。 + * 支持的换算:无换算、线性(`phys = c0 + c1 * raw`)、值 → 文本(`cc_type = 7`,输出到 + * `DataTable::columnEnumText`,界面只在光标读数处以尾缀显示)。其它换算按“无换算”处理。 * * @param [IN] filePath MDF 文件完整路径。 * @param [OUT] source 解析结果(每个通道组一个时间轴块)。 * @param [OUT] error 失败原因描述(成功时清空)。 - * @param [IN] progress 进度回调(按字节比例),可为空。 + * @param [IN] progress 进度回调(按已处理数据组比例;同时上报已读/总字节),可为空。 * @return 解析成功返回 true。 */ bool LoadMf4Source(const std::wstring& filePath, LoadedSource& source, std::wstring& error, diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index c66eaaf..616366b 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -338,6 +338,12 @@ namespace jdi } signal.axisIndex = axisIndex; + // 值 → 文本枚举表(MDF4 `cc_type = 7` 等):与列同下标,缺省为空。 + if (column < table.columnEnumText.size()) + { + signal.enumText = std::move(table.columnEnumText[column]); + } + signal.values = std::move(table.columns[column]); const SignalStats stats = ComputeStats(signal.values, signal.coarseMin, signal.coarseMax); signal.coarseStride = kCoarseStride; diff --git a/Utilities/Src/MatH5.cpp b/Utilities/Src/MatH5.cpp index 7c23ad9..3bb1aef 100644 --- a/Utilities/Src/MatH5.cpp +++ b/Utilities/Src/MatH5.cpp @@ -82,9 +82,212 @@ namespace jdi } } // namespace + namespace + { + /** @brief 元数据缓存的块大小与槽数(16 × 64KB = 1MB,足够覆盖对象头/B-tree 热点)。 */ + constexpr std::size_t kMetaBlockBytes = 64u * 1024u; + constexpr std::size_t kMetaBlockSlots = 16; + + /** @brief 顺序预读窗口上限。 */ + constexpr std::size_t kReadAheadBytes = 4u * 1024u * 1024u; + + /** + * @brief 数据读取的最小粒度:按请求大小向上对齐读取,命中窗口时直接复用。 + * + * ⚠️ 分块数据集的 chunk 在文件里**并非完全顺序**(多个样本流交错写入, + * 每段连续区域约 1.8MB)。实测 `BIG_MAT.mat`:固定预读 4MB 会让每个连续段 + * 末尾浪费 2.2MB,总读盘量达到文件大小的 **2.2 倍**(2.87GB / 1.29GB)。 + * 改为「按需 + 64KB 对齐」后,读盘量回到文件大小附近。 + */ + constexpr std::size_t kReadGranularity = 64u * 1024u; + } + + bool MatHdf5::ReadAt(std::uint64_t offset, void* dst, std::size_t bytes) const + { + if (m_fileHandle == nullptr || bytes == 0) + { + return bytes == 0; + } + if (offset > m_size || bytes > m_size - offset) + { + return false; + } + const HANDLE file = static_cast(m_fileHandle); + LARGE_INTEGER position = {}; + position.QuadPart = static_cast(offset); + if (!SetFilePointerEx(file, position, nullptr, FILE_BEGIN)) + { + return false; + } + std::size_t done = 0; + while (done < bytes) + { + DWORD read = 0; + const DWORD want = static_cast(std::min(bytes - done, 1u << 30)); + if (!ReadFile(file, static_cast(dst) + done, want, &read, nullptr)) + { + return false; + } + if (read == 0) + { + return false; // 文件被截断 + } + done += read; + } + m_bytesRead += bytes; + return true; + } + + const std::uint8_t* MatHdf5::Cached(std::uint64_t offset, std::size_t bytes) const + { + if (offset >= m_size || bytes > kMetaBlockBytes) + { + return nullptr; + } + if (m_cache.empty()) + { + m_cache.resize(kMetaBlockSlots); + } + const std::uint64_t base = offset - (offset % kMetaBlockBytes); + // 快路径:连续读同一块(U16/U32/U64 与 B-tree 遍历几乎都是这种模式)。 + { + CacheSlot& slot = m_cache[m_lastSlot]; + if (slot.length > 0 && slot.base == base) + { + slot.stamp = ++m_stamp; + const std::uint64_t delta = offset - slot.base; + return (delta + bytes <= slot.length) ? (slot.bytes.data() + delta) : nullptr; + } + } + CacheSlot* victim = nullptr; + for (std::size_t index = 0; index < m_cache.size(); ++index) + { + CacheSlot& slot = m_cache[index]; + if (slot.length > 0 && slot.base == base) + { + slot.stamp = ++m_stamp; + m_lastSlot = index; + const std::uint64_t delta = offset - slot.base; + return (delta + bytes <= slot.length) ? (slot.bytes.data() + delta) : nullptr; + } + if (slot.length == 0 || victim == nullptr || slot.stamp < victim->stamp) + { + victim = &slot; + } + } + if (victim == nullptr) + { + return nullptr; + } + // 未命中:整块读入(块可能超出文件末尾,按可用长度裁剪)。 + const std::size_t wanted = + static_cast(std::min(kMetaBlockBytes, m_size - base)); + if (bytes > wanted) + { + return nullptr; + } + victim->bytes.resize(wanted); + victim->base = base; + victim->length = static_cast(wanted); + victim->stamp = ++m_stamp; + m_lastSlot = static_cast(victim - m_cache.data()); + if (!ReadAt(base, victim->bytes.data(), wanted)) + { + victim->length = 0; + return nullptr; + } + return victim->bytes.data() + (offset - base); + } + + bool MatHdf5::Tag(std::uint64_t offset, const char* id) const + { + const std::uint8_t* bytes = Cached(offset, 4); + if (bytes == nullptr) + { + return false; + } + return std::memcmp(bytes, id, 4) == 0; + } + + void MatHdf5::ReadCString(std::uint64_t offset, std::string& out) const + { + out.clear(); + std::uint64_t position = offset; + while (position < m_size && out.size() < 4096) + { + const std::uint8_t* bytes = Cached(position, 1); + if (bytes == nullptr) + { + return; + } + // 只消费本缓存块内剩余的字节,保证下一次循环拿到的新块仍然有效。 + const std::uint64_t blockBase = position - (position % kMetaBlockBytes); + const std::size_t limit = + static_cast(std::min(kMetaBlockBytes - (position - blockBase), + m_size - position)); + std::size_t used = 0; + while (used < limit) + { + if (bytes[used] == '\0') + { + return; + } + out.push_back(static_cast(bytes[used])); + ++used; + } + position += used; + } + } + + void MatHdf5::DropReadAhead() const + { + m_readAheadValid = false; + m_readAheadLength = 0; + } + + const std::uint8_t* MatHdf5::ReadData(std::uint64_t offset, std::size_t bytes) const + { + if (bytes > kReadAheadBytes || offset > m_size || bytes > m_size - offset) + { + return nullptr; + } + if (m_readAheadValid && offset >= m_readAheadBase && + (offset - m_readAheadBase) + bytes <= m_readAheadLength) + { + return m_readAhead.data() + (offset - m_readAheadBase); + } + // 按需读取 + 64KB 对齐:命中当前窗口就直接复用,否则只读「这次需要的一段」。 + // (固定 4MB 预读在交错存放的分块数据集上会浪费 2 倍以上的 I/O。) + const std::size_t requested = std::max(bytes, kReadGranularity); + std::size_t wanted = static_cast(std::min( + m_size - offset, std::min(kReadAheadBytes, + ((requested + kReadGranularity - 1) / kReadGranularity) * + kReadGranularity))); + if (m_readAhead.size() < wanted) + { + m_readAhead.resize(wanted); + } + if (!ReadAt(offset, m_readAhead.data(), wanted)) + { + DropReadAhead(); + return nullptr; + } + m_readAheadBase = offset; + m_readAheadLength = static_cast(wanted); + m_readAheadValid = true; + if (m_readProgress && !m_readProgress(m_bytesRead, m_size)) + { + m_readCancelled = true; + DropReadAhead(); + return nullptr; + } + return m_readAhead.data(); + } + std::uint8_t MatHdf5::U8(std::uint64_t offset) const { - return (offset < m_data.size()) ? m_data[static_cast(offset)] : 0; + const std::uint8_t* bytes = Cached(offset, 1); + return (bytes != nullptr) ? *bytes : 0; } std::uint16_t MatHdf5::U16(std::uint64_t offset) const @@ -121,12 +324,13 @@ namespace jdi error.clear(); Close(); - // ⚠️ **内存映射而不是整文件读入**:SDI 导出的 `.mat` 可能是 GB 级, - // “整文件读入 + 分块解码成字节数组 + 再拷成 double”会同时驻留三份内存 - //(用户 2026-10-19 报告,并在本轮改为流式填充)。 - // 映射只占虚拟地址空间:未被解析到的页不会驻留,系统也可以在内存压力下回收。 + // ⚠️ **随机读,不映射**:SDI 导出的 `.mat` 可能是 GB 级,整文件读入会同时驻留 + //「文件 + 未压缩字节 + double」三份内存;而内存映射在文件被截断 / 网络共享掉线 / + // 移动介质拔出时会触发 SEH(页访问异常),GCC 下的 `try/catch` 捕获不到 ⇒ 进程直接崩溃。 + // 这里改用 `ReadFile` + 元数据块缓存:任何 I/O 失败都只会变成 `false`。 const HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, - OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, + nullptr); if (file == INVALID_HANDLE_VALUE) { error = L"无法打开文件。"; @@ -139,29 +343,14 @@ namespace jdi error = L"无法读取文件大小。"; return false; } - const HANDLE mapping = CreateFileMappingW(file, nullptr, PAGE_READONLY, 0, 0, nullptr); - if (mapping == nullptr) - { - CloseHandle(file); - error = L"无法映射文件(请确认文件可读且未被独占)。"; - return false; - } - const void* view = MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, 0); - if (view == nullptr) - { - CloseHandle(mapping); - CloseHandle(file); - error = L"无法映射文件视图。"; - return false; - } m_fileHandle = file; - m_mapHandle = mapping; - m_data.pointer = static_cast(view); - m_data.length = static_cast(fileSize.QuadPart); + m_size = static_cast(fileSize.QuadPart); + m_bytesRead = 0; + m_readCancelled = false; - // 字节已全部可访问(映射完成):把“已加载 = 总大小”报给遮罩层, - // 之后的时间主要在“解码样本流”阶段(由 MatLoader 逐流推进)。 - if (progress && !progress(m_data.size(), m_data.size())) + // 打开阶段已知道总大小:立即上报一次,遮罩层可以显示「0 / 总大小」, + // 之后的真实进度由 `ReadAt` 累计的已读字节数驱动。 + if (progress && !progress(0, m_size)) { Close(); error.clear(); // 用户取消:不显示错误 @@ -169,17 +358,16 @@ namespace jdi } // 超级块:签名可能出现在 0、512、1024…(MATLAB 用 512 字节 user block)。 - std::size_t signature = m_data.size(); - for (std::size_t index = 0; index + 8 <= m_data.size() && index <= 4096; index += 512) + std::size_t signature = static_cast(m_size); + for (std::size_t index = 0; index + 8 <= m_size && index <= 4096; index += 512) { - if (m_data[index] == 0x89 && m_data[index + 1] == 'H' && m_data[index + 2] == 'D' && - m_data[index + 3] == 'F') + if (Tag(index, "\x89HDF")) { signature = index; break; } } - if (signature == m_data.size()) + if (signature == m_size) { error = L"不是 HDF5(MATLAB 7.3)格式。"; return false; @@ -200,7 +388,7 @@ namespace jdi m_base = U64(signature + 12); m_root = Resolve(U64(signature + 36)); } - if (m_root == 0 || m_root >= m_data.size()) + if (m_root == 0 || m_root >= m_size) { error = L"HDF5 超级块损坏。"; return false; @@ -210,31 +398,27 @@ namespace jdi void MatHdf5::Close() { - // 先解除视图,再关映射与文件句柄(顺序不能反)。 - if (m_data.pointer != nullptr) - { - UnmapViewOfFile(m_data.pointer); - } - if (m_mapHandle != nullptr) - { - CloseHandle(static_cast(m_mapHandle)); - m_mapHandle = nullptr; - } + DropReadAhead(); + m_cache.clear(); + m_lastSlot = 0; if (m_fileHandle != nullptr) { CloseHandle(static_cast(m_fileHandle)); m_fileHandle = nullptr; } - m_data.clear(); + m_size = 0; + m_bytesRead = 0; + m_readCancelled = false; m_base = 0; m_root = 0; m_heaps.clear(); + m_readProgress = ReadProgress(); } void MatHdf5::ReadMessages(std::uint64_t address, std::vector& messages) const { messages.clear(); - if (address + 8 > m_data.size()) + if (address + 8 > m_size) { return; } @@ -253,18 +437,21 @@ namespace jdi std::uint64_t offset = blocks[blockIndex].first; const std::uint64_t end = (blocks[blockIndex].second > 0) ? (offset + blocks[blockIndex].second) : 0; - while (read < count && offset + 8 <= m_data.size() && (end == 0 || offset + 8 <= end)) + while (read < count && offset + 8 <= m_size && (end == 0 || offset + 8 <= end)) { Message message; message.type = U16(offset); const std::uint16_t messageSize = U16(offset + 2); offset += 8; - if (offset + messageSize > m_data.size()) + if (offset + messageSize > m_size) + { + break; + } + message.data.resize(messageSize); + if (messageSize > 0 && !ReadAt(offset, message.data.data(), messageSize)) { break; } - message.data.assign(m_data.begin() + static_cast(offset), - m_data.begin() + static_cast(offset + messageSize)); offset += (messageSize + 7u) & ~static_cast(7u); ++read; if (message.type == kMessageContinuation && message.data.size() >= 16) @@ -286,9 +473,9 @@ namespace jdi std::vector>& out) const { int guard = 0; - while (node != 0 && node + 24 < m_data.size() && guard++ < 100000) + while (node != 0 && node + 24 < m_size && guard++ < 100000) { - if (std::memcmp(&m_data[static_cast(node)], "TREE", 4) != 0) + if (!Tag(node, "TREE")) { return; } @@ -323,8 +510,7 @@ namespace jdi void MatHdf5::WalkSymbolNode(std::uint64_t node, std::uint64_t heapData, std::vector>& out) const { - if (node == 0 || node + 8 > m_data.size() || - std::memcmp(&m_data[static_cast(node)], "SNOD", 4) != 0 || heapData == 0) + if (node == 0 || node + 8 > m_size || !Tag(node, "SNOD") || heapData == 0) { return; } @@ -332,19 +518,20 @@ namespace jdi std::uint64_t offset = node + 8; for (std::uint16_t index = 0; index < count; ++index) { - if (offset + 40 > m_data.size()) + if (offset + 40 > m_size) { return; } const std::uint64_t nameOffset = U64(offset); const std::uint64_t objectAddress = Resolve(U64(offset + 8)); offset += 40; - if (objectAddress == 0 || heapData + nameOffset >= m_data.size()) + if (objectAddress == 0 || heapData + nameOffset >= m_size) { continue; } - const char* name = reinterpret_cast(m_data.data() + heapData + nameOffset); - out.emplace_back(std::string(name), objectAddress); + std::string name; + ReadCString(heapData + nameOffset, name); + out.emplace_back(std::move(name), objectAddress); } } @@ -360,8 +547,7 @@ namespace jdi const std::uint64_t btree = Resolve(ReadLE(message.data.data(), 8)); const std::uint64_t heap = Resolve(ReadLE(message.data.data() + 8, 8)); std::uint64_t heapData = 0; - if (heap + 32 <= m_data.size() && - std::memcmp(&m_data[static_cast(heap)], "HEAP", 4) == 0) + if (heap + 32 <= m_size && Tag(heap, "HEAP")) { heapData = Resolve(U64(heap + 24)); bool known = false; @@ -724,26 +910,45 @@ namespace jdi return sink(dataset.compact.data(), dataset.compact.size(), 0); } - // 连续布局:按 4MB 切片直接吐出(文件是映射的,这里只是指针运算,不产生额外缓冲)。 + // 连续布局:按 4MB 切片读出(随机读 + 复用缓冲,不产生整文件大小的额外内存)。 if (dataset.layout == LayoutClass::Contiguous) { const std::uint64_t total = dataset.ElementCount(); const std::uint64_t totalBytes = total * elementSize; const std::uint64_t available = std::min(dataset.dataBytes, - (dataset.dataAddress < m_data.size()) - ? (m_data.size() - dataset.dataAddress) - : 0); + (dataset.dataAddress < m_size) ? (m_size - dataset.dataAddress) + : 0); const std::uint64_t bytes = std::min(totalBytes, available); + // ⚠️ 元数据声明了 N 个元素、文件里却不够:明确失败,而不是静默截短 + //(静默截短会让曲线“看起来只有一半”,很难诊断)。 + if (bytes < totalBytes) + { + return false; + } std::size_t slice = kStreamSliceBytes - (kStreamSliceBytes % elementSize); if (slice == 0) { slice = elementSize; } + if (m_slice.size() < slice) + { + m_slice.resize(slice); + } for (std::uint64_t offset = 0; offset < bytes; offset += slice) { - const std::size_t count = static_cast(std::min(slice, bytes - offset)); - if (!sink(m_data.data() + dataset.dataAddress + offset, count, offset / elementSize)) + const std::size_t count = + static_cast(std::min(slice, bytes - offset)); + if (!ReadAt(dataset.dataAddress + offset, m_slice.data(), count)) + { + return false; + } + if (m_readProgress && !m_readProgress(m_bytesRead, m_size)) + { + m_readCancelled = true; + return false; + } + if (!sink(m_slice.data(), count, offset / elementSize)) { return false; } @@ -788,8 +993,7 @@ namespace jdi { const std::uint64_t node = stack.back(); stack.pop_back(); - if (node == 0 || node + 24 > m_data.size() || - std::memcmp(&m_data[static_cast(node)], "TREE", 4) != 0) + if (node == 0 || node + 24 > m_size || !Tag(node, "TREE")) { continue; } @@ -799,7 +1003,7 @@ namespace jdi std::uint64_t offset = node + 8 + 8 + 8; for (std::uint16_t index = 0; index < static_cast(entries + 1u); ++index) { - const std::uint32_t chunkSize = (offset + 4 <= m_data.size()) ? U32(offset) : 0; + const std::uint32_t chunkSize = (offset + 4 <= m_size) ? U32(offset) : 0; std::vector chunkOffset(rank, 0); for (std::size_t axis = 0; axis < rank; ++axis) { @@ -817,12 +1021,38 @@ namespace jdi stack.push_back(child); continue; } - if (child == 0 || chunkSize == 0 || child + chunkSize > m_data.size()) + if (child == 0 || chunkSize == 0 || child + chunkSize > m_size) { continue; } - const std::uint8_t* source = m_data.data() + child; + // 顺序预读:分块数据通常连续存放,逐 chunk 读会退化成大量小系统调用。 + const std::uint8_t* source = nullptr; + if (chunkSize <= kReadAheadBytes) + { + source = ReadData(child, chunkSize); + } + if (source == nullptr) + { + if (m_chunk.size() < chunkSize) + { + m_chunk.resize(chunkSize); + } + if (!ReadAt(child, m_chunk.data(), chunkSize)) + { + continue; + } + if (m_readProgress && !m_readProgress(m_bytesRead, m_size)) + { + m_readCancelled = true; + return false; + } + source = m_chunk.data(); + } + if (m_readCancelled) + { + return false; + } std::size_t sourceSize = chunkSize; if (dataset.deflate && chunkSize != expected) { @@ -844,6 +1074,29 @@ namespace jdi // 归一化后 rank=2 ⇒「每行只有 1 字节」)。若逐行回调,就是上千万次 // 小 memcpy + 上千万次 vector 分配,实测把解压吞吐拖到 ~11MB/s。 // 因此这里把**目标连续的行合并成一次回调**(典型情况整块只需 1 次)。 + // + // 快路径(rank == 2 且第二维完全对齐):整块在目标里就是一段连续区域, + // 直接一次回调,省掉「每行算坐标 + 判断合并」的循环。 + // 实测 BIG_MAT.mat:`#data#` 为 129,139,768 字节 × 32KB chunk,每 chunk 32768 行, + // 逐行循环共 12.9 亿次迭代(约占总加载时间的一半以上)。 + if (rank == 2 && chunkDims[1] == dims[1] && chunkDims[1] > 0) + { + const std::uint64_t startRow = + std::min(chunkOffset[0], dims[0]); + const std::uint64_t rowCount = + std::min(chunkDims[0], dims[0] - startRow); + const std::uint64_t byteCount = rowCount * rowBytes; + if (startRow < dims[0] && byteCount <= static_cast(sourceSize)) + { + if (!sink(source, static_cast(byteCount), + startRow * chunkDims[1])) + { + return false; + } + } + continue; + } + std::vector coordinate(rank > 0 ? rank - 1 : 0, 0); std::uint64_t runElement = 0; // 合并段的起始元素下标 std::size_t runSourceOffset = 0; // 合并段的源偏移 diff --git a/Utilities/Src/MatLoader.cpp b/Utilities/Src/MatLoader.cpp index d038bbf..f0a38d8 100644 --- a/Utilities/Src/MatLoader.cpp +++ b/Utilities/Src/MatLoader.cpp @@ -142,6 +142,7 @@ namespace jdi double high = 1.0; /**< 本阶段占的进度区间上界。 */ std::uint64_t read = 0; /**< 已读字节。 */ std::uint64_t total = 0; /**< 总字节。 */ + mutable double lastRatio = -1.0; /**< 已上报的最大比例(保证单调不回退)。 */ /** @brief 上报一个 0-1 的阶段内进度(向宿主传递字节数与百分比)。 */ bool Report(double fraction) const @@ -158,11 +159,21 @@ namespace jdi { fraction = 1.0; } + double ratio = low + (high - low) * fraction; + // 「已读字节」与「逐流解码」是两条独立进度来源:取较大值,避免进度条回退。 + if (ratio < lastRatio) + { + ratio = lastRatio; + } + else + { + lastRatio = ratio; + } LoadProgressInfo info; - info.ratio = low + (high - low) * fraction; + info.ratio = ratio; // 总字节已知时一并上报(遮罩层据此显示“已加载 / 总大小”)。 info.hasBytes = (total > 0); - info.bytesRead = (total > 0) ? read : 0; + info.bytesRead = (total > 0) ? std::min(read, total) : 0; info.totalBytes = total; return (*sink)(info); } @@ -503,13 +514,19 @@ namespace jdi { if (file.IsDataset(address)) { + // ⚠️ 只解析元信息(维度),**不要**用 `ReadDataset`:那会把整块数据再解码一遍。 + // 旧实现先 `ReadNumbers` 解码一次、再 `ReadDataset` 只为拿 `dims`, + // 大矩阵的时间与内存都翻倍。 + MatHdf5::Dataset dataset; + if (!file.ReadDatasetInfo(address, dataset) || dataset.dims.empty()) + { + return; + } std::vector numbers; if (!file.ReadNumbers(address, numbers) || numbers.empty()) { return; } - MatHdf5::Dataset dataset; - file.ReadDataset(address, dataset); std::size_t rows = 1; std::size_t columns = 1; if (dataset.dims.size() >= 2) @@ -566,22 +583,24 @@ namespace jdi // ------------------------------------------------------------------ // 进度拆成三段: - // 0.00 – 0.02 映射文件(内存映射,几乎瞬时;遮罩层仍能看到“已加载 = 总大小”) - // 0.02 – 0.98 逐条解码样本流(每个流解压后直接填进数据模型,含解压开销) + // 0.00 – 0.02 打开文件(立刻知道总大小;遮罩层显示「0 / 总大小」) + // 0.02 – 0.98 随机读取 + 逐条解码样本流(进度按**实际已读字节**推进) // 0.98 – 1.00 收尾 - // ⚠️ 旧实现把 0–0.55 全给了“读文件字节”(因为旧实现真的整文件读入); - // 现在改成内存映射后那一段几乎没有耗时,若不调整权重,进度条会“瞬间跳到 55% 再慢慢走”。 + // ⚠️ 旧实现是内存映射:字节瞬间「全部可访问」⇒ 文件读取百分比立刻 100%, + // 而真正的解析还要跑很久(用户 2026-10-20 报告“这部分不太对劲”)。 + // 现在每次读盘都累计到 `MatHdf5::BytesRead()`,百分比随真实 I/O 推进。 // ------------------------------------------------------------------ - ProgressReporter readReporter; - readReporter.sink = &progress; - readReporter.low = 0.0; - readReporter.high = 0.02; + ProgressReporter parseReporter; + parseReporter.sink = &progress; + parseReporter.low = 0.02; + parseReporter.high = 0.98; + const MatHdf5::OpenProgress openProgress = - [&readReporter](std::uint64_t bytesRead, std::uint64_t totalBytes) -> bool + [&parseReporter](std::uint64_t bytesRead, std::uint64_t totalBytes) -> bool { - readReporter.read = bytesRead; - readReporter.total = totalBytes; - return readReporter.Report(totalBytes > 0 ? static_cast(bytesRead) / + parseReporter.read = bytesRead; + parseReporter.total = totalBytes; + return parseReporter.Report(totalBytes > 0 ? static_cast(bytesRead) / static_cast(totalBytes) : 0.0); }; @@ -589,12 +608,17 @@ namespace jdi { return false; // 用户取消时 `error` 为空 } - ProgressReporter parseReporter; - parseReporter.sink = &progress; - parseReporter.low = 0.02; - parseReporter.high = 0.98; - parseReporter.read = readReporter.total; - parseReporter.total = 0; // 字节已读完:本阶段只报百分比 + + // 流式解码期间的字节进度(每读一块回调一次);返回 false 即取消。 + file.SetReadProgress( + [&parseReporter](std::uint64_t bytesRead, std::uint64_t totalBytes) -> bool + { + parseReporter.read = bytesRead; + parseReporter.total = totalBytes; + return parseReporter.Report(totalBytes > 0 ? static_cast(bytesRead) / + static_cast(totalBytes) + : 0.0); + }); if (!parseReporter.Report(0.0)) { error.clear(); @@ -609,6 +633,10 @@ namespace jdi { if (!LoadSdi(file, refs, sigstream, stem, groups, error, parseReporter)) { + if (file.Cancelled()) + { + error.clear(); // 用户取消:不显示错误 + } return false; } } @@ -637,9 +665,9 @@ namespace jdi { LoadProgressInfo info; info.ratio = 1.0; - info.hasBytes = (readReporter.total > 0); - info.bytesRead = readReporter.total; - info.totalBytes = readReporter.total; + info.hasBytes = (file.FileBytes() > 0); + info.bytesRead = file.FileBytes(); + info.totalBytes = file.FileBytes(); progress(info); } return true; diff --git a/Utilities/Src/Mf4Loader.cpp b/Utilities/Src/Mf4Loader.cpp index cf5b274..2d084b7 100644 --- a/Utilities/Src/Mf4Loader.cpp +++ b/Utilities/Src/Mf4Loader.cpp @@ -45,12 +45,13 @@ namespace jdi MIME_STREAM = 12 }; - /** @brief 换算类型(`cc_type`)中我们支持的子集。 */ + /** @brief 换算类型(`cc_type`)中本项目实现的子集。 */ enum class ConversionType : int { - NONE = 0, /**< 无换算(恒等)。 */ - LINEAR = 1, /**< 线性:`phys = c0 + c1 * raw`。 */ - RATIONAL = 2 /**< 有理式(未支持,按恒等处理)。 */ + NONE = 0, /**< 无换算(恒等)。 */ + LINEAR = 1, /**< 线性:`phys = c0 + c1 * raw`。 */ + VALUE_TO_TEXT = 7, /**< 值 → 枚举文本(`cc_val[i]` ↔ `cc_ref[i]` 文本)。 */ + VALUE_RANGE_TO_TEXT = 8 /**< 值区间 → 文本(未支持,按恒等处理)。 */ }; /** @@ -109,6 +110,14 @@ namespace jdi std::uint64_t Size() const { return m_size; } + /** + * @brief 已从文件实际读取的字节数(用于「已读 / 总大小」进度)。 + * + * 随机访问会重复读取元数据,因此这个值可能超过文件大小; + * 上报时必须自行夹到 `Size()`。 + */ + std::uint64_t BytesRead() const { return m_bytesRead; } + /** @brief 读取一段原始字节。 */ bool ReadAt(std::uint64_t offset, void* dst, std::size_t bytes) const { @@ -120,7 +129,12 @@ namespace jdi { return false; } - return std::fread(dst, 1, bytes, m_file) == bytes; + const bool ok = std::fread(dst, 1, bytes, m_file) == bytes; + if (ok) + { + m_bytesRead += bytes; + } + return ok; } /** @brief 读取一个小端标量(最多 8 字节)。越界返回 0。 */ @@ -211,10 +225,37 @@ namespace jdi return BlockLength(offset) >= kBlockHeaderBytes && offset + BlockLength(offset) <= m_size; } - /** @brief 读取 `##TX` / `##MD` 块里的文本(MDF 的 TEXT 块)。 */ + /** + * @brief 读取 `##TX` / `##MD` 块里的文本。 + * + * ⚠️ 此前注释写的是「`##TX` / `##MD`」但实现只接受 `##TX`,于是 `##MD` + *(XML 元数据:单位、注释、显示名等)一律返回空。现在两种块都读: + * * `##TX`:兼容规范的「长度 + UTF-16 / UTF-8」与 CANape 的「NUL 结尾裸文本」; + * * `##MD`:数据段是 XML(UTF-8),没有长度前缀 ⇒ 直接转成宽字符串。 + */ std::wstring TextBlock(std::uint64_t offset) const { std::wstring text; + if (IsBlock(offset, "##MD")) + { + const std::uint64_t data = DataOffset(offset); + const std::uint64_t usable = BlockLength(offset) - (data - offset); + if (usable == 0) + { + return text; + } + std::vector buffer(static_cast(usable) + 1u, '\0'); + if (!ReadAt(data, buffer.data(), static_cast(usable))) + { + return text; + } + std::size_t length = static_cast(usable); + while (length > 0 && buffer[length - 1] == '\0') + { + --length; + } + return Utf8ToWide(buffer.data(), static_cast(length)); + } if (!IsBlock(offset, "##TX")) { return text; @@ -301,6 +342,7 @@ namespace jdi private: FILE* m_file = nullptr; std::uint64_t m_size = 0; + mutable std::uint64_t m_bytesRead = 0; /**< 已实际读取的字节数(进度上报用)。 */ }; /** @brief 一个通道的读取方案(已从 CN 块解析出来)。 */ @@ -313,10 +355,17 @@ namespace jdi bool allInvalid = false; /**< `cn_flags & 1`:整列无效。 */ bool hasInvalidBit = false; /**< `cn_flags & 2`:有无效位。 */ std::size_t invalidBitPos = 0; /**< 无效位的绝对位偏移。 */ - bool syncMaster = false; /**< `cn_sync_type == 1`:时间主通道。 */ + int type = 0; /**< `cn_type`:0 定长 / 1 VLSD / 2 虚拟主通道 / 3 同步通道。 */ + int syncType = 0; /**< `cn_sync_type`:1 时间 / 2 角度 / 3 距离。 */ + bool syncMaster = false; /**< `cn_sync_type >= 1`:本组的主通道。 */ + bool vlsd = false; /**< `cn_type == 1`:值存放在 `##SD` 里(记录内是偏移)。 */ + std::uint64_t signalData = 0; /**< VLSD 通道的 `##SD` 块地址(`cn_data`)。 */ + /** `##SI` 采样索引(`cn_si_source`);空表示每个记录都有采样。 */ + std::vector sampleIndex; bool linearConversion = false; /**< 是否带线性换算。 */ double offset = 0.0; /**< 线性换算常数项。 */ double factor = 1.0; /**< 线性换算系数(无法换算时为 1)。 */ + SignalEnumText enumText; /**< 值 → 文本枚举表(`cc_type == 7`,可能为空)。 */ /** 数值有效性:只有整数与浮点通道可以绘制。 */ bool numeric = false; }; @@ -426,7 +475,158 @@ namespace jdi return static_cast(raw); } - /** @brief 解析一个 CN 块为读取方案。 */ + /** + * @brief 把 VLSD 通道的 `##SD`(信号数据)块内容读进内存。 + * + * MDF4 的 VLSD 数据格式(实测 + 参考实现一致): + * * `##SD` 数据段是一串「`UINT32` 长度 + 该长度的字节」的样本; + * * 记录(`##DT`)在该通道的 `cn_byte_offset` 处放一个 **64 位偏移**, + * 指向样本长度前缀在 `##SD` 数据里的位置(偏移按各 `##SD` 数据段顺序累加)。 + * + * 只有 VLSD 通道才需要这块数据,因此按通道按需加载,不做全局缓存。 + * + * @param [IN] block 起始块(`##SD` / `##DZ` / `##DL` / `##HL`)。 + * @param [IN,OUT] out 追加写入的逻辑字节流。 + * @return 读到至少一段数据返回 true。 + */ + bool AppendSignalData(const MdfFile& file, std::uint64_t block, std::vector& out, + int depth) + { + if (block == 0 || depth > 8 || !file.IsBlock(block, "")) + { + return false; + } + // 防御:单个通道的 VLSD 数据超过 512MB 视为异常,避免损坏文件吃光内存。 + constexpr std::size_t kMaxSignalDataBytes = 512u * 1024u * 1024u; + char id[5] = {}; + file.BlockId(block, id); + const std::uint64_t data = file.DataOffset(block); + const std::uint64_t payload = file.BlockLength(block) - (data - block); + if (payload == 0) + { + return false; + } + if (std::memcmp(id, "##SD", 4) == 0) + { + if (out.size() + payload > kMaxSignalDataBytes) + { + return false; + } + const std::size_t base = out.size(); + out.resize(base + static_cast(payload)); + if (!file.ReadAt(data, out.data() + base, static_cast(payload))) + { + out.resize(base); + return false; + } + // `##SD` 可能还有同类型的后继块(链接 0)。 + if (file.LinkCount(block) > 0) + { + AppendSignalData(file, file.Link(block, 0), out, depth + 1); + } + return true; + } + if (std::memcmp(id, "##DZ", 4) == 0) + { + // `##DZ` 数据段布局:original_type(4) + zip_type(1) + reserved(3) + + // param(4) + original_size(8) + zip_size(8) + 压缩数据。 + const std::uint8_t zipType = file.U8(data + 4); + const std::uint64_t zipSize = file.U64(data + 20); + if (zipType != 0 || payload <= 28u) // 只支持纯 deflate(不支持转置/LZ4/ZSTD) + { + return false; + } + const std::uint64_t usable = std::min(payload - 28u, zipSize); + if (usable == 0 || out.size() + usable > kMaxSignalDataBytes) + { + return false; + } + std::vector compressed(static_cast(usable)); + if (!file.ReadAt(data + 28u, compressed.data(), compressed.size())) + { + return false; + } + std::vector inflated; + if (!InflateZlib(compressed.data(), compressed.size(), inflated)) + { + return false; + } + out.insert(out.end(), inflated.begin(), inflated.end()); + return true; + } + if (std::memcmp(id, "##DL", 4) == 0 || std::memcmp(id, "##HL", 4) == 0) + { + const std::uint64_t links = file.LinkCount(block); + bool any = false; + for (std::uint64_t index = 0; index < links && index < 1000000; ++index) + { + any = AppendSignalData(file, file.Link(block, index), out, depth + 1) || any; + } + return any; + } + return false; + } + + /** + * @brief 从记录中解出一个 VLSD 采样(取样本的第一个元素)。 + * + * 记录里放的是 64 位偏移。两种写法都接受: + * * 直接偏移(部分写盘器); + * * `偏移 << 1 | 标志位`(标志位为 1 时表示长度,此时按 `>> 1` 回退)。 + * 判据是「长度前缀自洽」——哪种解释能在 `##SD` 缓冲里定位到合法样本就用哪种。 + * + * @return 解出的数值;无法解释时返回 NaN。 + */ + double DecodeVlsdValue(const unsigned char* record, std::size_t recordBytes, const ChannelSpec& spec, + const std::vector& signalData) + { + if (signalData.empty() || spec.bitCount == 0) + { + return kMissingValue; + } + const std::size_t byteStart = spec.bitOffset / 8u; + if (byteStart + 8u > recordBytes) + { + return kMissingValue; + } + std::uint64_t raw = 0; + std::memcpy(&raw, record + byteStart, sizeof(raw)); + for (int attempt = 0; attempt < 2; ++attempt) + { + const std::uint64_t offset = (attempt == 0) ? raw : (raw >> 1); + if (offset + 4u > signalData.size()) + { + continue; + } + std::uint32_t length = 0; + std::memcpy(&length, signalData.data() + offset, sizeof(length)); + if (static_cast(length) > signalData.size() - offset - 4u) + { + continue; + } + const std::size_t elementBytes = (spec.bitCount + 7u) / 8u; + if (length < elementBytes) + { + return kMissingValue; + } + ChannelSpec element = spec; + element.bitOffset = 0; + element.bitCount = std::min(spec.bitCount, static_cast(length) * 8u); + element.vlsd = false; + return DecodeRawValue(signalData.data() + offset + 4u, length, element); + } + return kMissingValue; + } + + /** + * @brief 解析一个 CN 块为读取方案。 + * + * ⚠️ **链接布局有两种写法**:MDF 4.0 写 8 个链接(没有 `cn_sr_source`), + * 4.1 写 9 个。二者共有的尾部 4 个链接是 + * `cn_cc_conversion` / `cn_data` / `cn_md_unit` / `cn_md_comment`, + * 因此这里按**链接总数**反推换算/数据块下标,而不是写死 4/5。 + *(实测 Simulink 导出的「4.10」文件仍使用 8 链接布局。) + */ bool ParseChannel(const MdfFile& file, std::uint64_t cn, ChannelSpec& spec) { if (!file.IsBlock(cn, "##CN")) @@ -443,6 +643,12 @@ namespace jdi const std::uint32_t flags = file.U32(data + 12); const std::uint32_t invalidBit = file.U32(data + 16); + const std::uint64_t linkCount = file.LinkCount(cn); + // 8 链接布局:[4] = 换算、[5] = 数据块、[6] = 单位、[7] = 注释。 + // 9 链接布局:[5] = 换算、[6] = 数据块、[7] = 单位、[8] = 注释。 + const std::uint64_t conversionIndex = (linkCount >= 9) ? 5 : 4; + const std::uint64_t signalDataIndex = conversionIndex + 1; + spec.name = file.TextBlock(file.Link(cn, 2)); spec.dataType = static_cast(dataType); spec.bitCount = bitCount; @@ -450,10 +656,39 @@ namespace jdi spec.allInvalid = (flags & 0x1u) != 0u; spec.hasInvalidBit = (flags & 0x2u) != 0u; spec.invalidBitPos = invalidBit; - spec.syncMaster = (sync == 1); + spec.type = static_cast(type); + spec.syncType = static_cast(sync); + spec.syncMaster = (sync >= 1 && sync <= 3); + spec.vlsd = (type == 1); + if (spec.vlsd && linkCount > signalDataIndex) + { + spec.signalData = file.Link(cn, signalDataIndex); + } - // 换算:只支持“无换算”与“线性”。 - const std::uint64_t cc = file.Link(cn, 4); + // 采样索引(`##SI`):只有当它确实是「多条记录下标」时才生效。 + // ⚠️ 实测部分工具会给每个通道挂一个只含 1 个 8 字节值的 `##SI`(如 513), + // 那显然不是采样下标(远大于周期数)⇒ 必须忽略,否则会把整列清成 NaN。 + { + const std::uint64_t si = (linkCount > 3) ? file.Link(cn, 3) : 0; + if (file.IsBlock(si, "##SI")) + { + const std::uint64_t siData = file.DataOffset(si); + const std::uint64_t usable = file.BlockLength(si) - (siData - si); + const std::size_t count = static_cast(usable / 8u); + if (count >= 2 && count <= (1u << 24)) + { + spec.sampleIndex.resize(count); + bool ok = file.ReadAt(siData, spec.sampleIndex.data(), count * 8u); + if (!ok) + { + spec.sampleIndex.clear(); + } + } + } + } + + // 换算:支持「无换算」「线性」与「值 → 文本」三类。 + const std::uint64_t cc = (linkCount > conversionIndex) ? file.Link(cn, conversionIndex) : 0; if (file.IsBlock(cc, "##CC")) { const std::uint64_t ccData = file.DataOffset(cc); @@ -472,11 +707,38 @@ namespace jdi spec.factor = 1.0; } } + else if (ccType == static_cast(ConversionType::VALUE_TO_TEXT) && valCount > 0) + { + // `cc_val[i]`(F64 数组,从数据段 +24 开始)与 `cc_ref[4 + i]` 一一对应; + // 最后一个引用(下标 4 + valCount)是「默认文本」,这里不使用。 + const std::uint64_t ccLinkCount = file.LinkCount(cc); + spec.enumText.values.reserve(valCount); + spec.enumText.texts.reserve(valCount); + for (std::uint16_t index = 0; index < valCount; ++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; // 没有文本的取值不进入枚举表 + } + spec.enumText.values.push_back(file.F64(ccData + 24u + 8u * index)); + spec.enumText.texts.push_back(std::move(text)); + } + if (spec.enumText.texts.size() != spec.enumText.values.size() || + spec.enumText.texts.empty()) + { + spec.enumText = SignalEnumText(); + } + } } // 通道类型 2 = 虚拟主通道(CANape 的时间轴),同样可以取值。 spec.numeric = IsNumericType(spec.dataType) && spec.bitCount > 0 && spec.bitCount <= 64; - (void)type; return true; } @@ -546,7 +808,23 @@ namespace jdi } if (std::memcmp(id, "##DZ", 4) == 0) { - out.push_back(DataSegment{data, payload, true}); + // `##DZ` 数据段布局:original_type(4) + zip_type(1) + reserved(3) + + // param(4) + original_size(8) + zip_size(8) + 压缩数据。 + // ⚠️ 载荷里含 28 字节头,不能把整段当 zlib 流(旧实现在这里错了)。 + char originalType[5] = {}; + file.BlockId(data, originalType); // 读 4 字节原始类型("DT"/"SD"/...) + const std::uint8_t zipType = file.U8(data + 4); + const std::uint64_t zipSize = file.U64(data + 20); + if (zipType != 0 || payload <= 28u) + { + return false; // 只支持纯 deflate + } + if (std::memcmp(originalType, "SD", 2) == 0) + { + return false; // 压缩的 VLSD 数据由 AppendSignalData 负责 + } + const std::uint64_t usable = std::min(payload - 28u, zipSize); + out.push_back(DataSegment{data + 28u, usable, true}); return true; } if (std::memcmp(id, "##DL", 4) == 0 || std::memcmp(id, "##HL", 4) == 0) @@ -559,7 +837,9 @@ namespace jdi } return any; } - // `##SD`(已排序数据)、`##SI` 等不在支持范围内:跳过该数据组。 + // 到这里说明不是记录数据块: + // * `##SD`(Signal Data,VLSD 通道的样本)由 `AppendSignalData` 按通道读取; + // * `##SI` 是采样索引,不是数据块。 return false; } @@ -567,14 +847,16 @@ namespace jdi * @brief 依次从若干数据段中读取连续记录,并对每条完整记录调用 `visit`。 * * * 记录可能**跨段**(`##DT` 载荷不是记录长度的整数倍时)⇒ 用 `carry` 拼接; - * * 压缩段先用内置 inflate 展开(`##DZ`),再按普通字节流消费。 + * * 压缩段先用内置 inflate 展开(`##DZ`),再按普通字节流消费; + * * 每读一块调用 `cancelled`(可为空),返回 true 表示请求取消。 * * @return 成功读取到至少 `cycles` 条记录返回 true。 */ bool ReadRecordSegments( const MdfFile& file, const std::vector& segments, std::uint64_t cycles, std::size_t recordBytes, - const std::function& visit) + const std::function& visit, + const std::function& cancelled = std::function()) { if (recordBytes == 0 || cycles == 0) { @@ -591,6 +873,10 @@ namespace jdi { break; } + if (cancelled && cancelled()) + { + return false; + } const unsigned char* data = nullptr; std::size_t bytes = 0; if (segment.compressed) @@ -643,6 +929,10 @@ namespace jdi } } consumed += want; + if (cancelled && cancelled()) + { + return false; + } } continue; } @@ -675,18 +965,50 @@ namespace jdi std::vector> values; /**< 每个非时间通道的采样值。 */ }; + /** + * @brief 把 `##SI` 采样索引转换成「逐记录有效位图」。 + * + * 只有当索引表**至少两条**且**全部落在记录范围内**时才生效; + * 否则(例如某些工具给每个通道挂一个只含 `513` 的 `##SI`)返回空向量表示「全有效」, + * 与不支持 `##SI` 时的行为完全一致。 + */ + std::vector BuildSampleMask(const ChannelSpec& spec, std::uint64_t cycles) + { + std::vector mask; + if (spec.sampleIndex.size() < 2 || cycles == 0) + { + return mask; + } + for (const std::uint64_t index : spec.sampleIndex) + { + if (index >= cycles) + { + return mask; // 不像采样索引:忽略 + } + } + mask.assign(static_cast(cycles), 0); + for (const std::uint64_t index : spec.sampleIndex) + { + mask[static_cast(index)] = 1; + } + return mask; + } + /** * @brief 读取一个通道组的全部记录(流式,按 4MB 分块)。 * * @param [IN] file 文件访问器。 - * @param [IN] dt 数据块偏移(`##DT`),记录从 `DataOffset(dt)` 开始连续存放。 + * @param [IN] segments 数据段列表(`##DT` / `##DL` / `##HL` / `##DZ`)。 * @param [IN] cycles 记录条数。 * @param [IN] recordBytes 单条记录字节数。 + * @param [IN] masterIndex 时间主通道下标。 * @param [IN,OUT] group 输出(`timeName`/`channels` 已填好,函数填充 `time`/`values`)。 + * @param [IN] cancelled 取消检查(可为空)。 */ bool ReadChannelGroupRecords(const MdfFile& file, const std::vector& segments, std::uint64_t cycles, std::size_t recordBytes, - std::size_t masterIndex, ChannelGroupData& group) + std::size_t masterIndex, ChannelGroupData& group, + const std::function& cancelled = std::function()) { if (cycles == 0 || recordBytes == 0 || segments.empty()) { @@ -696,11 +1018,36 @@ namespace jdi group.values.assign(group.channels.size(), std::vector(static_cast(cycles), kMissingValue)); + // `##SI`:每个通道各自的「有效记录」位图(空 = 每个记录都有效)。 + std::vector> masks(group.channels.size()); + for (std::size_t index = 0; index < group.channels.size(); ++index) + { + masks[index] = BuildSampleMask(group.channels[index], cycles); + } + + // VLSD 通道的 `##SD` 数据:按通道加载(只有 VLSD 通道才需要)。 + std::vector> signalData(group.channels.size()); + for (std::size_t index = 0; index < group.channels.size(); ++index) + { + const ChannelSpec& spec = group.channels[index]; + if (spec.vlsd && spec.signalData != 0 && spec.numeric) + { + AppendSignalData(file, spec.signalData, signalData[index], 0); + } + } + + std::vector*> signalDataPtr(group.channels.size(), nullptr); + for (std::size_t index = 0; index < group.channels.size(); ++index) + { + signalDataPtr[index] = signalData[index].empty() ? nullptr : &signalData[index]; + } + // ⚠️ 记录可能分散在多个 `##DT` / `##DZ`(经 `##DL` / `##HL` 列表)中, // 由 `ReadRecordSegments` 负责跨段拼接与解压(见该函数的说明)。 return ReadRecordSegments( file, segments, cycles, recordBytes, - [&group, masterIndex, recordBytes](const unsigned char* record, std::uint64_t row) + [&group, masterIndex, recordBytes, &masks, &signalDataPtr](const unsigned char* record, + std::uint64_t row) { // 时间主通道。 { @@ -734,7 +1081,24 @@ namespace jdi continue; } const ChannelSpec& spec = group.channels[c]; - double value = DecodeRawValue(record, recordBytes, spec); + // `##SI` 采样索引:只有表中列出的记录才有采样,其余保持 NaN。 + if (!masks[c].empty() && masks[c][static_cast(row)] == 0) + { + continue; + } + double value = kMissingValue; + if (spec.vlsd) + { + // VLSD:记录里是 64 位偏移,真正的取值在 `##SD` 里。 + if (signalDataPtr[c] != nullptr) + { + value = DecodeVlsdValue(record, recordBytes, spec, *signalDataPtr[c]); + } + } + else + { + value = DecodeRawValue(record, recordBytes, spec); + } if (spec.allInvalid) { value = kMissingValue; @@ -754,20 +1118,46 @@ namespace jdi } group.values[c][static_cast(row)] = value; } - }); + }, + cancelled); + } + + /** @brief 主通道名字判定用的 ASCII 小写化。 */ + std::wstring LowerAscii(const std::wstring& text) + { + std::wstring lower; + lower.reserve(text.size()); + for (const wchar_t c : text) + { + lower.push_back((c >= L'A' && c <= L'Z') ? static_cast(c - L'A' + L'a') : c); + } + return lower; } /** * @brief 从通道组里选出时间主通道。 - * @return 主通道下标;找不到时返回 `channels.size()`(表示没有可用时间轴)。 + * + * 判定顺序(越靠前越可信): + * 1. `cn_sync_type` = 1(时间)/ 2(角度)/ 3(距离)且为数值通道; + * 2. 名字为 `t` / `time` / `timestamp` / `zeit` / `时间`(含 `t[s]` 这类带单位后缀); + * 3. 第一个数值通道(兜底:至少让数据能画出来,而不是整组丢弃)。 + * + * ⚠️ `cg_flags` 的第 2 位表示「远程主通道」(主通道不在本 CG 里)。此时跳过第 1 步, + * 直接按名字 / 首个数值通道兜底,避免把某个普通信号误当成时间轴。 + * + * @return 主通道下标;一个数值通道都没有时返回 `channels.size()`。 */ - std::size_t SelectMasterChannel(const std::vector& channels) + std::size_t SelectMasterChannel(const std::vector& channels, std::uint16_t cgFlags) { - for (std::size_t i = 0; i < channels.size(); ++i) + const bool remoteMaster = (cgFlags & 0x4u) != 0u; + if (!remoteMaster) { - if (channels[i].syncMaster && channels[i].numeric) + for (std::size_t i = 0; i < channels.size(); ++i) { - return i; + if (channels[i].syncMaster && channels[i].numeric) + { + return i; + } } } for (std::size_t i = 0; i < channels.size(); ++i) @@ -776,13 +1166,16 @@ namespace jdi { continue; } - std::wstring lower; - lower.reserve(channels[i].name.size()); - for (const wchar_t c : channels[i].name) + const std::wstring lower = LowerAscii(channels[i].name); + if (lower == L"time" || lower == L"t" || lower == L"timestamp" || lower == L"zeit" || + lower == L"时间" || lower.rfind(L"t[", 0) == 0 || lower.rfind(L"time[", 0) == 0) { - lower.push_back((c >= L'A' && c <= L'Z') ? static_cast(c - L'A' + L'a') : c); + return i; } - if (lower == L"time" || lower == L"t" || lower == L"timestamp") + } + for (std::size_t i = 0; i < channels.size(); ++i) + { + if (channels[i].numeric && !channels[i].vlsd) { return i; } @@ -827,141 +1220,267 @@ namespace jdi } const std::uint64_t firstDg = file.Link(hd, 0); - std::size_t groupCount = 0; + + // 先走一遍 DG 链只收集偏移(只读块头,代价极小): + // 进度分母必须是**真实数据组数量**(旧实现写死 64,组数少的文件会「停在 44% 再跳 100%」)。 + std::vector dataGroups; WalkChain(file, firstDg, "##DG", kMaxGroups, [&](std::uint64_t dg) { - ++groupCount; - const std::uint64_t cg = file.Link(dg, 1); - const std::uint64_t dataBlock = file.Link(dg, 2); - if (!file.IsBlock(cg, "##CG") || dataBlock == 0) - { - return true; // 跳过不完整的数据组 - } - - // 记录可能写在 `##DT` / `##DL`(数据列表)/ `##HL`(头部列表)/ `##DZ`(压缩)里: - // 旧实现只认 `##DT`,于是用 `##DL` 存放记录的文件会**整组被跳过** - //(用户 2026-10-19 报告:Simulink SDI 导出的 8 个信号只读出 4 个)。 - std::vector segments; - if (!CollectDataSegments(file, dataBlock, segments, 0) || segments.empty()) - { - return true; // 不支持的数据布局(如 ##SD)⇒ 跳过该组 - } - - const std::uint64_t cgData = file.DataOffset(cg); - const std::uint64_t cycles = file.U64(cgData + 8); - if (cycles == 0) - { - return true; - } - const std::uint64_t recordIdBytes = file.U8(cgData); // `cg_record_id_len` - std::uint64_t payload = 0; - bool anyCompressed = false; - for (const DataSegment& segment : segments) - { - payload += segment.bytes; - anyCompressed = anyCompressed || segment.compressed; - } - - // 解析通道链。 - std::vector channels; - WalkChain(file, file.Link(cg, 1), "##CN", kMaxChannelsPerGroup, - [&](std::uint64_t cn) - { - ChannelSpec spec; - if (ParseChannel(file, cn, spec)) - { - channels.push_back(std::move(spec)); - } - return true; - }); - if (channels.empty()) - { - return true; - } - - // 记录长度:优先用「数据块大小 / 记录数」反推(最可靠), - // ⚠️ 压缩段(`##DZ`)的载荷是压缩后长度,不能用来反推 ⇒ 改为按通道位宽推算。 - std::size_t recordBytes = 0; - if (!anyCompressed && payload % cycles == 0 && payload / cycles > 0) - { - recordBytes = static_cast(payload / cycles); - } - else - { - std::size_t bits = static_cast(recordIdBytes) * 8u; - for (const ChannelSpec& spec : channels) - { - bits = std::max(bits, spec.bitOffset + spec.bitCount); - } - recordBytes = (bits + 31u) / 32u * 4u; - } - if (recordBytes == 0) - { - return true; - } - - const std::size_t masterIndex = SelectMasterChannel(channels); - if (masterIndex >= channels.size()) - { - return true; // 没有可用时间轴通道 - } - - ChannelGroupData group; - group.timeName = channels[masterIndex].name; - if (group.timeName.empty()) - { - group.timeName = L"time"; - } - group.channels = std::move(channels); - if (!ReadChannelGroupRecords(file, segments, cycles, recordBytes, masterIndex, group)) - { - return true; - } - - // 组织成一个时间轴块:时间列 + 其余数值通道。 - TableBlock block; - block.timeHeader = group.timeName; - block.table.headers.push_back(group.timeName); - block.table.columns.push_back(std::move(group.time)); - for (std::size_t c = 0; c < group.channels.size(); ++c) - { - if (c == masterIndex || !group.channels[c].numeric) - { - continue; - } - std::wstring name = group.channels[c].name; - if (name.empty()) - { - name = L"Channel" + std::to_wstring(c + 1); - } - block.table.headers.push_back(name); - block.table.columns.push_back(std::move(group.values[c])); - } - if (block.table.columns.size() < 2) - { - return true; // 只有时间列:没有可绘制信号 - } - block.table.rowCount = static_cast(cycles); - source.blocks.push_back(std::move(block)); - - if (progress) - { - LoadProgressInfo info; - info.ratio = (groupCount > 0) ? std::min(1.0, static_cast(groupCount) / 64.0) - : 0.0; - info.hasBytes = false; - if (!progress(info)) - { - return false; - } - } + dataGroups.push_back(dg); return true; }); + std::size_t processedGroups = 0; + double lastRatio = -1.0; + const auto emitProgress = [&]() -> bool + { + if (!progress) + { + return true; + } + LoadProgressInfo info; + const double byGroup = + dataGroups.empty() ? 1.0 + : static_cast(processedGroups) / + static_cast(dataGroups.size()); + double ratio = 0.98 * byGroup; + ratio = std::max(ratio, lastRatio); // 保证单调 + lastRatio = ratio; + info.ratio = ratio; + // 同时上报真实字节数:`MdfFile::ReadAt` 逐次累计(随机访问可能重复读元数据,需夹住)。 + info.hasBytes = true; + info.bytesRead = std::min(file.BytesRead(), file.Size()); + info.totalBytes = file.Size(); + return progress(info); + }; + + std::size_t skippedNoLayout = 0; + std::size_t skippedEmpty = 0; + std::size_t skippedNoAxis = 0; + bool cancelRequested = false; + + for (const std::uint64_t dg : dataGroups) + { + if (cancelRequested) + { + break; + } + ++processedGroups; + const std::uint64_t cg = file.Link(dg, 1); + const std::uint64_t dataBlock = file.Link(dg, 2); + if (!file.IsBlock(cg, "##CG") || dataBlock == 0) + { + ++skippedNoLayout; // 不完整的数据组(没有 CG 或没有数据块) + if (!emitProgress()) + { + cancelRequested = true; + } + continue; + } + + // 记录可能写在 `##DT` / `##DL`(数据列表)/ `##HL`(头部列表)/ `##DZ`(压缩)里: + // 旧实现只认 `##DT`,于是用 `##DL` 存放记录的文件会**整组被跳过** + //(用户 2026-10-19 报告:Simulink SDI 导出的 8 个信号只读出 4 个)。 + std::vector segments; + if (!CollectDataSegments(file, dataBlock, segments, 0) || segments.empty()) + { + ++skippedNoLayout; + if (!emitProgress()) + { + cancelRequested = true; + } + continue; + } + + const std::uint64_t cgData = file.DataOffset(cg); + const std::uint16_t cgFlags = file.U16(cgData + 0); + const std::uint64_t cycles = file.U64(cgData + 8); + if (cycles == 0) + { + ++skippedEmpty; // 空数据组(例如只有注释通道的组) + if (!emitProgress()) + { + cancelRequested = true; + } + continue; + } + // `cg_data_bytes`(+24)是单条记录字节数,4.1 起由文件直接给出,最可靠。 + const std::uint32_t cgDataBytes = file.U32(cgData + 24); + + // 解析通道链。 + std::vector channels; + WalkChain(file, file.Link(cg, 1), "##CN", kMaxChannelsPerGroup, + [&](std::uint64_t cn) + { + ChannelSpec spec; + if (ParseChannel(file, cn, spec)) + { + channels.push_back(std::move(spec)); + } + return true; + }); + if (channels.empty()) + { + ++skippedNoLayout; + if (!emitProgress()) + { + cancelRequested = true; + } + continue; + } + + // 记录长度:优先用 `cg_data_bytes`;否则用「数据块大小 / 记录数」反推; + // ⚠️ 压缩段(`##DZ`)的载荷是压缩后长度,不能用来反推 ⇒ 退回按通道位宽推算。 + std::uint64_t payload = 0; + bool anyCompressed = false; + for (const DataSegment& segment : segments) + { + payload += segment.bytes; + anyCompressed = anyCompressed || segment.compressed; + } + std::size_t recordBytes = 0; + if (cgDataBytes > 0 && cgDataBytes <= (1u << 20)) + { + recordBytes = static_cast(cgDataBytes); + } + else if (!anyCompressed && payload % cycles == 0 && payload / cycles > 0 && + payload / cycles <= (1u << 20)) + { + recordBytes = static_cast(payload / cycles); + } + else + { + std::size_t bits = 0; + for (const ChannelSpec& spec : channels) + { + bits = std::max(bits, spec.bitOffset + spec.bitCount); + } + recordBytes = (bits + 31u) / 32u * 4u; + } + if (recordBytes == 0) + { + ++skippedNoLayout; + if (!emitProgress()) + { + cancelRequested = true; + } + continue; + } + + const std::size_t masterIndex = SelectMasterChannel(channels, cgFlags); + if (masterIndex >= channels.size()) + { + ++skippedNoAxis; // 没有任何数值通道,无法建立时间轴 + if (!emitProgress()) + { + cancelRequested = true; + } + continue; + } + // 主通道自身若是 VLSD/字符串,取值无法作为时间轴 ⇒ 直接跳过该组。 + if (channels[masterIndex].vlsd) + { + ++skippedNoAxis; + if (!emitProgress()) + { + cancelRequested = true; + } + continue; + } + + ChannelGroupData group; + group.timeName = channels[masterIndex].name; + if (group.timeName.empty()) + { + group.timeName = L"time"; + } + group.channels = std::move(channels); + bool readCancelled = false; + const auto cancelled = [&]() -> bool + { + if (emitProgress()) + { + return false; + } + readCancelled = true; + return true; + }; + const bool readOk = + ReadChannelGroupRecords(file, segments, cycles, recordBytes, masterIndex, group, cancelled); + if (readCancelled) + { + cancelRequested = true; + break; + } + if (!readOk) + { + ++skippedNoLayout; + if (!emitProgress()) + { + cancelRequested = true; + break; + } + continue; + } + + // 组织成一个时间轴块:时间列 + 其余数值通道。 + TableBlock block; + block.timeHeader = group.timeName; + block.table.headers.push_back(group.timeName); + block.table.columns.push_back(std::move(group.time)); + block.table.columnEnumText.emplace_back(); // 时间列没有枚举信息 + for (std::size_t c = 0; c < group.channels.size(); ++c) + { + if (c == masterIndex || !group.channels[c].numeric) + { + continue; + } + std::wstring name = group.channels[c].name; + if (name.empty()) + { + name = L"Channel" + std::to_wstring(c + 1); + } + block.table.headers.push_back(name); + block.table.columns.push_back(std::move(group.values[c])); + block.table.columnEnumText.push_back(std::move(group.channels[c].enumText)); + } + if (block.table.columns.size() < 2) + { + ++skippedEmpty; // 只有时间列:没有可绘制信号 + continue; + } + block.table.rowCount = static_cast(cycles); + source.blocks.push_back(std::move(block)); + } + + if (cancelRequested) + { + error.clear(); // 用户取消:不显示错误 + return false; + } + if (source.blocks.empty()) { + // 说明「为什么一个信号都没有」,而不是笼统地说找不到数值通道。 + std::wstring detail; + if (skippedNoLayout > 0) + { + detail += L"数据块布局无法解析 " + std::to_wstring(skippedNoLayout) + L" 组;"; + } + if (skippedNoAxis > 0) + { + detail += L"缺少可用时间通道 " + std::to_wstring(skippedNoAxis) + L" 组;"; + } + if (skippedEmpty > 0) + { + detail += L"空数据组 " + std::to_wstring(skippedEmpty) + L" 组;"; + } error = L"未在文件中找到可绘制的数值通道。"; + if (!detail.empty()) + { + error += L"(" + detail + L")"; + } return false; } @@ -969,6 +1488,9 @@ namespace jdi { LoadProgressInfo info; info.ratio = 1.0; + info.hasBytes = true; + info.bytesRead = std::min(file.BytesRead(), file.Size()); + info.totalBytes = file.Size(); progress(info); } return true;