6. Pin data, selection, and rotation

This tutorial is here to help make sense of how ARMI stores data on a Block for things that exist within the Block. For example, the parameter Block.p.linPowByPin is a (N, ) vector of linear pin powers with one entry per pin. You may be wondering

  1. Where do those powers exist in the block?

  2. What component produces those powers? linPowByPin is a Block parameter, not a Component parameter.

  3. What happens when the block is rotated?

By the end of this tutorial, these questions should be answered.

[1]:
import armi

if not armi.isConfigured():
    armi.configure()

       +===================================================+
       |            _      ____     __  __    ___          |
       |           / \    |  _ \   |  \/  |  |_ _|         |
       |          / _ \   | |_) |  | |\/| |   | |          |
       |         / ___ \  |  _ <   | |  | |   | |          |
       |        /_/   \_\ |_| \_\  |_|  |_|  |___|         |
       |        Advanced  Reactor  Modeling Interface      |
       |                                                   |
       |                    version 0.5.1                  |
       |                                                   |
       +===================================================+

6.1. Single pin demonstration

This tutorial uses the same anl-afci-177/anl-afci-177.yaml inputs that exist for the fast reactor example. We’ll start by initializing the reactor and grabbing a fuel block, it doens’t really matter what one. This reactor has a single fuel pin type which means we won’t immediately see interesting behavior, but it makes for easier discussion on the fundamentals. Towards the end, we’ll look at demonstrative assembly with multiple fuel pin types per block, a more realistic scenario.

[2]:
o = armi.init(fName="../anl-afci-177/anl-afci-177.yaml")
o.r.core.sortAssemsByRing()
=========== Settings Validation Checks ===========
=========== Case Information ===========
[info] ---------------------  --------------------------------------------------------------------------------
       Case Title:            anl-afci-177
       Case Description:      ANL-AFCI-177 CR 1.0 metal core but with HALEU instead of TRU
       Run Type:              Standard - Operator
       Current User:          anjohnson
       ARMI Location:         C:\Users\anjohnson\codes\armi\armi
       Working Directory:     c:\Users\anjohnson\codes\armi\armi\tests\anl-afci-177
       Python Interpreter:    3.13.3 (tags/v3.13.3:6280bb5, Apr  8 2025, 14:47:33) [MSC v.1943 64 bit (AMD64)]
       Python Executable:     c:\Users\anjohnson\codes\armi\.venv\armi\Scripts\python.exe
       Master Machine:        TP011870
       Number of Processors:  1
       Date and Time:         Tue Aug 12 15:18:24 2025
       ---------------------  --------------------------------------------------------------------------------
=========== Input File Information ===========
[info] --------------------------------------------------------------------  ------------------------------  ------------
       Input Type                                                            Path                            SHA-1 Hash
       --------------------------------------------------------------------  ------------------------------  ------------
       Case Settings                                                         anl-afci-177.yaml               93a5105368
       Blueprints                                                            anl-afci-177-blueprints.yaml    b7b2c74028
       Included blueprints                                                   anl-afci-177-coreMap.yaml       35ab97dadc
       <Setting shuffleLogic value:anl-afci-177-fuelManagement.py default:>  anl-afci-177-fuelManagement.py  baedb35785
       --------------------------------------------------------------------  ------------------------------  ------------
=========== Machine Information ===========
[info] ---------  ----------------------  -------
       Machine      Number of Processors    Ranks
       ---------  ----------------------  -------
       local                           1        0
       ---------  ----------------------  -------
=========== System Information ===========
[info] OS Name:                   Microsoft Windows 10 Enterprise
       OS Version:                10.0.19045 N/A Build 19045
       Processor(s):              1 Processor(s) Installed.
                                  [01]: Intel64 Family 6 Model 186 Stepping 2 GenuineIntel ~1425 Mhz
