Add chemistry-driven fouling and HX/condenser penalties

This commit is contained in:
Codex Agent
2025-11-28 20:04:33 +01:00
parent a2afbabe49
commit d4ed590b0d
9 changed files with 103 additions and 7 deletions

View File

@@ -2,6 +2,7 @@
- Reactor model: two-loop but tuned to RBMK-like pressures (nominal ~7 MPa for both loops). Loop pressure clamps to saturation baseline when pumps are off; pumps ramp flow/pressure over spool time when stopping or starting. - Reactor model: two-loop but tuned to RBMK-like pressures (nominal ~7 MPa for both loops). Loop pressure clamps to saturation baseline when pumps are off; pumps ramp flow/pressure over spool time when stopping or starting.
- Feedwater & level: secondary steam drum uses shrink/swell-aware level sensing to drive a feedwater valve; makeup flow scales with steam draw toward the target level instead of instant inventory clamps. - Feedwater & level: secondary steam drum uses shrink/swell-aware level sensing to drive a feedwater valve; makeup flow scales with steam draw toward the target level instead of instant inventory clamps.
- Chemistry/fouling: plant tracks dissolved O2/boron/sodium; impurities plus high temp/steam draw increase HX fouling (reduces UA) and add condenser fouling/back-pressure. Oxygen degasses with steam; impurity ingress accelerates when venting.
- Turbines: produce zero output unless steam quality is present and effective steam flow is >10 kg/s. Turbine panel shows steam availability (enthalpy × quality × mass flow) and steam enthalpy instead of loop pressure; condenser pressure/temperature/fouling shown with nominal bounds. - Turbines: produce zero output unless steam quality is present and effective steam flow is >10 kg/s. Turbine panel shows steam availability (enthalpy × quality × mass flow) and steam enthalpy instead of loop pressure; condenser pressure/temperature/fouling shown with nominal bounds.
- Generators: two diesel units, rated 50 MW, spool time 10s. Auto mode default `False`; manual toggles b/v. Auto stops when no load. Relief valves toggles l (primary) / ; (secondary) and displayed per loop. - Generators: two diesel units, rated 50 MW, spool time 10s. Auto mode default `False`; manual toggles b/v. Auto stops when no load. Relief valves toggles l (primary) / ; (secondary) and displayed per loop.
- Pumps: per-unit controls g/h (primary), j/k (secondary). Flow/pressure ramp down over spool when pumps stop. Pump status thresholds use >0.1 kg/s to show STOPPING. - Pumps: per-unit controls g/h (primary), j/k (secondary). Flow/pressure ramp down over spool when pumps stop. Pump status thresholds use >0.1 kg/s to show STOPPING.

View File

