diff --git a/src/reactor_sim/dashboard.py b/src/reactor_sim/dashboard.py index 802758f..b844fee 100644 --- a/src/reactor_sim/dashboard.py +++ b/src/reactor_sim/dashboard.py @@ -53,6 +53,12 @@ class ReactorDashboard: 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.sim = ReactorSimulation( self.reactor, @@ -65,7 +71,7 @@ class ReactorDashboard: try: for state in self.sim.run(): self._draw(stdscr, state) - self._handle_input(stdscr, state) + self._handle_input(stdscr) if self.quit_requested: self.sim.stop() break @@ -73,7 +79,7 @@ class ReactorDashboard: if self.save_path and self.sim and self.sim.last_state: self.reactor.save_state(self.save_path, self.sim.last_state) - def _handle_input(self, stdscr: "curses._CursesWindow", state: PlantState) -> None: + def _handle_input(self, stdscr: "curses._CursesWindow") -> None: while True: ch = stdscr.getch() if ch == -1: @@ -110,76 +116,168 @@ class ReactorDashboard: 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) + return + 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) - def _next_command(self, sim_time: float, _: PlantState) -> Optional[ReactorCommand]: + 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() - stdscr.addstr(0, 0, "Realtime Reactor Dashboard".ljust(60)) - stdscr.addstr(1, 0, f"Time: {state.time_elapsed:8.1f}s Mode: realtime") - stdscr.addstr( - 3, - 0, - f"Core Temp: {state.core.fuel_temperature:8.1f} K Power: {state.core.power_output_mw:8.1f} MW " - f"Flux: {state.core.neutron_flux:10.2e}", - ) - stdscr.addstr( - 4, - 0, - f"Rod fraction: {self.reactor.control.rod_fraction:.3f} Setpoint: {self.reactor.control.setpoint_mw:.0f} MW", - ) - stdscr.addstr( - 6, - 0, - f"Primary Loop: {'ON ' if self.reactor.primary_pump_active else 'OFF'} " - f"Flow {state.primary_loop.mass_flow_rate:7.0f} kg/s Outlet {state.primary_loop.temperature_out:6.1f} K", - ) - stdscr.addstr( - 7, - 0, - f"Secondary Loop: {'ON ' if self.reactor.secondary_pump_active else 'OFF'} " - f"Flow {state.secondary_loop.mass_flow_rate:7.0f} kg/s Pressure {state.secondary_loop.pressure:4.1f} MPa", - ) - stdscr.addstr( - 9, - 0, - f"Turbine: {'ON ' if self.reactor.turbine_active else 'OFF'} Electrical {state.turbine.electrical_output_mw:7.1f} MW " - f"Load {state.turbine.load_supplied_mw:7.1f}/{state.turbine.load_demand_mw:7.1f} MW", - ) - if self.reactor.consumer: + height, width = stdscr.getmaxyx() + if height < 24 or width < 90: stdscr.addstr( - 10, 0, - f"Consumer: {'ONLINE ' if self.reactor.consumer.online else 'OFFLINE'} " - f"Demand {self.reactor.consumer.demand_mw:7.1f} MW", + 0, + "Terminal window too small. Resize to at least 90x24.".ljust(width), + curses.color_pair(4), ) - health = self.reactor.health_monitor.components - stdscr.addstr( - 12, - 0, - "Health: " + " ".join(f"{name}:{comp.integrity*100:5.1f}%" for name, comp in health.items()), - ) - stdscr.addstr(14, 0, "Controls:") - for idx, key in enumerate(self.keys, start=15): - stdscr.addstr(idx, 0, f"{key.key.ljust(8)} - {key.description}") + stdscr.refresh() + return + + data_height = height - 6 + left_width = min(max(width - 32, 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", + [ + f"Tfuel {state.core.fuel_temperature:8.1f} K Power {state.core.power_output_mw:8.1f} MW", + f"Neutron Flux {state.core.neutron_flux:10.2e} Rods {self.reactor.control.rod_fraction:.3f}", + f"Setpoint {self.reactor.control.setpoint_mw:7.0f} MW Reactivity {state.core.reactivity_margin:+.4f}", + ], + ) + y = self._draw_section( + win, + y, + "Primary Loop", + [ + f"Pump {'ON ' if self.reactor.primary_pump_active else 'OFF'} " + f"Flow {state.primary_loop.mass_flow_rate:7.0f} kg/s", + f"T_in {state.primary_loop.temperature_in:7.1f} K " + f"T_out {state.primary_loop.temperature_out:7.1f} K", + f"Pressure {state.primary_loop.pressure:5.2f} MPa", + ], + ) + y = self._draw_section( + win, + y, + "Secondary Loop", + [ + f"Pump {'ON ' if self.reactor.secondary_pump_active else 'OFF'} " + f"Flow {state.secondary_loop.mass_flow_rate:7.0f} kg/s", + f"T_in {state.secondary_loop.temperature_in:7.1f} K " + f"Pressure {state.secondary_loop.pressure:5.2f} MPa", + f"Steam quality {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", + [ + f"Turbine {'ON ' if self.reactor.turbine_active else 'OFF'} " + f"Electrical {state.turbine.electrical_output_mw:7.1f} MW", + f"Load {state.turbine.load_supplied_mw:7.1f}/{state.turbine.load_demand_mw:7.1f} MW", + f"Consumer {consumer_status} demand {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, + ) + win.addstr(4, 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[str], + ) -> int: + width = win.getmaxyx()[1] - 4 + win.addstr(start_y, 2, title, curses.A_BOLD | curses.color_pair(1)) + for idx, line in enumerate(lines, start=start_y + 1): + win.addstr(idx, 4, line[:width]) + return start_y + len(lines) + 2 + + def _draw_health_bar(self, win: "curses._CursesWindow", start_y: int) -> None: + win.addstr(start_y, 2, "Component Health", curses.A_BOLD | curses.color_pair(1)) + bar_width = win.getmaxyx()[1] - 10 + 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 + win.addstr(start_y + idx, 4, f"{name:<12}", curses.A_BOLD) + win.addstr(start_y + idx, 16, bar, curses.color_pair(color)) + win.addstr(start_y + idx, 18 + bar_width, 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 diff --git a/src/reactor_sim/simulation.py b/src/reactor_sim/simulation.py index 0afd73b..c4e6e7e 100644 --- a/src/reactor_sim/simulation.py +++ b/src/reactor_sim/simulation.py @@ -72,10 +72,16 @@ def main() -> None: else: duration = None if realtime else 600.0 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") save_path = os.getenv("FISSION_SAVE_STATE") - dashboard_mode = os.getenv("FISSION_DASHBOARD", "0") == "1" if load_path: sim.start_state = reactor.load_state(load_path) if dashboard_mode: