diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..e26bfe0 Binary files /dev/null and b/.coverage differ diff --git a/pyproject.toml b/pyproject.toml index 2ea3e3c..72507e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,4 +81,4 @@ markers = [ [tool.coverage.run] omit = [ # add omissions here -] \ No newline at end of file +] diff --git a/src/terrain_diffusion/store.py b/src/terrain_diffusion/store.py index 0db068c..b2f8bd1 100644 --- a/src/terrain_diffusion/store.py +++ b/src/terrain_diffusion/store.py @@ -13,4 +13,159 @@ - The Windowed Blending Sampler writes window contributions into it and reads them back. - Generation Orchestration reads finished height grids from it. - It can persist tiles to local disk. + +The two jobs are two separate objects on purpose. `RegionGrids` is scratch space that lives only +while one region is being generated, and the sampler is the only thing that writes into it. +`TileCache` holds finished tiles for as long as the program runs, and only Generation +Orchestration touches it. Handing the sampler one object that also held every finished tile would +give it reach over things it has no use for. """ + +from collections import OrderedDict + +import numpy as np + + +class RegionGrids: + """The running sum and weight grids for one region being generated. + + Windows of terrain overlap, so a cell is usually written by more than one of them. Rather than + letting the last window win, each contribution is accumulated as a weighted average: + + height[cell] = sum(value * weight) / sum(weight) + + The two halves of that fraction are kept as two grids of their own. Every window adds into + both, and `heights` divides one by the other once the region is covered. + """ + + def __init__(self, height: int, width: int) -> None: + """Create zeroed sum and weight grids of the given size.""" + if height < 1 or width < 1: + raise ValueError(f"region size must be at least 1x1, asked for {height}x{width}") + + self.height = height + self.width = width + # float64, so that the division in `heights` is not silently truncated to whole numbers. + self.sums = np.zeros((height, width)) + self.weights = np.zeros((height, width)) + + def add_window( + self, + values: np.ndarray, + weights: np.ndarray, + row: int, + col: int, + ) -> None: + """Add one window's contribution at the position its top left corner sits at. + + Values are added multiplied by their weights, and the weights are added on their own. + Adding, not replacing: a second window covering the same cells builds on the first. + """ + values = np.asarray(values, dtype=float) + weights = np.asarray(weights, dtype=float) + + if values.ndim != 2: + raise ValueError(f"window must be a 2d grid, got {values.ndim} dimensions") + if values.shape != weights.shape: + raise ValueError( + f"window and weights must be the same shape, got {values.shape} and {weights.shape}" + ) + + window_height, window_width = values.shape + + # numpy slices clip instead of complaining, and a negative index wraps round to the far + # side of the grid. Both would write a window somewhere other than where it was asked for, + # so the position is checked here rather than left to the slice. + if row < 0 or col < 0: + raise ValueError(f"window position ({row}, {col}) is outside the region") + if row + window_height > self.height or col + window_width > self.width: + raise ValueError( + f"a {window_height}x{window_width} window at ({row}, {col}) hangs off the edge of " + f"a {self.height}x{self.width} region" + ) + + rows = slice(row, row + window_height) + cols = slice(col, col + window_width) + self.sums[rows, cols] += values * weights + self.weights[rows, cols] += weights + + def is_complete(self) -> bool: + """Whether every cell in the region has been written to by at least one window. + + A cell with a weight of zero was never written to. Dividing by it gives nonsense rather + than an error, so nothing else catches it. + """ + return bool(np.all(self.weights > 0)) + + def unfilled_count(self) -> int: + """How many cells no window has reached yet.""" + return int(np.count_nonzero(self.weights == 0)) + + def heights(self) -> np.ndarray: + """The finished height grid, each cell being its sum divided by its weight. + + Raises if any cell is still unfilled, rather than handing back the nan a zero weight + would produce. + """ + unfilled = self.unfilled_count() + if unfilled: + raise ValueError( + f"{unfilled} of {self.height * self.width} cells have not been written to yet, " + "so the region cannot be read" + ) + + return self.sums / self.weights + + +class TileCache: + """Finished tiles, kept so the same tile is not generated twice. + + Generating a tile is expensive and the same tile is asked for again as the user moves around. + An endless world has to stay explorable in a fixed amount of memory though, so the cache holds + a limited number of tiles and drops the least recently used one to make room. + + A tile is identified by its seed, which already has its coordinate worked into it, so tiles + from two different worlds cannot collide. + """ + + def __init__(self, capacity: int) -> None: + """Create an empty cache holding at most `capacity` finished tiles.""" + if capacity < 1: + raise ValueError(f"cache must hold at least one tile, asked for {capacity}") + + self.capacity = capacity + # Ordered oldest use first, so the tile to drop is the one at the front. + self._tiles: OrderedDict[int, np.ndarray] = OrderedDict() + + def __len__(self) -> int: + """How many finished tiles are being held.""" + return len(self._tiles) + + def __contains__(self, seed: int) -> bool: + """Whether a tile is held, without counting as a use of it.""" + return seed in self._tiles + + def get(self, seed: int) -> np.ndarray | None: + """The finished tile for a seed, or None if it has not been generated yet. + + Reading counts as using the tile, so a tile that is read constantly is never the one + dropped. The grid handed back is the cache's own, so treat it as read only. + """ + tile = self._tiles.get(seed) + if tile is None: + return None + + self._tiles.move_to_end(seed) + return tile + + def put(self, seed: int, tile: np.ndarray) -> None: + """Store a finished tile, dropping the least recently used one if the cache is full. + + The tile is copied on the way in, so whoever generated it can reuse its buffer without + changing what was cached. + """ + self._tiles[seed] = np.array(tile) + self._tiles.move_to_end(seed) + + if len(self._tiles) > self.capacity: + self._tiles.popitem(last=False) diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..684d05a --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,279 @@ +"""Checks the terrain store: the region grids and the tile cache. + +The two are tested apart because they are meant to stay apart. The region grids are scratch space +for one region being generated, and the tile cache holds finished tiles for the life of the +program. Nothing here should need both at once. +""" + +import numpy as np +import pytest +from numpy.testing import assert_allclose + +from terrain_diffusion.store import RegionGrids, TileCache + +# --------------------------------------------------------------------------- +# region grids: starting empty +# --------------------------------------------------------------------------- + + +def test_new_grids_are_the_size_asked_for() -> None: + grids = RegionGrids(4, 6) + + assert grids.sums.shape == (4, 6) + assert grids.weights.shape == (4, 6) + + +def test_new_grids_start_at_zero() -> None: + grids = RegionGrids(4, 6) + + assert np.all(grids.sums == 0) + assert np.all(grids.weights == 0) + + +def test_an_empty_region_is_refused() -> None: + with pytest.raises(ValueError): + RegionGrids(0, 5) + + +# --------------------------------------------------------------------------- +# region grids: adding windows +# --------------------------------------------------------------------------- + + +def test_a_window_lands_where_it_was_put_and_nowhere_else() -> None: + grids = RegionGrids(4, 4) + + grids.add_window(np.ones((2, 2)), np.ones((2, 2)), row=1, col=1) + + assert np.all(grids.sums[1:3, 1:3] == 1) + assert np.all(grids.weights[1:3, 1:3] == 1) + + # everything outside the window is untouched + assert grids.sums.sum() == 4 + assert grids.weights.sum() == 4 + + +def test_adding_the_same_window_twice_doubles_it() -> None: + """Contributions accumulate. The second window must not replace the first.""" + grids = RegionGrids(4, 4) + values = np.full((2, 2), 3.0) + weights = np.full((2, 2), 0.5) + + grids.add_window(values, weights, row=0, col=0) + grids.add_window(values, weights, row=0, col=0) + + assert_allclose(grids.sums[0:2, 0:2], 3.0) + assert_allclose(grids.weights[0:2, 0:2], 1.0) + + +def test_overlapping_windows_add_together_in_the_overlap() -> None: + grids = RegionGrids(1, 6) + values = np.full((1, 4), 1.0) + weights = np.full((1, 4), 1.0) + + grids.add_window(values, weights, row=0, col=0) + grids.add_window(values, weights, row=0, col=2) + + # cells 2 and 3 were reached by both windows, the rest by one + assert_allclose(grids.weights[0], [1, 1, 2, 2, 1, 1]) + + +@pytest.mark.parametrize( + "row, col", + [ + (3, 0), + (0, 3), + (-1, 0), + (0, -1), + ], +) +def test_a_window_that_hangs_off_the_edge_is_refused(row: int, col: int) -> None: + """numpy slices clip rather than complain, so this has to be caught by hand.""" + grids = RegionGrids(4, 4) + + with pytest.raises(ValueError): + grids.add_window(np.ones((2, 2)), np.ones((2, 2)), row=row, col=col) + + +def test_a_window_and_its_weights_must_be_the_same_shape() -> None: + grids = RegionGrids(4, 4) + + with pytest.raises(ValueError): + grids.add_window(np.ones((2, 2)), np.ones((2, 3)), row=0, col=0) + + +# --------------------------------------------------------------------------- +# region grids: reading heights back +# --------------------------------------------------------------------------- + + +def test_heights_do_not_depend_on_how_large_the_weights_were() -> None: + """A weighted average of one value is that value, whatever the weight.""" + grids = RegionGrids(2, 2) + + grids.add_window(np.full((2, 2), 5.0), np.full((2, 2), 0.25), row=0, col=0) + + assert_allclose(grids.heights(), 5.0) + + +def test_the_overlap_of_two_equal_windows_does_not_read_double() -> None: + """If the overlap reads as ten then the division by the weights is missing.""" + grids = RegionGrids(1, 6) + values = np.full((1, 4), 5.0) + weights = np.array([[0.25, 0.75, 0.75, 0.25]]) + + grids.add_window(values, weights, row=0, col=0) + grids.add_window(values, weights, row=0, col=2) + + assert_allclose(grids.heights(), 5.0) + + +def test_the_overlap_of_two_different_windows_mixes_them() -> None: + """The worked example from the ticket: two windows of 10 and 20 over a 1x6 region.""" + grids = RegionGrids(1, 6) + weights = np.array([[0.25, 0.75, 0.75, 0.25]]) + + grids.add_window(np.full((1, 4), 10.0), weights, row=0, col=0) + grids.add_window(np.full((1, 4), 20.0), weights, row=0, col=2) + + heights = grids.heights() + + assert_allclose(heights[0], [10, 10, 12.5, 17.5, 20, 20]) + # the overlap is a blend of the two, not one or the other + assert 10 < heights[0][2] < 20 + assert 10 < heights[0][3] < 20 + + +def test_the_finished_grid_is_the_size_of_the_region() -> None: + grids = RegionGrids(3, 5) + + grids.add_window(np.ones((3, 5)), np.ones((3, 5)), row=0, col=0) + + assert grids.heights().shape == (3, 5) + + +# --------------------------------------------------------------------------- +# region grids: unfilled cells +# --------------------------------------------------------------------------- + + +def test_a_region_nothing_was_written_to_is_not_complete() -> None: + assert RegionGrids(4, 4).is_complete() is False + + +def test_a_fully_covered_region_is_complete() -> None: + grids = RegionGrids(4, 4) + + grids.add_window(np.ones((4, 4)), np.ones((4, 4)), row=0, col=0) + + assert grids.is_complete() is True + + +def test_a_region_with_a_gap_is_not_complete_and_cannot_be_read() -> None: + """A weight of zero divides into nonsense rather than an error, so reading has to refuse.""" + grids = RegionGrids(1, 5) + window = np.ones((1, 2)) + + grids.add_window(window, window, row=0, col=0) + grids.add_window(window, window, row=0, col=3) + + assert grids.is_complete() is False + assert grids.unfilled_count() == 1 + with pytest.raises(ValueError): + grids.heights() + + +# --------------------------------------------------------------------------- +# tile cache: holding tiles +# --------------------------------------------------------------------------- + + +def test_a_tile_comes_back_out_unchanged() -> None: + cache = TileCache(capacity=4) + tile = np.arange(9.0).reshape(3, 3) + + cache.put(1234, tile) + + assert np.array_equal(cache.get(1234), tile) + + +def test_a_tile_that_was_never_generated_is_missing() -> None: + cache = TileCache(capacity=4) + + assert cache.get(1234) is None + + +def test_two_seeds_do_not_overwrite_each_other() -> None: + cache = TileCache(capacity=4) + + cache.put(1, np.zeros((2, 2))) + cache.put(2, np.ones((2, 2))) + + assert_allclose(cache.get(1), 0.0) + assert_allclose(cache.get(2), 1.0) + + +def test_a_stored_tile_is_not_changed_by_its_generator_afterwards() -> None: + cache = TileCache(capacity=4) + tile = np.zeros((2, 2)) + + cache.put(1, tile) + tile[0, 0] = 99 + + assert_allclose(cache.get(1), 0.0) + + +# --------------------------------------------------------------------------- +# tile cache: dropping tiles +# --------------------------------------------------------------------------- + + +def test_the_cache_never_holds_more_than_its_limit() -> None: + cache = TileCache(capacity=3) + + for seed in range(10): + cache.put(seed, np.zeros((2, 2))) + + assert len(cache) == 3 + + +def test_the_tile_dropped_is_the_least_recently_used_not_the_oldest() -> None: + """Reading tile 1 keeps it, so tile 2 goes instead. First in first out would drop 1.""" + cache = TileCache(capacity=2) + cache.put(1, np.zeros((2, 2))) + cache.put(2, np.zeros((2, 2))) + + cache.get(1) + cache.put(3, np.zeros((2, 2))) + + assert 1 in cache + assert 2 not in cache + assert 3 in cache + + +def test_a_tile_read_constantly_is_never_dropped() -> None: + cache = TileCache(capacity=2) + cache.put(0, np.zeros((2, 2))) + + for seed in range(1, 6): + cache.get(0) + cache.put(seed, np.zeros((2, 2))) + + assert cache.get(0) is not None + + +def test_a_dropped_tile_can_be_generated_again_and_put_back() -> None: + cache = TileCache(capacity=1) + cache.put(1, np.zeros((2, 2))) + cache.put(2, np.zeros((2, 2))) + + assert cache.get(1) is None + + cache.put(1, np.full((2, 2), 7.0)) + + assert_allclose(cache.get(1), 7.0) + + +def test_a_cache_that_holds_nothing_is_refused() -> None: + with pytest.raises(ValueError): + TileCache(capacity=0)