Skip to content

Instantly share code, notes, and snippets.

@paretech
Last active March 10, 2025 03:58
Show Gist options
  • Save paretech/a2c00bd63ee0d2c9740faefef145b8c9 to your computer and use it in GitHub Desktop.
Save paretech/a2c00bd63ee0d2c9740faefef145b8c9 to your computer and use it in GitHub Desktop.
Grid Explorer
"""Convenience module for working with grids"""
import dataclasses
import unittest
@dataclasses.dataclass
class GridPoint:
x: int
y: int
def __iter__(self):
yield self.x
yield self.y
def __str__(self) -> str:
return f"({self.x}, {self.y})"
@property
def row(self):
return self.y
@property
def column(self):
return self.x
class GridArea:
def __init__(
self,
p1: tuple[int, int],
p2: tuple[int, int],
cells_per_col: int = 1,
min_move: int = 1,
max_move: int = None,
):
"""Define and iterate over grid
Grid defined in terms of unit of cells. The smallest grid (p1 == p2) has
area of 1.
The number of cells that can be visited in a single iteration is
determined by requirements specified as initialization arguments.
Args:
p1: Tuple of positive integers representing the upper left hand
corner of grid area.
p2: Tuple of positive integers representing the lower right hand
corner of grid area.
cells_per_col: Positive integer representing how many cells are in a
column. Defaults to 1.
min_move: Positive integer representing the minimum number of cells
that can be consumed in a single iteration. The total grid area must
be evenly divisible by this value. Defaults to 1.
max_move: Positive integer representing the maximum number of cells
that can be consumed in a single iteration. Defaults to `remaining`
area.
Raises:
ValueError: Raised if starting x position is not evenly divisible by
specified `cells`.
ValueError: Raised if width is not evenly divisible by specified
`cells`.
ValueError: Raised if area is not divisible by `min_move`.
"""
# Track how many cells of grid area have been visited
self._visited = 0
# Track how many steps have been taken so far
self._steps = 0
# Track if `move` method was called
self._has_moved = None
self._p1, self._p2 = GridPoint(*p1), GridPoint(*p2)
self._cells_per_column = cells_per_col
self._min_move = min_move
self._max_move = max_move or self.remaining
if not ((self._p1.x <= self._p2.x) and (self._p1.y <= self._p2.y)):
raise ValueError("p2 must be greater than or equal to p1")
if (self._p1.x % self._cells_per_column) != 0:
raise ValueError(
f"The starting x position (p1.x) must be evenly divisible by {self._cells_per_column}"
)
if (self.width % self._cells_per_column) != 0:
raise ValueError(
f"`width` ({self.width}) must be evenly divisible by `cells_per_col` ({self._cells_per_column})"
)
if (self.area % self.min_move) != 0:
raise ValueError(
f"Area ({self.area}) must be evenly divisible by min_move ({self.min_move})"
)
def __iter__(self) -> "GridArea":
self.reset()
return self
def __next__(self) -> "GridArea":
"""Iterate over a grid area until all cells have been explored"""
if self.remaining <= 0:
raise StopIteration
# First iteration does NOT trigger auto-move (`None` check prevents it).
# Subsequent iterations trigger auto-move unless `move()` is called.
if self._has_moved is False:
self._move_default()
# Reset for next iteration
self._has_moved = False
return self
def __repr__(self):
return (
f"{self.__class__.__name__}("
f"p1={self._p1}, p2={self._p2}, cells_per_col={self._cells_per_column}, "
f"min_move={self._min_move}, max_move={self._max_move})"
)
@property
def status(self) -> str:
"""Return a status string summarizing state"""
return (
f"[Step {self._steps}] Visited: {self.visited}/{self.area} "
f"(Remaining: {self.remaining}, Max Move: {self.max_move})\n"
f" ├── Next: ({self.next_row}, {self.next_column})\n"
f" └── Last: ({self.last_row}, {self.last_col})"
)
@property
def width(self) -> int:
"""Return width of area as cell count"""
return self._p2.x - self._p1.x + 1
@property
def height(self) -> int:
"""Return height of area as cell count"""
return self._p2.y - self._p1.y + 1
@property
def size(self) -> tuple[int, int]:
"""Return (width, height)"""
return self.width, self.height
@property
def area(self) -> int:
"""Return the area as cell count"""
return self.width * self.height
@property
def rows(self) -> int:
"""Return number of rows in area"""
return self.height
@property
def columns(self) -> int:
"""Return number of columns in area as function of `cells`"""
return self.width // self._cells_per_column
@property
def visited(self) -> int:
"""Return number of grid cells already visited"""
return self._visited
@property
def remaining(self) -> int:
"""Return number of grid cells not yet visited"""
return self.area - self.visited
@property
def next_row(self) -> int:
"""Return row index of the next cell to visit"""
return self._calc_row(self.visited)
@property
def next_column(self) -> int:
"""Return column index of the next cell to visit"""
return self._calc_col(self.visited)
@property
def last_row(self) -> int:
"""Return row index of the last available cell to visit
Last cell available is limited by "max_step"
"""
return self._calc_row(self.visited + self.max_move - 1)
@property
def last_col(self) -> int:
"""Return row index of the last available cell to visit
Last cell available is limited by "max_step"
"""
return self._calc_col(self.visited + self.max_move - 1)
@property
def max_move(self) -> int:
"""Return the largest valid move available
Value is guranteed to be an even multiple of min_move.
"""
return self._nearest_valid_move(min(self._max_move, self.remaining))
@property
def min_move(self) -> int:
return self._min_move
def reset(self) -> None:
"""Reset visited and steps counter"""
self._visited = 0
self._steps = 0
self._has_moved = None
def move(self, count: int) -> None:
"""Advance the visited cells counter.
Args:
count: Number of cells to move. Must be a multiple of `min_move` and
cannot exceed `max_move`.
Raises:
RuntimeError: If move is attempted but no cells remaining.
ValueError: If `count` is not a multiple of `min_move` or exceeds `max_move`.
"""
if self.remaining <= 0:
raise RuntimeError("Cannot move: no cells remaining.")
if (count % self._min_move) != 0:
raise ValueError(f"Count must be evenly divisible by {self._min_move}")
if count > self.max_move:
raise ValueError(f"Count must be less than or equal to {self.max_move}")
self._visited += count
self._steps += 1
# If `_has_moved` is False, then `__next__` was just called and we are
# in iterator protocol. It is important to set `_has_moved` to True so
# that the default move is not triggered.
if self._has_moved is False:
self._has_moved = True
def _move_default(self):
self.move(self._min_move)
def _nearest_valid_move(self, count: int) -> int:
return (count // self._min_move) * self._min_move
def _calc_row(self, cell_index: int) -> int:
"""Calculate next row index given cell index"""
if self.remaining <= 0:
return self._p2.row
return (cell_index // self.width) + self._p1.row
def _calc_col(self, cell_index: int) -> int:
"""Calculate next column index given cell index"""
if self.remaining <= 0:
return self._p2.column
return (cell_index % self.columns) + (self._p1.column % self._cells_per_column)
class TestGridPoint(unittest.TestCase):
def test_grid_point_init(self):
"""Test GridPoint initialization."""
p = GridPoint(3, 4)
self.assertEqual(p.x, 3)
self.assertEqual(p.y, 4)
def test_grid_point_str(self):
"""Test GridPoint string representation."""
p = GridPoint(3, 4)
self.assertEqual(str(p), "(3, 4)")
def test_grid_point_iter(self):
"""Test unpacking GridPoint using iteration."""
p = GridPoint(3, 4)
self.assertEqual(tuple(p), (3, 4))
class TestGridArea(unittest.TestCase):
def test_grid_area_init(self):
"""Test initialization of GridArea with valid inputs."""
grid = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1)
self.assertEqual(grid.width, 5)
self.assertEqual(grid.height, 5)
self.assertEqual(grid.area, 25)
def test_grid_area_invalid_p1_p2(self):
"""Test invalid GridArea initialization when p1 is not top-left of p2."""
with self.assertRaises(ValueError):
GridArea((5, 5), (2, 2), cells_per_col=1, min_move=1)
def test_grid_area_invalid_cells_per_column(self):
"""Test validation of starting x position divisibility by cells_per_col."""
with self.assertRaises(ValueError):
GridArea((1, 0), (4, 4), cells_per_col=2, min_move=1)
def test_grid_area_invalid_width_divisibility(self):
"""Test validation of width divisibility by cells_per_col."""
with self.assertRaises(ValueError):
GridArea((0, 0), (6, 4), cells_per_col=2, min_move=1)
def test_grid_area_invalid_area_divisibility(self):
"""Test validation of area divisibility by min_step."""
with self.assertRaises(ValueError):
GridArea((0, 0), (4, 4), cells_per_col=1, min_move=3)
def test_grid_area_auto_iteration(self):
"""Test that GridArea iterates correctly over all cells."""
grid = GridArea((0, 0), (2, 2), cells_per_col=1, min_move=1)
visited_steps = []
for area in grid:
visited_steps.append(area.visited)
self.assertEqual(visited_steps[-1], grid.area)
def test_grid_area_manual_movement(self):
"""Test manual movement through the grid using move()."""
area = GridArea((0, 0), (2, 2), cells_per_col=1, min_move=1)
while area.remaining > 0:
area.move(area.min_move)
self.assertEqual(area.visited, area.area)
def test_grid_area_move_valid(self):
"""Test valid moves within grid constraints."""
area = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1)
area.move(5)
self.assertEqual(area.visited, 5)
def test_grid_area_move_invalid_step(self):
"""Test move with an invalid step size (not divisible by min_step)."""
area = GridArea((0, 0), (5, 5), cells_per_col=1, min_move=2)
with self.assertRaises(ValueError):
area.move(3) # Not a multiple of min_step=2
def test_grid_area_move_exceeding_max(self):
"""Test move that exceeds max_move constraint."""
area = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1, max_move=5)
with self.assertRaises(ValueError):
area.move(6) # Exceeds max_step=5
def test_grid_area_remaining(self):
"""Test remaining cell calculation."""
area = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1)
self.assertEqual(area.remaining, area.area)
area.move(10)
self.assertEqual(area.remaining, area.area - 10)
def test_grid_area_last_cell_calculation(self):
"""Test calculation of last available cell for current step."""
area = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1, max_move=3)
area.move(3)
self.assertEqual(area.last_row, area._calc_row(5))
self.assertEqual(area.last_col, area._calc_col(5))
def test_grid_area_status(self):
"""Test status message generation."""
area = GridArea((0, 0), (4, 4), cells_per_col=1, min_move=1, max_move=5)
expected_status = "[Step 0] Visited: 0/25 (Remaining: 25, Max Move: 5)\n ├── Next: (0, 0)\n └── Last: (0, 4)"
self.assertEqual(area.status, expected_status)
class TestAdHoc(unittest.TestCase):
def test_grid_explore(self):
# Max step here is limitation of the grid
grid = GridArea((0, 0), (4, 4), max_move=4)
# Max step here is limitation of the explorer
max_step = 2
# print(f"Exploring {grid}")
for step, area in enumerate(grid):
# print(f"Step {step}: {area.status}")
step_size = min(area.max_move, max_step)
# print(f"\tVisited: {step_size} cells")
area.move(step_size)
def test_min_grid(self):
"""Smallest grid area"""
# Max step here is limitation of the grid
grid = GridArea((0, 0), (0, 0), max_move=4)
# Max step here is limitation of the explorer
client_max_move = 2
# print(f"Exploring {grid}")
for step, area in enumerate(grid):
# print(f"Step {step}: {area.status}")
step_size = min(area.max_move, client_max_move)
# print(f"\tVisited: {step_size} cells")
area.move(step_size)
def test_unlimited_move(self):
"""Large grid with no move constraints"""
grid = GridArea((0, 0), (1024, 768))
# print(f"Exploring {grid}")
for step, area in enumerate(grid):
# print(f"Step {step}: {area.status}")
step_size = area.max_move
# print(f"\tVisited: {step_size} cells")
area.move(step_size)
def test_client_limited(self):
"""Large grid with no move constraints"""
grid = GridArea((0, 0), (1024, 768))
# Max step here is limitation of the explorer
client_max_move = 512
for area in grid:
step_size = min(area.max_move, client_max_move)
area.move(step_size)
def test_dev(self):
grid = GridArea(
(799 - 5, 599 - 5),
(799, 599),
cells_per_col=2,
min_move=4,
max_move=256,
)
# Max step here is limitation of the explorer
client_max_move = 4
# print(
# f"Exploring {grid} ({grid.width}x{grid.height} grid = {grid.rows} rows, {grid.columns} columns)"
# )
for area in grid:
# print(f"{area.status}")
step_size = min(area.max_move, client_max_move)
# print(f"\tVisited: {step_size} cells")
area.move(step_size)
# print(f"{area.status}")
self.assertEqual(area._steps, 9)
self.assertEqual(area.remaining, 0)
if __name__ == "__main__":
unittest.main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment