1"""How a stand's controls reach its drawing: which valve each state-machine
2actuator drives, and which knob sets which regulator.
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.
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:
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.
38Gauge pressures throughout, as on the panel.
41from __future__
import annotations
43from dataclasses
import dataclass, field, replace
44from typing
import Any, Mapping
72UNSET_PSIG = 500.0 - 14.695948775513449
75@dataclass(frozen=True, slots=True)
77 """One dial on the GSE page, and the regulators it sets."""
81 regulators: tuple[str, ...] = ()
82 """Drawing ids of the PR symbols this knob sets."""
84 """Where it starts [psig]."""
99 def from_dict(cls, raw: Mapping[str, Any]) ->
"Knob":
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)),
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."""
131 def from_dict(cls, raw: Mapping[str, Any]) ->
"Channel":
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 ""),
140@dataclass(frozen=True, slots=True)
142 """What a person decided about one drawing's controls."""
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."""
173 return next((k
for k
in self.
knobs if regulator
in k.regulators),
None)
176 return next((c
for c
in self.
channels or ()
if c.symbol == symbol),
None)
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."""
183 **{c.symbol: c.name
for c
in self.
channels or ()
if c.symbol},
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] = {
192 "valves": dict(self.
valves),
193 "knobs": [k.to_dict()
for k
in self.
knobs],
197 out[
"channels"] = [c.to_dict()
for c
in self.
channels]
199 out[
"rows"] = dict(self.
rows)
201 out[
"auto"] = sorted(self.
auto)
205 out[
"schema"] = SCHEMA
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")
222 None if listed
is None else tuple(Channel.from_dict(c)
for c
in listed)
225 machine_raw = raw.get(
"machine")
227 valves={str(a): str(s
or "")
for a, s
in (raw.get(
"valves")
or {}).items()},
230 str(k): str(v).strip()
231 for k, v
in (raw.get(
"aliases")
or {}).items()
232 if str(v
or "").strip()
236 str(board):
max(1, int(n))
237 for board, n
in (raw.get(
"rows")
or {}).items()
239 auto=frozenset(str(a)
for a
in raw.get(
"auto")
or ()),
240 machine=machine_from_dict(machine_raw)
if machine_raw
else 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]
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]
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)}")
266@dataclass(frozen=True, slots=True)
268 """A PR symbol on the drawing, as a knob sees it."""
273 """``loader`` (sets another's dome), ``dome`` (dome-loaded, no loader
274 drawn) or ``plain`` (a hand-loaded setpoint)."""
276 drawn_psig: float |
None
277 """What the drawing sets it to [psig], if it says."""
279 """The signal a knob drives (``<label>.dome``); for a loader, the loaded
283@dataclass(frozen=True, slots=True)
288 role: tuple[str, ...]
289 """What the topology says it does (``("fuel", "press")``), if it settles it."""
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."""
296 by_id = {n.id: n
for n
in model.diagram.nodes}
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:
305 label=node.label
or sid,
306 page=node.page
or "Main",
307 role=tuple(sorted(built.valve_roles.get(sid, frozenset()))),
310 return sorted(out, key=
lambda v: (v.page, v.label))
314 """Every regulator on the drawing a knob could set."""
316 loaded = {loader.signal
for loader
in built.dome_loaders.values()}
319 for node
in model.diagram.nodes:
320 if node.type !=
"PR":
322 if f
"{node.label or node.id}.dome" in loaded:
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")
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:
338 label=node.label
or node.id,
340 page=node.page
or "Main",
343 psig(param.si)
if param
is not None else implied.get(node.id)
348 return sorted(out, key=
lambda r: (r.page, r.label))
352 model: Model, dome_psig: float = 500.0, charge_psig: float = 4500.0
354 """The hookup the drawing implies, before anyone writes one down.
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.
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)
375 owned: tuple[str, ...] = tuple(loaders)
378 (sid
for sid, s
in built.actuators.items()
if s.endswith(
".dome")),
""
380 owned = (first,)
if first
else ()
386 "Dome control regulator"
388 else f
"Dome control regulator ({labels.get(owned[0], owned[0])})"
398 if any(k.id == CHARGE
for k
in knobs):
403 label=f
"COPV fill ({labels.get(regulator, regulator)})",
404 regulators=(regulator,),
417 label=labels.get(regulator, regulator),
418 regulators=(regulator,),
419 psig=drawn.get(regulator, UNSET_PSIG),
421 high=
max(1000.0, 2.0 * drawn.get(regulator, 0.0)),
424 return Hookup(knobs=tuple(knobs))
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.
435 ids = {n.id
for n
in model.diagram.nodes}
437 kept = {k.id: k
for k
in saved.knobs}
438 channels = saved.channels
440 if channels
is not None:
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)
449 valves={a: v
for a, v
in saved.valves.items()
if not v
or v
in ids},
453 replace(k, psig=kept[k.id].psig)
454 if k.id
in kept
and kept[k.id].regulators == k.regulators
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.
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
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":
482 if node.options.get(
"domeLoaded") ==
"yes"
483 and node.id
not in model.built.dome_loaders
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():
495 for n
in model.diagram.nodes
496 if n.type ==
"PR" and f
"{labels[n.id]}.dome" == loader.signal
500 dome =
None if loaded
is None else loaded.params.get(
"dome_pressure")
502 out[loader_id] = psig(dome.si)
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."""
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)
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."""
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:
544 drawn: Mapping[str, float], regulators: tuple[str, ...], default: float
546 return next((drawn[r]
for r
in regulators
if r
in drawn), default)
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."""
556 if built.vehicle
is None
557 else frozenset(n.id
for n
in model.diagram.nodes
if n.id
not in built.vehicle)
560 for node
in model.diagram.nodes:
561 if node.type !=
"PR" or node.id
in taken
or node.id
in built.dome_loaders:
563 if node.options.get(
"domeLoaded") ==
"yes":
565 if node.id
not in built.network.branches:
567 if {
"setpoint",
"dome_pressure"} & set(node.params)
and node.id
not in ground:
574 """Whether a regulator's outlet reaches the vehicle's pressurant bottle:
575 through any valve, across mated disconnects, without passing a vessel."""
579def _or(value: float |
None, default: float) -> float:
580 return default
if value
is None else value
584 """The drawing id of the vehicle pressurant bottle a regulator's outlet
585 reaches (see :func:`_charges_vehicle_bottle`), or None."""
588 branch = net.branches.get(regulator)
591 types = {n.id: n.type
for n
in model.diagram.nodes}
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)
597 adjacent: dict[str, list[str]] = {}
598 for other
in net.branches.values():
599 if other.id == regulator:
601 adjacent.setdefault(other.upstream, []).append(other.downstream)
602 adjacent.setdefault(other.downstream, []).append(other.upstream)
603 seen = {branch.downstream}
604 frontier = [branch.downstream]
606 here = frontier.pop()
607 for there
in adjacent.get(here, ()):
612 return bottles[there]
613 if net.nodes[there].pressure
is not None:
615 frontier.append(there)
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."""
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
639 overrides: dict[str, str] |
None =
None
640 if hookup
is not None and hookup.channels
is not None:
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}
648 for c
in hookup.channels:
649 row = rows.get(c.name.casefold())
652 if c.symbol
in labels:
653 overrides[row] = c.symbol
654 elif c.symbol
not in drawn:
659 auto.add(row.casefold())
662 for a
in machine.actuators
663 if a
not in overrides
and a.casefold()
not in auto
665 overrides.update({a:
"" for a
in free})
667 if stand_ins
and free:
676 roles=built.valve_roles,
677 overrides={k: v
for k, v
in overrides.items()
if k
not in free},
680 found = trial.to_symbol.get(a,
"")
681 if found
in stand_ins:
683 elif hookup
is not None:
684 overrides = dict(hookup.valves)
688 roles=model.built.valve_roles,
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()
701 for n
in model.diagram.nodes
702 if n.id
in capped
and n.type ==
"QD" and n.id
in model.built.actuators
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]
"Channel" from_dict(cls, Mapping[str, Any] raw)
dict[str, Any] to_dict(self)
Knob|None knob_for(self, str regulator)
dict[str, Any] to_dict(self)
dict[str, str] names(self)
Channel|None channel_of(self, str symbol)
"Hookup" from_dict(cls, Mapping[str, Any] raw)
"Knob" from_dict(cls, Mapping[str, Any] raw)
dict[str, Any] to_dict(self)
list[Channel] lost_connectors(Hookup hookup, Model model)
float _first_drawn(Mapping[str, float] drawn, tuple[str,...] regulators, float default)
dict[str, float] knob_starts(Hookup hookup, Model model)
Hookup on_vehicle(Hookup saved, Model model)
dict[str, float] drawn_settings(Model model)
None _check_channels(tuple[Channel,...] channels)
list[str] _hand_loaded_regulators(Model model, set[str] taken)
Hookup suggest(Model model, float dome_psig=500.0, float charge_psig=4500.0)
float _or(float|None value, float default)
bool _charges_vehicle_bottle(Model model, str regulator)
frozenset[str] _stand_ins(Model model)
list[RegulatorInfo] regulators(Model model)
str|None _charged_bottle(Model model, str regulator)
float|None bottle_charge(Model model, str regulator)
Binding binding(Model model, StateMachine machine, Hookup|None hookup)