1"""The stand's state machine, read from the same tables the DAQ runs on.
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.
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.
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.
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.
29from __future__
import annotations
33from dataclasses
import dataclass, field
34from pathlib
import Path
35from typing
import Any, Mapping, Sequence
42KEYED_STATES: dict[str, str] = {
43 "Idle":
"a stand opens in Idle",
44 "Ready":
"T-0 primes in Ready",
46 "Vent":
"a dry tank vents to Vent",
47 "Engine Abort":
"ENG ABORT goes to Engine Abort",
53FILL_WORDS: dict[str, tuple[str, ...]] = {
"lox": (
"ox",
"lox"),
"fuel": (
"fuel",
"eth")}
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."""
60 return "fill" in name
and any(word
in name
for word
in FILL_WORDS[side])
65_NOISE = {
"valve",
"sv",
"bv",
"nv",
"av",
"act",
"the"}
84 "pressurisation":
"press",
85 "pressurization":
"press",
117_EXPAND: dict[str, tuple[str, ...]] = {
118 "fv": (
"fuel",
"vent"),
119 "ff": (
"fuel",
"fill"),
120 "ov": (
"lox",
"vent"),
122 "hpc": (
"high",
"press",
"control"),
123 "hp": (
"high",
"press"),
124 "lp": (
"low",
"press"),
125 "mp": (
"med",
"press"),
126 "ctrl": (
"control",),
132_FLUIDS = frozenset({
"lox",
"fuel",
"gn2"})
135def _split(token: str) -> list[str] |
None:
136 """Break a run-together tag into known words, or ``None``.
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.
143 Only splits where *both* halves are known words, so an ordinary tag is not
144 shredded into coincidences.
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]]
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()
158 if not part
or part
in _NOISE:
161 out.add(_SYNONYM[part])
164 out.update(_EXPAND[part])
166 pieces = _split(part)
167 out.update(pieces
if pieces
is not None else [part])
168 return frozenset(out)
171@dataclass(frozen=True, slots=True)
173 """States, the actuator positions each one commands, and the legal moves."""
176 states: tuple[str, ...]
177 actuators: tuple[str, ...]
179 positions: Mapping[str, Mapping[str, bool]]
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
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."""
196 def is_abort(self, state: str) -> bool:
197 return state
in self.aborts
if self.aborts
is not None else _is_abort(state)
199 def can_go(self, current: str, target: str) -> bool:
200 """Whether the stand would accept this transition.
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.
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.
212 if current == target
or self.is_abort(target):
214 reachable = self.allowed.get(current)
215 if reachable
is None:
222 return target
in reachable
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)]
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)
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
239 "schema": MACHINE_SCHEMA,
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),
250 "actuators": list(self.actuators),
252 s: [a
for a
in self.actuators
if written.get(s, {}).get(a)]
256 s: [t
for t
in self.states
if t
in self.allowed[s]]
263@dataclass(frozen=True, slots=True)
265 """How the machine's actuators map onto one drawing's symbols."""
268 to_symbol: Mapping[str, str] = field(default_factory=dict)
272 unmatched: tuple[str, ...] = ()
275 uncommanded: tuple[str, ...] = ()
278 by_role: tuple[str, ...] = ()
281 by_user: tuple[str, ...] = ()
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)
290 out[symbol] = 1.0
if is_open
else 0.0
294def _is_abort(state: str) -> bool:
295 return "abort" in state.lower()
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)]
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
310 name: str =
"diablo",
312 actuators: Path |
None =
None,
313 transitions: Path |
None =
None,
314 tables: Path |
None =
None,
316 """Read a machine from its two tables.
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).
323 if (actuators
is None or transitions
is None)
and tables
is None:
325 f
"load_machine({name!r}) needs the table directory or both table "
326 "paths; feedtwin ships no state machines of its own"
328 act_path = actuators
or Path(str(tables)) / f
"{name}_actuators.csv"
329 trans_path = transitions
or Path(str(tables)) / f
"{name}_transitions.csv"
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()}
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):
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"
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."
368 warnings.extend(_fire_bypasses(allowed, trans_path.name))
370 layout, aborts = _read_panel(
371 Path(str(tables)) / f
"{name}_states.csv" if tables
is not None else None,
376 states=tuple(states),
377 actuators=tuple(actuator_names),
380 warnings=tuple(warnings),
388 states: Sequence[str],
389 actuators: Sequence[str],
390 positions: dict[str, dict[str, bool]],
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.
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.
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
413 warnings: list[str] = []
414 mains = [a
for a
in actuators
if "main" in a.lower()]
416 opened = sorted(a
for a
in actuators
if positions[state].get(a))
417 if state.lower() ==
"idle" and opened:
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 "
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"):
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."
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."""
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")
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():
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()
466 state = (row.get(
"name")
or "").strip()
467 if state
not in states:
470 place = (row.get(
"row")
or "").strip(), (row.get(
"col")
or "").strip()
472 layout[state] = (int(place[0]), int(place[1]))
473 if _flag(row.get(
"abort")):
475 flagged |= {s
for s
in states
if s
not in listed
and _is_abort(s)}
476 return layout, frozenset(flagged)
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):
485 if isinstance(value, (int, float)):
487 return str(value
or "").strip().lower()
in (
"1",
"true",
"yes")
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:
499 f
"state table schema {schema}; this feedtwin reads {MACHINE_SCHEMA}"
501 rows = raw.get(
"states")
or []
502 states = [str((s
or {}).get(
"name")
or "").strip()
for s
in rows]
504 raise ValueError(
"the state table has no 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 []]
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)
516 opened: Mapping[str, Any] = raw.get(
"open")
or {}
517 for state, names
in opened.items():
518 if state
not in known_states:
520 f
"the table opens valves in {state!r}, which is not a state"
522 stray = sorted(set(map(str, names
or [])) - known_rows)
524 raise ValueError(f
"{state} opens {', '.join(stray)}, which have no row")
526 s: {a: a
in set(map(str, opened.get(s)
or []))
for a
in actuators}
529 table = {s: dict(row)
for s, row
in positions.items()}
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)
539 f
"{state} may go to {', '.join(stray)}, which are not states"
541 allowed[state] = frozenset(map(str, targets
or []))
543 layout: dict[str, tuple[int, int]] = {}
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))
549 str(row[
"name"]).strip()
for row
in rows
if _flag(row.get(
"abort"))
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]
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)})."
564 for side, tank, example
in (
565 (
"lox",
"LOX",
"Ox Fill"),
566 (
"fuel",
"fuel",
"Fuel Fill"),
568 if not any(
fills(state, side)
for state
in states)
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'}."
578 name=str(raw.get(
"name")
or "edited"),
579 states=tuple(states),
580 actuators=tuple(actuators),
583 warnings=tuple(warnings),
591 machine: StateMachine,
592 valves: Mapping[str, str],
593 roles: Mapping[str, frozenset[str]] |
None =
None,
594 overrides: Mapping[str, str] |
None =
None,
596 """Join the machine's actuators to a drawing's valves by name.
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".
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.
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.
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.
622 table = {name: _words(name)
for name
in machine.actuators}
623 drawn = {sid: _words(label)
for sid, label
in valves.items()}
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:
632 decided.add(actuator)
633 elif sid
in valves
and sid
not in taken:
634 to_symbol[actuator] = sid
636 decided.add(actuator)
637 for actuator
in machine.actuators:
638 wanted = table[actuator]
639 if not wanted
or actuator
in decided:
648 if sid
not in taken
and roles
is not None and roles.get(sid) == wanted
650 best: tuple[int, int, str, str] |
None =
None
651 for sid, words
in drawn.items():
658 and (words & _FLUIDS)
659 and not (wanted & words & _FLUIDS)
662 overlap = len(wanted & words)
665 if overlap < len(wanted):
671 and roles.get(sid) != wanted
676 score = (overlap, -len(words - wanted), valves[sid], sid)
677 if best
is None or score > best:
680 to_symbol[actuator] = best[3]
683 by_role: list[str] = []
684 for actuator
in machine.actuators:
686 actuator
in to_symbol
687 or actuator
in decided
689 or not table[actuator]
694 for sid
in sorted(valves)
695 if sid
not in taken
and roles.get(sid) == table[actuator]
698 to_symbol[actuator] = fits[0]
700 by_role.append(actuator)
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),
711def available(tables: Path) -> list[str]:
712 """Machines whose tables are in ``tables``."""
713 if not tables.is_dir():
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)