Repository: terrapower/armi
Pull request #1966 (2024-10-21 23:38:06)

Allow users to define flag names with numerical values
## What is the change?

Historically, ARMI `Flags` have been all-caps and English Alphabet only: `FUEL`, `DUCT``, `FEED`, and so on.  And if you added a numerical digit into the mix, it would be ignored (for various reasons).

This PR makes it so if the user defines a parameter it will be treated as an exception to the "letters only" rule, and handled exactly as-is. 

This is only meant to support "upper case letters and numbers though".  This PR still does not support flag names with:

* whitespace
* non-English characters
* special characters, like punctuation
* _very_ long names

## Why is the change being made?

This was a feature request made by downstream users.


---

## Checklist

- [x] This PR has only [one purpose or idea](https://terrapower.github.io/armi/developer/tooling.html#one-idea-one-pr).
- [x] [Tests](https://terrapower.github.io/armi/developer/tooling.html#test-it) have been added/updated to verify any new/changed code.

<!-- Check the code quality -->

- [x] The code style follows [good practices](https://terrapower.github.io/armi/developer/standards_and_practices.html).
- [x] The commit message(s) follow [good practices](https://terrapower.github.io/armi/developer/tooling.html).

<!-- Check the project-level cruft -->

- [x] The [release notes](https://terrapower.github.io/armi/developer/tooling.html#add-release-notes) have been updated if necessary.
- [x] The [documentation](https://terrapower.github.io/armi/developer/tooling.html#document-it) is still up-to-date in the `doc` folder.
- [x] The dependencies are still up-to-date in `pyproject.toml`.


Patch:
diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py
index 9210ca5b..9d5ef791 100644
--- a/armi/reactor/blocks.py
+++ b/armi/reactor/blocks.py
@@ -1454,7 +1454,7 @@ class Block(composites.Composite):
         # Handle all other components that may be linked to the fuel multiplicity
         # by unlinking them and setting them directly.
         # TODO: What about other (actual) dimensions? This is a limitation in that only fuel
-        # compuents are duplicated, and not the entire pin. It is also a reasonable assumption with
+        # components are duplicated, and not the entire pin. It is also a reasonable assumption with
         # current/historical usage of ARMI.
         for comp, dim in self.getComponentsThatAreLinkedTo(fuel, "mult"):
             comp.setDimension(dim, nPins)
diff --git a/armi/reactor/flags.py b/armi/reactor/flags.py
index 3386772f..cba4efe5 100644
--- a/armi/reactor/flags.py
+++ b/armi/reactor/flags.py
@@ -128,11 +128,16 @@ def __fromStringGeneral(cls, typeSpec, updateMethod):
             result |= _CONVERSIONS[conversion]
 
     for name in typeSpec.split():
-        # ignore numbers so we don't have to define flags up to 217+ (number of pins/assem)
-        typeSpecWithoutNumbers = "".join([c for c in name if not c.isdigit()])
-        if not typeSpecWithoutNumbers:
-            continue
-        result |= updateMethod(typeSpecWithoutNumbers)
+        try:
+            # first, check for an exact match, to cover flags with digits
+            result |= cls[name]
+        except KeyError:
+            # ignore numbers so we don't have to define flags up to the number of pins/assem
+            typeSpecWithoutNumbers = "".join([c for c in name if not c.isdigit()])
+            if not typeSpecWithoutNumbers:
+                continue
+            result |= updateMethod(typeSpecWithoutNumbers)
+
     return result
 
 
@@ -148,8 +153,7 @@ def _fromStringIgnoreErrors(cls, typeSpec):
 
     Complications arise when:
 
-    a. multiple-word flags are used such as *grid plate* or
-       *inlet nozzle* so we use lookups.
+    a. multiple-word flags are used such as *grid plate* or *inlet nozzle* so we use lookups.
     b. Some flags have digits in them. We just strip those off.
     """
 
@@ -170,8 +174,8 @@ def _fromString(cls, typeSpec):
             return cls[typeSpec]
         except KeyError:
             raise InvalidFlagsError(
-                "The requested type specification `{}` is invalid. "
-                "See armi.reactor.flags documentation.".format(typeSpec)
+                f"The requested type specification `{typeSpec}` is invalid. "
+                "See armi.reactor.flags documentation."
             )
 
     return __fromStringGeneral(cls, typeSpec, updateMethod)
@@ -289,15 +293,14 @@ class Flags(Flag):
             :id: I_ARMI_FLAG_TO_STR0
             :implements: R_ARMI_FLAG_TO_STR
 
-            For a string passed as ``typeSpec``, first converts the whole string
-            to uppercase. Then tries to parse the string for any special phrases, as
-            defined in the module dictionary ``_CONVERSIONS``, and converts those
-            phrases to flags directly.
+            For a string passed as ``typeSpec``, first converts the whole string to uppercase. Then
+            tries to parse the string for any special phrases, as defined in the module dictionary
+            ``_CONVERSIONS``, and converts those phrases to flags directly.
 
-            Then it splits the remaining string into separate words based on the presence
-            of spaces. Looping over each of the words, any numbers are stripped out
-            and the remaining string is matched up to any class attribute names.
-            If any matches are found these are returned as flags.
+            Then it splits the remaining string into words based on spaces. Looping over each of the
+            words, if any word exactly matches a flag name. Otherwise, any numbers are stripped out
+            and the remaining string is matched up to any class attribute names. If any matches are
+            found these are returned as flags.
         """
         return _fromString(cls, typeSpec)
 
@@ -310,11 +313,9 @@ class Flags(Flag):
             :id: I_ARMI_FLAG_TO_STR1
             :implements: R_ARMI_FLAG_TO_STR
 
-            This converts the representation of a bunch of flags from ``typeSpec``,
-            which might look like ``Flags.A|B``,
-            into a string with spaces in between the flag names, which would look
-            like  ``'A B'``. This is done via nesting string splitting and replacement
-            actions.
+            This converts the representation of a bunch of flags from ``typeSpec``, which might look
+            like ``Flags.A|B``, into a string with spaces in between the flag names, which would
+            look like  ``'A B'``. This is done via nesting string splitting and replacement actions.
         """
         return _toString(cls, typeSpec)
 
diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst
index c5d52338..f8cc73aa 100644
--- a/doc/release/0.4.rst
+++ b/doc/release/0.4.rst
@@ -18,6 +18,7 @@ New Features
 #. Allow merging a component with zero area into another component. (`PR#1858 <https://github.com/terrapower/armi/pull/1858>`_)
 #. Provide ``Parameter.hasCategory`` for quickly checking if a parameter is defined with a given category. (`PR#1899 <https://github.com/terrapower/armi/pull/1899>`_)
 #. Provide ``ParameterCollection.where`` for efficient iteration over parameters who's definition matches a given condition. (`PR#1899 <https://github.com/terrapower/armi/pull/1899>`_)
