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.
7All board IPs, ports, and sensor counts come from config/config.toml.
9Calibration model: temp_°C = A·adc³ + B·adc² + C·adc + D
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
17import sys, os, time, json, socket, struct, signal, threading, logging
19from pathlib
import Path
20from typing
import Dict, List, Optional, Tuple
21from dataclasses
import dataclass
22from collections
import deque, defaultdict
23from datetime
import datetime
26 level=logging.INFO, format=
"%(asctime)s - %(levelname)s - %(message)s"
28logger = logging.getLogger(__name__)
32 from config_loader
import (
36 get_calibration_config,
46 return get_sensor_port()
if _cfg_loaded
else 5006
51 b = get_board_by_type(
"TC")
54 return "192.168.2.103"
58 """The channels this board samples, from active_connectors.
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.
65 b = get_board_by_type(
"TC")
67 return [int(c)
for c
in b.get(
"active_connectors", [])
or []]
68 return list(range(1, 10 + 1))
73 cc = get_calibration_config(
"tc")
74 jd = cc.get(
"json_dir")
76 return resolve_path(jd)
77 return Path(__file__).parent /
"calibrations" /
"tc"
81PACKET_HEADER_FORMAT =
"<BBI"
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
93CALIBRATION_DIR.mkdir(parents=
True, exist_ok=
True)
100 reference_temp_c: float
102 quality_score: float = 1.0
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
117 """Autonomous thermocouple calibration via direct UDP board packets."""
129 self.
live_adc: Dict[int, deque] = defaultdict(
lambda: deque(maxlen=200))
139 "packets_received": 0,
140 "messages_received": 0,
141 "calibration_points_collected": 0,
142 "calibrations_completed": 0,
145 logger.info(
"🌡️ Autonomous TC Calibrator initialized")
149 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
151 pt, _, _ = struct.unpack(PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE])
152 if pt != PACKET_TYPE_SENSOR_DATA:
154 off = PACKET_HEADER_SIZE
155 nc, ns = struct.unpack(
156 SENSOR_DATA_PACKET_FORMAT, data[off : off + SENSOR_DATA_PACKET_SIZE]
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:
164 off += SENSOR_DATA_CHUNK_SIZE
166 sid, raw = struct.unpack(
167 SENSOR_DATAPOINT_FORMAT, data[off : off + SENSOR_DATAPOINT_SIZE]
169 off += SENSOR_DATAPOINT_SIZE
170 signed = raw
if raw < 0x80000000
else raw - 0x100000000
171 samples.append((sid, signed, raw))
176 logger.info(f
"🌡️ CH{ch} → {temp_c:.1f} °C")
182 logger.info(f
"🌡️ TC channels {channels} → {temp_c:.1f} °C")
197 logger.warning(
"No reference temps set")
199 logger.info(f
"📊 Collecting TC data for {dur:.1f}s")
203 logger.info(f
"✅ {self.stats['calibration_points_collected']} total points")
206 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
207 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
210 sock.bind((
"0.0.0.0", self.
udp_port))
212 logger.info(f
"📡 TC listening UDP :{self.udp_port}")
214 logger.error(f
"Bind failed: {e}")
218 data, addr = sock.recvfrom(MAX_PACKET_SIZE)
219 except socket.timeout:
221 except Exception
as e:
223 logger.error(f
"recv: {e}")
230 self.
stats[
"packets_received"] += 1
231 for sid, signed, raw
in samples:
232 self.
stats[
"messages_received"] += 1
246 s.calibration_points.append(pt)
247 self.
stats[
"calibration_points_collected"] += 1
255 adc = np.array([p.adc_code
for p
in s.calibration_points], dtype=np.float64)
257 [p.reference_temp_c
for p
in s.calibration_points], dtype=np.float64
259 if np.std(ref) < 1e-9:
260 s.polynomial_coeffs = (0.0, 0.0, 0.0, float(np.mean(ref)))
262 s.is_calibrated =
True
265 c = np.polyfit(adc, ref,
min(3, len(s.calibration_points) - 1))
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
278 s.confidence_level =
"MAXIMUM"
280 s.confidence_level =
"HIGH"
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}")
290 cal = {sid: s
for sid, s
in self.
sensor_states.items()
if s.polynomial_coeffs}
293 ts = datetime.now().strftime(
"%Y%m%d_%H%M%S")
294 jp = CALIBRATION_DIR / f
"tc_calibration_{ts}.json"
298 "polynomial_order": 3,
299 "created": datetime.now().isoformat(),
300 "calibration_polynomials": {},
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),
310 with open(jp,
"w")
as f:
311 json.dump(data, f, indent=2)
312 logger.info(f
"💾 {jp}")
317 print(
"🌡️ Autonomous TC Calibration Status")
319 print(f
"UDP: {'✅ :'+str(self.udp_port) if self.udp_connected else '❌'}")
321 f
"Pkts: {self.stats['packets_received']} Samples: {self.stats['messages_received']} Points: {self.stats['calibration_points_collected']}"
329 f
"ADC={int(np.mean(list(buf))):>10d}" if buf
and len(buf)
else "ADC=---"
332 ref_str = f
" ref={ref:.1f}°C" if ref
is not None else ""
335 f
"R²={s.r_squared:.4f} [{s.confidence_level}]"
340 f
" CH{ch:>2d}: {len(s.calibration_points):>3d} pts {cal} {adc_str}{ref_str}"
343 print(f
" CH{ch:>2d}: 0 pts --- {adc_str}{ref_str}")
350 parser = argparse.ArgumentParser(description=
"Autonomous TC Calibration (UDP)")
352 "--port", type=int, default=
None, help=
"UDP port (default: from config.toml)"
355 "--board-ip", default=
None, help=
"Board IP filter (default: from config.toml)"
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()
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)
376 cal.save_calibrations()
380 signal.signal(signal.SIGINT, shutdown)
384 "Commands: ref <ch> <°C> | ref-all <°C> | collect [s] | fit | save | status | live | quit"
388 cmd = input(
" [TC] > ").strip()
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]))
400 cal.collect_samples(float(p[1])
if len(p) > 1
else args.collect_time)
406 cal.save_calibrations()
409 elif v
in (
"quit",
"exit",
"q"):
412 print(f
" ⚠️ Unknown: {cmd}")
414 if cal.reference_temps:
415 cal.collect_samples(args.collect_time)
417 cal.save_calibrations()
421 print(
"No refs set — showing live (Ctrl+C to stop)")
427if __name__ ==
"__main__":
set_reference_all(self, temp_c, n=None)
__init__(self, udp_port=None, board_ip=None)
collect_samples(self, dur=5.0)
Dict[int, deque] live_adc
_parse_sensor_packet(data)
int max_calibration_points
int target_calibration_points
set_reference_temp(self, ch, temp_c)
int min_calibration_points
_default_calibration_dir()
TOML_NODISCARD_CTOR time() noexcept=default