Repository: terrapower/armi

Issue:
Location of pins in tips-up hexagonal grid is off by 60 degrees
I have a tips up grid with fuel pins and one non-fuel pin on a tips-up hexagonal grid. The lattice map is
```yaml
  pins:
    geom: hex_corners_up
    symmetry: full
    lattice map: |
      - - O F F
       - F F F F
        F F F F F
         F F F F
          F F F
```
where `O` is the non-fuel and `F` is the fuel pin.

As I read it, the non-fuel pin should be in the top left of the hexagonal grid.  But, the locator attached to that component has a `getLocalCoordinates` of `[-2.0, 0, 0]`, which implies it is located at due-left, 60 degrees off of where it should be.

<details><summary>Blueprint</summary>

```yaml
blocks:
  fuel: &block_fuel
    grid name: pins
    duct:
      shape: Hexagon
      material: HT9
      Tinput: 450.0
      Thot: 450.0
      ip: 16
      op: 16.5
    coolant:
      shape: DerivedShape
      material: Sodium
      Tinput: 600.0
      Thot: 600.0
    fuel:
      shape: Circle
      material: UZr
      Tinput: 900
      Thot: 900
      od: 1
      latticeIDs: [F]
    other:
      shape: Circle
      material: HT9
      od: 1
      Tinput: 900
      Thot: 900
      latticeIDs: [O]
assemblies:
  fuel:
    specifier: F
    height: [10]
    axial mesh points: [1]
    blocks: [*block_fuel]
    xs types: [A]
systems:
  core:
    grid name: core
    origin:
      x: 0.0
      y: 0.0
      z: 0.0
grids:
  core:
    geom: hex
    symmetry: full
    lattice map: |
      F
  pins:
    geom: hex_corners_up
    symmetry: full
    lattice map: |
      - - O F F
       - F F F F
        F F F F F
         F F F F
          F F F
nuclide flags:
  NA: &nuc_flags {burn: false, xs: true}
  U: *nuc_flags
  ZR: *nuc_flags
  MO: *nuc_flags
  MN: *nuc_flags
  SI: *nuc_flags
  CR: *nuc_flags
  V: *nuc_flags
  C: *nuc_flags
  FE: *nuc_flags
  W: *nuc_flags
  NI: *nuc_flags
```

</details> 

<details><summary>Processing script</summary>

```python
import armi

armi.configure()

o = armi.init(fName="sample.yaml")

b = o.r.core[0][0]

for child in b:
    locator = child.spatialLocator
    if locator is None:
        continue
    if len(locator) == 1:
        print(child, locator[0].getLocalCoordinates())
        break
```

</details>

The asciimap testing shows it's being read in properly
https://github.com/terrapower/armi/blob/328458cd62c1d4c6459ebcee1f2f6af10b6fb859/armi/utils/tests/test_asciimaps.py#L112-L131

https://github.com/terrapower/armi/blob/328458cd62c1d4c6459ebcee1f2f6af10b6fb859/armi/utils/tests/test_asciimaps.py#L312-L318

with `asciimap[-9, 0] == "7"` being the location due-left (or negative x axis). But this doesn't seem to be sent to the grid properly

Discussion:
john-science: @keckler I took a look at the first part of Drew's issue:

https://github.com/terrapower/armi/issues/1278#issuecomment-1959940388

I'm not sure what to make of it yet.
john-science: I still haven't resolved the "60-degree rotation" of the hex grid stated in the original comment:

https://github.com/terrapower/armi/issues/1278#issuecomment-1959940388

Mind you, that original ticket had 6 major complaints, and I sorted 5 of them. But, I figure, I'll convert this PR to draft and try to solve the last concern.

Patch:
diff --git a/README.rst b/README.rst
index d689dd0f..b7b03454 100644
--- a/README.rst
+++ b/README.rst
@@ -68,17 +68,21 @@ commands. You probably want to do this in a virtual environment as described in
 documentation <https://terrapower.github.io/armi/installation.html>`_. Otherwise, the
 dependencies could conflict with your system dependencies.
 
-::
+First, upgrade your version of pip::
+
+    $ pip install pip>=22.1
+
+Now clone and install ARMI::
 
     $ git clone https://github.com/terrapower/armi
     $ cd armi
     $ pip install -e .
-    $ armi
+    $ armi --help
 
 The easiest way to run the tests is to install `tox <https://tox.readthedocs.io/en/latest/>`_
 and then run::
 
-    $ pip install -e .[test]
+    $ pip install -e ".[test]"
     $ tox -- -n 6
 
 This runs the unit tests in parallel on 6 processes. Omit the ``-n 6`` argument
@@ -86,7 +90,7 @@ to run on a single process.
 
 The tests can also be run directly, using ``pytest``::
 
