From 2434034505693c950367ad64ff0d1c1f57169a26 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 22 Nov 2025 18:20:21 +0100 Subject: [PATCH] Handle heat-sink loss and update turbine controls --- AGENTS.md | 2 +- src/reactor_sim/dashboard.py | 22 ++++++++++------------ src/reactor_sim/failures.py | 2 +- src/reactor_sim/reactor.py | 20 ++++++++++++++++++-- src/reactor_sim/thermal.py | 2 ++ tests/test_simulation.py | 15 ++++++++++++++- 6 files changed, 46 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c558f7b..c31d3cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ All source code lives under `src/reactor_sim`. Submodules map to plant systems: - A git remote for `origin` is configured; push changes to `origin/main` once work is complete so dashboards stay in sync. ## Operations & Control Hooks -Manual commands live in `reactor_sim.commands.ReactorCommand`. Pass a `command_provider` callable to `ReactorSimulation` to adjust rods, pumps, turbine, coolant demand, or the attached `ElectricalConsumer`. Use `ReactorCommand.scram_all()` for full shutdown, `ReactorCommand(consumer_online=True, consumer_demand=600)` to hook the grid, or toggle pumps (`primary_pump_on=False`) to simulate faults. Control helpers in `control.py` expose `set_rods`, `increment_rods`, and `scram`, and you can switch `set_manual_mode(True)` to pause the automatic rod controller. For hands-on runs, launch the curses dashboard (`FISSION_DASHBOARD=1 FISSION_REALTIME=1 python run_simulation.py`) and use the on-screen shortcuts (q quit/save, space SCRAM, p/o pumps, t turbine, 1/2/3 toggle individual turbines, r reset/clear saved state, +/- rods in 0.05 steps, [/ ] consumer demand, s/d setpoint, `a` toggles auto/manual rods). Recommended startup: enable manual rods (`a`), withdraw to ~0.3 before ramping the turbine/consumer, then re-enable auto control when you want closed-loop operation. +Manual commands live in `reactor_sim.commands.ReactorCommand`. Pass a `command_provider` callable to `ReactorSimulation` to adjust rods, pumps, turbine, coolant demand, or the attached `ElectricalConsumer`. Use `ReactorCommand.scram_all()` for full shutdown, `ReactorCommand(consumer_online=True, consumer_demand=600)` to hook the grid, or toggle pumps (`primary_pump_on=False`) to simulate faults. Control helpers in `control.py` expose `set_rods`, `increment_rods`, and `scram`, and you can switch `set_manual_mode(True)` to pause the automatic rod controller. For hands-on runs, launch the curses dashboard (`FISSION_DASHBOARD=1 FISSION_REALTIME=1 python run_simulation.py`) and use the on-screen shortcuts (q quit/save, space SCRAM, p/o pumps, t turbine, 1/2 toggle individual turbines, y/u turbine maintenance, m/n pump maintenance, k core maintenance, c consumer, r reset/clear saved state, +/- rods in 0.05 steps, [/ ] consumer demand, s/d setpoint, `a` toggles auto/manual rods). Recommended startup: enable manual rods (`a`), withdraw to ~0.3 before ramping the turbine/consumer, then re-enable auto control when you want closed-loop operation. The plant now boots cold (ambient core temperature, idle pumps); scripts must sequence startup: enable pumps, gradually withdraw rods, connect the consumer after turbine spin-up, and use `ControlSystem.set_power_setpoint` to chase desired output. Set `FISSION_REALTIME=1` to run continuously with real-time pacing; optionally set `FISSION_SIM_DURATION=infinite` for indefinite runs and send SIGINT/Ctrl+C to stop. Use `FISSION_SIM_DURATION=600` (default) for bounded offline batches. ## Coding Style & Naming Conventions diff --git a/src/reactor_sim/dashboard.py b/src/reactor_sim/dashboard.py index 4b87e9b..8452bec 100644 --- a/src/reactor_sim/dashboard.py +++ b/src/reactor_sim/dashboard.py @@ -52,8 +52,8 @@ class ReactorDashboard: DashboardKey("p", "Toggle primary pump"), DashboardKey("o", "Toggle secondary pump"), DashboardKey("t", "Toggle turbine"), - DashboardKey("1/2/3", "Toggle turbine units 1-3"), - DashboardKey("y/u/i", "Maintain turbine 1/2/3"), + DashboardKey("1/2", "Toggle turbine units 1-2"), + DashboardKey("y/u", "Maintain turbine 1/2"), DashboardKey("c", "Toggle consumer"), DashboardKey("r", "Reset & clear state"), DashboardKey("m", "Maintain primary pump"), @@ -162,8 +162,6 @@ class ReactorDashboard: self._queue_command(ReactorCommand.maintain("turbine_1")) elif ch in (ord("u"), ord("U")): self._queue_command(ReactorCommand.maintain("turbine_2")) - elif ch in (ord("i"), ord("I")): - self._queue_command(ReactorCommand.maintain("turbine_3")) def _queue_command(self, command: ReactorCommand) -> None: if self.pending_command is None: @@ -302,12 +300,12 @@ 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"), + ], ) self._draw_health_bar(win, y + 1) @@ -323,8 +321,8 @@ class ReactorDashboard: tips = [ "Start pumps before withdrawing rods.", "Bring turbine and consumer online after thermal stabilization.", - "Toggle turbine units (1/2/3) for staggered maintenance.", - "Use m/n/k/y/u/i to request maintenance (stop equipment first).", + "Toggle turbine units (1/2) for staggered maintenance.", + "Use m/n/k/y/u to request maintenance (stop equipment first).", "Press 'r' to reset/clear state if you want a cold start.", "Watch component health to avoid automatic trips.", ] diff --git a/src/reactor_sim/failures.py b/src/reactor_sim/failures.py index 07760ce..d9eb8d9 100644 --- a/src/reactor_sim/failures.py +++ b/src/reactor_sim/failures.py @@ -56,7 +56,7 @@ class HealthMonitor: "primary_pump": ComponentHealth("primary_pump"), "secondary_pump": ComponentHealth("secondary_pump"), } - for idx in range(3): + for idx in range(2): name = f"turbine_{idx + 1}" self.components[name] = ComponentHealth(name) self.failure_log: list[str] = [] diff --git a/src/reactor_sim/reactor.py b/src/reactor_sim/reactor.py index c273e52..8e87238 100644 --- a/src/reactor_sim/reactor.py +++ b/src/reactor_sim/reactor.py @@ -59,7 +59,7 @@ class Reactor: secondary_pump=Pump(nominal_flow=16_000.0, efficiency=0.85), thermal=ThermalSolver(), steam_generator=SteamGenerator(), - turbines=[Turbine() for _ in range(3)], + turbines=[Turbine() for _ in range(2)], atomic_model=atomic_model, consumer=ElectricalConsumer(name="Grid", demand_mw=800.0, online=False), health_monitor=HealthMonitor(), @@ -150,11 +150,17 @@ class Reactor: state.secondary_loop.pressure = 0.5 self.thermal.step_core(state.core, state.primary_loop, total_power, dt) - transferred = heat_transfer(state.primary_loop, state.secondary_loop, total_power) + if not self.secondary_pump_active or state.secondary_loop.mass_flow_rate <= 1.0: + transferred = 0.0 + else: + transferred = heat_transfer(state.primary_loop, state.secondary_loop, total_power) self.thermal.step_secondary(state.secondary_loop, transferred) self._step_turbine_bank(state, transferred) + 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) + failures = self.health_monitor.evaluate( state, self.primary_pump_active, @@ -388,6 +394,16 @@ class Reactor: ) return plant + def _handle_heat_sink_loss(self, state: PlantState) -> None: + if not self.shutdown: + LOGGER.critical("Loss of secondary heat sink detected. Initiating SCRAM.") + self.shutdown = True + self.control.scram() + self._set_turbine_state(False) + # Clear turbine output and demands to reflect lost steam. + for turbine_state in state.turbines: + self._reset_turbine_state(turbine_state) + def _check_poison_alerts(self, state: PlantState) -> None: inventory = state.core.fission_product_inventory or {} for symbol, threshold in constants.KEY_POISON_THRESHOLDS.items(): diff --git a/src/reactor_sim/thermal.py b/src/reactor_sim/thermal.py index bc7a305..4e1610f 100644 --- a/src/reactor_sim/thermal.py +++ b/src/reactor_sim/thermal.py @@ -15,6 +15,8 @@ LOGGER = logging.getLogger(__name__) def heat_transfer(primary: CoolantLoopState, secondary: CoolantLoopState, core_power_mw: float) -> float: """Return MW transferred to the secondary loop.""" + if secondary.mass_flow_rate <= 0.0: + return 0.0 delta_t = max(0.0, primary.temperature_out - secondary.temperature_in) conductance = 0.15 # steam generator effectiveness efficiency = 1.0 - math.exp(-conductance * delta_t) diff --git a/tests/test_simulation.py b/tests/test_simulation.py index fa52b95..6bd2720 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -37,7 +37,7 @@ def test_health_monitor_flags_core_failure(): reactor = Reactor.default() state = reactor.initial_state() state.core.fuel_temperature = constants.MAX_CORE_TEMPERATURE - failures = reactor.health_monitor.evaluate(state, True, True, [True, True, True], dt=200.0) + failures = reactor.health_monitor.evaluate(state, True, True, [True, True], dt=200.0) assert "core" in failures reactor._handle_failure("core") assert reactor.shutdown is True @@ -52,3 +52,16 @@ def test_maintenance_recovers_component_health(): assert restored is True assert pump.integrity == pytest.approx(0.8) assert pump.failed is False + + +def test_secondary_pump_loss_triggers_scram_and_no_steam(): + reactor = Reactor.default() + state = reactor.initial_state() + # Make sure some power is present to trigger heat-sink logic. + state.core.power_output_mw = 500.0 + reactor.secondary_pump_active = False + reactor.control.manual_control = True + reactor.control.rod_fraction = 0.1 + reactor.step(state, dt=1.0) + assert reactor.shutdown is True + assert all(t.electrical_output_mw == 0.0 for t in state.turbines)