STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
calibration_server.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2import asyncio
3import json
4import logging
5import math
6import socket
7import struct
8import threading
9import time
10from http.server import HTTPServer, BaseHTTPRequestHandler
11from typing import Any, Optional
12import websockets
13from urllib.parse import urlparse
14from pathlib import Path
15
16import sys
17
18sys.path.append(str(Path(__file__).resolve().parent.parent.parent))
19
20from scripts.calibration.calibration_orchestrator import ( # noqa: E402
21 CalibrationOrchestrator,
22)
23from scripts.calibration.robust_calibration import ( # noqa: E402
24 CalibrationPoint,
25 EnvironmentalState,
26)
27from scripts.calibration.sense_conversions import ( # noqa: E402
28 raw_to_physical,
29 hp_pt_adc_to_psi,
30)
31from scripts.calibration.config_loader import ( # noqa: E402
32 load_config,
33 build_channel_to_orchestrator_key,
34 get_hp_pt_packet_channels,
35 decode_board_namespaced_low,
36 packet_ch_for_board_connector,
37)
38
39logging.basicConfig(
40 level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
41)
42logger = logging.getLogger("CalibrationServer")
43
44# Single source of truth for config: config/config.toml via shared loader.
45config = load_config()
46
47
49 """Minimal TCP client that writes TABLE packets directly to Elodin DB."""
50
51 def __init__(self, host: str = "127.0.0.1", port: int = 2240):
52 self.host = host
53 self.port = port
54 self.sock: Optional[socket.socket] = None
55 self.connected = False
56
57 def connect(self) -> bool:
58 try:
59 self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
60 self.sock.settimeout(3.0)
61 self.sock.connect((self.host, self.port))
62 self.connected = True
63 logger.info(f"[ElodinWriter] Connected to {self.host}:{self.port}")
64 return True
65 except Exception as e:
66 logger.warning(f"[ElodinWriter] Connect failed: {e}")
67 self.sock = None
68 self.connected = False
69 return False
70
72 self,
73 connector_id: int,
74 pressure_psi: float,
75 raw_adc: int,
76 *,
77 raw_packet_low: int,
78 ) -> bool:
79 """
80 Write calibrated PT to Elodin. Packet low byte = raw_packet_low + 0x10 (board-namespaced),
81 matching FSW calibration_service / daq_bridge (NOT legacy 0x10 + global_ch).
82 """
83 return self._write_calibrated(
84 0x20, raw_packet_low, connector_id, pressure_psi, raw_adc
85 )
86
88 self,
89 high: int,
90 raw_packet_low: int,
91 connector_id: int,
92 value: float,
93 raw_counts: int,
94 ) -> bool:
95 """21-byte TABLE row; cal packet id [high, raw_low + 0x10] (same rule as C++ cal_main)."""
96 if not self.connected or not self.sock:
97 return False
98 try:
99 cal_low = (raw_packet_low + 0x10) & 0xFF
100 ts_ns = time.time_ns()
101 payload = struct.pack("<Q", ts_ns)
102 payload += struct.pack("<B", connector_id & 0xFF)
103 payload += bytes(3)
104 payload += struct.pack("<f", value)
105 payload += struct.pack("<I", raw_counts & 0xFFFFFFFF)
106 payload += struct.pack("<B", 0)
107 header = struct.pack("<I", len(payload) + 4)
108 header += struct.pack("<B", 1) # TABLE
109 header += struct.pack("<BB", high, cal_low)
110 header += struct.pack("<B", 0)
111 self.sock.sendall(header + payload)
112 return True
113 except Exception as e:
114 logger.debug(f"[ElodinWriter] Write failed: {e}")
115 self.sock = None
116 self.connected = False
117 return False
118
120 self,
121 connector_id: int,
122 temperature_c: float,
123 raw_adc: int,
124 *,
125 raw_packet_low: int,
126 ) -> bool:
127 return self._write_calibrated(
128 0x21, raw_packet_low, connector_id, temperature_c, raw_adc
129 )
130
132 self,
133 connector_id: int,
134 temperature_c: float,
135 raw_counts: int,
136 *,
137 raw_packet_low: int,
138 ) -> bool:
139 return self._write_calibrated(
140 0x22, raw_packet_low, connector_id, temperature_c, raw_counts
141 )
142
144 self, connector_id: int, force: float, raw_adc: int, *, raw_packet_low: int
145 ) -> bool:
146 return self._write_calibrated(
147 0x23, raw_packet_low, connector_id, force, raw_adc
148 )
149
150
151def _parse_raw_adc(payload: bytes) -> Optional[int]:
152 """Extract raw ADC uint32 from a raw PT/TC/RTD payload (21-byte format)."""
153 if len(payload) >= 21:
154 return struct.unpack_from("<I", payload, 12)[0]
155 if len(payload) >= 12:
156 return struct.unpack_from("<I", payload, 8)[0]
157 return None
158
159
160def _parse_raw_signed(payload: bytes) -> Optional[int]:
161 """Extract raw ADC int32 (for LC signed ADC)."""
162 if len(payload) >= 21:
163 return struct.unpack_from("<i", payload, 12)[0]
164 return None
165
166
167def _parse_int32_at_12(payload: bytes) -> Optional[int]:
168 """Signed ADC at offset 12 (PT/LP, TC, RTD raw on wire)."""
169 if len(payload) >= 21:
170 return struct.unpack_from("<i", payload, 12)[0]
171 return None
172
173
174# Per-channel write throttle (configurable, default 100 Hz)
175_last_elodin_write: dict = {}
176_first_calibrated_write_logged: set = set()
177_hp_pt_channels_cache: dict = {} # {packet_ch: hp_pt_cfg} — populated lazily
178
179# ── EMA (exponential moving average) noise filter ────────────────────────────
180# Applied to LP PT channels before writing calibrated value to Elodin.
181# alpha=1.0 disables filtering; lower values smooth more at the cost of lag.
182_EMA_ALPHA_DEFAULT = 0.4 # ~4 Hz effective bandwidth at 20 Hz sample rate
183_ema_state: dict = {} # packet_ch → last EMA value
184
185
186def _apply_ema(key: int, value: float, alpha: float = _EMA_ALPHA_DEFAULT) -> float:
187 prev = _ema_state.get(key)
188 if prev is None or not math.isfinite(prev):
189 _ema_state[key] = value
190 return value
191 ema = alpha * value + (1.0 - alpha) * prev
192 _ema_state[key] = ema
193 return ema
194
195
196def _reset_ema_for_packet_channels(packet_channels: list) -> None:
197 """Clear EMA state for a list of packet_ch values so corrected values take effect immediately."""
198 for pch in packet_channels:
199 _ema_state.pop(pch, None)
200
201
203 key: tuple,
204 adc_code: float,
205 target_psi: float,
206) -> bool:
207 """
208 Directly correct theta_mean[0] (the D intercept) so the RCF outputs target_psi
209 at the given adc_code without disturbing the slope coefficients.
210 Returns True if the correction was applied.
211 """
212 rcf = state.orchestrator.robust.get(key)
213 if rcf is None:
214 return False
215 try:
216 current_pred, _ = rcf.predict_pressure_with_uncertainty(
217 float(adc_code), state.env_state
218 )
219 if not math.isfinite(current_pred):
220 return False
221 delta = current_pred - target_psi
222 rcf.theta_mean[0] -= delta
223 # Tighten covariance on the offset term to reflect this high-confidence fix.
224 rcf.theta_cov[0, 0] = max(rcf.theta_cov[0, 0] * 0.1, 1e-4)
225 return True
226 except Exception:
227 return False
228
229
230def _parse_pt_raw_for_packet_ch(payload: bytes, packet_ch: int) -> Optional[int]:
231 """4-20 mA PT: unsigned ADC; LP PT: signed ADS1262 counts (matches FSW)."""
232 if len(payload) < 21:
233 return None
234 if not _hp_pt_channels_cache:
235 _hp_pt_channels_cache.update(get_hp_pt_packet_channels())
236 if packet_ch in _hp_pt_channels_cache:
237 return struct.unpack_from("<I", payload, 12)[0]
238 return struct.unpack_from("<i", payload, 12)[0]
239
240
241def _throttle_key(stype: str, ch: int) -> str:
242 return f"{stype}:{ch}"
243
244
245_raw_conversion_config_cache: dict = {}
246
247
249 """Get config for raw sense_conversions fallback (cached)."""
250 if not _raw_conversion_config_cache:
251 cal = config.get("calibration", {})
252 rtd_cfg = cal.get("rtd", {})
253 tc_cfg = cal.get("tc", {})
254 lc_cfg = cal.get("lc", {})
255 _raw_conversion_config_cache.update(
256 {
257 "rtd_r0": rtd_cfg.get("r0_ohm", 1000.0),
258 "rtd_adc_ref_v": rtd_cfg.get("adc_ref_voltage", 2.5),
259 "rtd_excitation_ua": rtd_cfg.get("excitation_ua", 1000.0),
260 "tc_adc_ref_v": tc_cfg.get("adc_ref_voltage", 2.5),
261 "lc_sensitivity_mv_per_v": lc_cfg.get("sensitivity_mv_per_v", 2.0),
262 "lc_pga_gain": lc_cfg.get("pga_gain", 128.0),
263 "lc_full_scale_value": lc_cfg.get("full_scale_value", 100.0),
264 }
265 )
266 return _raw_conversion_config_cache
267
268
270 stype: str,
271 packet_ch: int,
272 raw_val: int,
273 writer: Any,
274 channel_to_key: dict,
275 *,
276 connector_id: int,
277 raw_packet_low: int,
278) -> None:
279 """Compute calibrated value (RCF or raw conversion) and optionally write to Elodin DB."""
280 sidecar_cfg = config.get("calibration", {}).get("sidecar", {})
281 if not sidecar_cfg.get("write_to_elodin", True):
282 return
283
284 interval = float(sidecar_cfg.get("write_interval_sec", 0.01))
285 now = time.monotonic()
286 throttle_key = _throttle_key(stype, packet_ch)
287 if now - _last_elodin_write.get(throttle_key, 0) < interval:
288 return
289
290 # Ensure caches are populated (done once per process lifetime)
291 if not _hp_pt_channels_cache:
292 _hp_pt_channels_cache.update(get_hp_pt_packet_channels())
293
294 pred = None
295 is_hp_pt = stype == "PT" and packet_ch in _hp_pt_channels_cache
296 key = channel_to_key.get((stype, packet_ch))
297
298 # Path 0H: HP PT (4-20 mA) — bypass RCF entirely; the orchestrator's polynomial
299 # is calibrated for LP PT voltage-mode inputs and produces garbage for HP PT.
300 # board_simulator.py generates valid 4-20 mA ADC codes for current-loop connectors, so
301 # hp_pt_adc_to_psi is correct in both sim and real-hardware modes.
302 if is_hp_pt:
303 hp_cfg = _hp_pt_channels_cache[packet_ch]
304 pred = hp_pt_adc_to_psi(
305 raw_val,
306 hp_cfg["full_scale_psi"],
307 hp_cfg["sense_resistor_ohms"],
308 hp_cfg["adc_ref_voltage"],
309 )
310
311 else:
312 # Path 1: Reuse fresh prediction from _online_update (avoids duplicate predict)
313 if key and key in state.orchestrator.latest_predictions:
314 p, _, ts = state.orchestrator.latest_predictions[key]
315 if now - ts < 0.5 and math.isfinite(p):
316 pred = p
317
318 # Path 2: Compute via RCF (LP PT polynomial / Bayesian model)
319 if pred is None and key and key in state.orchestrator.robust:
320 try:
321 rcf = state.orchestrator.robust[key]
322 pred, _unc = rcf.predict_pressure_with_uncertainty(
323 float(raw_val), state.env_state
324 )
325 except Exception:
326 pass
327
328 # Path 4: Fallback to raw physical conversion for TC/RTD/LC/LP PT
329 if pred is None or not math.isfinite(pred):
331 pred = raw_to_physical(
332 stype,
333 raw_val,
334 channel_id=packet_ch,
335 rtd_r0=cfg["rtd_r0"],
336 rtd_adc_ref_v=cfg["rtd_adc_ref_v"],
337 rtd_excitation_ua=cfg["rtd_excitation_ua"],
338 tc_adc_ref_v=cfg["tc_adc_ref_v"],
339 lc_sensitivity_mv_per_v=cfg["lc_sensitivity_mv_per_v"],
340 lc_pga_gain=cfg["lc_pga_gain"],
341 lc_full_scale_value=cfg["lc_full_scale_value"],
342 )
343
344 # EMA noise filter — only for LP PT (noisy ratiometric ADC)
345 if pred is not None and math.isfinite(pred) and stype == "PT":
346 sidecar_cfg2 = config.get("calibration", {}).get("sidecar", {})
347 alpha = float(sidecar_cfg2.get("ema_alpha", _EMA_ALPHA_DEFAULT))
348 pred = _apply_ema(packet_ch, pred, alpha)
349
350 if pred is None or not math.isfinite(pred):
351 return
352
353 _last_elodin_write[throttle_key] = now
354 if not writer.connected:
355 writer.connect()
356
357 if stype == "PT":
358 writer.write_calibrated_pt(
359 connector_id, float(pred), raw_val, raw_packet_low=raw_packet_low
360 )
361 elif stype == "TC":
362 writer.write_calibrated_tc(
363 connector_id, float(pred), raw_val, raw_packet_low=raw_packet_low
364 )
365 elif stype == "RTD":
366 writer.write_calibrated_rtd(
367 connector_id, float(pred), raw_val, raw_packet_low=raw_packet_low
368 )
369 elif stype == "LC":
370 writer.write_calibrated_lc(
371 connector_id, float(pred), raw_val, raw_packet_low=raw_packet_low
372 )
373 k = (stype, packet_ch)
374 if k not in _first_calibrated_write_logged:
375 _first_calibrated_write_logged.add(k)
376 logger.info(
377 f"[Cal] First calibrated write: {stype} packet_ch={packet_ch} → {pred:.2f}"
378 )
379
380
381_ADJUSTMENTS_PATH = Path(__file__).resolve().parent / "calibrations" / "adjustments.json"
382
383
385 """
386 Persist all RLS-learned theta_mean / theta_cov back to adjustments.json so
387 that the next server restart loads the latest autonomous calibration state.
388 Channels are keyed by unique_id = board_id * 100 + connector (framework_v2 format).
389 Only channels that have an active RCF entry are written; any existing channels
390 without an RCF entry are preserved.
391 """
392 rcf_map = state.orchestrator.robust # {(stype, unique_ch): RCF}
393 if not rcf_map:
394 return
395
396 # Load existing data so we don't clobber unrelated keys
397 existing: dict = {}
398 if _ADJUSTMENTS_PATH.exists():
399 try:
400 with open(_ADJUSTMENTS_PATH) as f:
401 existing = json.load(f)
402 except Exception:
403 pass
404
405 fw2 = existing.get("framework_v2", {})
406 for (stype, unique_ch), rcf in rcf_map.items():
407 if stype != "PT":
408 continue # only PT uses this format currently
409 key_str = str(unique_ch)
410 fw2[key_str] = {
411 "theta_mean": rcf.theta_mean.tolist(),
412 "theta_cov": rcf.theta_cov.tolist(),
413 "rls_updates": getattr(rcf, "rls_updates", 0),
414 "saved_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
415 }
416
417 existing["framework_v2"] = fw2
418 existing["auto_saved_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
419 _ADJUSTMENTS_PATH.parent.mkdir(parents=True, exist_ok=True)
420 tmp = _ADJUSTMENTS_PATH.with_suffix(".json.tmp")
421 with open(tmp, "w") as f:
422 json.dump(existing, f, indent=2)
423 tmp.replace(_ADJUSTMENTS_PATH)
424 logger.info(f"[AutoSave] adjustments.json updated ({len(fw2)} PT channels)")
425
426
428 """
429 Subscribe to the Elodin Relay WebSocket to get raw ADC data.
430 Feeds the calibration engine and writes calibrated values back to Elodin DB.
431 The relay is the sole subscriber to Elodin DB and fans out TABLE packets here.
432 """
433 sidecar_cfg = config.get("calibration", {}).get("sidecar", {})
434 relay_host = sidecar_cfg.get("relay_host", "127.0.0.1")
435 relay_port = sidecar_cfg.get("relay_port", 9090)
436 relay_url = f"ws://{relay_host}:{relay_port}"
437
438 elodin_cfg = config.get("database", {})
439 elodin_host = elodin_cfg.get("host", "127.0.0.1")
440 elodin_port = elodin_cfg.get("port", 2240)
441 writer = ElodinWriter(elodin_host, elodin_port)
442 channel_to_key = build_channel_to_orchestrator_key()
443 _we = sidecar_cfg.get("write_to_elodin", True)
444 logger.info(
445 f"[Relay] Relay subscriber starting → {relay_url} (channel map: {len(channel_to_key)} entries, "
446 f"write_to_elodin={_we})"
447 )
448
449 adj_save_interval = float(
450 config.get("calibration", {}).get("sidecar", {}).get(
451 "adjustments_save_interval_sec", 120.0
452 )
453 )
454 _last_adj_save = time.monotonic()
455
456 while True:
457 try:
458 async with websockets.connect(relay_url, ping_interval=20) as ws:
459 logger.info(f"[Relay] Connected to relay at {relay_url}")
460 async for message in ws:
461 if not isinstance(message, bytes) or len(message) < 8:
462 continue
463 ty = message[4]
464 high = message[5]
465 low = message[6]
466 payload = message[8:]
467
468 if ty != 1: # TABLE = 1
469 continue
470
471 stype = None
472 raw_val = None
473 connector_id: Optional[int] = None
474 packet_ch: Optional[int] = None
475
476 dec = decode_board_namespaced_low(low)
477 if dec is None:
478 continue
479 board_slot, connector_id, is_raw = dec
480 if not is_raw:
481 continue
482
483 if high == 0x20:
484 stype = "PT"
485 packet_ch = packet_ch_for_board_connector(
486 "PT", board_slot, connector_id
487 )
488 if packet_ch is not None:
489 raw_val = _parse_pt_raw_for_packet_ch(payload, packet_ch)
490 elif high == 0x21:
491 stype = "TC"
492 packet_ch = packet_ch_for_board_connector(
493 "TC", board_slot, connector_id
494 )
495 if packet_ch is not None:
496 raw_val = _parse_int32_at_12(payload)
497 elif high == 0x22:
498 stype = "RTD"
499 packet_ch = packet_ch_for_board_connector(
500 "RTD", board_slot, connector_id
501 )
502 if packet_ch is not None:
503 raw_val = _parse_int32_at_12(payload)
504 elif high == 0x23:
505 stype = "LC"
506 packet_ch = packet_ch_for_board_connector(
507 "LC", board_slot, connector_id
508 )
509 if packet_ch is not None:
510 raw_val = _parse_raw_signed(payload)
511
512 if (
513 stype is None
514 or raw_val is None
515 or packet_ch is None
516 or connector_id is None
517 ):
518 continue
519
520 key = channel_to_key.get((stype, packet_ch))
521 # HP PT channels use a direct conversion, not the RCF polynomial.
522 # Feeding their raw codes into _online_update would corrupt the Bayesian
523 # model (the polynomial is calibrated for LP PT voltage-mode).
524 # Also skip HP PT online update in sim mode (FSW handles those channels).
525 _is_hp = stype == "PT" and packet_ch in _hp_pt_channels_cache
526 if key and key in state.orchestrator.robust and not _is_hp:
527 state.orchestrator._online_update(key, float(raw_val))
528
530 stype,
531 packet_ch,
532 raw_val,
533 writer,
534 channel_to_key,
535 connector_id=connector_id,
536 raw_packet_low=low,
537 )
538
539 # Periodic auto-save of RLS-learned calibration to adjustments.json
540 _now = time.monotonic()
541 if _now - _last_adj_save >= adj_save_interval:
542 _last_adj_save = _now
543 try:
545 except Exception as _se:
546 logger.warning(f"[Relay] adjustments auto-save failed: {_se}")
547
548 except Exception as e:
549 logger.warning(f"[Relay] Disconnected ({e}), retrying in 5s...")
550 await asyncio.sleep(5)
551
552
554 def __init__(self):
555 self.orchestrator = CalibrationOrchestrator()
556 self.orchestrator.load_existing()
557 self.orchestrator.run_phase2_background()
558
559 # Load env state from config
560 env_cfg = config.get("calibration", {}).get("environmental", {})
561 self.env_state = EnvironmentalState(
562 temperature=env_cfg.get("temperature", 25.0),
563 humidity=env_cfg.get("humidity", 50.0),
564 vibration=env_cfg.get("vibration", 0.0),
565 aging_factor=env_cfg.get("aging_factor", 1.0),
566 mounting_torque=env_cfg.get("mounting_torque", 1.0),
567 )
568 self.orchestrator.env_state = self.env_state
569
570 self.ws_clients = set()
571
572
573state = ServerState()
574
575
576class CalibrationHTTPRequestHandler(BaseHTTPRequestHandler):
578 self.send_header("Access-Control-Allow-Origin", "*")
579 self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
580 self.send_header("Access-Control-Allow-Headers", "Content-Type")
581
582 def do_OPTIONS(self):
583 self.send_response(200, "ok")
584 self._send_cors_headers()
585 self.end_headers()
586
587 def do_GET(self):
588 parsed = urlparse(self.path)
589 path = parsed.path
590
591 if path == "/api/status":
592 self.send_response(200)
593 self._send_cors_headers()
594 self.send_header("Content-Type", "application/json")
595 self.end_headers()
596
597 channels = []
598 for key, rcf in state.orchestrator.robust.items():
599 if key[0] not in ("PT", "TC", "RTD", "LC"):
600 continue
601 channel_id = key[1]
602 idx = state.orchestrator._key_to_idx.get(key)
603
604 # Get autonomous learning status (new engine API may not expose per-sensor history)
605 drift = False
606 try:
607 # Prefer engine-per-sensor drift status if available
608 if (
609 hasattr(state.orchestrator.engine, "get_sensor_status")
610 and idx is not None
611 ):
612 sensor_status = state.orchestrator.engine.get_sensor_status(idx)
613 if sensor_status is not None:
614 drift = bool(sensor_status.get("drift_detected", False))
615 # Fallback: use last GLR value on the robust framework
616 except Exception:
617 drift = False
618
619 summary = rcf.get_calibration_summary()
620
621 glr = 0.0
622 if hasattr(rcf, "_last_glr"):
623 glr = rcf._last_glr
624
625 channels.append(
626 {
627 "sensorType": key[0],
628 "sensorId": channel_id,
629 "updateCount": len(rcf.calibration_points),
630 "rlsUpdateCount": len(rcf.calibration_points),
631 "lastUpdate": 0,
632 "driftDetected": drift,
633 "meanResidual": summary.get("rmse", 0.0),
634 "glrStat": glr,
635 "confidence": summary.get("confidence_level", "UNCALIBRATED"),
636 "coeffs": {
637 "A": (
638 summary.get("parameters", [0, 0, 0, 0, 0, 0])[3]
639 if len(summary.get("parameters", [])) > 3
640 else 0
641 ),
642 "B": (
643 summary.get("parameters", [0, 0, 0, 0, 0, 0])[2]
644 if len(summary.get("parameters", [])) > 2
645 else 0
646 ),
647 "C": (
648 summary.get("parameters", [0, 0, 0, 0, 0, 0])[1]
649 if len(summary.get("parameters", [])) > 1
650 else 0
651 ),
652 "D": (
653 summary.get("parameters", [0, 0, 0, 0, 0, 0])[0]
654 if len(summary.get("parameters", [])) > 0
655 else 0
656 ),
657 },
658 "phase2Active": True,
659 "covarianceTrace": sum(summary.get("uncertainty", [0])),
660 "bayesianConfidence": (
661 1.0 if summary.get("confidence_level") == "MAXIMUM" else 0.5
662 ),
663 "populationPriorActive": True,
664 "sidecarConnected": True,
665 }
666 )
667
668 payload = {"channels": channels, "phase2Enabled": True, "timestamp": 0}
669 self.wfile.write(json.dumps(payload).encode("utf-8"))
670
671 elif path.startswith("/api/coefficients/"):
672 try:
673 ch = int(path.split("/")[-1])
674 key = ("PT", ch)
675 if key in state.orchestrator.robust:
676 rcf = state.orchestrator.robust[key]
677 summary = rcf.get_calibration_summary()
678 self.send_response(200)
679 self._send_cors_headers()
680 self.send_header("Content-Type", "application/json")
681 self.end_headers()
682 self.wfile.write(json.dumps(summary).encode("utf-8"))
683 else:
684 self.send_response(404)
685 self._send_cors_headers()
686 self.end_headers()
687 except ValueError:
688 self.send_response(400)
689 self.end_headers()
690
691 elif path == "/api/environmental":
692 self.send_response(200)
693 self._send_cors_headers()
694 self.send_header("Content-Type", "application/json")
695 self.end_headers()
696 self.wfile.write(
697 json.dumps(
698 {
699 "temperature": state.env_state.temperature,
700 "humidity": state.env_state.humidity,
701 "vibration": state.env_state.vibration,
702 "aging_factor": state.env_state.aging_factor,
703 "mounting_torque": state.env_state.mounting_torque,
704 }
705 ).encode("utf-8")
706 )
707
708 else:
709 self.send_response(404)
710 self._send_cors_headers()
711 self.end_headers()
712
713 def do_POST(self):
714 parsed = urlparse(self.path)
715 path = parsed.path
716 length = int(self.headers.get("Content-Length", 0))
717 data = self.rfile.read(length)
718
719 try:
720 req = json.loads(data.decode("utf-8"))
721 except json.JSONDecodeError:
722 self.send_response(400)
723 self._send_cors_headers()
724 self.end_headers()
725 return
726
727 if path == "/api/calibrate":
728 ch = req.get("channel")
729 adc = req.get("adc_code")
730 ref_value = req.get("reference_value") or req.get("reference_psi")
731 sensor_type = req.get("sensor_type", "PT") # PT/TC/RTD/LC
732
733 channel_to_key = build_channel_to_orchestrator_key()
734 key = channel_to_key.get((sensor_type, ch), (sensor_type, ch))
735 if key in state.orchestrator.robust:
736 # Add calibration point (triggers TLS/Bayesian update in RCF)
737 pt = CalibrationPoint(
738 adc_code=adc,
739 pressure=ref_value,
740 timestamp=0,
741 environmental_state=state.env_state,
742 uncertainty=(
743 1e-3 if abs(ref_value) < 10 else 0.01
744 ), # Paper: human=10⁻⁶ σ²
745 )
746 res = state.orchestrator.robust[key].add_calibration_point(pt)
747 # For near-zero reference points apply a direct offset correction immediately
748 # (Bayesian update with one point converges slowly against the prior).
749 if abs(ref_value) < 50:
750 _apply_direct_offset_correction(key, float(adc), float(ref_value))
752 # Paper: zero-point propagation — when |p|<10 PSI, add (v_k,0,0.01) for all k≠j
753 if abs(ref_value) < 10:
754 state.orchestrator.propagate_zero_point(key, sensor_type)
755 state.orchestrator._save_all()
756 self.send_response(200)
757 self._send_cors_headers()
758 self.send_header("Content-Type", "application/json")
759 self.end_headers()
760 self.wfile.write(
761 json.dumps({"success": True, "result": res}).encode("utf-8")
762 )
763 asyncio.run_coroutine_threadsafe(
765 json.dumps(
766 {
767 "type": "coefficient_update",
768 "sensor_type": sensor_type,
769 "channel": ch,
770 }
771 )
772 ),
773 ws_loop,
774 )
775 else:
776 self.send_response(404)
777 self._send_cors_headers()
778 self.end_headers()
779
780 elif path == "/api/zero_all":
781 channels = req.get("channels", [])
782 channel_to_key = build_channel_to_orchestrator_key()
783 corrected_packet_chs = []
784 # Direct offset correction: shift theta_mean[0] so each sensor reads target_psi
785 # at its current ADC reading. This preserves the learned slope and is instantaneous.
786 # (Previously called clear_calibration() first which discarded all learned state.)
787 for ch_data in channels:
788 packet_ch = ch_data.get("id")
789 adc = ch_data.get("adc_code")
790 target = float(ch_data.get("target_psi", 0.0))
791 if packet_ch is None or adc is None:
792 continue
793 # Skip HP PT channels — those have hardware-level conversions
794 if not _hp_pt_channels_cache:
795 _hp_pt_channels_cache.update(get_hp_pt_packet_channels())
796 if packet_ch in _hp_pt_channels_cache:
797 continue
798 key = channel_to_key.get(("PT", packet_ch), ("PT", packet_ch))
799 if _apply_direct_offset_correction(key, float(adc), target):
800 corrected_packet_chs.append(packet_ch)
801 # Reset EMA so corrected value reaches the GUI immediately
802 _reset_ema_for_packet_channels(corrected_packet_chs)
803 state.orchestrator._save_all()
804 self.send_response(200)
805 self._send_cors_headers()
806 self.send_header("Content-Type", "application/json")
807 self.end_headers()
808 self.wfile.write(json.dumps({"success": True}).encode("utf-8"))
809
810 asyncio.run_coroutine_threadsafe(
812 json.dumps({"type": "coefficient_update", "channel": "all"})
813 ),
814 ws_loop,
815 )
816
817 elif path == "/api/environmental":
818 state.env_state.temperature = req.get(
819 "temperature", state.env_state.temperature
820 )
821 state.env_state.humidity = req.get("humidity", state.env_state.humidity)
822 state.env_state.vibration = req.get("vibration", state.env_state.vibration)
823 state.env_state.aging_factor = req.get(
824 "aging_factor", state.env_state.aging_factor
825 )
826 state.env_state.mounting_torque = req.get(
827 "mounting_torque", state.env_state.mounting_torque
828 )
829 state.orchestrator.env_state = state.env_state
830
831 self.send_response(200)
832 self._send_cors_headers()
833 self.send_header("Content-Type", "application/json")
834 self.end_headers()
835 self.wfile.write(json.dumps({"success": True}).encode("utf-8"))
836
837 elif path == "/api/adc_sample":
838 samples = req.get("samples", [])
839 channel_to_key = build_channel_to_orchestrator_key()
840 for sample in samples:
841 packet_ch = sample.get("channel")
842 adc = sample.get("adc")
843 if packet_ch is None or adc is None:
844 continue
845 # Map packet channel ID to orchestrator key (stype, unique_ch)
846 key = None
847 for stype in ("PT", "TC", "RTD", "LC"):
848 k = channel_to_key.get((stype, packet_ch))
849 if k and k in state.orchestrator.robust:
850 key = k
851 break
852 if key:
853 state.orchestrator._online_update(key, float(adc))
854
855 self.send_response(200)
856 self._send_cors_headers()
857 self.send_header("Content-Type", "application/json")
858 self.end_headers()
859 self.wfile.write(json.dumps({"success": True}).encode("utf-8"))
860
861 else:
862 self.send_response(404)
863 self._send_cors_headers()
864 self.end_headers()
865
866
867def run_http_server(host, port):
868 server = HTTPServer((host, port), CalibrationHTTPRequestHandler)
869 logger.info(f"Starting HTTP server on {host}:{port}")
870 server.serve_forever()
871
872
873async def ws_handler(websocket):
874 state.ws_clients.add(websocket)
875 try:
876 await websocket.wait_closed()
877 finally:
878 state.ws_clients.remove(websocket)
879
880
881async def broadcast_ws(message):
882 if state.ws_clients:
883 await asyncio.gather(*[client.send(message) for client in state.ws_clients])
884
885
886def run_ws_server(host, port):
887 global ws_loop
888 ws_loop = asyncio.new_event_loop()
889 asyncio.set_event_loop(ws_loop)
890
891 async def _run_ws():
892 async with websockets.serve(ws_handler, host, port):
893 logger.info(f"Starting WebSocket server on {host}:{port}")
894 asyncio.ensure_future(relay_subscriber_task())
895 await asyncio.Future() # run forever
896
897 ws_loop.run_until_complete(_run_ws())
898
899
900if __name__ == "__main__":
901 sidecar_cfg = config.get("calibration", {}).get("sidecar", {})
902 host = sidecar_cfg.get("host", "127.0.0.1")
903 port = sidecar_cfg.get("port", 8100)
904
905 http_thread = threading.Thread(
906 target=run_http_server, args=(host, port), daemon=True
907 )
908 ws_thread = threading.Thread(
909 target=run_ws_server, args=(host, port + 1), daemon=True
910 )
911
912 http_thread.start()
913 ws_thread.start()
914
915 try:
916 while True:
917 # Check for active learning alerts to broadcast.
918 # Atomically snapshot-and-clear to avoid losing alerts appended
919 # by the orchestrator thread between the loop and clear().
920 if state.orchestrator.pending_alerts:
921 alerts, state.orchestrator.pending_alerts = (
922 state.orchestrator.pending_alerts,
923 [],
924 )
925 for alert in alerts:
926 asyncio.run_coroutine_threadsafe(
928 json.dumps(
929 {
930 "type": "active_learning_alert",
931 "sensor_id": alert.sensor_id,
932 "reason": alert.reason,
933 "urgency": alert.urgency,
934 "confidence": alert.confidence,
935 }
936 )
937 ),
938 ws_loop,
939 )
940
941 time.sleep(1)
942 except KeyboardInterrupt:
943 logger.info("Shutting down...")
944 state.orchestrator.running = False
#define max(a, b)
Definition Arduino.h:43
bool write_calibrated_rtd(self, int connector_id, float temperature_c, int raw_counts, *, int raw_packet_low)
bool write_calibrated_pt(self, int connector_id, float pressure_psi, int raw_adc, *, int raw_packet_low)
bool write_calibrated_lc(self, int connector_id, float force, int raw_adc, *, int raw_packet_low)
bool write_calibrated_tc(self, int connector_id, float temperature_c, int raw_adc, *, int raw_packet_low)
__init__(self, str host="127.0.0.1", int port=2240)
bool _write_calibrated(self, int high, int raw_packet_low, int connector_id, float value, int raw_counts)
float _apply_ema(int key, float value, float alpha=_EMA_ALPHA_DEFAULT)
bool _apply_direct_offset_correction(tuple key, float adc_code, float target_psi)
Optional[int] _parse_raw_adc(bytes payload)
str _throttle_key(str stype, int ch)
Optional[int] _parse_int32_at_12(bytes payload)
None _reset_ema_for_packet_channels(list packet_channels)
Optional[int] _parse_pt_raw_for_packet_ch(bytes payload, int packet_ch)
Optional[int] _parse_raw_signed(bytes payload)
None _process_raw_and_write_calibrated(str stype, int packet_ch, int raw_val, Any writer, dict channel_to_key, *, int connector_id, int raw_packet_low)
static double read(int32_t code)