STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
contour.py
Go to the documentation of this file.
1"""Real chamber contour -> meridional acoustic mesh (P1, paper Sections III.F and IX).
2
3Replaces the synthetic rectangles of the P0 verification cases with the true chamber
4shape. The wall geometry deliberately MIRRORS the suite's hardware construction
5(``engine/core/chamber_geometry.py`` / ``chamber_geometry_solver.py`` — the same code
6that drives the DXF export), rather than inventing its own parameterization, so the
7acoustic domain is the machined part:
8
9 x = 0 (injector face)
10 |-- cylindrical section, radius R_c, length L_cyl
11 |-- straight contraction cone at half-angle theta_c (hardware default 45 deg)
12 |-- circular entrance arc, radius f*R_t (hardware f = 1.5), tangent to the cone
13 | and to the throat: center at (x_throat, (1+f)*R_t), swept down to the throat
14 x = x_end (truncation plane; the throat itself is never in the domain)
15
16Tangency algebra (matches ``rao()``'s entrance arc and
17``contraction_length_horizontal_calc`` exactly): the arc point at angle theta from its
18center has slope theta from vertical, so the cone (slope theta_c) meets it smoothly at
19
20 r_tangent = R_t * (1 + f*(1 - cos(theta_c))),
21 L_cone = (R_c - r_tangent) / tan(theta_c),
22 arc axial span (tangency -> throat) = f * R_t * sin(theta_c).
23
24Where does the ACOUSTIC domain end? (paper Section III.E)
25---------------------------------------------------------
26Not at the throat: the mean flow is sonic there, and the Helmholtz reduction (paper
27Appendix B, assumption i) requires low Mach. Instead the domain is truncated at a plane
28in the subsonic chamber, and everything downstream of that plane — convergent remainder,
29throat, supersonic bell — is represented by the Marble–Candel compact-nozzle admittance
30y = (gamma-1)*Mbar_e/2 (paper Appendix C) applied AT the truncation plane, with Mbar_e
31the mean Mach number there. Two defensible conventions, both supported:
32
33 * ``truncate_area_ratio=None`` (default): truncate at the convergence-start plane
34 (end of the cylinder). This is the classical rocket-stability treatment and the
35 paper's own words ("applied at the nozzle-entrance plane"): the ENTIRE convergent
36 section + throat is "the compact nozzle". Mbar_e is then the chamber Mach — low
37 (M ~ 0.1 for a contraction ratio of 6), where the Helmholtz assumptions are most
38 comfortable. Price: the convergent section's volume is excluded from the mode
39 computation, so longitudinal frequencies come out slightly high.
40 * ``truncate_area_ratio = A_plane/A_t in (1, CR)``: extend the domain down the cone/
41 arc to the plane with that area ratio, and evaluate Mbar_e there. Captures the
42 convergent volume's effect on the modes (more accurate frequencies); price is that
43 the neglected mean-flow terms, O(M), grow toward the plane. Keep the plane where
44 M <~ 0.4 (area ratio >~ 1.6) unless deliberately studying the sensitivity.
45
46Mbar_e comes from the subsonic branch of the isentropic area–Mach relation
47(``subsonic_mach_from_area_ratio``); the compact admittance itself is deliberately NOT
48computed here — geometry provides the Mach number, ``bcs``/assembly applies
49(gamma-1)/2 * M, keeping the physics of the boundary condition in one place.
50
51Meshing a mapped domain
52-----------------------
53The wall is now a function r_wall(x) instead of a constant, so the structured grid maps
54radially: logical node (i, j) sits at (x_i, r_wall(x_i) * j/(nr-1)). The triangulation
55topology (``mesh._grid_triangles``) is IDENTICAL to the P0 rectangles — which is the
56point: axis handling (row j=0 is exactly r=0), ``nodes_at_x`` for the admittance
57column (x_end is an exact grid column by construction), and all of ``assembly.py``
58work unchanged. The rigid sloped wall costs nothing: homogeneous Neumann is the
59natural BC of the weak form — it is imposed by NOT adding a boundary term there.
60
61The axial grid is built per segment (cylinder / cone / arc), sharing endpoints, so the
62segment breaks are exact node columns and no element straddles a slope discontinuity
63(same reasoning as ``two_zone_duct_mesh``'s interface column: the pressure is smooth
64there, but the wall slope is not, and elements that straddle a corner would smear it).
65"""
66
67from __future__ import annotations
68
69from dataclasses import dataclass
70from typing import Optional
71
72import numpy as np
73from scipy.optimize import brentq
74
75from engine.stability_hifi.acoustics.mesh import MeridionalMesh, _grid_triangles
76
77
78# ---------------------------------------------------------------------------
79# Isentropic area–Mach relation (subsonic branch)
80# ---------------------------------------------------------------------------
81
82def area_ratio_from_mach(M: float, gamma: float) -> float:
83 """Isentropic A/A* as a function of Mach number (either branch).
84
85 A/A* = (1/M) * [ (2/(gamma+1)) * (1 + (gamma-1)/2 * M^2) ]^((gamma+1)/(2(gamma-1)))
86
87 Standard 1-D compressible flow result (mass conservation + isentropic relations
88 between a plane at Mach M and the sonic throat).
89 """
90 if M <= 0:
91 return float("inf")
92 g = gamma
93 term = (2.0 / (g + 1.0)) * (1.0 + 0.5 * (g - 1.0) * M * M)
94 return float(term ** ((g + 1.0) / (2.0 * (g - 1.0))) / M)
95
96
97def subsonic_mach_from_area_ratio(area_ratio: float, gamma: float) -> float:
98 """Invert the area–Mach relation on the SUBSONIC branch (0 < M < 1).
99
100 The relation is not analytically invertible; on (0, 1) it is strictly decreasing
101 from +inf to 1, so for any area_ratio > 1 there is exactly one subsonic root,
102 bracketed and found by Brent's method. Used to get the mean Mach at the acoustic
103 truncation plane for the Marble–Candel admittance.
104 """
105 if area_ratio < 1.0:
106 raise ValueError(f"area_ratio must be >= 1 (got {area_ratio}); A < A* is unphysical")
107 if area_ratio == 1.0:
108 return 1.0
109 return float(brentq(lambda M: area_ratio_from_mach(M, gamma) - area_ratio, 1e-8, 1.0 - 1e-12))
110
111
112# ---------------------------------------------------------------------------
113# The contour
114# ---------------------------------------------------------------------------
115
116@dataclass
118 """Piecewise wall description of the acoustic domain (module docstring for geometry).
119
120 All x measured from the injector face. ``x_throat`` lies BEYOND ``x_end`` (the
121 throat is never inside the acoustic domain); it is kept for reference/plotting.
122 """
123 R_c: float # chamber (cylinder) radius [m]
124 R_t: float # throat radius [m]
125 theta_c: float # contraction cone half-angle [rad]
126 arc_factor: float # entrance-arc radius / R_t (hardware: 1.5)
127 L_cyl: float # cylinder length [m]
128 x_cone_end: float # cone/arc tangency plane [m] (= L_cyl + L_cone)
129 x_throat: float # virtual throat plane [m]
130 x_end: float # acoustic truncation plane [m]
131 area_ratio_end: float # A(x_end) / A_throat
132 mach_end: float # subsonic Mach at x_end (for the Marble–Candel BC)
133
134 def r_wall(self, x) -> np.ndarray:
135 """Wall radius at axial position(s) x — piecewise cylinder / cone / arc."""
136 x = np.asarray(x, dtype=float)
137 r = np.full_like(x, self.R_c)
138 on_cone = (x > self.L_cyl) & (x <= self.x_cone_end)
139 r = np.where(on_cone, self.R_c - (x - self.L_cyl) * np.tan(self.theta_c), r)
140 on_arc = x > self.x_cone_end
141 rho = self.arc_factor * self.R_t
142 dx = np.minimum(np.abs(x - self.x_throat), rho) # clamp: past-throat query saturates at R_t
143 r = np.where(on_arc, (1.0 + self.arc_factor) * self.R_t - np.sqrt(rho * rho - dx * dx), r)
144 return r if r.ndim else float(r)
145
146
147def build_chamber_contour(*, chamber_diameter: float, A_throat: float,
148 length_cylindrical: float, gamma: float,
149 theta_contraction_deg: float = 45.0,
150 arc_factor: float = 1.5,
151 truncate_area_ratio: Optional[float] = None
152 ) -> ChamberAcousticContour:
153 """Build the acoustic contour from the same inputs the hardware geometry uses
154 (``ChamberGeometryConfig``: chamber_diameter, A_throat, length_cylindrical;
155 the 45 deg cone and 1.5*R_t arc are the hardware defaults from
156 ``chamber_geometry_solver.solved_chamber_plot``).
157
158 ``truncate_area_ratio`` selects the acoustic truncation plane (module docstring):
159 None = convergence-start plane; else the plane where A(x)/A_t equals it.
160 """
161 R_c = 0.5 * chamber_diameter
162 R_t = float(np.sqrt(A_throat / np.pi))
163 if R_t >= R_c:
164 raise ValueError(f"throat radius {R_t:.4f} m >= chamber radius {R_c:.4f} m")
165 theta = np.radians(theta_contraction_deg)
166 f = arc_factor
167
168 r_tangent = R_t * (1.0 + f * (1.0 - np.cos(theta)))
169 if r_tangent >= R_c:
170 raise ValueError("entrance arc alone exceeds the chamber radius; "
171 "contraction ratio too small for this arc_factor/theta")
172 L_cone = (R_c - r_tangent) / np.tan(theta)
173 x_cone_end = length_cylindrical + L_cone
174 x_throat = x_cone_end + f * R_t * np.sin(theta)
175
176 CR = (R_c / R_t) ** 2
177 if truncate_area_ratio is None:
178 x_end = length_cylindrical
179 area_ratio_end = CR
180 else:
181 if not (1.05 <= truncate_area_ratio < CR):
182 raise ValueError(
183 f"truncate_area_ratio must be in [1.05, CR={CR:.2f}) — the throat plane "
184 f"(ratio 1) is sonic and outside Helmholtz validity")
185 r_trunc = R_t * np.sqrt(truncate_area_ratio)
186 if r_trunc >= r_tangent:
187 # plane lands on the straight cone
188 x_end = length_cylindrical + (R_c - r_trunc) / np.tan(theta)
189 else:
190 # plane lands on the entrance arc: invert r_arc(x)
191 rho = f * R_t
192 dx = np.sqrt(rho ** 2 - ((1.0 + f) * R_t - r_trunc) ** 2)
193 x_end = x_throat - dx
194 area_ratio_end = truncate_area_ratio
195
196 mach_end = subsonic_mach_from_area_ratio(area_ratio_end, gamma)
197 return ChamberAcousticContour(R_c=R_c, R_t=R_t, theta_c=theta, arc_factor=f,
198 L_cyl=length_cylindrical, x_cone_end=x_cone_end,
199 x_throat=x_throat, x_end=x_end,
200 area_ratio_end=area_ratio_end, mach_end=mach_end)
201
202
203# ---------------------------------------------------------------------------
204# Wall-mapped mesh
205# ---------------------------------------------------------------------------
206
207def _segment_xs(contour: ChamberAcousticContour, n_axial: int) -> np.ndarray:
208 """Axial node columns: per-segment linspaces sharing endpoints, counts allocated
209 proportionally to segment length (minimum 2 columns per nonempty segment), so the
210 cylinder/cone and cone/arc breaks — and x_end itself — are exact node columns.
211 """
212 breaks = [0.0, min(contour.L_cyl, contour.x_end)]
213 if contour.x_end > contour.L_cyl:
214 breaks.append(min(contour.x_cone_end, contour.x_end))
215 if contour.x_end > contour.x_cone_end:
216 breaks.append(contour.x_end)
217 breaks = np.array(sorted(set(breaks)))
218
219 lengths = np.diff(breaks)
220 total = lengths.sum()
221 xs_parts = []
222 for k, (a, b) in enumerate(zip(breaks[:-1], breaks[1:])):
223 n_seg = max(2, int(round(n_axial * (b - a) / total)) + 1)
224 seg = np.linspace(a, b, n_seg)
225 xs_parts.append(seg if k == 0 else seg[1:]) # drop duplicated shared endpoint
226 return np.concatenate(xs_parts)
227
228
229def contour_mesh(contour: ChamberAcousticContour, n_axial: int, nr: int) -> MeridionalMesh:
230 """Wall-mapped structured mesh of the acoustic domain.
231
232 Logical node (i, j) -> (x_i, r_wall(x_i) * j/(nr-1)): row j=0 is exactly the axis,
233 row j=nr-1 exactly the wall, and every column is a constant-x line (so
234 ``nodes_at_x(contour.x_end)`` finds the admittance-plane column exactly, as the
235 boundary assembly requires). Topology shared with the P0 meshes via
236 ``_grid_triangles`` — nothing downstream (assembly, axis conditions, solvers)
237 changes for a mapped domain.
238 """
239 if nr < 2 or n_axial < 4:
240 raise ValueError("need nr >= 2 and n_axial >= 4")
241 xs = _segment_xs(contour, n_axial)
242 r_wall = contour.r_wall(xs) # (nx,)
243 eta = np.linspace(0.0, 1.0, nr) # radial mapping coordinate
244 X = np.repeat(xs, nr)
245 Rr = np.outer(r_wall, eta).ravel()
246 nodes = np.column_stack([X, Rr])
247 return MeridionalMesh(nodes=nodes, triangles=_grid_triangles(len(xs), nr))
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
np.ndarray _segment_xs(ChamberAcousticContour contour, int n_axial)
Definition contour.py:207
float subsonic_mach_from_area_ratio(float area_ratio, float gamma)
Definition contour.py:97
MeridionalMesh contour_mesh(ChamberAcousticContour contour, int n_axial, int nr)
Definition contour.py:229
ChamberAcousticContour build_chamber_contour(*, float chamber_diameter, float A_throat, float length_cylindrical, float gamma, float theta_contraction_deg=45.0, float arc_factor=1.5, Optional[float] truncate_area_ratio=None)
Definition contour.py:152
float area_ratio_from_mach(float M, float gamma)
Definition contour.py:82