STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layer1_derived_dofs.py
Go to the documentation of this file.
1"""Guards for the Layer-1 DOFs that are solved rather than searched.
2
3``expansion_ratio`` and ``A_throat`` are determined by requirements the user
4already stated (expand to ambient; hit the thrust target), so Layer 1 solves
5them and collapses their CMA-ES bounds. These tests pin the parts that are easy
6to break silently:
7
8 * the closed-form expansion-ratio inversion actually reproduces the target
9 exit pressure when pushed back through the isentropic relation;
10 * the solver respects its clamps and degrades safely on nonsense input;
11 * both toggles are real config fields, so the frontend checkboxes reach the
12 optimizer instead of being dropped by the schema.
13"""
14from __future__ import annotations
15
16import numpy as np
17import pytest
18
20 _layer1_eps_for_exit_pressure,
21 _layer1_solve_derived_geometry,
22 _requirement_bool,
23)
24from engine.pipeline.config_schemas import DesignRequirementsConfig
25
26PSI = 6894.76
27
28
29def _exit_pressure_from_eps(eps: float, Pc: float, gamma: float) -> float:
30 """Invert the area-ratio relation numerically: eps -> Pe.
31
32 Deliberately independent of the production formula so this is a real check
33 and not a restatement of the code under test.
34 """
35 g = gamma
36
37 def area_ratio(M: float) -> float:
38 return (1.0 / M) * (
39 (2.0 / (g + 1.0)) * (1.0 + 0.5 * (g - 1.0) * M * M)
40 ) ** ((g + 1.0) / (2.0 * (g - 1.0)))
41
42 lo, hi = 1.0 + 1e-9, 50.0
43 for _ in range(200):
44 mid = 0.5 * (lo + hi)
45 if area_ratio(mid) < eps:
46 lo = mid
47 else:
48 hi = mid
49 M = 0.5 * (lo + hi)
50 return Pc / (1.0 + 0.5 * (g - 1.0) * M * M) ** (g / (g - 1.0))
51
52
53@pytest.mark.parametrize("Pc_psi", [200.0, 395.6, 450.0, 700.0])
54@pytest.mark.parametrize("gamma", [1.10, 1.1367, 1.20])
56 Pc = Pc_psi * PSI
57 Pe_target = 13.6436 * PSI
58
59 eps = _layer1_eps_for_exit_pressure(Pc, gamma, Pe_target)
60 assert eps is not None and eps > 1.0
61
62 Pe_actual = _exit_pressure_from_eps(eps, Pc, gamma)
63 # Exact by construction; the tolerance only covers the bisection above.
64 assert Pe_actual == pytest.approx(Pe_target, rel=1e-6)
65
66
68 Pe = 13.6 * PSI
69 # Chamber below ambient, zero/negative pressures, and a gamma outside the
70 # ideal-gas range must all decline rather than return a garbage area ratio.
71 assert _layer1_eps_for_exit_pressure(0.5 * Pe, 1.2, Pe) is None
72 assert _layer1_eps_for_exit_pressure(-1.0, 1.2, Pe) is None
73 assert _layer1_eps_for_exit_pressure(400 * PSI, 1.2, 0.0) is None
74 assert _layer1_eps_for_exit_pressure(400 * PSI, 0.9, Pe) is None
75 assert _layer1_eps_for_exit_pressure(400 * PSI, float("nan"), Pe) is None
76
77
79 """An unreachable thrust target must clamp to the bound, not run away.
80
81 The residual is then carried by the ordinary thrust penalty, which the
82 infeasibility gradient can still descend.
83 """
84 x = np.array([1.5e-3, 1.0, 5.0, 0.15, 20, 2e-3, 45.0, 5e-3,
85 1.7e-3, 45.0, 5e-3, 550.0, 550.0], dtype=float)
86 at_lo, at_hi = 1.0e-3, 2.0e-3
87 constants = {
88 "layer1_derive_expansion_ratio": False,
89 "layer1_derive_throat_from_thrust": True,
90 "layer1_derive_max_iters": 6,
91 "derive_At_min": at_lo,
92 "derive_At_max": at_hi,
93 "derive_eps_min": 2.0,
94 "derive_eps_max": 20.0,
95 "P_ambient": 13.6 * PSI,
96 # Absurd target: no throat area in range can deliver this.
97 "target_thrust": 1.0e9,
98 "max_nozzle_exit": 0.2,
99 "TOTAL_WALL_THICKNESS_M": 0.006,
100 }
101
102 class _Cfg:
103 chamber_geometry = None
104 chamber = None
105 nozzle = None
106 combustion = type("C", (), {})()
107
108 calls = {"n": 0}
109
110 def _evaluate():
111 calls["n"] += 1
112 return {"F": 7000.0, "Pc": 400 * PSI, "gamma": 1.14}
113
114 # A stub config would blow up inside the geometry writer, so only exercise
115 # the clamp when the writer is a no-op.
117
118 original = L1._layer1_apply_chamber_geometry_to_config
119 L1._layer1_apply_chamber_geometry_to_config = lambda *a, **k: 5.0
120 try:
121 _layer1_solve_derived_geometry(x, _Cfg(), constants, _evaluate,
122 {"F": 7000.0, "Pc": 400 * PSI, "gamma": 1.14})
123 finally:
124 L1._layer1_apply_chamber_geometry_to_config = original
125
126 assert at_lo <= x[0] <= at_hi, "solved A_throat escaped its search range"
127 assert x[0] == pytest.approx(at_hi), "unreachable target should pin to the upper bound"
128 assert calls["n"] <= int(constants["layer1_derive_max_iters"])
129
130
132 x = np.array([1.5e-3, 1.0, 5.0, 0.15], dtype=float)
133 before = x.copy()
134 res = {"F": 1.0, "Pc": 400 * PSI, "gamma": 1.14}
135 out = _layer1_solve_derived_geometry(
136 x, object(),
137 {"layer1_derive_expansion_ratio": False,
138 "layer1_derive_throat_from_thrust": False},
139 lambda: (_ for _ in ()).throw(AssertionError("must not evaluate")),
140 res,
141 )
142 assert out is res
143 np.testing.assert_array_equal(x, before)
144
145
148 layer1_derive_expansion_ratio=False,
149 layer1_derive_throat_from_thrust=False,
150 )
151 dumped = req.model_dump()
152 assert dumped["layer1_derive_expansion_ratio"] is False
153 assert dumped["layer1_derive_throat_from_thrust"] is False
154 # Unset must mean "use the default", not "off".
155 assert DesignRequirementsConfig().model_dump()["layer1_derive_expansion_ratio"] is None
156 assert _requirement_bool(dumped, "layer1_derive_expansion_ratio", True) is False
157 assert _requirement_bool({}, "layer1_derive_expansion_ratio", True) is True
158
159
160@pytest.mark.parametrize(
161 "raw,expected",
162 [("false", False), ("False", False), ("0", False), ("no", False), ("off", False),
163 ("true", True), ("1", True), ("yes", True), (0, False), (1, True), (None, True)],
164)
166 assert _requirement_bool({"k": raw}, "k", True) is expected
167
168
169# ---------------------------------------------------------------------------
170# Manufacturability snapping
171# ---------------------------------------------------------------------------
172
174 _quantize_chamber_od_m,
175 _tank_pressure_equal_squared,
176)
177
178IN = 0.0254
179
180
182 wall = 1.1299 * IN
183 od = _quantize_chamber_od_m(5.9629 * IN, 0.5, wall_m=wall, snap_target="outer")
184 assert od / IN == pytest.approx(6.0, abs=1e-9)
185
186
188 wall = 1.1299 * IN
189 od = _quantize_chamber_od_m(5.9629 * IN, 0.5, wall_m=wall, snap_target="bore")
190 assert (od - wall) / IN == pytest.approx(5.0, abs=1e-9)
191 # ...and then the OD is NOT a stock size. That is the whole point of the choice:
192 # bore = OD - wall, so only one of the two can land on the increment.
193 assert abs(round((od / IN) / 0.5) * 0.5 - od / IN) > 1e-6
194
195
197 for inc in (0.0, -1.0, None, float("nan")):
198 assert _quantize_chamber_od_m(0.1234, inc) == pytest.approx(0.1234)
199
200
202 wall = 1.0 * IN
203 od = _quantize_chamber_od_m(5.9 * IN, 0.5, wall_m=wall, snap_target="nonsense")
204 assert od / IN == pytest.approx(6.0, abs=1e-9)
205
206
208 """The in-band pull is OFF by default.
209
210 It scales with W_TANK_EQUAL, so at W=30000 it charged 300 objective points for a delta
211 sitting INSIDE the tolerance -- most of the reported residual, for a design that met the
212 spec. It exists only to stop the optimizer parking just outside the band, and a large
213 W_TANK_EQUAL already does that.
214 """
215 kw = dict(scale_psi=10.0, tol_psi=10.0)
216 assert _tank_pressure_equal_squared(600.0, 598.0, **kw) == 0.0
217 assert _tank_pressure_equal_squared(600.0, 590.1, **kw) == 0.0
218 # ...and it is still available when explicitly asked for.
219 near = _tank_pressure_equal_squared(600.0, 598.0, inband_frac=0.01, **kw)
220 edge = _tank_pressure_equal_squared(600.0, 590.1, inband_frac=0.01, **kw)
221 assert edge > near > 0.0
222
223 # ...but the pull stays negligible against a real violation.
224 out = _tank_pressure_equal_squared(600.0, 570.0, **kw)
225 assert out > 20.0 * edge
226
227 # inband_frac=0 restores the original flat deadband exactly.
228 flat_near = _tank_pressure_equal_squared(600.0, 598.0, inband_frac=0.0, **kw)
229 flat_edge = _tank_pressure_equal_squared(600.0, 590.1, inband_frac=0.0, **kw)
230 assert flat_near == 0.0 and flat_edge == 0.0
231
232
234 """Index 6 is the LOX angle for doublets but n_orifices for a pintle.
235
236 Snapping [6, 9] on a pintle vector would quantise an orifice diameter, so the
237 injector-type guard is load-bearing, not cosmetic.
238 """
239 import inspect
241
242 src = inspect.getsource(L1.run_layer1_optimization)
243 idx = src.index("layer1_integer_jet_angles")
244 guard = src[max(0, idx - 400):idx]
245 assert 'l1_injector_type == "impinging"' in guard
246
247
248# ---------------------------------------------------------------------------
249# Discrete DOFs handed to CMA-ES as discrete
250# ---------------------------------------------------------------------------
251
253 _layer1_cma_discrete_options,
254)
255
256
258 """Element counts and whole-degree angles have granularity 1 -> integer_variables."""
259 iv, minstd = _layer1_cma_discrete_options(13, [4, 6, 9])
260 assert iv == [4, 6, 9]
261 assert [i for i, v in enumerate(minstd) if v > 0] == [4, 6, 9]
262 assert all(minstd[i] == pytest.approx(0.2) for i in (4, 6, 9))
263
264
266 """Its granularity is one stock increment in METRES, not 1.
267
268 Declaring it an integer variable would centre it on integer metres, which is
269 meaningless. It only needs a std floor so sigma cannot collapse inside one cell.
270 """
271 step = 0.5 * 0.0254
272 iv, minstd = _layer1_cma_discrete_options(13, [4, 6, 9], od_index=3, od_step_m=step)
273 assert 3 not in iv, "chamber OD must not be declared an integer variable"
274 assert minstd[3] == pytest.approx(0.2 * step)
275 assert minstd[3] < 0.2, "floor must be in metres, not integer units"
276
277
279 """With snapping disabled the coordinate really is continuous."""
280 iv, minstd = _layer1_cma_discrete_options(13, [4], od_index=3, od_step_m=0.0)
281 assert minstd[3] == 0.0
282 assert iv == [4]
283
284
286 iv, minstd = _layer1_cma_discrete_options(5, [1, 9, -2], od_index=99, od_step_m=0.01)
287 assert iv == [1]
288 assert len(minstd) == 5
289
290
292 """A block optimizes a SUBSET, so global indices do not address the sub-vector.
293
294 Guards the remap in run_hybrid_optimization: passing the global list would mark the
295 wrong coordinates as integers.
296 """
297 import inspect
299
300 src = inspect.getsource(L1.run_hybrid_optimization)
301 assert "_blk_int_dims = [j for j, g in enumerate(_blk) if g in set(integer_dims or [])]" in src
302 assert "_blk_od_index = _blk.index(od_index) if od_index in _blk else -1" in src
303
304 # and the remap itself is correct
305 block_indices = [1, 3, 4, 7, 9]
306 integer_dims = [4, 6, 9]
307 blk_int = [j for j, g in enumerate(block_indices) if g in set(integer_dims)]
308 assert blk_int == [2, 4] # global 4 and 9 sit at sub-indices 2 and 4
309 assert block_indices.index(3) == 1 # the chamber OD lands at sub-index 1
310
311
312# ---------------------------------------------------------------------------
313# Impingement standoff
314# ---------------------------------------------------------------------------
315
317 """It must be in optimized_parameters -- that dict IS the frontend's geometry table.
318
319 Also pins the canonical SEED: both rings used to sit on the same 38.2 mm pitch circle,
320 which is a zero radial offset, i.e. a doublet whose two jets never converge. The old
321 standoff formula reported a plausible number for that degenerate geometry; this one
322 returns NaN, so a finite value here is the guard.
323 """
324 from engine.optimizer.utils import extract_all_parameters
325 from engine.pipeline.io import load_config
326
327 cfg = load_config("configs/canonical/impinging.yaml")
328 params = extract_all_parameters(cfg)
329 assert "L_imp" in params
330 assert np.isfinite(params["L_imp"]) and params["L_imp"] > 0.0
331 assert params["ring_radial_offset"] > 0.0, "seed doublet rings must not be coincident"
332 assert params["D_pitch_F"] > params["D_pitch_O"], "seed must put fuel outboard"
333 d_avg = 0.5 * (params["d_jet_O"] + params["d_jet_F"])
334 assert 3.0 <= params["L_imp"] / d_avg <= 7.0, "seed must start inside the L/d band"
335
336
337# ---------------------------------------------------------------------------
338# Doublet ring geometry
339# ---------------------------------------------------------------------------
340
342 """The offset a doublet closes is the gap between the two RINGS.
343
344 ``spacing`` is the arc between adjacent holes on ONE ring -- it says how densely that
345 ring is populated and nothing about how far the O and F jets of a single doublet are
346 from each other. Using it as the offset (the old behaviour) under-reports the standoff.
347 """
348 from engine.core.injectors.impinging import impingement_standoff_m
349 import math
350
351 n, s_O, s_F, a_O, a_F = 30, 0.006716, 0.004889, 43.0, 47.0
352 dr = 0.5 * abs(n * s_O / math.pi - n * s_F / math.pi)
353 tan_sum = math.tan(math.radians(a_O)) + math.tan(math.radians(a_F))
354 assert impingement_standoff_m(n, s_O, s_F, a_O, a_F) == pytest.approx(dr / tan_sum)
355
356 # The discarded formula, for contrast -- it is a different, smaller number.
357 wrong = 0.5 * (s_O + s_F) / tan_sum
358 assert impingement_standoff_m(n, s_O, s_F, a_O, a_F) > wrong
359
360 # Element count now matters, because it sets the pitch circles.
361 assert impingement_standoff_m(60, s_O, s_F, a_O, a_F) == pytest.approx(
362 2 * impingement_standoff_m(30, s_O, s_F, a_O, a_F))
363
364 # Coincident rings never cross.
365 assert not np.isfinite(impingement_standoff_m(n, s_O, s_O, a_O, a_F))
366
367
370 _impinging_ring_geometry_squared,
371 )
372
373 common = dict(
374 n_elements=30, d_jet_O_m=0.00196, d_jet_F_m=0.00168,
375 D_chamber_inner_m=0.1016, angle_O_deg=43.0, angle_F_deg=47.0,
376 Ld_min=3.0, Ld_max=7.0,
377 )
378 # LOX ring outboard (larger pitch) -- what we do NOT want.
379 lox_out = _impinging_ring_geometry_squared(
380 spacing_O_m=0.0067, spacing_F_m=0.0049, **common)
381 # Fuel ring outboard -- same |dr|, so identical impingement physics.
382 fuel_out = _impinging_ring_geometry_squared(
383 spacing_O_m=0.0049, spacing_F_m=0.0067, **common)
384 assert lox_out > fuel_out, "fuel-outboard must be the cheaper arrangement"
385
386 # ...and the preference is switchable, for anyone who wants the other order.
387 off_lox_out = _impinging_ring_geometry_squared(
388 spacing_O_m=0.0067, spacing_F_m=0.0049,
389 ring_order_fuel_outboard=False, **common)
390 off_fuel_out = _impinging_ring_geometry_squared(
391 spacing_O_m=0.0049, spacing_F_m=0.0067,
392 ring_order_fuel_outboard=False, **common)
393 assert off_lox_out == pytest.approx(off_fuel_out)
394
395
397 """It was opt-in while the infeasibility plateau made a hard constraint stall the
398 search. The gradient fixed that, so leaving it off silences a weight the user set."""
399 from engine.optimizer.layers.layer1_static_optimization import _requirement_bool
400 assert _requirement_bool({}, "layer1_enforce_ring_geometry", True) is True
401 assert _requirement_bool(
402 {"layer1_enforce_ring_geometry": False}, "layer1_enforce_ring_geometry", True) is False
403
404
406 """Both numbers, so 'spacing' can't be misread as the ring diameter."""
407 from engine.optimizer.utils import extract_all_parameters
408 from engine.pipeline.io import load_config
409 import math
410
411 cfg = load_config("configs/canonical/impinging.yaml")
412 p = extract_all_parameters(cfg)
413 n = p["n_doublets"]
414 assert p["D_pitch_O"] == pytest.approx(n * p["spacing_O"] / math.pi)
415 assert p["D_pitch_F"] == pytest.approx(n * p["spacing_F"] / math.pi)
416 assert p["ring_radial_offset"] == pytest.approx(0.5 * abs(p["D_pitch_O"] - p["D_pitch_F"]))
417 # The pitch circle is much larger than the hole pitch -- that is the whole confusion.
418 assert p["D_pitch_O"] > 5 * p["spacing_O"]
419
420
421# ---------------------------------------------------------------------------
422# L* target correlated to spray SMD
423# ---------------------------------------------------------------------------
424
426 _layer1_lstar_target_from_smd,
427 _layer1_lstar_band_term,
428 _layer1_resolve_lstar_target,
429 _effective_smd_um,
430)
431
432
434 """SMD 99 um must give a 1.05-1.15 m window."""
435 t = _layer1_lstar_target_from_smd(99.0)
436 assert t == pytest.approx(1.10, abs=1e-9)
437 assert (t - 0.05, t + 0.05) == pytest.approx((1.05, 1.15))
438
439
441 """tau_vap ~ D32^2 and tau_res ~ L*, so L*_target ~ SMD^2 -- not linear, not a fit."""
442 base = _layer1_lstar_target_from_smd(50.0)
443 assert _layer1_lstar_target_from_smd(100.0) == pytest.approx(4.0 * base)
444 assert _layer1_lstar_target_from_smd(150.0) == pytest.approx(9.0 * base)
445 # finer spray => shorter chamber
446 assert _layer1_lstar_target_from_smd(70.0) < _layer1_lstar_target_from_smd(99.0)
447
448
450 """A target pinned at the ceiling is information: the spray is too coarse to vaporise
451 inside the chamber the requirements allow."""
452 t = _layer1_lstar_target_from_smd(150.0, Lstar_min_m=0.5, Lstar_max_m=1.2)
453 assert t == pytest.approx(1.2)
454 assert _layer1_lstar_target_from_smd(30.0, Lstar_min_m=0.5, Lstar_max_m=1.2) == pytest.approx(0.5)
455
456
458 tgt, db = 1.10, 0.1
459 assert _layer1_lstar_band_term(1.10, tgt, db) == 0.0
460 # Band edges: exactly free up to float round-off on |L*-target| - half.
461 assert _layer1_lstar_band_term(1.05, tgt, db) == pytest.approx(0.0, abs=1e-30)
462 assert _layer1_lstar_band_term(1.15, tgt, db) == pytest.approx(0.0, abs=1e-30)
463 # Under-shooting now costs too: droplets do not finish evaporating.
464 assert _layer1_lstar_band_term(0.90, tgt, db) > 0.0
465 assert _layer1_lstar_band_term(1.40, tgt, db) > 0.0
466 # Saturating, so an unreachable target can never dominate a hard gate.
467 assert _layer1_lstar_band_term(50.0, tgt, db) < 1.0
468
469
471 """model_dump() emits None for every unset Optional -- .get(key, default) returns None,
472 not the default. This crashed every run until the resolver handled it."""
473 req = {
474 "layer1_Lstar_deadband_m": None,
475 "layer1_Lstar_from_smd": None,
476 "layer1_Lstar_smd_ref_um": None,
477 "layer1_Lstar_ref_m": None,
478 "layer1_Lstar_smd_exponent": None,
479 "min_Lstar": None,
480 "max_Lstar": None,
481 }
482 tgt, db = _layer1_resolve_lstar_target(99.0, req, req.get, 1.0)
483 assert tgt == pytest.approx(1.10)
484 assert db == pytest.approx(0.1)
485
486
488 req = {}
489 # No SMD available -> fixed target, penalty still active.
490 tgt, _ = _layer1_resolve_lstar_target(float("nan"), req, req.get, 0.85)
491 assert tgt == pytest.approx(0.85)
492 # Explicit opt-out -> fixed target.
493 req = {"layer1_Lstar_from_smd": False}
494 tgt, _ = _layer1_resolve_lstar_target(99.0, req, req.get, 0.85)
495 assert tgt == pytest.approx(0.85)
496
497
499 diag = {"D32_O": 110e-6, "D32_F": 80e-6}
500 mr = 1.69
501 expected = (mr / (1 + mr) * 110e-6 + 1 / (1 + mr) * 80e-6) * 1e6
502 assert _effective_smd_um(diag, mr) == pytest.approx(expected)
503 assert not np.isfinite(_effective_smd_um({}, mr))
504 assert not np.isfinite(_effective_smd_um(None, mr))
505
506
508 from engine.pipeline.config_schemas import DesignRequirementsConfig
509 import inspect
511
512 assert "layer1_impingement_Ld_target" in DesignRequirementsConfig.model_fields
513 from engine.core.injectors.layout import impingement_ld_band
514 # The default is a value, not a string in the source: resolve it the way Layer 1 does.
515 assert impingement_ld_band({}).target == 4.0
516 src = inspect.getsource(L1.run_layer1_optimization)
517 assert "impingement_ld_band(" in src, "Layer 1 must resolve the band through the shared resolver"
518
519
521 """One equation, one unknown -- the standoff must land ON the number, not near it."""
523 from engine.core.injectors.impinging import impingement_standoff_m
524
525 for k in (3.0, 4.0, 5.5, 7.0):
526 x = np.array([1.6e-3, 1.13, 5.2, 0.152, 30, 1.965e-3, 43.0, 3.0e-3,
527 1.685e-3, 47.0, 9.9e-3, 590.0, 600.0], dtype=float)
528 c = {"injector_type": "impinging", "layer1_derive_impingement_spacing": True,
529 "layer1_impingement_Ld_target": k, "layer1_ring_order_fuel_outboard": True,
530 "derive_spacing_F_min": 1e-4, "derive_spacing_F_max": 0.05}
531 L1._layer1_derive_fuel_spacing(x, c)
532 d_avg = 0.5 * (x[5] + x[8])
533 L = impingement_standoff_m(x[4], x[7], x[10], x[6], x[9])
534 assert L / d_avg == pytest.approx(k, rel=1e-9), f"k={k}"
535 assert x[10] > x[7], "fuel ring must end up outboard"
536
537
539 """The worker truncates int(x[4]) while apply_x_to_config rounds; the derivation divides
540 by n, so the two must be made to agree or the solved standoff misses."""
542
543 x = np.array([1.6e-3, 1.13, 5.2, 0.152, 29.7, 1.965e-3, 43.0, 3.0e-3,
544 1.685e-3, 47.0, 9.9e-3, 590.0, 600.0], dtype=float)
545 c = {"injector_type": "impinging", "layer1_derive_impingement_spacing": True,
546 "layer1_impingement_Ld_target": 4.0, "layer1_ring_order_fuel_outboard": True,
547 "derive_spacing_F_min": 1e-4, "derive_spacing_F_max": 0.05}
548 L1._layer1_derive_fuel_spacing(x, c)
549 assert x[4] == 30.0
550 assert int(x[4]) == int(round(x[4]))
551
552
555
556 base = np.array([1.6e-3, 1.13, 5.2, 0.152, 30, 1.965e-3, 43.0, 3.0e-3,
557 1.685e-3, 47.0, 9.9e-3, 590.0, 600.0], dtype=float)
558 c = {"injector_type": "impinging", "layer1_derive_impingement_spacing": True,
559 "layer1_impingement_Ld_target": 4.0, "derive_spacing_F_min": 1e-6,
560 "derive_spacing_F_max": 0.05}
561
562 x = base.copy(); L1._layer1_derive_fuel_spacing(x, {**c, "layer1_ring_order_fuel_outboard": True})
563 assert x[10] > x[7]
564 x = base.copy(); L1._layer1_derive_fuel_spacing(x, {**c, "layer1_ring_order_fuel_outboard": False})
565 assert x[10] < x[7]
566
567 # Opt-out leaves the searched value untouched.
568 x = base.copy()
569 L1._layer1_derive_fuel_spacing(x, {**c, "layer1_derive_impingement_spacing": False})
570 assert x[10] == pytest.approx(9.9e-3)
571
572 # Pintle vectors have no fuel ring; must be a no-op.
573 x = base.copy()
574 L1._layer1_derive_fuel_spacing(x, {**c, "injector_type": "pintle"})
575 assert x[10] == pytest.approx(9.9e-3)
576
577
579 """If the solved ring will not fit the bore it clamps -- and L/d then misses, which the
580 ring-geometry penalty is there to report. Silently producing an unbuildable ring, or
581 silently pretending the target was met, would both be worse."""
583 from engine.core.injectors.impinging import impingement_standoff_m
584
585 x = np.array([1.6e-3, 1.13, 5.2, 0.152, 30, 1.965e-3, 43.0, 3.0e-3,
586 1.685e-3, 47.0, 9.9e-3, 590.0, 600.0], dtype=float)
587 c = {"injector_type": "impinging", "layer1_derive_impingement_spacing": True,
588 "layer1_impingement_Ld_target": 4.0, "layer1_ring_order_fuel_outboard": True,
589 "derive_spacing_F_min": 1e-4, "derive_spacing_F_max": 0.004}
590 L1._layer1_derive_fuel_spacing(x, c)
591 assert x[10] == pytest.approx(0.004)
592 d_avg = 0.5 * (x[5] + x[8])
593 assert impingement_standoff_m(x[4], x[7], x[10], x[6], x[9]) / d_avg < 4.0
594
595
596# ---------------------------------------------------------------------------
597# Momentum ratio: fail toward the core, never toward the wall
598# ---------------------------------------------------------------------------
599
601 _impinging_momentum_asymmetric_squared as _mom,
602)
603
604
606 """R's only job was the liner guard, and it did it wrong. It is now a mild symmetric
607 mixing preference; the resultant tilt owns the liner."""
608 for r in (0.96, 0.98, 1.0, 1.02, 1.04, 1.05):
609 assert _mom(r) == 0.0
610 assert _mom(0.90) > 0.0
611 assert _mom(1.12) > 0.0
612 # symmetric: equal log-distance outside the band costs the same
613 assert _mom(1.0 / 0.90) == pytest.approx(_mom(0.90), rel=1e-6)
614
615
617 """A config left over from the ban era can still carry 999999. Unclamped, that put a
618 quality term at 1e7..1e11 against BASE_INFEAS = 1e6 and stopped the run in ~1 s."""
619 huge = _mom(1.20, wall_side_multiplier=999999.0)
620 assert 0.0 < huge <= 1.0e3 * _mom(1.20, wall_side_multiplier=1.0) + 1e-9
621
622
624 """Retained only so older configs/tests keep working; the live guard is the tilt."""
626 _impinging_momentum_wall_violation as _viol,
627 )
628 assert _viol(1.01, safe_side_is_below_one=False) == 0.0
629 assert _viol(0.99, safe_side_is_below_one=False) > 0.0
630
631
633 """A momentum miss is a mixing loss, not a hazard -- it must stay negotiable.
634
635 The liner hazard is the resultant tilt, which is a separate, one-sided constraint.
636 """
637 assert 0.0 < _mom(0.88) < 10.0
638 assert 0.0 < _mom(1.14) < 10.0
639
640
642 assert _mom(None) == 0.0
643 assert _mom(float("nan")) == 0.0
644 assert _mom(-1.0) == 0.0
645 assert _mom(0.0) == 0.0
646
647
649 """R is no longer the liner gate (the resultant tilt is), so its gate is symmetric.
650
651 A one-sided gate failed tilt-balanced designs, which sit at R ~ 1.05: every other check
652 green, pressure_candidate_valid False.
653 """
655
656 # Symmetric about 1, on the configured band by default (L1-09: the old 3x default slack
657 # passed R = 1.15 against a stated [0.95, 1.05] and printed the unwidened band).
658 ok, band = L1._layer1_momentum_gate(1.049, 0.95, 1.05)
659 assert ok and band == pytest.approx((0.95, 1.05))
660 assert not L1._layer1_momentum_gate(1.0508, 0.95, 1.05)[0]
661 assert not L1._layer1_momentum_gate(0.9492, 0.95, 1.05)[0]
662 # an explicit layer1_momentum_gate_safe_slack still widens it, symmetrically
663 ok, band = L1._layer1_momentum_gate(1.10, 0.95, 1.05, slack=3.0)
664 assert ok and band == pytest.approx((0.85, 1.15))
665
666
668 """A frozen or derived-pinned coordinate must be left out of minstd/integer_variables.
669
670 Its domain is ~2e-12 wide. Handing cma a floor of 0.2 (or 0.00254 m for the chamber OD)
671 against that makes it inflate the sampling std by ~1e9 to satisfy the floor -- 'sigma0 x
672 stds is larger than the bounded domain size in variable 3' -- which wrecks the covariance
673 matrix on a coordinate that cannot move anyway. Freezing the chamber OD at 6.5 in took the
674 objective from ~40 to ~14700 before this guard.
675 """
677 _layer1_cma_discrete_options,
678 )
679 PIN = 1e-12
680 free = [(0.0, 1.0)] * 3 + [(0.08, 0.20)] + [(10.0, 50.0), (0.0, 1.0), (20.0, 80.0),
681 (0.0, 1.0), (0.0, 1.0), (20.0, 80.0), (0.0, 1.0), (0.0, 1.0), (0.0, 1.0)]
682
683 # baseline: everything free
684 iv, ms = _layer1_cma_discrete_options(13, [4, 6, 9], od_index=3, od_step_m=0.0127,
685 bounds=free)
686 assert iv == [4, 6, 9]
687 assert ms[3] == pytest.approx(0.2 * 0.0127)
688
689 # chamber OD frozen -> no std floor on it, integers untouched
690 frozen_od = list(free)
691 frozen_od[3] = (0.1651 - PIN, 0.1651 + PIN)
692 iv, ms = _layer1_cma_discrete_options(13, [4, 6, 9], od_index=3, od_step_m=0.0127,
693 bounds=frozen_od)
694 assert ms[3] == 0.0, "frozen coordinate must not get a std floor"
695 assert iv == [4, 6, 9]
696
697 # a frozen integer dim drops out of integer_variables too
698 frozen_n = list(free)
699 frozen_n[4] = (30.0 - 0.01, 30.0 + 0.01)
700 iv, ms = _layer1_cma_discrete_options(13, [4, 6, 9], od_index=3, od_step_m=0.0127,
701 bounds=frozen_n)
702 assert 4 not in iv and ms[4] == 0.0
703 assert iv == [6, 9]
704
705 # no bounds supplied -> behave as before (nothing to check against)
706 iv, ms = _layer1_cma_discrete_options(13, [4, 6, 9], od_index=3, od_step_m=0.0127)
707 assert iv == [4, 6, 9] and ms[3] > 0.0
708
709
710# ---------------------------------------------------------------------------
711# Thrust deadband follows whether the throat is solved
712# ---------------------------------------------------------------------------
713
715 from engine.optimizer.layers.layer1_static_optimization import _layer1_thrust_deadband as db
716 assert db({}) == pytest.approx(0.001) # default: derived
717 assert db({"layer1_derive_throat_from_thrust": True}) == pytest.approx(0.001)
718 assert db({"layer1_derive_throat_from_thrust": False}) == pytest.approx(0.02)
719 assert db({"layer1_thrust_deadband_rel": 0.005}) == pytest.approx(0.005) # explicit wins
720 assert db({"layer1_thrust_deadband_rel": None}) == pytest.approx(0.001) # None == unset
721 assert db(object()) == pytest.approx(0.02) # no .get -> legacy
722
723
725 """x and the config must always describe the RETURNED result.
726
727 A failed last step used to leave x/config on the geometry that had just failed while
728 returning the result from the one before -- the finalised design then did not evaluate.
729 """
731
732 x = np.array([1.5e-3, 1.0, 5.0, 0.15, 20, 2e-3, 45.0, 5e-3,
733 1.7e-3, 45.0, 5e-3, 550.0, 550.0], dtype=float)
734 c = {"layer1_derive_expansion_ratio": False, "layer1_derive_throat_from_thrust": True,
735 "layer1_derive_max_iters": 3, "derive_At_min": 1e-4, "derive_At_max": 1e-2,
736 "derive_eps_min": 2.0, "derive_eps_max": 20.0, "P_ambient": 13.6 * PSI,
737 "target_thrust": 9000.0, "max_nozzle_exit": 0.2, "TOTAL_WALL_THICKNESS_M": 0.006}
738 first = {"F": 7000.0, "Pc": 400 * PSI, "gamma": 1.14}
739
740 L1._layer1_apply_chamber_geometry_to_config, _orig = (lambda *a, **k: 5.0), L1._layer1_apply_chamber_geometry_to_config
741 try:
742 out = L1._layer1_solve_derived_geometry(x, object(), c, lambda: None, first) # every step fails
743 finally:
744 L1._layer1_apply_chamber_geometry_to_config = _orig
745 assert out is first
746 assert x[0] == pytest.approx(1.5e-3), "x must be restored to the state the returned result describes"
747 assert first.get("_derive_restored_after_failed_step") is True
748
749
750# ---------------------------------------------------------------------------
751# Ablative guard: the spray RESULTANT, not the momentum-flux ratio
752# ---------------------------------------------------------------------------
753
755 _impinging_resultant_tilt_deg as _tilt,
756 _impinging_resultant_wall_violation as _tviol,
757)
758
759
761 """The whole reason the old guard was wrong: p_O/p_F = R^2 * (A_O/A_F).
762
763 Equal jets at equal angles balance; unequal AREAS do not, no matter what R is.
764 """
765 kw = dict(rho_O=1141.0, rho_F=789.0, n_elements=18, angle_O_deg=45.0, angle_F_deg=45.0)
766 # EQUAL STREAM MOMENTA -> axial. p = mdot^2/(rho*A), so equal mdot with unequal density
767 # is NOT balanced; matching p is what matters.
768 import math as _m
769 d_O = 2e-3
770 d_F = _m.sqrt(1141.0 / 789.0) * d_O # A_F/A_O = rho_O/rho_F => p_O = p_F
771 assert _tilt(mdot_O=1.0, mdot_F=1.0, d_jet_O_m=d_O, d_jet_F_m=d_F, **kw) == pytest.approx(0.0, abs=1e-9)
772 # the real design: LOX-heavy stream momentum -> fan thrown OUTWARD even though R ~ 1
773 t = _tilt(mdot_O=2.05, mdot_F=0.94, d_jet_O_m=2.73e-3, d_jet_F_m=2.02e-3, **kw)
774 assert t > 10.0, f"expected a large outward tilt, got {t}"
775
776
778 """sin(th_O)/sin(th_F) = p_F/p_O -> alpha = 0. This is what the optimizer found (29/62)."""
779 kw = dict(rho_O=1141.0, rho_F=789.0, n_elements=18, d_jet_O_m=2.73e-3, d_jet_F_m=2.02e-3)
780 sym = _tilt(mdot_O=2.05, mdot_F=0.94, angle_O_deg=45.0, angle_F_deg=45.0, **kw)
781 asym = _tilt(mdot_O=2.05, mdot_F=0.94, angle_O_deg=29.0, angle_F_deg=62.0, **kw)
782 assert abs(asym) < abs(sym) / 4.0, f"asymmetry must flatten the fan: {sym} -> {asym}"
783
784
786 kw = dict(rho_O=1141.0, rho_F=789.0, n_elements=18, d_jet_O_m=2.73e-3, d_jet_F_m=2.02e-3,
787 angle_O_deg=45.0, angle_F_deg=45.0, mdot_O=2.05, mdot_F=0.94)
788 assert _tilt(lox_inboard=True, **kw) > 0 # LOX inboard, LOX-heavy -> outward
789 assert _tilt(lox_inboard=False, **kw) < 0 # flip the rings, flip the direction
790
791
793 assert _tviol(-5.0) == 0.0 # inward: a mixing loss, not a hazard
794 assert _tviol(0.0) == 0.0
795 assert _tviol(2.0) > 0.0
796 assert _tviol(8.0) > _tviol(2.0)
797 for bad in (None, float("nan")):
798 assert _tviol(bad) == 0.0
799 assert not np.isfinite(_tilt(mdot_O=0.0, mdot_F=0.0, rho_O=1141.0, rho_F=789.0,
800 n_elements=18, d_jet_O_m=2e-3, d_jet_F_m=2e-3,
801 angle_O_deg=45.0, angle_F_deg=45.0))
802
803
805 """A one-sided R band taxed exactly the tilt-balanced designs (R ~ 1.05)."""
806 assert _mom(1.03, band_width=0.05) == 0.0
807 assert _mom(0.97, band_width=0.05) == 0.0
808 assert _mom(1.08, band_width=0.05) > 0.0
809 assert _mom(0.92, band_width=0.05) > 0.0
810
811
813 """chamber_geometry.design_* must describe THIS engine, not the template it came from.
814
815 Nothing ever wrote these: config_schemas builds them with
816 `getattr(chamber, 'design_MR', 2.55)`, so an optimised config carried the template's values
817 forward forever. A real emitted design was stamped MR 2.55 / 350 psi / 7000 N while actually
818 solving at O/F ~1.65 / 416 psi / 7200 N -- and backend/routers/geometry.py feeds design_MR
819 straight into solve_chamber_geometry_with_cea, so the geometry tab drew the contour at the
820 stale ratio.
821 """
822 import logging
823 from engine.pipeline.io import load_config
824 from engine.optimizer.layers.layer1_static_optimization import _layer1_stamp_design_point
825
826 cfg = load_config("configs/canonical/impinging.yaml")
827 cfg.chamber_geometry.design_MR = 2.55 # the stale template values
828 cfg.chamber_geometry.design_pressure = 2.413166e6
829 cfg.chamber_geometry.design_thrust = 7000.0
830
831 solved = {"MR": 1.6461, "Pc": 2.8690e6, "F": 7200.0}
832 _layer1_stamp_design_point(cfg, solved, None)
833
834 assert cfg.chamber_geometry.design_MR == pytest.approx(solved["MR"])
835 assert cfg.chamber_geometry.design_pressure == pytest.approx(solved["Pc"])
836 assert cfg.chamber_geometry.design_thrust == pytest.approx(solved["F"])
837
838
840 """An MR outside combustion.cea.MR_range means anything reading it extrapolates."""
841 import logging
842 from engine.pipeline.io import load_config
843 from engine.optimizer.layers.layer1_static_optimization import _layer1_stamp_design_point
844
845 cfg = load_config("configs/canonical/impinging.yaml")
846 lo, hi = [float(v) for v in cfg.combustion.cea.MR_range]
847
848 seen = []
849 logger = logging.getLogger("stamp_range_test")
850 logger.warning = lambda msg, *a, **k: seen.append(msg % a if a else msg)
851
852 _layer1_stamp_design_point(cfg, {"MR": (lo + hi) / 2.0, "Pc": 2.8e6, "F": 7200.0}, logger)
853 assert not seen, "in-range MR must not warn"
854
855 _layer1_stamp_design_point(cfg, {"MR": hi + 1.0, "Pc": 2.8e6, "F": 7200.0}, logger)
856 assert seen and "outside the CEA cache" in seen[0]
857
858
860 """A failed evaluate must not overwrite a good design point with NaN."""
861 from engine.pipeline.io import load_config
862 from engine.optimizer.layers.layer1_static_optimization import _layer1_stamp_design_point
863
864 cfg = load_config("configs/canonical/impinging.yaml")
865 cfg.chamber_geometry.design_MR = 1.65
866 _layer1_stamp_design_point(cfg, {"MR": float("nan"), "Pc": 0.0, "F": None}, None)
867 assert cfg.chamber_geometry.design_MR == pytest.approx(1.65)
868
869
871 """The initial tank pressure exists twice, owned by different layers, never reconciled.
872
873 Layer 1 writes lox_tank/fuel_tank.initial_pressure_psi; Layer 2 writes
874 pressure_curves.initial_lox/fuel_pressure_pa. Re-running Layer 1 silently leaves the curves
875 describing the previous design (observed 11.3 psi out on LOX, 24.9 psi on fuel). Tank
876 pressure is the upstream boundary condition for the feed-system twin (docs/adr/0001), so a
877 disagreement must not cross that boundary unannounced.
878 """
879 import logging
880 from engine.pipeline.io import load_config
882 _layer1_warn_stale_pressure_curves,
883 )
884
885 cfg = load_config("configs/canonical/impinging.yaml")
886 if getattr(cfg, "pressure_curves", None) is None or getattr(cfg, "lox_tank", None) is None:
887 pytest.skip("config has no pressure_curves / lox_tank to compare")
888
889 seen = []
890 logger = logging.getLogger("stale_curves_test")
891 logger.warning = lambda msg, *a, **k: seen.append(msg % a if a else msg)
892
893 PSI = 6894.76
894 cfg.lox_tank.initial_pressure_psi = 548.6
895 cfg.pressure_curves.initial_lox_pressure_pa = 548.6 * PSI # agrees
896 cfg.fuel_tank.initial_pressure_psi = 548.6
897 cfg.pressure_curves.initial_fuel_pressure_pa = 548.6 * PSI # agrees
898 _layer1_warn_stale_pressure_curves(cfg, logger)
899 assert not seen, "matching pressures must not warn"
900
901 cfg.pressure_curves.initial_lox_pressure_pa = 537.3 * PSI # 11.3 psi stale
902 _layer1_warn_stale_pressure_curves(cfg, logger)
903 assert seen and "LOX tank pressure disagrees" in seen[0]
#define max(a, b)
Definition Arduino.h:43
test_derived_eps_reproduces_target_exit_pressure(Pc_psi, gamma)
float _exit_pressure_from_eps(float eps, float Pc, float gamma)
test_toggles_are_real_schema_fields_so_the_frontend_reaches_the_optimizer()
test_requirement_bool_parses_the_forms_yaml_and_the_ui_produce(raw, expected)