From 5a41b25fe9f262acb03347923443c4a2808837db Mon Sep 17 00:00:00 2001 From: Drew Johnson Date: Thu, 12 Sep 2024 11:22:29 -0700 Subject: [PATCH] Removing Assembly.rotatePins, and making Block.rotatePins private (#1846) --- .../fuelCycle/assemblyRotationAlgorithms.py | 55 +++++++---- .../fuelCycle/hexAssemblyFuelMgmtUtils.py | 6 +- armi/reactor/assemblies.py | 34 ++++--- armi/reactor/blocks.py | 79 +++++++--------- armi/reactor/tests/test_assemblies.py | 3 + armi/reactor/tests/test_blocks.py | 38 ++++++-- armi/utils/hexagon.py | 46 ++++++++++ armi/utils/iterables.py | 26 ++++++ armi/utils/tests/test_hexagon.py | 91 +++++++++++++++++++ armi/utils/tests/test_iterables.py | 20 ++++ doc/release/0.4.rst | 6 ++ 11 files changed, 314 insertions(+), 90 deletions(-) diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py index a56d30827..30c87ba2e 100644 --- a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py @@ -19,8 +19,10 @@ These algorithms are defined in assemblyRotationAlgorithms.py, but they are used in: ``FuelHandler.outage()``. -.. warning:: Nothing should do in this file, but rotation algorithms. +.. warning:: Nothing should go in this file, but rotation algorithms. """ +import math + from armi import runLog from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import ( getOptimalAssemblyOrientation, @@ -28,8 +30,13 @@ from armi.physics.fuelCycle.settings import CONF_ASSEM_ROTATION_STATIONARY +def _rotationNumberToRadians(rot: int) -> float: + """Convert a rotation number to radians, assuming a HexAssembly.""" + return rot * math.pi / 3 + + def buReducingAssemblyRotation(fh): - r""" + """ Rotates all detail assemblies to put the highest bu pin in the lowest power orientation. Parameters @@ -48,31 +55,38 @@ def buReducingAssemblyRotation(fh): aNow = fh.r.core.getAssemblyWithStringLocation(aPrev.lastLocationLabel) # no point in rotation if there's no pin detail if aNow in hist.getDetailAssemblies(): - rot = getOptimalAssemblyOrientation(aNow, aPrev) - aNow.rotatePins(rot) # rot = integer between 0 and 5 + _rotateByComparingLocations(aNow, aPrev) numRotated += 1 - # Print out rotation operation (mainly for testing) - # hex indices (i,j) = (ring,pos) - (i, j) = aNow.spatialLocator.getRingPos() - runLog.important( - "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, rot) - ) - # rotate NON-MOVING assemblies (stationary) if fh.cs[CONF_ASSEM_ROTATION_STATIONARY]: for a in hist.getDetailAssemblies(): if a not in fh.moved: - rot = getOptimalAssemblyOrientation(a, a) - a.rotatePins(rot) # rot = integer between 0 and 6 + _rotateByComparingLocations(a, a) numRotated += 1 - (i, j) = a.spatialLocator.getRingPos() - runLog.important( - "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, rot) - ) runLog.info("Rotated {0} assemblies".format(numRotated)) +def _rotateByComparingLocations(aNow, aPrev): + """Rotate an assembly based on its previous location. + + Parameters + ---------- + aNow : Assembly + Assembly to be rotated + aPrev : Assembly + Assembly that previously occupied the location of this assembly. + If ``aNow`` has not been moved, this should be ``aNow`` + """ + rot = getOptimalAssemblyOrientation(aNow, aPrev) + radians = _rotationNumberToRadians(rot) + aNow.rotate(radians) + (ring, pos) = aNow.spatialLocator.getRingPos() + runLog.important( + "Rotating Assembly ({0},{1}) to Orientation {2}".format(ring, pos, rot) + ) + + def simpleAssemblyRotation(fh): """ Rotate all pin-detail assemblies that were just shuffled by 60 degrees. @@ -98,13 +112,14 @@ def simpleAssemblyRotation(fh): runLog.info("Rotating assemblies by 60 degrees") numRotated = 0 hist = fh.o.getInterface("history") + rot = math.radians(60) for a in hist.getDetailAssemblies(): if a in fh.moved or fh.cs[CONF_ASSEM_ROTATION_STATIONARY]: - a.rotatePins(1) + a.rotate(rot) numRotated += 1 - i, j = a.spatialLocator.getRingPos() # hex indices (i,j) = (ring,pos) + ring, pos = a.spatialLocator.getRingPos() runLog.extra( - "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, 1) + "Rotating Assembly ({0},{1}) to Orientation {2}".format(ring, pos, 1) ) runLog.extra("Rotated {0} assemblies".format(numRotated)) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index e475fa01a..e1c73608e 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -24,6 +24,7 @@ import numpy as np from armi import runLog +from armi.utils.hexagon import getIndexOfRotatedCell from armi.reactor.flags import Flags from armi.utils.mathematics import findClosest @@ -101,10 +102,7 @@ def getOptimalAssemblyOrientation(a, aPrev): prevAssemPowHereMIN = float("inf") for possibleRotation in range(6): - # get rotated pin index - indexLookup = maxBuBlock.rotatePins(possibleRotation, justCompute=True) - # rotated index of highest-BU pin - index = int(indexLookup[maxBuPinIndexAssem]) + index = getIndexOfRotatedCell(maxBuPinIndexAssem, possibleRotation) # get pin power at this index in the previously assembly located here # power previously at rotated index prevAssemPowHere = aPrev[bIndexMaxBu].p.linPowByPin[index - 1] diff --git a/armi/reactor/assemblies.py b/armi/reactor/assemblies.py index 14cdbc250..84bc09e99 100644 --- a/armi/reactor/assemblies.py +++ b/armi/reactor/assemblies.py @@ -315,11 +315,6 @@ def getAveragePlenumTemperature(self): return sum(plenumTemps) / len(plenumTemps) - def rotatePins(self, *args, **kwargs): - """Rotate an assembly, which means rotating the indexing of pins.""" - for b in self: - b.rotatePins(*args, **kwargs) - def doubleResolution(self): """ Turns each block into two half-size blocks. @@ -1248,8 +1243,7 @@ def rotate(self, rad): This method loops through every ``Block`` in this ``Assembly`` and rotates it by a given angle (in radians). The rotation angle is positive in the - counter-clockwise direction, and must be divisible by increments of PI/6 - (60 degrees). To actually perform the ``Block`` rotation, the + counter-clockwise direction. To perform the ``Block`` rotation, the :py:meth:`armi.reactor.blocks.Block.rotate` method is called. Parameters @@ -1257,11 +1251,8 @@ def rotate(self, rad): rad: float number (in radians) specifying the angle of counter clockwise rotation - Warning - ------- - rad must be in 60-degree increments! (i.e., PI/6, PI/3, PI, 2 * PI/3, etc) """ - for b in self.getBlocks(): + for b in self: b.rotate(rad) def isOnWhichSymmetryLine(self): @@ -1272,7 +1263,26 @@ def isOnWhichSymmetryLine(self): class HexAssembly(Assembly): """Placeholder, so users can explicitly define a hex-based Assembly.""" - pass + def rotate(self, rad: float): + """Rotate an assembly and its children. + + Parameters + ---------- + rad : float + Counter clockwise rotation in radians. **MUST** be in increments of + 60 degrees (PI / 3) + + Raises + ------ + ValueError + If rotation is not divisible by pi / 3. + """ + if math.isclose(rad % (math.pi / 3), 0, abs_tol=1e-12): + return super().rotate(rad) + raise ValueError( + f"Rotation must be in 60 degree increments, got {math.degrees(rad)} " + f"degrees ({rad} radians)" + ) class CartesianAssembly(Assembly): diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py index 89d4bca38..bef0ade54 100644 --- a/armi/reactor/blocks.py +++ b/armi/reactor/blocks.py @@ -45,6 +45,7 @@ from armi.reactor.parameters import ParamLocation from armi.utils import densityTools from armi.utils import hexagon +from armi.utils import iterables from armi.utils import units from armi.utils.plotting import plotBlockFlux from armi.utils.units import TRACE_NUMBER_DENSITY @@ -2012,8 +2013,7 @@ def rotate(self, rad): Python list of length 6 in order to be eligible for rotation; all parameters that do not meet these two criteria are not rotated. - The pin indexing, as stored on the pinLocation parameter, is also updated via - :py:meth:`rotatePins `. + The pin indexing, as stored on the ``pinLocation`` parameter, is also updated. Parameters ---------- @@ -2022,54 +2022,53 @@ def rotate(self, rad): in 60-degree increments (i.e., PI/6, PI/3, PI, 2 * PI/3, 5 * PI/6, and 2 * PI) - See Also - -------- - :py:meth:`rotatePins ` """ rotNum = round((rad % (2 * math.pi)) / math.radians(60)) - self.rotatePins(rotNum) - params = self.p.paramDefs.atLocation(ParamLocation.CORNERS).names - params += self.p.paramDefs.atLocation(ParamLocation.EDGES).names - for param in params: - if isinstance(self.p[param], list): - if len(self.p[param]) == 6: - self.p[param] = self.p[param][-rotNum:] + self.p[param][:-rotNum] - elif self.p[param] == []: - # List hasn't been defined yet, no warning needed. - pass - else: - msg = ( - "No rotation method defined for spatial parameters that aren't " - "defined once per hex edge/corner. No rotation performed " - f"on {param}" - ) - runLog.warning(msg) - elif isinstance(self.p[param], np.ndarray): - if len(self.p[param]) == 6: - self.p[param] = np.concatenate( - (self.p[param][-rotNum:], self.p[param][:-rotNum]) - ) - elif len(self.p[param]) == 0: + self._rotatePins(rotNum) + self._rotateBoundaryParameters(rotNum) + self._rotateDisplacement(rad) + + def _rotateBoundaryParameters(self, rotNum: int): + """Rotate any parameters defined on the corners or edge of bounding hexagon. + + Parameters + ---------- + rotNum : int + Rotation number between zero and five, inclusive, specifying how many + rotations have taken place. + + """ + names = self.p.paramDefs.atLocation(ParamLocation.CORNERS).names + names += self.p.paramDefs.atLocation(ParamLocation.EDGES).names + for name in names: + original = self.p[name] + if isinstance(original, (list, np.ndarray)): + if len(original) == 6: + # Rotate by making the -rotNum item be first + self.p[name] = iterables.pivot(original, -rotNum) + elif len(original) == 0: # Hasn't been defined yet, no warning needed. pass else: msg = ( "No rotation method defined for spatial parameters that aren't " "defined once per hex edge/corner. No rotation performed " - f"on {param}" + f"on {name}" ) runLog.warning(msg) - elif isinstance(self.p[param], (int, float)): + elif isinstance(original, (int, float)): # this is a scalar and there shouldn't be any rotation. pass - elif self.p[param] is None: + elif original is None: # param is not set yet. no rotations as well. pass else: raise TypeError( - f"b.rotate() method received unexpected data type for {param} on block {self}\n" - + f"expected list, np.ndarray, int, or float. received {self.p[param]}" + f"b.rotate() method received unexpected data type for {name} on block {self}\n" + + f"expected list, np.ndarray, int, or float. received {original}" ) + + def _rotateDisplacement(self, rad: float): # This specifically uses the .get() functionality to avoid an error if this # parameter does not exist. dispx = self.p.get("displacementX") @@ -2078,7 +2077,7 @@ def rotate(self, rad): self.p.displacementX = dispx * math.cos(rad) - dispy * math.sin(rad) self.p.displacementY = dispx * math.sin(rad) + dispy * math.cos(rad) - def rotatePins(self, rotNum, justCompute=False): + def _rotatePins(self, rotNum, justCompute=False): """ Rotate the pins of a block, which means rotating the indexing of pins. Note that this does not rotate all block quantities, just the pins. @@ -2153,17 +2152,7 @@ def rotatePins(self, rotNum, justCompute=False): # Rotation to reference orientation. Pin locations are pin IDs. pass else: - # Determine the pin ring. Rotation does not change the pin ring! - ring = int( - math.ceil((3.0 + math.sqrt(9.0 - 12.0 * (1.0 - pinNum))) / 6.0) - ) - - # Rotate the pin position (within the ring, which does not change) - tot_pins = 1 + 3 * ring * (ring - 1) - newPinLocation = pinNum + (ring - 1) * rotNum - if newPinLocation > tot_pins: - newPinLocation -= (ring - 1) * 6 - + newPinLocation = hexagon.getIndexOfRotatedCell(pinNum, rotNum) # Assign "before" and "after" pin indices to the index lookup rotateIndexLookup[pinNum] = newPinLocation diff --git a/armi/reactor/tests/test_assemblies.py b/armi/reactor/tests/test_assemblies.py index 477f70674..978e8b3e0 100644 --- a/armi/reactor/tests/test_assemblies.py +++ b/armi/reactor/tests/test_assemblies.py @@ -1090,6 +1090,9 @@ def test_rotate(self): a.rotate(math.radians(120)) self.assertIn("No rotation method defined", mock.getStdout()) + with self.assertRaisesRegex(ValueError, expected_regex="60 degree"): + a.rotate(math.radians(40)) + def test_assem_block_types(self): """Test that all children of an assembly are blocks, ordered from top to bottom. diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py index abd6d5477..0645a76c4 100644 --- a/armi/reactor/tests/test_blocks.py +++ b/armi/reactor/tests/test_blocks.py @@ -1454,33 +1454,33 @@ def test_106_getAreaFractions(self): def test_rotatePins(self): b = self.block b.setRotationNum(0) - index = b.rotatePins(0, justCompute=True) + index = b._rotatePins(0, justCompute=True) self.assertEqual(b.getRotationNum(), 0) self.assertEqual(index[5], 5) self.assertEqual(index[2], 2) # pin 1 is center and never rotates. - index = b.rotatePins(1) + index = b._rotatePins(1) self.assertEqual(b.getRotationNum(), 1) self.assertEqual(index[2], 3) self.assertEqual(b.p.pinLocation[1], 3) - index = b.rotatePins(1) + index = b._rotatePins(1) self.assertEqual(b.getRotationNum(), 2) self.assertEqual(index[2], 4) self.assertEqual(b.p.pinLocation[1], 4) - index = b.rotatePins(2) - index = b.rotatePins(4) # over-rotate to check modulus + index = b._rotatePins(2) + index = b._rotatePins(4) # over-rotate to check modulus self.assertEqual(b.getRotationNum(), 2) self.assertEqual(index[2], 4) self.assertEqual(index[6], 2) self.assertEqual(b.p.pinLocation[1], 4) self.assertEqual(b.p.pinLocation[5], 2) - self.assertRaises(ValueError, b.rotatePins, -1) - self.assertRaises(ValueError, b.rotatePins, 10) - self.assertRaises((ValueError, TypeError), b.rotatePins, None) - self.assertRaises((ValueError, TypeError), b.rotatePins, "a") + self.assertRaises(ValueError, b._rotatePins, -1) + self.assertRaises(ValueError, b._rotatePins, 10) + self.assertRaises((ValueError, TypeError), b._rotatePins, None) + self.assertRaises((ValueError, TypeError), b._rotatePins, "a") def test_expandElementalToIsotopics(self): r"""Tests the expand to elementals capability.""" @@ -2627,3 +2627,23 @@ def test_massConsistency(self): 10, "Sum of component mass {0} != total block mass {1}. ".format(tMass, bMass), ) + + +class EmptyBlockRotateTest(unittest.TestCase): + """Rotation tests on an empty hexagonal block. + + Useful for enforcing rotation works on blocks without pins. + + """ + + def setUp(self): + self.block = blocks.HexBlock("empty") + + def test_orientation(self): + """Test the orientation parameter is updated on a rotated empty block.""" + rotDegrees = 60 + preRotateOrientation = self.block.p.orientation[2] + self.block.rotate(math.radians(rotDegrees)) + postRotationOrientation = self.block.p.orientation[2] + self.assertNotEqual(preRotateOrientation, postRotationOrientation) + self.assertEqual(postRotationOrientation, rotDegrees) diff --git a/armi/utils/hexagon.py b/armi/utils/hexagon.py index 3f9b389b8..126c55e1c 100644 --- a/armi/utils/hexagon.py +++ b/armi/utils/hexagon.py @@ -145,3 +145,49 @@ def numPositionsInRing(ring): rings is indexed to 1, i.e. the centermost position in the lattice is ``ring=1``. """ return (ring - 1) * 6 if ring != 1 else 1 + + +def totalPositionsUpToRing(ring: int) -> int: + """Return the number of positions in a hexagon with a given number of rings.""" + return 1 + 3 * ring * (ring - 1) + + +def getIndexOfRotatedCell(initialCellIndex: int, orientationNumber: int) -> int: + """Obtain a new cell number after placing a hexagon in a new orientation. + + Parameters + ---------- + initialCellIndex : int + Positive number for this cell's position in a hexagonal lattice. + orientationNumber : + Orientation in number of 60 degree, counter clockwise rotations. An orientation + of zero means the first cell in each ring of a flags up hexagon is in the upper + right corner. + + Returns + ------- + int + New cell number across the rotation + + Raises + ------ + ValueError + If ``initialCellIndex`` is not positive. + If ``orientationNumber`` is less than zero or greater than five. + """ + if orientationNumber < 0 or orientationNumber > 5: + raise ValueError( + f"Orientation number must be in [0:5], got {orientationNumber}" + ) + if initialCellIndex > 1: + if orientationNumber == 0: + return initialCellIndex + ring = numRingsToHoldNumCells(initialCellIndex) + tot_pins = totalPositionsUpToRing(ring) + newPinLocation = initialCellIndex + (ring - 1) * orientationNumber + if newPinLocation > tot_pins: + newPinLocation -= (ring - 1) * 6 + return newPinLocation + elif initialCellIndex == 1: + return initialCellIndex + raise ValueError(f"Cell number must be positive, got {initialCellIndex}") diff --git a/armi/utils/iterables.py b/armi/utils/iterables.py index 8f6720410..ce204aa0a 100644 --- a/armi/utils/iterables.py +++ b/armi/utils/iterables.py @@ -18,6 +18,8 @@ from six.moves import filterfalse, map, xrange, filter +import numpy as np + def flatten(lst): """Flattens an iterable of iterables by one level. @@ -241,3 +243,27 @@ def __add__(self, other): def __iadd__(self, other): self.extend(Sequence(other)) return self + + +def pivot(items, position: int): + """Pivot the items in an iterable to start at a given position. + + Functionally just ``items[position:] + items[:position]`` with + some logic to handle numpy arrays (concatenation not summation) + + Parameters + ---------- + items : list or numpy.ndarray + Sequence to be re-ordered + position : int + Position that will be the first item in the sequence after the pivot + + Returns + ------- + list or numpy.ndarray + """ + if isinstance(items, np.ndarray): + return np.concatenate((items[position:], items[:position])) + elif isinstance(items, list): + return items[position:] + items[:position] + raise TypeError(f"Pivoting {type(items)} not supported : {items}") diff --git a/armi/utils/tests/test_hexagon.py b/armi/utils/tests/test_hexagon.py index ea0873f87..c3ea046e4 100644 --- a/armi/utils/tests/test_hexagon.py +++ b/armi/utils/tests/test_hexagon.py @@ -13,12 +13,16 @@ # limitations under the License. """Test hexagon tools.""" import math +import random import unittest from armi.utils import hexagon class TestHexagon(unittest.TestCase): + N_FUZZY_DRAWS: int = 10 + """Number of random draws to use in some fuzzy testing""" + def test_hexagon_area(self): """ Area of a hexagon. @@ -43,3 +47,90 @@ def test_numPositionsInRing(self): self.assertEqual(hexagon.numPositionsInRing(2), 6) self.assertEqual(hexagon.numPositionsInRing(3), 12) self.assertEqual(hexagon.numPositionsInRing(4), 18) + + def test_rotatedCellCenter(self): + """Test that location of the center cell is invariant through rotation.""" + for rot in range(6): + self.assertTrue(hexagon.getIndexOfRotatedCell(1, rot), 1) + + def test_rotatedFirstRing(self): + """Simple test for the corners of the first ring are maintained during rotation.""" + # A 60 degree rotation is just incrementing the cell index by one here + locations = list(range(2, 8)) + for locIndex, initialPosition in enumerate(locations): + for rot in range(6): + actual = hexagon.getIndexOfRotatedCell(initialPosition, rot) + newIndex = (locIndex + rot) % 6 + expectedPosition = locations[newIndex] + self.assertEqual( + actual, expectedPosition, msg=f"{initialPosition=}, {rot=}" + ) + + def test_rotateFuzzy(self): + """Select some position number and rotation and check for consistency.""" + N_DRAWS = 100 + for _ in range(N_DRAWS): + self._rotateFuzzyInner() + + def _rotateFuzzyInner(self): + rot = random.randint(1, 5) + initialCell = random.randint(2, 300) + testInfoMsg = f"{rot=}, {initialCell=}" + newCell = hexagon.getIndexOfRotatedCell(initialCell, rot) + self.assertNotEqual(newCell, initialCell, msg=testInfoMsg) + # should be in the same ring + initialRing = hexagon.numRingsToHoldNumCells(initialCell) + newRing = hexagon.numRingsToHoldNumCells(newCell) + self.assertEqual(newRing, initialRing, msg=testInfoMsg) + # If we un-rotate, we should get our initial cell + reverseRot = (6 - rot) % 6 + reverseCell = hexagon.getIndexOfRotatedCell(newCell, reverseRot) + self.assertEqual(reverseCell, initialCell, msg=testInfoMsg) + + def test_positionsUpToRing(self): + """Test totalPositionsUpToRing is consistent with numPositionsInRing.""" + self.assertEqual(hexagon.totalPositionsUpToRing(1), 1) + self.assertEqual(hexagon.totalPositionsUpToRing(2), 7) + self.assertEqual(hexagon.totalPositionsUpToRing(3), 19) + + totalPositions = 19 + for ring in range(4, 30): + posInThisRing = hexagon.numPositionsInRing(ring) + totalPositions += posInThisRing + self.assertEqual( + hexagon.totalPositionsUpToRing(ring), totalPositions, msg=f"{ring=}" + ) + + def test_rotatedCellIndexErrors(self): + """Test errors for non-positive initial cell indices during rotation.""" + self._testNonPosRotIndex(0) + for _ in range(self.N_FUZZY_DRAWS): + index = random.randint(-100, -1) + self._testNonPosRotIndex(index) + + def _testNonPosRotIndex(self, index: int): + with self.assertRaisesRegex(ValueError, ".*must be positive", msg=f"{index=}"): + hexagon.getIndexOfRotatedCell(index, 0) + + def test_rotatedCellOrientationErrors(self): + """Test errors for invalid orientation numbers during rotation.""" + for _ in range(self.N_FUZZY_DRAWS): + upper = random.randint(6, 100) + self._testRotOrientation(upper) + lower = random.randint(-100, -1) + self._testRotOrientation(lower) + + def _testRotOrientation(self, orientation: int): + with self.assertRaisesRegex( + ValueError, "Orientation number", msg=f"{orientation=}" + ): + hexagon.getIndexOfRotatedCell( + initialCellIndex=1, orientationNumber=orientation + ) + + def test_indexWithNoRotation(self): + """Test that the initial cell location is returned if not rotated.""" + for _ in range(self.N_FUZZY_DRAWS): + ix = random.randint(1, 300) + postRotation = hexagon.getIndexOfRotatedCell(ix, orientationNumber=0) + self.assertEqual(postRotation, ix) diff --git a/armi/utils/tests/test_iterables.py b/armi/utils/tests/test_iterables.py index e681cd0ed..f1ebcb8e0 100644 --- a/armi/utils/tests/test_iterables.py +++ b/armi/utils/tests/test_iterables.py @@ -16,6 +16,8 @@ import time import unittest +import numpy as np + from armi.utils import iterables # CONSTANTS @@ -174,3 +176,21 @@ def test_addingSequences(self): self.assertEqual(vals[0], 0) self.assertEqual(vals[-1], 5) self.assertEqual(len(vals), 6) + + def test_listPivot(self): + data = list(range(10)) + loc = 4 + actual = iterables.pivot(data, loc) + self.assertEqual(actual, data[loc:] + data[:loc]) + + def test_arrayPivot(self): + data = np.arange(10) + loc = -7 + actual = iterables.pivot(data, loc) + expected = np.array(iterables.pivot(data.tolist(), loc)) + self.assertTrue((actual == expected).all(), msg=f"{actual=} != {expected=}") + # Catch a silent failure case where pivot doesn't change the iterable + self.assertTrue( + (actual != data).all(), + msg=f"Pre-pivot {data=} should not equal post-pivot {actual=}", + ) diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst index 16a35ebbb..700d90bdc 100644 --- a/doc/release/0.4.rst +++ b/doc/release/0.4.rst @@ -11,6 +11,9 @@ New Features #. ARMI now supports Python 3.12. (`PR#1813 `_) #. Removing the ``tabulate`` dependency by ingesting it to ``armi.utils.tabulate``. (`PR#1811 `_) #. Adding ``--skip-inspection`` flag to ``CompareCases`` CLI. (`PR#1842 `_) +#. Provide utilities for determining location of a rotated object in a hexagonal lattice: + :func:`armi.utils.hexagon.getIndexOfRotatedCell` + (`PR#1846 `_) #. TBD @@ -20,6 +23,9 @@ API Changes #. Removing deprecated method ``prepSearch``. (`PR#1845 `_) #. Removing unused function ``SkippingXsGen_BuChangedLessThanTolerance``. (`PR#1845 `_) #. Allow for unknown Flags when opening a DB. (`PR#1844 `_) +#. ``Assembly.rotatePins`` and ``Block.rotatePins`` have been removed. Prefer to use + :meth:`armi.reactor.assemblies.Assembly.rotate` and meth:`armi.reactor.block.Block.rotate` + (`PR#1846