File size: 21,712 Bytes
7c5df99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ebd57ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c5df99
ebd57ea
 
 
 
 
7c5df99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
"""
scheduling_rl.py β€” PPO-based replacement scheduling for the HP Metal Jet S100.

Environment
-----------
    State  : [h(9), X_t(9), budget_remaining/W(1), t_hours(1)]  β†’  R^20
    Action : Discrete(512) β€” one of 2^9 joint component-replacement combinations
    Reward : +1.0 per survived hour (dt=1); 0.0 on the terminal step
    Done   : any h_i < HEALTH_THRESHOLD  OR  replacement cost exceeds budget

Algorithm
---------
    PPO (Stable-Baselines3) with a 512-output Softmax actor.
    The Softmax over the full 2^9 action space captures all joint replacement
    correlations without requiring an autoregressive architecture.

    Actor:  Linear(20β†’64) β†’ Tanh β†’ Linear(64β†’64) β†’ Tanh β†’ Linear(64β†’512)
    Critic: Linear(20β†’64) β†’ Tanh β†’ Linear(64β†’64) β†’ Tanh β†’ Linear(64β†’1)

    SB3 default orthogonal init + 0.01 final-layer scale β†’ initial policy
    is near-uniform over all 512 actions (max-entropy start).

Component costs (€)
--------------------
    Sourced from analogous industrial parts; HP does not publish official prices.
    Sources listed in DEFAULT_COSTS docstring below.
"""

from __future__ import annotations

import numpy as np
import torch as th
import gymnasium as gym
from gymnasium import spaces
from stable_baselines3 import PPO

from model import DegradationModel

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

COMPONENT_NAMES: list[str] = [
    "recoater_blade",       # 0
    "nozzle_plate",         # 1
    "heating_elements",     # 2
    "temperature_sensors",  # 3
    "insulation_panels",    # 4
    "firing_resistors",     # 5
    "cleaning_interface",   # 6
    "recoater_motor",       # 7
    "linear_rail",          # 8
]

# Replacement costs in euros per component.
# Sources:
#   recoater_blade      β€” WINK3D EOS M290 ceramic blade ~$160, scaled to HP Metal Jet tier
#                         https://winking3d.com/product/ceramic-recoater-blade-3/
#   nozzle_plate        β€” 6Γ— HP TIJ printheads; HP industrial OEM pricing estimate
#                         https://3dprintingindustry.com/news/hp-launches-new-metal-jet-s100...
#   heating_elements    β€” Industrial resistive heating elements for 180Β°C chamber
#                         https://www.sentrotech.com/heating-elements/
#   temperature_sensors β€” Industrial PT100/thermocouple bundle (Omega, multiple sensors)
#                         https://www.omega.co.uk/pptst/T3PROBES.html
#   insulation_panels   β€” Ceramic fibre panel section for printer-sized chamber
#                         https://www.sentrotech.com/ceramic-fiber-insulation/
#   firing_resistors    β€” Printhead array embedded resistors, proprietary HP part
#   cleaning_interface  β€” Wiper blade + solvent delivery module
#                         https://digiprint-usa.com/blogs/printhead-guides-tips-digiprint-usa/...
#   recoater_motor      β€” 500 W–1 kW industrial servo motor
#                         https://teknic.com/products/clearpath-brushless-dc-servo-motors/
#   linear_rail         β€” THK/HIWIN precision guide rail assembly
DEFAULT_COSTS = np.array([
     350.0,   # recoater_blade
    2500.0,   # nozzle_plate
    1500.0,   # heating_elements
     200.0,   # temperature_sensors
     800.0,   # insulation_panels
     400.0,   # firing_resistors
     600.0,   # cleaning_interface
     900.0,   # recoater_motor
     600.0,   # linear_rail
], dtype=np.float64)

HEALTH_THRESHOLD: float = 0.1   # printer fails when any component drops below this
N_COMPONENTS: int = 9
N_ACTIONS: int = 2 ** N_COMPONENTS  # 512

# Precompute action β†’ binary replacement vector table once at import time.
# _ACTION_TABLE[i] is a (9,) float32 array: bit j = 1 means replace component j.
_ACTION_TABLE: np.ndarray = np.array(
    [[int(b) for b in format(i, f"0{N_COMPONENTS}b")] for i in range(N_ACTIONS)],
    dtype=np.float32,
)


