COMSOL多物理场二次开发教程(05):模型树即对象树——tag 与 type 的语法规范与十分支测绘
版本与事实声明
- 版本锚点:COMSOL Multiphysics® 6.3。本文所用例子全部基于官方 Application Programming Guide 的示例节点名(
comp1/geom1/mesh1/ht/std1/sol1/pg1等),这些是官方示例沿用的惯例名,不是强制名称——tag 由你自己定。- 文中属性自省方法(如
getAllowedPropertyValues)在不同节点上的可用性以官方文档为准;本文同时给出"不依赖自省也能工作"的替代写法。- 示例数值仅用于教学,不代表任何标准规定。
一句话结论:COMSOL API 的每个节点由"分支(branch)+ 类型(type)+ 标签名(tag)+ 显示名(label)"四要素构成,其中tag 是唯一程序句柄(model.component("comp1").geom("geom1").feature("cyl1")),type 决定节点能力与可用属性(如"Cylinder"、"HeatTransfer"),label 只影响显示;模型树的十个分支——component / geom / mesh / physics / material / func / study / sol / result / batch(外加方法method)——都可以用tags()枚举,因此可以写出一份不带任何硬编码节点名的通用测绘脚本。
〇、本篇要解决的认知问题
- Q1:tag、type、label 三者到底怎么区分?为什么改名 tag 会让脚本崩掉?
- Q2:模型树有哪些分支?哪些是"全局"的,哪些必须住在 Component 里?
- Q3:
tags()这类"自省 API"能做什么?为什么它比背节点名更重要? - Q4:几何实体编号为什么会变?改几何后必须做哪三步核对?
- Q5:
createAutoSequences生成的求解器序列,和我在界面上看到的求解器节点是什么关系?
一、机制解析
1.1 价值锚点:把"节点名"从脚本里赶出去
回想你见过的 COMSOL 脚本,几乎每行都有"comp1"、"geom1"、"ht"。这些名字是脚本与模型之间的耦合点。一旦同事把组件改名成"core",整份脚本立刻报废。
所以本篇要建立的第一条工程直觉是:能用tags()拿到的,就不要写死。
| 写法 | 鲁棒性 | 适用场景 |
|---|---|---|
硬编码 tag:model.component("comp1") | 低(改名即崩) | 自己完全掌控的模型;教学示例 |
枚举 tag:model.component(model.component().tags()[0]) | 中(顺序敏感) | 单组件模型 |
按类型筛选:遍历tags()后用类型判断 | 高 | 通用工具、跨团队复用 |
第三行才是生产写法。而它成立的前提是你要能读出节点的类型——这正是"type"这个要素的价值。
1.2 四要素:branch / type / tag / label
| 要素 | 例子 | 谁在用 | 能否改 | 改了会怎样 |
|---|---|---|---|---|
| 分支 branch | component、geom、physics、study | API 路径 | 不能 | — |
| 类型 type | "Cylinder"、"HeatTransfer"、"Stationary"、"PlotGroup3D" | 创建时指定 | 不能(改类型=重建节点) | 属性集完全变化 |
| 标签名 tag | "comp1"、"geom1"、"ht"、"std1" | 程序引用 | 建议不改 | 引用全部失效(铁律 4) |
| 显示名 label | "Steel AISI 4340"、"Temperature (ht)" | 人 | 随时可改 | 不影响脚本 |
官方示例里model.result("pg1").label("Temperature (ht)")就是"tag 与 label 分离"的教科书示范:tag 简短稳定、label 信息充分。请把这条当作团队规范。
铁律 4 的实际含义:COMSOL 的 API 里没有"稳定引用 ID"这层抽象——路径上的每个 tag 都是句柄。你如果在已有模型的某个节点上"改名",等于把它从旧路径上摘下来,所有写旧路径的脚本都会报"找不到节点"。所以:tag 一旦被任何脚本引用,就冻结。
1.3 十个分支与它们的"户口"
| 分支(API 路径) | 归属 | 说明 | 典型 type |
|---|---|---|---|
model.component() | 模型级 | 组件;一切几何/物理场/材料的容器 | 组件(由create(tag, true)创建) |
comp.geom() | 组件内 | 几何序列;创建时必须给维度 | "Interval"、"Rectangle"、"Cylinder" |
comp.mesh() | 组件内 | 网格;默认网格在求解时自动划分 | 网格序列节点 |
comp.physics() | 组件内 | 物理场;创建时必须给几何 tag | "HeatTransfer"等(以官方文档为准) |
comp.material() | 组件内 | 材料;"Common"是通用材料类型 | "Common" |
comp.multiphysics()(多物理场耦合) | 组件内 | 耦合节点;依赖被耦合物理场已存在 | 以官方文档为准 |
model.func() | 模型级 | 用户定义函数,可在表达式中按 tag 调用 | "Random"等 |
model.param() | 模型级 | 全局参数(第 06 篇详解) | 参数表 |
model.study() | 模型级 | 研究;声明求解哪些物理场 | "Stationary"、"Time"、"Frequency"、"Eigenvalue" |
model.sol() | 模型级 | 求解器序列 | "Stationary"等 |
model.result() | 模型级 | 结果:图组、导出、数值 | "PlotGroup1D"、"PlotGroup3D"、"Data"、"Table" |
model.batch() | 模型级 | 作业配置:参数化/批处理/集群 | "Parametric"、"Batch" |
model.method() | 模型级 | 模型方法(App 开发器) | 方法节点 |
看懂这张表就能排掉一半的报错:报错说某分支不存在,先确认你走的是"模型级分支"还是"组件内分支"——model.physics()一定错,正确是model.component("comp1").physics()。
1.4 自省:tags()与属性枚举
三类自省能力(具体方法名以官方文档为准):
- 枚举子节点:
model.component().tags()、model.study().tags()、...physics("ht").feature().tags()——返回字符串数组,用于遍历。 - 枚举属性:特征上的属性列表方法族(官方 API 参考中,导出特征就有
model.result().export(etag).getAllowedPropertyValues(property)这样的用法),用于在不看界面的时候确认属性名与允许值。 - 读回当前值:
get系列(同名属性的读取)。
不依赖自省的兜底做法:如果你不确定某属性名,就用Record Method在界面上设一次,录制结果里的字符串就是权威名字(铁律 1的合法源)。
1.5 实体编号:唯一的"隐式耦合"
selection().set(1)这类调用是模型里唯一无法用 tag 稳定下来的东西——几何实体(域/边界/边/点)没有 tag,只有编号,而编号由几何序列的拓扑决定。
改几何之后必须做的三步核对(最佳实践):
- 重跑几何(
geom("geom1").run()),让编号落定; - 打印实体统计(各类实体的数量与你打算选中的具体编号对应的空间位置);
- 在一个已知解析解的退化算例上验证一次(如 1D 稳态导热,检查温度是否落在预期区间)——这是唯一能发现"选错编号却算得出结果"的手段。
1.6 求解器序列从哪里来
model.study("std1").createAutoSequences("all")的作用是按研究步骤生成配套的求解器序列。所以在 Java Shell 里跑完这行之后,model.sol()下会多出节点(官方示例里是sol1)。
工程含义有两点:
- 不要手工猜求解器序列的名字。要么用
createAutoSequences让它生成后枚举model.sol().tags(),要么完全手工创建(第 13、14 篇的模式)。 - 序列是"可重生成"的:改了研究步骤后,重新调用
createAutoSequences会重写序列。这也意味着任何你对序列的手工定制都可能被覆盖——这是参数化扫描与优化调试期一个很隐蔽的坑。
1.7 测绘一次,长期受益
把本篇的测绘做成习惯,收益是可累积的:
| 场景 | 没有测绘 | 有测绘 |
|---|---|---|
| 接手同事的模型 | 逐个节点点开看,半小时起步 | 一份 JSON 结构清单,2 分钟看清 |
| 写通用脚本 | 靠猜 tag,改名即崩 | 按 type 筛选,改名不崩 |
| 版本比对 | 肉眼比界面 | diff 两份 JSON,节点增删一目了然 |
| 交接文档 | “大概是这样” | 结构清单 + 参数清单即为交付物 |
建议:把测绘脚本与它的输出(model_structure.json)一起纳入版本管理;每次模型发生结构性变更(加物理场、改几何维度、换材料)时重跑一次并提交 diff。这样"模型结构"这件事就和代码一样有了可追溯的历史——这是把仿真资产纳入工程管理的第一步。
二、完整代码与逐行剖析
代码 2-1:模型结构测绘脚本(Java,方法编辑器/Java Shell 内运行)
// ===== 把当前模型的"结构"(分支/类型/标签/显示名)序列化为 JSON =====// 用途:文档化交付、变更审计、生成"节点名清单"供其他脚本使用StringBuilderjson=newStringBuilder();json.append("{\n");// [1] 组件级:枚举所有组件 tag,再逐个深挖String[]comps=model.component().tags();json.append(" \"components\": [\n");for(inti=0;i<comps.length;i++){Stringc=comps[i];json.append(" {\"tag\":\"").append(c).append("\",");json.append("\"geom\":[");// [2] 几何:先几何序列,再几何特征(type 才是关键信息)String[]gs=model.component(c).geom().tags();for(intj=0;j<gs.length;j++){Stringg=gs[j];intdim=model.component(c).geom(g).getSDim();// [3] 几何维度(以官方文档为准)json.append("{\"tag\":\"").append(g).append("\",\"dim\":").append(dim).append(",\"features\":[");String[]gfs=model.component(c).geom(g).feature().tags();for(intk=0;k<gfs.length;k++){Stringf=gfs[k];Stringt=model.component(c).geom(g).feature(f).getType();// [4] 节点类型字符串json.append("{\"tag\":\"").append(f).append("\",\"type\":\"").append(t).append("\"}");if(k<gfs.length-1)json.append(",");}json.append("]}");if(j<gs.length-1)json.append(",");}json.append("],\"physics\":[");// [5] 物理场:枚举接口与其特征String[]ps=model.component(c).physics().tags();for(intj=0;j<ps.length;j++){Stringp=ps[j];Stringptype=model.component(c).physics(p).getType();json.append("{\"tag\":\"").append(p).append("\",\"type\":\"").append(ptype).append("\",\"features\":[");String[]pfs=model.component(c).physics(p).feature().tags();for(intk=0;k<pfs.length;k++){json.append("\"").append(pfs[k]).append("\"");if(k<pfs.length-1)json.append(",");}json.append("]}");if(j<ps.length-1)json.append(",");}json.append("],\"materials\":[");String[]ms=model.component(c).material().tags();for(intj=0;j<ms.length;j++){json.append("\"").append(ms[j]).append("\"");if(j<ms.length-1)json.append(",");}json.append("]}");if(i<comps.length-1)json.append(",");}json.append("\n ],\n");// [6] 模型级分支:研究 / 求解器 / 结果 / 作业配置json.append(" \"studies\": [").append(String.join(",",quote(model.study().tags()))).append("],\n");json.append(" \"solvers\": [").append(String.join(",",quote(model.sol().tags()))).append("],\n");json.append(" \"results\": [").append(String.join(",",quote(model.result().tags()))).append("],\n");json.append(" \"batches\": [").append(String.join(",",quote(model.batch().tags()))).append("]\n");json.append("}\n");// [7] 写文件(用 COMSOL 的文件工具;具体类以官方文档为准)// 也可改用内置的 writeFile 类工具;此处演示"先存字符串、后落盘"的思路Stringout="D:\\work\\model_structure.json";// writeFile(out, json.toString()); // 具体内置方法名以官方文档为准debugLog(json.toString());// [8] 同时打到消息区,便于 Java Shell 里直接看// 辅助方法:给字符串数组加引号(Java 8+ 可用 stream;此处用显式循环保证兼容)privatestaticString[]quote(String[]a){String[]r=newString[a.length];for(inti=0;i<a.length;i++)r[i]="\""+a[i]+"\"";returnr;}逐行剖析
- [1]
model.component().tags():一切通用脚本的起点。返回字符串数组,顺序即模型树顺序。 - [2] 几何特征要分两层枚举:
geom().tags()是几何序列(可能有多个),feature().tags()是该序列内的特征。层级搞混会得到空数组——这是新手第二个高频"空数组"陷阱。 - [3]
getSDim()取几何维度(以官方文档为准,不同版本命名可能不同)。用程序读维度而不是靠约定,能提前拦住"物理场挂到错误维度几何"的问题。 - [4]
getType()取节点类型字符串。这是本脚本最有价值的输出:它把"我在界面上看到的"翻译成"API 认可的类型名",从而成为后续脚本的字典(也是铁律 1的合规做法)。 - [5] 物理场同理:先接口,再接口下的特征。特征 tag(如
temp1、hs1)就是后面改边界条件时要引用的句柄。 - [6] 模型级分支用
String.join一次性拼装。model.sol().tags()在createAutoSequences之前可能是空的——这正好可以用来验证研究是否已成功生成序列。 - [7][8] 落盘方式:不同版本可用的内置文件工具方法名不同,以官方文档为准;教学阶段先用
debugLog把 JSON 打到消息区,配合 Java Shell 的复制功能即可保存。要真落盘,建议在方法编辑器里用 Java 标准java.nio.file.Files.writeString(6.3 支持 Java 11,以官方文档为准)。
代码 2-2:不带硬编码节点名的"通用取用器"(Java 片段)
// 目标:给一个"物理场类型字符串",自动找到组件里第一个该类型的接口 tag// 这样当同事把 ht 改名成 heat1 时,你的脚本不会崩privatestaticStringfindPhysicsByType(Modelmodel,StringtypeName){String[]comps=model.component().tags();for(Stringc:comps){String[]ps=model.component(c).physics().tags();for(Stringp:ps){Stringt=model.component(c).physics(p).getType();if(t.equals(typeName)){returnc+"/"+p;// [1] 返回"组件/接口"复合路径}}}returnnull;// [2] 未找到时显式返回 null}// 用法示意Stringpath=findPhysicsByType(model,"HeatTransfer");// [3] 类型字符串来自录制/文档if(path==null){thrownewRuntimeException("未找到 HeatTransfer 接口:请检查模型或类型名(以官方文档为准)");}String[]parts=path.split("/");// 之后可用 model.component(parts[0]).physics(parts[1]) 访问该接口debugLog("found physics at: "+path);逐行剖析
- [1] 用
"组件/接口"复合路径作为返回值:因为物理场 tag 只在组件内唯一,跨组件可能重名。不要只返回接口 tag——这是多组件模型里最容易埋的雷。 - [2] 显式返回
null并让调用方处理:比"抛异常或静默返回空串"更可控。脚本的失败应该是显式的(这个原则在第 18~20 篇的工程化里反复出现)。 - [3]
"HeatTransfer"这个字符串仍然是个"魔法值",但它现在是集中在一处的魔法值,而不是散落全程。集中化 + 录制核对(铁律 1)= 可维护。
三、常见报错与排查
报错 3-1:model.physics()报方法不存在。
现象:编译/运行期报找不到方法。根因:physics是组件内分支,模型级只有component/param/func/study/sol/result/batch/method等。解法:写成model.component("comp1").physics()。同类错误还有把mesh()写到模型级。
报错 3-2:feature().tags()返回空数组。
现象:遍历不到任何特征。根因:层级错了(在"几何序列"层调了feature(),或在"物理场接口"层调了geom());或该节点确实还没有特征。解法:先打印上一层tags()确认层级;feature().tags()只在"特征列表"节点上有效。
报错 3-3:改了几何尺寸后,边界条件"看起来没生效"。
现象:模型能算,但物理上不合理(绝热却像有热流、固定温度却没约束住)。根因:实体编号在几何拓扑变化后重排,set(1)指到了别的实体。解法:按 1.5 节三步核对(重跑几何→打印实体统计→退化算例验证)。这是**唯一一类"不报错但结果错"**的错误,必须靠流程而非靠异常来防。
报错 3-4:手工改过求解器序列后,重跑研究序列被覆盖。
现象:定制消失。根因:createAutoSequences会重写序列。解法:要么改用手工创建序列的模式(第 13/14 篇),要么把定制放在不会被重生成的位置;调参期务必避免"混用两种模式"。
报错 3-5:把 tag 改名后所有脚本报"找不到节点"。
现象:脚本全面失效。根因:铁律 4——路径上的 tag 就是句柄,没有稳定 ID 层。解法:不要改已被引用的 tag;确实要重构时,用代码 2-2 的"按类型查找"写法过渡,并同步更新所有引用。
四、动手练习
- 练习 1(测绘自己的模型):对第 03 篇建好的 1D 传热模型运行代码 2-1。判定:输出 JSON 能解析(用 Python
json.loads验证),且components[0].geom[0].dim == 1、studies含std1、solvers非空。 - 练习 2(去硬编码):用代码 2-2 取到传热接口路径后,把边界温度值改为 300 K(含单位)。判定:脚本在把组件改名为
comp2后仍能运行(这是"去硬编码"是否成功的最硬判据)。 - 练习 3(实体编号核对):在 1D 模型上把区间长度从 0.1 改为 0.15,重跑几何后打印端点编号与坐标。判定:写出"编号 1 ↔ 坐标 x"的对照表,且与你
set(...)选中的编号一致;随后把冷端温度从 273 K 改到 373 K,确认温度分布整体抬升(物理合理性检查)。 - 练习 4(自省能力,思考题):说明为什么"背节点类型字符串"是不可持续的做法。验证要点(至少 3 点):① 类型字符串随物理模块/版本变化,且模块未安装时不存在;② 合法来源只有官方文档、录制生成代码、Java Shell 实跑通过(铁律 1);③
tags()/getType()让脚本可自省,从而摆脱对具体节点名的依赖。
五、小结与下一篇预告
本篇把"模型树"从比喻变成了可操作的数据结构:四要素(branch/type/tag/label)里 tag 是唯一句柄且不可改名(铁律 4);十个分支分居模型级与组件级,走错层级是高频报错源;几何实体编号是唯一的隐式耦合,必须靠"重跑几何→打印统计→退化算例验证"三步流程防错;求解器序列可由createAutoSequences生成、也可手工创建,但两者不可混用。
第 06 篇《参数与表达式》将补齐"数据的入口":model.param("default").set("name","value")的参数表设计、带单位表达式的书写规范、setIndex与数组属性的"清空再赋值"惯例,以及用model.disableUpdates(...)在try/finally里做批量改参提速的完整范式(铁律 10)——第 13 篇的参数化扫描正是建立在它之上。
本篇认知问题回显(FAQ)
Q1:tag、type、label 三者怎么区分?
A:tag 是标签名,是 API 引用的唯一句柄(如"comp1"、"ht");type 是节点类型,创建时指定、决定节点能力与可用属性(如"Cylinder"、"HeatTransfer"、"Stationary");label 是显示名,只影响界面上显示(如"Steel AISI 4340")。改 label 不影响脚本,改 tag 会让所有引用该路径的脚本报"找不到节点"。
Q2:模型树有哪些分支?哪些必须住在 Component 里?
A:组件内分支有geom、mesh、physics、material以及多物理场耦合节点(multiphysics,以官方文档为准);模型级分支有component、func、param、study、sol、result、batch、method。写model.physics()一定报错,正确写法是model.component("comp1").physics()。
Q3:tags()这类自省方法能做什么?
A:它返回子节点 tag 的字符串数组,让你遍历模型树而不必写死节点名;配合getType()读取节点类型、以及特征上的属性枚举方法(如导出特征的getAllowedPropertyValues,以官方文档为准),就能写出"换个人改名也不崩"的通用脚本,这也是去硬编码的根本手段。
Q4:几何实体编号为什么会变?改几何后要做哪三步核对?
A:因为几何实体(域/边界/边/点)没有 tag、只有编号,编号由几何序列生成的拓扑决定,几何变化即可能重排。三步核对是:① 重跑几何让编号落定;② 打印各维度实体数量与目标编号的空间位置;③ 在一个有解析解或已知趋势的退化算例上验证结果合理性——因为选错编号不会报错,只会在物理上出错。
Q5:createAutoSequences生成的求解器序列和界面上的求解器节点是什么关系?
A:它按研究步骤自动生成配套的求解器序列(官方示例生成sol1),可用model.sol().tags()枚举。这些序列是可重生成的——重新调用会重写序列,因此任何对序列的手工定制都可能被覆盖,这正是调试期不宜混用"自动生成"与"手工创建"两种模式的原因。