STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
mesh.py
Go to the documentation of this file.
1"""Structured triangular meridional mesh (paper Section III.F).
2
3A "meridional mesh" is a 2-D triangulation of the half-plane Omega = {(x, r): r >= 0}
4obtained by revolving the chamber contour about the axis of symmetry. Every axisymmetric
5mode family (m=0 longitudinal/radial, m=1 first tangential, ...) is solved on the *same*
62-D mesh — only the azimuthal wavenumber ``m`` changes between solves (Eq. 9).
7
8``cylinder_mesh`` builds the mesh for the uniform right-circular cylinder used by
9verification case V1. Later tiers replace it with a mesh built from the true chamber
10contour (revolved via gmsh), without touching the assembly or eigensolver code.
11"""
12
13from __future__ import annotations
14
15from dataclasses import dataclass
16
17import numpy as np
18
19
20@dataclass
22 """P1 (linear) triangular mesh of the meridional half-plane.
23
24 ``nodes[k] = (x_k, r_k)`` and ``triangles[e] = (i, j, k)`` are node indices with
25 counter-clockwise winding in the (x, r) plane.
26 """
27
28 nodes: np.ndarray # (n_nodes, 2)
29 triangles: np.ndarray # (n_tri, 3), int64
30
31 @property
32 def n_nodes(self) -> int:
33 return int(self.nodes.shape[0])
34
35 @property
36 def n_tri(self) -> int:
37 return int(self.triangles.shape[0])
38
39 def axis_nodes(self, tol: float = 1e-9) -> np.ndarray:
40 """Node indices on the r=0 axis.
41
42 Needed for the axis-regularity condition (Section III.E): p=0 there for m>=1
43 (essential Dirichlet BC), nothing imposed for m=0 (the ``1/r`` weak-form measure
44 already enforces the natural condition automatically).
45 """
46 return np.nonzero(self.nodes[:, 1] <= tol)[0]
47
48 def nodes_at_x(self, x_value: float, tol: float = 1e-9) -> np.ndarray:
49 """Node indices lying on the vertical (constant-x) line ``x = x_value``, sorted
50 by radius. Used to find the boundary column for a Robin/admittance condition
51 (verification case V3's choked-nozzle end) — any structured mesh built by
52 ``_structured_mesh`` has an exact shared column there, so this is exact, not a
53 nearest-node approximation, as long as ``x_value`` matches a column that was
54 actually built into the grid (e.g. ``L`` for ``cylinder_mesh(L, ...)``).
55 """
56 idx = np.nonzero(np.abs(self.nodes[:, 0] - x_value) <= tol)[0]
57 return idx[np.argsort(self.nodes[idx, 1])]
58
59 def locate_point(self, x0: float, r0: float, tol: float = 1e-9
60 ) -> tuple[int, np.ndarray]:
61 """Find the element containing ``(x0, r0)`` and its P1 barycentric weights there.
62
63 Needed to place a *compact* (point) source — like V3's point flame — at an
64 arbitrary location that generally does not sit exactly on a mesh node: the
65 weak-form contribution of a point source is the test function evaluated there,
66 which for a P1 field is exactly the barycentric-coordinate interpolation
67 returned here (see ``assembly.point_sampling_vector``).
68
69 A plain linear scan over triangles, computing barycentric coordinates and
70 checking they are all in [0, 1] — simple and unambiguous, and fast enough for
71 the verification-case mesh sizes this is used on (a handful of thousand
72 triangles, done once per case setup, not per eigen-solve iteration).
73
74 Returns ``(triangle_index, weights)`` with ``weights`` the 3 barycentric
75 coordinates (summing to 1) at the 3 nodes of that triangle.
76 """
77 for e, tri in enumerate(self.triangles):
78 v = self.nodes[tri]
79 x1, r1 = v[0]; x2, r2 = v[1]; x3, r3 = v[2]
80 denom = (r2 - r3) * (x1 - x3) + (x3 - x2) * (r1 - r3)
81 if abs(denom) < 1e-30:
82 continue # degenerate triangle, skip
83 w1 = ((r2 - r3) * (x0 - x3) + (x3 - x2) * (r0 - r3)) / denom
84 w2 = ((r3 - r1) * (x0 - x3) + (x1 - x3) * (r0 - r3)) / denom
85 w3 = 1.0 - w1 - w2
86 if (w1 >= -tol) and (w2 >= -tol) and (w3 >= -tol):
87 return e, np.array([w1, w2, w3])
88 raise ValueError(f"point ({x0}, {r0}) not found inside any mesh triangle")
89
90
91def _grid_triangles(nx: int, nr: int) -> np.ndarray:
92 """Triangulation topology of an nx-by-nr logical node grid (node id = i*nr + j):
93 each logical quad cell split into 2 triangles along the same diagonal.
94
95 Shared by the plain tensor-product meshes below AND the wall-mapped meshes of
96 ``contour.py`` (where r depends on x): the topology is identical, only the node
97 coordinates differ — so the triangulation convention lives in exactly one place.
98 """
99 def nid(i: int, j: int) -> int:
100 return i * nr + j
101
102 tris = []
103 for i in range(nx - 1):
104 for j in range(nr - 1):
105 n00, n10 = nid(i, j), nid(i + 1, j)
106 n01, n11 = nid(i, j + 1), nid(i + 1, j + 1)
107 # diagonal n00-n11; both triangles wound consistently in (x, r)
108 tris.append((n00, n10, n11))
109 tris.append((n00, n11, n01))
110 return np.asarray(tris, dtype=np.int64)
111
112
113def _structured_mesh(xs: np.ndarray, rs: np.ndarray) -> MeridionalMesh:
114 """Shared builder: structured triangulation over an arbitrary (not necessarily
115 uniform) axial node grid ``xs`` crossed with a radial node grid ``rs``.
116
117 Both ``cylinder_mesh`` (uniform ``xs`` spacing) and ``two_zone_duct_mesh`` (two
118 uniformly-spaced runs of ``xs`` stitched together, with a shared column at the
119 zone interface) delegate to this.
120 """
121 nx, nr = len(xs), len(rs)
122 X, Rr = np.meshgrid(xs, rs, indexing="ij") # shape (nx, nr); node id = i*nr + j
123 nodes = np.column_stack([X.ravel(), Rr.ravel()])
124 return MeridionalMesh(nodes=nodes, triangles=_grid_triangles(nx, nr))
125
126
127def cylinder_mesh(L: float, R: float, nx: int, nr: int) -> MeridionalMesh:
128 """Structured mesh of the rectangle [0, L] x [0, R] (uniform cylinder, case V1).
129
130 ``nx``, ``nr`` are node counts (not cell counts) along the axial and radial
131 directions. Each of the ``(nx-1)*(nr-1)`` rectangular cells is split into 2
132 triangles sharing the same diagonal, so refinement is simply increasing nx, nr.
133 """
134 if nx < 2 or nr < 2:
135 raise ValueError("cylinder_mesh needs at least 2 nodes per direction")
136 if L <= 0 or R <= 0:
137 raise ValueError("L and R must be positive")
138
139 return _structured_mesh(np.linspace(0.0, L, nx), np.linspace(0.0, R, nr))
140
141
142def two_zone_duct_mesh(L1: float, L2: float, R: float, nx1: int, nx2: int, nr: int
143 ) -> tuple[MeridionalMesh, np.ndarray]:
144 """Structured mesh of a duct [0, L1+L2] x [0, R] with an exact node column at the
145 zone interface ``x = L1`` (verification case V2, temperature-jump duct).
146
147 Why an exact shared column, and not two independent grids glued approximately:
148 the pressure field itself is physically continuous across a sound-speed
149 discontinuity (only its second derivative jumps — see the derivation in
150 ``validation/v2_temperature_jump.py``), so the standard, exact way to represent
151 that in FEM is a single shared node at the interface, with each side's *element*
152 (not node) carrying its own material property. Building ``xs`` as two runs of
153 ``linspace`` that share their common endpoint (rather than gluing two separately-
154 spaced grids) guarantees that shared column exists exactly, with no search or
155 tolerance-based node-snapping needed.
156
157 Returns ``(mesh, zone_of_element)`` where ``zone_of_element`` is an ``(n_tri,)``
158 int array, 1 for elements entirely in ``[0, L1]`` and 2 for elements entirely in
159 ``[L1, L1+L2]`` — unambiguous because no element straddles the shared column by
160 construction.
161 """
162 if nx1 < 2 or nx2 < 2 or nr < 2:
163 raise ValueError("two_zone_duct_mesh needs at least 2 nodes per direction/zone")
164 if L1 <= 0 or L2 <= 0 or R <= 0:
165 raise ValueError("L1, L2, and R must be positive")
166
167 xs1 = np.linspace(0.0, L1, nx1) # nx1 columns, last one at x=L1
168 xs2 = np.linspace(L1, L1 + L2, nx2) # nx2 columns, first one at x=L1 (shared)
169 xs = np.concatenate([xs1, xs2[1:]]) # drop the duplicate shared column
170 rs = np.linspace(0.0, R, nr)
171 mesh = _structured_mesh(xs, rs)
172
173 interface_col = nx1 - 1 # 0-indexed column at x=L1
174 n_r_cells = nr - 1
175 zone_of_element = []
176 for i in range(len(xs) - 1):
177 zone = 1 if (i < interface_col) else 2
178 zone_of_element += [zone, zone] * n_r_cells # 2 triangles per (i, j) cell
179 return mesh, np.asarray(zone_of_element, dtype=np.int64)
tuple[int, np.ndarray] locate_point(self, float x0, float r0, float tol=1e-9)
Definition mesh.py:60
np.ndarray axis_nodes(self, float tol=1e-9)
Definition mesh.py:39
np.ndarray nodes_at_x(self, float x_value, float tol=1e-9)
Definition mesh.py:48
MeridionalMesh _structured_mesh(np.ndarray xs, np.ndarray rs)
Definition mesh.py:113
MeridionalMesh cylinder_mesh(float L, float R, int nx, int nr)
Definition mesh.py:127
tuple[MeridionalMesh, np.ndarray] two_zone_duct_mesh(float L1, float L2, float R, int nx1, int nx2, int nr)
Definition mesh.py:143
np.ndarray _grid_triangles(int nx, int nr)
Definition mesh.py:91