STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_injector_layout.py
Go to the documentation of this file.
1"""engine.core.injectors.layout -- the one place the injector's geometry is derived.
2
3Reference engine: configs/ethalox_6500N.yaml (24 doublets, 43/46 deg, contoured face, channels
4on the back, plug filling a 6.0 in sleeve bore). Every contoured-face and channel number below
5is recomputed in the test from plain trigonometry, not read back from the module, so a wrong
6formula in layout.py fails here rather than being agreed with.
7"""
8
9import json
10import math
11from pathlib import Path
12
13import numpy as np
14import pytest
15import yaml
16
18 EXIT_LAND_DEFAULT, IMPINGEMENT_LD_TARGET_DEFAULT, compute_layout, impingement_ld_band,
19 layout_from_config,
20)
21
22ROOT = Path(__file__).resolve().parents[1]
23CASES = json.loads((ROOT / "tests/impingement_ld_band_cases.json").read_text())["cases"]
24C6500 = ROOT / "configs/ethalox_6500N.yaml"
25T = lambda a: math.tan(math.radians(a)) # noqa: E731
26S = lambda a: math.sin(math.radians(a)) # noqa: E731
27C = lambda a: math.cos(math.radians(a)) # noqa: E731
28
29
30def _cfg(**plate):
31 cfg = yaml.safe_load(C6500.read_text())
32 cfg["injector"].setdefault("plate", {}).update(plate)
33 return cfg
34
35
36def _hole_ld(cfg, ld, **discharge):
37 """Both propellants' hole L/d (discharge.<side>.orifice_l_over_d), and any other discharge keys."""
38 for side in ("oxidizer", "fuel"):
39 cfg["discharge"][side].update(orifice_l_over_d=ld, **discharge)
40 return cfg
41
42
43def _texts(out):
44 return " | ".join(w["text"] for w in out["warnings"])
45
46
47def _codes(out):
48 return {w["code"]: w["level"] for w in out["warnings"]}
49
50
51# =============================================================================================
52# Standoff band
53# =============================================================================================
54
55@pytest.mark.parametrize("case", CASES, ids=[json.dumps(c["req"]) for c in CASES])
57 assert tuple(impingement_ld_band(case["req"])) == pytest.approx(tuple(case["band"]))
58
59
61 req = yaml.safe_load(C6500.read_text())["design_requirements"]
62 assert tuple(impingement_ld_band(req)) == pytest.approx((4.0, 3.0, 5.0))
63
64
66 src = (ROOT / "engine/optimizer/layers/layer1_static_optimization.py").read_text()
67 assert "impingement_ld_band(" in src
68 assert '"layer1_impingement_Ld_min", max(0.0' not in src
69
70
72 from engine.optimizer.layers.layer1_static_optimization import _impinging_ring_geometry_squared
73 kw = dict(n_elements=27, spacing_O_m=0.0081070, spacing_F_m=0.018, d_jet_O_m=0.0017094,
74 d_jet_F_m=0.0015024, D_chamber_inner_m=0.25, angle_O_deg=40.0, angle_F_deg=50.0)
75 assert _impinging_ring_geometry_squared(**kw) == 0.0
76 assert _impinging_ring_geometry_squared(**kw, Ld_min=3.0, Ld_max=5.0) > 0.0
77
78
80 assert IMPINGEMENT_LD_TARGET_DEFAULT == 4.0
81
82
83# =============================================================================================
84# The envelope: the plug fills the sleeve bore, the liner fills the rest
85# =============================================================================================
86
88 out = layout_from_config(_cfg(), drawings=False)
89
91 env = self.out["envelope"]
92 # 6.5 in OD - 2 x 0.25 in wall = 6.0 in bore; 5.0 in gas bore + 2 x 0.5 in liner = 6.0 in
93 assert env["r_sleeve_od"] == pytest.approx(0.5 * 0.1651)
94 assert env["r_sleeve_id"] == pytest.approx(0.5 * 0.1524)
95 assert env["r_bore"] + env["liner_thickness"] == pytest.approx(env["r_sleeve_id"])
96 assert "liner_gap" not in _codes(self.out)
97
99 cfg = _cfg()
100 cfg["ablative_cooling"]["initial_thickness"] = 0.010
101 assert "liner_gap" in _codes(layout_from_config(cfg, drawings=False))
102
104 out = layout_from_config(_cfg())
105 plate = [p for p in out["drawings"]["section_doublet"] if p["layer"] == "PLATE"][0]
106 assert max(x for x, _ in plate["pts"]) == pytest.approx(0.0762, abs=1e-7)
107
108
109# =============================================================================================
110# Contoured face, against hand trigonometry
111# =============================================================================================
112
113def _hand_6500N(exit_land=EXIT_LAND_DEFAULT):
114 g = yaml.safe_load(C6500.read_text())["injector"]["geometry"]
115 O, F = g["oxidizer"], g["fuel"]
116 n = O["n_elements"]
117 rO, rF = n * O["spacing"] / (2 * math.pi), n * F["spacing"] / (2 * math.pi)
118 dO, dF, tO, tF = O["d_jet"], F["d_jet"], O["impingement_angle"], F["impingement_angle"]
119 dE = max((dO / 2 + exit_land) * S(tO), (dF / 2 + exit_land) * S(tF))
120 L = (rF - rO) / (T(tO) + T(tF))
121 return dict(rO=rO, rF=rF, dO=dO, dF=dF, tO=tO, tF=tF, n=n, dE=dE, L=L,
122 r_imp=rO + L * T(tO), z_imp=L - dE, jO=L / C(tO), jF=L / C(tF),
123 edge_in=rO - dE / T(tO), edge_out=rF + dE / T(tF))
124
125
128 f = layout_from_config(_cfg(), drawings=False)["face"]
129
131 assert self.f["contoured"] is True
132 assert self.f["z_exit"] == pytest.approx(-self.h["dE"], rel=1e-12)
133 assert self.f["z_exit"] * 1000 == pytest.approx(-0.871, abs=1e-3)
134
136 assert self.f["l_imp"] == pytest.approx(self.h["L"], rel=1e-12)
137 assert self.f["r_imp"] == pytest.approx(self.h["r_imp"], rel=1e-12)
138 assert self.f["z_imp"] == pytest.approx(self.h["z_imp"], rel=1e-12)
139 assert self.f["z_imp"] * 1000 == pytest.approx(5.022, abs=1e-3) # in front of the face
140
142 """SP-8089 measures impingement distance ALONG the jet: 5.2 d LOX, 6.0 d fuel -- not the
143 3.97 axial figure the flat-face readout used to call the free-jet length."""
144 assert self.f["free_jet_O"] == pytest.approx(self.h["jO"], rel=1e-12)
145 assert self.f["free_jet_F"] == pytest.approx(self.h["jF"], rel=1e-12)
146 assert self.f["free_jet_ld_O"] == pytest.approx(5.21, abs=0.01)
147 assert self.f["free_jet_ld_F"] == pytest.approx(5.97, abs=0.01)
148 assert self.f["l_over_d"] == pytest.approx(3.97, abs=0.01)
149
151 """The flank through each exit is normal to that jet: the inner flank descends outward at
152 tan(th_O), the outer descends inward at tan(th_F). Check both profile segments."""
153 prof = self.f["profile"]
154 (r1, z1), (r2, z2) = prof[1], prof[2] # inner flank
155 assert (z2 - z1) / (r2 - r1) == pytest.approx(-T(self.h["tO"]), rel=1e-9)
156 (r3, z3), (r4, z4) = prof[3], prof[4] # outer flank
157 assert (z4 - z3) / (r4 - r3) == pytest.approx(T(self.h["tF"]), rel=1e-9)
158 # the exits lie on those flanks
159 assert z1 + (self.h["rO"] - r1) * (z2 - z1) / (r2 - r1) == pytest.approx(-self.h["dE"], abs=1e-12)
160 assert z3 + (self.h["rF"] - r3) * (z4 - z3) / (r4 - r3) == pytest.approx(-self.h["dE"], abs=1e-12)
161
163 g = self.f["groove"]
164 assert g["groove_edge_in"] == pytest.approx(self.h["edge_in"], rel=1e-12)
165 assert g["groove_edge_out"] == pytest.approx(self.h["edge_out"], rel=1e-12)
166 assert g["flank_included"] == pytest.approx(180 - 43 - 46)
167 # shallowest flat bottom: each flank gives d/2 + land below its exit
168 want = self.h["dE"] + max((self.h["dO"] / 2 + EXIT_LAND_DEFAULT) * S(43), (self.h["dF"] / 2 + EXIT_LAND_DEFAULT) * S(46))
169 assert g["groove_depth"] == pytest.approx(want, rel=1e-12)
170 # the sharp V is where the flanks meet
171 x = (self.h["rF"] - self.h["rO"]) * T(46) / (T(43) + T(46))
172 assert g["groove_v_r"] == pytest.approx(self.h["rO"] + x, rel=1e-12)
173 assert g["groove_v_depth"] == pytest.approx(self.h["dE"] + x * T(43), rel=1e-12)
174
176 f = layout_from_config(_cfg(groove_bottom="v"), drawings=False)["face"]
177 assert f["groove"]["groove_is_v"] and f["groove"]["groove_depth"] == pytest.approx(f["groove"]["groove_v_depth"])
178
180 assert self.f["centre_clear"] == pytest.approx(2 * self.h["edge_in"], rel=1e-12)
181 assert self.f["wall_land"] == pytest.approx(0.0635 - self.h["edge_out"], rel=1e-12)
182
184 """The same injector with a flat face: exits at the datum, the ellipse traces."""
185 f = layout_from_config(_cfg(face="flat"), drawings=False)["face"]
186 assert f["z_exit"] == 0.0 and f["z_imp"] == pytest.approx(self.h["L"])
187 assert f["centre_clear"] == pytest.approx(2 * (self.h["rO"] - self.h["dO"] / (2 * C(43))))
188
189
190# =============================================================================================
191# Channels, against hand trigonometry
192# =============================================================================================
193
196 out = layout_from_config(_cfg())
197
198 @pytest.mark.parametrize("k", ["O", "F"])
200 d, th, r0 = (self.h["dO"], 43, self.h["rO"]) if k == "O" else (self.h["dF"], 46, self.h["rF"])
201 sgn = -1 if k == "O" else +1 # LOX inner: goes inward; fuel out
202 lam = 4.0 * d # discharge orifice_l_over_d
203 ch = self.out["passages"][k]["channel"]
204 assert ch["length"] == pytest.approx(lam, rel=1e-12)
205 assert ch["end"][0] == pytest.approx(r0 + sgn * lam * S(th), rel=1e-12)
206 assert ch["end"][1] == pytest.approx(-self.h["dE"] - lam * C(th), rel=1e-12)
207 # aligned: the channel is centred where the passage meets its floor
208 assert ch["r_center"] == pytest.approx(ch["end"][0])
209 assert ch["depth"] == pytest.approx(0.0127 + ch["end"][1], rel=1e-12)
210 # the whole footprint sits on the floor with a land each side
211 assert ch["width"] == pytest.approx(d / C(th) + 2 * EXIT_LAND_DEFAULT, rel=1e-12)
212
214 P = self.out["passages"]
215 assert P["O"]["channel"]["r_center"] * 1000 == pytest.approx(30.19, abs=0.01)
216 assert P["F"]["channel"]["r_center"] * 1000 == pytest.approx(50.11, abs=0.01)
217 assert P["O"]["channel"]["depth"] * 1000 == pytest.approx(7.30, abs=0.01)
218 assert P["F"]["channel"]["depth"] * 1000 == pytest.approx(7.88, abs=0.01)
219
221 """Each hole wall runs from its exit on the flank to the channel floor, inside the channel.
222 The openings themselves are not edges: the section draws the two walls, not a box."""
223 from engine.core.injectors.layout import face_z_at
224 f = self.out["face"]
225 walls = [p for p in self.out["drawings"]["section_doublet"]
226 if p["layer"].startswith("PASSAGE") and p["t"] == "poly" and p["pts"][0][0] > 0]
227 assert len(walls) == 4 and not any(p["closed"] for p in walls)
228 for p in walls:
229 ch = self.out["passages"][p["layer"][-1]]["channel"]
230 start, end = [(-y, x) for x, y in (p["pts"][0], p["pts"][-1])] # back to (z, r)
231 assert start[0] == pytest.approx(face_z_at(f["profile"], start[1]), abs=2e-7)
232 assert end[0] == pytest.approx(ch["end"][1], abs=2e-7)
233 assert ch["r_lo"] - 1e-9 <= end[1] <= ch["r_hi"] + 1e-9
234
236 """The half section gives each channel's width and depth, and draws the hole axis through
237 to the channel floor, where the channel's centre line meets it."""
238 sec = self.out["drawings"]["section_doublet"]
239 dims = {p["text"] for p in sec if p["t"] == "dim"}
240 for k in ("O", "F"):
241 ch = self.out["passages"][k]["channel"]
242 assert {f"{ch['width'] * 1000:.2f}", f"{ch['depth'] * 1000:.2f}"} <= dims
243 q = [ch["end"][0], -ch["end"][1]] # entry point, drawing frame
244 axis = [p for p in sec if p["layer"] == f"JET_{k}" and p["pts"][0][0] > 0][0]
245 assert axis["pts"][-1] == pytest.approx(q, abs=2e-7)
246 cl = [p for p in sec if p["layer"] == "CENTER" and p["pts"][0][0] == pytest.approx(ch["r_center"], abs=2e-7)]
247 assert cl and cl[0]["pts"][-1] == pytest.approx(q, abs=2e-7)
248
250 L = self.out["back"]["lands"]
251 P = self.out["passages"]
252 assert L["between"] == pytest.approx(P["F"]["channel"]["r_lo"] - P["O"]["channel"]["r_hi"])
253 assert L["outer"] == pytest.approx(0.0762 - P["F"]["channel"]["r_hi"])
254 assert L["inner"] == pytest.approx(P["O"]["channel"]["r_lo"] - 0.5 * 0.840 * 0.0254)
255
257 out = layout_from_config(_hole_ld(_cfg(), 12.0), drawings=False)
258 assert "pierce_O" in _codes(out) and out["passages"]["O"]["land_ld"] < 12.0
259
261 out = layout_from_config(_cfg(channel_floor="coned"), drawings=False)
262 for k, th in (("O", 43), ("F", 46)):
263 ch = out["passages"][k]["channel"]
264 # floor slope dz/dr perpendicular to the passage direction (-+sin, -cos)
265 assert abs(ch["floor_slope"]) == pytest.approx(T(th), rel=1e-12)
266 assert ch["breakthrough"] == "square"
267 assert "oblique_inlet_O" not in _codes(out)
268
270 """On a flat floor the hole breaks in at the jet angle: the lip on its acute side is
271 90 - 43 = 47 deg. SP-8089's L/d >= 4 is the axis length of a square-entry hole, so the
272 oblique cut is not an L/d shortfall; it is an inlet the Cd model does not cover (info)."""
273 ch = self.out["passages"]["O"]["channel"]
274 assert ch["entry_lip_deg"] == pytest.approx(90.0 - 43.0)
275 assert "short_wall_l_over_d" not in ch
276 assert not [c for c in _codes(self.out) if c.startswith("short_wall")]
277 assert _codes(self.out).get("oblique_inlet_O") == "info"
278 coned = layout_from_config(_cfg(channel_floor="coned"), drawings=False)
279 assert coned["passages"]["O"]["channel"]["entry_lip_deg"] == 90.0
280
282 ch = self.out["passages"]["O"]["channel"]
283 assert ch["flow_area"] == pytest.approx(ch["width"] * ch["depth"], rel=1e-9)
284 assert ch["area_ratio"] == pytest.approx(ch["flow_area"] / (24 * math.pi * self.h["dO"] ** 2 / 4), rel=1e-9)
285 assert "channel_area_O" in _codes(self.out)
286
288 """The coned floor tilts by theta, so its shallow wall sits (w/2) tan(theta) nearer the back
289 face than the passage axis: the breakout test must use that wall."""
290 out = layout_from_config(_hole_ld(_cfg(channel_floor="coned"), 9.8))
291 ch = out["passages"]["O"]["channel"]
292 assert ch["pierces_back"] and "pierce_O" in _codes(out)
293 assert min(ch["floor_z_lo"], ch["floor_z_hi"]) >= -0.0127 - 1e-12
294 plate = [p for p in out["drawings"]["section_doublet"] if p["layer"] == "PLATE"][0]
295 assert min(x for x, _ in plate["pts"]) >= -0.0127 - 1e-9
296
298 out = layout_from_config(_cfg(channel_width=0.022), drawings=False)
299 assert _codes(out).get("channels_cross") == "bad"
300
301
302# =============================================================================================
303# Plenum back (the flat-face plug many injectors use) still behaves
304# =============================================================================================
305
307 out = layout_from_config(_cfg(face="flat", back="plenum"), drawings=False)
308 h = _hand_6500N()
309 P = out["passages"]
310 assert P["O"]["r_back"] == pytest.approx(h["rO"] - 0.0127 * T(43), rel=1e-12)
311 assert P["F"]["r_back"] == pytest.approx(h["rF"] + 0.0127 * T(46), rel=1e-12)
312 assert out["back"]["mode"] == "plenum"
313
314
316 out = layout_from_config(_cfg(face="flat"), drawings=False)
317 assert "flat_entry" in _codes(out)
318 assert "flat_entry" not in _codes(layout_from_config(_cfg(), drawings=False))
319
320
321# =============================================================================================
322# Cd, from the hole as built
323# =============================================================================================
324
326 """One L/d per hole: it sets the Cd (the solver's own discharge model) and, with channels, the
327 passage to the channel floor. The layout reports the Cd so a change shows where it is made."""
328
330 cd = layout_from_config(_cfg(), drawings=False)["passages"]["O"]["cd"]
331 # Lichtarowicz (1965) sharp inlet at L/d 4: Cd_u = 0.827 - 0.0085 x 4
332 assert cd["value"] == pytest.approx(0.827 - 0.0085 * 4.0) and cd["inlet"] == "sharp"
333 assert cd["length_factor"] == pytest.approx((0.827 - 0.0085 * 4.0) / 0.80) and cd["uses_ld"]
334
336 from engine.core.discharge import cd_inf_from_orifice_diameter
337 from engine.core.injectors.layout import effective_discharge
338 from engine.pipeline.config_schemas import PintleEngineConfig
339 cfg = _hole_ld(_cfg(), 9.0, length_model="lichtarowicz")
340 lay = layout_from_config(cfg, drawings=False)
341 model = PintleEngineConfig.model_validate(cfg)
342 for k, side in (("O", "oxidizer"), ("F", "fuel")):
343 d = cfg["injector"]["geometry"][side]["d_jet"]
344 want = cd_inf_from_orifice_diameter(d, effective_discharge(model, side))
345 assert lay["passages"][k]["cd"]["value"] == pytest.approx(want, rel=1e-12)
346
348 a = layout_from_config(_hole_ld(_cfg(), 4.0, length_model="lichtarowicz"), drawings=False)
349 b = layout_from_config(_hole_ld(_cfg(), 8.0, length_model="lichtarowicz"), drawings=False)
350 ca, cb = a["passages"]["O"]["cd"]["value"], b["passages"]["O"]["cd"]["value"]
351 # Cd_u = 0.827 - 0.0085 L/d, normalised to 0.80 at L/d 3.18
352 assert cb / ca == pytest.approx((0.827 - 0.0085 * 8) / (0.827 - 0.0085 * 4), rel=1e-9)
353 # and the channel follows the same number
354 assert b["passages"]["O"]["channel"]["l_over_d"] == pytest.approx(8.0)
355
357 cfg = _hole_ld(_cfg(), 8.0, inlet_geometry=None)
358 cd = layout_from_config(cfg, drawings=False)["passages"]["O"]["cd"]
359 assert cd["inlet"] is None and cd["uses_ld"] is False
360
362 """With channels the hole is the passage: there is no second number to disagree with."""
363 for ld in (3.0, 6.0, 9.0):
364 assert not any(c.startswith("cd_ld") for c in _codes(layout_from_config(_hole_ld(_cfg(), ld), drawings=False)))
365
367 cfg = _cfg(face="flat", back="plenum")
368 cfg["design_requirements"]["layer1_injector_counterbore_dia_m"] = None
369 assert "cd_ld_O" in _codes(layout_from_config(cfg, drawings=False))
370
371
373 """The inlet declared for the Cd is cut into the hole's entry edge in the section (and the DXF),
374 from the same r/d the Cd table is keyed to."""
375
376 @staticmethod
377 def _walls(inlet, **plate):
378 cfg = _hole_ld(_cfg(**plate), 4.0, inlet_geometry=inlet)
379 out = layout_from_config(cfg)
380 return out, [p for p in out["drawings"]["section_doublet"]
381 if p["layer"] == "PASSAGE_O" and p["t"] == "poly" and p["pts"][0][0] > 0]
382
384 _, walls = self._walls("sharp")
385 assert [len(w["pts"]) for w in walls] == [2, 2]
386
388 out, walls = self._walls("rounded")
389 d = out["inputs"]["oxidizer"]["d_jet"]
390 r = 0.125 * d # INLET_GEOMETRY_RD["rounded"]
391 ch = out["passages"]["O"]["channel"]
392 floor_y = -ch["end"][1] # drawing frame: Y = -z
393 for w in walls:
394 arc = np.array(w["pts"][1:]) # after the exit point: tangent point .. floor
395 # all arc points sit r from one centre
396 A, B = arc[:, 0], arc[:, 1]
397 M = np.c_[2 * A, 2 * B, np.ones(len(A))]
398 cx, cy, c = np.linalg.lstsq(M, A ** 2 + B ** 2, rcond=None)[0]
399 assert math.sqrt(c + cx ** 2 + cy ** 2) == pytest.approx(r, abs=3e-7) # points kept to 0.1 um
400 assert arc[-1][1] == pytest.approx(floor_y, abs=1e-7) # ends on the floor
401 assert abs(cy - floor_y) == pytest.approx(r, abs=3e-7) # tangent to it
402
404 out, walls = self._walls("chamfered")
405 d = out["inputs"]["oxidizer"]["d_jet"]
406 floor_y = -out["passages"]["O"]["channel"]["end"][1]
407 for w in walls:
408 assert len(w["pts"]) == 3 # exit, cut start, cut end
409 (x1, y1), (x2, y2) = w["pts"][1], w["pts"][2]
410 assert y2 == pytest.approx(floor_y, abs=1e-7) and y1 < floor_y # from the wall (below the floor, toward the face) to the floor
411 # legs of 0.1 d each side of the corner: the cut face is 2 (0.1 d) sin(wedge / 2) long
412 assert 0.05 * d < math.hypot(x2 - x1, y2 - y1) < 0.2 * d
413
415 out, _ = self._walls("rounded")
416 labels = [p["text"] for p in out["drawings"]["section_doublet"] if p["t"] == "text" and p["text"].startswith("LOX 43")]
417 assert labels == ["LOX 43° ⌀1.548 L/d 4.0, rounded inlet r 0.19"]
418 assert not any("inlet" in p.get("text", "") for p in self._walls("sharp")[0]["drawings"]["section_doublet"])
419
421 out, _ = self._walls("bellmouth")
422 th, d = 43.0, out["inputs"]["oxidizer"]["d_jet"]
423 reach = 0.20 * d / math.tan(math.radians(0.5 * (90 - th)))
424 assert out["passages"]["O"]["inlet_reach"] == pytest.approx(reach, rel=1e-12)
425 assert _codes(out)["inlet_fit_O"] == "warn"
426 ch = out["passages"]["O"]["channel"]
427 wide = _cfg(channel_width=ch["footprint"] + 2 * reach + 1e-6)
428 assert "inlet_fit_O" not in _codes(layout_from_config(_hole_ld(wide, 4.0, inlet_geometry="bellmouth"), drawings=False))
429 assert "inlet_fit_O" not in _codes(self._walls("rounded")[0])
430
431
432# =============================================================================================
433# Igniter
434# =============================================================================================
435
438 out = layout_from_config(_cfg(), drawings=False)
439 # 21.336 thread OD + 2 x 2.00 mm web
440 assert out["centre_keepout"]["dia"] * 1000 == pytest.approx(25.34, abs=0.01)
441 assert out["centre_keepout"]["source"] == "igniter 1/2 NPT"
442
444 cfg = _cfg()
445 assert "igniter_engagement" in _codes(layout_from_config(cfg, drawings=False))
446 cfg["injector"]["igniter"]["hub_thickness"] = 0.01905
447 assert "igniter_engagement" not in _codes(layout_from_config(cfg, drawings=False))
448
449 @pytest.mark.parametrize("hub", [None, 0.01905])
451 """The thread line starts on the plug's back face (or the boss's) and ends on the drilled
452 wall: both ends lie on the plug outline."""
453 cfg = _cfg()
454 cfg["injector"]["igniter"]["hub_thickness"] = hub
455 sec = layout_from_config(cfg)["drawings"]["section_doublet"]
456 plate = [p for p in sec if p["layer"] == "PLATE" and max(x for x, _ in p["pts"]) > 0][0]["pts"]
457 thread = [p for p in sec if p["layer"] in ("THREAD", "BAD") and p["t"] == "poly" and p["pts"][0][0] > 0][0]["pts"]
458
459 def on_outline(q):
460 for (x1, y1), (x2, y2) in zip(plate, plate[1:] + plate[:1]):
461 cross = (x2 - x1) * (q[1] - y1) - (y2 - y1) * (q[0] - x1)
462 if abs(cross) < 1e-10 and min(x1, x2) - 1e-9 <= q[0] <= max(x1, x2) + 1e-9 \
463 and min(y1, y2) - 1e-9 <= q[1] <= max(y1, y2) + 1e-9:
464 return True
465 return False
466 assert on_outline(thread[0]) and on_outline(thread[-1])
467
469 def port(cfg):
470 sec = layout_from_config(cfg)["drawings"]["section_doublet"]
471 bad = [p for p in sec if p["layer"] == "BAD"]
472 labels = [p["text"] for p in sec if p["t"] == "text" and ("NPT port" in p["text"] or "needs" in p["text"])]
473 return bad, labels
474 cfg = _cfg()
475 bad, labels = port(cfg)
476 assert bad and labels == ["1/2 NPT port, L2 13.56", "needs 13.56, has 12.70"]
477 cfg["injector"]["igniter"]["hub_thickness"] = 0.01905
478 bad, labels = port(cfg)
479 assert not bad and labels == ["1/2 NPT port, L2 13.56"]
480
482 cfg = _cfg()
483 cfg["design_requirements"]["layer1_injector_center_clear_dia_m"] = 0.0381
484 out = layout_from_config(cfg, drawings=False)
485 assert out["centre_keepout"]["dia"] == pytest.approx(0.0381)
486 assert _codes(out).get("centre_reserve_oversized") == "info"
487
488
489# =============================================================================================
490# The old flat-face regressions (SHIP / FINAL fixtures), kept: they are why the checks exist
491# =============================================================================================
492
493BEFORE = dict(
494 oxidizer=dict(n_elements=28, d_jet=0.0015821116699998301, impingement_angle=40, spacing=0.0030182972100858507),
495 fuel=dict(n_elements=28, d_jet=0.001375132476863662, impingement_angle=69, spacing=0.009875029755263455),
496 bore_diameter=0.127,
497)
498AFTER = dict(
499 oxidizer=dict(n_elements=27, d_jet=0.0017094, impingement_angle=40.0, spacing=0.0081070167),
500 fuel=dict(n_elements=27, d_jet=0.0015024, impingement_angle=50.0, spacing=0.0111428181),
501 bore_diameter=0.127,
502)
503REQS = dict(center_clear_dia=0.0381, min_web=0.002, wall_clearance=0.008, ld_min=3, ld_max=5,
504 plate_thickness=0.0127, counterbore_dia=0.004, land_ld_O=4,
505 plate=dict(face="flat", back="plenum"))
506
507
509 out = compute_layout(**BEFORE, **REQS)
510
512 assert self.out["face"]["r_imp"] * 2000 == pytest.approx(41.79, abs=0.05)
513 assert self.out["face"]["core_frac"] < 0.11
514
516 assert self.out["face"]["centre_clear"] * 1000 == pytest.approx(24.84, abs=0.05)
517 assert "centre clear" in _texts(self.out)
518
520 assert "web between holes on a ring 1.44 mm < 2.00 mm required" in _texts(self.out)
521
523 bare = compute_layout(**BEFORE, **dict(REQS, counterbore_dia=0.0))
524 assert "L/d 21.8" in _texts(bare)
525
527 assert "SP-8089" in _texts(self.out)
528
530 out = compute_layout(**AFTER, **REQS)
531 assert [w for w in out["warnings"] if w["level"] == "bad"] == []
532
533
535 same = dict(
536 oxidizer=dict(n_elements=20, d_jet=0.002, impingement_angle=50, spacing=0.006),
537 fuel=dict(n_elements=20, d_jet=0.002, impingement_angle=60, spacing=0.006),
538 bore_diameter=0.0813,
539 )
540 out = compute_layout(**same)
541 assert out["face"]["degenerate"]
542 assert "same pitch circle" in _texts(out)
543 assert "of the chamber area is fed" not in _texts(out)
544
545
546# =============================================================================================
547# Parity: Layer 1's inner-loop copies flip exactly where the layout says
548# =============================================================================================
549
550def _random_layouts(n=60, seed=7):
551 rng = np.random.default_rng(seed)
552 for _ in range(n):
553 n_el = int(rng.integers(6, 40))
554 d_O, d_F = rng.uniform(0.8e-3, 2.5e-3, 2)
555 th_O, th_F = rng.uniform(20, 60, 2)
556 s_O = rng.uniform(max(d_O * 1.5, 3e-3), 0.012)
557 s_F = s_O + rng.uniform(1e-3, 6e-3)
558 bore = (n_el * s_F / np.pi) * rng.uniform(1.15, 1.6)
559 yield dict(
560 oxidizer=dict(n_elements=n_el, d_jet=d_O, impingement_angle=th_O, spacing=s_O),
561 fuel=dict(n_elements=n_el, d_jet=d_F, impingement_angle=th_F, spacing=s_F),
562 bore_diameter=bore,
563 )
564
565
567 return dict(
568 n_elements=float(g["oxidizer"]["n_elements"]),
569 spacing_O_m=g["oxidizer"]["spacing"], spacing_F_m=g["fuel"]["spacing"],
570 d_jet_O_m=g["oxidizer"]["d_jet"], d_jet_F_m=g["fuel"]["d_jet"],
571 D_chamber_inner_m=g["bore_diameter"],
572 angle_O_deg=g["oxidizer"]["impingement_angle"], angle_F_deg=g["fuel"]["impingement_angle"],
573 )
574
575
577 k = _l1_kwargs(g)
578 return dict(
579 d_jet_O=k["d_jet_O_m"], d_jet_F=k["d_jet_F_m"], sp_O=k["spacing_O_m"], sp_F=k["spacing_F_m"],
580 D_chamber_inner=k["D_chamber_inner_m"], D_throat_check=0.0, A_chamber_check=0.0,
581 A_throat_check=0.0, n_elements=k["n_elements"], angle_O_deg=k["angle_O_deg"],
582 angle_F_deg=k["angle_F_deg"],
583 )
584
585
586EPS = 1e-6
587
588
589@pytest.mark.parametrize("contoured", [False, True], ids=["flat", "contoured"])
590@pytest.mark.parametrize("path", ["worker", "soft", "hard"])
593 _impinging_face_infeasibility_terms, _impinging_hard_geometry_blocks_eval,
594 _impinging_ring_geometry_squared,
595 )
596 face = dict(face_contoured=contoured, exit_land=EXIT_LAND_DEFAULT)
597
598 def violated(g, **lim):
599 if path == "worker":
600 return _impinging_face_infeasibility_terms(**_l1_kwargs(g), **lim, **face) > 0.0
601 if path == "soft":
602 return _impinging_ring_geometry_squared(**_l1_kwargs(g), **lim, **face) > 0.0
603 return _impinging_hard_geometry_blocks_eval(**_hard_kwargs(g), **lim, **face)
604
605 for g in _random_layouts():
606 f = compute_layout(**g, plate={"face": "contoured" if contoured else "flat"})["face"]
607 cc, wl = f["centre_clear"], f["wall_land"]
608 if cc > 0:
609 assert not violated(g, center_clear_dia_m=cc * (1 - EPS)), "centre clear: false alarm"
610 assert violated(g, center_clear_dia_m=cc * (1 + EPS)), "centre clear: missed"
611 if wl > 0:
612 assert not violated(g, wall_clearance_m=wl * (1 - EPS)), "wall land: false alarm"
613 assert violated(g, wall_clearance_m=wl * (1 + EPS)), "wall land: missed"
614
615
617 """Widen both channels until they meet: Layer 1's channel term turns on exactly where the
618 layout starts calling them crossed."""
619 from engine.optimizer.layers.layer1_static_optimization import _impinging_back_face_terms
620 for g in _random_layouts():
621 liner = 0.1 * g["bore_diameter"]
622 env = {"r_sleeve_id": 0.5 * g["bore_diameter"] + liner, "liner_thickness": liner}
623 base = compute_layout(**g, envelope=env, plate_thickness=0.02,
624 plate={"face": "contoured", "back": "channels"})
625 P = base["passages"]
626 if any(p["channel"]["pierces_back"] for p in P.values()):
627 continue
628 w_meet = abs(P["F"]["channel"]["r_center"] - P["O"]["channel"]["r_center"])
629 outs = {}
630 for f in (1 - EPS, 1 + EPS):
631 outs[f] = compute_layout(**g, envelope=env, plate_thickness=0.02,
632 plate={"face": "contoured", "back": "channels", "channel_width": w_meet * f})
633 clear = outs[1 - EPS]
634 if [w for w in clear["warnings"] if w["level"] == "bad"]:
635 continue # something else already fails at that width
636 assert "channels_cross" in _codes(outs[1 + EPS])
637 kw = dict(_l1_kwargs(g), plate_thickness_m=0.02, face_contoured=True, back_channels=True,
638 liner_thickness_m=liner)
639 assert _impinging_back_face_terms(**kw, channel_width_m=w_meet * (1 - EPS)) == 0.0
640 assert _impinging_back_face_terms(**kw, channel_width_m=w_meet * (1 + EPS)) > 0.0
641
642
644 import inspect
646 src = inspect.getsource(L1._compute_objective_value)
647 for key in ("layer1_injector_face_contoured", "layer1_injector_back_channels",
648 "layer1_injector_land_ld_O", "layer1_injector_r_plate_m"):
649 assert key in src, key
650
651
653 from engine.core.injectors.impinging import impingement_standoff_m
654 for g in _random_layouts():
655 out = compute_layout(**g)
656 L = impingement_standoff_m(g["oxidizer"]["n_elements"], g["oxidizer"]["spacing"],
657 g["fuel"]["spacing"], g["oxidizer"]["impingement_angle"],
658 g["fuel"]["impingement_angle"])
659 assert out["face"]["l_imp"] == pytest.approx(L, rel=1e-12)
660
661
662# =============================================================================================
663# The frontend renders from a fixture of this module's output; it must not go stale.
664# =============================================================================================
665
666FIXTURE = ROOT / "frontend/src/components/__fixtures__/layout_6500N.json"
667
668
670 """Regenerate: python3 scripts/injector_layout.py --no-flows configs/ethalox_6500N.yaml > <fixture>"""
671 want = json.loads(json.dumps(layout_from_config(yaml.safe_load(C6500.read_text()))))
672 got = json.loads(FIXTURE.read_text())
673
674 def same(a, b, path="$"):
675 if isinstance(a, dict) and isinstance(b, dict):
676 assert a.keys() == b.keys(), f"{path}: keys differ"
677 for k in a:
678 same(a[k], b[k], f"{path}.{k}")
679 elif isinstance(a, list) and isinstance(b, list):
680 assert len(a) == len(b), f"{path}: length differs"
681 for i, (x, y) in enumerate(zip(a, b)):
682 same(x, y, f"{path}[{i}]")
683 elif isinstance(a, float) or isinstance(b, float):
684 assert a == pytest.approx(b, rel=1e-5, abs=1e-9), path
685 else:
686 assert a == b, path
687
688 same(got, want)
689
690
692 """No injector.plate: the drawing is the contoured, channelled plug (what the stand machines)
693 and says so; Layer 1 keeps the flat-face, plenum constraints it always had until declared."""
695 from engine.pipeline.config_schemas import InjectorPlateConfig
696 cfg = yaml.safe_load(C6500.read_text())
697 cfg["injector"]["plate"] = None
698 out = layout_from_config(cfg)
699 assert out["face"]["contoured"] and out["back"]["mode"] == "channels"
700 assert out["face"]["plate_declared"] is False and _codes(out)["plate_default"] == "info"
701 assert "plate_default" not in _codes(layout_from_config(_cfg(), drawings=False))
702
703 class _Cfg:
704 class injector:
705 plate = None
706 assert _layer1_plate(_Cfg) == {}
707 # declared with nothing said, the schema gives the same plug the drawing assumed
708 assert (InjectorPlateConfig().face, InjectorPlateConfig().back) == ("contoured", "channels")
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
test_a_rounded_inlet_is_an_arc_of_the_table_radius_tangent_to_wall_and_floor(self)
static const emmc_port_t port
Definition emmc_stm32.c:210
test_an_undeclared_plate_draws_the_stands_plug_and_layer1_stays_as_it_was()
_hand_6500N(exit_land=EXIT_LAND_DEFAULT)
test_centre_and_wall_clearance_flip_where_the_layout_says(path, contoured)
_hole_ld(cfg, ld, **discharge)