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

Improving getOptimalAssemblyOrientation #2019

Merged
merged 50 commits into from
Dec 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
f829b80
Fix getOptimalAssemblyOrientation
drewj-tp Oct 7, 2024
3520838
Expand test_oppositeRotation to all six configurations
drewj-tp Oct 11, 2024
c39e2aa
Update optimal assembly rotation to more robustly handle and test dif…
drewj-tp Oct 11, 2024
bd3f5fc
Require shuffled block to have a spatial grid when rotating
drewj-tp Oct 11, 2024
f3dfc50
Test requirement on optimal rotation needing consistent pin powers an…
drewj-tp Oct 11, 2024
3b8ef76
Fuel handler rotation test points to mocked optimal rotation
drewj-tp Oct 11, 2024
ec55273
Add requirement test and implementation notes for equalizing burnup r…
drewj-tp Oct 11, 2024
ef2e6fc
Use dict of ij to powers in optimal assembly orientation
drewj-tp Oct 17, 2024
b0db853
Add comment to confusing fuel handlers test
drewj-tp Oct 28, 2024
5272267
Cleaner handling of different rotation signatures
drewj-tp Oct 22, 2024
a0bb7ee
buReducingAssemblyRotation handles fresh assemblies better
drewj-tp Oct 22, 2024
3682c0d
Remove commented out import from assemblyRotationAlgorithms
drewj-tp Oct 22, 2024
a64332f
Put max burnup fuel pin location in fuelCycle/utils.py
drewj-tp Oct 28, 2024
f72cd62
Remove need for history interface in burnup equalizing rotation
drewj-tp Oct 22, 2024
9d4b0bc
Check against Component.p.pinPercentBu for burnup equalizing rotation
drewj-tp Oct 22, 2024
59b1eb4
Better comment for skipping aPrev not in core in rotation
drewj-tp Oct 31, 2024
fb7eb4c
Dont try to check burnup on new assembly if fresh from load queue
drewj-tp Oct 31, 2024
1bfcc39
Better usage of Component.p.pinPercentBu in finding max burnup block …
drewj-tp Oct 31, 2024
a965f2b
Update fuel handler test to use Comp.p.pinPercentBu
drewj-tp Oct 31, 2024
523af1f
Remove rotation-related usage on block burnup parameters
drewj-tp Oct 31, 2024
c181d90
Clean up clean up everybody do your share
drewj-tp Oct 31, 2024
f19c959
Round degrees in log statement about rotated assemblies
drewj-tp Nov 4, 2024
258a100
Add debug statements to burnup rotation
drewj-tp Nov 4, 2024
cace951
Improve fresh feed test
drewj-tp Nov 4, 2024
ee89d75
Don't rotate assemblies until all assembly rotations have been determ…
drewj-tp Nov 5, 2024
2db8643
Hopefully improve readability of fuel cycle fuel pin checks
drewj-tp Nov 5, 2024
48ce1ed
Expand FuelHandler testing to encompass stationary rotation
drewj-tp Nov 4, 2024
21ba4cb
Doc fixup for getOptimalAssemblyOrientation
drewj-tp Nov 5, 2024
1c12db1
More helpful error message in maxBurnupLocator
drewj-tp Nov 6, 2024
f46ea22
Correct N_PINS in FuelCycleUtilsTests
drewj-tp Nov 6, 2024
bb1a8da
Refactor burnup rotation test with a shuffled queue case
drewj-tp Nov 6, 2024
7410094
Testing for two errors in fuel cycle maxBurnupLocator
drewj-tp Nov 6, 2024
ad1d9de
Add a clad to the fake block in fuel cycle utils testing
drewj-tp Nov 6, 2024
2ad70b6
Add testing for fuelCycle utils maxBurnupBlock
drewj-tp Nov 6, 2024
b258ec6
fuelCycle.utils.maxBurnupBlock uses Block.p.percentBuPeak
drewj-tp Nov 18, 2024
ff904c4
Drop Block.p.percentBuMax and percentBuMaxPinLocation
drewj-tp Nov 22, 2024
0c3bac5
Type hint return on defineParameterRenames hookspec
drewj-tp Nov 18, 2024
bb6dee9
Sort some imports
drewj-tp Nov 22, 2024
b1befee
Unify convoluted fuel handler invocation of rotation algorithm
drewj-tp Nov 22, 2024
039373f
Revert "Drop Block.p.percentBuMax and percentBuMaxPinLocation"
drewj-tp Nov 22, 2024
d486f6c
Revert "Type hint return on defineParameterRenames hookspec"
drewj-tp Nov 22, 2024
ca62930
Update armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py
drewj-tp Nov 22, 2024
596b7ca
Indicate that fuelCycle/utils.py is for pin-type reactors
drewj-tp Nov 22, 2024
69519ad
Merge remote-tracking branch 'origin/main' into drewj/bu-rotate-with-…
drewj-tp Nov 22, 2024
12f51aa
Merge remote-tracking branch 'origin/main' into drewj/bu-rotate-with-…
drewj-tp Nov 27, 2024
e319100
Remove debug statements from getOptimalAssemblyOrientation
drewj-tp Nov 27, 2024
52ec5d8
Use runLog.error and exceptions in getOptimalAssemblyOrientation
drewj-tp Nov 27, 2024
730f7d1
Update release notes
drewj-tp Nov 27, 2024
159f476
Make PinLocations test util private
drewj-tp Dec 2, 2024
40754b2
Apply suggestions from code review
drewj-tp Dec 2, 2024
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
75 changes: 43 additions & 32 deletions armi/physics/fuelCycle/assemblyRotationAlgorithms.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,18 @@
.. warning:: Nothing should go in this file, but rotation algorithms.
"""
import math
from collections import defaultdict

from armi import runLog
from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import (
getOptimalAssemblyOrientation,
)
from armi.physics.fuelCycle.settings import CONF_ASSEM_ROTATION_STATIONARY
from armi.physics.fuelCycle.utils import (
assemblyHasFuelPinBurnup,
assemblyHasFuelPinPowers,
)
from armi.reactor.assemblies import Assembly


def _rotationNumberToRadians(rot: int) -> float:
Expand All @@ -49,42 +55,47 @@ def buReducingAssemblyRotation(fh):
simpleAssemblyRotation : an alternative rotation algorithm
"""
runLog.info("Algorithmically rotating assemblies to minimize burnup")
numRotated = 0
hist = fh.o.getInterface("history")
for aPrev in fh.moved: # much more convenient to loop through aPrev first
# Store how we should rotate each assembly but don't perform the rotation just yet
# Consider assembly A is shuffled to a new location and rotated.
# Now, assembly B is shuffled to where assembly A used to be. We need to consider the
# power profile of A prior to it's rotation to understand the power profile B may see.
rotations: dict[int, list[Assembly]] = defaultdict(list)
for aPrev in fh.moved:
# If the assembly was out of the core, it will not have pin powers.
# No rotation information to be gained.
if aPrev.lastLocationLabel in Assembly.NOT_IN_CORE:
continue
aNow = fh.r.core.getAssemblyWithStringLocation(aPrev.lastLocationLabel)
# An assembly in the SFP could have burnup but if it's coming from the load
# queue it's totally fresh. Skip a check over all pins in the model
if aNow.lastLocationLabel == Assembly.LOAD_QUEUE:
continue
# no point in rotation if there's no pin detail
if aNow in hist.getDetailAssemblies():
_rotateByComparingLocations(aNow, aPrev)
numRotated += 1
if assemblyHasFuelPinPowers(aPrev) and assemblyHasFuelPinBurnup(aNow):
rot = getOptimalAssemblyOrientation(aNow, aPrev)
rotations[rot].append(aNow)

