STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
config_loader.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Shared configuration loader for the calibration stack.
4
5Reads config/config.toml (the single source of truth) and exposes board IPs,
6ports, sensor counts, calibration paths, and network settings so that every
7calibration script derives its parameters from the same place.
8
9Usage:
10 from config_loader import load_config, get_board_by_type, get_calibration_config, get_abort_pts
11
12 cfg = load_config() # full dict
13 pt = get_board_by_type("PT") # {ip, send_port, active_connectors, …}
14 cal = get_calibration_config("pt") # {json_dir, csv_paths}
15 abort_pts = get_abort_pts() # {"Fuel Upstream": 400, …} (PSI)
16"""
17
18from __future__ import annotations
19
20from pathlib import Path
21from typing import Any, Dict, List, Optional
22
23# ── Locate config.toml ────────────────────────────────────────────────────
24# Walk up from this file until we find config/config.toml
25_THIS_DIR = Path(__file__).resolve().parent
26_REPO_ROOT: Optional[Path] = None
27for _p in [_THIS_DIR] + list(_THIS_DIR.parents):
28 if (_p / "config" / "config.toml").is_file():
29 _REPO_ROOT = _p
30 break
31
32if _REPO_ROOT is None:
33 # Fallback: assume repo root is two levels up from tools/calibration/
34 _REPO_ROOT = _THIS_DIR.parent.parent
35
36CONFIG_PATH = _REPO_ROOT / "config" / "config.toml"
37
38# ── TOML parser (stdlib tomllib in 3.11+, fallback to toml/tomli) ─────────
39_toml_load = None
40try:
41 import tomllib # Python ≥ 3.11
42
43 def _toml_load(path: Path) -> dict: # noqa: F811
44 with open(path, "rb") as f:
45 return tomllib.load(f)
46
47except ImportError:
48 pass
49
50if _toml_load is None:
51 try:
52 import tomli # pip install tomli
53
54 def _toml_load(path: Path) -> dict: # noqa: F811
55 with open(path, "rb") as f:
56 return tomli.load(f)
57
58 except ImportError:
59 pass
60
61if _toml_load is None:
62 try:
63 import toml # pip install toml
64
65 def _toml_load(path: Path) -> dict: # noqa: F811
66 return toml.load(str(path))
67
68 except ImportError:
69 pass
70
71if _toml_load is None:
72 # Minimal fallback: hand-roll a tiny TOML subset parser
73 # (handles only what our config.toml actually uses)
74 import re as _re
75
76 def _toml_load(path: Path) -> dict: # noqa: F811
77 """Minimal TOML parser — enough for config.toml."""
78 text = path.read_text()
79 root: dict = {}
80 current_section: dict = root
81
82 for raw_line in text.splitlines():
83 line = raw_line.strip()
84 if not line or line.startswith("#"):
85 continue
86
87 # Section header
88 m = _re.match(r"^\‍[([^\‍]]+)\‍]$", line)
89 if m:
90 keys = m.group(1).split(".")
91 d = root
92 for k in keys:
93 d = d.setdefault(k, {})
94 current_section = d
95 continue
96
97 # Key = value
98 m = _re.match(r'^"?([^"=]+?)"?\s*=\s*(.+)$', line)
99 if not m:
100 continue
101 key = m.group(1).strip().strip('"')
102 val_str = m.group(2).strip()
103 current_section[key] = _parse_value(val_str)
104
105 return root
106
107 def _parse_value(s: str):
108 s = s.strip()
109 if s.lower() == "true":
110 return True
111 if s.lower() == "false":
112 return False
113 # Hex int
114 if _re.match(r"^0x[0-9a-fA-F]+$", s):
115 return int(s, 16)
116 # Int
117 try:
118 return int(s)
119 except ValueError:
120 pass
121 # Float
122 try:
123 return float(s)
124 except ValueError:
125 pass
126 # String
127 if (s.startswith('"') and s.endswith('"')) or (
128 s.startswith("'") and s.endswith("'")
129 ):
130 return s[1:-1]
131 # Array
132 if s.startswith("["):
133 inner = s.strip("[]").strip()
134 if not inner:
135 return []
136 items = []
137 for part in _re.split(r",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", inner):
138 items.append(_parse_value(part.strip()))
139 return items
140 return s
141
142
143# ── Public API ─────────────────────────────────────────────────────────────
144
145_cached_config: Optional[dict] = None
146
147
148def load_config(path: Optional[Path] = None) -> dict:
149 """Load and cache the full config dict from config.toml."""
150 global _cached_config
151 if _cached_config is not None and path is None:
152 return _cached_config
153 p = path or CONFIG_PATH
154 if not p.is_file():
155 raise FileNotFoundError(f"Config not found: {p}")
156 _cached_config = _toml_load(p)
157 return _cached_config
158
159
160def get_repo_root() -> Path:
161 return _REPO_ROOT
162
163
164def get_network_config() -> dict:
165 """Return [network] section."""
166 cfg = load_config()
167 return cfg.get("network", {})
168
169
171 """Return [database] section."""
172 cfg = load_config()
173 return cfg.get("database", {})
174
175
176def get_boards() -> Dict[str, dict]:
177 """Return all [boards.*] as {board_name: {type, ip, …}}."""
178 cfg = load_config()
179 return cfg.get("boards", {})
180
181
182def get_board_by_type(board_type: str) -> Optional[dict]:
183 """Return the first enabled board matching the given type (PT, TC, RTD, LC, ACTUATOR).
184 Returns None if no matching enabled board is found."""
185 for name, board in get_boards().items():
186 if board.get("type", "").upper() == board_type.upper():
187 if board.get("enabled", True):
188 return {**board, "name": name}
189 # Also check disabled boards as fallback
190 for name, board in get_boards().items():
191 if board.get("type", "").upper() == board_type.upper():
192 return {**board, "name": name}
193 return None
194
195
196def get_boards_by_type(board_type: str) -> List[dict]:
197 """Return all boards matching the given type."""
198 result = []
199 for name, board in get_boards().items():
200 if board.get("type", "").upper() == board_type.upper():
201 result.append({**board, "name": name})
202 return result
203
204
205def active_connectors(board: dict) -> List[int]:
206 """The connectors this board samples.
207
208 ``active_connectors`` is the config's only statement of which channels exist —
209 config_broadcast packs exactly this list into the board's SensorConfigPacket, and
210 the firmware's ``num_sensors`` byte is just its length. There is no separate count
211 to expand into a 1..N range, so an empty list means the board samples nothing.
212 """
213 raw = board.get("active_connectors")
214 if not isinstance(raw, (list, tuple)):
215 return []
216 out: List[int] = []
217 for c in raw:
218 try:
219 out.append(int(c))
220 except (TypeError, ValueError):
221 continue
222 return out
223
224
225def decode_board_namespaced_low(low: int) -> Optional[tuple]:
226 """
227 Decode daq_bridge / Elodin low byte: (board_slot, connector_1_10, is_raw).
228 Matches web-gui elodin-protocol decodeLow (PT/TC/RTD/LC 0x20–0x23).
229 Returns None if not a valid raw/cal sensor slot.
230 """
231 if low < 1:
232 return None
233 block_offset = low & 0x1F
234 is_raw = block_offset < 0x10
235 board_slot = (low >> 5) + 1
236 connector = block_offset & 0x0F
237 if connector < 1 or connector > 10:
238 return None
239 return (board_slot, connector, is_raw)
240
241
243 stype: str, board_slot: int, connector: int
244) -> Optional[int]:
245 """
246 Map physical board slot + connector to packet channel id (local connector 1–10; same as Elodin/daq_bridge).
247 """
248 for board in get_boards_by_type(stype):
249 if not board.get("enabled", True):
250 continue
251 board_id = int(board.get("board_id", 1))
252 mod = board_id % 10
253 slot = 10 if mod == 0 else mod
254 if slot != board_slot:
255 continue
256 active = active_connectors(board)
257 if connector not in active:
258 continue
259 return int(connector)
260 return None
261
262
263def build_channel_to_orchestrator_key() -> Dict[tuple, tuple]:
264 """
265 Build mapping (stype, packet_channel_id) → (stype, unique_ch) for relay packets.
266 Packet channel id is the local connector (1–10), matching daq_bridge / Elodin low-byte scheme.
267 unique_ch = board_id * 100 + connector_id. Returns dict for PT, TC, RTD, LC.
268 """
269 mapping: Dict[tuple, tuple] = {}
270 for stype in ("PT", "TC", "RTD", "LC"):
271 for board in get_boards_by_type(stype):
272 if not board.get("enabled", True):
273 continue
274 board_id = board.get("board_id", 1)
275 for conn in active_connectors(board):
276 packet_ch = conn
277 unique_ch = board_id * 100 + conn
278 mapping[(stype, packet_ch)] = (stype, unique_ch)
279 return mapping
280
281
282def is_current_loop_board(board: dict) -> bool:
283 """
284 True when a PT board's sensors are 4-20 mA current-loop transmitters.
285
286 `pt_type` is authoritative and matches what the C++ calibration service and the backend read.
287 A board written before pt_type existed is still recognised from its legacy hp_pt_* keys.
288 """
289 pt_type = board.get("pt_type")
290 if pt_type is not None:
291 return pt_type == "4-20 mA absolute"
292 return bool(board.get("hp_pt_connectors")) or "hp_pt_full_scale_psi" in board
293
294
295def get_hp_pt_packet_channels() -> Dict[int, dict]:
296 """
297 Return {connector: {full_scale_psi, sense_resistor_ohms, adc_ref_voltage}} for 4-20 mA PT
298 channels. Used when the robust stack excludes HP PTs — calibration_server applies the
299 4-20 mA linear conversion.
300
301 The ADC reference is set once per board, so every active connector on a current-loop board
302 is a loop channel. Keys are local connector ids, so two current-loop boards would collide
303 here; board-namespacing this layer is tracked separately.
304 """
305 result: Dict[int, dict] = {}
306 for board in get_boards_by_type("PT"):
307 if not board.get("enabled", True) or not is_current_loop_board(board):
308 continue
309 conns = active_connectors(board)
310 cfg = {
311 "full_scale_psi": float(board.get("hp_pt_full_scale_psi", 5000.0)),
312 "sense_resistor_ohms": float(board.get("hp_pt_sense_resistor_ohms", 120.0)),
313 "adc_ref_voltage": float(board.get("adc_ref_voltage", 2.5)),
314 }
315 for conn in conns:
316 result[conn] = cfg
317 return result
318
319
320def build_orchestrator_key_to_packet_ch() -> Dict[tuple, int]:
321 """Reverse: (stype, unique_ch) → packet_ch for legacy JSON keying."""
322 mapping: Dict[tuple, int] = {}
323 for stype in ("PT", "TC", "RTD", "LC"):
324 for board in get_boards_by_type(stype):
325 if not board.get("enabled", True):
326 continue
327 board_id = board.get("board_id", 1)
328 for conn in active_connectors(board):
329 packet_ch = conn
330 unique_ch = board_id * 100 + conn
331 mapping[(stype, unique_ch)] = packet_ch
332 return mapping
333
334
335def get_calibration_config(sensor_type: str) -> dict:
336 """Return [calibration.<sensor_type>] section.
337 sensor_type is case-insensitive (pt, tc, rtd, lc)."""
338 cfg = load_config()
339 cal = cfg.get("calibration", {})
340 return cal.get(sensor_type.lower(), {})
341
342
343def get_sensor_port() -> int:
344 """Return the UDP port boards send sensor data to (default 5006)."""
345 return get_network_config().get("sensor_port", 5006)
346
347
348def get_display_config() -> dict:
349 """Return [display] section."""
350 cfg = load_config()
351 return cfg.get("display", {})
352
353
354def get_abort_pts() -> Dict[str, Any]:
355 """Return [abort_pts] section: sensor role name → pressure threshold (PSI).
356 Used when building ACTUATOR_CONFIG (threshold is converted to ADC code per PT calibration).
357 """
358 cfg = load_config()
359 return cfg.get("abort_pts", {})
360
361
362def resolve_path(rel_path: str) -> Path:
363 """Resolve a repo-relative path from config to an absolute path."""
364 p = Path(rel_path)
365 if p.is_absolute():
366 return p
367 return _REPO_ROOT / p
368
369
370# ── Convenience: print summary ─────────────────────────────────────────────
371
372
374 """Print a human-readable summary of the loaded config."""
376 net = get_network_config()
378 boards = get_boards()
379 print(f"\n{'═'*70}")
380 print(" Config Summary ({})".format(CONFIG_PATH))
381 print(f"{'═'*70}")
382 print(
383 f" Network: bind={net.get('bind_ip','?')} sensor_port={net.get('sensor_port','?')}"
384 )
385 print(f" Database: {db.get('host','?')}:{db.get('port','?')}")
386 print(" Boards:")
387 for name, b in boards.items():
388 status = "✅" if b.get("enabled", True) else "⬜"
389 print(
390 f" {status} {name:<20s} type={b.get('type', '?'):<8s}"
391 f" ip={b.get('ip', '?'):<16s} channels={len(active_connectors(b))}"
392 )
393 print(" Calibration:")
394 for st in ["pt", "tc", "rtd", "lc"]:
396 if cc:
397 print(
398 f" {st.upper()}: json_dir={cc.get('json_dir','—')} csv={cc.get('csv_paths',['—'])}"
399 )
400 print(f"{'═'*70}\n")
401
402
403if __name__ == "__main__":
static emmc_status_t format(emmc_log_t *log)
Definition emmc_log.c:324
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
dict get_calibration_config(str sensor_type)
List[int] active_connectors(dict board)
Dict[tuple, int] build_orchestrator_key_to_packet_ch()
Dict[int, dict] get_hp_pt_packet_channels()
Path resolve_path(str rel_path)
Optional[int] packet_ch_for_board_connector(str stype, int board_slot, int connector)
Optional[tuple] decode_board_namespaced_low(int low)
List[dict] get_boards_by_type(str board_type)
Dict[str, Any] get_abort_pts()
dict get_network_config()
Dict[tuple, tuple] build_channel_to_orchestrator_key()
Optional[dict] get_board_by_type(str board_type)
Dict[str, dict] get_boards()
dict load_config(Optional[Path] path=None)
dict get_database_config()
bool is_current_loop_board(dict board)
dict get_display_config()