tpmslab 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tpmslab/__init__.py +30 -0
- tpmslab/__main__.py +3 -0
- tpmslab/cli.py +63 -0
- tpmslab/comsol.py +250 -0
- tpmslab/comsol_audit.py +128 -0
- tpmslab/comsol_dual.py +165 -0
- tpmslab/families.json +119 -0
- tpmslab/io.py +72 -0
- tpmslab/model.py +258 -0
- tpmslab/multidomain.py +196 -0
- tpmslab/py.typed +0 -0
- tpmslab/quality.py +46 -0
- tpmslab/static/app.js +30 -0
- tpmslab/static/index.html +16 -0
- tpmslab/static/style.css +3 -0
- tpmslab/static/vendor/OrbitControls.js +1523 -0
- tpmslab/static/vendor/THREE-LICENSE.txt +21 -0
- tpmslab/static/vendor/three.module.js +54155 -0
- tpmslab/verification.py +57 -0
- tpmslab/volume.py +310 -0
- tpmslab/web.py +302 -0
- tpmslab-0.3.0.dist-info/METADATA +220 -0
- tpmslab-0.3.0.dist-info/RECORD +28 -0
- tpmslab-0.3.0.dist-info/WHEEL +5 -0
- tpmslab-0.3.0.dist-info/entry_points.txt +2 -0
- tpmslab-0.3.0.dist-info/licenses/LICENSE +21 -0
- tpmslab-0.3.0.dist-info/licenses/licenses/LattGen-MIT.txt +21 -0
- tpmslab-0.3.0.dist-info/top_level.txt +1 -0
tpmslab/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Public API for TPMS Lab. Mesh coordinates use millimetres."""
|
|
2
|
+
|
|
3
|
+
from .model import Config
|
|
4
|
+
from .volume import generate_volume, write_nastran
|
|
5
|
+
from .io import save_model
|
|
6
|
+
|
|
7
|
+
__version__ = "0.3.0"
|
|
8
|
+
__all__ = ["Config", "generate", "generate_volume", "write_nastran", "save_model", "list_families"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate(config=None, *, progress=None):
|
|
12
|
+
"""Return a mesh dictionary with points, tetrahedra, boundaries and report.
|
|
13
|
+
|
|
14
|
+
``config`` may be Config, a configuration dictionary, or None for defaults.
|
|
15
|
+
Array connectivity is zero-based. Coordinates are millimetres.
|
|
16
|
+
"""
|
|
17
|
+
if config is None:
|
|
18
|
+
config = Config()
|
|
19
|
+
if isinstance(config, dict):
|
|
20
|
+
config = Config.from_dict(config)
|
|
21
|
+
if not isinstance(config, Config):
|
|
22
|
+
raise TypeError("config must be Config or dict")
|
|
23
|
+
return generate_volume(config, progress=progress or (lambda message: None))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_families():
|
|
27
|
+
"""Return a copy of the built-in formula metadata, including aliases."""
|
|
28
|
+
from .model import FAMILIES
|
|
29
|
+
|
|
30
|
+
return {name: dict(data) for name, data in FAMILIES.items()}
|
tpmslab/__main__.py
ADDED
tpmslab/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Command-line entry points; core commands do not import Flask or COMSOL."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from . import Config, generate, list_families, save_model, __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv=None):
|
|
11
|
+
parser = argparse.ArgumentParser(prog="tpmslab", description="Direct TPMS solid volume meshes")
|
|
12
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
13
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
sub.add_parser("families", help="List built-in formulas and aliases")
|
|
15
|
+
init = sub.add_parser("init-config", help="Write a default JSON configuration")
|
|
16
|
+
init.add_argument("path", type=Path)
|
|
17
|
+
make = sub.add_parser("generate", help="Generate and validate a volume mesh")
|
|
18
|
+
make.add_argument("--config", type=Path)
|
|
19
|
+
make.add_argument("--out", type=Path, required=True)
|
|
20
|
+
make.add_argument("--mph", action="store_true")
|
|
21
|
+
make.add_argument("--solve", action="store_true")
|
|
22
|
+
web = sub.add_parser("web", help="Start the optional local browser interface")
|
|
23
|
+
web.add_argument("--output", type=Path, default=Path.cwd() / "tpmslab-output")
|
|
24
|
+
web.add_argument("--port", type=int, default=0)
|
|
25
|
+
web.add_argument("--no-browser", action="store_true")
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
try:
|
|
28
|
+
if args.command == "families":
|
|
29
|
+
print(json.dumps(list_families(), indent=2))
|
|
30
|
+
return 0
|
|
31
|
+
if args.command == "init-config":
|
|
32
|
+
with args.path.open("x", encoding="utf8") as stream:
|
|
33
|
+
json.dump(Config().to_dict(), stream, indent=2)
|
|
34
|
+
return 0
|
|
35
|
+
if args.command == "web":
|
|
36
|
+
os.environ["TPMSLAB_OUTPUT_DIR"] = str(args.output.resolve())
|
|
37
|
+
try:
|
|
38
|
+
from .web import serve
|
|
39
|
+
except ImportError as exc:
|
|
40
|
+
raise RuntimeError('Install the web extra: pip install "tpmslab[web]"') from exc
|
|
41
|
+
serve(args.port, args.no_browser)
|
|
42
|
+
return 0
|
|
43
|
+
config = (
|
|
44
|
+
Config.from_dict(json.loads(args.config.read_text(encoding="utf-8-sig")))
|
|
45
|
+
if args.config
|
|
46
|
+
else Config()
|
|
47
|
+
)
|
|
48
|
+
if args.out.exists():
|
|
49
|
+
raise FileExistsError(f"Output already exists: {args.out}")
|
|
50
|
+
model = generate(config, progress=print)
|
|
51
|
+
save_model(model, args.out)
|
|
52
|
+
if args.mph or args.solve:
|
|
53
|
+
from .comsol import build_mph
|
|
54
|
+
|
|
55
|
+
build_mph(args.out, model["report"], solve=args.solve, progress=print)
|
|
56
|
+
print(str(args.out.resolve()))
|
|
57
|
+
return 0
|
|
58
|
+
except (ValueError, TypeError, OSError, RuntimeError) as exc:
|
|
59
|
+
parser.exit(2, f"tpmslab: {exc}\n")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
tpmslab/comsol.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""COMSOL 6.3 Java bridge. Generated MPH embeds the volume mesh.
|
|
2
|
+
No edits to user's existing MPH files; each export gets a new job directory.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import subprocess
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
TEMPLATE = r"""import com.comsol.model.*;
|
|
13
|
+
import com.comsol.model.util.*;
|
|
14
|
+
import java.util.*;
|
|
15
|
+
import java.nio.file.*;
|
|
16
|
+
import java.nio.charset.StandardCharsets;
|
|
17
|
+
public class TPMSBuild {
|
|
18
|
+
static String ROOT=@@ROOT@@;
|
|
19
|
+
static boolean SOLVE=@@SOLVE@@;
|
|
20
|
+
static void face(Model m,String tag,double value) {
|
|
21
|
+
m.component("c").selection().create(tag,"Box");
|
|
22
|
+
m.component("c").selection(tag).geom("g",2);
|
|
23
|
+
m.component("c").selection(tag).set("condition","inside");
|
|
24
|
+
m.component("c").selection(tag).set("xmin",-1.0);
|
|
25
|
+
m.component("c").selection(tag).set("xmax",@@LX@@+1.0);
|
|
26
|
+
m.component("c").selection(tag).set("ymin",-1.0);
|
|
27
|
+
m.component("c").selection(tag).set("ymax",@@LY@@+1.0);
|
|
28
|
+
m.component("c").selection(tag).set("zmin",value-@@TOL@@);
|
|
29
|
+
m.component("c").selection(tag).set("zmax",value+@@TOL@@);
|
|
30
|
+
if(m.component("c").selection(tag).entities(2).length==0) throw new RuntimeException("Empty boundary "+tag);
|
|
31
|
+
}
|
|
32
|
+
public static void main(String[] args)throws Exception {
|
|
33
|
+
Model m=ModelUtil.create("Model");
|
|
34
|
+
m.label("TPMS Lab - direct solid volume mesh");m.modelPath(ROOT);
|
|
35
|
+
m.param().set("E0","@@E@@[Pa]");m.param().set("nu0","@@NU@@");
|
|
36
|
+
m.param().set("rho0","@@RHO@@[kg/m^3]");m.param().set("uamp","@@DISP@@[mm]");
|
|
37
|
+
m.component().create("c",true);m.component("c").geom().create("g",3);
|
|
38
|
+
m.component("c").geom("g").lengthUnit("mm");
|
|
39
|
+
m.component("c").mesh().create("mesh","g");m.component("c").mesh("mesh").geometricModel("");
|
|
40
|
+
m.component("c").mesh("mesh").create("imp","Import");
|
|
41
|
+
m.component("c").mesh("mesh").feature("imp").set("source","nastran");
|
|
42
|
+
m.component("c").mesh("mesh").feature("imp").set("filename",ROOT+"mesh.nas");
|
|
43
|
+
m.component("c").mesh("mesh").feature("imp").set("data","mesh");
|
|
44
|
+
m.component("c").mesh("mesh").feature("imp").set("allowshellpartition",false);
|
|
45
|
+
m.component("c").mesh("mesh").feature("imp").set("facepartition","minimal");
|
|
46
|
+
m.component("c").mesh("mesh").run();m.component("c").geometricModel("mesh");
|
|
47
|
+
m.component("c").sorder("linear");
|
|
48
|
+
m.component("c").physics().create("solid","SolidMechanics","g");
|
|
49
|
+
m.component("c").physics("solid").prop("ShapeProperty").set("order_displacement",1);
|
|
50
|
+
for(String prop:new String[]{"E","nu","rho"}) m.component("c").physics("solid").feature("lemm1").set(prop+"_mat","userdef");
|
|
51
|
+
m.component("c").physics("solid").feature("lemm1").set("E","E0");
|
|
52
|
+
m.component("c").physics("solid").feature("lemm1").set("nu","nu0");
|
|
53
|
+
m.component("c").physics("solid").feature("lemm1").set("rho","rho0");
|
|
54
|
+
@@BOUNDARIES@@
|
|
55
|
+
m.component("c").mesh("mesh").feature("imp").set("filename","mesh.nas");
|
|
56
|
+
m.save(ROOT+"model_unsolved.mph");
|
|
57
|
+
double volume=0,energy=0,maxdisp=0;
|
|
58
|
+
if(SOLVE) {
|
|
59
|
+
System.out.println("TPMS_SOLVE_START"); m.study("std").run();
|
|
60
|
+
m.result().numerical().create("integrals","IntVolume");m.result().numerical("integrals").selection().all();
|
|
61
|
+
m.result().numerical("integrals").set("expr",new String[]{"1","solid.Ws"});
|
|
62
|
+
m.result().numerical("integrals").set("unit",new String[]{"m^3","J"});
|
|
63
|
+
double[][] v=m.result().numerical("integrals").getReal();volume=v[0][0];energy=v[1][0];
|
|
64
|
+
m.result().numerical().create("umax","MaxVolume");m.result().numerical("umax").selection().all();
|
|
65
|
+
m.result().numerical("umax").set("expr","solid.disp");m.result().numerical("umax").set("unit",new String[]{"m"});maxdisp=m.result().numerical("umax").getReal()[0][0];
|
|
66
|
+
if(maxdisp<0.99*m.param().evaluate("uamp") || maxdisp>100*m.param().evaluate("uamp")) throw new RuntimeException("Displacement unit/magnitude check failed");
|
|
67
|
+
if(!Double.isFinite(volume)||!Double.isFinite(energy)||!Double.isFinite(maxdisp)||energy<=0||maxdisp<=0) throw new RuntimeException("Invalid result");
|
|
68
|
+
if(Math.abs(volume-@@VOL@@)/@@VOL@@>1e-6) throw new RuntimeException("Imported volume mismatch");
|
|
69
|
+
m.result().create("pg1","PlotGroup3D");m.result("pg1").label("Displacement - static demonstration");
|
|
70
|
+
m.result("pg1").create("surf1","Surface");m.result("pg1").feature("surf1").set("expr","solid.disp");
|
|
71
|
+
m.save(ROOT+"model_solved.mph");
|
|
72
|
+
}
|
|
73
|
+
String saved=ROOT+(SOLVE?"model_solved.mph":"model_unsolved.mph");
|
|
74
|
+
ModelUtil.remove("Model");m=ModelUtil.load("Check",saved);
|
|
75
|
+
int tetra=m.component("c").mesh("mesh").getNumElem("tet");
|
|
76
|
+
if(tetra!=@@TETS@@)throw new RuntimeException("Embedded mesh mismatch: "+tetra);
|
|
77
|
+
if(SOLVE) {
|
|
78
|
+
double[][] v=m.result().numerical("integrals").getReal();
|
|
79
|
+
if(Math.abs(v[0][0]-volume)>1e-15||Math.abs(v[1][0]-energy)>Math.abs(energy)*1e-10) throw new RuntimeException("Reopen result mismatch");
|
|
80
|
+
}
|
|
81
|
+
String text="{\"import_verified\":true,\"reopen_verified\":true,\"solved\":"+SOLVE+",\"tetrahedra\":"+tetra+",\"volume_m3\":"+volume+",\"strain_energy_J\":"+energy+",\"max_displacement_m\":"+maxdisp+",\"convergence_verified\":false}";
|
|
82
|
+
System.out.println("TPMS_REOPEN_VERIFIED "+text);ModelUtil.remove("Check");
|
|
83
|
+
}
|
|
84
|
+
}"""
|
|
85
|
+
BOUNDARIES = r"""
|
|
86
|
+
face(m,"bottom",0.0);face(m,"top",@@LZ@@);
|
|
87
|
+
m.component("c").physics("solid").create("fix1","Fixed",2);
|
|
88
|
+
m.component("c").physics("solid").feature("fix1").selection().named("bottom");
|
|
89
|
+
m.component("c").physics("solid").create("disp1","Displacement2",2);
|
|
90
|
+
m.component("c").physics("solid").feature("disp1").selection().named("top");
|
|
91
|
+
m.component("c").physics("solid").feature("disp1").setIndex("Direction","prescribed",2);
|
|
92
|
+
m.component("c").physics("solid").feature("disp1").setIndex("U0","-uamp",2);
|
|
93
|
+
m.study().create("std");m.study("std").create("stat","Stationary");
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def detect_comsol():
|
|
98
|
+
paths = [
|
|
99
|
+
os.environ.get("COMSOL_BIN", ""),
|
|
100
|
+
r"D:/Program Files/COMSOL/COMSOL63/Multiphysics/bin/win64",
|
|
101
|
+
r"C:/Program Files/COMSOL/COMSOL63/Multiphysics/bin/win64",
|
|
102
|
+
]
|
|
103
|
+
for parent in [Path("C:/Program Files/COMSOL"), Path("D:/Program Files/COMSOL")]:
|
|
104
|
+
if parent.exists():
|
|
105
|
+
paths += [str(p / "Multiphysics/bin/win64") for p in parent.iterdir() if p.is_dir()]
|
|
106
|
+
for raw in paths:
|
|
107
|
+
p = Path(raw)
|
|
108
|
+
if (p / "comsolbatch.exe").is_file() and (p / "comsolcompile.exe").is_file():
|
|
109
|
+
return p
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def export_java(folder, report, solve=False, material=None):
|
|
114
|
+
folder = Path(folder).resolve()
|
|
115
|
+
if report["config"].get("domain_mode") == "solid_fluid":
|
|
116
|
+
from .comsol_dual import export_dual_java
|
|
117
|
+
|
|
118
|
+
return export_dual_java(folder, report, solve)
|
|
119
|
+
material = material or {"E": 1.5e9, "nu": 0.3, "rho": 950.0}
|
|
120
|
+
if any(
|
|
121
|
+
type(material.get(k)) not in (int, float) or not np.isfinite(material[k])
|
|
122
|
+
for k in ("E", "nu", "rho")
|
|
123
|
+
):
|
|
124
|
+
raise ValueError("材料参数必须是有限数。")
|
|
125
|
+
if not (
|
|
126
|
+
1e3 <= material["E"] <= 1e13 and 0 <= material["nu"] < 0.49 and 0 < material["rho"] <= 1e6
|
|
127
|
+
):
|
|
128
|
+
raise ValueError("材料参数超出支持范围。")
|
|
129
|
+
size = report["config"]["size"]
|
|
130
|
+
connected = report["volume_components"] == 1
|
|
131
|
+
if solve and not connected:
|
|
132
|
+
raise ValueError("静力学演示要求材料连通;当前存在多个独立实体,请先调整结构。")
|
|
133
|
+
text = TEMPLATE.replace("@@BOUNDARIES@@", BOUNDARIES if connected else "")
|
|
134
|
+
values = {
|
|
135
|
+
"ROOT": json.dumps(folder.as_posix() + "/", ensure_ascii=True),
|
|
136
|
+
"SOLVE": str(solve).lower(),
|
|
137
|
+
"LX": repr(float(size[0])),
|
|
138
|
+
"LY": repr(float(size[1])),
|
|
139
|
+
"LZ": repr(float(size[2])),
|
|
140
|
+
"TOL": repr(max(size) * 1e-7),
|
|
141
|
+
"VOL": repr(report["volume_mm3"] * 1e-9),
|
|
142
|
+
"TETS": str(report["tetrahedra"]),
|
|
143
|
+
"E": repr(float(material["E"])),
|
|
144
|
+
"NU": repr(float(material["nu"])),
|
|
145
|
+
"RHO": repr(float(material["rho"])),
|
|
146
|
+
"DISP": repr(float(size[2]) * 0.001),
|
|
147
|
+
}
|
|
148
|
+
for key, value in values.items():
|
|
149
|
+
text = text.replace("@@" + key + "@@", value)
|
|
150
|
+
(folder / "TPMSBuild.java").write_text(text, encoding="ascii")
|
|
151
|
+
(folder / "simulation_settings.json").write_text(
|
|
152
|
+
json.dumps(
|
|
153
|
+
{
|
|
154
|
+
"material": material,
|
|
155
|
+
"test": "bottom fixed, top z displacement -0.001*Lz; lateral top free; P1, linear geometry",
|
|
156
|
+
"solve_requested": solve,
|
|
157
|
+
},
|
|
158
|
+
indent=2,
|
|
159
|
+
),
|
|
160
|
+
encoding="utf8",
|
|
161
|
+
)
|
|
162
|
+
return folder / "TPMSBuild.java"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_mph(
|
|
166
|
+
folder,
|
|
167
|
+
report,
|
|
168
|
+
solve=False,
|
|
169
|
+
material=None,
|
|
170
|
+
progress=lambda s: None,
|
|
171
|
+
*,
|
|
172
|
+
max_solve_tetrahedra=180_000,
|
|
173
|
+
build_timeout_s=600,
|
|
174
|
+
):
|
|
175
|
+
if type(max_solve_tetrahedra) is not int or not 1 <= max_solve_tetrahedra <= 1_000_000:
|
|
176
|
+
raise ValueError("max_solve_tetrahedra must be an integer in [1, 1000000]")
|
|
177
|
+
if type(build_timeout_s) is not int or not 1 <= build_timeout_s <= 3600:
|
|
178
|
+
raise ValueError("build_timeout_s must be an integer in [1, 3600]")
|
|
179
|
+
folder = Path(folder).resolve()
|
|
180
|
+
exe = detect_comsol()
|
|
181
|
+
if not exe:
|
|
182
|
+
raise RuntimeError("未找到 COMSOL。已提供 NAS;请设置 COMSOL_BIN 后再创建 MPH。")
|
|
183
|
+
if solve and report["tetrahedra"] > max_solve_tetrahedra:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"演示求解上限为 {max_solve_tetrahedra:,} 个体单元;请降低采样数或明确设置研究用上限。"
|
|
186
|
+
)
|
|
187
|
+
java = export_java(folder, report, solve, material)
|
|
188
|
+
flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
|
189
|
+
for name, cmd, timeout in [
|
|
190
|
+
("compile", [str(exe / "comsolcompile.exe"), str(java)], 180),
|
|
191
|
+
(
|
|
192
|
+
"build",
|
|
193
|
+
[
|
|
194
|
+
str(exe / "comsolbatch.exe"),
|
|
195
|
+
"-np",
|
|
196
|
+
"2",
|
|
197
|
+
"-inputfile",
|
|
198
|
+
str(java.with_suffix(".class")),
|
|
199
|
+
"-batchlog",
|
|
200
|
+
str(folder / "comsol.log"),
|
|
201
|
+
],
|
|
202
|
+
build_timeout_s,
|
|
203
|
+
),
|
|
204
|
+
]:
|
|
205
|
+
progress(
|
|
206
|
+
"编译 COMSOL 接口…"
|
|
207
|
+
if name == "compile"
|
|
208
|
+
else ("COMSOL 导入并执行演示求解…" if solve else "COMSOL 创建并重开实体模型…")
|
|
209
|
+
)
|
|
210
|
+
with (folder / (name + ".log")).open("w", encoding="utf8") as log:
|
|
211
|
+
process = subprocess.Popen(
|
|
212
|
+
cmd, cwd=folder, stdout=log, stderr=subprocess.STDOUT, creationflags=flags
|
|
213
|
+
)
|
|
214
|
+
try:
|
|
215
|
+
code = process.wait(timeout=timeout)
|
|
216
|
+
except subprocess.TimeoutExpired:
|
|
217
|
+
if os.name == "nt":
|
|
218
|
+
subprocess.run(
|
|
219
|
+
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
220
|
+
stdout=log,
|
|
221
|
+
stderr=log,
|
|
222
|
+
creationflags=flags,
|
|
223
|
+
)
|
|
224
|
+
else:
|
|
225
|
+
process.kill()
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
"COMSOL 运行超时。日志已保留;请降低分辨率或明确设置更长的 build_timeout_s 后重试。"
|
|
228
|
+
)
|
|
229
|
+
if code:
|
|
230
|
+
raise RuntimeError(f"COMSOL {name} 失败,请查看 {name}.log 与 comsol.log。")
|
|
231
|
+
evidence = folder / "comsol_verification.json"
|
|
232
|
+
log_text = (folder / "build.log").read_text(encoding="utf8", errors="replace")
|
|
233
|
+
found = re.findall(r"TPMS_REOPEN_VERIFIED (\{[^\r\n]+\})", log_text)
|
|
234
|
+
if not found:
|
|
235
|
+
raise RuntimeError(
|
|
236
|
+
"COMSOL 未完成重开验证,请查看 build.log(可能是许可、导入或求解错误)。"
|
|
237
|
+
)
|
|
238
|
+
result = json.loads(found[-1])
|
|
239
|
+
evidence.write_text(json.dumps(result, indent=2), encoding="utf8")
|
|
240
|
+
if result.get("solved") != solve:
|
|
241
|
+
raise RuntimeError("求解验证记录与本次请求不一致。")
|
|
242
|
+
report.update(comsol_verified=True, comsol_solved=bool(result["solved"]), comsol=result)
|
|
243
|
+
(folder / "report.json").write_text(
|
|
244
|
+
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf8"
|
|
245
|
+
)
|
|
246
|
+
from .io import refresh_manifest
|
|
247
|
+
|
|
248
|
+
refresh_manifest(folder)
|
|
249
|
+
progress("COMSOL 模型已重新打开并验证。")
|
|
250
|
+
return result
|
tpmslab/comsol_audit.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Read-only audits of saved COMSOL pore-flow solutions (COMSOL 6.3)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
from .comsol import detect_comsol
|
|
10
|
+
|
|
11
|
+
JAVA = r"""import com.comsol.model.*;
|
|
12
|
+
import com.comsol.model.util.*;
|
|
13
|
+
import java.util.*;
|
|
14
|
+
public class TPMSAudit {
|
|
15
|
+
static double integral(Model m,String tag,String selection,String expr,String unit) {
|
|
16
|
+
m.result().numerical().create(tag,"IntSurface");
|
|
17
|
+
m.result().numerical(tag).selection().named(selection);
|
|
18
|
+
m.result().numerical(tag).set("expr",expr);
|
|
19
|
+
m.result().numerical(tag).set("unit",unit);
|
|
20
|
+
return m.result().numerical(tag).getReal()[0][0];
|
|
21
|
+
}
|
|
22
|
+
static String intersect(Model m,String tag,int[] adjacent,String plane) {
|
|
23
|
+
HashSet<Integer> a=new HashSet<>();for(int i:adjacent)a.add(i);
|
|
24
|
+
int[] ids=Arrays.stream(m.component("c").selection(plane).entities(2)).filter(a::contains).toArray();
|
|
25
|
+
if(ids.length==0)throw new RuntimeException("Empty channel opening "+tag);
|
|
26
|
+
m.component("c").selection().create(tag,"Explicit");m.component("c").selection(tag).geom("g",2);m.component("c").selection(tag).set(ids);return tag;
|
|
27
|
+
}
|
|
28
|
+
public static void main(String[] args)throws Exception {
|
|
29
|
+
Model m=ModelUtil.load("Audit",@@MODEL@@);
|
|
30
|
+
double area=integral(m,"auditArea","solid_fluid_interface","1","mm^2");
|
|
31
|
+
double eta=Math.abs(area-@@AREA@@)/@@AREA@@;
|
|
32
|
+
if(!Double.isFinite(area)||eta>1e-8)throw new RuntimeException("Interface area mismatch: "+area);
|
|
33
|
+
StringBuilder rows=new StringBuilder(); double sumIn=0,sumOut=0;
|
|
34
|
+
int[] domainIds=new int[]{@@IDS@@};
|
|
35
|
+
for(int id:domainIds) {
|
|
36
|
+
String adj="auditAdj"+id;m.component("c").selection().create(adj,"Adjacent");
|
|
37
|
+
m.component("c").selection(adj).set("entitydim",3);m.component("c").selection(adj).set("outputdim",2);
|
|
38
|
+
m.component("c").selection(adj).set("input",new String[]{"fluid_"+id});
|
|
39
|
+
int[] boundaries=m.component("c").selection(adj).entities(2);
|
|
40
|
+
String in=intersect(m,"auditIn"+id,boundaries,"fluid_zmin"),out=intersect(m,"auditOut"+id,boundaries,"fluid_zmax");
|
|
41
|
+
double ai=integral(m,"auditAi"+id,in,"1","mm^2"),ao=integral(m,"auditAo"+id,out,"1","mm^2");
|
|
42
|
+
double qi=integral(m,"auditQi"+id,in,"-w","m^3/s"),qo=integral(m,"auditQo"+id,out,"w","m^3/s");
|
|
43
|
+
double balance=Math.abs(qi+qo)/Math.max(Math.abs(qi),Math.abs(qo));
|
|
44
|
+
if(!Double.isFinite(balance)||!(qi<0)||!(qo>0)||balance>0.05)throw new RuntimeException("Channel balance failed: "+id+" "+balance);
|
|
45
|
+
if(rows.length()>0)rows.append(",");
|
|
46
|
+
rows.append("{\"domain_id\":"+id+",\"inlet_area_mm2\":"+ai+",\"outlet_area_mm2\":"+ao+",\"inlet_outward_flux_m3_s\":"+qi+",\"outlet_flux_m3_s\":"+qo+",\"relative_flux_imbalance\":"+balance+",\"solver_domain_ids\":"+Arrays.toString(m.component("c").selection("fluid_"+id).entities(3))+"}");
|
|
47
|
+
sumIn+=qi;sumOut+=qo;
|
|
48
|
+
}
|
|
49
|
+
System.out.println("TPMS_AUDIT {\"interface_area_comsol_mm2\":"+area+",\"interface_area_relative_error\":"+eta+",\"channels\":["+rows+"],\"channel_sum_inlet_m3_s\":"+sumIn+",\"channel_sum_outlet_m3_s\":"+sumOut+"}");
|
|
50
|
+
ModelUtil.remove("Audit");
|
|
51
|
+
}
|
|
52
|
+
}"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def audit_saved_flow(model_path, interface_area_mm2, domain_map, output):
|
|
56
|
+
"""Audit the stored solution without changing its MPH; write separate audit files."""
|
|
57
|
+
output = Path(output).resolve()
|
|
58
|
+
output.mkdir(parents=True, exist_ok=False)
|
|
59
|
+
exe = detect_comsol()
|
|
60
|
+
if exe is None:
|
|
61
|
+
raise RuntimeError("COMSOL installation not found")
|
|
62
|
+
text = JAVA.replace("@@MODEL@@", json.dumps(Path(model_path).resolve().as_posix()))
|
|
63
|
+
text = text.replace("@@AREA@@", repr(float(interface_area_mm2)))
|
|
64
|
+
text = text.replace(
|
|
65
|
+
"@@IDS@@", ",".join(str(d["id"]) for d in domain_map if d["phase"] == "fluid")
|
|
66
|
+
)
|
|
67
|
+
java = output / "TPMSAudit.java"
|
|
68
|
+
java.write_text(text, encoding="ascii")
|
|
69
|
+
for name, cmd, timeout in [
|
|
70
|
+
("compile", [str(exe / "comsolcompile.exe"), str(java)], 180),
|
|
71
|
+
(
|
|
72
|
+
"audit",
|
|
73
|
+
[
|
|
74
|
+
str(exe / "comsolbatch.exe"),
|
|
75
|
+
"-np",
|
|
76
|
+
"2",
|
|
77
|
+
"-inputfile",
|
|
78
|
+
str(java.with_suffix(".class")),
|
|
79
|
+
"-batchlog",
|
|
80
|
+
str(output / "comsol.log"),
|
|
81
|
+
],
|
|
82
|
+
600,
|
|
83
|
+
),
|
|
84
|
+
]:
|
|
85
|
+
with (output / (name + ".log")).open("w", encoding="utf8") as log:
|
|
86
|
+
subprocess.run(
|
|
87
|
+
cmd,
|
|
88
|
+
cwd=output,
|
|
89
|
+
stdout=log,
|
|
90
|
+
stderr=subprocess.STDOUT,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
check=True,
|
|
93
|
+
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
|
94
|
+
)
|
|
95
|
+
s = (output / "audit.log").read_text("utf8", errors="replace")
|
|
96
|
+
match = re.search(r"TPMS_AUDIT (\{[^\r\n]+\})", s)
|
|
97
|
+
if not match:
|
|
98
|
+
raise RuntimeError("COMSOL audit did not complete; inspect audit.log and comsol.log")
|
|
99
|
+
result = json.loads(match[1])
|
|
100
|
+
result["interface_area_mesh_mm2"] = interface_area_mm2
|
|
101
|
+
result["source_mph_sha256"] = hashlib.sha256(Path(model_path).read_bytes()).hexdigest()
|
|
102
|
+
mapping = {d["id"]: d for d in domain_map}
|
|
103
|
+
for channel in result["channels"]:
|
|
104
|
+
channel["tetrahedra"] = mapping[channel["domain_id"]]["tetrahedra"]
|
|
105
|
+
channel["volume_mm3"] = mapping[channel["domain_id"]]["volume_mm3"]
|
|
106
|
+
mesh_path = Path(model_path).parent / "mesh.npz"
|
|
107
|
+
if mesh_path.exists():
|
|
108
|
+
import numpy as np
|
|
109
|
+
from .verification import audit_openings
|
|
110
|
+
|
|
111
|
+
with np.load(mesh_path) as a:
|
|
112
|
+
mesh = dict(
|
|
113
|
+
points=a["points_mm"],
|
|
114
|
+
tetra=a["tetrahedra"],
|
|
115
|
+
phase_ids=a["phase_ids"],
|
|
116
|
+
domains=a["domain_ids"],
|
|
117
|
+
)
|
|
118
|
+
openings = {d["domain_id"]: d for d in audit_openings(mesh, mesh["points"].max(axis=0))}
|
|
119
|
+
for channel in result["channels"]:
|
|
120
|
+
for name in ("inlet", "outlet"):
|
|
121
|
+
area = openings[channel["domain_id"]][name + "_area_mesh_mm2"]
|
|
122
|
+
error = abs(channel[name + "_area_mm2"] - area) / area
|
|
123
|
+
if error > 1e-8:
|
|
124
|
+
raise RuntimeError("Imported channel opening area mismatch")
|
|
125
|
+
channel[name + "_area_mesh_mm2"] = area
|
|
126
|
+
channel[name + "_area_relative_error"] = error
|
|
127
|
+
(output / "audit.json").write_text(json.dumps(result, indent=2), "utf8")
|
|
128
|
+
return result
|
tpmslab/comsol_dual.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""COMSOL solid/fluid selections and optional fixed-wall creeping-flow smoke test."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
TEMPLATE = r"""import com.comsol.model.*;
|
|
7
|
+
import com.comsol.model.util.*;
|
|
8
|
+
import java.util.*;
|
|
9
|
+
import java.util.regex.*;
|
|
10
|
+
public class TPMSBuild {
|
|
11
|
+
static String ROOT=@@ROOT@@;
|
|
12
|
+
static boolean SOLVE=@@SOLVE@@;
|
|
13
|
+
static void select(Model m,String name,int dim,int[] ids) {
|
|
14
|
+
m.component("c").selection().create(name,"Explicit");
|
|
15
|
+
m.component("c").selection(name).geom("g",dim);
|
|
16
|
+
m.component("c").selection(name).set(ids);
|
|
17
|
+
m.component("c").selection(name).label(name);
|
|
18
|
+
}
|
|
19
|
+
static int[] pid(Model m,int value,int dim) {
|
|
20
|
+
Pattern p=Pattern.compile("\\bID\\s+"+value+"\\b");
|
|
21
|
+
for(String tag:m.component("c").selection().tags()) {
|
|
22
|
+
if(p.matcher(m.component("c").selection(tag).label()).find()) {
|
|
23
|
+
int[] ids=m.component("c").selection(tag).entities(dim);
|
|
24
|
+
if(ids.length>0)return ids;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new RuntimeException("Missing NASTRAN PID "+value+" dimension "+dim);
|
|
28
|
+
}
|
|
29
|
+
static int[] join(List<Integer> ids) {return ids.stream().mapToInt(Integer::intValue).distinct().toArray();}
|
|
30
|
+
static void verify(Model m) {
|
|
31
|
+
if(m.component("c").mesh("mesh").getNumElem("tet")!=@@TETS@@)throw new RuntimeException("Tetrahedron count mismatch");
|
|
32
|
+
int[] es=m.component("c").mesh("mesh").getElemEntity("tet");
|
|
33
|
+
int[][] ts=m.component("c").mesh("mesh").getElem("tet");
|
|
34
|
+
double[][] ps=m.component("c").mesh("mesh").getVertex();
|
|
35
|
+
HashMap<Integer,Double> vol=new HashMap<>();
|
|
36
|
+
for(int i=0;i<es.length;i++) {
|
|
37
|
+
double[] a=new double[3],b=new double[3],c=new double[3];
|
|
38
|
+
for(int j=0;j<3;j++){a[j]=ps[j][ts[1][i]]-ps[j][ts[0][i]];b[j]=ps[j][ts[2][i]]-ps[j][ts[0][i]];c[j]=ps[j][ts[3][i]]-ps[j][ts[0][i]];}
|
|
39
|
+
double v=Math.abs(a[0]*(b[1]*c[2]-b[2]*c[1])+a[1]*(b[2]*c[0]-b[0]*c[2])+a[2]*(b[0]*c[1]-b[1]*c[0]))/6;
|
|
40
|
+
vol.put(es[i],vol.getOrDefault(es[i],0.0)+v);
|
|
41
|
+
}
|
|
42
|
+
@@CHECK_VOLUMES@@
|
|
43
|
+
if(m.component("c").selection("solid_domains").entities(3).length!=@@SOLID_COUNT@@ || m.component("c").selection("fluid_domains").entities(3).length!=@@FLUID_COUNT@@)throw new RuntimeException("Phase selection mismatch");
|
|
44
|
+
if(m.component("c").selection("solid_fluid_interface").entities(2).length==0)throw new RuntimeException("Missing interface");
|
|
45
|
+
}
|
|
46
|
+
static double integral(Model m,String tag,String selection,String expr) {
|
|
47
|
+
m.result().numerical().create(tag,"IntSurface");
|
|
48
|
+
m.result().numerical(tag).selection().named(selection);
|
|
49
|
+
m.result().numerical(tag).set("expr",expr);
|
|
50
|
+
m.result().numerical(tag).set("unit","m^3/s");
|
|
51
|
+
return m.result().numerical(tag).getReal()[0][0];
|
|
52
|
+
}
|
|
53
|
+
public static void main(String[] args)throws Exception {
|
|
54
|
+
Model m=ModelUtil.create("Model");m.modelPath(ROOT);m.label("TPMS Lab - solid and pore fluid domains");
|
|
55
|
+
m.component().create("c",true);m.component("c").geom().create("g",3);m.component("c").geom("g").lengthUnit("mm");
|
|
56
|
+
m.component("c").mesh().create("mesh","g");m.component("c").mesh("mesh").geometricModel("");
|
|
57
|
+
m.component("c").mesh("mesh").create("imp","Import");
|
|
58
|
+
m.component("c").mesh("mesh").feature("imp").set("source","nastran");
|
|
59
|
+
m.component("c").mesh("mesh").feature("imp").set("filename",ROOT+"mesh.nas");
|
|
60
|
+
m.component("c").mesh("mesh").feature("imp").set("data","mesh");
|
|
61
|
+
m.component("c").mesh("mesh").feature("imp").set("materialsplit",true);
|
|
62
|
+
m.component("c").mesh("mesh").feature("imp").set("selcreation",true);
|
|
63
|
+
m.component("c").mesh("mesh").feature("imp").set("allowshellpartition",false);
|
|
64
|
+
m.component("c").mesh("mesh").feature("imp").set("facepartition","minimal");
|
|
65
|
+
m.component("c").mesh("mesh").run();m.component("c").geometricModel("mesh");
|
|
66
|
+
List<Integer> solid=new ArrayList<>(),fluid=new ArrayList<>();
|
|
67
|
+
@@SELECTIONS@@
|
|
68
|
+
select(m,"solid_domains",3,join(solid));select(m,"fluid_domains",3,join(fluid));
|
|
69
|
+
verify(m);
|
|
70
|
+
m.component("c").material().create("mat_s","Common");m.component("c").material("mat_s").label("Solid - demonstration properties");
|
|
71
|
+
m.component("c").material("mat_s").selection().named("solid_domains");
|
|
72
|
+
m.component("c").material("mat_s").propertyGroup("def").set("youngsmodulus","1.5e9[Pa]");
|
|
73
|
+
m.component("c").material("mat_s").propertyGroup("def").set("poissonsratio","0.3");
|
|
74
|
+
m.component("c").material("mat_s").propertyGroup("def").set("density","950[kg/m^3]");
|
|
75
|
+
m.component("c").material().create("mat_f","Common");m.component("c").material("mat_f").label("Pore fluid - demonstration properties");
|
|
76
|
+
m.component("c").material("mat_f").selection().named("fluid_domains");
|
|
77
|
+
m.component("c").material("mat_f").propertyGroup("def").set("density","1000[kg/m^3]");
|
|
78
|
+
m.component("c").material("mat_f").propertyGroup("def").set("dynamicviscosity","0.001[Pa*s]");
|
|
79
|
+
if(SOLVE) {
|
|
80
|
+
m.component("c").sorder("linear");
|
|
81
|
+
m.component("c").physics().create("spf","CreepingFlow","g");
|
|
82
|
+
m.component("c").physics("spf").selection().named("fluid_domains");
|
|
83
|
+
m.component("c").physics("spf").create("inl1","InletBoundary",2);
|
|
84
|
+
m.component("c").physics("spf").feature("inl1").selection().named("fluid_zmin");
|
|
85
|
+
m.component("c").physics("spf").feature("inl1").set("BoundaryCondition","Pressure");
|
|
86
|
+
m.component("c").physics("spf").feature("inl1").set("p0","0.01[Pa]");
|
|
87
|
+
m.component("c").physics("spf").create("out1","OutletBoundary",2);
|
|
88
|
+
m.component("c").physics("spf").feature("out1").selection().named("fluid_zmax");
|
|
89
|
+
m.component("c").physics("spf").feature("out1").set("p0","0[Pa]");
|
|
90
|
+
m.study().create("std");m.study("std").create("stat","Stationary");
|
|
91
|
+
}
|
|
92
|
+
m.component("c").mesh("mesh").feature("imp").set("filename","mesh.nas");
|
|
93
|
+
m.save(ROOT+"model_unsolved.mph");
|
|
94
|
+
double qin=0,qout=0,balance=0;
|
|
95
|
+
if(SOLVE) {
|
|
96
|
+
m.study("std").run();
|
|
97
|
+
qin=integral(m,"qin","fluid_zmin","-w");qout=integral(m,"qout","fluid_zmax","w");
|
|
98
|
+
balance=Math.abs(qin+qout)/Math.max(Math.abs(qin),Math.abs(qout));
|
|
99
|
+
if(!Double.isFinite(qin)||!Double.isFinite(qout)||!(qin<0)||!(qout>0)||balance>0.05)throw new RuntimeException("Flow or conservation check failed: "+qin+" "+qout+" "+balance);
|
|
100
|
+
m.result().create("pg1","PlotGroup3D");m.result("pg1").label("Pore fluid speed - fixed-wall creeping-flow demonstration");
|
|
101
|
+
m.result("pg1").create("surf1","Surface");m.result("pg1").feature("surf1").set("expr","spf.U");
|
|
102
|
+
m.save(ROOT+"model_solved.mph");
|
|
103
|
+
}
|
|
104
|
+
ModelUtil.remove("Model");m=ModelUtil.load("Check",ROOT+(SOLVE?"model_solved.mph":"model_unsolved.mph"));verify(m);
|
|
105
|
+
if(SOLVE && Math.abs(m.result().numerical("qout").getReal()[0][0]-qout)>Math.abs(qout)*1e-10)throw new RuntimeException("Reopened flow result mismatch");
|
|
106
|
+
String result="{\"import_verified\":true,\"reopen_verified\":true,\"phase_volumes_verified\":true,\"solid_domains\":@@SOLID_COUNT@@,\"fluid_domains\":@@FLUID_COUNT@@,\"interface_selection_verified\":true,\"solved\":"+SOLVE+",\"test\":\"fixed_wall_creeping_flow\",\"inlet_outward_flux_m3_s\":"+qin+",\"outlet_flux_m3_s\":"+qout+",\"relative_flux_imbalance\":"+balance+",\"fsi_verified\":false,\"convergence_verified\":false}";
|
|
107
|
+
System.out.println("TPMS_REOPEN_VERIFIED "+result);ModelUtil.remove("Check");
|
|
108
|
+
}
|
|
109
|
+
}"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def export_dual_java(folder, report, solve=False):
|
|
113
|
+
folder = Path(folder).resolve()
|
|
114
|
+
if solve and any(
|
|
115
|
+
not {16, 17}.issubset(d["exterior_boundary_ids"])
|
|
116
|
+
for d in report["domain_map"]
|
|
117
|
+
if d["phase"] == "fluid"
|
|
118
|
+
):
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"流动演示要求每个流体域都连通 Z- 和 Z+;请创建未求解 MPH 后按各流道设置边界。"
|
|
121
|
+
)
|
|
122
|
+
selections, checks = [], []
|
|
123
|
+
for d in report["domain_map"]:
|
|
124
|
+
var = f"d{d['id']}"
|
|
125
|
+
selections.append(
|
|
126
|
+
f'int[] {var}=pid(m,{d["nastran_pid"]},3); select(m,"{d["phase"]}_{d["id"]}",3,{var}); for(int id:{var}) {d["phase"]}.add(id);'
|
|
127
|
+
)
|
|
128
|
+
checks.append(
|
|
129
|
+
f'double v{d["id"]}=0; for(int id:m.component("c").selection("{d["phase"]}_{d["id"]}").entities(3)) v{d["id"]}+=vol.getOrDefault(id,0.0); if(Math.abs(v{d["id"]}-{d["volume_mm3"]})>{max(d["volume_mm3"] * 1e-8, 1e-10)})throw new RuntimeException("Volume mismatch for {var}: "+v{d["id"]});'
|
|
130
|
+
)
|
|
131
|
+
boundary_ids = {8}
|
|
132
|
+
for d in report["domain_map"]:
|
|
133
|
+
boundary_ids.update(d["exterior_boundary_ids"])
|
|
134
|
+
for pid in sorted(boundary_ids):
|
|
135
|
+
selections.append(f'select(m,"{report["boundary_tag_names"][str(pid)]}",2,pid(m,{pid},2));')
|
|
136
|
+
text = TEMPLATE
|
|
137
|
+
values = dict(
|
|
138
|
+
ROOT=json.dumps(folder.as_posix() + "/"),
|
|
139
|
+
SOLVE=str(solve).lower(),
|
|
140
|
+
TETS=str(report["tetrahedra"]),
|
|
141
|
+
SOLID_COUNT=str(report["solid_components"]),
|
|
142
|
+
FLUID_COUNT=str(report["fluid_components"]),
|
|
143
|
+
SELECTIONS="\n ".join(selections),
|
|
144
|
+
CHECK_VOLUMES="\n ".join(checks),
|
|
145
|
+
)
|
|
146
|
+
for key, value in values.items():
|
|
147
|
+
text = text.replace("@@" + key + "@@", value)
|
|
148
|
+
(folder / "TPMSBuild.java").write_text(text, encoding="ascii")
|
|
149
|
+
(folder / "simulation_settings.json").write_text(
|
|
150
|
+
json.dumps(
|
|
151
|
+
dict(
|
|
152
|
+
test="fixed-wall creeping flow in pore fluid only; no deformation/FSI",
|
|
153
|
+
solve_requested=solve,
|
|
154
|
+
inlet="fluid_zmin, 0.01 Pa",
|
|
155
|
+
outlet="fluid_zmax, 0 Pa",
|
|
156
|
+
walls="no slip on solid-fluid interface and other fluid exterior faces",
|
|
157
|
+
fluid_density_kg_m3=1000,
|
|
158
|
+
fluid_dynamic_viscosity_Pa_s=0.001,
|
|
159
|
+
convergence_verified=False,
|
|
160
|
+
),
|
|
161
|
+
indent=2,
|
|
162
|
+
),
|
|
163
|
+
encoding="utf8",
|
|
164
|
+
)
|
|
165
|
+
return folder / "TPMSBuild.java"
|