STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
layout.py
Go to the documentation of this file.
1"""Impinging-injector layout: the one place the injector's geometry is derived.
2
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.
6
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.
11
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.
19
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.
24
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.
27
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``.
30"""
31
32from __future__ import annotations
33
34import math
35from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple
36
38
39# =============================================================================================
40# Standoff band
41# =============================================================================================
42
43#: Target for Layer 1's standoff L/d, the AXIAL height of the impingement point above the exits
44#: over the mean orifice diameter. NOTE this is not SP-8089's impingement distance, which is
45#: measured ALONG the jet (``free_jet_*`` below); the axial figure is what the optimizer has
46#: always used, and the layout reports both.
47IMPINGEMENT_LD_TARGET_DEFAULT = 4.0
48
49#: Half-width of the standoff search window when the spacing is NOT derived and no explicit
50#: band or tolerance is given. With the derivation on the window has zero width.
51IMPINGEMENT_LD_TOL_UNDERIVED = 1.0
52
53
54class LdBand(NamedTuple):
55 target: float
56 lo: float
57 hi: float
58
59
60def _opt_float(req: Mapping[str, Any], key: str) -> Optional[float]:
61 v = req.get(key)
62 if v is None:
63 return None
64 try:
65 return float(v)
66 except (TypeError, ValueError):
67 return None
68
69
70def _opt_bool(req: Mapping[str, Any], key: str, default: bool) -> bool:
71 v = req.get(key)
72 if v is None:
73 return default
74 if isinstance(v, str):
75 s = v.strip().lower()
76 if s in ("false", "0", "no", "off", ""):
77 return False
78 if s in ("true", "1", "yes", "on"):
79 return True
80 return default
81 return bool(v)
82
83
84def impingement_ld_band(requirements: Optional[Mapping[str, Any]]) -> LdBand:
85 """Resolve the standoff L/d band from design requirements.
86
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.
90 """
91 req = requirements or {}
92 target = _opt_float(req, "layer1_impingement_Ld_target")
93 if target is None:
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")
97 if tol is None:
98 tol = 0.0 if derived else IMPINGEMENT_LD_TOL_UNDERIVED
99 tol = max(0.0, tol)
100 lo = _opt_float(req, "layer1_impingement_Ld_min")
101 hi = _opt_float(req, "layer1_impingement_Ld_max")
102 if lo is None:
103 lo = max(0.0, target - tol)
104 if hi is None:
105 hi = target + tol
106 return LdBand(float(target), float(lo), float(hi))
107
108
109# =============================================================================================
110# Limits with a source, and the one assumed default
111# =============================================================================================
112
113#: Floor on |cos(theta)| when sizing an inclined hole's trace, so a near-90 deg jet cannot
114#: report an infinite footprint. Layer 1 uses the same floor.
115_COS_FLOOR = 0.10
116
117#: Twist-drill practice: past about 10 diameters a small drill wanders and the shop reaches for
118#: a gundrill or EDM.
119DRILL_LD_PRACTICE_MAX = 10.0
120
121#: SP-8089 criterion 3.1.2.2 (p.85): "an orifice L/d of at least 4 should be used to guarantee
122#: full flow" (the stream leaves concentric with the hole).
123ORIFICE_LD_MIN = 4.0
124
125#: Sandvik Coromant, irregular-surface drilling: a solid-carbide drill tolerates at most 10 deg
126#: off square on entry; beyond that a flat is milled first. A flat face drilled at the jet angle
127#: is theta off square.
128DRILL_ENTRY_MAX_OFF_SQUARE_DEG = 10.0
129
130#: SP-8089 p.26: included angles above 90 deg give high heat flux to the injector face.
131FACE_HEATING_INCLUDED_DEG = 90.0
132
133#: SP-8089 criterion 3.1.1.1.4 (p.80): impingement distance, measured ALONG the jet, "no greater
134#: than 5 to 7 orifice diameters" -- an upper bound, not a band.
135FREE_JET_MAX_D = 7.0
136
137#: Share of the chamber cross-section inside the impingement circle below which the spray is
138#: feeding a core rather than the chamber.
139CORE_FRAC_WARN = 0.25
140
141#: ASSUMED: the metal left between the edge of a round exit and the edge of its flank on a
142#: contoured face (and between a passage and its channel wall). Override with
143#: ``injector.plate.exit_land``.
144EXIT_LAND_DEFAULT = 0.0005
145
146#: ASSUMED plate thickness when ``design_requirements.layer1_injector_plate_thickness_m`` is not
147#: set (0.5 in). Layer 1 uses the same fallback so the two agree; the layout says when it is used.
148PLATE_THICKNESS_DEFAULT = 0.0127
149
150#: Channel velocity head allowed at a feed port, as a share of the injector drop (config audit
151#: H-1, 2026-09-25). Holes fed across a channel see its static pressure and recover none of its
152#: velocity head (NASA TN D-5467); at 5 % the holes beside the port lose ~2.5 % of their flow.
153MANIFOLD_Q_FRAC = 0.05
154
155#: Factor on the plate material's yield that the plate-bending check allows (ASSUMED; the check
156#: says so when it fires). The stress it is compared with is already a conservative estimate.
157PLATE_YIELD_FACTOR = 1.5
158
159#: ASSUMED Poisson ratio for the plate-bending check when ``injector.plate.poisson_ratio`` is not
160#: declared (steels, aluminium and copper alloys sit at 0.28-0.34).
161PLATE_POISSON_DEFAULT = 0.3
162
163PSI = 6894.757
164ATM = 101325.0
165
166
167# =============================================================================================
168# Small helpers
169# =============================================================================================
170
171def _half_major(d: float, theta_deg: float) -> float:
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))))
174
175
176def _get(m: Any, key: str) -> Any:
177 if m is None:
178 return None
179 if isinstance(m, Mapping):
180 return m.get(key)
181 return getattr(m, key, None)
182
183
184def _f(v: Any, default: float = 0.0) -> float:
185 try:
186 x = float(v)
187 except (TypeError, ValueError):
188 return default
189 return x if math.isfinite(x) else default
190
191
192def _stream(raw: Any) -> Dict[str, float]:
193 return {
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")),
198 }
199
200
201def _plain(m: Any) -> Optional[Dict[str, Any]]:
202 """A config sub-block as a plain dict (None stays None)."""
203 if m is None:
204 return None
205 if isinstance(m, Mapping):
206 return dict(m)
207 if hasattr(m, "model_dump"):
208 return m.model_dump()
209 return None
210
211
212def _req_dict(cfg: Any) -> Dict[str, Any]:
213 req = _get(cfg, "design_requirements") or {}
214 if isinstance(req, Mapping):
215 return dict(req)
216 return req.model_dump() if hasattr(req, "model_dump") else {}
217
218
219def _tan(th: float) -> float:
220 return math.tan(math.radians(th))
221
222
223def _sin(th: float) -> float:
224 return math.sin(math.radians(th))
225
226
227def _cos(th: float) -> float:
228 return math.cos(math.radians(th))
229
230
231# =============================================================================================
232# The envelope the plug fills
233# =============================================================================================
234
235def assembly_envelope(cfg: Any) -> Dict[str, Any]:
236 """Bore, liner and sleeve radii from the engine config.
237
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.
243
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.
246 """
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
250 req = _req_dict(cfg)
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
256 return {
257 "r_bore": r_bore,
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,
263 # Radial gap (+) or interference (-) between the liner OD and the sleeve bore.
264 "liner_gap": r_sleeve_id - (r_bore + liner),
265 }
266
267
268# =============================================================================================
269# Face
270# =============================================================================================
271
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].
274
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.
280 """
281 return max((0.5 * d_in + exit_land) * _sin(th_in), (0.5 * d_out + exit_land) * _sin(th_out))
282
283
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.
289
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.
293 """
294 if not contoured:
295 return r_in - _half_major(d_in, th_in), r_out + _half_major(d_out, th_out)
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))
298
299
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",
303) -> Dict[str, Any]:
304 """Exits, impingement point, free-jet lengths and the face profile for one ring pair.
305
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.
311
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.
316 """
317 dr = r_out - r_in
318 ts = _tan(th_in) + _tan(th_out)
319 L_ax = dr / ts if ts > 1e-9 else 0.0
320 out: Dict[str, Any] = {"contoured": bool(contoured)}
321 if 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)
323 z_E = -dE
324 edge_in = r_in - dE / max(1e-9, _tan(th_in))
325 edge_out = r_out + dE / max(1e-9, _tan(th_out))
326 # The flanks' own intersection (the deepest the groove can be).
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)]
332 depth = d_v
333 else:
334 depth = want
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)]
338 out.update(
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,
344 )
345 else:
346 z_E = 0.0
347 profile = [(0.0, 0.0)]
348 out.update(exit_depth=0.0, z_exit=0.0)
349 out.update(
350 profile=profile,
351 l_ax=L_ax,
352 r_imp=r_in + L_ax * _tan(th_in),
353 z_imp=z_E + L_ax,
354 free_jet_in=L_ax / max(1e-9, _cos(th_in)),
355 free_jet_out=L_ax / max(1e-9, _cos(th_out)),
356 )
357 return out
358
359
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)
365 return 0.0
366
367
368# =============================================================================================
369# Passages, plenum or channels
370# =============================================================================================
371
372class BackEntry(NamedTuple):
373 r_back: float # radius of the passage axis on the back face [m]
374 entry_d: float # diameter of what opens there: counterbore, or the orifice if none
375 inner_edge: float # radial extent of the entry's elliptical trace [m]
376 outer_edge: float
377 web: float # chord to the neighbouring entry, less its width [m]
378
379
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,
383) -> BackEntry:
384 """Plenum back: where one ring's passage opens on the flat back face.
385
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.
390 """
391 t = float(plate_thickness) - float(exit_depth)
392 c = _cos(theta_deg)
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
398 hm = _half_major(entry_d, theta_deg)
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)
401
402
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,
407) -> Dict[str, Any]:
408 """Channels back: the passage from its exit to the channel floor, and the channel around it.
409
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.
416
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.)
423
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.
426 """
427 t = float(plate_thickness)
428 s, c = _sin(theta_deg), _cos(theta_deg)
429 k = -1.0 if is_inner else 1.0 # radial direction travelled going in
430 lam_want = float(passage_ld) * d
431 if floor == "spot":
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)
435 if floor == "coned":
436 foot = d * c # square breakthrough: a circle of d, seen radially
437 # Floor normal along the passage. Going outward the inner ring's floor falls toward the
438 # back face and the outer ring's rises toward the face.
439 slope = (1.0 if k > 0 else -1.0) * _tan(theta_deg)
440 else:
441 foot = d / c if c > 1e-9 else float("inf") # oblique breakthrough: an ellipse d / cos
442 slope = 0.0
443 w_min = foot + 2.0 * exit_land
444 w = max(float(width), w_min) if width else w_min
445 # The floor must stay inside the plate at its SHALLOWEST wall, not just on the passage axis:
446 # a coned floor tilts by theta, so one wall sits (w/2) tan(theta) nearer the back face.
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) # noqa: E731
452 lo, hi = rQ - 0.5 * w, rQ + 0.5 * w
453 return {
454 "exit": (r_exit, z_exit),
455 "end": (rQ, zQ),
456 "length": lam,
457 "length_wanted": lam_want,
458 "l_over_d": lam / d if d > 0 else 0.0,
459 "pierces_back": pierces,
460 "r_center": rQ,
461 "width": w,
462 "width_min": w_min,
463 "width_widened": bool(width) and float(width) < w_min,
464 "r_lo": lo,
465 "r_hi": hi,
466 "floor": floor,
467 "floor_slope": slope,
468 "floor_z_lo": floor_z(lo),
469 "floor_z_hi": floor_z(hi),
470 # Depth from the back face at the centre and at the shallowest wall.
471 "depth": t + zQ,
472 "depth_min": t + min(floor_z(lo), floor_z(hi)),
473 "footprint": foot,
474 "breakthrough": "square" if floor == "coned" else f"{theta_deg:.0f} deg off square",
475 # The metal lip where the hole meets the floor, on its acute side: 90 deg is the square
476 # sharp edge the Cd model's inlets are measured on; a flat floor leaves (90 - theta).
477 "entry_lip_deg": 90.0 if floor == "coned" else 90.0 - float(theta_deg),
478 # Cross-section of the channel itself (the cover may add more).
479 "flow_area": w * (t + min(floor_z(lo), floor_z(hi))) if w > 0 else 0.0,
480 # Its outline in (r, z), back face -> floor -> back face, in increasing r.
481 "section": [(lo, -t), (lo, max(-t, floor_z(lo))), (hi, max(-t, floor_z(hi))), (hi, -t)],
482 "r_floor": rQ,
483 }
484
485
486def _poly_area_centroid_r(ps: List[Tuple[float, float]]) -> Tuple[float, float, float]:
487 """(area, centroid radius, perimeter) of a closed polygon in (r, z)."""
488 a = cx = per = 0.0
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
492 a += cr
493 cx += (x0 + x1) * cr
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
496
497
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)
502 h = 0.5 * spot
503 # The facet's end away from the face sits (spot/2) sin(theta) nearer the back than Q.
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
508 # Facet direction, square to the passage (k s, -c): (c, k s). Its end nearer the face has
509 # the larger z; the flat floor runs from there, away from the facet's other end.
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
516 zf = near[1]
517 if out_dir > 0:
518 sec = [(far[0], -t), far, near, (edge, zf), (edge, -t)]
519 else:
520 sec = [(edge, -t), (edge, zf), near, far, (far[0], -t)]
521 area, r_cen, per = _poly_area_centroid_r(sec)
522 wet = per # walls, floor and spot, and the cover across the top
523 lo, hi = sec[0][0], sec[-1][0]
524 return {
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),
535 }
536
537
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.
544
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)``.
550 """
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
558 return {
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),
561 # The uniform-flow estimate for the holes beside the port. It assumes the design flow
562 # through every hole, so it means something only while q << dp: past dp it is undefined
563 # (the flow that makes q could not pass), not zero -- None.
564 "fed_end_flow": math.sqrt(1.0 - q / dp) if q < dp else None,
565 "inlets": n, "q_frac": q_frac,
566 }
567
568
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."""
574 return {
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"],
578 }
579
580
581# =============================================================================================
582# Igniter port
583# =============================================================================================
584
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.
589
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.
593 """
594 if not igniter or not igniter.get("thread"):
595 return None
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
601 # A centre left thicker than the field is as wide as the port's keep-out: the back face
602 # steps up there, and the inner channel must clear it.
603 hub_d = keepout if (hub_t is not None and float(hub_t) > float(plate_thickness)) else 0.0
604 return {
605 "thread": str(igniter["thread"]),
606 "thread_od": thr.od,
607 "l2": thr.l2_effective,
608 "tap_drill": thr.tap_drill,
609 "face_wall": wall,
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,
616 }
617
618
619# =============================================================================================
620# The plate's half-section
621# =============================================================================================
622
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."""
626 if rho <= 0:
627 return [P]
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:
632 return [P]
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)]
643
644
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)."""
647 out = [ps[0]]
648 for i in range(1, len(ps) - 1):
649 out += _fillet(ps[i - 1], ps[i], ps[i + 1], radii[i - 1])
650 out.append(ps[-1])
651 return out
652
653
654def plate_profile(out: Mapping[str, Any], plate: Mapping[str, Any], *, with_port: bool = True
655 ) -> Dict[str, Any]:
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.
659
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)]
671
672 gl = plate.get("rim_gland")
673 if gl:
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]
679 loop.append((R, -t))
680
681 # Back-face features, each an open outline from its high-r edge to its low-r edge.
682 feats: List[Tuple[float, float, str, List[Tuple[float, float]]]] = []
683 for k in ("O", "F"):
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] = []
689 grooves = []
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")
697 continue
698 pts = _rounded([(ro, -t), (ro, -t + dep), (ri, -t + dep), (ri, -t)], [rc, rc])
699 feats.append((ri, ro, name, pts))
700 # Channels first: they are the engine's; a groove gives way to them, never the reverse.
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
704 for f in feats:
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}")
710 continue
711 kept.append(f)
712 kept.sort(key=lambda f: -f[1])
713 for f in kept:
714 loop += f[3]
715 if hub_t > t and hub_r > r_port:
716 loop += [(hub_r, -t), (hub_r, -hub_t), (r_port, -hub_t)]
717 else:
718 loop.append((r_port, -t))
719 # Lands on the back face between neighbouring cuts (and the port, and the rim).
720 edges = sorted([(f[0], f[1], f[2]) for f in kept], key=lambda e: e[0])
721 lands = []
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})
727 # The port is tapped from the back: at the back face it is the thread's major diameter.
728 clash = []
729 if ign:
730 r_thr = 0.5 * float(ign["thread_od"])
731 for f in kept:
732 if f[0] < r_thr:
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)}
737
738
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":
745 return None
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
749 pitch = 360.0 / n
750 clock_F = plate.get("port_clock_F_deg")
751 clock_F = 0.5 * pitch if clock_F is None else float(clock_F)
752 rings = {}
753 for k in ("O", "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}
763
764
765def _check_back_features(out: Dict[str, Any]) -> None:
766 prof, ports = out.get("profile"), out.get("ports")
767 if not prof:
768 return
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})
773 if prof["grooves"]:
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)"})
779 if ports:
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"])
784 if ov > 0:
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)")})
791
792
793# =============================================================================================
794# Discharge coupling
795# =============================================================================================
796
798 *, d: float, theta_deg: float, plate_thickness: float, counterbore_dia: float,
799 declared_land_ld: Optional[float],
800) -> Dict[str, Any]:
801 """Plenum back: the orifice land the plate actually gives, and the counterbore feeding it.
802
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.
806 """
807 c = _cos(theta_deg)
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
813 else:
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)}
817
818
819def _plate_block(cfg: Any) -> Dict[str, Any]:
820 return _plain(_get(_get(cfg, "injector"), "plate")) or {}
821
822
823def effective_discharge(engine_config: Any, side: str) -> Any:
824 """The discharge block the Cd model should use for ``side``, given the plate as built.
825
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.
829 """
830 dc = engine_config.discharge[side]
831 if getattr(dc, "l_over_d_source", "declared") != "plate":
832 return dc
833 lay = layout_from_config(engine_config, drawings=False, checks=False)
834 key = "O" if side == "oxidizer" else "F"
835 if lay is None:
836 from engine.pipeline.assumptions import assume
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")
839 return dc
840 return _discharge_as_built(dc, lay["passages"][key])
841
842
843def _discharge_as_built(dc: Any, passage: Mapping[str, Any]) -> Any:
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":
846 return dc
847 return dc.model_copy(update={"orifice_l_over_d": passage["plate_l_over_d"],
848 "approach_beta": passage.get("beta")})
849
850
851def _check_inlet_fit(out: Dict[str, Any]) -> None:
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."""
856 from engine.core.injectors.drawing import _inlet_shape
857 for k in ("O", "F"):
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"]:
862 continue
863 st = out["inputs"]["oxidizer" if k == "O" else "fuel"]
864 d, th = float(st["d_jet"]), float(st["impingement_angle"])
865 kind, size = shape
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")})
877
878
879def _report_cd(cfg: Any, out: Dict[str, Any]) -> None:
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."""
883 from engine.core.discharge import (
884 INLET_GEOMETRY_CD, cd_from_inlet_radius_ratio, cd_inf_from_orifice_diameter,
885 cd_length_factor, cd_with_approach,
886 )
887 from engine.pipeline.config_schemas import DischargeConfig
888
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
893 if raw is None:
894 p["cd"] = None
895 continue
896 dc = raw if isinstance(raw, DischargeConfig) else DischargeConfig.model_validate(_plain(raw))
897 dc = _discharge_as_built(dc, p)
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"),
903 # what the drawing cuts at the hole's entry edge
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:
907 # Diameter-scaled Cd_inf: the hole's L/d does not enter it.
908 info.update(inlet=None, inlet_cd=None, length_factor=None, approach=None, uses_ld=False)
909 else:
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,
915 length_factor=lf,
916 approach=(cd_with_approach(base * lf, beta) / (base * lf)) if beta else None,
917 uses_ld=lod is not None)
918 p["cd"] = info
919
920
921# =============================================================================================
922# Reading a config
923# =============================================================================================
924
925def layout_inputs_from_config(cfg: Any) -> Optional[Dict[str, Any]]:
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":
930 return None
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:
935 return None
936 req = _req_dict(cfg)
937 dis = _get(cfg, "discharge") or {}
938 band = impingement_ld_band(req)
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
941 plate = _plate_block(cfg)
942 t_drawn = _drawing(plate)["thickness"] if plate.get("profile_dxf") else None
943 return {
944 "oxidizer": _stream(ox),
945 "fuel": _stream(fu),
946 "bore_diameter": bore,
947 "envelope": assembly_envelope(cfg),
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,
952 "ld_min": band.lo,
953 "ld_max": band.hi,
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,
957 "land_ld_O": land_O,
958 "land_ld_F": land_F,
959 "min_back_web": _opt_float(req, "layer1_injector_min_back_web_m") or 0.0,
960 "igniter": _plain(_get(inj, "igniter")),
961 "plate": plate,
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"),
964 }
965
966
967# =============================================================================================
968# The layout
969# =============================================================================================
970
972 *,
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,
993) -> Dict[str, Any]:
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)
998 # Undeclared => the stand's plug: contoured face, channel back (the schema's defaults).
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"]
1012
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"}
1024
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)]
1029 reach_in, reach_out = ring_face_reach(
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"])
1033 l_imp = fg["l_ax"]
1034 r_imp = fg["r_imp"]
1035 free = {inner: fg["free_jet_in"], outer: fg["free_jet_out"]}
1036 face = {
1037 "n": n,
1038 "n_F": n_F,
1039 "r_bore": r_bore,
1040 "r_O": r["O"], "r_F": r["F"],
1041 "d_pitch_O": d_pitch["O"], "d_pitch_F": d_pitch["F"],
1042 "dr": dr,
1043 "l_imp": l_imp, # axial, exits -> impingement
1044 "l_over_d": l_imp / d_avg if d_avg > 0 else 0.0, # what Layer 1's band means
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"], # impingement height above the datum
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],
1056 "r_imp": r_imp,
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,
1070 }
1071
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)}
1074 # One L/d per hole: discharge.<side>.orifice_l_over_d sets the Cd and, with channels, how far
1075 # the passage runs before it opens into its channel.
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
1079
1080 def _channel(k: str, tt: float) -> Dict[str, Any]:
1081 return channel_for_ring(
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)
1087
1088 passages: Dict[str, Dict[str, Any]] = {}
1089 chans: Dict[str, Dict[str, Any]] = {}
1090 for k in ("O", "F"):
1091 is_in = k == inner
1092 z_E = fg["z_exit"]
1093 src = ld_source_O if k == "O" else ld_source_F
1094 if channels:
1095 ch = _channel(k, t)
1096 h_flow = _f(plate.get(f"channel_flow_height_{k}"), 0.0)
1097 if h_flow and h_flow > 0:
1098 # The passage the manifold flow sees (channel + cover groove), as declared.
1099 ch["flow_height"] = h_flow
1100 ch["flow_area"] = ch["width"] * h_flow
1101 chans[k] = ch
1102 web_end = 2.0 * ch["r_center"] * math.sin(math.pi / (n if k == "O" else n_F)) - d[k]
1103 passages[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"],
1114 "beta": None,
1115 "channel": ch,
1116 }
1117 else:
1118 c = _cos(th[k])
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)
1123 be = passage_back_entry(
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]
1128 passages[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,
1135 "back_web": be.web,
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,
1140 "channel": None,
1141 }
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"],
1147 }
1148 if channels:
1149 back["lands"] = channel_lands(ch_in=chans[inner], ch_out=chans[outer],
1150 r_centre_hole=r_hole, r_plate=r_plate)
1151
1152 warnings: List[Dict[str, str]] = []
1153
1154 def warn(level: str, code: str, text: str) -> None:
1155 warnings.append({"level": level, "code": code, "text": text})
1156
1157 mm = lambda v: f"{v * 1000:.2f}" # noqa: E731
1158
1159 # ---- envelope ---------------------------------------------------------------------------
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)}")
1164
1165 # ---- centre keep-out -------------------------------------------------------------------
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']}"
1170 else:
1171 cc_req, cc_src = 0.0, None
1172
1173 # ---- face -------------------------------------------------------------------------------
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")
1215
1216 # ---- passages -----------------------------------------------------------------------------
1217 for k in ("O", "F"):
1218 p = passages[k]
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:
1230 # Plenum back only: there the plate, not the typed number, decides the hole length.
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")
1234
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")
1242 if channels:
1243 for k in ("O", "F"):
1244 ch = chans[k]
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":
1258 # SP-8089's L/d >= 4 is the AXIS length of a square-entry hole (checked above as
1259 # short_ld); the oblique breakthrough does not shorten it. What it changes is the
1260 # inlet: a lip of (90 - theta) on the acute side, which no Cd table here covers.
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")
1266 L = back["lands"]
1267 if L["between"] < 0:
1268 warn("bad", "channels_cross", "the LOX and fuel channels overlap on the back face")
1269 if L["inner"] < 0:
1270 warn("bad", "channel_port",
1271 f"the {tag[inner]} channel reaches the centre port (r {mm(chans[inner]['r_lo'])} mm)")
1272 if L["outer"] < 0:
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)})")
1275 else:
1276 for k in ("O", "F") if not face["degenerate"] else ():
1277 p = passages[k]
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")
1290
1291 # ---- centre port and the igniter's back keep-out, on 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: # channels: see channel_port
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)} "
1297 f"centre port")
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")
1302
1303 # ---- igniter ------------------------------------------------------------------------------
1304 if ign is not None:
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")
1314
1315 return {"face": face, "passages": passages, "back": back, "warnings": warnings,
1316 "igniter": ign, "centre_keepout": {"dia": cc_req, "source": cc_src},
1317 "envelope": env}
1318
1319
1320def flows_from_result(result: Mapping[str, Any], cfg: Any) -> Dict[str, float]:
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")):
1330 f = _get(fl, side)
1331 cd = result.get(f"Cd_{k}")
1332 out.update({
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")),
1339 })
1340 return out
1341
1342
1343def _check_manifold(cfg: Any, out: Dict[str, Any], flows: Mapping[str, Any]) -> None:
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
1348 hardware."""
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"]:
1354 continue
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):
1358 continue
1359 h = channel_velocity_head(flow_area=ch["flow_area"], mdot=m, rho=rho, dp=dp, inlets=inlets)
1360 by_ports = {str(n): channel_velocity_head(flow_area=ch["flow_area"], mdot=m, rho=rho, dp=dp,
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}")})
1381
1382
1383def _discharge_r_over_d(cfg: Any, side: str) -> float:
1384 """Inlet r/d a discharge block describes (dict or model), via engine.core.discharge."""
1385 from types import SimpleNamespace
1386 from engine.core.discharge import inlet_radius_ratio_of
1387 dc = _get(_get(cfg, "discharge"), side)
1388 if dc is None:
1389 return 0.0
1390 ns = SimpleNamespace(**dc) if isinstance(dc, Mapping) else dc
1391 return float(inlet_radius_ratio_of(ns))
1392
1393
1394def _check_cavitation(cfg: Any, out: Dict[str, Any], flows: Mapping[str, Any]) -> None:
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."""
1399 from engine.core.discharge import cavitation_margin
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)):
1408 continue
1409 rd = _discharge_r_over_d(cfg, side)
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"})
1421 else:
1422 out["warnings"].append({"level": "info", "code": f"cavitation_{k}",
1423 "text": f"{tag} orifices clear of cavitation, margin {m['margin']:.2f}: {body}"})
1424
1425
1426# =============================================================================================
1427# Plate bending
1428# =============================================================================================
1429
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``
1437 at r = a.
1438
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.
1445 """
1446 import numpy as np
1447 b = min(float(b), float(a))
1448 q = p * b * b / 4.0
1449
1450 # phi and phi' at x: (coefficients on [A, C, B, E], particular part)
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
1453
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
1456
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)
1459
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)
1462
1463 rows: List[Any] = []
1464 rhs: List[float] = []
1465
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
1470 rows.append(c)
1471 rhs.append(-v)
1472
1473 if r_hole > 0:
1474 eq((1.0, dph_i(r_hole)), (nu / r_hole, ph_i(r_hole))) # M_r(r_hole) = 0
1475 else:
1476 eq((1.0, (np.array([0.0, 1.0, 0.0, 0.0]), 0.0))) # C = 0: finite at the centre
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":
1480 eq((1.0, ph_o(a))) # no slope at the edge
1481 else:
1482 eq((1.0, dph_o(a)), (nu / a, ph_o(a))) # no moment at the edge
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)
1485 inner = rr <= b
1486 L = np.log(rr / a)
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)
1490 if np.ndim(r) == 0:
1491 return float(Mr), float(Mt)
1492 return Mr, Mt
1493
1494
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)."""
1504 import numpy as np
1505 inp = out["inputs"]
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"])
1510 req = _req_dict(cfg)
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"
1516 else:
1517 p, src = 0.0, None
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)"})
1523 return
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")}
1532
1533 r0 = max(r_hole, 1e-6)
1534 parts_r = [np.linspace(r0, a, 801)]
1535 for ch in chans.values():
1536 if ch is not None:
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))
1539 # Section left at each radius: face surface (groove) down to the channel floor, else the back
1540 # face. A thicker port hub is not credited: the moments are the uniform-t plate's.
1541 face = np.interp(R, [x for x, _ in prof], [z for _, z in prof])
1542 back = np.full_like(R, -t)
1543 in_ch = {}
1544 for k, ch in chans.items():
1545 if ch is None:
1546 continue
1547 m = (R >= ch["r_lo"] - 1e-12) & (R <= ch["r_hi"] + 1e-12)
1548 in_ch[k] = m
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"):
1552 # The section as built (or drawn): groove, channels, back grooves and gland come off it.
1553 from engine.core.injectors.plate_dxf import loop_section
1554 # At a wall the section steps: take the thinner side (the corner the ligament starts at).
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)]))
1558 Mr, Mt = plate_bending_moments(R, a=a, b=b, p=p, nu=nu, support=support, r_hole=r_hole)
1559 ok = tn > 1e-9 # no metal: a channel through the plate is flagged (pierce_*)
1560 tq = np.where(ok, tn, 1.0)
1561 # Radial: the moment crosses the thinned ring, so the ligament carries all of it.
1562 sr = 6.0 * Mr / tq ** 2
1563 # Hoop: the surrounding full plate imposes the hoop curvature kappa_t = (M_t - nu M_r) /
1564 # (D (1 - nu^2)); on a ligament of t_n that is sigma_t = nu sigma_r + 6 (M_t - nu M_r) t_n / t^3
1565 # (E cancels). At t_n = t it is 6 M_t / t^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) # Tresca
1568
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])}
1572
1573 worst = at(int(np.argmax(sig)))
1574 per_ch: Dict[str, Any] = {}
1575 for k, m in in_ch.items():
1576 if (m & ok).any():
1577 per_ch[k] = at(int(np.argmax(np.where(m, sig, -1.0))))
1578 solid = at(int(np.argmax(ok))) # the innermost radius with metal: port edge, or centre
1579
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"
1586 if face_z_at(prof, r) < 0:
1587 return "face groove"
1588 return "full section"
1589
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,
1596 }
1597 MPa = lambda v: f"{v / 1e6:.0f}" # noqa: E731
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: "
1604 + "; ".join(parts)
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)"
1607 if allow is None:
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})
1615 else:
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})
1619
1620
1621# =============================================================================================
1622# The plate as drawn
1623# =============================================================================================
1624
1625#: How far the config's ring radius may sit from the drawing's exit before the layout says the
1626#: two describe different plates [m] (a CAD dimension rounded to 0.001 in is 0.0254 mm).
1627DRAWING_RING_TOL = 0.05e-3
1628
1629#: Same, for a passage's L/d (config vs drawn length / d) [fraction].
1630DRAWING_LD_TOL = 0.02
1631
1632#: Check mode: how far the model's half-section may sit from the drawing's anywhere [m]. A
1633#: sketch dimensioned to 0.0001 in lands within ~0.003 mm; 0.025 mm (0.001 in) is a tolerance
1634#: no machinist would hold the revolve to, and far below any feature here.
1635DRAWING_PROFILE_TOL = 0.025e-3
1636
1637
1638def _drawing(plate: Mapping[str, Any]) -> Dict[str, Any]:
1639 from engine.core.injectors.plate_dxf import load_plate_profile
1640 return load_plate_profile(str(plate["profile_dxf"]))
1641
1642
1643def _apply_drawing(cfg: Any, out: Dict[str, Any]) -> None:
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."""
1648 from engine.core.injectors.plate_dxf import drilled_passages, loop_deviation
1649 inp = out["inputs"]
1650 plate = inp.get("plate") or {}
1651 if not plate.get("profile_dxf"):
1652 return
1653 prof = _drawing(plate)
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}" # noqa: E731
1662 t = float(prof["thickness"])
1663 fl = prof["_face_line"]
1664 drawn_loop = [(r, -abs(z - fl)) for r, z in prof["loop"]]
1665 summary = {
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"],
1674 }
1675
1676 if summary["mode"] == "check":
1677 # The plate is the model's; the drawing is what it is held to.
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]
1688 if h is None:
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}°"})
1691 continue
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"):
1701 bare = plate_profile(out, plate, with_port=False)["loop"]
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"
1707 if ok else
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])})")})
1711 return
1712
1713 # Face: the groove as drawn.
1714 face = out["face"]
1715 g = prof.get("_face_groove_pts")
1716 if g:
1717 fl = prof["_face_line"]
1718 pts = [(r, -abs(z - fl)) for r, z in g]
1719 if pts[0][0] > pts[-1][0]:
1720 pts = pts[::-1]
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"])
1726
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"]}
1737
1738 for k in ("O", "F"):
1739 p = out["passages"][k]
1740 h = holes.get(k)
1741 if h is None:
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"})
1745 continue
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")
1763 or "declared")
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"})
1769 if land < 0:
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")
1774 if ch is not None:
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)
1788 # The model's own checks of this passage and channel are about geometry now replaced.
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 "
1798 f"leaves on axis"})
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"})
1803
1804 out["drawing"] = summary
1805
1806
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).
1814
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."""
1818 inputs = layout_inputs_from_config(cfg)
1819 if inputs is None:
1820 return None
1821 out = compute_layout(**inputs)
1822 out["inputs"] = inputs
1823 plate = inputs.get("plate") or {}
1824 if not checks:
1825 if (plate.get("profile_dxf") and str(plate.get("profile_dxf_mode") or "geometry") == "geometry"):
1826 _apply_drawing(cfg, out)
1827 return out
1828 if out["back"]["mode"] == "channels" or plate.get("rim_gland") or plate.get("back_grooves"):
1829 out["profile"] = plate_profile(out, plate)
1830 out["ports"] = feed_ports(out, plate)
1831 _apply_drawing(cfg, out)
1833 _report_cd(cfg, out)
1834 _check_inlet_fit(out)
1835 if flows is not None:
1836 _check_manifold(cfg, out, flows)
1837 _check_cavitation(cfg, out, flows)
1838 _check_plate(cfg, out, flows)
1839 if drawings:
1840 from engine.core.injectors.drawing import injector_drawings
1841 out["drawings"] = injector_drawings(out)
1842 return out
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
T & at(const key_type &key)
Definition json.hpp:19110
bool _opt_bool(Mapping[str, Any] req, str key, bool default)
Definition layout.py:70
Dict[str, Any] assembly_envelope(Any cfg)
Definition layout.py:235
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)
Definition layout.py:1433
Dict[str, float] channel_lands(*, Dict[str, Any] ch_in, Dict[str, Any] ch_out, float r_centre_hole, float r_plate)
Definition layout.py:571
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)
Definition layout.py:499
Optional[Dict[str, Any]] _plain(Any m)
Definition layout.py:201
Optional[Dict[str, Any]] layout_inputs_from_config(Any cfg)
Definition layout.py:925
Optional[Dict[str, Any]] layout_from_config(Any cfg, bool drawings=True, Optional[Mapping[str, Any]] flows=None, *, bool checks=True)
Definition layout.py:1809
float _half_major(float d, float theta_deg)
Definition layout.py:171
Optional[Dict[str, Any]] igniter_keepouts(Optional[Mapping[str, Any]] igniter, *, float plate_thickness, float min_web)
Definition layout.py:587
Dict[str, Any] realized_land(*, float d, float theta_deg, float plate_thickness, float counterbore_dia, Optional[float] declared_land_ld)
Definition layout.py:800
Dict[str, float] channel_velocity_head(*, float flow_area, float mdot, float rho, float dp, int inlets=1, float q_frac=MANIFOLD_Q_FRAC)
Definition layout.py:541
List[Tuple[float, float]] _fillet(Tuple[float, float] a, Tuple[float, float] P, Tuple[float, float] b, float rho, int n=6)
Definition layout.py:624
Optional[float] _opt_float(Mapping[str, Any] req, str key)
Definition layout.py:60
Any effective_discharge(Any engine_config, str side)
Definition layout.py:823
float _sin(float th)
Definition layout.py:223
List[Tuple[float, float]] _rounded(List[Tuple[float, float]] ps, List[float] radii)
Definition layout.py:645
float face_z_at(List[Tuple[float, float]] profile, float r)
Definition layout.py:360
float _cos(float th)
Definition layout.py:227
float _f(Any v, float default=0.0)
Definition layout.py:184
None _check_manifold(Any cfg, Dict[str, Any] out, Mapping[str, Any] flows)
Definition layout.py:1343
None _report_cd(Any cfg, Dict[str, Any] out)
Definition layout.py:879
None _check_plate(Any cfg, Dict[str, Any] out, Optional[Mapping[str, Any]] flows)
Definition layout.py:1495
Any _get(Any m, str key)
Definition layout.py:176
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)
Definition layout.py:407
Dict[str, Any] plate_profile(Mapping[str, Any] out, Mapping[str, Any] plate, *, bool with_port=True)
Definition layout.py:655
None _check_cavitation(Any cfg, Dict[str, Any] out, Mapping[str, Any] flows)
Definition layout.py:1394
Dict[str, Any] _req_dict(Any cfg)
Definition layout.py:212
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)
Definition layout.py:383
float exit_recess(*, float d_in, float th_in, float d_out, float th_out, float exit_land)
Definition layout.py:272
Dict[str, float] _stream(Any raw)
Definition layout.py:192
Dict[str, Any] _plate_block(Any cfg)
Definition layout.py:819
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")
Definition layout.py:303
LdBand impingement_ld_band(Optional[Mapping[str, Any]] requirements)
Definition layout.py:84
Dict[str, Any] _drawing(Mapping[str, Any] plate)
Definition layout.py:1638
None _apply_drawing(Any cfg, Dict[str, Any] out)
Definition layout.py:1643
Optional[Dict[str, Any]] feed_ports(Mapping[str, Any] out, Mapping[str, Any] plate)
Definition layout.py:739
None _check_inlet_fit(Dict[str, Any] out)
Definition layout.py:851
float _tan(float th)
Definition layout.py:219
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)
Definition layout.py:287
Any _discharge_as_built(Any dc, Mapping[str, Any] passage)
Definition layout.py:843
Dict[str, float] flows_from_result(Mapping[str, Any] result, Any cfg)
Definition layout.py:1320
Tuple[float, float, float] _poly_area_centroid_r(List[Tuple[float, float]] ps)
Definition layout.py:486
float _discharge_r_over_d(Any cfg, str side)
Definition layout.py:1383
None _check_back_features(Dict[str, Any] out)
Definition layout.py:765
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)
Definition layout.py:993