1"""Guards for the Layer-1 DOFs that are solved rather than searched.
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
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.
14from __future__
import annotations
20 _layer1_eps_for_exit_pressure,
21 _layer1_solve_derived_geometry,
30 """Invert the area-ratio relation numerically: eps -> Pe.
32 Deliberately independent of the production formula so this is a real check
33 and not a restatement of the code under test.
37 def area_ratio(M: float) -> float:
39 (2.0 / (g + 1.0)) * (1.0 + 0.5 * (g - 1.0) * M * M)
40 ) ** ((g + 1.0) / (2.0 * (g - 1.0)))
42 lo, hi = 1.0 + 1e-9, 50.0
45 if area_ratio(mid) < eps:
50 return Pc / (1.0 + 0.5 * (g - 1.0) * M * M) ** (g / (g - 1.0))
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])
57 Pe_target = 13.6436 * PSI
59 eps = _layer1_eps_for_exit_pressure(Pc, gamma, Pe_target)
60 assert eps
is not None and eps > 1.0
64 assert Pe_actual == pytest.approx(Pe_target, rel=1e-6)
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
79 """An unreachable thrust target must clamp to the bound, not run away.
81 The residual is then carried by the ordinary thrust penalty, which the
82 infeasibility gradient can still descend.
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
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,
97 "target_thrust": 1.0e9,
98 "max_nozzle_exit": 0.2,
99 "TOTAL_WALL_THICKNESS_M": 0.006,
103 chamber_geometry =
None
106 combustion = type(
"C", (), {})()
112 return {
"F": 7000.0,
"Pc": 400 * PSI,
"gamma": 1.14}
118 original = L1._layer1_apply_chamber_geometry_to_config
119 L1._layer1_apply_chamber_geometry_to_config =
lambda *a, **k: 5.0
121 _layer1_solve_derived_geometry(x, _Cfg(), constants, _evaluate,
122 {
"F": 7000.0,
"Pc": 400 * PSI,
"gamma": 1.14})
124 L1._layer1_apply_chamber_geometry_to_config = original
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"])
132 x = np.array([1.5e-3, 1.0, 5.0, 0.15], dtype=float)
134 res = {
"F": 1.0,
"Pc": 400 * PSI,
"gamma": 1.14}
135 out = _layer1_solve_derived_geometry(
137 {
"layer1_derive_expansion_ratio":
False,
138 "layer1_derive_throat_from_thrust":
False},
139 lambda: (_
for _
in ()).throw(AssertionError(
"must not evaluate")),
143 np.testing.assert_array_equal(x, before)
148 layer1_derive_expansion_ratio=
False,
149 layer1_derive_throat_from_thrust=
False,
151 dumped = req.model_dump()
152 assert dumped[
"layer1_derive_expansion_ratio"]
is False
153 assert dumped[
"layer1_derive_throat_from_thrust"]
is False
156 assert _requirement_bool(dumped,
"layer1_derive_expansion_ratio",
True)
is False
157 assert _requirement_bool({},
"layer1_derive_expansion_ratio",
True)
is True
160@pytest.mark.parametrize(
162 [(
"false",
False), (
"False",
False), (
"0",
False), (
"no",
False), (
"off",
False),
163 (
"true",
True), (
"1",
True), (
"yes",
True), (0,
False), (1,
True), (
None,
True)],
166 assert _requirement_bool({
"k": raw},
"k",
True)
is expected
174 _quantize_chamber_od_m,
175 _tank_pressure_equal_squared,
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)
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)
193 assert abs(round((od / IN) / 0.5) * 0.5 - od / IN) > 1e-6
197 for inc
in (0.0, -1.0,
None, float(
"nan")):
198 assert _quantize_chamber_od_m(0.1234, inc) == pytest.approx(0.1234)
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)
208 """The in-band pull is OFF by default.
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.
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
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
224 out = _tank_pressure_equal_squared(600.0, 570.0, **kw)
225 assert out > 20.0 * edge
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
234 """Index 6 is the LOX angle for doublets but n_orifices for a pintle.
236 Snapping [6, 9] on a pintle vector would quantise an orifice diameter, so the
237 injector-type guard is load-bearing, not cosmetic.
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
253 _layer1_cma_discrete_options,
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))
266 """Its granularity is one stock increment in METRES, not 1.
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.
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"
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
286 iv, minstd = _layer1_cma_discrete_options(5, [1, 9, -2], od_index=99, od_step_m=0.01)
288 assert len(minstd) == 5
292 """A block optimizes a SUBSET, so global indices do not address the sub-vector.
294 Guards the remap in run_hybrid_optimization: passing the global list would mark the
295 wrong coordinates as integers.
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
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]
309 assert block_indices.index(3) == 1
317 """It must be in optimized_parameters -- that dict IS the frontend's geometry table.
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.
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"
342 """The offset a doublet closes is the gap between the two RINGS.
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.
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)
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
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))
365 assert not np.isfinite(impingement_standoff_m(n, s_O, s_O, a_O, a_F))
370 _impinging_ring_geometry_squared,
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,
379 lox_out = _impinging_ring_geometry_squared(
380 spacing_O_m=0.0067, spacing_F_m=0.0049, **common)
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"
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)
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."""
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
406 """Both numbers, so 'spacing' can't be misread as the ring diameter."""
411 cfg = load_config(
"configs/canonical/impinging.yaml")
412 p = extract_all_parameters(cfg)
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"]))
418 assert p[
"D_pitch_O"] > 5 * p[
"spacing_O"]
426 _layer1_lstar_target_from_smd,
427 _layer1_lstar_band_term,
428 _layer1_resolve_lstar_target,
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))
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)
446 assert _layer1_lstar_target_from_smd(70.0) < _layer1_lstar_target_from_smd(99.0)
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)
459 assert _layer1_lstar_band_term(1.10, tgt, db) == 0.0
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)
464 assert _layer1_lstar_band_term(0.90, tgt, db) > 0.0
465 assert _layer1_lstar_band_term(1.40, tgt, db) > 0.0
467 assert _layer1_lstar_band_term(50.0, tgt, db) < 1.0
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."""
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,
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)
490 tgt, _ = _layer1_resolve_lstar_target(float(
"nan"), req, req.get, 0.85)
491 assert tgt == pytest.approx(0.85)
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)
499 diag = {
"D32_O": 110e-6,
"D32_F": 80e-6}
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))
512 assert "layer1_impingement_Ld_target" in DesignRequirementsConfig.model_fields
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"
521 """One equation, one unknown -- the standoff must land ON the number, not near it."""
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"
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."""
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)
550 assert int(x[4]) == int(round(x[4]))
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}
562 x = base.copy(); L1._layer1_derive_fuel_spacing(x, {**c,
"layer1_ring_order_fuel_outboard":
True})
564 x = base.copy(); L1._layer1_derive_fuel_spacing(x, {**c,
"layer1_ring_order_fuel_outboard":
False})
569 L1._layer1_derive_fuel_spacing(x, {**c,
"layer1_derive_impingement_spacing":
False})
570 assert x[10] == pytest.approx(9.9e-3)
574 L1._layer1_derive_fuel_spacing(x, {**c,
"injector_type":
"pintle"})
575 assert x[10] == pytest.approx(9.9e-3)
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."""
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
601 _impinging_momentum_asymmetric_squared
as _mom,
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
613 assert _mom(1.0 / 0.90) == pytest.approx(_mom(0.90), rel=1e-6)
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
624 """Retained only so older configs/tests keep working; the live guard is the tilt."""
626 _impinging_momentum_wall_violation
as _viol,
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
633 """A momentum miss is a mixing loss, not a hazard -- it must stay negotiable.
635 The liner hazard is the resultant tilt, which is a separate, one-sided constraint.
637 assert 0.0 < _mom(0.88) < 10.0
638 assert 0.0 < _mom(1.14) < 10.0
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
649 """R is no longer the liner gate (the resultant tilt is), so its gate is symmetric.
651 A one-sided gate failed tilt-balanced designs, which sit at R ~ 1.05: every other check
652 green, pressure_candidate_valid False.
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]
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))
668 """A frozen or derived-pinned coordinate must be left out of minstd/integer_variables.
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.
677 _layer1_cma_discrete_options,
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)]
684 iv, ms = _layer1_cma_discrete_options(13, [4, 6, 9], od_index=3, od_step_m=0.0127,
686 assert iv == [4, 6, 9]
687 assert ms[3] == pytest.approx(0.2 * 0.0127)
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,
694 assert ms[3] == 0.0,
"frozen coordinate must not get a std floor"
695 assert iv == [4, 6, 9]
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,
702 assert 4
not in iv
and ms[4] == 0.0
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
716 assert db({}) == pytest.approx(0.001)
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)
720 assert db({
"layer1_thrust_deadband_rel":
None}) == pytest.approx(0.001)
721 assert db(object()) == pytest.approx(0.02)
725 """x and the config must always describe the RETURNED result.
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.
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}
740 L1._layer1_apply_chamber_geometry_to_config, _orig = (
lambda *a, **k: 5.0), L1._layer1_apply_chamber_geometry_to_config
742 out = L1._layer1_solve_derived_geometry(x, object(), c,
lambda:
None, first)
744 L1._layer1_apply_chamber_geometry_to_config = _orig
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
755 _impinging_resultant_tilt_deg
as _tilt,
756 _impinging_resultant_wall_violation
as _tviol,
761 """The whole reason the old guard was wrong: p_O/p_F = R^2 * (A_O/A_F).
763 Equal jets at equal angles balance; unequal AREAS do not, no matter what R is.
765 kw = dict(rho_O=1141.0, rho_F=789.0, n_elements=18, angle_O_deg=45.0, angle_F_deg=45.0)
770 d_F = _m.sqrt(1141.0 / 789.0) * d_O
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)
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}"
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}"
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
789 assert _tilt(lox_inboard=
False, **kw) < 0
793 assert _tviol(-5.0) == 0.0
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))
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
813 """chamber_geometry.design_* must describe THIS engine, not the template it came from.
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
826 cfg = load_config(
"configs/canonical/impinging.yaml")
827 cfg.chamber_geometry.design_MR = 2.55
828 cfg.chamber_geometry.design_pressure = 2.413166e6
829 cfg.chamber_geometry.design_thrust = 7000.0
831 solved = {
"MR": 1.6461,
"Pc": 2.8690e6,
"F": 7200.0}
832 _layer1_stamp_design_point(cfg, solved,
None)
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"])
840 """An MR outside combustion.cea.MR_range means anything reading it extrapolates."""
845 cfg = load_config(
"configs/canonical/impinging.yaml")
846 lo, hi = [float(v)
for v
in cfg.combustion.cea.MR_range]
849 logger = logging.getLogger(
"stamp_range_test")
850 logger.warning =
lambda msg, *a, **k: seen.append(msg % a
if a
else msg)
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"
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]
860 """A failed evaluate must not overwrite a good design point with NaN."""
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)
871 """The initial tank pressure exists twice, owned by different layers, never reconciled.
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.
882 _layer1_warn_stale_pressure_curves,
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")
890 logger = logging.getLogger(
"stale_curves_test")
891 logger.warning =
lambda msg, *a, **k: seen.append(msg % a
if a
else msg)
894 cfg.lox_tank.initial_pressure_psi = 548.6
895 cfg.pressure_curves.initial_lox_pressure_pa = 548.6 * PSI
896 cfg.fuel_tank.initial_pressure_psi = 548.6
897 cfg.pressure_curves.initial_fuel_pressure_pa = 548.6 * PSI
898 _layer1_warn_stale_pressure_curves(cfg, logger)
899 assert not seen,
"matching pressures must not warn"
901 cfg.pressure_curves.initial_lox_pressure_pa = 537.3 * PSI
902 _layer1_warn_stale_pressure_curves(cfg, logger)
903 assert seen
and "LOX tank pressure disagrees" in seen[0]
test_pitch_circles_are_reported_alongside_hole_pitch()
test_tank_deadband_is_free_inside_the_band_by_default()
test_derived_eps_reproduces_target_exit_pressure(Pc_psi, gamma)
test_angle_asymmetry_is_the_lever_that_balances_the_fan()
test_out_of_range_discrete_indices_are_dropped()
test_lstar_band_term_is_free_inside_and_two_sided_outside()
test_momentum_gate_is_symmetric_and_widened()
test_L_imp_reaches_the_reported_geometry()
test_thrust_deadband_is_tight_when_throat_is_derived()
test_effective_smd_is_the_mass_flux_weighted_blend()
test_ring_penalty_charges_for_lox_outboard_of_fuel()
test_fuel_spacing_is_solved_to_hit_the_target_exactly()
test_solver_is_a_noop_when_both_toggles_are_off()
test_R_equal_one_is_not_a_balanced_doublet()
test_solver_clamps_to_search_range_when_target_unreachable()
test_standoff_uses_the_RADIAL_ring_offset_not_the_hole_pitch()
test_design_point_stamp_warns_outside_the_cea_cache_range()
test_lstar_target_clamps_to_the_allowed_envelope()
test_unknown_snap_target_falls_back_to_outer()
test_fuel_spacing_derivation_snaps_the_doublet_count_in_place()
test_momentum_band_is_symmetric_now_that_tilt_owns_the_guard()
float _exit_pressure_from_eps(float eps, float Pc, float gamma)
test_design_point_stamp_ignores_non_finite_performance()
test_tilt_sign_follows_the_ring_order()
test_snap_outer_puts_the_tube_on_a_stock_size()
test_derived_eps_rejects_unphysical_inputs()
test_fuel_spacing_derivation_respects_ring_order_and_opt_out()
test_solve_restores_x_and_config_when_a_step_fails()
test_clamped_fuel_ring_reports_the_miss_instead_of_hiding_it()
test_impingement_target_defaults_to_four_jet_diameters()
test_integer_jet_angles_only_apply_to_impinging()
test_stale_pressure_curves_are_flagged()
test_momentum_band_is_free_and_symmetric()
test_snap_bore_puts_the_gas_side_on_a_stock_size()
test_lstar_target_follows_the_d_squared_law()
test_momentum_penalty_ignores_garbage()
test_resolver_falls_back_rather_than_silently_disabling()
test_tilt_violation_is_one_sided_and_ignores_garbage()
test_chamber_od_gets_a_std_floor_but_is_NOT_an_integer_variable()
test_design_point_is_stamped_from_the_solved_engine()
test_ring_geometry_is_enforced_by_default()
test_no_od_floor_when_stock_snapping_is_off()
test_pinned_dimensions_get_no_cma_std_floor()
test_momentum_multiplier_is_clamped_against_legacy_ban_values()
test_outside_band_is_discouraged_not_banned()
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)
test_superseded_R_wall_violation_still_behaves_for_back_compat()
test_block_subproblems_remap_discrete_indices()
test_resolver_treats_explicit_None_as_unset()
test_unit_granularity_dims_become_cma_integer_variables()
test_lstar_target_hits_the_anchor_band()
test_snapping_disabled_passes_the_value_through()