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.
Files changed (66) hide show
  1. hexmb/__init__.py +7 -0
  2. hexmb/assembly/__init__.py +0 -0
  3. hexmb/assembly/ami.py +223 -0
  4. hexmb/assembly/ogrid.py +144 -0
  5. hexmb/assembly/splithex.py +701 -0
  6. hexmb/assembly/sweep.py +74 -0
  7. hexmb/cli.py +797 -0
  8. hexmb/core/__init__.py +0 -0
  9. hexmb/core/block.py +89 -0
  10. hexmb/core/dedup.py +87 -0
  11. hexmb/core/multiblock.py +219 -0
  12. hexmb/core/tfi.py +140 -0
  13. hexmb/core/topology.py +118 -0
  14. hexmb/data/cap/GlobalHermiteParam.exelem +908 -0
  15. hexmb/data/cap/README.md +26 -0
  16. hexmb/data/cap/SCD0000101/SCD0000101_1.model.exnode +166 -0
  17. hexmb/data/cap/SCD0000101/SCD0000101_10.model.exnode +166 -0
  18. hexmb/data/cap/SCD0000101/SCD0000101_11.model.exnode +166 -0
  19. hexmb/data/cap/SCD0000101/SCD0000101_12.model.exnode +166 -0
  20. hexmb/data/cap/SCD0000101/SCD0000101_13.model.exnode +166 -0
  21. hexmb/data/cap/SCD0000101/SCD0000101_14.model.exnode +166 -0
  22. hexmb/data/cap/SCD0000101/SCD0000101_15.model.exnode +166 -0
  23. hexmb/data/cap/SCD0000101/SCD0000101_16.model.exnode +166 -0
  24. hexmb/data/cap/SCD0000101/SCD0000101_17.model.exnode +166 -0
  25. hexmb/data/cap/SCD0000101/SCD0000101_18.model.exnode +166 -0
  26. hexmb/data/cap/SCD0000101/SCD0000101_19.model.exnode +166 -0
  27. hexmb/data/cap/SCD0000101/SCD0000101_2.model.exnode +166 -0
  28. hexmb/data/cap/SCD0000101/SCD0000101_20.model.exnode +166 -0
  29. hexmb/data/cap/SCD0000101/SCD0000101_3.model.exnode +166 -0
  30. hexmb/data/cap/SCD0000101/SCD0000101_4.model.exnode +166 -0
  31. hexmb/data/cap/SCD0000101/SCD0000101_5.model.exnode +166 -0
  32. hexmb/data/cap/SCD0000101/SCD0000101_6.model.exnode +166 -0
  33. hexmb/data/cap/SCD0000101/SCD0000101_7.model.exnode +166 -0
  34. hexmb/data/cap/SCD0000101/SCD0000101_8.model.exnode +166 -0
  35. hexmb/data/cap/SCD0000101/SCD0000101_9.model.exnode +166 -0
  36. hexmb/io/__init__.py +0 -0
  37. hexmb/io/cap.py +202 -0
  38. hexmb/io/foam.py +91 -0
  39. hexmb/io/stl_surface.py +44 -0
  40. hexmb/io/surface.py +546 -0
  41. hexmb/io/vtk.py +296 -0
  42. hexmb/project/__init__.py +0 -0
  43. hexmb/project/skeleton.py +173 -0
  44. hexmb/project/stl_tube.py +225 -0
  45. hexmb/services/__init__.py +0 -0
  46. hexmb/services/cavity.py +109 -0
  47. hexmb/services/export_foam.py +194 -0
  48. hexmb/services/flow.py +261 -0
  49. hexmb/services/morph.py +95 -0
  50. hexmb/services/motion.py +600 -0
  51. hexmb/services/quality.py +184 -0
  52. hexmb/services/render.py +174 -0
  53. hexmb/services/smooth.py +144 -0
  54. hexmb/templates/__init__.py +0 -0
  55. hexmb/templates/cavity.py +122 -0
  56. hexmb/templates/conformal_lv.py +365 -0
  57. hexmb/templates/lv.py +71 -0
  58. hexmb/templates/stl.py +67 -0
  59. hexmb/templates/telescoping_lv.py +209 -0
  60. hexmb/templates/tube.py +80 -0
  61. hexmb-0.2.1.dist-info/METADATA +490 -0
  62. hexmb-0.2.1.dist-info/RECORD +66 -0
  63. hexmb-0.2.1.dist-info/WHEEL +5 -0
  64. hexmb-0.2.1.dist-info/entry_points.txt +2 -0
  65. hexmb-0.2.1.dist-info/licenses/LICENSE +21 -0
  66. 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