STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
component.py
Go to the documentation of this file.
1"""The injector as a network branch, and the engine as a network boundary.
2
3Two objects, and the split matters.
4
5An **injector leg** is an ordinary hydraulic component: an orifice of known area
6and discharge coefficient, one per propellant, sitting at the downstream end of
7each feed line. Nothing about it is special except that its area came out of a
8Layer-1 optimisation rather than off a drawing.
9
10The **engine** is not a component at all. It is a boundary condition, and a
11circular one: chamber pressure depends on the flows, and the flows depend on
12chamber pressure through the injector's pressure difference. The session closes
13that loop each coupling step (``Session._close_chamber``), root-finding the
14chamber pressure the network and the engine agree on -- the same coupled
15injector-to-chamber solve EngineDesign performs, done here around a whole feed
16system instead of around two pressures.
17
18Why the loop is worth having
19----------------------------
20It is the reason a pressure-fed engine is stable in the large. Raise tank
21pressure and flow rises, so chamber pressure rises, so the injector's pressure
22difference rises by *less* than the tank did -- the engine absorbs part of every
23upstream change. That is also why injector stiffness is a design rule rather
24than a preference: as the injector's share of the total pressure drop falls, the
25loop gain rises, and below roughly 20% the engine starts responding to its own
26chamber more than to its tanks.
27"""
28
29from __future__ import annotations
30
31import math
32from dataclasses import replace
33from typing import TYPE_CHECKING, Mapping
34
35from feedtwin.comps.base import (
36 FlowConditions,
37 HydraulicComponent,
38 InfeasibleOperatingPoint,
39 Violation,
40)
41from feedtwin.engine.chamber import ChamberResult
42from feedtwin.engine.design import EngineDesign, InjectorSide
43from feedtwin.model.component import ComponentInstance
44
45if TYPE_CHECKING:
46 from feedtwin.solve.network import Network
47
48
50 """One propellant's path through the injector face.
51
52 ``dp = mdot^2 / (2 rho (Cd A)^2)`` -- the orifice relation, with ``Cd``
53 coming from the Reynolds-dependent model in the engine config rather than
54 being a constant. That dependence is not decoration: at low flow during
55 startup the discharge coefficient is materially below its high-Reynolds
56 value, and an injector modelled at a fixed ``Cd`` opens too fast.
57
58 Built from an :class:`~feedtwin.engine.design.InjectorSide` rather than from
59 a component config, because the whole point of Phase 08 is that these
60 numbers arrive from Layer 1 and are not retyped.
61 """
62
63 def __init__(self, instance: ComponentInstance, side: InjectorSide) -> None:
64 super().__init__(instance)
65 self.side = side
66
67 @property
68 def id(self) -> str:
69 return self.instance.id
70
71 def effective_area(self, mdot: float, flow: FlowConditions) -> float:
72 """``Cd . A`` at this operating point [m^2].
73
74 With an engine card it is recovered from the card's pressure drop at the
75 density this leg sees, so the diagnostics and the mixture balance read
76 the same injector the solve used."""
77 card = self.side.card
78 if card is not None:
79 phi = card.capacity(abs(mdot), flow.p_upstream)
80 return phi / math.sqrt(2.0 * flow.rho) if flow.rho > 0.0 else 0.0
81 cd = self.side.cd_at(mdot, flow.rho, flow.mu, pressure=flow.p_upstream)
82 return cd * self.side.area
83
84 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
85 card = self.side.card
86 if card is not None:
87 # The card is the injector, inlet node to chamber, including the
88 # discharge into the manifold the drawing's line does not model.
89 #
90 # A magnitude, like every other pressure_drop: total_dp puts the
91 # sign on. The card's own relation is signed, and passing that
92 # through signed it twice, so a leg run backwards gained pressure
93 # and the network had no root whenever the chamber closure tried a
94 # chamber above that leg's tank.
95 return card.pressure_drop(abs(mdot), flow.p_upstream)
96 magnitude = abs(mdot)
97 if magnitude == 0.0:
98 return 0.0
99 area = self.effective_area(mdot, flow)
100 if area <= 0.0 or flow.rho <= 0.0:
101 raise InfeasibleOperatingPoint(self.id, mdot, flow.p_upstream)
102 return magnitude * magnitude / (2.0 * flow.rho * area * area)
103
104 def off_card(self, mdot: float, flow: FlowConditions) -> bool:
105 """True when this lookup is outside the hull the leg's card was fitted to.
106
107 The card's table clamps outside its box, which keeps the number
108 physical and says nothing; the hull is how a trace learns it left what
109 the card knows (:func:`fold_injectors`). No card, nothing to be off.
110 """
111 card = self.side.card
112 if card is None or mdot == 0.0:
113 return False
114 return not card.covers(mdot, flow.p_upstream)
115
116 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
117 area = self.effective_area(mdot, flow)
118 velocity = abs(mdot) / (flow.rho * self.side.area) if flow.rho > 0.0 else 0.0
119 return {
120 "area_geometric": self.side.area,
121 "area_effective": area,
122 "Cd": area / self.side.area if self.side.area > 0.0 else 0.0,
123 "velocity": velocity,
124 "Re": (
125 flow.rho * velocity * self.side.hydraulic_diameter / flow.mu
126 if flow.mu > 0.0
127 else 0.0
128 ),
129 "elements": float(self.side.element_count),
130 "dp": self.pressure_drop(mdot, flow),
131 "extrapolated": 1.0 if self.off_card(mdot, flow) else 0.0,
132 }
133
134 def check(self) -> list[Violation]:
135 out: list[Violation] = []
136 if self.side.area <= 0.0:
137 out.append(
138 Violation(self.id, "area", "injector area is zero and cannot flow")
139 )
140 if self.side.hydraulic_diameter <= 0.0:
141 out.append(
142 Violation(
143 self.id,
144 "hydraulic_diameter",
145 "no hydraulic diameter, so the Reynolds-dependent Cd is "
146 "pinned at its floor",
147 severity="warning",
148 )
149 )
150 return out
151
152
154 design: EngineDesign,
155) -> tuple[InjectorLeg, InjectorLeg]:
156 """Build the two injector branches from an imported engine design.
157
158 Returns ``(oxidiser, fuel)``. The component ids are derived from the design
159 name so a report can trace a branch back to the config it came from.
160 """
161 from feedtwin.model.param import Param, Provenance
162
163 def leg(side: InjectorSide, tag: str) -> InjectorLeg:
164 # A nominal bore for reporting: the single round hole of the same total
165 # area. It is not used by the physics -- the area is -- but a diagnostic
166 # that says "3.0 mm" is easier to sanity-check than one that says
167 # "7.0e-6 m^2".
168 bore = math.sqrt(4.0 * side.area / math.pi)
169 instance = ComponentInstance.build(
170 f"INJ-{tag}",
171 "orifice",
172 {
173 "bore": Param(bore, "m", Provenance.MANUFACTURER, design.name),
174 "pipe_bore": Param(bore, "m", Provenance.MANUFACTURER, design.name),
175 "Cd": Param(
176 side.discharge.cd_inf, "-", Provenance.MANUFACTURER, design.name
177 ),
178 },
179 )
180 return InjectorLeg(instance, side)
181
182 return leg(design.oxidiser, "OX"), leg(design.fuel, "FUEL")
183
184
186 chamber: ChamberResult,
187 network: Network,
188 ports: Mapping[str, str],
189 flows: Mapping[str, float],
190 pressures: Mapping[str, float],
191) -> ChamberResult:
192 """The chamber's answer, flagged extrapolated if either injector leg's card
193 lookup was off its hull.
194
195 A step's ``extrapolated`` flag is the chamber's, which only ever asked the
196 chamber table; an injector card read below its sampled inlet pressures (a
197 vent-down, a low-pressure firing) was clamped and never counted. The flag
198 means "outside what the card knows", so the injectors fold into it.
199 """
200 if chamber.combustion.extrapolated:
201 return chamber
202 for side in ("oxidiser", "fuel"):
203 branch_id = ports.get(side, "")
204 branch = network.branches.get(branch_id) if branch_id else None
205 if branch is None or not isinstance(branch.component, InjectorLeg):
206 continue
207 mdot = flows.get(branch_id, 0.0)
208 p_inlet = pressures.get(branch.upstream)
209 if mdot <= 0.0 or p_inlet is None:
210 continue
211 if branch.component.off_card(
212 mdot, FlowConditions(rho=0.0, mu=0.0, p_upstream=p_inlet)
213 ):
214 return replace(
215 chamber, combustion=replace(chamber.combustion, extrapolated=True)
216 )
217 return chamber
float pressure_drop(self, float mdot, FlowConditions flow)
Definition base.py:190
None __init__(self, ComponentInstance instance, InjectorSide side)
Definition component.py:63
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition component.py:116
float pressure_drop(self, float mdot, FlowConditions flow)
Definition component.py:84
bool off_card(self, float mdot, FlowConditions flow)
Definition component.py:104
float effective_area(self, float mdot, FlowConditions flow)
Definition component.py:71
ChamberResult fold_injectors(ChamberResult chamber, Network network, Mapping[str, str] ports, Mapping[str, float] flows, Mapping[str, float] pressures)
Definition component.py:191
tuple[InjectorLeg, InjectorLeg] injector_legs(EngineDesign design)
Definition component.py:155