diff --git a/src/reactor_sim/commands.py b/src/reactor_sim/commands.py index 7393a87..c1a7ec3 100644 --- a/src/reactor_sim/commands.py +++ b/src/reactor_sim/commands.py @@ -24,6 +24,7 @@ class ReactorCommand: primary_pumps: dict[int, bool] | None = None secondary_pumps: dict[int, bool] | None = None generator_units: dict[int, bool] | None = None + generator_auto: bool | None = None maintenance_components: tuple[str, ...] = tuple() @classmethod diff --git a/src/reactor_sim/dashboard.py b/src/reactor_sim/dashboard.py index 257035f..1fc15ce 100644 --- a/src/reactor_sim/dashboard.py +++ b/src/reactor_sim/dashboard.py @@ -56,6 +56,7 @@ class ReactorDashboard: DashboardKey("k", "Toggle secondary pump 2"), DashboardKey("b", "Toggle generator 1"), DashboardKey("v", "Toggle generator 2"), + DashboardKey("x", "Toggle generator auto"), DashboardKey("t", "Toggle turbine"), DashboardKey("1/2/3", "Toggle turbine units 1-3"), DashboardKey("y/u/i", "Maintain turbine 1/2/3"), @@ -146,6 +147,8 @@ class ReactorDashboard: self._toggle_generator_unit(0) elif ch in (ord("v"), ord("V")): self._toggle_generator_unit(1) + elif ch in (ord("x"), ord("X")): + self._queue_command(ReactorCommand(generator_auto=not self.reactor.generator_auto)) elif ch in (ord("t"), ord("T")): self._queue_command(ReactorCommand(turbine_on=not self.reactor.turbine_active)) elif ord("1") <= ch <= ord("9"): @@ -259,6 +262,7 @@ class ReactorDashboard: self.reactor = Reactor.default() self.start_state = None self.pending_command = None + self._last_state = None self.reset_requested = False self.log_buffer.clear() @@ -468,9 +472,15 @@ class ReactorDashboard: def _turbine_status_lines(self) -> list[str]: if not self.reactor.turbine_unit_active: return ["n/a"] - return [ - f"{idx + 1}:{'ON' if active else 'OFF'}" for idx, active in enumerate(self.reactor.turbine_unit_active) - ] + lines: list[str] = [] + for idx, active in enumerate(self.reactor.turbine_unit_active): + label = f"{idx + 1}:" + status = "ON" if active else "OFF" + if idx < len(getattr(self._last_state, "turbines", [])): + t_state = self._last_state.turbines[idx] + status = getattr(t_state, "status", status) + lines.append(f"{label}{status}") + return lines def _total_load_supplied(self, state: PlantState) -> float: return sum(t.load_supplied_mw for t in state.turbines) @@ -551,7 +561,8 @@ class ReactorDashboard: if index >= len(pumps): return "n/a" state = pumps[index] - return f"{'ON ' if state.active else 'OFF'} {state.flow_rate:6.0f} kg/s" + status = getattr(state, "status", "ON" if state.active else "OFF") + return f"{status:<8} {state.flow_rate:6.0f} kg/s" def _current_demand(self) -> float: if self.reactor.consumer: diff --git a/src/reactor_sim/generator.py b/src/reactor_sim/generator.py index 43a7b58..39bb023 100644 --- a/src/reactor_sim/generator.py +++ b/src/reactor_sim/generator.py @@ -17,6 +17,7 @@ class GeneratorState: spool_remaining: float power_output_mw: float battery_charge: float + status: str = "OFF" @dataclass @@ -32,6 +33,7 @@ class DieselGenerator: return state.starting = True state.spool_remaining = self.spool_time + state.status = "STARTING" LOGGER.info("Generator starting (spool %.0fs)", self.spool_time) def stop(self, state: GeneratorState) -> None: @@ -41,6 +43,7 @@ class DieselGenerator: state.starting = False state.spool_remaining = 0.0 state.power_output_mw = 0.0 + state.status = "OFF" LOGGER.info("Generator stopped") def step(self, state: GeneratorState, load_demand_mw: float, dt: float) -> float: @@ -51,12 +54,15 @@ class DieselGenerator: if state.spool_remaining <= 0.0: state.starting = False state.running = True + state.status = "RUN" LOGGER.info("Generator online at %.1f MW", self.rated_output_mw) elif state.running: available = self.rated_output_mw state.power_output_mw = min(available, load_demand_mw) + state.status = "RUN" if state.power_output_mw > 0 else "IDLE" else: state.power_output_mw = 0.0 + state.status = "OFF" if state.running: state.battery_charge = min(1.0, state.battery_charge + 0.02 * dt) diff --git a/src/reactor_sim/reactor.py b/src/reactor_sim/reactor.py index 89b57db..333496f 100644 --- a/src/reactor_sim/reactor.py +++ b/src/reactor_sim/reactor.py @@ -44,6 +44,7 @@ class Reactor: turbine_unit_active: list[bool] = field(default_factory=lambda: [True, True, True]) shutdown: bool = False meltdown: bool = False + generator_auto: bool = True poison_alerts: set[str] = field(default_factory=set) maintenance_active: set[str] = field(default_factory=set) @@ -115,10 +116,18 @@ class Reactor: mass_flow_rate=0.0, steam_quality=0.0, ) - primary_pumps = [PumpState(active=self.primary_pump_active, flow_rate=0.0, pressure=0.5) for _ in range(2)] - secondary_pumps = [PumpState(active=self.secondary_pump_active, flow_rate=0.0, pressure=0.5) for _ in range(2)] + primary_pumps = [ + PumpState(active=self.primary_pump_active and self.primary_pump_units[idx], flow_rate=0.0, pressure=0.5) + for idx in range(2) + ] + secondary_pumps = [ + PumpState(active=self.secondary_pump_active and self.secondary_pump_units[idx], flow_rate=0.0, pressure=0.5) + for idx in range(2) + ] generator_states = [ - GeneratorState(running=False, starting=False, spool_remaining=0.0, power_output_mw=0.0, battery_charge=1.0) + GeneratorState( + running=False, starting=False, spool_remaining=0.0, power_output_mw=0.0, battery_charge=1.0, status="OFF" + ) for _ in self.generators ] turbine_states = [ @@ -218,6 +227,14 @@ class Reactor: pump_state.pressure, desired_pressure, dt, self.primary_pump.spool_time ) pump_state.active = (unit_enabled and power_ratio > 0.05) or pump_state.flow_rate > 1.0 + if unit_enabled and pump_state.flow_rate < max(1.0, desired_flow * 0.8): + pump_state.status = "STARTING" + elif not unit_enabled and pump_state.flow_rate > 1.0: + pump_state.status = "STOPPING" + elif pump_state.active: + pump_state.status = "RUN" + else: + pump_state.status = "OFF" total_flow += pump_state.flow_rate loop_pressure = max(loop_pressure, pump_state.pressure) state.primary_loop.mass_flow_rate = total_flow @@ -239,6 +256,7 @@ class Reactor: pump_state.pressure = self._ramp_value( pump_state.pressure, state.primary_loop.pressure, dt, self.primary_pump.spool_time ) + pump_state.status = "STOPPING" if pump_state.flow_rate > 0 else "OFF" if self.secondary_pump_active: total_flow = 0.0 target_pressure = 12.0 * 0.75 + 2.0 @@ -257,6 +275,14 @@ class Reactor: pump_state.pressure, desired_pressure, dt, self.secondary_pump.spool_time ) pump_state.active = unit_enabled or pump_state.flow_rate > 1.0 + if unit_enabled and pump_state.flow_rate < max(1.0, desired_flow * 0.8): + pump_state.status = "STARTING" + elif not unit_enabled and pump_state.flow_rate > 1.0: + pump_state.status = "STOPPING" + elif pump_state.active: + pump_state.status = "RUN" + else: + pump_state.status = "OFF" total_flow += pump_state.flow_rate loop_pressure = max(loop_pressure, pump_state.pressure) state.secondary_loop.mass_flow_rate = total_flow @@ -278,6 +304,7 @@ class Reactor: pump_state.pressure = self._ramp_value( pump_state.pressure, state.secondary_loop.pressure, dt, self.secondary_pump.spool_time ) + pump_state.status = "STOPPING" if pump_state.flow_rate > 0 else "OFF" self.thermal.step_core(state.core, state.primary_loop, total_power, dt) if not self.secondary_pump_active or state.secondary_loop.mass_flow_rate <= 1.0: @@ -334,8 +361,13 @@ class Reactor: turbine_state = state.turbines[idx] if idx in active_indices: turbine.step(state.secondary_loop, turbine_state, steam_power_mw=power_per_unit, dt=dt) + if turbine_state.electrical_output_mw < max(1.0, power_per_unit * 0.7): + turbine_state.status = "STARTING" + else: + turbine_state.status = "RUN" else: self._spin_down_turbine(turbine_state, dt, turbine.spool_time) + turbine_state.status = "STOPPING" if turbine_state.electrical_output_mw > 0 else "OFF" self._dispatch_consumer_load(state, active_indices) def _reset_turbine_state(self, turbine_state: TurbineState) -> None: @@ -343,6 +375,7 @@ class Reactor: turbine_state.electrical_output_mw = 0.0 turbine_state.load_demand_mw = 0.0 turbine_state.load_supplied_mw = 0.0 + turbine_state.status = "OFF" @staticmethod def _ramp_value(current: float, target: float, dt: float, time_constant: float) -> float: @@ -405,6 +438,8 @@ class Reactor: self.shutdown = True overrides["rod_fraction"] = self.control.scram() self._set_turbine_state(False) + if command.generator_auto is not None: + self.generator_auto = command.generator_auto if command.power_setpoint is not None: self.control.set_power_setpoint(command.power_setpoint) if command.rod_manual is not None: @@ -512,17 +547,18 @@ class Reactor: GeneratorState(running=False, starting=False, spool_remaining=0.0, power_output_mw=0.0, battery_charge=1.0) ) deficit = max(0.0, aux_demand - turbine_electric) - if deficit > 0.0: - for idx, gen_state in enumerate(state.generators): - if not (gen_state.running or gen_state.starting): - self.generators[idx].start(gen_state) - deficit -= self.generators[idx].rated_output_mw - if deficit <= 0: - break - elif turbine_electric > aux_demand: - for idx, gen_state in enumerate(state.generators): - if gen_state.running and not gen_state.starting: - self.generators[idx].stop(gen_state) + if self.generator_auto: + if deficit > 0.0: + for idx, gen_state in enumerate(state.generators): + if not (gen_state.running or gen_state.starting): + self.generators[idx].start(gen_state) + deficit -= self.generators[idx].rated_output_mw + if deficit <= 0: + break + elif turbine_electric > aux_demand: + for idx, gen_state in enumerate(state.generators): + if gen_state.running and not gen_state.starting: + self.generators[idx].stop(gen_state) total_power = 0.0 remaining = max(0.0, aux_demand - turbine_electric) @@ -640,6 +676,7 @@ class Reactor: "turbine_units": self.turbine_unit_active, "shutdown": self.shutdown, "meltdown": self.meltdown, + "generator_auto": self.generator_auto, "maintenance_active": list(self.maintenance_active), "generators": [ { @@ -648,6 +685,7 @@ class Reactor: "spool_remaining": g.spool_remaining, "power_output_mw": g.power_output_mw, "battery_charge": g.battery_charge, + "status": g.status, } for g in state.generators ], @@ -671,6 +709,7 @@ class Reactor: self.turbine_active = metadata.get("turbine_active", any(self.turbine_unit_active)) self.shutdown = metadata.get("shutdown", self.shutdown) self.meltdown = metadata.get("meltdown", self.meltdown) + self.generator_auto = metadata.get("generator_auto", self.generator_auto) maint = metadata.get("maintenance_active") if maint is not None: self.maintenance_active = set(maint) @@ -691,7 +730,12 @@ class Reactor: # Back-fill pump state lists for compatibility. if not plant.primary_pumps or len(plant.primary_pumps) < 2: plant.primary_pumps = [ - PumpState(active=self.primary_pump_active, flow_rate=plant.primary_loop.mass_flow_rate / 2, pressure=plant.primary_loop.pressure) + PumpState( + active=self.primary_pump_active, + flow_rate=plant.primary_loop.mass_flow_rate / 2, + pressure=plant.primary_loop.pressure, + status="OFF", + ) for _ in range(2) ] if not plant.secondary_pumps or len(plant.secondary_pumps) < 2: @@ -700,6 +744,7 @@ class Reactor: active=self.secondary_pump_active, flow_rate=plant.secondary_loop.mass_flow_rate / 2, pressure=plant.secondary_loop.pressure, + status="OFF", ) for _ in range(2) ] @@ -726,6 +771,7 @@ class Reactor: spool_remaining=0.0, power_output_mw=0.0, battery_charge=1.0, + status="OFF", ) ) for idx, gen_state in enumerate(plant.generators): @@ -736,6 +782,7 @@ class Reactor: gen_state.spool_remaining = cfg.get("spool_remaining", gen_state.spool_remaining) gen_state.power_output_mw = cfg.get("power_output_mw", gen_state.power_output_mw) gen_state.battery_charge = cfg.get("battery_charge", gen_state.battery_charge) + gen_state.status = cfg.get("status", gen_state.status) return plant def _handle_heat_sink_loss(self, state: PlantState) -> None: diff --git a/src/reactor_sim/state.py b/src/reactor_sim/state.py index d5e84a2..20f1203 100644 --- a/src/reactor_sim/state.py +++ b/src/reactor_sim/state.py @@ -54,6 +54,7 @@ class TurbineState: condenser_temperature: float load_demand_mw: float = 0.0 load_supplied_mw: float = 0.0 + status: str = "OFF" @dataclass @@ -61,6 +62,7 @@ class PumpState: active: bool flow_rate: float pressure: float + status: str = "OFF" @dataclass diff --git a/tests/test_simulation.py b/tests/test_simulation.py index c704725..b19cc7f 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -206,6 +206,30 @@ def test_generator_spools_and_powers_pumps(): assert state.primary_loop.mass_flow_rate > 0.0 +def test_generator_manual_mode_allows_single_unit_and_stop(): + reactor = Reactor.default() + state = reactor.initial_state() + reactor.shutdown = False + reactor.control.manual_control = True + reactor.control.rod_fraction = 0.95 + reactor.generator_auto = False + reactor.primary_pump_units = [True, False] + reactor.secondary_pump_units = [False, False] + + reactor.step(state, dt=1.0, command=ReactorCommand(generator_units={1: True}, primary_pumps={1: True})) + assert state.generators[0].starting or state.generators[0].running + + for _ in range(15): + reactor.step(state, dt=1.0) + + assert state.generators[0].running is True + reactor.step(state, dt=1.0, command=ReactorCommand(generator_units={1: False})) + for _ in range(5): + reactor.step(state, dt=1.0) + assert state.generators[0].running is False + assert state.generators[1].running is False + + def test_meltdown_triggers_shutdown(): reactor = Reactor.default() state = reactor.initial_state()