-    $ pip install -e .[test]
+    $ pip install -e ".[test]"
     $ pytest -n 4 armi
 
 From here, we recommend going through a few of our `gallery examples
diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py
index 8fd59963..46524a54 100644
--- a/armi/reactor/blocks.py
+++ b/armi/reactor/blocks.py
@@ -2286,7 +2286,7 @@ class HexBlock(Block):
         # note that it's the pointed end of the cell hexes that are up (but the
         # macro shape of the pins forms a hex with a flat top fitting in the assembly)
         grid = grids.HexGrid.fromPitch(
-            self.getPinPitch(cold=True), numRings=0, pointedEndUp=True
+            self.getPinPitch(cold=True), numRings=0, cornersUp=True
         )
         spatialLocators = grids.MultiIndexLocation(grid=self.spatialGrid)
         numLocations = 0
diff --git a/armi/reactor/blueprints/gridBlueprint.py b/armi/reactor/blueprints/gridBlueprint.py
index ce133a67..bb6dba98 100644
--- a/armi/reactor/blueprints/gridBlueprint.py
+++ b/armi/reactor/blueprints/gridBlueprint.py
@@ -318,7 +318,7 @@ class GridBlueprint(yamlize.Object):
             spatialGrid = grids.HexGrid.fromPitch(
                 pitch,
                 numRings=maxIndex + 2,
-                pointedEndUp=geom == geometry.HEX_CORNERS_UP,
+                cornersUp=geom == geometry.HEX_CORNERS_UP,
             )
         elif geom == geometry.CARTESIAN:
             # if full core or not cut-off, bump the first assembly from the center of
diff --git a/armi/reactor/grids/hexagonal.py b/armi/reactor/grids/hexagonal.py
index 67ba13f0..d3c75a54 100644
--- a/armi/reactor/grids/hexagonal.py
+++ b/armi/reactor/grids/hexagonal.py
@@ -30,7 +30,7 @@ from armi.reactor.grids.structuredGrid import StructuredGrid
 
 COS30 = sqrt(3) / 2.0
 SIN30 = 1.0 / 2.0
-# going CCW from "position 1" (top right)
+# going counter-clockwise from "position 1" (top right)
 TRIANGLES_IN_HEXAGON = numpy.array(
     [
         (+COS30, SIN30),
@@ -44,7 +44,7 @@ TRIANGLES_IN_HEXAGON = numpy.array(
 
 
 class HexGrid(StructuredGrid):
-    """
+    r"""
     Has 6 neighbors in plane.
 
     It is recommended to use :meth:`fromPitch` rather than calling the ``__init__``
@@ -63,25 +63,59 @@ class HexGrid(StructuredGrid):
 
     Notes
     -----
-    In an axial plane (i, j) are as follows (second one is pointedEndUp)::
-
-
-                    ( 0, 1)
-             (-1, 1)       ( 1, 0)
-                    ( 0, 0)
-             (-1, 0)       ( 1,-1)
-                    ( 0,-1)
+    In an axial plane (i, j) are as follows (flats up)::
+             _____
+            /     \
+      _____/  0,1  \_____
+     /     \       /     \
+    / -1,1  \_____/  1,0  \
+    \       /     \       /
+     \_____/  0,0  \_____/
+     /     \       /     \
+    / -1,0  \_____/  1,-1 \
+    \       /     \       /
+     \_____/  0,-1 \_____/
+           \       /
+            \_____/
+
+    In an axial plane (i, j) are as follows (corners up)::
+
+           / \     / \
+         /     \ /     \
+        |  0,1  |  1,0  |
+        |       |       |
+       / \     / \     / \
+     /     \ /     \ /     \
+    | -1,1  |  0,0  |  1,-1 |
+    |       |       |       |
+     \     / \     / \     /
+       \ /     \ /     \ /
+        | -1,0  |  0,-1 |
+        |       |       |
+         \     / \     /
+           \ /     \ /
+
+    Basic hexagon geometry::
+
+    - pitch = sqrt(3) * side
+    - long diagonal = 2 * side
+    - Area = (sqrt(3) / 4) * side^2
+    - perimeter = 6 * side
 
+    """
 
-                ( 0, 1) ( 1, 0)
-
-            (-1, 1) ( 0, 0) ( 1,-1)
+    @property
+    def cornersUp(self) -> bool:
+        """
+        Check whether the hexagonal grid is "corners up" or "flats up".
 
-                (-1, 0) ( 0,-1)
-    """
+        See the armi.reactor.grids.HexGrid class documentation for an
+        illustration of the two types of grid indexing.
+        """
+        return self._unitSteps[0][1] != 0.0
 
     @staticmethod
-    def fromPitch(pitch, numRings=25, armiObject=None, pointedEndUp=False, symmetry=""):
+    def fromPitch(pitch, numRings=25, armiObject=None, cornersUp=False, symmetry=""):
         """
         Build a finite step-based 2-D hex grid from a hex pitch in cm.
 
@@ -116,9 +150,9 @@ class HexGrid(StructuredGrid):
         armiObject : ArmiObject, optional
             The object that this grid is anchored to (i.e. the reactor for a grid of
             assemblies)
-        pointedEndUp : bool, optional
-            Rotate the hexagons 30 degrees so that the pointed end faces up instead of
-            the flat.
+        cornersUp : bool, optional
+            Rotate the hexagons 30 degrees so that the corners point up instead of
+            the flat faces.
         symmetry : string, optional
             A string representation of the symmetry options for the grid.
 
@@ -127,27 +161,15 @@ class HexGrid(StructuredGrid):
         HexGrid
             A functional hexagonal grid object.
         """
-        side = hexagon.side(pitch)
-        if pointedEndUp:
-            # rotated 30 degrees CCW from normal
-            # increases in i move you in x and y
-            # increases in j also move you in x and y
-            unitSteps = (
-                (pitch / 2.0, -pitch / 2.0, 0),
-                (1.5 * side, 1.5 * side, 0),
-                (0, 0, 0),
-            )
-        else:
-            # x direction is only a function of i because j-axis is vertical.
-            # y direction is a function of both.
-            unitSteps = ((1.5 * side, 0.0, 0.0), (pitch / 2.0, pitch, 0.0), (0, 0, 0))
+        unitSteps = HexGrid._getRawUnitSteps(pitch, cornersUp)
 
-        return HexGrid(
+        hex = HexGrid(
             unitSteps=unitSteps,
             unitStepLimits=((-numRings, numRings), (-numRings, numRings), (0, 1)),
             armiObject=armiObject,
             symmetry=symmetry,
         )
+        return hex
 
     @property
     def pitch(self) -> float:
@@ -158,7 +180,7 @@ class HexGrid(StructuredGrid):
         --------
         armi.reactor.grids.HexGrid.fromPitch
         """
-        return self._unitSteps[1][1]
+        return sqrt(self._unitSteps[0][0] ** 2 + self._unitSteps[1][0] ** 2)
 
     @staticmethod
     def indicesToRingPos(i: int, j: int) -> Tuple[int, int]:
@@ -221,7 +243,7 @@ class HexGrid(StructuredGrid):
         Return the indices of the immediate neighbors of a mesh point in the plane.
 
         Note that these neighbors are ordered counter-clockwise beginning from the
-        30 or 60 degree direction. Exact direction is dependent on pointedEndUp arg.
+        30 or 60 degree direction. Exact direction is dependent on cornersUp arg.
         """
         return [
             (i + 1, j, k),
@@ -248,20 +270,42 @@ class HexGrid(StructuredGrid):
 
     @staticmethod
     def _indicesAndEdgeFromRingAndPos(ring, position):
+        """Given the ring and position, return the (I,J) coordinates, and which edge the grid
+        cell is on.
+
+        Parameters
+        ----------
+        ring : int
+            Starting with 1 (not zero), the ring of the grid cell.
+        position : int
+            Starting with 1 (not zero), the position of the grid cell, in the ring.
+
+        Returns
+        -------
+        (int, int, int) : I coordinate, J coordinate, which edge of the hex ring
+
+        Notes
+        -----
+        - Edge indicates which edge of the ring in which the hexagon resides.
+        - Edge 0 is the NE edge, edge 1 is the N edge, etc.
+        - Offset is (0-based) index of the hexagon in that edge. For instance,
+          ring 3, pos 12 resides in edge 5 at index 1; it is the second hexagon
+          in ring 3, edge 5.
+        """
+        # The inputs start counting at 1, but the grid starts counting at zero.
         ring = ring - 1
         pos = position - 1
 
+        # Handle the center grid cell.
         if ring == 0:
             if pos != 0:
                 raise ValueError(f"Position in center ring must be 1, not {position}")
             return 0, 0, 0
 
-        # Edge indicates which edge of the ring in which the hexagon resides.
-        # Edge 0 is the NE edge, edge 1 is the N edge, etc.
-        # Offset is (0-based) index of the hexagon in that edge. For instance,
-        # ring 3, pos 12 resides in edge 5 at index 1; it is the second hexagon
-        # in ring 3, edge 5.
-        edge, offset = divmod(pos, ring)  # = pos//ring, pos%ring
+        # find the edge and offset (pos//ring or pos%ring)
+        edge, offset = divmod(pos, ring)
+
+        # find (I,J) based on the ring, edge, and offset
         if edge == 0:
             i = ring - offset
             j = offset
@@ -270,9 +314,9 @@ class HexGrid(StructuredGrid):
             j = ring
         elif edge == 2:
             i = -ring
-            j = -offset + ring
+            j = ring - offset
         elif edge == 3:
-            i = -ring + offset
+            i = offset - ring
             j = -offset
         elif edge == 4:
             i = offset
@@ -281,13 +325,47 @@ class HexGrid(StructuredGrid):
             i = ring
             j = offset - ring
         else:
-            raise ValueError(
-                "Edge {} is invalid. From ring {}, pos {}".format(edge, ring, pos)
-            )
+            raise ValueError(f"Edge {edge} is invalid. From ring {ring}, pos {pos}")
+
         return i, j, edge
 
     @staticmethod
     def getIndicesFromRingAndPos(ring: int, pos: int) -> IJType:
+        r"""Given the ring and position, return the (I,J) coordinates in the hex grid.
+
+        Parameters
+        ----------
+        ring : int
+            Starting with 1 (not zero), the ring of the grid cell.
+        position : int
+            Starting with 1 (not zero), the position of the grid cell, in the ring.
+
+        Returns
+        -------
+        (int, int) : I coordinate, J coordinate
+
+        Notes
+        -----
+        In an axial plane, the (ring, position) coordinates are as follows::
+
+             Flat-to-Flat                    Corners Up
+                 _____
+                /     \                      / \     / \
+          _____/  2,2  \_____              /     \ /     \
+         /     \       /     \            |  2,2  |  2,1  |
+        /  2,3  \_____/  2,1  \           |       |       |
+        \       /     \       /          / \     / \     / \
+         \_____/  1,1  \_____/         /     \ /     \ /     \
+         /     \       /     \        |  2,3  |  1,1  |  2,6  |
+        /  2,4  \_____/  2,6  \       |       |       |       |
+        \       /     \       /        \     / \     / \     /
+         \_____/  2,5  \_____/           \ /     \ /     \ /
+               \       /                  |  2,4  |  2,5  |
+                \_____/                   |       |       |
+                                           \     / \     /
+                                             \ /     \ /
+
+        """
         i, j, _edge = HexGrid._indicesAndEdgeFromRingAndPos(ring, pos)
         return i, j
 
@@ -341,16 +419,16 @@ class HexGrid(StructuredGrid):
         return symmetryLine
 
     def getSymmetricEquivalents(self, indices: IJKType) -> List[IJType]:
-        """Retrieve the equivalent indices; return them as-is if this is full core, but
-        return the symmetric equivalent if this is a 1/3-core grid.
+        """Retrieve the equivalent indices. If full core return nothing, if 1/3-core grid,
+        return the symmetric equivalents, if any other grid, raise an error.
 
         .. impl:: Equivalent contents in 1/3-core geometries are retrievable.
             :id: I_ARMI_GRID_EQUIVALENTS
             :implements: R_ARMI_GRID_EQUIVALENTS
 
             This method takes in (I,J,K) indices, and if this ``HexGrid`` is full core,
-            it returns them as-is. If this ``HexGrid`` is 1/3-core, this method will
-            return the 1/3-core symmetric equivalent. If this grid is any other kind,
+            it returns nothing. If this ``HexGrid`` is 1/3-core, this method will return
+            the 1/3-core symmetric equivalent of just (I,J). If this grid is any other kind,
             this method will just return an error; a hexagonal grid with any other
             symmetry is probably an error.
         """
@@ -370,9 +448,7 @@ class HexGrid(StructuredGrid):
 
     @staticmethod
     def _getSymmetricIdenticalsThird(indices) -> List[IJType]:
-        """This works by rotating the indices by 120 degrees twice,
-        counterclockwise.
-        """
+        """This works by rotating the indices by 120 degrees twice, counterclockwise."""
         i, j = indices[:2]
         if i == 0 and j == 0:
             return []
@@ -390,12 +466,42 @@ class HexGrid(StructuredGrid):
         scale = self.pitch / 3.0
         return xy + scale * TRIANGLES_IN_HEXAGON
 
+    @staticmethod
+    def _getRawUnitSteps(pitch, cornersUp=False):
+        """Get the raw unit steps (ignore step dimensions), for a hex grid.
+
+        Parameters
+        ----------
+        pitch : float
+            The short diameter of the hexagons (flat to flat).
+        cornersUp : bool, optional
+            If True, the hexagons have a corner pointing in the Y direction. Default: False
+
+        Returns
+        -------
+        tuple : The full 3D set of derivatives of X,Y,Z in terms of i,j,k.
+        """
+        side = hexagon.side(pitch)
+        if cornersUp:
+            # rotated 30 degrees counter-clockwise from normal
+            # increases in i moves you in x and y
+            # increases in j also moves you in x and y
+            unitSteps = (
+                (pitch / 2.0, -pitch / 2.0, 0),
+                (1.5 * side, 1.5 * side, 0),
+                (0, 0, 0),
+            )
+        else:
+            # x direction is only a function of i because j-axis is vertical.
+            # y direction is a function of both.
+            unitSteps = ((1.5 * side, 0.0, 0.0), (pitch / 2.0, pitch, 0.0), (0, 0, 0))
+
+        return unitSteps
+
     def changePitch(self, newPitchCm: float):
         """Change the hex pitch."""
-        side = hexagon.side(newPitchCm)
-        self._unitSteps = numpy.array(
-            ((1.5 * side, 0.0, 0.0), (newPitchCm / 2.0, newPitchCm, 0.0), (0, 0, 0))
-        )[self._stepDims]
+        unitSteps = numpy.array(HexGrid._getRawUnitSteps(newPitchCm, self.cornersUp))
+        self._unitSteps = unitSteps[self._stepDims]
 
     def locatorInDomain(self, locator, symmetryOverlap: Optional[bool] = False) -> bool:
         # This will include the "top" 120-degree symmetry lines. This is to support
diff --git a/armi/reactor/grids/structuredGrid.py b/armi/reactor/grids/structuredGrid.py
index 420a83f9..81d74add 100644
--- a/armi/reactor/grids/structuredGrid.py
+++ b/armi/reactor/grids/structuredGrid.py
@@ -58,7 +58,7 @@ class StructuredGrid(Grid):
         variety of geometries, including hexagonal and Cartesian.  The tuples are not
         vectors in the direction of the translation, but rather grouped by direction. If
         the bounds argument is described for a direction, the bounds will be used rather
-        than the unit step information.  The default of (0, 0, 0) makes all dimensions
+        than the unit step information. The default of (0, 0, 0) makes all dimensions
         insensitive to indices since the coordinates are calculated by the dot product
         of this and the indices.  With this default, any dimension that is desired to
         change with indices should be defined with bounds. RZtheta grids are created
@@ -73,7 +73,7 @@ class StructuredGrid(Grid):
         grids to be finite so we can populate them with SpatialLocator objects.
     offset : 3-tuple, optional
         Offset in cm for each axis. By default the center of the (0,0,0)-th object is in
-        the center of the grid.  Offsets can move it so that the (0,0,0)-th object can
+        the center of the grid. Offsets can move it so that the (0,0,0)-th object can
         be fully within a quadrant (i.e. in a Cartesian grid).
     armiObject : ArmiObject, optional
         The ArmiObject that this grid describes. For example if it's a 1-D assembly
diff --git a/armi/utils/asciimaps.py b/armi/utils/asciimaps.py
index 127dc6af..20cee223 100644
--- a/armi/utils/asciimaps.py
+++ b/armi/utils/asciimaps.py
@@ -40,7 +40,6 @@ i, j
     but in other geometries (like hex), it is a totally different
     coordinate system.
 
-
 See Also
 --------
 armi.reactor.grids : More powerful, nestable lattices with specific dimensions
@@ -97,8 +96,21 @@ class AsciiMap:
 
     def writeAscii(self, stream):
         """Write out the ascii representation."""
+        stream.write(self.__str__())
+
+    def __str__(self):
+        """Build the human-readable ASCII string representing the lattice map.
+
+        This method is useful for quickly printing out a lattice map.
+
+        Returns
+        -------
+        str : The custom ARMI ASCII-art-style string representing the map.
+        """
+        # Do some basic validation
         if not self.asciiLines:
-            raise ValueError("Cannot write ASCII map before ASCII lines are processed")
+            raise ValueError("Cannot write ASCII map before ASCII lines are processed.")
+
         if len(self.asciiOffsets) != len(self.asciiLines):
             runLog.error(f"AsciiLines: {self.asciiLines}")
             runLog.error(f"Offsets: {self.asciiOffsets}")
@@ -106,17 +118,27 @@ class AsciiMap:
                 f"Inconsistent lines ({len(self.asciiLines)}) "
                 f"and offsets ({len(self.asciiOffsets)})"
             )
+
+        # Finally, build the string representation.
+        txt = ""
         fmt = f"{{val:{len(self._placeholder)}s}}"
         for offset, line in zip(self.asciiOffsets, self.asciiLines):
             data = [fmt.format(val=v) for v in line]
             line = self._spacer * offset + self._spacer.join(data) + "\n"
-            stream.write(line)
+            txt += line
+
+        return txt
 
     def readAscii(self, text):
         """
         Read ascii representation from a stream.
 
         Update placeholder size according to largest thing read.
+
+        Parameters
+        ----------
+        text : str
+            Custom string that describes the ASCII map of the core.
         """
         text = text.strip().splitlines()
 
@@ -127,6 +149,7 @@ class AsciiMap:
             self.asciiLines.append(columns)
             if len(columns) > self._asciiMaxCol:
                 self._asciiMaxCol = len(columns)
+
         self._asciiMaxLine = li + 1
         self._updateDimensionsFromAsciiLines()
         self._asciiLinesToIndices()
@@ -212,10 +235,10 @@ class AsciiMap:
                 # if entire newline is wiped out, it's a full row of placeholders!
                 # but oops this actually still won't work. Needs more work when
                 # doing pure rows from data is made programmatically.
-                # newLines.append(line)
                 raise ValueError(
                     "Cannot write asciimaps with blank rows from pure data yet."
                 )
+
         if not newLines:
             raise ValueError("No data found")
         self.asciiLines = newLines
@@ -251,6 +274,7 @@ class AsciiMap:
 
     def _makeOffsets(self):
         """Build offsets."""
+        raise NotImplementedError
 
     def items(self):
         return self.asciiLabelByIndices.items()
@@ -296,7 +320,7 @@ class AsciiMapCartesian(AsciiMap):
 
     def _makeOffsets(self):
         """Cartesian grids have 0 offset on all lines."""
-        AsciiMap._makeOffsets(self)
+        self.asciiOffsets = []
         for _line in self.asciiLines:
             self.asciiOffsets.append(0)
 
@@ -308,9 +332,9 @@ class AsciiMapHexThirdFlatsUp(AsciiMap):
     """
     Hex ascii map for 1/3 core flats-up map.
 
-    Indices start with (0,0) in the bottom left (origin).
-    i increments on the 30-degree ray
-    j increments on the 90-degree ray
+    - Indices start with (0,0) in the bottom left (origin).
+    - i increments on the 30-degree ray
+    - j increments on the 90-degree ray
 
     In all flats-up hex maps, i increments by 2*col for each col
     and j decrements by col from the base.
@@ -319,7 +343,6 @@ class AsciiMapHexThirdFlatsUp(AsciiMap):
     there are 2 ascii lines for every j index (jaggedly).
 
     Lines are read from the bottom of the ascii map up in this case.
-
     """
 
     def _asciiLinesToIndices(self):
@@ -396,7 +419,7 @@ class AsciiMapHexThirdFlatsUp(AsciiMap):
 
         # renomalize the offsets to start at 0
         minOffset = min(self.asciiOffsets)
-        for li, (_, offset) in enumerate(zip(self.asciiLines, self.asciiOffsets)):
+        for offset in self.asciiOffsets:
             newOffsets.append(offset - minOffset)
         self.asciiOffsets = newOffsets
 
@@ -527,20 +550,16 @@ class AsciiMapHexFullTipsUp(AsciiMap):
     """
     Full hex with tips up of the smaller cells.
 
-    I axis is pure horizontal here
-    J axis is 60 degrees up. (upper right corner)
-
-    (0,0) is in the center of the hexagon.
+    - I axis is pure horizontal here
+    - J axis is 60 degrees up. (upper right corner)
+    - (0,0) is in the center of the hexagon.
 
     Frequently used for pins inside hex assemblies.
 
-    This does not currently support omitted positions on
-    the hexagonal corners.
+    This does not currently support omitted positions on the hexagonal corners.
 
-    In this geometry, the outline-defining _ijMax is equal
-    to I at the far right of the hex. Thus, ijMax represents
-    the number of positions from the center to the outer edge
-    towards any of the 6 corners.
+    In this geometry, the outline-defining _ijMax is equal to I at the far right of the hex. Thus, ijMax represents the
+    number of positions from the center to the outer edge towards any of the 6 corners.
     """
 
     def _asciiLinesToIndices(self):
@@ -560,7 +579,7 @@ class AsciiMapHexFullTipsUp(AsciiMap):
 
         Indices simply increment from the base across the rows.
         """
-        return iBase + columnNum, jBase
+        return iBase + columnNum + jBase, -(iBase + columnNum)
 
     def _getIJFromColRow(self, columnNum, lineNum):
         """
@@ -568,8 +587,7 @@ class AsciiMapHexFullTipsUp(AsciiMap):
 
         Notes
         -----
-        Not used in reading from file b/c inefficient/repeated base calc
-        but required for writing from ij data
+        Not used in reading from file b/c inefficient/repeated base calc but required for writing from ij data.
         """
         iBase, jBase = self._getIJBaseByAsciiLine(lineNum)
         return self._getIJFromColAndBase(columnNum, iBase, jBase)
@@ -580,8 +598,7 @@ class AsciiMapHexFullTipsUp(AsciiMap):
 
         Upper left is shifted by (size-1)//2
 
-        for a 19-line grid, we have the top left as (-18,9)
-        and then: (-17, 8), (-16, 7), ...
+        for a 19-line grid, we have the top left as (-18,9) and then: (-17, 8), (-16, 7), ...
         """
         shift = self._ijMax
         iBase = -shift * 2 + asciiLineNum
@@ -590,8 +607,7 @@ class AsciiMapHexFullTipsUp(AsciiMap):
 
     def _updateDimensionsFromAsciiLines(self):
         """Update dimension metadata when reading ascii."""
-        # ijmax here can be inferred directly from the max number of columns
-        # in the asciimap text
+        # ijmax here can be inferred directly from the max number of columns in the asciimap text
         self._ijMax = (self._asciiMaxCol - 1) // 2
 
     def _updateDimensionsFromData(self):
@@ -610,7 +626,7 @@ class AsciiMapHexFullTipsUp(AsciiMap):
 
     def _makeOffsets(self):
         """Full hex tips-up grids have linearly incrementing offset."""
-        AsciiMap._makeOffsets(self)
+        self.asciiOffsets = []
         for li, _line in enumerate(self.asciiLines):
             self.asciiOffsets.append(li)
 
diff --git a/doc/release/0.3.rst b/doc/release/0.3.rst
index 7da71ac8..f241dff6 100644
--- a/doc/release/0.3.rst
+++ b/doc/release/0.3.rst
@@ -22,11 +22,13 @@ API Changes
 
 Bug Fixes
 ---------
+#. Fixed four bugs with "corners up" hex grids. (`PR#1649 <https://github.com/terrapower/armi/pull/1649>`_)
 #. TBD
 
 Changes that Affect Requirements
 --------------------------------
 #. Very minor change to ``Block.coords()``, removing unused argument. (`PR#1651 <https://github.com/terrapower/armi/pull/1651>`_)
