STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
assembly.py
Go to the documentation of this file.
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``.
4
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
8
9With these, the passive discrete eigenproblem (flame off, rigid walls: C=0, F=0) is
10
11 (K + m^2 Km) p = -lambda^2 M2 p
12
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``.
16
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:
22
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.
25
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.
29
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.
35
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.
39"""
40
41from __future__ import annotations
42
43import math
44from typing import Tuple
45
46import numpy as np
47from scipy import sparse
48
49from engine.stability_hifi.acoustics.mesh import MeridionalMesh
50
51
52# ---------------------------------------------------------------------------
53# Quadrature and element-level geometry
54# ---------------------------------------------------------------------------
55
56# 6-point symmetric Gauss quadrature on the reference triangle, exact to degree 4
57# (Dunavant 1985). Rows are barycentric coordinates (lambda1, lambda2, lambda3) of
58# the quadrature point; weights sum to 1 (fraction-of-area convention, so a physical
59# integral is `area * sum_q w_q * f(point_q)`).
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],
67])
68_GAUSS6_W = np.array([
69 0.223381589678011, 0.223381589678011, 0.223381589678011,
70 0.109951743655322, 0.109951743655322, 0.109951743655322,
71])
72
73
74def _element_geometry(v: np.ndarray) -> Tuple[float, np.ndarray]:
75 """Triangle area and constant P1 shape-function gradients.
76
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).
79 """
80 x = v[:, 0]
81 r = v[:, 1]
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
87 return area, grad
88
89
90# Exact triangle-integral tensor for int L_i L_j L_k dA, from the standard formula
91# int_T L1^a L2^b L3^c dA = a! b! c! / (a+b+c+2)! * 2*Area .
92# For a triple of shape-function indices (i, j, k) drawn from {0,1,2} (repeats
93# allowed), (a, b, c) is just the multiplicity of label 0, 1, 2 among the triple, so
94# the value depends only on how many of (i, j, k) coincide:
95# all three equal -> 3!0!0!/5! * 2A = A/10
96# exactly two equal -> 2!1!0!/5! * 2A = A/30
97# all three distinct -> 1!1!1!/5! * 2A = A/60
98def _triple_integral_factor(i: int, j: int, k: int) -> float:
99 counts = [0, 0, 0]
100 for idx in (i, j, k):
101 counts[idx] += 1
102 prod = math.factorial(counts[0]) * math.factorial(counts[1]) * math.factorial(counts[2])
103 return prod / math.factorial(3 + 2) # = prod/120; multiply by 2*Area for the integral
104
105
106_TRIPLE_FACTOR = np.array(
107 [[[_triple_integral_factor(i, j, k) for k in range(3)] for j in range(3)] for i in range(3)]
108) # (3,3,3); integral = 2*Area * _TRIPLE_FACTOR[i,j,k]
109
110
111def _mass_r_element(area: float, r_vertices: np.ndarray) -> np.ndarray:
112 """Exact element mass matrix ``int_T N_i N_j r dA`` (r linear -> cubic integrand).
113
114 Expand ``r = sum_k N_k r_k`` and use the exact triple-product triangle integral.
115 """
116 # Me[i,j] = sum_k r_k * (2*Area * _TRIPLE_FACTOR[i,j,k])
117 return 2.0 * area * np.tensordot(_TRIPLE_FACTOR, r_vertices, axes=([2], [0]))
118
119
120def _stiffness_r_element(area: float, grad: np.ndarray, r_vertices: np.ndarray) -> np.ndarray:
121 """Exact element stiffness ``int_T (grad N_i . grad N_j) r dA`` (centroid rule).
122
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.
125 """
126 r_bar = float(np.mean(r_vertices))
127 return area * r_bar * (grad @ grad.T)
128
129
130def _azimuthal_element(area: float, r_vertices: np.ndarray) -> np.ndarray:
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)
135 if r_q <= 0.0:
136 continue # quadrature point exactly on the axis: 1/r term vanishes weakly
137 Ke += (w * area / r_q) * np.outer(bary, bary)
138 return Ke
139
140
141# ---------------------------------------------------------------------------
142# Assembly
143# ---------------------------------------------------------------------------
144
145def assemble_passive(mesh: MeridionalMesh, c_sound: np.ndarray, m: int
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).
148
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.
157
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.
162
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.
166 """
167 n = mesh.n_nodes
168 rows, cols, vK, vKm, vM = [], [], [], [], []
169
170 need_azimuthal = (m != 0)
171
172 c_sound = np.asarray(c_sound)
173 if c_sound.shape[0] == mesh.n_nodes:
174 per_element_c2 = None # computed inside the loop, by averaging vertex values
175 elif c_sound.shape[0] == mesh.n_tri:
176 per_element_c2 = c_sound ** 2 # already one value per element; use directly
177 else:
178 raise ValueError(
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)"
181 )
182
183 for e, tri in enumerate(mesh.triangles):
184 v = mesh.nodes[tri] # (3, 2)
185 r_vertices = v[:, 1]
186 area, grad = _element_geometry(v)
187 if area <= 0.0:
188 raise ValueError("degenerate (zero-area) triangle in mesh")
189
190 c2_e = float(per_element_c2[e]) if per_element_c2 is not None else float(np.mean(c_sound[tri]) ** 2)
191
192 Ke = c2_e * _stiffness_r_element(area, grad, r_vertices)
193 Me = _mass_r_element(area, r_vertices)
194 Kme = c2_e * _azimuthal_element(area, r_vertices) if need_azimuthal else np.zeros((3, 3))
195
196 for a in range(3):
197 for b in range(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])
200
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()
204 return K, Km, M2
205
206
207# ---------------------------------------------------------------------------
208# Boundary admittance term C (Eq. 8, 10) — new for verification case V3
209# ---------------------------------------------------------------------------
210#
211# Section III.E, Eq. (8): a Robin condition grad(p_hat).n = -(lambda / (c*z)) * p_hat
212# on a boundary patch with specific impedance z (admittance y = 1/z). In the weak form
213# (Eq. 10) this becomes a boundary integral that, after moving everything to one side,
214# contributes a term "+ lambda * C" to the discrete operator N(lambda), where
215#
216# C = int_Gamma (c_bar / z) p_tilde phi_bar r dGamma = int_Gamma (c_bar * y) ... r dGamma
217#
218# For the *compact* choked-nozzle admittance (Appendix C), y = y_noz = (gamma-1)*Mbar_e/2
219# is a REAL CONSTANT (no dependence on lambda or position along the boundary), so C here
220# is just a fixed real matrix -- the general C(lambda) notation in the paper allows for a
221# frequency-dependent z(lambda) (e.g. the quasi-1D nozzle admittance ODE, Section III.E),
222# which is not needed for this compact case.
223#
224# Geometrically the boundary here is the disk at x = L (all r in [0, R]): revolved about
225# the axis, a "boundary edge" of the meridional mesh is a radial line segment between two
226# adjacent boundary nodes, and dGamma = dr along it (x fixed). The r-weighted edge mass
227# matrix int_edge N_i N_j r dr has the same kind of exact closed form as the volume mass
228# matrix M2 (Eq. above): r is linear along the edge, so N_i*N_j*r is a cubic polynomial
229# in the edge's local (1-D barycentric) coordinate, integrated exactly below.
230
231def _boundary_mass_r_edge(r_a: float, r_b: float) -> np.ndarray:
232 """Exact 2x2 edge matrix ``int_edge N_i N_j r dr`` for a radial edge from r_a to r_b.
233
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).
238 """
239 L = abs(r_b - r_a)
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],
243 ])
244
245
246def assemble_boundary_admittance(mesh: MeridionalMesh, boundary_nodes: np.ndarray,
247 coefficient: float) -> sparse.csr_matrix:
248 """Assemble ``C`` for a compact (frequency-independent) admittance boundary patch.
249
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.
255 """
256 n = mesh.n_nodes
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]
261 Ce = coefficient * _boundary_mass_r_edge(r_vals[e], r_vals[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()
266
267
268# ---------------------------------------------------------------------------
269# Compact-flame vectors b_k, g_k — new for verification case V3
270# ---------------------------------------------------------------------------
271#
272# Eq. 11's flame matrix is F(lambda) = (gamma-1) * sum_k exp(-lambda*tau_k) * g_k @ b_k.T
273# with two DIFFERENT roles, not one vector reused twice:
274#
275# b_k = [N_i(x_ref,k)]_i a plain POINT SAMPLE (Eq. 7a: the flame reads the pressure
276# at one reference point x_ref, dimensionless interpolation,
277# no r-weighting).
278# g_k = [int_Omega n_p (qbar_dot/pbar) N_i r dOmega]_i a VOLUME-INTEGRATED weight
279# (how much of the domain's test function each node's
280# energy-injection couples to; carries the same r dOmega
281# measure as every other matrix here).
282#
283# Treating both as the same plain point sample (an easy mistake — an earlier attempt at
284# this module did exactly that) silently makes the flame's effect on the discrete system
285# depend on the chamber radius R in an unphysical way: g_k, unlike b_k, needs the r
286# dOmega measure to have consistent physical scale against K, M2, and C, all of which
287# carry the same measure. Concretely: a point-sampled g_k does not shrink as R shrinks,
288# but M2 and K do (both are r-weighted volume integrals) — so on a smaller-radius mesh
289# the flame term becomes artificially, arbitrarily stronger relative to everything else.
290# Verified numerically before trusting this: with a point-sampled g_k, even a nominally
291# "very weak" flame_gain moved the mode's frequency by hundreds of Hz, an obviously
292# unphysical sensitivity for a small parameter, and the shift did not scale down when
293# flame_gain was reduced further -- the tell that the *coupling itself*, not just its
294# value, was mis-scaled. Fixed below by giving g_k its own, properly r-weighted builder.
295
296def point_sampling_vector(mesh: MeridionalMesh, x0: float, r0: float) -> np.ndarray:
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.
299
300 Nonzero only at the 3 nodes of the triangle containing the point (its barycentric
301 weights there — see ``MeridionalMesh.locate_point``).
302 """
303 tri_index, weights = mesh.locate_point(x0, r0)
304 b = np.zeros(mesh.n_nodes)
305 b[mesh.triangles[tri_index]] = weights
306 return b
307
308
309def disk_load_vector(mesh: MeridionalMesh, column_nodes: np.ndarray) -> np.ndarray:
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).
316
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).
323
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.
327 """
328 n = mesh.n_nodes
329 g = np.zeros(n)
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]
333 edge_len = r_b - r_a
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)
336 return g
float _triple_integral_factor(int i, int j, int k)
Definition assembly.py:98
np.ndarray point_sampling_vector(MeridionalMesh mesh, float x0, float r0)
Definition assembly.py:296
np.ndarray disk_load_vector(MeridionalMesh mesh, np.ndarray column_nodes)
Definition assembly.py:309
np.ndarray _stiffness_r_element(float area, np.ndarray grad, np.ndarray r_vertices)
Definition assembly.py:120
np.ndarray _boundary_mass_r_edge(float r_a, float r_b)
Definition assembly.py:231
Tuple[sparse.csr_matrix, sparse.csr_matrix, sparse.csr_matrix] assemble_passive(MeridionalMesh mesh, np.ndarray c_sound, int m)
Definition assembly.py:146
np.ndarray _mass_r_element(float area, np.ndarray r_vertices)
Definition assembly.py:111
sparse.csr_matrix assemble_boundary_admittance(MeridionalMesh mesh, np.ndarray boundary_nodes, float coefficient)
Definition assembly.py:247
Tuple[float, np.ndarray] _element_geometry(np.ndarray v)
Definition assembly.py:74
np.ndarray _azimuthal_element(float area, np.ndarray r_vertices)
Definition assembly.py:130