Compare commits
10 Commits
15f2930b0c
...
7c8321e3c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c8321e3c4 | ||
|
|
5c0ad3fb72 | ||
|
|
4873777ba8 | ||
|
|
303258a6bb | ||
|
|
2400ce669e | ||
|
|
6e8520c925 | ||
|
|
2bc45d1594 | ||
|
|
37fded30e6 | ||
|
|
8dec574ac6 | ||
|
|
146e8fd26b |
@@ -9,7 +9,7 @@ All source code lives under `src/reactor_sim`. Submodules map to plant systems:
|
|||||||
- `python -m pip install -e .[dev]` — install editable package with optional tooling when dev dependencies are defined.
|
- `python -m pip install -e .[dev]` — install editable package with optional tooling when dev dependencies are defined.
|
||||||
|
|
||||||
## Operations & Control Hooks
|
## Operations & Control Hooks
|
||||||
Manual commands live in `reactor_sim.commands.ReactorCommand`. Pass a `command_provider` callable to `ReactorSimulation` to adjust rods, pumps, turbine, coolant demand, or the attached `ElectricalConsumer`. Use `ReactorCommand.scram_all()` for full shutdown, `ReactorCommand(consumer_online=True, consumer_demand=600)` to hook the grid, or toggle pumps (`primary_pump_on=False`) to simulate faults. Control helpers in `control.py` expose `set_rods`, `increment_rods`, and `scram`.
|
Manual commands live in `reactor_sim.commands.ReactorCommand`. Pass a `command_provider` callable to `ReactorSimulation` to adjust rods, pumps, turbine, coolant demand, or the attached `ElectricalConsumer`. Use `ReactorCommand.scram_all()` for full shutdown, `ReactorCommand(consumer_online=True, consumer_demand=600)` to hook the grid, or toggle pumps (`primary_pump_on=False`) to simulate faults. Control helpers in `control.py` expose `set_rods`, `increment_rods`, and `scram`, and you can switch `set_manual_mode(True)` to pause the automatic rod controller. For hands-on runs, launch the curses dashboard (`FISSION_DASHBOARD=1 FISSION_REALTIME=1 python run_simulation.py`) and use the on-screen shortcuts (q quit/save, space SCRAM, p/o pumps, t turbine, +/- rods in 0.05 steps, [/ ] consumer demand, s/d setpoint, `a` toggles auto/manual rods). Recommended startup: enable manual rods (`a`), withdraw to ~0.3 before ramping the turbine/consumer, then re-enable auto control when you want closed-loop operation.
|
||||||
The plant now boots cold (ambient core temperature, idle pumps); scripts must sequence startup: enable pumps, gradually withdraw rods, connect the consumer after turbine spin-up, and use `ControlSystem.set_power_setpoint` to chase desired output. Set `FISSION_REALTIME=1` to run continuously with real-time pacing; optionally set `FISSION_SIM_DURATION=infinite` for indefinite runs and send SIGINT/Ctrl+C to stop. Use `FISSION_SIM_DURATION=600` (default) for bounded offline batches.
|
The plant now boots cold (ambient core temperature, idle pumps); scripts must sequence startup: enable pumps, gradually withdraw rods, connect the consumer after turbine spin-up, and use `ControlSystem.set_power_setpoint` to chase desired output. Set `FISSION_REALTIME=1` to run continuously with real-time pacing; optionally set `FISSION_SIM_DURATION=infinite` for indefinite runs and send SIGINT/Ctrl+C to stop. Use `FISSION_SIM_DURATION=600` (default) for bounded offline batches.
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
from .atomic import Atom, AtomicPhysics, FissionEvent
|
from .atomic import Atom, AtomicPhysics, FissionEvent
|
||||||
from .commands import ReactorCommand
|
from .commands import ReactorCommand
|
||||||
from .consumer import ElectricalConsumer
|
from .consumer import ElectricalConsumer
|
||||||
|
from .dashboard import ReactorDashboard
|
||||||
from .failures import HealthMonitor
|
from .failures import HealthMonitor
|
||||||
from .logging_utils import configure_logging
|
from .logging_utils import configure_logging
|
||||||
from .state import CoreState, CoolantLoopState, TurbineState, PlantState
|
from .state import CoreState, CoolantLoopState, TurbineState, PlantState
|
||||||
@@ -24,5 +25,6 @@ __all__ = [
|
|||||||
"ReactorCommand",
|
"ReactorCommand",
|
||||||
"ElectricalConsumer",
|
"ElectricalConsumer",
|
||||||
"HealthMonitor",
|
"HealthMonitor",
|
||||||
|
"ReactorDashboard",
|
||||||
"configure_logging",
|
"configure_logging",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class ReactorCommand:
|
|||||||
power_setpoint: float | None = None
|
power_setpoint: float | None = None
|
||||||
consumer_online: bool | None = None
|
consumer_online: bool | None = None
|
||||||
consumer_demand: float | None = None
|
consumer_demand: float | None = None
|
||||||
|
rod_manual: bool | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def scram_all(cls) -> "ReactorCommand":
|
def scram_all(cls) -> "ReactorCommand":
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ GENERATOR_EFFICIENCY = 0.96
|
|||||||
ENVIRONMENT_TEMPERATURE = 295.0 # K
|
ENVIRONMENT_TEMPERATURE = 295.0 # K
|
||||||
AMU_TO_KG = 1.660_539_066_60e-27
|
AMU_TO_KG = 1.660_539_066_60e-27
|
||||||
MEV_TO_J = 1.602_176_634e-13
|
MEV_TO_J = 1.602_176_634e-13
|
||||||
ELECTRON_FISSION_CROSS_SECTION = 5e-23 # cm^2, tuned for simulation scale
|
ELECTRON_FISSION_CROSS_SECTION = 5e-16 # cm^2, tuned for simulation scale
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class ElectricalConsumer:
|
|||||||
demand_mw: float
|
demand_mw: float
|
||||||
online: bool = False
|
online: bool = False
|
||||||
power_received_mw: float = 0.0
|
power_received_mw: float = 0.0
|
||||||
|
_under_supply_logged: bool = False
|
||||||
|
|
||||||
def request_power(self) -> float:
|
def request_power(self) -> float:
|
||||||
return self.demand_mw if self.online else 0.0
|
return self.demand_mw if self.online else 0.0
|
||||||
@@ -30,11 +31,16 @@ class ElectricalConsumer:
|
|||||||
|
|
||||||
def update_power_received(self, supplied_mw: float) -> None:
|
def update_power_received(self, supplied_mw: float) -> None:
|
||||||
self.power_received_mw = supplied_mw
|
self.power_received_mw = supplied_mw
|
||||||
if supplied_mw < self.request_power():
|
if supplied_mw + 1e-6 < self.request_power():
|
||||||
LOGGER.warning(
|
if not self._under_supply_logged:
|
||||||
"%s under-supplied: %.1f/%.1f MW",
|
LOGGER.warning(
|
||||||
self.name,
|
"%s under-supplied: %.1f/%.1f MW",
|
||||||
supplied_mw,
|
self.name,
|
||||||
self.request_power(),
|
supplied_mw,
|
||||||
)
|
self.request_power(),
|
||||||
|
)
|
||||||
|
self._under_supply_logged = True
|
||||||
|
else:
|
||||||
|
if self._under_supply_logged:
|
||||||
|
LOGGER.info("%s demand satisfied", self.name)
|
||||||
|
self._under_supply_logged = False
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ def clamp(value: float, lo: float, hi: float) -> float:
|
|||||||
class ControlSystem:
|
class ControlSystem:
|
||||||
setpoint_mw: float = 3_000.0
|
setpoint_mw: float = 3_000.0
|
||||||
rod_fraction: float = 0.5
|
rod_fraction: float = 0.5
|
||||||
|
manual_control: bool = False
|
||||||
|
|
||||||
def update_rods(self, state: CoreState, dt: float) -> float:
|
def update_rods(self, state: CoreState, dt: float) -> float:
|
||||||
|
if self.manual_control:
|
||||||
|
return self.rod_fraction
|
||||||
error = (state.power_output_mw - self.setpoint_mw) / self.setpoint_mw
|
error = (state.power_output_mw - self.setpoint_mw) / self.setpoint_mw
|
||||||
adjustment = -error * 0.3
|
adjustment = -error * 0.3
|
||||||
adjustment = clamp(adjustment, -constants.CONTROL_ROD_SPEED * dt, constants.CONTROL_ROD_SPEED * dt)
|
adjustment = clamp(adjustment, -constants.CONTROL_ROD_SPEED * dt, constants.CONTROL_ROD_SPEED * dt)
|
||||||
@@ -50,6 +53,11 @@ class ControlSystem:
|
|||||||
self.setpoint_mw = clamp(megawatts, 100.0, 4_000.0)
|
self.setpoint_mw = clamp(megawatts, 100.0, 4_000.0)
|
||||||
LOGGER.info("Power setpoint %.0f -> %.0f MW", previous, self.setpoint_mw)
|
LOGGER.info("Power setpoint %.0f -> %.0f MW", previous, self.setpoint_mw)
|
||||||
|
|
||||||
|
def set_manual_mode(self, manual: bool) -> None:
|
||||||
|
if self.manual_control != manual:
|
||||||
|
self.manual_control = manual
|
||||||
|
LOGGER.info("Rod control %s", "manual" if manual else "automatic")
|
||||||
|
|
||||||
def coolant_demand(self, primary: CoolantLoopState) -> float:
|
def coolant_demand(self, primary: CoolantLoopState) -> float:
|
||||||
desired_temp = 580.0
|
desired_temp = 580.0
|
||||||
error = (primary.temperature_out - desired_temp) / 100.0
|
error = (primary.temperature_out - desired_temp) / 100.0
|
||||||
|
|||||||
367
src/reactor_sim/dashboard.py
Normal file
367
src/reactor_sim/dashboard.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
"""Interactive curses dashboard for real-time reactor operation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import logging
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .commands import ReactorCommand
|
||||||
|
from .reactor import Reactor
|
||||||
|
from .simulation import ReactorSimulation
|
||||||
|
from .state import PlantState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DashboardKey:
|
||||||
|
key: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReactorDashboard:
|
||||||
|
"""Minimal TUI dashboard allowing user control in real-time."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
reactor: Reactor,
|
||||||
|
start_state: Optional[PlantState],
|
||||||
|
timestep: float = 1.0,
|
||||||
|
save_path: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
self.reactor = reactor
|
||||||
|
self.start_state = start_state
|
||||||
|
self.timestep = timestep
|
||||||
|
self.save_path = save_path
|
||||||
|
self.pending_command: Optional[ReactorCommand] = None
|
||||||
|
self.sim: Optional[ReactorSimulation] = None
|
||||||
|
self.quit_requested = False
|
||||||
|
self.log_buffer: deque[str] = deque(maxlen=4)
|
||||||
|
self._log_handler: Optional[logging.Handler] = None
|
||||||
|
self._previous_handlers: list[logging.Handler] = []
|
||||||
|
self._logger = logging.getLogger("reactor_sim")
|
||||||
|
self.keys = [
|
||||||
|
DashboardKey("q", "Quit & save"),
|
||||||
|
DashboardKey("space", "SCRAM"),
|
||||||
|
DashboardKey("p", "Toggle primary pump"),
|
||||||
|
DashboardKey("o", "Toggle secondary pump"),
|
||||||
|
DashboardKey("t", "Toggle turbine"),
|
||||||
|
DashboardKey("c", "Toggle consumer"),
|
||||||
|
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:
|
||||||
|
curses.wrapper(self._main)
|
||||||
|
|
||||||
|
def _main(self, stdscr: "curses._CursesWindow") -> None: # type: ignore[name-defined]
|
||||||
|
curses.curs_set(0)
|
||||||
|
curses.start_color()
|
||||||
|
curses.use_default_colors()
|
||||||
|
curses.init_pair(1, curses.COLOR_CYAN, -1)
|
||||||
|
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||||
|
curses.init_pair(3, curses.COLOR_GREEN, -1)
|
||||||
|
curses.init_pair(4, curses.COLOR_RED, -1)
|
||||||
|
stdscr.nodelay(True)
|
||||||
|
self._install_log_capture()
|
||||||
|
self.sim = ReactorSimulation(
|
||||||
|
self.reactor,
|
||||||
|
timestep=self.timestep,
|
||||||
|
duration=None,
|
||||||
|
realtime=True,
|
||||||
|
command_provider=self._next_command,
|
||||||
|
)
|
||||||
|
self.sim.start_state = self.start_state
|
||||||
|
try:
|
||||||
|
for state in self.sim.run():
|
||||||
|
self._draw(stdscr, state)
|
||||||
|
self._handle_input(stdscr)
|
||||||
|
if self.quit_requested:
|
||||||
|
self.sim.stop()
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
if self.save_path and self.sim and self.sim.last_state:
|
||||||
|
self.reactor.save_state(self.save_path, self.sim.last_state)
|
||||||
|
self._restore_logging()
|
||||||
|
|
||||||
|
def _handle_input(self, stdscr: "curses._CursesWindow") -> None:
|
||||||
|
while True:
|
||||||
|
ch = stdscr.getch()
|
||||||
|
if ch == -1:
|
||||||
|
break
|
||||||
|
if ch in (ord("q"), ord("Q")):
|
||||||
|
self.quit_requested = True
|
||||||
|
return
|
||||||
|
if ch == ord(" "):
|
||||||
|
self._queue_command(ReactorCommand.scram_all())
|
||||||
|
elif ch in (ord("p"), ord("P")):
|
||||||
|
self._queue_command(ReactorCommand(primary_pump_on=not self.reactor.primary_pump_active))
|
||||||
|
elif ch in (ord("o"), ord("O")):
|
||||||
|
self._queue_command(ReactorCommand(secondary_pump_on=not self.reactor.secondary_pump_active))
|
||||||
|
elif ch in (ord("t"), ord("T")):
|
||||||
|
self._queue_command(ReactorCommand(turbine_on=not self.reactor.turbine_active))
|
||||||
|
elif ch in (ord("+"), ord("=")):
|
||||||
|
self._queue_command(ReactorCommand(rod_position=self._clamped_rod(-0.05)))
|
||||||
|
elif ch == ord("-"):
|
||||||
|
self._queue_command(ReactorCommand(rod_position=self._clamped_rod(0.05)))
|
||||||
|
elif ch == ord("["):
|
||||||
|
demand = self._current_demand() - 50.0
|
||||||
|
self._queue_command(ReactorCommand(consumer_demand=max(0.0, demand)))
|
||||||
|
elif ch == ord("]"):
|
||||||
|
demand = self._current_demand() + 50.0
|
||||||
|
self._queue_command(ReactorCommand(consumer_demand=demand))
|
||||||
|
elif ch in (ord("c"), ord("C")):
|
||||||
|
online = not (self.reactor.consumer.online if self.reactor.consumer else False)
|
||||||
|
self._queue_command(ReactorCommand(consumer_online=online))
|
||||||
|
elif ch in (ord("s"), ord("S")):
|
||||||
|
self._queue_command(ReactorCommand(power_setpoint=self.reactor.control.setpoint_mw - 250.0))
|
||||||
|
elif ch in (ord("d"), ord("D")):
|
||||||
|
self._queue_command(ReactorCommand(power_setpoint=self.reactor.control.setpoint_mw + 250.0))
|
||||||
|
elif ch in (ord("a"), ord("A")):
|
||||||
|
self._queue_command(ReactorCommand(rod_manual=not self.reactor.control.manual_control))
|
||||||
|
|
||||||
|
def _queue_command(self, command: ReactorCommand) -> None:
|
||||||
|
if self.pending_command is None:
|
||||||
|
self.pending_command = command
|
||||||
|
else:
|
||||||
|
for field in command.__dataclass_fields__: # type: ignore[attr-defined]
|
||||||
|
value = getattr(command, field)
|
||||||
|
if value is None or value is False:
|
||||||
|
continue
|
||||||
|
if field == "rod_step" and getattr(self.pending_command, field) is not None:
|
||||||
|
setattr(
|
||||||
|
self.pending_command,
|
||||||
|
field,
|
||||||
|
getattr(self.pending_command, field) + value,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
setattr(self.pending_command, field, value)
|
||||||
|
if self.pending_command.rod_position is not None and self.pending_command.rod_manual is None:
|
||||||
|
self.pending_command.rod_manual = True
|
||||||
|
|
||||||
|
|
||||||
|
def _next_command(self, _: float, __: PlantState) -> Optional[ReactorCommand]:
|
||||||
|
cmd = self.pending_command
|
||||||
|
self.pending_command = None
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def _draw(self, stdscr: "curses._CursesWindow", state: PlantState) -> None:
|
||||||
|
stdscr.erase()
|
||||||
|
height, width = stdscr.getmaxyx()
|
||||||
|
if height < 24 or width < 90:
|
||||||
|
stdscr.addstr(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
"Terminal window too small. Resize to at least 90x24.".ljust(width),
|
||||||
|
curses.color_pair(4),
|
||||||
|
)
|
||||||
|
stdscr.refresh()
|
||||||
|
return
|
||||||
|
|
||||||
|
data_height = height - 6
|
||||||
|
right_width = max(32, width // 3)
|
||||||
|
left_width = width - right_width
|
||||||
|
if left_width < 60:
|
||||||
|
left_width = min(60, width - 20)
|
||||||
|
right_width = width - left_width
|
||||||
|
|
||||||
|
data_win = stdscr.derwin(data_height, left_width, 0, 0)
|
||||||
|
help_win = stdscr.derwin(data_height, right_width, 0, left_width)
|
||||||
|
status_win = stdscr.derwin(6, width, data_height, 0)
|
||||||
|
|
||||||
|
self._draw_data_panel(data_win, state)
|
||||||
|
self._draw_help_panel(help_win)
|
||||||
|
self._draw_status_panel(status_win, state)
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
def _draw_data_panel(self, win: "curses._CursesWindow", state: PlantState) -> None:
|
||||||
|
win.erase()
|
||||||
|
win.box()
|
||||||
|
win.addstr(0, 2, " Plant Overview ", curses.color_pair(1) | curses.A_BOLD)
|
||||||
|
y = 2
|
||||||
|
y = self._draw_section(
|
||||||
|
win,
|
||||||
|
y,
|
||||||
|
"Core",
|
||||||
|
[
|
||||||
|
("Fuel Temp", f"{state.core.fuel_temperature:8.1f} K"),
|
||||||
|
("Core Power", f"{state.core.power_output_mw:8.1f} MW"),
|
||||||
|
("Neutron Flux", f"{state.core.neutron_flux:10.2e}"),
|
||||||
|
("Rods", f"{self.reactor.control.rod_fraction:.3f}"),
|
||||||
|
("Setpoint", f"{self.reactor.control.setpoint_mw:7.0f} MW"),
|
||||||
|
("Reactivity", f"{state.core.reactivity_margin:+.4f}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
y = self._draw_section(
|
||||||
|
win,
|
||||||
|
y,
|
||||||
|
"Primary Loop",
|
||||||
|
[
|
||||||
|
("Pump", "ON" if self.reactor.primary_pump_active else "OFF"),
|
||||||
|
("Flow", f"{state.primary_loop.mass_flow_rate:7.0f} kg/s"),
|
||||||
|
("Inlet Temp", f"{state.primary_loop.temperature_in:7.1f} K"),
|
||||||
|
("Outlet Temp", f"{state.primary_loop.temperature_out:7.1f} K"),
|
||||||
|
("Pressure", f"{state.primary_loop.pressure:5.2f} MPa"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
y = self._draw_section(
|
||||||
|
win,
|
||||||
|
y,
|
||||||
|
"Secondary Loop",
|
||||||
|
[
|
||||||
|
("Pump", "ON" if self.reactor.secondary_pump_active else "OFF"),
|
||||||
|
("Flow", f"{state.secondary_loop.mass_flow_rate:7.0f} kg/s"),
|
||||||
|
("Inlet Temp", f"{state.secondary_loop.temperature_in:7.1f} K"),
|
||||||
|
("Pressure", f"{state.secondary_loop.pressure:5.2f} MPa"),
|
||||||
|
("Steam Quality", f"{state.secondary_loop.steam_quality:5.2f}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
consumer_status = "n/a"
|
||||||
|
consumer_demand = 0.0
|
||||||
|
if self.reactor.consumer:
|
||||||
|
consumer_status = "ONLINE" if self.reactor.consumer.online else "OFF"
|
||||||
|
consumer_demand = self.reactor.consumer.demand_mw
|
||||||
|
y = self._draw_section(
|
||||||
|
win,
|
||||||
|
y,
|
||||||
|
"Turbine / Grid",
|
||||||
|
[
|
||||||
|
("Turbine", "ON" if self.reactor.turbine_active else "OFF"),
|
||||||
|
("Electrical", f"{state.turbine.electrical_output_mw:7.1f} MW"),
|
||||||
|
("Load", f"{state.turbine.load_supplied_mw:7.1f}/{state.turbine.load_demand_mw:7.1f} MW"),
|
||||||
|
("Consumer", f"{consumer_status}"),
|
||||||
|
("Demand", f"{consumer_demand:7.1f} MW"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self._draw_health_bar(win, y + 1)
|
||||||
|
|
||||||
|
def _draw_help_panel(self, win: "curses._CursesWindow") -> None:
|
||||||
|
win.erase()
|
||||||
|
win.box()
|
||||||
|
win.addstr(0, 2, " Controls ", curses.color_pair(1) | curses.A_BOLD)
|
||||||
|
y = 2
|
||||||
|
for entry in self.keys:
|
||||||
|
win.addstr(y, 2, f"{entry.key:<8} {entry.description}")
|
||||||
|
y += 1
|
||||||
|
win.addstr(y + 1, 2, "Tips:", curses.color_pair(2) | curses.A_BOLD)
|
||||||
|
tips = [
|
||||||
|
"Start pumps before withdrawing rods.",
|
||||||
|
"Bring turbine and consumer online after thermal stabilization.",
|
||||||
|
"Watch component health to avoid automatic trips.",
|
||||||
|
]
|
||||||
|
for idx, tip in enumerate(tips, start=y + 2):
|
||||||
|
win.addstr(idx, 4, f"- {tip}")
|
||||||
|
|
||||||
|
def _draw_status_panel(self, win: "curses._CursesWindow", state: PlantState) -> None:
|
||||||
|
win.erase()
|
||||||
|
win.hline(0, 0, curses.ACS_HLINE, win.getmaxyx()[1])
|
||||||
|
msg = (
|
||||||
|
f"Time {state.time_elapsed:7.1f}s | Rods {self.reactor.control.rod_fraction:.3f} | "
|
||||||
|
f"Primary {'ON' if self.reactor.primary_pump_active else 'OFF'} | "
|
||||||
|
f"Secondary {'ON' if self.reactor.secondary_pump_active else 'OFF'} | "
|
||||||
|
f"Turbine {'ON' if self.reactor.turbine_active else 'OFF'}"
|
||||||
|
)
|
||||||
|
win.addstr(1, 1, msg, curses.color_pair(3))
|
||||||
|
if self.reactor.health_monitor.failure_log:
|
||||||
|
win.addstr(
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
f"Failures: {', '.join(self.reactor.health_monitor.failure_log)}",
|
||||||
|
curses.color_pair(4) | curses.A_BOLD,
|
||||||
|
)
|
||||||
|
log_y = 3
|
||||||
|
for record in list(self.log_buffer):
|
||||||
|
if log_y >= win.getmaxyx()[0] - 1:
|
||||||
|
break
|
||||||
|
win.addstr(log_y, 1, record[: win.getmaxyx()[1] - 2], curses.color_pair(2))
|
||||||
|
log_y += 1
|
||||||
|
win.addstr(log_y, 1, "Press 'q' to exit and persist the current snapshot.", curses.color_pair(2))
|
||||||
|
|
||||||
|
def _draw_section(
|
||||||
|
self,
|
||||||
|
win: "curses._CursesWindow",
|
||||||
|
start_y: int,
|
||||||
|
title: str,
|
||||||
|
lines: list[tuple[str, str] | str],
|
||||||
|
) -> int:
|
||||||
|
height, width = win.getmaxyx()
|
||||||
|
inner_width = width - 4
|
||||||
|
if start_y >= height - 2:
|
||||||
|
return height - 2
|
||||||
|
win.addstr(start_y, 2, title[:inner_width], curses.A_BOLD | curses.color_pair(1))
|
||||||
|
row = start_y + 1
|
||||||
|
for line in lines:
|
||||||
|
if row >= height - 1:
|
||||||
|
break
|
||||||
|
if isinstance(line, tuple):
|
||||||
|
label, value = line
|
||||||
|
text = f"{label:<18}: {value}"
|
||||||
|
else:
|
||||||
|
text = line
|
||||||
|
win.addstr(row, 4, text[:inner_width])
|
||||||
|
row += 1
|
||||||
|
return row + 1
|
||||||
|
|
||||||
|
def _draw_health_bar(self, win: "curses._CursesWindow", start_y: int) -> None:
|
||||||
|
height, width = win.getmaxyx()
|
||||||
|
if start_y >= height - 2:
|
||||||
|
return
|
||||||
|
win.addstr(start_y, 2, "Component Health", curses.A_BOLD | curses.color_pair(1))
|
||||||
|
bar_width = max(10, min(width - 28, 40))
|
||||||
|
for idx, (name, comp) in enumerate(self.reactor.health_monitor.components.items(), start=1):
|
||||||
|
filled = int(bar_width * comp.integrity)
|
||||||
|
bar = "█" * filled + "-" * (bar_width - filled)
|
||||||
|
color = 3 if comp.integrity > 0.5 else 2 if comp.integrity > 0.2 else 4
|
||||||
|
row = start_y + idx
|
||||||
|
if row >= height - 1:
|
||||||
|
break
|
||||||
|
label = f"{name:<12}:"
|
||||||
|
win.addstr(row, 4, label[:14], curses.A_BOLD)
|
||||||
|
bar_start = 4 + max(len(label), 14) + 1
|
||||||
|
win.addstr(row, bar_start, bar[:bar_width], curses.color_pair(color))
|
||||||
|
percent_col = min(width - 8, bar_start + bar_width + 2)
|
||||||
|
win.addstr(row, percent_col, f"{comp.integrity*100:5.1f}%", curses.color_pair(color))
|
||||||
|
|
||||||
|
def _current_demand(self) -> float:
|
||||||
|
if self.reactor.consumer:
|
||||||
|
return self.reactor.consumer.demand_mw
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _clamped_rod(self, delta: float) -> float:
|
||||||
|
new_fraction = self.reactor.control.rod_fraction + delta
|
||||||
|
return max(0.0, min(0.95, new_fraction))
|
||||||
|
|
||||||
|
def _install_log_capture(self) -> None:
|
||||||
|
if self._log_handler:
|
||||||
|
return
|
||||||
|
self._previous_handlers = list(self._logger.handlers)
|
||||||
|
for handler in self._previous_handlers:
|
||||||
|
self._logger.removeHandler(handler)
|
||||||
|
handler = _DashboardLogHandler(self.log_buffer)
|
||||||
|
handler.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
|
||||||
|
self._logger.addHandler(handler)
|
||||||
|
self._logger.propagate = False
|
||||||
|
self._log_handler = handler
|
||||||
|
|
||||||
|
def _restore_logging(self) -> None:
|
||||||
|
if not self._log_handler:
|
||||||
|
return
|
||||||
|
self._logger.removeHandler(self._log_handler)
|
||||||
|
for handler in self._previous_handlers:
|
||||||
|
self._logger.addHandler(handler)
|
||||||
|
self._log_handler = None
|
||||||
|
self._previous_handlers = []
|
||||||
|
|
||||||
|
|
||||||
|
class _DashboardLogHandler(logging.Handler):
|
||||||
|
def __init__(self, buffer: deque[str]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.buffer = buffer
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
msg = self.format(record)
|
||||||
|
self.buffer.append(msg)
|
||||||
@@ -42,7 +42,10 @@ class FuelAssembly:
|
|||||||
event = self.simulate_electron_hit()
|
event = self.simulate_electron_hit()
|
||||||
effective_flux = max(0.0, flux * max(0.0, 1.0 - control_fraction))
|
effective_flux = max(0.0, flux * max(0.0, 1.0 - control_fraction))
|
||||||
atoms = self.mass_kg / self.fissile_atom.atomic_mass_kg
|
atoms = self.mass_kg / self.fissile_atom.atomic_mass_kg
|
||||||
event_rate = effective_flux * constants.ELECTRON_FISSION_CROSS_SECTION * atoms * self.enrichment
|
event_rate = max(
|
||||||
|
0.0,
|
||||||
|
effective_flux * constants.ELECTRON_FISSION_CROSS_SECTION * atoms * self.enrichment,
|
||||||
|
)
|
||||||
power_watts = event_rate * event.energy_mev * constants.MEV_TO_J
|
power_watts = event_rate * event.energy_mev * constants.MEV_TO_J
|
||||||
power_mw = power_watts / constants.MEGAWATT
|
power_mw = power_watts / constants.MEGAWATT
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
@@ -53,4 +56,4 @@ class FuelAssembly:
|
|||||||
event.products[1].mass_number,
|
event.products[1].mass_number,
|
||||||
power_mw,
|
power_mw,
|
||||||
)
|
)
|
||||||
return max(0.0, power_mw), event
|
return max(0.0, power_mw), event_rate, event
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ class Reactor:
|
|||||||
|
|
||||||
self.neutronics.step(state.core, rod_fraction, dt)
|
self.neutronics.step(state.core, rod_fraction, dt)
|
||||||
|
|
||||||
prompt_power, fission_event = self.fuel.prompt_energy_rate(state.core.neutron_flux, rod_fraction)
|
prompt_power, fission_rate, fission_event = self.fuel.prompt_energy_rate(
|
||||||
|
state.core.neutron_flux, rod_fraction
|
||||||
|
)
|
||||||
decay_power = decay_heat_fraction(state.core.burnup) * state.core.power_output_mw
|
decay_power = decay_heat_fraction(state.core.burnup) * state.core.power_output_mw
|
||||||
total_power = prompt_power + decay_power
|
total_power = prompt_power + decay_power
|
||||||
state.core.power_output_mw = total_power
|
state.core.power_output_mw = total_power
|
||||||
@@ -125,7 +127,7 @@ class Reactor:
|
|||||||
self.thermal.step_secondary(state.secondary_loop, transferred)
|
self.thermal.step_secondary(state.secondary_loop, transferred)
|
||||||
|
|
||||||
if self.turbine_active:
|
if self.turbine_active:
|
||||||
self.turbine.step(state.secondary_loop, state.turbine, self.consumer)
|
self.turbine.step(state.secondary_loop, state.turbine, self.consumer, steam_power_mw=transferred)
|
||||||
else:
|
else:
|
||||||
state.turbine.shaft_power_mw = 0.0
|
state.turbine.shaft_power_mw = 0.0
|
||||||
state.turbine.electrical_output_mw = 0.0
|
state.turbine.electrical_output_mw = 0.0
|
||||||
@@ -147,16 +149,13 @@ class Reactor:
|
|||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
(
|
(
|
||||||
"t=%5.1fs rods=%.2f core_power=%.1fMW prompt=%.1fMW :: "
|
"t=%5.1fs rods=%.2f core_power=%.1fMW prompt=%.1fMW :: "
|
||||||
"%s-%d + %s-%d, outlet %.1fK, electrical %.1fMW load %.1f/%.1fMW"
|
"fissions %.2e/s, outlet %.1fK, electrical %.1fMW load %.1f/%.1fMW"
|
||||||
),
|
),
|
||||||
state.time_elapsed,
|
state.time_elapsed,
|
||||||
rod_fraction,
|
rod_fraction,
|
||||||
total_power,
|
total_power,
|
||||||
prompt_power,
|
prompt_power,
|
||||||
fission_event.products[0].symbol,
|
fission_rate,
|
||||||
fission_event.products[0].mass_number,
|
|
||||||
fission_event.products[1].symbol,
|
|
||||||
fission_event.products[1].mass_number,
|
|
||||||
state.primary_loop.temperature_out,
|
state.primary_loop.temperature_out,
|
||||||
state.turbine.electrical_output_mw,
|
state.turbine.electrical_output_mw,
|
||||||
state.turbine.load_supplied_mw,
|
state.turbine.load_supplied_mw,
|
||||||
@@ -183,7 +182,10 @@ class Reactor:
|
|||||||
self._set_turbine_state(False)
|
self._set_turbine_state(False)
|
||||||
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:
|
||||||
|
self.control.set_manual_mode(command.rod_manual)
|
||||||
if command.rod_position is not None:
|
if command.rod_position is not None:
|
||||||
|
self.control.set_manual_mode(True)
|
||||||
overrides["rod_fraction"] = self.control.set_rods(command.rod_position)
|
overrides["rod_fraction"] = self.control.set_rods(command.rod_position)
|
||||||
self.shutdown = self.shutdown or command.rod_position >= 0.95
|
self.shutdown = self.shutdown or command.rod_position >= 0.95
|
||||||
elif command.rod_step is not None:
|
elif command.rod_step is not None:
|
||||||
|
|||||||
@@ -72,11 +72,29 @@ def main() -> None:
|
|||||||
else:
|
else:
|
||||||
duration = None if realtime else 600.0
|
duration = None if realtime else 600.0
|
||||||
reactor = Reactor.default()
|
reactor = Reactor.default()
|
||||||
sim = ReactorSimulation(reactor, timestep=5.0, duration=duration, realtime=realtime)
|
dashboard_mode = os.getenv("FISSION_DASHBOARD", "0") == "1"
|
||||||
|
timestep_env = os.getenv("FISSION_TIMESTEP")
|
||||||
|
if timestep_env:
|
||||||
|
timestep = float(timestep_env)
|
||||||
|
else:
|
||||||
|
timestep = 1.0 if dashboard_mode or realtime else 5.0
|
||||||
|
|
||||||
|
sim = ReactorSimulation(reactor, timestep=timestep, duration=duration, realtime=realtime)
|
||||||
load_path = os.getenv("FISSION_LOAD_STATE")
|
load_path = os.getenv("FISSION_LOAD_STATE")
|
||||||
save_path = os.getenv("FISSION_SAVE_STATE")
|
save_path = os.getenv("FISSION_SAVE_STATE")
|
||||||
if load_path:
|
if load_path:
|
||||||
sim.start_state = reactor.load_state(load_path)
|
sim.start_state = reactor.load_state(load_path)
|
||||||
|
if dashboard_mode:
|
||||||
|
from .dashboard import ReactorDashboard
|
||||||
|
|
||||||
|
dashboard = ReactorDashboard(
|
||||||
|
reactor,
|
||||||
|
start_state=sim.start_state,
|
||||||
|
timestep=sim.timestep,
|
||||||
|
save_path=save_path,
|
||||||
|
)
|
||||||
|
dashboard.run()
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
if realtime:
|
if realtime:
|
||||||
LOGGER.info("Running in real-time mode (Ctrl+C to stop)...")
|
LOGGER.info("Running in real-time mode (Ctrl+C to stop)...")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
from . import constants
|
from . import constants
|
||||||
from .state import CoolantLoopState, CoreState
|
from .state import CoolantLoopState, CoreState
|
||||||
|
|
||||||
@@ -14,8 +16,9 @@ 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) -> float:
|
||||||
"""Return MW transferred to the secondary loop."""
|
"""Return MW transferred to the secondary loop."""
|
||||||
delta_t = max(0.0, primary.temperature_out - secondary.temperature_in)
|
delta_t = max(0.0, primary.temperature_out - secondary.temperature_in)
|
||||||
conductance = 0.05 # steam generator effectiveness
|
conductance = 0.15 # steam generator effectiveness
|
||||||
transferred = min(core_power_mw, conductance * delta_t)
|
efficiency = 1.0 - math.exp(-conductance * delta_t)
|
||||||
|
transferred = min(core_power_mw, core_power_mw * efficiency)
|
||||||
LOGGER.debug("Heat transfer %.2f MW with ΔT=%.1fK", transferred, delta_t)
|
LOGGER.debug("Heat transfer %.2f MW with ΔT=%.1fK", transferred, delta_t)
|
||||||
return transferred
|
return transferred
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ class Turbine:
|
|||||||
loop: CoolantLoopState,
|
loop: CoolantLoopState,
|
||||||
state: TurbineState,
|
state: TurbineState,
|
||||||
consumer: Optional[ElectricalConsumer] = None,
|
consumer: Optional[ElectricalConsumer] = None,
|
||||||
|
steam_power_mw: float = 0.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
enthalpy = 2_700.0 + loop.steam_quality * 600.0
|
enthalpy = 2_700.0 + loop.steam_quality * 600.0
|
||||||
mass_flow = loop.mass_flow_rate * 0.6
|
mass_flow = loop.mass_flow_rate * 0.6
|
||||||
shaft_power_mw = (enthalpy * mass_flow / 1_000.0) * self.mechanical_efficiency / 1_000.0
|
available_power = max(steam_power_mw, (enthalpy * mass_flow / 1_000.0) / 1_000.0)
|
||||||
|
shaft_power_mw = available_power * self.mechanical_efficiency
|
||||||
electrical = shaft_power_mw * self.generator_efficiency
|
electrical = shaft_power_mw * self.generator_efficiency
|
||||||
if consumer:
|
if consumer:
|
||||||
load_demand = consumer.request_power()
|
load_demand = consumer.request_power()
|
||||||
|
|||||||
Reference in New Issue
Block a user