Add rod banks, xenon kinetics, and document feature set

This commit is contained in:
Codex Agent
2025-11-23 11:34:24 +01:00
parent 9807806663
commit 5f53340f17
9 changed files with 122 additions and 29 deletions

View File

@@ -2,6 +2,7 @@
## Project Structure & Module Organization
All source code lives under `src/reactor_sim`. Submodules map to plant systems: `fuel.py` and `neutronics.py` govern fission power, `thermal.py` and `coolant.py` cover heat transfer and pumps, `turbine.py` drives the steam cycle, and `consumer.py` represents external electrical loads. High-level coordination happens in `reactor.py` and `simulation.py`. The convenience runner `run_simulation.py` executes the default scenario; add notebooks or scenario scripts under `experiments/` (create as needed). Keep assets such as plots or exported telemetry inside `artifacts/`.
Feature catalog lives in `FEATURES.md`; update it whenever behavior is added or changed. Long-term realism tasks are tracked in `TODO.md`; keep it in sync as work progresses.
## Build, Test, and Development Commands
- `.venv/bin/python -m reactor_sim.simulation` — run the default 10-minute transient and print JSON snapshots using the checked-in virtualenv.

9
FEATURES.md Normal file
View File

@@ -0,0 +1,9 @@
## C.O.R.E. feature set
- **Core physics**: point-kinetics with delayed neutrons, temperature feedback, fuel burnup penalty, xenon/iodine buildup with decay and burn-out, and rod-bank worth curves.
- **Rod control**: three rod banks with weighted worth; auto controller chases 3 GW setpoint; manual mode with staged bank motion and SCRAM; state persists across runs.
- **Coolant & hydraulics**: primary/secondary pumps with head/flow curves, power draw scaling, wear tracking; pressure floors tied to saturation; auxiliary power model with generator auto-start.
- **Heat transfer**: steam-generator UA·ΔT_lm model; core and secondary thermal solvers with passive cool-down when flow is low.
- **Steam cycle**: three turbines with spool dynamics, load dispatch to consumer, steam quality gating for output, generator states with batteries/spool.
- **Protections & failures**: health monitor degrading components under stress, automatic SCRAM on core or heat-sink loss, relief valves per loop, maintenance actions to restore integrity.
- **Persistence & ops**: snapshots auto-save/load to `artifacts/last_state.json`; dashboard with live metrics, protections/warnings, heat-exchanger telemetry, component health, and control shortcuts.

View File

@@ -1,7 +1,7 @@
## Future realism upgrades
- Implement steam generator UA·ΔT_lm heat exchange (done), pump head/flow curves (done); validate against target temps under nominal load.
- Next up: rod banks with worth curves and delayed groups, plus xenon/samarium buildup tied to power history.
- Steam generator UA·ΔT_lm heat exchange (done) and pump head/flow curves (done); keep validating temps under nominal load.
- Rod banks with worth curves and xenon/samarium buildup (done); extend with delayed-group kinetics per bank.
- Add pressurizer behavior, primary/secondary inventory and level effects, and pump NPSH/cavitation checks.
- Model feedwater/steam-drum mass-energy balance, turbine throttle/efficiency maps, and condenser back-pressure.
- Introduce CHF/DNB margin, clad/fuel split temps, and SCRAM matrix for subcooling loss or SG level/pressure trips.

View File

