1"""FEM assembly of the passive (no flame, no boundary impedance) axisymmetric
2thermoacoustic operator: the ``K``, ``Km``, ``M2`` matrices of Eq. (10)/(11) in
3``docs/stability/thermoacoustic_global_stability_paper.md``.
5 K = int_Omega c^2 (grad p . grad phi) r dOmega stiffness
6 Km = int_Omega (c^2 / r) p phi dOmega azimuthal term (x m^2)
7 M2 = int_Omega p phi r dOmega mass
9With these, the passive discrete eigenproblem (flame off, rigid walls: C=0, F=0) is
11 (K + m^2 Km) p = -lambda^2 M2 p
13a *linear* generalized eigenvalue problem in mu = -lambda^2 (real, >= 0 for a lossless
14rigid-wall cavity, since K, Km, M2 are all real symmetric positive-(semi)definite) —
15see ``eigen/passive.py``.
17Why K and M2 have exact closed forms but Km does not
18-----------------------------------------------------
19On a P1 (linear) triangle, the shape functions N_i are the barycentric coordinates
20L_i, and grad(N_i) is *constant* over the element. The radial coordinate r(x) is
21itself a linear (degree-1) function of position, since it is a coordinate. So:
23 - K's integrand, c^2 * (grad N_i . grad N_j) * r, is (const) * (linear in r) ->
24 integrable exactly by the 1-point centroid rule: int_T r dA = Area * r_centroid.
26 - M2's integrand, N_i * N_j * r, is a *cubic* polynomial (degree 2 from N_i N_j,
27 degree 1 from r). Triangle integrals of monomials in barycentric coordinates have
28 a standard closed form (see ``_mass_r_element`` below), so M2 is also exact.
30 - Km's integrand, N_i * N_j / r, is *not* polynomial (r appears in the denominator)
31 so no closed form exists; it is evaluated with a 6-point Gauss quadrature rule
32 (exact for polynomials up to degree 4), which is far more resolution than the
33 (non-polynomial) 1/r factor needs for engineering accuracy, and cheap since it
34 only touches the m>=1 (transverse) matrix.
36Mean-flow fields (``c``, i.e. sound speed) are supplied per-node and averaged over the
37three vertices of an element to get a single c^2 per element — piecewise-constant per
38element, consistent with the P1 discretization.
41from __future__
import annotations
44from typing
import Tuple
47from scipy
import sparse
60_GAUSS6_BARY = np.array([
61 [0.108103018168070, 0.445948490915965, 0.445948490915965],
62 [0.445948490915965, 0.108103018168070, 0.445948490915965],
63 [0.445948490915965, 0.445948490915965, 0.108103018168070],
64 [0.816847572980459, 0.091576213509771, 0.091576213509771],
65 [0.091576213509771, 0.816847572980459, 0.091576213509771],
66 [0.091576213509771, 0.091576213509771, 0.816847572980459],
69 0.223381589678011, 0.223381589678011, 0.223381589678011,
70 0.109951743655322, 0.109951743655322, 0.109951743655322,
75 """Triangle area and constant P1 shape-function gradients.
77 ``v`` is (3, 2): vertex (x, r) coordinates. Returns ``(area, grad)`` with
78 ``grad[i] = (dN_i/dx, dN_i/dr)``, constant over the element (standard P1 formula).
82 two_area = x[0] * (r[1] - r[2]) + x[1] * (r[2] - r[0]) + x[2] * (r[0] - r[1])
83 area = 0.5 * abs(two_area)
84 b = np.array([r[1] - r[2], r[2] - r[0], r[0] - r[1]])
85 c = np.array([x[2] - x[1], x[0] - x[2], x[1] - x[0]])
86 grad = np.column_stack([b, c]) / two_area
100 for idx
in (i, j, k):
102 prod = math.factorial(counts[0]) * math.factorial(counts[1]) * math.factorial(counts[2])
103 return prod / math.factorial(3 + 2)
106_TRIPLE_FACTOR = np.array(
112 """Exact element mass matrix ``int_T N_i N_j r dA`` (r linear -> cubic integrand).
114 Expand ``r = sum_k N_k r_k`` and use the exact triple-product triangle integral.
117 return 2.0 * area * np.tensordot(_TRIPLE_FACTOR, r_vertices, axes=([2], [0]))
121 """Exact element stiffness ``int_T (grad N_i . grad N_j) r dA`` (centroid rule).
123 ``grad`` is constant over the element, and ``r`` is linear, so the 1-point
124 centroid rule (`int_T r dA = Area * mean(r_vertices)`) is exact.
126 r_bar = float(np.mean(r_vertices))
127 return area * r_bar * (grad @ grad.T)
131 """Element ``int_T N_i N_j / r dA`` via 6-point Gauss quadrature (no closed form)."""
132 Ke = np.zeros((3, 3))
133 for bary, w
in zip(_GAUSS6_BARY, _GAUSS6_W):
134 r_q = float(bary @ r_vertices)
137 Ke += (w * area / r_q) * np.outer(bary, bary)
146 ) -> Tuple[sparse.csr_matrix, sparse.csr_matrix, sparse.csr_matrix]:
147 """Assemble the passive (flame-off, rigid-wall) K, Km, M2 matrices (Eq. 10, 11).
149 ``c_sound`` is the sound-speed field [m/s], given EITHER per-node (shape
150 ``(n_nodes,)`` — the mean flow varies smoothly, e.g. V1's uniform field or a real
151 CFD/parametric temperature profile; each element's c^2 is the mean of its 3 vertex
152 values, i.e. the P1 linear interpolant evaluated at the centroid) OR per-element
153 (shape ``(n_tri,)`` — the material is piecewise-constant across a genuine interface
154 that a shared node cannot represent, e.g. V2's temperature-jump duct; each element's
155 c^2 is used directly, with no averaging across the interface). See
156 ``_element_c_squared`` above for why this distinction matters physically.
158 ``m`` is the azimuthal wavenumber; ``Km`` is returned *without* the m^2 factor
159 (caller multiplies when assembling ``K + m^2 * Km``), so the m=0 matrix (which has
160 no 1/r term at all) can be skipped cheaply by callers that only need
161 longitudinal/radial modes.
163 Returns ``(K, Km, M2)`` as real symmetric sparse CSR matrices of size n_nodes^2.
164 Axis-regularity Dirichlet elimination for m>=1 is the caller's job (``eigen/passive.py``),
165 since it depends on how the reduced system is solved, not on the assembly.
168 rows, cols, vK, vKm, vM = [], [], [], [], []
170 need_azimuthal = (m != 0)
172 c_sound = np.asarray(c_sound)
173 if c_sound.shape[0] == mesh.n_nodes:
174 per_element_c2 =
None
175 elif c_sound.shape[0] == mesh.n_tri:
176 per_element_c2 = c_sound ** 2
179 f
"c_sound has length {c_sound.shape[0]}, expected n_nodes={mesh.n_nodes} "
180 f
"(smooth per-node field) or n_tri={mesh.n_tri} (piecewise-constant per-element field)"
183 for e, tri
in enumerate(mesh.triangles):
188 raise ValueError(
"degenerate (zero-area) triangle in mesh")
190 c2_e = float(per_element_c2[e])
if per_element_c2
is not None else float(np.mean(c_sound[tri]) ** 2)
194 Kme = c2_e *
_azimuthal_element(area, r_vertices)
if need_azimuthal
else np.zeros((3, 3))
198 rows.append(tri[a]); cols.append(tri[b])
199 vK.append(Ke[a, b]); vKm.append(Kme[a, b]); vM.append(Me[a, b])
201 K = sparse.coo_matrix((vK, (rows, cols)), shape=(n, n)).tocsr()
202 Km = sparse.coo_matrix((vKm, (rows, cols)), shape=(n, n)).tocsr()
203 M2 = sparse.coo_matrix((vM, (rows, cols)), shape=(n, n)).tocsr()
232 """Exact 2x2 edge matrix ``int_edge N_i N_j r dr`` for a radial edge from r_a to r_b.
234 Derived the same way as ``_mass_r_element`` but for a 1-D edge (2 nodes) instead of
235 a 2-D triangle (3 nodes): expand r(zeta) = r_a*(1-zeta) + r_b*zeta linearly in the
236 edge's local coordinate and integrate exactly (integrals of monomials in 1-D
237 barycentric coordinates have the same style of closed form as the 2-D case).
240 return L * np.array([
241 [r_a / 4.0 + r_b / 12.0, (r_a + r_b) / 12.0],
242 [(r_a + r_b) / 12.0, r_a / 12.0 + r_b / 4.0],
247 coefficient: float) -> sparse.csr_matrix:
248 """Assemble ``C`` for a compact (frequency-independent) admittance boundary patch.
250 ``boundary_nodes`` must be the node indices along the boundary, SORTED BY RADIUS
251 (exactly what ``MeridionalMesh.nodes_at_x`` returns) — consecutive pairs are treated
252 as the mesh's boundary edges there. ``coefficient`` is ``c_bar * y`` (real, for the
253 compact admittance case); the caller multiplies the returned matrix into ``lambda*C``
254 when assembling the full operator.
257 rows, cols, vals = [], [], []
258 r_vals = mesh.nodes[boundary_nodes, 1]
259 for e
in range(len(boundary_nodes) - 1):
260 i, j = boundary_nodes[e], boundary_nodes[e + 1]
262 for a, ia
in enumerate((i, j)):
263 for b, ib
in enumerate((i, j)):
264 rows.append(ia); cols.append(ib); vals.append(Ce[a, b])
265 return sparse.coo_matrix((vals, (rows, cols)), shape=(n, n)).tocsr()
297 """The b_k vector: nodal weights such that ``g @ p`` = the P1-interpolated value of
298 field ``p`` at the point ``(x0, r0)`` — a plain point sample, Eq. 7a's x_ref.
300 Nonzero only at the 3 nodes of the triangle containing the point (its barycentric
301 weights there — see ``MeridionalMesh.locate_point``).
303 tri_index, weights = mesh.locate_point(x0, r0)
304 b = np.zeros(mesh.n_nodes)
305 b[mesh.triangles[tri_index]] = weights
310 """The g_k vector for a compact-in-x, UNIFORM-across-the-cross-section flame at the
311 axial column given by ``column_nodes`` (node indices there, SORTED BY RADIUS —
312 exactly what ``MeridionalMesh.nodes_at_x`` returns; the flame location must
313 therefore fall on an exact mesh column, e.g. by building the mesh with
314 ``two_zone_duct_mesh`` even when there is no real material jump, purely to get an
315 exact shared column at the flame's axial location).
317 ``g_i = int_r N_i(x_f, r) * r dr`` along that column: same r dOmega measure as K,
318 M2, C (see the module-level note above for why that consistency matters), computed
319 per edge via the same barycentric-monomial exact-integral trick as
320 ``_boundary_mass_r_edge``, but for a LOAD VECTOR (int N_i * 1 * r dr) rather than a
321 mass MATRIX (int N_i N_j r dr): for an edge from r_a to r_b, the exact two-node
322 contribution is ``L*[r_a/3+r_b/6, r_a/6+r_b/3]`` (L = r_b - r_a).
324 Sanity check used while developing this: summed over the whole column, ``g``'s
325 total is exactly ``int_0^R r dr = R^2/2`` — reproduced below to machine precision,
326 confirming the exact-integral bookkeeping is right.
330 r_vals = mesh.nodes[column_nodes, 1]
331 for e
in range(len(column_nodes) - 1):
332 r_a, r_b = r_vals[e], r_vals[e + 1]
334 g[column_nodes[e]] += edge_len * (r_a / 3.0 + r_b / 6.0)
335 g[column_nodes[e + 1]] += edge_len * (r_a / 6.0 + r_b / 3.0)
float _triple_integral_factor(int i, int j, int k)
np.ndarray point_sampling_vector(MeridionalMesh mesh, float x0, float r0)
np.ndarray disk_load_vector(MeridionalMesh mesh, np.ndarray column_nodes)
np.ndarray _stiffness_r_element(float area, np.ndarray grad, np.ndarray r_vertices)
np.ndarray _boundary_mass_r_edge(float r_a, float r_b)
Tuple[sparse.csr_matrix, sparse.csr_matrix, sparse.csr_matrix] assemble_passive(MeridionalMesh mesh, np.ndarray c_sound, int m)
np.ndarray _mass_r_element(float area, np.ndarray r_vertices)
sparse.csr_matrix assemble_boundary_admittance(MeridionalMesh mesh, np.ndarray boundary_nodes, float coefficient)
Tuple[float, np.ndarray] _element_geometry(np.ndarray v)
np.ndarray _azimuthal_element(float area, np.ndarray r_vertices)