structsolve 0.4.1__tar.gz → 0.4.2__tar.gz

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.
Files changed (30) hide show
  1. {structsolve-0.4.1/structsolve.egg-info → structsolve-0.4.2}/PKG-INFO +14 -4
  2. {structsolve-0.4.1 → structsolve-0.4.2}/README.md +13 -3
  3. {structsolve-0.4.1 → structsolve-0.4.2}/pyproject.toml +1 -1
  4. structsolve-0.4.2/structsolve/linear_buckling.py +357 -0
  5. {structsolve-0.4.1 → structsolve-0.4.2/structsolve.egg-info}/PKG-INFO +14 -4
  6. {structsolve-0.4.1 → structsolve-0.4.2}/tests/test_linear_buckling.py +160 -3
  7. structsolve-0.4.1/structsolve/linear_buckling.py +0 -163
  8. {structsolve-0.4.1 → structsolve-0.4.2}/LICENSE +0 -0
  9. {structsolve-0.4.1 → structsolve-0.4.2}/setup.cfg +0 -0
  10. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve/__init__.py +0 -0
  11. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve/analysis.py +0 -0
  12. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve/arc_length.py +0 -0
  13. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve/arc_length_crisfield.py +0 -0
  14. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve/arc_length_riks.py +0 -0
  15. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve/freq.py +0 -0
  16. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve/logger.py +0 -0
  17. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve/newton_raphson.py +0 -0
  18. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve/sparseutils.py +0 -0
  19. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve/static.py +0 -0
  20. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve.egg-info/SOURCES.txt +0 -0
  21. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve.egg-info/dependency_links.txt +0 -0
  22. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve.egg-info/requires.txt +0 -0
  23. {structsolve-0.4.1 → structsolve-0.4.2}/structsolve.egg-info/top_level.txt +0 -0
  24. {structsolve-0.4.1 → structsolve-0.4.2}/tests/test_analysis.py +0 -0
  25. {structsolve-0.4.1 → structsolve-0.4.2}/tests/test_arc_length.py +0 -0
  26. {structsolve-0.4.1 → structsolve-0.4.2}/tests/test_freq.py +0 -0
  27. {structsolve-0.4.1 → structsolve-0.4.2}/tests/test_newton_raphson.py +0 -0
  28. {structsolve-0.4.1 → structsolve-0.4.2}/tests/test_sparseutils.py +0 -0
  29. {structsolve-0.4.1 → structsolve-0.4.2}/tests/test_static.py +0 -0
  30. {structsolve-0.4.1 → structsolve-0.4.2}/tests/test_static_deflection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structsolve
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Structural analysis solvers
5
5
  Author-email: "Saullo G. P. Castro" <castrosaullo@gmail.com>
6
6
  License: BSD-2-Clause
@@ -60,7 +60,7 @@ Currently these solvers are pretty much compatible with my other repositories
60
60
  Citing this library
61
61
  ===================
62
62
 
63
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.4.1). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
63
+ Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.4.2). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
64
64
 
65
65
 
66
66
  Documentation
@@ -75,8 +75,18 @@ History
75
75
 
76
76
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
77
77
 
78
- * version 0.4.1 (2026-09-17)
79
- - Fixed the eigenvalue shift of the sparse solver of `lb`, which could return buckling loads higher than the critical ones
78
+ * version 0.4.2 (2026-09-17)
79
+ - Fixed the eigenvalue shift of the sparse solver of `lb`, which could
80
+ return buckling loads higher than the critical ones
81
+ - Fixed wrong buckling loads of the sparse solver of `lb` when SciPy is
82
+ linked against Intel MKL 2024.2.0 to 2025.0.0 (e.g. Anaconda on
83
+ Windows), whose `dsteqr` routine breaks ARPACK
84
+ - `lb` condenses out the dofs where `KG` is null and solves small
85
+ condensed problems with a dense solver
86
+ - `lb` verifies the eigenpairs of the sparse solver, raising an error
87
+ instead of returning wrong results
88
+ - The sparse solver of `lb` returns the load multipliers sorted as the
89
+ dense solver
80
90
  - Newton-Raphson with full Newton iterations and a relative convergence
81
91
  criterion by default, reaching quadratic convergence with exact tangent
82
92
  stiffness matrices
@@ -23,7 +23,7 @@ Currently these solvers are pretty much compatible with my other repositories
23
23
  Citing this library
24
24
  ===================
25
25
 
26
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.4.1). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
26
+ Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.4.2). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
27
27
 
28
28
 
29
29
  Documentation
@@ -38,8 +38,18 @@ History
38
38
 
39
39
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
40
40
 
41
- * version 0.4.1 (2026-09-17)
42
- - Fixed the eigenvalue shift of the sparse solver of `lb`, which could return buckling loads higher than the critical ones
41
+ * version 0.4.2 (2026-09-17)
42
+ - Fixed the eigenvalue shift of the sparse solver of `lb`, which could
43
+ return buckling loads higher than the critical ones
44
+ - Fixed wrong buckling loads of the sparse solver of `lb` when SciPy is
45
+ linked against Intel MKL 2024.2.0 to 2025.0.0 (e.g. Anaconda on
46
+ Windows), whose `dsteqr` routine breaks ARPACK
47
+ - `lb` condenses out the dofs where `KG` is null and solves small
48
+ condensed problems with a dense solver
49
+ - `lb` verifies the eigenpairs of the sparse solver, raising an error
50
+ instead of returning wrong results
51
+ - The sparse solver of `lb` returns the load multipliers sorted as the
52
+ dense solver
43
53
  - Newton-Raphson with full Newton iterations and a relative convergence