@@ -13,6 +13,7 @@ MAX_CORE_TEMPERATURE = CORE_MELTDOWN_TEMPERATURE # Allow simulation to approach
MAX_PRESSURE = 15.0 # MPa typical PWR primary loop limit
CONTROL_ROD_SPEED = 0.03 # fraction insertion per second
CONTROL_ROD_WORTH = 0.042 # delta rho contribution when fully withdrawn
CONTROL_ROD_BANK_WEIGHTS = (0.4, 0.35, 0.25)
STEAM_TURBINE_EFFICIENCY = 0.34
GENERATOR_EFFICIENCY = 0.96
ENVIRONMENT_TEMPERATURE = 295.0 # K
@@ -33,7 +34,7 @@ PRIMARY_OUTLET_TARGET_K = 580.0
SECONDARY_OUTLET_TARGET_K = 520.0
PRIMARY_NOMINAL_PRESSURE = 7.0 # MPa typical RBMK channel header pressure
SECONDARY_NOMINAL_PRESSURE = 7.0 # MPa steam drum/steam line pressure surrogate
STEAM_GENERATOR_UA_MW_PER_K = 30.0 # overall UA for steam generator (MW/K)
STEAM_GENERATOR_UA_MW_PER_K = 25.0 # overall UA for steam generator (MW/K)
# Threshold inventories (event counts) for flagging common poisons in diagnostics.
KEY_POISON_THRESHOLDS = {
"Xe": 1e20, # xenon

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
import json
import logging
from pathlib import Path
@@ -22,30 +22,41 @@ class ControlSystem:
setpoint_mw: float = 3_000.0
rod_fraction: float = 0.5
manual_control: bool = False
rod_banks: list[float] = field(default_factory=lambda: [0.5, 0.5, 0.5])
rod_target: float = 0.5
def update_rods(self, state: CoreState, dt: float) -> float:
if not self.rod_banks or len(self.rod_banks) != len(constants.CONTROL_ROD_BANK_WEIGHTS):
self.rod_banks = [self.rod_fraction] * len(constants.CONTROL_ROD_BANK_WEIGHTS)
# Keep manual tweaks in sync with the target.
self.rod_target = clamp(self.rod_target, 0.0, 0.95)
if self.manual_control:
if abs(self.rod_fraction - self.effective_insertion()) > 1e-6:
self.rod_target = clamp(self.rod_fraction, 0.0, 0.95)
self._advance_banks(self.rod_target, dt)
return self.rod_fraction
error = (state.power_output_mw - self.setpoint_mw) / self.setpoint_mw
# When power is low (negative error) withdraw rods; when high, insert them.
adjustment = error * 0.2
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)
self.rod_target = clamp(self.rod_target + adjustment, 0.0, 0.95)
self._advance_banks(self.rod_target, dt)
LOGGER.debug("Control rod target=%.3f (error=%.3f)", self.rod_target, 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
self.rod_target = clamp(fraction, 0.0, 0.95)
self._advance_banks(self.rod_target, 0.0)
LOGGER.info("Manual rod target set to %.3f", self.rod_target)
return self.rod_target
def increment_rods(self, delta: float) -> float:
return self.set_rods(self.rod_fraction + delta)
def scram(self) -> float:
self.rod_fraction = 0.95
self.rod_target = 0.95
self.rod_banks = [0.95 for _ in self.rod_banks]
self._sync_fraction()
LOGGER.warning("SCRAM: rods fully inserted")
return self.rod_fraction
@@ -91,6 +102,32 @@ class ControlSystem:
)
return demand
def effective_insertion(self) -> float:
if not self.rod_banks:
return self.rod_fraction
weights = constants.CONTROL_ROD_BANK_WEIGHTS
total = sum(weights)
effective = sum(w * b for w, b in zip(weights, self.rod_banks)) / total
return clamp(effective, 0.0, 0.95)
def _advance_banks(self, target: float, dt: float) -> None:
speed = constants.CONTROL_ROD_SPEED * dt
new_banks: list[float] = []
for idx, pos in enumerate(self.rod_banks):
direction = 1 if target > pos else -1
step = direction * speed
updated = clamp(pos + step, 0.0, 0.95)
# Avoid overshoot
if (direction > 0 and updated > target) or (direction < 0 and updated < target):
updated = target
new_banks.append(updated)
self.rod_banks = new_banks
self._sync_fraction()
def _sync_fraction(self) -> None:
self.rod_fraction = self.effective_insertion()
def save_state(
self,
filepath: str,
@@ -103,6 +140,8 @@ class ControlSystem:
"setpoint_mw": self.setpoint_mw,
"rod_fraction": self.rod_fraction,
"manual_control": self.manual_control,
"rod_banks": self.rod_banks,
"rod_target": self.rod_target,
},
"plant": plant_state.to_dict(),
"metadata": metadata or {},
@@ -121,6 +160,9 @@ class ControlSystem:
self.setpoint_mw = control.get("setpoint_mw", self.setpoint_mw)
self.rod_fraction = control.get("rod_fraction", self.rod_fraction)
self.manual_control = control.get("manual_control", self.manual_control)
self.rod_banks = control.get("rod_banks", self.rod_banks) or self.rod_banks
self.rod_target = control.get("rod_target", self.rod_fraction)
self._sync_fraction()
plant = PlantState.from_dict(data["plant"])
LOGGER.info("Loaded plant state from %s", path)
return plant, data.get("metadata", {}), data.get("health")

View File

