STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
autonomous_pt_calibration.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Autonomous PT Calibration System
4Automatically calibrates PT sensors by listening to raw UDP packets from the
5DiabloAvionics boards and collecting calibration points at known reference pressures.
6
7All board IPs, ports, sensor counts, and calibration paths are read from
8config/config.toml via config_loader so you only change the config file.
9
10Features:
11- Reads raw board UDP packets (DiabloAvionics protocol) — no external dependencies
12- Automatic calibration point collection with quality scoring
13- Reference pressure input (from gauges, regulators, etc.)
14- Automatic cubic polynomial fitting (psi = A·adc³ + B·adc² + C·adc + D)
15- R² quality validation with confidence levels
16- Automatic saving of calibration coefficients (JSON + CSV)
17- Confidence-based autonomous operation
18- Live ADC monitoring
19"""
20
21import sys
22import os
23import time
24import json
25import socket
26import struct
27import signal
28import threading
29import numpy as np
30from pathlib import Path
31from typing import Dict, List, Optional, Tuple
32from dataclasses import dataclass, asdict
33from collections import deque, defaultdict
34from datetime import datetime
35import logging
36
37logging.basicConfig(
38 level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
39)
40logger = logging.getLogger(__name__)
41
42# ── Config-driven defaults ───────────────────────────────────────────────
43try:
44 from config_loader import (
45 load_config,
46 get_board_by_type,
47 get_sensor_port,
48 get_calibration_config,
49 resolve_path,
50 )
51
52 _cfg_loaded = True
53except ImportError:
54 _cfg_loaded = False
55
56
58 if _cfg_loaded:
59 return get_sensor_port()
60 return 5006
61
62
64 if _cfg_loaded:
65 b = get_board_by_type("PT")
66 if b:
67 return b.get("ip")
68 return "192.168.2.101"
69
70
72 """The channels this board samples, from active_connectors.
73
74 That list is the config's only statement of which channels exist, and it need
75 not be contiguous — a board may declare {1, 2, 6} — so callers iterate it
76 rather than counting it.
77 """
78 if _cfg_loaded:
79 b = get_board_by_type("PT")
80 if b:
81 return [int(c) for c in b.get("active_connectors", []) or []]
82 return list(range(1, 10 + 1))
83
84
86 if _cfg_loaded:
87 cc = get_calibration_config("pt")
88 jd = cc.get("json_dir")
89 if jd:
90 return resolve_path(jd)
91 return Path(__file__).parent / "calibrations"
92
93
94# ── DiabloAvionics packet constants (match combined_gui.py exactly) ─────
95PACKET_HEADER_FORMAT = "<BBI" # packet_type(u8), version(u8), timestamp(u32) = 6 bytes
96PACKET_HEADER_SIZE = 6
97SENSOR_DATA_PACKET_FORMAT = "<BB" # num_chunks(u8), num_sensors(u8) = 2 bytes
98SENSOR_DATA_PACKET_SIZE = 2
99SENSOR_DATA_CHUNK_FORMAT = "<I" # chunk_timestamp(u32) = 4 bytes
100SENSOR_DATA_CHUNK_SIZE = 4
101SENSOR_DATAPOINT_FORMAT = "<BI" # sensor_id(u8), data(u32) = 5 bytes
102SENSOR_DATAPOINT_SIZE = 5
103PACKET_TYPE_SENSOR_DATA = 3 # PacketType.SENSOR_DATA
104MAX_PACKET_SIZE = 512
105
106# Calibration storage (config-driven)
107CALIBRATION_DIR = _default_calibration_dir()
108CALIBRATION_DIR.mkdir(parents=True, exist_ok=True)
109
110
111@dataclass
113 """Calibration data point with ADC codes"""
114
115 sensor_id: int
116 adc_code: int # Raw ADC code (32-bit signed)
117 reference_pressure: float # Reference pressure in PSI
118 timestamp: float
119 voltage: float = 0.0 # For display/reference
120 quality_score: float = 1.0 # Quality of this point (0-1)
121
122
123@dataclass
125 """State for a single sensor's calibration"""
126
127 sensor_id: int
128 calibration_points: List[CalibrationPoint]
129 polynomial_coeffs: Optional[Tuple[float, float, float, float]] = (
130 None # [A, B, C, D]
131 )
132 r_squared: float = 0.0
133 confidence_level: str = "LOW" # LOW, MEDIUM, HIGH, MAXIMUM
134 last_updated: float = 0.0
135 is_calibrated: bool = False
136
137
139 """
140 Autonomous PT calibration system that automatically calibrates sensors
141 by listening to raw DiabloAvionics UDP packets and collecting calibration
142 points at known reference pressures.
143
144 Data path: Board → UDP → this script (direct, no middleware needed)
145 """
146
147 def __init__(self, udp_port: int = None, board_ip: Optional[str] = None):
148 if udp_port is None:
149 udp_port = _default_port()
150 if board_ip is None:
151 board_ip = _default_board_ip()
152 self.udp_port = udp_port
153 self.board_ip = board_ip # filter source IP (None = accept all)
154 self.udp_connected = False
155
156 # Sensor calibration states
157 self.sensor_states: Dict[int, SensorCalibrationState] = {}
158
159 # Reference pressure sources (can be set manually or from external gauges)
160 self.reference_pressures: Dict[int, float] = {} # sensor_id -> ref_psi
161
162 # Live ADC buffers (for stability scoring)
163 self.live_adc: Dict[int, deque] = defaultdict(lambda: deque(maxlen=200))
164
165 # Calibration settings
166 self.min_calibration_points = 5 # Minimum points for calibration
167 self.target_calibration_points = 10 # Target points for good calibration
168 self.max_calibration_points = 20 # Maximum points to keep
169
170 # Quality thresholds
171 self.min_r_squared = 0.95 # Minimum R² for acceptable calibration
172 self.target_r_squared = 0.99 # Target R² for good calibration
173
174 # Data collection
175 self.running = False
176 self.collecting = False # True = actively recording points at current ref
177 self.data_thread: Optional[threading.Thread] = None
178
179 # Statistics
180 self.stats = {
181 "messages_received": 0,
182 "packets_received": 0,
183 "calibration_points_collected": 0,
184 "calibrations_completed": 0,
185 "start_time": time.time(),
186 }
187
188 logger.info("🤖 Autonomous PT Calibrator initialized")
189 logger.info(f" UDP port: {self.udp_port}")
190 logger.info(f" Board IP filter: {self.board_ip or 'any'}")
191
192 # ── UDP packet parsing ──────────────────────────────────────────────
193 @staticmethod
194 def _parse_sensor_packet(data: bytes) -> Optional[List[Tuple[int, int, int]]]:
195 """Parse a DiabloAvionics sensor data packet.
196 Returns list of (sensor_id, adc_signed, adc_raw) or None."""
197 if len(data) < PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE:
198 return None
199 pkt_type, _ver, _ts = struct.unpack(
200 PACKET_HEADER_FORMAT, data[:PACKET_HEADER_SIZE]
201 )
202 if pkt_type != PACKET_TYPE_SENSOR_DATA:
203 return None
204
205 off = PACKET_HEADER_SIZE
206 num_chunks, num_sensors = struct.unpack(
207 SENSOR_DATA_PACKET_FORMAT, data[off : off + SENSOR_DATA_PACKET_SIZE]
208 )
209 off += SENSOR_DATA_PACKET_SIZE
210
211 per_chunk = SENSOR_DATA_CHUNK_SIZE + num_sensors * SENSOR_DATAPOINT_SIZE
212 expected = PACKET_HEADER_SIZE + SENSOR_DATA_PACKET_SIZE + num_chunks * per_chunk
213 if len(data) < expected:
214 return None
215
216 samples = []
217 for _ in range(num_chunks):
218 _chunk_ts = struct.unpack(
219 SENSOR_DATA_CHUNK_FORMAT, data[off : off + SENSOR_DATA_CHUNK_SIZE]
220 )
221 off += SENSOR_DATA_CHUNK_SIZE
222 for _ in range(num_sensors):
223 sid, raw = struct.unpack(
224 SENSOR_DATAPOINT_FORMAT, data[off : off + SENSOR_DATAPOINT_SIZE]
225 )
226 off += SENSOR_DATAPOINT_SIZE
227 adc_signed = raw if raw < 0x80000000 else raw - 0x100000000
228 samples.append((sid, adc_signed, raw))
229 return samples
230
231 # ── Reference pressure management ───────────────────────────────────
232 def set_reference_pressure(self, sensor_id: int, pressure_psi: float):
233 """Set reference pressure for a sensor (from gauge, regulator, etc.)"""
234 self.reference_pressures[sensor_id] = pressure_psi
235 logger.info(
236 f"📊 Set reference pressure for sensor {sensor_id}: {pressure_psi:.2f} PSI"
237 )
238
239 def set_reference_pressures(self, pressures: Dict[int, float]):
240 """Set reference pressures for multiple sensors"""
241 self.reference_pressures.update(pressures)
242 logger.info(f"📊 Set reference pressures for {len(pressures)} sensors")
243
244 def set_reference_all(self, pressure_psi: float, num_channels: int = None):
245 """Set all channels to the same reference pressure (e.g. zero cal)"""
246 channels = (
248 if num_channels is None
249 else list(range(1, num_channels + 1))
250 )
251 for ch in channels:
252 self.reference_pressures[ch] = pressure_psi
253 logger.info(f"📊 Set channels {channels} → {pressure_psi:.2f} PSI")
254
255 # ── Start / stop ────────────────────────────────────────────────────
256 def start(self):
257 """Start autonomous calibration (UDP listener thread)"""
258 self.running = True
259 self.data_thread = threading.Thread(
260 target=self._data_collection_loop, daemon=True
261 )
262 self.data_thread.start()
263 logger.info("🚀 Autonomous calibration started")
264 return True
265
266 def stop(self):
267 """Stop autonomous calibration"""
268 self.running = False
269 if self.data_thread:
270 self.data_thread.join(timeout=2.0)
271 logger.info("🛑 Autonomous calibration stopped")
272
273 def collect_samples(self, duration_sec: float = 5.0):
274 """Collect calibration points for `duration_sec` at current references."""
275 if not self.reference_pressures:
276 logger.warning("⚠️ No reference pressures set — nothing to collect")
277 return
278 refs = ", ".join(
279 f"CH{c}={v:.1f}" for c, v in sorted(self.reference_pressures.items())
280 )
281 logger.info(f"📊 Collecting for {duration_sec:.1f}s at: {refs}")
282 self.collecting = True
283 time.sleep(duration_sec)
284 self.collecting = False
285 logger.info(
286 f"✅ Collection done — {self.stats['calibration_points_collected']} total points"
287 )
288
289 # ── UDP listener loop ───────────────────────────────────────────────
291 """Main data collection loop — reads raw UDP packets from boards"""
292 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
293 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
294 sock.settimeout(0.2)
295 try:
296 sock.bind(("0.0.0.0", self.udp_port))
297 self.udp_connected = True
298 logger.info(f"📡 Listening on UDP :{self.udp_port}")
299 except OSError as e:
300 logger.error(f"❌ Failed to bind UDP :{self.udp_port}: {e}")
301 return
302
303 last_auto_fit = time.time()
304 last_auto_save = time.time()
305
306 while self.running:
307 try:
308 data, addr = sock.recvfrom(MAX_PACKET_SIZE)
309 except socket.timeout:
310 continue
311 except Exception as e:
312 if self.running:
313 logger.error(f"recv error: {e}")
314 continue
315
316 source_ip = addr[0]
317 if self.board_ip and source_ip != self.board_ip:
318 continue
319
320 samples = self._parse_sensor_packet(data)
321 if samples is None:
322 continue
323
324 self.stats["packets_received"] += 1
325
326 for sensor_id, adc_signed, adc_raw in samples:
327 self.stats["messages_received"] += 1
328 self.live_adc[sensor_id].append(adc_signed)
329
330 # Only record calibration points when collecting is active
331 if not self.collecting:
332 continue
333 if sensor_id not in self.reference_pressures:
334 continue
335
336 # Compute quality score from ADC stability
337 buf = self.live_adc[sensor_id]
338 quality = 1.0
339 if len(buf) >= 10:
340 std = float(np.std(list(buf)[-50:]))
341 # Lower std = higher quality (normalize to ~0-1 range)
342 quality = max(0.1, 1.0 - min(std / 100000.0, 0.9))
343
344 voltage = (adc_signed * 2.5) / 2147483648.0
345
346 point = CalibrationPoint(
347 sensor_id=sensor_id,
348 adc_code=adc_signed,
349 reference_pressure=self.reference_pressures[sensor_id],
350 timestamp=time.time(),
351 voltage=voltage,
352 quality_score=quality,
353 )
354 self._add_calibration_point(point)
355
356 # Periodic auto-fit (every 5s)
357 now = time.time()
358 if now - last_auto_fit > 5.0:
360 last_auto_fit = now
361
362 # Periodic auto-save (every 60s)
363 if now - last_auto_save > 60.0:
364 self.save_calibrations()
365 last_auto_save = now
366
367 sock.close()
368 self.udp_connected = False
369
370 def _process_pt_sample(self, sensor_id: int, adc_signed: int, adc_raw: int):
371 """Process a single PT ADC sample."""
372 self.live_adc[sensor_id].append(adc_signed)
373
374 if not self.collecting or sensor_id not in self.reference_pressures:
375 return
376
377 voltage = (adc_signed * 2.5) / 2147483648.0
378 point = CalibrationPoint(
379 sensor_id=sensor_id,
380 adc_code=adc_signed,
381 reference_pressure=self.reference_pressures[sensor_id],
382 timestamp=time.time(),
383 voltage=voltage,
384 quality_score=1.0,
385 )
386 self._add_calibration_point(point)
387
388 def _add_calibration_point(self, point: CalibrationPoint):
389 """Add a calibration point for a sensor"""
390 sensor_id = point.sensor_id
391
392 # Initialize sensor state if needed
393 if sensor_id not in self.sensor_states:
394 self.sensor_states[sensor_id] = SensorCalibrationState(
395 sensor_id=sensor_id, calibration_points=[]
396 )
397
398 state = self.sensor_states[sensor_id]
399
400 # Add point
401 state.calibration_points.append(point)
402 self.stats["calibration_points_collected"] += 1
403
404 # Limit number of points
405 if len(state.calibration_points) > self.max_calibration_points:
406 state.calibration_points = state.calibration_points[
407 -self.max_calibration_points :
408 ]
409
410 logger.debug(
411 f"📝 Added calibration point for sensor {sensor_id}: "
412 f"ADC={point.adc_code}, Pressure={point.reference_pressure:.2f} PSI"
413 )
414
415 def _auto_fit_calibrations(self):
416 """Automatically fit calibration polynomials when enough points are collected"""
417 for sensor_id, state in self.sensor_states.items():
418 if len(state.calibration_points) >= self.min_calibration_points:
419 if not state.is_calibrated or len(state.calibration_points) % 2 == 0:
420 # Fit calibration
421 success = self._fit_calibration(sensor_id)
422 if success and not state.is_calibrated:
423 state.is_calibrated = True
424 self.stats["calibrations_completed"] += 1
425 logger.info(
426 f"✅ Auto-calibrated sensor {sensor_id}: "
427 f"R²={state.r_squared:.4f}, {len(state.calibration_points)} points"
428 )
429
430 def _fit_calibration(self, sensor_id: int) -> bool:
431 """Fit calibration polynomial for a sensor"""
432 if sensor_id not in self.sensor_states:
433 return False
434
435 state = self.sensor_states[sensor_id]
436
437 if len(state.calibration_points) < self.min_calibration_points:
438 return False
439
440 try:
441 # Extract ADC codes and pressures
442 adc_codes = np.array([p.adc_code for p in state.calibration_points])
443 pressures = np.array(
444 [p.reference_pressure for p in state.calibration_points]
445 )
446
447 # Fit cubic polynomial: psi = A*adc³ + B*adc² + C*adc + D
448 poly_coeffs = np.polyfit(adc_codes, pressures, 3)
449
450 # Calculate R²
451 y_pred = np.polyval(poly_coeffs, adc_codes)
452 ss_res = np.sum((pressures - y_pred) ** 2)
453 ss_tot = np.sum((pressures - np.mean(pressures)) ** 2)
454 r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
455
456 # Update state
457 state.polynomial_coeffs = tuple(poly_coeffs)
458 state.r_squared = r_squared
459 state.last_updated = time.time()
460
461 # Update confidence level
462 if (
463 r_squared >= self.target_r_squared
464 and len(state.calibration_points) >= self.target_calibration_points
465 ):
466 state.confidence_level = "MAXIMUM"
467 elif (
468 r_squared >= self.min_r_squared
469 and len(state.calibration_points) >= self.min_calibration_points
470 ):
471 state.confidence_level = "HIGH"
472 elif len(state.calibration_points) >= self.min_calibration_points:
473 state.confidence_level = "MEDIUM"
474 else:
475 state.confidence_level = "LOW"
476
477 return True
478
479 except Exception as e:
480 logger.error(f"Error fitting calibration for sensor {sensor_id}: {e}")
481 return False
482
483 def save_calibrations(self):
484 """Save all calibrations to JSON + CSV files (C++ stack reads both)"""
485 calibrated = {
486 sid: s for sid, s in self.sensor_states.items() if s.polynomial_coeffs
487 }
488 if not calibrated:
489 return None
490
491 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
492
493 # ── JSON (primary format the C++ SensorCalibrationManager loads) ──
494 try:
495 json_path = CALIBRATION_DIR / f"pt_calibration_{timestamp}.json"
496 data = {
497 "sensor_type": "PT",
498 "unit": "PSI",
499 "polynomial_order": 3,
500 "created": datetime.now().isoformat(),
501 "calibration_polynomials": {},
502 "quality": {},
503 }
504 for sensor_id, state in sorted(calibrated.items()):
505 data["calibration_polynomials"][str(sensor_id)] = list(
506 state.polynomial_coeffs
507 )
508 data["quality"][str(sensor_id)] = {
509 "r_squared": state.r_squared,
510 "confidence_level": state.confidence_level,
511 "num_points": len(state.calibration_points),
512 "last_updated": state.last_updated,
513 }
514 with open(json_path, "w") as f:
515 json.dump(data, f, indent=2)
516 logger.info(f"💾 JSON: {json_path}")
517 except Exception as e:
518 logger.error(f"Error saving JSON: {e}")
519 json_path = None
520
521 # ── CSV (DiabloAvionics compat format) ──
522 try:
523 csv_path = CALIBRATION_DIR / f"pt_calibration_{timestamp}.csv"
524 cols = ["Timestamp"]
525 for ch in sorted(calibrated.keys()):
526 for ci in range(4):
527 cols.append(f"PT{ch} Coefficient {ci}")
528
529 row = [datetime.now().isoformat()]
530 for ch in sorted(calibrated.keys()):
531 coeffs = calibrated[ch].polynomial_coeffs or (0, 0, 0, 0)
532 for ci in range(4):
533 row.append(f"{coeffs[ci]:.15e}" if ci < len(coeffs) else "0.0")
534
535 with open(csv_path, "w") as f:
536 f.write(",".join(cols) + "\n")
537 f.write(",".join(row) + "\n")
538 logger.info(f"💾 CSV: {csv_path}")
539 except Exception as e:
540 logger.error(f"Error saving CSV: {e}")
541
542 return json_path
543
544 def get_status(self) -> dict:
545 """Get calibration status"""
546 status = {
547 "udp_connected": self.udp_connected,
548 "running": self.running,
549 "collecting": self.collecting,
550 "sensors": {},
551 "stats": self.stats.copy(),
552 }
553
554 for sensor_id, state in self.sensor_states.items():
555 status["sensors"][sensor_id] = {
556 "num_points": len(state.calibration_points),
557 "is_calibrated": state.is_calibrated,
558 "r_squared": state.r_squared,
559 "confidence_level": state.confidence_level,
560 "has_reference": sensor_id in self.reference_pressures,
561 }
562
563 return status
564
565 def print_status(self):
566 """Print calibration status"""
567 status = self.get_status()
568 print("\n" + "=" * 70)
569 print("🤖 Autonomous PT Calibration Status")
570 print("=" * 70)
571 print(
572 f"UDP: {'✅ Listening :{}'.format(self.udp_port) if status['udp_connected'] else '❌ Not connected'}"
573 )
574 print(f"Running: {'✅ Yes' if status['running'] else '❌ No'}")
575 print(f"Collecting: {'📊 YES' if status['collecting'] else '⏸ No'}")
576 print(f"\nStatistics:")
577 print(f" Packets received: {status['stats']['packets_received']}")
578 print(f" Samples received: {status['stats']['messages_received']}")
579 print(
580 f" Calibration points: {status['stats']['calibration_points_collected']}"
581 )
582 print(f" Calibrations done: {status['stats']['calibrations_completed']}")
583
584 # Live ADC values
585 if self.live_adc:
586 print(f"\nLive ADC:")
587 for ch in sorted(self.live_adc.keys()):
588 buf = self.live_adc[ch]
589 if len(buf) > 0:
590 mean = int(np.mean(list(buf)))
591 std = int(np.std(list(buf)))
592 print(f" CH{ch:>2d}: ADC={mean:>11d} σ={std:>8d}")
593
594 print(f"\nCalibration Channels:")
595 for sensor_id in sorted(
596 set(list(self.sensor_states.keys()) + list(self.live_adc.keys()))
597 ):
598 state = self.sensor_states.get(sensor_id)
599 ref = self.reference_pressures.get(sensor_id)
600 ref_str = f" ref={ref:.1f} PSI" if ref is not None else ""
601
602 if state:
603 cal_str = (
604 f"R²={state.r_squared:.4f} [{state.confidence_level}]"
605 if state.is_calibrated
606 else "⏳ collecting"
607 )
608 print(
609 f" CH{sensor_id:>2d}: {len(state.calibration_points):>3d} pts "
610 f"{'✅' if state.is_calibrated else ' '} {cal_str}{ref_str}"
611 )
612 else:
613 print(f" CH{sensor_id:>2d}: 0 pts no data yet{ref_str}")
614 print("=" * 70 + "\n")
615
616
617def main():
618 """Main entry point — interactive or scripted PT calibration via UDP"""
619 import argparse
620
621 parser = argparse.ArgumentParser(
622 description="Autonomous PT Calibration System (UDP)",
623 formatter_class=argparse.RawDescriptionHelpFormatter,
624 epilog="""
625Examples:
626 # Interactive mode — prompts for reference pressures
627 python3 autonomous_pt_calibration.py --interactive
628
629 # Zero-cal all channels
630 python3 autonomous_pt_calibration.py --ref-all 0.0 --collect-time 10
631
632 # Specific channels
633 python3 autonomous_pt_calibration.py --sensor 1 --pressure 14.7 --sensor 2 --pressure 100.0
634 """,
635 )
636 parser.add_argument(
637 "--port",
638 type=int,
639 default=None,
640 help="UDP port to listen on (default: from config.toml)",
641 )
642 parser.add_argument(
643 "--board-ip",
644 default=None,
645 help="Filter by board source IP (default: from config.toml)",
646 )
647 parser.add_argument(
648 "--reference-pressures",
649 type=str,
650 help="JSON file with reference pressures {channel_id: psi}",
651 )
652 parser.add_argument(
653 "--sensor", type=int, action="append", help="Sensor channel ID to calibrate"
654 )
655 parser.add_argument(
656 "--pressure",
657 type=float,
658 action="append",
659 help="Reference pressure (PSI) — paired with --sensor",
660 )
661 parser.add_argument(
662 "--ref-all",
663 type=float,
664 default=None,
665 help="Set all channels (1-10) to this reference pressure",
666 )
667 parser.add_argument(
668 "--collect-time",
669 type=float,
670 default=5.0,
671 help="Seconds to collect per reference point (default: 5)",
672 )
673 parser.add_argument(
674 "--status-interval",
675 type=float,
676 default=10.0,
677 help="Status print interval in seconds (default: 10)",
678 )
679 parser.add_argument(
680 "--interactive",
681 "-i",
682 action="store_true",
683 help="Interactive mode — prompt for commands",
684 )
685
686 args = parser.parse_args()
687
688 # Create calibrator
689 calibrator = AutonomousPTCalibrator(udp_port=args.port, board_ip=args.board_ip)
690
691 # Load reference pressures from file
692 if args.reference_pressures:
693 with open(args.reference_pressures, "r") as f:
694 ref_pressures = json.load(f)
695 # Convert keys to int
696 calibrator.set_reference_pressures(
697 {int(k): float(v) for k, v in ref_pressures.items()}
698 )
699
700 # Set from --ref-all
701 if args.ref_all is not None:
702 calibrator.set_reference_all(args.ref_all)
703
704 # Set from --sensor / --pressure pairs
705 if args.sensor and args.pressure:
706 if len(args.sensor) != len(args.pressure):
707 print("❌ Error: Number of --sensor must match number of --pressure")
708 return 1
709 for sensor_id, pressure in zip(args.sensor, args.pressure):
710 calibrator.set_reference_pressure(sensor_id, pressure)
711
712 # Start UDP listener
713 calibrator.start()
714 time.sleep(1.0)
715
716 def shutdown(sig=None, frame=None):
717 print("\n🛑 Stopping...")
718 calibrator.stop()
719 calibrator.save_calibrations()
720 calibrator.print_status()
721 print("✅ Calibrations saved")
722 sys.exit(0)
723
724 signal.signal(signal.SIGINT, shutdown)
725 signal.signal(signal.SIGTERM, shutdown)
726
727 if args.interactive:
728 # ── Interactive calibration loop ────────────────────────────────
729 print(f"\n{'═' * 70}")
730 print(" INTERACTIVE PT CALIBRATION (UDP)")
731 print(f" Listening on UDP :{args.port}")
732 print(f"{'═' * 70}")
733 print(" Commands:")
734 print(" ref <ch> <psi> — set reference for one channel")
735 print(" ref-all <psi> — set reference for all channels (1-10)")
736 print(" collect [secs] — collect calibration points")
737 print(" fit — fit polynomials now")
738 print(" save — save calibrations to JSON + CSV")
739 print(" status — show full status")
740 print(" live — show live ADC values (Ctrl+C to stop)")
741 print(" quit — save and exit")
742 print()
743
744 while True:
745 try:
746 cmd = input(" [PT] > ").strip()
747 except EOFError:
748 break
749 if not cmd:
750 continue
751 parts = cmd.split()
752 verb = parts[0].lower()
753
754 if verb == "ref-all" and len(parts) >= 2:
755 calibrator.set_reference_all(float(parts[1]))
756 elif verb == "ref" and len(parts) >= 3:
757 calibrator.set_reference_pressure(int(parts[1]), float(parts[2]))
758 elif verb == "collect":
759 dur = float(parts[1]) if len(parts) > 1 else args.collect_time
760 calibrator.collect_samples(dur)
761 elif verb == "fit":
762 calibrator._auto_fit_calibrations()
763 calibrator.print_status()
764 elif verb == "save":
765 calibrator._auto_fit_calibrations()
766 calibrator.save_calibrations()
767 elif verb == "status":
768 calibrator.print_status()
769 elif verb == "live":
770 print(" (Ctrl+C to stop live view)")
771 try:
772 while True:
773 parts_live = []
774 for ch in sorted(calibrator.live_adc.keys()):
775 buf = calibrator.live_adc[ch]
776 if len(buf) > 0:
777 parts_live.append(
778 f"CH{ch}:{int(np.mean(list(buf))):>10d}"
779 )
780 print(
781 f"\r [{calibrator.stats['packets_received']:>6d} pkts] "
782 + " ".join(parts_live),
783 end="",
784 flush=True,
785 )
786 time.sleep(0.25)
787 except KeyboardInterrupt:
788 print()
789 elif verb in ("quit", "exit", "q"):
790 shutdown()
791 else:
792 print(f" ⚠️ Unknown command: {cmd}")
793
794 else:
795 # ── Non-interactive mode ────────────────────────────────────────
796 if not calibrator.reference_pressures:
797 print("⚠️ No references set — showing live ADC (Ctrl+C to stop)")
798 try:
799 while True:
800 calibrator.print_status()
801 time.sleep(args.status_interval)
802 except KeyboardInterrupt:
803 shutdown()
804 else:
805 # Collect at current references and save
806 calibrator.collect_samples(args.collect_time)
807 calibrator._auto_fit_calibrations()
808 calibrator.save_calibrations()
809 calibrator.print_status()
810 calibrator.stop()
811
812 return 0
813
814
815if __name__ == "__main__":
816 sys.exit(main())
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
_process_pt_sample(self, int sensor_id, int adc_signed, int adc_raw)
Optional[List[Tuple[int, int, int]]] _parse_sensor_packet(bytes data)
set_reference_all(self, float pressure_psi, int num_channels=None)
set_reference_pressure(self, int sensor_id, float pressure_psi)
__init__(self, int udp_port=None, Optional[str] board_ip=None)
TOML_NODISCARD_CTOR time() noexcept=default