diff --git a/armi/bookkeeping/report/reportInterface.py b/armi/bookkeeping/report/reportInterface.py index ad57cd857..fe6dfaafa 100644 --- a/armi/bookkeeping/report/reportInterface.py +++ b/armi/bookkeeping/report/reportInterface.py @@ -64,8 +64,6 @@ def interactBOL(self): runLog.info(report.ALL[report.RUN_META]) def interactEveryNode(self, cycle, node): - if self.cs["zoneFlowSummary"]: - reportingUtils.summarizeZones(self.r.core, self.cs) if self.cs["assemPowSummary"]: reportingUtils.summarizePower(self.r.core) diff --git a/armi/bookkeeping/report/reportingUtils.py b/armi/bookkeeping/report/reportingUtils.py index 4dc3c66e7..8e678a239 100644 --- a/armi/bookkeeping/report/reportingUtils.py +++ b/armi/bookkeeping/report/reportingUtils.py @@ -594,92 +594,6 @@ def summarizePower(core): ) -def summarizeZones(core, cs): - r"""Summarizes the active zone and other zone. - - Parameters - ---------- - core: armi.reactor.reactors.Core - cs: armi.settings.caseSettings.Settings - - """ - - totPow = core.getTotalBlockParam("power") - if not totPow: - # protect against divide-by-zero - return - powList = [] # eventually will be a sorted list of power - for a in core.getAssemblies(): - if a.hasFlags(Flags.FUEL): - aPow = a.calcTotalParam("power") - powList.append((aPow / totPow, a)) - powList.sort() # lowest power assems first. - - # now build "low power region" and high power region. - # at BOL (cycle 0) just take all feeds as low power. (why not just use power fractions?, - # oh, because if you do that, a few igniters will make up the 1st 5% of the power.) - totFrac = 0.0 - lowPow = [] - highPow = [] - pFracList = [] # list of power fractions in the high power zone. - - for pFrac, a in powList: - if core.r.p.cycle > 0 and totFrac <= cs["lowPowerRegionFraction"]: - lowPow.append(a) - elif ( - core.r.p.cycle == 0 - and a.hasFlags(Flags.FEED | Flags.FUEL) - and a.getMaxUraniumMassEnrich() > 0.01 - ): - lowPow.append(a) - else: - highPow.append(a) - pFracList.append(pFrac) - totFrac += pFrac - - if not pFracList: - # sometimes this is empty (why?), which causes an error below when - # calling max(pFracList) - return - - if abs(totFrac - 1.0) < 1e-4: - runLog.warning("total power fraction not equal to sum of assembly powers.") - - peak = max(pFracList) # highest power assembly - peakIndex = pFracList.index(peak) - peakAssem = highPow[peakIndex] - - avgPFrac = sum(pFracList) / len(pFracList) # true mean power fraction - # the closest-to-average pfrac in the list - _avgAssemPFrac, avgIndex = findClosest(pFracList, avgPFrac, indx=True) - avgAssem = highPow[avgIndex] # the actual average assembly - - # ok, now need counts, and peak and avg. flow and power in high power region. - mult = core.powerMultiplier - - summary = "Zone Summary For Safety Analysis cycle {0}\n".format(core.r.p.cycle) - summary += " Assemblies in high-power zone: {0}\n".format(len(highPow) * mult) - summary += " Assemblies in low-power zone: {0}\n".format(len(lowPow) * mult) - summary += " " * 13 + "{0:15s} {1:15s} {2:15s} {3:15s}\n".format( - "Location", "Power (W)", "Flow (kg/s)", "Pu frac" - ) - - for lab, a in [("Peak", peakAssem), ("Average", avgAssem)]: - flow = a.p.THmassFlowRate - if not flow: - runLog.warning("No TH data. Reporting zero flow.") - # no TH for some reason - flow = 0.0 - puFrac = a.getPuFrac() - ring, pos = a.spatialLocator.getRingPos() - summary += ( - " {0:10s} ({ring:02d}, {pos:02d}) {1:15.6E} {2:15.6E} {pu:15.6E}\n".format( - lab, a.calcTotalParam("power"), flow, ring=ring, pos=pos, pu=puFrac - ) - ) - runLog.important(summary) - - def makeCoreDesignReport(core, cs): r"""Builds report to summarize core design inputs diff --git a/armi/bookkeeping/report/tests/test_report.py b/armi/bookkeeping/report/tests/test_report.py index f614ac856..060c846ac 100644 --- a/armi/bookkeeping/report/tests/test_report.py +++ b/armi/bookkeeping/report/tests/test_report.py @@ -25,7 +25,6 @@ summarizePinDesign, summarizePower, summarizePowerPeaking, - summarizeZones, writeAssemblyMassSummary, writeCycleSummary, ) @@ -123,11 +122,6 @@ def test_reactorSpecificReporting(self): self.assertIn("End of Cycle", mock._outputStream) mock._outputStream = "" - # this report won't do much for the test reactor - improve test reactor - summarizeZones(r.core, o.cs) - self.assertEqual(len(mock._outputStream), 0) - mock._outputStream = "" - # this report won't do much for the test reactor - improve test reactor makeBlockDesignReport(r) self.assertEqual(len(mock._outputStream), 0) diff --git a/armi/meta.py b/armi/meta.py index 90de9c4da..dde917669 100644 --- a/armi/meta.py +++ b/armi/meta.py @@ -16,4 +16,4 @@ Metadata describing an ARMI distribution. """ -__version__ = "0.2.4" +__version__ = "0.2.5" diff --git a/armi/operators/settingsValidation.py b/armi/operators/settingsValidation.py index 82071c2c9..d20eecd99 100644 --- a/armi/operators/settingsValidation.py +++ b/armi/operators/settingsValidation.py @@ -363,16 +363,6 @@ def _inspectSettings(self): lambda: self._assignCS("outputFileExtension", "png"), ) - self.addQuery( - lambda: ( - self.cs[globalSettings.CONF_ZONING_STRATEGY] == "manual" - and not self.cs["zoneDefinitions"] - ), - "`manual` zoningStrategy requires that `zoneDefinitions` setting be defined. Run will have " - "no zones.", - "", - self.NO_ACTION, - ) self.addQuery( lambda: ( ( diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py index b7e60ed6d..b8f7da128 100644 --- a/armi/physics/fuelCycle/fuelHandlers.py +++ b/armi/physics/fuelCycle/fuelHandlers.py @@ -724,6 +724,7 @@ def getParamWithBlockLevelMax(a, paramName): # this assembly is in the excluded location list. skip it. continue + # only continue of the Assembly is in a Zone if zoneList: found = False # guilty until proven innocent for zone in zoneList: diff --git a/armi/reactor/converters/geometryConverters.py b/armi/reactor/converters/geometryConverters.py index 9a85770b1..cd8fc8957 100644 --- a/armi/reactor/converters/geometryConverters.py +++ b/armi/reactor/converters/geometryConverters.py @@ -1296,16 +1296,23 @@ def convert(self, r): for a in self._sourceReactor.core.getAssemblies(): # make extras and add them too. since the input is assumed to be 1/3 core. otherLocs = grid.getSymmetricEquivalents(a.spatialLocator.indices) + thisZone = ( + self._sourceReactor.core.zones.findZoneItIsIn(a) + if len(self._sourceReactor.core.zones) > 0 + else None + ) angle = 2 * math.pi / (len(otherLocs) + 1) count = 1 for i, j in otherLocs: newAssem = copy.deepcopy(a) newAssem.makeUnique() newAssem.rotate(count * angle) - count = count + 1 + count += 1 self._sourceReactor.core.add( newAssem, self._sourceReactor.core.spatialGrid[i, j, 0] ) + if thisZone: + thisZone.addLoc(newAssem.getLocation()) self._newAssembliesAdded.append(newAssem) if a.getLocation() == "001-001": diff --git a/armi/reactor/grids.py b/armi/reactor/grids.py index 9bd57d3d5..fa0c4a660 100644 --- a/armi/reactor/grids.py +++ b/armi/reactor/grids.py @@ -1704,21 +1704,21 @@ def generateSortedHexLocationList(self, nLocs): nLocs = int(nLocs) # need to make this an integer # next, generate a list of locations and corresponding distances - locList = [] + locs = [] for ring in range(1, hexagon.numRingsToHoldNumCells(nLocs) + 1): positions = self.getPositionsInRing(ring) for position in range(1, positions + 1): i, j = self.getIndicesFromRingAndPos(ring, position) - locList.append(self[(i, j, 0)]) + locs.append(self[(i, j, 0)]) # round to avoid differences due to floating point math - locList.sort( + locs.sort( key=lambda loc: ( round(numpy.linalg.norm(loc.getGlobalCoordinates()), 6), loc.i, # loc.i=ring loc.j, ) ) # loc.j= pos - return locList[:nLocs] + return locs[:nLocs] # TODO: this is only used by testing and another method that just needs the count of assemblies # in a ring, not the actual positions diff --git a/armi/reactor/reactors.py b/armi/reactor/reactors.py index a5af37a93..3b9595acb 100644 --- a/armi/reactor/reactors.py +++ b/armi/reactor/reactors.py @@ -194,7 +194,7 @@ def __init__(self, name): self.locParams = {} # location-based parameters # overridden in case.py to include pre-reactor time. self.timeOfStart = time.time() - self.zones = None + self.zones = zones.Zones() # initialize with empty Zones object # initialize the list that holds all shuffles self.moveList = {} self.scalarVals = {} @@ -1019,15 +1019,14 @@ def getAssemblies( includeAll : bool, optional Will include ALL assemblies. - zones : str or iterable, optional - Only include assemblies that are in this zone/these zones + zones : iterable, optional + Only include assemblies that are in this these zones Notes ----- Attempts have been made to make this a generator but there were some Cython incompatibilities that we could not get around and so we are sticking with a list. - """ if includeAll: includeBolAssems = includeSFP = True @@ -1211,9 +1210,6 @@ def regenAssemblyLists(self): """ self._getAssembliesByName() self._genBlocksByName() - runLog.important("Regenerating Core Zones") - # TODO: this call is questionable... the cs should correspond to analysis - self.buildZones(settings.getMasterCs()) self._genChildByLocationLookupTable() def getAllXsSuffixes(self): @@ -1762,11 +1758,6 @@ def getAssembliesOnSymmetryLine(self, symmetryLineID): assembliesOnLine.sort(key=lambda a: a.spatialLocator.getRingPos()) return assembliesOnLine - def buildZones(self, cs): - """Update the zones on the reactor.""" - self.zones = zones.buildZones(self, cs) - self.zones = zones.splitZones(self, cs, self.zones) - def getCoreRadius(self): """Returns a radius that the core would fit into.""" return self.getNumRings(indexBased=True) * self.getFirstBlock().getPitch() @@ -2313,15 +2304,54 @@ def processLoading(self, cs, dbLoad: bool = False): self.stationaryBlockFlagsList = stationaryBlockFlags - # Perform initial zoning task - self.buildZones(cs) - self.p.maxAssemNum = self.getMaxParam("assemNum") getPluginManagerOrFail().hook.onProcessCoreLoading( core=self, cs=cs, dbLoad=dbLoad ) + def buildManualZones(self, cs): + """ + Build the Zones that are defined manually in the given CaseSettings file, + in the `zoneDefinitions` setting. + + Parameters + ---------- + cs : CaseSettings + The standard ARMI settings object + + Examples + -------- + Manual zones will be defined in a special string format, e.g.: + + zoneDefinitions: + - ring-1: 001-001 + - ring-2: 002-001, 002-002 + - ring-3: 003-001, 003-002, 003-003 + + Notes + ----- + This function will just define the Zones it sees in the settings, it does + not do any validation against a Core object to ensure those manual zones + make sense. + """ + runLog.debug( + "Building Zones by manual definitions in `zoneDefinitions` setting" + ) + stripper = lambda s: s.strip() + self.zones = zones.Zones() + + # parse the special input string for zone definitions + for zoneString in cs["zoneDefinitions"]: + zoneName, zoneLocs = zoneString.split(":") + zoneLocs = zoneLocs.split(",") + zone = zones.Zone(zoneName.strip()) + zone.addLocs(map(stripper, zoneLocs)) + self.zones.addZone(zone) + + if not len(self.zones): + runLog.debug("No manual zones defined in `zoneDefinitions` setting") + def _applyThermalExpansion( self, assems: list, dbLoad: bool, referenceAssembly=None ): diff --git a/armi/reactor/tests/test_reactors.py b/armi/reactor/tests/test_reactors.py index 3c425fb1f..06547cb99 100644 --- a/armi/reactor/tests/test_reactors.py +++ b/armi/reactor/tests/test_reactors.py @@ -936,6 +936,32 @@ def test_updateBlockBOLHeights_DBLoad(self): for param in equalParameters: self.assertAlmostEqual(oldBlockParameters[param][b], b.p[param]) + def test_buildManualZones(self): + # define some manual zones in the settings + newSettings = {} + newSettings["zoneDefinitions"] = [ + "ring-1: 001-001", + "ring-2: 002-001, 002-002", + "ring-3: 003-001, 003-002, 003-003", + ] + cs = self.o.cs.modified(newSettings=newSettings) + self.r.core.buildManualZones(cs) + + zonez = self.r.core.zones + self.assertEqual(len(list(zonez)), 3) + self.assertIn("002-001", zonez["ring-2"]) + self.assertIn("003-002", zonez["ring-3"]) + + def test_buildManualZonesEmpty(self): + # ensure there are no zone definitions in the settings + newSettings = {} + newSettings["zoneDefinitions"] = [] + cs = self.o.cs.modified(newSettings=newSettings) + + # verify that buildZones behaves well when no zones are defined + self.r.core.buildManualZones(cs) + self.assertEqual(len(list(self.r.core.zones)), 0) + class CartesianReactorTests(ReactorTests): def setUp(self): diff --git a/armi/reactor/tests/test_zones.py b/armi/reactor/tests/test_zones.py index 2f8afee44..18384f5ba 100644 --- a/armi/reactor/tests/test_zones.py +++ b/armi/reactor/tests/test_zones.py @@ -14,393 +14,302 @@ """Test for Zones""" # pylint: disable=missing-function-docstring,missing-class-docstring,abstract-method,protected-access -import copy import logging import os import unittest from armi import runLog from armi.reactor import assemblies +from armi.reactor import blocks from armi.reactor import blueprints from armi.reactor import geometry from armi.reactor import grids from armi.reactor import reactors from armi.reactor import zones -from armi.reactor.flags import Flags from armi.reactor.tests import test_reactors -from armi.settings.fwSettings import globalSettings from armi.tests import mockRunLogs THIS_DIR = os.path.dirname(__file__) -class Zone_TestCase(unittest.TestCase): +class TestZone(unittest.TestCase): def setUp(self): + # set up a Reactor, for the spatialLocator bp = blueprints.Blueprints() r = reactors.Reactor("zonetest", bp) r.add(reactors.Core("Core")) r.core.spatialGrid = grids.HexGrid.fromPitch(1.0) + r.core.spatialGrid._bounds = ( + [0, 1, 2, 3, 4], + [0, 10, 20, 30, 40], + [0, 20, 40, 60, 80], + ) r.core.spatialGrid.symmetry = geometry.SymmetryType( geometry.DomainType.THIRD_CORE, geometry.BoundaryType.PERIODIC ) r.core.spatialGrid.geomType = geometry.HEX - aList = [] - for ring in range(10): + + # some testing constants + self.numAssems = 5 + self.numBlocks = 5 + + # build a list of Assemblies + self.aList = [] + for ring in range(self.numAssems): a = assemblies.HexAssembly("fuel") + a.spatialGrid = r.core.spatialGrid a.spatialLocator = r.core.spatialGrid[ring, 1, 0] a.parent = r.core - aList.append(a) - self.aList = aList + self.aList.append(a) + + # build a list of Blocks + self.bList = [] + for _ in range(self.numBlocks): + b = blocks.HexBlock("TestHexBlock") + b.setType("defaultType") + b.p.nPins = 3 + b.setHeight(3.0) + self.aList[0].add(b) + self.bList.append(b) + + def test_addItem(self): + zone = zones.Zone("test_addItem") + zone.addItem(self.aList[0]) + self.assertIn(self.aList[0].getLocation(), zone) + + self.assertRaises(AssertionError, zone.addItem, "nope") + + def test_removeItem(self): + zone = zones.Zone("test_removeItem", [a.getLocation() for a in self.aList]) + zone.removeItem(self.aList[0]) + self.assertNotIn(self.aList[0].getLocation(), zone) + + self.assertRaises(AssertionError, zone.removeItem, "also nope") + + def test_addItems(self): + zone = zones.Zone("test_addItems") + zone.addItems(self.aList) + for a in self.aList: + self.assertIn(a.getLocation(), zone) - def test_addAssemblyLocations(self): - zone = zones.Zone("TestZone") - zone.addAssemblyLocations(self.aList) + def test_removeItems(self): + zone = zones.Zone("test_removeItems", [a.getLocation() for a in self.aList]) + zone.removeItems(self.aList) + for a in self.aList: + self.assertNotIn(a.getLocation(), zone) + + def test_addLoc(self): + zone = zones.Zone("test_addLoc") + zone.addLoc(self.aList[0].getLocation()) + self.assertIn(self.aList[0].getLocation(), zone) + + self.assertRaises(AssertionError, zone.addLoc, 1234) + + def test_removeLoc(self): + zone = zones.Zone("test_removeLoc", [a.getLocation() for a in self.aList]) + zone.removeLoc(self.aList[0].getLocation()) + self.assertNotIn(self.aList[0].getLocation(), zone) + + self.assertRaises(AssertionError, zone.removeLoc, 1234) + + def test_addLocs(self): + zone = zones.Zone("test_addLocs") + zone.addLocs([a.getLocation() for a in self.aList]) for a in self.aList: self.assertIn(a.getLocation(), zone) - self.assertRaises(RuntimeError, zone.addAssemblyLocations, self.aList) + def test_removeLocs(self): + zone = zones.Zone("test_removeLocs", [a.getLocation() for a in self.aList]) + zone.removeLocs([a.getLocation() for a in self.aList]) + for a in self.aList: + self.assertNotIn(a.getLocation(), zone) def test_iteration(self): locs = [a.getLocation() for a in self.aList] - zone = zones.Zone("TestZone") - zone.addAssemblyLocations(self.aList) + zone = zones.Zone("test_iteration") + + # BONUS TEST: Zone.__len__() + self.assertEqual(len(zone), 0) + zone.addLocs(locs) + self.assertEqual(len(zone), self.numAssems) + + # loop once to prove looping works for aLoc in zone: self.assertIn(aLoc, locs) + self.assertTrue(aLoc in zone) # Tests Zone.__contains__() # loop twice to make sure it iterates nicely. for aLoc in zone: self.assertIn(aLoc, locs) + self.assertTrue(aLoc in zone) # Tests Zone.__contains__() - def test_extend(self): - zone = zones.Zone("TestZone") - zone.extend([a.getLocation() for a in self.aList]) - for a in self.aList: - self.assertIn(a.getLocation(), zone) + def test_repr(self): + zone = zones.Zone("test_repr") + zone.addItems(self.aList) + zStr = "Zone test_repr with 5 Assemblies" + self.assertIn(zStr, str(zone)) + + def test_blocks(self): + zone = zones.Zone("test_blocks", zoneType=blocks.Block) - def test_index(self): - zone = zones.Zone("TestZone") - zone.addAssemblyLocations(self.aList) - for i, loc in enumerate(zone.locList): - self.assertEqual(i, zone.index(loc)) - - def test_addRing(self): - zone = zones.Zone("TestZone") - zone.addRing(5) - self.assertIn("005-003", zone) - self.assertNotIn("006-002", zone) - - zone.addRing(6, 3, 9) - self.assertIn("006-003", zone) - self.assertIn("006-009", zone) - self.assertNotIn("006-002", zone) - self.assertNotIn("006-010", zone) - - def test_add(self): - zone = zones.Zone("TestZone") - zone.addRing(5) - otherZone = zones.Zone("OtherZone") - otherZone.addRing(6, 3, 9) - combinedZoneList = zone + otherZone - self.assertIn("005-003", combinedZoneList) - self.assertIn("006-003", combinedZoneList) - self.assertIn("006-009", combinedZoneList) - - -class Zones_InReactor(unittest.TestCase): + # test the blocks were correctly added + self.assertEqual(len(zone), 0) + zone.addItems(self.bList) + self.assertEqual(len(zone), self.numBlocks) + + # loop once to prove looping works + for aLoc in zone: + self.assertIn(aLoc, zone.locs) + self.assertTrue(aLoc in zone) # Tests Zone.__contains__() + + +class TestZones(unittest.TestCase): def setUp(self): + # spin up the test reactor self.o, self.r = test_reactors.loadTestReactor() - def test_buildRingZones(self): - o, r = self.o, self.r - cs = o.cs - - newSettings = {globalSettings.CONF_ZONING_STRATEGY: "byRingZone"} - newSettings["ringZones"] = [] - cs = cs.modified(newSettings=newSettings) - zonez = zones.buildZones(r.core, cs) - self.assertEqual(len(list(zonez)), 1) - self.assertEqual(9, r.core.numRings) - - newSettings = {"ringZones": [5, 8]} - cs = cs.modified(newSettings=newSettings) - zonez = zones.buildZones(r.core, cs) - self.assertEqual(len(list(zonez)), 2) - zone = zonez["ring-1"] - self.assertEqual(len(zone), (5 * (5 - 1) + 1)) - zone = zonez["ring-2"] - # Note that the actual number of rings in the reactor model is 9. Even though we - # asked for the last zone to to to 8, the zone engine should bump it out. Not - # sure if this is behavior that we want to preserve, but at least it's being - # tested properly now. - self.assertEqual(len(zone), (9 * (9 - 1) + 1) - (5 * (5 - 1) + 1)) - - newSettings = {"ringZones": [5, 7, 8]} - cs = cs.modified(newSettings=newSettings) - zonez = zones.buildZones(r.core, cs) - self.assertEqual(len(list(zonez)), 3) - zone = zonez["ring-3"] - self.assertEqual(len(zone), 30) # rings 8 and 9. See above comment - - def test_buildManualZones(self): - o, r = self.o, self.r - cs = o.cs - - # customize settings for this test - newSettings = {globalSettings.CONF_ZONING_STRATEGY: "manual"} + # build some generic test zones to get started with + newSettings = {} newSettings["zoneDefinitions"] = [ "ring-1: 001-001", "ring-2: 002-001, 002-002", "ring-3: 003-001, 003-002, 003-003", ] - cs = cs.modified(newSettings=newSettings) - zonez = zones.buildZones(r.core, cs) - - self.assertEqual(len(list(zonez)), 3) - self.assertIn("003-002", zonez["ring-3"]) - - def test_buildAssemTypeZones(self): - o, r = self.o, self.r - cs = o.cs - - # customize settings for this test - newSettings = {globalSettings.CONF_ZONING_STRATEGY: "byFuelType"} - cs = cs.modified(newSettings=newSettings) - zonez = zones.buildZones(r.core, cs) - - self.assertEqual(len(list(zonez)), 4) - self.assertIn("008-040", zonez["feed fuel"]) - self.assertIn("005-023", zonez["igniter fuel"]) - self.assertIn("003-002", zonez["lta fuel"]) - self.assertIn("004-003", zonez["lta fuel b"]) - - def test_buildZonesForEachFA(self): - o, r = self.o, self.r - cs = o.cs - + cs = self.o.cs.modified(newSettings=newSettings) + self.r.core.buildManualZones(cs) + self.zonez = self.r.core.zones + + def test_dictionaryInterface(self): + zs = zones.Zones() + + # validate the addZone() and __len__() work + self.assertEqual(len(zs.names), 0) + zs.addZone(self.zonez["ring-2"]) + self.assertEqual(len(zs.names), 1) + + # validate that __contains__() works + self.assertFalse("ring-1" in zs) + self.assertTrue("ring-2" in zs) + self.assertFalse("ring-3" in zs) + + # validate that __remove__() works + del zs["ring-2"] + self.assertEqual(len(zs.names), 0) + + # validate that addZones() works + zs.addZones(self.zonez) + self.assertEqual(len(zs.names), 3) + self.assertTrue("ring-1" in zs) + self.assertTrue("ring-2" in zs) + self.assertTrue("ring-3" in zs) + + # validate that get() works + ring3 = zs["ring-3"] + self.assertEqual(len(ring3), 3) + self.assertIn("003-002", ring3) + + # validate that removeZones() works + zonesToRemove = [z.name for z in self.zonez] + zs.removeZones(zonesToRemove) + self.assertEqual(len(zs.names), 0) + self.assertFalse("ring-1" in zs) + self.assertFalse("ring-2" in zs) + self.assertFalse("ring-3" in zs) + + def test_findZoneItIsIn(self): # customize settings for this test - newSettings = {globalSettings.CONF_ZONING_STRATEGY: "everyFA"} - cs = cs.modified(newSettings=newSettings) - zonez = zones.buildZones(r.core, cs) - - self.assertEqual(len(list(zonez)), 53) - self.assertIn("008-040", zonez["channel 1"]) - self.assertIn("005-023", zonez["channel 2"]) - self.assertIn("006-029", zonez["channel 3"]) - - def test_buildZonesByOrifice(self): - o, r = self.o, self.r - cs = o.cs + newSettings = {} + newSettings["zoneDefinitions"] = [ + "ring-1: 001-001", + "ring-2: 002-001, 002-002", + ] + cs = self.o.cs.modified(newSettings=newSettings) - newSettings = {globalSettings.CONF_ZONING_STRATEGY: "byOrifice"} - cs = cs.modified(newSettings=newSettings) - zonez = zones.buildZones(r.core, cs) + self.r.core.buildManualZones(cs) + daZones = self.r.core.zones + for zone in daZones: + a = self.r.core.getAssemblyWithStringLocation(sorted(zone.locs)[0]) + aZone = daZones.findZoneItIsIn(a) + self.assertEqual(aZone, zone) - self.assertEqual(len(list(zonez)), 4) - self.assertIn("008-040", zonez["zone0-Outer"]) - self.assertIn("005-023", zonez["zone0-Inner"]) - self.assertIn("003-002", zonez["zone0-lta"]) - self.assertIn("009-001", zonez["zone0-Default"]) + # get assem from first zone + a = self.r.core.getAssemblyWithStringLocation( + sorted(daZones[daZones.names[0]].locs)[0] + ) + # remove the zone + daZones.removeZone(daZones.names[0]) - def test_removeZone(self): - o, r = self.o, self.r - cs = o.cs + # ensure that we can no longer find the assembly in the zone + self.assertEqual(daZones.findZoneItIsIn(a), None) + def test_getZoneLocations(self): # customize settings for this test - newSettings = {globalSettings.CONF_ZONING_STRATEGY: "byRingZone"} - newSettings["ringZones"] = [5, 8] - cs = cs.modified(newSettings=newSettings) - - # produce 2 zones, with the names ringzone0 and ringzone1 - daZones = zones.buildZones(r.core, cs) - daZones.removeZone("ring-1") - - # The names list should only house the only other remaining zone now - self.assertEqual(["ring-2"], daZones.names) - - # if indexed like a dict, the zones object should give a key error from the removed zone - with self.assertRaises(KeyError): - daZones["ring-1"] # pylint: disable=pointless-statement - - # Ensure we can still iterate through our zones object - for name in daZones.names: - _ = daZones[name] - - def test_findZoneAssemblyIsIn(self): - cs = self.o.cs - - newSettings = {"ringZones": [5, 7, 8]} - cs = cs.modified(newSettings=newSettings) - - daZones = zones.buildZones(self.r.core, cs) - for zone in daZones: - a = self.r.core.getAssemblyWithStringLocation(zone.locList[0]) - aZone = daZones.findZoneAssemblyIsIn(a) - self.assertEqual(aZone, zone) + newSettings = {} + newSettings["zoneDefinitions"] = [ + "ring-1: 001-001", + "ring-2: 002-001, 002-002", + ] + cs = self.o.cs.modified(newSettings=newSettings) + self.r.core.buildManualZones(cs) - # lets test if we get a none and a warning if the assembly does not exist in a zone - a = self.r.core.getAssemblyWithStringLocation( - daZones[daZones.names[0]].locList[0] - ) # get assem from first zone - daZones.removeZone( - daZones.names[0] - ) # remove a zone to ensure that our assem does not have a zone anymore - - self.assertEqual(daZones.findZoneAssemblyIsIn(a), None) - - -class Zones_InRZReactor(unittest.TestCase): - def test_splitZones(self): - # Test to make sure that we can split a zone containing control and fuel assemblies. - # Also test that we can separate out assemblies with differing numbers of blocks. - - o, r = test_reactors.loadTestReactor() - cs = o.cs - - newSettings = {"splitZones": False} - newSettings[globalSettings.CONF_ZONING_STRATEGY] = "byRingZone" - newSettings["ringZones"] = [1, 2, 3, 4, 5, 6, 7, 8, 9] - cs = cs.modified(newSettings=newSettings) - - diverseZone = "ring-4" - r.core.buildZones(cs) - daZones = r.core.zones - # lets make one of the assemblies have an extra block - zoneLocations = daZones.getZoneLocations(diverseZone) - originalAssemblies = r.core.getLocationContents( - zoneLocations, assemblyLevel=True + # test the retrieval of zone locations + self.assertEqual( + set(["002-001", "002-002"]), self.r.core.zones.getZoneLocations("ring-2") ) - fuel = [a for a in originalAssemblies if a.hasFlags(Flags.FUEL)][0] - newBlock = copy.deepcopy(fuel[-1]) - fuel.add(newBlock) - - # should contain a zone for every ring zone - # we only want one ring zone for this test, containing assemblies of different types. - zoneTup = tuple(daZones.names) - for zoneName in zoneTup: - if zoneName != diverseZone: - daZones.removeZone(zoneName) - - # this should split diverseZone into multiple zones by nodalization type. - newSettings = {"splitZones": True} - cs = cs.modified(newSettings=newSettings) - zones.splitZones(r.core, cs, daZones) - - # test to make sure that we split the ring zone correctly - self.assertEqual(len(daZones["ring-4-igniter-fuel-5"]), 4) - self.assertEqual(len(daZones["ring-4-igniter-fuel-6"]), 1) - self.assertEqual(len(daZones["ring-4-lta-fuel-b-5"]), 1) - - def test_createHotZones(self): - # Test to make sure createHotZones identifies the highest p/f location in a zone - # Test to make sure createHotZones can remove the peak assembly from that zone and place it in a new zone - # Test that the power in the old zone and the new zone is conserved. - # Test that if a hot zone can not be created from a single assembly zone. - o, r = test_reactors.loadTestReactor() - cs = o.cs - - newSettings = {"splitZones": False} - newSettings[globalSettings.CONF_ZONING_STRATEGY] = "byRingZone" - newSettings["ringZones"] = [9] # build one giant zone - cs = cs.modified(newSettings=newSettings) - - r.core.buildZones(cs) - daZones = r.core.zones - - originalassemblies = [] - originalPower = 0.0 - peakZonePFRatios = [] - - # Create a single assembly zone to verify that it will not create a hot zone - single = zones.Zone("single") - daZones.add(single) - aLoc = next( - a - for a in r.core.getAssemblies(Flags.FUEL) - if a.spatialLocator.getRingPos() == (1, 1) - ).getLocation() - single.append(aLoc) - - # Set power and flow. - # Also gather channel peak P/F ratios, assemblies and power. - for zone in daZones: - powerToFlow = [] - zoneLocations = daZones.getZoneLocations(zone.name) - assems = r.core.getLocationContents(zoneLocations, assemblyLevel=True) - power = 300.0 - flow = 300.0 - for a in assems: - a.getFirstBlock().p.power = power - assemblyPower = a.calcTotalParam("power") - a[-1].p.THmassFlowRate = flow - powerToFlow.append(assemblyPower / a[-1].p.THmassFlowRate) - originalPower += assemblyPower - originalassemblies.append(a) - power += 1 - flow -= 1 - peakZonePFRatios.append(max(powerToFlow)) - - daZones = zones.createHotZones(r.core, daZones) - # Test that the hot zones have the peak P/F from the host channels - i = 0 - for zone in daZones: - if zone.hotZone: - hotAssemLocation = daZones.getZoneLocations(zone.name) - hotAssem = r.core.getLocationContents( - hotAssemLocation, assemblyLevel=True - )[0] - self.assertEqual( - peakZonePFRatios[i], - hotAssem.calcTotalParam("power") / hotAssem[-1].p.THmassFlowRate, - ) - i += 1 - - powerAfterHotZoning = 0.0 - assembliesAfterHotZoning = [] - - # Check that power is conserved and that we did not lose any assemblies - for zone in daZones: - locs = daZones.getZoneLocations(zone.name) - assems = r.core.getLocationContents(locs, assemblyLevel=True) - for a in assems: - assembliesAfterHotZoning.append(a) - powerAfterHotZoning += a.calcTotalParam("power") - self.assertEqual(powerAfterHotZoning, originalPower) - self.assertEqual(len(assembliesAfterHotZoning), len(originalassemblies)) - - # check that the original zone with 1 channel has False for hotzone - self.assertEqual(single.hotZone, False) - # check that we have the correct number of hot and normal zones. - hotCount = 0 - normalCount = 0 - for zone in daZones: - if zone.hotZone: - hotCount += 1 - else: - normalCount += 1 - self.assertEqual(hotCount, 1) - self.assertEqual(normalCount, 2) - def test_zoneSummary(self): - o, r = test_reactors.loadTestReactor() + def test_getAllLocations(self): + # customize settings for this test + newSettings = {} + newSettings["zoneDefinitions"] = [ + "ring-1: 001-001", + "ring-2: 002-001, 002-002", + ] + cs = self.o.cs.modified(newSettings=newSettings) + self.r.core.buildManualZones(cs) - r.core.buildZones(o.cs) - daZones = r.core.zones + # test the retrieval of zone locations + self.assertEqual( + set(["001-001", "002-001", "002-002"]), self.r.core.zones.getAllLocations() + ) + def test_summary(self): # make sure we have a couple of zones to test on - for name0 in ["ring-1-radial-shield-5", "ring-1-feed-fuel-5"]: - self.assertIn(name0, daZones.names) + for name0 in ["ring-1", "ring-2", "ring-3"]: + self.assertIn(name0, self.zonez.names) + # test the summary (in the log) with mockRunLogs.BufferLog() as mock: - runLog.LOG.startLog("test_zoneSummary") + runLog.LOG.startLog("test_summary") runLog.LOG.setVerbosity(logging.INFO) - self.assertEqual("", mock._outputStream) - daZones.summary() - - self.assertIn("Zone Summary", mock._outputStream) - self.assertIn("Zone Power", mock._outputStream) - self.assertIn("Zone Average Flow", mock._outputStream) + self.zonez.summary() + + self.assertIn("zoneDefinitions:", mock._outputStream) + self.assertIn("- ring-1: ", mock._outputStream) + self.assertIn("- ring-2: ", mock._outputStream) + self.assertIn("- ring-3: ", mock._outputStream) + self.assertIn("003-001, 003-002, 003-003", mock._outputStream) + + def test_sortZones(self): + # create some zones in non-alphabetical order + zs = zones.Zones() + zs.addZone(self.zonez["ring-3"]) + zs.addZone(self.zonez["ring-1"]) + zs.addZone(self.zonez["ring-2"]) + + # check the initial order of the zones + self.assertEqual(list(zs._zones.keys())[0], "ring-3") + self.assertEqual(list(zs._zones.keys())[1], "ring-1") + self.assertEqual(list(zs._zones.keys())[2], "ring-2") + + # sort the zones + zs.sortZones() + + # check the final order of the zones + self.assertEqual(list(zs._zones.keys())[0], "ring-1") + self.assertEqual(list(zs._zones.keys())[1], "ring-2") + self.assertEqual(list(zs._zones.keys())[2], "ring-3") if __name__ == "__main__": diff --git a/armi/reactor/zones.py b/armi/reactor/zones.py index 7c6cb884f..b7e3c627d 100644 --- a/armi/reactor/zones.py +++ b/armi/reactor/zones.py @@ -13,152 +13,237 @@ # limitations under the License. """ -Zones are collections of locations. +A Zone object is a collection of locations in the Core. +A Zones object is a collection of Zone objects. +Together, they are used to conceptually divide the Core for analysis. """ -import tabulate +from typing import Iterator, List, Optional, Set, Union from armi import runLog -from armi import utils -from armi.reactor import grids -from armi.reactor.flags import Flags -from armi.settings.fwSettings import globalSettings +from armi.reactor.assemblies import Assembly +from armi.reactor.blocks import Block class Zone: """ - A group of locations labels useful for choosing where to shuffle from or where to compute - reactivity coefficients. - locations if specified should be provided as a list of assembly locations. + A group of locations in the Core, used to divide it up for analysis. + Each location represents an Assembly or a Block. """ - def __init__(self, name, locations=None, symmetry=3): - self.symmetry = symmetry + VALID_TYPES = (Assembly, Block) + + def __init__( + self, name: str, locations: Optional[List] = None, zoneType: type = Assembly + ): self.name = name + + # A single Zone must contain items of the same type + if zoneType not in Zone.VALID_TYPES: + raise TypeError( + "Invalid Type {0}; A Zone can only be of type {1}".format( + zoneType, Zone.VALID_TYPES + ) + ) + self.zoneType = zoneType + + # a Zone is mostly just a collection of locations in the Reactor if locations is None: - locations = [] - self.locList = locations - self.hotZone = False - self.hostZone = name + self.locs = set() + else: + # NOTE: We are not validating the locations. + self.locs = set(locations) + + def __contains__(self, loc: str) -> bool: + return loc in self.locs + + def __iter__(self) -> Iterator[str]: + """Loop through the locations, in alphabetical order.""" + for loc in sorted(self.locs): + yield loc + + def __len__(self) -> int: + """Return the number of locations""" + return len(self.locs) + + def __repr__(self) -> str: + zType = "Assemblies" + if self.zoneType == Block: + zType = "Blocks" - def __repr__(self): - return "".format(self.name, len(self)) + return "".format(self.name, len(self), zType) - def __getitem__(self, index): - return self.locList[index] + def addLoc(self, loc: str) -> None: + """ + Adds the location to this Zone. - def __setitem__(self, index, locStr): - self.locList[index] = locStr + Parameters + ---------- + loc : str + Location within the Core. - def extend(self, locList): - self.locList.extend(locList) + Notes + ----- + This method does not validate that the location given is somehow "valid". + We are not doing any reverse lookups in the Reactor to prove that the type + or location is valid. Because this would require heavier computation, and + would add some chicken-and-the-egg problems into instantiating a new Reactor. + + Returns + ------- + None + """ + assert isinstance(loc, str), "The location must be a str: {0}".format(loc) + self.locs.add(loc) - def index(self, loc): - return self.locList.index(loc) + def removeLoc(self, loc: str) -> None: + """ + Removes the location from this Zone. - def __len__(self): - return len(self.locList) + Parameters + ---------- + loc : str + Location within the Core. - def __add__(self, other): - """Returns all the blocks in both assemblies.""" - return self.locList + other.locList + Notes + ----- + This method does not validate that the location given is somehow "valid". + We are not doing any reverse lookups in the Reactor to prove that the type + or location is valid. Because this would require heavier computation, and + would add some chicken-and-the-egg problems into instantiating a new Reactor. - def append(self, obj): - if obj in self.locList: - # locations must be unique - raise RuntimeError("{0} is already in this zone: {1}".format(obj, self)) - self.locList.append(obj) + Returns + ------- + None + """ + assert isinstance(loc, str), "The location must be a str: {0}".format(loc) + self.locs.remove(loc) - def addAssemblyLocations(self, aList): + def addLocs(self, locs: List) -> None: """ - Adds the locations of a list of assemblies to a zone + Adds the locations to this Zone. Parameters ---------- - aList : list - List of assembly objects + items : list + List of str objects + """ + for loc in locs: + self.addLoc(loc) + + def removeLocs(self, locs: List) -> None: """ + Removes the locations from this Zone. - for a in aList: - self.append(a.getLocation()) + Parameters + ---------- + items : list + List of str objects + """ + for loc in locs: + self.removeLoc(loc) - # TODO: p0, p1 are only used in testing - def addRing(self, ring, p0=None, p1=None): + def addItem(self, item: Union[Assembly, Block]) -> None: """ - Adds a section of a ring (or a whole ring) to the zone + Adds the location of an Assembly or Block to a zone Parameters ---------- - ring : int - The ring to add - - p0 : int, optional - beginning position within ring. Default: None (full ring) - - p1 : int, optional - Ending position within ring. - - """ - grid = grids.HexGrid.fromPitch(1.0) - if p0 is None or p1 is None: - if self.symmetry == 3: - posList = grid.allPositionsInThird(ring) - elif self.symmetry == 1: - posList = range(1, grid.getPositionsInRing(ring) + 1) - else: - raise RuntimeError( - "Zones are not written to handle {0}-fold symmetry yet" - "".format(self.symmetry) - ) - else: - posList = range(p0, p1 + 1) + item : Assembly or Block + A single item with Core location (Assembly or Block) + """ + assert issubclass( + type(item), self.zoneType + ), "The item ({0}) but be have a type in: {1}".format(item, Zone.VALID_TYPES) + self.addLoc(item.getLocation()) - for pos in posList: - newLoc = grid.getLabel( - grid.getLocatorFromRingAndPos(ring, pos).getCompleteIndices()[:2] - ) - if newLoc not in self.locList: - self.append(newLoc) + def removeItem(self, item: Union[Assembly, Block]) -> None: + """ + Removes the location of an Assembly or Block from a zone + + Parameters + ---------- + item : Assembly or Block + A single item with Core location (Assembly or Block) + """ + assert issubclass( + type(item), self.zoneType + ), "The item ({0}) but be have a type in: {1}".format(item, Zone.VALID_TYPES) + self.removeLoc(item.getLocation()) + + def addItems(self, items: List) -> None: + """ + Adds the locations of a list of Assemblies or Blocks to a zone + + Parameters + ---------- + items : list + List of Assembly/Block objects + """ + for item in items: + self.addItem(item) + + def removeItems(self, items: List) -> None: + """ + Removes the locations of a list of Assemblies or Blocks from a zone + + Parameters + ---------- + items : list + List of Assembly/Block objects + """ + for item in items: + self.removeItem(item) class Zones: """Collection of Zone objects.""" - def __init__(self, core, cs): + def __init__(self): """Build a Zones object.""" - self.core = core - self.cs = cs self._zones = {} - self._names = [] @property - def names(self): - """Ordered names of contained zones.""" - return self._names + def names(self) -> List: + """Ordered names of contained zones. - def __getitem__(self, name): - """Access a zone by name.""" - return self._zones[name] + Returns + ------- + list + Alphabetical collection of Zone names + """ + return sorted(self._zones.keys()) + + def __contains__(self, name: str) -> bool: + return name in self._zones - def __delitem__(self, name): + def __delitem__(self, name: str) -> None: del self._zones[name] - # Now delete the corresponding zone name from the names list - try: - self._names.remove(name) - except ValueError as ee: - raise ValueError(ee) - def __iter__(self): + def __getitem__(self, name: str) -> Zone: + """Access a zone by name.""" + return self._zones[name] + + def __iter__(self) -> Iterator[Zone]: """Loop through the zones in order.""" - for nm in self._names: + for nm in sorted(self._zones.keys()): yield self._zones[nm] - def update(self, zones): - """Merge with another Zones.""" - for zone in zones: - self.add(zone) + def __len__(self) -> int: + """Return the number of Zone objects""" + return len(self._zones) + + def addZone(self, zone: Zone) -> None: + """Add a zone to the collection. - def add(self, zone): - """Add a zone to the collection.""" + Parameters + ---------- + zone: Zone + A new Zone to add to this collection. + + Returns + ------- + None + """ if zone.name in self._zones: raise ValueError( "Cannot add {} because a zone of that name already exists.".format( @@ -166,574 +251,181 @@ def add(self, zone): ) ) self._zones[zone.name] = zone - self._names.append(zone.name) - def removeZone(self, name): - """delete a zone by name.""" + def addZones(self, zones: List) -> None: + """ + Add multiple zones to the collection, + then validate that this Zones collection still make sense. + + Parameters + ---------- + zones: List (or Zones) + A multiple new Zone objects to add to this collection. + + Returns + ------- + None + """ + for zone in zones: + self.addZone(zone) + + self.checkDuplicates() + + def removeZone(self, name: str) -> None: + """Delete a zone by name + + Parameters + ---------- + name: str + Name of zone to remove + + Returns + ------- + None + """ del self[name] - def summary(self, zoneNames=None): - """Print out power distribution of fuel assemblies this/these zone.""" - if zoneNames is None: - zoneNames = self.names - msg = "Zone Summary" - if self.core.r is not None: - msg += " at Cycle {0}, timenode {1}".format( - self.core.r.p.cycle, self.core.r.p.timeNode - ) - runLog.info(msg) - totalPower = 0.0 - - for zoneName in sorted(zoneNames): - runLog.info("zone {0}".format(zoneName)) - massFlow = 0.0 - - # find the maximum power to flow in each zone - maxPower = -1.0 - maxPowerAssem = None - fuelAssemsInZone = self.core.getAssemblies(Flags.FUEL, zones=zoneName) - for a in fuelAssemsInZone: - flow = a.p.THmassFlowRate * a.getSymmetryFactor() - aPow = a.calcTotalParam("power", calcBasedOnFullObj=True) - if aPow > maxPower: - maxPower = aPow - maxPowerAssem = a - if not flow: - runLog.important( - "No TH data. Run with thermal hydraulics activated. " - "Zone report will have flow rate of zero", - single=True, - label="Cannot summarize ring zone T/H", - ) - # no TH for some reason - flow = 0.0 - massFlow += flow - - # Get power from the extracted power method. - slabPowList = self.getZoneAxialPowerDistribution(zoneName) - if not slabPowList or not fuelAssemsInZone: - runLog.important( - "No fuel assemblies exist in zone {0}".format(zoneName) - ) - return - - # loop over the last assembly to produce the final output. - z = 0.0 - totalZonePower = 0.0 - for zi, b in enumerate(a): - slabHeight = b.getHeight() - thisSlabPow = slabPowList[zi] - runLog.info( - " Power of {0:8.3f} cm slab at z={1:8.3f} (W): {2:12.5E}" - "".format(slabHeight, z, thisSlabPow) - ) - z += slabHeight - totalZonePower += thisSlabPow + def removeZones(self, names: List) -> None: + """ + Delete multiple zones by name - runLog.info(" Total Zone Power (Watts): {0:.3E}".format(totalZonePower)) - runLog.info( - " Zone Average Flow rate (kg/s): {0:.3f}" - "".format(massFlow / len(fuelAssemsInZone)) - ) - runLog.info( - " There are {0} assemblies in this zone" - "".format(len(fuelAssemsInZone) * self.core.powerMultiplier) - ) - if self.cs["doOrificedTH"] and maxPowerAssem[0].p.THmaxLifeTimePower: - # print on the maximum power to flow in each ring zone. This only has any meaning in - # an orficedTH case, no reason to use it otherwise. - runLog.info( - " The maximum power to flow is {} from assembly {} in this zone" - "".format( - maxPower / maxPowerAssem[0].p.THmaxLifeTimePower, maxPowerAssem - ) - ) - # runLog.info('Flow rate (m/s): {0:.3f}'.format()) - totalPower += totalZonePower - runLog.info( - "Total power of fuel in all zones is {0:.6E} Watts".format(totalPower) - ) - - def getZoneAxialPowerDistribution(self, zone): - """Return a list of powers in watts of the axial levels of zone.""" - slabPower = {} - zi = 0 - for a in self.core.getAssemblies(Flags.FUEL, zones=zone): - # Add up slab power and flow rates - for zi, b in enumerate(a): - slabPower[zi] = ( - slabPower.get(zi, 0.0) - + b.p.power * b.getSymmetryFactor() * self.core.powerMultiplier - ) + Parameters + ---------- + names: List (or names) + Multiple Zone names to remove from this collection. - # reorder the dictionary into a list, knowing that zi is stopped at the highest block - slabPowList = [] - for i in range(zi + 1): - try: - slabPowList.append(slabPower[i]) - except: - runLog.warning("slabPower {} zone {}".format(slabPower, zone)) - return slabPowList + Returns + ------- + None + """ + for name in names: + self.removeZone(name) + + def checkDuplicates(self) -> None: + """ + Validate that the the zones are mutually exclusive. + + That is, make sure that no item appears in more than one Zone. + + Returns + ------- + None + """ + allLocs = [] + for zone in self: + allLocs.extend(list(zone.locs)) + + # use set lotic to test for duplicates + if len(allLocs) == len(set(allLocs)): + # no duplicates + return + + # find duplicates by removing unique locs from the full list + for uniqueLoc in set(allLocs): + allLocs.remove(uniqueLoc) + + # there are duplicates, so raise an error + locs = sorted(set(allLocs)) + raise RuntimeError("Duplicate items found in Zones: {0}".format(locs)) - def getZoneLocations(self, zoneNames): + def getZoneLocations(self, zoneNames: List) -> Set: """ Get the location labels of a particular (or a few) zone(s). Parameters ---------- - zoneNames : str or list + zoneNames : str, or list the zone name or list of names Returns ------- - zoneLocs : list + zoneLocs : set List of location labels of this/these zone(s) - """ if not isinstance(zoneNames, list): zoneNames = [zoneNames] - zoneLocs = [] + zoneLocs = set() for zn in zoneNames: try: - thisZoneLocs = self[zn] + thisZoneLocs = set(self[zn]) except KeyError: runLog.error( "The zone {0} does not exist. Please define it.".format(zn) ) raise - zoneLocs.extend(thisZoneLocs) + zoneLocs.update(thisZoneLocs) return zoneLocs - def getRingZoneRings(self): - """ - Get rings in each ring zone as a list of lists. + def getAllLocations(self) -> Set: + """Return all locations across every Zone in this Zones object Returns ------- - ringZones : list - List of lists. Each entry is the ring numbers in a ring zone. - If there are no ring zones defined, returns a list of all rings. - """ - core = self.core - if not self.cs["ringZones"]: - # no ring zones defined. Return all rings. - return [range(1, core.getNumRings() + 1)] - - # ringZones are upper limits, defining ring zones from the center. so if they're - # [3, 5, 8, 90] then the ring zones are from 1 to 3, 4 to 5, 6 to 8, etc. - # AKA, the upper bound is included in that particular zone. - - # check validity of ringZones. Increasing order and integers. - ring0 = 0 - for i, ring in enumerate(self.cs["ringZones"]): - if ring <= ring0 or not isinstance(ring, int): - runLog.warning( - "ring zones {0} are invalid. Must be integers, increasing in order. " - "Can not return ring zone rings.".format(self.cs["ringZones"]) - ) - return - ring0 = ring - if i == len(self.cs["ringZones"]) - 1: - # this is the final ring zone - if ring < (core.getNumRings() + 1): - finalRing = core.getNumRings() - else: - finalRing = None - - # modify the ringZones to definitely include all assemblies - if finalRing: - runLog.debug( - "Modifying final ring zone definition to include all assemblies. New max: {0}".format( - finalRing - ), - single=True, - label="Modified ring zone definition", - ) - self.cs["ringZones"][-1] = finalRing - - # build the ringZone list - ring0 = 0 - ringZones = [] - for upperRing in self.cs["ringZones"]: - ringsInThisZone = range( - ring0 + 1, upperRing + 1 - ) # the rings in this ring zone as defined above. - - ringZones.append(ringsInThisZone) - ring0 = upperRing + set + A combination set of all locations, from every Zone + """ + locs = set() + for zone in self: + locs.update(self[zone.name]) - return ringZones + return locs - def findZoneAssemblyIsIn(self, a): + def findZoneItIsIn(self, a: Union[Assembly, Block]) -> Optional[Zone]: """ - Return the zone object that this assembly is in. + Return the zone object that this Assembly/Block is in. Parameters ---------- - a : assembly - The assembly to locate + a : Assembly or Block + The item to locate Returns ------- - zone : Zone object that the input assembly resides in. + zone : Zone object that the input item resides in. """ aLoc = a.getLocation() zoneFound = False for zone in self: - if aLoc in zone.locList: + if aLoc in zone.locs: zoneFound = True return zone + if not zoneFound: runLog.warning("Was not able to find which zone {} is in".format(a)) + return None -def buildZones(core, cs): - """ - Build/update the zones. + def sortZones(self, reverse=False) -> None: + """Sorts the Zone objects alphabetically. - Zones are groups of assembly locations used for various purposes such as defining SASSYS channels and - reactivity coefficients. - - The zoning option is determined by the ``zoningStrategy`` setting. - """ - zones = Zones(core, cs) - zoneOption = cs[globalSettings.CONF_ZONING_STRATEGY] - if "byRingZone" in zoneOption: - zones.update(_buildRingZoneZones(core, cs)) - elif "manual" in zoneOption: - zones.update(_buildManualZones(core, cs)) - elif "byFuelType" in zoneOption: - zones.update(_buildAssemTypeZones(core, cs, Flags.FUEL)) - elif "byOrifice" in zoneOption: - zones.update(_buildZonesByOrifice(core, cs)) - elif "everyFA" in zoneOption: - zones.update(_buildZonesforEachFA(core, cs)) - else: - raise ValueError( - "Invalid `zoningStrategy` grouping option {}".format(zoneOption) - ) - - if cs["createAssemblyTypeZones"]: - zones.update(_buildAssemTypeZones(core, cs, Flags.FUEL)) - - # Summarize the zone information - headers = [ - "Zone\nNumber", - "\nName", - "\nAssemblies", - "\nLocations", - "Symmetry\nFactor", - "Hot\nZone", - ] - zoneSummaryData = [] - for zi, zone in enumerate(zones, 1): - assemLocations = utils.createFormattedStrWithDelimiter( - zone, maxNumberOfValuesBeforeDelimiter=6 - ) - zoneSummaryData.append( - (zi, zone.name, len(zone), assemLocations, zone.symmetry, zone.hotZone) - ) - runLog.info( - "Assembly zone definitions:\n" - + tabulate.tabulate( - tabular_data=zoneSummaryData, headers=headers, tablefmt="armi" - ), - single=True, - ) - return zones - - -def _buildZonesByOrifice(core, cs): - """ - Group fuel assemblies by orifice zones. - - Each zone will contain all FAs with in same orifice coefficients. - - Return - ------ - faZonesForSafety : dict - dictionary of zone name and list of FAs name in that zone - - Notes - ----- - Orifice coefficients are determined by a combination of the - ``THorificeZone`` and ``nozzleType`` parameters. Each combination of - ``THorificeZone`` and ``nozzleType`` is treated as a unique ``Zone``. - """ - runLog.extra("Building Zones by Orifice zone") - orificeZones = Zones(core, cs) - - # first get all different orifice setting zones - for a in core.getAssemblies(): - orificeSetting = "zone" + str(a.p.THorificeZone) + "-" + str(a.p.nozzleType) - if orificeSetting not in orificeZones.names: - orificeZones.add(Zone(orificeSetting)) - - # now put FAs of the same orifice zone in to one channel - for a in core.getAssemblies(): - orificeSetting = "zone" + str(a.p.THorificeZone) + "-" + str(a.p.nozzleType) - orificeZones[orificeSetting].append(a.getLocation()) - - return orificeZones - - -def _buildManualZones(core, cs): - runLog.extra( - "Building Zones by manual zone definitions in `zoneDefinitions` setting" - ) - stripper = lambda s: s.strip() - zones = Zones(core, cs) - # read input zones, which are special strings like this: "zoneName: loc1,loc2,loc3,loc4,..." - for zoneString in cs["zoneDefinitions"]: - zoneName, zoneLocs = zoneString.split(":") - zoneLocs = zoneLocs.split(",") - zone = Zone(zoneName.strip()) - zone.extend(map(stripper, zoneLocs)) - zones.add(zone) - return zones - - -def _buildZonesforEachFA(core, cs): - """ - Split every fuel assembly in to a zones for safety analysis. - - Returns - ------ - reactorChannelZones : dict - dictionary of each channel as a zone - """ - runLog.extra("Creating zones for `everyFA`") - reactorChannelZones = Zones(core, cs) - for i, a in enumerate(core.getAssembliesOfType(Flags.FUEL)): - reactorChannelZones.add(Zone("channel " + str(int(i) + 1), [a.getLocation()])) - return reactorChannelZones - - -def _buildRingZoneZones(core, cs): - """ - Build zones based on annular rings. - - Notes - ----- - Originally, there were ringZones. These were defined by a user-input list of - upper bound rings and the zones were just annular regions between rings. - They were used to compute reactivity coefficients and whatnot. Then - one day, it became clear that more general zones were required. To support - old code that used ringZones, this code produces modern zones out - of the ringZone input. - - It creates zones called ring-0, ring-1, etc. for each zone. - - If no zones are defined, one big ringzone comprising the whole core will be built. - - See Also - -------- - getRingZoneAssemblies : gets assemblies in a ringzone - getRingZoneRings : computes rings in a ringzone - getAssembly : accesses assemblies in a zone the new way. - - """ - zones = Zones(core, cs) - zoneRings = zones.getRingZoneRings() - for ringZone, rings in enumerate(zoneRings, 1): - zoneName = "ring-{0}".format(ringZone) - zone = Zone(zoneName) - for ring in rings: - zone.addRing(ring) - zones.add(zone) - return zones - - -def _buildAssemTypeZones(core, cs, typeSpec=None): - """ - Builds zones based on assembly type labels. - - Notes - ----- - Creates a zone for each assembly type. All locations occupied by - a certain type of assembly become a new zone based on that type. - - For example, after this call, all 'feed fuel a' assemblies will reside - in the 'feed fuel a' zone. - - Useful for building static zones based on some BOL core layout. - - Parameters - ---------- - core : Core - The core + Parameters + ---------- + reverse : bool, optional + Whether to sort in reverse order, by default False + """ - typeSpec : Flags or list of Flags, optional - Assembly types to consider (e.g. Flags.FUEL) + self._zones = dict(sorted(self._zones.items(), reverse=reverse)) - Return - ------ - zones : Zones - """ - zones = Zones(core, cs) - for a in core.getAssemblies(typeSpec): - zoneName = a.getType() - try: - zone = zones[zoneName] - except KeyError: - zone = Zone(zoneName) - zones.add(zone) - zone.append(a.getLocation()) - return zones - - -def splitZones(core, cs, zones): - """ - Split the existing zone style into smaller zones via number of blocks and assembly type. - - Parameters - ---------- - core: Core - The Core object to which the Zones belong - cs: Case settings - The case settings for the run - zones: Zones - The Zones to split - - Returns - ------- - zones: Zones - - Notes - ----- - Zones are collections of locations grouped by some user input. - Calling this method transforms a collection of zones into another collection of zones - further broken down by assembly type and number of blocks. - Each new zone only contains assemblies of the same type and block count. - any other arbitrary grouping method. A subZone is a further breakdown of a zone. - - Examples - -------- - If the original zone was ringZone3 and ringZone3 contained three assemblies, one of them being a burner with - nine blocks, one being a burner with 8 blocks and one control assembly with 3 blocks three splitZones - would be produced and ringZone3 would be deleted. The the zones would be named ringZone3_Burner_8, - ringZone3_Burner_9, ringZone3_Control_3. - """ - - if not cs["splitZones"]: - return zones - - # We should make this unchangeable - originalZoneNames = tuple([zone.name for zone in zones]) - splitZoneToOriginalZonesMap = {} - subZoneNameSeparator = "-" - for name in originalZoneNames: - - assems = core.getAssemblies(zones=name) - for a in assems: - # figure out what type of assembly we have - flavor = a.getType().replace(" ", subZoneNameSeparator) # replace spaces - subZoneName = ( - name - + subZoneNameSeparator - + flavor - + subZoneNameSeparator - + str(len(a)) - ) - splitZoneToOriginalZonesMap[subZoneName] = name - try: - zone = zones[subZoneName] - except KeyError: - zone = Zone(subZoneName) - zones.add(zone) - zone.append(a.getLocation()) - # now remove the original non separated zone - zones.removeZone(name) - # Summarize the zone information - headers = [ - "Zone\nNumber", - "\nName", - "Original\nName", - "\nAssemblies", - "\nLocations", - "Symmetry\nFactor", - "Hot\nZone", - ] - zoneSummaryData = [] - for zi, zone in enumerate(zones, 1): - assemLocations = utils.createFormattedStrWithDelimiter( - zone, maxNumberOfValuesBeforeDelimiter=6 - ) - zoneSummaryData.append( - ( - zi, - zone.name, - splitZoneToOriginalZonesMap[zone.name], - len(zone), - assemLocations, - zone.symmetry, - zone.hotZone, - ) - ) - runLog.info( - "The setting `splitZones` is enabled. Building subzones from core zones:\n" - + tabulate.tabulate( - tabular_data=zoneSummaryData, headers=headers, tablefmt="armi" - ), - single=True, - ) - return zones - - -def createHotZones(core, zones): - """ - Make new zones from assemblies with the max power to flow ratio in a zone. - - Parameters - ---------- - core : Core - The core object - zones: Zones - Zones - - Returns - ------- - zones: zones object - - Notes - ----- - This method determines which assembly has the highest power to flow ratio in each zone. - This method then removes that assembly from its original zone and places it in a new zone. - """ - originalZoneNames = tuple([zone.name for zone in zones]) - for name in originalZoneNames: - assems = core.getAssemblies(zones=name) - # don't create hot zones from zones with only one assembly - if len(assems) > 1: - maxPOverF = 0.0 - hotLocation = "" - for a in assems: - # Check to make sure power and TH calcs were performed for this zon - try: - pOverF = a.calcTotalParam("power") / a[-1].p.THmassFlowRate - loc = a.getLocation() - if pOverF >= maxPOverF: - maxPOverF = pOverF - hotLocation = loc - except ZeroDivisionError: - runLog.warning( - "{} has no flow. Skipping {} in hot channel generation.".format( - a, name - ) - ) - break - # If we were able to identify the hot location, create a hot zone. - if hotLocation: - zones[name].locList.remove(hotLocation) - hotZoneName = "hot_" + name - hotZone = Zone(hotZoneName) - zones.add(hotZone) - zones[hotZoneName].append(hotLocation) - zones[hotZoneName].hotZone = True - zones[hotZoneName].hostZone = name - # Now remove the original zone if its does not store any locations anymore. - if len(zones[name]) == 0: - zones.removeZone(name) - return zones + def summary(self) -> None: + """ + Summarize the zone defintions clearly, and in a way that can be copy/pasted + back into a settings file under "zoneDefinitions", if the user wants to + manually re-use these zones later. + + Examples + -------- + zoneDefinitions: + - ring-1: 001-001 + - ring-2: 002-001, 002-002 + - ring-3: 003-001, 003-002, 003-003 + """ + # log a quick header + runLog.info("zoneDefinitions:") + + # log the zone definitions in a way that can be copy/pasted back into a settings file + for name in sorted(self._zones.keys()): + locs = sorted(self._zones[name].locs) + line = "- {0}: ".format(name) + ", ".join(locs) + runLog.info(line) diff --git a/armi/settings/fwSettings/globalSettings.py b/armi/settings/fwSettings/globalSettings.py index e972f9211..e7aaf97fb 100644 --- a/armi/settings/fwSettings/globalSettings.py +++ b/armi/settings/fwSettings/globalSettings.py @@ -34,7 +34,6 @@ # Framework settings CONF_NUM_PROCESSORS = "numProcessors" CONF_BURN_CHAIN_FILE_NAME = "burnChainFileName" -CONF_ZONING_STRATEGY = "zoningStrategy" CONF_AXIAL_MESH_REFINEMENT_FACTOR = "axialMeshRefinementFactor" CONF_AUTOMATIC_VARIABLE_MESH = "automaticVariableMesh" CONF_TRACE = "trace" @@ -57,7 +56,6 @@ CONF_COMMENT = "comment" CONF_COPY_FILES_FROM = "copyFilesFrom" CONF_COPY_FILES_TO = "copyFilesTo" -CONF_CREATE_ASSEMBLY_TYPE_ZONES = "createAssemblyTypeZones" CONF_DEBUG = "debug" CONF_DEBUG_MEM = "debugMem" CONF_DEBUG_MEM_SIZE = "debugMemSize" @@ -94,8 +92,6 @@ CONF_VERBOSITY = "verbosity" CONF_ZONE_DEFINITIONS = "zoneDefinitions" CONF_ACCEPTABLE_BLOCK_AREA_ERROR = "acceptableBlockAreaError" -CONF_RING_ZONES = "ringZones" -CONF_SPLIT_ZONES = "splitZones" CONF_FLUX_RECON = "fluxRecon" # strange coupling in fuel handlers CONF_INDEPENDENT_VARIABLES = "independentVariables" CONF_HCF_CORETYPE = "HCFcoretype" @@ -138,17 +134,6 @@ def defineSettings() -> List[setting.Setting]: label="Burn Chain File", description="Path to YAML file that has the depletion chain defined in it", ), - setting.Setting( - CONF_ZONING_STRATEGY, - default="byRingZone", - label="Automatic core zone creation strategy", - description="Channel Grouping Options for Safety;" - "everyFA: every FA is its own channel, " - "byRingZone: based on ringzones, " - "byFuelType: based on fuel type, " - "Manual: you must specify 'zoneDefinitions' setting", - options=["byRingZone", "byOrifice", "byFuelType", "everyFA", "manual"], - ), setting.Setting( CONF_AXIAL_MESH_REFINEMENT_FACTOR, default=1, @@ -409,12 +394,6 @@ def defineSettings() -> List[setting.Setting]: setting.Setting( CONF_COPY_FILES_TO, default=[], label="None", description="None" ), - setting.Setting( - CONF_CREATE_ASSEMBLY_TYPE_ZONES, - default=False, - label="Create Fuel Zones Automatically", - description="Let ARMI create zones based on fuel type automatically ", - ), setting.Setting( CONF_DEBUG, default=False, label="Python Debug Mode", description="None" ), @@ -705,9 +684,9 @@ def defineSettings() -> List[setting.Setting]: CONF_ZONE_DEFINITIONS, default=[], label="Zone Definitions", - description="Definitions of zones as lists of assembly locations (e.g. " - "'zoneName: loc1, loc2, loc3') . Zones are groups of assemblies used by " - "various summary and calculation routines.", + description="Manual definitions of zones as lists of assembly locations " + "(e.g. 'zoneName: loc1, loc2, loc3') . Zones are groups of assemblies used " + "by various summary and calculation routines.", ), setting.Setting( CONF_ACCEPTABLE_BLOCK_AREA_ERROR, @@ -718,21 +697,6 @@ def defineSettings() -> List[setting.Setting]: "consistency check", schema=vol.All(vol.Coerce(float), vol.Range(min=0, min_included=False)), ), - setting.Setting( - CONF_RING_ZONES, - default=[], - label="Ring Zones", - description="Define zones by concentric radial rings. Each zone will get " - "independent reactivity coefficients.", - schema=vol.Schema([int]), - ), - setting.Setting( - CONF_SPLIT_ZONES, - default=True, - label="Split Zones", - description="Automatically split defined zones further based on number of " - "blocks and assembly types", - ), setting.Setting( CONF_INDEPENDENT_VARIABLES, default=[], diff --git a/armi/settings/fwSettings/reportSettings.py b/armi/settings/fwSettings/reportSettings.py index b8777e409..c2ed49942 100644 --- a/armi/settings/fwSettings/reportSettings.py +++ b/armi/settings/fwSettings/reportSettings.py @@ -21,7 +21,6 @@ CONF_GEN_REPORTS = "genReports" CONF_ASSEM_POW_SUMMARY = "assemPowSummary" -CONF_ZONE_FLOW_SUMMARY = "zoneFlowSummary" CONF_SUMMARIZE_ASSEM_DESIGN = "summarizeAssemDesign" CONF_TIMELINE_INCLUSION_CUTOFF = "timelineInclusionCutoff" @@ -43,12 +42,6 @@ def defineSettings(): description="Print a summary of how much power is in each assembly " "type at every timenode", ), - setting.Setting( - CONF_ZONE_FLOW_SUMMARY, - default=True, - label="Zone Flow Summary", - description="Print flow and power edits for peak and average assemblies", - ), setting.Setting( CONF_SUMMARIZE_ASSEM_DESIGN, default=True, diff --git a/armi/tests/armiRun.yaml b/armi/tests/armiRun.yaml index fe7cb4db1..999463122 100644 --- a/armi/tests/armiRun.yaml +++ b/armi/tests/armiRun.yaml @@ -59,4 +59,3 @@ settings: targetK: 1.002 transientForSensitivity: '' verbosity: extra - zoneFlowSummary: false diff --git a/armi/tests/detailedAxialExpansion/armiRun.yaml b/armi/tests/detailedAxialExpansion/armiRun.yaml index 955702d09..851d04ebf 100644 --- a/armi/tests/detailedAxialExpansion/armiRun.yaml +++ b/armi/tests/detailedAxialExpansion/armiRun.yaml @@ -44,4 +44,3 @@ settings: summarizeAssemDesign: false targetK: 1.002 verbosity: extra - zoneFlowSummary: false diff --git a/armi/tests/refTestCartesian.yaml b/armi/tests/refTestCartesian.yaml index 07f5e241e..47683a0f1 100644 --- a/armi/tests/refTestCartesian.yaml +++ b/armi/tests/refTestCartesian.yaml @@ -31,4 +31,3 @@ settings: summarizeAssemDesign: false targetK: 1.002 transientForSensitivity: '' - zoneFlowSummary: false diff --git a/armi/tests/test_user_plugins.py b/armi/tests/test_user_plugins.py index 5573da9bf..61545c25c 100644 --- a/armi/tests/test_user_plugins.py +++ b/armi/tests/test_user_plugins.py @@ -11,22 +11,17 @@ # 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 the UserPlugin class.""" # pylint: disable=missing-function-docstring,missing-class-docstring,protected-access,invalid-name,no-self-use,no-method-argument,import-outside-toplevel import copy import os import unittest -import pluggy - from armi import context from armi import getApp -from armi import getPluginManagerOrFail from armi import interfaces from armi import plugins from armi import utils -from armi.bookkeeping.db.database3 import DatabaseInterface from armi.reactor.flags import Flags from armi.reactor.tests import test_reactors from armi.settings import caseSettings @@ -120,7 +115,7 @@ def interactEveryNode(self, cycle, node): class UserPluginWithInterface(plugins.UserPlugin): - """A little test UserPlugin, just to show how to add an Inteface through a UserPlugin""" + """A little test UserPlugin, just to show how to add an Interface through a UserPlugin""" @staticmethod @plugins.HOOKIMPL diff --git a/armi/tests/zpprTest.yaml b/armi/tests/zpprTest.yaml index 9ae746a09..4619fd906 100644 --- a/armi/tests/zpprTest.yaml +++ b/armi/tests/zpprTest.yaml @@ -44,4 +44,3 @@ settings: Tout: 20.0 verbosity: extra xsBlockRepresentation: ComponentAverage1DSlab - zoneFlowSummary: false diff --git a/doc/release/0.2.rst b/doc/release/0.2.rst index 68da896aa..8e6ab8741 100644 --- a/doc/release/0.2.rst +++ b/doc/release/0.2.rst @@ -2,18 +2,29 @@ ARMI v0.2 Release Notes ======================= -ARMI v0.2.5 +ARMI v0.2.6 =========== Release Date: TBD What's new in ARMI ------------------ +#. TBD +Bug fixes +--------- #. TBD + + +ARMI v0.2.5 +=========== +Release Date: 2022-10-24 + +What's new in ARMI +------------------ #. Cleanup of stale ``coveragerc`` file (`PR#923 `_) #. Added `medium` writer style option to ``SettingsWriter``. Added it as arg to modify CLI (`PR#924 `_), and to clone CLI (`PR#932 `_). #. Update the EntryPoint class to provide user feedback on required positional arguments (`PR#922 `_) -#. Allow for the loading of a reactor from a db without necessarily setting the associated cs as the master +#. Overhaul ``reactor.zones`` tooling and remove application-specific zoning logic (`PR#943 `_) Bug fixes ---------