NuCon/scripts/reactor_control.py
Dominik Roth 88f4896086 feat: hand-written PID reactor controller with curses TUI
Full classical operator in scripts/reactor_control.py: rod control with
criticality feedforward, per-train MSCV/pump management, grid-demand
following with proportional cap distribution, pressurizer spray valve,
condenser and retention tank aux controllers, and a live curses TUI with
keyboard-driven target/setpoint adjustment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 00:18:00 +01:00

719 lines
32 KiB
Python

"""Classical PID-based reactor controller with curses TUI.
Architecture:
Core control (shared):
- Rod PID: keeps CORE_TEMP at setpoint via ROD_BANK_POS_0_ORDERED
Per-train control (trains 1/2/3, 0-indexed as 0/1/2 in param names):
- Primary pump: fixed 50%; hill-climbs only when deficit > 5 MW
- MSCV PI: drives train power output, gated on steam availability
- Secondary pump feedforward: half of steam outlet + level PID
- Bypass: hold near 0
Auxiliary:
- Retention tank: drain via ejector return valve when > 75%, stop at 50%
- Condenser fill: run FREIGHT_PUMP_CONDENSER below 33%, stop at 50%
Usage:
python3.14 scripts/reactor_control.py --trains 3 --target 50000
python3.14 scripts/reactor_control.py --trains 1 3 --target 30000 40000
python3.14 scripts/reactor_control.py --trains 1 2 3 --target 20000 20000 20000
TUI keys:
0 Select core (then +/- adjusts temp setpoint ±5°C)
1 / 2 / 3 Select train (then +/- adjusts target power ±5 MW; + adds if absent)
d Remove selected train from control
g Toggle grid-demand following
q / Esc Quit
"""
import argparse
import curses
import time
import numpy as np
from enum import Enum
from nucon import Nucon
parser = argparse.ArgumentParser()
parser.add_argument('--trains', type=int, nargs='+', default=[3])
parser.add_argument('--target', type=float, nargs='+', default=[50_000])
parser.add_argument('--temp-setpoint', type=float, default=330.0)
parser.add_argument('--dt', type=float, default=5.0)
parser.add_argument('--grid-follow', action='store_true',
help='Auto-set train targets from grid demand')
parser.add_argument('--grid-buffer', type=float, default=10.0,
help='Extra MW above grid demand when grid-following (default: 5)')
args = parser.parse_args()
if len(args.target) == 1:
targets = {t: args.target[0] for t in args.trains}
else:
if len(args.target) != len(args.trains):
raise ValueError("--target must have 1 value or one per --trains entry")
targets = dict(zip(args.trains, args.target))
nucon = Nucon()
# ---------------------------------------------------------------------------
# PID controller
# ---------------------------------------------------------------------------
class PID:
def __init__(self, kp, ki, kd, out_min, out_max, integral_max=None):
self.kp, self.ki, self.kd = kp, ki, kd
self.out_min, self.out_max = out_min, out_max
self.integral_max = integral_max or (out_max - out_min)
self._integral = 0.0
self._prev_error = None
def step(self, error, dt):
self._integral = np.clip(self._integral + error * dt,
-self.integral_max, self.integral_max)
derivative = 0.0 if self._prev_error is None else (error - self._prev_error) / dt
self._prev_error = error
return float(np.clip(
self.kp * error + self.ki * self._integral + self.kd * derivative,
self.out_min, self.out_max))
def reset(self):
self._integral = 0.0
self._prev_error = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_all_readable = None
def _get_all_readable():
global _all_readable
if _all_readable is None:
_all_readable = nucon.get_all_readable()
return _all_readable
def set_param(param_id, value):
param = nucon._parameters[param_id]
v = float(np.clip(value, param.min_val or 0, param.max_val or 100))
nucon.set(param, v)
return v
def read_state(param_ids):
all_r = _get_all_readable()
raw = nucon._batch_query([p for p in param_ids if p in all_r])
state = {}
for p in param_ids:
if p not in all_r:
state[p] = 0.0
continue
try:
v = nucon._parse_value(all_r[p], raw.get(p, '0'))
state[p] = float(v.value if isinstance(v, Enum) else v)
except Exception:
state[p] = 0.0
return state
# ---------------------------------------------------------------------------
# Per-train controller
# ---------------------------------------------------------------------------
class TrainController:
"""Controls one train (steam gen N + turbine N + generator N)."""
def __init__(self, train_num, target_kw):
self.n = train_num
self.i = train_num - 1
self.target_kw = target_kw
self.mscv_pid = PID(kp=0.00002, ki=0.000002, kd=0.0,
out_min=-0.3, out_max=0.2, integral_max=3.0)
self._prev_steam_out = None
self.sec_pid = PID(kp=0.0005, ki=0.00005, kd=0.001,
out_min=-2.0, out_max=2.0, integral_max=3.0)
self.sec_level_target = 25_000.0
self.prim_pump = 50.0
self.mscv = 9.0
self.sec_pump = 40.0
self._pump_hill_cycle = 0
self._pump_hill_dir = +1.0
self._pump_hill_steam = None
self.PUMP_SWEEP_THRESHOLD = 5_000.0
self.sweeping = False
set_param(f'STEAM_TURBINE_{self.i}_BYPASS_ORDERED', 0.0)
self._params = [
f'STEAM_GEN_{self.i}_OUTLET',
f'MSCV_{self.i}_OPENING_ACTUAL',
f'STEAM_TURBINE_{self.i}_RPM',
f'STEAM_TURBINE_{self.i}_BYPASS_ACTUAL',
f'GENERATOR_{self.i}_KW',
f'COOLANT_SEC_CIRCULATION_PUMP_{self.i}_ORDERED_SPEED',
f'COOLANT_SEC_{self.i}_LIQUID_VOLUME',
]
def params(self):
return self._params
def step(self, s, dt):
steam_out = s[f'STEAM_GEN_{self.i}_OUTLET']
power_kw = s[f'GENERATOR_{self.i}_KW']
power_error = self.target_kw - power_kw
# Dead-band: don't adjust MSCV when within 3% of target (avoid hunting)
if abs(power_error) < 0.03 * self.target_kw:
mscv_delta = 0.0
self.mscv_pid.reset()
else:
mscv_delta = self.mscv_pid.step(power_error, dt)
steam_rose = (self._prev_steam_out is None or
steam_out >= self._prev_steam_out - 1.0)
if mscv_delta > 0 and not steam_rose:
mscv_delta = 0.0
self._prev_steam_out = steam_out
# Cap only prevents opening further — don't force MSCV down as steam fluctuates.
mscv_max = max(steam_out / 8.0, 1.0)
new_mscv = self.mscv + mscv_delta
if mscv_delta > 0:
new_mscv = min(new_mscv, mscv_max)
self.mscv = float(np.clip(new_mscv, 0.5, 100.0))
set_param(f'MSCV_{self.i}_OPENING_ORDERED', self.mscv)
self.sweeping = power_error > self.PUMP_SWEEP_THRESHOLD
if self.sweeping:
self._pump_hill_cycle += 1
if self._pump_hill_cycle >= 12:
self._pump_hill_cycle = 0
if self._pump_hill_steam is not None:
if steam_out < self._pump_hill_steam - 0.2:
self._pump_hill_dir = -self._pump_hill_dir
self._pump_hill_steam = steam_out
self.prim_pump = float(np.clip(self.prim_pump + self._pump_hill_dir, 15.0, 90.0))
set_param(f'COOLANT_CORE_CIRCULATION_PUMP_{self.i}_ORDERED_SPEED', self.prim_pump)
else:
if self.prim_pump != 50.0:
self.prim_pump = 50.0
set_param(f'COOLANT_CORE_CIRCULATION_PUMP_{self.i}_ORDERED_SPEED', self.prim_pump)
self._pump_hill_cycle = 0
self._pump_hill_steam = None
sec_ff = steam_out / 2.0
level = s[f'COOLANT_SEC_{self.i}_LIQUID_VOLUME']
level_error = self.sec_level_target - level
sec_corr = self.sec_pid.step(level_error, dt)
sec_target = float(np.clip(sec_ff + sec_corr, 5.0, 100.0))
self.sec_pump += 0.3 * (sec_target - self.sec_pump)
set_param(f'COOLANT_SEC_CIRCULATION_PUMP_{self.i}_ORDERED_SPEED', self.sec_pump)
if s[f'STEAM_TURBINE_{self.i}_BYPASS_ACTUAL'] > 1.0:
set_param(f'STEAM_TURBINE_{self.i}_BYPASS_ORDERED', 0.0)
return power_kw, power_error, steam_out, level, level_error
# ---------------------------------------------------------------------------
# Global controller state
# ---------------------------------------------------------------------------
TEMP_MAX = 410.0
ROD_INTERVAL = 6
ROD_TIERS = [
(3.0, 0.1),
(8.0, 0.4),
(15.0, 0.8),
(float('inf'), 1.2),
]
rod_pos = float(nucon.get('ROD_BANK_POS_0_ACTUAL') or 85.0)
rod_cycle = 0
rod_integral = 0.0
train_controllers = {t: TrainController(t, targets[t]) for t in args.trains}
core_params = [
'CORE_TEMP', 'ROD_BANK_POS_0_ACTUAL',
'CORE_STATE_CRITICALITY',
'VACUUM_RETENTION_TANK_VOLUME',
'CONDENSER_VOLUME', 'CONDENSER_VAPOR_VOLUME',
'POWER_DEMAND_MW',
'CORE_PRIMARY_CIRCUIT_COOLING_TANK_VOLUME', # pressurizer water volume
'COOLANT_CORE_PRIMARY_LOOP_LEVEL', # overall primary loop fill %
'FREIGHT_PUMP_FEEDWATER_ACTIVE',
]
RETENTION_MAX = 40_000.0
RETENTION_HI = 0.75 * RETENTION_MAX
RETENTION_MID = 0.50 * RETENTION_MAX
# ---------------------------------------------------------------------------
# Pressurizer / primary circuit constants
# ---------------------------------------------------------------------------
PRSR_VALVE = 'Valvula_Pressurizer_Spray'
# CORE_PRIMARY_CIRCUIT_COOLING_TANK_VOLUME is the pressurizer water volume.
# Observed: 106030 = 60% → max ≈ 176717
PRSR_VOL_MAX = 176_717.0
PRSR_LEVEL_LO = 50.0 # % — open spray valve below this
PRSR_LEVEL_CLOSE = 60.0 # % — close spray valve once level recovers
PRSR_LEVEL_HI = 70.0 # % — op range high (informational)
PRIM_FILL_LO = 80.0 # % — start feedwater pump below this (uses COOLANT_CORE_PRIMARY_LOOP_LEVEL)
PRIM_FILL_HI = 90.0 # % — stop feedwater pump above this
# Initialise aux state from live game values so restarts are seamless.
_init = read_state([
'VACUUM_RETENTION_TANK_VOLUME',
'STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ACTUAL',
'CONDENSER_VOLUME', 'CONDENSER_VAPOR_VOLUME',
'FREIGHT_PUMP_CONDENSER_ACTIVE',
])
_ret_vol_init = _init.get('VACUUM_RETENTION_TANK_VOLUME', 0.0)
_ret_valve_init = _init.get('STEAM_EJECTOR_CONDENSER_RETURN_VALVE_ACTUAL', 0.0)
ret_valve = _ret_valve_init
ret_draining = (_ret_valve_init > 0.5 and _ret_vol_init > RETENTION_MID)
if _ret_valve_init > 0.5 and not ret_draining:
set_param('STEAM_EJECTOR_CONDENSER_RETURN_VALVE', 0.0)
ret_valve = 0.0
ret_prev_vol = _ret_vol_init
_cond_vol_init = _init.get('CONDENSER_VOLUME', 0.0)
_cond_vap_init = _init.get('CONDENSER_VAPOR_VOLUME', 0.0)
_cond_tot_init = _cond_vol_init + _cond_vap_init
_cond_pct_init = (_cond_vol_init / _cond_tot_init * 100.0) if _cond_tot_init > 0 else 0.0
_cond_pump_init = bool(_init.get('FREIGHT_PUMP_CONDENSER_ACTIVE', False))
if _cond_pump_init and _cond_pct_init >= 50.0:
nucon.set(nucon._parameters['FREIGHT_PUMP_CONDENSER_SWITCH'], False)
cond_pump_on = False
elif not _cond_pump_init and _cond_pct_init < 45.0:
nucon.set(nucon._parameters['FREIGHT_PUMP_CONDENSER_SWITCH'], True)
cond_pump_on = True
else:
cond_pump_on = _cond_pump_init
# Pressurizer spray valve — init from live state
_prsr_live = read_state(['CORE_PRIMARY_CIRCUIT_COOLING_TANK_VOLUME', 'COOLANT_CORE_PRIMARY_LOOP_LEVEL', 'FREIGHT_PUMP_FEEDWATER_ACTIVE'])
_prsr_level = _prsr_live.get('CORE_PRIMARY_CIRCUIT_COOLING_TANK_VOLUME', PRSR_VOL_MAX * 0.6) / PRSR_VOL_MAX * 100.0
_prsr_valve = nucon.get_valve(PRSR_VALVE)
_prsr_open = _prsr_valve.get('IsOpened', False) or _prsr_valve.get('Value', 0) > 50
prsr_spraying = _prsr_open and _prsr_level < PRSR_LEVEL_CLOSE
if _prsr_open and not prsr_spraying:
nucon.close_valve(PRSR_VALVE)
feedwater_on = bool(_prsr_live.get('FREIGHT_PUMP_FEEDWATER_ACTIVE', False))
# ---------------------------------------------------------------------------
# TUI helpers
# ---------------------------------------------------------------------------
def _bar(pct, width=18):
pct = max(0.0, min(100.0, pct))
filled = int(pct / 100.0 * width)
return '' * filled + '' * (width - filled)
def _safe_addstr(scr, row, col, text, attr=0):
H, W = scr.getmaxyx()
if row < 0 or row >= H:
return
if col < 0:
text = text[-col:]
col = 0
if col >= W:
return
text = text[:W - col]
try:
scr.addstr(row, col, text, attr)
except curses.error:
pass
def _hline(scr, row, char=''):
H, W = scr.getmaxyx()
if 0 <= row < H:
_safe_addstr(scr, row, 0, char * (W - 1))
# ---------------------------------------------------------------------------
# Main TUI loop
# ---------------------------------------------------------------------------
def run_controller(stdscr):
global rod_pos, rod_cycle, rod_integral
global ret_valve, ret_draining, ret_prev_vol, cond_pump_on
global prsr_spraying, feedwater_on
global train_controllers, targets
curses.curs_set(0)
stdscr.nodelay(True)
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_GREEN, -1) # good / normal
curses.init_pair(2, curses.COLOR_YELLOW, -1) # warning
curses.init_pair(3, curses.COLOR_RED, -1) # alarm
curses.init_pair(4, curses.COLOR_CYAN, -1) # selected
curses.init_pair(5, curses.COLOR_WHITE, curses.COLOR_BLUE) # title bar
GREEN = curses.color_pair(1)
YELLOW = curses.color_pair(2)
RED = curses.color_pair(3)
CYAN = curses.color_pair(4)
TITLE = curses.color_pair(5)
BOLD = curses.A_BOLD
REV = curses.A_REVERSE
SELECTION_ORDER = [0, 1, 2, 3, 4] # 0=core 1/2/3=trains 4=grid
selected_train = args.trains[0] if args.trains else 1
temp_setpoint = args.temp_setpoint # mutable; adjustable from TUI
temp_auto = True # auto-adjust setpoint to meet total power demand
grid_follow = args.grid_follow
# Per-train power caps: manual max each train should carry (used for proportional distribution)
grid_caps = {t: tc.target_kw for t, tc in train_controllers.items()}
cycle = 0
train_data = {}
# display state — updated each control cycle, read by draw() at any time
disp = dict(s={}, dynamic_setpoint=temp_setpoint, temp_auto=temp_auto, criticality=0.0,
ret_pct=0.0, ret_draining=False, ret_valve=0.0,
cond_pct=0.0, cond_pump_on=False,
prsr_level=_prsr_level, prsr_spraying=prsr_spraying,
prim_level=_prsr_live.get('COOLANT_CORE_PRIMARY_LOOP_LEVEL', 100.0),
feedwater_on=feedwater_on,
grid_follow=grid_follow, grid_demand_kw=0.0)
def rebuild_all_params():
p = list(core_params)
for tc in train_controllers.values():
p += tc.params()
return p
all_params = rebuild_all_params()
def handle_key(key):
nonlocal selected_train, all_params, temp_setpoint, temp_auto, grid_follow, grid_caps
if key in (ord('q'), 27):
return True # signal quit
# Direct selection by number
elif key in (ord('0'), ord('1'), ord('2'), ord('3')):
selected_train = key - ord('0')
elif key == ord('g'):
selected_train = 4
# Up/Down cycle through selections
elif key == curses.KEY_UP:
idx = SELECTION_ORDER.index(selected_train) if selected_train in SELECTION_ORDER else 0
selected_train = SELECTION_ORDER[(idx - 1) % len(SELECTION_ORDER)]
elif key == curses.KEY_DOWN:
idx = SELECTION_ORDER.index(selected_train) if selected_train in SELECTION_ORDER else 0
selected_train = SELECTION_ORDER[(idx + 1) % len(SELECTION_ORDER)]
# Right/+ increase Left/- decrease
elif key in (ord('+'), ord('='), curses.KEY_RIGHT):
if selected_train == 0 and not temp_auto:
temp_setpoint = min(round(temp_setpoint / 5.0) * 5.0 + 5.0, 410.0)
elif selected_train == 4:
args.grid_buffer = min(args.grid_buffer + 1.0, 100.0)
elif selected_train in train_controllers:
if grid_follow:
cur = grid_caps.get(selected_train, train_controllers[selected_train].target_kw)
grid_caps[selected_train] = min(round(cur / 5_000) * 5_000 + 5_000, 100_000) # snap+step
else:
tc = train_controllers[selected_train]
tc.target_kw = min(round(tc.target_kw / 5_000) * 5_000 + 5_000, 100_000)
targets[selected_train] = tc.target_kw
grid_caps[selected_train] = tc.target_kw
elif selected_train in (1, 2, 3):
targets[selected_train] = 5_000
train_controllers[selected_train] = TrainController(selected_train, 5_000)
grid_caps[selected_train] = 5_000
all_params = rebuild_all_params()
elif key in (ord('-'), curses.KEY_LEFT):
if selected_train == 0 and not temp_auto:
temp_setpoint = max(round(temp_setpoint / 5.0) * 5.0 - 5.0, 250.0)
elif selected_train == 4:
args.grid_buffer = max(args.grid_buffer - 1.0, 0.0)
elif selected_train in train_controllers:
if grid_follow:
cur = grid_caps.get(selected_train, train_controllers[selected_train].target_kw)
grid_caps[selected_train] = max(round(cur / 5_000) * 5_000 - 5_000, 0)
else:
tc = train_controllers[selected_train]
tc.target_kw = max(round(tc.target_kw / 5_000) * 5_000 - 5_000, 0)
targets[selected_train] = tc.target_kw
grid_caps[selected_train] = tc.target_kw
elif key == ord('d'):
if selected_train == 0:
temp_auto = not temp_auto
disp['temp_auto'] = temp_auto
elif selected_train == 4:
grid_follow = not grid_follow
disp['grid_follow'] = grid_follow
elif selected_train in train_controllers:
del train_controllers[selected_train]
grid_caps.pop(selected_train, None)
if selected_train in targets:
del targets[selected_train]
all_params = rebuild_all_params()
selected_train = 0 if not train_controllers else list(train_controllers.keys())[0]
return False
def draw():
s = disp['s']
dynamic_setpoint = disp['dynamic_setpoint']
criticality = disp['criticality']
ret_pct = disp['ret_pct']
ret_draining = disp['ret_draining']
ret_valve = disp['ret_valve']
cond_pct = disp['cond_pct']
cond_pump_on = disp['cond_pump_on']
if not s:
return
stdscr.erase()
H, W = stdscr.getmaxyx()
row = 0
title = f" NUCLEARES CONTROLLER ─ Cycle {cycle:5d} ─ dt={args.dt:.0f}s "
_safe_addstr(stdscr, row, 0, title.ljust(W - 1), TITLE | BOLD)
row += 1
_hline(stdscr, row); row += 1
core_sel = (selected_train == 0)
core_attr = CYAN | BOLD if core_sel else BOLD
temp_auto_ = disp['temp_auto']
core_temp = s.get('CORE_TEMP', 0.0)
temp_color = RED if core_temp > 370 else YELLOW if core_temp > 355 else GREEN
scram_str = ' !! SCRAM !!' if core_temp > TEMP_MAX else ''
auto_str = 'AUTO' if temp_auto_ else 'MAN '
auto_color = GREEN if temp_auto_ else YELLOW
_safe_addstr(stdscr, row, 2, '◆ CORE' + ('' if core_sel else ''), core_attr)
_safe_addstr(stdscr, row, 10, f'[{auto_str}]', auto_color | BOLD)
_safe_addstr(stdscr, row, 16, 'Temp: ', BOLD)
_safe_addstr(stdscr, row, 22, f'{core_temp:6.1f}°C', temp_color | BOLD)
sp_color = RED if dynamic_setpoint < 306 or dynamic_setpoint > 360 else 0
_safe_addstr(stdscr, row, 32, f'sp=', 0)
_safe_addstr(stdscr, row, 35, f'{dynamic_setpoint:.0f}°C', sp_color | BOLD)
_safe_addstr(stdscr, row, 40,
f' Rod: {s.get("ROD_BANK_POS_0_ACTUAL", 0):5.1f} '
f'Crit: {criticality:+.3f}{scram_str}')
row += 1
for t in (1, 2, 3):
_hline(stdscr, row); row += 1
is_sel = (t == selected_train)
is_active = (t in train_controllers)
tc = train_controllers.get(t)
sel_attr = CYAN | BOLD if is_sel else 0
label = f'◆ TRAIN {t}' + ('' if is_sel else '')
_safe_addstr(stdscr, row, 2, label, sel_attr | BOLD)
if is_active and t in train_data:
power_kw, power_error, steam_out, level, level_error = train_data[t]
pwr_pct = power_kw / tc.target_kw * 100.0 if tc.target_kw > 0 else 0.0
pwr_color = GREEN if abs(power_error) < 2000 else YELLOW if abs(power_error) < 8000 else RED
cap = grid_caps.get(t, tc.target_kw)
gf = disp['grid_follow']
tgt_str = (f'tgt={tc.target_kw/1000:.1f}/{cap/1000:.0f}MW'
if gf and abs(tc.target_kw - cap) > 500
else f'tgt={tc.target_kw/1000:.0f}MW')
_safe_addstr(stdscr, row, 16, 'Power: ', BOLD)
_safe_addstr(stdscr, row, 23, f'{power_kw/1000:5.1f} MW', pwr_color | BOLD)
_safe_addstr(stdscr, row, 32,
f'[{_bar(pwr_pct, 14)}] {power_error/1000:+5.1f}MW {tgt_str}')
row += 1
sweep_ind = '~' if tc.sweeping else ' '
_safe_addstr(stdscr, row, 16,
f'Steam: {steam_out:5.1f} MSCV: {tc.mscv:4.1f} '
f'Prim: {tc.prim_pump:3.0f}%{sweep_ind} Sec: {tc.sec_pump:3.0f}% '
f'Lvl: {level:.0f}{level_error:+.0f})')
elif not is_active:
hint = ' (+/Up to add)' if is_sel else ''
_safe_addstr(stdscr, row, 16, f'not controlled{hint}',
YELLOW if is_sel else 0)
row += 1
_hline(stdscr, row); row += 1
gf = disp['grid_follow']
gdkw = disp['grid_demand_kw']
total_cap = sum(grid_caps.get(t, tc.target_kw) for t, tc in train_controllers.items())
grid_sel = (selected_train == 4)
grid_attr = CYAN | BOLD if grid_sel else BOLD
gf_color = GREEN | BOLD if gf else (CYAN | BOLD if grid_sel else 0)
_safe_addstr(stdscr, row, 2, '◆ GRID' + ('' if grid_sel else ''), grid_attr)
_safe_addstr(stdscr, row, 16, f'Demand: {gdkw/1000:5.1f} MW', BOLD)
if gf:
target_total = gdkw + args.grid_buffer * 1000.0
_safe_addstr(stdscr, row, 34,
f' AUTO buf={args.grid_buffer:.0f}MW '
f'{target_total/1000:.1f}/{total_cap/1000:.0f}MW total', gf_color)
else:
_safe_addstr(stdscr, row, 34,
f' off buf={args.grid_buffer:.0f}MW cap={total_cap/1000:.0f}MW', gf_color)
row += 1
_hline(stdscr, row); row += 1
ret_color = RED if ret_pct > 75 else YELLOW if ret_pct > 60 else GREEN
_safe_addstr(stdscr, row, 2, '◆ RETENTION TANK ', BOLD)
_safe_addstr(stdscr, row, 20, f'[{_bar(ret_pct, 20)}]', ret_color)
_safe_addstr(stdscr, row, 43, f' {ret_pct:4.0f}%')
_safe_addstr(stdscr, row, 49,
f' DRAINING valve={ret_valve:.0f}%' if ret_draining else ' OK',
YELLOW if ret_draining else GREEN)
row += 1
cond_color = RED if cond_pct < 25 else YELLOW if cond_pct < 40 else GREEN
_safe_addstr(stdscr, row, 2, '◆ CONDENSER FILL ', BOLD)
_safe_addstr(stdscr, row, 20, f'[{_bar(cond_pct, 20)}]', cond_color)
_safe_addstr(stdscr, row, 43, f' {cond_pct:4.0f}%')
_safe_addstr(stdscr, row, 49, ' PUMP ON' if cond_pump_on else ' OK',
YELLOW if cond_pump_on else GREEN)
row += 1
prsr_level_ = disp['prsr_level']
prsr_spray_ = disp['prsr_spraying']
feedwater_ = disp['feedwater_on']
prsr_color = RED if prsr_level_ < 40 or prsr_level_ > 80 else YELLOW if prsr_level_ < PRSR_LEVEL_LO or prsr_level_ > PRSR_LEVEL_HI else GREEN
_safe_addstr(stdscr, row, 2, '◆ PRESSURIZER ', BOLD)
_safe_addstr(stdscr, row, 20, f'[{_bar(prsr_level_, 20)}]', prsr_color)
_safe_addstr(stdscr, row, 43, f' {prsr_level_:4.1f}%')
_safe_addstr(stdscr, row, 49, ' SPRAY ON' if prsr_spray_ else ' OK',
YELLOW if prsr_spray_ else GREEN)
row += 1
prim_level_ = disp.get('prim_level', 100.0)
prim_color = RED if prim_level_ < 70 else YELLOW if prim_level_ < PRIM_FILL_LO else GREEN
_safe_addstr(stdscr, row, 2, '◆ PRIMARY VESSEL ', BOLD)
_safe_addstr(stdscr, row, 20, f'[{_bar(prim_level_, 20)}]', prim_color)
_safe_addstr(stdscr, row, 43, f' {prim_level_:4.1f}%')
_safe_addstr(stdscr, row, 49, ' FW PUMP ON' if feedwater_ else ' OK',
YELLOW if feedwater_ else GREEN)
row += 1
if selected_train == 0:
adj_hint = f'←/→ sp {disp["dynamic_setpoint"]:.0f}°C±5' if not disp['temp_auto'] else f'sp={disp["dynamic_setpoint"]:.0f}°C (auto)'
d_hint = f' [d] auto {"OFF" if disp["temp_auto"] else "ON"}'
elif selected_train == 4:
adj_hint = f'←/→ buf {args.grid_buffer:.0f}MW±1'
d_hint = ' [d] toggle auto'
elif disp['grid_follow']:
cap = grid_caps.get(selected_train, 0)
adj_hint = f'←/→ max {cap/1000:.0f}MW±5'
d_hint = ' [d] remove'
else:
adj_hint = '←/→ target ±5MW'
d_hint = ' [d] remove'
_safe_addstr(stdscr, H - 1, 0,
f' [↑↓] select [0-3/g] jump {adj_hint}{d_hint} [q] quit '.ljust(W - 1),
REV)
stdscr.refresh()
while True:
t0 = time.time()
s = read_state(all_params)
cycle += 1
# ---- Rod control ----
temp_error = s['CORE_TEMP'] - temp_setpoint
criticality = s.get('CORE_STATE_CRITICALITY', 0.0)
rod_cycle += 1
if s['CORE_TEMP'] > TEMP_MAX:
rod_pos = 100.0
for tc in train_controllers.values():
tc.prim_pump = 90.0
set_param(f'COOLANT_CORE_CIRCULATION_PUMP_{tc.i}_ORDERED_SPEED', 90.0)
else:
urgent = temp_error > 5.0 or criticality > 0.3
if urgent or rod_cycle >= ROD_INTERVAL:
if rod_cycle >= ROD_INTERVAL:
rod_cycle = 0
abs_err = abs(temp_error)
max_step = next(lim for thresh, lim in ROD_TIERS if abs_err <= thresh)
if urgent and rod_cycle != 0:
max_step = min(max_step, 0.25)
if not urgent:
rod_integral = float(np.clip(rod_integral + 0.002 * temp_error, -3.0, 3.0))
else:
rod_integral *= 0.5
raw_delta = 0.04 * temp_error + 1.0 * criticality + rod_integral
rod_delta = float(np.clip(raw_delta, -max_step, max_step))
rod_pos = float(np.clip(s['ROD_BANK_POS_0_ACTUAL'] + rod_delta, 0.0, 100.0))
set_param('ROD_BANK_POS_0_ORDERED', rod_pos)
# ---- Grid-demand following ----
grid_demand_kw = s.get('POWER_DEMAND_MW', 0.0) * 1000.0
if grid_follow and train_controllers:
total_target_kw = grid_demand_kw + args.grid_buffer * 1000.0
# Distribute proportionally to each train's manual cap; never exceed cap
total_cap = sum(grid_caps.get(t, tc.target_kw) for t, tc in train_controllers.items())
if total_cap > 0:
for t, tc in train_controllers.items():
cap = grid_caps.get(t, tc.target_kw)
share = total_target_kw * (cap / total_cap)
tc.target_kw = float(np.clip(share, 0.0, cap))
# ---- Per-train control ----
for t, tc in train_controllers.items():
res = tc.step(s, args.dt)
train_data[t] = res
# ---- Auto temp setpoint ----
if temp_auto and train_data:
total_error = sum(train_data[t][1] for t in train_data) # sum of power_errors
sp_delta = float(np.clip(total_error * 0.00002, -0.5, 0.5))
temp_setpoint = float(np.clip(temp_setpoint + sp_delta, 306.0, 360.0))
# ---- Aux: retention tank ----
ret_vol = s.get('VACUUM_RETENTION_TANK_VOLUME', 0.0)
ret_pct = ret_vol / RETENTION_MAX * 100.0
if ret_draining and ret_vol <= RETENTION_MID:
ret_draining = False
ret_valve = 0.0
set_param('STEAM_EJECTOR_CONDENSER_RETURN_VALVE', 0.0)
elif ret_vol > RETENTION_HI:
ret_draining = True
if ret_prev_vol is not None and ret_vol >= ret_prev_vol - 50.0:
ret_valve = min(ret_valve + 1.0, 50.0)
set_param('STEAM_EJECTOR_CONDENSER_RETURN_VALVE', ret_valve)
elif ret_draining:
set_param('STEAM_EJECTOR_CONDENSER_RETURN_VALVE', ret_valve)
ret_prev_vol = ret_vol
# ---- Aux: condenser fill ----
cond_vol = s.get('CONDENSER_VOLUME', 0.0)
cond_vap = s.get('CONDENSER_VAPOR_VOLUME', 0.0)
cond_tot = cond_vol + cond_vap
cond_pct = (cond_vol / cond_tot * 100.0) if cond_tot > 0 else 0.0
if not cond_pump_on and cond_pct < 45.0:
cond_pump_on = True
nucon.set(nucon._parameters['FREIGHT_PUMP_CONDENSER_SWITCH'], True)
elif cond_pump_on and cond_pct >= 50.0:
cond_pump_on = False
nucon.set(nucon._parameters['FREIGHT_PUMP_CONDENSER_SWITCH'], False)
# ---- Aux: pressurizer spray valve (level 50-70%) ----
prsr_level = s.get('CORE_PRIMARY_CIRCUIT_COOLING_TANK_VOLUME', PRSR_VOL_MAX * 0.6) / PRSR_VOL_MAX * 100.0
if not prsr_spraying and prsr_level < PRSR_LEVEL_LO:
prsr_spraying = True
nucon.open_valve(PRSR_VALVE)
elif prsr_spraying and prsr_level >= PRSR_LEVEL_CLOSE:
prsr_spraying = False
nucon.close_valve(PRSR_VALVE)
elif not prsr_spraying:
# Valve should be at rest — power off actuator if it's reached closed position
_vs = nucon.get_valve(PRSR_VALVE)
if _vs.get('IsClosed') and _vs.get('Actuator') != 'OFF':
nucon.off_valve(PRSR_VALVE)
# ---- Aux: primary circuit feedwater (overall loop fill > 80%) ----
prim_level = s.get('COOLANT_CORE_PRIMARY_LOOP_LEVEL', 100.0)
if not feedwater_on and prim_level < PRIM_FILL_LO:
feedwater_on = True
nucon.set(nucon._parameters['FREIGHT_PUMP_FEEDWATER_SWITCH'], True)
elif feedwater_on and prim_level >= PRIM_FILL_HI:
feedwater_on = False
nucon.set(nucon._parameters['FREIGHT_PUMP_FEEDWATER_SWITCH'], False)
# ---- Update display state and redraw ----
disp.update(s=s, dynamic_setpoint=temp_setpoint, temp_auto=temp_auto,
criticality=criticality,
ret_pct=ret_pct, ret_draining=ret_draining, ret_valve=ret_valve,
cond_pct=cond_pct, cond_pump_on=cond_pump_on,
prsr_level=prsr_level, prsr_spraying=prsr_spraying,
prim_level=prim_level, feedwater_on=feedwater_on,
grid_follow=grid_follow, grid_demand_kw=grid_demand_kw)
draw()
# ---- Poll input + redraw at 50 ms intervals for the rest of the cycle ----
sim_speed = nucon.GAME_SIM_SPEED.value or 1.0
deadline = t0 + args.dt / sim_speed
stdscr.timeout(50)
while time.time() < deadline:
key = stdscr.getch()
if key == -1:
continue
if handle_key(key):
return
disp['dynamic_setpoint'] = temp_setpoint
disp['temp_auto'] = temp_auto
disp['grid_follow'] = grid_follow
draw()
stdscr.timeout(-1)
curses.wrapper(run_controller)