Compare commits

...

8 Commits

Author SHA1 Message Date
Codex Agent
fb1276f39f Clarify generator control and turbine status 2025-11-22 22:57:31 +01:00
Codex Agent
c30c838fcc Show pumps as OFF once fully stopped 2025-11-22 22:51:05 +01:00
Codex Agent
faac1dc7b0 Keep pump units off until explicitly enabled 2025-11-22 22:48:04 +01:00
Codex Agent
bcd1eec84f Balance generator load sharing 2025-11-22 22:44:00 +01:00
Codex Agent
79f98faeeb Group dashboard controls and show turbine control mode 2025-11-22 20:53:03 +01:00
Codex Agent
5d8b617c9e Improve generator control and status displays 2025-11-22 20:35:13 +01:00
Codex Agent
2856d83600 Align dashboard health bars 2025-11-22 20:26:31 +01:00
Codex Agent
f0f2128ae6 Split dashboard data into two columns 2025-11-22 20:24:33 +01:00
6 changed files with 203 additions and 74 deletions

View File

@@ -24,6 +24,7 @@ class ReactorCommand:
primary_pumps: dict[int, bool] | None = None primary_pumps: dict[int, bool] | None = None
secondary_pumps: dict[int, bool] | None = None secondary_pumps: dict[int, bool] | None = None
generator_units: dict[int, bool] | None = None generator_units: dict[int, bool] | None = None
generator_auto: bool | None = None
maintenance_components: tuple[str, ...] = tuple() maintenance_components: tuple[str, ...] = tuple()
@classmethod @classmethod

View File