=========== Reactor Cycle Information ===========
[info] ---------------------------  -----------------------------------------------------------------
       Reactor Thermal Power (MW):  1000.0
       Number of Cycles:            10
       Cycle Lengths:               411.11, 411.11, 411.11, 411.11, 411.11, 411.11, 411.11, 411.11,
                                    411.11, 411.11
       Availability Factors:        0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9
       Power Fractions:             [1.0, 1.0], [1.0, 1.0], [1.0, 1.0], [1.0, 1.0], [1.0, 1.0], [1.0,
                                    1.0], [1.0, 1.0], [1.0, 1.0], [1.0, 1.0], [1.0, 1.0]
       Step Lengths (days):         [184.9995, 184.9995], [184.9995, 184.9995], [184.9995, 184.9995],
                                    [184.9995, 184.9995], [184.9995, 184.9995], [184.9995, 184.9995],
                                    [184.9995, 184.9995], [184.9995, 184.9995], [184.9995, 184.9995],
                                    [184.9995, 184.9995]
       ---------------------------  -----------------------------------------------------------------
=========== Constructing Reactor and Verifying Inputs ===========
[info] Constructing the `core`
=========== Adding Composites to <Core: core id:1902501464784> ===========
[info] Will expand HE, NA, AL, SI, V, CR, MN, FE, CO, NI, ZR, NB, MO, W elementals to have natural isotopics
[info] Constructing assembly `inner fuel`
[warn] Some component was missing in <reflector block-bol-000 at ExCore XS: A ENV GP: A> so pin-to-duct gap not calculated
[warn] The gap between wire wrap and clad in block <plenum block-bol-006 at ExCore XS: A ENV GP: A> was 3.999999999998449e-05 cm. Expected 0.0.
[info] Constructing assembly `middle core fuel`
[warn] Some component was missing in <reflector block-bol-000 at ExCore XS: B ENV GP: A> so pin-to-duct gap not calculated
[warn] The gap between wire wrap and clad in block <plenum block-bol-006 at ExCore XS: B ENV GP: A> was 3.999999999998449e-05 cm. Expected 0.0.
[info] Constructing assembly `outer core fuel`
[warn] Some component was missing in <reflector block-bol-000 at ExCore XS: C ENV GP: A> so pin-to-duct gap not calculated
[warn] The gap between wire wrap and clad in block <plenum block-bol-006 at ExCore XS: C ENV GP: A> was 3.999999999998449e-05 cm. Expected 0.0.
[info] Constructing assembly `radial reflector`
[warn] Some component was missing in <reflector block-bol-000 at ExCore XS: A ENV GP: A> so pin-to-duct gap not calculated
[info] Constructing assembly `radial shield`
[warn] Temperature 597.0 out of range (25 to 500) for B4C linear expansion percent
[info] Constructing assembly `control`
[info] Constructing assembly `ultimate shutdown`
=========== Verifying Assembly Configurations ===========
=========== Applying Geometry Modifications ===========
[info] Resetting the state of the converted reactor core model in <EdgeAssemblyChanger>
[info] Updating spatial grid pitch data for hex geometry
=========== Summarizing Source of Material Data for <Core: core id:1902501464784> ===========
[info] ---------------  -----------------
       Material Name    Source Location
       ---------------  -----------------
       B4C              ARMI
       HT9              ARMI
       Sodium           ARMI
       UZr              ARMI
       Void             ARMI
       ---------------  -----------------
=========== Initializing Mesh, Assembly Zones, and Nuclide Categories ===========
[info] Nuclide categorization for cross section temperature assignments:
       ------------------  -----------------------------------------------------
       Nuclide Category    Nuclides
       ------------------  -----------------------------------------------------
       Fuel                PU236, LFP40, HE4, LFP41, LFP38, DUMP1, AM241, NP237,
                           AM243, U236, PU242, U234, NB93, DUMP2, CM245, LFP39,
                           NP238, U238, ZR90, PU240, U235, ZR91, PU239,
                           CO59, PU238, CM243, CM244, PU241, ZR92, AM242M,
                           ZR96, CM242, CM246, LFP35, ZR94, CM247, AL27
       Coolant             NA23
       Structure           SI29, NI64, MO98, CR54, MO97, FE58, W182, B11, CR53,
                           NI61, MO92, MO94, FE54, SI28, NI62, CR52, MN55,
                           MO96, CR50, MO100, V51, MO95, SI30, NI60, V50,
                           NI58, FE56, C, W183, B10, W184, FE57, W186
       ------------------  -----------------------------------------------------
