STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
hookup.py
Go to the documentation of this file.
1"""How a stand's controls reach its drawing: which valve each state-machine
2actuator drives, and which knob sets which regulator.
3
4A drawing says what is plumbed; it does not say what the operator's panel
5calls it. The DAQ's table commands "LOX Press" and "Fuel Main"; the drawing
6has OU_SOL_R and FM-R. The GSE page has a hand-loaded regulator or three; the
7drawing has PR symbols on two pages. Matching those up by name works for most
8of a drawing (:func:`feedtwin.session.statemachine.bind`) and fails quietly for
9the rest -- a main valve nothing commands, a cart regulator nothing turns.
10
11A :class:`Hookup` is that matching written down, per drawing, by whoever knows
12the stand. :func:`suggest` fills it the way the twin always matched -- names,
13then what each valve is plumbed to do, and the one dome knob -- so an imported
14drawing works at once and the person only has to fix what the names got wrong:
15
16* ``channels``: the DAQ box, written down. Each is one connector on one of the
17 DAQ's boards, cabled to one symbol, with the name the DAQ gives it -- the
18 role name the console shows and, for a solenoid, the state table's row. That
19 is the whole of how the real stand is declared (``[actuator_roles]``: name ->
20 board and channel; the table opens names), so a hookup that has channels is
21 *wired*: a row drives the valve on the channel of that name, and a row with
22 no channel drives nothing. Without channels (``None``) the hookup is the old
23 kind and the next field decides.
24* ``valves``: actuator -> drawing id, only for what a person pinned. An empty
25 id means "this actuator has no valve here". Everything not pinned is matched
26 automatically, every time, so a drawing that grows a valve picks it up.
27 Ignored once the hookup is wired.
28* ``machine``: the stand's own state table when somebody edited it (the
29 DAQ's State tab, in the twin); ``None`` is the shipped DAQ table.
30* ``knobs``: each a dial on the GSE page and the regulators it sets. A knob on
31 a dome loader (the hand-loaded control regulator) sets that loader, and the
32 dome follows through it as it always did; on a dome-loaded regulator with no
33 loader drawn it sets the dome; on a plain regulator it sets the setpoint. A
34 regulator on no knob holds what the drawing says. The knob with id
35 :data:`DOME` is the session's ``Setup.dome_psi`` -- the cockpit's dome knob
36 and Layer X's lockup solve keep driving it.
37
38Gauge pressures throughout, as on the panel.
39"""
40
41from __future__ import annotations
42
43from dataclasses import dataclass, field, replace
44from typing import Any, Mapping
45
46from feedtwin.session.gauge import psig
47from feedtwin.session.model import Model
49 Binding,
50 StateMachine,
51 bind,
52 machine_from_dict,
53)
54
55#: Bumped whenever the stored form changes meaning. 2 added the DAQ box
56#: (``channels``, ``rows``, ``auto``) and an edited state table; 1 still reads.
57SCHEMA = 2
58_READS = (1, 2)
59
60#: The knob the session's ``Setup.dome_psi`` drives.
61DOME = "dome"
62
63#: The knob the session's ``Setup.copv_target_psi`` drives: what the vehicle's
64#: pressurant bottle is charged to. With a fill regulator drawn on the cart it
65#: sets that regulator; with none, it is the built-in charge's target. One dial
66#: either way.
67CHARGE = "charge"
68
69#: Where a hand-loaded regulator's knob starts when the drawing gives it no
70#: setting [psig]: the regulator fallback the build fills in (500 psia,
71#: feedtwin.pid.network.FALLBACKS), so giving it a knob changes nothing.
72UNSET_PSIG = 500.0 - 14.695948775513449
73
74
75@dataclass(frozen=True, slots=True)
76class Knob:
77 """One dial on the GSE page, and the regulators it sets."""
78
79 id: str
80 label: str
81 regulators: tuple[str, ...] = ()
82 """Drawing ids of the PR symbols this knob sets."""
83 psig: float = 500.0
84 """Where it starts [psig]."""
85 low: float = 0.0
86 high: float = 1000.0
87
88 def to_dict(self) -> dict[str, Any]:
89 return {
90 "id": self.id,
91 "label": self.label,
92 "regulators": list(self.regulators),
93 "psig": self.psig,
94 "low": self.low,
95 "high": self.high,
96 }
97
98 @classmethod
99 def from_dict(cls, raw: Mapping[str, Any]) -> "Knob":
100 return cls(
101 id=str(raw["id"]),
102 label=str(raw.get("label") or raw["id"]),
103 regulators=tuple(str(r) for r in raw.get("regulators") or ()),
104 psig=float(raw.get("psig", 500.0)),
105 low=float(raw.get("low", 0.0)),
106 high=float(raw.get("high", 1000.0)),
107 )
108
109
110@dataclass(frozen=True, slots=True)
112 """One connector on the DAQ box: which board, which connector, the symbol
113 its cable goes to, and the name the DAQ gives it. ``board`` is the app's
114 word for the board (feed-twin: ``sol12``, ``pt_low``...); the library only
115 needs the name and the symbol."""
116
117 board: str
118 slot: int
119 name: str
120 symbol: str
121
122 def to_dict(self) -> dict[str, Any]:
123 return {
124 "board": self.board,
125 "slot": self.slot,
126 "name": self.name,
127 "symbol": self.symbol,
128 }
129
130 @classmethod
131 def from_dict(cls, raw: Mapping[str, Any]) -> "Channel":
132 return cls(
133 board=str(raw["board"]),
134 slot=int(raw["slot"]),
135 name=str(raw.get("name") or "").strip(),
136 symbol=str(raw.get("symbol") or ""),
137 )
138
139
140@dataclass(frozen=True, slots=True)
141class Hookup:
142 """What a person decided about one drawing's controls."""
143
144 valves: Mapping[str, str] = field(default_factory=dict)
145 """Pinned actuators: table name -> drawing id ("" = no valve here)."""
146 knobs: tuple[Knob, ...] = ()
147 aliases: Mapping[str, str] = field(default_factory=dict)
148 """What the console calls a valve or transducer, by drawing id (or
149 channel id, ``engine.pc``) -- "Chamber pressure" for a PT tagged PC. A
150 name only: nothing is bound, solved or recorded by it. Absent, the
151 console shows the drawing's own tag. A symbol on a channel goes by the
152 channel's name instead (:meth:`names`)."""
153 channels: tuple[Channel, ...] | None = None
154 """The DAQ box: every connector with a cable on it. ``None``: not wired,
155 the old hookup (pins and automatic matching). ``()``: wired, and nothing
156 is plugged in -- no row drives anything."""
157 rows: Mapping[str, int] = field(default_factory=dict)
158 """How many rows of connectors each board shows. The panel's, not the
159 stand's; kept so the box looks the same tomorrow."""
160 auto: frozenset[str] = frozenset()
161 """Rows still matched automatically on a wired hookup: on a stand of the
162 rocket alone (:func:`on_vehicle`), the ones whose cable went to the cart,
163 which is not there. Empty on anything a person saved."""
164 machine: StateMachine | None = None
165 """The stand's own state table, when somebody edited it. ``None``: the
166 shipped DAQ table."""
167
168 @property
169 def wired(self) -> bool:
170 return self.channels is not None
171
172 def knob_for(self, regulator: str) -> Knob | None:
173 return next((k for k in self.knobs if regulator in k.regulators), None)
174
175 def channel_of(self, symbol: str) -> Channel | None:
176 return next((c for c in self.channels or () if c.symbol == symbol), None)
177
178 def names(self) -> dict[str, str]:
179 """What the console calls each thing, by drawing id: a symbol on a
180 channel by the channel's name, anything else by its alias."""
181 return {
182 **dict(self.aliases),
183 **{c.symbol: c.name for c in self.channels or () if c.symbol},
184 }
185
186 def to_dict(self) -> dict[str, Any]:
187 """The stored form. A hookup of the old kind -- no box, no edited
188 table -- is written exactly as it always was (schema 1), so a run
189 recorded before the box existed and one recorded since diff clean."""
190 out: dict[str, Any] = {
191 "schema": 1,
192 "valves": dict(self.valves),
193 "knobs": [k.to_dict() for k in self.knobs],
194 "aliases": dict(self.aliases),
195 }
196 if self.channels is not None:
197 out["channels"] = [c.to_dict() for c in self.channels]
198 if self.rows:
199 out["rows"] = dict(self.rows)
200 if self.auto:
201 out["auto"] = sorted(self.auto)
202 if self.machine is not None:
203 out["machine"] = self.machine.to_dict()
204 if len(out) > 4:
205 out["schema"] = SCHEMA
206 return out
207
208 @classmethod
209 def from_dict(cls, raw: Mapping[str, Any]) -> "Hookup":
210 schema = int(raw.get("schema", SCHEMA))
211 if schema not in _READS:
212 raise ValueError(f"hookup schema {schema}; this feedtwin reads {SCHEMA}")
213 knobs = tuple(Knob.from_dict(k) for k in raw.get("knobs") or ())
214 ids = [k.id for k in knobs]
215 if len(set(ids)) != len(ids):
216 raise ValueError("two knobs share an id")
217 owned = [r for k in knobs for r in k.regulators]
218 if len(set(owned)) != len(owned):
219 raise ValueError("a regulator is on two knobs; it can only be set once")
220 listed = raw.get("channels")
221 channels = (
222 None if listed is None else tuple(Channel.from_dict(c) for c in listed)
223 )
224 _check_channels(channels or ())
225 machine_raw = raw.get("machine")
226 return cls(
227 valves={str(a): str(s or "") for a, s in (raw.get("valves") or {}).items()},
228 knobs=knobs,
229 aliases={
230 str(k): str(v).strip()
231 for k, v in (raw.get("aliases") or {}).items()
232 if str(v or "").strip()
233 },
234 channels=channels,
235 rows={
236 str(board): max(1, int(n))
237 for board, n in (raw.get("rows") or {}).items()
238 },
239 auto=frozenset(str(a) for a in raw.get("auto") or ()),
240 machine=machine_from_dict(machine_raw) if machine_raw else None,
241 )
242
243
244def _check_channels(channels: tuple[Channel, ...]) -> None:
245 """A box that cannot be: two cables in one connector, one symbol on two
246 connectors, a connector with no name or two with the same one (the DAQ
247 resolves rows by name, so one of them would be unreachable)."""
248 places = [(c.board, c.slot) for c in channels]
249 if len(set(places)) != len(places):
250 raise ValueError("two cables in one connector")
251 if any(c.slot < 1 for c in channels):
252 raise ValueError("connectors are numbered from 1")
253 symbols = [c.symbol for c in channels]
254 if "" in symbols:
255 raise ValueError("a connector's cable goes to no symbol")
256 if len(set(symbols)) != len(symbols):
257 raise ValueError("one symbol on two connectors")
258 names = [c.name.casefold() for c in channels]
259 if "" in names:
260 raise ValueError("a connector has no name")
261 if len(set(names)) != len(names):
262 twice = sorted({c.name for c in channels if names.count(c.name.casefold()) > 1})
263 raise ValueError(f"two connectors are named {', '.join(twice)}")
264
265
266@dataclass(frozen=True, slots=True)
268 """A PR symbol on the drawing, as a knob sees it."""
269
270 id: str
271 label: str
272 kind: str
273 """``loader`` (sets another's dome), ``dome`` (dome-loaded, no loader
274 drawn) or ``plain`` (a hand-loaded setpoint)."""
275 page: str
276 drawn_psig: float | None
277 """What the drawing sets it to [psig], if it says."""
278 signal: str
279 """The signal a knob drives (``<label>.dome``); for a loader, the loaded
280 regulator's."""
281
282
283@dataclass(frozen=True, slots=True)
285 id: str
286 label: str
287 page: str
288 role: tuple[str, ...]
289 """What the topology says it does (``("fuel", "press")``), if it settles it."""
290
291
292def valves(model: Model) -> list[ValveInfo]:
293 """Every valve a state-machine actuator could drive. Hand valves are turned
294 by a person on the P&ID, never by the table, so they are not among them."""
295 built = model.built
296 by_id = {n.id: n for n in model.diagram.nodes}
297 out = []
298 for sid, signal in built.actuators.items():
299 if signal.endswith(".dome") or sid not in by_id or sid in built.hand_valves:
300 continue
301 node = by_id[sid]
302 out.append(
303 ValveInfo(
304 id=sid,
305 label=node.label or sid,
306 page=node.page or "Main",
307 role=tuple(sorted(built.valve_roles.get(sid, frozenset()))),
308 )
309 )
310 return sorted(out, key=lambda v: (v.page, v.label))
311
312
313def regulators(model: Model) -> list[RegulatorInfo]:
314 """Every regulator on the drawing a knob could set."""
315 built = model.built
316 loaded = {loader.signal for loader in built.dome_loaders.values()}
317 implied = drawn_settings(model)
318 out = []
319 for node in model.diagram.nodes:
320 if node.type != "PR":
321 continue
322 if f"{node.label or node.id}.dome" in loaded:
323 continue # its dome comes from the loader drawn on it: turn that
324 if node.id in built.dome_loaders:
325 kind, signal = "loader", built.dome_loaders[node.id].signal
326 param = node.params.get("setpoint")
327 elif node.options.get("domeLoaded") == "yes":
328 kind, signal = "dome", f"{node.label or node.id}.dome"
329 param = node.params.get("dome_pressure")
330 else:
331 kind, signal = "plain", f"{node.label or node.id}.dome"
332 param = node.params.get("setpoint")
333 if node.id not in built.dome_loaders and node.id not in built.network.branches:
334 continue # not built: a regulator with nothing plumbed to it
335 out.append(
337 id=node.id,
338 label=node.label or node.id,
339 kind=kind,
340 page=node.page or "Main",
341 # A blank loader reads the dome it loads (drawn_settings).
342 drawn_psig=(
343 psig(param.si) if param is not None else implied.get(node.id)
344 ),
345 signal=signal,
346 )
347 )
348 return sorted(out, key=lambda r: (r.page, r.label))
349
350
352 model: Model, dome_psig: float = 500.0, charge_psig: float = 4500.0
353) -> Hookup:
354 """The hookup the drawing implies, before anyone writes one down.
355
356 No pinned valves (names and roles decide). The knob :data:`DOME` on every
357 dome loader -- or, with none drawn, on the first dome-loaded regulator --
358 which is what the session's dome knob always drove. Then a knob for every
359 regulator a hand sets: every regulator on the ground support, whatever the
360 sheet says (the cart's regulators are turned on the pad), and any other
361 the drawing gives no setting at all. The one whose outlet charges the
362 vehicle's pressurant bottle is :data:`CHARGE` (the COPV fill), the rest are
363 knobs of their own. Every knob starts at the drawing's setting
364 (:func:`drawn_settings`); only a regulator the sheet says nothing about
365 starts at ``dome_psig``, ``charge_psig`` or where the build left it. A
366 drawing of the rocket alone whose regulators all carry their settings gets
367 only the dome knob, as before.
368 """
369 drawn = drawn_settings(model)
370 built = model.built
371 labels = {n.id: n.label or n.id for n in model.diagram.nodes}
372 knobs: list[Knob] = []
373 loaders = sorted(built.dome_loaders)
374 if loaders:
375 owned: tuple[str, ...] = tuple(loaders)
376 else:
377 first = next(
378 (sid for sid, s in built.actuators.items() if s.endswith(".dome")), ""
379 )
380 owned = (first,) if first else ()
381 if owned:
382 knobs.append(
383 Knob(
384 id=DOME,
385 label=(
386 "Dome control regulator"
387 if len(owned) != 1
388 else f"Dome control regulator ({labels.get(owned[0], owned[0])})"
389 ),
390 regulators=owned,
391 psig=_first_drawn(drawn, owned, dome_psig),
392 low=0.0,
393 high=1000.0,
394 )
395 )
396 for regulator in _hand_loaded_regulators(model, set(owned)):
397 if _charges_vehicle_bottle(model, regulator):
398 if any(k.id == CHARGE for k in knobs):
399 continue # two fill regulators: the second holds its fallback
400 knobs.append(
401 Knob(
402 id=CHARGE,
403 label=f"COPV fill ({labels.get(regulator, regulator)})",
404 regulators=(regulator,),
405 psig=drawn.get(
406 regulator,
407 _or(bottle_charge(model, regulator), charge_psig),
408 ),
409 low=0.0,
410 high=6000.0,
411 )
412 )
413 else:
414 knobs.append(
415 Knob(
416 id=regulator,
417 label=labels.get(regulator, regulator),
418 regulators=(regulator,),
419 psig=drawn.get(regulator, UNSET_PSIG),
420 low=0.0,
421 high=max(1000.0, 2.0 * drawn.get(regulator, 0.0)),
422 )
423 )
424 return Hookup(knobs=tuple(knobs))
425
426
427def on_vehicle(saved: Hookup, model: Model) -> Hookup:
428 """``saved`` for a stand built on the vehicle alone (``Setup.ignore_gse``):
429 the valves a person pinned on the vehicle kept, the knobs the cut drawing
430 suggests. The saved knobs turn the cart's regulators, which are not there;
431 with the cart's dome loader gone, the dome knob sets the dome-loaded
432 regulator itself, as on a drawing of the rocket alone, and the COPV fill
433 is the built-in charge's target.
434 """
435 ids = {n.id for n in model.diagram.nodes}
436 knobs = suggest(model).knobs
437 kept = {k.id: k for k in saved.knobs}
438 channels = saved.channels
439 auto = saved.auto
440 if channels is not None:
441 # A wired hookup keeps the rocket's connectors. A row whose cable went
442 # to the cart is matched on the rocket as the old hookup's would be
443 # (its vent row finds the tank-top disconnect, the GSE vent); a row
444 # with no cable at all stays unwired, as the person left it.
445 auto = auto | {c.name for c in channels if c.symbol not in ids}
446 channels = tuple(c for c in channels if c.symbol in ids)
447 return replace(
448 saved,
449 valves={a: v for a, v in saved.valves.items() if not v or v in ids},
450 # A knob the cut drawing also has starts where the saved one did.
451 knobs=tuple(
452 (
453 replace(k, psig=kept[k.id].psig)
454 if k.id in kept and kept[k.id].regulators == k.regulators
455 else k
456 )
457 for k in knobs
458 ),
459 channels=channels,
460 auto=auto,
461 )
462
463
464def drawn_settings(model: Model) -> dict[str, float]:
465 """What the drawing sets each regulator to [psig], by drawing id: a
466 loader's or plain regulator's ``setpoint``, a dome-loaded one's
467 ``dome_pressure``. A regulator the sheet says nothing about is absent.
468
469 A dome loader drawn with no setpoint of its own is set to what the drawing
470 says the dome it loads holds: the loaded regulator's ``dome_pressure``.
471 That number is the loader's whole job, and LE4 (6) draws it there (DPR_HP
472 535, DR-REG-G blank); without it the dome knob started at the session's
473 500 and the cockpit primed 35 psi under the Layer X stand on the same
474 drawing."""
475 out: dict[str, float] = {}
476 labels = {n.id: n.label or n.id for n in model.diagram.nodes}
477 for node in model.diagram.nodes:
478 if node.type != "PR":
479 continue
480 key = (
481 "dome_pressure"
482 if node.options.get("domeLoaded") == "yes"
483 and node.id not in model.built.dome_loaders
484 else "setpoint"
485 )
486 param = node.params.get(key)
487 if param is not None:
488 out[node.id] = psig(param.si)
489 for loader_id, loader in model.built.dome_loaders.items():
490 if loader_id in out:
491 continue
492 loaded = next(
493 (
494 n
495 for n in model.diagram.nodes
496 if n.type == "PR" and f"{labels[n.id]}.dome" == loader.signal
497 ),
498 None,
499 )
500 dome = None if loaded is None else loaded.params.get("dome_pressure")
501 if dome is not None:
502 out[loader_id] = psig(dome.si)
503 return out
504
505
506def bottle_charge(model: Model, regulator: str) -> float | None:
507 """The drawn charge [psig] of the vehicle bottle ``regulator`` fills, or
508 None: where a COPV fill knob starts when the fill regulator itself carries
509 no setting. The drawing's bottle pressure is the charge it was drawn at
510 (LE4 (6): COPV 4000, PR-1 blank), and starting the knob at the session's
511 4,500 put the gauge amber at its own "normal" charge."""
512 bottle = _charged_bottle(model, regulator)
513 if bottle is None:
514 return None
515 node = next((n for n in model.diagram.nodes if n.id == bottle), None)
516 param = None if node is None else node.params.get("pressure")
517 return None if param is None else round(psig(param.si), 6)
518
519
520def knob_starts(hookup: Hookup, model: Model) -> dict[str, float]:
521 """Where each of ``hookup``'s knobs starts on this drawing [psig], by knob
522 id: the drawing's setting of the first regulator on it that states one.
523 A knob on regulators the sheet says nothing about is absent -- it starts
524 at the session's own setting."""
525 drawn = drawn_settings(model)
526 out: dict[str, float] = {}
527 for knob in hookup.knobs:
528 found = _first_drawn(drawn, knob.regulators, float("nan"))
529 if found != found and knob.id == CHARGE:
530 found = next(
531 (
532 c
533 for c in (bottle_charge(model, r) for r in knob.regulators)
534 if c is not None
535 ),
536 float("nan"),
537 )
538 if found == found:
539 out[knob.id] = found
540 return out
541
542
544 drawn: Mapping[str, float], regulators: tuple[str, ...], default: float
545) -> float:
546 return next((drawn[r] for r in regulators if r in drawn), default)
547
548
549def _hand_loaded_regulators(model: Model, taken: set[str]) -> list[str]:
550 """Built regulators a hand sets, not already on a knob: every one on the
551 ground support (the cart's regulators are turned on the pad, whatever the
552 sheet says they were set to), and any other the drawing gives no setting."""
553 built = model.built
554 ground = (
555 frozenset()
556 if built.vehicle is None
557 else frozenset(n.id for n in model.diagram.nodes if n.id not in built.vehicle)
558 )
559 out = []
560 for node in model.diagram.nodes:
561 if node.type != "PR" or node.id in taken or node.id in built.dome_loaders:
562 continue
563 if node.options.get("domeLoaded") == "yes":
564 continue # its dome is the dome knob's
565 if node.id not in built.network.branches:
566 continue
567 if {"setpoint", "dome_pressure"} & set(node.params) and node.id not in ground:
568 continue
569 out.append(node.id)
570 return out
571
572
573def _charges_vehicle_bottle(model: Model, regulator: str) -> bool:
574 """Whether a regulator's outlet reaches the vehicle's pressurant bottle:
575 through any valve, across mated disconnects, without passing a vessel."""
576 return _charged_bottle(model, regulator) is not None
577
578
579def _or(value: float | None, default: float) -> float:
580 return default if value is None else value
581
582
583def _charged_bottle(model: Model, regulator: str) -> str | None:
584 """The drawing id of the vehicle pressurant bottle a regulator's outlet
585 reaches (see :func:`_charges_vehicle_bottle`), or None."""
586 built = model.built
587 net = built.network
588 branch = net.branches.get(regulator)
589 if branch is None:
590 return None
591 types = {n.id: n.type for n in model.diagram.nodes}
592 bottles = {
593 built.node_of.get(sid, sid): sid
594 for sid, kind in types.items()
595 if kind == "KBOTTLE" and (built.vehicle is None or sid in built.vehicle)
596 }
597 adjacent: dict[str, list[str]] = {}
598 for other in net.branches.values():
599 if other.id == regulator:
600 continue
601 adjacent.setdefault(other.upstream, []).append(other.downstream)
602 adjacent.setdefault(other.downstream, []).append(other.upstream)
603 seen = {branch.downstream}
604 frontier = [branch.downstream]
605 while frontier:
606 here = frontier.pop()
607 for there in adjacent.get(here, ()):
608 if there in seen:
609 continue
610 seen.add(there)
611 if there in bottles:
612 return bottles[there]
613 if net.nodes[there].pressure is not None:
614 continue
615 frontier.append(there)
616 return None
617
618
619def binding(model: Model, machine: StateMachine, hookup: Hookup | None) -> Binding:
620 """The machine bound to the drawing's valves: pins first, then names and
621 roles -- or, on a wired hookup, exactly its connectors. There a row drives
622 the valve on the connector of its name and nothing else: a row with no
623 connector, or one whose cable goes to a symbol that is not a valve here,
624 drives nothing (and says so in ``unmatched``) rather than being matched
625 behind the person's back. Only the rows in ``auto`` are matched."""
626 # Valves only: a regulator's dome signal is a knob's, not the table's.
627 built = model.built
628 # A valve on the ground-support side answers to "GSE" as well as to its
629 # tag: the table says "GSE High Press Control", the cart's drawing says
630 # HPC_SOL, and only the page it is on says the rest.
631 labels = {
632 node.id: (node.label or node.id)
633 + (" GSE" if built.vehicle is not None and node.id not in built.vehicle else "")
634 for node in model.diagram.nodes
635 if node.id in built.actuators
636 and not built.actuators[node.id].endswith(".dome")
637 and node.id not in built.hand_valves
638 }
639 overrides: dict[str, str] | None = None
640 if hookup is not None and hookup.channels is not None:
641 # A connector joins the row of its name as a person reads it: "Lox
642 # Main" is the table's "LOX Main". (Names are unique ignoring case on
643 # the box and in the table, so this can only find the one row.)
644 rows = {a.casefold(): a for a in machine.actuators}
645 drawn = {n.id for n in model.diagram.nodes}
646 auto = {a.casefold() for a in hookup.auto}
647 overrides = {}
648 for c in hookup.channels:
649 row = rows.get(c.name.casefold())
650 if row is None:
651 continue
652 if c.symbol in labels:
653 overrides[row] = c.symbol
654 elif c.symbol not in drawn:
655 # The drawing lost the symbol the cable went to (redrawn, a
656 # new id): matched by name again, as a pin to a lost valve
657 # always was -- a main the drawing still has must not go
658 # uncommanded because its old id did. The stand says so.
659 auto.add(row.casefold())
660 free = [
661 a
662 for a in machine.actuators
663 if a not in overrides and a.casefold() not in auto
664 ]
665 overrides.update({a: "" for a in free})
666 stand_ins = _stand_ins(model)
667 if stand_ins and free:
668 # Rocket only, the cut drawing makes stand-ins of the disconnects
669 # that mated the cart (the tank-top GSE vent). The box cannot take
670 # one -- on the whole drawing it is not a valve -- so a row the box
671 # leaves unwired drives the stand-in the old matching gives it,
672 # and nothing else.
673 trial = bind(
674 machine,
675 labels,
676 roles=built.valve_roles,
677 overrides={k: v for k, v in overrides.items() if k not in free},
678 )
679 for a in free:
680 found = trial.to_symbol.get(a, "")
681 if found in stand_ins:
682 overrides[a] = found
683 elif hookup is not None:
684 overrides = dict(hookup.valves)
685 return bind(
686 machine,
687 labels,
688 roles=model.built.valve_roles,
689 overrides=overrides,
690 )
691
692
693def _stand_ins(model: Model) -> frozenset[str]:
694 """A rocket-only drawing's valves that are not valves on the whole one: a
695 disconnect whose mate went with the cart (``meta["capped"]``), read as the
696 GSE vent it couples (:func:`feedtwin.pid.network._gse_vents`)."""
697 raw = model.meta.get("capped")
698 capped = {str(c) for c in raw} if isinstance(raw, (list, tuple)) else set()
699 return frozenset(
700 n.id
701 for n in model.diagram.nodes
702 if n.id in capped and n.type == "QD" and n.id in model.built.actuators
703 )
704
705
706def lost_connectors(hookup: Hookup, model: Model) -> list[Channel]:
707 """The box's connectors whose cable goes to a symbol the drawing no longer
708 has, which the stand should say. A connector named for a table row (a
709 valve's) has its row matched by name instead (:func:`binding`); any other
710 -- a transducer's -- names nothing until it is rewired. Give it the whole
711 drawing: on a rocket-only one the cart's connectors are cut on purpose
712 (:func:`on_vehicle`)."""
713 drawn = {n.id for n in model.diagram.nodes}
714 return [c for c in hookup.channels or () if c.symbol not in drawn]
#define max(a, b)
Definition Arduino.h:43
"Channel" from_dict(cls, Mapping[str, Any] raw)
Definition hookup.py:131
dict[str, Any] to_dict(self)
Definition hookup.py:122
Knob|None knob_for(self, str regulator)
Definition hookup.py:172
dict[str, Any] to_dict(self)
Definition hookup.py:186
dict[str, str] names(self)
Definition hookup.py:178
Channel|None channel_of(self, str symbol)
Definition hookup.py:175
"Hookup" from_dict(cls, Mapping[str, Any] raw)
Definition hookup.py:209
"Knob" from_dict(cls, Mapping[str, Any] raw)
Definition hookup.py:99
dict[str, Any] to_dict(self)
Definition hookup.py:88
static Valve valves[]
Definition main.cpp:79
list[Channel] lost_connectors(Hookup hookup, Model model)
Definition hookup.py:706
float _first_drawn(Mapping[str, float] drawn, tuple[str,...] regulators, float default)
Definition hookup.py:545
dict[str, float] knob_starts(Hookup hookup, Model model)
Definition hookup.py:520
Hookup on_vehicle(Hookup saved, Model model)
Definition hookup.py:427
dict[str, float] drawn_settings(Model model)
Definition hookup.py:464
None _check_channels(tuple[Channel,...] channels)
Definition hookup.py:244
list[str] _hand_loaded_regulators(Model model, set[str] taken)
Definition hookup.py:549
Hookup suggest(Model model, float dome_psig=500.0, float charge_psig=4500.0)
Definition hookup.py:353
float _or(float|None value, float default)
Definition hookup.py:579
bool _charges_vehicle_bottle(Model model, str regulator)
Definition hookup.py:573
frozenset[str] _stand_ins(Model model)
Definition hookup.py:693
list[RegulatorInfo] regulators(Model model)
Definition hookup.py:313
str|None _charged_bottle(Model model, str regulator)
Definition hookup.py:583
float|None bottle_charge(Model model, str regulator)
Definition hookup.py:506
Binding binding(Model model, StateMachine machine, Hookup|None hookup)
Definition hookup.py:619