@@ -3,8 +3,8 @@
- **Core physics**: point-kinetics with per-bank delayed neutron precursors, temperature feedback, fuel burnup penalty, xenon/iodine buildup with decay and burn-out, and rod-bank worth curves. - **Core physics**: point-kinetics with per-bank delayed neutron precursors, 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 with safety backoff; manual mode with staged bank motion and SCRAM; state persists across runs. - **Rod control**: three rod banks with weighted worth; auto controller chases 3 GW setpoint with safety backoff; 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. - **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 with a pinch cap to keep the primary outlet hotter than the secondary, coolant heating uses total fission power with fuel heating decoupled from exchanger draw, and the secondary thermal solver includes passive cool-down plus steam-drum mass/energy balance with latent heat and a shrink/swell-aware feedwater valve controller. - **Heat transfer**: steam-generator UA·ΔT_lm model with a pinch cap to keep the primary outlet hotter than the secondary, coolant heating uses total fission power with fuel heating decoupled from exchanger draw, and the secondary thermal solver includes passive cool-down plus steam-drum mass/energy balance with latent heat and a shrink/swell-aware feedwater valve controller; dissolved oxygen/sodium drive HX fouling that reduces effective UA.
- **Pressurizer & inventory**: primary pressurizer trims toward 7 MPa with level tracking, loop inventories/levels steer flow availability, secondary steam boil-off draws down level with auto makeup, and pumps reduce flow/status to `CAV` when NPSH is insufficient. - **Pressurizer & inventory**: primary pressurizer trims toward 7 MPa with level tracking, loop inventories/levels steer flow availability, secondary steam boil-off draws down level with auto makeup, and pumps reduce flow/status to `CAV` when NPSH is insufficient.
- **Steam cycle**: three turbines with spool dynamics, throttle mapping and a simple governor with overspeed/overload trip, condenser vacuum/back-pressure with fouling and cooling sink temperature, load dispatch to consumer, steam quality gating for output, generator states with batteries/spool, and steam enthalpy-driven availability readout on the dashboard. - **Steam cycle**: three turbines with spool dynamics, throttle mapping and a simple governor with overspeed/overload trip, condenser vacuum/back-pressure with fouling and cooling sink temperature, chemistry-driven fouling/back-pressure penalties, load dispatch to consumer, steam quality gating for output, generator states with batteries/spool, and steam enthalpy-driven availability readout on the dashboard.
- **Protections & failures**: health monitor degrading components under stress, automatic SCRAM on core or heat-sink loss plus DNB/subcool and secondary level/pressure trips, relief valves per loop with venting/mass loss and pump pressure caps, maintenance actions to restore integrity. - **Protections & failures**: health monitor degrading components under stress, automatic SCRAM on core or heat-sink loss plus DNB/subcool and secondary level/pressure trips, relief valves per loop with venting/mass loss and pump pressure caps, 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. - **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

@@ -10,7 +10,7 @@
- [ ] Dashboard multi-page view (F1/F2): retain numeric view on F1; future F2 schematic should mirror real PWR layout with ASCII art, flow/relief status, and minimal animations; add help/status hints and size checks; keep perf sane. - [ ] Dashboard multi-page view (F1/F2): retain numeric view on F1; future F2 schematic should mirror real PWR layout with ASCII art, flow/relief status, and minimal animations; add help/status hints and size checks; keep perf sane.
- [x] Core thermal realism: add a simple radial fuel model (pellet/rim/clad temps) with burnup-driven conductivity drop and gap conductance; upgrade CHF/DNB correlation (e.g., Groeneveld/Chen) parameterized by pressure, mass flux, and quality, then calibrate margins. - [x] Core thermal realism: add a simple radial fuel model (pellet/rim/clad temps) with burnup-driven conductivity drop and gap conductance; upgrade CHF/DNB correlation (e.g., Groeneveld/Chen) parameterized by pressure, mass flux, and quality, then calibrate margins.
- [ ] Transient protection ladder: add dP/dt and dT/dt trips for SG overfill/depressurization and rod-run-in alarms; implement graded warn/arm/trip stages surfaced on the dashboard. - [ ] Transient protection ladder: add dP/dt and dT/dt trips for SG overfill/depressurization and rod-run-in alarms; implement graded warn/arm/trip stages surfaced on the dashboard.
- [ ] Chemistry & fouling: track dissolved oxygen/boron and corrosion/fouling that degrade HX efficiency and condenser vacuum; let feedwater temperature/chemistry affect steam purity/back-pressure. - [x] Chemistry & fouling: track dissolved oxygen/boron and corrosion/fouling that degrade HX efficiency and condenser vacuum; let feedwater temperature/chemistry affect steam purity/back-pressure.
- [x] Balance-of-plant dynamics: steam-drum level controller with shrink/swell, feedwater valve model, turbine throttle governor/overspeed trip, and improved load-follow tied to grid demand ramps. - [x] Balance-of-plant dynamics: steam-drum level controller with shrink/swell, feedwater valve model, turbine throttle governor/overspeed trip, and improved load-follow tied to grid demand ramps.
- [ ] Component wear & maintenance: make wear depend on duty cycle and off-nominal conditions (cavitation, high ΔP, high temp); add preventive maintenance scheduling and show next-due in the dashboard. - [ ] Component wear & maintenance: make wear depend on duty cycle and off-nominal conditions (cavitation, high ΔP, high temp); add preventive maintenance scheduling and show next-due in the dashboard.
- [ ] Scenarios & tooling: presets for cold start, load-follow, and fault injection (pump fail, relief stuck) with seedable randomness; snapshot diff tooling to compare saved states. - [ ] Scenarios & tooling: presets for cold start, load-follow, and fault injection (pump fail, relief stuck) with seedable randomness; snapshot diff tooling to compare saved states.