44
54
  criterion by default, reaching quadratic convergence with exact tangent
45
55
  stiffness matrices
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "structsolve"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Structural analysis solvers"
9
9
  readme = "README.md"
10
10
  license = {text = "BSD-2-Clause"}
@@ -0,0 +1,357 @@
1
+ import warnings
2
+
3
+ import numpy as np
4
+ from scipy.sparse import csc_matrix, csr_matrix
5
+ from scipy.sparse.linalg import eigsh, splu
6
+ from scipy.linalg import eigh, LinAlgError
7
+
8
+ from .logger import msg, warn
9
+ from .sparseutils import remove_null_cols
10
+
11
+
12
+ def _estimate_sigma(K, KG, safety=10., max_iter=50, rel_tol=1e-3):
13
+ r"""Shift of the Cayley mode used by :func:`lb`
14
+
15
+ With ``sigma > 0``, ``eigsh(A=KG, M=K, sigma=sigma, mode='cayley',
16
+ which='SM')`` returns the eigenvalues `\mu` of ``KG u = mu K u`` with the
17
+ smallest `|(\mu + \sigma)/(\mu - \sigma)|`. This ordering selects the most
18
+ negative `\mu`, i.e. the lowest positive load multipliers `-1/\mu`, only
19
+ when `\sigma` is larger than `|\mu|` of these critical eigenvalues.
20
+ Otherwise the eigenvalues closest to `-\sigma` are returned.
21
+
22
+ The largest `|\mu|` is estimated with power iterations on
23
+ `[K]^{-1}[K_G]`, whose norm ratio in the `[K]`-norm increases towards the
24
+ largest `|\mu|`, and multiplied by ``safety``. The default shift ``1.`` is
25
+ returned when ``K`` is singular, not positive definite, or the linear
26
+ solution is inaccurate.
27
+
28
+ """
29
+ try:
30
+ with warnings.catch_warnings():
31
+ warnings.simplefilter("ignore")
32
+ lu = splu(csc_matrix(K))
33
+ x = np.random.RandomState(42).randn(K.shape[0])
34
+ Kx = K @ x
35
+ mu_max = 0.
36
+ for i in range(max_iter):
37
+ xKx = x @ Kx
38
+ if not np.isfinite(xKx) or xKx <= 0:
39
+ return 1.
40
+ x = x / np.sqrt(xKx)
41
+ rhs = KG @ x
42
+ y = lu.solve(rhs)
43
+ Ky = K @ y
44
+ if i == 0:
45
+ residual = np.linalg.norm(Ky - rhs)
46
+ if not residual <= 1e-6 * np.linalg.norm(rhs):
47
+ return 1.
48
+ # ||y||_K / ||x||_K, with ||x||_K = 1
49
+ ratio = np.sqrt(abs(y @ Ky))
50
+ if not np.isfinite(ratio) or ratio == 0:
51
+ return 1.
52
+ converged = i > 0 and ratio - mu_max <= rel_tol * ratio
53
+ mu_max = max(mu_max, ratio)
54
+ if converged:
55
+ break
56
+ x, Kx = y, Ky
57
+ return safety * mu_max
58
+ except Exception:
59
+ return 1.
60
+
61
+
62
+ def _is_positive_definite(A):
63
+ """Tells if the symmetric sparse matrix ``A`` is positive definite
64
+
65
+ ``A`` is factorized with a symmetric permutation and without row
66
+ interchanges. The Gaussian elimination of a positive definite matrix
67
+ never finds a zero or negative pivot, and by Sylvester's law of inertia
68
+ any negative pivot means a negative eigenvalue of ``A``.
69
+
70
+ """
71
+ try:
72
+ with warnings.catch_warnings():
73
+ warnings.simplefilter("ignore")
74
+ lu = splu(csc_matrix(A), permc_spec='MMD_AT_PLUS_A',
75
+ diag_pivot_thresh=0., options=dict(SymmetricMode=True))
76
+ except RuntimeError:
77
+ # exactly singular
78
+ return False
79
+ if not np.array_equal(lu.perm_r, lu.perm_c):
80
+ # a zero pivot forced a row interchange
81
+ return False
82
+ return bool(np.all(lu.U.diagonal() > 0))
83
+
84
+
85
+ def _check_eigenpairs(K, KG, eigvals, eigvecs, rtol, min_rel_gap=1e-4):
86
+ r"""Verify the eigenpairs of `([K] + \lambda [K_G])\{u\} = \{0\}`
87
+
88
+ - The relative residual
89
+ `||K u + \lambda K_G u|| / (||K u|| + |\lambda| ||K_G u||)` of every
90
+ eigenpair with a finite `\lambda` must not be larger than ``rtol``.
91
+ - No load multiplier is missing below the lowest positive `\lambda_1`
92
+ of ``eigvals``: when ``K`` is positive definite, `[K] + s [K_G]` with
93
+ `s = \lambda_1 (1 - min\_rel\_gap)` must also be positive definite.
94
+
95
+ Returns
96
+ -------
97
+ error : str or None
98
+ Description of the failed check, ``None`` when all checks passed.
99
+
100
+ """
101
+ finite = np.isfinite(eigvals)
102
+ if not np.any(finite):
103
+ return 'no finite load multiplier was found'
104
+ lam = eigvals[finite]
105
+ u = eigvecs[:, finite]
106
+ Ku = K @ u
107
+ KGu = KG @ u
108
+ num = np.linalg.norm(Ku + KGu*lam, axis=0)
109
+ den = np.linalg.norm(Ku, axis=0) + np.abs(lam)*np.linalg.norm(KGu, axis=0)
110
+ residual = np.full(lam.shape, np.inf)
111
+ np.divide(num, den, out=residual, where=den > 0)
112
+ if not np.all(residual <= rtol):
113
+ i = np.argmax(np.nan_to_num(residual, nan=np.inf))
114
+ return ('relative residual {0:.2e} > {1:.1e} for the load multiplier '
115
+ '{2}'.format(residual[i], rtol, lam[i]))
116
+ positive = lam[lam > 0]
117
+ if positive.size and _is_positive_definite(K):
118
+ s = positive.min()*(1 - min_rel_gap)
119
+ if not _is_positive_definite(K + s*KG):
120
+ return ('at least one load multiplier lower than {0} is missing'
121
+ .format(positive.min()))
122
+ return None
123
+
124
+
125
+ def _sort_eigenpairs(mu, eigvecs):
126
+ r"""Sort by increasing `\mu`, i.e. the positive `\lambda = -1/\mu` first
127
+ in increasing order, as returned by :func:`scipy.linalg.eigh`"""
128
+ order = np.argsort(mu, kind='stable')
129
+ return mu[order], eigvecs[:, order]
130
+
131
+
132
+ def _eigh_condensed(K, KG, k):
133
+ r"""Dense solution after condensing out the dofs where ``KG`` is null
134
+
135
+ With the dofs split in `b`, where `[K_G]` has non-null rows, and `a`,
136
+ where it does not, the static condensation of the `a` dofs gives:
137
+
138
+ .. math::
139
+
140
+ ([K_{bb}] - [K_{ba}][K_{aa}]^{-1}[K_{ab}]
141
+ + \lambda [K_{G_{bb}}])\{u_b\} = \{0\}, \qquad
142
+ \{u_a\} = -[K_{aa}]^{-1}[K_{ab}]\{u_b\}
143
+
144
+ solved with :func:`scipy.linalg.eigh`, which requires a positive
145
+ definite Schur complement. Returns the ``k`` lowest eigenvalues `\mu` of
146
+ `K_G u = \mu K u` and the corresponding eigenvectors.
147
+
148
+ """
149
+ active = np.asarray(abs(KG).sum(axis=1)).ravel() > 0
150
+ b = np.flatnonzero(active)
151
+ a = np.flatnonzero(~active)
152
+ if b.size == 0:
153
+ raise ValueError('KG is a null matrix')
154
+ K = csr_matrix(K)
155
+ KGbb = csr_matrix(KG)[b, :][:, b].toarray()
156
+ S = K[b, :][:, b].toarray()
157
+ if a.size:
158
+ Kab = csc_matrix(K[a, :][:, b])
159
+ Kba = K[b, :][:, a]
160
+ with warnings.catch_warnings():
161
+ warnings.simplefilter("ignore")
162
+ lu = splu(csc_matrix(K[a, :][:, a]))
163
+ # solving blocks of right-hand sides with at most ~1e7 entries
164
+ chunk = max(1, int(1e7) // a.size)
165
+ for j0 in range(0, b.size, chunk):
166
+ j1 = min(j0 + chunk, b.size)
167
+ X = lu.solve(Kab[:, j0:j1].toarray())
168
+ S[:, j0:j1] -= Kba @ X
169
+ mu, ub = eigh(a=0.5*(KGbb + KGbb.T), b=0.5*(S + S.T))
170
+ k = min(k, b.size)
171
+ ub = ub[:, :k]
172
+ eigvecs = np.zeros((K.shape[0], k), dtype=ub.dtype)
173
+ eigvecs[b, :] = ub
174
+ if a.size:
175
+ eigvecs[a, :] = -lu.solve(np.asarray(Kab @ ub))
176
+ return mu[:k], eigvecs
177
+
178
+
179
+ def _eigsh_cayley(K, KG, k, tol, silent):
180
+ """Solution with :func:`scipy.sparse.linalg.eigsh` in Cayley mode"""
181
+ sigma = _estimate_sigma(K, KG)
182
+ msg('sigma={0}'.format(sigma), level=4, silent=silent)
183
+ k = min(k, K.shape[0] - 2)
184
+ mu, eigvecs = eigsh(A=KG, k=k, which='SM', M=K, tol=tol, sigma=sigma,
185
+ mode='cayley')
186
+ return _sort_eigenpairs(mu, eigvecs)
187
+
188
+
189
+ def lb(K, KG, tol=0, sparse_solver=True, silent=False,
190
+ num_eigvalues=25, num_eigvalues_print=5,
191
+ skip_null_cols=False, max_dense_size=2000, check_rtol=1e-3):
192
+ r"""Linear buckling analysis
193
+
194
+ Calculates the eigenvalues `\lambda` and eigenvectors `\{u\}` of the
195
+ eigenvalue problem:
196
+
197
+ .. math::
198
+
199
+ ([K] + \lambda [K_G])\{u\} = \{0\}
200
+
201
+ where `\lambda` is the load multiplier of the load state that generated
202
+ `[K_G]`. It can also be used for more general eigenvalue analyses, e.g.
203
+ if ``K`` is the tangent stiffness matrix of a given load state. The null
204
+ rows and columns of ``K`` are removed from both matrices before solving
205
+ the eigenvalue problem, unless ``skip_null_cols=True``.
206
+
207
+ Parameters
208
+ ----------
209
+ K : sparse_matrix
210
+ Stiffness matrix. Should include all constant terms of the initial
211
+ stress stiffness matrix, aerodynamic matrix and so forth when
212
+ applicable.
213
+ KG : sparse_matrix
214
+ Initial stress stiffness matrix that multiplies the load multiplier
215
+ `\lambda` of the eigenvalue problem.
216
+ tol : float, optional
217
+ A float tolerance passed to :func:`scipy.sparse.linalg.eigsh`.
218
+ sparse_solver : bool, optional
219
+ With ``True``, when ``KG`` has at most ``max_dense_size`` non-null
220
+ rows, the other dofs (typically the in-plane dofs) are condensed out
221
+ with a sparse factorization of ``K`` and the condensed problem is
222
+ solved with :func:`scipy.linalg.eigh`. Otherwise, or when this
223
+ solution fails, :func:`scipy.sparse.linalg.eigsh` solves the full
224
+ problem in Cayley mode, with a shift estimated from the matrices.
225
+ The eigenpairs are verified as described in the Notes, and
226
+ ``num_eigvalues`` load multipliers are returned.
227
+ With ``False``, :func:`scipy.linalg.eigh` solves the full problem,
228
+ returning all load multipliers, and requires a positive definite
229
+ ``K``.
230
+ silent : bool, optional
231
+ A boolean to tell whether the log messages should be printed.
232
+ num_eigvalues : int, optional
233
+ Number of load multipliers calculated with the sparse solver, and
234
+ number of returned eigenvectors.
235
+ num_eigvalues_print : int, optional
236
+ Number of eigenvalues to print.
237
+ skip_null_cols : bool, optional
238
+ If True, skip the removal of null columns from the matrices.
239
+ Use only when K is known to be non-singular.
240
+ max_dense_size : int, optional
241
+ Maximum number of non-null rows of ``KG`` for which the sparse solver
242
+ solves the condensed problem with :func:`scipy.linalg.eigh`. Use
243
+ ``0`` to always use :func:`scipy.sparse.linalg.eigsh`.
244
+ check_rtol : float, optional
245
+ Maximum relative residual of the eigenpairs of the sparse solver,
246
+ see the Notes.
247
+
248
+ Returns
249
+ -------
250
+ eigvals : ndarray
251
+ The load multipliers `\lambda`, calculated as ``-1/eigval`` from the
252
+ eigenvalues ``eigval`` of ``KG u = eigval K u``. The positive load
253
+ multipliers come first in increasing order, followed by the negative
254
+ ones, for both solvers.
255
+ eigvecs : ndarray
256
+ The `i^{th}` eigenvector is ``eigvecs[:, i]``, with the size of the
257
+ original matrices. Only ``num_eigvalues`` eigenvectors are returned.
258
+
259
+ Raises
260
+ ------
261
+ RuntimeError
262
+ When no eigenpairs of the sparse solver pass the verification.
263
+
264
+ Notes
265
+ -----
266
+ The eigenpairs of the sparse solver are verified as follows:
267
+
268
+ - the relative residual
269
+ `||K u + \lambda K_G u|| / (||K u|| + |\lambda| ||K_G u||)` of each
270
+ eigenpair must not be larger than ``check_rtol``;
271
+ - when ``K`` is positive definite, `[K] + s [K_G]` must also be positive
272
+ definite for `s` slightly lower than the lowest positive load
273
+ multiplier found, i.e. no lower load multiplier was missed. The
274
+ factorization is done with SuperLU, without row interchanges.
275
+
276
+ When the condensed dense solution fails the verification,
277
+ :func:`scipy.sparse.linalg.eigsh` is tried, and a ``RuntimeError`` is
278
+ raised when it also fails.
279
+
280
+ ARPACK, used by :func:`scipy.sparse.linalg.eigsh`, returns wrong
281
+ eigenpairs at random, without any error, when linked against Intel MKL
282
+ 2024.2.0 to 2025.0.0, e.g. in some Anaconda builds of SciPy: the
283
+ ``dsteqr`` routine of these MKL versions returns wrong eigenvectors for
284
+ matrices larger than 32 x 32. MKL 2025.0.1 or newer is not affected.
285
+
286
+ """
287
+ msg('Running linear buckling analysis...', silent=silent)
288
+
289
+ msg('Eigenvalue solver... ', level=2, silent=silent)
290
+
291
+ size = KG.shape[0]
292
+ if skip_null_cols:
293
+ used_cols = None
294
+ else:
295
+ K, KG, used_cols = remove_null_cols(K, KG, silent=silent)
296
+ if sparse_solver:
297
+ K = csr_matrix(K)
298
+ KG = csr_matrix(KG)
299
+ num_active = int(np.count_nonzero(abs(KG).sum(axis=1)))
300
+ solvers = []
301
+ if num_active <= max_dense_size:
302
+ solvers.append(('eigh() with condensed dofs',
303
+ lambda: _eigh_condensed(K, KG, num_eigvalues)))
304
+ solvers.append(('eigsh()',
305
+ lambda: _eigsh_cayley(K, KG, num_eigvalues, tol,
306
+ silent)))
307
+ errors = []
308
+ for name, solver in solvers:
309
+ msg('{0} solver ({1} of {2} rows of KG are non-null)...'.format(
310
+ name, num_active, K.shape[0]), level=3, silent=silent)
311
+ try:
312
+ eigvals, peigvecs = solver()
313
+ with np.errstate(divide='ignore'):
314
+ error = _check_eigenpairs(K, KG, -1./eigvals, peigvecs,
315
+ check_rtol)
316
+ except (LinAlgError, RuntimeError, ValueError) as e:
317
+ error = '{0}: {1}'.format(type(e).__name__, e)
318
+ if error is None:
319
+ msg('finished!', level=3, silent=silent)
320
+ break
321
+ warn('{0} solver failed: {1}'.format(name, error), level=3,
322
+ silent=silent)
323
+ errors.append('{0} solver: {1}'.format(name, error))
324
+ else:
325
+ raise RuntimeError(
326
+ 'Linear buckling analysis failed, no eigenvalue solver passed '
327
+ 'the verification ({0}). When ARPACK is linked against Intel '
328
+ 'MKL 2024.2.0 to 2025.0.0, update MKL to 2025.0.1 or newer. '
329
+ 'Otherwise, try sparse_solver=False or a larger '
330
+ 'max_dense_size.'.format('; '.join(errors)))
331
+
332
+ else:
333
+ K = K.toarray()
334
+ KG = KG.toarray()
335
+ msg('eigh() solver...', level=3, silent=silent)
336
+ eigvals, peigvecs = eigh(a=KG, b=K)
337
+ msg('finished!', level=3, silent=silent)
338
+
339
+ num_eigvecs = min(num_eigvalues, peigvecs.shape[1])
340
+ if used_cols is not None:
341
+ eigvecs = np.zeros((size, num_eigvecs), dtype=peigvecs.dtype)
342
+ eigvecs[used_cols, :] = peigvecs[:, :num_eigvecs]
343
+ else:
344
+ eigvecs = peigvecs[:, :num_eigvecs]
345
+
346
+ with np.errstate(divide='ignore'):
347
+ eigvals = -1./eigvals
348
+
349
+ msg('finished!', level=2, silent=silent)
350
+
351
+ msg('first {0} eigenvalues:'.format(num_eigvalues_print), level=1,
352
+ silent=silent)
353
+
354
+ for eig in eigvals[:num_eigvalues_print]:
355
+ msg('{0}'.format(eig), level=2, silent=silent)
356
+
357
+ return eigvals, eigvecs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structsolve
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Structural analysis solvers
5
5
  Author-email: "Saullo G. P. Castro" <castrosaullo@gmail.com>
6
6
  License: BSD-2-Clause
@@ -60,7 +60,7 @@ Currently these solvers are pretty much compatible with my other repositories
60
60
  Citing this library
61
61
  ===================
62
62
 
63
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.4.1). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
63
+ Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.4.2). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
64
64
 
