diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py new file mode 100644 index 0000000000..fbbce50e9b --- /dev/null +++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py @@ -0,0 +1,109 @@ +# Copyright 2022 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. +""" +Algorithms used to rotate hex assemblies in a reactor core. + +Notes +----- +These algorithms are defined in assemblyRotationAlgorithms.py, but they are used in: +``FuelHandler.outage()``. + +.. warning:: Nothing should do in this file, but rotation algorithms. +""" +from armi import runLog +from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import ( + getOptimalAssemblyOrientation, +) + + +def buReducingAssemblyRotation(fh): + r""" + Rotates all detail assemblies to put the highest bu pin in the lowest power orientation + + Parameters + ---------- + fh : FuelHandler object + A fully initialized FuelHandler object. + + See Also + -------- + simpleAssemblyRotation : an alternative rotation algorithm + """ + 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 + aNow = fh.r.core.getAssemblyWithStringLocation(aPrev.lastLocationLabel) + # no point in rotation if there's no pin detail + if aNow in hist.getDetailAssemblies(): + rot = getOptimalAssemblyOrientation(aNow, aPrev) + aNow.rotatePins(rot) # rot = integer between 0 and 5 + numRotated += 1 + # Print out rotation operation (mainly for testing) + # hex indices (i,j) = (ring,pos) + (i, j) = aNow.spatialLocator.getRingPos() + runLog.important( + "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, rot) + ) + + # rotate NON-MOVING assemblies (stationary) + if fh.cs["assemblyRotationStationary"]: + for a in hist.getDetailAssemblies(): + if a not in fh.moved: + rot = getOptimalAssemblyOrientation(a, a) + a.rotatePins(rot) # rot = integer between 0 and 6 + numRotated += 1 + (i, j) = a.spatialLocator.getRingPos() + runLog.important( + "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, rot) + ) + + runLog.info("Rotated {0} assemblies".format(numRotated)) + + +def simpleAssemblyRotation(fh): + """ + Rotate all pin-detail assemblies that were just shuffled by 60 degrees + + Parameters + ---------- + fh : FuelHandler object + A fully initialized FuelHandler object. + + Notes + ----- + Also, optionally rotate stationary (non-shuffled) assemblies if the setting is set. + Obviously, only pin-detail assemblies can be rotated, because homogenized assemblies are isotropic. + + Examples + -------- + >>> simpleAssemblyRotation(fh) + + See Also + -------- + FuelHandler.outage : calls this method based on a user setting + """ + runLog.info("Rotating assemblies by 60 degrees") + numRotated = 0 + hist = fh.o.getInterface("history") + for a in hist.getDetailAssemblies(): + if a in fh.moved or fh.cs["assemblyRotationStationary"]: + a.rotatePins(1) + numRotated += 1 + i, j = a.spatialLocator.getRingPos() # hex indices (i,j) = (ring,pos) + runLog.extra( + "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, 1) + ) + + runLog.extra("Rotated {0} assemblies".format(numRotated)) diff --git a/armi/physics/fuelCycle/fuelHandlerFactory.py b/armi/physics/fuelCycle/fuelHandlerFactory.py index e21ff3fe43..a5f9faa512 100644 --- a/armi/physics/fuelCycle/fuelHandlerFactory.py +++ b/armi/physics/fuelCycle/fuelHandlerFactory.py @@ -13,9 +13,8 @@ # limitations under the License. """factory for the FuelHandler""" -from armi.operators import RunTypes -from armi.utils import directoryChangers, pathTools from armi.physics.fuelCycle import fuelHandlers +from armi.utils import directoryChangers, pathTools def fuelHandlerFactory(operator): diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py index b8f7da1281..4c5cf6d1b9 100644 --- a/armi/physics/fuelCycle/fuelHandlers.py +++ b/armi/physics/fuelCycle/fuelHandlers.py @@ -11,7 +11,6 @@ # 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. - """ This module handles fuel management operations such as shuffling, rotation, and fuel processing (in fluid systems). @@ -25,20 +24,18 @@ This module also handles repeat shuffles when doing a restart. """ -import math import os import re import warnings import numpy - from armi import runLog -from armi.utils.customExceptions import InputError -from armi.reactor.flags import Flags -from armi.utils.mathematics import findClosest, resampleStepwise +from armi.physics.fuelCycle import assemblyRotationAlgorithms as rotAlgos from armi.physics.fuelCycle.fuelHandlerFactory import fuelHandlerFactory from armi.physics.fuelCycle.fuelHandlerInterface import FuelHandlerInterface +from armi.reactor.flags import Flags +from armi.utils.customExceptions import InputError class FuelHandler: @@ -121,16 +118,16 @@ def outage(self, factor=1.0): # do rotations if pin-level details are available (requires fluxRecon plugin) if self.cs["fluxRecon"] and self.cs["assemblyRotationAlgorithm"]: - # Rotate assemblies ONLY IF at least some assemblies have pin detail (enabled by fluxRecon) + # Rotate assemblies ONLY IF at least some assemblies have pin detail # The user can choose the algorithm method name directly in the settings - if hasattr(self, self.cs["assemblyRotationAlgorithm"]): - rotationMethod = getattr(self, self.cs["assemblyRotationAlgorithm"]) + if hasattr(rotAlgos, self.cs["assemblyRotationAlgorithm"]): + rotationMethod = getattr(rotAlgos, self.cs["assemblyRotationAlgorithm"]) rotationMethod() else: raise RuntimeError( "FuelHandler {0} does not have a rotation algorithm called {1}.\n" 'Change your "assemblyRotationAlgorithm" setting' - "".format(self, self.cs["assemblyRotationAlgorithm"]) + "".format(rotAlgos, self.cs["assemblyRotationAlgorithm"]) ) # inform the reactor of how many moves occurred so it can put the number in the database. @@ -170,9 +167,7 @@ def outage(self, factor=1.0): return moved def chooseSwaps(self, shuffleFactors=None): - """ - Moves the fuel around or otherwise processes it between cycles. - """ + """Moves the fuel around or otherwise processes it between cycles.""" raise NotImplementedError @staticmethod @@ -198,192 +193,6 @@ def getFactorList(cycle, cs=None, fallBack=False): factorSearchFlags = [] return defaultFactorList, factorSearchFlags - def simpleAssemblyRotation(self): - """ - Rotate all pin-detail assemblies that were just shuffled by 60 degrees - - Notes - ----- - Also, optionally rotate stationary (non-shuffled) assemblies if the setting is set. - Obviously, only pin-detail assemblies can be rotated, because homogenized assemblies are isotropic. - - Examples - -------- - >>> fh.simpleAssemblyRotation() - - See Also - -------- - buReducingAssemblyRotation : an alternative rotation algorithm - outage : calls this method based on a user setting - """ - runLog.info("Rotating assemblies by 60 degrees") - numRotated = 0 - hist = self.o.getInterface("history") - for a in hist.getDetailAssemblies(): - if a in self.moved or self.cs["assemblyRotationStationary"]: - a.rotatePins(1) - numRotated += 1 - i, j = a.spatialLocator.getRingPos() # hex indices (i,j) = (ring,pos) - runLog.extra( - "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, 1) - ) - runLog.extra("Rotated {0} assemblies".format(numRotated)) - - def buReducingAssemblyRotation(self): - r""" - Rotates all detail assemblies to put the highest bu pin in the lowest power orientation - - See Also - -------- - simpleAssemblyRotation : an alternative rotation algorithm - outage : calls this method based on a user setting - - """ - - runLog.info("Algorithmically rotating assemblies to minimize burnup") - numRotated = 0 - hist = self.o.getInterface("history") - for aPrev in self.moved: # much more convenient to loop through aPrev first - aNow = self.r.core.getAssemblyWithStringLocation(aPrev.lastLocationLabel) - # no point in rotation if there's no pin detail - if aNow in hist.getDetailAssemblies(): - - rot = self.getOptimalAssemblyOrientation(aNow, aPrev) - aNow.rotatePins(rot) # rot = integer between 0 and 5 - numRotated += 1 - # Print out rotation operation (mainly for testing) - # hex indices (i,j) = (ring,pos) - (i, j) = aNow.spatialLocator.getRingPos() - runLog.important( - "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, rot) - ) - - # rotate NON-MOVING assemblies (stationary) - if self.cs["assemblyRotationStationary"]: - for a in hist.getDetailAssemblies(): - if a not in self.moved: - rot = self.getOptimalAssemblyOrientation(a, a) - a.rotatePins(rot) # rot = integer between 0 and 6 - numRotated += 1 - (i, j) = a.spatialLocator.getRingPos() - runLog.important( - "Rotating Assembly ({0},{1}) to Orientation {2}".format( - i, j, rot - ) - ) - - runLog.info("Rotated {0} assemblies".format(numRotated)) - - @staticmethod - def getOptimalAssemblyOrientation(a, aPrev): - """ - 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. - - 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. - - Examples - -------- - >>> fh.getOptimalAssemblyOrientation(a,aPrev) - 4 - - See Also - -------- - rotateAssemblies : calls this to figure out how to rotate - """ - - # determine whether or not aPrev had pin details - fuelPrev = aPrev.getFirstBlock(Flags.FUEL) - if fuelPrev: - aPrevDetailFlag = fuelPrev.p.pinLocation[4] is not None - 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 - # maxBuPinIndexAssem = self.pinIndexLookup[maxBuPinIndexAssem] - # dummyList = numpy.where(self.pinIndexLookup == maxBuPinIndexAssem) - # maxBuPinIndexAssem = dummyList[0][0] - - if aPrevDetailFlag: - - # aPrev has pin detail. Excellent! - # Determine which of 6 possible rotated pin indices had the lowest power when aPrev was here. - - prevAssemPowHereMIN = float("inf") - - for possibleRotation in range(6): # k = 1,2,3,4,5 - # get rotated pin index - indexLookup = maxBuBlock.rotatePins( - possibleRotation, justCompute=True - ) - # rotated index of highest-BU pin - index = int(indexLookup[maxBuPinIndexAssem]) - # 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 - def prepCore(self): """Aux. function to run before XS generation (do moderation, etc. here)""" @@ -787,7 +596,7 @@ def getParamWithBlockLevelMax(a, paramName): return minDiff[1] if not minDiff[1]: - # warning("can't find assembly in targetRing %d with close %s to %s" % (targetRing,param,compareTo),'findAssembly') + # can't find assembly in targetRing with close param to compareTo pass if findMany: @@ -834,7 +643,6 @@ def _getAssembliesInRings( ------- assemblyList : list List of assemblies in each ring of the ringList. [[a1,a2,a3],[a4,a5,a6,a7],...] - """ assemblyList = [[] for _i in range(len(ringList))] # empty lists for each ring if exclusions is None: @@ -861,9 +669,7 @@ def _getAssembliesInRings( assemListTmp2.append(a) # make the list of lists of assemblies assemblyList[i] = assemListTmp2 - else: - if ringList[0] == "SFP": # kind of a hack for now. Need the capability. assemList = self.r.core.sfp.getChildren() @@ -886,249 +692,6 @@ def _getAssembliesInRings( return assemblyList - def buildRingSchedule( - self, - chargeRing=None, - dischargeRing=None, - jumpRingFrom=None, - jumpRingTo=None, - coarseFactor=0.0, - ): - r""" - Build a ring schedule for shuffling. - - Notes - ----- - General enough to do convergent, divergent, or any combo, plus jumprings. - - The center of the core is ring 1, based on the DIF3D numbering scheme. - - Jump ring behavior can be generalized by first building a base ring list - where assemblies get charged to H and discharge from A:: - - [A,B,C,D,E,F,G,H] - - - If a jump should be placed where it jumps from ring G to C, reversed back to F, and then discharges from A, - we simply reverse the sublist [C,D,E,F], leaving us with:: - - [A,B,F,E,D,C,G,H] - - - A less-complex, more standard convergent-divergent scheme is a subcase of this, where the - sublist [A,B,C,D,E] or so is reversed, leaving:: - - [E,D,C,B,A,F,G,H] - - - So the task of this function is simply to determine what subsection, if any, to reverse of - the baselist. - - Parameters - ---------- - chargeRing : int, optional - The peripheral ring into which an assembly enters the core. Default is outermost ring. - - dischargeRing : int, optional - The last ring an assembly sits in before discharging. Default is jumpRing-1 - - jumpRingFrom : int - The last ring an assembly sits in before jumping to the center - - jumpRingTo : int, optional - The inner ring into which a jumping assembly jumps. Default is 1. - - coarseFactor : float, optional - A number between 0 and 1 where 0 hits all rings and 1 only hits the outer, rJ, center, and rD rings. - This allows coarse shuffling, with large jumps. Default: 0 - - Returns - ------- - ringSchedule : list - A list of rings in order from discharge to charge. - - ringWidths : list - A list of integers corresponding to the ringSchedule determining the widths of each ring area - - Examples - ------- - >>> f.buildRingSchedule(17,1,jumpRingFrom=14) - ([13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 14, 15, 16, 17], - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) - - See Also - -------- - findAssembly - - """ - maxRingInCore = self.r.core.getNumRings() - if dischargeRing > maxRingInCore: - runLog.warning( - f"Discharge ring {dischargeRing} is outside the core (max {maxRingInCore}). " - "Changing it to be the max ring" - ) - dischargeRing = maxRingInCore - if chargeRing > maxRingInCore: - runLog.warning( - f"Charge ring {chargeRing} is outside the core (max {maxRingInCore}). " - "Changing it to be the max ring." - ) - chargeRing = maxRingInCore - - # process arguments - if dischargeRing is None: - # No discharge ring given, so we default to converging from outside to inside - # and therefore discharging from the center - dischargeRing = 1 - if chargeRing is None: - # Charge ring not specified. Since we default to convergent shuffling, we - # must insert the fuel at the periphery. - chargeRing = maxRingInCore - if jumpRingFrom is not None and not (1 < jumpRingFrom < maxRingInCore): - raise ValueError(f"JumpRingFrom {jumpRingFrom} is not in the core.") - if jumpRingTo is not None and not (1 <= jumpRingTo < maxRingInCore): - raise ValueError(f"JumpRingTo {jumpRingTo} is not in the core.") - - if chargeRing > dischargeRing and jumpRingTo is None: - # a convergent shuffle with no jumping. By setting - # jumpRingTo to be 1, no jumping will be activated - # in the later logic. - jumpRingTo = 1 - elif jumpRingTo is None: - # divergent case. Disable jumping by putting jumpring - # at periphery. - if self.r: - jumpRingTo = maxRingInCore - else: - jumpRingTo = 18 - - if ( - chargeRing > dischargeRing - and jumpRingFrom is not None - and jumpRingFrom < jumpRingTo - ): - raise RuntimeError("Cannot have outward jumps in convergent cases.") - if ( - chargeRing < dischargeRing - and jumpRingFrom is not None - and jumpRingFrom > jumpRingTo - ): - raise RuntimeError("Cannot have inward jumps in divergent cases.") - - # step 1: build the base rings - numSteps = int((abs(dischargeRing - chargeRing) + 1) * (1.0 - coarseFactor)) - if numSteps < 2: - # don't let it be smaller than 2 because linspace(1,5,1)= [1], linspace(1,5,2)= [1,5] - numSteps = 2 - baseRings = [ - int(ring) for ring in numpy.linspace(dischargeRing, chargeRing, numSteps) - ] - # eliminate duplicates. - newBaseRings = [] - for br in baseRings: - if br not in newBaseRings: - newBaseRings.append(br) - baseRings = newBaseRings - # baseRings = list(set(baseRings)) # eliminate duplicates. but ruins order. - # build widths - widths = [] - for i, ring in enumerate(baseRings[:-1]): - # 0 is the most restrictive, meaning don't even look in other rings. - widths.append(abs(baseRings[i + 1] - ring) - 1) - widths.append(0) # add the last ring with width 0. - - # step 2: locate which rings should be reversed to give the jump-ring effect. - if jumpRingFrom is not None: - _closestRingFrom, jumpRingFromIndex = findClosest( - baseRings, jumpRingFrom, indx=True - ) - _closestRingTo, jumpRingToIndex = findClosest( - baseRings, jumpRingTo, indx=True - ) - else: - jumpRingToIndex = 0 - - # step 3: build the final ring list, potentially with a reversed section - newBaseRings = [] - newWidths = [] - # add in the non-reversed section before the reversed section - - if jumpRingFrom is not None: - newBaseRings.extend(baseRings[:jumpRingToIndex]) - newWidths.extend(widths[:jumpRingToIndex]) - # add in reversed section that is jumped - newBaseRings.extend(reversed(baseRings[jumpRingToIndex:jumpRingFromIndex])) - newWidths.extend(reversed(widths[jumpRingToIndex:jumpRingFromIndex])) - # add the rest. - newBaseRings.extend(baseRings[jumpRingFromIndex:]) - newWidths.extend(widths[jumpRingFromIndex:]) - else: - # no jump section. Just fill in the rest. - newBaseRings.extend(baseRings[jumpRingToIndex:]) - newWidths.extend(widths[jumpRingToIndex:]) - - return newBaseRings, newWidths - - def buildConvergentRingSchedule( - self, dischargeRing=1, chargeRing=None, coarseFactor=0.0 - ): - r""" - Builds a ring schedule for convergent shuffling from chargeRing to dischargeRing - - Parameters - ---------- - dischargeRing : int, optional - The last ring an assembly sits in before discharging. If no discharge, this is the one that - gets placed where the charge happens. Default: Innermost ring - - chargeRing : int, optional - The peripheral ring into which an assembly enters the core. Default is outermost ring. - - coarseFactor : float, optional - A number between 0 and 1 where 0 hits all rings and 1 only hits the outer, rJ, center, and rD rings. - This allows coarse shuffling, with large jumps. Default: 0 - - Returns - ------- - convergent : list - A list of rings in order from discharge to charge. - - conWidths : list - A list of integers corresponding to the ringSchedule determining the widths of each ring area - - Examples - ------- - - See Also - -------- - findAssembly - - """ - # process arguments - if chargeRing is None: - chargeRing = self.r.core.getNumRings() - - # step 1: build the convergent rings - numSteps = int((chargeRing - dischargeRing + 1) * (1.0 - coarseFactor)) - if numSteps < 2: - # don't let it be smaller than 2 because linspace(1,5,1)= [1], linspace(1,5,2)= [1,5] - numSteps = 2 - convergent = [ - int(ring) for ring in numpy.linspace(dischargeRing, chargeRing, numSteps) - ] - - # step 2. eliminate duplicates - convergent = sorted(list(set(convergent))) - - # step 3. compute widths - conWidths = [] - for i, ring in enumerate(convergent[:-1]): - conWidths.append(convergent[i + 1] - ring) - conWidths.append(1) - - # step 4. assemble and return - return convergent, conWidths - def swapAssemblies(self, a1, a2): r""" Moves a whole assembly from one place to another @@ -1305,7 +868,6 @@ def repeatShufflePattern(self, explicitRepeatShuffles): processMoveList : Converts a stored list of moves into a functional list of assemblies to swap makeShuffleReport : Creates the file that is processed here """ - # read moves file moves = self.readMoves(explicitRepeatShuffles) # get the correct cycle number @@ -1354,7 +916,6 @@ def readMoves(fname): repeatShufflePattern : reads this file and repeats the shuffling outage : creates the moveList in the first place. makeShuffleReport : writes the file that is read here. - """ try: f = open(fname) @@ -1467,9 +1028,7 @@ def trackChain(moveList, startingAt, alreadyDone=None): See Also -------- repeatShufflePattern - mcnpInterface.getMoveCards processMoveList - """ if alreadyDone is None: alreadyDone = [] @@ -1587,13 +1146,10 @@ def processMoveList(self, moveList): -------- makeShuffleReport : writes the file that is being processed repeatShufflePattern : uses this to repeat shuffles - """ alreadyDone = [] loadChains = [] # moves that have discharges - loadChargeTypes = ( - [] - ) # the assembly types (strings) that should be used in a load chain. + loadChargeTypes = [] # the assembly types (str) to be used in a load chain. loopChains = [] # moves that don't have discharges enriches = [] # enrichments of each loadChain loadNames = [] # assembly name of each load assembly (to read from SFP) @@ -1746,122 +1302,6 @@ def doRepeatShuffle( return moved - def buildEqRingSchedule(self, ringSchedule): - r""" - Expands simple ringSchedule input into full-on location schedule - - Parameters - ---------- - ringSchedule, r, cs - - Returns - ------- - locationSchedule : list - - """ - - def squaredDistanceFromOrigin(a): - origin = numpy.array([0.0, 0.0, 0.0]) - p = numpy.array(a.spatialLocator.getLocalCoordinates()) - return ((p - origin) ** 2).sum() - - def assemAngle(a): - x, y, _ = a.spatialLocator.getLocalCoordinates() - return math.atan2(y, x) - - locationSchedule = [] - # start by expanding the user-input eqRingSchedule list into a list containing - # all the rings as it goes. - ringList = self.buildEqRingScheduleHelper(ringSchedule) - - # now build the locationSchedule ring by ring using this ringSchedule. - lastRing = 0 - for ring in ringList: - assemsInRing = self.r.core.getAssembliesInRing(ring, typeSpec=Flags.FUEL) - if self.cs["circularRingOrder"] == "angle": - sorter = lambda a: assemAngle(a) - elif self.cs["circularRingOrder"] == "distanceSmart": - if lastRing == ring + 1: - # converging. Put things on the outside first. - sorter = lambda a: -squaredDistanceFromOrigin(a) - else: - # diverging. Put things on the inside first. - sorter = lambda a: squaredDistanceFromOrigin(a) - else: - # purely based on distance. Can mix things up in convergent-divergent cases. Prefer distanceSmart - sorter = lambda a: squaredDistanceFromOrigin(a) - assemsInRing = sorted(assemsInRing, key=sorter) - for a in assemsInRing: - locationSchedule.append(a.getLocation()) - lastRing = ring - return locationSchedule - - def buildEqRingScheduleHelper(self, ringSchedule): - r""" - turns ringScheduler into explicit list of rings - - Pulled out of buildEqRingSchedule for testing. - - Parameters - ---------- - ringSchedule : list - List of ring bounds that is required to be an even number of entries. These - entries then are used in a from - to approach to add the rings. The from ring will - always be included. - - Returns - ------- - ringList : list - List of all rings in the order they should be shuffled. - - Examples - -------- - >>> buildEqRingScheduleHelper([1,5]) - [1,2,3,4,5] - - >>> buildEqRingScheduleHelper([1,5,9,6]) - [1,2,3,4,5,9,8,7,6] - - >>> buildEqRingScheduleHelper([9,5,3,4,1,2]) - [9,8,7,6,5,3,4,1,2] - - >>> buildEqRingScheduleHelper([2,5,1,1]) - [2,3,4,5,1] - - """ - if len(ringSchedule) % 2 != 0: - runLog.error("Ring schedule: {}".format(ringSchedule)) - raise RuntimeError("Ring schedule does not have an even number of entries.") - - ringList = [] - for i in range(0, len(ringSchedule), 2): - fromRing = ringSchedule[i] - toRing = ringSchedule[i + 1] - numRings = abs(toRing - fromRing) + 1 - - ringList.extend( - [int(j) for j in numpy.linspace(fromRing, toRing, numRings)] - ) - - # eliminate doubles (but allow a ring to show up multiple times) - newList = [] - lastRing = None - for ring in ringList: - if ring != lastRing: - newList.append(ring) - if self.r.core and ring > self.r.core.getNumRings(): - # error checking. - runLog.warning( - "Ring {0} in eqRingSchedule larger than largest ring in reactor {1}. " - "Adjust shuffling.".format(ring, self.r.core.getNumRings()), - single=True, - label="too many rings", - ) - lastRing = ring - ringList = newList - - return ringList - def workerOperate(self, cmd): """Handle a mpi command on the worker nodes.""" pass @@ -1880,7 +1320,6 @@ def makeShuffleArrows(self): ------- arrows : list Values are (currentCoords, oldCoords) tuples - """ arrows = [] runLog.extra("Building list of shuffle arrows.") @@ -1891,4 +1330,5 @@ def makeShuffleArrows(self): oldCoords = numpy.array((-50, -50, 0)) elif any(currentCoords != oldCoords): arrows.append((oldCoords, currentCoords)) + return arrows diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py new file mode 100644 index 0000000000..7383099a76 --- /dev/null +++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py @@ -0,0 +1,506 @@ +# Copyright 2022 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. +""" +This is a selection of fuel management utilities that seem generally useful enough to +keep in ARMI, but they still only apply to hex assembly reactors. + +Notes +----- +We are keeping these in ARMI even if they appear unused internally. +""" +import math + +import numpy + +from armi import runLog +from armi.reactor.flags import Flags +from armi.utils.mathematics import findClosest + + +def getOptimalAssemblyOrientation(a, aPrev): + """ + 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. + + 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. + + Examples + -------- + >>> getOptimalAssemblyOrientation(a, aPrev) + 4 + + See Also + -------- + rotateAssemblies : calls this to figure out how to rotate + """ + # determine whether or not aPrev had pin details + fuelPrev = aPrev.getFirstBlock(Flags.FUEL) + if fuelPrev: + aPrevDetailFlag = fuelPrev.p.pinLocation[4] is not None + 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): + # get rotated pin index + indexLookup = maxBuBlock.rotatePins(possibleRotation, justCompute=True) + # rotated index of highest-BU pin + index = int(indexLookup[maxBuPinIndexAssem]) + # 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 + + +def buildRingSchedule( + maxRingInCore, + chargeRing=None, + dischargeRing=None, + jumpRingFrom=None, + jumpRingTo=None, + coarseFactor=0.0, +): + r""" + Build a ring schedule for shuffling. + + Notes + ----- + General enough to do convergent, divergent, or any combo, plus jumprings. + + The center of the core is ring 1, based on the DIF3D numbering scheme. + + Jump ring behavior can be generalized by first building a base ring list + where assemblies get charged to H and discharge from A:: + + [A,B,C,D,E,F,G,H] + + If a jump should be placed where it jumps from ring G to C, reversed back to F, and then discharges from A, + we simply reverse the sublist [C,D,E,F], leaving us with:: + + [A,B,F,E,D,C,G,H] + + A less-complex, more standard convergent-divergent scheme is a subcase of this, where the + sublist [A,B,C,D,E] or so is reversed, leaving:: + + [E,D,C,B,A,F,G,H] + + So the task of this function is simply to determine what subsection, if any, to reverse of + the baselist. + + Parameters + ---------- + maxRingInCore : int + The number of rings in the hex assembly reactor. + + chargeRing : int, optional + The peripheral ring into which an assembly enters the core. Default is outermost ring. + + dischargeRing : int, optional + The last ring an assembly sits in before discharging. Default is jumpRing-1 + + jumpRingFrom : int + The last ring an assembly sits in before jumping to the center + + jumpRingTo : int, optional + The inner ring into which a jumping assembly jumps. Default is 1. + + coarseFactor : float, optional + A number between 0 and 1 where 0 hits all rings and 1 only hits the outer, rJ, center, and rD rings. + This allows coarse shuffling, with large jumps. Default: 0 + + Returns + ------- + ringSchedule : list + A list of rings in order from discharge to charge. + + ringWidths : list + A list of integers corresponding to the ringSchedule determining the widths of each ring area + + Examples + ------- + >>> f.buildRingSchedule(17,1,jumpRingFrom=14) + ([13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 14, 15, 16, 17], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + """ + if dischargeRing > maxRingInCore: + runLog.warning( + f"Discharge ring {dischargeRing} is outside the core (max {maxRingInCore}). " + "Changing it to be the max ring" + ) + dischargeRing = maxRingInCore + if chargeRing > maxRingInCore: + runLog.warning( + f"Charge ring {chargeRing} is outside the core (max {maxRingInCore}). " + "Changing it to be the max ring." + ) + chargeRing = maxRingInCore + + # process arguments + if dischargeRing is None: + # No discharge ring given, so we default to converging from outside to inside + # and therefore discharging from the center + dischargeRing = 1 + if chargeRing is None: + # Charge ring not specified. Since we default to convergent shuffling, we + # must insert the fuel at the periphery. + chargeRing = maxRingInCore + if jumpRingFrom is not None and not (1 < jumpRingFrom < maxRingInCore): + raise ValueError(f"JumpRingFrom {jumpRingFrom} is not in the core.") + if jumpRingTo is not None and not (1 <= jumpRingTo < maxRingInCore): + raise ValueError(f"JumpRingTo {jumpRingTo} is not in the core.") + + if chargeRing > dischargeRing and jumpRingTo is None: + # a convergent shuffle with no jumping. By setting + # jumpRingTo to be 1, no jumping will be activated + # in the later logic. + jumpRingTo = 1 + elif jumpRingTo is None: + # divergent case. Disable jumpring by putting jumpring at periphery. + jumpRingTo = maxRingInCore + + if ( + chargeRing > dischargeRing + and jumpRingFrom is not None + and jumpRingFrom < jumpRingTo + ): + raise RuntimeError("Cannot have outward jumps in convergent cases.") + if ( + chargeRing < dischargeRing + and jumpRingFrom is not None + and jumpRingFrom > jumpRingTo + ): + raise RuntimeError("Cannot have inward jumps in divergent cases.") + + # step 1: build the base rings + numSteps = int((abs(dischargeRing - chargeRing) + 1) * (1.0 - coarseFactor)) + # don't let it be smaller than 2 because linspace(1,5,1)= [1], linspace(1,5,2)= [1,5] + numSteps = max(numSteps, 2) + + baseRings = [ + int(ring) for ring in numpy.linspace(dischargeRing, chargeRing, numSteps) + ] + # eliminate duplicates. + newBaseRings = [] + for br in baseRings: + if br not in newBaseRings: + newBaseRings.append(br) + + baseRings = newBaseRings + + # build widths + widths = [] + for i, ring in enumerate(baseRings[:-1]): + # 0 is the most restrictive, meaning don't even look in other rings. + widths.append(abs(baseRings[i + 1] - ring) - 1) + widths.append(0) # add the last ring with width 0. + + # step 2: locate which rings should be reversed to give the jump-ring effect. + if jumpRingFrom is not None: + _closestRingFrom, jumpRingFromIndex = findClosest( + baseRings, jumpRingFrom, indx=True + ) + _closestRingTo, jumpRingToIndex = findClosest(baseRings, jumpRingTo, indx=True) + else: + jumpRingToIndex = 0 + + # step 3: build the final ring list, potentially with a reversed section + newBaseRings = [] + newWidths = [] + # add in the non-reversed section before the reversed section + + if jumpRingFrom is not None: + newBaseRings.extend(baseRings[:jumpRingToIndex]) + newWidths.extend(widths[:jumpRingToIndex]) + # add in reversed section that is jumped + newBaseRings.extend(reversed(baseRings[jumpRingToIndex:jumpRingFromIndex])) + newWidths.extend(reversed(widths[jumpRingToIndex:jumpRingFromIndex])) + # add the rest. + newBaseRings.extend(baseRings[jumpRingFromIndex:]) + newWidths.extend(widths[jumpRingFromIndex:]) + else: + # no jump section. Just fill in the rest. + newBaseRings.extend(baseRings[jumpRingToIndex:]) + newWidths.extend(widths[jumpRingToIndex:]) + + return newBaseRings, newWidths + + +def buildConvergentRingSchedule(chargeRing, dischargeRing=1, coarseFactor=0.0): + r""" + Builds a ring schedule for convergent shuffling from chargeRing to dischargeRing + + Parameters + ---------- + chargeRing : int + The peripheral ring into which an assembly enters the core. A good default is + outermost ring: ``r.core.getNumRings()``. + + dischargeRing : int, optional + The last ring an assembly sits in before discharging. If no discharge, this is the one that + gets placed where the charge happens. Default: Innermost ring + + coarseFactor : float, optional + A number between 0 and 1 where 0 hits all rings and 1 only hits the outer, rJ, center, and rD rings. + This allows coarse shuffling, with large jumps. Default: 0 + + Returns + ------- + convergent : list + A list of rings in order from discharge to charge. + + conWidths : list + A list of integers corresponding to the ringSchedule determining the widths of each ring area + """ + # step 1: build the convergent rings + numSteps = int((chargeRing - dischargeRing + 1) * (1.0 - coarseFactor)) + # don't let it be smaller than 2 because linspace(1,5,1)= [1], linspace(1,5,2)= [1,5] + numSteps = max(numSteps, 2) + convergent = [ + int(ring) for ring in numpy.linspace(dischargeRing, chargeRing, numSteps) + ] + + # step 2. eliminate duplicates + convergent = sorted(list(set(convergent))) + + # step 3. compute widths + conWidths = [] + for i, ring in enumerate(convergent[:-1]): + conWidths.append(convergent[i + 1] - ring) + conWidths.append(1) + + # step 4. assemble and return + return convergent, conWidths + + +def _buildEqRingScheduleHelper(ringSchedule, numRings): + r""" + turns ringScheduler into explicit list of rings + + Pulled out of buildEqRingSchedule for testing. + + Parameters + ---------- + ringSchedule : list + List of ring bounds that is required to be an even number of entries. These + entries then are used in a from - to approach to add the rings. The from ring will + always be included. + + numRings : int + The number of rings in the hex assembly reactor. + + Returns + ------- + ringList : list + List of all rings in the order they should be shuffled. + + Examples + -------- + >>> _buildEqRingScheduleHelper([1,5]) + [1,2,3,4,5] + + >>> _buildEqRingScheduleHelper([1,5,9,6]) + [1,2,3,4,5,9,8,7,6] + + >>> _buildEqRingScheduleHelper([9,5,3,4,1,2]) + [9,8,7,6,5,3,4,1,2] + + >>> _buildEqRingScheduleHelper([2,5,1,1]) + [2,3,4,5,1] + """ + if len(ringSchedule) % 2 != 0: + runLog.error("Ring schedule: {}".format(ringSchedule)) + raise RuntimeError("Ring schedule does not have an even number of entries.") + + ringList = [] + for i in range(0, len(ringSchedule), 2): + fromRing = ringSchedule[i] + toRing = ringSchedule[i + 1] + numRings = abs(toRing - fromRing) + 1 + + ringList.extend([int(j) for j in numpy.linspace(fromRing, toRing, numRings)]) + + # eliminate doubles (but allow a ring to show up multiple times) + newList = [] + lastRing = None + for ring in ringList: + if ring != lastRing: + newList.append(ring) + if ring > numRings: + # error checking + runLog.warning( + "Ring {0} in eqRingSchedule larger than largest ring in reactor {1}. " + "Adjust shuffling.".format(ring, numRings), + single=True, + label="too many rings", + ) + lastRing = ring + + return newList + + +def _squaredDistanceFromOrigin(a): + """Get the squared distance from the origin of an assembly. + + Notes + ----- + Just a helper for ``buildEqRingSchedule()`` + + Parameters + ---------- + a: Assembly + Fully initialize Assembly object; already part of a reactor core. + + Returns + ------- + float: Distance from reactor center + """ + origin = numpy.array([0.0, 0.0, 0.0]) + p = numpy.array(a.spatialLocator.getLocalCoordinates()) + return ((p - origin) ** 2).sum() + + +def _assemAngle(a): + """Get the angle of the Assembly, in the reactor core. + + Notes + ----- + Just a helper for ``buildEqRingSchedule()`` + + Parameters + ---------- + a: Assembly + Fully initialize Assembly object; already part of a reactor core. + + Returns + ------- + float: Angle position of assembly around the reactor core + """ + x, y, _ = a.spatialLocator.getLocalCoordinates() + return math.atan2(y, x) + + +def buildEqRingSchedule(core, ringSchedule, circularRingOrder): + r""" + Expands simple ringSchedule input into full-on location schedule + + Parameters + ---------- + core : Core object + Fully initialized Core object, for a hex assembly reactor. + + ringSchedule : list + List of ring bounds that is required to be an even number of entries. These + entries then are used in a from - to approach to add the rings. The from ring will + always be included. + + circularRingOrder : str + From the circularRingOrder setting. Valid values include angle and distanceSmart, + anything else will + + Returns + ------- + list: location schedule + """ + # start by expanding the user-input eqRingSchedule list into a list containing + # all the rings as it goes. + ringList = _buildEqRingScheduleHelper(ringSchedule, core.getNumRings()) + + # now build the locationSchedule ring by ring using this ringSchedule + lastRing = 0 + locationSchedule = [] + for ring in ringList: + assemsInRing = core.getAssembliesInRing(ring, typeSpec=Flags.FUEL) + if circularRingOrder == "angle": + sorter = lambda a: _assemAngle(a) + elif circularRingOrder == "distanceSmart": + if lastRing == ring + 1: + # converging. Put things on the outside first. + sorter = lambda a: -_squaredDistanceFromOrigin(a) + else: + # diverging. Put things on the inside first. + sorter = _squaredDistanceFromOrigin + else: + # purely based on distance. Can mix things up in convergent-divergent cases. Prefer distanceSmart + sorter = _squaredDistanceFromOrigin + + assemsInRing = sorted(assemsInRing, key=sorter) + for a in assemsInRing: + locationSchedule.append(a.getLocation()) + lastRing = ring + + return locationSchedule diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py new file mode 100644 index 0000000000..e83f4ea948 --- /dev/null +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -0,0 +1,68 @@ +# Copyright 2022 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. +""" +Tests for tools used to rotate hex assemblies. + +Notes +----- +These algorithms are defined in assemblyRotationAlgorithms.py, but they are used in: +``FuelHandler.outage()``. +""" +# pylint: disable=missing-function-docstring,missing-class-docstring,protected-access,invalid-name,no-self-use,no-method-argument,import-outside-toplevel +import unittest + +from armi.physics.fuelCycle import fuelHandlers +from armi.physics.fuelCycle import assemblyRotationAlgorithms as rotAlgos +from armi.physics.fuelCycle.tests.test_fuelHandlers import addSomeDetailAssemblies +from armi.physics.fuelCycle.tests.test_fuelHandlers import FuelHandlerTestHelper +from armi.reactor.flags import Flags + + +class TestFuelHandlerMgmtTools(FuelHandlerTestHelper): + def test_buReducingAssemblyRotation(self): + fh = fuelHandlers.FuelHandler(self.o) + hist = self.o.getInterface("history") + newSettings = {"assemblyRotationStationary": True} + 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.breakFuelComponentsIntoIndividuals() + b.initializePinLocations() + b.p.percentBuMaxPinLocation = 10 + b.p.percentBuMax = 5 + b.p.linPowByPin = list(reversed(range(b.getNumPins()))) + + addSomeDetailAssemblies(hist, [assem]) + rotNum = b.getRotationNum() + rotAlgos.buReducingAssemblyRotation(fh) + self.assertNotEqual(b.getRotationNum(), rotNum) + + def test_simpleAssemblyRotation(self): + fh = fuelHandlers.FuelHandler(self.o) + newSettings = {"assemblyRotationStationary": True} + self.o.cs = self.o.cs.modified(newSettings=newSettings) + hist = self.o.getInterface("history") + assems = hist.o.r.core.getAssemblies(Flags.FUEL)[:5] + addSomeDetailAssemblies(hist, assems) + b = self.o.r.core.getFirstBlock(Flags.FUEL) + rotNum = b.getRotationNum() + rotAlgos.simpleAssemblyRotation(fh) + rotAlgos.simpleAssemblyRotation(fh) + self.assertEqual(b.getRotationNum(), rotNum + 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/armi/physics/fuelCycle/tests/test_fuelHandlers.py b/armi/physics/fuelCycle/tests/test_fuelHandlers.py index 02600316a7..c6dcad44f3 100644 --- a/armi/physics/fuelCycle/tests/test_fuelHandlers.py +++ b/armi/physics/fuelCycle/tests/test_fuelHandlers.py @@ -17,10 +17,9 @@ This test is high enough level that it requires input files to be present. The ones to use are called armiRun.yaml which is located in armi.tests """ -# pylint: disable=missing-function-docstring,missing-class-docstring,abstract-method,protected-access +# pylint: disable=missing-function-docstring,missing-class-docstring,protected-access,invalid-name,no-self-use,no-method-argument,import-outside-toplevel import collections import copy -import os import unittest import numpy as np @@ -34,7 +33,7 @@ from armi.utils import directoryChangers -class TestFuelHandler(ArmiTestHelper): +class FuelHandlerTestHelper(ArmiTestHelper): @classmethod def setUpClass(cls): # prepare the input files. This is important so the unit tests run from wherever @@ -119,6 +118,8 @@ def tearDown(self): self.directoryChanger.close() + +class TestFuelHandler(FuelHandlerTestHelper): def test_findHighBu(self): loc = self.r.core.spatialGrid.getLocatorFromRingAndPos(5, 4) a = self.r.core.childrenByLocator[loc] @@ -333,25 +334,6 @@ def runShuffling(self, fh): self.assertEqual(a.getLocation(), "SFP") fh.interactEOL() - def test_buildEqRingScheduleHelper(self): - fh = fuelHandlers.FuelHandler(self.o) - - ringList1 = [1, 5] - buildRing1 = fh.buildEqRingScheduleHelper(ringList1) - self.assertEqual(buildRing1, [1, 2, 3, 4, 5]) - - ringList2 = [1, 5, 9, 6] - buildRing2 = fh.buildEqRingScheduleHelper(ringList2) - self.assertEqual(buildRing2, [1, 2, 3, 4, 5, 9, 8, 7, 6]) - - ringList3 = [9, 5, 3, 4, 1, 2] - buildRing3 = fh.buildEqRingScheduleHelper(ringList3) - self.assertEqual(buildRing3, [9, 8, 7, 6, 5, 3, 4, 1, 2]) - - ringList4 = [2, 5, 1, 1] - buildRing1 = fh.buildEqRingScheduleHelper(ringList4) - self.assertEqual(buildRing1, [2, 3, 4, 5, 1]) - def test_repeatShuffles(self): r""" Builds a dummy core. Does some shuffles. Repeats the shuffles. Checks that it was a perfect repeat. @@ -447,19 +429,6 @@ def test_getFactorList(self): factors, _ = fh.getFactorList(0) self.assertIn("eqShuffles", factors) - def test_simpleAssemblyRotation(self): - fh = fuelHandlers.FuelHandler(self.o) - newSettings = {"assemblyRotationStationary": True} - self.o.cs = self.o.cs.modified(newSettings=newSettings) - hist = self.o.getInterface("history") - assems = hist.o.r.core.getAssemblies(Flags.FUEL)[:5] - addSomeDetailAssemblies(hist, assems) - b = self.o.r.core.getFirstBlock(Flags.FUEL) - rotNum = b.getRotationNum() - fh.simpleAssemblyRotation() - fh.simpleAssemblyRotation() - self.assertEqual(b.getRotationNum(), rotNum + 2) - def test_linPowByPin(self): _fh = fuelHandlers.FuelHandler(self.o) _hist = self.o.getInterface("history") @@ -502,74 +471,6 @@ def test_linPowByPinGamma(self): b.p.linPowByPinGamma = np.array([1, 2, 3]) self.assertEqual(type(b.p.linPowByPinGamma), np.ndarray) - def test_buReducingAssemblyRotation(self): - fh = fuelHandlers.FuelHandler(self.o) - hist = self.o.getInterface("history") - newSettings = {"assemblyRotationStationary": True} - 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.breakFuelComponentsIntoIndividuals() - b.initializePinLocations() - b.p.percentBuMaxPinLocation = 10 - b.p.percentBuMax = 5 - b.p.linPowByPin = list(reversed(range(b.getNumPins()))) - - addSomeDetailAssemblies(hist, [assem]) - rotNum = b.getRotationNum() - fh.buReducingAssemblyRotation() - self.assertNotEqual(b.getRotationNum(), rotNum) - - def test_buildRingSchedule(self): - fh = fuelHandlers.FuelHandler(self.o) - - # simple divergent - schedule, widths = fh.buildRingSchedule(1, 9) - self.assertEqual(schedule, [9, 8, 7, 6, 5, 4, 3, 2, 1]) - - # simple with no jumps - schedule, widths = fh.buildRingSchedule(9, 1, jumpRingTo=1) - self.assertEqual(schedule, [1, 2, 3, 4, 5, 6, 7, 8, 9]) - - # simple with 1 jump - schedule, widths = fh.buildRingSchedule(9, 1, jumpRingFrom=6) - self.assertEqual(schedule, [5, 4, 3, 2, 1, 6, 7, 8, 9]) - self.assertEqual(widths, [0, 0, 0, 0, 0, 0, 0, 0, 0]) - - # 1 jump plus auto-correction to core size - schedule, widths = fh.buildRingSchedule(1, 17, jumpRingFrom=5) - self.assertEqual(schedule, [6, 7, 8, 9, 5, 4, 3, 2, 1]) - self.assertEqual(widths, [0, 0, 0, 0, 0, 0, 0, 0, 0]) - - # crash on invalid jumpring - with self.assertRaises(ValueError): - schedule, widths = fh.buildRingSchedule(1, 17, jumpRingFrom=0) - - # test 4: Mid way jumping - schedule, widths = fh.buildRingSchedule(1, 9, jumpRingTo=6, jumpRingFrom=3) - self.assertEqual(schedule, [9, 8, 7, 4, 5, 6, 3, 2, 1]) - - def test_buildConvergentRingSchedule(self): - fh = fuelHandlers.FuelHandler(self.o) - schedule, widths = fh.buildConvergentRingSchedule(17, 1) - self.assertEqual(schedule, [1, 17]) - self.assertEqual(widths, [16, 1]) - - def test_buildEqRingSchedule(self): - fh = fuelHandlers.FuelHandler(self.o) - locSchedule = fh.buildEqRingSchedule([2, 1]) - self.assertEqual(locSchedule, ["002-001", "002-002", "001-001"]) - - fh.cs["circularRingOrder"] = "distanceSmart" - locSchedule = fh.buildEqRingSchedule([2, 1]) - self.assertEqual(locSchedule, ["002-001", "002-002", "001-001"]) - - fh.cs["circularRingOrder"] = "somethingCrazy" - locSchedule = fh.buildEqRingSchedule([2, 1]) - self.assertEqual(locSchedule, ["002-001", "002-002", "001-001"]) - def test_transferStationaryBlocks(self): """ Test the _transferStationaryBlocks method . diff --git a/armi/physics/fuelCycle/tests/test_hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/tests/test_hexAssemblyFuelMgmtUtils.py new file mode 100644 index 0000000000..d339d3bd75 --- /dev/null +++ b/armi/physics/fuelCycle/tests/test_hexAssemblyFuelMgmtUtils.py @@ -0,0 +1,121 @@ +# Copyright 2022 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. +""" +TODO + +Tests some capabilities of the fuel handling tools, +specific to hex-assembly reactors. +""" +# pylint: disable=missing-function-docstring,missing-class-docstring,protected-access,invalid-name,no-self-use,no-method-argument,import-outside-toplevel +import unittest + +from armi.physics.fuelCycle import hexAssemblyFuelMgmtUtils as hexUtils +from armi.reactor.tests import test_reactors +from armi.tests import ArmiTestHelper, TEST_ROOT +from armi.utils import directoryChangers + + +class TestHexAssemMgmtTools(ArmiTestHelper): + def setUp(self): + # prepare the input files. This is important so the unit tests run from wherever + # they need to run from. + self.td = directoryChangers.TemporaryDirectoryChanger() + self.td.__enter__() + + def tearDown(self): + self.td.__exit__(None, None, None) + + def test_buildEqRingScheduleHelper(self): + numRings = 9 + ringList1 = [1, 5] + buildRing1 = hexUtils._buildEqRingScheduleHelper(ringList1, numRings) + self.assertEqual(buildRing1, [1, 2, 3, 4, 5]) + + ringList2 = [1, 5, 9, 6] + buildRing2 = hexUtils._buildEqRingScheduleHelper(ringList2, numRings) + self.assertEqual(buildRing2, [1, 2, 3, 4, 5, 9, 8, 7, 6]) + + ringList3 = [9, 5, 3, 4, 1, 2] + buildRing3 = hexUtils._buildEqRingScheduleHelper(ringList3, numRings) + self.assertEqual(buildRing3, [9, 8, 7, 6, 5, 3, 4, 1, 2]) + + ringList4 = [2, 5, 1, 1] + buildRing1 = hexUtils._buildEqRingScheduleHelper(ringList4, numRings) + self.assertEqual(buildRing1, [2, 3, 4, 5, 1]) + + def test_buildEqRingSchedule(self): + _o, r = test_reactors.loadTestReactor(TEST_ROOT) + core = r.core + + circularRingOrder = "angle" + locSchedule = hexUtils.buildEqRingSchedule(core, [2, 1], circularRingOrder) + self.assertEqual(locSchedule, ["002-001", "002-002", "001-001"]) + + circularRingOrder = "distanceSmart" + locSchedule = hexUtils.buildEqRingSchedule(core, [2, 1], circularRingOrder) + self.assertEqual(locSchedule, ["002-001", "002-002", "001-001"]) + + circularRingOrder = "somethingCrazy" + locSchedule = hexUtils.buildEqRingSchedule(core, [2, 1], circularRingOrder) + self.assertEqual(locSchedule, ["002-001", "002-002", "001-001"]) + + def test_buildConvergentRingSchedule(self): + schedule, widths = hexUtils.buildConvergentRingSchedule(1, 17, 0) + self.assertEqual(schedule, [1, 17]) + self.assertEqual(widths, [16, 1]) + + schedule, widths = hexUtils.buildConvergentRingSchedule(3, 17, 1) + self.assertEqual(schedule, [3, 17]) + self.assertEqual(widths, [14, 1]) + + schedule, widths = hexUtils.buildConvergentRingSchedule(12, 16, 0.5) + self.assertEqual(schedule, [12, 16]) + self.assertEqual(widths, [4, 1]) + + def test_buildRingSchedule(self): + # simple divergent + schedule, widths = hexUtils.buildRingSchedule(9, 1, 9) + self.assertEqual(schedule, [9, 8, 7, 6, 5, 4, 3, 2, 1]) + zeroWidths = [0, 0, 0, 0, 0, 0, 0, 0, 0] + self.assertEqual(widths, zeroWidths) + + # simple with no jumps + schedule, widths = hexUtils.buildRingSchedule(9, 9, 1, jumpRingTo=1) + self.assertEqual(schedule, [1, 2, 3, 4, 5, 6, 7, 8, 9]) + self.assertEqual(widths, zeroWidths) + + # simple with 1 jump + schedule, widths = hexUtils.buildRingSchedule(9, 9, 1, jumpRingFrom=6) + self.assertEqual(schedule, [5, 4, 3, 2, 1, 6, 7, 8, 9]) + self.assertEqual(widths, zeroWidths) + + # 1 jump plus auto-correction to core size + schedule, widths = hexUtils.buildRingSchedule(9, 1, 17, jumpRingFrom=5) + self.assertEqual(schedule, [6, 7, 8, 9, 5, 4, 3, 2, 1]) + self.assertEqual(widths, zeroWidths) + + # crash on invalid jumpring + with self.assertRaises(ValueError): + schedule, widths = hexUtils.buildRingSchedule(9, 1, 17, jumpRingFrom=0) + + # test 4: Mid way jumping + schedule, widths = hexUtils.buildRingSchedule( + 9, 1, 9, jumpRingTo=6, jumpRingFrom=3 + ) + self.assertEqual(schedule, [9, 8, 7, 4, 5, 6, 3, 2, 1]) + self.assertEqual(widths, zeroWidths) + + +if __name__ == "__main__": + unittest.main() diff --git a/doc/release/0.2.rst b/doc/release/0.2.rst index d4f1cc75f8..df15308bae 100644 --- a/doc/release/0.2.rst +++ b/doc/release/0.2.rst @@ -12,7 +12,7 @@ What's new in ARMI #. savePhysicsFiles setting to copy physics kernel input and output to directories organized by cycle and time step number (e.g., c2n1) to retain files from a simulation and avoid overwriting them. (`PR#958 `_) #. Add pinQuantities parameter category for blockParams that have spatial distribution within a block. #. Use r.core.p.axialMesh instead of r.core.refAssem.getAxialMesh() for the uniform mesh converter. (`PR#959 `_) -#. TBD +#. Split algorithms specific to hex assemblies out of ``FuelHandler``. (`PR#962 `_) Bug fixes ---------