Compare commits

...

10 Commits

Author SHA1 Message Date
Andrii Prokhorov
7c8321e3c4 fix: keep dashboard logging inside curses UI 2025-11-21 18:19:33 +02:00
Andrii Prokhorov
5c0ad3fb72 feat: link turbine output to transferred heat 2025-11-21 18:15:11 +02:00
Andrii Prokhorov
4873777ba8 fix: guard dashboard health panel 2025-11-21 18:09:06 +02:00
Andrii Prokhorov
303258a6bb chore: improve dashboard layout 2025-11-21 18:03:45 +02:00
Andrii Prokhorov
2400ce669e feat: improve manual rod control and reactor power 2025-11-21 17:59:25 +02:00
Andrii Prokhorov
6e8520c925 fix: adjust dashboard rod controls 2025-11-21 17:51:54 +02:00
Andrii Prokhorov
2bc45d1594 chore: show fission rate in status 2025-11-21 17:38:13 +02:00
Andrii Prokhorov
37fded30e6 fix: guard dashboard drawing widths 2025-11-21 17:31:42 +02:00
Andrii Prokhorov
8dec574ac6 chore: refine dashboard layout and timestep 2025-11-21 17:26:39 +02:00
Andrii Prokhorov
146e8fd26b feat: add realtime dashboard control 2025-11-21 17:22:02 +02:00
12 changed files with 435 additions and 23 deletions

View File

@@ -9,7 +9,7 @@ All source code lives under `src/reactor_sim`. Submodules map to plant systems:
- `python -m pip install -e .[dev]` — install editable package with optional tooling when dev dependencies are defined.
## Operations & Control Hooks
Manual commands live in `reactor_sim.commands.ReactorCommand`. Pass a `command_provider` callable to `ReactorSimulation` to adjust rods, pumps, turbine, coolant demand, or the attached `ElectricalConsumer`. Use `ReactorCommand.scram_all()` for full shutdown, `ReactorCommand(consumer_online=True, consumer_demand=600)` to hook the grid, or toggle pumps (`primary_pump_on=False`) to simulate faults. Control helpers in `control.py` expose `set_rods`, `increment_rods`, and `scram`.
Manual commands live in `reactor_sim.commands.ReactorCommand`. Pass a `command_provider` callable to `ReactorSimulation` to adjust rods, pumps, turbine, coolant demand, or the attached `ElectricalConsumer`. Use `ReactorCommand.scram_all()` for full shutdown, `ReactorCommand(consumer_online=True, consumer_demand=600)` to hook the grid, or toggle pumps (`primary_pump_on=False`) to simulate faults. Control helpers in `control.py` expose `set_rods`, `increment_rods`, and `scram`, and you can switch `set_manual_mode(True)` to pause the automatic rod controller. For hands-on runs, launch the curses dashboard (`FISSION_DASHBOARD=1 FISSION_REALTIME=1 python run_simulation.py`) and use the on-screen shortcuts (q quit/save, space SCRAM, p/o pumps, t turbine, +/- rods in 0.05 steps, [/ ] consumer demand, s/d setpoint, `a` toggles auto/manual rods). Recommended startup: enable manual rods (`a`), withdraw to ~0.3 before ramping the turbine/consumer, then re-enable auto control when you want closed-loop operation.
The plant now boots cold (ambient core temperature, idle pumps); scripts must sequence startup: enable pumps, gradually withdraw rods, connect the consumer after turbine spin-up, and use `ControlSystem.set_power_setpoint` to chase desired output. Set `FISSION_REALTIME=1` to run continuously with real-time pacing; optionally set `FISSION_SIM_DURATION=infinite` for indefinite runs and send SIGINT/Ctrl+C to stop. Use `FISSION_SIM_DURATION=600` (default) for bounded offline batches.
## Coding Style & Naming Conventions

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from .atomic import Atom, AtomicPhysics, FissionEvent
from .commands import ReactorCommand
from .consumer import ElectricalConsumer
from .dashboard import ReactorDashboard
from .failures import HealthMonitor
from .logging_utils import configure_logging
from .state import CoreState, CoolantLoopState, TurbineState, PlantState
@@ -24,5 +25,6 @@ __all__ = [
"ReactorCommand",
"ElectricalConsumer",
"HealthMonitor",
"ReactorDashboard",
"configure_logging",
]

