diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py index 8fd59963c..46524a54c 100644 --- a/armi/reactor/blocks.py +++ b/armi/reactor/blocks.py @@ -2286,7 +2286,7 @@ def autoCreateSpatialGrids(self): # 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 ce133a673..bb6dba989 100644 --- a/armi/reactor/blueprints/gridBlueprint.py +++ b/armi/reactor/blueprints/gridBlueprint.py @@ -318,7 +318,7 @@ def _constructSpatialGrid(self): 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 a98b88b82..f479dddcd 100644 --- a/armi/reactor/grids/hexagonal.py +++ b/armi/reactor/grids/hexagonal.py @@ -30,7 +30,7 @@ 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 @@ 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 @@ def fromPitch(pitch, numRings=25, armiObject=None, pointedEndUp=False, symmetry= 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 @@ def fromPitch(pitch, numRings=25, armiObject=None, pointedEndUp=False, symmetry= 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: @@ -221,7 +243,7 @@ def getNeighboringCellIndices( 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 @@ def getLabel(self, indices): @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 @@ def _indicesAndEdgeFromRingAndPos(ring, position): 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 @@ def _indicesAndEdgeFromRingAndPos(ring, position): 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 @@ def overlapsWhichSymmetryLine(self, indices: IJType) -> Optional[int]: 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 @@ def getSymmetricEquivalents(self, indices: IJKType) -> List[IJType]: @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 @@ def triangleCoords(self, indices: IJKType) -> numpy.ndarray: 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 @@ -470,8 +576,7 @@ def generateSortedHexLocationList(self, nLocs: int): return locList[:nLocs] - # TODO: this is only used by testing and another method that just needs the count of assemblies - # in a ring, not the actual positions + # TODO: This is only used by the old GUI code, and should be moved there. def allPositionsInThird(self, ring, includeEdgeAssems=False): """ Returns a list of all the positions in a ring (in the first third). diff --git a/armi/reactor/grids/structuredGrid.py b/armi/reactor/grids/structuredGrid.py index 420a83f90..81d74add4 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/reactor/grids/tests/test_grids.py b/armi/reactor/grids/tests/test_grids.py index 4c1f2634b..e2090cc23 100644 --- a/armi/reactor/grids/tests/test_grids.py +++ b/armi/reactor/grids/tests/test_grids.py @@ -353,15 +353,15 @@ def test_thirdAndFullSymmetry(self): 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) + tipsUp = grids.HexGrid.fromPitch(1.0, cornersUp=True) + flatsUp = grids.HexGrid.fromPitch(1.0, cornersUp=False) self.assertEqual(tipsUp._unitSteps[0][0], 0.5) self.assertAlmostEqual(flatsUp._unitSteps[0][0], 0.8660254037844388) @@ -433,11 +433,11 @@ def test_is_pickleable(self): 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 pich 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 +453,66 @@ def test_adjustPitch(self): 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.assertEqual(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, math.sqrt(3), 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/utils/asciimaps.py b/armi/utils/asciimaps.py index 127dc6afe..c62086117 100644 --- a/armi/utils/asciimaps.py +++ b/armi/utils/asciimaps.py @@ -40,7 +40,6 @@ 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 @@ def __init__(self): 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 @@ def writeAscii(self, stream): 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 @@ def readAscii(self, text): 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 @@ def gridContentsToAscii(self): # 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 @@ def __setitem__(self, ijKey, item): def _makeOffsets(self): """Build offsets.""" + raise NotImplementedError def items(self): return self.asciiLabelByIndices.items() @@ -296,7 +320,7 @@ def _getIJFromColRow(self, columnNum, lineNum): 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 @@ def _makeOffsets(self): # 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): @@ -568,8 +587,7 @@ def _getIJFromColRow(self, columnNum, lineNum): 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 @@ def _getIJBaseByAsciiLine(self, asciiLineNum): 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 @@ def _getIJBaseByAsciiLine(self, asciiLineNum): 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 @@ def _getLineNumsToWrite(self): 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/armi/utils/tests/test_asciimaps.py b/armi/utils/tests/test_asciimaps.py index 17fbc366c..272099604 100644 --- a/armi/utils/tests/test_asciimaps.py +++ b/armi/utils/tests/test_asciimaps.py @@ -108,6 +108,7 @@ 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 @@ 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,23 @@ def test_troublesomeHexThird(self): 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_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() - with io.StringIO() as stream: - stream.write(HEX_FULL_MAP) - stream.seek(0) - asciimap.readAscii(stream.read()) + 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[-8, 7], "8") + self.assertEqual(asciimap[-9, 0], "7") + self.assertEqual(asciimap[0, -1], "2") + self.assertEqual(asciimap[0, -8], "6") + self.assertEqual(asciimap[0, 0], "0") + self.assertEqual(asciimap[0, 9], "4") + self.assertEqual(asciimap[6, -6], "3") + + # 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 +318,40 @@ def test_hexFull(self): 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 +372,7 @@ def test_hexFullFlat(self): 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 diff --git a/doc/release/0.3.rst b/doc/release/0.3.rst index 4f708df3f..7fa35a882 100644 --- a/doc/release/0.3.rst +++ b/doc/release/0.3.rst @@ -18,11 +18,13 @@ API Changes Bug Fixes --------- +#. Fixed two bugs with "corners up" hex grids. (`PR#1649 `_) #. TBD Changes that Affect Requirements -------------------------------- -#. (`PR#1651 `_) - Very minor change to ``Block.coords()``, removing unused argument. +#. Touched ``HexGrid`` by adding a "cornersUp" property and fixing two bugs. (`PR#1649 `_) +#. Very minor change to ``Block.coords()``, removing unused argument. (`PR#1651 `_) #. TBD