diff --git a/src/reactor_sim/textual_dashboard.py b/src/reactor_sim/textual_dashboard.py index abb829e..0e58df2 100644 --- a/src/reactor_sim/textual_dashboard.py +++ b/src/reactor_sim/textual_dashboard.py @@ -9,8 +9,8 @@ from pathlib import Path from typing import Optional from textual.app import App, ComposeResult -from textual.containers import Horizontal, VerticalScroll -from textual.widgets import Static, Header, Footer +from textual.containers import Grid, Horizontal, HorizontalScroll, Vertical, VerticalScroll +from textual.widgets import Button, Footer, Header, Static from textual.timer import Timer from . import constants @@ -33,8 +33,12 @@ class TextualDashboard(App): CSS = """ Screen { layout: vertical; } - .col { width: 1fr; } - .panel { padding: 0 1; border: solid gray; } + #controls { padding: 0 0; height: auto; } + #controls .row { height: auto; } + #controls Button { min-width: 8; padding: 0 1; } + .columns { height: 1fr; } + .col { width: 1fr; height: 1fr; } + .panel { padding: 0 1; border: round $primary; } """ BINDINGS = [ @@ -61,6 +65,7 @@ class TextualDashboard(App): ("c", "toggle_consumer", "Consumer"), ("a", "toggle_auto_rods", "Auto rods"), ("f12", "snapshot", "Snapshot"), + # Maintenance is mouse-only here; keyboard remains in curses. ] timestep: float = 1.0 @@ -104,27 +109,30 @@ class TextualDashboard(App): self.help_panel = Static(classes="panel") self.log_panel = Static(classes="panel") self.status_panel = Static(classes="panel") + self.controls_panel: Grid | None = None def compose(self) -> ComposeResult: yield Header() - with Horizontal(): + controls = self._build_controls() + yield controls + with Horizontal(classes="columns"): with VerticalScroll(classes="col"): yield self.core_panel yield self.trend_panel yield self.poison_panel yield self.primary_panel yield self.secondary_panel + yield self.power_panel + yield self.generator_panel + yield self.status_panel with VerticalScroll(classes="col"): yield self.turbine_panel - yield self.generator_panel - yield self.power_panel yield self.hx_panel yield self.protection_panel yield self.maintenance_panel yield self.health_panel yield self.log_panel yield self.help_panel - yield self.status_panel yield Footer() def on_mount(self) -> None: @@ -206,6 +214,37 @@ class TextualDashboard(App): def action_toggle_auto_rods(self) -> None: self._enqueue(ReactorCommand(rod_manual=not self.reactor.control.manual_control)) + # Maintenance (mouse-driven) + def action_maintain_core(self) -> None: + self._enqueue(ReactorCommand.maintain("core")) + + def action_maintain_primary_p1(self) -> None: + self._enqueue(ReactorCommand.maintain("primary_pump_1")) + + def action_maintain_primary_p2(self) -> None: + self._enqueue(ReactorCommand.maintain("primary_pump_2")) + + def action_maintain_secondary_p1(self) -> None: + self._enqueue(ReactorCommand.maintain("secondary_pump_1")) + + def action_maintain_secondary_p2(self) -> None: + self._enqueue(ReactorCommand.maintain("secondary_pump_2")) + + def action_maintain_turbine_1(self) -> None: + self._enqueue(ReactorCommand.maintain("turbine_1")) + + def action_maintain_turbine_2(self) -> None: + self._enqueue(ReactorCommand.maintain("turbine_2")) + + def action_maintain_turbine_3(self) -> None: + self._enqueue(ReactorCommand.maintain("turbine_3")) + + def action_maintain_generator_1(self) -> None: + self._enqueue(ReactorCommand.maintain("generator_1")) + + def action_maintain_generator_2(self) -> None: + self._enqueue(ReactorCommand.maintain("generator_2")) + def action_snapshot(self) -> None: self._save_snapshot(auto=False) @@ -229,40 +268,89 @@ class TextualDashboard(App): if self.snapshot_at is not None and not self.snapshot_done and self.state.time_elapsed >= self.snapshot_at: self._save_snapshot(auto=True) + def _build_controls(self) -> Vertical: + row1 = Horizontal( + Button("SCRAM", id="scram"), + Button("P1", id="p1"), + Button("P2", id="p2"), + Button("S1", id="s1"), + Button("S2", id="s2"), + Button("RelP", id="relief_pri"), + Button("RelS", id="relief_sec"), + Button("Turb", id="turbines"), + Button("T1", id="t1"), + Button("T2", id="t2"), + Button("T3", id="t3"), + Button("G1", id="g1"), + Button("G2", id="g2"), + Button("Grid", id="consumer"), + Button("AutoR", id="auto_rods"), + Button("+Rod", id="rods_plus"), + Button("-Rod", id="rods_minus"), + classes="row", + ) + row2 = Horizontal( + Button("D+50", id="demand_up"), + Button("D-50", id="demand_down"), + Button("SP+250", id="sp_up"), + Button("SP-250", id="sp_down"), + Button("Snap", id="snapshot"), + classes="row", + ) + row3 = Horizontal( + Button("MCore", id="m_core"), + Button("MP1", id="m_p1"), + Button("MP2", id="m_p2"), + Button("MS1", id="m_s1"), + Button("MS2", id="m_s2"), + Button("MT1", id="m_t1"), + Button("MT2", id="m_t2"), + Button("MT3", id="m_t3"), + Button("MG1", id="m_g1"), + Button("MG2", id="m_g2"), + classes="row", + ) + return Vertical( + HorizontalScroll(row1, classes="row"), + HorizontalScroll(row2, classes="row"), + HorizontalScroll(row3, classes="row"), + id="controls", + ) + def _render_panels(self) -> None: self._trend_history.append((self.state.time_elapsed, self.state.core.fuel_temperature, self.state.core.power_output_mw)) self.core_panel.update( - f"[bold cyan]Core[/bold cyan]\\n" - f"Power {self.state.core.power_output_mw:6.1f} MW (Nom {constants.NORMAL_CORE_POWER_MW:4.0f}/Max {constants.TEST_MAX_POWER_MW:4.0f})\\n" - f"Fuel {self.state.core.fuel_temperature:6.1f} K Rods {self.reactor.control.rod_fraction:.3f} ({'AUTO' if not self.reactor.control.manual_control else 'MAN'})\\n" - f"Setpoint {self.reactor.control.setpoint_mw:5.0f} MW Reactivity {self.state.core.reactivity_margin:+.4f}\\n" + "[bold cyan]Core[/bold cyan]\n" + f"Power {self.state.core.power_output_mw:6.1f} MW (Nom {constants.NORMAL_CORE_POWER_MW:4.0f}/Max {constants.TEST_MAX_POWER_MW:4.0f})\n" + f"Fuel {self.state.core.fuel_temperature:6.1f} K Rods {self.reactor.control.rod_fraction:.3f} ({'AUTO' if not self.reactor.control.manual_control else 'MAN'})\n" + f"Setpoint {self.reactor.control.setpoint_mw:5.0f} MW Reactivity {self.state.core.reactivity_margin:+.4f}\n" f"DNB {self.state.core.dnb_margin:4.2f} Subcool {self.state.core.subcooling_margin:4.1f}K" ) self.trend_panel.update(self._trend_text()) self.poison_panel.update(self._poison_text()) self.primary_panel.update( - f"[bold cyan]Primary Loop[/bold cyan]\\n" - f"Flow {self.state.primary_loop.mass_flow_rate:7.0f}/{self.reactor.primary_pump.nominal_flow * len(self.reactor.primary_pump_units):.0f} kg/s\\n" - f"Level {self.state.primary_loop.level*100:6.1f}%\\n" - f"Tin {self.state.primary_loop.temperature_in:7.1f} K Tout {self.state.primary_loop.temperature_out:7.1f} K (Target {constants.PRIMARY_OUTLET_TARGET_K:4.0f})\\n" - f"P {self.state.primary_loop.pressure:5.2f}/{constants.MAX_PRESSURE:4.1f} MPa Pressurizer {self.reactor.pressurizer_level*100:6.1f}% @ {constants.PRIMARY_PRESSURIZER_SETPOINT_MPA:4.1f} MPa\\n" + "[bold cyan]Primary Loop[/bold cyan]\n" + f"Flow {self.state.primary_loop.mass_flow_rate:7.0f}/{self.reactor.primary_pump.nominal_flow * len(self.reactor.primary_pump_units):.0f} kg/s\n" + f"Level {self.state.primary_loop.level*100:6.1f}%\n" + f"Tin {self.state.primary_loop.temperature_in:7.1f} K Tout {self.state.primary_loop.temperature_out:7.1f} K (Target {constants.PRIMARY_OUTLET_TARGET_K:4.0f})\n" + f"P {self.state.primary_loop.pressure:5.2f}/{constants.MAX_PRESSURE:4.1f} MPa Pressurizer {self.reactor.pressurizer_level*100:6.1f}% @ {constants.PRIMARY_PRESSURIZER_SETPOINT_MPA:4.1f} MPa\n" f"Relief {'OPEN' if self.reactor.primary_relief_open else 'CLOSED'} Pumps {[p.status for p in self.state.primary_pumps]}" ) self.secondary_panel.update( - f"[bold cyan]Secondary Loop[/bold cyan]\\n" - f"Flow {self.state.secondary_loop.mass_flow_rate:7.0f}/{self.reactor.secondary_pump.nominal_flow * len(self.reactor.secondary_pump_units):.0f} kg/s\\n" - f"Level {self.state.secondary_loop.level*100:6.1f}%\\n" - f"Tin {self.state.secondary_loop.temperature_in:7.1f} K Tout {self.state.secondary_loop.temperature_out:7.1f} K (Target {constants.SECONDARY_OUTLET_TARGET_K:4.0f})\\n" - f"P {self.state.secondary_loop.pressure:5.2f}/{constants.MAX_PRESSURE:4.1f} MPa q {self.state.secondary_loop.steam_quality:5.2f}/1.00\\n" + "[bold cyan]Secondary Loop[/bold cyan]\n" + f"Flow {self.state.secondary_loop.mass_flow_rate:7.0f}/{self.reactor.secondary_pump.nominal_flow * len(self.reactor.secondary_pump_units):.0f} kg/s\n" + f"Level {self.state.secondary_loop.level*100:6.1f}%\n" + f"Tin {self.state.secondary_loop.temperature_in:7.1f} K Tout {self.state.secondary_loop.temperature_out:7.1f} K (Target {constants.SECONDARY_OUTLET_TARGET_K:4.0f})\n" + f"P {self.state.secondary_loop.pressure:5.2f}/{constants.MAX_PRESSURE:4.1f} MPa q {self.state.secondary_loop.steam_quality:5.2f}/1.00\n" f"Relief {'OPEN' if self.reactor.secondary_relief_open else 'CLOSED'} Pumps {[p.status for p in self.state.secondary_pumps]}" ) self.turbine_panel.update(self._turbine_text()) self.generator_panel.update(self._generator_text()) self.power_panel.update(self._power_text()) self.hx_panel.update( - f"[bold cyan]Heat Exchanger[/bold cyan]\\n" - f"ΔT (pri-sec) {self.state.primary_to_secondary_delta_t:4.0f} K\\n" + "[bold cyan]Heat Exchanger[/bold cyan]\n" + f"ΔT (pri-sec) {self.state.primary_to_secondary_delta_t:4.0f} K\n" f"Efficiency {self.state.heat_exchanger_efficiency*100:5.1f}%" ) self.protection_panel.update(self._protection_text()) @@ -272,15 +360,15 @@ class TextualDashboard(App): self.help_panel.update(self._help_text()) failures = ", ".join(self.reactor.health_monitor.failure_log) if self.reactor.health_monitor.failure_log else "None" self.status_panel.update( - f"[bold cyan]Status[/bold cyan]\\n" - f"Time {self.state.time_elapsed:6.1f}s\\n" - f"Consumer {'ON' if (self.reactor.consumer and self.reactor.consumer.online) else 'OFF'} Demand {self.reactor.consumer.demand_mw if self.reactor.consumer else 0.0:5.1f} MW\\n" + "[bold cyan]Status[/bold cyan]\n" + f"Time {self.state.time_elapsed:6.1f}s\n" + f"Consumer {'ON' if (self.reactor.consumer and self.reactor.consumer.online) else 'OFF'} Demand {self.reactor.consumer.demand_mw if self.reactor.consumer else 0.0:5.1f} MW\n" f"Failures: {failures}" ) def _trend_text(self) -> str: if len(self._trend_history) < 2: - return "[bold cyan]Trends[/bold cyan]\\nFuel Temp Δ n/a\\nCore Power Δ n/a" + return "[bold cyan]Trends[/bold cyan]\nFuel Temp Δ n/a\nCore Power Δ n/a" start_t, start_temp, start_power = self._trend_history[0] end_t, end_temp, end_power = self._trend_history[-1] duration = max(1.0, end_t - start_t) @@ -289,8 +377,8 @@ class TextualDashboard(App): temp_rate = temp_delta / duration power_rate = power_delta / duration return ( - "[bold cyan]Trends[/bold cyan]\\n" - f"Fuel Temp Δ {end_temp:7.1f} K (Δ{temp_delta:+6.1f} / {duration:4.0f}s, {temp_rate:+5.2f}/s)\\n" + "[bold cyan]Trends[/bold cyan]\n" + f"Fuel Temp Δ {end_temp:7.1f} K (Δ{temp_delta:+6.1f} / {duration:4.0f}s, {temp_rate:+5.2f}/s)\n" f"Core Power Δ {end_power:7.1f} MW (Δ{power_delta:+6.1f} / {duration:4.0f}s, {power_rate:+5.2f}/s)" ) @@ -302,11 +390,11 @@ class TextualDashboard(App): iodine = inventory.get("I", 0.0) xe_drho = getattr(self.state.core, "reactivity_margin", 0.0) return ( - "[bold cyan]Key Poisons / Emitters[/bold cyan]\\n" - f"Xe (xenon): {xe:9.2e}\\n" - f"Sm (samarium): {sm:9.2e}\\n" - f"I (iodine): {iodine:9.2e}\\n" - f"Xe Δρ: {xe_drho:+.4f}\\n" + "[bold cyan]Key Poisons / Emitters[/bold cyan]\n" + f"Xe (xenon): {xe:9.2e}\n" + f"Sm (samarium): {sm:9.2e}\n" + f"I (iodine): {iodine:9.2e}\n" + f"Xe Δρ: {xe_drho:+.4f}\n" f"Neutrons (src): {particles.get('neutrons', 0.0):.2e}" ) @@ -331,7 +419,7 @@ class TextualDashboard(App): lines.append(f"Electrical {self.state.total_electrical_output():7.1f} MW Load {self._total_load_supplied(self.state):7.1f}/{self._total_load_demand(self.state):7.1f} MW") if self.reactor.consumer: lines.append(f"Consumer {'ONLINE' if self.reactor.consumer.online else 'OFF'} Demand {self.reactor.consumer.demand_mw:7.1f} MW") - return "\\n".join(lines) + return "\n".join(lines) def _generator_text(self) -> str: lines = ["[bold cyan]Generators[/bold cyan]"] @@ -340,7 +428,7 @@ class TextualDashboard(App): if g.starting: status = "START" lines.append(f"Gen{idx+1}: {status:5} {g.power_output_mw:5.1f} MW batt {g.battery_charge*100:5.1f}%") - return "\\n".join(lines) + return "\n".join(lines) def _power_text(self) -> str: draws = getattr(self.state, "aux_draws", {}) or {} @@ -352,9 +440,9 @@ class TextualDashboard(App): gen_out = draws.get("generator_output", 0.0) turb_out = draws.get("turbine_output", 0.0) return ( - "[bold cyan]Power Stats[/bold cyan]\\n" - f"Base Aux {base:5.1f} MW Prim Aux {prim:5.1f} MW Sec Aux {sec:5.1f} MW\\n" - f"Aux demand {demand:5.1f} MW supplied {supplied:5.1f} MW\\n" + "[bold cyan]Power Stats[/bold cyan]\n" + f"Base Aux {base:5.1f} MW Prim Aux {prim:5.1f} MW Sec Aux {sec:5.1f} MW\n" + f"Aux demand {demand:5.1f} MW supplied {supplied:5.1f} MW\n" f"Gen out {gen_out:5.1f} MW Turbine out {turb_out:5.1f} MW" ) @@ -375,17 +463,17 @@ class TextualDashboard(App): lines.append(f"DNB margin {self.state.core.dnb_margin:4.2f}") lines.append(f"Subcooling {self.state.core.subcooling_margin:5.1f} K") lines.append(f"Reliefs pri={'OPEN' if self.reactor.primary_relief_open else 'CLOSED'} sec={'OPEN' if self.reactor.secondary_relief_open else 'CLOSED'}") - return "\\n".join(lines) + return "\n".join(lines) def _maintenance_text(self) -> str: active = list(self.reactor.maintenance_active) - return "[bold cyan]Maintenance[/bold cyan]\\nActive: " + (", ".join(active) if active else "None") + return "[bold cyan]Maintenance[/bold cyan]\nActive: " + (", ".join(active) if active else "None") def _health_text(self) -> str: lines = ["[bold cyan]Component Health[/bold cyan]"] for name, comp in self.reactor.health_monitor.components.items(): lines.append(_bar(name, comp.integrity)) - return "\\n".join(lines) + return "\n".join(lines) def _help_text(self) -> str: tips = [ @@ -395,7 +483,7 @@ class TextualDashboard(App): "Use m/n/,/. in curses; mapped to j/k etc here.", "F12 saves a snapshot; set FISSION_SNAPSHOT_AT for auto.", ] - return "[bold cyan]Controls & Tips[/bold cyan]\\n" + "\\n".join(f"- {t}" for t in tips) + return "[bold cyan]Controls & Tips[/bold cyan]\n" + "\n".join(f"- {t}" for t in tips) def _log_text(self) -> str: lines = ["[bold cyan]Logs[/bold cyan]"] @@ -403,7 +491,7 @@ class TextualDashboard(App): lines.append("No recent logs.") else: lines.extend(list(self.log_buffer)) - return "\\n".join(lines) + return "\n".join(lines) def _turbine_status_lines(self) -> list[str]: if not self.reactor.turbine_unit_active: @@ -437,14 +525,19 @@ class TextualDashboard(App): def _save_snapshot(self, auto: bool = False) -> None: try: self.snapshot_path.parent.mkdir(parents=True, exist_ok=True) - self.snapshot_path.write_text("\\n".join(self._snapshot_lines())) + self.snapshot_path.write_text("\n".join(self._snapshot_lines())) self.snapshot_done = True LOGGER.info("Saved dashboard snapshot to %s%s", self.snapshot_path, " (auto)" if auto else "") except Exception as exc: # pragma: no cover LOGGER.error("Failed to save snapshot: %s", exc) def _install_log_capture(self) -> None: + # Silence existing root handlers to avoid spewing logs over the UI. + root = logging.getLogger() + root.handlers = [] + # Capture reactor_sim logs into the on-screen log buffer. logger = logging.getLogger("reactor_sim") + logger.propagate = False self._previous_handlers = list(logger.handlers) handler = logging.StreamHandler() handler.setLevel(logging.INFO) @@ -465,6 +558,45 @@ class TextualDashboard(App): if self._log_handler and self._log_handler in logger.handlers: logger.removeHandler(self._log_handler) + def on_button_pressed(self, event: Button.Pressed) -> None: # type: ignore[override] + mapping = { + "scram": self.action_scram, + "p1": self.action_toggle_primary_p1, + "p2": self.action_toggle_primary_p2, + "s1": self.action_toggle_secondary_p1, + "s2": self.action_toggle_secondary_p2, + "relief_pri": self.action_toggle_primary_relief, + "relief_sec": self.action_toggle_secondary_relief, + "turbines": self.action_toggle_turbine_bank, + "t1": lambda: self.action_toggle_turbine_unit("1"), + "t2": lambda: self.action_toggle_turbine_unit("2"), + "t3": lambda: self.action_toggle_turbine_unit("3"), + "g1": self.action_toggle_generator1, + "g2": self.action_toggle_generator2, + "consumer": self.action_toggle_consumer, + "auto_rods": self.action_toggle_auto_rods, + "rods_plus": self.action_rods_insert, + "rods_minus": self.action_rods_withdraw, + "demand_up": self.action_demand_up, + "demand_down": self.action_demand_down, + "sp_up": self.action_setpoint_up, + "sp_down": self.action_setpoint_down, + "snapshot": lambda: self._save_snapshot(auto=False), + "m_core": self.action_maintain_core, + "m_p1": self.action_maintain_primary_p1, + "m_p2": self.action_maintain_primary_p2, + "m_s1": self.action_maintain_secondary_p1, + "m_s2": self.action_maintain_secondary_p2, + "m_t1": self.action_maintain_turbine_1, + "m_t2": self.action_maintain_turbine_2, + "m_t3": self.action_maintain_turbine_3, + "m_g1": self.action_maintain_generator_1, + "m_g2": self.action_maintain_generator_2, + } + handler = mapping.get(event.button.id or "") + if handler: + handler() + def run_textual_dashboard(reactor: Reactor, start_state: Optional[PlantState], timestep: float, save_path: Optional[str]) -> None: app = TextualDashboard(reactor, start_state, timestep=timestep, save_path=save_path)