Skip to content

Commit

Permalink
Removing Assembly.rotatePins, and making Block.rotatePins private (#1846
Browse files Browse the repository at this point in the history
)
  • Loading branch information
drewj-tp authored Sep 12, 2024
1 parent 57dc6fe commit 5a41b25
Show file tree
Hide file tree
Showing 11 changed files with 314 additions and 90 deletions.
55 changes: 35 additions & 20 deletions armi/physics/fuelCycle/assemblyRotationAlgorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,24 @@
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,
)
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
Expand All @@ -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.
Expand All @@ -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))
6 changes: 2 additions & 4 deletions armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down
34 changes: 22 additions & 12 deletions armi/reactor/assemblies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1248,20 +1243,16 @@ 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
----------
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):
Expand All @@ -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):
Expand Down
79 changes: 34 additions & 45 deletions armi/reactor/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <armi.reactor.blocks.HexBlock.rotatePins>`.
The pin indexing, as stored on the ``pinLocation`` parameter, is also updated.
Parameters
----------
Expand All @@ -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 <armi.reactor.blocks.HexBlock.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")
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions armi/reactor/tests/test_assemblies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 29 additions & 9 deletions armi/reactor/tests/test_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Loading

0 comments on commit 5a41b25

Please sign in to comment.