STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
autonomous_lc_calibration.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Autonomous Load Cell (LC) Calibration System
4Reads raw ADC counts from LC boards via UDP, collects calibration points
5at known reference forces (lbs), fits polynomials, and saves coefficients.
6
7All board IPs, ports, and sensor counts come from config/config.toml.
8
9Polynomial model: force_lbs = A·adc³ + B·adc² + C·adc + D
10
11Usage:
12 python3 autonomous_lc_calibration.py --interactive
13 python3 autonomous_lc_calibration.py --ref-all 0.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("LC")
51 if b:
52 return b.get("ip")
53 return "192.168.2.102"
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("LC")
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("lc")
73 jd = cc.get("json_dir")
74 if jd:
75 return resolve_path(jd)
76 return Path(__file__).parent / "calibrations" / "lc"
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 adc_counts: int
99 reference_force_lbs: 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 Load-Cell 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_forces: 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 LC 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_force(self, ch, lbs):
174 self.reference_forces[ch] = lbs
175 logger.info(f"⚖️ LC CH{ch} → {lbs:.2f} lbs")
176
177 def set_reference_all(self, lbs, n=None):
178 channels = _default_connectors() if n is None else list(range(1, n + 1))
179 for ch in channels:
180 self.reference_forces[ch] = lbs
181 logger.info(f"⚖️ LC channels {channels} → {lbs:.2f} lbs")
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_forces:
196 logger.warning("No reference forces set")
197 return
198 logger.info(f"📊 Collecting LC 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"📡 LC 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_forces:
234 pt = CalibrationPoint(
235 sid, signed, self.reference_forces[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.adc_counts for p in s.calibration_points], dtype=np.float64
256 )
257 ref = np.array(
258 [p.reference_force_lbs 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"lc_calibration_{ts}.json"
296 data = {
297 "sensor_type": "LC",
298 "unit": "lbs",
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 LC 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"ADC={int(np.mean(list(buf))):>10d}" if buf and len(buf) else "ADC=---"
331 )
332 ref = self.reference_forces.get(ch)
333 ref_str = f" ref={ref:.2f}lbs" if ref is not None else ""
334 if s:
335 cal = (
336 f"R²={s.r_squared:.4f} [{s.confidence_level}]"
337 if s.is_calibrated
338 else "⏳"
339 )
340 print(
341 f" CH{ch:>2d}: {len(s.calibration_points):>3d} pts {cal} {adc_str}{ref_str}"
342 )
343 else:
344 print(f" CH{ch:>2d}: 0 pts --- {adc_str}{ref_str}")
345 print(f"{'═'*70}\n")
346
347
348def main():
349 import argparse
350
351 parser = argparse.ArgumentParser(description="Autonomous LC Calibration (UDP)")
352 parser.add_argument(
353 "--port", type=int, default=None, help="UDP port (default: from config.toml)"
354 )
355 parser.add_argument(
356 "--board-ip", default=None, help="Board IP filter (default: from config.toml)"
357 )
358 parser.add_argument("--sensor", type=int, action="append")
359 parser.add_argument("--force", type=float, action="append")
360 parser.add_argument("--ref-all", type=float, default=None)
361 parser.add_argument("--collect-time", type=float, default=5.0)
362 parser.add_argument("--interactive", "-i", action="store_true")
363 args = parser.parse_args()
364
365 cal = AutonomousLCCalibrator(args.port, args.board_ip)
366 if args.ref_all is not None:
367 cal.set_reference_all(args.ref_all)
368 if args.sensor and args.force:
369 for s, f in zip(args.sensor, args.force):
370 cal.set_reference_force(s, f)
371 cal.start()
372 time.sleep(1)
373
374 def shutdown(*_):
375 cal.stop()
376 cal.auto_fit()
377 cal.save_calibrations()
378 cal.print_status()
379 sys.exit(0)
380
381 signal.signal(signal.SIGINT, shutdown)
382
383 if args.interactive:
384 print(
385 "Commands: ref <ch> <lbs> | ref-all <lbs> | collect [s] | fit | save | status | quit"
386 )
387 while True:
388 try:
389 cmd = input(" [LC] > ").strip()
390 except EOFError:
391 break
392 if not cmd:
393 continue
394 p = cmd.split()
395 v = p[0].lower()
396 if v == "ref-all" and len(p) >= 2:
397 cal.set_reference_all(float(p[1]))
398 elif v == "ref" and len(p) >= 3:
399 cal.set_reference_force(int(p[1]), float(p[2]))
400 elif v == "collect":
401 cal.collect_samples(float(p[1]) if len(p) > 1 else args.collect_time)
402 elif v == "fit":
403 cal.auto_fit()
404 cal.print_status()
405 elif v == "save":
406 cal.auto_fit()
407 cal.save_calibrations()
408 elif v == "status":
409 cal.print_status()
410 elif v in ("quit", "exit", "q"):
411 shutdown()
412 else:
413 print(f" ⚠️ Unknown: {cmd}")
414 else:
415 if cal.reference_forces:
416 cal.collect_samples(args.collect_time)
417 cal.auto_fit()
418 cal.save_calibrations()
419 cal.print_status()
420 cal.stop()
421 else:
422 print("No refs set — showing live (Ctrl+C to stop)")
423 while True:
424 cal.print_status()
425 time.sleep(10)
426
427
428if __name__ == "__main__":
429 sys.exit(main())
#define min(a, b)
Definition Arduino.h:42
TOML_NODISCARD_CTOR time() noexcept=default