159 lines
6.2 KiB
Python
159 lines
6.2 KiB
Python
"""Neutron balance and reactivity calculations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import logging
|
|
|
|
from . import constants
|
|
from .fuel import fuel_reactivity_penalty
|
|
from .state import CoreState, clamp
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
def temperature_feedback(temp: float) -> float:
|
|
"""Negative coefficient: higher temperature lowers reactivity."""
|
|
reference = 900.0
|
|
coefficient = -1.7e-5
|
|
return coefficient * (temp - reference)
|
|
|
|
|
|
def xenon_poisoning(flux: float) -> float:
|
|
return min(0.01, 5e-10 * flux)
|
|
|
|
|
|
@dataclass
|
|
class NeutronDynamics:
|
|
beta_effective: float = 0.0065
|
|
delayed_decay_const: float = 0.08 # 1/s effective precursor decay
|
|
external_source_coupling: float = 1e-6
|
|
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.05
|
|
|
|
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 +
|
|
rod_term
|
|
+ temperature_feedback(state.fuel_temperature)
|
|
- fuel_reactivity_penalty(state.burnup)
|
|
- self.xenon_penalty(state)
|
|
)
|
|
return rho
|
|
|
|
def flux_derivative(
|
|
self,
|
|
state: CoreState,
|
|
rho: float,
|
|
delayed_source: float,
|
|
external_source_rate: float = 0.0,
|
|
baseline_source: float = 1e5,
|
|
) -> float:
|
|
generation_time = constants.NEUTRON_LIFETIME
|
|
beta = self.beta_effective
|
|
source_term = self.external_source_coupling * external_source_rate
|
|
prompt = ((rho - beta) / generation_time) * state.neutron_flux
|
|
return prompt + delayed_source + baseline_source + source_term
|
|
|
|
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:
|
|
rho = min(rho, -0.04)
|
|
baseline = 0.0 if shutdown else 1e5
|
|
source = 0.0 if shutdown else external_source_rate
|
|
rod_positions = rod_banks if rod_banks else [control_fraction] * len(constants.CONTROL_ROD_BANK_WEIGHTS)
|
|
self._ensure_precursors(state, len(rod_positions))
|
|
bank_factors = self._bank_factors(rod_positions)
|
|
bank_betas = self._bank_betas(len(bank_factors))
|
|
delayed_source = self._delayed_source(state, bank_factors)
|
|
|
|
d_flux = self.flux_derivative(
|
|
state,
|
|
rho,
|
|
delayed_source,
|
|
external_source_rate=source,
|
|
baseline_source=baseline,
|
|
)
|
|
state.neutron_flux = max(0.0, state.neutron_flux + d_flux * dt)
|
|
state.reactivity_margin = rho
|
|
self._update_precursors(state, bank_factors, bank_betas, dt)
|
|
LOGGER.debug(
|
|
"Neutronics: rho=%.5f, flux=%.2e n/cm2/s, d_flux=%.2e",
|
|
rho,
|
|
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 delta-rho penalty from xenon inventory (positive magnitude)."""
|
|
return self._xenon_penalty(state)
|
|
|
|
def _xenon_penalty(self, state: CoreState) -> float:
|
|
return min(0.05, state.xenon_inventory * self.xenon_reactivity_coeff)
|
|
|
|
def _bank_betas(self, bank_count: int) -> list[float]:
|
|
weights = list(constants.CONTROL_ROD_BANK_WEIGHTS)
|
|
if bank_count != len(weights):
|
|
weights = [1.0 for _ in range(bank_count)]
|
|
total = sum(weights) if weights else 1.0
|
|
return [self.beta_effective * (w / total) for w in weights]
|
|
|
|
def _bank_factors(self, positions: list[float]) -> list[float]:
|
|
factors: list[float] = []
|
|
for pos in positions:
|
|
insertion = clamp(pos, 0.0, 0.95)
|
|
factors.append(max(0.0, 1.0 - insertion / 0.95))
|
|
return factors
|
|
|
|
def _ensure_precursors(self, state: CoreState, bank_count: int) -> None:
|
|
if not state.delayed_precursors or len(state.delayed_precursors) != bank_count:
|
|
state.delayed_precursors = [0.0 for _ in range(bank_count)]
|
|
|
|
def _delayed_source(self, state: CoreState, bank_factors: list[float]) -> float:
|
|
decay = self.delayed_decay_const
|
|
return sum(decay * precursor * factor for precursor, factor in zip(state.delayed_precursors, bank_factors))
|
|
|
|
def _update_precursors(
|
|
self, state: CoreState, bank_factors: list[float], bank_betas: list[float], dt: float
|
|
) -> None:
|
|
generation_time = constants.NEUTRON_LIFETIME
|
|
decay = self.delayed_decay_const
|
|
new_pools: list[float] = []
|
|
for precursor, factor, beta in zip(state.delayed_precursors, bank_factors, bank_betas):
|
|
production = (beta / generation_time) * state.neutron_flux * factor
|
|
loss = decay * precursor
|
|
updated = max(0.0, precursor + (production - loss) * dt)
|
|
new_pools.append(updated)
|
|
state.delayed_precursors = new_pools
|