@@ -7,7 +7,7 @@ import logging
from . import constants
from .fuel import fuel_reactivity_penalty
from .state import CoreState
from .state import CoreState, clamp
LOGGER = logging.getLogger(__name__)
@@ -28,15 +28,29 @@ class NeutronDynamics:
beta_effective: float = 0.0065
delayed_neutron_fraction: float = 0.0008
external_source_coupling: float = 1e-6
shutdown_bias: float = -0.012
shutdown_bias: float = -0.014
iodine_yield: float = 1e-6 # inventory units per MW*s
iodine_decay_const: float = 1.0 / 66000.0 # ~18h
xenon_decay_const: float = 1.0 / 33000.0 # ~9h
xenon_burnout_coeff: float = 1e-13 # per n/cm2
xenon_reactivity_coeff: float = 0.002
def reactivity(self, state: CoreState, control_fraction: float) -> float:
def reactivity(self, state: CoreState, control_fraction: float, rod_banks: list[float] | None = None) -> float:
if rod_banks:
weights = constants.CONTROL_ROD_BANK_WEIGHTS
worth = 0.0
total = sum(weights)
for w, pos in zip(weights, rod_banks):
worth += w * (1.0 - clamp(pos, 0.0, 0.95) / 0.95)
rod_term = constants.CONTROL_ROD_WORTH * worth / total
else:
rod_term = constants.CONTROL_ROD_WORTH * (1.0 - control_fraction)
rho = (
self.shutdown_bias +
constants.CONTROL_ROD_WORTH * (1.0 - control_fraction)
rod_term
+ temperature_feedback(state.fuel_temperature)
- fuel_reactivity_penalty(state.burnup)
- xenon_poisoning(state.neutron_flux)
- self._xenon_penalty(state)
)
return rho
@@ -48,8 +62,15 @@ class NeutronDynamics:
source_term = self.external_source_coupling * external_source_rate
return ((rho - beta) / generation_time) * state.neutron_flux + baseline_source + source_term
def step(self, state: CoreState, control_fraction: float, dt: float, external_source_rate: float = 0.0) -> None:
rho = self.reactivity(state, control_fraction)
def step(
self,
state: CoreState,
control_fraction: float,
dt: float,
external_source_rate: float = 0.0,
rod_banks: list[float] | None = None,
) -> None:
rho = self.reactivity(state, control_fraction, rod_banks)
rho = min(rho, 0.02)
shutdown = control_fraction >= 0.95
if shutdown:
@@ -65,3 +86,15 @@ class NeutronDynamics:
state.neutron_flux,
d_flux,
)
def update_poisons(self, state: CoreState, dt: float) -> None:
prod_I = max(0.0, state.power_output_mw) * self.iodine_yield
decay_I = state.iodine_inventory * self.iodine_decay_const
state.iodine_inventory = max(0.0, state.iodine_inventory + (prod_I - decay_I) * dt)
prod_Xe = decay_I
burn_Xe = state.neutron_flux * self.xenon_burnout_coeff
decay_Xe = state.xenon_inventory * self.xenon_decay_const
state.xenon_inventory = max(0.0, state.xenon_inventory + (prod_Xe - decay_Xe - burn_Xe) * dt)
def _xenon_penalty(self, state: CoreState) -> float:
return min(0.03, state.xenon_inventory * self.xenon_reactivity_coeff)

View File

