From cc7fba4e7a82c9dbc649effd7b6e97597abb6893 Mon Sep 17 00:00:00 2001 From: Andrii Prokhorov Date: Fri, 21 Nov 2025 17:11:00 +0200 Subject: [PATCH] feat: add reactor control persistence and tests --- .gitignore | 2 + AGENTS.md | 28 ++ pyproject.toml | 16 ++ run_simulation.py | 6 + src/fission_sim.egg-info/PKG-INFO | 8 + src/fission_sim.egg-info/SOURCES.txt | 23 ++ src/fission_sim.egg-info/dependency_links.txt | 1 + src/fission_sim.egg-info/requires.txt | 3 + src/fission_sim.egg-info/top_level.txt | 1 + src/reactor_sim/__init__.py | 28 ++ .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 850 bytes .../__pycache__/atomic.cpython-310.pyc | Bin 0 -> 4324 bytes .../__pycache__/commands.cpython-310.pyc | Bin 0 -> 1206 bytes .../__pycache__/constants.cpython-310.pyc | Bin 0 -> 797 bytes .../__pycache__/consumer.cpython-310.pyc | Bin 0 -> 1704 bytes .../__pycache__/control.cpython-310.pyc | Bin 0 -> 3891 bytes .../__pycache__/coolant.cpython-310.pyc | Bin 0 -> 1225 bytes .../__pycache__/failures.cpython-310.pyc | Bin 0 -> 4154 bytes .../__pycache__/fuel.cpython-310.pyc | Bin 0 -> 2792 bytes .../__pycache__/logging_utils.cpython-310.pyc | Bin 0 -> 1406 bytes .../__pycache__/neutronics.cpython-310.pyc | Bin 0 -> 2263 bytes .../__pycache__/reactor.cpython-310.pyc | Bin 0 -> 7825 bytes .../__pycache__/simulation.cpython-310.pyc | Bin 0 -> 3841 bytes .../__pycache__/state.cpython-310.pyc | Bin 0 -> 3163 bytes .../__pycache__/thermal.cpython-310.pyc | Bin 0 -> 2424 bytes .../__pycache__/turbine.cpython-310.pyc | Bin 0 -> 2314 bytes src/reactor_sim/atomic.py | 254 +++++++++++++++++ src/reactor_sim/commands.py | 26 ++ src/reactor_sim/constants.py | 19 ++ src/reactor_sim/consumer.py | 40 +++ src/reactor_sim/control.py | 90 ++++++ src/reactor_sim/coolant.py | 27 ++ src/reactor_sim/failures.py | 110 ++++++++ src/reactor_sim/fuel.py | 56 ++++ src/reactor_sim/logging_utils.py | 38 +++ src/reactor_sim/neutronics.py | 55 ++++ src/reactor_sim/reactor.py | 263 ++++++++++++++++++ src/reactor_sim/simulation.py | 98 +++++++ src/reactor_sim/state.py | 77 +++++ src/reactor_sim/thermal.py | 55 ++++ src/reactor_sim/turbine.py | 69 +++++ ...st_simulation.cpython-310-pytest-9.0.1.pyc | Bin 0 -> 5168 bytes tests/test_simulation.py | 42 +++ 43 files changed, 1435 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 pyproject.toml create mode 100644 run_simulation.py create mode 100644 src/fission_sim.egg-info/PKG-INFO create mode 100644 src/fission_sim.egg-info/SOURCES.txt create mode 100644 src/fission_sim.egg-info/dependency_links.txt create mode 100644 src/fission_sim.egg-info/requires.txt create mode 100644 src/fission_sim.egg-info/top_level.txt create mode 100644 src/reactor_sim/__init__.py create mode 100644 src/reactor_sim/__pycache__/__init__.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/atomic.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/commands.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/constants.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/consumer.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/control.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/coolant.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/failures.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/fuel.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/logging_utils.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/neutronics.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/reactor.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/simulation.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/state.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/thermal.cpython-310.pyc create mode 100644 src/reactor_sim/__pycache__/turbine.cpython-310.pyc create mode 100644 src/reactor_sim/atomic.py create mode 100644 src/reactor_sim/commands.py create mode 100644 src/reactor_sim/constants.py create mode 100644 src/reactor_sim/consumer.py create mode 100644 src/reactor_sim/control.py create mode 100644 src/reactor_sim/coolant.py create mode 100644 src/reactor_sim/failures.py create mode 100644 src/reactor_sim/fuel.py create mode 100644 src/reactor_sim/logging_utils.py create mode 100644 src/reactor_sim/neutronics.py create mode 100644 src/reactor_sim/reactor.py create mode 100644 src/reactor_sim/simulation.py create mode 100644 src/reactor_sim/state.py create mode 100644 src/reactor_sim/thermal.py create mode 100644 src/reactor_sim/turbine.py create mode 100644 tests/__pycache__/test_simulation.cpython-310-pytest-9.0.1.pyc create mode 100644 tests/test_simulation.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a230a78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv/ +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b1bd546 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,28 @@ +# Repository Guidelines + +## Project Structure & Module Organization +All source code lives under `src/reactor_sim`. Submodules map to plant systems: `fuel.py` and `neutronics.py` govern fission power, `thermal.py` and `coolant.py` cover heat transfer and pumps, `turbine.py` drives the steam cycle, and `consumer.py` represents external electrical loads. High-level coordination happens in `reactor.py` and `simulation.py`. The convenience runner `run_simulation.py` executes the default scenario; add notebooks or scenario scripts under `experiments/` (create as needed). Keep assets such as plots or exported telemetry inside `artifacts/`. + +## Build, Test, and Development Commands +- `python -m reactor_sim.simulation` — run the default 10-minute transient and print JSON snapshots. +- `python run_simulation.py` — same as above, handy for IDE launch configs. +- `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`. +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 +Use Python 3.10+ with type hints and dataclasses. Stick to PEP 8 plus 4-space indentation. Module names remain lowercase with underscores mirroring plant subsystems (`control.py`, `turbine.py`). Exported classes use PascalCase (`ReactorSimulation`), internal helpers stay snake_case. Keep docstrings concise and prefer explicit units inside attribute names to avoid ambiguity. + +## Testing Guidelines +Pytest is the preferred framework. Place tests under `tests/` mirroring the `reactor_sim` tree (e.g., `tests/test_neutronics.py`). Name fixtures after the system (e.g., `primary_loop`). Each PR should add regression tests for new physical models, persistence helpers, and failure paths (core power within ±5% plus expected component integrity behavior). Run `python -m pytest` locally before opening a pull request. + +## Commit & Pull Request Guidelines +Write commits in imperative mood (`Add turbine moisture separator model`). Squash small WIP commits before review. Pull requests must describe the scenario, attach log excerpts (`snapshots.json` diff) or plots for novel behavior, and link to any tracking issues. Include validation notes: commands run, expected trends (e.g., outlet temperature increase), and outstanding risks. + +## Safety & Configuration +Sim parameters live in constructors; never hard-code environment-specific paths. When adding new physics, guard unstable calculations with clamps and highlight operating limits in comments. Sensitive experiments (e.g., beyond design basis) should default to disabled flags so scripted studies remain safe by default. + +## Reliability & Persistence +Component wear is tracked via `failures.py`; stress from overheating, pump starvation, or turbine imbalance will degrade integrity and eventually disable the affected subsystem with automatic SCRAM for core damage. Persist plant snapshots with `ControlSystem.save_state()`/`load_state()` (used by `Reactor.save_state`/`load_state`) so long-running studies can resume; `FISSION_LOAD_STATE`/`FISSION_SAVE_STATE` env vars wire this into the CLI. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9a16511 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "fission-sim" +version = "0.1.0" +description = "Simplified nuclear reactor simulation" +requires-python = ">=3.10" +authors = [ + { name = "Fission Sim Team" } +] +dependencies = [] + +[project.optional-dependencies] +dev = ["pytest>=7.0"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" diff --git a/run_simulation.py b/run_simulation.py new file mode 100644 index 0000000..3afb3ca --- /dev/null +++ b/run_simulation.py @@ -0,0 +1,6 @@ +"""Convenience entry-point for running the reactor simulation.""" + +from reactor_sim.simulation import main + +if __name__ == "__main__": + main() diff --git a/src/fission_sim.egg-info/PKG-INFO b/src/fission_sim.egg-info/PKG-INFO new file mode 100644 index 0000000..f4a713e --- /dev/null +++ b/src/fission_sim.egg-info/PKG-INFO @@ -0,0 +1,8 @@ +Metadata-Version: 2.4 +Name: fission-sim +Version: 0.1.0 +Summary: Simplified nuclear reactor simulation +Author: Fission Sim Team +Requires-Python: >=3.10 +Provides-Extra: dev +Requires-Dist: pytest>=7.0; extra == "dev" diff --git a/src/fission_sim.egg-info/SOURCES.txt b/src/fission_sim.egg-info/SOURCES.txt new file mode 100644 index 0000000..b2c5a6a --- /dev/null +++ b/src/fission_sim.egg-info/SOURCES.txt @@ -0,0 +1,23 @@ +pyproject.toml +src/fission_sim.egg-info/PKG-INFO +src/fission_sim.egg-info/SOURCES.txt +src/fission_sim.egg-info/dependency_links.txt +src/fission_sim.egg-info/requires.txt +src/fission_sim.egg-info/top_level.txt +src/reactor_sim/__init__.py +src/reactor_sim/atomic.py +src/reactor_sim/commands.py +src/reactor_sim/constants.py +src/reactor_sim/consumer.py +src/reactor_sim/control.py +src/reactor_sim/coolant.py +src/reactor_sim/failures.py +src/reactor_sim/fuel.py +src/reactor_sim/logging_utils.py +src/reactor_sim/neutronics.py +src/reactor_sim/reactor.py +src/reactor_sim/simulation.py +src/reactor_sim/state.py +src/reactor_sim/thermal.py +src/reactor_sim/turbine.py +tests/test_simulation.py \ No newline at end of file diff --git a/src/fission_sim.egg-info/dependency_links.txt b/src/fission_sim.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/fission_sim.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/fission_sim.egg-info/requires.txt b/src/fission_sim.egg-info/requires.txt new file mode 100644 index 0000000..9a62782 --- /dev/null +++ b/src/fission_sim.egg-info/requires.txt @@ -0,0 +1,3 @@ + +[dev] +pytest>=7.0 diff --git a/src/fission_sim.egg-info/top_level.txt b/src/fission_sim.egg-info/top_level.txt new file mode 100644 index 0000000..d3ae5bf --- /dev/null +++ b/src/fission_sim.egg-info/top_level.txt @@ -0,0 +1 @@ +reactor_sim diff --git a/src/reactor_sim/__init__.py b/src/reactor_sim/__init__.py new file mode 100644 index 0000000..f0959a9 --- /dev/null +++ b/src/reactor_sim/__init__.py @@ -0,0 +1,28 @@ +"""Top-level package for the nuclear fission reactor simulation.""" + +from __future__ import annotations + +from .atomic import Atom, AtomicPhysics, FissionEvent +from .commands import ReactorCommand +from .consumer import ElectricalConsumer +from .failures import HealthMonitor +from .logging_utils import configure_logging +from .state import CoreState, CoolantLoopState, TurbineState, PlantState +from .reactor import Reactor +from .simulation import ReactorSimulation + +__all__ = [ + "Atom", + "AtomicPhysics", + "FissionEvent", + "CoreState", + "CoolantLoopState", + "TurbineState", + "PlantState", + "Reactor", + "ReactorSimulation", + "ReactorCommand", + "ElectricalConsumer", + "HealthMonitor", + "configure_logging", +] diff --git a/src/reactor_sim/__pycache__/__init__.cpython-310.pyc b/src/reactor_sim/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d9056c8b0a97e38176f95f8833a78ab5c25554c GIT binary patch literal 850 zcmZuv&5qMB5O%U@o2E&(+xY7qxOE4n9NER(pcwRT+DNyT#E6?g`2 zypm5y9MHZ3Cp=CB2;oRy#^1=FzZs9pvIM#H?NiQwgb00y&ELy$=s7}x`RsxUVN?ZG zC;}PcFhJEo6^R2G;YbefKo0THa|1DwF^+va6l0m-#K$8sktt4n9E(g&@zlp-aVTea zCXeuuoa4DX#>bvd#ECq`r#_yDGkK2B1GGU^`X#^@RklKl>BpzHYWGC2JrntkmUnc+ z^0m@=vt@bRmV!~8uemW?)wyQ0Y+TN8*$Ub?>hb|fT?BSQ>smFQGYQoqx^m3?TClRwyrg2OYST)l9h<#kR5aT+s^-pu zBaTW{ulc6cj0m;a@Ora|?0Bg(TR9)h&X!6ES~sti+I38N+v@kc?(@kFWPQe|jd~j( z+=r{*RW33exsvn%UR;llV!?Z7}Q{^!gk0|K cZG0tF)e80u#>1RBh)^1yglW)y7u`Sn1?f`iS^xk5 literal 0 HcmV?d00001 diff --git a/src/reactor_sim/__pycache__/atomic.cpython-310.pyc b/src/reactor_sim/__pycache__/atomic.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b556cd266be01f2debf120d7bda0ea8a1f83db5 GIT binary patch literal 4324 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 literal 0 HcmV?d00001 diff --git a/src/reactor_sim/__pycache__/commands.cpython-310.pyc b/src/reactor_sim/__pycache__/commands.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c3bceb89e927cfd8d9beafe9d6cec47c932af63 GIT binary patch literal 1206 zcmZuwyKdVs6s0KnEkDvGeE=P_1s-YxSz{DM1EgCD^s#6m7?F;JN~B6kNn@l_G8O3F zU+6Ds?bKh$)O)GKaEi+C#knt%_wY~;hkXLy;kPLL?i2DGo$W6G=QSaoJpdp`OoS(B z=H)b|9%4W9^B@j9B7?U?1fq3AMC+PfdvQy&Kago>{qkKQ4KKBc5}oH<2^%fJ0aWoU zGLk1vPL)Pyvp=}`)T;+vDP8h1)yjg~6TIX}#;r}TVEfyg@Fzqii9I+a{RC%=eL>?u z__8H}1p(Z4xCOW!cSHx`T^H|)9^k&iJ%&==A$E6YNss;tM*Y^xkXT4m6) z6}anY8wu;F+sXbzCiW9=>pl^;;eUjpn*g|Sk^`xM*nR{+Ww1Ut(&}8QRH{URs+28F zWy0DxXpy{DO<^Mvl`AKHYBMhOrUCdWHm-E8t z&ns>8Iahq1Xd%DOm#MYD@Z6eYey3apcLUi**|XxZ?m4m<&$6emn5AGQ{`%R&nP;1# zDK$8y*#R@%sk@9Ro=e8+K4ZBSRfafZ>yG(~Mp%cbT3wr7?V_RSoGj z=nxY_G$S-)F!jJas$7;UEzBN_u^Bf9s rgk=SH4kH-kp&o3Wv9!jAh^znKmUVa0O#cc6VezU00=rV`?|Od$?K41p literal 0 HcmV?d00001 diff --git a/src/reactor_sim/__pycache__/constants.cpython-310.pyc b/src/reactor_sim/__pycache__/constants.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cc173be7d6a47be24fefc16113596c8e1bbdb58a GIT binary patch literal 797 zcmZWnzi-n(6h5bJo2E&Vwn1f~vY6gaW26nJ_q|!=m~LHIoB~HcCj7Qc4DAn zL1G5+4|IaDtS~dO+=PULgb-{92^P-5l`8R0clzG>4l|I0SJ3~p!h3O0DMlJ z)0PuX0{|(;0x&=TlUQyjjdCO>0X9XZm`r3gO{Uq5D^~%TVY6hG5+kvUc%1vXFS zS&Ddh!9cgD#$aw(0{uJR=tY9?yhvMxIh^nGdljHY#eGrb^`fe0H7=%&y$O)W!KVenOJ t=_g@C#e9fpHcYkMeV)HE3fXw*UX{d;@p*B`pqP_$vLs7+vHxFr=`VYx5oee5go+C$M-D2lc-l-D1LD?sF* zBhLw;_G=K5jESU@6|`hyMo|YvQ1UVNnit_XRN;A~Iu~>tD}F#^D5Lj8MrZ7dj=Pe- zB7@Fsuzy-BQ>8^;6)LMuo`KACWt&o&VWCsmFSS&K-Tf0?8Bn*BR+X;PItL>dc4b