65
65
 
66
66
  Documentation
@@ -75,8 +75,18 @@ History
75
75
 
76
76
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
77
77
 
78
- * version 0.4.1 (2026-09-17)
79
- - Fixed the eigenvalue shift of the sparse solver of `lb`, which could return buckling loads higher than the critical ones
78
+ * version 0.4.2 (2026-09-17)
79
+ - Fixed the eigenvalue shift of the sparse solver of `lb`, which could
80
+ return buckling loads higher than the critical ones
81
+ - Fixed wrong buckling loads of the sparse solver of `lb` when SciPy is
82
+ linked against Intel MKL 2024.2.0 to 2025.0.0 (e.g. Anaconda on
83
+ Windows), whose `dsteqr` routine breaks ARPACK
84
+ - `lb` condenses out the dofs where `KG` is null and solves small
85
+ condensed problems with a dense solver
86
+ - `lb` verifies the eigenpairs of the sparse solver, raising an error
87
+ instead of returning wrong results
88
+ - The sparse solver of `lb` returns the load multipliers sorted as the
89
+ dense solver
80
90
  - Newton-Raphson with full Newton iterations and a relative convergence
81
91
  criterion by default, reaching quadratic convergence with exact tangent
82
92
  stiffness matrices
@@ -1,4 +1,6 @@
1
1
  """Tests for structsolve.linear_buckling: lb()"""
