Coverage for src/cvx/linalg/core/exceptions.py: 100%

54 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-03 18:56 +0000

1"""Domain-specific exceptions and warnings for cvx.linalg.""" 

2 

3from __future__ import annotations 

4 

5import warnings 

6from typing import Literal 

7 

8import numpy as np 

9 

10from .types import Matrix 

11 

12DEFAULT_COND_THRESHOLD: float = 1e12 

13"""Default condition-number threshold above which an IllConditionedMatrixWarning is emitted.""" 

14 

15 

16class NotAMatrixError(TypeError): 

17 """Raised when a 2-D matrix is required but the input has a different number of dimensions. 

18 

19 Args: 

20 ndim: Actual number of dimensions of the offending array. 

21 func: Name of the function that rejected the input. 

22 

23 Examples: 

24 >>> raise NotAMatrixError(3) 

25 Traceback (most recent call last): 

26 ... 

27 cvx.linalg.core.exceptions.NotAMatrixError: eigvals() expected a 2-D matrix, got 3-D input. 

28 >>> raise NotAMatrixError(3, func="qr") 

29 Traceback (most recent call last): 

30 ... 

31 cvx.linalg.core.exceptions.NotAMatrixError: qr() expected a 2-D matrix, got 3-D input. 

32 """ 

33 

34 def __init__(self, ndim: int, func: str = "eigvals") -> None: 

35 """Initialize with the actual number of dimensions and the rejecting function.""" 

36 super().__init__(f"{func}() expected a 2-D matrix, got {ndim}-D input.") 

37 self.ndim = ndim 

38 self.func = func 

39 

40 

41class NonSquareMatrixError(ValueError): 

42 """Raised when a square matrix is required but the input is not square. 

43 

44 Args: 

45 rows: Number of rows in the offending matrix. 

46 cols: Number of columns in the offending matrix. 

47 

48 Examples: 

49 >>> raise NonSquareMatrixError(3, 2) 

50 Traceback (most recent call last): 

51 ... 

52 cvx.linalg.core.exceptions.NonSquareMatrixError: Matrix must be square, got shape (3, 2). 

53 """ 

54 

55 def __init__(self, rows: int, cols: int) -> None: 

56 """Initialize with the offending matrix shape.""" 

57 super().__init__(f"Matrix must be square, got shape ({rows}, {cols}).") 

58 self.rows = rows 

59 self.cols = cols 

60 

61 

62class DimensionMismatchError(ValueError): 

63 """Raised when vector and matrix dimensions are incompatible. 

64 

65 Args: 

66 vector_size: Length of the offending vector. 

67 matrix_size: Expected dimension inferred from the matrix. 

68 

69 Examples: 

70 >>> raise DimensionMismatchError(3, 2) 

71 Traceback (most recent call last): 

72 ... 

73 cvx.linalg.core.exceptions.DimensionMismatchError: Vector length 3 does not match matrix dimension 2. 

74 """ 

75 

76 def __init__(self, vector_size: int, matrix_size: int) -> None: 

77 """Initialize with the offending vector and matrix sizes.""" 

78 super().__init__(f"Vector length {vector_size} does not match matrix dimension {matrix_size}.") 

79 self.vector_size = vector_size 

80 self.matrix_size = matrix_size 

81 

82 

83class SingularMatrixError(ValueError): 

84 """Raised when a matrix is (numerically) singular and cannot be inverted. 

85 

86 Args: 

87 detail: Optional extra detail string to append to the message. 

88 

89 Examples: 

90 >>> raise SingularMatrixError() 

91 Traceback (most recent call last): 

92 ... 

93 cvx.linalg.core.exceptions.SingularMatrixError: Matrix is singular and cannot be solved. 

94 """ 

95 

96 def __init__(self, detail: str = "") -> None: 

97 """Initialize with an optional extra detail string.""" 

98 msg = "Matrix is singular and cannot be solved." 

99 if detail: 

100 msg = f"{msg} {detail}" 

101 super().__init__(msg) 

102 

103 

104class NegativeWarmupError(ValueError): 

105 """Raised when a negative warmup period is requested. 

106 

107 Args: 

108 warmup: The offending warmup value. 

109 

110 Examples: 

111 >>> raise NegativeWarmupError(-3) 

112 Traceback (most recent call last): 

113 ... 

114 cvx.linalg.core.exceptions.NegativeWarmupError: warmup must be non-negative, got -3. 

115 """ 

116 

117 def __init__(self, warmup: int | None = None) -> None: 

118 """Initialize with the offending warmup value.""" 

119 msg = "warmup must be non-negative." 

120 if warmup is not None: 

121 msg = f"warmup must be non-negative, got {warmup}." 

122 super().__init__(msg) 

123 self.warmup = warmup 

124 

125 

126class NonIntegerWarmupError(TypeError): 