+#. Flags can now be defined with letters and numbers. (`PR#1966 <https://github.com/terrapower/armi/pull/1966>`_)
 #. Plugins can provide the ``getAxialExpansionChanger`` hook to customize axial expansion. (`PR#1870 <https://github.com/terrapower/armi/pull/1870`_)
 #. Plugins can provide the ``getAxialExpansionChanger`` hook to customize axial expansion. (`PR#1870 <https://github.com/terrapower/armi/pull/1870>`_)
 #. New plugin hook ``beforeReactorConstruction`` added to enable plugins to process case settings before reactor init. (`PR#1945 <https://github.com/terrapower/armi/pull/1945>`_)


Test patch:
diff --git a/armi/reactor/tests/test_flags.py b/armi/reactor/tests/test_flags.py
index 7155cae9..7d3d2fe8 100644
--- a/armi/reactor/tests/test_flags.py
+++ b/armi/reactor/tests/test_flags.py
@@ -26,6 +26,49 @@ class TestFlags(unittest.TestCase):
         self._help_fromString(flags.Flags.fromStringIgnoreErrors)
         self.assertEqual(flags.Flags.fromStringIgnoreErrors("invalid"), flags.Flags(0))
 
+    def test_fromStringWithNumbers(self):
+        # testing pure numbers
+        self.assertEqual(flags.Flags.fromStringIgnoreErrors("1"), flags.Flags(0))
+        self.assertEqual(flags.Flags.fromStringIgnoreErrors("7"), flags.Flags(0))
+
+        # testing fuel naming logic
+        self.assertEqual(flags.Flags.fromStringIgnoreErrors("Fuel1"), flags.Flags.FUEL)
+        self.assertEqual(
+            flags.Flags.fromStringIgnoreErrors("Fuel123"), flags.Flags.FUEL
+        )
+        self.assertEqual(flags.Flags.fromStringIgnoreErrors("fuel 1"), flags.Flags.FUEL)
+        self.assertEqual(
+            flags.Flags.fromStringIgnoreErrors("fuel 123"), flags.Flags.FUEL
+        )
+
+    def test_flagsDefinedWithNumbers(self):
+        """Test that if we DEFINE flags with numbers in them, those are treated as exceptions."""
+        # define flags TYPE1 and TYPE1B (arbitrary example)
+        flags.Flags.extend({"TYPE1": flags.auto(), "TYPE1B": flags.auto()})
+
+        # verify that these flags are correctly found
+        self.assertEqual(flags.Flags["TYPE1"], flags.Flags.TYPE1)
+        self.assertEqual(flags.Flags["TYPE1B"], flags.Flags.TYPE1B)
+        self.assertEqual(flags.Flags.fromStringIgnoreErrors("type1"), flags.Flags.TYPE1)
+        self.assertEqual(
+            flags.Flags.fromStringIgnoreErrors("Type1b"), flags.Flags.TYPE1B
+        )
+
+        # the more complicated situation where our exceptions are mixed with the usual flag logic
+        self.assertEqual(
+            flags.Flags.fromString("type1 fuel"), flags.Flags.TYPE1 | flags.Flags.FUEL
+        )
+
+        self.assertEqual(
+            flags.Flags.fromString("type1 fuel 123 bond"),
+            flags.Flags.TYPE1 | flags.Flags.FUEL | flags.Flags.BOND,
+        )
+
+        self.assertEqual(
+            flags.Flags.fromString("type1 fuel123 bond"),
+            flags.Flags.TYPE1 | flags.Flags.FUEL | flags.Flags.BOND,
+        )
+
     def test_flagsToAndFromString(self):
         """
         Convert flag to and from string for serialization.
@@ -58,8 +101,7 @@ class TestFlags(unittest.TestCase):
         self.assertEqual(method("bond1"), flags.Flags.BOND)
         self.assertEqual(method("bond 2"), flags.Flags.BOND)
         self.assertEqual(method("fuel test"), flags.Flags.FUEL | flags.Flags.TEST)
-        # test the more strict GRID conversion, which can cause collisions with
-        # GRID_PLATE
+        # test the more strict GRID conversion, which can cause collisions with GRID_PLATE
         self.assertEqual(
             flags.Flags.fromStringIgnoreErrors("grid_plate"), flags.Flags.GRID_PLATE
         )