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.
7All board IPs, ports, and sensor counts come from config/config.toml.
9Polynomial model: force_lbs = A·adc³ + B·adc² + C·adc + D
12 python3 autonomous_lc_calibration.py --interactive
13 python3 autonomous_lc_calibration.py --ref-all 0.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(
"LC")
53 return "192.168.2.102"
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(
"LC")
66 return [int(c)
for c
in b.get(
"active_connectors", [])
or []]
67 return list(range(1, 4 + 1))
72 cc = get_calibration_config(
"lc")
73 jd = cc.get(
"json_dir")
75 return resolve_path(jd)
76 return Path(__file__).parent /
"calibrations" /
"lc"
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)
99 reference_force_lbs: 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 Load-Cell 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 LC 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
"⚖️ LC CH{ch} → {lbs:.2f} lbs")
181 logger.info(f
"⚖️ LC channels {channels} → {lbs:.2f} lbs")
196 logger.warning(
"No reference forces set")
198 logger.info(f
"📊 Collecting LC 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
"📡 LC 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.adc_counts
for p
in s.calibration_points], dtype=np.float64
258 [p.reference_force_lbs
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
"lc_calibration_{ts}.json"
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 LC 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
"ADC={int(np.mean(list(buf))):>10d}" if buf
and len(buf)
else "ADC=---"
333 ref_str = f
" ref={ref:.2f}lbs" if ref
is not None else ""
336 f
"R²={s.r_squared:.4f} [{s.confidence_level}]"
341 f
" CH{ch:>2d}: {len(s.calibration_points):>3d} pts {cal} {adc_str}{ref_str}"
344 print(f
" CH{ch:>2d}: 0 pts --- {adc_str}{ref_str}")
351 parser = argparse.ArgumentParser(description=
"Autonomous LC Calibration (UDP)")
353 "--port", type=int, default=
None, help=
"UDP port (default: from config.toml)"
356 "--board-ip", default=
None, help=
"Board IP filter (default: from config.toml)"
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()
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)
377 cal.save_calibrations()
381 signal.signal(signal.SIGINT, shutdown)
385 "Commands: ref <ch> <lbs> | ref-all <lbs> | collect [s] | fit | save | status | quit"
389 cmd = input(
" [LC] > ").strip()
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]))
401 cal.collect_samples(float(p[1])
if len(p) > 1
else args.collect_time)
407 cal.save_calibrations()
410 elif v
in (
"quit",
"exit",
"q"):
413 print(f
" ⚠️ Unknown: {cmd}")
415 if cal.reference_forces:
416 cal.collect_samples(args.collect_time)
418 cal.save_calibrations()
422 print(
"No refs set — showing live (Ctrl+C to stop)")
428if __name__ ==
"__main__":
set_reference_all(self, lbs, n=None)
collect_samples(self, dur=5.0)
set_reference_force(self, ch, lbs)
__init__(self, udp_port=None, board_ip=None)
_parse_sensor_packet(data)
int min_calibration_points
int target_calibration_points
int max_calibration_points
Dict[int, deque] live_adc
_default_calibration_dir()
TOML_NODISCARD_CTOR time() noexcept=default