Repository: scipy/scipy

Issue:
MAINT: circular SphericalVoronoi input
As [noted](https://github.com/scipy/scipy/pull/10759#issuecomment-621176032) by @pmla:

> [I]t has occurred to me that the Voronoi vertices for circular input are incorrect. Circular input has exactly two Voronoi vertices - one at each pole. The other "vertices" I have calculated (those in between each pair of adjacent input points) make it possible to plot regions correctly, but are not actually Voronoi vertices. If we use the actual Voronoi vertices only, we need a different mechanism for region plotting. This could be e.g. representing regions as plane normals, but this would be a big change. Otherwise we could add a note to explain that only two vertices are actual Voronoi vertices, and the rest are merely "helper" vertices for plotting.

Since circular input has provided an error or garbage in previous releases, what we're doing now is clearly better. There's also an absence of literature guidance on this scenario as far as I can tell.

I'd suggest removing the "guide vertices" between generators and document the use of i.e. `geometric_slerp` to achieve reasonable plots without those "guides."

We don't actually offer plotting functionality, so just documenting the suggestion seems reasonable.

One possible point of hesitation to using just two Voronoi vertices here--would `sort_vertices_of_regions()` handle this gracefully?


Discussion:
> One possible point of hesitation to using just two Voronoi vertices here--would sort_vertices_of_regions() handle this gracefully?

It will raise an exception:
```
        for i in range(3):
            k = simplices[current_simplex, i]
```
The 'simplex' contains only two vertices. This is not actually a simplex - we can't represent the Voronoi regions as a collection of points. The Voronoi regions of all input points contain the same two Voronoi vertices.

Resolving patch:
diff --git a/scipy/spatial/_spherical_voronoi.py b/scipy/spatial/_spherical_voronoi.py
--- a/scipy/spatial/_spherical_voronoi.py
+++ b/scipy/spatial/_spherical_voronoi.py
@@ -184,8 +184,8 @@ def __init__(self, points, radius=1, center=None, threshold=1e-06):
         # test degenerate input
         self._rank = np.linalg.matrix_rank(self.points - self.points[0],
                                            tol=threshold * self.radius)
-        if self._rank <= 1:
-            raise ValueError("Rank of input points must be at least 2")
+        if self._rank < self._dim:
+            raise ValueError("Rank of input points must be at least {0}".format(self._dim))
 
         if cKDTree(self.points).query_pairs(threshold * self.radius):
             raise ValueError("Duplicate generators present.")
@@ -197,38 +197,6 @@ def __init__(self, points, radius=1, center=None, threshold=1e-06):
 
         self._calc_vertices_regions()
 
-    def _handle_geodesic_input(self):
-
-        # center the points
-        centered = self.points - self.center
-
-        # calculate an orthogonal transformation which puts circle on x-y axis
-        _, _, vh = np.linalg.svd(centered - np.roll(centered, 1, axis=0))
-
-        # apply transformation
-        circle = centered @ vh.T
-        h = np.mean(circle[:, 2])
-        if h < 0:
-            h, vh, circle = -h, -vh, -circle
-        circle_radius = np.sqrt(np.maximum(0, self.radius**2 - h**2))
-
-        # calculate the north and south poles in this basis
-        poles = [[0, 0, self.radius], [0, 0, -self.radius]]
-
-        # calculate spherical voronoi diagram on the circle
-        lower_dimensional = SphericalVoronoi(circle[:, :2],
-                                             radius=circle_radius)
-        n = len(lower_dimensional.vertices)
-        vertices = h * np.ones((n, 3))
-        vertices[:, :2] = lower_dimensional.vertices
-
-        # north and south poles are also Voronoi vertices
-        self.vertices = np.concatenate((vertices, poles)) @ vh + self.center
-
-        # each region contains two vertices from the plane and the north and
-        # south poles
-        self.regions = [[a, n, b, n + 1] for a, b in lower_dimensional.regions]
-
     def _calc_vertices_regions(self):
         """
         Calculates the Voronoi vertices and regions of the generators stored
@@ -238,10 +206,6 @@ def _calc_vertices_regions(self):
         This algorithm was discussed at PyData London 2015 by
         Tyler Reddy, Ross Hemsley and Nikolai Nowaczyk
         """
-        if self._dim == 3 and self._rank == 2:
-            self._handle_geodesic_input()
-            return
-
         # get Convex Hull
         conv = scipy.spatial.ConvexHull(self.points)
         # get circumcenters of Convex Hull triangles from facet equations
@@ -296,8 +260,6 @@ def sort_vertices_of_regions(self):
         """
         if self._dim != 3:
             raise TypeError("Only supported for three-dimensional point sets")
-        if self._rank == 2:
-            return  # regions are sorted by construction
         _voronoi.sort_vertices_of_regions(self._simplices, self.regions)
 
     def calculate_areas(self):


Test patch:
diff --git a/scipy/spatial/tests/test_spherical_voronoi.py b/scipy/spatial/tests/test_spherical_voronoi.py
--- a/scipy/spatial/tests/test_spherical_voronoi.py
+++ b/scipy/spatial/tests/test_spherical_voronoi.py
@@ -10,10 +10,8 @@
 from pytest import warns as assert_warns
 from scipy.spatial import SphericalVoronoi, distance
 from scipy.spatial import _spherical_voronoi as spherical_voronoi
-from scipy.spatial.transform import Rotation
 from scipy.optimize import linear_sum_assignment
 from scipy.constants import golden as phi
-from scipy.spatial import geometric_slerp
 
 
 TOL = 1E-10
@@ -232,67 +230,13 @@ def test_single_hemisphere_handling(self):
             circumradii = np.arccos(np.clip(dots, -1, 1))
             assert np.max(circumradii) > np.pi / 2
 
-    def test_rank_deficient(self):
-        # rank-1 input cannot be triangulated
-        points = np.array([[-1, 0, 0], [1, 0, 0]])
-        with pytest.raises(ValueError, match="Rank of input points"):
-            spherical_voronoi.SphericalVoronoi(points)
-
-    @pytest.mark.parametrize("n", [8, 15, 21])
-    @pytest.mark.parametrize("radius", [0.5, 1, 2])
-    @pytest.mark.parametrize("fraction", [0, 0.6])
+    @pytest.mark.parametrize("n", [1, 2, 10])
     @pytest.mark.parametrize("center", [(0, 0, 0), (1, 2, 3)])
-    def test_circular_input(self, n, radius, fraction, center):
-        # generate a random circle
-        rng = np.random.RandomState(0)
-        U = Rotation.random(random_state=rng).as_matrix()
-        h = radius * fraction
-        circle_radius = np.sqrt(radius**2 - h**2)
+    def test_rank_deficient(self, n, center):
         thetas = np.linspace(0, 2 * np.pi, n, endpoint=False)
-        points = np.vstack([circle_radius * np.sin(thetas),
-                            circle_radius * np.cos(thetas),
-                            h * np.ones(n)]).T
-        points = points @ U
-
-        # calculate Spherical Voronoi diagram
-        sv = SphericalVoronoi(points + center, radius=radius, center=center)
-
-        # each region must have 4 vertices
-        region_sizes = np.array([len(region) for region in sv.regions])
-        assert (region_sizes == 4).all()
-        regions = np.array(sv.regions)
-
-        # vertices are those between each pair of input points + north and
-        # south poles
-        vertices = sv.vertices - center
-        assert len(vertices) == n + 2
-
-        # verify that north and south poles are orthogonal to circle on which
-        # input points lie
-        poles = vertices[n:]
-        midpoint = np.array([0, 0, h]) @ U
-        assert np.abs(np.dot(points - midpoint, poles.T)).max() < 1E-10
-
-        # test arc lengths to forward and backward neighbors
-        for point, region in zip(points, sv.regions):
-            cosine = np.dot(vertices[region] - midpoint, point - midpoint)
-            sine = np.linalg.norm(np.cross(vertices[region] - midpoint,
-                                           point - midpoint), axis=1)
-            arclengths = circle_radius * np.arctan2(sine, cosine)
-            assert_almost_equal(arclengths[[0, 2]], circle_radius * np.pi / n)
-
-        # test arc lengths to poles
-        for point, region in zip(points, sv.regions):
-            cosine = np.dot(vertices[region], point)
-            sine = np.linalg.norm(np.cross(vertices[region], point), axis=1)
-            arclengths = np.arctan2(sine, cosine)
-            angle = np.arcsin(h / radius)
-            assert_almost_equal(arclengths[1], np.pi / 2 - angle)
-            assert_almost_equal(arclengths[3], np.pi / 2 + angle)
-
-        regions = sv.regions.copy()
-        sv.sort_vertices_of_regions()
-        assert regions == sv.regions
+        points = np.vstack([np.sin(thetas), np.cos(thetas), np.zeros(n)]).T
+        with pytest.raises(ValueError, match="Rank of input points"):
+            spherical_voronoi.SphericalVoronoi(points + center, center=center)
 
     @pytest.mark.parametrize("dim", range(2, 7))
     def test_higher_dimensions(self, dim):
@@ -383,15 +327,3 @@ def test_equal_area_regions(self, poly):
         sv = SphericalVoronoi(points)
         areas = sv.calculate_areas()
         assert_almost_equal(areas, 4 * np.pi / len(points))
-
-    def test_ultra_close_gens(self):
-        # use geometric_slerp to produce generators that
-        # are close together, to push the limits
-        # of the area (angle) calculations
-        # also, limit generators to a single hemisphere
-        path = geometric_slerp([0, 0, 1],
-                               [1, 0, 0],
-                               t=np.linspace(0, 1, 1000))
-        sv = SphericalVoronoi(path)
-        areas = sv.calculate_areas()
-        assert_almost_equal(areas.sum(), 4 * np.pi)