if fh.cs[CONF_ASSEM_ROTATION_STATIONARY]:
for a in hist.getDetailAssemblies():
if a not in fh.moved:
_rotateByComparingLocations(a, a)
numRotated += 1

runLog.info("Rotated {0} assemblies".format(numRotated))


def _rotateByComparingLocations(aNow, aPrev):
"""Rotate an assembly based on its previous location.

Parameters
----------
aNow : Assembly
Assembly to be rotated
aPrev : Assembly
Assembly that previously occupied the location of this assembly.
If ``aNow`` has not been moved, this should be ``aNow``
"""
rot = getOptimalAssemblyOrientation(aNow, aPrev)
radians = _rotationNumberToRadians(rot)
aNow.rotate(radians)
(ring, pos) = aNow.spatialLocator.getRingPos()
runLog.important(
"Rotating Assembly ({0},{1}) to Orientation {2}".format(ring, pos, rot)
)
for a in filter(
lambda asm: asm not in fh.moved
and assemblyHasFuelPinPowers(asm)
and assemblyHasFuelPinBurnup(asm),
fh.r.core,
):
rot = getOptimalAssemblyOrientation(a, a)
rotations[rot].append(a)

nRotations = 0
for rot, assems in filter(lambda item: item[0], rotations.items()):
# Radians used for the actual rotation. But a neater degrees print out is nice for logs
radians = _rotationNumberToRadians(rot)
degrees = round(math.degrees(radians), 3)
for a in assems:
runLog.important(f"Rotating assembly {a} {degrees} CCW.")
a.rotate(radians)
nRotations += 1

