HTRI二次开发教程(10):结构化落盘——案例矩阵、结果长表与断点账本
版本与事实声明
- 版本锚点:当前Xchanger Suite 9.4;落盘时必须记录所运行的软件版本(9.4 的方法更新会改变结果,如 9.3 改了 RPM 冷凝方法、9.4 改了超临界传热方法)。
- 示例代码中所有标识符为占位符;数值均为示例性建模,不代表任何标准规定,不对应任何真实装置。
一句话结论:批量结果落盘的核心是三张"契约表"——案例矩阵(唯一键 = case_id,含全部变量取值)、结果长表(case_id × parameter × value × unit + 来源)、断点账本(case_id × status 追加式,取最新记录);加上一条铁律:每份结果都必须带上"软件版本 + 单位 + 运行时间"三类元数据,否则明天的你无法复现今天的数。
〇、本篇要解决的认知问题
- Q1:结果该存长表还是宽表?各自的使用场景是什么?
- Q2:CSV、Parquet、JSON 三种落盘格式,在批量工程里怎么选?
- Q3:案例矩阵的"唯一键"为什么必须是
case_id而不是变量组合? - Q4:断点账本的 schema 该怎么设计,怎么做到幂等?
- Q5:什么叫"结果可复现",需要落哪些元数据才能做到?
一、机制解析
1.1 长表 vs 宽表
| 维度 | 长表(long) | 宽表(wide) |
|---|---|---|
| 结构 | case_id, parameter, value, unit, source | case_id, 指标1, 指标2, ... |
| 优点 | 易追加、易扩展新指标、单位可逐行标注 | 便于人看、便于直接建模 |
| 缺点 | 查看不直观 | 指标增减要改 schema |
| 用途 | 存储层(原始、持久) | 展示层(报表、分析入口) |
最佳实践:存储永远用长表,展示按需转宽表。长表对"指标集变动"免疫(9.4 新增了温度有效度输出——宽表因此要加列,长表不用改结构)。
1.2 格式选择
| 格式 | 优点 | 缺点 | 适合 |
|---|---|---|---|
CSV(utf-8-sig) | 通用、Excel 能开、易 diff | 无类型、大文件慢 | 结果交付、小批量、人工复核 |
| Parquet | 有类型、压缩率高、读取快 | 需 pyarrow/fastparquet,Excel 不能直接开 | 大批量存储、后续分析 |
| JSON | 层级结构、元数据友好 | 数值精度/体积 | 元数据、探测报告、契约 |
经验法则:元数据与契约用 JSON,结果存储 CSV(小)或 Parquet(大),交付件从长表转宽表导出 CSV/Excel。
1.3 案例矩阵的唯一键
为什么用case_id而不是"变量组合":变量组合随扫描设计变化(今天扫 3 个变量、明天加第 4 个),用它当主键会让历史结果与未来结果无法对齐;且浮点变量组合做键有精度歧义。case_id(零填充编号)+ 一份独立的cases.csv(记录每个 case_id 的变量取值),把"标识"与"参数"解耦——这是扫描工程的标准做法(第 08 篇已用)。
1.4 断点账本 schema
case_id | status | detail | timestamp | software_version | results_json- 追加式:每次运行追加一行,永不修改历史行;
- 取最新:读取时对同一
case_id取timestamp最大的一行; - 幂等:重跑同一案例 = 再追加一行(状态更新),不产生歧义;
- 自带版本:
software_version记本次运行所用 Xchanger Suite 版本——这是可复现性的关键字段。
1.5 可复现性元数据
"结果可复现"要能回答四个问题:用什么版本跑的、单位是什么、什么时候跑的、输入是什么。对应四类元数据:
software_version(如 9.4);unit(每个数值的单位);timestamp(运行时间);- 案例矩阵(变量取值)+ 模板标识(模板文件名 + 哈希)。
最佳实践:给模板案例算一个内容哈希(如 SHA-256),写进结果元数据。这样"模板被谁改过"一眼可查——第 08 篇强调的"绝不覆盖模板"由此获得可验证性。
1.6 落盘目录结构与命名规范
为什么这对你重要:批量工程的目录一旦失控,找文件比跑计算还慢。落盘不是"写一个文件",而是"建立一套可检索、可分片、可归档的结构"。
推荐目录结构(与第 20 篇五层架构对齐):
run_2026-09-26_0930/ # 一次运行 = 一个带时间戳的目录 ├── meta/ │ ├── run_meta.json # 软件版本 / 模板哈希 / 单位集 / 时间 / 声明 │ └── sweep_contract.json # 扫描契约(变量-目标-模式) ├── input/ │ ├── cases.csv # 案例矩阵(唯一键 case_id) │ └── shards/ # 分片输入(第 19 篇) ├── state/ │ └── ledger.csv # 断点账本(追加 + 取最新) ├── results/ │ ├── results_merged_long.csv # 结果长表(审计源) │ └── results_wide_summary.csv # 宽表(展示用,可选) └── delivery/ ├── delivery_report.xlsx # 交付工作簿(第 15 篇) └── delivery_checklist.csv # 交付自检命名规范四条(经验法则):
- 时间戳用 ISO 紧凑格式(
run_2026-09-26_0930):字典序即时间序,排序不会错; - 中间产物与交付物分目录(
results/vsdelivery/):别让人在原始结果与加工交付件之间混淆; - 文件名不含空格与中文标点:跨工具(命令行、CI、云存储)最稳;中文标题写进文件内容而非文件名;
- 每个目录可独立打包:
results/单独拷走就能复现分析,state/单独拷走就能续扫。
一条常被忽略的纪律:一次运行一个目录,绝不往"共享的活动目录"里写。共享目录是批量工程里"结果互相覆盖"的头号来源——两个批次同时写ledger.csv,账本会变成一锅粥。带时间戳的独立运行目录让"隔离"成为默认。
1.7 落盘性能的三级策略
为什么这对你重要:落盘在小批量时"怎么写都对",到上千案例时才暴露性能问题——而那时你正急着出结果。
第一级(≤200 案例):CSV 全量重写。第 10 篇代码里的append_long就是这一级——每次读入、去重、全量写回。简单、可读、无依赖,最适合教学与中小批量。
第二级(200~5000 案例):分文件追加。按case_id前缀或批次日期分成多个 CSV,每片独立追加,只在最后合并一次。这样单次写的文件不会越来越大,且并行分片也天然写不同的文件(与第 19 篇分片对齐)。账本仍是单一文件、追加式——因为账本写入很轻(一行),不构成瓶颈。
第三级(>5000 案例):列式存储 + 分区。改用 Parquet 按分区写(如按 case_id 前缀分区),读取与分析用pandas.read_parquet按需加载分区。交付件仍从汇总层生成,不给下游看 Parquet——把"存储格式"与"交付格式"彻底分开。
一条经验法则:先按第一级写对,遇到性能再升级,不要提前为"可能的百万案例"过度设计。很多团队的落盘代码复杂到没人敢改,只因为"以防万一"——而真实的批量往往在千级以内。能用 CSV 解决的事,不要上数据库。
二、完整代码与逐行剖析
代码 10-1:三张契约表的落盘与读取
# -*- coding: utf-8 -*-""" ledger.py —— 案例矩阵 / 结果长表 / 断点账本 的幂等读写组件 用法:作为模块被其它脚本 import 说明:本模块只处理"结构与 IO",不触碰 HTRI;数值均为示例性建模。 """importcsvimportjsonimporthashlibimportdatetimeimportos NOW=lambda:datetime.datetime.now().isoformat(timespec="seconds")# noqa: E731# ---------- 元数据 ----------deffile_sha256(path,chunk=1<<20):"""计算文件内容哈希,用于记录"模板未被改动"的证据。"""h=hashlib.sha256()withopen(path,"rb")asf:whileTrue:b=f.read(chunk)ifnotb:breakh.update(b)returnh.hexdigest()defwrite_meta(path,meta):withopen(path,"w",encoding="utf-8")asf:json.dump(meta,f,ensure_ascii=False,indent=2)defbuild_run_meta(software_version,template_path,unit_set_note):return{"software_version":software_version,# 例:"9.4""template":os.path.basename(template_path),"template_sha256":file_sha256(template_path),"unit_set_note":unit_set_note,"created_at":NOW(),"disclaimer":"示例性建模,不代表任何标准规定,不对应任何真实装置",}# ---------- 断点账本 ----------LEDGER_FIELDS=["case_id","status","detail","timestamp","software_version","results_json"]defledger_latest(path):"""读账本 -> {case_id: 最新一行记录}"""latest={}ifnotos.path.exists(path):returnlatestwithopen(path,encoding="utf-8-sig")asf:forrincsv.DictReader(f):cur=latest.get(r["case_id"])ifcurisNoneorr["timestamp"]>=cur["timestamp"]:latest[r["case_id"]]=rreturnlatestdefledger_append(path,case_id,status,detail,software_version,results=None):"""追加一行;文件不存在则写表头。追加式 + 取最新 = 幂等。"""new_file=notos.path.exists(path)withopen(path,"a",newline="",encoding="utf-8-sig")asf:w=csv.DictWriter(f,fieldnames=LEDGER_FIELDS)ifnew_file:w.writeheader()w.writerow({"case_id":case_id,"status":status,"detail":detail,"timestamp":NOW(),"software_version":software_version,"results_json":json.dumps(resultsor{},ensure_ascii=False),})# ---------- 结果长表 ----------LONG_FIELDS=["case_id","section","parameter","value","unit","source"]defappend_long(path,rows):"""把若干长表记录追加到结果文件;用 case+parameter 去重覆盖。"""existing={}ifos.path.exists(path):withopen(path,encoding="utf-8-sig")asf:forrincsv.DictReader(f):existing[(r["case_id"],r["section"],r["parameter"])]=rforrinrows:existing[(r["case_id"],r["section"],r["parameter"])]=rwithopen(path,"w",newline="",encoding="utf-8-sig")asf:w=csv.DictWriter(f,fieldnames=LONG_FIELDS)w.writeheader()forrinexisting.values():w.writerow(r)if__name__=="__main__":# 自测:不可运行 HTRI,只验证 IO 幂等性ledger_append("_demo_ledger.csv","case_0001","done","","9.4",{"outputs.summary.overall_u":812.5})ledger_append("_demo_ledger.csv","case_0001","done","重跑","9.4",{"outputs.summary.overall_u":812.5})latest=ledger_latest("_demo_ledger.csv")print("自测:case_0001 最新状态 =",latest["case_0001"]["detail"]or"(空)")逐行剖析:
file_sha256分块读取:大文件(含图形的*.htri)也能算哈希而不爆内存;哈希进元数据,让"模板没被改"可验证。ledger_latest用r["timestamp"] >= cur["timestamp"]取最新:即使同一秒内多次追加,也稳定取后写的一条;追加式 + 取最新的组合实现幂等。ledger_append用os.path.exists判断是否写表头:保证首次创建与后续追加用同一段代码,不会出现"两个写表头的地方"。append_long先读后写整文件:以(case_id, section, parameter)为去重键,重跑同一案例时覆盖而非重复——这是长表落盘实现幂等的关键;批量很大时应改为"分片追加 + 定期压实",但对百级案例足够。- 自测段不触碰 HTRI:
ledger.py是纯 IO 组件,任何机器可跑,验证幂等逻辑本身。 - 每个
results_json都随行落盘:账本既记状态又暂存结果,第 08 篇扫描器的结果来源。
代码 10-2:长表转宽表(展示层)
# -*- coding: utf-8 -*-""" long_to_wide.py —— 结果长表转宽表(展示/建模入口) 用法:python long_to_wide.py results_merged_long.csv wide.csv """importsysimportpandasaspddefmain():iflen(sys.argv)<3:print("用法:python long_to_wide.py <long.csv> <wide.csv>")returndf=pd.read_csv(sys.argv[1],encoding="utf-8-sig")# 只取 summary 段,避免 detailed 剖面把宽表撑成稀疏矩阵df=df[df["section"]=="summary"]wide=df.pivot_table(index="case_id",columns="parameter",values="value",aggfunc="last").reset_index()# 单位列单独另存,避免与数值混在一张表里units=(df.drop_duplicates("parameter").set_index("parameter")["unit"].to_dict())wide.to_csv(sys.argv[2],index=False,encoding="utf-8-sig")print(f"宽表:{wide.shape[0]}行 ×{wide.shape[1]}列 ->{sys.argv[2]}")print("单位对照:",units)if__name__=="__main__":main()逐行剖析:
- 只转
section == "summary":明确"宽表只服务标量指标",把 detailed 剖面挡在外面(呼应第 09 篇"detailed 不能一键转表")。 aggfunc="last":长表里同一case_id × parameter可能有多次运行记录,取最新一次。- 单位单独导出为字典打印:宽表只放数值,单位另存,避免"把单位混进数值表"造成后续误解。
- 存储用长表、展示用宽表的两段式在本脚本落地,无需改动存储层。
三、常见报错与排查
报错 3-1:CSV 用 Excel 打开中文乱码。
现象:中文列名/数值乱码。根因:未带 BOM 写出。解法:统一encoding="utf-8-sig"(本系列一贯约定)。
报错 3-2:结果里同一 case_id 出现多行且数值不同。
现象:长表/账本有重复。根因:用"追加"而非"去重覆盖"写长表。解法:长表用append_long的去重逻辑;账本用"取最新"语义,两者职责不同——长表去重、账本追加。
报错 3-3:Parquet 写出失败ImportError: pyarrow。
现象:df.to_parquet报缺依赖。根因:未安装引擎。解法:pip install pyarrow;若环境受限无法装,退回 CSV(utf-8-sig)。别为了 Parquet 阻塞整条流水线。
报错 3-4:换台机器复现结果,数值对不上。
现象:同样的案例矩阵,换机器结果不同。根因:软件版本不同(方法更新),或物性库不同(VMGThermo 版本),或模板被改过。解法:落盘时记录software_version与template_sha256;复现前先核对这两项;物性相关差异以官方文档为准。
报错 3-5:结果文件随批次无限增长。
现象:长表越来越长。根因:append_long每次全量重写且无历史清理策略。解法:约定"结果按批次/日期分文件";或改 Parquet 分区存储(按 case_id 前缀分区)。
报错 3-6:两个批次同时写同一份账本,状态错乱。
现象:ledger.csv出现互相矛盾的最新记录。根因:多个批次共用一个活动目录(1.6 节的"共享目录"陷阱)。解法:一次运行一个带时间戳的独立目录;若确需共享,用文件锁或数据库替代 CSV 追加。
四、动手练习
- 练习 1(三表齐备):依代码 10-1 生成
cases.csv(矩阵)、*_long.csv(长表)、ledger.csv(账本)三张表。判定:三表都能用 pandas 读入;长表含case_id/section/parameter/value/unit/source六列。 - 练习 2(幂等验证):对同一
case_id连续调用ledger_append两次。判定:账本有两行,但ledger_latest只返回最新一行;再次运行长表写入不产生重复行。 - 练习 3(可复现元数据):对模板案例算
template_sha256并写进run_meta.json。判定:run_meta.json含software_version/template/template_sha256/unit_set_note/created_at五项;故意改动模板后哈希随之改变。 - 练习 4(长转宽):对长表运行代码 10-2。判定:宽表行数 = summary 段 case_id 数;
detailed记录未进入宽表;打印出单位对照字典。 - 练习 5(运行目录规范):按 1.6 节的目录结构,为一次运行建立
run_<时间戳>/目录,把cases.csv、ledger.csv、results_merged_long.csv、run_meta.json各就各位。判定:meta/、input/、state/、results/、delivery/五个子目录齐全;把整个目录改名(换时间戳)后,脚本仍能凭目录内文件独立运行,不依赖任何外部共享路径。
五、小结与下一篇预告
本篇立起落盘工程的三根柱子:三张契约表(矩阵 / 长表 / 账本)、两条幂等规则(长表去重覆盖、账本追加取最新)、四类可复现元数据(版本、单位、时间、模板哈希)。并给出纯 IO 组件ledger.py——它不触碰 HTRI,任何机器可跑,是后续平台化(第 20 篇)的存储层内核。
第 11 篇《板式换热器自动化:Xphe》:我们从管壳式走向板式。Xphe 与 Xist 的数据模型有本质差异——板型来自内部厂商库或用户自定义、含端口分布模型,且官方明确"Xist 可把适用案例数据传递给 Xphe/Xace"等模块。我们要讲清这些差异,并给出板式扫描的变量裁剪策略。
本篇认知问题回显(FAQ)
Q1:结果该存长表还是宽表?
A:存储用长表(case_id, section, parameter, value, unit, source),展示与分析入口按需转宽表。长表对"指标集变动"免疫——9.4 新增温度有效度等输出时,长表无需改结构,宽表则要加列;宽表便于人看与直接建模,但只适合承载 summary 段的标量,detailed 剖面若强塞进宽表会变稀疏错位。
Q2:CSV、Parquet、JSON 怎么选?
A:元数据与契约用 JSON(层级结构、适合run_meta.json与探测报告);结果存储小批量用 CSV(utf-8-sig,通用、Excel 可开、易 diff),大批量用 Parquet(有类型、压缩率高、读取快,但需 pyarrow 且 Excel 不能直接开);交付件从长表转宽表导出 CSV/Excel。经验法则是"元数据 JSON、存储 CSV/Parquet、交付宽表"。
Q3:案例矩阵的唯一键为什么用 case_id?
A:变量组合随扫描设计变化(今天扫 3 个变量、明天加第 4 个),用它当主键会让历史结果与未来结果无法对齐,且浮点组合做键有精度歧义。用零填充的case_id作为标识、另存一份cases.csv记录每个 case_id 的变量取值,把"标识"与"参数"解耦。
Q4:断点账本 schema 怎么设计,怎么幂等?
A:字段为case_id | status | detail | timestamp | software_version | results_json。规则是"追加 + 取最新":每次运行追加一行、永不改历史行;读取时对同一case_id取 timestamp 最大的一行。这样重跑同一案例只是再追加一行更新状态,天然幂等、无歧义;software_version字段让每次运行都自带版本证据。
Q5:什么叫结果可复现,需要哪些元数据?
A:可复现要能回答"用什么版本跑的、单位是什么、什么时候跑的、输入是什么"。对应四类元数据:software_version(如 9.4,方法更新会改结果,如 9.3 的 RPM 冷凝方法、9.4 的超临界传热方法)、每个数值的unit、timestamp、以及案例矩阵(变量取值)加模板标识(文件名 + SHA-256 内容哈希)。模板哈希让"模板是否被改"可验证。