diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..937fbf0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI + +on: + push: + branches: [ main, develop, claude/* ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests with pytest + run: | + pytest --cov=pyT5 --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff mypy + + - name: Lint with ruff + run: | + ruff check src/ tests/ + + - name: Check formatting with ruff + run: | + ruff format --check src/ tests/ + + - name: Type check with mypy + run: | + mypy src/ + + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: | + python -m build + + - name: Check package + run: | + pip install twine + twine check dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff72b19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,143 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +docs/_static/ +docs/_templates/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project specific +*.tripoli5 +*.h5 +*.hdf5 +output/ +results/ diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..0cef9aa --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,33 @@ +# Ruff configuration for pyT5 + +line-length = 100 +target-version = "py39" +src = ["src", "tests"] + +[lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify + "PL", # pylint + "RUF", # ruff-specific rules +] + +ignore = [ + "E501", # line too long (handled by formatter) + "PLR0913", # too many arguments + "PLR2004", # magic value comparison +] + +[lint.per-file-ignores] +"__init__.py" = ["F401"] # Allow unused imports +"tests/*" = ["ARG", "PLR2004"] # Allow unused arguments and magic values in tests + +[lint.isort] +known-first-party = ["pyT5"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9a3fb14 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 IRSN + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..2699e47 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include README.md +include LICENSE +include pyproject.toml +recursive-include src/pyT5 *.py py.typed +recursive-include tests *.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..e35e3d8 --- /dev/null +++ b/README.md @@ -0,0 +1,315 @@ +# pyT5: Python Interface for Tripoli-5 Monte-Carlo Code + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) + +## Overview + +**pyT5** is a user-friendly, object-oriented Python interface for the Tripoli-5 Monte-Carlo code, designed for modeling and simulating Pressurized Water Reactor (PWR) systems. It provides a high-level API for creating complex reactor geometries, from individual pin cells to full reactor cores. + +### Key Features + +- **Modular Architecture**: Object-oriented design with maximum modularity for easy use and maintenance +- **Multiple Geometry Levels**: Support for pin cells, fuel assemblies, colorsets, and full cores +- **Comprehensive Material Modeling**: Flexible material definition with temperature and nuclide composition +- **Simulation Control**: Full control over Monte-Carlo parameters, sources, and scores +- **Remote Computing**: Built-in support for HPC cluster execution +- **Type-Safe**: Complete type hints throughout the codebase +- **Well-Tested**: Comprehensive test suite with pytest + +## Installation + +### From Source + +```bash +git clone https://github.com/IRSN/pyT5.git +cd pyT5 +pip install -e . +``` + +### Development Installation + +```bash +pip install -e ".[dev]" +``` + +## Quick Start + +### Basic Pin Cell Model + +```python +import pyT5 + +# Define materials +fuel = pyT5.Material( + name="UO2_fuel", + nuclides={"U235": 0.03, "U238": 0.97, "O16": 2.0}, + temperature=900.0, + density=10.4, + state="solid" +) + +water = pyT5.Material( + name="light_water", + nuclides={"H1": 2.0, "O16": 1.0}, + temperature=300.0, + density=1.0, + state="liquid" +) + +# Create material library +materials = pyT5.MaterialLibrary() +materials.add_material(fuel) +materials.add_material(water) + +# Define pin cell geometry +pin = pyT5.PinCell( + name="fuel_pin", + pitch=1.26, + height=365.76, + fuel_radius=0.4096, + clad_inner_radius=0.418, + clad_outer_radius=0.475 +) + +# Calculate volumes +fuel_volume = pin.get_fuel_volume() +print(f"Fuel volume: {fuel_volume:.2f} cm³") +``` + +### Assembly Definition + +```python +# Create a 17x17 assembly +assembly = pyT5.Assembly( + name="17x17_assembly", + lattice_type="square", + n_pins_x=17, + n_pins_y=17, + pin_pitch=1.26, + assembly_pitch=21.5 +) + +# Populate assembly with pins +for i in range(17): + for j in range(17): + # Skip guide tube positions + if (i, j) not in [(5, 5), (8, 8), (11, 11)]: + assembly.set_pin(i, j, pin) + +print(f"Assembly contains {assembly.count_pins()} pins") +``` + +### Full Core Simulation + +```python +# Define nuclear data +nuclear_data = pyT5.NuclearData( + cross_section_library="path/to/xsections.dat", + temperature=300.0 +) +nuclear_data.validate() + +# Create core geometry +core = pyT5.Core( + name="PWR_core", + core_type="square", + n_assemblies_x=15, + n_assemblies_y=15 +) + +# Place assemblies in core +for i in range(15): + for j in range(15): + core.set_assembly((i, j), assembly) + +# Define neutron source +source = pyT5.NeutronSource( + name="fission_source", + source_type="criticality", + intensity=1.0e6 +) + +# Define scores +scores = pyT5.ScoreLibrary() +keff_score = pyT5.Score(name="k_effective", score_type="keff") +flux_score = pyT5.Score( + name="core_flux", + score_type="flux", + cells=["fuel_cell"] +) +scores.add_score(keff_score) +scores.add_score(flux_score) + +# Set up simulation +sim = pyT5.Simulation( + name="PWR_criticality", + n_particles=10000, + n_cycles=150, + n_inactive=50, + n_threads=8 +) + +sim.set_nuclear_data(nuclear_data) +sim.set_materials(materials) +sim.set_geometry(core) +sim.set_source(source) +sim.set_scores(scores) + +# Run simulation +results = sim.run() + +# Extract results +keff, keff_std = results.get_k_effective() +print(f"k-effective: {keff:.5f} ± {keff_std:.5f}") +``` + +### Visualization + +```python +# Add geometry visualization +viz = pyT5.Visualization( + name="xy_midplane", + plot_type="2D", + plane="xy", + position=182.88, # cm + extent=(-150, 150, -150, 150), + resolution=(1000, 1000) +) + +sim.add_visualization(viz) +``` + +### Remote Computing + +```python +# Configure remote execution +remote = pyT5.RemoteCompute( + name="hpc_cluster", + host="cluster.example.com", + username="user", + scheduler="slurm", + queue="standard", + walltime=24.0, + nodes=4, + cores_per_node=32 +) + +# Submit job +job_id = remote.submit_job(sim) +print(f"Job submitted with ID: {job_id}") + +# Check status +status = remote.check_status(job_id) +print(f"Job status: {status}") + +# Retrieve results when complete +results_dir = remote.retrieve_results(job_id) +``` + +## Core Components + +### Materials (`pyT5.Material`) +- Define materials with nuclide composition +- Specify temperature and density +- Support for solid, liquid, and gas states + +### Cells (`pyT5.Cell`) +- Geometric regions containing materials +- Volume and importance specifications +- Void cell support + +### Geometry Classes +- **`PinCell`**: Cylindrical fuel pin geometry +- **`Assembly`**: Square or hexagonal pin lattices +- **`Colorset`**: Collections of assemblies +- **`Core`**: Full reactor core layout +- **`Reflector`**: Core reflector regions + +### Simulation (`pyT5.Simulation`) +- Monte-Carlo parameter control +- Multi-threaded execution +- Integration of all simulation components + +### Scores (`pyT5.Score`) +- Flux tallies +- Reaction rates +- k-effective calculations +- Energy-dependent scores +- Spatial mesh tallies + +### Results (`pyT5.Results`) +- k-effective extraction with uncertainties +- Score value retrieval +- Statistical analysis +- Export functionality + +## Documentation + +Full documentation is available in the `docs/` directory and includes: + +- API Reference +- Detailed Examples +- Best Practices +- Integration with Tripoli-5 + +## Development + +### Running Tests + +```bash +pytest +``` + +### Code Quality + +```bash +# Run linting +ruff check src/ + +# Run type checking +mypy src/ + +# Format code +black src/ tests/ +``` + +## Design Philosophy + +pyT5 is inspired by the [PyDrag](https://pydrag.asnr.dev/PyDrag/index.html) package, providing a user-friendly, high-level modeling overlay for the Tripoli-5 API. The package emphasizes: + +- **Simplicity**: Intuitive API for common reactor modeling tasks +- **Modularity**: Independent, reusable components +- **Type Safety**: Comprehensive type hints for IDE support +- **Extensibility**: Easy to extend with custom components + +## Related Projects + +- [Tripoli-5 Documentation](https://tripoli5.asnr.dev/documentation/examples/index.html) +- [Tripoli-5 Python API](https://tripoli5.asnr.dev/documentation/api/python-api.html) +- [PyDrag Package](https://pydrag.asnr.dev/PyDrag/index.html) + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## Citation + +If you use pyT5 in your research, please cite: + +```bibtex +@software{pyT5, + title = {pyT5: Python Interface for Tripoli-5 Monte-Carlo Code}, + author = {IRSN}, + year = {2025}, + url = {https://github.com/IRSN/pyT5} +} +``` + +## Support + +For issues, questions, or contributions, please visit our [GitHub repository](https://github.com/IRSN/pyT5). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..0de0c43 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,47 @@ +# pyT5 Documentation + +Welcome to the pyT5 documentation! pyT5 is a Python interface for the Tripoli-5 Monte-Carlo code, designed for modeling and simulating PWR reactor systems. + +## Contents + +- [Getting Started](getting_started.md) +- [API Reference](api_reference.md) +- [Examples](examples/) + - [Pin Cell Example](examples/pin_cell_example.md) + - [Assembly Example](examples/assembly_example.md) + - [Core Simulation Example](examples/core_simulation_example.md) +- [Best Practices](best_practices.md) +- [Integration with Tripoli-5](tripoli5_integration.md) + +## Quick Links + +- [GitHub Repository](https://github.com/IRSN/pyT5) +- [Tripoli-5 Documentation](https://tripoli5.asnr.dev/documentation/examples/index.html) +- [PyDrag Package](https://pydrag.asnr.dev/PyDrag/index.html) + +## Overview + +pyT5 provides an object-oriented interface for creating complex reactor models: + +```python +import pyT5 + +# Create materials +fuel = pyT5.Material(name="UO2", nuclides={"U235": 0.03, "U238": 0.97, "O16": 2.0}) + +# Define geometry +pin = pyT5.PinCell(name="fuel_pin", pitch=1.26, height=365.76, ...) + +# Set up simulation +sim = pyT5.Simulation(name="PWR", n_particles=10000) +results = sim.run() +``` + +## Features + +- Modular, object-oriented architecture +- Support for pin cells, assemblies, and full cores +- Comprehensive material modeling +- Remote computing support +- Type-safe with full type hints +- Extensive test coverage diff --git a/examples/assembly_model.py b/examples/assembly_model.py new file mode 100644 index 0000000..66d00de --- /dev/null +++ b/examples/assembly_model.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Example: 17x17 PWR assembly model with pyT5. + +This example demonstrates how to create a typical 17x17 PWR fuel assembly +with fuel pins and guide tubes. +""" + +import pyT5 + + +def main() -> None: + """Create a 17x17 PWR assembly.""" + print("=" * 60) + print("pyT5 Example: 17x17 PWR Assembly") + print("=" * 60) + + # Define materials + print("\n1. Defining materials...") + fuel = pyT5.Material( + name="UO2_fuel", + nuclides={"U235": 0.03, "U238": 0.97, "O16": 2.0}, + temperature=900.0, + density=10.4, + state="solid", + ) + + water = pyT5.Material( + name="light_water", + nuclides={"H1": 2.0, "O16": 1.0}, + temperature=580.0, + density=0.74, + state="liquid", + ) + + # Create material library + materials = pyT5.MaterialLibrary() + materials.add_material(fuel) + materials.add_material(water) + print(f" Created {len(materials)} materials") + + # Create fuel pin + print("\n2. Creating pin cells...") + fuel_pin = pyT5.PinCell( + name="fuel_pin", + pitch=1.26, + height=365.76, + fuel_radius=0.4096, + clad_inner_radius=0.4178, + clad_outer_radius=0.4750, + ) + print(f" Fuel pin created: pitch={fuel_pin.pitch} cm") + + # Create guide tube (larger diameter, no fuel) + guide_tube = pyT5.PinCell( + name="guide_tube", + pitch=1.26, + height=365.76, + fuel_radius=0.001, # Small dummy value + clad_inner_radius=0.561, + clad_outer_radius=0.602, + ) + print(f" Guide tube created") + + # Create 17x17 assembly + print("\n3. Creating 17x17 assembly...") + assembly = pyT5.Assembly( + name="17x17_PWR_assembly", + lattice_type="square", + n_pins_x=17, + n_pins_y=17, + pin_pitch=1.26, + assembly_pitch=21.50, + ) + + # Guide tube positions in a typical 17x17 assembly (24 positions) + guide_tube_positions = [ + (2, 5), (2, 8), (2, 11), + (5, 2), (5, 5), (5, 8), (5, 11), (5, 14), + (8, 2), (8, 5), (8, 8), (8, 11), (8, 14), + (11, 2), (11, 5), (11, 8), (11, 11), (11, 14), + (14, 5), (14, 8), (14, 11), + ] + + # Populate assembly + print("\n4. Populating assembly with pins...") + fuel_count = 0 + guide_count = 0 + + for i in range(17): + for j in range(17): + if (i, j) in guide_tube_positions: + assembly.set_pin(i, j, guide_tube) + guide_count += 1 + else: + assembly.set_pin(i, j, fuel_pin) + fuel_count += 1 + + print(f" Fuel pins: {fuel_count}") + print(f" Guide tubes: {guide_count}") + print(f" Total pins: {assembly.count_pins()}") + + # Assembly statistics + print("\n5. Assembly statistics...") + total_fuel_volume = fuel_count * fuel_pin.get_fuel_volume() + assembly_volume = assembly.assembly_pitch**2 * assembly.n_pins_x * fuel_pin.height + + print(f" Assembly pitch: {assembly.assembly_pitch:.2f} cm") + print(f" Total fuel volume: {total_fuel_volume:.2f} cm³") + print(f" Assembly volume: {assembly_volume:.2f} cm³") + + print("\n" + "=" * 60) + print("Example completed successfully!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/examples/pin_cell_model.py b/examples/pin_cell_model.py new file mode 100644 index 0000000..416f781 --- /dev/null +++ b/examples/pin_cell_model.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Example: Basic pin cell model with pyT5. + +This example demonstrates how to create a simple fuel pin cell model +and calculate basic geometric properties. +""" + +import pyT5 + + +def main() -> None: + """Create and analyze a fuel pin cell.""" + print("=" * 60) + print("pyT5 Example: Pin Cell Model") + print("=" * 60) + + # Define fuel material + print("\n1. Defining materials...") + fuel = pyT5.Material( + name="UO2_fuel", + nuclides={ + "U235": 0.03, # 3% enrichment + "U238": 0.97, + "O16": 2.0, + }, + temperature=900.0, # Kelvin + density=10.4, # g/cm³ + state="solid", + ) + print(f" Created material: {fuel}") + + # Define cladding material (Zircaloy-4) + clad = pyT5.Material( + name="Zircaloy4", + nuclides={ + "Zr90": 0.5145, + "Zr91": 0.1122, + "Zr92": 0.1715, + "Zr94": 0.1738, + "Zr96": 0.0280, + }, + temperature=600.0, + density=6.56, + state="solid", + ) + print(f" Created material: {clad}") + + # Define moderator (water) + water = pyT5.Material( + name="light_water", + nuclides={"H1": 2.0, "O16": 1.0}, + temperature=580.0, # PWR operating temperature + density=0.74, # g/cm³ at operating conditions + state="liquid", + ) + print(f" Created material: {water}") + + # Create material library + materials = pyT5.MaterialLibrary() + materials.add_material(fuel) + materials.add_material(clad) + materials.add_material(water) + print(f"\n Material library contains {len(materials)} materials") + + # Define pin cell geometry (typical PWR dimensions) + print("\n2. Creating pin cell geometry...") + pin = pyT5.PinCell( + name="PWR_fuel_pin", + pitch=1.26, # cm + height=365.76, # cm (active fuel height) + fuel_radius=0.4096, # cm + clad_inner_radius=0.4178, # cm + clad_outer_radius=0.4750, # cm + ) + print(f" Created pin: {pin}") + + # Calculate volumes + print("\n3. Calculating volumes...") + fuel_volume = pin.get_fuel_volume() + gap_volume = pin.get_gap_volume() + clad_volume = pin.get_clad_volume() + moderator_volume = (pin.pitch**2 * pin.height) - ( + 3.14159 * pin.clad_outer_radius**2 * pin.height + ) + + print(f" Fuel volume: {fuel_volume:.2f} cm³") + print(f" Gap volume: {gap_volume:.2f} cm³") + print(f" Cladding volume: {clad_volume:.2f} cm³") + print(f" Moderator volume: {moderator_volume:.2f} cm³") + + # Calculate fuel-to-moderator ratio + fm_ratio = fuel_volume / moderator_volume + print(f"\n Fuel-to-moderator ratio: {fm_ratio:.4f}") + + print("\n" + "=" * 60) + print("Example completed successfully!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..bf5f4f6 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,22 @@ +[mypy] +python_version = 3.9 +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_untyped_decorators = True +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_no_return = True +strict_equality = True +mypy_path = src +namespace_packages = True +explicit_package_bases = True + +[mypy-numpy.*] +ignore_missing_imports = True + +[mypy-pytest.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..77c7a31 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,122 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyT5" +version = "0.1.0" +description = "Python interface for Tripoli-5 Monte-Carlo code computing for PWR reactor modeling" +readme = "README.md" +requires-python = ">=3.9" +license = {text = "MIT"} +authors = [ + {name = "pyT5 Development Team"} +] +maintainers = [ + {name = "pyT5 Development Team"} +] +keywords = [ + "nuclear", + "monte-carlo", + "tripoli-5", + "reactor", + "simulation", + "PWR", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Physics", +] +dependencies = [ + "numpy>=1.20.0", + "typing-extensions>=4.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "mypy>=1.0.0", + "ruff>=0.1.0", + "black>=23.0.0", +] +docs = [ + "sphinx>=6.0.0", + "sphinx-rtd-theme>=1.0.0", +] + +[project.urls] +Homepage = "https://github.com/IRSN/pyT5" +Documentation = "https://github.com/IRSN/pyT5/docs" +Repository = "https://github.com/IRSN/pyT5" +Issues = "https://github.com/IRSN/pyT5/issues" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +pyT5 = ["py.typed"] + +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "-ra -q --strict-markers --cov=pyT5 --cov-report=term-missing" +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.mypy] +python_version = "3.9" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +strict_equality = true +mypy_path = "src" +namespace_packages = true +explicit_package_bases = true + +[tool.ruff] +line-length = 100 +target-version = "py39" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # Allow unused imports in __init__.py +"tests/*" = ["ARG"] # Allow unused arguments in tests + +[tool.ruff.lint.isort] +known-first-party = ["pyT5"] + +[tool.black] +line-length = 100 +target-version = ["py39", "py310", "py311", "py312"] +include = '\.pyi?$' diff --git a/src/pyT5/__init__.py b/src/pyT5/__init__.py new file mode 100644 index 0000000..d6b5189 --- /dev/null +++ b/src/pyT5/__init__.py @@ -0,0 +1,74 @@ +"""pyT5: Python interface for Tripoli-5 Monte-Carlo code computing. + +pyT5 provides a user-friendly, object-oriented interface for modeling +and simulating PWR reactors at different levels: pin cells, fuel assemblies, +colorsets, and full reactor cores. + +Example: + >>> import pyT5 + >>> # Define materials + >>> fuel = pyT5.Material("UO2", {"U235": 0.03, "U238": 0.97, "O16": 2.0}) + >>> # Create geometry + >>> pin = pyT5.PinCell("fuel_pin", pitch=1.26, height=365.76, ...) + >>> # Set up simulation + >>> sim = pyT5.Simulation("PWR_criticality", n_particles=10000) + >>> results = sim.run() +""" + +__version__ = "0.1.0" +__author__ = "pyT5 Development Team" + +# Core imports +from .nuclear_data import NuclearData +from .materials import Material, MaterialLibrary +from .cells import Cell, CellLibrary +from .geometry import ( + GeometryBase, + PinCell, + Assembly, + Colorset, + Core, + Reflector, +) +from .source import NeutronSource, NeutronMedia +from .visualization import Visualization +from .scores import Score, ScoreLibrary +from .simulation import Simulation +from .remote import RemoteCompute +from .results import Results + +# Define public API +__all__ = [ + # Version info + "__version__", + "__author__", + # Nuclear data + "NuclearData", + # Materials + "Material", + "MaterialLibrary", + # Cells + "Cell", + "CellLibrary", + # Geometry + "GeometryBase", + "PinCell", + "Assembly", + "Colorset", + "Core", + "Reflector", + # Source + "NeutronSource", + "NeutronMedia", + # Visualization + "Visualization", + # Scores + "Score", + "ScoreLibrary", + # Simulation + "Simulation", + # Remote computing + "RemoteCompute", + # Results + "Results", +] diff --git a/src/pyT5/cells.py b/src/pyT5/cells.py new file mode 100644 index 0000000..ec24732 --- /dev/null +++ b/src/pyT5/cells.py @@ -0,0 +1,213 @@ +"""Cell definition module for pyT5. + +This module provides classes for defining cells that contain materials +and define regions in the geometry for Tripoli-5 simulations. +""" + +from typing import Dict, List, Optional, Union +from .materials import Material + + +class Cell: + """Represents a geometric cell containing a material. + + A cell is a region of space defined by boundary surfaces and filled + with a specific material. Cells are the building blocks of geometry + in Monte-Carlo simulations. + + Attributes: + name: Unique identifier for the cell. + material: Material filling the cell (None for void). + volume: Cell volume in cm³ (optional). + importance: Neutron importance for variance reduction. + + Examples: + >>> fuel_cell = Cell( + ... name="fuel_pin", + ... material=uo2_material, + ... volume=100.0, + ... importance=1.0 + ... ) + """ + + def __init__( + self, + name: str, + material: Optional[Material] = None, + volume: Optional[float] = None, + importance: float = 1.0, + ) -> None: + """Initialize Cell object. + + Args: + name: Unique identifier for the cell. + material: Material object filling the cell. None represents void. + volume: Cell volume in cm³. If None, will be calculated by + Tripoli-5 during simulation. + importance: Neutron importance for variance reduction. Values > 1 + increase sampling, < 1 decrease sampling. Defaults to 1.0. + + Raises: + ValueError: If volume or importance is negative. + """ + if volume is not None and volume < 0: + raise ValueError(f"Volume must be non-negative, got {volume}") + if importance < 0: + raise ValueError(f"Importance must be non-negative, got {importance}") + + self.name = name + self.material = material + self.volume = volume + self.importance = importance + + def set_material(self, material: Optional[Material]) -> None: + """Set or update the material filling the cell. + + Args: + material: Material object, or None for void. + """ + self.material = material + + def set_volume(self, volume: float) -> None: + """Set the cell volume. + + Args: + volume: Volume in cm³. + + Raises: + ValueError: If volume is negative. + """ + if volume < 0: + raise ValueError(f"Volume must be non-negative, got {volume}") + self.volume = volume + + def set_importance(self, importance: float) -> None: + """Set the neutron importance for variance reduction. + + Args: + importance: Importance weight factor. + + Raises: + ValueError: If importance is negative. + """ + if importance < 0: + raise ValueError(f"Importance must be non-negative, got {importance}") + self.importance = importance + + def is_void(self) -> bool: + """Check if the cell is void (contains no material). + + Returns: + True if cell is void, False otherwise. + """ + return self.material is None + + def __repr__(self) -> str: + """Return string representation of Cell object.""" + material_name = self.material.name if self.material else "void" + return ( + f"Cell(name='{self.name}', material='{material_name}', " + f"volume={self.volume} cm³, importance={self.importance})" + ) + + +class CellLibrary: + """Collection of cells for a simulation. + + This class manages a library of cells that can be used to build + complex geometries in Tripoli-5 simulations. + + Attributes: + cells: Dictionary mapping cell names to Cell objects. + + Examples: + >>> library = CellLibrary() + >>> library.add_cell(fuel_cell) + >>> library.add_cell(clad_cell) + >>> cell = library.get_cell("fuel_pin") + """ + + def __init__(self) -> None: + """Initialize empty CellLibrary.""" + self.cells: Dict[str, Cell] = {} + + def add_cell(self, cell: Cell) -> None: + """Add a cell to the library. + + Args: + cell: Cell object to add. + + Raises: + ValueError: If cell with same name already exists. + """ + if cell.name in self.cells: + raise ValueError(f"Cell '{cell.name}' already exists in library") + self.cells[cell.name] = cell + + def remove_cell(self, name: str) -> None: + """Remove a cell from the library. + + Args: + name: Name of the cell to remove. + + Raises: + KeyError: If cell not found in library. + """ + if name not in self.cells: + raise KeyError(f"Cell '{name}' not found in library") + del self.cells[name] + + def get_cell(self, name: str) -> Cell: + """Retrieve a cell from the library. + + Args: + name: Name of the cell to retrieve. + + Returns: + Cell object. + + Raises: + KeyError: If cell not found in library. + """ + if name not in self.cells: + raise KeyError(f"Cell '{name}' not found in library") + return self.cells[name] + + def list_cells(self) -> List[str]: + """Get list of all cell names in the library. + + Returns: + List of cell names. + """ + return list(self.cells.keys()) + + def get_cells_by_material(self, material_name: str) -> List[Cell]: + """Get all cells containing a specific material. + + Args: + material_name: Name of the material to search for. + + Returns: + List of Cell objects containing the specified material. + """ + return [ + cell + for cell in self.cells.values() + if cell.material and cell.material.name == material_name + ] + + def get_void_cells(self) -> List[Cell]: + """Get all void cells (cells with no material). + + Returns: + List of void Cell objects. + """ + return [cell for cell in self.cells.values() if cell.is_void()] + + def __len__(self) -> int: + """Return number of cells in the library.""" + return len(self.cells) + + def __repr__(self) -> str: + """Return string representation of CellLibrary object.""" + return f"CellLibrary(cells={len(self.cells)})" diff --git a/src/pyT5/geometry.py b/src/pyT5/geometry.py new file mode 100644 index 0000000..ac21216 --- /dev/null +++ b/src/pyT5/geometry.py @@ -0,0 +1,453 @@ +"""Geometry definition module for pyT5. + +This module provides classes for defining various geometric objects +in PWR reactor simulations, from pin cells to full cores. +""" + +from typing import Dict, List, Optional, Tuple, Union +import numpy as np +from .cells import Cell + + +class GeometryBase: + """Base class for all geometry objects. + + Provides common functionality for geometric objects in the simulation. + + Attributes: + name: Unique identifier for the geometry object. + cells: List of Cell objects contained in this geometry. + """ + + def __init__(self, name: str) -> None: + """Initialize GeometryBase object. + + Args: + name: Unique identifier for the geometry object. + """ + self.name = name + self.cells: List[Cell] = [] + + def add_cell(self, cell: Cell) -> None: + """Add a cell to the geometry. + + Args: + cell: Cell object to add. + """ + self.cells.append(cell) + + def get_cells(self) -> List[Cell]: + """Get all cells in the geometry. + + Returns: + List of Cell objects. + """ + return self.cells + + def __repr__(self) -> str: + """Return string representation of geometry object.""" + return f"{self.__class__.__name__}(name='{self.name}', cells={len(self.cells)})" + + +class PinCell(GeometryBase): + """Represents a fuel pin cell geometry. + + A pin cell typically consists of concentric cylindrical regions + (fuel, gap, cladding) surrounded by moderator. + + Attributes: + name: Unique identifier for the pin cell. + pitch: Pin cell pitch (distance between pin centers) in cm. + height: Pin cell height in cm. + fuel_radius: Fuel pellet radius in cm. + clad_inner_radius: Inner radius of cladding in cm. + clad_outer_radius: Outer radius of cladding in cm. + + Examples: + >>> pin = PinCell( + ... name="standard_pin", + ... pitch=1.26, + ... height=365.76, + ... fuel_radius=0.4096, + ... clad_inner_radius=0.418, + ... clad_outer_radius=0.475 + ... ) + """ + + def __init__( + self, + name: str, + pitch: float, + height: float, + fuel_radius: float, + clad_inner_radius: float, + clad_outer_radius: float, + ) -> None: + """Initialize PinCell object. + + Args: + name: Unique identifier for the pin cell. + pitch: Pin cell pitch in cm. + height: Pin cell height in cm. + fuel_radius: Fuel pellet radius in cm. + clad_inner_radius: Inner radius of cladding in cm. + clad_outer_radius: Outer radius of cladding in cm. + + Raises: + ValueError: If dimensions are invalid or inconsistent. + """ + super().__init__(name) + + if pitch <= 0 or height <= 0: + raise ValueError("Pitch and height must be positive") + if not (0 < fuel_radius < clad_inner_radius < clad_outer_radius < pitch / 2): + raise ValueError("Invalid radii: must satisfy 0 < r_fuel < r_clad_in < r_clad_out < pitch/2") + + self.pitch = pitch + self.height = height + self.fuel_radius = fuel_radius + self.clad_inner_radius = clad_inner_radius + self.clad_outer_radius = clad_outer_radius + + def get_fuel_volume(self) -> float: + """Calculate the fuel volume. + + Returns: + Fuel volume in cm³. + """ + return np.pi * self.fuel_radius**2 * self.height + + def get_gap_volume(self) -> float: + """Calculate the gap volume between fuel and cladding. + + Returns: + Gap volume in cm³. + """ + return np.pi * (self.clad_inner_radius**2 - self.fuel_radius**2) * self.height + + def get_clad_volume(self) -> float: + """Calculate the cladding volume. + + Returns: + Cladding volume in cm³. + """ + return np.pi * (self.clad_outer_radius**2 - self.clad_inner_radius**2) * self.height + + +class Assembly(GeometryBase): + """Represents a fuel assembly geometry. + + A fuel assembly consists of an array of pin cells arranged in a + rectangular or hexagonal lattice. + + Attributes: + name: Unique identifier for the assembly. + lattice_type: Type of lattice ('square' or 'hexagonal'). + n_pins_x: Number of pins in x-direction (for square lattice). + n_pins_y: Number of pins in y-direction (for square lattice). + pin_pitch: Distance between pin centers in cm. + assembly_pitch: Overall assembly pitch in cm. + + Examples: + >>> assembly = Assembly( + ... name="17x17_assembly", + ... lattice_type="square", + ... n_pins_x=17, + ... n_pins_y=17, + ... pin_pitch=1.26, + ... assembly_pitch=21.5 + ... ) + """ + + def __init__( + self, + name: str, + lattice_type: str = "square", + n_pins_x: int = 17, + n_pins_y: int = 17, + pin_pitch: float = 1.26, + assembly_pitch: float = 21.5, + ) -> None: + """Initialize Assembly object. + + Args: + name: Unique identifier for the assembly. + lattice_type: Type of lattice arrangement. Either 'square' or + 'hexagonal'. Defaults to 'square'. + n_pins_x: Number of pins in x-direction. Defaults to 17. + n_pins_y: Number of pins in y-direction. Defaults to 17. + pin_pitch: Distance between pin centers in cm. Defaults to 1.26. + assembly_pitch: Overall assembly pitch in cm. Defaults to 21.5. + + Raises: + ValueError: If lattice_type is invalid or dimensions are non-positive. + """ + super().__init__(name) + + if lattice_type not in ("square", "hexagonal"): + raise ValueError(f"Invalid lattice_type '{lattice_type}', must be 'square' or 'hexagonal'") + if n_pins_x <= 0 or n_pins_y <= 0: + raise ValueError("Number of pins must be positive") + if pin_pitch <= 0 or assembly_pitch <= 0: + raise ValueError("Pitches must be positive") + + self.lattice_type = lattice_type + self.n_pins_x = n_pins_x + self.n_pins_y = n_pins_y + self.pin_pitch = pin_pitch + self.assembly_pitch = assembly_pitch + self.pin_map: List[List[Optional[PinCell]]] = [ + [None for _ in range(n_pins_y)] for _ in range(n_pins_x) + ] + + def set_pin(self, i: int, j: int, pin: Optional[PinCell]) -> None: + """Place a pin cell at position (i, j) in the assembly lattice. + + Args: + i: Row index (0-based). + j: Column index (0-based). + pin: PinCell object to place, or None for empty position. + + Raises: + IndexError: If indices are out of range. + """ + if not (0 <= i < self.n_pins_x and 0 <= j < self.n_pins_y): + raise IndexError(f"Pin position ({i}, {j}) out of range") + self.pin_map[i][j] = pin + + def get_pin(self, i: int, j: int) -> Optional[PinCell]: + """Get the pin cell at position (i, j). + + Args: + i: Row index (0-based). + j: Column index (0-based). + + Returns: + PinCell object at the position, or None if empty. + + Raises: + IndexError: If indices are out of range. + """ + if not (0 <= i < self.n_pins_x and 0 <= j < self.n_pins_y): + raise IndexError(f"Pin position ({i}, {j}) out of range") + return self.pin_map[i][j] + + def count_pins(self) -> int: + """Count the number of non-empty pin positions. + + Returns: + Number of pins in the assembly. + """ + return sum(1 for row in self.pin_map for pin in row if pin is not None) + + +class Colorset(GeometryBase): + """Represents a colorset of multiple fuel assemblies. + + A colorset is a collection of assemblies that may have different + properties (fuel enrichment, burnup, etc.). + + Attributes: + name: Unique identifier for the colorset. + assemblies: Dictionary mapping assembly positions to Assembly objects. + + Examples: + >>> colorset = Colorset(name="quarter_core") + >>> colorset.add_assembly((0, 0), assembly1) + >>> colorset.add_assembly((0, 1), assembly2) + """ + + def __init__(self, name: str) -> None: + """Initialize Colorset object. + + Args: + name: Unique identifier for the colorset. + """ + super().__init__(name) + self.assemblies: Dict[Tuple[int, int], Assembly] = {} + + def add_assembly(self, position: Tuple[int, int], assembly: Assembly) -> None: + """Add an assembly at a specific position in the colorset. + + Args: + position: (i, j) position tuple for the assembly. + assembly: Assembly object to place. + + Raises: + ValueError: If position is already occupied. + """ + if position in self.assemblies: + raise ValueError(f"Position {position} is already occupied") + self.assemblies[position] = assembly + + def get_assembly(self, position: Tuple[int, int]) -> Optional[Assembly]: + """Get the assembly at a specific position. + + Args: + position: (i, j) position tuple. + + Returns: + Assembly object at the position, or None if empty. + """ + return self.assemblies.get(position) + + def remove_assembly(self, position: Tuple[int, int]) -> None: + """Remove an assembly from the colorset. + + Args: + position: (i, j) position tuple. + + Raises: + KeyError: If no assembly exists at the position. + """ + if position not in self.assemblies: + raise KeyError(f"No assembly at position {position}") + del self.assemblies[position] + + def count_assemblies(self) -> int: + """Count the number of assemblies in the colorset. + + Returns: + Number of assemblies. + """ + return len(self.assemblies) + + +class Core(GeometryBase): + """Represents a full reactor core geometry. + + A core consists of multiple assemblies or colorsets arranged in a + specific configuration. + + Attributes: + name: Unique identifier for the core. + core_type: Type of core layout ('square' or 'hexagonal'). + n_assemblies_x: Number of assemblies in x-direction. + n_assemblies_y: Number of assemblies in y-direction. + + Examples: + >>> core = Core( + ... name="PWR_core", + ... core_type="square", + ... n_assemblies_x=15, + ... n_assemblies_y=15 + ... ) + """ + + def __init__( + self, + name: str, + core_type: str = "square", + n_assemblies_x: int = 15, + n_assemblies_y: int = 15, + ) -> None: + """Initialize Core object. + + Args: + name: Unique identifier for the core. + core_type: Type of core layout. Either 'square' or 'hexagonal'. + Defaults to 'square'. + n_assemblies_x: Number of assemblies in x-direction. Defaults to 15. + n_assemblies_y: Number of assemblies in y-direction. Defaults to 15. + + Raises: + ValueError: If core_type is invalid or dimensions are non-positive. + """ + super().__init__(name) + + if core_type not in ("square", "hexagonal"): + raise ValueError(f"Invalid core_type '{core_type}', must be 'square' or 'hexagonal'") + if n_assemblies_x <= 0 or n_assemblies_y <= 0: + raise ValueError("Number of assemblies must be positive") + + self.core_type = core_type + self.n_assemblies_x = n_assemblies_x + self.n_assemblies_y = n_assemblies_y + self.assembly_map: Dict[Tuple[int, int], Union[Assembly, Colorset]] = {} + + def set_assembly( + self, position: Tuple[int, int], assembly: Union[Assembly, Colorset] + ) -> None: + """Place an assembly or colorset at a specific position in the core. + + Args: + position: (i, j) position tuple. + assembly: Assembly or Colorset object to place. + + Raises: + IndexError: If position is out of range. + """ + i, j = position + if not (0 <= i < self.n_assemblies_x and 0 <= j < self.n_assemblies_y): + raise IndexError(f"Assembly position {position} out of range") + self.assembly_map[position] = assembly + + def get_assembly(self, position: Tuple[int, int]) -> Optional[Union[Assembly, Colorset]]: + """Get the assembly or colorset at a specific position. + + Args: + position: (i, j) position tuple. + + Returns: + Assembly or Colorset object at the position, or None if empty. + """ + return self.assembly_map.get(position) + + def count_assemblies(self) -> int: + """Count the number of assemblies/colorsets in the core. + + Returns: + Number of assemblies/colorsets. + """ + return len(self.assembly_map) + + +class Reflector(GeometryBase): + """Represents a reflector surrounding the core. + + A reflector is a region of material (typically water or steel) + surrounding the active core to reduce neutron leakage. + + Attributes: + name: Unique identifier for the reflector. + thickness: Reflector thickness in cm. + material_name: Name of the reflector material. + + Examples: + >>> reflector = Reflector( + ... name="water_reflector", + ... thickness=20.0, + ... material_name="light_water" + ... ) + """ + + def __init__( + self, + name: str, + thickness: float, + material_name: str, + ) -> None: + """Initialize Reflector object. + + Args: + name: Unique identifier for the reflector. + thickness: Reflector thickness in cm. + material_name: Name of the reflector material. + + Raises: + ValueError: If thickness is non-positive. + """ + super().__init__(name) + + if thickness <= 0: + raise ValueError(f"Thickness must be positive, got {thickness}") + + self.thickness = thickness + self.material_name = material_name + + def __repr__(self) -> str: + """Return string representation of Reflector object.""" + return ( + f"Reflector(name='{self.name}', thickness={self.thickness} cm, " + f"material='{self.material_name}')" + ) diff --git a/src/pyT5/materials.py b/src/pyT5/materials.py new file mode 100644 index 0000000..49dc99e --- /dev/null +++ b/src/pyT5/materials.py @@ -0,0 +1,235 @@ +"""Material definition module for pyT5. + +This module provides classes for defining materials used in +Tripoli-5 simulations, including their composition and properties. +""" + +from typing import Dict, List, Optional, Union +import numpy as np + + +class Material: + """Represents a material with its composition and physical properties. + + This class defines a material by its nuclide composition, density, + temperature, and other physical properties needed for neutron + transport calculations. + + Attributes: + name: Unique identifier for the material. + nuclides: Dictionary mapping nuclide names to their concentrations. + temperature: Material temperature in Kelvin. + density: Material density in g/cm³ (optional). + state: Physical state ('solid', 'liquid', 'gas'). + + Examples: + >>> water = Material( + ... name="light_water", + ... nuclides={"H1": 2.0, "O16": 1.0}, + ... temperature=300.0, + ... density=1.0, + ... state="liquid" + ... ) + >>> water.normalize_concentrations() + """ + + def __init__( + self, + name: str, + nuclides: Dict[str, float], + temperature: float = 300.0, + density: Optional[float] = None, + state: str = "solid", + ) -> None: + """Initialize Material object. + + Args: + name: Unique identifier for the material. + nuclides: Dictionary mapping nuclide names to their atomic or + mass concentrations. + temperature: Material temperature in Kelvin. Defaults to 300.0 K. + density: Material density in g/cm³. If None, will be calculated + from composition. + state: Physical state of the material. One of 'solid', 'liquid', + or 'gas'. Defaults to 'solid'. + + Raises: + ValueError: If temperature is negative, nuclides dict is empty, + or invalid state specified. + """ + if temperature < 0: + raise ValueError(f"Temperature must be non-negative, got {temperature}") + if not nuclides: + raise ValueError("Material must contain at least one nuclide") + if state not in ("solid", "liquid", "gas"): + raise ValueError(f"Invalid state '{state}', must be solid, liquid, or gas") + + self.name = name + self.nuclides = nuclides.copy() + self.temperature = temperature + self.density = density + self.state = state + + def normalize_concentrations(self) -> None: + """Normalize nuclide concentrations to sum to 1.0.""" + total = sum(self.nuclides.values()) + if total > 0: + self.nuclides = {k: v / total for k, v in self.nuclides.items()} + + def add_nuclide(self, nuclide: str, concentration: float) -> None: + """Add or update a nuclide in the material composition. + + Args: + nuclide: Nuclide identifier (e.g., 'U235', 'Pu239'). + concentration: Atomic or mass concentration. + + Raises: + ValueError: If concentration is negative. + """ + if concentration < 0: + raise ValueError(f"Concentration must be non-negative, got {concentration}") + self.nuclides[nuclide] = concentration + + def remove_nuclide(self, nuclide: str) -> None: + """Remove a nuclide from the material composition. + + Args: + nuclide: Nuclide identifier to remove. + + Raises: + KeyError: If nuclide not found in material. + """ + if nuclide not in self.nuclides: + raise KeyError(f"Nuclide '{nuclide}' not found in material '{self.name}'") + del self.nuclides[nuclide] + + def get_concentration(self, nuclide: str) -> float: + """Get the concentration of a specific nuclide. + + Args: + nuclide: Nuclide identifier. + + Returns: + Concentration of the nuclide. + + Raises: + KeyError: If nuclide not found in material. + """ + if nuclide not in self.nuclides: + raise KeyError(f"Nuclide '{nuclide}' not found in material '{self.name}'") + return self.nuclides[nuclide] + + def set_temperature(self, temperature: float) -> None: + """Set the material temperature. + + Args: + temperature: Temperature in Kelvin. + + Raises: + ValueError: If temperature is negative. + """ + if temperature < 0: + raise ValueError(f"Temperature must be non-negative, got {temperature}") + self.temperature = temperature + + def set_density(self, density: float) -> None: + """Set the material density. + + Args: + density: Density in g/cm³. + + Raises: + ValueError: If density is negative. + """ + if density < 0: + raise ValueError(f"Density must be non-negative, got {density}") + self.density = density + + def __repr__(self) -> str: + """Return string representation of Material object.""" + nuclide_list = ", ".join(f"{k}: {v}" for k, v in list(self.nuclides.items())[:3]) + if len(self.nuclides) > 3: + nuclide_list += ", ..." + return ( + f"Material(name='{self.name}', nuclides={{{nuclide_list}}}, " + f"T={self.temperature} K, density={self.density} g/cm³)" + ) + + +class MaterialLibrary: + """Collection of materials for a simulation. + + This class manages a library of materials that can be referenced + by cells in the geometry definition. + + Attributes: + materials: Dictionary mapping material names to Material objects. + + Examples: + >>> library = MaterialLibrary() + >>> library.add_material(water) + >>> library.add_material(fuel) + >>> mat = library.get_material("light_water") + """ + + def __init__(self) -> None: + """Initialize empty MaterialLibrary.""" + self.materials: Dict[str, Material] = {} + + def add_material(self, material: Material) -> None: + """Add a material to the library. + + Args: + material: Material object to add. + + Raises: + ValueError: If material with same name already exists. + """ + if material.name in self.materials: + raise ValueError(f"Material '{material.name}' already exists in library") + self.materials[material.name] = material + + def remove_material(self, name: str) -> None: + """Remove a material from the library. + + Args: + name: Name of the material to remove. + + Raises: + KeyError: If material not found in library. + """ + if name not in self.materials: + raise KeyError(f"Material '{name}' not found in library") + del self.materials[name] + + def get_material(self, name: str) -> Material: + """Retrieve a material from the library. + + Args: + name: Name of the material to retrieve. + + Returns: + Material object. + + Raises: + KeyError: If material not found in library. + """ + if name not in self.materials: + raise KeyError(f"Material '{name}' not found in library") + return self.materials[name] + + def list_materials(self) -> List[str]: + """Get list of all material names in the library. + + Returns: + List of material names. + """ + return list(self.materials.keys()) + + def __len__(self) -> int: + """Return number of materials in the library.""" + return len(self.materials) + + def __repr__(self) -> str: + """Return string representation of MaterialLibrary object.""" + return f"MaterialLibrary(materials={len(self.materials)})" diff --git a/src/pyT5/nuclear_data.py b/src/pyT5/nuclear_data.py new file mode 100644 index 0000000..b9cd9e5 --- /dev/null +++ b/src/pyT5/nuclear_data.py @@ -0,0 +1,116 @@ +"""Nuclear data handling module for pyT5. + +This module provides classes for managing nuclear data including +cross-sections and decay data required for Tripoli-5 simulations. +""" + +from pathlib import Path +from typing import Dict, List, Optional, Union + + +class NuclearData: + """Handles nuclear data for Tripoli-5 simulations. + + This class manages cross-section libraries and decay data files + required for Monte-Carlo neutron transport calculations. + + Attributes: + cross_section_library: Path to the cross-section library file. + decay_data_library: Path to the decay data library file (optional). + temperature: Reference temperature for cross-sections in Kelvin. + data_format: Format of the nuclear data files. + + Examples: + >>> nuclear_data = NuclearData( + ... cross_section_library="path/to/xsections.dat", + ... temperature=300.0 + ... ) + >>> nuclear_data.validate() + """ + + def __init__( + self, + cross_section_library: Union[str, Path], + decay_data_library: Optional[Union[str, Path]] = None, + temperature: float = 300.0, + data_format: str = "ENDF", + ) -> None: + """Initialize NuclearData object. + + Args: + cross_section_library: Path to the cross-section library file. + decay_data_library: Path to the decay data library file (optional). + temperature: Reference temperature for cross-sections in Kelvin. + Defaults to 300.0 K. + data_format: Format of the nuclear data files. Defaults to "ENDF". + + Raises: + ValueError: If temperature is negative or invalid format specified. + """ + if temperature < 0: + raise ValueError(f"Temperature must be non-negative, got {temperature}") + + self.cross_section_library = Path(cross_section_library) + self.decay_data_library = ( + Path(decay_data_library) if decay_data_library else None + ) + self.temperature = temperature + self.data_format = data_format + self._validated = False + + def validate(self) -> bool: + """Validate that nuclear data files exist and are accessible. + + Returns: + True if validation successful, False otherwise. + + Raises: + FileNotFoundError: If required data files are not found. + """ + if not self.cross_section_library.exists(): + raise FileNotFoundError( + f"Cross-section library not found: {self.cross_section_library}" + ) + + if self.decay_data_library and not self.decay_data_library.exists(): + raise FileNotFoundError( + f"Decay data library not found: {self.decay_data_library}" + ) + + self._validated = True + return True + + def get_available_nuclides(self) -> List[str]: + """Get list of available nuclides in the cross-section library. + + Returns: + List of nuclide identifiers available in the library. + + Raises: + RuntimeError: If nuclear data has not been validated. + """ + if not self._validated: + raise RuntimeError("Nuclear data must be validated before use") + + # Placeholder implementation - would parse actual library file + return [] + + def set_temperature(self, temperature: float) -> None: + """Set the reference temperature for cross-sections. + + Args: + temperature: Temperature in Kelvin. + + Raises: + ValueError: If temperature is negative. + """ + if temperature < 0: + raise ValueError(f"Temperature must be non-negative, got {temperature}") + self.temperature = temperature + + def __repr__(self) -> str: + """Return string representation of NuclearData object.""" + return ( + f"NuclearData(cross_section_library={self.cross_section_library}, " + f"temperature={self.temperature} K, format={self.data_format})" + ) diff --git a/src/pyT5/py.typed b/src/pyT5/py.typed new file mode 100644 index 0000000..7632ecf --- /dev/null +++ b/src/pyT5/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 diff --git a/src/pyT5/remote.py b/src/pyT5/remote.py new file mode 100644 index 0000000..4370afe --- /dev/null +++ b/src/pyT5/remote.py @@ -0,0 +1,236 @@ +"""Remote computation module for pyT5. + +This module provides classes for executing Tripoli-5 simulations +on remote computing resources. +""" + +from pathlib import Path +from typing import Dict, List, Optional, Union + + +class RemoteCompute: + """Manages remote execution of Tripoli-5 simulations. + + This class handles submission, monitoring, and retrieval of + simulations on remote computing resources (HPC clusters, cloud). + + Attributes: + name: Unique identifier for the remote configuration. + host: Remote host address. + username: Username for authentication. + scheduler: Job scheduler type ('slurm', 'pbs', 'sge', etc.). + queue: Queue/partition name. + walltime: Maximum job walltime in hours. + nodes: Number of compute nodes. + cores_per_node: Number of cores per node. + + Examples: + >>> remote = RemoteCompute( + ... name="hpc_cluster", + ... host="cluster.example.com", + ... username="user", + ... scheduler="slurm", + ... queue="standard", + ... walltime=24.0, + ... nodes=2, + ... cores_per_node=32 + ... ) + >>> job_id = remote.submit_job(simulation) + >>> status = remote.check_status(job_id) + """ + + def __init__( + self, + name: str, + host: str, + username: str, + scheduler: str = "slurm", + queue: str = "standard", + walltime: float = 24.0, + nodes: int = 1, + cores_per_node: int = 32, + ) -> None: + """Initialize RemoteCompute object. + + Args: + name: Unique identifier for the remote configuration. + host: Remote host address (e.g., 'cluster.example.com'). + username: Username for authentication. + scheduler: Job scheduler type. One of 'slurm', 'pbs', 'sge', + 'lsf'. Defaults to 'slurm'. + queue: Queue/partition name. Defaults to 'standard'. + walltime: Maximum job walltime in hours. Defaults to 24.0. + nodes: Number of compute nodes. Defaults to 1. + cores_per_node: Number of cores per node. Defaults to 32. + + Raises: + ValueError: If scheduler is invalid or resource parameters + are non-positive. + """ + valid_schedulers = ("slurm", "pbs", "sge", "lsf") + if scheduler not in valid_schedulers: + raise ValueError( + f"Invalid scheduler '{scheduler}', must be one of {valid_schedulers}" + ) + + if walltime <= 0: + raise ValueError(f"walltime must be positive, got {walltime}") + if nodes <= 0: + raise ValueError(f"nodes must be positive, got {nodes}") + if cores_per_node <= 0: + raise ValueError(f"cores_per_node must be positive, got {cores_per_node}") + + self.name = name + self.host = host + self.username = username + self.scheduler = scheduler + self.queue = queue + self.walltime = walltime + self.nodes = nodes + self.cores_per_node = cores_per_node + self._ssh_key_path: Optional[Path] = None + + def set_ssh_key(self, key_path: Union[str, Path]) -> None: + """Set path to SSH private key for authentication. + + Args: + key_path: Path to SSH private key file. + + Raises: + FileNotFoundError: If key file does not exist. + """ + key_path = Path(key_path) + if not key_path.exists(): + raise FileNotFoundError(f"SSH key not found: {key_path}") + self._ssh_key_path = key_path + + def set_walltime(self, walltime: float) -> None: + """Set the job walltime. + + Args: + walltime: Walltime in hours. + + Raises: + ValueError: If walltime is non-positive. + """ + if walltime <= 0: + raise ValueError(f"walltime must be positive, got {walltime}") + self.walltime = walltime + + def set_resources(self, nodes: int, cores_per_node: int) -> None: + """Set compute resource allocation. + + Args: + nodes: Number of compute nodes. + cores_per_node: Number of cores per node. + + Raises: + ValueError: If parameters are non-positive. + """ + if nodes <= 0: + raise ValueError(f"nodes must be positive, got {nodes}") + if cores_per_node <= 0: + raise ValueError(f"cores_per_node must be positive, got {cores_per_node}") + self.nodes = nodes + self.cores_per_node = cores_per_node + + def submit_job( + self, + simulation: "Simulation", # type: ignore # noqa: F821 + working_dir: Optional[Union[str, Path]] = None, + ) -> str: + """Submit a simulation job to the remote resource. + + Args: + simulation: Simulation object to execute. + working_dir: Remote working directory. If None, uses home + directory. Defaults to None. + + Returns: + Job ID string assigned by the scheduler. + + Raises: + RuntimeError: If job submission fails. + """ + # Placeholder implementation - would use SSH/scheduler API + print(f"Submitting job '{simulation.name}' to {self.host}...") + print(f" Scheduler: {self.scheduler}") + print(f" Resources: {self.nodes} nodes x {self.cores_per_node} cores") + print(f" Walltime: {self.walltime} hours") + + # In real implementation, would: + # 1. Transfer input files to remote host + # 2. Generate job script for scheduler + # 3. Submit job via scheduler command + # 4. Return job ID + + return "12345" # Placeholder job ID + + def check_status(self, job_id: str) -> str: + """Check the status of a submitted job. + + Args: + job_id: Job ID returned by submit_job. + + Returns: + Job status string ('pending', 'running', 'completed', 'failed'). + + Raises: + RuntimeError: If status check fails. + """ + # Placeholder implementation - would query scheduler + print(f"Checking status of job {job_id} on {self.host}...") + + # In real implementation, would query scheduler for job status + return "running" + + def cancel_job(self, job_id: str) -> None: + """Cancel a submitted job. + + Args: + job_id: Job ID to cancel. + + Raises: + RuntimeError: If cancellation fails. + """ + # Placeholder implementation - would use scheduler API + print(f"Cancelling job {job_id} on {self.host}...") + + # In real implementation, would use scheduler cancel command + + def retrieve_results( + self, + job_id: str, + local_dir: Optional[Union[str, Path]] = None, + ) -> Path: + """Retrieve results from a completed job. + + Args: + job_id: Job ID to retrieve results from. + local_dir: Local directory to store results. If None, uses + current directory. Defaults to None. + + Returns: + Path to local directory containing results. + + Raises: + RuntimeError: If retrieval fails or job not completed. + """ + # Placeholder implementation - would use SCP/SFTP + local_dir = Path(local_dir) if local_dir else Path.cwd() + print(f"Retrieving results for job {job_id} from {self.host}...") + print(f" Saving to: {local_dir}") + + # In real implementation, would: + # 1. Check job is completed + # 2. Transfer output files to local directory + # 3. Return path to results + + return local_dir + + def __repr__(self) -> str: + """Return string representation of RemoteCompute object.""" + return ( + f"RemoteCompute(name='{self.name}', host='{self.host}', " + f"scheduler='{self.scheduler}', nodes={self.nodes})" + ) diff --git a/src/pyT5/results.py b/src/pyT5/results.py new file mode 100644 index 0000000..1bdbc01 --- /dev/null +++ b/src/pyT5/results.py @@ -0,0 +1,224 @@ +"""Results retrieval module for pyT5. + +This module provides classes for retrieving and analyzing results +from Tripoli-5 simulations. +""" + +from typing import Dict, List, Optional, Tuple +import numpy as np + + +class Results: + """Handles retrieval and analysis of simulation results. + + This class provides methods to extract computed quantities from + completed simulations, including k-effective, fluxes, reaction + rates, and their statistical uncertainties. + + Attributes: + simulation_name: Name of the simulation these results belong to. + k_effective: Effective multiplication factor (mean, std dev). + scores: Dictionary of score results. + + Examples: + >>> results = Results(simulation_name="PWR_core") + >>> keff, keff_std = results.get_k_effective() + >>> flux = results.get_score("fuel_flux") + """ + + def __init__(self, simulation_name: str) -> None: + """Initialize Results object. + + Args: + simulation_name: Name of the simulation. + """ + self.simulation_name = simulation_name + self.k_effective: Optional[Tuple[float, float]] = None + self.scores: Dict[str, np.ndarray] = {} + self._score_uncertainties: Dict[str, np.ndarray] = {} + + def set_k_effective(self, mean: float, std_dev: float) -> None: + """Set the k-effective value and standard deviation. + + Args: + mean: Mean k-effective value. + std_dev: Standard deviation of k-effective. + + Raises: + ValueError: If mean is negative or std_dev is non-positive. + """ + if mean < 0: + raise ValueError(f"k-effective mean must be non-negative, got {mean}") + if std_dev < 0: + raise ValueError(f"Standard deviation must be non-negative, got {std_dev}") + + self.k_effective = (mean, std_dev) + + def get_k_effective(self) -> Tuple[float, float]: + """Get the k-effective value and standard deviation. + + Returns: + Tuple of (mean, std_dev) for k-effective. + + Raises: + RuntimeError: If k-effective has not been set. + """ + if self.k_effective is None: + raise RuntimeError("k-effective has not been set") + return self.k_effective + + def get_k_effective_mean(self) -> float: + """Get the mean k-effective value. + + Returns: + Mean k-effective value. + + Raises: + RuntimeError: If k-effective has not been set. + """ + if self.k_effective is None: + raise RuntimeError("k-effective has not been set") + return self.k_effective[0] + + def get_k_effective_std(self) -> float: + """Get the k-effective standard deviation. + + Returns: + Standard deviation of k-effective. + + Raises: + RuntimeError: If k-effective has not been set. + """ + if self.k_effective is None: + raise RuntimeError("k-effective has not been set") + return self.k_effective[1] + + def add_score( + self, + score_name: str, + values: np.ndarray, + uncertainties: Optional[np.ndarray] = None, + ) -> None: + """Add a score result. + + Args: + score_name: Name of the score. + values: Array of score values. + uncertainties: Array of relative uncertainties (optional). + + Raises: + ValueError: If score already exists or array shapes don't match. + """ + if score_name in self.scores: + raise ValueError(f"Score '{score_name}' already exists in results") + + if uncertainties is not None and values.shape != uncertainties.shape: + raise ValueError("Values and uncertainties arrays must have same shape") + + self.scores[score_name] = values + if uncertainties is not None: + self._score_uncertainties[score_name] = uncertainties + + def get_score(self, score_name: str) -> np.ndarray: + """Get the values for a specific score. + + Args: + score_name: Name of the score to retrieve. + + Returns: + Array of score values. + + Raises: + KeyError: If score not found in results. + """ + if score_name not in self.scores: + raise KeyError(f"Score '{score_name}' not found in results") + return self.scores[score_name] + + def get_score_uncertainty(self, score_name: str) -> np.ndarray: + """Get the uncertainties for a specific score. + + Args: + score_name: Name of the score. + + Returns: + Array of relative uncertainties. + + Raises: + KeyError: If score not found or uncertainties not available. + """ + if score_name not in self._score_uncertainties: + raise KeyError( + f"Uncertainties for score '{score_name}' not found in results" + ) + return self._score_uncertainties[score_name] + + def has_score(self, score_name: str) -> bool: + """Check if a score exists in the results. + + Args: + score_name: Name of the score. + + Returns: + True if score exists, False otherwise. + """ + return score_name in self.scores + + def list_scores(self) -> List[str]: + """Get list of all available score names. + + Returns: + List of score names. + """ + return list(self.scores.keys()) + + def get_score_statistics(self, score_name: str) -> Dict[str, float]: + """Get statistical summary for a score. + + Args: + score_name: Name of the score. + + Returns: + Dictionary with 'mean', 'std', 'min', 'max' statistics. + + Raises: + KeyError: If score not found in results. + """ + values = self.get_score(score_name) + return { + "mean": float(np.mean(values)), + "std": float(np.std(values)), + "min": float(np.min(values)), + "max": float(np.max(values)), + } + + def export_to_file(self, filepath: str, score_name: Optional[str] = None) -> None: + """Export results to a file. + + Args: + filepath: Path to output file. + score_name: Name of specific score to export. If None, exports + all results. Defaults to None. + + Raises: + KeyError: If specified score not found. + """ + # Placeholder implementation - would write to file + print(f"Exporting results to {filepath}...") + if score_name: + values = self.get_score(score_name) + print(f" Score '{score_name}': shape {values.shape}") + else: + print(f" All scores: {len(self.scores)}") + if self.k_effective: + print(f" k-effective: {self.k_effective[0]:.5f} ± {self.k_effective[1]:.5f}") + + def __repr__(self) -> str: + """Return string representation of Results object.""" + keff_str = "" + if self.k_effective: + keff_str = f", k-eff={self.k_effective[0]:.5f}±{self.k_effective[1]:.5f}" + return ( + f"Results(simulation='{self.simulation_name}', " + f"scores={len(self.scores)}{keff_str})" + ) diff --git a/src/pyT5/scores.py b/src/pyT5/scores.py new file mode 100644 index 0000000..50c3569 --- /dev/null +++ b/src/pyT5/scores.py @@ -0,0 +1,207 @@ +"""Scores and tallies module for pyT5. + +This module provides classes for defining scores (tallies) to be +computed during Tripoli-5 simulations. +""" + +from typing import Dict, List, Optional, Union + + +class Score: + """Represents a score (tally) to be computed in the simulation. + + Scores define what physical quantities should be computed during + the Monte-Carlo simulation (e.g., flux, reaction rates, k-effective). + + Attributes: + name: Unique identifier for the score. + score_type: Type of score to compute. + cells: List of cell names where the score is computed. + energy_bins: Energy bin boundaries in MeV (optional). + spatial_mesh: Spatial mesh specification (optional). + + Examples: + >>> flux_score = Score( + ... name="fuel_flux", + ... score_type="flux", + ... cells=["fuel_cell_1", "fuel_cell_2"] + ... ) + >>> keff_score = Score( + ... name="k_effective", + ... score_type="keff" + ... ) + """ + + def __init__( + self, + name: str, + score_type: str, + cells: Optional[List[str]] = None, + energy_bins: Optional[List[float]] = None, + spatial_mesh: Optional[Dict[str, int]] = None, + ) -> None: + """Initialize Score object. + + Args: + name: Unique identifier for the score. + score_type: Type of score. Common types include 'flux', + 'fission_rate', 'absorption_rate', 'keff', 'power'. + cells: List of cell names where the score is computed. + Not needed for global scores like 'keff'. + energy_bins: List of energy bin boundaries in MeV for + energy-dependent scores. Defaults to None (integrated). + spatial_mesh: Dictionary specifying spatial mesh for mesh + tallies, e.g., {'nx': 10, 'ny': 10, 'nz': 10}. + + Raises: + ValueError: If score_type is empty or energy_bins are not + in ascending order. + """ + if not score_type: + raise ValueError("score_type cannot be empty") + + if energy_bins is not None and len(energy_bins) > 1: + if not all(energy_bins[i] < energy_bins[i + 1] for i in range(len(energy_bins) - 1)): + raise ValueError("energy_bins must be in ascending order") + + self.name = name + self.score_type = score_type + self.cells = cells or [] + self.energy_bins = energy_bins + self.spatial_mesh = spatial_mesh + + def add_cell(self, cell_name: str) -> None: + """Add a cell to the score computation. + + Args: + cell_name: Name of the cell to add. + """ + if cell_name not in self.cells: + self.cells.append(cell_name) + + def remove_cell(self, cell_name: str) -> None: + """Remove a cell from the score computation. + + Args: + cell_name: Name of the cell to remove. + + Raises: + ValueError: If cell not found in score. + """ + if cell_name not in self.cells: + raise ValueError(f"Cell '{cell_name}' not found in score '{self.name}'") + self.cells.remove(cell_name) + + def set_energy_bins(self, energy_bins: List[float]) -> None: + """Set energy bin boundaries for the score. + + Args: + energy_bins: List of energy bin boundaries in MeV. + + Raises: + ValueError: If energy_bins are not in ascending order. + """ + if len(energy_bins) > 1: + if not all(energy_bins[i] < energy_bins[i + 1] for i in range(len(energy_bins) - 1)): + raise ValueError("energy_bins must be in ascending order") + self.energy_bins = energy_bins + + def set_spatial_mesh(self, nx: int, ny: int, nz: int) -> None: + """Set spatial mesh for mesh tally. + + Args: + nx: Number of mesh cells in x-direction. + ny: Number of mesh cells in y-direction. + nz: Number of mesh cells in z-direction. + + Raises: + ValueError: If any mesh dimension is non-positive. + """ + if nx <= 0 or ny <= 0 or nz <= 0: + raise ValueError("Mesh dimensions must be positive") + self.spatial_mesh = {"nx": nx, "ny": ny, "nz": nz} + + def __repr__(self) -> str: + """Return string representation of Score object.""" + cells_str = f", cells={len(self.cells)}" if self.cells else "" + energy_str = f", energy_bins={len(self.energy_bins)}" if self.energy_bins else "" + return f"Score(name='{self.name}', type='{self.score_type}'{cells_str}{energy_str})" + + +class ScoreLibrary: + """Collection of scores for a simulation. + + This class manages a library of scores that will be computed + during the Tripoli-5 simulation. + + Attributes: + scores: Dictionary mapping score names to Score objects. + + Examples: + >>> library = ScoreLibrary() + >>> library.add_score(flux_score) + >>> library.add_score(keff_score) + >>> score = library.get_score("fuel_flux") + """ + + def __init__(self) -> None: + """Initialize empty ScoreLibrary.""" + self.scores: Dict[str, Score] = {} + + def add_score(self, score: Score) -> None: + """Add a score to the library. + + Args: + score: Score object to add. + + Raises: + ValueError: If score with same name already exists. + """ + if score.name in self.scores: + raise ValueError(f"Score '{score.name}' already exists in library") + self.scores[score.name] = score + + def remove_score(self, name: str) -> None: + """Remove a score from the library. + + Args: + name: Name of the score to remove. + + Raises: + KeyError: If score not found in library. + """ + if name not in self.scores: + raise KeyError(f"Score '{name}' not found in library") + del self.scores[name] + + def get_score(self, name: str) -> Score: + """Retrieve a score from the library. + + Args: + name: Name of the score to retrieve. + + Returns: + Score object. + + Raises: + KeyError: If score not found in library. + """ + if name not in self.scores: + raise KeyError(f"Score '{name}' not found in library") + return self.scores[name] + + def list_scores(self) -> List[str]: + """Get list of all score names in the library. + + Returns: + List of score names. + """ + return list(self.scores.keys()) + + def __len__(self) -> int: + """Return number of scores in the library.""" + return len(self.scores) + + def __repr__(self) -> str: + """Return string representation of ScoreLibrary object.""" + return f"ScoreLibrary(scores={len(self.scores)})" diff --git a/src/pyT5/simulation.py b/src/pyT5/simulation.py new file mode 100644 index 0000000..bef0260 --- /dev/null +++ b/src/pyT5/simulation.py @@ -0,0 +1,272 @@ +"""Simulation execution module for pyT5. + +This module provides classes for configuring and executing +Tripoli-5 Monte-Carlo simulations. +""" + +from pathlib import Path +from typing import Dict, List, Optional, Union +from .nuclear_data import NuclearData +from .materials import MaterialLibrary +from .cells import CellLibrary +from .geometry import GeometryBase +from .source import NeutronSource, NeutronMedia +from .visualization import Visualization +from .scores import ScoreLibrary + + +class Simulation: + """Manages Tripoli-5 simulation configuration and execution. + + This class brings together all components needed for a Monte-Carlo + simulation: geometry, materials, sources, scores, and execution + parameters. + + Attributes: + name: Unique identifier for the simulation. + nuclear_data: Nuclear data library configuration. + materials: Material library. + cells: Cell library. + geometry: Geometry definition. + source: Neutron source definition. + media: Neutron media properties. + scores: Score library. + visualizations: List of visualization configurations. + n_particles: Number of particles per cycle. + n_cycles: Total number of cycles. + n_inactive: Number of inactive cycles for k-eigenvalue problems. + n_threads: Number of parallel threads. + random_seed: Random number generator seed. + + Examples: + >>> sim = Simulation( + ... name="PWR_criticality", + ... n_particles=10000, + ... n_cycles=150, + ... n_inactive=50, + ... n_threads=8 + ... ) + >>> sim.set_nuclear_data(nuclear_data) + >>> sim.set_materials(material_library) + >>> sim.set_geometry(core_geometry) + >>> sim.run() + """ + + def __init__( + self, + name: str, + n_particles: int = 10000, + n_cycles: int = 100, + n_inactive: int = 20, + n_threads: int = 1, + random_seed: Optional[int] = None, + ) -> None: + """Initialize Simulation object. + + Args: + name: Unique identifier for the simulation. + n_particles: Number of particles per cycle. Defaults to 10000. + n_cycles: Total number of cycles. Defaults to 100. + n_inactive: Number of inactive cycles for criticality problems. + Defaults to 20. + n_threads: Number of parallel threads. Defaults to 1. + random_seed: Random number generator seed. If None, uses + system time. Defaults to None. + + Raises: + ValueError: If any parameter is negative or n_inactive >= n_cycles. + """ + if n_particles <= 0: + raise ValueError(f"n_particles must be positive, got {n_particles}") + if n_cycles <= 0: + raise ValueError(f"n_cycles must be positive, got {n_cycles}") + if n_inactive < 0: + raise ValueError(f"n_inactive must be non-negative, got {n_inactive}") + if n_inactive >= n_cycles: + raise ValueError( + f"n_inactive ({n_inactive}) must be less than n_cycles ({n_cycles})" + ) + if n_threads <= 0: + raise ValueError(f"n_threads must be positive, got {n_threads}") + + self.name = name + self.n_particles = n_particles + self.n_cycles = n_cycles + self.n_inactive = n_inactive + self.n_threads = n_threads + self.random_seed = random_seed + + # Simulation components + self.nuclear_data: Optional[NuclearData] = None + self.materials: Optional[MaterialLibrary] = None + self.cells: Optional[CellLibrary] = None + self.geometry: Optional[GeometryBase] = None + self.source: Optional[NeutronSource] = None + self.media: Optional[NeutronMedia] = None + self.scores: Optional[ScoreLibrary] = None + self.visualizations: List[Visualization] = [] + + def set_nuclear_data(self, nuclear_data: NuclearData) -> None: + """Set the nuclear data library. + + Args: + nuclear_data: NuclearData object. + """ + self.nuclear_data = nuclear_data + + def set_materials(self, materials: MaterialLibrary) -> None: + """Set the material library. + + Args: + materials: MaterialLibrary object. + """ + self.materials = materials + + def set_cells(self, cells: CellLibrary) -> None: + """Set the cell library. + + Args: + cells: CellLibrary object. + """ + self.cells = cells + + def set_geometry(self, geometry: GeometryBase) -> None: + """Set the geometry definition. + + Args: + geometry: GeometryBase subclass object (PinCell, Assembly, Core, etc.). + """ + self.geometry = geometry + + def set_source(self, source: NeutronSource) -> None: + """Set the neutron source. + + Args: + source: NeutronSource object. + """ + self.source = source + + def set_media(self, media: NeutronMedia) -> None: + """Set the neutron media properties. + + Args: + media: NeutronMedia object. + """ + self.media = media + + def set_scores(self, scores: ScoreLibrary) -> None: + """Set the score library. + + Args: + scores: ScoreLibrary object. + """ + self.scores = scores + + def add_visualization(self, visualization: Visualization) -> None: + """Add a visualization configuration. + + Args: + visualization: Visualization object. + """ + self.visualizations.append(visualization) + + def set_particles_per_cycle(self, n_particles: int) -> None: + """Set the number of particles per cycle. + + Args: + n_particles: Number of particles. + + Raises: + ValueError: If n_particles is non-positive. + """ + if n_particles <= 0: + raise ValueError(f"n_particles must be positive, got {n_particles}") + self.n_particles = n_particles + + def set_cycles(self, n_cycles: int, n_inactive: int) -> None: + """Set the number of cycles and inactive cycles. + + Args: + n_cycles: Total number of cycles. + n_inactive: Number of inactive cycles. + + Raises: + ValueError: If parameters are invalid. + """ + if n_cycles <= 0: + raise ValueError(f"n_cycles must be positive, got {n_cycles}") + if n_inactive < 0: + raise ValueError(f"n_inactive must be non-negative, got {n_inactive}") + if n_inactive >= n_cycles: + raise ValueError( + f"n_inactive ({n_inactive}) must be less than n_cycles ({n_cycles})" + ) + self.n_cycles = n_cycles + self.n_inactive = n_inactive + + def set_threads(self, n_threads: int) -> None: + """Set the number of parallel threads. + + Args: + n_threads: Number of threads. + + Raises: + ValueError: If n_threads is non-positive. + """ + if n_threads <= 0: + raise ValueError(f"n_threads must be positive, got {n_threads}") + self.n_threads = n_threads + + def validate(self) -> bool: + """Validate that all required components are set. + + Returns: + True if validation successful. + + Raises: + RuntimeError: If required components are missing. + """ + if self.nuclear_data is None: + raise RuntimeError("Nuclear data not set") + if self.materials is None: + raise RuntimeError("Materials not set") + if self.geometry is None: + raise RuntimeError("Geometry not set") + if self.source is None: + raise RuntimeError("Neutron source not set") + + return True + + def run(self, output_dir: Optional[Union[str, Path]] = None) -> "Results": + """Execute the simulation. + + Args: + output_dir: Directory for output files. If None, uses current + directory. Defaults to None. + + Returns: + Results object containing simulation results. + + Raises: + RuntimeError: If simulation validation fails or execution error. + """ + from .results import Results + + self.validate() + + # Placeholder implementation - would interface with Tripoli-5 API + print(f"Running simulation '{self.name}'...") + print(f" Particles per cycle: {self.n_particles}") + print(f" Total cycles: {self.n_cycles} ({self.n_inactive} inactive)") + print(f" Threads: {self.n_threads}") + + # In real implementation, would call Tripoli-5 API here + # and return actual results + return Results(simulation_name=self.name) + + def __repr__(self) -> str: + """Return string representation of Simulation object.""" + return ( + f"Simulation(name='{self.name}', particles={self.n_particles}, " + f"cycles={self.n_cycles}, threads={self.n_threads})" + ) diff --git a/src/pyT5/source.py b/src/pyT5/source.py new file mode 100644 index 0000000..3f73c5b --- /dev/null +++ b/src/pyT5/source.py @@ -0,0 +1,181 @@ +"""Neutron source definition module for pyT5. + +This module provides classes for defining initial neutron sources +for Tripoli-5 Monte-Carlo simulations. +""" + +from typing import List, Optional, Tuple +import numpy as np + + +class NeutronSource: + """Defines the initial neutron source for a simulation. + + The neutron source specifies the spatial, energy, and angular + distribution of source neutrons at the start of the simulation. + + Attributes: + name: Unique identifier for the source. + source_type: Type of source ('point', 'volume', 'surface', 'criticality'). + position: Source position coordinates (x, y, z) in cm. + energy: Source energy in MeV (None for fission spectrum). + intensity: Source intensity (neutrons/s). + + Examples: + >>> source = NeutronSource( + ... name="fission_source", + ... source_type="criticality", + ... intensity=1e6 + ... ) + """ + + def __init__( + self, + name: str, + source_type: str = "criticality", + position: Optional[Tuple[float, float, float]] = None, + energy: Optional[float] = None, + intensity: float = 1.0e6, + ) -> None: + """Initialize NeutronSource object. + + Args: + name: Unique identifier for the source. + source_type: Type of source. One of 'point', 'volume', 'surface', + or 'criticality'. Defaults to 'criticality'. + position: (x, y, z) coordinates of source position in cm. + Required for 'point' source type. + energy: Source neutron energy in MeV. If None, uses fission + spectrum. Defaults to None. + intensity: Source intensity in neutrons/s. Defaults to 1e6. + + Raises: + ValueError: If source_type is invalid or required parameters + are missing. + """ + valid_types = ("point", "volume", "surface", "criticality") + if source_type not in valid_types: + raise ValueError( + f"Invalid source_type '{source_type}', must be one of {valid_types}" + ) + + if source_type == "point" and position is None: + raise ValueError("Point source requires position to be specified") + + if intensity <= 0: + raise ValueError(f"Intensity must be positive, got {intensity}") + + self.name = name + self.source_type = source_type + self.position = position + self.energy = energy + self.intensity = intensity + + def set_position(self, x: float, y: float, z: float) -> None: + """Set the source position. + + Args: + x: X-coordinate in cm. + y: Y-coordinate in cm. + z: Z-coordinate in cm. + """ + self.position = (x, y, z) + + def set_energy(self, energy: float) -> None: + """Set the source energy. + + Args: + energy: Energy in MeV. + + Raises: + ValueError: If energy is negative. + """ + if energy < 0: + raise ValueError(f"Energy must be non-negative, got {energy}") + self.energy = energy + + def set_intensity(self, intensity: float) -> None: + """Set the source intensity. + + Args: + intensity: Intensity in neutrons/s. + + Raises: + ValueError: If intensity is non-positive. + """ + if intensity <= 0: + raise ValueError(f"Intensity must be positive, got {intensity}") + self.intensity = intensity + + def __repr__(self) -> str: + """Return string representation of NeutronSource object.""" + pos_str = f" at {self.position}" if self.position else "" + energy_str = f" {self.energy} MeV" if self.energy else " (fission spectrum)" + return ( + f"NeutronSource(name='{self.name}', type='{self.source_type}'{pos_str}, " + f"energy={energy_str}, intensity={self.intensity:.2e} n/s)" + ) + + +class NeutronMedia: + """Defines the neutron transport media properties. + + This class configures properties related to neutron transport + in different media, including scattering treatment and thermal + scattering laws. + + Attributes: + name: Unique identifier for the media. + thermal_scattering: Enable S(alpha, beta) thermal scattering treatment. + free_gas_scattering: Enable free gas scattering model. + + Examples: + >>> media = NeutronMedia( + ... name="thermal_media", + ... thermal_scattering=True + ... ) + """ + + def __init__( + self, + name: str, + thermal_scattering: bool = False, + free_gas_scattering: bool = False, + ) -> None: + """Initialize NeutronMedia object. + + Args: + name: Unique identifier for the media. + thermal_scattering: If True, enables S(alpha, beta) thermal + scattering treatment for materials below ~4 eV. + Defaults to False. + free_gas_scattering: If True, enables free gas scattering model. + Defaults to False. + """ + self.name = name + self.thermal_scattering = thermal_scattering + self.free_gas_scattering = free_gas_scattering + + def enable_thermal_scattering(self) -> None: + """Enable thermal scattering treatment.""" + self.thermal_scattering = True + + def disable_thermal_scattering(self) -> None: + """Disable thermal scattering treatment.""" + self.thermal_scattering = False + + def enable_free_gas_scattering(self) -> None: + """Enable free gas scattering model.""" + self.free_gas_scattering = True + + def disable_free_gas_scattering(self) -> None: + """Disable free gas scattering model.""" + self.free_gas_scattering = False + + def __repr__(self) -> str: + """Return string representation of NeutronMedia object.""" + return ( + f"NeutronMedia(name='{self.name}', " + f"thermal_scattering={self.thermal_scattering}, " + f"free_gas={self.free_gas_scattering})" + ) diff --git a/src/pyT5/visualization.py b/src/pyT5/visualization.py new file mode 100644 index 0000000..ad06680 --- /dev/null +++ b/src/pyT5/visualization.py @@ -0,0 +1,131 @@ +"""Visualization module for pyT5. + +This module provides classes for visualizing geometry and simulation +results in Tripoli-5. +""" + +from typing import List, Optional, Tuple + + +class Visualization: + """Handles geometry visualization and plotting instructions. + + This class configures visualization parameters for rendering + the simulation geometry in 2D or 3D views. + + Attributes: + name: Unique identifier for the visualization. + plot_type: Type of plot ('2D' or '3D'). + plane: Plane for 2D plots ('xy', 'xz', 'yz'). + position: Position along the perpendicular axis for 2D plots. + extent: Spatial extent of the plot region. + resolution: Plot resolution in pixels. + + Examples: + >>> viz = Visualization( + ... name="xy_midplane", + ... plot_type="2D", + ... plane="xy", + ... position=182.88, + ... extent=(-150, 150, -150, 150), + ... resolution=(800, 800) + ... ) + """ + + def __init__( + self, + name: str, + plot_type: str = "2D", + plane: str = "xy", + position: float = 0.0, + extent: Optional[Tuple[float, float, float, float]] = None, + resolution: Tuple[int, int] = (800, 800), + ) -> None: + """Initialize Visualization object. + + Args: + name: Unique identifier for the visualization. + plot_type: Type of plot. Either '2D' or '3D'. Defaults to '2D'. + plane: Plotting plane for 2D plots. One of 'xy', 'xz', or 'yz'. + Defaults to 'xy'. + position: Position along perpendicular axis for 2D plots in cm. + Defaults to 0.0. + extent: Spatial extent as (xmin, xmax, ymin, ymax) for 2D plots + in cm. If None, will be auto-determined. Defaults to None. + resolution: Plot resolution as (width, height) in pixels. + Defaults to (800, 800). + + Raises: + ValueError: If plot_type or plane is invalid, or resolution is + non-positive. + """ + if plot_type not in ("2D", "3D"): + raise ValueError(f"Invalid plot_type '{plot_type}', must be '2D' or '3D'") + if plane not in ("xy", "xz", "yz"): + raise ValueError(f"Invalid plane '{plane}', must be 'xy', 'xz', or 'yz'") + if resolution[0] <= 0 or resolution[1] <= 0: + raise ValueError("Resolution must have positive width and height") + + self.name = name + self.plot_type = plot_type + self.plane = plane + self.position = position + self.extent = extent + self.resolution = resolution + + def set_plane(self, plane: str, position: float) -> None: + """Set the plotting plane and position. + + Args: + plane: Plotting plane ('xy', 'xz', or 'yz'). + position: Position along perpendicular axis in cm. + + Raises: + ValueError: If plane is invalid. + """ + if plane not in ("xy", "xz", "yz"): + raise ValueError(f"Invalid plane '{plane}', must be 'xy', 'xz', or 'yz'") + self.plane = plane + self.position = position + + def set_extent( + self, xmin: float, xmax: float, ymin: float, ymax: float + ) -> None: + """Set the spatial extent of the plot. + + Args: + xmin: Minimum x-coordinate in cm. + xmax: Maximum x-coordinate in cm. + ymin: Minimum y-coordinate in cm. + ymax: Maximum y-coordinate in cm. + + Raises: + ValueError: If max values are not greater than min values. + """ + if xmax <= xmin or ymax <= ymin: + raise ValueError("Max coordinates must be greater than min coordinates") + self.extent = (xmin, xmax, ymin, ymax) + + def set_resolution(self, width: int, height: int) -> None: + """Set the plot resolution. + + Args: + width: Plot width in pixels. + height: Plot height in pixels. + + Raises: + ValueError: If width or height is non-positive. + """ + if width <= 0 or height <= 0: + raise ValueError("Resolution must have positive width and height") + self.resolution = (width, height) + + def __repr__(self) -> str: + """Return string representation of Visualization object.""" + if self.plot_type == "2D": + return ( + f"Visualization(name='{self.name}', type='2D', plane='{self.plane}', " + f"position={self.position} cm, resolution={self.resolution})" + ) + else: + return f"Visualization(name='{self.name}', type='3D')" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..cd57728 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for pyT5 package.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..11502a4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,111 @@ +"""Pytest configuration and fixtures for pyT5 tests.""" + +import pytest +import numpy as np +from pathlib import Path +from pyT5 import ( + Material, + MaterialLibrary, + Cell, + CellLibrary, + NuclearData, + NeutronSource, + PinCell, + Assembly, +) + + +@pytest.fixture +def sample_material() -> Material: + """Create a sample UO2 fuel material.""" + return Material( + name="UO2_fuel", + nuclides={"U235": 0.03, "U238": 0.97, "O16": 2.0}, + temperature=900.0, + density=10.4, + state="solid", + ) + + +@pytest.fixture +def sample_water_material() -> Material: + """Create a sample water material.""" + return Material( + name="light_water", + nuclides={"H1": 2.0, "O16": 1.0}, + temperature=300.0, + density=1.0, + state="liquid", + ) + + +@pytest.fixture +def material_library(sample_material: Material, sample_water_material: Material) -> MaterialLibrary: + """Create a material library with sample materials.""" + library = MaterialLibrary() + library.add_material(sample_material) + library.add_material(sample_water_material) + return library + + +@pytest.fixture +def sample_cell(sample_material: Material) -> Cell: + """Create a sample cell.""" + return Cell( + name="fuel_cell", + material=sample_material, + volume=100.0, + importance=1.0, + ) + + +@pytest.fixture +def cell_library(sample_cell: Cell) -> CellLibrary: + """Create a cell library with a sample cell.""" + library = CellLibrary() + library.add_cell(sample_cell) + return library + + +@pytest.fixture +def sample_pin_cell() -> PinCell: + """Create a sample pin cell.""" + return PinCell( + name="standard_pin", + pitch=1.26, + height=365.76, + fuel_radius=0.4096, + clad_inner_radius=0.418, + clad_outer_radius=0.475, + ) + + +@pytest.fixture +def sample_assembly() -> Assembly: + """Create a sample 17x17 assembly.""" + return Assembly( + name="17x17_assembly", + lattice_type="square", + n_pins_x=17, + n_pins_y=17, + pin_pitch=1.26, + assembly_pitch=21.5, + ) + + +@pytest.fixture +def sample_neutron_source() -> NeutronSource: + """Create a sample neutron source.""" + return NeutronSource( + name="fission_source", + source_type="criticality", + intensity=1.0e6, + ) + + +@pytest.fixture +def temp_nuclear_data_file(tmp_path: Path) -> Path: + """Create a temporary nuclear data file.""" + data_file = tmp_path / "test_xsections.dat" + data_file.write_text("# Mock nuclear data file\n") + return data_file diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..18d9733 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,199 @@ +"""Tests for geometry module.""" + +import pytest +import numpy as np +from pyT5 import PinCell, Assembly, Colorset, Core, Reflector + + +class TestPinCell: + """Tests for PinCell class.""" + + def test_pin_cell_creation(self, sample_pin_cell: PinCell) -> None: + """Test creating a pin cell.""" + assert sample_pin_cell.name == "standard_pin" + assert sample_pin_cell.pitch == 1.26 + assert sample_pin_cell.height == 365.76 + assert sample_pin_cell.fuel_radius == 0.4096 + + def test_pin_cell_invalid_dimensions(self) -> None: + """Test that invalid dimensions raise error.""" + with pytest.raises(ValueError, match="must be positive"): + PinCell( + name="test", + pitch=-1.0, + height=100.0, + fuel_radius=0.4, + clad_inner_radius=0.42, + clad_outer_radius=0.48, + ) + + def test_pin_cell_invalid_radii(self) -> None: + """Test that inconsistent radii raise error.""" + with pytest.raises(ValueError, match="Invalid radii"): + PinCell( + name="test", + pitch=1.26, + height=365.76, + fuel_radius=0.5, # Too large + clad_inner_radius=0.418, + clad_outer_radius=0.475, + ) + + def test_get_fuel_volume(self, sample_pin_cell: PinCell) -> None: + """Test fuel volume calculation.""" + volume = sample_pin_cell.get_fuel_volume() + expected = np.pi * sample_pin_cell.fuel_radius**2 * sample_pin_cell.height + assert volume == pytest.approx(expected) + + def test_get_gap_volume(self, sample_pin_cell: PinCell) -> None: + """Test gap volume calculation.""" + volume = sample_pin_cell.get_gap_volume() + expected = np.pi * ( + sample_pin_cell.clad_inner_radius**2 - sample_pin_cell.fuel_radius**2 + ) * sample_pin_cell.height + assert volume == pytest.approx(expected) + + def test_get_clad_volume(self, sample_pin_cell: PinCell) -> None: + """Test cladding volume calculation.""" + volume = sample_pin_cell.get_clad_volume() + expected = np.pi * ( + sample_pin_cell.clad_outer_radius**2 - sample_pin_cell.clad_inner_radius**2 + ) * sample_pin_cell.height + assert volume == pytest.approx(expected) + + +class TestAssembly: + """Tests for Assembly class.""" + + def test_assembly_creation(self, sample_assembly: Assembly) -> None: + """Test creating an assembly.""" + assert sample_assembly.name == "17x17_assembly" + assert sample_assembly.lattice_type == "square" + assert sample_assembly.n_pins_x == 17 + assert sample_assembly.n_pins_y == 17 + + def test_assembly_invalid_lattice_type(self) -> None: + """Test that invalid lattice type raises error.""" + with pytest.raises(ValueError, match="Invalid lattice_type"): + Assembly( + name="test", + lattice_type="triangular", + n_pins_x=17, + n_pins_y=17, + ) + + def test_set_pin(self, sample_assembly: Assembly, sample_pin_cell: PinCell) -> None: + """Test setting a pin in the assembly.""" + sample_assembly.set_pin(0, 0, sample_pin_cell) + pin = sample_assembly.get_pin(0, 0) + assert pin is not None + assert pin.name == "standard_pin" + + def test_set_pin_out_of_range(self, sample_assembly: Assembly, sample_pin_cell: PinCell) -> None: + """Test that setting pin out of range raises error.""" + with pytest.raises(IndexError): + sample_assembly.set_pin(20, 20, sample_pin_cell) + + def test_get_pin_empty_position(self, sample_assembly: Assembly) -> None: + """Test getting pin from empty position.""" + pin = sample_assembly.get_pin(0, 0) + assert pin is None + + def test_count_pins(self, sample_assembly: Assembly, sample_pin_cell: PinCell) -> None: + """Test counting pins in assembly.""" + assert sample_assembly.count_pins() == 0 + sample_assembly.set_pin(0, 0, sample_pin_cell) + sample_assembly.set_pin(1, 1, sample_pin_cell) + assert sample_assembly.count_pins() == 2 + + +class TestColorset: + """Tests for Colorset class.""" + + def test_colorset_creation(self) -> None: + """Test creating a colorset.""" + colorset = Colorset(name="test_colorset") + assert colorset.name == "test_colorset" + assert colorset.count_assemblies() == 0 + + def test_add_assembly(self, sample_assembly: Assembly) -> None: + """Test adding assembly to colorset.""" + colorset = Colorset(name="test") + colorset.add_assembly((0, 0), sample_assembly) + assert colorset.count_assemblies() == 1 + + def test_add_assembly_duplicate_position(self, sample_assembly: Assembly) -> None: + """Test that adding assembly to occupied position raises error.""" + colorset = Colorset(name="test") + colorset.add_assembly((0, 0), sample_assembly) + with pytest.raises(ValueError, match="already occupied"): + colorset.add_assembly((0, 0), sample_assembly) + + def test_get_assembly(self, sample_assembly: Assembly) -> None: + """Test getting assembly from colorset.""" + colorset = Colorset(name="test") + colorset.add_assembly((0, 0), sample_assembly) + asm = colorset.get_assembly((0, 0)) + assert asm is not None + assert asm.name == "17x17_assembly" + + def test_remove_assembly(self, sample_assembly: Assembly) -> None: + """Test removing assembly from colorset.""" + colorset = Colorset(name="test") + colorset.add_assembly((0, 0), sample_assembly) + colorset.remove_assembly((0, 0)) + assert colorset.count_assemblies() == 0 + + +class TestCore: + """Tests for Core class.""" + + def test_core_creation(self) -> None: + """Test creating a core.""" + core = Core(name="PWR_core", core_type="square", n_assemblies_x=15, n_assemblies_y=15) + assert core.name == "PWR_core" + assert core.core_type == "square" + assert core.n_assemblies_x == 15 + + def test_core_invalid_type(self) -> None: + """Test that invalid core type raises error.""" + with pytest.raises(ValueError, match="Invalid core_type"): + Core(name="test", core_type="triangular") + + def test_set_assembly(self, sample_assembly: Assembly) -> None: + """Test setting assembly in core.""" + core = Core(name="test", n_assemblies_x=15, n_assemblies_y=15) + core.set_assembly((0, 0), sample_assembly) + assert core.count_assemblies() == 1 + + def test_get_assembly(self, sample_assembly: Assembly) -> None: + """Test getting assembly from core.""" + core = Core(name="test", n_assemblies_x=15, n_assemblies_y=15) + core.set_assembly((5, 5), sample_assembly) + asm = core.get_assembly((5, 5)) + assert asm is not None + assert asm.name == "17x17_assembly" + + +class TestReflector: + """Tests for Reflector class.""" + + def test_reflector_creation(self) -> None: + """Test creating a reflector.""" + reflector = Reflector( + name="water_reflector", + thickness=20.0, + material_name="light_water", + ) + assert reflector.name == "water_reflector" + assert reflector.thickness == 20.0 + assert reflector.material_name == "light_water" + + def test_reflector_invalid_thickness(self) -> None: + """Test that invalid thickness raises error.""" + with pytest.raises(ValueError, match="Thickness must be positive"): + Reflector( + name="test", + thickness=-10.0, + material_name="water", + ) diff --git a/tests/test_materials.py b/tests/test_materials.py new file mode 100644 index 0000000..1f52e0e --- /dev/null +++ b/tests/test_materials.py @@ -0,0 +1,120 @@ +"""Tests for materials module.""" + +import pytest +from pyT5 import Material, MaterialLibrary + + +class TestMaterial: + """Tests for Material class.""" + + def test_material_creation(self, sample_material: Material) -> None: + """Test creating a material.""" + assert sample_material.name == "UO2_fuel" + assert sample_material.temperature == 900.0 + assert sample_material.density == 10.4 + assert sample_material.state == "solid" + assert "U235" in sample_material.nuclides + + def test_material_invalid_temperature(self) -> None: + """Test that negative temperature raises error.""" + with pytest.raises(ValueError, match="Temperature must be non-negative"): + Material( + name="test", + nuclides={"U235": 1.0}, + temperature=-10.0, + ) + + def test_material_empty_nuclides(self) -> None: + """Test that empty nuclides dict raises error.""" + with pytest.raises(ValueError, match="must contain at least one nuclide"): + Material(name="test", nuclides={}) + + def test_material_invalid_state(self) -> None: + """Test that invalid state raises error.""" + with pytest.raises(ValueError, match="Invalid state"): + Material( + name="test", + nuclides={"U235": 1.0}, + state="plasma", + ) + + def test_normalize_concentrations(self) -> None: + """Test concentration normalization.""" + mat = Material(name="test", nuclides={"U235": 2.0, "U238": 8.0}) + mat.normalize_concentrations() + assert mat.nuclides["U235"] == pytest.approx(0.2) + assert mat.nuclides["U238"] == pytest.approx(0.8) + + def test_add_nuclide(self, sample_material: Material) -> None: + """Test adding a nuclide.""" + sample_material.add_nuclide("Pu239", 0.01) + assert "Pu239" in sample_material.nuclides + assert sample_material.nuclides["Pu239"] == 0.01 + + def test_remove_nuclide(self, sample_material: Material) -> None: + """Test removing a nuclide.""" + sample_material.remove_nuclide("U235") + assert "U235" not in sample_material.nuclides + + def test_remove_nonexistent_nuclide(self, sample_material: Material) -> None: + """Test removing nonexistent nuclide raises error.""" + with pytest.raises(KeyError): + sample_material.remove_nuclide("Pu239") + + def test_get_concentration(self, sample_material: Material) -> None: + """Test getting nuclide concentration.""" + conc = sample_material.get_concentration("U235") + assert conc == 0.03 + + def test_set_temperature(self, sample_material: Material) -> None: + """Test setting temperature.""" + sample_material.set_temperature(1200.0) + assert sample_material.temperature == 1200.0 + + def test_set_density(self, sample_material: Material) -> None: + """Test setting density.""" + sample_material.set_density(11.0) + assert sample_material.density == 11.0 + + +class TestMaterialLibrary: + """Tests for MaterialLibrary class.""" + + def test_library_creation(self) -> None: + """Test creating an empty library.""" + lib = MaterialLibrary() + assert len(lib) == 0 + + def test_add_material(self, sample_material: Material) -> None: + """Test adding material to library.""" + lib = MaterialLibrary() + lib.add_material(sample_material) + assert len(lib) == 1 + assert "UO2_fuel" in lib.list_materials() + + def test_add_duplicate_material(self, material_library: MaterialLibrary, sample_material: Material) -> None: + """Test that adding duplicate material raises error.""" + with pytest.raises(ValueError, match="already exists"): + material_library.add_material(sample_material) + + def test_get_material(self, material_library: MaterialLibrary) -> None: + """Test retrieving material from library.""" + mat = material_library.get_material("UO2_fuel") + assert mat.name == "UO2_fuel" + + def test_get_nonexistent_material(self, material_library: MaterialLibrary) -> None: + """Test getting nonexistent material raises error.""" + with pytest.raises(KeyError): + material_library.get_material("nonexistent") + + def test_remove_material(self, material_library: MaterialLibrary) -> None: + """Test removing material from library.""" + material_library.remove_material("UO2_fuel") + assert "UO2_fuel" not in material_library.list_materials() + + def test_list_materials(self, material_library: MaterialLibrary) -> None: + """Test listing all materials.""" + materials = material_library.list_materials() + assert len(materials) == 2 + assert "UO2_fuel" in materials + assert "light_water" in materials diff --git a/tests/test_simulation.py b/tests/test_simulation.py new file mode 100644 index 0000000..3c2a0ab --- /dev/null +++ b/tests/test_simulation.py @@ -0,0 +1,83 @@ +"""Tests for simulation module.""" + +import pytest +from pyT5 import Simulation, NuclearData, MaterialLibrary, Core, NeutronSource + + +class TestSimulation: + """Tests for Simulation class.""" + + def test_simulation_creation(self) -> None: + """Test creating a simulation.""" + sim = Simulation( + name="test_sim", + n_particles=10000, + n_cycles=100, + n_inactive=20, + n_threads=4, + ) + assert sim.name == "test_sim" + assert sim.n_particles == 10000 + assert sim.n_cycles == 100 + assert sim.n_inactive == 20 + assert sim.n_threads == 4 + + def test_simulation_invalid_particles(self) -> None: + """Test that invalid particle count raises error.""" + with pytest.raises(ValueError, match="n_particles must be positive"): + Simulation(name="test", n_particles=-100) + + def test_simulation_invalid_cycles(self) -> None: + """Test that invalid cycle counts raise error.""" + with pytest.raises(ValueError, match="n_inactive.*must be less than n_cycles"): + Simulation(name="test", n_cycles=100, n_inactive=150) + + def test_simulation_invalid_threads(self) -> None: + """Test that invalid thread count raises error.""" + with pytest.raises(ValueError, match="n_threads must be positive"): + Simulation(name="test", n_threads=0) + + def test_set_particles_per_cycle(self) -> None: + """Test setting particles per cycle.""" + sim = Simulation(name="test") + sim.set_particles_per_cycle(20000) + assert sim.n_particles == 20000 + + def test_set_cycles(self) -> None: + """Test setting cycle counts.""" + sim = Simulation(name="test") + sim.set_cycles(n_cycles=200, n_inactive=50) + assert sim.n_cycles == 200 + assert sim.n_inactive == 50 + + def test_set_threads(self) -> None: + """Test setting thread count.""" + sim = Simulation(name="test") + sim.set_threads(8) + assert sim.n_threads == 8 + + def test_validate_missing_components(self) -> None: + """Test that validation fails when components missing.""" + sim = Simulation(name="test") + with pytest.raises(RuntimeError, match="Nuclear data not set"): + sim.validate() + + def test_validate_complete_simulation( + self, + temp_nuclear_data_file, + material_library: MaterialLibrary, + sample_neutron_source: NeutronSource, + ) -> None: + """Test validation with all components set.""" + sim = Simulation(name="test") + nuclear_data = NuclearData(cross_section_library=temp_nuclear_data_file) + nuclear_data.validate() + + core = Core(name="test_core") + + sim.set_nuclear_data(nuclear_data) + sim.set_materials(material_library) + sim.set_geometry(core) + sim.set_source(sample_neutron_source) + + assert sim.validate() is True