STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_pid_drawn_freely.py
Go to the documentation of this file.
1"""A drawing made in pid-designer the way a person makes one.
2
3pid-designer's lines have no direction: "walking any line in either direction.
4What 'upstream' means on a drawing with no arrows" (its `checks.ts`). Which end
5is a line's ``source`` is only the end the mouse started at. The shipped stand
6drawings were authored in flow order, so reading ``source -> target`` as
7upstream to downstream happened to work on them; a drawing made freely broke on
8it. Run a press line *from* the tank *to* its solenoid and both of the
9solenoid's lines arrived at its inlet, its outlet hung open, and the press
10valve was read as a vent from the regulator to atmosphere.
11
12These pin the rule that replaced it -- the port a line was drawn on decides the
13side, but only where the old reading left a side with nothing on it -- and the
14two messages a user importing their own drawing actually needs.
15"""
16
17from __future__ import annotations
18
19from typing import Any
20
21import pytest
22
23from feedtwin.pid import build_network, read_diagram
24from feedtwin.session import assemble_model
25from feedtwin.session.model import AssemblyError
26
27AMBIENT = 101325.0
28
29
30def P(value: float, unit: str, source: str = "measured") -> dict[str, object]:
31 return {"value": value, "unit": unit, "source": source, "reference": ""}
32
33
34def node(nid: str, ctype: str, label: str, **data: object) -> dict[str, object]:
35 return {
36 "id": nid,
37 "position": {"x": 0, "y": 0},
38 "data": {"componentType": ctype, "label": label, **data},
39 }
40
41
42def line(
43 eid: str, a: str, b: str, from_port: str = "", to_port: str = ""
44) -> dict[str, object]:
45 out: dict[str, Any] = {
46 "id": eid,
47 "source": a,
48 "target": b,
49 "data": {
50 "lineType": "pipe",
51 "params": {
52 "length": P(1.0, "m"),
53 "bore": P(9.5, "mm"),
54 "roughness": P(0.0015, "mm"),
55 },
56 },
57 }
58 if from_port:
59 out["sourceHandle"] = from_port
60 if to_port:
61 out["targetHandle"] = to_port
62 return out
63
64
65def press_leg(*lines: dict[str, object], sol: dict[str, Any] | None = None) -> Any:
66 """Bottle, press solenoid, fuel tank, with the two lines as given."""
67 return {
68 "nodes": [
69 node(
70 "KB",
71 "KBOTTLE",
72 "KB-N2",
73 fluid="nitrogen",
74 params={"pressure": P(4500, "psi"), "temperature": P(293.15, "K")},
75 ),
76 sol
77 or node(
78 "SV",
79 "SOL",
80 "SV-PRESS",
81 params={"Cv": P(1.7, "Cv"), "bore": P(6.35, "mm")},
82 ),
83 node(
84 "TK",
85 "TANK",
86 "TK-FUEL",
87 fluid="ethanol",
88 params={
89 "pressure": P(550, "psi"),
90 "temperature": P(293.15, "K"),
91 "volume": P(8.67, "L"),
92 },
93 ),
94 ],
95 "edges": list(lines),
96 }
97
98
99def built(payload: Any): # type: ignore[no-untyped-def]
100 return build_network(read_diagram(payload, name="press leg"))
101
102
103# ------------------------------------------------------- which side of a valve
104
105
107 """Both lines drawn *into* the solenoid, on its two ports. The tank's line is
108 on ``r``, so it is the outlet; nothing is vented, and the pressurant reaches
109 the tank's ullage."""
110 b = built(
111 press_leg(
112 line("bottle-sv", "KB", "SV", to_port="l"),
113 line("tank-sv", "TK", "SV", from_port="t", to_port="r"),
114 )
115 )
116 net = b.network
117 assert net.branches["bottle-sv"].downstream == "SV.in"
118 assert net.branches["tank-sv"].downstream == "SV.out"
119 assert net.nodes["SV.out"].pressure is None, "the press valve was read as a vent"
120 assert net.branches["tank-sv"].upstream == b.tanks["TK"].ullage
121 assert not [w for w in b.warnings if "venting" in w]
122
123
125 b = built(
126 press_leg(
127 line("sv-bottle", "SV", "KB", from_port="l"),
128 line("sv-tank", "SV", "TK", from_port="r", to_port="t"),
129 )
130 )
131 net = b.network
132 assert net.branches["sv-bottle"].upstream == "SV.in"
133 assert net.branches["sv-tank"].upstream == "SV.out"
134 assert net.nodes["SV.out"].pressure is None
135
136
138 b = built(
139 press_leg(
140 line("bottle-sv", "KB", "SV", to_port="l"),
141 line("tank-sv", "TK", "SV", from_port="t", to_port="r"),
142 )
143 )
144 assert [w for w in b.warnings if "SV-PRESS" in w and "port" in w]
145
146
148 """The gate. Drawn in flow order, the ports are not consulted even when they
149 disagree with the direction -- a drawing that worked yesterday builds the
150 same network today."""
151 flow_order = press_leg(
152 line("bottle-sv", "KB", "SV", to_port="r"),
153 line("sv-tank", "SV", "TK", from_port="l", to_port="t"),
154 )
155 b = built(flow_order)
156 assert b.network.branches["bottle-sv"].downstream == "SV.in"
157 assert b.network.branches["sv-tank"].upstream == "SV.out"
158 assert not [w for w in b.warnings if "port" in w]
159
160
161def _through(ctype: str, *lines: dict[str, object]) -> Any:
162 """Bottle, a check valve or regulator ``X``, vent: the smallest directed run."""
163 params = {"setpoint": P(500, "psi")} if ctype == "PR" else {}
164 return {
165 "nodes": [
166 node(
167 "KB",
168 "KBOTTLE",
169 "KB-N2",
170 fluid="nitrogen",
171 params={"pressure": P(4500, "psi"), "temperature": P(293.15, "K")},
172 ),
173 node("X", ctype, f"{ctype}-1", params=params),
174 node("V", "VENT", "VENT-1"),
175 ],
176 "edges": list(lines),
177 }
178
179
180def _flow(payload: Any) -> float:
181 from feedtwin.solve import solve_steady
182
183 result = solve_steady(built(payload).network, raise_on_failure=False)
184 return float(result.flows.get("X", 0.0))
185
186
187@pytest.mark.parametrize("ctype", ["CV", "PR"])
189 ctype: str,
190) -> None:
191 """Ports right (``l`` in, as pid-designer draws and checks a CV), both lines
192 dragged from the far end. pid-designer is silent, so the twin must flow;
193 it read the direction and put the bottle on the outlet, flowing nothing."""
194 payload = _through(
195 ctype,
196 line("x-kb", "X", "KB", from_port="l", to_port="r"),
197 line("v-x", "V", "X", from_port="l", to_port="r"),
198 )
199 net = built(payload).network
200 assert net.branches["x-kb"].upstream == "X.in", "the bottle is on the inlet"
201 assert net.branches["v-x"].downstream == "X.out"
202 assert _flow(payload) > 0.1
203
204
205@pytest.mark.parametrize("ctype", ["CV", "PR"])
207 payload = _through(
208 ctype,
209 line("kb-x", "KB", "X", from_port="r", to_port="l"),
210 line("x-v", "X", "V", from_port="r", to_port="l"),
211 )
212 b = built(payload)
213 assert b.network.branches["kb-x"].downstream == "X.in"
214 assert not [w for w in b.warnings if "port" in w]
215 assert _flow(payload) > 0.1
216
217
219 """Lines in flow order, but onto ``r`` from the bottle: pid-designer's
220 ``cv-backwards``. The valve's own inlet faces the vent, so it does not
221 pass the bottle -- and the build says why."""
222 payload = _through(
223 "CV",
224 line("kb-x", "KB", "X", from_port="r", to_port="r"),
225 line("x-v", "X", "V", from_port="l", to_port="l"),
226 )
227 b = built(payload)
228 assert b.network.branches["kb-x"].downstream == "X.out"
229 assert [w for w in b.warnings if "CV-1" in w and "port" in w], b.warnings
230 assert _flow(payload) <= 1e-6
231
232
234 """No port to read, so nothing to correct: the old reading stands."""
235 b = built(
236 press_leg(
237 line("bottle-sv", "KB", "SV"),
238 line("tank-sv", "TK", "SV"),
239 )
240 )
241 assert b.network.nodes["SV.out"].pressure == pytest.approx(AMBIENT)
242
243
245 """A QD with no coefficient is a fitting, priced as a bend. Capped, it is a
246 stub, and the solver back-fills a stub at exactly zero flow -- where the
247 bend correlation (Rennels, via fluids) refuses a Reynolds number of zero.
248 The ValueError escaped a solve asked not to raise, on every solve of any
249 drawing with a fill QD on it."""
250 from feedtwin.solve.steady import solve_steady
251
252 payload = press_leg(line("bottle-sv", "KB", "SV"), line("sv-tank", "SV", "TK"))
253 payload["nodes"].append(node("QD", "QD", "QD-FILL"))
254 payload["edges"].append(line("tank-qd", "TK", "QD", from_port="b", to_port="l"))
255 b = built(payload)
256 result = solve_steady(
257 b.network, signals={"SV-PRESS.command": 1.0}, raise_on_failure=False
258 )
259 assert result.converged
260 assert result.flows["tank-qd"] == 0.0
261
262
263# ------------------------------------------------------- what a user is told
264
265
267 """A solenoid with ``Cd = 20`` reached the API as an unhandled SpecError, a
268 bare 500, and the message said "valve" rather than which one."""
269 bad = node(
270 "SV",
271 "SOL",
272 "FM_SOL_G",
273 params={"Cd": P(20, "-", "estimated"), "bore": P(6.35, "mm")},
274 )
275 payload = press_leg(
276 line("bottle-sv", "KB", "SV"), line("sv-tank", "SV", "TK"), sol=bad
277 )
278 with pytest.raises(AssemblyError, match="FM_SOL_G") as caught:
279 assemble_model(read_diagram(payload, name="press leg"), diagram_id="t")
280 assert "Cd" in str(caught.value)
281
282
284 """pid-designer offers ``copv`` as a tank *wall material*; the vessel that
285 supplies the press lines is its Pressurant bottle (KBOTTLE). A COPV drawn as
286 a TANK is read as a propellant tank, and nothing presses anything."""
287 payload = press_leg(line("bottle-sv", "KB", "SV"), line("sv-tank", "SV", "TK"))
288 payload["nodes"].append(
289 node(
290 "COPV",
291 "TANK",
292 "TK-1",
293 fluid="nitrogen",
294 options={"material": "copv"},
295 params={"pressure": P(4000, "psi"), "temperature": P(293, "K")},
296 )
297 )
298 b = built(payload)
299 told = [w for w in b.warnings if w.startswith("TK-1")]
300 assert any("KBOTTLE" in w and "Pressurant bottle" in w for w in told), told
301 # A propellant tank that states a liquid temperature is not.
302 assert not [w for w in b.warnings if w.startswith("TK-FUEL") and "KBOTTLE" in w]
303
304
305def _stand_with_copv_tank(temperature: bool) -> Any:
306 payload = press_leg(line("bottle-sv", "KB", "SV"), line("sv-tank", "SV", "TK"))
307 params = {"pressure": P(4000, "psi")}
308 if temperature:
309 params["temperature"] = P(293, "K")
310 payload["nodes"].append(
311 node("COPV", "TANK", "TK-1", fluid="nitrogen", params=params)
312 )
313 return payload
314
315
316def _open(payload: Any): # type: ignore[no-untyped-def]
317 from pathlib import Path
318
319 from feedtwin.session import Session, load_machine
320 from feedtwin.session.statemachine import bind
321
322 tables = (
323 Path(__file__).resolve().parents[3] / "feed-twin" / "backend" / "statemachines"
324 )
325 if not tables.is_dir():
326 pytest.skip("state machine tables absent")
327 model = assemble_model(read_diagram(payload, name="press leg"), diagram_id="t")
328 machine = load_machine(tables=tables)
329 return model, Session(model, machine, bind(machine, {}))
330
331
333 """Nitrogen stated at 293 K is above its 126 K critical point: it can only be
334 gas, so the TANK is the pressurant bottle. LE4 drew its COPV this way, and
335 read as a propellant tank it could not even open (a propellant tank starts
336 from a saturated liquid that does not exist there)."""
337 model, session = _open(_stand_with_copv_tank(temperature=True))
338 assert "COPV" in session.bottles and "COPV" not in session.tanks
339 assert [w for w in model.report.warnings if w.startswith("TK-1") and "KBOTTLE" in w]
340
341
343 """LE4's COPV gives no volume. It was given a 44 L K-bottle, so a burn took
344 250 psi out of a bottle that really loses thousands: 45 scf of air at 4500
345 psi is a 4.64 L cylinder (4.6871 L with its fittings), the one the stand and
346 the Study run."""
347 _, session = _open(_stand_with_copv_tank(temperature=True))
348 assert session.bottles["COPV"].volume.volume == pytest.approx(4.6871e-3)
349 assert any("4.69 L assumed" in a for a in session.assumptions)
350
351
353 """With no temperature the drawing has not ruled a liquid out, so nothing is
354 re-read -- and the stand refuses to open with the tank named and the reason
355 given, rather than CoolProp's "rhoV is invalid" as a bare 500."""
356 with pytest.raises(AssemblyError, match="TK-1") as caught:
357 _open(_stand_with_copv_tank(temperature=False))
358 assert "critical temperature (126 K)" in str(caught.value)
359 assert "KBOTTLE" in str(caught.value)
360
361
363 None
364):
365 """LE4's regulator dome line: a solenoid, then a quick-disconnect left open
366 to nothing. With the solenoid shut that run is an island cut off from every
367 fixed pressure. Peeled from both ends, the stubs met in the middle and left
368 one node with no equation and no live end -- "the Jacobian could not be
369 factorised" on every solve with the solenoid shut, which in a DAQ sequence
370 that never commands it is every solve."""
371 from feedtwin.solve.steady import solve_steady
372
373 payload = press_leg(line("bottle-sv", "KB", "SV"), line("sv-tank", "SV", "TK"))
374 payload["nodes"] += [
375 node(
376 "DS", "SOL", "DOME-SOL", params={"Cv": P(1.0, "Cv"), "bore": P(4.0, "mm")}
377 ),
378 node("QDX", "QD", "QD-DOME"),
379 node("QDF", "QD", "QD-FILL"),
380 ]
381 payload["edges"] += [
382 line("tank-fill", "TK", "QDF", from_port="b", to_port="l"),
383 line("bottle-ds", "KB", "DS", to_port="l"),
384 line("ds-qd", "DS", "QDX", from_port="r", to_port="l"),
385 ]
386 b = built(payload)
387 for command in (0.0, 1.0):
388 result = solve_steady(
389 b.network,
390 signals={"SV-PRESS.command": 1.0, "DOME-SOL.command": command},
391 raise_on_failure=False,
392 )
393 assert result.converged, f"DOME-SOL at {command}"
394
395
396# ------------------------------------------------- what the drawing means
397
398
400 """A relief valve is shut until its set pressure; with none it never lifts.
401 It used to be built as an always-open Cv valve -- a hole to atmosphere on
402 the tank it guards, so the stand could never hold press."""
403 payload = press_leg(line("bottle-sv", "KB", "SV"), line("sv-tank", "SV", "TK"))
404 payload["nodes"] += [node("RV", "RV", "RV-F"), node("QDF", "QD", "QD-FILL")]
405 payload["edges"] += [
406 line("tank-rv", "TK", "RV", from_port="t", to_port="r"),
407 line("tank-fill", "TK", "QDF", from_port="b", to_port="l"),
408 ]
409 b = built(payload)
410 assert "RV" not in b.network.branches
411 assert not [
412 n for n, v in b.network.nodes.items() if n.startswith("RV.") and v.pressure
413 ]
414 assert [w for w in b.warnings if w.startswith("RV-F") and "read as shut" in w]
415
416
418 """pid-designer's engine dialog saves "Chamber pressure" as
419 `chamber_pressure`; hand-written drawings say `pressure`. Both are read."""
420 payload = press_leg(line("bottle-sv", "KB", "SV"), line("sv-tank", "SV", "TK"))
421 payload["nodes"].append(
422 node("ENG", "ENGINE", "ENG-1", params={"chamber_pressure": P(385, "psi")})
423 )
424 payload["edges"].append(
425 line("tank-eng", "TK", "ENG", from_port="b", to_port="fuel")
426 )
427 b = built(payload)
428 assert b.network.nodes["ENG"].pressure == pytest.approx(385 * 6894.757293168361)
429 assert not [w for w in b.warnings if w.startswith("ENG-1 has no pressure")]
430
431
432def _le4_like(fuel_main_label: str = "FM-R") -> Any:
433 """Bottle, regulator-less press manifold, two tanks, two press solenoids,
434 two mains to an engine -- tagged the way LE4 tags them."""
435 payload = {
436 "nodes": [
437 node(
438 "KB",
439 "KBOTTLE",
440 "TK-1",
441 fluid="nitrogen",
442 params={"pressure": P(4000, "psi"), "temperature": P(293, "K")},
443 ),
444 node("MF", "MANIFOLD", "MF-1"),
445 node(
446 "FT",
447 "TANK",
448 "TK-2",
449 fluid="ethanol",
450 params={
451 "pressure": P(580, "psi"),
452 "temperature": P(293, "K"),
453 "volume": P(8.19, "L"),
454 },
455 ),
456 node(
457 "OT",
458 "TANK",
459 "TK-3",
460 fluid="oxygen",
461 params={
462 "pressure": P(585, "psi"),
463 "temperature": P(90.19, "K"),
464 "volume": P(8.19, "L"),
465 },
466 ),
467 node(
468 "FP",
469 "SOL",
470 "FU_SOL_R",
471 params={"Cv": P(4, "Cv"), "bore": P(6.35, "mm")},
472 ),
473 node(
474 "OP",
475 "SOL",
476 "OU_SOL_R",
477 params={"Cv": P(4, "Cv"), "bore": P(6.35, "mm")},
478 ),
479 node(
480 "FM",
481 "ROT",
482 fuel_main_label,
483 params={"Cv": P(20, "Cv"), "bore": P(12.7, "mm")},
484 ),
485 node(
486 "OM", "ROT", "OM-R", params={"Cv": P(20, "Cv"), "bore": P(12.7, "mm")}
487 ),
488 node("ENG", "ENGINE", "ENG-1", params={"chamber_pressure": P(385, "psi")}),
489 ],
490 "edges": [
491 line("kb-mf", "KB", "MF"),
492 line("mf-fp", "MF", "FP", to_port="l"),
493 line("ft-fp", "FT", "FP", from_port="t", to_port="r"),
494 line("mf-op", "MF", "OP", to_port="l"),
495 line("ot-op", "OT", "OP", from_port="t", to_port="r"),
496 line("ft-fm", "FT", "FM", from_port="b", to_port="l"),
497 line("fm-eng", "FM", "ENG", from_port="r", to_port="fuel"),
498 line("ot-om", "OT", "OM", from_port="b", to_port="l"),
499 line("om-eng", "OM", "ENG", from_port="r", to_port="t"),
500 ],
501 }
502 return payload
503
504
505def _machine(): # type: ignore[no-untyped-def]
506 from pathlib import Path
507
508 from feedtwin.session import load_machine
509
510 tables = (
511 Path(__file__).resolve().parents[3] / "feed-twin" / "backend" / "statemachines"
512 )
513 if not tables.is_dir():
514 pytest.skip("state machine tables absent")
515 return load_machine(tables=tables)
516
517
519 b = built(_le4_like())
520 assert b.valve_roles == {
521 "FP": frozenset({"fuel", "press"}),
522 "OP": frozenset({"lox", "press"}),
523 "FM": frozenset({"fuel", "main"}),
524 "OM": frozenset({"lox", "main"}),
525 }
526
527
529 """A motor where the rotary has a pneumatic actuator changes nothing about
530 what the valve does on the stand."""
531 payload = _le4_like()
532 for n in payload["nodes"]:
533 if n["id"] in ("FM", "OM"):
534 n["data"]["componentType"] = "MOV"
535 b = built(payload)
536 assert b.valve_roles["FM"] == frozenset({"fuel", "main"})
537 assert b.valve_roles["OM"] == frozenset({"lox", "main"})
538
539
541 """ "FU_SOL_R" shares no word with "Fuel Press", so LE4 bound nothing and
542 Fire opened nothing. Bound by role, every press and main is commanded."""
543 from feedtwin.session.statemachine import bind
544
545 b = built(_le4_like())
546 labels = {
547 sid: label
548 for sid, label in (
549 ("FP", "FU_SOL_R"),
550 ("OP", "OU_SOL_R"),
551 ("FM", "FM-R"),
552 ("OM", "OM-R"),
553 )
554 }
555 machine = _machine()
556 assert bind(machine, labels).to_symbol == {} # names alone: nothing
557 binding = bind(machine, labels, roles=b.valve_roles)
558 assert {
559 a: binding.to_symbol.get(a)
560 for a in ("Fuel Press", "LOX Press", "Fuel Main", "LOX Main")
561 } == {"Fuel Press": "FP", "LOX Press": "OP", "Fuel Main": "FM", "LOX Main": "OM"}
562 assert set(binding.by_role) == {"Fuel Press", "LOX Press", "Fuel Main", "LOX Main"}
563 assert binding.uncommanded == ()
564
565
567 from feedtwin.session.statemachine import bind
568
569 b = built(_le4_like(fuel_main_label="MV-FU"))
570 labels = {"FP": "FU_SOL_R", "OP": "OU_SOL_R", "FM": "MV-FU", "OM": "OM-R"}
571 binding = bind(_machine(), labels, roles=b.valve_roles)
572 assert binding.to_symbol["Fuel Main"] == "FM"
573 assert "Fuel Main" not in binding.by_role
574
575
577 """Two fuel press valves in parallel: which one "Fuel Press" means is not
578 settled by the plumbing, so neither is guessed."""
579 from feedtwin.session.statemachine import bind
580
581 labels = {"FP": "FU_SOL_R", "FP2": "FU_SOL_R2"}
582 roles = {"FP": frozenset({"fuel", "press"}), "FP2": frozenset({"fuel", "press"})}
583 binding = bind(_machine(), labels, roles=roles)
584 assert "Fuel Press" not in binding.to_symbol
585
586
588 """LE4's regulator is dome-loaded, with its dome filled through a QD rather
589 than by a drawn control regulator. Nothing exposed the dome, so the
590 cockpit's dome knob did not reach it and Layer X could not solve the dome
591 for a lockup ("Could not solve the dome setting"). It is a signal now --
592 and the press valves are still found through the regulator."""
593 payload = _le4_like()
594 payload["nodes"].append(
595 node(
596 "REG",
597 "PR",
598 "DPR_HP",
599 options={"domeLoaded": "yes"},
600 params={
601 "dome_pressure": P(535, "psi"),
602 "dome_bias": P(50, "psi"),
603 "Cv": P(0.8, "Cv"),
604 },
605 )
606 )
607 payload["edges"] = [e for e in payload["edges"] if e["id"] != "kb-mf"]
608 payload["edges"] += [
609 line("kb-reg", "KB", "REG", to_port="l"),
610 line("reg-mf", "REG", "MF", from_port="r"),
611 ]
612 b = built(payload)
613 assert b.actuators["REG"] == "DPR_HP.dome"
614 assert b.valve_roles["FP"] == frozenset({"fuel", "press"})
615 assert b.valve_roles["OP"] == frozenset({"lox", "press"})
616 assert "REG" not in b.valve_roles
617
618
619# ------------------------------------------------- a tank's top, and its GSE vent
620
621
622def _le4_with_top_manifold(*, vent_valve: bool = False, rotation: int = 0) -> Any:
623 """LE4's LOX tank top: a manifold on the tank's ``t2`` port carrying a
624 transducer and a quick-disconnect with nothing beyond it -- the GSE vent
625 couples there, and the vent valve is on the cart."""
626 payload = _le4_like()
627 if rotation:
628 for n in payload["nodes"]:
629 if n["id"] == "OT":
630 n["data"]["rotation"] = rotation
631 payload["nodes"] += [
632 node("VM", "MANIFOLD", "MF-3"),
633 node("PT", "PT", "QD_PT_R"),
634 node("QD", "QD", "QD_OVA"),
635 ]
636 payload["edges"] += [
637 line("ot-vm", "OT", "VM", from_port="t2"),
638 line("vm-pt", "VM", "PT", to_port="b"),
639 line("vm-qd", "VM", "QD", to_port="r"),
640 ]
641 if vent_valve:
642 payload["nodes"].append(
643 node(
644 "OV",
645 "SOL",
646 "OV_SOL",
647 params={"Cv": P(1.7, "Cv"), "bore": P(6.35, "mm")},
648 )
649 )
650 payload["edges"].append(line("vm-ov", "VM", "OV", to_port="l"))
651 return payload
652
653
654def _tank_end(b, edge_id: str, tank: str) -> str: # type: ignore[no-untyped-def]
655 branch = b.network.branches[edge_id]
656 ports = b.tanks[tank]
657 return next(
658 e
659 for e in (branch.upstream, branch.downstream)
660 if e in (ports.ullage, ports.outlet)
661 )
662
663
665 """It reaches no supply and no atmosphere, so topology alone hung it on the
666 *liquid outlet*: LE4's LOX vent manifold read as full of LOX, and its
667 transducer read the outlet. Drawn on the top port, it is on the ullage."""
669 assert _tank_end(b, "ot-vm", "OT") == b.tanks["OT"].ullage
670 assert b.network.nodes["VM"].phase == "gas"
671
672
674 b = built(_le4_with_top_manifold(rotation=90))
675 assert _tank_end(b, "ot-vm", "OT") == b.tanks["OT"].outlet
676
677
679 """No vent valve on the drawing: the pad's Ox Fill boiled TK-3 up to the
680 717 psig critical pin and the sequence never left it. The disconnect is
681 where the cart's vent couples, so the table's LOX Vent opens it."""
682 from feedtwin.session.statemachine import bind
683
685 assert b.actuators["QD"] == "QD_OVA.command"
686 assert b.valve_roles["QD"] == frozenset({"lox", "vent"})
687 branch = b.network.branches["QD"]
688 ends = {branch.upstream, branch.downstream}
689 assert any(b.network.nodes[e].pressure == AMBIENT for e in ends)
690 assert any("QD_OVA" in w and "GSE vent" in w for w in b.warnings)
691 labels = {
692 "FP": "FU_SOL_R",
693 "OP": "OU_SOL_R",
694 "FM": "FM-R",
695 "OM": "OM-R",
696 "QD": "QD_OVA",
697 }
698 binding = bind(_machine(), labels, roles=b.valve_roles)
699 assert binding.to_symbol["LOX Vent"] == "QD"
700
701
703 b = built(_le4_with_top_manifold(vent_valve=True))
704 assert b.valve_roles["OV"] == frozenset({"lox", "vent"})
705 assert "QD" not in b.actuators
706 assert type(b.network.branches["QD"].component).__name__ == "Fitting"
707 assert not any("GSE vent" in w for w in b.warnings)
708
709
711 """The cockpit's own pad: Ox Fill with the LOX Vent bound to the disconnect
712 holds the ullage near ambient instead of boiling it toward critical."""
713 from feedtwin.session import Session, load_machine
714 from feedtwin.session.gauge import psig
715 from feedtwin.session.statemachine import bind
716
717 _machine() # skips when the tables are absent
718 from pathlib import Path
719
720 tables = (
721 Path(__file__).resolve().parents[3] / "feed-twin" / "backend" / "statemachines"
722 )
723 payload = _le4_with_top_manifold()
724 model = assemble_model(read_diagram(payload, name="le4 top"), diagram_id="t")
725 machine = load_machine(tables=tables)
726 labels = {
727 n.id: n.label for n in model.diagram.nodes if n.id in model.built.actuators
728 }
729 session = Session(
730 model, machine, bind(machine, labels, roles=model.built.valve_roles)
731 )
732 for state in ("Armed", "Ox Fill"):
733 session.command_state(state)
734 # A minute of fill: capped, the ullage is past 100 psig by now and climbing
735 # toward the 717 psig critical pin; vented, it sits under 10.
736 for _ in range(120):
737 sample = session.step(0.5)
738 ullage = model.built.tanks["OT"].ullage
739 assert psig(sample.pressures[ullage]) < 50.0
740
741
742# ------------------------------------------------------- lines the drawing did not size
743
744
745def _sizeless(payload: Any, *edge_ids: str) -> Any:
746 for e in payload["edges"]:
747 if e["id"] in edge_ids:
748 e["data"]["params"] = {}
749 return payload
750
751
753 """A line drawn with no length and no bore is two parts screwed together --
754 a tank's port into its valve. It used to be solved as the fallback tube, a
755 metre of 9.5 mm, which put 94 psi on a LOX feed that has none."""
756 b = built(_sizeless(_le4_like(), "ot-om"))
757 assert "ot-om" not in b.network.branches
758 assert b.network.branches["OM"].upstream == b.tanks["OT"].outlet
759 assert not [w for w in b.warnings if "fallback tube" in w]
760
761
763 from feedtwin.engine.design import DischargeModel, EngineDesign, InjectorSide
764
765 design = EngineDesign(
766 name="fixture",
767 injector_type="impinging",
768 oxidiser=InjectorSide("oxygen", 99.245e-6, 2.2e-3, DischargeModel(), 26),
769 fuel=InjectorSide("ethanol", 75.125e-6, 1.92e-3, DischargeModel(), 26),
770 throat_area=1.7e-3,
771 design_mixture_ratio=1.65,
772 )
773 payload = _sizeless(_le4_like(), "om-eng")
774 b = build_network(read_diagram(payload, name="direct"), engine=design)
775 leg = b.network.branches[b.engine_ports["oxidiser"]]
776 assert leg.upstream == b.network.branches["OM"].downstream
777 # Not read as a vent: an outlet with only the injector leg on it once
778 # pinned at atmosphere and dumped the LOX overboard.
779 assert b.network.nodes[leg.upstream].pressure is None
780 assert not [w for w in b.warnings if "OM-R" in w and "venting" in w]
781 assert leg.downstream == b.engine_ports["chamber"]
782 assert "om-eng" not in b.network.branches
783 assert b.valve_roles["OM"] == frozenset({"lox", "main"})
784
785
787 payload = _le4_like()
788 edge = next(e for e in payload["edges"] if e["id"] == "ot-om")
789 edge["data"]["params"] = {"length": P(0.5, "m")}
790 b = built(payload)
791 assert "ot-om" in b.network.branches
792 found = [w for w in b.warnings if "fallback" in w and "TK-3 -> OM-R" in w]
793 assert found, b.warnings
794
795
797 """A bottle drawn straight into a tank: two pressures cannot be one place."""
798 payload = _le4_like()
799 payload["edges"].append(line("kb-ft", "KB", "FT", to_port="t2"))
800 payload["edges"][-1]["data"]["params"] = {}
801 b = built(payload)
802 assert "kb-ft" in b.network.branches
803 assert any("TK-1 -> TK-2" in w for w in b.warnings), b.warnings
804
805
807 """A stated 0 mm passed the reader where a missing bore was reported, and the
808 run then contributed no loss at all (LE4's fuel tank-to-main run)."""
809 payload = _le4_like()
810 edge = next(e for e in payload["edges"] if e["id"] == "ft-fm")
811 edge["data"]["segments"] = [
812 {
813 "id": "seg_1",
814 "method": "itemised",
815 "bore": P(0, "mm", "estimated"),
816 "length": P(429.5, "mm"),
817 }
818 ]
819 b = built(payload)
820 assert any("seg_1" in w and "no bore" in w for w in b.warnings), b.warnings
821
822
824 """A line-level 0 mm with no segments built without a word, as a branch no
825 flow can pass, and a steady solve through it stalls on its first step. It
826 is said; the line itself is left as drawn, because Layer X reads that 0 as
827 "not a line" (its preflight names it) and a fallback here would contradict
828 it."""
829 payload = _le4_like()
830 edge = next(e for e in payload["edges"] if e["id"] == "ot-om")
831 edge["data"]["params"] = {"length": P(0.5, "m"), "bore": P(0, "mm", "estimated")}
832 b = built(payload)
833 line = b.network.branches["ot-om"].component
834 assert line.p["bore"] == 0.0, "as drawn: said, not replaced"
835 assert any(
836 "ot-om (TK-3 -> OM-R)" in w and "bore of 0 mm" in w and "no flow" in w
837 for w in b.warnings
838 ), b.warnings
839 # The same line drawn with a real bore says nothing of the kind.
840 edge["data"]["params"]["bore"] = P(9.5, "mm")
841 sized = built(payload)
842 assert not any("ot-om" in w and "no flow can pass" in w for w in sized.warnings)
843
844
846 """A run split before its diameter was given, one stretch bored and one
847 not. The solver sizes the bare stretch at the run's narrowest stated bore
848 (`SegmentedPipe`), so it is priced -- and the reader used to say it
849 "contributes nothing", which the run report then repeated."""
850 from feedtwin.comps import build_component
851 from feedtwin.comps.base import FlowConditions
852 from feedtwin.model import ComponentInstance
853 from feedtwin.pid.segments import read_segments
854
855 def run(first_bore: float | None) -> Any:
856 first: dict[str, Any] = {"id": "s1", "length": P(300, "mm")}
857 if first_bore is not None:
858 first["bore"] = P(first_bore, "mm")
859 return read_segments(
860 [first, {"id": "s2", "bore": P(7.7, "mm"), "length": P(400, "mm")}],
861 "L1",
862 )
863
864 def dp(loss: Any) -> float:
865 line = build_component(
866 ComponentInstance.build(
867 "L1", "pipe", {}, model="segmented", segments=loss.segments
868 )
869 )
870 flow = FlowConditions(rho=789.0, mu=1.2e-3, p_upstream=4e6, temperature=293.15)
871 return float(line.pressure_drop(0.2, flow))
872
873 for bare in (None, 0.0):
874 loss = run(bare)
875 assert dp(loss) == pytest.approx(dp(run(7.7))), "priced at 7.7 mm"
876 (said,) = [w for w in loss.warnings if "s1" in w]
877 assert "narrowest stated bore, 7.70 mm" in said, said
878 assert "contributes nothing" not in said, said
879
880 # With no bore anywhere on the run, nothing is the honest answer, and said.
881 alone = read_segments([{"id": "s1", "length": P(300, "mm")}], "L1")
882 assert dp(alone) == 0.0
883 assert any("contributes nothing" in w for w in alone.warnings), alone.warnings
884
885
887 """A K multiplies a dynamic head, which needs a bore. With none on the run
888 the segment priced nothing and the reader said nothing."""
889 from feedtwin.pid.segments import read_segments
890
891 k = {"id": "k1", "method": "measured_K", "K": P(2.0, "-")}
892 alone = read_segments([k], "L1")
893 assert any(
894 "k1" in w and "contributes nothing" in w for w in alone.warnings
895 ), alone.warnings
896 borrowed = read_segments(
897 [k, {"id": "s2", "bore": P(6.0, "mm"), "length": P(0.4, "m")}], "L1"
898 )
899 assert any(
900 "k1" in w and "narrowest stated bore, 6.00 mm" in w for w in borrowed.warnings
901 ), borrowed.warnings
902 own = read_segments([{**k, "bore": P(6.0, "mm")}], "L1")
903 assert not any("k1" in w and "bore" in w for w in own.warnings), own.warnings
904
905
907 """Press valves joined straight onto their tanks: the two ullages trade gas
908 through the manifold, and what one sent and the other refused was handed
909 to the bottle, whose draw cannot go negative -- so it vanished. LE4 lost
910 7 g/s this way and emptied its COPV before ignition."""
911 from feedtwin.session import Session, load_machine
912 from feedtwin.session.burn import press_valves
913 from feedtwin.session.statemachine import bind
914
915 _machine() # skips when the tables are absent
916 from pathlib import Path
917
918 tables = (
919 Path(__file__).resolve().parents[3] / "feed-twin" / "backend" / "statemachines"
920 )
921 payload = _sizeless(_le4_like(), "ft-fp", "ot-op")
922 payload["nodes"].append(
923 node(
924 "REG",
925 "PR",
926 "DPR_HP",
927 options={"domeLoaded": "yes"},
928 params={
929 "dome_pressure": P(500, "psi"),
930 "dome_bias": P(50, "psi"),
931 "Cv": P(0.8, "Cv"),
932 },
933 )
934 )
935 payload["edges"] = [e for e in payload["edges"] if e["id"] != "kb-mf"]
936 payload["edges"] += [
937 line("kb-reg", "KB", "REG", to_port="l"),
938 line("reg-mf", "REG", "MF", from_port="r"),
939 ]
940 model = assemble_model(read_diagram(payload, name="direct press"), diagram_id="t")
941 machine = load_machine(tables=tables)
942 labels = {
943 n.id: n.label for n in model.diagram.nodes if n.id in model.built.actuators
944 }
945 session = Session(
946 model, machine, bind(machine, labels, roles=model.built.valve_roles)
947 )
948 session.prime(fill_fraction=0.95, tank_psi=563.0, copv_psi=4000.0, state="Ready")
949 for valve in press_valves(session):
950 session.set_valve(valve, True)
951
952 def pressurant() -> float:
953 return sum(b.state.mass for b in session.bottles.values()) + sum(
954 t.state.ullage.mass for t in session.tanks.values()
955 )
956
957 before = pressurant()
958 for _ in range(40):
959 session.step(0.05)
960 assert pressurant() == pytest.approx(before, rel=1e-9)
Definition run.py:1
None test_a_directed_valve_dragged_against_the_flow_reads_its_ports(str ctype)
None test_two_tanks_on_one_direct_press_manifold_conserve_pressurant()
str _tank_end(b, str edge_id, str tank)
None test_a_directed_valve_drawn_in_flow_order_is_read_as_before(str ctype)
None test_a_fill_disconnect_left_capped_does_not_crash_the_solve()
None test_a_drawn_vent_valve_leaves_the_disconnect_capped()
None test_a_motorized_main_valve_has_the_same_job_as_a_rotary_one()
dict[str, object] P(float value, str unit, str source="measured")
Any _le4_with_top_manifold(*, bool vent_valve=False, int rotation=0)
None test_a_turned_tanks_ports_say_nothing_about_up()
None test_without_ports_a_one_sided_valve_is_still_a_vent()
None test_a_tank_that_only_forgot_its_temperature_is_not_reread_and_says_why()
None test_a_check_valve_drawn_backwards_checks_the_flow()
Any _le4_like(str fuel_main_label="FM-R")
None test_a_part_the_library_refuses_is_named_and_fails_the_assembly()
None test_a_bottle_with_no_volume_is_the_stands_copv_not_a_k_bottle()
None test_a_copv_drawn_as_a_tank_is_read_as_the_pressurant_supply()
None test_two_fixed_pressures_joined_directly_stay_a_line()
None test_a_line_with_no_size_is_a_direct_connection()
None test_valves_the_names_do_not_match_are_bound_by_what_they_do()
Any press_leg(*dict[str, object] lines, dict[str, Any]|None sol=None)
None test_a_direct_line_into_the_engine_starts_the_injector_leg_at_the_valve()
( None) test_a_capped_fitting_behind_a_shut_valve_does_not_make_the_solve_singular()
None test_a_valve_with_lines_on_both_sides_is_read_exactly_as_before()
None test_a_capped_disconnect_on_an_unvented_tank_top_is_its_gse_vent()
None test_a_manifold_on_a_tanks_top_port_is_on_its_ullage()
dict[str, object] node(str nid, str ctype, str label, **object data)
dict[str, object] line(str eid, str a, str b, str from_port="", str to_port="")
None test_a_stretch_with_no_bore_on_a_bored_run_is_sized_at_the_run_and_said()
None test_a_line_with_half_a_size_takes_the_fallback_for_the_rest_and_says_so()
None test_a_press_line_drawn_from_the_tank_lands_on_the_valves_other_side()
None test_a_dome_nothing_loads_is_the_operators_setting()
None test_a_name_that_matches_still_wins_over_a_role()
None test_a_tank_full_of_gas_is_told_to_be_a_pressurant_bottle()
None test_the_engine_symbols_chamber_pressure_is_its_boundary()
None test_each_commandable_valves_job_is_read_off_the_plumbing()
None test_the_same_holds_when_both_lines_are_drawn_out_of_the_valve()
Any _through(str ctype, *dict[str, object] lines)
Any _sizeless(Any payload, *str edge_ids)
Any _stand_with_copv_tank(bool temperature)
None test_a_relief_valve_with_no_set_pressure_is_shut_not_a_hole()