+#. Touched ``HexGrid`` by adding a "cornersUp" property and fixing two bugs. (`PR#1649 <https://github.com/terrapower/armi/pull/1649>`_)
 #. TBD
 
 
diff --git a/doc/user/user_install.rst b/doc/user/user_install.rst
index 8ab44d8d..6a454b58 100644
--- a/doc/user/user_install.rst
+++ b/doc/user/user_install.rst
@@ -63,6 +63,18 @@ Getting the code
 ================
 Choose one of the following two installation methods depending on your needs.
 
+Step 0: Update PIP
+------------------
+In order to use the commands below, you're going to want to use a version of ``pip>=22.1``.
+Two common ways of solving that are::
+
+    (armi-venv) $ pip install pip>=22.1
+
+or, in most cases::
+
+    (armi-venv) $ pip install -U pip
+
+
 Option 1: Install as a library
 ------------------------------
 If you plan on running ARMI without viewing or modifying source code, you may
@@ -72,6 +84,7 @@ in another project::
 
    	(armi-venv) $ pip install https://github.com/terrapower/armi/archive/main.zip
 
+
 Option 2: Install as a repository (for developers)
 --------------------------------------------------
 If you'd like to view or change the ARMI source code (common!), you need to
@@ -86,7 +99,7 @@ the git repository with::
 Now install ARMI with all its dependencies::
 
     (armi-venv) $ cd armi
