STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
design_audit.py
Go to the documentation of this file.
1"""Audit a design against EVERY limit its own config declares, with NO gate slack forgiven.
2
3 python3 scripts/design_audit.py configs/ethalox_8kN_SHIP.yaml [more.yaml ...]
4
5Layer 1's own gates carry tolerances -- the spray-tilt gate forgives
6``layer1_resultant_tilt_gate_tol_deg`` (shipped default 1.0 deg) and the O/F gate forgives
715 %. Those exist so a search sitting on a boundary is not thrown away, and they are
8reasonable there. They are NOT reasonable in a sign-off: measured, three candidates reported
9ALL GATES PASS while exceeding their own declared tilt allowance, because 1.0 deg of slack is
10most of the margin the allowance exists to create. This re-checks every limit at face value.
11
12Exits non-zero if any design fails, so it can gate a commit.
13"""
14import sys, copy, math, yaml
15sys.path.insert(0, str(__import__('pathlib').Path(__file__).resolve().parents[1]))
16from engine.pipeline.io import load_config
17from engine.core.runner import PintleEngineRunner
18from engine.core.injectors.layout import impingement_ld_band, layout_from_config, flows_from_result
20 _impinging_resultant_tilt_deg, _resultant_tilt_breakeven_deg,
21 _resolve_tilt_allowance_deg, _impinging_face_infeasibility_terms,
22 _layer1_centre_clear_m, _layer1_plate, _layer1_declared_limit_gates,
23 _layer1_momentum_gate,
24 _LAYER1_DEFAULT_DP_O_BAND, _LAYER1_DEFAULT_DP_F_BAND)
25from engine.core.injectors.layout import EXIT_LAND_DEFAULT
26
27
28def _opt(rq, key, default=None):
29 """A requirement as a float, or ``default`` when the config leaves it unset."""
30 v = rq.get(key)
31 return default if v is None else float(v)
32
33
34def _flag(rq, key, default):
35 v = rq.get(key)
36 if v is None:
37 return default
38 if isinstance(v, str):
39 return v.strip().lower() in ("true", "1", "yes", "on")
40 return bool(v)
41
42
43def audit(f):
44 """Every limit is the one THIS config declares (Layer 1's default where it declares none).
45
46 An earlier version hardcoded the SHIP numbers -- densities 1140/789, dP/Pc 0.20-0.40,
47 n <= 30, 90 deg, 40 deg -- so it audited every other design against SHIP's requirements.
48 A limit the config does not declare and Layer 1 has no default for is skipped and said so.
49 """
50 c = load_config(f); Y = yaml.safe_load(open(f))
51 g = Y['injector']['geometry']; cg = Y['chamber_geometry']; rq = Y.get('design_requirements') or {}
52 run = PintleEngineRunner(copy.deepcopy(c))
53 PO = c.lox_tank.initial_pressure_psi*6894.757; PF = c.fuel_tank.initial_pressure_psi*6894.757
54 r = run.evaluate(PO, PF, silent=True)
55 O, F = g['oxidizer'], g['fuel']; n = int(O['n_elements'])
56 thO, thF = float(O['impingement_angle']), float(F['impingement_angle'])
57 bore = cg['chamber_diameter']; Lch = cg['length_cylindrical']+cg['length_contraction']
58 rho_O = float(c.fluids['oxidizer'].density); rho_F = float(c.fluids['fuel'].density)
59 tilt = _impinging_resultant_tilt_deg(r['mdot_O'], r['mdot_F'], rho_O, rho_F, n,
60 O['d_jet'], F['d_jet'], thO, thF)
61 be = _resultant_tilt_breakeven_deg(n_elements=n, spacing_O_m=O['spacing'],
62 spacing_F_m=F['spacing'], angle_O_deg=thO,
63 angle_F_deg=thF, D_chamber_inner_m=bore, L_chamber_m=Lch)
64 allow = _resolve_tilt_allowance_deg(
65 from_reach=_flag(rq, 'layer1_resultant_tilt_from_reach', False),
66 constant_deg=_opt(rq, 'layer1_resultant_tilt_max_deg', 0.0),
67 breakeven_deg=be, margin=_opt(rq, 'layer1_resultant_tilt_reach_margin', 1.5))
68 plate = _layer1_plate(c)
69 contoured = plate.get('face') == 'contoured'
70 # A contoured face has every exit square to its flank: no drill-incidence limit applies.
71 inc_min = 0.0 if contoured else _opt(rq, 'layer1_injector_min_face_incidence_deg', 0.0)
72 face = _impinging_face_infeasibility_terms(n_elements=float(n),
73 spacing_O_m=O['spacing'], spacing_F_m=F['spacing'], d_jet_O_m=O['d_jet'],
74 d_jet_F_m=F['d_jet'], D_chamber_inner_m=bore,
75 angle_O_deg=thO, angle_F_deg=thF,
76 center_clear_dia_m=_layer1_centre_clear_m(c, rq),
77 min_web_m=_opt(rq, 'layer1_injector_min_web_m', 0.0),
78 wall_clearance_m=_opt(rq, 'layer1_injector_wall_clearance_m', 0.0),
79 spray_radius_frac=_opt(rq, 'layer1_injector_spray_radius_frac', 0.0),
80 spray_radius_tol=_opt(rq, 'layer1_injector_spray_radius_tol', 0.08),
81 min_face_incidence_deg=inc_min, face_contoured=contoured,
82 exit_land=plate.get('exit_land') if plate.get('exit_land') is not None else EXIT_LAND_DEFAULT)
83 # The band is injector dP / Pc (schema: injector_dp_ratio_*), across the orifices. Tank minus
84 # Pc also carries the feed line and the manifold dump loss.
85 _d = r['diagnostics']
86 dpo, dpf = _d['delta_p_injector_O']/r['Pc'], _d['delta_p_injector_F']/r['Pc']
87 dpo_lo = _opt(rq, 'injector_dp_ratio_O_min', _LAYER1_DEFAULT_DP_O_BAND[0])
88 dpo_hi = _opt(rq, 'injector_dp_ratio_O_max', _LAYER1_DEFAULT_DP_O_BAND[1])
89 dpf_lo = _opt(rq, 'injector_dp_ratio_F_min', _LAYER1_DEFAULT_DP_F_BAND[0])
90 dpf_hi = _opt(rq, 'injector_dp_ratio_F_max', _LAYER1_DEFAULT_DP_F_BAND[1])
91 # Targets come from the config being audited, not from whatever design happened to be
92 # current when this script was written. Hardcoding 8000 N here made every 6405 N
93 # candidate report FAILS: thrust, which is the tool being stale, not the design.
94 F_tgt = float(rq.get('target_thrust') or 0.0)
95 OF_tgt = float(rq.get('optimal_of_ratio') or 0.0)
96 band = impingement_ld_band(rq)
97 tan_sum = math.tan(math.radians(thO)) + math.tan(math.radians(thF))
98 L_imp = 0.5 * abs(n*O['spacing'] - n*F['spacing']) / math.pi / tan_sum
99 ld = L_imp / (0.5*(O['d_jet'] + F['d_jet']))
100 ld_eps = 1e-6 * max(1.0, band.hi) # arithmetic noise only -- this is a no-slack audit
101 # The solve's flows, so the layout also runs its manifold velocity-head, orifice cavitation
102 # and (at the solved Pc) plate-bending checks.
103 lay = layout_from_config(Y, flows=flows_from_result(r, c))
104 back_web_req = _opt(rq, 'layer1_injector_min_back_web_m')
105 channels = lay['back']['mode'] == 'channels'
106 back_web = min(lay['passages']['O']['back_web'], lay['passages']['F']['back_web'])
107 # With channels the propellants are separated by the land between the channels; with a
108 # plenum back, by the land between the two rings' entries.
109 o_f_land = lay['back']['lands']['between'] if channels else lay['back']['o_f_land']
110 if channels:
111 back_web_req = None # the passages open into a channel, not onto a web
112 n_max = _opt(rq, 'layer1_impinging_n_doublets_max')
113 inc_lo = _opt(rq, 'layer1_impinging_angle_deg_min')
114 inc_hi = _opt(rq, 'layer1_impinging_angle_deg_max')
115 jet_lo = _opt(rq, 'layer1_impinging_jet_angle_min_deg')
116 SKIP = None
117 # Layer 1's own sign-off gates (element pitch, L_cyl/D, face-to-exit length, SP-8089 free
118 # jet), the same function validation uses, at face value. The only constant those four read
119 # is the convergent half-angle (chamber lengths). Its injector_face / injector_back_face
120 # gates read Layer 1's curated plate constants, which this script does not rebuild: given
121 # none they would run as a flat face on a plenum, so only the four above are read -- the face terms
122 # above and the layout (declared plate) are what check the injector.
123 gates = _layer1_declared_limit_gates(
124 c, rq, {'layer1_contraction_half_angle_deg': rq.get('layer1_contraction_half_angle_deg')})
125 R = float((r.get('diagnostics') or {}).get('momentum_ratio_R', float('nan')))
126 r_lo, r_hi = _opt(rq, 'impinging_momentum_R_min'), _opt(rq, 'impinging_momentum_R_max')
127 checks = [
128 (f"thrust {F_tgt:.0f} +/-2%",
129 F_tgt > 0 and abs(r['F']-F_tgt)/F_tgt <= 0.02,
130 f"{r['F']:.1f} N" + ("" if F_tgt > 0 else " (no target_thrust in config)")),
131 (f"O/F {OF_tgt:.3f} +/-5%",
132 OF_tgt > 0 and abs(r['MR']-OF_tgt)/OF_tgt <= 0.05,
133 f"{r['MR']:.4f}" + ("" if OF_tgt > 0 else " (no optimal_of_ratio in config)")),
134 (f"dP/Pc O in [{dpo_lo:g},{dpo_hi:g}]", dpo_lo <= dpo <= dpo_hi, f"{dpo:.3f}"),
135 (f"dP/Pc F in [{dpf_lo:g},{dpf_hi:g}]", dpf_lo <= dpf <= dpf_hi, f"{dpf:.3f}"),
136 (f"n <= {n_max:g}" if n_max else "n <= (unset)",
137 SKIP if n_max is None else n <= n_max, f"{n}"),
138 (f"included in [{inc_lo or 0:g},{inc_hi or 180:g}]",
139 SKIP if inc_lo is None and inc_hi is None
140 else (inc_lo or 0.0) <= thO + thF <= (inc_hi or 180.0), f"{thO+thF:.1f} deg"),
141 (f"jet >= {jet_lo:g} deg" if jet_lo else "jet >= (unset)",
142 SKIP if jet_lo is None else min(thO, thF) >= jet_lo, f"{thO:.1f}/{thF:.1f}"),
143 (f"incidence >= {inc_min:g} deg" if not contoured else "incidence (contoured face: square exits)",
144 SKIP if not inc_min else 90 - max(thO, thF) >= inc_min, f"{90-max(thO, thF):.1f} deg"),
145 (f"standoff L/d in [{band.lo:g},{band.hi:g}]",
146 band.lo - ld_eps <= ld <= band.hi + ld_eps, f"{ld:.4f}"),
147 ("face limits all met", face <= 1e-12, f"{face:.2e}"),
148 (f"back-face web >= {back_web_req * 1000:g} mm" if back_web_req else "back-face web >= (unset)",
149 SKIP if not back_web_req else back_web >= back_web_req, f"{back_web * 1000:.2f} mm"),
150 ("LOX/fuel entries separate", o_f_land >= 0.0, f"{o_f_land * 1000:.2f} mm land"),
151 ("tilt <= allowed (NO slack)", tilt <= allow, f"{tilt:+.3f} vs {allow:.3f}"),
152 (f"momentum R in [{r_lo:g},{r_hi:g}]" if r_lo is not None and r_hi is not None
153 else "momentum R band (unset)",
154 SKIP if r_lo is None or r_hi is None else _layer1_momentum_gate(R, r_lo, r_hi)[0],
155 f"{R:.4f}"),
156 *[(f"L1 gate: {k}", ok, msg) for k, (ok, msg) in gates.items()
157 if k in ("element_pitch", "lcyl_over_d", "engine_length", "free_jet")],
158 ("layout: nothing impossible",
159 not [w for w in lay['warnings'] if w['level'] == 'bad'],
160 "; ".join(w['code'] for w in lay['warnings'] if w['level'] == 'bad') or "none"),
161 ]
162 bad = [c for c in checks if c[1] is False]
163 skipped = [c[0] for c in checks if c[1] is None]
164 print(f"{f.split('/')[-1]:34s} n={n:2d} Isp {r['Isp']:.2f} O/F {r['MR']:.4f} "
165 f"{'CLEAN' if not bad else 'FAILS: ' + ', '.join(c[0] for c in bad)}")
166 for name, ok, val in checks:
167 if ok is False: print(f" {name:30s} {val}")
168 if skipped:
169 print(f" not declared, not checked: {', '.join(skipped)}")
170 # The drawing's own findings, so a sign-off sees what the Geometry tab would show.
171 for w in lay['warnings']:
172 print(f" drawing {w['level']:4s}: {w['text']}")
173 return not bad
174
175
176if __name__ == '__main__':
177 ok = all([audit(f) for f in sys.argv[1:]])
178 sys.exit(0 if ok else 1)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
_flag(rq, key, default)
_opt(rq, key, default=None)