Compare commits

..

9 Commits

Author SHA1 Message Date
Codex Agent
c2bbadcaf4 Map digits to rod presets; use Shift+1-3 for turbine toggles 2025-11-25 18:07:37 +01:00
Codex Agent
0e2ff1a324 Detect KP_ digit keys for rod presets when NumLock is on 2025-11-25 18:03:42 +01:00
Codex Agent
28af1ec365 Handle common keypad codes for numpad rod presets 2025-11-25 18:01:17 +01:00
Codex Agent
52eeee3a0d Fix numpad rod mapping on terminals without keypad constants 2025-11-25 17:58:56 +01:00
Codex Agent
157212a00d Restore number-row turbine toggles; numpad sets rod presets 2025-11-25 17:58:00 +01:00
Codex Agent
cde6731119 Add numpad rod presets and keypad handling 2025-11-25 17:57:10 +01:00
Codex Agent
27b34d1c71 Map numeric keys to rod presets in dashboard 2025-11-25 17:53:48 +01:00
Codex Agent
0ded2370c9 Guard dashboard help panel writes to avoid curses overflow 2025-11-25 17:49:18 +01:00
Codex Agent
0f54540526 Add enthalpy-based secondary boil-off and turbine mapping 2025-11-25 17:47:37 +01:00
8 changed files with 187 additions and 45 deletions

View File

@@ -8,7 +8,7 @@
- [ ] Flesh out condenser behavior: vacuum pump limits, cooling water temperature coupling, and dynamic back-pressure with fouling. - [ ] 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 polish: compact turbine/generator rows, color critical warnings (SCRAM/heat-sink), and reduce repeated log noise.
- [ ] Incremental realism plan: - [ ] 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. - 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 1516 MPa, 290320 °C inlet/320330 °C outlet, secondary saturation ~67 MPa with boil at ~490510 K.
- Adjust HX/pressure handling to use stored energy (saturation clamp and pressure rise) and validate steam formation with both pumps at ~3 GW. - 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.
- Update turbine power mapping to consume steam enthalpy/quality and align protection trips with real steam presence. - Update turbine power mapping to consume steam enthalpy/quality and align protection trips with real steam presence; drive inlet steam around 67 MPa, quality/enthalpy-based flow to ~550600 MW(e) per machine class if steam is available.
- Add integration test: cold start → gens/pumps 2/2 → ramp to ~3 GW → confirm steam quality threshold → enable all turbines and require electrical output. - Add integration test: cold start → gens/pumps 2/2 → ramp to ~3 GW → confirm steam quality threshold at the secondary drum → enable all turbines and require electrical output. Include a step that tolerates one secondary pump off for a period to prove redundancy still yields steam.

View File

