feat: add reactor control persistence and tests
This commit is contained in:
90
src/reactor_sim/control.py
Normal file
90
src/reactor_sim/control.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Control system for rods and plant automation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from . import constants
|
||||
from .state import CoolantLoopState, CoreState, PlantState
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def clamp(value: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, value))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlSystem:
|
||||
setpoint_mw: float = 3_000.0
|
||||
rod_fraction: float = 0.5
|
||||
|
||||
def update_rods(self, state: CoreState, dt: float) -> float:
|
||||
error = (state.power_output_mw - self.setpoint_mw) / self.setpoint_mw
|
||||
adjustment = -error * 0.3
|
||||
adjustment = clamp(adjustment, -constants.CONTROL_ROD_SPEED * dt, constants.CONTROL_ROD_SPEED * dt)
|
||||
previous = self.rod_fraction
|
||||
self.rod_fraction = clamp(self.rod_fraction + adjustment, 0.0, 0.95)
|
||||
LOGGER.debug("Control rods %.3f -> %.3f (error=%.3f)", previous, self.rod_fraction, error)
|
||||
return self.rod_fraction
|
||||
|
||||
def set_rods(self, fraction: float) -> float:
|
||||
previous = self.rod_fraction
|
||||
self.rod_fraction = clamp(fraction, 0.0, 0.95)
|
||||
LOGGER.info("Manual rod set %.3f -> %.3f", previous, self.rod_fraction)
|
||||
return self.rod_fraction
|
||||
|
||||
def increment_rods(self, delta: float) -> float:
|
||||
return self.set_rods(self.rod_fraction + delta)
|
||||
|
||||
def scram(self) -> float:
|
||||
self.rod_fraction = 0.95
|
||||
LOGGER.warning("SCRAM: rods fully inserted")
|
||||
return self.rod_fraction
|
||||
|
||||
def set_power_setpoint(self, megawatts: float) -> None:
|
||||
previous = self.setpoint_mw
|
||||
self.setpoint_mw = clamp(megawatts, 100.0, 4_000.0)
|
||||
LOGGER.info("Power setpoint %.0f -> %.0f MW", previous, self.setpoint_mw)
|
||||
|
||||
def coolant_demand(self, primary: CoolantLoopState) -> float:
|
||||
desired_temp = 580.0
|
||||
error = (primary.temperature_out - desired_temp) / 100.0
|
||||
demand = clamp(0.8 - error, 0.0, 1.0)
|
||||
LOGGER.debug("Coolant demand %.2f for outlet %.1fK", demand, primary.temperature_out)
|
||||
return demand
|
||||
|
||||
def save_state(
|
||||
self,
|
||||
filepath: str,
|
||||
plant_state: PlantState,
|
||||
metadata: dict | None = None,
|
||||
health_snapshot: dict | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"control": {
|
||||
"setpoint_mw": self.setpoint_mw,
|
||||
"rod_fraction": self.rod_fraction,
|
||||
},
|
||||
"plant": plant_state.to_dict(),
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
if health_snapshot:
|
||||
payload["health"] = health_snapshot
|
||||
path = Path(filepath)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2))
|
||||
LOGGER.info("Saved control & plant state to %s", path)
|
||||
|
||||
def load_state(self, filepath: str) -> tuple[PlantState, dict, dict | None]:
|
||||
path = Path(filepath)
|
||||
data = json.loads(path.read_text())
|
||||
control = data.get("control", {})
|
||||
self.setpoint_mw = control.get("setpoint_mw", self.setpoint_mw)
|
||||
self.rod_fraction = control.get("rod_fraction", self.rod_fraction)
|
||||
plant = PlantState.from_dict(data["plant"])
|
||||
LOGGER.info("Loaded plant state from %s", path)
|
||||
return plant, data.get("metadata", {}), data.get("health")
|
||||
Reference in New Issue
Block a user