-    (armi-venv) $ pip install -e .[test]
+    (armi-venv) $ pip install -e ".[test]"
 
 .. tip:: If you don't want to install ARMI into your venv, you will need to add the ARMI source
 	location to your system's ``PYTHONPATH`` environment variable so that


Test patch:
diff --git a/armi/reactor/blueprints/tests/test_gridBlueprints.py b/armi/reactor/blueprints/tests/test_gridBlueprints.py
index 61f1bf0d..076a6818 100644
--- a/armi/reactor/blueprints/tests/test_gridBlueprints.py
+++ b/armi/reactor/blueprints/tests/test_gridBlueprints.py
@@ -361,7 +361,7 @@ class TestGridBlueprintsSection(unittest.TestCase):
     def test_simpleRead(self):
         gridDesign = self.grids["control"]
         _ = gridDesign.construct()
-        self.assertEqual(gridDesign.gridContents[0, -8], "6")
+        self.assertEqual(gridDesign.gridContents[-8, 0], "6")
 
         # Cartesian full, odd
         gridDesign2 = self.grids["sfp"]
diff --git a/armi/reactor/grids/tests/test_grids.py b/armi/reactor/grids/tests/test_grids.py
index 4c1f2634..99b5934a 100644
--- a/armi/reactor/grids/tests/test_grids.py
+++ b/armi/reactor/grids/tests/test_grids.py
@@ -183,6 +183,7 @@ class TestGrid(unittest.TestCase):
 
     def test_isAxialOnly(self):
         grid = grids.HexGrid.fromPitch(1.0, numRings=3)
