diff --git a/armi/physics/fuelCycle/fuelHandlerInterface.py b/armi/physics/fuelCycle/fuelHandlerInterface.py index fc83052a7..a5f27926a 100644 --- a/armi/physics/fuelCycle/fuelHandlerInterface.py +++ b/armi/physics/fuelCycle/fuelHandlerInterface.py @@ -123,7 +123,12 @@ def validateLocations(self): ] if len(locations) != len(set(locations)): - raise ValueError("Two or more assemblies share the same core location") + 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): """ diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py index 2fbd06be8..cfe910455 100644 --- a/armi/physics/fuelCycle/fuelHandlers.py +++ b/armi/physics/fuelCycle/fuelHandlers.py @@ -93,8 +93,12 @@ def r(self): 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._prepShuffleMap() self.r.core.locateAllAssemblies() def outage(self, factor=1.0): @@ -892,249 +896,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 @@ -1171,7 +932,6 @@ def swapAssemblies(self, a1, a2): a1StationaryBlocks, oldA1Location, a2StationaryBlocks, oldA2Location ) - def _validateAssemblySwap( self, a1StationaryBlocks, oldA1Location, a2StationaryBlocks, oldA2Location ): @@ -1900,7 +1660,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 e6f22115c..36cf8cfc5 100644 --- a/armi/physics/fuelCycle/tests/test_fuelHandlers.py +++ b/armi/physics/fuelCycle/tests/test_fuelHandlers.py @@ -541,21 +541,35 @@ def test_buildRingSchedule(self): schedule = fh.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 = fh.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 = fh.translationFunctions.buildRingSchedule(self, 1, 9) + self.assertEqual(schedule, [[1], [2], [3], [4], [5], [6], [7], [8], [9]]) # simple with 1 jump schedule = fh.translationFunctions.buildRingSchedule(self, 1, 9, jumpRingFrom=6) self.assertEqual(schedule, [[5], [4], [3], [2], [1], [6], [7], [8], [9]]) - # crash on invalid jumpring + # crash on outward jumps with converging with self.assertRaises(RuntimeError): schedule = fh.translationFunctions.buildRingSchedule( self, 1, 17, jumpRingFrom=0 ) - # test 4: Mid way jumping + # crash on inward jumps for diverging + with self.assertRaises(RuntimeError): + schedule = fh.translationFunctions.buildRingSchedule( + self, 1, 17, jumpRingFrom=5, jumpRingTo=3, diverging=True + ) + + # mid way jumping schedule = fh.translationFunctions.buildRingSchedule( self, 1, 9, jumpRingTo=6, jumpRingFrom=3, diverging=True ) @@ -566,8 +580,18 @@ def test_buildConvergentRingSchedule(self): schedule = fh.translationFunctions.buildConvergentRingSchedule(self, 1, 9) self.assertEqual(schedule, [[1], [2], [3], [4], [5], [6], [7], [8], [9]]) + # default inner and outer rings + schedule = fh.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) + def test_getRingAssemblies(self): fh = fuelHandlers.FuelHandler(self.o) + + # simple schedule = [[2], [1]] assemblies = fh.translationFunctions.getRingAssemblies(fh, schedule) self.assertEqual( @@ -581,7 +605,48 @@ def test_getRingAssemblies(self): ], ) + # circular ring + schedule = [[2], [1]] + assemblies = fh.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 = fh.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 = fh.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 = fh.translationFunctions.getBatchZoneAssembliesFromLocation( fh, @@ -604,6 +669,117 @@ def test_getBatchZoneAssembliesFromLocation(self): ], ) + # test invalid assembly locations + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fh.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["001-002"]] + ) + ) + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fh.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["002-007"]] + ) + ) + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fh.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["{:03d}-001".format(fh.r.core.getNumRings() + 1)]] + ) + ) + + # test new assembly + newAssembly = fh.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 = fh.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 = ( + fh.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, + [["new: {}; enrichment: 5,5".format(fh.r.core.refAssem.getType())]], + ) + ) + # test invalid new assembly + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fh.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["new: invalidType"]] + ) + ) + + # test sfp assembly + sfpAssembly = fh.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["sfp: {}".format(fh.r.core.sfp.getChildren()[0].getName())]] + ) + # test invalid sfp assembly + with self.assertRaises(RuntimeError): + invalidAssembly = ( + fh.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, [["sfp: invalidAssyName"]] + ) + ) + + # test invalid assembly setting + with self.assertRaises((NotImplementedError, RuntimeError)): + invalidAssembly = ( + fh.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 = fh.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 = fh.translationFunctions.getBatchZoneAssembliesFromLocation( + fh, + [ + [ + "003-001", + "003-002", + ], + ["001-001"], + ], + sortFun="a", + ) + def test_getCascadesFromLocations(self): fh = fuelHandlers.FuelHandler(self.o) locations = [ @@ -621,18 +797,87 @@ def test_getCascadesFromLocations(self): def test_buildBatchCascades(self): fh = fuelHandlers.FuelHandler(self.o) + # converging 2 jumps schedule = fh.translationFunctions.buildRingSchedule( - self, 1, 2, diverging=False + self, 1, 3, diverging=False + ) + assemblies = fh.translationFunctions.getRingAssemblies(fh, schedule) + batchCascade = fh.translationFunctions.buildBatchCascades(assemblies) + self.assertEqual( + batchCascade, + fh.translationFunctions.getCascadesFromLocations( + fh, + [ + ["003-003"], + ["002-001", "003-012"], + ["002-002", "003-002"], + ["001-001", "003-001"], + ], + ), ) + + # diverging 1 jump + schedule = fh.translationFunctions.buildRingSchedule(self, 1, 2, diverging=True) assemblies = fh.translationFunctions.getRingAssemblies(fh, schedule) batchCascade = fh.translationFunctions.buildBatchCascades(assemblies) self.assertEqual( batchCascade, fh.translationFunctions.getCascadesFromLocations( - fh, [["002-002"], ["001-001", "002-001"]] + fh, [["002-002", "002-001", "001-001"]] ), ) + # diverging 2 jumps + schedule = fh.translationFunctions.buildRingSchedule(self, 1, 3, diverging=True) + assemblies = fh.translationFunctions.getRingAssemblies(fh, schedule) + batchCascade = fh.translationFunctions.buildBatchCascades(assemblies) + self.assertEqual( + batchCascade, + fh.translationFunctions.getCascadesFromLocations( + fh, + [ + [ + "003-003", + "003-001", + "003-002", + "003-0012", + "002-002", + "002-001", + "001-001", + ] + ], + ), + ) + + # new fuel + schedule = fh.translationFunctions.buildRingSchedule( + self, + 1, + 2, + diverging=False, + ) + assemblies = fh.translationFunctions.getRingAssemblies(fh, schedule) + batchCascade = fh.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 = fh.translationFunctions.buildRingSchedule( + self, + 1, + 2, + diverging=False, + ) + assemblies = fh.translationFunctions.getRingAssemblies(fh, schedule) + with self.assertRaises(ValueError): + batchCascade = fh.translationFunctions.buildBatchCascades( + assemblies, newFuelName="invalidType" + ) + def test_changeBlockLevelEnrichment(self): fh = fuelHandlers.FuelHandler(self.o) assy = self.r.core.getAssemblies(Flags.FEED)[0] diff --git a/armi/physics/fuelCycle/translationFunctions.py b/armi/physics/fuelCycle/translationFunctions.py index d43511561..eacd8aecf 100644 --- a/armi/physics/fuelCycle/translationFunctions.py +++ b/armi/physics/fuelCycle/translationFunctions.py @@ -1,3 +1,17 @@ +# 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. + import json import math import numpy @@ -329,7 +343,7 @@ def getBatchZoneAssembliesFromLocation( ------------------------ core assembly: "xxx-xxx" new assembly: "new: assemType" - new assembly with modified enrichment: "new: assemType, enrichment: value" + new assembly with modified enrichment: "new: assemType; enrichment: value" sfp assembly: "sfp: assemName" Parameters