[info] Constructing the `Spent Fuel Pool`
[warn] Changing the name of the Spent Fuel Pool to 'sfp'.
=========== Creating Interfaces ===========
=========== Interface Stack Summary  ===========
[info] -------  ------------------------  ---------------  ----------  ---------  -----------  ------------
         Index  Type                      Name             Function    Enabled    EOL order    BOL forced
       -------  ------------------------  ---------------  ----------  ---------  -----------  ------------
            01  Main                      main                         Yes        Reversed     No
            02  FissionProductModel       fissionProducts              Yes        Normal       No
            03  FuelHandler               fuelHandler                  Yes        Normal       No
            04  CrossSectionGroupManager  xsGroups                     Yes        Normal       No
            05  HistoryTracker            history                      Yes        Normal       No
            06  Report                    report                       Yes        Normal       No
            07  Database                  database                     Yes        Normal       No
            08  MemoryProfiler            memoryProfiler               Yes        Normal       No
            09  Snapshot                  snapshot                     Yes        Normal       No
       -------  ------------------------  ---------------  ----------  ---------  -----------  ------------
===========  Triggering Init Event ===========
=========== 01 - main                           Init            ===========
=========== 02 - fissionProducts                Init            ===========
=========== 03 - fuelHandler                    Init            ===========
=========== 04 - xsGroups                       Init            ===========
=========== 05 - history                        Init            ===========
=========== 06 - report                         Init            ===========
=========== 07 - database                       Init            ===========
=========== 08 - memoryProfiler                 Init            ===========
=========== 09 - snapshot                       Init            ===========
===========  Completed Init Event ===========
[3]:
from armi.reactor.blocks import HexBlock
from armi.reactor.flags import Flags
[4]:
import numpy as np
[5]:
fuelBlock = o.r.core.getFirstBlock(Flags.FUEL)

Next, assign some power profile to the block. We’ll pick a 2D function p(x, y) = x + y for each pin centered at (x, y). This way, the rotation of the block be visible.

This introduces the first big point: pin-related data assigned as a block parameter must be ordered according to Block.getPinLocations(). That is the key connection between how data are ordered, where data exist in space, and what components are associated with those data.

[6]:
def setPinPow(b: HexBlock):
    """Fake a pin power p(x, y) = x + y."""
    pinPow = np.empty(b.getNumPins(), dtype=float)
    for ix, loc in enumerate(b.getPinLocations()):
        x, y, _z = loc.getLocalCoordinates()
        pinPow[ix] = x + y
    b.p.linPowByPin = pinPow
[7]:
setPinPow(fuelBlock)
[8]:
from matplotlib import pyplot

To demonstrate this, we’ll make a plot of the block-level pin powers by iterating jointly over the locations in Block.getPinLocations and scalar pin values in Block.p.linPowByPin. It’s not immediately useful because the function above already set that for us. But this will be helpful to show off rotation too.

[9]:
def plotPinPow(b: HexBlock, **kwargs):
    pinPows = b.p.linPowByPin
    xs: list[float] = []
    ys: list[float] = []
    ps: list[float] = []
    for ix, loc in enumerate(b.getPinLocations()):
        x, y, _z = loc.getLocalCoordinates()
        xs.append(x)
        ys.append(y)
        ps.append(pinPows[ix])
    # finely tuned scatter plot size to make nice images here
    kwargs.setdefault("s", 150)
    return pyplot.scatter(xs, ys, c=ps, **kwargs)
[10]:
plotPinPow(fuelBlock)
[10]:
<matplotlib.collections.PathCollection at 0x1baf72d38c0>
../_images/tutorials_pin-rotations_13_1.png

Unsurprisingly, we have a pin power profile that matches our p(x, y) = x + y. Pretty!

6.2. Rotation

HexBlock objects have an implemented .rotate method that supports CCW rotation in 60 degree increments. Before we rotate this block, make copies of the locations and pin power data arrays to compare before and after rotation.

[11]:
def getPinRingPos(b: HexBlock) -> np.ndarray[tuple[int, int], int]:
    locs = b.getPinLocations()
    allRingPos = [l.getRingPos() for l in locs]
    return np.array(allRingPos)
[12]:
ringPosBefore = getPinRingPos(fuelBlock)
pinPowerBefore = fuelBlock.p.linPowByPin.copy()
[13]:
import math

fuelBlock.rotate(math.pi)
[14]:
plotPinPow(fuelBlock)
[14]:
<matplotlib.collections.PathCollection at 0x1baf95134d0>
../_images/tutorials_pin-rotations_19_1.png

