STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
run_config.py
Go to the documentation of this file.
1"""Human names for a run's numeric Elodin identities, from the run's config snapshot.
2
3Elodin records only numeric identities — `PT1.CH5`, `ACT2.CH1`, `ACT_CMD.B2.CH1`,
4`BOARD.HB_21` — and every state as a raw u8. The names live in config.toml, which the
5backend copies beside the run dir as `<run_id>.toml` when the session starts
6(service-controller.ts `snapshotRunConfig`). This module reads that snapshot back and
7rebuilds the mapping, so a past run reads as "Ox Upstream" and "Fire" instead of
8"PT1.CH5" and "16".
9
10Everything here mirrors conventions that already exist elsewhere and must not drift:
11
12* entity spelling — DatabaseConfig.cpp (`PT<n>.CH<c>`, `PT<n>_Cal.CH<c>`,
13 `ACT_CMD.B<n>.CH<c>`, `BOARD.HB_<board_id>`, `SELF_TEST.BOARD_<board_id>`, and for a
14 LabJack T7 `T7<n>.AIN<a>`, `T7<n>_Cal.AIN<a>`, `T7<n>.CJC` — register_t7_*_tables)
15* Elodin slot `<n>` = `board_id % 10`, 0 → 10 — LoadActiveBoards.hpp / sensor-config.ts
16* board display names — the config editor's `boardDisplayName` (app/config/page.tsx)
17
18A run predating the snapshot has no `.toml` and keeps its numeric entity names. State
19ids are the exception: they are stable by contract (see the `[[states]]` comment in
20config_base.toml), so the built-in table below names them even with no snapshot, and
21`[[states]]` overrides it the same way it overrides the C++ enum.
22"""
23
24from __future__ import annotations
25
26import re
27import tomllib
28from pathlib import Path
29
30from . import config, lc_tare
31
32# The state ids as the C++ enum defines them (control/StateMachine.hpp), named the way
33# config_base.toml's [[states]] does. Only a fallback: a snapshot's [[states]] wins.
34# Debug (0) is no longer a state the machine can enter but stays here so old runs decode.
35BUILTIN_STATES: dict[int, str] = {
36 0: "Debug",
37 1: "Idle",
38 2: "Armed",
39 3: "Fuel Fill",
40 4: "Ox Fill",
41 5: "GN2 Low Press",
42 6: "GN2 Low Vent",
43 7: "Fuel Press",
44 8: "Fuel Vent",
45 9: "Ox Press",
46 10: "Ox Vent",
47 11: "GN2 High Press",
48 12: "GN2 High Vent",
49 13: "Vent",
50 14: "Calibrate",
51 15: "Ready",
52 16: "Fire",
53 17: "Engine Abort",
54 18: "GSE Abort",
55 19: "Emergency Abort",
56 20: "Press Standby",
57 255: "Unknown",
58}
59
60# Fields whose value is a state id (see DatabaseConfig.cpp). BOARD.HB_*.engine_state is
61# NOT one of these — it carries the coarse daq::EngineState (SAFE/PRESSURIZING/…) that
62# heartbeat_service_main.cpp derives from the sequencer state, a different enum.
63STATE_FIELDS = frozenset({"current_state", "from_state", "to_state"})
64
65# Entity prefix per board `type`, as DatabaseConfig.cpp spells it.
66_ENTITY_PREFIX = {
67 "PT": "PT",
68 "TC": "TC",
69 "RTD": "RTD",
70 "LC": "LC",
71 "ACTUATOR": "ACT",
72 "ENCODER": "ENC",
73}
74
75# Board `type` → the word the config editor uses for it.
76_BOARD_TYPE_LABEL = {
77 "PT": "PT",
78 "ACTUATOR": "Actuator",
79 "BALL_VALVE": "Ball Valve",
80 "LC": "LC",
81 "TC": "TC",
82 "RTD": "RTD",
83 "ENCODER": "Encoder",
84 "T7": "LabJack T7",
85}
86
87#: AIN14 is the T7's internal temperature sensor, streamed raw beside any thermocouple.
88T7_CJC_AIN = 14
89
90
91def snapshot_path(run_id: str) -> Path:
92 """The config copied beside the run dir at session start."""
93 return config.ELODIN_DIR / f"{run_id}.toml"
94
95
96def load(run_id: str) -> dict:
97 """Parse the run's config snapshot. Missing or malformed → {} (names degrade to
98 numbers; a viewer must never fail to open a run over its metadata)."""
99 p = snapshot_path(run_id)
100 try:
101 with p.open("rb") as fh:
102 return tomllib.load(fh)
103 except (OSError, tomllib.TOMLDecodeError):
104 return {}
105
106
107def _slot(board_id: int) -> int:
108 """Elodin slot for a board_id: the last digit, with 0 meaning 10."""
109 return board_id % 10 or 10
110
111
112def _board_display_names(boards: dict) -> dict[int, str]:
113 """board_id → "PT Board #2", numbered by position among boards of the same type
114 (matching the config editor). A lone board of its type drops the ordinal."""
115 out: dict[int, str] = {}
116 for key, board in boards.items():
117 if not isinstance(board, dict):
118 continue
119 btype = board.get("type")
120 board_id = board.get("board_id")
121 if not isinstance(btype, str) or not isinstance(board_id, int):
122 continue
123 same = [k for k, b in boards.items() if isinstance(b, dict) and b.get("type") == btype]
124 label = _BOARD_TYPE_LABEL.get(btype, btype)
125 out[board_id] = (
126 f"{label} Board #{same.index(key) + 1}" if len(same) > 1 else f"{label} Board"
127 )
128 return out
129
130
131def entity_labels(cfg: dict) -> dict[str, str]:
132 """Entity name → the role it plays, for every entity config can name."""
133 labels: dict[str, str] = {}
134 boards = cfg.get("boards") or {}
135 if not isinstance(boards, dict):
136 return labels
137
138 # Sensor channels: each board's own [sensor_roles_<board key>] maps role → connector.
139 # Raw and calibrated entities are the same physical channel, so both take the role.
140 for key, board in boards.items():
141 if not isinstance(board, dict):
142 continue
143 prefix = _ENTITY_PREFIX.get(board.get("type"))
144 board_id = board.get("board_id")
145 if prefix is None or not isinstance(board_id, int):
146 continue
147 n = _slot(board_id)
148 roles = cfg.get(f"sensor_roles_{key}") or {}
149 if not isinstance(roles, dict):
150 continue
151 for role, ch in roles.items():
152 if not isinstance(ch, int):
153 continue
154 labels[f"{prefix}{n}.CH{ch}"] = role
155 labels[f"{prefix}{n}_Cal.CH{ch}"] = role
156
157 # LabJack T7s: each [[boards.<key>.channels]] row names its own AIN (no sensor_roles_ map).
158 # The raw table carries volts and the calibrated one psi / °C / kg; both are that sensor.
159 display = _board_display_names(boards)
160 for key, board in boards.items():
161 if not isinstance(board, dict) or board.get("type") != "T7":
162 continue
163 board_id = board.get("board_id")
164 channels = board.get("channels")
165 if not isinstance(board_id, int) or not isinstance(channels, list):
166 continue
167 n = _slot(board_id)
168 has_tc = False
169 for ch in channels:
170 if not isinstance(ch, dict) or not isinstance(ch.get("ain"), int):
171 continue
172 has_tc = has_tc or ch.get("kind") == "TC"
173 role = ch.get("role")
174 if isinstance(role, str) and role:
175 labels[f"T7{n}.AIN{ch['ain']}"] = role
176 labels[f"T7{n}_Cal.AIN{ch['ain']}"] = role
177 if has_tc:
178 name = display.get(board_id, f"T7 {board_id}")
179 labels[f"T7{n}.AIN{T7_CJC_AIN}"] = f"{name} internal sensor"
180 labels[f"T7{n}.CJC"] = f"{name} cold junction"
181
182 # Actuators: [actuator_roles] is role = [NO|NC, channel, board_id]. It wins over any
183 # sensor role on the same channel — on an actuator board the valve IS the channel.
184 # board_id defaults to 11 (the first actuator board), as sensor-config.ts does.
185 # A role on a BALL_VALVE board is recorded under its own namespace (DatabaseConfig.cpp):
186 # BV<n>.CH<c> position counts, BV<n>_Cal.CH<c> % open, BV_CMD.B<n>.CH<c> commanded.
187 ball_valve_ids = {
188 b.get("board_id")
189 for b in boards.values()
190 if isinstance(b, dict) and b.get("type") == "BALL_VALVE"
191 }
192 for role, spec in (cfg.get("actuator_roles") or {}).items():
193 if not isinstance(spec, list) or len(spec) < 2 or not isinstance(spec[1], int):
194 continue
195 ch = spec[1]
196 board_id = spec[2] if len(spec) >= 3 and isinstance(spec[2], int) else 11
197 n = _slot(board_id)
198 if board_id in ball_valve_ids:
199 labels[f"BV{n}.CH{ch}"] = role
200 labels[f"BV{n}_Cal.CH{ch}"] = role # its position, % open
201 labels[f"BV_CMD.B{n}.CH{ch}"] = role
202 continue
203 labels[f"ACT{n}.CH{ch}"] = role
204 labels[f"ACT{n}_Cal.CH{ch}"] = role # current sense on that valve
205 labels[f"ACT_CMD.B{n}.CH{ch}"] = role # what the sequencer commanded it to
206
207 # Board-level entities are keyed by the raw board_id, not the Elodin slot.
208 for board_id, name in _board_display_names(boards).items():
209 labels[f"BOARD.HB_{board_id}"] = name
210 labels[f"SELF_TEST.BOARD_{board_id}"] = name
211
212 return labels
213
214
215# A self-test entity is per-sensor: SELF_TEST.BOARD_<board_id>.s<sensor_id>. The board
216# half is what config can name; the sensor index rides along.
217_SELF_TEST_RE = re.compile(r"^(SELF_TEST\.BOARD_\d+)\.s(\d+)$")
218
219
220def label_for(entity: str, labels: dict[str, str]) -> str:
221 """The human name for one entity, or "" when config does not name it."""
222 hit = labels.get(entity)
223 if hit:
224 return hit
225 m = _SELF_TEST_RE.match(entity)
226 if m and (board := labels.get(m.group(1))):
227 return f"{board} sensor {m.group(2)}"
228 return ""
229
230
231def state_names(cfg: dict) -> dict[int, str]:
232 """State id → name. `[[states]]` overrides the built-in table entry by entry, so a
233 partial list cannot erase the machine's knowledge of its own states."""
234 names = dict(BUILTIN_STATES)
235 entries = cfg.get("states")
236 if isinstance(entries, list):
237 for e in entries:
238 if isinstance(e, dict) and isinstance(e.get("id"), int) and isinstance(e.get("name"), str):
239 names[e["id"]] = e["name"]
240 return names
241
242
243def annotate(index: dict, run_id: str) -> dict:
244 """Attach the run's names to a component index, in place.
245
246 Done at serve time rather than baked into the cached index: the snapshot is a
247 separate file from the parquet cache, so one dropped in later (or a config fix)
248 takes effect on the next open instead of needing the export thrown away.
249 """
250 cfg = load(run_id)
251 # Tared load-cell channels are synthesised HERE rather than in build_index, for the same
252 # reason names are: the sidecar is a separate file from the parquet cache, so a run exported
253 # before its tare record was written still shows its tared traces on the next open instead of
254 # needing the export thrown away. This is also why INDEX_VERSION does not move for this
255 # feature — bumping it would invalidate every cached export on the box for nothing.
256 comps = index.get("components", [])
257 comps.extend(lc_tare.tared_components(run_id, comps))
258 index["components"] = comps
259
260 labels = entity_labels(cfg)
261 for comp in index.get("components", []):
262 comp["label"] = label_for(comp.get("entity", ""), labels)
263 index["has_config"] = bool(cfg)
264 # JSON object keys are strings; the frontend indexes by String(value).
265 index["states"] = {str(k): v for k, v in sorted(state_names(cfg).items())}
266 index["state_fields"] = sorted(STATE_FIELDS)
267 return index
dict[int, str] _board_display_names(dict boards)
dict annotate(dict index, str run_id)
str label_for(str entity, dict[str, str] labels)
dict[int, str] state_names(dict cfg)
dict load(str run_id)
Definition run_config.py:96
Path snapshot_path(str run_id)
Definition run_config.py:91
int _slot(int board_id)
dict[str, str] entity_labels(dict cfg)