+        self.assertAlmostEqual(grid.pitch, 1.0)
         self.assertEqual(grid.isAxialOnly, False)
 
         grid2 = grids.AxialGrid.fromNCells(10)
@@ -190,11 +191,13 @@ class TestGrid(unittest.TestCase):
 
     def test_lookupFactory(self):
         grid = grids.HexGrid.fromPitch(1.0, numRings=3)
+        self.assertAlmostEqual(grid.pitch, 1.0)
         self.assertEqual(grid[10, 5, 0].i, 10)
 
     def test_quasiReduce(self):
         """Make sure our DB-friendly version of reduce works."""
         grid = grids.HexGrid.fromPitch(1.0, numRings=3)
+        self.assertAlmostEqual(grid.pitch, 1.0)
         reduction = grid.reduce()
         self.assertAlmostEqual(reduction[0][1][1], 1.0)
 
@@ -208,6 +211,7 @@ class TestGrid(unittest.TestCase):
             :tests: R_ARMI_GRID_ELEM_LOC
         """
         grid = grids.HexGrid.fromPitch(1.0, numRings=0)
+        self.assertAlmostEqual(grid.pitch, 1.0)
         self.assertNotIn((0, 0, 0), grid._locations)
         _ = grid[0, 0, 0]
         self.assertIn((0, 0, 0), grid._locations)
@@ -236,6 +240,7 @@ class TestHexGrid(unittest.TestCase):
 
     def test_positions(self):
         grid = grids.HexGrid.fromPitch(1.0)
+        self.assertAlmostEqual(grid.pitch, 1.0)
         side = 1.0 / math.sqrt(3)
         assert_allclose(grid.getCoordinates((0, 0, 0)), (0.0, 0.0, 0.0))
         assert_allclose(grid.getCoordinates((1, 0, 0)), (1.5 * side, 0.5, 0.0))
@@ -353,18 +358,20 @@ class TestHexGrid(unittest.TestCase):
         self.assertEqual(third.getPositionsInRing(3), 12)
         self.assertEqual(third.getSymmetricEquivalents((3, -2)), [(-1, 3), (-2, -1)])
 
-    def test_pointsUpFlatsUp(self):
-        """Test the pointedEndUp attribute of the fromPitch method.
+    def test_cornersUpFlatsUp(self):
+        """Test the cornersUp attribute of the fromPitch method.
 
         .. test:: Build a points-up and a flats-up hexagonal grids.
             :id: T_ARMI_GRID_HEX_TYPE
             :tests: R_ARMI_GRID_HEX_TYPE
         """