@@ -47,29 +47,45 @@ class ReactorDashboard:
self._log_handler: Optional[logging.Handler] = None self._log_handler: Optional[logging.Handler] = None
self._previous_handlers: list[logging.Handler] = [] self._previous_handlers: list[logging.Handler] = []
self._logger = logging.getLogger("reactor_sim") self._logger = logging.getLogger("reactor_sim")
self.keys = [ self.help_sections: list[tuple[str, list[DashboardKey]]] = [
(
"Reactor / Safety",
[
DashboardKey("q", "Quit & save"), DashboardKey("q", "Quit & save"),
DashboardKey("space", "SCRAM"), DashboardKey("space", "SCRAM"),
DashboardKey("g", "Toggle primary pump 1"), DashboardKey("r", "Reset & clear state"),
DashboardKey("h", "Toggle primary pump 2"), DashboardKey("a", "Toggle auto rod control"),
DashboardKey("j", "Toggle secondary pump 1"), DashboardKey("+/-", "Withdraw/insert rods"),
DashboardKey("k", "Toggle secondary pump 2"), DashboardKey("[/]", "Adjust consumer demand /+50 MW"),
DashboardKey("b", "Toggle generator 1"), DashboardKey("s/d", "Setpoint /+250 MW"),
DashboardKey("v", "Toggle generator 2"), ],
DashboardKey("t", "Toggle turbine"), ),
(
"Pumps",
[
DashboardKey("g/h", "Toggle primary pump 1/2"),
DashboardKey("j/k", "Toggle secondary pump 1/2"),
DashboardKey("m/n", "Maintain primary pumps 1/2"),
DashboardKey(",/.", "Maintain secondary pumps 1/2"),
],
),
(
"Generators",
[
DashboardKey("b/v", "Toggle generator 1/2"),
DashboardKey("x", "Toggle generator auto"),
DashboardKey("B/V", "Maintain generator 1/2"),
],
),
(
"Turbines / Grid",
[
DashboardKey("t", "Toggle turbine bank"),
DashboardKey("1/2/3", "Toggle turbine units 1-3"), DashboardKey("1/2/3", "Toggle turbine units 1-3"),
DashboardKey("y/u/i", "Maintain turbine 1/2/3"), DashboardKey("y/u/i", "Maintain turbine 1/2/3"),
DashboardKey("c", "Toggle consumer"), DashboardKey("c", "Toggle consumer"),
DashboardKey("r", "Reset & clear state"), ],
DashboardKey("m/n", "Maintain primary pumps 1/2"), ),
DashboardKey(",/.", "Maintain secondary pumps 1/2"),
DashboardKey("B/V", "Maintain generator 1/2"),
DashboardKey("k", "Maintain core (requires shutdown)"),
DashboardKey("+/-", "Withdraw/insert rods"),
DashboardKey("[/]", "Adjust consumer demand /+50 MW"),
DashboardKey("s", "Setpoint 250 MW"),
DashboardKey("d", "Setpoint +250 MW"),
DashboardKey("a", "Toggle auto rod control"),
] ]
def run(self) -> None: def run(self) -> None:
@@ -146,6 +162,8 @@ class ReactorDashboard:
self._toggle_generator_unit(0) self._toggle_generator_unit(0)
elif ch in (ord("v"), ord("V")): elif ch in (ord("v"), ord("V")):
self._toggle_generator_unit(1) self._toggle_generator_unit(1)
elif ch in (ord("x"), ord("X")):
self._queue_command(ReactorCommand(generator_auto=not self.reactor.generator_auto))
elif ch in (ord("t"), ord("T")): elif ch in (ord("t"), ord("T")):
self._queue_command(ReactorCommand(turbine_on=not self.reactor.turbine_active)) self._queue_command(ReactorCommand(turbine_on=not self.reactor.turbine_active))
elif ord("1") <= ch <= ord("9"): elif ord("1") <= ch <= ord("9"):
@@ -259,6 +277,7 @@ class ReactorDashboard:
self.reactor = Reactor.default() self.reactor = Reactor.default()
self.start_state = None self.start_state = None
self.pending_command = None self.pending_command = None
self._last_state = None
self.reset_requested = False self.reset_requested = False
self.log_buffer.clear() self.log_buffer.clear()
@@ -307,10 +326,23 @@ class ReactorDashboard:
win.erase() win.erase()
win.box() win.box()
win.addstr(0, 2, " Plant Overview ", curses.color_pair(1) | curses.A_BOLD) win.addstr(0, 2, " Plant Overview ", curses.color_pair(1) | curses.A_BOLD)
y = 2 height, width = win.getmaxyx()
y = self._draw_section( inner_height = height - 2
win, inner_width = width - 2
y, left_width = max(28, inner_width // 2)
right_width = inner_width - left_width
left_win = win.derwin(inner_height, left_width, 1, 1)
right_win = win.derwin(inner_height, right_width, 1, 1 + left_width)
for row in range(1, height - 1):
win.addch(row, 1 + left_width, curses.ACS_VLINE)
left_win.erase()
right_win.erase()
left_y = 0
left_y = self._draw_section(
left_win,
left_y,
"Core", "Core",
[ [
("Fuel Temp", f"{state.core.fuel_temperature:8.1f} K"), ("Fuel Temp", f"{state.core.fuel_temperature:8.1f} K"),
@@ -322,10 +354,10 @@ class ReactorDashboard:
("Reactivity", f"{state.core.reactivity_margin:+.4f}"), ("Reactivity", f"{state.core.reactivity_margin:+.4f}"),
], ],
) )
y = self._draw_section(win, y, "Key Poisons / Emitters", self._poison_lines(state)) left_y = self._draw_section(left_win, left_y, "Key Poisons / Emitters", self._poison_lines(state))
y = self._draw_section( left_y = self._draw_section(
win, left_win,
y, left_y,
"Primary Loop", "Primary Loop",
[ [
("Pump1", self._pump_status(state.primary_pumps, 0)), ("Pump1", self._pump_status(state.primary_pumps, 0)),
@@ -336,9 +368,9 @@ class ReactorDashboard:
("Pressure", f"{state.primary_loop.pressure:5.2f} MPa"), ("Pressure", f"{state.primary_loop.pressure:5.2f} MPa"),
], ],
) )
y = self._draw_section( self._draw_section(
win, left_win,
y, left_y,
"Secondary Loop", "Secondary Loop",
[ [
("Pump1", self._pump_status(state.secondary_pumps, 0)), ("Pump1", self._pump_status(state.secondary_pumps, 0)),
@@ -349,20 +381,16 @@ class ReactorDashboard:
("Steam Quality", f"{state.secondary_loop.steam_quality:5.2f}"), ("Steam Quality", f"{state.secondary_loop.steam_quality:5.2f}"),
], ],
) )
y = self._draw_section(
win, right_y = 0
y,
"Generators",
self._generator_lines(state),
)
consumer_status = "n/a" consumer_status = "n/a"
consumer_demand = 0.0 consumer_demand = 0.0
if self.reactor.consumer: if self.reactor.consumer:
consumer_status = "ONLINE" if self.reactor.consumer.online else "OFF" consumer_status = "ONLINE" if self.reactor.consumer.online else "OFF"
consumer_demand = self.reactor.consumer.demand_mw consumer_demand = self.reactor.consumer.demand_mw
y = self._draw_section( right_y = self._draw_section(
win, right_win,
y, right_y,
"Turbine / Grid", "Turbine / Grid",
[ [
("Turbines", " ".join(self._turbine_status_lines())), ("Turbines", " ".join(self._turbine_status_lines())),
@@ -381,18 +409,23 @@ class ReactorDashboard:
("Demand", f"{consumer_demand:7.1f} MW"), ("Demand", f"{consumer_demand:7.1f} MW"),
], ],
) )
y = self._draw_section(win, y, "Maintenance", self._maintenance_lines()) right_y = self._draw_section(right_win, right_y, "Generators", self._generator_lines(state))
y = self._draw_health_bars(win, y) right_y = self._draw_section(right_win, right_y, "Maintenance", self._maintenance_lines())
self._draw_health_bars(right_win, right_y)
def _draw_help_panel(self, win: "curses._CursesWindow") -> None: def _draw_help_panel(self, win: "curses._CursesWindow") -> None:
win.erase() win.erase()
win.box() win.box()
win.addstr(0, 2, " Controls ", curses.color_pair(1) | curses.A_BOLD) win.addstr(0, 2, " Controls ", curses.color_pair(1) | curses.A_BOLD)
y = 2 y = 2
for entry in self.keys: for title, entries in self.help_sections:
win.addstr(y, 2, f"{entry.key:<8} {entry.description}") win.addstr(y, 2, title, curses.color_pair(1) | curses.A_BOLD)
y += 1 y += 1
win.addstr(y + 1, 2, "Tips:", curses.color_pair(2) | curses.A_BOLD) for entry in entries:
win.addstr(y, 4, f"{entry.key:<8} {entry.description}")
y += 1
y += 1
win.addstr(y, 2, "Tips:", curses.color_pair(2) | curses.A_BOLD)
tips = [ tips = [
"Start pumps before withdrawing rods.", "Start pumps before withdrawing rods.",
"Bring turbine and consumer online after thermal stabilization.", "Bring turbine and consumer online after thermal stabilization.",
@@ -458,9 +491,15 @@ class ReactorDashboard:
def _turbine_status_lines(self) -> list[str]: def _turbine_status_lines(self) -> list[str]:
if not self.reactor.turbine_unit_active: if not self.reactor.turbine_unit_active:
return ["n/a"] return ["n/a"]
return [ lines: list[str] = []
f"{idx + 1}:{'ON' if active else 'OFF'}" for idx, active in enumerate(self.reactor.turbine_unit_active) for idx, active in enumerate(self.reactor.turbine_unit_active):
] label = f"{idx + 1}:"
status = "ON" if active else "OFF"
if idx < len(getattr(self._last_state, "turbines", [])):
t_state = self._last_state.turbines[idx]
status = getattr(t_state, "status", status)
lines.append(f"{label}{status}")
return lines
def _total_load_supplied(self, state: PlantState) -> float: def _total_load_supplied(self, state: PlantState) -> float:
return sum(t.load_supplied_mw for t in state.turbines) return sum(t.load_supplied_mw for t in state.turbines)
@@ -504,6 +543,8 @@ class ReactorDashboard:
if not state.generators: if not state.generators:
return [("Status", "n/a")] return [("Status", "n/a")]
lines: list[tuple[str, str]] = [] lines: list[tuple[str, str]] = []
control = "AUTO" if self.reactor.generator_auto else "MANUAL"
lines.append(("Control", control))
for idx, gen in enumerate(state.generators): for idx, gen in enumerate(state.generators):
status = "RUN" if gen.running else "START" if gen.starting else "OFF" status = "RUN" if gen.running else "START" if gen.starting else "OFF"
spool = f" spool {gen.spool_remaining:4.1f}s" if gen.starting else "" spool = f" spool {gen.spool_remaining:4.1f}s" if gen.starting else ""
@@ -518,17 +559,18 @@ class ReactorDashboard:
return height - 2 return height - 2
win.addstr(start_y, 2, "Component Health", curses.A_BOLD | curses.color_pair(1)) win.addstr(start_y, 2, "Component Health", curses.A_BOLD | curses.color_pair(1))
bar_width = max(8, min(inner_width - 18, 40)) bar_width = max(8, min(inner_width - 18, 40))
label_width = 16
row = start_y + 1 row = start_y + 1
for name, comp in self.reactor.health_monitor.components.items(): for name, comp in self.reactor.health_monitor.components.items():
if row >= height - 1: if row >= height - 1:
break break
label = f"{name:<12}" label = f"{name:<{label_width}}"
target = 0.0 if comp.failed else comp.integrity target = 0.0 if comp.failed else comp.integrity
filled = int(bar_width * max(0.0, min(1.0, target))) filled = int(bar_width * max(0.0, min(1.0, target)))
bar = "#" * filled + "-" * (bar_width - filled) bar = "#" * filled + "-" * (bar_width - filled)
color = 3 if comp.integrity > 0.5 else 2 if comp.integrity > 0.2 else 4 color = 3 if comp.integrity > 0.5 else 2 if comp.integrity > 0.2 else 4
win.addstr(row, 4, f"{label}:") win.addstr(row, 4, f"{label}:")
bar_start = 4 + len(label) + 1 bar_start = 4 + label_width + 1
win.addstr(row, bar_start, bar[:bar_width], curses.color_pair(color)) win.addstr(row, bar_start, bar[:bar_width], curses.color_pair(color))
percent_text = "FAILED" if comp.failed else f"{comp.integrity*100:5.1f}%" percent_text = "FAILED" if comp.failed else f"{comp.integrity*100:5.1f}%"
percent_x = min(width - len(percent_text) - 2, bar_start + bar_width + 2) percent_x = min(width - len(percent_text) - 2, bar_start + bar_width + 2)
@@ -540,7 +582,8 @@ class ReactorDashboard:
if index >= len(pumps): if index >= len(pumps):
return "n/a" return "n/a"
state = pumps[index] state = pumps[index]
return f"{'ON ' if state.active else 'OFF'} {state.flow_rate:6.0f} kg/s" status = getattr(state, "status", "ON" if state.active else "OFF")
return f"{status:<8} {state.flow_rate:6.0f} kg/s"
def _current_demand(self) -> float: def _current_demand(self) -> float:
if self.reactor.consumer: if self.reactor.consumer:

View File

@@ -17,6 +17,7 @@ class GeneratorState:
spool_remaining: float spool_remaining: float
power_output_mw: float power_output_mw: float
battery_charge: float battery_charge: float
status: str = "OFF"
@dataclass @dataclass
@@ -32,6 +33,7 @@ class DieselGenerator:
return return
state.starting = True state.starting = True
state.spool_remaining = self.spool_time state.spool_remaining = self.spool_time
state.status = "STARTING"
LOGGER.info("Generator starting (spool %.0fs)", self.spool_time) LOGGER.info("Generator starting (spool %.0fs)", self.spool_time)
def stop(self, state: GeneratorState) -> None: def stop(self, state: GeneratorState) -> None:
@@ -41,6 +43,7 @@ class DieselGenerator:
state.starting = False state.starting = False
state.spool_remaining = 0.0 state.spool_remaining = 0.0
state.power_output_mw = 0.0 state.power_output_mw = 0.0
state.status = "OFF"
LOGGER.info("Generator stopped") LOGGER.info("Generator stopped")
def step(self, state: GeneratorState, load_demand_mw: float, dt: float) -> float: def step(self, state: GeneratorState, load_demand_mw: float, dt: float) -> float:
@@ -51,12 +54,15 @@ class DieselGenerator:
if state.spool_remaining <= 0.0: if state.spool_remaining <= 0.0:
state.starting = False state.starting = False
state.running = True state.running = True
state.status = "RUN"
LOGGER.info("Generator online at %.1f MW", self.rated_output_mw) LOGGER.info("Generator online at %.1f MW", self.rated_output_mw)
elif state.running: elif state.running:
available = self.rated_output_mw available = self.rated_output_mw
state.power_output_mw = min(available, load_demand_mw) state.power_output_mw = min(available, load_demand_mw)
state.status = "RUN" if state.power_output_mw > 0 else "IDLE"
else: else:
state.power_output_mw = 0.0 state.power_output_mw = 0.0
state.status = "OFF"
if state.running: if state.running:
state.battery_charge = min(1.0, state.battery_charge + 0.02 * dt) state.battery_charge = min(1.0, state.battery_charge + 0.02 * dt)

View File

@@ -44,6 +44,7 @@ class Reactor:
turbine_unit_active: list[bool] = field(default_factory=lambda: [True, True, True]) turbine_unit_active: list[bool] = field(default_factory=lambda: [True, True, True])
shutdown: bool = False shutdown: bool = False
meltdown: bool = False meltdown: bool = False
generator_auto: bool = True
poison_alerts: set[str] = field(default_factory=set) poison_alerts: set[str] = field(default_factory=set)
maintenance_active: set[str] = field(default_factory=set) maintenance_active: set[str] = field(default_factory=set)
@@ -96,6 +97,8 @@ class Reactor:
self.meltdown = False self.meltdown = False
self.primary_pump_active = False self.primary_pump_active = False
self.secondary_pump_active = False self.secondary_pump_active = False
self.primary_pump_units = [False] * len(self.primary_pump_units)
self.secondary_pump_units = [False] * len(self.secondary_pump_units)
self.turbine_unit_active = [False] * len(self.turbines) self.turbine_unit_active = [False] * len(self.turbines)
self.turbine_active = any(self.turbine_unit_active) self.turbine_active = any(self.turbine_unit_active)
if self.consumer: if self.consumer:
@@ -115,10 +118,18 @@ class Reactor:
mass_flow_rate=0.0, mass_flow_rate=0.0,
steam_quality=0.0, steam_quality=0.0,
) )
primary_pumps = [PumpState(active=self.primary_pump_active, flow_rate=0.0, pressure=0.5) for _ in range(2)] primary_pumps = [
secondary_pumps = [PumpState(active=self.secondary_pump_active, flow_rate=0.0, pressure=0.5) for _ in range(2)] PumpState(active=self.primary_pump_active and self.primary_pump_units[idx], flow_rate=0.0, pressure=0.5)
for idx in range(2)
]
secondary_pumps = [
PumpState(active=self.secondary_pump_active and self.secondary_pump_units[idx], flow_rate=0.0, pressure=0.5)
for idx in range(2)
]
generator_states = [ generator_states = [
GeneratorState(running=False, starting=False, spool_remaining=0.0, power_output_mw=0.0, battery_charge=1.0) GeneratorState(
running=False, starting=False, spool_remaining=0.0, power_output_mw=0.0, battery_charge=1.0, status="OFF"
)
for _ in self.generators for _ in self.generators
] ]
turbine_states = [ turbine_states = [
@@ -218,6 +229,14 @@ class Reactor:
pump_state.pressure, desired_pressure, dt, self.primary_pump.spool_time pump_state.pressure, desired_pressure, dt, self.primary_pump.spool_time
) )
pump_state.active = (unit_enabled and power_ratio > 0.05) or pump_state.flow_rate > 1.0 pump_state.active = (unit_enabled and power_ratio > 0.05) or pump_state.flow_rate > 1.0
if unit_enabled and pump_state.flow_rate < max(1.0, desired_flow * 0.8):
pump_state.status = "STARTING"
elif not unit_enabled and pump_state.flow_rate > 1.0:
pump_state.status = "STOPPING"
elif pump_state.active:
pump_state.status = "RUN"
else:
pump_state.status = "OFF"
total_flow += pump_state.flow_rate total_flow += pump_state.flow_rate
loop_pressure = max(loop_pressure, pump_state.pressure) loop_pressure = max(loop_pressure, pump_state.pressure)
state.primary_loop.mass_flow_rate = total_flow state.primary_loop.mass_flow_rate = total_flow
@@ -239,6 +258,7 @@ class Reactor:
pump_state.pressure = self._ramp_value( pump_state.pressure = self._ramp_value(
pump_state.pressure, state.primary_loop.pressure, dt, self.primary_pump.spool_time pump_state.pressure, state.primary_loop.pressure, dt, self.primary_pump.spool_time
) )
pump_state.status = "STOPPING" if pump_state.flow_rate > 1.0 else "OFF"
if self.secondary_pump_active: if self.secondary_pump_active:
total_flow = 0.0 total_flow = 0.0
target_pressure = 12.0 * 0.75 + 2.0 target_pressure = 12.0 * 0.75 + 2.0
@@ -257,6 +277,14 @@ class Reactor:
pump_state.pressure, desired_pressure, dt, self.secondary_pump.spool_time pump_state.pressure, desired_pressure, dt, self.secondary_pump.spool_time
) )
pump_state.active = unit_enabled or pump_state.flow_rate > 1.0 pump_state.active = unit_enabled or pump_state.flow_rate > 1.0
if unit_enabled and pump_state.flow_rate < max(1.0, desired_flow * 0.8):
pump_state.status = "STARTING"
elif not unit_enabled and pump_state.flow_rate > 1.0:
pump_state.status = "STOPPING"
elif pump_state.active:
pump_state.status = "RUN"
else:
pump_state.status = "OFF"
total_flow += pump_state.flow_rate total_flow += pump_state.flow_rate
loop_pressure = max(loop_pressure, pump_state.pressure) loop_pressure = max(loop_pressure, pump_state.pressure)
state.secondary_loop.mass_flow_rate = total_flow state.secondary_loop.mass_flow_rate = total_flow
@@ -278,6 +306,7 @@ class Reactor:
pump_state.pressure = self._ramp_value( pump_state.pressure = self._ramp_value(
pump_state.pressure, state.secondary_loop.pressure, dt, self.secondary_pump.spool_time pump_state.pressure, state.secondary_loop.pressure, dt, self.secondary_pump.spool_time
) )
pump_state.status = "STOPPING" if pump_state.flow_rate > 1.0 else "OFF"
self.thermal.step_core(state.core, state.primary_loop, total_power, dt) 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: if not self.secondary_pump_active or state.secondary_loop.mass_flow_rate <= 1.0:
@@ -334,8 +363,15 @@ class Reactor:
turbine_state = state.turbines[idx] turbine_state = state.turbines[idx]
if idx in active_indices: if idx in active_indices:
turbine.step(state.secondary_loop, turbine_state, steam_power_mw=power_per_unit, dt=dt) turbine.step(state.secondary_loop, turbine_state, steam_power_mw=power_per_unit, dt=dt)
if power_per_unit <= 0.0 and turbine_state.electrical_output_mw < 0.1:
turbine_state.status = "OFF"
elif turbine_state.electrical_output_mw < max(0.5, power_per_unit * 0.5):
turbine_state.status = "STARTING"
else:
turbine_state.status = "RUN"
else: else:
self._spin_down_turbine(turbine_state, dt, turbine.spool_time) self._spin_down_turbine(turbine_state, dt, turbine.spool_time)
turbine_state.status = "STOPPING" if turbine_state.electrical_output_mw > 0 else "OFF"
self._dispatch_consumer_load(state, active_indices) self._dispatch_consumer_load(state, active_indices)
def _reset_turbine_state(self, turbine_state: TurbineState) -> None: def _reset_turbine_state(self, turbine_state: TurbineState) -> None:
@@ -343,6 +379,7 @@ class Reactor:
turbine_state.electrical_output_mw = 0.0 turbine_state.electrical_output_mw = 0.0
turbine_state.load_demand_mw = 0.0 turbine_state.load_demand_mw = 0.0
turbine_state.load_supplied_mw = 0.0 turbine_state.load_supplied_mw = 0.0
turbine_state.status = "OFF"
@staticmethod @staticmethod
def _ramp_value(current: float, target: float, dt: float, time_constant: float) -> float: def _ramp_value(current: float, target: float, dt: float, time_constant: float) -> float:
@@ -405,6 +442,8 @@ class Reactor:
self.shutdown = True self.shutdown = True
overrides["rod_fraction"] = self.control.scram() overrides["rod_fraction"] = self.control.scram()
self._set_turbine_state(False) self._set_turbine_state(False)
if command.generator_auto is not None:
self.generator_auto = command.generator_auto
if command.power_setpoint is not None: if command.power_setpoint is not None:
self.control.set_power_setpoint(command.power_setpoint) self.control.set_power_setpoint(command.power_setpoint)
if command.rod_manual is not None: if command.rod_manual is not None:
@@ -446,8 +485,6 @@ class Reactor:
LOGGER.info("Primary pump %s", "enabled" if active else "stopped") LOGGER.info("Primary pump %s", "enabled" if active else "stopped")
if not active: if not active:
self.primary_pump_units = [False] * len(self.primary_pump_units) self.primary_pump_units = [False] * len(self.primary_pump_units)
elif active and not any(self.primary_pump_units):
self.primary_pump_units = [True] * len(self.primary_pump_units)
def _set_secondary_pump(self, active: bool) -> None: def _set_secondary_pump(self, active: bool) -> None:
if self.secondary_pump_active != active: if self.secondary_pump_active != active:
@@ -455,8 +492,6 @@ class Reactor:
LOGGER.info("Secondary pump %s", "enabled" if active else "stopped") LOGGER.info("Secondary pump %s", "enabled" if active else "stopped")
if not active: if not active:
self.secondary_pump_units = [False] * len(self.secondary_pump_units) self.secondary_pump_units = [False] * len(self.secondary_pump_units)
elif active and not any(self.secondary_pump_units):
self.secondary_pump_units = [True] * len(self.secondary_pump_units)
def _toggle_primary_pump_unit(self, index: int, active: bool) -> None: def _toggle_primary_pump_unit(self, index: int, active: bool) -> None:
if index < 0 or index >= len(self.primary_pump_units): if index < 0 or index >= len(self.primary_pump_units):
@@ -512,6 +547,7 @@ class Reactor:
GeneratorState(running=False, starting=False, spool_remaining=0.0, power_output_mw=0.0, battery_charge=1.0) GeneratorState(running=False, starting=False, spool_remaining=0.0, power_output_mw=0.0, battery_charge=1.0)
) )
deficit = max(0.0, aux_demand - turbine_electric) deficit = max(0.0, aux_demand - turbine_electric)
if self.generator_auto:
if deficit > 0.0: if deficit > 0.0:
for idx, gen_state in enumerate(state.generators): for idx, gen_state in enumerate(state.generators):
if not (gen_state.running or gen_state.starting): if not (gen_state.running or gen_state.starting):
@@ -526,8 +562,10 @@ class Reactor:
total_power = 0.0 total_power = 0.0
remaining = max(0.0, aux_demand - turbine_electric) remaining = max(0.0, aux_demand - turbine_electric)
active_indices = [idx for idx, g in enumerate(state.generators) if g.running or g.starting]
share = remaining / len(active_indices) if active_indices and remaining > 0 else 0.0
for idx, gen_state in enumerate(state.generators): for idx, gen_state in enumerate(state.generators):
load = remaining if remaining > 0 else 0.0 load = share if idx in active_indices else 0.0
delivered = self.generators[idx].step(gen_state, load, dt) delivered = self.generators[idx].step(gen_state, load, dt)
total_power += delivered total_power += delivered
remaining = max(0.0, remaining - delivered) remaining = max(0.0, remaining - delivered)
@@ -640,6 +678,7 @@ class Reactor:
"turbine_units": self.turbine_unit_active, "turbine_units": self.turbine_unit_active,
"shutdown": self.shutdown, "shutdown": self.shutdown,
"meltdown": self.meltdown, "meltdown": self.meltdown,
"generator_auto": self.generator_auto,
"maintenance_active": list(self.maintenance_active), "maintenance_active": list(self.maintenance_active),
"generators": [ "generators": [
{ {
@@ -648,6 +687,7 @@ class Reactor:
"spool_remaining": g.spool_remaining, "spool_remaining": g.spool_remaining,
"power_output_mw": g.power_output_mw, "power_output_mw": g.power_output_mw,
"battery_charge": g.battery_charge, "battery_charge": g.battery_charge,
"status": g.status,
} }
for g in state.generators for g in state.generators
], ],
@@ -671,6 +711,7 @@ class Reactor:
self.turbine_active = metadata.get("turbine_active", any(self.turbine_unit_active)) self.turbine_active = metadata.get("turbine_active", any(self.turbine_unit_active))
self.shutdown = metadata.get("shutdown", self.shutdown) self.shutdown = metadata.get("shutdown", self.shutdown)
self.meltdown = metadata.get("meltdown", self.meltdown) self.meltdown = metadata.get("meltdown", self.meltdown)
self.generator_auto = metadata.get("generator_auto", self.generator_auto)
maint = metadata.get("maintenance_active") maint = metadata.get("maintenance_active")
if maint is not None: if maint is not None:
self.maintenance_active = set(maint) self.maintenance_active = set(maint)
@@ -691,7 +732,12 @@ class Reactor:
# Back-fill pump state lists for compatibility. # Back-fill pump state lists for compatibility.
if not plant.primary_pumps or len(plant.primary_pumps) < 2: if not plant.primary_pumps or len(plant.primary_pumps) < 2:
plant.primary_pumps = [ plant.primary_pumps = [
PumpState(active=self.primary_pump_active, flow_rate=plant.primary_loop.mass_flow_rate / 2, pressure=plant.primary_loop.pressure) PumpState(
active=self.primary_pump_active,
flow_rate=plant.primary_loop.mass_flow_rate / 2,
pressure=plant.primary_loop.pressure,
status="OFF",
)
for _ in range(2) for _ in range(2)
] ]
if not plant.secondary_pumps or len(plant.secondary_pumps) < 2: if not plant.secondary_pumps or len(plant.secondary_pumps) < 2:
@@ -700,6 +746,7 @@ class Reactor:
active=self.secondary_pump_active, active=self.secondary_pump_active,
flow_rate=plant.secondary_loop.mass_flow_rate / 2, flow_rate=plant.secondary_loop.mass_flow_rate / 2,
pressure=plant.secondary_loop.pressure, pressure=plant.secondary_loop.pressure,
status="OFF",
) )
for _ in range(2) for _ in range(2)
] ]
@@ -726,6 +773,7 @@ class Reactor:
spool_remaining=0.0, spool_remaining=0.0,
power_output_mw=0.0, power_output_mw=0.0,
battery_charge=1.0, battery_charge=1.0,
status="OFF",
) )
) )
for idx, gen_state in enumerate(plant.generators): for idx, gen_state in enumerate(plant.generators):
@@ -736,6 +784,7 @@ class Reactor:
gen_state.spool_remaining = cfg.get("spool_remaining", gen_state.spool_remaining) gen_state.spool_remaining = cfg.get("spool_remaining", gen_state.spool_remaining)
gen_state.power_output_mw = cfg.get("power_output_mw", gen_state.power_output_mw) gen_state.power_output_mw = cfg.get("power_output_mw", gen_state.power_output_mw)
gen_state.battery_charge = cfg.get("battery_charge", gen_state.battery_charge) gen_state.battery_charge = cfg.get("battery_charge", gen_state.battery_charge)
gen_state.status = cfg.get("status", gen_state.status)
return plant return plant
def _handle_heat_sink_loss(self, state: PlantState) -> None: def _handle_heat_sink_loss(self, state: PlantState) -> None:

