Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add measure of convergence for tight coupling #1033

Merged
merged 43 commits into from
Jan 23, 2023
Merged
Show file tree
Hide file tree
Changes from 42 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
df348b0
initial commit
albeanth Nov 2, 2022
237e314
adding power as convergence option for global flux
albeanth Nov 3, 2022
6dde1a3
merge main + resolve merge conflicts
albeanth Dec 16, 2022
76c0105
Merge branch 'main' into tightCoupling_Convergence
albeanth Dec 16, 2022
167d23c
Merge branch 'main' into tightCoupling_Convergence
albeanth Dec 21, 2022
b0debd1
decrease default # of tight coupling iters
albeanth Dec 21, 2022
931763e
reviewer feedback
albeanth Dec 21, 2022
43f98e1
reviewer feedback
albeanth Dec 21, 2022
f8f8a8e
replace key with .join() + black formatting
albeanth Dec 22, 2022
57d5a60
Merge branch 'main' into tightCoupling_Convergence
albeanth Jan 3, 2023
7980951
rm tightCouplingReport at end of coupling loop
albeanth Jan 3, 2023
facb177
add unittests + rm _checkTightCouplingConvergence
albeanth Jan 3, 2023
d79f967
Update the implementation to:
jakehader Jan 4, 2023
e40104b
a couple simple typo fixes
albeanth Jan 5, 2023
e279efc
adding unit test for exception
albeanth Jan 5, 2023
22c4b06
simplify conditionals
albeanth Jan 5, 2023
668f87a
update coupling tests with new structure
albeanth Jan 5, 2023
beceef7
replace class attribute for TightCoupler attribute
albeanth Jan 5, 2023
914599d
chnge TightCoupler.param to TightCoupler.parameter
albeanth Jan 5, 2023
e01ecd0
update o.interactAllCoupled + unit testing
albeanth Jan 5, 2023
ceb13d8
add runLog.warning on maxNumIters + pylint cleanup
albeanth Jan 5, 2023
47d1021
adding unit tests +cleanup
albeanth Jan 6, 2023
5b79e5e
reviewer feedback for _setTightCouplerByInterfaceFunction
albeanth Jan 6, 2023
88770af
add unittest + coupler defaults for global flux interface
albeanth Jan 6, 2023
c7ea9b1
clean up import
albeanth Jan 6, 2023
b458f38
rm lingering "numCoupledIterations" + black
albeanth Jan 6, 2023
ba816ff
updating settings validators + adding additional notes for new tight …
albeanth Jan 6, 2023
e7200eb
black formatting
albeanth Jan 6, 2023
b2bd487
fix unit tests
albeanth Jan 6, 2023
949d7c6
reviewer comments to cleanup interfaces.py
albeanth Jan 9, 2023
1bf6db9
update constructor for tight coupling defaults
albeanth Jan 10, 2023
abe98ac
rm cs from setTightCouplingDefaults
albeanth Jan 11, 2023
bf79d0b
bug fixes for TightCoupler.isConverged()
albeanth Jan 12, 2023
d909f2e
release notes
albeanth Jan 13, 2023
ae34fbf
reviewer feedback on static method + private
albeanth Jan 13, 2023
0b5ab65
merge in main + resolve merge conflicts
albeanth Jan 13, 2023
77f8591
initial cut at revised physics coupling docs
albeanth Jan 16, 2023
dbd729b
Merge branch 'main' into tightCoupling_Convergence
albeanth Jan 17, 2023
7a6fa1c
it helps to add the illustrations used in the docs
albeanth Jan 17, 2023
024e5f9
slight revision to docs
albeanth Jan 17, 2023
48da0c8
rename assorted_guide to physics_coupling
albeanth Jan 17, 2023
a3a5ab2
update srsd
albeanth Jan 17, 2023
fd0b812
update copyright for new files
albeanth Jan 17, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion armi/bookkeeping/db/databaseInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions armi/bookkeeping/db/tests/test_databaseInterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion armi/bookkeeping/report/newReportUtils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 11 additions & 3 deletions armi/bookkeeping/report/reportingUtils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
)
Expand Down
190 changes: 189 additions & 1 deletion armi/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing that I've noticed is that every time a TightCoupler class is instantiated, it is necessary to pass in maxIters. However, this value should be the same for all instances of the TightCoupler, because it comes from the case settings.

This is just a little unideal, because it somehow could trigger the coupling iterations to stop early if one of the interfaces is supplied with a maxIters smaller than the others.

This could be resolved if the tight coupling mechanics were pulled off of the interfaces into somewhere higher, perhaps the operator or into its own interface. I put a comment such as this on the PR page, but I'm gonna put another one again with some more thoughts.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing in an integer seems like the lightest-possible-weight solution to me.

I like that not ALL of our code has to directly call the CaseSettings object. It makes the code more reusable, and flexible. And helps us toward our Roadmap of not having the "Operator is King" mentality.

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.
Expand Down Expand Up @@ -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 "<Interface {0}>".format(self.name)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 44 additions & 4 deletions armi/operators/operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import re
import shutil
import time
import collections

from armi import context
from armi import interfaces
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
Loading