STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
balance.py
Go to the documentation of this file.
1r"""Why the mixture ratio is what it is.
2
3A coupled run reports an O/F. On its own that number is nearly useless as a
4design input, because it says nothing about *what set it* -- and the two things
5that could have are owned by different people. The injector face is the engine
6designer's; the pressure the two legs arrive at is the stand's. Told only that
7O/F came out at 2.19 against a 1.65 target, the engine designer redrills the
8face and the stand builder does nothing, and the redrilled face is then wrong
9for the day the fuel line gets shortened.
10
11The decomposition is exact, not a correlation
12---------------------------------------------
13Each side of an injector is an orifice::
14
15 mdot = Cd . A . sqrt(2 . rho . dp)
16
17so the ratio of the two is, with no approximation at all,
18
19.. code-block:: text
20
21 O/F = Cd_ox A_ox sqrt(rho_ox) . sqrt( dp_ox )
22 ------------------------- ------------
23 Cd_f A_f sqrt(rho_f ) sqrt( dp_f )
24
25 \______ face ratio ______/ \_ feed _/
26
27Two multiplicative terms. The **face ratio** is what the injector would deliver
28if both sides saw the same pressure difference -- pure geometry, discharge
29coefficient and propellant density, and the number the engine was drilled to.
30The **feed term** is everything the stand does to that, and it is one at
31equal injector drop, above one when the fuel leg is the more restrictive, below
32when the ox leg is.
33
34Because the split is exact, it is a *fault localisation* rather than a summary:
35a face ratio that is off tells the engine designer to redrill, a feed term that
36is off tells the stand builder to re-plumb, and neither has to wonder.
37
38Stiffness is the other half of the same picture
39-----------------------------------------------
40The injector's share of the total pressure drop, ``dp_inj / p_c``, is the
41classic design rule -- roughly 20% and up, and both legs, not the average. Below
42it the chamber starts talking back to the feed system faster than the feed
43system can answer, which is where coupled instability lives. It is reported per
44side here because an average hides exactly the case that matters: one stiff leg
45and one soft one is not a healthy injector, it is a healthy leg next to the leg
46that will chug.
47"""
48
49from __future__ import annotations
50
51import math
52from dataclasses import dataclass
53
54from feedtwin.engine.design import EngineDesign
55
56#: Injector stiffness below which a leg is called soft [-]. The usual rule of
57#: thumb, and a rule of thumb is what it is: 20% is where the literature puts
58#: the knee for stable pressure-fed operation, not a cliff edge. A leg at 0.18
59#: is a note; a leg at 0.05 is a finding.
60SOFT_STIFFNESS = 0.20
61
62#: Below this, the leg is barely metering at all and the feed system is setting
63#: the flow. Reported at a higher severity.
64VERY_SOFT_STIFFNESS = 0.10
65
66#: Relative disagreement with the design mixture ratio worth reporting [-].
67MIXTURE_TOLERANCE = 0.05
68
69
70@dataclass(frozen=True, slots=True)
72 """One propellant's arrival at the face.
73
74 Args:
75 propellant: Species name.
76 mdot: Delivered mass flow [kg/s].
77 density: Liquid density at the face [kg/m^3].
78 area: Geometric injector area, all elements [m^2].
79 cd: Discharge coefficient at this operating point [-].
80 injector_dp: Pressure difference across the face [Pa].
81 supply_pressure: Pressure at the tank outlet [Pa]. The head of the
82 budget, so ``supply - injector_dp - p_c`` is what the plumbing ate.
83 """
84
85 propellant: str
86 mdot: float
87 density: float
88 area: float
89 cd: float
90 injector_dp: float
91 supply_pressure: float = 0.0
92 band: tuple[float, float] = (0.0, 0.0)
93 """``(min, max)`` stiffness this side was designed to, from the engine
94 config. ``(0, 0)`` falls back to :data:`SOFT_STIFFNESS`."""
95
96 @property
97 def effective_area(self) -> float:
98 """``Cd . A`` [m^2] -- the area the flow actually sees."""
99 return self.cd * self.area
100
101 @property
102 def velocity(self) -> float:
103 """Mean injection velocity [m/s]. Sets atomisation, so it is worth
104 seeing next to the pressure drop rather than derived later."""
105 if self.density <= 0.0 or self.area <= 0.0:
106 return 0.0
107 return abs(self.mdot) / (self.density * self.area)
108
109 def feed_loss(self, chamber_pressure: float) -> float:
110 """What the plumbing spent between tank outlet and face [Pa].
111
112 Zero when no supply pressure was supplied, rather than a negative
113 number that would read as the feed system adding pressure.
114 """
115 if self.supply_pressure <= 0.0:
116 return 0.0
117 return max(self.supply_pressure - chamber_pressure - self.injector_dp, 0.0)
118
119 def stiffness(self, chamber_pressure: float) -> float:
120 """``dp_injector / p_c`` [-]. The design rule's number."""
121 if chamber_pressure <= 0.0:
122 return 0.0
123 return self.injector_dp / chamber_pressure
124
125 @property
126 def floor(self) -> float:
127 """The stiffness this side must clear: its own band, else the rule."""
128 return self.band[0] if self.band[0] > 0.0 else SOFT_STIFFNESS
129
130 @property
131 def ceiling(self) -> float:
132 """The top of the design band, or zero when none was stated.
133
134 A real upper bound, not a formality: past it the injector is spending
135 tank pressure the vehicle carried structural mass to hold, and the
136 optimiser that chose the band was trading exactly that.
137 """
138 return self.band[1]
139
140
141@dataclass(frozen=True, slots=True)
143 """The O/F, split into the half the engine owns and the half the stand does.
144
145 ``mixture_ratio == face_ratio * feed_term`` holds to solver tolerance. It is
146 an identity, not a fit, so a discrepancy between the two sides of it means
147 the flows and pressure drops handed in are not self-consistent -- which is
148 itself worth knowing, and is what :attr:`residual` reports.
149 """
150
151 oxidiser: SideBalance
152 fuel: SideBalance
153 chamber_pressure: float
154 design_ratio: float = 0.0
155
156 # ------------------------------------------------------------- the split
157
158 @property
159 def mixture_ratio(self) -> float:
160 """Delivered O/F, straight from the flows."""
161 if self.fuel.mdot <= 0.0:
162 return 0.0
163 return self.oxidiser.mdot / self.fuel.mdot
164
165 @property
166 def face_ratio(self) -> float:
167 """O/F the face alone would deliver, at equal injector pressure drop.
168
169 The number the injector was drilled to. Independent of the stand, the
170 tanks, and the run -- change the plumbing and this does not move.
171 """
172 ox, fu = self.oxidiser, self.fuel
173 denominator = fu.effective_area * math.sqrt(max(fu.density, 0.0))
174 if denominator <= 0.0:
175 return 0.0
176 return ox.effective_area * math.sqrt(max(ox.density, 0.0)) / denominator
177
178 @property
179 def feed_term(self) -> float:
180 """``sqrt(dp_ox / dp_fuel)`` [-]. Everything the stand contributes.
181
182 One when both legs drop the same across the face. Above one when the
183 fuel leg is the more restrictive one upstream, because then the fuel
184 arrives at a lower pressure and spends less of it at the face.
185 """
186 # Either face backwards (an abort's trickle, the chamber pushing up a
187 # leg) and there is no split to decompose: the square root of a
188 # negative ratio took the cockpit's /history down with it.
189 if self.fuel.injector_dp <= 0.0 or self.oxidiser.injector_dp <= 0.0:
190 return 0.0
191 return math.sqrt(self.oxidiser.injector_dp / self.fuel.injector_dp)
192
193 @property
194 def residual(self) -> float:
195 """Relative gap between the identity and the delivered ratio [-].
196
197 Should be at solver tolerance. Anything larger means the pressure drops
198 and the flows disagree, so the decomposition below is not describing
199 the run it claims to.
200 """
201 predicted = self.face_ratio * self.feed_term
202 actual = self.mixture_ratio
203 if actual <= 0.0:
204 return 0.0
205 return abs(predicted - actual) / actual
206
207 @property
208 def design_error(self) -> float:
209 """Relative distance from the design mixture ratio [-]."""
210 if self.design_ratio <= 0.0:
211 return 0.0
212 return (self.mixture_ratio - self.design_ratio) / self.design_ratio
213
214 # --------------------------------------------------------- what to do now
215
216 @property
217 def feed_term_for_design(self) -> float:
218 """The feed term that would land the design O/F on this face.
219
220 What the stand would have to do, taking the injector as built. Compared
221 against :attr:`feed_term`, it says how far the plumbing is from
222 delivering what the engine was drilled for.
223 """
224 face = self.face_ratio
225 if face <= 0.0 or self.design_ratio <= 0.0:
226 return 0.0
227 return self.design_ratio / face
228
229 def trim_pressure(self) -> float:
230 """Extra fuel-side supply pressure that would restore the design O/F [Pa].
231
232 Holding the ox leg where it is, the fuel injector drop has to rise to
233 ``dp_ox / feed_target^2``; every pascal of that has to come from the
234 fuel tank, *plus* the extra the fuel plumbing eats at the higher flow.
235 The second part is why this is a first step and not an answer: it is
236 computed at the present flow, so it under-states the true requirement.
237 Positive means the fuel side needs more pressure.
238 """
239 target = self.feed_term_for_design
240 if target <= 0.0 or self.oxidiser.injector_dp <= 0.0:
241 return 0.0
242 wanted = self.oxidiser.injector_dp / (target * target)
243 return wanted - self.fuel.injector_dp
244
245 def notes(self) -> list[str]:
246 """Findings, in the order an operator would want to read them.
247
248 Written as sentences rather than codes because they land in a report
249 that a person reads once, before a test, and the cost of a
250 misinterpreted code there is higher than the cost of a long line.
251 """
252 out: list[str] = []
253 pc = self.chamber_pressure
254
255 if self.design_ratio > 0.0 and abs(self.design_error) > MIXTURE_TOLERANCE:
256 face_off = (
257 abs(self.face_ratio - self.design_ratio) / self.design_ratio
258 > MIXTURE_TOLERANCE
259 )
260 direction = "rich" if self.design_error < 0.0 else "lean"
261 blame = (
262 "the injector face itself: at equal pressure drop it would "
263 f"deliver {self.face_ratio:.2f}, so redrilling is the lever"
264 if face_off
265 else (
266 f"the feed system, not the face: the face would deliver "
267 f"{self.face_ratio:.2f} at equal drop and the plumbing "
268 f"multiplies it by {self.feed_term:.2f}"
269 )
270 )
271 out.append(
272 f"O/F is {self.mixture_ratio:.2f} against a design "
273 f"{self.design_ratio:.2f} "
274 f"({abs(self.design_error) * 100:.0f}% {direction}). "
275 f"That is {blame}."
276 )
277
278 for side, label in ((self.oxidiser, "oxidiser"), (self.fuel, "fuel")):
279 stiffness = side.stiffness(pc)
280 if stiffness <= 0.0:
281 continue
282 floor = side.floor
283 # Against the engine's own band where it states one. The rule of
284 # thumb is the fallback, and saying which was used matters: "under
285 # 20%" is an opinion, "under the 20-30% this engine was optimised
286 # to" is the config talking.
287 against = (
288 f"the {floor * 100:.0f}"
289 + (f"-{side.ceiling * 100:.0f}" if side.ceiling > 0.0 else "")
290 + "% this engine was designed to"
291 if side.band[0] > 0.0
292 else f"a {floor * 100:.0f}% rule of thumb"
293 )
294 if stiffness < min(floor, VERY_SOFT_STIFFNESS * (floor / SOFT_STIFFNESS)):
295 out.append(
296 f"The {label} leg is barely metering: {stiffness * 100:.0f}% "
297 f"injector stiffness against {against}. At that authority "
298 "the feed system sets the flow and the chamber sets it "
299 "back, which is where coupled instability lives."
300 )
301 elif stiffness < floor:
302 out.append(
303 f"The {label} leg is soft: {stiffness * 100:.0f}% injector "
304 f"stiffness against {against}."
305 )
306 elif side.ceiling > 0.0 and stiffness > side.ceiling:
307 out.append(
308 f"The {label} leg is stiffer than designed: "
309 f"{stiffness * 100:.0f}% against {against}. Not a stability "
310 "risk, but it is tank pressure being spent at the face that "
311 "the design did not intend to spend there."
312 )
313
314 loss_ox = self.oxidiser.feed_loss(pc)
315 loss_fuel = self.fuel.feed_loss(pc)
316 if loss_ox > 0.0 and loss_fuel > 0.0:
317 for loss, side, label in (
318 (loss_ox, self.oxidiser, "oxidiser"),
319 (loss_fuel, self.fuel, "fuel"),
320 ):
321 budget = loss + side.injector_dp
322 if budget > 0.0 and loss / budget > 0.5:
323 out.append(
324 f"The {label} plumbing eats {loss / budget * 100:.0f}% of "
325 f"that leg's pressure budget before the face "
326 f"({loss / 6894.757:.0f} psi of "
327 f"{budget / 6894.757:.0f}). The line is the meter here, "
328 "not the injector."
329 )
330
331 if self.residual > 1.0e-3:
332 out.append(
333 f"The flows and the injector pressure drops disagree by "
334 f"{self.residual * 100:.1f}%, so this split does not describe "
335 "the run it was taken from. Treat it as indicative."
336 )
337 return out
338
339
341 design: EngineDesign,
342 *,
343 mdot_oxidiser: float,
344 mdot_fuel: float,
345 density_oxidiser: float,
346 density_fuel: float,
347 injector_dp_oxidiser: float,
348 injector_dp_fuel: float,
349 chamber_pressure: float,
350 supply_oxidiser: float = 0.0,
351 supply_fuel: float = 0.0,
352) -> MixtureBalance:
353 """Assemble a balance from a solved operating point.
354
355 ``Cd`` is recovered from the flow and the pressure drop rather than
356 re-evaluated from the Reynolds model, so the identity closes on the numbers
357 the solver actually produced. Re-evaluating it would make the residual
358 report the difference between two ``Cd`` models instead of a real
359 inconsistency, which is the one thing the residual exists to catch.
360 """
361
362 def recovered_cd(mdot: float, rho: float, area: float, dp: float) -> float:
363 if area <= 0.0 or rho <= 0.0 or dp <= 0.0:
364 return 0.0
365 return abs(mdot) / (area * math.sqrt(2.0 * rho * dp))
366
367 ox = SideBalance(
368 propellant=design.oxidiser.propellant,
369 mdot=mdot_oxidiser,
370 density=density_oxidiser,
371 area=design.oxidiser.area,
372 cd=recovered_cd(
373 mdot_oxidiser, density_oxidiser, design.oxidiser.area, injector_dp_oxidiser
374 ),
375 injector_dp=injector_dp_oxidiser,
376 supply_pressure=supply_oxidiser,
377 band=design.oxidiser.stiffness_band,
378 )
379 fuel = SideBalance(
380 propellant=design.fuel.propellant,
381 mdot=mdot_fuel,
382 density=density_fuel,
383 area=design.fuel.area,
384 cd=recovered_cd(mdot_fuel, density_fuel, design.fuel.area, injector_dp_fuel),
385 injector_dp=injector_dp_fuel,
386 supply_pressure=supply_fuel,
387 band=design.fuel.stiffness_band,
388 )
389 return MixtureBalance(
390 oxidiser=ox,
391 fuel=fuel,
392 chamber_pressure=chamber_pressure,
393 design_ratio=design.design_mixture_ratio,
394 )
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
float feed_loss(self, float chamber_pressure)
Definition balance.py:109
float stiffness(self, float chamber_pressure)
Definition balance.py:119
MixtureBalance balance_from(EngineDesign design, *, float mdot_oxidiser, float mdot_fuel, float density_oxidiser, float density_fuel, float injector_dp_oxidiser, float injector_dp_fuel, float chamber_pressure, float supply_oxidiser=0.0, float supply_fuel=0.0)
Definition balance.py:352