diff --git a/src/reactor_sim/dashboard.py b/src/reactor_sim/dashboard.py index 599d418..c0b8c54 100644 --- a/src/reactor_sim/dashboard.py +++ b/src/reactor_sim/dashboard.py @@ -304,13 +304,14 @@ class ReactorDashboard: y, "Turbine / Grid", [ - ("Turbines", " ".join(self._turbine_status_lines())), - ("Electrical", f"{state.total_electrical_output():7.1f} MW"), - ("Load", f"{self._total_load_supplied(state):7.1f}/{self._total_load_demand(state):7.1f} MW"), - ("Consumer", f"{consumer_status}"), - ("Demand", f"{consumer_demand:7.1f} MW"), - ], + ("Turbines", " ".join(self._turbine_status_lines())), + ("Electrical", f"{state.total_electrical_output():7.1f} MW"), + ("Load", f"{self._total_load_supplied(state):7.1f}/{self._total_load_demand(state):7.1f} MW"), + ("Consumer", f"{consumer_status}"), + ("Demand", f"{consumer_demand:7.1f} MW"), + ], ) + y = self._draw_section(win, y, "Maintenance", self._maintenance_lines()) self._draw_health_bar(win, y + 1) def _draw_help_panel(self, win: "curses._CursesWindow") -> None: @@ -415,6 +416,11 @@ class ReactorDashboard: lines.append(("Alphas", f"{particles.get('alpha', 0.0):9.2e}")) return lines + def _maintenance_lines(self) -> list[tuple[str, str]]: + if not self.reactor.maintenance_active: + return [("Active", "None")] + return [(comp, "IN PROGRESS") for comp in sorted(self.reactor.maintenance_active)] + def _draw_health_bar(self, win: "curses._CursesWindow", start_y: int) -> None: height, width = win.getmaxyx() if start_y >= height - 2: diff --git a/src/reactor_sim/reactor.py b/src/reactor_sim/reactor.py index 1fbbe95..907ea78 100644 --- a/src/reactor_sim/reactor.py +++ b/src/reactor_sim/reactor.py @@ -40,6 +40,7 @@ class Reactor: turbine_unit_active: list[bool] = field(default_factory=lambda: [True, True, True]) shutdown: bool = False poison_alerts: set[str] = field(default_factory=set) + maintenance_active: set[str] = field(default_factory=set) def __post_init__(self) -> None: if not self.turbines: @@ -168,6 +169,7 @@ class Reactor: self.thermal.step_secondary(state.secondary_loop, transferred) self._step_turbine_bank(state, transferred) + self._maintenance_tick(state, dt) if (not self.secondary_pump_active or state.secondary_loop.mass_flow_rate <= 1.0) and total_power > 50.0: self._handle_heat_sink_loss(state) @@ -289,7 +291,7 @@ class Reactor: if command.coolant_demand is not None: overrides["coolant_demand"] = max(0.0, min(1.0, command.coolant_demand)) for component in command.maintenance_components: - self._perform_maintenance(component) + self._toggle_maintenance(component) return overrides def _set_primary_pump(self, active: bool) -> None: @@ -324,24 +326,54 @@ class Reactor: return -1 def _perform_maintenance(self, component: str) -> None: + if not self._can_maintain(component): + return + self.health_monitor.maintain(component) + + def _maintenance_tick(self, state: PlantState, dt: float) -> None: + if not self.maintenance_active: + return + completed: list[str] = [] + for component in list(self.maintenance_active): + if not self._can_maintain(component): + continue + restored = self.health_monitor.maintain(component, amount=0.02 * dt) + comp_state = self.health_monitor.component(component) + if comp_state.integrity >= 0.999: + completed.append(component) + for comp in completed: + self.maintenance_active.discard(comp) + LOGGER.info("Maintenance completed for %s", comp) + + def _toggle_maintenance(self, component: str) -> None: + if component in self.maintenance_active: + self.maintenance_active.remove(component) + LOGGER.info("Maintenance stopped for %s", component) + return + if not self._can_maintain(component): + return + self.maintenance_active.add(component) + LOGGER.info("Maintenance started for %s", component) + + def _can_maintain(self, component: str) -> bool: if component == "core" and not self.shutdown: LOGGER.warning("Cannot maintain core while reactor is running") - return + return False if component == "primary_pump" and self.primary_pump_active: LOGGER.warning("Stop primary pump before maintenance") - return + return False if component == "secondary_pump" and self.secondary_pump_active: LOGGER.warning("Stop secondary pump before maintenance") - return + return False if component.startswith("turbine"): idx = self._component_index(component) if idx < 0 or idx >= len(self.turbine_unit_active): LOGGER.warning("Unknown turbine maintenance target %s", component) - return + return False if self.turbine_unit_active[idx]: LOGGER.warning("Stop turbine %d before maintenance", idx + 1) - return - self.health_monitor.maintain(component) + return False + return True def attach_consumer(self, consumer: ElectricalConsumer) -> None: self.consumer = consumer @@ -359,6 +391,7 @@ class Reactor: "turbine_active": self.turbine_active, "turbine_units": self.turbine_unit_active, "shutdown": self.shutdown, + "maintenance_active": list(self.maintenance_active), "consumer": { "online": self.consumer.online if self.consumer else False, "demand_mw": self.consumer.demand_mw if self.consumer else 0.0, @@ -376,6 +409,9 @@ class Reactor: self.turbine_unit_active = list(unit_states) self.turbine_active = metadata.get("turbine_active", any(self.turbine_unit_active)) self.shutdown = metadata.get("shutdown", self.shutdown) + maint = metadata.get("maintenance_active") + if maint is not None: + self.maintenance_active = set(maint) consumer_cfg = metadata.get("consumer") if consumer_cfg: if not self.consumer: diff --git a/tests/test_simulation.py b/tests/test_simulation.py index ca7e56a..581d41f 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from reactor_sim import constants +from reactor_sim.commands import ReactorCommand from reactor_sim.failures import HealthMonitor from reactor_sim.reactor import Reactor from reactor_sim.simulation import ReactorSimulation @@ -79,3 +80,20 @@ def test_cold_shutdown_stays_subcritical(): reactor.step(state, dt=1.0) assert state.core.power_output_mw <= initial_power + 0.5 assert reactor.shutdown is True + + +def test_toggle_maintenance_progresses_until_restored(): + reactor = Reactor.default() + reactor.primary_pump_active = False + pump = reactor.health_monitor.component("primary_pump") + pump.integrity = 0.2 + + def provider(t: float, _state): + if t == 0: + return ReactorCommand.maintain("primary_pump") + return None + + sim = ReactorSimulation(reactor, timestep=1.0, duration=50.0, command_provider=provider) + sim.log() + assert pump.integrity >= 0.99 + assert "primary_pump" not in reactor.maintenance_active