23
23
24
24
import pytest
25
25
from fluids .numerics .arrays import (inv , solve , lu , gelsd , eye , dot_product , transpose , matrix_vector_dot , matrix_multiply , sum_matrix_rows , sum_matrix_cols ,
26
- scalar_divide_matrix , scalar_multiply_matrix , scalar_subtract_matrices , scalar_add_matrices )
26
+ scalar_divide_matrix , scalar_multiply_matrix , scalar_subtract_matrices , scalar_add_matrices , null_space )
27
27
from fluids .numerics import (
28
28
array_as_tridiagonals ,
29
29
assert_close ,
@@ -2138,3 +2138,186 @@ def test_sort_paired_lists():
2138
2138
# Test 6: Unequal length lists
2139
2139
sort_paired_lists ([1 , 2 ], [1 ])
2140
2140
2141
+
2142
+ def format_matrix_error_null_space (matrix ):
2143
+ """Format a detailed error message for matrix comparison failure"""
2144
+ def matrix_info (matrix ):
2145
+ """Get diagnostic information about a matrix"""
2146
+ arr = np .array (matrix )
2147
+ rank = np .linalg .matrix_rank (arr )
2148
+ shape = arr .shape
2149
+ try :
2150
+ cond = np .linalg .cond (arr )
2151
+ except :
2152
+ cond = float ('inf' )
2153
+ # Only compute determinant for square matrices
2154
+ det = np .linalg .det (arr ) if shape [0 ] == shape [1 ] else None
2155
+ return {
2156
+ 'rank' : rank ,
2157
+ 'condition_number' : cond ,
2158
+ 'shape' : shape ,
2159
+ 'null_space_dim' : shape [1 ] - rank ,
2160
+ 'determinant' : det
2161
+ }
2162
+ info = matrix_info (matrix )
2163
+
2164
+ msg = (
2165
+ f"\n Matrix properties:"
2166
+ f"\n Shape: { info ['shape' ]} "
2167
+ f"\n Rank: { info ['rank' ]} "
2168
+ f"\n Null space dimension: { info ['null_space_dim' ]} "
2169
+ f"\n Condition number: { info ['condition_number' ]:.2e} "
2170
+ )
2171
+ if info ['determinant' ] is not None :
2172
+ msg += f"\n Determinant: { info ['determinant' ]:.2e} "
2173
+ msg += (
2174
+ f"\n Input matrix:"
2175
+ f"\n { np .array2string (np .array (matrix ), precision = 6 , suppress_small = True )} "
2176
+ )
2177
+ return msg
2178
+
2179
+ def check_null_space (matrix , rtol = None ):
2180
+ """Check if null_space implementation matches scipy behavior"""
2181
+ import scipy
2182
+ just_return = False
2183
+ try :
2184
+ # This will fail for bad matrix inputs
2185
+ cond = np .linalg .cond (np .array (matrix ))
2186
+ except :
2187
+ just_return = True
2188
+
2189
+ py_fail = False
2190
+ scipy_fail = False
2191
+
2192
+ try :
2193
+ result = null_space (matrix )
2194
+ if not result : # Empty result is valid for some cases
2195
+ return
2196
+ result = np .array (result )
2197
+ except :
2198
+ py_fail = True
2199
+
2200
+ # Convert to numpy array if not already
2201
+ matrix = np .array (matrix )
2202
+ try :
2203
+ expected = scipy .linalg .null_space (matrix , rcond = rtol )
2204
+ except :
2205
+ scipy_fail = True
2206
+
2207
+ if py_fail and not scipy_fail :
2208
+ if not just_return and cond > 1e14 :
2209
+ # Let ill conditioned matrices pass
2210
+ return
2211
+ raise ValueError (f"Inconsistent failure states: Python Fail: { py_fail } , SciPy Fail: { scipy_fail } " )
2212
+ if py_fail and scipy_fail :
2213
+ return
2214
+ if not py_fail and scipy_fail :
2215
+ return
2216
+ if just_return :
2217
+ return
2218
+
2219
+
2220
+ if rtol is None :
2221
+ rtol = get_rtol (matrix )
2222
+
2223
+ # Compute matrix norm for threshold
2224
+ matrix_norm = np .max (np .sum (np .abs (matrix ), axis = 1 ))
2225
+ thresh = matrix_norm * np .finfo (float ).eps
2226
+
2227
+
2228
+ # We need to handle sign ambiguity in the basis vectors
2229
+ # Both +v and -v are valid basis vectors
2230
+ # Compare shapes first
2231
+ assert result .shape == expected .shape , \
2232
+ f"Shape mismatch: got { result .shape } , expected { expected .shape } "
2233
+
2234
+ if result .shape [1 ] > 0 : # Only if we have vectors
2235
+ # For each column in result, check if it matches any expected column or its negative
2236
+ used_expected_cols = set ()
2237
+
2238
+ for i in range (result .shape [1 ]):
2239
+ res_col = result [:, i ].reshape (- 1 , 1 )
2240
+ found_match = False
2241
+
2242
+ # Try matching with each unused expected column
2243
+ for j in range (expected .shape [1 ]):
2244
+ if j in used_expected_cols :
2245
+ continue
2246
+
2247
+ exp_col = expected [:, j ].reshape (- 1 , 1 )
2248
+
2249
+ # Check both orientations with looser tolerance
2250
+ matches_positive = np .allclose (res_col , exp_col , rtol = 1e-7 , atol = 1e-10 )
2251
+ matches_negative = np .allclose (res_col , - exp_col , rtol = 1e-7 , atol = 1e-10 )
2252
+
2253
+ if matches_positive or matches_negative :
2254
+ used_expected_cols .add (j )
2255
+ found_match = True
2256
+ break
2257
+
2258
+ assert found_match , f"Column { i } doesn't match any expected column in either orientation"
2259
+
2260
+ # Verify orthonormality
2261
+ gram = result .T @ result
2262
+ assert_allclose (gram , np .eye (gram .shape [0 ]), rtol = 1e-10 , atol = 100 * thresh ,
2263
+ err_msg = "Basis vectors are not orthonormal" )
2264
+
2265
+ # Verify it's actually a null space
2266
+ product = matrix @ result
2267
+ assert_allclose (product , np .zeros_like (product ), atol = 100 * thresh ,
2268
+ err_msg = "Result vectors are not in the null space" )
2269
+
2270
+ # Test cases specific to null space
2271
+ matrices_full_rank = [
2272
+ [[1.0 ]], # 1x1
2273
+ [[1.0 , 0.0 ], [0.0 , 1.0 ]], # 2x2 identity
2274
+ [[1.0 , 2.0 ], [3.0 , 4.0 ]], # 2x2 full rank
2275
+ ]
2276
+
2277
+ matrices_rank_deficient = [
2278
+ [[1.0 , 1.0 ], [1.0 , 1.0 ]], # 2x2 rank 1
2279
+ [[1.0 , 2.0 , 3.0 ], [2.0 , 4.0 , 6.0 ]], # 2x3 rank 1
2280
+ [[1.0 , 0.0 , 0.0 ], [0.0 , 1.0 , 0.0 ], [0.0 , 0.0 , 0.0 ]], # 3x3 rank 2
2281
+ ]
2282
+
2283
+ matrices_tall = [
2284
+ [[1.0 ], [0.0 ], [0.0 ]], # 3x1
2285
+ [[1.0 , 0.0 ], [0.0 , 1.0 ], [1.0 , 1.0 ]], # 3x2
2286
+ ]
2287
+
2288
+ matrices_wide = [
2289
+ [[1.0 , 0.0 , 0.0 ]], # 1x3
2290
+ [[1.0 , 0.0 , 1.0 ], [0.0 , 1.0 , 1.0 ]], # 2x3
2291
+ ]
2292
+
2293
+ @pytest .mark .parametrize ("matrix" , matrices_full_rank )
2294
+ def test_null_space_full_rank (matrix ):
2295
+ try :
2296
+ check_null_space (matrix )
2297
+ except Exception as e :
2298
+ new_message = f"Original error: { str (e )} \n Additional context: { format_matrix_error_null_space (matrix )} "
2299
+ raise Exception (new_message )
2300
+
2301
+ @pytest .mark .parametrize ("matrix" , matrices_rank_deficient )
2302
+ def test_null_space_rank_deficient (matrix ):
2303
+ try :
2304
+ check_null_space (matrix )
2305
+ except Exception as e :
2306
+ new_message = f"Original error: { str (e )} \n Additional context: { format_matrix_error_null_space (matrix )} "
2307
+ raise Exception (new_message )
2308
+
2309
+ @pytest .mark .parametrize ("matrix" , matrices_tall )
2310
+ def test_null_space_tall (matrix ):
2311
+ try :
2312
+ check_null_space (matrix )
2313
+ except Exception as e :
2314
+ new_message = f"Original error: { str (e )} \n Additional context: { format_matrix_error_null_space (matrix )} "
2315
+ raise Exception (new_message )
2316
+
2317
+ @pytest .mark .parametrize ("matrix" , matrices_wide )
2318
+ def test_null_space_wide (matrix ):
2319
+ try :
2320
+ check_null_space (matrix )
2321
+ except Exception as e :
2322
+ new_message = f"Original error: { str (e )} \n Additional context: { format_matrix_error_null_space (matrix )} "
2323
+ raise Exception (new_message )
0 commit comments