Add rod banks, xenon kinetics, and document feature set
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user