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/families.json
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Primitive Schwartz": {
|
|
3
|
+
"expression": "cos(X)+cos(Y)+cos(Z)",
|
|
4
|
+
"source_label": "Primitive Schwartz Surface"
|
|
5
|
+
},
|
|
6
|
+
"Double P": {
|
|
7
|
+
"expression": "0.5*(cos(X)*cos(Y)+cos(Y)*cos(Z)+cos(Z)*cos(X))+0.2*(cos(2*X)+cos(2*Y)+cos(2*Z))",
|
|
8
|
+
"source_label": "Double P Surface"
|
|
9
|
+
},
|
|
10
|
+
"Gyroid": {
|
|
11
|
+
"expression": "sin(X)*cos(Y) + sin(Z)*cos(X) + sin(Y)*cos(Z)",
|
|
12
|
+
"source_label": "Gyroid Surface"
|
|
13
|
+
},
|
|
14
|
+
"Double Gyroid": {
|
|
15
|
+
"expression": "2.75*(sin(2*X)*sin(Z)*cos(Y) + sin(2*Y)*sin(X)*cos(Z) + sin(2*Z)*sin(Y)*cos(X))-(cos(2*X)*cos(2*Y)+cos(2*Y)*cos(2*Z)+cos(2*Z)*cos(2*X))",
|
|
16
|
+
"source_label": "Double Gyroid Surface"
|
|
17
|
+
},
|
|
18
|
+
"Diamond": {
|
|
19
|
+
"expression": "(cos(X)*cos(Y)*cos(Z)) - (sin(X)*sin(Y)*sin(Z))",
|
|
20
|
+
"source_label": "Diamond Surface"
|
|
21
|
+
},
|
|
22
|
+
"Double Diamond 1": {
|
|
23
|
+
"expression": "sin(2*X)*sin(2*Y)+sin(2*Y)*sin(2*Z)+sin(2*X)*sin(2*Z)+cos(2*X)*cos(2*Y)*cos(2*Z)",
|
|
24
|
+
"source_label": "Double Diamond 1 Surface"
|
|
25
|
+
},
|
|
26
|
+
"Double Diamond 2": {
|
|
27
|
+
"expression": "cos(2*X)*cos(2*Y)+cos(2*Y)*cos(2*Z)+cos(2*X)*cos(2*Z)+sin(2*X)*sin(2*Y)*sin(2*Z)",
|
|
28
|
+
"source_label": "Double Diamond 2 Surface"
|
|
29
|
+
},
|
|
30
|
+
"IWP": {
|
|
31
|
+
"expression": "2*(cos(X)*cos(Y)+cos(Y)*cos(Z)+cos(Z)*cos(X))-(cos(2*X)+cos(2*Y)+cos(2*Z))",
|
|
32
|
+
"source_label": "IWP Surface"
|
|
33
|
+
},
|
|
34
|
+
"FRD Prime": {
|
|
35
|
+
"expression": "4*(cos(X)*cos(Y)*cos(Z))-(cos(2*X)*cos(2*Y)+cos(2*X)*cos(2*Z)+cos(2*Y)*cos(2*Z))",
|
|
36
|
+
"source_label": "FRD Prime Surface"
|
|
37
|
+
},
|
|
38
|
+
"I2-Y": {
|
|
39
|
+
"expression": "-2*(sin(2*X)*cos(Y)*sin(Z) + sin(X)*sin(2*Y)*cos(Z) + cos(X)*sin(Y)*sin(2*Z)) + cos(2*X)*cos(2*Y) + cos(2*Y)*cos(2*Z) + cos(2*X)*cos(2*Z)",
|
|
40
|
+
"source_label": "I2-Y Surface"
|
|
41
|
+
},
|
|
42
|
+
"G Prime 1": {
|
|
43
|
+
"expression": "5*(sin(2*X)*sin(Z)*cos(Y)+sin(2*Y)*sin(X)*cos(Z)+sin(2*Z)*sin(Y)*cos(X))+(cos(2*X)*cos(2*Y)+cos(2*Y)*cos(2*Z)+cos(2*Z)*cos(2*X))",
|
|
44
|
+
"source_label": "G Prime 1 Surface"
|
|
45
|
+
},
|
|
46
|
+
"G Prime 2": {
|
|
47
|
+
"expression": "(sin(2*X)*sin(Z)*cos(Y)+sin(2*Y)*sin(X)*cos(Z)+sin(2*Z)*sin(Y)*cos(X))+0.32",
|
|
48
|
+
"source_label": "G Prime 2 Surface"
|
|
49
|
+
},
|
|
50
|
+
"Neovius": {
|
|
51
|
+
"expression": "3*(cos(X)+cos(Y)+cos(Z))+4*(cos(X)*cos(Y)*cos(Z))",
|
|
52
|
+
"source_label": "Neovius Surface"
|
|
53
|
+
},
|
|
54
|
+
"Lidinoid": {
|
|
55
|
+
"expression": "(sin(2*X)*sin(Z)*cos(Y)+sin(2*Y)*sin(X)*cos(Z)+sin(2*Z)*sin(Y)*cos(X))-(cos(2*X)*cos(2*Y)+cos(2*Y)*cos(2*Z)+cos(2*Z)*cos(2*X))+0.3",
|
|
56
|
+
"source_label": "Lidinoid Surface"
|
|
57
|
+
},
|
|
58
|
+
"D Prime": {
|
|
59
|
+
"expression": "0.5*(sin(X)*sin(Y)*sin(Z)+cos(X)*cos(Y)*cos(Z))-0.5*(cos(2*X)*cos(2*Y)+cos(2*Y)*cos(2*Z)+cos(2*Z)*cos(2*X))-0.2",
|
|
60
|
+
"source_label": "D Prime Surface"
|
|
61
|
+
},
|
|
62
|
+
"KP": {
|
|
63
|
+
"expression": "0.3*(cos(X)+cos(Y)+cos(Z))+0.3*(cos(X)*cos(Y)+cos(Y)*cos(Z)+cos(Z)*cos(X))-0.4*(cos(2*X)+cos(2*Y)+cos(2*Z))+0.2",
|
|
64
|
+
"source_label": "KP Surface"
|
|
65
|
+
},
|
|
66
|
+
"S": {
|
|
67
|
+
"expression": "cos(2*X)*sin(Y)*cos(Z)+cos(2*Y)*sin(Z)*cos(X)+cos(2*Z)*sin(X)*cos(Y)-0.4",
|
|
68
|
+
"source_label": "S Surface"
|
|
69
|
+
},
|
|
70
|
+
"Fischer-Koch": {
|
|
71
|
+
"expression": "(cos(X)*cos(Y)+cos(Y)*cos(Z)+cos(Z)*cos(X))-(cos(2*X)+cos(2*Y)+cos(2*Z))",
|
|
72
|
+
"source_label": "Fischer-Koch Surface"
|
|
73
|
+
},
|
|
74
|
+
"Bionic Bone 1": {
|
|
75
|
+
"expression": "20*(cos(X)*sin(Y)+cos(Y)*sin(Z)+cos(Z)*sin(X))-0.5*(cos(2*X)*cos(2*Y)+cos(2*Y)*cos(2*Z)+cos(2*Z)*cos(2*X))-4",
|
|
76
|
+
"source_label": "Bionic Bone 1 Surface"
|
|
77
|
+
},
|
|
78
|
+
"Bionic Bone 2": {
|
|
79
|
+
"expression": "10*(cos(X)*sin(Y)+cos(Y)*sin(Z)+cos(Z)*sin(X))-2*(cos(2*X)*cos(2*Y)+cos(2*Y)*cos(2*Z)+cos(2*Z)*cos(2*X))-12",
|
|
80
|
+
"source_label": "Bionic Bone 2 Surface"
|
|
81
|
+
},
|
|
82
|
+
"Octo 1": {
|
|
83
|
+
"expression": "4*(cos(X)*cos(Y)+cos(Y)*cos(Z)+cos(Z)*cos(X))-2.8*(cos(X)*cos(Y)*cos(Z))+(cos(X)+cos(Y)+cos(Z))+1.5",
|
|
84
|
+
"source_label": "Octo 1 Surface"
|
|
85
|
+
},
|
|
86
|
+
"Octo 2": {
|
|
87
|
+
"expression": "4*(cos(X)*cos(Y)+cos(Y)*cos(Z)+cos(Z)*cos(X))-3*(cos(X)+cos(Y)+cos(Z))+2.4",
|
|
88
|
+
"source_label": "Octo 2 Surface"
|
|
89
|
+
},
|
|
90
|
+
"PN": {
|
|
91
|
+
"expression": "0.6*(cos(X)*cos(Y)*cos(Z))+0.4*(cos(X)+cos(Y)+cos(Z))+0.2*(cos(2*X)*cos(2*Y)*cos(2*Z))+0.2*(cos(2*X)+cos(2*Y)+cos(2*Z))+0.1*(cos(3*X)+cos(3*Y)+cos(3*Z))+0.2*(cos(X)*cos(Y)+cos(Y)*cos(Z)+cos(Z)*cos(X))",
|
|
92
|
+
"source_label": "PN Surface"
|
|
93
|
+
},
|
|
94
|
+
"C(Y)": {
|
|
95
|
+
"expression": "sin(X)*sin(Y)*sin(Z)+sin(2*X)*sin(Y)+sin(2*Y)*sin(Z)+sin(2*Z)*sin(X)-cos(X)*cos(Y)*cos(Z)+sin(2*X)*cos(Z)+sin(2*Y)*cos(X)+sin(2*Z)*cos(Y)",
|
|
96
|
+
"source_label": "C(Y) Surface"
|
|
97
|
+
},
|
|
98
|
+
"Black D": {
|
|
99
|
+
"expression": "sin(X)*sin(Y)*sin(Z)+sin(X)*cos(Y)*cos(Z)+cos(X)*sin(Y)*cos(Z)+cos(X)*cos(Y)*sin(Z)",
|
|
100
|
+
"source_label": "Black D Surface"
|
|
101
|
+
},
|
|
102
|
+
"FRD": {
|
|
103
|
+
"expression": "8*(cos(X)*cos(Y)*cos(Z))+cos(2*X)*cos(2*Y)*cos(2*Z)-cos(2*X)*cos(2*Y)-cos(2*Y)*cos(2*Z)-cos(2*Z)*cos(2*X)",
|
|
104
|
+
"source_label": "FRD Surface"
|
|
105
|
+
},
|
|
106
|
+
"Diamond D": {
|
|
107
|
+
"expression": "sin(X)*sin(Y)*sin(Z)+sin(X)*cos(Y)*cos(Z)+cos(X)*sin(Y)*cos(Z)+cos(X)*cos(Y)*sin(Z)",
|
|
108
|
+
"source_label": "Diamond D Surface",
|
|
109
|
+
"alias_of": "Black D"
|
|
110
|
+
},
|
|
111
|
+
"D": {
|
|
112
|
+
"expression": "cos(X)*cos(Y)*cos(Z)-sin(X)*sin(Y)*sin(Z)",
|
|
113
|
+
"source_label": "D Surface"
|
|
114
|
+
},
|
|
115
|
+
"Split P": {
|
|
116
|
+
"expression": "1.1*(sin(2*X)*sin(Z)*cos(Y)+sin(2*Y)*sin(X)*cos(Z)+sin(2*Z)*sin(Y)*cos(X))-0.2*(cos(2*X)*cos(2*Y)+cos(2*Y)*cos(2*Z)+cos(2*Z)*cos(2*X))-0.4*(cos(X)+cos(Y)+cos(Z))",
|
|
117
|
+
"source_label": "Split P Surface"
|
|
118
|
+
}
|
|
119
|
+
}
|
tpmslab/io.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Deterministic model export and file-integrity manifest."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import platform
|
|
8
|
+
import numpy as np
|
|
9
|
+
from .volume import write_nastran
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def save_model(model, directory):
|
|
13
|
+
"""Write NAS, NPZ, STL preview, parameters, checks and hashes to a NEW folder.
|
|
14
|
+
|
|
15
|
+
Existing directories are rejected. STL is a preview, not the solver mesh.
|
|
16
|
+
"""
|
|
17
|
+
folder = Path(directory).resolve()
|
|
18
|
+
if folder.exists():
|
|
19
|
+
raise FileExistsError(f"Output already exists: {folder}")
|
|
20
|
+
folder.mkdir(parents=True)
|
|
21
|
+
write_nastran(model, folder / "mesh.nas")
|
|
22
|
+
np.savez_compressed(
|
|
23
|
+
folder / "mesh.npz",
|
|
24
|
+
points_mm=model["points"],
|
|
25
|
+
tetrahedra=model["tetra"],
|
|
26
|
+
boundary=model["boundary"],
|
|
27
|
+
boundary_ids=model["boundary_ids"],
|
|
28
|
+
domain_ids=model["domains"],
|
|
29
|
+
**{
|
|
30
|
+
k: model[k]
|
|
31
|
+
for k in ("phase_ids", "interface", "solid_boundary", "fluid_boundary")
|
|
32
|
+
if k in model
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
model["surface"].export(folder / "preview.stl")
|
|
36
|
+
if "fluid_surface" in model:
|
|
37
|
+
model["fluid_surface"].export(folder / "fluid_preview.stl")
|
|
38
|
+
(folder / "domains.json").write_text(
|
|
39
|
+
json.dumps(
|
|
40
|
+
{
|
|
41
|
+
"domains": model["report"]["domain_map"],
|
|
42
|
+
"boundaries": model["report"]["boundary_tag_names"],
|
|
43
|
+
},
|
|
44
|
+
indent=2,
|
|
45
|
+
),
|
|
46
|
+
encoding="utf8",
|
|
47
|
+
)
|
|
48
|
+
for filename, data in (
|
|
49
|
+
("config.json", model["report"]["config"]),
|
|
50
|
+
("report.json", model["report"]),
|
|
51
|
+
):
|
|
52
|
+
(folder / filename).write_text(
|
|
53
|
+
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf8"
|
|
54
|
+
)
|
|
55
|
+
environment = {"python": platform.python_version(), "platform": platform.platform()}
|
|
56
|
+
for name in ("numpy", "scipy", "trimesh"):
|
|
57
|
+
environment[name] = importlib.metadata.version(name)
|
|
58
|
+
(folder / "environment.json").write_text(json.dumps(environment, indent=2), encoding="utf8")
|
|
59
|
+
refresh_manifest(folder)
|
|
60
|
+
return folder
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def refresh_manifest(directory):
|
|
64
|
+
"""Refresh checksums after optional COMSOL artifacts change."""
|
|
65
|
+
folder = Path(directory)
|
|
66
|
+
manifest = {
|
|
67
|
+
p.name: {"bytes": p.stat().st_size, "sha256": hashlib.sha256(p.read_bytes()).hexdigest()}
|
|
68
|
+
for p in sorted(folder.iterdir())
|
|
69
|
+
if p.is_file() and p.name != "manifest.json"
|
|
70
|
+
}
|
|
71
|
+
(folder / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf8")
|
|
72
|
+
return folder
|
tpmslab/model.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Implicit field definitions and density calibration for direct volume meshing.
|
|
2
|
+
|
|
3
|
+
The trigonometric functions are level-set approximations, not exact minimal
|
|
4
|
+
surfaces. Density grading uses the CDF of a sampled periodic unit cell.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import ast
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import operator
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
DATA = Path(__file__).with_name("families.json")
|
|
17
|
+
FAMILIES = json.loads(DATA.read_text(encoding="utf-8"))
|
|
18
|
+
FUNCTIONS = {name: getattr(np, name) for name in ("sin", "cos", "tan", "sqrt", "abs", "exp")}
|
|
19
|
+
OPS = {
|
|
20
|
+
ast.Add: operator.add,
|
|
21
|
+
ast.Sub: operator.sub,
|
|
22
|
+
ast.Mult: operator.mul,
|
|
23
|
+
ast.Div: operator.truediv,
|
|
24
|
+
ast.Pow: operator.pow,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@lru_cache(maxsize=128)
|
|
29
|
+
def parse_expression(expression):
|
|
30
|
+
if not isinstance(expression, str) or len(expression) > 2000:
|
|
31
|
+
raise ValueError("自定义公式过长(最多 2000 字符)。")
|
|
32
|
+
tree = ast.parse(expression, mode="eval")
|
|
33
|
+
nodes = list(ast.walk(tree))
|
|
34
|
+
if len(nodes) > 500:
|
|
35
|
+
raise ValueError("公式过于复杂。")
|
|
36
|
+
for node in nodes:
|
|
37
|
+
if not isinstance(
|
|
38
|
+
node,
|
|
39
|
+
(
|
|
40
|
+
ast.Expression,
|
|
41
|
+
ast.BinOp,
|
|
42
|
+
ast.UnaryOp,
|
|
43
|
+
ast.Call,
|
|
44
|
+
ast.Name,
|
|
45
|
+
ast.Load,
|
|
46
|
+
ast.Constant,
|
|
47
|
+
ast.Add,
|
|
48
|
+
ast.Sub,
|
|
49
|
+
ast.Mult,
|
|
50
|
+
ast.Div,
|
|
51
|
+
ast.Pow,
|
|
52
|
+
ast.USub,
|
|
53
|
+
ast.UAdd,
|
|
54
|
+
),
|
|
55
|
+
):
|
|
56
|
+
raise ValueError("公式只允许 X/Y/Z、pi、数字、数学运算和已列出的函数。")
|
|
57
|
+
if isinstance(node, ast.Name) and node.id not in {*FUNCTIONS, "X", "Y", "Z", "pi"}:
|
|
58
|
+
raise ValueError(f"不支持的名称:{node.id}")
|
|
59
|
+
if isinstance(node, ast.Constant) and (
|
|
60
|
+
type(node.value) not in (int, float) or abs(node.value) > 1e6
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("公式常量必须是绝对值不超过 1e6 的数字。")
|
|
63
|
+
if isinstance(node, ast.Call) and (
|
|
64
|
+
not isinstance(node.func, ast.Name)
|
|
65
|
+
or node.func.id not in FUNCTIONS
|
|
66
|
+
or len(node.args) != 1
|
|
67
|
+
or node.keywords
|
|
68
|
+
):
|
|
69
|
+
raise ValueError("函数必须是单参数数学函数。")
|
|
70
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Pow):
|
|
71
|
+
if (
|
|
72
|
+
not isinstance(node.right, ast.Constant)
|
|
73
|
+
or not isinstance(node.right.value, (int, float))
|
|
74
|
+
or abs(node.right.value) > 8
|
|
75
|
+
):
|
|
76
|
+
raise ValueError("指数必须是 -8 到 8 之间的数值常量。")
|
|
77
|
+
return tree.body
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def evaluate(expression, X, Y, Z):
|
|
81
|
+
variables = {"X": X, "Y": Y, "Z": Z, "pi": np.pi}
|
|
82
|
+
|
|
83
|
+
def visit(node):
|
|
84
|
+
if isinstance(node, ast.Constant):
|
|
85
|
+
return node.value
|
|
86
|
+
if isinstance(node, ast.Name):
|
|
87
|
+
if node.id not in variables:
|
|
88
|
+
raise ValueError("A function name must be followed by parentheses")
|
|
89
|
+
return variables[node.id]
|
|
90
|
+
if isinstance(node, ast.BinOp):
|
|
91
|
+
return OPS[type(node.op)](visit(node.left), visit(node.right))
|
|
92
|
+
if isinstance(node, ast.UnaryOp):
|
|
93
|
+
return -visit(node.operand) if isinstance(node.op, ast.USub) else visit(node.operand)
|
|
94
|
+
if isinstance(node, ast.Call):
|
|
95
|
+
return FUNCTIONS[node.func.id](visit(node.args[0]))
|
|
96
|
+
raise ValueError("公式节点不受支持。")
|
|
97
|
+
|
|
98
|
+
with np.errstate(all="ignore"):
|
|
99
|
+
value = np.asarray(visit(parse_expression(expression)), dtype=np.float64)
|
|
100
|
+
if not np.all(np.isfinite(value)):
|
|
101
|
+
raise ValueError("公式在采样范围内出现无穷或无效值,请检查除法、开方和指数。")
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Config:
|
|
107
|
+
family: str = "Gyroid"
|
|
108
|
+
mode: str = "sheet"
|
|
109
|
+
size: tuple = (5.0, 5.0, 5.0)
|
|
110
|
+
cells: tuple = (1, 1, 1)
|
|
111
|
+
density_start: float = 0.25
|
|
112
|
+
density_end: float = 0.50
|
|
113
|
+
gradient: str = "linear"
|
|
114
|
+
axis: str = "z"
|
|
115
|
+
shape: str = "box"
|
|
116
|
+
resolution: int = 16
|
|
117
|
+
quality_strategy: str = "quality_fan"
|
|
118
|
+
domain_mode: str = "solid"
|
|
119
|
+
periodic_amplitudes: tuple = (0.05, 0.05, 0.05)
|
|
120
|
+
phase_degrees: tuple = (0.0, 0.0, 90.0)
|
|
121
|
+
custom: str = "sin(X)*cos(Y)+sin(Y)*cos(Z)+sin(Z)*cos(X)"
|
|
122
|
+
|
|
123
|
+
def __post_init__(self):
|
|
124
|
+
for name in ("size", "cells", "periodic_amplitudes", "phase_degrees"):
|
|
125
|
+
value = getattr(self, name)
|
|
126
|
+
if not isinstance(value, (list, tuple)):
|
|
127
|
+
raise ValueError(f"{name} must be a sequence of length 3")
|
|
128
|
+
object.__setattr__(self, name, tuple(value))
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def from_dict(cls, data):
|
|
132
|
+
if not isinstance(data, dict):
|
|
133
|
+
raise ValueError("参数必须是 JSON 对象。")
|
|
134
|
+
try:
|
|
135
|
+
c = cls(**data)
|
|
136
|
+
except TypeError as exc:
|
|
137
|
+
raise ValueError("存在未知参数字段。") from exc
|
|
138
|
+
c.validate()
|
|
139
|
+
return c
|
|
140
|
+
|
|
141
|
+
def validate(self):
|
|
142
|
+
if self.domain_mode not in ("solid", "solid_fluid"):
|
|
143
|
+
raise ValueError("domain_mode must be solid or solid_fluid")
|
|
144
|
+
if self.quality_strategy not in ("pulling", "quality_fan"):
|
|
145
|
+
raise ValueError("quality_strategy must be pulling or quality_fan")
|
|
146
|
+
if self.family not in FAMILIES and self.family != "Custom":
|
|
147
|
+
raise ValueError("未知曲面族。")
|
|
148
|
+
if self.mode not in ("sheet", "solid_above", "solid_below"):
|
|
149
|
+
raise ValueError("未知结构形式。")
|
|
150
|
+
if self.gradient not in (
|
|
151
|
+
"uniform",
|
|
152
|
+
"linear",
|
|
153
|
+
"quadratic",
|
|
154
|
+
"cubic",
|
|
155
|
+
"cosine",
|
|
156
|
+
"exponential",
|
|
157
|
+
"radial",
|
|
158
|
+
"periodic",
|
|
159
|
+
):
|
|
160
|
+
raise ValueError("未知梯度。")
|
|
161
|
+
if self.axis not in ("x", "y", "z"):
|
|
162
|
+
raise ValueError("未知梯度方向。")
|
|
163
|
+
if self.shape != "box":
|
|
164
|
+
raise ValueError("未知外形。")
|
|
165
|
+
if len(self.size) != 3 or any(
|
|
166
|
+
type(v) not in (int, float) or not np.isfinite(v) or not 0.1 <= v <= 1000
|
|
167
|
+
for v in self.size
|
|
168
|
+
):
|
|
169
|
+
raise ValueError("三个外形尺寸必须在 0.1–1000 mm 之间。")
|
|
170
|
+
if len(self.cells) != 3 or any(type(v) is not int or not 1 <= v <= 6 for v in self.cells):
|
|
171
|
+
raise ValueError("胞元数必须为 1–6 的整数。")
|
|
172
|
+
if type(self.resolution) is not int or not 8 <= self.resolution <= 64:
|
|
173
|
+
raise ValueError("每胞元采样数必须为 8–64 的整数。")
|
|
174
|
+
if np.prod(np.array(self.cells) * self.resolution) > 200_000:
|
|
175
|
+
raise ValueError("背景立方网格超过 20 万,请减少胞元数或分辨率。")
|
|
176
|
+
for v in (self.density_start, self.density_end):
|
|
177
|
+
if type(v) not in (int, float) or not np.isfinite(v) or not 0.08 <= v <= 0.85:
|
|
178
|
+
raise ValueError("相对密度必须在 0.08–0.85 之间。")
|
|
179
|
+
if len(self.periodic_amplitudes) != 3 or any(
|
|
180
|
+
type(v) not in (int, float) or not np.isfinite(v) or abs(v) > 0.4
|
|
181
|
+
for v in self.periodic_amplitudes
|
|
182
|
+
):
|
|
183
|
+
raise ValueError("周期梯度幅值必须为三个绝对值不超过 0.4 的有限数。")
|
|
184
|
+
if self.gradient == "periodic" and (
|
|
185
|
+
self.density_start - sum(map(abs, self.periodic_amplitudes)) < 0.08 - 1e-10
|
|
186
|
+
or self.density_start + sum(map(abs, self.periodic_amplitudes)) > 0.85 + 1e-10
|
|
187
|
+
):
|
|
188
|
+
raise ValueError("周期梯度的密度范围超出 0.08–0.85,请减小幅值或调整平均密度。")
|
|
189
|
+
if len(self.phase_degrees) != 3 or any(
|
|
190
|
+
type(v) not in (int, float) or not np.isfinite(v) or abs(v) > 360
|
|
191
|
+
for v in self.phase_degrees
|
|
192
|
+
):
|
|
193
|
+
raise ValueError("相位必须是三个 -360 到 360 度之间的数。")
|
|
194
|
+
parse_expression(self.expression)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def expression(self):
|
|
198
|
+
return self.custom if self.family == "Custom" else FAMILIES[self.family]["expression"]
|
|
199
|
+
|
|
200
|
+
def to_dict(self):
|
|
201
|
+
return asdict(self)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@lru_cache(maxsize=64)
|
|
205
|
+
def calibration(expression, mode):
|
|
206
|
+
# Midpoints avoid bias from duplicated periodic endpoints.
|
|
207
|
+
a = (np.arange(56) + 0.5) * (2 * np.pi / 56)
|
|
208
|
+
values = evaluate(expression, a[:, None, None], a[None, :, None], a[None, None, :])
|
|
209
|
+
values = np.broadcast_to(values, (56, 56, 56))
|
|
210
|
+
if mode == "sheet":
|
|
211
|
+
values = np.abs(values)
|
|
212
|
+
elif mode == "solid_above":
|
|
213
|
+
values = -values
|
|
214
|
+
values = np.sort(values.ravel())
|
|
215
|
+
if np.ptp(values) < 1e-10:
|
|
216
|
+
raise ValueError("公式场是常量,不能生成指定密度的结构。")
|
|
217
|
+
return np.linspace(0, 1, len(values)), values
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def target_density(config, X, Y, Z):
|
|
221
|
+
pos = (X, Y, Z)
|
|
222
|
+
if config.gradient == "periodic":
|
|
223
|
+
return config.density_start + sum(
|
|
224
|
+
a * np.cos(2 * np.pi * p / L)
|
|
225
|
+
for a, p, L in zip(config.periodic_amplitudes, pos, config.size)
|
|
226
|
+
)
|
|
227
|
+
k = "xyz".index(config.axis)
|
|
228
|
+
t = np.clip(pos[k] / config.size[k], 0, 1)
|
|
229
|
+
if config.gradient == "uniform":
|
|
230
|
+
t = np.zeros_like(t)
|
|
231
|
+
elif config.gradient == "quadratic":
|
|
232
|
+
t = t**2
|
|
233
|
+
elif config.gradient == "cubic":
|
|
234
|
+
t = t**3
|
|
235
|
+
elif config.gradient == "cosine":
|
|
236
|
+
t = (1 - np.cos(np.pi * t)) / 2
|
|
237
|
+
elif config.gradient == "exponential":
|
|
238
|
+
t = np.expm1(3 * t) / np.expm1(3)
|
|
239
|
+
elif config.gradient == "radial":
|
|
240
|
+
axes = [i for i in range(3) if i != k]
|
|
241
|
+
radius = min(config.size[i] for i in axes) / 2
|
|
242
|
+
t = np.clip(np.sqrt(sum((pos[i] - config.size[i] / 2) ** 2 for i in axes)) / radius, 0, 1)
|
|
243
|
+
return config.density_start + (config.density_end - config.density_start) * t
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def raw_field(config, X, Y, Z):
|
|
247
|
+
angles = [
|
|
248
|
+
2 * np.pi * p * n / L + np.deg2rad(phase)
|
|
249
|
+
for p, n, L, phase in zip((X, Y, Z), config.cells, config.size, config.phase_degrees)
|
|
250
|
+
]
|
|
251
|
+
f = evaluate(config.expression, *angles)
|
|
252
|
+
probabilities, values = calibration(config.expression, config.mode)
|
|
253
|
+
q = np.interp(target_density(config, X, Y, Z), probabilities, values)
|
|
254
|
+
if config.mode == "sheet":
|
|
255
|
+
f = np.abs(f)
|
|
256
|
+
elif config.mode == "solid_above":
|
|
257
|
+
f = -f
|
|
258
|
+
return q - f # Positive means solid material.
|
tpmslab/multidomain.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Complementary solid/fluid meshes sharing exact cut-edge identities."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
import hashlib
|
|
5
|
+
import time
|
|
6
|
+
import numpy as np
|
|
7
|
+
import trimesh
|
|
8
|
+
from scipy.sparse import coo_matrix
|
|
9
|
+
from scipy.sparse.csgraph import connected_components
|
|
10
|
+
from .volume import generate_volume, TET_FACES, EDGES, boundary_digest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_solid_fluid(config, progress):
|
|
14
|
+
start = time.monotonic()
|
|
15
|
+
single = replace(config, domain_mode="solid")
|
|
16
|
+
progress("生成固体域及互补孔隙流体域…")
|
|
17
|
+
parts = [generate_volume(single, progress)]
|
|
18
|
+
for side in range(2 if config.mode == "sheet" else 1):
|
|
19
|
+
part = generate_volume(single, progress, _fluid_side=side)
|
|
20
|
+
if part is not None:
|
|
21
|
+
parts.append(part)
|
|
22
|
+
points, lookup, cells, phases = [], {}, [], []
|
|
23
|
+
for part_index, part in enumerate(parts):
|
|
24
|
+
remap = []
|
|
25
|
+
for key, point in zip(part["vertex_keys"], part["points"]):
|
|
26
|
+
# Interior fan vertices belong to a single clipped region.
|
|
27
|
+
key = (*key, part_index) if key[0] == 2 else key
|
|
28
|
+
if key not in lookup:
|
|
29
|
+
lookup[key] = len(points)
|
|
30
|
+
points.append(point)
|
|
31
|
+
elif not np.allclose(points[lookup[key]], point, rtol=0, atol=max(config.size) * 1e-12):
|
|
32
|
+
raise ValueError("Solid/fluid cut vertex coordinates disagree")
|
|
33
|
+
remap.append(lookup[key])
|
|
34
|
+
cells.append(np.asarray(remap)[part["tetra"]])
|
|
35
|
+
phases.extend([1 if part_index == 0 else 2] * len(part["tetra"]))
|
|
36
|
+
points = np.asarray(points)
|
|
37
|
+
tetra = np.concatenate(cells).astype(np.int32)
|
|
38
|
+
phases = np.asarray(phases, dtype=np.int32)
|
|
39
|
+
faces = tetra[:, TET_FACES].reshape(-1, 3)
|
|
40
|
+
_, inverse, counts = np.unique(
|
|
41
|
+
np.sort(faces, axis=1), axis=0, return_inverse=True, return_counts=True
|
|
42
|
+
)
|
|
43
|
+
if counts.max() > 2:
|
|
44
|
+
raise ValueError("Nonmanifold solid/fluid partition")
|
|
45
|
+
order = np.argsort(inverse, kind="stable")
|
|
46
|
+
starts = np.r_[0, np.cumsum(counts)[:-1]]
|
|
47
|
+
exterior = order[starts[counts == 1]]
|
|
48
|
+
pairs = starts[counts == 2]
|
|
49
|
+
a, b = order[pairs], order[pairs + 1]
|
|
50
|
+
interface_pair = phases[a // 4] != phases[b // 4]
|
|
51
|
+
# Consistent positive tetrahedron orientation implies opposite face normals.
|
|
52
|
+
fa, fb = faces[a], faces[b]
|
|
53
|
+
na = np.cross(points[fa[:, 1]] - points[fa[:, 0]], points[fa[:, 2]] - points[fa[:, 0]])
|
|
54
|
+
nb = np.cross(points[fb[:, 1]] - points[fb[:, 0]], points[fb[:, 2]] - points[fb[:, 0]])
|
|
55
|
+
if np.any(np.einsum("ij,ij->i", na, nb) >= 0):
|
|
56
|
+
raise ValueError("Overlapping or inconsistently oriented adjacent cells")
|
|
57
|
+
interface = np.where(phases[a[interface_pair] // 4] == 1, a[interface_pair], b[interface_pair])
|
|
58
|
+
if not len(interface):
|
|
59
|
+
raise ValueError("No solid-fluid interface generated")
|
|
60
|
+
exterior_faces = faces[exterior]
|
|
61
|
+
side_ids = np.zeros(len(exterior), dtype=np.int32)
|
|
62
|
+
tol = max(config.size) * 1e-9
|
|
63
|
+
for axis in range(3):
|
|
64
|
+
for side, value in enumerate((0, config.size[axis])):
|
|
65
|
+
mask = np.all(np.abs(points[exterior_faces, axis] - value) < tol, axis=1)
|
|
66
|
+
side_ids[mask] = 2 + 2 * axis + side
|
|
67
|
+
if np.any(side_ids == 0):
|
|
68
|
+
raise ValueError("Solid/fluid partition has an internal crack or unmatched interface")
|
|
69
|
+
side_ids += np.where(phases[exterior // 4] == 2, 10, 0)
|
|
70
|
+
boundary = np.concatenate([exterior_faces, faces[interface]])
|
|
71
|
+
boundary_ids = np.r_[side_ids, np.full(len(interface), 8)].astype(np.int32)
|
|
72
|
+
same = ~interface_pair
|
|
73
|
+
graph = coo_matrix(
|
|
74
|
+
(np.ones(np.sum(same)), (a[same] // 4, b[same] // 4)), shape=(len(tetra), len(tetra))
|
|
75
|
+
)
|
|
76
|
+
components, labels = connected_components(graph, directed=False)
|
|
77
|
+
# Number all solid components first, then all fluid components.
|
|
78
|
+
_, representatives = np.unique(labels, return_index=True)
|
|
79
|
+
raw = sorted(range(components), key=lambda k: (int(phases[representatives[k]]), k))
|
|
80
|
+
mapping = np.empty(components, dtype=np.int32)
|
|
81
|
+
mapping[raw] = np.arange(1, components + 1)
|
|
82
|
+
domains = mapping[labels]
|
|
83
|
+
p = points[tetra]
|
|
84
|
+
volumes = (
|
|
85
|
+
np.einsum("ij,ij->i", p[:, 1] - p[:, 0], np.cross(p[:, 2] - p[:, 0], p[:, 3] - p[:, 0])) / 6
|
|
86
|
+
)
|
|
87
|
+
expected = float(np.prod(config.size))
|
|
88
|
+
if np.any(volumes <= 0) or not np.isclose(volumes.sum(), expected, rtol=1e-10):
|
|
89
|
+
raise ValueError("Solid and fluid do not form a positive-volume partition of the box")
|
|
90
|
+
domain_map = []
|
|
91
|
+
for d in np.unique(domains):
|
|
92
|
+
mask = domains == d
|
|
93
|
+
phase = "solid" if phases[mask][0] == 1 else "fluid"
|
|
94
|
+
touching = sorted(set(map(int, side_ids[domains[exterior // 4] == d])))
|
|
95
|
+
domain_map.append(
|
|
96
|
+
dict(
|
|
97
|
+
id=int(d),
|
|
98
|
+
nastran_pid=100 + int(d),
|
|
99
|
+
phase=phase,
|
|
100
|
+
tetrahedra=int(mask.sum()),
|
|
101
|
+
volume_mm3=float(volumes[mask].sum()),
|
|
102
|
+
exterior_boundary_ids=touching,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
quality = (
|
|
106
|
+
12
|
|
107
|
+
* (3 * volumes) ** (2 / 3)
|
|
108
|
+
/ sum(np.sum((p[:, a] - p[:, b]) ** 2, axis=1) for a, b in EDGES)
|
|
109
|
+
)
|
|
110
|
+
solid_volume = float(volumes[phases == 1].sum())
|
|
111
|
+
fluid_volume = float(volumes[phases == 2].sum())
|
|
112
|
+
solid_faces = np.concatenate([exterior_faces[phases[exterior // 4] == 1], faces[interface]])
|
|
113
|
+
fluid_faces = np.concatenate(
|
|
114
|
+
[exterior_faces[phases[exterior // 4] == 2], faces[interface][:, [0, 2, 1]]]
|
|
115
|
+
)
|
|
116
|
+
surface = trimesh.Trimesh(points.copy(), solid_faces, process=False)
|
|
117
|
+
surface.remove_unreferenced_vertices()
|
|
118
|
+
fluid_surface = trimesh.Trimesh(points.copy(), fluid_faces, process=False)
|
|
119
|
+
fluid_surface.remove_unreferenced_vertices()
|
|
120
|
+
report = dict(parts[0]["report"])
|
|
121
|
+
report.update(
|
|
122
|
+
config=config.to_dict(),
|
|
123
|
+
representation="conforming complementary solid/fluid tetrahedral domains",
|
|
124
|
+
vertices=len(points),
|
|
125
|
+
bounds_mm=[points.min(axis=0).tolist(), points.max(axis=0).tolist()],
|
|
126
|
+
tetrahedra=len(tetra),
|
|
127
|
+
triangles=len(boundary),
|
|
128
|
+
volume_components=components,
|
|
129
|
+
solid_components=sum(x["phase"] == "solid" for x in domain_map),
|
|
130
|
+
fluid_components=sum(x["phase"] == "fluid" for x in domain_map),
|
|
131
|
+
domain_map=domain_map,
|
|
132
|
+
volume_mm3=solid_volume,
|
|
133
|
+
solid_volume_mm3=solid_volume,
|
|
134
|
+
fluid_volume_mm3=fluid_volume,
|
|
135
|
+
total_mesh_volume_mm3=float(volumes.sum()),
|
|
136
|
+
actual_density=solid_volume / expected,
|
|
137
|
+
porosity=fluid_volume / expected,
|
|
138
|
+
partition_volume_error_mm3=float(abs(volumes.sum() - expected)),
|
|
139
|
+
interface_triangles=len(interface),
|
|
140
|
+
interface_conforming=True,
|
|
141
|
+
interface_invalid_triangles=int(np.sum(counts[inverse[interface]] != 2)),
|
|
142
|
+
interface_area_mm2=float(np.linalg.norm(na[interface_pair], axis=1).sum() / 2),
|
|
143
|
+
boundary_tag_names={
|
|
144
|
+
**{
|
|
145
|
+
str(2 + i): "solid_" + name
|
|
146
|
+
for i, name in enumerate(("xmin", "xmax", "ymin", "ymax", "zmin", "zmax"))
|
|
147
|
+
},
|
|
148
|
+
**{
|
|
149
|
+
str(12 + i): "fluid_" + name
|
|
150
|
+
for i, name in enumerate(("xmin", "xmax", "ymin", "ymax", "zmin", "zmax"))
|
|
151
|
+
},
|
|
152
|
+
"8": "solid_fluid_interface",
|
|
153
|
+
},
|
|
154
|
+
minimum_tetra_volume_mm3=float(volumes.min()),
|
|
155
|
+
quality_quantiles=dict(
|
|
156
|
+
zip(
|
|
157
|
+
("min", "p01", "median", "max"), map(float, np.quantile(quality, [0, 0.01, 0.5, 1]))
|
|
158
|
+
)
|
|
159
|
+
),
|
|
160
|
+
low_quality_count_below_001=int(np.sum(quality < 0.01)),
|
|
161
|
+
low_quality_volume_fraction_below_001=float(volumes[quality < 0.01].sum() / expected),
|
|
162
|
+
improved_cut_cells=sum(part["report"]["improved_cut_cells"] for part in parts),
|
|
163
|
+
minimum_selected_local_quality_ratio=None,
|
|
164
|
+
mesh_sha256=hashlib.sha256(
|
|
165
|
+
points.astype("<f8").tobytes()
|
|
166
|
+
+ tetra.astype("<i4").tobytes()
|
|
167
|
+
+ domains.astype("<i4").tobytes()
|
|
168
|
+
).hexdigest(),
|
|
169
|
+
boundary_sha256=boundary_digest(points, boundary),
|
|
170
|
+
schema_version=2,
|
|
171
|
+
generator_version="0.3.0rc1",
|
|
172
|
+
elapsed_seconds=round(time.monotonic() - start, 3),
|
|
173
|
+
)
|
|
174
|
+
report["warnings"] = [w for w in report["warnings"] if "静力学演示" not in w]
|
|
175
|
+
report["warnings"] += [
|
|
176
|
+
"固体与流体仅填充同一长方体外形内部;不包含外部流场或入口缓冲段。",
|
|
177
|
+
"固液界面为共享节点的共形网格;流体边界层和流动网格收敛尚需按工况检查。",
|
|
178
|
+
"流体可能含多个独立流道或封闭孔隙,应按连通域选择进出口。",
|
|
179
|
+
]
|
|
180
|
+
progress(
|
|
181
|
+
f"双域分区通过:{report['solid_components']} 个固体域,{report['fluid_components']} 个流体域,共享 {len(interface):,} 个界面三角形。"
|
|
182
|
+
)
|
|
183
|
+
return dict(
|
|
184
|
+
points=points,
|
|
185
|
+
tetra=tetra,
|
|
186
|
+
boundary=boundary,
|
|
187
|
+
boundary_ids=boundary_ids,
|
|
188
|
+
domains=domains,
|
|
189
|
+
phase_ids=phases,
|
|
190
|
+
interface=faces[interface],
|
|
191
|
+
solid_boundary=solid_faces,
|
|
192
|
+
fluid_boundary=fluid_faces,
|
|
193
|
+
surface=surface,
|
|
194
|
+
fluid_surface=fluid_surface,
|
|
195
|
+
report=report,
|
|
196
|
+
)
|
tpmslab/py.typed
ADDED
|
File without changes
|