# ---------------------------------------------------------------------------
# Environment
# ---------------------------------------------------------------------------

class PrinterEnv(gym.Env):
    """
    Gymnasium environment wrapping DegradationModel for replacement scheduling.

    Parameters
    ----------
    model       : Fitted DegradationModel with N=9 components and C inputs.
    X_series    : (T_max, C) array of predicted operating-condition vectors,
                  one row per simulated hour. If the episode outlasts X_series
                  the last row is repeated.
    W           : Total budget in euros available for the episode.
    costs       : (9,) replacement cost per component in euros.
                  Defaults to DEFAULT_COSTS when None.
    dt          : Simulation time-step in hours (default 1.0).
    stochastic  : Include Poisson shock terms from model.lambda_rates (default True).
    seed        : RNG seed for reproducibility.
    """

    metadata: dict = {"render_modes": []}

    def __init__(
        self,
        model: DegradationModel,
        X_series: np.ndarray,
        W: float,
        costs: np.ndarray | None = None,
        dt: float = 1.0,
        stochastic: bool = True,
        seed: int | None = None,
    ) -> None:
        """
        Initialise the environment and validate inputs.

        Builds the observation and action spaces, stores a reference to the
        fitted DegradationModel, and sets the episode state to its initial
        values (all components fully healthy, full budget, t=0).

        Raises ValueError if model.N != 9.
        """
        super().__init__()

        if model.N != N_COMPONENTS:
            raise ValueError(f"DegradationModel must have N={N_COMPONENTS}, got {model.N}")

        self.model = model
        self.X_series = np.asarray(X_series, dtype=np.float64)  # (T_max, C)
        self.W = float(W)
        self.costs = (
            np.asarray(costs, dtype=np.float64) if costs is not None else DEFAULT_COSTS
        )
        self.dt = float(dt)
        self.stochastic = stochastic
        self._rng = np.random.default_rng(seed)

        # Observation: h(9) + X_t(C) + budget/W(1) + t_hours(1)
        obs_dim = N_COMPONENTS + model.C + 2
        self.observation_space = spaces.Box(
            low=-np.inf, high=np.inf, shape=(obs_dim,), dtype=np.float32
        )
        self.action_space = spaces.Discrete(N_ACTIONS)

        # Episode state β€” initialised properly in reset()
        self._h = np.ones(N_COMPONENTS, dtype=np.float64)
        self._t: float = 0.0
        self._budget: float = self.W
        self._step_idx: int = 0

    # ------------------------------------------------------------------
    # Core Gymnasium interface
    # ------------------------------------------------------------------

    def reset(
        self,
        *,
        seed: int | None = None,
        options: dict | None = None,
    ) -> tuple[np.ndarray, dict]:
        """
        Reset the environment to the start of a new episode.

        All components are restored to full health (h=1), the budget is
        refilled to W, and the simulation clock is set to t=0. If a seed
        is supplied the internal RNG is re-seeded for reproducibility.

        Returns
        -------
        obs : (obs_dim,) float32 observation vector.
        info : empty dict (required by the Gymnasium API).
        """
        if seed is not None:
            self._rng = np.random.default_rng(seed)

        self._h = np.ones(N_COMPONENTS, dtype=np.float64)
        self._t = 0.0
        self._budget = self.W
        self._step_idx = 0

        return self._obs(), {}

    def step(
        self, action: int
    ) -> tuple[np.ndarray, float, bool, bool, dict]:
        """
        Advance the simulation by one hour and apply the chosen replacements.

        The action integer is decoded to a 9-bit binary replacement vector via
        _ACTION_TABLE. Components whose bit is 1 are replaced (health reset to
        1.0) before the ODE step is taken, so new components benefit from the
        full degradation rate of a healthy part.

        Budget is checked before applying replacements. If the total cost of
        the selected action exceeds the remaining budget the episode terminates
        immediately with reward 0 and no state change.

        After replacements the health vector is updated with one Euler step:

            h_{t+1} = h_t + f(h_t, X_t) * dt  [- Q * H  if stochastic]

        where Q ~ N(0,1)Β² and H ~ Poisson(Ξ»_i * dt) are independent per
        component (matching the stochastic extension in model.py Β§5).

        Parameters
        ----------
        action : int in [0, 511].

        Returns
        -------
        obs         : (obs_dim,) float32 observation after the step.
        reward      : dt (1.0) if the printer survived; 0.0 on failure.
        terminated  : True when any h_i < HEALTH_THRESHOLD or budget exceeded.
        truncated   : always False (no fixed time limit).
        info        : dict with keys t_hours, budget_remaining, min_health
                      (or termination on budget failure).
        """
        bits = _ACTION_TABLE[int(action)]          # (9,) float32 binary vector
        replacement_cost = float(np.dot(bits, self.costs))

        # Hard budget constraint: action is unaffordable β†’ episode ends
        if replacement_cost > self._budget:
            return self._obs(), 0.0, True, False, {
                "termination": "budget_exceeded",
                "t_hours": self._t,
                "budget_remaining": self._budget,
            }

        # Apply replacements: reset selected components to full health
        self._budget -= replacement_cost
        self._h[bits.astype(bool)] = 1.0

        # Euler step: dh = f(h, X_t) * dt  [+ optional stochastic shock]
        X_t = self._current_X()
        dh = self.model.f(self._h, X_t) * self.dt

        if self.stochastic:
            Q = self._rng.standard_normal(N_COMPONENTS) ** 2          # N(0,1)Β² β€” event intensity
            H = self._rng.poisson(self.model.lambda_rates * self.dt)  # Poisson(Ξ»_i Β· dt)
            dh -= Q * H

        self._h = self._h + dh
        self._t += self.dt
        self._step_idx += 1

        # Failure: any component below threshold
        failed = bool(np.any(self._h < HEALTH_THRESHOLD))
        reward = 0.0 if failed else self.dt

        return self._obs(), reward, failed, False, {
            "t_hours": self._t,
            "budget_remaining": self._budget,
            "min_health": float(self._h.min()),
        }

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _obs(self) -> np.ndarray:
        """
        Build the current observation vector.

        Layout (obs_dim = 9 + C + 2):
            h[0:9]              β€” component health values (float, may be negative
                                  after a stochastic shock representing catastrophic
                                  failure before the done flag is raised)
            X_t[9:9+C]         β€” current operating-condition inputs from X_series
            budget/W [9+C]     β€” remaining budget as a fraction of the initial
                                  budget W; always in [0, 1] during a valid episode
            t_hours  [9+C+1]   β€” elapsed simulation time in hours

        Returns float32 to match observation_space.dtype.
        """
        X_t = self._current_X()
        return np.concatenate([
            self._h,                        # 9  β€” component healths (may be < 0 stochastic)
            X_t,                            # C  β€” current operating conditions
            [self._budget / self.W],        # 1  β€” remaining budget fraction ∈ [0, 1]
            [self._t],                      # 1  β€” elapsed hours
        ]).astype(np.float32)

    def _current_X(self) -> np.ndarray:
        """
        Return the operating-condition vector for the current simulation step.

        Clamps the index to the last row of X_series if the episode outlasts
        the provided forecast horizon, so the environment never raises an
        IndexError regardless of episode length.
        """
        idx = min(self._step_idx, len(self.X_series) - 1)
        return self.X_series[idx]


