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