2
+ import os
3
+
2
4
  import numpy as np
3
5
  import pytest
4
6
  from scipy.sparse import csc_matrix
@@ -41,14 +43,15 @@ def test_lb_dense_solver():
41
43
  np.testing.assert_allclose(eigvals[:5], -2.0, rtol=1e-6)
42
44
 
43
45
 
44
- def test_lb_sparse_shift_mixed_spectrum():
46
+ @pytest.mark.parametrize('max_dense_size', [0, 2000])
47
+ def test_lb_sparse_shift_mixed_spectrum(max_dense_size):
45
48
  """Sparse and dense lb agree when the load multipliers have mixed signs
46
49
 
47
50
  Regression test: the shift of the sparse solver was a single Rayleigh
48
51
  quotient, in which the positive and negative eigenvalues of
49
52
  ``KG u = mu K u`` cancel. The shift landed below the critical ``|mu|``
50
53
  and eigsh returned the load multipliers near ``1/sigma`` instead of the
51
- lowest ones.
54
+ lowest ones. With ``max_dense_size=0`` eigsh is always used.
52
55
  """
53
56
  from scipy.sparse import diags
54
57
  from structsolve.linear_buckling import _estimate_sigma
@@ -65,13 +68,167 @@ def test_lb_sparse_shift_mixed_spectrum():
65
68
  # the shift must bound the largest |mu| of the critical eigenvalues
