1"""Impinging-injector layout: the one place the injector's geometry is derived.
3Layer 1, ``scripts/design_audit.py``, the forward solver and the Geometry tab all read the same
4derived quantities from here -- pitch circles, where the jets meet, where each drilled passage
5runs and what it breaks into, how much metal is left. Callers do not re-derive them.
7The hardware this describes
8---------------------------
9The injector is a plug that fills the chamber sleeve's bore. Its chamber-side face bears on the
10phenolic liner's forward end; the gas sees the face only inside the liner bore.
12* **Face.** ``flat``: every orifice leaves an axis-normal face at its jet angle, so the exit is an
13 ellipse and the drill enters off square. ``contoured``: the face is turned with an annular
14 groove whose two flanks are normal to the two jets (a cone per ring, since every jet lies in a
15 radial plane), so each orifice leaves its flank square and round. That groove is a valley: the
16 inner ring's jet points outward, so its flank faces outward, and the outer ring's the reverse.
17Both default to the plug this stand machines (contoured, channels), also when ``injector.plate``
18is not declared; Layer 1 applies their constraints only once the block is declared.
20* **Back.** ``plenum``: the passages run straight through to a flat back face (a dome or plate
21 manifold behind it). ``channels``: the back face is flat with one annular channel per ring;
22 each passage runs from its exit straight to its channel's floor, and the channel is centred on
23 where the passage axis meets that floor. That is what "the channels line up" means.
25Seals, the manifold cover, the igniter hardware and how the plug is retained are the designer's,
26not this module's. It reports the lands those parts would have to fit in; it does not size them.
28Coordinates: axisymmetric, ``r`` radial, ``z`` axial with ``z = 0`` the face DATUM (the flat
29lands, and the liner's forward end), the chamber toward ``+z`` and the back face at ``z = -t``.
32from __future__
import annotations
35from typing
import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple
47IMPINGEMENT_LD_TARGET_DEFAULT = 4.0
51IMPINGEMENT_LD_TOL_UNDERIVED = 1.0
60def _opt_float(req: Mapping[str, Any], key: str) -> Optional[float]:
66 except (TypeError, ValueError):
70def _opt_bool(req: Mapping[str, Any], key: str, default: bool) -> bool:
74 if isinstance(v, str):
76 if s
in (
"false",
"0",
"no",
"off",
""):
78 if s
in (
"true",
"1",
"yes",
"on"):
85 """Resolve the standoff L/d band from design requirements.
87 Precedence, highest first: explicit ``layer1_impingement_Ld_min`` / ``_max``; then
88 ``layer1_impingement_Ld_target`` +/- ``layer1_impingement_Ld_tol``; the tolerance defaults
89 to 0 when ``layer1_derive_impingement_spacing`` is on (the default) and to 1.0 when off.
91 req = requirements
or {}
92 target =
_opt_float(req,
"layer1_impingement_Ld_target")
94 target = IMPINGEMENT_LD_TARGET_DEFAULT
95 derived =
_opt_bool(req,
"layer1_derive_impingement_spacing",
True)
96 tol =
_opt_float(req,
"layer1_impingement_Ld_tol")
98 tol = 0.0
if derived
else IMPINGEMENT_LD_TOL_UNDERIVED
100 lo =
_opt_float(req,
"layer1_impingement_Ld_min")
101 hi =
_opt_float(req,
"layer1_impingement_Ld_max")
103 lo =
max(0.0, target - tol)
106 return LdBand(float(target), float(lo), float(hi))
119DRILL_LD_PRACTICE_MAX = 10.0
128DRILL_ENTRY_MAX_OFF_SQUARE_DEG = 10.0
131FACE_HEATING_INCLUDED_DEG = 90.0
144EXIT_LAND_DEFAULT = 0.0005
148PLATE_THICKNESS_DEFAULT = 0.0127
153MANIFOLD_Q_FRAC = 0.05
157PLATE_YIELD_FACTOR = 1.5
161PLATE_POISSON_DEFAULT = 0.3
172 """Radial half-extent of an inclined hole's elliptical trace on an axis-normal plane."""
173 return 0.5 * d /
max(_COS_FLOOR, abs(math.cos(math.radians(theta_deg))))
176def _get(m: Any, key: str) -> Any:
179 if isinstance(m, Mapping):
181 return getattr(m, key,
None)
184def _f(v: Any, default: float = 0.0) -> float:
187 except (TypeError, ValueError):
189 return x
if math.isfinite(x)
else default
194 "n_elements":
_f(
_get(raw,
"n_elements")),
195 "d_jet":
_f(
_get(raw,
"d_jet")),
196 "impingement_angle":
_f(
_get(raw,
"impingement_angle")),
197 "spacing":
_f(
_get(raw,
"spacing")),
201def _plain(m: Any) -> Optional[Dict[str, Any]]:
202 """A config sub-block as a plain dict (None stays None)."""
205 if isinstance(m, Mapping):
207 if hasattr(m,
"model_dump"):
208 return m.model_dump()
213 req =
_get(cfg,
"design_requirements")
or {}
214 if isinstance(req, Mapping):
216 return req.model_dump()
if hasattr(req,
"model_dump")
else {}
220 return math.tan(math.radians(th))
224 return math.sin(math.radians(th))
228 return math.cos(math.radians(th))
236 """Bore, liner and sleeve radii from the engine config.
238 * gas bore: ``chamber_geometry.chamber_diameter``;
239 * liner: ``ablative_cooling.initial_thickness`` when ablative cooling is on;
240 * sleeve: OD from ``frozen_parameters.D_chamber_outer_mm``, else
241 ``design_requirements.max_chamber_outer_diameter``; wall from
242 ``metal_wall_thickness_per_side_m`` -- the same numbers Layer 1 sizes the bore from.
244 The injector plug's radius is the sleeve's inner radius. When the sleeve is not declared it
245 is taken as bore + liner, and ``sleeve_declared`` says so.
247 r_bore = 0.5 *
_f(
_get(
_get(cfg,
"chamber_geometry"),
"chamber_diameter"))
248 abl =
_get(cfg,
"ablative_cooling")
249 liner =
_f(
_get(abl,
"initial_thickness"))
if (abl
is not None and _get(abl,
"enabled"))
else 0.0
251 frozen = req.get(
"frozen_parameters")
or {}
252 od =
_f(
_get(frozen,
"D_chamber_outer_mm")) / 1000.0
or _f(req.get(
"max_chamber_outer_diameter"))
253 wall =
_f(req.get(
"metal_wall_thickness_per_side_m"))
254 declared = od > 0
and wall > 0
255 r_sleeve_id = 0.5 * od - wall
if declared
else r_bore + liner
258 "liner_thickness": liner,
259 "r_sleeve_id": r_sleeve_id,
260 "r_sleeve_od": 0.5 * od
if declared
else r_sleeve_id,
261 "sleeve_wall": wall
if declared
else 0.0,
262 "sleeve_declared": declared,
264 "liner_gap": r_sleeve_id - (r_bore + liner),
272def exit_recess(*, d_in: float, th_in: float, d_out: float, th_out: float, exit_land: float) -> float:
273 """Depth below the face datum both exits sit at on a contoured face [m, positive].
275 Each exit is a round hole of diameter d in a flank inclined theta to the face; for the whole
276 hole plus ``exit_land`` to sit on the flank below its edge, the exit centre must be
277 ``(d/2 + land) sin(theta)`` below the datum. Both exits go to the deeper of the two, so they
278 share one plane: then the jets meet at exactly the flat-face radius and axial height above
279 the exits, and every ring formula Layer 1 uses stays exact.
281 return max((0.5 * d_in + exit_land) *
_sin(th_in), (0.5 * d_out + exit_land) *
_sin(th_out))
285 *, contoured: bool, r_in: float, d_in: float, th_in: float,
286 r_out: float, d_out: float, th_out: float, exit_land: float = EXIT_LAND_DEFAULT,
287) -> Tuple[float, float]:
288 """Innermost and outermost radius the ring pair occupies on the face datum.
290 Flat face: the elliptical exit traces, ``d / (2 cos theta)`` radially. Contoured face: the
291 groove's edges, where each flank reaches the datum. These are what an igniter keep-out and the
292 bore clearance are measured against.
296 dE =
exit_recess(d_in=d_in, th_in=th_in, d_out=d_out, th_out=th_out, exit_land=exit_land)
297 return r_in - dE /
max(1e-9,
_tan(th_in)), r_out + dE /
max(1e-9,
_tan(th_out))
301 *, contoured: bool, r_in: float, r_out: float, d_in: float, d_out: float,
302 th_in: float, th_out: float, exit_land: float = EXIT_LAND_DEFAULT, groove_bottom: str =
"flat",
304 """Exits, impingement point, free-jet lengths and the face profile for one ring pair.
306 The inner ring's jet leaves along (sin th_in, cos th_in) -- outward and downstream -- and the
307 outer ring's along (-sin th_out, cos th_out). With both exits at depth ``z_E`` they meet
308 ``L_ax = (r_out - r_in) / (tan th_in + tan th_out)`` downstream of the exits, at
309 ``r_imp = r_in + L_ax tan th_in``. Along each jet that is ``L_ax / cos th`` -- the
310 impingement distance as SP-8089 defines it.
312 Contoured groove: inner flank ``z = z_E - (r - r_in) tan th_in``, outer flank
313 ``z = z_E - (r_out - r) tan th_out``. The bottom is flat at the depth where each flank still
314 gives ``d/2 + land`` of slant below its exit, unless the flanks meet first (a sharp V), or
315 ``groove_bottom = "v"`` asks for the V.
319 L_ax = dr / ts
if ts > 1e-9
else 0.0
320 out: Dict[str, Any] = {
"contoured": bool(contoured)}
322 dE =
exit_recess(d_in=d_in, th_in=th_in, d_out=d_out, th_out=th_out, exit_land=exit_land)
324 edge_in = r_in - dE /
max(1e-9,
_tan(th_in))
325 edge_out = r_out + dE /
max(1e-9,
_tan(th_out))
327 x = dr *
_tan(th_out) / ts
if ts > 1e-9
else 0.0
328 r_v, d_v = r_in + x, dE + x *
_tan(th_in)
329 want = dE +
max((0.5 * d_in + exit_land) *
_sin(th_in), (0.5 * d_out + exit_land) *
_sin(th_out))
330 if groove_bottom ==
"v" or want >= d_v:
331 bottom = [(r_v, -d_v)]
335 bottom = [(r_in + (depth - dE) /
_tan(th_in), -depth),
336 (r_out - (depth - dE) /
_tan(th_out), -depth)]
337 profile = [(0.0, 0.0), (edge_in, 0.0), *bottom, (edge_out, 0.0)]
339 exit_depth=dE, z_exit=z_E, groove_edge_in=edge_in, groove_edge_out=edge_out,
340 groove_depth=depth, groove_v_depth=d_v, groove_v_r=r_v,
341 groove_is_v=len(bottom) == 1, groove_short_of_land=want > d_v + 1e-12,
342 groove_width=edge_out - edge_in,
343 flank_included=180.0 - th_in - th_out,
347 profile = [(0.0, 0.0)]
348 out.update(exit_depth=0.0, z_exit=0.0)
352 r_imp=r_in + L_ax *
_tan(th_in),
354 free_jet_in=L_ax /
max(1e-9,
_cos(th_in)),
355 free_jet_out=L_ax /
max(1e-9,
_cos(th_out)),
360def face_z_at(profile: List[Tuple[float, float]], r: float) -> float:
361 """Face surface height at radius r (0 outside the groove)."""
362 for (r0, z0), (r1, z1)
in zip(profile, profile[1:]):
363 if r0 <= r <= r1
and r1 > r0:
364 return z0 + (z1 - z0) * (r - r0) / (r1 - r0)
381 *, r_face: float, d: float, theta_deg: float, plate_thickness: float,
382 counterbore_dia: float, land_ld: float, is_inner: bool, n: int, exit_depth: float = 0.0,
384 """Plenum back: where one ring's passage opens on the flat back face.
386 From an exit at depth ``exit_depth`` the hole runs ``(t - exit_depth) / cos(theta)`` along its
387 axis and moves ``(t - exit_depth) tan(theta)`` radially -- inward for the inner ring, outward
388 for the outer, because the jets aim at each other. What opens there is the counterbore when
389 the land leaves room for one, the orifice otherwise.
391 t = float(plate_thickness) - float(exit_depth)
393 thru = t / c
if c > 1e-9
else float(
"inf")
394 land =
min(thru, land_ld * d)
395 entry_d = counterbore_dia
if (counterbore_dia > d
and thru - land > 0.0)
else d
396 shift = t *
_tan(theta_deg)
397 r_back = r_face - shift
if is_inner
else r_face + shift
399 web = 2.0 * r_back * math.sin(math.pi /
max(1, n)) - entry_d
if r_back > 0
else -entry_d
400 return BackEntry(r_back, entry_d, r_back - hm, r_back + hm, web)
404 *, r_exit: float, z_exit: float, d: float, theta_deg: float, is_inner: bool,
405 plate_thickness: float, passage_ld: float, width: Optional[float], floor: str =
"flat",
406 exit_land: float = EXIT_LAND_DEFAULT, spot_length: Optional[float] =
None,
408 """Channels back: the passage from its exit to the channel floor, and the channel around it.
410 The passage is a straight hole of diameter d along the jet axis, ``passage_ld * d`` long,
411 running from the exit into the plate: toward the axis for the inner ring, away for the outer.
412 It ends at ``Q``. The channel floor passes through ``Q`` -- flat (axis-normal), or ``coned``
413 (normal to the passage, so the hole breaks through square) -- and the channel is centred on
414 ``Q``'s radius. That centring is the alignment: the hole's whole footprint lands on the floor
415 with ``exit_land`` to each wall.
417 ``spot``: the floor stays flat, and where the passage enters a drill spot -- a facet square
418 to the passage, ``spot_length`` across (None => d + 2 x exit_land) -- is cut at the floor's
419 edge. The facet is centred on ``Q``; the flat floor runs ``width`` from the facet's end
420 nearer the face, away from the exit side, and the channel's walls go straight up to the back
421 face from the floor's far edge and the facet's other end. (The stand's plate: a 0.300 in
422 floor beside a 0.100 in spot.)
424 If the passage would come out through the back face before reaching that length, it is
425 clipped there (``pierces_back``): the channel depth is then zero and the realised L/d short.
427 t = float(plate_thickness)
428 s, c =
_sin(theta_deg),
_cos(theta_deg)
429 k = -1.0
if is_inner
else 1.0
430 lam_want = float(passage_ld) * d
432 return _spot_channel(r_exit=r_exit, z_exit=z_exit, d=d, theta_deg=theta_deg, k=k, t=t,
433 lam_want=lam_want, width=width, exit_land=exit_land,
434 spot=float(spot_length)
if spot_length
else d + 2.0 * exit_land)
439 slope = (1.0
if k > 0
else -1.0) *
_tan(theta_deg)
441 foot = d / c
if c > 1e-9
else float(
"inf")
443 w_min = foot + 2.0 * exit_land
444 w =
max(float(width), w_min)
if width
else w_min
447 lam_max = (t + z_exit - abs(slope) * 0.5 * w) / c
if c > 1e-9
else float(
"inf")
448 pierces = lam_want > lam_max
449 lam =
max(0.0,
min(lam_want, lam_max))
450 rQ, zQ = r_exit + k * lam * s, z_exit - lam * c
451 floor_z =
lambda r: zQ + slope * (r - rQ)
452 lo, hi = rQ - 0.5 * w, rQ + 0.5 * w
454 "exit": (r_exit, z_exit),
457 "length_wanted": lam_want,
458 "l_over_d": lam / d
if d > 0
else 0.0,
459 "pierces_back": pierces,
463 "width_widened": bool(width)
and float(width) < w_min,
467 "floor_slope": slope,
468 "floor_z_lo": floor_z(lo),
469 "floor_z_hi": floor_z(hi),
472 "depth_min": t +
min(floor_z(lo), floor_z(hi)),
474 "breakthrough":
"square" if floor ==
"coned" else f
"{theta_deg:.0f} deg off square",
477 "entry_lip_deg": 90.0
if floor ==
"coned" else 90.0 - float(theta_deg),
479 "flow_area": w * (t +
min(floor_z(lo), floor_z(hi)))
if w > 0
else 0.0,
481 "section": [(lo, -t), (lo,
max(-t, floor_z(lo))), (hi,
max(-t, floor_z(hi))), (hi, -t)],
487 """(area, centroid radius, perimeter) of a closed polygon in (r, z)."""
489 for i
in range(len(ps)):
490 (x0, y0), (x1, y1) = ps[i], ps[(i + 1) % len(ps)]
491 cr = x0 * y1 - x1 * y0
494 per += math.hypot(x1 - x0, y1 - y0)
495 return abs(0.5 * a), (cx / (3.0 * a)
if abs(a) > 1e-30
else 0.0), per
498def _spot_channel(*, r_exit: float, z_exit: float, d: float, theta_deg: float, k: float, t: float,
499 lam_want: float, width: Optional[float], exit_land: float, spot: float) -> Dict[str, Any]:
500 """``channel_for_ring`` with ``floor == "spot"``; see there."""
501 s, c =
_sin(theta_deg),
_cos(theta_deg)
504 lam_max = (t + z_exit - h * s) / c
if c > 1e-9
else float(
"inf")
505 pierces = lam_want > lam_max
506 lam =
max(0.0,
min(lam_want, lam_max))
507 rQ, zQ = r_exit + k * lam * s, z_exit - lam * c
510 e1 = (rQ + h * c, zQ + h * k * s)
511 e2 = (rQ - h * c, zQ - h * k * s)
512 near, far = (e1, e2)
if e1[1] >= e2[1]
else (e2, e1)
513 w = float(width)
if width
else spot
514 out_dir = 1.0
if near[0] > far[0]
else -1.0
515 edge = near[0] + out_dir * w
518 sec = [(far[0], -t), far, near, (edge, zf), (edge, -t)]
520 sec = [(edge, -t), (edge, zf), near, far, (far[0], -t)]
523 lo, hi = sec[0][0], sec[-1][0]
525 "exit": (r_exit, z_exit),
"end": (rQ, zQ),
"length": lam,
"length_wanted": lam_want,
526 "l_over_d": lam / d
if d > 0
else 0.0,
"pierces_back": pierces,
527 "r_center": r_cen,
"width": hi - lo,
"width_min": spot,
"width_widened":
False,
528 "r_lo": lo,
"r_hi": hi,
"floor":
"spot",
"floor_slope": 0.0,
529 "floor_z_lo": zf,
"floor_z_hi": zf,
530 "depth": t + zf,
"depth_min": t +
max(zf, far[1]),
531 "footprint": d,
"breakthrough":
"square (drill spot)",
"entry_lip_deg": 90.0,
532 "flow_area": area,
"hydraulic_diameter": 4.0 * area / wet
if wet > 0
else 0.0,
533 "section": sec,
"r_floor": near[0] + 0.5 * out_dir * w,
534 "spot_length": spot,
"floor_width": w,
"spot": (near, far),
539 *, flow_area: float, mdot: float, rho: float, dp: float, inlets: int = 1,
540 q_frac: float = MANIFOLD_Q_FRAC,
541) -> Dict[str, float]:
542 """A channel's velocity head at its feed port against the injector drop, and the area that
543 keeps it to ``q_frac`` of that drop.
545 Each of ``inlets`` ports splits two ways around the ring, so a branch leaves the port with
546 ``mdot / (2 inlets)``: ``v = mdot_branch / (rho A)``, ``q = rho v^2 / 2``. The holes beside the
547 port are driven by the channel's static pressure, ``dp - q`` (Rohde, Richards & Metger, NASA
548 TN D-5467: no velocity-head recovery with the approach flow normal to the hole), so they flow
549 ``sqrt(1 - q/dp)`` of design. ``A_needed = mdot_branch / sqrt(2 rho q_frac dp)``.
551 n =
max(1, int(inlets))
552 branch = float(mdot) / (2.0 * n)
553 if not (flow_area > 0
and rho > 0
and dp > 0):
554 return {
"branch_mdot": branch,
"v": float(
"nan"),
"q": float(
"nan"),
"q_over_dp": float(
"nan"),
555 "area_needed": float(
"nan"),
"fed_end_flow": float(
"nan"),
"inlets": n,
"q_frac": q_frac}
556 v = branch / (rho * flow_area)
557 q = 0.5 * rho * v * v
559 "branch_mdot": branch,
"v": v,
"q": q,
"q_over_dp": q / dp,
560 "area_needed": branch / math.sqrt(2.0 * rho * q_frac * dp),
564 "fed_end_flow": math.sqrt(1.0 - q / dp)
if q < dp
else None,
565 "inlets": n,
"q_frac": q_frac,
570 *, ch_in: Dict[str, Any], ch_out: Dict[str, Any], r_centre_hole: float, r_plate: float,
571) -> Dict[str, float]:
572 """Metal left on the back face: centre hole to inner channel, between channels, outer channel
573 to the plate edge. These are what any seal, and the wall between propellants, must fit in."""
575 "inner": ch_in[
"r_lo"] - r_centre_hole,
576 "between": ch_out[
"r_lo"] - ch_in[
"r_hi"],
577 "outer": r_plate - ch_out[
"r_hi"],
586 igniter: Optional[Mapping[str, Any]], *, plate_thickness: float, min_web: float,
587) -> Optional[Dict[str, Any]]:
588 """What a declared igniter port takes out of the plate, from its thread alone.
590 Front: the tapped hole at thread OD plus ``min_web`` of wall. Engagement: the plate (or a
591 thicker centre hub, as wide as that keep-out) must be at least the thread's effective length
592 L2. None when no igniter is declared.
594 if not igniter
or not igniter.get(
"thread"):
596 thr = NPT[str(igniter[
"thread"])]
597 wall = float(min_web
or 0.0)
598 hub_t = igniter.get(
"hub_thickness")
599 engaged = float(hub_t)
if hub_t
is not None else float(plate_thickness)
600 keepout = thr.od + 2.0 * wall
603 hub_d = keepout
if (hub_t
is not None and float(hub_t) > float(plate_thickness))
else 0.0
605 "thread": str(igniter[
"thread"]),
607 "l2": thr.l2_effective,
608 "tap_drill": thr.tap_drill,
610 "face_keepout_dia": keepout,
611 "engaged_thickness": engaged,
612 "hub_thickness": float(hub_t)
if hub_t
is not None else None,
613 "hub_diameter": hub_d
or None,
614 "back_keepout_dia": hub_d,
615 "source": thr.source,
623def _fillet(a: Tuple[float, float], P: Tuple[float, float], b: Tuple[float, float], rho: float,
624 n: int = 6) -> List[Tuple[float, float]]:
625 """Corner P (coming from a, going to b) rounded to radius rho: the arc's points, P if 0."""
628 ua = (a[0] - P[0], a[1] - P[1])
629 ub = (b[0] - P[0], b[1] - P[1])
630 la, lb = math.hypot(*ua), math.hypot(*ub)
631 if la <= 0
or lb <= 0:
633 ua, ub = (ua[0] / la, ua[1] / la), (ub[0] / lb, ub[1] / lb)
634 ang = math.acos(
max(-1.0,
min(1.0, ua[0] * ub[0] + ua[1] * ub[1])))
635 tl = rho / math.tan(0.5 * ang)
636 bis = (ua[0] + ub[0], ua[1] + ub[1])
637 bl = math.hypot(*bis)
638 C = (P[0] + bis[0] / bl * rho / math.sin(0.5 * ang), P[1] + bis[1] / bl * rho / math.sin(0.5 * ang))
639 T1, T2 = (P[0] + ua[0] * tl, P[1] + ua[1] * tl), (P[0] + ub[0] * tl, P[1] + ub[1] * tl)
640 a1, a2 = math.atan2(T1[1] - C[1], T1[0] - C[0]), math.atan2(T2[1] - C[1], T2[0] - C[0])
641 sw = (a2 - a1 + math.pi) % (2 * math.pi) - math.pi
642 return [(C[0] + rho * math.cos(a1 + sw * i / n), C[1] + rho * math.sin(a1 + sw * i / n))
for i
in range(n + 1)]
645def _rounded(ps: List[Tuple[float, float]], radii: List[float]) -> List[Tuple[float, float]]:
646 """An open polyline with its interior corners rounded (radii per interior vertex)."""
648 for i
in range(1, len(ps) - 1):
649 out +=
_fillet(ps[i - 1], ps[i], ps[i + 1], radii[i - 1])
654def plate_profile(out: Mapping[str, Any], plate: Mapping[str, Any], *, with_port: bool =
True
656 """The plug's half-section as one closed loop in (r, z) -- face datum z = 0, back face z = -t --
657 from the layout and the plate's declared features: the face (groove), the centre port's
658 drilled bore, the rim gland, and along the back each channel's section and each groove.
660 This is the revolve sketch: what CAD turns round the axis, what the bending check cuts, and
661 what a drawing (``profile_dxf``) is compared with. A groove that would cut into a channel, the
662 port or another groove is left out and reported, so the loop stays one simple outline."""
663 t = float(out[
"inputs"][
"plate_thickness"])
664 R = float(out[
"envelope"][
"r_sleeve_id"])
665 ign = out.get(
"igniter")
if with_port
else None
666 r_port = 0.5 * float(ign[
"tap_drill"])
if ign
else 0.0
667 hub_t = float(ign.get(
"hub_thickness")
or t)
if ign
else t
668 hub_r = 0.5 * float(ign.get(
"hub_diameter")
or 0.0)
if ign
else 0.0
669 face = [(r, z)
for r, z
in out[
"face"][
"profile"]
if r_port < r < R - 1e-12]
670 loop: List[Tuple[float, float]] = [(r_port, 0.0), *face, (R, 0.0)]
672 gl = plate.get(
"rim_gland")
674 z0, z1 = float(gl[
"z_start"]), float(gl[
"z_start"]) + float(gl[
"width"])
675 dep, rc = float(gl[
"depth"]), float(gl.get(
"corner_radius")
or 0.0)
676 if z1 < t
and dep < R - r_port:
677 loop +=
_rounded([(R, 0.0), (R, -z0), (R - dep, -z0), (R - dep, -z1), (R, -z1), (R, -t)],
678 [0.0, rc, rc, 0.0])[1:-1]
682 feats: List[Tuple[float, float, str, List[Tuple[float, float]]]] = []
684 ch = out[
"passages"][k].get(
"channel")
685 if ch
and ch.get(
"section"):
686 sec = [(r,
max(z, -t))
for r, z
in ch[
"section"]]
687 feats.append((sec[0][0], sec[-1][0], f
"{'LOX' if k == 'O' else 'fuel'} channel", sec[::-1]))
688 skipped: List[str] = []
690 for i, g
in enumerate(plate.get(
"back_grooves")
or []):
691 ri, ro, dep = float(g[
"r_inner"]), float(g[
"r_outer"]), float(g[
"depth"])
692 rc = float(g.get(
"corner_radius")
or 0.0)
693 name = f
"back groove {i + 1}"
694 grooves.append({
"name": name,
"r_inner": ri,
"r_outer": ro,
"depth": dep})
695 if not (ro > ri
and dep < t):
696 skipped.append(f
"{name}: not a groove inside the plate")
698 pts =
_rounded([(ro, -t), (ro, -t + dep), (ri, -t + dep), (ri, -t)], [rc, rc])
699 feats.append((ri, ro, name, pts))
701 feats.sort(key=
lambda f: (f[2].startswith(
"back groove"), -f[1]))
702 kept: List[Tuple[float, float, str, List[Tuple[float, float]]]] = []
703 lo_edge = hub_r
if hub_t > t
else r_port
705 clash = [q[2]
for q
in kept
if f[0] < q[1]
and q[0] < f[1]]
706 if f[1] > R
or f[0] < lo_edge
or clash:
707 why =
", ".join(clash)
or (
"the rim" if f[1] > R
else "the centre port")
708 if f[2].startswith(
"back groove"):
709 skipped.append(f
"{f[2]} (r {f[0] * 1000:.2f}–{f[1] * 1000:.2f}) runs into {why}")
712 kept.sort(key=
lambda f: -f[1])
715 if hub_t > t
and hub_r > r_port:
716 loop += [(hub_r, -t), (hub_r, -hub_t), (r_port, -hub_t)]
718 loop.append((r_port, -t))
720 edges = sorted([(f[0], f[1], f[2])
for f
in kept], key=
lambda e: e[0])
722 prev_hi, prev_name = lo_edge, (
"the centre port" if r_port > 0
else "the axis")
723 for lo, hi, name
in edges:
724 lands.append({
"between": f
"{prev_name} and {name}",
"land": lo - prev_hi})
725 prev_hi, prev_name = hi, name
726 lands.append({
"between": f
"{prev_name} and the rim",
"land": R - prev_hi})
730 r_thr = 0.5 * float(ign[
"thread_od"])
733 clash.append(f
"the {ign['thread']} igniter thread (⌀{2 * r_thr * 1000:.2f} at the back face) "
734 f
"cuts {f[2]} (r {f[0] * 1000:.2f}–{f[1] * 1000:.2f})")
735 return {
"loop": loop,
"grooves": grooves,
"skipped": skipped,
"back_lands": lands,
736 "thread_clash": clash,
"thickness": t,
"with_port": bool(ign)}
739def feed_ports(out: Mapping[str, Any], plate: Mapping[str, Any]) -> Optional[Dict[str, Any]]:
740 """The cover plate's feed ports, ``channel_inlets`` per ring on its channel: where they sit,
741 and the back-face grooves each port's bore lands across (a face seal with its sealing land
742 opened by a port does not seal). None without ``port_thread`` or channels."""
743 thread = plate.get(
"port_thread")
744 if not thread
or out[
"back"][
"mode"] !=
"channels":
746 thr = NPT[str(thread)]
747 n = int(
_f(plate.get(
"channel_inlets"), 1.0)
or 1)
748 bore =
_f(plate.get(
"port_bore"), 0.0)
or thr.tap_drill
750 clock_F = plate.get(
"port_clock_F_deg")
751 clock_F = 0.5 * pitch
if clock_F
is None else float(clock_F)
754 ch = out[
"passages"][k][
"channel"]
755 r =
_f(plate.get(f
"port_radius_{k}"), 0.0)
or 0.5 * (ch[
"r_lo"] + ch[
"r_hi"])
756 a0 = 0.0
if k ==
"O" else clock_F
757 rings[k] = {
"r": r,
"angles_deg": [a0 + i * pitch
for i
in range(n)],
758 "bore_r_lo": r - 0.5 * bore,
"bore_r_hi": r + 0.5 * bore,
759 "channel_r_lo": ch[
"r_lo"],
"channel_r_hi": ch[
"r_hi"]}
760 return {
"thread": str(thread),
"thread_od": thr.od,
"tap_drill": thr.tap_drill,
"bore": bore,
761 "bore_declared": plate.get(
"port_bore")
is not None,
"per_ring": n,
762 "clock_F_deg": clock_F,
"rings": rings,
"source": thr.source}
766 prof, ports = out.get(
"profile"), out.get(
"ports")
769 for msg
in prof[
"skipped"]:
770 out[
"warnings"].append({
"level":
"bad",
"code":
"back_groove",
"text": msg +
" — not drawn"})
771 for msg
in prof.get(
"thread_clash")
or []:
772 out[
"warnings"].append({
"level":
"bad",
"code":
"igniter_groove",
"text": msg})
774 worst =
min(prof[
"back_lands"], key=
lambda l: l[
"land"])
775 out[
"warnings"].append({
"level":
"info",
"code":
"back_lands",
776 "text":
"back-face lands: " +
"; ".join(
777 f
"{l['between']} {l['land'] * 1000:.2f} mm" for l
in prof[
"back_lands"])
778 + f
" (least {worst['land'] * 1000:.2f} mm)"})
780 for k, ring
in ports[
"rings"].items():
781 tag =
"LOX" if k ==
"O" else "fuel"
782 for g
in prof[
"grooves"]:
783 ov =
min(ring[
"bore_r_hi"], g[
"r_outer"]) -
max(ring[
"bore_r_lo"], g[
"r_inner"])
785 out[
"warnings"].append({
786 "level":
"warn",
"code": f
"port_over_groove_{k}",
787 "text": (f
"{tag} port bore ⌀{ports['bore'] * 1000:.2f} on r {ring['r'] * 1000:.2f} spans r "
788 f
"{ring['bore_r_lo'] * 1000:.2f}–{ring['bore_r_hi'] * 1000:.2f}, {ov * 1000:.2f} mm over "
789 f
"{g['name']} (r {g['r_inner'] * 1000:.2f}–{g['r_outer'] * 1000:.2f}): the seal there has no "
790 f
"land under the port — move the port, or open it with a smaller bore (injector.plate.port_bore)")})
798 *, d: float, theta_deg: float, plate_thickness: float, counterbore_dia: float,
799 declared_land_ld: Optional[float],
801 """Plenum back: the orifice land the plate actually gives, and the counterbore feeding it.
803 With a counterbore wider than the orifice, the land is what the counterbore leaves:
804 ``declared_land_ld * d``, capped at the passage length. Without one, the small drill runs the
805 whole passage, ``t / cos(theta)``. Measured on the hole axis.
808 thru = plate_thickness / c
if c > 1e-9
else float(
"inf")
809 if counterbore_dia > d:
810 want = (declared_land_ld * d)
if declared_land_ld
else thru
811 land =
min(thru, want)
812 beta = d / counterbore_dia
if thru - land > 1e-12
else None
814 land, beta = thru,
None
815 return {
"land": land,
"land_ld": land / d
if d > 0
else 0.0,
"thru": thru,
"beta": beta,
816 "wall_skew": d *
_tan(theta_deg)}
824 """The discharge block the Cd model should use for ``side``, given the plate as built.
826 Unchanged unless the block says ``l_over_d_source: plate``. Then the orifice L/d is the one the
827 geometry gives: with channels, the passage length to the channel floor; with a plenum, the
828 land (or the whole passage when there is no counterbore). Both solver paths call this.
830 dc = engine_config.discharge[side]
831 if getattr(dc,
"l_over_d_source",
"declared") !=
"plate":
834 key =
"O" if side ==
"oxidizer" else "F"
837 assume(f
"discharge.{side}.l_over_d_source",
"declared",
838 reason=
"l_over_d_source is 'plate' but there is no impinging injector layout")
844 """``dc`` with the L/d (and counterbore approach) the plate gives, when it asks for that."""
845 if getattr(dc,
"l_over_d_source",
"declared") !=
"plate":
847 return dc.model_copy(update={
"orifice_l_over_d": passage[
"plate_l_over_d"],
848 "approach_beta": passage.get(
"beta")})
852 """A radiused or chamfered inlet takes floor beside the hole: on a flat floor the hole meets
853 it (90 - theta) on its acute side, where a fillet of radius r runs r / tan((90 - theta)/2)
854 onto the floor. The channel is sized from the bare hole, so say when the inlet does not fit
855 and what width would take it."""
858 p = out[
"passages"][k]
859 ch = p.get(
"channel")
860 shape = _inlet_shape(p)
861 if ch
is None or shape
is None or ch[
"pierces_back"]:
863 st = out[
"inputs"][
"oxidizer" if k ==
"O" else "fuel"]
864 d, th = float(st[
"d_jet"]), float(st[
"impingement_angle"])
866 alpha = math.radians(90.0
if ch[
"floor"] ==
"coned" else 90.0 - th)
867 reach = size * d
if kind ==
"chamfer" else size * d / math.tan(0.5 * alpha)
868 room = 0.5 * (ch[
"width"] - ch[
"footprint"])
869 p[
"inlet_reach"] = reach
870 if reach > room + 1e-9:
871 tag =
"LOX" if k ==
"O" else "fuel"
872 out[
"warnings"].append({
873 "level":
"warn",
"code": f
"inlet_fit_{k}",
874 "text": (f
"{tag} inlet runs {reach * 1000:.2f} mm onto the channel floor beside the hole; the "
875 f
"channel leaves {room * 1000:.2f} mm — make the channel ≥ "
876 f
"{(ch['footprint'] + 2 * reach) * 1000:.2f} mm wide, or use a smaller inlet")})
880 """Each hole's high-Re Cd from the discharge model the solver uses, at the L/d it uses, and
881 what that number is made of -- so a change of hole L/d shows its effect on Cd (and Δp)
882 where it is made. ``passages[k]["cd"]`` is None without a discharge block."""
884 INLET_GEOMETRY_CD, cd_from_inlet_radius_ratio, cd_inf_from_orifice_diameter,
885 cd_length_factor, cd_with_approach,
889 dis =
_get(cfg,
"discharge")
890 for k, side
in ((
"O",
"oxidizer"), (
"F",
"fuel")):
891 p = out[
"passages"][k]
892 raw =
_get(dis, side)
if dis
is not None else None
896 dc = raw
if isinstance(raw, DischargeConfig)
else DischargeConfig.model_validate(
_plain(raw))
898 d = float(out[
"inputs"][
"oxidizer" if k ==
"O" else "fuel"][
"d_jet"])
899 cd = cd_inf_from_orifice_diameter(d, dc)
900 rd, name = getattr(dc,
"inlet_radius_ratio",
None), getattr(dc,
"inlet_geometry",
None)
901 info: Dict[str, Any] = {
"value": cd,
"l_over_d": getattr(dc,
"orifice_l_over_d",
None),
902 "model": getattr(dc,
"length_model",
"lichtarowicz"),
904 "inlet_name": str(name).lower()
if name
is not None else None,
905 "inlet_r_over_d": float(rd)
if rd
is not None else None}
906 if rd
is None and name
is None:
908 info.update(inlet=
None, inlet_cd=
None, length_factor=
None, approach=
None, uses_ld=
False)
910 base = cd_from_inlet_radius_ratio(float(rd))
if rd
is not None else INLET_GEOMETRY_CD[str(name).lower()]
911 lod = info[
"l_over_d"]
912 lf = cd_length_factor(float(lod), info[
"model"])
if lod
is not None else 1.0
913 beta = getattr(dc,
"approach_beta",
None)
914 info.update(inlet=(f
"r/d {float(rd):g}" if rd
is not None else str(name)), inlet_cd=base,
916 approach=(cd_with_approach(base * lf, beta) / (base * lf))
if beta
else None,
917 uses_ld=lod
is not None)
926 """Everything the layout needs, from an engine config (dict or pydantic model). None when the
927 injector is not an impinging doublet or the geometry is incomplete."""
928 inj =
_get(cfg,
"injector")
929 if inj
is None or str(
_get(inj,
"type")
or "").lower() !=
"impinging":
931 geom =
_get(inj,
"geometry")
932 ox, fu =
_get(geom,
"oxidizer"),
_get(geom,
"fuel")
933 bore =
_f(
_get(
_get(cfg,
"chamber_geometry"),
"chamber_diameter"))
934 if ox
is None or fu
is None or not bore > 0:
937 dis =
_get(cfg,
"discharge")
or {}
939 land_O =
_f(
_get(
_get(dis,
"oxidizer"),
"orifice_l_over_d"), 4.0)
or 4.0
940 land_F =
_f(
_get(
_get(dis,
"fuel"),
"orifice_l_over_d"), land_O)
or land_O
942 t_drawn =
_drawing(plate)[
"thickness"]
if plate.get(
"profile_dxf")
else None
946 "bore_diameter": bore,
948 "fuel_outboard":
_opt_bool(req,
"layer1_ring_order_fuel_outboard",
True),
949 "center_clear_dia":
_opt_float(req,
"layer1_injector_center_clear_dia_m")
or 0.0,
950 "min_web":
_opt_float(req,
"layer1_injector_min_web_m")
or 0.0,
951 "wall_clearance":
_opt_float(req,
"layer1_injector_wall_clearance_m")
or 0.0,
954 "plate_thickness": t_drawn
or _opt_float(req,
"layer1_injector_plate_thickness_m")
or PLATE_THICKNESS_DEFAULT,
955 "plate_thickness_declared": bool(t_drawn
or _opt_float(req,
"layer1_injector_plate_thickness_m")),
956 "counterbore_dia":
_opt_float(req,
"layer1_injector_counterbore_dia_m")
or 0.0,
959 "min_back_web":
_opt_float(req,
"layer1_injector_min_back_web_m")
or 0.0,
962 "ld_source_O": str(
_get(
_get(dis,
"oxidizer"),
"l_over_d_source")
or "declared"),
963 "ld_source_F": str(
_get(
_get(dis,
"fuel"),
"l_over_d_source")
or "declared"),
973 oxidizer: Mapping[str, float],
974 fuel: Mapping[str, float],
975 bore_diameter: float,
976 envelope: Optional[Mapping[str, Any]] =
None,
977 fuel_outboard: bool =
True,
978 center_clear_dia: float = 0.0,
979 min_web: float = 0.0,
980 wall_clearance: float = 0.0,
981 ld_min: float = IMPINGEMENT_LD_TARGET_DEFAULT,
982 ld_max: float = IMPINGEMENT_LD_TARGET_DEFAULT,
983 plate_thickness: float = 0.0127,
984 counterbore_dia: float = 0.0,
985 land_ld_O: float = 4.0,
986 land_ld_F: Optional[float] =
None,
987 min_back_web: float = 0.0,
988 igniter: Optional[Mapping[str, Any]] =
None,
989 plate: Optional[Mapping[str, Any]] =
None,
990 ld_source_O: str =
"declared",
991 ld_source_F: str =
"declared",
992 plate_thickness_declared: bool =
True,
994 """Every derived dimension of an unlike-doublet ring pair in its plug, and what is wrong with
995 it. Lengths in metres, angles in degrees from the chamber axis."""
996 plate = dict(plate
or {})
997 plate_declared = bool(plate)
999 contoured = str(plate.get(
"face")
or "contoured") ==
"contoured"
1000 channels = str(plate.get(
"back")
or "channels") ==
"channels"
1001 exit_land = float(plate[
"exit_land"])
if plate.get(
"exit_land")
is not None else EXIT_LAND_DEFAULT
1002 env = dict(envelope
or {})
1003 r_bore = bore_diameter / 2.0
1004 env.setdefault(
"r_bore", r_bore)
1005 env.setdefault(
"liner_thickness", 0.0)
1006 env.setdefault(
"r_sleeve_id", r_bore + env[
"liner_thickness"])
1007 env.setdefault(
"r_sleeve_od", env[
"r_sleeve_id"])
1008 env.setdefault(
"sleeve_wall", 0.0)
1009 env.setdefault(
"sleeve_declared",
False)
1010 env.setdefault(
"liner_gap", env[
"r_sleeve_id"] - r_bore - env[
"liner_thickness"])
1011 r_plate = env[
"r_sleeve_id"]
1013 n =
max(1, int(round(oxidizer[
"n_elements"])))
1014 n_F =
max(1, int(round(fuel[
"n_elements"])))
1015 th = {
"O": float(oxidizer[
"impingement_angle"]),
"F": float(fuel[
"impingement_angle"])}
1016 d = {
"O": float(oxidizer[
"d_jet"]),
"F": float(fuel[
"d_jet"])}
1017 s = {
"O": float(oxidizer[
"spacing"]),
"F": float(fuel[
"spacing"])}
1018 d_pitch = {
"O": n * s[
"O"] / math.pi,
"F": n_F * s[
"F"] / math.pi}
1019 r = {k: d_pitch[k] / 2.0
for k
in (
"O",
"F")}
1020 dr = abs(d_pitch[
"O"] - d_pitch[
"F"]) / 2.0
1021 ox_inner = fuel_outboard
if d_pitch[
"O"] == d_pitch[
"F"]
else d_pitch[
"O"] < d_pitch[
"F"]
1022 inner, outer = (
"O",
"F")
if ox_inner
else (
"F",
"O")
1023 tag = {
"O":
"LOX",
"F":
"fuel"}
1025 fg =
face_geometry(contoured=contoured, r_in=r[inner], r_out=r[outer], d_in=d[inner],
1026 d_out=d[outer], th_in=th[inner], th_out=th[outer], exit_land=exit_land,
1027 groove_bottom=str(plate.get(
"groove_bottom")
or "flat"))
1028 fg[
"profile"] = [*fg[
"profile"], (r_plate, 0.0)]
1030 contoured=contoured, r_in=r[inner], d_in=d[inner], th_in=th[inner],
1031 r_out=r[outer], d_out=d[outer], th_out=th[outer], exit_land=exit_land)
1032 d_avg = 0.5 * (d[
"O"] + d[
"F"])
1035 free = {inner: fg[
"free_jet_in"], outer: fg[
"free_jet_out"]}
1040 "r_O": r[
"O"],
"r_F": r[
"F"],
1041 "d_pitch_O": d_pitch[
"O"],
"d_pitch_F": d_pitch[
"F"],
1044 "l_over_d": l_imp / d_avg
if d_avg > 0
else 0.0,
1045 "free_jet_O": free[
"O"],
"free_jet_F": free[
"F"],
1046 "free_jet_ld_O": free[
"O"] / d[
"O"]
if d[
"O"] > 0
else 0.0,
1047 "free_jet_ld_F": free[
"F"] / d[
"F"]
if d[
"F"] > 0
else 0.0,
1048 "free_jet_ld_avg": 0.5 * (free[
"O"] + free[
"F"]) / d_avg
if d_avg > 0
else 0.0,
1049 "z_exit": fg[
"z_exit"],
1050 "z_imp": fg[
"z_imp"],
1051 "included": th[
"O"] + th[
"F"],
1052 "web_O": s[
"O"] - d[
"O"],
1053 "web_F": s[
"F"] - d[
"F"],
1054 "ox_is_inner": ox_inner,
1055 "r_inner": r[inner],
"r_outer": r[outer],
1057 "centre_clear": 2.0 * reach_in,
1058 "wall_land": r_bore - reach_out,
1059 "core_frac": (r_imp / r_bore) ** 2
if r_bore > 0
else 0.0,
1060 "overflow": reach_out > r_bore,
1061 "degenerate":
not (l_imp > 1e-6),
1062 "contoured": contoured,
1063 "plate_declared": plate_declared,
1064 "profile": fg[
"profile"],
1065 "groove": ({k: fg[k]
for k
in (
"groove_edge_in",
"groove_edge_out",
"groove_depth",
1066 "groove_v_depth",
"groove_v_r",
"groove_is_v",
1067 "groove_short_of_land",
"groove_width",
"flank_included",
1068 "exit_depth")}
if contoured
else None),
1069 "exit_land": exit_land,
1072 t = float(plate_thickness)
1073 land_ld = {
"O": float(land_ld_O),
"F": float(land_ld_O
if land_ld_F
is None else land_ld_F)}
1076 passage_ld = dict(land_ld)
1077 ign =
igniter_keepouts(igniter, plate_thickness=plate_thickness, min_web=min_web)
1078 r_hole = 0.5 * ign[
"thread_od"]
if ign
else 0.0
1080 def _channel(k: str, tt: float) -> Dict[str, Any]:
1082 r_exit=r[k], z_exit=fg[
"z_exit"], d=d[k], theta_deg=th[k], is_inner=(k == inner),
1083 plate_thickness=tt, passage_ld=passage_ld[k],
1084 width=
_f(plate.get(f
"channel_width_{k}")
or plate.get(
"channel_width"), 0.0)
or None,
1085 floor=str(plate.get(
"channel_floor")
or "flat"), exit_land=exit_land,
1086 spot_length=
_f(plate.get(
"channel_spot_length"), 0.0)
or None)
1088 passages: Dict[str, Dict[str, Any]] = {}
1089 chans: Dict[str, Dict[str, Any]] = {}
1090 for k
in (
"O",
"F"):
1093 src = ld_source_O
if k ==
"O" else ld_source_F
1096 h_flow =
_f(plate.get(f
"channel_flow_height_{k}"), 0.0)
1097 if h_flow
and h_flow > 0:
1099 ch[
"flow_height"] = h_flow
1100 ch[
"flow_area"] = ch[
"width"] * h_flow
1102 web_end = 2.0 * ch[
"r_center"] * math.sin(math.pi / (n
if k ==
"O" else n_F)) - d[k]
1104 "exit": ch[
"exit"],
"end": ch[
"end"],
1105 "thru": ch[
"length"],
"land": ch[
"length"],
"land_ld": ch[
"l_over_d"],
1106 "bore": d[k],
"bore_len": 0.0,
"bore_ld": 0.0,
1107 "entry_off_square": 0.0
if contoured
else th[k],
1108 "r_back": ch[
"r_center"],
"entry_d": d[k],
1109 "back_inner_edge": ch[
"r_lo"],
"back_outer_edge": ch[
"r_hi"],
1110 "back_web": web_end,
1111 "cd_l_over_d": ch[
"l_over_d"]
if src ==
"plate" else land_ld[k],
1112 "cd_ld_source": src,
1113 "plate_l_over_d": ch[
"l_over_d"],
1119 thru = (t + z_E) / c
if c > 1e-9
else float(
"inf")
1120 land =
min(thru, land_ld[k] * d[k])
1121 bore = counterbore_dia
if counterbore_dia > d[k]
else d[k]
1122 bore_len =
max(0.0, thru - land)
1124 r_face=r[k], d=d[k], theta_deg=th[k], plate_thickness=t,
1125 counterbore_dia=counterbore_dia, land_ld=land_ld[k], is_inner=is_in,
1126 n=n
if k ==
"O" else n_F, exit_depth=-z_E)
1127 plate_ld = land / d[k]
if bore > d[k]
else thru / d[k]
1129 "exit": (r[k], z_E),
"end": (be.r_back, -t),
1130 "thru": thru,
"land": land,
"land_ld": land / d[k]
if d[k] > 0
else 0.0,
1131 "bore": bore,
"bore_len": bore_len,
"bore_ld": bore_len / bore
if bore > 0
else 0.0,
1132 "entry_off_square": 0.0
if contoured
else th[k],
1133 "r_back": be.r_back,
"entry_d": be.entry_d,
1134 "back_inner_edge": be.inner_edge,
"back_outer_edge": be.outer_edge,
1136 "cd_l_over_d": plate_ld
if src ==
"plate" else land_ld[k],
1137 "cd_ld_source": src,
1138 "plate_l_over_d": plate_ld,
1139 "beta": (d[k] / bore)
if (bore > d[k]
and bore_len > 0)
else None,
1142 back: Dict[str, Any] = {
1143 "mode":
"channels" if channels
else "plenum",
1144 "o_f_land": passages[outer][
"back_inner_edge"] - passages[inner][
"back_outer_edge"],
1145 "inner_edge": passages[inner][
"back_inner_edge"],
1146 "outer_edge": passages[outer][
"back_outer_edge"],
1149 back[
"lands"] =
channel_lands(ch_in=chans[inner], ch_out=chans[outer],
1150 r_centre_hole=r_hole, r_plate=r_plate)
1152 warnings: List[Dict[str, str]] = []
1154 def warn(level: str, code: str, text: str) ->
None:
1155 warnings.append({
"level": level,
"code": code,
"text": text})
1157 mm =
lambda v: f
"{v * 1000:.2f}"
1160 if env[
"sleeve_declared"]
and abs(env[
"liner_gap"]) > 1e-4:
1161 warn(
"warn",
"liner_gap",
1162 f
"bore ⌀{mm(2 * r_bore)} + 2 × {mm(env['liner_thickness'])} liner = "
1163 f
"⌀{mm(2 * (r_bore + env['liner_thickness']))}, but the sleeve bore is ⌀{mm(2 * r_plate)}")
1166 if center_clear_dia > 0:
1167 cc_req, cc_src = float(center_clear_dia),
"layer1_injector_center_clear_dia_m"
1168 elif ign
is not None:
1169 cc_req, cc_src = ign[
"face_keepout_dia"], f
"igniter {ign['thread']}"
1171 cc_req, cc_src = 0.0,
None
1174 if face[
"overflow"]:
1175 warn(
"bad",
"overflow", f
"the {tag[outer]} ring reaches r {mm(reach_out)} mm on the face, past the "
1176 f
"{mm(r_bore)} mm bore — it would sit under the liner")
1177 if face[
"degenerate"]:
1178 warn(
"bad",
"degenerate",
1179 "the two rings are on the same pitch circle — the jets meet at the face, not in front of it")
1180 if cc_req > 0
and face[
"centre_clear"] < cc_req:
1181 warn(
"bad",
"centre_clear",
1182 f
"centre clear ⌀{mm(face['centre_clear'])} < ⌀{mm(cc_req)} reserved ({cc_src})")
1183 if wall_clearance > 0
and face[
"wall_land"] < wall_clearance:
1184 warn(
"bad",
"wall_land", f
"land to the bore {mm(face['wall_land'])} mm < {mm(wall_clearance)} mm required")
1185 if min_web > 0
and min(face[
"web_O"], face[
"web_F"]) < min_web:
1186 warn(
"bad",
"web", f
"web between holes on a ring {mm(min(face['web_O'], face['web_F']))} mm "
1187 f
"< {mm(min_web)} mm required")
1188 if face[
"included"] > FACE_HEATING_INCLUDED_DEG:
1189 warn(
"warn",
"included", f
"included {face['included']:.0f}° > 90° — SP-8089's face-heating threshold")
1190 ld_tol = 1e-3 *
max(1.0, ld_max)
1191 if not face[
"degenerate"]
and (face[
"l_over_d"] < ld_min - ld_tol
or face[
"l_over_d"] > ld_max + ld_tol):
1192 band = f
"target {ld_min:g}" if ld_min == ld_max
else f
"{ld_min:g}–{ld_max:g} band"
1193 warn(
"warn",
"standoff", f
"axial standoff {face['l_over_d']:.2f} d̄ outside the {band}")
1194 if not face[
"degenerate"]
and max(face[
"free_jet_ld_O"], face[
"free_jet_ld_F"]) > FREE_JET_MAX_D:
1195 warn(
"warn",
"free_jet",
1196 f
"free jet {face['free_jet_ld_O']:.1f} d (LOX) / {face['free_jet_ld_F']:.1f} d (fuel) along the "
1197 f
"jet — SP-8089 keeps it under 5–7 d")
1198 if not face[
"degenerate"]
and face[
"core_frac"] < CORE_FRAC_WARN:
1199 warn(
"warn",
"core_frac",
1200 f
"all {n} elements collide on a ⌀{mm(2 * r_imp)} circle — only {face['core_frac'] * 100:.0f}% "
1201 f
"of the chamber area is fed directly")
1202 if contoured
and not face[
"degenerate"]:
1203 if face[
"z_imp"] <= 0:
1204 warn(
"warn",
"imp_in_groove",
1205 f
"the jets meet {mm(-face['z_imp'])} mm inside the groove — the reaction sits in the face")
1206 if fg[
"groove_short_of_land"]:
1207 warn(
"warn",
"groove_land",
1208 "the flanks meet before each exit has its land below it — the V is shallower than the holes need")
1209 elif not face[
"degenerate"]:
1210 worst =
max((
"O",
"F"), key=
lambda k: th[k])
1211 if th[worst] > DRILL_ENTRY_MAX_OFF_SQUARE_DEG:
1212 warn(
"info",
"flat_entry",
1213 f
"flat face: the {tag[worst]} drill enters {th[worst]:.0f}° off square "
1214 f
"(drill makers allow {DRILL_ENTRY_MAX_OFF_SQUARE_DEG:.0f}°) — spot-face each hole, or contour the face")
1217 for k
in (
"O",
"F"):
1219 if p[
"land_ld"] > DRILL_LD_PRACTICE_MAX:
1220 warn(
"warn", f
"drill_ld_{k}",
1221 f
"{tag[k]} orifice drilled {p['land'] * 1000:.1f} mm at ⌀{d[k] * 1000:.3f} — L/d {p['land_ld']:.1f}, "
1222 f
"past twist-drill practice")
1223 if p[
"land_ld"] < ORIFICE_LD_MIN - 1e-9:
1224 warn(
"warn", f
"short_ld_{k}",
1225 f
"{tag[k]} orifice L/d {p['land_ld']:.1f} < 4 — SP-8089's minimum for a jet that leaves on axis")
1226 if p[
"bore_ld"] > DRILL_LD_PRACTICE_MAX:
1227 warn(
"warn", f
"bore_ld_{k}",
1228 f
"{tag[k]} counterbore {p['bore_len'] * 1000:.1f} mm at ⌀{mm(p['bore'])} — L/d {p['bore_ld']:.1f}")
1229 if not channels
and p[
"cd_ld_source"] !=
"plate" and abs(p[
"plate_l_over_d"] - p[
"cd_l_over_d"]) > 0.25:
1231 warn(
"warn", f
"cd_ld_{k}",
1232 f
"{tag[k]} Cd uses the typed L/d {p['cd_l_over_d']:.1f}; this plate drills L/d "
1233 f
"{p['plate_l_over_d']:.1f} — set 'Cd L/d from' to the plug")
1235 if not plate_declared:
1236 warn(
"info",
"plate_default",
1237 "injector.plate not declared: drawn as a contoured face with channels on the back. "
1238 "Layer 1 checks the groove and channels only once it is declared (Injector hardware, Apply)")
1239 if not plate_thickness_declared:
1240 warn(
"info",
"plate_assumed",
1241 f
"plate thickness not declared (layer1_injector_plate_thickness_m): {mm(t)} mm assumed")
1243 for k
in (
"O",
"F"):
1245 n_k = n
if k ==
"O" else n_F
1246 ch[
"orifice_area"] = n_k * math.pi * d[k] ** 2 / 4.0
1247 ch[
"area_ratio"] = ch[
"flow_area"] / ch[
"orifice_area"]
if ch[
"orifice_area"] > 0
else 0.0
1248 if ch[
"area_ratio"] < 1.0
and not ch[
"pierces_back"]:
1249 warn(
"info", f
"channel_area_{k}",
1250 f
"{tag[k]} channel cross-section {ch['flow_area'] * 1e6:.1f} mm² is {ch['area_ratio']:.2f}× the "
1251 f
"{ch['orifice_area'] * 1e6:.1f} mm² of orifices it feeds — the channel (and any groove in the "
1252 f
"cover) is the manifold; SP-8089: as large a cross-section as possible, confirm by cold flow")
1253 if ch[
"pierces_back"]:
1254 warn(
"bad", f
"pierce_{k}",
1255 f
"{tag[k]} passage of L/d {passage_ld[k]:.1f} runs out through the back face of a "
1256 f
"{mm(t)} mm plate — it gives only L/d {ch['l_over_d']:.1f}")
1257 elif ch[
"floor"] ==
"flat":
1261 warn(
"info", f
"oblique_inlet_{k}",
1262 f
"{tag[k]} holes break into the flat channel floor {th[k]:.0f}° off square: the inlet lip "
1263 f
"on the acute side is {ch['entry_lip_deg']:.0f}°, not the 90° sharp edge the Cd (and "
1264 f
"the cavitation Cc) are for; its effect on Cd and on the jet direction is not modelled "
1265 f
"— cone the floor (channel_floor: coned) or cold-flow one element for its Cd")
1267 if L[
"between"] < 0:
1268 warn(
"bad",
"channels_cross",
"the LOX and fuel channels overlap on the back face")
1270 warn(
"bad",
"channel_port",
1271 f
"the {tag[inner]} channel reaches the centre port (r {mm(chans[inner]['r_lo'])} mm)")
1273 warn(
"bad",
"channel_edge",
1274 f
"the {tag[outer]} channel runs off the plate (r {mm(chans[outer]['r_hi'])} mm > {mm(r_plate)})")
1276 for k
in (
"O",
"F")
if not face[
"degenerate"]
else ():
1278 if p[
"back_inner_edge"] < 0.0:
1279 warn(
"bad", f
"back_axis_{k}",
1280 f
"{tag[k]} passages cross the axis before reaching the back face — the plate is too "
1281 f
"thick for a {th[k]:.0f}° hole starting at r {mm(r[k])} mm")
1282 elif p[
"back_web"] < 0.0:
1283 warn(
"bad", f
"back_overlap_{k}",
1284 f
"{tag[k]} passages run into each other at the back face — web {mm(p['back_web'])} mm")
1285 elif min_back_web > 0
and p[
"back_web"] < min_back_web:
1286 warn(
"bad", f
"back_web_{k}",
1287 f
"{tag[k]} back-face web {mm(p['back_web'])} mm < {mm(min_back_web)} mm required")
1288 if not face[
"degenerate"]
and back[
"o_f_land"] < 0.0:
1289 warn(
"bad",
"back_cross",
"LOX and fuel passages overlap at the back face")
1292 if not face[
"degenerate"]:
1293 inner_back = passages[inner][
"back_inner_edge"]
1294 if r_hole > 0
and not channels
and inner_back < r_hole:
1295 warn(
"bad",
"back_port",
1296 f
"{tag[inner]} passages reach r {mm(inner_back)} mm on the back, inside the ⌀{mm(2 * r_hole)} "
1298 if ign
is not None and ign[
"back_keepout_dia"] > 0
and inner_back < 0.5 * ign[
"back_keepout_dia"] + ign[
"face_wall"]:
1299 warn(
"bad",
"igniter_back",
1300 f
"{tag[inner]} passages reach r {mm(inner_back)} mm on the back, inside the igniter's "
1301 f
"⌀{mm(ign['back_keepout_dia'])} thick centre plus {mm(ign['face_wall'])} mm")
1305 if ign[
"engaged_thickness"] < ign[
"l2"]:
1306 warn(
"bad",
"igniter_engagement",
1307 f
"{ign['thread']} needs {mm(ign['l2'])} mm of thread (ASME B1.20.1 L2); the plate is "
1308 f
"{mm(ign['engaged_thickness'])} mm at the port — raise 'Thickness at port' "
1309 f
"(injector.igniter.hub_thickness) or thicken the plate")
1310 if center_clear_dia > 0
and center_clear_dia > 1.2 * ign[
"face_keepout_dia"]:
1311 warn(
"info",
"centre_reserve_oversized",
1312 f
"⌀{mm(center_clear_dia)} is reserved at the centre; the {ign['thread']} port needs "
1313 f
"⌀{mm(ign['face_keepout_dia'])} — unset layer1_injector_center_clear_dia_m to let the igniter set it")
1315 return {
"face": face,
"passages": passages,
"back": back,
"warnings": warnings,
1316 "igniter": ign,
"centre_keepout": {
"dia": cc_req,
"source": cc_src},
1321 """The ``flows`` mapping :func:`layout_from_config` takes, from a
1322 ``PintleEngineRunner.evaluate`` result: per stream ``mdot``, injector ``dp`` and inlet
1323 pressure ``P_inj``, ``Cd``, density and vapour pressure (the config's fluids), plus ``Pc`` and
1324 the ambient pressure the solve used."""
1325 dg = result.get(
"diagnostics")
or {}
1326 fl =
_get(cfg,
"fluids")
or {}
1327 out: Dict[str, float] = {
"Pc":
_f(result.get(
"Pc")),
1328 "P_amb":
_f(result.get(
"P_ambient"), ATM)
or ATM}
1329 for k, side
in ((
"O",
"oxidizer"), (
"F",
"fuel")):
1331 cd = result.get(f
"Cd_{k}")
1333 f
"mdot_{k}":
_f(result.get(f
"mdot_{k}")),
1334 f
"dp_{k}":
_f(dg.get(f
"delta_p_injector_{k}")),
1335 f
"P_inj_{k}":
_f(dg.get(f
"P_injector_{k}")),
1336 f
"Cd_{k}":
_f(cd
if cd
is not None else dg.get(f
"Cd_{k}")),
1337 f
"rho_{k}":
_f(
_get(f,
"density")),
1338 f
"Pv_{k}":
_f(
_get(f,
"vapor_pressure")),
1344 """Channels back: each channel's velocity head at its feed port against the injector drop
1345 (channel_velocity_head), from a solve's ``mdot_O/F`` and ``dp_O/F`` (and ``rho_O/F``, else the
1346 config's fluids). Supersedes the bare area-ratio note. Over ``MANIFOLD_Q_FRAC`` it is 'bad':
1347 the holes by the port are not flowing what the solve assumed, so the solve is not the
1349 inlets = int(
_f(out[
"inputs"][
"plate"].get(
"channel_inlets"), 1.0)
or 1)
1350 fl =
_get(cfg,
"fluids")
or {}
1351 for k, side
in ((
"O",
"oxidizer"), (
"F",
"fuel")):
1352 ch = out[
"passages"][k].get(
"channel")
1353 if ch
is None or ch[
"pierces_back"]:
1355 rho =
_f(flows.get(f
"rho_{k}"))
or _f(
_get(
_get(fl, side),
"density"))
1356 m, dp =
_f(flows.get(f
"mdot_{k}")),
_f(flows.get(f
"dp_{k}"))
1357 if not (m > 0
and dp > 0
and rho > 0):
1361 inlets=n)[
"area_needed"]
1362 for n
in sorted({inlets, 1, 2, 4})}
1363 ch.update(q_over_dp=h[
"q_over_dp"], area_needed=h[
"area_needed"], channel_v=h[
"v"],
1364 channel_q=h[
"q"], injector_dp=dp, fed_end_flow=h[
"fed_end_flow"], inlets=h[
"inlets"],
1365 area_needed_by_ports=by_ports)
1366 out[
"warnings"] = [w
for w
in out[
"warnings"]
if w[
"code"] != f
"channel_area_{k}"]
1367 if h[
"q_over_dp"] > h[
"q_frac"]:
1368 tag =
"LOX" if k ==
"O" else "fuel"
1369 port = f
"{inlets} feed port{'s' if inlets > 1 else ''}"
1370 order = [inlets] + [n
for n
in (1, 2, 4)
if n != inlets]
1371 need =
", ".join(f
"{by_ports[str(n)] * 1e6:.0f} mm² ({n} port{'s' if n > 1 else ''}"
1372 f
"{', as drawn' if n == inlets else ''})" for n
in order)
1373 out[
"warnings"].append({
1374 "level":
"bad",
"code": f
"manifold_q_{k}",
1375 "text": (f
"{tag} channel velocity head at its {port}: q = {h['q'] / 1e3:.0f} kPa "
1376 f
"({h['v']:.1f} m/s each way) is {100 * h['q_over_dp']:.0f} % of the "
1377 f
"{dp / 1e3:.0f} kPa injector Δp (limit {100 * h['q_frac']:.0f} %). Holes by the "
1378 f
"port see the channel's static pressure, so they do not flow what the solve "
1379 f
"assumed. Channel is {ch['flow_area'] * 1e6:.1f} mm²; for q ≤ "
1380 f
"{100 * h['q_frac']:.0f} % of Δp it needs ≥ {need}")})
1384 """Inlet r/d a discharge block describes (dict or model), via engine.core.discharge."""
1385 from types
import SimpleNamespace
1387 dc =
_get(
_get(cfg,
"discharge"), side)
1390 ns = SimpleNamespace(**dc)
if isinstance(dc, Mapping)
else dc
1391 return float(inlet_radius_ratio_of(ns))
1395 """Each orifice's cavitation number against Nurick's (1976) critical value:
1396 ``K = (P_inj - P_v) / (P_inj - Pc)`` vs ``K_crit = (Cd / Cc)^2``, with Cc from the inlet's r/d
1397 (engine.core.discharge.cavitation_margin). K below K_crit: the vena contracta reaches vapour
1398 pressure and the Cd (and momentum ratio) the solve used do not hold."""
1400 fl =
_get(cfg,
"fluids")
or {}
1401 Pc =
_f(flows.get(
"Pc"))
1402 for k, side
in ((
"O",
"oxidizer"), (
"F",
"fuel")):
1403 p = out[
"passages"][k]
1404 P_in, cd =
_f(flows.get(f
"P_inj_{k}")),
_f(flows.get(f
"Cd_{k}"))
1405 pv = flows.get(f
"Pv_{k}")
1406 pv =
_f(pv)
if pv
is not None else _f(
_get(
_get(fl, side),
"vapor_pressure"), float(
"nan"))
1407 if not (Pc > 0
and P_in > Pc
and cd > 0
and math.isfinite(pv)):
1410 m = cavitation_margin(P_in=P_in, Pc=Pc, Pv=pv, Cd=cd, r_over_d=rd)
1411 p[
"cavitation"] = {**m,
"Cd": cd,
"r_over_d": rd,
"P_inj": P_in,
"Pc": Pc}
1412 tag =
"LOX" if k ==
"O" else "fuel"
1413 ch = p.get(
"channel")
1414 lip = (f
"; Cc is the square edge's, and this inlet's lip is {ch['entry_lip_deg']:.0f}°"
1415 if ch
is not None and ch[
"floor"] ==
"flat" else "")
1416 body = (f
"K = (P_inj − P_v)/(P_inj − Pc) = {m['K']:.2f} vs K_crit = (Cd/Cc)² = {m['K_crit']:.2f} "
1417 f
"(Cd {cd:.3f}, Cc {m['Cc']:.2f} at r/d {rd:g}; Nurick 1976){lip}")
1418 if m[
"margin"] < 1.0:
1419 out[
"warnings"].append({
"level":
"bad",
"code": f
"cavitation_{k}",
1420 "text": f
"{tag} orifices cavitate: {body} — the solve's Cd does not hold"})
1422 out[
"warnings"].append({
"level":
"info",
"code": f
"cavitation_{k}",
1423 "text": f
"{tag} orifices clear of cavitation, margin {m['margin']:.2f}: {body}"})
1431 r: Any, *, a: float, b: float, p: float, nu: float, support: str =
"simply_supported",
1432 r_hole: float = 0.0,
1433) -> Tuple[Any, Any]:
1434 """Radial and tangential bending moments ``(M_r, M_t)`` [N m / m] at radius r (scalar or
1435 array) in a circular plate of radius a under a uniform pressure p over r <= b (Timoshenko &
1436 Woinowsky-Krieger §19; Roark Table 11.2). ``support``: ``simply_supported`` or ``clamped``
1439 Positive sags toward the load. With ``r_hole`` > 0 the plate has a centre hole whose plug
1440 (the igniter) carries its share of p into the hole edge as shear, with no moment there
1441 (M_r(r_hole) = 0); r_hole = 0 is the solid plate. Solved exactly: slope
1442 phi = p r^3/(16 D) + A r + C/r inside b and (p b^2/(4 D)) r (ln(r/a) - 1/2) + B r + E/r
1443 outside, continuous in phi and phi' at b, with the edge condition at a. D = 1 throughout:
1444 the moments do not depend on it.
1447 b =
min(float(b), float(a))
1451 def ph_i(x: float) -> Tuple[Any, float]:
1452 return np.array([x, 1.0 / x, 0.0, 0.0]), p * x ** 3 / 16.0
1454 def dph_i(x: float) -> Tuple[Any, float]:
1455 return np.array([1.0, -1.0 / x ** 2, 0.0, 0.0]), 3.0 * p * x ** 2 / 16.0
1457 def ph_o(x: float) -> Tuple[Any, float]:
1458 return np.array([0.0, 0.0, x, 1.0 / x]), q * x * (math.log(x / a) - 0.5)
1460 def dph_o(x: float) -> Tuple[Any, float]:
1461 return np.array([0.0, 0.0, 1.0, -1.0 / x ** 2]), q * (math.log(x / a) + 0.5)
1463 rows: List[Any] = []
1464 rhs: List[float] = []
1466 def eq(*terms: Tuple[float, Tuple[Any, float]]) ->
None:
1467 c, v = np.zeros(4), 0.0
1468 for k, (cc, vv)
in terms:
1469 c, v = c + k * cc, v + k * vv
1474 eq((1.0, dph_i(r_hole)), (nu / r_hole, ph_i(r_hole)))
1476 eq((1.0, (np.array([0.0, 1.0, 0.0, 0.0]), 0.0)))
1477 eq((1.0, ph_i(b)), (-1.0, ph_o(b)))
1478 eq((1.0, dph_i(b)), (-1.0, dph_o(b)))
1479 if support ==
"clamped":
1482 eq((1.0, dph_o(a)), (nu / a, ph_o(a)))
1483 A, C, B, E = np.linalg.solve(np.array(rows), np.array(rhs))
1484 rr = np.maximum(np.asarray(r, dtype=float), 1e-12)
1487 phi = np.where(inner, p * rr ** 3 / 16.0 + A * rr + C / rr, q * rr * (L - 0.5) + B * rr + E / rr)
1488 dphi = np.where(inner, 3.0 * p * rr ** 2 / 16.0 + A - C / rr ** 2, q * (L + 0.5) + B - E / rr ** 2)
1489 Mr, Mt = -(dphi + nu * phi / rr), -(phi / rr + nu * dphi)
1491 return float(Mr), float(Mt)
1495def _check_plate(cfg: Any, out: Dict[str, Any], flows: Optional[Mapping[str, Any]]) ->
None:
1496 """Bending of the plug under chamber pressure: a circular plate of the plug's radius, Pc
1497 (gauge) over the gas bore, edge support as declared (``injector.plate.support``). Moments
1498 from the solid-plate solution (with the igniter port as a centre hole), divided at every
1499 radius by the section left there (face groove above, channel floor below): the full radial
1500 moment crosses the ligament, ``sigma_r = 6 M_r / t_n^2``, and the hoop stress follows the
1501 curvature the full plate imposes. Stress is Tresca at the surface. Keeping the solid plate's
1502 M_r across a thinned ring is conservative; it is an estimate, not an FEA. Not in this load
1503 case: manifold pressure in the channels and the cover's bolt loads (the designer's)."""
1506 plate = inp.get(
"plate")
or {}
1507 env = out[
"envelope"]
1508 a, b = float(env[
"r_sleeve_id"]), float(env[
"r_bore"])
1509 t = float(inp[
"plate_thickness"])
1511 if flows
is not None and _f(flows.get(
"Pc")) > 0:
1512 P_amb =
_f(flows.get(
"P_amb"), ATM)
or ATM
1513 p, src =
_f(flows.get(
"Pc")) - P_amb,
"solved Pc"
1514 elif _f(req.get(
"target_chamber_pressure_psi")) > 0:
1515 p, src =
_f(req.get(
"target_chamber_pressure_psi")) * PSI - ATM,
"target_chamber_pressure_psi, 1 atm outside"
1518 if not (p > 0
and a > 0
and b > 0
and t > 0):
1519 out[
"plate_bending"] =
None
1520 out[
"warnings"].append({
"level":
"info",
"code":
"plate_bending",
1521 "text":
"plate bending not checked: no chamber pressure (solve, or "
1522 "target_chamber_pressure_psi)"})
1524 support = str(plate.get(
"support")
or "simply_supported")
1525 nu_decl = plate.get(
"poisson_ratio")
1526 nu = float(nu_decl)
if nu_decl
is not None else PLATE_POISSON_DEFAULT
1527 ys =
_f(plate.get(
"yield_strength"))
or None
1528 ign = out.get(
"igniter")
1529 r_hole = 0.5 * ign[
"thread_od"]
if ign
else 0.0
1530 prof = out[
"face"][
"profile"]
1531 chans = {k: out[
"passages"][k].get(
"channel")
for k
in (
"O",
"F")}
1533 r0 =
max(r_hole, 1e-6)
1534 parts_r = [np.linspace(r0, a, 801)]
1535 for ch
in chans.values():
1537 parts_r.append(np.linspace(
max(r0, ch[
"r_lo"]),
min(a, ch[
"r_hi"]), 41))
1538 R = np.unique(np.concatenate(parts_r))
1541 face = np.interp(R, [x
for x, _
in prof], [z
for _, z
in prof])
1542 back = np.full_like(R, -t)
1544 for k, ch
in chans.items():
1547 m = (R >= ch[
"r_lo"] - 1e-12) & (R <= ch[
"r_hi"] + 1e-12)
1549 back = np.where(m, np.maximum(-t, ch[
"end"][1] + ch[
"floor_slope"] * (R - ch[
"r_center"])), back)
1550 tn = np.minimum(t, face - back)
1551 if out.get(
"profile"):
1555 L = out[
"profile"][
"loop"]
1556 tn = np.minimum(t, np.minimum.reduce([loop_section(L, R - 1e-9), loop_section(L, R),
1557 loop_section(L, R + 1e-9)]))
1560 tq = np.where(ok, tn, 1.0)
1562 sr = 6.0 * Mr / tq ** 2
1566 st = nu * sr + 6.0 * (Mt - nu * Mr) * tq / t ** 3
1567 sig = np.where(ok, np.maximum.reduce([np.abs(sr), np.abs(st), np.abs(sr - st)]), -1.0)
1569 def at(i: int) -> Dict[str, float]:
1570 return {
"r": float(R[i]),
"t_net": float(tn[i]),
"M_r": float(Mr[i]),
"M_t": float(Mt[i]),
1571 "sigma": float(sig[i]),
"sigma_r": float(sr[i]),
"sigma_t": float(st[i])}
1573 worst =
at(int(np.argmax(sig)))
1574 per_ch: Dict[str, Any] = {}
1575 for k, m
in in_ch.items():
1577 per_ch[k] =
at(int(np.argmax(np.where(m, sig, -1.0))))
1578 solid =
at(int(np.argmax(ok)))
1580 def where(r: float) -> str:
1581 for k, ch
in chans.items():
1582 if ch
is not None and ch[
"r_lo"] <= r <= ch[
"r_hi"]:
1583 return f
"{'LOX' if k == 'O' else 'fuel'} channel root"
1584 if r_hole > 0
and r <= r_hole + 1e-9:
1585 return "igniter port edge"
1587 return "face groove"
1588 return "full section"
1590 allow = ys / PLATE_YIELD_FACTOR
if ys
else None
1591 out[
"plate_bending"] = {
1592 "p_gauge": p,
"p_source": src,
"support": support,
"poisson_ratio": nu,
1593 "poisson_assumed": nu_decl
is None,
"r_plate": a,
"r_loaded": b,
"r_hole": r_hole,
1594 "thickness": t,
"channels": per_ch,
"centre": solid,
"max": {**worst,
"where": where(worst[
"r"])},
1595 "yield_strength": ys,
"yield_factor": PLATE_YIELD_FACTOR,
"allowable": allow,
1597 MPa =
lambda v: f
"{v / 1e6:.0f}"
1598 parts = [f
"{'LOX' if k == 'O' else 'fuel'} channel ligament {d['t_net'] * 1000:.2f} mm at r "
1599 f
"{d['r'] * 1000:.1f}: {MPa(d['sigma'])} MPa" for k, d
in per_ch.items()]
1600 parts.append(f
"{'port edge' if r_hole > 0 else 'centre'} {MPa(solid['sigma'])} MPa")
1601 head = (f
"plate bending under Pc {p / 1e6:.2f} MPa gauge ({src}) over ⌀{2 * b * 1000:.1f}, "
1602 f
"⌀{2 * a * 1000:.1f} plug {support.replace('_', ' ')}, ν {nu:g}"
1603 f
"{' assumed' if nu_decl is None else ''}, Tresca: "
1605 + f
"; max {MPa(worst['sigma'])} MPa at r {worst['r'] * 1000:.1f} mm ({where(worst['r'])})")
1606 tail =
" (manifold pressure in the channels and cover/bolt loads not included)"
1608 out[
"warnings"].append({
"level":
"info",
"code":
"plate_bending",
1609 "text": head +
" — material undeclared (injector.plate.yield_strength): "
1610 "stress only" + tail})
1611 elif worst[
"sigma"] > allow:
1612 out[
"warnings"].append({
"level":
"bad",
"code":
"plate_bending",
1613 "text": head + f
" > yield {MPa(ys)} MPa / {PLATE_YIELD_FACTOR:g} = "
1614 f
"{MPa(allow)} MPa — thicken the plate or shallow the channels" + tail})
1616 out[
"warnings"].append({
"level":
"info",
"code":
"plate_bending",
1617 "text": head + f
" ≤ yield {MPa(ys)} MPa / {PLATE_YIELD_FACTOR:g} = "
1618 f
"{MPa(allow)} MPa" + tail})
1627DRAWING_RING_TOL = 0.05e-3
1630DRAWING_LD_TOL = 0.02
1635DRAWING_PROFILE_TOL = 0.025e-3
1638def _drawing(plate: Mapping[str, Any]) -> Dict[str, Any]:
1640 return load_plate_profile(str(plate[
"profile_dxf"]))
1644 """Replace the layout's drawn geometry with the plate's revolve sketch
1645 (``injector.plate.profile_dxf``): face groove, passages (exit, length, entry), channel
1646 sections. What the config says that the drawing contradicts -- ring radius, L/d, plug
1647 radius, a centre port -- is flagged ``bad``; the drawing is what gets machined."""
1650 plate = inp.get(
"plate")
or {}
1651 if not plate.get(
"profile_dxf"):
1654 st = {
"O": inp[
"oxidizer"],
"F": inp[
"fuel"]}
1655 th = {k: float(st[k][
"impingement_angle"])
for k
in st}
1656 d = {k: float(st[k][
"d_jet"])
for k
in st}
1657 holes = drilled_passages(prof, th)
1658 tag = {
"O":
"LOX",
"F":
"fuel"}
1659 src = str(plate[
"profile_dxf"])
1660 warn = out[
"warnings"]
1661 mm =
lambda v: f
"{v * 1000:.3f}"
1662 t = float(prof[
"thickness"])
1663 fl = prof[
"_face_line"]
1664 drawn_loop = [(r, -abs(z - fl))
for r, z
in prof[
"loop"]]
1666 "source": src,
"mode": str(plate.get(
"profile_dxf_mode")
or "geometry"),
"thickness": t,
1667 "plate_radius": prof[
"plate_radius"],
"axis_hole": prof[
"axis_hole"],
1668 "face_groove": {k: v
for k, v
in (prof[
"face_groove"]
or {}).items()
if k !=
"flanks"},
1669 "channels": [{k: v
for k, v
in c.items()
if not k.startswith(
"_")
and k !=
"floor"}
1670 for c
in prof[
"channels"]],
1671 "passages": {k: {kk: vv
for kk, vv
in v.items()
if kk
not in (
"flank",
"facet")}
1672 for k, v
in holes.items()},
1673 "seal_grooves_back": prof[
"seal_grooves_back"],
1676 if summary[
"mode"] ==
"check":
1678 out[
"drawing"] = summary
1679 if out.get(
"igniter")
and not prof[
"axis_hole"]:
1680 warn.append({
"level":
"warn",
"code":
"drawing_igniter",
1681 "text": f
"the drawing has no centre port; the {out['igniter']['thread']} igniter is "
1682 f
"the config's (compared without it)"})
1683 if abs(t - float(inp[
"plate_thickness"])) > DRAWING_RING_TOL:
1684 warn.append({
"level":
"bad",
"code":
"drawing_thickness",
1685 "text": f
"plate {mm(inp['plate_thickness'])} mm in the config, {mm(t)} mm in the drawing"})
1686 for k
in (
"O",
"F"):
1687 h, p = holes.get(k), out[
"passages"][k]
1689 warn.append({
"level":
"bad",
"code": f
"drawing_passage_{k}",
1690 "text": f
"{tag[k]}: the drawing has no channel facet and flank at {th[k]:g}°"})
1692 de = math.hypot(h[
"exit_r"] - p[
"exit"][0], h[
"exit_depth"] + p[
"exit"][1])
1693 dl = h[
"length"] - float(p[
"thru"])
1694 summary[
"passages"][k].update(model_exit=p[
"exit"], model_length=p[
"thru"],
1695 exit_offset=de, length_offset=dl)
1696 if de > DRAWING_RING_TOL
or abs(dl) > DRAWING_RING_TOL:
1697 warn.append({
"level":
"bad",
"code": f
"drawing_passage_{k}",
1698 "text": f
"{tag[k]} passage: exit {mm(de)} mm and length {mm(dl)} mm off the drawing "
1699 f
"(drawn exit r {mm(h['exit_r'])}, length {mm(h['length'])})"})
1700 if out.get(
"profile"):
1702 dev = loop_deviation(bare, drawn_loop)
1703 summary[
"deviation"] = dev
1704 ok = dev[
"max"] <= DRAWING_PROFILE_TOL
1705 warn.append({
"level":
"info" if ok
else "bad",
"code":
"drawing_profile",
1706 "text": (f
"the model's section matches the drawing to {mm(dev['max'])} mm"
1708 f
"the model's section is {mm(dev['max'])} mm off the drawing (model at r "
1709 f
"{mm(dev['where_a'][0])}, z {mm(dev['where_a'][1])}; drawing at r "
1710 f
"{mm(dev['where_b'][0])}, z {mm(dev['where_b'][1])})")})
1715 g = prof.get(
"_face_groove_pts")
1717 fl = prof[
"_face_line"]
1718 pts = [(r, -abs(z - fl))
for r, z
in g]
1719 if pts[0][0] > pts[-1][0]:
1721 face[
"profile"] = [(0.0, 0.0), *pts, (float(out[
"envelope"][
"r_sleeve_id"]), 0.0)]
1722 fg = prof[
"face_groove"]
1723 if face.get(
"groove")
is not None:
1724 face[
"groove"].update(groove_edge_in=fg[
"r_lo"], groove_edge_out=fg[
"r_hi"],
1725 groove_depth=fg[
"depth"], groove_width=fg[
"r_hi"] - fg[
"r_lo"])
1727 if abs(prof[
"plate_radius"] - float(out[
"envelope"][
"r_sleeve_id"])) > DRAWING_RING_TOL:
1728 warn.append({
"level":
"bad",
"code":
"drawing_plug_radius",
1729 "text": f
"the drawing's plug is r {mm(prof['plate_radius'])} mm; the sleeve bore "
1730 f
"the config gives is r {mm(out['envelope']['r_sleeve_id'])} mm"})
1731 if out.get(
"igniter")
and not prof[
"axis_hole"]:
1732 warn.append({
"level":
"warn",
"code":
"drawing_igniter",
1733 "text": f
"the config declares a centre igniter ({out['igniter'].get('thread')}); "
1734 f
"the drawing has no hole on the axis — the section is the drawing's"})
1735 out[
"profile"] = {**(out.get(
"profile")
or {
"grooves": [],
"skipped": [],
"back_lands": []}),
1736 "loop": drawn_loop,
"thickness": t,
"with_port": prof[
"axis_hole"]}
1738 for k
in (
"O",
"F"):
1739 p = out[
"passages"][k]
1742 warn.append({
"level":
"bad",
"code": f
"drawing_passage_{k}",
1743 "text": f
"{tag[k]}: the drawing has no channel facet and face-groove flank "
1744 f
"at {th[k]:g}° to locate the passage between; the model's passage is kept"})
1746 ch_d = prof[
"channels"][h[
"channel"]]
1747 r_cfg = float(face[f
"r_{k}"])
1748 if abs(h[
"exit_r"] - r_cfg) > DRAWING_RING_TOL:
1749 warn.append({
"level":
"bad",
"code": f
"drawing_ring_{k}",
1750 "text": f
"{tag[k]} exits are on r {mm(h['exit_r'])} mm in the drawing and r "
1751 f
"{mm(r_cfg)} mm in the config (spacing "
1752 f
"{2 * math.pi * h['exit_r'] / max(1, face['n']) * 1000:.4f} mm matches the drawing)"})
1753 ld = h[
"length"] / d[k]
1754 ld_cfg = float(p.get(
"plate_l_over_d")
or p.get(
"land_ld")
or 0.0)
1755 z_exit, z_end = -h[
"exit_depth"], -h[
"entry_depth"]
1756 land =
min(h[
"flank_slant"]) - 0.5 * d[k]
1757 p.update(exit=(h[
"exit_r"], z_exit), end=(h[
"entry_r"], z_end), thru=h[
"length"],
1758 land=h[
"length"], land_ld=ld, plate_l_over_d=ld, cd_l_over_d=ld,
1759 entry_off_square=h[
"entry_off_square_deg"], r_back=h[
"entry_r"],
1760 exit_flank_land=land, source=
"drawing")
1761 if abs(ld - ld_cfg) > DRAWING_LD_TOL * ld:
1762 ds = (
_get(
_get(
_get(cfg,
"discharge"),
"oxidizer" if k ==
"O" else "fuel"),
"l_over_d_source")
1764 if str(ds) !=
"plate":
1765 warn.append({
"level":
"bad",
"code": f
"drawing_ld_{k}",
1766 "text": f
"{tag[k]} passage is {mm(h['length'])} mm = {ld:.2f} d in the drawing; "
1767 f
"the Cd is taken at L/d {ld_cfg:.2f} — set discharge."
1768 f
"{'oxidizer' if k == 'O' else 'fuel'}.l_over_d_source: plate"})
1770 warn.append({
"level":
"bad",
"code": f
"drawing_exit_land_{k}",
1771 "text": f
"{tag[k]} exit ({mm(d[k])} mm) is wider than its flank allows: "
1772 f
"{mm(land)} mm land"})
1773 ch = p.get(
"channel")
1775 A = float(ch_d[
"flow_area"])
1776 ch.update(r_lo=ch_d[
"r_lo"], r_hi=ch_d[
"r_hi"], r_center=ch_d[
"r_centroid"],
1777 width=ch_d[
"width"], depth=ch_d[
"depth"], flow_area=A,
1778 hydraulic_diameter=ch_d[
"hydraulic_diameter"], exit=(h[
"exit_r"], z_exit),
1779 end=(h[
"entry_r"], z_end), length=h[
"length"], length_wanted=h[
"length"],
1780 l_over_d=ld, pierces_back=
False, floor=
"drawing", floor_slope=0.0,
1781 floor_z_lo=-(t - ch_d[
"depth"]), floor_z_hi=-(t - ch_d[
"depth"]),
1782 ligament_min=ch_d[
"ligament_min"],
1783 breakthrough=(
"square" if h[
"entry_off_square_deg"] < 1.0
1784 else f
"{h['entry_off_square_deg']:.0f} deg off square"),
1785 entry_lip_deg=90.0 - h[
"entry_off_square_deg"],
1786 area_ratio=A / (face[
"n"] * math.pi * d[k] ** 2 / 4.0), source=
"drawing")
1787 ch.pop(
"flow_height",
None)
1789 stale = {f
"oblique_inlet_{k}", f
"channel_area_{k}", f
"short_ld_{k}", f
"drill_ld_{k}",
"groove_land"}
1790 out[
"warnings"] = warn = [w
for w
in warn
if w.get(
"code")
not in stale]
1791 if h[
"entry_off_square_deg"] >= 1.0:
1792 warn.append({
"level":
"info",
"code": f
"oblique_inlet_{k}",
1793 "text": f
"{tag[k]} passage enters its drawn channel facet "
1794 f
"{h['entry_off_square_deg']:.1f}° off square"})
1795 if ld < ORIFICE_LD_MIN:
1796 warn.append({
"level":
"warn",
"code": f
"short_ld_{k}",
1797 "text": f
"{tag[k]} orifice L/d {ld:.1f} < 4 — SP-8089's minimum for a jet that "
1799 if ch
is not None and ch[
"area_ratio"] < 1.0:
1800 warn.append({
"level":
"info",
"code": f
"channel_area_{k}",
1801 "text": f
"{tag[k]} channel cross-section {ch['flow_area'] * 1e6:.1f} mm² (drawn) is "
1802 f
"{ch['area_ratio']:.2f}× the orifice area it feeds"})
1804 out[
"drawing"] = summary
1808 cfg: Any, drawings: bool =
True, flows: Optional[Mapping[str, Any]] =
None, *, checks: bool =
True,
1809) -> Optional[Dict[str, Any]]:
1810 """``compute_layout`` on an engine config, plus its drawing primitives
1811 (``engine.core.injectors.drawing``); None when it is not an impinging doublet. ``flows``
1812 (a solve, see :func:`flows_from_result`) adds the manifold velocity-head and orifice
1813 cavitation checks and puts the solved Pc on the plate-bending check (else the target Pc).
1815 ``checks=False`` is the geometry alone -- passages and channels, a drawing in geometry mode
1816 applied -- for the flow solve, which needs the channel sections many times per operating point:
1817 the plate-bending check and the drawing comparison cost ~0.1 s each and were 80 % of a solve."""
1822 out[
"inputs"] = inputs
1823 plate = inputs.get(
"plate")
or {}
1825 if (plate.get(
"profile_dxf")
and str(plate.get(
"profile_dxf_mode")
or "geometry") ==
"geometry"):
1828 if out[
"back"][
"mode"] ==
"channels" or plate.get(
"rim_gland")
or plate.get(
"back_grooves"):
1835 if flows
is not None:
1841 out[
"drawings"] = injector_drawings(out)
T & at(const key_type &key)
bool _opt_bool(Mapping[str, Any] req, str key, bool default)
Dict[str, Any] assembly_envelope(Any cfg)
Tuple[Any, Any] plate_bending_moments(Any r, *, float a, float b, float p, float nu, str support="simply_supported", float r_hole=0.0)
Dict[str, float] channel_lands(*, Dict[str, Any] ch_in, Dict[str, Any] ch_out, float r_centre_hole, float r_plate)
Dict[str, Any] _spot_channel(*, float r_exit, float z_exit, float d, float theta_deg, float k, float t, float lam_want, Optional[float] width, float exit_land, float spot)
Optional[Dict[str, Any]] _plain(Any m)
Optional[Dict[str, Any]] layout_inputs_from_config(Any cfg)
Optional[Dict[str, Any]] layout_from_config(Any cfg, bool drawings=True, Optional[Mapping[str, Any]] flows=None, *, bool checks=True)
float _half_major(float d, float theta_deg)
Optional[Dict[str, Any]] igniter_keepouts(Optional[Mapping[str, Any]] igniter, *, float plate_thickness, float min_web)
Dict[str, Any] realized_land(*, float d, float theta_deg, float plate_thickness, float counterbore_dia, Optional[float] declared_land_ld)
Dict[str, float] channel_velocity_head(*, float flow_area, float mdot, float rho, float dp, int inlets=1, float q_frac=MANIFOLD_Q_FRAC)
List[Tuple[float, float]] _fillet(Tuple[float, float] a, Tuple[float, float] P, Tuple[float, float] b, float rho, int n=6)
Optional[float] _opt_float(Mapping[str, Any] req, str key)
Any effective_discharge(Any engine_config, str side)
List[Tuple[float, float]] _rounded(List[Tuple[float, float]] ps, List[float] radii)
float face_z_at(List[Tuple[float, float]] profile, float r)
float _f(Any v, float default=0.0)
None _check_manifold(Any cfg, Dict[str, Any] out, Mapping[str, Any] flows)
None _report_cd(Any cfg, Dict[str, Any] out)
None _check_plate(Any cfg, Dict[str, Any] out, Optional[Mapping[str, Any]] flows)
Dict[str, Any] channel_for_ring(*, float r_exit, float z_exit, float d, float theta_deg, bool is_inner, float plate_thickness, float passage_ld, Optional[float] width, str floor="flat", float exit_land=EXIT_LAND_DEFAULT, Optional[float] spot_length=None)
Dict[str, Any] plate_profile(Mapping[str, Any] out, Mapping[str, Any] plate, *, bool with_port=True)
None _check_cavitation(Any cfg, Dict[str, Any] out, Mapping[str, Any] flows)
Dict[str, Any] _req_dict(Any cfg)
BackEntry passage_back_entry(*, float r_face, float d, float theta_deg, float plate_thickness, float counterbore_dia, float land_ld, bool is_inner, int n, float exit_depth=0.0)
float exit_recess(*, float d_in, float th_in, float d_out, float th_out, float exit_land)
Dict[str, float] _stream(Any raw)
Dict[str, Any] _plate_block(Any cfg)
Dict[str, Any] face_geometry(*, bool contoured, float r_in, float r_out, float d_in, float d_out, float th_in, float th_out, float exit_land=EXIT_LAND_DEFAULT, str groove_bottom="flat")
LdBand impingement_ld_band(Optional[Mapping[str, Any]] requirements)
Dict[str, Any] _drawing(Mapping[str, Any] plate)
None _apply_drawing(Any cfg, Dict[str, Any] out)
Optional[Dict[str, Any]] feed_ports(Mapping[str, Any] out, Mapping[str, Any] plate)
None _check_inlet_fit(Dict[str, Any] out)
Tuple[float, float] ring_face_reach(*, bool contoured, float r_in, float d_in, float th_in, float r_out, float d_out, float th_out, float exit_land=EXIT_LAND_DEFAULT)
Any _discharge_as_built(Any dc, Mapping[str, Any] passage)
Dict[str, float] flows_from_result(Mapping[str, Any] result, Any cfg)
Tuple[float, float, float] _poly_area_centroid_r(List[Tuple[float, float]] ps)
float _discharge_r_over_d(Any cfg, str side)
None _check_back_features(Dict[str, Any] out)
Dict[str, Any] compute_layout(*, Mapping[str, float] oxidizer, Mapping[str, float] fuel, float bore_diameter, Optional[Mapping[str, Any]] envelope=None, bool fuel_outboard=True, float center_clear_dia=0.0, float min_web=0.0, float wall_clearance=0.0, float ld_min=IMPINGEMENT_LD_TARGET_DEFAULT, float ld_max=IMPINGEMENT_LD_TARGET_DEFAULT, float plate_thickness=0.0127, float counterbore_dia=0.0, float land_ld_O=4.0, Optional[float] land_ld_F=None, float min_back_web=0.0, Optional[Mapping[str, Any]] igniter=None, Optional[Mapping[str, Any]] plate=None, str ld_source_O="declared", str ld_source_F="declared", bool plate_thickness_declared=True)