@@ -18,6 +18,40 @@ from .state import PlantState
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def _build_numpad_mapping() -> dict[int, float]:
# Use keypad matrix constants when available; skip missing ones to avoid import errors on some terminals.
mapping: dict[int, float] = {}
table = {
"KEY_C1": 0.1, # numpad 1
"KEY_C2": 0.2, # numpad 2
"KEY_C3": 0.3, # numpad 3
"KEY_B1": 0.4, # numpad 4
"KEY_B2": 0.5, # numpad 5
"KEY_B3": 0.6, # numpad 6
"KEY_A1": 0.7, # numpad 7
"KEY_A2": 0.8, # numpad 8
"KEY_A3": 0.9, # numpad 9
# Common keypad aliases when NumLock is on
"KEY_END": 0.1,
"KEY_DOWN": 0.2,
"KEY_NPAGE": 0.3,
"KEY_LEFT": 0.4,
"KEY_B2": 0.5, # center stays 0.5
"KEY_RIGHT": 0.6,
"KEY_HOME": 0.7,
"KEY_UP": 0.8,
"KEY_PPAGE": 0.9,
}
for name, value in table.items():
code = getattr(curses, name, None)
if code is not None:
mapping[code] = value
return mapping
_NUMPAD_ROD_KEYS = _build_numpad_mapping()
@dataclass @dataclass
class DashboardKey: class DashboardKey:
key: str key: str
@@ -57,6 +91,7 @@ class ReactorDashboard:
DashboardKey("r", "Reset & clear state"), DashboardKey("r", "Reset & clear state"),
DashboardKey("a", "Toggle auto rod control"), DashboardKey("a", "Toggle auto rod control"),
DashboardKey("+/-", "Withdraw/insert rods"), DashboardKey("+/-", "Withdraw/insert rods"),
DashboardKey("1-9 / Numpad", "Set rods to 0.1 … 0.9 (manual)"),
DashboardKey("[/]", "Adjust consumer demand /+50 MW"), DashboardKey("[/]", "Adjust consumer demand /+50 MW"),
DashboardKey("s/d", "Setpoint /+250 MW"), DashboardKey("s/d", "Setpoint /+250 MW"),
DashboardKey("p", "Maintain core (shutdown required)"), DashboardKey("p", "Maintain core (shutdown required)"),
@@ -84,7 +119,7 @@ class ReactorDashboard:
"Turbines / Grid", "Turbines / Grid",
[ [
DashboardKey("t", "Toggle turbine bank"), DashboardKey("t", "Toggle turbine bank"),
DashboardKey("1/2/3", "Toggle turbine units 1-3"), DashboardKey("Shift+1/2/3", "Toggle turbine units 1-3"),
DashboardKey("y/u/i", "Maintain turbine 1/2/3"), DashboardKey("y/u/i", "Maintain turbine 1/2/3"),
DashboardKey("c", "Toggle consumer"), DashboardKey("c", "Toggle consumer"),
], ],
@@ -103,6 +138,7 @@ class ReactorDashboard:
curses.init_pair(3, curses.COLOR_GREEN, -1) curses.init_pair(3, curses.COLOR_GREEN, -1)
curses.init_pair(4, curses.COLOR_RED, -1) curses.init_pair(4, curses.COLOR_RED, -1)
stdscr.nodelay(True) stdscr.nodelay(True)
stdscr.keypad(True)
self._install_log_capture() self._install_log_capture()
try: try:
while True: while True:
@@ -143,6 +179,11 @@ class ReactorDashboard:
ch = stdscr.getch() ch = stdscr.getch()
if ch == -1: if ch == -1:
break break
keyname = None
try:
keyname = curses.keyname(ch)
except curses.error:
keyname = None
if ch in (ord("q"), ord("Q")): if ch in (ord("q"), ord("Q")):
self.quit_requested = True self.quit_requested = True
return return
@@ -172,9 +213,20 @@ class ReactorDashboard:
self._queue_command(ReactorCommand(generator_auto=not self.reactor.generator_auto)) self._queue_command(ReactorCommand(generator_auto=not self.reactor.generator_auto))
elif ch in (ord("t"), ord("T")): elif ch in (ord("t"), ord("T")):
self._queue_command(ReactorCommand(turbine_on=not self.reactor.turbine_active)) self._queue_command(ReactorCommand(turbine_on=not self.reactor.turbine_active))
elif keyname and keyname.startswith(b"KP_") and keyname[-1:] in b"123456789":
target = (keyname[-1] - ord("0")) / 10.0 # type: ignore[arg-type]
self._queue_command(ReactorCommand(rod_position=target, rod_manual=True))
elif ord("1") <= ch <= ord("9"): elif ord("1") <= ch <= ord("9"):
idx = ch - ord("1") target = (ch - ord("0")) / 10.0
self._queue_command(ReactorCommand(rod_position=target, rod_manual=True))
elif ch in (ord("!"), ord("@"), ord("#")):
idx = ch - ord("!")
self._toggle_turbine_unit(idx) self._toggle_turbine_unit(idx)
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:
target = (ch - curses.KEY_F1 + 1) / 10.0
self._queue_command(ReactorCommand(rod_position=target, rod_manual=True))
elif ch in (ord("+"), ord("=")): elif ch in (ord("+"), ord("=")):
# Insert rods (increase fraction) # Insert rods (increase fraction)
self._queue_command(ReactorCommand(rod_position=self._clamped_rod(constants.ROD_MANUAL_STEP))) self._queue_command(ReactorCommand(rod_position=self._clamped_rod(constants.ROD_MANUAL_STEP)))
@@ -457,18 +509,32 @@ class ReactorDashboard:
self._draw_health_bars(right_win, right_y) self._draw_health_bars(right_win, right_y)
def _draw_help_panel(self, win: "curses._CursesWindow") -> None: def _draw_help_panel(self, win: "curses._CursesWindow") -> None:
def _add_safe(row: int, col: int, text: str, attr: int = 0) -> bool:
max_y, max_x = win.getmaxyx()
if row >= max_y - 1 or col >= max_x - 1:
return False
clipped = text[: max(0, max_x - col - 1)]
try:
win.addstr(row, col, clipped, attr)
except curses.error:
return False
return True
win.erase() win.erase()
win.box() win.box()
win.addstr(0, 2, " Controls ", curses.color_pair(1) | curses.A_BOLD) _add_safe(0, 2, " Controls ", curses.color_pair(1) | curses.A_BOLD)
y = 2 y = 2
for title, entries in self.help_sections: for title, entries in self.help_sections:
win.addstr(y, 2, title, curses.color_pair(1) | curses.A_BOLD) if not _add_safe(y, 2, title, curses.color_pair(1) | curses.A_BOLD):
return
y += 1 y += 1
for entry in entries: for entry in entries:
win.addstr(y, 4, f"{entry.key:<8} {entry.description}") if not _add_safe(y, 4, f"{entry.key:<8} {entry.description}"):
return
y += 1 y += 1
y += 1 y += 1
win.addstr(y, 2, "Tips:", curses.color_pair(2) | curses.A_BOLD) if not _add_safe(y, 2, "Tips:", curses.color_pair(2) | curses.A_BOLD):
return
tips = [ tips = [
"Start pumps before withdrawing rods.", "Start pumps before withdrawing rods.",
"Bring turbine and consumer online after thermal stabilization.", "Bring turbine and consumer online after thermal stabilization.",
@@ -478,7 +544,8 @@ class ReactorDashboard:
"Watch component health to avoid automatic trips.", "Watch component health to avoid automatic trips.",
] ]
for idx, tip in enumerate(tips, start=y + 2): for idx, tip in enumerate(tips, start=y + 2):
win.addstr(idx, 4, f"- {tip}") if not _add_safe(idx, 4, f"- {tip}"):
break
def _draw_status_panel(self, win: "curses._CursesWindow", state: PlantState) -> None: def _draw_status_panel(self, win: "curses._CursesWindow", state: PlantState) -> None:
win.erase() win.erase()

