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

43 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 05:03 +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: 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 # Elementwise broadcast of the scalar radius across all points. 

51 # `cp.multiply` (not `*`) avoids CVXPY's deprecated `*`-as-matmul 

52 # path, which is ambiguous when n == 1 ((1,) * (1,) -> dot product). 

53 cp.multiply(r, np.ones(points.shape[0])), # type: ignore[attr-defined] # cvxpy re-exports atoms via star-import; stubs don't expose them 

54 points - x, # Broadcasting handles this automatically 

55 axis=1, 

56 ) 

57 ] 

58 

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

60 problem.solve(**kwargs) # type: ignore[no-untyped-call] # cvxpy's Problem.solve is unannotated 

61 

62 # Ensure the problem was solved successfully 

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

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

65 

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

67 

68 

69def _build_soc_program( 

70 points: np.ndarray, 

71) -> tuple[sp.csc_matrix, np.ndarray, sp.csc_matrix, np.ndarray, list[Any]]: 

72 """Assemble the Clarabel second-order-cone program for the enclosing ball. 

73 

74 The problem is written in Clarabel's standard form:: 

75 

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

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

78 

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

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

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

82 

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

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

85 

86 Args: 

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

88 points and *d* is the ambient dimension. 

89 

90 Returns: 

91 A tuple ``(p_mat, q, a_mat, b, cones)`` of the objective quadratic 

92 ``P``, the objective linear term ``q``, the constraint matrix ``A``, 

93 the constraint right-hand side ``b``, and the list of *n* second-order 

94 cones — the exact positional arguments Clarabel's ``DefaultSolver`` 

95 expects. 

96 """ 

97 n, d = points.shape 

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

99 

100 # --- Objective: minimise r ----------------------------------------------- 

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

102 q = np.zeros(n_vars) 

103 q[0] = 1.0 

104 

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

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

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

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

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

110 total_rows = n * (d + 1) 

111 

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

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

114 

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

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

117 x_rows = x_row_offsets.ravel() 

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

119 

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

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

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

123 

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

125 

126 b = np.zeros(total_rows) 

127 b[x_rows] = points.ravel() 

128 

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

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

131 

132 return p_mat, q, a_mat, b, cones 

133 

134 

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

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

137 

138 This function solves the same convex optimisation problem as 

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

140 directly. The second-order-cone program is assembled by 

141 :func:`_build_soc_program`; this function then solves it and extracts the 

142 optimal radius and centre. 

143 

144 Args: 

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

146 points and *d* is the ambient dimension. 

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

148 ``False``. 

149 

150 Returns: 

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

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

153 

154 Raises: 

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

156 

157 Example: 

158 >>> import numpy as np 

159 >>> from cvxball.solver import min_circle_clarabel 

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

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

162 """ 

163 p_mat, q, a_mat, b, cones = _build_soc_program(points) 

164 

165 # --- Solve --------------------------------------------------------------- 

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

167 settings.verbose = verbose 

168 

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

170 solution = solver.solve() 

171 

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

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

174 

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