-        tipsUp = grids.HexGrid.fromPitch(1.0, pointedEndUp=True)
-        flatsUp = grids.HexGrid.fromPitch(1.0, pointedEndUp=False)
+        flatsUp = grids.HexGrid.fromPitch(1.0, cornersUp=False)
+        self.assertAlmostEqual(flatsUp._unitSteps[0][0], math.sqrt(3) / 2)
+        self.assertAlmostEqual(flatsUp.pitch, 1.0)
 
-        self.assertEqual(tipsUp._unitSteps[0][0], 0.5)
-        self.assertAlmostEqual(flatsUp._unitSteps[0][0], 0.8660254037844388)
+        cornersUp = grids.HexGrid.fromPitch(1.0, cornersUp=True)
+        self.assertAlmostEqual(cornersUp._unitSteps[0][0], 0.5)
+        self.assertAlmostEqual(cornersUp.pitch, 1.0)
 
     def test_triangleCoords(self):
         g = grids.HexGrid.fromPitch(8.15)
@@ -433,11 +440,11 @@ class TestHexGrid(unittest.TestCase):
             newLoc = pickle.load(buf)
             assert_allclose(loc.indices, newLoc.indices)
 
-    def test_adjustPitch(self):
-        """Adjust the pich of a hexagonal lattice.
+    def test_adjustPitchFlatsUp(self):
+        """Adjust the pitch of a hexagonal lattice, for a "flats up" grid.
 
         .. test:: Construct a hexagonal lattice with three rings.
