290 def __init__(self, sensor_names: Optional[List[str]] =
None):
293 for name, info
in SENSOR_TYPES.items():
294 if sensor_names
and name
not in sensor_names:
307 from config_loader
import get_boards_by_type
309 boards = get_boards_by_type(stype)
315 board_ip = info.board_ip
or "unknown"
316 active = list(info.connectors)
320 f
" {stype} ({board_ip}): active connectors = {active} (default: all)"
325 load_config().get(
"calibration", {}).get(
"exclude_hp_pt",
True)
331 if stype ==
"PT" and exclude_hp
and is_current_loop_board(board):
333 f
" {stype} ({board.get('name', '?')}, {board.get('ip', '?')}): skipped (HP PT board)"
336 board_ip = board.get(
"ip", info.board_ip
or "unknown")
337 board_name = board.get(
"name",
"unknown")
340 active = board_active_connectors(board)
346 if not hasattr(self,
"ip_to_board_id"):
351 f
" {stype} ({board_name}, {board_ip}, ID {self.ip_to_board_id[board_ip]}): active connectors = {active}"
357 self.
robust: Dict[Tuple[str, int], RobustCalibrationFramework] = {}
363 board_id = getattr(self,
"ip_to_board_id", {}).get(board_ip, 1)
364 for ch
in active_conns:
366 unique_ch = board_id * 100 + ch
367 key = (stype, unique_ch)
368 if key
not in self.
robust:
373 f
" ⚠️ Channel conflict: {stype} key {key} exists on multiple boards. Using first."
382 orch_cfg = load_config().get(
"calibration", {}).get(
"orchestrator", {})
383 forgetting = float(orch_cfg.get(
"rls_forgetting_factor", 0.995))
385 n_sensors=total_channels,
387 forgetting_factor=forgetting,
394 for key
in sorted(self.
robust.keys()):
400 self.
live_adc: Dict[Tuple[str, int], deque] = defaultdict(
401 lambda: deque(maxlen=200)
428 self.
target_r2 = float(orch_cfg.get(
"target_r_squared", 0.99))
429 self.
min_r2 = float(orch_cfg.get(
"min_r_squared", 0.95))
435 phase2 = load_config().get(
"phase2", {})
if _cfg_loaded
else {}
437 load_config().get(
"calibration", {}).get(
"environmental", {})
444 orch_cfg.get(
"self_cal_alpha_threshold", 0.6)
452 for rcf
in self.
robust.values():
454 rcf.forgetting_factor = forgetting
468 if ACTUATOR_COMM_AVAILABLE:
470 from config_loader
import get_board_by_type
472 actuator_board = get_board_by_type(
"ACTUATOR")
if _cfg_loaded
else None
473 if actuator_board
and actuator_board.get(
"enabled",
False):
474 actuator_ip = actuator_board.get(
"ip",
"192.168.2.201")
475 actuator_port = actuator_board.get(
"port", 5005)
478 f
"✅ Actuator communication initialized: {actuator_ip}:{actuator_port}"
480 except Exception
as e:
481 logger.warning(f
"⚠️ Failed to initialize actuator comm: {e}")
487 "bayesian_updates": 0,
489 "drifts_detected": 0,
491 "active_learning_alerts": 0,
498 if ROBUSTNESS_AVAILABLE:
500 rb_cfg = RobustnessConfig()
501 rb_cfg.model_order = N_PARAMS - 1
502 rb_cfg.num_sensors = total_channels
505 "🛡️ Robustness manager enabled (backup, validation, health)"
507 except Exception
as e:
508 logger.warning(f
"⚠️ Robustness manager init failed: {e}")
511 f
"🔧 Orchestrator: {', '.join(self.sensor_types.keys())} "
512 f
"({total_channels} channels, {N_PARAMS}-param env-robust basis)"
544 while self.
receiver.sample_queue
and drained < max_drain:
546 src_ip, sid, signed, ts = self.
receiver.sample_queue.popleft()
547 except (IndexError, ValueError):
556 if not hasattr(self,
"_actuator_raw_samples"):
563 board_id = getattr(self,
"ip_to_board_id", {}).get(src_ip, 1)
564 unique_ch = board_id * 100 + channel_id
565 key = (stype, unique_ch)
567 if key
not in self.
robust:
569 if self.
stats.get(
"missed_channels", 0) < 5:
571 f
"⚠️ Unknown channel: {stype} CH{channel_id} (sid={sid})"
573 self.
stats[
"missed_channels"] = (
574 self.
stats.get(
"missed_channels", 0) + 1
595 adc_code=float(signed),
602 result = rcf.add_calibration_point(pt)
603 self.
stats[
"phase1_points"] += 1
605 if result.get(
"drift_detected"):
606 self.
stats[
"drifts_detected"] += 1
610 phi = rcf.environmental_robust_basis_functions(
613 self.
engine.add_calibration_point(idx, phi, ref_val, pt.uncertainty)
616 if self.
phase ==
"MONITORING":
669 Phase 2 per-sample update:
670 1. RLS update (if reference available)
671 2. Prediction + uncertainty for active-learning check
672 3. GLR drift detection → full Bayesian recal if triggered
673 4. Store for consensus (live_adc, latest_predictions)
689 result = rcf.add_calibration_point(pt)
690 self.
stats[
"rls_updates"] += 1
692 if result.get(
"drift_detected"):
695 f
"⚠️ DRIFT on {stype} CH{ch} "
696 f
"(GLR={result['glr_statistic']:.2f}) — full Bayesian recal"
698 self.
stats[
"drifts_detected"] += 1
699 self.
stats[
"recalibrations"] += 1
702 phi = rcf.environmental_robust_basis_functions(adc_code, self.
env_state)
703 self.
engine.add_calibration_point(idx, phi, ref_val, pt.uncertainty)
706 pred, sigma = rcf.predict_pressure_with_uncertainty(adc_code, self.
env_state)
709 phi = rcf.environmental_robust_basis_functions(adc_code, self.
env_state)
710 _, _, alert = self.
engine.predict(idx, phi, adc_code=adc_code)
711 if alert
is not None:
712 self.
stats[
"active_learning_alerts"] += 1
719 f
"📝 Active learning: {stype} CH{ch} needs recal — "
720 f
"{alert.reason} (urgency={alert.urgency:.2f})"
862 """Run TLS + Bayesian update for all channels with enough data."""
863 for key, rcf
in self.
robust.items():
864 pts = rcf.calibration_points
868 theta_tls, cov_tls = rcf.total_least_squares_calibration(pts)
869 self.
stats[
"tls_fits"] += 1
871 theta_post, cov_post = rcf.bayesian_update(pts)
872 rcf.theta_mean = theta_post
873 rcf.theta_cov = cov_post
874 self.
stats[
"bayesian_updates"] += 1
877 conf = rcf.get_confidence_level()
878 summary = rcf.get_calibration_summary()
879 rmse = summary.get(
"rmse", 0.0)
881 f
"✅ {stype} CH{ch}: TLS+Bayesian "
882 f
"RMSE={rmse:.4f} [{conf}] {len(pts)}pts"
948 """Clear all references and per-channel calibration data. User can start fresh."""
950 for key, rcf
in self.
robust.items():
951 rcf.calibration_points.clear()
952 rcf.theta_mean = rcf.population_prior_mean.copy()
953 rcf.theta_cov = rcf.individual_prior_cov.copy()
954 rcf.rls_P = np.eye(rcf.n_params) * 100.0
955 rcf.bias_model.b = np.zeros(3)
956 rcf.bias_model.P_b = np.eye(3) * 0.01
957 rcf.inflation_factor = 1.0
958 logger.info(
"🗑️ Calibration cleared — references and points reset")
961 """Paper Section 6: consensus + agreement + self-cal when α≥0.6, agreement>0.6."""
976 alpha, _ = self.
robust[key].get_autonomy_score()
979 w = alpha / (sigma**2 + 1e-6)
980 active.append((key, pred, sigma, w))
985 self.
robust[key].inflation_factor = 1.0
989 w_sum = sum(w
for _, _, _, w
in active)
990 p_consensus = sum(p * w
for _, p, _, w
in active) / w_sum
991 sigma_consensus = 1.0 / np.sqrt(w_sum)
995 for i, (_, pi, si, _)
in enumerate(active):
996 for j, (_, pj, sj, _)
in enumerate(active):
999 d2 = (pi - pj) ** 2 / (si**2 + sj**2 + 1e-12)
1000 agreement *= np.exp(-d2 / (2 * len(active) * (len(active) - 1)))
1004 inflate = 2.0 - agreement
1007 self.
robust[key].inflation_factor = inflate
1011 deflate = 0.5 + 0.5 * agreement
1014 self.
robust[key].inflation_factor = deflate
1017 for key, pred, sigma, w
in active:
1019 alpha, _ = rcf.get_autonomy_score()
1023 if not buf
or len(buf) < 3:
1025 mean_adc = float(np.mean(list(buf)))
1026 unc = sigma_consensus * (1 + (1 - alpha) /
max(alpha, 0.01))
1027 pt = RobustCalPoint(
1029 pressure=p_consensus,
1034 rcf.add_calibration_point(pt)
1035 self.
stats[
"phase1_points"] = self.
stats.get(
"phase1_points", 0) + 1
1038 phi = rcf.environmental_robust_basis_functions(mean_adc, self.
env_state)
1039 self.
engine.add_calibration_point(idx, phi, p_consensus, unc)
1041 f
"Self-cal: {key} α={alpha:.2f} agreement={agreement:.2f} p={p_consensus:.1f}"
1046 """When human provides zero for one PT, add (v_k, 0, 0.01) for all other PTs of same type."""
1047 for key, rcf
in self.
robust.items():
1048 if key[0] != stype
or key == calibrated_key:
1051 if buf
and len(buf) >= 3:
1052 mean_adc = float(np.mean(list(buf)))
1053 pt = RobustCalPoint(
1060 rcf.add_calibration_point(pt)
1062 f
" Zero propagated: {key[0]} CH{key[1]} ← (adc={mean_adc:.0f}, 0 PSI)"
1067 """Save calibration constants to JSON (and CSV) in config calibration path."""
1073 pop_prior = self.
engine.export_learned_prior()
1075 for key, rcf
in self.
robust.items():
1078 "theta_mean": rcf.theta_mean.tolist(),
1079 "theta_cov": rcf.theta_cov.tolist(),
1080 "n_points": len(rcf.calibration_points),
1084 "population_mean": pop_prior.get(
"prior_mean", []),
1085 "population_covariance": pop_prior.get(
"prior_covariance", []),
1086 "effective_sample_size": pop_prior.get(
"effective_sample_size", 0),
1088 pt_states=pt_states,
1089 metadata={
"phase": self.
phase,
"stats": self.
stats},
1094 for key, rcf
in self.
robust.items():
1100 has_points = len(rcf.calibration_points) >= self.
min_points
1101 has_rls = getattr(rcf,
"rls_updates", 0) > 0
1102 if has_points
or has_rls:
1103 calibrated[ch] = rcf
1107 ts = datetime.now().strftime(
"%Y%m%d_%H%M%S")
1110 jp = info.calibration_dir / f
"{stype.lower()}_calibration_{ts}.json"
1112 "sensor_type": stype,
1114 "framework":
"robust_bayesian_tls",
1115 "n_params": N_PARAMS,
1116 "created": datetime.now().isoformat(),
1117 "phase": self.
phase,
1118 "calibration_parameters": {},
1121 for ch, rcf
in sorted(calibrated.items()):
1122 summary = rcf.get_calibration_summary()
1123 data[
"calibration_parameters"][str(ch)] = {
1124 "theta_mean": rcf.theta_mean.tolist(),
1125 "theta_cov": rcf.theta_cov.tolist(),
1126 "rls_P": rcf.rls_P.tolist(),
1128 data[
"quality"][str(ch)] = {
1129 "confidence": summary.get(
"confidence_level",
"UNKNOWN"),
1130 "num_points": summary.get(
"calibration_points", 0),
1131 "rmse": summary.get(
"rmse", 0.0),
1132 "max_residual": summary.get(
"max_residual", 0.0),
1133 "forgetting_factor": rcf.forgetting_factor,
1139 key_to_packet = build_orchestrator_key_to_packet_ch()
1140 data[
"calibration_polynomials"] = {}
1141 for ch, rcf
in sorted(calibrated.items()):
1144 t[3] / 1e27
if len(t) > 3
else 0,
1145 t[2] / 1e18
if len(t) > 2
else 0,
1146 t[1] / 1e9
if len(t) > 1
else 0,
1147 t[0]
if len(t) > 0
else 0,
1149 packet_ch = key_to_packet.get((stype, ch), ch)
1150 data[
"calibration_polynomials"][str(packet_ch)] = leg
1152 with open(jp,
"w")
as f:
1153 json.dump(data, f, indent=2)
1156 cp = info.calibration_dir / f
"{stype.lower()}_calibration_{ts}.csv"
1157 cols = [
"Timestamp"]
1158 for ch
in sorted(calibrated.keys()):
1159 for ci
in range(N_PARAMS):
1160 cols.append(f
"{stype}{ch} Param {ci}")
1161 row = [datetime.now().isoformat()]
1162 for ch
in sorted(calibrated.keys()):
1163 theta = calibrated[ch].theta_mean
1164 for ci
in range(N_PARAMS):
1165 row.append(f
"{theta[ci]:.15e}")
1166 with open(cp,
"w")
as f:
1167 f.write(
",".join(cols) +
"\n")
1168 f.write(
",".join(row) +
"\n")
1170 logger.info(f
"💾 {stype}: {jp.name} ({len(calibrated)} channels)")
1171 self.
stats[
"saves"] += 1
1177 """Save the learned population prior for warm-starting future sessions."""
1178 prior_path = Path(__file__).parent /
"calibrations" /
"learned_prior.json"
1179 prior_path.parent.mkdir(parents=
True, exist_ok=
True)
1181 prior_data = self.
engine.export_learned_prior()
1182 prior_data[
"saved_at"] = datetime.now().isoformat()
1183 engine_status = self.
engine.get_system_status()
1184 prior_data[
"system_status"] = {
1185 "total_calibrations": engine_status[
"total_calibrations"],
1186 "prior_confidence": engine_status[
"prior_confidence"],
1187 "average_sensor_quality": engine_status[
"average_sensor_quality"],
1188 "drift_rate": engine_status[
"drift_rate"],
1190 with open(prior_path,
"w")
as f:
1191 json.dump(prior_data, f, indent=2)
1193 f
"💾 Prior saved: ESS={prior_data['effective_sample_size']:.1f}"
1195 except Exception
as e:
1196 logger.error(f
"Save prior: {e}")
1200 Load polynomial calibration (calibration_poly_coeffs + adc_norm_min/scale)
1201 from calibration/ or calibration_backups/ and use as initial theta for RCFs.
1202 Fixes 150 PSI reading as 56.8 — the default prior theta[1]=200 has wrong slope.
1204 if _cfg_loaded
and not load_config().get(
"calibration", {}).get(
1205 "prior_from_polynomial",
True
1208 from config_loader
import get_repo_root
1210 repo = get_repo_root()
1212 repo /
"calibration",
1213 repo /
"calibration_backups",
1214 repo /
"scripts" /
"calibration" /
"calibrations",
1219 for cal_dir
in search_dirs:
1220 if not cal_dir.is_dir():
1223 cal_dir.glob(
"*.json"), key=os.path.getmtime, reverse=
True
1225 if "learned_prior" in fp.name:
1230 p = data.get(
"calibration_poly_coeffs")
or data.get(
1231 "calibration_polynomials"
1233 m = data.get(
"calibration_adc_norm_min")
or {}
1234 s = data.get(
"calibration_adc_norm_scale")
or {}
1236 for k, v
in p.items():
1237 if isinstance(v, (list, tuple))
and len(v) >= 2:
1240 adc_min[k] = float(m[k])
1242 adc_scale[k] = float(s[k])
1253 def cal_key_to_unique_ch(k: str) -> Optional[int]:
1256 return 2100 + (n - 100)
1257 if 1100 <= n <= 1199:
1258 return 2200 + (n - 1100)
1261 for ch_str, coeffs
in polys.items():
1262 uch = cal_key_to_unique_ch(ch_str)
1266 if key
not in self.
robust:
1268 mn = adc_min.get(ch_str)
1269 sc = adc_scale.get(ch_str)
1270 if mn
is None or sc
is None or sc <= 0:
1272 self.
robust[key].set_theta_from_polynomial(coeffs, mn, sc)
1274 logger.info(f
"📐 Loaded polynomial priors for {len(polys)} PT channels")
1277 """Load latest calibrations + learned prior for cold-start Phase 2."""
1280 key_to_packet = build_orchestrator_key_to_packet_ch()
1282 for (stype, uch), pch
in key_to_packet.items():
1283 packet_to_key[(stype, pch)] = (stype, uch)
1286 if not info.calibration_dir.is_dir():
1289 info.calibration_dir.glob(f
"{stype.lower()}_calibration_*.json"),
1290 key=os.path.getmtime,
1296 with open(latest)
as f:
1298 params = data.get(
"calibration_parameters", {})
1299 polys = data.get(
"calibration_polynomials", {})
1301 for ch_str, pdata
in params.items():
1304 if key
not in self.
robust:
1307 theta = np.array(pdata[
"theta_mean"])
1308 cov = np.array(pdata[
"theta_cov"])
1310 theta = np.concatenate([theta, [0.0, 0.0, 0.0]])
1311 cov = np.pad(cov, ((0, 3), (0, 3)), constant_values=0.1)
1312 rcf.theta_mean = theta
1314 if "rls_P" in pdata:
1315 rls_P = np.array(pdata[
"rls_P"])
1316 if rls_P.shape[0] == 6:
1318 rls_P, ((0, 3), (0, 3)), constant_values=100.0
1323 if not params
and polys
and stype ==
"PT":
1324 for pch_str, coeffs
in polys.items():
1327 key = packet_to_key.get((stype, int(pch_str)))
1330 if key
not in self.
robust:
1332 A, B, C, D = coeffs[0], coeffs[1], coeffs[2], coeffs[3]
1346 self.
robust[key].theta_mean = theta
1347 self.
robust[key].theta_cov = np.eye(9) * 0.1
1349 n = len(params)
or (len(polys)
if stype ==
"PT" else 0)
1351 logger.info(f
"📂 Loaded {stype}: {latest.name} ({n} channels)")
1352 except Exception
as e:
1353 logger.error(f
"Load {latest}: {e}")
1356 prior_path = Path(__file__).parent /
"calibrations" /
"learned_prior.json"
1357 if prior_path.exists():
1359 with open(prior_path)
as f:
1360 prior_data = json.load(f)
1361 self.
engine.import_learned_prior(prior_data)
1363 f
"📂 Loaded prior: ESS={prior_data.get('effective_sample_size', '?')}"
1365 except Exception
as e:
1366 logger.error(f
"Load prior: {e}")
1371 adj_path = Path(__file__).parent /
"calibrations" /
"adjustments.json"
1372 if adj_path.exists():
1374 with open(adj_path)
as f:
1375 adj_data = json.load(f)
1376 fw2 = adj_data.get(
"framework_v2", {})
1378 for unique_ch_str, pdata
in fw2.items():
1379 theta = pdata.get(
"theta_mean")
1380 cov = pdata.get(
"theta_cov")
1381 if not theta
or not cov:
1383 key = (
"PT", int(unique_ch_str))
1384 if key
not in self.
robust:
1389 t = np.concatenate([t, [0.0, 0.0, 0.0]])
1390 c = np.pad(c, ((0, 3), (0, 3)), constant_values=0.1)
1392 self.
robust[key].theta_mean = t
1393 if c.shape == (9, 9):
1394 self.
robust[key].theta_cov = c
1398 f
"📂 Loaded adjustments.json: {loaded_adj} PT channels "
1399 f
"(saved {adj_data.get('auto_saved_at', '?')})"
1401 except Exception
as e:
1402 logger.error(f
"Load adjustments.json: {e}")
1406 print(f
"\n{'═'*78}")
1407 print(f
" Calibration Orchestrator — Phase: {self.phase}")
1410 f
" Uptime: {uptime/60:.1f}m UDP: {self.receiver.stats['packets']} pkts / "
1411 f
"{self.receiver.stats['samples']} samples"
1414 f
" Cal pts: {self.stats['phase1_points']} "
1415 f
"TLS: {self.stats['tls_fits']} "
1416 f
"Bayes: {self.stats['bayesian_updates']} "
1417 f
"RLS: {self.stats['rls_updates']} "
1418 f
"Drifts: {self.stats['drifts_detected']} "
1419 f
"Alerts: {self.stats['active_learning_alerts']}"
1423 eng = self.
engine.get_system_status()
1425 f
" Engine: prior_conf={eng['prior_confidence']:.3f} "
1426 f
"avg_quality={eng['average_sensor_quality']:.3f} "
1427 f
"drift_rate={eng['drift_rate']:.2e} "
1428 f
"ESS={eng['effective_sample_size']:.1f}"
1434 f
" {stype} ({info.unit}) board={info.board_ip} ch={len(info.connectors)}"
1436 for ch
in info.connectors:
1441 if buf
and len(buf):
1442 mean_adc = int(np.mean(list(buf)))
1444 adc_str = f
"V={mean_v:>8.5f}"
1449 ref_str = f
" ref={ref:.2f}" if ref
is not None else ""
1451 npts = len(rcf.calibration_points)
1452 conf = rcf.get_confidence_level()
1453 summary = rcf.get_calibration_summary()
1454 rmse = summary.get(
"rmse", 0.0)
1458 if buf
and len(buf):
1459 mean_adc_code = int(np.mean(list(buf)))
1460 pred, sigma = rcf.predict_pressure_with_uncertainty(
1463 pred_str = f
" →{pred:>8.2f}±{sigma:.2f}"
1466 src =
"DIRECT" if npts > 0
else "PRIOR "
1469 f
" CH{ch:>2d}: {src} {npts:>3d}pts [{conf:<7s}] "
1470 f
"RMSE={rmse:<8.4f} {adc_str}{pred_str}{ref_str}"
1472 print(f
"{'═'*78}\n")