127 """Raised when warmup is not an integer (booleans are rejected as well). 

128 

129 Args: 

130 value: The offending warmup value. 

131 

132 Examples: 

133 >>> raise NonIntegerWarmupError(True) 

134 Traceback (most recent call last): 

135 ... 

136 cvx.linalg.core.exceptions.NonIntegerWarmupError: warmup must be an integer, got bool. 

137 """ 

138 

139 def __init__(self, value: object) -> None: 

140 """Initialize with the offending warmup value.""" 

141 super().__init__(f"warmup must be an integer, got {type(value).__name__}.") 

142 self.value = value 

143 

144 

145class InvalidComponentsError(ValueError): 

146 """Raised when the requested number of principal components is out of range. 

147 

148 Args: 

149 n_components: The requested number of components. 

150 max_components: The largest number of components supported by the data. 

151 

152 Examples: 

153 >>> raise InvalidComponentsError(10, 5) 

154 Traceback (most recent call last): 

155 ... 

156 cvx.linalg.core.exceptions.InvalidComponentsError: n_components must be between 1 and 5, got 10. 

157 """ 

158 

159 def __init__(self, n_components: int, max_components: int) -> None: 

160 """Initialize with the requested and maximum number of components.""" 

161 super().__init__(f"n_components must be between 1 and {max_components}, got {n_components}.") 

162 self.n_components = n_components 

163 self.max_components = max_components 

164 

165 

166class IllConditionedMatrixWarning(UserWarning): 

167 """Emitted when a matrix condition number exceeds a configurable threshold. 

168 

169 Examples: 

170 >>> import warnings 

171 >>> with warnings.catch_warnings(record=True) as w: 

172 ... warnings.simplefilter("always") 

173 ... warnings.warn("condition number 1e13", IllConditionedMatrixWarning) 

174 ... issubclass(w[-1].category, IllConditionedMatrixWarning) 

175 True 

176 """ 

177 

178 

179def cond(matrix: Matrix, p: int | float | Literal["fro", "nuc"] | None = None) -> float: 

180 """Return the condition number of a matrix. 

181 

182 Returns ``nan`` if the matrix contains any non-finite (NaN or inf) entries. 

183 Otherwise delegates to :func:`numpy.linalg.cond`. 

184 

185 Args: 

186 matrix: Input matrix. 

187 p: Order of the norm used to compute the condition number. 

188 Accepts the same values as :func:`numpy.linalg.cond` 

189 (``None``, ``1``, ``-1``, ``2``, ``-2``, ``numpy.inf``, 

190 ``-numpy.inf``, ``'fro'``). Defaults to ``None`` which 

191 corresponds to the 2-norm (largest singular value divided by 

192 the smallest). 

193 

194 Returns: 

195 The condition number as a ``float``, or ``nan`` when the matrix 

196 contains non-finite entries. 

197 

198 Examples: 

199 >>> import numpy as np 

200 >>> cond(np.eye(3)) 

201 1.0 

202 >>> import math 

203 >>> math.isnan(cond(np.array([[float('nan'), 1.0], [1.0, 2.0]]))) 

204 True 

205 >>> cond(np.diag([1.0, 1e10]), p=1) 

206 10000000000.0 

207 """ 

208 if not np.all(np.isfinite(matrix)): 

209 return float("nan") 

210 return float(np.linalg.cond(matrix, p=p)) 

211 

212 

213def warn_ill_conditioned(cond_value: float, threshold: float, stacklevel: int = 3) -> None: 

214 """Emit IllConditionedMatrixWarning when *cond_value* exceeds *threshold*. 

215 

216 Args: 

217 cond_value: Condition number to compare against the threshold. 

218 threshold: Upper bound before a warning is issued. 

219 stacklevel: Stack level passed to :func:`warnings.warn` so the warning 

220 points at the caller of the public API. Defaults to ``3``. 

221 

222 Example: 

223 >>> import warnings 

224 >>> with warnings.catch_warnings(record=True) as w: 

225 ... warnings.simplefilter("always") 

226 ... warn_ill_conditioned(2.0, 0.5) 

227 ... len(w) 

228 1 

229 """ 

230 if cond_value > threshold: 

231 warnings.warn( 

232 f"Matrix condition number {cond_value:.3e} exceeds threshold {threshold:.3e}; " 

233 "results may be numerically unreliable.", 

234 IllConditionedMatrixWarning, 

235 stacklevel=stacklevel, 

236 ) 

237 

238 

239def check_and_warn_condition(matrix: Matrix, threshold: float) -> None: 

240 """Emit IllConditionedMatrixWarning when the condition number exceeds threshold. 

241 

242 Args: 

243 matrix: Square matrix whose condition number is checked. 

244 threshold: Upper bound before a warning is issued. 

245 

246 Example: 

247 >>> import numpy as np 

248 >>> import warnings 

249 >>> with warnings.catch_warnings(record=True) as w: 

250 ... warnings.simplefilter("always") 

251 ... check_and_warn_condition(np.eye(2), 0.5) 

252 ... len(w) 

253 1 

254 """ 

255 warn_ill_conditioned(cond(matrix), threshold, stacklevel=4)