diff --git a/.github/scripts/check_version.py b/.github/scripts/check_version.py new file mode 100755 index 00000000..c804d1a0 --- /dev/null +++ b/.github/scripts/check_version.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python + +import argparse +import re +import sys +from setuptools_scm import get_version + +parser = argparse.ArgumentParser() +parser.add_argument("-a", "--alpha", action="store_true") +DEPLOY_VERSION = r"\d+\.\d+\.\d+" +ALPHA_VERSION = DEPLOY_VERSION + r"a\d+" +args = parser.parse_args() +if args.alpha: + print("checking alpha release") + parser = ALPHA_VERSION +else: + print("checking Final release.") + parser = DEPLOY_VERSION + +version = get_version() +print(f"version = {version}") +if not re.fullmatch(parser, version): + exit(1) +exit(0) diff --git a/.github/scripts/check_version.sh b/.github/scripts/check_version.sh deleted file mode 100755 index a912b2f7..00000000 --- a/.github/scripts/check_version.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -if [[ "$1" == *"dev"* ]]; -then - exit 1 -fi diff --git a/.github/workflows/deploy-alpha.yml b/.github/workflows/deploy-alpha.yml new file mode 100644 index 00000000..0be7b291 --- /dev/null +++ b/.github/workflows/deploy-alpha.yml @@ -0,0 +1,107 @@ +name: Deploy + +on: + push: + branches: [alpha-test] + + +jobs: + last-minute-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: set up python 3.12 + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - run: pip install . montepy[develop] + - run: python -m pytest + + build-packages: + name: Build, sign, and release packages on github + runs-on: ubuntu-latest + needs: [last-minute-test] + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: set up python 3.12 + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - run: pip install . montepy[build] + - name: Get Version + id: get_version + run: echo "version=`python -m setuptools_scm`" >> $GITHUB_OUTPUT + - name: Verify that this is a non-dev alpha release + run: .github/scripts/check_version.py --alpha + - run: python -m build . + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v2.1.1 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Create a GitHub release + uses: ncipollo/release-action@v1 + with: + tag: v${{ steps.get_version.outputs.version }} + name: Release ${{ steps.get_version.outputs.version }} + draft: true + - run: >- + gh release upload + 'v${{ steps.get_version.outputs.version }}' dist/** + --repo '${{ github.repository }}' + - uses: actions/upload-artifact@v4 + with: + name: build + path: | + dist/*.tar.gz + dist/*.whl + + + deploy-test-pypi: + environment: + name: test-pypi + url: https://test.pypi.org/p/montepy # Replace with your PyPI project name + needs: [build-packages] + permissions: + contents: read + id-token: write + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: build + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + deploy-pypi: + environment: + name: pypi + url: https://pypi.org/p/montepy # Replace with your PyPI project name + needs: [deploy-pages, deploy-test-pypi, build-packages] + permissions: + contents: read + id-token: write + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: build + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + + + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b580fea7..8bf941ba 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,7 +41,7 @@ jobs: id: get_version run: echo "version=`python -m setuptools_scm`" >> $GITHUB_OUTPUT - name: Verify that this is a non-dev release - run: .github/scripts/check_version.sh ${{ steps.get_version.outputs.version }} + run: .github/scripts/check_version.py - run: python -m build . - name: Sign the dists with Sigstore uses: sigstore/gh-action-sigstore-python@v3.0.0 diff --git a/.gitignore b/.gitignore index 4168a4c8..5f1cbc17 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ doc/build/* .idea/ .ipynb_checkpoints/ montepy/_version.py + +# various testing results +htmlcov +.hypothesis +.mutmut-cache diff --git a/benchmark/benchmark_big_model.py b/benchmark/benchmark_big_model.py index d4f3aac6..a5e5f5d8 100644 --- a/benchmark/benchmark_big_model.py +++ b/benchmark/benchmark_big_model.py @@ -1,25 +1,41 @@ import gc -import montepy import time import tracemalloc -FAIL_THRESHOLD = 30 - tracemalloc.start() + +import montepy + +FAIL_THRESHOLD = 40 +MEMORY_FRACTION = 0.50 + +starting_mem = tracemalloc.get_traced_memory()[0] +print(f"starting memory with montepy. {starting_mem/1024/1024} MB") start = time.time() problem = montepy.read_input("benchmark/big_model.imcnp") stop = time.time() +problem_mem = tracemalloc.get_traced_memory()[0] print(f"Took {stop - start} seconds") -print(f"Memory usage report: {tracemalloc.get_traced_memory()[0]/1024/1024} MB") +print(f"Memory usage report: {problem_mem/1024/1024} MB") del problem gc.collect() -print(f"Memory usage report after GC: {tracemalloc.get_traced_memory()[0]/1024/1024} MB") +ending_mem = tracemalloc.get_traced_memory()[0] +print(f"Memory usage report after GC: {ending_mem/1024/1024} MB") if (stop - start) > FAIL_THRESHOLD: raise RuntimeError( f"Benchmark took too long to complete. It must be faster than: {FAIL_THRESHOLD} s." ) + +prob_gc_mem = problem_mem - ending_mem +prob_actual_mem = problem_mem - starting_mem +gc_ratio = prob_gc_mem / prob_actual_mem +print(f"{gc_ratio:.2%} of the problem's memory was garbage collected.") +if (prob_gc_mem / prob_actual_mem) < MEMORY_FRACTION: + raise RuntimeError( + f"Benchmark had too many memory leaks. Only {gc_ratio:.2%} of the memory was collected." + ) diff --git a/demo/Pin_cell.ipynb b/demo/Pin_cell.ipynb index 0434e374..53bce52c 100644 --- a/demo/Pin_cell.ipynb +++ b/demo/Pin_cell.ipynb @@ -9,6 +9,7 @@ "source": [ "import montepy\n", "import os\n", + "\n", "montepy.__version__" ] }, @@ -95,7 +96,7 @@ "metadata": {}, "outputs": [], "source": [ - "#make folder\n", + "# make folder\n", "os.mkdir(\"parametric\")\n", "\n", "fuel_wall = problem.surfaces[1]\n", diff --git a/doc/source/_test_for_missing_docs.py b/doc/source/_test_for_missing_docs.py index 41e8a870..cc47f338 100644 --- a/doc/source/_test_for_missing_docs.py +++ b/doc/source/_test_for_missing_docs.py @@ -9,6 +9,7 @@ "_version.py", "__main__.py", "_cell_data_control.py", + "_singleton.py" } base = os.path.join("..", "..") diff --git a/doc/source/api/montepy.data_inputs.nuclide.rst b/doc/source/api/montepy.data_inputs.nuclide.rst new file mode 100644 index 00000000..3cea14f6 --- /dev/null +++ b/doc/source/api/montepy.data_inputs.nuclide.rst @@ -0,0 +1,10 @@ +montepy.data_inputs.nuclide module +================================== + + +.. automodule:: montepy.data_inputs.nuclide + :members: + :inherited-members: + :undoc-members: + :show-inheritance: + diff --git a/doc/source/api/montepy.data_inputs.rst b/doc/source/api/montepy.data_inputs.rst index 84cd93fd..12bbd074 100644 --- a/doc/source/api/montepy.data_inputs.rst +++ b/doc/source/api/montepy.data_inputs.rst @@ -23,6 +23,7 @@ montepy.data\_inputs package montepy.data_inputs.lattice_input montepy.data_inputs.material montepy.data_inputs.material_component + montepy.data_inputs.nuclide montepy.data_inputs.mode montepy.data_inputs.thermal_scattering montepy.data_inputs.transform diff --git a/doc/source/api/montepy.input_parser.material_parser.rst b/doc/source/api/montepy.input_parser.material_parser.rst new file mode 100644 index 00000000..86ed6f18 --- /dev/null +++ b/doc/source/api/montepy.input_parser.material_parser.rst @@ -0,0 +1,9 @@ +montepy.input\_parser.material\_parser module +============================================== + + +.. automodule:: montepy.input_parser.material_parser + :members: + :inherited-members: + :undoc-members: + :show-inheritance: diff --git a/doc/source/api/montepy.input_parser.rst b/doc/source/api/montepy.input_parser.rst index 2e773250..22d3fec9 100644 --- a/doc/source/api/montepy.input_parser.rst +++ b/doc/source/api/montepy.input_parser.rst @@ -17,6 +17,7 @@ montepy.input\_parser package montepy.input_parser.input_file montepy.input_parser.input_reader montepy.input_parser.input_syntax_reader + montepy.input_parser.material_parser montepy.input_parser.mcnp_input montepy.input_parser.parser_base montepy.input_parser.read_parser @@ -24,5 +25,6 @@ montepy.input\_parser package montepy.input_parser.surface_parser montepy.input_parser.syntax_node montepy.input_parser.tally_parser + montepy.input_parser.tally_seg_parser montepy.input_parser.thermal_parser montepy.input_parser.tokens diff --git a/doc/source/api/montepy.input_parser.tally_seg_parser.rst b/doc/source/api/montepy.input_parser.tally_seg_parser.rst new file mode 100644 index 00000000..3ede85e7 --- /dev/null +++ b/doc/source/api/montepy.input_parser.tally_seg_parser.rst @@ -0,0 +1,9 @@ +montepy.input\_parser.tally\_seg\_parser module +=============================================== + + +.. automodule:: montepy.input_parser.tally_seg_parser + :members: + :inherited-members: + :undoc-members: + :show-inheritance: diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 75fe024f..8d810620 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -2,6 +2,63 @@ MontePy Changelog ***************** +1.0 releases +============ + +#Next Version# +-------------- + +**Features Added** + +* Redesigned how Materials hold Material_Components. See :ref:`migrate 0 1` (:pull:`507`). +* Made it easier to create an Isotope, or now Nuclide: ``montepy.Nuclide("H-1.80c")`` (:issue:`505`). +* When a typo in an object attribute is made an Error is raised rather than silently having no effect (:issue:`508`). +* Improved material printing to avoid very long lists of components (:issue:`144`). +* Allow querying for materials by components (:issue:`95`). +* Added support for getting and setting default libraries, e.g., ``nlib``, from a material (:issue:`369`). +* Added most objects to the top level so they can be accessed like: ``montepy.Cell``. +* Made ``Material.is_atom_fraction`` settable (:issue:`511`). +* Made NumberedObjectCollections act like a set (:issue:`138`). +* Automatically added children objects, e.g., the surfaces in a cell, to the problem when the cell is added to the problem (:issue:`63`). +* Added ability to parse all MCNP objects from a string (:issue:`88`). +* Added function: :func:`~montepy.mcnp_problem.MCNP_Problem.parse` to parse arbitrary MCNP object (:issue:`88`). +* An error is now raised when typos in object attributes are used, e.g., ``cell.nubmer`` (:issue:`508`). + + +**Bugs Fixed** + +* Made it so that a material created from scratch can be written to file (:issue:`512`). +* Added support for parsing materials with parameters mixed throughout the definition (:issue:`182`). + +**Breaking Changes** + +* Removed :func:`~montepy.data_inputs.material.Material.material_components``. See :ref:`migrate 0 1` (:pull:`507`). +* Removed :class:`~montepy.data_inputs.isotope.Isotope` and changed them to :class:`~montepy.data_inputs.nuclide.Nuclide`. +* Removed :func:`~montepy.mcnp_problem.MCNP_Problem.add_cell_children_to_problem` as it is no longer needed. + +**Deprecated code Removed** + +* ``montepy.Cell.geometry_logic_string`` +* ``montepy.data_inputs.cell_modifier.CellModifier.has_changed_print_style`` +* ``montepy.data_inputs.data_input.DataInputAbstract`` + + * ``class_prefix`` + * ``has_number`` + * ``has_classifier`` + +* ``montepy.input_parser.mcnp_input.Card`` +* ``montepy.input_parser.mcnp_input.ReadCard`` +* ``montepy.input_parser.mcnp_input.Input.words`` +* ``montepy.input_parser.mcnp_input.Comment`` +* ``montepy.input_parser.mcnp_input.parse_card_shortcuts`` +* ``montepy.mcnp_object.MCNP_Object`` + + * ``wrap_words_for_mcnp`` + * ``compress_repeat_values`` + * ``compress_jump_values`` + * ``words`` + * ``allowed_keywords`` + 0.5 releases ============ @@ -54,7 +111,7 @@ MontePy Changelog ============ 0.4.1 --------------- +---------------- **Features Added** diff --git a/doc/source/conf.py b/doc/source/conf.py index c0752d7b..a410895d 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -37,8 +37,9 @@ "sphinx.ext.intersphinx", "sphinx.ext.extlinks", "sphinx.ext.doctest", + "sphinx_autodoc_typehints", "sphinx_favicon", - "sphinx_copybutton" + "sphinx_copybutton", ] # Add any paths that contain templates here, relative to this directory. @@ -58,6 +59,12 @@ # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] +# autodoc +autodoc_typehints = "both" +typehints_use_signature = True +typehints_use_signature_return = True +autodoc_typehints_description_target = "all" +autodoc_member_order = "groupwise" # Display the version display_version = True @@ -66,16 +73,23 @@ "https://mcnp.lanl.gov/pdf_files/TechReport_2022_LANL_LA-UR-22-30006" "Rev.1_KuleszaAdamsEtAl.pdf" ) +UM631 = "https://www.osti.gov/servlets/purl/2372634" UM62 = ( "https://mcnp.lanl.gov/pdf_files/TechReport_2017_LANL_LA-UR-17-29981" "_WernerArmstrongEtAl.pdf" ) extlinks = { # MCNP 6.3 User's Manual - "manual63sec": (UM63 + "#section.%s", "MCNP 6.3 manual § %s"), - "manual63": (UM63 + "#subsection.%s", "MCNP 6.3 manual § %s"), - "manual63part": (UM63 + "#part.%s", "MCNP 6.3 manual § %s"), - "manual63chapter": (UM63 + "#chapter.%s", "MCNP 6.3 manual § %s"), + "manual63sec": (UM63 + "#section.%s", "MCNP 6.3.0 manual § %s"), + "manual63": (UM63 + "#subsection.%s", "MCNP 6.3.0 manual § %s"), + "manual63part": (UM63 + "#part.%s", "MCNP 6.3.0 manual part %s"), + "manual63chapter": (UM63 + "#chapter.%s", "MCNP 6.3.0 manual Ch. %s"), + # MCNP 6.3.1 User's Manual + "manual631sec": (UM631 + "#section.%s", "MCNP 6.3.1 manual § %s"), + "manual631": (UM631 + "#subsection.%s", "MCNP 6.3.1 manual § %s"), + "manual631part": (UM631 + "#part.%s", "MCNP 6.3.1 manual part %s"), + "manual631chapter": (UM631 + "#chapter.%s", "MCNP 6.3.1 manual Ch. %s"), + # MCNP 6.2 User's manual "manual62": (UM62 + "#page=%s", "MCNP 6.2 manual p. %s"), "issue": ("https://github.com/idaholab/MontePy/issues/%s", "#%s"), "pull": ("https://github.com/idaholab/MontePy/pull/%s", "#%s"), diff --git a/doc/source/dev_checklist.rst b/doc/source/dev_checklist.rst new file mode 100644 index 00000000..f74f0ae3 --- /dev/null +++ b/doc/source/dev_checklist.rst @@ -0,0 +1,96 @@ +Developer's Guide to Common Tasks +================================= + +Setting up and Typical Development Workflow +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +#. Clone the repository. + +#. Install the required packages. + MontePy comes with the requirements specfied in ``pyproject.toml``. + Optional packages are also specified. + To install all packages needed for development simply run: + + ``pip install .[develop]`` + +#. Tie your work to an issue. All work on MontePy is tracked through issues. + If you are working on a new feature or bug that is not covered by an issue, please file an issue first. + +#. Work on a new branch. The branches: ``develop`` and ``main`` are protected. + All new code must be accepted through a merge request or pull request. + The easiest way to make this branch is to "create pull request" from github. + This will create a new branch (though with an unwieldy name) that you can checkout and work on. + +#. Run the test cases. MontePy relies heavily on its over 380 tests for the development process. + These are configured so if you run: ``pytest`` from the root of the git repository + all tests will be found and ran. + +#. Develop test cases. This is especially important if you are working on a bug fix. + A merge request will not be accepted until it can be shown that a test case can replicate the + bug and does in deed fail without the bug fix in place. + To achieve this, it is recommended that you commit the test first, and push it to gitlab. + This way there will be a record of the CI pipeline failing that can be quickly reviewed as part of the merge request. + + MontePy is currently working on migrating from ``unittest`` to ``pytest`` for test fixtures. + All new tests should use a ``pytest`` architecture. + Generally unit tests of new features go in the test file with the closest class name. + Integration tests have all been dumped in ``tests/test_integration.py``. + For integration tests you can likely use the ``tests/inputs/test.imcnp`` input file. + This is pre-loaded as an :class:`~montepy.mcnp_problem.MCNP_Problem` stored as: ``self.simple_problem``. + If you need to mutate it at all you must first make a ``copy.deepcopy`` of it. + +#. Write the code. + +#. Document all new classes and functions. MontePy uses `Sphinx docstrings `_. + +#. Format the code with ``black``. You can simply run ``black montepy tests`` + +#. Add more test cases as necessary. The merge request should show you the code coverage. + The general goal is near 100\% coverage. + +#. Update the documentation. Read the "Getting Started" guide and the "Developer's Guide", and see if any information there should be updated. + If you expect the feature to be commonly used it should be mentioned in the getting started guide. + Otherwise just the docstrings may suffice. + Another option is to write an example in the "Tips and Tricks" guide. + +#. Update the authors as necessary. + The authors information is in ``AUTHORS`` and ``pyproject.toml``. + +#. Start a merge request review. Generally Micah (@micahgale) or Travis (@tjlaboss) are good reviewers. + + +Deploy Process +^^^^^^^^^^^^^^ +MontePy currently does not use a continuous deploy (CD) process. +Changes are staged on the ``develop`` branch prior to a release. +Both ``develop`` and ``main`` are protected branches. +``main`` is only be used for releases. +If someone clones ``main`` they will get the most recent official release. +Only a select few core-developers are allowed to approve a merge to ``main`` and therefore a new release. +``develop`` is for production quality code that has been approved for release, +but is waiting on the next release. +So all new features and bug fixes must first be merged onto ``develop``. + +The expectation is that features once merged onto ``develop`` are stable, +well tested, well documented, and well-formatted. + +Merge Checklist +^^^^^^^^^^^^^^^ + +Here are some common issues to check before approving a merge request. + +#. If this is a bug fix did the new testing fail without the fix? +#. Were the authors and credits properly updated? +#. Check also the authors in ``pyproject.toml`` +#. Is this merge request tied to an issue? + +Deploy Checklist +^^^^^^^^^^^^^^^^ + +For a deployment you need to: + +#. Run the deploy script : ``.github/scripts/deploy.sh`` +#. Manually merge onto main without creating a new commit. + This is necessary because there's no way to do a github PR that will not create a new commit, which will break setuptools_scm. +#. Update the release notes on the draft release, and finalize it on GitHub. +#. Update the `Conda feedstock and deploy `_. diff --git a/doc/source/dev_standards.rst b/doc/source/dev_standards.rst new file mode 100644 index 00000000..e2607f8a --- /dev/null +++ b/doc/source/dev_standards.rst @@ -0,0 +1,190 @@ +Development Standards +===================== + +Contributing +------------ + +Here is a getting started guide to contributing. +If you have any questions Micah and Travis are available to give input and answer your questions. +Before contributing you should review the :ref:`scope` and design philosophy. + + +Versioning +---------- + +Version information is stored in git tags, +and retrieved using `setuptools scm `_. +The version tag shall match the regular expression: + +``v\d\.\d+\.\d+``. + +These tags will be applied by a maintainer during the release process, +and cannot be applied by normal users. + +MontePy follows the semantic versioning standard to the best of our abilities. + +Additional References: + +#. `Semantic versioning standard `_ + +Design Philosophy +----------------- + +#. **Do Not Repeat Yourself (DRY)** +#. If it's worth doing, it's worth doing well. +#. Use abstraction and inheritance smartly. +#. Use ``_private`` fields mostly. Use ``__private`` for very private things that should never be touched. +#. Use ``@property`` getters, and if needed setters. Setters must verify and clean user inputs. For the most part use :func:`~montepy.utilities.make_prop_val_node`, and :func:`~montepy.utilities.make_prop_pointer`. +#. Fail early and politely. If there's something that might be bad: the user should get a helpful error as + soon as the error is apparent. +#. Test. test. test. The goal is to achieve 100% test coverage. Unit test first, then do integration testing. A new feature merge request will ideally have around a dozen new test cases. +#. Do it right the first time. +#. Document all functions. +#. Expect everything to mutate at any time. +#. Avoid relative imports when possible. Use top level ones instead: e.g., ``import montepy.cell.Cell``. +#. Defer to vanilla python, and only use the standard library. Currently the only dependencies are `numpy `_ and `sly `_. + There must be good justification for breaking from this convention and complicating things for the user. + +Doc Strings +----------- + +All public (not ``_private``) classes and functions *must* have doc strings. +Most ``_private`` classes and functions should still be documented for other developers. + +Mandatory Elements +^^^^^^^^^^^^^^^^^^ + +#. One line descriptions. +#. Type annotations in the function signature +#. Description of all inputs. +#. Description of return values (can be skipped for None). +#. ``.. versionadded::``/ ``.. versionchanged::`` information for all new functions and classes. This information can + be dropped with major releases. +#. Example code for showing how to use objects that implement atypical ``__dunders__``, e.g., for ``__setitem__``, ``__iter__``, etc. +#. `Type hints `_ on all new or modified functions. + +.. note:: + + Class ``__init__`` arguments are documented in the class docstrings and not in ``__init__``. + +.. note:: + + MontePy is in the process of migrating to type annotations, so not all functions will have them. + Eventually MontePy may use a type enforcement engine that will use these hints. + See :issue:`91` for more information. + If you have issues with circular imports add the import: ``from __future__ import annotations``, + this is from `PEP 563 `_. + + +Highly Recommended. +^^^^^^^^^^^^^^^^^^^ + +#. A class level ``.. seealso:`` section referencing the user manuals. + + +#. An examples code block. These should start with a section header: "Exampes". All code blocks should use `sphinx doctest `_. + +.. note:: + + MontePy docstrings features custom commands for linking to MCNP user manuals. + These in general follow the ``:manual62:``, ``:manual63:``, ``:manual631:`` pattern. + + The MCNP 6.2.0 manual only supports linking to a specific page, and not a section, so the argument it takes is a + page number: ``:manual62:`123```: becomes :manual62:`123`. + + The MCNP 6.3 manuals do support linking to section anchors. + By default the command links to a ``\\subsubsection``, e.g., ``:manual63:`5.6.1``` becomes: :manual63:`5.6.1`. + For other sections see: ``doc/source/conf.py``. + +Example +^^^^^^^ + +Here is the docstrings for :class:`~montepy.cell.Cell`. + +.. code-block:: python + + class Cell(Numbered_MCNP_Object): + """ + Object to represent a single MCNP cell defined in CSG. + + Examples + ^^^^^^^^ + + First the cell needs to be initialized. + + .. testcode:: python + + import montepy + cell = montepy.Cell() + + Then a number can be set. + By default the cell is voided: + + .. doctest:: python + + >>> cell.number = 5 + >>> cell.material + None + >>> mat = montepy.Material() + >>> mat.number = 20 + >>> mat.add_nuclide("1001.80c", 1.0) + >>> cell.material = mat + >>> # mass and atom density are different + >>> cell.mass_density = 0.1 + + Cells can be inverted with ``~`` to make a geometry definition that is a compliment of + that cell. + + .. testcode:: python + + complement = ~cell + + + .. seealso:: + + * :manual63sec:`5.2` + * :manual62:`55` + + :param input: the input for the cell definition + :type input: Input + + """ + + # snip + + def __init__(self, input: montepy.input_parser.mcnp_input.Input = None): + +Testing +------- + +Pytest is the official testing framework for MontePy. +In the past it was unittest, and so the test suite is in a state of transition. +Here are the principles for writing new tests: + +#. Do not write any new tests using ``unittest.TestCase``. +#. Use ``assert`` and not ``self.assert...``, even if it's available. +#. `parametrizing `_ is preferred over verbose tests. +#. Use `fixtures `_. +#. Use property based testing with `hypothesis `_, when it makes sense. + This is generally for complicated functions that users use frequently, such as constructors. + See this `tutorial for an introduction to property based testing + `_. + +Test Organization +^^^^^^^^^^^^^^^^^ + +Tests are organized in the ``tests`` folder in the following way: + +#. Unit tests are in their own files for each class or a group of classes. +#. Integration tests go in ``tests/test_*integration.py``. New integration files are welcome. +#. Interface tests with other libraries, e.g., ``pickle`` go in ``tests/test_interface.py``. +#. Test classes are preffered to organize tests by concepts. + Each MontePy class should have its own test class. These should not subclass anything. + Methods should accept ``_`` instead of ``self`` to note that class structure is purely organizational. + +Test Migration +^^^^^^^^^^^^^^ + +Currently the test suite does not conform to these standards fully. +Help with making the migration to the new standards is appreciated. +So don't think something is sacred about a test file that does not follow these conventions. diff --git a/doc/source/dev_tree.rst b/doc/source/dev_tree.rst index 95097dba..c69c4d8b 100644 --- a/doc/source/dev_tree.rst +++ b/doc/source/dev_tree.rst @@ -10,5 +10,7 @@ Developer's Resources :maxdepth: 1 :caption: Contents: + dev_checklist + dev_standards developing scope diff --git a/doc/source/developing.rst b/doc/source/developing.rst index f3be7eed..0265bf5f 100644 --- a/doc/source/developing.rst +++ b/doc/source/developing.rst @@ -4,8 +4,8 @@ and guidance on how to make contributions. MontePy is the most user-friendly Python library for reading, editing, and writing MCNP input files. -Developer's Guide -================= +Developer's Reference +===================== MontePy can be thought of as having two layers: the syntax, and the semantic layers. The syntax layers handle the boring syntax things: like multi-line cards, and comments, etc. @@ -26,124 +26,7 @@ The semantic layer takes this information and makes sense of it, like what the m import montepy problem = montepy.read_input("tests/inputs/test.imcnp") -Contributing ------------- -Here is a getting started guide to contributing. -If you have any questions Micah and Travis are available to give input and answer your questions. -Before contributing you should review the :ref:`scope` and design philosophy. - -Setting up and Typical Development Workflow -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -#. Clone the repository. - -#. Install the required packages. - MontePy comes with the requirements specfied in ``pyproject.toml``. - Optional packages are also specified. - To install all packages needed for development simply run: - - ``pip install .[develop]`` - -#. Tie your work to an issue. All work on MontePy is tracked through issues. - If you are working on a new feature or bug that is not covered by an issue, please file an issue first. - -#. Work on a new branch. The branches: ``develop`` and ``main`` are protected. - All new code must be accepted through a merge request or pull request. - The easiest way to make this branch is to "create pull request" from github. - This will create a new branch (though with an unwieldy name) that you can checkout and work on. - -#. Run the test cases. MontePy relies heavily on its over 380 tests for the development process. - These are configured so if you run: ``pytest`` from the root of the git repository - all tests will be found and ran. - -#. Develop test cases. This is especially important if you are working on a bug fix. - A merge request will not be accepted until it can be shown that a test case can replicate the - bug and does in deed fail without the bug fix in place. - To achieve this, it is recommended that you commit the test first, and push it to gitlab. - This way there will be a record of the CI pipeline failing that can be quickly reviewed as part of the merge request. - - MontePy is currently working on migrating from ``unittest`` to ``pytest`` for test fixtures. - All new tests should use a ``pytest`` architecture. - Generally unit tests of new features go in the test file with the closest class name. - Integration tests have all been dumped in ``tests/test_integration.py``. - For integration tests you can likely use the ``tests/inputs/test.imcnp`` input file. - This is pre-loaded as an :class:`~montepy.mcnp_problem.MCNP_Problem` stored as: ``self.simple_problem``. - If you need to mutate it at all you must first make a ``copy.deepcopy`` of it. - -#. Write the code. - -#. Document all new classes and functions. MontePy uses `Sphinx docstrings `_. - -#. Format the code with ``black``. You can simply run ``black montepy tests`` - -#. Add more test cases as necessary. The merge request should show you the code coverage. - The general goal is near 100\% coverage. - -#. Update the documentation. Read the "Getting Started" guide and the "Developer's Guide", and see if any information there should be updated. - If you expect the feature to be commonly used it should be mentioned in the getting started guide. - Otherwise just the docstrings may suffice. - Another option is to write an example in the "Tips and Tricks" guide. - -#. Update the authors as necessary. - The authors information is in ``AUTHORS`` and ``pyproject.toml``. - -#. Start a merge request review. Generally Micah (@micahgale) or Travis (@tjlaboss) are good reviewers. - - -Deploy Process -^^^^^^^^^^^^^^ -MontePy currently does not use a continuous deploy (CD) process. -Changes are staged on the ``develop`` branch prior to a release. -Both ``develop`` and ``main`` are protected branches. -``main`` is only be used for releases. -If someone clones ``main`` they will get the most recent official release. -Only a select few core-developers are allowed to approve a merge to ``main`` and therefore a new release. -``develop`` is for production quality code that has been approved for release, -but is waiting on the next release. -So all new features and bug fixes must first be merged onto ``develop``. - -The expectation is that features once merged onto ``develop`` are stable, -well tested, well documented, and well-formatted. - -Versioning -^^^^^^^^^^ - -Version information is stored in git tags, -and retrieved using `setuptools scm `_. -The version tag shall match the regular expression: - -``v\d\.\d+\.\d+``. - -These tags will be applied by a maintainer during the release process, -and cannot be applied by normal users. - -MontePy follows the semantic versioning standard to the best of our abilities. - -Additional References: - -#. `Semantic versioning standard `_ - -Merge Checklist -^^^^^^^^^^^^^^^ - -Here are some common issues to check before approving a merge request. - -#. If this is a bug fix did the new testing fail without the fix? -#. Were the authors and credits properly updated? -#. Check also the authors in ``pyproject.toml`` -#. Is this merge request tied to an issue? - -Deploy Checklist -^^^^^^^^^^^^^^^^ - -For a deployment you need to: - -#. Run the deploy script : ``.github/scripts/deploy.sh`` -#. Manually merge onto main without creating a new commit. - This is necessary because there's no way to do a github PR that will not create a new commit, which will break setuptools_scm. -#. Update the release notes on the draft release, and finalize it on GitHub. -#. Update the `Conda feedstock and deploy `_. Package Structure ----------------- diff --git a/doc/source/foo.imcnp b/doc/source/foo.imcnp index 050b96a9..3fe6940f 100644 --- a/doc/source/foo.imcnp +++ b/doc/source/foo.imcnp @@ -12,3 +12,7 @@ Example Problem kcode 1.0 100 25 100 TR1 0 0 1.0 TR2 0 0 1.00001 +c light water +m1 1001.80c 2.0 + 8016.80c 1.0 + plib=80p diff --git a/doc/source/index.rst b/doc/source/index.rst index 511b6303..e62fe899 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -35,6 +35,7 @@ See Also * `MontePy github Repository `_ * `MontePy PyPI Project `_ +* `MCNP 6.3.1 User Manual `_ DOI: `10.2172/2372634 `_ * `MCNP 6.3 User Manual `_ DOI: `10.2172/1889957 `_ * `MCNP 6.2 User Manual `_ * `MCNP Forum `_ diff --git a/doc/source/scope.rst b/doc/source/scope.rst index 0d534de6..897ac636 100644 --- a/doc/source/scope.rst +++ b/doc/source/scope.rst @@ -48,23 +48,6 @@ MontePy shouldn't be: #. A linking code to other software. #. Written in other languages* -Design Philosophy ------------------ - -#. **Do Not Repeat Yourself (DRY)** -#. If it's worth doing, it's worth doing well. -#. Use abstraction and inheritance smartly. -#. Use ``_private`` fields mostly. Use ``__private`` for very private things that should never be touched. -#. Use ``@property`` getters, and if needed setters. Setters must verify and clean user inputs. For the most part use :func:`~montepy.utilities.make_prop_val_node`, and :func:`~montepy.utilities.make_prop_pointer`. -#. Fail early and politely. If there's something that might be bad: the user should get a helpful error as - soon as the error is apparent. -#. Test. test. test. The goal is to achieve 100% test coverage. Unit test first, then do integration testing. A new feature merge request will ideally have around a dozen new test cases. -#. Do it right the first time. -#. Document all functions. -#. Expect everything to mutate at any time. -#. Avoid relative imports when possible. Use top level ones instead: e.g., ``import montepy.cell.Cell``. -#. Defer to vanilla python, and only use the standard library. Currently the only dependencies are `numpy `_ and `sly `_. - There must be good justification for breaking from this convention and complicating things for the user. Style Guide ----------- diff --git a/doc/source/starting.rst b/doc/source/starting.rst index 5d0cafbc..f0873b28 100644 --- a/doc/source/starting.rst +++ b/doc/source/starting.rst @@ -273,8 +273,7 @@ The ``NumberedObjectCollection`` has various mechanisms internally to avoid numb import montepy prob = montepy.read_input("tests/inputs/test.imcnp") - cell = montepy.Cell() - cell.number = 2 + cell = montepy.Cell(number = 2) prob.cells.append(cell) .. testoutput:: @@ -340,21 +339,23 @@ Using the generators in this way does not cause any issues, but there are ways t by making "stale" information. This can be done by making a copy of it with ``list()``. ->>> for num in problem.cells.numbers: -... print(num) -1 -2 -3 -99 -5 ->>> numbers = list(problem.cells.numbers) ->>> numbers -[1, 2, 3, 99, 5] ->>> problem.cells[1].number = 1000 ->>> 1000 in problem.cells.numbers -True ->>> 1000 in numbers -False +.. doctest:: + + >>> for num in problem.cells.numbers: + ... print(num) + 1 + 2 + 3 + 99 + 5 + >>> numbers = list(problem.cells.numbers) + >>> numbers + [1, 2, 3, 99, 5] + >>> problem.cells[1].number = 1000 + >>> 1000 in problem.cells.numbers + True + >>> 1000 in numbers + False Oh no! When we made a list of the numbers we broke the link, and the new list won't update when the numbers of the cells change, and you can cause issues this way. @@ -370,6 +371,69 @@ If a ``Cell`` or a group of ``Cells`` are cloned their numbers will be to change However, if a whole :class:`~montepy.mcnp_problem.MCNP_Problem` is cloned these objects will not have their numbers changed. For an example for how to clone a numbered object see :ref:`Cloning a Cell`. +Creating Objects from a String +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Sometimes its more convenient to create an MCNP object from its input string for MCNP, rather than setting a lot of properties, +or the object you need isn't supported by MontePy yet. +In this case there are a few ways to generate this object. +First all :class:`~montepy.mcnp_object.MCNP_Object` constructors can take a string: + +.. doctest:: + + >>> cell = montepy.Cell("1 0 -2 imp:n=1") + >>> cell.number + 1 + >>> cell.importance[montepy.Particle.NEUTRON] + 1.0 + >>> # surfaces + >>> surf = montepy.AxisPlane("5 PZ 10") + >>> surf.number + 5 + >>> surf.location + 10.0 + >>> # materials + >>> mat = montepy.Material("M1 1001.80c 2 8016.80c 1") + >>> mat.number + 1 + >>> thermal_scat = montepy.ThermalScatteringLaw("MT1 lwrt.40t") + >>> thermal_scat.old_number + 1 + >>> #object linking hasn't occuring + >>> print(thermal_scat.parent_material) + None + +For data inputs and surfaces there are some helper functions that help parse all objects of that type, +and return the appropriate object. +For surfaces this is: :func:`~montepy.surfaces.surface_builder.parse_surface`, +and for data inputs this is :func:`~montepy.data_inputs.data_parser.parse_data`. + +.. doctest:: + >>> surf = montepy.parse_surface("1 cz 5.0") + >>> type(surf) + foo + >>> surf.radius + 5.0 + >>> mat = montepy.parse_data("m1 1001.80c 1") + >>> type(mat) + foo + + +This object is still unlinked from other objects, and won't be kept with a problem. +So there is also :func:`~montepy.mcnp_problem.MCNP_Problem.parse`. +This takes a string, and then creates the MCNP object, +links it to the problem, +links it to its other objects (e.g., surfaces, materials, etc.), +and appends it to necessary collections (if requested): + +.. testcode:: + + cell = problem.parse("123 0 -1005") + assert cell in problem.cells + assert cell.surfaces[1005] is problem.surfaces[1005] + cell = problem.parse("124 0 -1005", append=False) + assert cell not in problem.cells + Surfaces -------- @@ -553,7 +617,9 @@ This actually creates a new object so don't worry about modifying the surface. .. doctest:: >>> bottom_plane = montepy.surfaces.surface.Surface() + >>> bottom_plane.number = 1 >>> top_plane = montepy.surfaces.surface.Surface() + >>> top_plane.number = 2 >>> type(+bottom_plane) >>> type(-bottom_plane) @@ -565,6 +631,7 @@ Instead you use the binary not operator (``~``). .. doctest:: >>> capsule_cell = montepy.Cell() + >>> capsule_cell.number = 1 >>> type(~capsule_cell) @@ -593,23 +660,18 @@ Order of precedence and grouping is automatically handled by Python so you can e .. testcode:: # build blank surfaces - bottom_plane = montepy.surfaces.axis_plane.AxisPlane() + bottom_plane = montepy.AxisPlane(number=1) bottom_plane.location = 0.0 - top_plane = montepy.surfaces.axis_plane.AxisPlane() + top_plane = montepy.AxisPlane(number=2) top_plane.location = 10.0 - fuel_cylinder = montepy.surfaces.cylinder_on_axis.CylinderOnAxis() + fuel_cylinder = montepy.CylinderOnAxis(number=3) fuel_cylinder.radius = 1.26 / 2 - clad_cylinder = montepy.surfaces.cylinder_on_axis.CylinderOnAxis() + clad_cylinder = montepy.CylinderOnAxis( number=4) clad_cylinder.radius = (1.26 / 2) + 1e-3 # fuel, gap, cladding - clad_od = montepy.surfaces.cylinder_on_axis.CylinderOnAxis() + clad_od = montepy.CylinderOnAxis(number=5) clad_od.radius = clad_cylinder.radius + 0.1 # add thickness - other_fuel = montepy.surfaces.cylinder_on_axis.CylinderOnAxis() + other_fuel = montepy.CylinderOnAxis(number=6) other_fuel.radius = 3.0 - bottom_plane.number = 1 - top_plane.number = 2 - fuel_cylinder.number = 3 - clad_cylinder.number = 4 - clad_od.number = 5 #make weird truncated fuel sample slug_half_space = +bottom_plane & -top_plane & -fuel_cylinder @@ -639,7 +701,10 @@ This will completely redefine the cell's geometry. You can also modify the geome .. testcode:: - other_fuel_region = -montepy.surfaces.cylinder_on_axis.CylinderOnAxis() + fuel_cyl = montepy.CylinderOnAxis() + fuel_cyl.number = 20 + fuel_cyl.radius = 1.20 + other_fuel_region = -fuel_cyl fuel_cell.geometry |= other_fuel_region .. warning:: @@ -801,10 +866,9 @@ You can also easy apply a transform to the filling universe with: .. testcode:: import numpy as np - transform = montepy.data_inputs.transform.Transform() - transform.number = 5 + transform = montepy.data_inputs.transform.Transform(number=5) transform.displacement_vector = np.array([1, 2, 0]) - cell.fill.tranform = transform + cell.fill.transform = transform .. note:: diff --git a/montepy/__init__.py b/montepy/__init__.py index 12f3b791..129a6d62 100644 --- a/montepy/__init__.py +++ b/montepy/__init__.py @@ -7,20 +7,43 @@ You will receive an MCNP_Problem object that you will interact with. """ +from . import data_inputs from . import input_parser from . import constants import importlib.metadata -from .input_parser.input_reader import read_input -from montepy.cell import Cell -from montepy.mcnp_problem import MCNP_Problem + +# data input promotion + from montepy.data_inputs.material import Material from montepy.data_inputs.transform import Transform +from montepy.data_inputs.nuclide import Library, Nuclide +from montepy.data_inputs.element import Element +from montepy.data_inputs.thermal_scattering import ThermalScatteringLaw +from montepy.data_inputs.data_parser import parse_data + +# geometry from montepy.geometry_operators import Operator from montepy import geometry_operators -from montepy.input_parser.mcnp_input import Jump -from montepy.particle import Particle from montepy.surfaces.surface_type import SurfaceType +from montepy.surfaces import * + +# input parser +from montepy.input_parser.mcnp_input import Jump +from .input_parser.input_reader import read_input + +# top level +from montepy.particle import Particle, LibraryType from montepy.universe import Universe +from montepy.cell import Cell +from montepy.mcnp_problem import MCNP_Problem + +# collections +from montepy.cells import Cells +from montepy.materials import Materials +from montepy.universes import Universes +from montepy.surface_collection import Surfaces +from montepy.transforms import Transforms + import montepy.errors import sys diff --git a/montepy/_singleton.py b/montepy/_singleton.py new file mode 100644 index 00000000..296f8b52 --- /dev/null +++ b/montepy/_singleton.py @@ -0,0 +1,61 @@ +# Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from abc import ABC, abstractmethod +import inspect +from functools import wraps + + +class SingletonGroup(ABC): + """ + A base class for implementing a Singleton-like data structure. + + This treats immutable objects are Enums without having to list all. + This is used for: Element, Nucleus, Library. When a brand new instance + is requested it is created, cached and returned. + If an existing instance is requested it is returned. + This is done to reduce the memory usage for these objects. + + """ + + __slots__ = "_instances" + + def __new__(cls, *args, **kwargs): + kwargs_t = tuple([(k, v) for k, v in kwargs.items()]) + try: + return cls._instances[args + kwargs_t] + except KeyError: + instance = super().__new__(cls) + instance.__init__(*args, **kwargs) + cls._instances[args + kwargs_t] = instance + return cls._instances[args + kwargs_t] + + def __init_subclass__(cls, **kwargs): + """ + Workaround to get sphinx autodoc happy. + """ + cls._instances = {} + super().__init_subclass__(**kwargs) + + original_new = cls.__new__ + + @wraps(original_new) + def __new__(cls, *args, **kwargs): + return original_new(cls, *args, **kwargs) + + __new__.__signature__ = inspect.signature(cls.__init__) + cls.__new__ = staticmethod(__new__) + + def __deepcopy__(self, memo): + """ + Make deepcopy happy. + """ + if self in memo: + return memo[self] + memo[self] = self + return self + + @abstractmethod + def __reduce__(self): + """ + See: + """ + pass diff --git a/montepy/cell.py b/montepy/cell.py index 5b07d7d0..7ad0e0b3 100644 --- a/montepy/cell.py +++ b/montepy/cell.py @@ -1,8 +1,11 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations import copy import itertools import numbers +from typing import Union +import montepy from montepy.cells import Cells from montepy.constants import BLANK_SPACE_CONTINUE from montepy.data_inputs import importance, fill, lattice_input, universe_input, volume @@ -10,7 +13,7 @@ from montepy.input_parser.cell_parser import CellParser from montepy.input_parser import syntax_node from montepy.errors import * -from montepy.numbered_mcnp_object import Numbered_MCNP_Object +from montepy.numbered_mcnp_object import Numbered_MCNP_Object, InitInput from montepy.data_inputs.material import Material from montepy.geometry_operators import Operator from montepy.surfaces.half_space import HalfSpace, UnitHalfSpace @@ -18,6 +21,7 @@ from montepy.surface_collection import Surfaces from montepy.universe import Universe from montepy.utilities import * +import montepy def _link_geometry_to_cell(self, geom): @@ -29,17 +33,52 @@ class Cell(Numbered_MCNP_Object): """ Object to represent a single MCNP cell defined in CSG. - .. versionchanged:: 0.2.0 - Removed the ``comments`` argument due to overall simplification of init process. + Examples + ^^^^^^^^ + First the cell needs to be initialized. - :param input: the input for the cell definition - :type input: Input + .. testcode:: python + + import montepy + cell = montepy.Cell() + + Then a number can be set. + By default the cell is voided: + + .. doctest:: python + + >>> cell.number = 5 + >>> print(cell.material) + None + >>> mat = montepy.Material() + >>> mat.number = 20 + >>> mat.add_nuclide("1001.80c", 1.0) + >>> cell.material = mat + >>> # mass and atom density are different + >>> cell.mass_density = 0.1 + + Cells can be inverted with ``~`` to make a geometry definition that is a compliment of + that cell. + + .. testcode:: python + + complement = ~cell .. seealso:: * :manual63sec:`5.2` * :manual62:`55` + + .. versionchanged:: 1.0.0 + + Added number parameter + + + :param input: The Input syntax object this will wrap and parse. + :type input: Union[Input, str] + :param number: The number to set for this object. + :type number: int """ _ALLOWED_KEYWORDS = { @@ -69,9 +108,21 @@ class Cell(Numbered_MCNP_Object): lattice_input.LatticeInput: ("_lattice", True), fill.Fill: ("_fill", True), } + _parser = CellParser() - def __init__(self, input=None): + def __init__( + self, + input: InitInput = None, + number: int = None, + ): + self._BLOCK_TYPE = montepy.input_parser.block_type.BlockType.CELL + self._CHILD_OBJ_MAP = { + "material": Material, + "surfaces": Surface, + "complements": Cell, + "_fill_transform": montepy.data_inputs.transform.Transform, + } self._material = None self._old_number = self._generate_default_node(int, -1) self._load_blank_modifiers() @@ -79,10 +130,9 @@ def __init__(self, input=None): self._density_node = self._generate_default_node(float, None) self._surfaces = Surfaces() self._complements = Cells() - self._number = self._generate_default_node(int, -1) - super().__init__(input, self._parser) + super().__init__(input, self._parser, number) if not input: - self._generate_default_tree() + self._generate_default_tree(number) self._old_number = copy.deepcopy(self._tree["cell_num"]) self._number = self._tree["cell_num"] mat_tree = self._tree["material"] @@ -169,6 +219,12 @@ def universe(self): """ return self._universe.universe + @universe.setter + def universe(self, value): + if not isinstance(value, Universe): + raise TypeError("universe must be set to a Universe") + self._universe.universe = value + @property def fill(self): """ @@ -182,11 +238,14 @@ def fill(self): """ return self._fill - @universe.setter - def universe(self, value): - if not isinstance(value, Universe): - raise TypeError("universe must be set to a Universe") - self._universe.universe = value + @property + def _fill_transform(self): + """ + A simple wrapper to get the transform of the fill or None. + """ + if self.fill: + return self.fill.transform + return None # pragma: no cover @property def not_truncated(self): @@ -322,9 +381,6 @@ def geometry(self): """ The Geometry for this problem. - .. versionadded:: 0.2.0 - Added with the new ability to represent true CSG geometry logic. - The HalfSpace tree that is able to represent this cell's geometry. MontePy's geometry is based upon dividers, which includes both Surfaces, and cells. A half-space is created by choosing one side of the divider. @@ -362,21 +418,6 @@ def geometry(self): """ pass - @property - def geometry_logic_string(self): # pragma: no cover - """ - The original geoemtry input string for the cell. - - .. warning:: - .. deprecated:: 0.2.0 - This was removed to allow for :func:`geometry` to truly implement CSG geometry. - - :raise DeprecationWarning: Will always be raised as an error (which will cause program to halt). - """ - raise DeprecationWarning( - "Geometry_logic_string has been removed from cell. Use Cell.geometry instead." - ) - @make_prop_val_node( "_density_node", (float, int, type(None)), base_type=float, deletable=True ) @@ -537,20 +578,32 @@ def update_pointers(self, cells, materials, surfaces): def remove_duplicate_surfaces(self, deleting_dict): """Updates old surface numbers to prepare for deleting surfaces. - :param deleting_dict: a dict of the surfaces to delete. - :type deleting_dict: dict + .. versionchanged:: 1.0.0 + + The form of the deleting_dict was changed as :class:`~montepy.surfaces.Surface` is no longer hashable. + + :param deleting_dict: a dict of the surfaces to delete, mapping the old surface to the new surface to replace it. + The keys are the number of the old surface. The values are a tuple + of the old surface, and then the new surface. + :type deleting_dict: dict[int, tuple[Surface, Surface]] """ new_deleting_dict = {} - for dead_surface, new_surface in deleting_dict.items(): + + def get_num(obj): + if isinstance(obj, int): + return obj + return obj.number + + for num, (dead_surface, new_surface) in deleting_dict.items(): if dead_surface in self.surfaces: - new_deleting_dict[dead_surface] = new_surface + new_deleting_dict[get_num(dead_surface)] = (dead_surface, new_surface) if len(new_deleting_dict) > 0: self.geometry.remove_duplicate_surfaces(new_deleting_dict) - for dead_surface in new_deleting_dict: + for dead_surface, _ in new_deleting_dict.values(): self.surfaces.remove(dead_surface) def _update_values(self): - if self.material: + if self.material is not None: mat_num = self.material.number self._tree["material"]["density"].is_negative = not self.is_atom_dens else: @@ -561,7 +614,7 @@ def _update_values(self): for input_class, (attr, _) in self._INPUTS_TO_PROPERTY.items(): getattr(self, attr)._update_values() - def _generate_default_tree(self): + def _generate_default_tree(self, number: int = None): material = syntax_node.SyntaxNode( "material", { @@ -573,7 +626,7 @@ def _generate_default_tree(self): self._tree = syntax_node.SyntaxNode( "cell", { - "cell_num": self._generate_default_node(int, None), + "cell_num": self._generate_default_node(int, number), "material": material, "geometry": None, "parameters": syntax_node.ParametersNode(), @@ -644,6 +697,10 @@ def __lt__(self, other): return self.number < other.number def __invert__(self): + if not self.number: + raise IllegalState( + f"Cell number must be set for a cell to be used in a geometry definition." + ) base_node = UnitHalfSpace(self, True, True) return HalfSpace(base_node, Operator.COMPLEMENT) @@ -706,7 +763,12 @@ def cleanup_last_line(ret): return self.wrap_string_for_mcnp(ret, mcnp_version, True) def clone( - self, clone_material=False, clone_region=False, starting_number=None, step=None + self, + clone_material=False, + clone_region=False, + starting_number=None, + step=None, + add_collect=True, ): """ Create a new almost independent instance of this cell with a new number. @@ -764,47 +826,48 @@ def clone( result._material = None else: result._material = self._material - special_keys = {"_surfaces", "_complements"} keys -= special_keys memo = {} + + def num(obj): + if isinstance(obj, int): + return obj + return obj.number + + # copy simple stuff for key in keys: attr = getattr(self, key) setattr(result, key, copy.deepcopy(attr, memo)) - if clone_region: + # copy geometry + for special in special_keys: + new_objs = [] + collection = getattr(self, special) region_change_map = {} + # get starting number + if not self._problem: + child_starting_number = starting_number + else: + child_starting_number = None # ensure the new geometry gets mapped to the new surfaces - for special in special_keys: - collection = getattr(self, special) - new_objs = [] - for obj in collection: - new_obj = obj.clone() - region_change_map[obj] = new_obj - new_objs.append(new_obj) - setattr(result, special, type(collection)(new_objs)) - - else: - region_change_map = {} - for special in special_keys: - setattr(result, special, copy.copy(getattr(self, special))) - leaves = result.geometry._get_leaf_objects() - # undo deepcopy of surfaces in cell.geometry - for geom_collect, collect in [ - (leaves[0], self.complements), - (leaves[1], self.surfaces), - ]: - for surf in geom_collect: - try: - region_change_map[surf] = collect[ - surf.number if isinstance(surf, (Surface, Cell)) else surf - ] - except KeyError: - # ignore empty surfaces on clone - pass - result.geometry.remove_duplicate_surfaces(region_change_map) + for obj in collection: + if clone_region: + new_obj = obj.clone( + starting_number=child_starting_number, step=step + ) + # avoid num collision of problem isn't handling this. + if child_starting_number: + child_starting_number = new_obj.number + step + else: + new_obj = obj + region_change_map[num(obj)] = (obj, new_obj) + new_objs.append(new_obj) + setattr(result, special, type(collection)(new_objs)) + result.geometry.remove_duplicate_surfaces(region_change_map) if self._problem: result.number = self._problem.cells.request_number(starting_number, step) - self._problem.cells.append(result) + if add_collect: + self._problem.cells.append(result) else: for number in itertools.count(starting_number, step): result.number = number diff --git a/montepy/cells.py b/montepy/cells.py index d1184d4d..2be3e1bb 100644 --- a/montepy/cells.py +++ b/montepy/cells.py @@ -8,6 +8,10 @@ class Cells(NumberedObjectCollection): """A collections of multiple :class:`montepy.cell.Cell` objects. + .. note:: + + For examples see the ``NumberedObjectCollection`` :ref:`collect ex`. + :param cells: the list of cells to start with if needed :type cells: list :param problem: the problem to link this collection to. @@ -180,3 +184,58 @@ def _run_children_format_for_mcnp(self, data_inputs, mcnp_version): if buf := getattr(self, attr).format_for_mcnp_input(mcnp_version): ret += buf return ret + + def clone( + self, clone_material=False, clone_region=False, starting_number=None, step=None + ): + """ + Create a new instance of this collection, with all new independent + objects with new numbers. + + This relies mostly on ``copy.deepcopy``. + + .. note :: + If starting_number, or step are not specified :func:`starting_number`, + and :func:`step` are used as default values. + + .. versionadded:: 0.5.0 + + .. versionchanged:: 1.0.0 + + Added ``clone_material`` and ``clone_region``. + + :param clone_material: Whether to create a new clone of the materials for the cells. + :type clone_material: bool + :param clone_region: Whether to clone the underlying objects (Surfaces, Cells) of these cells' region. + :type clone_region: bool + :param starting_number: The starting number to request for a new object numbers. + :type starting_number: int + :param step: the step size to use to find a new valid number. + :type step: int + :returns: a cloned copy of this object. + :rtype: type(self) + + """ + if not isinstance(starting_number, (int, type(None))): + raise TypeError( + f"Starting_number must be an int. {type(starting_number)} given." + ) + if not isinstance(step, (int, type(None))): + raise TypeError(f"step must be an int. {type(step)} given.") + if starting_number is not None and starting_number <= 0: + raise ValueError(f"starting_number must be >= 1. {starting_number} given.") + if step is not None and step <= 0: + raise ValueError(f"step must be >= 1. {step} given.") + if starting_number is None: + starting_number = self.starting_number + if step is None: + step = self.step + objs = [] + for obj in list(self): + new_obj = obj.clone( + clone_material, clone_region, starting_number, step, add_collect=False + ) + starting_number = new_obj.number + objs.append(new_obj) + starting_number = new_obj.number + step + return type(self)(objs) diff --git a/montepy/constants.py b/montepy/constants.py index 3bf4d8b3..069691bb 100644 --- a/montepy/constants.py +++ b/montepy/constants.py @@ -25,12 +25,25 @@ Number of spaces in a new line before it's considered a continuation. """ -LINE_LENGTH = {(5, 1, 60): 80, (6, 1, 0): 80, (6, 2, 0): 128} +LINE_LENGTH = { + (5, 1, 60): 80, + (6, 1, 0): 80, + (6, 2, 0): 128, + (6, 3, 0): 128, + (6, 3, 1): 128, +} """ The number of characters allowed in a line for each MCNP version. + +Citations: + +* 5.1.60 and 6.1.0: Section 2.6.2 of LA-UR-18-20808 +* 6.2.0: Section 1.1.1 of LA-UR-17-29981 +* 6.3.0: Section 3.2.2 of LA-UR-22-30006 +* 6.3.1: Section 3.2.2 of LA-UR-24-24602 """ -DEFAULT_VERSION = (6, 2, 0) +DEFAULT_VERSION = (6, 3, 0) """ The default version of MCNP to use. """ @@ -47,6 +60,11 @@ Source: `Wikipedia `_ """ +MAX_ATOMIC_SYMBOL_LENGTH = 2 +""" +The maximum length of an atomic symbol. +""" + def get_max_line_length(mcnp_version=DEFAULT_VERSION): """ diff --git a/montepy/data_inputs/cell_modifier.py b/montepy/data_inputs/cell_modifier.py index 3cf77685..6ddf90c0 100644 --- a/montepy/data_inputs/cell_modifier.py +++ b/montepy/data_inputs/cell_modifier.py @@ -1,7 +1,7 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. from abc import abstractmethod import montepy -from montepy.data_inputs.data_input import DataInputAbstract +from montepy.data_inputs.data_input import DataInputAbstract, InitInput from montepy.input_parser import syntax_node from montepy.input_parser.block_type import BlockType from montepy.input_parser.mcnp_input import Input, Jump @@ -15,7 +15,7 @@ class CellModifierInput(DataInputAbstract): Examples: IMP, VOL, etc. :param input: the Input object representing this data input - :type input: Input + :type input: Union[Input, str] :param in_cell_block: if this card came from the cell block of an input file. :type in_cell_block: bool :param key: the key from the key-value pair in a cell @@ -24,7 +24,13 @@ class CellModifierInput(DataInputAbstract): :type value: SyntaxNode """ - def __init__(self, input=None, in_cell_block=False, key=None, value=None): + def __init__( + self, + input: InitInput = None, + in_cell_block: bool = False, + key: str = None, + value: syntax_node.SyntaxNode = None, + ): fast_parse = False if key and value: input = Input([key], BlockType.DATA) @@ -178,8 +184,6 @@ def _tree_value(self): """ The ValueNode that holds the information for this instance, that should be included in the data block. - .. versionadded:: 0.2.0 - :returns: The ValueNode to update the data-block syntax tree with. :rtype: ValueNode """ @@ -191,8 +195,6 @@ def _collect_new_values(self): This will be a list in the same order as :func:`montepy.mcnp_problem.MCNP_Problem.cells`. - .. versionadded:: 0.2.0 - :returns: a list of the ValueNodes to update the data block syntax tree with :rtype: list """ @@ -207,8 +209,6 @@ def _collect_new_values(self): def _update_cell_values(self): """ Updates values in the syntax tree when in the cell block. - - .. versionadded:: 0.2.0 """ pass @@ -271,40 +271,3 @@ def format_for_mcnp_input(self, mcnp_version, has_following=False): suppress_blank_end=not self.in_cell_block, ) return [] - - @property - def has_changed_print_style(self): # pragma: no cover - """ - returns true if the printing style for this modifier has changed - from cell block to data block, or vice versa. - - .. deprecated:: 0.2.0 - This property is no longer needed and overly complex. - - :returns: true if the printing style for this modifier has changed - :rtype: bool - :raises DeprecationWarning: raised always. - """ - warnings.warn( - "has_changed_print_style will be removed soon.", - DeprecationWarning, - stacklevel=2, - ) - if self._problem: - print_in_cell_block = not self._problem.print_in_data_block[ - self.class_prefix - ] - set_in_cell_block = print_in_cell_block - if not self.in_cell_block: - for cell in self._problem.cells: - attr = montepy.Cell._CARDS_TO_PROPERTY[type(self)][0] - modifier = getattr(cell, attr) - if modifier.has_information: - set_in_cell_block = modifier.set_in_cell_block - break - else: - if self.has_information: - set_in_cell_block = self.set_in_cell_block - return print_in_cell_block ^ set_in_cell_block - else: - return False diff --git a/montepy/data_inputs/data_input.py b/montepy/data_inputs/data_input.py index 3beb8290..daeab526 100644 --- a/montepy/data_inputs/data_input.py +++ b/montepy/data_inputs/data_input.py @@ -1,4 +1,5 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations from abc import abstractmethod import copy @@ -11,9 +12,10 @@ ) from montepy.input_parser.mcnp_input import Input from montepy.particle import Particle -from montepy.mcnp_object import MCNP_Object +from montepy.mcnp_object import MCNP_Object, InitInput import re +from typing import Union class _ClassifierInput(Input): @@ -43,7 +45,7 @@ class DataInputAbstract(MCNP_Object): Parent class to describe all MCNP data inputs. :param input: the Input object representing this data input - :type input: Input + :type input: Union[Input, str] :param fast_parse: Whether or not to only parse the first word for the type of data. :type fast_parse: bool """ @@ -52,17 +54,29 @@ class DataInputAbstract(MCNP_Object): _classifier_parser = ClassifierParser() - def __init__(self, input=None, fast_parse=False): + def __init__( + self, + input: InitInput = None, + fast_parse=False, + ): self._particles = None if not fast_parse: super().__init__(input, self._parser) if input: self.__split_name(input) else: - input = copy.copy(input) - input.__class__ = _ClassifierInput + if input: + if isinstance(input, str): + input = _ClassifierInput( + input.split("\n"), + montepy.input_parser.block_type.BlockType.DATA, + ) + else: + input = copy.copy(input) + input.__class__ = _ClassifierInput super().__init__(input, self._classifier_parser) - self.__split_name(input) + if input: + self.__split_name(input) @staticmethod @abstractmethod @@ -229,7 +243,7 @@ def __enforce_name(self, input): if self._has_number(): try: num = classifier.number.value - assert num > 0 + assert num >= 0 except (AttributeError, AssertionError) as e: raise MalformedInputError( input, @@ -260,84 +274,22 @@ def __lt__(self, other): else: # otherwise first part is equal return self._input_number.value < other._input_number.value - @property - def class_prefix(self): # pragma: no cover - """The text part of the card identifier. - - For example: for a material the prefix is ``m`` - - this must be lower case - - .. deprecated:: 0.2.0 - This has been moved to :func:`_class_prefix` - - :returns: the string of the prefix that identifies a card of this class. - :rtype: str - :raises DeprecationWarning: always raised. - """ - warnings.warn( - "This has been moved to the property _class_prefix.", - DeprecationWarning, - stacklevl=2, - ) - - @property - def has_number(self): # pragma: no cover - """Whether or not this class supports numbering. - - For example: ``kcode`` doesn't allow numbers but tallies do allow it e.g., ``f7`` - - .. deprecated:: 0.2.0 - This has been moved to :func:`_has_number` - - :returns: True if this class allows numbers - :rtype: bool - :raises DeprecationWarning: always raised. - """ - warnings.warn( - "This has been moved to the property _has_number.", - DeprecationWarning, - stacklevl=2, - ) - - @property - def has_classifier(self): # pragma: no cover - """Whether or not this class supports particle classifiers. - - For example: ``kcode`` doesn't allow particle types but tallies do allow it e.g., ``f7:n`` - - * 0 : not allowed - * 1 : is optional - * 2 : is mandatory - - .. deprecated:: 0.2.0 - This has been moved to :func:`_has_classifier` - - - :returns: True if this class particle classifiers - :rtype: int - :raises DeprecationWarning: always raised. - """ - warnings.warn( - "This has been moved to the property _has_classifier.", - DeprecationWarning, - stacklevl=2, - ) - class DataInput(DataInputAbstract): """ Catch-all for all other MCNP data inputs. :param input: the Input object representing this data input - :type input: Input + :type input: Union[Input, str] :param fast_parse: Whether or not to only parse the first word for the type of data. :type fast_parse: bool :param prefix: The input prefix found during parsing (internal use only) :type prefix: str """ - def __init__(self, input=None, fast_parse=False, prefix=None): + def __init__( + self, input: InitInput = None, fast_parse: bool = False, prefix: str = None + ): if prefix: self._load_correct_parser(prefix) super().__init__(input, fast_parse) diff --git a/montepy/data_inputs/data_parser.py b/montepy/data_inputs/data_parser.py index 7d0ca9a2..32b85181 100644 --- a/montepy/data_inputs/data_parser.py +++ b/montepy/data_inputs/data_parser.py @@ -1,4 +1,6 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. + +import montepy from montepy.data_inputs import ( data_input, fill, @@ -26,15 +28,12 @@ } -def parse_data(input): +def parse_data(input: montepy.mcnp_object.InitInput): """ Parses the data input as the appropriate object if it is supported. - .. versionchanged:: 0.2.0 - Removed the ``comment`` parameter, as it's in the syntax tree directly now. - :param input: the Input object for this Data input - :type input: Input + :type input: Union[Input, str] :return: the parsed DataInput object :rtype: DataInput """ diff --git a/montepy/data_inputs/element.py b/montepy/data_inputs/element.py index 3c6b7d3c..f3443d1a 100644 --- a/montepy/data_inputs/element.py +++ b/montepy/data_inputs/element.py @@ -1,23 +1,36 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations from montepy.errors import * +from montepy._singleton import SingletonGroup +MAX_Z_NUM = 118 -class Element: + +class Element(SingletonGroup): """ Class to represent an element e.g., Aluminum. + .. Note:: + + This class is immutable, and hashable, meaning it is suitable as a dictionary key. + + :param Z: the Z number of the element :type Z: int :raises UnknownElement: if there is no element with that Z number. """ - def __init__(self, Z): + __slots__ = "_Z" + + def __init__(self, Z: int): + if not isinstance(Z, int): + raise TypeError(f"Z must be an int. {Z} of type {type(Z)} given.") self._Z = Z if Z not in self.__Z_TO_SYMBOL: raise UnknownElement(f"Z={Z}") @property - def symbol(self): + def symbol(self) -> str: """ The atomic symbol for this Element. @@ -27,7 +40,7 @@ def symbol(self): return self.__Z_TO_SYMBOL[self.Z] @property - def Z(self): + def Z(self) -> int: """ The atomic number for this Element. @@ -37,7 +50,7 @@ def Z(self): return self._Z @property - def name(self): + def name(self) -> str: """ The name of the element. @@ -50,16 +63,19 @@ def __str__(self): return self.name def __repr__(self): - return f"Z={self.Z}, symbol={self.symbol}, name={self.name}" + return f"Element({self.Z})" def __hash__(self): return hash(self.Z) def __eq__(self, other): - return self.Z == other.Z + return self is other + + def __reduce__(self): + return (type(self), (self.Z,)) @classmethod - def get_by_symbol(cls, symbol): + def get_by_symbol(cls, symbol: str) -> Element: """ Get an element by it's symbol. @@ -76,7 +92,7 @@ def get_by_symbol(cls, symbol): raise UnknownElement(f"The symbol: {symbol}") @classmethod - def get_by_name(cls, name): + def get_by_name(cls, name: str) -> Element: """ Get an element by it's name. diff --git a/montepy/data_inputs/fill.py b/montepy/data_inputs/fill.py index 1801a70b..2c3653d0 100644 --- a/montepy/data_inputs/fill.py +++ b/montepy/data_inputs/fill.py @@ -1,6 +1,6 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. import itertools as it -from montepy.data_inputs.cell_modifier import CellModifierInput +from montepy.data_inputs.cell_modifier import CellModifierInput, InitInput from montepy.data_inputs.transform import Transform from montepy.errors import * from montepy.input_parser.block_type import BlockType @@ -17,7 +17,7 @@ class Fill(CellModifierInput): Object to handle the ``FILL`` input in cell and data blocks. :param input: the Input object representing this data input - :type input: Input + :type input: Union[Input, str] :param in_cell_block: if this card came from the cell block of an input file. :type in_cell_block: bool :param key: the key from the key-value pair in a cell @@ -31,7 +31,13 @@ class Fill(CellModifierInput): Maps the dimension to its axis number """ - def __init__(self, input=None, in_cell_block=False, key=None, value=None): + def __init__( + self, + input: InitInput = None, + in_cell_block: bool = False, + key: str = None, + value: syntax_node.SyntaxNode = None, + ): self._old_number = self._generate_default_node(int, None) self._old_numbers = None self._universe = None diff --git a/montepy/data_inputs/importance.py b/montepy/data_inputs/importance.py index 0315aefc..74bbcab0 100644 --- a/montepy/data_inputs/importance.py +++ b/montepy/data_inputs/importance.py @@ -2,7 +2,7 @@ import collections import copy import math -from montepy.data_inputs.cell_modifier import CellModifierInput +from montepy.data_inputs.cell_modifier import CellModifierInput, InitInput from montepy.errors import * from montepy.constants import DEFAULT_VERSION, rel_tol, abs_tol from montepy.input_parser import syntax_node @@ -31,7 +31,7 @@ class Importance(CellModifierInput): A data input that sets the importance for a cell(s). :param input: the Input object representing this data input - :type input: Input + :type input: Union[Input, str] :param in_cell_block: if this card came from the cell block of an input file. :type in_cell_block: bool :param key: the key from the key-value pair in a cell @@ -40,7 +40,13 @@ class Importance(CellModifierInput): :type value: SyntaxNode """ - def __init__(self, input=None, in_cell_block=False, key=None, value=None): + def __init__( + self, + input: InitInput = None, + in_cell_block: bool = False, + key: str = None, + value: syntax_node.SyntaxNode = None, + ): self._particle_importances = {} self._real_tree = {} self._part_combos = [] diff --git a/montepy/data_inputs/isotope.py b/montepy/data_inputs/isotope.py index a0f5a78c..f00e4d78 100644 --- a/montepy/data_inputs/isotope.py +++ b/montepy/data_inputs/isotope.py @@ -1,240 +1,17 @@ -# Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. -from montepy.data_inputs.element import Element -from montepy.errors import * -from montepy.input_parser.syntax_node import PaddingNode, ValueNode - -import warnings - - class Isotope: """ A class to represent an MCNP isotope .. deprecated:: 0.4.1 - This will class is deprecated, and will be renamed: ``Nuclde``. - For more details see the :ref:`migrate 0 1`. - :param ZAID: the MCNP isotope identifier - :type ZAID: str - :param suppress_warning: Whether to suppress the ``FutureWarning``. - :type suppress_warning: bool - """ + This will class is deprecated, and has been renamed: :class:`~montepy.data_inputs.nuclide.Nuclide`. + For more details see the :ref:`migrate 0 1`. - # Cl-52 Br-101 Xe-150 Os-203 Cm-251 Og-296 - _BOUNDING_CURVE = [(17, 52), (35, 101), (54, 150), (76, 203), (96, 251), (118, 296)] - _STUPID_MAP = { - "95642": {"_is_metastable": False, "_meta_state": None}, - "95242": {"_is_metastable": True, "_meta_state": 1}, - } + :raises DeprecationWarning: Whenever called. """ - Points on bounding curve for determining if "valid" isotope - """ - - def __init__(self, ZAID="", node=None, suppress_warning=False): - if not suppress_warning: - warnings.warn( - "montepy.data_inputs.isotope.Isotope is deprecated and will be renamed: Nuclide.\n" - "See for more information ", - FutureWarning, - ) - - if node is not None and isinstance(node, ValueNode): - if node.type == float: - node = ValueNode(node.token, str, node.padding) - self._tree = node - ZAID = node.value - parts = ZAID.split(".") - try: - assert len(parts) <= 2 - int(parts[0]) - except (AssertionError, ValueError) as e: - raise ValueError(f"ZAID: {ZAID} could not be parsed as a valid isotope") - self._ZAID = parts[0] - self.__parse_zaid() - if len(parts) == 2: - self._library = parts[1] - else: - self._library = "" - if node is None: - self._tree = ValueNode(self.mcnp_str(), str, PaddingNode(" ")) - self._handle_stupid_legacy_stupidity() - - def _handle_stupid_legacy_stupidity(self): - # TODO work on this for mat_redesign - if self.ZAID in self._STUPID_MAP: - stupid_overwrite = self._STUPID_MAP[self.ZAID] - for key, value in stupid_overwrite.items(): - setattr(self, key, value) - - def __parse_zaid(self): - """ - Parses the ZAID fully including metastable isomers. - - See Table 3-32 of LA-UR-17-29881 - - """ - - def is_probably_an_isotope(Z, A): - for lim_Z, lim_A in self._BOUNDING_CURVE: - if Z <= lim_Z: - if A <= lim_A: - return True - else: - return False - else: - continue - # if you are above Lv it's probably legit. - return True - - ZAID = int(self._ZAID) - self._Z = int(ZAID / 1000) - self._element = Element(self.Z) - A = int(ZAID % 1000) - if not is_probably_an_isotope(self.Z, A): - self._is_metastable = True - true_A = A - 300 - # only m1,2,3,4 allowed - found = False - for i in range(1, 5): - true_A -= 100 - # assumes that can only vary 40% from A = 2Z - if is_probably_an_isotope(self.Z, true_A): - found = True - break - if found: - self._meta_state = i - self._A = true_A - else: - raise ValueError( - f"ZAID: {ZAID} cannot be parsed as a valid metastable isomer. " - "Only isomeric state 1 - 4 are allowed" - ) - - else: - self._is_metastable = False - self._meta_state = None - self._A = A - - @property - def ZAID(self): - """ - The ZZZAAA identifier following MCNP convention - - :rtype: int - """ - # if this is made mutable this cannot be user provided, but must be calculated. - return self._ZAID - - @property - def Z(self): - """ - The Z number for this isotope. - - :returns: the atomic number. - :rtype: int - """ - return self._Z - - @property - def A(self): - """ - The A number for this isotope. - - :returns: the isotope's mass. - :rtype: int - """ - return self._A - - @property - def element(self): - """ - The base element for this isotope. - - :returns: The element for this isotope. - :rtype: Element - """ - return self._element - - @property - def is_metastable(self): - """ - Whether or not this is a metastable isomer. - - :returns: boolean of if this is metastable. - :rtype: bool - """ - return self._is_metastable - - @property - def meta_state(self): - """ - If this is a metastable isomer, which state is it? - - Can return values in the range [1,4] (or None). The exact state - number is decided by who made the ACE file for this, and not quantum mechanics. - Convention states that the isomers should be numbered from lowest to highest energy. - - :returns: the metastable isomeric state of this "isotope" in the range [1,4], or None - if this is a ground state isomer. - :rtype: int - """ - return self._meta_state - - @property - def library(self): - """ - The MCNP library identifier e.g. 80c - - :rtype: str - """ - return self._library - - @library.setter - def library(self, library): - if not isinstance(library, str): - raise TypeError("library must be a string") - self._library = library - - def __repr__(self): - return f"{self.__class__.__name__}({repr(self.nuclide_str())})" - - def mcnp_str(self): - """ - Returns an MCNP formatted representation. - - E.g., 1001.80c - - :returns: a string that can be used in MCNP - :rtype: str - """ - return f"{self.ZAID}.{self.library}" if self.library else self.ZAID - - def nuclide_str(self): - meta_suffix = f"m{self.meta_state}" if self.is_metastable else "" - suffix = f".{self._library}" if self._library else "" - return f"{self.element.symbol}-{self.A}{meta_suffix}{suffix}" - - def get_base_zaid(self): - """ - Get the ZAID identifier of the base isotope this is an isomer of. - - This is mostly helpful for working with metastable isomers. - - :returns: the mcnp ZAID of the ground state of this isotope. - :rtype: int - """ - return self.Z * 1000 + self.A - - def __str__(self): - meta_suffix = f"m{self.meta_state}" if self.is_metastable else "" - suffix = f" ({self._library})" if self._library else "" - return f"{self.element.symbol:>2}-{self.A:<3}{meta_suffix:<2}{suffix}" - - def __hash__(self): - return hash(self._ZAID) - - def __lt__(self, other): - return int(self.ZAID) < int(other.ZAID) - def __format__(self, format_str): - return str(self).__format__(format_str) + def __init__(self, *args, **kwargs): + raise DeprecationWarning( + "montepy.data_inputs.isotope.Isotope is deprecated and is renamed: Nuclide.\n" + "See for more information " + ) diff --git a/montepy/data_inputs/lattice_input.py b/montepy/data_inputs/lattice_input.py index 69bc69be..4f12e11c 100644 --- a/montepy/data_inputs/lattice_input.py +++ b/montepy/data_inputs/lattice_input.py @@ -1,6 +1,7 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. import itertools -from montepy.data_inputs.cell_modifier import CellModifierInput + +from montepy.data_inputs.cell_modifier import CellModifierInput, InitInput from montepy.data_inputs.lattice import Lattice from montepy.errors import * from montepy.input_parser.mcnp_input import Jump @@ -14,7 +15,7 @@ class LatticeInput(CellModifierInput): Object to handle the inputs from ``LAT``. :param input: the Input object representing this data input - :type input: Input + :type input: Union[Input, str] :param in_cell_block: if this card came from the cell block of an input file. :type in_cell_block: bool :param key: the key from the key-value pair in a cell @@ -23,7 +24,13 @@ class LatticeInput(CellModifierInput): :type value: SyntaxNode """ - def __init__(self, input=None, in_cell_block=False, key=None, value=None): + def __init__( + self, + input: InitInput = None, + in_cell_block: bool = False, + key: str = None, + value: syntax_node.SyntaxNode = None, + ): super().__init__(input, in_cell_block, key, value) self._lattice = self._generate_default_node(int, None) self._lattice._convert_to_enum(Lattice, True, int) diff --git a/montepy/data_inputs/material.py b/montepy/data_inputs/material.py index f337fa0b..caafbfa3 100644 --- a/montepy/data_inputs/material.py +++ b/montepy/data_inputs/material.py @@ -1,81 +1,387 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations +import collections as co import copy +import itertools +import math +import re +from typing import Generator, Union +import warnings +import weakref + +import montepy from montepy.data_inputs import data_input, thermal_scattering -from montepy.data_inputs.isotope import Isotope +from montepy.data_inputs.nuclide import Library, Nucleus, Nuclide +from montepy.data_inputs.element import Element from montepy.data_inputs.material_component import MaterialComponent from montepy.input_parser import syntax_node from montepy.input_parser.material_parser import MaterialParser from montepy import mcnp_object -from montepy.numbered_mcnp_object import Numbered_MCNP_Object +from montepy.numbered_mcnp_object import Numbered_MCNP_Object, InitInput from montepy.errors import * from montepy.utilities import * -import itertools -import re +from montepy.particle import LibraryType -import warnings + +MAX_PRINT_ELEMENTS: int = 5 +""" +The maximum number of elements to print in a material string descripton. +""" + +DEFAULT_INDENT: int = 6 +""" +The default number of spaces to indent on a new line by. + +This is used for adding new material components. +By default all components made from scratch are added to their own line with this many leading spaces. +""" + + +class _DefaultLibraries: + """ + A dictionary wrapper for handling the default libraries for a material. + + The default libraries are those specified by keyword, e.g., ``nlib=80c``. + + :param parent_mat: the material that this default library is associated with. + :type parent_mat: Material + """ + + __slots__ = "_libraries", "_parent" + + def __init__(self, parent_mat: Material): + self._libraries = {} + self._parent = weakref.ref(parent_mat) + + def __getitem__(self, key): + key = self._validate_key(key) + try: + return Library(self._libraries[key]["data"].value) + except KeyError: + return None + + def __setitem__(self, key, value): + key = self._validate_key(key) + if not isinstance(value, (Library, str)): + raise TypeError("") + if isinstance(value, str): + value = Library(value) + try: + node = self._libraries[key] + except KeyError: + node = self._generate_default_node(key) + self._parent()._append_param_lib(node) + self._libraries[key] = node + node["data"].value = str(value) + + def __delitem__(self, key): + key = self._validate_key(key) + node = self._libraries.pop(key) + self._parent()._delete_param_lib(node) + + def __str__(self): + return str(self._libraries) + + def __iter__(self): + return iter(self._libraries) + + def items(self): + for lib_type, node in self._libraries.items(): + yield (lib_type, node["data"].value) + + @staticmethod + def _validate_key(key): + if not isinstance(key, (str, LibraryType)): + raise TypeError("") + if not isinstance(key, LibraryType): + key = LibraryType(key.upper()) + return key + + @staticmethod + def _generate_default_node(key: LibraryType): + classifier = syntax_node.ClassifierNode() + classifier.prefix = key.value + ret = { + "classifier": classifier, + "seperator": syntax_node.PaddingNode(" = "), + "data": syntax_node.ValueNode("", str), + } + return syntax_node.SyntaxNode("mat library", ret) + + def _load_node(self, key: Union[str, LibraryType], node: syntax_node.SyntaxNode): + key = self._validate_key(key) + self._libraries[key] = node + + def __getstate__(self): + return {"_libraries": self._libraries} + + def __setstate__(self, state): + self._libraries = state["_libraries"] + + def _link_to_parent(self, parent_mat: Material): + self._parent = weakref.ref(parent_mat) + + +class _MatCompWrapper: + """ + A wrapper that allows unwrapping Nuclide and fractions + """ + + __slots__ = "_parent", "_index", "_setter" + + def __init__(self, parent, index, setter): + self._parent = parent + self._index = index + self._setter = setter + + def __iter__(self): + + def generator(): + for component in self._parent: + yield component[self._index] + + return generator() + + def __getitem__(self, idx): + return self._parent[idx][self._index] + + def __setitem__(self, idx, val): + new_val = self._setter(self._parent[idx], val) + self._parent[idx] = new_val class Material(data_input.DataInputAbstract, Numbered_MCNP_Object): """ A class to represent an MCNP material. - .. note:: + Examples + -------- + + First it might be useful to load an example problem: + + .. testcode:: + + import montepy + problem = montepy.read_input("foo.imcnp") + mat = problem.materials[1] + print(mat) + + .. testoutput:: + + MATERIAL: 1, ['hydrogen', 'oxygen'] + + Materials are iterable + ^^^^^^^^^^^^^^^^^^^^^^ + + Materials look like a list of tuples, and is iterable. + Whether or not the material is defined in mass fraction or atom fraction + is stored for the whole material in :func:`~montepy.data_inputs.material.Material.is_atom_fraction`. + The fractions (atom or mass) of the componenets are always positive, + because MontePy believes in physics. + + .. testcode:: + + assert mat.is_atom_fraction # ensures it is in atom_fraction + + for nuclide, fraction in mat: + print("nuclide", nuclide, fraction) + + This would display: + + .. testoutput:: + + nuclide H-1 (80c) 2.0 + nuclide O-16 (80c) 1.0 + + As a list, Materials can be indexed: + + .. testcode:: + + oxygen, ox_frac = mat[1] + mat[1] = (oxygen, ox_frac + 1e-6) + del mat[1] + + If you need just the nuclides or just the fractions of components in this material see: :func:`nuclides` and + :func:`values`. + + You can check if a Nuclide is in a Material + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - There is a known bug (:issue:`182`) that valid MCNP material definitions cannot be parsed. + You can check if a :class:`~montepy.data_inputs.nuclide.Nuclide` or :class:`~montepy.data_input.element.Element` is + in a Material with ``in``. + .. doctest:: - :param input: the input card that contains the data - :type input: Input + >>> montepy.Nuclide("H-1") in mat + True + >>> "H-1" in mat + True + >>> montepy.Element(1) in mat + True + >>> montepy.Element(92) in mat + False + + Add New Component + ^^^^^^^^^^^^^^^^^ + + The easiest way to add new components to a material is with + :func:`~montepy.data_inputs.material.Material.add_nuclide`. + + .. testcode:: + + # add boric acid to water + boric_acid_frac = 1e-6 + mat[0] + # Add by nuclide object + mat.add_nuclide(oxygen, ox_frac + 3 * boric_acid_frac) + # add by nuclide Name or ZAID + mat.add_nuclide("B-10.80c", 1e-6) + print(mat) + + .. testoutput:: + + MATERIAL: 1, ['hydrogen', 'oxygen', 'boron'] + + Default Libraries + ^^^^^^^^^^^^^^^^^ + + Also materials have the concept of :func:`~montepy.data_inputs.material.Material.default_libraries`. + These are the libraries set by ``NLIB``, ``PLIB``, etc., + which are used when a library of the correct :class:`~montepy.particle.LibraryType` is not provided with the + nuclide. + :func:`~montepy.data_inputs.material.Material.default_libraries` acts like a dictionary, + and can accept a string or a :class:`~montepy.particle.LibraryType` as keys. + + .. testcode:: + + print(mat.default_libraries["plib"]) + mat.default_libraries[montepy.LibraryType.NEUTRON] = "00c" + print(mat.default_libraries["nlib"]) + + .. testoutput:: + + 80p + 00c + + .. versionchanged:: 1.0.0 + + .. seealso:: + + * :manual63:`5.6.1` + * :manual62:`106` + + .. versionchanged:: 1.0.0 + + * Added number parameter + * This was the primary change for this release. For more details on what changed see :ref:`migrate 0 1`. + + :param input: The Input syntax object this will wrap and parse. + :type input: Union[Input, str] + :param parser: The parser object to parse the input with. + :type parser: MCNP_Parser + :param number: The number to set for this object. + :type number: int """ _parser = MaterialParser() - def __init__(self, input=None): - self._material_components = {} + def __init__( + self, + input: InitInput = None, + number: int = None, + ): + self._components = [] self._thermal_scattering = None self._is_atom_fraction = True - self._number = self._generate_default_node(int, -1) + self._number = self._generate_default_node(int, -1, None) + self._number.never_pad = True + self._elements = set() + self._nuclei = set() + self._default_libs = _DefaultLibraries(self) super().__init__(input) + self._load_init_num(number) if input: num = self._input_number self._old_number = copy.deepcopy(num) self._number = num set_atom_frac = False isotope_fractions = self._tree["data"] - if isinstance(isotope_fractions, syntax_node.IsotopesNode): - iterator = iter(isotope_fractions) - else: # pragma: no cover - # this is a fall through error, that should never be raised, - # but is here just in case + is_first = True + for group in isotope_fractions: + if len(group) == 2: + self._grab_isotope(*group, is_first=is_first) + is_first = False + else: + self._grab_default(*group) + else: + self._create_default_tree() + + def _grab_isotope( + self, nuclide: Nuclide, fraction: syntax_node.ValueNode, is_first: bool = False + ): + """ + Grabs and parses the nuclide and fraction from the init function, and loads it. + """ + isotope = Nuclide(node=nuclide) + fraction.is_negatable_float = True + if is_first: + self._is_atom_fraction = not fraction.is_negative + else: + # if switching fraction formatting + if fraction.is_negative == self._is_atom_fraction: raise MalformedInputError( input, - f"Material definitions for material: {self.number} is not valid.", - ) - for isotope_node, fraction in iterator: - isotope = Isotope(node=isotope_node, suppress_warning=True) - fraction.is_negatable_float = True - if not set_atom_frac: - set_atom_frac = True - if not fraction.is_negative: - self._is_atom_fraction = True - else: - self._is_atom_fraction = False - else: - # if switching fraction formatting - if (not fraction.is_negative and not self._is_atom_fraction) or ( - fraction.is_negative and self._is_atom_fraction - ): - raise MalformedInputError( - input, - f"Material definitions for material: {self.number} cannot use atom and mass fraction at the same time", - ) - - self._material_components[isotope] = MaterialComponent( - isotope, fraction, suppress_warning=True + f"Material definitions for material: {self.number} cannot use atom and mass fraction at the same time", ) + self._elements.add(isotope.element) + self._nuclei.add(isotope.nucleus) + self._components.append((isotope, fraction)) + + def _grab_default(self, param: syntax_node.SyntaxNode): + """ + Grabs and parses default libraris from init process. + """ + try: + lib_type = LibraryType(param["classifier"].prefix.value.upper()) + self._default_libs._load_node(lib_type, param) + # skip extra parameters + except ValueError: + pass + + def _create_default_tree(self): + classifier = syntax_node.ClassifierNode() + classifier.number = self._number + classifier.number.never_pad = True + classifier.prefix = syntax_node.ValueNode("M", str, never_pad=True) + classifier.padding = syntax_node.PaddingNode(" ") + mats = syntax_node.MaterialsNode("mat stuff") + self._tree = syntax_node.SyntaxNode( + "mats", + { + "start_pad": syntax_node.PaddingNode(), + "classifier": classifier, + "data": mats, + }, + ) + + def _append_param_lib(self, node: syntax_node.SyntaxNode): + """ + Adds the given syntax node to this Material's data list. + + This is called from _DefaultLibraries. + """ + self._tree["data"].append_param(node) + + def _delete_param_lib(self, node: syntax_node.SyntaxNode): + """ + Deletes the given syntax node from this Material's data list. + + This is called from _DefaultLibraries. + """ + self._tree["data"].nodes.remove((node,)) @make_prop_val_node("_old_number") - def old_number(self): + def old_number(self) -> int: """ The material number that was used in the read file @@ -83,39 +389,781 @@ def old_number(self): """ pass - @property - def is_atom_fraction(self): + @make_prop_pointer("_is_atom_fraction", bool) + def is_atom_fraction(self) -> bool: """ If true this constituent is in atom fraction, not weight fraction. + .. versionchanged:: 1.0.0 + + This property is now settable. + :rtype: bool """ - return self._is_atom_fraction + pass @property - def material_components(self): + def material_components(self): # pragma: no cover """ - The internal dictionary containing all the components of this material. + The internal dictionary containing all the components of this material. .. deprecated:: 0.4.1 MaterialComponent has been deprecated as part of a redesign for the material interface due to a critical bug in how MontePy handles duplicate nuclides. See :ref:`migrate 0 1`. - The keys are :class:`~montepy.data_inputs.isotope.Isotope` instances, and the values are - :class:`~montepy.data_inputs.material_component.MaterialComponent` instances. + :raises DeprecationWarning: This has been fully deprecated and cannot be used. + """ + raise DeprecationWarning( + f"""material_components is deprecated, and has been removed in MontePy 1.0.0. +See for more information """ + ) + + @make_prop_pointer("_default_libs") + def default_libraries(self): + """ + The default libraries that are used when a nuclide doesn't have a relevant library specified. + + Default Libraries + ^^^^^^^^^^^^^^^^^ + + Also materials have the concept of :func:`~montepy.data_inputs.material.Material.default_libraries`. + These are the libraries set by ``NLIB``, ``PLIB``, etc., + which are used when a library of the correct :class:`~montepy.particle.LibraryType` is not provided with the + nuclide. + :func:`~montepy.data_inputs.material.Material.default_libraries` acts like a dictionary, + and can accept a string or a :class:`~montepy.particle.LibraryType` as keys. + + .. testcode:: + + print(mat.default_libraries["plib"]) + mat.default_libraries[montepy.LibraryType.NEUTRON] = "00c" + print(mat.default_libraries["nlib"]) + + .. testoutput:: + + None + 00c + + .. versionadded:: 1.0.0 + + """ + pass + + def get_nuclide_library( + self, nuclide: Nuclide, library_type: LibraryType + ) -> Union[Library, None]: + """ + Figures out which nuclear data library will be used for the given nuclide in this + given material in this given problem. + + This follows the MCNP lookup process and returns the first Library to meet these rules. + + #. The library extension for the nuclide. For example if the nuclide is ``1001.80c`` for ``LibraryType("nlib")``, ``Library("80c")`` will be returned. + + #. Next if a relevant nuclide library isn't provided the :func:`~montepy.data_inputs.material.Material.default_libraries` will be used. + + #. Finally if the two other options failed ``M0`` will be checked. These are stored in :func:`montepy.materials.Materials.default_libraries`. + + .. note:: + + The final backup is that MCNP will use the first matching library in ``XSDIR``. + Currently MontePy doesn't support reading an ``XSDIR`` file and so it will return none in this case. + + .. versionadded:: 1.0.0 + + :param nuclide: the nuclide to check. + :type nuclide: Union[Nuclide, str] + :param library_type: the LibraryType to check against. + :type library_type: LibraryType + :returns: the library that will be used in this scenario by MCNP. + :rtype: Union[Library, None] + :raises TypeError: If arguments of the wrong type are given. + + """ + if not isinstance(nuclide, (Nuclide, str)): + raise TypeError(f"nuclide must be a Nuclide. {nuclide} given.") + if isinstance(nuclide, str): + nuclide = Nuclide(nuclide) + if not isinstance(library_type, (str, LibraryType)): + raise TypeError( + f"Library_type must be a LibraryType. {library_type} given." + ) + if not isinstance(library_type, LibraryType): + library_type = LibraryType(library_type.upper()) + if nuclide.library.library_type == library_type: + return nuclide.library + lib = self.default_libraries[library_type] + if lib: + return lib + if self._problem: + return self._problem.materials.default_libraries[library_type] + return None + + def __getitem__(self, idx): + """ """ + if not isinstance(idx, (int, slice)): + raise TypeError(f"Not a valid index. {idx} given.") + if isinstance(idx, int): + comp = self._components[idx] + return self.__unwrap_comp(comp) + # else it's a slice + return [self.__unwrap_comp(comp) for comp in self._components[idx]] + + @staticmethod + def __unwrap_comp(comp): + return (comp[0], comp[1].value) + + def __iter__(self): + def gen_wrapper(): + for comp in self._components: + yield self.__unwrap_comp(comp) + + return gen_wrapper() + + def __setitem__(self, idx, newvalue): + """ """ + if not isinstance(idx, (int, slice)): + raise TypeError(f"Not a valid index. {idx} given.") + old_vals = self._components[idx] + self._check_valid_comp(newvalue) + # grab fraction + old_vals[1].value = newvalue[1] + node_idx = self._tree["data"].nodes.index((old_vals[0]._tree, old_vals[1]), idx) + self._tree["data"].nodes[node_idx] = (newvalue[0]._tree, old_vals[1]) + self._components[idx] = (newvalue[0], old_vals[1]) + + def __len__(self): + return len(self._components) + + def _check_valid_comp(self, newvalue: tuple[Nuclide, float]): + """ + Checks valid compositions and raises an error if needed. + """ + if not isinstance(newvalue, tuple): + raise TypeError( + f"Invalid component given. Must be tuple of Nuclide, fraction. {newvalue} given." + ) + if len(newvalue) != 2: + raise ValueError( + f"Invalid component given. Must be tuple of Nuclide, fraction. {newvalue} given." + ) + if not isinstance(newvalue[0], Nuclide): + raise TypeError(f"First element must be an Nuclide. {newvalue[0]} given.") + if not isinstance(newvalue[1], (float, int)): + raise TypeError( + f"Second element must be a fraction greater than 0. {newvalue[1]} given." + ) + if newvalue[1] < 0.0: + raise ValueError( + f"Second element must be a fraction greater than 0. {newvalue[1]} given." + ) + + def __delitem__(self, idx): + if not isinstance(idx, (int, slice)): + raise TypeError(f"Not a valid index. {idx} given.") + if isinstance(idx, int): + self.__delitem(idx) + return + # else it's a slice + end = idx.start if idx.start is not None else 0 + start = idx.stop if idx.stop is not None else len(self) - 1 + step = -idx.step if idx.step is not None else -1 + for i in range(start, end, step): + self.__delitem(i) + if end == 0: + self.__delitem(0) + + def __delitem(self, idx): + comp = self._components[idx] + element = self[idx][0].element + nucleus = self[idx][0].nucleus + found_el = False + found_nuc = False + # keep indices positive for testing. + if idx < 0: + idx += len(self) + # determine if other components use this element and nucleus + for i, (nuclide, _) in enumerate(self): + if i == idx: + continue + if nuclide.element == element: + found_el = True + if nuclide.nucleus == nucleus: + found_nuc = True + if found_el and found_nuc: + break + if not found_el: + self._elements.remove(element) + if not found_nuc: + self._nuclei.remove(nucleus) + self._tree["data"].nodes.remove((comp[0]._tree, comp[1])) + del self._components[idx] + + def __contains__(self, nuclide): + if not isinstance(nuclide, (Nuclide, Nucleus, Element, str)): + raise TypeError( + f"Can only check if a Nuclide, Nucleus, Element, or str is in a material. {nuclide} given." + ) + if isinstance(nuclide, str): + nuclide = Nuclide(nuclide) + # switch to elemental + if isinstance(nuclide, (Nucleus, Nuclide)) and nuclide.A == 0: + nuclide = nuclide.element + # switch to nucleus if no library. + if isinstance(nuclide, Nuclide) and not nuclide.library: + nuclide = nuclide.nucleus + if isinstance(nuclide, (Nucleus, Nuclide)): + if isinstance(nuclide, Nuclide): + if nuclide.nucleus not in self._nuclei: + return False + for self_nuc, _ in self: + if self_nuc == nuclide: + return True + return False + if isinstance(nuclide, Nucleus): + return nuclide in self._nuclei + if isinstance(nuclide, Element): + element = nuclide + return element in self._elements + + def append(self, nuclide_frac_pair: tuple[Nuclide, float]): + """ + Appends the tuple to this material. + + .. versionadded:: 1.0.0 + + :param nuclide_frac_pair: a tuple of the nuclide and the fraction to add. + :type nuclide_frac_pair: tuple[Nuclide, float] + """ + self._check_valid_comp(nuclide_frac_pair) + self._elements.add(nuclide_frac_pair[0].element) + self._nuclei.add(nuclide_frac_pair[0].nucleus) + node = self._generate_default_node( + float, str(nuclide_frac_pair[1]), "\n" + " " * DEFAULT_INDENT + ) + syntax_node.ValueNode(str(nuclide_frac_pair[1]), float) + node.is_negatable_float = True + nuclide_frac_pair = (nuclide_frac_pair[0], node) + node.is_negative = not self._is_atom_fraction + self._components.append(nuclide_frac_pair) + self._tree["data"].append_nuclide(("_", nuclide_frac_pair[0]._tree, node)) + + def change_libraries(self, new_library: Union[str, Library]): + """ + Change the library for all nuclides in the material. + + .. versionadded:: 1.0.0 + + :param new_library: the new library to set all Nuclides to use. + :type new_library: Union[str, Library] + """ + if not isinstance(new_library, (Library, str)): + raise TypeError( + f"new_library must be a Library or str. {new_library} given." + ) + if isinstance(new_library, str): + new_library = Library(new_library) + for nuclide, _ in self: + nuclide.library = new_library - :rtype: dict + def add_nuclide(self, nuclide: Union[Nuclide, str, int], fraction: float): """ - warnings.warn( - f"""material_components is deprecated, and will be removed in MontePy 1.0.0. -See for more information """, - DeprecationWarning, + Add a new component to this material of the given nuclide, and fraction. + + .. versionadded:: 1.0.0 + + :param nuclide: The nuclide to add, which can be a string Identifier, or ZAID. + :type nuclide: Nuclide, str, int + :param fraction: the fraction of this component being added. + :type fraction: float + """ + if not isinstance(nuclide, (Nuclide, str, int)): + raise TypeError( + f"Nuclide must of type Nuclide, str, or int. {nuclide} of type {type(nuclide)} given." + ) + if not isinstance(fraction, (float, int)): + raise TypeError( + f"Fraction must be a numerical value. {fraction} of type {type(fraction)}" + ) + if isinstance(nuclide, (str, int)): + nuclide = Nuclide(nuclide) + self.append((nuclide, fraction)) + + def contains( + self, + nuclide: Union[Nuclide, Nucleus, Element, str, int], + *args: Union[Nuclide, Nucleus, Element, str, int], + threshold: float = 0.0, + ) -> bool: + """ + Checks if this material contains multiple nuclides. + + A boolean and is used for this comparison. + That is this material must contain all nuclides at or above the given threshold + in order to return true. + + Examples + ^^^^^^^^ + + .. testcode:: + + import montepy + problem = montepy.read_input("tests/inputs/test.imcnp") + + # try to find LEU materials + for mat in problem.materials: + if mat.contains("U-235", threshold=0.02): + # your code here + pass + + # try to find any fissile materials + for mat in problem.materials: + if mat.contains("U-235", "U-233", "Pu-239", threshold=1e-6): + pass + + # try to find a uranium + for mat in problem.materials: + if mat.contains("U"): + pass + + .. note:: + + If a nuclide is in a material multiple times, and cumulatively exceeds the threshold, + but for each instance it appears it is below the threshold this method will return False. + .. versionadded:: 1.0.0 + + :param nuclide: the first nuclide to check for. + :type nuclide: Union[Nuclide, Nucleus, Element, str, int] + :param args: a plurality of other nuclides to check for. + :type args: Union[Nuclide, Nucleus, Element, str, int] + :param threshold: the minimum concentration of a nuclide to be considered. The material components are not + first normalized. + :type threshold: float + + :return: whether or not this material contains all components given above the threshold. + :rtype: bool + + :raises TypeError: if any argument is of the wrong type. + :raises ValueError: if the fraction is not positive or zero, or if nuclide cannot be interpreted as a Nuclide. + + """ + nuclides = [] + for nuclide in [nuclide] + list(args): + if not isinstance(nuclide, (str, int, Element, Nucleus, Nuclide)): + raise TypeError( + f"Nuclide must be a type that can be converted to a Nuclide. The allowed types are: " + f"Nuclide, Nucleus, str, int. {nuclide} given." + ) + if isinstance(nuclide, (str, int)): + nuclide = Nuclide(nuclide) + # treat elemental as element + if isinstance(nuclide, (Nucleus, Nuclide)) and nuclide.A == 0: + nuclide = nuclide.element + if isinstance(nuclide, Nuclide) and not str(nuclide.library): + nuclide = nuclide.nucleus + nuclides.append(nuclide) + + if not isinstance(threshold, float): + raise TypeError( + f"Threshold must be a float. {threshold} of type: {type(threshold)} given" + ) + if threshold < 0.0: + raise ValueError(f"Threshold must be positive or zero. {threshold} given.") + + # fail fast + for nuclide in nuclides: + if nuclide not in self: + return False + + nuclides_search = {} + nuclei_search = {} + element_search = {} + for nuclide in nuclides: + if isinstance(nuclide, Element): + element_search[nuclide] = False + if isinstance(nuclide, Nucleus): + nuclei_search[nuclide] = False + if isinstance(nuclide, Nuclide): + nuclides_search[str(nuclide).lower()] = False + + for nuclide, fraction in self: + if fraction < threshold: + continue + if str(nuclide).lower() in nuclides_search: + nuclides_search[str(nuclide).lower()] = True + if nuclide.nucleus in nuclei_search: + nuclei_search[nuclide.nucleus] = True + if nuclide.element in element_search: + element_search[nuclide.element] = True + return all( + ( + all(nuclides_search.values()), + all(nuclei_search.values()), + all(element_search.values()), + ) ) - return self._material_components + + def normalize(self): + """ + Normalizes the components fractions so that they sum to 1.0. + + .. versionadded:: 1.0.0 + """ + total_frac = sum(self.values) + for _, val_node in self._components: + val_node.value /= total_frac + + @property + def values(self): + """ + Get just the fractions, or values from this material. + + This acts like a list. It is iterable, and indexable. + + Examples + ^^^^^^^^ + + .. testcode:: + + import montepy + mat = montepy.Material() + mat.number = 5 + enrichment = 0.04 + + # define UO2 with enrichment of 4.0% + mat.add_nuclide("8016.00c", 2/3) + mat.add_nuclide("U-235.00c", 1/3 * enrichment) + mat.add_nuclide("U-238.00c", 2/3 * (1 - enrichment)) + + for val in mat.values: + print(val) + # iterables can be used with other functions + max_frac = max(mat.values) + print("max", max_frac) + + This would print: + + .. testoutput:: + + 0.6666666666666666 + 0.013333333333333332 + 0.6399999999999999 + max 0.6666666666666666 + + .. testcode:: + + # get value by index + print(mat.values[0]) + + # set the value, and double enrichment + mat.values[1] *= 2.0 + print(mat.values[1]) + + This would print: + + .. testoutput:: + + 0.6666666666666666 + 0.026666666666666665 + + .. versionadded:: 1.0.0 + + :rtype: Generator[float] + """ + + def setter(old_val, new_val): + if not isinstance(new_val, float): + raise TypeError( + f"Value must be set to a float. {new_val} of type {type(new_val)} given." + ) + if new_val < 0.0: + raise ValueError( + f"Value must be greater than or equal to 0. {new_val} given." + ) + return (old_val[0], new_val) + + return _MatCompWrapper(self, 1, setter) + + @property + def nuclides(self): + """ + Get just the fractions, or values from this material. + + This acts like a list. It is iterable, and indexable. + + Examples + ^^^^^^^^ + + .. testcode:: + + import montepy + mat = montepy.Material() + mat.number = 5 + enrichment = 0.04 + + # define UO2 with enrichment of 4.0% + mat.add_nuclide("8016.00c", 2/3) + mat.add_nuclide("U-235.00c", 1/3 * enrichment) + mat.add_nuclide("U-238.00c", 2/3 * (1 - enrichment)) + + for nuc in mat.nuclides: + print(repr(nuc)) + # iterables can be used with other functions + max_zaid = max(mat.nuclides) + + this would print: + + .. testoutput:: + + Nuclide('O-16.00c') + Nuclide('U-235.00c') + Nuclide('U-238.00c') + + .. testcode:: + + # get value by index + print(repr(mat.nuclides[0])) + + # set the value, and double enrichment + mat.nuclides[1] = montepy.Nuclide("U-235.80c") + + .. testoutput:: + + Nuclide('O-16.00c') + + .. versionadded:: 1.0.0 + + :rtype: Generator[Nuclide] + """ + + def setter(old_val, new_val): + if not isinstance(new_val, Nuclide): + raise TypeError( + f"Nuclide must be set to a Nuclide. {new_val} of type {type(new_val)} given." + ) + return (new_val, old_val[1]) + + return _MatCompWrapper(self, 0, setter) + + def __prep_element_filter(self, filter_obj): + """ + Makes a filter function for an element. + + For use by find + """ + if isinstance(filter_obj, str): + filter_obj = Element.get_by_symbol(filter_obj).Z + if isinstance(filter_obj, Element): + filter_obj = filter_obj.Z + wrapped_filter = self.__prep_filter(filter_obj, "Z") + return wrapped_filter + + def __prep_filter(self, filter_obj, attr=None): + """ + Makes a filter function wrapper + """ + if filter_obj is None: + return lambda _: True + + if isinstance(filter_obj, slice): + + def slicer(val): + if attr is not None: + val = getattr(val, attr) + if filter_obj.start: + start = filter_obj.start + if val < filter_obj.start: + return False + else: + start = 0 + if filter_obj.stop: + if val >= filter_obj.stop: + return False + if filter_obj.step: + if (val - start) % filter_obj.step != 0: + return False + return True + + return slicer + if attr: + return lambda val: getattr(val, attr) == filter_obj + return lambda val: val == filter_obj + + def find( + self, + name: str = None, + element: Union[Element, str, int, slice] = None, + A: Union[int, slice] = None, + meta_state: Union[int, slice] = None, + library: Union[str, slice] = None, + ) -> Generator[tuple[int, tuple[Nuclide, float]]]: + """ + Finds all components that meet the given criteria. + + The criteria are additive, and a component must match all criteria. + That is the boolean and operator is used. + Slices can be specified at most levels allowing to search by a range of values. + For numerical quantities slices are rather intuitive, and follow the same rules that list indices do. + For elements slices are by Z number only. + For the library the slicing is done using string comparisons. + + Examples + ^^^^^^^^ + + .. testcode:: + + import montepy + mat = montepy.Material() + mat.number = 1 + + # make non-sense material + for nuclide in ["U-235.80c", "U-238.70c", "Pu-239.00c", "O-16.00c"]: + mat.add_nuclide(nuclide, 0.1) + + print("Get all uranium nuclides.") + print(list(mat.find(element = "U"))) + + print("Get all transuranics") + print(list(mat.find(element = slice(92, 100)))) + + print("Get all ENDF/B-VIII.0") + print(list(mat.find(library = slice("00c", "09c")))) + + This would print: + + .. testoutput:: + + Get all uranium nuclides. + [(0, (Nuclide('U-235.80c'), 0.1)), (1, (Nuclide('U-238.70c'), 0.1))] + Get all transuranics + [(0, (Nuclide('U-235.80c'), 0.1)), (1, (Nuclide('U-238.70c'), 0.1)), (2, (Nuclide('Pu-239.00c'), 0.1))] + Get all ENDF/B-VIII.0 + [(2, (Nuclide('Pu-239.00c'), 0.1)), (3, (Nuclide('O-16.00c'), 0.1))] + + .. versionadded:: 1.0.0 + + :param name: The name to pass to Nuclide to search by a specific Nuclide. If an element name is passed this + will only match elemental nuclides. + :type name: str + :param element: the element to filter by, slices must be slices of integers. This will match all nuclides that + are based on this element. e.g., "U" will match U-235 and U-238. + :type element: Element, str, int, slice + :param A: the filter for the nuclide A number. + :type A: int, slice + :param meta_state: the metastable isomer filter. + :type meta_state: int, slice + :param library: the libraries to limit the search to. + :type library: str, slice + + :returns: a generator of all matching nuclides, as their index and then a tuple of their nuclide, and fraction pairs that match. + :rtype: Generator[tuple[int, tuple[Nuclide, float]]] + """ + # nuclide type enforcement handled by `Nuclide` + if not isinstance(element, (Element, str, int, slice, type(None))): + raise TypeError( + f"Element must be only Element, str, int or slice types. {element} of type{type(element)} given." + ) + if not isinstance(A, (int, slice, type(None))): + raise TypeError( + f"A must be an int or a slice. {A} of type {type(A)} given." + ) + if not isinstance(meta_state, (int, slice, type(None))): + raise TypeError( + f"meta_state must an int or a slice. {meta_state} of type {type(meta_state)} given." + ) + if not isinstance(library, (str, slice, type(None))): + raise TypeError( + f"library must a str or a slice. {library} of type {type(library)} given." + ) + if name: + fancy_nuclide = Nuclide(name) + if fancy_nuclide.A == 0: + element = fancy_nuclide.element + fancy_nuclide = None + else: + fancy_nuclide = None + if fancy_nuclide and not fancy_nuclide.library: + first_filter = self.__prep_filter(fancy_nuclide.nucleus, "nucleus") + else: + first_filter = self.__prep_filter(fancy_nuclide) + + filters = [ + first_filter, + self.__prep_element_filter(element), + self.__prep_filter(A, "A"), + self.__prep_filter(meta_state, "meta_state"), + self.__prep_filter(library, "library"), + ] + for idx, component in enumerate(self): + for filt in filters: + found = filt(component[0]) + if not found: + break + if found: + yield idx, component + + def find_vals( + self, + name: str = None, + element: Union[Element, str, int, slice] = None, + A: Union[int, slice] = None, + meta_state: Union[int, slice] = None, + library: Union[str, slice] = None, + ) -> Generator[float]: + """ + A wrapper for :func:`find` that only returns the fractions of the components. + + For more examples see that function. + + Examples + ^^^^^^^^ + + .. testcode:: + + import montepy + mat = montepy.Material() + mat.number = 1 + + # make non-sense material + for nuclide in ["U-235.80c", "U-238.70c", "Pu-239.00c", "O-16.00c"]: + mat.add_nuclide(nuclide, 0.1) + + # get fraction that is uranium + print(sum(mat.find_vals(element= "U"))) + + which would intuitively print: + + .. testoutput:: + + 0.2 + + .. versionadded:: 1.0.0 + + :param name: The name to pass to Nuclide to search by a specific Nuclide. If an element name is passed this + will only match elemental nuclides. + :type name: str + :param element: the element to filter by, slices must be slices of integers. This will match all nuclides that + are based on this element. e.g., "U" will match U-235 and U-238. + :type element: Element, str, int, slice + :param A: the filter for the nuclide A number. + :type A: int, slice + :param meta_state: the metastable isomer filter. + :type meta_state: int, slice + :param library: the libraries to limit the search to. + :type library: str, slice + + :returns: a generator of fractions whose nuclide matches the criteria. + :rtype: Generator[float] + """ + for _, (_, fraction) in self.find(name, element, A, meta_state, library): + yield fraction + + def __bool__(self): + return bool(self._components) @make_prop_pointer("_thermal_scattering", thermal_scattering.ThermalScatteringLaw) - def thermal_scattering(self): + def thermal_scattering(self) -> thermal_scattering.ThermalScatteringLaw: """ The thermal scattering law for this material @@ -124,11 +1172,11 @@ def thermal_scattering(self): return self._thermal_scattering @property - def cells(self): + def cells(self) -> Generator[montepy.cell.Cell]: """A generator of the cells that use this material. :returns: an iterator of the Cell objects which use this. - :rtype: generator + :rtype: Generator[Cell] """ if self._problem: for cell in self._problem.cells: @@ -136,31 +1184,22 @@ def cells(self): yield cell def format_for_mcnp_input(self, mcnp_version): - """ - Creates a string representation of this MCNP_Object that can be - written to file. - - :param mcnp_version: The tuple for the MCNP version that must be exported to. - :type mcnp_version: tuple - :return: a list of strings for the lines that this input will occupy. - :rtype: list - """ lines = super().format_for_mcnp_input(mcnp_version) if self.thermal_scattering is not None: lines += self.thermal_scattering.format_for_mcnp_input(mcnp_version) return lines def _update_values(self): - new_list = syntax_node.IsotopesNode("new isotope list") - for idx, (isotope, component) in enumerate(self._material_components.items()): - isotope._tree.value = isotope.mcnp_str() - node = component._tree - node.is_negatable_float = True - node.is_negative = not self.is_atom_fraction - if idx < len(self._material_components) - 1 and not node.padding: - node.padding = syntax_node.PaddingNode(" ") - new_list.append(("_", isotope._tree, component._tree)) - self._tree.nodes["data"] = new_list + for nuclide, fraction in self._components: + node = nuclide._tree + parts = node.value.split(".") + fraction.is_negative = not self.is_atom_fraction + if ( + len(parts) > 1 + and parts[-1] != str(nuclide.library) + or (len(parts) == 1 and str(nuclide.library)) + ): + node.value = nuclide.mcnp_str() def add_thermal_scattering(self, law): """ @@ -178,12 +1217,12 @@ def add_thermal_scattering(self, law): ) self._thermal_scattering.add_scattering_law(law) - def update_pointers(self, data_inputs): + def update_pointers(self, data_inputs: list[montepy.data_inputs.DataInput]): """ Updates pointer to the thermal scattering data :param data_inputs: a list of the data inputs in the problem - :type data_inputs: list + :type data_inputs: list[DataInput] """ pass @@ -206,52 +1245,66 @@ def __repr__(self): else: ret += "mass\n" - for component in self._material_components: - ret += repr(self._material_components[component]) + "\n" + for component in self: + ret += f"{component[0]} {component[1]}\n" if self.thermal_scattering: ret += f"Thermal Scattering: {self.thermal_scattering}" return ret def __str__(self): - elements = self._get_material_elements() - return f"MATERIAL: {self.number}, {elements}" - - def _get_material_elements(self): - sortable_components = [ - (iso, component.fraction) - for iso, component in self._material_components.items() + elements = self.get_material_elements() + print_el = [] + if len(elements) > MAX_PRINT_ELEMENTS: + print_elements = elements[0:MAX_PRINT_ELEMENTS] + print_elements.append("...") + print_elements.append(elements[-1]) + else: + print_elements = elements + print_elements = [ + element.name if isinstance(element, Element) else element + for element in print_elements ] - sorted_comps = sorted(sortable_components) - elements_set = set() - elements = [] - for isotope, _ in sorted_comps: - if isotope.element not in elements_set: - elements_set.add(isotope.element) - elements.append(isotope.element.name) + return f"MATERIAL: {self.number}, {print_elements}" + + def get_material_elements(self): + """ + Get the elements that are contained in this material. + + This is sorted by the most common element to the least common. + + :returns: a sorted list of elements by total fraction + :rtype: list[Element] + """ + element_frac = co.Counter() + for nuclide, fraction in self: + element_frac[nuclide.element] += fraction + element_sort = sorted(element_frac.items(), key=lambda p: p[1], reverse=True) + elements = [p[0] for p in element_sort] return elements def validate(self): - if len(self._material_components) == 0: + if len(self._components) == 0 and self.number != 0: raise IllegalState( f"Material: {self.number} does not have any components defined." ) - def __hash__(self): - """WARNING: this is a temporary solution to make sets remove duplicate materials. - - This should be fixed in the future to avoid issues with object mutation: - - - """ - temp_hash = "" - sorted_isotopes = sorted(list(self._material_components.keys())) - for isotope in sorted_isotopes: - temp_hash = hash( - (temp_hash, str(isotope), self._material_components[isotope].fraction) - ) - - return hash((temp_hash, self.number)) - def __eq__(self, other): - return hash(self) == hash(other) + if not isinstance(other, Material): + return False + if self.number != other.number: + return False + if len(self) != len(other): + return False + my_comp = sorted(self, key=lambda c: c[0]) + other_comp = sorted(other, key=lambda c: c[0]) + for mine, yours in zip(my_comp, other_comp): + if mine[0] != yours[0]: + return False + if not math.isclose(mine[1], yours[1]): + return False + return True + + def __setstate__(self, state): + super().__setstate__(state) + self._default_libs._link_to_parent(self) diff --git a/montepy/data_inputs/material_component.py b/montepy/data_inputs/material_component.py index e8738575..5d3e7fcf 100644 --- a/montepy/data_inputs/material_component.py +++ b/montepy/data_inputs/material_component.py @@ -1,14 +1,4 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. -from montepy.data_inputs.isotope import Isotope -from montepy.input_parser.syntax_node import PaddingNode, ValueNode -from montepy.utilities import make_prop_val_node - -import warnings - - -def _enforce_positive(self, val): - if val <= 0: - raise ValueError(f"material component fraction must be > 0. {val} given.") class MaterialComponent: @@ -20,57 +10,14 @@ class MaterialComponent: .. deprecated:: 0.4.1 MaterialComponent has been deprecated as part of a redesign for the material interface due to a critical bug in how MontePy handles duplicate nuclides. + It has been removed in 1.0.0. See :ref:`migrate 0 1`. - :param isotope: the Isotope object representing this isotope - :type isotope: Isotope - :param fraction: the fraction of this component in the material - :type fraction: ValueNode - :param suppress_warning: Whether to suppress the ``DeprecationWarning``. - :type suppress_warning: bool + :raises DeprecationWarning: whenever called. """ - def __init__(self, isotope, fraction, suppress_warning=False): - if not suppress_warning: - warnings.warn( - f"""MaterialComponent is deprecated, and will be removed in MontePy 1.0.0. + def __init__(self, *args): + raise DeprecationWarning( + f"""MaterialComponent is deprecated, and has been removed in MontePy 1.0.0. See for more information """, - DeprecationWarning, - ) - if not isinstance(isotope, Isotope): - raise TypeError(f"Isotope must be an Isotope. {isotope} given") - if isinstance(fraction, (float, int)): - fraction = ValueNode(str(fraction), float, padding=PaddingNode(" ")) - elif not isinstance(fraction, ValueNode) or not isinstance( - fraction.value, float - ): - raise TypeError(f"fraction must be float ValueNode. {fraction} given.") - self._isotope = isotope - self._tree = fraction - if fraction.value < 0: - raise ValueError(f"Fraction must be > 0. {fraction.value} given.") - self._fraction = fraction - - @property - def isotope(self): - """ - The isotope for this material_component - - :rtype: Isotope - """ - return self._isotope - - @make_prop_val_node("_fraction", (float, int), float, _enforce_positive) - def fraction(self): - """ - The fraction of the isotope for this component - - :rtype: float - """ - pass - - def __str__(self): - return f"{self.isotope} {self.fraction}" - - def __repr__(self): - return f"{self.isotope} {self.fraction}" + ) diff --git a/montepy/data_inputs/nuclide.py b/montepy/data_inputs/nuclide.py new file mode 100644 index 00000000..22350e6f --- /dev/null +++ b/montepy/data_inputs/nuclide.py @@ -0,0 +1,800 @@ +# Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from montepy.constants import MAX_ATOMIC_SYMBOL_LENGTH +from montepy._singleton import SingletonGroup +from montepy.data_inputs.element import Element +from montepy.errors import * +from montepy.utilities import * +from montepy.input_parser.syntax_node import PaddingNode, ValueNode +from montepy.particle import LibraryType + +import collections +from functools import total_ordering +import re +from typing import Union +import warnings + +DEFAULT_NUCLIDE_WIDTH = 11 +""" +How many characters wide a nuclide with spacing should be. +""" + + +@total_ordering +class Library(SingletonGroup): + """ + A class to represent an MCNP nuclear data library, e.g., ``80c``. + + + Examples + ^^^^^^^^ + + .. testcode:: python + + import montepy + library = montepy.Library("710nc") + assert library.library == "710nc" + assert str(library) == "710nc" + assert library.library_type == montepy.LibraryType.NEUTRON + assert library.number == 710 + assert library.suffix == "c" + + .. Note:: + + This class is immutable, and hashable, meaning it is suitable as a dictionary key. + + .. versionadded:: 1.0.0 + + :param library: The name of the library. + :type library: str + :raises TypeErrror: if a string is not provided. + :raises ValueError: if a valid library is not provided. + """ + + __slots__ = "_library", "_lib_type", "_num", "_suffix" + + _SUFFIX_MAP = { + "c": LibraryType.NEUTRON, + "d": LibraryType.NEUTRON, + "m": LibraryType.NEUTRON, # coupled neutron photon, invokes `g` + "g": LibraryType.PHOTO_ATOMIC, + "p": LibraryType.PHOTO_ATOMIC, + "u": LibraryType.PHOTO_NUCLEAR, + "y": LibraryType.NEUTRON, + "e": LibraryType.ELECTRON, + "h": LibraryType.PROTON, + "o": LibraryType.DEUTERON, + "r": LibraryType.TRITON, + "s": LibraryType.HELION, + "a": LibraryType.ALPHA_PARTICLE, + } + _LIBRARY_RE = re.compile(r"(\d{2,3})[a-z]?([a-z])", re.I) + + def __init__(self, library: str): + self._lib_type = None + self._suffix = "" + self._num = None + if not isinstance(library, str): + raise TypeError(f"library must be a str. {library} given.") + if library: + match = self._LIBRARY_RE.fullmatch(library) + if not match: + raise ValueError(f"Not a valid library. {library} given.") + self._num = int(match.group(1)) + extension = match.group(2).lower() + self._suffix = extension + try: + lib_type = self._SUFFIX_MAP[extension] + except KeyError: + raise ValueError( + f"Not a valid library extension suffix. {library} with extension: {extension} given." + ) + self._lib_type = lib_type + self._library = library + + @property + def library(self) -> str: + """ + The full name of the library. + + :rtype: str + """ + return self._library + + @property + def library_type(self) -> LibraryType: + """ + The :class:`~montepy.particle.LibraryType` of this library. + + This corresponds to the type of library this would specified + in a material definition e.g., ``NLIB``, ``PLIB``, etc. + + .. seealso:: + + * :manual63:`5.6.1` + + :returns: the type of library this library is. + :rtype: LibraryType + """ + return self._lib_type + + @property + def number(self) -> int: + """ + The base number in the library. + + For example: this would be ``80`` for the library: ``Library('80c')``. + + :returns: the base number of the library. + :rtype: int + """ + return self._num + + @property + def suffix(self) -> str: + """ + The suffix of the library, or the final character of its definition. + + For example this would be ``"c"`` for the library: ``Library('80c')``. + + :returns: the suffix of the library. + :rtype: str + """ + return self._suffix + + def __hash__(self): + return hash(self._library.upper()) + + def __eq__(self, other): + if not isinstance(other, (type(self), str)): + raise TypeError(f"Can only compare Library instances.") + if not isinstance(other, type(self)): + return self.library.upper() == other.upper() + # due to SingletonGroup + return self.library.upper() == other.library.upper() + + def __bool__(self): + return bool(self.library) + + def __str__(self): + return self.library + + def __repr__(self): + return f"Library('{self.library}')" + + def __lt__(self, other): + if not isinstance(other, (str, type(self))): + raise TypeError(f"Can only compare Library instances.") + if isinstance(other, str): + other = Library(other) + if self.suffix == other.suffix: + return self.number < other.number + return self.suffix < other.suffix + + def __reduce__(self): + return (self.__class__, (self._library,)) + + +_ZAID_A_ADDER = 1000 +""" +How much to multiply Z by to form a ZAID. +""" + + +class Nucleus(SingletonGroup): + """ + A class to represent a nuclide irrespective of the nuclear data being used. + + This is meant to be an immutable representation of the nuclide, no matter what nuclear data + library is used. ``U-235`` is always ``U-235``. + Generally users don't need to interact with this much as it is almost always wrapped + by: :class:`montepy.data_inputs.nuclide.Nuclide`. + + + .. Note:: + + This class is immutable, and hashable, meaning it is suitable as a dictionary key. + + .. versionadded:: 1.0.0 + + :param element: the element this Nucleus is based on. + :type element: Element + :param A: The A-number (atomic mass) of the nuclide. If this is elemental this should be 0. + :type A: int + :param meta_state: The metastable state if this nuclide is isomer. + :type meta_state: int + + :raises TypeError: if an parameter is the wrong type. + :raises ValueError: if non-sensical values are given. + """ + + __slots__ = "_element", "_A", "_meta_state" + + def __init__( + self, + element: Element, + A: int = 0, + meta_state: int = 0, + ): + if not isinstance(element, Element): + raise TypeError( + f"Only type Element is allowed for element argument. {element} given." + ) + self._element = element + + if not isinstance(A, int): + raise TypeError(f"A number must be an int. {A} given.") + if A < 0: + raise ValueError(f"A cannot be negative. {A} given.") + self._A = A + if not isinstance(meta_state, (int, type(None))): + raise TypeError(f"Meta state must be an int. {meta_state} given.") + if A == 0 and meta_state != 0: + raise ValueError( + f"A metastable elemental state is Non-sensical. A: {A}, meta_state: {meta_state} given." + ) + if meta_state not in range(0, 5): + raise ValueError( + f"Meta state can only be in the range: [0,4]. {meta_state} given." + ) + self._meta_state = meta_state + + @property + def ZAID(self) -> int: + """ + The ZZZAAA identifier following MCNP convention. + + If this is metastable the MCNP convention for ZAIDs for metastable isomers will be used. + + :rtype: int + """ + meta_adder = 300 + 100 * self.meta_state if self.is_metastable else 0 + temp = self.Z * _ZAID_A_ADDER + self.A + meta_adder + if temp in Nuclide._STUPID_ZAID_SWAP: + return Nuclide._STUPID_ZAID_SWAP[temp] + return temp + + @property + def Z(self) -> int: + """ + The Z number for this isotope. + + :returns: the atomic number. + :rtype: int + """ + return self._element.Z + + @make_prop_pointer("_A") + def A(self) -> int: + """ + The A number for this isotope. + + :returns: the isotope's mass. + :rtype: int + """ + pass + + @make_prop_pointer("_element") + def element(self) -> Element: + """ + The base element for this isotope. + + :returns: The element for this isotope. + :rtype: Element + """ + pass + + @property + def is_metastable(self) -> bool: + """ + Whether or not this is a metastable isomer. + + :returns: boolean of if this is metastable. + :rtype: bool + """ + return bool(self._meta_state) + + @make_prop_pointer("_meta_state") + def meta_state(self) -> int: + """ + If this is a metastable isomer, which state is it? + + Can return values in the range [0,4]. The exact state + number is decided by who made the ACE file for this, and not quantum mechanics. + Convention states that the isomers should be numbered from lowest to highest energy. + The ground state will be 0. + + :returns: the metastable isomeric state of this "isotope" in the range [0,4]. + :rtype: int + """ + pass + + def __hash__(self): + return hash((self.element, self.A, self.meta_state)) + + def __eq__(self, other): + if not isinstance(other, type(self)): + raise TypeError( + f"Nucleus can only be compared to a Nucleus. {other} of type {type(other)} given." + ) + # due to SingletonGroup + return ( + self.element == other.element + and self.A == other.A + and self.meta_state == other.meta_state + ) + + def __reduce__(self): + return (type(self), (self.element, self.A, self._meta_state)) + + def __lt__(self, other): + if not isinstance(other, type(self)): + raise TypeError("") + return (self.Z, self.A, self.meta_state) < (other.Z, other.A, other.meta_state) + + def __str__(self): + meta_suffix = f"m{self.meta_state}" if self.is_metastable else "" + return f"{self.element.symbol:>2}-{self.A:<3}{meta_suffix:<2}" + + def __repr__(self): + return f"Nucleus({self.element}, {self.A}, {self.meta_state})" + + +class Nuclide: + r""" + A class to represent an MCNP nuclide with nuclear data library information. + + Nuclide accepts ``name`` as a way of specifying a nuclide. + This is meant to be more ergonomic than ZAIDs while not going insane with possible formats. + This accepts ZAID and Atomic_symbol-A format. + All cases support metastables as m# and a library specification. + Examples include: + + * ``1001.80c`` + * ``92235m1.80c`` + * ``92635.80c`` + * ``U.80c`` + * ``U-235.80c`` + * ``U-235m1.80c`` + + To be specific this must match the regular expression: + + .. code-block:: + + import re + parser = re.compile(\"\"\" + (\d{4,6}) # ZAID + | + ([a-z]{1,2} # or atomic symbol + -?\d*) # optional A-number + (m\d+)? # optional metastable + (\.\d{{2,}}[a-z]+)? # optional library + \"\"\", + re.IGNORE_CASE | re.VERBOSE + ) + + .. Note:: + + As discussed in :manual63:`5.6.1`: + + To represent a metastable isotope, adjust the AAA value using the + following convention: AAA’=(AAA+300)+(m × 100), where m is the + metastable level and m=1, 2, 3, or 4. + + MontePy attempts to apply these rules to determine the isomeric state of the nuclide. + This requires MontePy to determine if a ZAID is a realistic base isomeric state. + + This is done simply by manually specifying 6 rectangles of realistic ZAIDs. + MontePy checks if a ZAID is inside of these rectangles. + These rectangles are defined by their upper right corner as an isotope. + The lower left corner is defined by the Z-number of the previous isotope and A=0. + + These isotopes are: + + * Cl-52 + * Br-101 + * Xe-150 + * Os-203 + * Cm-251 + * Og-296 + + .. Warning:: + + Due to legacy reasons the nuclear data for Am-242 and Am-242m1 have been swapped for the nuclear data + provided by LANL. + This is documented in `section 1.2.2 of the MCNP 6.3.1 manual `_ : + + As a historical quirk, 242m1Am and 242Am are swapped in the ZAID and SZAID formats, so that the + former is 95242 and the latter is 95642 for ZAID and 1095242 for SZAID. It is important to verify if a + data library follows this convention. To date, all LANL-published libraries do. The name format does + not swap these isomers. As such, Am-242m1 can load a table labeled 95242. + + Due to this MontePy follows the MCNP convention, and swaps these ZAIDs. + If you have custom generated ACE data for Am-242, + that does not follow this convention you have a few options: + + #. Do nothing. If you do not need to modify a material in an MCNP input file the ZAID will be written out the same as it was in the original file. + + #. Specify the Nucleus by ZAID. This will have the same effect as before. Note that MontePy will display the wrong metastable state, but will preserve the ZAID. + + #. Open an issue. If this approach doesn't work for you please open an issue so we can develop a better solution. + + .. seealso:: + + * :manual62:`107` + * :manual63:`5.6.1` + + .. versionadded:: 1.0.0 + + This was added as replacement for ``montepy.data_inputs.Isotope``. + + + + :param name: A fancy name way of specifying a nuclide. + :type name: str + :param ZAID: The ZAID in MCNP format, the library can be included. + :type ZAID: str + :param element: the element this Nucleus is based on. + :type element: Element + :param Z: The Z-number (atomic number) of the nuclide. + :type Z: int + :param A: The A-number (atomic mass) of the nuclide. If this is elemental this should be 0. + :type A: int + :param meta_state: The metastable state if this nuclide is isomer. + :type meta_state: int + :param library: the library to use for this nuclide. + :type library: str + :param node: The ValueNode to build this off of. Should only be used by MontePy. + :type node: ValueNode + + :raises TypeError: if an parameter is the wrong type. + :raises ValueError: if non-sensical values are given. + """ + + _NAME_PARSER = re.compile( + rf"""( + (?P\d{{4,6}})| + ((?P[a-z]{{1,{MAX_ATOMIC_SYMBOL_LENGTH}}})-?(?P\d*)) + ) + (m(?P\d+))? + (\.(?P\d{{2,}}[a-z]+))?""", + re.I | re.VERBOSE, + ) + """ + Parser for fancy names. + """ + + # Cl-52 Br-101 Xe-150 Os-203 Cm-251 Og-296 + _BOUNDING_CURVE = [(17, 52), (35, 101), (54, 150), (76, 203), (96, 251), (118, 296)] + """ + Points on bounding curve for determining if "valid" isotope + """ + _STUPID_MAP = { + "95642": {"_meta_state": 0}, + "95242": {"_meta_state": 1}, + } + _STUPID_ZAID_SWAP = {95242: 95642, 95642: 95242} + + def __init__( + self, + name: Union[str, int, Element, Nucleus] = "", + element: Element = None, + Z: int = None, + A: int = 0, + meta_state: int = 0, + library: str = "", + node: ValueNode = None, + ): + self._library = Library("") + ZAID = "" + + if not isinstance(name, (str, int, Element, Nucleus, Nuclide, type(None))): + raise TypeError( + f"Name must be str, int, Element, or Nucleus. {name} of type {type(name)} given." + ) + if name: + element, A, meta_state, new_library = self._parse_fancy_name(name) + # give library precedence always + if library == "": + library = new_library + if node is not None and isinstance(node, ValueNode): + if node.type == float: + node = ValueNode(node.token, str, node.padding) + self._tree = node + ZAID = node.value + parts = ZAID.split(".") + if ZAID: + za_info = self._parse_zaid(int(parts[0])) + element = za_info["_element"] + A = za_info["_A"] + meta_state = za_info["_meta_state"] + if Z: + element = Element(Z) + if element is None: + raise ValueError( + "no elemental information was provided via name, element, or z. " + f"Given: name: {name}, element: {element}, Z: {Z}" + ) + self._nucleus = Nucleus(element, A, meta_state) + if len(parts) > 1 and library == "": + library = parts[1] + if not isinstance(library, str): + raise TypeError(f"Library can only be str. {library} given.") + self._library = Library(library) + if not node: + padding_num = DEFAULT_NUCLIDE_WIDTH - len(self.mcnp_str()) + if padding_num < 1: + padding_num = 1 + self._tree = ValueNode(self.mcnp_str(), str, PaddingNode(" " * padding_num)) + + @classmethod + def _handle_stupid_legacy_stupidity(cls, ZAID): + """ + This handles legacy issues where ZAID are swapped. + + For now this is only for Am-242 and Am-242m1. + + .. seealso:: + + * :manual631:`1.2.2` + """ + ZAID = str(ZAID) + ret = {} + if ZAID in cls._STUPID_MAP: + stupid_overwrite = cls._STUPID_MAP[ZAID] + for key, value in stupid_overwrite.items(): + ret[key] = value + return ret + + @classmethod + def _parse_zaid(cls, ZAID) -> dict[str, object]: + """ + Parses the ZAID fully including metastable isomers. + + See Table 3-32 of LA-UR-17-29881 + + :param ZAID: the ZAID without the library + :type ZAID: int + :returns: a dictionary with the parsed information, + in a way that can be loaded into nucleus. Keys are: _element, _A, _meta_state + :rtype: dict[str, Object] + """ + + def is_probably_an_isotope(Z, A): + for lim_Z, lim_A in cls._BOUNDING_CURVE: + if Z <= lim_Z: + if A <= lim_A: + return True + else: + return False + else: + continue + # if you are above Og it's probably legit. + # to reach this state requires new elements to be discovered. + return True # pragma: no cover + + ret = {} + Z = int(ZAID / _ZAID_A_ADDER) + ret["_element"] = Element(Z) + ret["_A"] = 0 + ret["_meta_state"] = 0 + A = int(ZAID % _ZAID_A_ADDER) + ret["_A"] = A + if not is_probably_an_isotope(Z, A): + true_A = A - 300 + # only m1,2,3,4 allowed + found = False + for i in range(1, 5): + true_A -= 100 + # assumes that can only vary 40% from A = 2Z + if is_probably_an_isotope(Z, true_A): + found = True + break + if found: + ret["_meta_state"] = i + ret["_A"] = true_A + else: + raise ValueError( + f"ZAID: {ZAID} cannot be parsed as a valid metastable isomer. " + "Only isomeric state 0 - 4 are allowed" + ) + + ret.update(cls._handle_stupid_legacy_stupidity(ZAID)) + return ret + + @property + def ZAID(self) -> int: + """ + The ZZZAAA identifier following MCNP convention + + :rtype: int + """ + # if this is made mutable this cannot be user provided, but must be calculated. + return self._nucleus.ZAID + + @property + def Z(self) -> int: + """ + The Z number for this isotope. + + :returns: the atomic number. + :rtype: int + """ + return self._nucleus.Z + + @property + def A(self) -> int: + """ + The A number for this isotope. + + :returns: the isotope's mass. + :rtype: int + """ + return self._nucleus.A + + @property + def element(self) -> Element: + """ + The base element for this isotope. + + :returns: The element for this isotope. + :rtype: Element + """ + return self._nucleus.element + + @make_prop_pointer("_nucleus") + def nucleus(self) -> Nucleus: + """ + The base nuclide of this nuclide without the nuclear data library. + + :rtype:Nucleus + """ + pass + + @property + def is_metastable(self) -> bool: + """ + Whether or not this is a metastable isomer. + + :returns: boolean of if this is metastable. + :rtype: bool + """ + return self._nucleus.is_metastable + + @property + def meta_state(self) -> int: + """ + If this is a metastable isomer, which state is it? + + Can return values in the range [0,4]. 0 corresponds to the ground state. + The exact state number is decided by who made the ACE file for this, and not quantum mechanics. + Convention states that the isomers should be numbered from lowest to highest energy. + + :returns: the metastable isomeric state of this "isotope" in the range [0,4]l + :rtype: int + """ + return self._nucleus.meta_state + + @make_prop_pointer("_library", (str, Library), Library) + def library(self) -> Library: + """ + The MCNP library identifier e.g. 80c + + :rtype: Library + """ + pass + + def mcnp_str(self) -> str: + """ + Returns an MCNP formatted representation. + + E.g., 1001.80c + + :returns: a string that can be used in MCNP + :rtype: str + """ + return f"{self.ZAID}.{self.library}" if str(self.library) else str(self.ZAID) + + def nuclide_str(self) -> str: + """ + Creates a human readable version of this nuclide excluding the data library. + + This is of the form Atomic symbol - A [metastable state]. e.g., ``U-235m1``. + + :rtypes: str + """ + meta_suffix = f"m{self.meta_state}" if self.is_metastable else "" + suffix = f".{self._library}" if str(self._library) else "" + return f"{self.element.symbol}-{self.A}{meta_suffix}{suffix}" + + def get_base_zaid(self) -> int: + """ + Get the ZAID identifier of the base isotope this is an isomer of. + + This is mostly helpful for working with metastable isomers. + + :returns: the mcnp ZAID of the ground state of this isotope. + :rtype: int + """ + return self.Z * _ZAID_A_ADDER + self.A + + @classmethod + def _parse_fancy_name(cls, identifier): + """ + Parses a fancy name that is a ZAID, a Symbol-A, or nucleus, nuclide, or element. + + :param identifier: + :type idenitifer: Union[str, int, element, Nucleus, Nuclide] + :returns: a tuple of element, a, isomer, library + :rtype: tuple + """ + if isinstance(identifier, (Nucleus, Nuclide)): + if isinstance(identifier, Nuclide): + lib = identifier.library + else: + lib = "" + return (identifier.element, identifier.A, identifier.meta_state, str(lib)) + if isinstance(identifier, Element): + element = identifier + return (element, 0, 0, "") + A = 0 + isomer = 0 + library = "" + if isinstance(identifier, (int, float)): + if identifier > _ZAID_A_ADDER: + parts = Nuclide._parse_zaid(int(identifier)) + element, A, isomer = ( + parts["_element"], + parts["_A"], + parts["_meta_state"], + ) + else: + element = Element(int(identifier)) + elif isinstance(identifier, str): + if match := cls._NAME_PARSER.fullmatch(identifier): + match = match.groupdict() + if match["ZAID"]: + parts = cls._parse_zaid(int(match["ZAID"])) + element, A, isomer = ( + parts["_element"], + parts["_A"], + parts["_meta_state"], + ) + else: + element_name = match["element"] + element = Element.get_by_symbol(element_name.capitalize()) + if match["A"]: + A = int(match["A"]) + if match["meta"]: + extra_isomer = int(match["meta"]) + isomer += extra_isomer + if match["library"]: + library = match["library"] + else: + raise ValueError(f"Not a valid nuclide identifier. {identifier} given") + + return (element, A, isomer, library) + + def __repr__(self): + return f"{self.__class__.__name__}({repr(self.nuclide_str())})" + + def __str__(self): + meta_suffix = f"m{self.meta_state}" if self.is_metastable else "" + suffix = f" ({self._library})" if str(self._library) else "()" + return f"{self.element.symbol:>2}-{self.A:<3}{meta_suffix:<2}{suffix:>5}" + + def __eq__(self, other): + if not isinstance(other, type(self)): + raise TypeError( + f"Cannot compare Nuclide to other values. {other} of type {type(other)}." + ) + return self.nucleus == other.nucleus and self.library == other.library + + def __lt__(self, other): + if not isinstance(other, type(self)): + raise TypeError( + f"Cannot compare Nuclide to other values. {other} of type {type(other)}." + ) + return (self.nucleus, self.library) < (other.nucleus, other.library) + + def __format__(self, format_str): + return str(self).__format__(format_str) diff --git a/montepy/data_inputs/thermal_scattering.py b/montepy/data_inputs/thermal_scattering.py index c52b083a..f2879b51 100644 --- a/montepy/data_inputs/thermal_scattering.py +++ b/montepy/data_inputs/thermal_scattering.py @@ -1,10 +1,12 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. -from montepy.data_inputs.data_input import DataInputAbstract +from __future__ import annotations + +import montepy +from montepy.data_inputs.data_input import DataInputAbstract, InitInput from montepy.input_parser.thermal_parser import ThermalParser from montepy import mcnp_object from montepy.errors import * from montepy.utilities import * -import montepy class ThermalScatteringLaw(DataInputAbstract): @@ -15,15 +17,20 @@ class ThermalScatteringLaw(DataInputAbstract): The first is with a read input file using input_card, comment The second is after a read with a material and a comment (using named inputs) + .. seealso:: + + * :manual63:`5.6.2` + * :manual62:`110` + :param input: the Input object representing this data input - :type input: Input + :type input: Union[Input, str] :param material: the parent Material object that owns this :type material: Material """ _parser = ThermalParser() - def __init__(self, input="", material=None): + def __init__(self, input: InitInput = "", material: montepy.Material = None): self._old_number = self._generate_default_node(int, -1) self._parent_material = None self._scattering_laws = [] diff --git a/montepy/data_inputs/transform.py b/montepy/data_inputs/transform.py index d4917642..9658c806 100644 --- a/montepy/data_inputs/transform.py +++ b/montepy/data_inputs/transform.py @@ -1,31 +1,46 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations import copy +import numpy as np +import re +from typing import Union + +import montepy from montepy import mcnp_object from montepy.data_inputs import data_input from montepy.errors import * -from montepy.numbered_mcnp_object import Numbered_MCNP_Object +from montepy.numbered_mcnp_object import Numbered_MCNP_Object, InitInput from montepy.utilities import * -import numpy as np -import re class Transform(data_input.DataInputAbstract, Numbered_MCNP_Object): """ Input to represent a transform input (TR). - :param input: The Input syntax object this will wrap and parse. - :type input: Input + .. versionchanged:: 1.0.0 + + Added number parameter + + :param input: The Input object representing the input + :type input: Union[Input, str] + :param number: The number to set for this object. + :type number: int """ - def __init__(self, input=None, pass_through=False): + def __init__( + self, + input: InitInput = None, + pass_through: bool = False, + number: int = None, + ): self._pass_through = pass_through - self._number = self._generate_default_node(int, -1) self._old_number = self._generate_default_node(int, -1) self._displacement_vector = np.array([]) self._rotation_matrix = np.array([]) self._is_in_degrees = False self._is_main_to_aux = True super().__init__(input) + self._load_init_num(number) if input: words = self._tree["data"] i = 0 diff --git a/montepy/data_inputs/universe_input.py b/montepy/data_inputs/universe_input.py index f189f058..923b59c6 100644 --- a/montepy/data_inputs/universe_input.py +++ b/montepy/data_inputs/universe_input.py @@ -1,6 +1,6 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. import itertools -from montepy.data_inputs.cell_modifier import CellModifierInput +from montepy.data_inputs.cell_modifier import CellModifierInput, InitInput from montepy.errors import * from montepy.constants import DEFAULT_VERSION from montepy.input_parser.mcnp_input import Jump @@ -16,7 +16,7 @@ class UniverseInput(CellModifierInput): and data blocks. :param input: the Input object representing this data input - :type input: Input + :type input: Union[Input, str] :param in_cell_block: if this card came from the cell block of an input file. :type in_cell_block: bool :param key: the key from the key-value pair in a cell @@ -25,7 +25,13 @@ class UniverseInput(CellModifierInput): :type value: SyntaxNode """ - def __init__(self, input=None, in_cell_block=False, key=None, value=None): + def __init__( + self, + input: InitInput = None, + in_cell_block: bool = False, + key: str = None, + value: syntax_node.SyntaxNode = None, + ): self._universe = None self._old_numbers = [] self._old_number = self._generate_default_node(int, Jump()) @@ -42,10 +48,7 @@ def __init__(self, input=None, in_cell_block=False, key=None, value=None): for node in self.data: try: node.is_negatable_identifier = True - if node.value is not None: - self._old_numbers.append(node) - else: - self._old_numbers.append(node) + self._old_numbers.append(node) except ValueError: raise MalformedInputError( input, diff --git a/montepy/input_parser/cell_parser.py b/montepy/input_parser/cell_parser.py index 65d347e6..f9b4ed5c 100644 --- a/montepy/input_parser/cell_parser.py +++ b/montepy/input_parser/cell_parser.py @@ -8,9 +8,6 @@ class CellParser(MCNP_Parser): """ The parser for parsing a Cell input. - .. versionadded:: 0.2.0 - This was added with the major parser rework. - :returns: a syntax tree of the cell. :rtype: SyntaxNode """ diff --git a/montepy/input_parser/data_parser.py b/montepy/input_parser/data_parser.py index 57635454..25fa98f7 100644 --- a/montepy/input_parser/data_parser.py +++ b/montepy/input_parser/data_parser.py @@ -9,9 +9,6 @@ class DataParser(MCNP_Parser): """ A parser for almost all data inputs. - .. versionadded:: 0.2.0 - This was added with the major parser rework. - :returns: a syntax tree for the data input. :rtype: SyntaxNode """ @@ -74,8 +71,8 @@ def isotope_fractions(self, p): if hasattr(p, "isotope_fractions"): fractions = p.isotope_fractions else: - fractions = syntax_node.IsotopesNode("isotope list") - fractions.append(p.isotope_fraction) + fractions = syntax_node.MaterialsNode("isotope list") + fractions.append_nuclide(p.isotope_fraction) return fractions @_("ZAID", "ZAID padding") @@ -148,9 +145,6 @@ class ClassifierParser(DataParser): """ A parser for parsing the first word or classifier of a data input. - .. versionadded:: 0.2.0 - This was added with the major parser rework. - :returns: the classifier of the data input. :rtype: ClassifierNode """ diff --git a/montepy/input_parser/input_reader.py b/montepy/input_parser/input_reader.py index 4c508a01..3cc438af 100644 --- a/montepy/input_parser/input_reader.py +++ b/montepy/input_parser/input_reader.py @@ -1,5 +1,5 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. -from montepy import mcnp_problem +import montepy from montepy.constants import DEFAULT_VERSION @@ -26,7 +26,7 @@ def read_input(destination, mcnp_version=DEFAULT_VERSION, replace=True): :raises BrokenObjectLinkError: If a reference is made to an object that is not in the input file. :raises UnknownElement: If an isotope is specified for an unknown element. """ - problem = mcnp_problem.MCNP_Problem(destination) + problem = montepy.mcnp_problem.MCNP_Problem(destination) problem.mcnp_version = mcnp_version problem.parse_input(replace=replace) return problem diff --git a/montepy/input_parser/input_syntax_reader.py b/montepy/input_parser/input_syntax_reader.py index 86a7a129..086ca917 100644 --- a/montepy/input_parser/input_syntax_reader.py +++ b/montepy/input_parser/input_syntax_reader.py @@ -59,9 +59,6 @@ def read_front_matters(fh, mcnp_version): .. warning:: This function will not close the file handle. - .. versionchanged:: 0.2.0 - ``fh`` was changed to be an MCNP_InputFile to hold more information. - :param fh: The file handle of the input file. :type fh: MCNP_InputFile :param mcnp_version: The version of MCNP that the input is intended for. @@ -105,9 +102,6 @@ def read_data(fh, mcnp_version, block_type=None, recursion=False): .. warning:: This function will not close the file handle. - .. versionchanged:: 0.2.0 - ``file_wrapper`` was added to better track which file is being read. - :param fh: The file handle of the input file. :type fh: MCNP_InputFile :param mcnp_version: The version of MCNP that the input is intended for. diff --git a/montepy/input_parser/material_parser.py b/montepy/input_parser/material_parser.py index 1a932188..82ce7d29 100644 --- a/montepy/input_parser/material_parser.py +++ b/montepy/input_parser/material_parser.py @@ -9,36 +9,36 @@ class MaterialParser(DataParser): debugfile = None @_( - "introduction isotopes", - "introduction isotopes parameters", - "introduction isotopes mat_parameters", + "classifier_phrase mat_data", + "padding classifier_phrase mat_data", ) def material(self, p): ret = {} - for key, node in p.introduction.nodes.items(): - ret[key] = node - ret["data"] = p.isotopes - if len(p) > 2: - ret["parameters"] = p[2] + if isinstance(p[0], syntax_node.PaddingNode): + ret["start_pad"] = p.padding + else: + ret["start_pad"] = syntax_node.PaddingNode() + ret["classifier"] = p.classifier_phrase + ret["data"] = p.mat_data return syntax_node.SyntaxNode("data", ret) - @_("isotope_fractions", "number_sequence", "isotope_hybrid_fractions") - def isotopes(self, p): - if hasattr(p, "number_sequence"): - return self._convert_to_isotope(p.number_sequence) - return p[0] - - @_("number_sequence isotope_fraction", "isotope_hybrid_fractions isotope_fraction") - def isotope_hybrid_fractions(self, p): - if hasattr(p, "number_sequence"): - ret = self._convert_to_isotope(p.number_sequence) + @_("mat_datum", "mat_data mat_datum") + def mat_data(self, p): + if len(p) == 1: + ret = syntax_node.MaterialsNode("mat stuff") else: - ret = p[0] - ret.append(p.isotope_fraction) + ret = p.mat_data + datum = p.mat_datum + if isinstance(datum, tuple): + ret.append_nuclide(datum) + elif isinstance(datum, syntax_node.ListNode): + [ret.append_nuclide(n) for n in self._convert_to_isotope(datum)] + else: + ret.append_param(datum) return ret def _convert_to_isotope(self, old): - new_list = syntax_node.IsotopesNode("converted isotopes") + new_list = [] def batch_gen(): it = iter(old) @@ -49,27 +49,9 @@ def batch_gen(): new_list.append(("foo", *group)) return new_list - @_( - "mat_parameter", - "parameter", - "mat_parameters mat_parameter", - "mat_parameters parameter", - ) - def mat_parameters(self, p): - """ - A list of the parameters (key, value pairs) that allows material libraries. - - :returns: all parameters - :rtype: ParametersNode - """ - if len(p) == 1: - params = syntax_node.ParametersNode() - param = p[0] - else: - params = p[0] - param = p[1] - params.append(param) - return params + @_("isotope_fraction", "number_sequence", "parameter", "mat_parameter") + def mat_datum(self, p): + return p[0] @_( "classifier param_seperator library", diff --git a/montepy/input_parser/mcnp_input.py b/montepy/input_parser/mcnp_input.py index 700c034e..f3380a1a 100644 --- a/montepy/input_parser/mcnp_input.py +++ b/montepy/input_parser/mcnp_input.py @@ -69,9 +69,6 @@ class ParsingNode(ABC): """ Object to represent a single coherent MCNP input, such as an input. - .. versionadded:: 0.2.0 - This was added as part of the parser rework. - :param input_lines: the lines read straight from the input file. :type input_lines: list """ @@ -110,30 +107,10 @@ def format_for_mcnp_input(self, mcnp_version): pass -class Card(ParsingNode): # pragma: no cover - """ - .. warning:: - - .. deprecated:: 0.2.0 - Punch cards are dead. Use :class:`~montepy.input_parser.mcnp_input.Input` instead. - - :raises DeprecatedError: punch cards are dead. - """ - - def __init__(self, *args, **kwargs): - raise DeprecatedError( - "This has been deprecated. Use montepy.input_parser.mcnp_input.Input instead" - ) - - class Input(ParsingNode): """ Represents a single MCNP "Input" e.g. a single cell definition. - .. versionadded:: 0.2.0 - This was added as part of the parser rework, and rename. - This was a replacement for :class:`Card`. - :param input_lines: the lines read straight from the input file. :type input_lines: list :param block_type: An enum showing which of three MCNP blocks this was inside of. @@ -247,35 +224,6 @@ def lexer(self): """ pass - @property - def words(self): # pragma: no cover - """ - .. warning:: - .. deprecated:: 0.2.0 - - This has been deprecated, and removed. - - :raises DeprecationWarning: use the parser and tokenize workflow instead. - """ - raise DeprecationWarning( - "This has been deprecated. Use a parser and tokenize instead" - ) - - -class Comment(ParsingNode): # pragma: no cover - """ - .. warning:: - .. deprecated:: 0.2.0 - This has been replaced by :class:`~montepy.input_parser.syntax_node.CommentNode`. - - :raises DeprecationWarning: Can not be created anymore. - """ - - def __init__(self, *args, **kwargs): - raise DeprecationWarning( - "This has been deprecated and replaced by montepy.input_parser.syntax_node.CommentNode." - ) - class ReadInput(Input): """ @@ -335,22 +283,6 @@ def __repr__(self): ) -class ReadCard(Card): # pragma: no cover - """ - .. warning:: - - .. deprecated:: 0.2.0 - Punch cards are dead. Use :class:`~montepy.input_parser.mcnp_input.ReadInput` instead. - - :raises DeprecatedError: punch cards are dead. - """ - - def __init__(self, *args, **kwargs): - raise DeprecatedError( - "This has been deprecated. Use montepy.input_parser.mcnp_input.ReadInput instead" - ) - - class Message(ParsingNode): """ Object to represent an MCNP message. @@ -444,16 +376,3 @@ def format_for_mcnp_input(self, mcnp_version): line_length = 0 line_length = get_max_line_length(mcnp_version) return [self.title[0 : line_length - 1]] - - -def parse_card_shortcuts(*args, **kwargs): # pragma: no cover - """ - .. warning:: - .. deprecated:: 0.2.0 - This is no longer necessary and should not be called. - - :raises DeprecationWarning: This is not needed anymore. - """ - raise DeprecationWarning( - "This is deprecated and unnecessary. This will be automatically handled by montepy.input_parser.parser_base.MCNP_Parser." - ) diff --git a/montepy/input_parser/parser_base.py b/montepy/input_parser/parser_base.py index d725cc24..471e2626 100644 --- a/montepy/input_parser/parser_base.py +++ b/montepy/input_parser/parser_base.py @@ -12,9 +12,6 @@ class MetaBuilder(sly.yacc.ParserMeta): Custom MetaClass for allowing subclassing of MCNP_Parser. - .. versionadded:: 0.2.0 - This was added with the major parser rework. - Note: overloading functions is not allowed. """ @@ -57,9 +54,6 @@ def _flatten_rules(classname, basis, attributes): class SLY_Supressor: """ This is a fake logger meant to mostly make warnings dissapear. - - .. versionadded:: 0.2.0 - This was added with the major parser rework. """ def __init__(self): @@ -111,9 +105,6 @@ def __len__(self): class MCNP_Parser(Parser, metaclass=MetaBuilder): """ Base class for all MCNP parsers that provides basics. - - .. versionadded:: 0.2.0 - This was added with the major parser rework. """ # Remove this if trying to see issues with parser diff --git a/montepy/input_parser/read_parser.py b/montepy/input_parser/read_parser.py index ab46c302..a106f7f9 100644 --- a/montepy/input_parser/read_parser.py +++ b/montepy/input_parser/read_parser.py @@ -6,9 +6,6 @@ class ReadParser(MCNP_Parser): """ A parser for handling "read" inputs. - - .. versionadded:: 0.2.0 - This was added with the major parser rework. """ debugfile = None diff --git a/montepy/input_parser/shortcuts.py b/montepy/input_parser/shortcuts.py index 14237101..f6aabe48 100644 --- a/montepy/input_parser/shortcuts.py +++ b/montepy/input_parser/shortcuts.py @@ -5,9 +5,6 @@ class Shortcuts(Enum): """ Enumeration of the possible MCNP shortcuts. - - .. versionadded:: 0.2.0 - This was added with the major parser rework. """ REPEAT = "r" diff --git a/montepy/input_parser/surface_parser.py b/montepy/input_parser/surface_parser.py index f4fc7d3c..fd6f8414 100644 --- a/montepy/input_parser/surface_parser.py +++ b/montepy/input_parser/surface_parser.py @@ -8,9 +8,6 @@ class SurfaceParser(MCNP_Parser): """ A parser for MCNP surfaces. - .. versionadded:: 0.2.0 - This was added with the major parser rework. - :rtype: SyntaxNode """ diff --git a/montepy/input_parser/syntax_node.py b/montepy/input_parser/syntax_node.py index 8d92d961..af8d6c78 100644 --- a/montepy/input_parser/syntax_node.py +++ b/montepy/input_parser/syntax_node.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod import collections import copy +import itertools as it import enum import math @@ -24,9 +25,6 @@ class SyntaxNodeBase(ABC): A syntax node is any component of the syntax tree for a parsed input. - .. versionadded:: 0.2.0 - This was added with the major parser rework. - :param name: a name for labeling this node. :type name: str """ @@ -195,6 +193,19 @@ def flatten(self): ret += node.flatten() return ret + def _pretty_str(self): + INDENT = 2 + if not self.nodes: + return f"" + ret = f"" def __repr__(self): return str(self) @@ -310,14 +318,23 @@ def flatten(self): ret += node.flatten() return ret + def _pretty_str(self): + INDENT = 2 + ret = f"" ) + def _pretty_str(self): + INDENT = 2 + ret = f"" def __repr__(self): return str(self) @@ -742,9 +777,6 @@ class CommentNode(SyntaxNodeBase): """ Object to represent a comment in an MCNP problem. - .. versionadded:: 0.2.0 - This was added with the major parser rework. - :param input: the token from the lexer :type input: Token """ @@ -842,6 +874,9 @@ def comments(self): def __str__(self): return self.format() + def _pretty_str(self): + return str(self) + def __repr__(self): ret = f"COMMENT: " for node in self.nodes: @@ -859,9 +894,6 @@ class ValueNode(SyntaxNodeBase): This stores the original input token, the current value, and the possible associated padding. - .. versionadded:: 0.2.0 - This was added with the major parser rework. - :param token: the original token for the ValueNode. :type token: str :param token_type: the type for the ValueNode. @@ -1074,7 +1106,7 @@ def _reverse_engineer_formatting(self): delta -= 1 if token.startswith("+"): self._formatter["sign"] = "+" - if token.startswith("-"): + if token.startswith("-") and not self.never_pad: self._formatter["sign"] = " " if delta > 0: self._formatter["zero_padding"] = length @@ -1295,7 +1327,10 @@ def token(self): return self._token def __str__(self): - return f"(Value, {self._value}, padding: {self._padding})" + return f"" + + def _pretty_str(self): + return str(self) def __repr__(self): return str(self) @@ -1365,9 +1400,6 @@ class ParticleNode(SyntaxNodeBase): """ A node to hold particles information in a :class:`ClassifierNode`. - .. versionadded:: 0.2.0 - This was added with the major parser rework. - :param name: the name for the node. :type name: str :param token: the original token from parsing @@ -1501,9 +1533,6 @@ class ListNode(SyntaxNodeBase): """ A node to represent a list of values. - .. versionadded:: 0.2.0 - This was added with the major parser rework. - :param name: the name of this node. :type name: str """ @@ -1746,14 +1775,17 @@ def __eq__(self, other): return True -class IsotopesNode(SyntaxNodeBase): +class MaterialsNode(SyntaxNodeBase): """ - A node for representing isotopes and their concentration. + A node for representing isotopes and their concentration, + and the material parameters. - This stores a list of tuples of ZAIDs and concentrations. + This stores a list of tuples of ZAIDs and concentrations, + or a tuple of a parameter. - .. versionadded:: 0.2.0 - This was added with the major parser rework. + .. versionadded:: 1.0.0 + + This was added as a more general version of ``IsotopesNodes``. :param name: a name for labeling this node. :type name: str @@ -1762,9 +1794,13 @@ class IsotopesNode(SyntaxNodeBase): def __init__(self, name): super().__init__(name) - def append(self, isotope_fraction): + def append_nuclide(self, isotope_fraction): """ - Append the node to this node. + Append the isotope fraction to this node. + + .. versionadded:: 1.0.0 + + Added to replace ``append`` :param isotope_fraction: the isotope_fraction to add. This must be a tuple from A Yacc production. This will consist of: the string identifying the Yacc production, @@ -1774,14 +1810,41 @@ def append(self, isotope_fraction): isotope, concentration = isotope_fraction[1:3] self._nodes.append((isotope, concentration)) + def append(self): # pragma: no cover + raise DeprecationWarning("Deprecated. Use append_param or append_nuclide") + + def append_param(self, param): + """ + Append the parameter to this node. + + .. versionadded:: 1.0.0 + + Added to replace ``append`` + + :param param: the parameter to add to this node. + :type param: ParametersNode + """ + self._nodes.append((param,)) + def format(self): ret = "" - for isotope, concentration in self.nodes: - ret += isotope.format() + concentration.format() + for node in it.chain(*self.nodes): + ret += node.format() return ret def __repr__(self): - return f"(Isotopes: {self.nodes})" + return f"(Materials: {self.nodes})" + + def _pretty_str(self): + INDENT = 2 + ret = f" +""" + @property def comments(self): if self.padding is not None: @@ -2433,9 +2501,6 @@ class ParametersNode(SyntaxNodeBase): This behaves like a dictionary and is accessible by their key* - .. versionadded:: 0.2.0 - This was added with the major parser rework. - .. Note:: How to access values. @@ -2485,11 +2550,23 @@ def append(self, val, is_default=False): self._nodes[key] = val def __str__(self): - return f"(Parameters, {self.nodes})" + return f"" def __repr__(self): return str(self) + def _pretty_str(self): + INDENT = 2 + ret = f" Generator[Material]: + """ + Get all materials that contain these nuclides. + + This uses :func:`~montepy.data_inputs.material.Material.contains` under the hood. + See that documentation for more guidance. + + Examples + ^^^^^^^^ + + One example would to be find all water bearing materials: + + .. testcode:: + + import montepy + problem = montepy.read_input("foo.imcnp") + for mat in problem.materials.get_containing("H-1", "O-16", threshold = 0.3): + print(mat) + + .. testoutput:: + + MATERIAL: 1, ['hydrogen', 'oxygen'] + + .. versionadded:: 1.0.0 + + :param nuclide: the first nuclide to check for. + :type nuclide: Union[Nuclide, Nucleus, Element, str, int] + :param args: a plurality of other nuclides to check for. + :type args: Union[Nuclide, Nucleus, Element, str, int] + :param threshold: the minimum concentration of a nuclide to be considered. The material components are not + first normalized. + :type threshold: float + + :return: A generator of all matching materials + :rtype: Generator[Material] + + :raises TypeError: if any argument is of the wrong type. + :raises ValueError: if the fraction is not positive or zero, or if nuclide cannot be interpreted as a Nuclide. + """ + nuclides = [] + for nuclide in [nuclide] + list(args): + if not isinstance( + nuclide, + ( + str, + int, + montepy.Element, + montepy.data_inputs.nuclide.Nucleus, + montepy.Nuclide, + ), + ): + raise TypeError( + f"nuclide must be of type str, int, Element, Nucleus, or Nuclide. " + f"{nuclide} of type {type(nuclide)} given." + ) + if isinstance(nuclide, (str, int)): + nuclide = montepy.Nuclide(nuclide) + nuclides.append(nuclide) + + def sort_by_type(nuclide): + type_map = { + montepy.data_inputs.element.Element: 0, + montepy.data_inputs.nuclide.Nucleus: 1, + montepy.data_inputs.nuclide.Nuclide: 2, + } + return type_map[type(nuclide)] + + # optimize by most hashable and fail fast + nuclides = sorted(nuclides, key=sort_by_type) + for material in self: + if material.contains(*nuclides, threshold=threshold): + # maybe? Maybe not? + # should Materials act like a set? + yield material + + @property + def default_libraries(self) -> dict[montepy.LibraryType, montepy.Library]: + """ + The default libraries for this problem defined by ``M0``. + + + Examples + ^^^^^^^^ + + To set the default libraries for a problem you need to set this dictionary + to a Library or string. + + .. testcode:: python + + import montepy + problem = montepy.read_input("foo.imcnp") + + # set neutron default to ENDF/B-VIII.0 + problem.materials.default_libraries["nlib"] = "00c" + # set photo-atomic + problem.materials.default_libraries[montepy.LibraryType.PHOTO_ATOMIC] = montepy.Library("80p") + + .. versionadded:: 1.0.0 + + :returns: the default libraries in use + :rtype: dict[LibraryType, Library] + """ + try: + return self[0].default_libraries + except KeyError: + default = Material() + default.number = 0 + self.append(default) + return self.default_libraries + + def mix( + self, + materials: list[Material], + fractions: list[float], + starting_number=None, + step=None, + ) -> Material: + """ + Mix the given materials in the provided fractions to create a new material. + + All materials must use the same fraction type, either atom fraction or mass fraction. + The fractions given to this method are interpreted in that way as well. + + This new material will automatically be added to this collection. + + Examples + -------- + + An example way to mix materials is to first create the materials to mix: + + .. testcode:: + + import montepy + mats = montepy.Materials() + h2o = montepy.Material() + h2o.number = 1 + h2o.add_nuclide("1001.80c", 2.0) + h2o.add_nuclide("8016.80c", 1.0) + + boric_acid = montepy.Material() + boric_acid.number = 2 + for nuclide, fraction in { + "1001.80c": 3.0, + "B-10.80c": 1.0 * 0.189, + "B-11.80c": 1.0 * 0.796, + "O-16.80c": 3.0 + }.items(): + boric_acid.add_nuclide(nuclide, fraction) + + Then to make the material mixture you just need to specify the fractions: + + .. testcode:: + + boron_ppm = 10 + boric_conc = boron_ppm * 1e-6 + borated_water = mats.mix([h2o, boric_acid], [1 - boric_conc, boric_conc]) + + + :param materials: the materials to mix. + :type materials: list[Material] + :param fractions: the corresponding fractions for each material in either atom or mass fractions, depending on + the materials fraction type. + :param starting_number: the starting number to assign this new material. + :type starting_number: Union[int, None] + :param step: the step size to take when finding a new number. + :type step: Union[int, None] + :returns: a new material with the mixed components of the given materials + :rtype: Material + :raises TypeError: if invalid objects are given. + :raises ValueError: if the number of elements in the two lists mismatch, or if not all the materials are of the + same fraction type, or if a negative starting_number or step are given. + """ + if not isinstance(materials, list): + raise TypeError(f"materials must be a list. {materials} given.") + if len(materials) == 0: + raise ValueError(f"materials must be non-empty. {materials} given.") + for mat in materials: + if not isinstance(mat, Material): + raise TypeError( + f"material in materials is not of type Material. {mat} given." + ) + if mat.is_atom_fraction != materials[0].is_atom_fraction: + raise ValueError( + f"All materials must have the same is_atom_fraction value. {mat} is the odd one out." + ) + if not isinstance(fractions, list): + raise TypeError(f"fractions must be a list. {fractions} given.") + for frac in fractions: + if not isinstance(frac, float): + raise TypeError(f"fraction in fractions must be a float. {frac} given.") + if frac < 0.0: + raise ValueError(f"Fraction cannot be negative. {frac} given.") + if len(fractions) != len(materials): + raise ValueError( + f"Length of materials and fractions don't match. The lengths are, materials: {len(materials)}, fractions: {len(fractions)}" + ) + if not isinstance(starting_number, (int, type(None))): + raise TypeError( + f"starting_number must be an int. {starting_number} of type {type(starting_number)} given." + ) + if starting_number is not None and starting_number <= 0: + raise ValueError( + f"starting_number must be positive. {starting_number} given." + ) + if not isinstance(step, (int, type(None))): + raise TypeError(f"step must be an int. {step} of type {type(step)} given.") + if step is not None and step <= 0: + raise ValueError(f"step must be positive. {step} given.") + ret = Material() + if starting_number is None: + starting_number = self.starting_number + if step is None: + step = self.step + ret.number = self.request_number(starting_number, step) + ret.is_atom_fraction = materials[0].is_atom_fraction + new_mats = copy.deepcopy(materials) + for mat, fraction in zip(new_mats, fractions): + mat.normalize() + for nuclide, frac in mat._components: + frac = copy.deepcopy(frac) + frac.value *= fraction + ret._components.append((nuclide, frac)) + return ret diff --git a/montepy/mcnp_object.py b/montepy/mcnp_object.py index c96d4126..54e15f63 100644 --- a/montepy/mcnp_object.py +++ b/montepy/mcnp_object.py @@ -1,8 +1,16 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations from abc import ABC, ABCMeta, abstractmethod import copy import functools import itertools as it +import numpy as np +import sys +import textwrap +from typing import Union +import warnings +import weakref + from montepy.errors import * from montepy.constants import ( BLANK_SPACE_CONTINUE, @@ -17,10 +25,8 @@ ValueNode, ) import montepy -import numpy as np -import textwrap -import warnings -import weakref + +InitInput = Union[montepy.input_parser.mcnp_input.Input, str] class _ExceptionContextAdder(ABCMeta): @@ -42,7 +48,13 @@ def wrapped(*args, **kwargs): except Exception as e: if len(args) > 0 and isinstance(args[0], MCNP_Object): self = args[0] - add_line_number_to_exception(e, self) + if hasattr(self, "_handling_exception"): + raise e + self._handling_exception = True + try: + add_line_number_to_exception(e, self) + finally: + del self._handling_exception else: raise e @@ -56,7 +68,6 @@ def __new__(meta, classname, bases, attributes): """ This will replace all properties and callable attributes with wrapped versions. - """ new_attrs = {} for key, value in attributes.items(): @@ -86,23 +97,31 @@ class MCNP_Object(ABC, metaclass=_ExceptionContextAdder): """ Abstract class for semantic representations of MCNP inputs. - .. versionchanged:: 0.2.0 - Generally significant changes for parser rework. - For init removed ``comments``, and added ``parser`` as arguments. - :param input: The Input syntax object this will wrap and parse. - :type input: Input + :type input: Union[Input, str] :param parser: The parser object to parse the input with. - :type parser: MCNP_Lexer + :type parser: MCNP_Parser """ - def __init__(self, input, parser): + def __init__( + self, + input: InitInput, + parser: montepy.input_parser.parser_base.MCNP_Parser, + ): + try: + self._BLOCK_TYPE + except AttributeError: + self._BLOCK_TYPE = montepy.input_parser.block_type.BlockType.DATA self._problem_ref = None self._parameters = ParametersNode() self._input = None if input: - if not isinstance(input, montepy.input_parser.mcnp_input.Input): - raise TypeError("input must be an Input") + if not isinstance(input, (montepy.input_parser.mcnp_input.Input, str)): + raise TypeError(f"input must be an Input or str. {input} given.") + if isinstance(input, str): + input = montepy.input_parser.mcnp_input.Input( + input.split("\n"), self._BLOCK_TYPE + ) try: try: parser.restart() @@ -114,7 +133,7 @@ def __init__(self, input, parser): except ValueError as e: raise MalformedInputError( input, f"Error parsing object of type: {type(self)}: {e.args[0]}" - ) + ).with_traceback(e.__traceback__) if self._tree is None: raise ParsingError( input, @@ -124,15 +143,35 @@ def __init__(self, input, parser): if "parameters" in self._tree: self._parameters = self._tree["parameters"] + def __setattr__(self, key, value): + # handle properties first + if hasattr(type(self), key): + descriptor = getattr(type(self), key) + if isinstance(descriptor, property): + descriptor.__set__(self, value) + return + # handle _private second + if key.startswith("_"): + super().__setattr__(key, value) + else: + # kwargs added in 3.10 + if sys.version_info >= (3, 10): + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{key}'", + obj=self, + name=key, + ) + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{key}'", + ) + @staticmethod - def _generate_default_node(value_type, default, padding=" "): + def _generate_default_node(value_type: type, default, padding: str = " "): """ Generates a "default" or blank ValueNode. None is generally a safe default value to provide. - .. versionadded:: 0.2.0 - :param value_type: the data type for the ValueNode. :type value_type: Class :param default: the default value to provide (type needs to agree with value_type) @@ -151,7 +190,7 @@ def _generate_default_node(value_type, default, padding=" "): return ValueNode(str(default), value_type, padding_node) @property - def parameters(self): + def parameters(self) -> dict[str, str]: """ A dictionary of the additional parameters for the object. @@ -173,12 +212,10 @@ def _update_values(self): The most common need is to update a value based on the number for an object pointed at, e.g., the material number in a cell definition. - .. versionadded:: 0.2.0 - """ pass - def format_for_mcnp_input(self, mcnp_version): + def format_for_mcnp_input(self, mcnp_version: tuple[int]) -> list[str]: """ Creates a string representation of this MCNP_Object that can be written to file. @@ -195,7 +232,7 @@ def format_for_mcnp_input(self, mcnp_version): return lines @property - def comments(self): + def comments(self) -> list[PaddingNode]: """ The comments associated with this input if any. @@ -208,12 +245,10 @@ def comments(self): return list(self._tree.comments) @property - def leading_comments(self): + def leading_comments(self) -> list[PaddingNode]: """ Any comments that come before the beginning of the input proper. - .. versionadded:: 0.2.0 - :returns: the leading comments. :rtype: list """ @@ -227,6 +262,13 @@ def leading_comments(self, comments): ) if isinstance(comments, CommentNode): comments = [comments] + if isinstance(comments, (list, tuple)): + for comment in comments: + if not isinstance(comment, CommentNode): + raise TypeError( + f"Comments must be a CommentNode, or a list of Comments. {comment} given." + ) + for i, comment in enumerate(comments): if not isinstance(comment, CommentNode): raise TypeError( @@ -234,7 +276,7 @@ def leading_comments(self, comments): ) new_nodes = list(*zip(comments, it.cycle(["\n"]))) if self._tree["start_pad"] is None: - self._tree["start_pad"] = syntax_node.PaddingNode(" ") + self._tree["start_pad"] = PaddingNode(" ") self._tree["start_pad"]._nodes = new_nodes @leading_comments.deleter @@ -244,7 +286,7 @@ def leading_comments(self): @staticmethod def wrap_string_for_mcnp( string, mcnp_version, is_first_line, suppress_blank_end=True - ): + ) -> list[str]: """ Wraps the list of the words to be a well formed MCNP input. @@ -307,7 +349,7 @@ def validate(self): """ pass - def link_to_problem(self, problem): + def link_to_problem(self, problem: montepy.mcnp_problem.MCNP_Problem): """Links the input to the parent problem for this input. This is done so that inputs can find links to other objects. @@ -323,7 +365,7 @@ def link_to_problem(self, problem): self._problem_ref = weakref.ref(problem) @property - def _problem(self): + def _problem(self) -> montepy.MCNP_Problem: if self._problem_ref is not None: return self._problem_ref() return None @@ -336,7 +378,7 @@ def _problem(self, problem): self.link_to_problem(problem) @property - def trailing_comment(self): + def trailing_comment(self) -> list[PaddingNode]: """ The trailing comments and padding of an input. @@ -350,172 +392,10 @@ def trailing_comment(self): def _delete_trailing_comment(self): self._tree._delete_trailing_comment() - def _grab_beginning_comment(self, padding, last_obj=None): + def _grab_beginning_comment(self, padding: list[PaddingNode], last_obj=None): if padding: self._tree["start_pad"]._grab_beginning_comment(padding) - @staticmethod - def wrap_words_for_mcnp(words, mcnp_version, is_first_line): # pragma: no cover - """ - Wraps the list of the words to be a well formed MCNP input. - - multi-line cards will be handled by using the indentation format, - and not the "&" method. - - .. deprecated:: 0.2.0 - The concept of words is deprecated, and should be handled by syntax trees now. - - :param words: A list of the "words" or data-grams that needed to added to this card. - Each word will be separated by at least one space. - :type words: list - :param mcnp_version: the tuple for the MCNP that must be formatted for. - :type mcnp_version: tuple - :param is_first_line: If true this will be the beginning of an MCNP card. - The first line will not be indented. - :type is_first_line: bool - :returns: A list of strings that can be written to an input file, one item to a line. - :rtype: list - :raises DeprecationWarning: raised always. - """ - warnings.warn( - "wrap_words_for_mcnp is deprecated. Use syntax trees instead.", - DeprecationWarning, - stacklevel=2, - ) - string = " ".join(words) - return MCNP_Card.wrap_string_for_mcnp(string, mcnp_version, is_first_line) - - @staticmethod - def compress_repeat_values(values, threshold=1e-6): # pragma: no cover - """ - Takes a list of floats, and tries to compress it using repeats. - - E.g., 1 1 1 1 would compress to 1 3R - - .. deprecated:: 0.2.0 - This should be automatically handled by the syntax tree instead. - - :param values: a list of float values to try to compress - :type values: list - :param threshold: the minimum threshold to consider two values different - :type threshold: float - :returns: a list of MCNP word strings that have repeat compression - :rtype: list - :raises DeprecationWarning: always raised. - """ - warnings.warn( - "compress_repeat_values is deprecated, and shouldn't be necessary anymore", - DeprecationWarning, - stacklevel=2, - ) - ret = [] - last_value = None - float_formatter = "{:n}" - repeat_counter = 0 - - def flush_repeats(): - nonlocal repeat_counter, ret - if repeat_counter >= 2: - ret.append(f"{repeat_counter}R") - elif repeat_counter == 1: - ret.append(float_formatter.format(last_value)) - repeat_counter = 0 - - for value in values: - if isinstance(value, montepy.input_parser.mcnp_input.Jump): - ret.append(value) - last_value = None - elif last_value: - if np.isclose(value, last_value, atol=threshold): - repeat_counter += 1 - else: - flush_repeats() - ret.append(float_formatter.format(value)) - last_value = value - else: - ret.append(float_formatter.format(value)) - last_value = value - repeat_counter = 0 - flush_repeats() - return ret - - @staticmethod - def compress_jump_values(values): # pragma: no cover - """ - Takes a list of strings and jump values and combines repeated jump values. - - e.g., 1 1 J J 3 J becomes 1 1 2J 3 J - - .. deprecated:: 0.2.0 - This should be automatically handled by the syntax tree instead. - - :param values: a list of string and Jump values to try to compress - :type values: list - :returns: a list of MCNP word strings that have jump compression - :rtype: list - :raises DeprecationWarning: raised always. - """ - warnings.warn( - "compress_jump_values is deprecated, and will be removed in the future.", - DeprecationWarning, - stacklevel=2, - ) - ret = [] - jump_counter = 0 - - def flush_jumps(): - nonlocal jump_counter, ret - if jump_counter == 1: - ret.append("J") - elif jump_counter >= 1: - ret.append(f"{jump_counter}J") - jump_counter = 0 - - for value in values: - if isinstance(value, montepy.input_parser.mcnp_input.Jump): - jump_counter += 1 - else: - flush_jumps() - ret.append(value) - flush_jumps() - return ret - - @property - def words(self): # pragma: no cover - """ - The words from the input file for this card. - - .. warning:: - .. deprecated:: 0.2.0 - This has been replaced by the syntax tree data structure. - - :raises DeprecationWarning: Access the syntax tree instead. - """ - raise DeprecationWarning("This has been removed; instead use the syntax tree") - - @property - def allowed_keywords(self): # pragma: no cover - """ - The allowed keywords for this class of MCNP_Card. - - The allowed keywords that would appear in the parameters block. - For instance for cells the keywords ``IMP`` and ``VOL`` are allowed. - The allowed keywords need to be in upper case. - - .. deprecated:: 0.2.0 - This is no longer needed. Instead this is specified in - :func:`montepy.input_parser.tokens.MCNP_Lexer._KEYWORDS`. - - :returns: A set of the allowed keywords. If there are none this should return the empty set. - :rtype: set - """ - warnings.warn( - "allowed_keywords are deprecated, and will be removed soon.", - DeprecationWarning, - stacklevel=2, - ) - return set() - def __getstate__(self): state = self.__dict__.copy() bad_keys = {"_problem_ref", "_parser"} @@ -528,7 +408,7 @@ def __setstate__(self, crunchy_data): crunchy_data["_problem_ref"] = None self.__dict__.update(crunchy_data) - def clone(self): + def clone(self) -> montepy.mcnp_object.MCNP_Object: """ Create a new independent instance of this object. diff --git a/montepy/mcnp_problem.py b/montepy/mcnp_problem.py index cd248aa3..247a199a 100644 --- a/montepy/mcnp_problem.py +++ b/montepy/mcnp_problem.py @@ -19,7 +19,7 @@ from montepy.data_inputs import parse_data from montepy.input_parser import input_syntax_reader, block_type, mcnp_input from montepy.input_parser.input_file import MCNP_InputFile -from montepy.universes import Universes +from montepy.universes import Universe, Universes from montepy.transforms import Transforms import montepy @@ -40,7 +40,7 @@ class MCNP_Problem: surface.Surface: Surfaces, Material: Materials, transform.Transform: Transforms, - montepy.universe.Universe: Universes, + Universe: Universes, } def __init__(self, destination): @@ -339,7 +339,7 @@ def parse_input(self, check_input=False, replace=True): OBJ_MATCHER = { block_type.BlockType.CELL: (Cell, self._cells), block_type.BlockType.SURFACE: ( - surface_builder.surface_builder, + surface_builder.parse_surface, self._surfaces, ), block_type.BlockType.DATA: (parse_data, self._data_inputs), @@ -366,7 +366,13 @@ def parse_input(self, check_input=False, replace=True): try: obj = obj_parser(input) obj.link_to_problem(self) - obj_container.append(obj) + if isinstance( + obj_container, + montepy.numbered_object_collection.NumberedObjectCollection, + ): + obj_container.append(obj, initial_load=True) + else: + obj_container.append(obj) except ( MalformedInputError, NumberConflictError, @@ -381,9 +387,9 @@ def parse_input(self, check_input=False, replace=True): else: raise e if isinstance(obj, Material): - self._materials.append(obj, False) + self._materials.append(obj, insert_in_data=False) if isinstance(obj, transform.Transform): - self._transforms.append(obj, False) + self._transforms.append(obj, insert_in_data=False) if trailing_comment is not None and last_obj is not None: obj._grab_beginning_comment(trailing_comment, last_obj) last_obj._delete_trailing_comment() @@ -449,7 +455,7 @@ def remove_duplicate_surfaces(self, tolerance): :param tolerance: The amount of relative error to consider two surfaces identical :type tolerance: float """ - to_delete = set() + to_delete = montepy.surface_collection.Surfaces() matching_map = {} for surface in self.surfaces: if surface not in to_delete: @@ -457,38 +463,28 @@ def remove_duplicate_surfaces(self, tolerance): if matches: for match in matches: to_delete.add(match) - matching_map[match] = surface + matching_map[match.number] = (match, surface) for cell in self.cells: cell.remove_duplicate_surfaces(matching_map) self.__update_internal_pointers() for surface in to_delete: self._surfaces.remove(surface) - def add_cell_children_to_problem(self): + def add_cell_children_to_problem(self): # pragma: no cover """ Adds the surfaces, materials, and transforms of all cells in this problem to this problem to the internal lists to allow them to be written to file. - .. warning:: - this does not move complement cells, and probably other objects. + .. deprecated:: 1.0.0 + + This function is no longer needed. When cells are added to problem.cells these children are added as well. + + :raises DeprecationWarning: """ - surfaces = set(self.surfaces) - materials = set(self.materials) - transforms = set(self.transforms) - for cell in self.cells: - surfaces.update(set(cell.surfaces)) - for surf in cell.surfaces: - if surf.transform: - transforms.add(surf.transform) - if cell.material: - materials.add(cell.material) - surfaces = sorted(surfaces) - materials = sorted(materials) - transforms = sorted(transforms) - self._surfaces = Surfaces(surfaces, problem=self) - self._materials = Materials(materials, problem=self) - self._transforms = Transforms(transforms, problem=self) - self._data_inputs = sorted(set(self._data_inputs + materials + transforms)) + raise DeprecationWarning( + "add_cell_children_to_problem has been removed," + " as the children are automatically added with the cell." + ) def write_problem(self, destination, overwrite=False): """ @@ -627,3 +623,60 @@ def __repr__(self): ret += f"{obj}\n" ret += "\n" return ret + + def parse(self, input: str, append: bool = True) -> montepy.mcnp_object.MCNP_Object: + """ + Parses the MCNP object given by the string, and links it adds it to this problem. + + This attempts to identify the input type by trying to parse it in the following order: + + #. Data Input + #. Surface + #. Cell + + This is done mostly for optimization to go from easiest parsing to hardest. + This will: + + #. Parse the input + #. Link it to other objects in the problem. Note: this will raise an error if those objects don't exist. + #. Append it to the appropriate collection + + :param input: the string describing the input. New lines are allowed but this does not need to meet MCNP line + length rules. + :type input: str + :param append: Whether to append this parsed object to this problem. + :type append: bool + :returns: the parsed object. + :rtype: MCNP_Object + + :raises TypeError: If a str is not given + :raises ParsingError: If this is not a valid input. + :raises BrokenObjectLinkError: if the dependent objects are not already in the problem. + :raises NumberConflictError: if the object's number is already taken + """ + try: + obj = montepy.parse_data(input) + except ParsingError: + try: + obj = montepy.parse_surface(input) + except ParsingError: + obj = montepy.Cell(input) + # let final parsing error bubble up + obj.link_to_problem(self) + if isinstance(obj, montepy.Cell): + obj.update_pointers(self.cells, self.materials, self.surfaces) + if append: + self.cells.append(obj) + elif isinstance(obj, montepy.surfaces.surface.Surface): + obj.update_pointers(self.surfaces, self.data_inputs) + if append: + self.surfaces.append(obj) + else: + obj.update_pointers(self.data_inputs) + if append: + self.data_inputs.append(obj) + if isinstance(obj, Material): + self._materials.append(obj, insert_in_data=False) + if isinstance(obj, transform.Transform): + self._transforms.append(obj, insert_in_data=False) + return obj diff --git a/montepy/numbered_mcnp_object.py b/montepy/numbered_mcnp_object.py index 9d79ee6c..31c14d82 100644 --- a/montepy/numbered_mcnp_object.py +++ b/montepy/numbered_mcnp_object.py @@ -1,9 +1,13 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations from abc import abstractmethod import copy import itertools +from typing import Union + + from montepy.errors import NumberConflictError -from montepy.mcnp_object import MCNP_Object +from montepy.mcnp_object import MCNP_Object, InitInput import montepy from montepy.utilities import * @@ -30,6 +34,44 @@ def _number_validator(self, number): class Numbered_MCNP_Object(MCNP_Object): + """ + An abstract class to represent an mcnp object that has a number. + + .. versionchanged:: 1.0.0 + + Added number parameter + + :param input: The Input syntax object this will wrap and parse. + :type input: Union[Input, str] + :param parser: The parser object to parse the input with. + :type parser: MCNP_Parser + :param number: The number to set for this object. + :type number: int + """ + + def __init__( + self, + input: InitInput, + parser: montepy.input_parser.parser_base.MCNP_Parser, + number: int = None, + ): + self._number = self._generate_default_node(int, -1) + super().__init__(input, parser) + self._load_init_num(number) + + def _load_init_num(self, number): + if number is not None: + if not isinstance(number, int): + raise TypeError( + f"Number must be an int. {number} of type {type(number)} given." + ) + if number < 0: + raise ValueError(f"Number must be 0 or greater. {number} given.") + self.number = number + + _CHILD_OBJ_MAP = {} + """ + """ @make_prop_val_node("_number", int, validator=_number_validator) def number(self): @@ -50,6 +92,36 @@ def old_number(self): """ pass + def _add_children_objs(self, problem): + """ + Adds all children objects from self to the given problem. + + This is called from an append_hook in `NumberedObjectCollection`. + """ + # skip lambda transforms + filters = {montepy.Transform: lambda transform: not transform.hidden_transform} + prob_attr_map = montepy.MCNP_Problem._NUMBERED_OBJ_MAP + for attr_name, obj_class in self._CHILD_OBJ_MAP.items(): + child_collect = getattr(self, attr_name) + # allow skipping certain items + if ( + obj_class in filters + and child_collect + and not filters[obj_class](child_collect) + ): + continue + if child_collect: + prob_collect_name = prob_attr_map[obj_class].__name__.lower() + prob_collect = getattr(problem, prob_collect_name) + try: + # check if iterable + iter(child_collect) + assert not isinstance(child_collect, MCNP_Object) + # ensure isn't a material or something + prob_collect.update(child_collect) + except (TypeError, AssertionError): + prob_collect.append(child_collect) + def clone(self, starting_number=None, step=None): """ Create a new independent instance of this object with a new number. diff --git a/montepy/numbered_object_collection.py b/montepy/numbered_object_collection.py index abe74394..fc7b4ab6 100644 --- a/montepy/numbered_object_collection.py +++ b/montepy/numbered_object_collection.py @@ -1,5 +1,7 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations from abc import ABC, abstractmethod +import itertools as it import typing import weakref @@ -17,25 +19,122 @@ def _enforce_positive(self, num): class NumberedObjectCollection(ABC): """A collections of MCNP objects. + .. _collect ex: + + Examples + ________ + + Accessing Objects + ^^^^^^^^^^^^^^^^^ + It quacks like a dict, it acts like a dict, but it's a list. The items in the collection are accessible by their number. For instance to get the Cell with a number of 2 you can just say: - ``problem.cells[2]`` + .. doctest:: python + + >>> import montepy + >>> problem = montepy.read_input("tests/inputs/test.imcnp") + >>> cell = problem.cells[2] + >>> print(cell) + CELL: 2, mat: 2, DENS: 8.0 atom/b-cm + + You can also add, and delete items like you would in a dictionary normally. + Though :func:`append` and :func:`add` are the preferred way of adding items. + When adding items by key the key given is actually ignored. + + .. testcode:: + + import montepy + problem = montepy.read_input("tests/inputs/test.imcnp") + cell = montepy.Cell() + cell.number = 25 + # this will actually append ignoring the key given + problem.cells[3] = cell + print(problem.cells[3] is cell) + del problem.cells[25] + print(cell not in problem.cells) + + This shows: - You can also add delete items like you would in a dictionary normally. + .. testoutput:: + + False + True + + Slicing a Collection + ^^^^^^^^^^^^^^^^^^^^ Unlike dictionaries this collection also supports slices e.g., ``[1:3]``. This will return a new :class:`NumberedObjectCollection` with objects - that have cell numbers that fit that slice. If a number is in a slice that - is not an actual object it will just be skipped. + that have numbers that fit that slice. + + .. testcode:: + + for cell in problem.cells[1:3]: + print(cell.number) + + Which shows + + .. testoutput:: + + 1 + 2 + 3 Because MCNP numbered objects start at 1, so do the indices. The slices are effectively 1-based and endpoint-inclusive. This means rather than the normal behavior of [0:5] excluding the index 5, 5 would be included. + Set-Like Operations + ^^^^^^^^^^^^^^^^^^^ + + .. versionchanged:: 1.0.0 + + Introduced set-like behavior. + + These collections act like `sets `_. + The supported operators are: ``&``, ``|``, ``-``, ``^``, ``<``, ``<=``, ``>``, ``>=``, ``==``. + See the set documentation for how these operators function. + The set operations are applied to the object numbers. + The corresponding objects are then taken to form a new instance of this collection. + The if both collections have objects with the same number but different objects, + the left-hand-side's object is taken. + + .. testcode:: + + cells1 = montepy.Cells() + + for i in range(5, 10): + cell = montepy.Cell() + cell.number = i + cells1.add(cell) + + cells2 = montepy.Cells() + + for i in range(8, 15): + cell = montepy.Cell() + cell.number = i + cells2.add(cell) + + overlap = cells1 & cells2 + + # The only overlapping numbers are 8, 9, 10 + + print({8, 9} == set(overlap.keys())) + + This would print: + + .. testoutput:: + + True + + Other set-like functions are: :func:`difference`, :func:`difference_update`, + :func:`intersection`, :func:`isdisjoint`, :func:`issubset`, :func:`issuperset`, + :func:`symmetric_difference`, :func:`symmetric_difference_update`, :func:`union`, :func:`discard`, and :func:`update`. + :param obj_class: the class of numbered objects being collected :type obj_class: type :param objects: the list of cells to start with if needed @@ -44,7 +143,12 @@ class NumberedObjectCollection(ABC): :type problem: MCNP_Problem """ - def __init__(self, obj_class, objects=None, problem=None): + def __init__( + self, + obj_class: type, + objects: list = None, + problem: montepy.MCNP_Problem = None, + ): self.__num_cache = {} assert issubclass(obj_class, Numbered_MCNP_Object) self._obj_class = obj_class @@ -137,7 +241,7 @@ def check_number(self, number): conflict = True if conflict: raise NumberConflictError( - f"Number {number} is already in use for the collection: {type(self)} by {self[number]}" + f"Number {number} is already in use for the collection: {type(self).__name__} by {self[number]}" ) def _update_number(self, old_num, new_num, obj): @@ -180,8 +284,8 @@ def pop(self, pos=-1): """ if not isinstance(pos, int): raise TypeError("The index for popping must be an int") - obj = self._objects.pop(pos) - self.__num_cache.pop(obj.number, None) + obj = self._objects[pos] + self.__internal_delete(obj) return obj def clear(self): @@ -201,6 +305,7 @@ def extend(self, other_list): """ if not isinstance(other_list, (list, type(self))): raise TypeError("The extending list must be a list") + # this is the optimized version to get all numbers if self._problem: nums = set(self.__num_cache) else: @@ -213,16 +318,13 @@ def extend(self, other_list): if obj.number in nums: raise NumberConflictError( ( - f"When adding to {type(self)} there was a number collision due to " + f"When adding to {type(self).__name__} there was a number collision due to " f"adding {obj} which conflicts with {self[obj.number]}" ) ) nums.add(obj.number) - self._objects.extend(other_list) - self.__num_cache.update({obj.number: obj for obj in other_list}) - if self._problem: - for obj in other_list: - obj.link_to_problem(self._problem) + for obj in other_list: + self.__internal_append(obj) def remove(self, delete): """ @@ -231,8 +333,13 @@ def remove(self, delete): :param delete: the object to delete :type delete: Numbered_MCNP_Object """ - self.__num_cache.pop(delete.number, None) - self._objects.remove(delete) + if not isinstance(delete, self._obj_class): + raise TypeError("") + candidate = self[delete.number] + if delete is candidate: + del self[delete.number] + else: + raise KeyError(f"This object is not in this collection") def clone(self, starting_number=None, step=None): """ @@ -313,20 +420,119 @@ def __repr__(self): f"Number cache: {self.__num_cache}" ) - def append(self, obj): + def _append_hook(self, obj, initial_load=False): + """ + A hook that is called every time append is called. + """ + if initial_load: + return + if self._problem: + obj._add_children_objs(self._problem) + + def _delete_hook(self, obj, **kwargs): + """ + A hook that is called every time delete is called. + """ + pass + + def __internal_append(self, obj, **kwargs): + """ + The internal append method. + + This should always be called rather than manually added. + + :param obj: the obj to append + :param kwargs: keyword arguments passed through to the append_hook + """ + if not isinstance(obj, self._obj_class): + raise TypeError( + f"Object must be of type: {self._obj_class.__name__}. {obj} given." + ) + if obj.number in self.__num_cache: + try: + if obj is self[obj.number]: + return + # if cache is bad and it's not actually in use ignore it + except KeyError as e: + pass + else: + raise NumberConflictError( + f"Number {obj.number} is already in use for the collection: {type(self).__name__} by {self[obj.number]}" + ) + self.__num_cache[obj.number] = obj + self._objects.append(obj) + self._append_hook(obj, **kwargs) + if self._problem: + obj.link_to_problem(self._problem) + + def __internal_delete(self, obj, **kwargs): + """ + The internal delete method. + + This should always be called rather than manually added. + """ + self.__num_cache.pop(obj.number, None) + self._objects.remove(obj) + self._delete_hook(obj, **kwargs) + + def add(self, obj: Numbered_MCNP_Object): + """ + Add the given object to this collection. + + :param obj: The object to add. + :type obj: Numbered_MCNP_Object + + :raises TypeError: if the object is of the wrong type. + :raises NumberConflictError: if this object's number is already in use in the collection. + """ + self.__internal_append(obj) + + def update(self, *objs: typing.Self): + """ + Add the given objects to this collection. + + + .. note:: + + This is not a thread-safe method. + + .. versionchanged:: 1.0.0 + + Changed to be more set like. Accepts multiple arguments. If there is a number conflict, + the current object will be kept. + + :param objs: The objects to add. + :type objs: list[Numbered_MCNP_Object] + :raises TypeError: if the object is of the wrong type. + :raises NumberConflictError: if this object's number is already in use in the collection. + """ + try: + iter(objs) + except TypeError: + raise TypeError(f"Objs must be an iterable. {objs} given.") + others = [] + for obj in objs: + if isinstance(obj, list): + others.append(type(self)(obj)) + else: + others.append(obj) + if len(others) == 1: + self |= others[0] + else: + other = others[0].union(*others[1:]) + self |= others + + def append(self, obj, **kwargs): """Appends the given object to the end of this collection. :param obj: the object to add. :type obj: Numbered_MCNP_Object + :param kwargs: extra arguments that are used internally. :raises NumberConflictError: if this object has a number that is already in use. """ if not isinstance(obj, self._obj_class): raise TypeError(f"object being appended must be of type: {self._obj_class}") - self.check_number(obj.number) - self.__num_cache[obj.number] = obj - self._objects.append(obj) - if self._problem: - obj.link_to_problem(self._problem) + self.__internal_append(obj, **kwargs) def append_renumber(self, obj, step=1): """Appends the object, but will renumber the object if collision occurs. @@ -462,8 +668,7 @@ def __delitem__(self, idx): if not isinstance(idx, int): raise TypeError("index must be an int") obj = self[idx] - self.__num_cache.pop(obj.number, None) - self._objects.remove(obj) + self.__internal_delete(obj) def __setitem__(self, key, newvalue): if not isinstance(key, int): @@ -478,8 +683,280 @@ def __iadd__(self, other): return self def __contains__(self, other): + if not isinstance(other, self._obj_class): + return False + # if cache can be trusted from #563 + if self._problem: + try: + if other is self[other.number]: + return True + return False + except KeyError: + return False return other in self._objects + def __set_logic(self, other, operator): + """ + Takes another collection, and apply the operator to it, and returns a new instance. + + Operator must be a callable that accepts a set of the numbers of self, + and another set for other's numbers. + """ + if not isinstance(other, type(self)): + raise TypeError( + f"Other side must be of the type {type(self).__name__}. {other} given." + ) + self_nums = set(self.keys()) + other_nums = set(other.keys()) + new_nums = operator(self_nums, other_nums) + new_objs = {} + # give preference to self + for obj in it.chain(other, self): + if obj.number in new_nums: + new_objs[obj.number] = obj + return type(self)(list(new_objs.values())) + + def __and__(self, other): + return self.__set_logic(other, lambda a, b: a & b) + + def __iand__(self, other): + new_vals = self & other + self.__num_cache.clear() + self._objects.clear() + self.update(new_vals) + return self + + def __or__(self, other): + return self.__set_logic(other, lambda a, b: a | b) + + def __ior__(self, other): + new_vals = other - self + self.extend(new_vals) + return self + + def __sub__(self, other): + return self.__set_logic(other, lambda a, b: a - b) + + def __isub__(self, other): + excess_values = self & other + for excess in excess_values: + del self[excess.number] + return self + + def __xor__(self, other): + return self.__set_logic(other, lambda a, b: a ^ b) + + def __ixor__(self, other): + new_values = self ^ other + self._objects.clear() + self.__num_cache.clear() + self.update(new_values) + return self + + def __set_logic_test(self, other, operator): + """ + Takes another collection, and apply the operator to it, testing the logic of it. + + Operator must be a callable that accepts a set of the numbers of self, + and another set for other's numbers. + """ + if not isinstance(other, type(self)): + raise TypeError( + f"Other side must be of the type {type(self).__name__}. {other} given." + ) + self_nums = set(self.keys()) + other_nums = set(other.keys()) + return operator(self_nums, other_nums) + + def __le__(self, other): + return self.__set_logic_test(other, lambda a, b: a <= b) + + def __lt__(self, other): + return self.__set_logic_test(other, lambda a, b: a < b) + + def __ge__(self, other): + return self.__set_logic_test(other, lambda a, b: a >= b) + + def __gt__(self, other): + return self.__set_logic_test(other, lambda a, b: a > b) + + def issubset(self, other: typing.Self): + """ + Test whether every element in the collection is in other. + + ``collection <= other`` + + .. versionadded:: 1.0.0 + + :param other: the set to compare to. + :type other: Self + :rtype: bool + """ + return self.__set_logic_test(other, lambda a, b: a.issubset(b)) + + def isdisjoint(self, other: typing.Self): + """ + Test if there are no elements in common between the collection, and other. + + Collections are disjoint if and only if their intersection + is the empty set. + + .. versionadded:: 1.0.0 + + :param other: the set to compare to. + :type other: Self + :rtype: bool + """ + return self.__set_logic_test(other, lambda a, b: a.isdisjoint(b)) + + def issuperset(self, other: typing.Self): + """ + Test whether every element in other is in the collection. + + ``collection >= other`` + + .. versionadded:: 1.0.0 + + :param other: the set to compare to. + :type other: Self + :rtype: bool + """ + return self.__set_logic_test(other, lambda a, b: a.issuperset(b)) + + def __set_logic_multi(self, others, operator): + for other in others: + if not isinstance(other, type(self)): + raise TypeError( + f"Other argument must be of type {type(self).__name__}. {other} given." + ) + self_nums = set(self.keys()) + other_sets = [] + for other in others: + other_sets.append(set(other.keys())) + valid_nums = operator(self_nums, *other_sets) + objs = {} + for obj in it.chain(*others, self): + if obj.number in valid_nums: + objs[obj.number] = obj + return type(self)(list(objs.values())) + + def intersection(self, *others: typing.Self): + """ + Return a new collection with all elements in common in collection, and all others. + + ``collection & other & ...`` + + .. versionadded:: 1.0.0 + + :param others: the other collections to compare to. + :type others: Self + :rtype: typing.Self + """ + return self.__set_logic_multi(others, lambda a, *b: a.intersection(*b)) + + def intersection_update(self, *others: typing.Self): + """ + Update the collection keeping all elements in common in collection, and all others. + + ``collection &= other & ...`` + + .. versionadded:: 1.0.0 + + :param others: the other collections to compare to. + :type others: Self + """ + if len(others) == 1: + self &= others[0] + else: + other = others[0].intersection(*others[1:]) + self &= other + + def union(self, *others: typing.Self): + """ + Return a new collection with all elements from collection, and all others. + + ``collection | other | ...`` + + .. versionadded:: 1.0.0 + + :param others: the other collections to compare to. + :type others: Self + :rtype: typing.Self + """ + return self.__set_logic_multi(others, lambda a, *b: a.union(*b)) + + def difference(self, *others: typing.Self): + """ + Return a new collection with elements from collection, that are not in the others. + + ``collection - other - ...`` + + .. versionadded:: 1.0.0 + + :param others: the other collections to compare to. + :type others: Self + :rtype: typing.Self + """ + return self.__set_logic_multi(others, lambda a, *b: a.difference(*b)) + + def difference_update(self, *others: typing.Self): + """ + Update the new collection removing all elements from others. + + ``collection -= other | ...`` + + .. versionadded:: 1.0.0 + + :param others: the other collections to compare to. + :type others: Self + """ + new_vals = self.difference(*others) + self.clear() + self.update(new_vals) + return self + + def symmetric_difference(self, other: typing.Self): + """ + Return a new collection with elements in either the collection or the other, but not both. + + ``collection ^ other`` + + .. versionadded:: 1.0.0 + + :param others: the other collections to compare to. + :type others: Self + :rtype: typing.Self + """ + return self ^ other + + def symmetric_difference_update(self, other: typing.Self): + """ + Update the collection, keeping only elements found in either collection, but not in both. + + ``collection ^= other`` + + .. versionadded:: 1.0.0 + + :param others: the other collections to compare to. + :type others: Self + """ + self ^= other + return self + + def discard(self, obj: montepy.numbered_mcnp_object.Numbered_MCNP_Object): + """ + Remove the object from the collection if it is present. + + .. versionadded:: 1.0.0 + + :param obj: the object to remove. + :type obj: Numbered_MCNP_Object + """ + try: + self.remove(obj) + except (TypeError, KeyError) as e: + pass + def get(self, i: int, default=None) -> (Numbered_MCNP_Object, None): """ Get ``i`` if possible, or else return ``default``. @@ -498,6 +975,7 @@ def get(self, i: int, default=None) -> (Numbered_MCNP_Object, None): except KeyError: pass for obj in self._objects: + self.__num_cache[obj.number] = obj if obj.number == i: self.__num_cache[i] = obj return obj @@ -509,7 +987,10 @@ def keys(self) -> typing.Generator[int, None, None]: :rtype: int """ + if len(self) == 0: + yield from [] for o in self._objects: + self.__num_cache[o.number] = o yield o.number def values(self) -> typing.Generator[Numbered_MCNP_Object, None, None]: @@ -519,6 +1000,7 @@ def values(self) -> typing.Generator[Numbered_MCNP_Object, None, None]: :rtype: Numbered_MCNP_Object """ for o in self._objects: + self.__num_cache[o.number] = o yield o def items( @@ -532,6 +1014,22 @@ def items( for o in self._objects: yield o.number, o + def __eq__(self, other): + if not isinstance(other, type(self)): + raise TypeError( + f"Can only compare {type(self).__name__} to each other. {other} was given." + ) + if len(self) != len(other): + return False + keys = sorted(self.keys()) + for key in keys: + try: + if self[key] != other[key]: + return False + except KeyError: + return False + return True + class NumberedDataObjectCollection(NumberedObjectCollection): def __init__(self, obj_class, objects=None, problem=None): @@ -543,7 +1041,7 @@ def __init__(self, obj_class, objects=None, problem=None): pass super().__init__(obj_class, objects, problem) - def append(self, obj, insert_in_data=True): + def _append_hook(self, obj, insert_in_data=True): """Appends the given object to the end of this collection. :param obj: the object to add. @@ -552,7 +1050,6 @@ def append(self, obj, insert_in_data=True): :type insert_in_data: bool :raises NumberConflictError: if this object has a number that is already in use. """ - super().append(obj) if self._problem: if self._last_index: index = self._last_index @@ -567,41 +1064,9 @@ def append(self, obj, insert_in_data=True): self._problem.data_inputs.insert(index + 1, obj) self._last_index = index + 1 - def __delitem__(self, idx): - if not isinstance(idx, int): - raise TypeError("index must be an int") - obj = self[idx] - super().__delitem__(idx) - if self._problem: - self._problem.data_inputs.remove(obj) - - def remove(self, delete): - """ - Removes the given object from the collection. - - :param delete: the object to delete - :type delete: Numbered_MCNP_Object - """ - super().remove(delete) - if self._problem: - self._problem.data_inputs.remove(delete) - - def pop(self, pos=-1): - """ - Pop the final items off of the collection - - :param pos: The index of the element to pop from the internal list. - :type pos: int - :return: the final elements - :rtype: Numbered_MCNP_Object - """ - if not isinstance(pos, int): - raise TypeError("The index for popping must be an int") - obj = self._objects.pop(pos) - super().pop(pos) + def _delete_hook(self, obj): if self._problem: self._problem.data_inputs.remove(obj) - return obj def clear(self): """ diff --git a/montepy/particle.py b/montepy/particle.py index 21359266..6e52f9dd 100644 --- a/montepy/particle.py +++ b/montepy/particle.py @@ -1,9 +1,9 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. -from enum import Enum, unique +from enum import unique, Enum @unique -class Particle(Enum): +class Particle(str, Enum): """ Supported MCNP supported particles. @@ -53,3 +53,48 @@ def __lt__(self, other): def __str__(self): return self.name.lower() + + def __eq__(self, other): + return self.value == other.value + + def __hash__(self): + return hash(self.value) + + +@unique +class LibraryType(str, Enum): + """ + Enum to represent the possible types that a nuclear data library can be. + + .. versionadded:: 1.0.0 + + Taken from section of 5.6.1 of LA-UR-22-30006 + """ + + def __new__(cls, value, particle=None): + obj = str.__new__(cls) + obj._value_ = value + obj._particle_ = particle + return obj + + NEUTRON = ("NLIB", Particle.NEUTRON) + PHOTO_ATOMIC = ("PLIB", None) + PHOTO_NUCLEAR = ("PNLIB", None) + ELECTRON = ("ELIB", Particle.ELECTRON) + PROTON = ("HLIB", Particle.PROTON) + ALPHA_PARTICLE = ("ALIB", Particle.ALPHA_PARTICLE) + HELION = ("SLIB", Particle.HELION) + TRITON = ("TLIB", Particle.TRITON) + DEUTERON = ("DLIB", Particle.DEUTERON) + + def __str__(self): + return self.name.lower() + + def __lt__(self, other): + return self.value < other.value + + def __eq__(self, other): + return self.value == other.value + + def __hash__(self): + return hash(self.value) diff --git a/montepy/surface_collection.py b/montepy/surface_collection.py index bb8f5ff6..9c05ab83 100644 --- a/montepy/surface_collection.py +++ b/montepy/surface_collection.py @@ -1,4 +1,6 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations +import montepy from montepy.surfaces.surface import Surface from montepy.surfaces.surface_type import SurfaceType from montepy.numbered_object_collection import NumberedObjectCollection @@ -31,16 +33,23 @@ class Surfaces(NumberedObjectCollection): This example will shift all PZ surfaces up by 10 cm. - .. code-block:: python + .. testcode:: python + import montepy + problem = montepy.read_input("tests/inputs/test.imcnp") for surface in problem.surfaces.pz: surface.location += 10 + .. note:: + + For examples see the ``NumberedObjectCollection`` :ref:`collect ex`. + + :param surfaces: the list of surfaces to start with if needed :type surfaces: list """ - def __init__(self, surfaces=None, problem=None): + def __init__(self, surfaces: list = None, problem: montepy.MCNP_Problem = None): super().__init__(Surface, surfaces, problem) diff --git a/montepy/surfaces/__init__.py b/montepy/surfaces/__init__.py index 2a904d8b..872f4aac 100644 --- a/montepy/surfaces/__init__.py +++ b/montepy/surfaces/__init__.py @@ -1,10 +1,18 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. from . import axis_plane -from .axis_plane import AxisPlane from . import cylinder_par_axis -from .cylinder_par_axis import CylinderParAxis from . import cylinder_on_axis -from .cylinder_on_axis import CylinderOnAxis from . import half_space from . import surface from . import surface_builder + +# promote objects +from .axis_plane import AxisPlane +from .cylinder_par_axis import CylinderParAxis +from .cylinder_on_axis import CylinderOnAxis +from .half_space import HalfSpace, UnitHalfSpace +from .surface import Surface +from .surface_type import SurfaceType + +# promote functions +from .surface_builder import parse_surface diff --git a/montepy/surfaces/axis_plane.py b/montepy/surfaces/axis_plane.py index eae04f42..5c9edb10 100644 --- a/montepy/surfaces/axis_plane.py +++ b/montepy/surfaces/axis_plane.py @@ -1,6 +1,7 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. + from .surface_type import SurfaceType -from .surface import Surface +from .surface import Surface, InitInput from montepy.errors import * from montepy.utilities import * @@ -9,15 +10,21 @@ class AxisPlane(Surface): """ Represents PX, PY, PZ + .. versionchanged:: 1.0.0 + + Added number parameter + :param input: The Input object representing the input - :type input: Input + :type input: Union[Input, str] + :param number: The number to set for this object. + :type number: int """ COORDINATE = {SurfaceType.PX: "x", SurfaceType.PY: "y", SurfaceType.PZ: "z"} - def __init__(self, input=None): + def __init__(self, input: InitInput = None, number: int = None): self._location = self._generate_default_node(float, None) - super().__init__(input) + super().__init__(input, number) ST = SurfaceType if input: if self.surface_type not in [ST.PX, ST.PY, ST.PZ]: diff --git a/montepy/surfaces/cylinder_on_axis.py b/montepy/surfaces/cylinder_on_axis.py index 0ade7c00..93d99f65 100644 --- a/montepy/surfaces/cylinder_on_axis.py +++ b/montepy/surfaces/cylinder_on_axis.py @@ -1,6 +1,6 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. from .surface_type import SurfaceType -from .surface import Surface +from .surface import Surface, InitInput from montepy.errors import * from montepy.utilities import * @@ -14,13 +14,20 @@ class CylinderOnAxis(Surface): """ Represents surfaces: CX, CY, CZ + .. versionchanged:: 1.0.0 + + Added number parameter + + :param input: The Input object representing the input - :type input: Input + :type input: Union[Input, str] + :param number: The number to set for this object. + :type number: int """ - def __init__(self, input=None): + def __init__(self, input: InitInput = None, number: int = None): self._radius = self._generate_default_node(float, None) - super().__init__(input) + super().__init__(input, number) ST = SurfaceType if input: if self.surface_type not in [ST.CX, ST.CY, ST.CZ]: diff --git a/montepy/surfaces/cylinder_par_axis.py b/montepy/surfaces/cylinder_par_axis.py index 85f52270..3ada7c58 100644 --- a/montepy/surfaces/cylinder_par_axis.py +++ b/montepy/surfaces/cylinder_par_axis.py @@ -1,6 +1,6 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. from .surface_type import SurfaceType -from .surface import Surface +from .surface import Surface, InitInput from montepy.errors import * from montepy.utilities import * @@ -14,8 +14,14 @@ class CylinderParAxis(Surface): """ Represents surfaces: C/X, C/Y, C/Z + .. versionchanged:: 1.0.0 + + Added number parameter + :param input: The Input object representing the input - :type input: Input + :type input: Union[Input, str] + :param number: The number to set for this object. + :type number: int """ COORDINATE_PAIRS = { @@ -26,13 +32,13 @@ class CylinderParAxis(Surface): """Which coordinate is what value for each cylinder type. """ - def __init__(self, input=None): + def __init__(self, input: InitInput = None, number: int = None): self._coordinates = [ self._generate_default_node(float, None), self._generate_default_node(float, None), ] self._radius = self._generate_default_node(float, None) - super().__init__(input) + super().__init__(input, number) ST = SurfaceType if input: if self.surface_type not in [ST.C_X, ST.C_Y, ST.C_Z]: diff --git a/montepy/surfaces/general_plane.py b/montepy/surfaces/general_plane.py index 9bf118ea..224ea782 100644 --- a/montepy/surfaces/general_plane.py +++ b/montepy/surfaces/general_plane.py @@ -1,19 +1,34 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from typing import Union + +import montepy from montepy.errors import * from montepy.surfaces.surface_type import SurfaceType -from montepy.surfaces.surface import Surface +from montepy.surfaces.surface import Surface, InitInput class GeneralPlane(Surface): """ Represents P + .. versionchanged:: 1.0.0 + + Added number parameter + :param input: The Input object representing the input :type input: Input + :param input: The Input object representing the input + :type input: Union[Input, str] + :param number: The number to set for this object. + :type number: int """ - def __init__(self, input=None): - super().__init__(input) + def __init__( + self, + input: InitInput = None, + number: int = None, + ): + super().__init__(input, number) if input: if self.surface_type != SurfaceType.P: raise ValueError("A GeneralPlane must be a surface of type P") diff --git a/montepy/surfaces/half_space.py b/montepy/surfaces/half_space.py index 358eecaa..68095684 100644 --- a/montepy/surfaces/half_space.py +++ b/montepy/surfaces/half_space.py @@ -1,4 +1,5 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations import montepy from montepy.errors import * from montepy.geometry_operators import Operator @@ -15,9 +16,6 @@ class HalfSpace: """ Class representing a geometry half_space. - .. versionadded:: 0.2.0 - This was added as the core of the rework to how MCNP geometries are implemented. - The term `half-spaces `_ in MontePy is used very loosely, and is not mathematically rigorous. In MontePy a divider is a something that splits a space (R\\ :sup:`3` ) into two half-spaces. At the simplest this would @@ -202,20 +200,31 @@ def _add_new_children_to_cell(self, other): if item not in parent: parent.append(item) - def remove_duplicate_surfaces(self, deleting_dict): + def remove_duplicate_surfaces( + self, + deleting_dict: dict[ + int, tuple[montepy.surfaces.Surface, montepy.surfaces.Surface] + ], + ): """Updates old surface numbers to prepare for deleting surfaces. This will ensure any new surfaces or complements properly get added to the parent cell's :func:`~montepy.cell.Cell.surfaces` and :func:`~montepy.cell.Cell.complements`. + .. versionchanged:: 1.0.0 + + The form of the deleting_dict was changed as :class:`~montepy.surfaces.Surface` is no longer hashable. + :param deleting_dict: a dict of the surfaces to delete, mapping the old surface to the new surface to replace it. - :type deleting_dict: dict + The keys are the number of the old surface. The values are a tuple + of the old surface, and then the new surface. + :type deleting_dict: dict[int, tuple[Surface, Surface]] """ - _, surfaces = self._get_leaf_objects() + cells, surfaces = self._get_leaf_objects() new_deleting_dict = {} - for dead_surface, new_surface in deleting_dict.items(): - if dead_surface in surfaces: - new_deleting_dict[dead_surface] = new_surface + for num, (dead_obj, new_obj) in deleting_dict.items(): + if dead_obj in surfaces or dead_obj in cells: + new_deleting_dict[num] = (dead_obj, new_obj) if len(new_deleting_dict) > 0: self.left.remove_duplicate_surfaces(new_deleting_dict) if self.right is not None: @@ -468,9 +477,6 @@ class UnitHalfSpace(HalfSpace): """ The leaf node for the HalfSpace tree. - .. versionadded:: 0.2.0 - This was added as the core of the rework to how MCNP geometries are implemented. - This can only be used as leaves and represents one half_space of a a divider. The easiest way to generate one is with the divider with unary operators. @@ -671,20 +677,60 @@ def _update_node(self): self._node.is_negative = not self.side def _get_leaf_objects(self): - if self._is_cell: - return ({self._divider}, set()) - return (set(), {self._divider}) + if isinstance( + self._divider, (montepy.cell.Cell, montepy.surfaces.surface.Surface) + ): + + def cell_cont(div=None): + if div: + return montepy.cells.Cells([div]) + return montepy.cells.Cells() + + def surf_cont(div=None): + if div: + return montepy.surface_collection.Surfaces([div]) + return montepy.surface_collection.Surfaces() - def remove_duplicate_surfaces(self, deleting_dict): + else: + raise IllegalState( + f"Geometry cannot be modified while not linked to surfaces. Run Cell.update_pointers" + ) + if self._is_cell: + return (cell_cont(self._divider), surf_cont()) + return (cell_cont(), surf_cont(self._divider)) + + def remove_duplicate_surfaces( + self, + deleting_dict: dict[ + int, tuple[montepy.surfaces.Surface, montepy.surfaces.Surface] + ], + ): """Updates old surface numbers to prepare for deleting surfaces. - :param deleting_dict: a dict of the surfaces to delete. - :type deleting_dict: dict + This will ensure any new surfaces or complements properly get added to the parent + cell's :func:`~montepy.cell.Cell.surfaces` and :func:`~montepy.cell.Cell.complements`. + + .. versionchanged:: 1.0.0 + + The form of the deleting_dict was changed as :class:`~montepy.surfaces.Surface` is no longer hashable. + + :param deleting_dict: a dict of the surfaces to delete, mapping the old surface to the new surface to replace it. + The keys are the number of the old surface. The values are a tuple + of the old surface, and then the new surface. + :type deleting_dict: dict[int, tuple[Surface, Surface]] """ - if not self.is_cell: - if self.divider in deleting_dict: - new_surface = deleting_dict[self.divider] - self.divider = new_surface + + def num(obj): + if isinstance(obj, int): + return obj + return obj.number + + if num(self.divider) in deleting_dict: + old_obj, new_obj = deleting_dict[num(self.divider)] + if isinstance(self.divider, ValueNode) or type(new_obj) == type( + self.divider + ): + self.divider = new_obj def __len__(self): return 1 diff --git a/montepy/surfaces/surface.py b/montepy/surfaces/surface.py index 9612b750..856a0bb9 100644 --- a/montepy/surfaces/surface.py +++ b/montepy/surfaces/surface.py @@ -1,28 +1,48 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations import copy +import re +from typing import Union + +import montepy from montepy.errors import * from montepy.data_inputs import transform from montepy.input_parser import syntax_node from montepy.input_parser.surface_parser import SurfaceParser -from montepy.numbered_mcnp_object import Numbered_MCNP_Object +from montepy.numbered_mcnp_object import Numbered_MCNP_Object, InitInput from montepy.surfaces import half_space from montepy.surfaces.surface_type import SurfaceType from montepy.utilities import * -import re class Surface(Numbered_MCNP_Object): """ Object to hold a single MCNP surface + .. versionchanged:: 1.0.0 + + Added number parameter + :param input: The Input object representing the input - :type input: Input + :type input: Union[Input, str] + :param number: The number to set for this object. + :type number: int """ _parser = SurfaceParser() - def __init__(self, input=None): - super().__init__(input, self._parser) + def __init__( + self, + input: InitInput = None, + number: int = None, + ): + self._CHILD_OBJ_MAP = { + "periodic_surface": Surface, + "transform": transform.Transform, + } + self._BLOCK_TYPE = montepy.input_parser.block_type.BlockType.SURFACE + self._number = self._generate_default_node(int, -1) + super().__init__(input, self._parser, number) self._periodic_surface = None self._old_periodic_surface = self._generate_default_node(int, None) self._transform = None @@ -31,7 +51,6 @@ def __init__(self, input=None): self._is_white_boundary = False self._surface_constants = [] self._surface_type = self._generate_default_node(str, None) - self._number = self._generate_default_node(int, -1) self._modifier = self._generate_default_node(str, None) # surface number if input: @@ -285,9 +304,6 @@ def __eq__(self, other): def __ne__(self, other): return not self == other - def __hash__(self): - return hash((self.number, str(self.surface_type))) - def find_duplicate_surfaces(self, surfaces, tolerance): """Finds all surfaces that are effectively the same as this one. @@ -302,7 +318,15 @@ def find_duplicate_surfaces(self, surfaces, tolerance): return [] def __neg__(self): + if self.number <= 0: + raise IllegalState( + f"Surface number must be set for a surface to be used in a geometry definition." + ) return half_space.UnitHalfSpace(self, False, False) def __pos__(self): + if self.number <= 0: + raise IllegalState( + f"Surface number must be set for a surface to be used in a geometry definition." + ) return half_space.UnitHalfSpace(self, True, False) diff --git a/montepy/surfaces/surface_builder.py b/montepy/surfaces/surface_builder.py index d72fa99d..a86cd139 100644 --- a/montepy/surfaces/surface_builder.py +++ b/montepy/surfaces/surface_builder.py @@ -1,21 +1,18 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. from montepy.surfaces.axis_plane import AxisPlane -from montepy.surfaces.surface import Surface +from montepy.surfaces.surface import Surface, InitInput from montepy.surfaces.surface_type import SurfaceType from montepy.surfaces.cylinder_on_axis import CylinderOnAxis from montepy.surfaces.cylinder_par_axis import CylinderParAxis from montepy.surfaces.general_plane import GeneralPlane -def surface_builder(input): +def parse_surface(input: InitInput): """ Builds a Surface object for the type of Surface - .. versionchanged:: 0.2.0 - The ``comments`` argument has been removed with the simpler init function. - :param input: The Input object representing the input - :type input: Input + :type input: Union[Input, str] :returns: A Surface object properly parsed. If supported a sub-class of Surface will be given. :rtype: Surface """ @@ -32,3 +29,17 @@ def surface_builder(input): return GeneralPlane(input) else: return buffer_surface + + +surface_builder = parse_surface +""" +Alias for :func:`parse_surface`. + +:deprecated: 1.0.0 + Renamed to be :func:`parse_surface` to be more pythonic. + +:param input: The Input object representing the input +:type input: Union[Input, str] +:returns: A Surface object properly parsed. If supported a sub-class of Surface will be given. +:rtype: Surface +""" diff --git a/montepy/surfaces/surface_type.py b/montepy/surfaces/surface_type.py index bd440c53..6fb86f47 100644 --- a/montepy/surfaces/surface_type.py +++ b/montepy/surfaces/surface_type.py @@ -2,7 +2,7 @@ from enum import unique, Enum -# @unique +@unique class SurfaceType(str, Enum): """ An enumeration of the surface types allowed. diff --git a/montepy/transforms.py b/montepy/transforms.py index fc5f26fd..ee858b58 100644 --- a/montepy/transforms.py +++ b/montepy/transforms.py @@ -1,4 +1,6 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations +import montepy from montepy.numbered_object_collection import NumberedDataObjectCollection from montepy.data_inputs.transform import Transform @@ -6,7 +8,12 @@ class Transforms(NumberedDataObjectCollection): """ A container of multiple :class:`~montepy.data_inputs.transform.Transform` instances. + + .. note:: + + For examples see the ``NumberedObjectCollection`` :ref:`collect ex`. + """ - def __init__(self, objects=None, problem=None): + def __init__(self, objects: list = None, problem: montepy.MCNP_Problem = None): super().__init__(Transform, objects, problem) diff --git a/montepy/universe.py b/montepy/universe.py index 3a17552a..e4b7a2e3 100644 --- a/montepy/universe.py +++ b/montepy/universe.py @@ -16,7 +16,7 @@ class Universe(Numbered_MCNP_Object): :type number: int """ - def __init__(self, number): + def __init__(self, number: int): self._number = self._generate_default_node(int, -1) if not isinstance(number, int): raise TypeError("number must be int") @@ -28,7 +28,7 @@ class Parser: def parse(self, token_gen, input): return syntax_node.SyntaxNode("fake universe", {}) - super().__init__(Input(["U"], BlockType.DATA), Parser()) + super().__init__(Input(["U"], BlockType.DATA), Parser(), number) @property def cells(self): diff --git a/montepy/universes.py b/montepy/universes.py index aefb9060..9fd3c3e0 100644 --- a/montepy/universes.py +++ b/montepy/universes.py @@ -1,4 +1,6 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations +import montepy from montepy.numbered_object_collection import NumberedObjectCollection from montepy.universe import Universe @@ -6,7 +8,12 @@ class Universes(NumberedObjectCollection): """ A container of multiple :class:`~montepy.universe.Universe` instances. + + .. note:: + + For examples see the ``NumberedObjectCollection`` :ref:`collect ex`. + """ - def __init__(self, objects=None, problem=None): + def __init__(self, objects: list = None, problem: montepy.MCNP_Problem = None): super().__init__(Universe, objects, problem) diff --git a/pyproject.toml b/pyproject.toml index 102e5331..22ecd05e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,8 @@ doc = [ "sphinxcontrib-apidoc", "pydata_sphinx_theme", "sphinx-favicon", - "sphinx-copybutton" + "sphinx-copybutton", + "sphinx_autodoc_typehints", ] format = ["black>=23.3.0"] build = [ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..8b31e928 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +from hypothesis import settings, Phase + +settings.register_profile( + "failfast", phases=[Phase.explicit, Phase.reuse, Phase.generate] +) diff --git a/tests/inputs/test.imcnp b/tests/inputs/test.imcnp index 074438b0..767df6b0 100644 --- a/tests/inputs/test.imcnp +++ b/tests/inputs/test.imcnp @@ -39,6 +39,7 @@ m1 92235.80c 5 & 92238.80c 95 C Iron m2 26054.80c 5.85 + plib= 80p 26056.80c 91.75 26057.80c 2.12 26058.80c 0.28 $ trailing comment shouldn't move #458. diff --git a/tests/inputs/test_importance.imcnp b/tests/inputs/test_importance.imcnp index a8d89e09..85f13222 100644 --- a/tests/inputs/test_importance.imcnp +++ b/tests/inputs/test_importance.imcnp @@ -26,6 +26,7 @@ C surfaces C data C materials +m0 plib=80p nlib=00c C UO2 5 atpt enriched m1 92235.80c 5 & 92238.80c 95 diff --git a/tests/test_cell_problem.py b/tests/test_cell_problem.py index 8dd1ed8c..4d2b1673 100644 --- a/tests/test_cell_problem.py +++ b/tests/test_cell_problem.py @@ -12,7 +12,7 @@ class TestCellClass(TestCase): def test_bad_init(self): with self.assertRaises(TypeError): - Cell("5") + Cell(5) # TODO test updating cell geometry once done def test_cell_validator(self): @@ -29,9 +29,7 @@ def test_cell_validator(self): # TODO test geometry stuff def test_number_setter(self): - in_str = "1 0 2" - card = Input([in_str], BlockType.CELL) - cell = Cell(card) + cell = Cell("1 0 2") cell.number = 5 self.assertEqual(cell.number, 5) with self.assertRaises(TypeError): @@ -160,6 +158,15 @@ def test_init(line, is_void, mat_number, density, atom_dens, parameters): assert cell.parameters[parameter]["data"][0].value == pytest.approx(value) +def test_blank_num_init(): + cell = Cell(number=5) + assert cell.number == 5 + with pytest.raises(TypeError): + Cell(number="hi") + with pytest.raises(ValueError): + Cell(number=-1) + + @pytest.mark.parametrize("line", ["foo", "foo bar", "1 foo", "1 1 foo"]) def test_malformed_init(line): with pytest.raises(montepy.errors.MalformedInputError): @@ -271,11 +278,12 @@ def verify_clone_format(cell): num = 1000 surf.number = num output = cell.format_for_mcnp_input((6, 3, 0)) + note(output) input = montepy.input_parser.mcnp_input.Input( output, montepy.input_parser.block_type.BlockType.CELL ) new_cell = montepy.Cell(input) - if cell.material: + if cell.material is not None: mats = montepy.materials.Materials([cell.material]) else: mats = [] @@ -320,3 +328,11 @@ def test_cell_clone_bad(args, error): cell.update_pointers([], [], surfs) with pytest.raises(error): cell.clone(*args) + + +def test_bad_setattr(): + cell = montepy.Cell() + with pytest.raises(AttributeError): + cell.nuber = 5 + cell._nuber = 5 + assert cell._nuber == 5 diff --git a/tests/test_data_inputs.py b/tests/test_data_inputs.py index 43eedb2d..3cbb8ad2 100644 --- a/tests/test_data_inputs.py +++ b/tests/test_data_inputs.py @@ -44,14 +44,13 @@ def test_data_card_format_mcnp(self): for answer, out in zip(in_strs, output): self.assertEqual(answer, out) - # TODO implement comment setting def test_comment_setter(self): in_str = "m1 1001.80c 1.0" input_card = Input([in_str], BlockType.DATA) - comment = "foo" + comment = syntax_node.CommentNode("foo") data_card = DataInput(input_card) - data_card.comment = comment - self.assertEqual(comment, data_card.comment) + data_card.leading_comments = [comment] + self.assertEqual(comment, data_card.comments[0]) def test_data_parser(self): identifiers = { diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 4745d76e..a9636ad2 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -19,7 +19,7 @@ def test_complement_edge_case(self): def test_surface_edge_case(self): capsule = montepy.read_input("tests/inputs/test_complement_edge.imcnp") problem_cell = capsule.cells[61441] - self.assertEqual(len(set(problem_cell.surfaces)), 6) + self.assertEqual(len(problem_cell.surfaces), 6) def test_interp_surface_edge_case(self): capsule = montepy.read_input("tests/inputs/test_interp_edge.imcnp") diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 00000000..02eb8ff5 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,30 @@ +import pytest + +import montepy +from montepy.mcnp_object import MCNP_Object + + +class TestErrorWrapper: + + def test_error_handler(_): + obj = ObjectFixture() + with pytest.raises(ValueError): + obj.bad_static() + with pytest.raises(ValueError): + obj.bad_class() + + +class ObjectFixture(MCNP_Object): + def __init__(self): + pass + + def _update_values(self): + pass + + @staticmethod + def bad_static(): + raise ValueError("foo") + + @classmethod + def bad_class(cls): + raise ValueError("bar") diff --git a/tests/test_geom_integration.py b/tests/test_geom_integration.py index 8ff2a422..1b415062 100644 --- a/tests/test_geom_integration.py +++ b/tests/test_geom_integration.py @@ -1,4 +1,4 @@ -from hypothesis import given +from hypothesis import given, assume, settings import hypothesis.strategies as st import montepy @@ -8,10 +8,15 @@ geom_pair = st.tuples(st.integers(min_value=1), st.booleans()) +@settings(max_examples=50, deadline=500) @given( - st.integers(min_value=1), st.lists(geom_pair, min_size=1, unique_by=lambda x: x[0]) + st.integers(min_value=1), + st.lists(geom_pair, min_size=1, max_size=10, unique_by=lambda x: x[0]), ) def test_build_arbitrary_cell_geometry(first_surf, new_surfaces): + assume( + len({first_surf, *[num for num, _ in new_surfaces]}) == len(new_surfaces) + 1 + ) input = montepy.input_parser.mcnp_input.Input( [f"1 0 {first_surf} imp:n=1"], montepy.input_parser.block_type.BlockType.CELL ) @@ -38,3 +43,13 @@ def test_cell_geometry_set_warns(): surf = montepy.surfaces.surface.Surface() surf.number = 5 cell.geometry &= +surf + + +def test_geom_invalid(): + surf = montepy.AxisPlane() + with pytest.raises(montepy.errors.IllegalState): + -surf + with pytest.raises(montepy.errors.IllegalState): + +surf + with pytest.raises(montepy.errors.IllegalState): + ~montepy.Cell() diff --git a/tests/test_geometry.py b/tests/test_geometry.py index effa8795..002f9585 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -10,6 +10,7 @@ def test_halfspace_init(): surface = montepy.surfaces.CylinderOnAxis() + surface.number = 1 node = montepy.input_parser.syntax_node.GeometryTree("hi", {}, "*", " ", " ") half_space = HalfSpace(+surface, Operator.UNION, -surface, node) assert half_space.operator is Operator.UNION @@ -32,16 +33,20 @@ def test_halfspace_init(): def test_get_leaves(): surface = montepy.surfaces.CylinderOnAxis() + surface.number = 1 cell = montepy.Cell() + cell.number = 1 half_space = -surface & ~cell cells, surfaces = half_space._get_leaf_objects() - assert cells == {cell} - assert surfaces == {surface} + assert cells == montepy.cells.Cells([cell]) + assert surfaces == montepy.surface_collection.Surfaces([surface]) def test_half_len(): surface = montepy.surfaces.CylinderOnAxis() cell = montepy.Cell() + surface.number = 1 + cell.number = 1 half_space = -surface & ~cell assert len(half_space) == 2 @@ -49,6 +54,8 @@ def test_half_len(): def test_half_eq(): cell1 = montepy.Cell() cell2 = montepy.Cell() + cell1.number = 1 + cell2.number = 2 half1 = ~cell1 & ~cell2 assert half1 == half1 half2 = ~cell1 | ~cell2 @@ -125,6 +132,7 @@ def test_unit_str(): # test geometry integration def test_surface_half_space(): surface = montepy.surfaces.cylinder_on_axis.CylinderOnAxis() + surface.number = 1 half_space = +surface assert isinstance(half_space, HalfSpace) assert isinstance(half_space, UnitHalfSpace) @@ -145,6 +153,7 @@ def test_surface_half_space(): def test_cell_half_space(): cell = montepy.Cell() + cell.number = 1 half_space = ~cell assert isinstance(half_space, HalfSpace) assert half_space.left.divider is cell @@ -176,8 +185,12 @@ def test_parens_node_export(): def test_intersect_half_space(): - cell1 = ~montepy.Cell() - cell2 = ~montepy.Cell() + cell1 = montepy.Cell() + cell2 = montepy.Cell() + cell1.number = 1 + cell2.number = 2 + cell1 = ~cell1 + cell2 = ~cell2 half_space = cell1 & cell2 assert isinstance(half_space, HalfSpace) assert half_space.operator is Operator.INTERSECTION @@ -194,8 +207,12 @@ def test_intersect_half_space(): def test_union_half_space(): - cell1 = ~montepy.Cell() - cell2 = ~montepy.Cell() + cell1 = montepy.Cell() + cell2 = montepy.Cell() + cell1.number = 1 + cell2.number = 2 + cell1 = ~cell1 + cell2 = ~cell2 half_space = cell1 | cell2 assert isinstance(half_space, HalfSpace) assert half_space.operator is Operator.UNION @@ -208,8 +225,12 @@ def test_union_half_space(): def test_invert_half_space(): - cell1 = ~montepy.Cell() - cell2 = ~montepy.Cell() + cell1 = montepy.Cell() + cell2 = montepy.Cell() + cell1.number = 1 + cell2.number = 2 + cell1 = ~cell1 + cell2 = ~cell2 half_space1 = cell1 | cell2 half_space = ~half_space1 assert isinstance(half_space, HalfSpace) @@ -222,10 +243,10 @@ def test_iand_recursion(): cell1 = montepy.Cell() cell2 = montepy.Cell() cell3 = montepy.Cell() - half_space = ~cell1 & ~cell2 cell1.number = 1 cell2.number = 2 cell3.number = 3 + half_space = ~cell1 & ~cell2 cell3.geometry = half_space half_space &= ~cell1 assert half_space.left == ~cell1 @@ -241,6 +262,7 @@ def test_iand_recursion(): half_space &= "hi" # test with unit halfspaces surf = montepy.surfaces.CylinderParAxis() + surf.number = 5 # test going from leaf to tree half_space = -surf half_space &= +surf @@ -252,8 +274,12 @@ def test_iand_recursion(): def test_ior_recursion(): - cell1 = ~montepy.Cell() - cell2 = ~montepy.Cell() + cell1 = montepy.Cell() + cell2 = montepy.Cell() + cell1.number = 1 + cell2.number = 2 + cell1 = ~cell1 + cell2 = ~cell2 half_space = cell1 | cell2 half_space |= cell1 assert half_space.left is cell1 @@ -269,6 +295,7 @@ def test_ior_recursion(): half_space |= "hi" # test with unit halfspaces surf = montepy.surfaces.CylinderParAxis() + surf.number = 5 half_space = -surf half_space |= +surf assert len(half_space) == 2 diff --git a/tests/test_integration.py b/tests/test_integration.py index c569c31c..278cd6d1 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -130,23 +130,23 @@ def test_cells_parsing_linking(simple_problem): mats = simple_problem.materials mat_answer = [mats[1], mats[2], mats[3], None, None] surfs = simple_problem.surfaces + Surfaces = montepy.surface_collection.Surfaces surf_answer = [ - {surfs[1000]}, - {surfs[1005], *surfs[1015:1026]}, - set(surfs[1000:1011]), - {surfs[1010]}, - set(), + Surfaces([surfs[1000]]), + Surfaces([surfs[1005], *surfs[1015:1026]]), + surfs[1000:1011], + Surfaces([surfs[1010]]), + Surfaces(), ] cells = simple_problem.cells - complements = [set()] * 4 + [{cells[99]}] + complements = [montepy.cells.Cells()] * 4 + [cells[99:100]] for i, cell in enumerate(simple_problem.cells): print(cell) print(surf_answer[i]) assert cell.number == cell_numbers[i] assert cell.material == mat_answer[i] - surfaces = set(cell.surfaces) - assert surfaces.union(surf_answer[i]) == surfaces - assert set(cell.complements).union(complements[i]) == complements[i] + assert cell.surfaces.union(surf_answer[i]) == surf_answer[i] + assert cell.complements.union(complements[i]) == complements[i] def test_message(simple_problem): @@ -223,18 +223,22 @@ def test_cell_material_setter(simple_problem): def test_problem_cells_setter(simple_problem): problem = copy.deepcopy(simple_problem) - cells = copy.deepcopy(simple_problem.cells) - cells.remove(cells[1]) + # TODO test cells clone + cells = problem.cells.clone() + cells.remove(cells[4]) with pytest.raises(TypeError): problem.cells = 5 with pytest.raises(TypeError): problem.cells = [5] with pytest.raises(TypeError): problem.cells.append(5) + # handle cell complement copying + old_cell = problem.cells[99] problem.cells = cells - assert problem.cells.objects == cells.objects + cells.append(old_cell) + assert problem.cells == cells problem.cells = list(cells) - assert problem.cells[2] == cells[2] + assert problem.cells[6] == cells[6] # test that cell modifiers are still there problem.cells._importance.format_for_mcnp_input((6, 2, 0)) @@ -269,7 +273,6 @@ def test_problem_children_adder(simple_problem): cell.number = cell_num cell.universe = problem.universes[350] problem.cells.append(cell) - problem.add_cell_children_to_problem() assert surf in problem.surfaces assert mat in problem.materials assert mat in problem.data_inputs @@ -286,6 +289,26 @@ def test_problem_children_adder(simple_problem): assert "U=350" in "\n".join(output).upper() +def test_children_adder_hidden_tr(simple_problem): + problem = copy.deepcopy(simple_problem) + in_str = "260 0 -1000 fill = 350 (1 0 0)" + input = montepy.input_parser.mcnp_input.Input( + [in_str], montepy.input_parser.block_type.BlockType.CELL + ) + cell = montepy.Cell(input) + cell.update_pointers(problem.cells, problem.materials, problem.surfaces) + problem.cells.add(cell) + assert cell.fill.transform not in problem.transforms + # test blank _fill_transform + in_str = "261 0 -1000 fill = 350" + input = montepy.input_parser.mcnp_input.Input( + [in_str], montepy.input_parser.block_type.BlockType.CELL + ) + cell = montepy.Cell(input) + cell.update_pointers(problem.cells, problem.materials, problem.surfaces) + problem.cells.add(cell) + + def test_problem_mcnp_version_setter(simple_problem): problem = copy.deepcopy(simple_problem) with pytest.raises(ValueError): @@ -578,7 +601,7 @@ def test_importance_write_cell(importance_problem): fh = io.StringIO() problem = copy.deepcopy(importance_problem) if "new" in state: - cell = copy.deepcopy(problem.cells[5]) + cell = problem.cells[5].clone() cell.number = 999 problem.cells.append(cell) problem.print_in_data_block["imp"] = False @@ -775,6 +798,8 @@ def test_cell_not_truncate_setter(simple_problem): with pytest.raises(ValueError): cell = problem.cells[2] cell.not_truncated = True + with pytest.raises(TypeError): + cell.not_truncated = 5 def test_universe_setter(simple_problem): @@ -815,7 +840,7 @@ def test_universe_data_formatter(data_universe_problem): print(output) assert "u 350 J -350 -1" in output # test appending a new mutated cell - new_cell = copy.deepcopy(cell) + new_cell = cell.clone() new_cell.number = 1000 new_cell.universe = universe new_cell.not_truncated = False @@ -827,7 +852,7 @@ def test_universe_data_formatter(data_universe_problem): # test appending a new UNmutated cell problem = copy.deepcopy(data_universe_problem) cell = problem.cells[3] - new_cell = copy.deepcopy(cell) + new_cell = cell.clone() new_cell.number = 1000 new_cell.universe = universe new_cell.not_truncated = False @@ -1166,3 +1191,23 @@ def test_read_write_cycle(file): ) else: raise e + + +def test_arbitrary_parse(simple_problem): + simple_problem = simple_problem.clone() + for append in [False, True]: + cell = simple_problem.parse("20 0 -1005", append) + assert (cell in simple_problem.cells) == append + assert cell.number == 20 + assert cell.surfaces[1005] in simple_problem.surfaces + surf = simple_problem.parse("5 SO 7.5", append) + assert (surf in simple_problem.surfaces) == append + assert surf.number == 5 + mat = simple_problem.parse("m123 1001.80c 1.0 8016.80c 2.0", append) + assert (mat in simple_problem.materials) == append + assert (mat in simple_problem.data_inputs) == append + assert mat.number == 123 + transform = simple_problem.parse("tr25 0 0 1", append) + assert (transform in simple_problem.transforms) == append + with pytest.raises(ParsingError): + simple_problem.parse("123 hello this is invalid") diff --git a/tests/test_material.py b/tests/test_material.py index 144acbab..71010351 100644 --- a/tests/test_material.py +++ b/tests/test_material.py @@ -1,45 +1,344 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. -from hypothesis import given, strategies as st -from unittest import TestCase +from hypothesis import given, strategies as st, settings, HealthCheck import pytest +from hypothesis import assume, given, note, strategies as st import montepy from montepy.data_inputs.element import Element -from montepy.data_inputs.isotope import Isotope -from montepy.data_inputs.material import Material +from montepy.data_inputs.nuclide import Nucleus, Nuclide, Library +from montepy.data_inputs.material import Material, _DefaultLibraries as DL from montepy.data_inputs.material_component import MaterialComponent from montepy.data_inputs.thermal_scattering import ThermalScatteringLaw from montepy.errors import MalformedInputError, UnknownElement from montepy.input_parser.block_type import BlockType from montepy.input_parser.mcnp_input import Input - - -class testMaterialClass(TestCase): - def test_material_parameter_parsing(self): - for line in ["M20 1001.80c 1.0 gas=0", "M20 1001.80c 1.0 gas = 0 nlib = 00c"]: - input = Input([line], BlockType.CELL) - material = Material(input) - - def test_material_validator(self): +from montepy.particle import LibraryType + + +# test material +class TestMaterial: + @pytest.fixture + def big_material(_): + components = [ + "h1.00c", + "h1.04c", + "h1.80c", + "h1.04p", + "h2", + "h3", + "th232", + "th232.701nc", + "U235", + "U235.80c", + "U235m1.80c", + "u238", + "am242", + "am242m1", + "Pu239", + ] + mat = Material() + mat.number = 1 + for component in components: + mat.add_nuclide(component, 0.05) + return mat + + @pytest.fixture + def big_mat_lib(_, big_material): + mat = big_material + mat.default_libraries["nlib"] = "00c" + mat.default_libraries["plib"] = "80p" + return mat + + @pytest.fixture + def prob_default(_): + prob = montepy.MCNP_Problem("hi") + prob.materials.default_libraries["alib"] = "24a" + return prob + + @pytest.mark.parametrize( + "isotope_str, lib_type, lib_str", + [ + ("H-1.80c", "nlib", "80c"), + ("H-1.80c", "plib", "80p"), + ("H-1.80c", "hlib", None), + ("H-1.80c", "alib", "24a"), + ], + ) + def test_mat_get_nuclide_library( + _, big_mat_lib, prob_default, isotope_str, lib_type, lib_str + ): + nuclide = Nuclide(isotope_str) + if lib_str: + lib = Library(lib_str) + big_mat_lib.link_to_problem(prob_default) + else: + lib = None + assert big_mat_lib.get_nuclide_library(nuclide, lib_type) == lib + assert ( + big_mat_lib.get_nuclide_library(nuclide, LibraryType(lib_type.upper())) + == lib + ) + if lib is None: + big_mat_lib.link_to_problem(prob_default) + assert big_mat_lib.get_nuclide_library(nuclide, lib_type) == lib + # test iter, items defaults + for iter_key, (item_key, item_val) in zip( + big_mat_lib.default_libraries, big_mat_lib.default_libraries.items() + ): + assert iter_key == item_key + assert big_mat_lib.default_libraries[iter_key] == item_val + + def test_mat_get_nuclide_library_bad(_, big_mat_lib): + with pytest.raises(TypeError): + big_mat_lib.get_nuclide_library(5, "nlib") + with pytest.raises(TypeError): + big_mat_lib.get_nuclide_library("1001.80c", 5) + + def test_material_parameter_parsing(_): + for line in [ + "M20 1001.80c 1.0 gas=0", + "M20 1001.80c 1.0 gas = 0 nlib = 00c", + "M120 nlib=80c 1001 1.0", + ]: + material = Material(line) + + def test_material_validator(_): material = Material() - with self.assertRaises(montepy.errors.IllegalState): + with pytest.raises(montepy.errors.IllegalState): material.validate() - with self.assertRaises(montepy.errors.IllegalState): + with pytest.raises(montepy.errors.IllegalState): material.format_for_mcnp_input((6, 2, 0)) - def test_material_setter(self): - in_str = "M20 1001.80c 0.5 8016.80c 0.5" + def test_material_number_setter(_): + in_str = "M20 1001.80c 0.5 8016.80c 0.5" input_card = Input([in_str], BlockType.DATA) material = Material(input_card) material.number = 30 - self.assertEqual(material.number, 30) - with self.assertRaises(TypeError): + assert material.number == 30 + with pytest.raises(TypeError): material.number = "foo" - with self.assertRaises(ValueError): + with pytest.raises(ValueError): material.number = -5 + _.verify_export(material) - def test_material_str(self): + @pytest.mark.filterwarnings("ignore") + def test_material_is_atom_frac_setter(_, big_material): + in_str = "M20 1001.80c 0.5 8016.80c 0.5" + input = Input([in_str], BlockType.DATA) + material = Material(input) + assert material.is_atom_fraction + _.verify_export(material) + material.is_atom_fraction = False + assert not material.is_atom_fraction + _.verify_export(material) + for frac_type in [False, True]: + big_material.is_atom_fraction = frac_type + assert big_material.is_atom_fraction == frac_type + _.verify_export(big_material) + + def test_material_getter_iter(_, big_material): + for i, (nuclide, frac) in enumerate(big_material): + gotten = big_material[i] + assert gotten[0] == nuclide + assert gotten[1] == pytest.approx(frac) + comp_0, comp_1 = big_material[0:2] + assert comp_0 == big_material[0] + assert comp_1 == big_material[1] + _, comp_1 = big_material[0:4:3] + assert comp_1 == big_material[3] + with pytest.raises(TypeError): + big_material["hi"] + + def test_material_setter(_, big_material): + big_material[2] = (Nuclide("1001.80c"), 1.0) + assert big_material[2][0] == Nuclide("1001.80c") + assert big_material[2][1] == pytest.approx(1.0) + with pytest.raises(TypeError): + big_material["hi"] = 5 + with pytest.raises(TypeError): + big_material[2] = 5 + with pytest.raises(ValueError): + big_material[2] = (5,) + with pytest.raises(TypeError): + big_material[2] = (5, 1.0) + with pytest.raises(TypeError): + big_material[2] = (Nuclide("1001.80c"), "hi") + with pytest.raises(ValueError): + big_material[2] = (Nuclide("1001.80c"), -1.0) + _.verify_export(big_material) + + def test_material_deleter(_, big_material): + old_comp = big_material[6] + del big_material[6] + assert old_comp[0] not in big_material.nuclides + old_comps = big_material[0:2] + del big_material[0:2] + for nuc, _f in old_comps: + assert nuc not in big_material.nuclides + with pytest.raises(TypeError): + del big_material["hi"] + pu_comp = big_material[-1] + del big_material[-1] + assert pu_comp[0] not in big_material.nuclides + _.verify_export(big_material) + + def test_material_values(_, big_material): + # test iter + for value in big_material.values: + assert value == pytest.approx(0.05) + assert len(list(big_material.values)) == len(big_material) + # test getter setter + for i, comp in enumerate(big_material): + assert big_material.values[i] == pytest.approx(comp[1]) + big_material.values[i] = 1.0 + assert big_material[i][1] == pytest.approx(1.0) + with pytest.raises(TypeError): + big_material.values["hi"] + with pytest.raises(IndexError): + big_material.values[len(big_material) + 1] + with pytest.raises(TypeError): + big_material.values[0] = "hi" + with pytest.raises(ValueError): + big_material.values[0] = -1.0 + _.verify_export(big_material) + + def test_material_nuclides(_, big_material): + # test iter + for nuclide, comp in zip(big_material.nuclides, big_material): + assert nuclide == comp[0] + # test getter setter + for i, comp in enumerate(big_material): + assert big_material.nuclides[i] == comp[0] + big_material.nuclides[i] = Nuclide("1001.80c") + assert big_material[i][0] == Nuclide("1001.80c") + with pytest.raises(TypeError): + big_material.nuclides["hi"] + with pytest.raises(IndexError): + big_material.nuclides[len(big_material) + 1] + with pytest.raises(TypeError): + big_material.nuclides[0] = "hi" + _.verify_export(big_material) + + @given(st.integers(1, 99), st.floats(1.9, 2.3), st.floats(0, 20, allow_nan=False)) + def test_material_append(_, Z, a_multiplier, fraction): + mat = Material() + mat.number = 5 + A = int(Z * a_multiplier) + zaid = Z * 1000 + A + nuclide = Nuclide(zaid) + mat.append((nuclide, fraction)) + assert mat[0][0] == nuclide + assert mat[0][1] == pytest.approx(fraction) + _.verify_export(mat) + + def test_material_append_bad(_): + mat = Material() + with pytest.raises(TypeError): + mat.append(5) + with pytest.raises(ValueError): + mat.append((1, 2, 3)) + with pytest.raises(TypeError): + mat.append(("hi", 1)) + with pytest.raises(TypeError): + mat.append((Nuclide("1001.80c"), "hi")) + with pytest.raises(ValueError): + mat.append((Nuclide("1001.80c"), -1.0)) + + @pytest.mark.parametrize( + "content, is_in", + [ + ("1001.80c", True), + ("H-1", True), + (Element(1), True), + (Nucleus(Element(1), 1), True), + (Element(43), False), + ("B-10.00c", False), + ("H", True), + (Nucleus(Element(5), 10), False), + ], + ) + def test_material_contains(_, big_material, content, is_in): + assert is_in == (content in big_material), "Contains didn't work properly" + assert is_in == big_material.contains(content) + with pytest.raises(TypeError): + 5 in big_material + + def test_material_multi_contains(_, big_material): + assert big_material.contains("1001", "U-235", "Pu-239", threshold=0.01) + assert not big_material.contains("1001", "U-235", "Pu-239", threshold=0.07) + assert not big_material.contains("U-235", "B-10") + + def test_material_contains_bad(_): + mat = Material() + with pytest.raises(TypeError): + mat.contains(mat) + with pytest.raises(TypeError): + mat.contains("1001", mat) + with pytest.raises(ValueError): + mat.contains("hi") + with pytest.raises(TypeError): + mat.contains("1001", threshold="hi") + with pytest.raises(ValueError): + mat.contains("1001", threshold=-1.0) + + def test_material_normalize(_, big_material): + # make sure it's not an invalid starting condition + assert sum(big_material.values) != pytest.approx(1.0) + answer = 1.0 / len(big_material) + big_material.normalize() + for value in big_material.values: + assert value == pytest.approx(answer) + + @pytest.mark.parametrize( + "kwargs, length", + [ + ({"name": "H"}, 6), + ({"name": "H-1"}, 4), + ({"name": "H-1.04c"}, 1), + ({"name": "H-1.00c"}, 1), + ({"name": "U235m1"}, 1), + ({"element": Element(1)}, 6), + ({"element": "H"}, 6), + ({"element": slice(92, 95)}, 5), + ({"A": 1}, 4), + ({"A": slice(235, 240)}, 5), + ({"A": slice(232, 243, 2)}, 5), + ({"A": slice(None)}, 15), + ({"meta_state": 0}, 13), + ({"meta_state": 1}, 2), + ({"meta_state": slice(0, 2)}, 15), + ({"library": "80c"}, 3), + ({"library": slice("00c", "10c")}, 2), + ], + ) + def test_material_find(_, big_material, kwargs, length): + returned = list(big_material.find(**kwargs)) + assert len(returned) == length + for idx, (nuclide, fraction) in returned: + assert isinstance(idx, int) + assert isinstance(nuclide, Nuclide) + assert isinstance(fraction, float) + returned = list(big_material.find_vals(**kwargs)) + assert len(returned) == length + for fraction in returned: + assert isinstance(fraction, float) + + def test_material_find_bad(_, big_material): + with pytest.raises(TypeError): + list(big_material.find(_)) + with pytest.raises(ValueError): + list(big_material.find("not_good")) + with pytest.raises(TypeError): + list(big_material.find(A="hi")) + with pytest.raises(TypeError): + list(big_material.find(meta_state="hi")) + with pytest.raises(TypeError): + list(big_material.find(element=1.23)) + with pytest.raises(TypeError): + list(big_material.find(library=5)) + + def test_material_str(_): in_str = "M20 1001.80c 0.5 8016.80c 0.4 94239.80c 0.1" input_card = Input([in_str], BlockType.DATA) material = Material(input_card) @@ -53,9 +352,10 @@ def test_material_str(self): print(output) assert output == answers output = str(material) + print(output) assert output == "MATERIAL: 20, ['hydrogen', 'oxygen', 'plutonium']" - def test_material_sort(self): + def test_material_sort(_): in_str = "M20 1001.80c 0.5 8016.80c 0.5" input_card = Input([in_str], BlockType.DATA) material1 = Material(input_card) @@ -65,337 +365,252 @@ def test_material_sort(self): sort_list = sorted([material2, material1]) answers = [material1, material2] for i, mat in enumerate(sort_list): - self.assertEqual(mat, answers[i]) - - -@pytest.mark.parametrize( - "isotope_str, atom_frac, fraction", - [("2004.80c", False, 0.1), ("1001.70c", True, 0.1)], -) -@pytest.mark.filterwarnings("ignore") -def test_material_component_add(isotope_str, atom_frac, fraction): - frac_marker = "-" if not atom_frac else "" - in_str = f"M20 1001.80c {frac_marker}0.5 8016.80c {frac_marker}0.5" - input_card = Input([in_str], BlockType.DATA) - material = Material(input_card) - iso = Isotope(isotope_str) - comp = MaterialComponent(iso, fraction) - material.material_components[iso] = comp - verify_export(material) - - -def verify_export(mat): - output = mat.format_for_mcnp_input((6, 3, 0)) - print(output) - new_mat = Material(Input(output, BlockType.DATA)) - assert mat.number == new_mat.number, "Material number not preserved." - assert len(mat.material_components) == len(new_mat.material_components) - assert mat.is_atom_fraction == new_mat.is_atom_fraction - for old_comp, new_comp in zip( - mat.material_components.values(), new_mat.material_components.values() - ): - assert str(old_comp.isotope) == str( - new_comp.isotope - ), "Material didn't preserve nuclides." - assert old_comp.fraction == pytest.approx(new_comp.fraction) - - -def test_material_format_mcnp(): - in_strs = ["M20 1001.80c 0.5", " 8016.80c 0.5"] - input_card = Input(in_strs, BlockType.DATA) - material = Material(input_card) - material.number = 25 - answers = ["M25 1001.80c 0.5", " 8016.80c 0.5"] - output = material.format_for_mcnp_input((6, 2, 0)) - assert output == answers - - -@pytest.mark.parametrize( - "isotope, conc, error", - [ - ("1001.80c", -0.1, ValueError), - ("1001.80c", "hi", TypeError), - ("hi", 1.0, ValueError), - ], -) -def test_material_comp_init(isotope, conc, error): - with pytest.raises(error): - MaterialComponent(Isotope(isotope, suppress_warning=True), conc, True) - - -def test_mat_comp_init_warn(): - with pytest.warns(DeprecationWarning): - MaterialComponent(Isotope("1001.80c", suppress_warning=True), 0.1) - - -def test_material_comp_fraction_setter(): - comp = MaterialComponent(Isotope("1001.80c", suppress_warning=True), 0.1, True) - comp.fraction = 5.0 - assert comp.fraction == pytest.approx(5.0) - with pytest.raises(ValueError): - comp.fraction = -1.0 - with pytest.raises(TypeError): - comp.fraction = "hi" - - -def test_material_comp_fraction_str(): - comp = MaterialComponent(Isotope("1001.80c", suppress_warning=True), 0.1, True) - str(comp) - repr(comp) - - -def test_material_update_format(): - in_str = "M20 1001.80c 0.5 8016.80c 0.5" - input_card = Input([in_str], BlockType.DATA) - material = Material(input_card) - assert material.format_for_mcnp_input((6, 2, 0)) == [in_str] - material.number = 5 - print(material.format_for_mcnp_input((6, 2, 0))) - assert "8016" in material.format_for_mcnp_input((6, 2, 0))[0] - # addition - isotope = Isotope("2004.80c", suppress_warning=True) - with pytest.deprecated_call(): - material.material_components[isotope] = MaterialComponent(isotope, 0.1, True) - print(material.format_for_mcnp_input((6, 2, 0))) - assert "2004" in material.format_for_mcnp_input((6, 2, 0))[0] - # update - isotope = list(material.material_components.keys())[-1] - print(material.material_components.keys()) - material.material_components[isotope].fraction = 0.7 - print(material.format_for_mcnp_input((6, 2, 0))) - assert "0.7" in material.format_for_mcnp_input((6, 2, 0))[0] - material.material_components[isotope] = MaterialComponent(isotope, 0.6, True) - print(material.format_for_mcnp_input((6, 2, 0))) - assert "0.6" in material.format_for_mcnp_input((6, 2, 0))[0] - # delete - del material.material_components[isotope] - print(material.format_for_mcnp_input((6, 2, 0))) - assert "8016" in material.format_for_mcnp_input((6, 2, 0))[0] - - -@pytest.mark.parametrize( - "line, mat_number, is_atom, fractions", - [ - ("M20 1001.80c 0.5 8016.710nc 0.5", 20, True, [0.5, 0.5]), - ("m1 1001 0.33 8016 0.666667", 1, True, [0.33, 0.666667]), - ("M20 1001 0.5 8016 0.5", 20, True, [0.5, 0.5]), - ("M20 1001.80c -0.5 8016.80c -0.5", 20, False, [0.5, 0.5]), - ("M20 1001.80c -0.5 8016.710nc -0.5", 20, False, [0.5, 0.5]), - ("M20 1001.80c 0.5 8016.80c 0.5 Gas=1", 20, True, [0.5, 0.5]), - ( - "m1 8016.71c 2.6999999-02 8017.71c 9.9999998-01 plib=84p", - 1, - True, - [2.6999999e-2, 9.9999998e-01], - ), - *[ - (f"M20 1001.80c 0.5 8016.80c 0.5 {part}={lib}", 20, True, [0.5, 0.5]) - for part, lib in [ - ("nlib", "80c"), - ("nlib", "701nc"), - ("estep", 1), - ("pnlib", "710nc"), - ("slib", "80c"), - ] + assert mat == answers[i] + + def test_material_format_mcnp(_): + in_strs = ["M20 1001.80c 0.5", " 8016.80c 0.5"] + input_card = Input(in_strs, BlockType.DATA) + material = Material(input_card) + material.number = 25 + answers = ["M25 1001.80c 0.5", " 8016.80c 0.5"] + output = material.format_for_mcnp_input((6, 2, 0)) + assert output == answers + + def test_material_comp_init(_): + with pytest.raises(DeprecationWarning): + MaterialComponent(Nuclide("1001"), 0.1) + + def test_mat_comp_init_warn(_): + with pytest.raises(DeprecationWarning): + MaterialComponent(Nuclide("1001.80c"), 0.1) + + def test_mat_eq(_, big_material): + new_mat = big_material.clone() + new_mat.number = big_material.number + assert new_mat == big_material + assert new_mat != 5 + new_mat.values[-1] += 1.5 + assert new_mat != big_material + new_mat.nuclides[-1].library = "09c" + assert new_mat != big_material + del new_mat[0] + assert new_mat != big_material + new_mat.number = 23 + assert new_mat != big_material + + def test_mat_long_str(_, big_material): + for i in range(23, 30): + big_material.add_nuclide(Nuclide(element=Element(i)), 0.123) + str(big_material) + repr(big_material) + + @pytest.mark.parametrize( + "line, mat_number, is_atom, fractions", + [ + ("M20 1001.80c 0.5 8016.710nc 0.5", 20, True, [0.5, 0.5]), + ("m1 1001 0.33 8016 0.666667", 1, True, [0.33, 0.666667]), + ("M20 1001 0.5 8016 0.5", 20, True, [0.5, 0.5]), + ("M20 1001.80c -0.5 8016.80c -0.5", 20, False, [0.5, 0.5]), + ("M20 1001.80c -0.5 8016.710nc -0.5", 20, False, [0.5, 0.5]), + ("M20 1001.80c 0.5 8016.80c 0.5 Gas=1", 20, True, [0.5, 0.5]), + ( + "m1 8016.71c 2.6999999-02 8017.71c 9.9999998-01 plib=84p", + 1, + True, + [2.6999999e-2, 9.9999998e-01], + ), + *[ + (f"M20 1001.80c 0.5 8016.80c 0.5 {part}={lib}", 20, True, [0.5, 0.5]) + for part, lib in [ + ("nlib", "80c"), + ("nlib", "701nc"), + ("estep", 1), + ("pnlib", "710nc"), + ("slib", "80c"), + ] + ], ], - ], -) -def test_material_init(line, mat_number, is_atom, fractions): - input = Input([line], BlockType.DATA) - material = Material(input) - assert material.number == mat_number - assert material.old_number == mat_number - assert material.is_atom_fraction == is_atom - with pytest.deprecated_call(): - for component, gold in zip(material.material_components.values(), fractions): - assert component.fraction == pytest.approx(gold) - if "gas" in line: - assert material.parameters["gas"]["data"][0].value == pytest.approx(1.0) - - -@pytest.mark.parametrize( - "line", ["Mfoo", "M-20", "M20 1001.80c foo", "M20 1001.80c 0.5 8016.80c -0.5"] -) -def test_bad_init(line): - # test invalid material number - input = Input([line], BlockType.DATA) - with pytest.raises(MalformedInputError): - Material(input) - - -@pytest.mark.filterwarnings("ignore") -@given(st.integers(), st.integers()) -def test_mat_clone(start_num, step): - input = Input(["m1 1001.80c 0.3 8016.80c 0.67"], BlockType.DATA) - mat = Material(input) - problem = montepy.MCNP_Problem("foo") - for prob in {None, problem}: - mat.link_to_problem(prob) - if prob is not None: - problem.materials.append(mat) - if start_num <= 0 or step <= 0: - with pytest.raises(ValueError): - mat.clone(start_num, step) - return - new_mat = mat.clone(start_num, step) - assert new_mat is not mat - for (iso, fraction), (gold_iso, gold_fraction) in zip( - new_mat.material_components.items(), mat.material_components.items() + ) + def test_material_init(_, line, mat_number, is_atom, fractions): + input = Input([line], BlockType.DATA) + material = Material(input) + assert material.number == mat_number + assert material.old_number == mat_number + assert material.is_atom_fraction == is_atom + for component, gold in zip(material, fractions): + assert component[1] == pytest.approx(gold) + if "gas" in line: + assert material.parameters["gas"]["data"][0].value == pytest.approx(1.0) + + @pytest.mark.parametrize( + "line", ["Mfoo", "M-20", "M20 1001.80c foo", "M20 1001.80c 0.5 8016.80c -0.5"] + ) + def test_bad_init(_, line): + # test invalid material number + input = Input([line], BlockType.DATA) + with pytest.raises(MalformedInputError): + Material(input) + + @pytest.mark.filterwarnings("ignore") + @given(st.integers(), st.integers()) + def test_mat_clone(_, start_num, step): + input = Input(["m1 1001.80c 0.3 8016.80c 0.67"], BlockType.DATA) + mat = Material(input) + problem = montepy.MCNP_Problem("foo") + for prob in {None, problem}: + mat.link_to_problem(prob) + if prob is not None: + problem.materials.append(mat) + if start_num <= 0 or step <= 0: + with pytest.raises(ValueError): + mat.clone(start_num, step) + return + new_mat = mat.clone(start_num, step) + assert new_mat is not mat + for (iso, fraction), (gold_iso, gold_fraction) in zip(new_mat, mat): + assert iso is not gold_iso + assert iso.ZAID == gold_iso.ZAID + assert fraction == pytest.approx(gold_fraction) + assert new_mat._number is new_mat._tree["classifier"].number + output = new_mat.format_for_mcnp_input((6, 3, 0)) + input = Input(output, BlockType.DATA) + newer_mat = Material(input) + assert newer_mat.number == new_mat.number + + @pytest.mark.parametrize( + "args, error", + [ + (("c", 1), TypeError), + ((1, "d"), TypeError), + ((-1, 1), ValueError), + ((0, 1), ValueError), + ((1, 0), ValueError), + ((1, -1), ValueError), + ], + ) + def test_mat_clone_bad(_, args, error): + input = Input(["m1 1001.80c 0.3 8016.80c 0.67"], BlockType.CELL) + mat = Material(input) + with pytest.raises(error): + mat.clone(*args) + + def test_mat_num_init(_): + mat = Material(number=5) + assert mat.number == 5 + + @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) + @given( + lib_num=st.integers(0, 99), + extra_char=st.characters(min_codepoint=97, max_codepoint=122), + lib_suffix=st.sampled_from("cdmgpuyehorsa"), + ) + def test_mat_change_lib(_, big_material, lib_num, extra_char, lib_suffix): + mat = big_material.clone() + library = f"{lib_num:02g}" + if lib_num >= 100: + library += extra_char + library += lib_suffix + for wrapper in {str, Library}: + mat.change_libraries(wrapper(library)) + for nuclide in mat.nuclides: + assert nuclide.library == Library(library) + _.verify_export(mat) + + def test_mat_change_lib_bad(_): + mat = Material() + with pytest.raises(TypeError): + mat.change_libraries(5) + with pytest.raises(ValueError): + mat.change_libraries("hi") + + @given(st.integers(1, 99), st.floats(1.9, 2.3), st.floats(0, 20, allow_nan=False)) + def test_mat_add_nuclide(_, Z, a_multiplier, fraction): + mat = montepy.Material() + A = int(Z * a_multiplier) + ZAID = Z * 1000 + A + for wrapper in {str, Nuclide}: + mat.add_nuclide(wrapper(ZAID), fraction) + assert mat.nuclides[-1].ZAID == ZAID + assert mat.values[-1] == fraction + with pytest.raises(TypeError): + mat.add_nuclide(5.0, 5.0) + with pytest.raises(TypeError): + mat.add_nuclide(Nuclide("1001.80c"), "hi") + with pytest.raises(ValueError): + mat.add_nuclide(Nuclide("1001.80c"), -1.0) + + @pytest.mark.filterwarnings("ignore::montepy.errors.LineExpansionWarning") + def test_add_nuclide_export(_, big_material): + _.verify_export(big_material) + + def verify_export(_, mat): + output = mat.format_for_mcnp_input((6, 3, 0)) + print("Material output", output) + new_mat = Material(Input(output, BlockType.DATA)) + assert mat.number == new_mat.number, "Material number not preserved." + assert len(mat) == len(new_mat), "number of components not kept." + assert mat.is_atom_fraction == new_mat.is_atom_fraction + for (old_nuc, old_frac), (new_nuc, new_frac) in zip(mat, new_mat): + assert old_nuc == new_nuc, "Material didn't preserve nuclides." + assert old_frac == pytest.approx(new_frac) + for (old_type, old_lib), (new_type, new_lib) in zip( + mat.default_libraries, new_mat.default_libraries ): - assert iso is not gold_iso - assert iso.ZAID == gold_iso.ZAID - assert fraction.fraction == pytest.approx(gold_fraction.fraction) - assert new_mat._number is new_mat._tree["classifier"].number - output = new_mat.format_for_mcnp_input((6, 3, 0)) - input = Input(output, BlockType.DATA) - newer_mat = Material(input) - assert newer_mat.number == new_mat.number - - -@pytest.mark.parametrize( - "args, error", - [ - (("c", 1), TypeError), - ((1, "d"), TypeError), - ((-1, 1), ValueError), - ((0, 1), ValueError), - ((1, 0), ValueError), - ((1, -1), ValueError), - ], -) -def test_cell_clone_bad(args, error): - input = Input(["m1 1001.80c 0.3 8016.80c 0.67"], BlockType.CELL) - mat = Material(input) - with pytest.raises(error): - mat.clone(*args) - - -class TestIsotope(TestCase): - def test_isotope_init(self): - with pytest.warns(FutureWarning): - isotope = Isotope("1001.80c") - self.assertEqual(isotope.ZAID, "1001") - self.assertEqual(isotope.Z, 1) - self.assertEqual(isotope.A, 1) - self.assertEqual(isotope.element.Z, 1) - self.assertEqual(isotope.library, "80c") - with self.assertRaises(ValueError): - Isotope("1001.80c.5", suppress_warning=True) - with self.assertRaises(ValueError): - Isotope("hi.80c", suppress_warning=True) - - def test_isotope_metastable_init(self): - isotope = Isotope("13426.02c", suppress_warning=True) - self.assertEqual(isotope.ZAID, "13426") - self.assertEqual(isotope.Z, 13) - self.assertEqual(isotope.A, 26) - self.assertTrue(isotope.is_metastable) - self.assertEqual(isotope.meta_state, 1) - isotope = Isotope("92635.02c", suppress_warning=True) - self.assertEqual(isotope.A, 235) - self.assertEqual(isotope.meta_state, 1) - isotope = Isotope("92935.02c", suppress_warning=True) - self.assertEqual(isotope.A, 235) - self.assertEqual(isotope.meta_state, 4) - self.assertEqual(isotope.mcnp_str(), "92935.02c") - edge_cases = [ - ("4412", 4, 12, 1), - ("4413", 4, 13, 1), - ("4414", 4, 14, 1), - ("36569", 36, 69, 2), - ("77764", 77, 164, 3), - ] - for ZA, Z_ans, A_ans, isomer_ans in edge_cases: - isotope = Isotope(ZA + ".80c", suppress_warning=True) - self.assertEqual(isotope.Z, Z_ans) - self.assertEqual(isotope.A, A_ans) - self.assertEqual(isotope.meta_state, isomer_ans) - with self.assertRaises(ValueError): - isotope = Isotope("13826.02c", suppress_warning=True) - - def test_isotope_get_base_zaid(self): - isotope = Isotope("92635.02c", suppress_warning=True) - self.assertEqual(isotope.get_base_zaid(), 92235) - - def test_isotope_library_setter(self): - isotope = Isotope("1001.80c", suppress_warning=True) - isotope.library = "70c" - self.assertEqual(isotope.library, "70c") - with self.assertRaises(TypeError): - isotope.library = 1 - - def test_isotope_str(self): - isotope = Isotope("1001.80c", suppress_warning=True) - assert isotope.mcnp_str() == "1001.80c" - assert isotope.nuclide_str() == "H-1.80c" - assert repr(isotope) == "Isotope('H-1.80c')" - assert str(isotope) == " H-1 (80c)" - isotope = Isotope("94239.80c", suppress_warning=True) - assert isotope.nuclide_str() == "Pu-239.80c" - assert isotope.mcnp_str() == "94239.80c" - assert repr(isotope) == "Isotope('Pu-239.80c')" - isotope = Isotope("92635.80c", suppress_warning=True) - assert isotope.nuclide_str() == "U-235m1.80c" - assert isotope.mcnp_str() == "92635.80c" - assert str(isotope) == " U-235m1 (80c)" - assert repr(isotope) == "Isotope('U-235m1.80c')" - # stupid legacy stupidity #486 - isotope = Isotope("95642", suppress_warning=True) - assert isotope.nuclide_str() == "Am-242" - assert isotope.mcnp_str() == "95642" - assert repr(isotope) == "Isotope('Am-242')" - isotope = Isotope("95242", suppress_warning=True) - assert isotope.nuclide_str() == "Am-242m1" - assert isotope.mcnp_str() == "95242" - assert repr(isotope) == "Isotope('Am-242m1')" - - -class TestThermalScattering(TestCase): - def test_thermal_scattering_init(self): + assert old_type == new_type + assert old_lib == new_lib + + +class TestThermalScattering: + def test_thermal_scattering_init(_): # test wrong input type assertion input_card = Input(["M20"], BlockType.DATA) - with self.assertRaises(MalformedInputError): + with pytest.raises(MalformedInputError): ThermalScatteringLaw(input_card) input_card = Input(["Mt20 grph.20t"], BlockType.DATA) card = ThermalScatteringLaw(input_card) - self.assertEqual(card.old_number, 20) - self.assertEqual(card.thermal_scattering_laws, ["grph.20t"]) + assert card.old_number == 20 + assert card.thermal_scattering_laws == ["grph.20t"] input_card = Input(["Mtfoo"], BlockType.DATA) - with self.assertRaises(MalformedInputError): + with pytest.raises(MalformedInputError): ThermalScatteringLaw(input_card) input_card = Input(["Mt-20"], BlockType.DATA) - with self.assertRaises(MalformedInputError): + with pytest.raises(MalformedInputError): ThermalScatteringLaw(input_card) in_str = "M20 1001.80c 0.5 8016.80c 0.5" input_card = Input([in_str], BlockType.DATA) material = Material(input_card) card = ThermalScatteringLaw(material=material) - self.assertEqual(card.parent_material, material) + assert card.parent_material == material - def test_thermal_scattering_particle_parser(self): + def test_thermal_scattering_particle_parser(_): # replicate issue #121 input_card = Input(["Mt20 h-h2o.40t"], BlockType.DATA) card = ThermalScatteringLaw(input_card) - self.assertEqual(card.old_number, 20) - self.assertEqual(card.thermal_scattering_laws, ["h-h2o.40t"]) + assert card.old_number == 20 + assert card.thermal_scattering_laws == ["h-h2o.40t"] - def test_thermal_scatter_validate(self): + def test_thermal_scatter_validate(_): thermal = ThermalScatteringLaw() - with self.assertRaises(montepy.errors.IllegalState): + with pytest.raises(montepy.errors.IllegalState): thermal.validate() - with self.assertRaises(montepy.errors.IllegalState): + with pytest.raises(montepy.errors.IllegalState): thermal.format_for_mcnp_input((6, 2, 0)) material = Material() material.number = 1 thermal._old_number = montepy.input_parser.syntax_node.ValueNode("1", int) thermal.update_pointers([material]) - with self.assertRaises(montepy.errors.IllegalState): + with pytest.raises(montepy.errors.IllegalState): thermal.validate() thermal._old_number = montepy.input_parser.syntax_node.ValueNode("2", int) - with self.assertRaises(montepy.errors.MalformedInputError): + with pytest.raises(montepy.errors.MalformedInputError): thermal.update_pointers([material]) + with self.assertRaises(montepy.errors.IllegalState): + thermal.validate() + thermal._old_number = montepy.input_parser.syntax_node.ValueNode("2", int) + with self.assertRaises(montepy.errors.MalformedInputError): + thermal.update_pointers([material]) def test_thermal_scattering_add(self): in_str = "Mt20 grph.20t" @@ -453,52 +668,105 @@ def test_thermal_str(self): "THERMAL SCATTER: material: None, old_num: 20, scatter: ['grph.20t']", ) + def test_thermal_scattering_add(_): + in_str = "Mt20 grph.20t" + input_card = Input([in_str], BlockType.DATA) + card = ThermalScatteringLaw(input_card) + card.add_scattering_law("grph.21t") + assert len(card.thermal_scattering_laws) == 2 + assert card.thermal_scattering_laws == ["grph.20t", "grph.21t"] + card.thermal_scattering_laws = ["grph.22t"] + assert card.thermal_scattering_laws == ["grph.22t"] + + def test_thermal_scattering_setter(_): + in_str = "Mt20 grph.20t" + input_card = Input([in_str], BlockType.DATA) + card = ThermalScatteringLaw(input_card) + laws = ["grph.21t"] + card.thermal_scattering_laws = laws + assert card.thermal_scattering_laws == laws + with pytest.raises(TypeError): + card.thermal_scattering_laws = 5 + with pytest.raises(TypeError): + card.thermal_scattering_laws = [5] + + def test_thermal_scattering_material_add(_): + in_str = "M20 1001.80c 1.0" + input_card = Input([in_str], BlockType.DATA) + card = Material(input_card) + card.add_thermal_scattering("grph.21t") + assert len(card.thermal_scattering.thermal_scattering_laws) == 1 + assert card.thermal_scattering.thermal_scattering_laws == ["grph.21t"] + card.thermal_scattering.thermal_scattering_laws = ["grph.22t"] + assert card.thermal_scattering.thermal_scattering_laws == ["grph.22t"] + with pytest.raises(TypeError): + card.add_thermal_scattering(5) + + def test_thermal_scattering_format_mcnp(_): + in_str = "Mt20 grph.20t" + input_card = Input([in_str], BlockType.DATA) + card = ThermalScatteringLaw(input_card) + in_str = "M20 1001.80c 0.5 8016.80c 0.5" + input_card = Input([in_str], BlockType.DATA) + material = Material(input_card) + material.thermal_scattering = card + card._parent_material = material + material.thermal_scattering.thermal_scattering_laws = ["grph.20t"] + card.format_for_mcnp_input((6, 2, 0)) == ["Mt20 grph.20t "] + + def test_thermal_str(_): + in_str = "Mt20 grph.20t" + input_card = Input([in_str], BlockType.DATA) + card = ThermalScatteringLaw(input_card) + assert str(card) == "THERMAL SCATTER: ['grph.20t']" + assert ( + repr(card) + == "THERMAL SCATTER: material: None, old_num: 20, scatter: ['grph.20t']" + ) + -class TestElement(TestCase): - def test_element_init(self): - for Z in range(1, 119): - element = Element(Z) - self.assertEqual(element.Z, Z) - # Test to ensure there are no missing elements - name = element.name - symbol = element.symbol - - with self.assertRaises(UnknownElement): - Element(119) - - spot_check = { - 1: ("H", "hydrogen"), - 40: ("Zr", "zirconium"), - 92: ("U", "uranium"), - 94: ("Pu", "plutonium"), - 29: ("Cu", "copper"), - 13: ("Al", "aluminum"), - } - for z, (symbol, name) in spot_check.items(): - element = Element(z) - self.assertEqual(z, element.Z) - self.assertEqual(symbol, element.symbol) - self.assertEqual(name, element.name) - - def test_element_str(self): - element = Element(1) - self.assertEqual(str(element), "hydrogen") - self.assertEqual(repr(element), "Z=1, symbol=H, name=hydrogen") - - def test_get_by_symbol(self): - element = Element.get_by_symbol("Hg") - self.assertEqual(element.name, "mercury") - with self.assertRaises(UnknownElement): - Element.get_by_symbol("Hi") - - def test_get_by_name(self): - element = Element.get_by_name("mercury") - self.assertEqual(element.symbol, "Hg") - with self.assertRaises(UnknownElement): - Element.get_by_name("hudrogen") - - -class TestParticle(TestCase): - def test_particle_str(self): - part = montepy.Particle("N") - self.assertEqual(str(part), "neutron") +class TestDefaultLib: + + @pytest.fixture + def mat(_): + mat = Material() + mat.number = 1 + return mat + + @pytest.fixture + def dl(_, mat): + return DL(mat) + + def test_dl_init(_, dl): + assert isinstance(dl._parent(), Material) + assert isinstance(dl._libraries, dict) + + @pytest.mark.parametrize( + "lib_type, lib", [("nlib", "80c"), ("plib", "80p"), ("alib", "24a")] + ) + def test_set_get(_, dl, lib_type, lib): + lib_type_load = LibraryType(lib_type.upper()) + dl[lib_type] = lib + assert dl[lib_type] == Library(lib), "Library not properly stored." + assert ( + len(dl._parent()._tree["data"]) == 1 + ), "library not added to parent material" + dl[lib_type_load] = Library(lib) + dl[lib_type_load] == Library(lib), "Library not properly stored." + del dl[lib_type] + assert ( + len(dl._parent()._tree["data"]) == 0 + ), "library not deleted from parent material" + assert dl[lib_type] is None, "Default libraries did not delete" + assert dl["hlib"] is None, "Default value not set." + + def test_bad_set_get(_, dl): + with pytest.raises(TypeError): + dl[5] = "80c" + with pytest.raises(TypeError): + dl["nlib"] = 5 + with pytest.raises(TypeError): + del dl[5] + + def test_dl_str(_, dl): + str(dl) diff --git a/tests/test_mcnp_problem.py b/tests/test_mcnp_problem.py index 985091a1..3ae097a6 100644 --- a/tests/test_mcnp_problem.py +++ b/tests/test_mcnp_problem.py @@ -21,7 +21,7 @@ def test_problem_init(problem, problem_path): ) assert problem.input_file.path == problem_path assert problem.input_file.name == problem_path - assert problem.mcnp_version == (6, 2, 0) + assert problem.mcnp_version == (6, 3, 0) def test_problem_str(problem, problem_path): diff --git a/tests/test_mode.py b/tests/test_mode.py index c7f37074..4973fe46 100644 --- a/tests/test_mode.py +++ b/tests/test_mode.py @@ -100,7 +100,7 @@ def test_set_mode(self): mode.set(5) with self.assertRaises(TypeError): mode.set([5]) - with self.assertRaises(ValueError): - mode.set(["n", Particle.PHOTON]) - with self.assertRaises(ValueError): - mode.set([Particle.PHOTON, "n"]) + mode.set(["n", Particle.PHOTON]) + assert len(mode) == 2 + mode.set([Particle.PHOTON, "n"]) + assert len(mode) == 2 diff --git a/tests/test_nuclide.py b/tests/test_nuclide.py new file mode 100644 index 00000000..86e87562 --- /dev/null +++ b/tests/test_nuclide.py @@ -0,0 +1,464 @@ +# Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +import pytest +from hypothesis import assume, given, note, strategies as st, settings + +import montepy + +from montepy.data_inputs.element import Element +from montepy.data_inputs.nuclide import Nucleus, Nuclide, Library +from montepy.input_parser import syntax_node +from montepy.errors import * +from montepy.particle import LibraryType + + +class TestNuclide: + def test_nuclide_init(_): + isotope = Nuclide("1001.80c") + assert isotope.ZAID == 1001 + assert isotope.Z == 1 + assert isotope.A == 1 + assert isotope.element.Z == 1 + assert isotope.library == "80c" + with pytest.raises(ValueError): + Nuclide("1001.80c.5") + with pytest.raises(ValueError): + Nuclide("hi.80c") + + def test_nuclide_metastable_init(_): + isotope = Nuclide("13426.02c") + assert isotope.ZAID == 13426 + assert isotope.Z == 13 + assert isotope.A == 26 + assert isotope.is_metastable + assert isotope.meta_state == 1 + isotope = Nuclide("92635.02c") + assert isotope.A == 235 + assert isotope.meta_state == 1 + isotope = Nuclide("92935.02c") + assert isotope.A == 235 + assert isotope.meta_state == 4 + assert isotope.mcnp_str() == "92935.02c" + edge_cases = [ + ("4412", 4, 12, 1), + ("4413", 4, 13, 1), + ("4414", 4, 14, 1), + ("36569", 36, 69, 2), + ("77764", 77, 164, 3), + ] + for ZA, Z_ans, A_ans, isomer_ans in edge_cases: + isotope = Nuclide(ZA + ".80c") + assert isotope.Z == Z_ans + assert isotope.A == A_ans + assert isotope.meta_state == isomer_ans + with pytest.raises(ValueError): + isotope = Nuclide("13826.02c") + + def test_nuclide_get_base_zaid(_): + isotope = Nuclide("92635.02c") + assert isotope.get_base_zaid() == 92235 + + def test_nuclide_library_setter(_): + isotope = Nuclide("1001.80c") + isotope.library = "70c" + assert isotope.library == "70c" + with pytest.raises(TypeError): + isotope.library = 1 + + def test_nuclide_str(_): + isotope = Nuclide("1001.80c") + assert isotope.mcnp_str() == "1001.80c" + assert isotope.nuclide_str() == "H-1.80c" + assert repr(isotope) == "Nuclide('H-1.80c')" + assert str(isotope) == " H-1 (80c)" + isotope = Nuclide("94239.80c") + assert isotope.nuclide_str() == "Pu-239.80c" + assert isotope.mcnp_str() == "94239.80c" + assert repr(isotope) == "Nuclide('Pu-239.80c')" + isotope = Nuclide("92635.80c") + assert isotope.nuclide_str() == "U-235m1.80c" + assert isotope.mcnp_str() == "92635.80c" + assert str(isotope) == " U-235m1 (80c)" + assert repr(isotope) == "Nuclide('U-235m1.80c')" + # stupid legacy stupidity #486 + isotope = Nuclide("95642") + assert isotope.nuclide_str() == "Am-242" + assert isotope.mcnp_str() == "95642" + assert repr(isotope) == "Nuclide('Am-242')" + isotope = Nuclide("95242") + assert isotope.nuclide_str() == "Am-242m1" + assert isotope.mcnp_str() == "95242" + assert repr(isotope) == "Nuclide('Am-242m1')" + # test that can be formatted at all: + f"{isotope:0>10s}" + + @pytest.mark.parametrize( + "input, Z, A, meta, library", + [ + (1001, 1, 1, 0, ""), + ("1001.80c", 1, 1, 0, "80c"), + ("h1", 1, 1, 0, ""), + ("h-1", 1, 1, 0, ""), + ("h-1.80c", 1, 1, 0, "80c"), + ("h", 1, 0, 0, ""), + ("92635m2.710nc", 92, 235, 3, "710nc"), + (Nuclide("1001.80c"), 1, 1, 0, "80c"), + (Nucleus(Element(1), 1), 1, 1, 0, ""), + (Element(1), 1, 0, 0, ""), + (92, 92, 0, 0, ""), + ], + ) + def test_fancy_names(_, input, Z, A, meta, library): + isotope = Nuclide(input) + assert isotope.A == A + assert isotope.Z == Z + assert isotope.meta_state == meta + assert isotope.library == Library(library) + + nuclide_strat = ( + st.integers(1, 118), + st.floats(2.1, 2.7), + st.integers(0, 4), + st.integers(0, 999), + # based on Table B.1 of the 6.3.1 manual + # ignored `t` because that requires an `MT` + st.sampled_from( + [c for c in "cdmgpuyehporsa"] + ), # lazy way to avoid so many quotation marks + st.booleans(), + ) + + @given(*nuclide_strat) + def test_fancy_names_pbt( + _, Z, A_multiplier, meta, library_base, library_extension, hyphen + ): + # avoid Am-242 metastable legacy + A = int(Z * A_multiplier) + element = Element(Z) + assume(not (Z == 95 and A == 242)) + # ignore H-*m* as it's nonsense + assume(not (Z == 1 and meta > 0)) + for lim_Z, lim_A in Nuclide._BOUNDING_CURVE: + if Z <= lim_Z: + break + assume(A <= lim_A) + library = f"{library_base:02}{library_extension}" + inputs = [ + f"{Z* 1000 + A}{f'm{meta}' if meta > 0 else ''}.{library}", + f"{Z* 1000 + A}{f'm{meta}' if meta > 0 else ''}", + f"{element.symbol}{'-' if hyphen else ''}{A}{f'm{meta}' if meta > 0 else ''}.{library}", + f"{element.symbol}{'-' if hyphen else ''}{A}{f'm{meta}' if meta > 0 else ''}", + ] + + if meta: + inputs.append(f"{Z* 1000 + A + 300 + 100 * meta}.{library}") + note(inputs) + for input in inputs: + note(input) + isotope = Nuclide(input) + assert isotope.A == A + assert isotope.Z == Z + assert isotope.meta_state == meta + if "." in input: + assert isotope.library == Library(library) + new_isotope = Nuclide(Z=Z, A=A, meta_state=meta, library=library) + else: + assert isotope.library == Library("") + new_isotope = Nuclide(Z=Z, A=A, meta_state=meta) + # test eq and lt + assert new_isotope == isotope + new_isotope = Nuclide(Z=Z, A=A + 5, meta_state=meta) + assert new_isotope != isotope + assert isotope < new_isotope + if library_base < 998: + new_isotope = Nuclide( + Z=Z, + A=A, + meta_state=meta, + library=f"{library_base+2:02}{library_extension}", + ) + assert isotope < new_isotope + with pytest.raises(TypeError): + isotope == "str" + with pytest.raises(TypeError): + isotope < 5 + + @given(*nuclide_strat) + def test_valuenode_init( + _, Z, A_multiplier, meta, library_base, library_extension, hyphen + ): + # avoid Am-242 metastable legacy + A = int(Z * A_multiplier) + element = Element(Z) + assume(not (Z == 95 and A == 242)) + # ignore H-*m* as it's nonsense + assume(not (Z == 1 and meta > 0)) + for lim_Z, lim_A in Nuclide._BOUNDING_CURVE: + if Z <= lim_Z: + break + assume(A <= lim_A) + library = f"{library_base:02}{library_extension}" + ZAID = Z * 1_000 + A + if meta > 0: + ZAID += 300 + meta * 100 + + inputs = [ + f"{ZAID}.{library}", + f"{ZAID}", + ] + for input in inputs: + note(input) + for type in {float, str}: + if type == float and "." in input: + continue + node = syntax_node.ValueNode(input, type, syntax_node.PaddingNode(" ")) + nuclide = Nuclide(node=node) + assert nuclide.Z == Z + assert nuclide.A == A + assert nuclide.meta_state == meta + if "." in input: + assert str(nuclide.library) == library + else: + assert str(nuclide.library) == "" + + @pytest.mark.parametrize( + "kwargs, error", + [ + ({"name": 1.23}, TypeError), + ({"name": int(1e6)}, ValueError), + ({"name": "1001.hi"}, ValueError), + ({"name": "hello"}, ValueError), + ({"element": "hi"}, TypeError), + ({"Z": "hi"}, TypeError), + ({"Z": 1000}, montepy.errors.UnknownElement), + ({"Z": 1, "A": "hi"}, TypeError), + ({"Z": 1, "A": -1}, ValueError), + ({"A": 1}, ValueError), + ({"meta_state": 1}, ValueError), + ({"library": "80c"}, ValueError), + ({"Z": 1, "A": 2, "meta_state": "hi"}, TypeError), + ({"Z": 1, "A": 2, "meta_state": -1}, ValueError), + ({"Z": 1, "A": 2, "meta_state": 5}, ValueError), + ({"name": "1001", "library": 5}, TypeError), + ({"name": "1001", "library": "hi"}, ValueError), + ], + ) + def test_nuclide_bad_init(_, kwargs, error): + with pytest.raises(error): + Nuclide(**kwargs) + + +class TestLibrary: + + @pytest.mark.parametrize( + "input, lib_type", + [ + ("80c", LibraryType.NEUTRON), + ("710nc", LibraryType.NEUTRON), + ("50d", LibraryType.NEUTRON), + ("50M", LibraryType.NEUTRON), + ("01g", LibraryType.PHOTO_ATOMIC), + ("84P", LibraryType.PHOTO_ATOMIC), + ("24u", LibraryType.PHOTO_NUCLEAR), + ("30Y", LibraryType.NEUTRON), + ("03e", LibraryType.ELECTRON), + ("70H", LibraryType.PROTON), + ("70o", LibraryType.DEUTERON), + ("70r", LibraryType.TRITON), + ("70s", LibraryType.HELION), + ("70a", LibraryType.ALPHA_PARTICLE), + ], + ) + def test_library_init(_, input, lib_type): + lib = Library(input) + assert lib.library_type == lib_type, "Library type not properly parsed" + assert str(lib) == input, "Original string not preserved." + assert lib.library == input, "Original string not preserved." + + def test_library_bad_init(_): + with pytest.raises(TypeError): + Library(5) + with pytest.raises(ValueError): + Library("hi") + with pytest.raises(ValueError): + Library("00x") + + @given( + input_num=st.integers(min_value=0, max_value=999), + extra_char=st.characters(min_codepoint=97, max_codepoint=122), + lib_extend=st.sampled_from("cdmgpuyehorsa"), + capitalize=st.booleans(), + ) + def test_library_mass_init(_, input_num, extra_char, lib_extend, capitalize): + if input_num > 100: + input = f"{input_num:02d}{extra_char}{lib_extend}" + else: + input = f"{input_num:02d}{lib_extend}" + if capitalize: + input = input.upper() + note(input) + lib = Library(input) + assert str(lib) == input, "Original string not preserved." + assert repr(lib) == f"Library('{input}')", "Original string not preserved." + assert lib.library == input, "Original string not preserved." + assert lib.number == input_num, "Library number not preserved." + assert lib.suffix == lib_extend, "Library suffix not preserved." + lib2 = Library(input) + assert lib == lib2, "Equality broke." + assert hash(lib) == hash(lib2), "Hashing broke for library." + + @pytest.mark.parametrize( + "input, error", [(5, TypeError), ("hi", ValueError), ("75b", ValueError)] + ) + def test_bad_library_init(_, input, error): + with pytest.raises(error): + Library(input) + lib = Library("00c") + if not isinstance(input, str): + with pytest.raises(TypeError): + lib == input, "Type enforcement for library equality failed." + + def test_library_sorting(_): + lib = Library("00c") + with pytest.raises(TypeError): + lib < 5 + libs = {Library(s) for s in ["00c", "70c", "70g", "80m", "24y", "90a"]} + libs.add("50d") + gold_order = ["90a", "00c", "70c", "50d", "70g", "80m", "24y"] + assert [str(lib) for lib in sorted(libs)] == gold_order, "Sorting failed." + + def test_library_bool(_): + assert Library("80c") + assert not Library("") + + +# test element +class TestElement: + def test_element_init(_): + for Z in range(1, 119): + element = Element(Z) + assert element.Z == Z + # Test to ensure there are no missing elements + name = element.name + symbol = element.symbol + + with pytest.raises(UnknownElement): + Element(119) + + spot_check = { + 1: ("H", "hydrogen"), + 40: ("Zr", "zirconium"), + 92: ("U", "uranium"), + 94: ("Pu", "plutonium"), + 29: ("Cu", "copper"), + 13: ("Al", "aluminum"), + } + for z, (symbol, name) in spot_check.items(): + element = Element(z) + assert z == element.Z + assert symbol == element.symbol + assert name == element.name + + def test_element_str(_): + element = Element(1) + assert str(element) == "hydrogen" + assert repr(element) == "Element(1)" + + def test_get_by_symbol(_): + element = Element.get_by_symbol("Hg") + assert element.name == "mercury" + with pytest.raises(UnknownElement): + Element.get_by_symbol("Hi") + + def test_get_by_name(_): + element = Element.get_by_name("mercury") + assert element.symbol == "Hg" + with pytest.raises(UnknownElement): + Element.get_by_name("hudrogen") + + # particle + def test_particle_str(_): + part = montepy.Particle("N") + assert str(part) == "neutron" + + +class TestNucleus: + + @given(Z=st.integers(1, 99), A=st.integers(0, 300), meta=st.integers(0, 4)) + def test_nucleus_init_eq_hash(_, Z, A, meta): + # avoid metastable elemental + assume((A == 0) == (meta == 0)) + nucleus = Nucleus(Element(Z), A, meta) + assert nucleus.Z == Z + assert nucleus.A == A + assert nucleus.meta_state == meta + # test eq + other = Nucleus(Element(Z), A, meta) + assert nucleus == other + assert hash(nucleus) == hash(other) + assert str(nucleus) == str(other) + assert repr(nucleus) == repr(other) + with pytest.raises(TypeError): + nucleus == 5 + with pytest.raises(TypeError): + nucleus < 5 + # test not eq + if A != 0: + new_meta = meta + 1 if meta <= 3 else meta - 1 + for other in { + Nucleus(Element(Z), A + 5, meta), + Nucleus(Element(Z), A, new_meta), + }: + assert nucleus != other + assert hash(nucleus) != hash(other) + assert str(nucleus) != str(other) + assert repr(nucleus) != repr(other) + if other.A > A: + assert nucleus < other + else: + if new_meta > meta: + assert nucleus < other + elif new_meta < meta: + assert other < nucleus + # avoid insane ZAIDs + a_ratio = A / Z + if a_ratio >= 1.9 and a_ratio < 2.3: + nuclide = Nuclide(nucleus.ZAID) + assert nuclide.nucleus == nucleus + nucleus = Nucleus(Element(Z)) + assert nucleus.Z == Z + + @pytest.mark.parametrize( + "kwargs, error", + [ + ({"element": "hi"}, TypeError), + ({"A": "hi"}, TypeError), + ({"A": -1}, ValueError), + ({"meta_state": "hi"}, TypeError), + ({"meta_state": -1}, ValueError), + ({"meta_state": 5}, ValueError), + ], + ) + def test_nucleus_bad_init(_, kwargs, error): + if "element" not in kwargs: + kwargs["element"] = Element(1) + with pytest.raises(error): + Nucleus(**kwargs) + + +class TestLibraryType: + + def test_sort_order(_): + gold = [ + "alpha_particle", + "deuteron", + "electron", + "proton", + "neutron", + "photo_atomic", + "photo_nuclear", + "helion", + "triton", + ] + sort_list = sorted(LibraryType) + answer = [str(lib_type) for lib_type in sort_list] + assert gold == answer diff --git a/tests/test_numbered_collection.py b/tests/test_numbered_collection.py index bd6842ed..48a58447 100644 --- a/tests/test_numbered_collection.py +++ b/tests/test_numbered_collection.py @@ -2,72 +2,81 @@ import hypothesis from hypothesis import given, settings, strategies as st import copy +import itertools as it + import montepy import montepy.cells from montepy.errors import NumberConflictError -import unittest import pytest import os -class TestNumberedObjectCollection(unittest.TestCase): - def setUp(self): - self.simple_problem = montepy.read_input("tests/inputs/test.imcnp") +class TestNumberedObjectCollection: + + @pytest.fixture(scope="class") + def read_simple_problem(_): + return montepy.read_input(os.path.join("tests", "inputs", "test.imcnp")) + + @pytest.fixture + def cp_simple_problem(_, read_simple_problem): + return copy.deepcopy(read_simple_problem) def test_bad_init(self): - with self.assertRaises(TypeError): - montepy.cells.Cells(5) + with pytest.raises(TypeError): + montepy.Cells(5) + with pytest.raises(TypeError): + montepy.Cells([5]) - def test_numbers(self): + def test_numbers(self, cp_simple_problem): cell_numbers = [1, 2, 3, 99, 5] surf_numbers = [1000, 1005, 1010, 1015, 1020, 1025] mat_numbers = [1, 2, 3] - problem = self.simple_problem - self.assertEqual(list(problem.cells.numbers), cell_numbers) - self.assertEqual(list(problem.surfaces.numbers), surf_numbers) - self.assertEqual(list(problem.materials.numbers), mat_numbers) + problem = cp_simple_problem + assert list(problem.cells.numbers) == cell_numbers + assert list(problem.surfaces.numbers) == surf_numbers + assert list(problem.materials.numbers) == mat_numbers - def test_number_conflict_init(self): - cells = list(self.simple_problem.cells) + def test_number_conflict_init(self, cp_simple_problem): + cells = list(cp_simple_problem.cells) cells.append(cells[1]) - with self.assertRaises(NumberConflictError): + with pytest.raises(NumberConflictError): montepy.cells.Cells(cells) - def test_check_number(self): - with self.assertRaises(NumberConflictError): - self.simple_problem.cells.check_number(1) - with self.assertRaises(TypeError): - self.simple_problem.cells.check_number("5") + def test_check_number(self, cp_simple_problem): + with pytest.raises(NumberConflictError): + cp_simple_problem.cells.check_number(1) + with pytest.raises(TypeError): + cp_simple_problem.cells.check_number("5") # testing a number that shouldn't conflict to ensure error isn't raised - self.simple_problem.cells.check_number(20) + cp_simple_problem.cells.check_number(20) - def test_objects(self): - generated = list(self.simple_problem.cells) - objects = self.simple_problem.cells.objects - self.assertEqual(generated, objects) + def test_objects(self, cp_simple_problem): + generated = list(cp_simple_problem.cells) + objects = cp_simple_problem.cells.objects + assert generated == objects - def test_pop(self): - cells = copy.deepcopy(self.simple_problem.cells) + def test_pop(self, cp_simple_problem): + cells = copy.deepcopy(cp_simple_problem.cells) size = len(cells) target = list(cells)[-1] popped = cells.pop() - self.assertEqual(target, popped) - self.assertEqual(size - 1, len(cells)) - with self.assertRaises(TypeError): + assert target == popped + assert size - 1 == len(cells) + with pytest.raises(TypeError): cells.pop("hi") - def test_extend(self): - surfaces = copy.deepcopy(self.simple_problem.surfaces) + def test_extend(self, cp_simple_problem): + surfaces = copy.deepcopy(cp_simple_problem.surfaces) extender = list(surfaces)[0:2] size = len(surfaces) - with self.assertRaises(NumberConflictError): + with pytest.raises(NumberConflictError): surfaces.extend(extender) - self.assertEqual(len(surfaces), size) + assert len(surfaces) == size extender = copy.deepcopy(extender) extender[0].number = 50 extender[1].number = 60 surfaces.extend(extender) - self.assertEqual(len(surfaces), size + 2) + assert len(surfaces) == size + 2 # force a num_cache miss extender = copy.deepcopy(extender) for surf in extender: @@ -76,54 +85,87 @@ def test_extend(self): extender[0].number = 1000 extender[1].number = 70 surfaces.extend(extender) - self.assertEqual(len(surfaces), size + 4) - with self.assertRaises(TypeError): + assert len(surfaces) == size + 4 + with pytest.raises(TypeError): surfaces.extend(5) - with self.assertRaises(TypeError): + with pytest.raises(TypeError): surfaces.extend([5]) - def test_iter(self): - size = len(self.simple_problem.cells) + def test_iter(self, cp_simple_problem): + size = len(cp_simple_problem.cells) counter = 0 - for cell in self.simple_problem.cells: + for cell in cp_simple_problem.cells: counter += 1 - self.assertEqual(size, counter) + assert size == counter - def test_append(self): - cells = copy.deepcopy(self.simple_problem.cells) + def test_append(self, cp_simple_problem): + cells = copy.deepcopy(cp_simple_problem.cells) cell = copy.deepcopy(cells[1]) size = len(cells) - with self.assertRaises(NumberConflictError): + with pytest.raises(NumberConflictError): cells.append(cell) - with self.assertRaises(TypeError): + with pytest.raises(TypeError): cells.append(5) cell.number = 20 cells.append(cell) - self.assertEqual(len(cells), size + 1) + assert len(cells) == size + 1 + + def test_add(_): + cells = montepy.Cells() + cell = montepy.Cell() + cell.number = 2 + cells.add(cell) + assert cell in cells + # test silent no-op + cells.add(cell) + cell = copy.deepcopy(cell) + with pytest.raises(NumberConflictError): + cells.add(cell) + with pytest.raises(TypeError): + cells.add(5) + + def test_update(_): + cells = montepy.Cells() + cell_list = [] + for i in range(1, 6): + cell_list.append(montepy.Cell()) + cell_list[-1].number = i + cells.update(cell_list) + for cell in cell_list: + assert cell in cells + with pytest.raises(TypeError): + cells.update(5) + with pytest.raises(TypeError): + cells.update({5}) + cell = montepy.Cell() + cell.number = 1 + cells.update([cell]) + assert cells[1] is cell_list[0] + assert cells[1] is not cell - def test_append_renumber(self): - cells = copy.deepcopy(self.simple_problem.cells) + def test_append_renumber(self, cp_simple_problem): + cells = copy.deepcopy(cp_simple_problem.cells) size = len(cells) cell = copy.deepcopy(cells[1]) cell.number = 20 cells.append_renumber(cell) - self.assertEqual(len(cells), size + 1) - with self.assertRaises(TypeError): + assert len(cells) == size + 1 + with pytest.raises(TypeError): cells.append_renumber(5) - with self.assertRaises(TypeError): + with pytest.raises(TypeError): cells.append_renumber(cell, "hi") cell = copy.deepcopy(cell) cell._problem = None cell.number = 1 cells.append_renumber(cell) - self.assertEqual(cell.number, 4) - self.assertEqual(len(cells), size + 2) - - def test_append_renumber_problems(self): - print(hex(id(self.simple_problem.materials._problem))) - prob1 = copy.deepcopy(self.simple_problem) - prob2 = copy.deepcopy(self.simple_problem) - print(hex(id(self.simple_problem.materials._problem))) + assert cell.number == 4 + assert len(cells) == size + 2 + + def test_append_renumber_problems(self, cp_simple_problem): + print(hex(id(cp_simple_problem.materials._problem))) + prob1 = copy.deepcopy(cp_simple_problem) + prob2 = copy.deepcopy(cp_simple_problem) + print(hex(id(cp_simple_problem.materials._problem))) # Delete Material 2, making its number available. prob2.materials.remove(prob2.materials[2]) len_mats = len(prob2.materials) @@ -133,307 +175,686 @@ def test_append_renumber_problems(self): assert len(prob2.materials) == len_mats + 1, "Material not appended" assert prob2.materials[2] is mat1, "Material 2 is not the new material" - def test_request_number(self): - cells = self.simple_problem.cells - self.assertEqual(cells.request_number(6), 6) - self.assertEqual(cells.request_number(1), 4) - self.assertEqual(cells.request_number(99, 6), 105) - with self.assertRaises(TypeError): + def test_request_number(self, cp_simple_problem): + cells = cp_simple_problem.cells + assert cells.request_number(6) == 6 + assert cells.request_number(1) == 4 + assert cells.request_number(99, 6) == 105 + with pytest.raises(TypeError): cells.request_number("5") - with self.assertRaises(TypeError): + with pytest.raises(TypeError): cells.request_number(1, "5") - def test_next_number(self): - cells = self.simple_problem.cells - self.assertEqual(cells.next_number(), 100) - self.assertEqual(cells.next_number(6), 105) - with self.assertRaises(TypeError): + def test_next_number(self, cp_simple_problem): + cells = cp_simple_problem.cells + assert cells.next_number() == 100 + assert cells.next_number(6) == 105 + with pytest.raises(TypeError): cells.next_number("5") - with self.assertRaises(ValueError): + with pytest.raises(ValueError): cells.next_number(-1) - def test_getitem(self): - cells = self.simple_problem.cells + def test_getitem(self, cp_simple_problem): + cells = cp_simple_problem.cells list_version = list(cells) - self.assertEqual(cells[1], list_version[0]) + assert cells[1] == list_version[0] # force stale cache misses cells[1].number = 20 - with self.assertRaises(KeyError): + with pytest.raises(KeyError): cells[1] # force cache miss - self.assertEqual(cells[20], list_version[0]) - with self.assertRaises(TypeError): + assert cells[20] == list_version[0] + with pytest.raises(TypeError): cells["5"] - def test_delete(self): - cells = copy.deepcopy(self.simple_problem.cells) + def test_delete(self, cp_simple_problem): + cells = copy.deepcopy(cp_simple_problem.cells) size = len(cells) del cells[1] - self.assertEqual(size - 1, len(cells)) - with self.assertRaises(TypeError): + assert size - 1 == len(cells) + with pytest.raises(TypeError): del cells["5"] - def test_setitem(self): - cells = copy.deepcopy(self.simple_problem.cells) + def test_setitem(self, cp_simple_problem): + cells = copy.deepcopy(cp_simple_problem.cells) cell = cells[1] size = len(cells) - with self.assertRaises(NumberConflictError): + cell = copy.deepcopy(cell) + with pytest.raises(NumberConflictError): cells[1] = cell - with self.assertRaises(TypeError): + with pytest.raises(TypeError): cells[1] = 5 - with self.assertRaises(TypeError): + with pytest.raises(TypeError): cells["1"] = cell cell = copy.deepcopy(cell) cell.number = 20 cells[50] = cell - self.assertEqual(len(cells), size + 1) + assert len(cells) == size + 1 - def test_iadd(self): - cells = copy.deepcopy(self.simple_problem.cells) + def test_iadd(self, cp_simple_problem): + cells = copy.deepcopy(cp_simple_problem.cells) list_cells = list(cells) size = len(cells) - with self.assertRaises(NumberConflictError): + with pytest.raises(NumberConflictError): cells += list_cells - with self.assertRaises(NumberConflictError): + with pytest.raises(NumberConflictError): cells += montepy.cells.Cells(list_cells) - with self.assertRaises(TypeError): + with pytest.raises(TypeError): cells += 5 - with self.assertRaises(TypeError): + with pytest.raises(TypeError): cells += [5] list_cells = [copy.deepcopy(cells[1])] list_cells[0].number = 20 cells += list_cells - self.assertEqual(len(cells), size + 1) + assert len(cells) == size + 1 - this_problem = copy.deepcopy(self.simple_problem) + this_problem = copy.deepcopy(cp_simple_problem) + # just ignore materials being added + this_problem.materials.clear() for cell in this_problem.cells: cell.number += 1000 - this_problem.cells += self.simple_problem.cells - self.assertEqual(len(this_problem.cells), size * 2) - - def test_slice(self): - test_numbers = [c.number for c in self.simple_problem.cells[1:5]] - self.assertEqual([1, 2, 3, 5], test_numbers) - test_numbers = [c.number for c in self.simple_problem.cells[2:]] - self.assertEqual([2, 3, 5, 99], test_numbers) - test_numbers = [c.number for c in self.simple_problem.cells[::-3]] - self.assertEqual([99, 3], test_numbers) - test_numbers = [c.number for c in self.simple_problem.cells[:6:3]] - self.assertEqual([3], test_numbers) - test_numbers = [c.number for c in self.simple_problem.cells[5::-1]] - self.assertEqual([5, 3, 2, 1], test_numbers) - test_numbers = [s.number for s in self.simple_problem.surfaces[1000::10]] - self.assertEqual([1000, 1010, 1020], test_numbers) - test_numbers = [s.number for s in self.simple_problem.surfaces[:]] - self.assertEqual([1000, 1005, 1010, 1015, 1020, 1025], test_numbers) - test_numbers = [m.number for m in self.simple_problem.materials[:2]] - self.assertEqual([1, 2], test_numbers) - test_numbers = [m.number for m in self.simple_problem.materials[::2]] - self.assertEqual([2], test_numbers) - - def test_get(self): - cell_found = self.simple_problem.cells.get(1) - self.assertEqual(self.simple_problem.cells[1], cell_found) - surf_not_found = self.simple_problem.surfaces.get(39) # 39 buried, 0 found - self.assertIsNone(surf_not_found) - default_mat = self.simple_problem.materials[3] - self.assertEqual( - self.simple_problem.materials.get(42, default_mat), default_mat - ) + this_problem.cells += cp_simple_problem.cells + assert len(this_problem.cells) == size * 2 + + def test_slice(self, cp_simple_problem): + test_numbers = [c.number for c in cp_simple_problem.cells[1:5]] + assert [1, 2, 3, 5] == test_numbers + test_numbers = [c.number for c in cp_simple_problem.cells[2:]] + assert [2, 3, 5, 99] == test_numbers + test_numbers = [c.number for c in cp_simple_problem.cells[::-3]] + assert [99, 3] == test_numbers + test_numbers = [c.number for c in cp_simple_problem.cells[:6:3]] + assert [3] == test_numbers + test_numbers = [c.number for c in cp_simple_problem.cells[5::-1]] + assert [5, 3, 2, 1] == test_numbers + test_numbers = [s.number for s in cp_simple_problem.surfaces[1000::10]] + assert [1000, 1010, 1020] == test_numbers + test_numbers = [s.number for s in cp_simple_problem.surfaces[:]] + assert [1000, 1005, 1010, 1015, 1020, 1025] == test_numbers + test_numbers = [m.number for m in cp_simple_problem.materials[:2]] + assert [1, 2] == test_numbers + test_numbers = [m.number for m in cp_simple_problem.materials[::2]] + assert [2] == test_numbers + + def test_get(self, cp_simple_problem): + cell_found = cp_simple_problem.cells.get(1) + assert cp_simple_problem.cells[1] == cell_found + surf_not_found = cp_simple_problem.surfaces.get(39) # 39 buried, 0 found + assert (surf_not_found) is None + default_mat = cp_simple_problem.materials[3] + assert cp_simple_problem.materials.get(42, default_mat) == default_mat + # force a cache miss + cells = cp_simple_problem.cells + cells.link_to_problem(None) + cell = cells[1] + cell.number = 23 + assert cells.get(23) is cell - def test_keys(self): + def test_keys(self, cp_simple_problem): cell_nums = [] - for c in self.simple_problem.cells: + for c in cp_simple_problem.cells: cell_nums.append(c.number) cell_keys = [] - for k in self.simple_problem.cells.keys(): + for k in cp_simple_problem.cells.keys(): cell_keys.append(k) - self.assertEqual(cell_nums, cell_keys) - - def test_values(self): - list_cells = list(self.simple_problem.cells) - list_values = list(self.simple_problem.cells.values()) - self.assertEqual(list_cells, list_values) - - def test_items(self): - zipped = zip( - self.simple_problem.cells.keys(), self.simple_problem.cells.values() - ) - cell_items = self.simple_problem.cells.items() - self.assertTupleEqual(tuple(zipped), tuple(cell_items)) - - def test_surface_generators(self): + assert cell_nums == cell_keys + cells = montepy.Cells() + # test blank keys + assert len(list(cells.keys())) == 0 + + def test_values(self, cp_simple_problem): + list_cells = list(cp_simple_problem.cells) + list_values = list(cp_simple_problem.cells.values()) + assert list_cells == list_values + cells = montepy.Cells() + assert len(list(cells.keys())) == 0 + + def test_items(self, cp_simple_problem): + zipped = zip(cp_simple_problem.cells.keys(), cp_simple_problem.cells.values()) + cell_items = cp_simple_problem.cells.items() + assert tuple(zipped) == tuple(cell_items) + cells = montepy.Cells() + assert len(list(cells.keys())) == 0 + + def test_eq(_, cp_simple_problem): + cells = cp_simple_problem.cells + new_cells = copy.copy(cells) + assert cells == new_cells + new_cells = montepy.Cells() + assert cells != new_cells + for i in range(len(cells)): + cell = montepy.Cell() + cell.number = i + 500 + new_cells.add(cell) + assert new_cells != cells + new_cells[501].number = 2 + assert new_cells != cells + with pytest.raises(TypeError): + cells == 5 + + def test_surface_generators(self, cp_simple_problem): answer_num = [1000, 1010] - spheres = list(self.simple_problem.surfaces.so) - self.assertEqual(len(answer_num), len(spheres)) + spheres = list(cp_simple_problem.surfaces.so) + assert len(answer_num) == len(spheres) for i, sphere in enumerate(spheres): - self.assertEqual(answer_num[i], sphere.number) + assert answer_num[i] == sphere.number - def test_number_adding_concurancy(self): - surfaces = copy.deepcopy(self.simple_problem.surfaces) + def test_number_adding_concurancy(self, cp_simple_problem): + surfaces = copy.deepcopy(cp_simple_problem.surfaces) new_surf = copy.deepcopy(surfaces[1005]) new_surf.number = 5 surfaces.append(new_surf) size = len(surfaces) - new_surf = copy.deepcopy(new_surf) - with self.assertRaises(NumberConflictError): - surfaces.append(new_surf) - surfaces.append_renumber(new_surf) - self.assertEqual(len(surfaces), size + 1) - self.assertEqual(new_surf.number, 6) - - def test_str(self): - cells = self.simple_problem.cells - self.assertEqual(str(cells), "Cells: [1, 2, 3, 99, 5]") + new_surf1 = copy.deepcopy(new_surf) + with pytest.raises(NumberConflictError): + surfaces.append(new_surf1) + surfaces.append_renumber(new_surf1) + assert len(surfaces) == size + 1 + assert new_surf1.number == 6 + + def test_str(self, cp_simple_problem): + cells = cp_simple_problem.cells + assert str(cells) == "Cells: [1, 2, 3, 99, 5]" key_phrases = [ "Numbered_object_collection: obj_class: ", "Objects: [CELL: 1", "Number cache: {1: CELL: 1", ] for phrase in key_phrases: - self.assertIn(phrase, repr(cells)) - + assert phrase in repr(cells) -# test data numbered object -@pytest.fixture(scope="module") -def read_simple_problem(): - return montepy.read_input(os.path.join("tests", "inputs", "test.imcnp")) + def test_data_init(_, cp_simple_problem): + new_mats = montepy.materials.Materials( + list(cp_simple_problem.materials), problem=cp_simple_problem + ) + assert list(new_mats) == list(cp_simple_problem.materials) + + def test_data_append(_, cp_simple_problem): + prob = cp_simple_problem + new_mat = copy.deepcopy(next(iter(prob.materials))) + new_mat.number = prob.materials.request_number() + prob.materials.append(new_mat) + assert new_mat in prob.materials + assert new_mat in prob.data_inputs + assert prob.data_inputs.count(new_mat) == 1 + # trigger getting data_inputs end + prob.materials.clear() + prob.materials.append(new_mat) + assert new_mat in prob.materials + assert new_mat in prob.data_inputs + assert prob.data_inputs.count(new_mat) == 1 + prob.data_inputs.clear() + prob.materials._last_index = None + new_mat = copy.deepcopy(next(iter(prob.materials))) + new_mat.number = prob.materials.request_number() + prob.materials.append(new_mat) + assert new_mat in prob.materials + assert new_mat in prob.data_inputs + assert prob.data_inputs.count(new_mat) == 1 + # trigger getting index of last material + prob.materials._last_index = None + new_mat = copy.deepcopy(next(iter(prob.materials))) + new_mat.number = prob.materials.request_number() + prob.materials.append(new_mat) + assert new_mat in prob.materials + assert new_mat in prob.data_inputs + assert prob.data_inputs.count(new_mat) == 1 + + def test_data_append_renumber(_, cp_simple_problem): + prob = cp_simple_problem + new_mat = copy.deepcopy(next(iter(prob.materials))) + prob.materials.append_renumber(new_mat) + assert new_mat in prob.materials + assert new_mat in prob.data_inputs + assert prob.data_inputs.count(new_mat) == 1 + + def test_data_remove(_, cp_simple_problem): + prob = cp_simple_problem + old_mat = next(iter(prob.materials)) + prob.materials.remove(old_mat) + assert old_mat not in prob.materials + assert old_mat not in prob.data_inputs + with pytest.raises(TypeError): + prob.materials.remove(5) + mat = montepy.Material() + with pytest.raises(KeyError): + prob.materials.remove(mat) + # do a same number fakeout + mat = copy.deepcopy(prob.materials[2]) + with pytest.raises(KeyError): + prob.materials.remove(mat) + + def test_numbered_discard(_, cp_simple_problem): + mats = cp_simple_problem.materials + mat = mats[2] + mats.discard(mat) + assert mat not in mats + # no error + mats.discard(mat) + mats.discard(5) + + def test_numbered_contains(_, cp_simple_problem): + mats = cp_simple_problem.materials + mat = mats[2] + assert mat in mats + assert 5 not in mats + mat = montepy.Material() + mat.number = 100 + assert mat not in mats + # num cache fake out + mat.number = 2 + assert mat not in mats + + @pytest.fixture + def mats_sets(_): + mats1 = montepy.Materials() + mats2 = montepy.Materials() + for i in range(1, 10): + mat = montepy.Material() + mat.number = i + mats1.append(mat) + for i in range(5, 15): + mat = montepy.Material() + mat.number = i + mats2.append(mat) + return (mats1, mats2) + + @pytest.mark.parametrize( + "name, operator", + [ + ("and", lambda a, b: a & b), + ("or", lambda a, b: a | b), + ("sub", lambda a, b: a - b), + ("xor", lambda a, b: a ^ b), + ("sym diff", lambda a, b: a.symmetric_difference(b)), + ], + ) + def test_numbered_set_logic(_, mats_sets, name, operator): + mats1, mats2 = mats_sets + mats1_nums = set(mats1.keys()) + mats2_nums = set(mats2.keys()) + new_mats = operator(mats1, mats2) + new_nums = set(new_mats.keys()) + assert new_nums == operator(mats1_nums, mats2_nums) + + @pytest.mark.parametrize( + "name", + ["iand", "ior", "isub", "ixor", "sym_diff", "diff", "union", "intersection"], + ) + def test_numbered_set_logic_update(_, mats_sets, name): + def operator(a, b): + if name == "iand": + a &= b + elif name == "ior": + a |= b + elif name == "isub": + a -= b + elif name == "ixor": + a ^= b + elif name == "sym_diff": + a.symmetric_difference_update(b) + elif name == "diff": + a.difference_update(b) + elif name == "union": + a.update(b) + elif name == "intersection": + a.intersection_update(b) + + mats1, mats2 = mats_sets + mats1_nums = set(mats1.keys()) + mats2_nums = set(mats2.keys()) + operator(mats1, mats2) + new_nums = set(mats1.keys()) + operator(mats1_nums, mats2_nums) + assert new_nums == mats1_nums + + @pytest.mark.parametrize( + "name, operator", + [ + ("le", lambda a, b: a <= b), + ("lt", lambda a, b: a < b), + ("ge", lambda a, b: a >= b), + ("gt", lambda a, b: a > b), + ("subset", lambda a, b: a.issubset(b)), + ("superset", lambda a, b: a.issuperset(b)), + ("disjoint", lambda a, b: a.isdisjoint(b)), + ], + ) + def test_numbered_set_logic_test(_, mats_sets, name, operator): + mats1, mats2 = mats_sets + mats1_nums = set(mats1.keys()) + mats2_nums = set(mats2.keys()) + answer = operator(mats1, mats2) + assert answer == operator(mats1_nums, mats2_nums) + + @pytest.mark.parametrize( + "name, operator", + [ + ("intersection", lambda a, *b: a.intersection(*b)), + ("union", lambda a, *b: a.union(*b)), + ("difference", lambda a, *b: a.difference(*b)), + ], + ) + def test_numbered_set_logic_multi(_, mats_sets, name, operator): + mats3 = montepy.Materials() + for i in range(7, 19): + mat = montepy.Material() + mat.number = i + mats3.add(mat) + mats1, mats2 = mats_sets + mats1_nums = set(mats1.keys()) + mats2_nums = set(mats2.keys()) + mats3_nums = set(mats3.keys()) + new_mats = operator(mats1, mats2, mats3) + new_nums = set(new_mats.keys()) + assert new_nums == operator(mats1_nums, mats2_nums, mats3_nums) + + def test_numbered_set_logic_bad(_): + mats = montepy.Materials() + with pytest.raises(TypeError): + mats & 5 + with pytest.raises(TypeError): + mats &= {5} + with pytest.raises(TypeError): + mats |= {5} + with pytest.raises(TypeError): + mats -= {5} + with pytest.raises(TypeError): + mats ^= {5} + with pytest.raises(TypeError): + mats > 5 + with pytest.raises(TypeError): + mats.union(5) + + def test_data_delete(_, cp_simple_problem): + prob = cp_simple_problem + old_mat = next(iter(prob.materials)) + del prob.materials[old_mat.number] + assert old_mat not in prob.materials + assert old_mat not in prob.data_inputs + with pytest.raises(TypeError): + del prob.materials["foo"] + + def test_data_clear(_, cp_simple_problem): + data_len = len(cp_simple_problem.data_inputs) + mat_len = len(cp_simple_problem.materials) + cp_simple_problem.materials.clear() + assert len(cp_simple_problem.materials) == 0 + assert len(cp_simple_problem.data_inputs) == data_len - mat_len + + def test_data_pop(_, cp_simple_problem): + old_mat = next(reversed(list(cp_simple_problem.materials))) + old_len = len(cp_simple_problem.materials) + popper = cp_simple_problem.materials.pop() + assert popper is old_mat + assert len(cp_simple_problem.materials) == old_len - 1 + assert old_mat not in cp_simple_problem.materials + assert old_mat not in cp_simple_problem.data_inputs + with pytest.raises(TypeError): + cp_simple_problem.materials.pop("foo") + + def test_numbered_starting_number(_): + cells = montepy.Cells() + assert cells.starting_number == 1 + cells.starting_number = 5 + assert cells.starting_number == 5 + with pytest.raises(TypeError): + cells.starting_number = "hi" + with pytest.raises(ValueError): + cells.starting_number = -1 + + # disable function scoped fixtures + @settings(suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture]) + @given(start_num=st.integers(), step=st.integers()) + def test_num_collect_clone(_, read_simple_problem, start_num, step): + cp_simple_problem = copy.deepcopy(read_simple_problem) + surfs = copy.deepcopy(cp_simple_problem.surfaces) + if start_num <= 0 or step <= 0: + with pytest.raises(ValueError): + surfs.clone(start_num, step) + return + for clear in [False, True]: + if clear: + surfs.link_to_problem(None) + new_surfs = surfs.clone(start_num, step) + for new_surf, old_surf in zip(new_surfs, surfs): + assert new_surf is not old_surf + assert new_surf.surface_type == old_surf.surface_type + assert new_surf.number != old_surf.number + + @settings( + suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture], + deadline=500, + ) + @given( + start_num=st.integers(), + step=st.integers(), + clone_mat=st.booleans(), + clone_region=st.booleans(), + ) + def test_cells_clone( + _, read_simple_problem, start_num, step, clone_mat, clone_region + ): + cp_simple_problem = copy.deepcopy(read_simple_problem) + cells = copy.deepcopy(cp_simple_problem.cells) + if start_num <= 0 or step <= 0: + with pytest.raises(ValueError): + cells.clone(starting_number=start_num, step=step) + return + for clear in [False, True]: + if clear: + cells.link_to_problem(None) + new_cells = cells.clone(clone_mat, clone_region, start_num, step) + for new_cell, old_cell in zip(new_cells, cells): + assert new_cell is not old_cell + assert new_cell.number != old_cell.number + assert new_cell.geometry is not old_cell.geometry + if clone_mat and old_cell.material: + assert new_cell.material is not old_cell.material + else: + assert new_cell.material == old_cell.material + if clone_region: + if len(old_cell.surfaces) > 0: + assert new_cell.surfaces != old_cell.surfaces + if len(old_cell.complements) > 0: + assert new_cell.complements != old_cell.complements + else: + assert new_cell.surfaces == old_cell.surfaces + assert new_cell.complements == old_cell.complements + assert new_cell.importance.neutron == old_cell.importance.neutron + + def test_num_collect_clone_default(_, cp_simple_problem): + surfs = copy.deepcopy(cp_simple_problem.surfaces) + for clear in [False, True]: + if clear: + surfs.link_to_problem(None) + new_surfs = surfs.clone() + for new_surf, old_surf in zip(new_surfs, surfs): + assert new_surf is not old_surf + assert new_surf.surface_type == old_surf.surface_type + assert new_surf.number != old_surf.number + + def test_num_collect_link_problem(_, cp_simple_problem): + cells = montepy.Cells() + cells.link_to_problem(cp_simple_problem) + assert cells._problem == cp_simple_problem + cells.link_to_problem(None) + assert cells._problem is None + with pytest.raises(TypeError): + cells.link_to_problem("hi") + + @pytest.mark.parametrize( + "args, error", + [ + (("c", 1), TypeError), + ((1, "d"), TypeError), + ((-1, 1), ValueError), + ((0, 1), ValueError), + ((1, 0), ValueError), + ((1, -1), ValueError), + ], + ) + def test_num_collect_clone_bad(_, cp_simple_problem, args, error): + surfs = cp_simple_problem.surfaces + with pytest.raises(error): + surfs.clone(*args) -@pytest.fixture -def cp_simple_problem(read_simple_problem): - return copy.deepcopy(read_simple_problem) +class TestMaterials: + @pytest.fixture(scope="class") + def m0_prob(_): + return montepy.read_input( + os.path.join("tests", "inputs", "test_importance.imcnp") + ) -def test_data_init(cp_simple_problem): - new_mats = montepy.materials.Materials( - list(cp_simple_problem.materials), problem=cp_simple_problem + @pytest.fixture + def cp_m0_prob(_, m0_prob): + return copy.deepcopy(m0_prob) + + def test_m0_defaults(_, m0_prob): + prob = m0_prob + assert prob.materials.default_libraries["nlib"] == "00c" + assert prob.materials.default_libraries["plib"] == "80p" + assert prob.materials.default_libraries["alib"] is None + + def test_m0_defaults_fresh(_): + prob = montepy.MCNP_Problem("") + prob.materials.default_libraries["nlib"] = "00c" + prob.materials.default_libraries["plib"] = "80p" + assert prob.materials.default_libraries["nlib"] == "00c" + assert prob.materials.default_libraries["plib"] == "80p" + assert prob.materials.default_libraries["alib"] is None + + @pytest.mark.parametrize( + "nuclides, threshold, num", + [ + (("26054", "26056"), 1.0, 1), + ((montepy.Nuclide("H-1"),), 0.0, 1), + (("B",), 1.0, 0), + ], ) - assert list(new_mats) == list(cp_simple_problem.materials) - - -def test_data_append(cp_simple_problem): - prob = cp_simple_problem - new_mat = copy.deepcopy(next(iter(prob.materials))) - new_mat.number = prob.materials.request_number() - prob.materials.append(new_mat) - assert new_mat in prob.materials - assert new_mat in prob.data_inputs - assert prob.data_inputs.count(new_mat) == 1 - # trigger getting data_inputs end - prob.materials.clear() - prob.materials.append(new_mat) - assert new_mat in prob.materials - assert new_mat in prob.data_inputs - assert prob.data_inputs.count(new_mat) == 1 - prob.data_inputs.clear() - prob.materials._last_index = None - new_mat = copy.deepcopy(next(iter(prob.materials))) - new_mat.number = prob.materials.request_number() - prob.materials.append(new_mat) - assert new_mat in prob.materials - assert new_mat in prob.data_inputs - assert prob.data_inputs.count(new_mat) == 1 - # trigger getting index of last material - prob.materials._last_index = None - new_mat = copy.deepcopy(next(iter(prob.materials))) - new_mat.number = prob.materials.request_number() - prob.materials.append(new_mat) - assert new_mat in prob.materials - assert new_mat in prob.data_inputs - assert prob.data_inputs.count(new_mat) == 1 - - -def test_data_append_renumber(cp_simple_problem): - prob = cp_simple_problem - new_mat = copy.deepcopy(next(iter(prob.materials))) - prob.materials.append_renumber(new_mat) - assert new_mat in prob.materials - assert new_mat in prob.data_inputs - assert prob.data_inputs.count(new_mat) == 1 - - -def test_data_remove(cp_simple_problem): - prob = cp_simple_problem - old_mat = next(iter(prob.materials)) - prob.materials.remove(old_mat) - assert old_mat not in prob.materials - assert old_mat not in prob.data_inputs - - -def test_data_delete(cp_simple_problem): - prob = cp_simple_problem - old_mat = next(iter(prob.materials)) - del prob.materials[old_mat.number] - assert old_mat not in prob.materials - assert old_mat not in prob.data_inputs - with pytest.raises(TypeError): - del prob.materials["foo"] - - -def test_data_clear(cp_simple_problem): - data_len = len(cp_simple_problem.data_inputs) - mat_len = len(cp_simple_problem.materials) - cp_simple_problem.materials.clear() - assert len(cp_simple_problem.materials) == 0 - assert len(cp_simple_problem.data_inputs) == data_len - mat_len - - -def test_data_pop(cp_simple_problem): - old_mat = next(reversed(list(cp_simple_problem.materials))) - popper = cp_simple_problem.materials.pop() - assert popper is old_mat - assert old_mat not in cp_simple_problem.materials - assert old_mat not in cp_simple_problem.data_inputs - with pytest.raises(TypeError): - cp_simple_problem.materials.pop("foo") - - -# disable function scoped fixtures -@settings(suppress_health_check=[hypothesis.HealthCheck.function_scoped_fixture]) -@given(start_num=st.integers(), step=st.integers()) -def test_num_collect_clone(cp_simple_problem, start_num, step): - surfs = copy.deepcopy(cp_simple_problem.surfaces) - if start_num <= 0 or step <= 0: - with pytest.raises(ValueError): - surfs.clone(start_num, step) - return - for clear in [False, True]: - if clear: - surfs.link_to_problem(None) - new_surfs = surfs.clone(start_num, step) - for new_surf, old_surf in zip(new_surfs, surfs): - assert new_surf is not old_surf - assert new_surf.surface_type == old_surf.surface_type - assert new_surf.number != old_surf.number - - -def test_num_collect_clone_default(cp_simple_problem): - surfs = copy.deepcopy(cp_simple_problem.surfaces) - for clear in [False, True]: - if clear: - surfs.link_to_problem(None) - new_surfs = surfs.clone() - for new_surf, old_surf in zip(new_surfs, surfs): - assert new_surf is not old_surf - assert new_surf.surface_type == old_surf.surface_type - assert new_surf.number != old_surf.number - - -@pytest.mark.parametrize( - "args, error", - [ - (("c", 1), TypeError), - ((1, "d"), TypeError), - ((-1, 1), ValueError), - ((0, 1), ValueError), - ((1, 0), ValueError), - ((1, -1), ValueError), - ], -) -def test_num_collect_clone_bad(cp_simple_problem, args, error): - surfs = cp_simple_problem.surfaces - with pytest.raises(error): - surfs.clone(*args) + def test_get_containing(_, m0_prob, nuclides, threshold, num): + ret = list(m0_prob.materials.get_containing(*nuclides, threshold=threshold)) + assert len(ret) == num + for mat in ret: + assert isinstance(mat, montepy.Material) + with pytest.raises(TypeError): + next(m0_prob.materials.get_containing(m0_prob)) + + @pytest.fixture + def h2o(_): + mat = montepy.Material() + mat.number = 1 + mat.add_nuclide("H-1.80c", 2.0) + mat.add_nuclide("O-16.80c", 1.0) + return mat + + @pytest.fixture + def mass_h2o(_): + mat = montepy.Material() + mat.number = 1 + mat.is_atom_fraction = False + mat.add_nuclide("H-1.80c", 2.0) + mat.add_nuclide("O-16.80c", 1.0) + return mat + + @pytest.fixture + def boric_acid(_): + mat = montepy.Material() + mat.number = 2 + for nuclide, fraction in { + "1001.80c": 3.0, + "B-10.80c": 1.0 * 0.189, + "B-11.80c": 1.0 * 0.796, + "O-16.80c": 3.0, + }.items(): + mat.add_nuclide(nuclide, fraction) + return mat + + @pytest.fixture + def mats_dict(_, h2o, mass_h2o, boric_acid): + return {"h2o": h2o, "mass_h2o": mass_h2o, "boric_acid": boric_acid} + + @pytest.mark.parametrize( + "args, error, use_fixture", + [ + (("hi", [1]), TypeError, False), + ((["hi"], [1]), TypeError, False), + (([], [1]), ValueError, False), # empty materials + ((["h2o", "mass_h2o"], [1, 2]), ValueError, True), # mismatch is_atom + ((["h2o", "boric_acid"], [1.0]), ValueError, True), # mismatch lengths + ((["h2o", "boric_acid"], "hi"), TypeError, True), + ((["h2o", "boric_acid"], ["hi"]), TypeError, True), + ((["h2o", "boric_acid"], [-1.0, 2.0]), ValueError, True), + ((["h2o", "boric_acid"], [1.0, 2.0], "hi"), TypeError, True), + ((["h2o", "boric_acid"], [1.0, 2.0], -1), ValueError, True), + ((["h2o", "boric_acid"], [1.0, 2.0], 1, "hi"), TypeError, True), + ((["h2o", "boric_acid"], [1.0, 2.0], 1, -1), ValueError, True), + ], + ) + def test_mix_bad(_, mats_dict, args, error, use_fixture): + if use_fixture: + mats = [] + for mat in args[0]: + mats.append(mats_dict[mat]) + args = (mats,) + args[1:] + with pytest.raises(error): + mats = montepy.Materials() + mats.mix(*args) + + @given( + starting_num=st.one_of(st.none(), st.integers(1)), + step=st.one_of(st.none(), st.integers(1)), + ) + def test_mix(_, starting_num, step): + mat = montepy.Material() + mat.number = 1 + mat.add_nuclide("H-1.80c", 2.0) + mat.add_nuclide("O-16.80c", 1.0) + parents = [mat] + mat = montepy.Material() + mat.number = 2 + for nuclide, fraction in { + "1001.80c": 3.0, + "B-10.80c": 1.0 * 0.189, + "B-11.80c": 1.0 * 0.796, + "O-16.80c": 3.0, + }.items(): + mat.add_nuclide(nuclide, fraction) + parents.append(mat) + boron_conc = 10 * 1e-6 + fractions = [1 - boron_conc, boron_conc] + mats = montepy.Materials() + for par in parents: + mats.append(par) + new_mat = mats.mix( + parents, + fractions, + starting_num, + step, + ) + assert sum(new_mat.values) == pytest.approx( + 1.0 + ) # should normalize to 1 with fractions + assert new_mat.is_atom_fraction == parents[0].is_atom_fraction + flat_fracs = [] + for par, frac in zip(parents, fractions): + par.normalize() + flat_fracs += [frac] * len(par) + for (new_nuc, new_frac), (old_nuc, old_frac), fraction in zip( + new_mat, it.chain(*parents), flat_fracs + ): + assert new_nuc == old_nuc + assert new_nuc is not old_nuc + assert new_frac == pytest.approx(old_frac * fraction) + if starting_num is None: + starting_num = mats.starting_number + if step is None: + step = mats.step + if starting_num not in [p.number for p in parents]: + assert new_mat.number == starting_num + else: + assert (new_mat.number - starting_num) % step == 0 diff --git a/tests/test_surfaces.py b/tests/test_surfaces.py index 9d22ef8b..d20dee31 100644 --- a/tests/test_surfaces.py +++ b/tests/test_surfaces.py @@ -17,9 +17,7 @@ class testSurfaces(TestCase): def test_surface_init(self): - in_str = "1 PZ 0.0" - card = Input([in_str], BlockType.SURFACE) - surf = Surface(card) + surf = Surface("1 PZ 0.0") self.assertEqual(surf.number, 1) self.assertEqual(surf.old_number, 1) self.assertEqual(len(surf.surface_constants), 1) @@ -79,6 +77,8 @@ def test_surface_init(self): card = Input([in_str], BlockType.SURFACE) with self.assertRaises(MalformedInputError): Surface(card) + surf = Surface(number=5) + assert surf.number == 5 def test_validator(self): surf = Surface() @@ -236,6 +236,8 @@ def test_axis_plane_init(self): surf = montepy.surfaces.axis_plane.AxisPlane( Input([bad_input], BlockType.SURFACE) ) + surf = montepy.surfaces.axis_plane.AxisPlane(number=5) + assert surf.number == 5 def test_cylinder_on_axis_init(self): bad_inputs = ["1 P 0.0", "1 CZ 0.0 10.0"] @@ -244,6 +246,8 @@ def test_cylinder_on_axis_init(self): surf = montepy.surfaces.cylinder_on_axis.CylinderOnAxis( Input([bad_input], BlockType.SURFACE) ) + surf = montepy.surfaces.cylinder_on_axis.CylinderOnAxis(number=5) + assert surf.number == 5 def test_cylinder_par_axis_init(self): bad_inputs = ["1 P 0.0", "1 C/Z 0.0"] @@ -252,6 +256,8 @@ def test_cylinder_par_axis_init(self): surf = montepy.surfaces.cylinder_par_axis.CylinderParAxis( Input([bad_input], BlockType.SURFACE) ) + surf = montepy.surfaces.cylinder_par_axis.CylinderParAxis(number=5) + assert surf.number == 5 def test_gen_plane_init(self): bad_inputs = ["1 PZ 0.0", "1 P 0.0"] @@ -260,6 +266,8 @@ def test_gen_plane_init(self): surf = montepy.surfaces.general_plane.GeneralPlane( Input([bad_input], BlockType.SURFACE) ) + surf = montepy.surfaces.general_plane.GeneralPlane(number=5) + assert surf.number == 5 def test_axis_plane_location_setter(self): in_str = "1 PZ 0.0" diff --git a/tests/test_syntax_parsing.py b/tests/test_syntax_parsing.py index 1534d588..67c5e5fa 100644 --- a/tests/test_syntax_parsing.py +++ b/tests/test_syntax_parsing.py @@ -440,6 +440,7 @@ def test_syntax_trailing_comments(self): def test_syntax_str(self): str(self.test_node) repr(self.test_node) + self.test_node._pretty_str() class TestGeometryTree(TestCase): @@ -478,6 +479,7 @@ def test_geometry_str(self): test = self.test_tree str(test) repr(test) + test._pretty_str() def test_geometry_comments(self): test = copy.deepcopy(self.test_tree) @@ -696,6 +698,7 @@ def test_list_str(self): list_node.append(syntax_node.ValueNode("1.0", float)) str(list_node) repr(list_node) + list_node._pretty_str() def test_list_slicing(self): list_node = syntax_node.ListNode("list") @@ -762,71 +765,72 @@ def test_list_comments(self): self.assertEqual(len(comments), 1) -class TestIsotopesNode(TestCase): +class TestMaterialssNode(TestCase): def test_isotopes_init(self): - isotope = syntax_node.IsotopesNode("test") + isotope = syntax_node.MaterialsNode("test") self.assertEqual(isotope.name, "test") self.assertIsInstance(isotope.nodes, list) def test_isotopes_append(self): - isotopes = syntax_node.IsotopesNode("test") + isotopes = syntax_node.MaterialsNode("test") zaid = syntax_node.ValueNode("1001.80c", str) concentration = syntax_node.ValueNode("1.5", float) - isotopes.append(("isotope_fraction", zaid, concentration)) + isotopes.append_nuclide(("isotope_fraction", zaid, concentration)) self.assertEqual(isotopes.nodes[-1][0], zaid) self.assertEqual(isotopes.nodes[-1][1], concentration) def test_isotopes_format(self): padding = syntax_node.PaddingNode(" ") - isotopes = syntax_node.IsotopesNode("test") + isotopes = syntax_node.MaterialsNode("test") zaid = syntax_node.ValueNode("1001.80c", str) zaid.padding = padding concentration = syntax_node.ValueNode("1.5", float) concentration.padding = padding - isotopes.append(("isotope_fraction", zaid, concentration)) + isotopes.append_nuclide(("isotope_fraction", zaid, concentration)) self.assertEqual(isotopes.format(), "1001.80c 1.5 ") def test_isotopes_str(self): - isotopes = syntax_node.IsotopesNode("test") + isotopes = syntax_node.MaterialsNode("test") zaid = syntax_node.ValueNode("1001.80c", str) concentration = syntax_node.ValueNode("1.5", float) - isotopes.append(("isotope_fraction", zaid, concentration)) + isotopes.append_nuclide(("isotope_fraction", zaid, concentration)) str(isotopes) repr(isotopes) + isotopes._pretty_str() def test_isotopes_iter(self): - isotopes = syntax_node.IsotopesNode("test") + isotopes = syntax_node.MaterialsNode("test") zaid = syntax_node.ValueNode("1001.80c", str) concentration = syntax_node.ValueNode("1.5", float) - isotopes.append(("isotope_fraction", zaid, concentration)) - isotopes.append(("isotope_fraction", zaid, concentration)) + isotopes.append_nuclide(("isotope_fraction", zaid, concentration)) + isotopes.append_nuclide(("isotope_fraction", zaid, concentration)) for combo in isotopes: self.assertEqual(len(combo), 2) def test_isotopes_comments(self): padding = syntax_node.PaddingNode(" ") - isotopes = syntax_node.IsotopesNode("test") + isotopes = syntax_node.MaterialsNode("test") zaid = syntax_node.ValueNode("1001.80c", str) zaid.padding = padding concentration = syntax_node.ValueNode("1.5", float) padding = copy.deepcopy(padding) padding.append("$ hi", True) concentration.padding = padding - isotopes.append(("isotope_fraction", zaid, concentration)) + isotopes.append_nuclide(("isotope_fraction", zaid, concentration)) comments = list(isotopes.comments) self.assertEqual(len(comments), 1) self.assertEqual(comments[0].contents, "hi") def test_isotopes_trailing_comment(self): padding = syntax_node.PaddingNode(" ") - isotopes = syntax_node.IsotopesNode("test") + isotopes = syntax_node.MaterialsNode("test") zaid = syntax_node.ValueNode("1001.80c", str) zaid.padding = padding concentration = syntax_node.ValueNode("1.5", float) padding = copy.deepcopy(padding) padding.append("c hi", True) concentration.padding = padding - isotopes.append(("isotope_fraction", zaid, concentration)) + isotopes.append_nuclide(("isotope_fraction", zaid, concentration)) comments = isotopes.get_trailing_comment() self.assertEqual(len(comments), 1) self.assertEqual(comments[0].contents, "hi") @@ -1463,8 +1467,8 @@ def test_get_line_numbers(self): (5, 1, 60): 80, (6, 1, 0): 80, (6, 2, 0): 128, - (6, 2, 3): 128, (6, 3, 0): 128, + (6, 3, 3): 128, # Test for newer not released versions (7, 4, 0): 128, } for version, answer in answers.items(): @@ -1573,6 +1577,7 @@ def test_parameter_dict(self): def test_parameter_str(self): str(self.param) repr(self.param) + self.param._pretty_str() def test_parameter_format(self): self.assertEqual(self.param.format(), "vol=1.0") diff --git a/tests/test_transform.py b/tests/test_transform.py index 421530a1..b1d59574 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -48,6 +48,8 @@ def test_transform_init(self): with self.assertRaises(MalformedInputError): card = Input(["TR5:n,p 0.0 0.0 0.0"], BlockType.DATA) Transform(card) + transform = Transform(number=5) + assert transform.number == 5 # test vanilla case in_str = "tr5 " + "1.0 " * 3 + "0.0 " * 9