STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
diagnostics.py
Go to the documentation of this file.
1"""Assemble the injector diagnostics dict from Numba's own solve outputs.
2
3Replaces the second, C-side injector solve that the Numba path used to make for
4diagnostics alone (native_injector._nat().injector_solve + _result_to_diag). The
5physics was always there -- injector_solve computed these values and discarded
6them -- so this is plumbing, not new physics.
7
8Everything is derived from the flat param vector plus the solve tuple; no config
9object is needed, which is what keeps this callable from inside a worker without
10re-parsing YAML.
11
12OMISSIONS. C's _result_to_diag also emits J, TMR, theta and
13feed_orifice_coupling_iterations; no reader for those exists anywhere, and they
14are impinging spray quantities the accelerated path never needs.
15
16A_eff_O/F and the turbulence block ARE emitted, after an initial omission proved
17wrong: A_eff is asserted present (not merely derivable) by
18tests/test_flow_capacity_effective_area.py, and turbulence_intensity_mix reaches
19chamber_solver.py:180 through *closure* diagnostics -- i.e. the accel.solve path,
20which is distinct from the accel.evaluate path the first analysis looked at.
21"""
22from __future__ import annotations
23
24import math
25
26from engine.accel.params import _IDX
27
28_DJO, _DJF = _IDX["DJO"], _IDX["DJF"]
29_NO, _NF = _IDX["NO"], _IDX["NF"]
30_RHO_O, _RHO_F = _IDX["RHO_O"], _IDX["RHO_F"]
31_ANG_O, _ANG_F = _IDX["ANG_O"], _IDX["ANG_F"]
32_MU_O, _MU_F = _IDX["MU_O"], _IDX["MU_F"]
33_INJ_TYPE = _IDX["INJ_TYPE"]
34_PIN_DHO, _PIN_DHF = _IDX["PIN_DHO"], _IDX["PIN_DHF"]
35
36
37def _impinging_spray_extras(config, P, sol, Pc):
38 """The spray/doublet diagnostics impinging.py publishes that the kernel tuple does not carry.
39
40 Layer 1 reads D_pitch_*, element_gap_*, L_imp and vaporization_length_total out of the
41 diagnostics (its impinging geometry-fit term and final report), and on the DEFAULT path those
42 diagnostics are this dict (closure.flows -> accel.solve). Without these keys the accelerated
43 path reported None where ED_ACCEL=off reported numbers. Computed from the solved state with
44 the SAME helpers, in the same order, as ImpingingInjector.solve -- not re-derived.
45 """
46 import numpy as np
47
48 from engine.core.injectors.impinging import impingement_standoff_m
49 from engine.core.spray import (
50 check_spray_constraints, evaporation_constant_m2_s, momentum_flux_ratio,
51 ohnesorge_number, spray_angle_from_J, spray_angle_from_TMR, tau_evap, tau_evap_from_k,
52 thrust_momentum_ratio,
53 )
54 (_ok, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, _mom_R, _Cd_O, _Cd_F,
55 _Pi_O, _Pi_F, _dpi_O, _dpi_F, _A_O, _A_F, _dpf_O, _dpf_F, We_O, We_F, u_rel, x_star,
56 _cok, _n_iter, _ti_O, _ti_F) = sol[:26]
57 geometry = config.injector.geometry
58 spray_cfg = config.spray
59 fluids = config.fluids
60 rho_O, mu_O, sigma_O = fluids["oxidizer"].density, fluids["oxidizer"].viscosity, fluids["oxidizer"].surface_tension
61 rho_F, mu_F, sigma_F = fluids["fuel"].density, fluids["fuel"].viscosity, fluids["fuel"].surface_tension
62
63 rho_gas = float(max(Pc / (spray_cfg.smd.chamber_gas_R * spray_cfg.smd.chamber_gas_T), 1e-6))
64 J = momentum_flux_ratio(rho_O, u_O, rho_F, u_F)
65 MR = mdot_O / mdot_F if mdot_F > 0 else np.inf
66 TMR = thrust_momentum_ratio(J, MR)
67 if spray_cfg.spray_angle.model == "J":
68 theta = spray_angle_from_J(J, spray_cfg.spray_angle.k, spray_cfg.spray_angle.n)
69 else:
70 theta = spray_angle_from_TMR(TMR)
71 Oh_O = ohnesorge_number(mu_O, rho_O, sigma_O, geometry.oxidizer.d_jet)
72 Oh_F = ohnesorge_number(mu_F, rho_F, sigma_F, geometry.fuel.d_jet)
73
74 _ev = spray_cfg.evaporation
75 k_evap_O = k_evap_F = float("nan")
76 if getattr(_ev, "model", "derived") == "derived":
77 k_evap_O = evaporation_constant_m2_s(
78 Tc=spray_cfg.smd.chamber_gas_T, Pc=Pc, rho_g=rho_gas, rho_l=rho_O,
79 L_vap=float(getattr(fluids["oxidizer"], "latent_heat", 0.0) or 0.0),
80 T_boil=float(getattr(fluids["oxidizer"], "boiling_point", 0.0) or 0.0),
81 cp_g=float(getattr(_ev, "cp_gas", 2200.0)), C_evap=float(getattr(_ev, "C_evap", 1.562)))
82 k_evap_F = evaporation_constant_m2_s(
83 Tc=spray_cfg.smd.chamber_gas_T, Pc=Pc, rho_g=rho_gas, rho_l=rho_F,
84 L_vap=float(getattr(fluids["fuel"], "latent_heat", 0.0) or 0.0),
85 T_boil=float(getattr(fluids["fuel"], "boiling_point", 0.0) or 0.0),
86 cp_g=float(getattr(_ev, "cp_gas", 2200.0)), C_evap=float(getattr(_ev, "C_evap", 1.562)))
87 if np.isfinite(k_evap_O) and np.isfinite(k_evap_F):
88 tau_evap_O = tau_evap_from_k(D32_O, k_evap_O)
89 tau_evap_F = tau_evap_from_k(D32_F, k_evap_F)
90 else:
91 tau_evap_O = tau_evap(D32_O, _ev.K)
92 tau_evap_F = tau_evap(D32_F, _ev.K)
93
94 s_O = float(getattr(geometry.oxidizer, "spacing", 0.0) or 0.0)
95 s_F = float(getattr(geometry.fuel, "spacing", 0.0) or 0.0)
96 L_imp = impingement_standoff_m(
97 0.5 * (geometry.oxidizer.n_elements + geometry.fuel.n_elements), s_O, s_F,
98 geometry.oxidizer.impingement_angle, geometry.fuel.impingement_angle)
99 D_pitch_O = float(max(1, int(geometry.oxidizer.n_elements)) * s_O / np.pi)
100 D_pitch_F = float(max(1, int(geometry.fuel.n_elements)) * s_F / np.pi)
101 _d_avg = 0.5 * (float(geometry.oxidizer.d_jet) + float(geometry.fuel.d_jet))
102 _rho_l_avg = 0.5 * (float(rho_O) + float(rho_F))
103 if np.isfinite(L_imp) and L_imp > 0 and _d_avg > 0 and rho_gas > 1e-4:
104 L_b = float((_d_avg ** 2) / (4.0 * L_imp) * np.sqrt(_rho_l_avg / rho_gas))
105 L_b = float(np.clip(L_b, 0.0, 20.0 * _d_avg))
106 else:
107 L_b = 0.0
108 _cok2, violations = check_spray_constraints(We_O, We_F, x_star, spray_cfg)
109 return {
110 "violations": violations, "J": J, "TMR": TMR, "theta": theta,
111 "rho_gas_breakup": float(rho_gas), "Oh_O": float(Oh_O), "Oh_F": float(Oh_F),
112 "k_evap_O": float(k_evap_O), "k_evap_F": float(k_evap_F),
113 "tau_evap_O": float(tau_evap_O), "tau_evap_F": float(tau_evap_F),
114 "L_imp": L_imp, "L_sheet_breakup": float(L_b),
115 "D_pitch_O": D_pitch_O, "D_pitch_F": D_pitch_F, "s_pair": float(0.5 * (s_O + s_F)),
116 "element_gap_O": float(s_O - geometry.oxidizer.d_jet),
117 "element_gap_F": float(s_F - geometry.fuel.d_jet),
118 "vaporization_length_total": float((L_imp if np.isfinite(L_imp) else 0.0) + L_b + x_star),
119 "apply_tau_res_correction": bool(getattr(_ev, "apply_tau_res_correction", False)),
120 "breakup_multiplier": 1.0, "penetration_multiplier": 1.0,
121 }
122
123
124def build_diag(P, sol, config=None, Pc=None):
125 """Mirror native_injector._result_to_diag using Numba's injector_solve tuple.
126
127 `sol` is the 24-tuple from kernels.injector_solve. With `config` and `Pc` (every caller in
128 engine.accel passes them), an impinging result also carries the spray/doublet keys
129 ImpingingInjector.solve publishes (see _impinging_spray_extras).
130 """
131 (_ok, mdot_O, mdot_F, u_O, u_F, D32_O, D32_F, mom_R, Cd_O, Cd_F,
132 Pi_O, Pi_F, dpi_O, dpi_F, A_geom_O, A_geom_F,
133 dpf_O, dpf_F, We_O, We_F, u_rel, x_star, constraints_ok, n_iter,
134 ti_O, ti_F) = sol[:26]
135 holes_O, holes_F = (sol[26], sol[27]) if len(sol) > 26 else ((), ())
136
137 djo, djf = float(P[_DJO]), float(P[_DJF])
138 rho_O, rho_F = float(P[_RHO_O]), float(P[_RHO_F])
139 n_O, n_F = max(1, int(P[_NO])), max(1, int(P[_NF]))
140
141 mdot_bn_O = Cd_O * A_geom_O * math.sqrt(2.0 * rho_O * dpi_O) if dpi_O > 0 else 0.0
142 mdot_bn_F = Cd_F * A_geom_F * math.sqrt(2.0 * rho_F * dpi_F) if dpi_F > 0 else 0.0
143
144 # Effective flow areas. flow_capacity.effective_flow_areas_from_cd recomputes
145 # these downstream, but tests and consumers require them PRESENT on the result,
146 # so emit them here exactly as Cd * A_geom.
147 A_eff_O = Cd_O * A_geom_O
148 A_eff_F = Cd_F * A_geom_F
149
150 # Shear-layer turbulence, mirroring impinging.py:155-172 / pintle.py:218-228.
151 # The characteristic length is the HYDRAULIC diameter, which for impinging
152 # happens to equal d_jet (impinging.py:117-118) but for pintle is a separate
153 # geometry field -- using d_jet there gives Re=0 and a flat 0.1.
154 # Consumed via closure diagnostics at chamber_solver.py:180, i.e. the
155 # accel.solve path rather than accel.evaluate.
156 # ti_O/ti_F come from the solve rather than being recomputed here: pintle
157 # derives them from PRE-update Reynolds numbers while weighting with
158 # POST-update velocities, which cannot be reconstructed from the outputs.
159 if float(P[_INJ_TYPE]) == 0.0: # pintle
160 dh_O, dh_F = float(P[_PIN_DHO]), float(P[_PIN_DHF])
161 else:
162 dh_O, dh_F = djo, djf
163 v_tot = max(u_O + u_F, 1e-6)
164 ti_mix = min(max((ti_O * u_O + ti_F * u_F) / v_tot, 0.02), 0.35)
165
166 diag = {
167 "injector_type": "impinging",
168 "A_eff_O": A_eff_O, "A_eff_F": A_eff_F,
169 "turbulence_intensity_O": ti_O, "turbulence_intensity_F": ti_F,
170 "turbulence_length_O": 0.07 * dh_O, "turbulence_length_F": 0.07 * dh_F,
171 "turbulence_intensity_mix": ti_mix,
172 "iterations": int(n_iter),
173 "constraints_satisfied": bool(constraints_ok),
174 "We_O": We_O, "We_F": We_F,
175 "D32_O": D32_O, "D32_F": D32_F,
176 "x_star": x_star, "u_rel": u_rel, "V_rel": u_rel,
177 "u_O": u_O, "u_F": u_F,
178 "Cd_O": Cd_O, "Cd_F": Cd_F,
179 "P_injector_O": Pi_O, "P_injector_F": Pi_F,
180 "delta_p_injector_O": dpi_O, "delta_p_injector_F": dpi_F,
181 "delta_p_feed_O": dpf_O, "delta_p_feed_F": dpf_F,
182 "mdot_from_bernoulli_O": mdot_bn_O, "mdot_from_bernoulli_F": mdot_bn_F,
183 "A_geom_O": A_geom_O, "A_geom_F": A_geom_F,
184 "A_jet_O": math.pi * (djo / 2.0) ** 2, "A_jet_F": math.pi * (djf / 2.0) ** 2,
185 "d_jet_O": djo, "d_jet_F": djf,
186 "momentum_ratio_n_elements_O": n_O, "momentum_ratio_n_elements_F": n_F,
187 "rho_O_momentum": rho_O, "rho_F_momentum": rho_F,
188 "MR": (mdot_O / mdot_F) if mdot_F > 0 else float("nan"),
189 }
190 # v_*_bulk = mdot / (rho * n_elements * A_jet); A_geom IS n_elements*A_jet
191 # (impinging.py:58). Conditionally included exactly as _result_to_diag does.
192 for tag, mdot, rho, area in (("O", mdot_O, rho_O, A_geom_O),
193 ("F", mdot_F, rho_F, A_geom_F)):
194 if rho > 0 and area > 0:
195 v_bulk = mdot / (rho * area)
196 if math.isfinite(v_bulk):
197 diag[f"v_{tag}_bulk"] = v_bulk
198
199 # Same conditional inclusion as _result_to_diag: absent, not NaN, when invalid.
200 if math.isfinite(mom_R) and mom_R > 0:
201 diag["momentum_ratio_R"] = mom_R
202 if float(P[_INJ_TYPE]) != 0.0: # impinging only, as impinging.py publishes it
203 # Rupe's mixing ratio M = rho_O v_O^2 d_O / (rho_F v_F^2 d_F) on the same bulk velocities
204 # (impinging.rupe_mixing_ratio); absent, not NaN, when invalid.
205 v_Ob = mdot_O / (rho_O * A_geom_O) if (rho_O > 0 and A_geom_O > 0) else float("nan")
206 v_Fb = mdot_F / (rho_F * A_geom_F) if (rho_F > 0 and A_geom_F > 0) else float("nan")
207 den = rho_F * v_Fb ** 2 * djf
208 if math.isfinite(den) and den > 0 and math.isfinite(v_Ob) and rho_O > 0 and djo > 0:
209 rupe_M = rho_O * v_Ob ** 2 * djo / den
210 if math.isfinite(rupe_M) and rupe_M > 0:
211 diag["rupe_M"] = rupe_M
212 # Momentum-weighted axial velocity of the collided pair (spray.spray_axial_velocity),
213 # u_rel when that is not positive -- the transport velocity the spray march starts from.
214 mt = mdot_O + mdot_F
215 u_ax = ((mdot_O * u_O * math.cos(math.radians(float(P[_ANG_O])))
216 + mdot_F * u_F * math.cos(math.radians(float(P[_ANG_F])))) / mt) if mt > 0 else float("nan")
217 diag["u_axial_spray"] = u_ax if (math.isfinite(u_ax) and u_ax > 0) else float(u_rel)
218 if float(P[_INJ_TYPE]) != 0.0:
219 # Same included-angle convention as impinging.py: separation = theta_O + theta_F. The
220 # pintle publishes none (the c* model then takes the drops as axial), so neither does this.
221 imp_sep = float(P[_ANG_O]) + float(P[_ANG_F])
222 diag["impingement_angle_deg"] = max(1.0, min(179.0, imp_sep))
223 diag.update(_manifold_diagnostics(P, holes_O, holes_F, mdot_O, mdot_F, dpi_O, dpi_F))
224 if config is not None and Pc is not None and float(P[_INJ_TYPE]) != 0.0:
225 diag.update(_impinging_spray_extras(config, P, sol, float(Pc)))
226 return diag
227
228
229def _manifold_diagnostics(P, holes_O, holes_F, mdot_O, mdot_F, dpi_O, dpi_F):
230 """impinging._manifold_diagnostics from the kernel's per-hole flows."""
231 if len(holes_O) == 0 and len(holes_F) == 0:
232 return {"manifold_model": "plenum"}
233 out = {"manifold_model": "ring_network"}
234 per = {}
235 for k, holes, md, dpi, rho, d, n in (
236 ("O", holes_O, mdot_O, dpi_O, float(P[_RHO_O]), float(P[_DJO]), float(P[_NO])),
237 ("F", holes_F, mdot_F, dpi_F, float(P[_RHO_F]), float(P[_DJF]), float(P[_NF]))):
238 if len(holes) == 0:
239 continue
240 b = _IDX[f"NET_{k}"]
241 n_ports, scale, A_ch = float(P[b + 1]), float(P[b + 7]), float(P[b + 3])
242 hs = [float(q) for q in holes]
243 mean = md / (2.0 * n_ports * scale * len(hs)) if md > 0 else float("nan")
244 per[k] = hs
245 out[f"element_mass_flows_{k}"] = hs
246 out[f"element_flow_ratio_min_{k}"] = float(min(hs) / mean) if mean > 0 else float("nan")
247 out[f"element_flow_ratio_max_{k}"] = float(max(hs) / mean) if mean > 0 else float("nan")
248 out[f"manifold_branch_velocity_{k}"] = float(md / (2.0 * n_ports) / (rho * A_ch))
249 out[f"manifold_ports_{k}"] = int(n_ports)
250 A = n * math.pi * d ** 2 / 4.0
251 out[f"Cd_eff_manifold_{k}"] = (float(md / (A * math.sqrt(2.0 * rho * dpi)))
252 if dpi > 0 and md > 0 else float("nan"))
253 if "O" in per and "F" in per and len(per["O"]) == len(per["F"]):
254 mrs = [o / f if f > 0 else float("inf") for o, f in zip(per["O"], per["F"])]
255 out["element_mixture_ratios"] = mrs
256 out["element_mass_flows"] = [o + f for o, f in zip(per["O"], per["F"])]
257 out["element_mixture_ratio_min"] = float(min(mrs))
258 out["element_mixture_ratio_max"] = float(max(mrs))
259 return out
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
_manifold_diagnostics(P, holes_O, holes_F, mdot_O, mdot_F, dpi_O, dpi_F)
_impinging_spray_extras(config, P, sol, Pc)
build_diag(P, sol, config=None, Pc=None)