STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layer1_objective_unified.py
Go to the documentation of this file.
1"""One Layer-1 objective, scored on the chamber and engine that are actually drawn.
2
3L1-03 / CN-3: the worker and inline objectives were two implementations that disagreed by
4~300 points on one design (worker cone to R_t, clipped at 1 m; config cone to r_tan). Both now
5call ``_compute_objective_value``. CN-9: engine length was cone + 0.8 D_exit instead of
6face-to-throat + the 80 % Rao bell the contour draws. L1-06, L1-09, L1-10, L1-11 and INJ-7 are
7defects in the same scoring function.
8
9Expected numbers come from hand geometry (frustum + entrance arc, H&H ch. 4) and the Rao
10contour generator, never from the function under test.
11"""
12from __future__ import annotations
13
14import copy
15import inspect
16import math
17from pathlib import Path
18
19import numpy as np
20import pytest
21
23from engine.pipeline.io import load_config
24
25ROOT = Path(__file__).resolve().parents[1]
26
27# The shipped 6500 N point (configs/ethalox_6500N.yaml chamber_geometry).
28AT = 1.533694488707181e-3
29BORE = 0.127
30WALL = 0.0381
31LSTAR = 1.0000002573548417
32EPS = 5.598521540485944
33PC = 2.972e6
34
35
36def _x(eps=EPS, lstar=LSTAR, ang=(43.0, 46.0), n=24, P=583.1):
37 return np.array([AT, lstar, eps, BORE + WALL, float(n), 2.4e-3, ang[0], 0.0126,
38 2.2e-3, ang[1], 0.0152, P, P], dtype=float)
39
40
41def _constants(**over):
42 c = {
43 "injector_type": "impinging", "idx_P_O": 11, "idx_P_F": 12,
44 "target_thrust": 6500.0, "optimal_of": 1.5, "P_ambient": 94070.0,
45 "max_lox_P_psi": 600.0, "max_fuel_P_psi": 600.0,
46 "TOTAL_WALL_THICKNESS_M": WALL, "max_nozzle_exit": 0.2032,
47 "layer1_W_THRUST": 6e4, "layer1_W_OF": 2e4,
48 "layer1_derive_impingement_spacing": False,
49 "W_IMP_GEOM": 0.0, "W_CHAMBER_SHAPE": 0.0, "W_MOM": 0.0, "W_DP": 0.0,
50 "W_DP_O": 0.0, "W_DP_F": 0.0, "W_DP_HIGH": 0.0, "W_DP_CENTER": 0.0,
51 "layer1_W_EXIT": 0.0,
52 "layer1_resultant_tilt_max_deg": 45.0, # the liner tilt gate is not under test here
53 }
54 c.update(over)
55 return c
56
57
58def _req(**over):
59 r = {"min_stability_margin": 0.0, "min_stability_score": 0.0,
60 "require_stable_state": False, "layer1_infeasibility_gate_eps": 0.002,
61 "max_engine_length": 0.40}
62 r.update(over)
63 return r
64
65
66def _result(**over):
67 r = {"F": 6500.0, "MR": 1.5, "Pc": PC, "Cf": 1.417, "Cf_actual": 1.417, "Isp": 235.0,
68 "P_exit": 94070.0, "eps": EPS, "mdot_O": 1.69, "mdot_F": 1.13,
69 "stability_results": {"stability_state": "stable", "stability_score": 1.0,
70 "chugging": {"stability_margin": 1.3},
71 "acoustic": {"stability_margin": 1.3},
72 "feed_system": {"stability_margin": 1.3}},
73 "diagnostics": {"momentum_ratio_R": 1.0}}
74 r.update(over)
75 return r
76
77
78def _hand_lengths(theta=math.radians(45.0)):
79 """Frustum-plus-arc chamber, by hand (H&H ch. 4 geometry), independent of the module."""
80 Rt = math.sqrt(AT / math.pi)
81 Rc = BORE / 2.0
82 r_tan = Rt * (1.0 + 1.5 * (1.0 - math.cos(theta)))
83 L_cone = (Rc - r_tan) / math.tan(theta)
84 # cone volume between Rc and r_tan, arc volume between r_tan and Rt (numerical)
85 V_cone = math.pi * L_cone / 3.0 * (Rc ** 2 + Rc * r_tan + r_tan ** 2)
86 t = np.linspace(-(math.pi / 2 + theta), -math.pi / 2, 20001)
87 xa = 1.5 * Rt * np.cos(t)
88 ya = 1.5 * Rt * np.sin(t) + 2.5 * Rt
89 V_arc = float(np.sum(np.pi * 0.5 * (ya[1:] ** 2 + ya[:-1] ** 2) * np.diff(xa)))
90 L_cyl = (LSTAR * AT - V_cone - V_arc) / (math.pi * Rc ** 2)
91 L_arc = 1.5 * Rt * math.sin(theta)
92 return L_cyl, L_cone, L_arc
93
94
96 """CN-3: the mass term uses the chamber length _layer1_apply_chamber_geometry_to_config
97 writes. The worker used a cone to R_t (140.2 mm here) against 130.5 mm built."""
98 L_cyl, L_cone, _ = _hand_lengths()
99 cfg = load_config(str(ROOT / "configs" / "ethalox_6500N.yaml"))
100 L1._layer1_apply_chamber_geometry_to_config(
101 cfg, A_throat=AT, Lstar=LSTAR, expansion_ratio=EPS, D_chamber_outer=BORE + WALL,
102 max_nozzle_exit=0.2032, wall_thickness_m=WALL)
103 assert cfg.chamber_geometry.length == pytest.approx(L_cyl + L_cone, abs=2e-5)
104
105 c_on = _constants(layer1_W_MASS=3000.0, layer1_chamber_wall_density_kg_m3=3400.0,
106 layer1_chamber_mass_ref_kg=5.0, layer1_target_Pc_pa=PC, layer1_W_PC=0.0)
107 c_off = dict(c_on, layer1_W_MASS=0.0)
108 d = (L1._compute_objective_value(_result(), _x(), _req(), c_on)
109 - L1._compute_objective_value(_result(), _x(), _req(), c_off))
110 m = L1._layer1_chamber_mass_kg(math.pi * BORE ** 2 / 4, L_cyl + L_cone, WALL, 3400.0, Pc_pa=PC)
111 assert d == pytest.approx(3000.0 * (m / 5.0) ** 2, rel=1e-3)
112
113
115 """CN-9: face-to-exit length of the drawn engine is 244.1 mm here (CN-1 contour
116 integration); the estimate used 214-224 mm and let a 240 mm limit through."""
117 from engine.core.nozzle_solver import rao
118 L_cyl, L_cone, L_arc = _hand_lengths()
119 xs, _ = rao(AT, AT * EPS, bell_percent=0.8, do_plot=False, method="top")[:2]
120 L_true = L_cyl + L_cone + L_arc + float(np.max(xs))
121 assert L_true == pytest.approx(0.2441, abs=5e-4)
122 c = _constants()
123 over = L1._compute_objective_value(_result(), _x(), _req(max_engine_length=L_true - 0.004), c)
124 under = L1._compute_objective_value(_result(), _x(), _req(max_engine_length=L_true + 0.004), c)
125 assert over >= 1e6 > under
126
127
128@pytest.mark.parametrize("eps", [3.0, 5.6, 20.0])
130 from engine.core.nozzle_solver import rao
131 xs, _ = rao(AT, AT * eps, bell_percent=0.8, do_plot=False, method="top")[:2]
132 assert L1._layer1_bell_length_m(AT, eps) == pytest.approx(float(np.max(xs)), abs=5e-4)
133
134
136 """L1-03: the closure must not keep a second scoring (obj_quality sum of its own)."""
137 src = inspect.getsource(L1.run_layer1_optimization)
138 assert "_layer1_objective_terms(" in src
139 assert "obj_quality = (" not in src
140
141
143 """L1-06: Pc_actual was bound only inside the Pc-target branch (UnboundLocalError)."""
144 c = _constants(layer1_W_MASS=3000.0)
145 v = L1._compute_objective_value(_result(), _x(), _req(), c)
146 assert np.isfinite(v)
147
148
150 """L1-09: the free band was [1/1.05, 1.05] whatever impinging_momentum_R_min/max said."""
151 # layer1_momentum_band_width 0.05 is what run_layer1_optimization always passes
152 c = _constants(W_MOM=75.0, impinging_momentum_R_min=0.98, impinging_momentum_R_max=1.02,
153 layer1_momentum_band_width=0.05)
154 c0 = dict(c, W_MOM=0.0)
155 res = _result(diagnostics={"momentum_ratio_R": 1.04})
156 assert (L1._compute_objective_value(res, _x(), _req(), c)
157 > L1._compute_objective_value(res, _x(), _req(), c0))
158 res_in = _result(diagnostics={"momentum_ratio_R": 1.01})
159 assert (L1._compute_objective_value(res_in, _x(), _req(), c)
160 == L1._compute_objective_value(res_in, _x(), _req(), c0))
161
162
164 """L1-10: 'Require stable (not just marginal)' allowed {'stable', 'marginal'}."""
165 sr = dict(_result()["stability_results"], stability_state="marginal")
166 obj = L1._compute_objective_value(_result(stability_results=sr), _x(),
167 _req(require_stable_state=True), _constants())
168 assert obj >= 1e6
169 ok = L1._compute_objective_value(_result(), _x(), _req(require_stable_state=True), _constants())
170 assert ok < 1e6
171
172
174 """L1-10: a hidden 5 % let a chug gate margin of 0.9975 pass a 1.05 floor."""
175 passed, parts = L1._layer1_stability_gate(
176 "stable", 1.0, 0.9975, 1.3, 1.3,
177 {"chugging": {"stability_margin": 0.9975, "chug_gain_margin": 0.998}},
178 {"min_stability_score": 0.5, "require_stable_state": False}, 1.05)
179 assert not passed and any("chugging_margin" in p for p in parts)
180 passed_nan, parts_nan = L1._layer1_stability_gate(
181 "stable", 1.0, 1.10, 1.3, 1.3, {"chugging": {"stability_margin": 1.10,
182 "chug_gain_margin": float("nan")}},
183 {"min_stability_score": 0.5, "require_stable_state": False}, 1.05)
184 assert not passed_nan and "chug gain margin unknown" in parts_nan
185
186
188 """INJ-7: axial standoff 4 d_avg at 60/60 deg is 8 d along each jet (> 7 d)."""
189 n, d = 20.0, 2.0e-3
190 s_O = 0.010
191 tan_sum = 2.0 * math.tan(math.radians(60.0))
192 s_F = s_O + 2.0 * math.pi * 4.0 * d * tan_sum / n # axial L_imp = 4 d
193 ld = L1._impinging_free_jet_ld(n_elements=n, spacing_O_m=s_O, spacing_F_m=s_F,
194 d_jet_O_m=d, d_jet_F_m=d, angle_O_deg=60.0, angle_F_deg=60.0)
195 assert ld == pytest.approx(8.0, rel=1e-9)
196 x = _x(ang=(60.0, 60.0), n=int(n))
197 x[5], x[7], x[8], x[10] = d, s_O, d, s_F
198 obj = L1._compute_objective_value(_result(), x, _req(), _constants())
199 assert obj >= 1e6
200 x45 = x.copy()
201 x45[6] = x45[9] = 40.0
202 tan40 = 2.0 * math.tan(math.radians(40.0))
203 x45[10] = s_O + 2.0 * math.pi * 4.0 * d * tan40 / n # 4/cos40 = 5.2 d
204 assert L1._compute_objective_value(_result(), x45, _req(), _constants()) < 1e6
205
206
208 """L1-11: after 200 consecutive failures the inline objective returned 1e5, which
209 counts as feasible and beats every infeasible-but-evaluable design."""
210 from unittest.mock import MagicMock, patch
211 real_cfg = load_config(str(ROOT / "configs" / "impinging_smoke.yaml"))
212 engine_mock = MagicMock()
213 engine_mock.evaluate.side_effect = ValueError("Supply < Demand at all Pc")
214 x13 = [0.002, 1.0, 8.0, 0.14, 12.0, 0.002, 45.0, 0.012, 0.0022, 45.0, 0.011, 550.0, 650.0]
215
216 def _dc(obj, memo=None):
217 return real_cfg if obj is real_cfg else copy.deepcopy(obj)
218
219 with patch("engine.optimizer.layers.layer1_static_optimization.copy.deepcopy", side_effect=_dc), \
220 patch("engine.optimizer.layers.layer1_static_optimization.PintleEngineRunner",
221 return_value=engine_mock), \
222 patch("engine.optimizer.layers.layer1_static_optimization.cma.CMAEvolutionStrategy") as mcma, \
223 patch("engine.optimizer.layers.layer1_static_optimization.minimize") as mmin, \
224 patch("engine.optimizer.layers.layer1_static_optimization.ProcessPoolExecutor"):
225 mcma.return_value.stop.return_value = True
226 mcma.return_value.result.xbest = x13
227 mcma.return_value.result.fbest = 0.1
228 lb = MagicMock(); lb.x = np.array(x13); lb.fun = 0.05; lb.success = True
229 mmin.return_value = lb
230 req = real_cfg.design_requirements.model_dump()
231 try:
232 L1.run_layer1_optimization(
233 config_obj=real_cfg, runner=MagicMock(), requirements=req, target_burn_time=10.0,
234 tolerances={"thrust": 0.1}, pressure_config={"mode": "optimizer_controlled"},
235 layer1_max_iterations=1, layer1_cma_restarts=1)
236 except Exception:
237 pass # validation cannot replay a design nothing evaluates; not under test
238 assert mmin.call_args is not None
239 objective = mmin.call_args[0][0]
240 vals = [objective(np.array(x13, dtype=float)) for _ in range(205)]
241 assert min(vals) >= 1e6
242
243
245 """The face-closure thickness in the chamber-mass proxy used t = a sqrt(0.3 p / sigma),
246 37 % thinner than Roark's fixed-edge plate, sigma_max = 3 p a^2 / (4 t^2)."""
247 a, p, sig, rho = 0.0635, 3.0e6, 205e6, 3400.0
248 t = a * math.sqrt(3.0 * p / (4.0 * sig))
249 A_c = math.pi * (2 * a) ** 2 / 4
250 m_barrel_only = L1._layer1_chamber_mass_kg(A_c, 0.1, WALL, rho, Pc_pa=0.0)
251 m = L1._layer1_chamber_mass_kg(A_c, 0.1, WALL, rho, Pc_pa=p)
252 assert m - m_barrel_only == pytest.approx(math.pi * a * a * t * rho, rel=1e-12)
#define min(a, b)
Definition Arduino.h:42
_x(eps=EPS, lstar=LSTAR, ang=(43.0, 46.0), n=24, P=583.1)