diff --git a/.github/scripts/check_sitemap.py b/.github/scripts/check_sitemap.py old mode 100644 new mode 100755 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 f9505ba6..757b9ec9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -62,7 +62,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@v2.1.1 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 27b2202a..56f92368 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -2,6 +2,61 @@ MontePy Changelog ***************** +1.0.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`). + +**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 ============ @@ -47,7 +102,7 @@ MontePy Changelog ============ 0.4.1 --------------- +---------------- **Features Added** diff --git a/doc/source/conf.py b/doc/source/conf.py index 4adf71f5..8ea8e1b0 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -37,9 +37,10 @@ "sphinx.ext.intersphinx", "sphinx.ext.extlinks", "sphinx.ext.doctest", + "sphinx_autodoc_typehints", "sphinx_sitemap", "sphinx_favicon", - "sphinx_copybutton" + "sphinx_copybutton", ] # Add any paths that contain templates here, relative to this directory. @@ -60,6 +61,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 @@ -68,16 +75,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..d4d5db66 --- /dev/null +++ b/doc/source/dev_checklist.rst @@ -0,0 +1,95 @@ +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. 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 df91c14d..122c198f 100644 --- a/doc/source/dev_tree.rst +++ b/doc/source/dev_tree.rst @@ -5,5 +5,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 1af832ad..bdfe2e73 100644 --- a/doc/source/developing.rst +++ b/doc/source/developing.rst @@ -1,5 +1,5 @@ -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. @@ -20,123 +20,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. 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 3fd427d2..d80fe940 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -33,6 +33,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 ec413b6b..441281ae 100644 --- a/doc/source/starting.rst +++ b/doc/source/starting.rst @@ -547,7 +547,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) @@ -559,6 +561,7 @@ Instead you use the binary not operator (``~``). .. doctest:: >>> capsule_cell = montepy.Cell() + >>> capsule_cell.number = 1 >>> type(~capsule_cell) @@ -587,18 +590,19 @@ 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() bottom_plane.location = 0.0 - top_plane = montepy.surfaces.axis_plane.AxisPlane() + top_plane = montepy.AxisPlane() top_plane.location = 10.0 - fuel_cylinder = montepy.surfaces.cylinder_on_axis.CylinderOnAxis() + fuel_cylinder = montepy.CylinderOnAxis() fuel_cylinder.radius = 1.26 / 2 - clad_cylinder = montepy.surfaces.cylinder_on_axis.CylinderOnAxis() + clad_cylinder = montepy.CylinderOnAxis() clad_cylinder.radius = (1.26 / 2) + 1e-3 # fuel, gap, cladding - clad_od = montepy.surfaces.cylinder_on_axis.CylinderOnAxis() + clad_od = montepy.surfaces.CylinderOnAxis() clad_od.radius = clad_cylinder.radius + 0.1 # add thickness - other_fuel = montepy.surfaces.cylinder_on_axis.CylinderOnAxis() + other_fuel = montepy.surfaces.CylinderOnAxis() other_fuel.radius = 3.0 + other_fuel.number = 10 bottom_plane.number = 1 top_plane.number = 2 fuel_cylinder.number = 3 @@ -633,7 +637,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:: @@ -798,7 +805,7 @@ You can also easy apply a transform to the filling universe with: transform = montepy.data_inputs.transform.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..ad83b5e1 100644 --- a/montepy/__init__.py +++ b/montepy/__init__.py @@ -10,17 +10,37 @@ 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 + +# 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..4d7123ff 100644 --- a/montepy/cell.py +++ b/montepy/cell.py @@ -1,4 +1,6 @@ -# Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +# 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations + import copy import itertools import numbers @@ -18,6 +20,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 +32,47 @@ 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. + + .. 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 - :param input: the input for the cell definition - :type input: Input .. seealso:: * :manual63sec:`5.2` * :manual62:`55` + + :param input: the input for the cell definition + :type input: Input + """ _ALLOWED_KEYWORDS = { @@ -69,9 +102,16 @@ 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: montepy.input_parser.mcnp_input.Input = None): + 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() @@ -169,6 +209,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 +228,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): @@ -362,21 +411,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 +571,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: @@ -644,6 +690,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 +756,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 +819,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..230e44e5 100644 --- a/montepy/data_inputs/cell_modifier.py +++ b/montepy/data_inputs/cell_modifier.py @@ -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..ed7cab19 100644 --- a/montepy/data_inputs/data_input.py +++ b/montepy/data_inputs/data_input.py @@ -229,7 +229,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,70 +260,6 @@ 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): """ 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/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/material.py b/montepy/data_inputs/material.py index b019ff36..610c5791 100644 --- a/montepy/data_inputs/material.py +++ b/montepy/data_inputs/material.py @@ -1,7 +1,16 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. +from __future__ import annotations + import copy +import collections as co +import itertools +import math +from typing import Generator, Union +import weakref + 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 @@ -9,32 +18,278 @@ from montepy.numbered_mcnp_object import Numbered_MCNP_Object from montepy.errors import * from montepy.utilities import * -import itertools -import re +from montepy.particle import LibraryType +import montepy +import re 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 + >>> 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 + + + .. seealso:: + + * :manual63:`5.6.1` + * :manual62:`106` + + .. versionchanged:: 1.0.0 + + This was the primary change for this release. For more details on what changed see :ref:`migrate 0 1`. + + :param input: the input that contains the data for this material :type input: Input """ _parser = MaterialParser() - def __init__(self, input=None): - self._material_components = {} + def __init__(self, input: montepy.input_parser.mcnp_input.Input = 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) if input: num = self._input_number @@ -42,40 +297,81 @@ def __init__(self, input=None): 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.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 +379,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 +1162,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,26 +1174,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 isotope, component in self._material_components.items(): - isotope._tree.value = isotope.mcnp_str() - 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): """ @@ -173,12 +1207,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 @@ -201,52 +1235,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..11d6974c 100644 --- a/montepy/data_inputs/thermal_scattering.py +++ b/montepy/data_inputs/thermal_scattering.py @@ -15,6 +15,11 @@ 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 :param material: the parent Material object that owns this diff --git a/montepy/input_parser/data_parser.py b/montepy/input_parser/data_parser.py index 57635454..497e8e7b 100644 --- a/montepy/input_parser/data_parser.py +++ b/montepy/input_parser/data_parser.py @@ -74,8 +74,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") 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..6f6fb9f4 100644 --- a/montepy/input_parser/mcnp_input.py +++ b/montepy/input_parser/mcnp_input.py @@ -110,22 +110,6 @@ 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. @@ -247,35 +231,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 +290,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 +383,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/syntax_node.py b/montepy/input_parser/syntax_node.py index 8d92d961..7d8843dd 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 @@ -1074,7 +1075,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 @@ -1746,14 +1747,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 +1766,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,10 +1782,26 @@ 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): @@ -1794,12 +1818,12 @@ def comments(self): def get_trailing_comment(self): tail = self.nodes[-1] - tail = tail[1] + tail = tail[-1] return tail.get_trailing_comment() def _delete_trailing_comment(self): tail = self.nodes[-1] - tail = tail[1] + tail = tail[-1] tail._delete_trailing_comment() def flatten(self): diff --git a/montepy/materials.py b/montepy/materials.py index 66273773..406f1147 100644 --- a/montepy/materials.py +++ b/montepy/materials.py @@ -1,4 +1,9 @@ # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved. + +from __future__ import annotations +import copy +from typing import Generator, Union + import montepy from montepy.numbered_object_collection import NumberedDataObjectCollection @@ -13,9 +18,255 @@ class Materials(NumberedDataObjectCollection): When items are added to this (and this object is linked to a problem), they will also be added to :func:`montepy.mcnp_problem.MCNP_Problem.data_inputs`. + .. note:: + + For examples see the ``NumberedObjectCollection`` :ref:`collect ex`. + + :param objects: the list of materials to start with if needed :type objects: list """ def __init__(self, objects=None, problem=None): super().__init__(Material, objects, problem) + + def get_containing( + self, + nuclide: Union[ + montepy.data_inputs.nuclide.Nuclide, + montepy.data_inputs.nuclide.Nucleus, + montepy.Element, + str, + int, + ], + *args: Union[ + montepy.data_inputs.nuclide.Nuclide, + montepy.data_inputs.nuclide.Nucleus, + montepy.Element, + str, + int, + ], + threshold: float = 0.0, + ) -> 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..26841ed2 100644 --- a/montepy/mcnp_object.py +++ b/montepy/mcnp_object.py @@ -1,8 +1,15 @@ # 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 +import warnings +import weakref + from montepy.errors import * from montepy.constants import ( BLANK_SPACE_CONTINUE, @@ -17,10 +24,6 @@ ValueNode, ) import montepy -import numpy as np -import textwrap -import warnings -import weakref class _ExceptionContextAdder(ABCMeta): @@ -42,7 +45,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 @@ -96,7 +105,11 @@ class MCNP_Object(ABC, metaclass=_ExceptionContextAdder): :type parser: MCNP_Lexer """ - def __init__(self, input, parser): + def __init__( + self, + input: montepy.input_parser.mcnp_input.Input, + parser: montepy.input_parser.parser_base.MCNP_Parser, + ): self._problem_ref = None self._parameters = ParametersNode() self._input = None @@ -114,7 +127,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,8 +137,30 @@ 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. @@ -151,7 +186,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. @@ -178,7 +213,7 @@ def _update_values(self): """ 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 +230,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,7 +243,7 @@ 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. @@ -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..3f36de8c 100644 --- a/montepy/mcnp_problem.py +++ b/montepy/mcnp_problem.py @@ -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): """ diff --git a/montepy/numbered_mcnp_object.py b/montepy/numbered_mcnp_object.py index 9d79ee6c..9b7c4da3 100644 --- a/montepy/numbered_mcnp_object.py +++ b/montepy/numbered_mcnp_object.py @@ -31,6 +31,10 @@ def _number_validator(self, number): class Numbered_MCNP_Object(MCNP_Object): + _CHILD_OBJ_MAP = {} + """ + """ + @make_prop_val_node("_number", int, validator=_number_validator) def number(self): """ @@ -50,6 +54,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..e2e29558 100644 --- a/montepy/surfaces/__init__.py +++ b/montepy/surfaces/__init__.py @@ -1,10 +1,15 @@ # 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 diff --git a/montepy/surfaces/half_space.py b/montepy/surfaces/half_space.py index 358eecaa..e571b6d1 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 @@ -202,20 +203,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: @@ -671,20 +683,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 remove_duplicate_surfaces(self, deleting_dict): + def surf_cont(div=None): + if div: + return montepy.surface_collection.Surfaces([div]) + return montepy.surface_collection.Surfaces() + + 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..a340f7be 100644 --- a/montepy/surfaces/surface.py +++ b/montepy/surfaces/surface.py @@ -23,6 +23,10 @@ class Surface(Numbered_MCNP_Object): def __init__(self, input=None): super().__init__(input, self._parser) + self._CHILD_OBJ_MAP = { + "periodic_surface": Surface, + "transform": transform.Transform, + } self._periodic_surface = None self._old_periodic_surface = self._generate_default_node(int, None) self._transform = None @@ -285,9 +289,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 +303,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_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/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 0ad39002..01dd7a06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,8 @@ doc = [ "pydata_sphinx_theme", "sphinx-sitemap", "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..0a02a422 100644 --- a/tests/test_cell_problem.py +++ b/tests/test_cell_problem.py @@ -271,11 +271,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 +321,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..e03d27b9 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 diff --git a/tests/test_material.py b/tests/test_material.py index d9267c6a..64e753d5 100644 --- a/tests/test_material.py +++ b/tests/test_material.py @@ -1,45 +1,345 @@ # 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) +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", + ]: + input = Input([line], BlockType.DATA) material = Material(input) - def test_material_validator(self): + 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 +353,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,305 +366,248 @@ 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]) - - -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) + + @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" @@ -421,52 +665,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_syntax_parsing.py b/tests/test_syntax_parsing.py index 1534d588..cbd80e82 100644 --- a/tests/test_syntax_parsing.py +++ b/tests/test_syntax_parsing.py @@ -762,71 +762,71 @@ 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) 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 +1463,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():