diff --git a/armi/physics/fuelCycle/fuelHandlerInterface.py b/armi/physics/fuelCycle/fuelHandlerInterface.py index 068b3c08d0..a5f27926aa 100644 --- a/armi/physics/fuelCycle/fuelHandlerInterface.py +++ b/armi/physics/fuelCycle/fuelHandlerInterface.py @@ -14,8 +14,8 @@ """A place for the FuelHandler's Interface""" -from armi import runLog -from armi import interfaces + +from armi import interfaces, runLog from armi.utils import plotting from armi.physics.fuelCycle import fuelHandlerFactory @@ -34,11 +34,11 @@ class FuelHandlerInterface(interfaces.Interface): def __init__(self, r, cs): interfaces.Interface.__init__(self, r, cs) + self.cycle = 0 # assembly name key, (x, y) values. used for making shuffle arrows. self.oldLocations = {} # need order due to nature of moves but with fast membership tests self.moved = [] - self.cycle = 0 # filled during summary of EOC time in years of each cycle (time at which shuffling occurs) self.cycleTime = {} @@ -55,11 +55,11 @@ def specifyInputs(cs): def interactBOC(self, cycle=None): """ - Move and/or process fuel. + Beginning of cycle hook. Initiate all cyclical fuel management processes. - Also, if requested, first have the lattice physics system update XS. + If requested, first have the lattice physics system update XS. """ - # if lattice physics is requested, compute it here instead of after fuel management. + # if requested, compute lattice physics here instead of after fuel management. # This enables XS to exist for branch searching, etc. mc2 = self.o.getInterface(function="latticePhysics") if mc2 and self.cs["runLatticePhysicsBeforeShuffling"]: @@ -71,11 +71,14 @@ def interactBOC(self, cycle=None): if self.enabled(): self.manageFuel(cycle) + self.validateLocations() def interactEOC(self, cycle=None): - timeYears = self.r.p.time + """ + End of cycle hook. Record cycle time and report number of assemblies in SFP. + """ # keep track of the EOC time in years. - self.cycleTime[cycle] = timeYears + self.cycleTime[cycle] = self.r.p.time runLog.extra( "There are {} assemblies in the Spent Fuel Pool".format( len(self.r.core.sfp) @@ -83,19 +86,21 @@ def interactEOC(self, cycle=None): ) def interactEOL(self): - """Make reports at EOL""" + """ + End of life hook. Generate operator life shuffle report. + """ self.makeShuffleReport() def manageFuel(self, cycle): - """Perform the fuel management for this cycle.""" + """ + Perform the fuel management for a given cycle. + """ fh = fuelHandlerFactory.fuelHandlerFactory(self.o) - fh.prepCore() - fh.prepShuffleMap() - # take note of where each assembly is located before the outage - # for mapping after the outage - self.r.core.locateAllAssemblies() - shuffleFactors, _ = fh.getFactorList(cycle) - fh.outage(shuffleFactors) # move the assemblies around + fh.preoutage() + ## is the factor list useful at this point? + shuffleFactors = fh.getFactorList(cycle) + fh.outage(shuffleFactors) + if self.cs["plotShuffleArrows"]: arrows = fh.makeShuffleArrows() plotting.plotFaceMap( @@ -107,13 +112,31 @@ def manageFuel(self, cycle): ) plotting.close() + def validateLocations(self): + """ + Check that all assemblies have a unique location in the core. + """ + locations = [ + assembly.getLocation() + for assembly in self.r.core.getAssemblies(includeAll=True) + if assembly.getLocation()[:3].isnumeric() + ] + + if len(locations) != len(set(locations)): + duplicateLocations = set([i for i in locations if locations.count(i) > 1]) + raise ValueError( + "Two or more assemblies share the same core location ({})".format( + duplicateLocations + ) + ) + def makeShuffleReport(self): """ Create a data file listing all the shuffles that occurred in a case. - This can be used to export shuffling to an external code or to - perform explicit repeat shuffling in a restart. - It creates a ``*SHUFFLES.txt`` file based on the Reactor.moveList structure + This can be used to export shuffling to an external code or to perform + explicit repeat shuffling in a restart. It creates a *SHUFFLES.txt file + based on the Reactor.moveList structure See Also -------- @@ -123,10 +146,9 @@ def makeShuffleReport(self): fname = self.cs.caseTitle + "-SHUFFLES.txt" out = open(fname, "w") for cycle in range(self.cs["nCycles"]): - # do cycle+1 because cycle 0 at t=0 isn't usually interesting - # remember, we put cycle 0 in so we could do BOL branch searches. - # This also syncs cycles up with external physics kernel cycles. + # Write cycle header to the report out.write("Before cycle {0}:\n".format(cycle + 1)) + # Pull move list for the cycle movesThisCycle = self.r.core.moveList.get(cycle) if movesThisCycle is not None: for ( diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py index b7e60ed6dc..a162955fa7 100644 --- a/armi/physics/fuelCycle/fuelHandlers.py +++ b/armi/physics/fuelCycle/fuelHandlers.py @@ -13,7 +13,7 @@ # limitations under the License. """ -This module handles fuel management operations such as shuffling, rotation, and +This module handles fuel management operations such as translation, rotation, and fuel processing (in fluid systems). The :py:class:`FuelHandlerInterface` instantiates a ``FuelHandler``, which is typically a user-defined @@ -32,13 +32,13 @@ 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.fuelHandlerFactory import fuelHandlerFactory from armi.physics.fuelCycle.fuelHandlerInterface import FuelHandlerInterface +from armi.physics.fuelCycle import translationFunctions class FuelHandler: @@ -90,6 +90,15 @@ def r(self): """Link to the Reactor object.""" return self.o.r + def preoutage(self): + r""" + Stores locations of assemblies before they are shuffled to support generation of shuffle reports, etc. + Also performs any additional functionality before shuffling. + """ + self.prepCore() + self._prepShuffleMap() + self.r.core.locateAllAssemblies() + def outage(self, factor=1.0): r""" Simulates a reactor reload outage. Moves and tracks fuel. @@ -885,249 +894,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 @@ -1155,13 +921,37 @@ def swapAssemblies(self, a1, a2): if a not in self.moved: self.moved.append(a) oldA1Location = a1.spatialLocator - self._transferStationaryBlocks(a1, a2) - a1.moveTo(a2.spatialLocator) + oldA2Location = a2.spatialLocator + a1StationaryBlocks, a2StationaryBlocks = self._transferStationaryBlocks(a1, a2) + a1.moveTo(oldA2Location) a2.moveTo(oldA1Location) + self._validateAssemblySwap( + a1StationaryBlocks, oldA1Location, a2StationaryBlocks, oldA2Location + ) + + def _validateAssemblySwap( + self, a1StationaryBlocks, oldA1Location, a2StationaryBlocks, oldA2Location + ): + """ + Detect whether any blocks containing stationary components were moved + after a swap. + """ + for assemblyBlocks, oldLocation in [ + [a1StationaryBlocks, oldA1Location], + [a2StationaryBlocks, oldA2Location], + ]: + for block in assemblyBlocks: + if block.parent.spatialLocator != oldLocation: + raise ValueError( + """Stationary block {} has been moved. Expected to be in location {}. Was moved to {}.""".format( + block, oldLocation, block.parent.spatialLocator + ) + ) + def _transferStationaryBlocks(self, assembly1, assembly2): """ - Exchange the stationary blocks (e.g. grid plate) between the moving assemblies + Exchange the stationary blocks (e.g. grid plate) between the moving assemblies. These blocks in effect are not moved at all. """ @@ -1203,6 +993,10 @@ def _transferStationaryBlocks(self, assembly1, assembly2): assembly1.insert(assem1BlockIndex, assem2Block) assembly2.insert(assem2BlockIndex, assem1Block) + return [item[0] for item in a1StationaryBlocks], [ + item[0] for item in a2StationaryBlocks + ] + def dischargeSwap(self, incoming, outgoing): r""" Removes one assembly from the core and replace it with another assembly. @@ -1865,7 +1659,7 @@ def workerOperate(self, cmd): """Handle a mpi command on the worker nodes.""" pass - def prepShuffleMap(self): + def _prepShuffleMap(self): """Prepare a table of current locations for plotting shuffle maneuvers.""" self.oldLocations = {} for a in self.r.core.getAssemblies(): diff --git a/armi/physics/fuelCycle/tests/test_fuelHandlers.py b/armi/physics/fuelCycle/tests/test_fuelHandlers.py index 6b314c2106..2b57d80b18 100644 --- a/armi/physics/fuelCycle/tests/test_fuelHandlers.py +++ b/armi/physics/fuelCycle/tests/test_fuelHandlers.py @@ -538,36 +538,399 @@ 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]) + schedule = fuelHandlers.translationFunctions.buildRingSchedule( + self, 1, 9, diverging=True + ) + self.assertEqual(schedule, [[9], [8], [7], [6], [5], [4], [3], [2], [1]]) + + # default inner and outer rings + schedule = fuelHandlers.translationFunctions.buildRingSchedule(self) + self.assertEqual(schedule[0][0], 1) + if fh.r: + self.assertEqual(schedule[-1][0], fh.r.core.getNumRings()) + else: + self.assertEqual(schedule[-1][0], 18) # simple with no jumps - schedule, widths = fh.buildRingSchedule(9, 1, jumpRingTo=1) - self.assertEqual(schedule, [1, 2, 3, 4, 5, 6, 7, 8, 9]) + schedule = fuelHandlers.translationFunctions.buildRingSchedule(self, 1, 9) + 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]) + schedule = fuelHandlers.translationFunctions.buildRingSchedule( + self, 1, 9, jumpRingFrom=6 + ) + self.assertEqual(schedule, [[5], [4], [3], [2], [1], [6], [7], [8], [9]]) + + # crash on outward jumps with converging + with self.assertRaises(RuntimeError): + schedule = fuelHandlers.translationFunctions.buildRingSchedule( + self, 1, 17, jumpRingFrom=0 + ) + + # crash on inward jumps for diverging + with self.assertRaises(RuntimeError): + schedule = fuelHandlers.translationFunctions.buildRingSchedule( + self, 1, 17, jumpRingFrom=5, jumpRingTo=3, diverging=True + ) + + # mid way jumping + schedule = fuelHandlers.translationFunctions.buildRingSchedule( + self, 1, 9, jumpRingTo=6, jumpRingFrom=3, diverging=True + ) + self.assertEqual(schedule, [[9], [8], [7], [4], [5], [6], [3], [2], [1]]) + + def test_buildConvergentRingSchedule(self): + fh = fuelHandlers.FuelHandler(self.o) + schedule = fuelHandlers.translationFunctions.buildConvergentRingSchedule( + self, 1, 9 + ) + self.assertEqual(schedule, [[1], [2], [3], [4], [5], [6], [7], [8], [9]]) - # 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]) + # default inner and outer rings + schedule = fuelHandlers.translationFunctions.buildConvergentRingSchedule(self) + self.assertEqual(schedule[0][0], 1) + if fh.r: + self.assertEqual(schedule[-1][0], fh.r.core.getNumRings()) + else: + self.assertEqual(schedule[-1][0], 18) - # crash on invalid jumpring - with self.assertRaises(ValueError): - schedule, widths = fh.buildRingSchedule(1, 17, jumpRingFrom=0) + def test_getRingAssemblies(self): + fh = fuelHandlers.FuelHandler(self.o) - # 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]) + # simple + schedule = [[2], [1]] + assemblies = fuelHandlers.translationFunctions.getRingAssemblies(fh, schedule) + self.assertEqual( + [[assy.getLocation() for assy in assyList] for assyList in assemblies], + [ + [ + "002-001", + "002-002", + ], + ["001-001"], + ], + ) - def test_buildConvergentRingSchedule(self): + # circular ring + schedule = [[2], [1]] + assemblies = fuelHandlers.translationFunctions.getRingAssemblies( + fh, schedule, circular=True + ) + self.assertEqual( + [[assy.getLocation() for assy in assyList] for assyList in assemblies], + [ + ["004-018", "003-001", "004-002", "004-003", "003-003", "004-005"], + ["001-001", "003-012", "002-001", "003-002", "002-002"], + ], + ) + + # distance smart sorting + fh.cs["circularRingOrder"] = "distanceSmart" + schedule = [[3], [2], [1]] + assemblies = fuelHandlers.translationFunctions.getRingAssemblies(fh, schedule) + self.assertEqual( + [[assy.getLocation() for assy in assyList] for assyList in assemblies], + [ + ["003-012", "003-002", "003-001", "003-003"], + ["002-001", "002-002"], + ["001-001"], + ], + ) + + # default to distance smart sorting + fh.cs["circularRingOrder"] = None + schedule = [[3], [2], [1]] + assemblies = fuelHandlers.translationFunctions.getRingAssemblies(fh, schedule) + self.assertEqual( + [[assy.getLocation() for assy in assyList] for assyList in assemblies], + [ + ["003-012", "003-002", "003-001", "003-003"], + ["002-001", "002-002"], + ["001-001"], + ], + ) + + def test_getBatchZoneAssembliesFromLocation(self): + import numpy + + fh = fuelHandlers.FuelHandler(self.o) + assemblies = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, + [ + [ + "002-001", + "002-002", + ], + ["001-001"], + ], + ) + ) + self.assertEqual( + [[assy.getLocation() for assy in assyList] for assyList in assemblies], + [ + [ + "002-001", + "002-002", + ], + ["001-001"], + ], + ) + + # test invalid assembly locations + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["001-002"]] + ) + ) + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["002-007"]] + ) + ) + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["{:03d}-001".format(fh.r.core.getNumRings() + 1)]] + ) + ) + + # test new assembly + newAssembly = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["new: {}".format(fh.r.core.refAssem.getType())]] + ) + ) + self.assertEqual(newAssembly[0][0].getType(), fh.r.core.refAssem.getType()) + self.assertEqual(len(newAssembly[0][0]), len(fh.r.core.refAssem)) + # test new assembly with changed enrichment + newAssembly = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["new: {}; enrichment: 14.5".format(fh.r.core.refAssem.getType())]] + ) + ) + self.assertEqual(newAssembly[0][0].getType(), fh.r.core.refAssem.getType()) + for block in newAssembly[0][0].getChildrenWithFlags(Flags.FUEL): + self.assertAlmostEqual(block.p.enrichmentBOL, 14.5, 13) + # test new assembly with invalid enrichment change + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, + [["new: {}; enrichment: 5,5".format(fh.r.core.refAssem.getType())]], + ) + ) + # test invalid new assembly + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["new: invalidType"]] + ) + ) + + # test sfp assembly + sfpAssembly = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["sfp: {}".format(fh.r.core.sfp.getChildren()[0].getName())]] + ) + ) + # test invalid sfp assembly + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["sfp: invalidAssyName"]] + ) + ) + + # test invalid assembly setting + with self.assertRaises((NotImplementedError, RuntimeError)): + invalidAssembly = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["invalid: 1"]] + ) + ) + + # test sort function + def _sortTestFun(assembly): + origin = numpy.array([0.0, 0.0, 0.0]) + p = numpy.array(assembly.spatialLocator.getLocalCoordinates()) + return round(((p - origin) ** 2).sum(), 5) + + assemblies = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, + [ + [ + "003-001", + "003-002", + ], + ["001-001"], + ], + sortFun=_sortTestFun, + ) + ) + self.assertEqual( + [[assy.getLocation() for assy in assyList] for assyList in assemblies], + [ + [ + "003-002", + "003-001", + ], + ["001-001"], + ], + ) + + # test invalid sort function + with self.assertRaises(RuntimeError): + assemblies = ( + fuelHandlers.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, + [ + [ + "003-001", + "003-002", + ], + ["001-001"], + ], + sortFun="a", + ) + ) + + def test_getCascadesFromLocations(self): + fh = fuelHandlers.FuelHandler(self.o) + locations = [ + [ + "002-001", + "002-002", + ], + ["001-001"], + ] + assemblies = fuelHandlers.translationFunctions.getCascadesFromLocations( + fh, locations + ) + self.assertEqual( + [[assy.getLocation() for assy in assyList] for assyList in assemblies], + locations, + ) + + def test_buildBatchCascades(self): fh = fuelHandlers.FuelHandler(self.o) - schedule, widths = fh.buildConvergentRingSchedule(17, 1) - self.assertEqual(schedule, [1, 17]) - self.assertEqual(widths, [16, 1]) + # converging 2 jumps + schedule = fuelHandlers.translationFunctions.buildRingSchedule( + self, 1, 3, diverging=False + ) + assemblies = fuelHandlers.translationFunctions.getRingAssemblies(fh, schedule) + batchCascade = fuelHandlers.translationFunctions.buildBatchCascades(assemblies) + self.assertEqual( + batchCascade, + fuelHandlers.translationFunctions.getCascadesFromLocations( + fh, + [ + ["003-003"], + ["002-001", "003-012"], + ["002-002", "003-002"], + ["001-001", "003-001"], + ], + ), + ) + + # diverging 1 jump + schedule = fuelHandlers.translationFunctions.buildRingSchedule( + self, 1, 2, diverging=True + ) + assemblies = fuelHandlers.translationFunctions.getRingAssemblies(fh, schedule) + batchCascade = fuelHandlers.translationFunctions.buildBatchCascades(assemblies) + self.assertEqual( + batchCascade, + fuelHandlers.translationFunctions.getCascadesFromLocations( + fh, [["002-002", "002-001", "001-001"]] + ), + ) + + # diverging 2 jumps + schedule = fuelHandlers.translationFunctions.buildRingSchedule( + self, 1, 3, diverging=True + ) + assemblies = fuelHandlers.translationFunctions.getRingAssemblies(fh, schedule) + batchCascade = fuelHandlers.translationFunctions.buildBatchCascades(assemblies) + self.assertEqual( + batchCascade, + fuelHandlers.translationFunctions.getCascadesFromLocations( + fh, + [ + [ + "003-003", + "003-001", + "003-002", + "003-0012", + "002-002", + "002-001", + "001-001", + ] + ], + ), + ) + + # new fuel + schedule = fuelHandlers.translationFunctions.buildRingSchedule( + self, + 1, + 2, + diverging=False, + ) + assemblies = fuelHandlers.translationFunctions.getRingAssemblies(fh, schedule) + batchCascade = fuelHandlers.translationFunctions.buildBatchCascades( + assemblies, newFuelName=fh.r.core.refAssem.getType() + ) + self.assertEqual( + [[assy.getLocation() for assy in assyList] for assyList in batchCascade], + [["002-002", "LoadQueue"], ["001-001", "002-001", "LoadQueue"]], + ) + + # invalid new fuel + schedule = fuelHandlers.translationFunctions.buildRingSchedule( + self, + 1, + 2, + diverging=False, + ) + assemblies = fuelHandlers.translationFunctions.getRingAssemblies(fh, schedule) + with self.assertRaises(ValueError): + batchCascade = fuelHandlers.translationFunctions.buildBatchCascades( + assemblies, newFuelName="invalidType" + ) + + def test_changeBlockLevelEnrichment(self): + fh = fuelHandlers.FuelHandler(self.o) + assy = self.r.core.getAssemblies(Flags.FEED)[0] + + # Test single enrichment + newEnrich = 0.16 + fuelHandlers.translationFunctions.changeBlockLevelEnrichment(assy, newEnrich) + for block in assy.getBlocks(Flags.FUEL): + self.assertAlmostEqual(block.getFissileMassEnrich(), 0.16, delta=1e-6) + + # Test enrichment list + newEnrich = [0.12, 0.14, 0.16] + fuelHandlers.translationFunctions.changeBlockLevelEnrichment(assy, newEnrich) + for index, block in enumerate(assy.getBlocks(Flags.FUEL)): + self.assertAlmostEqual( + block.getFissileMassEnrich(), newEnrich[index], delta=1e-6 + ) + + # Test invalid enrichment list length and invalid enrichment value + newEnrich = [0.12, 0.14, 0.16, 0.15] + with self.assertRaises(RuntimeError): + fuelHandlers.translationFunctions.changeBlockLevelEnrichment( + assy, newEnrich + ) + newEnrich = "a" + with self.assertRaises(RuntimeError): + fuelHandlers.translationFunctions.changeBlockLevelEnrichment( + assy, newEnrich + ) def test_buildEqRingSchedule(self): fh = fuelHandlers.FuelHandler(self.o) @@ -815,6 +1178,56 @@ def test_dischargeSwapIncompatibleStationaryBlocks(self): with self.assertRaises(ValueError): fh.dischargeSwap(a2, a1) + def test_validateAssemblySwap(self): + """ + Test the _validateAssemblySwap method. + """ + + # grab the assemblies + assems = self.r.core.getAssemblies(Flags.FUEL) + + # grab two arbitrary assemblies + a1 = assems[1] + a2 = assems[2] + + # swap assemblies + fh = fuelHandlers.FuelHandler(self) + oldA1Location = a1.spatialLocator + oldA2Location = a2.spatialLocator + a1StationaryBlocks, a2StationaryBlocks = fh._transferStationaryBlocks(a1, a2) + a1.moveTo(oldA2Location) + self.assertTrue(a1.spatialLocator == oldA2Location) + a2.moveTo(oldA1Location) + self.assertTrue(a2.spatialLocator == oldA1Location) + + # swap the stationary blocks back + fh._transferStationaryBlocks(a1, a2) + + with self.assertRaises(ValueError): + fh._validateAssemblySwap( + a1StationaryBlocks, oldA1Location, a2StationaryBlocks, oldA2Location + ) + + def test_validateLocations(self): + """ + Test the validateLocations method. + """ + # grab the assemblies + assems = self.r.core.getAssemblies(Flags.FUEL) + + # grab two arbitrary assemblies + a1 = assems[1] + a2 = assems[2] + + # move assembly 1 to assembly 2 location + a1.moveTo(a2.spatialLocator) + self.assertTrue(a1.spatialLocator == a2.spatialLocator) + + # + fh = self.r.o.getInterface("fuelHandler") + with self.assertRaises(ValueError): + fh.validateLocations() + class TestFuelPlugin(unittest.TestCase): """Tests that make sure the plugin is being discovered well.""" diff --git a/armi/physics/fuelCycle/translationFunctions.py b/armi/physics/fuelCycle/translationFunctions.py new file mode 100644 index 0000000000..5e4ab44779 --- /dev/null +++ b/armi/physics/fuelCycle/translationFunctions.py @@ -0,0 +1,694 @@ +# 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. +""" +A place for additional functionality associated with the translation of assemblies. +Typically these functions are utilized by a FuelHandler, including preprocessing +user inputs to generate universal shuffle pattern data structures. +""" +import json +import math +import numpy +from armi import utils +from armi.reactor.flags import Flags + + +def buildRingSchedule( + fuelHandler, + internalRing=None, + externalRing=None, + diverging=False, + jumpRingFrom=None, + jumpRingTo=None, + coarseFactor=0.0, +): + r""" + Build a ring schedule based on user inputs. This function returns an list, in order from discharge to charge, + of groups of rings. This function will default to hexagonal ring structure if insufficient inputs are provided. + The function is general enough to create inputs for convergent and divergent ring shuffling with jump rings. + Ring numbering is consistant with DIF3D numbering scheme with ring 1 as the center assembly of the core. + An outline of the functions behaviour is described below. + + Notes + ----- + Jump ring behavior can be generalized by first building a base ring list where assemblies get discharged from + A and charge to H:: + + [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] + + Parameters + ---------- + fuelHandler : Object + An object for moving fuel around the core and reactor. + + internalRing : int, optional + The center most ring of the ring shuffle. Default is 1. + + externalRing : int, optional + Largest ring of the ring shuffle. Default is outermost ring + + diverging: bool, optional + Is the ring schedule converging or diverging. default is false (converging) + + jumpRingFrom : int, optional + 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 nested list of the rings in each group of the ring shuffle in order from discharge to charge. + + Examples + ------- + >>> RingSchedule.buildRingSchedule( + fuelHandler, + internalRing=1, + externalRing=17, + jumpRingFrom = 14, + coarseFactor=0.3) + >>> [[12], [10, 11], [9], [7, 8], [5, 6], [4], [2, 3], [1], [13, 14], [15, 16], [17]] + + See Also + -------- + findAssembly + + """ + # process arguments + if internalRing is None: + internalRing = 1 + + if externalRing is None: + if fuelHandler.r: + externalRing = fuelHandler.r.core.getNumRings() + else: + externalRing = 18 + + if diverging and not jumpRingTo: + jumpRingTo = externalRing + elif not jumpRingTo: + jumpRingTo = internalRing + + if diverging and jumpRingFrom is not None and jumpRingFrom > jumpRingTo: + raise RuntimeError("Cannot have inward jumps in divergent cases.") + elif not diverging and jumpRingFrom is not None and jumpRingFrom < jumpRingTo: + raise RuntimeError("Cannot have outward jumps in convergent cases.") + + # step 1: build the base rings + numSteps = int((abs(internalRing - externalRing) + 1) * (1.0 - coarseFactor)) + # don't let it be smaller than 2 because linspace(1,5,1)= [1], linspace(1,5,2)= [1,5] + if numSteps < 2: + numSteps = 2 + # Build preliminary ring list + if diverging: + baseRings = [ + int(ring) for ring in numpy.linspace(externalRing, internalRing, numSteps) + ] + else: + baseRings = [ + int(ring) for ring in numpy.linspace(internalRing, externalRing, numSteps) + ] + # eliminate duplicates. + newBaseRings = [] + for br in baseRings: + if br not in newBaseRings: + newBaseRings.append(br) + baseRings = newBaseRings + + # step 2: locate which rings should be reversed to give the jump-ring effect. + if jumpRingFrom is not None: + _closestRingFrom, jumpRingFromIndex = utils.findClosest( + baseRings, jumpRingFrom, indx=True + ) + _closestRingTo, jumpRingToIndex = utils.findClosest( + baseRings, jumpRingTo, indx=True + ) + else: + jumpRingToIndex = 0 + + # Update rings + if diverging: + for i, ring in enumerate(baseRings[:-1]): + baseRings[i] = [j + 1 for j in range(baseRings[i + 1], ring)] + baseRings[-1] = [baseRings[-1]] + else: + for i, ring in enumerate(baseRings[:-1]): + baseRings[i] = [j for j in range(ring, baseRings[i + 1])] + baseRings[-1] = [baseRings[-1]] + + # step 3: build the final ring list, potentially with a reversed section + newBaseRings = [] + # add in the non-reversed section before the reversed section + if jumpRingFrom is not None: + newBaseRings.extend(baseRings[:jumpRingToIndex]) + # add in reversed section that is jumped + newBaseRings.extend(reversed(baseRings[jumpRingToIndex:jumpRingFromIndex])) + # add the rest. + newBaseRings.extend(baseRings[jumpRingFromIndex:]) + else: + # no jump section. Just fill in the rest. + newBaseRings.extend(baseRings[jumpRingToIndex:]) + + return newBaseRings + + +def buildConvergentRingSchedule( + fuelHandler, dischargeRing=1, chargeRing=None, coarseFactor=0.0 +): + r""" + Build a convergent ring schedule based on user inputs. This function returns a list, in order from discharge + to charge, of groups of rings. This function will default to hexagonal ring structure. Ring numbering is + consistent with DIF3D numbering scheme with ring 1 as the center assembly of the core. An outline of the + function's behavior is described below. + + Parameters + ---------- + fuelHandler : Object + An object for moving fuel around the core and reactor. + + dischargeRing : int, optional + The last ring an assembly sits in before discharging. Default is ring 1. + + 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 and center rings. + This allows coarse shuffling, with large jumps. Default: 0 + + Returns + ------- + ringSchedule : list + A nested list of the rings in each group of the ring shuffle in order from discharge to charge. + + Examples + ------- + See Also + -------- + findAssembly + """ + # process arguments + if chargeRing is None: + if fuelHandler.r: + chargeRing = fuelHandler.r.core.getNumRings() + else: + chargeRing = 18 + # step 1: build the ringSchedule 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 + ringSchedule = [ + int(ring) for ring in numpy.linspace(dischargeRing, chargeRing, numSteps) + ] + # step 2. eliminate duplicates + ringSchedule = sorted(list(set(ringSchedule))) + # step 3. compute widths + for i, ring in enumerate(ringSchedule[:-1]): + ringSchedule[i] = [j for j in range(ring, ringSchedule[i + 1])] + ringSchedule[-1] = [ringSchedule[-1]] + # step 4. assemble and return + return ringSchedule + + +def getRingAssemblies(fuelHandler, ringSchedule, circular=False, flags=Flags.FUEL): + r""" + Gather all assemblies within the ring groups described in ringSchedule. This function takes a ringSchedule, like + those output by buildRingSchedule and buildConvergentRingSchedule, and returns all assemblies within those rings + in a similar structure. An outline of the functions behaviour is described below. + + Example + ------- + Assuming core state has all fuel assemblies in the first 6 hexagonal rings. + ringSchedule = [[1,2],[3],[6],[4,5]] + + ringAssemblyArray = [[001-001, 002-001, 002-002, 002-003, 002-004, 002-005, 002-006], + [003-001, ... 003-012], + [006-001, ... 006-030], + [004-001, ... 004-018, 005-001, ... 005-024]] + + Note: Order of assemblies within each ring group is defined by the case settings "circularRingOrder + + Parameters + ---------- + fuelHandler : Object + An object for moving fuel around the core and reactor. + + ringSchedule: list + A list of lists of rings in shuffle order from discharge to charge + + circular: bool, optional + A variable to control the use of circular rings rather than hexagonal rings. + Default is False + + flags: Flags object, options + A variable to control the type of assemblies returned by the function. + + Returns + ------- + ringAssemblyArray : list + A nested list of the assemblies in each group of the ring shuffle in order from discharge to charge. + The output is formatted the same as how batch loading zones are defined. + + """ + + # define basic sorting functions + def squaredDistanceFromOrigin(assembly): + origin = numpy.array([0.0, 0.0, 0.0]) + p = numpy.array(assembly.spatialLocator.getLocalCoordinates()) + return round(((p - origin) ** 2).sum(), 5) + + def assemAngle(assembly): + x, y, _ = assembly.spatialLocator.getLocalCoordinates() + return round(math.atan2(y, x), 5) + + ringAssemblyArray = [] + + for rings in ringSchedule: + assemblies = [] + # Get assemblies for each ring group + for ring in rings: + if circular: + assemblies += fuelHandler.r.core.getAssembliesInCircularRing( + ring, typeSpec=flags + ) + else: + assemblies += fuelHandler.r.core.getAssembliesInRing( + ring, typeSpec=flags + ) + # Sort assemblies within each ring group + if fuelHandler.cs["circularRingOrder"] == "angle": + assemblies.sort(key=lambda x: squaredDistanceFromOrigin(x)) + assemblies.sort(key=lambda x: assemAngle(x)) + elif fuelHandler.cs["circularRingOrder"] == "distanceSmart": + assemblies.sort(key=lambda x: assemAngle(x)) + assemblies.sort(key=lambda x: squaredDistanceFromOrigin(x)) + else: + assemblies.sort(key=lambda x: assemAngle(x)) + assemblies.sort(key=lambda x: squaredDistanceFromOrigin(x)) + # append assemblies in ring group to data structure + ringAssemblyArray.append(assemblies) + + return ringAssemblyArray + + +def getBatchZoneAssembliesFromLocation( + fuelHandler, + batchZoneAssembliesLocations, + sortFun=None, +): + r""" + Gather all assembly objects by locations provided in BatchZoneAssembliesLocations. This function converts an + array of location strings and converts it to an array of assemblies. New assemblies and assemblies that were + previously removed from the core can also be gathered. The function can sort the assemblies in each zone by + a user defined function if desired. An outline of the function behaviour is described below. + + Example + ------- + batchZoneAssembliesLocations = [["001-001", "002-001", "002-002", "002-003", "002-004", "002-005", "002-006"], + ["003-001", ... "003-012"], + ... + ["004-001", ... "004-018", "005-001", ... "005-024"]] + + batchAssemblyArray = [[, ... ], + [, ... ], + ... + [, ... ]] + + Acceptable Input Formats + ------------------------ + core assembly: "xxx-xxx" + new assembly: "new: assemType" + new assembly with modified enrichment: "new: assemType; enrichment: value" + sfp assembly: "sfp: assemName" + + Parameters + ---------- + fuelHandler : Object + An object for moving fuel around the core and reactor. + + batchZoneAssembliesLocations: list + A list of lists of assembly location strings. This function perserves the organization of this parameter. + This input should be organized by zone, from discharge to charge, and include all assembly location for + each zone. See example above + + sortFun: function, optional + A function that returns a value to sort assemblies in each zone. + + Returns + ------- + batchAssemblyArray : list + A nested list of the assemblies in each zone of a batch loading pattern in order from discharge to charge. + """ + + batchAssemblyArray = [] + + for zone in batchZoneAssembliesLocations: + zoneAssembly = [] + for assemblyLocation in zone: + # is assemblyLocation a core location + if assemblyLocation[:3].isnumeric(): + # is assemblyLocation the center of the core + if int(assemblyLocation[:3]) == 1: + if int(assemblyLocation[4:]) == 1: + zoneAssembly.append( + fuelHandler.r.core.getAssemblyWithStringLocation( + assemblyLocation + ) + ) + else: + raise RuntimeError( + "the provided assembly location, {}, is not valid".format( + assemblyLocation + ) + ) + elif int(assemblyLocation[:3]) <= fuelHandler.r.core.getNumRings(): + if (int(assemblyLocation[:3]) - 1) * 6 >= int(assemblyLocation[4:]): + zoneAssembly.append( + fuelHandler.r.core.getAssemblyWithStringLocation( + assemblyLocation + ) + ) + else: + raise RuntimeError( + "the provided assembly location, {}, is not valid".format( + assemblyLocation + ) + ) + else: + raise RuntimeError( + "the provided assembly location, {}, is not valid".format( + assemblyLocation + ) + ) + # is assemblyLocation outside the core + else: + try: + assembly = None + for settings in assemblyLocation.split("; "): + setting, value = settings.split(": ") + # is assemblyLocation a new assembly + if setting.lower() == "new": + if value in fuelHandler.r.blueprints.assemblies.keys(): + assembly = fuelHandler.r.core.createAssemblyOfType( + assemType=value + ) + else: + raise RuntimeError( + "{} is not defined in the blueprint".format(value) + ) + # is assemblyLocation in the SFP + elif setting.lower() == "sfp": + if value in [ + i.getName() + for i in fuelHandler.r.core.sfp.getChildren() + ]: + assembly = fuelHandler.r.core.sfp.getAssembly(value) + else: + raise RuntimeError( + "{} does not exist in the SFP".format(value) + ) + # is the enrichment changing + elif setting.lower() == "enrichment": + if assembly and _is_list(value): + changeBlockLevelEnrichment(assembly, json.loads(value)) + else: + raise RuntimeError( + "{} is not a valid enrichment".format(value) + ) + else: + raise NotImplementedError( + "Setting, {}, not reconized".format(setting) + ) + zoneAssembly.append(assembly) + except: + raise RuntimeError("Error loading assemblies, check inputs") + + # if sort function provided, sort assemblies + if sortFun: + try: + zoneAssembly.sort(key=lambda x: sortFun(x)) + except: + raise RuntimeError("the provided sorting function is not valid") + + # append zoneAssembly to batchAssemblyArray + batchAssemblyArray.append(zoneAssembly) + + return batchAssemblyArray + + +def getCascadesFromLocations(fuelHandler, cascadeAssemblyLocations): + r""" + Translate lists of locations into cascade shuffle data structure. This function converts an array of + location strings into an array of assemblies. The locations in each list should be provided in order + of discharge to charge. An outline of the function behavior is described below. + + Example + ------- + cascadeAssemblyLocations = [["001-001", "002-001", "003-001", "003-007", "004-001"], + ["002-002", "003-001", "003-008", ...], + ... + ["002-006", ... "003-012", ...]] + + batchAssemblyArray = [[, ... ], + [, ... , ...], + ... + [, ... , ...]] + + Parameters + ---------- + fuelHandler : Object + An object for moving fuel around the core and reactor. + + cascadeAssemblyLocations: list + A list of cascade lists of assembly location strings. This function preserves the organization of this parameter. + see getBatchZoneAssembliesFromLocation for more information. + + Returns + ------- + see getBatchZoneAssembliesFromLocation + """ + + return getBatchZoneAssembliesFromLocation( + fuelHandler, batchZoneAssembliesLocations=cascadeAssemblyLocations, sortFun=None + ) + + +def buildBatchCascades( + assemblyArray, + fromSort=None, + toSort=None, + newFuelName=None, +): + r""" + Gather all assemblies within the ring groups described in ringSchedule. This function takes a ringSchedule, like + those output by buildRingSchedule and buildConvergentRingSchedule, and returns all assemblies within those rings + in a similar structure. An outline of the functions behaviour is described below. + + Build cascade swap shuffle pattern data structures for the batch loading array provided. The function takes a batch + loading array and creates a number of swap cascades equal to the number of assemblies in the charge zone. The number + of assemblies in each zone does not need to be equal: + + if the number of cascades is greater than the number of spaces in a zone - The function will assign all cascades to + each available location then assign the remaining cascades to spaces in the next zone. Further iterations will + prioritize assigning further cascade steps to cascades assigned to initial location. + + if the number of cascades is less than the number of spaces in a zone - The function will assign all cascades to a + space within the zone, then repeat the process for the remaining spaces. + + Parameters + ---------- + assemblyArray : list + A nested list of the assemblies in each batch loading zone ordered from discharge to charge. A number of cascades + equal to the length of assemblyArray[-1] will be created. + + fromSort: function, optional + A function that returns a value to sort assemblies that need to be moved, smallest to largest. Default function + sorts by distance from center. + e.g. If the highest burnup assemblies in assemblyArray[0] were moving to the highest flux region in assemblyArray[1]. + This function would return the burnup of an assembly. + + toSort: function, optional + A function that returns a value to sort locations that assemblies are being moved to, smallest to largest. Default + function sorts by distance from center. + e.g. If the highest burnup assemblies in assemblyArray[0] were moving to the highest flux region in assemblyArray[1]. + This function would return the flux at an assemblies location. + + newFuelName: string, optional + The string name of the assembly type to be charged on each shuffle cascade. Only functions if an input is provided. + + Returns + ------- + dataStructure : array + An array of assemblies to be sent to the swapCascade function of the fuel handler + + """ + + if not fromSort: + fromSort = _defaultSort + + if not toSort: + toSort = _defaultSort + + # Clean input array and reverse order to match convention + cleanArray = [i for i in assemblyArray if i != []] + cleanArray.reverse() + tempChains = [[[i] for i in cleanArray[0]], []] + + if len(cleanArray) > 1: + check = True + # Set up assembliesToAssign + fromGroupIndex = 1 + assemblyFrom = cleanArray[fromGroupIndex] + assemblyFrom.sort(key=lambda x: fromSort(x)) + else: + check = False + + while check: + # Sort current chains by previous assembly + sortedAssemblyTo = tempChains[0] + sortedAssemblyTo.sort(key=lambda x: toSort(x[-1])) + + if len(sortedAssemblyTo) < len(assemblyFrom): + # Update tempChains to prevent doubling up + if len(tempChains) == 1: + tempChains = [[]] + else: + tempChains = tempChains[1:] + # Add assemblies to swap chains and update tempChains + for i in range(len(sortedAssemblyTo)): + sortedAssemblyTo[i].append(assemblyFrom[i]) + tempChains[-1].append(sortedAssemblyTo[i]) + # Update assemblyFrom + assemblyFrom = assemblyFrom[len(sortedAssemblyTo) :] + + elif len(tempChains[0]) == len(assemblyFrom): + # Update tempChains to prevent doubling up + if len(tempChains) == 1: + tempChains = [[]] + else: + tempChains = tempChains[1:] + # Add assemblies to swap chains and update tempChains + for i in range(len(sortedAssemblyTo)): + sortedAssemblyTo[i].append(assemblyFrom[i]) + tempChains[-1].append(sortedAssemblyTo[i]) + # Get next set of assemblies + fromGroupIndex += 1 + if fromGroupIndex < len(cleanArray): + assemblyFrom = cleanArray[fromGroupIndex] + assemblyFrom.sort(key=lambda x: fromSort(x)) + tempChains.append([]) + + elif len(sortedAssemblyTo) > len(assemblyFrom): + # Update tempChains to prevent doubling up + tempChains[0] = sortedAssemblyTo[len(assemblyFrom) :] + # Add assemblies to swap chains and update tempChains + for i in range(len(assemblyFrom)): + sortedAssemblyTo[i].append(assemblyFrom[i]) + tempChains[-1].append(sortedAssemblyTo[i]) + # Get next set of assemblies + fromGroupIndex += 1 + if fromGroupIndex < len(cleanArray): + assemblyFrom = cleanArray[fromGroupIndex] + assemblyFrom.sort(key=lambda x: fromSort(x)) + tempChains.append([]) + + # Break Loop once complete + if fromGroupIndex >= len(cleanArray): + check = False + + dataStructure = [cascade for group in tempChains for cascade in group] + + # Reverse order to match convention + for cascade in dataStructure: + cascade.reverse() + + if newFuelName: + if newFuelName in dataStructure[0][0].parent.r.blueprints.assemblies.keys(): + for cascade in dataStructure: + cascade.append( + cascade[-1].parent.createAssemblyOfType(assemType=newFuelName) + ) + else: + raise ValueError("{} not a valid assembly name".format(newFuelName)) + + return dataStructure + + +def changeBlockLevelEnrichment( + assembly, + enrichmentList, +): + r""" + This function changes the block level enrichment of an assembly to match an + input value or list of values. The block level function adjustUEnrich is used + to adjust the number uranium number density. + + Parameters + ---------- + assembly : assembly object + Object that represents an assembly within the simulation + + enrichmentList : list + A list of enrichments values to assign to each block in the assembly. + This variable can also be a single float to assign to all blocks in the assembly. + """ + + if isinstance(enrichmentList, list): + if len(assembly.getBlocks(Flags.FUEL)) == len(enrichmentList): + for block, enrichment in zip( + assembly.getBlocks(Flags.FUEL), enrichmentList + ): + block.adjustUEnrich(enrichment) + else: + raise RuntimeError( + "Number of enrichment values provided does not match number of blocks in assembly {}" + "".format(assembly.name) + ) + elif isinstance(enrichmentList, float): + for block in assembly: + block.adjustUEnrich(enrichmentList) + else: + raise RuntimeError("{} is not a valid enrichment input".format(enrichmentList)) + + +def _defaultSort(assembly): + origin = numpy.array([0.0, 0.0, 0.0]) + p = numpy.array(assembly.spatialLocator.getLocalCoordinates()) + return round(((p - origin) ** 2).sum(), 5) + + +def _is_list(string): + try: + json.loads(string) + return True + except ValueError: + return False diff --git a/armi/reactor/reactors.py b/armi/reactor/reactors.py index 4ed0f035e2..bf9fe3deca 100644 --- a/armi/reactor/reactors.py +++ b/armi/reactor/reactors.py @@ -2307,6 +2307,7 @@ def processLoading(self, cs, dbLoad: bool = False): self.getNuclideCategories() # Generate list of flags that are to be stationary during assembly shuffling + stationaryBlockFlags = [] for stationaryBlockFlagString in cs["stationaryBlockFlags"]: