Skip to content

Commit ad15580

Browse files
committed
Added complex matrix support in cholmoddense2numpy and numpy2cholmoddense so that solve() works.
1 parent 15fb679 commit ad15580

4 files changed

Lines changed: 83 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
66

77
## [Unreleased]
88

9+
## [v1.5.1] - 2026-02-03
10+
### Added
11+
- Added complex matrix support in `cholmoddense2numpy` and `numpy2cholmoddense` so that `solve()` works.
12+
913
## [v1.5] - 2026-02-03
1014
### Added
1115
- Added complex matrix support in `cholmodsparse2scipy` and `scipy2cholmodsparse`.

sparseqr/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from __future__ import absolute_import
1919

20-
__version__ = '1.5'
20+
__version__ = '1.5.1'
2121

2222
# import the important things into the package's top-level namespace.
2323
from .sparseqr import qr, rz, solve, permutation_vector_to_matrix, qr_factorize,qmult

sparseqr/sparseqr.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,19 +170,41 @@ def numpy2cholmoddense( numpy_A ):
170170
nrow = numpy_A.shape[0]
171171
ncol = numpy_A.shape[1]
172172
lda = nrow # cholmod_dense is column-oriented
173-
chol_A = lib.cholmod_l_allocate_dense( nrow, ncol, lda, lib.CHOLMOD_REAL, cc )
173+
174+
# Check if the array has complex entries and adapt cholmod type accordingly
175+
is_complex = numpy.issubdtype(numpy_A.dtype, numpy.complexfloating)
176+
cholmod_dtype = lib.CHOLMOD_COMPLEX if is_complex else lib.CHOLMOD_REAL
177+
178+
chol_A = lib.cholmod_l_allocate_dense( nrow, ncol, lda, cholmod_dtype, cc )
174179
if chol_A == ffi.NULL:
175180
raise RuntimeError("Failed to allocate chol_A")
176181
Adata = ffi.cast( "double*", chol_A.x )
177-
for j in range(ncol): # FIXME inefficient?
178-
Adata[(j*lda):((j+1)*lda)] = numpy_A[:,j]
182+
183+
if is_complex:
184+
# chol_A.x has size 2*nrow*ncol and real and imag parts are interleaved [real, imag, real, imag, ...]
185+
array_element_size = ffi.sizeof(ffi.typeof(Adata).item)
186+
Adata_view = numpy.frombuffer(ffi.buffer(Adata, 2*nrow*ncol * array_element_size), ctype2dtype["double"])
187+
for j in range(ncol):
188+
col_data = numpy_A[:,j]
189+
Adata_view[(j*lda*2):((j+1)*lda*2):2] = col_data.real
190+
Adata_view[(j*lda*2)+1:((j+1)*lda*2):2] = col_data.imag
191+
else:
192+
for j in range(ncol): # FIXME inefficient?
193+
Adata[(j*lda):((j+1)*lda)] = numpy_A[:,j]
179194
return chol_A
180195

181196
def cholmoddense2numpy( chol_A ):
182197
'''Convert a CHOLMOD dense matrix to a NumPy array.'''
183198
Adata = ffi.cast( "double*", chol_A.x )
184199

185-
result = asarray( ffi, Adata, chol_A.nrow*chol_A.ncol ).copy()
200+
if chol_A.xtype == lib.CHOLMOD_COMPLEX:
201+
# read real and imag part from the buffer and create view as complex datatype
202+
result = asarray(ffi, Adata, 2*chol_A.nrow*chol_A.ncol).copy()
203+
complex_dtype = numpy.dtype(f"c{result.itemsize * 2}")
204+
result = result.view(complex_dtype)
205+
else:
206+
result = asarray( ffi, Adata, chol_A.nrow*chol_A.ncol ).copy()
207+
186208
result = result.reshape( (chol_A.nrow, chol_A.ncol), order='F' )
187209
return result
188210

test/test_complex.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import numpy as np
44
import pytest
5-
from scipy.sparse import random as sparse_random
5+
import scipy.sparse
66

77
import sparseqr
88

@@ -19,23 +19,23 @@ class TestComplexQR:
1919

2020
def test_complex_qr_unitary_q(self):
2121
"""Test that Q is unitary for complex matrix (Q @ Q^H = I)."""
22-
A = sparse_random(m=120, n=100, density=0.1, dtype=np.complex128, random_state=42)
22+
A = scipy.sparse.random(m=120, n=100, density=0.1, dtype=np.complex128, random_state=42)
2323
Q, R, P, rank = sparseqr.qr(A)
2424