View File

@@ -19,6 +19,7 @@ class ReactorCommand:
power_setpoint: float | None = None
consumer_online: bool | None = None
consumer_demand: float | None = None
rod_manual: bool | None = None
@classmethod
def scram_all(cls) -> "ReactorCommand":

View File

@@ -16,4 +16,4 @@ GENERATOR_EFFICIENCY = 0.96
ENVIRONMENT_TEMPERATURE = 295.0 # K
AMU_TO_KG = 1.660_539_066_60e-27
MEV_TO_J = 1.602_176_634e-13
ELECTRON_FISSION_CROSS_SECTION = 5e-23 # cm^2, tuned for simulation scale
ELECTRON_FISSION_CROSS_SECTION = 5e-16 # cm^2, tuned for simulation scale

View File

@@ -14,6 +14,7 @@ class ElectricalConsumer:
demand_mw: float
online: bool = False
power_received_mw: float = 0.0
_under_supply_logged: bool = False
def request_power(self) -> float:
return self.demand_mw if self.online else 0.0
@@ -30,11 +31,16 @@ class ElectricalConsumer:
def update_power_received(self, supplied_mw: float) -> None:
self.power_received_mw = supplied_mw
if supplied_mw < self.request_power():
LOGGER.warning(
"%s under-supplied: %.1f/%.1f MW",
self.name,
supplied_mw,
self.request_power(),
)
if supplied_mw + 1e-6 < self.request_power():
if not self._under_supply_logged:
LOGGER.warning(
"%s under-supplied: %.1f/%.1f MW",
self.name,
supplied_mw,
self.request_power(),
)
self._under_supply_logged = True
else:
if self._under_supply_logged:
LOGGER.info("%s demand satisfied", self.name)
self._under_supply_logged = False

View File

@@ -21,8 +21,11 @@ def clamp(value: float, lo: float, hi: float) -> float:
class ControlSystem:
setpoint_mw: float = 3_000.0
rod_fraction: float = 0.5
manual_control: bool = False
def update_rods(self, state: CoreState, dt: float) -> float:
if self.manual_control:
return self.rod_fraction
error = (state.power_output_mw - self.setpoint_mw) / self.setpoint_mw
adjustment = -error * 0.3
adjustment = clamp(adjustment, -constants.CONTROL_ROD_SPEED * dt, constants.CONTROL_ROD_SPEED * dt)
@@ -50,6 +53,11 @@ class ControlSystem:
self.setpoint_mw = clamp(megawatts, 100.0, 4_000.0)
LOGGER.info("Power setpoint %.0f -> %.0f MW", previous, self.setpoint_mw)
def set_manual_mode(self, manual: bool) -> None:
if self.manual_control != manual:
self.manual_control = manual
LOGGER.info("Rod control %s", "manual" if manual else "automatic")
def coolant_demand(self, primary: CoolantLoopState) -> float:
desired_temp = 580.0
error = (primary.temperature_out - desired_temp) / 100.0

View File