runLog.info(f"Rotated {nRotations} assemblies.")


def simpleAssemblyRotation(fh):
Expand Down
7 changes: 2 additions & 5 deletions armi/physics/fuelCycle/fuelHandlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
This module also handles repeat shuffles when doing a restart.
"""
# ruff: noqa: F401
import inspect
import os
import re
import warnings

import numpy as np

Expand Down Expand Up @@ -114,10 +114,7 @@ def outage(self, factor=1.0):
# The user can choose the algorithm method name directly in the settings
if hasattr(rotAlgos, self.cs[CONF_ASSEMBLY_ROTATION_ALG]):
rotationMethod = getattr(rotAlgos, self.cs[CONF_ASSEMBLY_ROTATION_ALG])
try:
rotationMethod()
except TypeError:
rotationMethod(self)
rotationMethod(self)
else:
raise RuntimeError(
"FuelHandler {0} does not have a rotation algorithm called {1}.\n"
Expand Down
165 changes: 85 additions & 80 deletions armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,108 +19,113 @@
-----
We are keeping these in ARMI even if they appear unused internally.
"""
import math
import typing

import numpy as np

from armi import runLog
from armi.reactor.flags import Flags
from armi.utils.hexagon import getIndexOfRotatedCell
from armi.physics.fuelCycle.utils import maxBurnupBlock, maxBurnupLocator
from armi.utils.mathematics import findClosest

if typing.TYPE_CHECKING:
from armi.reactor.assemblies import HexAssembly


