AFT二次开发教程(09):参数扫描工程化——Python 生成 AFT Transfer 变更工作簿
版本与事实声明
- 产品与版本:AFT Fathom 15 / AFT Impulse 12(当前);本篇列契约与变更码规则引用官方Fathom 13帮助页
Importing From Excel正文,当前版以官方文档为准。- 语言/环境:Python 3.x + openpyxl(
itertools标准库)。- 本文目标:读完能把一个扫描设计(变量、水平、工况数)自动翻译成合法且可分批导入的
AFT Transfer变更工作簿。- 所有参数与数值为示例性建模,不代表任何标准规定。
一句话结论:AFT 参数扫描的工程化落点是把设计矩阵翻译成AFT Transfer工作表——每个"参数×水平"组合对应若干变更行,用Apply列的 Yes/No 做轮次门控(一份模板跑多轮)、用对象类型白名单校验参数与变更码(避免Invalid parameter ID/Invalid change type)、并在场景数过大时按块拆分成多个工作簿分批导入,这就是 AFT 世界里"参数扫描"的正确形态。
〇、本篇要解决的认知问题
- Q1:AFT 没有 API,参数扫描到底"扫"在哪里?扫描的产物是什么?
- Q2:为什么
Apply列是扫描工程化的"总闸"?"一份模板跑多轮"怎么实现? - Q3:全因子(full factorial)会爆炸,什么情况下该改用抽样?AFT 侧需要什么配合?
- Q4:一个 60 工况的扫描,要不要一次性生成一张大表?拆分的依据是什么?
- Q5:怎么把"扫描设计"做成可以断点续跑、可追溯的?
一、机制解析
9.1 扫描的"落点":变更矩阵 → AFT Transfer
先纠正一个直觉:在 AFT 里做参数扫描,扫描的产物不是脚本,而是一张表。因为:
- 通道 ①
Import Excel Change Data是唯一合法的"写输入"口(第 05 篇); - 通道 ③ Scenario Manager 负责"组织工况"(第 08 篇);
- 通道 ④ Start Batch Run 负责"执行"(第 10 篇)。
于是扫描的完整链路是:
设计矩阵(变量×水平×工况) → 变更矩阵(行) → AFT Transfer 工作簿 → 导入 → 批跑 → 取数 Python 生成 Python 生成 openpyxl 落盘 File 菜单 FBS Excel Export关键认识:扫描设计器 = "设计矩阵 → 变更矩阵"的编译器。这是本篇代码的主职。
9.2 Apply 门控:一份模板跑多轮
Apply列只接受Yes/No,只有 Yes 的变更会被导入(第 05 篇)。把它用在扫描上,能得到一个很实用的模式:
一次生成全部候选行,用
Apply决定"本轮生效哪些"。
好处:
- 省去反复生成文件:60 工况的候选行一次写好,第一轮把前 20 个设为 Yes,导入批跑;第二轮换 20 个……模板不动。
- 天然可审计:哪个工况"跑过/没跑"在表里一目了然(配合第 10 篇的账本)。
- 误操作代价低:改错一行只影响该行,不会污染别的工况。
注意:Apply=No的行不生效,因此不会报错(它根本没被处理)。如果一轮导入后日志"少了行",先检查是不是Apply写错了。
9.3 全因子 vs 抽样
| 设计 | 工况数 | 适用 | AFT 侧配合 |
|---|---|---|---|
| 全因子 | 各变量水平数连乘 | 变量少(2~3 个)、要完整响应面 | 场景数=工况数;命名要能编码变量水平 |
| 单变量(OAT) | 变量数 × (水平数-1) + 1 | 敏感性初筛、快速看方向 | 场景树一层一个变量 |
| 拉丁超立方等抽样 | 自定义样本数 | 变量多、算力有限 | 用 Python 生成样本 → 每样本一个场景 |
关键约束(AFT 特性):工况数 =场景数。所以"扫描空间裁剪"在 AFT 里等价于"场景树规模控制"。经验法则:先圈定真正的可变量(常有 2~3 个),再决定设计;动辄 6 个变量全因子(如 3^6=729 场景)会让场景树和批跑时长都失控。这条对应本系列"先圈定参数再扫描"的最佳实践。
9.4 大表的拆分依据
把几百上千行变更塞进一张导入表有风险:
- 一次导入过大:出错时定位困难;日志长;
- 场景数过大:场景树难管理、批跑时间长、中途失败重跑代价高;
- 单位集/打底:新字段多时,"打底"遗漏的风险随规模上升。
拆分依据(经验法则):按**“导入单元”**拆——一个导入单元 = 一个可独立批跑、可独立对账的分块。常见做法:每 20~50 个场景一块,每块一个.xlsx+ 一个账本条目。这样单块失败只重跑一块。
9.5 变更矩阵的最小契约
一行变更必须有:
Apply | Object Type | Object Number | Parameter | Change Code | Value | Scenario Path Name 门控 对象类型 对象编号 参数 变更码 值 全限定场景路径加上一条元数据(写在你自己的账本里,不进导入表):case_id(工况编号)、variable(变量名)、level(水平)。这样结果回来时能 join 回设计。
纪律:导入表只需七列(多写无害,因为 A 列与第 1 行本就不读);但账本必须独立维护 case_id ↔ 场景路径 ↔ 变量水平的映射,否则结果永远回不到设计上。
二、完整代码与逐行剖析
代码 9-1:sweep_designer.py(设计矩阵 → 变更矩阵 → 分批工作簿)
# -*- coding: utf-8 -*-""" sweep_designer.py —— AFT 参数扫描设计器 输入:变量定义(对象类型/编号/参数/变更码/水平) 输出:分批的 AFT Transfer 工作簿 + 账本 JSON 运行: python sweep_designer.py --selftest python sweep_designer.py --outdir out --block 20 """importargparseimportitertoolsimportjsonimportosimportsysfromopenpyxlimportWorkbook SHEET="AFT Transfer"COLS=["Apply","Object Type","Object Number","Parameter","Change Code","Value","Scenario Path Name"]# 变量定义:object_type 对应官方 Table 1;变更码必须是该参数类型的合法码VARIABLES=[{"name":"pump_speed","object_type":"Pump","object_number":3,"parameter":"Fixed Speed (%)","change_code":"Set equal to value","levels":[80.0,90.0,100.0]},{"name":"valve_open","object_type":"Valve","object_number":1,"parameter":"Open Percentage","change_code":"Set equal to value","levels":[40.0,70.0,100.0]},]BASE=r"Base Scenario\US Units"# 参数类型白名单(官方 Table 1 节选):用于拦截 Invalid parameter ID / typePARAM_OWNER={"Fixed Speed (%)":("Pump","S"),"Open Percentage":("Valve","S"),}LEGAL_CODES={"S":{"Set equal to value","Change by value (+/-)","Change by percent (+/-)"},"I":{"Set equal to value","Change by value (+/-)"},"C":{"Set equal to value","Add string to list","Delete string from list"},}defvalidate_variables(vars_):problems=[]forvinvars_:owner=PARAM_OWNER.get(v["parameter"])ifownerisNone:problems.append(f"[待确认] 参数未登记类型:{v['parameter']}")continueifowner[0]!=v["object_type"]:problems.append(f"Invalid parameter ID:{v['parameter']}不属于{v['object_type']}")ifv["change_code"]notinLEGAL_CODES[owner[1]]:problems.append(f"Invalid change type:{v['change_code']}不适用于{owner[1]}")ifnotv.get("levels"):problems.append(f"变量{v['name']}没有水平值")returnproblemsdefcase_id(combo:tuple)->str:"""工况 ID:把各水平编码进名字,便于回 join。"""return"C"+"_".join(str(c).replace(".","p")forcincombo)defbuild_matrix(vars_,base):"""全因子:返回 (rows, ledger)。rows 只含 Apply=Yes 的生效行。"""rows,ledger=[],[]forcomboinitertools.product(*[v["levels"]forvinvars_]):cid=case_id(combo)scn=f"{base}\\{cid}"forv,lvlinzip(vars_,combo):rows.append({"Apply":"Yes","Object Type":v["object_type"],"Object Number":v["object_number"],"Parameter":v["parameter"],"Change Code":v["change_code"],"Value":lvl,"Scenario Path Name":scn,})ledger.append({"case_id":cid,"scenario":scn,**{v["name"]:lvlforv,lvlinzip(vars_,combo)}})returnrows,ledgerdefwrite_blocks(rows,outdir,block=20):"""按场景分块:每块 <= block 个场景,落成独立工作簿;返回块清单。"""os.makedirs(outdir,exist_ok=True)by_scn={}forrinrows:by_scn.setdefault(r["Scenario Path Name"],[]).append(r)scenes=list(by_scn)blocks=[]foriinrange(0,len(scenes),block):chunk=scenes[i:i+block]wb=Workbook()ws=wb.active ws.title=SHEET ws.append(COLS)# 第 1 行表头(内容不被读取)forscninchunk:forrinby_scn[scn]:ws.append([r[c]forcinCOLS])path=os.path.join(outdir,f"transfer_block_{i//block+1:02d}.xlsx")wb.save(path)blocks.append({"file":path,"scenarios":len(chunk),"rows":sum(len(by_scn[s])forsinchunk)})returnblocksdefselftest():assertvalidate_variables(VARIABLES)==[],validate_variables(VARIABLES)rows,ledger=build_matrix(VARIABLES,BASE)# 全因子:3 × 3 = 9 工况,每工况 2 行 -> 18 行assertlen(ledger)==9andlen(rows)==18,(len(ledger),len(rows))assertall(r["Apply"]=="Yes"forrinrows)# 非法组合必须被拦:给 Valve 用 Pump 的参数bad=[{"name":"x","object_type":"Valve","object_number":1,"parameter":"Fixed Speed (%)","change_code":"Set equal to value","levels":[1.0]}]assertany("Invalid parameter ID"inpforpinvalidate_variables(bad))# 分块:9 场景按每块 4 个 -> 3 块blocks=write_blocks(rows,"selftest_out",block=4)assertlen(blocks)==3,blocksassertsum(b["scenarios"]forbinblocks)==9print("SELFTEST OK:9 工况 × 2 行 = 18 行;非法参数被拦;9 场景分成 3 块。")print(json.dumps(ledger[0],ensure_ascii=False))defmain():ap=argparse.ArgumentParser()ap.add_argument("--outdir",default="out")ap.add_argument("--block",type=int,default=20)ap.add_argument("--selftest",action="store_true")a=ap.parse_args()ifa.selftest:selftest()return0probs=validate_variables(VARIABLES)ifprobs:print("变量定义有问题:")forpinprobs:print(" -",p)return1rows,ledger=build_matrix(VARIABLES,BASE)blocks=write_blocks(rows,a.outdir,a.block)withopen(os.path.join(a.outdir,"ledger.json"),"w",encoding="utf-8")asf:json.dump({"ledger":ledger,"blocks":blocks},f,ensure_ascii=False,indent=2)print(f"{len(ledger)}个工况、{len(rows)}行变更,拆成{len(blocks)}块,写入{a.outdir}/")return0if__name__=="__main__":sys.exit(main())逐行剖析:
VARIABLES是扫描设计的唯一真相源。它把"扫什么"结构化:对象类型、编号、参数、变更码、水平。改扫描只改这里。PARAM_OWNER+LEGAL_CODES合起来实现导入前的两道静态闸:Invalid parameter ID(参数不属于该对象类型)与Invalid change type(变更码不属于该参数类型)。这两条错误在导入时才暴露,代价是白跑一轮,所以前置拦截是硬收益。case_id()把水平编码进工况名(C80p0_40p0这类):工况名自带设计信息,结果回来一眼能看出是哪组参数。小数点替换成p是为了避免出现在场景名里引发歧义。build_matrix()用itertools.product做全因子;返回的ledger是结果回 join 的钥匙——case_id / scenario / 各变量水平。write_blocks()按场景分块(不是按行),保证同一场景的所有行不跨块——否则导入时该场景参数不完整。这是分块逻辑的关键正确性点。selftest()断言9×2=18、非法参数被拦、9 场景按 4/块 → 3 块。三个数字都能手算复核。
代码 9-2:gate_apply.py(Apply 门控:切换轮次)
# -*- coding: utf-8 -*-""" gate_apply.py —— 用 Apply 列做轮次门控:只让指定 case 集合生效。 读取一个账本,把某工作簿中不在集合内的场景行设为 Apply=No。 运行:python gate_apply.py --selftest """importsysdefgate(rows:list,active_scenarios:set)->list:out=[]forrinrows:r=dict(r)r["Apply"]="Yes"ifr["Scenario Path Name"]inactive_scenarioselse"No"out.append(r)returnoutdefselftest():# 复用 9-1 的行结构(此处内联最小样例,保证脚本自洽)rows=[{"Scenario Path Name":f"B\\{i}","Apply":"Yes"}foriinrange(5)]active={"B\\1","B\\3"}g=gate(rows,active)assertsum(1forringifr["Apply"]=="Yes")==2assert{r["Scenario Path Name"]forringifr["Apply"]=="Yes"}==activeprint("SELFTEST OK:门控后仅目标 2 个场景生效。")if__name__=="__main__":if"--selftest"insys.argv:selftest()为什么门控要单独成函数:扫描常常是"发现结果不对 → 缩小到某几个工况重跑"的迭代过程。门控做成纯函数(输入行、输出行,不改外部状态),就能被反复调用、能被测试。把可变性关进函数里,是数据面工程的基本卫生。
三、常见报错与排查
报错 9-1:全因子设计出来工况数爆炸(如 3^6=729),批跑到天亮还没完。
现象:场景数失控。根因:把不该扫的变量也放进了设计。解法:先圈定参数再扫描(最佳实践)——用 OAT 初筛剔掉不敏感变量,只对 2~3 个可变量做全因子;必要时改用抽样。
报错 9-2:导入后日志里"少了行",但没报错。
现象:明明生成了 18 行,日志只处理了 12 行。根因:剩下 6 行Apply是No(门控没开)——No 的行不生效也不报错。解法:检查门控集合;导入前打印sum(Apply=='Yes')与预期比对。
报错 9-3:报Invalid parameter ID,但参数名看着没错。
现象:参数与对象类型不符。根因:设计中object_type与parameter搭配错了(如把 Pump 的参数配给了 Valve)。解法:validate_variables()前置拦;以官方 Table 1 为准。
报错 9-4:分块导入后,某场景参数只改了一半。
现象:某工况结果异常,像只应用了部分参数。根因:分块时按行切割导致同一场景的行被拆到两块,只导了一块。解法:按场景分块(write_blocks的做法),保证场景完整性。
报错 9-5:结果回来后对不上设计——不知道哪个数对应哪组参数。
现象:结果表里只有场景名,看不出参数组合。根因:没维护账本(ledger)。解法:生成扫描时同步写ledger.json(case_id ↔ 场景 ↔ 水平),结果取回后用 case_id join。
报错 9-6:扫描空间设计过大,场景树多到无法维护。
现象:几百个场景,改名/查找都痛苦。根因:全因子铺得太开,或场景命名无结构。解法:先圈定 2~3 个可变量(最佳实践);场景名用可编码的形式(如C80p0_40p0编码各水平,第 09 篇case_id),并借场景树分层(根=单位制,父=工况族)控制规模(第 08 篇)。
四、动手练习
- 练习 1(跑通设计器):跑
python sweep_designer.py --selftest。判定:输出SELFTEST OK;生成selftest_out/下 3 个工作簿,每个只有一张名为AFT Transfer的表。 - 练习 2(加一个变量):在
VARIABLES里加第三个变量(如 Reservoir 的Liquid Surface Elevation,3 个水平),并把它登记进PARAM_OWNER。判定:自检的断言数改成27 工况 × 3 行 = 81 行并全部通过;说明工况数的乘法来源。 - 练习 3(门控实战):用
gate_apply.py只让C80p0_40p0与C90p0_70p0两个工况生效,重新导入。判定:日志只出现这 2 个场景的变更;其余场景参数不变。 - 练习 4(对账闭环):批跑后用第 03 篇
read_export.py取回结果,用ledger.json把case_idjoin 进去。判定:结果表里每个 case 都能追溯到具体的参数水平组合;写出 join 键与你使用的乘法式。 - 练习 5(规模预算):为"3 个变量 × 各 4 个水平"估算全因子工况数,再与"只对 2 个变量做全因子、第 3 个用 OAT"的方案对比。判定:给出两个方案的工况数(4³=64 与 4²+3=19 之类),并说明为何后者常更划算。
五、小结与下一篇预告
本篇把参数扫描落到了 AFT 的正确形态:扫描的产物是AFT Transfer变更矩阵;Apply列是轮次总闸(一份模板跑多轮);全因子要先圈定参数,否则场景数爆炸;大表要按场景分块(不能按行切);结果要对回设计必须靠账本。你的sweep_designer.py现在能从变量定义一路生成"分批工作簿 + 账本"。
第 10 篇《Start Batch Run 工程化》:我们讲执行侧——Batch Run Type两类、Add Model Files/Save List to File与Batch File文本清单、Run Batch in Background、Export Only (Do Not Run)、Cancel后已跑输出保留,以及本系列铁律 8 的落地——超时 + try/finally 释放 + 残留进程清理的批跑看门狗。
FAQ(与第〇节一一对应)
Q1:AFT 没有 API,参数扫描扫在哪里、产物是什么?
A:扫描的产物不是脚本而是数据表——设计矩阵(变量×水平×工况)经 Python 翻译成 AFT Transfer 变更矩阵,落成工作簿后由 File > Import Excel Change Data 导入,再经 Scenario Manager 组织场景、Start Batch Run 批跑,最后用 Excel Export Manager 取数。
Q2:Apply 列为什么是扫描工程化的总闸?
A:因为 Apply 只接受 Yes/No 且只有 Yes 的变更会被导入,所以可一次生成全部候选行、用 Apply 决定本轮生效哪些,实现一份模板跑多轮、天然可审计、误操作只影响单行;注意 Apply=No 的行不生效也不报错,故导入前应统计 Yes 行数。
Q3:全因子什么时候该改用抽样?
A:当变量较多(如超过 3 个)导致工况数连乘爆炸时应改用拉丁超立方等抽样;因为 AFT 里工况数等于场景数,场景过多会让场景树管理、批跑时长与失败重跑代价都失控,所以经验做法是先圈定 2~3 个可变量再做全因子,其余用 OAT 初筛剔除。
Q4:一个 60 工况的扫描要不要一次生成一张大表?
A:建议按场景分块(经验值每 20~50 个场景一块),每块一个工作簿加一条账本条目;分块必须按场景而非按行切割,否则同一场景的变更行会被拆到两块导致参数只改一半。
Q5:怎么让扫描可断点续跑、可追溯?
A:扫描时同步生成账本 JSON(记录 case_id、场景路径与各变量水平),用它把结果 join 回设计;配合 Apply 门控只重跑目标工况,并用第 11 篇的长表落库与幂等 upsert 保证重跑不产生重复数据。