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
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
"""Conformal split-hex 2:1 transition + telescoping O-grid (one conservative region).
|
|
2
|
+
|
|
3
|
+
This ports snappyHexMesh's ``hexRef8`` octree-refinement idea to a *structured*
|
|
4
|
+
O-grid. Where a COARSE O-grid segment meets a 2x-FINE one, each coarse cell's
|
|
5
|
+
interface face is split into 2x2 sub-faces so it matches the four fine cells
|
|
6
|
+
that abut it. The coarse cell becomes a **split-hex polyhedron**; the fine
|
|
7
|
+
cells stay hexes; the interface is **conformal** (the cells share faces), so
|
|
8
|
+
flux is conserved across it.
|
|
9
|
+
|
|
10
|
+
Why it matters
|
|
11
|
+
--------------
|
|
12
|
+
The alternative coupling for a coarse<->fine O-grid join is an AMI /
|
|
13
|
+
``nonConformalCyclic`` interface, which *interpolates* fluxes and is therefore
|
|
14
|
+
**non-conservative** for the pressure/continuity equation. On a moving left
|
|
15
|
+
ventricle that stalls the diastolic (filling) pressure solve. A conformal
|
|
16
|
+
split-hex join keeps one conservative region, so the moving filling runs.
|
|
17
|
+
|
|
18
|
+
What you build with it
|
|
19
|
+
-----------------------
|
|
20
|
+
A **telescoping O-grid**: a swept O-grid whose ends are coarse and whose middle
|
|
21
|
+
is fine (coarse cap - fine body - coarse cap), in ONE region, with no
|
|
22
|
+
interpolation coupling. This breaks the single-resolution cell-count ceiling of
|
|
23
|
+
a plain morphing O-grid: refine only where you need it.
|
|
24
|
+
|
|
25
|
+
The exactness trick
|
|
26
|
+
-------------------
|
|
27
|
+
The fine ring is the coarse ring **subdivided** (each edge gets a midpoint), and
|
|
28
|
+
the O-grid cross-section is built with a *bilinear* core and *linear* petals.
|
|
29
|
+
Both are affine, so the coarse nodes are an exact subset of the fine nodes and
|
|
30
|
+
the geometric split points (edge midpoints, face centre) coincide with the fine
|
|
31
|
+
nodes. That is what makes the join watertight.
|
|
32
|
+
|
|
33
|
+
Requirements
|
|
34
|
+
------------
|
|
35
|
+
* fine ring size ``NT_f = 2 * NT_c`` and fine radial ``NR_f = 2 * NR_c - 1``;
|
|
36
|
+
* ``NT`` divisible by 4 (O-grid has four petals).
|
|
37
|
+
|
|
38
|
+
Known limits
|
|
39
|
+
------------
|
|
40
|
+
Split cells inside strongly-curved petals carry residual skewness (~5-6) and
|
|
41
|
+
non-orthogonality (~75-80 deg); skewness-corrected FV schemes tolerate them but
|
|
42
|
+
they are real. Refining the coarse ring refines the apex ring too, which
|
|
43
|
+
pinches at end-systole on a contracting ventricle; refine the body axially or
|
|
44
|
+
use :func:`build_multilevel_telescoping_ogrid` to keep the apex coarse.
|
|
45
|
+
|
|
46
|
+
This is the mesh route behind the validated moving-ventricle CFD runs (the
|
|
47
|
+
AMI-coupled :mod:`hexmb.templates.telescoping_lv` is the other route; its
|
|
48
|
+
interpolated coupling is not flux-conservative and stalls the filling solve).
|
|
49
|
+
"""
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
from collections import defaultdict
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
import numpy as np
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"PolyMesh",
|
|
59
|
+
"ogrid_section",
|
|
60
|
+
"subdivide_ring",
|
|
61
|
+
"emit_ogrid_cell",
|
|
62
|
+
"emit_ogrid_layer",
|
|
63
|
+
"build_telescoping_ogrid",
|
|
64
|
+
"build_multilevel_telescoping_ogrid",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --------------------------------------------------------------------------- #
|
|
69
|
+
# Polyhedral mesh container + OpenFOAM writer #
|
|
70
|
+
# --------------------------------------------------------------------------- #
|
|
71
|
+
class PolyMesh:
|
|
72
|
+
"""A polyhedral mesh built face-list by face-list, with coordinate dedup.
|
|
73
|
+
|
|
74
|
+
Cells are added as a list of faces, each face a list of xyz points. Points
|
|
75
|
+
are de-duplicated by rounded coordinate so shared faces between neighbouring
|
|
76
|
+
cells collapse to one internal face. ``write`` emits an OpenFOAM
|
|
77
|
+
``constant/polyMesh`` with owner<neighbour ordering and boundary faces
|
|
78
|
+
grouped per patch.
|
|
79
|
+
|
|
80
|
+
Unlike the all-hex :class:`hexmb.core.multiblock.MultiBlockMesh`, this holds
|
|
81
|
+
general polyhedra, which is what the split-hex transition cells are.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, round_dp: int = 4):
|
|
85
|
+
self._round = round_dp
|
|
86
|
+
self._index: dict[tuple, int] = {}
|
|
87
|
+
self.P: list[tuple] = []
|
|
88
|
+
self.cells: list[list[list[int]]] = []
|
|
89
|
+
self.cell_patch: list[dict[int, str]] = []
|
|
90
|
+
self._classify = None # default boundary classifier (write may override)
|
|
91
|
+
self.patches: list[str] = [] # patch names in the order the last write() used
|
|
92
|
+
self.frame = None # optional (axis, e1, e2, mu) stashed by a template
|
|
93
|
+
self.top_faces: set = set() # face keys on the last axial section (valve plane), tagged at emit
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def points(self) -> np.ndarray:
|
|
97
|
+
"""Point coordinates as an ``(n_points, 3)`` array (mm)."""
|
|
98
|
+
return np.asarray(self.P, float)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def n_hex(self) -> int:
|
|
102
|
+
"""Number of plain hexahedra (6 faces)."""
|
|
103
|
+
return sum(1 for c in self.cells if len(c) == 6)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def n_split(self) -> int:
|
|
107
|
+
"""Number of split-hex transition polyhedra (9 faces)."""
|
|
108
|
+
return sum(1 for c in self.cells if len(c) != 6)
|
|
109
|
+
|
|
110
|
+
def same_topology(self, other: "PolyMesh") -> bool:
|
|
111
|
+
"""True when both meshes have identical cell/face/point connectivity (the
|
|
112
|
+
condition for using them as phases of one moving mesh)."""
|
|
113
|
+
return len(self.P) == len(other.P) and self.cells == other.cells
|
|
114
|
+
|
|
115
|
+
def pid(self, xyz) -> int:
|
|
116
|
+
k = tuple(round(float(v), self._round) for v in xyz)
|
|
117
|
+
i = self._index.get(k)
|
|
118
|
+
if i is None:
|
|
119
|
+
i = len(self.P)
|
|
120
|
+
self._index[k] = i
|
|
121
|
+
self.P.append(k)
|
|
122
|
+
return i
|
|
123
|
+
|
|
124
|
+
def add(self, faces, patch_faces=None) -> None:
|
|
125
|
+
"""Add one cell. ``faces`` is a list of faces (each a list of xyz).
|
|
126
|
+
``patch_faces`` optionally tags boundary faces of THIS cell by their
|
|
127
|
+
local index: ``{local_face_index: patch_name}``."""
|
|
128
|
+
self.cells.append([[self.pid(p) for p in f] for f in faces])
|
|
129
|
+
self.cell_patch.append(patch_faces or {})
|
|
130
|
+
self._ft_key = None
|
|
131
|
+
|
|
132
|
+
# -- geometry helpers --------------------------------------------------- #
|
|
133
|
+
def _face_table(self):
|
|
134
|
+
"""Flat arrays over every (cell, face): ``cid`` (n_faces,), and per face
|
|
135
|
+
length a dict ``{k: (rows, vid (m, k))}``. Cached until cells change."""
|
|
136
|
+
key = (len(self.cells), len(self.P))
|
|
137
|
+
if getattr(self, "_ft_key", None) == key:
|
|
138
|
+
return self._ft
|
|
139
|
+
cid, by_len = [], {}
|
|
140
|
+
for ci, c in enumerate(self.cells):
|
|
141
|
+
for f in c:
|
|
142
|
+
by_len.setdefault(len(f), ([], []))
|
|
143
|
+
by_len[len(f)][0].append(ci)
|
|
144
|
+
by_len[len(f)][1].append(f)
|
|
145
|
+
ft = {k: (np.asarray(r, int), np.asarray(v, int)) for k, (r, v) in by_len.items()}
|
|
146
|
+
self._ft, self._ft_key = ft, key
|
|
147
|
+
return ft
|
|
148
|
+
|
|
149
|
+
def _cell_centres(self) -> np.ndarray:
|
|
150
|
+
P = np.asarray(self.P, float)
|
|
151
|
+
pairs = np.concatenate([np.stack([np.repeat(cid, v.shape[1]), v.ravel()], 1)
|
|
152
|
+
for cid, v in self._face_table().values()])
|
|
153
|
+
pairs = np.unique(pairs, axis=0) # (cell, unique vertex)
|
|
154
|
+
cc = np.zeros((len(self.cells), 3)); cnt = np.zeros(len(self.cells))
|
|
155
|
+
np.add.at(cc, pairs[:, 0], P[pairs[:, 1]]); np.add.at(cnt, pairs[:, 0], 1.0)
|
|
156
|
+
return cc / cnt[:, None]
|
|
157
|
+
|
|
158
|
+
def _face_normal(self, f) -> np.ndarray:
|
|
159
|
+
v = [np.asarray(self.P[i], float) for i in f]
|
|
160
|
+
n = np.zeros(3)
|
|
161
|
+
for i in range(len(v)):
|
|
162
|
+
n += np.cross(v[i], v[(i + 1) % len(v)])
|
|
163
|
+
return n / (np.linalg.norm(n) + 1e-30)
|
|
164
|
+
|
|
165
|
+
def _face_vectors(self):
|
|
166
|
+
"""Per (cell, face) entry in face-table order: cell id, face centroid, area
|
|
167
|
+
vector (right-hand rule on the stored vertex order)."""
|
|
168
|
+
P = np.asarray(self.P, float)
|
|
169
|
+
cids, fcs, sfs = [], [], []
|
|
170
|
+
for k, (cid, vid) in self._face_table().items():
|
|
171
|
+
pts = P[vid] # (m, k, 3)
|
|
172
|
+
fc = pts.mean(1)
|
|
173
|
+
s = np.zeros((len(cid), 3))
|
|
174
|
+
for i in range(k):
|
|
175
|
+
s += np.cross(pts[:, i] - fc, pts[:, (i + 1) % k] - fc)
|
|
176
|
+
cids.append(cid); fcs.append(fc); sfs.append(0.5 * s)
|
|
177
|
+
return np.concatenate(cids), np.concatenate(fcs), np.concatenate(sfs)
|
|
178
|
+
|
|
179
|
+
def pyramid_volumes(self):
|
|
180
|
+
"""Signed volume of the pyramid (cell centre, face) for every (cell, face)
|
|
181
|
+
entry, using the stored face orientation (outward for a right-handed cell
|
|
182
|
+
as emitted by :func:`emit_ogrid_cell`). A negative value means the face
|
|
183
|
+
is folded past the cell centre or the cell is inverted; OpenFOAM's
|
|
184
|
+
checkMesh fails such cells ("open cells" / negative pyramids) even when
|
|
185
|
+
the unsigned volume looks fine. Returns ``(cell_ids, pyramid_volumes)``."""
|
|
186
|
+
cid, fc, sf = self._face_vectors()
|
|
187
|
+
cc = self._cell_centres()
|
|
188
|
+
return cid, np.einsum("ij,ij->i", fc - cc[cid], sf) / 3.0
|
|
189
|
+
|
|
190
|
+
def cell_volumes(self) -> np.ndarray:
|
|
191
|
+
"""Signed cell volumes: the sum of the signed pyramid volumes, so an
|
|
192
|
+
inverted cell is negative and a folded cell is less than its convex hull."""
|
|
193
|
+
cid, pv = self.pyramid_volumes()
|
|
194
|
+
vols = np.zeros(len(self.cells))
|
|
195
|
+
np.add.at(vols, cid, pv)
|
|
196
|
+
return vols
|
|
197
|
+
|
|
198
|
+
def min_pyramid_per_cell(self) -> np.ndarray:
|
|
199
|
+
"""Smallest signed pyramid volume of each cell (the hard validity gate:
|
|
200
|
+
every entry must be > 0)."""
|
|
201
|
+
cid, pv = self.pyramid_volumes()
|
|
202
|
+
out = np.full(len(self.cells), np.inf)
|
|
203
|
+
np.minimum.at(out, cid, pv)
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
def face_areas(self) -> np.ndarray:
|
|
207
|
+
"""Area of every (cell, face) entry, flattened in face-table order. A zero
|
|
208
|
+
area means a degenerate face (coincident points), which OpenFOAM rejects
|
|
209
|
+
even when every cell volume is positive. An internal face appears twice
|
|
210
|
+
here (once per cell); :meth:`n_zero_area_faces` counts each face once."""
|
|
211
|
+
return np.linalg.norm(self._face_vectors()[2], axis=1)
|
|
212
|
+
|
|
213
|
+
def n_zero_area_faces(self, tol: float = 1e-9) -> int:
|
|
214
|
+
"""Number of distinct faces whose area is at most ``tol`` (mm^2). A face
|
|
215
|
+
shared by two cells is counted once."""
|
|
216
|
+
areas = np.linalg.norm(self._face_vectors()[2], axis=1)
|
|
217
|
+
keys = set()
|
|
218
|
+
pos = 0
|
|
219
|
+
for k, (cid, vid) in self._face_table().items():
|
|
220
|
+
small = np.flatnonzero(areas[pos:pos + len(cid)] <= tol)
|
|
221
|
+
for i in small:
|
|
222
|
+
keys.add(frozenset(vid[i].tolist()))
|
|
223
|
+
pos += len(cid)
|
|
224
|
+
return len(keys)
|
|
225
|
+
|
|
226
|
+
def smooth(self, iters: int = 3, step: float = 0.35, radial_constrain: bool = False,
|
|
227
|
+
axis=None) -> None:
|
|
228
|
+
"""Gentle interior-point Laplacian relaxation.
|
|
229
|
+
|
|
230
|
+
Boundary points are held fixed; only points interior to the volume move,
|
|
231
|
+
and shared interface nodes move consistently for every cell that owns
|
|
232
|
+
them, so conformity is preserved. Use it to relax the skew that the
|
|
233
|
+
split-hex transition cells introduce in curved regions.
|
|
234
|
+
|
|
235
|
+
``radial_constrain`` freezes each moving node's **radial distance** from
|
|
236
|
+
the centreline (its move is projected to the tangential+axial plane), so a
|
|
237
|
+
boundary layer (wall-clustered radial spacing) survives the smoothing.
|
|
238
|
+
``axis`` is the centreline direction; if omitted it is estimated by PCA."""
|
|
239
|
+
P = np.asarray(self.P, float)
|
|
240
|
+
adj: dict[int, set] = defaultdict(set)
|
|
241
|
+
fcount: dict[frozenset, int] = defaultdict(int)
|
|
242
|
+
for c in self.cells:
|
|
243
|
+
for f in c:
|
|
244
|
+
fcount[frozenset(f)] += 1
|
|
245
|
+
for i in range(len(f)):
|
|
246
|
+
adj[f[i]].add(f[(i + 1) % len(f)])
|
|
247
|
+
adj[f[i]].add(f[i - 1])
|
|
248
|
+
boundary = {i for c in self.cells for f in c if fcount[frozenset(f)] == 1 for i in f}
|
|
249
|
+
interior = [i for i in range(len(P)) if i not in boundary]
|
|
250
|
+
if radial_constrain:
|
|
251
|
+
mu0 = P.mean(0)
|
|
252
|
+
if axis is None:
|
|
253
|
+
axis = np.linalg.svd(P - mu0, full_matrices=False)[2][0]
|
|
254
|
+
axis = np.asarray(axis, float); axis = axis / np.linalg.norm(axis)
|
|
255
|
+
for _ in range(iters):
|
|
256
|
+
new = P.copy()
|
|
257
|
+
for i in interior:
|
|
258
|
+
nb = list(adj[i])
|
|
259
|
+
if not nb:
|
|
260
|
+
continue
|
|
261
|
+
target = (1 - step) * P[i] + step * P[np.array(nb)].mean(0)
|
|
262
|
+
if radial_constrain:
|
|
263
|
+
c = mu0 + np.dot(P[i] - mu0, axis) * axis # centreline point at this axial pos
|
|
264
|
+
rvec = P[i] - c
|
|
265
|
+
rn = np.linalg.norm(rvec)
|
|
266
|
+
if rn > 1e-9:
|
|
267
|
+
rhat = rvec / rn
|
|
268
|
+
move = target - P[i]
|
|
269
|
+
target = P[i] + (move - np.dot(move, rhat) * rhat) # drop the radial move
|
|
270
|
+
new[i] = target
|
|
271
|
+
P = new
|
|
272
|
+
self.P = [tuple(p) for p in P]
|
|
273
|
+
|
|
274
|
+
# -- OpenFOAM export ---------------------------------------------------- #
|
|
275
|
+
def topology(self, classify=None):
|
|
276
|
+
"""Assemble the OpenFOAM face topology without writing it.
|
|
277
|
+
|
|
278
|
+
Returns ``(faces, owner, neighbour, patches)``: internal faces first
|
|
279
|
+
(owner < neighbour, sorted by owner then neighbour, each face oriented
|
|
280
|
+
from owner to neighbour), then the boundary faces grouped per patch and
|
|
281
|
+
oriented outward; ``patches`` is an ordered ``{name: (start, n_faces)}``.
|
|
282
|
+
``classify`` is as in :meth:`write`. :meth:`write`, the VTK writers and
|
|
283
|
+
:func:`hexmb.services.quality.polymesh_metrics` all work from this.
|
|
284
|
+
"""
|
|
285
|
+
classify = classify or self._classify or (lambda fc, nrm: "wall")
|
|
286
|
+
prepare = getattr(classify, "prepare", None)
|
|
287
|
+
if prepare is not None:
|
|
288
|
+
prepare() # recompute from the points as they are NOW (a phase may have been refilled)
|
|
289
|
+
P = np.asarray(self.P, float)
|
|
290
|
+
cc = self._cell_centres()
|
|
291
|
+
|
|
292
|
+
fmap: dict[frozenset, list] = {}
|
|
293
|
+
face_patch: dict[frozenset, str] = {}
|
|
294
|
+
for ci, (c, pp) in enumerate(zip(self.cells, self.cell_patch)):
|
|
295
|
+
for fi, f in enumerate(c):
|
|
296
|
+
key = frozenset(f)
|
|
297
|
+
fmap.setdefault(key, []).append((ci, f))
|
|
298
|
+
if fi in pp:
|
|
299
|
+
face_patch[key] = pp[fi]
|
|
300
|
+
|
|
301
|
+
internal, boundary = [], []
|
|
302
|
+
for key, occ in fmap.items():
|
|
303
|
+
if len(occ) == 2:
|
|
304
|
+
(a, fa), (b, fb) = occ
|
|
305
|
+
own, nei = (a, b) if a < b else (b, a)
|
|
306
|
+
f = fa if own == a else fb
|
|
307
|
+
if np.dot(self._face_normal(f), cc[nei] - cc[own]) < 0:
|
|
308
|
+
f = f[::-1]
|
|
309
|
+
internal.append((own, nei, f))
|
|
310
|
+
else:
|
|
311
|
+
if len(occ) > 2:
|
|
312
|
+
raise ValueError(f"face {sorted(key)} is shared by {len(occ)} cells "
|
|
313
|
+
f"(coincident points after rounding?); the mesh is not a valid polyMesh")
|
|
314
|
+
ci, f = occ[0]
|
|
315
|
+
fc = P[f].mean(0)
|
|
316
|
+
nrm = self._face_normal(f)
|
|
317
|
+
if np.dot(nrm, fc - cc[ci]) < 0:
|
|
318
|
+
f = f[::-1]
|
|
319
|
+
nrm = -nrm
|
|
320
|
+
if getattr(classify, "uses_top_tag", False):
|
|
321
|
+
name = face_patch.get(key) or classify(fc, nrm, key in self.top_faces)
|
|
322
|
+
else:
|
|
323
|
+
name = face_patch.get(key) or classify(fc, nrm)
|
|
324
|
+
boundary.append((ci, f, name))
|
|
325
|
+
internal.sort(key=lambda t: (t[0], t[1]))
|
|
326
|
+
|
|
327
|
+
groups: dict[str, list] = {}
|
|
328
|
+
for ci, f, name in boundary:
|
|
329
|
+
groups.setdefault(name, []).append((ci, f))
|
|
330
|
+
faces = [f for _, _, f in internal] + [f for pl in groups.values() for _, f in pl]
|
|
331
|
+
owner = np.array([o for o, _, _ in internal] + [ci for pl in groups.values() for ci, _ in pl], int)
|
|
332
|
+
neigh = np.array([n for _, n, _ in internal], int)
|
|
333
|
+
patches: dict[str, tuple] = {}
|
|
334
|
+
start = len(internal)
|
|
335
|
+
for name, fl in groups.items():
|
|
336
|
+
patches[name] = (start, len(fl))
|
|
337
|
+
start += len(fl)
|
|
338
|
+
return faces, owner, neigh, patches
|
|
339
|
+
|
|
340
|
+
def write(self, case_dir, classify=None, scale: float = 1e-3):
|
|
341
|
+
"""Write ``constant/polyMesh``.
|
|
342
|
+
|
|
343
|
+
Parameters
|
|
344
|
+
----------
|
|
345
|
+
case_dir : path-like
|
|
346
|
+
Case directory; ``constant/polyMesh`` is created under it.
|
|
347
|
+
classify : callable, optional
|
|
348
|
+
``classify(face_centroid_mm, outward_normal) -> patch_name`` for
|
|
349
|
+
boundary faces not already tagged via ``add(..., patch_faces=...)``.
|
|
350
|
+
Defaults to everything ``"wall"``.
|
|
351
|
+
scale : float
|
|
352
|
+
Applied to point coordinates on write (mm -> m by default).
|
|
353
|
+
|
|
354
|
+
Returns ``(n_cells, n_internal_faces, n_points)``.
|
|
355
|
+
"""
|
|
356
|
+
case_dir = Path(case_dir)
|
|
357
|
+
P = np.asarray(self.P, float)
|
|
358
|
+
faces, owner, neigh, patches = self.topology(classify)
|
|
359
|
+
n_internal = len(neigh)
|
|
360
|
+
|
|
361
|
+
pm = case_dir / "constant" / "polyMesh"
|
|
362
|
+
pm.mkdir(parents=True, exist_ok=True)
|
|
363
|
+
hdr = (lambda cls, obj: f'FoamFile{{version 2.0;format ascii;class {cls};'
|
|
364
|
+
f'location "constant/polyMesh";object {obj};}}\n')
|
|
365
|
+
(pm / "points").write_text(
|
|
366
|
+
hdr("vectorField", "points") + f"{len(P)}\n(\n"
|
|
367
|
+
+ "\n".join(f"({p[0]:.7g} {p[1]:.7g} {p[2]:.7g})" for p in P * scale) + "\n)\n")
|
|
368
|
+
(pm / "faces").write_text(
|
|
369
|
+
hdr("faceList", "faces") + f"{len(faces)}\n(\n"
|
|
370
|
+
+ "\n".join(f"{len(f)}({' '.join(map(str, f))})" for f in faces) + "\n)\n")
|
|
371
|
+
(pm / "owner").write_text(
|
|
372
|
+
hdr("labelList", "owner") + f"{len(owner)}\n(\n" + "\n".join(map(str, owner)) + "\n)\n")
|
|
373
|
+
(pm / "neighbour").write_text(
|
|
374
|
+
hdr("labelList", "neighbour") + f"{len(neigh)}\n(\n" + "\n".join(map(str, neigh)) + "\n)\n")
|
|
375
|
+
blk = ""
|
|
376
|
+
for name, (start, n) in patches.items():
|
|
377
|
+
typ = "wall" if name == "wall" else "patch"
|
|
378
|
+
blk += (f" {name}\n {{\n type {typ};\n"
|
|
379
|
+
f" nFaces {n};\n startFace {start};\n }}\n")
|
|
380
|
+
(pm / "boundary").write_text(
|
|
381
|
+
hdr("polyBoundaryMesh", "boundary") + f"{len(patches)}\n(\n{blk})\n")
|
|
382
|
+
self.patches = list(patches.keys())
|
|
383
|
+
return len(self.cells), n_internal, len(P)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# --------------------------------------------------------------------------- #
|
|
387
|
+
# O-grid cross-section (exactly subdivisible) #
|
|
388
|
+
# --------------------------------------------------------------------------- #
|
|
389
|
+
def ogrid_section(ring, core_frac: float, radial):
|
|
390
|
+
"""O-grid (butterfly) cross-section for a boundary ``ring`` of shape (NT,3).
|
|
391
|
+
|
|
392
|
+
Returns ``(core, petals)`` where ``core`` has shape ``(Q+1, Q+1, 3)`` and
|
|
393
|
+
``petals`` is a list of four arrays of shape ``(NR, Q+1, 3)`` with ``Q =
|
|
394
|
+
NT//4`` and ``NR = len(radial)``. The core is **bilinear** and the petals
|
|
395
|
+
are **linear** in (radius, tangent), so subdividing the ring subdivides the
|
|
396
|
+
section exactly -- the property the split-hex join relies on.
|
|
397
|
+
|
|
398
|
+
``radial`` is the 1-D list of normalised radial positions (0 at core edge, 1
|
|
399
|
+
at wall); pass e.g. ``np.linspace(0, 1, NR)``.
|
|
400
|
+
"""
|
|
401
|
+
ring = np.asarray(ring, float)
|
|
402
|
+
NT = len(ring)
|
|
403
|
+
if NT % 4:
|
|
404
|
+
raise ValueError(f"ring size NT={NT} must be divisible by 4 (four petals)")
|
|
405
|
+
Q = NT // 4
|
|
406
|
+
NR = len(radial)
|
|
407
|
+
c = ring.mean(0)
|
|
408
|
+
S = [c + core_frac * (ring[(k * Q) % NT] - c) for k in range(4)] # 4 core corners
|
|
409
|
+
uu = (np.arange(Q + 1) / Q)[:, None, None]
|
|
410
|
+
vv = (np.arange(Q + 1) / Q)[None, :, None]
|
|
411
|
+
core = (S[0] * (1 - uu) * (1 - vv) + S[1] * uu * (1 - vv)
|
|
412
|
+
+ S[2] * uu * vv + S[3] * (1 - uu) * vv)
|
|
413
|
+
petals = []
|
|
414
|
+
for k in range(4):
|
|
415
|
+
pet = np.zeros((NR, Q + 1, 3))
|
|
416
|
+
for t in range(Q + 1):
|
|
417
|
+
outer = ring[(k * Q + t) % NT]
|
|
418
|
+
inner = (1 - t / Q) * S[k] + (t / Q) * S[(k + 1) % 4]
|
|
419
|
+
for r in range(NR):
|
|
420
|
+
pet[r, t] = (1 - radial[r]) * inner + radial[r] * outer
|
|
421
|
+
petals.append(pet)
|
|
422
|
+
return core, petals
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def subdivide_ring(coarse_ring):
|
|
426
|
+
"""Return the 2x-fine ring: the coarse nodes plus one midpoint per edge.
|
|
427
|
+
|
|
428
|
+
Shape goes ``(NT_c, 3)`` -> ``(2*NT_c, 3)`` with even indices the coarse
|
|
429
|
+
nodes and odd indices the edge midpoints, so coarse nodes are an exact subset
|
|
430
|
+
of the fine ones.
|
|
431
|
+
"""
|
|
432
|
+
cr = np.asarray(coarse_ring, float)
|
|
433
|
+
n = len(cr)
|
|
434
|
+
fr = np.zeros((2 * n, 3))
|
|
435
|
+
fr[0::2] = cr
|
|
436
|
+
fr[1::2] = 0.5 * (cr + np.roll(cr, -1, axis=0))
|
|
437
|
+
return fr
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# --------------------------------------------------------------------------- #
|
|
441
|
+
# Split-hex cell primitive #
|
|
442
|
+
# --------------------------------------------------------------------------- #
|
|
443
|
+
def emit_ogrid_cell(mesh: PolyMesh, hexc, top_split: bool = False, bot_split: bool = False,
|
|
444
|
+
tag_top: str | None = None):
|
|
445
|
+
"""Emit one cell into ``mesh``: a plain hex, or a split-hex if one of its
|
|
446
|
+
z-faces meets a 2x-finer neighbour.
|
|
447
|
+
|
|
448
|
+
``hexc = [b0, b1, b2, b3, t0, t1, t2, t3]`` -- the bottom quad then the top
|
|
449
|
+
quad, in the usual hex vertex order. When ``top_split`` (or ``bot_split``)
|
|
450
|
+
is set, the fine-facing quad is split into a 2x2 grid using its edge
|
|
451
|
+
midpoints and centre; the four side faces of the coarse cell become
|
|
452
|
+
pentagons, and the coarse cell is a split-hex polyhedron.
|
|
453
|
+
|
|
454
|
+
Every face is emitted with an OUTWARD orientation for a right-handed cell
|
|
455
|
+
(bottom quad counter-clockwise seen from the top), so the signed pyramid
|
|
456
|
+
volumes in :meth:`PolyMesh.pyramid_volumes` are an absolute inversion /
|
|
457
|
+
fold test. ``tag_top`` records the top face(s) in ``mesh.top_faces``.
|
|
458
|
+
"""
|
|
459
|
+
b, t = hexc[:4], hexc[4:]
|
|
460
|
+
if not (top_split or bot_split):
|
|
461
|
+
faces = [b[::-1], t,
|
|
462
|
+
[b[0], b[1], t[1], t[0]], [b[1], b[2], t[2], t[1]],
|
|
463
|
+
[b[2], b[3], t[3], t[2]], [b[3], b[0], t[0], t[3]]]
|
|
464
|
+
mesh.add(faces)
|
|
465
|
+
if tag_top:
|
|
466
|
+
mesh.top_faces.add(frozenset(mesh.cells[-1][1]))
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
def split(q):
|
|
470
|
+
A, B, C, D = (np.asarray(x, float) for x in q)
|
|
471
|
+
AB, BC, CD, DA = (A + B) / 2, (B + C) / 2, (C + D) / 2, (D + A) / 2
|
|
472
|
+
O = (A + B + C + D) / 4
|
|
473
|
+
subs = [[A, AB, O, DA], [AB, B, BC, O], [O, BC, C, CD], [DA, O, CD, D]]
|
|
474
|
+
return (AB, BC, CD, DA, O), subs
|
|
475
|
+
|
|
476
|
+
if top_split and bot_split:
|
|
477
|
+
# a one-layer coarse zone between two finer zones: both end faces are split and
|
|
478
|
+
# every side face carries the mid-edge node of both ends (six vertices)
|
|
479
|
+
(tAB, tBC, tCD, tDA, _), tsubs = split(t)
|
|
480
|
+
(bAB, bBC, bCD, bDA, _), bsubs = split(b)
|
|
481
|
+
faces = tsubs + [q[::-1] for q in bsubs] + [
|
|
482
|
+
[b[0], bAB, b[1], t[1], tAB, t[0]], [b[1], bBC, b[2], t[2], tBC, t[1]],
|
|
483
|
+
[b[2], bCD, b[3], t[3], tCD, t[2]], [b[3], bDA, b[0], t[0], tDA, t[3]]]
|
|
484
|
+
top_local = range(0, 4)
|
|
485
|
+
elif top_split:
|
|
486
|
+
(AB, BC, CD, DA, O), subs = split(t)
|
|
487
|
+
faces = [b[::-1]] + subs + [
|
|
488
|
+
[b[0], b[1], t[1], AB, t[0]], [b[1], b[2], t[2], BC, t[1]],
|
|
489
|
+
[b[2], b[3], t[3], CD, t[2]], [b[3], b[0], t[0], DA, t[3]]]
|
|
490
|
+
top_local = range(1, 5)
|
|
491
|
+
else:
|
|
492
|
+
(AB, BC, CD, DA, O), subs = split(b)
|
|
493
|
+
faces = [t] + [q[::-1] for q in subs] + [
|
|
494
|
+
[b[0], AB, b[1], t[1], t[0]], [b[1], BC, b[2], t[2], t[1]],
|
|
495
|
+
[b[2], CD, b[3], t[3], t[2]], [b[3], DA, b[0], t[0], t[3]]]
|
|
496
|
+
top_local = [0]
|
|
497
|
+
mesh.add(faces)
|
|
498
|
+
if tag_top:
|
|
499
|
+
for lf in top_local:
|
|
500
|
+
mesh.top_faces.add(frozenset(mesh.cells[-1][lf]))
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def emit_ogrid_layer(mesh: PolyMesh, sec0, sec1, split_top: bool = False, split_bot: bool = False,
|
|
504
|
+
tag_top: str | None = None):
|
|
505
|
+
"""Emit one axial layer of O-grid cells between two cross-sections
|
|
506
|
+
``sec0`` and ``sec1`` (each an ``(core, petals)`` pair from
|
|
507
|
+
:func:`ogrid_section`). Cells that face a finer neighbour across the top
|
|
508
|
+
(``split_top``) or bottom (``split_bot``) plane are emitted as split-hex.
|
|
509
|
+
"""
|
|
510
|
+
(c0, p0), (c1, p1) = sec0, sec1
|
|
511
|
+
Q = c0.shape[0] - 1
|
|
512
|
+
NR = p0[0].shape[0]
|
|
513
|
+
for i in range(Q): # core cells
|
|
514
|
+
for j in range(Q):
|
|
515
|
+
hexc = [c0[i, j], c0[i + 1, j], c0[i + 1, j + 1], c0[i, j + 1],
|
|
516
|
+
c1[i, j], c1[i + 1, j], c1[i + 1, j + 1], c1[i, j + 1]]
|
|
517
|
+
emit_ogrid_cell(mesh, hexc, split_top, split_bot, tag_top)
|
|
518
|
+
for k in range(4): # petal cells
|
|
519
|
+
for r in range(NR - 1):
|
|
520
|
+
for t in range(Q):
|
|
521
|
+
hexc = [p0[k][r, t], p0[k][r + 1, t], p0[k][r + 1, t + 1], p0[k][r, t + 1],
|
|
522
|
+
p1[k][r, t], p1[k][r + 1, t], p1[k][r + 1, t + 1], p1[k][r, t + 1]]
|
|
523
|
+
emit_ogrid_cell(mesh, hexc, split_top, split_bot, tag_top)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# --------------------------------------------------------------------------- #
|
|
527
|
+
# Telescoping O-grid builder #
|
|
528
|
+
# --------------------------------------------------------------------------- #
|
|
529
|
+
def graded_radial(NR, wall_ratio=1.0):
|
|
530
|
+
"""Radial node positions in [0,1], from the O-grid core edge (0) to the wall (1).
|
|
531
|
+
|
|
532
|
+
``wall_ratio < 1`` clusters cells toward the **wall** (a boundary layer): the
|
|
533
|
+
wall interval is ``wall_ratio`` times the core-edge interval, spaced
|
|
534
|
+
geometrically. ``wall_ratio == 1`` is uniform. This is what makes the mesh
|
|
535
|
+
fine where the flow gradients are (near the wall / WSS), not in the core.
|
|
536
|
+
"""
|
|
537
|
+
if NR < 3 or wall_ratio == 1.0:
|
|
538
|
+
return np.linspace(0.0, 1.0, NR)
|
|
539
|
+
n = NR - 1 # number of intervals
|
|
540
|
+
g = wall_ratio ** (1.0 / (n - 1)) # geometric ratio per interval
|
|
541
|
+
d = g ** np.arange(n) # interval sizes, shrinking toward the wall
|
|
542
|
+
r = np.concatenate([[0.0], np.cumsum(d)])
|
|
543
|
+
return r / r[-1]
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _subdivide_1d(x):
|
|
547
|
+
"""Insert the midpoint of every interval -> the 2:1 fine distribution.
|
|
548
|
+
Keeps grading, and keeps the coarse nodes an exact subset (split-hex exactness)."""
|
|
549
|
+
y = np.zeros(2 * len(x) - 1)
|
|
550
|
+
y[0::2] = x
|
|
551
|
+
y[1::2] = 0.5 * (x[:-1] + x[1:])
|
|
552
|
+
return y
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def build_telescoping_ogrid(ring_at, *, NT_c=24, NR_c=4, core_frac=0.42, wall_ratio=1.0,
|
|
556
|
+
apex=(0.0, 0.24, 10), body=(0.24, 0.80, 30),
|
|
557
|
+
base=(0.80, 1.0, 12), classify=None, smooth=True, smooth_iters=3):
|
|
558
|
+
"""Build a telescoping O-grid: coarse cap - fine body - coarse cap, joined
|
|
559
|
+
by conformal split-hex transitions, as ONE region.
|
|
560
|
+
|
|
561
|
+
Parameters
|
|
562
|
+
----------
|
|
563
|
+
ring_at : callable
|
|
564
|
+
``ring_at(frac) -> (M, 3)`` boundary contour at normalised axial
|
|
565
|
+
position ``frac`` in [0, 1]. It is resampled internally to ``NT_c``
|
|
566
|
+
(coarse) or ``2*NT_c`` (fine).
|
|
567
|
+
NT_c, NR_c : int
|
|
568
|
+
Coarse circumferential / radial resolution. The fine body is ``2*NT_c``
|
|
569
|
+
circumferential and ``2*NR_c-1`` radial.
|
|
570
|
+
core_frac : float
|
|
571
|
+
O-grid core size as a fraction of the local radius.
|
|
572
|
+
wall_ratio : float
|
|
573
|
+
Boundary-layer clustering (``< 1`` clusters cells toward the wall; ``1``
|
|
574
|
+
uniform). Use it so the mesh is fine at the wall (WSS) rather than in the
|
|
575
|
+
low-gradient core. Grading is preserved through the split-hex transition.
|
|
576
|
+
apex, body, base : (start_frac, end_frac, n_levels)
|
|
577
|
+
The three axial zones. ``apex``/``base`` are coarse; ``body`` is fine.
|
|
578
|
+
classify : callable, optional
|
|
579
|
+
Boundary-face patch classifier passed to :meth:`PolyMesh.write`.
|
|
580
|
+
smooth : bool
|
|
581
|
+
Relax the transition skew with a light interior Laplacian.
|
|
582
|
+
|
|
583
|
+
Returns the :class:`PolyMesh`.
|
|
584
|
+
"""
|
|
585
|
+
from scipy.signal import resample
|
|
586
|
+
|
|
587
|
+
rad_c = graded_radial(NR_c, wall_ratio) # coarse radial (wall-clustered if wall_ratio<1)
|
|
588
|
+
rad_f = _subdivide_1d(rad_c) # fine = coarse subdivided -> split-hex exact
|
|
589
|
+
|
|
590
|
+
# normalise the contour direction once: counter-clockwise about the apex->base
|
|
591
|
+
# direction, so every cell is emitted right-handed whatever the surface reader's
|
|
592
|
+
# angular convention was (a clockwise grid would otherwise mirror every cell)
|
|
593
|
+
r_lo, r_hi = np.asarray(ring_at(apex[0]), float), np.asarray(ring_at(base[1]), float)
|
|
594
|
+
ax = r_hi.mean(0) - r_lo.mean(0); ax /= (np.linalg.norm(ax) + 1e-30)
|
|
595
|
+
rm = np.asarray(ring_at(0.5 * (body[0] + body[1])), float); d = rm - rm.mean(0)
|
|
596
|
+
flip_ring = float(np.cross(d, np.roll(d, -1, axis=0)).sum(0) @ ax) < 0
|
|
597
|
+
|
|
598
|
+
def cring(f):
|
|
599
|
+
r = resample(ring_at(f), NT_c, axis=0)
|
|
600
|
+
return r[::-1] if flip_ring else r
|
|
601
|
+
|
|
602
|
+
def fring(f):
|
|
603
|
+
return subdivide_ring(cring(f))
|
|
604
|
+
|
|
605
|
+
def csec(f):
|
|
606
|
+
return ogrid_section(cring(f), core_frac, rad_c)
|
|
607
|
+
|
|
608
|
+
def fsec(f):
|
|
609
|
+
return ogrid_section(fring(f), core_frac, rad_f)
|
|
610
|
+
|
|
611
|
+
mesh = PolyMesh()
|
|
612
|
+
|
|
613
|
+
if not (apex[0] < apex[1] <= body[0] < body[1] <= base[0] < base[1]):
|
|
614
|
+
raise ValueError(f"axial zones must advance: apex {apex[:2]}, body {body[:2]}, base {base[:2]}")
|
|
615
|
+
|
|
616
|
+
def add_segment(fracs, sec_fn, split_top=False, split_bot=False, last=False):
|
|
617
|
+
secs = [sec_fn(f) for f in fracs]
|
|
618
|
+
for L in range(len(fracs) - 1):
|
|
619
|
+
emit_ogrid_layer(mesh, secs[L], secs[L + 1],
|
|
620
|
+
split_top=(split_top and L == len(fracs) - 2),
|
|
621
|
+
split_bot=(split_bot and L == 0),
|
|
622
|
+
tag_top="top" if (last and L == len(fracs) - 2) else None)
|
|
623
|
+
|
|
624
|
+
add_segment(np.linspace(apex[0], apex[1], apex[2]), csec, split_top=True)
|
|
625
|
+
add_segment(np.linspace(body[0], body[1], body[2]), fsec)
|
|
626
|
+
add_segment(np.linspace(base[0], base[1], base[2]), csec, split_bot=True, last=True)
|
|
627
|
+
if smooth:
|
|
628
|
+
# always constrain the smoother radially: it relaxes the transition skew
|
|
629
|
+
# without flattening the graded near-wall spacing (a boundary layer), and it
|
|
630
|
+
# is stable at high NR (the unconstrained Laplacian blows up there)
|
|
631
|
+
mesh.smooth(iters=smooth_iters, radial_constrain=True)
|
|
632
|
+
mesh._classify = classify # stash for convenience; write() takes it too
|
|
633
|
+
return mesh
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def build_multilevel_telescoping_ogrid(ring_at, *, NT_c=24, NR_c=4, core_frac=0.42,
|
|
637
|
+
wall_ratio=1.0, zones, classify=None,
|
|
638
|
+
smooth=True, smooth_iters=3):
|
|
639
|
+
"""Telescoping O-grid with an arbitrary number of refinement levels.
|
|
640
|
+
|
|
641
|
+
Where :func:`build_telescoping_ogrid` has one fine body between two coarse
|
|
642
|
+
caps (a single 2:1 step on each side), this takes a list of axial ``zones``
|
|
643
|
+
at chosen refinement levels and joins each pair of neighbouring zones with a
|
|
644
|
+
conformal split-hex transition. It exists for the moving left ventricle: the
|
|
645
|
+
apex ring must stay **coarse** (a fine apex ring pinches to near-zero cell
|
|
646
|
+
size when the cavity contracts at end-systole), while the mid-body -- which
|
|
647
|
+
keeps a large radius through the whole cycle -- can be refined circumferentially
|
|
648
|
+
for the diastolic vortex. So you nest the resolution: coarse apex cap, then a
|
|
649
|
+
step up to the body, then a further step up in the mid-body, then back down.
|
|
650
|
+
|
|
651
|
+
Parameters
|
|
652
|
+
----------
|
|
653
|
+
ring_at : callable
|
|
654
|
+
``ring_at(frac) -> (M, 3)`` boundary contour at axial position ``frac``.
|
|
655
|
+
NT_c, NR_c : int
|
|
656
|
+
Level-0 (coarsest) circumferential / radial resolution. A zone at level
|
|
657
|
+
``L`` has ``NT_c * 2**L`` circumferential cells and its radial line is the
|
|
658
|
+
level-0 line subdivided ``L`` times, so the coarse nodes stay an exact
|
|
659
|
+
subset of the fine ones (the split-hex exactness the join relies on).
|
|
660
|
+
zones : list of (start_frac, end_frac, n_levels, L)
|
|
661
|
+
Ordered apex->base. ``n_levels`` axial sections span ``[start, end]``;
|
|
662
|
+
``L`` is the refinement level of the zone. **Neighbouring zones must
|
|
663
|
+
differ by at most one level** (a 2:1 face split); a jump of two or more is
|
|
664
|
+
rejected. Put the apex and base zones at ``L=0``.
|
|
665
|
+
core_frac, wall_ratio, classify, smooth, smooth_iters
|
|
666
|
+
As in :func:`build_telescoping_ogrid`.
|
|
667
|
+
|
|
668
|
+
Returns the :class:`PolyMesh`.
|
|
669
|
+
"""
|
|
670
|
+
from scipy.signal import resample
|
|
671
|
+
|
|
672
|
+
levels = [z[3] for z in zones]
|
|
673
|
+
for a, b in zip(levels, levels[1:]):
|
|
674
|
+
if abs(a - b) > 1:
|
|
675
|
+
raise ValueError(f"neighbouring zones must differ by <=1 refine level; got {a} and {b}")
|
|
676
|
+
maxL = max(levels)
|
|
677
|
+
rad = [graded_radial(NR_c, wall_ratio)] # radial line per level (subdivided)
|
|
678
|
+
for _ in range(maxL):
|
|
679
|
+
rad.append(_subdivide_1d(rad[-1]))
|
|
680
|
+
|
|
681
|
+
def sec_at(f, L):
|
|
682
|
+
ring = resample(ring_at(f), NT_c, axis=0) # level-0 ring
|
|
683
|
+
for _ in range(L):
|
|
684
|
+
ring = subdivide_ring(ring) # subdivide L times -> exact fine ring
|
|
685
|
+
return ogrid_section(ring, core_frac, rad[L])
|
|
686
|
+
|
|
687
|
+
mesh = PolyMesh()
|
|
688
|
+
for zi, (s, e, nlev, L) in enumerate(zones):
|
|
689
|
+
fracs = np.linspace(s, e, nlev)
|
|
690
|
+
secs = [sec_at(f, L) for f in fracs]
|
|
691
|
+
# split toward whichever neighbour is finer (the split lives on the coarse side)
|
|
692
|
+
split_bot = zi > 0 and L < levels[zi - 1]
|
|
693
|
+
split_top = zi < len(zones) - 1 and L < levels[zi + 1]
|
|
694
|
+
for Ln in range(len(fracs) - 1):
|
|
695
|
+
emit_ogrid_layer(mesh, secs[Ln], secs[Ln + 1],
|
|
696
|
+
split_top=(split_top and Ln == len(fracs) - 2),
|
|
697
|
+
split_bot=(split_bot and Ln == 0))
|
|
698
|
+
if smooth:
|
|
699
|
+
mesh.smooth(iters=smooth_iters, radial_constrain=True)
|
|
700
|
+
mesh._classify = classify
|
|
701
|
+
return mesh
|