As expected, our pin powers have rotated 180 degrees, with the maxima now in the south west direction. So what changed: the locations of pins or the pin power data array?

This introduces the second key concept: with limited and documented exceptions, Block parameter data are not modified during rotation, the locations of objects within the Block are updated. See a discussion in

If we compare the post-rotation pin powers and pin locations, this is confirmed.

[15]:
assert (fuelBlock.p.linPowByPin == pinPowerBefore).all()
[16]:
assert (getPinRingPos(fuelBlock) != ringPosBefore).any()

6.3. Component-level powers

This gets a little trickier to explain because, in our example here, one fuel Component occupies the entire fuel lattice. Cases where that may not be the case can follow a similar pattern.

The connection between block level pin powers and the related components is the Circle.getPinIndices() method. For a block with N pins, a given pin component will have a multiplicity of M <= N. Circle.getPinIndices will return an (M, ) vector of integers that translate between the component and block level data.

For the k-th pin reflected in Circle, with 0 <= k < M, kx = Circle.getPinIndices()[k] is the index in parameters like Block.p.linPowByPin[kx] for that particular instance of the pin. And this k-th instance of the pin is spatially located in Block.getPinLocations()[kx].

To demonstrate, we’ll present the trivial case for a singular fuel Circle occupying every lattice site in the grid. Here, we would expect the .getIndices() to return what is essentially a numpy.arange vector, since every position [0, N) is held by this fuel pin.

[17]:
from armi.reactor.components import Circle
[18]:
fuelPin: Circle = fuelBlock.getComponent(Flags.FUEL)
[19]:
fpIndices = fuelPin.getPinIndices()
assert (fpIndices == np.arange(0, fuelPin.getDimension("mult"))).all(), fpIndices

To help illustrate how to map between component data -> block data -> spatial data, let’s plot pin power assigned to just this component.

[20]:
from matplotlib.colors import Normalize


def plotCompPinPow(c: Circle, **kwargs):
    blockLinPowByPin = c.parent.p.linPowByPin
    xs = []
    ys = []
    ps = []
    myIndices = c.getPinIndices()
    for k, loc in enumerate(c.spatialLocator):
        x, y, _z = loc.getLocalCoordinates()
        xs.append(x)
        ys.append(y)
        kx = myIndices[k]
        ps.append(blockLinPowByPin[kx])
    # normalize the color scheme against all the pin powers in the block
    # not just those for this pin
    norm = Normalize(vmin=blockLinPowByPin.min(), vmax=blockLinPowByPin.max())
    kwargs.setdefault("s", 150)
    return pyplot.scatter(xs, ys, c=ps, norm=norm, **kwargs)
[21]:
plotCompPinPow(fuelPin)
[21]:
<matplotlib.collections.PathCollection at 0x1baf958fc50>
../_images/tutorials_pin-rotations_29_1.png

And one more 60 degree CCW rotation for good measure.

[22]:
fuelBlock.rotate(math.pi / 3)
[23]:
plotCompPinPow(fuelPin)
[23]:
<matplotlib.collections.PathCollection at 0x1baf95f79d0>
../_images/tutorials_pin-rotations_32_1.png

6.4. Multi-pin example

For the last example, we’ll define a semi-covoluted but demonstrative block that has two fuel pin types existing on the same lattice grid. The use of yaml anchors &, aliases *, and merge keys <<. This helps use similar fuel and clad definitions (e.g., material, dimension) but overwrite things like latticeIDs and flags that we want to be specific to each fuel pin type.

