Spaces:
Sleeping
Sleeping
Dan Vancea commited on
Commit Β·
7c5df99
1
Parent(s): 798b15b
v2
Browse files- model.py +509 -0
- predict_from_supabase.py +140 -0
- scheduling_rl.py +530 -0
model.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
model.py β Degradation Function Estimation
|
| 3 |
+
|
| 4 |
+
Implements the model from the project PDF:
|
| 5 |
+
|
| 6 |
+
dhi/dt = K * (β_c I_c) * β_j (ΞΈ_ij,I * h_j + ΞΈ_ij,II * h_j * ln(h_j))
|
| 7 |
+
|
| 8 |
+
K is absorbed into theta_I and theta_II, so no separate input-scaling parameter is estimated.
|
| 9 |
+
|
| 10 |
+
Parameters:
|
| 11 |
+
theta_I : (N, N) β linear health coupling; theta_I[i,j] = ΞΈ_ij,I
|
| 12 |
+
theta_II : (N, N) β log-linear health coupling; theta_II[i,j] = ΞΈ_ij,II
|
| 13 |
+
|
| 14 |
+
Adjacency mask A (N x N, binary):
|
| 15 |
+
A[i, j] = 1 iff component j is physically allowed to influence component i.
|
| 16 |
+
Built from component_graph.COMPONENT_GRAPH so that the learned theta matrices
|
| 17 |
+
can only be non-zero where a real physical coupling exists. The mask is applied
|
| 18 |
+
element-wise: theta_I_eff = A * theta_I
|
| 19 |
+
Positions where A[i,j] = 0 are zeroed on init and their gradients are zeroed
|
| 20 |
+
during fitting β the model cannot learn phantom interactions.
|
| 21 |
+
|
| 22 |
+
Stochastic extension (Β§5): each Euler step subtracts Q_i * H_i where
|
| 23 |
+
Q_i ~ N(0,1)Β² (squared standard normal β event intensity, always β₯ 0)
|
| 24 |
+
H_i ~ Poisson(Ξ»_i * Ο / n) (event count per step; Ξ»_i set per component)
|
| 25 |
+
Negative health values are valid in this mode and represent catastrophic failure.
|
| 26 |
+
|
| 27 |
+
Fitting: Euler-forward simulation + manual Jacobian recurrence β SGD + L1 regularisation.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import json
|
| 33 |
+
from dataclasses import dataclass
|
| 34 |
+
from typing import List, Optional, Tuple
|
| 35 |
+
|
| 36 |
+
import numpy as np
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Data structures
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
@dataclass
|
| 44 |
+
class Sample:
|
| 45 |
+
"""One training example."""
|
| 46 |
+
X: np.ndarray # (C,) constant input vector over [0, tau]
|
| 47 |
+
tau: float # time horizon
|
| 48 |
+
y: np.ndarray # (N,) target health at tau
|
| 49 |
+
M: np.ndarray # (N,) mask: 1 = observed, 0 = ignored
|
| 50 |
+
|
| 51 |
+
@classmethod
|
| 52 |
+
def from_json(cls, path: str) -> List["Sample"]:
|
| 53 |
+
"""
|
| 54 |
+
Load a list of samples from a JSON file.
|
| 55 |
+
|
| 56 |
+
Expected format β a JSON array where each element has:
|
| 57 |
+
"X" : list of C floats (inputs)
|
| 58 |
+
"tau" : float (time horizon)
|
| 59 |
+
"y" : list of N floats (target health per component)
|
| 60 |
+
"M" : list of N ints (mask: 1 = observed, 0 = ignored)
|
| 61 |
+
"""
|
| 62 |
+
with open(path, "r") as f:
|
| 63 |
+
records = json.load(f)
|
| 64 |
+
return [
|
| 65 |
+
cls(
|
| 66 |
+
X = np.array(r["X"], dtype=float),
|
| 67 |
+
tau = float(r["tau"]),
|
| 68 |
+
y = np.array(r["y"], dtype=float),
|
| 69 |
+
M = np.array(r["M"], dtype=float),
|
| 70 |
+
)
|
| 71 |
+
for r in records
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------------------
|
| 76 |
+
# Model
|
| 77 |
+
# ---------------------------------------------------------------------------
|
| 78 |
+
|
| 79 |
+
class DegradationModel:
|
| 80 |
+
"""
|
| 81 |
+
Parametric ODE model for multi-component health degradation.
|
| 82 |
+
|
| 83 |
+
State starts at h(0) = 1 (all components fully healthy).
|
| 84 |
+
The ODE for component i couples to every component j via theta_I and theta_II,
|
| 85 |
+
and to every input c via theta_input.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
def __init__(
|
| 89 |
+
self,
|
| 90 |
+
N: int,
|
| 91 |
+
C: int,
|
| 92 |
+
lambda_rates: Optional[np.ndarray] = None,
|
| 93 |
+
adjacency_mask: Optional[np.ndarray] = None,
|
| 94 |
+
seed: int = 0,
|
| 95 |
+
) -> None:
|
| 96 |
+
self.N = N
|
| 97 |
+
self.C = C
|
| 98 |
+
# Per-component Poisson rates Ξ»_i for the stochastic shock term (Β§5).
|
| 99 |
+
# Defaults to zeros β no randomness unless explicitly set.
|
| 100 |
+
self.lambda_rates: np.ndarray = (
|
| 101 |
+
np.asarray(lambda_rates, dtype=float)
|
| 102 |
+
if lambda_rates is not None
|
| 103 |
+
else np.zeros(N, dtype=float)
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Adjacency mask A[i,j] = 1 iff component j may influence component i.
|
| 107 |
+
# If None, defaults to all-ones (fully connected β no structural constraint).
|
| 108 |
+
# Pass build_adjacency_matrix() to enforce the physical graph topology.
|
| 109 |
+
if adjacency_mask is not None:
|
| 110 |
+
self.A: np.ndarray = np.asarray(adjacency_mask, dtype=float)
|
| 111 |
+
if self.A.shape != (N, N):
|
| 112 |
+
raise ValueError(
|
| 113 |
+
f"adjacency_mask must be ({N},{N}), got {self.A.shape}"
|
| 114 |
+
)
|
| 115 |
+
else:
|
| 116 |
+
self.A = np.ones((N, N), dtype=float)
|
| 117 |
+
|
| 118 |
+
rng = np.random.default_rng(seed)
|
| 119 |
+
# Small negative diagonal drives self-degradation; off-diagonals start near 0.
|
| 120 |
+
# Mask is applied immediately so forbidden positions start at exactly 0.
|
| 121 |
+
self.theta_I: np.ndarray = -np.abs(rng.normal(0.0, 1e-3, (N, N))) * self.A
|
| 122 |
+
np.fill_diagonal(self.theta_I, -1e-2) # diagonal always in mask (self-coupling)
|
| 123 |
+
self.theta_II: np.ndarray = np.zeros((N, N), dtype=float)
|
| 124 |
+
|
| 125 |
+
# ------------------------------------------------------------------
|
| 126 |
+
# Parameter vector helpers
|
| 127 |
+
# ------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
@property
|
| 130 |
+
def num_params(self) -> int:
|
| 131 |
+
return 2 * self.N * self.N
|
| 132 |
+
|
| 133 |
+
def _get_params(self) -> np.ndarray:
|
| 134 |
+
return np.concatenate([
|
| 135 |
+
self.theta_I.ravel(),
|
| 136 |
+
self.theta_II.ravel(),
|
| 137 |
+
])
|
| 138 |
+
|
| 139 |
+
def _set_params(self, p: np.ndarray) -> None:
|
| 140 |
+
N = self.N
|
| 141 |
+
self.theta_I = np.minimum(p[: N * N].reshape(N, N), 0.0)
|
| 142 |
+
self.theta_II = np.maximum(p[N * N :].reshape(N, N), 0.0)
|
| 143 |
+
|
| 144 |
+
# ------------------------------------------------------------------
|
| 145 |
+
# ODE
|
| 146 |
+
# ------------------------------------------------------------------
|
| 147 |
+
|
| 148 |
+
@staticmethod
|
| 149 |
+
def _safe_hlog(h: np.ndarray) -> np.ndarray:
|
| 150 |
+
"""h * ln(h) with h clipped away from 0."""
|
| 151 |
+
h_safe = np.clip(h, 1e-10, None)
|
| 152 |
+
return h_safe * np.log(h_safe)
|
| 153 |
+
|
| 154 |
+
def _P(self, I: np.ndarray) -> float:
|
| 155 |
+
"""Input product P = β_c I_c."""
|
| 156 |
+
return float(np.prod(I))
|
| 157 |
+
|
| 158 |
+
def f(self, h: np.ndarray, I: np.ndarray) -> np.ndarray:
|
| 159 |
+
"""Rate vector dh/dt, shape (N,).
|
| 160 |
+
|
| 161 |
+
The mask A is applied element-wise before the matrix products so that
|
| 162 |
+
forbidden couplings (A[i,j]=0) never contribute to dh/dt regardless of
|
| 163 |
+
the current value of theta_I or theta_II.
|
| 164 |
+
"""
|
| 165 |
+
P = self._P(I)
|
| 166 |
+
h_log = self._safe_hlog(h)
|
| 167 |
+
g = (self.A * self.theta_I) @ h + (self.A * self.theta_II) @ h_log # (N,)
|
| 168 |
+
return P * g
|
| 169 |
+
|
| 170 |
+
# ------------------------------------------------------------------
|
| 171 |
+
# Forward simulation (Euler integration)
|
| 172 |
+
# ------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
def simulate(
|
| 175 |
+
self,
|
| 176 |
+
X: np.ndarray,
|
| 177 |
+
tau: float,
|
| 178 |
+
n_steps: int = 100,
|
| 179 |
+
stochastic: bool = False,
|
| 180 |
+
seed: Optional[int] = None,
|
| 181 |
+
) -> np.ndarray:
|
| 182 |
+
"""
|
| 183 |
+
Integrate from h(0)=1 to h(tau) using Euler steps.
|
| 184 |
+
|
| 185 |
+
When stochastic=True, each step subtracts a random shock Q_i * H_i where
|
| 186 |
+
Q_i ~ N(0,1)Β² (squared standard normal β event intensity)
|
| 187 |
+
H_i ~ Poisson(lambda_rates[i] * tau / n_steps)
|
| 188 |
+
Negative health values are kept as-is; they represent catastrophic failure.
|
| 189 |
+
|
| 190 |
+
Returns shape (n_steps + 1, N) β row 0 is h(0), row k is h(k * tau / n_steps).
|
| 191 |
+
"""
|
| 192 |
+
h = np.ones(self.N, dtype=float)
|
| 193 |
+
dt = tau / n_steps
|
| 194 |
+
rng = np.random.default_rng(seed)
|
| 195 |
+
trajectory = [h.copy()]
|
| 196 |
+
|
| 197 |
+
for _ in range(n_steps):
|
| 198 |
+
dh = dt * self.f(h, X)
|
| 199 |
+
if stochastic:
|
| 200 |
+
Q = rng.standard_normal(self.N) ** 2 # N(0,1)Β²
|
| 201 |
+
H = rng.poisson(self.lambda_rates * tau / n_steps) # Poisson(Ξ»i*Ο/n)
|
| 202 |
+
dh -= Q * H
|
| 203 |
+
h = h + dh
|
| 204 |
+
if not stochastic:
|
| 205 |
+
h = np.clip(h, 0.0, 1.0)
|
| 206 |
+
trajectory.append(h.copy())
|
| 207 |
+
|
| 208 |
+
return np.array(trajectory) # (n_steps + 1, N)
|
| 209 |
+
|
| 210 |
+
# ------------------------------------------------------------------
|
| 211 |
+
# Loss
|
| 212 |
+
# ------------------------------------------------------------------
|
| 213 |
+
|
| 214 |
+
def loss(self, y_hat: np.ndarray, y: np.ndarray, M: np.ndarray) -> float:
|
| 215 |
+
"""Masked MSE: β_i (Ε·i - yi)Β² * Mi."""
|
| 216 |
+
return float(np.sum((y_hat - y) ** 2 * M))
|
| 217 |
+
|
| 218 |
+
# ------------------------------------------------------------------
|
| 219 |
+
# Jacobians
|
| 220 |
+
# ------------------------------------------------------------------
|
| 221 |
+
|
| 222 |
+
def _df_dtheta(self, h: np.ndarray, I: np.ndarray) -> np.ndarray:
|
| 223 |
+
"""
|
| 224 |
+
βf/βΞΈ, shape (N, num_params).
|
| 225 |
+
|
| 226 |
+
Columns correspond to [theta_I (flattened) | theta_II (flattened)].
|
| 227 |
+
Gradient columns for positions where A[i,j]=0 are zeroed so those
|
| 228 |
+
parameters receive no update signal during backprop.
|
| 229 |
+
"""
|
| 230 |
+
N = self.N
|
| 231 |
+
P = self._P(I)
|
| 232 |
+
h_log = self._safe_hlog(h)
|
| 233 |
+
|
| 234 |
+
jac = np.zeros((N, self.num_params))
|
| 235 |
+
|
| 236 |
+
# βfi/βΞΈij,I = P * hj β then zero out forbidden positions via A
|
| 237 |
+
raw_I = np.kron(np.eye(N), P * h[np.newaxis, :]) # (N, N*N)
|
| 238 |
+
jac[:, : N * N] = raw_I * self.A.ravel()[np.newaxis, :]
|
| 239 |
+
|
| 240 |
+
# βfi/βΞΈij,II = P * hj * ln(hj) β same masking
|
| 241 |
+
raw_II = np.kron(np.eye(N), P * h_log[np.newaxis, :]) # (N, N*N)
|
| 242 |
+
jac[:, N * N :] = raw_II * self.A.ravel()[np.newaxis, :]
|
| 243 |
+
|
| 244 |
+
return jac
|
| 245 |
+
|
| 246 |
+
def _df_dh(self, h: np.ndarray, I: np.ndarray) -> np.ndarray:
|
| 247 |
+
"""
|
| 248 |
+
βf/βh (Jacobian of rate w.r.t. state), shape (N, N).
|
| 249 |
+
|
| 250 |
+
βfi/βhj = KP * (ΞΈij,I + ΞΈij,II * (1 + ln(hj)))
|
| 251 |
+
"""
|
| 252 |
+
P = self._P(I)
|
| 253 |
+
h_safe = np.clip(h, 1e-10, None)
|
| 254 |
+
d_log = 1.0 + np.log(h_safe) # d/dhj [hj ln hj] = 1 + ln hj
|
| 255 |
+
return P * (self.theta_I + self.theta_II * d_log[np.newaxis, :])
|
| 256 |
+
|
| 257 |
+
# ------------------------------------------------------------------
|
| 258 |
+
# Gradient via Jacobian recurrence
|
| 259 |
+
# ------------------------------------------------------------------
|
| 260 |
+
|
| 261 |
+
def compute_gradient(
|
| 262 |
+
self,
|
| 263 |
+
sample: Sample,
|
| 264 |
+
n_steps: int = 50,
|
| 265 |
+
J_clip: float = 1e6,
|
| 266 |
+
) -> Tuple[np.ndarray, float]:
|
| 267 |
+
"""
|
| 268 |
+
Gradient of the masked MSE loss for one sample, via:
|
| 269 |
+
|
| 270 |
+
JΞΈΕ·(t + dt) = JΞΈΕ·(t) + dt * (βf/βΞΈ + βf/βΕ· Β· JΞΈΕ·(t))
|
| 271 |
+
JΞΈΕ·(0) = 0
|
| 272 |
+
|
| 273 |
+
where JΞΈΕ· = βΕ·/βΞΈ has shape (N, num_params).
|
| 274 |
+
|
| 275 |
+
Returns (gradient w.r.t. params, scalar loss).
|
| 276 |
+
"""
|
| 277 |
+
X, tau, y, M = sample.X, sample.tau, sample.y, sample.M
|
| 278 |
+
dt = tau / n_steps
|
| 279 |
+
N, P = self.N, self.num_params
|
| 280 |
+
|
| 281 |
+
h = np.ones(N, dtype=float)
|
| 282 |
+
J = np.zeros((N, P)) # JΞΈΕ·
|
| 283 |
+
|
| 284 |
+
for _ in range(n_steps):
|
| 285 |
+
df_dt = self._df_dtheta(h, X) # (N, P)
|
| 286 |
+
df_dh = self._df_dh(h, X) # (N, N)
|
| 287 |
+
J = np.clip(J + dt * (df_dt + df_dh @ J), -J_clip, J_clip)
|
| 288 |
+
h = np.clip(h + dt * self.f(h, X), 0.0, 1.0)
|
| 289 |
+
|
| 290 |
+
# βL/βΞΈr = 2 * β_i (Ε·i β yi) * Mi * βΕ·i/βΞΈr
|
| 291 |
+
residual = (h - y) * M # (N,)
|
| 292 |
+
grad = 2.0 * (residual @ J) # (P,)
|
| 293 |
+
loss_val = float(np.sum(residual ** 2))
|
| 294 |
+
return grad, loss_val
|
| 295 |
+
|
| 296 |
+
# ------------------------------------------------------------------
|
| 297 |
+
# Summary
|
| 298 |
+
# ------------------------------------------------------------------
|
| 299 |
+
|
| 300 |
+
def summary(self, component_names: Optional[List[str]] = None) -> None:
|
| 301 |
+
"""Print a human-readable overview of the fitted parameters."""
|
| 302 |
+
comp = component_names or [f"comp_{i}" for i in range(self.N)]
|
| 303 |
+
w = max(len(n) for n in comp) # column width
|
| 304 |
+
|
| 305 |
+
print(f"DegradationModel N={self.N} C={self.C} params={self.num_params}")
|
| 306 |
+
print()
|
| 307 |
+
|
| 308 |
+
print("Linear health coupling (theta_I[i,j]) -- row i influenced by col j:")
|
| 309 |
+
header = " " * (w + 4) + " ".join(f"{n:>{w}}" for n in comp)
|
| 310 |
+
print(header)
|
| 311 |
+
for i, row_name in enumerate(comp):
|
| 312 |
+
vals = " ".join(
|
| 313 |
+
f"{self.theta_I[i, j]:+{w}.4f}" if self.A[i, j] else " " * (w + 1) + "-"
|
| 314 |
+
for j in range(self.N)
|
| 315 |
+
)
|
| 316 |
+
print(f" {row_name:<{w}} {vals}")
|
| 317 |
+
print()
|
| 318 |
+
|
| 319 |
+
print("Log-linear health coupling (theta_II[i,j]) -- row i influenced by col j:")
|
| 320 |
+
print(header)
|
| 321 |
+
for i, row_name in enumerate(comp):
|
| 322 |
+
vals = " ".join(
|
| 323 |
+
f"{self.theta_II[i, j]:+{w}.4f}" if self.A[i, j] else " " * (w + 1) + "-"
|
| 324 |
+
for j in range(self.N)
|
| 325 |
+
)
|
| 326 |
+
print(f" {row_name:<{w}} {vals}")
|
| 327 |
+
print()
|
| 328 |
+
|
| 329 |
+
print("Stochastic shock rates (lambda_rates):")
|
| 330 |
+
for i, (name, lam) in enumerate(zip(comp, self.lambda_rates)):
|
| 331 |
+
print(f" {name}: lambda={lam:.6f}")
|
| 332 |
+
|
| 333 |
+
# ------------------------------------------------------------------
|
| 334 |
+
# Persistence
|
| 335 |
+
# ------------------------------------------------------------------
|
| 336 |
+
|
| 337 |
+
def save(self, path: str) -> None:
|
| 338 |
+
"""Save all model arrays to a .npz file."""
|
| 339 |
+
np.savez(
|
| 340 |
+
path,
|
| 341 |
+
N = self.N,
|
| 342 |
+
C = self.C,
|
| 343 |
+
lambda_rates = self.lambda_rates,
|
| 344 |
+
A = self.A,
|
| 345 |
+
theta_I = self.theta_I,
|
| 346 |
+
theta_II = self.theta_II,
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
@classmethod
|
| 350 |
+
def load(cls, path: str) -> "DegradationModel":
|
| 351 |
+
"""Load a model saved with save()."""
|
| 352 |
+
d = np.load(path)
|
| 353 |
+
m = cls(
|
| 354 |
+
N = int(d["N"]),
|
| 355 |
+
C = int(d["C"]),
|
| 356 |
+
lambda_rates = d["lambda_rates"],
|
| 357 |
+
adjacency_mask = d["A"],
|
| 358 |
+
)
|
| 359 |
+
m.theta_I = np.minimum(d["theta_I"], 0.0)
|
| 360 |
+
m.theta_II = np.maximum(d["theta_II"], 0.0)
|
| 361 |
+
return m
|
| 362 |
+
|
| 363 |
+
# ------------------------------------------------------------------
|
| 364 |
+
# Fitting (SGD + L1)
|
| 365 |
+
# ------------------------------------------------------------------
|
| 366 |
+
|
| 367 |
+
def fit(
|
| 368 |
+
self,
|
| 369 |
+
dataset: List[Sample],
|
| 370 |
+
lr: float = 1e-3,
|
| 371 |
+
epochs: int = 100,
|
| 372 |
+
lambda_l1: float = 1e-4,
|
| 373 |
+
n_steps: int = 50,
|
| 374 |
+
batch_size: Optional[int] = None,
|
| 375 |
+
verbose: bool = True,
|
| 376 |
+
) -> List[float]:
|
| 377 |
+
"""
|
| 378 |
+
Stochastic gradient descent with L1 regularisation.
|
| 379 |
+
|
| 380 |
+
L1 promotes sparsity β zero parameters mean no coupling between components
|
| 381 |
+
or inputs, letting the model discover the true dependency structure.
|
| 382 |
+
|
| 383 |
+
Returns per-epoch average loss history.
|
| 384 |
+
"""
|
| 385 |
+
rng = np.random.default_rng(0)
|
| 386 |
+
loss_history: List[float] = []
|
| 387 |
+
|
| 388 |
+
for epoch in range(epochs):
|
| 389 |
+
indices = rng.permutation(len(dataset))
|
| 390 |
+
if batch_size is not None:
|
| 391 |
+
batches: List[np.ndarray] = [
|
| 392 |
+
indices[i : i + batch_size]
|
| 393 |
+
for i in range(0, len(indices), batch_size)
|
| 394 |
+
]
|
| 395 |
+
else:
|
| 396 |
+
batches = [indices]
|
| 397 |
+
|
| 398 |
+
epoch_loss = 0.0
|
| 399 |
+
for batch_idx in batches:
|
| 400 |
+
results = [self.compute_gradient(dataset[i], n_steps) for i in batch_idx]
|
| 401 |
+
grads, losses = zip(*results)
|
| 402 |
+
grad = np.mean(grads, axis=0)
|
| 403 |
+
batch_loss = float(np.mean(losses))
|
| 404 |
+
|
| 405 |
+
# L1 subgradient
|
| 406 |
+
params = self._get_params()
|
| 407 |
+
grad = grad + lambda_l1 * np.sign(params)
|
| 408 |
+
|
| 409 |
+
# Gradient clipping to prevent exploding updates
|
| 410 |
+
grad_norm = float(np.linalg.norm(grad))
|
| 411 |
+
if grad_norm > 1.0:
|
| 412 |
+
grad = grad / grad_norm
|
| 413 |
+
|
| 414 |
+
self._set_params(params - lr * grad)
|
| 415 |
+
|
| 416 |
+
# Re-apply mask after update: forbidden positions must stay at 0
|
| 417 |
+
# even if numerical noise crept in through the L1 subgradient.
|
| 418 |
+
self.theta_I *= self.A
|
| 419 |
+
self.theta_II *= self.A
|
| 420 |
+
|
| 421 |
+
epoch_loss += batch_loss
|
| 422 |
+
|
| 423 |
+
epoch_loss /= len(batches)
|
| 424 |
+
loss_history.append(epoch_loss)
|
| 425 |
+
|
| 426 |
+
if verbose and (epoch % max(1, epochs // 10) == 0 or epoch == epochs - 1):
|
| 427 |
+
print(f"Epoch {epoch:4d}/{epochs}: loss = {epoch_loss:.6f}")
|
| 428 |
+
|
| 429 |
+
return loss_history
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
COMPONENT_NAMES: List[str] = [
|
| 433 |
+
"recoater_blade",
|
| 434 |
+
"nozzle_plate",
|
| 435 |
+
"heating_elements",
|
| 436 |
+
"temperature_sensors",
|
| 437 |
+
"insulation_panels",
|
| 438 |
+
"firing_resistors",
|
| 439 |
+
"cleaning_interface",
|
| 440 |
+
"recoater_motor",
|
| 441 |
+
"linear_rail",
|
| 442 |
+
]
|
| 443 |
+
|
| 444 |
+
INPUT_NAMES: List[str] = [
|
| 445 |
+
"ambient_temperature_c",
|
| 446 |
+
"build_chamber_temp_c",
|
| 447 |
+
"ambient_humidity_pct",
|
| 448 |
+
"powder_contamination_level",
|
| 449 |
+
"print_hours",
|
| 450 |
+
"build_volume_cm3",
|
| 451 |
+
"recoating_speed_mm_s",
|
| 452 |
+
"recoating_cycles",
|
| 453 |
+
"maintenance_level",
|
| 454 |
+
]
|
| 455 |
+
|
| 456 |
+
if __name__ == "__main__":
|
| 457 |
+
N, C = 9, 9
|
| 458 |
+
lambda_rates = 1e-5 * np.ones(9)
|
| 459 |
+
|
| 460 |
+
A = np.array([
|
| 461 |
+
[1, 1, 1, 1, 0, 1, 0, 0, 0],
|
| 462 |
+
[1, 1, 1, 1, 0, 0, 0, 0, 0],
|
| 463 |
+
[1, 1, 1, 1, 0, 0, 0, 0, 0],
|
| 464 |
+
[1, 1, 1, 1, 1, 1, 0, 0, 0],
|
| 465 |
+
[0, 0, 0, 1, 1, 1, 1, 0, 0],
|
| 466 |
+
[1, 0, 0, 1, 1, 1, 0, 0, 0],
|
| 467 |
+
[0, 0, 0, 0, 1, 0, 1, 1, 1],
|
| 468 |
+
[0, 0, 0, 0, 0, 0, 1, 1, 1],
|
| 469 |
+
[0, 0, 0, 0, 0, 0, 1, 1, 1],
|
| 470 |
+
]) # fully connected; use build_adjacency_matrix() to enforce graph structure
|
| 471 |
+
print("Adjacency mask A:")
|
| 472 |
+
print(A)
|
| 473 |
+
|
| 474 |
+
model = DegradationModel(N=N, C=C, lambda_rates=lambda_rates,
|
| 475 |
+
adjacency_mask=A, seed=42)
|
| 476 |
+
print(f"\ntheta_I (masked, forbidden positions = 0):")
|
| 477 |
+
print(model.theta_I.round(5))
|
| 478 |
+
|
| 479 |
+
rng = np.random.default_rng(7)
|
| 480 |
+
dataset: List[Sample] = Sample.from_json("samples.json")
|
| 481 |
+
|
| 482 |
+
s = dataset[0]
|
| 483 |
+
det = model.simulate(s.X, s.tau, n_steps=50, stochastic=False)
|
| 484 |
+
sto = model.simulate(s.X, s.tau, n_steps=50, stochastic=True, seed=0)
|
| 485 |
+
|
| 486 |
+
print("Deterministic simulation:")
|
| 487 |
+
print(f" y_hat = {det[-1]}")
|
| 488 |
+
print(f" loss = {model.loss(det[-1], s.y, s.M):.4f}")
|
| 489 |
+
print("Stochastic simulation (lambda_rates =", lambda_rates, "):")
|
| 490 |
+
print(f" y_hat = {sto[-1]}")
|
| 491 |
+
print(f" loss = {model.loss(sto[-1], s.y, s.M):.4f}")
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
model.summary(component_names=COMPONENT_NAMES)
|
| 495 |
+
print("\nFitting:")
|
| 496 |
+
learning_rates = [1e-5, 2e-5, 1e-4, 2e-4]
|
| 497 |
+
min_loss = 10
|
| 498 |
+
best_lr = 0
|
| 499 |
+
for lr in learning_rates:
|
| 500 |
+
import time
|
| 501 |
+
model = DegradationModel(N=N, C=C, lambda_rates=lambda_rates,
|
| 502 |
+
adjacency_mask=A, seed=int(time.time()))
|
| 503 |
+
final_loss = model.fit(dataset, lr=1e-3, epochs=300, lambda_l1=0, n_steps=20, verbose=True)[-1]
|
| 504 |
+
if final_loss < min_loss:
|
| 505 |
+
model.save("model.npz")
|
| 506 |
+
min_loss = final_loss
|
| 507 |
+
best_lr = lr
|
| 508 |
+
print(best_lr, min_loss)
|
| 509 |
+
model.summary(component_names=COMPONENT_NAMES)
|
predict_from_supabase.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Given a printer_id and timestamp t, fetch health + operating conditions from
|
| 3 |
+
Supabase and return the PPO-recommended replacement schedule.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import numpy as np
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from supabase import create_client
|
| 10 |
+
from stable_baselines3 import PPO
|
| 11 |
+
from model import DegradationModel
|
| 12 |
+
from scheduling_rl import _ACTION_TABLE, COMPONENT_NAMES
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# Supabase client
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
_sb = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_KEY"])
|
| 19 |
+
|
| 20 |
+
# Column order must match the obs vector expected by the PPO (C=7 conditions)
|
| 21 |
+
_CONDITION_COLS = [
|
| 22 |
+
"ambient_temperature_c",
|
| 23 |
+
"build_chamber_temp_c",
|
| 24 |
+
"ambient_humidity_pct",
|
| 25 |
+
"powder_contamination_level",
|
| 26 |
+
"build_volume_cm3",
|
| 27 |
+
"recoating_speed_mm_s",
|
| 28 |
+
"maintenance_level",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
_HEALTH_COLS = [
|
| 32 |
+
"recoater_blade",
|
| 33 |
+
"nozzle_plate",
|
| 34 |
+
"heating_elements",
|
| 35 |
+
"temperature_sensors",
|
| 36 |
+
"insulation_panels",
|
| 37 |
+
"firing_resistors",
|
| 38 |
+
"cleaning_interface",
|
| 39 |
+
"recoater_motor",
|
| 40 |
+
"linear_rail",
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# Fetch helpers
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
def _fetch_health(printer_id: str, t: datetime) -> np.ndarray:
|
| 48 |
+
"""Snapshot whose time_step_id matches hours elapsed since last_repair."""
|
| 49 |
+
printer = (
|
| 50 |
+
_sb.table("printers")
|
| 51 |
+
.select("last_repair")
|
| 52 |
+
.eq("id", printer_id)
|
| 53 |
+
.single()
|
| 54 |
+
.execute()
|
| 55 |
+
.data
|
| 56 |
+
)
|
| 57 |
+
if not printer:
|
| 58 |
+
raise ValueError(f"Printer {printer_id} not found")
|
| 59 |
+
|
| 60 |
+
last_repair = datetime.fromisoformat(printer["last_repair"])
|
| 61 |
+
time_step_id = int((t - last_repair).total_seconds() // 3600)
|
| 62 |
+
|
| 63 |
+
row = (
|
| 64 |
+
_sb.table("snapshots")
|
| 65 |
+
.select(", ".join(_HEALTH_COLS))
|
| 66 |
+
.eq("id", printer_id)
|
| 67 |
+
.gte("time_step_id", time_step_id)
|
| 68 |
+
.order("time_step_id", desc=False)
|
| 69 |
+
.limit(1)
|
| 70 |
+
.execute()
|
| 71 |
+
.data
|
| 72 |
+
)
|
| 73 |
+
if not row:
|
| 74 |
+
raise ValueError(f"No snapshot for printer {printer_id} at time_step_id={time_step_id} (t={t})")
|
| 75 |
+
return np.array([row[0][c] for c in _HEALTH_COLS], dtype=np.float64)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _fetch_conditions(printer_id: str, t: datetime) -> np.ndarray:
|
| 79 |
+
"""Closest conditions row at or before t."""
|
| 80 |
+
row = (
|
| 81 |
+
_sb.table("conditions")
|
| 82 |
+
.select(", ".join(_CONDITION_COLS))
|
| 83 |
+
.eq("id", printer_id)
|
| 84 |
+
.lte("timestamp", t.isoformat())
|
| 85 |
+
.order("timestamp", desc=True)
|
| 86 |
+
.limit(1)
|
| 87 |
+
.execute()
|
| 88 |
+
.data
|
| 89 |
+
)
|
| 90 |
+
if not row:
|
| 91 |
+
raise ValueError(f"No conditions found for printer {printer_id} at {t}")
|
| 92 |
+
return np.array([row[0][c] for c in _CONDITION_COLS], dtype=np.float64)
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Main prediction
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
def predict_replacements(
|
| 99 |
+
printer_id: str,
|
| 100 |
+
t: datetime,
|
| 101 |
+
*,
|
| 102 |
+
budget_remaining: float,
|
| 103 |
+
W: float = 10_000.0,
|
| 104 |
+
t_hours: float = 0.0,
|
| 105 |
+
ppo_path: str = "scheduler_ppo",
|
| 106 |
+
model_path: str = "model.npz",
|
| 107 |
+
) -> dict:
|
| 108 |
+
DegradationModel.load(model_path) # validates model exists
|
| 109 |
+
ppo = PPO.load(ppo_path)
|
| 110 |
+
|
| 111 |
+
health = _fetch_health(printer_id, t)
|
| 112 |
+
X_t = _fetch_conditions(printer_id, t)
|
| 113 |
+
|
| 114 |
+
obs = np.concatenate([health, X_t, [budget_remaining / W], [t_hours]]).astype(np.float32)
|
| 115 |
+
action, _ = ppo.predict(obs, deterministic=True)
|
| 116 |
+
bits = _ACTION_TABLE[int(action)]
|
| 117 |
+
|
| 118 |
+
to_replace = [COMPONENT_NAMES[i] for i, b in enumerate(bits) if b]
|
| 119 |
+
return {
|
| 120 |
+
"printer_id": printer_id,
|
| 121 |
+
"timestamp": t.isoformat(),
|
| 122 |
+
"health": dict(zip(COMPONENT_NAMES, health.tolist())),
|
| 123 |
+
"conditions": dict(zip(_CONDITION_COLS, X_t.tolist())),
|
| 124 |
+
"replace": to_replace,
|
| 125 |
+
"action_id": int(action),
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
# CLI
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
import json, sys
|
| 135 |
+
printer_id = sys.argv[1] if len(sys.argv) > 1 else "printer_001"
|
| 136 |
+
t = datetime.fromisoformat(sys.argv[2]) if len(sys.argv) > 2 else datetime.now()
|
| 137 |
+
budget = float(sys.argv[3]) if len(sys.argv) > 3 else 10_000.0
|
| 138 |
+
|
| 139 |
+
result = predict_replacements(printer_id, t, budget_remaining=budget)
|
| 140 |
+
print(json.dumps(result, indent=2))
|
scheduling_rl.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
scheduling_rl.py β PPO-based replacement scheduling for the HP Metal Jet S100.
|
| 3 |
+
|
| 4 |
+
Environment
|
| 5 |
+
-----------
|
| 6 |
+
State : [h(9), X_t(9), budget_remaining/W(1), t_hours(1)] β R^20
|
| 7 |
+
Action : Discrete(512) β one of 2^9 joint component-replacement combinations
|
| 8 |
+
Reward : +1.0 per survived hour (dt=1); 0.0 on the terminal step
|
| 9 |
+
Done : any h_i < HEALTH_THRESHOLD OR replacement cost exceeds budget
|
| 10 |
+
|
| 11 |
+
Algorithm
|
| 12 |
+
---------
|
| 13 |
+
PPO (Stable-Baselines3) with a 512-output Softmax actor.
|
| 14 |
+
The Softmax over the full 2^9 action space captures all joint replacement
|
| 15 |
+
correlations without requiring an autoregressive architecture.
|
| 16 |
+
|
| 17 |
+
Actor: Linear(20β64) β Tanh β Linear(64β64) β Tanh β Linear(64β512)
|
| 18 |
+
Critic: Linear(20β64) β Tanh β Linear(64β64) β Tanh β Linear(64β1)
|
| 19 |
+
|
| 20 |
+
SB3 default orthogonal init + 0.01 final-layer scale β initial policy
|
| 21 |
+
is near-uniform over all 512 actions (max-entropy start).
|
| 22 |
+
|
| 23 |
+
Component costs (β¬)
|
| 24 |
+
--------------------
|
| 25 |
+
Sourced from analogous industrial parts; HP does not publish official prices.
|
| 26 |
+
Sources listed in DEFAULT_COSTS docstring below.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import torch as th
|
| 33 |
+
import gymnasium as gym
|
| 34 |
+
from gymnasium import spaces
|
| 35 |
+
from stable_baselines3 import PPO
|
| 36 |
+
|
| 37 |
+
from model import DegradationModel
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Constants
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
COMPONENT_NAMES: list[str] = [
|
| 44 |
+
"recoater_blade", # 0
|
| 45 |
+
"nozzle_plate", # 1
|
| 46 |
+
"heating_elements", # 2
|
| 47 |
+
"temperature_sensors", # 3
|
| 48 |
+
"insulation_panels", # 4
|
| 49 |
+
"firing_resistors", # 5
|
| 50 |
+
"cleaning_interface", # 6
|
| 51 |
+
"recoater_motor", # 7
|
| 52 |
+
"linear_rail", # 8
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
# Replacement costs in euros per component.
|
| 56 |
+
# Sources:
|
| 57 |
+
# recoater_blade β WINK3D EOS M290 ceramic blade ~$160, scaled to HP Metal Jet tier
|
| 58 |
+
# https://winking3d.com/product/ceramic-recoater-blade-3/
|
| 59 |
+
# nozzle_plate β 6Γ HP TIJ printheads; HP industrial OEM pricing estimate
|
| 60 |
+
# https://3dprintingindustry.com/news/hp-launches-new-metal-jet-s100...
|
| 61 |
+
# heating_elements β Industrial resistive heating elements for 180Β°C chamber
|
| 62 |
+
# https://www.sentrotech.com/heating-elements/
|
| 63 |
+
# temperature_sensors β Industrial PT100/thermocouple bundle (Omega, multiple sensors)
|
| 64 |
+
# https://www.omega.co.uk/pptst/T3PROBES.html
|
| 65 |
+
# insulation_panels β Ceramic fibre panel section for printer-sized chamber
|
| 66 |
+
# https://www.sentrotech.com/ceramic-fiber-insulation/
|
| 67 |
+
# firing_resistors β Printhead array embedded resistors, proprietary HP part
|
| 68 |
+
# cleaning_interface β Wiper blade + solvent delivery module
|
| 69 |
+
# https://digiprint-usa.com/blogs/printhead-guides-tips-digiprint-usa/...
|
| 70 |
+
# recoater_motor β 500 Wβ1 kW industrial servo motor
|
| 71 |
+
# https://teknic.com/products/clearpath-brushless-dc-servo-motors/
|
| 72 |
+
# linear_rail β THK/HIWIN precision guide rail assembly
|
| 73 |
+
DEFAULT_COSTS = np.array([
|
| 74 |
+
350.0, # recoater_blade
|
| 75 |
+
2500.0, # nozzle_plate
|
| 76 |
+
1500.0, # heating_elements
|
| 77 |
+
200.0, # temperature_sensors
|
| 78 |
+
800.0, # insulation_panels
|
| 79 |
+
400.0, # firing_resistors
|
| 80 |
+
600.0, # cleaning_interface
|
| 81 |
+
900.0, # recoater_motor
|
| 82 |
+
600.0, # linear_rail
|
| 83 |
+
], dtype=np.float64)
|
| 84 |
+
|
| 85 |
+
HEALTH_THRESHOLD: float = 0.1 # printer fails when any component drops below this
|
| 86 |
+
N_COMPONENTS: int = 9
|
| 87 |
+
N_ACTIONS: int = 2 ** N_COMPONENTS # 512
|
| 88 |
+
|
| 89 |
+
# Precompute action β binary replacement vector table once at import time.
|
| 90 |
+
# _ACTION_TABLE[i] is a (9,) float32 array: bit j = 1 means replace component j.
|
| 91 |
+
_ACTION_TABLE: np.ndarray = np.array(
|
| 92 |
+
[[int(b) for b in format(i, f"0{N_COMPONENTS}b")] for i in range(N_ACTIONS)],
|
| 93 |
+
dtype=np.float32,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
# Environment
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
|
| 101 |
+
class PrinterEnv(gym.Env):
|
| 102 |
+
"""
|
| 103 |
+
Gymnasium environment wrapping DegradationModel for replacement scheduling.
|
| 104 |
+
|
| 105 |
+
Parameters
|
| 106 |
+
----------
|
| 107 |
+
model : Fitted DegradationModel with N=9 components and C inputs.
|
| 108 |
+
X_series : (T_max, C) array of predicted operating-condition vectors,
|
| 109 |
+
one row per simulated hour. If the episode outlasts X_series
|
| 110 |
+
the last row is repeated.
|
| 111 |
+
W : Total budget in euros available for the episode.
|
| 112 |
+
costs : (9,) replacement cost per component in euros.
|
| 113 |
+
Defaults to DEFAULT_COSTS when None.
|
| 114 |
+
dt : Simulation time-step in hours (default 1.0).
|
| 115 |
+
stochastic : Include Poisson shock terms from model.lambda_rates (default True).
|
| 116 |
+
seed : RNG seed for reproducibility.
|
| 117 |
+
"""
|
| 118 |
+
|
| 119 |
+
metadata: dict = {"render_modes": []}
|
| 120 |
+
|
| 121 |
+
def __init__(
|
| 122 |
+
self,
|
| 123 |
+
model: DegradationModel,
|
| 124 |
+
X_series: np.ndarray,
|
| 125 |
+
W: float,
|
| 126 |
+
costs: np.ndarray | None = None,
|
| 127 |
+
dt: float = 1.0,
|
| 128 |
+
stochastic: bool = True,
|
| 129 |
+
seed: int | None = None,
|
| 130 |
+
) -> None:
|
| 131 |
+
"""
|
| 132 |
+
Initialise the environment and validate inputs.
|
| 133 |
+
|
| 134 |
+
Builds the observation and action spaces, stores a reference to the
|
| 135 |
+
fitted DegradationModel, and sets the episode state to its initial
|
| 136 |
+
values (all components fully healthy, full budget, t=0).
|
| 137 |
+
|
| 138 |
+
Raises ValueError if model.N != 9.
|
| 139 |
+
"""
|
| 140 |
+
super().__init__()
|
| 141 |
+
|
| 142 |
+
if model.N != N_COMPONENTS:
|
| 143 |
+
raise ValueError(f"DegradationModel must have N={N_COMPONENTS}, got {model.N}")
|
| 144 |
+
|
| 145 |
+
self.model = model
|
| 146 |
+
self.X_series = np.asarray(X_series, dtype=np.float64) # (T_max, C)
|
| 147 |
+
self.W = float(W)
|
| 148 |
+
self.costs = (
|
| 149 |
+
np.asarray(costs, dtype=np.float64) if costs is not None else DEFAULT_COSTS
|
| 150 |
+
)
|
| 151 |
+
self.dt = float(dt)
|
| 152 |
+
self.stochastic = stochastic
|
| 153 |
+
self._rng = np.random.default_rng(seed)
|
| 154 |
+
|
| 155 |
+
# Observation: h(9) + X_t(C) + budget/W(1) + t_hours(1)
|
| 156 |
+
obs_dim = N_COMPONENTS + model.C + 2
|
| 157 |
+
self.observation_space = spaces.Box(
|
| 158 |
+
low=-np.inf, high=np.inf, shape=(obs_dim,), dtype=np.float32
|
| 159 |
+
)
|
| 160 |
+
self.action_space = spaces.Discrete(N_ACTIONS)
|
| 161 |
+
|
| 162 |
+
# Episode state β initialised properly in reset()
|
| 163 |
+
self._h = np.ones(N_COMPONENTS, dtype=np.float64)
|
| 164 |
+
self._t: float = 0.0
|
| 165 |
+
self._budget: float = self.W
|
| 166 |
+
self._step_idx: int = 0
|
| 167 |
+
|
| 168 |
+
# ------------------------------------------------------------------
|
| 169 |
+
# Core Gymnasium interface
|
| 170 |
+
# ------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
def reset(
|
| 173 |
+
self,
|
| 174 |
+
*,
|
| 175 |
+
seed: int | None = None,
|
| 176 |
+
options: dict | None = None,
|
| 177 |
+
) -> tuple[np.ndarray, dict]:
|
| 178 |
+
"""
|
| 179 |
+
Reset the environment to the start of a new episode.
|
| 180 |
+
|
| 181 |
+
All components are restored to full health (h=1), the budget is
|
| 182 |
+
refilled to W, and the simulation clock is set to t=0. If a seed
|
| 183 |
+
is supplied the internal RNG is re-seeded for reproducibility.
|
| 184 |
+
|
| 185 |
+
Returns
|
| 186 |
+
-------
|
| 187 |
+
obs : (obs_dim,) float32 observation vector.
|
| 188 |
+
info : empty dict (required by the Gymnasium API).
|
| 189 |
+
"""
|
| 190 |
+
if seed is not None:
|
| 191 |
+
self._rng = np.random.default_rng(seed)
|
| 192 |
+
|
| 193 |
+
self._h = np.ones(N_COMPONENTS, dtype=np.float64)
|
| 194 |
+
self._t = 0.0
|
| 195 |
+
self._budget = self.W
|
| 196 |
+
self._step_idx = 0
|
| 197 |
+
|
| 198 |
+
return self._obs(), {}
|
| 199 |
+
|
| 200 |
+
def step(
|
| 201 |
+
self, action: int
|
| 202 |
+
) -> tuple[np.ndarray, float, bool, bool, dict]:
|
| 203 |
+
"""
|
| 204 |
+
Advance the simulation by one hour and apply the chosen replacements.
|
| 205 |
+
|
| 206 |
+
The action integer is decoded to a 9-bit binary replacement vector via
|
| 207 |
+
_ACTION_TABLE. Components whose bit is 1 are replaced (health reset to
|
| 208 |
+
1.0) before the ODE step is taken, so new components benefit from the
|
| 209 |
+
full degradation rate of a healthy part.
|
| 210 |
+
|
| 211 |
+
Budget is checked before applying replacements. If the total cost of
|
| 212 |
+
the selected action exceeds the remaining budget the episode terminates
|
| 213 |
+
immediately with reward 0 and no state change.
|
| 214 |
+
|
| 215 |
+
After replacements the health vector is updated with one Euler step:
|
| 216 |
+
|
| 217 |
+
h_{t+1} = h_t + f(h_t, X_t) * dt [- Q * H if stochastic]
|
| 218 |
+
|
| 219 |
+
where Q ~ N(0,1)Β² and H ~ Poisson(Ξ»_i * dt) are independent per
|
| 220 |
+
component (matching the stochastic extension in model.py Β§5).
|
| 221 |
+
|
| 222 |
+
Parameters
|
| 223 |
+
----------
|
| 224 |
+
action : int in [0, 511].
|
| 225 |
+
|
| 226 |
+
Returns
|
| 227 |
+
-------
|
| 228 |
+
obs : (obs_dim,) float32 observation after the step.
|
| 229 |
+
reward : dt (1.0) if the printer survived; 0.0 on failure.
|
| 230 |
+
terminated : True when any h_i < HEALTH_THRESHOLD or budget exceeded.
|
| 231 |
+
truncated : always False (no fixed time limit).
|
| 232 |
+
info : dict with keys t_hours, budget_remaining, min_health
|
| 233 |
+
(or termination on budget failure).
|
| 234 |
+
"""
|
| 235 |
+
bits = _ACTION_TABLE[int(action)] # (9,) float32 binary vector
|
| 236 |
+
replacement_cost = float(np.dot(bits, self.costs))
|
| 237 |
+
|
| 238 |
+
# Hard budget constraint: action is unaffordable β episode ends
|
| 239 |
+
if replacement_cost > self._budget:
|
| 240 |
+
return self._obs(), 0.0, True, False, {
|
| 241 |
+
"termination": "budget_exceeded",
|
| 242 |
+
"t_hours": self._t,
|
| 243 |
+
"budget_remaining": self._budget,
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
# Apply replacements: reset selected components to full health
|
| 247 |
+
self._budget -= replacement_cost
|
| 248 |
+
self._h[bits.astype(bool)] = 1.0
|
| 249 |
+
|
| 250 |
+
# Euler step: dh = f(h, X_t) * dt [+ optional stochastic shock]
|
| 251 |
+
X_t = self._current_X()
|
| 252 |
+
dh = self.model.f(self._h, X_t) * self.dt
|
| 253 |
+
|
| 254 |
+
if self.stochastic:
|
| 255 |
+
Q = self._rng.standard_normal(N_COMPONENTS) ** 2 # N(0,1)Β² β event intensity
|
| 256 |
+
H = self._rng.poisson(self.model.lambda_rates * self.dt) # Poisson(Ξ»_i Β· dt)
|
| 257 |
+
dh -= Q * H
|
| 258 |
+
|
| 259 |
+
self._h = self._h + dh
|
| 260 |
+
self._t += self.dt
|
| 261 |
+
self._step_idx += 1
|
| 262 |
+
|
| 263 |
+
# Failure: any component below threshold
|
| 264 |
+
failed = bool(np.any(self._h < HEALTH_THRESHOLD))
|
| 265 |
+
reward = 0.0 if failed else self.dt
|
| 266 |
+
|
| 267 |
+
return self._obs(), reward, failed, False, {
|
| 268 |
+
"t_hours": self._t,
|
| 269 |
+
"budget_remaining": self._budget,
|
| 270 |
+
"min_health": float(self._h.min()),
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
# ------------------------------------------------------------------
|
| 274 |
+
# Helpers
|
| 275 |
+
# ------------------------------------------------------------------
|
| 276 |
+
|
| 277 |
+
def _obs(self) -> np.ndarray:
|
| 278 |
+
"""
|
| 279 |
+
Build the current observation vector.
|
| 280 |
+
|
| 281 |
+
Layout (obs_dim = 9 + C + 2):
|
| 282 |
+
h[0:9] β component health values (float, may be negative
|
| 283 |
+
after a stochastic shock representing catastrophic
|
| 284 |
+
failure before the done flag is raised)
|
| 285 |
+
X_t[9:9+C] β current operating-condition inputs from X_series
|
| 286 |
+
budget/W [9+C] β remaining budget as a fraction of the initial
|
| 287 |
+
budget W; always in [0, 1] during a valid episode
|
| 288 |
+
t_hours [9+C+1] β elapsed simulation time in hours
|
| 289 |
+
|
| 290 |
+
Returns float32 to match observation_space.dtype.
|
| 291 |
+
"""
|
| 292 |
+
X_t = self._current_X()
|
| 293 |
+
return np.concatenate([
|
| 294 |
+
self._h, # 9 β component healths (may be < 0 stochastic)
|
| 295 |
+
X_t, # C β current operating conditions
|
| 296 |
+
[self._budget / self.W], # 1 β remaining budget fraction β [0, 1]
|
| 297 |
+
[self._t], # 1 β elapsed hours
|
| 298 |
+
]).astype(np.float32)
|
| 299 |
+
|
| 300 |
+
def _current_X(self) -> np.ndarray:
|
| 301 |
+
"""
|
| 302 |
+
Return the operating-condition vector for the current simulation step.
|
| 303 |
+
|
| 304 |
+
Clamps the index to the last row of X_series if the episode outlasts
|
| 305 |
+
the provided forecast horizon, so the environment never raises an
|
| 306 |
+
IndexError regardless of episode length.
|
| 307 |
+
"""
|
| 308 |
+
idx = min(self._step_idx, len(self.X_series) - 1)
|
| 309 |
+
return self.X_series[idx]
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
# ---------------------------------------------------------------------------
|
| 313 |
+
# Training
|
| 314 |
+
# ---------------------------------------------------------------------------
|
| 315 |
+
|
| 316 |
+
def train(
|
| 317 |
+
env: PrinterEnv,
|
| 318 |
+
*,
|
| 319 |
+
total_timesteps: int = 1_000_000,
|
| 320 |
+
save_path: str = "scheduler_ppo",
|
| 321 |
+
learning_rate: float = 3e-4,
|
| 322 |
+
n_steps: int = 2048,
|
| 323 |
+
batch_size: int = 64,
|
| 324 |
+
n_epochs: int = 10,
|
| 325 |
+
gamma: float = 0.99,
|
| 326 |
+
ent_coef: float = 0.01,
|
| 327 |
+
verbose: int = 1,
|
| 328 |
+
) -> PPO:
|
| 329 |
+
"""
|
| 330 |
+
Train a PPO agent on PrinterEnv and save the result to disk.
|
| 331 |
+
|
| 332 |
+
Architecture
|
| 333 |
+
------------
|
| 334 |
+
Both the actor and critic share the same MLP topology
|
| 335 |
+
(two hidden layers of 64 units with Tanh activations) but have
|
| 336 |
+
separate weights, as is standard in Actor-Critic methods:
|
| 337 |
+
|
| 338 |
+
Actor (Ο_ΞΈ): obs(20) β 64 β 64 β logits(512) β Softmax
|
| 339 |
+
Critic (V_Ο): obs(20) β 64 β 64 β scalar(1)
|
| 340 |
+
|
| 341 |
+
The 512 Softmax output over the full 2^9 action space implicitly models
|
| 342 |
+
the joint probability distribution over all replacement combinations,
|
| 343 |
+
capturing correlations (e.g. replacing component 0 should reduce the
|
| 344 |
+
probability of also replacing component 1 if they interact positively)
|
| 345 |
+
without requiring an autoregressive sampling pass.
|
| 346 |
+
|
| 347 |
+
Initialisation
|
| 348 |
+
--------------
|
| 349 |
+
SB3 applies orthogonal initialisation to all layers (scale β2 for hidden,
|
| 350 |
+
scale 0.01 for the final policy layer). With scale 0.01 the logits start
|
| 351 |
+
near zero, so Softmax(~0) β 1/512 β effectively maximum entropy over the
|
| 352 |
+
action space, ensuring the agent explores broadly before committing.
|
| 353 |
+
|
| 354 |
+
Hyperparameters
|
| 355 |
+
---------------
|
| 356 |
+
learning_rate : Adam step size (3e-4 is a reliable PPO default).
|
| 357 |
+
n_steps : Rollout length before each PPO update (2048 steps β
|
| 358 |
+
one to several full episodes depending on episode length).
|
| 359 |
+
batch_size : Mini-batch size for gradient updates.
|
| 360 |
+
n_epochs : Number of gradient passes over each collected rollout.
|
| 361 |
+
gamma : Discount factor; 0.99 weights future rewards heavily,
|
| 362 |
+
encouraging the agent to maximise long-term lifespan.
|
| 363 |
+
ent_coef : Entropy bonus coefficient; keeps action probabilities from
|
| 364 |
+
collapsing to a single action too early in training.
|
| 365 |
+
|
| 366 |
+
Parameters
|
| 367 |
+
----------
|
| 368 |
+
env : Configured PrinterEnv instance.
|
| 369 |
+
total_timesteps : Total environment steps to train for.
|
| 370 |
+
save_path : File path (without .zip) for the saved model.
|
| 371 |
+
learning_rate : Adam learning rate.
|
| 372 |
+
n_steps : Rollout buffer size (steps per PPO update).
|
| 373 |
+
batch_size : SGD mini-batch size.
|
| 374 |
+
n_epochs : PPO epochs per rollout.
|
| 375 |
+
gamma : Discount factor Ξ³.
|
| 376 |
+
ent_coef : Entropy regularisation coefficient.
|
| 377 |
+
verbose : SB3 verbosity level (0=silent, 1=info, 2=debug).
|
| 378 |
+
|
| 379 |
+
Returns
|
| 380 |
+
-------
|
| 381 |
+
PPO : The trained Stable-Baselines3 PPO model, ready for evaluate().
|
| 382 |
+
"""
|
| 383 |
+
policy_kwargs = dict(
|
| 384 |
+
net_arch=dict(pi=[64, 64], vf=[64, 64]),
|
| 385 |
+
activation_fn=th.nn.Tanh,
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
ppo = PPO(
|
| 389 |
+
policy="MlpPolicy",
|
| 390 |
+
env=env,
|
| 391 |
+
learning_rate=learning_rate,
|
| 392 |
+
n_steps=n_steps,
|
| 393 |
+
batch_size=batch_size,
|
| 394 |
+
n_epochs=n_epochs,
|
| 395 |
+
gamma=gamma,
|
| 396 |
+
ent_coef=ent_coef,
|
| 397 |
+
policy_kwargs=policy_kwargs,
|
| 398 |
+
verbose=verbose,
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
ppo.learn(total_timesteps=total_timesteps)
|
| 402 |
+
ppo.save(save_path)
|
| 403 |
+
if verbose:
|
| 404 |
+
print(f"[train] Model saved to {save_path}.zip")
|
| 405 |
+
return ppo
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
# ---------------------------------------------------------------------------
|
| 409 |
+
# Evaluation
|
| 410 |
+
# ---------------------------------------------------------------------------
|
| 411 |
+
|
| 412 |
+
def evaluate(
|
| 413 |
+
ppo: PPO,
|
| 414 |
+
env: PrinterEnv,
|
| 415 |
+
n_episodes: int = 10,
|
| 416 |
+
) -> dict:
|
| 417 |
+
"""
|
| 418 |
+
Run the trained policy deterministically and return summary statistics.
|
| 419 |
+
|
| 420 |
+
Each episode starts from a fresh reset() call. The policy is queried with
|
| 421 |
+
deterministic=True, meaning the action with the highest probability under
|
| 422 |
+
the current Softmax distribution is always selected (no sampling noise).
|
| 423 |
+
This gives a reproducible, greedy estimate of the policy's performance.
|
| 424 |
+
|
| 425 |
+
Parameters
|
| 426 |
+
----------
|
| 427 |
+
ppo : Trained PPO model returned by train().
|
| 428 |
+
env : PrinterEnv instance (can be the same env used for training).
|
| 429 |
+
n_episodes : Number of evaluation episodes to average over.
|
| 430 |
+
|
| 431 |
+
Returns
|
| 432 |
+
-------
|
| 433 |
+
dict with keys:
|
| 434 |
+
mean_hours β average hours survived across all episodes.
|
| 435 |
+
std_hours β standard deviation of hours survived.
|
| 436 |
+
mean_replacements β average total number of component replacements
|
| 437 |
+
performed per episode (summed over all steps).
|
| 438 |
+
mean_budget_spent β average euros spent on replacements per episode.
|
| 439 |
+
episodes β list of per-episode dicts, each containing:
|
| 440 |
+
episode, hours_survived, budget_spent, replacements.
|
| 441 |
+
"""
|
| 442 |
+
hours, n_replacements, budget_spent = [], [], []
|
| 443 |
+
episodes = []
|
| 444 |
+
|
| 445 |
+
for ep in range(n_episodes):
|
| 446 |
+
obs, _ = env.reset()
|
| 447 |
+
done = False
|
| 448 |
+
ep_replacements = 0
|
| 449 |
+
|
| 450 |
+
while not done:
|
| 451 |
+
action, _ = ppo.predict(obs, deterministic=True)
|
| 452 |
+
obs, _, terminated, truncated, _ = env.step(int(action))
|
| 453 |
+
done = terminated or truncated
|
| 454 |
+
ep_replacements += int(_ACTION_TABLE[int(action)].sum())
|
| 455 |
+
|
| 456 |
+
hours.append(env._t)
|
| 457 |
+
n_replacements.append(ep_replacements)
|
| 458 |
+
budget_spent.append(env.W - env._budget)
|
| 459 |
+
episodes.append({
|
| 460 |
+
"episode": ep,
|
| 461 |
+
"hours_survived": env._t,
|
| 462 |
+
"budget_spent": env.W - env._budget,
|
| 463 |
+
"replacements": ep_replacements,
|
| 464 |
+
})
|
| 465 |
+
|
| 466 |
+
return {
|
| 467 |
+
"mean_hours": float(np.mean(hours)),
|
| 468 |
+
"std_hours": float(np.std(hours)),
|
| 469 |
+
"mean_replacements": float(np.mean(n_replacements)),
|
| 470 |
+
"mean_budget_spent": float(np.mean(budget_spent)),
|
| 471 |
+
"episodes": episodes,
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
# ---------------------------------------------------------------------------
|
| 476 |
+
# Sanity demo
|
| 477 |
+
# ---------------------------------------------------------------------------
|
| 478 |
+
|
| 479 |
+
if __name__ == "__main__":
|
| 480 |
+
|
| 481 |
+
N, C = 9, 9
|
| 482 |
+
lambda_rates = np.array([0.05, 0.10, 0.02, 0.03, 0.01, 0.08, 0.06, 0.04, 0.03])
|
| 483 |
+
|
| 484 |
+
deg_model = DegradationModel(
|
| 485 |
+
N=N, C=C, lambda_rates=lambda_rates, seed=0
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
# Synthetic X_series: 8 000 hours of operating conditions drawn from [0, 1]
|
| 489 |
+
rng = np.random.default_rng(42)
|
| 490 |
+
#X_series = rng.uniform(0.0, 1.0, size=(8_000, C))
|
| 491 |
+
|
| 492 |
+
env = PrinterEnv(
|
| 493 |
+
model=deg_model,
|
| 494 |
+
X_series=X_series,
|
| 495 |
+
W=10_000.0,
|
| 496 |
+
dt=1.0,
|
| 497 |
+
stochastic=True,
|
| 498 |
+
seed=42,
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
print("Observation space:", env.observation_space.shape)
|
| 502 |
+
print("Action space: ", env.action_space.n, "discrete actions")
|
| 503 |
+
print()
|
| 504 |
+
|
| 505 |
+
# --- Random-policy baseline ---
|
| 506 |
+
obs, _ = env.reset(seed=0)
|
| 507 |
+
done = False
|
| 508 |
+
while not done:
|
| 509 |
+
action = env.action_space.sample()
|
| 510 |
+
obs, _, terminated, truncated, info = env.step(action)
|
| 511 |
+
done = terminated or truncated
|
| 512 |
+
print(f"Random policy β {env._t:.0f} h survived | β¬{env.W - env._budget:.0f} spent")
|
| 513 |
+
|
| 514 |
+
# --- PPO training (short demo: 200k steps) ---
|
| 515 |
+
print("\nTraining PPO (200 000 timesteps) ...")
|
| 516 |
+
trained = train(
|
| 517 |
+
env,
|
| 518 |
+
total_timesteps=200_000,
|
| 519 |
+
save_path="scheduler_ppo",
|
| 520 |
+
verbose=1,
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
# --- Evaluation ---
|
| 524 |
+
results = evaluate(trained, env, n_episodes=10)
|
| 525 |
+
print(f"\n{'β'*45}")
|
| 526 |
+
print(f" Mean hours survived : {results['mean_hours']:>8.1f} h")
|
| 527 |
+
print(f" Std hours : {results['std_hours']:>8.1f} h")
|
| 528 |
+
print(f" Mean replacements : {results['mean_replacements']:>8.1f}")
|
| 529 |
+
print(f" Mean budget spent : β¬{results['mean_budget_spent']:>7.0f}")
|
| 530 |
+
print(f"{'β'*45}")
|