66
69
  assert _estimate_sigma(K, KG) >= np.abs(mu).max()
67
70
 
68
- eig_sparse, _ = lb(K, KG, silent=True, num_eigvalues=10)
71
+ eig_sparse, _ = lb(K, KG, silent=True, num_eigvalues=10,
72
+ max_dense_size=max_dense_size)
69
73
  eig_dense, _ = lb(K, KG, silent=True, sparse_solver=False)
70
74
  np.testing.assert_allclose(eig_sparse[:4], [1.0, 1.0, 1.1, 1.2],
71
75
  rtol=1e-8)
72
76
  np.testing.assert_allclose(eig_sparse[:4], eig_dense[:4], rtol=1e-8)
73
77
 
74
78
 
79
+ DATA = os.path.join(os.path.dirname(__file__), 'data')
80
+
81
+ # K and KG of panels models, null rows and columns removed, with the
82
+ # critical load multipliers of the dense solver
83
+ SAVED_MATRICES = [
84
+ ('plate_ssss_Nxx', [157.5386, 226.3470, 232.5623]),
85
+ ('cylinder_5panels', [473.1924, 473.1924, 478.6168]),
86
+ ]
87
+
88
+
89
+ def load_saved_matrices(name):
90
+ from scipy.sparse import load_npz
91
+ K = load_npz(os.path.join(DATA, '%s_K.npz' % name)).tocsr()
92
+ KG = load_npz(os.path.join(DATA, '%s_KG.npz' % name)).tocsr()
93
+ return K, KG
94
+
95
+
96
+ def relative_residuals(K, KG, eigvals, eigvecs):
97
+ Ku = K @ eigvecs
98
+ KGu = KG @ eigvecs
99
+ return (np.linalg.norm(Ku + KGu*eigvals, axis=0)
100
+ / (np.linalg.norm(Ku, axis=0)
101
+ + np.abs(eigvals)*np.linalg.norm(KGu, axis=0)))
102
+
103
+
104
+ @pytest.mark.parametrize('name, ref', SAVED_MATRICES)
105
+ def test_lb_saved_matrices_sparse_solver(name, ref):
106
+ """Regression test: ARPACK linked against Intel MKL 2024.2.0 to 2025.0.0
107
+ returned wrong load multipliers at random, e.g. with the Anaconda builds
108
+ of SciPy. The default sparse solver condenses out the dofs where KG is
109
+ null and verifies the eigenpairs"""
110
+ K, KG = load_saved_matrices(name)
111
+ eig_dense, _ = lb(K, KG, silent=True, sparse_solver=False)
112
+ np.testing.assert_allclose(eig_dense[:3], ref, rtol=1e-5)
113
+ for trial in range(3):
114
+ eigvals, eigvecs = lb(K, KG, silent=True)
115
+ assert eigvals.shape == (25,)
116
+ assert eigvecs.shape == (K.shape[0], 25)
117
+ np.testing.assert_allclose(eigvals, eig_dense[:25], rtol=1e-6)
118
+ assert np.all(relative_residuals(K, KG, eigvals, eigvecs) < 1e-6)
119
+
120
+
121
+ @pytest.mark.parametrize('name, ref', SAVED_MATRICES)
122
+ def test_lb_saved_matrices_eigsh_never_wrong(name, ref):
123
+ """With max_dense_size=0 only eigsh is used, which either returns the
124
+ correct load multipliers or raises a RuntimeError, when the eigenpairs
125
+ returned by ARPACK are wrong"""
126
+ K, KG = load_saved_matrices(name)
127
+ for trial in range(3):
128
+ try:
129
+ eigvals, eigvecs = lb(K, KG, silent=True, max_dense_size=0)
130
+ except RuntimeError as e:
131
+ assert 'verification' in str(e)
132
+ continue
133
+ np.testing.assert_allclose(eigvals[:3], ref, rtol=1e-5)
134
+ assert np.all(eigvals[:-1] <= eigvals[1:])
135
+
136
+
137
+ def test_lb_check_eigenpairs():
138
+ """The verification detects wrong eigenpairs and a missing lowest load
139
+ multiplier"""
140
+ from structsolve.linear_buckling import _check_eigenpairs, _eigh_condensed
141
+
142
+ K, KG = load_saved_matrices('plate_ssss_Nxx')
143
+ mu, eigvecs = _eigh_condensed(K, KG, 10)
144
+ eigvals = -1./mu
145
+ assert _check_eigenpairs(K, KG, eigvals, eigvecs, 1e-3) is None
146
+
147
+ # eigenvectors of other eigenvalues
148
+ error = _check_eigenpairs(K, KG, eigvals, eigvecs[:, ::-1], 1e-3)
149
+ assert 'relative residual' in error
150
+
151
+ # wrong eigenvalues
152
+ error = _check_eigenpairs(K, KG, eigvals*1.01, eigvecs, 1e-3)
153
+ assert 'relative residual' in error
154
+
155
+ # lowest load multiplier missing
156
+ error = _check_eigenpairs(K, KG, eigvals[1:], eigvecs[:, 1:], 1e-3)
157
+ assert 'missing' in error
158
+
159
+ # both equal critical load multipliers missing
160
+ K, KG = load_saved_matrices('cylinder_5panels')
161
+ mu, eigvecs = _eigh_condensed(K, KG, 10)
162
+ eigvals = -1./mu
163
+ assert _check_eigenpairs(K, KG, eigvals, eigvecs, 1e-3) is None
164
+ error = _check_eigenpairs(K, KG, eigvals[2:], eigvecs[:, 2:], 1e-3)
165
+ assert 'missing' in error
166
+
167
+
168
+ def test_lb_sparse_raises_when_verification_fails(monkeypatch):
169
+ """A RuntimeError is raised instead of returning wrong eigenpairs"""
170
+ import structsolve.linear_buckling as linear_buckling
171
+
172
+ K, KG = load_saved_matrices('plate_ssss_Nxx')
173
+
174
+ def wrong_eigsh(K, KG, k, tol, silent):
175
+ rng = np.random.RandomState(0)
176
+ return -rng.rand(k), rng.randn(K.shape[0], k)
177
+
178
+ monkeypatch.setattr(linear_buckling, '_eigsh_cayley', wrong_eigsh)
179
+ with pytest.raises(RuntimeError, match='verification'):
180
+ lb(K, KG, silent=True, max_dense_size=0)
181
+ # the condensed dense solution is used first
182
+ eigvals, _ = lb(K, KG, silent=True)
183
+ np.testing.assert_allclose(eigvals[0], 157.5386, rtol=1e-5)
184
+
185
+
186
+ @pytest.mark.parametrize('max_dense_size', [0, 2000])
187
+ def test_lb_sparse_sorted_as_dense(max_dense_size):
188
+ """The sparse solver returns the positive load multipliers first, in
189
+ increasing order, followed by the negative ones, as the dense solver"""
190
+ from scipy.sparse import diags
191
+
192
+ n = 40
193
+ k = np.linspace(1., 3., n)
194
+ rng = np.random.RandomState(1)
195
+ lambdas = rng.permutation(np.concatenate([np.linspace(1., 20., 5),
196
+ -np.linspace(1., 20., n - 5)]))
197
+ K = diags(k).tocsr()
198
+ KG = diags(-k/lambdas).tocsr()
199
+ eig_sparse, eigvecs = lb(K, KG, silent=True, num_eigvalues=10,
200
+ max_dense_size=max_dense_size)
201
+ eig_dense, _ = lb(K, KG, silent=True, sparse_solver=False)
202
+ np.testing.assert_allclose(eig_sparse, eig_dense[:10], rtol=1e-8)
203
+ np.testing.assert_allclose(eig_sparse[:5], np.linspace(1., 20., 5),
204
+ rtol=1e-8)
205
+ assert np.all(eig_sparse[5:] < 0)
206
+ assert np.all(relative_residuals(K, KG, eig_sparse, eigvecs) < 1e-8)
207
+
208
+
209
+ def test_lb_sparse_null_KG_rows_and_null_cols():
210
+ """Condensed solution with null rows of KG and null rows of K, returning
211
+ eigenvectors with the size of the original matrices"""
212
+ from scipy.sparse import random as sprandom, identity, bmat
213
+
214
+ n = 60
215
+ A = sprandom(n, n, density=0.1, random_state=2)
216
+ K = (A @ A.T + identity(n)).tocsr()
217
+ B = sprandom(n//3, n//3, density=0.3, random_state=3)
218
+ KG = bmat([[-(B @ B.T + 0.1*identity(n//3)), None],
219
+ [None, csc_matrix((n - n//3, n - n//3))]]).tocsr()
220
+ # adding two null dofs
221
+ Z = csc_matrix((2, 2))
222
+ K2 = bmat([[K, None], [None, Z]]).tocsr()
223
+ KG2 = bmat([[KG, None], [None, Z]]).tocsr()
224
+ eig_sparse, eigvecs = lb(K2, KG2, silent=True, num_eigvalues=10)
225
+ eig_dense, _ = lb(K2, KG2, silent=True, sparse_solver=False)
226
+ np.testing.assert_allclose(eig_sparse, eig_dense[:10], rtol=1e-8)
227
+ assert eigvecs.shape == (n + 2, 10)
228
+ assert np.all(eigvecs[n:, :] == 0)
229
+ assert np.all(relative_residuals(K2, KG2, eig_sparse, eigvecs) < 1e-8)
230
+
231
+
75
232
  def test_lb_sparse_singular_K():
76
233
  """The shift falls back to 1 when K is singular"""
77
234
  from structsolve.linear_buckling import _estimate_sigma
@@ -1,163 +0,0 @@
1
- import warnings
2
-
3
- import numpy as np
4
- from scipy.sparse import csc_matrix
5
- from scipy.sparse.linalg import eigsh, splu
6
- from scipy.linalg import eigh
7
-
8
- from .logger import msg, warn
9
- from .sparseutils import remove_null_cols
10
-
11
-
12
- def _estimate_sigma(K, KG, safety=10., max_iter=50, rel_tol=1e-3):
13
- r"""Shift of the Cayley mode used by :func:`lb`
14
-
15
- With ``sigma > 0``, ``eigsh(A=KG, M=K, sigma=sigma, mode='cayley',
16
- which='SM')`` returns the eigenvalues `\mu` of ``KG u = mu K u`` with the
17
- smallest `|(\mu + \sigma)/(\mu - \sigma)|`. This ordering selects the most
18
- negative `\mu`, i.e. the lowest positive load multipliers `-1/\mu`, only
19
- when `\sigma` is larger than `|\mu|` of these critical eigenvalues.
20
- Otherwise the eigenvalues closest to `-\sigma` are returned.
21
-
22
- The largest `|\mu|` is estimated with power iterations on
23
- `[K]^{-1}[K_G]`, whose norm ratio in the `[K]`-norm increases towards the
24
- largest `|\mu|`, and multiplied by ``safety``. The default shift ``1.`` is
25
- returned when ``K`` is singular, not positive definite, or the linear
26
- solution is inaccurate.
27
-
28
- """
29
- try:
30
- with warnings.catch_warnings():
31
- warnings.simplefilter("ignore")
32
- lu = splu(csc_matrix(K))
33
- x = np.random.RandomState(42).randn(K.shape[0])
34
- Kx = K @ x
35
- mu_max = 0.
36
- for i in range(max_iter):
37
- xKx = x @ Kx
38
- if not np.isfinite(xKx) or xKx <= 0:
39
- return 1.
40
- x = x / np.sqrt(xKx)
41
- rhs = KG @ x
42
- y = lu.solve(rhs)
43
- Ky = K @ y
44
- if i == 0:
45
- residual = np.linalg.norm(Ky - rhs)
46
- if not residual <= 1e-6 * np.linalg.norm(rhs):
47
- return 1.
48
- # ||y||_K / ||x||_K, with ||x||_K = 1
49
- ratio = np.sqrt(abs(y @ Ky))
50
- if not np.isfinite(ratio) or ratio == 0:
51
- return 1.
52
- converged = i > 0 and ratio - mu_max <= rel_tol * ratio
53
- mu_max = max(mu_max, ratio)
54
- if converged:
55
- break
56
- x, Kx = y, Ky
57
- return safety * mu_max
58
- except Exception:
59
- return 1.
60
-
61
- def lb(K, KG, tol=0, sparse_solver=True, silent=False,
62
- num_eigvalues=25, num_eigvalues_print=5,
63
- skip_null_cols=False):
64
- r"""Linear buckling analysis
65
-
66
- Calculates the eigenvalues `\lambda` and eigenvectors `\{u\}` of the
67
- eigenvalue problem:
68
-
69
- .. math::
70
-
71
- ([K] + \lambda [K_G])\{u\} = \{0\}
72
-
73
- where `\lambda` is the load multiplier of the load state that generated
74
- `[K_G]`. It can also be used for more general eigenvalue analyses, e.g.
75
- if ``K`` is the tangent stiffness matrix of a given load state. The null
76
- rows and columns of ``K`` are removed from both matrices before solving
77
- the eigenvalue problem, unless ``skip_null_cols=True``.
78
-
79
- Parameters
80
- ----------
81
- K : sparse_matrix
82
- Stiffness matrix. Should include all constant terms of the initial
83
- stress stiffness matrix, aerodynamic matrix and so forth when
84
- applicable.
85
- KG : sparse_matrix
86
- Initial stress stiffness matrix that multiplies the load multiplier
87
- `\lambda` of the eigenvalue problem.
88
- tol : float, optional
89
- A float tolerance passed to the eigenvalue solver.
90
- sparse_solver : bool, optional
91
- Tells if solver :func:`scipy.sparse.linalg.eigsh` (``True``) or
92
- :func:`scipy.linalg.eigh` (``False``) should be used. The sparse
93
- solver uses the Cayley mode with a shift estimated from the matrices
94
- and calculates ``num_eigvalues`` eigenvalues. The dense solver
95
- calculates all eigenvalues and requires a positive definite ``K``.
96
- silent : bool, optional
97
- A boolean to tell whether the log messages should be printed.
98
- num_eigvalues : int, optional
99
- Number of calculated eigenvalues with the sparse solver, limited to
100
- the size of ``KG`` minus 2, and number of returned eigenvectors.
101
- num_eigvalues_print : int, optional
102
- Number of eigenvalues to print.
103
- skip_null_cols : bool, optional
104
- If True, skip the removal of null columns from the matrices.
105
- Use only when K is known to be non-singular.
106
-
107
- Returns
108
- -------
109
- eigvals : ndarray
110
- The load multipliers `\lambda`, calculated as ``-1/eigval`` from the
111
- eigenvalues ``eigval`` of ``KG u = eigval K u``. The dense solver
112
- returns all load multipliers, the positive ones first in increasing
113
- order.
114
- eigvecs : ndarray
115
- The `i^{th}` eigenvector is ``eigvecs[:, i]``, with the size of the
116
- original matrices. Only ``num_eigvalues`` eigenvectors are returned.
117
-
118
- """
119
- msg('Running linear buckling analysis...', silent=silent)
120
-
121
- msg('Eigenvalue solver... ', level=2, silent=silent)
122
-
123
- k = min(num_eigvalues, KG.shape[0]-2)
124
- size = KG.shape[0]
125
- if skip_null_cols:
126
- used_cols = None
127
- else:
128
- K, KG, used_cols = remove_null_cols(K, KG, silent=silent)
129
- if sparse_solver:
130
- mode = 'cayley'
131
- sigma = _estimate_sigma(K, KG)
132
- msg('eigsh() solver (sigma={0})...'.format(sigma), level=3, silent=silent)
133
- eigvals, peigvecs = eigsh(A=KG, k=k,
134
- which='SM', M=K, tol=tol, sigma=sigma, mode=mode)
135
- msg('finished!', level=3, silent=silent)
136
-
137
- else:
138
- K = K.toarray()
139
- KG = KG.toarray()
140
- msg('eigh() solver...', level=3, silent=silent)
141
- eigvals, peigvecs = eigh(a=KG, b=K)
142
- msg('finished!', level=3, silent=silent)
143
-
144
- if used_cols is not None:
145
- eigvecs = np.zeros((size, num_eigvalues), dtype=peigvecs.dtype)
146
- eigvecs[used_cols, :] = peigvecs[:, :num_eigvalues]
147
- else:
148
- eigvecs = peigvecs[:, :num_eigvalues]
149
-
150
- eigvals = -1./eigvals
151
-
152
- eigvals = eigvals
153
- eigvecs = eigvecs
154
-
155
- msg('finished!', level=2, silent=silent)
156
-
157
- msg('first {0} eigenvalues:'.format(num_eigvalues_print), level=1,
158
- silent=silent)
159
-
160
- for eig in eigvals[:num_eigvalues_print]:
161
- msg('{0}'.format(eig), level=2, silent=silent)
162
-
163
- return eigvals, eigvecs
File without changes
File without changes