1"""engine.core.injectors.layout -- the one place the injector's geometry is derived.
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.
11from pathlib
import Path
18 EXIT_LAND_DEFAULT, IMPINGEMENT_LD_TARGET_DEFAULT, compute_layout, impingement_ld_band,
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))
26S =
lambda a: math.sin(math.radians(a))
27C =
lambda a: math.cos(math.radians(a))
31 cfg = yaml.safe_load(C6500.read_text())
32 cfg[
"injector"].setdefault(
"plate", {}).update(plate)
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)
44 return " | ".join(w[
"text"]
for w
in out[
"warnings"])
48 return {w[
"code"]: w[
"level"]
for w
in out[
"warnings"]}
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"]))
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))
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
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
80 assert IMPINGEMENT_LD_TARGET_DEFAULT == 4.0
88 out = layout_from_config(
_cfg(), drawings=
False)
91 env = self.
out[
"envelope"]
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)
100 cfg[
"ablative_cooling"][
"initial_thickness"] = 0.010
101 assert "liner_gap" in _codes(layout_from_config(cfg, drawings=
False))
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)
114 g = yaml.safe_load(C6500.read_text())[
"injector"][
"geometry"]
115 O, F = g[
"oxidizer"], g[
"fuel"]
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))
128 f = layout_from_config(
_cfg(), drawings=
False)[
"face"]
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)
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)
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)
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]
155 assert (z2 - z1) / (r2 - r1) == pytest.approx(-
T(self.
h[
"tO"]), rel=1e-9)
156 (r3, z3), (r4, z4) = prof[3], prof[4]
157 assert (z4 - z3) / (r4 - r3) == pytest.approx(
T(self.
h[
"tF"]), rel=1e-9)
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)
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)
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)
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)
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"])
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)
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))))
196 out = layout_from_config(
_cfg())
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
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)
208 assert ch[
"r_center"] == pytest.approx(ch[
"end"][0])
209 assert ch[
"depth"] == pytest.approx(0.0127 + ch[
"end"][1], rel=1e-12)
211 assert ch[
"width"] == pytest.approx(d /
C(th) + 2 * EXIT_LAND_DEFAULT, rel=1e-12)
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)
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."""
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)
229 ch = self.
out[
"passages"][p[
"layer"][-1]][
"channel"]
230 start, end = [(-y, x)
for x, y
in (p[
"pts"][0], p[
"pts"][-1])]
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
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"}
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]]
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)
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)
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
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"]
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)
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
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)
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
298 out = layout_from_config(
_cfg(channel_width=0.022), drawings=
False)
299 assert _codes(out).get(
"channels_cross") ==
"bad"
307 out = layout_from_config(
_cfg(face=
"flat", back=
"plenum"), drawings=
False)
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"
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))
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."""
330 cd = layout_from_config(
_cfg(), drawings=
False)[
"passages"][
"O"][
"cd"]
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"]
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)
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"]
352 assert cb / ca == pytest.approx((0.827 - 0.0085 * 8) / (0.827 - 0.0085 * 4), rel=1e-9)
354 assert b[
"passages"][
"O"][
"channel"][
"l_over_d"] == pytest.approx(8.0)
358 cd = layout_from_config(cfg, drawings=
False)[
"passages"][
"O"][
"cd"]
359 assert cd[
"inlet"]
is None and cd[
"uses_ld"]
is False
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)))
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))
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."""
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]
384 _, walls = self.
_walls(
"sharp")
385 assert [len(w[
"pts"])
for w
in walls] == [2, 2]
388 out, walls = self.
_walls(
"rounded")
389 d = out[
"inputs"][
"oxidizer"][
"d_jet"]
391 ch = out[
"passages"][
"O"][
"channel"]
392 floor_y = -ch[
"end"][1]
394 arc = np.array(w[
"pts"][1:])
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)
400 assert arc[-1][1] == pytest.approx(floor_y, abs=1e-7)
401 assert abs(cy - floor_y) == pytest.approx(r, abs=3e-7)
404 out, walls = self.
_walls(
"chamfered")
405 d = out[
"inputs"][
"oxidizer"][
"d_jet"]
406 floor_y = -out[
"passages"][
"O"][
"channel"][
"end"][1]
408 assert len(w[
"pts"]) == 3
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
412 assert 0.05 * d < math.hypot(x2 - x1, y2 - y1) < 0.2 * d
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"])
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])
438 out = layout_from_config(
_cfg(), drawings=
False)
440 assert out[
"centre_keepout"][
"dia"] * 1000 == pytest.approx(25.34, abs=0.01)
441 assert out[
"centre_keepout"][
"source"] ==
"igniter 1/2 NPT"
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))
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."""
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"]
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:
466 assert on_outline(thread[0])
and on_outline(thread[-1])
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"])]
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"]
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"
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),
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),
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"))
509 out = compute_layout(**BEFORE, **REQS)
512 assert self.
out[
"face"][
"r_imp"] * 2000 == pytest.approx(41.79, abs=0.05)
513 assert self.
out[
"face"][
"core_frac"] < 0.11
516 assert self.
out[
"face"][
"centre_clear"] * 1000 == pytest.approx(24.84, abs=0.05)
517 assert "centre clear" in _texts(self.
out)
520 assert "web between holes on a ring 1.44 mm < 2.00 mm required" in _texts(self.
out)
523 bare = compute_layout(**BEFORE, **dict(REQS, counterbore_dia=0.0))
524 assert "L/d 21.8" in _texts(bare)
530 out = compute_layout(**AFTER, **REQS)
531 assert [w
for w
in out[
"warnings"]
if w[
"level"] ==
"bad"] == []
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,
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)
551 rng = np.random.default_rng(seed)
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)
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),
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"],
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"],
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,
596 face = dict(face_contoured=contoured, exit_land=EXIT_LAND_DEFAULT)
598 def violated(g, **lim):
600 return _impinging_face_infeasibility_terms(**
_l1_kwargs(g), **lim, **face) > 0.0
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)
606 f = compute_layout(**g, plate={
"face":
"contoured" if contoured
else "flat"})[
"face"]
607 cc, wl = f[
"centre_clear"], f[
"wall_land"]
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"
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"
617 """Widen both channels until they meet: Layer 1's channel term turns on exactly where the
618 layout starts calling them crossed."""
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"})
626 if any(p[
"channel"][
"pierces_back"]
for p
in P.values()):
628 w_meet = abs(P[
"F"][
"channel"][
"r_center"] - P[
"O"][
"channel"][
"r_center"])
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"]:
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
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
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)
666FIXTURE = ROOT /
"frontend/src/components/__fixtures__/layout_6500N.json"
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())
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"
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
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."""
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))
706 assert _layer1_plate(_Cfg) == {}
708 assert (InjectorPlateConfig().face, InjectorPlateConfig().back) == (
"contoured",
"channels")
test_drawn_passages_start_on_the_flank_and_end_on_the_floor(self)
test_flat_floor_leaves_an_acute_inlet_lip_and_no_short_wall_rule(self)
test_lands_between_are_what_is_left(self)
test_a_longer_passage_needs_a_thicker_plate(self)
test_passage_runs_its_L_over_d_to_the_channel_floor(self, k)
test_the_section_dimensions_each_channel_and_its_hole_axis_meets_it(self)
test_a_coned_floor_cannot_break_out_of_the_back(self)
test_coned_floor_is_normal_to_the_passage(self)
test_channel_flow_area_is_reported_against_the_orifices(self)
test_numbers_as_drawn(self)
test_overlapping_channels_are_impossible(self)
test_clearances_are_to_the_groove_edges(self)
test_each_exit_is_square_on_its_flank(self)
test_exits_share_one_recessed_plane(self)
test_v_bottom_on_request(self)
test_groove_edges_and_depth(self)
test_flat_face_keeps_the_old_geometry(self)
test_the_jets_meet_where_trigonometry_says(self)
test_free_jet_is_measured_along_the_jet(self)
test_the_plug_is_drawn_to_the_sleeve_bore(self)
test_a_liner_that_does_not_fill_the_sleeve_is_called_out(self)
test_sleeve_from_the_requirements(self)
test_a_drilled_plate_still_asks(self)
test_channels_never_ask_to_couple_the_cd(self)
test_the_reported_cd_is_the_solvers(self)
test_6500N_is_a_sharp_short_tube(self)
test_a_longer_hole_lowers_cd_under_lichtarowicz(self)
test_without_an_inlet_the_hole_length_does_not_enter_cd(self)
test_half_inch_npt_keepout(self)
test_the_thread_is_cut_into_the_plug_not_floating(self, hub)
test_the_section_says_why_the_port_is_red(self)
test_engagement_needs_the_centre_thicker(self)
test_an_explicit_reservation_wins_and_is_called_oversized(self)
test_a_chamfer_is_one_straight_cut(self)
test_an_inlet_too_big_for_the_channel_says_how_wide_to_go(self)
test_sharp_is_a_corner(self)
test_a_rounded_inlet_is_an_arc_of_the_table_radius_tangent_to_wall_and_floor(self)
test_the_hole_label_says_which_inlet(self)
test_needs_a_counterbore_to_be_drillable(self)
test_calls_out_the_thin_lox_web(self)
test_leaves_no_room_at_the_axis(self)
test_sp8089_included_angle(self)
test_the_replacement_raises_nothing_blocking(self)
test_crams_every_element_onto_a_circle_narrower_than_the_throat(self)
static const emmc_port_t port
_random_layouts(n=60, seed=7)
test_an_undeclared_plate_draws_the_stands_plug_and_layer1_stays_as_it_was()
_hand_6500N(exit_land=EXIT_LAND_DEFAULT)
test_flat_face_drill_entry_is_flagged_as_off_square()
test_worker_path_reads_the_plate_settings()
test_plenum_back_face_positions()
test_default_constant_is_four()
test_layer1_uses_the_resolver()
test_centre_and_wall_clearance_flip_where_the_layout_says(path, contoured)
test_frontend_fixture_is_current()
test_standoff_matches_the_physics_helper()
test_channel_overlap_flips_where_the_layout_says()
test_6500N_band_is_what_it_declares()
_hole_ld(cfg, ld, **discharge)
test_ring_geometry_default_band_is_inert()