def getOptimalAssemblyOrientation(a, aPrev):
def getOptimalAssemblyOrientation(a: "HexAssembly", aPrev: "HexAssembly") -> int:
"""
Get optimal assembly orientation/rotation to minimize peak burnup.
Get optimal hex assembly orientation/rotation to minimize peak burnup.

Notes
-----
Works by placing the highest-BU pin in the location (of 6 possible locations) with lowest
expected pin power. We evaluated "expected pin power" based on the power distribution in
aPrev, which is the previous assembly located here. If aPrev has no pin detail, then we must use its
corner fast fluxes to make an estimate.
.. impl:: Provide an algorithm for rotating hexagonal assemblies to equalize burnup
:id: I_ARMI_ROTATE_HEX_BURNUP
:implements: R_ARMI_ROTATE_HEX_BURNUP

Parameters
----------
a : Assembly object
The assembly that is being rotated.

aPrev : Assembly object
The assembly that previously occupied this location (before the last shuffle).

If the assembly "a" was not shuffled, then "aPrev" = "a".

If "aPrev" has pin detail, then we will determine the orientation of "a" based on
the pin powers of "aPrev" when it was located here.

If "aPrev" does NOT have pin detail, then we will determine the orientation of "a" based on
the corner fast fluxes in "aPrev" when it was located here.
If the assembly "a" was not shuffled, it's sufficient to pass ``a``.

Returns
-------
rot : int
An integer from 0 to 5 representing the "orientation" of the assembly.
This orientation is relative to the current assembly orientation.
rot = 0 corresponds to no rotation.
rot represents the number of pi/3 counterclockwise rotations for the default orientation.
int
An integer from 0 to 5 representing the number of pi/3 (60 degree) counterclockwise
rotations from where ``a`` is currently oriented to the "optimal" orientation

Examples
--------
>>> getOptimalAssemblyOrientation(a, aPrev)
4
Raises
------
ValueError
If there is insufficient information to determine the rotation of ``a``. This could
be due to a lack of fuel blocks or parameters like ``linPowByPin``.

See Also
--------
rotateAssemblies : calls this to figure out how to rotate
Notes
-----
Works by placing the highest-burnup pin in the location (of 6 possible locations) with lowest
expected pin power. We evaluated "expected pin power" based on the power distribution in
``aPrev``, the previous assembly located where ``a`` is going. The algorithm goes as follows.

1. Get all the pin powers and ``IndexLocation``s from the block at the previous location/timenode.
2. Obtain the ``IndexLocation`` of the pin with the highest burnup in the current assembly.
3. For each possible rotation,
- Find the new location with ``HexGrid.rotateIndex``
- Find the index where that location occurs in previous locations
- Find the previous power at that location
4. Return the rotation with the lowest previous power

This algorithm assumes a few things.

1. ``len(HexBlock.getPinCoordinates()) == len(HexBlock.p.linPowByPin)`` and,
by extension, ``linPowByPin[i]`` is found at ``getPinCoordinates()[i]``.
2. Your assembly has at least 60 degree symmetry of fuel pins and
powers. This means if we find a fuel pin and rotate it 60 degrees, there should
be another fuel pin at that lattice site. This is mostly a safe assumption
since many hexagonal reactors have at least 60 degree symmetry of fuel pin layout.
This assumption holds if you have a full hexagonal lattice of fuel pins as well.
3. Fuel pins in ``a`` have similar locations in ``aPrev``. This is mostly a safe
assumption in that most fuel assemblies have similar layouts so it's plausible
that if ``a`` has a fuel pin at ``(1, 0, 0)``, so does ``aPrev``.
"""
# determine whether or not aPrev had pin details
fuelPrev = aPrev.getFirstBlock(Flags.FUEL)
if fuelPrev:
aPrevDetailFlag = fuelPrev.p.pinLocation[4] is not None
maxBuBlock = maxBurnupBlock(a)
if maxBuBlock.spatialGrid is None:
msg = f"Block {maxBuBlock} in {a} does not have a spatial grid. Cannot rotate."
runLog.error(msg)
raise ValueError(msg)
maxBuPinLocation = maxBurnupLocator(maxBuBlock)
Comment on lines +94 to +95
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
raise ValueError(msg)
maxBuPinLocation = maxBurnupLocator(maxBuBlock)
raise ValueError(msg)
# 2. Obtain the ``IndexLocation`` of the pin with the highest burnup in the current assembly.
maxBuPinLocation = maxBurnupLocator(maxBuBlock)

Copy link
Member

Choose a reason for hiding this comment

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

We don't have to do this exactly, but there is a lot of stuff happening in this function, so I am just trying to make it a touch easier to read.

Your call.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If it's my call, I'm going to forgo this and similar requests.