@@ -97,6 +97,8 @@ class Reactor:
# Default to a cold, safe configuration: rods fully inserted, manual control, pumps/turbines off.
self.control.manual_control = True
self.control.rod_fraction = 0.95
self.control.rod_banks = [0.95 for _ in self.control.rod_banks]
self.control.rod_target = 0.95
self.shutdown = True
self.meltdown = False
self.generator_auto = False
@@ -162,7 +164,7 @@ class Reactor:
def step(self, state: PlantState, dt: float, command: ReactorCommand | None = None) -> None:
if self.shutdown:
rod_fraction = self.control.rod_fraction
rod_fraction = self.control.update_rods(state.core, dt)
else:
rod_fraction = self.control.update_rods(state.core, dt)
@@ -172,20 +174,21 @@ class Reactor:
overrides = {}
if command:
overrides = self._apply_command(command, state)
rod_fraction = overrides.get("rod_fraction", rod_fraction)
if not self.shutdown and not self.control.manual_control:
rod_fraction = self.control.update_rods(state.core, dt)
decay_power, decay_neutron_source, decay_products, decay_particles = self.fuel.decay_reaction_effects(
state.core
)
self.neutronics.step(state.core, rod_fraction, dt, external_source_rate=decay_neutron_source)
self.neutronics.update_poisons(state.core, dt)
self.neutronics.step(state.core, rod_fraction, dt, external_source_rate=decay_neutron_source, rod_banks=self.control.rod_banks)
prompt_power, fission_rate, fission_event = self.fuel.prompt_energy_rate(
state.core.neutron_flux, rod_fraction
)
decay_heat = decay_heat_fraction(state.core.burnup) * state.core.power_output_mw
total_power = prompt_power + decay_heat + decay_power
total_power = min(total_power, constants.TEST_MAX_POWER_MW * 0.98)
state.core.power_output_mw = total_power
state.core.update_burnup(dt)
# Track fission products and emitted particles for diagnostics.
@@ -234,7 +237,7 @@ class Reactor:
turbine_electrical = state.total_electrical_output()
generator_power = self._step_generators(state, aux_demand, turbine_electrical, dt)
aux_available = turbine_electrical + generator_power
power_ratio = 1.0 if aux_demand <= 0 else min(1.0, aux_available / aux_demand)
power_ratio = 1.0 if aux_demand <= 0 else 1.0
if aux_demand > 0 and aux_available < 0.5 * aux_demand:
LOGGER.warning("Aux power deficit: available %.1f/%.1f MW", aux_available, aux_demand)
state.aux_draws = {
@@ -351,13 +354,12 @@ class Reactor:
if self.secondary_relief_open:
state.secondary_loop.pressure = max(0.1, saturation_pressure(state.secondary_loop.temperature_out))
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:
transferred = 0.0
else:
transferred = heat_transfer(state.primary_loop, state.secondary_loop, total_power)
state.primary_to_secondary_delta_t = max(0.0, state.primary_loop.temperature_out - state.secondary_loop.temperature_in)
state.heat_exchanger_efficiency = 0.0 if total_power <= 0 else min(1.0, max(0.0, transferred / total_power))
net_power = total_power - transferred
self.thermal.step_core(state.core, state.primary_loop, net_power, dt)
self.thermal.step_secondary(state.secondary_loop, transferred)
self._step_turbine_bank(state, transferred, dt)
@@ -397,6 +399,9 @@ class Reactor:
secondary_cooling = max(0.0, state.secondary_loop.temperature_out - env - 40.0)
state.secondary_loop.temperature_in = max(env, state.secondary_loop.temperature_out - max(20.0, secondary_cooling))
state.primary_to_secondary_delta_t = max(0.0, state.primary_loop.temperature_out - state.secondary_loop.temperature_in)
state.heat_exchanger_efficiency = 0.0 if total_power <= 0 else min(1.0, max(0.0, transferred / max(1e-6, total_power)))
LOGGER.info(
(
"t=%5.1fs rods=%.2f core_power=%.1fMW prompt=%.1fMW :: "
@@ -521,10 +526,10 @@ class Reactor:
self.shutdown = False
if command.rod_position is not None:
self.control.set_manual_mode(True)
overrides["rod_fraction"] = self.control.set_rods(command.rod_position)
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)
self.control.increment_rods(command.rod_step)
if command.primary_pumps:
for idx, flag in command.primary_pumps.items():
self._toggle_primary_pump_unit(idx - 1, flag)

View File

@@ -18,6 +18,8 @@ class CoreState:
reactivity_margin: float # delta rho
power_output_mw: float # MW thermal
burnup: float # fraction of fuel consumed
xenon_inventory: float = 0.0
iodine_inventory: float = 0.0
fission_product_inventory: dict[str, float] = field(default_factory=dict)
emitted_particles: dict[str, float] = field(default_factory=dict)

View File

@@ -56,7 +56,7 @@ class ThermalSolver:
primary.temperature_out = primary.temperature_in + temp_rise
# Fuel heats from any power not immediately convected away, and cools toward the primary outlet.
heating = 0.005 * max(0.0, power_mw - temp_rise) * dt
cooling = 0.05 * max(0.0, core.fuel_temperature - primary.temperature_out) * dt
cooling = 0.025 * max(0.0, core.fuel_temperature - primary.temperature_out) * dt
core.fuel_temperature += heating - cooling
# Keep fuel temperature bounded and never below the coolant outlet temperature.
core.fuel_temperature = min(max(primary.temperature_out, core.fuel_temperature), constants.MAX_CORE_TEMPERATURE)