-            :id: T_ARMI_GRID_HEX
+            :id: T_ARMI_GRID_HEX0
             :tests: R_ARMI_GRID_HEX
 
         .. test:: Return the grid coordinates of different locations.
@@ -453,20 +460,66 @@ class TestHexGrid(unittest.TestCase):
                 offset=numpy.array([offset, offset, offset]),
             )
 
+            # test number of rings before converting pitch
+            self.assertEqual(grid._unitStepLimits[0][1], 3)
+
             # test that we CAN change the pitch, and it scales the grid (but not the offset)
             v1 = grid.getCoordinates((1, 0, 0))
             grid.changePitch(2.0)
+            self.assertAlmostEqual(grid.pitch, 2.0)
             v2 = grid.getCoordinates((1, 0, 0))
             assert_allclose(2 * v1 - offset, v2)
-            self.assertEqual(grid.pitch, 2.0)
 
-            # basic sanity: test number of rings has changed
+            # basic sanity: test number of rings has not changed
             self.assertEqual(grid._unitStepLimits[0][1], 3)
 
             # basic sanity: check the offset exists and is correct
             for i in range(3):
                 self.assertEqual(grid.offset[i], offset)
 
+    def test_adjustPitchCornersUp(self):
+        """Adjust the pich of a hexagonal lattice, for a "corners up" grid.
+
+        .. test:: Construct a hexagonal lattice with three rings.
+            :id: T_ARMI_GRID_HEX1
+            :tests: R_ARMI_GRID_HEX
+
+        .. test:: Return the grid coordinates of different locations.
+            :id: T_ARMI_GRID_GLOBAL_POS1
+            :tests: R_ARMI_GRID_GLOBAL_POS
+        """
+        # run this test for a grid with no offset, and then a few random offset values
+        for offset in [0, 1, 1.123, 3.14]:
+            offsets = [offset, 0, 0]
+            # build a hex grid with pitch=1, 3 rings, and the above offset
+            grid = grids.HexGrid(
+                unitSteps=(
+                    (0.5, -0.5, 0),
+                    (1.5 / math.sqrt(3), 1.5 / math.sqrt(3), 0),
+                    (0, 0, 0),
+                ),
+                unitStepLimits=((-3, 3), (-3, 3), (0, 1)),
+                offset=numpy.array(offsets),
+            )
+
+            # test number of rings before converting pitch
+            self.assertEqual(grid._unitStepLimits[0][1], 3)
+
+            # test that we CAN change the pitch, and it scales the grid (but not the offset)
+            v1 = grid.getCoordinates((1, 0, 0))
+            grid.changePitch(2.0)
+            self.assertAlmostEqual(grid.pitch, 2.0, delta=1e-9)
+            v2 = grid.getCoordinates((1, 0, 0))
+            correction = numpy.array([0.5, math.sqrt(3) / 2, 0])
+            assert_allclose(v1 + correction, v2)
+
+            # basic sanity: test number of rings has not changed
+            self.assertEqual(grid._unitStepLimits[0][1], 3)
+
+            # basic sanity: check the offset exists and is correct
+            for i, off in enumerate(offsets):
+                self.assertEqual(grid.offset[i], off)
+
     def test_badIndices(self):
         grid = grids.HexGrid.fromPitch(1.0, numRings=3)
 
diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py
index 257a2d40..ff61a120 100644
--- a/armi/reactor/tests/test_blocks.py
+++ b/armi/reactor/tests/test_blocks.py
@@ -1963,7 +1963,7 @@ class HexBlock_TestCase(unittest.TestCase):
         with self.HexBlock.retainState():
             self.HexBlock.setType("fuel")
             self.HexBlock.spatialGrid.changePitch(2.0)
-        self.assertEqual(self.HexBlock.spatialGrid.pitch, 1.0)
+        self.assertAlmostEqual(self.HexBlock.spatialGrid.pitch, 1.0)
         self.assertTrue(self.HexBlock.hasFlags(Flags.INTERCOOLANT))
 
     def test_getPinCoords(self):
diff --git a/armi/utils/tests/test_asciimaps.py b/armi/utils/tests/test_asciimaps.py
index 17fbc366..b154b1b5 100644
--- a/armi/utils/tests/test_asciimaps.py
+++ b/armi/utils/tests/test_asciimaps.py
@@ -108,6 +108,7 @@ HEX_THIRD_MAP_WITH_EMPTY_ROW = """-   -   SH  SH
           EX  IC  IC  PC  OC
 """
 
+# This is a "corners-up" hexagonal map.
 HEX_FULL_MAP = """- - - - - - - - - 1 1 1 1 1 1 1 1 1 4
  - - - - - - - - 1 1 1 1 1 1 1 1 1 1 1
   - - - - - - - 1 8 1 1 1 1 1 1 1 1 1 1
@@ -129,6 +130,7 @@ HEX_FULL_MAP = """- - - - - - - - - 1 1 1 1 1 1 1 1 1 4
                   1 1 1 1 1 1 1 1 1 1
 """
 
+# This is a "flats-up" hexagonal map.
 HEX_FULL_MAP_FLAT = """-       -       -       -       ORS     ORS     ORS 
     -       -       -       ORS     ORS     ORS     ORS 
 -       -       -       ORS     IRS     IRS     IRS     ORS 
@@ -289,15 +291,69 @@ class TestAsciiMaps(unittest.TestCase):
 
         self.assertEqual(asciimap[5, 0], "TG")
 
