diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
| commit | 3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch) | |
| tree | 4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /tests/test_tiles.py | |
| download | worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip | |
worldmap-viewer: initial public history
Diffstat (limited to 'tests/test_tiles.py')
| -rw-r--r-- | tests/test_tiles.py | 1383 |
1 files changed, 1383 insertions, 0 deletions
diff --git a/tests/test_tiles.py b/tests/test_tiles.py new file mode 100644 index 0000000..bf0e618 --- /dev/null +++ b/tests/test_tiles.py @@ -0,0 +1,1383 @@ +import io +import json +import os +import shutil +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest import mock + +import numpy as np +from PIL import Image + +from mapgen.sphere import latlon_to_xyz + +import serve +import tiles as T +from tests.test_serve import Running, built_world, request +from tests.helpers import big_river + + +def tile_at(lat, lon, z): + span = 180.0 / 2 ** z + return int((lon + 180) // span), int((90 - lat) // span) + + +class TilesTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.root = built_world() + cls.cache = Path(tempfile.mkdtemp()) + cls.src = T.TileSource(serve.World(cls.root, 2), seed=7, cache_dir=cls.cache) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.cache) + + def test_tile_scheme(self): + self.assertTrue(T.tile_ok(0, 1, 0)) + self.assertFalse(T.tile_ok(0, 2, 0)) + self.assertFalse(T.tile_ok(3, 0, 8)) + self.assertFalse(T.tile_ok(17, 0, 0)) + lat, lon = T.tile_lat_lon(0, 0, 0, np.array([0, 255])) + self.assertAlmostEqual(lat[0], 90 - 0.5 / 256 * 180) + self.assertAlmostEqual(lon[1], -180 + 255.5 / 256 * 180) + + def test_coarse_zoom_is_the_build_raster(self): + f = self.src.fields(0, 0, 0, 0, ("z", "cat")) + base = self.src.bilinear(self.src.surface(), f["lat"], f["lon"]) + dry = ~f["lake"].astype(bool) # (lakes lie flat at their level) + np.testing.assert_array_equal(f["z"][dry], base[dry]) # the 8 km detail is below this zoom's pixel size + + def test_detail_appears_when_zoomed_in(self): + f = self.src.fields(12, *tile_at(10.0, 20.0, 12), 0, ("z",)) + base = self.src.bilinear(self.src.raster("elevation"), f["lat"], f["lon"]) + self.assertGreater(float(np.std(f["z"] - base)), 1.0) + self.assertTrue(np.all(np.isfinite(f["z"]))) + + def test_seams_match(self): + z, x, y = 7, *tile_at(5.0, 10.0, 7) + a = self.src.fields(z, x, y, 1, ("z", "cat")) + east = self.src.fields(z, x + 1, y, 1, ("z", "cat")) + south = self.src.fields(z, x, y + 1, 1, ("z", "cat")) + np.testing.assert_allclose(a["z"][:, -1], east["z"][:, 1], atol=1e-6) + np.testing.assert_array_equal(a["holdridge"][:, -1], east["holdridge"][:, 1]) + np.testing.assert_allclose(a["z"][-1, :], south["z"][1, :], atol=1e-6) + + def test_antimeridian_seam(self): + z = 6 + y = tile_at(0.0, 0.0, z)[1] + west = self.src.fields(z, 2 ** (z + 1) - 1, y, 1, ("z",)) + east = self.src.fields(z, 0, y, 1, ("z",)) + np.testing.assert_allclose(west["z"][:, -1], east["z"][:, 1], atol=1e-6) + + def test_detail_amplitude_is_continuous(self): + # blending landform amplitudes across cells must not jump where the set of nearest cells changes + lon = np.linspace(-60.0, 60.0, 40001) + amp, ridge, sea = self.src._amp(latlon_to_xyz(np.full_like(lon, 10.0), lon)) + for a in (amp, ridge, sea): + span = a.max() - a.min() + if span > 0: + self.assertLess(np.abs(np.diff(a)).max(), 0.02 * span) + + def test_coast_rule_is_continuous(self): + sea = np.linspace(0.0, 1.0, 1001) + for z in (-3000.0, -100.0, 0.0, 100.0, 3000.0): + w = T.coast_water(np.full_like(sea, z), sea) + self.assertFalse(w[sea < 0.001].any()) + self.assertTrue(w[sea > 0.999].all()) + self.assertEqual(int(np.sum(np.diff(w.astype(int)) != 0)), 1, z) # one clean switch, no hard cut at a threshold + self.assertEqual(bool(T.coast_water(np.array([z]), np.array([0.5]))[0]), z < 0) + # below sea level, water reaches further into the mixed band than a flat threshold would allow + self.assertTrue(T.coast_water(np.array([-3000.0]), np.array([0.05]))[0]) + + def test_coast_band(self): + f = self.src.fields(4, *tile_at(10.0, -70.0, 4), 0, ("z", "water")) # an 11° tile over the small world's west coast + self.assertTrue(f["water"][f["sea"] > 0.999].all()) + self.assertFalse(f["water"][f["sea"] < 0.001].any()) + self.assertTrue(f["water"].any() and (~f["water"]).any(), "tile should hold both land and sea") + + def test_layers_render(self): + for layer in T.LAYERS: + im = Image.open(io.BytesIO(self.src.tile(layer, 5, *tile_at(10.0, 20.0, 5)))) + self.assertEqual((im.format, im.size), ("JPEG", (256, 256)), layer) + h = Image.open(io.BytesIO(self.src.tile("height", 5, *tile_at(10.0, 20.0, 5)))) + self.assertEqual((h.format, h.size), ("PNG", (256, 256))) + self.assertIn(h.mode, ("I;16", "I")) + for y in (0, 2 ** 5 - 1): # polar rows + Image.open(io.BytesIO(self.src.tile("relief", 5, 3, y))).load() + + def test_deterministic_and_cached(self): + args = ("relief", 9, *tile_at(12.0, 25.0, 9)) + a = self.src.tile(*args) + other = T.TileSource(serve.World(self.root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp())) + self.assertEqual(a, other.render(*args)) + self.assertTrue((self.cache / self.src.tag / "relief" / "9").is_dir()) + t = time.time() + self.src.tile(*args) + self.assertLess(time.time() - t, 0.05) + + def test_speed(self): + t = time.time() + self.src.render("relief", 14, *tile_at(3.0, 5.0, 14)) + self.assertLess(time.time() - t, 3.0) + + def test_fingerprint_follows_the_build(self): + import os + a = T.TileSource(serve.World(self.root, 2), seed=7) + self.assertEqual(a.fingerprint, T.TileSource(serve.World(self.root, 2), seed=7).fingerprint) + self.assertNotEqual(a.fingerprint, T.TileSource(serve.World(self.root, 2), seed=8).fingerprint) + self.assertIn(a.fingerprint, str(a.cache_dir)) + self.assertIn(a.fingerprint, a.meta()["url"]) + elev = self.root / "out" / "r2" / "raster" / "elevation.png" + st = elev.stat() + os.utime(elev, ns=(st.st_atime_ns, st.st_mtime_ns + 10**9)) # a rebuild rewrites the rasters + self.assertNotEqual(a.fingerprint, T.TileSource(serve.World(self.root, 2), seed=7).fingerprint) + + def test_z_at(self): + z = self.src.z_at(10.0, 20.0) + self.assertEqual(z.shape, (1,)) + self.assertTrue(np.isfinite(z[0])) + + + def test_mesh_heights(self): + z, (x, y) = 9, tile_at(40.0, 10.0, 9) + h = self.src.mesh(z, x, y)[0] + self.assertEqual(h.shape, (T.MESH_N, T.MESH_N)) + self.assertTrue(np.isfinite(h).all()) + np.testing.assert_array_equal(h[:, -1], self.src.mesh(z, x + 1, y)[0][:, 0]) # shared edges: same points, same heights + np.testing.assert_array_equal(h[-1, :], self.src.mesh(z, x, y + 1)[0][0, :]) + n = 2 ** (z + 1) + np.testing.assert_allclose(self.src.mesh(z, n - 1, y)[0][:, -1], self.src.mesh(z, 0, y)[0][:, 0], atol=1e-3) + + def test_mesh_true_heights_and_water(self): + for x in range(64): # a coastal z5 tile + h, water = self.src.mesh(5, x, 12) + if water.any() and (~water).any(): + break + else: + self.fail("no coastal z5 tile on row 12") + span = 180.0 / 32 + f = np.arange(T.MESH_N) / (T.MESH_N - 1) + LA, LO = np.meshgrid(90.0 - (12 + f) * span, -180.0 + (x + f) * span, indexing="ij") + z, wet = self.src.surface_at(LA.ravel(), LO.ravel(), span / 128 * np.pi / 180 * self.src.R) + np.testing.assert_allclose(h, z.reshape(h.shape), atol=1e-6) + self.assertLess(float(h[water].min()), -100.0, "the sea floor, not the water surface") + self.assertTrue(np.all(h[water] < 60.0), "water only where the coast rule puts sea") + + def test_mesh_cached_and_fast(self): + args = (11, *tile_at(-20.0, -35.0, 11)) + t = time.time() + b = self.src.tile("mesh", *args) + self.assertLess(time.time() - t, 0.5) + self.assertEqual(len(b), T.MESH_BYTES) + h, water = self.src.mesh(*args) + n2 = T.MESH_N * T.MESH_N + np.testing.assert_array_equal(np.frombuffer(b[:4 * n2], "<f4"), h.astype("<f4").ravel()) + np.testing.assert_array_equal(np.frombuffer(b[4 * n2:], np.uint8), water.astype(np.uint8).ravel()) + self.assertTrue((self.cache / self.src.tag / "mesh" / "11" / str(args[1]) / f"{args[2]}.bin").is_file()) + + def test_a_saved_mesh_of_the_wrong_size_is_rendered_again(self): + args = (10, *tile_at(-21.0, -36.0, 10)) + f = self.src.cache_path("mesh", *args) + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(b"\0" * (T.MESH_N * T.MESH_N * 4)) + self.assertEqual(len(self.src.tile("mesh", *args, remember=False)), T.MESH_BYTES) + self.assertEqual(f.stat().st_size, T.MESH_BYTES) + + def test_surface_matches_the_tile_pixels(self): + z, (x, y) = 8, tile_at(10.0, -70.0, 8) # the test world's west coast + f = self.src.fields(z, x, y, 0, ("z", "cat")) + zz, water = self.src.surface_at(f["lat"].ravel(), f["lon"].ravel(), f["px_km"]) + tile_water = (f["water"] | f["lake"].astype(bool)).ravel() + self.assertLess(np.mean(water != tile_water), 0.02) + self.assertLess(np.mean(np.abs(zz - f["z"].ravel())), 10.0) + + + def _river_tile(self, z): + net = self.src.river_net + a = self.src.w.arrays + wet = a["lake"].astype(bool) | a["ocean"].astype(bool) + ok = a["river"][net.seg_a].astype(bool) & ~wet[net.seg_a] & ~wet[a["recv"][net.seg_a]] # on over land + i = int(net.seg_a[np.argmax(np.where(ok, net.half_w, 0))]) # the widest river + xyz = self.src.w.arrays["g_xyz"][i] + lat, lon = np.degrees(np.arcsin(xyz[2])), np.degrees(np.arctan2(xyz[1], xyz[0])) + return (z, *tile_at(lat, lon, z)) + + def test_valleys_cut_never_raise_and_hold_water(self): + args = self._river_tile(9) + f = self.src.fields(*args, 0, ("z", "cat")) + raw = self.src.fields(*args, 0, ("z", "cat"), valleys=False) + rel = self.src.relief_at(f["lat"].ravel(), f["lon"].ravel(), f["px_km"]).reshape(f["z"].shape) + self.assertTrue(np.all(f["z"] <= np.maximum(raw["z"], rel) + 1.0), "at most a micro-relief dip is kept dry") + self.assertTrue(f["river"].any(), "the river channel is water in its tile") + self.assertTrue((raw["z"] - f["z"]).max() > 1.0, "a valley was cut") + + def test_valley_seams_match(self): + z, x, y = self._river_tile(10) + a = self.src.fields(z, x, y, 1, ("z",)) + east = self.src.fields(z, x + 1, y, 1, ("z",)) + np.testing.assert_allclose(a["z"][:, -1], east["z"][:, 1], atol=1e-6) + + def test_valley_tiles_stay_fast_near_big_rivers(self): + for z in (12, 16): + args = self._river_tile(z) + t = time.time() + self.src.render("relief", *args) + self.assertLess(time.time() - t, 1.5, f"z{z}") + t = time.time() + self.src.surface_at(np.linspace(-40, 40, 256), np.full(256, 20.0), 20.0) # a 17,800 km profile + self.assertLess(time.time() - t, 0.5) + + def test_valleys_fade_in(self): + z, x, y = self._river_tile(5) + f = self.src.fields(z, x, y, 0, ("z",)) + raw = self.src.fields(z, x, y, 0, ("z",), valleys=False) + self.assertLess(np.abs(f["z"] - raw["z"]).max(), 2000.0) + + + def test_relief_draws_the_river(self): + args = self._river_tile(11) + f = self.src.fields(*args, 1, ("z", "water", "cat")) + img = self.src.rgb("relief", *args) + lake = np.array([79, 143, 192]) + river = f["river_draw"][1:-1, 1:-1] & ~f["water"][1:-1, 1:-1] + self.assertTrue(river.any()) + shade = img[river].astype(float) / lake + self.assertTrue(np.all(np.abs(shade - shade[:, :1]) < 0.02), "river pixels are the (shaded) water colour") + + def test_tiles_draw_micro_relief_over_the_refine_surface(self): + z, x, y = 15, *tile_at(12.0, 25.0, 15) + f = self.src.fields(z, x, y, 0, ("z",), valleys=False) + rel = self.src.relief_at(f["lat"].ravel(), f["lon"].ravel(), f["px_km"]).reshape(f["z"].shape) + d = f["z"] - rel # relief_at (refine's start) stays without it + self.assertGreater(float(np.std(d)), 0.5) + self.assertLess(float(np.abs(d).max()), 60.0) + + def test_micro_relief_never_digs_dry_ground_below_the_river(self): + z, x, y = self._river_tile(14) + f = self.src.fields(z, x, y, 0, ("z",)) + ch, zz = f["river"], f["z"] + self.assertTrue(ch.any() and (~ch).any()) + for s in ((0, 1), (0, -1), (1, 0), (-1, 0)): # dry pixels beside the channel: not below its water + wet, h = np.roll(ch, s, (0, 1)), np.roll(zz, s, (0, 1)) + dry = wet & ~ch + dry[[0, -1], :] = dry[:, [0, -1]] = False + self.assertTrue(np.all(zz[dry] >= h[dry] - 1e-6), s) + + def test_land_colour_varies_within_one_zone(self): + args = (13, *tile_at(12.0, 25.0, 13)) + f = self.src.fields(*args, 0, ("z", "water", "cat")) + img = self.src.rgb("relief", *args).astype(float) + land = ~f["water"] & ~f["lake"].astype(bool) & ~f["river_draw"] & (f["ice"] == 0) + zone = f["holdridge"] == np.bincount(f["holdridge"][land]).argmax() + hue = (img[..., 0] - img[..., 1]) / (img[..., 0] + img[..., 1] + 1.0) + self.assertGreater(float(np.std(hue[land & zone])), 0.004, "not one flat colour under the hillshade") + self.assertLess(float(np.std(hue[land & zone])), 0.05) + + +class RegionTilesTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + import refine as RF + cls.root = built_world() + from tests.test_refine import clear_regions + clear_regions(cls.root) + w = RF.WorldCells(cls.root / "out" / "r2") + i = big_river(w.a) + cls.lat, cls.lon = float(w.a["g_lat"][i]), float(w.a["g_lon"][i]) + reg = cls.root / "places" / "regions.json" + reg.parent.mkdir(exist_ok=True) + d = 3 + reg.write_text(json.dumps({"regions": [{"id": "r", "name": "t", "outline": [[cls.lat - d, cls.lon - d], [cls.lat - d, cls.lon + d], [cls.lat + d, cls.lon + d], [cls.lat + d, cls.lon - d]]}]})) + from mapgen import config as C + seed = int(C.load(cls.root)[0]["build"]["seed"]) # the build's seed, as the server uses + cls.plain = T.TileSource(serve.World(cls.root, 2), seed=seed, cache_dir=Path(tempfile.mkdtemp())) + RF.build_areas(cls.root, 2, reg, log=lambda s: None) + cls.src = T.TileSource(serve.World(cls.root, 2), seed=seed, cache_dir=Path(tempfile.mkdtemp())) + + @classmethod + def tearDownClass(cls): + from tests.test_refine import clear_regions + clear_regions(cls.root) + + def test_tag_changes_with_regions(self): + self.assertFalse(self.src.regions.empty) + self.assertNotEqual(self.src.tag, self.plain.tag) + self.assertRegex(self.src.tag, r"^v\d+\.\d+-[0-9a-f]+$") + + def test_region_tile_urls_follow_the_region_rendering_version(self): + from unittest import mock + with mock.patch.object(T, "REGION_VERSION", T.REGION_VERSION + 1): + other = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp())) + self.assertNotEqual(other.tag, self.src.tag, "new region rendering: new tile URLs, no stale saved tiles") + self.assertEqual(other.base_tag, self.src.base_tag, "the world's own tiles stay") + self.assertRegex(other.tag, r"^v\d+\.\d+-[0-9a-f]+$") + + def test_inside_uses_refined_heights(self): + z, (x, y) = 8, tile_at(self.lat, self.lon, 8) + a, b = self.src.fields(z, x, y, 0, ("z",)), self.plain.fields(z, x, y, 0, ("z",)) + self.assertGreater(np.abs(a["z"] - b["z"]).mean(), 1.0) + + def test_region_edge_seams(self): + for dx in (-3, -2, 2, 3): + z = 8 + x, y = tile_at(self.lat, self.lon + dx, z) + a, e = self.src.fields(z, x, y, 1, ("z",)), self.src.fields(z, x + 1, y, 1, ("z",)) + np.testing.assert_allclose(a["z"][:, -1], e["z"][:, 1], atol=1e-6) + + def test_blend_band_has_no_step(self): + lat = np.full(400, self.lat) + lon = self.lon + np.linspace(2.0, 4.0, 400) # across the east edge (lon + 3) + z = self.src.z_at(lat, lon, 1.0) + xyz = latlon_to_xyz(lat, lon).reshape(-1, 3) + lake = self.src._flat_lakes(np.zeros(len(lat)), lat, lon, *self.src._warp_offsets(xyz, 4.0)) != 0 + shore = lake[1:] != lake[:-1] # (a world lake's shore may be a bank) + k = int(np.argmax(np.where(shore, 0.0, np.abs(np.diff(z))))) + fine = np.linspace(lon[k], lon[k + 1], 41) # 10× finer around the worst step + zf = self.src.z_at(np.full(41, self.lat), fine, 1.0) + self.assertLess(np.abs(np.diff(zf)).max(), 0.3 * abs(z[k + 1] - z[k]) + 1.0, + "continuous: the worst step shrinks with the sample spacing (a cliff would not)") + + def test_outside_unchanged_and_no_regions_unchanged(self): + z, (x, y) = 8, tile_at(self.lat + 20, self.lon, 8) + np.testing.assert_array_equal(self.src.fields(z, x, y, 0, ("z",))["z"], self.plain.fields(z, x, y, 0, ("z",))["z"]) + self.assertTrue(self.plain.regions.empty) + + def test_inspector_answers_refined(self): + w = serve.World(self.root, 2) + w.regions = self.src.regions + d = w.describe(self.lat, self.lon) + self.assertTrue(d.get("refined")) + far = w.describe(self.lat + 20, self.lon) + self.assertFalse(far.get("refined", False)) + + + def _exit(self): + import refine as RF + (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint) + a = dict(np.load(d / "cells.npz")) + halo = a["halo"].astype(bool) + feed = np.where(a["river"].astype(bool) & ~halo & halo[a["recv"]])[0] + return a, feed + + def test_rivers_run_on_to_the_halo(self): + a, feed = self._exit() + net = self.src.regions.net + self.assertTrue(len(feed), "the test area has rivers leaving it") + self.assertTrue(np.all(net.seg_len > 0), "no zero-length segments") + ends = {tuple(np.round(v, 9)) for v in net.b_xyz} + for i in feed: + self.assertIn(tuple(np.round(a["g_xyz"][a["recv"][i]], 9)), ends, "the river reaches its halo cell") + + def test_world_rivers_stay_drawn_across_the_blend_band(self): + a, feed = self._exit() + found = False + for i in feed: + lat, lon = float(a["g_lat"][a["recv"][i]]), float(a["g_lon"][a["recv"][i]]) + for dz in (7, 8): + x, y = tile_at(lat, lon, dz) + f, g = self.src.fields(dz, x, y, 0, ("z",)), self.plain.fields(dz, x, y, 0, ("z",)) + xyz = latlon_to_xyz(f["lat"], f["lon"]).reshape(-1, 3) + w = self.src.regions.weight(xyz).reshape(f["z"].shape) + band = g["river_draw"] & (w < 1) + if band.any(): + found = True + self.assertTrue(np.all(f["river_draw"][band]), "world rivers drawn wherever the world still shows") + self.assertTrue(found) + + def test_region_valleys_reach_up_to_the_shoulders(self): + import refine as RF + import rivers as RV + (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint) + tmp = Path(tempfile.mkdtemp()) + dst = RF.results_dir(tmp, "k") / d.name + shutil.copytree(d, dst) + a = dict(np.load(dst / "cells.npz")) + src = ~np.isin(np.arange(len(a["recv"])), a["recv"][a["recv"] != np.arange(len(a["recv"]))]) + dry = src & ~a["halo"].astype(bool) & ~a["ocean"].astype(bool) & ~a["lake"].astype(bool) # (coarse test cells + a["river"] = a["river"].astype(bool) & ~dry # are all rivers) + a["z_surface_m"] = np.where(dry, a["z_surface_m"] + 500.0, a["z_surface_m"]) # canyons + np.savez(dst / "cells.npz", **a) + rs = RF.RegionSet(tmp, "k", self.src.w.legends, self.src.R) + plain = RV.RiverNet(rs.arrays, self.src.w.legends, rs.R, levels=rs.arrays["z_surface_m"]) + self.assertTrue(np.any(rs.net.reach[: len(plain.reach)] > plain.reach + 1e-9), "walls reach up to the shoulders") + + def test_profile_inside_ignores_world_lakes(self): + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp())) + src.w.arrays = dict(src.w.arrays, lake=np.ones(len(src.w.arrays["g_ids"]), bool)) # lakes everywhere + lat, lon = np.full(50, self.lat), self.lon + np.linspace(-0.5, 0.5, 50) + np.testing.assert_array_equal(src.surface_at(lat, lon)[1], self.src.surface_at(lat, lon)[1]) + + def test_inspector_band_answers_from_the_world(self): + rs = self.src.regions + a, feed = self._exit() + pa, pb = a["g_xyz"][feed[0]], a["g_xyz"][a["recv"][feed[0]]] + pts = pa + np.linspace(0.0, 0.6, 121)[:, None] * (pb - pa) # from the border cell toward the halo + pts /= np.linalg.norm(pts, axis=1, keepdims=True) + lat, lon = np.degrees(np.arcsin(pts[:, 2])), np.degrees(np.arctan2(pts[:, 1], pts[:, 0])) + band = [k for k in range(len(pts)) if rs.weight(pts[k:k + 1])[0] < 0.5 and rs.index_of(lat[k], lon[k]) is not None] + self.assertTrue(band, "a point of a refined cell in the outer blend band") + world = serve.World(self.root, 2) + world.regions = rs + d = world.describe(float(lat[band[0]]), float(lon[band[0]])) + self.assertFalse(d["refined"]) + self.assertEqual(d["inner_km"], 0.0, "no reuse of an answer next to a refined area") + + def test_a_server_without_regions_leaves_region_tile_caches_alone(self): + root = self.root / "out" / "r2" / "tiles" + plain = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, regions_dir=Path(tempfile.mkdtemp())) # no regions + other_world, region = root / "v1-deadbeef00", root / (plain.base_tag + "abc123") + for d in (other_world, region): + d.mkdir(parents=True, exist_ok=True) + plain.warm() + self.assertFalse(other_world.exists(), "another world's tiles go") + self.assertTrue(region.exists(), "a smoke server must not wipe the real server's region tiles") + shutil.rmtree(region) + + def test_worker_processes_render_the_same_tiles(self): + from concurrent.futures import ThreadPoolExecutor + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp())) + src.start_workers(2) + try: + args = [("relief", 8, *tile_at(self.lat, self.lon, 8)), ("mesh", 8, *tile_at(self.lat, self.lon, 8)), + ("relief", 6, *tile_at(self.lat + 20, self.lon, 6))] + with ThreadPoolExecutor(3) as ex: + got = list(ex.map(lambda a: src.tile(*a), args)) + self.assertEqual(got, [self.src.render(*a) for a in args]) + self.assertEqual(src.pool.alive, 2) + finally: + src.pool.close() + + def test_workers_exit_when_the_server_process_dies(self): + import os + import signal + import subprocess + import sys + import textwrap + code = textwrap.dedent(f""" + import sys, socket, tempfile, pathlib; sys.path.insert(0, {str(Path(T.__file__).parent)!r}) + import tiles as T, serve + lsock = socket.socket(); lsock.bind(("127.0.0.1", 0)); lsock.listen() # a server's listening socket + src = T.TileSource(serve.World(pathlib.Path({str(self.root)!r}), 2), seed=7, cache_dir=pathlib.Path(tempfile.mkdtemp())) + src.start_workers(2) + print(lsock.getsockname()[1], *[p.pid for p in src.pool.procs], flush=True) + import time; time.sleep(60) + """) + p = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True) + port, *pids = map(int, p.stdout.readline().split()) + os.kill(p.pid, signal.SIGKILL) # no atexit: as a killed server + p.wait() + deadline = time.time() + 10 + while time.time() < deadline and any(os.path.exists(f"/proc/{q}") and open(f"/proc/{q}/stat").read().split()[2] != "Z" + for q in pids): + time.sleep(0.2) + alive = [q for q in pids if os.path.exists(f"/proc/{q}") and open(f"/proc/{q}/stat").read().split()[2] != "Z"] + for q in alive: + os.kill(q, signal.SIGKILL) + self.assertEqual(alive, [], "orphaned render workers linger") + import socket + s = socket.socket() + self.assertNotEqual(s.connect_ex(("127.0.0.1", port)), 0, "nobody still holds the server's listening socket") + s.close() + + def test_workers_follow_a_region_reload(self): + import refine as RF + empty = Path(tempfile.mkdtemp()) + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), + regions_dir=empty) + src.start_workers(1) + try: + args = ("relief", 8, *tile_at(self.lat, self.lon, 8)) + self.assertEqual(src.tile(*args), self.plain.render(*args)) + (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint) + shutil.copytree(d, RF.results_dir(empty, src.fingerprint) / d.name) + src.reload_regions() + self.assertEqual(src.tile(*args), self.src.render(*args)) + finally: + src.pool.close() + + def test_prerender_saves_the_region_tiles(self): + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp())) + seen = [] + n = src.prerender(max_z=6, progress=lambda done, total: seen.append((done, total))) + for z in (5, 6): + for layer in ("relief", "mesh"): + self.assertTrue(src.cache_path(layer, z, *tile_at(self.lat, self.lon, z)).exists(), (layer, z)) + self.assertFalse(src.cache_path("relief", 6, *tile_at(self.lat + 40, self.lon, 6)).exists()) + self.assertGreater(n, 0) + self.assertEqual(seen[-1], (n, n)) + self.assertEqual(src.prerender(max_z=6), 0, "saved tiles are not rendered again") + + def _empty_src(self): + return T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), + regions_dir=Path(tempfile.mkdtemp())) + + def _add_area(self, src): + import refine as RF + (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint) + dst = RF.results_dir(src.regions_dir, src.fingerprint) / d.name + shutil.copytree(d, dst) + return dst + + def test_a_reload_during_a_render_never_caches_mixed_tiles(self): + src = self._empty_src() + args = ("relief", 8, *tile_at(self.lat, self.lon, 8)) + orig, calls = src.render, [] + + def render(*a): + data = orig(*a) # rendered with the old (no) regions … + if not calls: + calls.append(1) + self._add_area(src) + src.reload_regions() # … while a build finished + return data + src.render = render + want = self.src.render(*args) + self.assertEqual(src.tile(*args), want) + self.assertEqual(src.tile(*args), want, "nor from the memory cache") + base = src.cache_root / src.base_tag / "relief" / "8" / str(args[2]) / f"{args[3]}.jpg" + self.assertFalse(base.exists(), "no pre-build bytes in the world's cache for a tile the region covers") + + def test_workers_never_render_a_half_built_region_set(self): + src = self._empty_src() + src.start_workers(1) # forked without regions + try: + area = self._add_area(src) + src.reload_regions() # the server has the region … + shutil.rmtree(area) # … a new build has pruned it on disk meanwhile + args = ("relief", 8, *tile_at(self.lat, self.lon, 8)) + self.assertEqual(src.tile(*args), self.src.render(*args)) + finally: + src.pool.close() + + def test_dead_workers_never_hang_tile_requests(self): + import os + import signal + src = self._empty_src() + orig = src.render + src.render = lambda *a: (time.sleep(1.0), orig(*a))[1] # slow renders (the forked worker inherits this) + src.start_workers(1) + try: + args = [("relief", 9, *tile_at(self.lat, self.lon, 9)), ("relief", 9, *tile_at(self.lat + 10, self.lon, 9))] + got = [None, None] + + def run(k): + got[k] = src.tile(*args[k]) + ts = [threading.Thread(target=run, args=(k,), daemon=True) for k in (0, 1)] + ts[0].start() + time.sleep(0.15) # the first request is inside the worker … + ts[1].start() + time.sleep(0.15) # … the second waits for it … + os.kill(src.pool.procs[0].pid, signal.SIGKILL) # … and the worker dies + for t in ts: + t.join(30) + self.assertEqual(got, [self.plain.render(*a) for a in args], "both requests answered (rendered here)") + finally: + src.pool.close() + + def test_a_region_change_keeps_the_saved_tiles_of_unchanged_areas(self): + import refine as RF + from tests.test_refine import square + tmp = Path(tempfile.mkdtemp()) + far_lat = self.lat - 50 if self.lat > 0 else self.lat + 50 + a, b = square(self.lat, self.lon, 3, "a"), square(far_lat, self.lon + 90, 3, "b") + (tmp / "one.json").write_text(json.dumps({"regions": [a]})) + (tmp / "two.json").write_text(json.dumps({"regions": [a, b]})) + res = tmp / "results" + RF.build_areas(self.root, 2, tmp / "one.json", log=lambda s: None, regions_root=res) + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=tmp / "cache", regions_dir=res) + first = src.prerender(max_z=6, layers=("relief",)) + near_a = ("relief", 6, *tile_at(self.lat, self.lon, 6)) + old_tag = src.tag + RF.build_areas(self.root, 2, tmp / "two.json", log=lambda s: None, regions_root=res) + src.reload_regions() + self.assertNotEqual(src.tag, old_tag) + self.assertTrue(src.cache_path(*near_a).exists(), "area a's saved tiles moved to the new region set") + self.assertFalse((src.cache_root / old_tag).exists()) + again = src.prerender(max_z=6, layers=("relief",)) + self.assertGreater(again, 0, "area b's tiles are new") + self.assertLess(again, first + again - 1, "a's are not rendered again") + self.assertLessEqual(again, first * 1.5) + + def _two_areas(self): + import refine as RF + from tests.test_refine import square + tmp = Path(tempfile.mkdtemp()) + far_lat = self.lat - 50 if self.lat > 0 else self.lat + 50 + a, b = square(self.lat, self.lon, 3, "a"), square(far_lat, self.lon + 90, 3, "b") + (tmp / "one.json").write_text(json.dumps({"regions": [a]})) + (tmp / "two.json").write_text(json.dumps({"regions": [a, b]})) + RF.build_areas(self.root, 2, tmp / "one.json", log=lambda s: None, regions_root=tmp / "results") + return tmp + + def _source(self, tmp): + return T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=tmp / "cache", + regions_dir=tmp / "results") + + def _rekey(self, src): + """Area a's content changes (as after a new refine model or config) while its outline stays.""" + import refine as RF + (link,) = RF.built_areas(src.regions_dir, src.fingerprint) + old = Path(os.path.realpath(link)) + new = old.with_name(old.name + "x") + shutil.copytree(old, new) + os.utime(new / "cells.npz", ns=(time.time_ns(), time.time_ns() + 10 ** 9)) + link.unlink() + link.symlink_to(os.path.relpath(new, link.parent)) + + def test_a_region_change_while_stopped_keeps_the_saved_tiles_of_unchanged_areas(self): + import refine as RF + tmp = self._two_areas() + src = self._source(tmp) + src.carry_stale() # a server start + src.prerender(max_z=6, layers=("relief",)) + near_a = ("relief", 6, *tile_at(self.lat, self.lon, 6)) + old_tag = src.tag + RF.build_areas(self.root, 2, tmp / "two.json", log=lambda s: None, regions_root=tmp / "results") + src = self._source(tmp) # the next start + self.assertNotEqual(src.tag, old_tag) + src.carry_stale() + self.assertTrue(src.cache_path(*near_a).exists(), "area a's saved tiles moved to the new region set") + self.assertFalse((src.cache_root / old_tag).exists()) + + def test_saved_tiles_of_an_area_whose_content_changed_are_dropped(self): + tmp = self._two_areas() + src = self._source(tmp) + src.carry_stale() + src.prerender(max_z=6, layers=("relief",)) + near_a = ("relief", 6, *tile_at(self.lat, self.lon, 6)) + self._rekey(src) + src.reload_regions() + self.assertFalse(src.cache_path(*near_a).exists(), "a live reload drops them") + src.carry_stale() + src.prerender(max_z=6, layers=("relief",)) + self._rekey(src) + src = self._source(tmp) + src.carry_stale() + self.assertFalse(src.cache_path(*near_a).exists(), "so does the next start") + + def test_saved_region_tiles_drawn_by_an_older_region_version_are_not_carried_over(self): + tmp = self._two_areas() + src = self._source(tmp) + src.carry_stale() + src.prerender(max_z=6, layers=("relief",)) + near_a = ("relief", 6, *tile_at(self.lat, self.lon, 6)) + with mock.patch.object(T, "REGION_VERSION", T.REGION_VERSION + 1): # the next start draws refined areas anew + src = self._source(tmp) + src.carry_stale() + self.assertFalse(src.cache_path(*near_a).exists(), "tiles drawn the old way re-render") + + def test_prerendering_never_delays_the_server_exit(self): + import subprocess + import sys + import textwrap + code = textwrap.dedent(f""" + import sys, tempfile, threading, time, pathlib; sys.path.insert(0, {str(Path(T.__file__).parent)!r}) + import tiles as T, serve + src = T.TileSource(serve.World(pathlib.Path({str(self.root)!r}), 2), seed={self.src.seed}, + cache_dir=pathlib.Path(tempfile.mkdtemp())) + threading.Thread(target=src.prerender, kwargs=dict(max_z=12, threads=2), daemon=True).start() + time.sleep(1.0) + print("exiting", flush=True) # as after Ctrl-C: main returns + """) + t = time.time() + p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=120) + self.assertIn("exiting", p.stdout, p.stderr[-500:]) + self.assertLess(time.time() - t, 20, "the process ends without rendering the whole queue first") + + def test_a_reload_forks_fresh_workers_sharing_the_new_regions(self): + src = self._empty_src() + src.start_workers(2) + try: + before = [p.pid for p in src.pool.procs] + self._add_area(src) + src.reload_regions() + after = [p.pid for p in src.pool.procs] + self.assertTrue(set(before).isdisjoint(after), "new processes (they share the loaded regions)") + self.assertEqual(src.pool.alive, 2) + args = ("relief", 8, *tile_at(self.lat, self.lon, 8)) + self.assertEqual(src.tile(*args), self.src.render(*args)) + time.sleep(0.5) + self.assertFalse(any(os.path.exists(f"/proc/{q}") and open(f"/proc/{q}/stat").read().split()[2] != "Z" + for q in before), "the old workers are gone") + finally: + src.pool.close() + + def test_workers_forked_by_a_short_lived_thread_keep_working(self): + src = self._empty_src() + src.start_workers(2) + try: + self._add_area(src) + t = threading.Thread(target=src.reload_regions) # as a region build's thread does + t.start() + t.join() + time.sleep(0.5) # the forking thread is gone now + self.assertEqual(src.pool.alive, 2) + self.assertTrue(all(p.is_alive() for p in src.pool.procs), "workers outlive the thread that forked them") + args = ("relief", 8, *tile_at(self.lat, self.lon, 8)) + data = src.pool.render(src.name, *args, src.regions.fingerprint) # straight to a worker, no fallback + self.assertEqual(data, self.src.render(*args)) + finally: + src.pool.close() + + def test_prerender_keeps_to_a_tile_budget_by_zoom(self): + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp())) + full = src.prerender(max_z=6, layers=("relief",)) + src2 = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp())) + seen = [] + n = src2.prerender(max_z=6, layers=("relief",), max_tiles=full - 1, progress=lambda d, t: seen.append(t)) + self.assertLess(n, full, "the deepest zoom that would overrun the budget is left out") + self.assertLessEqual(seen[-1], full - 1) + self.assertTrue(src2.cache_path("relief", 5, *tile_at(self.lat, self.lon, 5)).exists(), "coarse zooms first") + + def test_no_water_walls_where_a_lake_sits_on_a_cliff(self): + import refine as RF + (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint) + tmp = Path(tempfile.mkdtemp()) + dst = RF.results_dir(tmp, self.src.fingerprint) / d.name + shutil.copytree(d, dst) + a = dict(np.load(dst / "cells.npz")) + halo, xyz = a["halo"].astype(bool), a["g_xyz"] + land = ~halo & ~a["ocean"].astype(bool) & ~a["lake"].astype(bool) + mid = latlon_to_xyz(self.lat, self.lon).reshape(3) # well inside the area: by its centre + c = int(np.where(land)[0][np.argmin(np.linalg.norm(xyz[land] - mid, axis=1))]) + a["river"][c] = False # (the coarse test cells are all rivers) + nb = np.argsort(np.linalg.norm(xyz - xyz[c], axis=1))[1:7] # its six neighbours: a cliff below it + a["z_surface_m"][nb] = a["z_surface_m"][c] - 1500.0 + a["lake"][c], a["lake_id"][c] = True, int(a["lake_id"].max()) + 1 + a["z_filled_m"][c] = a["z_surface_m"][c] + 50.0 + a["depression_depth_m"][c] = 50.0 + np.savez(dst / "cells.npz", **a) + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), regions_dir=tmp) + p = xyz[c] + np.linspace(0, 1, 400)[:, None] * (xyz[nb[0]] - xyz[c]) # from the lake over the cliff edge + p /= np.linalg.norm(p, axis=1, keepdims=True) + lat, lon = np.degrees(np.arcsin(p[:, 2])), np.degrees(np.arctan2(p[:, 1], p[:, 0])) + z = src.z_at(lat, lon, 1.0) + step = np.linalg.norm(p[1] - p[0]) * src.R + self.assertLess(np.abs(np.diff(z)).max(), 50.0 + RF.LAKE_FLOOR_M + 60.0, + "the lake's edge stands at most its depth (+ margin, detail) over the drop, not 1.5 km") + + def test_no_dry_pits_inside_a_lake(self): + import refine as RF + (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint) + tmp = Path(tempfile.mkdtemp()) + dst = RF.results_dir(tmp, self.src.fingerprint) / d.name + shutil.copytree(d, dst) + a = dict(np.load(dst / "cells.npz")) + halo, xyz = a["halo"].astype(bool), a["g_xyz"] + land = ~halo & ~a["ocean"].astype(bool) + mid = latlon_to_xyz(self.lat, self.lon).reshape(3) # well inside the area: by its centre + c = int(np.where(land)[0][np.argmin(np.linalg.norm(xyz[land] - mid, axis=1))]) + lake = np.argsort(np.linalg.norm(xyz - xyz[c], axis=1))[:7] # a cell and its ring: one lake + bed = float(a["z_surface_m"][c]) + a["z_surface_m"][lake] = bed + a["lake"][lake], a["river"][lake] = True, False + a["lake_id"][lake] = int(a["lake_id"].max()) + 1 + a["z_filled_m"][lake] = bed + 100.0 + a["depression_depth_m"][lake] = 100.0 + np.savez(dst / "cells.npz", **a) + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), regions_dir=tmp) + rng = np.random.default_rng(3) + sp = src.regions.spacing_km / src.R + p = xyz[c] + rng.normal(size=(400, 3)) * sp * 0.12 # well inside the lake (its middle + p /= np.linalg.norm(p, axis=1, keepdims=True) # cell and ring: 90 km cells here) + lat, lon = np.degrees(np.arcsin(p[:, 2])), np.degrees(np.arctan2(p[:, 1], p[:, 0])) + z, water = src.surface_at(lat, lon, 1.0) + below = z < bed + 100.0 - 1e-6 + self.assertFalse((below & ~water).any(), f"{(below & ~water).sum()} dry pits below the lake's surface") + np.testing.assert_allclose(z[water], bed + 100.0, atol=1e-6) # (detail may rise as islands) + + def test_no_dry_pits_where_a_lake_bed_deepens_between_its_cells(self): + import refine as RF + (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint) + tmp = Path(tempfile.mkdtemp()) + dst = RF.results_dir(tmp, self.src.fingerprint) / d.name + shutil.copytree(d, dst) + a = dict(np.load(dst / "cells.npz")) + halo, xyz = a["halo"].astype(bool), a["g_xyz"] + land = ~halo & ~a["ocean"].astype(bool) + mid = latlon_to_xyz(self.lat, self.lon).reshape(3) # well inside the area: by its centre + c = int(np.where(land)[0][np.argmin(np.linalg.norm(xyz[land] - mid, axis=1))]) + lake = np.argsort(np.linalg.norm(xyz - xyz[c], axis=1))[:19] # a cell and two rings: one lake + bed = float(a["z_surface_m"][c]) + a["z_surface_m"][lake] = bed + a["z_surface_m"][c] = bed - 200.0 # its middle much deeper + a["lake"][lake], a["river"][lake] = True, False + a["lake_id"][lake] = int(a["lake_id"].max()) + 1 + a["z_filled_m"][lake] = bed + 100.0 + a["depression_depth_m"][lake] = 100.0 + a["depression_depth_m"][c] = 300.0 + isle = lake[1] # and an island beside it + a["lake"][isle], a["z_surface_m"][isle], a["z_filled_m"][isle] = False, bed + 150.0, bed + 150.0 + np.savez(dst / "cells.npz", **a) + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), regions_dir=tmp) + p = np.concatenate([xyz[c] + np.linspace(0.2, 0.9, 100)[:, None] * (xyz[n] - xyz[c]) for n in lake[2:7]]) + p /= np.linalg.norm(p, axis=1, keepdims=True) # from the deep middle to the ring + lat, lon = np.degrees(np.arcsin(p[:, 2])), np.degrees(np.arctan2(p[:, 1], p[:, 0])) + z, water = src.surface_at(lat, lon, 1.0) + below = z < bed + 100.0 - 1e-6 + self.assertFalse((below & ~water).any(), f"{(below & ~water).sum()} dry pits below the lake's surface") + + def test_ground_beside_a_lake_below_its_surface_is_under_water(self): + import refine as RF + (d,) = RF.built_areas(self.root / "out" / "r2" / "regions", self.src.fingerprint) + tmp = Path(tempfile.mkdtemp()) + dst = RF.results_dir(tmp, self.src.fingerprint) / d.name + shutil.copytree(d, dst) + a = dict(np.load(dst / "cells.npz")) + halo, xyz = a["halo"].astype(bool), a["g_xyz"] + land = ~halo & ~a["ocean"].astype(bool) + mid = latlon_to_xyz(self.lat, self.lon).reshape(3) # well inside the area: by its centre + c = int(np.where(land)[0][np.argmin(np.linalg.norm(xyz[land] - mid, axis=1))]) + ring = np.argsort(np.linalg.norm(xyz - xyz[c], axis=1))[:7] + lake, shore = ring[:6], ring[6] # a lake and a dry cell beside it + bed = float(a["z_surface_m"][c]) + a["z_surface_m"][ring] = bed + a["lake"][lake], a["river"][ring] = True, False + a["lake"][shore] = False + a["lake_id"][lake] = int(a["lake_id"].max()) + 1 + a["z_filled_m"][lake] = bed + 100.0 + a["depression_depth_m"][lake] = 100.0 + a["z_surface_m"][shore] = a["z_filled_m"][shore] = bed + 60.0 # 40 m below the lake's surface + np.savez(dst / "cells.npz", **a) + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), regions_dir=tmp) + p = xyz[c] + np.linspace(0.55, 0.65, 50)[:, None] * (xyz[shore] - xyz[c]) # just past the cells' border + p /= np.linalg.norm(p, axis=1, keepdims=True) + lat, lon = np.degrees(np.arcsin(p[:, 2])), np.degrees(np.arctan2(p[:, 1], p[:, 0])) + z, water = src.surface_at(lat, lon, 1.0) + below = z < bed + 100.0 - 1e-6 + self.assertFalse((below & ~water).any(), f"{(below & ~water).sum()} dry pits below the lake's surface") + + def test_tiles_away_from_regions_share_the_world_cache(self): + far, near = ("relief", 8, *tile_at(self.lat + 20, self.lon, 8)), ("relief", 8, *tile_at(self.lat, self.lon, 8)) + self.assertIn(self.plain.tag, self.src.cache_path(*far).parts, "a region change keeps the tiles away from it") + self.assertIn(self.src.tag, self.src.cache_path(*near).parts) + + def test_a_region_set_lists_the_built_areas_once(self): + import refine as RF + real, calls = RF.built_areas, [] + with mock.patch.object(RF, "built_areas", side_effect=lambda *a: calls.append(1) or real(*a)): + rs = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R) + self.assertEqual(len(calls), 1, "names and fingerprint from one listing: a build finishing between is not mixed") + self.assertEqual(rs.fingerprint, RF.regions_fingerprint(self.src.regions_dir, self.src.fingerprint)) + + def test_cached_region_set_equals_the_uncached_one(self): + import refine as RF + fresh = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R, use_cache=False) + cached = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R) + self.assertIsNotNone(cached.cache) + self.assertTrue((cached.cache / "done").exists()) + self.assertEqual(set(cached.arrays), set(fresh.arrays)) + for k in fresh.arrays: + np.testing.assert_array_equal(np.asarray(cached.arrays[k]), fresh.arrays[k], err_msg=k) + for k in ("z_env", "lake_level", "lake_floor"): + np.testing.assert_array_equal(getattr(cached, k), getattr(fresh, k), err_msg=k) + self.assertEqual(cached.spacing_km, fresh.spacing_km) + self.assertEqual(cached.area_names, fresh.area_names) + import rivers as RV + for k in RV.STATE: + np.testing.assert_array_equal(getattr(cached.net, k), getattr(fresh.net, k), err_msg=k) + + def test_low_memory_region_set_spills_to_disk_with_equal_arrays(self): + import refine as RF + dirs = RF.built_areas(self.src.regions_dir, self.src.fingerprint) + want, wmeta = RF._compute_region_set(dirs, self.src.w.legends, self.src.R) # oracle: the in-RAM build + with tempfile.TemporaryDirectory() as spill: + got, gmeta = RF._compute_region_set(dirs, self.src.w.legends, self.src.R, spill=Path(spill)) + self.assertEqual(set(got), set(want)) + self.assertEqual(gmeta, wmeta) + for k in want: + np.testing.assert_array_equal(np.asarray(got[k]), want[k], err_msg=k) + big = [k for k in got if k.startswith("a.")] + self.assertTrue(big and all(isinstance(got[k], np.memmap) for k in big), "fields kept on disk") + del got + + def test_low_memory_region_set_leaves_no_spill_folder(self): + import refine as RF + with tempfile.TemporaryDirectory() as d, mock.patch.dict(os.environ, {"WORLDGEN_LOW_MEMORY": "1"}): + regions = Path(d) / "regions" + shutil.copytree(self.src.regions_dir, regions, symlinks=True) + for p in regions.glob("*/serve-*"): + shutil.rmtree(p) + rs = RF.RegionSet(regions, self.src.fingerprint, self.src.w.legends, self.src.R) + self.assertTrue((rs.cache / "done").exists()) + self.assertEqual(list(regions.rglob(".spill-*")), []) + + def test_cached_tiles_and_meshes_are_byte_identical(self): + import refine as RF + fresh = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp())) + fresh.regions = RF.RegionSet(fresh.regions_dir, fresh.fingerprint, fresh.w.legends, fresh.R, use_cache=False) + fresh.tag = fresh.base_tag + fresh._region_tag(fresh.regions) + for z in (6, 8): + x, y = tile_at(self.lat, self.lon, z) + self.assertEqual(self.src.render("relief", z, x, y), fresh.render("relief", z, x, y), f"relief z{z}") + np.testing.assert_array_equal(self.src.mesh(z, x, y), fresh.mesh(z, x, y)) + + def test_trees_are_not_built_at_load(self): + import refine as RF + rs = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R) + self.assertIsNone(rs._tree) + self.assertIsNone(rs._halo_tree) + self.assertEqual(len(rs.area_trees._built), 0) + rs.warm() + self.assertIsNotNone(rs._tree) + self.assertIsNotNone(rs._halo_tree) + self.assertIsNotNone(rs.net._tree) + + def test_start_workers_warms_region_trees_before_forking(self): + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp())) + order = [] + with mock.patch.object(src.regions, "warm", side_effect=lambda: order.append("warm")), \ + mock.patch.object(T, "RenderPool", side_effect=lambda s, n: order.append("fork")): + src.start_workers(2) + self.assertEqual(order, ["warm", "fork"]) + + def test_rebuilt_regions_get_a_new_cache_and_the_old_is_pruned(self): + import refine as RF + own = Path(tempfile.mkdtemp()) / "regions" + shutil.copytree(self.src.regions_dir, own, symlinks=True) # the class's areas keep their mtimes + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), + regions_dir=own) + old = src.regions.cache + area = RF.built_areas(src.regions_dir, src.fingerprint)[0] / "cells.npz" + os.utime(area) # a rebuilt area: new mtime → new regions fingerprint + src.reload_regions(cleanup=True) + self.assertNotEqual(src.regions.cache, old) + self.assertTrue(src.regions.cache.exists()) + self.assertFalse(old.exists()) + self.assertTrue(self.src.regions.cache.exists(), "the shared fixture's cache is untouched") + + def test_build_areas_keeps_serve_cache_folders(self): + import refine as RF + import servecache as SC + base = RF.results_dir(self.src.regions_dir, self.src.fingerprint) + marker = SC.cache_dir(base, "somefp") + marker.mkdir(parents=True, exist_ok=True) + try: + RF.build_areas(self.root, 2, self.root / "places" / "regions.json", log=lambda s: None) + self.assertTrue(marker.exists(), "serve caches are pruned by the server, not by builds") + finally: + shutil.rmtree(marker, ignore_errors=True) + + def test_region_cache_follows_the_code_that_computes_it(self): + import refine as RF + code = Path(tempfile.mkdtemp()) / "rivers.py" + code.write_text("A = 1\n") + with mock.patch.object(RF, "CODE_FILES", (code,)): + a = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R).cache + code.write_text("A = 2\n") # e.g. a river constant changed: the stored net is stale + b = RF.RegionSet(self.src.regions_dir, self.src.fingerprint, self.src.w.legends, self.src.R).cache + self.assertNotEqual(a, b) + self.assertTrue((b / "done").exists()) + + def test_an_empty_region_set_prunes_the_last_cache(self): + import refine as RF + regions = Path(tempfile.mkdtemp()) / "regions" + shutil.copytree(self.src.regions_dir, regions) # (the shared fixture stays as it is) + src = T.TileSource(serve.World(self.root, 2), seed=self.src.seed, cache_dir=Path(tempfile.mkdtemp()), + regions_dir=regions) + old = src.regions.cache + self.assertTrue((old / "done").exists()) + for d in RF.built_areas(regions, src.fingerprint): # the last region deleted + shutil.rmtree(d) + src.reload_regions(cleanup=True) + self.assertTrue(src.regions.empty) + self.assertFalse(old.exists()) + + +class ProfileSurfaceTest(unittest.TestCase): + def test_profile_water_matches_the_tiles(self): + server = serve.make_server(built_world(), 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json") + with Running(server) as base: + prof = json.loads(request(base + "/api/profile?path=10,-85;10,-55&n=64")[1]) # across the west coast + S = prof["samples"] + step = S[1]["d_km"] - S[0]["d_km"] + z, water = server.tiles.surface_at([x["lat"] for x in S], [x["lon"] for x in S], step / 2) + np.testing.assert_allclose([x["z_m"] for x in S], z, atol=0.1) + self.assertEqual([x["water"] for x in S], [bool(w) for w in water]) + self.assertTrue(any(water) and not all(water)) + + +class TileEndpointTest(unittest.TestCase): + def test_endpoints(self): + v = T.VERSION + with Running(serve.make_server(built_world(), 2, 0, pins_path=Path(tempfile.mkdtemp()) / "p.json")) as base: + meta = json.loads(request(base + "/api/meta")[1]) + tag = meta["tiles"]["url"].split("/")[2] # v1-<build fingerprint> + s1, b1 = request(base + f"/tiles/{tag}/relief/5/40/10.jpg") + s2, _ = request(base + f"/tiles/{tag}/height/5/40/10.png") + s3, b3 = request(base + f"/tiles/{tag}/mesh/5/40/10.bin") + s4 = request(base + f"/tiles/{tag}/mesh/5/40/10.jpg")[0] + bad = [request(base + p)[0] for p in (f"/tiles/{tag}/nope/5/1/1.jpg", f"/tiles/{tag}/relief/5/99/1.jpg", + f"/tiles/{tag}/relief/17/0/0.jpg", f"/tiles/v{v}-0000000000/relief/5/1/1.jpg", f"/tiles/{tag}/relief/5/1/1.png", + f"/tiles/{tag}/height/5/1/1.jpg", f"/tiles/v{v}/relief/5/1/1.jpg")] + cell = json.loads(request(base + "/api/cell?lat=10&lon=20")[1]) + prof = json.loads(request(base + "/api/profile?path=10,10;10,30&n=32")[1]) + self.assertRegex(meta["tiles"]["url"], rf"^/tiles/v{v}\.{T.DRAW}-[0-9a-f]{{10}}/{{layer}}/{{z}}/{{x}}/{{y}}\.jpg$") + self.assertEqual((meta["tiles"]["min_z"], meta["tiles"]["max_z"]), (5, 16)) + self.assertEqual((s1, Image.open(io.BytesIO(b1)).size), (200, (256, 256))) + self.assertEqual(s2, 200) + self.assertEqual(bad, [404] * 7) + self.assertRegex(meta["tiles"]["mesh_url"], rf"^/tiles/v{v}\.{T.DRAW}-[0-9a-f]{{10}}/mesh/{{z}}/{{x}}/{{y}}\.bin$") + self.assertEqual(T.MESH_N, 129, "3D heights: 129 × 129 per tile (author 2026-09-25)") + self.assertEqual((s3, len(b3), s4), (200, T.MESH_BYTES, 404)) + self.assertEqual((T.VERSION, T.DRAW), (6, 5)) + f = {x["key"]: x for x in cell["fields"]} + self.assertEqual(f["z_detail_m"]["label"], "Local elevation (procedural)") + self.assertIn("spans", f["z_detail_m"]["alt"]) + self.assertTrue(all("z_cell_m" in s for s in prof["samples"])) + + +class SharedPoolTest(unittest.TestCase): + def test_two_sources_share_one_pool(self): + a_root, b_root = built_world(), built_world() + a = T.TileSource(serve.World(a_root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp()), name="a") + b = T.TileSource(serve.World(b_root, 2), seed=8, cache_dir=Path(tempfile.mkdtemp()), name="b") + T.join([a, b]) + self.assertIs(a.peers, b.peers) + a.start_workers(1) + try: + self.assertIs(a.pool, b.pool) + args = (6, *tile_at(10.0, 20.0, 6)) + self.assertEqual(b.pool.render("b", "relief", *args, b.regions.fingerprint), b.render("relief", *args)) + self.assertEqual(a.pool.render("a", "relief", *args, a.regions.fingerprint), a.render("relief", *args)) + self.assertNotEqual(a.render("relief", *args), b.render("relief", *args), "seeds differ: two worlds") + old = a.pool + b.reload_regions() + self.assertIsNot(a.pool, old, "a reload forks fresh workers for every peer") + self.assertIs(a.pool, b.pool) + made = [] + real = T.RenderPool + with mock.patch.object(T, "RenderPool", side_effect=lambda *x: made.append(1) or real(*x)): + old = a.pool + T.reload_all([a, b]) + self.assertEqual(len(made), 1, "every era reloaded, then one fork") + self.assertIsNot(a.pool, old) + self.assertIs(a.pool, b.pool) + finally: + a.pool.close() + + +class _Hangs: + name = "h" + + class regions: + fingerprint = "fp" + + def render(self, layer, z, x, y): + time.sleep(3600) + + +class PoolTimeoutTest(unittest.TestCase): + def test_a_hung_worker_is_stopped_and_the_request_fails(self): + pool = T.RenderPool({"h": _Hangs()}, 1) + try: + with mock.patch.object(T, "RENDER_TIMEOUT_S", 0.5): + t = time.time() + with self.assertRaisesRegex(RuntimeError, "timed out"): + pool.render("h", "relief", 5, 1, 1, "fp") + self.assertLess(time.time() - t, 5) + self.assertEqual(pool.alive, 0) + pool.procs[0].join(timeout=5) + self.assertFalse(pool.procs[0].is_alive(), "the stuck process is gone") + finally: + pool.close() + + +class EraShareTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + import refine as RF + from mapgen import config as C + from mapgen import eras as ER + from tests.test_refine import era_world, square, write_regions + cls.root, cls.far, cls.cut, _ = era_world() + tmp = Path(tempfile.mkdtemp()) + reg = write_regions(tmp / "regions.json", square(*cls.far, 2, "far"), square(*cls.cut, 2, "cut")) + cls.rdir = tmp / "res" + RF.build_areas(cls.root, 2, reg, log=lambda s: None, regions_root=cls.rdir) + seed = int(C.load(cls.root)[0]["build"]["seed"]) # the areas were built for the world's own seed + cls.base = T.TileSource(serve.World(cls.root, 2), seed=seed, cache_dir=tmp / "tb", regions_dir=cls.rdir) + cls.era = T.TileSource(serve.World(cls.root, 2, out=ER.era_dir(cls.root, 2, "after")), seed=seed, + cache_dir=tmp / "te", regions_dir=cls.rdir, name="after", share=cls.base) + T.join([cls.base, cls.era]) + + def test_area_keys_name_the_content(self): + rs_b, rs_e = self.base.regions, self.era.regions + self.assertEqual(sorted(rs_b.area_keys), sorted(rs_b.area_names)) + same = [h for h in rs_e.area_names if rs_e.area_keys[h] == rs_b.area_keys.get(h)] + self.assertEqual(len(same), 1, "the far area is shared, the cut one is the era's own") + + def test_tiles_away_from_the_era_are_the_bases(self): + args = (7, *tile_at(*self.far, 7)) + self.assertTrue(self.era.shared(*args)) + self.assertEqual(self.era.tile("relief", *args), self.base.tile("relief", *args)) + self.assertEqual(self.era.cache_path("relief", *args), self.base.cache_path("relief", *args)) + self.assertFalse(any((self.era.cache_root).rglob("*.jpg")), "nothing saved twice") + + def test_a_shared_tile_keeps_the_render_limit(self): + import threading + args = (8, *tile_at(*self.far, 8)) + self.assertTrue(self.era.shared(*args)) + gate = threading.BoundedSemaphore(1) + gate.acquire() # no slot free + with self.assertRaises(T.Busy): + self.era.tile("seafloor", *args, remember=False, gate=gate) + + def test_tiles_near_the_mask_are_the_eras_own(self): + args = (7, *tile_at(*self.cut, 7)) + self.assertFalse(self.era.shared(*args)) + self.assertNotEqual(self.era.tile("height", *args), self.base.tile("height", *args)) + + def test_the_mask_edge_alone_decides_without_refined_areas(self): + from mapgen import eras as ER + from mapgen.sphere import gc_dist_km + tmp = Path(tempfile.mkdtemp()) + era = T.TileSource(serve.World(self.root, 2, out=ER.era_dir(self.root, 2, "after")), seed=self.base.seed, + cache_dir=tmp / "te", regions_dir=tmp / "none", name="after", share=self.base) + with np.load(ER.era_dir(self.root, 2, "after") / "cells.npz") as a: + xyz, m = np.asarray(a["g_xyz"], np.float64), a["era_mask"].astype(bool) + z, span = 9, 180.0 / 2 ** 9 + near = far = None + for k in range(1, 4000): + lon = self.cut[1] + k * span + x, y = tile_at(self.cut[0], lon, z) + c = latlon_to_xyz(90 - (y + 0.5) * span, -180 + (x + 0.5) * span) + d = gc_dist_km(xyz, c, era.R) + to_mask = float(d[m].min()) + if near is None and not m[int(np.argmin(d))] and to_mask < 2 * era._spacing_km: + near = (x, y) + if to_mask > 2 * era._spacing_km + T.CARRY_KM + 2 * span * np.pi / 180 * era.R: + far = (x, y) + break + self.assertIsNotNone(near) + self.assertIsNotNone(far) + self.assertFalse(era.shared(z, *near), "just outside the mask: the era's own") + self.assertTrue(era.shared(z, *far), "beyond the mask's reach: the base's") + + def test_the_base_shares_nothing(self): + self.assertFalse(self.base.shared(7, *tile_at(*self.far, 7))) + + +class SeaFloorTileTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.root = built_world() + cls.src = T.TileSource(serve.World(cls.root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp())) + + def test_depth_shading_darkens_with_depth(self): + z = np.tile(np.linspace(-6000.0, -10.0, 64), (64, 1)) + rgb = T.seafloor_rgb(z, np.full(z.shape, 0.7), np.full(z.shape, 3), np.zeros(z.shape, bool)).astype(float) + self.assertLess(rgb[:, :8].mean(), rgb[:, -8:].mean(), "deep is darker") + + def test_type_tints_the_sea_and_land_keeps_relief(self): + z = np.full((8, 8), -3000.0) + land = np.zeros((8, 8), bool) + land[:, :4] = True + a = T.seafloor_rgb(np.where(land, 500.0, z), np.full(z.shape, 0.7), np.full(z.shape, 3), land) + b = T.seafloor_rgb(np.where(land, 500.0, z), np.full(z.shape, 0.7), np.full(z.shape, 7), land) + self.assertFalse(np.array_equal(a[:, 4:], b[:, 4:]), "clay vs vent field") + np.testing.assert_array_equal(a[:, :4], b[:, :4]) + + def test_seafloor_tile(self): + self.assertIn("seafloor", T.LAYERS) + args = (5, *tile_at(-30.0, -150.0, 5)) + im = Image.open(io.BytesIO(self.src.tile("seafloor", *args))) + self.assertEqual(im.size, (256, 256)) + f = self.src.fields(*args, 0, ("cat",)) + self.assertIn("seabed_type", f) + + def test_world_holds_the_new_fields(self): + for k in ("seabed_type", "bottom_temp_c", "pressure_bar", "fire_reactivity", "o2_fraction"): + self.assertIn(k, self.src.w.arrays, k) + + def test_new_data_layers_render(self): + args = (6, *tile_at(10.0, 20.0, 6)) + for layer in ("seabed", "minerals", "bottom_temp", "sediment", "vent_potential", "pressure", "fire"): + self.assertIn(layer, T.LAYERS, layer) + self.assertEqual(Image.open(io.BytesIO(self.src.tile(layer, *args))).size, (256, 256), layer) + + def test_ocean_layers(self): + for layer in ("currents", "sst", "productivity"): + self.assertIn(layer, T.LAYERS, layer) + self.assertEqual(T.CONT["currents"], "current_speed") + + +class LandColourTest(unittest.TestCase): + """Land colour = terrain shading + a vegetation mosaic (woods vs open ground; author 2026-10-03, spike E).""" + @classmethod + def setUpClass(cls): + cls.root = built_world() + cls.src = T.TileSource(serve.World(cls.root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp())) + a = cls.src.w.arrays + i = int(np.flatnonzero(~np.asarray(a["ocean"]).astype(bool))[0]) + q = np.asarray(a["g_xyz"][i], dtype=float) + cls.land = float(np.degrees(np.arcsin(q[2] / np.linalg.norm(q)))), float(np.degrees(np.arctan2(q[1], q[0]))) + names = list(cls.src.w.legends["holdridge"]) + cls.zone = {n: names.index(n) for n in names} + + def synthetic(self, z, x, y, zone, height=None, water=None): + B = T.LAND_BORDER + f = dict(self.src.fields(z, x, y, B, ("z",), valleys=False)) + n = f["lat"].shape[0] + f["z"] = f["base"] + (0.0 if height is None else height) # height: above the smooth base surface + f["water"] = np.zeros((n, n), bool) if water is None else water + f["lake"] = np.zeros((n, n), np.int64) + f["river_draw"] = np.zeros((n, n), bool) + f["holdridge"] = np.full((n, n), self.zone[zone]) + f["ground"] = np.zeros((n, n), np.int64) + return f + + def wood_share(self, zone, z=9): + x0, y0 = tile_at(*self.land, z) + return float(np.mean([self.src._veg(self.synthetic(z, x0 + i, y0 + j, zone), T.LAND_BORDER)["wood"].mean() + for i in range(3) for j in range(3)])) + + def test_wide_border_keeps_the_interior(self): # a wider border must not change what the tile itself holds + z, x, y = 10, *tile_at(*self.land, 10) + a = self.src.fields(z, x, y, 1, ("z", "water", "cat")) + b = self.src.fields(z, x, y, 9, ("z", "water", "cat")) + for k in ("z", "water", "holdridge", "river_draw", "lake"): + np.testing.assert_array_equal(a[k], b[k][8:-8, 8:-8], k) + + def test_border_is_the_neighbours_own_pixels(self): # a wide border holds what the next tile draws there + B = T.LAND_BORDER + z, x, y = 10, *tile_at(*self.land, 10) + f = self.src.fields(z, x, y, B, ("z", "water", "cat")) + west = self.src.fields(z, x - 1, y, 0, ("z", "water", "cat")) + np.testing.assert_allclose(f["z"][B:-B, :B], west["z"][:, -B:], atol=1e-6) + np.testing.assert_array_equal(f["holdridge"][B:-B, :B], west["holdridge"][:, -B:]) + + def test_colour_does_not_depend_on_the_border(self): # seamless: nothing reaches past LAND_BORDER + B = T.LAND_BORDER + z, x, y = 10, *tile_at(*self.land, 10) + a = self.src._veg(self.src.fields(z, x, y, B, ("z", "water", "cat")), B) + b = self.src._veg(self.src.fields(z, x, y, 2 * B, ("z", "water", "cat")), 2 * B) + for k in ("wood", "bright", "tint"): + np.testing.assert_allclose(a[k], b[k], atol=1e-9, err_msg=k) + + def test_cover_by_zone(self): + c = T.veg_cover(list(self.src.w.legends["holdridge"])) + z = self.zone + self.assertEqual(c[z["polar desert"]], 0.0) + self.assertLess(c[z["tropical desert"]], c[z["tropical thorn woodland"]]) + self.assertLess(c[z["tropical thorn woodland"]], c[z["tropical dry forest"]]) + self.assertLess(c[z["tropical dry forest"]], c[z["tropical rain forest"]]) + self.assertLess(c[z["cool temperate steppe"]], c[z["cool temperate moist forest"]]) + self.assertLess(c[z["subpolar dry tundra"]], c[z["subpolar wet tundra"]]) + + def test_wetter_zones_are_more_wooded(self): + rain, dry, desert = (self.wood_share(n) for n in ("tropical rain forest", "tropical dry forest", "tropical desert")) + self.assertGreater(rain, 0.6) + self.assertLess(desert, 0.12) + self.assertLess(desert, dry) + self.assertLess(dry, rain) + + def test_far_out_the_mosaic_is_the_zone_mean(self): # patches of a few px read as noise: none at z4 + f = self.synthetic(4, *tile_at(*self.land, 4), "tropical dry forest") + c = T.veg_cover(list(self.src.w.legends["holdridge"]))[self.zone["tropical dry forest"]] + np.testing.assert_allclose(self.src._veg(f, T.LAND_BORDER)["wood"], c, atol=1e-12) + + def test_salt_flats_bare(self): + f = self.synthetic(9, *tile_at(*self.land, 9), "tropical rain forest") + f["ground"][:] = list(self.src.w.legends["ground"]).index("salt flat") + self.assertEqual(float(self.src._veg(f, T.LAND_BORDER)["wood"].max()), 0.0) + + def test_woods_in_hollows_not_on_crests(self): # the same pixels, flat vs sine ridges (the patches cancel) + z, (x, y) = 11, tile_at(*self.land, 11) + n, B = T.TILE + 2 * T.LAND_BORDER, T.LAND_BORDER + s = np.sin(2 * np.pi * np.arange(n) / 48.0) + zone = "warm temperate dry forest" # cover ½: room to gain and to lose + d = np.hstack([self.src._veg(self.synthetic(z, x + i, y, zone, height=np.tile(120.0 * s, (n, 1))), B)["wood"] + - self.src._veg(self.synthetic(z, x + i, y, zone), B)["wood"] for i in range(3)]) + s = np.tile(s[B:-B], 3) + self.assertLess(d[:, s > 0.97].mean(), -0.03, "fewer woods on crests") # flat tops and bottoms: no slope term + self.assertGreater(d[:, s < -0.97].mean(), 0.03, "more in hollows") + + def test_woods_along_water(self): + z, x, y = 12, *tile_at(*self.land, 12) + n, B = T.TILE + 2 * T.LAND_BORDER, T.LAND_BORDER + wet = np.zeros((n, n), bool) + wet[:, n // 2] = True + zone = "warm temperate dry forest" + d = self.src._veg(self.synthetic(z, x, y, zone, water=wet), B)["wood"] - \ + self.src._veg(self.synthetic(z, x, y, zone), B)["wood"] + mid = T.TILE // 2 + self.assertGreater(d[:, np.r_[mid - 3:mid, mid + 1:mid + 4]].mean(), 0.15, "woods along the water") + self.assertAlmostEqual(float(np.abs(d[:, :mid - 20]).max()), 0.0, 9, msg="none far away") + + def test_relief_tile_renders_and_repeats(self): + z, x, y = 9, *tile_at(*self.land, 9) + a, b = self.src.rgb("relief", z, x, y), self.src.rgb("relief", z, x, y) + self.assertEqual(a.shape, (T.TILE, T.TILE, 3)) + np.testing.assert_array_equal(a, b) + + +class GullyTest(unittest.TestCase): + """Relief detail with erosion gullies on slopes (author 2026-10-03: spike C; full rebuild accepted).""" + @classmethod + def setUpClass(cls): + cls.root = built_world() + cls.src = T.TileSource(serve.World(cls.root, 2), seed=7, cache_dir=Path(tempfile.mkdtemp())) + + def line(self, lat0, lon0, bearing, km, n=400): # points along a straight east (90°) or north (0°) line + d = np.linspace(0.0, km, n) / self.src.R + if bearing == 90: + return np.full(n, lat0), lon0 + np.degrees(d) / np.cos(np.radians(lat0)) + return lat0 + np.degrees(d), np.full(n, lon0) + + def detail(self, lat, lon, px_km, slope): # on a plane rising east at `slope` m/m, landform amplitude 450 m + xyz = latlon_to_xyz(lat, lon) + base = lambda la, lo: np.radians(lo) * self.src.R * 1000.0 * np.cos(np.radians(10.0)) * slope + n = len(lat) + return self.src._detail(xyz, px_km, np.full(n, 450.0), np.full(n, 0.5), lat, lon, base=base) + + def test_gullies_run_downhill(self): # the first (4 km) octave alone (px 0.6 km): stripes along the slope + px = 0.6 # (finer octaves follow the gully walls: that is the branching) + down = self.detail(*self.line(10.0, 20.0, 90, 40.0), px, 0.4) + across = self.detail(*self.line(10.0, 20.0, 0, 40.0), px, 0.4) + self.assertGreater(np.std(np.diff(across)), 2.0 * np.std(np.diff(down))) + + def test_flat_ground_keeps_its_detail(self): # no slope: (almost) the plain fractal detail + lat, lon = self.line(10.0, 20.0, 0, 3.0) + xyz = latlon_to_xyz(lat, lon) + n = len(lat) + flat = self.src._detail(xyz, 0.03, np.full(n, 25.0), np.full(n, 0.0), lat, lon, base=lambda la, lo: 0.0 * la) + plain = 25.0 * self.src._octaves(xyz, 0.03, T.TOP_KM, T.MIN_KM, T.GAIN, self.src.seed + 7001, np.zeros(n)) + self.assertLess(np.abs(flat - plain).max(), 0.1 * 25.0) # ≤ 10 % of the landform amplitude + + def test_far_out_no_gullies(self): # gullies need ≥ 4 px per wavelength: none at z6 + lat, lon = self.line(10.0, 20.0, 0, 300.0) + xyz, n = latlon_to_xyz(lat, lon), len(lat) + px = 2.0 + d = self.detail(lat, lon, px, 0.4) + plain = 450.0 * self.src._octaves(xyz, px, T.TOP_KM, T.MIN_KM, T.GAIN, self.src.seed + 7001, np.full(n, 0.5)) + np.testing.assert_array_equal(d, plain) + + def test_heights_agree_everywhere(self): # tiles, profile/3D points and the refinement start share one detail + z, x, y = 11, *tile_at(10.0, 20.0, 11) + f = self.src.fields(z, x, y, 0, ("z",), valleys=False) + lat, lon = f["lat"][::37, ::41].ravel(), f["lon"][::37, ::41].ravel() + ra = self.src.relief_at(lat, lon, f["px_km"]) + xyz = latlon_to_xyz(lat, lon) + amp, ridge, _ = self.src._amp(xyz) + np.testing.assert_allclose(ra - self.src.bilinear(self.src.raster("elevation"), lat, lon), + self.src._detail(xyz, f["px_km"], amp, ridge, lat, lon), atol=1e-9) + + def test_compiled_stripes_match_numpy(self): + if T._phacelle_jit is None: + self.skipTest("numba not installed") + rng = np.random.default_rng(3) + p = rng.normal(size=(5000, 3)) + p /= np.linalg.norm(p, axis=1, keepdims=True) + e, n = T.east_north(p) + ang = rng.uniform(0, 2 * np.pi, 5000)[:, None] + a = np.cos(ang) * e + np.sin(ang) * n + jit = self.src._phacelle(p, a, 0.5, 123) + with mock.patch.object(T, "_phacelle_jit", None): + ref = self.src._phacelle(p, a, 0.5, 123) + np.testing.assert_allclose(jit[0], ref[0], atol=1e-9) + np.testing.assert_allclose(jit[1], ref[1], atol=1e-9) |
