""" Given a printer_id and timestamp t, fetch health + operating conditions from Supabase and return the PPO-recommended replacement schedule. """ import os import numpy as np from datetime import datetime, timezone from supabase import create_client from stable_baselines3 import PPO from model import DegradationModel from scheduling_rl import _ACTION_TABLE, COMPONENT_NAMES from process_inputs import process_inputs # --------------------------------------------------------------------------- # Supabase client # --------------------------------------------------------------------------- _sb = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_KEY"]) # Column order must match INPUT_NAMES in model.py (C=9, obs vector is R^20) _CONDITION_COLS = [ "ambient_temperature_c", "build_chamber_temp_c", "ambient_humidity_pct", "powder_contamination_level", "print_hours", "build_volume_cm3", "recoating_speed_mm_s", "recoating_cycles", "maintenance_level", ] _HEALTH_COLS = [ "recoater_blade", "nozzle_plate", "heating_elements", "temperature_sensors", "insulation_panels", "firing_resistors", "cleaning_interface", "recoater_motor", "linear_rail", ] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _as_aware(dt: datetime) -> datetime: """Return a timezone-aware datetime, treating naive datetimes as UTC.""" if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt def _parse_ts(s: str) -> datetime: """Parse an ISO timestamp string that may use a trailing Z.""" return _as_aware(datetime.fromisoformat(s.replace("Z", "+00:00"))) # --------------------------------------------------------------------------- # Fetch helpers # --------------------------------------------------------------------------- def _fetch_health(printer_id: str) -> np.ndarray: """Latest health snapshot for the printer.""" row = ( _sb.table("snapshots") .select(", ".join(_HEALTH_COLS)) .eq("id", printer_id) .order("time_step_id", desc=True) .limit(1) .execute() .data ) if not row: raise ValueError(f"No snapshot found for printer {printer_id}") return np.array([row[0][c] for c in _HEALTH_COLS], dtype=np.float64) def _fetch_conditions(printer_id: str, t: datetime) -> np.ndarray: """Closest conditions row at or before t.""" row = ( _sb.table("conditions") .select(", ".join(_CONDITION_COLS)) .eq("id", printer_id) .lte("timestamp", _as_aware(t).isoformat()) .order("timestamp", desc=True) .limit(1) .execute() .data ) if not row: raise ValueError(f"No conditions found for printer {printer_id} at {t}") # Coerce NULL columns to 0.0 (seed_data.py may omit some fields) return np.array([float(row[0][c] or 0.0) for c in _CONDITION_COLS], dtype=np.float64) # --------------------------------------------------------------------------- # Main prediction # --------------------------------------------------------------------------- def predict_replacements( printer_id: str, t: datetime, *, budget_remaining: float, W: float = 10_000.0, t_hours: float = 0.0, ppo_path: str = "scheduler_ppo", model_path: str = "model.npz", ) -> dict: DegradationModel.load(model_path) # validates model exists ppo = PPO.load(ppo_path) health = _fetch_health(printer_id) X_t = process_inputs(_fetch_conditions(printer_id, t)) obs = np.concatenate([health, X_t, [budget_remaining / W], [t_hours]]).astype(np.float32) action, _ = ppo.predict(obs, deterministic=True) bits = _ACTION_TABLE[int(action)] to_replace = [COMPONENT_NAMES[i] for i, b in enumerate(bits) if b] return { "printer_id": printer_id, "timestamp": _as_aware(t).isoformat(), "health": dict(zip(COMPONENT_NAMES, health.tolist())), "conditions": dict(zip(_CONDITION_COLS, X_t.tolist())), "replace": to_replace, "action_id": int(action), } # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- if __name__ == "__main__": import json, sys printer_id = sys.argv[1] if len(sys.argv) > 1 else "printer_001" t = _parse_ts(sys.argv[2]) if len(sys.argv) > 2 else datetime.now(tz=timezone.utc) budget = float(sys.argv[3]) if len(sys.argv) > 3 else 10_000.0 result = predict_replacements(printer_id, t, budget_remaining=budget) print(json.dumps(result, indent=2))