|
| 1 | +import importlib.util |
| 2 | + |
1 | 3 | import numpy as np |
2 | 4 | import pytest |
3 | 5 |
|
4 | 6 | import autoarray as aa |
| 7 | +from autoarray.inversion.mesh.interpolator.delaunay import ( |
| 8 | + barycentric_dual_area_from, |
| 9 | + jax_delaunay, |
| 10 | + scipy_delaunay, |
| 11 | +) |
| 12 | +from autoarray.inversion.mesh.mesh_geometry.delaunay import voronoi_areas_numpy |
5 | 13 |
|
6 | 14 |
|
7 | 15 | def test__scipy_delaunay__simplices(grid_2d_sub_1_7x7): |
@@ -57,3 +65,143 @@ def test__scipy_delaunay__split(grid_2d_sub_1_7x7): |
57 | 65 | assert mesh_grid.delaunay.splitted_mappings[-1, :] == pytest.approx( |
58 | 66 | [5, 1, 2], 1.0e-4 |
59 | 67 | ) |
| 68 | + |
| 69 | + |
| 70 | +# ---------------------------------------------------------------------------- |
| 71 | +# Barycentric dual areas (euclid-dr1-prep phase 8 audit). |
| 72 | +# |
| 73 | +# Two different "areas" exist for a Delaunay mesh on two different code paths: |
| 74 | +# |
| 75 | +# * `barycentric_dual_area_from` (here) -- sum of triangle_area / 3 over the |
| 76 | +# triangles touching each vertex. These tile the convex hull exactly and |
| 77 | +# integrate the piecewise-linear interpolant exactly. |
| 78 | +# * `MeshGeometryDelaunay.areas_for_magnification` -- scipy Voronoi cell |
| 79 | +# areas with only the *unbounded* cells zeroed. These do NOT tile the hull |
| 80 | +# and do NOT integrate the interpolant. |
| 81 | +# |
| 82 | +# The tests below pin both facts, including the size of the divergence. |
| 83 | +# ---------------------------------------------------------------------------- |
| 84 | + |
| 85 | +# jax is an `[optional]` extra and is absent on the NumPy-only matrix env, so |
| 86 | +# the in-graph parity test skips rather than fails there (same convention as |
| 87 | +# test_knn_barycentric.py). |
| 88 | +requires_jax = pytest.mark.skipif( |
| 89 | + importlib.util.find_spec("jax") is None, |
| 90 | + reason="requires jax (installed via the [optional] extras; absent on the NumPy-only matrix env)", |
| 91 | +) |
| 92 | + |
| 93 | + |
| 94 | +def test__barycentric_dual_area__single_triangle(): |
| 95 | + |
| 96 | + points = np.array([[0.0, 0.0], [0.0, 4.0], [3.0, 0.0]]) |
| 97 | + simplices = np.array([[0, 1, 2]]) |
| 98 | + |
| 99 | + area = 0.5 * 3.0 * 4.0 |
| 100 | + |
| 101 | + dual = barycentric_dual_area_from(points, simplices, xp=np) |
| 102 | + |
| 103 | + assert dual == pytest.approx(np.full(3, area / 3.0), 1.0e-10) |
| 104 | + assert dual.sum() == pytest.approx(area, 1.0e-10) |
| 105 | + |
| 106 | + |
| 107 | +def test__barycentric_dual_area__sums_to_convex_hull_area(): |
| 108 | + """ |
| 109 | + The dual areas partition the convex hull exactly, so they sum to the hull |
| 110 | + area. The Voronoi areas behind `areas_for_magnification` do not -- on this |
| 111 | + configuration they overshoot the hull by ~29%, because the bounded boundary |
| 112 | + cells extend well outside the hull and are kept. |
| 113 | + """ |
| 114 | + import scipy.spatial |
| 115 | + |
| 116 | + points = np.random.default_rng(1).random((40, 2)) |
| 117 | + |
| 118 | + simplices = scipy.spatial.Delaunay(points).simplices |
| 119 | + |
| 120 | + dual = barycentric_dual_area_from(points, simplices, xp=np) |
| 121 | + |
| 122 | + hull_area = scipy.spatial.ConvexHull(points).volume |
| 123 | + |
| 124 | + assert dual.sum() == pytest.approx(hull_area, rel=1.0e-10) |
| 125 | + |
| 126 | + voronoi = voronoi_areas_numpy(points) |
| 127 | + voronoi = np.where(voronoi == -1.0, 0.0, voronoi) |
| 128 | + |
| 129 | + ratio = voronoi.sum() / hull_area |
| 130 | + |
| 131 | + assert ratio == pytest.approx(1.2868, 1.0e-3), ( |
| 132 | + f"Voronoi areas (unbounded cells zeroed) sum to {voronoi.sum()} against a " |
| 133 | + f"convex-hull area of {hull_area} (ratio {ratio}); the two area " |
| 134 | + f"definitions are not interchangeable." |
| 135 | + ) |
| 136 | + assert voronoi.sum() != pytest.approx(hull_area, rel=1.0e-2) |
| 137 | + |
| 138 | + |
| 139 | +def test__linear_field_integral__dual_areas_exact(): |
| 140 | + """ |
| 141 | + For a field that is linear over the mesh, the piecewise-linear interpolant |
| 142 | + is the field itself, so its integral over the hull is exactly |
| 143 | + `sum(f_i * dual_area_i)`. The Voronoi areas get the same integral wrong by |
| 144 | + tens of percent. |
| 145 | + """ |
| 146 | + import scipy.spatial |
| 147 | + |
| 148 | + points = np.random.default_rng(1).random((40, 2)) |
| 149 | + |
| 150 | + simplices = scipy.spatial.Delaunay(points).simplices |
| 151 | + |
| 152 | + f = 0.3 + 0.7 * points[:, 0] - 0.2 * points[:, 1] |
| 153 | + |
| 154 | + p0 = points[simplices[:, 0]] |
| 155 | + p1 = points[simplices[:, 1]] |
| 156 | + p2 = points[simplices[:, 2]] |
| 157 | + |
| 158 | + cross = (p1[:, 0] - p0[:, 0]) * (p2[:, 1] - p0[:, 1]) - ( |
| 159 | + p1[:, 1] - p0[:, 1] |
| 160 | + ) * (p2[:, 0] - p0[:, 0]) |
| 161 | + |
| 162 | + tri_area = 0.5 * np.abs(cross) |
| 163 | + |
| 164 | + # exact: on each triangle a linear field integrates to (mean of its three |
| 165 | + # vertex values) x (triangle area) |
| 166 | + exact = (f[simplices].mean(axis=1) * tri_area).sum() |
| 167 | + |
| 168 | + dual = barycentric_dual_area_from(points, simplices, xp=np) |
| 169 | + |
| 170 | + assert (f * dual).sum() == pytest.approx(exact, rel=1.0e-10) |
| 171 | + |
| 172 | + voronoi = voronoi_areas_numpy(points) |
| 173 | + voronoi = np.where(voronoi == -1.0, 0.0, voronoi) |
| 174 | + |
| 175 | + voronoi_integral = (f * voronoi).sum() |
| 176 | + |
| 177 | + assert abs(voronoi_integral - exact) / exact > 0.01, ( |
| 178 | + f"Voronoi-weighted integral {voronoi_integral} vs exact {exact}" |
| 179 | + ) |
| 180 | + |
| 181 | + |
| 182 | +@requires_jax |
| 183 | +def test__barycentric_dual_area__numpy_matches_jax_in_graph(): |
| 184 | + """ |
| 185 | + `jax_delaunay` carries its own in-graph copy of the dual-area computation |
| 186 | + (a masked scatter-add over the padded simplices) rather than calling |
| 187 | + `barycentric_dual_area_from`. The two must agree. |
| 188 | +
|
| 189 | + The dual areas are not returned by either path; they enter it as the split |
| 190 | + point weights (`areas_factor * sqrt(areas)`), so the split points -- which |
| 191 | + both paths do return -- are the observable that pins them. Comparing them |
| 192 | + exercises the real library path rather than a hand-rolled copy of it. |
| 193 | + """ |
| 194 | + import jax.numpy as jnp |
| 195 | + |
| 196 | + rng = np.random.default_rng(3) |
| 197 | + |
| 198 | + points = rng.random((25, 2)) |
| 199 | + query_points = rng.random((30, 2)) |
| 200 | + |
| 201 | + _, simplices_np, _, split_np, _ = scipy_delaunay(points, query_points, 0.5) |
| 202 | + _, simplices_jx, _, split_jx, _ = jax_delaunay( |
| 203 | + jnp.asarray(points), jnp.asarray(query_points), 0.5 |
| 204 | + ) |
| 205 | + |
| 206 | + assert np.array_equal(np.asarray(simplices_np), np.asarray(simplices_jx)) |
| 207 | + assert np.asarray(split_jx) == pytest.approx(np.asarray(split_np), abs=1.0e-10) |
0 commit comments