├── tests ├── __init__.py ├── inputs │ ├── testReadRecursive.imcnp │ ├── testReadTarget.imcnp │ ├── testRead.imcnp │ └── testReadReference.imcnp ├── conftest.py ├── test_models.py ├── test_read_file.py ├── test_boundary.py ├── test_syntax.py ├── test_multiparticle_importance.py ├── test_rotation_matrix.py ├── test_material.py ├── test_geometry.py ├── models │ └── tinkertoy.mcnp └── test_surfaces.py ├── .gitignore ├── src └── openmc_mcnp_adapter │ ├── __init__.py │ ├── parse.py │ └── openmc_conversion.py ├── LICENSE ├── .github └── workflows │ └── tests.yml ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/inputs/testReadRecursive.imcnp: -------------------------------------------------------------------------------- 1 | 2 0 +1 $ lowest level -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | __pycache__ 3 | *.egg-info 4 | .coverage 5 | -------------------------------------------------------------------------------- /tests/inputs/testReadTarget.imcnp: -------------------------------------------------------------------------------- 1 | 1 0 -1 2 | read file=testReadRecursive.imcnp 3 | c -------------------------------------------------------------------------------- /tests/inputs/testRead.imcnp: -------------------------------------------------------------------------------- 1 | Testing read card 2 | C this is a comment 3 | read echo file=testReadTarget.imcnp encode 4 | 5 | 1 so 0.5 6 | 7 | mode n -------------------------------------------------------------------------------- /tests/inputs/testReadReference.imcnp: -------------------------------------------------------------------------------- 1 | Testing read card 2 | C this is a comment 3 | 1 0 -1 4 | 2 0 +1 $ lowest level 5 | c 6 | 7 | 1 so 0.5 8 | 9 | mode n -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import openmc 3 | 4 | 5 | @pytest.fixture(scope='function', autouse=True) 6 | def reset_openmc_ids(request): 7 | # Reset autogenerated IDs assigned to OpenMC objects 8 | openmc.reset_auto_ids() 9 | -------------------------------------------------------------------------------- /src/openmc_mcnp_adapter/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-2025 UChicago Argonne, LLC and contributors 2 | # SPDX-License-Identifier: MIT 3 | 4 | from .openmc_conversion import * # noqa: F403 5 | from .parse import * # noqa: F403 6 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | 4 | import pytest 5 | from openmc_mcnp_adapter.openmc_conversion import mcnp_to_openmc 6 | 7 | 8 | MODELS_DIR = Path(__file__).parent / "models" 9 | MODELS = sorted(MODELS_DIR.glob("*.mcnp")) 10 | 11 | 12 | @pytest.mark.parametrize("mcnp_model", MODELS, ids=[path.stem for path in MODELS]) 13 | def test_mcnp_models_convert(tmp_path, monkeypatch, mcnp_model): 14 | output_path = tmp_path / "model.xml" 15 | monkeypatch.chdir(tmp_path) 16 | monkeypatch.setattr(sys, "argv", [ 17 | "mcnp_to_openmc", 18 | str(mcnp_model), 19 | "-o", 20 | str(output_path), 21 | ]) 22 | 23 | mcnp_to_openmc() 24 | 25 | assert output_path.exists(), "Expected OpenMC model.xml was not created" 26 | -------------------------------------------------------------------------------- /tests/test_read_file.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import textwrap 3 | import pytest 4 | 5 | from openmc_mcnp_adapter import mcnp_str_to_model, mcnp_to_model 6 | from openmc_mcnp_adapter.parse import expand_read_cards 7 | 8 | 9 | INPUT_DIR = Path(__file__).with_name("inputs") 10 | 11 | 12 | def test_read_not_found(): 13 | deck = textwrap.dedent(""" 14 | title 15 | c The next line points to an invalid file 16 | read file=/badfile.path 17 | """) 18 | with pytest.raises(FileNotFoundError): 19 | mcnp_str_to_model(deck) 20 | 21 | 22 | def test_read_recursive(): 23 | reference = expand_read_cards(INPUT_DIR / "testReadReference.imcnp") 24 | trial = expand_read_cards(INPUT_DIR / "testRead.imcnp") 25 | assert trial == reference 26 | 27 | 28 | def test_recursive_mcnp_to_model(): 29 | mcnp_to_model(INPUT_DIR / "testRead.imcnp") 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022-2025 UChicago Argonne, LLC and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | # allows us to run workflows manually 5 | workflow_dispatch: 6 | 7 | pull_request: 8 | branches: 9 | - main 10 | push: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | main: 16 | runs-on: ubuntu-latest 17 | container: openmc/openmc:latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Apt dependencies 23 | shell: bash 24 | run: | 25 | apt -y update 26 | apt install -y python3-venv curl 27 | python -m venv venv 28 | source venv/bin/activate 29 | 30 | - name: Install 31 | shell: bash 32 | run: | 33 | pip install -U pip 34 | pushd /root/OpenMC/openmc 35 | pip install .[test] 36 | popd 37 | pip install .[test] 38 | 39 | - name: Test 40 | shell: bash 41 | run: pytest --cov=openmc_mcnp_adapter --cov-branch --cov-report=xml -rs tests 42 | 43 | - name: Upload coverage reports to Codecov 44 | uses: codecov/codecov-action@v5 45 | with: 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | slug: openmc-dev/openmc_mcnp_adapter 48 | -------------------------------------------------------------------------------- /tests/test_boundary.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | from openmc_mcnp_adapter import mcnp_str_to_model 4 | 5 | 6 | def test_vacuum_sphere(): 7 | mcnp_model = textwrap.dedent(""" 8 | title 9 | 1 1 -1.0 -1 imp:n=1 10 | 2 0 1 imp:n=0 11 | 12 | 1 so 30.0 13 | 14 | m1 1001.80c 3.0 15 | """) 16 | 17 | model = mcnp_str_to_model(mcnp_model) 18 | surf = model.geometry.get_all_surfaces()[1] 19 | assert surf.boundary_type == 'vacuum' 20 | 21 | 22 | def test_vacuum_cylinder(): 23 | mcnp_model = textwrap.dedent(""" 24 | title 25 | 1 1 -1.0 -1 2 -3 imp:n=1.0 26 | 2 0 1:-2:3 imp:n=0.0 27 | 28 | 1 cz 1.0 29 | 2 pz -1.0 30 | 3 pz 1.0 31 | 32 | m1 1001.80c 3.0 33 | """) 34 | 35 | model = mcnp_str_to_model(mcnp_model) 36 | for surf in model.geometry.get_all_surfaces().values(): 37 | assert surf.boundary_type == 'vacuum' 38 | 39 | 40 | def test_vacuum_box(): 41 | mcnp_model = textwrap.dedent(""" 42 | title 43 | 1 1 -1.0 -1 imp:n=1.00 44 | 2 0 1 imp:n=0.00 45 | 46 | 1 rpp -1 1 -1 1 -1 1 47 | 48 | m1 1001.80c 3.0 49 | """) 50 | 51 | model = mcnp_str_to_model(mcnp_model) 52 | for surf in model.geometry.get_all_surfaces().values(): 53 | assert surf.boundary_type == 'vacuum' 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "openmc_mcnp_adapter" 7 | version = "0.1.0" 8 | description = "Tool to convert MCNP input file to OpenMC classes/XML" 9 | readme = "README.md" 10 | authors = [ 11 | { name = "Paul Romano", email = "paul.k.romano@gmail.com" }, 12 | ] 13 | license = "MIT" 14 | requires-python = ">=3.10" 15 | 16 | dependencies = [ 17 | "numpy", 18 | ] 19 | 20 | classifiers = [ 21 | "Development Status :: 3 - Alpha", 22 | "Intended Audience :: Developers", 23 | "Intended Audience :: End Users/Desktop", 24 | "Intended Audience :: Science/Research", 25 | "Natural Language :: English", 26 | "Operating System :: OS Independent", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Topic :: Scientific/Engineering :: Physics", 33 | ] 34 | 35 | [project.optional-dependencies] 36 | test = ["pytest", "pytest-cov"] 37 | 38 | [project.urls] 39 | "Bug Tracker" = "https://github.com/openmc-dev/openmc_mcnp_adapter/issues" 40 | Discussions = "https://openmc.discourse.org" 41 | "Source Code" = "https://github.com/openmc-dev/openmc_mcnp_adapter" 42 | 43 | [project.scripts] 44 | mcnp_to_openmc = "openmc_mcnp_adapter.openmc_conversion:mcnp_to_openmc" 45 | -------------------------------------------------------------------------------- /tests/test_syntax.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | from pytest import approx 4 | from openmc_mcnp_adapter import mcnp_str_to_model 5 | 6 | 7 | def test_repeat_shortcut(): 8 | mcnp_str = dedent(""" 9 | title 10 | 1 0 -1 11 | 12 | 1 gq 1.0 3r 0.0 5r 13 | 14 | m1 1001.80c 3.0 15 | """) 16 | model = mcnp_str_to_model(mcnp_str) 17 | surf = model.geometry.get_all_surfaces()[1] 18 | print(surf) 19 | 20 | # Make sure A, B, C, and D parameters are 1.0 21 | for attr in 'abcd': 22 | assert getattr(surf, attr) == 1.0 23 | 24 | # Make sure E, F, G, H, J, and K parameters are 0.0 25 | for attr in 'efghj': 26 | assert getattr(surf, attr) == 0.0 27 | 28 | 29 | def test_comments(): 30 | mcnp_str = dedent(""" 31 | title 32 | c This is a comment line 33 | 1 1 -5.0 -1 $ This is an end-of-line comment 34 | 35 | c surface block 36 | 1 so 3.0 $ Another comment 37 | 38 | m1 1001.80c 3.0 $ Material comment 39 | """) 40 | model = mcnp_str_to_model(mcnp_str) 41 | surf = model.geometry.get_all_surfaces()[1] 42 | cell = model.geometry.get_all_cells()[1] 43 | mat = model.materials[0] 44 | 45 | # Sanity checks 46 | assert surf.r == 3.0 47 | assert surf.x0 == 0.0 48 | assert cell.id == 1 49 | assert str(cell.region) == "-1" 50 | assert 'H1' in mat.get_nuclide_densities() 51 | assert mat.get_mass_density() == approx(5.0) 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCNP Conversion Tools for OpenMC 2 | 3 | [![License](https://img.shields.io/badge/license-MIT-green)](https://opensource.org/licenses/MIT) 4 | [![codecov](https://codecov.io/github/openmc-dev/openmc_mcnp_adapter/graph/badge.svg?token=KX00MQ57G5)](https://codecov.io/github/openmc-dev/openmc_mcnp_adapter) 5 | 6 | This repository provides tools for parsing/converting MCNP models to OpenMC 7 | classes and/or XML files. To install these tools, run: 8 | 9 | python -m pip install git+https://github.com/openmc-dev/openmc_mcnp_adapter.git 10 | 11 | This makes the `openmc_mcnp_adapter` Python module and `mcnp_to_openmc` console 12 | script available. To convert an MCNP model, run: 13 | 14 | mcnp_to_openmc mcnp_input 15 | 16 | ## Disclaimer 17 | 18 | There has been no methodical V&V on this converter; use at your own risk! 19 | 20 | ## Known Limitations 21 | 22 | The converter currently only handles geometry and material information; source 23 | definition (SDEF) and tally specifications are ignored. 24 | 25 | The converter will try to set surface boundary conditions to match the MCNP 26 | model, but in many cases it doesn't work cleanly. For these cases, you will need 27 | to manually set boundary conditions on the outermost surfaces. 28 | 29 | Some geometry features are not currently supported: 30 | 31 | - `X`, `Y`, and `Z` surfaces with 3 coordinate pairs 32 | - `RHP`, `REC`, `ELL`, `WED`, and `ARB` macrobodies 33 | - Hexagonal lattices 34 | - One-dimensional lattices 35 | - Two-dimensional lattices with basis other than x-y 36 | - `U`, `LAT`, and `FILL` cards specified in the data card block 37 | -------------------------------------------------------------------------------- /tests/test_multiparticle_importance.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 UChicago Argonne, LLC and contributors 2 | # SPDX-License-Identifier: MIT 3 | 4 | from textwrap import dedent 5 | 6 | from openmc_mcnp_adapter import mcnp_str_to_model, parse_cell 7 | 8 | 9 | def test_multiparticle_importance(): 10 | """Test that multi-particle importance keywords (e.g., imp:n,p) are parsed correctly.""" 11 | mcnp_str = dedent(""" 12 | title 13 | 1 1 -1.0 -1 imp:n,p=1 14 | 2 0 1 imp:n,p=0 15 | 16 | 1 so 1.0 17 | 18 | m1 1001.80c 1.0 19 | mode n p 20 | """) 21 | model = mcnp_str_to_model(mcnp_str) 22 | 23 | # Get cells 24 | cells = model.geometry.get_all_cells() 25 | cell1 = cells[1] 26 | cell2 = cells[2] 27 | 28 | # Check that cells exist and have correct IDs 29 | assert cell1.id == 1 30 | assert cell2.id == 2 31 | 32 | # Test parse_cell directly 33 | line1 = "1 1 -1.0 -1 imp:n,p=1" 34 | cell_dict1 = parse_cell(line1) 35 | assert 'imp:n,p' in cell_dict1['parameters'] 36 | assert cell_dict1['parameters']['imp:n,p'] == '1' 37 | 38 | line2 = "2 0 1 imp:n,p=0" 39 | cell_dict2 = parse_cell(line2) 40 | assert 'imp:n,p' in cell_dict2['parameters'] 41 | assert cell_dict2['parameters']['imp:n,p'] == '0' 42 | 43 | 44 | def test_multiparticle_importance_three_particles(): 45 | line = "1 1 -1.0 -1 imp:n,|,e=1" 46 | cell_dict = parse_cell(line) 47 | assert 'imp:n,|,e' in cell_dict['parameters'] 48 | assert cell_dict['parameters']['imp:n,|,e'] == '1' 49 | 50 | 51 | def test_multiparticle_importance_with_other_params(): 52 | """Test that multi-particle importance works with other cell parameters.""" 53 | line = "1 1 -1.0 -1 imp:n,p=1 vol=5.0 u=2" 54 | cell_dict = parse_cell(line) 55 | assert 'imp:n,p' in cell_dict['parameters'] 56 | assert cell_dict['parameters']['imp:n,p'] == '1' 57 | assert cell_dict['parameters']['vol'] == '5.0' 58 | assert cell_dict['parameters']['u'] == '2' 59 | 60 | 61 | def test_single_particle_importance_still_works(): 62 | """Ensure single-particle importance still works correctly.""" 63 | line = "1 1 -1.0 -1 imp:n=1" 64 | cell_dict = parse_cell(line) 65 | assert 'imp:n' in cell_dict['parameters'] 66 | assert cell_dict['parameters']['imp:n'] == '1' 67 | -------------------------------------------------------------------------------- /tests/test_rotation_matrix.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from openmc_mcnp_adapter import rotation_matrix 5 | 6 | 7 | def is_orthogonal(R: np.ndarray, atol: float = 1e-12) -> bool: 8 | """Check if the matrix R is orthogonal""" 9 | I = np.identity(3) 10 | return np.allclose(R.T @ R, I, atol=atol) and np.allclose(R @ R.T, I, atol=atol) 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "v1, v2", 15 | [ 16 | # Same direction, different magnitudes 17 | (np.array([1.0, 2.0, 3.0]), np.array([2.0, 4.0, 6.0])), 18 | (np.array([0.0, 0.0, 1.0]), np.array([0.0, 0.0, 10.0])), 19 | ], 20 | ) 21 | def test_rotation_parallel(v1, v2): 22 | """Test rotation_matrix for parallel vectors""" 23 | R = rotation_matrix(v1, v2) 24 | # Should be exactly identity from the parallel branch 25 | assert np.allclose(R, np.identity(3)) 26 | assert is_orthogonal(R) 27 | assert np.isclose(np.linalg.det(R), 1.0) 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "v1, v2", 32 | [ 33 | (np.array([0.0, 0.0, 1.0]), np.array([0.0, 0.0, -5.0])), # z -> -z 34 | (np.array([1.0, 0.0, 0.0]), np.array([-2.0, 0.0, 0.0])), # x -> -x 35 | ], 36 | ) 37 | def test_rotation_antiparallel(v1, v2): 38 | """Test rotation_matrix for anti-parallel vectors""" 39 | R = rotation_matrix(v1, v2) 40 | 41 | # Maps v1 direction to v2 direction 42 | v2_hat = v2 / np.linalg.norm(v2) 43 | mapped = R @ (v1 / np.linalg.norm(v1)) 44 | assert np.allclose(mapped, v2_hat, atol=1e-12) 45 | 46 | # No NaNs, orthogonal and det +1 47 | assert not np.isnan(R).any() 48 | assert is_orthogonal(R) 49 | assert np.isclose(np.linalg.det(R), 1.0, atol=1e-12) 50 | # 180-degree rotation has trace -1 51 | assert np.isclose(np.trace(R), -1.0, atol=1e-12) 52 | 53 | 54 | @pytest.mark.parametrize( 55 | "v1, v2", 56 | [ 57 | (np.array([0.0, 0.0, 1.0]), np.array([1.0, 2.0, 3.0])), 58 | (np.array([0.0, 1.0, 0.0]), np.array([3.0, -1.0, 2.0])), 59 | ], 60 | ) 61 | def test_rotation_general(v1, v2): 62 | """Test rotation_matrix for general vectors""" 63 | R = rotation_matrix(v1, v2) 64 | 65 | # Maps v1 to v2 direction 66 | v2_hat = v2 / np.linalg.norm(v2) 67 | mapped = R @ (v1 / np.linalg.norm(v1)) 68 | assert np.allclose(mapped, v2_hat, atol=1e-12) 69 | 70 | # Orthogonal and proper rotation 71 | assert is_orthogonal(R) 72 | assert np.isclose(np.linalg.det(R), 1.0, atol=1e-12) 73 | 74 | # A vector perpendicular to v1 remains perpendicular to R v1 (i.e., to v2_hat) 75 | # Use a simple perpendicular vector: pick the least-aligned basis axis and project out 76 | basis = [ 77 | np.array([1.0, 0.0, 0.0]), 78 | np.array([0.0, 1.0, 0.0]), 79 | np.array([0.0, 0.0, 1.0]) 80 | ] 81 | u1_hat = v1 / np.linalg.norm(v1) 82 | e = min(basis, key=lambda b: abs(np.dot(b, u1_hat))) 83 | x = e - np.dot(e, u1_hat) * u1_hat 84 | x /= np.linalg.norm(x) 85 | 86 | # Should be perpendicular to v2_hat 87 | assert np.isclose(np.dot(R @ x, v2_hat), 0.0, atol=1e-12) 88 | -------------------------------------------------------------------------------- /tests/test_material.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | 3 | import openmc 4 | from openmc_mcnp_adapter import mcnp_str_to_model 5 | from pytest import approx 6 | 7 | 8 | def convert_material(mat_card: str, density: float, thermal_card: str = "", **kwargs) -> openmc.Material: 9 | mcnp_model = textwrap.dedent(f""" 10 | title 11 | 1 1 {density} 1 12 | 13 | 1 px 0.0 14 | 15 | {mat_card} 16 | {thermal_card} 17 | """) 18 | model = mcnp_str_to_model(mcnp_model, **kwargs) 19 | return model.materials[0] 20 | 21 | 22 | def test_material_clones(): 23 | mcnp_model = textwrap.dedent(""" 24 | title 25 | 1 1 -1.0 1 -2 26 | 2 1 -2.0 1 2 27 | 3 1 -2.0 -1 -2 28 | 4 1 -1.0 -1 2 29 | 30 | 1 px 0.0 31 | 2 py 1.0 32 | 33 | m1 1001.80c 3.0 34 | """) 35 | 36 | model = mcnp_str_to_model(mcnp_model) 37 | cells = model.geometry.get_all_cells() 38 | assert cells[1].fill.id == cells[4].fill.id == 1 39 | assert cells[2].fill.id == cells[3].fill.id != 1 40 | assert cells[1].fill.get_mass_density() == approx(1.0) 41 | assert cells[2].fill.get_mass_density() == approx(2.0) 42 | 43 | 44 | def test_material_suffixes(): 45 | # H1 with XS suffix; O16 without suffix 46 | mat_card = "m1 1001.80c 2.0 8016 1.0" 47 | thermal_card = "mt1 lwtr grph.10t" 48 | m = convert_material(mat_card, -2.0, thermal_card) 49 | nd = m.get_nuclide_densities() 50 | assert set(nd.keys()) == {'H1', 'O16'} 51 | assert nd['H1'].percent == approx(2.0) 52 | assert nd['H1'].percent_type == 'ao' 53 | assert nd['O16'].percent == approx(1.0) 54 | assert nd['O16'].percent_type == 'ao' 55 | 56 | # Check S(a,b) tables mapped via get_thermal_name 57 | # Access private field because there is no public accessor 58 | sab_names = {name for (name, _) in getattr(m, '_sab', [])} 59 | assert 'c_H_in_H2O' in sab_names 60 | assert 'c_Graphite' in sab_names 61 | 62 | 63 | def test_weight_fractions(): 64 | # 6000 -> natural C, 5010/5011 -> B-10/B-11; negative => weight 65 | mat_card = "m1 6000 -0.12 5010 -0.2 5011 -0.8" 66 | m = convert_material(mat_card, -1.0) 67 | nd = m.get_nuclide_densities() 68 | 69 | # Natural carbon represented as C0 in OpenMC 70 | assert 'C0' in nd and nd['C0'].percent_type == 'wo' and nd['C0'].percent == approx(0.12) 71 | assert 'B10' in nd and nd['B10'].percent_type == 'wo' and nd['B10'].percent == approx(0.2) 72 | assert 'B11' in nd and nd['B11'].percent_type == 'wo' and nd['B11'].percent == approx(0.8) 73 | 74 | 75 | def test_no_expand_elements(): 76 | # With expand_elements=False, natural oxygen should be added as O0 directly 77 | mat_card = "m1 47000 1.0" 78 | m = convert_material(mat_card, -1.0, expand_elements=False) 79 | nd = m.get_nuclide_densities() 80 | assert 'Ag0' in nd 81 | assert nd['Ag0'].percent_type == 'ao' 82 | assert nd['Ag0'].percent == approx(1.0) 83 | 84 | 85 | def test_mass_density(): 86 | mat_card = "m1 3006.80c 0.5 3007.80c 0.5" 87 | m = convert_material(mat_card, -3.0) 88 | assert m.get_mass_density() == approx(3.0) 89 | 90 | 91 | def test_atom_density(): 92 | mat_card = "m1 3006.80c 0.5 3007.80c 0.5" 93 | m = convert_material(mat_card, 0.02) 94 | nuclide_densities = m.get_nuclide_atom_densities() 95 | assert sum(nuclide_densities.values()) == approx(0.02) 96 | assert nuclide_densities['Li6'] == approx(0.01) 97 | assert nuclide_densities['Li7'] == approx(0.01) 98 | 99 | 100 | def test_density_no_whitespace(): 101 | mcnp_model = textwrap.dedent(""" 102 | title 103 | 1 1 -4.5( -1 ) 104 | 105 | 1 px 0.0 106 | 107 | m1 1001.80c 2.0 108 | """) 109 | model = mcnp_str_to_model(mcnp_model) 110 | m = model.materials[0] 111 | assert m.get_mass_density() == approx(4.5) 112 | 113 | 114 | def test_material_keywords_at_beginning(): 115 | """Test material card with keywords at the beginning""" 116 | mat_card = "m1 NLIB=70c PLIB=04p 92235.70c 1.0 92238.70c 0.5" 117 | m = convert_material(mat_card, -1.0) 118 | nd = m.get_nuclide_densities() 119 | assert 'U235' in nd and nd['U235'].percent == approx(1.0) 120 | assert 'U238' in nd and nd['U238'].percent == approx(0.5) 121 | 122 | 123 | def test_material_keywords_in_middle(): 124 | """Test material card with keywords interspersed with nuclide pairs""" 125 | mat_card = "m1 92235.70c 1.0 NLIB=70c 92238.70c 0.5 PLIB=04p 92234.70c 0.3" 126 | m = convert_material(mat_card, -1.0) 127 | nd = m.get_nuclide_densities() 128 | assert 'U235' in nd and nd['U235'].percent == approx(1.0) 129 | assert 'U238' in nd and nd['U238'].percent == approx(0.5) 130 | assert 'U234' in nd and nd['U234'].percent == approx(0.3) 131 | 132 | 133 | def test_material_keywords_with_spaces(): 134 | """Test material card with keywords that have spaces around equals sign""" 135 | mat_card = "m1 92235.70c 1.5 NLIB = 80c 92238.70c 0.5 GAS = 0" 136 | m = convert_material(mat_card, -2.0) 137 | nd = m.get_nuclide_densities() 138 | assert 'U235' in nd and nd['U235'].percent == approx(1.5) 139 | assert 'U238' in nd and nd['U238'].percent == approx(0.5) 140 | assert m.get_mass_density() == approx(2.0) 141 | 142 | 143 | def test_material_keywords_at_end(): 144 | """Test material card with keywords at the end""" 145 | mat_card = "m1 92235.70c 2.0 92238.70c 1.0 NLIB=70c PLIB=04p" 146 | m = convert_material(mat_card, -1.0) 147 | nd = m.get_nuclide_densities() 148 | assert 'U235' in nd and nd['U235'].percent == approx(2.0) 149 | assert 'U238' in nd and nd['U238'].percent == approx(1.0) 150 | 151 | 152 | def test_material_without_keywords(): 153 | """Test that material cards without keywords still work correctly""" 154 | mat_card = "m1 92235.70c 1.0 92238.70c 0.5" 155 | m = convert_material(mat_card, -1.0) 156 | nd = m.get_nuclide_densities() 157 | assert 'U235' in nd and nd['U235'].percent == approx(1.0) 158 | assert 'U238' in nd and nd['U238'].percent == approx(0.5) 159 | 160 | def test_material_with_code_keyword(): 161 | """Test that keywords starting with 'c' are not treated as comment cards""" 162 | # This tests the case where continuation line has keyword starting with 'c' 163 | # The 'code=0' line should not be treated as a comment card 164 | mcnp_model = textwrap.dedent(""" 165 | title 166 | 1 1 -1.0 1 167 | 2 2 -1.0 -1 168 | 169 | 1 px 0.0 170 | 171 | m1 1001.70c 1.0 & 172 | code=0 173 | m2 1001.70c 1.0 174 | """) 175 | model = mcnp_str_to_model(mcnp_model) 176 | # Should have two distinct materials 177 | assert len(model.materials) == 2 178 | # Both materials should have H1 179 | for mat in model.materials: 180 | nd = mat.get_nuclide_densities() 181 | assert 'H1' in nd 182 | assert nd['H1'].percent == approx(1.0) 183 | -------------------------------------------------------------------------------- /tests/test_geometry.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | from pytest import mark, approx, param 4 | from openmc_mcnp_adapter import mcnp_str_to_model 5 | 6 | 7 | @mark.parametrize("whitespace", ["", " ", "\t"]) 8 | def test_cell_complement(whitespace): 9 | # Cell 2 corresponds to r < 2 intersected with z > 0 10 | mcnp_str = dedent(f""" 11 | title 12 | 100 1 1.0 +1 : -2 13 | 2 1 1.0 #{whitespace}100 14 | 15 | 1 so 2.0 16 | 2 pz 0.0 17 | 18 | m1 1001.80c 1.0 19 | """) 20 | model = mcnp_str_to_model(mcnp_str) 21 | cell = model.geometry.get_all_cells()[2] 22 | 23 | # Check various points 24 | assert (0., 0., 0.1) in cell.region 25 | assert (0., 0., -0.1) not in cell.region 26 | assert (0., 0., 1.99) in cell.region 27 | assert (0., 0., 2.01) not in cell.region 28 | assert (1., 1., 1.) in cell.region 29 | assert (2., 0., 1.) not in cell.region 30 | 31 | 32 | def test_likenbut(): 33 | mcnp_str = dedent(""" 34 | title 35 | 1 1 -1.0 -1 36 | 2 LIKE 1 BUT MAT=2 RHO=-2.0 TRCL=(2.0 0.0 0.0) 37 | 38 | 1 so 1.0 39 | 40 | m1 1001.80c 1.0 41 | m2 1002.80c 1.0 42 | """) 43 | model = mcnp_str_to_model(mcnp_str) 44 | cell = model.geometry.get_all_cells()[2] 45 | 46 | # Material should be changed to m2 47 | mat = cell.fill 48 | assert 'H2' in mat.get_nuclide_densities() 49 | 50 | # Density should be 2.0 g/cm3 51 | assert mat.get_mass_density() == approx(2.0) 52 | 53 | # Points should correspond to sphere of r=1 centered at (2, 0, 0) 54 | assert (2.0, 0.0, 0.0) in cell.region 55 | assert (0.0, 0.0, 0.0) not in cell.region 56 | assert (2.0, 0.9, 0.0) in cell.region 57 | assert (2.0, 1.1, 0.0) not in cell.region 58 | 59 | 60 | @mark.parametrize( 61 | "cell_card, surface_cards, points_inside, points_outside", 62 | [ 63 | ( 64 | "1 1 -1.0 -1 TRCL=(2.0 0.0 0.0)", 65 | ("1 so 1.0",), 66 | [ 67 | (2.0, 0.0, 0.0), 68 | (2.0, 0.9, 0.0), 69 | (2.0, 0.0, 0.9), 70 | ], 71 | [ 72 | (0.9, 0.0, 0.0), 73 | (2.0, 1.1, 0.0), 74 | (2.0, 0.0, 1.1), 75 | ], 76 | ), 77 | ( 78 | "1 0 -1 TRCL=(1.0 0.0 0.0 0.0 -1.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0)", 79 | ("1 rpp -0.5 0.5 -0.25 0.25 -0.1 0.1",), 80 | [ 81 | (1.0, 0.0, 0.0), 82 | (1.2, 0.0, 0.0), 83 | (1.0, 0.4, 0.0), 84 | (1.0, -0.4, 0.0), 85 | ], 86 | [ 87 | (0.7, 0.0, 0.0), 88 | (1.3, 0.0, 0.0), 89 | (1.0, 0.6, 0.0), 90 | (1.0, 0.0, 0.2), 91 | ], 92 | ), 93 | ( 94 | "1 0 -1 *TRCL=(1.0 0.0 0.0 90.0 180.0 90.0 0.0 90.0 90.0 90.0 90.0 0.0)", 95 | ("1 rpp -0.5 0.5 -0.25 0.25 -0.1 0.1",), 96 | [ 97 | (1.0, 0.0, 0.0), 98 | (1.2, 0.0, 0.0), 99 | (1.0, 0.4, 0.0), 100 | (1.0, -0.4, 0.0), 101 | ], 102 | [ 103 | (0.7, 0.0, 0.0), 104 | (1.3, 0.0, 0.0), 105 | (1.0, 0.6, 0.0), 106 | (1.0, 0.0, 0.2), 107 | ], 108 | ), 109 | ( 110 | "1 0 -1 TRCL=(0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 -1)", 111 | ("1 rpp -0.5 0.5 -0.25 0.25 -0.1 0.1",), 112 | [ 113 | (-0.4, 0.1, 0.0), 114 | (0.0, 0.0, 0.0), 115 | (0.49, -0.24, -0.05), 116 | ], 117 | [ 118 | (-0.6, 0.0, 0.0), 119 | (0.0, 0.5, 0.0), 120 | (0.0, 0.0, 0.2), 121 | ], 122 | ), 123 | ], 124 | ) 125 | def test_trcl(cell_card, surface_cards, points_inside, points_outside): 126 | surface_block = "\n".join(surface_cards) 127 | mcnp_str = dedent(f""" 128 | title 129 | {cell_card} 130 | 131 | {surface_block} 132 | 133 | m1 1001.80c 1.0 134 | """) 135 | model = mcnp_str_to_model(mcnp_str) 136 | cell = model.geometry.get_all_cells()[1] 137 | 138 | for point in points_inside: 139 | assert point in cell.region 140 | 141 | for point in points_outside: 142 | assert point not in cell.region 143 | 144 | 145 | def test_trcl_macrobody(): 146 | mcnp_str = dedent(""" 147 | title 148 | 1 0 -1 trcl=(2.0 0.0 0.0) 149 | 150 | 1 rpp -1.0 1.0 -1.0 1.0 -1.0 1.0 151 | 152 | m1 1001.80c 1.0 153 | """) 154 | model = mcnp_str_to_model(mcnp_str) 155 | cell = model.geometry.get_all_cells()[1] 156 | assert (1.5, 0., 0.) in cell.region 157 | assert (0., 0., 0.) not in cell.region 158 | 159 | 160 | @mark.parametrize( 161 | "keywords", 162 | [ 163 | "FILL=10 TRCL=(2.0 0.0 0.0)", 164 | "FILL=10(2.0 0.0 0.0)", 165 | "FILL=10 TRCL=(2.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0)", 166 | "FILL=10(2.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0)", 167 | "FILL=10 *TRCL=(2.0 0.0 0.0 0.0 90.0 90.0 90.0 0.0 90.0 90.0 90.0 0.0)", 168 | "*FILL=10(2.0 0.0 0.0 0.0 90.0 90.0 90.0 0.0 90.0 90.0 90.0 0.0)", 169 | "FILL=10 TRCL=(2.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 1)", 170 | "FILL=10 TRCL=(-2.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 -1)", 171 | "FILL=10(2.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 1)", 172 | "FILL=10(-2.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 -1)", 173 | "FILL=10 *TRCL=(2.0 0.0 0.0 0.0 90.0 90.0 90.0 0.0 90.0 90.0 90.0 0.0 1)", 174 | "FILL=10 *TRCL=(-2.0 0.0 0.0 0.0 90.0 90.0 90.0 0.0 90.0 90.0 90.0 0.0 -1)", 175 | "*FILL=10(2.0 0.0 0.0 0.0 90.0 90.0 90.0 0.0 90.0 90.0 90.0 0.0 1)", 176 | "*FILL=10(-2.0 0.0 0.0 0.0 90.0 90.0 90.0 0.0 90.0 90.0 90.0 0.0 -1)", 177 | "FILL=10(1)", 178 | "FILL=10(2)", 179 | "FILL=10(3)", 180 | ] 181 | ) 182 | def test_fill_transformation(keywords): 183 | mcnp_str = dedent(f""" 184 | title 185 | 1 0 -1 {keywords} 186 | 2 0 -2 U=10 187 | 3 0 +2 U=10 188 | 189 | 1 so 10.0 190 | 2 so 1.0 191 | 192 | m1 1001.80c 1.0 193 | tr1 2.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0 1 194 | *tr2 -2.0 0.0 0.0 0.0 90.0 90.0 90.0 0.0 90.0 90.0 90.0 0.0 -1 195 | tr3 2.0 0.0 0.0 196 | """) 197 | model = mcnp_str_to_model(mcnp_str) 198 | geometry = model.geometry 199 | cells = geometry.get_all_cells() 200 | 201 | # Make sure that the cells in universe 10 were shifted 202 | assert geometry.find((2.0, 0.0, 0.0))[-1] is cells[2] 203 | assert geometry.find((4.0, 0.0, 0.0))[-1] is cells[3] 204 | assert geometry.find((0.0, 0.0, 0.0))[-1] is cells[3] 205 | 206 | 207 | def test_cell_volume(): 208 | mcnp_str = dedent(""" 209 | title 210 | 1 0 -1 VOL=5.0 211 | 212 | 1 so 1.0 213 | 214 | m1 1001.80c 1.0 215 | """) 216 | model = mcnp_str_to_model(mcnp_str) 217 | cell = model.geometry.get_all_cells()[1] 218 | assert cell.volume == 5.0 219 | -------------------------------------------------------------------------------- /tests/models/tinkertoy.mcnp: -------------------------------------------------------------------------------- 1 | Tinkertoy 2 2 | 1 1 -18.76 -1 2 3 7 -8 u=1 $ HEU Cylinder 3 | 2 2 0.087058 -2 7 u=1 $ Left SS Rod 4 | 3 2 0.087058 -3 7 u=1 $ Right SS Rod 5 | 4 0 1 7 -8 u=1 $ Side Void 6 | 5 0 -7 u=1 $ Bottom Void 7 | 6 0 2 3 8 u=1 $ Top Void 8 | 41 1 -18.76 -1 2 3 7 -8 u=2 $ HEU Cylinder 9 | 42 2 0.087058 -2 u=2 $ Left SS Rod 10 | 43 2 0.087058 -3 u=2 $ Right SS Rod 11 | 44 0 1 7 -8 u=2 $ Side Void 12 | 45 0 2 3 -7 u=2 $ Bottom Void 13 | 46 0 2 3 8 u=2 $ Top Void 14 | 51 1 -18.76 -1 2 3 7 -8 u=3 $ HEU Cylinder 15 | 52 2 0.087058 -2 -8 u=3 $ Left SS Rod 16 | 53 2 0.087058 -3 -8 u=3 $ Right SS Rod 17 | 54 0 1 7 -8 u=3 $ Side Void 18 | 55 0 2 3 -7 u=3 $ Bottom Void 19 | 56 0 8 u=3 $ Top Void 20 | 7 0 -9 6 -15 14 -25 22 u=4 $ 3x3x3 Array 21 | lat=1 fill=-1:1 -1:1 -1:1 22 | 1 2 3 1 2 3 1 2 3 23 | 1 2 3 1 2 3 1 2 3 24 | 1 2 3 1 2 3 1 2 3 25 | 8 0 5 -10 13 -16 19 -28 fill=4 $ Core 26 | 9 0 4 -5 13 -16 20 -21 $ Left Void 27 | 10 0 4 -5 13 -16 23 -24 $ Center Void 28 | 11 0 4 -5 13 -16 26 -27 $ Right Void 29 | 12 4 -0.93 4 -5 13 -16 19 -28 30 | #9 #10 #11 $ Lower Reflector 31 | 13 0 10 -11 13 -16 20 -21 $ Left Void 32 | 14 0 10 -11 13 -16 23 -24 $ Center Void 33 | 15 0 10 -11 13 -16 26 -27 $ Right Void 34 | 16 4 -0.93 10 -11 13 -16 19 -28 35 | #13 #14 #15 $ Upper Reflector 36 | 17 4 -0.93 4 -11 12 -13 18 -29 $ Back Reflector 37 | 18 4 -0.93 4 -11 16 -17 18 -29 $ Front Reflector 38 | 19 4 -0.93 4 -11 13 -16 18 -19 $ Left Reflector 39 | 20 4 -0.93 4 -11 13 -16 28 -29 $ Right Reflector 40 | c Concrete Room 41 | 21 0 31 -32 36 -37 40 -41 (-4:11:-12:17:-18:29) $ Room void 42 | 22 0 31 -32 35 -37 41 -42 $ Room void 43 | 23 3 7.96492e-02 30 -33 34 -38 39 -40 $ floor of room 44 | 24 3 7.96492e-02 30 -33 34 -36 40 -41 $ ledge 45 | 25 3 7.96492e-02 30 -33 34 -35 41 -42 $ wall above ledge (south) 46 | 26 3 7.96492e-02 30 -31 35 -36 41 -42 $ wall above ledge (west) 47 | 27 3 7.96492e-02 32 -33 35 -36 41 -42 $ wall above ledge (east) 48 | 28 3 7.96492e-02 30 -31 36 -37 40 -42 $ west wall 49 | 29 3 7.96492e-02 32 -33 36 -37 40 -42 $ east wall 50 | 30 3 7.96492e-02 30 -33 37 -38 40 -42 $ north wall 51 | 31 3 7.96492e-02 30 -33 34 -38 42 -43 $ ceiling of room 52 | 32 0 -30:33:-34:38:-39:43 53 | 54 | 1 cz 5.742 $ Radius of HEU Cylinder 55 | 2 c/z 0 -4.2735 0.254 $ Radius of Left SS Rod 56 | 3 c/z 0 4.2735 0.254 $ Radius of Right SS Rod 57 | 4 pz -51.8275 $ Bottom of Lower Paraffin Sheet 58 | 5 pz -44.2275 $ Top of Lower Paraffin Sheet 59 | 6 pz -14.7425 $ Bottom of Cell 60 | 7 pz -5.3825 $ Bottom of HEU Cylinder 61 | 8 pz 5.3825 $ Top of HEU Cylinder 62 | 9 pz 14.7425 $ Top of Cell 63 | 10 pz 44.2275 $ Bottom of Upper Paraffin Sheet 64 | 11 pz 51.8275 $ Top of Upper Paraffin Sheet 65 | 12 py -52.906 $ Front Edge of Front Paraffin Sheet 66 | 13 py -45.306 $ Back Edge of Front Paraffin Sheet 67 | 14 py -15.102 $ Front Edge of Cell 68 | 15 py 15.102 $ Back Edge of Cell 69 | 16 py 45.306 $ Front Edge of Back Paraffin Sheet 70 | 17 py 52.906 $ Back Edge of Back Paraffin Sheet 71 | 18 px -52.906 $ Left Edge of Left Paraffin Sheet 72 | 19 px -45.306 $ Right Edge of Left Paraffin Sheet 73 | 20 px -30.458 $ Left Edge of Left Paraffin Gap 74 | 21 px -29.950 $ Right Edge of Left Paraffin Gap 75 | 22 px -15.102 $ Left Edge of Cell 76 | 23 px -0.254 $ Left Edge of Center Paraffin Gap 77 | 24 px 0.254 $ Right Edge of Center Paraffin Gap 78 | 25 px 15.102 $ Right Edge of Cell 79 | 26 px 29.950 $ Left Edge of Right Paraffin Gap 80 | 27 px 30.458 $ Right Edge of Right Paraffin Ggap 81 | 28 px 45.306 $ Left Edge of Right Paraffin Sheet 82 | 29 px 52.906 $ Right Edge of Right Paraffin Sheet 83 | c Surfaces for room 84 | 30 px -496.57 $ outside surface of concrete room 85 | 31 px -344.17 $ inside surface of concrete room 86 | 32 px 554.99 $ inside surface of concrete room 87 | 33 px 600.71 $ outside surface of concrete room 88 | 34 py -624.84 $ outside surface of concrete room 89 | 35 py -563.88 $ inside surface of concrete room above 12 ft level 90 | 36 py -502.92 $ inside surface of concrete room below 12 ft level 91 | 37 py 428.96 $ inside surface of concrete room 92 | 38 py 581.36 $ outside surface of concrete room 93 | c Heights in room -- these vary for each case 94 | 39 pz -265.3725 $ bottom of room 95 | 40 pz -173.9325 $ floor of room 96 | 41 pz 191.8275 $ 12 ft high ledge in room 97 | 42 pz 905.5675 $ ceiling of room 98 | 43 pz 936.0475 $ top of room 99 | 100 | c HEU 101 | m1 92234.80c 4.8271e-04 102 | 92235.80c 4.4797e-02 103 | 92236.80c 9.5723e-05 104 | 92238.80c 2.6577e-03 105 | c Stainless steel 106 | m2 6000.80c 3.1691e-04 107 | 25055.80c 1.7321e-03 108 | 14028.80c 1.5624e-03 109 | 14029.80c 7.9333e-05 110 | 14030.80c 5.2296e-05 111 | 24050.80c 7.1571e-04 112 | 24052.80c 1.3802e-02 113 | 24053.80c 1.5650e-03 114 | 24054.80c 3.8956e-04 115 | 26054.80c 3.5280e-03 116 | 26056.80c 5.5383e-02 117 | 26057.80c 1.2790e-03 118 | 26058.80c 1.7022e-04 119 | 28058.80c 4.4137e-03 120 | 28060.80c 1.7001e-03 121 | 28061.80c 7.3904e-05 122 | 28062.80c 2.3564e-04 123 | 28064.80c 6.0010e-05 124 | c Concrete 125 | m3 1001.80c 1.4866e-02 126 | 1002.80c 1.7098e-06 127 | 6000.80c 3.8144e-03 128 | 8016.80c 4.1503e-02 129 | 8017.80c 1.5777e-05 130 | 11023.80c 3.0400e-04 131 | 12024.80c 4.6367e-04 132 | 12025.80c 5.8700e-05 133 | 12026.80c 6.4629e-05 134 | 13027.80c 7.3500e-04 135 | 14028.80c 5.5679e-03 136 | 14029.80c 2.8272e-04 137 | 14030.80c 1.8637e-04 138 | 20040.80c 1.1234e-02 139 | 20042.80c 7.4974e-05 140 | 20043.80c 1.5644e-05 141 | 20044.80c 2.4173e-04 142 | 20046.80c 4.6352e-07 143 | 20048.80c 2.1670e-05 144 | 26054.80c 1.1503e-05 145 | 26056.80c 1.8057e-04 146 | 26057.80c 4.1702e-06 147 | 26058.80c 5.5498e-07 148 | c Paraffin 149 | m4 1001.80c 8.2565e-02 150 | 1002.80c 9.4960e-06 151 | 6000.80c 3.9699e-02 152 | mt4 poly.20t 153 | kcode 10000 1.0 20 3000 154 | ksrc 0 0 0 155 | imp:n 1 42r 0 156 | -------------------------------------------------------------------------------- /src/openmc_mcnp_adapter/parse.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-2025 UChicago Argonne, LLC and contributors 2 | # SPDX-License-Identifier: MIT 3 | 4 | from collections import defaultdict 5 | from copy import deepcopy 6 | from math import pi 7 | import re 8 | from pathlib import Path 9 | 10 | import numpy as np 11 | 12 | # Regular expressions for cell parameters 13 | _KEYWORDS = [ 14 | r'\*?trcl', r'\*?fill', 'tmp', 'u', 'lat', 15 | 'imp:(?:.(?:,.)*)', 'vol', 'pwt', 'ext:.', 'fcl', 'wwn', 'dxc', 'nonu', 'pd', 16 | 'elpt', 'cosy', 'bflcl', 'unc', 'mat', 'rho', 17 | 'pmt' # D1SUNED-specific 18 | ] 19 | _ANY_KEYWORD = '|'.join(f'(?:{k})' for k in _KEYWORDS) 20 | _CELL_PARAMETERS_RE = re.compile(rf""" 21 | ((?:{_ANY_KEYWORD})) # Keyword 22 | \s*=?\s* # = sign (optional) 23 | (.*? # Value 24 | (?={_ANY_KEYWORD}|\Z)) # Followed by another keyword or end-of-string 25 | """, re.VERBOSE 26 | ) 27 | 28 | _READ_RE = re.compile(r""" 29 | ^ # Beginning of line 30 | \s*read # Keyword 31 | \s.*?file # Everything up to filename 32 | \s*=\s* # = sign (required) with optional spaces 33 | (\S+) # The file name is anything without whitespace 34 | .* # Anything else until end-of-line 35 | """, re.IGNORECASE | re.VERBOSE | re.MULTILINE 36 | ) 37 | 38 | _CELL1_RE = re.compile(r'\s*(\d+)\s+(\d+)([ \t0-9:#().dDeE\+-]+)\s*(.*)') 39 | _CELL2_RE = re.compile(r'\s*(\d+)\s+like\s+(\d+)\s+but\s*(.*)') 40 | _CELL_FILL_RE = re.compile(r'\s*(\d+)\s*(?:\((.*)\))?') 41 | _SURFACE_RE = re.compile(r'\s*([*+]?\d+)(\s*[-0-9]+)?\s+(\S+)((?:\s+\S+)+)') 42 | _MATERIAL_RE = re.compile(r'\s*[Mm](\d+)((?:\s+\S+)+)') 43 | _TR_RE = re.compile(r'\s*(\*)?[Tt][Rr](\d+)\s+(.*)') 44 | _SAB_RE = re.compile(r'\s*[Mm][Tt](\d+)((?:\s+\S+)+)') 45 | _MODE_RE = re.compile(r'\s*mode(?:\s+\S+)*') 46 | _COMPLEMENT_RE = re.compile(r'(#)[ ]*(\d+)') 47 | _NUM_RE = re.compile(r'(\d)([+-])(\d)') 48 | 49 | _HAS_REPEAT_RE = re.compile(r'\b\d+[rR]\b') 50 | 51 | _REPEAT_RE = re.compile(r""" 52 | (?P # The numeric value to be repeated 53 | [+-]? # Optional sign 54 | (?: # Mantissa 55 | \d+(?:\.\d*)? # Digits with optional fractional part (e.g., 3 or 3. or 3.0) 56 | | # or 57 | \.\d+ # Leading-dot form (e.g., .25) 58 | ) 59 | (?: # Optional exponent 60 | [eEdD][+-]?\d+ # E/D exponent with optional sign (e.g., 1e-3, 2D+3) 61 | | # or MCNP "bare" exponent without E/D 62 | [+-]\d+ # appended sign+digits (e.g., 1.0-3 -> 1.0e-3) 63 | )? 64 | ) 65 | \s+ # One or more spaces between value and count 66 | (?P\d+) # The repeat count 67 | [rR] # The 'R' or 'r' suffix 68 | """, re.VERBOSE) 69 | 70 | 71 | def float_(val): 72 | """Convert scientific notation literals that don't have an 'e' in them to float""" 73 | return float(_NUM_RE.sub(r'\1e\2\3', val)) 74 | 75 | 76 | def cell_parameters(s): 77 | """Return dictionary of cell parameters 78 | 79 | Parameters 80 | ---------- 81 | s : str 82 | Portion of cell card representing parameters 83 | 84 | Returns 85 | ------- 86 | dict 87 | Dictionary mapping keywords to values 88 | 89 | """ 90 | return {key: value.strip() for key, value in _CELL_PARAMETERS_RE.findall(s)} 91 | 92 | 93 | def parse_cell(line): 94 | """Parse cell card into dictionary of information 95 | 96 | Parameters 97 | ---------- 98 | line : str 99 | Single MCNP cell card 100 | 101 | Returns 102 | ------- 103 | dict 104 | Dictionary with cell information 105 | 106 | """ 107 | if 'like' in line.lower(): 108 | # Handle LIKE n BUT form 109 | m = _CELL2_RE.match(line.lower()) 110 | if m is None: 111 | raise ValueError(f"Could not parse cell card: {line}") 112 | g = m.groups() 113 | return { 114 | 'id': int(g[0]), 115 | 'like': int(g[1]), 116 | 'parameters': cell_parameters(g[2]) 117 | } 118 | 119 | else: 120 | # Handle normal form 121 | m = _CELL1_RE.match(line.lower()) 122 | if m is None: 123 | raise ValueError(f"Could not parse cell card: {line}") 124 | 125 | g = m.groups() 126 | if g[1] == '0': 127 | density = None 128 | region = g[2].strip() 129 | else: 130 | words = g[2].split() 131 | # MCNP allows the density and the start of the geometry 132 | # specification to appear without a space inbetween if the geometry 133 | # starts with '('. If this happens, move the end of the first word 134 | # onto the second word to ensure the density is by itself 135 | if (pos := words[0].find('(')) >= 0: 136 | words[1] = words[0][pos:] + words[1] 137 | words[0] = words[0][:pos] 138 | density = float_(words[0]) 139 | region = ' '.join(words[1:]) 140 | return { 141 | 'id': int(g[0]), 142 | 'material': int(g[1]), 143 | 'density': density, 144 | 'region': region, 145 | 'parameters': cell_parameters(g[3]) 146 | } 147 | 148 | 149 | def resolve_likenbut(cells): 150 | """Resolve LIKE n BUT by copying attributes from like cell 151 | 152 | Parameters 153 | ---------- 154 | cells : list of dict 155 | Cell information for each cell 156 | 157 | """ 158 | # Create dictionary mapping ID to cell dict 159 | cell_by_id = {c['id']: c for c in cells} 160 | for cell in cells: 161 | if 'like' in cell: 162 | cell_id = cell['id'] 163 | like_cell_id = cell['like'] 164 | params = cell['parameters'] 165 | 166 | # Clear dictionary and copy information from like cell 167 | cell.clear() 168 | cell.update(deepcopy(cell_by_id[like_cell_id])) 169 | 170 | # Update ID and specified parameters 171 | cell['id'] = cell_id 172 | for key, value in params.items(): 173 | if key == 'mat': 174 | cell['material'] = int(value) 175 | elif key == 'rho': 176 | cell['density'] = float_(value) 177 | else: 178 | cell['parameters'][key] = value 179 | 180 | 181 | def parse_surface(line): 182 | """Parse surface card into dictionary of information 183 | 184 | Parameters 185 | ---------- 186 | line : str 187 | Single MCNP surface card 188 | 189 | Returns 190 | ------- 191 | dict 192 | Dictionary with surface information 193 | 194 | """ 195 | m = _SURFACE_RE.match(line) 196 | if m is None: 197 | raise ValueError("Unable to convert surface card: {}".format(line)) 198 | 199 | g = m.groups() 200 | surface = {} 201 | if '*' in g[0]: 202 | surface['boundary'] = 'reflective' 203 | uid = int(g[0][1:]) 204 | elif '+' in g[0]: 205 | surface['boundary'] = 'white' 206 | uid = int(g[0][1:]) 207 | else: 208 | uid = int(g[0]) 209 | surface.update({ 210 | 'id': uid, 211 | 'mnemonic': g[2].lower(), 212 | 'coefficients': [float_(x) for x in g[3].split()] 213 | }) 214 | if g[1] is not None: 215 | if int(g[1]) < 0: 216 | surface['boundary'] = 'periodic' 217 | surface['periodic_surface'] = abs(int(g[1])) 218 | else: 219 | surface['tr'] = int(g[1]) 220 | return surface 221 | 222 | 223 | def parse_data(section): 224 | """Parse data block into dictionary of information 225 | 226 | Parameters 227 | ---------- 228 | line : str 229 | MCNP data block 230 | 231 | Returns 232 | ------- 233 | dict 234 | Dictionary with data-block information 235 | 236 | """ 237 | 238 | data = {'materials': defaultdict(dict), 'tr': {}} 239 | 240 | lines = section.split('\n') 241 | for line in lines: 242 | if _MATERIAL_RE.match(line): 243 | g = _MATERIAL_RE.match(line).groups() 244 | spec_text = g[1] 245 | 246 | # Extract keywords (key=value) and remove them from spec 247 | keyword_pattern = re.compile(r'(\S+)\s*=\s*(\S+)') 248 | keywords = {} 249 | def extract_keyword(match): 250 | keywords[match.group(1).lower()] = match.group(2) 251 | return '' 252 | spec_text = keyword_pattern.sub(extract_keyword, spec_text) 253 | 254 | # Parse remaining nuclide-density pairs 255 | spec = spec_text.split() 256 | try: 257 | nuclides = list(zip(spec[::2], map(float_, spec[1::2]))) 258 | except Exception: 259 | raise ValueError('Invalid material specification?') 260 | 261 | uid = int(g[0]) 262 | material_data = {'id': uid, 'nuclides': nuclides} 263 | if keywords: 264 | material_data['keywords'] = keywords 265 | data['materials'][uid].update(material_data) 266 | elif _SAB_RE.match(line): 267 | g = _SAB_RE.match(line).groups() 268 | uid = int(g[0]) 269 | spec = g[1].split() 270 | data['materials'][uid]['sab'] = spec 271 | elif line.lower().startswith('mode'): 272 | words = line.split() 273 | data['mode'] = words[1:] 274 | elif line.lower().startswith('kcode'): 275 | words = line.split() 276 | data['kcode'] = {'n_particles': int(words[1]), 277 | 'initial_k': float_(words[2]), 278 | 'inactive': int(words[3]), 279 | 'batches': int(words[4])} 280 | elif _TR_RE.match(line): 281 | g = _TR_RE.match(line).groups() 282 | use_degrees = g[0] is not None 283 | tr_num = int(g[1]) 284 | values = g[2].split() 285 | if len(values) >= 3: 286 | displacement = np.array([float(x) for x in values[:3]]) 287 | if len(values) >= 12: 288 | if len(values) == 13: 289 | if int(values[12]) == -1: 290 | displacement *= -1 291 | rotation = np.array([float(x) for x in values[3:12]]).reshape((3,3)).T 292 | if use_degrees: 293 | rotation = np.cos(rotation * pi/180.0) 294 | else: 295 | rotation = None 296 | data['tr'][tr_num] = (displacement, rotation) 297 | else: 298 | words = line.split() 299 | if words: 300 | data[words[0]] = words[1:] 301 | 302 | return data 303 | 304 | 305 | def expand_read_cards(filename) -> str: 306 | """Recursively read the MCNP input file and files referenced by READ cards 307 | 308 | READ card keywords other than FILE are ignored. 309 | 310 | Parameters 311 | ---------- 312 | filename : str 313 | Path to MCNP file 314 | 315 | Returns 316 | ------- 317 | str 318 | Text of the MCNP input file 319 | 320 | """ 321 | path = Path(filename).resolve() 322 | text = path.read_text() 323 | for match in _READ_RE.finditer(text): 324 | card = match[0].strip() 325 | # If the requested path is absolute, use it directly 326 | requested = Path(match[1]) 327 | target = requested if requested.is_absolute() else path.parent / requested 328 | if not target.is_file(): 329 | errstr = f"In card {repr(card)}, failed to find: {target}" 330 | raise FileNotFoundError(errstr) 331 | subtext = expand_read_cards(target) 332 | text = text.replace(card, subtext) 333 | return text 334 | 335 | 336 | def split_mcnp(text): 337 | """Split MCNP file into three strings, one for each block 338 | 339 | Parameters 340 | ---------- 341 | text : str 342 | Text of the MCNP input file 343 | 344 | Returns 345 | ------- 346 | list of str 347 | List containing one string for each block 348 | 349 | """ 350 | # Find beginning of cell section 351 | m = re.search(r'^[ \t]*(\d+)[ \t]+', text, flags=re.MULTILINE) 352 | text = text[m.start():] 353 | return re.split('\n[ \t]*\n', text) 354 | 355 | 356 | def sanitize(section: str) -> str: 357 | """Sanitize one section of an MCNP input 358 | 359 | This function will remove comments, join continuation lines into a single 360 | line, and expand repeated numbers explicitly. 361 | 362 | Parameters 363 | ---------- 364 | section : str 365 | String representing one section of an MCNP input 366 | 367 | Returns 368 | ------- 369 | str 370 | Sanitized input section 371 | 372 | """ 373 | 374 | # Replace tab characters 375 | section = section.expandtabs() 376 | 377 | # Remove end-of-line comments 378 | section = re.sub(r'\$.*$', '', section, flags=re.MULTILINE) 379 | 380 | # Remove comment cards: 'c' in first 5 columns followed by at least one 381 | # blank, or 'c' as the only character on the line 382 | section = re.sub(r'^[ \t]{0,4}[cC](?:[ \t]+.*)?$\n?', '', section, flags=re.MULTILINE) 383 | 384 | # Turn continuation lines into single line 385 | section = re.sub('&.*\n', ' ', section) 386 | section = re.sub('\n {5}', ' ', section) 387 | 388 | # Expand repeated numbers 389 | if _HAS_REPEAT_RE.search(section): 390 | section = _REPEAT_RE.sub( 391 | lambda m: ' '.join([m.group('value')] * (int(m.group('count')) + 1)), 392 | section, 393 | ) 394 | 395 | return section 396 | 397 | 398 | def parse(filename): 399 | """Parse an MCNP file and return information from three main blocks 400 | 401 | Parameters 402 | ---------- 403 | filename : str 404 | Path to MCNP file 405 | 406 | Returns 407 | ------- 408 | cells : list 409 | List of dictionaries, where each dictionary contains information for one cell 410 | surfaces : list 411 | List of dictionaries, where each dictionary contains information for one surface 412 | data : dict 413 | Dictionary containing data-block information, including materials 414 | 415 | """ 416 | # Read the text of the file and any referenced files into memory 417 | text = expand_read_cards(filename) 418 | 419 | # Split file into main three sections (cells, surfaces, data) 420 | sections = split_mcnp(text) 421 | 422 | # Sanitize lines (remove comments, continuation lines, etc.) 423 | cell_section = sanitize(sections[0]) 424 | surface_section = sanitize(sections[1]) 425 | data_section = sanitize(sections[2]) 426 | 427 | cells = [parse_cell(x) for x in cell_section.strip().split('\n')] 428 | surfaces = [parse_surface(x) for x in surface_section.strip().split('\n')] 429 | data = parse_data(data_section) 430 | 431 | # Replace LIKE n BUT with actual parameters 432 | resolve_likenbut(cells) 433 | 434 | return cells, surfaces, data 435 | -------------------------------------------------------------------------------- /tests/test_surfaces.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from textwrap import dedent 3 | 4 | import numpy as np 5 | import openmc 6 | from openmc.model.surface_composite import OrthogonalBox, \ 7 | RectangularParallelepiped, RightCircularCylinder, ConicalFrustum, \ 8 | XConeOneSided, YConeOneSided, ZConeOneSided 9 | from openmc_mcnp_adapter import mcnp_str_to_model, get_openmc_surfaces 10 | from pytest import approx, mark, raises 11 | 12 | 13 | def convert_surface(mnemonic: str, params: Sequence[float]) -> openmc.Surface: 14 | """Return an OpenMC surface converted from an MCNP surface 15 | 16 | Parameters 17 | ---------- 18 | mnemonic 19 | MCNP surface mnemonic (e.g., "CX") 20 | params 21 | Parameters for the surface 22 | 23 | Returns 24 | ------- 25 | Converted surface 26 | 27 | """ 28 | surface = {'id': 1, 'mnemonic': mnemonic, 'coefficients': params, 'reflective': False} 29 | surfaces = get_openmc_surfaces([surface], {}) 30 | return surfaces[1] 31 | 32 | 33 | @mark.parametrize( 34 | "prefix, boundary_type", 35 | [ 36 | ("*", "reflective"), 37 | ("+", "white"), 38 | ] 39 | ) 40 | def test_boundary_conditions(prefix, boundary_type): 41 | mcnp_str = dedent(f""" 42 | title 43 | 1 1 1.0 -1 44 | 45 | {prefix}1 so 2.0 46 | 47 | m1 1001.80c 1.0 48 | """) 49 | model = mcnp_str_to_model(mcnp_str) 50 | surf = model.geometry.get_all_surfaces()[1] 51 | assert surf.boundary_type == boundary_type 52 | 53 | 54 | def test_boundary_periodic(): 55 | mcnp_str = dedent(""" 56 | title 57 | 1 1 1.0 1 -2 imp:n=1 58 | 2 0 -1:2 imp:n=0 59 | 60 | 1 -2 pz -10.0 61 | 2 -1 pz 10.0 62 | 63 | m1 1001.80c 1.0 64 | """) 65 | model = mcnp_str_to_model(mcnp_str) 66 | surf1 = model.geometry.get_all_surfaces()[1] 67 | surf2 = model.geometry.get_all_surfaces()[2] 68 | assert surf1.boundary_type == 'periodic' 69 | assert surf1.periodic_surface == surf2 70 | assert surf2.boundary_type == 'periodic' 71 | assert surf2.periodic_surface == surf1 72 | 73 | 74 | @mark.parametrize( 75 | "mnemonic, params, expected_type, attrs", 76 | [ 77 | ("p", (4.0, 7.0, -2.5, 1.0), openmc.Plane, ("a", "b", "c", "d")), 78 | ("px", (5.5,), openmc.XPlane, ("x0",)), 79 | ("py", (-3.0,), openmc.YPlane, ("y0",)), 80 | ("pz", ( 1.2,), openmc.ZPlane, ("z0",)), 81 | ], 82 | ) 83 | def test_planes(mnemonic, params, expected_type, attrs): 84 | surf = convert_surface(mnemonic, params) 85 | assert isinstance(surf, expected_type) 86 | for attr, value in zip(attrs, params): 87 | assert getattr(surf, attr) == approx(value) 88 | 89 | 90 | def test_plane_9points(): 91 | # Points defining plane y = x - 1 92 | coeffs = (1.0, 0.0, 0.0, 93 | 2.0, 1.0, 0.0, 94 | 1.0, 0.0, 1.0) 95 | surf = convert_surface("p", coeffs) 96 | assert isinstance(surf, openmc.Plane) 97 | assert surf.a == approx(1.0) 98 | assert surf.b == approx(-1.0) 99 | assert surf.c == approx(0.0) 100 | assert surf.d == approx(1.0) 101 | 102 | 103 | def test_plane_sense_rule1(): 104 | # In general, origin is required to have negative sense 105 | coeffs = ( 106 | 0., 1., 0., 107 | 1., 1., 0., 108 | 0., 1., 1. 109 | ) 110 | surf = convert_surface("p", coeffs) 111 | assert (0., 0., 0.) in -surf 112 | 113 | 114 | def test_plane_sense_rule2(): 115 | # If plane passes through origin, the point (0, 0, ∞) has positive sense. In 116 | # this case, we use the surface x - z = 0 117 | coeffs = ( 118 | 1., 0., 1., 119 | 0., 0., 0., 120 | 1., 1., 1. 121 | ) 122 | surf = convert_surface("p", coeffs) 123 | assert (0., 0., 1e10) in +surf 124 | 125 | 126 | def test_plane_sense_rule3(): 127 | # If D = C = 0, the point (0, ∞, 0) has positive sense. In this case, we use 128 | # the surface, x - y = 0 129 | coeffs = ( 130 | 0., 0., 0., 131 | 1., 1., 0., 132 | 0., 0., 1. 133 | ) 134 | surf = convert_surface("p", coeffs) 135 | assert (0., 1e10, 0.) in +surf 136 | 137 | 138 | def test_plane_sense_rule4(): 139 | # If D = C = B = 0, the point (∞, 0, 0) has positive sense. In this case, we 140 | # use the surface x = 0 141 | coeffs = ( 142 | 0., 1., 1., 143 | 0., 0., 0., 144 | 0., 0., 1. 145 | ) 146 | surf = convert_surface("p", coeffs) 147 | assert (1e10, 0., 0.) in +surf 148 | 149 | 150 | def test_plane_invalid(): 151 | coeffs = ( 152 | 0., 0., 0., 153 | 0., 0., 1., 154 | 0., 0., 2., 155 | ) 156 | with raises(ValueError): 157 | convert_surface("p", coeffs) 158 | 159 | 160 | def test_surface_transformation_with_tr_card(): 161 | mcnp_str = dedent(""" 162 | title 163 | 1 0 -1 164 | 165 | 1 1 pz 0.0 166 | 167 | tr1 0.0 1.0 1.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 -1.0 0.0 168 | """) 169 | model = mcnp_str_to_model(mcnp_str) 170 | surf = model.geometry.get_all_surfaces()[1] 171 | 172 | # The transformed plane corresponds to -y + 1 = 0, so y > 1 is negative sense 173 | assert (0.0, 1.1, 1.0) in -surf 174 | assert (0.0, 0.9, 1.0) in +surf 175 | 176 | 177 | @mark.parametrize( 178 | "mnemonic, params", 179 | [ 180 | ("so", (2.4,)), 181 | ("s", (1.0, -2.0, 3.0, 4.5)), 182 | ("sph", (1.0, -2.0, 3.0, 4.5)), 183 | ("sx", (2.0, 1.5)), 184 | ("sy", (9.0, 0.5)), 185 | ("sz", (-4.0, 4.0)), 186 | ], 187 | ) 188 | def test_spheres(mnemonic, params): 189 | surf = convert_surface(mnemonic, params) 190 | 191 | assert isinstance(surf, openmc.Sphere) 192 | 193 | if mnemonic == "so": 194 | r = params[0] 195 | assert surf.x0 == 0.0 196 | assert surf.y0 == 0.0 197 | assert surf.z0 == 0.0 198 | elif mnemonic in ("sx", "sy", "sz"): 199 | center, r = params 200 | if mnemonic == "sx": 201 | assert surf.x0 == approx(center) 202 | assert surf.y0 == 0.0 203 | assert surf.z0 == 0.0 204 | elif mnemonic == "sy": 205 | assert surf.x0 == 0.0 206 | assert surf.y0 == approx(center) 207 | assert surf.z0 == 0.0 208 | else: 209 | assert surf.x0 == 0.0 210 | assert surf.y0 == 0.0 211 | assert surf.z0 == approx(center) 212 | else: 213 | x0, y0, z0, r = params 214 | assert surf.x0 == approx(x0) 215 | assert surf.y0 == approx(y0) 216 | assert surf.z0 == approx(z0) 217 | 218 | # Check radius 219 | assert surf.r == approx(r) 220 | 221 | 222 | @mark.parametrize( 223 | "mnemonic, params, expected_type, center_attrs", 224 | [ 225 | ("c/x", ( 1.5, -4.0, 2.2), openmc.XCylinder, ("y0", "z0")), 226 | ("c/y", (-3.3, 0.0, 1.0), openmc.YCylinder, ("x0", "z0")), 227 | ("c/z", ( 2.0, 5.5, 0.8), openmc.ZCylinder, ("x0", "y0")), 228 | ("cx", (2.2,), openmc.XCylinder, ("y0", "z0")), 229 | ("cy", (1.0,), openmc.YCylinder, ("x0", "z0")), 230 | ("cz", (0.8,), openmc.ZCylinder, ("x0", "y0")), 231 | ], 232 | ) 233 | def test_cylinders(mnemonic, params, expected_type, center_attrs): 234 | surf = convert_surface(mnemonic, params) 235 | 236 | assert isinstance(surf, expected_type) 237 | 238 | # center coordinates 239 | if mnemonic in ("cx", "cy", "cz"): 240 | assert surf.x0 == 0.0 241 | assert surf.y0 == 0.0 242 | assert surf.z0 == 0.0 243 | else: 244 | for attr, val in zip(center_attrs, params[:-1]): 245 | assert getattr(surf, attr) == approx(val) 246 | 247 | # radius (last parameter) 248 | assert surf.r == approx(params[-1]) 249 | 250 | 251 | @mark.parametrize( 252 | "mnemonic, params, expected_type, attrs", 253 | [ 254 | # parallel to axis 255 | ("k/x", (0.0, -1.5, 2.0, 1.3), openmc.XCone, ("x0", "y0", "z0", "r2")), 256 | ("k/y", (3.0, 1.0, 0.0, 0.8), openmc.YCone, ("x0", "y0", "z0", "r2")), 257 | ("k/z", (-2.0, 0.5, 4.1, 2.2), openmc.ZCone, ("x0", "y0", "z0", "r2")), 258 | 259 | # on axis 260 | ("kx", (-4.1, 2.5), openmc.XCone, ("x0", "r2")), 261 | ("ky", (3.2, 1.1), openmc.YCone, ("y0", "r2")), 262 | ("kz", (-0.4, 0.7), openmc.ZCone, ("z0", "r2")), 263 | ], 264 | ) 265 | def test_cones_two_sided(mnemonic, params, expected_type, attrs): 266 | surf = convert_surface(mnemonic, params) 267 | assert isinstance(surf, expected_type) 268 | for attr, val in zip(attrs, params): 269 | assert getattr(surf, attr) == approx(val) 270 | 271 | if "mnemonic" == "kx": 272 | assert surf.y0 == 0.0 273 | assert surf.z0 == 0.0 274 | elif "mnemonic" == "ky": 275 | assert surf.x0 == 0.0 276 | assert surf.z0 == 0.0 277 | elif "mnemonic" == "kz": 278 | assert surf.x0 == 0.0 279 | assert surf.y0 == 0.0 280 | 281 | 282 | def test_ellipsoid_sq(): 283 | coeffs = (1.0, 2.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.2, 0.0, 0.0) 284 | surf = convert_surface("sq", coeffs) 285 | a, b, c, d, e, f, g, x, y, z = coeffs 286 | assert isinstance(surf, openmc.Quadric) 287 | assert surf.a == approx(a) 288 | assert surf.b == approx(b) 289 | assert surf.c == approx(c) 290 | assert surf.d == surf.e == surf.f == 0.0 291 | assert surf.g == approx(2*(d - a*x)) 292 | assert surf.h == approx(2*(e - b*y)) 293 | assert surf.j == approx(2*(f - c*z)) 294 | assert surf.k == approx(a*x*x + b*y*y + c*z*z + 2*(d*x + e*y + f*z) + g) 295 | 296 | 297 | def test_general_quadric_gq(): 298 | coeffs = (1.0, -0.5, 0.8, 0.0, 1.2, -0.3, 2.0, 0.0, -1.1, 0.6) 299 | surf = convert_surface("gq", coeffs) 300 | 301 | assert isinstance(surf, openmc.Quadric) 302 | 303 | names = ("a", "b", "c", "d", "e", "f", "g", "h", "j", "k") 304 | for name, val in zip(names, coeffs): 305 | assert getattr(surf, name) == approx(val) 306 | 307 | 308 | @mark.parametrize( 309 | "mnemonic, expected_type", 310 | [ 311 | ("tx", openmc.XTorus), 312 | ("ty", openmc.YTorus), 313 | ("tz", openmc.ZTorus), 314 | ], 315 | ) 316 | def test_torus(mnemonic, expected_type): 317 | coeffs = (-2.0, 3.5, 5.0, 1.0, 0.3, 0.2) 318 | surf = convert_surface(mnemonic, coeffs) 319 | assert isinstance(surf, expected_type) 320 | 321 | names = ("x0", "y0", "z0", "a", "b", "c") 322 | for name, val in zip(names, coeffs): 323 | assert getattr(surf, name) == approx(val) 324 | 325 | 326 | @mark.parametrize( 327 | "mnemonic, params, expected_type, attr, value", 328 | [ 329 | ("x", (-4.0, 3.0), openmc.XPlane, "x0", -4.0), 330 | ("x", (1.0, 2.0, 1.0, 3.0), openmc.XPlane, "x0", 1.0), 331 | ("x", (0.0, 1.0, 5.0, 1.0), openmc.XCylinder, "r", 1.0), 332 | ("y", (6.0, 2.0), openmc.YPlane, "y0", 6.0), 333 | ("y", (2.5, 3.0, 2.5, 6.0), openmc.YPlane, "y0", 2.5), 334 | ("y", (0.0, 0.8, -4.0, 0.8), openmc.YCylinder, "r", 0.8), 335 | ("z", (0.0, 1.0), openmc.ZPlane, "z0", 0.0), 336 | ("z", (-3.0, 4.4, -3.0, 7.7), openmc.ZPlane, "z0", -3.0), 337 | ("z", (0.0, 2.2, 9.0, 2.2), openmc.ZCylinder, "r", 2.2), 338 | ], 339 | ) 340 | def test_axisymmetric_surfaces(mnemonic, params, expected_type, attr, value): 341 | """Test conversion of X/Y/Z surfaces""" 342 | surf = convert_surface(mnemonic, params) 343 | assert isinstance(surf, expected_type) 344 | assert getattr(surf, attr) == approx(value) 345 | 346 | 347 | @mark.parametrize("mnemonic", ["x", "y", "z"]) 348 | def test_axisymmetric_surfaces_cone(mnemonic): 349 | # cone with a r=1 bottom at plane=0 and a r=2 top at plane=3 350 | coeffs = (0.0, 1.0, 3.0, 2.0) 351 | surf = convert_surface(mnemonic, coeffs) 352 | 353 | # Helper to build a point (x,y,z) given radial distance r and axial coord a 354 | def pt(r: float, a: float): 355 | if mnemonic == "x": 356 | return (a, r, 0.0) # axial along x; radius in y 357 | elif mnemonic == "y": 358 | return (r, a, 0.0) # axial along y; radius in x 359 | else: # "z" 360 | return (r, 0.0, a) # axial along z; radius in x 361 | 362 | # Points near the r=1 slice (at axial ~ 0) 363 | assert pt(0.0, 0.01) in -surf 364 | assert pt(0.0, -0.01) in -surf 365 | assert pt(0.99, 0.01) in -surf 366 | assert pt(1.05, 0.01) in +surf 367 | 368 | # Points near the r=2 slice (at axial ~ 3) 369 | assert pt(1.99, 2.99) in -surf 370 | assert pt(2.0, 2.99) in +surf 371 | assert pt(0.0, 2.99) in -surf 372 | assert pt(0.0, 3.01) in -surf 373 | 374 | # Points between the r=1 and r=2 slices (at axial = 1.5) 375 | assert pt(1.49, 1.5) in -surf 376 | assert pt(1.51, 1.5) in +surf 377 | 378 | 379 | @mark.parametrize( 380 | "mnemonic, params, expected_type, up_expected", 381 | [ 382 | # k/x with one-sided flag: +1 -> up=True, -1 -> up=False 383 | ("k/x", (0.0, 0.0, 0.0, 1.0, 1.0), XConeOneSided, True), 384 | ("k/x", (0.0, 0.0, 0.0, 2.5, -1.0), XConeOneSided, False), 385 | ("k/y", (1.0, -2.0, 3.0, 0.5, 1.0), YConeOneSided, True), 386 | ("k/z", (-1.0, 2.0, -3.0, 4.0, -2.0), ZConeOneSided, False), 387 | ], 388 | ) 389 | def test_cones_one_sided(mnemonic, params, expected_type, up_expected): 390 | surf = convert_surface(mnemonic, params) 391 | assert isinstance(surf, expected_type) 392 | assert getattr(surf, "up") is up_expected 393 | # Access nested cone attributes for center and r2 394 | assert surf.cone.x0 == approx(params[0]) 395 | assert surf.cone.y0 == approx(params[1]) 396 | assert surf.cone.z0 == approx(params[2]) 397 | assert surf.cone.r2 == approx(params[3]) 398 | 399 | 400 | @mark.parametrize( 401 | "mnemonic, params, expected_type, up_expected", 402 | [ 403 | ("kx", (0.5, 2.0, 1.0), XConeOneSided, True), 404 | ("kx", (0.5, 2.0, -1.0), XConeOneSided, False), 405 | ("ky", (-0.3, 1.2, 2.0), YConeOneSided, True), 406 | ("ky", (-0.3, 1.2, -2.0), YConeOneSided, False), 407 | ("kz", (2.2, 0.7, 3.0), ZConeOneSided, True), 408 | ("kz", (2.2, 0.7, -3.0), ZConeOneSided, False), 409 | ], 410 | ) 411 | def test_cones_one_sided_short(mnemonic, params, expected_type, up_expected): 412 | surf = convert_surface(mnemonic, params) 413 | assert isinstance(surf, expected_type) 414 | assert getattr(surf, "up") is up_expected 415 | 416 | # Check plane position coincides with axis center value 417 | axis = mnemonic[1] # 'x', 'y', or 'z' 418 | assert getattr(surf.plane, f"{axis}0") == approx(params[0]) 419 | 420 | # Check cone radius-squared parameter 421 | assert surf.cone.r2 == approx(params[1]) 422 | 423 | 424 | def test_box_macrobody(): 425 | coeffs = (0.0, 0.0, 0.0, 426 | 1.0, 0.0, 0.0, 427 | 0.0, 2.0, 0.0, 428 | 0.0, 0.0, 3.0) 429 | surf = convert_surface("box", coeffs) 430 | assert isinstance(surf, OrthogonalBox) 431 | # Plane position along an axis is d / coefficient 432 | assert surf.ax1_min.d / surf.ax1_min.a == approx(0.0) 433 | assert surf.ax1_max.d / surf.ax1_max.a == approx(1.0) 434 | assert surf.ax2_min.d / surf.ax2_min.b == approx(0.0) 435 | assert surf.ax2_max.d / surf.ax2_max.b == approx(2.0) 436 | assert surf.ax3_min.d / surf.ax3_min.c == approx(0.0) 437 | assert surf.ax3_max.d / surf.ax3_max.c == approx(3.0) 438 | 439 | 440 | def test_box_macrobody_inf(): 441 | coeffs = (0.0, 0.0, 0.0, 442 | 1.0, 0.0, 0.0, 443 | 0.0, 2.0, 0.0) 444 | surf = convert_surface("box", coeffs) 445 | assert isinstance(surf, OrthogonalBox) 446 | 447 | # Check a few points; since it is infinite in z, any value in z should work 448 | assert (0.5, 0.5, 0.0) in -surf 449 | assert (0.5, 0.5, -100.0) in -surf 450 | assert (0.5, 0.5, 100.0) in -surf 451 | assert (0.5, -0.01, 0.0) in +surf 452 | assert (1.01, 0.5, 0.0) in +surf 453 | 454 | 455 | def test_rpp_macrobody(): 456 | coeffs = (-1.0, 2.0, -3.0, 4.0, 0.5, 5.5) 457 | surf = convert_surface("rpp", coeffs) 458 | assert isinstance(surf, RectangularParallelepiped) 459 | assert surf.xmin.d / surf.xmin.a == approx(-1.0) 460 | assert surf.xmax.d / surf.xmax.a == approx(2.0) 461 | assert surf.ymin.d / surf.ymin.b == approx(-3.0) 462 | assert surf.ymax.d / surf.ymax.b == approx(4.0) 463 | assert surf.zmin.d / surf.zmin.c == approx(0.5) 464 | assert surf.zmax.d / surf.zmax.c == approx(5.5) 465 | 466 | 467 | @mark.parametrize( 468 | "coeffs, expected_bottom, expected_top, r, coeff", 469 | [ 470 | # Base at (0,0,0), positive/negative height along x 471 | ((0.0, 0.0, 0.0, 5.0, 0.0, 0.0, 1.5), 0.0, 5.0, 1.5, 'a'), 472 | ((0.0, 0.0, 0.0, -5.0, 0.0, 0.0, 1.0), 0.0, -5.0, 1.0, 'a'), 473 | # Base at (0,0,0), positive/negative height along y 474 | ((0.0, 0.0, 0.0, 0.0, 5.0, 0.0, 1.5), 0.0, 5.0, 1.5, 'b'), 475 | ((0.0, 0.0, 0.0, 0.0, -5.0, 0.0, 1.0), 0.0, -5.0, 1.0, 'b'), 476 | # Base at (0,0,0), positive/negative height along z 477 | ((0.0, 0.0, 0.0, 0.0, 0.0, 5.0, 1.5), 0.0, 5.0, 1.5, 'c'), 478 | ((0.0, 0.0, 0.0, 0.0, 0.0, -5.0, 1.0), 0.0, -5.0, 1.0, 'c'), 479 | ], 480 | ) 481 | def test_rcc_macrobody(coeffs, expected_bottom, expected_top, r, coeff): 482 | surf = convert_surface("rcc", coeffs) 483 | assert isinstance(surf, RightCircularCylinder) 484 | assert surf.cyl.r == approx(r) 485 | assert surf.bottom.d / getattr(surf.bottom, coeff) == approx(expected_bottom) 486 | assert surf.top.d / getattr(surf.top, coeff) == approx(expected_top) 487 | 488 | 489 | def test_rcc_macrobody_non_axis_aligned(): 490 | coeffs = (0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.5) 491 | surf = convert_surface("rcc", coeffs) 492 | assert isinstance(surf, RightCircularCylinder) 493 | 494 | # Points along and around the axis defined by the height vector (1, 1, 0) 495 | midpoint = (0.5, 0.5, 0.0) 496 | axis_unit = np.array(coeffs[3:6]) 497 | 498 | inside_point = (midpoint[0], midpoint[1], 0.1) 499 | outside_radial = (midpoint[0], midpoint[1], 0.6) 500 | below_bottom = -0.1 * axis_unit 501 | top = np.array((1.0, 1.0, 0.0)) 502 | above_top = top + 0.1 * axis_unit 503 | 504 | assert inside_point in -surf 505 | assert outside_radial in +surf 506 | assert below_bottom in +surf 507 | assert above_top in +surf 508 | 509 | 510 | def test_trc_macrobody(): 511 | coeffs = (0.0, 0.0, 0.0, 0.0, 0.0, 10.0, 1.0, 2.0) 512 | surf = convert_surface("trc", coeffs) 513 | assert isinstance(surf, ConicalFrustum) 514 | assert surf.plane_bottom.d / surf.plane_bottom.c == approx(0.0) 515 | assert surf.plane_top.d / surf.plane_top.c == approx(10.0) 516 | # Check points near boundary 517 | assert (0.99, 0., 0.01) in -surf 518 | assert (1.01, 0., 0.01) in +surf 519 | assert (1.99, 0., 9.99) in -surf 520 | assert (2.01, 0., 9.99) in +surf 521 | assert (0., 0., -0.01) in +surf 522 | assert (0., 0., 10.01) in +surf 523 | 524 | 525 | def test_rpp_facets(): 526 | mcnp_str = dedent(""" 527 | title 528 | 1 1 -1.0 -1.1 -1.2 529 | 2 1 -1.0 -1.3 -1.4 530 | 3 1 -1.0 -1.5 -1.6 531 | 532 | 1 rpp -1.0 2.0 -3.0 4.0 0.5 5.5 533 | 534 | m1 1001.80c 3.0 535 | """) 536 | model = mcnp_str_to_model(mcnp_str) 537 | cells = model.geometry.get_all_cells() 538 | assert (0., 0., 0.) in cells[1].region 539 | assert (-2.0, 0., 0) not in cells[1].region 540 | assert (2.5, 0., 0.) not in cells[1].region 541 | assert (0., -1.0, 0.) in cells[2].region 542 | assert (0., -4.0, 0.) not in cells[2].region 543 | assert (0., 5.0, 0.) not in cells[2].region 544 | assert (0., 0., 1.0) in cells[3].region 545 | assert (0., 0., -2.0) not in cells[3].region 546 | assert (0., 0., 6.0) not in cells[3].region 547 | 548 | 549 | # Remaining macrobody / complex surfaces not yet implemented in conversion: 550 | # RHP, HEX, REC, ELL, WED, ARB 551 | -------------------------------------------------------------------------------- /src/openmc_mcnp_adapter/openmc_conversion.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-2025 UChicago Argonne, LLC and contributors 2 | # SPDX-License-Identifier: MIT 3 | 4 | import argparse 5 | from math import pi, isclose 6 | import os 7 | import re 8 | import tempfile 9 | import warnings 10 | 11 | import numpy as np 12 | import openmc 13 | from openmc.data import get_thermal_name 14 | from openmc.data.ace import get_metadata 15 | from openmc.model.surface_composite import ( 16 | CompositeSurface, 17 | RightCircularCylinder as RCC, 18 | RectangularParallelepiped as RPP, 19 | OrthogonalBox as BOX, 20 | ConicalFrustum as TRC, 21 | ) 22 | from openmc.model import surface_composite 23 | 24 | from .parse import parse, _COMPLEMENT_RE, _CELL_FILL_RE 25 | 26 | 27 | # The facet number corresponding to the SurfaceComposite's surface by 28 | # attribute name and whether or not to flip the sense of that surface 29 | # based on the facet surface's relationship to the composite surface region 30 | _MACROBODY_FACETS = { 31 | BOX: { 32 | 1: ('ax1_max', False), 33 | 2: ('ax1_min', False), 34 | 3: ('ax2_max', False), 35 | 4: ('ax2_min', False), 36 | 5: ('ax3_max', False), 37 | 6: ('ax3_min', False), 38 | }, 39 | RCC: { 40 | 1: ('cyl', False), 41 | 2: ('top', False), 42 | 3: ('bottom', True) 43 | }, 44 | RPP: { 45 | 1: ('xmax', False), 46 | 2: ('xmin', True), 47 | 3: ('ymax', False), 48 | 4: ('ymin', True), 49 | 5: ('zmax', False), 50 | 6: ('zmin', True) 51 | }, 52 | TRC: { 53 | 1: ('cone', False), 54 | 2: ('plane_top', False), 55 | 3: ('plane_bottom', True), 56 | } 57 | } 58 | 59 | 60 | def rotation_matrix(v1, v2): 61 | """Compute rotation matrix that would rotate v1 into v2. 62 | 63 | Parameters 64 | ---------- 65 | v1 : numpy.ndarray 66 | Unrotated vector 67 | v2 : numpy.ndarray 68 | Rotated vector 69 | 70 | Returns 71 | ------- 72 | 3x3 rotation matrix 73 | 74 | """ 75 | # Normalize vectors and compute cosine 76 | u1 = v1 / np.linalg.norm(v1) 77 | u2 = v2 / np.linalg.norm(v2) 78 | cos_angle = float(np.clip(np.dot(u1, u2), -1.0, 1.0)) 79 | 80 | I = np.identity(3) 81 | 82 | # Handle special case where vectors are parallel or anti-parallel 83 | if isclose(abs(cos_angle), 1.0, rel_tol=1e-8): 84 | if cos_angle > 0.0: 85 | return I 86 | else: 87 | # Proper 180° rotation: rotate about any axis that is orthogonal 88 | # with u1. Because |k| = 1 and cos(180°) = -1, the rotation matrix 89 | # is simply K = I + 2 * (k k^T - I) = 2 k k^T - I 90 | 91 | # Choose reference vector not parallel to u1 92 | ref = np.array([1.0, 0.0, 0.0]) if abs(u1[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) 93 | 94 | # Create orthogonal unit vector 95 | k = np.cross(u1, ref) 96 | k /= np.linalg.norm(k) 97 | 98 | # Create rotation matrix 99 | return 2.0 * np.outer(k, k) - I 100 | else: 101 | # Calculate rotation angle 102 | sin_angle = np.sqrt(1 - cos_angle*cos_angle) 103 | 104 | # Calculate axis of rotation 105 | axis = np.cross(u1, u2) 106 | axis /= np.linalg.norm(axis) 107 | 108 | # Create cross-product matrix K 109 | kx, ky, kz = axis 110 | K = np.array([ 111 | [0.0, -kz, ky], 112 | [kz, 0.0, -kx], 113 | [-ky, kx, 0.0] 114 | ]) 115 | 116 | # Create rotation matrix using Rodrigues' rotation formula 117 | return I + K * sin_angle + (K @ K) * (1 - cos_angle) 118 | 119 | 120 | def get_openmc_materials(materials, expand_elements: bool = True): 121 | """Get OpenMC materials from MCNP materials 122 | 123 | Parameters 124 | ---------- 125 | materials : list 126 | List of MCNP material information 127 | 128 | Returns 129 | ------- 130 | dict 131 | Dictionary mapping material ID to :class:`openmc.Material` 132 | 133 | """ 134 | openmc_materials = {} 135 | for m in materials.values(): 136 | if 'id' not in m: 137 | continue 138 | material = openmc.Material(m['id']) 139 | for nuclide, percent in m['nuclides']: 140 | if '.' in nuclide: 141 | zaid, xs = nuclide.split('.') 142 | else: 143 | zaid = nuclide 144 | name, element, Z, A, metastable = get_metadata(int(zaid), 'mcnp') 145 | if percent < 0: 146 | if (A > 0) or (not expand_elements): 147 | material.add_nuclide(name, abs(percent), 'wo') 148 | else: 149 | material.add_element(element, abs(percent), 'wo') 150 | else: 151 | if (A > 0) or (not expand_elements): 152 | material.add_nuclide(name, percent, 'ao') 153 | else: 154 | material.add_element(element, percent, 'ao') 155 | 156 | if 'sab' in m: 157 | for sab in m['sab']: 158 | if '.' in sab: 159 | name, xs = sab.split('.') 160 | else: 161 | name = sab 162 | material.add_s_alpha_beta(get_thermal_name(name)) 163 | openmc_materials[m['id']] = material 164 | 165 | return openmc_materials 166 | 167 | 168 | def get_openmc_surfaces(surfaces, data): 169 | """Get OpenMC surfaces from MCNP surfaces 170 | 171 | Parameters 172 | ---------- 173 | surfaces : list 174 | List of MCNP surfaces 175 | data : dict 176 | MCNP data-block information 177 | 178 | Returns 179 | ------- 180 | dict 181 | Dictionary mapping surface ID to :class:`openmc.Surface` instance 182 | 183 | """ 184 | # Ensure that autogenerated IDs for surfaces don't conflict 185 | openmc.Surface.next_id = max(s['id'] for s in surfaces) + 1 186 | 187 | openmc_surfaces = {} 188 | for s in surfaces: 189 | coeffs = s['coefficients'] 190 | if s['mnemonic'] == 'p': 191 | if len(coeffs) == 9: 192 | p1 = coeffs[:3] 193 | p2 = coeffs[3:6] 194 | p3 = coeffs[6:] 195 | surf = openmc.Plane.from_points(p1, p2, p3, surface_id=s['id']) 196 | 197 | # Helper function to flip signs on plane coefficients 198 | def flip_sense(surf): 199 | surf.a = -surf.a 200 | surf.b = -surf.b 201 | surf.c = -surf.c 202 | surf.d = -surf.d 203 | 204 | # Enforce MCNP sense requirements 205 | if surf.d != 0.0: 206 | if surf.d < 0.0: 207 | flip_sense(surf) 208 | elif surf.c != 0.0: 209 | if surf.c < 0.0: 210 | flip_sense(surf) 211 | elif surf.b != 0.0: 212 | if surf.b < 0.0: 213 | flip_sense(surf) 214 | elif surf.a != 0.0: 215 | if surf.a < 0.0: 216 | flip_sense(surf) 217 | else: 218 | raise ValueError(f"Plane {s['id']} appears to be a line? ({coeffs})") 219 | else: 220 | A, B, C, D = coeffs 221 | surf = openmc.Plane(surface_id=s['id'], a=A, b=B, c=C, d=D) 222 | elif s['mnemonic'] == 'px': 223 | surf = openmc.XPlane(surface_id=s['id'], x0=coeffs[0]) 224 | elif s['mnemonic'] == 'py': 225 | surf = openmc.YPlane(surface_id=s['id'], y0=coeffs[0]) 226 | elif s['mnemonic'] == 'pz': 227 | surf = openmc.ZPlane(surface_id=s['id'], z0=coeffs[0]) 228 | elif s['mnemonic'] == 'so': 229 | surf = openmc.Sphere(surface_id=s['id'], r=coeffs[0]) 230 | elif s['mnemonic'] in ('s', 'sph'): 231 | x0, y0, z0, R = coeffs 232 | surf = openmc.Sphere(surface_id=s['id'], x0=x0, y0=y0, z0=z0, r=R) 233 | elif s['mnemonic'] == 'sx': 234 | x0, R = coeffs 235 | surf = openmc.Sphere(surface_id=s['id'], x0=x0, r=R) 236 | elif s['mnemonic'] == 'sy': 237 | y0, R = coeffs 238 | surf = openmc.Sphere(surface_id=s['id'], y0=y0, r=R) 239 | elif s['mnemonic'] == 'sz': 240 | z0, R = coeffs 241 | surf = openmc.Sphere(surface_id=s['id'], z0=z0, r=R) 242 | elif s['mnemonic'] == 'c/x': 243 | y0, z0, R = coeffs 244 | surf = openmc.XCylinder(surface_id=s['id'], y0=y0, z0=z0, r=R) 245 | elif s['mnemonic'] == 'c/y': 246 | x0, z0, R = coeffs 247 | surf = openmc.YCylinder(surface_id=s['id'], x0=x0, z0=z0, r=R) 248 | elif s['mnemonic'] == 'c/z': 249 | x0, y0, R = coeffs 250 | surf = openmc.ZCylinder(surface_id=s['id'], x0=x0, y0=y0, r=R) 251 | elif s['mnemonic'] == 'cx': 252 | surf = openmc.XCylinder(surface_id=s['id'], r=coeffs[0]) 253 | elif s['mnemonic'] == 'cy': 254 | surf = openmc.YCylinder(surface_id=s['id'], r=coeffs[0]) 255 | elif s['mnemonic'] == 'cz': 256 | surf = openmc.ZCylinder(surface_id=s['id'], r=coeffs[0]) 257 | elif s['mnemonic'] in ('k/x', 'k/y', 'k/z'): 258 | x0, y0, z0, R2 = coeffs[:4] 259 | if len(coeffs) > 4 and coeffs[4] != 0.0: 260 | up = (coeffs[4] > 0.0) 261 | if s['mnemonic'] == 'k/x': 262 | surf = surface_composite.XConeOneSided(x0=x0, y0=y0, z0=z0, r2=R2, up=up) 263 | elif s['mnemonic'] == 'k/y': 264 | surf = surface_composite.YConeOneSided(x0=x0, y0=y0, z0=z0, r2=R2, up=up) 265 | else: 266 | surf = surface_composite.ZConeOneSided(x0=x0, y0=y0, z0=z0, r2=R2, up=up) 267 | else: 268 | if s['mnemonic'] == 'k/x': 269 | surf = openmc.XCone(surface_id=s['id'], x0=x0, y0=y0, z0=z0, r2=R2) 270 | elif s['mnemonic'] == 'k/y': 271 | surf = openmc.YCone(surface_id=s['id'], x0=x0, y0=y0, z0=z0, r2=R2) 272 | else: 273 | surf = openmc.ZCone(surface_id=s['id'], x0=x0, y0=y0, z0=z0, r2=R2) 274 | elif s['mnemonic'] in ('kx', 'ky', 'kz'): 275 | x, R2 = coeffs[:2] 276 | if len(coeffs) > 2 and coeffs[2] != 0.0: 277 | up = (coeffs[2] > 0.0) 278 | if s['mnemonic'] == 'kx': 279 | surf = surface_composite.XConeOneSided(x0=x, r2=R2, up=up) 280 | elif s['mnemonic'] == 'ky': 281 | surf = surface_composite.YConeOneSided(y0=x, r2=R2, up=up) 282 | else: 283 | surf = surface_composite.ZConeOneSided(z0=x, r2=R2, up=up) 284 | else: 285 | if s['mnemonic'] == 'kx': 286 | surf = openmc.XCone(surface_id=s['id'], x0=x, r2=R2) 287 | elif s['mnemonic'] == 'ky': 288 | surf = openmc.YCone(surface_id=s['id'], y0=x, r2=R2) 289 | else: 290 | surf = openmc.ZCone(surface_id=s['id'], z0=x, r2=R2) 291 | elif s['mnemonic'] == 'sq': 292 | a, b, c, D, E, F, G, x, y, z = coeffs 293 | d = e = f = 0.0 294 | g = 2*(D - a*x) 295 | h = 2*(E - b*y) 296 | j = 2*(F - c*z) 297 | k = a*x*x + b*y*y + c*z*z + 2*(D*x + E*y + F*z) + G 298 | surf = openmc.Quadric(surface_id=s['id'], a=a, b=b, c=c, d=d, e=e, 299 | f=f, g=g, h=h, j=j, k=k) 300 | elif s['mnemonic'] == 'gq': 301 | a, b, c, d, e, f, g, h, j, k = coeffs 302 | surf = openmc.Quadric(surface_id=s['id'], a=a, b=b, c=c, d=d, e=e, 303 | f=f, g=g, h=h, j=j, k=k) 304 | elif s['mnemonic'] == 'tx': 305 | x0, y0, z0, a, b, c = coeffs 306 | surf = openmc.XTorus(surface_id=s['id'], x0=x0, y0=y0, z0=z0, a=a, b=b, c=c) 307 | elif s['mnemonic'] == 'ty': 308 | x0, y0, z0, a, b, c = coeffs 309 | surf = openmc.YTorus(surface_id=s['id'], x0=x0, y0=y0, z0=z0, a=a, b=b, c=c) 310 | elif s['mnemonic'] == 'tz': 311 | x0, y0, z0, a, b, c = coeffs 312 | surf = openmc.ZTorus(surface_id=s['id'], x0=x0, y0=y0, z0=z0, a=a, b=b, c=c) 313 | elif s['mnemonic'] in ('x', 'y', 'z'): 314 | axis = s['mnemonic'].upper() 315 | cls_plane = getattr(openmc, f'{axis}Plane') 316 | cls_cylinder = getattr(openmc, f'{axis}Cylinder') 317 | cls_cone = getattr(surface_composite, f'{axis}ConeOneSided') 318 | if len(coeffs) == 2: 319 | x1, r1 = coeffs 320 | surf = cls_plane(x1, surface_id=s['id']) 321 | elif len(coeffs) == 4: 322 | x1, r1, x2, r2 = coeffs 323 | if x1 == x2: 324 | surf = cls_plane(x1, surface_id=s['id']) 325 | elif r1 == r2: 326 | surf = cls_cylinder(r=r1, surface_id=s['id']) 327 | else: 328 | dr = r2 - r1 329 | dx = x2 - x1 330 | grad = dx/dr 331 | offset = x2 - grad*r2 332 | angle = (-1/grad)**2 333 | 334 | # decide if we want the up or down part of the 335 | # cone since one sheet is used 336 | up = grad >= 0 337 | kwargs = {f"{s['mnemonic']}0": offset, "r2": angle, "up": up} 338 | surf = cls_cone(**kwargs) 339 | else: 340 | raise NotImplementedError(f"{s['mnemonic']} surface with {len(coeffs)} parameters") 341 | elif s['mnemonic'] == 'rcc': 342 | vx, vy, vz, hx, hy, hz, r = coeffs 343 | if hx == 0.0 and hy == 0.0 and hz > 0.0: 344 | surf = RCC((vx, vy, vz), hz, r, axis='z') 345 | elif hy == 0.0 and hz == 0.0 and hx > 0.0: 346 | surf = RCC((vx, vy, vz), hx, r, axis='x') 347 | elif hx == 0.0 and hz == 0.0 and hy > 0.0: 348 | surf = RCC((vx, vy, vz), hy, r, axis='y') 349 | else: 350 | # Create vectors for Z-axis and cylinder orientation 351 | u = np.array([0., 0., 1.]) 352 | h = np.array([hx, hy, hz]) 353 | 354 | # Determine rotation matrix to transform u -> h 355 | rotation = rotation_matrix(u, h) 356 | 357 | # Create RCC aligned with Z-axis 358 | height = np.linalg.norm(h) 359 | surf = RCC((vx, vy, vz), height, r, axis='z') 360 | 361 | # Rotate the RCC 362 | surf = surf.rotate(rotation, pivot=(vx, vy, vz)) 363 | 364 | elif s['mnemonic'] == 'rpp': 365 | surf = RPP(*coeffs) 366 | elif s['mnemonic'] == 'box': 367 | v = coeffs[:3] 368 | a1 = coeffs[3:6] 369 | a2 = coeffs[6:9] 370 | if len(coeffs) == 12: 371 | a3 = coeffs[9:] 372 | surf = BOX(v, a1, a2, a3) 373 | else: 374 | surf = BOX(v, a1, a2) 375 | elif s['mnemonic'] == 'trc': 376 | v = coeffs[:3] 377 | h = coeffs[3:6] 378 | r1 = coeffs[6] 379 | r2 = coeffs[7] 380 | surf = TRC(v, h, r1, r2) 381 | else: 382 | raise NotImplementedError('Surface type "{}" not supported' 383 | .format(s['mnemonic'])) 384 | 385 | # Set boundary conditions 386 | boundary = s.get('boundary') 387 | if boundary == 'reflective': 388 | surf.boundary_type = 'reflective' 389 | elif boundary == 'white': 390 | surf.boundary_type = 'white' 391 | elif boundary == 'periodic': 392 | surf.boundary_type = 'periodic' 393 | 394 | if 'tr' in s: 395 | tr_num = s['tr'] 396 | displacement, rotation = data['tr'][tr_num] 397 | with warnings.catch_warnings(): 398 | warnings.simplefilter("ignore", openmc.IDWarning) 399 | surf = surf.translate(displacement, inplace=True) 400 | if rotation is not None: 401 | surf = surf.rotate(rotation, pivot=displacement, inplace=True) 402 | 403 | openmc_surfaces[s['id']] = surf 404 | 405 | # For macrobodies, we also need to add generated surfaces to dictionary 406 | if isinstance(surf, surface_composite.CompositeSurface): 407 | openmc_surfaces.update((-surf).get_surfaces()) 408 | 409 | # Make another pass to set periodic surfaces 410 | for s in surfaces: 411 | periodic_surface_id = s.get('periodic_surface') 412 | if periodic_surface_id is not None: 413 | surf.periodic_surface = openmc_surfaces[periodic_surface_id] 414 | 415 | return openmc_surfaces 416 | 417 | 418 | def replace_macrobody_facets(region: str, surfaces: dict) -> str: 419 | """Replace macrobody facet identifiers with the corresponding OpenMC surface 420 | 421 | Parameters 422 | ---------- 423 | region : str 424 | Boolean expression relating surface half-spaces. 425 | surfaces : dict 426 | Dictionary mapping surface ID to :class:`openmc.Surface` 427 | 428 | Returns 429 | ------- 430 | str 431 | An updated expression replacing the macrobody facet specification with 432 | the ID of that surface in the SurfaceComposite object. 433 | """ 434 | # Get list of facets, sorted by string length to ensure that, e.g., 435 | # replacing '3.1' will not happen before replacing '23.1' 436 | facets = set(re.findall(r'[-+]?\d+\.\d', region)) 437 | facets = sorted(facets, key=len, reverse=True) 438 | 439 | for facet in facets: 440 | # Break up macrobody facet into surface ID and facet number 441 | surface_id, facet_num = facet.split('.') 442 | surface_id = int(surface_id) 443 | facet_num = int(facet_num) 444 | 445 | # Get corresponding composite surface 446 | composite_surf = surfaces[abs(surface_id)] 447 | if isinstance(composite_surf, CompositeSurface): 448 | # Get composite surface and whether to flip sense 449 | facet_attr, flip_sense = _MACROBODY_FACETS[type(composite_surf)][facet_num] 450 | facet_surface = getattr(composite_surf, facet_attr) 451 | else: 452 | warnings.warn(f'Macrobody facet {facet} ignored (not a macrobody)') 453 | facet_surface = composite_surf 454 | flip_sense = False 455 | 456 | # Get corresponding surface and its ID 457 | facet_id = facet_surface.id 458 | 459 | # starting with a positive facet ID, adjust for: 460 | # a) the specified sense in the original expression 461 | if surface_id < 0: 462 | facet_id = -facet_id 463 | 464 | # b) the sense of the facet with respect to the macrobody 465 | if flip_sense: 466 | facet_id = -facet_id 467 | 468 | # ensure this surface is present in the surfaces dictionary 469 | surfaces[facet_surface.id] = facet_surface 470 | 471 | # re-build the region expression with this entry in place of the macrobody facet entry 472 | region = region.replace(facet, str(facet_id)) 473 | 474 | return region 475 | 476 | 477 | def get_openmc_universes(cells, surfaces, materials, data): 478 | """Get OpenMC surfaces from MCNP surfaces 479 | 480 | Parameters 481 | ---------- 482 | cells : list 483 | List of MCNP cells 484 | surfaces : dict 485 | Dictionary mapping surface ID to :class:`openmc.Surface` 486 | materials : dict 487 | Dictionary mapping material ID to :class:`openmc.Material` 488 | data : dict 489 | MCNP data-block information 490 | 491 | Returns 492 | ------- 493 | dict 494 | Dictionary mapping universe ID to :class:`openmc.Universe` instance 495 | 496 | """ 497 | openmc_cells = {} 498 | cell_by_id = {c['id']: c for c in cells} 499 | universes = {} 500 | root_universe = openmc.Universe(0) 501 | universes[0] = root_universe 502 | 503 | # Determine maximum IDs so that autogenerated IDs don't conflict 504 | openmc.Cell.next_id = max(c['id'] for c in cells) + 1 505 | all_univ_ids = set() 506 | for c in cells: 507 | if 'u' in c['parameters']: 508 | all_univ_ids.add(abs(int(c['parameters']['u']))) 509 | if all_univ_ids: 510 | openmc.Universe.next_id = max(all_univ_ids) + 1 511 | 512 | # Cell-complements pose a unique challenge for conversion because the 513 | # referenced cell may have a region that was translated, so we can't simply 514 | # replace the cell-complement by what appears on the referenced 515 | # cell. Instead, we loop over all the cells and construct regions for all 516 | # cells without cell complements. Then, we handle the remaining cells by 517 | # replacing the cell-complement with the string representation of the actual 518 | # region that was already converted 519 | has_cell_complement = [] 520 | translate_memo = {} 521 | for c in cells: 522 | # Skip cells that have cell-complements to be handled later 523 | match = _COMPLEMENT_RE.search(c['region']) 524 | if match: 525 | has_cell_complement.append(c) 526 | continue 527 | 528 | # Assign region to cell based on expression 529 | region = c['region'].replace('#', '~').replace(':', '|') 530 | 531 | # Replace macrobody facet specifiers in the region expression 532 | if '.' in region: 533 | region = replace_macrobody_facets(region, surfaces) 534 | 535 | try: 536 | c['_region'] = openmc.Region.from_expression(region, surfaces) 537 | except Exception: 538 | raise ValueError('Could not parse region for cell (ID={}): {}' 539 | .format(c['id'], region)) 540 | 541 | if 'trcl' in c['parameters'] or '*trcl' in c['parameters']: 542 | if 'trcl' in c['parameters']: 543 | trcl = c['parameters']['trcl'].strip() 544 | use_degrees = False 545 | else: 546 | trcl = c['parameters']['*trcl'].strip() 547 | use_degrees = True 548 | 549 | # Apply transformation to fill 550 | if 'fill' in c['parameters']: 551 | # TODO: Check for existing transformations on the fill 552 | fill = c['parameters']['fill'] 553 | if use_degrees: 554 | c['parameters']['*fill'] = f'{fill} {trcl}' 555 | c['parameters'].pop('fill') 556 | else: 557 | c['parameters']['fill'] = f'{fill} {trcl}' 558 | 559 | if not trcl.startswith('('): 560 | raise NotImplementedError( 561 | 'TRn card not supported (cell {}).'.format(c['id'])) 562 | 563 | # Drop parentheses 564 | trcl = trcl[1:-1].split() 565 | 566 | # Get displacement vector 567 | vector = np.array([float(c) for c in trcl[:3]]) 568 | 569 | if len(trcl) > 3: 570 | # If displacement vector origin is -1, reverse displacement vector 571 | if len(trcl) == 13: 572 | if int(trcl[12]) == -1: 573 | vector *= -1 574 | c['_region'] = c['_region'].translate(vector, translate_memo) 575 | 576 | rotation_matrix = np.array([float(x) for x in trcl[3:12]]).reshape((3, 3)) 577 | if use_degrees: 578 | rotation_matrix = np.cos(rotation_matrix * pi/180.0) 579 | c['_region'] = c['_region'].rotate(rotation_matrix.T, pivot=vector) 580 | else: 581 | c['_region'] = c['_region'].translate(vector, translate_memo) 582 | 583 | # Update surfaces dictionary with new surfaces 584 | for surf_id, surf in c['_region'].get_surfaces().items(): 585 | surfaces[surf_id] = surf 586 | if isinstance(surf, surface_composite.CompositeSurface): 587 | surfaces.update((-surf).get_surfaces()) 588 | 589 | has_cell_complement_ordered = [] 590 | def add_to_ordered(c): 591 | region = c['region'] 592 | matches = _COMPLEMENT_RE.findall(region) 593 | for _, other_id in matches: 594 | other_cell = cell_by_id[int(other_id)] 595 | if other_cell in has_cell_complement: 596 | add_to_ordered(other_cell) 597 | if c not in has_cell_complement_ordered: 598 | has_cell_complement_ordered.append(c) 599 | for c in has_cell_complement: 600 | add_to_ordered(c) 601 | 602 | # Now that all cells without cell-complements have been handled, we loop 603 | # over the remaining ones and convert any cell-complement expressions by 604 | # using str(region) 605 | for c in has_cell_complement_ordered: 606 | # Replace cell-complement with regular complement 607 | region = c['region'] 608 | matches = _COMPLEMENT_RE.findall(region) 609 | assert matches 610 | for _, other_id in matches: 611 | other_cell = cell_by_id[int(other_id)] 612 | try: 613 | r = ~other_cell['_region'] 614 | except KeyError: 615 | raise NotImplementedError( 616 | 'Cannot handle nested cell-complements for cell {}: {}' 617 | .format(c['id'], c['region'])) 618 | region = _COMPLEMENT_RE.sub(str(r), region, count=1) 619 | 620 | # Assign region to cell based on expression 621 | region = region.replace('#', '~').replace(':', '|') 622 | 623 | # Replace macrobody facet specifiers in the region expression 624 | if '.' in region: 625 | region = replace_macrobody_facets(region, surfaces) 626 | 627 | try: 628 | c['_region'] = openmc.Region.from_expression(region, surfaces) 629 | except Exception: 630 | raise ValueError('Could not parse region for cell (ID={}): {}' 631 | .format(c['id'], region)) 632 | 633 | # assume these cells are not translated themselves 634 | assert 'trcl' not in c['parameters'] 635 | 636 | # Now that all cell regions have been converted, the next loop is to create 637 | # actual Cell/Universe/Lattice objects 638 | material_clones = {} 639 | for c in cells: 640 | cell = openmc.Cell(cell_id=c['id']) 641 | 642 | # Assign region to cell based on expression 643 | cell.region = c['_region'] 644 | 645 | # Add cell to universes if necessary 646 | if 'u' in c['parameters']: 647 | if 'lat' not in c['parameters']: 648 | # Note: a negative universe indicates that the cell is not 649 | # truncated by the boundary of a higher level cell. 650 | uid = abs(int(c['parameters']['u'])) 651 | if uid not in universes: 652 | universes[uid] = openmc.Universe(uid) 653 | universes[uid].add_cell(cell) 654 | else: 655 | root_universe.add_cell(cell) 656 | 657 | # Look for vacuum boundary condition 658 | if isinstance(cell.region, openmc.Union): 659 | if all([isinstance(n, openmc.Halfspace) for n in cell.region]): 660 | if 'imp:n' in c['parameters'] and float(c['parameters']['imp:n']) == 0.0: 661 | for n in cell.region: 662 | if n.surface.boundary_type == 'transmission': 663 | n.surface.boundary_type = 'vacuum' 664 | root_universe.remove_cell(cell) 665 | elif isinstance(cell.region, openmc.Halfspace): 666 | if 'imp:n' in c['parameters'] and float(c['parameters']['imp:n']) == 0.0: 667 | if cell.region.surface.boundary_type == 'transmission': 668 | cell.region.surface.boundary_type = 'vacuum' 669 | root_universe.remove_cell(cell) 670 | 671 | # Determine material fill if present -- this is not assigned until later 672 | # in case it's used in a lattice (need to create an extra universe then) 673 | cell_material_id: int = c['material'] 674 | if cell_material_id > 0: 675 | mat = materials[cell_material_id] 676 | cell_density = c['density'] 677 | if mat.density is None: 678 | if cell_density > 0: 679 | mat.set_density('atom/b-cm', cell_density) 680 | else: 681 | mat.set_density('g/cm3', abs(cell_density)) 682 | elif mat.density != abs(c['density']): 683 | key = (cell_material_id, cell_density) 684 | if key not in material_clones: 685 | material_clones[key] = mat = mat.clone() 686 | if c['density'] > 0: 687 | mat.set_density('atom/b-cm', c['density']) 688 | else: 689 | mat.set_density('g/cm3', abs(c['density'])) 690 | else: 691 | mat = material_clones[key] 692 | 693 | # Create lattices 694 | if 'fill' in c['parameters'] or '*fill' in c['parameters']: 695 | if 'lat' in c['parameters']: 696 | # Check what kind of lattice this is 697 | if int(c['parameters']['lat']) == 2: 698 | raise NotImplementedError("Hexagonal lattices not supported") 699 | 700 | # Cell filled with Lattice 701 | uid = abs(int(c['parameters']['u'])) 702 | if uid not in universes: 703 | universes[uid] = openmc.RectLattice(uid) 704 | lattice = universes[uid] 705 | 706 | # Determine dimensions of single lattice element 707 | if len(cell.region) < 4: 708 | raise NotImplementedError('One-dimensional lattices not supported') 709 | sides = {'x': [], 'y': [], 'z': []} 710 | for n in cell.region: 711 | if isinstance(n.surface, openmc.XPlane): 712 | sides['x'].append(n.surface.x0) 713 | elif isinstance(n.surface, openmc.YPlane): 714 | sides['y'].append(n.surface.y0) 715 | elif isinstance(n.surface, openmc.ZPlane): 716 | sides['z'].append(n.surface.z0) 717 | if not sides['x'] or not sides['y']: 718 | raise NotImplementedError('2D lattice with basis other than x-y not supported') 719 | 720 | # MCNP's convention is that across the first surface listed is 721 | # the (1,0,0) element and across the second surface is the 722 | # (-1,0,0) element 723 | if sides['z']: 724 | v1, v0 = np.array([sides['x'], sides['y'], sides['z']]).T 725 | else: 726 | v1, v0 = np.array([sides['x'], sides['y']]).T 727 | 728 | pitch = abs(v1 - v0) 729 | 730 | def get_universe(uid): 731 | if uid not in universes: 732 | universes[uid] = openmc.Universe(uid) 733 | return universes[uid] 734 | 735 | # Get extent of lattice 736 | words = c['parameters']['fill'].split() 737 | 738 | # If there's only a single parameter, the lattice is infinite 739 | inf_lattice = (len(words) == 1) 740 | 741 | if inf_lattice: 742 | # Infinite lattice 743 | xmin = xmax = ymin = ymax = zmin = zmax = 0 744 | univ_ids = words 745 | else: 746 | pairs = re.findall(r'-?\d+\s*:\s*-?\d+', c['parameters']['fill']) 747 | i_colon = c['parameters']['fill'].rfind(':') 748 | univ_ids = c['parameters']['fill'][i_colon + 1:].split()[1:] 749 | 750 | if not pairs: 751 | raise ValueError('Cant find lattice specification') 752 | 753 | xmin, xmax = map(int, pairs[0].split(':')) 754 | ymin, ymax = map(int, pairs[1].split(':')) 755 | zmin, zmax = map(int, pairs[2].split(':')) 756 | assert xmax >= xmin 757 | assert ymax >= ymin 758 | assert zmax >= zmin 759 | 760 | if pitch.size == 3: 761 | index0 = np.array([xmin, ymin, zmin]) 762 | index1 = np.array([xmax, ymax, zmax]) 763 | else: 764 | index0 = np.array([xmin, ymin]) 765 | index1 = np.array([xmax, ymax]) 766 | shape = index1 - index0 + 1 767 | 768 | # Determine lower-left corner of lattice 769 | corner0 = v0 + index0*(v1 - v0) 770 | corner1 = v1 + index1*(v1 - v0) 771 | lower_left = np.min(np.vstack((corner0, corner1)), axis=0) 772 | 773 | lattice.pitch = pitch 774 | lattice.lower_left = lower_left 775 | lattice.dimension = shape 776 | 777 | # Universe IDs array as ([z], y, x) 778 | univ_ids = np.asarray(univ_ids, dtype=int) 779 | univ_ids.shape = shape[::-1] 780 | 781 | # Depending on the order of the surfaces listed, it may be 782 | # necessary to flip some axes 783 | if (v1 - v0)[0] < 0.: 784 | # lattice positions on x-axis are backwards 785 | univ_ids = np.flip(univ_ids, axis=-1) 786 | if (v1 - v0)[1] < 0.: 787 | # lattice positions on y-axis are backwards 788 | univ_ids = np.flip(univ_ids, axis=-2) 789 | if sides['z'] and (v1 - v0)[2] < 0.: 790 | # lattice positions on z-axis are backwards 791 | univ_ids = np.flip(univ_ids, axis=-3) 792 | 793 | # Check for universe ID same as the ID assigned to the cell 794 | # itself -- since OpenMC can't handle this directly, we need 795 | # to create an extra cell/universe to fill in the lattice 796 | if np.any(univ_ids == uid): 797 | extra_cell = openmc.Cell(fill=mat) 798 | u = openmc.Universe(cells=[extra_cell]) 799 | univ_ids[univ_ids == uid] = u.id 800 | 801 | # Put it in universes dictionary so that get_universe 802 | # works correctly 803 | universes[u.id] = u 804 | 805 | # If center of MCNP lattice element is not (0,0,0), we need 806 | # to translate the universe 807 | center = np.zeros(3) 808 | center[:v0.size] = (v0 + v1)/2 809 | if not np.all(center == 0.0): 810 | for uid in np.unique(univ_ids): 811 | # Create translated universe 812 | trans_cell = openmc.Cell(fill=get_universe(uid)) 813 | trans_cell.translation = -center 814 | u = openmc.Universe(cells=[trans_cell]) 815 | universes[u.id] = u 816 | 817 | # Replace original universes with translated ones 818 | univ_ids[univ_ids == uid] = u.id 819 | 820 | # Get an array of universes instead of IDs 821 | lat_univ = np.vectorize(get_universe)(univ_ids) 822 | 823 | # Fill universes in OpenMC lattice, reversing y direction 824 | lattice.universes = lat_univ[..., ::-1, :] 825 | 826 | # For infinite lattices, set the outer universe 827 | if inf_lattice: 828 | lattice.outer = lat_univ.ravel()[0] 829 | 830 | cell._lattice = True 831 | else: 832 | # Cell filled with universes 833 | if 'fill' in c['parameters']: 834 | uid, ftrans = _CELL_FILL_RE.search(c['parameters']['fill']).groups() 835 | use_degrees = False 836 | else: 837 | uid, ftrans = _CELL_FILL_RE.search(c['parameters']['*fill']).groups() 838 | use_degrees = True 839 | 840 | # First assign fill based on whether it is a universe/lattice 841 | uid = int(uid) 842 | if uid not in universes: 843 | for ci in cells: 844 | if 'u' in ci['parameters']: 845 | if abs(int(ci['parameters']['u'])) == uid: 846 | if 'lat' in ci['parameters']: 847 | universes[uid] = openmc.RectLattice(uid) 848 | else: 849 | universes[uid] = openmc.Universe(uid) 850 | break 851 | cell.fill = universes[uid] 852 | 853 | # Set fill transformation 854 | if ftrans is not None: 855 | ftrans = ftrans.split() 856 | if len(ftrans) > 3: 857 | vector = np.array([float(x) for x in ftrans[:3]]) 858 | if len(ftrans) == 13: 859 | if int(ftrans[12]) == -1: 860 | vector *= -1 861 | 862 | cell.translation = tuple(vector) 863 | rotation_matrix = np.array([float(x) for x in ftrans[3:12]]).reshape((3, 3)) 864 | if use_degrees: 865 | rotation_matrix = np.cos(rotation_matrix * pi/180.0) 866 | cell.rotation = rotation_matrix 867 | elif len(ftrans) < 3: 868 | assert len(ftrans) == 1 869 | tr_num = int(ftrans[0]) 870 | translation, rotation = data['tr'][tr_num] 871 | cell.translation = translation 872 | if rotation is not None: 873 | cell.rotation = rotation.T 874 | else: 875 | cell.translation = tuple(float(x) for x in ftrans) 876 | 877 | elif c['material'] > 0: 878 | cell.fill = mat 879 | 880 | if 'vol' in c["parameters"]: 881 | cell.volume = float(c["parameters"]["vol"]) 882 | 883 | if not hasattr(cell, '_lattice'): 884 | openmc_cells[c['id']] = cell 885 | 886 | # Expand shorthand notation 887 | def replace_complement(region, cells): 888 | if isinstance(region, (openmc.Intersection, openmc.Union)): 889 | for n in region: 890 | replace_complement(n, cells) 891 | elif isinstance(region, openmc.Complement): 892 | if isinstance(region.node, openmc.Halfspace): 893 | region.node = cells[region.node.surface.id].region 894 | 895 | for cell in openmc_cells.values(): 896 | replace_complement(cell.region, openmc_cells) 897 | return universes 898 | 899 | 900 | def mcnp_to_model(filename, merge_surfaces: bool = True, expand_elements: bool = True) -> openmc.Model: 901 | """Convert MCNP input to OpenMC model 902 | 903 | Parameters 904 | ---------- 905 | filename : str 906 | Path to MCNP file 907 | merge_surfaces : bool 908 | Whether to remove redundant surfaces when the geometry is exported. 909 | 910 | Returns 911 | ------- 912 | openmc.Model 913 | Equivalent OpenMC model 914 | 915 | """ 916 | 917 | cells, surfaces, data = parse(filename) 918 | 919 | openmc_materials = get_openmc_materials(data['materials'], expand_elements) 920 | openmc_surfaces = get_openmc_surfaces(surfaces, data) 921 | openmc_universes = get_openmc_universes(cells, openmc_surfaces, 922 | openmc_materials, data) 923 | 924 | geometry = openmc.Geometry(openmc_universes[0]) 925 | geometry.merge_surfaces = merge_surfaces 926 | materials = openmc.Materials(geometry.get_all_materials().values()) 927 | 928 | settings = openmc.Settings() 929 | settings.batches = 40 930 | settings.inactive = 20 931 | settings.particles = 100 932 | settings.output = {'summary': True} 933 | 934 | # Determine bounding box for geometry 935 | all_volume = openmc.Union([cell.region for cell in 936 | geometry.root_universe.cells.values()]) 937 | ll, ur = all_volume.bounding_box 938 | src_class = getattr(openmc, 'IndependentSource') 939 | if src_class is None: 940 | src_class = openmc.Source 941 | if np.any(np.isinf(ll)) or np.any(np.isinf(ur)): 942 | settings.source = src_class(space=openmc.stats.Point()) 943 | else: 944 | settings.source = src_class(space=openmc.stats.Point((ll + ur)/2)) 945 | 946 | return openmc.Model(geometry, materials, settings) 947 | 948 | 949 | def mcnp_str_to_model(text: str, **kwargs): 950 | # Write string to a temporary file 951 | with tempfile.NamedTemporaryFile('w', delete=False) as fp: 952 | fp.write(text) 953 | 954 | # Parse model from file 955 | model = mcnp_to_model(fp.name, **kwargs) 956 | 957 | # Remove temporary file and return model 958 | os.remove(fp.name) 959 | return model 960 | 961 | 962 | def mcnp_to_openmc(): 963 | """Command-line interface for converting MCNP model""" 964 | parser = argparse.ArgumentParser() 965 | parser.add_argument('mcnp_filename') 966 | parser.add_argument('--merge-surfaces', action='store_true', 967 | help='Remove redundant surfaces when exporting XML') 968 | parser.add_argument('--no-merge-surfaces', dest='merge_surfaces', action='store_false', 969 | help='Do not remove redundant surfaces when exporting XML') 970 | parser.add_argument('--expand-elements', action='store_true', 971 | help='Expand elements to their constituent isotopes') 972 | parser.add_argument('--no-expand-elements', dest='expand_elements', action='store_false', 973 | help='Do not expand elements to their constituent isotopes') 974 | parser.add_argument('-o', '--output', default='model.xml', 975 | help='Name for the OpenMC model XML file') 976 | parser.add_argument('-s', '--separate-xml', action='store_true', 977 | help='Write separate XML files') 978 | parser.set_defaults(merge_surfaces=True) 979 | parser.set_defaults(expand_elements=True) 980 | args = parser.parse_args() 981 | 982 | model = mcnp_to_model(args.mcnp_filename, args.merge_surfaces, args.expand_elements) 983 | if args.separate_xml: 984 | model.export_to_xml() 985 | else: 986 | model.export_to_model_xml(args.output) 987 | --------------------------------------------------------------------------------