Spaces:
Sleeping
Sleeping
| """ | |
| 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}") | |