-    def test_hexFull(self):
-        """Test sample full hex map against known answers."""
-        # hex map is 19 rows tall, so it should go from -9 to 9
+    def test_hexFullCornersUpSpotCheck(self):
+        """Spot check some hex grid coordinates are what they should be."""
+        # The corners and a central line of non-zero values.
+        corners_map = """- - - - - - - - - 3 0 0 0 0 0 0 0 0 2
+         - - - - - - - - 0 0 0 0 0 0 0 0 0 0 0
+          - - - - - - - 0 0 0 0 0 0 0 0 0 0 0 0
+           - - - - - - 0 0 0 0 0 0 0 0 0 0 0 0 0
+            - - - - - 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+             - - - - 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+              - - - 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+               - - 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+                - 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+                 4 0 0 0 0 0 0 0 0 0 1 2 3 4 5 6 7 0 1
+                  0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+                   0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+                    0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+                     0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+                      0 0 0 0 0 0 0 0 0 0 0 0 0 0
+                       0 0 0 0 0 0 0 0 0 0 0 0 0
+                        0 0 0 0 0 0 0 0 0 0 0 0
+                         0 0 0 0 0 0 0 0 0 0 0
+                          5 0 0 0 0 0 0 0 0 6
+        """
+
+        # hex map is 19 rows tall: from -9 to 9
         asciimap = asciimaps.AsciiMapHexFullTipsUp()
-        with io.StringIO() as stream:
-            stream.write(HEX_FULL_MAP)
-            stream.seek(0)
-            asciimap.readAscii(stream.read())
+        asciimap.readAscii(corners_map)
+
+        # verify the corners
+        self.assertEqual(asciimap[9, -9], "1")
+        self.assertEqual(asciimap[9, 0], "2")
+        self.assertEqual(asciimap[0, 9], "3")
+        self.assertEqual(asciimap[-9, 9], "4")
+        self.assertEqual(asciimap[-9, 0], "5")
+        self.assertEqual(asciimap[0, -9], "6")
+
+        # verify a line of coordinates
+        self.assertEqual(asciimap[0, 0], "0")
+        self.assertEqual(asciimap[1, -1], "1")
+        self.assertEqual(asciimap[2, -2], "2")
+        self.assertEqual(asciimap[3, -3], "3")
+        self.assertEqual(asciimap[4, -4], "4")
+        self.assertEqual(asciimap[5, -5], "5")
+        self.assertEqual(asciimap[6, -6], "6")
+        self.assertEqual(asciimap[7, -7], "7")
+
+    def test_hexFullCornersUp(self):
+        """Test sample full hex map (with hex corners up) against known answers."""
+        # hex map is 19 rows tall: from -9 to 9
+        asciimap = asciimaps.AsciiMapHexFullTipsUp()
+        asciimap.readAscii(HEX_FULL_MAP)
+
+        # spot check some values in the map
+        self.assertIn("7 1 1 1 1 1 1 1 1 0", str(asciimap))
+        self.assertEqual(asciimap[-9, 9], "7")
+        self.assertEqual(asciimap[-8, 0], "6")
+        self.assertEqual(asciimap[-1, 0], "2")
+        self.assertEqual(asciimap[-1, 8], "8")
+        self.assertEqual(asciimap[0, -6], "3")
+        self.assertEqual(asciimap[0, 0], "0")
+        self.assertEqual(asciimap[9, 0], "4")
 
+        # also test writing from pure data (vs. reading) gives the exact same map
         asciimap2 = asciimaps.AsciiMapHexFullTipsUp()
         for ij, spec in asciimap.items():
             asciimap2.asciiLabelByIndices[ij] = spec
@@ -308,13 +364,40 @@ class TestAsciiMaps(unittest.TestCase):
             stream.seek(0)
             output = stream.read()
             self.assertEqual(output, HEX_FULL_MAP)
-        self.assertEqual(asciimap[0, 0], "0")
-        self.assertEqual(asciimap[0, -1], "2")
-        self.assertEqual(asciimap[0, -8], "6")
-        self.assertEqual(asciimap[0, 9], "4")
-        self.assertEqual(asciimap[-9, 0], "7")
-        self.assertEqual(asciimap[-8, 7], "8")
-        self.assertEqual(asciimap[6, -6], "3")
+
+        self.assertIn("7 1 1 1 1 1 1 1 1 0", str(asciimap))
+        self.assertIn("7 1 1 1 1 1 1 1 1 0", str(asciimap2))
+
+    def test_hexFullFlatsUp(self):
+        """Test sample full hex map (with hex flats up) against known answers."""
+        # hex map is 21 rows tall: from -10 to 10
+        asciimap = asciimaps.AsciiMapHexFullFlatsUp()
+        asciimap.readAscii(HEX_FULL_MAP_FLAT)
+
+        # spot check some values in the map
+        self.assertIn("VOTA    ICS     IC      IRT     ICS     OC", str(asciimap))
+        self.assertEqual(asciimap[-3, 10], "ORS")
+        self.assertEqual(asciimap[0, -9], "ORS")
+        self.assertEqual(asciimap[0, 0], "IC")
+        self.assertEqual(asciimap[0, 9], "ORS")
+        self.assertEqual(asciimap[4, -6], "RR7")
+        self.assertEqual(asciimap[6, 0], "RR7")
+        self.assertEqual(asciimap[7, -1], "RR89")
+
+        # also test writing from pure data (vs. reading) gives the exact same map
+        asciimap2 = asciimaps.AsciiMapHexFullFlatsUp()
+        for ij, spec in asciimap.items():
+            asciimap2.asciiLabelByIndices[ij] = spec
+
+        with io.StringIO() as stream:
+            asciimap2.gridContentsToAscii()
+            asciimap2.writeAscii(stream)
+            stream.seek(0)
+            output = stream.read()
+            self.assertEqual(output, HEX_FULL_MAP_FLAT)
+
+        self.assertIn("VOTA    ICS     IC      IRT     ICS     OC", str(asciimap))
+        self.assertIn("VOTA    ICS     IC      IRT     ICS     OC", str(asciimap2))
 
     def test_hexFullFlat(self):
         """Test sample full hex map against known answers."""
@@ -335,7 +418,7 @@ class TestAsciiMaps(unittest.TestCase):
         self.assertEqual(asciimap[-5, 2], "VOTA")
         self.assertEqual(asciimap[2, 3], "FS")
 
-        # also test writing from pure data (vs. reading) gives the exact same map :o
+        # also test writing from pure data (vs. reading) gives the exact same map
         with io.StringIO() as stream:
             asciimap2 = asciimaps.AsciiMapHexFullFlatsUp()
             asciimap2.asciiLabelByIndices = asciimap.asciiLabelByIndices