I get readability through comments. I've also seen comments rot as the code changes while comments are not updated accordingly. So I'm inclined to have more verbose variable names and functions over comments. To hopefully help explain things.

maxBuPinLocation feels to me like a thing that tells us where the pin with the highest burnup can be found

It's very much a personal stance of mine that, if I'm left to decide, I'm going to lean on fewer comments but more descriptive docstrings and verbose variables/functions

Copy link
Member

Choose a reason for hiding this comment

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

Okay, that's fine.

That's not the M.O. for ARMI though. I don't particularly feel that docstrings are less likely to go out of date than comments. And without your docstring, this function's logic would be too complicated for someone to read at a glance. That's really my problem, I don't want some poor engineer to come to this function in 3 years and have to puzzle out this important logic on their own.

To that end, the docstring can stand as sufficient.

# No need to rotate if max burnup pin is the center
if maxBuPinLocation.i == 0 and maxBuPinLocation.j == 0:
return 0

if aPrev is not a:
blockAtPreviousLocation = aPrev[a.index(maxBuBlock)]
else:
aPrevDetailFlag = False

rot = 0 # default: no rotation
# First get pin index of maximum BU in this assembly.
_maxBuAssem, maxBuBlock = a.getMaxParam("percentBuMax", returnObj=True)
if maxBuBlock is None:
# no max block. They're all probably zero
return rot

# start at 0 instead of 1
maxBuPinIndexAssem = int(maxBuBlock.p.percentBuMaxPinLocation - 1)
bIndexMaxBu = a.index(maxBuBlock)

if maxBuPinIndexAssem == 0:
# Don't bother rotating if the highest-BU pin is the central pin. End this method.
return rot
else:
# transfer percentBuMax rotated pin index to non-rotated pin index
if aPrevDetailFlag:
# aPrev has pin detail
# Determine which of 6 possible rotated pin indices had the lowest power when aPrev was here.
prevAssemPowHereMIN = float("inf")

for possibleRotation in range(6):
index = getIndexOfRotatedCell(maxBuPinIndexAssem, possibleRotation)
# get pin power at this index in the previously assembly located here
# power previously at rotated index
prevAssemPowHere = aPrev[bIndexMaxBu].p.linPowByPin[index - 1]

if prevAssemPowHere is not None:
runLog.debug(
"Previous power in rotation {0} where pinLoc={1} is {2:.4E} W/cm"
"".format(possibleRotation, index, prevAssemPowHere)
)
if prevAssemPowHere < prevAssemPowHereMIN:
prevAssemPowHereMIN = prevAssemPowHere
rot = possibleRotation
else:
raise ValueError(
"Cannot perform detailed rotation analysis without pin-level "
"flux information."
)

runLog.debug("Best relative rotation is {0}".format(rot))
return rot
blockAtPreviousLocation = maxBuBlock

previousLocations = blockAtPreviousLocation.getPinLocations()
previousPowers = blockAtPreviousLocation.p.linPowByPin
if len(previousLocations) != len(previousPowers):
msg = (
f"Inconsistent pin powers and number of pins in {blockAtPreviousLocation}. "
f"Found {len(previousLocations)} locations but {len(previousPowers)} powers."
)
runLog.error(msg)
raise ValueError(msg)

ringPowers = {
(loc.i, loc.j): p for loc, p in zip(previousLocations, previousPowers)
}

targetGrid = blockAtPreviousLocation.spatialGrid
candidateRotation = 0
candidatePower = ringPowers.get((maxBuPinLocation.i, maxBuPinLocation.j), math.inf)
for rot in range(1, 6):
candidateLocation = targetGrid.rotateIndex(maxBuPinLocation, rot)
newPower = ringPowers.get((candidateLocation.i, candidateLocation.j), math.inf)
if newPower < candidatePower:
candidateRotation = rot
candidatePower = newPower
return candidateRotation


def buildRingSchedule(
Expand Down
Loading