2525
QQH = (Q @ adj(Q)).toarray()
2626
np.testing.assert_allclose(QQH, np.eye(Q.shape[0]), atol=self.ATOL)
2727

2828
def test_complex_qr_upper_triangular_r(self):
2929
"""Test that R is upper triangular for complex matrix."""
30-
A = sparse_random(m=120, n=100, density=0.1, dtype=np.complex128, random_state=42)
30+
A = scipy.sparse.random(m=120, n=100, density=0.1, dtype=np.complex128, random_state=42)
3131
Q, R, P, rank = sparseqr.qr(A)
3232

3333
lower_triangle = np.tril(R.toarray(), k=-1)
3434
np.testing.assert_allclose(lower_triangle, 0, atol=self.ATOL)
3535

3636
def test_complex_qr_reconstruction(self):
3737
"""Test that A can be reconstructed from QR decomposition: A = (QR) @ P^T."""
38-
A = sparse_random(m=120, n=100, density=0.1, dtype=np.complex128, random_state=42)
38+
A = scipy.sparse.random(m=120, n=100, density=0.1, dtype=np.complex128, random_state=42)
3939
Q, R, P, rank = sparseqr.qr(A)
4040

4141
P_matrix = sparseqr.permutation_vector_to_matrix(P)
@@ -46,13 +46,60 @@ def test_complex_qr_reconstruction(self):
4646
def test_complex_qr_various_shapes(self, shape):
4747
"""Test complex QR on square, tall, and wide matrices."""
4848
m, n = shape
49-
A = sparse_random(m=m, n=n, density=0.1, dtype=np.complex128, random_state=42)
49+
A = scipy.sparse.random(m=m, n=n, density=0.1, dtype=np.complex128, random_state=42)
5050
Q, R, P, rank = sparseqr.qr(A)
5151

5252
P_matrix = sparseqr.permutation_vector_to_matrix(P)
5353
A_reconstructed = (Q @ R) @ P_matrix.T
5454
np.testing.assert_allclose(A_reconstructed.toarray(), A.toarray(), atol=self.ATOL)
5555

5656

57+
class TestComplexSolve:
58+
"""Tests for solve function with complex matrices."""
59+
60+
ATOL = 1e-10
61+
62+
def test_complex_solve_exact_system(self):
63+
"""Test solving an exact complex system where Ax=b has exact solution."""
64+
A = scipy.sparse.random(m=50, n=50, density=0.2, dtype=np.complex128, random_state=42)
65+
# Add diagonal to make it well-conditioned
66+
A = A + 5 * scipy.sparse.eye(50, dtype=np.complex128)
67+
x_true = np.random.RandomState(42).random(50) + 1j * np.random.RandomState(43).random(50)
68+
b = A @ x_true
69+
70+
x = sparseqr.solve(A, b, tolerance=0)
71+
72+
assert x is not None, "solve() returned None"
73+
np.testing.assert_allclose(x, x_true, atol=self.ATOL)
74+
75+
def test_complex_solve_overdetermined_least_squares(self):
76+
"""Test least-squares solution for overdetermined complex system."""
77+
# 100 equations, 50 unknowns
78+
A = scipy.sparse.random(m=100, n=50, density=0.2, dtype=np.complex128, random_state=42)
79+
x_true = np.random.RandomState(42).random(50) + 1j * np.random.RandomState(43).random(50)
80+
b = A @ x_true
81+
82+
x = sparseqr.solve(A, b, tolerance=0)
83+
84+
assert x is not None, "solve() returned None"
85+
assert x.shape == (50,), f"Solution shape wrong: {x.shape}"
86+
# For an exact system (b in range of A), solution should match
87+
np.testing.assert_allclose(x, x_true, atol=self.ATOL)
88+
89+
def test_complex_solve_multiple_rhs(self):
90+
"""Test solving AX=B with multiple complex RHS vectors."""
91+
A = scipy.sparse.random(m=50, n=50, density=0.2, dtype=np.complex128, random_state=42)
92+
A = A + 5 * scipy.sparse.eye(50, dtype=np.complex128)
93+
X_true = (np.random.RandomState(42).random((50, 3)) +
94+
1j * np.random.RandomState(43).random((50, 3)))
95+
B = A @ X_true
96+
97+
X = sparseqr.solve(A, B, tolerance=0)
98+
99+
assert X is not None, "solve() returned None"
100+
assert X.shape == (50, 3), f"Solution shape wrong: {X.shape}"
101+
np.testing.assert_allclose(X, X_true, atol=self.ATOL)
102+
103+
57104
if __name__ == '__main__':
58105
pytest.main([__file__, '-v'])

0 commit comments

Comments
 (0)