Dan Vancea commited on
Commit
7c5df99
Β·
1 Parent(s): 798b15b
Files changed (3) hide show
  1. model.py +509 -0
  2. predict_from_supabase.py +140 -0
  3. 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}")