View File

@@ -41,6 +41,8 @@ CONDENSER_VACUUM_PUMP_RATE = 0.05 # MPa per second drawdown toward base when be
CONDENSER_COOLING_WATER_TEMP_K = 295.0 # cooling sink temperature CONDENSER_COOLING_WATER_TEMP_K = 295.0 # cooling sink temperature
CONDENSER_FOULING_RATE = 0.00002 # incremental penalty per second of hot operation CONDENSER_FOULING_RATE = 0.00002 # incremental penalty per second of hot operation
CONDENSER_FOULING_MAX_PENALTY = 0.2 # max additional backpressure penalty from fouling CONDENSER_FOULING_MAX_PENALTY = 0.2 # max additional backpressure penalty from fouling
CONDENSER_CHEM_FOULING_RATE = 0.0005 # per-second fouling increment scaled by impurity ppm
CONDENSER_CHEM_BACKPRESSURE_FACTOR = 0.0002 # MPa increase per ppm impurities toward condenser pressure
GENERATOR_SPOOL_TIME = 10.0 # seconds to reach full output GENERATOR_SPOOL_TIME = 10.0 # seconds to reach full output
# Auxiliary power assumptions # Auxiliary power assumptions
PUMP_POWER_MW = 12.0 # MW draw per pump unit PUMP_POWER_MW = 12.0 # MW draw per pump unit
@@ -67,6 +69,14 @@ SECONDARY_INVENTORY_TARGET = 0.9
SECONDARY_STEAM_LOSS_FRACTION = 0.02 # fraction of steam mass that leaves the loop each second SECONDARY_STEAM_LOSS_FRACTION = 0.02 # fraction of steam mass that leaves the loop each second
NPSH_REQUIRED_MPA = 0.25 NPSH_REQUIRED_MPA = 0.25
LOW_LEVEL_FLOW_FLOOR = 0.05 LOW_LEVEL_FLOW_FLOOR = 0.05
# Chemistry & fouling
CHEM_MAX_PPM = 5_000.0
CHEM_OXYGEN_DEFAULT_PPM = 50.0 # deoxygenated feedwater target (ppb -> ppm surrogate)
CHEM_BORON_DEFAULT_PPM = 500.0
CHEM_SODIUM_DEFAULT_PPM = 5.0
HX_FOULING_RATE = 1e-5 # fouling increment per second scaled by impurities/temp
HX_FOULING_HEAL_RATE = 5e-6 # cleaning/settling when cool/low steam
HX_FOULING_MAX_PENALTY = 0.25 # fractional UA loss cap
# Threshold inventories (event counts) for flagging common poisons in diagnostics. # Threshold inventories (event counts) for flagging common poisons in diagnostics.
KEY_POISON_THRESHOLDS = { KEY_POISON_THRESHOLDS = {
"Xe": 1e20, # xenon "Xe": 1e20, # xenon

View File

@@ -397,7 +397,12 @@ class Reactor:
if not self.secondary_pump_active or state.secondary_loop.mass_flow_rate <= 1.0: if not self.secondary_pump_active or state.secondary_loop.mass_flow_rate <= 1.0:
transferred = 0.0 transferred = 0.0
else: else:
transferred = heat_transfer(state.primary_loop, state.secondary_loop, total_power) transferred = heat_transfer(
state.primary_loop,
state.secondary_loop,
total_power,
fouling_factor=getattr(state, "hx_fouling", 0.0),
)
residual = max(0.0, total_power - transferred) residual = max(0.0, total_power - transferred)
self.thermal.step_core(state.core, state.primary_loop, total_power, dt, residual_power_mw=residual) self.thermal.step_core(state.core, state.primary_loop, total_power, dt, residual_power_mw=residual)
self.thermal.step_secondary(state.secondary_loop, transferred, dt) self.thermal.step_secondary(state.secondary_loop, transferred, dt)
@@ -425,6 +430,7 @@ class Reactor:
self.control.safety_backoff(state.core.subcooling_margin, state.core.dnb_margin, dt) self.control.safety_backoff(state.core.subcooling_margin, state.core.dnb_margin, dt)
self._apply_secondary_boiloff(state, dt) self._apply_secondary_boiloff(state, dt)
self._update_secondary_level(state, dt) self._update_secondary_level(state, dt)
self._update_chemistry(state, dt)
steam_draw = self._step_turbine_bank(state, transferred, dt) steam_draw = self._step_turbine_bank(state, transferred, dt)
if steam_draw > 0.0: if steam_draw > 0.0:
@@ -652,6 +658,36 @@ class Reactor:
loop.level = min(1.2, max(0.0, loop.inventory_kg / nominal_mass)) loop.level = min(1.2, max(0.0, loop.inventory_kg / nominal_mass))
self._last_steam_out_kg_s = steam_out self._last_steam_out_kg_s = steam_out
def _update_chemistry(self, state: PlantState, dt: float) -> None:
"""Track dissolved species and fouling impacts on HX and condenser."""
env = constants.ENVIRONMENT_TEMPERATURE
steam_out = state.secondary_loop.mass_flow_rate * max(0.0, state.secondary_loop.steam_quality)
temp = state.secondary_loop.temperature_out
temp_factor = max(0.0, (temp - env) / 300.0)
impurity_load = max(0.0, state.dissolved_oxygen_ppm + 0.5 * state.sodium_ppm)
fouling_rate = constants.HX_FOULING_RATE * temp_factor * impurity_load
heal = constants.HX_FOULING_HEAL_RATE * (1.0 if steam_out < 200.0 or temp_factor < 0.2 else 0.0)
state.hx_fouling = max(
0.0,
min(constants.HX_FOULING_MAX_PENALTY, state.hx_fouling + (fouling_rate - heal) * dt),
)
# Degas oxygen with steam production; small impurity ingress over time (worse when venting).
degas = 0.0005 * steam_out * dt / max(1.0, constants.SECONDARY_LOOP_VOLUME_M3)
state.dissolved_oxygen_ppm = max(0.0, state.dissolved_oxygen_ppm - degas)
ingress = (0.01 if self.secondary_relief_open else 0.002) * dt
state.sodium_ppm = min(constants.CHEM_MAX_PPM, state.sodium_ppm + ingress)
state.boron_ppm = max(0.0, state.boron_ppm - 0.001 * dt)
chem_penalty = constants.CONDENSER_CHEM_FOULING_RATE * impurity_load / 1_000.0
for turb_state in state.turbines:
turb_state.fouling_penalty = min(
constants.CONDENSER_FOULING_MAX_PENALTY,
max(0.0, turb_state.fouling_penalty + chem_penalty * dt),
)
backpressure = constants.CONDENSER_CHEM_BACKPRESSURE_FACTOR * impurity_load * dt
turb_state.condenser_pressure = min(
constants.CONDENSER_MAX_PRESSURE_MPA, turb_state.condenser_pressure + backpressure
)
def _inventory_flow_scale(self, loop: CoolantLoopState) -> float: def _inventory_flow_scale(self, loop: CoolantLoopState) -> float:
if loop.level <= constants.LOW_LEVEL_FLOW_FLOOR: if loop.level <= constants.LOW_LEVEL_FLOW_FLOOR:
return 0.0 return 0.0

View File

@@ -102,6 +102,10 @@ class PlantState:
aux_draws: dict[str, float] = field(default_factory=dict) aux_draws: dict[str, float] = field(default_factory=dict)
heat_exchanger_efficiency: float = 0.0 heat_exchanger_efficiency: float = 0.0
primary_to_secondary_delta_t: float = 0.0 primary_to_secondary_delta_t: float = 0.0
dissolved_oxygen_ppm: float = constants.CHEM_OXYGEN_DEFAULT_PPM
boron_ppm: float = constants.CHEM_BORON_DEFAULT_PPM
sodium_ppm: float = constants.CHEM_SODIUM_DEFAULT_PPM
hx_fouling: float = 0.0
time_elapsed: float = field(default=0.0) time_elapsed: float = field(default=0.0)
def snapshot(self) -> dict[str, float]: def snapshot(self) -> dict[str, float]:
@@ -150,6 +154,10 @@ class PlantState:
aux_draws = data.get("aux_draws", {}) aux_draws = data.get("aux_draws", {})
hx_eff = data.get("heat_exchanger_efficiency", 0.0) hx_eff = data.get("heat_exchanger_efficiency", 0.0)
delta_t = data.get("primary_to_secondary_delta_t", 0.0) delta_t = data.get("primary_to_secondary_delta_t", 0.0)
dissolved_oxygen = data.get("dissolved_oxygen_ppm", constants.CHEM_OXYGEN_DEFAULT_PPM)
boron_ppm = data.get("boron_ppm", constants.CHEM_BORON_DEFAULT_PPM)
sodium_ppm = data.get("sodium_ppm", constants.CHEM_SODIUM_DEFAULT_PPM)
hx_fouling = data.get("hx_fouling", 0.0)
return cls( return cls(
core=CoreState(**core_blob, fission_product_inventory=inventory, emitted_particles=particles), core=CoreState(**core_blob, fission_product_inventory=inventory, emitted_particles=particles),
primary_loop=CoolantLoopState(**_with_energy(data["primary_loop"])), primary_loop=CoolantLoopState(**_with_energy(data["primary_loop"])),
@@ -161,6 +169,10 @@ class PlantState:
aux_draws=aux_draws, aux_draws=aux_draws,
heat_exchanger_efficiency=hx_eff, heat_exchanger_efficiency=hx_eff,
primary_to_secondary_delta_t=delta_t, primary_to_secondary_delta_t=delta_t,
dissolved_oxygen_ppm=dissolved_oxygen,
boron_ppm=boron_ppm,
sodium_ppm=sodium_ppm,
hx_fouling=hx_fouling,
time_elapsed=data.get("time_elapsed", 0.0), time_elapsed=data.get("time_elapsed", 0.0),
) )

View File

@@ -13,7 +13,9 @@ from .state import CoolantLoopState, CoreState
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def heat_transfer(primary: CoolantLoopState, secondary: CoolantLoopState, core_power_mw: float) -> float: def heat_transfer(
primary: CoolantLoopState, secondary: CoolantLoopState, core_power_mw: float, fouling_factor: float = 0.0
) -> float:
"""Return MW transferred to the secondary loop.""" """Return MW transferred to the secondary loop."""
if primary.mass_flow_rate <= 0.0 or secondary.mass_flow_rate <= 0.0: if primary.mass_flow_rate <= 0.0 or secondary.mass_flow_rate <= 0.0:
return 0.0 return 0.0
@@ -25,7 +27,8 @@ def heat_transfer(primary: CoolantLoopState, secondary: CoolantLoopState, core_p
lmtd = delta_t1 lmtd = delta_t1
else: else:
lmtd = (delta_t1 - delta_t2) / math.log(delta_t1 / delta_t2) lmtd = (delta_t1 - delta_t2) / math.log(delta_t1 / delta_t2)
ua = constants.STEAM_GENERATOR_UA_MW_PER_K fouling = max(0.0, min(constants.HX_FOULING_MAX_PENALTY, fouling_factor))
ua = constants.STEAM_GENERATOR_UA_MW_PER_K * (1.0 - fouling)
ua_limited = ua * lmtd ua_limited = ua * lmtd
# Prevent the heat exchanger from over-transferring and inverting the outlet temperatures. # Prevent the heat exchanger from over-transferring and inverting the outlet temperatures.

View File

@@ -268,6 +268,28 @@ def test_auto_control_resets_shutdown_and_moves_rods():
assert reactor.control.rod_fraction < 0.95 assert reactor.control.rod_fraction < 0.95
def test_chemistry_builds_fouling_and_backpressure():
reactor = Reactor.default()
state = reactor.initial_state()
# Push impurities high to accelerate fouling dynamics.
state.dissolved_oxygen_ppm = 200.0
state.sodium_ppm = 100.0
state.secondary_loop.mass_flow_rate = 20_000.0
state.secondary_loop.steam_quality = 0.3
state.secondary_loop.temperature_out = 600.0
state.secondary_loop.temperature_in = 560.0
base_hx = state.hx_fouling
base_foul = state.turbines[0].fouling_penalty if state.turbines else 0.0
base_pressure = state.turbines[0].condenser_pressure if state.turbines else constants.CONDENSER_BASE_PRESSURE_MPA
reactor._update_chemistry(state, dt=20.0)
assert state.hx_fouling > base_hx
if state.turbines:
assert state.turbines[0].fouling_penalty > base_foul
assert state.turbines[0].condenser_pressure >= base_pressure
def test_full_power_reaches_steam_and_turbine_output(): def test_full_power_reaches_steam_and_turbine_output():
"""Integration: ramp to full power with staged rod control and verify sustained steam/electric output.""" """Integration: ramp to full power with staged rod control and verify sustained steam/electric output."""
reactor = Reactor.default() reactor = Reactor.default()

View File

@@ -2,7 +2,7 @@ import pytest
from reactor_sim import constants from reactor_sim import constants
from reactor_sim.state import CoolantLoopState from reactor_sim.state import CoolantLoopState
from reactor_sim.thermal import ThermalSolver, saturation_temperature from reactor_sim.thermal import ThermalSolver, heat_transfer, saturation_temperature
def _secondary_loop(temp_in: float = 350.0, pressure: float = 0.5, flow: float = 200.0) -> CoolantLoopState: def _secondary_loop(temp_in: float = 350.0, pressure: float = 0.5, flow: float = 200.0) -> CoolantLoopState:
@@ -36,3 +36,15 @@ def test_secondary_generates_steam_when_energy_exceeds_sensible_heat():
assert loop.temperature_out == pytest.approx(sat_temp, rel=0.05) assert loop.temperature_out == pytest.approx(sat_temp, rel=0.05)
assert loop.steam_quality > 0.0 assert loop.steam_quality > 0.0
assert loop.steam_quality < 1.0 assert loop.steam_quality < 1.0
def test_heat_transfer_reduced_by_fouling():
primary = CoolantLoopState(
temperature_in=360.0, temperature_out=380.0, pressure=15.0, mass_flow_rate=50_000.0, steam_quality=0.0
)
secondary = CoolantLoopState(
temperature_in=320.0, temperature_out=330.0, pressure=6.5, mass_flow_rate=50_000.0, steam_quality=0.1
)
clean = heat_transfer(primary, secondary, core_power_mw=7_000.0, fouling_factor=0.0)
fouled = heat_transfer(primary, secondary, core_power_mw=7_000.0, fouling_factor=0.25)
assert fouled < clean