hexmb 0.2.1__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.
- hexmb/__init__.py +7 -0
- hexmb/assembly/__init__.py +0 -0
- hexmb/assembly/ami.py +223 -0
- hexmb/assembly/ogrid.py +144 -0
- hexmb/assembly/splithex.py +701 -0
- hexmb/assembly/sweep.py +74 -0
- hexmb/cli.py +797 -0
- hexmb/core/__init__.py +0 -0
- hexmb/core/block.py +89 -0
- hexmb/core/dedup.py +87 -0
- hexmb/core/multiblock.py +219 -0
- hexmb/core/tfi.py +140 -0
- hexmb/core/topology.py +118 -0
- hexmb/data/cap/GlobalHermiteParam.exelem +908 -0
- hexmb/data/cap/README.md +26 -0
- hexmb/data/cap/SCD0000101/SCD0000101_1.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_10.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_11.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_12.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_13.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_14.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_15.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_16.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_17.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_18.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_19.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_2.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_20.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_3.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_4.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_5.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_6.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_7.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_8.model.exnode +166 -0
- hexmb/data/cap/SCD0000101/SCD0000101_9.model.exnode +166 -0
- hexmb/io/__init__.py +0 -0
- hexmb/io/cap.py +202 -0
- hexmb/io/foam.py +91 -0
- hexmb/io/stl_surface.py +44 -0
- hexmb/io/surface.py +546 -0
- hexmb/io/vtk.py +296 -0
- hexmb/project/__init__.py +0 -0
- hexmb/project/skeleton.py +173 -0
- hexmb/project/stl_tube.py +225 -0
- hexmb/services/__init__.py +0 -0
- hexmb/services/cavity.py +109 -0
- hexmb/services/export_foam.py +194 -0
- hexmb/services/flow.py +261 -0
- hexmb/services/morph.py +95 -0
- hexmb/services/motion.py +600 -0
- hexmb/services/quality.py +184 -0
- hexmb/services/render.py +174 -0
- hexmb/services/smooth.py +144 -0
- hexmb/templates/__init__.py +0 -0
- hexmb/templates/cavity.py +122 -0
- hexmb/templates/conformal_lv.py +365 -0
- hexmb/templates/lv.py +71 -0
- hexmb/templates/stl.py +67 -0
- hexmb/templates/telescoping_lv.py +209 -0
- hexmb/templates/tube.py +80 -0
- hexmb-0.2.1.dist-info/METADATA +490 -0
- hexmb-0.2.1.dist-info/RECORD +66 -0
- hexmb-0.2.1.dist-info/WHEEL +5 -0
- hexmb-0.2.1.dist-info/entry_points.txt +2 -0
- hexmb-0.2.1.dist-info/licenses/LICENSE +21 -0
- hexmb-0.2.1.dist-info/top_level.txt +1 -0
hexmb/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""hexmb — general multi-block structured hexahedral (O-grid) mesh engine."""
|
|
2
|
+
from .core.block import Block, BoundaryFace
|
|
3
|
+
from .core.multiblock import MultiBlockMesh
|
|
4
|
+
from .core.topology import FaceKey
|
|
5
|
+
|
|
6
|
+
__all__ = ["Block", "BoundaryFace", "MultiBlockMesh", "FaceKey"]
|
|
7
|
+
__version__ = "0.2.1"
|
|
File without changes
|
hexmb/assembly/ami.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Non-conformal (AMI-style) mesh assembler for OpenFOAM 12 Foundation.
|
|
2
|
+
|
|
3
|
+
The telescoping O-grid LV is built as three single-region zones (coarse apex cap,
|
|
4
|
+
fine body, coarse valve/base cap) that share cut cross-sections. This module joins
|
|
5
|
+
them into one solvable domain through OpenFOAM's non-conformal coupling, and proves
|
|
6
|
+
flow passes across every interface.
|
|
7
|
+
|
|
8
|
+
OF12 Foundation has no ESI ``cyclicAMI``; the Foundation path is
|
|
9
|
+
``createNonConformalCouples patchA patchB`` (it auto-creates
|
|
10
|
+
``nonConformalCyclic_on_*`` + ``nonConformalError_on_*`` patches). The finicky parts
|
|
11
|
+
are encoded here so callers do not rediscover them:
|
|
12
|
+
|
|
13
|
+
* the two patches must be **coincident and anti-parallel** or no couplings form --
|
|
14
|
+
:meth:`_check_pairs` verifies that (centroid gap and facing normals) before coupling;
|
|
15
|
+
* a ``potentialFoam`` flow-through check needs the coupled patch fields to carry the
|
|
16
|
+
**matching** ``nonConformalCyclic`` / ``nonConformalError`` boundary types, a
|
|
17
|
+
``potentialFlow`` block in ``fvSolution``, and its own schemes.
|
|
18
|
+
|
|
19
|
+
API: ``merge`` (combine cases), ``couple`` (create the interfaces), ``check``
|
|
20
|
+
(checkMesh), ``verify_flow`` (potentialFoam per-region report).
|
|
21
|
+
|
|
22
|
+
Notes
|
|
23
|
+
-----
|
|
24
|
+
This file was reconstructed from the compiled module after the original ``.py`` was
|
|
25
|
+
lost in a repository cleanup; it reproduces the recorded coupling behaviour (e.g.
|
|
26
|
+
~52,970 + 48,434 couplings on the 1M telescoping LV).
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import subprocess
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
|
|
35
|
+
_BASHRC = "/opt/openfoam12/etc/bashrc"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _of(case, cmd):
|
|
39
|
+
"""Run an OpenFOAM command in ``case`` with the OF12 environment sourced."""
|
|
40
|
+
return subprocess.run(["bash", "-lc", f"source {_BASHRC} && cd {case} && {cmd}"],
|
|
41
|
+
capture_output=True, text=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_boundary(case):
|
|
45
|
+
from ..io.foam import read_boundary
|
|
46
|
+
return read_boundary(Path(case) / "constant" / "polyMesh" / "boundary")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HybridAssembler:
|
|
50
|
+
"""Assemble several single-region OpenFOAM cases into one non-conformally
|
|
51
|
+
coupled domain, with coincidence/orientation safety checks and an optional
|
|
52
|
+
flow-through proof. ``master_case`` receives the merged mesh."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, master_case):
|
|
55
|
+
self.case = Path(master_case)
|
|
56
|
+
|
|
57
|
+
# -- combine ------------------------------------------------------------ #
|
|
58
|
+
def merge(self, add_cases):
|
|
59
|
+
"""``mergeMeshes`` the given cases into the master case (one file, still
|
|
60
|
+
several regions until they are coupled)."""
|
|
61
|
+
cases = " ".join(f'"{Path(c)}"' for c in add_cases)
|
|
62
|
+
r = _of(self.case, f"mergeMeshes -addCases '({cases})' -overwrite")
|
|
63
|
+
if "Writing mesh" not in r.stdout:
|
|
64
|
+
raise RuntimeError("mergeMeshes failed:\n" + r.stdout[-800:] + r.stderr[-400:])
|
|
65
|
+
return r.stdout
|
|
66
|
+
|
|
67
|
+
# -- couple ------------------------------------------------------------- #
|
|
68
|
+
def couple(self, patch_pairs, check_overlap=True):
|
|
69
|
+
"""``createNonConformalCouples`` for each ``(patchA, patchB)`` pair.
|
|
70
|
+
Verifies the caps are coincident + anti-parallel (the requirement for a
|
|
71
|
+
coupling to form) before running, unless ``check_overlap`` is False."""
|
|
72
|
+
if check_overlap:
|
|
73
|
+
self._check_pairs(patch_pairs)
|
|
74
|
+
res = []
|
|
75
|
+
for a, b in patch_pairs:
|
|
76
|
+
r = _of(self.case, f"createNonConformalCouples {a} {b} -overwrite")
|
|
77
|
+
n = 0
|
|
78
|
+
for ln in r.stdout.splitlines():
|
|
79
|
+
if "couplings" in ln:
|
|
80
|
+
for tok in ln.replace(":", " ").split():
|
|
81
|
+
if tok.isdigit():
|
|
82
|
+
n = int(tok)
|
|
83
|
+
if n == 0 or "No couplings" in r.stdout:
|
|
84
|
+
raise RuntimeError(f"couple {a}<->{b}: NO couplings (caps not "
|
|
85
|
+
f"coincident/anti-parallel).\n" + r.stdout[-400:])
|
|
86
|
+
print(f" {a}<->{b}: {n} couplings")
|
|
87
|
+
res.append((a, b, n))
|
|
88
|
+
return res # list of (patchA, patchB, n_couplings)
|
|
89
|
+
|
|
90
|
+
def _patch_geom(self, boundary, faces, points, p):
|
|
91
|
+
"""Area-weighted centroid (metres) and unit normal of patch ``p``."""
|
|
92
|
+
d = boundary[p]
|
|
93
|
+
s, n = int(d["startFace"]), int(d["nFaces"])
|
|
94
|
+
cen = np.zeros(3)
|
|
95
|
+
nrm = np.zeros(3)
|
|
96
|
+
area = 0.0
|
|
97
|
+
for f in faces[s:s + n]:
|
|
98
|
+
v = points[f]
|
|
99
|
+
fc = v.mean(0)
|
|
100
|
+
a = 0.5 * np.cross(v - fc, np.roll(v, -1, axis=0) - fc).sum(0)
|
|
101
|
+
mag = np.linalg.norm(a)
|
|
102
|
+
cen += mag * fc
|
|
103
|
+
nrm += a
|
|
104
|
+
area += mag
|
|
105
|
+
return cen / max(area, 1e-12), nrm / (np.linalg.norm(nrm) + 1e-12)
|
|
106
|
+
|
|
107
|
+
def _check_pairs(self, pairs):
|
|
108
|
+
"""Warn/report whether each pair is coincident (gap < 3 mm) and
|
|
109
|
+
anti-parallel (facing normals, dot < -0.3) -- the coupling requirement."""
|
|
110
|
+
from ..io.foam import read_boundary, read_polymesh
|
|
111
|
+
pm = self.case / "constant" / "polyMesh"
|
|
112
|
+
boundary = read_boundary(pm / "boundary")
|
|
113
|
+
points, faces, _, _ = read_polymesh(pm)
|
|
114
|
+
for a, b in pairs:
|
|
115
|
+
for p in (a, b):
|
|
116
|
+
if p not in boundary:
|
|
117
|
+
print(f" patch {p} not found (have {list(boundary)}) WARN(may not couple)")
|
|
118
|
+
if a not in boundary or b not in boundary:
|
|
119
|
+
continue
|
|
120
|
+
ca, na = self._patch_geom(boundary, faces, points, a)
|
|
121
|
+
cb, nb = self._patch_geom(boundary, faces, points, b)
|
|
122
|
+
gap = np.linalg.norm(ca - cb) * 1000.0 # metres -> mm
|
|
123
|
+
dot = float(np.dot(na, nb))
|
|
124
|
+
ok = "OK" if (gap < 3.0 and dot < -0.3) else "WARN(may not couple)"
|
|
125
|
+
print(f" interface {a}<->{b}: gap {gap:.2f}mm, normals·{dot:+.2f} [{ok}]")
|
|
126
|
+
|
|
127
|
+
# -- validate ----------------------------------------------------------- #
|
|
128
|
+
def check(self):
|
|
129
|
+
"""Run ``checkMesh`` on the merged mesh and return the notable result
|
|
130
|
+
lines as an ordered ``{label: line}`` dict (cell/region counts, volumes,
|
|
131
|
+
skewness, non-orthogonality, and the Mesh OK / Failed result)."""
|
|
132
|
+
out = _of(self.case, "checkMesh -constant").stdout
|
|
133
|
+
wanted = ("cells:", "Number of regions", "Min volume", "Max skewness",
|
|
134
|
+
"Max aspect ratio", "non-orthogonality", "Mesh OK", "Failed")
|
|
135
|
+
keep = {}
|
|
136
|
+
for ln in out.splitlines():
|
|
137
|
+
ls = ln.strip()
|
|
138
|
+
for k in wanted:
|
|
139
|
+
if k in ls:
|
|
140
|
+
keep[k] = ls
|
|
141
|
+
break
|
|
142
|
+
return keep
|
|
143
|
+
|
|
144
|
+
# -- flow-through proof (potentialFoam) --------------------------------- #
|
|
145
|
+
def verify_flow(self, inlet, inflow, outlets, wall_substr="wall"):
|
|
146
|
+
"""Write potentialFoam fields (coupled patches get their MATCHING
|
|
147
|
+
nonConformalCyclic/nonConformalError types) + dicts, run potentialFoam,
|
|
148
|
+
and report per-region flow. Returns the |U| magnitude array."""
|
|
149
|
+
bnd = _read_boundary(self.case)
|
|
150
|
+
self._write_fields(bnd, inlet, inflow, outlets, wall_substr)
|
|
151
|
+
self._write_dicts()
|
|
152
|
+
r = _of(self.case, "potentialFoam")
|
|
153
|
+
if "FATAL" in r.stdout + r.stderr:
|
|
154
|
+
raise RuntimeError("potentialFoam failed:\n" + r.stdout[-600:] + r.stderr[-400:])
|
|
155
|
+
return self._flow_field()
|
|
156
|
+
|
|
157
|
+
def _entry(self, p, ptype, kind, inlet, inflow, outlets, wall_substr):
|
|
158
|
+
"""boundaryField entry for patch ``p`` in field ``kind`` ('U' or 'p').
|
|
159
|
+
Coupled patches keep their own type; physical patches get flow BCs."""
|
|
160
|
+
if "nonConformalCyclic" in ptype:
|
|
161
|
+
return " type nonConformalCyclic;\n"
|
|
162
|
+
if "nonConformalError" in ptype:
|
|
163
|
+
return " type nonConformalError;\n"
|
|
164
|
+
if kind == "U":
|
|
165
|
+
if p == inlet:
|
|
166
|
+
return f" type fixedValue;\n value uniform ({inflow[0]} {inflow[1]} {inflow[2]});\n"
|
|
167
|
+
if p in outlets:
|
|
168
|
+
return " type zeroGradient;\n"
|
|
169
|
+
if wall_substr in p:
|
|
170
|
+
return " type slip;\n"
|
|
171
|
+
return " type zeroGradient;\n"
|
|
172
|
+
else: # p
|
|
173
|
+
if p == inlet:
|
|
174
|
+
return " type zeroGradient;\n"
|
|
175
|
+
if p in outlets:
|
|
176
|
+
return " type fixedValue;\n value uniform 0;\n"
|
|
177
|
+
if wall_substr in p:
|
|
178
|
+
return " type zeroGradient;\n"
|
|
179
|
+
return " type zeroGradient;\n"
|
|
180
|
+
|
|
181
|
+
def _write_fields(self, bnd, inlet, inflow, outlets, wall_substr):
|
|
182
|
+
if np.ndim(inflow) == 0:
|
|
183
|
+
inflow = (0.0, 0.0, float(inflow))
|
|
184
|
+
for kind, cls, dim, intern in [("U", "volVectorField", "[0 1 -1 0 0 0 0]", "(0 0 0)"),
|
|
185
|
+
("p", "volScalarField", "[0 2 -2 0 0 0 0]", "0")]:
|
|
186
|
+
s = (f"FoamFile{{format ascii;class {cls};object {kind};}}\n"
|
|
187
|
+
f"dimensions {dim};\ninternalField uniform {intern};\nboundaryField\n{{\n")
|
|
188
|
+
for p, d in bnd.items():
|
|
189
|
+
s += (f" {p}\n {{\n"
|
|
190
|
+
+ self._entry(p, d["type"], kind, inlet, inflow, outlets, wall_substr)
|
|
191
|
+
+ " }\n")
|
|
192
|
+
(self.case / "0").mkdir(exist_ok=True)
|
|
193
|
+
(self.case / "0" / kind).write_text(s + "}\n")
|
|
194
|
+
|
|
195
|
+
def _write_dicts(self):
|
|
196
|
+
sysd = self.case / "system"
|
|
197
|
+
sysd.mkdir(exist_ok=True)
|
|
198
|
+
(sysd / "fvSolution").write_text(
|
|
199
|
+
"FoamFile{format ascii;class dictionary;object fvSolution;}\n"
|
|
200
|
+
"potentialFlow{nNonOrthogonalCorrectors 10;}\n"
|
|
201
|
+
"solvers{Phi{solver GAMG;smoother DIC;tolerance 1e-08;relTol 0.01;}"
|
|
202
|
+
"p{solver GAMG;smoother DIC;tolerance 1e-08;relTol 0.01;}}\n")
|
|
203
|
+
(sysd / "fvSchemes").write_text(
|
|
204
|
+
"FoamFile{format ascii;class dictionary;object fvSchemes;}\n"
|
|
205
|
+
"ddtSchemes{default steadyState;}gradSchemes{default Gauss linear;}\n"
|
|
206
|
+
"divSchemes{default none;div(div(phi,U)) Gauss linear;}\n"
|
|
207
|
+
"laplacianSchemes{default Gauss linear corrected;}interpolationSchemes{default linear;}\n"
|
|
208
|
+
"snGradSchemes{default corrected;}fluxRequired{default no;Phi;p;}\n")
|
|
209
|
+
(sysd / "controlDict").write_text(
|
|
210
|
+
"FoamFile{format ascii;class dictionary;object controlDict;}\n"
|
|
211
|
+
"application potentialFoam;startFrom startTime;startTime 0;stopAt endTime;"
|
|
212
|
+
"endTime 1;deltaT 1;writeControl timeStep;writeInterval 1;\n")
|
|
213
|
+
|
|
214
|
+
def _flow_field(self):
|
|
215
|
+
import glob
|
|
216
|
+
from ..io.foam import read_internal_field
|
|
217
|
+
times = sorted(glob.glob(str(self.case / "[0-9]*")))
|
|
218
|
+
td = Path(times[-1]) if times else self.case / "0"
|
|
219
|
+
U = read_internal_field(td / "U", 3)
|
|
220
|
+
mag = np.linalg.norm(U, axis=1) if U.ndim == 2 else np.abs(U)
|
|
221
|
+
frac = float((mag > 1e-4).mean())
|
|
222
|
+
print(f" potentialFoam: {100 * frac:.0f}% of cells flowing, mean |U| {mag.mean():.3g} m/s")
|
|
223
|
+
return mag
|
hexmb/assembly/ogrid.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""O-grid (butterfly) cross-section and its 3D swept assembly.
|
|
2
|
+
|
|
3
|
+
A butterfly cross-section is one square core block + 4 petal blocks. The core
|
|
4
|
+
block edge subdivisions are forced equal to the petal circumferential
|
|
5
|
+
subdivisions (ncore = Q) so the assembly is conforming. Sweeping the
|
|
6
|
+
cross-section along a stack of boundary rings gives a 5-block 3D O-grid with no
|
|
7
|
+
singular polar axis. Pipe, U-bend, aorta and LV are all this one operation with
|
|
8
|
+
different rings + per-station frames.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from ..core.block import Block
|
|
15
|
+
from ..core.multiblock import MultiBlockMesh
|
|
16
|
+
from ..core.topology import FaceKey
|
|
17
|
+
|
|
18
|
+
# 8 fixed joins for the butterfly (petal inner edge -> core edge; petal seam -> petal seam).
|
|
19
|
+
OGRID_JOINS = [
|
|
20
|
+
("p0", FaceKey.I0, "core", FaceKey.J0),
|
|
21
|
+
("p1", FaceKey.I0, "core", FaceKey.I1),
|
|
22
|
+
("p2", FaceKey.I0, "core", FaceKey.J1),
|
|
23
|
+
("p3", FaceKey.I0, "core", FaceKey.I0),
|
|
24
|
+
("p0", FaceKey.J1, "p1", FaceKey.J0),
|
|
25
|
+
("p1", FaceKey.J1, "p2", FaceKey.J0),
|
|
26
|
+
("p2", FaceKey.J1, "p3", FaceKey.J0),
|
|
27
|
+
("p3", FaceKey.J1, "p0", FaceKey.J0),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _plane_project(pts, c, e1, e2):
|
|
32
|
+
d = pts - c
|
|
33
|
+
return np.stack([d @ e1, d @ e2], axis=-1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def graded_radial(NR: int, wall_ratio: float = 1.0) -> np.ndarray:
|
|
37
|
+
"""Radial node positions in [0,1] (r=0 inner/core edge, r=1 outer/wall).
|
|
38
|
+
`wall_ratio` = size of the cell at the wall relative to the cell at the core
|
|
39
|
+
edge. wall_ratio < 1 clusters cells at the wall (a boundary layer);
|
|
40
|
+
wall_ratio = 1 is uniform."""
|
|
41
|
+
if not isinstance(NR, (int, np.integer)) or NR < 2:
|
|
42
|
+
raise ValueError("NR must be an integer >= 2")
|
|
43
|
+
if not np.isfinite(wall_ratio) or wall_ratio <= 0:
|
|
44
|
+
raise ValueError("wall_ratio must be finite and positive")
|
|
45
|
+
if abs(wall_ratio - 1.0) < 1e-9:
|
|
46
|
+
return np.linspace(0.0, 1.0, NR)
|
|
47
|
+
ncell = NR - 1
|
|
48
|
+
g = wall_ratio ** (1.0 / (ncell - 1)) if ncell > 1 else 1.0 # per-cell size ratio
|
|
49
|
+
sizes = g ** np.arange(ncell)
|
|
50
|
+
pos = np.concatenate([[0.0], np.cumsum(sizes)])
|
|
51
|
+
return pos / pos[-1]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def add_ogrid_tube(mesh: MultiBlockMesh, rings, e1s, e2s, NR: int = 6, core_frac: float = 0.42,
|
|
55
|
+
radial_dist=None, prefix: str = "") -> MultiBlockMesh:
|
|
56
|
+
"""Add one O-grid tube (5 prefixed blocks + 8 joins) to an existing mesh, so
|
|
57
|
+
several tubes can live in one MultiBlockMesh (e.g. a bifurcation tree)."""
|
|
58
|
+
rings = np.asarray(rings, float)
|
|
59
|
+
ns, NT, _ = rings.shape
|
|
60
|
+
if NT % 4 != 0:
|
|
61
|
+
raise ValueError(f"NT={NT} must be divisible by 4")
|
|
62
|
+
Q = NT // 4
|
|
63
|
+
e1s = np.asarray(e1s, float)
|
|
64
|
+
e2s = np.asarray(e2s, float)
|
|
65
|
+
|
|
66
|
+
def nm(x):
|
|
67
|
+
return f"{prefix}{x}"
|
|
68
|
+
|
|
69
|
+
core = Block(nm("core"), (Q + 1, Q + 1, ns))
|
|
70
|
+
petals = [Block(nm(f"p{k}"), (NR, Q + 1, ns)) for k in range(4)]
|
|
71
|
+
|
|
72
|
+
uu = (np.arange(Q + 1) / Q)[:, None]
|
|
73
|
+
vv = (np.arange(Q + 1) / Q)[None, :]
|
|
74
|
+
if radial_dist is None:
|
|
75
|
+
radial_dist = np.linspace(0.0, 1.0, NR)
|
|
76
|
+
radial_dist = np.asarray(radial_dist, float)
|
|
77
|
+
if radial_dist.shape != (NR,):
|
|
78
|
+
raise ValueError(f"radial_dist must have shape ({NR},)")
|
|
79
|
+
rr = radial_dist[:, None]
|
|
80
|
+
|
|
81
|
+
for s in range(ns):
|
|
82
|
+
ring = rings[s]
|
|
83
|
+
c = ring.mean(axis=0)
|
|
84
|
+
e1, e2 = e1s[s], e2s[s]
|
|
85
|
+
S = np.empty((4, 3))
|
|
86
|
+
for k in range(4):
|
|
87
|
+
corner = ring[(k * Q) % NT]
|
|
88
|
+
p2 = _plane_project(corner, c, e1, e2)
|
|
89
|
+
S[k] = c + core_frac * (p2[0] * e1 + p2[1] * e2)
|
|
90
|
+
core3d = (
|
|
91
|
+
S[0] * (1 - uu)[..., None] * (1 - vv)[..., None]
|
|
92
|
+
+ S[1] * uu[..., None] * (1 - vv)[..., None]
|
|
93
|
+
+ S[2] * uu[..., None] * vv[..., None]
|
|
94
|
+
+ S[3] * (1 - uu)[..., None] * vv[..., None]
|
|
95
|
+
)
|
|
96
|
+
core.nodes[:, :, s, :] = core3d
|
|
97
|
+
for k in range(4):
|
|
98
|
+
a0 = k * Q
|
|
99
|
+
idx = [(a0 + t) % NT for t in range(Q + 1)]
|
|
100
|
+
outer = ring[idx]
|
|
101
|
+
inner = (1 - vv.T) * S[k] + vv.T * S[(k + 1) % 4]
|
|
102
|
+
pet = (1 - rr)[..., None] * inner[None, :, :] + rr[..., None] * outer[None, :, :]
|
|
103
|
+
petals[k].nodes[:, :, s, :] = pet
|
|
104
|
+
|
|
105
|
+
mesh.add_block(core)
|
|
106
|
+
for p in petals:
|
|
107
|
+
mesh.add_block(p)
|
|
108
|
+
for (ba, fa, bb, fb) in OGRID_JOINS:
|
|
109
|
+
mesh.join(nm(ba), fa, nm(bb), fb)
|
|
110
|
+
return mesh
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_ogrid_from_rings(rings, e1s, e2s, NR: int = 6, core_frac: float = 0.42,
|
|
114
|
+
radial_dist=None) -> MultiBlockMesh:
|
|
115
|
+
"""Sweep a butterfly cross-section along a stack of boundary rings.
|
|
116
|
+
|
|
117
|
+
rings: (ns, NT, 3) boundary ring at each station (NT divisible by 4).
|
|
118
|
+
e1s, e2s: (ns, 3) in-plane frame at each station (used to place the core
|
|
119
|
+
square; for a bending path use rotation-minimizing frames).
|
|
120
|
+
Returns a MultiBlockMesh with blocks {core,p0,p1,p2,p3} + OGRID_JOINS (not
|
|
121
|
+
assembled, no patches). Block axes: core (u,v,station); petals
|
|
122
|
+
(r=inner..outer, c=circumferential, station).
|
|
123
|
+
"""
|
|
124
|
+
m = MultiBlockMesh()
|
|
125
|
+
return add_ogrid_tube(m, rings, e1s, e2s, NR=NR, core_frac=core_frac, radial_dist=radial_dist)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_lv_ogrid(grid, axis, e1, e2, NR: int = 6, core_frac: float = 0.42,
|
|
129
|
+
wall_ratio: float = 1.0) -> MultiBlockMesh:
|
|
130
|
+
"""LV special case: rings = surface levels, a single fixed in-plane frame
|
|
131
|
+
(e1,e2) for every level (the LV long axis is nearly straight, so a fixed
|
|
132
|
+
frame avoids per-level twist).
|
|
133
|
+
|
|
134
|
+
`wall_ratio` < 1 clusters the petal cells at the endocardial wall (a
|
|
135
|
+
boundary layer); 1 is uniform. NOTE: a wall layer thins the near-wall cells,
|
|
136
|
+
which raises their aspect ratio and their risk of inverting under a large
|
|
137
|
+
moving-mesh morph -- gate quality across the cycle before relying on it.
|
|
138
|
+
"""
|
|
139
|
+
grid = np.asarray(grid, float)
|
|
140
|
+
ns = grid.shape[0]
|
|
141
|
+
e1s = np.tile(np.asarray(e1, float), (ns, 1))
|
|
142
|
+
e2s = np.tile(np.asarray(e2, float), (ns, 1))
|
|
143
|
+
rd = graded_radial(NR, wall_ratio)
|
|
144
|
+
return build_ogrid_from_rings(grid, e1s, e2s, NR=NR, core_frac=core_frac, radial_dist=rd)
|