Tighten Textual dashboard controls and panel layout

This commit is contained in:
Codex Agent
2025-11-28 18:28:12 +01:00
parent d626007fae
commit 7ffb2a8ce0

View File

@@ -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)