[24]:
BP_STR = """
blocks:
    fuel: &fuel_block
        grid name: fuel grid
        fuel 1: &fuel_def
            shape: Circle
            # Use void material because we don't need nuclides, just components with flags
            material: Void
            od: 0.68
            Tinput: 25
            Thot: 600
            latticeIDs: [1]
            flags: primary fuel
        clad 1: &clad_def
            shape: Circle
            material: Void
            id: 0.7
            od: 0.71
            Tinput: 600
            Thot: 450
            latticeIDs: [1]
        fuel 2:
            <<: *fuel_def
            latticeIDs: [2]
            flags: secondary fuel
        clad 2:
            <<: *clad_def
            latticeIDs: [2]
        duct:
            shape: Hexagon
            material: Void
            Tinput: 25
            Thot: 450
            ip: 15.3
            op: 16
grids:
    fuel grid:
        geom: hex_corners_up
        symmetry: full
        # Kind of a convoluted map but helps test a lot of edge conditions
        lattice map: |
            - - -  1 1 1 1
              - - 1 1 1 1 1
               - 1 1 2 2 1 1
                1 1 2 1 2 1 1
                 1 1 2 2 1 1
                  1 1 1 1 1
                   1 2 1 1
# Stuff that isn't germane to this example, but necessary to make the blueprints build correctly
assemblies:
    fuel:
        specifier: F
        blocks: [*fuel_block]
        height: [10]
        axial mesh points: [1]
        xs types: [A]
nuclide flags:
"""
[25]:
from armi.reactor.blueprints import Blueprints
from armi.settings import Settings
[26]:
def buildMultiPinBlock() -> HexBlock:
    cs = Settings()
    bp = Blueprints.load(BP_STR)
    bp._prepConstruction(cs)
    block = bp.blockDesigns["fuel"].construct(cs, bp, 0, 2, 10, "A", {})
    block.assignPinIndices()
    setPinPow(block)
    return block
[27]:
multiPinBlock = buildMultiPinBlock()
[info] Will expand HE, NA, AL, SI, V, CR, MN, FE, CO, NI, ZR, NB, MO, W elementals to have natural isotopics
[info] Constructing assembly `fuel`
[info] Block design <fuel block-bol-000 at ExCore XS: A ENV GP: A> is too complicated to verify dimensions. Make sure they are correct!
=========== Verifying Assembly Configurations ===========
[info] Block design <fuel block-bol-000 at ExCore XS: A ENV GP: A> is too complicated to verify dimensions. Make sure they are correct!

Plotting our block-level pin power shows a similar profile to before.

[28]:
plotPinPow(multiPinBlock)
[28]:
<matplotlib.collections.PathCollection at 0x1bafa69ca50>
../_images/tutorials_pin-rotations_39_1.png
[29]:
primaryFuel: Circle = multiPinBlock.getComponent(Flags.PRIMARY)
primaryFuel.getPinIndices()
[29]:
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16,
       17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29], dtype=uint16)

The ordering is worth discussing. primaryFuel.getPinIndices() being sequential [0, 29] would imply, at first, that all the primaryFuel pins reside in some sequence adjacent to each other. However, the lattice map has primaryFuel in the center of the block, and then in the second and third full rings. This ordering is still consistent with Block.getPinLocations and is a side-effect of

  1. How the hexagonal ascii maps are processed,

  2. How pin locations are discovered within a block,

    • For each clad component, extend it’s spatial locators

We can see the first “pin” location in our block is not the center, but the north west pin in the block.

[30]:
assert multiPinBlock.getPinLocations()[0].getRingPos() == (4, 4)
[31]:
secondaryFuel: Circle = multiPinBlock.getComponent(Flags.SECONDARY)
secondaryFuel.getPinIndices()
[31]:
array([30, 31, 32, 33, 34, 35, 36], dtype=uint16)

The component level pin plotter shows that we can still collect the same power profile by connecting

  1. Block.getPinLocations

  2. Block.p.linPowByPin

  3. Circle.getPinIndices

[32]:
plotCompPinPow(primaryFuel, marker="s", label="primary")
plotCompPinPow(secondaryFuel, marker="o", label="secondary")
pyplot.legend()
[32]:
<matplotlib.legend.Legend at 0x1baf72d34d0>
../_images/tutorials_pin-rotations_45_1.png

Rotate 60 degrees CCW or pi/3

[33]:
multiPinBlock.rotate(math.pi / 3)
[34]:
plotCompPinPow(primaryFuel, marker="s", label="primary")
plotCompPinPow(secondaryFuel, marker="o", label="secondary")
pyplot.legend()
[34]:
<matplotlib.legend.Legend at 0x1baf73611d0>
../_images/tutorials_pin-rotations_48_1.png

6.5. Bringing it all together.

Pin-like parameters are ordered by a pin-index, not strictly a spatial ordering. Therefore they are invariant of rotation; Block.p.linPowByPin[i] is the linear power for pin i, wherever it may be in the block.

Without looking into the components, pin i is located at Block.getPinLocations()[i]. If the block is rotated, the locator Block.getPinLocations()[i] will indicate a new location, but it still represents pin i.