@@ -0,0 +1,367 @@
"""Interactive curses dashboard for real-time reactor operation."""
from __future__ import annotations
import curses
import logging
from collections import deque
from dataclasses import dataclass
from typing import Optional
from .commands import ReactorCommand
from .reactor import Reactor
from .simulation import ReactorSimulation
from .state import PlantState
@dataclass
class DashboardKey:
key: str
description: str
class ReactorDashboard:
"""Minimal TUI dashboard allowing user control in real-time."""
def __init__(
self,
reactor: Reactor,
start_state: Optional[PlantState],
timestep: float = 1.0,
save_path: Optional[str] = None,
) -> None:
self.reactor = reactor
self.start_state = start_state
self.timestep = timestep
self.save_path = save_path
self.pending_command: Optional[ReactorCommand] = None
self.sim: Optional[ReactorSimulation] = None
self.quit_requested = False
self.log_buffer: deque[str] = deque(maxlen=4)
self._log_handler: Optional[logging.Handler] = None
self._previous_handlers: list[logging.Handler] = []
self._logger = logging.getLogger("reactor_sim")
self.keys = [
DashboardKey("q", "Quit & save"),
DashboardKey("space", "SCRAM"),
DashboardKey("p", "Toggle primary pump"),
DashboardKey("o", "Toggle secondary pump"),
DashboardKey("t", "Toggle turbine"),
DashboardKey("c", "Toggle consumer"),
DashboardKey("+/-", "Withdraw/insert rods"),
DashboardKey("[/]", "Adjust consumer demand /+50 MW"),
DashboardKey("s", "Setpoint 250 MW"),
DashboardKey("d", "Setpoint +250 MW"),
DashboardKey("a", "Toggle auto rod control"),
]
def run(self) -> None:
curses.wrapper(self._main)
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._install_log_capture()
self.sim = ReactorSimulation(
self.reactor,
timestep=self.timestep,
duration=None,
realtime=True,
command_provider=self._next_command,
)
self.sim.start_state = self.start_state
try:
for state in self.sim.run():
self._draw(stdscr, state)
self._handle_input(stdscr)
if self.quit_requested:
self.sim.stop()
break
finally:
if self.save_path and self.sim and self.sim.last_state:
self.reactor.save_state(self.save_path, self.sim.last_state)
self._restore_logging()
def _handle_input(self, stdscr: "curses._CursesWindow") -> None:
while True:
ch = stdscr.getch()
if ch == -1:
break
if ch in (ord("q"), ord("Q")):
self.quit_requested = True
return
if ch == ord(" "):
self._queue_command(ReactorCommand.scram_all())
elif ch in (ord("p"), ord("P")):
self._queue_command(ReactorCommand(primary_pump_on=not self.reactor.primary_pump_active))
elif ch in (ord("o"), ord("O")):
self._queue_command(ReactorCommand(secondary_pump_on=not self.reactor.secondary_pump_active))
elif ch in (ord("t"), ord("T")):
self._queue_command(ReactorCommand(turbine_on=not self.reactor.turbine_active))
elif ch in (ord("+"), ord("=")):
self._queue_command(ReactorCommand(rod_position=self._clamped_rod(-0.05)))
elif ch == ord("-"):
self._queue_command(ReactorCommand(rod_position=self._clamped_rod(0.05)))
elif ch == ord("["):
demand = self._current_demand() - 50.0
self._queue_command(ReactorCommand(consumer_demand=max(0.0, demand)))
elif ch == ord("]"):
demand = self._current_demand() + 50.0
self._queue_command(ReactorCommand(consumer_demand=demand))
elif ch in (ord("c"), ord("C")):
online = not (self.reactor.consumer.online if self.reactor.consumer else False)
self._queue_command(ReactorCommand(consumer_online=online))
elif ch in (ord("s"), ord("S")):
self._queue_command(ReactorCommand(power_setpoint=self.reactor.control.setpoint_mw - 250.0))
elif ch in (ord("d"), ord("D")):
self._queue_command(ReactorCommand(power_setpoint=self.reactor.control.setpoint_mw + 250.0))
elif ch in (ord("a"), ord("A")):
self._queue_command(ReactorCommand(rod_manual=not self.reactor.control.manual_control))
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)
if self.pending_command.rod_position is not None and self.pending_command.rod_manual is None:
self.pending_command.rod_manual = True
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()
height, width = stdscr.getmaxyx()
if height < 24 or width < 90:
stdscr.addstr(
0,
0,
"Terminal window too small. Resize to at least 90x24.".ljust(width),
curses.color_pair(4),
)
stdscr.refresh()
return
data_height = height - 6
right_width = max(32, width // 3)
left_width = width - right_width
if left_width < 60:
left_width = min(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",
[
("Fuel Temp", f"{state.core.fuel_temperature:8.1f} K"),
("Core Power", f"{state.core.power_output_mw:8.1f} MW"),
("Neutron Flux", f"{state.core.neutron_flux:10.2e}"),
("Rods", f"{self.reactor.control.rod_fraction:.3f}"),
("Setpoint", f"{self.reactor.control.setpoint_mw:7.0f} MW"),
("Reactivity", f"{state.core.reactivity_margin:+.4f}"),
],
)
y = self._draw_section(
win,
y,
"Primary Loop",
[
("Pump", "ON" if self.reactor.primary_pump_active else "OFF"),
("Flow", f"{state.primary_loop.mass_flow_rate:7.0f} kg/s"),
("Inlet Temp", f"{state.primary_loop.temperature_in:7.1f} K"),
("Outlet Temp", f"{state.primary_loop.temperature_out:7.1f} K"),
("Pressure", f"{state.primary_loop.pressure:5.2f} MPa"),
],
)
y = self._draw_section(
win,
y,
"Secondary Loop",
[
("Pump", "ON" if self.reactor.secondary_pump_active else "OFF"),
("Flow", f"{state.secondary_loop.mass_flow_rate:7.0f} kg/s"),
("Inlet Temp", f"{state.secondary_loop.temperature_in:7.1f} K"),
("Pressure", f"{state.secondary_loop.pressure:5.2f} MPa"),
("Steam Quality", f"{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",
[
("Turbine", "ON" if self.reactor.turbine_active else "OFF"),
("Electrical", f"{state.turbine.electrical_output_mw:7.1f} MW"),
("Load", f"{state.turbine.load_supplied_mw:7.1f}/{state.turbine.load_demand_mw:7.1f} MW"),
("Consumer", f"{consumer_status}"),
("Demand", f"{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,
)
log_y = 3
for record in list(self.log_buffer):
if log_y >= win.getmaxyx()[0] - 1:
break
win.addstr(log_y, 1, record[: win.getmaxyx()[1] - 2], curses.color_pair(2))
log_y += 1
win.addstr(log_y, 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[tuple[str, str] | str],
) -> int:
height, width = win.getmaxyx()
inner_width = width - 4
if start_y >= height - 2:
return height - 2
win.addstr(start_y, 2, title[:inner_width], curses.A_BOLD | curses.color_pair(1))
row = start_y + 1
for line in lines:
if row >= height - 1:
break
if isinstance(line, tuple):
label, value = line
text = f"{label:<18}: {value}"
else:
text = line
win.addstr(row, 4, text[:inner_width])
row += 1
return row + 1
def _draw_health_bar(self, win: "curses._CursesWindow", start_y: int) -> None:
height, width = win.getmaxyx()
if start_y >= height - 2:
return
win.addstr(start_y, 2, "Component Health", curses.A_BOLD | curses.color_pair(1))
bar_width = max(10, min(width - 28, 40))
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
row = start_y + idx
if row >= height - 1:
break
label = f"{name:<12}:"
win.addstr(row, 4, label[:14], curses.A_BOLD)
bar_start = 4 + max(len(label), 14) + 1
win.addstr(row, bar_start, bar[:bar_width], curses.color_pair(color))
percent_col = min(width - 8, bar_start + bar_width + 2)
win.addstr(row, percent_col, 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
return 0.0
def _clamped_rod(self, delta: float) -> float:
new_fraction = self.reactor.control.rod_fraction + delta
return max(0.0, min(0.95, new_fraction))
def _install_log_capture(self) -> None:
if self._log_handler:
return
self._previous_handlers = list(self._logger.handlers)
for handler in self._previous_handlers:
self._logger.removeHandler(handler)
handler = _DashboardLogHandler(self.log_buffer)
handler.setFormatter(logging.Formatter("%(levelname)s | %(message)s"))
self._logger.addHandler(handler)
self._logger.propagate = False
self._log_handler = handler
def _restore_logging(self) -> None:
if not self._log_handler:
return
self._logger.removeHandler(self._log_handler)
for handler in self._previous_handlers:
self._logger.addHandler(handler)
self._log_handler = None
self._previous_handlers = []
class _DashboardLogHandler(logging.Handler):
def __init__(self, buffer: deque[str]) -> None:
super().__init__()
self.buffer = buffer
def emit(self, record: logging.LogRecord) -> None:
msg = self.format(record)
self.buffer.append(msg)

View File

@@ -42,7 +42,10 @@ class FuelAssembly:
event = self.simulate_electron_hit()
effective_flux = max(0.0, flux * max(0.0, 1.0 - control_fraction))
atoms = self.mass_kg / self.fissile_atom.atomic_mass_kg
event_rate = effective_flux * constants.ELECTRON_FISSION_CROSS_SECTION * atoms * self.enrichment
event_rate = max(
0.0,
effective_flux * constants.ELECTRON_FISSION_CROSS_SECTION * atoms * self.enrichment,
)
power_watts = event_rate * event.energy_mev * constants.MEV_TO_J
power_mw = power_watts / constants.MEGAWATT
LOGGER.debug(
@@ -53,4 +56,4 @@ class FuelAssembly:
event.products[1].mass_number,
power_mw,
)
return max(0.0, power_mw), event
return max(0.0, power_mw), event_rate, event

View File

@@ -102,7 +102,9 @@ class Reactor:
self.neutronics.step(state.core, rod_fraction, dt)
prompt_power, fission_event = self.fuel.prompt_energy_rate(state.core.neutron_flux, rod_fraction)
prompt_power, fission_rate, fission_event = self.fuel.prompt_energy_rate(
state.core.neutron_flux, rod_fraction
)
decay_power = decay_heat_fraction(state.core.burnup) * state.core.power_output_mw
total_power = prompt_power + decay_power
state.core.power_output_mw = total_power
@@ -125,7 +127,7 @@ class Reactor:
self.thermal.step_secondary(state.secondary_loop, transferred)
if self.turbine_active:
self.turbine.step(state.secondary_loop, state.turbine, self.consumer)
self.turbine.step(state.secondary_loop, state.turbine, self.consumer, steam_power_mw=transferred)
else:
state.turbine.shaft_power_mw = 0.0
state.turbine.electrical_output_mw = 0.0
@@ -147,16 +149,13 @@ class Reactor:
LOGGER.info(
(
"t=%5.1fs rods=%.2f core_power=%.1fMW prompt=%.1fMW :: "
"%s-%d + %s-%d, outlet %.1fK, electrical %.1fMW load %.1f/%.1fMW"
"fissions %.2e/s, outlet %.1fK, electrical %.1fMW load %.1f/%.1fMW"
),
state.time_elapsed,
rod_fraction,
total_power,
prompt_power,
fission_event.products[0].symbol,
fission_event.products[0].mass_number,
fission_event.products[1].symbol,
fission_event.products[1].mass_number,
fission_rate,
state.primary_loop.temperature_out,
state.turbine.electrical_output_mw,
state.turbine.load_supplied_mw,
@@ -183,7 +182,10 @@ class Reactor:
self._set_turbine_state(False)
if command.power_setpoint is not None:
self.control.set_power_setpoint(command.power_setpoint)
if command.rod_manual is not None:
self.control.set_manual_mode(command.rod_manual)
if command.rod_position is not None:
self.control.set_manual_mode(True)
overrides["rod_fraction"] = self.control.set_rods(command.rod_position)
self.shutdown = self.shutdown or command.rod_position >= 0.95
elif command.rod_step is not None:

View File

@@ -72,11 +72,29 @@ 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")
if load_path:
sim.start_state = reactor.load_state(load_path)
if dashboard_mode:
from .dashboard import ReactorDashboard
dashboard = ReactorDashboard(
reactor,
start_state=sim.start_state,
timestep=sim.timestep,
save_path=save_path,
)
dashboard.run()
return
try:
if realtime:
LOGGER.info("Running in real-time mode (Ctrl+C to stop)...")

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
import math
from . import constants
from .state import CoolantLoopState, CoreState
@@ -14,8 +16,9 @@ LOGGER = logging.getLogger(__name__)
def heat_transfer(primary: CoolantLoopState, secondary: CoolantLoopState, core_power_mw: float) -> float:
"""Return MW transferred to the secondary loop."""
delta_t = max(0.0, primary.temperature_out - secondary.temperature_in)
conductance = 0.05 # steam generator effectiveness
transferred = min(core_power_mw, conductance * delta_t)
conductance = 0.15 # steam generator effectiveness
efficiency = 1.0 - math.exp(-conductance * delta_t)
transferred = min(core_power_mw, core_power_mw * efficiency)
LOGGER.debug("Heat transfer %.2f MW with ΔT=%.1fK", transferred, delta_t)
return transferred

View File

@@ -34,10 +34,12 @@ class Turbine:
loop: CoolantLoopState,
state: TurbineState,
consumer: Optional[ElectricalConsumer] = None,
steam_power_mw: float = 0.0,
) -> None:
enthalpy = 2_700.0 + loop.steam_quality * 600.0
mass_flow = loop.mass_flow_rate * 0.6
shaft_power_mw = (enthalpy * mass_flow / 1_000.0) * self.mechanical_efficiency / 1_000.0
available_power = max(steam_power_mw, (enthalpy * mass_flow / 1_000.0) / 1_000.0)
shaft_power_mw = available_power * self.mechanical_efficiency
electrical = shaft_power_mw * self.generator_efficiency
if consumer:
load_demand = consumer.request_power()