pyMOTO 1.3.0__py3-none-any.whl → 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyMOTO-1.3.0.dist-info → pyMOTO-1.5.0.dist-info}/METADATA +7 -8
- pyMOTO-1.5.0.dist-info/RECORD +29 -0
- {pyMOTO-1.3.0.dist-info → pyMOTO-1.5.0.dist-info}/WHEEL +1 -1
- pymoto/__init__.py +17 -11
- pymoto/common/domain.py +61 -5
- pymoto/common/dyadcarrier.py +87 -29
- pymoto/common/mma.py +142 -129
- pymoto/core_objects.py +129 -117
- pymoto/modules/aggregation.py +209 -0
- pymoto/modules/assembly.py +250 -10
- pymoto/modules/complex.py +3 -3
- pymoto/modules/filter.py +171 -24
- pymoto/modules/generic.py +12 -1
- pymoto/modules/io.py +85 -12
- pymoto/modules/linalg.py +92 -120
- pymoto/modules/scaling.py +5 -4
- pymoto/routines.py +34 -9
- pymoto/solvers/__init__.py +14 -0
- pymoto/solvers/auto_determine.py +108 -0
- pymoto/{common/solvers_dense.py → solvers/dense.py} +90 -70
- pymoto/solvers/iterative.py +361 -0
- pymoto/solvers/matrix_checks.py +60 -0
- pymoto/solvers/solvers.py +253 -0
- pymoto/{common/solvers_sparse.py → solvers/sparse.py} +42 -29
- pyMOTO-1.3.0.dist-info/RECORD +0 -24
- pymoto/common/solvers.py +0 -236
- {pyMOTO-1.3.0.dist-info → pyMOTO-1.5.0.dist-info}/LICENSE +0 -0
- {pyMOTO-1.3.0.dist-info → pyMOTO-1.5.0.dist-info}/top_level.txt +0 -0
- {pyMOTO-1.3.0.dist-info → pyMOTO-1.5.0.dist-info}/zip-safe +0 -0
@@ -0,0 +1,361 @@
|
|
1
|
+
import warnings
|
2
|
+
import time
|
3
|
+
import numpy as np
|
4
|
+
import scipy.sparse as sps
|
5
|
+
from scipy.sparse.linalg import splu, spilu
|
6
|
+
from .solvers import LinearSolver
|
7
|
+
from .auto_determine import auto_determine_solver
|
8
|
+
from pymoto import DomainDefinition
|
9
|
+
|
10
|
+
|
11
|
+
class Preconditioner(LinearSolver):
|
12
|
+
""" Abstract base class for preconditioners to inexact solvers """
|
13
|
+
def update(self, A):
|
14
|
+
pass
|
15
|
+
|
16
|
+
def solve(self, rhs, x0=None, trans='N'):
|
17
|
+
return rhs.copy()
|
18
|
+
|
19
|
+
|
20
|
+
class DampedJacobi(Preconditioner):
|
21
|
+
r""" Damped Jacobi preconditioner
|
22
|
+
:math:`M = \frac{1}{\omega} D`
|
23
|
+
Args:
|
24
|
+
A (optional): The matrix
|
25
|
+
w (optional): Weight factor :math:`0 < \omega \leq 1`
|
26
|
+
"""
|
27
|
+
def __init__(self, A=None, w=1.0):
|
28
|
+
assert 0 < w <= 1, 'w must be between 0 and 1'
|
29
|
+
self.w = w
|
30
|
+
self.D = None
|
31
|
+
super().__init__(A)
|
32
|
+
|
33
|
+
def update(self, A):
|
34
|
+
self.D = A.diagonal()
|
35
|
+
|
36
|
+
def solve(self, rhs, x0=None, trans='N'):
|
37
|
+
if trans == 'N' or trans == 'T':
|
38
|
+
return self.w * (rhs.T/self.D).T
|
39
|
+
elif trans == 'H':
|
40
|
+
return self.w * (rhs.T/self.D.conj()).T
|
41
|
+
else:
|
42
|
+
raise TypeError("Only N, T, or H transposition is possible")
|
43
|
+
|
44
|
+
|
45
|
+
class SOR(Preconditioner):
|
46
|
+
r""" Successive over-relaxation preconditioner
|
47
|
+
The matrix :math:`A = L + D + U` is split into a lower triangular, diagonal, and upper triangular part.
|
48
|
+
:math:`M = \left(\frac{D}{\omega} + L\right) \frac{\omega D^{-1}}{2-\omega} \left(\frac{D}{\omega} + U\right)`
|
49
|
+
|
50
|
+
Args:
|
51
|
+
A (optional): The matrix
|
52
|
+
w (optional): Weight factor :math:`0 < \omega < 2`
|
53
|
+
"""
|
54
|
+
def __init__(self, A=None, w=1.0):
|
55
|
+
assert 0 < w < 2, 'w must be between 0 and 2'
|
56
|
+
self.w = w
|
57
|
+
self.L = None
|
58
|
+
self.U = None
|
59
|
+
self.Dw = None
|
60
|
+
super().__init__(A)
|
61
|
+
|
62
|
+
def update(self, A):
|
63
|
+
diag = A.diagonal()
|
64
|
+
diagw = sps.diags(diag)/self.w
|
65
|
+
self.L = splu(sps.tril(A, k=-1) + diagw) # Lower triangular part including diagonal
|
66
|
+
self.U = splu(sps.triu(A, k=1) + diagw)
|
67
|
+
|
68
|
+
self.Dw = diag * (2 - self.w) / self.w
|
69
|
+
|
70
|
+
def solve(self, rhs, x0=None, trans='N'):
|
71
|
+
if trans == 'N':
|
72
|
+
# M = (D/w + L) wD^-1 / (2-w) (D/w + U)
|
73
|
+
# from scipy.sparse.linalg import spsolve_triangular
|
74
|
+
# u1 = spsolve_triangular(self.L, rhs, lower=True, overwrite_A=False) # Solve triangular is still very slow :(
|
75
|
+
u1 = self.L.solve(rhs)
|
76
|
+
u1 *= self.Dw[:, None]
|
77
|
+
# u2 = spsolve_triangular(self.U, u1, lower=False, overwrite_A=False, overwrite_b=True)
|
78
|
+
u2 = self.U.solve(u1)
|
79
|
+
return u2
|
80
|
+
elif trans == 'T':
|
81
|
+
u1 = self.U.solve(rhs, trans='T')
|
82
|
+
u1 *= self.Dw[:, None]
|
83
|
+
u2 = self.L.solve(u1, trans='T')
|
84
|
+
return u2
|
85
|
+
elif trans == 'H':
|
86
|
+
u1 = self.U.solve(rhs, trans='H')
|
87
|
+
u1 *= self.Dw[:, None].conj()
|
88
|
+
u2 = self.L.solve(u1, trans='H')
|
89
|
+
return u2
|
90
|
+
else:
|
91
|
+
raise TypeError("Only N, T, or H transposition is possible")
|
92
|
+
|
93
|
+
|
94
|
+
class ILU(Preconditioner):
|
95
|
+
""" Incomplete LU factorization
|
96
|
+
|
97
|
+
Args:
|
98
|
+
A (optional): The matrix
|
99
|
+
**kwargs (optional): Keyword arguments passed to `scipy.sparse.linalg.spilu`
|
100
|
+
"""
|
101
|
+
def __init__(self, A=None, **kwargs):
|
102
|
+
self.kwargs = kwargs
|
103
|
+
self.ilu = None
|
104
|
+
super().__init__(A)
|
105
|
+
|
106
|
+
def update(self, A):
|
107
|
+
self.ilu = spilu(A, **self.kwargs)
|
108
|
+
|
109
|
+
def solve(self, rhs, x0=None, trans='N'):
|
110
|
+
return self.ilu.solve(rhs, trans=trans)
|
111
|
+
|
112
|
+
|
113
|
+
class GeometricMultigrid(Preconditioner):
|
114
|
+
""" Geometric multigrid preconditioner
|
115
|
+
|
116
|
+
Args:
|
117
|
+
domain: The `DomainDefinition` used for the geometry
|
118
|
+
A (optional): The matrix
|
119
|
+
inner_level (optional): Inner solver for the coarse grid, for instance, a direct solver or another MG level.
|
120
|
+
The default is an automatically determined direct solver.
|
121
|
+
smoother (optional): Smoother to use to smooth the residual and solution before and after coarse level.
|
122
|
+
The default is `DampedJacobi(w=0.5)`.
|
123
|
+
smooth_steps (optional): Number of smoothing steps to execute
|
124
|
+
"""
|
125
|
+
_available_cycles = ['v', 'w']
|
126
|
+
|
127
|
+
def __init__(self, domain: DomainDefinition, A=None, cycle='V', inner_level=None, smoother=None, smooth_steps=5):
|
128
|
+
assert domain.nelx % 2 == 0 and domain.nely % 2 == 0 and domain.nelz % 2 == 0, \
|
129
|
+
f"Domain sizes {domain.nelx, domain.nely, domain.nelz} must be divisible by 2"
|
130
|
+
self.domain = domain
|
131
|
+
self.A = A
|
132
|
+
assert cycle.lower() in self._available_cycles, f"Cycle ({cycle}) is not available. Options are {self._available_cycles}"
|
133
|
+
self.cycle = cycle
|
134
|
+
self.inner_level = None if inner_level is None else inner_level
|
135
|
+
self.smoother = DampedJacobi(w=0.5) if smoother is None else None
|
136
|
+
self.smooth_steps = smooth_steps
|
137
|
+
self.R = None
|
138
|
+
self.sub_domain = DomainDefinition(domain.nelx // 2, domain.nely // 2, domain.nelz // 2,
|
139
|
+
domain.unitx * 2, domain.unity * 2, domain.unitz * 2)
|
140
|
+
|
141
|
+
super().__init__(A)
|
142
|
+
|
143
|
+
def update(self, A):
|
144
|
+
if self.R is None:
|
145
|
+
self.setup_interpolation(A)
|
146
|
+
self.A = A
|
147
|
+
self.smoother.update(A)
|
148
|
+
Ac = self.R.T @ A @ self.R
|
149
|
+
if self.inner_level is None:
|
150
|
+
self.inner_level = auto_determine_solver(Ac)
|
151
|
+
self.inner_level.update(Ac)
|
152
|
+
|
153
|
+
def setup_interpolation(self, A):
|
154
|
+
assert A.shape[0] % self.domain.nnodes == 0
|
155
|
+
ndof = int(A.shape[0] / self.domain.nnodes) # Number of dofs per node
|
156
|
+
|
157
|
+
w = np.ones((3, 3, 3))*0.125
|
158
|
+
w[1, :, :] = 0.25
|
159
|
+
w[:, 1, :] = 0.25
|
160
|
+
w[:, :, 1] = 0.25
|
161
|
+
w[1, 1, :] = 0.5
|
162
|
+
w[1, :, 1] = 0.5
|
163
|
+
w[:, 1, 1] = 0.5
|
164
|
+
w[1, 1, 1] = 1.0
|
165
|
+
|
166
|
+
rows = []
|
167
|
+
cols = []
|
168
|
+
vals = []
|
169
|
+
for i in [-1, 0, 1]:
|
170
|
+
imin, imax = max(-i, 0), min(self.sub_domain.nelx + 1 - i, self.sub_domain.nelx + 1)
|
171
|
+
ix = np.arange(imin, imax)
|
172
|
+
for j in [-1, 0, 1]:
|
173
|
+
jmin, jmax = max(-j, 0), min(self.sub_domain.nely + 1 - j, self.sub_domain.nely + 1)
|
174
|
+
iy = np.arange(jmin, jmax)
|
175
|
+
for k in ([-1, 0, 1] if self.domain.dim == 3 else [0]):
|
176
|
+
# Coarse node cartesian indices
|
177
|
+
kmin, kmax = max(-k, 0), min(self.sub_domain.nelz + 1 - k, self.sub_domain.nelz + 1)
|
178
|
+
iz = np.arange(kmin, kmax)
|
179
|
+
# Coarse node numbers
|
180
|
+
nod_c = self.sub_domain.get_nodenumber(*np.meshgrid(ix, iy, iz, indexing='ij')).flatten()
|
181
|
+
# Fine node numbers with offset
|
182
|
+
ixc, iyc, izc = ix * 2 + i, iy * 2 + j, iz * 2 + k
|
183
|
+
nod_f = self.domain.get_nodenumber(*np.meshgrid(ixc, iyc, izc, indexing='ij')).flatten()
|
184
|
+
for d in range(ndof):
|
185
|
+
rows.append(nod_f * ndof + d)
|
186
|
+
cols.append(nod_c * ndof + d)
|
187
|
+
vals.append(np.ones_like(rows[-1], dtype=w.dtype) * w[1+i, 1+j, 1+k])
|
188
|
+
|
189
|
+
rows = np.concatenate(rows)
|
190
|
+
cols = np.concatenate(cols)
|
191
|
+
vals = np.concatenate(vals)
|
192
|
+
nfine = ndof * self.domain.nnodes
|
193
|
+
ncoarse = ndof * self.sub_domain.nnodes
|
194
|
+
self.R = sps.coo_matrix((vals, (rows, cols)), shape=(nfine, ncoarse))
|
195
|
+
self.R = type(A)(self.R) # Convert to correct matrix type
|
196
|
+
|
197
|
+
def solve(self, rhs, x0=None, trans='N'):
|
198
|
+
if trans == 'N':
|
199
|
+
A = self.A
|
200
|
+
elif trans == 'T':
|
201
|
+
A = self.A.T
|
202
|
+
elif trans == 'H':
|
203
|
+
A = self.A.conj().T
|
204
|
+
else:
|
205
|
+
raise TypeError("Only N, T, or H transposition is possible")
|
206
|
+
|
207
|
+
# Pre-smoothing
|
208
|
+
if x0 is None:
|
209
|
+
u_f = self.smoother.solve(rhs, trans=trans)
|
210
|
+
else:
|
211
|
+
r = rhs - self.A @ x0
|
212
|
+
u_f = x0 + self.smoother.solve(r, trans=trans)
|
213
|
+
for i in range(self.smooth_steps-1):
|
214
|
+
r = rhs - self.A @ u_f
|
215
|
+
u_f += self.smoother.solve(r, trans=trans)
|
216
|
+
|
217
|
+
r = rhs - A @ u_f
|
218
|
+
# Restrict residual to coarse level
|
219
|
+
r_c = self.R.T @ r
|
220
|
+
|
221
|
+
# Solve at coarse level
|
222
|
+
u_c = self.inner_level.solve(r_c)
|
223
|
+
|
224
|
+
# Interpolate and correct
|
225
|
+
u_f += self.R @ u_c
|
226
|
+
|
227
|
+
# Post-smoothing
|
228
|
+
for i in range(self.smooth_steps):
|
229
|
+
r = rhs - self.A @ u_f
|
230
|
+
u_f += self.smoother.solve(r, trans=trans)
|
231
|
+
return u_f
|
232
|
+
|
233
|
+
|
234
|
+
def orth(u, normalize=True, zero_rtol=1e-15):
|
235
|
+
""" Create orthogonal basis from a set of vectors
|
236
|
+
|
237
|
+
Args:
|
238
|
+
u: Set of vectors of size (#dof, #vectors)
|
239
|
+
normalize: Also normalize the basis vectors
|
240
|
+
zero_rtol: Relative tolerance for detection of zero vectors (in case of a rank-deficient basis)
|
241
|
+
|
242
|
+
Returns:
|
243
|
+
v: Orthogonal basis vectors (#dof, #non-zero-vectors)
|
244
|
+
"""
|
245
|
+
if u.ndim == 1:
|
246
|
+
return u
|
247
|
+
elif u.ndim > 2:
|
248
|
+
raise TypeError("Only valid for 1D or 2D matrix")
|
249
|
+
|
250
|
+
def dot(a, b): # Define inner product
|
251
|
+
return a @ b.conj()
|
252
|
+
|
253
|
+
orth_vecs = []
|
254
|
+
for i in range(u.shape[-1]):
|
255
|
+
vi = np.copy(u[..., i])
|
256
|
+
beta_i = dot(vi, vi)
|
257
|
+
for vj in orth_vecs:
|
258
|
+
alpha_ij = dot(vi, vj)
|
259
|
+
alpha_jj = 1.0 if normalize else dot(vj, vj)
|
260
|
+
vi -= vj * alpha_ij / alpha_jj
|
261
|
+
beta_i_new = dot(vi, vi)
|
262
|
+
if beta_i_new / beta_i < zero_rtol: # Detect zero vector
|
263
|
+
continue
|
264
|
+
if normalize:
|
265
|
+
vi /= np.sqrt(beta_i_new)
|
266
|
+
orth_vecs.append(vi)
|
267
|
+
return np.stack(orth_vecs, axis=-1)
|
268
|
+
|
269
|
+
|
270
|
+
class CG(LinearSolver):
|
271
|
+
""" Preconditioned conjugate gradient method
|
272
|
+
Works for positive-definite self-adjoint matrices (:math:`A=A^H`)
|
273
|
+
|
274
|
+
References:
|
275
|
+
Ji & Li (2017), A breakdown-free BCG method. DOI 10.1007/s10543-016-0631-z
|
276
|
+
https://www.cs.odu.edu/~yaohang/portfolio/BIT2017.pdf
|
277
|
+
Shewchuck (1994), Introduction to CG method without the agonzing pain.
|
278
|
+
https://www.cs.cmu.edu/~quake-papers/painless-conjugate-gradient.pdf
|
279
|
+
|
280
|
+
Args:
|
281
|
+
A: The matrix
|
282
|
+
preconditioner: Preconditioner to use
|
283
|
+
tol: Convergence tolerance
|
284
|
+
maxit: Maximum number of iterations
|
285
|
+
restart: Restart every Nth iteration
|
286
|
+
verbosity: Log level
|
287
|
+
"""
|
288
|
+
def __init__(self, A=None, preconditioner=Preconditioner(), tol=1e-7, maxit=10000, restart=50, verbosity=0):
|
289
|
+
self.preconditioner = preconditioner
|
290
|
+
self.A = A
|
291
|
+
self.tol = tol
|
292
|
+
self.maxit = maxit
|
293
|
+
self.restart = restart
|
294
|
+
self.verbosity = verbosity
|
295
|
+
super().__init__(A)
|
296
|
+
|
297
|
+
def update(self, A):
|
298
|
+
tstart = time.perf_counter()
|
299
|
+
self.A = A
|
300
|
+
self.preconditioner.update(A)
|
301
|
+
if self.verbosity >= 1:
|
302
|
+
print(f"Preconditioner set up in {np.round(time.perf_counter() - tstart,3)}s")
|
303
|
+
|
304
|
+
def solve(self, rhs, x0=None, trans='N'):
|
305
|
+
if trans == 'N':
|
306
|
+
A = self.A
|
307
|
+
elif trans == 'T':
|
308
|
+
A = self.A.T
|
309
|
+
elif trans == 'H':
|
310
|
+
A = self.A.conj().T
|
311
|
+
else:
|
312
|
+
raise TypeError("Only N, T, or H transposition is possible")
|
313
|
+
|
314
|
+
tstart = time.perf_counter()
|
315
|
+
if rhs.ndim == 1:
|
316
|
+
b = rhs.reshape((rhs.size, 1))
|
317
|
+
else:
|
318
|
+
b = rhs
|
319
|
+
x = np.zeros_like(rhs, dtype=np.result_type(rhs, A)) if x0 is None else x0.copy()
|
320
|
+
if x.ndim == 1:
|
321
|
+
x = x.reshape((x.size, 1))
|
322
|
+
|
323
|
+
r = b - A@x
|
324
|
+
z = self.preconditioner.solve(r, trans=trans)
|
325
|
+
p = orth(z, normalize=True)
|
326
|
+
if self.verbosity >= 2:
|
327
|
+
print(f"Initial residual = {np.linalg.norm(r, axis=0) / np.linalg.norm(b, axis=0)}")
|
328
|
+
|
329
|
+
for i in range(self.maxit):
|
330
|
+
q = A @ p
|
331
|
+
pq = p.conj().T @ q
|
332
|
+
pq_inv = np.linalg.inv(pq)
|
333
|
+
alpha = pq_inv @ (p.conj().T @ r)
|
334
|
+
|
335
|
+
x += p @ alpha
|
336
|
+
if i % 50 == 0: # Explicit restart
|
337
|
+
r = b - A@x
|
338
|
+
else:
|
339
|
+
r -= q @ alpha
|
340
|
+
|
341
|
+
if self.verbosity >= 2:
|
342
|
+
print(f"i = {i}, residuals = {np.linalg.norm(r, axis=0) / np.linalg.norm(b, axis=0)}")
|
343
|
+
|
344
|
+
tval = np.linalg.norm(r)/np.linalg.norm(b)
|
345
|
+
if tval <= self.tol:
|
346
|
+
break
|
347
|
+
|
348
|
+
z = self.preconditioner.solve(r, trans=trans)
|
349
|
+
|
350
|
+
beta = -pq_inv @ (q.conj().T @ z)
|
351
|
+
p = orth(z + p@beta, normalize=False)
|
352
|
+
|
353
|
+
if tval > self.tol:
|
354
|
+
warnings.warn(f'Maximum iterations ({self.maxit}) reached, with final residual {tval}')
|
355
|
+
elif self.verbosity >= 1:
|
356
|
+
print(f"Converged in {i} iterations and {np.round(time.perf_counter() - tstart, 3)}s, with final residuals {np.linalg.norm(r, axis=0) / np.linalg.norm(b, axis=0)}")
|
357
|
+
|
358
|
+
if rhs.ndim == 1:
|
359
|
+
return x.flatten()
|
360
|
+
else:
|
361
|
+
return x
|
@@ -0,0 +1,60 @@
|
|
1
|
+
import numpy as np
|
2
|
+
import scipy.sparse as sps
|
3
|
+
try:
|
4
|
+
import cvxopt
|
5
|
+
_has_cvxopt = True
|
6
|
+
except ImportError:
|
7
|
+
_has_cvxopt = False
|
8
|
+
|
9
|
+
|
10
|
+
def is_cvxopt_spmatrix(A):
|
11
|
+
""" Checks if the argument is a cvxopt sparse matrix """
|
12
|
+
return isinstance(A, cvxopt.spmatrix) if _has_cvxopt else False
|
13
|
+
|
14
|
+
|
15
|
+
def matrix_is_sparse(A):
|
16
|
+
return sps.issparse(A)
|
17
|
+
|
18
|
+
|
19
|
+
def matrix_is_complex(A):
|
20
|
+
""" Checks if the matrix is complex """
|
21
|
+
if is_cvxopt_spmatrix(A):
|
22
|
+
return A.typecode == 'z'
|
23
|
+
else:
|
24
|
+
return np.iscomplexobj(A)
|
25
|
+
|
26
|
+
|
27
|
+
def matrix_is_diagonal(A):
|
28
|
+
""" Checks if the matrix is diagonal"""
|
29
|
+
if matrix_is_sparse(A):
|
30
|
+
if isinstance(A, sps.dia_matrix):
|
31
|
+
return len(A.offsets) == 1 and A.offsets[0] == 0
|
32
|
+
else:
|
33
|
+
return np.allclose((A - sps.spdiags(A.diagonal(), 0, *A.shape)).data, 0.0)
|
34
|
+
elif is_cvxopt_spmatrix(A):
|
35
|
+
return max(abs(A.I - A.J)) == 0
|
36
|
+
else:
|
37
|
+
return np.allclose(A, np.diag(np.diag(A)))
|
38
|
+
|
39
|
+
|
40
|
+
def matrix_is_symmetric(A):
|
41
|
+
""" Checks whether a matrix is numerically symmetric """
|
42
|
+
if matrix_is_sparse(A):
|
43
|
+
return np.allclose((A-A.T).data, 0)
|
44
|
+
elif is_cvxopt_spmatrix(A):
|
45
|
+
return np.isclose(max(abs(A-A.T)), 0.0)
|
46
|
+
else:
|
47
|
+
return np.allclose(A, A.T)
|
48
|
+
|
49
|
+
|
50
|
+
def matrix_is_hermitian(A):
|
51
|
+
""" Checks whether a matrix is numerically Hermitian """
|
52
|
+
if matrix_is_complex(A):
|
53
|
+
if matrix_is_sparse(A):
|
54
|
+
return np.allclose((A-A.T.conj()).data, 0)
|
55
|
+
elif is_cvxopt_spmatrix(A):
|
56
|
+
return np.isclose(max(abs(A-A.ctrans())), 0.0)
|
57
|
+
else:
|
58
|
+
return np.allclose(A, A.T.conj())
|
59
|
+
else:
|
60
|
+
return matrix_is_symmetric(A)
|
@@ -0,0 +1,253 @@
|
|
1
|
+
import warnings
|
2
|
+
import numpy as np
|
3
|
+
from .matrix_checks import matrix_is_hermitian, matrix_is_symmetric, matrix_is_complex
|
4
|
+
|
5
|
+
|
6
|
+
class LinearSolver:
|
7
|
+
""" Base class of all linear solvers
|
8
|
+
|
9
|
+
Keyword Args:
|
10
|
+
A (matrix): Optionally provide a matrix, which is used in :method:`update` right away.
|
11
|
+
|
12
|
+
Attributes:
|
13
|
+
defined (bool): Flag if the solver is able to run, e.g. false if some dependent library is not available
|
14
|
+
"""
|
15
|
+
|
16
|
+
defined = True
|
17
|
+
_err_msg = ""
|
18
|
+
|
19
|
+
def __init__(self, A=None):
|
20
|
+
if A is not None:
|
21
|
+
self.update(A)
|
22
|
+
|
23
|
+
def update(self, A):
|
24
|
+
""" Updates with a new matrix of the same structure
|
25
|
+
|
26
|
+
Args:
|
27
|
+
A (matrix): The new matrix of size ``(N, N)``
|
28
|
+
|
29
|
+
Returns:
|
30
|
+
self
|
31
|
+
"""
|
32
|
+
raise NotImplementedError(f"Solver not implemented {self._err_msg}")
|
33
|
+
|
34
|
+
def solve(self, rhs, x0=None, trans='N'):
|
35
|
+
r""" Solves the linear system of equations :math:`\mathbf{A} \mathbf{x} = \mathbf{b}`
|
36
|
+
|
37
|
+
Args:
|
38
|
+
rhs: Right hand side :math:`\mathbf{b}` of shape ``(N)`` or ``(N, K)`` for multiple right-hand-sides
|
39
|
+
x0 (optional): Initial guess for the solution
|
40
|
+
trans (optional): Option to transpose matrix
|
41
|
+
'N': A @ x == rhs (default) Normal matrix
|
42
|
+
'T': A^T @ x == rhs Transposed matrix
|
43
|
+
'H': A^H @ x == rhs Hermitian transposed matrix (conjugate transposed)
|
44
|
+
|
45
|
+
Returns:
|
46
|
+
Solution vector :math:`\mathbf{x}` of same shape as :math:`\mathbf{b}`
|
47
|
+
"""
|
48
|
+
raise NotImplementedError(f"Solver not implemented {self._err_msg}")
|
49
|
+
|
50
|
+
@staticmethod
|
51
|
+
def residual(A, x, b, trans='N'):
|
52
|
+
r""" Calculates the (relative) residual of the linear system of equations
|
53
|
+
|
54
|
+
The residual is calculated as
|
55
|
+
:math:`r = \frac{\left| \mathbf{A} \mathbf{x} - \mathbf{b} \right|}{\left| \mathbf{b} \right|}`
|
56
|
+
|
57
|
+
Args:
|
58
|
+
A: The matrix
|
59
|
+
x: Solution vector
|
60
|
+
b: Right-hand side
|
61
|
+
trans (optional): Matrix tranformation (`N` is normal, `T` is transposed, `H` is hermitian transposed)
|
62
|
+
|
63
|
+
Returns:
|
64
|
+
Residual value
|
65
|
+
"""
|
66
|
+
assert x.shape == b.shape
|
67
|
+
if trans == 'N':
|
68
|
+
mat = A
|
69
|
+
elif trans == 'T':
|
70
|
+
mat = A.T
|
71
|
+
elif trans == 'H':
|
72
|
+
mat = A.conj().T
|
73
|
+
else:
|
74
|
+
raise TypeError("Only N, T, or H transposition is possible")
|
75
|
+
return np.linalg.norm(mat@x - b, axis=0) / np.linalg.norm(b, axis=0)
|
76
|
+
|
77
|
+
|
78
|
+
class LDAWrapper(LinearSolver):
|
79
|
+
r""" Linear dependency aware solver (LDAS)
|
80
|
+
|
81
|
+
This solver uses previous solutions of the system :math:`\mathbf{A} \mathbf{x} = \mathbf{b}` to reduce computational
|
82
|
+
effort. In case the solution :math:`\mathbf{x}` is linearly dependent on the previous solutions, the solution
|
83
|
+
will be nearly free of cost.
|
84
|
+
|
85
|
+
Args:
|
86
|
+
solver: The internal solver to be used
|
87
|
+
tol (optional): Residual tolerance above which the internal solver is used to add a new solution vector.
|
88
|
+
A (optional): The matrix :math:`\mathbf{A}`
|
89
|
+
symmetric (optional): Flag to indicate a symmetric matrix :math:`A=A^T`
|
90
|
+
hermitian (optional): Flag to indicate a Hermitian matrix :math:`A=A^H`
|
91
|
+
|
92
|
+
References:
|
93
|
+
Koppen, S., van der Kolk, M., van den Boom, S., & Langelaar, M. (2022).
|
94
|
+
Efficient computation of states and sensitivities for compound structural optimisation problems using a Linear Dependency Aware Solver (LDAS).
|
95
|
+
Structural and Multidisciplinary Optimization, 65(9), 273.
|
96
|
+
DOI: 10.1007/s00158-022-03378-8
|
97
|
+
"""
|
98
|
+
def __init__(self, solver: LinearSolver, tol=1e-7, A=None, symmetric=None, hermitian=None):
|
99
|
+
self.solver = solver
|
100
|
+
self.tol = tol
|
101
|
+
# Storage for solution vectors (solutions of A x = b)
|
102
|
+
self.x_stored = []
|
103
|
+
self.b_stored = []
|
104
|
+
# Storage for adjoint solution vectors (solutions of A^H x = b)
|
105
|
+
self.xadj_stored = []
|
106
|
+
self.badj_stored = []
|
107
|
+
self.A = None
|
108
|
+
self._did_solve = False # For debugging purposes
|
109
|
+
self._last_rtol = 0.
|
110
|
+
self.hermitian = hermitian
|
111
|
+
self.symmetric = symmetric
|
112
|
+
self.complex = None
|
113
|
+
super().__init__(A)
|
114
|
+
|
115
|
+
def update(self, A):
|
116
|
+
""" Clear the internal stored solution vectors and update the internal ``solver`` """
|
117
|
+
if self.symmetric is None:
|
118
|
+
self.symmetric = matrix_is_symmetric(A)
|
119
|
+
|
120
|
+
if self.hermitian is None:
|
121
|
+
if not matrix_is_complex(A):
|
122
|
+
self.hermitian = self.symmetric
|
123
|
+
self.hermitian = matrix_is_hermitian(A)
|
124
|
+
|
125
|
+
self.A = A
|
126
|
+
self.x_stored.clear()
|
127
|
+
self.b_stored.clear()
|
128
|
+
self.xadj_stored.clear()
|
129
|
+
self.badj_stored.clear()
|
130
|
+
self.solver.update(A)
|
131
|
+
|
132
|
+
def _do_solve_1rhs(self, A, rhs, x_data, b_data, solve_fn, x0=None):
|
133
|
+
dtype = np.result_type(A, rhs)
|
134
|
+
if rhs.ndim == 1:
|
135
|
+
rhs_loc = np.zeros((rhs.size, 1), dtype=dtype)
|
136
|
+
rhs_loc[:, 0] = rhs
|
137
|
+
else:
|
138
|
+
rhs_loc = np.zeros_like(rhs, dtype=dtype)
|
139
|
+
rhs_loc[:] = rhs
|
140
|
+
sol = np.zeros_like(rhs_loc, dtype=dtype)
|
141
|
+
|
142
|
+
# Check linear dependencies in the rhs using modified Gram-Schmidt
|
143
|
+
for (x, b) in zip(x_data, b_data):
|
144
|
+
assert x.ndim == b.ndim == 1
|
145
|
+
assert x.size == b.size == rhs_loc.shape[0]
|
146
|
+
alpha = rhs_loc.T @ b.conj() / (b.conj() @ b)
|
147
|
+
|
148
|
+
rem_rhs = alpha * b[:, None]
|
149
|
+
add_sol = alpha * x[:, None]
|
150
|
+
|
151
|
+
if np.iscomplexobj(rem_rhs) and not np.iscomplexobj(rhs_loc):
|
152
|
+
if np.linalg.norm(np.imag(rem_rhs)) < 1e-10*np.linalg.norm(np.real(rem_rhs)):
|
153
|
+
rem_rhs = np.real(rem_rhs)
|
154
|
+
else:
|
155
|
+
warnings.warn('LDAS: Complex vector cannot be subtracted from real rhs')
|
156
|
+
continue
|
157
|
+
|
158
|
+
if np.iscomplexobj(add_sol) and not np.iscomplexobj(sol):
|
159
|
+
if np.linalg.norm(np.imag(add_sol)) < 1e-10*np.linalg.norm(np.real(add_sol)):
|
160
|
+
add_sol = np.real(add_sol)
|
161
|
+
else:
|
162
|
+
warnings.warn('LDAS: Complex vector cannot be added to real solution')
|
163
|
+
continue
|
164
|
+
|
165
|
+
rhs_loc -= rem_rhs
|
166
|
+
sol += add_sol
|
167
|
+
|
168
|
+
# Check tolerance
|
169
|
+
self._last_rtol = np.ones(rhs_loc.shape[-1]) if len(x_data) == 0 else self.residual(A, sol, rhs if rhs.ndim > 1 else rhs.reshape(-1, 1))
|
170
|
+
self._did_solve = self._last_rtol > self.tol
|
171
|
+
if np.any(self._did_solve):
|
172
|
+
if x0 is not None:
|
173
|
+
if x0.ndim == 1:
|
174
|
+
x0_loc = x0.reshape(-1, 1).copy()
|
175
|
+
else:
|
176
|
+
x0_loc = x0[..., self._did_solve].copy()
|
177
|
+
for x in x_data:
|
178
|
+
beta = x0_loc.T @ x.conj() / (x.conj() @ x)
|
179
|
+
x0_loc -= beta * x
|
180
|
+
else:
|
181
|
+
x0_loc = None
|
182
|
+
|
183
|
+
# Calculate a new solution
|
184
|
+
xnew = solve_fn(rhs_loc[..., self._did_solve], x0_loc)
|
185
|
+
self.residual(A, xnew, rhs_loc[..., self._did_solve])
|
186
|
+
sol[..., self._did_solve] += xnew
|
187
|
+
|
188
|
+
# Add to database
|
189
|
+
for i in range(xnew.shape[-1]):
|
190
|
+
# Remove all previous components that are already in the database (orthogonalize)
|
191
|
+
xadd = xnew[..., i]
|
192
|
+
badd = A @ xadd
|
193
|
+
for x, b in zip(x_data, b_data):
|
194
|
+
beta = badd @ b.conj() / (b.conj() @ b)
|
195
|
+
badd -= beta * b
|
196
|
+
xadd -= beta * x
|
197
|
+
bnrm = np.linalg.norm(badd)
|
198
|
+
if not np.isfinite(bnrm) or bnrm == 0:
|
199
|
+
continue
|
200
|
+
badd /= bnrm
|
201
|
+
xadd /= bnrm
|
202
|
+
x_data.append(xadd)
|
203
|
+
b_data.append(badd)
|
204
|
+
|
205
|
+
if rhs.ndim == 1:
|
206
|
+
return sol.flatten()
|
207
|
+
else:
|
208
|
+
return sol
|
209
|
+
|
210
|
+
def solve(self, rhs, x0=None, trans='N'):
|
211
|
+
r""" Solves the linear system of equations :math:`\mathbf{A} \mathbf{x} = \mathbf{b}` by performing a modified
|
212
|
+
Gram-Schmidt over the previously calculated solutions :math:`\mathbf{U}` and corresponding right-hand-sides
|
213
|
+
:math:`\mathbf{F}`. This is used to construct an approximate solution
|
214
|
+
:math:`\tilde{\mathbf{x}} = \sum_k \alpha_k \mathbf{u}_k` in the subspace of :math:`\mathbf{U}`.
|
215
|
+
If the residual of :math:`\mathbf{A} \tilde{\mathbf{x}} = \mathbf{b}` is above the tolerance, a new solution
|
216
|
+
:math:`\mathbf{u}_{k+1}` will be added to the database such that
|
217
|
+
:math:`\mathbf{x} = \tilde{\mathbf{x}}+\mathbf{u}_{k+1}` is the solution to the system
|
218
|
+
:math:`\mathbf{A} \mathbf{x} = \mathbf{b}`.
|
219
|
+
|
220
|
+
The right-hand-side :math:`\mathbf{b}` can be of size ``(N)`` or ``(N, K)``, where ``N`` is the size of matrix
|
221
|
+
:math:`\mathbf{A}` and ``K`` is the number of right-hand sides.
|
222
|
+
"""
|
223
|
+
''' Required Symm. matrix Herm. matrix Any matrix (uses adjoint storage)
|
224
|
+
A^T = A A^H = A
|
225
|
+
A x = b
|
226
|
+
A^T x = b A x = b A x^* = b^* A^H x^* = b^*
|
227
|
+
A^H x = b A x^* = b^* A x = b A^H x = b
|
228
|
+
|
229
|
+
For symmetric or Hermitian matrices, only the normal storage is required. For any other matrix, the `T` and
|
230
|
+
`H` mode will require the adjoint storage space.
|
231
|
+
'''
|
232
|
+
if trans not in ['N', 'T', 'H']:
|
233
|
+
raise TypeError("Only N, T, or H transposition is possible")
|
234
|
+
|
235
|
+
# Use adjoint storage?
|
236
|
+
adjoint_mode = trans != 'N' and not (self.symmetric or self.hermitian)
|
237
|
+
# Use conjugation?
|
238
|
+
conj_mode = self.symmetric and trans == 'H' or not self.symmetric and trans == 'T'
|
239
|
+
|
240
|
+
storage = 'H' if adjoint_mode else 'N'
|
241
|
+
rhs = rhs.conj() if conj_mode else rhs
|
242
|
+
if storage == 'N': # Use the normal storage
|
243
|
+
ret = self._do_solve_1rhs(self.A, rhs,
|
244
|
+
self.x_stored, self.b_stored,
|
245
|
+
lambda b, x_init: self.solver.solve(b, trans='N', x0=x_init),
|
246
|
+
x0=x0)
|
247
|
+
elif storage == 'H': # Use adjoint storage
|
248
|
+
ret = self._do_solve_1rhs(self.A.conj().T, rhs,
|
249
|
+
self.xadj_stored, self.badj_stored,
|
250
|
+
lambda b, x_init: self.solver.solve(b, trans='H', x0=x_init),
|
251
|
+
x0=x0)
|
252
|
+
|
253
|
+
return ret.conj() if conj_mode else ret
|