From abc1cb79e1f3917d11620ed6d8deaab011fc25ff Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 26 Nov 2025 22:33:55 +0100 Subject: [PATCH] Add schematic dashboard page and F1/F2 navigation --- TODO.md | 5 ++ src/reactor_sim/dashboard.py | 89 ++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index f494a20..2247842 100644 --- a/TODO.md +++ b/TODO.md @@ -7,6 +7,11 @@ - [ ] Introduce CHF/DNB margin, clad/fuel split temps, and SCRAM matrix for subcooling loss or SG level/pressure trips. - [ ] Flesh out condenser behavior: vacuum pump limits, cooling water temperature coupling, and dynamic back-pressure with fouling. - [ ] Dashboard polish: compact turbine/generator rows, color critical warnings (SCRAM/heat-sink), and reduce repeated log noise. +- [ ] Dashboard multi-page view (F1/F2): + - F1 retains current numeric layout. + - F2 adds an ASCII schematic of the plant (core, primary pumps/pressurizer/HX, secondary pumps/drum, turbines/gens/consumer, reliefs/condenser) with inline key values (flows, pressures, steam quality/enthalpy, MW) and simple animations for flow/status. + - Add page indicator/status hint and size checks; keep updates performant (prebuilt template, minimal redraws). + - Optional: color-coded states (RUN/START/CAV/RELIEF) and blinking for alarms. - [ ] Incremental realism plan: - Add stored enthalpy for primary/secondary loops and a steam-drum mass/energy balance (sensible + latent) while keeping existing pump logic and tests passing. Target representative PWR conditions: primary 15–16 MPa, 290–320 °C inlet/320–330 °C outlet, secondary saturation ~6–7 MPa with boil at ~490–510 K. - Adjust HX/pressure handling to use stored energy (saturation clamp and pressure rise) and validate steam formation with both pumps at ~3 GW. Use realistic tube-side material assumptions (Inconel 690/SS cladding) and clamp steam quality to phase-equilibrium enthalpy. diff --git a/src/reactor_sim/dashboard.py b/src/reactor_sim/dashboard.py index b963f3b..c684f23 100644 --- a/src/reactor_sim/dashboard.py +++ b/src/reactor_sim/dashboard.py @@ -13,7 +13,7 @@ from . import constants from .commands import ReactorCommand from .reactor import Reactor from .simulation import ReactorSimulation -from .state import PlantState +from .state import PlantState, PumpState LOGGER = logging.getLogger(__name__) @@ -76,6 +76,7 @@ class ReactorDashboard: self.sim: Optional[ReactorSimulation] = None self.quit_requested = False self.reset_requested = False + self.page = 1 # 1=metrics, 2=schematic self._last_state: Optional[PlantState] = None self._trend_history: deque[tuple[float, float, float]] = deque(maxlen=120) self.log_buffer: deque[str] = deque(maxlen=8) @@ -90,6 +91,7 @@ class ReactorDashboard: DashboardKey("space", "SCRAM"), DashboardKey("r", "Reset & clear state"), DashboardKey("a", "Toggle auto rod control"), + DashboardKey("F1/F2", "Metrics / schematic views"), DashboardKey("+/-", "Withdraw/insert rods"), DashboardKey("1-9 / Numpad", "Set rods to 0.1 … 0.9 (manual)"), DashboardKey("[/]", "Adjust consumer demand −/+50 MW"), @@ -213,6 +215,10 @@ class ReactorDashboard: self._queue_command(ReactorCommand(generator_auto=not self.reactor.generator_auto)) elif ch in (ord("t"), ord("T")): self._queue_command(ReactorCommand(turbine_on=not self.reactor.turbine_active)) + elif ch == curses.KEY_F1: + self.page = 1 + elif ch == curses.KEY_F2: + self.page = 2 elif keyname and keyname.decode(errors="ignore") in ("!", "@", "#", '"'): name = keyname.decode(errors="ignore") turbine_hotkeys = {"!": 0, "@": 1, "#": 2, '"': 1} @@ -228,7 +234,7 @@ class ReactorDashboard: self._queue_command(ReactorCommand(rod_position=target, rod_manual=True)) elif ch in _NUMPAD_ROD_KEYS: self._queue_command(ReactorCommand(rod_position=_NUMPAD_ROD_KEYS[ch], rod_manual=True)) - elif curses.KEY_F1 <= ch <= curses.KEY_F9: + elif curses.KEY_F3 <= ch <= curses.KEY_F9: target = (ch - curses.KEY_F1 + 1) / 10.0 self._queue_command(ReactorCommand(rod_position=target, rod_manual=True)) elif ch in (ord("+"), ord("=")): @@ -379,7 +385,10 @@ class ReactorDashboard: help_win = stdscr.derwin(data_height, right_width, 0, left_width + gap) status_win = stdscr.derwin(status_height, width, data_height, 0) - self._draw_data_panel(data_win, state) + if self.page == 1: + self._draw_data_panel(data_win, state) + else: + self._draw_schematic_panel(data_win, state) self._draw_help_panel(help_win) self._draw_status_panel(status_win, state) stdscr.refresh() @@ -563,7 +572,7 @@ class ReactorDashboard: 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"Turbines {turbine_text}" + f"Turbines {turbine_text} | Page {'Metrics' if self.page == 1 else 'Schematic'}" ) win.addstr(1, 1, msg, curses.color_pair(3)) if self.reactor.health_monitor.failure_log: @@ -608,6 +617,78 @@ class ReactorDashboard: row += 1 return row + 1 + def _flow_arrow(self, flow: float) -> str: + if flow > 15000: + return "====>" + if flow > 5000: + return "===>" + if flow > 500: + return "->" + return "--" + + def _pump_glyph(self, pump_state: PumpState | None) -> str: + if pump_state is None: + return "·" + status = getattr(pump_state, "status", "OFF") + if status == "RUN": + return "▶" + if status == "CAV": + return "!" + if status == "STARTING": + return ">" + if status == "STOPPING": + return "-" + return "·" + + def _draw_schematic_panel(self, win: "curses._CursesWindow", state: PlantState) -> None: + win.erase() + win.box() + try: + win.addstr(0, 2, " Plant Schematic ", curses.color_pair(1) | curses.A_BOLD) + except curses.error: + pass + height, width = win.getmaxyx() + prim = state.primary_loop + sec = state.secondary_loop + p_pumps = state.primary_pumps if state.primary_pumps else [] + s_pumps = state.secondary_pumps if state.secondary_pumps else [] + p1 = p_pumps[0] if len(p_pumps) > 0 else None + p2 = p_pumps[1] if len(p_pumps) > 1 else None + s1 = s_pumps[0] if len(s_pumps) > 0 else None + s2 = s_pumps[1] if len(s_pumps) > 1 else None + steam_avail = self._steam_available_power(state) + enthalpy = state.turbines[0].steam_enthalpy if state.turbines else 0.0 + + lines = [ + f"CORE {state.core.power_output_mw:5.0f}MW {state.core.fuel_temperature:5.0f}K | Rods {self.reactor.control.rod_fraction:.2f} ({'AUTO' if not self.reactor.control.manual_control else 'MAN'})", + f"Primary Flow: {self._flow_arrow(prim.mass_flow_rate)} {prim.mass_flow_rate:7.0f} kg/s | ΔT hx={state.primary_to_secondary_delta_t:4.0f}K eff={state.heat_exchanger_efficiency*100:5.1f}%", + f"Pumps P1[{self._pump_glyph(p1)}]{(p1.flow_rate if p1 else 0):6.0f}kg/s P2[{self._pump_glyph(p2)}]{(p2.flow_rate if p2 else 0):6.0f}kg/s Relief:{'OPEN' if self.reactor.primary_relief_open else 'CLOSED'}", + f"Ppri={prim.pressure:4.1f}MPa | Tin={prim.temperature_in:6.1f}K Tout={prim.temperature_out:6.1f}K", + "-" * max(10, min(width - 4, 70)), + f"Secondary Flow: {self._flow_arrow(sec.mass_flow_rate)} {sec.mass_flow_rate:7.0f} kg/s | Steam q={sec.steam_quality:4.2f} h={enthalpy:5.0f} kJ/kg avail={steam_avail:5.1f}MW", + f"Pumps S1[{self._pump_glyph(s1)}]{(s1.flow_rate if s1 else 0):6.0f}kg/s S2[{self._pump_glyph(s2)}]{(s2.flow_rate if s2 else 0):6.0f}kg/s Relief:{'OPEN' if self.reactor.secondary_relief_open else 'CLOSED'}", + f"Psec={sec.pressure:4.1f}MPa | Tin={sec.temperature_in:6.1f}K Tout={sec.temperature_out:6.1f}K", + f"Drum Level={sec.level*100:5.1f}% Energy={sec.energy_j/1e6:7.0f} MJ", + ] + turbine_bits = [] + for idx, t_state in enumerate(state.turbines): + turbine_bits.append(f"T{idx+1}:{t_state.electrical_output_mw:4.0f}MW") + lines.append("Turbines " + " ".join(turbine_bits) if turbine_bits else "Turbines n/a") + consumer_status = "OFF" + demand = 0.0 + if self.reactor.consumer: + consumer_status = "ONLINE" if self.reactor.consumer.online else "OFF" + demand = self.reactor.consumer.demand_mw + lines.append(f"Consumer {consumer_status} demand={demand:5.0f}MW supplied={state.total_electrical_output():5.0f}MW") + + for row, text in enumerate(lines, start=1): + if row >= height - 1: + break + try: + win.addstr(row, 2, text[: max(1, width - 3)]) + except curses.error: + continue + def _turbine_status_lines(self) -> list[str]: if not self.reactor.turbine_unit_active: return ["n/a"]