diff options
Diffstat (limited to 'tests/test_graph.py')
| -rw-r--r-- | tests/test_graph.py | 362 |
1 files changed, 362 insertions, 0 deletions
diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..ca324cc --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,362 @@ +import unittest + +import numpy as np + +from mapgen import graph as G +from mapgen.sphere import latlon_to_xyz, east_north +from tests.helpers import small_grid + + +class GraphTest(unittest.TestCase): + def setUp(self): + self.g = small_grid(2) + + def test_mean_max_min_diffuse(self): + g = self.g + f = np.zeros(g.n) + f[0] = 6.0 + m = G.nbr_mean(g, f) + self.assertAlmostEqual(m[g.nbr_idx[g.nbr_ptr[0]]], 6.0 / g.counts[g.nbr_idx[g.nbr_ptr[0]]]) + self.assertEqual(G.nbr_max(g, f)[g.nbr_idx[g.nbr_ptr[0]]], 6.0) + d = G.diffuse(g, f, 10) + self.assertLess(d.max(), 6.0) + self.assertGreater(np.count_nonzero(d > 1e-6), 20) + + def test_gradient_of_linear_field(self): + g = self.g + f = g.xyz[:, 2] * g.radius_km # height ∝ z → gradient points north near the equator + gr = G.gradient(g, f) + eq = np.abs(g.lat) < 20 + _, n = east_north(g.xyz[eq]) + cos_to_north = np.sum(gr[eq] * n, axis=1) / np.linalg.norm(gr[eq], axis=1) + self.assertGreater(np.median(cos_to_north), 0.99) + self.assertAlmostEqual(float(np.median(np.linalg.norm(gr[eq], axis=1))), 1.0, delta=0.1) + + def test_distance_matches_great_circle(self): + g = self.g + i = g.cell_index(0.0, 0.0) + d = G.distance_to(g, np.arange(g.n) == i) + gc = g.radius_km * np.arccos(np.clip(g.xyz @ g.xyz[i], -1, 1)) + far = gc > 3000 + ratio = d[far] / gc[far] + self.assertTrue(np.all(ratio >= 0.999) and np.median(ratio) < 1.15) + self.assertTrue(np.all(np.isinf(G.distance_to(g, np.zeros(g.n, bool))))) + + def test_nearest_source_labels(self): + g = self.g + a, b = g.cell_index(0.0, -90.0), g.cell_index(0.0, 90.0) + _, src = G.nearest_source(g, [a, b]) + self.assertEqual(src[g.cell_index(0.0, -60.0)], a) + self.assertEqual(src[g.cell_index(0.0, 60.0)], b) + + def test_priority_flood_fills_basin_and_drains(self): + g = self.g + ocean = g.lat < -30 + z = np.where(ocean, -1000.0, 500.0 + 10 * g.lat) + pit = g.cell_index(40.0, 0.0) + z[pit] = -50.0 # land pit below sea level, not connected to ocean + zf = G.priority_flood(g, z, ocean) + self.assertGreater(zf[pit], z[pit]) + recv, slope, dist = G.steepest_receivers(g, zf) + recv[ocean] = np.flatnonzero(ocean) + levels = G.receiver_levels(recv) + self.assertEqual(sum(len(l) for l in levels), g.n) + self.assertTrue(np.all(ocean[levels[0]])) + + def test_priority_flood_needs_sink(self): + with self.assertRaisesRegex(ValueError, "no sink"): + G.priority_flood(self.g, np.ones(self.g.n), np.zeros(self.g.n, bool)) + + def test_accumulate_conserves(self): + g = self.g + ocean = g.lat < -30 + z = np.where(ocean, -1000.0, 1000.0 + 20 * g.lat) + zf = G.priority_flood(g, z, ocean) + recv, _, _ = G.steepest_receivers(g, zf) + recv[ocean] = np.flatnonzero(ocean) + lv = G.receiver_levels(recv) + w = np.where(ocean, 0.0, 1.0) + acc = G.accumulate(recv, lv, w) + self.assertAlmostEqual(acc[lv[0]].sum(), w.sum()) + + def test_cycle_detected(self): + with self.assertRaisesRegex(ValueError, "cycle"): + G.receiver_levels(np.array([1, 0, 2])) + + def test_components(self): + g = self.g + m = (np.abs(g.lat) < 10) & (np.abs(g.lon) < 20) | (np.abs(g.lat - 50) < 8) & (np.abs(g.lon) < 20) + lab = G.components(g, m) + self.assertEqual(len(np.unique(lab[m])), 2) + self.assertTrue(np.all(lab[~m] == -1)) + + +class SmoothKmTest(unittest.TestCase): + def test_constant_preserved_and_spike_decays(self): + g = small_grid(3) + np.testing.assert_allclose(G.smooth_km(g, np.full(g.n, 5.0), 1000.0), 5.0, rtol=1e-4) + i = g.cell_index(0.0, 0.0) + s = G.smooth_km(g, (np.arange(g.n) == i).astype(float), 1000.0) + d = g.radius_km * np.arccos(np.clip(g.xyz @ g.xyz[i], -1, 1)) + near, far = s[(d > 800) & (d < 1200)].mean(), s[(d > 2800) & (d < 3200)].mean() + self.assertTrue(near > far > 0) + + def test_resolution_independent(self): + vals = [] + for res in (2, 3): + g = small_grid(res) + d = g.radius_km * np.arccos(np.clip(g.xyz @ g.xyz[g.cell_index(0.0, 0.0)], -1, 1)) + s = G.smooth_km(g, (d < 2000).astype(float), 1500.0) + vals.append(s[g.cell_index(0.0, 30.0)]) # ~6700 km away + self.assertAlmostEqual(vals[0], vals[1], delta=0.25 * max(vals)) + self.assertGreater(min(vals), 0.005) + + +class OceanMaskTest(unittest.TestCase): + def test_inland_depression_is_not_ocean(self): + g = small_grid(3) + z = np.where(g.lat < 0, -3000.0, 500.0) + c = g.xyz[g.cell_index(40.0, 0.0)] + d = g.radius_km * np.arccos(np.clip(g.xyz @ c, -1, 1)) + z[d < 600] = -50.0 # interior basin below sea level + ocean = G.ocean_mask(g, z, 1.0e6) + self.assertTrue(ocean[g.lat < -5].all()) + self.assertFalse(ocean[d < 600].any()) + + +class SmoothKmRobustTest(unittest.TestCase): + def test_zero_and_tiny_fields(self): + g = small_grid(3) + np.testing.assert_array_equal(G.smooth_km(g, np.zeros(g.n), 25.0), 0.0) + f = np.where(g.lat > 0, 1e-9, 0.0) + s = G.smooth_km(g, f, 25.0) + self.assertTrue(np.all(np.isfinite(s)) and s.max() <= 1e-9 * (1 + 1e-6)) + + def test_femto_scale_field(self): + g = small_grid(3) + rng = np.random.default_rng(0) + f = np.where(rng.random(g.n) < 0.06, rng.random(g.n) * 8e-14, 0.0) + s = G.smooth_km(g, f, 25.0) + self.assertTrue(np.all(np.isfinite(s))) + self.assertAlmostEqual(float(s.sum() / f.sum()), 1.0, delta=0.05) + + +def _flood_ref(g, z, sink_mask, eps=0.01): + """The original pure-Python priority flood (oracle for the compiled one).""" + import heapq + has_open = np.bincount(g.src, weights=(~sink_mask)[g.dst].astype(np.float64), minlength=g.n) > 0 + zf, done = np.asarray(z, dtype=np.float64).tolist(), sink_mask.tolist() + ptr, idx = g.nbr_ptr.tolist(), g.nbr_idx.tolist() + heap = [(zf[i], i) for i in np.flatnonzero(sink_mask & has_open).tolist()] + heapq.heapify(heap) + while heap: + zc, c = heapq.heappop(heap) + for k in range(ptr[c], ptr[c + 1]): + n = idx[k] + if not done[n]: + done[n] = True + zf[n] = max(zf[n], zc + eps) + heapq.heappush(heap, (zf[n], n)) + return np.array(zf) + + +def _steepest_ref(g, z): + """The original lexsort version (oracle).""" + slope = (z[g.src] - z[g.dst]) / g.edge_km + first = np.lexsort((-slope, g.src))[g.nbr_ptr[:-1]] + s = slope[first] + down = s > 0 + return (np.where(down, g.dst[first], np.arange(g.n)), np.where(down, s, 0.0), + np.where(down, g.edge_km[first], np.inf)) + + +def _accumulate_ref(recv, levels, w): + acc = np.asarray(w, dtype=np.float64).copy() + for lv in reversed(levels[1:]): + acc += np.bincount(recv[lv], weights=acc[lv], minlength=len(acc)) + return acc + + +class FastPathsTest(unittest.TestCase): + """The speed-ups (numba flood, sort-free receivers, per-level accumulate) give bit-identical results.""" + + def fields(self, g): + rng = np.random.default_rng(7) + rough = rng.normal(0, 300, g.n) + flat = np.round(rng.normal(0, 2, g.n)) # many exact ties: tie order matters + pits = np.where(rng.random(g.n) < 0.2, -50.0, rough) + return {"rough": rough, "flat": flat, "pits": pits} + + def test_flood_matches_reference(self): + g = small_grid(3) + for name, z in self.fields(g).items(): + sink = z < np.quantile(z, 0.1) + for eps in (0.01, 0.0): + with self.subTest(name=name, eps=eps): + want = _flood_ref(g, z, sink, eps) + got = G.priority_flood(g, z, sink, eps) + self.assertTrue(np.array_equal(got, want)) + py = G._flood_py(z, sink, g.nbr_ptr, g.nbr_idx, + np.flatnonzero(sink & (np.bincount(g.src, weights=(~sink)[g.dst].astype(float), + minlength=g.n) > 0)), eps) + self.assertTrue(np.array_equal(py, want)) + + def test_steepest_receivers_match_reference(self): + g = small_grid(3) + for name, z in self.fields(g).items(): + with self.subTest(name=name): + for got, want in zip(G.steepest_receivers(g, z), _steepest_ref(g, z)): + self.assertTrue(np.array_equal(got, want)) + + def test_accumulate_matches_reference(self): + g = small_grid(3) + rng = np.random.default_rng(3) + for name, z in self.fields(g).items(): + with self.subTest(name=name): + zf = G.priority_flood(g, z, z < np.quantile(z, 0.1)) + recv, _, _ = G.steepest_receivers(g, zf) + lv = G.receiver_levels(recv) + w = rng.random(g.n) * 1e3 * np.where(rng.random(g.n) < 0.1, -0.0, 1.0) # with negative zeros + got, want = G.accumulate(recv, lv, w), _accumulate_ref(recv, lv, w) + self.assertTrue(np.array_equal(got, want)) + self.assertTrue(np.array_equal(np.signbit(got), np.signbit(want))) + + +class SweepTest(unittest.TestCase): + def test_sweep_matches_full_bincount(self): + from mapgen import hydrology as HY + + def sweep_ref(recv, levels, water, outlets, cap): + acc = np.asarray(water, dtype=np.float64).copy() + loss = np.zeros(len(acc)) + is_out = np.zeros(len(acc), bool) + is_out[outlets] = True + cap_cell = np.zeros(len(acc)) + cap_cell[outlets] = cap + for lv in reversed(levels[1:]): + push = acc[lv].copy() + o = is_out[lv] + if o.any(): + cells = lv[o] + lost = np.minimum(acc[cells], cap_cell[cells]) + loss[cells] = lost + push[o] = acc[cells] - lost + acc += np.bincount(recv[lv], weights=push, minlength=len(acc)) + return acc, loss + + g = small_grid(3) + rng = np.random.default_rng(11) + z = rng.normal(0, 300, g.n) + zf = G.priority_flood(g, z, z < np.quantile(z, 0.1)) + recv, _, _ = G.steepest_receivers(g, zf) + lv = G.receiver_levels(recv) + water = rng.random(g.n) * np.where(rng.random(g.n) < 0.1, -0.0, 1.0) # with negative zeros + outlets = rng.choice(g.n, 200, replace=False) + cap = rng.random(200) * 2 + for got, want in zip(HY._sweep(recv, lv, water, outlets, cap), sweep_ref(recv, lv, water, outlets, cap)): + self.assertTrue(np.array_equal(got, want)) + self.assertTrue(np.array_equal(np.signbit(got), np.signbit(want))) + + +class LeavesTest(unittest.TestCase): + def test_leaves_all_matches_the_walk(self): + from mapgen import hydrology as HY + g = small_grid(3) + rng = np.random.default_rng(4) + for seed in range(3): + z = rng.normal(0, 300, g.n) + ocean = z < np.quantile(z, 0.2) + zf = G.priority_flood(g, z, ocean) + lab = G.components(g, ~ocean & (zf - z > 1.0)) + recv, _, _ = G.steepest_receivers(g, zf) + recv = np.where(ocean, np.arange(g.n), recv) + lv = G.receiver_levels(recv) + xs = np.flatnonzero(lab >= 0) + for limit in (100000, 3): + want = np.array([HY._leaves(recv, lab, x, limit) for x in xs]) + got = HY._leaves_all(recv, lab, lv, limit)[xs] + self.assertGreater(want.sum(), 0) + self.assertTrue(np.array_equal(got, want), (seed, limit)) + + +class BicgstabJacobiTest(unittest.TestCase): + """The fused solver walks scipy's iterates exactly: same answers bit for bit, same exit codes.""" + def systems(self): + from scipy import sparse + rng = np.random.default_rng(0) + for n in (2000, 9000): + i = np.repeat(np.arange(n), 6) + j = (i + rng.integers(-50, 50, len(i))) % n + L = sparse.csr_matrix((rng.random(len(i)), (i, j)), shape=(n, n)) + S = L + L.T + yield (sparse.diags(np.asarray(S.sum(1)).ravel()) - S).tocsr() * 40 + sparse.identity(n, format="csr") + yield (sparse.identity(n, format="csr") * (1 + np.asarray(L.sum(1)).ravel().max() * 0.6) - L).tocsr() + + def setUp(self): + self.min_n = G.JIT_MIN_N + G.JIT_MIN_N = 0 # the compiled path even on small test systems + + def tearDown(self): + G.JIT_MIN_N = self.min_n + + def test_matches_scipy_bicgstab(self): + from scipy.sparse import linalg as splinalg + rng = np.random.default_rng(1) + for k, A in enumerate(self.systems()): + for rtol, maxiter in ((1e-6, 5000), (1e-9, 5000), (1e-12, 7)): + b = rng.normal(size=A.shape[0]) + inv = 1.0 / A.diagonal() + M = splinalg.LinearOperator(A.shape, matvec=lambda x: inv * x) + want = splinalg.bicgstab(A, b, x0=b * 0.5, rtol=rtol, maxiter=maxiter, M=M) + got = G.bicgstab_jacobi(A, b, b * 0.5, inv, rtol, maxiter) + with self.subTest(k=k, rtol=rtol, maxiter=maxiter): + self.assertEqual(got[1], want[1]) + self.assertTrue(np.array_equal(got[0], want[0])) + + def test_zero_right_hand_side_and_zero_start(self): + A = next(self.systems()) + inv = 1.0 / A.diagonal() + x, info = G.bicgstab_jacobi(A, np.zeros(A.shape[0]), np.zeros(A.shape[0]), inv, 1e-6, 100) + self.assertEqual(info, 0) + self.assertFalse(x.any()) + + +class PmapTest(unittest.TestCase): + def test_order_and_same_floats_as_serial(self): + import os + from unittest import mock + g = small_grid(2) + fields = [np.random.default_rng(i).normal(size=g.n) for i in range(4)] + serial = [G.smooth_km(g, f, 900.0) for f in fields] + with mock.patch.dict(os.environ, {"WORLDGEN_THREADS": "4"}): + self.assertEqual(G.workers(), 4) + got = G.pmap(lambda f: G.smooth_km(g, f, 900.0), fields) + for a, b in zip(serial, got): + self.assertTrue(np.array_equal(a, b)) + with mock.patch.dict(os.environ, {"WORLDGEN_THREADS": "3"}): + self.assertEqual(G.pmap(lambda k: k * k, range(9)), [k * k for k in range(9)]) + with mock.patch.dict(os.environ, {"WORLDGEN_THREADS": "x"}): + self.assertEqual(G.workers(), 3) + + +class ComponentsTest(unittest.TestCase): + def test_same_labels_as_scipy(self): + from scipy import sparse + from scipy.sparse import csgraph + g = small_grid(3) + rng = np.random.default_rng(11) + + def scipy_labels(mask): # the previous implementation, as the oracle + e = mask[g.src] & mask[g.dst] + m = sparse.csr_matrix((np.ones(int(e.sum())), (g.src[e], g.dst[e])), shape=(g.n, g.n)) + _, lab = csgraph.connected_components(m, directed=False) + return np.where(mask, lab, -1) + for p in (0.0, 0.2, 0.45, 0.6, 0.9, 1.0): + for _ in range(3): + mask = rng.random(g.n) < p + want, got = scipy_labels(mask), G.components(g, mask) + self.assertEqual(got.dtype, want.dtype) + self.assertTrue(np.array_equal(got, want), p) + smooth = G.smooth_km(g, rng.normal(size=g.n), 2000.0) > 0 # big blobs, many cells each + self.assertTrue(np.array_equal(G.components(g, smooth), scipy_labels(smooth))) + self.assertGreater(len(np.unique(G.components(g, smooth))), 2) |
