STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layer1_figure_of_merit.py
Go to the documentation of this file.
1"""Layer 1's figure of merit: delivered Isp against chamber mass (L1-02, DEF-07).
2
3With no performance term the blank ethalox objective reached exactly 0 for any feasible engine,
4so six seeds returned six engines (Pc 368-405 psia, L* 1.14-1.49 m, n 6-12). The term is the
5propellant the delivered Isp costs for the required total impulse, I/(g0 Isp) (Sutton &
6Biblarz ch. 2), relative to an ideal engine, and it is blind to the requirement-pinned O/F and
7Pc so it cannot pull them off target. Also L1-12 (an O/F outside the CEA table no longer
8aborts) and L1-13 (expansion ratio from the table's own exit pressure).
9
10Independent references: rocketcea (NASA CEA, shifting equilibrium) and hand arithmetic.
11"""
12from __future__ import annotations
13
14import math
15from pathlib import Path
16
17import numpy as np
18import pytest
19
21
22rocketcea = pytest.importorskip("rocketcea.cea_obj")
23
24ROOT = Path(__file__).resolve().parents[1]
25PSI = 6894.757
26PA = 94070.0
27G0 = 9.80665
28
29
30@pytest.fixture(scope="module")
31def cea():
32 from engine.pipeline.io import load_config
33 from engine.pipeline.cea_cache import CEACache
34 return CEACache(load_config(str(ROOT / "configs" / "ethalox_6500N.yaml")).combustion.cea)
35
36
37@pytest.fixture(scope="module")
38def rc():
39 return rocketcea.CEA_Obj(oxName="LOX", fuelName="Ethanol")
40
41
42def _rc_isp(rc, pc_psi, of, eps):
43 return rc.estimate_Ambient_Isp(Pc=pc_psi, MR=of, eps=eps, Pamb=PA / PSI)[0]
44
45
46def _constants(**over):
47 from test_layer1_objective_unified import _constants as base
48 return base(**over)
49
50
51def _setup():
52 from test_layer1_objective_unified import _req, _result, _x
53 return _req, _result, _x
54
55
57 """Two designs that differ only in delivered Isp must not tie (they did: 0 and 0)."""
58 _req, _result, _x = _setup()
59 c = _constants(layer1_W_ISP=1000.0, layer1_Isp_ref_s=264.0)
60 assert (L1._compute_objective_value(_result(Isp=237.0), _x(), _req(), c)
61 < L1._compute_objective_value(_result(Isp=230.0), _x(), _req(), c))
62 lo = L1._compute_objective_value(_result(Isp=230.0), _x(), _req(), c, return_terms=True)
63 hi = L1._compute_objective_value(_result(Isp=237.0), _x(), _req(), c, return_terms=True)
64 # readable: the propellant beyond an ideal engine's, per mille (hand arithmetic)
65 assert lo["terms"]["isp_penalty"] == pytest.approx(1000.0 * (264.0 / 230.0 - 1.0), rel=1e-12)
66 assert hi["terms"]["isp_penalty"] == pytest.approx(1000.0 * (264.0 / 237.0 - 1.0), rel=1e-12)
67
68
70 """O/F is a requirement: a design that runs at 1.6 against 1.5 gets no thermodynamic
71 credit for the 1.6 (ideal Isp 1.1 % higher there). Ratio from rocketcea."""
72 pc_psi, eps = 431.1, 5.3
73 ratio = _rc_isp(rc, pc_psi, 1.5, eps) / _rc_isp(rc, pc_psi, 1.6, eps)
74 res = {"Isp": 237.0, "MR": 1.6, "Pc": pc_psi * PSI, "eps": eps}
75 merit = L1._layer1_isp_merit_s(res, {"optimal_of": 1.5, "P_ambient": PA}, cea)
76 assert merit / 237.0 == pytest.approx(ratio, rel=3e-4)
77 assert ratio < 0.995 # the hold is not a no-op at this point
78 on_target = dict(res, MR=1.5)
79 assert L1._layer1_isp_merit_s(on_target, {"optimal_of": 1.5, "P_ambient": PA}, cea) == 237.0
80
81
83 """With a Pc target the merit sees at most the +0.8 % aim band (inside the 1 % gate)."""
84 eps = 5.3
85 res = {"Isp": 237.0, "MR": 1.5, "Pc": 450.0 * PSI, "eps": eps}
86 c = {"optimal_of": 1.5, "P_ambient": PA, "layer1_target_Pc_pa": 430.0 * PSI}
87 ratio = _rc_isp(rc, 430.0 * 1.008, 1.5, eps) / _rc_isp(rc, 450.0, 1.5, eps)
88 assert L1._layer1_isp_merit_s(res, c, cea) / 237.0 == pytest.approx(ratio, rel=5e-5)
89
90
92 """DEF-07: a blank design (W_MASS null) had W_MASS = W_LSTAR = 0 and nothing to choose L*
93 with. The defaults price a kilogram of chamber like a kilogram of propellant."""
94 pc = 430.0 * PSI
95 w = L1._layer1_merit_weights({}, cea, 1.5, pc, PA, (4.0, 14.0), 6500.0, 3.9, 0.0, 5.0)
96 eps_rc = rc.get_eps_at_PcOvPe(Pc=430.0, MR=1.5, PcOvPe=pc / PA)
97 isp_ref = _rc_isp(rc, 430.0, 1.5, eps_rc)
98 assert w["Isp_ref_s"] == pytest.approx(isp_ref, rel=1e-3)
99 m_prop = 6500.0 * 3.9 / (G0 * isp_ref)
100 assert w["m_prop_ref_kg"] == pytest.approx(m_prop, rel=1e-3)
101 assert w["W_ISP"] == 1000.0
102 # marginal price per kg equal at the reference mass: W_ISP/m_prop = 2 W_MASS/m_ref
103 assert 2.0 * w["W_MASS"] / 5.0 == pytest.approx(1000.0 / m_prop, rel=1e-3)
104 # an explicit W_MASS is the user's and is kept
105 w2 = L1._layer1_merit_weights({"layer1_W_MASS": 3000.0}, cea, 1.5, pc, PA, (4.0, 14.0),
106 6500.0, 3.9, 3000.0, 5.0)
107 assert w2["W_MASS"] == 3000.0
108
109
111 """DEF-07 test (b): L* 1.14 and 1.49 m scored identically (0.0) on the blank config."""
112 _req, _result, _x = _setup()
113 w = L1._layer1_merit_weights({}, cea, 1.5, 430.0 * PSI, PA, (4.0, 14.0), 6500.0, 3.9, 0.0, 5.0)
114 c = _constants(layer1_W_ISP=w["W_ISP"], layer1_Isp_ref_s=w["Isp_ref_s"],
115 layer1_W_MASS=w["W_MASS"], layer1_chamber_wall_density_kg_m3=2000.0)
116 a = L1._compute_objective_value(_result(), _x(lstar=1.14), _req(), c)
117 b = L1._compute_objective_value(_result(), _x(lstar=1.49), _req(), c)
118 assert a < 1e6 and b < 1e6 and b > a
119
120
122 """L1-12: the blank impinging template keeps O/F 2.8; ethalox's table stops at 2.5 and
123 Layer 1 aborted every run. It now designs to the peak ideal Isp O/F (rocketcea) and says so."""
124 from engine.pipeline.io import load_config
125 cfg = load_config(str(ROOT / "configs" / "ethalox_6500N.yaml"))
126 pc_psi = 461.5
127 of, msg = L1._layer1_resolve_of_target(cfg, 2.8, cea, pc_psi * PSI, PA)
128 grid = np.linspace(1.0, 2.5, 151)
129 isp = [_rc_isp(rc, pc_psi, m, rc.get_eps_at_PcOvPe(Pc=pc_psi, MR=m, PcOvPe=pc_psi * PSI / PA))
130 for m in grid]
131 assert of == pytest.approx(float(grid[int(np.argmax(isp))]), abs=0.03)
132 assert msg and "outside the CEA table" in msg
133 assert L1._layer1_resolve_of_target(cfg, 1.5, cea, pc_psi * PSI, PA) == (1.5, None)
134 of_edge, msg_edge = L1._layer1_resolve_of_target(cfg, 2.8, None)
135 assert of_edge == 2.5 and msg_edge
136
137
139 """L1-12, end to end: run_layer1_optimization raised ValueError before any work."""
140 import copy
141 from unittest.mock import MagicMock, patch
142 from engine.pipeline.io import load_config
143 real_cfg = load_config(str(ROOT / "configs" / "impinging_smoke.yaml"))
144 lo, hi = real_cfg.combustion.cea.MR_range
145 mock_engine = MagicMock()
146 mock_engine.evaluate.return_value = {"F": 7000.0, "Pc": 2.5e6, "MR": float(hi), "Cf": 1.55,
147 "Cf_actual": 1.55, "mdot_O": 1.0, "mdot_F": 0.45,
148 "stability_results": {}}
149 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]
150
151 def _dc(obj, memo=None):
152 return real_cfg if obj is real_cfg else copy.deepcopy(obj)
153
154 with patch("engine.optimizer.layers.layer1_static_optimization.copy.deepcopy", side_effect=_dc), \
155 patch("engine.optimizer.layers.layer1_static_optimization.PintleEngineRunner",
156 return_value=mock_engine), \
157 patch("engine.optimizer.layers.layer1_static_optimization.cma.CMAEvolutionStrategy") as mcma, \
158 patch("engine.optimizer.layers.layer1_static_optimization.minimize") as mmin, \
159 patch("engine.optimizer.layers.layer1_static_optimization.ProcessPoolExecutor"):
160 mcma.return_value.stop.return_value = True
161 mcma.return_value.result.xbest = x13
162 mcma.return_value.result.fbest = 0.1
163 lb = MagicMock(); lb.x = np.array(x13); lb.fun = 0.05; lb.success = True
164 mmin.return_value = lb
165 req = real_cfg.design_requirements.model_dump()
166 req["optimal_of_ratio"] = float(hi) + 1.3
167 _, results = L1.run_layer1_optimization(
168 config_obj=real_cfg, runner=MagicMock(), requirements=req, target_burn_time=10.0,
169 tolerances={"thrust": 0.1}, pressure_config={"mode": "optimizer_controlled"},
170 layer1_max_iterations=1, layer1_cma_restarts=1)
171 warns = results["performance"].get("layer1_warnings") or []
172 assert any("outside the CEA table" in w for w in warns)
173
174
176 """L1-13: the chamber-gamma isentrope gave eps 5.60 at 433.9 psia, Pe/Pa 0.93 in CEA;
177 rocketcea's matched eps there is 5.30."""
178 pc_psi = 433.9
179 res = {"Pc": pc_psi * PSI, "MR": 1.5, "gamma": 1.1375}
180 eps = L1._layer1_matched_eps(res, PA, cea, 5.6)
181 pe = pc_psi * PSI / rc.get_PcOvPe(Pc=pc_psi, MR=1.5, eps=eps)
182 assert pe == pytest.approx(PA, rel=0.01)
183 assert eps == pytest.approx(rc.get_eps_at_PcOvPe(Pc=pc_psi, MR=1.5, PcOvPe=pc_psi * PSI / PA),
184 rel=2e-3)
test_derived_expansion_ratio_puts_the_table_exit_pressure_at_ambient(cea, rc)