# ---------------------------------------------------------------------------
# Training
# ---------------------------------------------------------------------------

def train(
    env: PrinterEnv,
    *,
    total_timesteps: int = 1_000_000,
    save_path: str = "scheduler_ppo",
    learning_rate: float = 3e-4,
    n_steps: int = 2048,
    batch_size: int = 64,
    n_epochs: int = 10,
    gamma: float = 0.99,
    ent_coef: float = 0.01,
    verbose: int = 1,
) -> PPO:
    """
    Train a PPO agent on PrinterEnv and save the result to disk.

    Architecture
    ------------
    Both the actor and critic share the same MLP topology
    (two hidden layers of 64 units with Tanh activations) but have
    separate weights, as is standard in Actor-Critic methods:

        Actor  (Ο€_ΞΈ): obs(20) β†’ 64 β†’ 64 β†’ logits(512) β†’ Softmax
        Critic (V_Ο•): obs(20) β†’ 64 β†’ 64 β†’ scalar(1)

    The 512 Softmax output over the full 2^9 action space implicitly models
    the joint probability distribution over all replacement combinations,
    capturing correlations (e.g. replacing component 0 should reduce the
    probability of also replacing component 1 if they interact positively)
    without requiring an autoregressive sampling pass.

    Initialisation
    --------------
    SB3 applies orthogonal initialisation to all layers (scale √2 for hidden,
    scale 0.01 for the final policy layer). With scale 0.01 the logits start
    near zero, so Softmax(~0) β‰ˆ 1/512 β€” effectively maximum entropy over the
    action space, ensuring the agent explores broadly before committing.

    Hyperparameters
    ---------------
    learning_rate : Adam step size (3e-4 is a reliable PPO default).
    n_steps       : Rollout length before each PPO update (2048 steps β‰ˆ
                    one to several full episodes depending on episode length).
    batch_size    : Mini-batch size for gradient updates.
    n_epochs      : Number of gradient passes over each collected rollout.
    gamma         : Discount factor; 0.99 weights future rewards heavily,
                    encouraging the agent to maximise long-term lifespan.
    ent_coef      : Entropy bonus coefficient; keeps action probabilities from
                    collapsing to a single action too early in training.

    Parameters
    ----------
    env             : Configured PrinterEnv instance.
    total_timesteps : Total environment steps to train for.
    save_path       : File path (without .zip) for the saved model.
    learning_rate   : Adam learning rate.
    n_steps         : Rollout buffer size (steps per PPO update).
    batch_size      : SGD mini-batch size.
    n_epochs        : PPO epochs per rollout.
    gamma           : Discount factor Ξ³.
    ent_coef        : Entropy regularisation coefficient.
    verbose         : SB3 verbosity level (0=silent, 1=info, 2=debug).

    Returns
    -------
    PPO : The trained Stable-Baselines3 PPO model, ready for evaluate().
    """
    policy_kwargs = dict(
        net_arch=dict(pi=[64, 64], vf=[64, 64]),
        activation_fn=th.nn.Tanh,
    )

    ppo = PPO(
        policy="MlpPolicy",
        env=env,
        learning_rate=learning_rate,
        n_steps=n_steps,
        batch_size=batch_size,
        n_epochs=n_epochs,
        gamma=gamma,
        ent_coef=ent_coef,
        policy_kwargs=policy_kwargs,
        verbose=verbose,
    )

    ppo.learn(total_timesteps=total_timesteps)
    ppo.save(save_path)
    if verbose:
        print(f"[train] Model saved to {save_path}.zip")
    return ppo


