STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
autonomous_tc_calibration.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Autonomous Thermocouple (TC) Calibration System
4Reads raw ADC data from TC boards via UDP, collects calibration points at known
5reference temperatures, fits polynomials, and saves coefficients for the C++ DAQ bridge.
6
7All board IPs, ports, and sensor counts come from config/config.toml.
8
9Calibration model: temp_°C = A·adc³ + B·adc² + C·adc + D
10
11Usage:
12 python3 autonomous_tc_calibration.py --interactive
13 python3 autonomous_tc_calibration.py --ref-all 25.0 --collect-time 10
14 python3 autonomous_tc_calibration.py --sensor 1 --temp 100.0 --sensor 2 --temp 200.0
15"""
16
17import sys, os, time, json, socket, struct, signal, threading, logging
18import numpy as np
19from pathlib import Path
20from typing import Dict, List, Optional, Tuple
21from dataclasses import dataclass
22from collections import deque, defaultdict
23from datetime import datetime
24
25logging.basicConfig(
26 level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
27)
28logger = logging.getLogger(__name__)
29
30# ── Config-driven defaults ───────────────────────────────────────────────
31try:
32 from config_loader import (
33 load_config,
34 get_board_by_type,
35 get_sensor_port,
36 get_calibration_config,
37 resolve_path,
38 )
39
40 _cfg_loaded = True
41except ImportError:
42 _cfg_loaded = False
43
44
46 return get_sensor_port() if _cfg_loaded else 5006
47
48
50 if _cfg_loaded:
51 b = get_board_by_type("TC")
52 if b:
53 return b.get("ip")
54 return "192.168.2.103"
55
56
58 """The channels this board samples, from active_connectors.
59
60 That list is the config's only statement of which channels exist, and it need
61 not be contiguous — a board may declare {1, 2, 6} — so callers iterate it
62 rather than counting it.
63 """
64 if _cfg_loaded:
65 b = get_board_by_type("TC")
66 if b:
67 return [int(c) for c in b.get("active_connectors", []) or []]
68 return list(range(1, 10 + 1))
69
70
72 if _cfg_loaded:
73 cc = get_calibration_config("tc")
74 jd = cc.get("json_dir")
75 if jd:
76 return resolve_path(jd)
77 return Path(__file__).parent / "calibrations" / "tc"
78
79
80# ── DiabloAvionics packet constants ─────────────────────────────────────
81PACKET_HEADER_FORMAT = "<BBI"
82PACKET_HEADER_SIZE = 6
83SENSOR_DATA_PACKET_FORMAT = "<BB"
84SENSOR_DATA_PACKET_SIZE = 2
85SENSOR_DATA_CHUNK_FORMAT = "<I"
86SENSOR_DATA_CHUNK_SIZE = 4
87SENSOR_DATAPOINT_FORMAT = "<BI"
88SENSOR_DATAPOINT_SIZE = 5
89PACKET_TYPE_SENSOR_DATA = 3
90MAX_PACKET_SIZE = 512
91
92CALIBRATION_DIR = _default_calibration_dir()
93CALIBRATION_DIR.mkdir(parents=True, exist_ok=True)
94
95
96@dataclass
98 sensor_id: int
99 adc_code: int
100 reference_temp_c: float
101 timestamp: float
102 quality_score: float = 1.0
103
104
105@dataclass
107 sensor_id: int
108 calibration_points: List[CalibrationPoint]
109 polynomial_coeffs: Optional[tuple] = None
110 r_squared: float = 0.0
111 confidence_level: str = "LOW"
112 last_updated: float = 0.0
113 is_calibrated: bool = False
114
115
117 """Autonomous thermocouple calibration via direct UDP board packets."""
118
119 def __init__(self, udp_port=None, board_ip=None):
120 if udp_port is None:
121 udp_port = _default_port()
122 if board_ip is None:
123 board_ip = _default_board_ip()
124 self.udp_port = udp_port
125 self.board_ip = board_ip
126 self.udp_connected = False
127 self.sensor_states: Dict[int, SensorCalibrationState] = {}
128 self.reference_temps: Dict[int, float] = {}
129 self.live_adc: Dict[int, deque] = defaultdict(lambda: deque(maxlen=200))
133 self.min_r_squared = 0.95
135 self.running = False
136 self.collecting = False
137 self.data_thread = None
138 self.stats = {
139 "packets_received": 0,
140 "messages_received": 0,
141 "calibration_points_collected": 0,
142 "calibrations_completed": 0,
143 "start_time": time.time(),
144 }
145 logger.info("🌡️ Autonomous TC Calibrator initialized")
146
147 @staticmethod
149 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
150 return None
151 pt, _, _ = struct.unpack(PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE])
152 if pt != PACKET_TYPE_SENSOR_DATA:
153 return None
154 off = PACKET_HEADER_SIZE
155 nc, ns = struct.unpack(
156 SENSOR_DATA_PACKET_FORMAT, data[off : off + SENSOR_DATA_PACKET_SIZE]
157 )
158 off += SENSOR_DATA_PACKET_SIZE
159 per = SENSOR_DATA_CHUNK_SIZE + ns * SENSOR_DATAPOINT_SIZE
160 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + nc * per:
161 return None
162 samples = []
163 for _ in range(nc):
164 off += SENSOR_DATA_CHUNK_SIZE
165 for _ in range(ns):
166 sid, raw = struct.unpack(
167 SENSOR_DATAPOINT_FORMAT, data[off : off + SENSOR_DATAPOINT_SIZE]
168 )
169 off += SENSOR_DATAPOINT_SIZE
170 signed = raw if raw < 0x80000000 else raw - 0x100000000
171 samples.append((sid, signed, raw))
172 return samples
173
174 def set_reference_temp(self, ch, temp_c):
175 self.reference_temps[ch] = temp_c
176 logger.info(f"🌡️ CH{ch} → {temp_c:.1f} °C")
177
178 def set_reference_all(self, temp_c, n=None):
179 channels = _default_connectors() if n is None else list(range(1, n + 1))
180 for ch in channels:
181 self.reference_temps[ch] = temp_c
182 logger.info(f"🌡️ TC channels {channels} → {temp_c:.1f} °C")
183
184 def start(self):
185 self.running = True
186 self.data_thread = threading.Thread(target=self._listen, daemon=True)
187 self.data_thread.start()
188 return True
189
190 def stop(self):
191 self.running = False
192 if self.data_thread:
193 self.data_thread.join(timeout=2)
194
195 def collect_samples(self, dur=5.0):
196 if not self.reference_temps:
197 logger.warning("No reference temps set")
198 return
199 logger.info(f"📊 Collecting TC data for {dur:.1f}s")
200 self.collecting = True
201 time.sleep(dur)
202 self.collecting = False
203 logger.info(f"✅ {self.stats['calibration_points_collected']} total points")
204
205 def _listen(self):
206 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
207 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
208 sock.settimeout(0.2)
209 try:
210 sock.bind(("0.0.0.0", self.udp_port))
211 self.udp_connected = True
212 logger.info(f"📡 TC listening UDP :{self.udp_port}")
213 except OSError as e:
214 logger.error(f"Bind failed: {e}")
215 return
216 while self.running:
217 try:
218 data, addr = sock.recvfrom(MAX_PACKET_SIZE)
219 except socket.timeout:
220 continue
221 except Exception as e:
222 if self.running:
223 logger.error(f"recv: {e}")
224 continue
225 if self.board_ip and addr[0] != self.board_ip:
226 continue
227 samples = self._parse_sensor_packet(data)
228 if not samples:
229 continue
230 self.stats["packets_received"] += 1
231 for sid, signed, raw in samples:
232 self.stats["messages_received"] += 1
233 self.live_adc[sid].append(signed)
234 if self.collecting and sid in self.reference_temps:
235 pt = CalibrationPoint(
236 sid, signed, self.reference_temps[sid], time.time()
237 )
238 self._add_point(pt)
239 sock.close()
240 self.udp_connected = False
241
242 def _add_point(self, pt):
243 if pt.sensor_id not in self.sensor_states:
244 self.sensor_states[pt.sensor_id] = SensorCalibrationState(pt.sensor_id, [])
245 s = self.sensor_states[pt.sensor_id]
246 s.calibration_points.append(pt)
247 self.stats["calibration_points_collected"] += 1
248 if len(s.calibration_points) > self.max_calibration_points:
249 s.calibration_points = s.calibration_points[-self.max_calibration_points :]
250
251 def auto_fit(self):
252 for sid, s in self.sensor_states.items():
253 if len(s.calibration_points) < self.min_calibration_points:
254 continue
255 adc = np.array([p.adc_code for p in s.calibration_points], dtype=np.float64)
256 ref = np.array(
257 [p.reference_temp_c for p in s.calibration_points], dtype=np.float64
258 )
259 if np.std(ref) < 1e-9:
260 s.polynomial_coeffs = (0.0, 0.0, 0.0, float(np.mean(ref)))
261 s.r_squared = 0.0
262 s.is_calibrated = True
263 continue
264 try:
265 c = np.polyfit(adc, ref, min(3, len(s.calibration_points) - 1))
266 while len(c) < 4:
267 c = np.insert(c, 0, 0.0)
268 s.polynomial_coeffs = tuple(c)
269 pred = np.polyval(c, adc)
270 ss_r = np.sum((ref - pred) ** 2)
271 ss_t = np.sum((ref - np.mean(ref)) ** 2)
272 s.r_squared = 1 - ss_r / ss_t if ss_t > 0 else 0
273 s.last_updated = time.time()
274 if (
275 s.r_squared >= self.target_r_squared
276 and len(s.calibration_points) >= self.target_calibration_points
277 ):
278 s.confidence_level = "MAXIMUM"
279 elif s.r_squared >= self.min_r_squared:
280 s.confidence_level = "HIGH"
281 else:
282 s.confidence_level = "MEDIUM"
283 if not s.is_calibrated:
284 s.is_calibrated = True
285 self.stats["calibrations_completed"] += 1
286 except Exception as e:
287 logger.error(f"Fit failed CH{sid}: {e}")
288
290 cal = {sid: s for sid, s in self.sensor_states.items() if s.polynomial_coeffs}
291 if not cal:
292 return None
293 ts = datetime.now().strftime("%Y%m%d_%H%M%S")
294 jp = CALIBRATION_DIR / f"tc_calibration_{ts}.json"
295 data = {
296 "sensor_type": "TC",
297 "unit": "°C",
298 "polynomial_order": 3,
299 "created": datetime.now().isoformat(),
300 "calibration_polynomials": {},
301 "quality": {},
302 }
303 for sid, s in sorted(cal.items()):
304 data["calibration_polynomials"][str(sid)] = list(s.polynomial_coeffs)
305 data["quality"][str(sid)] = {
306 "r_squared": s.r_squared,
307 "confidence_level": s.confidence_level,
308 "num_points": len(s.calibration_points),
309 }
310 with open(jp, "w") as f:
311 json.dump(data, f, indent=2)
312 logger.info(f"💾 {jp}")
313 return jp
314
315 def print_status(self):
316 print(f"\n{'═'*70}")
317 print("🌡️ Autonomous TC Calibration Status")
318 print(f"{'═'*70}")
319 print(f"UDP: {'✅ :'+str(self.udp_port) if self.udp_connected else '❌'}")
320 print(
321 f"Pkts: {self.stats['packets_received']} Samples: {self.stats['messages_received']} Points: {self.stats['calibration_points_collected']}"
322 )
323 for ch in sorted(
324 set(list(self.sensor_states.keys()) + list(self.live_adc.keys()))
325 ):
326 s = self.sensor_states.get(ch)
327 buf = self.live_adc.get(ch)
328 adc_str = (
329 f"ADC={int(np.mean(list(buf))):>10d}" if buf and len(buf) else "ADC=---"
330 )
331 ref = self.reference_temps.get(ch)
332 ref_str = f" ref={ref:.1f}°C" if ref is not None else ""
333 if s:
334 cal = (
335 f"R²={s.r_squared:.4f} [{s.confidence_level}]"
336 if s.is_calibrated
337 else "⏳"
338 )
339 print(
340 f" CH{ch:>2d}: {len(s.calibration_points):>3d} pts {cal} {adc_str}{ref_str}"
341 )
342 else:
343 print(f" CH{ch:>2d}: 0 pts --- {adc_str}{ref_str}")
344 print(f"{'═'*70}\n")
345
346
347def main():
348 import argparse
349
350 parser = argparse.ArgumentParser(description="Autonomous TC Calibration (UDP)")
351 parser.add_argument(
352 "--port", type=int, default=None, help="UDP port (default: from config.toml)"
353 )
354 parser.add_argument(
355 "--board-ip", default=None, help="Board IP filter (default: from config.toml)"
356 )
357 parser.add_argument("--sensor", type=int, action="append")
358 parser.add_argument("--temp", type=float, action="append")
359 parser.add_argument("--ref-all", type=float, default=None)
360 parser.add_argument("--collect-time", type=float, default=5.0)
361 parser.add_argument("--interactive", "-i", action="store_true")
362 args = parser.parse_args()
363
364 cal = AutonomousTCCalibrator(args.port, args.board_ip)
365 if args.ref_all is not None:
366 cal.set_reference_all(args.ref_all)
367 if args.sensor and args.temp:
368 for s, t in zip(args.sensor, args.temp):
369 cal.set_reference_temp(s, t)
370 cal.start()
371 time.sleep(1)
372
373 def shutdown(*_):
374 cal.stop()
375 cal.auto_fit()
376 cal.save_calibrations()
377 cal.print_status()
378 sys.exit(0)
379
380 signal.signal(signal.SIGINT, shutdown)
381
382 if args.interactive:
383 print(
384 "Commands: ref <ch> <°C> | ref-all <°C> | collect [s] | fit | save | status | live | quit"
385 )
386 while True:
387 try:
388 cmd = input(" [TC] > ").strip()
389 except EOFError:
390 break
391 if not cmd:
392 continue
393 p = cmd.split()
394 v = p[0].lower()
395 if v == "ref-all" and len(p) >= 2:
396 cal.set_reference_all(float(p[1]))
397 elif v == "ref" and len(p) >= 3:
398 cal.set_reference_temp(int(p[1]), float(p[2]))
399 elif v == "collect":
400 cal.collect_samples(float(p[1]) if len(p) > 1 else args.collect_time)
401 elif v == "fit":
402 cal.auto_fit()
403 cal.print_status()
404 elif v == "save":
405 cal.auto_fit()
406 cal.save_calibrations()
407 elif v == "status":
408 cal.print_status()
409 elif v in ("quit", "exit", "q"):
410 shutdown()
411 else:
412 print(f" ⚠️ Unknown: {cmd}")
413 else:
414 if cal.reference_temps:
415 cal.collect_samples(args.collect_time)
416 cal.auto_fit()
417 cal.save_calibrations()
418 cal.print_status()
419 cal.stop()
420 else:
421 print("No refs set — showing live (Ctrl+C to stop)")
422 while True:
423 cal.print_status()
424 time.sleep(10)
425
426
427if __name__ == "__main__":
428 sys.exit(main())
#define min(a, b)
Definition Arduino.h:42
TOML_NODISCARD_CTOR time() noexcept=default