Add chemistry-driven fouling and HX/condenser penalties
This commit is contained in:
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
2
TODO.md
2
TODO.md
@@ -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.
|
||||
- [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.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
|
||||
@@ -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_FOULING_RATE = 0.00002 # incremental penalty per second of hot operation
|
||||
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
|
||||
# Auxiliary power assumptions
|
||||
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
|
||||
NPSH_REQUIRED_MPA = 0.25
|
||||
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.
|
||||
KEY_POISON_THRESHOLDS = {
|
||||
"Xe": 1e20, # xenon
|
||||
|
||||
@@ -397,7 +397,12 @@ class Reactor:
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
@@ -425,6 +430,7 @@ class Reactor:
|
||||
self.control.safety_backoff(state.core.subcooling_margin, state.core.dnb_margin, dt)
|
||||
self._apply_secondary_boiloff(state, dt)
|
||||
self._update_secondary_level(state, dt)
|
||||
self._update_chemistry(state, dt)
|
||||
|
||||
steam_draw = self._step_turbine_bank(state, transferred, dt)
|
||||
if steam_draw > 0.0:
|
||||
@@ -652,6 +658,36 @@ class Reactor:
|
||||
loop.level = min(1.2, max(0.0, loop.inventory_kg / nominal_mass))
|
||||
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:
|
||||
if loop.level <= constants.LOW_LEVEL_FLOW_FLOOR:
|
||||
return 0.0
|
||||
|
||||
@@ -102,6 +102,10 @@ class PlantState:
|
||||
aux_draws: dict[str, float] = field(default_factory=dict)
|
||||
heat_exchanger_efficiency: 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)
|
||||
|
||||
def snapshot(self) -> dict[str, float]:
|
||||
@@ -150,6 +154,10 @@ class PlantState:
|
||||
aux_draws = data.get("aux_draws", {})
|
||||
hx_eff = data.get("heat_exchanger_efficiency", 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(
|
||||
core=CoreState(**core_blob, fission_product_inventory=inventory, emitted_particles=particles),
|
||||
primary_loop=CoolantLoopState(**_with_energy(data["primary_loop"])),
|
||||
@@ -161,6 +169,10 @@ class PlantState:
|
||||
aux_draws=aux_draws,
|
||||
heat_exchanger_efficiency=hx_eff,
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ from .state import CoolantLoopState, CoreState
|
||||
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."""
|
||||
if primary.mass_flow_rate <= 0.0 or secondary.mass_flow_rate <= 0.0:
|
||||
return 0.0
|
||||
@@ -25,7 +27,8 @@ def heat_transfer(primary: CoolantLoopState, secondary: CoolantLoopState, core_p
|
||||
lmtd = delta_t1
|
||||
else:
|
||||
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
|
||||
|
||||
# Prevent the heat exchanger from over-transferring and inverting the outlet temperatures.
|
||||
|
||||
@@ -268,6 +268,28 @@ def test_auto_control_resets_shutdown_and_moves_rods():
|
||||
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():
|
||||
"""Integration: ramp to full power with staged rod control and verify sustained steam/electric output."""
|
||||
reactor = Reactor.default()
|
||||
|
||||
@@ -2,7 +2,7 @@ import pytest
|
||||
|
||||
from reactor_sim import constants
|
||||
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:
|
||||
@@ -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.steam_quality > 0.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
|
||||
|
||||
Reference in New Issue
Block a user