feat: add reactor control persistence and tests

This commit is contained in:
Andrii Prokhorov
2025-11-21 17:11:00 +02:00
commit cc7fba4e7a
43 changed files with 1435 additions and 0 deletions

263
src/reactor_sim/reactor.py Normal file
View File

@@ -0,0 +1,263 @@
"""High-level reactor orchestration."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
from . import constants
from .atomic import AtomicPhysics
from .commands import ReactorCommand
from .coolant import Pump
from .consumer import ElectricalConsumer
from .control import ControlSystem
from .failures import HealthMonitor
from .fuel import FuelAssembly, decay_heat_fraction
from .neutronics import NeutronDynamics
from .state import CoolantLoopState, CoreState, PlantState, TurbineState
from .thermal import ThermalSolver, heat_transfer
from .turbine import SteamGenerator, Turbine
LOGGER = logging.getLogger(__name__)
@dataclass
class Reactor:
fuel: FuelAssembly
neutronics: NeutronDynamics
control: ControlSystem
primary_pump: Pump
secondary_pump: Pump
thermal: ThermalSolver
steam_generator: SteamGenerator
turbine: Turbine
atomic_model: AtomicPhysics
consumer: ElectricalConsumer | None = None
health_monitor: HealthMonitor = field(default_factory=HealthMonitor)
primary_pump_active: bool = True
secondary_pump_active: bool = True
turbine_active: bool = True
shutdown: bool = False
@classmethod
def default(cls) -> "Reactor":
atomic_model = AtomicPhysics()
return cls(
fuel=FuelAssembly(enrichment=0.045, mass_kg=80_000.0, atomic_physics=atomic_model),
neutronics=NeutronDynamics(),
control=ControlSystem(),
primary_pump=Pump(nominal_flow=18_000.0),
secondary_pump=Pump(nominal_flow=16_000.0, efficiency=0.85),
thermal=ThermalSolver(),
steam_generator=SteamGenerator(),
turbine=Turbine(),
atomic_model=atomic_model,
consumer=ElectricalConsumer(name="Grid", demand_mw=800.0, online=False),
health_monitor=HealthMonitor(),
)
def initial_state(self) -> PlantState:
ambient = constants.ENVIRONMENT_TEMPERATURE
core = CoreState(
fuel_temperature=ambient,
neutron_flux=1e5,
reactivity_margin=-0.02,
power_output_mw=0.1,
burnup=0.0,
)
primary = CoolantLoopState(
temperature_in=ambient,
temperature_out=ambient,
pressure=0.5,
mass_flow_rate=0.0,
steam_quality=0.0,
)
secondary = CoolantLoopState(
temperature_in=ambient,
temperature_out=ambient,
pressure=0.5,
mass_flow_rate=0.0,
steam_quality=0.0,
)
turbine = TurbineState(
steam_enthalpy=2_000.0,
shaft_power_mw=0.0,
electrical_output_mw=0.0,
condenser_temperature=ambient,
load_demand_mw=0.0,
load_supplied_mw=0.0,
)
return PlantState(core=core, primary_loop=primary, secondary_loop=secondary, turbine=turbine)
def step(self, state: PlantState, dt: float, command: ReactorCommand | None = None) -> None:
if self.shutdown:
rod_fraction = self.control.rod_fraction
else:
rod_fraction = self.control.update_rods(state.core, dt)
overrides = {}
if command:
overrides = self._apply_command(command, state)
rod_fraction = overrides.get("rod_fraction", rod_fraction)
self.neutronics.step(state.core, rod_fraction, dt)
prompt_power, fission_event = self.fuel.prompt_energy_rate(state.core.neutron_flux, rod_fraction)
decay_power = decay_heat_fraction(state.core.burnup) * state.core.power_output_mw
total_power = prompt_power + decay_power
state.core.power_output_mw = total_power
state.core.update_burnup(dt)
pump_demand = overrides.get("coolant_demand", self.control.coolant_demand(state.primary_loop))
if self.primary_pump_active:
self.primary_pump.step(state.primary_loop, pump_demand)
else:
state.primary_loop.mass_flow_rate = 0.0
state.primary_loop.pressure = 0.5
if self.secondary_pump_active:
self.secondary_pump.step(state.secondary_loop, 0.75)
else:
state.secondary_loop.mass_flow_rate = 0.0
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)
self.thermal.step_secondary(state.secondary_loop, transferred)
if self.turbine_active:
self.turbine.step(state.secondary_loop, state.turbine, self.consumer)
else:
state.turbine.shaft_power_mw = 0.0
state.turbine.electrical_output_mw = 0.0
if self.consumer:
self.consumer.update_power_received(0.0)
failures = self.health_monitor.evaluate(
state,
self.primary_pump_active,
self.secondary_pump_active,
self.turbine_active,
dt,
)
for failure in failures:
self._handle_failure(failure)
state.time_elapsed += dt
LOGGER.info(
(
"t=%5.1fs rods=%.2f core_power=%.1fMW prompt=%.1fMW :: "
"%s-%d + %s-%d, outlet %.1fK, electrical %.1fMW load %.1f/%.1fMW"
),
state.time_elapsed,
rod_fraction,
total_power,
prompt_power,
fission_event.products[0].symbol,
fission_event.products[0].mass_number,
fission_event.products[1].symbol,
fission_event.products[1].mass_number,
state.primary_loop.temperature_out,
state.turbine.electrical_output_mw,
state.turbine.load_supplied_mw,
state.turbine.load_demand_mw,
)
def _handle_failure(self, component: str) -> None:
if component == "core":
LOGGER.critical("Core failure detected. Initiating SCRAM.")
self.shutdown = True
self.control.scram()
elif component == "primary_pump":
self._set_primary_pump(False)
elif component == "secondary_pump":
self._set_secondary_pump(False)
elif component == "turbine":
self._set_turbine_state(False)
def _apply_command(self, command: ReactorCommand, state: PlantState) -> dict[str, float]:
overrides: dict[str, float] = {}
if command.scram:
self.shutdown = True
overrides["rod_fraction"] = self.control.scram()
self._set_turbine_state(False)
if command.power_setpoint is not None:
self.control.set_power_setpoint(command.power_setpoint)
if command.rod_position is not None:
overrides["rod_fraction"] = self.control.set_rods(command.rod_position)
self.shutdown = self.shutdown or command.rod_position >= 0.95
elif command.rod_step is not None:
overrides["rod_fraction"] = self.control.increment_rods(command.rod_step)
if command.primary_pump_on is not None:
self._set_primary_pump(command.primary_pump_on)
if command.secondary_pump_on is not None:
self._set_secondary_pump(command.secondary_pump_on)
if command.turbine_on is not None:
self._set_turbine_state(command.turbine_on)
if command.consumer_online is not None and self.consumer:
self.consumer.set_online(command.consumer_online)
if command.consumer_demand is not None and self.consumer:
self.consumer.set_demand(command.consumer_demand)
if command.coolant_demand is not None:
overrides["coolant_demand"] = max(0.0, min(1.0, command.coolant_demand))
return overrides
def _set_primary_pump(self, active: bool) -> None:
if self.primary_pump_active != active:
self.primary_pump_active = active
LOGGER.info("Primary pump %s", "enabled" if active else "stopped")
def _set_secondary_pump(self, active: bool) -> None:
if self.secondary_pump_active != active:
self.secondary_pump_active = active
LOGGER.info("Secondary pump %s", "enabled" if active else "stopped")
def _set_turbine_state(self, active: bool) -> None:
if self.turbine_active != active:
self.turbine_active = active
LOGGER.info("Turbine %s", "started" if active else "stopped")
def attach_consumer(self, consumer: ElectricalConsumer) -> None:
self.consumer = consumer
LOGGER.info("Attached consumer %s (%.1f MW)", consumer.name, consumer.demand_mw)
def detach_consumer(self) -> None:
if self.consumer:
LOGGER.info("Detached consumer %s", self.consumer.name)
self.consumer = None
def save_state(self, filepath: str, state: PlantState) -> None:
metadata = {
"primary_pump_active": self.primary_pump_active,
"secondary_pump_active": self.secondary_pump_active,
"turbine_active": self.turbine_active,
"shutdown": self.shutdown,
"consumer": {
"online": self.consumer.online if self.consumer else False,
"demand_mw": self.consumer.demand_mw if self.consumer else 0.0,
"name": self.consumer.name if self.consumer else None,
},
}
self.control.save_state(filepath, state, metadata, self.health_monitor.snapshot())
def load_state(self, filepath: str) -> PlantState:
plant, metadata, health = self.control.load_state(filepath)
self.primary_pump_active = metadata.get("primary_pump_active", self.primary_pump_active)
self.secondary_pump_active = metadata.get("secondary_pump_active", self.secondary_pump_active)
self.turbine_active = metadata.get("turbine_active", self.turbine_active)
self.shutdown = metadata.get("shutdown", self.shutdown)
consumer_cfg = metadata.get("consumer")
if consumer_cfg:
if not self.consumer:
self.consumer = ElectricalConsumer(
name=consumer_cfg.get("name") or "External",
demand_mw=consumer_cfg.get("demand_mw", 0.0),
online=consumer_cfg.get("online", False),
)
else:
self.consumer.set_demand(consumer_cfg.get("demand_mw", self.consumer.demand_mw))
self.consumer.set_online(consumer_cfg.get("online", self.consumer.online))
if health:
self.health_monitor.load_snapshot(health)
LOGGER.info("Reactor state restored from %s", filepath)
return plant