structsolve 0.4.0__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 (31) hide show
  1. {structsolve-0.4.0/structsolve.egg-info → structsolve-0.4.2}/PKG-INFO +15 -5
  2. {structsolve-0.4.0 → structsolve-0.4.2}/README.md +14 -4
  3. {structsolve-0.4.0 → structsolve-0.4.2}/pyproject.toml +1 -1
  4. structsolve-0.4.2/structsolve/linear_buckling.py +357 -0
  5. {structsolve-0.4.0 → structsolve-0.4.2/structsolve.egg-info}/PKG-INFO +15 -5
  6. structsolve-0.4.2/tests/test_linear_buckling.py +352 -0
  7. structsolve-0.4.0/structsolve/linear_buckling.py +0 -132
  8. structsolve-0.4.0/tests/test_linear_buckling.py +0 -155
  9. {structsolve-0.4.0 → structsolve-0.4.2}/LICENSE +0 -0
  10. {structsolve-0.4.0 → structsolve-0.4.2}/setup.cfg +0 -0
  11. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve/__init__.py +0 -0
  12. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve/analysis.py +0 -0
  13. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve/arc_length.py +0 -0
  14. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve/arc_length_crisfield.py +0 -0
  15. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve/arc_length_riks.py +0 -0
  16. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve/freq.py +0 -0
  17. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve/logger.py +0 -0
  18. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve/newton_raphson.py +0 -0
  19. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve/sparseutils.py +0 -0
  20. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve/static.py +0 -0
  21. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve.egg-info/SOURCES.txt +0 -0
  22. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve.egg-info/dependency_links.txt +0 -0
  23. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve.egg-info/requires.txt +0 -0
  24. {structsolve-0.4.0 → structsolve-0.4.2}/structsolve.egg-info/top_level.txt +0 -0
  25. {structsolve-0.4.0 → structsolve-0.4.2}/tests/test_analysis.py +0 -0
  26. {structsolve-0.4.0 → structsolve-0.4.2}/tests/test_arc_length.py +0 -0
  27. {structsolve-0.4.0 → structsolve-0.4.2}/tests/test_freq.py +0 -0
  28. {structsolve-0.4.0 → structsolve-0.4.2}/tests/test_newton_raphson.py +0 -0
  29. {structsolve-0.4.0 → structsolve-0.4.2}/tests/test_sparseutils.py +0 -0
  30. {structsolve-0.4.0 → structsolve-0.4.2}/tests/test_static.py +0 -0
  31. {structsolve-0.4.0 → 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.0
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
@@ -41,8 +41,7 @@ Github Actions status:
41
41
 
42
42
  Coverage status:
43
43
  [![codecov](https://github.com/saullocastro/structsolve/actions/workflows/coverage.yml/badge.svg)](https://github.com/saullocastro/structsolve/actions/workflows/coverage.yml)
44
- [![Codecov Status](https://codecov.io/gh/saullocastro/structsolve/branch/master/graph/badge.svg)](https://codecov.io/gh/saullocastro/structsolve)
45
-
44
+ [![Codecov Status](https://codecov.io/gh/saullocastro/structsolve/branch/main/graph/badge.svg?token=NNHK0SFZNH)](https://codecov.io/gh/saullocastro/structsolve)
46
45
 
47
46
  Structural analysis solvers tailored for semi-analytical models
48
47
  ===============================================================
@@ -61,7 +60,7 @@ Currently these solvers are pretty much compatible with my other repositories
61
60
  Citing this library
62
61
  ===================
63
62
 
64
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.4.0). 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.
65
64
 
66
65
 
67
66
  Documentation
@@ -76,7 +75,18 @@ History
76
75
 
77
76
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
78
77
 
79
- * version 0.4.0 (2026-09-17)
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
@@ -4,8 +4,7 @@ Github Actions status:
4
4
 
5
5
  Coverage status:
6
6
  [![codecov](https://github.com/saullocastro/structsolve/actions/workflows/coverage.yml/badge.svg)](https://github.com/saullocastro/structsolve/actions/workflows/coverage.yml)
7
- [![Codecov Status](https://codecov.io/gh/saullocastro/structsolve/branch/master/graph/badge.svg)](https://codecov.io/gh/saullocastro/structsolve)
8
-
7
+ [![Codecov Status](https://codecov.io/gh/saullocastro/structsolve/branch/main/graph/badge.svg?token=NNHK0SFZNH)](https://codecov.io/gh/saullocastro/structsolve)
9
8
 
10
9
  Structural analysis solvers tailored for semi-analytical models
11
10
  ===============================================================
@@ -24,7 +23,7 @@ Currently these solvers are pretty much compatible with my other repositories
24
23
  Citing this library
25
24
  ===================
26
25
 
27
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.4.0). 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.
28
27
 
29
28
 
30
29
  Documentation
@@ -39,7 +38,18 @@ History
39
38
 
40
39
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
41
40
 
42
- * version 0.4.0 (2026-09-17)
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.0"
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.0
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
@@ -41,8 +41,7 @@ Github Actions status:
41
41
 
42
42
  Coverage status:
43
43
  [![codecov](https://github.com/saullocastro/structsolve/actions/workflows/coverage.yml/badge.svg)](https://github.com/saullocastro/structsolve/actions/workflows/coverage.yml)
44
- [![Codecov Status](https://codecov.io/gh/saullocastro/structsolve/branch/master/graph/badge.svg)](https://codecov.io/gh/saullocastro/structsolve)
45
-
44
+ [![Codecov Status](https://codecov.io/gh/saullocastro/structsolve/branch/main/graph/badge.svg?token=NNHK0SFZNH)](https://codecov.io/gh/saullocastro/structsolve)
46
45
 
47
46
  Structural analysis solvers tailored for semi-analytical models
48
47
  ===============================================================
@@ -61,7 +60,7 @@ Currently these solvers are pretty much compatible with my other repositories
61
60
  Citing this library
62
61
  ===================
63
62
 
64
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.4.0). 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.
65
64
 
66
65
 
67
66
  Documentation
@@ -76,7 +75,18 @@ History
76
75
 
77
76
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
78
77
 
79
- * version 0.4.0 (2026-09-17)
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
@@ -0,0 +1,352 @@
1
+ """Tests for structsolve.linear_buckling: lb()"""
2
+ import os
3
+
4
+ import numpy as np
5
+ import pytest
6
+ from scipy.sparse import csc_matrix
7
+
8
+ from structsolve import lb
9
+
10
+
11
+ def test_lb_simple_eigenvalue():
12
+ """Test linear buckling with a well-conditioned system
13
+
14
+ lb solves: [K] + lambda[KG] = 0
15
+ For K = diag(2,4,6,...) and KG = I, lambda_i = -K_ii
16
+ lb returns -1/eigvals from eigsh(A=KG, M=K), giving positive load factors.
17
+ """
18
+ n = 10
19
+ k_diag = np.arange(2, 2 * n + 2, 2, dtype=float)
20
+ K = csc_matrix(np.diag(k_diag))
21
+ KG = csc_matrix(np.eye(n))
22
+
23
+ eigvals, eigvecs = lb(K, KG, silent=True, num_eigvalues=5,
24
+ sparse_solver=True)
25
+ # lb returns eigenvalues (load multipliers), verify we get results
26
+ assert len(eigvals) >= 5
27
+ assert eigvecs.shape[0] == n
28
+
29
+
30
+ def test_lb_dense_solver():
31
+ """Test lb with sparse_solver=False
32
+
33
+ lb solves [K] + lambda[KG] = 0, returning -1/eigvals.
34
+ For K=2I, KG=I: eigsh gives eigval=0.5, so lb returns -1/0.5 = -2.
35
+ The dense solver (eigh) with b=K gives eigvals with the same sign convention.
36
+ """
37
+ n = 10
38
+ K = csc_matrix(np.eye(n) * 2.0)
39
+ KG = csc_matrix(np.eye(n))
40
+
41
+ eigvals, eigvecs = lb(K, KG, silent=True, num_eigvalues=5,
42
+ sparse_solver=False)
43
+ np.testing.assert_allclose(eigvals[:5], -2.0, rtol=1e-6)
44
+
45
+
46
+ @pytest.mark.parametrize('max_dense_size', [0, 2000])
47
+ def test_lb_sparse_shift_mixed_spectrum(max_dense_size):
48
+ """Sparse and dense lb agree when the load multipliers have mixed signs
49
+
50
+ Regression test: the shift of the sparse solver was a single Rayleigh
51
+ quotient, in which the positive and negative eigenvalues of
52
+ ``KG u = mu K u`` cancel. The shift landed below the critical ``|mu|``
53
+ and eigsh returned the load multipliers near ``1/sigma`` instead of the
54
+ lowest ones. With ``max_dense_size=0`` eigsh is always used.
55
+ """
56
+ from scipy.sparse import diags
57
+ from structsolve.linear_buckling import _estimate_sigma
58
+
59
+ n = 80
60
+ k = np.linspace(1e6, 5e6, n)
61
+ lambdas = np.concatenate([[1.0, 1.0, 1.1, 1.2],
62
+ np.linspace(1.5, 4., 36),
63
+ -np.linspace(1.5, 4., 40)])
64
+ mu = -1. / lambdas
65
+ K = diags(k).tocsc()
66
+ KG = diags(mu * k).tocsc()
67
+
68
+ # the shift must bound the largest |mu| of the critical eigenvalues
69
+ assert _estimate_sigma(K, KG) >= np.abs(mu).max()
70
+
71
+ eig_sparse, _ = lb(K, KG, silent=True, num_eigvalues=10,
72
+ max_dense_size=max_dense_size)
73
+ eig_dense, _ = lb(K, KG, silent=True, sparse_solver=False)
74
+ np.testing.assert_allclose(eig_sparse[:4], [1.0, 1.0, 1.1, 1.2],
75
+ rtol=1e-8)
76
+ np.testing.assert_allclose(eig_sparse[:4], eig_dense[:4], rtol=1e-8)
77
+
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
+
232
+ def test_lb_sparse_singular_K():
233
+ """The shift falls back to 1 when K is singular"""
234
+ from structsolve.linear_buckling import _estimate_sigma
235
+
236
+ K = csc_matrix(np.diag([1., 2., 0., 4., 5.]))
237
+ KG = csc_matrix(np.eye(5))
238
+ assert _estimate_sigma(K, KG) == 1.
239
+
240
+
241
+ def test_lb_plate_buckling_fsdt():
242
+ """Test plate buckling (FSDT) based on semi-analytical Ritz method.
243
+
244
+ Reference: notebook BucklingPlates-FSDT.ipynb from the buckling repository.
245
+ Uses Legendre polynomials as basis functions with Gauss-Legendre quadrature.
246
+ """
247
+ from scipy.special import roots_legendre
248
+ from composites import isotropic_plate
249
+ from buckling.legendre import vecf, vecfxi
250
+
251
+ m1 = 20
252
+ m2 = 10
253
+ N = 3 * m1 * m2
254
+
255
+ pts1, weights1 = roots_legendre(2 * m1 - 1)
256
+ pts2, weights2 = roots_legendre(2 * m2 - 1)
257
+
258
+ E = 200.e9
259
+ nu = 0.3
260
+
261
+ # BCs: w simply supported, phi free
262
+ wxit1, wxir1, wxit2, wxir2 = 0, 1, 0, 1
263
+ wetat1, wetar1, wetat2, wetar2 = 0, 1, 0, 1
264
+ xit1, xir1, xit2, xir2 = 1, 1, 1, 1
265
+ etat1, etar1, etat2, etar2 = 1, 1, 1, 1
266
+
267
+ a, b, h = 0.3, 0.1, 0.003
268
+ prop = isotropic_plate(thickness=h, E=E, nu=nu)
269
+
270
+ Nxxhat = -100.
271
+
272
+ Swx = np.zeros(N)
273
+ Swy = np.zeros(N)
274
+ Sphix = np.zeros(N)
275
+ Sphiy = np.zeros(N)
276
+ Sphixx = np.zeros(N)
277
+ Sphixy = np.zeros(N)
278
+ Sphiyx = np.zeros(N)
279
+ Sphiyy = np.zeros(N)
280
+
281
+ buff = np.zeros((N, N))
282
+ K = np.zeros((N, N))
283
+ KG = np.zeros((N, N))
284
+
285
+ def addouter(matrix, vec1, vec2):
286
+ np.outer(vec1, vec2, out=buff)
287
+ matrix += buff
288
+
289
+ for xi, wxi in zip(pts1, weights1):
290
+ wP_xi = vecf(m1, xi, wxit1, wxir1, wxit2, wxir2)
291
+ wPx_xi = vecfxi(m1, xi, wxit1, wxir1, wxit2, wxir2)
292
+ P_xi = vecf(m1, xi, xit1, xir1, xit2, xir2)
293
+ Px_xi = vecfxi(m1, xi, xit1, xir1, xit2, xir2)
294
+
295
+ for eta, weta in zip(pts2, weights2):
296
+ wP_eta = vecf(m2, eta, wetat1, wetar1, wetat2, wetar2)
297
+ wPx_eta = vecfxi(m2, eta, wetat1, wetar1, wetat2, wetar2)
298
+ P_eta = vecf(m2, eta, etat1, etar1, etat2, etar2)
299
+ Px_eta = vecfxi(m2, eta, etat1, etar1, etat2, etar2)
300
+
301
+ weight = wxi * weta
302
+
303
+ Pi, Pj = np.meshgrid(P_xi, P_eta, indexing='ij')
304
+ Sphix[m1*m2:2*m1*m2] = (Pi * Pj).flatten()
305
+ Sphiy[2*m1*m2:] = (Pi * Pj).flatten()
306
+
307
+ Pxi, Pj = np.meshgrid(wPx_xi, wP_eta, indexing='ij')
308
+ Swx[:m1*m2] = (Pxi * Pj * (2 / a)).flatten()
309
+
310
+ Pxi, Pj = np.meshgrid(Px_xi, P_eta, indexing='ij')
311
+ Sphixx[m1*m2:2*m1*m2] = (Pxi * Pj * (2 / a)).flatten()
312
+ Sphiyx[2*m1*m2:] = (Pxi * Pj * (2 / a)).flatten()
313
+
314
+ Pi, Pxj = np.meshgrid(wP_xi, wPx_eta, indexing='ij')
315
+ Swy[:m1*m2] = (Pi * Pxj * (2 / b)).flatten()
316
+
317
+ Pi, Pxj = np.meshgrid(P_xi, Px_eta, indexing='ij')
318
+ Sphixy[m1*m2:2*m1*m2] = (Pi * Pxj * (2 / b)).flatten()
319
+ Sphiyy[2*m1*m2:] = (Pi * Pxj * (2 / b)).flatten()
320
+
321
+ e1xx = Sphixx
322
+ e1yy = Sphiyy
323
+ e1xy = Sphixy + Sphiyx
324
+
325
+ g0yz = Sphiy + Swy
326
+ g0xz = Sphix + Swx
327
+
328
+ Mxx = prop.D11 * e1xx + prop.D12 * e1yy + prop.D16 * e1xy
329
+ Myy = prop.D12 * e1xx + prop.D22 * e1yy + prop.D26 * e1xy
330
+ Mxy = prop.D16 * e1xx + prop.D26 * e1yy + prop.D66 * e1xy
331
+
332
+ Qy = prop.A44 * g0yz + prop.A45 * g0xz
333
+ Qx = prop.A45 * g0yz + prop.A55 * g0xz
334
+
335
+ detJ = a * b / 4
336
+
337
+ addouter(K, detJ * weight * Mxx, e1xx)
338
+ addouter(K, detJ * weight * Myy, e1yy)
339
+ addouter(K, detJ * weight * Mxy, e1xy)
340
+ addouter(K, detJ * weight * Qy, g0yz)
341
+ addouter(K, detJ * weight * Qx, g0xz)
342
+
343
+ addouter(KG, detJ * weight * Nxxhat * Swx, Swx)
344
+
345
+ eigvals, eigvecs = lb(csc_matrix(K), csc_matrix(KG), silent=True)
346
+
347
+ D = E * h**3 / (12 * (1 - nu**2))
348
+ Ncr_analytical = 4 * np.pi**2 * D / b**2
349
+ Ncr_computed = eigvals[0] * abs(Nxxhat)
350
+
351
+ # FSDT gives slightly lower Ncr than CLPT due to shear deformation
352
+ np.testing.assert_allclose(Ncr_computed, Ncr_analytical, rtol=0.02)
@@ -1,132 +0,0 @@
1
- import warnings
2
-
3
- import numpy as np
4
- from scipy.sparse.linalg import eigsh, spsolve
5
- from scipy.linalg import eigh
6
-
7
- from .logger import msg, warn
8
- from .sparseutils import remove_null_cols
9
-
10
-
11
- def _estimate_sigma(K, KG):
12
- try:
13
- rhs = KG @ np.random.RandomState(42).randn(K.shape[0])
14
- with warnings.catch_warnings(record=True) as caught:
15
- warnings.simplefilter("always")
16
- y = spsolve(K, rhs)
17
- if any(issubclass(w.category, (RuntimeWarning, Warning))
18
- and "singular" in str(w.message).lower() for w in caught):
19
- return 1.
20
- residual = np.linalg.norm(K @ y - rhs)
21
- if residual > 1e-6 * np.linalg.norm(rhs):
22
- return 1.
23
- sigma = abs((y @ KG @ y) / (y @ K @ y))
24
- if not np.isfinite(sigma) or sigma == 0:
25
- return 1.
26
- return sigma
27
- except Exception:
28
- return 1.
29
-
30
- def lb(K, KG, tol=0, sparse_solver=True, silent=False,
31
- num_eigvalues=25, num_eigvalues_print=5,
32
- skip_null_cols=False):
33
- r"""Linear buckling analysis
34
-
35
- Calculates the eigenvalues `\lambda` and eigenvectors `\{u\}` of the
36
- eigenvalue problem:
37
-
38
- .. math::
39
-
40
- ([K] + \lambda [K_G])\{u\} = \{0\}
41
-
42
- where `\lambda` is the load multiplier of the load state that generated
43
- `[K_G]`. It can also be used for more general eigenvalue analyses, e.g.
44
- if ``K`` is the tangent stiffness matrix of a given load state. The null
45
- rows and columns of ``K`` are removed from both matrices before solving
46
- the eigenvalue problem, unless ``skip_null_cols=True``.
47
-
48
- Parameters
49
- ----------
50
- K : sparse_matrix
51
- Stiffness matrix. Should include all constant terms of the initial
52
- stress stiffness matrix, aerodynamic matrix and so forth when
53
- applicable.
54
- KG : sparse_matrix
55
- Initial stress stiffness matrix that multiplies the load multiplier
56
- `\lambda` of the eigenvalue problem.
57
- tol : float, optional
58
- A float tolerance passed to the eigenvalue solver.
59
- sparse_solver : bool, optional
60
- Tells if solver :func:`scipy.sparse.linalg.eigsh` (``True``) or
61
- :func:`scipy.linalg.eigh` (``False``) should be used. The sparse
62
- solver uses the Cayley mode with a shift estimated from the matrices
63
- and calculates ``num_eigvalues`` eigenvalues. The dense solver
64
- calculates all eigenvalues and requires a positive definite ``K``.
65
- silent : bool, optional
66
- A boolean to tell whether the log messages should be printed.
67
- num_eigvalues : int, optional
68
- Number of calculated eigenvalues with the sparse solver, limited to
69
- the size of ``KG`` minus 2, and number of returned eigenvectors.
70
- num_eigvalues_print : int, optional
71
- Number of eigenvalues to print.
72
- skip_null_cols : bool, optional
73
- If True, skip the removal of null columns from the matrices.
74
- Use only when K is known to be non-singular.
75
-
76
- Returns
77
- -------
78
- eigvals : ndarray
79
- The load multipliers `\lambda`, calculated as ``-1/eigval`` from the
80
- eigenvalues ``eigval`` of ``KG u = eigval K u``. The dense solver
81
- returns all load multipliers, the positive ones first in increasing
82
- order.
83
- eigvecs : ndarray
84
- The `i^{th}` eigenvector is ``eigvecs[:, i]``, with the size of the
85
- original matrices. Only ``num_eigvalues`` eigenvectors are returned.
86
-
87
- """
88
- msg('Running linear buckling analysis...', silent=silent)
89
-
90
- msg('Eigenvalue solver... ', level=2, silent=silent)
91
-
92
- k = min(num_eigvalues, KG.shape[0]-2)
93
- size = KG.shape[0]
94
- if skip_null_cols:
95
- used_cols = None
96
- else:
97
- K, KG, used_cols = remove_null_cols(K, KG, silent=silent)
98
- if sparse_solver:
99
- mode = 'cayley'
100
- sigma = _estimate_sigma(K, KG)
101
- msg('eigsh() solver (sigma={0})...'.format(sigma), level=3, silent=silent)
102
- eigvals, peigvecs = eigsh(A=KG, k=k,
103
- which='SM', M=K, tol=tol, sigma=sigma, mode=mode)
104
- msg('finished!', level=3, silent=silent)
105
-
106
- else:
107
- K = K.toarray()
108
- KG = KG.toarray()
109
- msg('eigh() solver...', level=3, silent=silent)
110
- eigvals, peigvecs = eigh(a=KG, b=K)
111
- msg('finished!', level=3, silent=silent)
112
-
113
- if used_cols is not None:
114
- eigvecs = np.zeros((size, num_eigvalues), dtype=peigvecs.dtype)
115
- eigvecs[used_cols, :] = peigvecs[:, :num_eigvalues]
116
- else:
117
- eigvecs = peigvecs[:, :num_eigvalues]
118
-
119
- eigvals = -1./eigvals
120
-
121
- eigvals = eigvals
122
- eigvecs = eigvecs
123
-
124
- msg('finished!', level=2, silent=silent)
125
-
126
- msg('first {0} eigenvalues:'.format(num_eigvalues_print), level=1,
127
- silent=silent)
128
-
129
- for eig in eigvals[:num_eigvalues_print]:
130
- msg('{0}'.format(eig), level=2, silent=silent)
131
-
132
- return eigvals, eigvecs
@@ -1,155 +0,0 @@
1
- """Tests for structsolve.linear_buckling: lb()"""
2
- import numpy as np
3
- import pytest
4
- from scipy.sparse import csc_matrix
5
-
6
- from structsolve import lb
7
-
8
-
9
- def test_lb_simple_eigenvalue():
10
- """Test linear buckling with a well-conditioned system
11
-
12
- lb solves: [K] + lambda[KG] = 0
13
- For K = diag(2,4,6,...) and KG = I, lambda_i = -K_ii
14
- lb returns -1/eigvals from eigsh(A=KG, M=K), giving positive load factors.
15
- """
16
- n = 10
17
- k_diag = np.arange(2, 2 * n + 2, 2, dtype=float)
18
- K = csc_matrix(np.diag(k_diag))
19
- KG = csc_matrix(np.eye(n))
20
-
21
- eigvals, eigvecs = lb(K, KG, silent=True, num_eigvalues=5,
22
- sparse_solver=True)
23
- # lb returns eigenvalues (load multipliers), verify we get results
24
- assert len(eigvals) >= 5
25
- assert eigvecs.shape[0] == n
26
-
27
-
28
- def test_lb_dense_solver():
29
- """Test lb with sparse_solver=False
30
-
31
- lb solves [K] + lambda[KG] = 0, returning -1/eigvals.
32
- For K=2I, KG=I: eigsh gives eigval=0.5, so lb returns -1/0.5 = -2.
33
- The dense solver (eigh) with b=K gives eigvals with the same sign convention.
34
- """
35
- n = 10
36
- K = csc_matrix(np.eye(n) * 2.0)
37
- KG = csc_matrix(np.eye(n))
38
-
39
- eigvals, eigvecs = lb(K, KG, silent=True, num_eigvalues=5,
40
- sparse_solver=False)
41
- np.testing.assert_allclose(eigvals[:5], -2.0, rtol=1e-6)
42
-
43
-
44
- def test_lb_plate_buckling_fsdt():
45
- """Test plate buckling (FSDT) based on semi-analytical Ritz method.
46
-
47
- Reference: notebook BucklingPlates-FSDT.ipynb from the buckling repository.
48
- Uses Legendre polynomials as basis functions with Gauss-Legendre quadrature.
49
- """
50
- from scipy.special import roots_legendre
51
- from composites import isotropic_plate
52
- from buckling.legendre import vecf, vecfxi
53
-
54
- m1 = 20
55
- m2 = 10
56
- N = 3 * m1 * m2
57
-
58
- pts1, weights1 = roots_legendre(2 * m1 - 1)
59
- pts2, weights2 = roots_legendre(2 * m2 - 1)
60
-
61
- E = 200.e9
62
- nu = 0.3
63
-
64
- # BCs: w simply supported, phi free
65
- wxit1, wxir1, wxit2, wxir2 = 0, 1, 0, 1
66
- wetat1, wetar1, wetat2, wetar2 = 0, 1, 0, 1
67
- xit1, xir1, xit2, xir2 = 1, 1, 1, 1
68
- etat1, etar1, etat2, etar2 = 1, 1, 1, 1
69
-
70
- a, b, h = 0.3, 0.1, 0.003
71
- prop = isotropic_plate(thickness=h, E=E, nu=nu)
72
-
73
- Nxxhat = -100.
74
-
75
- Swx = np.zeros(N)
76
- Swy = np.zeros(N)
77
- Sphix = np.zeros(N)
78
- Sphiy = np.zeros(N)
79
- Sphixx = np.zeros(N)
80
- Sphixy = np.zeros(N)
81
- Sphiyx = np.zeros(N)
82
- Sphiyy = np.zeros(N)
83
-
84
- buff = np.zeros((N, N))
85
- K = np.zeros((N, N))
86
- KG = np.zeros((N, N))
87
-
88
- def addouter(matrix, vec1, vec2):
89
- np.outer(vec1, vec2, out=buff)
90
- matrix += buff
91
-
92
- for xi, wxi in zip(pts1, weights1):
93
- wP_xi = vecf(m1, xi, wxit1, wxir1, wxit2, wxir2)
94
- wPx_xi = vecfxi(m1, xi, wxit1, wxir1, wxit2, wxir2)
95
- P_xi = vecf(m1, xi, xit1, xir1, xit2, xir2)
96
- Px_xi = vecfxi(m1, xi, xit1, xir1, xit2, xir2)
97
-
98
- for eta, weta in zip(pts2, weights2):
99
- wP_eta = vecf(m2, eta, wetat1, wetar1, wetat2, wetar2)
100
- wPx_eta = vecfxi(m2, eta, wetat1, wetar1, wetat2, wetar2)
101
- P_eta = vecf(m2, eta, etat1, etar1, etat2, etar2)
102
- Px_eta = vecfxi(m2, eta, etat1, etar1, etat2, etar2)
103
-
104
- weight = wxi * weta
105
-
106
- Pi, Pj = np.meshgrid(P_xi, P_eta, indexing='ij')
107
- Sphix[m1*m2:2*m1*m2] = (Pi * Pj).flatten()
108
- Sphiy[2*m1*m2:] = (Pi * Pj).flatten()
109
-
110
- Pxi, Pj = np.meshgrid(wPx_xi, wP_eta, indexing='ij')
111
- Swx[:m1*m2] = (Pxi * Pj * (2 / a)).flatten()
112
-
113
- Pxi, Pj = np.meshgrid(Px_xi, P_eta, indexing='ij')
114
- Sphixx[m1*m2:2*m1*m2] = (Pxi * Pj * (2 / a)).flatten()
115
- Sphiyx[2*m1*m2:] = (Pxi * Pj * (2 / a)).flatten()
116
-
117
- Pi, Pxj = np.meshgrid(wP_xi, wPx_eta, indexing='ij')
118
- Swy[:m1*m2] = (Pi * Pxj * (2 / b)).flatten()
119
-
120
- Pi, Pxj = np.meshgrid(P_xi, Px_eta, indexing='ij')
121
- Sphixy[m1*m2:2*m1*m2] = (Pi * Pxj * (2 / b)).flatten()
122
- Sphiyy[2*m1*m2:] = (Pi * Pxj * (2 / b)).flatten()
123
-
124
- e1xx = Sphixx
125
- e1yy = Sphiyy
126
- e1xy = Sphixy + Sphiyx
127
-
128
- g0yz = Sphiy + Swy
129
- g0xz = Sphix + Swx
130
-
131
- Mxx = prop.D11 * e1xx + prop.D12 * e1yy + prop.D16 * e1xy
132
- Myy = prop.D12 * e1xx + prop.D22 * e1yy + prop.D26 * e1xy
133
- Mxy = prop.D16 * e1xx + prop.D26 * e1yy + prop.D66 * e1xy
134
-
135
- Qy = prop.A44 * g0yz + prop.A45 * g0xz
136
- Qx = prop.A45 * g0yz + prop.A55 * g0xz
137
-
138
- detJ = a * b / 4
139
-
140
- addouter(K, detJ * weight * Mxx, e1xx)
141
- addouter(K, detJ * weight * Myy, e1yy)
142
- addouter(K, detJ * weight * Mxy, e1xy)
143
- addouter(K, detJ * weight * Qy, g0yz)
144
- addouter(K, detJ * weight * Qx, g0xz)
145
-
146
- addouter(KG, detJ * weight * Nxxhat * Swx, Swx)
147
-
148
- eigvals, eigvecs = lb(csc_matrix(K), csc_matrix(KG), silent=True)
149
-
150
- D = E * h**3 / (12 * (1 - nu**2))
151
- Ncr_analytical = 4 * np.pi**2 * D / b**2
152
- Ncr_computed = eigvals[0] * abs(Nxxhat)
153
-
154
- # FSDT gives slightly lower Ncr than CLPT due to shear deformation
155
- np.testing.assert_allclose(Ncr_computed, Ncr_analytical, rtol=0.02)
File without changes
File without changes