feat: add reactor control persistence and tests
This commit is contained in:
98
src/reactor_sim/simulation.py
Normal file
98
src/reactor_sim/simulation.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Reactor simulation harness and CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from threading import Event
|
||||
import time
|
||||
from typing import Callable, Iterable, Optional
|
||||
|
||||
from .commands import ReactorCommand
|
||||
from .logging_utils import configure_logging
|
||||
from .reactor import Reactor
|
||||
from .state import PlantState
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CommandProvider = Callable[[float, PlantState], Optional[ReactorCommand]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReactorSimulation:
|
||||
reactor: Reactor
|
||||
timestep: float = 1.0
|
||||
duration: float | None = 3600.0
|
||||
command_provider: CommandProvider | None = None
|
||||
realtime: bool = False
|
||||
stop_event: Event = field(default_factory=Event)
|
||||
start_state: PlantState | None = None
|
||||
last_state: PlantState | None = field(default=None, init=False)
|
||||
|
||||
def run(self) -> Iterable[PlantState]:
|
||||
state = copy.deepcopy(self.start_state) if self.start_state else self.reactor.initial_state()
|
||||
elapsed = 0.0
|
||||
last_step_wall = time.time()
|
||||
while self.duration is None or elapsed < self.duration:
|
||||
if self.stop_event.is_set():
|
||||
LOGGER.info("Stop signal received, terminating simulation loop")
|
||||
break
|
||||
snapshot = copy.deepcopy(state)
|
||||
yield snapshot
|
||||
command = self.command_provider(elapsed, snapshot) if self.command_provider else None
|
||||
self.reactor.step(state, self.timestep, command)
|
||||
elapsed += self.timestep
|
||||
if self.realtime:
|
||||
wall_elapsed = time.time() - last_step_wall
|
||||
sleep_time = self.timestep - wall_elapsed
|
||||
if sleep_time > 0:
|
||||
time.sleep(sleep_time)
|
||||
last_step_wall = time.time()
|
||||
self.last_state = state
|
||||
LOGGER.info("Simulation complete, %.0fs simulated", elapsed)
|
||||
|
||||
def log(self) -> list[dict[str, float]]:
|
||||
return [snapshot for snapshot in (s.snapshot() for s in self.run())]
|
||||
|
||||
def stop(self) -> None:
|
||||
self.stop_event.set()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
log_level = os.getenv("FISSION_LOG_LEVEL", "INFO")
|
||||
log_file = os.getenv("FISSION_LOG_FILE")
|
||||
configure_logging(log_level, log_file)
|
||||
realtime = os.getenv("FISSION_REALTIME", "0") == "1"
|
||||
duration_env = os.getenv("FISSION_SIM_DURATION")
|
||||
if duration_env:
|
||||
duration = None if duration_env.lower() in {"none", "infinite"} else float(duration_env)
|
||||
else:
|
||||
duration = None if realtime else 600.0
|
||||
reactor = Reactor.default()
|
||||
sim = ReactorSimulation(reactor, timestep=5.0, 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)
|
||||
try:
|
||||
if realtime:
|
||||
LOGGER.info("Running in real-time mode (Ctrl+C to stop)...")
|
||||
for _ in sim.run():
|
||||
pass
|
||||
else:
|
||||
snapshots = sim.log()
|
||||
LOGGER.info("Captured %d snapshots", len(snapshots))
|
||||
print(json.dumps(snapshots[-5:], indent=2))
|
||||
except KeyboardInterrupt:
|
||||
sim.stop()
|
||||
LOGGER.warning("Simulation interrupted by user")
|
||||
finally:
|
||||
if save_path and sim.last_state:
|
||||
reactor.save_state(save_path, sim.last_state)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user