>h%FyEG6Tsj(I{Xs|MaC4IW!4z}EkBVPpt^DsP#(H_`&R@nE&*I%YvXv8mdb?_psB%MRi>2` z<;gdW|EzW4B3%`ErT$oW?$)RJL>XaJrt)KjrYQ3nueH&~X_XE$2>9u6l3NQg z9$J$PjY_jx8)5SjR@>z4PJeOBM!jv6t%dja9&BhCG;4I$|2P=Cw~QSh=}Kj2zIZTQ z5xgGgGY|;$j@4AsIXz=@B1p~7A$2g%=h%`9tTqCuL5n)u-m(2Q!u_4yFDCt`&)WXm z9|xfifD6j>)I~49J2=??-toMeXoEi;1ZD-g)+U}Bb)4(Q`XIFpM%X1*)uP>h6UG+5 zjA)NOptHw!5MNv00!8Q$UJv>?2t=9Fqrhy}k-!7LB!Nz6dbu9|E-n_z+es)<{Al`iSy>{uimWcT2XJ zPK(@E=tq1V*yx(iQz6?qy&y-#Z23eYAz#ccQtA2FX!Zp7no273bn%<7d@6@NuRfnW zWLd`sfnDb$HC0|6-hxJ|53{)0OySm0ZPi|#vnOC_v4%0BoHGALH2$0aJNxvGWTI5O{wG;SwR>E^G?aJt5vUX>qx- zD#VRjQHZt*uETR5H{HhO5jNK~;1yQ3!OI;l3m<`m#4Ls=^k~A8APHAOzdlG@M+m92 z)|p8IcM>>bu%6x0)kUnV!K3Utg+4rle7NqRs$YUu#yo}vpDn-PU7omjucds3mz%{y P$w`m(z@db(V1xb#s}_lo literal 0 HcmV?d00001 diff --git a/src/reactor_sim/__pycache__/control.cpython-310.pyc b/src/reactor_sim/__pycache__/control.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54a23c11da6d62a2bc995c4593770577a9f6f058 GIT binary patch literal 3891 zcma)9OK%*<5uTpc&dx3$B58`Eh-4gyvc{H3$wpqbEHR)30Wq}_NC!bS2nOTrUUF9R z*!1*@;$nyZrhuIM0}}YKdkTEYkIAi10dg>O2?FG%070s{mye}F?wo|9=z?_}B zZ5Q57TFbn)2Wu{^rS;5j`-=9`M%HXMvzhixHrt*h26IPqY<^7ISLgzrrE{zyJm1sqd`mYm-%3OsVBDEfP_ zoyq!k!I>ugXSk$k=S7?jm~^^n5esRig)|Qm>2f9pJnvX|i%CP;foB`FIuC>}Dk4Z_ zRtCsv?it^bR%l8qi(f*Q>v$!J)9+gQ3t4ebl{Uhms@L3-}|8EnzdBT_A)q!WKoxxwhG&9-+ zOu+mV0N=K#$!u!fH>eGuBkcekQlB;eK9{~vXXq?YHTr>q(QbS6Ho&_Gb9H)$F4L<( z`E-M>((6Dq>TC5ix=HWS8+84Z({9px^gVhDR%YlL0RN$!nE=11aNk$=?EhtpH-M6j zl8JtiEARap&?e#tntg<09c>e&sWGM5ab@O@$(+pxnqc3>Uem3`HqCbdqi%7`4{j%w7d z9JOMN!EKNN>73U7N2>kp;dk&|)xPVh_RojIo2MqP?0kLWqi%5LL7?8Z80Q856zMSZ zX@Lk-06T2JO zp#xr!*n@+VxIcpV5)I5Hvt%9xU7i;7lo2(ZLI}DjBGTw^XOW>YM#}Iia@81MW7Ztj#F z9%ml_d#OAUEC&?1IDKu!BkLL4j}K!ZN@=%>oK5L-%4AUf|HovgHkgSQ{$@)DldZpQ z4S(%eg zVQ@+Ew>HRHpC?(&k9ZBXPU!$M%~`hj2@EJXHdRXd^>BD&k}w0xGEB(p8y|L6iiVJsDsQ{j z{XDeg9Hj0(<1wU1hT-bm<#+(chmN+4dqB%3Wo5z{jc^Y~7n80&{wj?2-U-xdu$MyGy%IAt9c2Ad1qtmv zHEjlHxcq0>;f?txco^LE2#PH#ZG`KxCHN)WQ{#3YxJy)kUAKw-U)Oet`^NS>;z5L7 zf@ipL$-&OHzPTX={gnOoywJNHpg6jpVE?-*heMpt98SOh%R4|2GSvvNw#E?3v5pwo zlqAO3gnGdST2%{U=p(=%#!xTx07)1F&Lgj~sC^B0Itpc{G7)8{B=8&;&rwuuOu7~z z@3lCJeU1eG6o$jq9Z(p+r_c{HXkA`p0GrnA1K^|q%fD_;}3{5KDUf?xbtd33ZunN5rmP0}=%Pm&pGBMJ9xQ literal 0 HcmV?d00001 diff --git a/src/reactor_sim/__pycache__/coolant.cpython-310.pyc b/src/reactor_sim/__pycache__/coolant.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab40b630b112521e25756ef9195b1cf1f0c2fa17 GIT binary patch literal 1225 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 literal 0 HcmV?d00001 diff --git a/src/reactor_sim/__pycache__/logging_utils.cpython-310.pyc b/src/reactor_sim/__pycache__/logging_utils.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b091299d5fb41a962161dfe9ca2d1a3fcf7bfee GIT binary patch literal 1406 zcmZuxO>f&q5Zzr;Bt=V#llBl4J@8N!;UQ9h20a9Zo5F#c0;zH^(1QzuAh;`uHpx|X zS4JXGKnmyD|6m;p^cU^5r(S#XrJbRu*!@@n+?k!(nR)Yul>L54VEwj=@(YiU-%+`{ zTu`19LiJM+I5{Ppa#qlioid8DQ#hv%>~7(fozo7S4fs9YHS((EFgiP0ZeL`>~oT2<0CHa!77c)dWs zw6N~?ljGO6Q;17Z+;sEGM0KQ%vVI|F)4UKj{hjMiaC~MxCCpM)Hv_O^H6yG_sElEz zb_K8_;v+=-CkTer#Lz3ILc^||nrvuAYqDmbxtwn3+NquEPR%xylG-`<^mDNI8?tr{ zI}bSH4tIZH>rU-nck9m9>pHC6MxyE)@ZPI2j#qpC@bJ*1dWbl z()4(TdmoytFUS?WqPo0|_OI;!6Px?Blk^eChU_BO$XBrX?nqYCe6~~~N_T`j)|p%g z9%1u@ibR2{0zLEb=MU5qlTWd zEA}IyJtcj^X+&p zOEF$3c`=ttUZzz#&LkIa$J1PE7|pR(+4v6daXW9x(&U96E#BGg!Aw-*?Lr-5sTvDs z?>{4}FS1?VliiFS6f#Q-eK`7fEN(bDFbOsc}f$G4q^3gU>Ld)V( z@T4KtdfPFv1E>*_C2e-wg)e`X4M${WP!BK^_ILaG6ocOXPdmDBtiOiWLl)AIdNiOe zJlA>v6yKShy)U1Aj&rfCBq#?6#G%B8B0wU9*pg$cMCKypqq%0Z-kDpkvmeLo zp0SU0<&+T>4Kz4qD5$8R%2+r4&DYy`zxR7@lIiK11?|^S zEBL--S-<1pWMeQ`u`H720EE@E1Q9eO5$#ce*beQ8^_amdbb5|(L@6vqWb2kVBjz4iV(x$*klq== z?pf_~qsv`6%v2J$Hr>#TJ=t<&(Nfa&vS263_FA4Bdc)Aof+S9to}(9SqNm(APO@SW zhE?Hat{1v#+I>!n9aRtZXKtLOu)feA%8-wZ@_~%qFx$hUtBI2Lz?9T1lhHss{V;Je zT~aa|s@OwwFPjYjf2&Ac6M!K5K-@mfX>R3Y(P~=Yl0EvkD}A`zj%;~}?DqpNka2dc zwH5eVQnm0aQn_GVw!-8KsnQefF8StTt;Z|=mDP)XJo~Wr^HVUXDlo09O7^9aK$4z1 zVOdSVqUrv=(b-BO*%_$h;Z~xO9XED6ULxdfryry#P}oV8*D*vSil;%;i3|1vFI^h! z>E?;c@xGK|)Ab%UzxaYLQ|us9C@jOy41mnYDhE~$DDu+vzx@W= z(`EnKxb%&lXfNVPCfd12rRd05?TVOo2K};+&WkGl0rs-l&xID&j0$ zdO_0A)yBDY3HGEIts1Qm`l3SVe-)elOJA_k===n?OXFkeA{;rz zg98&7e8a;V05WTvfRENT-Di-2Y|+Z?3&@Dw1dbLk7iqU}dHb)GHdAOp&vw@y+`oIL z%QtR)x_1B8XKM;GS)GTj?WhZgjc@pU-SB0Mxe8(9Sr9>uz;I7`+rV7lLaJcLr2RW^ z)Fe!al*l|8U7B=rV!x&$)2y4s(!*fCY#0js8HaBJ$gM+gFQ-q?Ve60pW`LcH3c%ZT z?&RS50;F3`xSfL#ILn;F637Kb0qak&rkf&lL=0FRy?=B)5cjnf1oM-(mOkiTZs8>X zUX`u5<3(3H>E)K-$CK@{dJPV%*P(+Pc6SYf`ph_jJaUyE#JaR`XMKI`u6Bgn9QvTV zF~_JMg`qUj)}_M!B)u%ny=lYHy6g_FD$(Ys9n_+&Y?g^&UKCRC=tUDah>zehi+J`7^Ue3=Oy{13?G8F~N! literal 0 HcmV?d00001 diff --git a/src/reactor_sim/__pycache__/reactor.cpython-310.pyc b/src/reactor_sim/__pycache__/reactor.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..236b169f93f566bd4f447180d2e29e2bae7b8990 GIT binary patch literal 7825 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 str: + if 0 < atomic_number < len(ELEMENT_SYMBOLS): + return ELEMENT_SYMBOLS[atomic_number] + return f"E{atomic_number}" + + +@dataclass(frozen=True) +class Atom: + symbol: str + protons: int + neutrons: int + + @property + def mass_number(self) -> int: + return self.protons + self.neutrons + + @property + def atomic_mass_kg(self) -> float: + return self.mass_number * constants.AMU_TO_KG + + +@dataclass(frozen=True) +class FissionEvent: + parent: Atom + products: tuple[Atom, Atom] + emitted_neutrons: int + energy_mev: float + + +def make_atom(protons: int, neutrons: int) -> Atom: + return Atom(symbol=element_symbol(protons), protons=protons, neutrons=neutrons) + + +FISSION_LIBRARY: dict[tuple[str, int], Sequence[FissionEvent]] = { + ("U", 235): ( + FissionEvent( + parent=make_atom(92, 143), + products=(make_atom(36, 56), make_atom(56, 85)), # Kr-92 + Ba-141 + emitted_neutrons=3, + energy_mev=202.0, + ), + FissionEvent( + parent=make_atom(92, 143), + products=(make_atom(37, 55), make_atom(55, 88)), # Rb-92 + Cs-143 + emitted_neutrons=2, + energy_mev=195.0, + ), + ), + ("U", 238): ( + FissionEvent( + parent=make_atom(92, 146), + products=(make_atom(38, 54), make_atom(54, 90)), + emitted_neutrons=2, + energy_mev=185.0, + ), + ), + ("Pu", 239): ( + FissionEvent( + parent=make_atom(94, 145), + products=(make_atom(36, 53), make_atom(58, 89)), + emitted_neutrons=3, + energy_mev=210.0, + ), + ), + ("Th", 232): ( + FissionEvent( + parent=make_atom(90, 142), + products=(make_atom(36, 54), make_atom(54, 88)), + emitted_neutrons=2, + energy_mev=178.0, + ), + ), +} + + +class AtomicPhysics: + """Utility that deterministically chooses fission fragments for electron impacts.""" + + def __init__(self, seed: int | None = None) -> None: + self.random = random.Random(seed) + + def electron_induced_fission(self, atom: Atom) -> FissionEvent: + key = (atom.symbol, atom.mass_number) + reactions = FISSION_LIBRARY.get(key) + if reactions: + event = self.random.choice(reactions) + else: + event = self._generic_split(atom) + LOGGER.debug( + "Electron impact fission: %s-%d -> %s-%d + %s-%d + %d n", + atom.symbol, + atom.mass_number, + event.products[0].symbol, + event.products[0].mass_number, + event.products[1].symbol, + event.products[1].mass_number, + event.emitted_neutrons, + ) + return event + + def _generic_split(self, atom: Atom) -> FissionEvent: + heavy_mass = max(1, math.floor(atom.mass_number * 0.55)) + light_mass = atom.mass_number - heavy_mass + heavy_protons = max(1, min(atom.protons - 1, math.floor(atom.protons * 0.55))) + light_protons = atom.protons - heavy_protons + heavy_neutrons = heavy_mass - heavy_protons + light_neutrons = light_mass - light_protons + heavy_atom = make_atom(heavy_protons, heavy_neutrons) + light_atom = make_atom(light_protons, light_neutrons) + emitted_neutrons = max(0, atom.neutrons - heavy_neutrons - light_neutrons) + energy_mev = 0.8 * atom.mass_number + return FissionEvent( + parent=atom, + products=(heavy_atom, light_atom), + emitted_neutrons=emitted_neutrons, + energy_mev=energy_mev, + ) diff --git a/src/reactor_sim/commands.py b/src/reactor_sim/commands.py new file mode 100644 index 0000000..071d488 --- /dev/null +++ b/src/reactor_sim/commands.py @@ -0,0 +1,26 @@ +"""Operator commands for manual reactor control.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ReactorCommand: + """Batch of operator actions applied during a simulation step.""" + + rod_position: float | None = None # Absolute rod insertion 0-0.95 + rod_step: float | None = None # Incremental change (-1..1 mapped internally) + scram: bool = False + primary_pump_on: bool | None = None + secondary_pump_on: bool | None = None + turbine_on: bool | None = None + coolant_demand: float | None = None + power_setpoint: float | None = None + consumer_online: bool | None = None + consumer_demand: float | None = None + + @classmethod + def scram_all(cls) -> "ReactorCommand": + """Convenience constructor for an emergency shutdown.""" + return cls(scram=True, turbine_on=False, primary_pump_on=True, secondary_pump_on=True) diff --git a/src/reactor_sim/constants.py b/src/reactor_sim/constants.py new file mode 100644 index 0000000..d8391e9 --- /dev/null +++ b/src/reactor_sim/constants.py @@ -0,0 +1,19 @@ +"""Physical constants and engineering limits for the simulation.""" + +from __future__ import annotations + +SECONDS_PER_MINUTE = 60.0 +MEGAWATT = 1_000_000.0 +NEUTRON_LIFETIME = 0.1 # seconds, prompt neutron lifetime surrogate +FUEL_ENERGY_DENSITY = 200.0 * MEGAWATT # J/kg released as heat +COOLANT_HEAT_CAPACITY = 4_200.0 # J/(kg*K) for water/steam +COOLANT_DENSITY = 700.0 # kg/m^3 averaged between phases +MAX_CORE_TEMPERATURE = 1_800.0 # K +MAX_PRESSURE = 15.0 # MPa typical PWR primary loop limit +CONTROL_ROD_SPEED = 0.05 # fraction insertion per second +STEAM_TURBINE_EFFICIENCY = 0.34 +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 diff --git a/src/reactor_sim/consumer.py b/src/reactor_sim/consumer.py new file mode 100644 index 0000000..cb656ee --- /dev/null +++ b/src/reactor_sim/consumer.py @@ -0,0 +1,40 @@ +"""External electrical consumer/load models.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class ElectricalConsumer: + name: str + demand_mw: float + online: bool = False + power_received_mw: float = 0.0 + + def request_power(self) -> float: + return self.demand_mw if self.online else 0.0 + + def set_demand(self, demand_mw: float) -> None: + previous = self.demand_mw + self.demand_mw = max(0.0, demand_mw) + LOGGER.info("%s demand %.1f -> %.1f MW", self.name, previous, self.demand_mw) + + def set_online(self, online: bool) -> None: + if self.online != online: + self.online = online + LOGGER.info("%s %s", self.name, "online" if online else "offline") + + 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(), + ) + diff --git a/src/reactor_sim/control.py b/src/reactor_sim/control.py new file mode 100644 index 0000000..8ceec32 --- /dev/null +++ b/src/reactor_sim/control.py @@ -0,0 +1,90 @@ +"""Control system for rods and plant automation.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import logging +from pathlib import Path + +from . import constants +from .state import CoolantLoopState, CoreState, PlantState + +LOGGER = logging.getLogger(__name__) + + +def clamp(value: float, lo: float, hi: float) -> float: + return max(lo, min(hi, value)) + + +@dataclass +class ControlSystem: + setpoint_mw: float = 3_000.0 + rod_fraction: float = 0.5 + + def update_rods(self, state: CoreState, dt: float) -> float: + 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) + previous = self.rod_fraction + self.rod_fraction = clamp(self.rod_fraction + adjustment, 0.0, 0.95) + LOGGER.debug("Control rods %.3f -> %.3f (error=%.3f)", previous, self.rod_fraction, error) + return self.rod_fraction + + def set_rods(self, fraction: float) -> float: + previous = self.rod_fraction + self.rod_fraction = clamp(fraction, 0.0, 0.95) + LOGGER.info("Manual rod set %.3f -> %.3f", previous, self.rod_fraction) + return self.rod_fraction + + def increment_rods(self, delta: float) -> float: + return self.set_rods(self.rod_fraction + delta) + + def scram(self) -> float: + self.rod_fraction = 0.95 + LOGGER.warning("SCRAM: rods fully inserted") + return self.rod_fraction + + def set_power_setpoint(self, megawatts: float) -> None: + previous = self.setpoint_mw + self.setpoint_mw = clamp(megawatts, 100.0, 4_000.0) + LOGGER.info("Power setpoint %.0f -> %.0f MW", previous, self.setpoint_mw) + + def coolant_demand(self, primary: CoolantLoopState) -> float: + desired_temp = 580.0 + error = (primary.temperature_out - desired_temp) / 100.0 + demand = clamp(0.8 - error, 0.0, 1.0) + LOGGER.debug("Coolant demand %.2f for outlet %.1fK", demand, primary.temperature_out) + return demand + + def save_state( + self, + filepath: str, + plant_state: PlantState, + metadata: dict | None = None, + health_snapshot: dict | None = None, + ) -> None: + payload = { + "control": { + "setpoint_mw": self.setpoint_mw, + "rod_fraction": self.rod_fraction, + }, + "plant": plant_state.to_dict(), + "metadata": metadata or {}, + } + if health_snapshot: + payload["health"] = health_snapshot + path = Path(filepath) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2)) + LOGGER.info("Saved control & plant state to %s", path) + + def load_state(self, filepath: str) -> tuple[PlantState, dict, dict | None]: + path = Path(filepath) + data = json.loads(path.read_text()) + control = data.get("control", {}) + self.setpoint_mw = control.get("setpoint_mw", self.setpoint_mw) + self.rod_fraction = control.get("rod_fraction", self.rod_fraction) + plant = PlantState.from_dict(data["plant"]) + LOGGER.info("Loaded plant state from %s", path) + return plant, data.get("metadata", {}), data.get("health") diff --git a/src/reactor_sim/coolant.py b/src/reactor_sim/coolant.py new file mode 100644 index 0000000..1894860 --- /dev/null +++ b/src/reactor_sim/coolant.py @@ -0,0 +1,27 @@ +"""Coolant loop control models.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from .state import CoolantLoopState + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class Pump: + nominal_flow: float + efficiency: float = 0.9 + + def flow_rate(self, demand: float) -> float: + demand = max(0.0, min(1.0, demand)) + return self.nominal_flow * (0.2 + 0.8 * demand) * self.efficiency + + def step(self, loop: CoolantLoopState, demand: float) -> None: + loop.mass_flow_rate = self.flow_rate(demand) + loop.pressure = 12.0 * demand + 2.0 + LOGGER.debug( + "Pump demand=%.2f -> %.0f kg/s, pressure=%.1f MPa", demand, loop.mass_flow_rate, loop.pressure + ) diff --git a/src/reactor_sim/failures.py b/src/reactor_sim/failures.py new file mode 100644 index 0000000..453d566 --- /dev/null +++ b/src/reactor_sim/failures.py @@ -0,0 +1,110 @@ +"""Wear and failure monitoring for reactor components.""" + +from __future__ import annotations + +from dataclasses import dataclass, asdict +import logging +from typing import Dict, Iterable, List + +from . import constants +from .state import PlantState + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class ComponentHealth: + name: str + integrity: float = 1.0 + failed: bool = False + + def degrade(self, amount: float) -> None: + if self.failed: + return + self.integrity = max(0.0, self.integrity - amount) + if self.integrity <= 0.0: + self.fail() + + def fail(self) -> None: + if not self.failed: + self.failed = True + LOGGER.error("Component %s has failed", self.name) + + def snapshot(self) -> dict: + return asdict(self) + + @classmethod + def from_snapshot(cls, data: dict) -> "ComponentHealth": + return cls(**data) + + +class HealthMonitor: + """Tracks component wear and signals failures.""" + + def __init__(self) -> None: + self.components: Dict[str, ComponentHealth] = { + "core": ComponentHealth("core"), + "primary_pump": ComponentHealth("primary_pump"), + "secondary_pump": ComponentHealth("secondary_pump"), + "turbine": ComponentHealth("turbine"), + } + self.failure_log: list[str] = [] + + def component(self, name: str) -> ComponentHealth: + return self.components[name] + + def evaluate( + self, + state: PlantState, + primary_active: bool, + secondary_active: bool, + turbine_active: bool, + dt: float, + ) -> List[str]: + events: list[str] = [] + core = self.component("core") + core_temp = state.core.fuel_temperature + temp_stress = max(0.0, (core_temp - 900.0) / (constants.MAX_CORE_TEMPERATURE - 900.0)) + base_degrade = 0.0001 * dt + core.degrade(base_degrade + temp_stress * 0.01 * dt) + + if primary_active: + primary_flow = state.primary_loop.mass_flow_rate + flow_ratio = 0.0 if primary_flow <= 0 else min(1.0, primary_flow / 18_000.0) + self.component("primary_pump").degrade((0.0002 + (1 - flow_ratio) * 0.005) * dt) + else: + self.component("primary_pump").degrade(0.0005 * dt) + + if secondary_active: + secondary_flow = state.secondary_loop.mass_flow_rate + flow_ratio = 0.0 if secondary_flow <= 0 else min(1.0, secondary_flow / 16_000.0) + self.component("secondary_pump").degrade((0.0002 + (1 - flow_ratio) * 0.004) * dt) + else: + self.component("secondary_pump").degrade(0.0005 * dt) + + if turbine_active: + electrical = state.turbine.electrical_output_mw + load_ratio = ( + 0.0 + if state.turbine.load_demand_mw <= 0 + else min(1.0, electrical / max(1e-6, state.turbine.load_demand_mw)) + ) + stress = 0.0002 + abs(1 - load_ratio) * 0.003 + self.component("turbine").degrade(stress * dt) + else: + self.component("turbine").degrade(0.0001 * dt) + + for name, component in self.components.items(): + if component.failed and name not in self.failure_log: + events.append(name) + self.failure_log.append(name) + return events + + def snapshot(self) -> dict: + return {name: comp.snapshot() for name, comp in self.components.items()} + + def load_snapshot(self, data: dict) -> None: + for name, comp_data in data.items(): + if name in self.components: + self.components[name] = ComponentHealth.from_snapshot(comp_data) + diff --git a/src/reactor_sim/fuel.py b/src/reactor_sim/fuel.py new file mode 100644 index 0000000..222d8fc --- /dev/null +++ b/src/reactor_sim/fuel.py @@ -0,0 +1,56 @@ +"""Fuel behavior, burnup, and decay heat modeling.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import logging + +from . import constants +from .atomic import Atom, AtomicPhysics, FissionEvent, make_atom +from .state import CoreState + +LOGGER = logging.getLogger(__name__) + + +def fuel_reactivity_penalty(burnup: float) -> float: + """Simplistic model that penalizes reactivity as burnup increases.""" + # Burnup is 0-1, penalty grows quadratically to mimic depletion. + return 0.4 * burnup**2 + + +def decay_heat_fraction(burnup: float) -> float: + """Return remaining decay heat fraction relative to nominal power.""" + return min(0.07 + 0.2 * burnup, 0.15) + + +@dataclass +class FuelAssembly: + enrichment: float # fraction U-235 + mass_kg: float + fissile_atom: Atom = field(default_factory=lambda: make_atom(92, 143)) + atomic_physics: AtomicPhysics = field(default_factory=AtomicPhysics) + + def available_energy_j(self, state: CoreState) -> float: + fraction_remaining = max(0.0, 1.0 - state.burnup) + return self.mass_kg * constants.FUEL_ENERGY_DENSITY * fraction_remaining + + def simulate_electron_hit(self) -> FissionEvent: + return self.atomic_physics.electron_induced_fission(self.fissile_atom) + + def prompt_energy_rate(self, flux: float, control_fraction: float) -> tuple[float, FissionEvent]: + """Compute MW thermal from prompt fission by sampling atomic physics.""" + 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 + power_watts = event_rate * event.energy_mev * constants.MEV_TO_J + power_mw = power_watts / constants.MEGAWATT + LOGGER.debug( + "Prompt fission products %s-%d + %s-%d yielding %.2f MW", + event.products[0].symbol, + event.products[0].mass_number, + event.products[1].symbol, + event.products[1].mass_number, + power_mw, + ) + return max(0.0, power_mw), event diff --git a/src/reactor_sim/logging_utils.py b/src/reactor_sim/logging_utils.py new file mode 100644 index 0000000..8e9818e --- /dev/null +++ b/src/reactor_sim/logging_utils.py @@ -0,0 +1,38 @@ +"""Logging helpers for the reactor simulation package.""" + +from __future__ import annotations + +import logging +from typing import Optional + + +def configure_logging(level: int | str = "INFO", logfile: Optional[str] = None) -> logging.Logger: + """Configure a package-scoped logger emitting to stdout and optional file.""" + resolved_level = logging.getLevelName(level) if isinstance(level, str) else level + logger = logging.getLogger("reactor_sim") + logger.setLevel(resolved_level) + if not logger.handlers: + stream_handler = logging.StreamHandler() + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", datefmt="%H:%M:%S" + ) + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) + if logfile: + file_handler = logging.FileHandler(logfile) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + else: + for handler in logger.handlers: + handler.setLevel(resolved_level) + if logfile and not any(isinstance(handler, logging.FileHandler) for handler in logger.handlers): + file_handler = logging.FileHandler(logfile) + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s", datefmt="%H:%M:%S" + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + # Keep package loggers self-contained so host apps can opt-in to propagation. + logger.propagate = False + logging.getLogger().setLevel(resolved_level) + return logger diff --git a/src/reactor_sim/neutronics.py b/src/reactor_sim/neutronics.py new file mode 100644 index 0000000..02c9fab --- /dev/null +++ b/src/reactor_sim/neutronics.py @@ -0,0 +1,55 @@ +"""Neutron balance and reactivity calculations.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from . import constants +from .fuel import fuel_reactivity_penalty +from .state import CoreState + +LOGGER = logging.getLogger(__name__) + + +def temperature_feedback(temp: float) -> float: + """Negative coefficient: higher temperature lowers reactivity.""" + reference = 900.0 + coefficient = -5e-5 + return coefficient * (temp - reference) + + +def xenon_poisoning(flux: float) -> float: + return min(0.05, 1e-8 * flux) + + +@dataclass +class NeutronDynamics: + beta_effective: float = 0.0065 + delayed_neutron_fraction: float = 0.0008 + + def reactivity(self, state: CoreState, control_fraction: float) -> float: + rho = ( + 0.02 * (1.0 - control_fraction) + + temperature_feedback(state.fuel_temperature) + - fuel_reactivity_penalty(state.burnup) + - xenon_poisoning(state.neutron_flux) + ) + return rho + + def flux_derivative(self, state: CoreState, rho: float) -> float: + generation_time = constants.NEUTRON_LIFETIME + beta = self.beta_effective + return ((rho - beta) / generation_time) * state.neutron_flux + 1e5 + + def step(self, state: CoreState, control_fraction: float, dt: float) -> None: + rho = self.reactivity(state, control_fraction) + d_flux = self.flux_derivative(state, rho) + state.neutron_flux = max(0.0, state.neutron_flux + d_flux * dt) + state.reactivity_margin = rho + LOGGER.debug( + "Neutronics: rho=%.5f, flux=%.2e n/cm2/s, d_flux=%.2e", + rho, + state.neutron_flux, + d_flux, + ) diff --git a/src/reactor_sim/reactor.py b/src/reactor_sim/reactor.py new file mode 100644 index 0000000..a6cd9db --- /dev/null +++ b/src/reactor_sim/reactor.py @@ -0,0 +1,263 @@ +"""High-level reactor orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import logging + +from . import constants +from .atomic import AtomicPhysics +from .commands import ReactorCommand +from .coolant import Pump +from .consumer import ElectricalConsumer +from .control import ControlSystem +from .failures import HealthMonitor +from .fuel import FuelAssembly, decay_heat_fraction +from .neutronics import NeutronDynamics +from .state import CoolantLoopState, CoreState, PlantState, TurbineState +from .thermal import ThermalSolver, heat_transfer +from .turbine import SteamGenerator, Turbine + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class Reactor: + fuel: FuelAssembly + neutronics: NeutronDynamics + control: ControlSystem + primary_pump: Pump + secondary_pump: Pump + thermal: ThermalSolver + steam_generator: SteamGenerator + turbine: Turbine + atomic_model: AtomicPhysics + consumer: ElectricalConsumer | None = None + health_monitor: HealthMonitor = field(default_factory=HealthMonitor) + primary_pump_active: bool = True + secondary_pump_active: bool = True + turbine_active: bool = True + shutdown: bool = False + + @classmethod + def default(cls) -> "Reactor": + atomic_model = AtomicPhysics() + return cls( + fuel=FuelAssembly(enrichment=0.045, mass_kg=80_000.0, atomic_physics=atomic_model), + neutronics=NeutronDynamics(), + control=ControlSystem(), + primary_pump=Pump(nominal_flow=18_000.0), + secondary_pump=Pump(nominal_flow=16_000.0, efficiency=0.85), + thermal=ThermalSolver(), + steam_generator=SteamGenerator(), + turbine=Turbine(), + atomic_model=atomic_model, + consumer=ElectricalConsumer(name="Grid", demand_mw=800.0, online=False), + health_monitor=HealthMonitor(), + ) + + def initial_state(self) -> PlantState: + ambient = constants.ENVIRONMENT_TEMPERATURE + core = CoreState( + fuel_temperature=ambient, + neutron_flux=1e5, + reactivity_margin=-0.02, + power_output_mw=0.1, + burnup=0.0, + ) + primary = CoolantLoopState( + temperature_in=ambient, + temperature_out=ambient, + pressure=0.5, + mass_flow_rate=0.0, + steam_quality=0.0, + ) + secondary = CoolantLoopState( + temperature_in=ambient, + temperature_out=ambient, + pressure=0.5, + mass_flow_rate=0.0, + steam_quality=0.0, + ) + turbine = TurbineState( + steam_enthalpy=2_000.0, + shaft_power_mw=0.0, + electrical_output_mw=0.0, + condenser_temperature=ambient, + load_demand_mw=0.0, + load_supplied_mw=0.0, + ) + return PlantState(core=core, primary_loop=primary, secondary_loop=secondary, turbine=turbine) + + def step(self, state: PlantState, dt: float, command: ReactorCommand | None = None) -> None: + if self.shutdown: + rod_fraction = self.control.rod_fraction + else: + rod_fraction = self.control.update_rods(state.core, dt) + + overrides = {} + if command: + overrides = self._apply_command(command, state) + rod_fraction = overrides.get("rod_fraction", rod_fraction) + + self.neutronics.step(state.core, rod_fraction, dt) + + prompt_power, 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 + state.core.update_burnup(dt) + + pump_demand = overrides.get("coolant_demand", self.control.coolant_demand(state.primary_loop)) + if self.primary_pump_active: + self.primary_pump.step(state.primary_loop, pump_demand) + else: + state.primary_loop.mass_flow_rate = 0.0 + state.primary_loop.pressure = 0.5 + if self.secondary_pump_active: + self.secondary_pump.step(state.secondary_loop, 0.75) + else: + state.secondary_loop.mass_flow_rate = 0.0 + state.secondary_loop.pressure = 0.5 + + self.thermal.step_core(state.core, state.primary_loop, total_power, dt) + transferred = heat_transfer(state.primary_loop, state.secondary_loop, total_power) + self.thermal.step_secondary(state.secondary_loop, transferred) + + if self.turbine_active: + self.turbine.step(state.secondary_loop, state.turbine, self.consumer) + else: + state.turbine.shaft_power_mw = 0.0 + state.turbine.electrical_output_mw = 0.0 + if self.consumer: + self.consumer.update_power_received(0.0) + + failures = self.health_monitor.evaluate( + state, + self.primary_pump_active, + self.secondary_pump_active, + self.turbine_active, + dt, + ) + for failure in failures: + self._handle_failure(failure) + + state.time_elapsed += dt + + LOGGER.info( + ( + "t=%5.1fs rods=%.2f core_power=%.1fMW prompt=%.1fMW :: " + "%s-%d + %s-%d, 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, + state.primary_loop.temperature_out, + state.turbine.electrical_output_mw, + state.turbine.load_supplied_mw, + state.turbine.load_demand_mw, + ) + + def _handle_failure(self, component: str) -> None: + if component == "core": + LOGGER.critical("Core failure detected. Initiating SCRAM.") + self.shutdown = True + self.control.scram() + elif component == "primary_pump": + self._set_primary_pump(False) + elif component == "secondary_pump": + self._set_secondary_pump(False) + elif component == "turbine": + self._set_turbine_state(False) + + def _apply_command(self, command: ReactorCommand, state: PlantState) -> dict[str, float]: + overrides: dict[str, float] = {} + if command.scram: + self.shutdown = True + overrides["rod_fraction"] = self.control.scram() + self._set_turbine_state(False) + if command.power_setpoint is not None: + self.control.set_power_setpoint(command.power_setpoint) + if command.rod_position is not None: + 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: + overrides["rod_fraction"] = self.control.increment_rods(command.rod_step) + if command.primary_pump_on is not None: + self._set_primary_pump(command.primary_pump_on) + if command.secondary_pump_on is not None: + self._set_secondary_pump(command.secondary_pump_on) + if command.turbine_on is not None: + self._set_turbine_state(command.turbine_on) + if command.consumer_online is not None and self.consumer: + self.consumer.set_online(command.consumer_online) + if command.consumer_demand is not None and self.consumer: + self.consumer.set_demand(command.consumer_demand) + if command.coolant_demand is not None: + overrides["coolant_demand"] = max(0.0, min(1.0, command.coolant_demand)) + return overrides + + def _set_primary_pump(self, active: bool) -> None: + if self.primary_pump_active != active: + self.primary_pump_active = active + LOGGER.info("Primary pump %s", "enabled" if active else "stopped") + + def _set_secondary_pump(self, active: bool) -> None: + if self.secondary_pump_active != active: + self.secondary_pump_active = active + LOGGER.info("Secondary pump %s", "enabled" if active else "stopped") + + def _set_turbine_state(self, active: bool) -> None: + if self.turbine_active != active: + self.turbine_active = active + LOGGER.info("Turbine %s", "started" if active else "stopped") + + def attach_consumer(self, consumer: ElectricalConsumer) -> None: + self.consumer = consumer + LOGGER.info("Attached consumer %s (%.1f MW)", consumer.name, consumer.demand_mw) + + def detach_consumer(self) -> None: + if self.consumer: + LOGGER.info("Detached consumer %s", self.consumer.name) + self.consumer = None + + def save_state(self, filepath: str, state: PlantState) -> None: + metadata = { + "primary_pump_active": self.primary_pump_active, + "secondary_pump_active": self.secondary_pump_active, + "turbine_active": self.turbine_active, + "shutdown": self.shutdown, + "consumer": { + "online": self.consumer.online if self.consumer else False, + "demand_mw": self.consumer.demand_mw if self.consumer else 0.0, + "name": self.consumer.name if self.consumer else None, + }, + } + self.control.save_state(filepath, state, metadata, self.health_monitor.snapshot()) + + def load_state(self, filepath: str) -> PlantState: + plant, metadata, health = self.control.load_state(filepath) + self.primary_pump_active = metadata.get("primary_pump_active", self.primary_pump_active) + self.secondary_pump_active = metadata.get("secondary_pump_active", self.secondary_pump_active) + self.turbine_active = metadata.get("turbine_active", self.turbine_active) + self.shutdown = metadata.get("shutdown", self.shutdown) + consumer_cfg = metadata.get("consumer") + if consumer_cfg: + if not self.consumer: + self.consumer = ElectricalConsumer( + name=consumer_cfg.get("name") or "External", + demand_mw=consumer_cfg.get("demand_mw", 0.0), + online=consumer_cfg.get("online", False), + ) + else: + self.consumer.set_demand(consumer_cfg.get("demand_mw", self.consumer.demand_mw)) + self.consumer.set_online(consumer_cfg.get("online", self.consumer.online)) + if health: + self.health_monitor.load_snapshot(health) + LOGGER.info("Reactor state restored from %s", filepath) + return plant diff --git a/src/reactor_sim/simulation.py b/src/reactor_sim/simulation.py new file mode 100644 index 0000000..383225a --- /dev/null +++ b/src/reactor_sim/simulation.py @@ -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() diff --git a/src/reactor_sim/state.py b/src/reactor_sim/state.py new file mode 100644 index 0000000..1ea79bd --- /dev/null +++ b/src/reactor_sim/state.py @@ -0,0 +1,77 @@ +"""Dataclasses that capture the thermal-hydraulic state of the plant.""" + +from __future__ import annotations + +from dataclasses import dataclass, field, asdict + + +def clamp(value: float, min_value: float, max_value: float) -> float: + return max(min_value, min(max_value, value)) + + +@dataclass +class CoreState: + fuel_temperature: float # Kelvin + neutron_flux: float # neutrons/cm^2-s equivalent + reactivity_margin: float # delta rho + power_output_mw: float # MW thermal + burnup: float # fraction of fuel consumed + + def update_burnup(self, dt: float) -> None: + produced_energy_mwh = self.power_output_mw * (dt / 3600.0) + self.burnup = clamp(self.burnup + produced_energy_mwh * 1e-5, 0.0, 0.99) + + +@dataclass +class CoolantLoopState: + temperature_in: float # K + temperature_out: float # K + pressure: float # MPa + mass_flow_rate: float # kg/s + steam_quality: float # fraction of vapor + + def average_temperature(self) -> float: + return 0.5 * (self.temperature_in + self.temperature_out) + + +@dataclass +class TurbineState: + steam_enthalpy: float # kJ/kg + shaft_power_mw: float + electrical_output_mw: float + condenser_temperature: float + load_demand_mw: float = 0.0 + load_supplied_mw: float = 0.0 + + +@dataclass +class PlantState: + core: CoreState + primary_loop: CoolantLoopState + secondary_loop: CoolantLoopState + turbine: TurbineState + time_elapsed: float = field(default=0.0) + + def snapshot(self) -> dict[str, float]: + return { + "time_elapsed": self.time_elapsed, + "core_temp": self.core.fuel_temperature, + "core_power": self.core.power_output_mw, + "neutron_flux": self.core.neutron_flux, + "primary_outlet_temp": self.primary_loop.temperature_out, + "secondary_pressure": self.secondary_loop.pressure, + "turbine_electric": self.turbine.electrical_output_mw, + } + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict) -> "PlantState": + return cls( + core=CoreState(**data["core"]), + primary_loop=CoolantLoopState(**data["primary_loop"]), + secondary_loop=CoolantLoopState(**data["secondary_loop"]), + turbine=TurbineState(**data["turbine"]), + time_elapsed=data.get("time_elapsed", 0.0), + ) diff --git a/src/reactor_sim/thermal.py b/src/reactor_sim/thermal.py new file mode 100644 index 0000000..adeda99 --- /dev/null +++ b/src/reactor_sim/thermal.py @@ -0,0 +1,55 @@ +"""Thermal hydraulics approximations.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from . import constants +from .state import CoolantLoopState, CoreState + +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) + LOGGER.debug("Heat transfer %.2f MW with ΔT=%.1fK", transferred, delta_t) + return transferred + + +def temperature_rise(power_mw: float, mass_flow: float) -> float: + if mass_flow <= 0: + return 0.0 + return (power_mw * constants.MEGAWATT) / (mass_flow * constants.COOLANT_HEAT_CAPACITY) + + +@dataclass +class ThermalSolver: + primary_volume_m3: float = 300.0 + + def step_core(self, core: CoreState, primary: CoolantLoopState, power_mw: float, dt: float) -> None: + temp_rise = temperature_rise(power_mw, primary.mass_flow_rate) + primary.temperature_out = primary.temperature_in + temp_rise + core.fuel_temperature += 0.1 * (power_mw - temp_rise) * dt + core.fuel_temperature = min(core.fuel_temperature, constants.MAX_CORE_TEMPERATURE) + LOGGER.debug( + "Primary loop: flow=%.0f kg/s temp_out=%.1fK core_temp=%.1fK", + primary.mass_flow_rate, + primary.temperature_out, + core.fuel_temperature, + ) + + def step_secondary(self, secondary: CoolantLoopState, transferred_mw: float) -> None: + delta_t = temperature_rise(transferred_mw, secondary.mass_flow_rate) + secondary.temperature_out = secondary.temperature_in + delta_t + secondary.steam_quality = min(1.0, max(0.0, delta_t / 100.0)) + secondary.pressure = min(constants.MAX_PRESSURE, 6.0 + delta_t * 0.01) + LOGGER.debug( + "Secondary loop: transferred=%.1fMW temp_out=%.1fK quality=%.2f", + transferred_mw, + secondary.temperature_out, + secondary.steam_quality, + ) diff --git a/src/reactor_sim/turbine.py b/src/reactor_sim/turbine.py new file mode 100644 index 0000000..1303d2d --- /dev/null +++ b/src/reactor_sim/turbine.py @@ -0,0 +1,69 @@ +"""Steam generator and turbine performance models.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from . import constants +from typing import Optional + +from .state import CoolantLoopState, TurbineState +from .consumer import ElectricalConsumer + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class SteamGenerator: + drum_volume_m3: float = 200.0 + + def steam_enthalpy(self, loop: CoolantLoopState) -> float: + base = 2_700.0 # kJ/kg saturated steam + quality_adjustment = 500.0 * loop.steam_quality + return base + quality_adjustment + + +@dataclass +class Turbine: + generator_efficiency: float = constants.GENERATOR_EFFICIENCY + mechanical_efficiency: float = constants.STEAM_TURBINE_EFFICIENCY + + def step( + self, + loop: CoolantLoopState, + state: TurbineState, + consumer: Optional[ElectricalConsumer] = None, + ) -> 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 + electrical = shaft_power_mw * self.generator_efficiency + if consumer: + load_demand = consumer.request_power() + supplied = min(electrical, load_demand) + consumer.update_power_received(supplied) + LOGGER.debug( + "Consumer %s demand %.1f -> supplied %.1f MW", + consumer.name, + load_demand, + supplied, + ) + else: + load_demand = 0.0 + supplied = 0.0 + condenser_temp = max(305.0, loop.temperature_in - 20.0) + state.steam_enthalpy = enthalpy + state.shaft_power_mw = shaft_power_mw + state.electrical_output_mw = electrical + state.condenser_temperature = condenser_temp + state.load_demand_mw = load_demand + state.load_supplied_mw = supplied + LOGGER.debug( + "Turbine output: shaft=%.1fMW electrical=%.1fMW condenser=%.1fK load %.1f/%.1f", + shaft_power_mw, + electrical, + condenser_temp, + supplied, + load_demand, + ) diff --git a/tests/__pycache__/test_simulation.cpython-310-pytest-9.0.1.pyc b/tests/__pycache__/test_simulation.cpython-310-pytest-9.0.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e5540775c7f7742949ecfa5f26c899b065b3a39 GIT binary patch literal 5168 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 literal 0 HcmV?d00001 diff --git a/tests/test_simulation.py b/tests/test_simulation.py new file mode 100644 index 0000000..c0d3ee7 --- /dev/null +++ b/tests/test_simulation.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path + +import pytest + +from reactor_sim import constants +from reactor_sim.reactor import Reactor +from reactor_sim.simulation import ReactorSimulation + + +def test_reactor_initial_state_is_cold(): + reactor = Reactor.default() + state = reactor.initial_state() + assert state.core.fuel_temperature == constants.ENVIRONMENT_TEMPERATURE + assert state.primary_loop.mass_flow_rate == 0.0 + assert state.turbine.electrical_output_mw == 0.0 + + +def test_state_save_and_load_roundtrip(tmp_path: Path): + reactor = Reactor.default() + sim = ReactorSimulation(reactor, timestep=5.0, duration=15.0) + sim.log() + save_path = tmp_path / "plant_state.json" + assert sim.last_state is not None + reactor.save_state(str(save_path), sim.last_state) + assert save_path.exists() + restored_reactor = Reactor.default() + restored_state = restored_reactor.load_state(str(save_path)) + assert restored_state.core.fuel_temperature == pytest.approx( + sim.last_state.core.fuel_temperature + ) + assert restored_reactor.control.rod_fraction == reactor.control.rod_fraction + + +def test_health_monitor_flags_core_failure(): + reactor = Reactor.default() + state = reactor.initial_state() + state.core.fuel_temperature = constants.MAX_CORE_TEMPERATURE + failures = reactor.health_monitor.evaluate(state, True, True, True, dt=200.0) + assert "core" in failures + reactor._handle_failure("core") + assert reactor.shutdown is True