1"""Injector-type physics bindings — single source of truth (UNIFICATION_PLAN Phase 2b, decision D4).
3Audited reality (2026-06-11), which these bindings encode rather than invent:
4 * SMD: ``PintleInjector.solve`` hard-binds ``smd_pintle`` (``spray.smd.model`` is IGNORED on the
5 pintle path); ``ImpingingInjector.solve`` hard-binds ``smd_impinging_tn4222`` (``spray.smd.model``
6 is IGNORED — legacy lefebvre on doublets yielded bogus ~1 µm D32).
7 * Geometry-Cd (``cd_inf_from_orifice_diameter``): called only on the impinging path; pintle keeps
8 fixed ``Cd_inf`` semantics (this is WHY anchor A stayed bit-compatible with main).
9 * Layer-1 design vector: pintle 10 DOF (integer dim [6]); impinging 13 DOF (integer dim [4]).
11``validate_config_bindings`` flags physically-incoherent combos LOUDLY (log warnings at load time)
12instead of letting them run silently — the hardcoded-Cd bug class. It does not mutate the config:
13behavior changes only happen explicitly in Phase 3 with anchor verification.
16from __future__
import annotations
18from dataclasses
import dataclass
19from typing
import Any, List, Optional
22@dataclass(frozen=True)
25 expected_smd_model: Optional[str]
26 smd_knob_ignored: bool
27 expected_use_geometry_cd: bool
28 layer1_dof: Optional[int]
29 layer1_integer_dims: Optional[List[int]]
34 injector_type=
"pintle",
35 expected_smd_model=
None, smd_knob_ignored=
True,
36 expected_use_geometry_cd=
False,
37 layer1_dof=10, layer1_integer_dims=[6],
40 injector_type=
"impinging",
41 expected_smd_model=
"ingebo", smd_knob_ignored=
True,
42 expected_use_geometry_cd=
True,
43 layer1_dof=13, layer1_integer_dims=[4],
46 injector_type=
"coaxial",
47 expected_smd_model=
None, smd_knob_ignored=
False,
48 expected_use_geometry_cd=
False,
49 layer1_dof=
None, layer1_integer_dims=
None,
56 return INJECTOR_BINDINGS[str(injector_type)]
59 f
"Unknown injector type {injector_type!r}; known: {sorted(INJECTOR_BINDINGS)}"
67INJECTOR_GEOMETRY_TEMPLATES = {
69 "lox": {
"n_orifices": 14,
"d_orifice": 0.00272,
"theta_orifice": 90.0,
70 "A_entry": 5.81e-06,
"d_hydraulic": 0.00272},
71 "fuel": {
"d_pintle_tip": 0.028194,
"d_reservoir_inner": 0.0292862,
"h_gap": 0.0005461,
72 "A_entry": 0.000624314,
"d_hydraulic": 0.0010922},
80 "oxidizer": {
"n_elements": 20,
"d_jet": 0.002,
"impingement_angle": 50.0,
"spacing": 0.006},
81 "fuel": {
"n_elements": 20,
"d_jet": 0.002,
"impingement_angle": 60.0,
"spacing": 0.006},
87 """Default injector.geometry block for a freshly-switched injector type."""
88 t = str(injector_type)
89 if t
not in INJECTOR_GEOMETRY_TEMPLATES:
90 raise ValueError(f
"No geometry template for injector type {t!r}; "
91 f
"known: {sorted(INJECTOR_GEOMETRY_TEMPLATES)}")
93 return copy.deepcopy(INJECTOR_GEOMETRY_TEMPLATES[t])
101_GEOMETRY_CD_PARAMS = {
102 "d_ref_m": 0.002,
"cd_small_hole_exponent": 0.20,
"cd_large_hole_log_gain": 0.015,
103 "cd_inf_max": 0.62,
"cd_inf_min_geom": 0.48,
"d_min_m": 0.0004,
105INJECTOR_DISCHARGE_BASELINES = {
107 "oxidizer": {
"Cd_inf": 0.40,
"a_Re": 0.15,
"Cd_min": 0.15,
"use_geometry_cd":
False},
108 "fuel": {
"Cd_inf": 0.65,
"a_Re": 0.20,
"Cd_min": 0.20,
"use_geometry_cd":
False},
111 "oxidizer": {
"Cd_inf": 0.60,
"a_Re": 0.18,
"Cd_min": 0.35,
"use_geometry_cd":
True, **_GEOMETRY_CD_PARAMS},
112 "fuel": {
"Cd_inf": 0.60,
"a_Re": 0.18,
"Cd_min": 0.35,
"use_geometry_cd":
True, **_GEOMETRY_CD_PARAMS},
118 """Per-stream discharge-Cd baseline for a freshly-switched injector type. Empty for types without
119 a registered baseline (caller then leaves discharge untouched)."""
121 return copy.deepcopy(INJECTOR_DISCHARGE_BASELINES.get(str(injector_type), {}))
127_FROZEN_COMMON = [
"A_throat_mm2",
"Lstar_mm",
"expansion_ratio",
"D_chamber_outer_mm",
128 "P_O_start_psi",
"P_F_start_psi"]
130 "pintle": [
"d_pintle_tip_mm",
"h_gap_mm",
"n_orifices",
"d_orifice_mm"],
131 "impinging": [
"n_doublets",
"d_jet_O_mm",
"d_jet_F_mm",
"impingement_angle_O_deg",
132 "impingement_angle_F_deg",
"spacing_O_mm",
"spacing_F_mm"],
138 """Frozen-param field names valid for this injector type (chamber-common + injector-specific)."""
139 t = str(injector_type)
140 if t
not in _FROZEN_INJECTOR:
141 raise ValueError(f
"Unknown injector type {t!r}; known: {sorted(_FROZEN_INJECTOR)}")
142 return list(_FROZEN_COMMON) + list(_FROZEN_INJECTOR[t])
146 """Return human-readable warnings for combos that contradict the physics bindings.
148 Warn-only by design (Phase 2b): the loader logs these; nothing is mutated so golden anchors
149 cannot shift. Tightening to hard errors / auto-derivation is a Phase-3 decision.
151 warnings: List[str] = []
152 inj_type = str(getattr(getattr(config,
"injector",
None),
"type",
"")
or "")
153 if inj_type
not in INJECTOR_BINDINGS:
154 return [f
"injector.type {inj_type!r} has no physics bindings registered"]
155 b = INJECTOR_BINDINGS[inj_type]
157 smd_model = str(getattr(getattr(getattr(config,
"spray",
None),
"smd",
None),
"model",
"")
or "")
158 if b.smd_knob_ignored
and smd_model:
160 if b.expected_smd_model
and smd_model == b.expected_smd_model:
162 elif not b.expected_smd_model
and smd_model !=
"lefebvre":
164 f
"spray.smd.model='{smd_model}' has NO EFFECT for injector.type='{inj_type}' "
165 f
"(solver hard-binds its own correlation)"
167 elif b.expected_smd_model
and smd_model != b.expected_smd_model:
169 f
"spray.smd.model='{smd_model}' is ignored for injector.type='{inj_type}' "
170 f
"(solver hard-binds {b.expected_smd_model!r}); reconcile via load/switch"
172 if b.expected_smd_model
and not b.smd_knob_ignored
and smd_model
and smd_model != b.expected_smd_model:
174 f
"injector.type='{inj_type}' expects spray.smd.model='{b.expected_smd_model}' but config "
175 f
"says '{smd_model}' — atomization (and stability time-lag, τ∝SMD²) will use the "
176 f
"unexpected correlation"
179 discharge = getattr(config,
"discharge",
None)
or {}
180 for side
in (
"oxidizer",
"fuel"):
181 d = discharge.get(side)
if isinstance(discharge, dict)
else getattr(discharge, side,
None)
184 geo = bool(getattr(d,
"use_geometry_cd",
False))
185 if geo != b.expected_use_geometry_cd:
187 f
"discharge.{side}.use_geometry_cd={geo} but injector.type='{inj_type}' expects "
188 f
"{b.expected_use_geometry_cd} (geometry-Cd is calibrated for drilled impinging "
189 f
"orifices; pintle uses fixed Cd_inf)"
List[str] frozen_param_fields_for(str injector_type)
InjectorBindings bindings_for(str injector_type)
dict default_geometry_for(str injector_type)
List[str] validate_config_bindings(Any config)
dict default_discharge_for(str injector_type)