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.
7All board IPs, ports, and sensor counts come from config/config.toml.
9Polynomial model: temp_°C = A·counts³ + B·counts² + C·counts + D
12 python3 autonomous_rtd_calibration.py --interactive
13 python3 autonomous_rtd_calibration.py --ref-all 25.0 --collect-time 10
16import sys, os, time, json, socket, struct, signal, threading, logging
18from pathlib
import Path
19from typing
import Dict, List, Optional
20from dataclasses
import dataclass
21from collections
import deque, defaultdict
22from datetime
import datetime
25 level=logging.INFO, format=
"%(asctime)s - %(levelname)s - %(message)s"
27logger = logging.getLogger(__name__)
31 from config_loader
import (
35 get_calibration_config,
45 return get_sensor_port()
if _cfg_loaded
else 5006
50 b = get_board_by_type(
"RTD")
53 return "192.168.2.104"
57 """The channels this board samples, from active_connectors.
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.
64 b = get_board_by_type(
"RTD")
66 return [int(c)
for c
in b.get(
"active_connectors", [])
or []]
67 return list(range(1, 4 + 1))
72 cc = get_calibration_config(
"rtd")
73 jd = cc.get(
"json_dir")
75 return resolve_path(jd)
76 return Path(__file__).parent /
"calibrations" /
"rtd"
80PACKET_HEADER_FORMAT =
"<BBI"
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
92CALIBRATION_DIR.mkdir(parents=
True, exist_ok=
True)
98 resistance_counts: int
99 reference_temp_c: float
101 quality_score: float = 1.0
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
116 """Autonomous RTD calibration via direct UDP board packets."""
128 self.
live_adc: Dict[int, deque] = defaultdict(
lambda: deque(maxlen=200))
138 "packets_received": 0,
139 "messages_received": 0,
140 "calibration_points_collected": 0,
141 "calibrations_completed": 0,
144 logger.info(
"🌡️ Autonomous RTD Calibrator initialized")
148 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
150 pt, _, _ = struct.unpack(PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE])
151 if pt != PACKET_TYPE_SENSOR_DATA:
153 off = PACKET_HEADER_SIZE
154 nc, ns = struct.unpack(
155 SENSOR_DATA_PACKET_FORMAT, data[off : off + SENSOR_DATA_PACKET_SIZE]
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:
163 off += SENSOR_DATA_CHUNK_SIZE
165 sid, raw = struct.unpack(
166 SENSOR_DATAPOINT_FORMAT, data[off : off + SENSOR_DATAPOINT_SIZE]
168 off += SENSOR_DATAPOINT_SIZE
169 signed = raw
if raw < 0x80000000
else raw - 0x100000000
170 samples.append((sid, signed, raw))
175 logger.info(f
"🌡️ RTD CH{ch} → {temp_c:.1f} °C")
181 logger.info(f
"🌡️ RTD channels {channels} → {temp_c:.1f} °C")
196 logger.warning(
"No reference temps set")
198 logger.info(f
"📊 Collecting RTD data for {dur:.1f}s")
202 logger.info(f
"✅ {self.stats['calibration_points_collected']} total points")
205 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
206 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
209 sock.bind((
"0.0.0.0", self.
udp_port))
211 logger.info(f
"📡 RTD listening UDP :{self.udp_port}")
213 logger.error(f
"Bind failed: {e}")
217 data, addr = sock.recvfrom(MAX_PACKET_SIZE)
218 except socket.timeout:
220 except Exception
as e:
222 logger.error(f
"recv: {e}")
229 self.
stats[
"packets_received"] += 1
230 for sid, signed, raw
in samples:
231 self.
stats[
"messages_received"] += 1
245 s.calibration_points.append(pt)
246 self.
stats[
"calibration_points_collected"] += 1
255 [p.resistance_counts
for p
in s.calibration_points], dtype=np.float64
258 [p.reference_temp_c
for p
in s.calibration_points], dtype=np.float64
260 if np.std(ref) < 1e-9:
261 s.polynomial_coeffs = (0.0, 0.0, 0.0, float(np.mean(ref)))
263 s.is_calibrated =
True
266 c = np.polyfit(counts, ref,
min(3, len(s.calibration_points) - 1))
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
279 s.confidence_level =
"MAXIMUM"
281 s.confidence_level =
"HIGH"
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}")
291 cal = {sid: s
for sid, s
in self.
sensor_states.items()
if s.polynomial_coeffs}
294 ts = datetime.now().strftime(
"%Y%m%d_%H%M%S")
295 jp = CALIBRATION_DIR / f
"rtd_calibration_{ts}.json"
297 "sensor_type":
"RTD",
299 "polynomial_order": 3,
300 "created": datetime.now().isoformat(),
301 "calibration_polynomials": {},
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),
311 with open(jp,
"w")
as f:
312 json.dump(data, f, indent=2)
313 logger.info(f
"💾 {jp}")
318 print(
"🌡️ Autonomous RTD Calibration Status")
320 print(f
"UDP: {'✅ :'+str(self.udp_port) if self.udp_connected else '❌'}")
322 f
"Pkts: {self.stats['packets_received']} Samples: {self.stats['messages_received']} Points: {self.stats['calibration_points_collected']}"
330 f
"R_cnt={int(np.mean(list(buf))):>10d}"
335 ref_str = f
" ref={ref:.1f}°C" if ref
is not None else ""
338 f
"R²={s.r_squared:.4f} [{s.confidence_level}]"
343 f
" CH{ch:>2d}: {len(s.calibration_points):>3d} pts {cal} {adc_str}{ref_str}"
346 print(f
" CH{ch:>2d}: 0 pts --- {adc_str}{ref_str}")
353 parser = argparse.ArgumentParser(description=
"Autonomous RTD Calibration (UDP)")
355 "--port", type=int, default=
None, help=
"UDP port (default: from config.toml)"
358 "--board-ip", default=
None, help=
"Board IP filter (default: from config.toml)"
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()
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)
379 cal.save_calibrations()
383 signal.signal(signal.SIGINT, shutdown)
387 "Commands: ref <ch> <°C> | ref-all <°C> | collect [s] | fit | save | status | quit"
391 cmd = input(
" [RTD] > ").strip()
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]))
403 cal.collect_samples(float(p[1])
if len(p) > 1
else args.collect_time)
409 cal.save_calibrations()
412 elif v
in (
"quit",
"exit",
"q"):
415 print(f
" ⚠️ Unknown: {cmd}")
417 if cal.reference_temps:
418 cal.collect_samples(args.collect_time)
420 cal.save_calibrations()
424 print(
"No refs set — showing live (Ctrl+C to stop)")
430if __name__ ==
"__main__":
int target_calibration_points
__init__(self, udp_port=None, board_ip=None)
set_reference_all(self, temp_c, n=None)
Dict[int, deque] live_adc
set_reference_temp(self, ch, temp_c)
_parse_sensor_packet(data)
int max_calibration_points
int min_calibration_points
collect_samples(self, dur=5.0)
_default_calibration_dir()
TOML_NODISCARD_CTOR time() noexcept=default