Add Textual alternate dashboard scaffolding

This commit is contained in:
Codex Agent
2025-11-27 13:08:07 +01:00
parent 4a872a9c1c
commit b9274ebecd
3 changed files with 251 additions and 5 deletions

View File

@@ -120,17 +120,16 @@ def main() -> None:
if dashboard_mode:
if alternate_dashboard:
try:
from .rich_dashboard import RichDashboard
from .textual_dashboard import run_textual_dashboard
except ImportError as exc: # pragma: no cover - optional dependency
LOGGER.error("Rich dashboard requested but 'rich' is not installed: %s", exc)
LOGGER.error("Textual dashboard requested but dependency missing: %s", exc)
return
dashboard = RichDashboard(
run_textual_dashboard(
reactor,
start_state=sim.start_state,
timestep=sim.timestep,
save_path=save_path,
)
dashboard.run()
return
else:
from .dashboard import ReactorDashboard

View File

@@ -0,0 +1,243 @@
"""Interactive Textual-based dashboard (optional)."""
from __future__ import annotations
import logging
from collections import deque
from typing import Optional
from textual.app import App, ComposeResult
from textual.widgets import Static, Header, Footer
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.timer import Timer
from . import constants
from .reactor import Reactor
from .simulation import ReactorSimulation
from .state import PlantState
from .commands import ReactorCommand
LOGGER = logging.getLogger(__name__)
class TextualDashboard(App):
"""A lightweight Textual UI with keybindings mapped to reactor commands."""
CSS = """
Screen { layout: vertical; }
.row { width: 1fr; }
.panel { padding: 0 1; }
"""
BINDINGS = [
("q", "quit", "Quit"),
("space", "scram", "SCRAM"),
("g", "toggle_primary_p1", "P1"),
("h", "toggle_primary_p2", "P2"),
("j", "toggle_secondary_p1", "S1"),
("k", "toggle_secondary_p2", "S2"),
("b", "toggle_generator1", "Gen1"),
("v", "toggle_generator2", "Gen2"),
("t", "toggle_turbine_bank", "Turbines"),
("1", "toggle_turbine_unit('1')", "T1"),
("2", "toggle_turbine_unit('2')", "T2"),
("3", "toggle_turbine_unit('3')", "T3"),
("+", "rods_insert", "+ rods"),
("-", "rods_withdraw", "- rods"),
("[", "demand_down", "Demand -"),
("]", "demand_up", "Demand +"),
("s", "setpoint_down", "SP -"),
("d", "setpoint_up", "SP +"),
("l", "toggle_primary_relief", "Relief pri"),
(";", "toggle_secondary_relief", "Relief sec"),
("c", "toggle_consumer", "Consumer"),
("a", "toggle_auto_rods", "Auto rods"),
]
timestep: float = 1.0
def __init__(
self,
reactor: Reactor,
start_state: Optional[PlantState],
timestep: float = 1.0,
save_path: Optional[str] = None,
) -> None:
super().__init__()
self.reactor = reactor
self.state = start_state or self.reactor.initial_state()
self.timestep = timestep
self.save_path = save_path
self._sim: Optional[ReactorSimulation] = None
self._pending: deque[ReactorCommand] = deque()
self._timer: Optional[Timer] = None
self.core_panel = Static(classes="panel")
self.loop_panel = Static(classes="panel")
self.turbine_panel = Static(classes="panel")
self.status_panel = Static(classes="panel")
def compose(self) -> ComposeResult:
yield Header()
with Vertical():
with Horizontal(classes="row"):
yield self.core_panel
yield self.loop_panel
with Horizontal(classes="row"):
yield self.turbine_panel
yield self.status_panel
yield Footer()
def on_mount(self) -> None:
self._timer = self.set_interval(self.timestep, self._tick, pause=False)
self._render_panels()
def action_quit(self) -> None:
if self._timer:
self._timer.pause()
if self.save_path and self.state:
self.reactor.save_state(self.save_path, self.state)
self.exit()
# Command helpers
def _enqueue(self, cmd: ReactorCommand) -> None:
self._pending.append(cmd)
def action_scram(self) -> None:
self._enqueue(ReactorCommand.scram_all())
def action_toggle_primary_p1(self) -> None:
self._enqueue(ReactorCommand(primary_pumps={1: not self.reactor.primary_pump_units[0]}))
def action_toggle_primary_p2(self) -> None:
self._enqueue(ReactorCommand(primary_pumps={2: not self.reactor.primary_pump_units[1]}))
def action_toggle_secondary_p1(self) -> None:
self._enqueue(ReactorCommand(secondary_pumps={1: not self.reactor.secondary_pump_units[0]}))
def action_toggle_secondary_p2(self) -> None:
self._enqueue(ReactorCommand(secondary_pumps={2: not self.reactor.secondary_pump_units[1]}))
def action_toggle_generator1(self) -> None:
self._enqueue(ReactorCommand(generator_units={1: True}))
def action_toggle_generator2(self) -> None:
self._enqueue(ReactorCommand(generator_units={2: True}))
def action_toggle_turbine_bank(self) -> None:
self._enqueue(ReactorCommand(turbine_on=not self.reactor.turbine_active))
def action_toggle_turbine_unit(self, unit: str) -> None:
idx = int(unit)
current = self.reactor.turbine_unit_active[idx - 1] if idx - 1 < len(self.reactor.turbine_unit_active) else False
self._enqueue(ReactorCommand(turbine_units={idx: not current}))
def action_rods_insert(self) -> None:
self._enqueue(ReactorCommand(rod_position=min(0.95, self.reactor.control.rod_fraction + constants.ROD_MANUAL_STEP)))
def action_rods_withdraw(self) -> None:
self._enqueue(ReactorCommand(rod_position=max(0.0, self.reactor.control.rod_fraction - constants.ROD_MANUAL_STEP)))
def action_demand_down(self) -> None:
if self.reactor.consumer:
self._enqueue(ReactorCommand(consumer_demand=max(0.0, self.reactor.consumer.demand_mw - 50.0)))
def action_demand_up(self) -> None:
if self.reactor.consumer:
self._enqueue(ReactorCommand(consumer_demand=self.reactor.consumer.demand_mw + 50.0))
def action_setpoint_down(self) -> None:
self._enqueue(ReactorCommand(power_setpoint=self.reactor.control.setpoint_mw - 250.0))
def action_setpoint_up(self) -> None:
self._enqueue(ReactorCommand(power_setpoint=self.reactor.control.setpoint_mw + 250.0))
def action_toggle_primary_relief(self) -> None:
self._enqueue(ReactorCommand(primary_relief=not self.reactor.primary_relief_open))
def action_toggle_secondary_relief(self) -> None:
self._enqueue(ReactorCommand(secondary_relief=not self.reactor.secondary_relief_open))
def action_toggle_consumer(self) -> None:
if self.reactor.consumer:
self._enqueue(ReactorCommand(consumer_online=not self.reactor.consumer.online))
def action_toggle_auto_rods(self) -> None:
self._enqueue(ReactorCommand(rod_manual=not self.reactor.control.manual_control))
def _merge_commands(self) -> Optional[ReactorCommand]:
if not self._pending:
return None
# Take the last command to approximate latest intent.
cmd = ReactorCommand()
while self._pending:
nxt = self._pending.popleft()
for field in nxt.__dataclass_fields__: # type: ignore[attr-defined]
val = getattr(nxt, field)
if val is None or val is False:
continue
setattr(cmd, field, val)
return cmd
def _tick(self) -> None:
cmd = self._merge_commands()
self.reactor.step(self.state, self.timestep, cmd)
self._render_panels()
def _render_panels(self) -> None:
core = self.state.core
prim = self.state.primary_loop
sec = self.state.secondary_loop
t0 = self.state.turbines[0] if self.state.turbines else None
self.core_panel.update(
f"[bold cyan]Core[/bold cyan]\\n"
f"Power {core.power_output_mw:6.1f} MW Fuel {core.fuel_temperature:6.1f} K\\n"
f"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 {core.reactivity_margin:+.4f}\\n"
f"DNB {core.dnb_margin:4.2f} Subcool {core.subcooling_margin:4.1f}K"
)
self.loop_panel.update(
f"[bold cyan]Loops[/bold cyan]\\n"
f"Primary P {prim.pressure:4.1f}/{constants.MAX_PRESSURE:4.1f} MPa Tin {prim.temperature_in:6.1f}K Tout {prim.temperature_out:6.1f}K\\n"
f"Flow {prim.mass_flow_rate:6.0f} kg/s Pressurizer {self.reactor.pressurizer_level*100:5.1f}%\\n"
f"Secondary P {sec.pressure:4.1f}/{constants.MAX_PRESSURE:4.1f} MPa Tin {sec.temperature_in:6.1f}K Tout {sec.temperature_out:6.1f}K q {sec.steam_quality:4.2f}\\n"
f"HX ΔT {self.state.primary_to_secondary_delta_t:4.0f}K Eff {self.state.heat_exchanger_efficiency*100:5.1f}%"
)
if t0:
condenser = f"P={t0.condenser_pressure:4.2f}/{constants.CONDENSER_MAX_PRESSURE_MPA:4.2f}MPa T={t0.condenser_temperature:6.1f}K"
else:
condenser = "n/a"
turbine_lines = [
f"[bold cyan]Turbines/Grid[/bold cyan]",
f"Steam avail {getattr(self, '_steam_available_power', lambda s:0.0)(self.state):5.1f} MW h={t0.steam_enthalpy if t0 else 0:5.0f} kJ/kg Cond {condenser}",
" ".join([f"T{idx+1}:{t.electrical_output_mw:5.1f}MW" for idx, t in enumerate(self.state.turbines)]) if self.state.turbines else "Turbines n/a",
f"Electrical {self.state.total_electrical_output():5.1f} MW Load {self._total_load_supplied(self.state):5.1f}/{self._total_load_demand(self.state):5.1f} MW",
]
self.turbine_panel.update("\\n".join(turbine_lines))
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"Pumps pri {[p.status for p in self.state.primary_pumps]} sec {[p.status for p in self.state.secondary_pumps]}\\n"
f"Reliefs pri={'OPEN' if self.reactor.primary_relief_open else 'CLOSED'} sec={'OPEN' if self.reactor.secondary_relief_open else 'CLOSED'}\\n"
f"Failures: {failures}"
)
def _total_load_supplied(self, state: PlantState) -> float:
return sum(t.load_supplied_mw for t in state.turbines)
def _total_load_demand(self, state: PlantState) -> float:
return sum(t.load_demand_mw for t in state.turbines)
def _steam_available_power(self, state: PlantState) -> float:
mass_flow = state.secondary_loop.mass_flow_rate * max(0.0, state.secondary_loop.steam_quality)
if mass_flow <= 1.0:
return 0.0
enthalpy = state.turbines[0].steam_enthalpy if state.turbines else (constants.STEAM_LATENT_HEAT / 1_000.0)
return (enthalpy * mass_flow) / 1_000.0
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)
app.run()