Skip to content

Commit 2d8e8a0

Browse files
authored
See about adding null space (#72)
* See about adding null space * . * .
1 parent 73e45d0 commit 2d8e8a0

File tree

4 files changed

+236
-8
lines changed

4 files changed

+236
-8
lines changed

fluids/friction.py

-4
Original file line numberDiff line numberDiff line change
@@ -355,10 +355,6 @@ def Colebrook(Re, eD, tol=None):
355355
for hundreds of thousand of points within the region 1E-12 < Re < 1E12
356356
and 0 < eD < 0.1.
357357
358-
The numerical solution attempts the secant method using `scipy`'s `newton`
359-
solver, and in the event of nonconvergence, attempts the `fsolve` solver
360-
as well. An initial guess is provided via the `Clamond` function.
361-
362358
The numerical and analytical solution take similar amounts of time; the
363359
`mpmath` solution used when `tol=0` is approximately 45 times slower. This
364360
function takes approximately 8 us normally.

fluids/numerics/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
from fluids.numerics.arrays import (solve as py_solve, inv, dot_product, norm2, matrix_vector_dot, eye,
3131
array_as_tridiagonals, tridiagonals_as_array, transpose,
3232
solve_tridiagonal, subset_matrix, argsort1d, shape, sort_paired_lists,
33-
stack_vectors, matrix_multiply, transpose, eye, inv, sum_matrix_rows, gelsd)
33+
stack_vectors, matrix_multiply, transpose, eye, inv, sum_matrix_rows, gelsd,
34+
null_space)
3435

3536
from fluids.numerics.special import (py_hypot, py_cacos, py_catan, py_catanh,
3637
trunc_exp, trunc_log, cbrt, factorial, comb)

fluids/numerics/arrays.py

+50-2
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
'argsort1d', 'lu', 'gelsd', 'matrix_vector_dot', 'matrix_multiply',
4949
'sum_matrix_rows', 'sum_matrix_cols', 'sort_paired_lists',
5050
'scalar_divide_matrix', 'scalar_multiply_matrix', 'scalar_subtract_matrices', 'scalar_add_matrices',
51-
'stack_vectors']
51+
'stack_vectors', 'null_space']
5252
primitive_containers = frozenset([list, tuple])
5353

5454
def transpose(matrix):
@@ -1587,4 +1587,52 @@ def gelsd(a, b, rcond=None):
15871587
# Compute residuals as |b - Ax|^2
15881588
diff = [b[i] - Ax[i] for i in range(m)]
15891589
residuals = dot_product(diff, diff)
1590-
return x, residuals, rank, s
1590+
return x, residuals, rank, s
1591+
1592+
def null_space(a, rcond=None):
1593+
"""
1594+
Construct an orthonormal basis for the null space of A using SVD.
1595+
1596+
Parameters
1597+
----------
1598+
a : list[list[float]]
1599+
Input matrix A of shape (M, N)
1600+
rcond : float, optional
1601+
Relative condition number. Singular values ``s`` smaller than
1602+
``rcond * max(s)`` are considered zero.
1603+
Default: floating point eps * max(M,N).
1604+
1605+
Returns
1606+
-------
1607+
Z : list[list[float]]
1608+
Orthonormal basis for the null space of A.
1609+
K = dimension of effective null space, as determined by rcond
1610+
"""
1611+
import numpy as np
1612+
1613+
# Get dimensions and handle empty cases
1614+
m = len(a)
1615+
n = len(a[0]) if m > 0 else 0
1616+
1617+
if m == 0 or n == 0:
1618+
return [] # Empty matrix
1619+
1620+
# Use numpy only for SVD computation
1621+
U, s, Vt = np.linalg.svd(np.array(a, dtype=np.float64), full_matrices=True)
1622+
1623+
# Convert numpy arrays to Python lists
1624+
s = s.tolist()
1625+
Vt = Vt.tolist()
1626+
1627+
# Set default rcond
1628+
if rcond is None:
1629+
rcond = max(m, n) * 2.2e-16 # Approximate machine epsilon for float64
1630+
1631+
# Determine effective null space dimension using rcond
1632+
tol = max(s) * rcond if s else 0.0
1633+
num = sum(sv > tol for sv in s)
1634+
# Extract null space basis
1635+
V = transpose(Vt) # V is transpose of Vt
1636+
Z = [row[num:] for row in V] # Extract last N - num columns
1637+
1638+
return Z

tests/test_numerics_arrays.py

+184-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
import pytest
2525
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)
2727
from fluids.numerics import (
2828
array_as_tridiagonals,
2929
assert_close,
@@ -2138,3 +2138,186 @@ def test_sort_paired_lists():
21382138
# Test 6: Unequal length lists
21392139
sort_paired_lists([1, 2], [1])
21402140

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"\nMatrix 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"\nInput 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)}\nAdditional 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)}\nAdditional 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)}\nAdditional 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)}\nAdditional context: {format_matrix_error_null_space(matrix)}"
2323+
raise Exception(new_message)

0 commit comments

Comments
 (0)