STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
statemachine.py
Go to the documentation of this file.
1"""The stand's state machine, read from the same tables the DAQ runs on.
2
3The DAQ does not have a state machine written in code; it has two CSVs, and the
4firmware and the GUI both read them. So does this. Copying the *behaviour* into
5Python would have created a second source of truth that drifts silently the
6first time somebody adds a state -- and the whole point of the twin is that it
7does what the stand does.
8
9``state_machine_actuators.csv``
10 A matrix. One row per actuator, one column per state, ``OPEN`` or ``CLOSE``
11 in each cell. This is what a state *is* on this stand: a column.
12
13``state_transitions.csv``
14 Which moves are legal, as a square 0/1 matrix. Idle cannot go straight to
15 Fire; it goes through Armed and the press states. Enforcing that here means
16 the twin refuses the same sequences the stand refuses, which is most of what
17 makes it useful for training somebody.
18
19Matching actuators to a drawing
20-------------------------------
21The tables name actuators the way the crew does -- "LOX Main", "Fuel Vent". A
22P&ID names them however the person drawing it typed them -- "MV-OX", "MVO",
23"SV-FUEL-VENT". So the join is fuzzy and, more importantly, *reported*: a run
24says which actuators matched a symbol and which did not, because an unmatched
25"LOX Main" means the main valve is not being commanded at all and the trace
26would look like a very smooth start.
27"""
28
29from __future__ import annotations
30
31import csv
32import re
33from dataclasses import dataclass, field
34from pathlib import Path
35from typing import Any, Mapping, Sequence
36
37#: Bumped whenever the stored form of an edited table changes meaning.
38MACHINE_SCHEMA = 1
39
40#: States the session and the cockpit find by name, and what each is for. An
41#: edited table without one is warned, with its reason.
42KEYED_STATES: dict[str, str] = {
43 "Idle": "a stand opens in Idle",
44 "Ready": "T-0 primes in Ready",
45 "Fire": "Fire burns",
46 "Vent": "a dry tank vents to Vent",
47 "Engine Abort": "ENG ABORT goes to Engine Abort",
48}
49
50#: What a state's name holds for the session to load a tank of each side in it
51#: (``Session._fills``): "fill" and one of these. The load comes from a tanker
52#: that is not on the drawing, so the name is all the session has to go on.
53FILL_WORDS: dict[str, tuple[str, ...]] = {"lox": ("ox", "lox"), "fuel": ("fuel", "eth")}
54
55
56def fills(state: str, side: str) -> bool:
57 """Whether the session loads a ``side`` (``lox``/``fuel``) tank in
58 ``state``: by its name, as :data:`FILL_WORDS` reads it."""
59 name = state.lower()
60 return "fill" in name and any(word in name for word in FILL_WORDS[side])
61
62
63#: Prefixes that say how a valve is actuated, not what it does. A solenoid and
64#: a ball valve in the same place are the same actuator to a state machine.
65_NOISE = {"valve", "sv", "bv", "nv", "av", "act", "the"}
66
67#: Drawing vocabulary to table vocabulary. Two kinds live here and both matter.
68#:
69#: The **function** prefixes are the ones an outsider would drop as noise and
70#: must not: on a P&ID "MV" is a *main valve* and "MV-OX" is exactly the table's
71#: "LOX Main". Treating it as noise leaves the main valves unbound, which is
72#: silent and catastrophic -- the mains are what a Fire state opens.
73#:
74#: The **fluid** synonyms are the ordinary half.
75_SYNONYM = {
76 # function
77 "mv": "main",
78 "mov": "main",
79 "main": "main",
80 "vent": "vent",
81 "fill": "fill",
82 "dump": "dump",
83 "press": "press",
84 "pressurisation": "press",
85 "pressurization": "press",
86 # fluid
87 "ox": "lox",
88 "oxidiser": "lox",
89 "oxidizer": "lox",
90 "oxygen": "lox",
91 "o": "lox",
92 "o2": "lox",
93 "fu": "fuel",
94 "eth": "fuel",
95 "ethanol": "fuel",
96 "etoh": "fuel",
97 "f": "fuel",
98 "n2": "gn2",
99 "gn": "gn2",
100 "nitrogen": "gn2",
101 # The table says GN2 because the stand presses on nitrogen; a drawing
102 # pressed on helium names the same valves HE. It is the pressurant either
103 # way, and "GN2 Vent" has to find "SV-HE-VENT" or the helium stand's
104 # pressurant vent is never commanded.
105 "he": "gn2",
106 "helium": "gn2",
107 "ghe": "gn2",
108 "press_gas": "gn2",
109 "pressurant": "gn2",
110}
111
112#: A stand's line prefixes, each standing for more than one word. "FV-SOL" is
113#: the fuel vent solenoid and "FF-SOL-Vent" the fuel *fill* (transfer tank)
114#: vent; "HPC_SOL" is the high press control solenoid the table calls "GSE High
115#: Press Control". "OF" is the ox-fill *line*, so it names the propellant only:
116#: OF-MOT-Dump is the dump on that line, not its fill valve.
117_EXPAND: dict[str, tuple[str, ...]] = {
118 "fv": ("fuel", "vent"),
119 "ff": ("fuel", "fill"),
120 "ov": ("lox", "vent"),
121 "of": ("lox",),
122 "hpc": ("high", "press", "control"),
123 "hp": ("high", "press"),
124 "lp": ("low", "press"),
125 "mp": ("med", "press"),
126 "ctrl": ("control",),
127}
128
129#: Words that name a propellant. Two names that disagree on one of these are
130#: different actuators however much else they share -- "LOX Vent" must never
131#: bind to a fuel valve just because both are vents.
132_FLUIDS = frozenset({"lox", "fuel", "gn2"})
133
134
135def _split(token: str) -> list[str] | None:
136 """Break a run-together tag into known words, or ``None``.
137
138 A drawing writes the same valve as ``MV-OX`` or ``MVO``, and the second is
139 one token that matches nothing. Splitting it into ``mv`` + ``o`` -- both of
140 which the table understands -- is the difference between the LOX main being
141 commanded and it being silently left out of every state.
142
143 Only splits where *both* halves are known words, so an ordinary tag is not
144 shredded into coincidences.
145 """
146 for cut in range(2, len(token)):
147 left, right = token[:cut], token[cut:]
148 if left in _SYNONYM and right in _SYNONYM:
149 return [_SYNONYM[left], _SYNONYM[right]]
150 return None
151
152
153def _words(text: str) -> frozenset[str]:
154 """Normalise a name to the set of words that identify it."""
155 parts = re.split(r"[^a-z0-9]+", text.lower())
156 out: set[str] = set()
157 for part in parts:
158 if not part or part in _NOISE:
159 continue
160 if part in _SYNONYM:
161 out.add(_SYNONYM[part])
162 continue
163 if part in _EXPAND:
164 out.update(_EXPAND[part])
165 continue
166 pieces = _split(part)
167 out.update(pieces if pieces is not None else [part])
168 return frozenset(out)
169
170
171@dataclass(frozen=True, slots=True)
172class StateMachine:
173 """States, the actuator positions each one commands, and the legal moves."""
174
175 name: str
176 states: tuple[str, ...]
177 actuators: tuple[str, ...]
178 #: ``positions[state][actuator]`` is True for open.
179 positions: Mapping[str, Mapping[str, bool]]
180 #: ``allowed[state]`` is the set of states reachable from it.
181 allowed: Mapping[str, frozenset[str]]
182 warnings: tuple[str, ...] = ()
183 """Problems in the tables themselves. Reported rather than repaired: only
184 whoever maintains the CSV knows which column a short row is missing."""
185 table: Mapping[str, Mapping[str, bool]] | None = None
186 """The positions as the table writes them, before the twin holds Idle
187 shut -- what an editor shows and a download writes. ``None``: the same as
188 ``positions``."""
189 layout: Mapping[str, tuple[int, int]] = field(default_factory=dict)
190 """Where each state sits on the panel, ``(row, col)`` -- the DAQ's
191 ``panel_row``/``panel_col``. A state not here is not placed."""
192 aborts: frozenset[str] | None = None
193 """The abort states, as the DAQ flags them (``is_abort``). ``None``: any
194 state with "abort" in its name, which is what the twin always assumed."""
195
196 def is_abort(self, state: str) -> bool:
197 return state in self.aborts if self.aborts is not None else _is_abort(state)
198
199 def can_go(self, current: str, target: str) -> bool:
200 """Whether the stand would accept this transition.
201
202 A state with no row in the transition table is unconstrained rather than
203 stuck: a table that lists fewer states than the actuator matrix is a
204 table somebody is still filling in, and refusing every move out of a
205 half-written state helps nobody.
206
207 Abort is always reachable, whatever the table says. That is a rule about
208 stands rather than an inference about this file, and it exists because
209 the failure it guards against is asymmetric: a spurious abort path costs
210 an operator one confused moment, and a missing one costs an abort.
211 """
212 if current == target or self.is_abort(target):
213 return True
214 reachable = self.allowed.get(current)
215 if reachable is None:
216 # FAIL CLOSED for a state with no row at all. A state the table
217 # never mentions constrains nothing, and treating that as "allow
218 # everything" is worse than no state machine, because the operator
219 # believes an interlock is watching. Short rows are a different
220 # case and are read the way the DAQ reads them -- see load_machine.
221 return False
222 return target in reachable
223
224 def targets(self, current: str) -> list[str]:
225 return [s for s in self.states if s != current and self.can_go(current, s)]
226
227 def open_actuators(self, state: str) -> frozenset[str]:
228 row = self.positions.get(state, {})
229 return frozenset(name for name, is_open in row.items() if is_open)
230
231 def to_dict(self) -> dict[str, Any]:
232 """The tables as an editor holds them: the states in column order with
233 their place on the panel, the rows, which rows each state opens *as
234 written* (Idle's OPEN cells included -- the twin's hold is applied
235 again on reading), and the legal moves. A state with no entry in
236 ``allowed`` has no row in the transition table, and fails closed."""
237 written = self.table if self.table is not None else self.positions
238 return {
239 "schema": MACHINE_SCHEMA,
240 "name": self.name,
241 "states": [
242 {
243 "name": s,
244 "row": self.layout[s][0] if s in self.layout else None,
245 "col": self.layout[s][1] if s in self.layout else None,
246 "abort": self.is_abort(s),
247 }
248 for s in self.states
249 ],
250 "actuators": list(self.actuators),
251 "open": {
252 s: [a for a in self.actuators if written.get(s, {}).get(a)]
253 for s in self.states
254 },
255 "allowed": {
256 s: [t for t in self.states if t in self.allowed[s]]
257 for s in self.states
258 if s in self.allowed
259 },
260 }
261
262
263@dataclass(frozen=True, slots=True)
264class Binding:
265 """How the machine's actuators map onto one drawing's symbols."""
266
267 #: Table actuator name -> drawing symbol id.
268 to_symbol: Mapping[str, str] = field(default_factory=dict)
269 #: Actuators with no symbol on this drawing. Not an error -- a stand has
270 #: fill and dump valves a feed drawing need not show -- but a "LOX Main"
271 #: in here means the main valve is never commanded, which is.
272 unmatched: tuple[str, ...] = ()
273 #: Drawing valves the machine never commands. They keep whatever position
274 #: the operator last set by hand.
275 uncommanded: tuple[str, ...] = ()
276 #: Actuators bound by what the valve does rather than by its name -- see
277 #: ``roles`` on :func:`bind`. Listed so a stand can say so.
278 by_role: tuple[str, ...] = ()
279 #: Actuators a person pinned (``overrides`` on :func:`bind`), bound or
280 #: deliberately left unbound. Everything else was matched automatically.
281 by_user: tuple[str, ...] = ()
282
283 def positions_for(self, machine: StateMachine, state: str) -> dict[str, float]:
284 """Commanded positions keyed by drawing symbol id, for the solver."""
285 row = machine.positions.get(state, {})
286 out: dict[str, float] = {}
287 for actuator, is_open in row.items():
288 symbol = self.to_symbol.get(actuator)
289 if symbol:
290 out[symbol] = 1.0 if is_open else 0.0
291 return out
292
293
294def _is_abort(state: str) -> bool:
295 return "abort" in state.lower()
296
297
298def _read_matrix(path: Path) -> tuple[list[str], list[str], list[list[str]]]:
299 with path.open(encoding="utf-8-sig", newline="") as handle:
300 rows = [r for r in csv.reader(handle) if any(cell.strip() for cell in r)]
301 if not rows:
302 raise ValueError(f"{path.name} is empty")
303 header = [c.strip() for c in rows[0][1:]]
304 labels = [r[0].strip() for r in rows[1:]]
305 body = [[c.strip() for c in r[1:]] for r in rows[1:]]
306 return header, labels, body
307
308
309def load_machine(
310 name: str = "diablo",
311 *,
312 actuators: Path | None = None,
313 transitions: Path | None = None,
314 tables: Path | None = None,
315) -> StateMachine:
316 """Read a machine from its two tables.
317
318 Either name both files, or name the directory holding
319 ``<name>_actuators.csv`` and ``<name>_transitions.csv``. The library ships
320 no tables of its own: they are the DAQ's files, and the feed-twin app keeps
321 the copy (its ``backend/statemachine.py`` supplies that directory).
322 """
323 if (actuators is None or transitions is None) and tables is None:
324 raise ValueError(
325 f"load_machine({name!r}) needs the table directory or both table "
326 "paths; feedtwin ships no state machines of its own"
327 )
328 act_path = actuators or Path(str(tables)) / f"{name}_actuators.csv"
329 trans_path = transitions or Path(str(tables)) / f"{name}_transitions.csv"
330
331 states, actuator_names, body = _read_matrix(act_path)
332 positions: dict[str, dict[str, bool]] = {s: {} for s in states}
333 for actuator, row in zip(actuator_names, body):
334 for state, cell in zip(states, row):
335 positions[state][actuator] = cell.upper().startswith("OPEN")
336 table = {s: dict(row) for s, row in positions.items()}
337
338 warnings = _hold_idle(states, actuator_names, positions, act_path.name)
339 allowed: dict[str, frozenset[str]] = {}
340 if trans_path.exists():
341 columns, sources, moves = _read_matrix(trans_path)
342 ragged: list[str] = []
343 for source, row in zip(sources, moves):
344 if len(row) != len(columns):
345 # A short row is read LEFT-ALIGNED, because that is how the
346 # stand reads it. The DAQ's own parser
347 # (diablo_server/backend/src/legacy/state-transitions.ts) maps
348 # row[j] onto headers[j-1] and stops at the end of the row, so
349 # a 20-cell row simply never speaks about the last column.
350 # This twin used to refuse every move out of such a state on
351 # the grounds that the alignment was unknowable -- but it is
352 # not unknowable, it is whatever the DAQ does, and an operator
353 # rehearsing on the twin must find the same doors open and shut
354 # as on the stand. Including the wrong ones: see the second
355 # warning below.
356 ragged.append(f"{source} ({len(row)} of {len(columns)})")
357 allowed[source] = frozenset(
358 column for column, cell in zip(columns, row) if cell.strip() == "1"
359 )
360 if ragged:
361 warnings.append(
362 f"{trans_path.name} has {len(ragged)} row(s) with the wrong "
363 f"number of columns: {', '.join(ragged)}. Read left-aligned, "
364 "exactly as the Diablo DAQ reads them, so the twin permits and "
365 "refuses the same moves as the stand. Fix the file and both "
366 "tools change together."
367 )
368 warnings.extend(_fire_bypasses(allowed, trans_path.name))
369
370 layout, aborts = _read_panel(
371 Path(str(tables)) / f"{name}_states.csv" if tables is not None else None,
372 states,
373 )
374 return StateMachine(
375 name=name,
376 states=tuple(states),
377 actuators=tuple(actuator_names),
378 positions=positions,
379 allowed=allowed,
380 warnings=tuple(warnings),
381 table=table,
382 layout=layout,
383 aborts=aborts,
384 )
385
386
387def _hold_idle(
388 states: Sequence[str],
389 actuators: Sequence[str],
390 positions: dict[str, dict[str, bool]],
391 source: str,
392) -> list[str]:
393 """Hold Idle shut in ``positions`` and say what the table claimed; flag a
394 main valve opened outside Fire. Shared by the CSV and the edited table, so
395 a table reads the same whichever way it arrives.
396
397 Positions that cannot be what anybody meant, read exactly as the DAQ
398 reads them. A main valve is only ever open with the engine lit, and a
399 cold, de-energised stand has nothing open at all; the shipped table says
400 otherwise in four places.
401
402 Idle is the one place the twin does NOT do as the table says. Idle is the
403 de-energised state -- what the panel is in before anybody arms anything
404 and what every solenoid falls back to unpowered -- and OPEN in that column
405 cannot be a position a normally-closed solenoid holds. Read literally it
406 opened LOX Press, and once a bottle could be full at start-up it pressed
407 the LOX tank to 564.7 psia before the operator had touched a thing.
408 Transitions stay faithful to the byte (an interlock the stand lacks must
409 be missing here too); a valve position that pressurises a cold stand is
410 not an interlock, it is a typo, and the warning says exactly what the
411 table claimed.
412 """
413 warnings: list[str] = []
414 mains = [a for a in actuators if "main" in a.lower()]
415 for state in states:
416 opened = sorted(a for a in actuators if positions[state].get(a))
417 if state.lower() == "idle" and opened:
418 warnings.append(
419 f"Idle commands {', '.join(opened)} OPEN in {source} as "
420 "the DAQ reads it. A cold, de-energised stand has nothing "
421 "open, so the twin holds Idle shut; the stand's table is what "
422 "needs fixing."
423 )
424 for actuator in opened:
425 positions[state][actuator] = False
426 wrong = [a for a in mains if positions[state].get(a)]
427 if wrong and state.lower() not in ("fire", "idle"):
428 warnings.append(
429 f"{state} commands {', '.join(wrong)} OPEN in {source} "
430 "as the DAQ reads it. Only Fire should open a main; the "
431 "stand's table is what needs fixing."
432 )
433 return warnings
434
435
436def _fire_bypasses(allowed: Mapping[str, frozenset[str]], source: str) -> list[str]:
437 """The consequence worth shouting about. Only Ready and Fire should be able
438 to reach Fire; if the table hands Fire to anything else, the stand has an
439 unguarded ignition path and so, faithfully, does this."""
440 return [
441 f"{state} -> Fire is permitted by {source} as the "
442 "DAQ reads it. That is an ignition path that bypasses "
443 "Ready. The twin allows it so a rehearsal matches the "
444 "stand; the stand's table is what needs fixing."
445 for state, targets in allowed.items()
446 if "Fire" in targets and state not in ("Ready", "Fire")
447 ]
448
449
450def _read_panel(
451 path: Path | None, states: Sequence[str]
452) -> tuple[dict[str, tuple[int, int]], frozenset[str] | None]:
453 """Where each state sits on the panel and which are aborts, from
454 ``<name>_states.csv`` (``name,row,col,abort``: the DAQ's ``[[states]]``
455 ``panel_row``/``panel_col``/``is_abort``). With no such file the panel is
456 the console's own and aborts go by name, as before. A state the file
457 does not list is an abort only by its name."""
458 if path is None or not path.exists():
459 return {}, None
460 with path.open(encoding="utf-8-sig", newline="") as handle:
461 rows = list(csv.DictReader(handle))
462 layout: dict[str, tuple[int, int]] = {}
463 flagged: set[str] = set()
464 listed: set[str] = set()
465 for row in rows:
466 state = (row.get("name") or "").strip()
467 if state not in states:
468 continue
469 listed.add(state)
470 place = (row.get("row") or "").strip(), (row.get("col") or "").strip()
471 if all(place):
472 layout[state] = (int(place[0]), int(place[1]))
473 if _flag(row.get("abort")):
474 flagged.add(state)
475 flagged |= {s for s in states if s not in listed and _is_abort(s)}
476 return layout, frozenset(flagged)
477
478
479def _flag(value: Any) -> bool:
480 """A yes/no a person or a file wrote: ``True``, ``1``, ``"1"``,
481 ``"true"``, ``"yes"``. Anything else -- ``"0"`` and ``"false"`` included
482 -- is no: an abort flag read loosely is an unguarded path into it."""
483 if isinstance(value, bool):
484 return value
485 if isinstance(value, (int, float)):
486 return value == 1
487 return str(value or "").strip().lower() in ("1", "true", "yes")
488
489
490def machine_from_dict(raw: Mapping[str, Any]) -> StateMachine:
491 """An edited table (:meth:`StateMachine.to_dict`'s shape), read as the CSVs
492 are: Idle held shut and the same warnings. Refused outright when it
493 contradicts itself -- a state or row named twice, a cell naming a state or
494 row the table does not have -- because a half-understood table would
495 command valves nobody chose."""
496 schema = int(raw.get("schema", MACHINE_SCHEMA))
497 if schema != MACHINE_SCHEMA:
498 raise ValueError(
499 f"state table schema {schema}; this feedtwin reads {MACHINE_SCHEMA}"
500 )
501 rows = raw.get("states") or []
502 states = [str((s or {}).get("name") or "").strip() for s in rows]
503 if not states:
504 raise ValueError("the state table has no states")
505 if "" in states:
506 raise ValueError("a state has no name")
507 if len(set(states)) != len(states):
508 raise ValueError("two states share a name")
509 actuators = [str(a or "").strip() for a in raw.get("actuators") or []]
510 if "" in actuators:
511 raise ValueError("an actuator row has no name")
512 if len({a.casefold() for a in actuators}) != len(actuators):
513 raise ValueError("two actuator rows share a name")
514 known_states, known_rows = set(states), set(actuators)
515
516 opened: Mapping[str, Any] = raw.get("open") or {}
517 for state, names in opened.items():
518 if state not in known_states:
519 raise ValueError(
520 f"the table opens valves in {state!r}, which is not a state"
521 )
522 stray = sorted(set(map(str, names or [])) - known_rows)
523 if stray:
524 raise ValueError(f"{state} opens {', '.join(stray)}, which have no row")
525 positions = {
526 s: {a: a in set(map(str, opened.get(s) or [])) for a in actuators}
527 for s in states
528 }
529 table = {s: dict(row) for s, row in positions.items()}
530
531 moves: Mapping[str, Any] = raw.get("allowed") or {}
532 allowed: dict[str, frozenset[str]] = {}
533 for state, targets in moves.items():
534 if state not in known_states:
535 raise ValueError(f"the transitions name {state!r}, which is not a state")
536 stray = sorted(set(map(str, targets or [])) - known_states)
537 if stray:
538 raise ValueError(
539 f"{state} may go to {', '.join(stray)}, which are not states"
540 )
541 allowed[state] = frozenset(map(str, targets or []))
542
543 layout: dict[str, tuple[int, int]] = {}
544 for row in rows:
545 r, c = row.get("row"), row.get("col")
546 if r is not None and c is not None and r != "" and c != "":
547 layout[str(row["name"]).strip()] = (int(r), int(c))
548 aborts = frozenset(
549 str(row["name"]).strip() for row in rows if _flag(row.get("abort"))
550 )
551
552 source = "the edited table"
553 warnings = _hold_idle(states, actuators, positions, source)
554 warnings.extend(_fire_bypasses(allowed, source))
555 missing = [s for s in KEYED_STATES if s not in known_states]
556 if missing:
557 warnings.append(
558 f"The table has no {', '.join(missing)}: the twin keys on "
559 f"{'that state' if len(missing) == 1 else 'those states'} by name "
560 f"({'; '.join(KEYED_STATES[s] for s in missing)})."
561 )
562 unloaded = [
563 (tank, example)
564 for side, tank, example in (
565 ("lox", "LOX", "Ox Fill"),
566 ("fuel", "fuel", "Fuel Fill"),
567 )
568 if not any(fills(state, side) for state in states)
569 ]
570 if unloaded:
571 warnings.append(
572 f"No state loads the {' or the '.join(t for t, _ in unloaded)} tank: "
573 "the twin loads a tank only in a state whose name says fill and its "
574 f"side, as {' and '.join(e for _, e in unloaded)} "
575 f"{'does' if len(unloaded) == 1 else 'do'}."
576 )
577 return StateMachine(
578 name=str(raw.get("name") or "edited"),
579 states=tuple(states),
580 actuators=tuple(actuators),
581 positions=positions,
582 allowed=allowed,
583 warnings=tuple(warnings),
584 table=table,
585 layout=layout,
586 aborts=aborts,
587 )
588
589
590def bind(
591 machine: StateMachine,
592 valves: Mapping[str, str],
593 roles: Mapping[str, frozenset[str]] | None = None,
594 overrides: Mapping[str, str] | None = None,
595) -> Binding:
596 """Join the machine's actuators to a drawing's valves by name.
597
598 ``valves`` is drawing symbol id -> its label. Matching is on word overlap
599 after normalising away the noise that differs between a crew's vocabulary
600 and a draughtsman's: "MV-OX" and "LOX Main" share "lox" and "main" once
601 "mv" is dropped and "ox" is mapped to "lox".
602
603 Ties are broken by overlap size then by label, so the result does not depend
604 on dict ordering -- a binding that changed between two runs of the same
605 drawing would be the worst possible bug here.
606
607 ``roles`` (drawing id -> what the valve does, e.g. ``{"fuel", "press"}``,
608 from :attr:`feedtwin.pid.network.BuiltNetwork.valve_roles`) is the fallback
609 for the actuators no name matched: a drawing that tags its fuel press
610 solenoid "FU_SOL_R" still has it plumbed between the regulator and the fuel
611 tank's ullage, and that is what "Fuel Press" means. An actuator is bound by
612 role only when exactly one unbound valve does exactly its job; names always
613 win, and without ``roles`` the binding is what it always was.
614
615 ``overrides`` (actuator -> drawing id) is what a person decided, and wins
616 over both: those valves are taken first, and the automatic match works
617 around them. An empty id leaves that actuator unbound on purpose. A pin
618 naming a valve no longer on the drawing is ignored and the actuator is
619 matched automatically again -- a drawing that lost a valve must not leave a
620 main valve silently uncommanded. Without ``overrides`` nothing changes.
621 """
622 table = {name: _words(name) for name in machine.actuators}
623 drawn = {sid: _words(label) for sid, label in valves.items()}
624
625 to_symbol: dict[str, str] = {}
626 taken: set[str] = set()
627 decided: set[str] = set()
628 for actuator, sid in (overrides or {}).items():
629 if actuator not in machine.actuators:
630 continue
631 if not sid:
632 decided.add(actuator)
633 elif sid in valves and sid not in taken:
634 to_symbol[actuator] = sid
635 taken.add(sid)
636 decided.add(actuator)
637 for actuator in machine.actuators:
638 wanted = table[actuator]
639 if not wanted or actuator in decided:
640 continue
641 # The plumbing outranks a name it contradicts: when exactly one free
642 # valve does this actuator's job, a name match that does not is passed
643 # over. A cart's LOX vent drawn on a disconnect nobody paired goes
644 # nowhere, while the tank-top disconnect is the vent that works.
645 doers = [
646 sid
647 for sid in valves
648 if sid not in taken and roles is not None and roles.get(sid) == wanted
649 ]
650 best: tuple[int, int, str, str] | None = None
651 for sid, words in drawn.items():
652 if sid in taken:
653 continue
654 # A disagreement about the propellant is disqualifying however much
655 # else matches.
656 if (
657 (wanted & _FLUIDS)
658 and (words & _FLUIDS)
659 and not (wanted & words & _FLUIDS)
660 ):
661 continue
662 overlap = len(wanted & words)
663 # Every identifying word of the actuator has to appear, or "LOX
664 # Vent" happily binds to "LOX Main" on a drawing with no vent.
665 if overlap < len(wanted):
666 continue
667 if (
668 roles is not None
669 and len(doers) == 1
670 and doers[0] != sid
671 and roles.get(sid) != wanted
672 ):
673 continue
674 # The closest name wins: "Fuel Vent" is FV-SOL, not FF-SOL-Vent,
675 # which has every word of it and "fill" besides.
676 score = (overlap, -len(words - wanted), valves[sid], sid)
677 if best is None or score > best:
678 best = score
679 if best is not None:
680 to_symbol[actuator] = best[3]
681 taken.add(best[3])
682
683 by_role: list[str] = []
684 for actuator in machine.actuators:
685 if (
686 actuator in to_symbol
687 or actuator in decided
688 or not roles
689 or not table[actuator]
690 ):
691 continue
692 fits = [
693 sid
694 for sid in sorted(valves)
695 if sid not in taken and roles.get(sid) == table[actuator]
696 ]
697 if len(fits) == 1:
698 to_symbol[actuator] = fits[0]
699 taken.add(fits[0])
700 by_role.append(actuator)
701
702 return Binding(
703 to_symbol=to_symbol,
704 unmatched=tuple(a for a in machine.actuators if a not in to_symbol),
705 uncommanded=tuple(sorted(set(valves) - taken)),
706 by_role=tuple(by_role),
707 by_user=tuple(a for a in machine.actuators if a in decided),
708 )
709
710
711def available(tables: Path) -> list[str]:
712 """Machines whose tables are in ``tables``."""
713 if not tables.is_dir():
714 return []
715 return sorted(p.stem[: -len("_actuators")] for p in tables.glob("*_actuators.csv"))
Parses state_transitions.csv and validates state transitions.
bool fills(str state, str side)