Coverage for src / cvxball / solver.py: 100%

40 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 10:49 +0000

1"""Convex utilities for computing the minimum enclosing circle/ball. 

2 

3Provides two solvers for the smallest enclosing ball problem: 

4 

5- :func:`min_circle_cvx`: uses CVXPY to model and then dispatch to a backend 

6 solver (default: CLARABEL). 

7- :func:`min_circle_clarabel`: bypasses CVXPY and calls the Clarabel solver 

8 directly, which removes the CVXPY canonicalisation overhead. 

9""" 

10 

11from typing import Any 

12 

13import clarabel 

14import cvxpy as cp 

15import numpy as np 

16import scipy.sparse as sp 

17 

18 

19def min_circle_cvx(points: np.ndarray, **kwargs: dict[str, Any]) -> tuple[float, np.ndarray]: 

20 """Compute the smallest enclosing circle for a set of points using convex optimization. 

21 

22 This function solves the convex optimization problem to find the minimum radius 

23 circle that contains all the given points. It uses a second-order cone constraint 

24 to enforce that all points lie within the circle. 

25 

26 Args: 

27 points: A numpy array of shape (n, d) where n is the number of points 

28 and d is the dimension of the space. 

29 **kwargs: Additional keyword arguments to pass to the solver. 

30 Common options include 'solver' to specify which CVXPY solver to use. 

31 

32 Returns: 

33 A tuple containing: 

34 - The radius of the minimum enclosing circle (float) 

35 - The center coordinates of the circle (numpy.ndarray) 

36 

37 Example: 

38 >>> import numpy as np 

39 >>> from cvxball.solver import min_circle_cvx 

40 >>> points = np.array([[0, 0], [1, 0], [0, 1]]) 

41 >>> radius, center = min_circle_cvx(points, solver="CLARABEL") 

42 """ 

43 # cvxpy variable for the radius 

44 r = cp.Variable(shape=1, name="Radius") 

45 # cvxpy variable for the midpoint 

46 x = cp.Variable(points.shape[1], name="Midpoint") 

47 objective = cp.Minimize(r) 

48 constraints: list[cp.Constraint] = [ 

49 cp.SOC( 

50 r * np.ones(points.shape[0]), 

51 points - x, # Broadcasting handles this automatically 

52 axis=1, 

53 ) 

54 ] 

55 

56 problem = cp.Problem(objective=objective, constraints=constraints) 

57 problem.solve(**kwargs) 

58 

59 # Ensure the problem was solved successfully 

60 if r.value is None or x.value is None: 

61 raise ValueError("Optimization failed to find a solution") # noqa: TRY003 

62 

63 return float(r.value[0]), x.value 

64 

65 

66def min_circle_clarabel(points: np.ndarray, verbose: bool = False) -> tuple[float, np.ndarray]: 

67 """Compute the smallest enclosing circle for a set of points using Clarabel directly. 

68 

69 This function solves the same convex optimisation problem as 

70 :func:`min_circle_cvx` but bypasses CVXPY and calls the Clarabel solver 

71 directly. The problem is assembled in Clarabel's standard form:: 

72 

73 minimise (1/2) z' P z + q' z 

74 subject to A z + s = b, s ∈ K 

75 

76 where the decision vector is ``z = [r, x₁, …, x_d]`` (radius followed by 

77 the d centre coordinates), the objective is to minimise *r* (so ``P = 0``, 

78 ``q = e₀``), and the feasible set is a product of *n* second-order cones. 

79 

80 For each point ``p_i`` we require ``[r, p_i - x] in Q^{d+1}``, which gives 

81 one SOC block of dimension ``d + 1`` per point. 

82 

83 Args: 

84 points: A numpy array of shape ``(n, d)`` where *n* is the number of 

85 points and *d* is the ambient dimension. 

86 verbose: If ``True``, print Clarabel's iteration log. Defaults to 

87 ``False``. 

88 

89 Returns: 

90 A tuple ``(radius, center)`` where *radius* is the optimal enclosing 

91 radius (float) and *center* is a numpy array of shape ``(d,)``. 

92 

93 Raises: 

94 ValueError: If Clarabel does not return a ``Solved`` status. 

95 

96 Example: 

97 >>> import numpy as np 

98 >>> from cvxball.solver import min_circle_clarabel 

99 >>> points = np.array([[0, 0], [1, 0], [0, 1]]) 

100 >>> radius, center = min_circle_clarabel(points) 

101 """ 

102 n, d = points.shape 

103 n_vars = 1 + d # decision vector: [r, x_1, ..., x_d] 

104 

105 # --- Objective: minimise r ----------------------------------------------- 

106 p_mat = sp.csc_matrix((n_vars, n_vars)) 

107 q = np.zeros(n_vars) 

108 q[0] = 1.0 

109 

110 # --- Constraints: one SOC block of size (d+1) per point ------------------ 

111 # We need b - a_mat @ z = s where s in K. 

112 # For point i the desired slack is s = [r, p_i - x], so: 

113 # row i*(d+1) : b = 0, a_mat col 0 = -1 (gives s_0 = r) 

114 # row i*(d+1)+j : b = p_i[j], a_mat col j = +1 (gives s_j = p_ij - x_j) 

115 total_rows = n * (d + 1) 

116 

117 # Entries for the r column (column 0): -1 at each block's first row 

118 r_rows = np.arange(n) * (d + 1) 

119 

120 # Entries for the x columns (columns 1..d): +1 at each block's inner rows 

121 x_row_offsets = np.arange(n)[:, None] * (d + 1) + np.arange(1, d + 1)[None, :] # (n, d) 

122 x_rows = x_row_offsets.ravel() 

123 x_cols = np.tile(np.arange(1, d + 1), n) 

124 

125 all_rows = np.concatenate([r_rows, x_rows]) 

126 all_cols = np.concatenate([np.zeros(n, dtype=np.intp), x_cols]) 

127 all_vals = np.concatenate([-np.ones(n), np.ones(n * d)]) 

128 

129 a_mat = sp.csc_matrix((all_vals, (all_rows, all_cols)), shape=(total_rows, n_vars)) 

130 

131 b = np.zeros(total_rows) 

132 b[x_rows] = points.ravel() 

133 

134 # --- Cones: n SOC cones each of dimension (d+1) -------------------------- 

135 cones = [clarabel.SecondOrderConeT(d + 1) for _ in range(n)] # ty: ignore[unresolved-attribute] 

136 

137 # --- Solve --------------------------------------------------------------- 

138 settings = clarabel.DefaultSettings.default() # ty: ignore[unresolved-attribute] 

139 settings.verbose = verbose 

140 

141 solver = clarabel.DefaultSolver(p_mat, q, a_mat, b, cones, settings) # ty: ignore[unresolved-attribute] 

142 solution = solver.solve() 

143 

144 if solution.status != clarabel.SolverStatus.Solved: # ty: ignore[unresolved-attribute] 

145 raise ValueError(f"Clarabel did not converge: status = {solution.status}") # noqa: TRY003 

146 

147 return float(solution.x[0]), np.asarray(solution.x[1:])