Repository: idaholab/MontePy

Issue:
Material always writes original material components
**Describe the bug**

Since v0.2.0 and the deprecation of the `_mutated` attribute, MontePy no longer knows when a `Material` has been updated. Now, a `Material` instance always writes out its original lines, even when the `material_components` have changed.

The original `lines` are always written. I suspect that [`Material._update_values()`](https://github.com/idaholab/MontePy/blob/v0.3.2/montepy/mcnp_object.py#L104-L117) needs to be implemented.


**To Reproduce**

``` python
# Show that material components don't get updated
  
import montepy
import copy

deck = montepy.read_input("deck1.i")
m1 = deck.materials[1]
nuclides = copy.copy(m1.material_components)
for nuc, component in nuclides.items():
    if nuc.ZAID not in ("92235", "92238"):
        del m1.material_components[nuc]
print("Material 1 with fission products deleted:\n", m1)
deck.write_to_file("deck2.i", overwrite=True)
print("Material 1 after writing:\n", montepy.read_input("deck2.i").materials[1])
```

You can try replacing or modifying the material components a number of different ways. As long as this dictionary is not entirely empty, MontePy will still write the old material.


**Output**


``` python
Material 1 with fission products deleted:
 MATERIAL: 1, ['uranium']
Material 1 after writing:
 MATERIAL: 1, ['krypton', 'yttrium', 'zirconium', 'niobium', 'molybdenum', 'rhodium', 'palladium', 'tellurium', 'iodine', 'xenon', 'cesium', 'barium', 'neodymium', 'samarium', 'uranium', 'plutonium']
```

**MCNP input file snippet**


```
MWE of Material.material_components not updated
1  1 -1.0  -1000        imp:n,p=1
2  1 -2.0  -2000 +1000  imp:n,p=1
99 0             +2000  imp:n,p=0

C surfaces
1000 SO 1
2000 SO 2

C data
C materials
C U with fission products
m1         92235.80c 5 
           92238.80c 8
           94239.00c 1
           54134.00c 2
           94240.00c 1
           55133.00c 1
           60143.00c 1
           54132.00c 1
           42098.00c 1
           60145.00c 1
           40092.00c 1
           60146.00c 9
           40095.00c 9
           60148.00c 5
           52130.00c 5
           39089.00c 7
           45103.00c 6
           42095.00c 5
           60144.00c 3
           56140.00c 2
           41095.00c 3
           36084.00c 2
           53129.00c 1
           46107.00c 1
           36083.00c 1
           46106.00c 7
           62151.00c 5
           92237.00c 2
C execution
ksrc 0 0 0
kcode 100000 1.000 50 1050
phys:p j 1 2j 1
mode n p
```

**Version**

 - Versions 0.2.0 - 0.3.2


**Additional context**

This worked prior to v0.2.0; see, for example, the Update ATR XS scripts inside INL.


Discussion:

Patch:
diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst
index 5af2893e..d8e1bd36 100644
--- a/doc/source/changelog.rst
+++ b/doc/source/changelog.rst
@@ -6,6 +6,7 @@ MontePy Changelog
 
 **Bug fixes**
 
+* Fixed bug with material compositions not being updated when written to file (:issue:`470`).
 * Fixed bug with appending and renumbering numbered objects from other MCNP problems (:issue:`466`).
 * Fixed bug with dynamic typing and the parsers that only appear in edge cases (:issue:`461`).
 
diff --git a/montepy/data_inputs/isotope.py b/montepy/data_inputs/isotope.py
index 4303198d..f682fe95 100644
--- a/montepy/data_inputs/isotope.py
+++ b/montepy/data_inputs/isotope.py
@@ -1,7 +1,7 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
 from montepy.data_inputs.element import Element
 from montepy.errors import *
-from montepy.input_parser.syntax_node import ValueNode
+from montepy.input_parser.syntax_node import PaddingNode, ValueNode
 
 
 class Isotope:
@@ -36,6 +36,8 @@ class Isotope:
             self._library = parts[1]
         else:
             self._library = ""
+        if node is None:
+            self._tree = ValueNode(self.mcnp_str(), str, PaddingNode(" "))
 
     def __parse_zaid(self):
         """
@@ -178,7 +180,7 @@ class Isotope:
         :returns: a string that can be used in MCNP
         :rtype: str
         """
-        return f"{self.ZAID}.{self.library}"
+        return f"{self.ZAID}.{self.library}" if self.library else self.ZAID
 
     def get_base_zaid(self):
         """
diff --git a/montepy/data_inputs/material.py b/montepy/data_inputs/material.py
index 75cd3413..a6fe2c04 100644
--- a/montepy/data_inputs/material.py
+++ b/montepy/data_inputs/material.py
@@ -41,15 +41,7 @@ class Material(data_input.DataInputAbstract, Numbered_MCNP_Object):
             self._number = num
             set_atom_frac = False
             isotope_fractions = self._tree["data"]
-            if isinstance(isotope_fractions, syntax_node.ListNode):
-                # in python 3.12 this can be replaced with itertools.batched
-                def batch_gen():
-                    it = iter(isotope_fractions)
-                    while batch := tuple(itertools.islice(it, 2)):
-                        yield batch
-
-                iterator = batch_gen()
-            elif isinstance(isotope_fractions, syntax_node.IsotopesNode):
+            if isinstance(isotope_fractions, syntax_node.IsotopesNode):
                 iterator = iter(isotope_fractions)
             else:  # pragma: no cover
                 # this is a fall through error, that should never be raised,
@@ -112,6 +104,9 @@ class Material(data_input.DataInputAbstract, Numbered_MCNP_Object):
         """
         The internal dictionary containing all the components of this material.
 
+        The keys are :class:`~montepy.data_inputs.isotope.Isotope` instances, and the values are
+        :class:`~montepy.data_inputs.material_component.MaterialComponent` instances.
+
         :rtype: dict
         """
         return self._material_components
@@ -152,6 +147,13 @@ class Material(data_input.DataInputAbstract, Numbered_MCNP_Object):
             lines += self.thermal_scattering.format_for_mcnp_input(mcnp_version)
         return lines
 
+    def _update_values(self):
+        new_list = syntax_node.IsotopesNode("new isotope list")
+        for isotope, component in self.material_components.items():
+            isotope._tree.value = isotope.mcnp_str()
+            new_list.append(("_", isotope._tree, component._tree))
+        self._tree.nodes["data"] = new_list
+
     def add_thermal_scattering(self, law):
         """
         Adds thermal scattering law to the material
diff --git a/montepy/input_parser/material_parser.py b/montepy/input_parser/material_parser.py
index f53e1b4b..f06fe59a 100644
--- a/montepy/input_parser/material_parser.py
+++ b/montepy/input_parser/material_parser.py
@@ -1,4 +1,6 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
+import itertools
+
 from montepy.input_parser.data_parser import DataParser
 from montepy.input_parser import syntax_node
 
@@ -21,11 +23,27 @@ class MaterialParser(DataParser):
 
     @_("isotope_fractions", "number_sequence", "isotope_hybrid_fractions")
     def isotopes(self, p):
+        if hasattr(p, "number_sequence"):
+            return self._convert_to_isotope(p.number_sequence)
         return p[0]
 
     @_("number_sequence isotope_fraction", "isotope_hybrid_fractions isotope_fraction")
     def isotope_hybrid_fractions(self, p):
-        ret = p[0]
-        for node in p.isotope_fraction[1:]:
-            ret.append(node)
+        if hasattr(p, "number_sequence"):
+            ret = self._convert_to_isotope(p.number_sequence)
+        else:
+            ret = p[0]
+        ret.append(p.isotope_fraction)
         return ret
+
+    def _convert_to_isotope(self, old):
+        new_list = syntax_node.IsotopesNode("converted isotopes")
+
+        def batch_gen():
+            it = iter(old)
+            while batch := tuple(itertools.islice(it, 2)):
+                yield batch
+
+        for group in batch_gen():
+            new_list.append(("foo", *group))
+        return new_list


Test patch:
diff --git a/tests/test_material.py b/tests/test_material.py
index aec22336..feb51472 100644
--- a/tests/test_material.py
+++ b/tests/test_material.py
@@ -1,5 +1,6 @@
 # Copyright 2024, Battelle Energy Alliance, LLC All Rights Reserved.
 from unittest import TestCase
+import pytest
 
 import montepy
 
@@ -121,51 +122,72 @@ Pu-239 (80c) 0.1
         for i, mat in enumerate(sort_list):
             self.assertEqual(mat, answers[i])
 
-    def test_material_format_mcnp(self):
-        in_strs = ["M20 1001.80c 0.5", "     8016.80c         0.5"]
-        input_card = Input(in_strs, BlockType.DATA)
-        material = Material(input_card)
-        material.number = 25
-        answers = ["M25 1001.80c 0.5", "     8016.80c         0.5"]
-        output = material.format_for_mcnp_input((6, 2, 0))
-        self.assertEqual(len(answers), len(output))
-        for i, line in enumerate(output):
-            self.assertEqual(line, answers[i])
-
-    def test_material_comp_init(self):
-        # test fraction test
-        with self.assertRaises(ValueError):
-            MaterialComponent(Isotope("1001.80c"), -0.1)
-
-        # test bad fraction
-        with self.assertRaises(TypeError):
-            MaterialComponent(Isotope("1001.80c"), "hi")
-
-        # test bad isotope
-        with self.assertRaises(TypeError):
-            MaterialComponent("hi", 1.0)
 
-    def test_material_comp_fraction_setter(self):
-        comp = MaterialComponent(Isotope("1001.80c"), 0.1)
-        comp.fraction = 5.0
-        self.assertEqual(comp.fraction, 5.0)
-        with self.assertRaises(ValueError):
-            comp.fraction = -1.0
-        with self.assertRaises(TypeError):
-            comp.fraction = "hi"
-
-    def test_material_comp_fraction_str(self):
-        comp = MaterialComponent(Isotope("1001.80c"), 0.1)
-        str(comp)
-        repr(comp)
-
-    def test_material_card_pass_through(self):
-        in_str = "M20 1001.80c 0.5 8016.80c 0.5"
-        input_card = Input([in_str], BlockType.DATA)
-        material = Material(input_card)
-        self.assertEqual(material.format_for_mcnp_input((6, 2, 0)), [in_str])
-        material.number = 5
-        self.assertNotIn("8016", material.format_for_mcnp_input((6, 2, 0)))
+def test_material_format_mcnp():
+    in_strs = ["M20 1001.80c 0.5", "     8016.80c         0.5"]
+    input_card = Input(in_strs, BlockType.DATA)
+    material = Material(input_card)
+    material.number = 25
+    answers = ["M25 1001.80c 0.5", "     8016.80c         0.5"]
+    output = material.format_for_mcnp_input((6, 2, 0))
+    assert output == answers
+
+
+@pytest.mark.parametrize(
+    "isotope, conc, error",
+    [
+        ("1001.80c", -0.1, ValueError),
+        ("1001.80c", "hi", TypeError),
+        ("hi", 1.0, ValueError),
+    ],
+)
+def test_material_comp_init(isotope, conc, error):
+    with pytest.raises(error):
+        MaterialComponent(Isotope(isotope), conc)
+
+
+def test_material_comp_fraction_setter():
+    comp = MaterialComponent(Isotope("1001.80c"), 0.1)
+    comp.fraction = 5.0
+    assert comp.fraction == pytest.approx(5.0)
+    with pytest.raises(ValueError):
+        comp.fraction = -1.0
+    with pytest.raises(TypeError):
+        comp.fraction = "hi"
+
+
+def test_material_comp_fraction_str():
+    comp = MaterialComponent(Isotope("1001.80c"), 0.1)
+    str(comp)
+    repr(comp)
+
+
+def test_material_update_format():
+    in_str = "M20 1001.80c 0.5 8016.80c 0.5"
+    input_card = Input([in_str], BlockType.DATA)
+    material = Material(input_card)
+    assert material.format_for_mcnp_input((6, 2, 0)) == [in_str]
+    material.number = 5
+    print(material.format_for_mcnp_input((6, 2, 0)))
+    assert "8016" in material.format_for_mcnp_input((6, 2, 0))[0]
+    # addition
+    isotope = Isotope("2004.80c")
+    material.material_components[isotope] = MaterialComponent(isotope, 0.1)
+    print(material.format_for_mcnp_input((6, 2, 0)))
+    assert "2004" in material.format_for_mcnp_input((6, 2, 0))[0]
+    # update
+    isotope = list(material.material_components.keys())[-1]
+    print(material.material_components.keys())
+    material.material_components[isotope].fraction = 0.7
+    print(material.format_for_mcnp_input((6, 2, 0)))
+    assert "0.7" in material.format_for_mcnp_input((6, 2, 0))[0]
+    material.material_components[isotope] = MaterialComponent(isotope, 0.6)
+    print(material.format_for_mcnp_input((6, 2, 0)))
+    assert "0.6" in material.format_for_mcnp_input((6, 2, 0))[0]
+    # delete
+    del material.material_components[isotope]
+    print(material.format_for_mcnp_input((6, 2, 0)))
+    assert "8016" in material.format_for_mcnp_input((6, 2, 0))[0]
 
 
 class TestIsotope(TestCase):