View File

@@ -54,6 +54,7 @@ class TurbineState:
condenser_temperature: float condenser_temperature: float
load_demand_mw: float = 0.0 load_demand_mw: float = 0.0
load_supplied_mw: float = 0.0 load_supplied_mw: float = 0.0
status: str = "OFF"
@dataclass @dataclass
@@ -61,6 +62,7 @@ class PumpState:
active: bool active: bool
flow_rate: float flow_rate: float
pressure: float pressure: float
status: str = "OFF"
@dataclass @dataclass

View File

@@ -162,6 +162,8 @@ def test_full_rod_withdrawal_reaches_gigawatt_power():
reactor.control.rod_fraction = 0.0 reactor.control.rod_fraction = 0.0
reactor.primary_pump_active = True reactor.primary_pump_active = True
reactor.secondary_pump_active = True reactor.secondary_pump_active = True
reactor.primary_pump_units = [True, True]
reactor.secondary_pump_units = [True, True]
early_power = 0.0 early_power = 0.0
for step in range(60): for step in range(60):
@@ -180,6 +182,8 @@ def test_partially_inserted_rods_hold_near_three_gw():
reactor.control.rod_fraction = 0.4 reactor.control.rod_fraction = 0.4
reactor.primary_pump_active = True reactor.primary_pump_active = True
reactor.secondary_pump_active = True reactor.secondary_pump_active = True
reactor.primary_pump_units = [True, True]
reactor.secondary_pump_units = [True, True]
for _ in range(120): for _ in range(120):
reactor.step(state, dt=1.0) reactor.step(state, dt=1.0)
@@ -206,6 +210,30 @@ def test_generator_spools_and_powers_pumps():
assert state.primary_loop.mass_flow_rate > 0.0 assert state.primary_loop.mass_flow_rate > 0.0
def test_generator_manual_mode_allows_single_unit_and_stop():
reactor = Reactor.default()
state = reactor.initial_state()
reactor.shutdown = False
reactor.control.manual_control = True
reactor.control.rod_fraction = 0.95
reactor.generator_auto = False
reactor.primary_pump_units = [True, False]
reactor.secondary_pump_units = [False, False]
reactor.step(state, dt=1.0, command=ReactorCommand(generator_units={1: True}, primary_pumps={1: True}))
assert state.generators[0].starting or state.generators[0].running
for _ in range(15):
reactor.step(state, dt=1.0)
assert state.generators[0].running is True
reactor.step(state, dt=1.0, command=ReactorCommand(generator_units={1: False}))
for _ in range(5):
reactor.step(state, dt=1.0)
assert state.generators[0].running is False
assert state.generators[1].running is False
def test_meltdown_triggers_shutdown(): def test_meltdown_triggers_shutdown():
reactor = Reactor.default() reactor = Reactor.default()
state = reactor.initial_state() state = reactor.initial_state()