diff --git a/armi/bookkeeping/db/databaseInterface.py b/armi/bookkeeping/db/databaseInterface.py index 752bad27e..b8726f11b 100644 --- a/armi/bookkeeping/db/databaseInterface.py +++ b/armi/bookkeeping/db/databaseInterface.py @@ -150,7 +150,7 @@ def interactEveryNode(self, cycle, node): - if tight coupling is enabled, the DB will be written in operator.py::Operator::_timeNodeLoop via writeDBEveryNode """ - if self.o.cs["numCoupledIterations"]: + if self.o.cs["tightCoupling"]: # h5 cant handle overwriting so we skip here and write once the tight coupling loop has completed return self.writeDBEveryNode(cycle, node) diff --git a/armi/bookkeeping/db/tests/test_databaseInterface.py b/armi/bookkeeping/db/tests/test_databaseInterface.py index a4be7ce4b..20ce65b8d 100644 --- a/armi/bookkeeping/db/tests/test_databaseInterface.py +++ b/armi/bookkeeping/db/tests/test_databaseInterface.py @@ -99,8 +99,8 @@ def tearDown(self): self.td.__exit__(None, None, None) def test_interactEveryNodeReturn(self): - """test that the DB is NOT written to if cs["numCoupledIterations"] != 0""" - self.o.cs["numCoupledIterations"] = 1 + """test that the DB is NOT written to if cs["tightCoupling"] = True""" + self.o.cs["tightCoupling"] = True self.dbi.interactEveryNode(0, 0) self.assertFalse(self.dbi.database.hasTimeStep(0, 0)) @@ -118,11 +118,11 @@ def test_distributable(self): self.dbi.interactDistributeState() self.assertEqual(self.dbi.distributable(), 4) - def test_timeNodeLoop_numCoupledIterations(self): + def test_timeNodeLoop_tightCoupling(self): """test that database is written out after the coupling loop has completed""" # clear out interfaces (no need to run physics) but leave database self.o.interfaces = [self.dbi] - self.o.cs["numCoupledIterations"] = 1 + self.o.cs["tightCoupling"] = True self.assertFalse(self.dbi._db.hasTimeStep(0, 0)) self.o._timeNodeLoop(0, 0) self.assertTrue(self.dbi._db.hasTimeStep(0, 0)) diff --git a/armi/bookkeeping/report/newReportUtils.py b/armi/bookkeeping/report/newReportUtils.py index 8894acb86..de50d5ff7 100644 --- a/armi/bookkeeping/report/newReportUtils.py +++ b/armi/bookkeeping/report/newReportUtils.py @@ -294,7 +294,7 @@ def _setGeneralSimulationData(core, cs, coreDesignTable): coreDesignTable.addRow([" ", ""]) coreDesignTable.addRow(["Full Core Model", "{}".format(core.isFullCore)]) coreDesignTable.addRow( - ["Loose Physics Coupling Enabled", "{}".format(bool(cs["looseCoupling"]))] + ["Tight Physics Coupling Enabled", "{}".format(bool(cs["tightCoupling"]))] ) coreDesignTable.addRow(["Lattice Physics Enabled for", "{}".format(cs["genXS"])]) coreDesignTable.addRow( diff --git a/armi/bookkeeping/report/reportingUtils.py b/armi/bookkeeping/report/reportingUtils.py index 8e678a239..6233b730f 100644 --- a/armi/bookkeeping/report/reportingUtils.py +++ b/armi/bookkeeping/report/reportingUtils.py @@ -40,7 +40,6 @@ from armi.utils import plotting from armi.utils import textProcessors from armi.utils import units -from armi.utils.mathematics import findClosest # Set to prevent the image and text from being too small to read. @@ -237,6 +236,15 @@ def getInterfaceStackSummary(o): return text +def writeTightCouplingConvergenceSummary(convergenceSummary): + runLog.info("Tight Coupling Convergence Summary: Norm Type = Inf") + runLog.info( + tabulate.tabulate( + convergenceSummary, headers="keys", showindex=True, tablefmt="armi" + ) + ) + + def writeAssemblyMassSummary(r): r"""Print out things like Assembly weights to the runLog. @@ -765,8 +773,8 @@ def _setGeneralSimulationData(core, cs, coreDesignTable): "Full Core Model", "{}".format(core.isFullCore), coreDesignTable, report.DESIGN ) report.setData( - "Loose Physics Coupling Enabled", - "{}".format(bool(cs["looseCoupling"])), + "Tight Physics Coupling Enabled", + "{}".format(bool(cs["tightCoupling"])), coreDesignTable, report.DESIGN, ) diff --git a/armi/interfaces.py b/armi/interfaces.py index 07f649744..bd0374597 100644 --- a/armi/interfaces.py +++ b/armi/interfaces.py @@ -31,9 +31,13 @@ from typing import List from typing import Dict +import numpy +from numpy.linalg import norm + from armi import getPluginManagerOrFail, settings, utils from armi.utils import textProcessors from armi.reactor import parameters +from armi import runLog class STACK_ORDER: # pylint: disable=invalid-name, too-few-public-methods @@ -77,6 +81,156 @@ class STACK_ORDER: # pylint: disable=invalid-name, too-few-public-methods POSTPROCESSING = BOOKKEEPING + 1 +class TightCoupler: + """ + Data structure that defines tight coupling attributes that are implemented + within an Interface and called upon when ``interactCoupled`` is called. + + Parameters + ---------- + param : str + The name of a parameter defined in the ARMI Reactor model. + + tolerance : float + Defines the allowable error, epsilon, between the current previous + parameter value(s) to determine if the selected coupling parameter has + been converged. + + maxIters : int + Maximum number of tight coupling iterations allowed + """ + + _SUPPORTED_TYPES = [float, int, list, numpy.ndarray] + + def __init__(self, param, tolerance, maxIters): + self.parameter = param + self.tolerance = tolerance + self.maxIters = maxIters + self._numIters = 0 + self._previousIterationValue = None + self.eps = numpy.inf + + def __repr__(self): + return f"<{self.__class__.__name__}, Parameter: {self.parameter}, Convergence Criteria: {self.tolerance}, Maximum Coupled Iterations: {self.maxIters}>" + + def storePreviousIterationValue(self, val: _SUPPORTED_TYPES): + """ + Stores the previous iteration value of the given parameter. + + Parameters + ---------- + val : _SUPPORTED_TYPES + the value to store. Is commonly equal to interface.getTightCouplingValue() + + Raises + ------ + TypeError + Checks the type of the val against ``_SUPPORTED_TYPES`` before storing. + If invalid, a TypeError is raised. + """ + if type(val) not in self._SUPPORTED_TYPES: + raise TypeError( + f"{val} supplied has type {type(val)} which is not supported in {self}. " + f"Supported types: {self._SUPPORTED_TYPES}" + ) + self._previousIterationValue = val + + def isConverged(self, val: _SUPPORTED_TYPES) -> bool: + """ + Return boolean indicating if the convergence criteria between the current and previous iteration values are met. + + Parameters + ---------- + val : _SUPPORTED_TYPES + the most recent value for computing convergence critera. Is commonly equal to interface.getTightCouplingValue() + + Returns + ------- + boolean + True (False) interface is (not) converged + + Notes + ----- + - On convergence, this class is automatically reset to its initial condition to avoid retaining + or holding a stale state. Calling this method will increment a counter that when exceeded will + clear the state. A warning will be reported if the state is cleared prior to the convergence + criteria being met. + - For computing convergence of arrays, only up to 2D is allowed. 3D arrays would arise from considering + component level parameters. However, converging on component level parameters is not supported at this time. + + Raises + ------ + ValueError + If the previous iteration value has not been assigned. The ``storePreviousIterationValue`` method + must be called first. + RuntimeError + Only support calculating norms for up to 2D arrays. + """ + if self._previousIterationValue is None: + raise ValueError( + f"Cannot check convergence of {self} with no previous iteration value set. " + f"Set using `storePreviousIterationValue` first." + ) + + previous = self._previousIterationValue + + # calculate convergence of val and previous + if isinstance(val, (int, float)): + self.eps = abs(val - previous) + else: + dim = self.getListDimension(val) + if dim == 1: # 1D array + self.eps = norm(numpy.subtract(val, previous), ord=2) + elif dim == 2: # 2D array + epsVec = [] + for old, new in zip(previous, val): + epsVec.append(norm(numpy.subtract(old, new), ord=2)) + self.eps = norm(epsVec, ord=numpy.inf) + else: + raise RuntimeError( + "Currently only support up to 2D arrays for calculating convergence of arrays." + ) + + # Check if convergence is satisfied. If so, or if reached max number of iters, then + # reset the number of iterations + converged = self.eps < self.tolerance + if converged: + self._numIters = 0 + else: + self._numIters += 1 + if self._numIters == self.maxIters: + runLog.warning( + f"Maximum number of iterations for {self.parameter} reached without convergence!" + f"Prescribed convergence criteria is {self.tolerance}." + ) + self._numIters = 0 + + return converged + + @staticmethod + def getListDimension(listToCheck: list, dim: int = 1) -> int: + """return the dimension of a python list + + Parameters + ---------- + listToCheck: list + the supplied python list to have its dimension returned + dim: int, optional + the dimension of the list + + Returns + ------- + dim, int + the dimension of the list. Typically 1, 2, or 3 but can be arbitrary order, N. + """ + for v in listToCheck: + if isinstance(v, list): + dim += 1 + dim = TightCoupler.getListDimension(v, dim) + break + return dim + + class Interface: """ The eponymous Interface between the ARMI Reactor model and modules that operate upon it. @@ -153,6 +307,7 @@ def __init__(self, r, cs): self.cs = settings.getMasterCs() if cs is None else cs self.r = r self.o = r.o if r else None + self.coupler = _setTightCouplerByInterfaceFunction(self, cs) def __repr__(self): return "".format(self.name) @@ -307,7 +462,11 @@ def interactEveryNode(self, cycle, node): pass def interactCoupled(self, iteration): - """Called repeatedly at each time node/subcycle when tight physics couping is active.""" + """Called repeatedly at each time node/subcycle when tight physics coupling is active.""" + pass + + def getTightCouplingValue(self): + """Abstract method to retrieve the value in which tight coupling will converge on.""" pass def interactError(self): @@ -528,6 +687,35 @@ def apply(self, reactor): raise NotImplementedError() +def _setTightCouplerByInterfaceFunction(interfaceClass, cs): + """ + Return an instance of a ``TightCoupler`` class or ``None``. + + Parameters + ---------- + interfaceClass : Interface + Interface class that a ``TightCoupler`` object will be added to. + + cs : Settings + Case settings that are parsed to determine if tight coupling is enabled + globally and if both a target parameter and convergence criteria defined. + """ + # No tight coupling if there is no function for the Interface defined. + if interfaceClass.function is None: + return None + + if not cs["tightCoupling"] or ( + interfaceClass.function not in cs["tightCouplingSettings"] + ): + return None + + parameter = cs["tightCouplingSettings"][interfaceClass.function]["parameter"] + tolerance = cs["tightCouplingSettings"][interfaceClass.function]["convergence"] + maxIters = cs["tightCouplingMaxNumIters"] + + return TightCoupler(parameter, tolerance, maxIters) + + def getActiveInterfaceInfo(cs): """ Return a list containing information for all of the Interface classes that are present. diff --git a/armi/operators/operator.py b/armi/operators/operator.py index 864e047da..166c821bc 100644 --- a/armi/operators/operator.py +++ b/armi/operators/operator.py @@ -30,6 +30,7 @@ import re import shutil import time +import collections from armi import context from armi import interfaces @@ -133,6 +134,7 @@ def __init__(self, cs): self._maxBurnSteps = None self._powerFractions = None self._availabilityFactors = None + self._convergenceSummary = None # Create the welcome headers for the case (case, input, machine, and some basic reactor information) reportingUtils.writeWelcomeHeaders(self, cs) @@ -376,10 +378,13 @@ def _timeNodeLoop(self, cycle, timeNode): self.r.p.timeNode = timeNode self.interactAllEveryNode(cycle, timeNode) # perform tight coupling if requested - if self.cs["numCoupledIterations"]: - for coupledIteration in range(self.cs["numCoupledIterations"]): + if self.couplingIsActive(): + self._convergenceSummary = collections.defaultdict(list) + for coupledIteration in range(self.cs["tightCouplingMaxNumIters"]): self.r.core.p.coupledIteration = coupledIteration + 1 - self.interactAllCoupled(coupledIteration) + converged = self.interactAllCoupled(coupledIteration) + if converged: + break # database has not yet been written, so we need to write it. dbi = self.getInterface("database") dbi.writeDBEveryNode(cycle, timeNode) @@ -616,8 +621,42 @@ def interactAllCoupled(self, coupledIteration): ARMI supports tight and loose coupling. """ activeInterfaces = [ii for ii in self.interfaces if ii.enabled()] + + # Store the previous iteration values before calling interactAllCoupled + # for each interface. + for interface in activeInterfaces: + if interface.coupler is not None: + interface.coupler.storePreviousIterationValue( + interface.getTightCouplingValue() + ) + self._interactAll("Coupled", activeInterfaces, coupledIteration) + return self._checkTightCouplingConvergence(activeInterfaces) + + def _checkTightCouplingConvergence(self, activeInterfaces: list): + """check if interfaces are converged + + Parameters + ---------- + activeInterfaces : list + the list of active interfaces on the operator + + Notes + ----- + This is split off from self.interactAllCoupled to accomodate testing""" + # Summarize the coupled results and the convergence status. + converged = [] + for interface in activeInterfaces: + coupler = interface.coupler + if coupler is not None: + key = f"{interface.name}: {coupler.parameter}" + converged.append(coupler.isConverged(interface.getTightCouplingValue())) + self._convergenceSummary[key].append(coupler.eps) + + reportingUtils.writeTightCouplingConvergenceSummary(self._convergenceSummary) + return all(converged) + def interactAllError(self): """Interact when an error is raised by any other interface. Provides a wrap-up option on the way to a crash.""" for i in self.interfaces: @@ -647,6 +686,7 @@ def createInterfaces(self): armi.interfaces.getActiveInterfaceInfo : Collects the interface classes from relevant packages. """ + runLog.header("=========== Creating Interfaces ===========") interfaceList = interfaces.getActiveInterfaceInfo(self.cs) for klass, kwargs in interfaceList: @@ -1063,4 +1103,4 @@ def setStateToDefault(cs): def couplingIsActive(self): """True if any kind of physics coupling is active.""" - return self.cs["looseCoupling"] or self.cs["numCoupledIterations"] > 0 + return self.cs["tightCoupling"] diff --git a/armi/operators/settingsValidation.py b/armi/operators/settingsValidation.py index b3cf099e3..805f6e32d 100644 --- a/armi/operators/settingsValidation.py +++ b/armi/operators/settingsValidation.py @@ -476,22 +476,22 @@ def _willBeCopiedFrom(fName): ) self.addQuery( - lambda: not self.cs["looseCoupling"] - and self.cs["numCoupledIterations"] > 0, - "You have {0} coupled iterations selected, but have not activated loose coupling.".format( - self.cs["numCoupledIterations"] + lambda: ( + not self.cs["tightCoupling"] + and self.cs["tightCouplingMaxNumIters"] != 4 ), - "Set looseCoupling to True?", - lambda: self._assignCS("looseCoupling", True), + "You've requested a non default number of tight coupling iterations but left tightCoupling: False." + "Do you want to set tightCoupling to True?", + "", + lambda: self._assignCS("tightCoupling", True), ) self.addQuery( - lambda: self.cs["numCoupledIterations"] > 0, - "You have {0} coupling iterations selected.".format( - self.cs["numCoupledIterations"] - ), - "1 coupling iteration doubles run time (2 triples, etc). Do you want to use 0 instead? ", - lambda: self._assignCS("numCoupledIterations", 0), + lambda: (not self.cs["tightCoupling"] and self.cs["tightCouplingSettings"]), + "You've requested non default tight coupling settings but tightCoupling: False." + "Do you want to set tightCoupling to True?", + "", + lambda: self._assignCS("tightCoupling", True), ) self.addQuery( diff --git a/armi/operators/tests/test_operators.py b/armi/operators/tests/test_operators.py index f9a1bc748..23a371b04 100644 --- a/armi/operators/tests/test_operators.py +++ b/armi/operators/tests/test_operators.py @@ -16,6 +16,7 @@ # pylint: disable=missing-function-docstring,missing-class-docstring,protected-access,invalid-name,no-method-argument,import-outside-toplevel import unittest +import collections from armi import settings from armi.interfaces import Interface @@ -23,6 +24,9 @@ from armi.reactor.tests import test_reactors from armi.settings.caseSettings import Settings from armi.utils.directoryChangers import TemporaryDirectoryChanger +from armi.physics.neutronics.globalFlux.globalFluxInterface import ( + GlobalFluxInterfaceUsingExecuters, +) class InterfaceA(Interface): @@ -46,6 +50,7 @@ class InterfaceC(Interface): class OperatorTests(unittest.TestCase): def setUp(self): self.o, self.r = test_reactors.loadTestReactor() + self.activeInterfaces = [ii for ii in self.o.interfaces if ii.enabled()] def test_addInterfaceSubclassCollision(self): cs = settings.Settings() @@ -97,7 +102,36 @@ def test_loadStateError(self): self.o.loadState(0, 1) def test_couplingIsActive(self): + """ensure that cs["tightCoupling"] controls couplingIsActive""" self.assertFalse(self.o.couplingIsActive()) + self.o.cs["tightCoupling"] = True + self.assertTrue(self.o.couplingIsActive()) + + def test_computeTightCouplingConvergence(self): + """ensure that tight coupling convergence can be computed and checked + + Notes + ----- + - Assertion #1: ensure that the convergence of Keff, eps, is greater than 1e-5 (the prescribed convergence criteria) + - Assertion #2: ensure that eps is (prevIterKeff - currIterKeff) + """ + prevIterKeff = 0.9 + currIterKeff = 1.0 + self.o.cs["tightCoupling"] = True + self.o.cs["tightCouplingSettings"] = { + "globalFlux": {"parameter": "keff", "convergence": 1e-05} + } + globalFlux = GlobalFluxInterfaceUsingExecuters(self.r, self.o.cs) + globalFlux.coupler.storePreviousIterationValue(prevIterKeff) + self.o.addInterface(globalFlux) + # set keff to some new value and compute tight coupling convergence + self.r.core.p.keff = currIterKeff + self.o._convergenceSummary = collections.defaultdict(list) + self.assertFalse(self.o._checkTightCouplingConvergence([globalFlux])) + self.assertAlmostEqual( + globalFlux.coupler.eps, + currIterKeff - prevIterKeff, + ) def test_setStateToDefault(self): diff --git a/armi/physics/neutronics/globalFlux/globalFluxInterface.py b/armi/physics/neutronics/globalFlux/globalFluxInterface.py index cf99525ca..549de5464 100644 --- a/armi/physics/neutronics/globalFlux/globalFluxInterface.py +++ b/armi/physics/neutronics/globalFlux/globalFluxInterface.py @@ -63,6 +63,19 @@ def __init__(self, r, cs): else: self.nodeFmt = "1d" # produce ig001_1.inp. self._bocKeff = None # for tracking rxSwing + self._setTightCouplingDefaults() + + def _setTightCouplingDefaults(self): + """enable tight coupling defaults for the interface + + - allows users to set tightCoupling: true in settings without + having to specify the specific tightCouplingSettings for this interface. + - this is splt off from self.__init__ for testing + """ + if self.coupler is None and self.cs["tightCoupling"]: + self.coupler = interfaces.TightCoupler( + "keff", 1.0e-4, self.cs["tightCouplingMaxNumIters"] + ) @staticmethod def getHistoryParams(): @@ -222,6 +235,24 @@ def interactCoupled(self, iteration): GlobalFluxInterface.interactCoupled(self, iteration) + def getTightCouplingValue(self): + """Return the parameter value""" + if self.coupler.parameter == "keff": + return self.r.core.p.keff + if self.coupler.parameter == "power": + scaledCorePowerDistribution = [] + for a in self.r.core.getChildren(): + scaledPower = [] + assemPower = sum(b.p.power for b in a) + for b in a: + scaledPower.append(b.p.power / assemPower) + + scaledCorePowerDistribution.append(scaledPower) + + return scaledCorePowerDistribution + + return None + @staticmethod def getOptionsCls(): """ diff --git a/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py b/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py index e537f7e3a..88b00099e 100644 --- a/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py +++ b/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py @@ -170,10 +170,12 @@ class TestGlobalFluxInterfaceWithExecuters(unittest.TestCase): @classmethod def setUpClass(cls): - cs = settings.Settings() + cls.cs = settings.Settings() _o, cls.r = test_reactors.loadTestReactor() - cls.r.core.p.keff = 1.0 - cls.gfi = MockGlobalFluxWithExecuters(cls.r, cs) + + def setUp(self): + self.r.core.p.keff = 1.0 + self.gfi = MockGlobalFluxWithExecuters(self.r, self.cs) def test_executerInteraction(self): gfi, r = self.gfi, self.r @@ -191,6 +193,32 @@ def test_getExecuterCls(self): class0 = globalFluxInterface.GlobalFluxInterfaceUsingExecuters.getExecuterCls() self.assertEqual(class0, globalFluxInterface.GlobalFluxExecuter) + def test_setTightCouplingDefaults(self): + """assert that tight coupling defaults are only set if cs["tightCoupling"]=True""" + self.assertIsNone(self.gfi.coupler) + self._setTightCouplingTrue() + self.assertEqual(self.gfi.coupler.parameter, "keff") + self._setTightCouplingFalse() + + def test_getTightCouplingValue(self): + """test getTightCouplingValue returns the correct value for keff and type for power""" + self._setTightCouplingTrue() + self.assertEqual(self.gfi.getTightCouplingValue(), 1.0) # set in setUp + self.gfi.coupler.parameter = "power" + for a in self.r.core.getChildren(): + for b in a: + b.p.power = 10.0 + self.assertIsInstance(self.gfi.getTightCouplingValue(), list) + self._setTightCouplingFalse() + + def _setTightCouplingTrue(self): + # pylint: disable=no-member,protected-access + self.cs["tightCoupling"] = True + self.gfi._setTightCouplingDefaults() + + def _setTightCouplingFalse(self): + self.cs["tightCoupling"] = False + class TestGlobalFluxInterfaceWithExecutersNonUniform(unittest.TestCase): """Tests for global flux execution with non-uniform assemblies.""" diff --git a/armi/settings/fwSettings/globalSettings.py b/armi/settings/fwSettings/globalSettings.py index ae2a9c9b5..79365fac7 100644 --- a/armi/settings/fwSettings/globalSettings.py +++ b/armi/settings/fwSettings/globalSettings.py @@ -29,6 +29,7 @@ from armi import context from armi.settings import setting from armi.utils.mathematics import isMonotonic +from armi.settings.fwSettings import tightCouplingSettings # Framework settings @@ -78,7 +79,9 @@ CONF_MODULE_VERBOSITY = "moduleVerbosity" CONF_MPI_TASKS_PER_NODE = "mpiTasksPerNode" CONF_N_CYCLES = "nCycles" -CONF_NUM_COUPLED_ITERATIONS = "numCoupledIterations" +CONF_TIGHT_COUPLING = "tightCoupling" +CONF_TIGHT_COUPLING_MAX_ITERS = "tightCouplingMaxNumIters" +CONF_TIGHT_COUPLING_SETTINGS = "tightCouplingSettings" CONF_OPERATOR_LOCATION = "operatorLocation" CONF_OUTPUT_FILE_EXTENSION = "outputFileExtension" CONF_PLOTS = "plots" @@ -97,7 +100,6 @@ CONF_FLUX_RECON = "fluxRecon" # strange coupling in fuel handlers CONF_INDEPENDENT_VARIABLES = "independentVariables" CONF_HCF_CORETYPE = "HCFcoretype" -CONF_LOOSE_COUPLING = "looseCoupling" CONF_T_IN = "Tin" CONF_T_OUT = "Tout" CONF_DEFERRED_INTERFACES_CYCLE = "deferredInterfacesCycle" @@ -585,12 +587,19 @@ def defineSettings() -> List[setting.Setting]: description="Number of blocks with control for a REBUS poison search", ), setting.Setting( - CONF_NUM_COUPLED_ITERATIONS, - default=0, - label="Tight Coupling Iterations", - description="Number of tight coupled physics iterations to occur at each " - "timestep", - schema=vol.All(vol.Coerce(int), vol.Range(min=0)), + CONF_TIGHT_COUPLING, + default=False, + label="Tight Coupling", + description="Boolean to turn on/off tight coupling", + ), + setting.Setting( + CONF_TIGHT_COUPLING_MAX_ITERS, + default=4, + label="Maximum number of iterations for tight coupling.", + description="Maximum number of iterations for tight coupling.", + ), + tightCouplingSettings.TightCouplingSettingDef( + CONF_TIGHT_COUPLING_SETTINGS, ), setting.Setting( CONF_OPERATOR_LOCATION, @@ -732,14 +741,6 @@ def defineSettings() -> List[setting.Setting]: "on design being analyzed", options=["TWRC", "TWRP", "TWRC-HEX"], ), - setting.Setting( - CONF_LOOSE_COUPLING, - default=False, - label="Activate Loose Physics Coupling", - description="Update material densities and dimensions after running " - "thermal-hydraulics. Note: Thermal-hydraulics calculation is needed " - "to perform the loose physics coupling calculation.", - ), setting.Setting( CONF_T_IN, default=360.0, diff --git a/armi/settings/fwSettings/tests/test_fwSettings.py b/armi/settings/fwSettings/tests/test_fwSettings.py index 07f1c537e..f27d7c8df 100644 --- a/armi/settings/fwSettings/tests/test_fwSettings.py +++ b/armi/settings/fwSettings/tests/test_fwSettings.py @@ -97,11 +97,6 @@ def setUp(self): "error": vol.error.MultipleInvalid, }, "nCycles": {"valid": 1, "invalid": -1, "error": vol.error.MultipleInvalid}, - "numCoupledIterations": { - "valid": 0, - "invalid": -1, - "error": vol.error.MultipleInvalid, - }, "power": {"valid": 0, "invalid": -1, "error": vol.error.MultipleInvalid}, "skipCycles": { "valid": 0, diff --git a/armi/settings/fwSettings/tests/test_tightCouplingSettings.py b/armi/settings/fwSettings/tests/test_tightCouplingSettings.py new file mode 100644 index 000000000..5e7601116 --- /dev/null +++ b/armi/settings/fwSettings/tests/test_tightCouplingSettings.py @@ -0,0 +1,147 @@ +# Copyright 2023 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. +""" +Unit testing for tight coupling settings. +- The settings example below shows the intended use for these settings in + an ARMI yaml input file. +- Note, for these to be recognized, they need to be prefixed with "tightCouplingSettings:" +""" +# pylint: disable=missing-function-docstring,missing-class-docstring,abstract-method,protected-access,unused-variable +import unittest +import io + +from ruamel.yaml import YAML +import voluptuous as vol + +from armi.settings.fwSettings.tightCouplingSettings import TightCouplingSettingDef +from armi.settings.fwSettings.tightCouplingSettings import ( + tightCouplingSettingsValidator, +) + +TIGHT_COUPLING_SETTINGS_EXAMPLE = """ + globalFlux: + parameter: keff + convergence: 1e-05 + fuelPerformance: + parameter: peakFuelTemperature + convergence: 1e-02 + """ + + +class TestTightCouplingSettings(unittest.TestCase): + def test_validAssignments(self): + """Tests that the tight coupling settings dictionary can be added to.""" + tc = {} + tc["globalFlux"] = {"parameter": "keff", "convergence": 1e-05} + tc["thermalHydraulics"] = { + "parameter": "peakCladdingTemperature", + "convergence": 1e-02, + } + tc = tightCouplingSettingsValidator(tc) + self.assertEqual(tc["globalFlux"]["parameter"], "keff") + self.assertEqual(tc["globalFlux"]["convergence"], 1e-05) + self.assertEqual( + tc["thermalHydraulics"]["parameter"], "peakCladdingTemperature" + ) + self.assertEqual(tc["thermalHydraulics"]["convergence"], 1e-02) + + def test_incompleteAssignment(self): + """Tests that the tight coupling settings is rendered empty if a complete dictionary is not provided.""" + tc = {} + tc["globalFlux"] = None + tc = tightCouplingSettingsValidator(tc) + self.assertNotIn("globalFlux", tc.keys()) + + tc = {} + tc["globalFlux"] = {} + tc = tightCouplingSettingsValidator(tc) + self.assertNotIn("globalFlux", tc.keys()) + + def test_missingAssignments(self): + """Tests failure if not all keys/value pairs are provided on initialization.""" + # Fails because `convergence` is not assigned at the same + # time as the `parameter` assignment. + with self.assertRaises(vol.MultipleInvalid): + tc = {} + tc["globalFlux"] = {"parameter": "keff"} + tc = tightCouplingSettingsValidator(tc) + + # Fails because `parameter` is not assigned at the same + # time as the `convergence` assignment. + with self.assertRaises(vol.MultipleInvalid): + tc = {} + tc["globalFlux"] = {"convergence": 1e-08} + tc = tightCouplingSettingsValidator(tc) + + def test_invalidArgumentTypes(self): + """Tests failure when the values of the parameters do not match the expected schema.""" + + # Fails because `parameter` value is required to be a string + with self.assertRaises(vol.MultipleInvalid): + tc = {} + tc["globalFlux"] = {"parameter": 1.0} + tc = tightCouplingSettingsValidator(tc) + + # Fails because `convergence` value is required to be something can be coerced into a float + with self.assertRaises(vol.MultipleInvalid): + tc = {} + tc["globalFlux"] = {"convergence": "keff"} + tc = tightCouplingSettingsValidator(tc) + + def test_extraAssignments(self): + """ + Tests failure if additional keys are supplied that do not match the expected schema or + if there are any typos in the expected keys. + """ + # Fails because the `parameter` key is misspelled. + with self.assertRaises(vol.MultipleInvalid): + tc = {} + tc["globalFlux"] = {"parameters": "keff", "convergence": 1e-05} + tc = tightCouplingSettingsValidator(tc) + + # Fails because of the `extra` key. + with self.assertRaises(vol.MultipleInvalid): + tc = {} + tc["globalFlux"] = { + "parameter": "keff", + "convergence": 1e-05, + "extra": "fails", + } + tc = tightCouplingSettingsValidator(tc) + + def test_serializeSettingsException(self): + """ensure the TypeError in serializeTightCouplingSettings can be reached""" + tc = ["globalFlux"] + with self.assertRaises(TypeError) as cm: + tc = tightCouplingSettingsValidator(tc) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_yamlIO(self): + """Ensure we can read/write this custom setting object to yaml""" + yaml = YAML() + inp = yaml.load(io.StringIO(TIGHT_COUPLING_SETTINGS_EXAMPLE)) + tcd = TightCouplingSettingDef("TestSetting") + tcd.setValue(inp) + self.assertEqual(tcd.value["globalFlux"]["parameter"], "keff") + outBuf = io.StringIO() + output = tcd.dump() + yaml.dump(output, outBuf) + outBuf.seek(0) + inp2 = yaml.load(outBuf) + self.assertEqual(inp.keys(), inp2.keys()) + + +if __name__ == "__main__": + unittest.main() diff --git a/armi/settings/fwSettings/tightCouplingSettings.py b/armi/settings/fwSettings/tightCouplingSettings.py new file mode 100644 index 000000000..983c40c5d --- /dev/null +++ b/armi/settings/fwSettings/tightCouplingSettings.py @@ -0,0 +1,138 @@ +# Copyright 2023 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. + +""" +The data structures and schema of the tight coupling settings. + +These are advanced/compound settings that are carried along in the normal cs +object but aren't simple key/value pairs. +""" + +from typing import Dict, Union + +import voluptuous as vol + +from armi.settings import Setting + +_SCHEMA = vol.Schema( + { + str: vol.Schema( + { + vol.Required("parameter"): str, + vol.Required("convergence"): vol.Coerce(float), + } + ) + } +) + + +class TightCouplingSettings(dict): + """ + Dictionary with keys of Interface functions and a dictionary value. + + Notes + ----- + The dictionary value for each Interface function is required to contain a ``parameter`` + and a ``convergence`` key with string and float values, respectively. No other + keys are allowed. + + Examples + -------- + couplingSettings = TightCouplingSettings({'globalFlux': {'parameter': 'keff', 'convergence': 1e-05}}) + """ + + def __repr__(self): + return f"<{self.__class__.__name__} with Interface functions {self.keys()}>" + + +def serializeTightCouplingSettings( + tightCouplingSettingsDict: Union[TightCouplingSettings, Dict] +) -> Dict[str, Dict]: + """ + Return a serialized form of the ``TightCouplingSettings`` as a dictionary. + + Notes + ----- + Attributes that are not set (i.e., set to None) will be skipped. + """ + if not isinstance(tightCouplingSettingsDict, dict): + raise TypeError(f"Expected a dictionary for {tightCouplingSettingsDict}") + + output = {} + for interfaceFunction, options in tightCouplingSettingsDict.items(): + + # Setting the value to an empty dictionary + # if it is set to a None or an empty + # dictionary. + if not options: + continue + + output[str(interfaceFunction)] = options + return output + + +class TightCouplingSettingDef(Setting): + """ + Custom setting object to manage the tight coupling settings for each interface. + + Notes + ----- + This uses the ``tightCouplingSettingsValidator`` schema to validate the inputs + and will automatically coerce the value into a ``TightCouplingSettings`` dictionary. + """ + + def __init__(self, name): + description = ( + "Data structure defining the tight coupling parameters " + "and convergence criteria for each interface." + ) + label = "Interface Tight Coupling Control" + default = TightCouplingSettings() + options = None + schema = tightCouplingSettingsValidator + enforcedOptions = False + subLabels = None + isEnvironment = False + oldNames = None + Setting.__init__( + self, + name, + default, + description, + label, + options, + schema, + enforcedOptions, + subLabels, + isEnvironment, + oldNames, + ) + + def dump(self): + """Return a serialized version of the ``TightCouplingSettings`` object.""" + return serializeTightCouplingSettings(self._value) + + +def tightCouplingSettingsValidator( + tightCouplingSettingsDict: Dict[str, Dict] +) -> TightCouplingSettings: + """Returns a ``TightCouplingSettings`` object if validation is successful.""" + tightCouplingSettingsDict = serializeTightCouplingSettings( + tightCouplingSettingsDict + ) + tightCouplingSettingsDict = _SCHEMA(tightCouplingSettingsDict) + vals = TightCouplingSettings() + for interfaceFunction, inputParams in tightCouplingSettingsDict.items(): + vals[interfaceFunction] = inputParams + return vals diff --git a/armi/tests/armiRun.yaml b/armi/tests/armiRun.yaml index 1565440a8..2b05556fa 100644 --- a/armi/tests/armiRun.yaml +++ b/armi/tests/armiRun.yaml @@ -48,7 +48,6 @@ settings: armi.reactor.reactors: info nCycles: 6 nodeGroup: OnlineNodes,TP - numCoupledIterations: 0 outputFileExtension: png percentNaReduction: 10.0 power: 100000000.0 diff --git a/armi/tests/detailedAxialExpansion/armiRun.yaml b/armi/tests/detailedAxialExpansion/armiRun.yaml index 58c08af56..cb52b0812 100644 --- a/armi/tests/detailedAxialExpansion/armiRun.yaml +++ b/armi/tests/detailedAxialExpansion/armiRun.yaml @@ -37,7 +37,6 @@ settings: moduleVerbosity: armi.reactor.reactors: info nCycles: 6 - numCoupledIterations: 0 outputFileExtension: png power: 100000000.0 summarizeAssemDesign: false diff --git a/armi/tests/test_interfaces.py b/armi/tests/test_interfaces.py index d44fcdbd5..32b793d05 100644 --- a/armi/tests/test_interfaces.py +++ b/armi/tests/test_interfaces.py @@ -25,16 +25,19 @@ class DummyInterface(interfaces.Interface): name = "Dummy" + function = "dummyAction" class TestCodeInterface(unittest.TestCase): """Test Code interface.""" + def setUp(self): + self.cs = settings.Settings() + def test_isRequestedDetailPoint(self): """Tests notification of detail points.""" - cs = settings.Settings() newSettings = {"dumpSnapshot": ["000001", "995190"]} - cs = cs.modified(newSettings=newSettings) + cs = self.cs.modified(newSettings=newSettings) i = DummyInterface(None, cs) @@ -44,7 +47,7 @@ def test_isRequestedDetailPoint(self): def test_enabled(self): """Test turning interfaces on and off.""" - i = DummyInterface(None, None) + i = DummyInterface(None, self.cs) self.assertEqual(i.enabled(), True) i.enabled(False) @@ -72,5 +75,77 @@ def test_fsearch_text(self): self.assertEqual(self.tp.fsearch("xml"), "") +class TestTightCoupler(unittest.TestCase): + """test the tight coupler class""" + + def setUp(self): + cs = settings.Settings() + cs["tightCoupling"] = True + cs["tightCouplingSettings"] = { + "dummyAction": {"parameter": "nothing", "convergence": 1.0e-5} + } + self.interface = DummyInterface(None, cs) + + def test_couplerActive(self): + self.assertIsNotNone(self.interface.coupler) + + def test_storePreviousIterationValue(self): + self.interface.coupler.storePreviousIterationValue(1.0) + self.assertEqual(self.interface.coupler._previousIterationValue, 1.0) + + def test_storePreviousIterationValueException(self): + with self.assertRaises(TypeError) as cm: + self.interface.coupler.storePreviousIterationValue({5.0}) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_isConvergedValueError(self): + with self.assertRaises(ValueError) as cm: + self.interface.coupler.isConverged(1.0) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_isConverged(self): + """ensure TightCoupler.isConverged() works with float, 1D list, and ragged 2D list + + Notes + ----- + 2D lists can end up being ragged as assemblies can have different number of blocks. + Ragged lists are easier to manage with lists as opposed to numpy.arrays, + namely, their dimension is preserved. + """ + previousValues = { + "float": 1.0, + "list1D": [1.0, 2.0], + "list2D": [[1, 2, 3], [1, 2]], + } + updatedValues = { + "float": 5.0, + "list1D": [5.0, 6.0], + "list2D": [[5, 6, 7], [5, 6]], + } + for previous, current in zip(previousValues.values(), updatedValues.values()): + self.interface.coupler.storePreviousIterationValue(previous) + self.assertFalse(self.interface.coupler.isConverged(current)) + + def test_isConvergedRuntimeError(self): + """test to ensure 3D arrays do not work""" + previous = [[[1, 2, 3]], [[1, 2, 3]], [[1, 2, 3]]] + updatedValues = [[[5, 6, 7]], [[5, 6, 7]], [[5, 6, 7]]] + self.interface.coupler.storePreviousIterationValue(previous) + with self.assertRaises(RuntimeError) as cm: + self.interface.coupler.isConverged(updatedValues) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_getListDimension(self): + a = [1, 2, 3] + self.assertEqual(interfaces.TightCoupler.getListDimension(a), 1) + a = [[1, 2, 3]] + self.assertEqual(interfaces.TightCoupler.getListDimension(a), 2) + a = [[[1, 2, 3]]] + self.assertEqual(interfaces.TightCoupler.getListDimension(a), 3) + + if __name__ == "__main__": unittest.main() diff --git a/doc/release/0.2.rst b/doc/release/0.2.rst index a817ab6c9..14dfcad38 100644 --- a/doc/release/0.2.rst +++ b/doc/release/0.2.rst @@ -22,6 +22,7 @@ What's new in ARMI #. Cleanup of unnecessary and confusing functionality in axialExpansionChanger.py. (`PR#1032 `_) #. Axially expand from cold to hot before deepcopy of assemblies into reactor improving speed and preserving same functionality. (`PR#1047 `_) #. Add a how-to on restart calculations in the docs +#. Overhaul of the tight coupling routine in ARMI. Removal of ``looseCoupling`` setting and introduction of new tight coupling user interface. (`https://github.com/terrapower/armi/pull/1033`_) #. General improvements to efficiency in many places to speed up uniform mesh conversion. (`PR#1042 `_) #. Allow MCNP material card number to be defined after the card is written. (`PR#1086 `_) diff --git a/doc/requirements/srsd.rst b/doc/requirements/srsd.rst index 73dde960d..8ac12e6e6 100644 --- a/doc/requirements/srsd.rst +++ b/doc/requirements/srsd.rst @@ -201,7 +201,7 @@ Many analysis tasks require high performance computing (HPC), and the operator p .. req:: The operator package shall allow physics coupling between analysis plugins. :id: REQ_OPERATOR_COUPLING - :status: needs implementation, needs test + :status: implemented, needs more tests For coupled physics (e.g. neutronics depends on thermal hydraulics depends on neutronics), the operator package shall allow loose and/or tight coupling. Loose coupling is using the values from the previous timestep to update the next timestep. Tight is an operator-splitting iteration until convergence between one or more plugins. diff --git a/doc/user/assorted_guide.rst b/doc/user/assorted_guide.rst deleted file mode 100644 index 7543f6fee..000000000 --- a/doc/user/assorted_guide.rst +++ /dev/null @@ -1,5 +0,0 @@ -Physics Coupling ----------------- -Tight coupled physics can be activated though the ``numCoupledIterations`` -setting. This is handled in the :py:meth:`mainOperate ` -method. diff --git a/doc/user/images/looseCouplingIllustration.png b/doc/user/images/looseCouplingIllustration.png new file mode 100644 index 000000000..c04cf68f7 Binary files /dev/null and b/doc/user/images/looseCouplingIllustration.png differ diff --git a/doc/user/images/tightCouplingIllustration.png b/doc/user/images/tightCouplingIllustration.png new file mode 100644 index 000000000..c3ae81134 Binary files /dev/null and b/doc/user/images/tightCouplingIllustration.png differ diff --git a/doc/user/index.rst b/doc/user/index.rst index 92016f1d4..2ed84d7cc 100644 --- a/doc/user/index.rst +++ b/doc/user/index.rst @@ -20,5 +20,5 @@ analyzing ARMI output files, etc. core_parameters_report assembly_parameters_report block_parameters_report - assorted_guide + physics_coupling accessingEntryPoints diff --git a/doc/user/physics_coupling.rst b/doc/user/physics_coupling.rst new file mode 100644 index 000000000..84746e304 --- /dev/null +++ b/doc/user/physics_coupling.rst @@ -0,0 +1,82 @@ +********************** +Physics Coupling +********************** + +Loose Coupling +---------------- +ARMI supports loose and tight coupling. Loose coupling is interpreted as one-way coupling between physics for a single time node. For example, a power distribution in cycle 0 node 0 is used to calculate a temperature distribution in cycle 0 node 0. This temperature is then used in cycle 0 node 1 to compute new cross sections and a new power distribution. This process repeats itself for the lifetime of the simulation. + +.. image:: images/looseCouplingIllustration.png + :width: 800 + :alt: Loose coupling illustration. + +Loose coupling is enabled by default in ARMI simulations. + +Tight Coupling +----------------- +Tight coupling is interpreted as two-way communication between physics within a given time node. Revisiting our previous example, enabling tight coupling results in the temperature distribution being used to generate updated cross sections (new temperatures induce changes such as Doppler broadening feedback) and ultimately an updated power distribution. This process is repeated iteratively until a numerical convergence criteria is met. + +.. image:: images/tightCouplingIllustration.png + :width: 800 + :alt: Tight coupling illustration. + +The following settings are involved with enabling tight coupling in ARMI: + +1. ``tightCoupling``: When ``True``, tight coupling is enabled. +2. ``tightCouplingSettings``: Used to specify which parameters and convergence criteria will be used to measure the convergence of a given interface. + +.. code-block:: yaml + + tightCoupling: true + tightCouplingSettings: + globalFlux: + parameter: power + convergence: 1.0e-4 + thermalHydraulics: + parameter: THaverageCladTemperature + convergence: 1.0e-2 + + +The ``tightCouplingSettings`` settings interact with the interfaces available in ARMI (or an ARMI app). The interface headers (i.e., "globalFlux" and "thermalHydraulics") must match the value prescribed for :py:attr:`Interface.function `. The option, ``parameter``, can be a registered parameter. The ``convergence`` option is expected to be any float value. In the current implementation, different interfaces may have different developer intended restrictions. For example, the global flux interface currently only allows the eigenvalue (i.e. :math:`k_{\text{eff}}`) or block-wise power to be valid ``parameter`` values. + +.. warning:: + The inherent limitations of the above interface-based tight coupling settings have been documented and a new and improved user-interface is currently being developed. + +In the global flux interface, the following norms are used to compute the convergence of :math:`k_{\text{eff}}` and block-wise power. + +Eigenvalue +^^^^^^^^^^ +The convergence of the eigenvalue is measured through an L2-norm. + +.. math:: + \epsilon = \| k_\text{eff} \|_2 = \left( \left( k_\text{eff,old} - k_\text{eff,new} \right)^2 \right) ^ \frac{1}{2} + +Block-wise Power +^^^^^^^^^^^^^^^^ +The block-wise power can be used as a convergence mechanism to avoid the integral effects of :math:`k_{\text{eff}}` (i.e., over and under predictions cancelling each other out) and in turn, can have a different convergence rate. To measure the convergence of the power distribution with the prescribed tolerances (e.g., 1e-4), the power is scaled in the following manner (otherwise the calculation struggles to converge). + +For an assembly, :math:`a`, we compute the total power of the assembly, + +.. math:: + a_{\text{power},i} = \sum_{j}b_{\text{power},(i,j)}, + +where :math:`i` is the :math:`i^{\text{th}}` assembly and :math:`j` is the :math:`j^{\text{th}}` block within assembly, :math:`i`. With the assembly power, we scale the block power and obtain an array of scaled block powers for a given assembly, :math:`\mathbf{b}_{i}`, + +.. math:: + \mathbf{b}_{i} = \left\lbrace \frac{b_{\text{power},(i,j)}}{a_{\text{power},i}} \right\rbrace, \quad \forall j \in a_i. + +We can now calculate a convergence parameter for each assembly, + +.. math:: + \epsilon_i &= \| \textbf{b}_{i,\text{old}} - \textbf{b}_{i,\text{new}} \|_2 \\ + &=\sqrt{\sum_{i}\left( \textbf{b}_{i,\text{old}} - \textbf{b}_{i,\text{new}} \right)^2}. + +These assembly-wise convergence parameters are then stored in an array of convergence values, + +.. math:: + \xi = \left\lbrace \epsilon_i \right\rbrace,\quad \forall i \in \text{Core}. + +The total convergence of the power distribution is finally measured through the infinity norm (i.e, the max) of :math:`\xi`, + +.. math:: + \epsilon = \| \xi \|_\inf = \max \xi.