Repository: idaholab/MontePy (language: python)

Issue:
remove o(n^2) operation from NumberedObjectCollection
<!--
If you are a first-time contributor to MontePy,
refer the developing guidelines at:
https://idaholab.github.io/MontePy/developing.html
-->

# Description

As described in #556 there was an O(N^2) operation for appending N items to a `NumberedObjectCollection`. This was because `self.numbers` (which is an O(N) generator) was checked every append. This was fixed by:

1. Moving all number conflict checks to the existing `check_number`
2. Trusting `__num_cache` explicitly iff this problem is linked to a problem.
3. creating a method: `_update_number` that updates `__num_cache` appropriately when a number is changed. 
4. Ensuring all `Numbered_MCNPObject` instances run `_update_number` if they are attached a problem. This is why `__num_cache` is trusted in this case.

This doesn't remove all bottlenecks and get us to O(N) time, but it does make `append` no longer a bottle neck as can be seen in the profiling results. 

Don't review this until #539 is merged because this is built off of that branch. 

Fixes #556

# Checklist

- [x] I have performed a self-review of my own code
- [x] I have made corresponding changes to the documentation (if applicable)
- [x] I have added tests that prove my fix is effective or that my feature works (if applicable)
<!--
While tests will automatically be checked by CI, it is good practice to
ensure that they pass locally first. 
-->

Remove O(N^2) operation from NumberedObjectCollection
**Is your feature request related to a problem? Please describe.**

I was analyzing some data from parsing times and found that we have O(N^2) scaling despite fixing #510. After some investigation I found the culprit is in `NumberedObjectCollections.append`. This runs through all of `self.numbers` every time which is a generator over the entire list.... O(N^2).

So this should be eliminated.

**Describe the solution you'd like**

A few solutions.

1. Remove this operation from `read_input` and do a bulk add that is optimized
2. Make `NumberedObjectCollection` a lot more like dicts, and rely on `__num_cache` a lot more. Through the number property we guarantee that numbers are always update for objects tied to problem. So maybe we use this O(n^2) operation only as a last resot.



Bug when attempting to edit a cell geometry

**To Reproduce**

A short code snippet of what you have ran. Please change or remove any specific values or anything that can't be public. For example:

``` python
input = montepy.input_parser.mcnp_input.Input(["1 0 (-1 : 2) 3 imp:n=1"], montepy.input_parser.block_type.BlockType.CELL)
cell = montepy.Cell(input)
surf4 = montepy.surfaces.surface.Surface()
surf4.number = 4
surf5 = montepy.surfaces.surface.Surface()
surf5.number = 5
cell.geometry = (cell.geometry & -surf4) | +surf5
```

**Error Message (if any)**

If an error message was printed please include the entire stacktrace. If it includes any specific values please change or remove them. For example:

``` python
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[12], line 1
----> 1 cell.geometry = (cell.geometry & -surf4) | +surf5

File ~/mambaforge/lib/python3.11/site-packages/montepy/utilities.py:160, in make_prop_pointer.<locals>.decorator.<locals>.setter(self, value)
    158     value = base_type(value)
    159 if validator:
--> 160     validator(self, value)
    161 setattr(self, hidden_param, value)

File ~/mambaforge/lib/python3.11/site-packages/montepy/cell.py:30, in _link_geometry_to_cell(self, geom)
     28 def _link_geometry_to_cell(self, geom):
     29     geom._cell = self
---> 30     geom._add_new_children_to_cell(geom)

File ~/mambaforge/lib/python3.11/site-packages/montepy/surfaces/half_space.py:195, in HalfSpace._add_new_children_to_cell(self, other)
    193 for item in container:
    194     if item not in parent:
--> 195         parent.append(item)

File ~/mambaforge/lib/python3.11/site-packages/montepy/numbered_object_collection.py:248, in NumberedObjectCollection.append(self, obj)
    241 """Appends the given object to the end of this collection.
    242
    243 :param obj: the object to add.
    244 :type obj: Numbered_MCNP_Object
    245 :raises NumberConflictError: if this object has a number that is already in use.
    246 """
    247 if not isinstance(obj, self._obj_class):
--> 248     raise TypeError(f"object being appended must be of type: {self._obj_class}")
    249 if obj.number in self.numbers:
    250     raise NumberConflictError(
    251         (
    252             "There was a numbering conflict when attempting to add "
    253             f"{obj} to {type(self)}. Conflict was with {self[obj.number]}"
    254         )
    255     )

TypeError: object being appended must be of type: <class 'montepy.surfaces.surface.Surface'>
```

**Version**

 - Version 0.4.1


**Additional context**

Add any other context about the problem here.

ListNode comparison is blocking contains for ListNode
<!--
**Reminders**
1. This is not a place to debug your MCNP models
1. MCNP is export controlled and only its 6.2 and 6.3 user manuals are public. Don't include any information about MCNP which is not in those manuals.
1. Your model may be export controlled, or proprietary. Please change specific numbers (e.g., dimensions, material compositions, etc.) in your minimum working examples. -->

**Describe the bug**

A clear and concise description of what the bug is.

**To Reproduce**

A short code snippet of what you have ran. Please change or remove any specific values or anything that can't be public. For example:

``` python
problem = montepy.read_input("foo.imcnp")
```

**Error Message (if any)**

If an error message was printed please include the entire stacktrace. If it includes any specific values please change or remove them. For example:

``` python
MontePy/montepy/input_parser/input_reader.py:31: in read_input
    problem.parse_input(replace=replace)
MontePy/montepy/mcnp_problem.py:282: in parse_input
    obj = obj_parser(input)
MontePy/montepy/cell.py:100: in __init__
    self._parse_keyword_modifiers()
MontePy/montepy/cell.py:124: in _parse_keyword_modifiers
    input = input_class(in_cell_block=True, key=key, value=value)
MontePy/montepy/data_inputs/fill.py:46: in __init__
    self._parse_cell_input(key, value)
MontePy/montepy/data_inputs/fill.py:115: in _parse_cell_input
    if "(" in data.nodes:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = (shortcut:Shortcuts.REPEAT: deque([(Value, 2.0, padding: (Padding, [' '])), (Value, 2.0, padding: (Padding, [' '])), (...e, 2.0, padding: (Padding, [' '])), (Value, 2.0, padding: (Padding, [' '])), (Value, 2.0, padding: (Padding, [' ']))]))
other = '('

    def __eq__(self, other):
        if not isinstance(other, (type(self), list)):
>           raise TypeError(
                f"ListNode can only be compared to a ListNode or List. {other} given."
            )
E           TypeError: ListNode can only be compared to a ListNode or List. ( given.

MontePy/montepy/input_parser/syntax_node.py:1703: TypeError
```

**MCNP input file snippet**

See: `tests/models/icsbep/HEU-COMP-THERM-021_case_007.imcnp`


**Version**

 - Version 0.4.1


ListNode comparison is blocking contains for ListNode
<!--
**Reminders**
1. This is not a place to debug your MCNP models
1. MCNP is export controlled and only its 6.2 and 6.3 user manuals are public. Don't include any information about MCNP which is not in those manuals.
1. Your model may be export controlled, or proprietary. Please change specific numbers (e.g., dimensions, material compositions, etc.) in your minimum working examples. -->

**Describe the bug**

A clear and concise description of what the bug is.

**To Reproduce**

A short code snippet of what you have ran. Please change or remove any specific values or anything that can't be public. For example:

``` python
problem = montepy.read_input("foo.imcnp")
```

**Error Message (if any)**

If an error message was printed please include the entire stacktrace. If it includes any specific values please change or remove them. For example:

``` python
MontePy/montepy/input_parser/input_reader.py:31: in read_input
    problem.parse_input(replace=replace)
MontePy/montepy/mcnp_problem.py:282: in parse_input
    obj = obj_parser(input)
MontePy/montepy/cell.py:100: in __init__
    self._parse_keyword_modifiers()
MontePy/montepy/cell.py:124: in _parse_keyword_modifiers
    input = input_class(in_cell_block=True, key=key, value=value)
MontePy/montepy/data_inputs/fill.py:46: in __init__
    self._parse_cell_input(key, value)
MontePy/montepy/data_inputs/fill.py:115: in _parse_cell_input
    if "(" in data.nodes:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = (shortcut:Shortcuts.REPEAT: deque([(Value, 2.0, padding: (Padding, [' '])), (Value, 2.0, padding: (Padding, [' '])), (...e, 2.0, padding: (Padding, [' '])), (Value, 2.0, padding: (Padding, [' '])), (Value, 2.0, padding: (Padding, [' ']))]))
other = '('

    def __eq__(self, other):
        if not isinstance(other, (type(self), list)):
>           raise TypeError(
                f"ListNode can only be compared to a ListNode or List. {other} given."
            )
E           TypeError: ListNode can only be compared to a ListNode or List. ( given.

MontePy/montepy/input_parser/syntax_node.py:1703: TypeError
```

**MCNP input file snippet**

See: `tests/models/icsbep/HEU-COMP-THERM-021_case_007.imcnp`


**Version**

 - Version 0.4.1




Discussion / hints:

Fix patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 821d4633..630f96d9 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -10,7 +10,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
 
     steps:
       - uses: actions/checkout@v4
