File size: 4,810 Bytes
7c5df99
 
 
 
 
 
 
632daa5
7c5df99
 
 
 
ebd57ea
7c5df99
 
 
 
 
ebd57ea
30771f0
ebd57ea
7c5df99
 
 
 
 
ebd57ea
7c5df99
 
ebd57ea
7c5df99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ebd57ea
7c5df99
 
ebd57ea
 
 
 
 
7c5df99
 
ebd57ea
 
 
 
 
 
 
 
 
 
 
7c5df99
ebd57ea
7c5df99
 
ebd57ea
7c5df99
 
 
 
 
ebd57ea
7c5df99
 
 
 
 
 
ebd57ea
7c5df99
 
ebd57ea
7c5df99
 
 
 
 
 
 
ebd57ea
 
7c5df99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ebd57ea
 
7c5df99
 
 
 
 
 
 
 
ebd57ea
7c5df99
 
 
 
 
 
 
 
 
 
 
 
 
 
ebd57ea
7c5df99
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
"""
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))