View File

@@ -16,7 +16,7 @@ from .fuel import FuelAssembly, decay_heat_fraction
from .generator import DieselGenerator, GeneratorState from .generator import DieselGenerator, GeneratorState
from .neutronics import NeutronDynamics from .neutronics import NeutronDynamics
from .state import CoolantLoopState, CoreState, PlantState, PumpState, TurbineState from .state import CoolantLoopState, CoreState, PlantState, PumpState, TurbineState
from .thermal import ThermalSolver, heat_transfer, saturation_pressure, temperature_rise from .thermal import ThermalSolver, heat_transfer, saturation_pressure, saturation_temperature, temperature_rise
from .turbine import SteamGenerator, Turbine from .turbine import SteamGenerator, Turbine
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -126,6 +126,7 @@ class Reactor:
steam_quality=0.0, steam_quality=0.0,
inventory_kg=primary_nominal_mass * constants.PRIMARY_INVENTORY_TARGET, inventory_kg=primary_nominal_mass * constants.PRIMARY_INVENTORY_TARGET,
level=constants.PRIMARY_INVENTORY_TARGET, level=constants.PRIMARY_INVENTORY_TARGET,
energy_j=primary_nominal_mass * constants.PRIMARY_INVENTORY_TARGET * constants.COOLANT_HEAT_CAPACITY * ambient,
) )
secondary = CoolantLoopState( secondary = CoolantLoopState(
temperature_in=ambient, temperature_in=ambient,
@@ -135,6 +136,7 @@ class Reactor:
steam_quality=0.0, steam_quality=0.0,
inventory_kg=secondary_nominal_mass * constants.SECONDARY_INVENTORY_TARGET, inventory_kg=secondary_nominal_mass * constants.SECONDARY_INVENTORY_TARGET,
level=constants.SECONDARY_INVENTORY_TARGET, level=constants.SECONDARY_INVENTORY_TARGET,
energy_j=secondary_nominal_mass * constants.SECONDARY_INVENTORY_TARGET * constants.COOLANT_HEAT_CAPACITY * ambient,
) )
primary_pumps = [ primary_pumps = [
PumpState(active=self.primary_pump_active and self.primary_pump_units[idx], flow_rate=0.0, pressure=0.5) PumpState(active=self.primary_pump_active and self.primary_pump_units[idx], flow_rate=0.0, pressure=0.5)
@@ -434,6 +436,17 @@ class Reactor:
cooling_drop = min(40.0, max(10.0, 0.2 * excess)) cooling_drop = min(40.0, max(10.0, 0.2 * excess))
state.secondary_loop.temperature_in = max(env, state.secondary_loop.temperature_out - cooling_drop) state.secondary_loop.temperature_in = max(env, state.secondary_loop.temperature_out - cooling_drop)
# Keep stored energies consistent with updated temperatures/quality.
cp = constants.COOLANT_HEAT_CAPACITY
primary_avg = 0.5 * (state.primary_loop.temperature_in + state.primary_loop.temperature_out)
state.primary_loop.energy_j = max(0.0, state.primary_loop.inventory_kg * cp * primary_avg)
sat_temp_sec = saturation_temperature(max(0.05, state.secondary_loop.pressure))
sec_liquid_energy = state.secondary_loop.inventory_kg * cp * min(state.secondary_loop.temperature_out, sat_temp_sec)
sec_latent = state.secondary_loop.inventory_kg * state.secondary_loop.steam_quality * constants.STEAM_LATENT_HEAT
superheat = max(0.0, state.secondary_loop.temperature_out - sat_temp_sec)
sec_superheat = state.secondary_loop.inventory_kg * cp * superheat if state.secondary_loop.steam_quality >= 1.0 else 0.0
state.secondary_loop.energy_j = max(0.0, sec_liquid_energy + sec_latent + sec_superheat)
state.primary_to_secondary_delta_t = max(0.0, state.primary_loop.temperature_out - state.secondary_loop.temperature_in) state.primary_to_secondary_delta_t = max(0.0, state.primary_loop.temperature_out - state.secondary_loop.temperature_in)
state.heat_exchanger_efficiency = 0.0 if total_power <= 0 else min(1.0, max(0.0, transferred / max(1e-6, total_power))) state.heat_exchanger_efficiency = 0.0 if total_power <= 0 else min(1.0, max(0.0, transferred / max(1e-6, total_power)))
@@ -578,7 +591,13 @@ class Reactor:
if loop.mass_flow_rate <= 0.0 or loop.steam_quality <= 0.0: if loop.mass_flow_rate <= 0.0 or loop.steam_quality <= 0.0:
return return
steam_mass = loop.mass_flow_rate * loop.steam_quality * constants.SECONDARY_STEAM_LOSS_FRACTION * dt steam_mass = loop.mass_flow_rate * loop.steam_quality * constants.SECONDARY_STEAM_LOSS_FRACTION * dt
if steam_mass <= 0.0:
return
prev_mass = max(1e-6, loop.inventory_kg)
loop.inventory_kg = max(0.0, loop.inventory_kg - steam_mass) loop.inventory_kg = max(0.0, loop.inventory_kg - steam_mass)
# Scale stored energy with the remaining mass to keep specific enthalpy consistent.
ratio = max(0.0, loop.inventory_kg) / prev_mass
loop.energy_j *= ratio
nominal = self._nominal_inventory(constants.SECONDARY_LOOP_VOLUME_M3) nominal = self._nominal_inventory(constants.SECONDARY_LOOP_VOLUME_M3)
loop.level = min(1.2, max(0.0, loop.inventory_kg / nominal)) if nominal > 0 else 0.0 loop.level = min(1.2, max(0.0, loop.inventory_kg / nominal)) if nominal > 0 else 0.0

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from . import constants
from .generator import GeneratorState from .generator import GeneratorState
@@ -51,6 +53,7 @@ class CoolantLoopState:
steam_quality: float # fraction of vapor steam_quality: float # fraction of vapor
inventory_kg: float = 0.0 # bulk mass of coolant inventory_kg: float = 0.0 # bulk mass of coolant
level: float = 1.0 # fraction full relative to nominal volume level: float = 1.0 # fraction full relative to nominal volume
energy_j: float = 0.0 # stored thermal/latent energy for the loop
def average_temperature(self) -> float: def average_temperature(self) -> float:
return 0.5 * (self.temperature_in + self.temperature_out) return 0.5 * (self.temperature_in + self.temperature_out)
@@ -135,8 +138,8 @@ class PlantState:
delta_t = data.get("primary_to_secondary_delta_t", 0.0) delta_t = data.get("primary_to_secondary_delta_t", 0.0)
return cls( return cls(
core=CoreState(**core_blob, fission_product_inventory=inventory, emitted_particles=particles), core=CoreState(**core_blob, fission_product_inventory=inventory, emitted_particles=particles),
primary_loop=CoolantLoopState(**data["primary_loop"]), primary_loop=CoolantLoopState(**_with_energy(data["primary_loop"])),
secondary_loop=CoolantLoopState(**data["secondary_loop"]), secondary_loop=CoolantLoopState(**_with_energy(data["secondary_loop"])),
turbines=turbines, turbines=turbines,
primary_pumps=[PumpState(**p) for p in prim_pumps_blob], primary_pumps=[PumpState(**p) for p in prim_pumps_blob],
secondary_pumps=[PumpState(**p) for p in sec_pumps_blob], secondary_pumps=[PumpState(**p) for p in sec_pumps_blob],
@@ -146,3 +149,14 @@ class PlantState:
primary_to_secondary_delta_t=delta_t, primary_to_secondary_delta_t=delta_t,
time_elapsed=data.get("time_elapsed", 0.0), time_elapsed=data.get("time_elapsed", 0.0),
) )
def _with_energy(loop_blob: dict) -> dict:
"""Backwards compatibility: derive energy if missing."""
if "energy_j" in loop_blob:
return loop_blob
energy = 0.5 * (loop_blob.get("temperature_in", 295.0) + loop_blob.get("temperature_out", 295.0))
energy *= loop_blob.get("inventory_kg", 0.0) * constants.COOLANT_HEAT_CAPACITY
out = dict(loop_blob)
out["energy_j"] = energy
return out

View File

@@ -95,6 +95,10 @@ class ThermalSolver:
core.fuel_temperature += heating - cooling core.fuel_temperature += heating - cooling
# Keep fuel temperature bounded and never below the coolant outlet temperature. # Keep fuel temperature bounded and never below the coolant outlet temperature.
core.fuel_temperature = min(max(primary.temperature_out, core.fuel_temperature), constants.MAX_CORE_TEMPERATURE) core.fuel_temperature = min(max(primary.temperature_out, core.fuel_temperature), constants.MAX_CORE_TEMPERATURE)
avg_temp = 0.5 * (primary.temperature_in + primary.temperature_out)
primary.energy_j = max(
0.0, primary.inventory_kg * constants.COOLANT_HEAT_CAPACITY * avg_temp
)
LOGGER.debug( LOGGER.debug(
"Primary loop: flow=%.0f kg/s temp_out=%.1fK core_temp=%.1fK", "Primary loop: flow=%.0f kg/s temp_out=%.1fK core_temp=%.1fK",
primary.mass_flow_rate, primary.mass_flow_rate,
@@ -103,42 +107,47 @@ class ThermalSolver:
) )
def step_secondary(self, secondary: CoolantLoopState, transferred_mw: float, dt: float = 1.0) -> None: def step_secondary(self, secondary: CoolantLoopState, transferred_mw: float, dt: float = 1.0) -> None:
"""Update secondary loop using a simple steam-drum mass/energy balance.""" """Update secondary loop using a stored-energy steam-drum balance."""
if transferred_mw <= 0.0 or secondary.mass_flow_rate <= 0.0:
secondary.steam_quality = max(0.0, secondary.steam_quality - 0.02 * dt)
secondary.temperature_out = max(
constants.ENVIRONMENT_TEMPERATURE, secondary.temperature_out - 0.5 * dt
)
secondary.pressure = max(
0.1, min(constants.MAX_PRESSURE, saturation_pressure(secondary.temperature_out))
)
return
temp_in = secondary.temperature_in
mass_flow = secondary.mass_flow_rate
cp = constants.COOLANT_HEAT_CAPACITY cp = constants.COOLANT_HEAT_CAPACITY
sat_temp = saturation_temperature(max(0.05, secondary.pressure)) mass = max(1e-6, secondary.inventory_kg)
energy_j = max(0.0, transferred_mw) * constants.MEGAWATT * dt if secondary.energy_j <= 0.0:
secondary.energy_j = mass * cp * secondary.average_temperature()
# Energy needed to heat incoming feed to saturation. # Add transferred heat; if no heat, bleed toward ambient.
sensible_j = max(0.0, sat_temp - temp_in) * mass_flow * cp * dt if transferred_mw > 0.0:
if energy_j <= sensible_j: secondary.energy_j += transferred_mw * constants.MEGAWATT * dt
delta_t = temperature_rise(transferred_mw, mass_flow)
secondary.temperature_out = temp_in + delta_t
secondary.steam_quality = 0.0
else: else:
energy_left = energy_j - sensible_j bleed = 0.01 * (secondary.temperature_out - constants.ENVIRONMENT_TEMPERATURE)
steam_mass = energy_left / constants.STEAM_LATENT_HEAT secondary.energy_j = max(
produced_fraction = steam_mass / max(1e-6, mass_flow * dt) mass * cp * constants.ENVIRONMENT_TEMPERATURE, secondary.energy_j - max(0.0, bleed) * mass * cp * dt
secondary.temperature_out = sat_temp )
secondary.steam_quality = min(1.0, max(0.0, produced_fraction))
sat_temp = saturation_temperature(max(0.05, secondary.pressure))
liquid_energy = mass * cp * sat_temp
available = secondary.energy_j
if available <= liquid_energy:
# Subcooled or saturated liquid.
temp = available / (mass * cp)
secondary.temperature_out = max(temp, constants.ENVIRONMENT_TEMPERATURE)
secondary.steam_quality = max(0.0, secondary.steam_quality - 0.01 * dt)
else:
excess = available - liquid_energy
quality = min(1.0, excess / (mass * constants.STEAM_LATENT_HEAT))
superheat_energy = max(0.0, excess - quality * mass * constants.STEAM_LATENT_HEAT)
superheat_temp = superheat_energy / (mass * cp) if quality >= 1.0 else 0.0
secondary.temperature_out = sat_temp + superheat_temp
secondary.steam_quality = quality
# Re-normalize stored energy to the realized state.
secondary.energy_j = liquid_energy + quality * mass * constants.STEAM_LATENT_HEAT + superheat_energy
secondary.pressure = min( secondary.pressure = min(
constants.MAX_PRESSURE, max(secondary.pressure, saturation_pressure(secondary.temperature_out)) constants.MAX_PRESSURE, max(secondary.pressure, saturation_pressure(secondary.temperature_out))
) )
LOGGER.debug( LOGGER.debug(
"Secondary loop: transferred=%.1fMW temp_out=%.1fK quality=%.2f", "Secondary loop: transferred=%.1fMW temp_out=%.1fK quality=%.2f energy=%.1eJ",
transferred_mw, transferred_mw,
secondary.temperature_out, secondary.temperature_out,
secondary.steam_quality, secondary.steam_quality,
secondary.energy_j,
) )

View File

@@ -6,6 +6,7 @@ from dataclasses import dataclass
import logging import logging
from . import constants from . import constants
from .thermal import saturation_temperature
from .state import CoolantLoopState, TurbineState from .state import CoolantLoopState, TurbineState
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -50,10 +51,13 @@ class Turbine:
throttle = min(constants.TURBINE_THROTTLE_MAX, max(constants.TURBINE_THROTTLE_MIN, self.throttle)) throttle = min(constants.TURBINE_THROTTLE_MAX, max(constants.TURBINE_THROTTLE_MIN, self.throttle))
throttle_eff = 1.0 - constants.TURBINE_THROTTLE_EFFICIENCY_DROP * (constants.TURBINE_THROTTLE_MAX - throttle) throttle_eff = 1.0 - constants.TURBINE_THROTTLE_EFFICIENCY_DROP * (constants.TURBINE_THROTTLE_MAX - throttle)
enthalpy = 2_700.0 + loop.steam_quality * 600.0 sat_temp = saturation_temperature(max(0.05, loop.pressure))
superheat = max(0.0, loop.temperature_out - sat_temp)
enthalpy = (constants.STEAM_LATENT_HEAT / 1_000.0) + (superheat * constants.COOLANT_HEAT_CAPACITY / 1_000.0)
mass_flow = effective_mass_flow * 0.6 * throttle mass_flow = effective_mass_flow * 0.6 * throttle
computed_power = (enthalpy * mass_flow / 1_000.0) / 1_000.0 computed_power = (enthalpy * mass_flow) / 1_000.0 # MW from enthalpy flow
available_power = steam_power_mw if steam_power_mw > 0 else computed_power available_power = steam_power_mw if steam_power_mw > 0 else computed_power
available_power = min(available_power, computed_power)
backpressure_loss = 1.0 - _backpressure_penalty(loop) backpressure_loss = 1.0 - _backpressure_penalty(loop)
shaft_power_mw = available_power * self.mechanical_efficiency * throttle_eff * backpressure_loss shaft_power_mw = available_power * self.mechanical_efficiency * throttle_eff * backpressure_loss
electrical = shaft_power_mw * self.generator_efficiency electrical = shaft_power_mw * self.generator_efficiency

View File

@@ -266,3 +266,31 @@ def test_auto_control_resets_shutdown_and_moves_rods():
assert reactor.shutdown is False assert reactor.shutdown is False
assert reactor.control.manual_control is False assert reactor.control.manual_control is False
assert reactor.control.rod_fraction < 0.95 assert reactor.control.rod_fraction < 0.95
def test_full_power_reaches_steam_and_turbine_output():
"""Integration: cold start -> pumps/gens on -> ramp to ~3 GW -> steam -> turbines online."""
reactor = Reactor.default()
state = reactor.initial_state()
reactor.step(
state,
dt=1.0,
command=ReactorCommand(
generator_units={1: True, 2: True},
primary_pumps={1: True, 2: True},
secondary_pumps={1: True, 2: True},
rod_manual=False,
),
)
for i in range(600):
cmd = None
if i == 200:
cmd = ReactorCommand(secondary_pumps={2: False})
if i == 300:
cmd = ReactorCommand(secondary_pumps={2: True})
if i == 400:
cmd = ReactorCommand(turbine_on=True, turbine_units={1: True, 2: True, 3: True})
reactor.step(state, dt=1.0, command=cmd)
assert state.secondary_loop.steam_quality > 0.02
assert state.total_electrical_output() > 50.0

View File

@@ -30,8 +30,9 @@ def test_secondary_heats_to_saturation_before_boiling():
def test_secondary_generates_steam_when_energy_exceeds_sensible_heat(): def test_secondary_generates_steam_when_energy_exceeds_sensible_heat():
solver = ThermalSolver() solver = ThermalSolver()
loop = _secondary_loop(temp_in=330.0, flow=180.0, pressure=0.5) loop = _secondary_loop(temp_in=330.0, flow=180.0, pressure=0.5)
loop.inventory_kg *= 0.1 # reduce mass to let boil-up happen quickly
sat_temp = saturation_temperature(loop.pressure) sat_temp = saturation_temperature(loop.pressure)
solver.step_secondary(loop, transferred_mw=120.0, dt=1.0) solver.step_secondary(loop, transferred_mw=120.0, dt=100.0)
assert loop.temperature_out == pytest.approx(sat_temp, rel=0.02) assert loop.temperature_out == pytest.approx(sat_temp, rel=0.05)
assert loop.steam_quality > 0.0 assert loop.steam_quality > 0.0
assert loop.steam_quality < 1.0 assert loop.steam_quality < 1.0