# ---------------------------------------------------------------------------
# Evaluation
# ---------------------------------------------------------------------------

def evaluate(
    ppo: PPO,
    env: PrinterEnv,
    n_episodes: int = 10,
) -> dict:
    """
    Run the trained policy deterministically and return summary statistics.

    Each episode starts from a fresh reset() call. The policy is queried with
    deterministic=True, meaning the action with the highest probability under
    the current Softmax distribution is always selected (no sampling noise).
    This gives a reproducible, greedy estimate of the policy's performance.

    Parameters
    ----------
    ppo         : Trained PPO model returned by train().
    env         : PrinterEnv instance (can be the same env used for training).
    n_episodes  : Number of evaluation episodes to average over.

    Returns
    -------
    dict with keys:
        mean_hours        β€” average hours survived across all episodes.
        std_hours         β€” standard deviation of hours survived.
        mean_replacements β€” average total number of component replacements
                            performed per episode (summed over all steps).
        mean_budget_spent β€” average euros spent on replacements per episode.
        episodes          β€” list of per-episode dicts, each containing:
                            episode, hours_survived, budget_spent, replacements.
    """
    hours, n_replacements, budget_spent = [], [], []
    episodes = []

    for ep in range(n_episodes):
        obs, _ = env.reset()
        done = False
        ep_replacements = 0

        while not done:
            action, _ = ppo.predict(obs, deterministic=True)
            obs, _, terminated, truncated, _ = env.step(int(action))
            done = terminated or truncated
            ep_replacements += int(_ACTION_TABLE[int(action)].sum())

        hours.append(env._t)
        n_replacements.append(ep_replacements)
        budget_spent.append(env.W - env._budget)
        episodes.append({
            "episode":        ep,
            "hours_survived": env._t,
            "budget_spent":   env.W - env._budget,
            "replacements":   ep_replacements,
        })

    return {
        "mean_hours":        float(np.mean(hours)),
        "std_hours":         float(np.std(hours)),
        "mean_replacements": float(np.mean(n_replacements)),
        "mean_budget_spent": float(np.mean(budget_spent)),
        "episodes":          episodes,
    }


