10from http.server
import HTTPServer, BaseHTTPRequestHandler
11from typing
import Any, Optional
13from urllib.parse
import urlparse
14from pathlib
import Path
18sys.path.append(str(Path(__file__).resolve().parent.parent.parent))
20from scripts.calibration.calibration_orchestrator
import (
21 CalibrationOrchestrator,
23from scripts.calibration.robust_calibration
import (
27from scripts.calibration.sense_conversions
import (
31from scripts.calibration.config_loader
import (
33 build_channel_to_orchestrator_key,
34 get_hp_pt_packet_channels,
35 decode_board_namespaced_low,
36 packet_ch_for_board_connector,
40 level=logging.INFO, format=
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
42logger = logging.getLogger(
"CalibrationServer")
49 """Minimal TCP client that writes TABLE packets directly to Elodin DB."""
51 def __init__(self, host: str =
"127.0.0.1", port: int = 2240):
54 self.
sock: Optional[socket.socket] =
None
59 self.
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
60 self.
sock.settimeout(3.0)
63 logger.info(f
"[ElodinWriter] Connected to {self.host}:{self.port}")
65 except Exception
as e:
66 logger.warning(f
"[ElodinWriter] Connect failed: {e}")
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).
84 0x20, raw_packet_low, connector_id, pressure_psi, raw_adc
95 """21-byte TABLE row; cal packet id [high, raw_low + 0x10] (same rule as C++ cal_main)."""
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)
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)
109 header += struct.pack(
"<BB", high, cal_low)
110 header += struct.pack(
"<B", 0)
111 self.
sock.sendall(header + payload)
113 except Exception
as e:
114 logger.debug(f
"[ElodinWriter] Write failed: {e}")
122 temperature_c: float,
128 0x21, raw_packet_low, connector_id, temperature_c, raw_adc
134 temperature_c: float,
140 0x22, raw_packet_low, connector_id, temperature_c, raw_counts
144 self, connector_id: int, force: float, raw_adc: int, *, raw_packet_low: int
147 0x23, raw_packet_low, connector_id, force, raw_adc
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]
161 """Extract raw ADC int32 (for LC signed ADC)."""
162 if len(payload) >= 21:
163 return struct.unpack_from(
"<i", payload, 12)[0]
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]
175_last_elodin_write: dict = {}
176_first_calibrated_write_logged: set = set()
177_hp_pt_channels_cache: dict = {}
182_EMA_ALPHA_DEFAULT = 0.4
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
191 ema = alpha * value + (1.0 - alpha) * prev
192 _ema_state[key] = ema
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)
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.
212 rcf = state.orchestrator.robust.get(key)
216 current_pred, _ = rcf.predict_pressure_with_uncertainty(
217 float(adc_code), state.env_state
219 if not math.isfinite(current_pred):
221 delta = current_pred - target_psi
222 rcf.theta_mean[0] -= delta
224 rcf.theta_cov[0, 0] =
max(rcf.theta_cov[0, 0] * 0.1, 1e-4)
231 """4-20 mA PT: unsigned ADC; LP PT: signed ADS1262 counts (matches FSW)."""
232 if len(payload) < 21:
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]
242 return f
"{stype}:{ch}"
245_raw_conversion_config_cache: dict = {}
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(
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),
266 return _raw_conversion_config_cache
274 channel_to_key: dict,
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):
284 interval = float(sidecar_cfg.get(
"write_interval_sec", 0.01))
285 now = time.monotonic()
287 if now - _last_elodin_write.get(throttle_key, 0) < interval:
291 if not _hp_pt_channels_cache:
292 _hp_pt_channels_cache.update(get_hp_pt_packet_channels())
295 is_hp_pt = stype ==
"PT" and packet_ch
in _hp_pt_channels_cache
296 key = channel_to_key.get((stype, packet_ch))
303 hp_cfg = _hp_pt_channels_cache[packet_ch]
304 pred = hp_pt_adc_to_psi(
306 hp_cfg[
"full_scale_psi"],
307 hp_cfg[
"sense_resistor_ohms"],
308 hp_cfg[
"adc_ref_voltage"],
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):
319 if pred
is None and key
and key
in state.orchestrator.robust:
321 rcf = state.orchestrator.robust[key]
322 pred, _unc = rcf.predict_pressure_with_uncertainty(
323 float(raw_val), state.env_state
329 if pred
is None or not math.isfinite(pred):
331 pred = raw_to_physical(
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"],
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))
350 if pred
is None or not math.isfinite(pred):
353 _last_elodin_write[throttle_key] = now
354 if not writer.connected:
358 writer.write_calibrated_pt(
359 connector_id, float(pred), raw_val, raw_packet_low=raw_packet_low
362 writer.write_calibrated_tc(
363 connector_id, float(pred), raw_val, raw_packet_low=raw_packet_low
366 writer.write_calibrated_rtd(
367 connector_id, float(pred), raw_val, raw_packet_low=raw_packet_low
370 writer.write_calibrated_lc(
371 connector_id, float(pred), raw_val, raw_packet_low=raw_packet_low
373 k = (stype, packet_ch)
374 if k
not in _first_calibrated_write_logged:
375 _first_calibrated_write_logged.add(k)
377 f
"[Cal] First calibrated write: {stype} packet_ch={packet_ch} → {pred:.2f}"
381_ADJUSTMENTS_PATH = Path(__file__).resolve().parent /
"calibrations" /
"adjustments.json"
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.
392 rcf_map = state.orchestrator.robust
398 if _ADJUSTMENTS_PATH.exists():
400 with open(_ADJUSTMENTS_PATH)
as f:
401 existing = json.load(f)
405 fw2 = existing.get(
"framework_v2", {})
406 for (stype, unique_ch), rcf
in rcf_map.items():
409 key_str = str(unique_ch)
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"),
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)")
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.
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}"
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)
442 channel_to_key = build_channel_to_orchestrator_key()
443 _we = sidecar_cfg.get(
"write_to_elodin",
True)
445 f
"[Relay] Relay subscriber starting → {relay_url} (channel map: {len(channel_to_key)} entries, "
446 f
"write_to_elodin={_we})"
449 adj_save_interval = float(
450 config.get(
"calibration", {}).get(
"sidecar", {}).get(
451 "adjustments_save_interval_sec", 120.0
454 _last_adj_save = time.monotonic()
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:
466 payload = message[8:]
473 connector_id: Optional[int] =
None
474 packet_ch: Optional[int] =
None
476 dec = decode_board_namespaced_low(low)
479 board_slot, connector_id, is_raw = dec
485 packet_ch = packet_ch_for_board_connector(
486 "PT", board_slot, connector_id
488 if packet_ch
is not None:
492 packet_ch = packet_ch_for_board_connector(
493 "TC", board_slot, connector_id
495 if packet_ch
is not None:
499 packet_ch = packet_ch_for_board_connector(
500 "RTD", board_slot, connector_id
502 if packet_ch
is not None:
506 packet_ch = packet_ch_for_board_connector(
507 "LC", board_slot, connector_id
509 if packet_ch
is not None:
516 or connector_id
is None
520 key = channel_to_key.get((stype, packet_ch))
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))
535 connector_id=connector_id,
540 _now = time.monotonic()
541 if _now - _last_adj_save >= adj_save_interval:
542 _last_adj_save = _now
545 except Exception
as _se:
546 logger.warning(f
"[Relay] adjustments auto-save failed: {_se}")
548 except Exception
as e:
549 logger.warning(f
"[Relay] Disconnected ({e}), retrying in 5s...")
550 await asyncio.sleep(5)
560 env_cfg = config.get(
"calibration", {}).get(
"environmental", {})
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),
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")
583 self.send_response(200,
"ok")
588 parsed = urlparse(self.path)
591 if path ==
"/api/status":
592 self.send_response(200)
594 self.send_header(
"Content-Type",
"application/json")
598 for key, rcf
in state.orchestrator.robust.items():
599 if key[0]
not in (
"PT",
"TC",
"RTD",
"LC"):
602 idx = state.orchestrator._key_to_idx.get(key)
609 hasattr(state.orchestrator.engine,
"get_sensor_status")
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))
619 summary = rcf.get_calibration_summary()
622 if hasattr(rcf,
"_last_glr"):
627 "sensorType": key[0],
628 "sensorId": channel_id,
629 "updateCount": len(rcf.calibration_points),
630 "rlsUpdateCount": len(rcf.calibration_points),
632 "driftDetected": drift,
633 "meanResidual": summary.get(
"rmse", 0.0),
635 "confidence": summary.get(
"confidence_level",
"UNCALIBRATED"),
638 summary.get(
"parameters", [0, 0, 0, 0, 0, 0])[3]
639 if len(summary.get(
"parameters", [])) > 3
643 summary.get(
"parameters", [0, 0, 0, 0, 0, 0])[2]
644 if len(summary.get(
"parameters", [])) > 2
648 summary.get(
"parameters", [0, 0, 0, 0, 0, 0])[1]
649 if len(summary.get(
"parameters", [])) > 1
653 summary.get(
"parameters", [0, 0, 0, 0, 0, 0])[0]
654 if len(summary.get(
"parameters", [])) > 0
658 "phase2Active":
True,
659 "covarianceTrace": sum(summary.get(
"uncertainty", [0])),
660 "bayesianConfidence": (
661 1.0
if summary.get(
"confidence_level") ==
"MAXIMUM" else 0.5
663 "populationPriorActive":
True,
664 "sidecarConnected":
True,
668 payload = {
"channels": channels,
"phase2Enabled":
True,
"timestamp": 0}
669 self.wfile.write(json.dumps(payload).encode(
"utf-8"))
671 elif path.startswith(
"/api/coefficients/"):
673 ch = int(path.split(
"/")[-1])
675 if key
in state.orchestrator.robust:
676 rcf = state.orchestrator.robust[key]
677 summary = rcf.get_calibration_summary()
678 self.send_response(200)
680 self.send_header(
"Content-Type",
"application/json")
682 self.wfile.write(json.dumps(summary).encode(
"utf-8"))
684 self.send_response(404)
688 self.send_response(400)
691 elif path ==
"/api/environmental":
692 self.send_response(200)
694 self.send_header(
"Content-Type",
"application/json")
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,
709 self.send_response(404)
714 parsed = urlparse(self.path)
716 length = int(self.headers.get(
"Content-Length", 0))
717 data = self.rfile.
read(length)
720 req = json.loads(data.decode(
"utf-8"))
721 except json.JSONDecodeError:
722 self.send_response(400)
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")
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:
737 pt = CalibrationPoint(
741 environmental_state=state.env_state,
743 1e-3
if abs(ref_value) < 10
else 0.01
746 res = state.orchestrator.robust[key].add_calibration_point(pt)
749 if abs(ref_value) < 50:
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)
758 self.send_header(
"Content-Type",
"application/json")
761 json.dumps({
"success":
True,
"result": res}).encode(
"utf-8")
763 asyncio.run_coroutine_threadsafe(
767 "type":
"coefficient_update",
768 "sensor_type": sensor_type,
776 self.send_response(404)
780 elif path ==
"/api/zero_all":
781 channels = req.get(
"channels", [])
782 channel_to_key = build_channel_to_orchestrator_key()
783 corrected_packet_chs = []
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:
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:
798 key = channel_to_key.get((
"PT", packet_ch), (
"PT", packet_ch))
800 corrected_packet_chs.append(packet_ch)
803 state.orchestrator._save_all()
804 self.send_response(200)
806 self.send_header(
"Content-Type",
"application/json")
808 self.wfile.write(json.dumps({
"success":
True}).encode(
"utf-8"))
810 asyncio.run_coroutine_threadsafe(
812 json.dumps({
"type":
"coefficient_update",
"channel":
"all"})
817 elif path ==
"/api/environmental":
818 state.env_state.temperature = req.get(
819 "temperature", state.env_state.temperature
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
826 state.env_state.mounting_torque = req.get(
827 "mounting_torque", state.env_state.mounting_torque
829 state.orchestrator.env_state = state.env_state
831 self.send_response(200)
833 self.send_header(
"Content-Type",
"application/json")
835 self.wfile.write(json.dumps({
"success":
True}).encode(
"utf-8"))
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:
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:
853 state.orchestrator._online_update(key, float(adc))
855 self.send_response(200)
857 self.send_header(
"Content-Type",
"application/json")
859 self.wfile.write(json.dumps({
"success":
True}).encode(
"utf-8"))
862 self.send_response(404)
868 server = HTTPServer((host, port), CalibrationHTTPRequestHandler)
869 logger.info(f
"Starting HTTP server on {host}:{port}")
870 server.serve_forever()
874 state.ws_clients.add(websocket)
876 await websocket.wait_closed()
878 state.ws_clients.remove(websocket)
883 await asyncio.gather(*[client.send(message)
for client
in state.ws_clients])
888 ws_loop = asyncio.new_event_loop()
889 asyncio.set_event_loop(ws_loop)
892 async with websockets.serve(ws_handler, host, port):
893 logger.info(f
"Starting WebSocket server on {host}:{port}")
895 await asyncio.Future()
897 ws_loop.run_until_complete(_run_ws())
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)
905 http_thread = threading.Thread(
906 target=run_http_server, args=(host, port), daemon=
True
908 ws_thread = threading.Thread(
909 target=run_ws_server, args=(host, port + 1), daemon=
True
920 if state.orchestrator.pending_alerts:
921 alerts, state.orchestrator.pending_alerts = (
922 state.orchestrator.pending_alerts,
926 asyncio.run_coroutine_threadsafe(
930 "type":
"active_learning_alert",
931 "sensor_id": alert.sensor_id,
932 "reason": alert.reason,
933 "urgency": alert.urgency,
934 "confidence": alert.confidence,
942 except KeyboardInterrupt:
943 logger.info(
"Shutting down...")
944 state.orchestrator.running =
False
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)
Optional[socket.socket] sock
float _apply_ema(int key, float value, float alpha=_EMA_ALPHA_DEFAULT)
None _auto_save_adjustments()
run_http_server(host, port)
bool _apply_direct_offset_correction(tuple key, float adc_code, float target_psi)
_get_raw_conversion_config()
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)
run_ws_server(host, port)
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)