STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
board_simulator.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2import socket
3import struct
4import time
5import threading
6import random
7import os
8import sys
9import argparse
10import json
11import math
12
13try:
14 import tomli
15except ModuleNotFoundError:
16 try:
17 import tomllib as tomli # stdlib since Python 3.11, same API
18 except ModuleNotFoundError:
19 raise SystemExit(
20 "board_simulator: no TOML parser available. "
21 "Install one with `pip install tomli` (or use Python >= 3.11)."
22 )
23
24# DiabloAvionics Packet Types (DAQv2-Comms DiabloEnums.h)
25PACKET_TYPE_HEARTBEAT = 1
26PACKET_TYPE_SERVER_HEARTBEAT = 2
27PACKET_TYPE_SENSOR_DATA = 3
28PACKET_TYPE_ACTUATOR_COMMAND = 4
29PACKET_TYPE_SENSOR_CONFIG = 5
30PACKET_TYPE_ACTUATOR_CONFIG = 6
31PACKET_TYPE_SELF_TEST = 12
32PACKET_TYPE_LOGS = 15
33
34# Board States (DAQv2-Comms DiabloEnums.h)
35BOARD_STATE_SETUP = 1
36BOARD_STATE_ACTIVE = 2
37BOARD_STATE_SELF_TEST = 10
38
39# Board Types
40BOARD_TYPE_PT = 1
41BOARD_TYPE_LC = 2
42BOARD_TYPE_RTD = 3
43BOARD_TYPE_TC = 4
44BOARD_TYPE_ACTUATOR = 5
45BOARD_TYPE_BALL_VALVE = 8
46
47# Motorized ball valve (firmware/Hotfire_Code/BallValve_Hotfire): position is a 4-20 mA loop across
48# 100 ohm into a 12-bit, 3.3 V ADC; the valve opens itself 5 s after server heartbeats stop.
49BALL_VALVE_TRAVEL_S = 3.0 # full stroke, either direction
50BALL_VALVE_HEARTBEAT_TIMEOUT_S = 5.0
51BALL_VALVE_SAFE_STATE = 1 # open
52
53
55 """Deliberate timing defects for exercising the bridge's clock sync.
56
57 All default off → simulator behaves exactly as before. See --net-jitter-ms,
58 --clock-drift-ppm, --clock-offset-ms, --reboot-after, --start-near-wrap,
59 --chunks-per-packet.
60 """
61
63 self,
64 chunks_per_packet=1,
65 net_jitter_ms=0.0,
66 clock_drift_ppm=0.0,
67 clock_offset_ms=0.0,
68 reboot_after_s=None,
69 start_near_wrap=False,
70 ):
71 self.chunks_per_packet = max(1, int(chunks_per_packet))
72 self.net_jitter_ms = float(net_jitter_ms)
73 self.clock_drift_ppm = float(clock_drift_ppm)
74 self.clock_offset_ms = float(clock_offset_ms)
75 self.reboot_after_s = reboot_after_s
76 self.start_near_wrap = bool(start_near_wrap)
77
78
81 self,
82 name,
83 board_config,
84 target_ip,
85 target_port,
86 low_noise=False,
87 board_index=0,
88 sim_pt_targets=None,
89 skip_startup=False,
90 timing=None,
91 sensor_hz=None,
92 allow_ip_fallback=False,
93 ):
94 self.name = name
95 self.board_index = board_index
96 self.config = board_config
97 # Only permit rebinding a non-bindable config IP to loopback when explicitly allowed
98 # (sim_config.toml already uses loopback IPs so it never needs this; a hardware config
99 # must NOT silently fall back — that emits synthetic data tagged with real board IDs).
100 self.allow_ip_fallback = bool(allow_ip_fallback)
101 self.target_ip = target_ip
102 self.target_port = target_port
103 self.low_noise = low_noise
104 self.sim_pt_targets = sim_pt_targets or {}
105 # None → per-type production defaults (see _run). A value overrides every
106 # board type uniformly, for CI where the point is "did anything obviously
107 # break", not sustaining hardware rates.
108 self.sensor_hz = sensor_hz
109
110 # ── Board clock model (faithful to firmware millis(): uptime-relative) ──
111 self.timing = timing or TimingPathology()
112 # Alternate drift sign across boards so both directions are exercised.
113 self.drift_ppm = self.timing.clock_drift_ppm * (
114 1 if board_index % 2 == 0 else -1
115 )
117 # start-near-wrap: begin ~30 s before the uint32 boundary. Compensate
118 # for clock_offset_ms so the wrap still lands ~30 s in when both
119 # pathologies are enabled together.
121 int(2**32 - 30_000 - self.timing.clock_offset_ms)
122 if self.timing.start_near_wrap
123 else 0
124 )
125 self._rebooted_at = None
126 # Deterministic per-board RNG for network jitter (reproducible runs).
127 self._jitter_rng = random.Random(0xD1AB70 + board_index * 7919)
128 self._pending_chunks = [] # [(chunk_ts_ms, packed_samples_bytes), ...]
129
130 self.ip = board_config.get("ip", "127.0.0.1")
131 self.board_id = board_config.get("board_id", 0)
132 self.board_type_str = board_config.get("type", "PT")
133 # The channels this board samples. active_connectors is the config's only
134 # statement of that (it is what config_broadcast packs into the board's
135 # SensorConfigPacket), so there is no count to fall back to.
137 int(c) for c in board_config.get("active_connectors", []) or []
138 ]
139 self.listen_port = board_config.get("listen_port", 5005)
140
141 # Map string type to enum
142 type_map = {
143 "PT": BOARD_TYPE_PT,
144 "LC": BOARD_TYPE_LC,
145 "RTD": BOARD_TYPE_RTD,
146 "TC": BOARD_TYPE_TC,
147 "ACTUATOR": BOARD_TYPE_ACTUATOR,
148 # Diablo::BoardType has no ENCODER yet — wire UNKNOWN(0); config drives routing.
149 "ENCODER": 0,
150 "ENVIRONMENTAL": 7,
151 "BALL_VALVE": BOARD_TYPE_BALL_VALVE,
152 }
153 self.board_type = type_map.get(self.board_type_str, BOARD_TYPE_PT)
154
155 # Sensor interface, declared per board. The ADC reference is set once per board, so every
156 # connector on a 4-20 mA board is a loop channel — there is no per-connector list.
157 # Falls back to the legacy hp_pt_* key presence for a config written before pt_type.
158 pt_type = board_config.get("pt_type")
159 if pt_type is not None:
160 self.is_current_loop = pt_type == "4-20 mA absolute"
161 else:
162 self.is_current_loop = bool(board_config.get("hp_pt_connectors")) or (
163 "hp_pt_full_scale_psi" in board_config
164 )
165 self.hp_pt_full_scale_psi = board_config.get("hp_pt_full_scale_psi", 5000.0)
166 self.hp_pt_sense_resistor_ohms = board_config.get(
167 "hp_pt_sense_resistor_ohms", 120
168 )
169
170 # State machine (matches SensorHotfireCore.h lifecycle)
171 self.skip_startup = skip_startup
172 self.board_state = BOARD_STATE_ACTIVE if skip_startup else BOARD_STATE_SETUP
173 if self.board_type_str == "ENVIRONMENTAL":
174 self.board_state = BOARD_STATE_ACTIVE # BME280 firmware streams without SENSOR_CONFIG.
175 self.can_receive = False # whether socket is bound to listen_port
176 # With --skip-startup we never bind listen_port (no SENSOR_CONFIG) so the normal
177 # SETUP→SELF_TEST→ACTIVE path never runs — self-test UDP is never sent unless we
178 # emit it once here (GUI + Playwright expect SELF_TEST.BOARD_* in sensorData).
180 # Self-test is a one-shot at activation, but on a session start it races the
181 # pipeline coming up (daq_bridge binding :5006, backend re-subscribing to the
182 # new Elodin DB). A single packet is often lost → no self-test shown. Re-send
183 # the same result a few times over the first seconds so at least one lands
184 # once the pipeline is ready. (Same idea as board_startup_sim's send burst.)
185 self._selftest_resend_until = 0.0 # wall-clock deadline; 0 = not arming
187
188 # Ball valve: commanded state and simulated position (% open) per connector. Boots closed.
189 self.valve_target = {ch: 0 for ch in self.active_connectors}
190 self.valve_pos = {ch: 0.0 for ch in self.active_connectors}
192 self._last_server_heartbeat = None # wall clock of the last SERVER_HEARTBEAT heard
193
194 self.running = False
195 self.sock = None
196 self.thread = None
198
199 def start(self):
200 self.running = True
201 self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
202 self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
203
204 bind_ip = self.ip
205 # A ball valve always takes its command port, even with --skip-startup: ACTUATOR_COMMAND
206 # arrives there for its whole life, not just during SETUP.
207 bind_port = (
208 self.listen_port
209 if self.board_state == BOARD_STATE_SETUP or self.board_type == BOARD_TYPE_BALL_VALVE
210 else 0
211 )
212
213 try:
214 self.sock.bind((bind_ip, bind_port))
215 self.can_receive = bind_port != 0
216 print(
217 f"[{self.name}] Bound to {bind_ip}:{bind_port}", flush=True
218 )
219 except Exception:
220 # The config IP (e.g. 192.168.2.21) is not on this host. Falling back to loopback here
221 # is how synthetic data ends up tagged with a REAL board's identity — the root cause of
222 # sim data sneaking into a live run. Only do it when explicitly allowed (sim_config.toml
223 # uses loopback IPs and doesn't need this; the integration test opts in). Otherwise abort.
224 if not self.allow_ip_fallback:
225 sys.exit(
226 f"[{self.name}] FATAL: config IP {self.config.get('ip')} is not bindable on this "
227 f"host. Refusing loopback fallback — it would emit synthetic data tagged as board "
228 f"{self.config.get('board_id')}. Run the simulator against config/sim_config.toml "
229 f"(loopback IPs), or pass --allow-ip-fallback (integration test only)."
230 )
231 # When config IPs (e.g. 192.168.2.21) are not on this host, bind a distinct loopback IP
232 # per board so daq_bridge can route each board's data. Mirror the config IP's host octet
233 # (192.168.2.51 -> 127.0.0.51): the bridge resolves loopback packets by that octet, which
234 # is identity-based and immune to [boards] iteration order (the C++ toml++ loader sorts
235 # alphabetically, this file iterates source order — an index-based scheme mislabels
236 # boards). Falls back to the old index scheme only if the config IP has no usable octet.
237 try:
238 host_octet = int(str(self.config.get("ip", "")).rsplit(".", 1)[-1])
239 except (ValueError, TypeError):
240 host_octet = 0
241 fallback_ip = (
242 f"127.0.0.{host_octet}" if host_octet >= 2 else f"127.0.0.{2 + self.board_index}"
243 )
244 try:
245 self.sock.bind((fallback_ip, bind_port))
246 self.ip = fallback_ip
247 self.can_receive = bind_port != 0
248 print(
249 f"[{self.name}] Bound to {fallback_ip}:{bind_port} (config IP {self.config.get('ip')} unavailable)",
250 flush=True,
251 )
252 except Exception:
253 # Last resort: ephemeral port, can't receive SENSOR_CONFIG
254 try:
255 self.sock.bind((fallback_ip, 0))
256 self.ip = fallback_ip
257 except Exception:
258 pass
259 if self.board_state == BOARD_STATE_SETUP:
260 print(
261 f"[{self.name}] Warning: Could not bind to port {bind_port}, skipping startup lifecycle",
262 flush=True,
263 )
264 self.board_state = BOARD_STATE_ACTIVE
265
266 # Non-blocking for interleaved send/receive
267 self.sock.settimeout(0)
268
269 self.thread = threading.Thread(target=self._run)
270 self.thread.daemon = True
271 self.thread.start()
272
273 def _board_ms(self, now):
274 """Board clock (uint32 millis), with optional drift/offset/wrap pathologies."""
275 uptime_s = (now - self._boot_epoch) * (1.0 + self.drift_ppm * 1e-6)
276 return (
277 int(uptime_s * 1000.0 + self._clock_base_ms + self.timing.clock_offset_ms)
278 & 0xFFFFFFFF
279 )
280
281 def _jitter_delay(self):
282 """One-sided network delay in seconds (0 when jitter disabled)."""
283 if self.timing.net_jitter_ms <= 0:
284 return 0.0
285 return self._jitter_rng.uniform(0.0, self.timing.net_jitter_ms) / 1000.0
286
287 def _run(self):
288 run_started = time.time()
289 last_heartbeat = 0
290 last_sensor_data = 0
291 last_log = 0
292 log_seq = 0
293
294 heartbeat_interval = 1.0 # 1 Hz
295 log_interval = 1.0 # 1 Hz — simple diagnostic logs, always on (see _send_logs)
296 if self.sensor_hz:
297 # Uniform override (--sensor-hz): every board type, encoder included.
298 # Encoder is normally 5x the others AND exempt from GUI envelope
299 # downsampling, so it dominates pipeline load — flattening it is the
300 # single biggest reduction available.
301 sensor_interval = 1.0 / self.sensor_hz
302 actuator_current_interval = sensor_interval
303 else:
304 sensor_interval = {"ENCODER": 0.02, "ENVIRONMENTAL": 0.2}.get(self.board_type_str, 0.1)
305 actuator_current_interval = 0.1 # Explicitly keep actuator current-sense packets at 10 Hz
306
307 while self.running:
308 now = time.time()
309
310 # --- Pathology: one-time reboot (millis resets to ~0) ---
311 if (
312 self.timing.reboot_after_s is not None
313 and self._rebooted_at is None
314 and now - run_started >= self.timing.reboot_after_s
315 ):
316 self._boot_epoch = now
317 self._clock_base_ms = 0 # a rebooted board counts from zero
318 self._rebooted_at = now
319 self._pending_chunks = []
320 print(f"[{self.name}] simulated REBOOT — board millis reset", flush=True)
321
322 ts_ms = self._board_ms(now)
323
324 # --- SETUP: wait for SENSOR_CONFIG (no timeout — matches real firmware) ---
325 if self.board_state == BOARD_STATE_SETUP:
326 if self.can_receive:
328
329 if self.board_type == BOARD_TYPE_BALL_VALVE:
330 self._step_ball_valve(now)
331
332 # --- skip-startup: no CONFIG, so emit one SELF_TEST pass burst (DAQ→Elodin→thin→WS) ---
333 if (
334 self.skip_startup
335 and self.board_type_str not in ("ENVIRONMENTAL", "BALL_VALVE")
336 and self.board_state == BOARD_STATE_ACTIVE
338 and now - run_started >= 2.0
339 ):
340 self._send_self_test()
342 self._selftest_resend_until = now + 6.0 # re-send window (pipeline-startup race)
343 print(
344 f"[{self.name}] skip-startup: one-shot SELF_TEST sent (pass all active connectors)",
345 flush=True,
346 )
347
348 # --- Self-test re-send window: the result is one-shot, but a session start
349 # races the pipeline coming up, so resend it ~1 Hz for a few seconds after
350 # activation. Same values (idempotent in the backend cache); ensures delivery. ---
351 if (
352 self._selftest_resend_until > 0.0
353 and now < self._selftest_resend_until
354 and now - self._selftest_last_resend >= 1.0
355 ):
356 self._send_self_test()
357 self._selftest_last_resend = now
358
359 # --- Send Heartbeat (all states, matching firmware) ---
360 if now - last_heartbeat >= heartbeat_interval:
361 self._send_heartbeat(ts_ms)
362 last_heartbeat = now
363
364 # --- Send diagnostic LOGS (type 15), 1 Hz, always on (does NOT honor the
365 # config mode byte — the sim just streams so the pipeline can be tested). ---
366 if now - last_log >= log_interval:
367 self._send_logs(ts_ms, log_seq)
368 log_seq += 1
369 last_log = now
370
371 # --- Send Sensor Data (ACTIVE only, matching firmware) ---
372 if self.board_state == BOARD_STATE_ACTIVE:
373 effective_sensor_interval = (
374 actuator_current_interval
375 if self.board_type == BOARD_TYPE_ACTUATOR
376 else sensor_interval
377 )
378 if now - last_sensor_data >= effective_sensor_interval:
379 self._send_sensor_data(ts_ms)
380 last_sensor_data = now
381
382 time.sleep(0.01)
383
385 """Non-blocking check for SENSOR_CONFIG or ACTUATOR_CONFIG from config_broadcast_service."""
386 try:
387 data, addr = self.sock.recvfrom(4096)
388 if data and len(data) >= 1 and data[0] in (PACKET_TYPE_SENSOR_CONFIG, PACKET_TYPE_ACTUATOR_CONFIG):
389 pkt_name = "ACTUATOR_CONFIG" if data[0] == PACKET_TYPE_ACTUATOR_CONFIG else "SENSOR_CONFIG"
390 if self.board_type == BOARD_TYPE_BALL_VALVE:
391 # The ball valve firmware sends no SELF_TEST; config only sets its log mode.
392 self.board_state = BOARD_STATE_ACTIVE
393 print(f"[{self.name}] {pkt_name} received from {addr}, ACTIVE", flush=True)
394 return
395 print(
396 f"[{self.name}] {pkt_name} received from {addr}, running self-test",
397 flush=True,
398 )
399 # Firmware: run self-test once, send results, immediately go ACTIVE
400 # (SensorHotfireCore.h: SelfTest state is transient — same loop iteration)
401 self._send_self_test()
402 self._selftest_resend_until = time.time() + 6.0 # re-send window (pipeline-startup race)
403 self.board_state = BOARD_STATE_ACTIVE
404 print(
405 f"[{self.name}] SELF_TEST sent, transitioning to ACTIVE",
406 flush=True,
407 )
408 except (BlockingIOError, socket.timeout, OSError):
409 pass
410
411 def _step_ball_valve(self, now):
412 """Read server heartbeats and actuator commands, apply the firmware's failsafe, and move
413 each valve toward its command at BALL_VALVE_TRAVEL_S per full stroke.
414
415 Like the firmware, CLOSE is refused while server heartbeats are not current, and the valve
416 opens itself 5 s after they stop. Unlike it, the gating only starts once a heartbeat has
417 been heard: a sim stack on loopback may never deliver the broadcast heartbeat, and a valve
418 that could never be closed would make the sim useless for exercising state transitions.
419 """
420 if self.board_state == BOARD_STATE_ACTIVE and self.can_receive:
421 while True:
422 try:
423 data, _addr = self.sock.recvfrom(4096)
424 except (BlockingIOError, socket.timeout, OSError):
425 break
426 if not data:
427 continue
428 if data[0] == PACKET_TYPE_SERVER_HEARTBEAT:
429 self._last_server_heartbeat = now
430 elif data[0] == PACKET_TYPE_ACTUATOR_COMMAND and len(data) >= 7:
431 n = data[6]
432 for i in range(n):
433 off = 7 + 2 * i
434 if off + 2 > len(data):
435 break
436 aid, state = data[off], 1 if data[off + 1] else 0
437 if aid not in self.valve_target:
438 continue
439 hb_current = self._last_server_heartbeat is None or (
440 now - self._last_server_heartbeat <= BALL_VALVE_HEARTBEAT_TIMEOUT_S
441 )
442 if state == BALL_VALVE_SAFE_STATE or hb_current:
443 self.valve_target[aid] = state
444
445 if (
446 self._last_server_heartbeat is not None
447 and now - self._last_server_heartbeat > BALL_VALVE_HEARTBEAT_TIMEOUT_S
448 ):
449 for ch in self.valve_target:
450 self.valve_target[ch] = BALL_VALVE_SAFE_STATE
451
452 dt = max(0.0, now - self._valve_last_step)
453 self._valve_last_step = now
454 step = 100.0 * dt / BALL_VALVE_TRAVEL_S
455 for ch, target in self.valve_target.items():
456 goal = 100.0 if target else 0.0
457 pos = self.valve_pos[ch]
458 self.valve_pos[ch] = min(goal, pos + step) if goal > pos else max(goal, pos - step)
459
461 """Send SELF_TEST packet with pass results for all active connectors.
462
463 Wire format (matches firmware SensorHotfireCore.h):
464 header(6B) + adc_good(1B) + num_sensors(1B) + N x (sensor_id(1B) + result(1B))
465
466 Firmware extracts sensor_0 (TDAC) from the results list and puts it in
467 the adc_good field. The DAQ bridge publishes adc_good as a sensor_id=0
468 Elodin row, then publishes each per-channel result separately.
469 """
470 if self.board_type_str == "ENVIRONMENTAL":
471 return # Environmental firmware has no ADC self-test packet.
472 active_connectors = self.active_connectors
473
474 ts_ms = self._board_ms(time.time())
475 header = struct.pack("<BBI", PACKET_TYPE_SELF_TEST, 0, ts_ms)
476
477 adc_good = 1
478 n = min(len(active_connectors), 255)
479 body = struct.pack("BB", adc_good, n)
480 for sensor_id in active_connectors[:n]:
481 body += struct.pack("BB", sensor_id & 0xFF, 1)
482
483 try:
484 self.sock.sendto(header + body, (self.target_ip, self.target_port))
485 except Exception:
486 pass
487
488 def _send_heartbeat(self, ts_ms):
489 # DAQv2-Comms: PacketHeader (6B) + BoardHeartbeatPacket = 32B firmware_hash + board_id +
490 # EngineState + BoardState (no board_type on wire — FSW maps type from config by source IP).
491 header = struct.pack("<BBI", PACKET_TYPE_HEARTBEAT, 0, ts_ms)
492 firmware_hash = bytes(32)
493 engine_safe = 0
494 body = firmware_hash + struct.pack(
495 "<BBB", self.board_id & 0xFF, engine_safe, self.board_state
496 )
497 try:
498 self.sock.sendto(header + body, (self.target_ip, self.target_port))
499 except Exception:
500 pass
501
502 def _send_logs(self, ts_ms, seq):
503 """Send a type-15 LOGS packet: a simple, board-identifying diagnostic line.
504 Wire format matches daq::create_log_packet / parse_log_packet:
505 PacketHeader(6B: type,version,ts) + flags(1B) + text_len(2B LE) + text.
506 The sim always streams these (it does NOT gate on the config mode byte) so
507 the board→bridge→backend→frontend log path can be exercised end to end."""
508 text = f"[SIM] board {self.board_id} {self.board_type_str} alive #{seq}".encode("utf-8")
509 flags = 0
510 text_len = len(text) & 0xFFFF
511 # <BBIBH = type(u8) version(u8) ts(u32 LE) flags(u8) text_len(u16 LE)
512 header = struct.pack("<BBIBH", PACKET_TYPE_LOGS, 0, ts_ms, flags, text_len)
513 try:
514 self.sock.sendto(header + text[:text_len], (self.target_ip, self.target_port))
515 except Exception:
516 pass
517
518 def _send_sensor_data(self, ts_ms):
519 """Collect one chunk (one scan of all channels, stamped with the board
520 clock); send a packet once chunks_per_packet chunks are accumulated —
521 matching real firmware batching (SENSOR_MAX_CHUNKS_BEFORE_SEND)."""
522 if self.board_type_str == "ENVIRONMENTAL":
523 if 1 not in self.active_connectors:
524 return
525 wave = 0.0 if self.low_noise else math.sin(time.time() * 0.1)
526 payload = struct.pack("<BBIfIf", 13, 0, ts_ms,
527 22.5 + wave, int(101325 + 100 * wave), 45.0 + wave)
528 try:
529 self.sock.sendto(payload, (self.target_ip, self.target_port))
530 self.packets_sent += 1
531 except OSError:
532 pass
533 return
534 active_connectors = self.active_connectors
535
536 chunk_data = struct.pack("<I", ts_ms)
537 for sensor_id in active_connectors:
538 val = self._generate_data(sensor_id)
539 chunk_data += struct.pack("<B I", sensor_id, val)
540 self._pending_chunks.append(chunk_data)
541
542 if len(self._pending_chunks) < self.timing.chunks_per_packet:
543 return
544
545 header = struct.pack("<BB I", PACKET_TYPE_SENSOR_DATA, 0, ts_ms)
546 body_header = struct.pack(
547 "<BB", len(self._pending_chunks), len(active_connectors)
548 )
549 payload = header + body_header + b"".join(self._pending_chunks)
550 self._pending_chunks = []
551
552 # Pathology: one-sided network delay before the send.
553 delay = self._jitter_delay()
554 if delay > 0:
555 time.sleep(delay)
556
557 try:
558 self.sock.sendto(payload, (self.target_ip, self.target_port))
559 self.packets_sent += 1
560 except Exception:
561 pass
562
563 def _generate_data(self, sensor_id):
564 t = time.time()
565 ADC_MAX = 2147483648 # 2^31 as per backend logic
566
567 if self.board_type == BOARD_TYPE_PT:
568 # Local connector id (1–10) — matches Elodin packet channel / daq_bridge
569 target_psi = self.sim_pt_targets.get(sensor_id)
570
571 if self.is_current_loop:
572 # HP PT (4-20 mA): psi = (i-4)/16 * full_scale. adc ∝ i.
573 # i_ma = 4 + 16*psi/full_scale; v = i*R/1000; adc = v/2.5 * ADC_MAX
574 psi = target_psi or 4000.0
575 psi = min(psi, self.hp_pt_full_scale_psi)
576 i_ma = 4.0 + 16.0 * (psi / self.hp_pt_full_scale_psi)
577 v_sense = i_ma * self.hp_pt_sense_resistor_ohms / 1000.0
578 adc_base = int(v_sense / 2.5 * ADC_MAX)
579 noise = 0 if self.low_noise else random.randint(-50000, 50000)
580 return max(0, min(adc_base + noise, ADC_MAX - 1))
581 else:
582 # Low-pressure PT: calibration psi ≈ C*adc + D (C≈6e-7, D≈-165)
583 # adc ≈ (target_psi + 165) / 6e-7 for typical polynomial
584 psi = target_psi or 500.0
585 adc_per_psi = 2_200_000 # ~(1/6e-7) for typical calibration
586 adc_base = int(500_000 + (psi + 165) * adc_per_psi)
587 if self.low_noise:
588 return max(0, min(adc_base, ADC_MAX - 1))
589 wave = (math.sin(t * 0.1 + sensor_id * 0.5) + 1) / 2.0
590 variation = int(20_000_000 * wave) # ±10M for slight movement
591 noise = random.randint(-10000, 10000)
592 return max(0, min(adc_base + variation + noise, ADC_MAX - 1))
593
594 elif self.board_type == BOARD_TYPE_LC:
595 if self.low_noise:
596 return 10_000_000 + sensor_id * 1_000_000
597 wave = (math.sin(t * 0.2 + self.board_id) + 1) / 2.0
598 val = int(wave * 50000000 + random.randint(-500, 500))
599 return max(0, min(val, ADC_MAX - 1))
600
601 elif self.board_type == BOARD_TYPE_TC:
602 if self.low_noise:
603 return 4_000_000 + sensor_id * 500_000
604 wave = (math.sin(t * 0.1 + self.board_id * 0.5) + 1) / 2.0
605 val = int(2000000 + wave * 8000000 + random.randint(-500, 500))
606 return max(0, min(val, ADC_MAX - 1))
607
608 elif self.board_type == BOARD_TYPE_RTD:
609 # Pt1000 at ~25°C: R≈1097Ω, I_exc=1000µA → V=1.097V
610 # ADC = (V/Vref) * 2^31 ≈ 942M. Small offset per sensor_id.
611 if self.low_noise:
612 return 942_000_000 + sensor_id * 5_000_000
613 wave = (math.sin(t * 0.05 + sensor_id) + 1) / 2.0
614 val = int(900_000_000 + wave * 100_000_000 + random.randint(-100, 100))
615 return max(0, min(val, ADC_MAX - 1))
616
617 elif self.board_type == BOARD_TYPE_ACTUATOR:
618 # 12-bit ADC (0-4095), 3.3V ref, V-to-I transfer = 1:1
619 # Slow sine wave for visual testing (~0-2.0A)
620 wave = (math.sin(t * 0.2 + sensor_id * 0.7) + 1) / 2.0
621 base = int(wave * 2500) # 0-2500 counts ≈ 0-2.0A
622 noise = 0 if self.low_noise else random.randint(-5, 5)
623 return max(0, min(base + noise, 4095))
624
625 elif self.board_type == BOARD_TYPE_BALL_VALVE:
626 # 4-20 mA position across 100 ohm into a 12-bit, 3.3 V ADC (calibration/ValvePosition.hpp).
627 ma = 4.0 + 16.0 * self.valve_pos.get(sensor_id, 0.0) / 100.0
628 counts = int(round(ma / 1000.0 * 100.0 / 3.3 * 4095.0))
629 noise = 0 if self.low_noise else random.randint(-2, 2)
630 return max(0, min(counts + noise, 4095))
631
632 elif self.board_type_str == "ENCODER":
633 # Deterministic encoder-ish counts for Elodin raw path (signed-friendly magnitude)
634 base = 1_000_000 + sensor_id * 10_000
635 return base + int(500 * math.sin(t + sensor_id))
636
637 return random.randint(1000, 5000)
638
639
640def main():
641 parser = argparse.ArgumentParser(description="DiabloAvionics Board Simulator")
642 parser.add_argument(
643 "--config", default="config/config.toml", help="Path to config.toml"
644 )
645 parser.add_argument(
646 "--target", default="127.0.0.1", help="Target IP (DAQ Bridge / Backend)"
647 )
648 parser.add_argument("--port", type=int, default=5006, help="Target Port")
649 parser.add_argument(
650 "--only-type",
651 metavar="TYPE",
652 help="Only simulate boards of this type (e.g. PT, TC, LC, RTD, ACTUATOR)",
653 )
654 parser.add_argument(
655 "--low-noise",
656 action="store_true",
657 help="Reduce PT noise for spike validation (smoother signal)",
658 )
659 parser.add_argument(
660 "--stats-file",
661 metavar="PATH",
662 help="Write JSON stats (packets sent per board) to this file on shutdown",
663 )
664 parser.add_argument(
665 "--duration",
666 type=float,
667 metavar="SECONDS",
668 help="Run for this many seconds then exit (default: run until Ctrl+C)",
669 )
670 parser.add_argument(
671 "--skip-startup",
672 action="store_true",
673 help="Skip SETUP/SELF_TEST lifecycle, go directly to ACTIVE",
674 )
675 parser.add_argument(
676 "--allow-ip-fallback",
677 action="store_true",
678 help="Permit binding loopback when a config IP isn't on this host (sim_config.toml / "
679 "integration test only — NEVER with a hardware config; it would emit synthetic data "
680 "tagged with real board IDs).",
681 )
682 parser.add_argument(
683 "--sensor-hz",
684 type=float,
685 default=None,
686 metavar="HZ",
687 help="Uniform sensor scan rate for ALL board types, overriding the per-type "
688 "defaults (10 Hz, 50 Hz encoder). For CI, where the goal is verifying "
689 "nothing obviously broke rather than sustaining hardware rates. Keep "
690 "above the GUI envelope budget (points_per_second) or downsampling "
691 "becomes a pass-through and stops being tested.",
692 )
693 # ── Timing pathologies (exercise the DAQ bridge's clock sync; default off) ──
694 parser.add_argument(
695 "--chunks-per-packet",
696 type=int,
697 default=1,
698 metavar="N",
699 help="Batch N chunks (scans) per sensor packet, each with its own board-clock timestamp",
700 )
701 parser.add_argument(
702 "--net-jitter-ms",
703 type=float,
704 default=0.0,
705 metavar="MS",
706 help="Random one-sided network delay in [0, MS] ms per sensor packet (seeded per board)",
707 )
708 parser.add_argument(
709 "--clock-drift-ppm",
710 type=float,
711 default=0.0,
712 metavar="PPM",
713 help="Board crystal drift; sign alternates per board so both directions run",
714 )
715 parser.add_argument(
716 "--clock-offset-ms",
717 type=float,
718 default=0.0,
719 metavar="MS",
720 help="Absolute board-clock offset (bogus clock)",
721 )
722 parser.add_argument(
723 "--reboot-after",
724 type=float,
725 default=None,
726 metavar="SECONDS",
727 help="Reset every board's millis to ~0 once, after this many seconds",
728 )
729 parser.add_argument(
730 "--start-near-wrap",
731 action="store_true",
732 help="Start board millis ~30 s before the uint32 wrap so a wrap occurs mid-run",
733 )
734 args = parser.parse_args()
735
736 if not os.path.exists(args.config):
737 print(f"Error: Config file not found at {args.config}")
738 return
739
740 try:
741 with open(args.config, "rb") as f:
742 config = tomli.load(f)
743 except Exception as e:
744 print(f"Error loading config: {e}")
745 return
746
747 boards = config.get("boards", {})
748 sim_pt_targets = {
749 int(k): float(v) for k, v in config.get("sim_pt_targets", {}).items()
750 }
751 simulated_boards = []
752
753 print(f"🚀 Starting Simulator - Target: {args.target}:{args.port}")
754 if sim_pt_targets:
755 print(
756 f" Sim PT targets: {len(sim_pt_targets)} channels (e.g. ch1/ch5=500, ch6=4k)"
757 )
758 if not args.skip_startup:
759 print(" Startup lifecycle: SETUP → SELF_TEST → ACTIVE (matching firmware)")
760
761 active_count = 0
762 for name, board_cfg in boards.items():
763 if not board_cfg.get("enabled", True):
764 continue
765 # Handled only by board_startup_sim.py (SETUP → SELF_TEST E2E)
766 if name == "integration_startup":
767 continue
768 # Filter by --only-type if specified
769 if (
770 args.only_type
771 and board_cfg.get("type", "PT").upper() != args.only_type.upper()
772 ):
773 continue
774
775 board = SimulatedBoard(
776 name,
777 board_cfg,
778 args.target,
779 args.port,
780 low_noise=args.low_noise,
781 board_index=active_count,
782 sim_pt_targets=sim_pt_targets,
783 skip_startup=args.skip_startup,
784 sensor_hz=args.sensor_hz,
785 allow_ip_fallback=args.allow_ip_fallback,
786 timing=TimingPathology(
787 chunks_per_packet=args.chunks_per_packet,
788 net_jitter_ms=args.net_jitter_ms,
789 clock_drift_ppm=args.clock_drift_ppm,
790 clock_offset_ms=args.clock_offset_ms,
791 reboot_after_s=args.reboot_after,
792 start_near_wrap=args.start_near_wrap,
793 ),
794 )
795 board.start()
796 simulated_boards.append(board)
797 active_count += 1
798 state_label = "ACTIVE" if args.skip_startup else "SETUP"
799 print(
800 f" [{state_label:6}] {name:15} | Type: {board.board_type_str:4} | ID: {board.board_id:2} | Source: {board.ip}:{board.listen_port}"
801 )
802
803 if active_count == 0:
804 print("No enabled boards found in config!", flush=True)
805 else:
806 print(f"{active_count} boards started.", flush=True)
807
808 print("Simulator is running. Press Ctrl+C to stop.", flush=True)
809
810 def write_stats():
811 if not args.stats_file:
812 return
813 stats = {
814 "total_sensor_packets": sum(b.packets_sent for b in simulated_boards),
815 "boards": {},
816 }
817 for b in simulated_boards:
818 active = b.active_connectors
819 stats["boards"][b.name] = {
820 "type": b.board_type_str,
821 "board_id": b.board_id,
822 "packets_sent": b.packets_sent,
823 "channels_per_packet": len(active),
824 "chunks_per_packet": b.timing.chunks_per_packet,
825 "total_sensor_updates": b.packets_sent
826 * len(active)
827 * b.timing.chunks_per_packet,
828 # Timing-pathology ground truth (for timestamp-quality tests)
829 "clock_drift_ppm": b.drift_ppm,
830 "clock_offset_ms": b.timing.clock_offset_ms,
831 "net_jitter_ms": b.timing.net_jitter_ms,
832 "start_near_wrap": b.timing.start_near_wrap,
833 "rebooted_at": b._rebooted_at,
834 }
835 stats["total_sensor_updates"] = sum(
836 v["total_sensor_updates"] for v in stats["boards"].values()
837 )
838 # Atomic write (tmp + rename): the integration test reads this file
839 # every run while the simulator is still updating it.
840 tmp_path = args.stats_file + ".tmp"
841 with open(tmp_path, "w") as f:
842 json.dump(stats, f, indent=2)
843 os.replace(tmp_path, args.stats_file)
844
845 # Register SIGTERM handler so integration test cleanup triggers stats write
846 import signal
847
848 def _sigterm_handler(signum, frame):
849 for b in simulated_boards:
850 b.running = False
851 time.sleep(0.1) # let threads finish current send
852 write_stats()
853 print(f"Stats written to {args.stats_file}", flush=True)
854 raise SystemExit(0)
855
856 signal.signal(signal.SIGTERM, _sigterm_handler)
857
858 try:
859 if args.duration:
860 time.sleep(args.duration)
861 print(f"\nDuration ({args.duration}s) reached. Stopping...")
862 for b in simulated_boards:
863 b.running = False
864 write_stats()
865 else:
866 while True:
867 time.sleep(1)
868 # Refresh ground-truth stats every second so the integration
869 # test's sample-conservation check can read live sent counts.
870 write_stats()
871 except KeyboardInterrupt:
872 print("\nStopping simulator...")
873 for b in simulated_boards:
874 b.running = False
875 write_stats()
876
877
878if __name__ == "__main__":
879 main()
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
__init__(self, name, board_config, target_ip, target_port, low_noise=False, board_index=0, sim_pt_targets=None, skip_startup=False, timing=None, sensor_hz=None, allow_ip_fallback=False)
__init__(self, chunks_per_packet=1, net_jitter_ms=0.0, clock_drift_ppm=0.0, clock_offset_ms=0.0, reboot_after_s=None, start_near_wrap=False)
TOML_NODISCARD_CTOR time() noexcept=default