@@ -36,25 +36,25 @@ jobs:
       - run: pip install --user dist/*.tar.gz
       - run: pip install --user . montepy[test]
       - run: pip install --user . montepy[doc]
-        if: ${{ matrix.python-version != '3.8'}}
       - run: pip install --user . montepy[format]
       - run: pip install --user . montepy[build]
       - run: pip install --user . montepy[develop]
-        if: ${{ matrix.python-version != '3.8'}}
       - run: pip freeze
       - name: Upload build artifacts
         uses: actions/upload-artifact@v4.3.1
-        if: ${{ matrix.python-version == '3.9' }}
+        if: ${{ matrix.python-version == '3.12'  && matrix.numpy-version == '2.0' }}
         with: 
            name: build
            path: dist/*
 
   test:
     runs-on: ubuntu-latest
-    permissions: write-all
+    permissions: 
+      actions: write
     strategy:
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
+        numpy-version: ["1.0", "2.0"]
     
     steps:
       - uses: actions/checkout@v4
@@ -62,30 +62,37 @@ jobs:
         uses: actions/setup-python@v5
         with: 
           python-version: ${{ matrix.python-version }}
+      - run: pip install numpy~=${{ matrix.numpy-version }}
+        name: install specific numpy version
       - run: pip install --user . montepy[test]
       - run: pip install --user . montepy[build]
+      - run: pip uninstall -y pytest-profiling
+        name: Uninstall incompatible library
+        if: ${{ matrix.python-version == '3.13' }}
+      - run: pip freeze
       - run: coverage run -m pytest --junitxml=test_report.xml
       - run: coverage report
         if:  ${{ success() || failure() }}
       - run: coverage xml
         if:  ${{ success() || failure() }}
       - name: Upload test report
-        if: ${{ matrix.python-version == '3.9' && (success() || failure() )}}
+        if: ${{ matrix.python-version == '3.12' && matrix.numpy-version == '2.0' && (success() || failure() )}}
         uses: actions/upload-artifact@v4
         with:
           name: test
           path: test_report.xml
       - name: Upload coverage report
-        if: ${{ matrix.python-version == '3.9' && (success() || failure() )}}
+        if: ${{ matrix.python-version == '3.12' && matrix.numpy-version == '2.0' && (success() || failure() )}}
         uses: actions/upload-artifact@v4
         with:
           name: coverage
           path: coverage.xml
       - name: Coveralls GitHub Action
-        if: ${{ matrix.python-version == '3.9' && (success() || failure() )}}
+        if: ${{ matrix.python-version == '3.12' && matrix.numpy-version == '2.0' && (success() || failure() )}}
         uses: coverallsapp/github-action@v2
         with:
           file: coverage.xml
+          github-token: ${{ secrets.github_token }}
 
 
   doc-test:
diff --git a/.gitignore b/.gitignore
index 7cbf2b6e..4168a4c8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,11 +5,15 @@ dist/*
 *egg-info/*
 *.mcnp
 .coverage
+htmlcov/
+htmlcov
+.mutmut-cache
 .idea/*
 *.*.sw*
 *.prof
 doc/build/*
 .coverage
+.hypothesis/
 .idea/
 .ipynb_checkpoints/
 montepy/_version.py
diff --git a/README.md b/README.md
index 290c7861..97836508 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,10 @@ Here a few of the known bugs and limitations:
 * Cannot handle vertical input mode.
 * Does not support editing tallies in a user-friendly way.
 * Does not support editing source definition in a user-friendly way.
+* Cannot parse all valid material definitions. There is a known bug ([#182](https://github.com/idaholab/MontePy/issues/182)) that MontePy can only parse materials where all
+    keyword-value pairs show up after the nuclide definitions. For example:
+   * `M1 1001.80c 1.0 plib=80p` can be parsed.
+   * `M1 plib=80p 1001.80c 1.0` cannot be parsed; despite it being a valid input.
 	
 ## Bugs, Requests and Development
 
diff --git a/benchmark/benchmark_big_model.py b/benchmark/benchmark_big_model.py
index 26af2a9b..d4f3aac6 100644
--- a/benchmark/benchmark_big_model.py
+++ b/benchmark/benchmark_big_model.py
@@ -1,3 +1,4 @@
+import gc
 import montepy
 
 import time
@@ -13,7 +14,10 @@
 stop = time.time()
 
 print(f"Took {stop - start} seconds")
-print(f"Memory usage report: {tracemalloc.get_traced_memory()}")
+print(f"Memory usage report: {tracemalloc.get_traced_memory()[0]/1024/1024} MB")
+del problem
+gc.collect()
+print(f"Memory usage report after GC: {tracemalloc.get_traced_memory()[0]/1024/1024} MB")
 
 if (stop - start) > FAIL_THRESHOLD:
     raise RuntimeError(
diff --git a/doc/source/_static/monty-192.png b/doc/source/_static/monty-192.png
new file mode 120000
index 00000000..aa9b65c3
--- /dev/null
+++ b/doc/source/_static/monty-192.png
@@ -0,0 +1,1 @@
+../../../graphics/monty-192.png
\ No newline at end of file
diff --git a/doc/source/_static/monty-32.ico b/doc/source/_static/monty-32.ico
new file mode 120000
index 00000000..cad0a6bc
--- /dev/null
+++ b/doc/source/_static/monty-32.ico
@@ -0,0 +1,1 @@
+../../../graphics/monty-32.ico
\ No newline at end of file
diff --git a/doc/source/_static/monty-32.png b/doc/source/_static/monty-32.png
new file mode 120000
index 00000000..55cdc09d
--- /dev/null
+++ b/doc/source/_static/monty-32.png
@@ -0,0 +1,1 @@
+../../../graphics/monty-32.png
\ No newline at end of file
diff --git a/doc/source/_static/monty.svg b/doc/source/_static/monty.svg
new file mode 120000
index 00000000..a47274f9
--- /dev/null
+++ b/doc/source/_static/monty.svg
@@ -0,0 +1,1 @@
+../../../graphics/monty.svg
\ No newline at end of file
diff --git a/doc/source/_static/placeholder b/doc/source/_static/placeholder
deleted file mode 100644
index e69de29b..00000000
diff --git a/doc/source/_templates/placeholder b/doc/source/_templates/placeholder
deleted file mode 100644
index e69de29b..00000000
diff --git a/doc/source/_templates/version.html b/doc/source/_templates/version.html
new file mode 100644
index 00000000..697e7527
--- /dev/null
+++ b/doc/source/_templates/version.html
@@ -0,0 +1,1 @@
+{{ version }}
diff --git a/doc/source/bugs.rst b/doc/source/bugs.rst
new file mode 100644
index 00000000..59fa41e9
--- /dev/null
+++ b/doc/source/bugs.rst
@@ -0,0 +1,83 @@
+**************************
+Known Bugs and limitations
+**************************
+
+This page is not meant to document every MCNP feature that is not supported.
+For seeing if a specific feature is supported refer to the API documentation 
+that exists for :mod:`~montepy.data_inputs`, :mod:`~montepy.surfaces`,
+to see if an object exists for the feature you want.
+
+This is meant for documenting limitations that are less obvious,
+and how to work-around them.
+
+Material Definitions Can't be Read Sometimes
+============================================
+
+As detailed in :issue:`182` there are some valid MCNP material definitions,
+that MontePy can't read.
+MontePy expects all key-value data (e.g., ``nlib=80c`` ) to be after all 
+isotope definitions. 
+
+So MontePy can read:
+
+* ``m1 1001.80c 1.0``
+* ``m1 1001 1.0 nlib=80c``
+
+But MontePy can't read:
+
+* ``m1 plib=80c 1001 1.0``.
+
+ 
+Recognizing the Error
+---------------------
+
+If MontePy encounters a material definition that it can't handled the error message
+will look like::
+
+        File ~/mambaforge/lib/python3.12/site-packages/montepy/data_inputs/material.py:32, in Material.__init__(self, input)
+             30 self._thermal_scattering = None
+             31 self._number = self._generate_default_node(int, -1)
+        ---> 32 super().__init__(input)
+             33 if input:
+             34     num = self._input_number
+
+        File ~/mambaforge/lib/python3.12/site-packages/montepy/data_inputs/data_input.py:58, in DataInputAbstract.__init__(self, input, fast_parse)
+             56 self._particles = None
+             57 if not fast_parse:
+        ---> 58     super().__init__(input, self._parser)
+             59     if input:
+             60         self.__split_name(input)
+
+        File ~/mambaforge/lib/python3.12/site-packages/montepy/mcnp_object.py:59, in MCNP_Object.__init__(self, input, parser)
+             55     raise MalformedInputError(
+             56         input, f"Error parsing object of type: {type(self)}: {e.args[0]}"
+             57     )
+             58 if self._tree is None:
+        ---> 59     raise ParsingError(
+             60         input,
+             61         "",
+             62         parser.log.clear_queue(),
+             63     )
+             64 if "parameters" in self._tree:
+             65     self._parameters = self._tree["parameters"]
+
+        ParsingError:     , line 0
+
+            >    0| M1 plib=80p 1001.80c 1.0
+                  |        ^ not expected here.
+        There was an error parsing "=".
+        sly: Syntax error at line 1, token==
+
+
+Workaround
+----------
+
+For the time being this can be fixed by manually editing all offending material
+definitions and moving all key-value pairs to the end of the input.
+
+Long Term Fix
+-------------
+
+Work is currently planned to fix this in release 1.0.0,
+which will improve many aspects of the material interface.
+For more details see: :ref:`migrate 0 1`. 
diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst
index 86568400..2fa0ddc8 100644
--- a/doc/source/changelog.rst
+++ b/doc/source/changelog.rst
@@ -1,5 +1,35 @@
+*****************
 MontePy Changelog
-=================
+*****************
+
+0.5 releases
+============
+
+0.5.0
+--------------
+
+**Features Added**
+
+* Added ``clone`` method to simplify making copies of objects (:issue:`469`).
+
+**Performance Improvement**
+
+* Fixed cyclic memory reference that lead to memory leak in ``copy.deepcopy`` (:issue:`514`).
+* Fixed O(N<sup>2</sup>) operation in how append works for object collections like Cells (:issue:`556`).
+
+**Bug Fixes**
+
+* Fixed bug with parsing an ``EO`` input (:issue:`551`).
+* Fixed a bug raised in an edge case when editing cell geometry, by making the error clearer (:issue:`558`).
+* Fixed bug with having a shortcut in a cell fill (:issue:`552`).
+* Fixed bug where file streams couldn't actually be read (:pull:`553`).
+
+**Support**
+
+* Added support for Python 3.13, and removed support for Python 3.8, and officially added support NumPy 1 & 2 (:pull:`548`).
+
+0.4 releases
+============
 
 0.4.1
 --------------
@@ -39,6 +69,9 @@ MontePy Changelog
 * Fixed bug where parentheses in cell geometry are not properly exported (:pull:`491`).
 
 
+0.3 releases
+=============
+
 0.3.3
 --------------
 
@@ -82,6 +115,9 @@ MontePy Changelog
 * Fixed bug with ``SDEF`` input, and made parser more robust (:issue:`396`).
 
 
+0.2 releases
+============
+
 0.2.10
 ----------------------
 
@@ -196,6 +232,9 @@ MontePy Changelog
 * `montepy.data_cards.data_card.DataCard.class_prefix` was moved to `_class_prefix` as the user usually shouldn't see this. Same goes for `has_classifier` and `has_number`.
 * Most of the data types inside `montepy.input_parser.mcnp_input` were deprecated or changed
 
+0.1 releases
+============
+
 0.1.7
 -----------------
 
diff --git a/doc/source/conf.py b/doc/source/conf.py
index d3af7e05..b6d52d1b 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -24,8 +24,8 @@
 copyright = "2021 – 2024, Battelle Energy Alliance LLC."
 author = "Micah D. Gale (@micahgale), Travis J. Labossiere-Hickman (@tjlaboss)"
 
-
-release = importlib.metadata.version("montepy")
+version = importlib.metadata.version("montepy")
+release = version  # Will be true at website deployment.
 # -- General configuration ---------------------------------------------------
 
 # Add any Sphinx extension module names here, as strings. They can be
@@ -37,11 +37,17 @@
     "sphinx.ext.intersphinx",
     "sphinx.ext.extlinks",
     "sphinx_sitemap",
+    "sphinx_favicon",
 ]
 
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ["_templates"]
-html_favicon = "monty.svg"
+favicons = [
+    "monty.svg",
+    "monty-192.png",
+    "monty-32.png",
+    "monty-32.ico",
+]
 html_logo = "monty.svg"
 
 html_baseurl = "https://www.montepy.org/"
@@ -52,6 +58,9 @@
 # This pattern also affects html_static_path and html_extra_path.
 exclude_patterns = []
 
+# Display the version
+display_version = True
+
 # -- External link configuration ---------------------------------------------
 UM63 = (
     "https://mcnp.lanl.gov/pdf_files/TechReport_2022_LANL_LA-UR-22-30006"
@@ -78,7 +87,18 @@
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
-html_theme = "sphinx_rtd_theme"
+html_theme = "pydata_sphinx_theme"
+html_theme_options = {
+    "navbar_start": ["navbar-logo", "version"],
+    "icon_links": [
+        {
+            "name": "GitHub",
+            "url": "https://github.com/idaholab/MontePy",
+            "icon": "fa-brands fa-square-github",
+            "type": "fontawesome",
+        },
+    ],
+}
 apidoc_module_dir = "../../montepy"
 apidoc_module_first = True
 apidoc_separate_modules = True
diff --git a/doc/source/starting.rst b/doc/source/starting.rst
index 8a268ec3..f6f362d9 100644
--- a/doc/source/starting.rst
+++ b/doc/source/starting.rst
@@ -192,6 +192,8 @@ Information Lost
 
    This will hopefully change soon and read "subfiles" will be kept, and will automatically be written as their own files.
 
+
+
 What a Problem Looks Like
 -------------------------
 
@@ -329,6 +331,16 @@ Oh no! When we made a list of the numbers we broke the link, and the new list wo
 and you can cause issues this way.
 The simple solution is to just access the generators directly; don't try to make copies for your own use.
 
+Cloning Objects
+^^^^^^^^^^^^^^^
+
+In the past the only way to make a copy of a MontePy object was with `copy.deepcopy <https://docs.python.org/3/library/copy.html#copy.deepcopy>`_.
+In MontePy 0.5.0 a better way was introduced: :func:`~montepy.mcnp_object.MCNP_Object.clone`.
+How numbered objects, for instance :class:`~montepy.cell.Cell`, is more complicated.
+If a ``Cell`` or a group of ``Cells`` are cloned their numbers will be to changed to avoid collisions.
+However, if a whole :class:`~montepy.mcnp_problem.MCNP_Problem` is cloned these objects will not have their numbers changed.
+For an example for how to clone a numbered object see :ref:`Cloning a Cell`.
+
 Surfaces
 --------
 
@@ -556,6 +568,61 @@ This will completely redefine the cell's geometry. You can also modify the geome
 
         ((+1000*+1005)*-1010)
 
+.. _Cloning a Cell:
+
+Cloning a Cell
+^^^^^^^^^^^^^^
+When a cell is cloned with :func:`~montepy.cell.Cell.clone` a new number will be assigned.
+If the cell is linked to a problem---either through being added to :class:`~montepy.cells.Cells`, or with :func:`~montepy.cell.Cell.link_to_problem`---
+the next available number in the problem will be used.
+Otherwise the ``starting_number`` will be used unless that is the original cell's number.
+How the number is picked is controlled by ``starting_number`` and ``step``. 
+The new cell will attempt to use ``starting_number`` as its number. 
+If this number is taken ``step`` will be added to it until an available number is found.
+For example:
+
+>>> base_cell = montepy.Cell()
+>>> base_cell.number = 1
+>>> # clone with an available number
+>>> new_cell = base_cell.clone(starting_number=1000)
+>>> new_cell.number
+1000
+>>> # force a number collision
+>>> new_cell = base_cell.clone(starting_number= 1, step =5)
+>>> new_cell.number
+6
+
+Cells can also clone their material, and their dividers. 
+By default this is not done, and only a new ``HalfSpace`` instance is created that points to the same objects.
+This is done so that the geometry definitions of the two cells can be edited without impacting the other cell.
+For a lot of problems this is preferred in order to avoid creating geometry gaps due to not using the same surfaces in geometry definitions.
+For example, if you have a problem read in already:
+
+>>> cell = problem.cells[1]
+>>> cell.material.number
+1
+>>> new_cell = cell.clone()
+>>> #the material didn't change
+>>> new_cell.material is cell.material
+True
+>>> new_cell = cell.clone(clone_material=True)
+>>> new_cell.material.number
+2 
+>>> new_cell.material is cell.material
+False
+
+When children objects (:class:`~montepy.data_inputs.material.Material`, :class:`~montepy.surfaces.surface.Surface`, and :class:`~montepy.cell.Cell`)
+are cloned the numbering behavior is defined by the problem's instance's instance of the respective collection (e.g., :class:`~montepy.materials.Materials`)
+by the properties: :func:`~montepy.numbered_object_collection.NumberedObjectCollection.starting_number` and :func:`~montepy.numbered_object_collection.NumberedObjectCollection.step`.
+For example:
+
+>>> problem.materials.starting_number = 100
+>>> problem.cells[1].material.number
+1
+>>> new_cell = problem.cells[1].clone(clone_material=True)
+>>> new_cell.material.number 
+100
+
 Universes
 ---------
 
diff --git a/doc/source/users.rst b/doc/source/users.rst
index 0e18411d..4b4ffeca 100644
--- a/doc/source/users.rst
+++ b/doc/source/users.rst
@@ -6,6 +6,7 @@ User Guide
    :caption: Contents:
 
    starting
+   bugs
    utilities
    tricks
    faq
diff --git a/graphics/monty-192.png b/graphics/monty-192.png
new file mode 100755
index 00000000..57d54a3b
Binary files /dev/null and b/graphics/monty-192.png differ
diff --git a/graphics/monty-32.ico b/graphics/monty-32.ico
new file mode 100755
index 00000000..3b4ba7d8
Binary files /dev/null and b/graphics/monty-32.ico differ
diff --git a/graphics/monty-32.png b/graphics/monty-32.png
new file mode 100755
index 00000000..3aac43b5
Binary files /dev/null and b/graphics/monty-32.png differ
diff --git a/montepy/cell.py b/montepy/cell.py
index c2600fd0..e5901b01 100644
--- a/montepy/cell.py
+++ b/montepy/cell.py
@@ -1,5 +1,8 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
 import copy
+import itertools
+import numbers
+
 from montepy.cells import Cells
 from montepy.constants import BLANK_SPACE_CONTINUE
 from montepy.data_inputs import importance, fill, lattice_input, universe_input, volume
@@ -15,14 +18,6 @@
 from montepy.surface_collection import Surfaces
 from montepy.universe import Universe
 from montepy.utilities import *
-import numbers
-
-
-def _number_validator(self, number):
-    if number <= 0:
-        raise ValueError("number must be > 0")
-    if self._problem:
-        self._problem.cells.check_number(number)
 
 
 def _link_geometry_to_cell(self, geom):
@@ -311,15 +306,6 @@ def old_number(self):
         """
         pass
 
-    @make_prop_val_node("_number", int, validator=_number_validator)
-    def number(self):
-        """
-        The current cell number that will be written out to a new input.
-
-        :rtype: int
-        """
-        pass
-
     @make_prop_pointer("_material", (Material, type(None)), deletable=True)
     def material(self):
         """
@@ -720,3 +706,110 @@ def cleanup_last_line(ret):
         # check for accidental empty lines from subsequent cell modifiers that didn't print
         ret = "\n".join([l for l in ret.splitlines() if l.strip()])
         return self.wrap_string_for_mcnp(ret, mcnp_version, True)
+
+    def clone(
+        self, clone_material=False, clone_region=False, starting_number=None, step=None
+    ):
+        """
+        Create a new almost independent instance of this cell with a new number.
+
+        This relies mostly on ``copy.deepcopy``.
+        All properties and attributes will be a deep copy unless otherwise requested.
+        The one exception is this will still be internally linked to the original problem.
+        Even if ``clone_region`` is ``True`` the actual region object will be a copy.
+        This means that changes to the new cell's geometry will be independent, but may or may not
+        refer to the original surfaces.
+
+        .. versionadded:: 0.5.0
+
+        :param clone_material: Whether to create a new clone of the material.
+        :type clone_material: bool
+        :param clone_region: Whether to clone the underlying objects (Surfaces, Cells) of this cell's region.
+        :type clone_region: bool
+        :param starting_number: The starting number to request for a new cell number.
+        :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 cell.
+        :rtype: Cell
+        """
+        if not isinstance(clone_material, bool):
+            raise TypeError(
+                f"clone_material must be a boolean. {clone_material} given."
+            )
+        if not isinstance(clone_region, bool):
+            raise TypeError(f"clone_region must be a boolean. {clone_region} given.")
+        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._problem.cells.starting_number if self._problem else 1
+            )
+        if step is None:
+            step = self._problem.cells.step if self._problem else 1
+        # get which properties to copy over
+        keys = set(vars(self))
+        keys.remove("_material")
+        result = Cell.__new__(Cell)
+        if clone_material:
+            if self.material is not None:
+                result._material = self._material.clone()
+            else:
+                result._material = None
+        else:
+            result._material = self._material
+
+        special_keys = {"_surfaces", "_complements"}
+        keys -= special_keys
+        memo = {}
+        for key in keys:
+            attr = getattr(self, key)
+            setattr(result, key, copy.deepcopy(attr, memo))
+        if clone_region:
+            region_change_map = {}
+            # 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)
+        if self._problem:
+            result.number = self._problem.cells.request_number(starting_number, step)
+            self._problem.cells.append(result)
+        else:
+            for number in itertools.count(starting_number, step):
+                result.number = number
+                if number != self.number:
+                    break
+        return result
diff --git a/montepy/cells.py b/montepy/cells.py
index 31a5f4a9..d1184d4d 100644
--- a/montepy/cells.py
+++ b/montepy/cells.py
@@ -89,6 +89,19 @@ def allow_mcnp_volume_calc(self, value):
             raise TypeError("allow_mcnp_volume_calc must be set to a bool")
         self._volume.is_mcnp_calculated = value
 
+    def link_to_problem(self, problem):
+        """Links the input to the parent problem for this input.
+
+        This is done so that inputs can find links to other objects.
+
+        :param problem: The problem to link this input to.
+        :type problem: MCNP_Problem
+        """
+        super().link_to_problem(problem)
+        inputs_to_property = montepy.Cell._INPUTS_TO_PROPERTY
+        for attr, _ in inputs_to_property.values():
+            getattr(self, attr).link_to_problem(problem)
+
     def update_pointers(
         self, cells, materials, surfaces, data_inputs, problem, check_input=False
     ):
diff --git a/montepy/data_inputs/cell_modifier.py b/montepy/data_inputs/cell_modifier.py
index d746f6cb..27e18dd9 100644
--- a/montepy/data_inputs/cell_modifier.py
+++ b/montepy/data_inputs/cell_modifier.py
@@ -101,7 +101,7 @@ def merge(self, other):
 
     def link_to_problem(self, problem):
         super().link_to_problem(problem)
-        if self.set_in_cell_block:
+        if problem and self.set_in_cell_block:
             self._problem.print_in_data_block[self._class_prefix()] = False
 
     @abstractmethod
diff --git a/montepy/data_inputs/fill.py b/montepy/data_inputs/fill.py
index 82e6d5d6..1801a70b 100644
--- a/montepy/data_inputs/fill.py
+++ b/montepy/data_inputs/fill.py
@@ -112,7 +112,9 @@ def get_universe(value):
                     )
 
         data = value["data"]
-        if "(" in data.nodes:
+        if not isinstance(data, syntax_node.ListNode):
+            data = data.nodes
+        if "(" in data:
             get_universe(value)
             trans_data = value["data"][
                 list(value["data"]).index("(") + 1 : list(value["data"]).index(")") - 1
diff --git a/montepy/data_inputs/material.py b/montepy/data_inputs/material.py
index fab0dbfa..b019ff36 100644
--- a/montepy/data_inputs/material.py
+++ b/montepy/data_inputs/material.py
@@ -15,17 +15,15 @@
 import warnings
 
 
-def _number_validator(self, number):
-    if number <= 0:
-        raise ValueError("number must be > 0")
-    if self._problem:
-        self._problem.materials.check_number(number)
-
-
 class Material(data_input.DataInputAbstract, Numbered_MCNP_Object):
     """
     A class to represent an MCNP material.
 
+    .. note::
+
+        There is a known bug (:issue:`182`) that valid MCNP material definitions cannot be parsed.
+
+
     :param input: the input card that contains the data
     :type input: Input
     """
@@ -35,6 +33,7 @@ class Material(data_input.DataInputAbstract, Numbered_MCNP_Object):
     def __init__(self, input=None):
         self._material_components = {}
         self._thermal_scattering = None
+        self._is_atom_fraction = True
         self._number = self._generate_default_node(int, -1)
         super().__init__(input)
         if input:
@@ -84,15 +83,6 @@ def old_number(self):
         """
         pass
 
-    @make_prop_val_node("_number", int, validator=_number_validator)
-    def number(self):
-        """
-        The number to use to identify the material by
-
-        :rtype: int
-        """
-        pass
-
     @property
     def is_atom_fraction(self):
         """
diff --git a/montepy/data_inputs/transform.py b/montepy/data_inputs/transform.py
index 4628f088..d4917642 100644
--- a/montepy/data_inputs/transform.py
+++ b/montepy/data_inputs/transform.py
@@ -9,11 +9,6 @@
 import re
 
 
-def _enforce_number(self, val):
-    if val <= 0:
-        raise ValueError(f"Transform number must be > 0. {val} given.")
-
-
 class Transform(data_input.DataInputAbstract, Numbered_MCNP_Object):
     """
     Input to represent a transform input (TR).
@@ -109,16 +104,6 @@ def is_in_degrees(self):
         """
         pass
 
-    @make_prop_val_node("_number", (int, float), int, _enforce_number)
-    def number(self):
-        """
-        The transform number for this transform
-
-        :rtype: int
-
-        """
-        pass
-
     @make_prop_val_node("_old_number")
     def old_number(self):
         """
diff --git a/montepy/input_parser/input_file.py b/montepy/input_parser/input_file.py
index 4e28078d..6184dbe0 100644
--- a/montepy/input_parser/input_file.py
+++ b/montepy/input_parser/input_file.py
@@ -31,6 +31,7 @@ def __init__(self, path, parent_file=None, overwrite=False):
         self._overwrite = overwrite
         self._mode = None
         self._fh = None
+        self._is_stream = False
 
     @classmethod
     def from_open_stream(cls, fh):
@@ -43,6 +44,7 @@ def from_open_stream(cls, fh):
         name = getattr(fh, "name", fh.__class__.__name__)
         inpfile = cls(path=name)
         inpfile._fh = fh
+        inpfile._is_stream = True
         return inpfile
 
     @make_prop_pointer("_path")
@@ -56,7 +58,11 @@ def path(self):
 
     @property
     def name(self):
-        return self.path
+        return self._path
+
+    @property
+    def is_stream(self):
+        return self._is_stream
 
     @make_prop_pointer("_parent_file")
     def parent_file(self):
diff --git a/montepy/input_parser/input_syntax_reader.py b/montepy/input_parser/input_syntax_reader.py
index ac202831..86a7a129 100644
--- a/montepy/input_parser/input_syntax_reader.py
+++ b/montepy/input_parser/input_syntax_reader.py
@@ -38,7 +38,11 @@ def read_input_syntax(input_file, mcnp_version=DEFAULT_VERSION, replace=True):
     """
     global reading_queue
     reading_queue = deque()
-    with input_file.open("r", replace=replace) as fh:
+    if input_file.is_stream:
+        context = input_file
+    else:
+        context = input_file.open("r", replace=replace)
+    with context as fh:
         yield from read_front_matters(fh, mcnp_version)
         yield from read_data(fh, mcnp_version)
 
diff --git a/montepy/input_parser/parser_base.py b/montepy/input_parser/parser_base.py
index 25413f55..ecf313ab 100644
--- a/montepy/input_parser/parser_base.py
+++ b/montepy/input_parser/parser_base.py
@@ -458,6 +458,7 @@ def data_prefix(self, p):
         "modifier data_prefix",
         "data_prefix",
         "classifier NUMBER",
+        "classifier NULL",
         "classifier particle_type",
     )
     def classifier(self, p):
@@ -478,8 +479,12 @@ def classifier(self, p):
             classifier.modifier = syntax_node.ValueNode(p.modifier, str)
         if hasattr(p, "data_prefix"):
             classifier.prefix = p.data_prefix
-        if hasattr(p, "NUMBER"):
-            classifier.number = syntax_node.ValueNode(p.NUMBER, int)
+        if hasattr(p, "NUMBER") or hasattr(p, "NULL"):
+            if hasattr(p, "NUMBER"):
+                num = p.NUMBER
+            else:
+                num = p.NULL
+            classifier.number = syntax_node.ValueNode(num, int)
         if hasattr(p, "particle_type"):
             classifier.particles = p.particle_type
         return classifier
diff --git a/montepy/input_parser/syntax_node.py b/montepy/input_parser/syntax_node.py
index ae9743b4..f387aea7 100644
--- a/montepy/input_parser/syntax_node.py
+++ b/montepy/input_parser/syntax_node.py
@@ -1646,6 +1646,12 @@ def __iter__(self):
             else:
                 yield node
 
+    def __contains__(self, value):
+        for node in self:
+            if node == value:
+                return True
+        return False
+
     def __getitem__(self, indx):
         if isinstance(indx, slice):
             return self.__get_slice(indx)
diff --git a/montepy/mcnp_object.py b/montepy/mcnp_object.py
index 975c8323..95cb51d9 100644
--- a/montepy/mcnp_object.py
+++ b/montepy/mcnp_object.py
@@ -1,5 +1,6 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
 from abc import ABC, abstractmethod
+import copy
 import itertools as it
 from montepy.errors import *
 from montepy.constants import (
@@ -18,6 +19,7 @@
 import numpy as np
 import textwrap
 import warnings
+import weakref
 
 
 class MCNP_Object(ABC):
@@ -35,7 +37,7 @@ class MCNP_Object(ABC):
     """
 
     def __init__(self, input, parser):
-        self._problem = None
+        self._problem_ref = None
         self._parameters = ParametersNode()
         self._input = None
         if input:
@@ -245,9 +247,25 @@ def link_to_problem(self, problem):
         :param problem: The problem to link this input to.
         :type problem: MCNP_Problem
         """
-        if not isinstance(problem, montepy.mcnp_problem.MCNP_Problem):
+        if not isinstance(problem, (montepy.mcnp_problem.MCNP_Problem, type(None))):
             raise TypeError("problem must be an MCNP_Problem")
-        self._problem = problem
+        if problem is None:
+            self._problem_ref = None
+        else:
+            self._problem_ref = weakref.ref(problem)
+
+    @property
+    def _problem(self):
+        if self._problem_ref is not None:
+            return self._problem_ref()
+        return None
+
+    @_problem.setter
+    def _problem(self, problem):
+        if problem is None:
+            self._problem_ref = None
+            return
+        self.link_to_problem(problem)
 
     @property
     def trailing_comment(self):
@@ -429,3 +447,23 @@ def allowed_keywords(self):  # pragma: no cover
             stacklevel=2,
         )
         return set()
+
+    def __getstate__(self):
+        state = self.__dict__.copy()
+        weakref_key = "_problem_ref"
+        if weakref_key in state:
+            del state[weakref_key]
+        return state
+
+    def __setstate__(self, crunchy_data):
+        crunchy_data["_problem_ref"] = None
+        self.__dict__.update(crunchy_data)
+
+    def clone(self):
+        """
+        Create a new independent instance of this object.
+
+        :returns: a new instance identical to this object.
+        :rtype: type(self)
+        """
+        return copy.deepcopy(self)
diff --git a/montepy/mcnp_problem.py b/montepy/mcnp_problem.py
index a284064d..24585584 100644
--- a/montepy/mcnp_problem.py
+++ b/montepy/mcnp_problem.py
@@ -1,22 +1,27 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
-import os
+import copy
 from enum import Enum
 import itertools
+import os
+import warnings
+
 from montepy.data_inputs import mode, transform
 from montepy._cell_data_control import CellDataPrintController
 from montepy.cell import Cell
 from montepy.cells import Cells
 from montepy.errors import *
 from montepy.constants import DEFAULT_VERSION
-from montepy.materials import Materials
-from montepy.surfaces import surface_builder
+from montepy.materials import Material, Materials
+from montepy.surfaces import surface, surface_builder
 from montepy.surface_collection import Surfaces
-from montepy.data_inputs import Material, parse_data
+
+# weird way to avoid circular imports
+from montepy.data_inputs import parse_data
 from montepy.input_parser import input_syntax_reader, block_type, mcnp_input
 from montepy.input_parser.input_file import MCNP_InputFile
 from montepy.universes import Universes
 from montepy.transforms import Transforms
-import warnings
+import montepy
 
 
 class MCNP_Problem:
@@ -30,6 +35,14 @@ class MCNP_Problem:
     :type destination: io.TextIOBase, str, os.PathLike
     """
 
+    _NUMBERED_OBJ_MAP = {
+        Cell: Cells,
+        surface.Surface: Surfaces,
+        Material: Materials,
+        transform.Transform: Transforms,
+        montepy.universe.Universe: Universes,
+    }
+
     def __init__(self, destination):
         if hasattr(destination, "read") and callable(getattr(destination, "read")):
             self._input_file = MCNP_InputFile.from_open_stream(destination)
@@ -37,17 +50,24 @@ def __init__(self, destination):
             self._input_file = MCNP_InputFile(destination)
         self._title = None
         self._message = None
+        self.__unpickled = False
         self._print_in_data_block = CellDataPrintController()
         self._original_inputs = []
-        self._cells = Cells(problem=self)
-        self._surfaces = Surfaces(problem=self)
-        self._universes = Universes(problem=self)
-        self._transforms = Transforms(problem=self)
+        for collect_type in self._NUMBERED_OBJ_MAP.values():
+            attr_name = f"_{collect_type.__name__.lower()}"
+            setattr(self, attr_name, collect_type(problem=self))
         self._data_inputs = []
-        self._materials = Materials(problem=self)
         self._mcnp_version = DEFAULT_VERSION
         self._mode = mode.Mode()
 
+    def __setstate__(self, nom_nom):
+        self.__dict__.update(nom_nom)
+        self.__unpickled = True
+
+    @staticmethod
+    def __get_collect_attr_name(collect_type):
+        return f"_{collect_type.__name__.lower()}"
+
     @property
     def original_inputs(self):
         """
@@ -64,6 +84,57 @@ def original_inputs(self):
         """
         return self._original_inputs
 
+    def __relink_objs(self):
+        if self.__unpickled:
+            for collection in set(self._NUMBERED_OBJ_MAP.values()) | {"_data_inputs"}:
+                if not isinstance(collection, str):
+                    collection = self.__get_collect_attr_name(collection)
+                collection = getattr(self, collection)
+                if isinstance(
+                    collection,
+                    montepy.numbered_object_collection.NumberedObjectCollection,
+                ):
+                    collection.link_to_problem(self)
+                else:
+                    for obj in collection:
+                        obj.link_to_problem(self)
+            self.__unpickled = False
+
+    def __unlink_objs(self):
+        for collection in set(self._NUMBERED_OBJ_MAP.values()) | {"_data_inputs"}:
+            if not isinstance(collection, str):
+                collection = self.__get_collect_attr_name(collection)
+            collection = getattr(self, collection)
+            if isinstance(
+                collection,
+                montepy.numbered_object_collection.NumberedObjectCollection,
+            ):
+                collection.link_to_problem(None)
+            else:
+                for obj in collection:
+                    obj.link_to_problem(None)
+        self.__unpickled = True
+        self.__relink_objs()
+
+    def __deepcopy__(self, memo):
+        cls = type(self)
+        result = cls.__new__(cls)
+        memo[id(self)] = result
+        for k, v in self.__dict__.items():
+            setattr(result, k, copy.deepcopy(v, memo))
+        result.__unlink_objs()
+        return result
+
+    def clone(self):
+        """
+        Creates a complete independent copy of this problem.
+
+        .. versionadded:: 0.5.0
+
+        :rtype: MCNP_Problem
+        """
+        return copy.deepcopy(self)
+
     @property
     def cells(self):
         """
@@ -72,6 +143,7 @@ def cells(self):
         :return: a collection of the Cell objects, ordered by the order they were in the input file.
         :rtype: Cells
         """
+        self.__relink_objs()
         return self._cells
 
     @cells.setter
@@ -141,8 +213,18 @@ def surfaces(self):
         :return: a collection of the Surface objects, ordered by the order they were in the input file.
         :rtype: Surfaces
         """
+        self.__relink_objs()
         return self._surfaces
 
+    @surfaces.setter
+    def surfaces(self, surfs):
+        if not isinstance(surfs, (list, Surfaces)):
+            raise TypeError("Surfaces must be of type list or Surfaces")
+        if isinstance(surfs, list):
+            surfs = Surfaces(surfs)
+        surfs.link_to_problem(self)
+        self._surfaces = surfs
+
     @property
     def materials(self):
         """
@@ -151,6 +233,7 @@ def materials(self):
         :return: a colection of the Material objects, ordered by the order they were in the input file.
         :rtype: Materials
         """
+        self.__relink_objs()
         return self._materials
 
     @materials.setter
@@ -159,6 +242,7 @@ def materials(self, mats):
             raise TypeError("materials must be of type list and Materials")
         if isinstance(mats, list):
             mats = Materials(mats)
+        mats.link_to_problem(self)
         self._materials = mats
 
     @property
@@ -183,6 +267,7 @@ def data_inputs(self):
         :return: a list of the :class:`~montepy.data_cards.data_card.DataCardAbstract` objects, ordered by the order they were in the input file.
         :rtype: list
         """
+        self.__relink_objs()
         return self._data_inputs
 
     @property
@@ -298,7 +383,7 @@ def parse_input(self, check_input=False, replace=True):
                         if isinstance(obj, Material):
                             self._materials.append(obj, False)
                         if isinstance(obj, transform.Transform):
-                            self._transforms.append(obj)
+                            self._transforms.append(obj, False)
                     if trailing_comment is not None and last_obj is not None:
                         obj._grab_beginning_comment(trailing_comment)
                         last_obj._delete_trailing_comment()
@@ -400,9 +485,9 @@ def add_cell_children_to_problem(self):
         surfaces = sorted(surfaces)
         materials = sorted(materials)
         transforms = sorted(transforms)
-        self._surfaces = Surfaces(surfaces)
-        self._materials = Materials(materials)
-        self._transforms = Transforms(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))
 
     def write_problem(self, destination, overwrite=False):
@@ -537,9 +622,8 @@ def __repr__(self):
         if self.message:
             ret += str(self._message) + "\n"
         ret += str(self._title) + "\n"
-        for cell in self._cells:
-            ret += str(cell) + "\n"
-        for input in self._data_inputs:
-            if not isinstance(input, Material):
-                ret += str(input) + "\n"
+        for collection in [self.cells, self.surfaces, self.data_inputs]:
+            for obj in collection:
+                ret += f"{obj}\n"
+            ret += "\n"
         return ret
diff --git a/montepy/numbered_mcnp_object.py b/montepy/numbered_mcnp_object.py
index dbc1035d..9d79ee6c 100644
--- a/montepy/numbered_mcnp_object.py
+++ b/montepy/numbered_mcnp_object.py
@@ -1,11 +1,37 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
 from abc import abstractmethod
+import copy
+import itertools
+from montepy.errors import NumberConflictError
 from montepy.mcnp_object import MCNP_Object
+import montepy
+from montepy.utilities import *
+
+
+def _number_validator(self, number):
+    if number < 0:
+        raise ValueError("number must be >= 0")
+    if self._problem:
+        obj_map = montepy.MCNP_Problem._NUMBERED_OBJ_MAP
+        try:
+            collection_type = obj_map[type(self)]
+        except KeyError as e:
+            found = False
+            for obj_class in obj_map:
+                if isinstance(self, obj_class):
+                    collection_type = obj_map[obj_class]
+                    found = True
+                    break
+            if not found:
+                raise e
+        collection = getattr(self._problem, collection_type.__name__.lower())
+        collection.check_number(number)
+        collection._update_number(self.number, number, self)
 
 
 class Numbered_MCNP_Object(MCNP_Object):
-    @property
-    @abstractmethod
+
+    @make_prop_val_node("_number", int, validator=_number_validator)
     def number(self):
         """
         The current number of the object that will be written out to a new input.
@@ -23,3 +49,59 @@ def old_number(self):
         :rtype: int
         """
         pass
+
+    def clone(self, starting_number=None, step=None):
+        """
+        Create a new independent instance of this object with a new number.
+
+        This relies mostly on ``copy.deepcopy``.
+
+        .. note ::
+            If starting_number, or step are not specified
+            :func:`~montepy.numbered_object_collection.NumberedObjectCollection.starting_number`,
+            and :func:`~montepy.numbered_object_collection.NumberedObjectCollection.step` are used as default values,
+            if this object is tied to a problem.
+            For instance a ``Material`` will use ``problem.materials`` default information.
+            Otherwise ``1`` will be used as default values
+
+        .. versionadded:: 0.5.0
+
+        :param starting_number: The starting number to request for a new object number.
+        :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.")
+        ret = copy.deepcopy(self)
+        if self._problem:
+            ret.link_to_problem(self._problem)
+            collection_type = montepy.MCNP_Problem._NUMBERED_OBJ_MAP[type(self)]
+            collection = getattr(self._problem, collection_type.__name__.lower())
+            if starting_number is None:
+                starting_number = collection.starting_number
+            if step is None:
+                step = collection.step
+            ret.number = collection.request_number(starting_number, step)
+            collection.append(ret)
+            return ret
+        if starting_number is None:
+            starting_number = 1
+        if step is None:
+            step = 1
+        for number in itertools.count(starting_number, step):
+            # only reached if not tied to a problem
+            ret.number = number
+            if number != self.number:
+                return ret
diff --git a/montepy/numbered_object_collection.py b/montepy/numbered_object_collection.py
index bbbd49d3..abe74394 100644
--- a/montepy/numbered_object_collection.py
+++ b/montepy/numbered_object_collection.py
@@ -1,9 +1,17 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
 from abc import ABC, abstractmethod
 import typing
+import weakref
+
 import montepy
 from montepy.numbered_mcnp_object import Numbered_MCNP_Object
 from montepy.errors import *
+from montepy.utilities import *
+
+
+def _enforce_positive(self, num):
+    if num <= 0:
+        raise ValueError(f"Value must be greater than 0. {num} given.")
 
 
 class NumberedObjectCollection(ABC):
@@ -41,7 +49,11 @@ def __init__(self, obj_class, objects=None, problem=None):
         assert issubclass(obj_class, Numbered_MCNP_Object)
         self._obj_class = obj_class
         self._objects = []
-        self._problem = problem
+        self._start_num = 1
+        self._step = 1
+        self._problem_ref = None
+        if problem is not None:
+            self._problem_ref = weakref.ref(problem)
         if objects:
             if not isinstance(objects, list):
                 raise TypeError("NumberedObjectCollection must be built from a list")
@@ -68,9 +80,31 @@ def link_to_problem(self, problem):
         :param problem: The problem to link this card to.
         :type problem: MCNP_Problem
         """
-        if not isinstance(problem, montepy.mcnp_problem.MCNP_Problem):
+        if not isinstance(problem, (montepy.mcnp_problem.MCNP_Problem, type(None))):
             raise TypeError("problem must be an MCNP_Problem")
-        self._problem = problem
+        if problem is None:
+            self._problem_ref = None
+        else:
+            self._problem_ref = weakref.ref(problem)
+        for obj in self:
+            obj.link_to_problem(problem)
+
+    @property
+    def _problem(self):
+        if self._problem_ref is not None:
+            return self._problem_ref()
+        return None
+
+    def __getstate__(self):
+        state = self.__dict__.copy()
+        weakref_key = "_problem_ref"
+        if weakref_key in state:
+            del state[weakref_key]
+        return state
+
+    def __setstate__(self, crunchy_data):
+        crunchy_data["_problem_ref"] = None
+        self.__dict__.update(crunchy_data)
 
     @property
     def numbers(self):
@@ -79,7 +113,6 @@ def numbers(self):
 
         :rtype: generator
         """
-        self.__num_cache
         for obj in self._objects:
             # update cache every time we go through all objects
             self.__num_cache[obj.number] = obj
@@ -94,11 +127,36 @@ def check_number(self, number):
         """
         if not isinstance(number, int):
             raise TypeError("The number must be an int")
-        if number in self.numbers:
+        conflict = False
+        # only can trust cache if being
+        if self._problem:
+            if number in self.__num_cache:
+                conflict = True
+        else:
+            if number in self.numbers:
+                conflict = True
+        if conflict:
             raise NumberConflictError(
                 f"Number {number} is already in use for the collection: {type(self)} by {self[number]}"
             )
 
+    def _update_number(self, old_num, new_num, obj):
+        """
+        Updates the number associated with a specific object in the internal cache.
+
+        :param old_num: the previous number the object had.
+        :type old_num: int
+        :param new_num: the number that is being set to.
+        :type new_num: int
+        :param obj: the object being updated.
+        :type obj: self._obj_class
+        """
+        # don't update numbers you don't own
+        if self.__num_cache.get(old_num, None) is not obj:
+            return
+        self.__num_cache.pop(old_num, None)
+        self.__num_cache[new_num] = obj
+
     @property
     def objects(self):
         """
@@ -143,22 +201,25 @@ def extend(self, other_list):
         """
         if not isinstance(other_list, (list, type(self))):
             raise TypeError("The extending list must be a list")
+        if self._problem:
+            nums = set(self.__num_cache)
+        else:
+            nums = set(self.numbers)
         for obj in other_list:
             if not isinstance(obj, self._obj_class):
                 raise TypeError(
                     "The object in the list {obj} is not of type: {self._obj_class}"
                 )
-            if obj.number in self.numbers:
+            if obj.number in nums:
                 raise NumberConflictError(
                     (
                         f"When adding to {type(self)} there was a number collision due to "
                         f"adding {obj} which conflicts with {self[obj.number]}"
                     )
                 )
-            # if this number is a ghost; remove it.
-            else:
-                self.__num_cache.pop(obj.number, None)
+            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)
@@ -173,6 +234,69 @@ def remove(self, delete):
         self.__num_cache.pop(delete.number, None)
         self._objects.remove(delete)
 
+    def clone(self, 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
+
+        :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 self:
+            new_obj = obj.clone(starting_number, step)
+            starting_number = new_obj.number
+            objs.append(new_obj)
+            starting_number = new_obj.number + step
+        return type(self)(objs)
+
+    @make_prop_pointer("_start_num", int, validator=_enforce_positive)
+    def starting_number(self):
+        """
+        The starting number to use when an object is cloned.
+
+        :returns: the starting number
+        :rtype: int
+        """
+        pass
+
+    @make_prop_pointer("_step", int, validator=_enforce_positive)
+    def step(self):
+        """
+        The step size to use to find a valid number during cloning.
+
+        :returns: the step size
+        :rtype: int
+        """
+        pass
+
     def __iter__(self):
         self._iter = self._objects.__iter__()
         return self._iter
@@ -198,15 +322,8 @@ def append(self, obj):
         """
         if not isinstance(obj, self._obj_class):
             raise TypeError(f"object being appended must be of type: {self._obj_class}")
-        if obj.number in self.numbers:
-            raise NumberConflictError(
-                (
-                    "There was a numbering conflict when attempting to add "
-                    f"{obj} to {type(self)}. Conflict was with {self[obj.number]}"
-                )
-            )
-        else:
-            self.__num_cache[obj.number] = obj
+        self.check_number(obj.number)
+        self.__num_cache[obj.number] = obj
         self._objects.append(obj)
         if self._problem:
             obj.link_to_problem(self._problem)
@@ -241,13 +358,20 @@ def append_renumber(self, obj, step=1):
 
         return number
 
-    def request_number(self, start_num=1, step=1):
+    def request_number(self, start_num=None, step=None):
         """Requests a new available number.
 
         This method does not "reserve" this number. Objects
         should be immediately added to avoid possible collisions
         caused by shifting numbers of other objects in the collection.
 
+        .. note ::
+            If starting_number, or step are not specified :func:`starting_number`,
+            and :func:`step` are used as default values.
+
+        .. versionchanged:: 0.5.0
+            In 0.5.0 the default values were changed to reference :func:`starting_number` and :func:`step`.
+
         :param start_num: the starting number to check.
         :type start_num: int
         :param step: the increment to jump by to find new numbers.
@@ -255,13 +379,21 @@ def request_number(self, start_num=1, step=1):
         :returns: an available number
         :rtype: int
         """
-        if not isinstance(start_num, int):
+        if not isinstance(start_num, (int, type(None))):
             raise TypeError("start_num must be an int")
-        if not isinstance(step, int):
+        if not isinstance(step, (int, type(None))):
             raise TypeError("step must be an int")
+        if start_num is None:
+            start_num = self.starting_number
+        if step is None:
+            step = self.step
         number = start_num
-        while number in self.numbers:
-            number += step
+        while True:
+            try:
+                self.check_number(number)
+                break
+            except NumberConflictError:
+                number += step
         return number
 
     def next_number(self, step=1):
@@ -342,29 +474,7 @@ def __len__(self):
         return len(self._objects)
 
     def __iadd__(self, other):
-        if not isinstance(other, (type(self), list)):
-            raise TypeError(f"Appended item must be a list or of type {type(self)}")
-        for obj in other:
-            if not isinstance(obj, self._obj_class):
-                raise TypeError(
-                    f"Appended object {obj} must be of type: {self._obj_class}"
-                )
-        if isinstance(other, type(self)):
-            other_list = other.objects
-        else:
-            other_list = other
-        for obj in other_list:
-            if obj.number in self.numbers:
-                raise NumberConflictError(
-                    (
-                        "There was a numbering conflict when attempting to add "
-                        f"{obj} to {type(self)}. Conflict was with {self[obj.number]}"
-                    )
-                )
-            else:
-                self.__num_cache[obj.number] = obj
-        for obj in other_list:
-            self.append(obj)
+        self.extend(other)
         return self
 
     def __contains__(self, other):
@@ -427,7 +537,10 @@ class NumberedDataObjectCollection(NumberedObjectCollection):
     def __init__(self, obj_class, objects=None, problem=None):
         self._last_index = None
         if problem and objects:
-            self._last_index = problem.data_inputs.index(objects[-1])
+            try:
+                self._last_index = problem.data_inputs.index(objects[-1])
+            except ValueError:
+                pass
         super().__init__(obj_class, objects, problem)
 
     def append(self, obj, insert_in_data=True):
diff --git a/montepy/surfaces/half_space.py b/montepy/surfaces/half_space.py
index b1271632..358eecaa 100644
--- a/montepy/surfaces/half_space.py
+++ b/montepy/surfaces/half_space.py
@@ -191,6 +191,14 @@ def _add_new_children_to_cell(self, other):
             (cells, surfaces), (self._cell.complements, self._cell.surfaces)
         ):
             for item in container:
+                if not isinstance(
+                    item, (montepy.surfaces.surface.Surface, montepy.Cell)
+                ):
+                    raise IllegalState(
+                        f"The geometry was not fully initialized, and cannot be changed. "
+                        f"The offending cell is {self._cell}. "
+                        f"Run cell.update_pointers."
+                    )
                 if item not in parent:
                     parent.append(item)
 
@@ -200,7 +208,7 @@ def remove_duplicate_surfaces(self, deleting_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`.
 
-        :param deleting_dict: a dict of the surfaces to delete.
+        :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
         """
         _, surfaces = self._get_leaf_objects()
diff --git a/montepy/surfaces/surface.py b/montepy/surfaces/surface.py
index fa201928..9612b750 100644
--- a/montepy/surfaces/surface.py
+++ b/montepy/surfaces/surface.py
@@ -11,11 +11,6 @@
 import re
 
 
-def _enforce_numbers(self, value):
-    if value <= 0:
-        raise ValueError(f"The number be greater than 0; {value} given.")
-
-
 class Surface(Numbered_MCNP_Object):
     """
     Object to hold a single MCNP surface
@@ -194,15 +189,6 @@ def old_number(self):
         """
         pass
 
-    @make_prop_val_node("_number", int, validator=_enforce_numbers)
-    def number(self):
-        """
-        The surface number to use.
-
-        :rtype: int
-        """
-        pass
-
     @property
     def cells(self):
         """
@@ -286,21 +272,8 @@ def __lt__(self, other):
         return self.number < other.number
 
     def __eq__(self, other):
-        return (
-            self.number == other.number
-            and self.surface_type == other.surface_type
-            and self.is_reflecting == other.is_reflecting
-            and self.is_white_boundary == other.is_white_boundary
-            and self.surface_constants == other.surface_constants
-        )
-
-    def __ne__(self, other):
-        return not self == other
-
-    def __hash__(self):
-        return hash((self.number, str(self.surface_type)))
-
-    def __eq__(self, other):
+        if not isinstance(other, type(self)):
+            return False
         return (
             self.number == other.number
             and self.surface_type == other.surface_type
diff --git a/montepy/transforms.py b/montepy/transforms.py
index bd37aa47..fc5f26fd 100644
--- a/montepy/transforms.py
+++ b/montepy/transforms.py
@@ -1,9 +1,9 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
-from montepy.numbered_object_collection import NumberedObjectCollection
+from montepy.numbered_object_collection import NumberedDataObjectCollection
 from montepy.data_inputs.transform import Transform
 
 
-class Transforms(NumberedObjectCollection):
+class Transforms(NumberedDataObjectCollection):
     """
     A container of multiple :class:`~montepy.data_inputs.transform.Transform` instances.
     """
diff --git a/montepy/universe.py b/montepy/universe.py
index 0e699c09..68064303 100644
--- a/montepy/universe.py
+++ b/montepy/universe.py
@@ -21,7 +21,7 @@ def __init__(self, number):
             raise TypeError("number must be int")
         if number < 0:
             raise ValueError(f"Universe number must be ≥ 0. {number} given.")
-        self._number = number
+        self._number = montepy.input_parser.syntax_node.ValueNode(number, int)
 
         class Parser:
             def parse(self, token_gen, input):
@@ -62,26 +62,6 @@ def claim(self, cells):
         for cell in cells:
             cell.universe = self
 
-    @property
-    def number(self):
-        """
-        The number for this Universe.
-
-        Universe 0 is protected, and a Universe cannot be set 0,
-        if it is not already Universe 0.
-        """
-        return self._number
-
-    @number.setter
-    def number(self, number):
-        if not isinstance(number, int):
-            raise TypeError("number must be int")
-        if number <= 0:
-            raise ValueError("Universe number must be > 0")
-        if self._problem:
-            self._problem.universes.check_number(number)
-        self._number = number
-
     @property
     def old_number(self):
         """
diff --git a/pyproject.toml b/pyproject.toml
index e6748755..baab33d5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ description = "A library for reading, editing, and writing MCNP input files"
 
 # gitlab limits the readme to 4,000 chars.
 readme = "README.md"
-requires-python = ">=3.8"
+requires-python = ">=3.9"
 maintainers = [
 	{name = "Micah Gale", email = "mgale@montepy.org"}
 ]
@@ -18,7 +18,6 @@ keywords = ["MCNP", "neutronics", "imcnp", "input file", "monte carlo", "radiati
 license = {file="LICENSE"}
 classifiers = [
        "Development Status :: 4 - Beta",
-       "Programming Language :: Python :: 3 :: Only",
        "Intended Audience :: Science/Research",
        "Intended Audience :: Developers",
        "License :: OSI Approved :: MIT License",
@@ -27,7 +26,12 @@ classifiers = [
        "Topic :: Scientific/Engineering :: Physics",
        "Topic :: Scientific/Engineering",
        "Topic :: Scientific/Engineering :: Human Machine Interfaces",
-
+       "Programming Language :: Python :: 3 :: Only",
+       "Programming Language :: Python :: 3.9",
+       "Programming Language :: Python :: 3.10",
+       "Programming Language :: Python :: 3.11",
+       "Programming Language :: Python :: 3.12",
+       "Programming Language :: Python :: 3.13"
 ]
 dependencies = [
 	"numpy>=1.18",
@@ -37,7 +41,9 @@ dependencies = [
 [project.optional-dependencies]
 test = [
 	"coverage[toml]>=6.3.2", 
+	"hypothesis",
 	"pytest", 
+	"hypothesis",
 	"pytest-profiling",
 	"montepy[build]",
 ]
@@ -45,8 +51,9 @@ test = [
 doc = [
 	"sphinx>=7.4.0", 
 	"sphinxcontrib-apidoc", 
-	"sphinx_rtd_theme",
-	"sphinx-sitemap"
+	"pydata_sphinx_theme",
+	"sphinx-sitemap",
+	"sphinx-favicon",
 ]
 format = ["black>=23.3.0"]
 build = [
@@ -62,10 +69,11 @@ develop = [
 
 
 [project.urls]
-Homepage = "https://www.montepy.org/"
-Repository = "https://github.com/idaholab/montepy.git"
-Documentation = "https://idaholab.github.io/MontePy/index.html"
+Homepage = "https://idaholab.github.io/MontePy/index.html"
+Repository = "https://github.com/idaholab/MontePy.git"
+Documentation = "https://www.montepy.org/"
 "Bug Tracker" = "https://github.com/idaholab/MontePy/issues"
+Changelog = "https://www.montepy.org/changelog.html"
 
 [project.scripts]
 "change_to_ascii" = "montepy._scripts.change_to_ascii:main"


Test patch:
diff --git a/tests/inputs/test_universe.imcnp b/tests/inputs/test_universe.imcnp
index 904db627..59cf99d5 100644
--- a/tests/inputs/test_universe.imcnp
+++ b/tests/inputs/test_universe.imcnp
@@ -10,7 +10,7 @@ c
      imp:n=1
      imp:p=0.5
      lat 1 
-     fill= 0:1 0:1 0:0 1 0 0 1 (5)
+     fill= 0:1 0:1 0:0 1 0 R 1 (5)
 3 3 -1
       1000 1005 -1010
      imp:n,p=1
@@ -52,5 +52,6 @@ kcode 100000 1.000 50 1050
 phys:p j 1 2j 1
 mode n p
 vol NO 2J 1 1.5 J
+E0 1 10 100
 
 
diff --git a/tests/test_cell_problem.py b/tests/test_cell_problem.py
index 6c6d1098..baad1418 100644
--- a/tests/test_cell_problem.py
+++ b/tests/test_cell_problem.py
@@ -1,4 +1,5 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
+from hypothesis import given, note, strategies as st
 from unittest import TestCase
 import pytest
 
@@ -171,3 +172,151 @@ def test_malformed_init(line):
     with pytest.raises(montepy.errors.UnsupportedFeature):
         input = Input([line], BlockType.CELL)
         Cell(input)
+
+
+@given(
+    st.booleans(),
+    st.booleans(),
+    st.booleans(),
+    st.integers(),
+    st.integers(),
+    st.integers(1),
+    st.integers(1),
+)
+@pytest.mark.filterwarnings("ignore::montepy.errors.LineExpansionWarning")
+def test_cell_clone(
+    has_mat, clone_region, clone_material, start_num, step, other_start, other_step
+):
+    if has_mat:
+        input = Input(["1 1 -0.5 2"], BlockType.CELL)
+    else:
+        input = Input(["1 0 2"], BlockType.CELL)
+    surf = montepy.surfaces.surface.Surface()
+    surf.number = 2
+    mat = montepy.data_inputs.material.Material()
+    mat.number = 1
+    surfs = montepy.surface_collection.Surfaces([surf])
+    mats = montepy.materials.Materials([mat])
+    if other_start <= 0 or other_step <= 0:
+        with pytest.raises(ValueError):
+            mats.starting_number = other_start
+            mats.step = other_step
+    else:
+        for collect in (surfs, mats):
+            collect.starting_number = other_start
+            collect.step = other_step
+    cell = Cell(input)
+    cell.update_pointers([], mats, surfs)
+    problem = montepy.MCNP_Problem("foo")
+    problem.surfaces = surfs
+    problem.materials = mats
+    for prob in {None, problem}:
+        cell.link_to_problem(prob)
+        if prob is not None:
+            prob.cells.append(cell)
+        if start_num <= 0 or step <= 0:
+            with pytest.raises(ValueError):
+                cell.clone(clone_material, clone_region, start_num, step)
+            return
+        new_cell = cell.clone(clone_material, clone_region, start_num, step)
+        verify_internal_links(new_cell)
+        verify_clone_format(new_cell)
+        assert new_cell is not cell
+        assert new_cell.number != 1
+        if start_num != 1:
+            assert new_cell.number == start_num
+        else:
+            assert new_cell.number == start_num + step
+        # force it to use the step
+        if prob is not None:
+            new_cell2 = cell.clone(clone_material, clone_region, start_num, step)
+            if start_num != 1:
+                assert new_cell2.number == start_num + step
+            else:
+                assert new_cell2.number == start_num + step * 2
+        for attr in {"_importance", "_volume", "_fill"}:
+            assert getattr(cell, attr) is not getattr(new_cell, attr)
+        for attr in {"mass_density"}:
+            assert getattr(cell, attr) == getattr(new_cell, attr)
+            if attr == "mass_density":
+                attr = "density_node"
+            assert getattr(cell, f"_{attr}") is not getattr(new_cell, f"_{attr}")
+        assert cell.geometry is not new_cell.geometry
+        if clone_region:
+            assert list(cell.surfaces)[0] is not list(new_cell.surfaces)[0]
+            assert list(new_cell.surfaces)[0].number != list(cell.surfaces)[0].number
+        else:
+            assert list(cell.surfaces)[0] is list(new_cell.surfaces)[0]
+        if clone_material:
+            if cell.material is None:
+                assert new_cell.material is None
+            else:
+                assert cell.material is not new_cell.material
+                assert new_cell.material.number != cell.material.number
+        else:
+            assert cell.material is new_cell.material
+
+
+def verify_internal_links(cell):
+    # verify _number is linked in tree
+    assert cell._number is cell._tree["cell_num"]
+    assert cell._old_mat_number is cell._tree["material"]["mat_number"]
+    assert cell.geometry.node is cell._tree["geometry"].left
+    assert cell.importance._tree is cell._tree["parameters"]["imp:n"]
+
+
+def verify_clone_format(cell):
+    surf = list(cell.surfaces)[0]
+    old_num = surf.number
+    num = 1000
+    surf.number = num
+    output = cell.format_for_mcnp_input((6, 3, 0))
+    input = montepy.input_parser.mcnp_input.Input(
+        output, montepy.input_parser.block_type.BlockType.CELL
+    )
+    new_cell = montepy.Cell(input)
+    if cell.material:
+        mats = montepy.materials.Materials([cell.material])
+    else:
+        mats = []
+    new_cell.update_pointers([], mats, montepy.surface_collection.Surfaces([surf]))
+    for attr in {"number", "mass_density", "old_mat_number"}:
+        assert getattr(cell, attr) == getattr(new_cell, attr)
+    new_surf = list(new_cell.surfaces)[0]
+    assert new_surf.number == num
+    surf.number = old_num
+
+
+def test_cell_clone_default():
+    input = Input(["1 1 -0.5 2"], BlockType.CELL)
+    cell = Cell(input)
+    problem = montepy.MCNP_Problem("")
+    problem.cells.append(cell)
+    for prob in {problem, None}:
+        cell.link_to_problem(prob)
+        new_cell = cell.clone()
+        assert new_cell.number != cell.number
+
+
+@pytest.mark.parametrize(
+    "args, error",
+    [
+        (("a", False, 1, 1), TypeError),
+        ((False, "b", 1, 1), TypeError),
+        ((False, False, "c", 1), TypeError),
+        ((False, False, 1, "d"), TypeError),
+        ((False, False, -1, 1), ValueError),
+        ((False, False, 0, 1), ValueError),
+        ((False, False, 1, 0), ValueError),
+        ((False, False, 1, -1), ValueError),
+    ],
+)
+def test_cell_clone_bad(args, error):
+    input = Input(["1 0 2"], BlockType.CELL)
+    cell = Cell(input)
+    surf = montepy.surfaces.surface.Surface()
+    surf.number = 2
+    surfs = montepy.surface_collection.Surfaces([surf])
+    cell.update_pointers([], [], surfs)
+    with pytest.raises(error):
+        cell.clone(*args)
diff --git a/tests/test_geom_integration.py b/tests/test_geom_integration.py
new file mode 100644
index 00000000..8ff2a422
--- /dev/null
+++ b/tests/test_geom_integration.py
@@ -0,0 +1,40 @@
+from hypothesis import given
+import hypothesis.strategies as st
+
+import montepy
+
+import pytest
+
+geom_pair = st.tuples(st.integers(min_value=1), st.booleans())
+
+
+@given(
+    st.integers(min_value=1), st.lists(geom_pair, min_size=1, unique_by=lambda x: x[0])
+)
+def test_build_arbitrary_cell_geometry(first_surf, new_surfaces):
+    input = montepy.input_parser.mcnp_input.Input(
+        [f"1 0 {first_surf} imp:n=1"], montepy.input_parser.block_type.BlockType.CELL
+    )
+    cell = montepy.Cell(input)
+    surf = montepy.surfaces.surface.Surface()
+    surf.number = first_surf
+    cell.update_pointers([], [], montepy.surface_collection.Surfaces([surf]))
+    for surf_num, operator in new_surfaces:
+        surf = montepy.surfaces.surface.Surface()
+        surf.number = surf_num
+        if operator:
+            cell.geometry &= +surf
+        else:
+            cell.geometry |= -surf
+    assert len(cell.geometry) == len(new_surfaces) + 1
+
+
+def test_cell_geometry_set_warns():
+    input = montepy.input_parser.mcnp_input.Input(
+        [f"1 0 -1 imp:n=1"], montepy.input_parser.block_type.BlockType.CELL
+    )
+    cell = montepy.Cell(input)
+    with pytest.raises(montepy.errors.IllegalState):
+        surf = montepy.surfaces.surface.Surface()
+        surf.number = 5
+        cell.geometry &= +surf
diff --git a/tests/test_integration.py b/tests/test_integration.py
index b50d0a65..c5fa2194 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -822,7 +822,7 @@ def test_universe_number_collision():
     with pytest.raises(montepy.errors.NumberConflictError):
         problem.universes[0].number = 350
 
-    with pytest.raises(ValueError):
+    with pytest.raises(montepy.errors.NumberConflictError):
         problem.universes[350].number = 0
 
 
@@ -942,7 +942,7 @@ def test_fill_cell_format(simple_problem, universe_problem):
     # test with complex universe lattice fill
     fill = problem.cells[2].fill
     output = fill.format_for_mcnp_input((6, 2, 0))
-    answers = ["fill= 0:1 0:1 0:0 1 0 0 1 (5)"]
+    answers = ["fill= 0:1 0:1 0:0 1 0 R 1 (5)"]
     assert output == answers
     problem.print_in_data_block["FILL"] = True
     # test that complex fill is not printed in data block
@@ -1106,10 +1106,12 @@ def test_read_write_cycle(file):
     problem = montepy.read_input(file)
     SKIPPERS = _SKIP_LINES.get(str(file), {})
     fh = io.StringIO()
+    # make string unclosable to keep open after reading.
+    fh.close = lambda: None
     problem.write_problem(fh)
     fh.seek(0)
     # test valid syntax
-    new_problem = montepy.MCNP_Problem("foo")
+    new_problem = montepy.read_input(fh)
     # verify lines are similar
     fh.seek(0)
     lines = [line.rstrip() for line in fh]
diff --git a/tests/test_interface.py b/tests/test_interface.py
index 1d85c2f8..8980c444 100644
--- a/tests/test_interface.py
+++ b/tests/test_interface.py
@@ -32,3 +32,10 @@ def test_pickling_all_models(self):
                     new_problem = pickle.load(fh)
                 data = pickle.dumps(problem)
                 new_problem = pickle.loads(data)
+                for attr in {"cells", "surfaces", "materials", "data_inputs"}:
+                    assert len(getattr(problem, attr)) == len(
+                        getattr(new_problem, attr)
+                    )
+                    for obj in getattr(new_problem, attr):
+                        assert obj._problem is not None
+                        assert obj._problem is new_problem
diff --git a/tests/test_material.py b/tests/test_material.py
index e61ce0da..d9267c6a 100644
--- a/tests/test_material.py
+++ b/tests/test_material.py
@@ -1,4 +1,5 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
+from hypothesis import given, strategies as st
 from unittest import TestCase
 import pytest
 
@@ -190,6 +191,53 @@ def test_bad_init(line):
         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()
+        ):
+            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):
diff --git a/tests/test_mcnp_problem.py b/tests/test_mcnp_problem.py
index 2232dff0..985091a1 100644
--- a/tests/test_mcnp_problem.py
+++ b/tests/test_mcnp_problem.py
@@ -36,3 +36,14 @@ def test_problem_write_type(problem):
     unwritable = object()
     with pytest.raises(TypeError):
         problem.write_problem(unwritable)
+
+
+def test_problem_clone(problem):
+    problem.parse_input()
+    new_problem = problem.clone()
+    for collection in {"cells", "surfaces", "materials"}:
+        for old_obj, new_obj in zip(
+            getattr(problem, collection), getattr(new_problem, collection)
+        ):
+            assert old_obj is not new_obj
+            assert new_obj._problem is new_problem
diff --git a/tests/test_numbered_collection.py b/tests/test_numbered_collection.py
index d5a43f9e..0f82aff4 100644
--- a/tests/test_numbered_collection.py
+++ b/tests/test_numbered_collection.py
@@ -1,4 +1,6 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
+import hypothesis
+from hypothesis import given, settings, strategies as st
 import copy
 import montepy
 import montepy.cells
@@ -118,8 +120,10 @@ def test_append_renumber(self):
         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)))
         # Delete Material 2, making its number available.
         prob2.materials.remove(prob2.materials[2])
         len_mats = len(prob2.materials)
@@ -385,3 +389,51 @@ def test_data_pop(cp_simple_problem):
     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)
diff --git a/tests/test_scripts.py b/tests/test_scripts.py
index aa68de76..ea690c1f 100644
--- a/tests/test_scripts.py
+++ b/tests/test_scripts.py
@@ -36,9 +36,10 @@ def run_script(args):
 
     def test_delete_bad(self):
         for in_file in self.files:
-            with open(os.path.join("tests", "inputs", in_file), "rb") as in_fh, open(
-                self.files[in_file]["-d"], "rb"
-            ) as out_fh:
+            with (
+                open(os.path.join("tests", "inputs", in_file), "rb") as in_fh,
+                open(self.files[in_file]["-d"], "rb") as out_fh,
+            ):
                 for in_line, out_line in zip(in_fh, out_fh):
                     try:
                         in_line.decode("ascii")
@@ -52,9 +53,10 @@ def test_delete_bad(self):
 
     def test_whitespace_bad(self):
         for in_file in self.files:
-            with open(os.path.join("tests", "inputs", in_file), "rb") as in_fh, open(
-                self.files[in_file]["-w"], "rb"
-            ) as out_fh:
+            with (
+                open(os.path.join("tests", "inputs", in_file), "rb") as in_fh,
+                open(self.files[in_file]["-w"], "rb") as out_fh,
+            ):
                 for in_line, out_line in zip(in_fh, out_fh):
                     try:
                         in_line.decode("ascii")
diff --git a/tests/test_syntax_parsing.py b/tests/test_syntax_parsing.py
index 5353237a..2596209c 100644
--- a/tests/test_syntax_parsing.py
+++ b/tests/test_syntax_parsing.py
@@ -1030,6 +1030,17 @@ def test_shortcut_format(in_str, answer):
     assert shortcut.format() == ""
 
 
+@pytest.mark.parametrize(
+    "testee, outcome",
+    [("(", False), (1.0, True), (0.0, True), ("s", False), (5.0, False)],
+)
+def test_shortcut_contains(testee, outcome):
+    parser = ShortcutTestFixture()
+    input = Input(["1 0 2R"], BlockType.CELL)
+    node = parser.parse(input.tokenize())
+    assert (testee in node) == outcome, "Contains result was incorrect."
+
+
 class ShortcutTestFixture(MCNP_Parser):
     @_("number_sequence", "shortcut_sequence")
     def shortcut_magic(self, p):