From f829b80608259bc3c1b5987405e7dbd218d3bd22 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 7 Oct 2024 14:21:05 -0700 Subject: [PATCH 01/48] Fix getOptimalAssemblyOrientation The algorithm goes as follows 1. Get all the pin powers and `IndexLocation`s from the block at the previous location 2. Obtain the `IndexLocation` of the pin with the highest burnup 3. For each possible rotation, - Find the new location with `HexGrid.rotateIndex` - Find the index where that location occurs in previous locations - Find the previous power at that location 4. Return the rotation with the lowest previous power This algorithm assumes a few things. 1. `len(pinLocations) == len(pinPowers)` in both cases. This may make sense, but we've found some cases where this assumption breaks. Not even edge cases, like the C5G7 LWR benchmark. 2. Your assembly has at least 60 degree symmetry of fuel pins and powers. This means if we find a fuel pin with high burnup and rotate it 60 degrees, there should be another fuel pin at that lattice site. This is mostly a safe assumption since many hexagonal reactors have at least 60 degree symmetry of fuel pin layout. This assumption holds if you have a full hexagonal lattice of fuel pins as well. --- .../fuelCycle/hexAssemblyFuelMgmtUtils.py | 189 ++++++++++-------- 1 file changed, 110 insertions(+), 79 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index 9afe2d4c8..6f2147891 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -19,108 +19,139 @@ ----- We are keeping these in ARMI even if they appear unused internally. """ +import typing import numpy as np from armi import runLog from armi.reactor.flags import Flags -from armi.utils.hexagon import getIndexOfRotatedCell from armi.utils.mathematics import findClosest +if typing.TYPE_CHECKING: + from armi.reactor.grids import IndexLocation + from armi.reactor.assemblies import HexAssembly + from armi.reactor.blocks import HexBlock -def getOptimalAssemblyOrientation(a, aPrev): + +def _getFuelBlockWithMostBurnup(a: "HexAssembly") -> "HexBlock": + candidateBlock = max(a, key=lambda b: b.p.percentBuMax) + if not candidateBlock.hasFlags(Flags.FUEL): + raise ValueError(f"Assembly {a} does not have any fuel blocks.") + if candidateBlock.p.linPowByPin is None: + raise ValueError( + f"Highest burnup block {candidateBlock} in {a} has no pin powers." + ) + if candidateBlock.spatialGrid is None: + raise ValueError( + f"No spatial grid on {candidateBlock}. Unable to find new locations" + ) + return candidateBlock + + +def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int: """ Get optimal assembly orientation/rotation to minimize peak burnup. - Notes - ----- - Works by placing the highest-BU pin in the location (of 6 possible locations) with lowest - expected pin power. We evaluated "expected pin power" based on the power distribution in - aPrev, which is the previous assembly located here. If aPrev has no pin detail, then we must use its - corner fast fluxes to make an estimate. - Parameters ---------- a : Assembly object The assembly that is being rotated. - aPrev : Assembly object The assembly that previously occupied this location (before the last shuffle). - - If the assembly "a" was not shuffled, then "aPrev" = "a". - - If "aPrev" has pin detail, then we will determine the orientation of "a" based on - the pin powers of "aPrev" when it was located here. - - If "aPrev" does NOT have pin detail, then we will determine the orientation of "a" based on - the corner fast fluxes in "aPrev" when it was located here. + If the assembly "a" was not shuffled, it's sufficient to pass ``a``. Returns ------- - rot : int - An integer from 0 to 5 representing the "orientation" of the assembly. - This orientation is relative to the current assembly orientation. - rot = 0 corresponds to no rotation. - rot represents the number of pi/3 counterclockwise rotations for the default orientation. + int + An integer from 0 to 5 representing the number of pi/3 (60 degree) counterclockwise + rotations from where ``a`` is currently oriented to the "optimal" orientation - Examples - -------- - >>> getOptimalAssemblyOrientation(a, aPrev) - 4 + Raises + ------ + ValueError + If there is insufficient information to determine the rotation of ``a``. This could + be due to a lack of fuel blocks or parameters like ``linPowByPin``. - See Also - -------- - rotateAssemblies : calls this to figure out how to rotate + Notes + ----- + Works by placing the highest-burnup pin in the location (of 6 possible locations) with lowest + expected pin power. We evaluated "expected pin power" based on the power distribution in + ``aPrev``, the previous assembly located where ``a`` is going. The algorithm goes as follows. + + 1. Get all the pin powers and ``IndexLocation``s from the block at the + previous location + 2. Obtain the ``IndexLocation`` of the pin with the highest burnup in the + current assembly. + 3. For each possible rotation, + - Find the new location with ``HexGrid.rotateIndex`` + - Find the index where that location occurs in previous locations + - Find the previous power at that location + 4. Return the rotation with the lowest previous power + + This algorithm assumes a few things. + + 1. ``len(HexBlock.getPinCoordinates()) == len(HexBlock.p.linPowByPin)`` and, + by extension, ``linPowByPin[i]`` is found at ``getPinCoordinates()[i]``. + 2. Your assembly has at least 60 degree symmetry of fuel pins and + powers. This means if we find a fuel pin and rotate it 60 degrees, there should + be another fuel pin at that lattice site. This is mostly a safe assumption + since many hexagonal reactors have at least 60 degree symmetry of fuel pin layout. + This assumption holds if you have a full hexagonal lattice of fuel pins as well. + 3. Fuel pins in ``a`` have similar locations in ``aPrev``. This is mostly a safe + assumption in that most fuel assemblies have similar layouts so it's plausible + that if ``a`` has a fuel pin at ``(1, 0, 0)``, so does ``aPrev``. """ - # determine whether or not aPrev had pin details - fuelPrev = aPrev.getFirstBlock(Flags.FUEL) - if fuelPrev: - aPrevDetailFlag = fuelPrev.p.pinLocation[4] is not None + maxBuBlock = _getFuelBlockWithMostBurnup(a) + maxBuPinLocation = _maxBuPinLocation(maxBuBlock) + # No need to rotate if max burnup pin is the center + if maxBuPinLocation.i == 0 and maxBuPinLocation.j == 0: + return 0 + + if aPrev is not a: + blockAtPreviousLocation = aPrev[a.index(maxBuBlock)] else: - aPrevDetailFlag = False - - rot = 0 # default: no rotation - # First get pin index of maximum BU in this assembly. - _maxBuAssem, maxBuBlock = a.getMaxParam("percentBuMax", returnObj=True) - if maxBuBlock is None: - # no max block. They're all probably zero - return rot - - # start at 0 instead of 1 - maxBuPinIndexAssem = int(maxBuBlock.p.percentBuMaxPinLocation - 1) - bIndexMaxBu = a.index(maxBuBlock) - - if maxBuPinIndexAssem == 0: - # Don't bother rotating if the highest-BU pin is the central pin. End this method. - return rot - else: - # transfer percentBuMax rotated pin index to non-rotated pin index - if aPrevDetailFlag: - # aPrev has pin detail - # Determine which of 6 possible rotated pin indices had the lowest power when aPrev was here. - prevAssemPowHereMIN = float("inf") - - for possibleRotation in range(6): - 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] - - if prevAssemPowHere is not None: - runLog.debug( - "Previous power in rotation {0} where pinLoc={1} is {2:.4E} W/cm" - "".format(possibleRotation, index, prevAssemPowHere) - ) - if prevAssemPowHere < prevAssemPowHereMIN: - prevAssemPowHereMIN = prevAssemPowHere - rot = possibleRotation - else: - raise ValueError( - "Cannot perform detailed rotation analysis without pin-level " - "flux information." - ) - - runLog.debug("Best relative rotation is {0}".format(rot)) - return rot + blockAtPreviousLocation = maxBuBlock + + previousLocations = blockAtPreviousLocation.getPinLocations() + previousPowers = blockAtPreviousLocation.p.linPowByPin + _checkConsistentPinPowerAndLocations( + blockAtPreviousLocation, previousPowers, previousLocations + ) + + currentGrid = maxBuBlock.spatialGrid + candidateRotation = 0 + candidatePower = previousPowers[previousLocations.index(maxBuPinLocation)] + for rot in range(1, 6): + candidateLocation = currentGrid.rotateIndex(maxBuPinLocation, rot) + newLocationIndex = previousLocations.index(candidateLocation) + newPower = previousPowers[newLocationIndex] + if newPower < candidatePower: + candidateRotation = rot + candidatePower = newPower + return candidateRotation + + +def _maxBuPinLocation(maxBuBlock: "HexBlock") -> "IndexLocation": + """Find the grid position for the highest burnup pin. + + percentBuMaxPinLocation corresponds to the "pin number" which is one indexed + and can be found at ``maxBuBlock.getPinLocations()[pinNumber - 1]`` + """ + buMaxPinNumber = maxBuBlock.p.percentBuMaxPinLocation + pinPowers = maxBuBlock.p.linPowByPin + pinLocations = maxBuBlock.getPinLocations() + _checkConsistentPinPowerAndLocations(maxBuBlock, pinPowers, pinLocations) + maxBuPinLocation = pinLocations[buMaxPinNumber - 1] + return maxBuPinLocation + + +def _checkConsistentPinPowerAndLocations( + block: "HexBlock", pinPowers: list[float], pinLocations: list["IndexLocation"] +): + if len(pinLocations) != len(pinPowers): + raise ValueError( + f"Inconsistent pin powers and number of pins in {block}. Found " + f"{len(pinLocations)} locations but {len(pinPowers)} powers." + ) def buildRingSchedule( From 3520838ea5fdb699ab72e36401d46c8790885a4e Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 11 Oct 2024 15:45:16 -0700 Subject: [PATCH 02/48] Expand test_oppositeRotation to all six configurations --- .../tests/test_assemblyRotationAlgorithms.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index bbbdec230..11e599981 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -19,7 +19,13 @@ These algorithms are defined in assemblyRotationAlgorithms.py, but they are used in: ``FuelHandler.outage()``. """ +from unittest import mock +import numpy as np + from armi.physics.fuelCycle import assemblyRotationAlgorithms as rotAlgos +from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import ( + getOptimalAssemblyOrientation, +) from armi.physics.fuelCycle import fuelHandlers from armi.physics.fuelCycle.settings import CONF_ASSEM_ROTATION_STATIONARY from armi.physics.fuelCycle.tests.test_fuelHandlers import addSomeDetailAssemblies @@ -27,6 +33,60 @@ from armi.reactor.flags import Flags +class TestOptimalAssemblyRotation(FuelHandlerTestHelper): + N_PINS = 271 + + def prepBlocks(self, percentBuMaxPinLocation: int, pinPowers: list[float]): + """Assign basic information to the blocks so we can rotate them. + + Parameters + ---------- + percentBuMaxPinLocation : int + ARMI pin number, 1-indexed, for the pin with the highest burnup + pinPowers : list[float] + Powers in each pin + """ + for b in self.assembly.getBlocks(Flags.FUEL): + b.p.percentBuMax = 5 + # Fake enough behavior so we can make a spatial grid + b.getPinPitch = mock.Mock(return_value=1.1) + b.autoCreateSpatialGrids() + b.p.percentBuMaxPinLocation = percentBuMaxPinLocation + b.p.linPowByPin = pinPowers + + + def test_flatPowerNoRotation(self): + """If all pin powers are identical, no rotation is suggested.""" + powers = np.ones(self.N_PINS) + # Identical powers but _some_ non-central "max" burnup pin + self.prepBlocks(percentBuMaxPinLocation=8, pinPowers=powers) + rot = getOptimalAssemblyOrientation(self.assembly, self.assembly) + self.assertEqual(rot, 0) + + def test_maxBurnupAtCenterNoRotation(self): + """If max burnup pin is at the center, no rotation is suggested.""" + # Fake a higher power towards the center + powers = np.arange(self.N_PINS)[::-1] + self.prepBlocks(percentBuMaxPinLocation=1, pinPowers=powers) + rot = getOptimalAssemblyOrientation(self.assembly, self.assembly) + self.assertEqual(rot, 0) + + def test_oppositeRotation(self): + """Test a 180 degree rotation is suggested when the max burnup pin is opposite the lowest power pin. + + Use the second ring of the hexagon because it's easier to write out pin locations + and check work. + """ + for startPin, oppositePin in ((2, 5), (3, 6), (4, 7), (5, 2), (6, 3), (7, 4)): + powers = np.ones(self.N_PINS) + powers[startPin - 1] *= 2 + powers[oppositePin - 1] = 0 + self.prepBlocks(percentBuMaxPinLocation=startPin, pinPowers=powers) + rot = getOptimalAssemblyOrientation(self.assembly, self.assembly) + # 180 degrees is three 60 degree rotations + self.assertEqual(rot, 3, msg=f"{startPin=} :: {oppositePin=}") + + class TestFuelHandlerMgmtTools(FuelHandlerTestHelper): def test_buReducingAssemblyRotation(self): fh = fuelHandlers.FuelHandler(self.o) From c39e2aa86131ff22153979000e6c10bcf3c3fb08 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 11 Oct 2024 16:24:46 -0700 Subject: [PATCH 03/48] Update optimal assembly rotation to more robustly handle and test different assemblies --- .../fuelCycle/hexAssemblyFuelMgmtUtils.py | 38 +++++++--------- .../tests/test_assemblyRotationAlgorithms.py | 43 +++++++++++-------- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index 6f2147891..b7d747023 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -19,11 +19,12 @@ ----- We are keeping these in ARMI even if they appear unused internally. """ +import math import typing + import numpy as np from armi import runLog -from armi.reactor.flags import Flags from armi.utils.mathematics import findClosest if typing.TYPE_CHECKING: @@ -32,21 +33,6 @@ from armi.reactor.blocks import HexBlock -def _getFuelBlockWithMostBurnup(a: "HexAssembly") -> "HexBlock": - candidateBlock = max(a, key=lambda b: b.p.percentBuMax) - if not candidateBlock.hasFlags(Flags.FUEL): - raise ValueError(f"Assembly {a} does not have any fuel blocks.") - if candidateBlock.p.linPowByPin is None: - raise ValueError( - f"Highest burnup block {candidateBlock} in {a} has no pin powers." - ) - if candidateBlock.spatialGrid is None: - raise ValueError( - f"No spatial grid on {candidateBlock}. Unable to find new locations" - ) - return candidateBlock - - def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int: """ Get optimal assembly orientation/rotation to minimize peak burnup. @@ -100,7 +86,7 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int assumption in that most fuel assemblies have similar layouts so it's plausible that if ``a`` has a fuel pin at ``(1, 0, 0)``, so does ``aPrev``. """ - maxBuBlock = _getFuelBlockWithMostBurnup(a) + maxBuBlock = max(a, key=lambda b: b.p.percentBuMax) maxBuPinLocation = _maxBuPinLocation(maxBuBlock) # No need to rotate if max burnup pin is the center if maxBuPinLocation.i == 0 and maxBuPinLocation.j == 0: @@ -117,11 +103,17 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int blockAtPreviousLocation, previousPowers, previousLocations ) - currentGrid = maxBuBlock.spatialGrid + targetGrid = blockAtPreviousLocation.spatialGrid candidateRotation = 0 - candidatePower = previousPowers[previousLocations.index(maxBuPinLocation)] - for rot in range(1, 6): - candidateLocation = currentGrid.rotateIndex(maxBuPinLocation, rot) + candidatePower = math.inf + for rot in range(6): + # We need to "rotate" even for rot=0 (location should be the same) + # because IndexLocation comparison requires the grids to be the same + # object, and the grid attribute on the location returned from + # grid.rotateIndex is grid. If we don't, even though location + # (i, j, k) exists in the previous location, if it isn't on literally + # the same grid, the index call fails. + candidateLocation = targetGrid.rotateIndex(maxBuPinLocation, rot) newLocationIndex = previousLocations.index(candidateLocation) newPower = previousPowers[newLocationIndex] if newPower < candidatePower: @@ -137,9 +129,9 @@ def _maxBuPinLocation(maxBuBlock: "HexBlock") -> "IndexLocation": and can be found at ``maxBuBlock.getPinLocations()[pinNumber - 1]`` """ buMaxPinNumber = maxBuBlock.p.percentBuMaxPinLocation - pinPowers = maxBuBlock.p.linPowByPin pinLocations = maxBuBlock.getPinLocations() - _checkConsistentPinPowerAndLocations(maxBuBlock, pinPowers, pinLocations) + if not pinLocations: + raise ValueError(f"{maxBuBlock} does not have pin locations.") maxBuPinLocation = pinLocations[buMaxPinNumber - 1] return maxBuPinLocation diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index 11e599981..091342580 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -19,9 +19,12 @@ These algorithms are defined in assemblyRotationAlgorithms.py, but they are used in: ``FuelHandler.outage()``. """ +import copy from unittest import mock + import numpy as np +from armi.reactor.assemblies import HexAssembly from armi.physics.fuelCycle import assemblyRotationAlgorithms as rotAlgos from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import ( getOptimalAssemblyOrientation, @@ -36,30 +39,32 @@ class TestOptimalAssemblyRotation(FuelHandlerTestHelper): N_PINS = 271 - def prepBlocks(self, percentBuMaxPinLocation: int, pinPowers: list[float]): - """Assign basic information to the blocks so we can rotate them. - - Parameters - ---------- - percentBuMaxPinLocation : int - ARMI pin number, 1-indexed, for the pin with the highest burnup - pinPowers : list[float] - Powers in each pin - """ - for b in self.assembly.getBlocks(Flags.FUEL): + @staticmethod + def prepShuffledAssembly(a: HexAssembly, percentBuMaxPinLocation: int): + """Prepare the assembly that will be shuffled and rotated.""" + for b in a.getChildrenWithFlags(Flags.FUEL): + # Fake some maximum burnup b.p.percentBuMax = 5 - # Fake enough behavior so we can make a spatial grid + # Fake enough information to build a spatial grid b.getPinPitch = mock.Mock(return_value=1.1) b.autoCreateSpatialGrids() b.p.percentBuMaxPinLocation = percentBuMaxPinLocation - b.p.linPowByPin = pinPowers + @staticmethod + def prepPreviousAssembly(a: HexAssembly, pinPowers: list[float]): + """Prep the assembly that existed at the site a shuffled assembly will occupy.""" + for b in a.getChildrenWithFlags(Flags.FUEL): + # Fake enough information to build a spatial grid + b.getPinPitch = mock.Mock(return_value=1.1) + b.autoCreateSpatialGrids() + b.p.linPowByPin = pinPowers def test_flatPowerNoRotation(self): """If all pin powers are identical, no rotation is suggested.""" powers = np.ones(self.N_PINS) # Identical powers but _some_ non-central "max" burnup pin - self.prepBlocks(percentBuMaxPinLocation=8, pinPowers=powers) + self.prepShuffledAssembly(self.assembly, percentBuMaxPinLocation=8) + self.prepPreviousAssembly(self.assembly, powers) rot = getOptimalAssemblyOrientation(self.assembly, self.assembly) self.assertEqual(rot, 0) @@ -67,7 +72,8 @@ def test_maxBurnupAtCenterNoRotation(self): """If max burnup pin is at the center, no rotation is suggested.""" # Fake a higher power towards the center powers = np.arange(self.N_PINS)[::-1] - self.prepBlocks(percentBuMaxPinLocation=1, pinPowers=powers) + self.prepShuffledAssembly(self.assembly, percentBuMaxPinLocation=1) + self.prepPreviousAssembly(self.assembly, powers) rot = getOptimalAssemblyOrientation(self.assembly, self.assembly) self.assertEqual(rot, 0) @@ -77,12 +83,15 @@ def test_oppositeRotation(self): Use the second ring of the hexagon because it's easier to write out pin locations and check work. """ + shuffledAssembly = self.assembly + previousAssembly = copy.deepcopy(shuffledAssembly) for startPin, oppositePin in ((2, 5), (3, 6), (4, 7), (5, 2), (6, 3), (7, 4)): powers = np.ones(self.N_PINS) powers[startPin - 1] *= 2 powers[oppositePin - 1] = 0 - self.prepBlocks(percentBuMaxPinLocation=startPin, pinPowers=powers) - rot = getOptimalAssemblyOrientation(self.assembly, self.assembly) + self.prepShuffledAssembly(shuffledAssembly, startPin) + self.prepPreviousAssembly(previousAssembly, powers) + rot = getOptimalAssemblyOrientation(shuffledAssembly, previousAssembly) # 180 degrees is three 60 degree rotations self.assertEqual(rot, 3, msg=f"{startPin=} :: {oppositePin=}") From bd3f5fc115c3ec2fa90ae43dd691f0ccb60d0fab Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 11 Oct 2024 16:29:53 -0700 Subject: [PATCH 04/48] Require shuffled block to have a spatial grid when rotating --- armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index b7d747023..ed6b81251 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -87,6 +87,10 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int that if ``a`` has a fuel pin at ``(1, 0, 0)``, so does ``aPrev``. """ maxBuBlock = max(a, key=lambda b: b.p.percentBuMax) + if maxBuBlock.spatialGrid is None: + raise ValueError( + f"Block {maxBuBlock} in {a} does not have a spatial grid. Cannot rotate." + ) maxBuPinLocation = _maxBuPinLocation(maxBuBlock) # No need to rotate if max burnup pin is the center if maxBuPinLocation.i == 0 and maxBuPinLocation.j == 0: @@ -130,8 +134,6 @@ def _maxBuPinLocation(maxBuBlock: "HexBlock") -> "IndexLocation": """ buMaxPinNumber = maxBuBlock.p.percentBuMaxPinLocation pinLocations = maxBuBlock.getPinLocations() - if not pinLocations: - raise ValueError(f"{maxBuBlock} does not have pin locations.") maxBuPinLocation = pinLocations[buMaxPinNumber - 1] return maxBuPinLocation From f3dfc505d3a7b759b507497896cfa7f19bc1ee67 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 11 Oct 2024 16:33:07 -0700 Subject: [PATCH 05/48] Test requirement on optimal rotation needing consistent pin powers and pin locations --- .../fuelCycle/hexAssemblyFuelMgmtUtils.py | 18 +++++------------- .../tests/test_assemblyRotationAlgorithms.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index ed6b81251..96deed20b 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -103,9 +103,11 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int previousLocations = blockAtPreviousLocation.getPinLocations() previousPowers = blockAtPreviousLocation.p.linPowByPin - _checkConsistentPinPowerAndLocations( - blockAtPreviousLocation, previousPowers, previousLocations - ) + if len(previousLocations) != len(previousPowers): + raise ValueError( + f"Inconsistent pin powers and number of pins in {blockAtPreviousLocation}. " + f"Found {len(previousLocations)} locations but {len(previousPowers)} powers." + ) targetGrid = blockAtPreviousLocation.spatialGrid candidateRotation = 0 @@ -138,16 +140,6 @@ def _maxBuPinLocation(maxBuBlock: "HexBlock") -> "IndexLocation": return maxBuPinLocation -def _checkConsistentPinPowerAndLocations( - block: "HexBlock", pinPowers: list[float], pinLocations: list["IndexLocation"] -): - if len(pinLocations) != len(pinPowers): - raise ValueError( - f"Inconsistent pin powers and number of pins in {block}. Found " - f"{len(pinLocations)} locations but {len(pinPowers)} powers." - ) - - def buildRingSchedule( maxRingInCore, chargeRing=None, diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index 091342580..e0950305f 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -95,6 +95,21 @@ def test_oppositeRotation(self): # 180 degrees is three 60 degree rotations self.assertEqual(rot, 3, msg=f"{startPin=} :: {oppositePin=}") + def test_noGridOnShuffledBlock(self): + """Require a spatial grid on the shuffled block.""" + with self.assertRaisesRegex(ValueError, "spatial grid"): + getOptimalAssemblyOrientation(self.assembly, self.assembly) + + def test_mismatchPinPowersAndLocations(self): + """Require pin powers and locations to be have the same length.""" + powers = np.arange(self.N_PINS + 1) + self.prepShuffledAssembly(self.assembly, percentBuMaxPinLocation=4) + self.prepPreviousAssembly(self.assembly, powers) + with self.assertRaisesRegex( + ValueError, "Inconsistent pin powers and number of pins" + ): + getOptimalAssemblyOrientation(self.assembly, self.assembly) + class TestFuelHandlerMgmtTools(FuelHandlerTestHelper): def test_buReducingAssemblyRotation(self): From 3b8ef7614cf3cb3d3924730bd509a42fe34a5d48 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 11 Oct 2024 16:41:56 -0700 Subject: [PATCH 06/48] Fuel handler rotation test points to mocked optimal rotation The current test just showed the orientations were different. Which maybe isn't what we want. Instead, use a patch to show that we call the getOptimalAssemblyOrientation with the assembly of interest, and delegate the testing on the accuracy of that function to more targeted unit tests. --- .../tests/test_assemblyRotationAlgorithms.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index e0950305f..2b1203063 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -127,9 +127,14 @@ def test_buReducingAssemblyRotation(self): b.p.linPowByPin = list(reversed(range(b.getNumPins()))) addSomeDetailAssemblies(hist, [assem]) - rotNum = b.getRotationNum() - rotAlgos.buReducingAssemblyRotation(fh) - self.assertNotEqual(b.getRotationNum(), rotNum) + # Show that we call the optimal assembly orientation function. + # This function is tested seperately and more extensively elsewhere. + with mock.patch( + "armi.physics.fuelCycle.assemblyRotationAlgorithms.getOptimalAssemblyOrientation", + return_value=0, + ) as p: + rotAlgos.buReducingAssemblyRotation(fh) + p.assert_called_once_with(assem, assem) def test_simpleAssemblyRotation(self): """Test rotating assemblies 120 degrees.""" From ec55273b010afd95194df671ba6c432bac2057cb Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 11 Oct 2024 16:48:49 -0700 Subject: [PATCH 07/48] Add requirement test and implementation notes for equalizing burnup rotation --- armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py | 4 ++++ .../fuelCycle/tests/test_assemblyRotationAlgorithms.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index 96deed20b..e3345be3f 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -37,6 +37,10 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int """ Get optimal assembly orientation/rotation to minimize peak burnup. + .. impl:: Provide an algoritm for rotating hexagonal assemblies to equalize burnup + :id: I_ARMI_ROTATE_HEX_BURNUP + :implements: R_ARMI_ROTATE_HEX_BURNUP + Parameters ---------- a : Assembly object diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index 2b1203063..a20fbfca8 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -82,6 +82,12 @@ def test_oppositeRotation(self): Use the second ring of the hexagon because it's easier to write out pin locations and check work. + + .. test:: Test the burnup equalizing rotation algorithm. + :id: T_ARMI_ROTATE_HEX_BURNUP + :tests: R_ARMI_ROTATE_HEX_BURNUP + :acceptance_criteria: After rotating a hexagonal assembly, confirm the pin with the highest burnup is + in the same sector as pin with the lowest power in the high burnup pin's ring. """ shuffledAssembly = self.assembly previousAssembly = copy.deepcopy(shuffledAssembly) From ef2e6fc921ca40d2833679fe1f240acf58920cd1 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 17 Oct 2024 15:20:02 -0700 Subject: [PATCH 08/48] Use dict of ij to powers in optimal assembly orientation The previous list approach, using the index method, got a little finicky depending on how `IndexLocation.__eq__` is implemented and on who's grid the location returned from `HexGrid.rotateIndex` belongs. So now just make a dictionary mapping `{(i, j): p}` for each location in the previous block. We could go a little more optimal and only store locations if they're in the same ring as the pin with the most burnup. But then we have to pass each location through `HexGrid.getRingPos`. And, at most we have maybe a few dozen entries per ring vs maybe 200 in a full hexagon. Splitting hairs but it may be useful to keep in mind. --- .../fuelCycle/hexAssemblyFuelMgmtUtils.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index e3345be3f..0e18e46a3 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -113,19 +113,16 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int f"Found {len(previousLocations)} locations but {len(previousPowers)} powers." ) + ringPowers = { + (loc.i, loc.j): p for loc, p in zip(previousLocations, previousPowers) + } + targetGrid = blockAtPreviousLocation.spatialGrid candidateRotation = 0 - candidatePower = math.inf - for rot in range(6): - # We need to "rotate" even for rot=0 (location should be the same) - # because IndexLocation comparison requires the grids to be the same - # object, and the grid attribute on the location returned from - # grid.rotateIndex is grid. If we don't, even though location - # (i, j, k) exists in the previous location, if it isn't on literally - # the same grid, the index call fails. + candidatePower = ringPowers.get((maxBuPinLocation.i, maxBuPinLocation.j), math.inf) + for rot in range(1, 6): candidateLocation = targetGrid.rotateIndex(maxBuPinLocation, rot) - newLocationIndex = previousLocations.index(candidateLocation) - newPower = previousPowers[newLocationIndex] + newPower = ringPowers.get((candidateLocation.i, candidateLocation.j), math.inf) if newPower < candidatePower: candidateRotation = rot candidatePower = newPower From b0db853e2a03f9c6580d1e326cb753f07675467f Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 28 Oct 2024 09:10:20 -0700 Subject: [PATCH 09/48] Add comment to confusing fuel handlers test Why is this even here? --- armi/physics/fuelCycle/tests/test_fuelHandlers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/armi/physics/fuelCycle/tests/test_fuelHandlers.py b/armi/physics/fuelCycle/tests/test_fuelHandlers.py index c4aca76a7..5a8d16b5d 100644 --- a/armi/physics/fuelCycle/tests/test_fuelHandlers.py +++ b/armi/physics/fuelCycle/tests/test_fuelHandlers.py @@ -121,6 +121,7 @@ def setUp(self): self.refAssembly = copy.deepcopy(self.assembly) self.directoryChanger.open() + self.r.core.locateAllAssemblies() def tearDown(self): # clean up the test @@ -222,6 +223,8 @@ def test_outage(self, mockChooseSwaps): self.assertEqual(len(fh.moved), 0) def test_outageEdgeCase(self): + """Check that an error is raised if the list of moved assemblies is invalid.""" + class MockFH(fuelHandlers.FuelHandler): def chooseSwaps(self, factor=1.0): self.moved = [None] From 527226740a6c9627d1ea51f75684ac565418e971 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 22 Oct 2024 13:31:58 -0700 Subject: [PATCH 10/48] Cleaner handling of different rotation signatures The previous logic of ```python try: rotationMethod() except: rotationMethod(self) ``` would lead to confusing tracebacks if the second call also produced an error. --- armi/physics/fuelCycle/fuelHandlers.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py index 0c1f8c452..5f814a5ea 100644 --- a/armi/physics/fuelCycle/fuelHandlers.py +++ b/armi/physics/fuelCycle/fuelHandlers.py @@ -25,9 +25,10 @@ This module also handles repeat shuffles when doing a restart. """ # ruff: noqa: F401 +import inspect import os import re -import warnings + import numpy as np @@ -114,10 +115,7 @@ def outage(self, factor=1.0): # The user can choose the algorithm method name directly in the settings if hasattr(rotAlgos, self.cs[CONF_ASSEMBLY_ROTATION_ALG]): rotationMethod = getattr(rotAlgos, self.cs[CONF_ASSEMBLY_ROTATION_ALG]) - try: - rotationMethod() - except TypeError: - rotationMethod(self) + self._tryCallAssemblyRotation(rotationMethod) else: raise RuntimeError( "FuelHandler {0} does not have a rotation algorithm called {1}.\n" @@ -166,6 +164,15 @@ def outage(self, factor=1.0): self.moved = [] return moved + def _tryCallAssemblyRotation(self, rotationMethod): + """Determine the best way to call the rotation algorithm. + + Some callers take no arguments, some take the fuel handler. + """ + sig = inspect.signature(rotationMethod) + args = (self,) if sig.parameters else () + rotationMethod(*args) + def chooseSwaps(self, shuffleFactors=None): """Moves the fuel around or otherwise processes it between cycles.""" raise NotImplementedError From a0bb7ee26476e2b81b63320fe3a4089f6ffb3f6d Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 22 Oct 2024 13:44:19 -0700 Subject: [PATCH 11/48] buReducingAssemblyRotation handles fresh assemblies better In some testing, I found that freshly created assemblies caused problems with the burnup reducing rotation algorithm. They didn't have a useful previous location, in that their `lastLocationLabel` was `LoadQueue` since they had yet to be placed within the core. This caused problems trying to find the assembly that now exists where they used to be because the logic for breaking an assembly locator label like `001-003` breaks when that label is `LoadQueue`. The fuel handler tests now work higher up on the `FuelHandler.outage` method that invokes the `buReducingAssemblyRotation` function. In the case of the fresh fuel, we fake a case where an assembly is created fresh and dropped into the reactor without moving another assembly. `FuelHandler.moved` only really makes sense for `N>1` entries if you're swapping or disharging assemblies. But, we want to show that we skip assemblies that are coming from outside the reactor. --- .../fuelCycle/assemblyRotationAlgorithms.py | 21 ++++---- .../tests/test_assemblyRotationAlgorithms.py | 48 +++++++++++++++++-- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py index 30c87ba2e..db205908f 100644 --- a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py @@ -24,6 +24,9 @@ import math from armi import runLog +from armi.reactor.assemblies import Assembly + +# from armi.physics.fuelCycle.fuelHandlers import FuelHandler from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import ( getOptimalAssemblyOrientation, ) @@ -51,15 +54,20 @@ def buReducingAssemblyRotation(fh): runLog.info("Algorithmically rotating assemblies to minimize burnup") numRotated = 0 hist = fh.o.getInterface("history") - for aPrev in fh.moved: # much more convenient to loop through aPrev first + detailAssemblies = hist.getDetailAssemblies() + for aPrev in fh.moved: + # If the assembly was out of the core, no need to rotate an assembly + # that is outside the core. + if aPrev.lastLocationLabel in Assembly.NOT_IN_CORE: + continue aNow = fh.r.core.getAssemblyWithStringLocation(aPrev.lastLocationLabel) # no point in rotation if there's no pin detail - if aNow in hist.getDetailAssemblies(): + if aNow in detailAssemblies: _rotateByComparingLocations(aNow, aPrev) numRotated += 1 if fh.cs[CONF_ASSEM_ROTATION_STATIONARY]: - for a in hist.getDetailAssemblies(): + for a in detailAssemblies: if a not in fh.moved: _rotateByComparingLocations(a, a) numRotated += 1 @@ -67,7 +75,7 @@ def buReducingAssemblyRotation(fh): runLog.info("Rotated {0} assemblies".format(numRotated)) -def _rotateByComparingLocations(aNow, aPrev): +def _rotateByComparingLocations(aNow: Assembly, aPrev: Assembly): """Rotate an assembly based on its previous location. Parameters @@ -80,11 +88,8 @@ def _rotateByComparingLocations(aNow, aPrev): """ rot = getOptimalAssemblyOrientation(aNow, aPrev) radians = _rotationNumberToRadians(rot) + runLog.important(f"Rotating Assembly {aNow} {math.degrees(radians)} degrees CCW.") aNow.rotate(radians) - (ring, pos) = aNow.spatialLocator.getRingPos() - runLog.important( - "Rotating Assembly ({0},{1}) to Orientation {2}".format(ring, pos, rot) - ) def simpleAssemblyRotation(fh): diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index a20fbfca8..1cec55462 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -36,6 +36,13 @@ from armi.reactor.flags import Flags +class FullImplFuelHandler(fuelHandlers.FuelHandler): + """Implements the entire interface but with empty methods.""" + + def chooseSwaps(self, *args, **kwargs): + pass + + class TestOptimalAssemblyRotation(FuelHandlerTestHelper): N_PINS = 271 @@ -119,9 +126,15 @@ def test_mismatchPinPowersAndLocations(self): class TestFuelHandlerMgmtTools(FuelHandlerTestHelper): def test_buReducingAssemblyRotation(self): - fh = fuelHandlers.FuelHandler(self.o) + """Test that the fuel handler supports the burnup reducing assembly rotation.""" + fh = FullImplFuelHandler(self.o) + hist = self.o.getInterface("history") - newSettings = {CONF_ASSEM_ROTATION_STATIONARY: True} + newSettings = { + CONF_ASSEM_ROTATION_STATIONARY: True, + "fluxRecon": True, + "assemblyRotationAlgorithm": "buReducingAssemblyRotation", + } self.o.cs = self.o.cs.modified(newSettings=newSettings) assem = self.o.r.core.getFirstAssembly(Flags.FUEL) @@ -130,17 +143,42 @@ def test_buReducingAssemblyRotation(self): b.initializePinLocations() b.p.percentBuMaxPinLocation = 10 b.p.percentBuMax = 5 - b.p.linPowByPin = list(reversed(range(b.getNumPins()))) + b.p.linPowByPin = reversed(range(b.getNumPins())) addSomeDetailAssemblies(hist, [assem]) # Show that we call the optimal assembly orientation function. # This function is tested seperately and more extensively elsewhere. with mock.patch( "armi.physics.fuelCycle.assemblyRotationAlgorithms.getOptimalAssemblyOrientation", - return_value=0, + return_value=4, ) as p: - rotAlgos.buReducingAssemblyRotation(fh) + fh.outage(1) p.assert_called_once_with(assem, assem) + for b in assem.getBlocks(Flags.FUEL): + # Four rotations is 240 degrees + self.assertEqual(b.p.orientation[2], 240) + + def test_buRotationWithFreshFeed(self): + """Test that rotation works if a new assembly is swapped with fresh fuel. + + Fresh feed assemblies will not exist in the reactor, and various checks that + try to the "previous" assembly's location can fail. + """ + newSettings = { + CONF_ASSEM_ROTATION_STATIONARY: True, + "fluxRecon": True, + "assemblyRotationAlgorithm": "buReducingAssemblyRotation", + } + self.o.cs = self.o.cs.modified(newSettings=newSettings) + fresh = self.r.core.createFreshFeed(self.o.cs) + self.assertEqual(fresh.lastLocationLabel, HexAssembly.LOAD_QUEUE) + fh = FullImplFuelHandler(self.o) + with mock.patch( + "armi.physics.fuelCycle.assemblyRotationAlgorithms.getOptimalAssemblyOrientation", + ) as p: + fh.outage() + # The only moved assembly was most recently outside the core so we have no need to rotate + p.assert_not_called() def test_simpleAssemblyRotation(self): """Test rotating assemblies 120 degrees.""" From 3682c0d73101841c7ac19c1ad6b18dd405a3a9a7 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 22 Oct 2024 15:15:07 -0700 Subject: [PATCH 12/48] Remove commented out import from assemblyRotationAlgorithms --- armi/physics/fuelCycle/assemblyRotationAlgorithms.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py index db205908f..fa638c13b 100644 --- a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py @@ -25,8 +25,6 @@ from armi import runLog from armi.reactor.assemblies import Assembly - -# from armi.physics.fuelCycle.fuelHandlers import FuelHandler from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import ( getOptimalAssemblyOrientation, ) @@ -88,7 +86,9 @@ def _rotateByComparingLocations(aNow: Assembly, aPrev: Assembly): """ rot = getOptimalAssemblyOrientation(aNow, aPrev) radians = _rotationNumberToRadians(rot) - runLog.important(f"Rotating Assembly {aNow} {math.degrees(radians)} degrees CCW.") + runLog.important( + f"Rotating Assembly {aNow} {math.degrees(radians)} degrees CCW." + ) aNow.rotate(radians) From a64332f4ae2c41fda98a24b7171061b81a768fb7 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 28 Oct 2024 09:11:52 -0700 Subject: [PATCH 13/48] Put max burnup fuel pin location in fuelCycle/utils.py --- .../fuelCycle/hexAssemblyFuelMgmtUtils.py | 17 +------ armi/physics/fuelCycle/tests/test_utils.py | 36 ++++++++++++++ armi/physics/fuelCycle/utils.py | 47 +++++++++++++++++++ 3 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 armi/physics/fuelCycle/tests/test_utils.py create mode 100644 armi/physics/fuelCycle/utils.py diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index 0e18e46a3..2a6244965 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -25,12 +25,11 @@ import numpy as np from armi import runLog +from armi.physics.fuelCycle.utils import maxBurnupFuelPinLocation from armi.utils.mathematics import findClosest if typing.TYPE_CHECKING: - from armi.reactor.grids import IndexLocation from armi.reactor.assemblies import HexAssembly - from armi.reactor.blocks import HexBlock def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int: @@ -95,7 +94,7 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int raise ValueError( f"Block {maxBuBlock} in {a} does not have a spatial grid. Cannot rotate." ) - maxBuPinLocation = _maxBuPinLocation(maxBuBlock) + maxBuPinLocation = maxBurnupFuelPinLocation(maxBuBlock) # No need to rotate if max burnup pin is the center if maxBuPinLocation.i == 0 and maxBuPinLocation.j == 0: return 0 @@ -129,18 +128,6 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int return candidateRotation -def _maxBuPinLocation(maxBuBlock: "HexBlock") -> "IndexLocation": - """Find the grid position for the highest burnup pin. - - percentBuMaxPinLocation corresponds to the "pin number" which is one indexed - and can be found at ``maxBuBlock.getPinLocations()[pinNumber - 1]`` - """ - buMaxPinNumber = maxBuBlock.p.percentBuMaxPinLocation - pinLocations = maxBuBlock.getPinLocations() - maxBuPinLocation = pinLocations[buMaxPinNumber - 1] - return maxBuPinLocation - - def buildRingSchedule( maxRingInCore, chargeRing=None, diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py new file mode 100644 index 000000000..67b410399 --- /dev/null +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -0,0 +1,36 @@ +# Copyright 2024 TerraPower, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest import TestCase, mock + +from armi.reactor.grids import IndexLocation +from armi.reactor.blocks import Block +from armi.physics.fuelCycle import utils + +class FuelCycleUtilsTests(TestCase): + """Tests for geometry indifferent fuel cycle routines.""" + + def setUp(self): + self.block = Block("test block") + + def test_maxBurnupPinLocationBlockParameter(self): + """Test that the ``Block.p.percentBuMaxPinLocation`` parameter gets the location.""" + # Zero-indexed pin index, pin number is this plus one + pinLocationIndex = 3 + self.block.p.percentBuMaxPinLocation = pinLocationIndex + 1 + locations = [IndexLocation(i, 0, 0, None) for i in range(pinLocationIndex + 5)] + self.block.getPinLocations = mock.Mock(return_value=locations) + expected = locations[pinLocationIndex] + actual = utils.maxBurnupFuelPinLocation(self.block) + self.assertIs(actual, expected) diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py new file mode 100644 index 000000000..e21ac54a9 --- /dev/null +++ b/armi/physics/fuelCycle/utils.py @@ -0,0 +1,47 @@ +# Copyright 2024 TerraPower, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Geometric agnostic routines that are useful for fuel cycle analysis.""" + +import typing + +from armi.reactor.grids import IndexLocation + +if typing.TYPE_CHECKING: + from armi.reactor.blocks import Block + + +def maxBurnupFuelPinLocation(b: "Block") -> IndexLocation: + """Find the grid position for the highest burnup fuel pin. + + Parameters + ---------- + b : Block + Block in question + + Returns + ------- + IndexLocation + The spatial location in the block corresponding to the pin with the + highest burnup. + """ + # Should be an integer, that's what the description says. But a couple places + # set it to a float like 1.0 so it's still int-like but not something we can slice + buMaxPinNumber = int(b.p.percentBuMaxPinLocation) + if buMaxPinNumber < 1: + raise ValueError(f"{b.p.percentBuMaxPinLocation=} must be greater than zero") + pinLocations = b.getPinLocations() + # percentBuMaxPinLocation corresponds to the "pin number" which is one indexed + # and can be found at ``maxBuBlock.getPinLocations()[pinNumber - 1]`` + maxBuPinLocation = pinLocations[buMaxPinNumber - 1] + return maxBuPinLocation From f72cd62be8695b69187f6f770d29655750f69d3c Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 22 Oct 2024 15:22:33 -0700 Subject: [PATCH 14/48] Remove need for history interface in burnup equalizing rotation It seems like checking if an assembly was in the detailed assembly prior to rotation was a means to check if it had pin power. But the workflow for adding detailed assemblies seems less widespread compared to setting pin powers. So, we'll first check any block in the assembly being rotated has pin power prior to the detailed assembly check. Check that the current assembly, `aNow`, has burnup defined on pins. Additionally check that the assembly previously located here, `aPrev`, has power defined on pins. These checks are facilitated by moving checks for assembly fuel pin powers and burnups into `fuelCycle/utils.py` --- .../fuelCycle/assemblyRotationAlgorithms.py | 25 ++++++---- .../tests/test_assemblyRotationAlgorithms.py | 2 - armi/physics/fuelCycle/tests/test_utils.py | 39 ++++++++++++++- armi/physics/fuelCycle/utils.py | 48 +++++++++++++++++++ 4 files changed, 101 insertions(+), 13 deletions(-) diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py index fa638c13b..3bcae55f4 100644 --- a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py @@ -23,8 +23,13 @@ """ import math + from armi import runLog from armi.reactor.assemblies import Assembly +from armi.physics.fuelCycle.utils import ( + assemblyHasFuelPinBurnup, + assemblyHasFuelPinPowers, +) from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import ( getOptimalAssemblyOrientation, ) @@ -51,8 +56,6 @@ def buReducingAssemblyRotation(fh): """ runLog.info("Algorithmically rotating assemblies to minimize burnup") numRotated = 0 - hist = fh.o.getInterface("history") - detailAssemblies = hist.getDetailAssemblies() for aPrev in fh.moved: # If the assembly was out of the core, no need to rotate an assembly # that is outside the core. @@ -60,15 +63,19 @@ def buReducingAssemblyRotation(fh): continue aNow = fh.r.core.getAssemblyWithStringLocation(aPrev.lastLocationLabel) # no point in rotation if there's no pin detail - if aNow in detailAssemblies: + if assemblyHasFuelPinPowers(aPrev) and assemblyHasFuelPinBurnup(aNow): _rotateByComparingLocations(aNow, aPrev) numRotated += 1 if fh.cs[CONF_ASSEM_ROTATION_STATIONARY]: - for a in detailAssemblies: - if a not in fh.moved: - _rotateByComparingLocations(a, a) - numRotated += 1 + for a in filter( + lambda asm: asm not in fh.moved + and assemblyHasFuelPinPowers(asm) + and assemblyHasFuelPinBurnup(asm), + fh.r.core, + ): + _rotateByComparingLocations(a, a) + numRotated += 1 runLog.info("Rotated {0} assemblies".format(numRotated)) @@ -86,9 +93,7 @@ def _rotateByComparingLocations(aNow: Assembly, aPrev: Assembly): """ rot = getOptimalAssemblyOrientation(aNow, aPrev) radians = _rotationNumberToRadians(rot) - runLog.important( - f"Rotating Assembly {aNow} {math.degrees(radians)} degrees CCW." - ) + runLog.important(f"Rotating Assembly {aNow} {math.degrees(radians)} degrees CCW.") aNow.rotate(radians) diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index 1cec55462..986a92ebd 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -129,7 +129,6 @@ def test_buReducingAssemblyRotation(self): """Test that the fuel handler supports the burnup reducing assembly rotation.""" fh = FullImplFuelHandler(self.o) - hist = self.o.getInterface("history") newSettings = { CONF_ASSEM_ROTATION_STATIONARY: True, "fluxRecon": True, @@ -145,7 +144,6 @@ def test_buReducingAssemblyRotation(self): b.p.percentBuMax = 5 b.p.linPowByPin = reversed(range(b.getNumPins())) - addSomeDetailAssemblies(hist, [assem]) # Show that we call the optimal assembly orientation function. # This function is tested seperately and more extensively elsewhere. with mock.patch( diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py index 67b410399..968a5b85a 100644 --- a/armi/physics/fuelCycle/tests/test_utils.py +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -14,15 +14,34 @@ from unittest import TestCase, mock -from armi.reactor.grids import IndexLocation +import numpy as np + from armi.reactor.blocks import Block +from armi.reactor.components import Circle +from armi.reactor.flags import Flags +from armi.reactor.grids import IndexLocation from armi.physics.fuelCycle import utils + class FuelCycleUtilsTests(TestCase): """Tests for geometry indifferent fuel cycle routines.""" + N_PINS = 271 + def setUp(self): self.block = Block("test block") + self.fuel = Circle( + "test pin", + material="UO2", + Tinput=20, + Thot=20, + mult=self.N_PINS, + id=0.0, + od=1.0, + ) + self.block.add(self.fuel) + # Force no fuel flags + self.fuel.p.flags = Flags.PIN def test_maxBurnupPinLocationBlockParameter(self): """Test that the ``Block.p.percentBuMaxPinLocation`` parameter gets the location.""" @@ -34,3 +53,21 @@ def test_maxBurnupPinLocationBlockParameter(self): expected = locations[pinLocationIndex] actual = utils.maxBurnupFuelPinLocation(self.block) self.assertIs(actual, expected) + + def test_assemblyHasPinPower(self): + """Test the ability to check if an assembly has fuel pin powers.""" + fakeAssem = [self.block] + # No fuel blocks, no pin power on blocks => no pin powers + self.assertFalse(utils.assemblyHasFuelPinBurnup(fakeAssem)) + + # Yes fuel blocks, no pin power on blocks => no pin powers + self.block.p.flags |= Flags.FUEL + self.assertFalse(utils.assemblyHasFuelPinPowers(fakeAssem)) + + # Yes fuel blocks, yes pin power on blocks => yes pin powers + self.block.p.linPowByPin = np.arange(self.N_PINS, dtype=float) + self.assertTrue(utils.assemblyHasFuelPinPowers(fakeAssem)) + + # Yes fuel blocks, yes pin power assigned but all zeros => no pin powers + self.block.p.linPowByPin = np.zeros(self.N_PINS, dtype=float) + self.assertFalse(utils.assemblyHasFuelPinPowers(fakeAssem)) diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py index e21ac54a9..e475b05b0 100644 --- a/armi/physics/fuelCycle/utils.py +++ b/armi/physics/fuelCycle/utils.py @@ -15,12 +15,60 @@ import typing +import numpy as np + +from armi.reactor.flags import Flags from armi.reactor.grids import IndexLocation if typing.TYPE_CHECKING: from armi.reactor.blocks import Block +def assemblyHasFuelPinPowers(a: typing.Iterable["Block"]) -> bool: + """Determine if an assembly has pin powers. + + These are necessary for determining rotation and may or may + not be present on all assemblies. + + Parameters + ---------- + a : Assembly + Assembly in question + + Returns + ------- + bool + If at least one fuel block in the assembly has pin powers. + """ + # Avoid using Assembly.getChildrenWithFlags(Flags.FUEL) + # because that creates an entire list where we may just need the first + # fuel block + return any(b.hasFlags(Flags.FUEL) and np.any(b.p.linPowByPin) for b in a) + + +def assemblyHasFuelPinBurnup(a: typing.Iterable["Block"]) -> bool: + """Determine if an assembly has pin burnups. + + These are necessary for determining rotation and may or may not + be present on all assemblies. + + Parameters + ---------- + a : Assembly + Assembly in question + + Returns + ------- + bool + If a block with pin burnup was found. + + """ + # Avoid using Assembly.getChildrenWithFlags(Flags.FUEL) + # because that creates an entire list where we may just need the first + # fuel block. Same for avoiding Block.getChildrenWithFlags. + return any(b.hasFlags(Flags.FUEL) and b.p.percentBuMaxPinLocation for b in a) + + def maxBurnupFuelPinLocation(b: "Block") -> IndexLocation: """Find the grid position for the highest burnup fuel pin. From 9d4b0bc8fb10ddbd31e24781bff049ad1057e4d6 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 22 Oct 2024 15:22:33 -0700 Subject: [PATCH 15/48] Check against Component.p.pinPercentBu for burnup equalizing rotation --- armi/physics/fuelCycle/tests/test_utils.py | 56 ++++++++++++++- armi/physics/fuelCycle/utils.py | 81 +++++++++++++++++++++- 2 files changed, 134 insertions(+), 3 deletions(-) diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py index 968a5b85a..74e350864 100644 --- a/armi/physics/fuelCycle/tests/test_utils.py +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -19,7 +19,7 @@ from armi.reactor.blocks import Block from armi.reactor.components import Circle from armi.reactor.flags import Flags -from armi.reactor.grids import IndexLocation +from armi.reactor.grids import IndexLocation, MultiIndexLocation from armi.physics.fuelCycle import utils @@ -54,6 +54,35 @@ def test_maxBurnupPinLocationBlockParameter(self): actual = utils.maxBurnupFuelPinLocation(self.block) self.assertIs(actual, expected) + def test_maxBurnupLocationFromComponents(self): + """Test that the ``Component.p.pinPercentBu`` parameter can reveal max burnup location.""" + self.fuel.spatialLocator = MultiIndexLocation(None) + locations = [] + for i in range(self.N_PINS): + loc = IndexLocation(i, 0, 0, None) + self.fuel.spatialLocator.append(loc) + locations.append(loc) + self.fuel.p.pinPercentBu = np.ones(self.N_PINS, dtype=float) + + # Pick an arbitrary index for the pin with the most burnup + maxBuIndex = self.N_PINS // 3 + self.fuel.p.pinPercentBu[maxBuIndex] *= 2 + expectedLoc = locations[maxBuIndex] + actual = utils.maxBurnupFuelPinLocation(self.block) + self.assertEqual(actual, expectedLoc) + + def test_singleLocatorWithBurnup(self): + """Test that a single component with burnup can be used to find the highest burnup.""" + freeComp = Circle( + "free fuel", material="UO2", Tinput=200, Thot=200, id=0, od=1, mult=1 + ) + freeComp.spatialLocator = IndexLocation(2, 4, 0, None) + freeComp.p.pinPercentBu = [ + 0.01, + ] + loc = utils.getMaxBurnupLocationFromChildren([freeComp]) + self.assertIs(loc, freeComp.spatialLocator) + def test_assemblyHasPinPower(self): """Test the ability to check if an assembly has fuel pin powers.""" fakeAssem = [self.block] @@ -71,3 +100,28 @@ def test_assemblyHasPinPower(self): # Yes fuel blocks, yes pin power assigned but all zeros => no pin powers self.block.p.linPowByPin = np.zeros(self.N_PINS, dtype=float) self.assertFalse(utils.assemblyHasFuelPinPowers(fakeAssem)) + + def test_assemblyHasPinBurnups(self): + """Test the ability to check if an assembly has fuel pin burnup.""" + fakeAssem = [self.block] + # No fuel components => no assembly burnups + self.assertFalse(self.block.getChildrenWithFlags(Flags.FUEL)) + self.assertFalse(utils.assemblyHasFuelPinBurnup(fakeAssem)) + # No fuel with burnup => no assembly burnups + self.block.p.flags |= Flags.FUEL + self.fuel.p.flags |= Flags.FUEL + self.assertFalse(utils.assemblyHasFuelPinBurnup(fakeAssem)) + # Fuel pin has burnup => yes assembly burnup + self.fuel.p.pinPercentBu = np.arange(self.N_PINS, dtype=float) + self.assertTrue(utils.assemblyHasFuelPinBurnup(fakeAssem)) + # Fuel pin has empty burnup => no assembly burnup + self.fuel.p.pinPercentBu = np.zeros(self.N_PINS) + self.assertFalse(utils.assemblyHasFuelPinBurnup(fakeAssem)) + # Yes burnup but no fuel flags => no assembly burnup + self.fuel.p.flags ^= Flags.FUEL + self.assertFalse(self.fuel.hasFlags(Flags.FUEL)) + self.fuel.p.pinPercentBu = np.arange(self.N_PINS, dtype=float) + self.assertFalse(utils.assemblyHasFuelPinBurnup(fakeAssem)) + # No pin component burnup, but a pin burnup location parameter => yes assembly burnup + self.block.p.percentBuMaxPinLocation = 3 + self.assertTrue(utils.assemblyHasFuelPinBurnup(fakeAssem)) diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py index e475b05b0..90b4d9518 100644 --- a/armi/physics/fuelCycle/utils.py +++ b/armi/physics/fuelCycle/utils.py @@ -13,14 +13,16 @@ # limitations under the License. """Geometric agnostic routines that are useful for fuel cycle analysis.""" +import contextlib import typing import numpy as np from armi.reactor.flags import Flags -from armi.reactor.grids import IndexLocation +from armi.reactor.grids import IndexLocation, MultiIndexLocation if typing.TYPE_CHECKING: + from armi.reactor.components import Component from armi.reactor.blocks import Block @@ -62,11 +64,24 @@ def assemblyHasFuelPinBurnup(a: typing.Iterable["Block"]) -> bool: bool If a block with pin burnup was found. + Notes + ----- + Checks two parameters on a fuel block to determine if there is burnup: + + 1. ``Block.p.percentBuMaxPinLocation``, or + 2. ``Component.p.pinPercentBu`` on a fuel component in the block. """ # Avoid using Assembly.getChildrenWithFlags(Flags.FUEL) # because that creates an entire list where we may just need the first # fuel block. Same for avoiding Block.getChildrenWithFlags. - return any(b.hasFlags(Flags.FUEL) and b.p.percentBuMaxPinLocation for b in a) + return any( + b.hasFlags(Flags.FUEL) + and ( + any(c.hasFlags(Flags.FUEL) and np.any(c.p.pinPercentBu) for c in b) + or b.p.percentBuMaxPinLocation + ) + for b in a + ) def maxBurnupFuelPinLocation(b: "Block") -> IndexLocation: @@ -82,7 +97,18 @@ def maxBurnupFuelPinLocation(b: "Block") -> IndexLocation: IndexLocation The spatial location in the block corresponding to the pin with the highest burnup. + + See Also + -------- + * :func:`getMaxBurnupLocationFromChildren` looks just at the children of this + block, e.g., looking at pins. This function also looks at the block parameter + ``Block.p.percentBuMaxPinLocation`` in case the max burnup location cannot be + determined from the child pins. """ + # If we can't find any burnup from the children, that's okay. We have + # another way to find the max burnup location. + with contextlib.suppress(ValueError): + return getMaxBurnupLocationFromChildren(b) # Should be an integer, that's what the description says. But a couple places # set it to a float like 1.0 so it's still int-like but not something we can slice buMaxPinNumber = int(b.p.percentBuMaxPinLocation) @@ -93,3 +119,54 @@ def maxBurnupFuelPinLocation(b: "Block") -> IndexLocation: # and can be found at ``maxBuBlock.getPinLocations()[pinNumber - 1]`` maxBuPinLocation = pinLocations[buMaxPinNumber - 1] return maxBuPinLocation + + +def getMaxBurnupLocationFromChildren( + children: typing.Iterable["Component"], +) -> IndexLocation: + """Find the location of the pin with highest burnup by looking at components. + + Parameters + ---------- + children : iterable[Component] + Iterator over children with a spatial locator and ``pinPercentBu`` parameter + + Returns + ------- + IndexLocation + Location of the pin with the highest burnup. + + Raises + ------ + ValueError + If no children have burnup, or the burnup and locators differ. + + See Also + -------- + * :func:`maxBurnupFuelPinLocation` uses this. You should use that method more generally, + unless you **know** you will always have ``Component.p.pinPercentBu`` defined. + """ + maxBu = 0 + maxLocation = None + withBurnupAndLocs = filter( + lambda c: c.spatialLocator is not None and c.p.pinPercentBu is not None, + children, + ) + for child in withBurnupAndLocs: + pinBu = child.p.pinPercentBu + if isinstance(child.spatialLocator, MultiIndexLocation): + locations = child.spatialLocator + else: + locations = [child.spatialLocator] + if len(locations) != pinBu.size: + raise ValueError( + f"Pin burnup and pin locations on {child} differ: {locations=} :: {pinBu=}" + ) + myMaxIX = pinBu.argmax() + myMaxBu = pinBu[myMaxIX] + if myMaxBu > maxBu: + maxBu = myMaxBu + maxLocation = locations[myMaxIX] + if maxLocation is not None: + return maxLocation + raise ValueError("No burnups found!") From 59b1eb48f76b31f4043c6c56b064fdf8540d7d09 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 31 Oct 2024 14:54:30 -0700 Subject: [PATCH 16/48] Better comment for skipping aPrev not in core in rotation --- armi/physics/fuelCycle/assemblyRotationAlgorithms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py index 3bcae55f4..5488a032a 100644 --- a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py @@ -57,8 +57,8 @@ def buReducingAssemblyRotation(fh): runLog.info("Algorithmically rotating assemblies to minimize burnup") numRotated = 0 for aPrev in fh.moved: - # If the assembly was out of the core, no need to rotate an assembly - # that is outside the core. + # If the assembly was out of the core, it will not have pin powers. + # No rotation information to be gained. if aPrev.lastLocationLabel in Assembly.NOT_IN_CORE: continue aNow = fh.r.core.getAssemblyWithStringLocation(aPrev.lastLocationLabel) From fb7eb4ccd55d19ccf88bf61cd6dd5ce6fddb252d Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 31 Oct 2024 14:54:45 -0700 Subject: [PATCH 17/48] Dont try to check burnup on new assembly if fresh from load queue --- armi/physics/fuelCycle/assemblyRotationAlgorithms.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py index 5488a032a..4ee14d0d1 100644 --- a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py @@ -62,6 +62,10 @@ def buReducingAssemblyRotation(fh): if aPrev.lastLocationLabel in Assembly.NOT_IN_CORE: continue aNow = fh.r.core.getAssemblyWithStringLocation(aPrev.lastLocationLabel) + # An assembly in the SFP could have burnup but if it's coming from the load + # queue it's totally fresh. Skip a check over all pins in the model + if aNow.lastLocationLabel == Assembly.LOAD_QUEUE: + continue # no point in rotation if there's no pin detail if assemblyHasFuelPinPowers(aPrev) and assemblyHasFuelPinBurnup(aNow): _rotateByComparingLocations(aNow, aPrev) From 1bfcc394128e590ce612a07d44700d0fe73485af Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 31 Oct 2024 16:24:33 -0700 Subject: [PATCH 18/48] Better usage of Component.p.pinPercentBu in finding max burnup block for rotation --- .../fuelCycle/hexAssemblyFuelMgmtUtils.py | 4 +-- .../tests/test_assemblyRotationAlgorithms.py | 28 ++++++++++++------- armi/physics/fuelCycle/utils.py | 20 +++++++++++++ 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index 2a6244965..103fadb3f 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -25,7 +25,7 @@ import numpy as np from armi import runLog -from armi.physics.fuelCycle.utils import maxBurnupFuelPinLocation +from armi.physics.fuelCycle.utils import maxBurnupFuelPinLocation, maxBurnupBlock from armi.utils.mathematics import findClosest if typing.TYPE_CHECKING: @@ -89,7 +89,7 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int assumption in that most fuel assemblies have similar layouts so it's plausible that if ``a`` has a fuel pin at ``(1, 0, 0)``, so does ``aPrev``. """ - maxBuBlock = max(a, key=lambda b: b.p.percentBuMax) + maxBuBlock = maxBurnupBlock(a) if maxBuBlock.spatialGrid is None: raise ValueError( f"Block {maxBuBlock} in {a} does not have a spatial grid. Cannot rotate." diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index 986a92ebd..24fb7269c 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -50,12 +50,16 @@ class TestOptimalAssemblyRotation(FuelHandlerTestHelper): def prepShuffledAssembly(a: HexAssembly, percentBuMaxPinLocation: int): """Prepare the assembly that will be shuffled and rotated.""" for b in a.getChildrenWithFlags(Flags.FUEL): - # Fake some maximum burnup - b.p.percentBuMax = 5 # Fake enough information to build a spatial grid b.getPinPitch = mock.Mock(return_value=1.1) b.autoCreateSpatialGrids() - b.p.percentBuMaxPinLocation = percentBuMaxPinLocation + for c in b.getChildrenWithFlags(Flags.FUEL): + mult = c.getDimension("mult") + if mult <= percentBuMaxPinLocation: + continue + burnups = np.ones(mult, dtype=float) + burnups[percentBuMaxPinLocation] *= 2 + c.p.pinPercentBu = burnups @staticmethod def prepPreviousAssembly(a: HexAssembly, pinPowers: list[float]): @@ -79,7 +83,7 @@ def test_maxBurnupAtCenterNoRotation(self): """If max burnup pin is at the center, no rotation is suggested.""" # Fake a higher power towards the center powers = np.arange(self.N_PINS)[::-1] - self.prepShuffledAssembly(self.assembly, percentBuMaxPinLocation=1) + self.prepShuffledAssembly(self.assembly, percentBuMaxPinLocation=0) self.prepPreviousAssembly(self.assembly, powers) rot = getOptimalAssemblyOrientation(self.assembly, self.assembly) self.assertEqual(rot, 0) @@ -95,22 +99,26 @@ def test_oppositeRotation(self): :tests: R_ARMI_ROTATE_HEX_BURNUP :acceptance_criteria: After rotating a hexagonal assembly, confirm the pin with the highest burnup is in the same sector as pin with the lowest power in the high burnup pin's ring. + + Notes + ----- + Note: use zero-indexed pin location not pin ID to assign burnups and powers. Since + we have a single component, ``Block.p.linPowByPin[i] <-> Component.p.pinPercentBu[i]`` """ shuffledAssembly = self.assembly previousAssembly = copy.deepcopy(shuffledAssembly) - for startPin, oppositePin in ((2, 5), (3, 6), (4, 7), (5, 2), (6, 3), (7, 4)): + for startPin, oppositePin in ((1, 4), (2, 5), (3, 6), (4, 1), (5, 2), (6, 3)): powers = np.ones(self.N_PINS) - powers[startPin - 1] *= 2 - powers[oppositePin - 1] = 0 + powers[oppositePin] = 0 self.prepShuffledAssembly(shuffledAssembly, startPin) self.prepPreviousAssembly(previousAssembly, powers) rot = getOptimalAssemblyOrientation(shuffledAssembly, previousAssembly) # 180 degrees is three 60 degree rotations self.assertEqual(rot, 3, msg=f"{startPin=} :: {oppositePin=}") - def test_noGridOnShuffledBlock(self): - """Require a spatial grid on the shuffled block.""" - with self.assertRaisesRegex(ValueError, "spatial grid"): + def test_noBlocksWithBurnup(self): + """Require at least one block to have burnup.""" + with self.assertRaisesRegex(ValueError, "No blocks with burnup found"): getOptimalAssemblyOrientation(self.assembly, self.assembly) def test_mismatchPinPowersAndLocations(self): diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py index 90b4d9518..c36b450b3 100644 --- a/armi/physics/fuelCycle/utils.py +++ b/armi/physics/fuelCycle/utils.py @@ -170,3 +170,23 @@ def getMaxBurnupLocationFromChildren( if maxLocation is not None: return maxLocation raise ValueError("No burnups found!") + + +def maxBurnupBlock(a: typing.Iterable["Block"]) -> "Block": + """Find the block that contains the pin with the highest burnup.""" + maxBlock = None + maxBurnup = 0 + for b in a: + maxCompBu = 0 + for c in b: + if not np.any(c.p.pinPercentBu): + continue + compBu = c.p.pinPercentBu.max() + if compBu > maxCompBu: + maxCompBu = compBu + if maxCompBu > maxBurnup: + maxBurnup = maxCompBu + maxBlock = b + if maxBlock is not None: + return maxBlock + raise ValueError(f"No blocks with burnup found") From a965f2bddd0a79625ff644adb124176dd53d09e3 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 31 Oct 2024 16:41:30 -0700 Subject: [PATCH 19/48] Update fuel handler test to use Comp.p.pinPercentBu --- .../fuelCycle/tests/test_assemblyRotationAlgorithms.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index 24fb7269c..965d70a0e 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -148,8 +148,9 @@ def test_buReducingAssemblyRotation(self): # apply dummy pin-level data to allow intelligent rotation for b in assem.getBlocks(Flags.FUEL): b.initializePinLocations() - b.p.percentBuMaxPinLocation = 10 - b.p.percentBuMax = 5 + fuel = b.getChildrenWithFlags(Flags.FUEL)[0] + mult = fuel.getDimension("mult") + fuel.p.pinPercentBu = np.arange(mult, dtype=float)[::-1] b.p.linPowByPin = reversed(range(b.getNumPins())) # Show that we call the optimal assembly orientation function. From 523af1f26954dc62ae5cefcfd03fcea500850aa7 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 31 Oct 2024 16:44:58 -0700 Subject: [PATCH 20/48] Remove rotation-related usage on block burnup parameters --- .../fuelCycle/hexAssemblyFuelMgmtUtils.py | 4 +- armi/physics/fuelCycle/tests/test_utils.py | 18 +------ armi/physics/fuelCycle/utils.py | 51 ++----------------- 3 files changed, 7 insertions(+), 66 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index 103fadb3f..85b59b72e 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -25,7 +25,7 @@ import numpy as np from armi import runLog -from armi.physics.fuelCycle.utils import maxBurnupFuelPinLocation, maxBurnupBlock +from armi.physics.fuelCycle.utils import maxBurnupBlock, maxBurnupLocator from armi.utils.mathematics import findClosest if typing.TYPE_CHECKING: @@ -94,7 +94,7 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int raise ValueError( f"Block {maxBuBlock} in {a} does not have a spatial grid. Cannot rotate." ) - maxBuPinLocation = maxBurnupFuelPinLocation(maxBuBlock) + maxBuPinLocation = maxBurnupLocator(maxBuBlock) # No need to rotate if max burnup pin is the center if maxBuPinLocation.i == 0 and maxBuPinLocation.j == 0: return 0 diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py index 74e350864..7a10621bf 100644 --- a/armi/physics/fuelCycle/tests/test_utils.py +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -43,17 +43,6 @@ def setUp(self): # Force no fuel flags self.fuel.p.flags = Flags.PIN - def test_maxBurnupPinLocationBlockParameter(self): - """Test that the ``Block.p.percentBuMaxPinLocation`` parameter gets the location.""" - # Zero-indexed pin index, pin number is this plus one - pinLocationIndex = 3 - self.block.p.percentBuMaxPinLocation = pinLocationIndex + 1 - locations = [IndexLocation(i, 0, 0, None) for i in range(pinLocationIndex + 5)] - self.block.getPinLocations = mock.Mock(return_value=locations) - expected = locations[pinLocationIndex] - actual = utils.maxBurnupFuelPinLocation(self.block) - self.assertIs(actual, expected) - def test_maxBurnupLocationFromComponents(self): """Test that the ``Component.p.pinPercentBu`` parameter can reveal max burnup location.""" self.fuel.spatialLocator = MultiIndexLocation(None) @@ -68,7 +57,7 @@ def test_maxBurnupLocationFromComponents(self): maxBuIndex = self.N_PINS // 3 self.fuel.p.pinPercentBu[maxBuIndex] *= 2 expectedLoc = locations[maxBuIndex] - actual = utils.maxBurnupFuelPinLocation(self.block) + actual = utils.maxBurnupLocator(self.block) self.assertEqual(actual, expectedLoc) def test_singleLocatorWithBurnup(self): @@ -80,7 +69,7 @@ def test_singleLocatorWithBurnup(self): freeComp.p.pinPercentBu = [ 0.01, ] - loc = utils.getMaxBurnupLocationFromChildren([freeComp]) + loc = utils.maxBurnupLocator([freeComp]) self.assertIs(loc, freeComp.spatialLocator) def test_assemblyHasPinPower(self): @@ -122,6 +111,3 @@ def test_assemblyHasPinBurnups(self): self.assertFalse(self.fuel.hasFlags(Flags.FUEL)) self.fuel.p.pinPercentBu = np.arange(self.N_PINS, dtype=float) self.assertFalse(utils.assemblyHasFuelPinBurnup(fakeAssem)) - # No pin component burnup, but a pin burnup location parameter => yes assembly burnup - self.block.p.percentBuMaxPinLocation = 3 - self.assertTrue(utils.assemblyHasFuelPinBurnup(fakeAssem)) diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py index c36b450b3..731c72070 100644 --- a/armi/physics/fuelCycle/utils.py +++ b/armi/physics/fuelCycle/utils.py @@ -66,10 +66,8 @@ def assemblyHasFuelPinBurnup(a: typing.Iterable["Block"]) -> bool: Notes ----- - Checks two parameters on a fuel block to determine if there is burnup: - - 1. ``Block.p.percentBuMaxPinLocation``, or - 2. ``Component.p.pinPercentBu`` on a fuel component in the block. + Checks if any `Component.p.pinPercentBu`` is set and contains non-zero data + on a fuel component in the block. """ # Avoid using Assembly.getChildrenWithFlags(Flags.FUEL) # because that creates an entire list where we may just need the first @@ -78,50 +76,12 @@ def assemblyHasFuelPinBurnup(a: typing.Iterable["Block"]) -> bool: b.hasFlags(Flags.FUEL) and ( any(c.hasFlags(Flags.FUEL) and np.any(c.p.pinPercentBu) for c in b) - or b.p.percentBuMaxPinLocation ) for b in a ) -def maxBurnupFuelPinLocation(b: "Block") -> IndexLocation: - """Find the grid position for the highest burnup fuel pin. - - Parameters - ---------- - b : Block - Block in question - - Returns - ------- - IndexLocation - The spatial location in the block corresponding to the pin with the - highest burnup. - - See Also - -------- - * :func:`getMaxBurnupLocationFromChildren` looks just at the children of this - block, e.g., looking at pins. This function also looks at the block parameter - ``Block.p.percentBuMaxPinLocation`` in case the max burnup location cannot be - determined from the child pins. - """ - # If we can't find any burnup from the children, that's okay. We have - # another way to find the max burnup location. - with contextlib.suppress(ValueError): - return getMaxBurnupLocationFromChildren(b) - # Should be an integer, that's what the description says. But a couple places - # set it to a float like 1.0 so it's still int-like but not something we can slice - buMaxPinNumber = int(b.p.percentBuMaxPinLocation) - if buMaxPinNumber < 1: - raise ValueError(f"{b.p.percentBuMaxPinLocation=} must be greater than zero") - pinLocations = b.getPinLocations() - # percentBuMaxPinLocation corresponds to the "pin number" which is one indexed - # and can be found at ``maxBuBlock.getPinLocations()[pinNumber - 1]`` - maxBuPinLocation = pinLocations[buMaxPinNumber - 1] - return maxBuPinLocation - - -def getMaxBurnupLocationFromChildren( +def maxBurnupLocator( children: typing.Iterable["Component"], ) -> IndexLocation: """Find the location of the pin with highest burnup by looking at components. @@ -140,11 +100,6 @@ def getMaxBurnupLocationFromChildren( ------ ValueError If no children have burnup, or the burnup and locators differ. - - See Also - -------- - * :func:`maxBurnupFuelPinLocation` uses this. You should use that method more generally, - unless you **know** you will always have ``Component.p.pinPercentBu`` defined. """ maxBu = 0 maxLocation = None From c181d90ebc1fb46fd45f93e7e56fd09fe56a1320 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Thu, 31 Oct 2024 16:59:08 -0700 Subject: [PATCH 21/48] Clean up clean up everybody do your share --- armi/physics/fuelCycle/tests/test_utils.py | 2 +- armi/physics/fuelCycle/utils.py | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py index 7a10621bf..3605fd647 100644 --- a/armi/physics/fuelCycle/tests/test_utils.py +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from unittest import TestCase, mock +from unittest import TestCase import numpy as np diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py index 731c72070..c00eab6f1 100644 --- a/armi/physics/fuelCycle/utils.py +++ b/armi/physics/fuelCycle/utils.py @@ -13,7 +13,6 @@ # limitations under the License. """Geometric agnostic routines that are useful for fuel cycle analysis.""" -import contextlib import typing import numpy as np @@ -74,9 +73,7 @@ def assemblyHasFuelPinBurnup(a: typing.Iterable["Block"]) -> bool: # fuel block. Same for avoiding Block.getChildrenWithFlags. return any( b.hasFlags(Flags.FUEL) - and ( - any(c.hasFlags(Flags.FUEL) and np.any(c.p.pinPercentBu) for c in b) - ) + and (any(c.hasFlags(Flags.FUEL) and np.any(c.p.pinPercentBu) for c in b)) for b in a ) @@ -144,4 +141,4 @@ def maxBurnupBlock(a: typing.Iterable["Block"]) -> "Block": maxBlock = b if maxBlock is not None: return maxBlock - raise ValueError(f"No blocks with burnup found") + raise ValueError("No blocks with burnup found") From f19c9599c6cd5fc395475c8c594123e10c0654a1 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 4 Nov 2024 07:26:10 -0800 Subject: [PATCH 22/48] Round degrees in log statement about rotated assemblies --- armi/physics/fuelCycle/assemblyRotationAlgorithms.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py index 4ee14d0d1..d92350d6a 100644 --- a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py @@ -97,7 +97,9 @@ def _rotateByComparingLocations(aNow: Assembly, aPrev: Assembly): """ rot = getOptimalAssemblyOrientation(aNow, aPrev) radians = _rotationNumberToRadians(rot) - runLog.important(f"Rotating Assembly {aNow} {math.degrees(radians)} degrees CCW.") + runLog.important( + f"Rotating Assembly {aNow} {math.degrees(radians):.3f} degrees CCW." + ) aNow.rotate(radians) From 258a100bd47c0297cfa3da21d24029303deee6f2 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 4 Nov 2024 14:56:56 -0800 Subject: [PATCH 23/48] Add debug statements to burnup rotation These will be removed. --- armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index 85b59b72e..83ca9bed4 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -119,12 +119,23 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int targetGrid = blockAtPreviousLocation.spatialGrid candidateRotation = 0 candidatePower = ringPowers.get((maxBuPinLocation.i, maxBuPinLocation.j), math.inf) + runLog.debug(f"::Checking possible rotations for {a}") + runLog.debug( + f"::rotation={candidateRotation}::power after rotation={candidatePower}::location={maxBuPinLocation}" + ) for rot in range(1, 6): candidateLocation = targetGrid.rotateIndex(maxBuPinLocation, rot) newPower = ringPowers.get((candidateLocation.i, candidateLocation.j), math.inf) + runLog.debug( + f"::rotation={rot}::power after rotation={newPower}::location={candidateLocation}" + ) if newPower < candidatePower: candidateRotation = rot candidatePower = newPower + runLog.debug( + f"::new minimum power for {candidateRotation=}::{candidatePower=}" + ) + runLog.debug(f"::chose {candidateRotation=}") return candidateRotation From cace9513f41a788c2a6de03931d93dffe1fe7ba6 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 4 Nov 2024 07:47:51 -0800 Subject: [PATCH 24/48] Improve fresh feed test Make sure we iterate over the fresh assembly in `fh.moved` or else this test really has nothing to do --- .../fuelCycle/tests/test_assemblyRotationAlgorithms.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index 965d70a0e..03b4b90f4 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -177,14 +177,19 @@ def test_buRotationWithFreshFeed(self): "assemblyRotationAlgorithm": "buReducingAssemblyRotation", } self.o.cs = self.o.cs.modified(newSettings=newSettings) + fresh = self.r.core.createFreshFeed(self.o.cs) self.assertEqual(fresh.lastLocationLabel, HexAssembly.LOAD_QUEUE) fh = FullImplFuelHandler(self.o) + fh.chooseSwaps = mock.Mock(side_effect=lambda _: fh.moved.append(fresh)) + with mock.patch( "armi.physics.fuelCycle.assemblyRotationAlgorithms.getOptimalAssemblyOrientation", ) as p: fh.outage() # The only moved assembly was most recently outside the core so we have no need to rotate + # Make sure our fake chooseSwaps added the fresh assembly to the moved assemblies + fh.chooseSwaps.assert_called_once() p.assert_not_called() def test_simpleAssemblyRotation(self): From ee89d75d8e1fe65716a13a1e541594a8ebe7f405 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 5 Nov 2024 11:36:44 -0800 Subject: [PATCH 25/48] Don't rotate assemblies until all assembly rotations have been determined If we rotate an assembly that is then used to determine how a different assembly should be rotated, we've poisoned our check. We want to compare against the state of the assembly that was in the core when the power was produced. Consider: 1. Assembly A is shuffled to a new location and then rotated. 2. Assembly B is shuffled to the previous location of assembly A. The power profile that is used to determine the rotation of assembly B is based on a rotated assembly A. This is not ideal because we want to compare against the power profile assembly B is expected to experience. And by rotating assembly A before this check, we've introduced a bias into the check. Now, the `buReducingAssemblyRotation` will store the number of rotations for each assembly first, then rotate all assemblies at once. Additionally, any assembly that is found to have a zero rotation is not going to be rotated. Calling `Assembly.rotate(0)` is a no-op that we don't need to consider. These no-rotations will also be excluded from the counting of rotations. --- .../fuelCycle/assemblyRotationAlgorithms.py | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py index d92350d6a..c69f2c6d7 100644 --- a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py @@ -22,7 +22,7 @@ .. warning:: Nothing should go in this file, but rotation algorithms. """ import math - +from collections import defaultdict from armi import runLog from armi.reactor.assemblies import Assembly @@ -55,7 +55,11 @@ def buReducingAssemblyRotation(fh): simpleAssemblyRotation : an alternative rotation algorithm """ runLog.info("Algorithmically rotating assemblies to minimize burnup") - numRotated = 0 + # Store how we should rotate each assembly but don't perform the rotation just yet + # Consider assembly A is shuffled to a new location and rotated. + # Now, assembly B is shuffled to where assembly A used to be. We need to consider the + # power profile of A prior to it's rotation to understand the power profile B may see. + rotations: dict[int, list[Assembly]] = defaultdict(list) for aPrev in fh.moved: # If the assembly was out of the core, it will not have pin powers. # No rotation information to be gained. @@ -68,8 +72,8 @@ def buReducingAssemblyRotation(fh): continue # no point in rotation if there's no pin detail if assemblyHasFuelPinPowers(aPrev) and assemblyHasFuelPinBurnup(aNow): - _rotateByComparingLocations(aNow, aPrev) - numRotated += 1 + rot = getOptimalAssemblyOrientation(aNow, aPrev) + rotations[rot].append(aNow) if fh.cs[CONF_ASSEM_ROTATION_STATIONARY]: for a in filter( @@ -78,29 +82,20 @@ def buReducingAssemblyRotation(fh): and assemblyHasFuelPinBurnup(asm), fh.r.core, ): - _rotateByComparingLocations(a, a) - numRotated += 1 - - runLog.info("Rotated {0} assemblies".format(numRotated)) - - -def _rotateByComparingLocations(aNow: Assembly, aPrev: Assembly): - """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) - runLog.important( - f"Rotating Assembly {aNow} {math.degrees(radians):.3f} degrees CCW." - ) - aNow.rotate(radians) + rot = getOptimalAssemblyOrientation(a, a) + rotations[rot].append(a) + + nRotations = 0 + for rot, assems in filter(lambda item: item[0], rotations.items()): + # Radians used for the actual rotation. But a neater degrees print out is nice for logs + radians = _rotationNumberToRadians(rot) + degrees = round(math.degrees(radians), 3) + for a in assems: + runLog.important(f"Rotating assembly {a} {degrees} CCW.") + a.rotate(radians) + nRotations += 1 + + runLog.info(f"Rotated {nRotations} assemblies.") def simpleAssemblyRotation(fh): From 2db86432bcab2144e6c313c1a90be2a2e240f8cf Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 5 Nov 2024 11:55:23 -0800 Subject: [PATCH 26/48] Hopefully improve readability of fuel cycle fuel pin checks --- armi/physics/fuelCycle/utils.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py index c00eab6f1..ac05321e9 100644 --- a/armi/physics/fuelCycle/utils.py +++ b/armi/physics/fuelCycle/utils.py @@ -44,7 +44,8 @@ def assemblyHasFuelPinPowers(a: typing.Iterable["Block"]) -> bool: # Avoid using Assembly.getChildrenWithFlags(Flags.FUEL) # because that creates an entire list where we may just need the first # fuel block - return any(b.hasFlags(Flags.FUEL) and np.any(b.p.linPowByPin) for b in a) + fuelBlocks = filter(lambda b: b.hasFlags(Flags.FUEL), a) + return any(b.hasFlags(Flags.FUEL) and np.any(b.p.linPowByPin) for b in fuelBlocks) def assemblyHasFuelPinBurnup(a: typing.Iterable["Block"]) -> bool: @@ -71,11 +72,12 @@ def assemblyHasFuelPinBurnup(a: typing.Iterable["Block"]) -> bool: # Avoid using Assembly.getChildrenWithFlags(Flags.FUEL) # because that creates an entire list where we may just need the first # fuel block. Same for avoiding Block.getChildrenWithFlags. - return any( - b.hasFlags(Flags.FUEL) - and (any(c.hasFlags(Flags.FUEL) and np.any(c.p.pinPercentBu) for c in b)) - for b in a - ) + hasFuelFlags = lambda o: o.hasFlags(Flags.FUEL) + for b in filter(hasFuelFlags, a): + for c in filter(hasFuelFlags, b): + if np.any(c.p.pinPercentBu): + return True + return False def maxBurnupLocator( From 48ce1edef04b8e05aab71760630f1b38acc9eec9 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 4 Nov 2024 07:48:04 -0800 Subject: [PATCH 27/48] Expand FuelHandler testing to encompass stationary rotation --- .../tests/test_assemblyRotationAlgorithms.py | 67 +++++++++++++++---- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index 03b4b90f4..2ce180d1f 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -133,35 +133,48 @@ def test_mismatchPinPowersAndLocations(self): class TestFuelHandlerMgmtTools(FuelHandlerTestHelper): + @staticmethod + def prepareAssemForBurnupRotation(assem: HexAssembly): + """Set necessary data in order for the burnup rotation to be performed.""" + for b in assem.getBlocks(Flags.FUEL): + b.initializePinLocations() + fuel = b.getChildrenWithFlags(Flags.FUEL)[0] + mult = fuel.getDimension("mult") + fuel.p.pinPercentBu = np.arange(mult, dtype=float)[::-1] + b.p.linPowByPin = reversed(range(b.getNumPins())) + def test_buReducingAssemblyRotation(self): """Test that the fuel handler supports the burnup reducing assembly rotation.""" - fh = FullImplFuelHandler(self.o) - newSettings = { - CONF_ASSEM_ROTATION_STATIONARY: True, + CONF_ASSEM_ROTATION_STATIONARY: False, "fluxRecon": True, "assemblyRotationAlgorithm": "buReducingAssemblyRotation", } self.o.cs = self.o.cs.modified(newSettings=newSettings) - assem = self.o.r.core.getFirstAssembly(Flags.FUEL) - # apply dummy pin-level data to allow intelligent rotation - for b in assem.getBlocks(Flags.FUEL): - b.initializePinLocations() - fuel = b.getChildrenWithFlags(Flags.FUEL)[0] - mult = fuel.getDimension("mult") - fuel.p.pinPercentBu = np.arange(mult, dtype=float)[::-1] - b.p.linPowByPin = reversed(range(b.getNumPins())) + # Grab two assemblies and make them look like they were part of a shuffling operation + rotatedAssem, shuffledAway = self.o.r.core.getChildrenWithFlags(Flags.FUEL)[:2] + self.prepareAssemForBurnupRotation(rotatedAssem) + self.prepareAssemForBurnupRotation(shuffledAway) + + # Mimic a shuffling where shuffledAway used to be where rotatedAssem is now + shuffledAway.lastLocationLabel = rotatedAssem.getLocation() # Show that we call the optimal assembly orientation function. # This function is tested seperately and more extensively elsewhere. + fh = FullImplFuelHandler(self.o) + # Mocked swap function updates the moved assemblies collection + fh.chooseSwaps = mock.Mock(side_effect=lambda _: fh.moved.append(shuffledAway)) with mock.patch( "armi.physics.fuelCycle.assemblyRotationAlgorithms.getOptimalAssemblyOrientation", return_value=4, ) as p: fh.outage(1) - p.assert_called_once_with(assem, assem) - for b in assem.getBlocks(Flags.FUEL): + # fh.outage clears fh.moved at the end. But if it was called, we know our mock function added + # our shuffled assembly to the moved assemblies. + fh.chooseSwaps.assert_called_once() + p.assert_called_once_with(rotatedAssem, shuffledAway) + for b in rotatedAssem.getBlocks(Flags.FUEL): # Four rotations is 240 degrees self.assertEqual(b.p.orientation[2], 240) @@ -172,7 +185,6 @@ def test_buRotationWithFreshFeed(self): try to the "previous" assembly's location can fail. """ newSettings = { - CONF_ASSEM_ROTATION_STATIONARY: True, "fluxRecon": True, "assemblyRotationAlgorithm": "buReducingAssemblyRotation", } @@ -192,6 +204,33 @@ def test_buRotationWithFreshFeed(self): fh.chooseSwaps.assert_called_once() p.assert_not_called() + def test_buRotationWithStationaryRotation(self): + """Test that the burnup equalizing rotation algorithm works on non-shuffled assemblies.""" + newSettings = { + CONF_ASSEM_ROTATION_STATIONARY: True, + "fluxRecon": True, + "assemblyRotationAlgorithm": "buReducingAssemblyRotation", + } + self.o.cs = self.o.cs.modified(newSettings=newSettings) + + # Grab two assemblies that were not moved. One of which will have the detailed information + # needed for rotation + detailedAssem, coarseAssem = self.o.r.core.getChildrenWithFlags(Flags.FUEL)[:2] + self.prepareAssemForBurnupRotation(detailedAssem) + detailedAssem.rotate = mock.Mock() + coarseAssem.rotate = mock.Mock() + + fh = FullImplFuelHandler(self.o) + + with mock.patch( + "armi.physics.fuelCycle.assemblyRotationAlgorithms.getOptimalAssemblyOrientation", + return_value=5, + ) as p: + fh.outage() + p.assert_called_once_with(detailedAssem, detailedAssem) + detailedAssem.rotate.assert_called_once() + coarseAssem.rotate.assert_not_called() + def test_simpleAssemblyRotation(self): """Test rotating assemblies 120 degrees.""" fh = fuelHandlers.FuelHandler(self.o) From 21ba4cb4d88c31217f36f74147ca82c319f88d98 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 5 Nov 2024 15:45:12 -0800 Subject: [PATCH 28/48] Doc fixup for getOptimalAssemblyOrientation --- armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index 83ca9bed4..b03b1223a 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -66,10 +66,8 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int expected pin power. We evaluated "expected pin power" based on the power distribution in ``aPrev``, the previous assembly located where ``a`` is going. The algorithm goes as follows. - 1. Get all the pin powers and ``IndexLocation``s from the block at the - previous location - 2. Obtain the ``IndexLocation`` of the pin with the highest burnup in the - current assembly. + 1. Get all the pin powers and ``IndexLocation``s from the block at the previous location. + 2. Obtain the ``IndexLocation`` of the pin with the highest burnup in the current assembly. 3. For each possible rotation, - Find the new location with ``HexGrid.rotateIndex`` - Find the index where that location occurs in previous locations From 1c12db1ffd8da17e7cac3a1238a9a0e091900f6f Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 5 Nov 2024 16:12:16 -0800 Subject: [PATCH 29/48] More helpful error message in maxBurnupLocator --- armi/physics/fuelCycle/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py index ac05321e9..fd32809d1 100644 --- a/armi/physics/fuelCycle/utils.py +++ b/armi/physics/fuelCycle/utils.py @@ -114,7 +114,8 @@ def maxBurnupLocator( locations = [child.spatialLocator] if len(locations) != pinBu.size: raise ValueError( - f"Pin burnup and pin locations on {child} differ: {locations=} :: {pinBu=}" + f"Pin burnup (n={len(locations)}) and pin locations (n={pinBu.size}) " + f"on {child} differ: {locations=} :: {pinBu=}" ) myMaxIX = pinBu.argmax() myMaxBu = pinBu[myMaxIX] From f46ea22888c2bcfc629564abd56ca14f22c56ee4 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 5 Nov 2024 16:12:29 -0800 Subject: [PATCH 30/48] Correct N_PINS in FuelCycleUtilsTests --- armi/physics/fuelCycle/tests/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py index 3605fd647..fa21eed84 100644 --- a/armi/physics/fuelCycle/tests/test_utils.py +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -26,7 +26,7 @@ class FuelCycleUtilsTests(TestCase): """Tests for geometry indifferent fuel cycle routines.""" - N_PINS = 271 + N_PINS = 169 def setUp(self): self.block = Block("test block") From bb1a8da3c6a722f0b14a5bab7a44964d153c5fad Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 5 Nov 2024 16:15:28 -0800 Subject: [PATCH 31/48] Refactor burnup rotation test with a shuffled queue case Adds a test where we involve three assemblies `A -> B -> C` mimicing a real outage event. This test is important to make sure we aren't rotating assemblies that we need later. Heavy refactor to make the tests a bit easier to read (hopefully) by adding a few things 1. PinLocations enumeration to de-mystify some magic pin locations. 2. Helper methods for getting burnup vector with some known max location and pin power vector with known min locaiton 3. Comparison of the rotation called on an assembly with the integer number of rotations we know from the test --- .../tests/test_assemblyRotationAlgorithms.py | 261 ++++++++++++------ 1 file changed, 177 insertions(+), 84 deletions(-) diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index 2ce180d1f..b6b50d0ab 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -20,11 +20,15 @@ ``FuelHandler.outage()``. """ import copy -from unittest import mock +import enum +import math +import typing +from unittest import mock, TestCase import numpy as np from armi.reactor.assemblies import HexAssembly +from armi.reactor.blocks import HexBlock from armi.physics.fuelCycle import assemblyRotationAlgorithms as rotAlgos from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import ( getOptimalAssemblyOrientation, @@ -32,59 +36,111 @@ from armi.physics.fuelCycle import fuelHandlers from armi.physics.fuelCycle.settings import CONF_ASSEM_ROTATION_STATIONARY from armi.physics.fuelCycle.tests.test_fuelHandlers import addSomeDetailAssemblies -from armi.physics.fuelCycle.tests.test_fuelHandlers import FuelHandlerTestHelper from armi.reactor.flags import Flags +from armi.reactor.tests import test_reactors -class FullImplFuelHandler(fuelHandlers.FuelHandler): +class MockFuelHandler(fuelHandlers.FuelHandler): """Implements the entire interface but with empty methods.""" def chooseSwaps(self, *args, **kwargs): pass -class TestOptimalAssemblyRotation(FuelHandlerTestHelper): - N_PINS = 271 +class PinLocations(enum.IntEnum): + """Zero-indexed locations for specific points of interest. + + If a data vector has an entry to all ``self.N_PINS=169`` pins in the test model, + then ``data[PIN_LOCATIONS.UPPER_RIGHT_VERTEX]`` will access the data for the pin + along the upper right 60 symmetry line. Since we're dealing with rotations here, it + does not need to literally be the pin at the vertex. Just along the symmetry line + to help explain tests. + + The use case here is setting the pin or burnup array to be a constant value, but + using a single max or minimum value to determine rotation. + """ + + CENTER = 0 + UPPER_RIGHT_VERTEX = 1 + UPPER_LEFT_VERTEX = 2 + DUE_LEFT_VERTEX = 3 + LOWER_LEFT_VERTEX = 4 + LOWER_RIGHT_VERTEX = 5 + DUE_RIGHT_VERTEX = 6 + + +class ShuffleAndRotateTestHelper(TestCase): + """Fixture class to assist in testing rotation of assemblies via the fuel handler.""" + + N_PINS = 169 + + def setUp(self): + self.o, self.r = test_reactors.loadTestReactor() + self.r.core.locateAllAssemblies() @staticmethod - def prepShuffledAssembly(a: HexAssembly, percentBuMaxPinLocation: int): - """Prepare the assembly that will be shuffled and rotated.""" - for b in a.getChildrenWithFlags(Flags.FUEL): - # Fake enough information to build a spatial grid + def ensureBlockHasSpatialGrid(b: HexBlock): + """If ``b`` does not have a spatial grid, auto create one.""" + if b.spatialGrid is None: b.getPinPitch = mock.Mock(return_value=1.1) b.autoCreateSpatialGrids() + + def setAssemblyPinBurnups(self, a: HexAssembly, burnups: np.ndarray): + """Prepare the assembly that will be shuffled and rotated.""" + for b in a.getChildrenWithFlags(Flags.FUEL): + self.ensureBlockHasSpatialGrid(b) for c in b.getChildrenWithFlags(Flags.FUEL): - mult = c.getDimension("mult") - if mult <= percentBuMaxPinLocation: - continue - burnups = np.ones(mult, dtype=float) - burnups[percentBuMaxPinLocation] *= 2 c.p.pinPercentBu = burnups - @staticmethod - def prepPreviousAssembly(a: HexAssembly, pinPowers: list[float]): + def setAssemblyPinPowers(self, a: HexAssembly, pinPowers: np.ndarray): """Prep the assembly that existed at the site a shuffled assembly will occupy.""" for b in a.getChildrenWithFlags(Flags.FUEL): - # Fake enough information to build a spatial grid - b.getPinPitch = mock.Mock(return_value=1.1) - b.autoCreateSpatialGrids() + self.ensureBlockHasSpatialGrid(b) b.p.linPowByPin = pinPowers + def powerWithMinValue(self, minIndex: int) -> np.ndarray: + """Create a vector of pin powers with a minimum value at a given index.""" + data = np.ones(self.N_PINS) + data[minIndex] = 0 + return data + + def burnupWithMaxValue(self, maxIndex: int) -> np.ndarray: + """Create a vector of pin burnups with a maximum value at a given index.""" + data = np.zeros(self.N_PINS) + data[maxIndex] = 50 + return data + + def compareMockedToExpectedRotation( + self, nRotations: int, mRotate: mock.Mock, msg: typing.Optional[str] = None + ): + """Helper function to check the mocked rotate and compare against expected rotation.""" + expectedRadians = nRotations * math.pi / 3 + (actualRadians,) = mRotate.call_args.args + self.assertAlmostEqual(actualRadians, expectedRadians, msg=msg) + + +class TestOptimalAssemblyRotation(ShuffleAndRotateTestHelper): + """Test the burnup dependent assembly rotation methods.""" + + def setUp(self): + super().setUp() + self.assembly: HexAssembly = self.r.core.getFirstAssembly(Flags.FUEL) + def test_flatPowerNoRotation(self): """If all pin powers are identical, no rotation is suggested.""" - powers = np.ones(self.N_PINS) - # Identical powers but _some_ non-central "max" burnup pin - self.prepShuffledAssembly(self.assembly, percentBuMaxPinLocation=8) - self.prepPreviousAssembly(self.assembly, powers) + burnups = self.burnupWithMaxValue(PinLocations.UPPER_LEFT_VERTEX) + powers = np.ones_like(burnups) + self.setAssemblyPinBurnups(self.assembly, burnups) + self.setAssemblyPinPowers(self.assembly, powers) rot = getOptimalAssemblyOrientation(self.assembly, self.assembly) self.assertEqual(rot, 0) def test_maxBurnupAtCenterNoRotation(self): """If max burnup pin is at the center, no rotation is suggested.""" - # Fake a higher power towards the center - powers = np.arange(self.N_PINS)[::-1] - self.prepShuffledAssembly(self.assembly, percentBuMaxPinLocation=0) - self.prepPreviousAssembly(self.assembly, powers) + burnups = self.burnupWithMaxValue(PinLocations.CENTER) + powers = np.zeros_like(burnups) + self.setAssemblyPinBurnups(self.assembly, burnups) + self.setAssemblyPinPowers(self.assembly, powers) rot = getOptimalAssemblyOrientation(self.assembly, self.assembly) self.assertEqual(rot, 0) @@ -107,11 +163,19 @@ def test_oppositeRotation(self): """ shuffledAssembly = self.assembly previousAssembly = copy.deepcopy(shuffledAssembly) - for startPin, oppositePin in ((1, 4), (2, 5), (3, 6), (4, 1), (5, 2), (6, 3)): - powers = np.ones(self.N_PINS) - powers[oppositePin] = 0 - self.prepShuffledAssembly(shuffledAssembly, startPin) - self.prepPreviousAssembly(previousAssembly, powers) + pairs = ( + (PinLocations.DUE_RIGHT_VERTEX, PinLocations.DUE_LEFT_VERTEX), + (PinLocations.UPPER_LEFT_VERTEX, PinLocations.LOWER_RIGHT_VERTEX), + (PinLocations.UPPER_RIGHT_VERTEX, PinLocations.LOWER_LEFT_VERTEX), + (PinLocations.DUE_LEFT_VERTEX, PinLocations.DUE_RIGHT_VERTEX), + (PinLocations.LOWER_RIGHT_VERTEX, PinLocations.UPPER_LEFT_VERTEX), + (PinLocations.LOWER_LEFT_VERTEX, PinLocations.UPPER_RIGHT_VERTEX), + ) + for startPin, oppositePin in pairs: + powers = self.powerWithMinValue(oppositePin) + burnups = self.burnupWithMaxValue(startPin) + self.setAssemblyPinBurnups(shuffledAssembly, burnups) + self.setAssemblyPinPowers(previousAssembly, powers) rot = getOptimalAssemblyOrientation(shuffledAssembly, previousAssembly) # 180 degrees is three 60 degree rotations self.assertEqual(rot, 3, msg=f"{startPin=} :: {oppositePin=}") @@ -124,60 +188,16 @@ def test_noBlocksWithBurnup(self): def test_mismatchPinPowersAndLocations(self): """Require pin powers and locations to be have the same length.""" powers = np.arange(self.N_PINS + 1) - self.prepShuffledAssembly(self.assembly, percentBuMaxPinLocation=4) - self.prepPreviousAssembly(self.assembly, powers) + burnups = np.arange(self.N_PINS) + self.setAssemblyPinBurnups(self.assembly, burnups) + self.setAssemblyPinPowers(self.assembly, powers) with self.assertRaisesRegex( ValueError, "Inconsistent pin powers and number of pins" ): getOptimalAssemblyOrientation(self.assembly, self.assembly) -class TestFuelHandlerMgmtTools(FuelHandlerTestHelper): - @staticmethod - def prepareAssemForBurnupRotation(assem: HexAssembly): - """Set necessary data in order for the burnup rotation to be performed.""" - for b in assem.getBlocks(Flags.FUEL): - b.initializePinLocations() - fuel = b.getChildrenWithFlags(Flags.FUEL)[0] - mult = fuel.getDimension("mult") - fuel.p.pinPercentBu = np.arange(mult, dtype=float)[::-1] - b.p.linPowByPin = reversed(range(b.getNumPins())) - - def test_buReducingAssemblyRotation(self): - """Test that the fuel handler supports the burnup reducing assembly rotation.""" - newSettings = { - CONF_ASSEM_ROTATION_STATIONARY: False, - "fluxRecon": True, - "assemblyRotationAlgorithm": "buReducingAssemblyRotation", - } - self.o.cs = self.o.cs.modified(newSettings=newSettings) - - # Grab two assemblies and make them look like they were part of a shuffling operation - rotatedAssem, shuffledAway = self.o.r.core.getChildrenWithFlags(Flags.FUEL)[:2] - self.prepareAssemForBurnupRotation(rotatedAssem) - self.prepareAssemForBurnupRotation(shuffledAway) - - # Mimic a shuffling where shuffledAway used to be where rotatedAssem is now - shuffledAway.lastLocationLabel = rotatedAssem.getLocation() - - # Show that we call the optimal assembly orientation function. - # This function is tested seperately and more extensively elsewhere. - fh = FullImplFuelHandler(self.o) - # Mocked swap function updates the moved assemblies collection - fh.chooseSwaps = mock.Mock(side_effect=lambda _: fh.moved.append(shuffledAway)) - with mock.patch( - "armi.physics.fuelCycle.assemblyRotationAlgorithms.getOptimalAssemblyOrientation", - return_value=4, - ) as p: - fh.outage(1) - # fh.outage clears fh.moved at the end. But if it was called, we know our mock function added - # our shuffled assembly to the moved assemblies. - fh.chooseSwaps.assert_called_once() - p.assert_called_once_with(rotatedAssem, shuffledAway) - for b in rotatedAssem.getBlocks(Flags.FUEL): - # Four rotations is 240 degrees - self.assertEqual(b.p.orientation[2], 240) - +class TestFuelHandlerMgmtTools(ShuffleAndRotateTestHelper): def test_buRotationWithFreshFeed(self): """Test that rotation works if a new assembly is swapped with fresh fuel. @@ -192,7 +212,7 @@ def test_buRotationWithFreshFeed(self): fresh = self.r.core.createFreshFeed(self.o.cs) self.assertEqual(fresh.lastLocationLabel, HexAssembly.LOAD_QUEUE) - fh = FullImplFuelHandler(self.o) + fh = MockFuelHandler(self.o) fh.chooseSwaps = mock.Mock(side_effect=lambda _: fh.moved.append(fresh)) with mock.patch( @@ -216,11 +236,12 @@ def test_buRotationWithStationaryRotation(self): # Grab two assemblies that were not moved. One of which will have the detailed information # needed for rotation detailedAssem, coarseAssem = self.o.r.core.getChildrenWithFlags(Flags.FUEL)[:2] - self.prepareAssemForBurnupRotation(detailedAssem) + self.setAssemblyPinBurnups(detailedAssem, burnups=np.arange(self.N_PINS)) + self.setAssemblyPinPowers(detailedAssem, pinPowers=np.arange(self.N_PINS)) detailedAssem.rotate = mock.Mock() coarseAssem.rotate = mock.Mock() - fh = FullImplFuelHandler(self.o) + fh = MockFuelHandler(self.o) with mock.patch( "armi.physics.fuelCycle.assemblyRotationAlgorithms.getOptimalAssemblyOrientation", @@ -228,11 +249,83 @@ def test_buRotationWithStationaryRotation(self): ) as p: fh.outage() p.assert_called_once_with(detailedAssem, detailedAssem) + # Assembly with detailed pin powers and pin burnups will be rotated detailedAssem.rotate.assert_called_once() + self.compareMockedToExpectedRotation(5, detailedAssem.rotate) + # Assembly without pin level data will not be rotated coarseAssem.rotate.assert_not_called() + def test_rotateInShuffleQueue(self): + """Test for expected behavior when multiple assemblies are shuffled and rotated in one outage. + + Examine the behavior of three assemblies: ``first -> second -> third`` + + 1. ``first`` is moved to the location of ``second`` and rotated by comparing + ``first`` burnup against ``second`` pin powers. + 2. ``second`` is moved to the location of ``third`` and rotated by comparing + ``second`` burnup against ``third`` pin powers. + + where: + + * ``first`` burnup is maximized in the upper left direction. + * ``second`` pin power is minimized along the lower left direction. + * ``second`` burnup is maximized in the upper right direction. + * ``third`` pin power is minimized in the direct right direction. + + We should expect: + + 1. ``first`` is rotated from upper left to lower left => two 60 degree CCW rotations. + 2. ``second`` is rotated from upper right to direct right => five 60 degree CCW rotations. + """ + newSettings = { + CONF_ASSEM_ROTATION_STATIONARY: False, + "fluxRecon": True, + "assemblyRotationAlgorithm": "buReducingAssemblyRotation", + } + self.o.cs = self.o.cs.modified(newSettings=newSettings) + + first, second, third = self.r.core.getChildrenWithFlags(Flags.FUEL)[:3] + + firstBurnups = self.burnupWithMaxValue(PinLocations.UPPER_LEFT_VERTEX) + self.setAssemblyPinBurnups(first, firstBurnups) + + secondPowers = self.powerWithMinValue(PinLocations.LOWER_LEFT_VERTEX) + self.setAssemblyPinPowers(second, pinPowers=secondPowers) + + secondBurnups = self.burnupWithMaxValue(PinLocations.UPPER_RIGHT_VERTEX) + self.setAssemblyPinBurnups(second, burnups=secondBurnups) + + thirdPowers = self.powerWithMinValue(PinLocations.DUE_RIGHT_VERTEX) + self.setAssemblyPinPowers(third, thirdPowers) + + # Set the shuffling sequence + # first -> second + # second -> third + second.lastLocationLabel = first.getLocation() + third.lastLocationLabel = second.getLocation() + + first.rotate = mock.Mock() + second.rotate = mock.Mock() + third.rotate = mock.Mock() + + fh = MockFuelHandler(self.o) + fh.chooseSwaps = mock.Mock( + side_effect=lambda _: fh.moved.extend([second, third]) + ) + fh.outage() + + first.rotate.assert_called_once() + self.compareMockedToExpectedRotation(2, first.rotate, "First") + second.rotate.assert_called_once() + self.compareMockedToExpectedRotation(5, second.rotate, "Second") + third.rotate.assert_not_called() + + +class SimpleRotationTests(ShuffleAndRotateTestHelper): + """Test the simple rotation where assemblies are rotated a fixed amount.""" + def test_simpleAssemblyRotation(self): - """Test rotating assemblies 120 degrees.""" + """Test rotating assemblies 120 degrees with two rotation events.""" fh = fuelHandlers.FuelHandler(self.o) newSettings = {CONF_ASSEM_ROTATION_STATIONARY: True} self.o.cs = self.o.cs.modified(newSettings=newSettings) From 7410094fdf9fb57c39d9591923ea33ce6008d2a9 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 5 Nov 2024 16:33:35 -0800 Subject: [PATCH 32/48] Testing for two errors in fuel cycle maxBurnupLocator --- armi/physics/fuelCycle/tests/test_utils.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py index fa21eed84..75123d6e1 100644 --- a/armi/physics/fuelCycle/tests/test_utils.py +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -72,6 +72,24 @@ def test_singleLocatorWithBurnup(self): loc = utils.maxBurnupLocator([freeComp]) self.assertIs(loc, freeComp.spatialLocator) + def test_maxBurnupLocatorWithNoBurnup(self): + """Ensure we catch an error if no burnup is found across components.""" + with self.assertRaisesRegex(ValueError, "No burnups found"): + utils.maxBurnupLocator([]) + + def test_maxBurnupLocatorMismatchedData(self): + """Ensure pin burnup and locations must agree.""" + freeComp = Circle( + "free fuel", material="UO2", Tinput=200, Thot=200, id=0, od=1, mult=1 + ) + freeComp.spatialLocator = IndexLocation(2, 4, 0, None) + freeComp.p.pinPercentBu = [ + 0.01, + 0.02, + ] + with self.assertRaisesRegex(ValueError, "Pin burnup.*pin locations.*differ"): + utils.maxBurnupLocator([freeComp]) + def test_assemblyHasPinPower(self): """Test the ability to check if an assembly has fuel pin powers.""" fakeAssem = [self.block] From ad1d9de5eec133571247dc5ebc21e9f96311025c Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 5 Nov 2024 16:34:08 -0800 Subject: [PATCH 33/48] Add a clad to the fake block in fuel cycle utils testing Not really necessary, but helps bolster the argument we handle blocks with more than just a single fuel pin --- armi/physics/fuelCycle/tests/test_utils.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py index 75123d6e1..9a6a2109d 100644 --- a/armi/physics/fuelCycle/tests/test_utils.py +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -39,7 +39,17 @@ def setUp(self): id=0.0, od=1.0, ) + + clad = Circle( + "clad", + material="HT9", + Tinput=20, + Thot=300, + id=1.0, + od=1.1, + ) self.block.add(self.fuel) + self.block.add(clad) # Force no fuel flags self.fuel.p.flags = Flags.PIN From 2ad70b6eb14d6af7c70fa398972915b5dae9abde Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 5 Nov 2024 16:34:24 -0800 Subject: [PATCH 34/48] Add testing for fuelCycle utils maxBurnupBlock --- armi/physics/fuelCycle/tests/test_utils.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py index 9a6a2109d..c2654219b 100644 --- a/armi/physics/fuelCycle/tests/test_utils.py +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy from unittest import TestCase import numpy as np @@ -139,3 +140,23 @@ def test_assemblyHasPinBurnups(self): self.assertFalse(self.fuel.hasFlags(Flags.FUEL)) self.fuel.p.pinPercentBu = np.arange(self.N_PINS, dtype=float) self.assertFalse(utils.assemblyHasFuelPinBurnup(fakeAssem)) + + def test_maxBurnupBlock(self): + """Test the ability to find maximum burnup block in an assembly.""" + reflector = Block("reflector") + assem = [reflector, self.block] + self.fuel.p.pinPercentBu = [0.1] + expected = utils.maxBurnupBlock(assem) + self.assertIs(expected, self.block) + + # add a new block with more burnup higher up the stack + hotter = copy.deepcopy(self.block) + hotter[0].p.pinPercentBu *= 2 + expected = utils.maxBurnupBlock( + [reflector, self.block, hotter, self.block, reflector] + ) + self.assertIs(expected, hotter) + + def test_maxBurnupBlockNoBurnup(self): + with self.assertRaisesRegex(ValueError, "No blocks with burnup found"): + utils.maxBurnupBlock([]) From b258ec6d2fc3641b90f44bd2e225470da3ed673d Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 18 Nov 2024 11:04:24 -0800 Subject: [PATCH 35/48] fuelCycle.utils.maxBurnupBlock uses Block.p.percentBuPeak --- .../tests/test_assemblyRotationAlgorithms.py | 4 ++- armi/physics/fuelCycle/tests/test_utils.py | 15 ++++++++--- armi/physics/fuelCycle/utils.py | 27 ++++++++----------- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index b6b50d0ab..497c35cc9 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -87,8 +87,10 @@ def ensureBlockHasSpatialGrid(b: HexBlock): def setAssemblyPinBurnups(self, a: HexAssembly, burnups: np.ndarray): """Prepare the assembly that will be shuffled and rotated.""" + peakBu = burnups.max() for b in a.getChildrenWithFlags(Flags.FUEL): self.ensureBlockHasSpatialGrid(b) + b.p.percentBuPeak = peakBu for c in b.getChildrenWithFlags(Flags.FUEL): c.p.pinPercentBu = burnups @@ -182,7 +184,7 @@ def test_oppositeRotation(self): def test_noBlocksWithBurnup(self): """Require at least one block to have burnup.""" - with self.assertRaisesRegex(ValueError, "No blocks with burnup found"): + with self.assertRaisesRegex(ValueError, "Error finding max burnup"): getOptimalAssemblyOrientation(self.assembly, self.assembly) def test_mismatchPinPowersAndLocations(self): diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py index c2654219b..a36b412a9 100644 --- a/armi/physics/fuelCycle/tests/test_utils.py +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -145,18 +145,25 @@ def test_maxBurnupBlock(self): """Test the ability to find maximum burnup block in an assembly.""" reflector = Block("reflector") assem = [reflector, self.block] - self.fuel.p.pinPercentBu = [0.1] + self.block.p.percentBuPeak = 40 expected = utils.maxBurnupBlock(assem) self.assertIs(expected, self.block) # add a new block with more burnup higher up the stack hotter = copy.deepcopy(self.block) - hotter[0].p.pinPercentBu *= 2 + hotter.p.percentBuPeak *= 2 expected = utils.maxBurnupBlock( [reflector, self.block, hotter, self.block, reflector] ) self.assertIs(expected, hotter) - def test_maxBurnupBlockNoBurnup(self): - with self.assertRaisesRegex(ValueError, "No blocks with burnup found"): + def test_maxBurnupBlockNoBlocks(self): + """Ensure a more helpful error is provided for empty sequence.""" + with self.assertRaisesRegex(ValueError, "Error finding max burnup"): utils.maxBurnupBlock([]) + + def test_maxBurnupBlockNoBurnup(self): + """Ensure that we will not return a block with zero burnup.""" + self.block.p.percentBuPeak = 0.0 + with self.assertRaisesRegex(ValueError, "Error finding max burnup"): + utils.maxBurnupBlock([self.block]) diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py index fd32809d1..142604027 100644 --- a/armi/physics/fuelCycle/utils.py +++ b/armi/physics/fuelCycle/utils.py @@ -14,9 +14,11 @@ """Geometric agnostic routines that are useful for fuel cycle analysis.""" import typing +import operator import numpy as np +from armi import runLog from armi.reactor.flags import Flags from armi.reactor.grids import IndexLocation, MultiIndexLocation @@ -129,19 +131,12 @@ def maxBurnupLocator( def maxBurnupBlock(a: typing.Iterable["Block"]) -> "Block": """Find the block that contains the pin with the highest burnup.""" - maxBlock = None - maxBurnup = 0 - for b in a: - maxCompBu = 0 - for c in b: - if not np.any(c.p.pinPercentBu): - continue - compBu = c.p.pinPercentBu.max() - if compBu > maxCompBu: - maxCompBu = compBu - if maxCompBu > maxBurnup: - maxBurnup = maxCompBu - maxBlock = b - if maxBlock is not None: - return maxBlock - raise ValueError("No blocks with burnup found") + buGetter = operator.attrgetter("p.percentBuPeak") + # Discard any blocks with zero burnup + blocksWithBurnup = filter(buGetter, a) + try: + return max(blocksWithBurnup, key=buGetter) + except Exception as ee: + msg = f"Error finding max burnup block from {a}" + runLog.error(msg) + raise ValueError(msg) from ee From ff904c48a83d48feade5b2b495eb263c21d91214 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 22 Nov 2024 08:42:18 -0800 Subject: [PATCH 36/48] Drop Block.p.percentBuMax and percentBuMaxPinLocation `percentBuMax` renamed / re-mapped to `percentBuPeak` while pin location is dropped. --- armi/physics/neutronics/__init__.py | 7 ++++++- armi/reactor/blockParameters.py | 21 +++++---------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/armi/physics/neutronics/__init__.py b/armi/physics/neutronics/__init__.py index 472ace732..1ca2f51cb 100644 --- a/armi/physics/neutronics/__init__.py +++ b/armi/physics/neutronics/__init__.py @@ -67,7 +67,11 @@ def defineParameters(): @staticmethod @plugins.HOOKIMPL def defineParameterRenames(): - return {"buGroup": "envGroup", "buGroupNum": "envGroupNum"} + return { + "buGroup": "envGroup", + "buGroupNum": "envGroupNum", + "percentBuMax": "percentBuPeak", + } @staticmethod @plugins.HOOKIMPL @@ -176,6 +180,7 @@ def getReportContents(r, cs, report, stage, blueprint): ZEROFLUX = "ZeroSurfaceFlux" ZERO_INWARD_CURRENT = "ZeroInwardCurrent" + # Common settings checks def gammaTransportIsRequested(cs): """ diff --git a/armi/reactor/blockParameters.py b/armi/reactor/blockParameters.py index ab65301a0..870492381 100644 --- a/armi/reactor/blockParameters.py +++ b/armi/reactor/blockParameters.py @@ -136,21 +136,6 @@ def getBlockParameterDefinitions(): location=ParamLocation.CHILDREN, ) - pb.defParam( - "percentBuMax", - units=units.PERCENT_FIMA, - description="Maximum percentage in a single pin of the initial heavy metal " - "atoms that have been fissioned", - location=ParamLocation.MAX, - ) - - pb.defParam( - "percentBuMaxPinLocation", - units=units.UNITLESS, - description="Peak burnup pin location (integer)", - location=ParamLocation.MAX, - ) - pb.defParam( "residence", units=units.DAYS, @@ -791,7 +776,11 @@ def xsTypeNum(self, value): units=units.PERCENT_FIMA, description="Peak percentage of the initial heavy metal atoms that have been fissioned", location=ParamLocation.MAX, - categories=["cumulative", "eq cumulative shift"], + categories=[ + parameters.Category.cumulative, + "eq cumulative shift", + parameters.Category.depletion, + ], ) pb.defParam( From 0c3bac51422854c7d524ce9da7f374ec57852f18 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 18 Nov 2024 11:36:50 -0800 Subject: [PATCH 37/48] Type hint return on defineParameterRenames hookspec --- armi/plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armi/plugins.py b/armi/plugins.py index fdeb4cd66..b32fd2869 100644 --- a/armi/plugins.py +++ b/armi/plugins.py @@ -556,7 +556,7 @@ def getOperatorClassFromRunType(runType: str): @staticmethod @HOOKSPEC - def defineParameterRenames() -> Dict: + def defineParameterRenames() -> dict[str, str]: """ Return a mapping from old parameter names to new parameter names. From bb6dee9d7d2b68b8e3a6703fd267bca2b7a54820 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 22 Nov 2024 10:46:34 -0800 Subject: [PATCH 38/48] Sort some imports --- armi/physics/fuelCycle/assemblyRotationAlgorithms.py | 10 +++++----- armi/physics/fuelCycle/fuelHandlers.py | 1 - .../fuelCycle/tests/test_assemblyRotationAlgorithms.py | 8 ++++---- armi/physics/fuelCycle/tests/test_fuelHandlers.py | 4 +--- armi/physics/fuelCycle/tests/test_utils.py | 2 +- armi/physics/fuelCycle/utils.py | 4 ++-- 6 files changed, 13 insertions(+), 16 deletions(-) diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py index c69f2c6d7..0230f2fbc 100644 --- a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py @@ -25,15 +25,15 @@ from collections import defaultdict from armi import runLog -from armi.reactor.assemblies import Assembly -from armi.physics.fuelCycle.utils import ( - assemblyHasFuelPinBurnup, - assemblyHasFuelPinPowers, -) from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import ( getOptimalAssemblyOrientation, ) from armi.physics.fuelCycle.settings import CONF_ASSEM_ROTATION_STATIONARY +from armi.physics.fuelCycle.utils import ( + assemblyHasFuelPinBurnup, + assemblyHasFuelPinPowers, +) +from armi.reactor.assemblies import Assembly def _rotationNumberToRadians(rot: int) -> float: diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py index 5f814a5ea..fe9562a0e 100644 --- a/armi/physics/fuelCycle/fuelHandlers.py +++ b/armi/physics/fuelCycle/fuelHandlers.py @@ -29,7 +29,6 @@ import os import re - import numpy as np from armi import runLog diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index 497c35cc9..b052ac4a3 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -23,19 +23,19 @@ import enum import math import typing -from unittest import mock, TestCase +from unittest import TestCase, mock import numpy as np -from armi.reactor.assemblies import HexAssembly -from armi.reactor.blocks import HexBlock from armi.physics.fuelCycle import assemblyRotationAlgorithms as rotAlgos +from armi.physics.fuelCycle import fuelHandlers from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import ( getOptimalAssemblyOrientation, ) -from armi.physics.fuelCycle import fuelHandlers from armi.physics.fuelCycle.settings import CONF_ASSEM_ROTATION_STATIONARY from armi.physics.fuelCycle.tests.test_fuelHandlers import addSomeDetailAssemblies +from armi.reactor.assemblies import HexAssembly +from armi.reactor.blocks import HexBlock from armi.reactor.flags import Flags from armi.reactor.tests import test_reactors diff --git a/armi/physics/fuelCycle/tests/test_fuelHandlers.py b/armi/physics/fuelCycle/tests/test_fuelHandlers.py index 5a8d16b5d..281a771ca 100644 --- a/armi/physics/fuelCycle/tests/test_fuelHandlers.py +++ b/armi/physics/fuelCycle/tests/test_fuelHandlers.py @@ -41,9 +41,7 @@ from armi.reactor.tests import test_reactors from armi.reactor.zones import Zone from armi.settings import caseSettings -from armi.tests import ArmiTestHelper -from armi.tests import mockRunLogs -from armi.tests import TEST_ROOT +from armi.tests import TEST_ROOT, ArmiTestHelper, mockRunLogs from armi.utils import directoryChangers diff --git a/armi/physics/fuelCycle/tests/test_utils.py b/armi/physics/fuelCycle/tests/test_utils.py index a36b412a9..a03eee700 100644 --- a/armi/physics/fuelCycle/tests/test_utils.py +++ b/armi/physics/fuelCycle/tests/test_utils.py @@ -17,11 +17,11 @@ import numpy as np +from armi.physics.fuelCycle import utils from armi.reactor.blocks import Block from armi.reactor.components import Circle from armi.reactor.flags import Flags from armi.reactor.grids import IndexLocation, MultiIndexLocation -from armi.physics.fuelCycle import utils class FuelCycleUtilsTests(TestCase): diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py index 142604027..8985be1ef 100644 --- a/armi/physics/fuelCycle/utils.py +++ b/armi/physics/fuelCycle/utils.py @@ -13,8 +13,8 @@ # limitations under the License. """Geometric agnostic routines that are useful for fuel cycle analysis.""" -import typing import operator +import typing import numpy as np @@ -23,8 +23,8 @@ from armi.reactor.grids import IndexLocation, MultiIndexLocation if typing.TYPE_CHECKING: - from armi.reactor.components import Component from armi.reactor.blocks import Block + from armi.reactor.components import Component def assemblyHasFuelPinPowers(a: typing.Iterable["Block"]) -> bool: From b1befeed584b27a99ab0acfec55c07583b0be607 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 22 Nov 2024 11:07:18 -0800 Subject: [PATCH 39/48] Unify convoluted fuel handler invocation of rotation algorithm --- armi/physics/fuelCycle/fuelHandlers.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py index fe9562a0e..abe079d0b 100644 --- a/armi/physics/fuelCycle/fuelHandlers.py +++ b/armi/physics/fuelCycle/fuelHandlers.py @@ -114,7 +114,7 @@ def outage(self, factor=1.0): # The user can choose the algorithm method name directly in the settings if hasattr(rotAlgos, self.cs[CONF_ASSEMBLY_ROTATION_ALG]): rotationMethod = getattr(rotAlgos, self.cs[CONF_ASSEMBLY_ROTATION_ALG]) - self._tryCallAssemblyRotation(rotationMethod) + rotationMethod(self) else: raise RuntimeError( "FuelHandler {0} does not have a rotation algorithm called {1}.\n" @@ -163,15 +163,6 @@ def outage(self, factor=1.0): self.moved = [] return moved - def _tryCallAssemblyRotation(self, rotationMethod): - """Determine the best way to call the rotation algorithm. - - Some callers take no arguments, some take the fuel handler. - """ - sig = inspect.signature(rotationMethod) - args = (self,) if sig.parameters else () - rotationMethod(*args) - def chooseSwaps(self, shuffleFactors=None): """Moves the fuel around or otherwise processes it between cycles.""" raise NotImplementedError From 039373f6d9eae49e2db790b1e2d3f61ed8b78cfa Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 22 Nov 2024 11:10:33 -0800 Subject: [PATCH 40/48] Revert "Drop Block.p.percentBuMax and percentBuMaxPinLocation" This reverts commit ff904c48a83d48feade5b2b495eb263c21d91214. --- armi/physics/neutronics/__init__.py | 7 +------ armi/reactor/blockParameters.py | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/armi/physics/neutronics/__init__.py b/armi/physics/neutronics/__init__.py index 1ca2f51cb..472ace732 100644 --- a/armi/physics/neutronics/__init__.py +++ b/armi/physics/neutronics/__init__.py @@ -67,11 +67,7 @@ def defineParameters(): @staticmethod @plugins.HOOKIMPL def defineParameterRenames(): - return { - "buGroup": "envGroup", - "buGroupNum": "envGroupNum", - "percentBuMax": "percentBuPeak", - } + return {"buGroup": "envGroup", "buGroupNum": "envGroupNum"} @staticmethod @plugins.HOOKIMPL @@ -180,7 +176,6 @@ def getReportContents(r, cs, report, stage, blueprint): ZEROFLUX = "ZeroSurfaceFlux" ZERO_INWARD_CURRENT = "ZeroInwardCurrent" - # Common settings checks def gammaTransportIsRequested(cs): """ diff --git a/armi/reactor/blockParameters.py b/armi/reactor/blockParameters.py index 870492381..ab65301a0 100644 --- a/armi/reactor/blockParameters.py +++ b/armi/reactor/blockParameters.py @@ -136,6 +136,21 @@ def getBlockParameterDefinitions(): location=ParamLocation.CHILDREN, ) + pb.defParam( + "percentBuMax", + units=units.PERCENT_FIMA, + description="Maximum percentage in a single pin of the initial heavy metal " + "atoms that have been fissioned", + location=ParamLocation.MAX, + ) + + pb.defParam( + "percentBuMaxPinLocation", + units=units.UNITLESS, + description="Peak burnup pin location (integer)", + location=ParamLocation.MAX, + ) + pb.defParam( "residence", units=units.DAYS, @@ -776,11 +791,7 @@ def xsTypeNum(self, value): units=units.PERCENT_FIMA, description="Peak percentage of the initial heavy metal atoms that have been fissioned", location=ParamLocation.MAX, - categories=[ - parameters.Category.cumulative, - "eq cumulative shift", - parameters.Category.depletion, - ], + categories=["cumulative", "eq cumulative shift"], ) pb.defParam( From d486f6c62c25e1dba9db420293a882b77608441f Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 22 Nov 2024 11:10:35 -0800 Subject: [PATCH 41/48] Revert "Type hint return on defineParameterRenames hookspec" This reverts commit 0c3bac51422854c7d524ce9da7f374ec57852f18. --- armi/plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armi/plugins.py b/armi/plugins.py index b32fd2869..fdeb4cd66 100644 --- a/armi/plugins.py +++ b/armi/plugins.py @@ -556,7 +556,7 @@ def getOperatorClassFromRunType(runType: str): @staticmethod @HOOKSPEC - def defineParameterRenames() -> dict[str, str]: + def defineParameterRenames() -> Dict: """ Return a mapping from old parameter names to new parameter names. From ca629301f28be0742e41e6da31caeeaa65c2466c Mon Sep 17 00:00:00 2001 From: Drew Johnson Date: Fri, 22 Nov 2024 11:12:31 -0800 Subject: [PATCH 42/48] Update armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py Co-authored-by: John Stilley <1831479+john-science@users.noreply.github.com> --- armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index b052ac4a3..ecaedd01a 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -160,7 +160,7 @@ def test_oppositeRotation(self): Notes ----- - Note: use zero-indexed pin location not pin ID to assign burnups and powers. Since + Use zero-indexed pin location not pin ID to assign burnups and powers. Since we have a single component, ``Block.p.linPowByPin[i] <-> Component.p.pinPercentBu[i]`` """ shuffledAssembly = self.assembly From 596b7ca6e58446ca09ce0ae660ec7ca5c64c71f9 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 22 Nov 2024 11:13:13 -0800 Subject: [PATCH 43/48] Indicate that fuelCycle/utils.py is for pin-type reactors --- armi/physics/fuelCycle/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armi/physics/fuelCycle/utils.py b/armi/physics/fuelCycle/utils.py index 8985be1ef..e0f9c14c3 100644 --- a/armi/physics/fuelCycle/utils.py +++ b/armi/physics/fuelCycle/utils.py @@ -11,7 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Geometric agnostic routines that are useful for fuel cycle analysis.""" +"""Geometric agnostic routines that are useful for fuel cycle analysis on pin-type reactors.""" import operator import typing From e319100fba44ccf2e2eb381896b35d26b0b9e975 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Wed, 27 Nov 2024 09:29:48 -0800 Subject: [PATCH 44/48] Remove debug statements from getOptimalAssemblyOrientation --- armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index b03b1223a..821b15096 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -117,23 +117,12 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int targetGrid = blockAtPreviousLocation.spatialGrid candidateRotation = 0 candidatePower = ringPowers.get((maxBuPinLocation.i, maxBuPinLocation.j), math.inf) - runLog.debug(f"::Checking possible rotations for {a}") - runLog.debug( - f"::rotation={candidateRotation}::power after rotation={candidatePower}::location={maxBuPinLocation}" - ) for rot in range(1, 6): candidateLocation = targetGrid.rotateIndex(maxBuPinLocation, rot) newPower = ringPowers.get((candidateLocation.i, candidateLocation.j), math.inf) - runLog.debug( - f"::rotation={rot}::power after rotation={newPower}::location={candidateLocation}" - ) if newPower < candidatePower: candidateRotation = rot candidatePower = newPower - runLog.debug( - f"::new minimum power for {candidateRotation=}::{candidatePower=}" - ) - runLog.debug(f"::chose {candidateRotation=}") return candidateRotation From 52ec5d8f0f009cb328b93f0cdfd4c429471801e1 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Wed, 27 Nov 2024 09:31:13 -0800 Subject: [PATCH 45/48] Use runLog.error and exceptions in getOptimalAssemblyOrientation --- armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index 821b15096..8acea4ab0 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -89,9 +89,9 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int """ maxBuBlock = maxBurnupBlock(a) if maxBuBlock.spatialGrid is None: - raise ValueError( - f"Block {maxBuBlock} in {a} does not have a spatial grid. Cannot rotate." - ) + msg = f"Block {maxBuBlock} in {a} does not have a spatial grid. Cannot rotate." + runLog.error(msg) + raise ValueError(msg) maxBuPinLocation = maxBurnupLocator(maxBuBlock) # No need to rotate if max burnup pin is the center if maxBuPinLocation.i == 0 and maxBuPinLocation.j == 0: @@ -105,10 +105,12 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int previousLocations = blockAtPreviousLocation.getPinLocations() previousPowers = blockAtPreviousLocation.p.linPowByPin if len(previousLocations) != len(previousPowers): - raise ValueError( + msg = ( f"Inconsistent pin powers and number of pins in {blockAtPreviousLocation}. " f"Found {len(previousLocations)} locations but {len(previousPowers)} powers." ) + runLog.error(msg) + raise ValueError(msg) ringPowers = { (loc.i, loc.j): p for loc, p in zip(previousLocations, previousPowers) From 730f7d145e7038b0b83be019b736df67738933a3 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Wed, 27 Nov 2024 09:41:08 -0800 Subject: [PATCH 46/48] Update release notes --- doc/release/0.5.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/release/0.5.rst b/doc/release/0.5.rst index 91a786eea..e77806186 100644 --- a/doc/release/0.5.rst +++ b/doc/release/0.5.rst @@ -81,6 +81,7 @@ API Changes #. History Tracker: "detail assemblies" are now fuel and control assemblies. (`PR#1990 `_) #. Removing ``Block.breakFuelComponentsIntoIndividuals()``. (`PR#1990 `_) #. Moving ``getPuMoles`` from blocks.py up to composites.py. (`PR#1990 `_) +#. ``buReducingAssemblyRotation`` and ``getOptimalAssemblyOrientation`` require pin level burnup. (`PR#2019 `_) Bug Fixes --------- From 159f4763f8d7711ab5f5531b735db67295cf2196 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 2 Dec 2024 09:17:30 -0800 Subject: [PATCH 47/48] Make PinLocations test util private Avoid people importing it into their code. Hopefully. IDK --- .../tests/test_assemblyRotationAlgorithms.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index ecaedd01a..66ea0a6ed 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -47,7 +47,7 @@ def chooseSwaps(self, *args, **kwargs): pass -class PinLocations(enum.IntEnum): +class _PinLocations(enum.IntEnum): """Zero-indexed locations for specific points of interest. If a data vector has an entry to all ``self.N_PINS=169`` pins in the test model, @@ -130,7 +130,7 @@ def setUp(self): def test_flatPowerNoRotation(self): """If all pin powers are identical, no rotation is suggested.""" - burnups = self.burnupWithMaxValue(PinLocations.UPPER_LEFT_VERTEX) + burnups = self.burnupWithMaxValue(_PinLocations.UPPER_LEFT_VERTEX) powers = np.ones_like(burnups) self.setAssemblyPinBurnups(self.assembly, burnups) self.setAssemblyPinPowers(self.assembly, powers) @@ -139,7 +139,7 @@ def test_flatPowerNoRotation(self): def test_maxBurnupAtCenterNoRotation(self): """If max burnup pin is at the center, no rotation is suggested.""" - burnups = self.burnupWithMaxValue(PinLocations.CENTER) + burnups = self.burnupWithMaxValue(_PinLocations.CENTER) powers = np.zeros_like(burnups) self.setAssemblyPinBurnups(self.assembly, burnups) self.setAssemblyPinPowers(self.assembly, powers) @@ -166,12 +166,12 @@ def test_oppositeRotation(self): shuffledAssembly = self.assembly previousAssembly = copy.deepcopy(shuffledAssembly) pairs = ( - (PinLocations.DUE_RIGHT_VERTEX, PinLocations.DUE_LEFT_VERTEX), - (PinLocations.UPPER_LEFT_VERTEX, PinLocations.LOWER_RIGHT_VERTEX), - (PinLocations.UPPER_RIGHT_VERTEX, PinLocations.LOWER_LEFT_VERTEX), - (PinLocations.DUE_LEFT_VERTEX, PinLocations.DUE_RIGHT_VERTEX), - (PinLocations.LOWER_RIGHT_VERTEX, PinLocations.UPPER_LEFT_VERTEX), - (PinLocations.LOWER_LEFT_VERTEX, PinLocations.UPPER_RIGHT_VERTEX), + (_PinLocations.DUE_RIGHT_VERTEX, _PinLocations.DUE_LEFT_VERTEX), + (_PinLocations.UPPER_LEFT_VERTEX, _PinLocations.LOWER_RIGHT_VERTEX), + (_PinLocations.UPPER_RIGHT_VERTEX, _PinLocations.LOWER_LEFT_VERTEX), + (_PinLocations.DUE_LEFT_VERTEX, _PinLocations.DUE_RIGHT_VERTEX), + (_PinLocations.LOWER_RIGHT_VERTEX, _PinLocations.UPPER_LEFT_VERTEX), + (_PinLocations.LOWER_LEFT_VERTEX, _PinLocations.UPPER_RIGHT_VERTEX), ) for startPin, oppositePin in pairs: powers = self.powerWithMinValue(oppositePin) @@ -288,16 +288,16 @@ def test_rotateInShuffleQueue(self): first, second, third = self.r.core.getChildrenWithFlags(Flags.FUEL)[:3] - firstBurnups = self.burnupWithMaxValue(PinLocations.UPPER_LEFT_VERTEX) + firstBurnups = self.burnupWithMaxValue(_PinLocations.UPPER_LEFT_VERTEX) self.setAssemblyPinBurnups(first, firstBurnups) - secondPowers = self.powerWithMinValue(PinLocations.LOWER_LEFT_VERTEX) + secondPowers = self.powerWithMinValue(_PinLocations.LOWER_LEFT_VERTEX) self.setAssemblyPinPowers(second, pinPowers=secondPowers) - secondBurnups = self.burnupWithMaxValue(PinLocations.UPPER_RIGHT_VERTEX) + secondBurnups = self.burnupWithMaxValue(_PinLocations.UPPER_RIGHT_VERTEX) self.setAssemblyPinBurnups(second, burnups=secondBurnups) - thirdPowers = self.powerWithMinValue(PinLocations.DUE_RIGHT_VERTEX) + thirdPowers = self.powerWithMinValue(_PinLocations.DUE_RIGHT_VERTEX) self.setAssemblyPinPowers(third, thirdPowers) # Set the shuffling sequence From 40754b22a49e81740ec803a14ea53a336c8cd362 Mon Sep 17 00:00:00 2001 From: Drew Johnson Date: Mon, 2 Dec 2024 11:57:29 -0800 Subject: [PATCH 48/48] Apply suggestions from code review Co-authored-by: John Stilley <1831479+john-science@users.noreply.github.com> --- armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py | 6 +++--- doc/release/0.5.rst | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py index 8acea4ab0..c39581548 100644 --- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -34,9 +34,9 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int: """ - Get optimal assembly orientation/rotation to minimize peak burnup. + Get optimal hex assembly orientation/rotation to minimize peak burnup. - .. impl:: Provide an algoritm for rotating hexagonal assemblies to equalize burnup + .. impl:: Provide an algorithm for rotating hexagonal assemblies to equalize burnup :id: I_ARMI_ROTATE_HEX_BURNUP :implements: R_ARMI_ROTATE_HEX_BURNUP @@ -66,7 +66,7 @@ def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int expected pin power. We evaluated "expected pin power" based on the power distribution in ``aPrev``, the previous assembly located where ``a`` is going. The algorithm goes as follows. - 1. Get all the pin powers and ``IndexLocation``s from the block at the previous location. + 1. Get all the pin powers and ``IndexLocation``s from the block at the previous location/timenode. 2. Obtain the ``IndexLocation`` of the pin with the highest burnup in the current assembly. 3. For each possible rotation, - Find the new location with ``HexGrid.rotateIndex`` diff --git a/doc/release/0.5.rst b/doc/release/0.5.rst index e77806186..0b92c954d 100644 --- a/doc/release/0.5.rst +++ b/doc/release/0.5.rst @@ -81,7 +81,7 @@ API Changes #. History Tracker: "detail assemblies" are now fuel and control assemblies. (`PR#1990 `_) #. Removing ``Block.breakFuelComponentsIntoIndividuals()``. (`PR#1990 `_) #. Moving ``getPuMoles`` from blocks.py up to composites.py. (`PR#1990 `_) -#. ``buReducingAssemblyRotation`` and ``getOptimalAssemblyOrientation`` require pin level burnup. (`PR#2019 `_) +#. Requiring ``buReducingAssemblyRotation`` and ``getOptimalAssemblyOrientation`` to have pin-level burnup. (`PR#2019 `_) Bug Fixes ---------