STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
calibration_orchestrator.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Unified Calibration Orchestrator
4=================================
5Two-phase calibration lifecycle for all sensor types (PT, TC, RTD, LC)
6backed by the full mathematical framework from the paper:
7
8 ▸ Environmental-robust basis functions φ(v, e) [Eq 66-72]
9 ▸ Total Least Squares (TLS) calibration [Eq 112-118]
10 ▸ Bayesian regression with hierarchical priors [Eq 126-149]
11 ▸ Recursive Least Squares with forgetting [Eq 162-166]
12 ▸ Generalised Likelihood Ratio drift detection [Eq 235-248]
13 ▸ Empirical Bayes population prior evolution
14 ▸ Active learning — system requests recalibration when quality degrades
15 ▸ Transfer learning across sensors and sessions
16
17Phase 1 – CALIBRATION (human-in-the-loop)
18 User supplies known reference values. The orchestrator collects raw ADC
19 via UDP, converts to voltage, runs TLS + Bayesian update, validates
20 quality, and saves coefficients.
21
22Phase 2 – MONITORING / SELF-RECALIBRATION (autonomous, runs forever)
23 After Phase 1 the orchestrator enters a continuous monitoring loop:
24 ▸ RLS online parameter updates per channel (with forgetting factor)
25 ▸ GLR drift detection (proper log-likelihood ratio)
26 ▸ Bayesian full-recalibration when drift detected
27 ▸ Empirical Bayes population prior propagation across all channels
28 ▸ Active learning: system raises recalibration alerts
29 ▸ Automatic periodic saves of coefficients + learned priors
30
31Everything is config-driven — board IPs, ports, sensor counts, and
32calibration paths are read from config/config.toml.
33
34Usage:
35 python3 calibration_orchestrator.py # Full lifecycle
36 python3 calibration_orchestrator.py --skip-phase1 # Monitor only
37 python3 calibration_orchestrator.py --phase1-only # Calibrate & exit
38 python3 calibration_orchestrator.py --sensors PT TC # Specific types
39"""
40
41from __future__ import annotations
42
43import sys, os, time, json, signal, socket, struct, threading, logging
44import numpy as np
45from pathlib import Path
46from typing import Dict, List, Optional, Tuple
47from dataclasses import dataclass, field
48from collections import deque, defaultdict
49from datetime import datetime
50
51logging.basicConfig(
52 level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
53)
54logger = logging.getLogger(__name__)
55
56# ── Config loader ────────────────────────────────────────────────────────
57try:
58 from config_loader import (
59 load_config,
60 get_board_by_type,
61 get_sensor_port,
62 get_calibration_config,
63 resolve_path,
64 get_boards,
65 build_orchestrator_key_to_packet_ch,
66 is_current_loop_board,
67 active_connectors as board_active_connectors,
68 )
69
70 _cfg_loaded = True
71except ImportError:
72 _cfg_loaded = False
73 logger.warning("config_loader not available — using hardcoded fallbacks")
74
75# ── Mathematical framework imports ───────────────────────────────────────
76from robust_calibration import (
77 RobustCalibrationFramework,
78 EnvironmentalState,
79 CalibrationPoint as RobustCalPoint,
80)
81from autonomous_calibration_engine import (
82 AutonomousCalibrationEngine,
83 AdaptivePriorEvolution,
84 DriftDetector as EngineDriftDetector,
85 ActiveLearningAgent,
86 OnlineBayesianLearner,
87 CalibrationRequest,
88)
89
90try:
91 from calibration_robustness import (
92 RobustnessManager,
93 SystemConfig as RobustnessConfig,
94 OperationMode,
95 )
96
97 ROBUSTNESS_AVAILABLE = True
98except ImportError:
99 ROBUSTNESS_AVAILABLE = False
100
101# ── Actuator communication (2-way: send commands, receive status) ──────
102try:
103 from controller_integration import ActuatorComm
104
105 ACTUATOR_COMM_AVAILABLE = True
106except ImportError:
107 ACTUATOR_COMM_AVAILABLE = False
108 logger.warning("⚠️ Actuator communication not available")
109
110# ── DiabloAvionics packet constants ─────────────────────────────────────
111PACKET_HEADER_FORMAT = "<BBI"
112PACKET_HEADER_SIZE = 6
113SENSOR_DATA_PACKET_FORMAT = "<BB"
114SENSOR_DATA_PACKET_SIZE = 2
115SENSOR_DATA_CHUNK_FORMAT = "<I"
116SENSOR_DATA_CHUNK_SIZE = 4
117SENSOR_DATAPOINT_FORMAT = "<BI"
118SENSOR_DATAPOINT_SIZE = 5
119PACKET_TYPE_SENSOR_DATA = 3
120MAX_PACKET_SIZE = 512
121
122# ADC → Voltage conversion (24-bit ADC at 2.5V reference, sign-extended to 32-bit)
123ADC_REF_VOLTAGE = 2.5
124ADC_FULL_SCALE = 2**31 # 2147483648
125
126
127def adc_to_voltage(adc_signed: int) -> float:
128 """Convert signed 32-bit ADC code to voltage."""
129 return (adc_signed * ADC_REF_VOLTAGE) / ADC_FULL_SCALE
130
131
132# ── Sensor type descriptors ─────────────────────────────────────────────
133
134
135@dataclass
137 name: str # PT, TC, RTD, LC
138 unit: str # PSI, °C, lbs
139 ref_prompt: str
140 board_ip: Optional[str]
141 udp_port: int
142 # The connectors this board samples, straight from active_connectors — the config's
143 # only statement of which channels exist. Not a count: a board may declare {1, 2, 6},
144 # so anything that walks channels must walk this list, never range(1, n + 1).
145 connectors: List[int]
146 calibration_dir: Path
147 enabled: bool
148
149
150def _build_sensor_types() -> Dict[str, SensorTypeInfo]:
151 types = {}
152 defs = {
153 "PT": ("PSI", "pressure (PSI)", "192.168.2.101", list(range(1, 11))),
154 "TC": ("°C", "temperature (°C)", "192.168.2.103", list(range(1, 11))),
155 "RTD": ("°C", "temperature (°C)", "192.168.2.104", list(range(1, 5))),
156 "LC": ("lbs", "force (lbs)", "192.168.2.102", list(range(1, 5))),
157 }
158 base_cal = Path(__file__).parent / "calibrations"
159 for name, (unit, prompt, fallback_ip, fallback_conns) in defs.items():
160 ip, conns, port, enabled = fallback_ip, fallback_conns, 5006, True
161 cal_dir = base_cal / name.lower() if name != "PT" else base_cal
162 if _cfg_loaded:
163 port = get_sensor_port()
164 b = get_board_by_type(name)
165 if b:
166 ip = b.get("ip", fallback_ip)
167 conns = board_active_connectors(b) or fallback_conns
168 enabled = b.get("enabled", True)
169 cc = get_calibration_config(name.lower())
170 jd = cc.get("json_dir")
171 if jd:
172 cal_dir = resolve_path(jd)
173 cal_dir.mkdir(parents=True, exist_ok=True)
174 types[name] = SensorTypeInfo(
175 name, unit, prompt, ip, port, conns, cal_dir, enabled
176 )
177 return types
178
179
180SENSOR_TYPES = _build_sensor_types()
181
182# Number of parameters in environmental-robust basis (from the paper)
183N_PARAMS = 9 # Paper: 9-parameter environmental-robust basis (φ₀–φ₈)
184
185# ═════════════════════════════════════════════════════════════════════════
186# UDP RECEIVER
187# ═════════════════════════════════════════════════════════════════════════
188
189
190class UDPSensorReceiver(threading.Thread):
191 """Receives raw DiabloAvionics UDP packets and dispatches samples."""
192
193 def __init__(self, port: int):
194 super().__init__(daemon=True)
195 self.port = port
196 self._stop = threading.Event()
197 self.sample_queue: deque = deque(maxlen=5000)
198 self.connected = False
199 self.stats = {"packets": 0, "samples": 0}
200
201 def stop(self):
202 self._stop.set()
203
204 @staticmethod
205 def parse(data: bytes):
206 """Parse DiabloAvionics sensor data packet with error handling."""
207 try:
208 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
209 return None
210 pt, _, _ = struct.unpack(PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE])
211 if pt != PACKET_TYPE_SENSOR_DATA:
212 return None
213 off = PACKET_HEADER_SIZE
214 nc, ns = struct.unpack(
215 SENSOR_DATA_PACKET_FORMAT, data[off : off + SENSOR_DATA_PACKET_SIZE]
216 )
217 off += SENSOR_DATA_PACKET_SIZE
218 per = SENSOR_DATA_CHUNK_SIZE + ns * SENSOR_DATAPOINT_SIZE
219 required_len = PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + nc * per
220 if len(data) < required_len:
221 logger.debug(f"Packet too short: {len(data)} < {required_len}")
222 return None
223 samples = []
224 for _ in range(nc):
225 off += SENSOR_DATA_CHUNK_SIZE
226 for _ in range(ns):
227 if off + SENSOR_DATAPOINT_SIZE > len(data):
228 logger.warning("Packet truncated during parsing")
229 return samples # Return partial samples
230 sid, raw = struct.unpack(
231 SENSOR_DATAPOINT_FORMAT, data[off : off + SENSOR_DATAPOINT_SIZE]
232 )
233 off += SENSOR_DATAPOINT_SIZE
234 # Sign-extend 32-bit value
235 signed = raw if raw < 0x80000000 else raw - 0x100000000
236 samples.append((sid, signed))
237 return samples
238 except struct.error as e:
239 logger.debug(f"Packet parse error: {e}")
240 return None
241 except Exception as e:
242 logger.warning(f"Unexpected parse error: {e}")
243 return None
244
245 def run(self):
246 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
247 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
248 sock.settimeout(0.2)
249 try:
250 sock.bind(("0.0.0.0", self.port))
251 self.connected = True
252 logger.info(f"📡 UDP receiver on :{self.port}")
253 except OSError as e:
254 logger.error(f"Bind :{self.port} failed: {e}")
255 return
256 while not self._stop.is_set():
257 try:
258 data, addr = sock.recvfrom(MAX_PACKET_SIZE)
259 except socket.timeout:
260 continue
261 except Exception:
262 if not self._stop.is_set():
263 continue
264 break
265 samples = self.parse(data)
266 if samples is None:
267 continue
268 self.stats["packets"] += 1
269 src_ip = addr[0]
270 for sid, signed in samples:
271 self.stats["samples"] += 1
272 self.sample_queue.append((src_ip, sid, signed, time.time()))
273 sock.close()
274 self.connected = False
275
276
277# ═════════════════════════════════════════════════════════════════════════
278# CALIBRATION ORCHESTRATOR
279# ═════════════════════════════════════════════════════════════════════════
280
281
283 """
284 Two-phase calibration lifecycle backed by the paper's full math:
285 • RobustCalibrationFramework per channel (TLS, Bayesian, GLR, RLS)
286 • AutonomousCalibrationEngine for cross-sensor empirical Bayes +
287 active learning
288 """
289
290 def __init__(self, sensor_names: Optional[List[str]] = None):
291 # ── sensor types ────────────────────────────────────────────────
292 self.sensor_types: Dict[str, SensorTypeInfo] = {}
293 for name, info in SENSOR_TYPES.items():
294 if sensor_names and name not in sensor_names:
295 continue
296 self.sensor_types[name] = info
297
298 # ── Active connectors per board IP (from config or all) ──────────────
299 # Key: (sensor_type, board_ip) -> List[int] of active connectors
300 # This allows multiple boards of the same type with different IPs
301 self.active_connectors: Dict[Tuple[str, str], List[int]] = {}
302 self.board_ip_to_type: Dict[str, str] = {} # IP -> sensor type mapping
303
304 for stype, info in self.sensor_types.items():
305 # Get all boards of this type (supports multiple boards)
306 if _cfg_loaded:
307 from config_loader import get_boards_by_type
308
309 boards = get_boards_by_type(stype)
310 else:
311 boards = []
312
313 if not boards:
314 # Fallback: use single board info from SensorTypeInfo
315 board_ip = info.board_ip or "unknown"
316 active = list(info.connectors)
317 self.active_connectors[(stype, board_ip)] = active
318 self.board_ip_to_type[board_ip] = stype
319 logger.info(
320 f" {stype} ({board_ip}): active connectors = {active} (default: all)"
321 )
322 else:
323 # Process each board separately
324 exclude_hp = (
325 load_config().get("calibration", {}).get("exclude_hp_pt", True)
326 if _cfg_loaded
327 else True
328 )
329 for board in boards:
330 # Skip HP PT boards (4-20 mA) — they use linear conversion, not robust calibration
331 if stype == "PT" and exclude_hp and is_current_loop_board(board):
332 logger.info(
333 f" {stype} ({board.get('name', '?')}, {board.get('ip', '?')}): skipped (HP PT board)"
334 )
335 continue
336 board_ip = board.get("ip", info.board_ip or "unknown")
337 board_name = board.get("name", "unknown")
338 # active_connectors is the board's whole channel list; a board with
339 # an empty one samples nothing, so there is no range to fall back to.
340 active = board_active_connectors(board)
341
342 self.active_connectors[(stype, board_ip)] = active
343 self.board_ip_to_type[board_ip] = stype
344
345 # Store board ID for this IP to build unique keys
346 if not hasattr(self, "ip_to_board_id"):
348 self.ip_to_board_id[board_ip] = board.get("board_id", 1)
349
350 logger.info(
351 f" {stype} ({board_name}, {board_ip}, ID {self.ip_to_board_id[board_ip]}): active connectors = {active}"
352 )
353
354 # ── Per-channel RobustCalibrationFramework ──────────────────────
355 # keyed by (sensor_type, board_ip, channel_id) - ONLY for active connectors
356 # We use (stype, ch) as the key but track board_ip separately for routing
357 self.robust: Dict[Tuple[str, int], RobustCalibrationFramework] = {}
358 self.channel_to_board_ip: Dict[Tuple[str, int], str] = (
359 {}
360 ) # Track which board each channel belongs to
361 channel_idx = 0
362 for (stype, board_ip), active_conns in self.active_connectors.items():
363 board_id = getattr(self, "ip_to_board_id", {}).get(board_ip, 1)
364 for ch in active_conns:
365 # Use board-aware unique ID: boardId * 100 + channelId
366 unique_ch = board_id * 100 + ch
367 key = (stype, unique_ch)
368 if key not in self.robust:
369 self.robust[key] = RobustCalibrationFramework(sensor_id=unique_ch)
370 self.channel_to_board_ip[key] = board_ip
371 else:
372 logger.warning(
373 f" ⚠️ Channel conflict: {stype} key {key} exists on multiple boards. Using first."
374 )
375
376 # Total channels = only active connectors
377 total_channels = sum(len(conns) for conns in self.active_connectors.values())
378
379 # ── Cross-sensor Autonomous Engine ──────────────────────────────
380 orch_cfg = {}
381 if _cfg_loaded:
382 orch_cfg = load_config().get("calibration", {}).get("orchestrator", {})
383 forgetting = float(orch_cfg.get("rls_forgetting_factor", 0.995))
385 n_sensors=total_channels,
386 n_params=N_PARAMS,
387 forgetting_factor=forgetting,
388 )
389
390 # Build flat index → key mapping (for engine ↔ robust) - ONLY active connectors
391 self._idx_to_key: Dict[int, Tuple[str, int]] = {}
392 self._key_to_idx: Dict[Tuple[str, int], int] = {}
393 idx = 0
394 for key in sorted(self.robust.keys()): # Sort for consistent ordering
395 self._idx_to_key[idx] = key
396 self._key_to_idx[key] = idx
397 idx += 1
398
399 # ── Live ADC buffers ────────────────────────────────────────────
400 self.live_adc: Dict[Tuple[str, int], deque] = defaultdict(
401 lambda: deque(maxlen=200)
402 )
403
404 # ── IP → sensor type routing ────────────────────────────────────
405 self.ip_to_type: Dict[str, str] = {}
406 for stype, info in self.sensor_types.items():
407 if info.board_ip:
408 self.ip_to_type[info.board_ip] = stype
409
410 # ── Reference values (set by user during Phase 1 or live) ──────
411 self.references: Dict[Tuple[str, int], float] = {}
412
413 # ── Environmental state (default; can be updated by sensors) ────
415
416 # ── UDP receiver ────────────────────────────────────────────────
417 port = (
418 next(iter(self.sensor_types.values())).udp_port
419 if self.sensor_types
420 else 5006
421 )
423
424 # ── Config-driven settings ──────────────────────────────────────
425 self.min_points = int(orch_cfg.get("min_points", 5))
426 self.target_points = int(orch_cfg.get("target_points", 15))
427 self.max_points = int(orch_cfg.get("max_points", 30))
428 self.target_r2 = float(orch_cfg.get("target_r_squared", 0.99))
429 self.min_r2 = float(orch_cfg.get("min_r_squared", 0.95))
430 self.glr_threshold = float(orch_cfg.get("drift_glr_threshold", 2.0))
431 self._save_interval = float(orch_cfg.get("auto_save_interval_sec", 300))
432 self._status_interval = float(orch_cfg.get("status_interval_sec", 30))
433 # Paper: consensus only in test/calibration mode; disabled in flight
434 self.consensus_enabled = bool(orch_cfg.get("consensus_enabled", True))
435 phase2 = load_config().get("phase2", {}) if _cfg_loaded else {}
436 env_cal = (
437 load_config().get("calibration", {}).get("environmental", {})
438 if _cfg_loaded
439 else {}
440 )
441 self.consensus_threshold = float(phase2.get("consensus_threshold_psi", 1.0))
442 self.min_consensus_sensors = int(env_cal.get("min_consensus_sensors", 2))
444 orch_cfg.get("self_cal_alpha_threshold", 0.6)
445 )
446 self.agreement_threshold = float(orch_cfg.get("agreement_threshold", 0.6))
447
448 # Latest predictions per channel for consensus (paper Section 6)
449 self.latest_predictions: Dict[Tuple[str, int], Tuple[float, float, float]] = {}
450
451 # Apply GLR threshold to per-channel frameworks
452 for rcf in self.robust.values():
453 rcf.glr_threshold = self.glr_threshold
454 rcf.forgetting_factor = forgetting
455
456 # ── State ───────────────────────────────────────────────────────
457 self.running = False
458 self.collecting = False
459 self.phase = "IDLE"
460 self.monitor_thread: Optional[threading.Thread] = None
461
462 # ── Pending active-learning alerts ──────────────────────────────
463 self.pending_alerts: List[CalibrationRequest] = []
464 self.pending_alerts_max = 50 # cap to prevent unbounded growth
465
466 # ── Actuator communication (optional) ────────────────────────────
467 self.actuator_comm = None
468 if ACTUATOR_COMM_AVAILABLE:
469 try:
470 from config_loader import get_board_by_type
471
472 actuator_board = get_board_by_type("ACTUATOR") if _cfg_loaded else None
473 if actuator_board and actuator_board.get("enabled", False):
474 actuator_ip = actuator_board.get("ip", "192.168.2.201")
475 actuator_port = actuator_board.get("port", 5005)
476 self.actuator_comm = ActuatorComm(actuator_ip, actuator_port)
477 logger.info(
478 f"✅ Actuator communication initialized: {actuator_ip}:{actuator_port}"
479 )
480 except Exception as e:
481 logger.warning(f"⚠️ Failed to initialize actuator comm: {e}")
482
483 # ── Stats ───────────────────────────────────────────────────────
484 self.stats = {
485 "phase1_points": 0,
486 "tls_fits": 0,
487 "bayesian_updates": 0,
488 "rls_updates": 0,
489 "drifts_detected": 0,
490 "recalibrations": 0,
491 "active_learning_alerts": 0,
492 "saves": 0,
493 "start_time": time.time(),
494 }
495
496 # ── Robustness manager (backup, validation, health) ────────────────
498 if ROBUSTNESS_AVAILABLE:
499 try:
500 rb_cfg = RobustnessConfig()
501 rb_cfg.model_order = N_PARAMS - 1 # 9 params → order 8
502 rb_cfg.num_sensors = total_channels
504 logger.info(
505 "🛡️ Robustness manager enabled (backup, validation, health)"
506 )
507 except Exception as e:
508 logger.warning(f"⚠️ Robustness manager init failed: {e}")
509
510 logger.info(
511 f"🔧 Orchestrator: {', '.join(self.sensor_types.keys())} "
512 f"({total_channels} channels, {N_PARAMS}-param env-robust basis)"
513 )
514
515 # ── UDP start / stop ─────────────────────────────────────────────────
516 def start_receiver(self):
517 self.receiver.start()
518 time.sleep(0.5)
519 if not self.receiver.connected:
520 logger.error("❌ UDP receiver failed to bind")
521 return False
522
523 # Start actuator communication if available
524 if self.actuator_comm:
525 if not self.actuator_comm.start():
526 logger.warning("⚠️ Actuator communication failed to start")
527
528 self.running = True
529 return True
530
531 def stop(self):
532 self.running = False
533 self.receiver.stop()
534 if self.actuator_comm:
535 self.actuator_comm.stop()
536 if self.monitor_thread and self.monitor_thread.is_alive():
537 self.monitor_thread.join(timeout=3)
538 logger.info("🛑 Orchestrator stopped")
539
540 # ── Queue drain (routes by source IP) ────────────────────────────────
541 def _drain_queue(self):
542 max_drain = 100 # Limit per call to prevent blocking
543 drained = 0
544 while self.receiver.sample_queue and drained < max_drain:
545 try:
546 src_ip, sid, signed, ts = self.receiver.sample_queue.popleft()
547 except (IndexError, ValueError):
548 break
549 drained += 1
550
551 stype = self.ip_to_type.get(src_ip)
552 if stype is None:
553 # Check if it's actuator board (for status parsing)
554 if self.actuator_comm and src_ip == self.actuator_comm.actuator_ip:
555 # Store raw data for actuator status parsing
556 if not hasattr(self, "_actuator_raw_samples"):
557 self._actuator_raw_samples = deque(maxlen=100)
558 self._actuator_raw_samples.append((sid, signed, ts))
559 continue
560
561 # FIX: sensor_id (sid) is 0-indexed from packet, channels are 1-indexed
562 channel_id = sid + 1
563 board_id = getattr(self, "ip_to_board_id", {}).get(src_ip, 1)
564 unique_ch = board_id * 100 + channel_id
565 key = (stype, unique_ch)
566
567 if key not in self.robust:
568 # Log first few misses to help debug
569 if self.stats.get("missed_channels", 0) < 5:
570 logger.warning(
571 f"⚠️ Unknown channel: {stype} CH{channel_id} (sid={sid})"
572 )
573 self.stats["missed_channels"] = (
574 self.stats.get("missed_channels", 0) + 1
575 )
576 continue
577 self.live_adc[key].append(signed)
578
579 # Phase 1: collect calibration points when user is collecting
580 # User reference is ground truth — validate then add
581 if self.collecting and key in self.references:
582 ref_val = self.references[key]
583 unc = self._adc_uncertainty(key)
584 # Robustness validation (optional)
585 if self.robustness_manager:
586 v_res = self.robustness_manager.validator.validate_pressure(
587 ref_val, key[1]
588 )
589 if not v_res:
590 self.robustness_manager.health_monitor.log_validation_error(
591 v_res
592 )
593 continue
594 pt = RobustCalPoint(
595 adc_code=float(signed), # Use raw ADC code directly
596 pressure=ref_val,
597 timestamp=ts,
598 environmental_state=self.env_state,
599 uncertainty=unc,
600 )
601 rcf = self.robust[key]
602 result = rcf.add_calibration_point(pt)
603 self.stats["phase1_points"] += 1
604
605 if result.get("drift_detected"):
606 self.stats["drifts_detected"] += 1
607
608 # Feed into autonomous engine
609 idx = self._key_to_idx[key]
610 phi = rcf.environmental_robust_basis_functions(
611 float(signed), self.env_state
612 )
613 self.engine.add_calibration_point(idx, phi, ref_val, pt.uncertainty)
614
615 # Phase 2: continuous RLS + drift monitoring
616 if self.phase == "MONITORING":
617 self._online_update(key, float(signed))
618
620 """Parse actuator current sense data from raw samples"""
621 if not hasattr(self, "_actuator_raw_samples") or not self._actuator_raw_samples:
622 return
623
624 # Process recent actuator samples
625 while self._actuator_raw_samples:
626 try:
627 sid, signed, ts = self._actuator_raw_samples.popleft()
628 # Convert ADC to voltage
629 voltage = (signed * 2.5) / 2147483648.0
630 # Update actuator status
631 if not hasattr(self, "actuator_statuses"):
633 self.actuator_statuses[sid] = {
634 "voltage": voltage,
635 "timestamp": ts,
636 "actuator_id": sid,
637 }
638 except (IndexError, ValueError):
639 break
640
642 """Print actuator current sense status"""
643 if not hasattr(self, "actuator_statuses") or not self.actuator_statuses:
644 print(" ⚠️ No actuator status data received yet")
645 return
646
647 print(f"\n {'─'*60}")
648 print(" Actuator Current Sense Status:")
649 for actuator_id in sorted(self.actuator_statuses.keys()):
650 status = self.actuator_statuses[actuator_id]
651 age = time.time() - status["timestamp"]
652 print(
653 f" Actuator {actuator_id}: {status['voltage']:.4f}V (age: {age:.1f}s)"
654 )
655 print(f" {'─'*60}\n")
656
657 def _adc_uncertainty(self, key) -> float:
658 """Compute measurement uncertainty from ADC noise."""
659 buf = self.live_adc.get(key)
660 if buf and len(buf) >= 10:
661 codes = list(buf)[-50:]
662 std_v = adc_to_voltage(int(np.std(codes)))
663 return max(abs(std_v), 0.001)
664 return 0.01 # default 1% of range
665
666 # ── Phase 2: online update per channel ───────────────────────────────
667 def _online_update(self, key: Tuple[str, int], adc_code: float):
668 """
669 Phase 2 per-sample update:
670 1. RLS update (if reference available)
671 2. Prediction + uncertainty for active-learning check
672 3. GLR drift detection → full Bayesian recal if triggered
673 4. Store for consensus (live_adc, latest_predictions)
674 """
675 self.live_adc[key].append(adc_code)
676 rcf = self.robust[key]
677 idx = self._key_to_idx[key]
678
679 # If a reference is available, do a proper RLS update
680 if key in self.references:
681 ref_val = self.references[key]
682 pt = RobustCalPoint(
683 adc_code=adc_code, # Use raw ADC code directly
684 pressure=ref_val,
685 timestamp=time.time(),
686 environmental_state=self.env_state,
687 uncertainty=self._adc_uncertainty(key),
688 )
689 result = rcf.add_calibration_point(pt)
690 self.stats["rls_updates"] += 1
691
692 if result.get("drift_detected"):
693 stype, ch = key
694 logger.warning(
695 f"⚠️ DRIFT on {stype} CH{ch} "
696 f"(GLR={result['glr_statistic']:.2f}) — full Bayesian recal"
697 )
698 self.stats["drifts_detected"] += 1
699 self.stats["recalibrations"] += 1
700
701 # Feed into engine
702 phi = rcf.environmental_robust_basis_functions(adc_code, self.env_state)
703 self.engine.add_calibration_point(idx, phi, ref_val, pt.uncertainty)
704
705 # RCF prediction for consensus and active learning
706 pred, sigma = rcf.predict_pressure_with_uncertainty(adc_code, self.env_state)
707 self.latest_predictions[key] = (pred, sigma, time.time())
708
709 phi = rcf.environmental_robust_basis_functions(adc_code, self.env_state)
710 _, _, alert = self.engine.predict(idx, phi, adc_code=adc_code)
711 if alert is not None:
712 self.stats["active_learning_alerts"] += 1
713 self.pending_alerts.append(alert)
714 if len(self.pending_alerts) > self.pending_alerts_max:
716 if len(self.pending_alerts) % 5 == 1:
717 stype, ch = key
718 logger.info(
719 f"📝 Active learning: {stype} CH{ch} needs recal — "
720 f"{alert.reason} (urgency={alert.urgency:.2f})"
721 )
722
723 # ══════════════════════════════════════════════════════════════════════
724 # Phase 1: Interactive Calibration
725 # ══════════════════════════════════════════════════════════════════════
726
727 def run_phase1(self):
728 self.phase = "CALIBRATION"
729 print(f"\n{'═'*72}")
730 print(" PHASE 1 — INTERACTIVE CALIBRATION (TLS + Bayesian)")
731 print(f"{'═'*72}")
732 print(" Commands:")
733 print(" ref <TYPE> <ch> <value> — set reference (e.g. ref PT 1 14.7)")
734 print(" ref <TYPE> <ch1-ch2> <val>— range ref (e.g. ref PT 1-4 14.7)")
735 print(" ref-all <TYPE> <value> — set all channels (e.g. ref-all PT 0.0)")
736 print(" collect [secs] — collect samples at current references")
737 print(" fit — run TLS + Bayesian fit for all channels")
738 print(" status — show calibration status")
739 print(" save — save calibrations + priors")
740 print(" live — show live ADC (Ctrl+C to stop)")
741 if self.actuator_comm:
742 print(
743 " actuator <id> <0|1> — send actuator command (test 2-way comm)"
744 )
745 print(" actuator-status — show actuator current sense readings")
746 print(" done — finish Phase 1, enter Phase 2")
747 print(" quit — save and exit")
748 print()
749 print(" NOTE: Channels WITHOUT references inherit the population prior from")
750 print(" calibrated channels (empirical Bayes). Calibrate a subset and")
751 print(" ALL channels benefit via hierarchical Bayesian transfer.")
752 print(f"{'─'*72}")
753 types_str = ", ".join(
754 f"{n}({len(i.connectors)}ch)" for n, i in self.sensor_types.items()
755 )
756 print(f" Sensors: {types_str}")
757 print(f" IP map: {self.ip_to_type}")
758 print(
759 f" Math: {N_PARAMS}-param env-robust basis, TLS, hierarchical Bayesian"
760 )
761 print(f"{'═'*72}\n")
762
763 while self.running:
764 try:
765 cmd = input(" [CAL] > ").strip()
766 except (EOFError, KeyboardInterrupt):
767 break
768 if not cmd:
769 continue
770 self._drain_queue()
771 parts = cmd.split()
772 verb = parts[0].lower()
773
774 if verb == "ref-all" and len(parts) >= 3:
775 stype = parts[1].upper()
776 val = float(parts[2])
777 if stype in self.sensor_types:
778 info = self.sensor_types[stype]
779 for ch in info.connectors:
780 self.references[(stype, ch)] = val
781 print(f" ✅ {stype} all → {val} {info.unit}")
782 else:
783 print(f" ⚠️ Unknown type: {stype}")
784
785 elif verb == "ref" and len(parts) >= 4:
786 stype = parts[1].upper()
787 ch_spec = parts[2]
788 val = float(parts[3])
789 u = self.sensor_types.get(
790 stype, SensorTypeInfo(stype, "?", "", None, 0, 0, Path(), True)
791 ).unit
792 # Support range: "ref PT 1-4 14.7"
793 if "-" in ch_spec:
794 lo, hi = ch_spec.split("-", 1)
795 for ch in range(int(lo), int(hi) + 1):
796 self.references[(stype, ch)] = val
797 print(f" ✅ {stype} CH{lo}-{hi} → {val} {u}")
798 else:
799 ch = int(ch_spec)
800 self.references[(stype, ch)] = val
801 print(f" ✅ {stype} CH{ch} → {val} {u}")
802
803 elif verb == "collect":
804 dur = float(parts[1]) if len(parts) > 1 else 5.0
805 self._collect(dur)
806
807 elif verb == "fit":
809 self._print_status()
810
811 elif verb == "status":
812 self._print_status()
813
814 elif verb == "save":
816 self._save_all()
817
818 elif verb == "live":
819 self._print_live()
820
821 elif verb == "done":
823 self._save_all()
824 print(" ➡️ Transitioning to Phase 2 (Monitoring)…")
825 break
826
827 elif verb in ("quit", "exit", "q"):
829 self._save_all()
830 self.running = False
831 return
832
833 else:
834 print(f" ⚠️ Unknown: {cmd}")
835
836 def _collect(self, dur: float):
837 if not self.references:
838 print(" ⚠️ No references set — nothing to collect")
839 return
840 refs = ", ".join(
841 f"{t} CH{c}={v}" for (t, c), v in sorted(self.references.items())
842 )
843 print(f" 📊 Collecting {dur:.1f}s at: {refs}")
844 self.collecting = True
845 t0 = time.time()
846 points_before = self.stats.get("phase1_points", 0)
847 try:
848 while time.time() - t0 < dur and self.running:
849 self._drain_queue()
850 time.sleep(0.01)
851 except KeyboardInterrupt:
852 print("\n ⚠️ Collection interrupted")
853 finally:
854 self.collecting = False
855 points_collected = self.stats.get("phase1_points", 0) - points_before
856 print(
857 f" ✅ Done — collected {points_collected} points ({self.stats.get('phase1_points', 0)} total)"
858 )
859
860 # ── TLS + Bayesian fitting ───────────────────────────────────────────
862 """Run TLS + Bayesian update for all channels with enough data."""
863 for key, rcf in self.robust.items():
864 pts = rcf.calibration_points
865 if len(pts) < self.min_points:
866 continue
867 # TLS fit
868 theta_tls, cov_tls = rcf.total_least_squares_calibration(pts)
869 self.stats["tls_fits"] += 1
870 # Full Bayesian update
871 theta_post, cov_post = rcf.bayesian_update(pts)
872 rcf.theta_mean = theta_post
873 rcf.theta_cov = cov_post
874 self.stats["bayesian_updates"] += 1
875
876 stype, ch = key
877 conf = rcf.get_confidence_level()
878 summary = rcf.get_calibration_summary()
879 rmse = summary.get("rmse", 0.0)
880 logger.info(
881 f"✅ {stype} CH{ch}: TLS+Bayesian "
882 f"RMSE={rmse:.4f} [{conf}] {len(pts)}pts"
883 )
884
885 # ══════════════════════════════════════════════════════════════════════
886 # Phase 2: Continuous Monitoring / Self-Recalibration
887 # ══════════════════════════════════════════════════════════════════════
888
889 def run_phase2(self):
890 self.phase = "MONITORING"
891 print(f"\n{'═'*72}")
892 print(" PHASE 2 — CONTINUOUS MONITORING / SELF-RECALIBRATION")
893 if self.actuator_comm:
894 print(" + Actuator communication enabled (2-way: commands + status)")
895 print(f"{'═'*72}")
896 print(" Math: RLS w/ forgetting → GLR drift → Bayesian recal")
897 print(" Empirical Bayes prior propagation across sensors")
898 print(" Active learning: system requests recal when needed")
899 print(f" Data: relay WebSocket (online updates via _online_update)")
900 print(f" Press Ctrl+C to stop. Status every {self._status_interval:.0f}s.")
901 print(f"{'═'*72}\n")
902
903 last_status = time.time()
904 last_save = time.time()
905
906 while self.running:
907 try:
908 # In sidecar mode the relay_subscriber_task calls _online_update() directly;
909 # _drain_queue() is only valid when the UDP receiver is started (CLI mode).
910 # Skip it here to avoid touching an unstarted receiver.
911 now = time.time()
912
913 if now - last_status > self._status_interval:
914 self._print_status()
915 self._print_alerts()
916 last_status = now
917 if now - last_save > self._save_interval:
918 self._save_all()
919 last_save = now
921 time.sleep(0.5)
922 except KeyboardInterrupt:
923 break
924 except Exception as e:
925 logger.error(f"Phase 2 error: {e}")
926 time.sleep(1)
927
929 self.phase = "MONITORING"
930 self.monitor_thread = threading.Thread(target=self.run_phase2, daemon=True)
931 self.monitor_thread.start()
932
933 def _print_alerts(self):
934 if not self.pending_alerts:
935 return
936 print(f" ⚡ Active Learning Alerts ({len(self.pending_alerts)}):")
937 # Show last 5
938 for alert in self.pending_alerts[-5:]:
939 key = self._idx_to_key.get(alert.sensor_id, ("?", "?"))
940 print(
941 f" {key[0]} CH{key[1]}: {alert.reason} "
942 f"(urgency={alert.urgency:.2f}, conf={alert.confidence:.2f})"
943 )
944 self.pending_alerts.clear()
945
946 # ── Clear calibration (start from scratch) ────────────────────────────
948 """Clear all references and per-channel calibration data. User can start fresh."""
949 self.references.clear()
950 for key, rcf in self.robust.items():
951 rcf.calibration_points.clear()
952 rcf.theta_mean = rcf.population_prior_mean.copy()
953 rcf.theta_cov = rcf.individual_prior_cov.copy()
954 rcf.rls_P = np.eye(rcf.n_params) * 100.0
955 rcf.bias_model.b = np.zeros(3)
956 rcf.bias_model.P_b = np.eye(3) * 0.01
957 rcf.inflation_factor = 1.0
958 logger.info("🗑️ Calibration cleared — references and points reset")
959
961 """Paper Section 6: consensus + agreement + self-cal when α≥0.6, agreement>0.6."""
962 if not self.consensus_enabled or self.phase != "MONITORING":
963 return
964
965 # Collect PT predictions (same stype for consensus)
966 pt_keys = [k for k in self.latest_predictions if k[0] == "PT"]
967 if len(pt_keys) < self.min_consensus_sensors:
968 return
969
970 now = time.time()
971 active = []
972 for key in pt_keys:
973 pred, sigma, ts = self.latest_predictions[key]
974 if now - ts > 2.0: # stale
975 continue
976 alpha, _ = self.robust[key].get_autonomy_score()
977 if alpha < 0.2:
978 continue
979 w = alpha / (sigma**2 + 1e-6)
980 active.append((key, pred, sigma, w))
981
982 if len(active) < self.min_consensus_sensors:
983 for key in pt_keys:
984 if key in self.robust:
985 self.robust[key].inflation_factor = 1.0
986 return
987
988 # Consensus pressure
989 w_sum = sum(w for _, _, _, w in active)
990 p_consensus = sum(p * w for _, p, _, w in active) / w_sum
991 sigma_consensus = 1.0 / np.sqrt(w_sum)
992
993 # Agreement score (paper Eq.)
994 agreement = 1.0
995 for i, (_, pi, si, _) in enumerate(active):
996 for j, (_, pj, sj, _) in enumerate(active):
997 if i >= j:
998 continue
999 d2 = (pi - pj) ** 2 / (si**2 + sj**2 + 1e-12)
1000 agreement *= np.exp(-d2 / (2 * len(active) * (len(active) - 1)))
1001
1002 if agreement < self.agreement_threshold:
1003 # Disagreement: inflate uncertainty (paper Section 7)
1004 inflate = 2.0 - agreement
1005 for key in pt_keys:
1006 if key in self.robust:
1007 self.robust[key].inflation_factor = inflate
1008 return
1009
1010 # Agreement: deflate uncertainty
1011 deflate = 0.5 + 0.5 * agreement
1012 for key in pt_keys:
1013 if key in self.robust:
1014 self.robust[key].inflation_factor = deflate
1015
1016 # Self-calibration for PTs with α≥0.6
1017 for key, pred, sigma, w in active:
1018 rcf = self.robust[key]
1019 alpha, _ = rcf.get_autonomy_score()
1020 if alpha < self.self_cal_alpha_threshold:
1021 continue
1022 buf = self.live_adc.get(key)
1023 if not buf or len(buf) < 3:
1024 continue
1025 mean_adc = float(np.mean(list(buf)))
1026 unc = sigma_consensus * (1 + (1 - alpha) / max(alpha, 0.01))
1027 pt = RobustCalPoint(
1028 adc_code=mean_adc,
1029 pressure=p_consensus,
1030 timestamp=now,
1031 environmental_state=self.env_state,
1032 uncertainty=unc,
1033 )
1034 rcf.add_calibration_point(pt)
1035 self.stats["phase1_points"] = self.stats.get("phase1_points", 0) + 1
1036 idx = self._key_to_idx.get(key)
1037 if idx is not None:
1038 phi = rcf.environmental_robust_basis_functions(mean_adc, self.env_state)
1039 self.engine.add_calibration_point(idx, phi, p_consensus, unc)
1040 logger.debug(
1041 f"Self-cal: {key} α={alpha:.2f} agreement={agreement:.2f} p={p_consensus:.1f}"
1042 )
1043
1044 # ── Zero-point propagation (paper Section 9) ───────────────────────────
1045 def propagate_zero_point(self, calibrated_key: Tuple[str, int], stype: str):
1046 """When human provides zero for one PT, add (v_k, 0, 0.01) for all other PTs of same type."""
1047 for key, rcf in self.robust.items():
1048 if key[0] != stype or key == calibrated_key:
1049 continue
1050 buf = self.live_adc.get(key)
1051 if buf and len(buf) >= 3:
1052 mean_adc = float(np.mean(list(buf)))
1053 pt = RobustCalPoint(
1054 adc_code=mean_adc,
1055 pressure=0.0,
1056 timestamp=time.time(),
1057 environmental_state=self.env_state,
1058 uncertainty=0.01,
1059 )
1060 rcf.add_calibration_point(pt)
1061 logger.info(
1062 f" Zero propagated: {key[0]} CH{key[1]} ← (adc={mean_adc:.0f}, 0 PSI)"
1063 )
1064
1065 # ── Save / Load ──────────────────────────────────────────────────────
1066 def _save_all(self):
1067 """Save calibration constants to JSON (and CSV) in config calibration path."""
1068 # Robustness backup before save
1069 if (
1071 and self.robustness_manager.backup_manager.should_backup()
1072 ):
1073 pop_prior = self.engine.export_learned_prior()
1074 pt_states = {}
1075 for key, rcf in self.robust.items():
1076 ch = key[1]
1077 pt_states[ch] = {
1078 "theta_mean": rcf.theta_mean.tolist(),
1079 "theta_cov": rcf.theta_cov.tolist(),
1080 "n_points": len(rcf.calibration_points),
1081 }
1082 self.robustness_manager.backup_manager.backup_calibration_state(
1083 population_prior={
1084 "population_mean": pop_prior.get("prior_mean", []),
1085 "population_covariance": pop_prior.get("prior_covariance", []),
1086 "effective_sample_size": pop_prior.get("effective_sample_size", 0),
1087 },
1088 pt_states=pt_states,
1089 metadata={"phase": self.phase, "stats": self.stats},
1090 )
1091
1092 for stype, info in self.sensor_types.items():
1093 calibrated = {}
1094 for key, rcf in self.robust.items():
1095 if key[0] != stype:
1096 continue
1097 ch = key[1]
1098 # Include channels that have enough calibration points OR have been
1099 # RLS-updated autonomously so the learned theta persists across restarts.
1100 has_points = len(rcf.calibration_points) >= self.min_points
1101 has_rls = getattr(rcf, "rls_updates", 0) > 0
1102 if has_points or has_rls:
1103 calibrated[ch] = rcf
1104 if not calibrated:
1105 continue
1106
1107 ts = datetime.now().strftime("%Y%m%d_%H%M%S")
1108
1109 # ── JSON (full Bayesian state) ──
1110 jp = info.calibration_dir / f"{stype.lower()}_calibration_{ts}.json"
1111 data = {
1112 "sensor_type": stype,
1113 "unit": info.unit,
1114 "framework": "robust_bayesian_tls",
1115 "n_params": N_PARAMS,
1116 "created": datetime.now().isoformat(),
1117 "phase": self.phase,
1118 "calibration_parameters": {},
1119 "quality": {},
1120 }
1121 for ch, rcf in sorted(calibrated.items()):
1122 summary = rcf.get_calibration_summary()
1123 data["calibration_parameters"][str(ch)] = {
1124 "theta_mean": rcf.theta_mean.tolist(),
1125 "theta_cov": rcf.theta_cov.tolist(),
1126 "rls_P": rcf.rls_P.tolist(),
1127 }
1128 data["quality"][str(ch)] = {
1129 "confidence": summary.get("confidence_level", "UNKNOWN"),
1130 "num_points": summary.get("calibration_points", 0),
1131 "rmse": summary.get("rmse", 0.0),
1132 "max_residual": summary.get("max_residual", 0.0),
1133 "forgetting_factor": rcf.forgetting_factor,
1134 }
1135
1136 # Legacy polynomial_coeffs [A,B,C,D] for psi = A*adc³ + B*adc² + C*adc + D
1137 # 9-param: p = θ₀ + θ₁*v + θ₂*v² + θ₃*v³ with v=adc/1e9 → A=θ₃/1e27, B=θ₂/1e18, C=θ₁/1e9, D=θ₀
1138 # Key by packet_ch so sense_conversions fallback can load
1139 key_to_packet = build_orchestrator_key_to_packet_ch()
1140 data["calibration_polynomials"] = {}
1141 for ch, rcf in sorted(calibrated.items()):
1142 t = rcf.theta_mean
1143 leg = [
1144 t[3] / 1e27 if len(t) > 3 else 0,
1145 t[2] / 1e18 if len(t) > 2 else 0,
1146 t[1] / 1e9 if len(t) > 1 else 0,
1147 t[0] if len(t) > 0 else 0,
1148 ]
1149 packet_ch = key_to_packet.get((stype, ch), ch)
1150 data["calibration_polynomials"][str(packet_ch)] = leg
1151
1152 with open(jp, "w") as f:
1153 json.dump(data, f, indent=2)
1154
1155 # ── CSV (DiabloAvionics compat — 4 coeffs per channel) ──
1156 cp = info.calibration_dir / f"{stype.lower()}_calibration_{ts}.csv"
1157 cols = ["Timestamp"]
1158 for ch in sorted(calibrated.keys()):
1159 for ci in range(N_PARAMS):
1160 cols.append(f"{stype}{ch} Param {ci}")
1161 row = [datetime.now().isoformat()]
1162 for ch in sorted(calibrated.keys()):
1163 theta = calibrated[ch].theta_mean
1164 for ci in range(N_PARAMS):
1165 row.append(f"{theta[ci]:.15e}")
1166 with open(cp, "w") as f:
1167 f.write(",".join(cols) + "\n")
1168 f.write(",".join(row) + "\n")
1169
1170 logger.info(f"💾 {stype}: {jp.name} ({len(calibrated)} channels)")
1171 self.stats["saves"] += 1
1172
1173 # ── Save learned population prior ──
1174 self._save_prior()
1175
1176 def _save_prior(self):
1177 """Save the learned population prior for warm-starting future sessions."""
1178 prior_path = Path(__file__).parent / "calibrations" / "learned_prior.json"
1179 prior_path.parent.mkdir(parents=True, exist_ok=True)
1180 try:
1181 prior_data = self.engine.export_learned_prior()
1182 prior_data["saved_at"] = datetime.now().isoformat()
1183 engine_status = self.engine.get_system_status()
1184 prior_data["system_status"] = {
1185 "total_calibrations": engine_status["total_calibrations"],
1186 "prior_confidence": engine_status["prior_confidence"],
1187 "average_sensor_quality": engine_status["average_sensor_quality"],
1188 "drift_rate": engine_status["drift_rate"],
1189 }
1190 with open(prior_path, "w") as f:
1191 json.dump(prior_data, f, indent=2)
1192 logger.info(
1193 f"💾 Prior saved: ESS={prior_data['effective_sample_size']:.1f}"
1194 )
1195 except Exception as e:
1196 logger.error(f"Save prior: {e}")
1197
1199 """
1200 Load polynomial calibration (calibration_poly_coeffs + adc_norm_min/scale)
1201 from calibration/ or calibration_backups/ and use as initial theta for RCFs.
1202 Fixes 150 PSI reading as 56.8 — the default prior theta[1]=200 has wrong slope.
1203 """
1204 if _cfg_loaded and not load_config().get("calibration", {}).get(
1205 "prior_from_polynomial", True
1206 ):
1207 return
1208 from config_loader import get_repo_root
1209
1210 repo = get_repo_root()
1211 search_dirs = [
1212 repo / "calibration",
1213 repo / "calibration_backups",
1214 repo / "scripts" / "calibration" / "calibrations",
1215 ]
1216 polys = {}
1217 adc_min = {}
1218 adc_scale = {}
1219 for cal_dir in search_dirs:
1220 if not cal_dir.is_dir():
1221 continue
1222 for fp in sorted(
1223 cal_dir.glob("*.json"), key=os.path.getmtime, reverse=True
1224 ):
1225 if "learned_prior" in fp.name:
1226 continue
1227 try:
1228 with open(fp) as f:
1229 data = json.load(f)
1230 p = data.get("calibration_poly_coeffs") or data.get(
1231 "calibration_polynomials"
1232 )
1233 m = data.get("calibration_adc_norm_min") or {}
1234 s = data.get("calibration_adc_norm_scale") or {}
1235 if p and (m or s):
1236 for k, v in p.items():
1237 if isinstance(v, (list, tuple)) and len(v) >= 2:
1238 polys[k] = list(v)
1239 if k in m:
1240 adc_min[k] = float(m[k])
1241 if k in s:
1242 adc_scale[k] = float(s[k])
1243 break
1244 except Exception:
1245 continue
1246 if polys:
1247 break
1248
1249 if not polys:
1250 return
1251
1252 # Map calibration key (101, 102, 1101) -> unique_ch (2101, 2102, 2201)
1253 def cal_key_to_unique_ch(k: str) -> Optional[int]:
1254 n = int(k)
1255 if 100 <= n <= 199:
1256 return 2100 + (n - 100)
1257 if 1100 <= n <= 1199:
1258 return 2200 + (n - 1100)
1259 return None
1260
1261 for ch_str, coeffs in polys.items():
1262 uch = cal_key_to_unique_ch(ch_str)
1263 if uch is None:
1264 continue
1265 key = ("PT", uch)
1266 if key not in self.robust:
1267 continue
1268 mn = adc_min.get(ch_str)
1269 sc = adc_scale.get(ch_str)
1270 if mn is None or sc is None or sc <= 0:
1271 continue
1272 self.robust[key].set_theta_from_polynomial(coeffs, mn, sc)
1273 if polys:
1274 logger.info(f"📐 Loaded polynomial priors for {len(polys)} PT channels")
1275
1276 def load_existing(self):
1277 """Load latest calibrations + learned prior for cold-start Phase 2."""
1279
1280 key_to_packet = build_orchestrator_key_to_packet_ch()
1281 packet_to_key = {}
1282 for (stype, uch), pch in key_to_packet.items():
1283 packet_to_key[(stype, pch)] = (stype, uch)
1284
1285 for stype, info in self.sensor_types.items():
1286 if not info.calibration_dir.is_dir():
1287 continue
1288 jsons = sorted(
1289 info.calibration_dir.glob(f"{stype.lower()}_calibration_*.json"),
1290 key=os.path.getmtime,
1291 )
1292 if not jsons:
1293 continue
1294 latest = jsons[-1]
1295 try:
1296 with open(latest) as f:
1297 data = json.load(f)
1298 params = data.get("calibration_parameters", {})
1299 polys = data.get("calibration_polynomials", {})
1300
1301 for ch_str, pdata in params.items():
1302 ch = int(ch_str)
1303 key = (stype, ch)
1304 if key not in self.robust:
1305 continue
1306 rcf = self.robust[key]
1307 theta = np.array(pdata["theta_mean"])
1308 cov = np.array(pdata["theta_cov"])
1309 if len(theta) == 6:
1310 theta = np.concatenate([theta, [0.0, 0.0, 0.0]])
1311 cov = np.pad(cov, ((0, 3), (0, 3)), constant_values=0.1)
1312 rcf.theta_mean = theta
1313 rcf.theta_cov = cov
1314 if "rls_P" in pdata:
1315 rls_P = np.array(pdata["rls_P"])
1316 if rls_P.shape[0] == 6:
1317 rls_P = np.pad(
1318 rls_P, ((0, 3), (0, 3)), constant_values=100.0
1319 )
1320 rcf.rls_P = rls_P
1321
1322 # Fallback: legacy polynomial-only → theta_mean
1323 if not params and polys and stype == "PT":
1324 for pch_str, coeffs in polys.items():
1325 if len(coeffs) < 4:
1326 continue
1327 key = packet_to_key.get((stype, int(pch_str)))
1328 if key is None:
1329 continue
1330 if key not in self.robust:
1331 continue
1332 A, B, C, D = coeffs[0], coeffs[1], coeffs[2], coeffs[3]
1333 theta = np.array(
1334 [
1335 D,
1336 C * 1e9,
1337 B * 1e18,
1338 A * 1e27,
1339 0.0,
1340 0.0,
1341 0.0,
1342 0.0,
1343 0.0,
1344 ]
1345 )
1346 self.robust[key].theta_mean = theta
1347 self.robust[key].theta_cov = np.eye(9) * 0.1
1348
1349 n = len(params) or (len(polys) if stype == "PT" else 0)
1350 if n:
1351 logger.info(f"📂 Loaded {stype}: {latest.name} ({n} channels)")
1352 except Exception as e:
1353 logger.error(f"Load {latest}: {e}")
1354
1355 # Load learned prior
1356 prior_path = Path(__file__).parent / "calibrations" / "learned_prior.json"
1357 if prior_path.exists():
1358 try:
1359 with open(prior_path) as f:
1360 prior_data = json.load(f)
1361 self.engine.import_learned_prior(prior_data)
1362 logger.info(
1363 f"📂 Loaded prior: ESS={prior_data.get('effective_sample_size', '?')}"
1364 )
1365 except Exception as e:
1366 logger.error(f"Load prior: {e}")
1367
1368 # Load auto-saved RLS state from adjustments.json (framework_v2 format).
1369 # This overrides the timestamped file load above with the most recent
1370 # autonomous calibration state (written every ~2 min by calibration_server).
1371 adj_path = Path(__file__).parent / "calibrations" / "adjustments.json"
1372 if adj_path.exists():
1373 try:
1374 with open(adj_path) as f:
1375 adj_data = json.load(f)
1376 fw2 = adj_data.get("framework_v2", {})
1377 loaded_adj = 0
1378 for unique_ch_str, pdata in fw2.items():
1379 theta = pdata.get("theta_mean")
1380 cov = pdata.get("theta_cov")
1381 if not theta or not cov:
1382 continue
1383 key = ("PT", int(unique_ch_str))
1384 if key not in self.robust:
1385 continue
1386 t = np.array(theta)
1387 c = np.array(cov)
1388 if len(t) == 6:
1389 t = np.concatenate([t, [0.0, 0.0, 0.0]])
1390 c = np.pad(c, ((0, 3), (0, 3)), constant_values=0.1)
1391 if t.shape[0] == 9:
1392 self.robust[key].theta_mean = t
1393 if c.shape == (9, 9):
1394 self.robust[key].theta_cov = c
1395 loaded_adj += 1
1396 if loaded_adj:
1397 logger.info(
1398 f"📂 Loaded adjustments.json: {loaded_adj} PT channels "
1399 f"(saved {adj_data.get('auto_saved_at', '?')})"
1400 )
1401 except Exception as e:
1402 logger.error(f"Load adjustments.json: {e}")
1403
1404 # ── Status ───────────────────────────────────────────────────────────
1405 def _print_status(self):
1406 print(f"\n{'═'*78}")
1407 print(f" Calibration Orchestrator — Phase: {self.phase}")
1408 uptime = time.time() - self.stats["start_time"]
1409 print(
1410 f" Uptime: {uptime/60:.1f}m UDP: {self.receiver.stats['packets']} pkts / "
1411 f"{self.receiver.stats['samples']} samples"
1412 )
1413 print(
1414 f" Cal pts: {self.stats['phase1_points']} "
1415 f"TLS: {self.stats['tls_fits']} "
1416 f"Bayes: {self.stats['bayesian_updates']} "
1417 f"RLS: {self.stats['rls_updates']} "
1418 f"Drifts: {self.stats['drifts_detected']} "
1419 f"Alerts: {self.stats['active_learning_alerts']}"
1420 )
1421
1422 # Engine status
1423 eng = self.engine.get_system_status()
1424 print(
1425 f" Engine: prior_conf={eng['prior_confidence']:.3f} "
1426 f"avg_quality={eng['average_sensor_quality']:.3f} "
1427 f"drift_rate={eng['drift_rate']:.2e} "
1428 f"ESS={eng['effective_sample_size']:.1f}"
1429 )
1430
1431 print(f"{'─'*78}")
1432 for stype, info in self.sensor_types.items():
1433 print(
1434 f" {stype} ({info.unit}) board={info.board_ip} ch={len(info.connectors)}"
1435 )
1436 for ch in info.connectors:
1437 key = (stype, ch)
1438 rcf = self.robust[key]
1439 buf = self.live_adc.get(key)
1440 adc_str = ""
1441 if buf and len(buf):
1442 mean_adc = int(np.mean(list(buf)))
1443 mean_v = adc_to_voltage(mean_adc)
1444 adc_str = f"V={mean_v:>8.5f}"
1445 else:
1446 adc_str = "V=---"
1447
1448 ref = self.references.get(key)
1449 ref_str = f" ref={ref:.2f}" if ref is not None else ""
1450
1451 npts = len(rcf.calibration_points)
1452 conf = rcf.get_confidence_level()
1453 summary = rcf.get_calibration_summary()
1454 rmse = summary.get("rmse", 0.0)
1455
1456 # Prediction at current ADC code (works for both direct + prior-only)
1457 pred_str = ""
1458 if buf and len(buf):
1459 mean_adc_code = int(np.mean(list(buf)))
1460 pred, sigma = rcf.predict_pressure_with_uncertainty(
1461 float(mean_adc_code), self.env_state
1462 )
1463 pred_str = f" →{pred:>8.2f}±{sigma:.2f}"
1464
1465 # Show calibration source: DIRECT (has data) vs PRIOR (population)
1466 src = "DIRECT" if npts > 0 else "PRIOR "
1467
1468 print(
1469 f" CH{ch:>2d}: {src} {npts:>3d}pts [{conf:<7s}] "
1470 f"RMSE={rmse:<8.4f} {adc_str}{pred_str}{ref_str}"
1471 )
1472 print(f"{'═'*78}\n")
1473
1474 def _print_live(self):
1475 print(" (Ctrl+C to stop)")
1476 try:
1477 while True:
1478 self._drain_queue()
1479 parts = []
1480 for key in sorted(self.live_adc.keys()):
1481 buf = self.live_adc[key]
1482 if len(buf):
1483 stype, ch = key
1484 v = adc_to_voltage(int(np.mean(list(buf))))
1485 parts.append(f"{stype}{ch}:{v:>8.5f}V")
1486 print(
1487 f"\r [{self.receiver.stats['packets']:>6d}] " + " ".join(parts),
1488 end="",
1489 flush=True,
1490 )
1491 time.sleep(0.25)
1492 except KeyboardInterrupt:
1493 print()
1494
1495
1496# ═════════════════════════════════════════════════════════════════════════
1497# MAIN
1498# ═════════════════════════════════════════════════════════════════════════
1499
1500
1501def main():
1502 import argparse
1503
1504 parser = argparse.ArgumentParser(
1505 description="Unified Calibration Orchestrator — Robust Bayesian + TLS",
1506 formatter_class=argparse.RawDescriptionHelpFormatter,
1507 epilog="""
1508Mathematical Framework (from the paper):
1509 Phase 1: TLS + Bayesian hierarchical regression with environmental basis
1510 Phase 2: RLS with forgetting → GLR drift detection → Bayesian recal
1511 Empirical Bayes prior evolution → Active learning alerts
1512
1513Examples:
1514 python3 calibration_orchestrator.py # Full lifecycle
1515 python3 calibration_orchestrator.py --skip-phase1 # Monitor only
1516 python3 calibration_orchestrator.py --phase1-only # Calibrate & exit
1517 python3 calibration_orchestrator.py --sensors PT TC # Specific types
1518""",
1519 )
1520 parser.add_argument(
1521 "--sensors",
1522 nargs="+",
1523 default=None,
1524 help="Sensor types to include (default: all enabled)",
1525 )
1526 parser.add_argument(
1527 "--skip-phase1",
1528 action="store_true",
1529 help="Skip Phase 1, load existing calibrations, go to monitoring",
1530 )
1531 parser.add_argument(
1532 "--phase1-only",
1533 action="store_true",
1534 help="Run Phase 1 only (calibrate and exit)",
1535 )
1536 parser.add_argument(
1537 "--port",
1538 type=int,
1539 default=None,
1540 help="UDP port override (default: from config.toml)",
1541 )
1542 args = parser.parse_args()
1543
1544 sensor_names = [s.upper() for s in args.sensors] if args.sensors else None
1545 orch = CalibrationOrchestrator(sensor_names)
1546
1547 if args.port:
1548 orch.receiver = UDPSensorReceiver(args.port)
1549
1550 if not orch.start_receiver():
1551 return 1
1552
1553 def shutdown(*_):
1554 print("\n🛑 Shutting down…")
1555 orch._save_all()
1556 orch._print_status()
1557 orch.stop()
1558 sys.exit(0)
1559
1560 signal.signal(signal.SIGINT, shutdown)
1561 signal.signal(signal.SIGTERM, shutdown)
1562
1563 # Phase 1
1564 if not args.skip_phase1:
1565 orch.run_phase1()
1566 if not orch.running:
1567 return 0
1568 else:
1569 orch.load_existing()
1570
1571 # Phase 2
1572 if not args.phase1_only and orch.running:
1573 orch.run_phase2()
1574
1575 orch._save_all()
1576 orch._print_status()
1577 orch.stop()
1578 return 0
1579
1580
1581if __name__ == "__main__":
1582 sys.exit(main())
#define max(a, b)
Definition Arduino.h:43
_online_update(self, Tuple[str, int] key, float adc_code)
propagate_zero_point(self, Tuple[str, int] calibrated_key, str stype)
__init__(self, Optional[List[str]] sensor_names=None)
static void start(gnss_frame_parser_t *p, uint8_t byte)
Definition gnss_frame.c:45
Dict[str, SensorTypeInfo] _build_sensor_types()
float adc_to_voltage(int adc_signed)
Definition run.py:1
TOML_NODISCARD_CTOR time() noexcept=default