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
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
@@ -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)