# ---------------------------------------------------------------------------
# Sanity demo
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    from process_inputs import process_inputs

    deg_model = DegradationModel.load("model.npz")
    print(f"Loaded model.npz  N={deg_model.N}  C={deg_model.C}")

    # Representative operating-condition scenarios in natural units (matches phase2.py SCENARIOS).
    # Each row: [ambient_temp_c, chamber_temp_c, humidity_pct, contamination_aqi,
    #            print_hours, build_volume_cm3, recoating_speed_mm_s, recoating_cycles, maintenance_level]
    _SCENARIO_CONDITIONS = np.array([
        [ 22.0, 180.0, 40.0,   20.0,  56.0,  4500.0, 100.0,  5000.0, 0.10],  # nominal
        [ 22.0, 180.0, 45.0,  150.0,  56.0,  4500.0, 100.0,  5000.0, 0.20],  # high contamination
        [ 28.0, 185.0, 50.0,   80.0, 168.0, 13500.0, 120.0, 15000.0, 0.20],  # 24/7 heavy use
        [ 20.0, 175.0, 30.0,    5.0, 300.0,  3000.0,  90.0,  3500.0, 0.05],  # optimal lab
        [ 40.0, 200.0, 55.0,   60.0,  56.0,  4500.0, 100.0,  5000.0, 0.20],  # hot environment
        [ 22.0, 180.0, 85.0,   30.0,  56.0,  4500.0, 100.0,  5000.0, 0.15],  # high humidity
        [ 25.0, 182.0, 45.0,   40.0,  56.0,  4500.0, 100.0,  5000.0, 0.90],  # neglected maintenance
        [ 38.0, 195.0, 20.0,  200.0,  80.0,  6500.0, 110.0,  7000.0, 0.40],  # desert factory
    ], dtype=np.float64)

    # Build an 8 000-step X_series by cycling through scenarios with small noise
    rng = np.random.default_rng(42)
    T = 8_000
    base_rows = _SCENARIO_CONDITIONS[np.arange(T) % len(_SCENARIO_CONDITIONS)]
    noise = rng.normal(0.0, 0.02, size=base_rows.shape) * base_rows  # 2% relative noise
    X_natural = np.clip(base_rows + noise, 0.0, None)
    X_series = np.stack([process_inputs(row) for row in X_natural])  # (T, C) normalised

    env = PrinterEnv(
        model=deg_model,
        X_series=X_series,
        W=10_000.0,
        dt=1.0,
        stochastic=True,
        seed=42,
    )

    print("Observation space:", env.observation_space.shape)
    print("Action space:     ", env.action_space.n, "discrete actions")
    print()

    # --- Random-policy baseline ---
    obs, _ = env.reset(seed=0)
    done = False
    while not done:
        action = env.action_space.sample()
        obs, _, terminated, truncated, info = env.step(action)
        done = terminated or truncated
    print(f"Random policy  β†’ {env._t:.0f} h survived | €{env.W - env._budget:.0f} spent")

    # --- PPO training (short demo: 200k steps) ---
    print("\nTraining PPO (200 000 timesteps) ...")
    trained = train(
        env,
        total_timesteps=200_000,
        save_path="scheduler_ppo",
        verbose=1,
    )

    # --- Evaluation ---
    results = evaluate(trained, env, n_episodes=10)
    print(f"\n{'─'*45}")
    print(f"  Mean hours survived : {results['mean_hours']:>8.1f} h")
    print(f"  Std  hours          : {results['std_hours']:>8.1f} h")
    print(f"  Mean replacements   : {results['mean_replacements']:>8.1f}")
    print(f"  Mean budget spent   : €{results['mean_budget_spent']:>7.0f}")
    print(f"{'─'*45}")