structsolve 0.5.0__tar.gz → 0.5.3__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 (33) hide show
  1. {structsolve-0.5.0/structsolve.egg-info → structsolve-0.5.3}/PKG-INFO +13 -4
  2. {structsolve-0.5.0 → structsolve-0.5.3}/README.md +12 -3
  3. {structsolve-0.5.0 → structsolve-0.5.3}/pyproject.toml +1 -1
  4. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/freq.py +6 -2
  5. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/linear_buckling.py +112 -34
  6. {structsolve-0.5.0 → structsolve-0.5.3/structsolve.egg-info}/PKG-INFO +13 -4
  7. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve.egg-info/SOURCES.txt +1 -0
  8. {structsolve-0.5.0 → structsolve-0.5.3}/tests/test_follower_loads.py +7 -1
  9. structsolve-0.5.3/tests/test_linear_buckling_unsymmetric.py +241 -0
  10. {structsolve-0.5.0 → structsolve-0.5.3}/LICENSE +0 -0
  11. {structsolve-0.5.0 → structsolve-0.5.3}/setup.cfg +0 -0
  12. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/__init__.py +0 -0
  13. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/analysis.py +0 -0
  14. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/arc_length.py +0 -0
  15. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/arc_length_crisfield.py +0 -0
  16. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/arc_length_riks.py +0 -0
  17. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/callbacks.py +0 -0
  18. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/logger.py +0 -0
  19. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/newton_raphson.py +0 -0
  20. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/sparseutils.py +0 -0
  21. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve/static.py +0 -0
  22. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve.egg-info/dependency_links.txt +0 -0
  23. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve.egg-info/requires.txt +0 -0
  24. {structsolve-0.5.0 → structsolve-0.5.3}/structsolve.egg-info/top_level.txt +0 -0
  25. {structsolve-0.5.0 → structsolve-0.5.3}/tests/test_analysis.py +0 -0
  26. {structsolve-0.5.0 → structsolve-0.5.3}/tests/test_arc_length.py +0 -0
  27. {structsolve-0.5.0 → structsolve-0.5.3}/tests/test_follower_freq_static.py +0 -0
  28. {structsolve-0.5.0 → structsolve-0.5.3}/tests/test_freq.py +0 -0
  29. {structsolve-0.5.0 → structsolve-0.5.3}/tests/test_linear_buckling.py +0 -0
  30. {structsolve-0.5.0 → structsolve-0.5.3}/tests/test_newton_raphson.py +0 -0
  31. {structsolve-0.5.0 → structsolve-0.5.3}/tests/test_sparseutils.py +0 -0
  32. {structsolve-0.5.0 → structsolve-0.5.3}/tests/test_static.py +0 -0
  33. {structsolve-0.5.0 → structsolve-0.5.3}/tests/test_static_deflection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structsolve
3
- Version: 0.5.0
3
+ Version: 0.5.3
4
4
  Summary: Structural analysis solvers
5
5
  Author-email: "Saullo G. P. Castro" <castrosaullo@gmail.com>
6
6
  License: BSD-2-Clause
@@ -61,7 +61,7 @@ Currently these solvers are pretty much compatible with my other repositories
61
61
  Citing this library
62
62
  ===================
63
63
 
64
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.5.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
64
+ Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.5.3). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
65
65
 
66
66
 
67
67
  Documentation
@@ -76,8 +76,17 @@ History
76
76
 
77
77
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
78
78
 
79
- * version 0.5.0 (2026-10-01)
80
- - Configuration-dependent loads and unsymmetric eigenproblems
79
+ * version 0.5.3 (2026-10-06)
80
+ - Configuration-dependent loads, e.g. follower pressures: the load factor
81
+ is passed to the callables that accept `inc`, and the arc-length
82
+ methods use the load vector of the current configuration
83
+ - Unsymmetric matrices in `lb` (automatic detection), `freq` (residual
84
+ check, optional symmetric solvers, kinetic criterion) and `solve`
85
+ - The unsymmetric solvers of `lb` and `freq` use the complex LAPACK
86
+ drivers of `scipy.linalg.eig`, avoiding crashes of the Python process
87
+ with Intel MKL 2025.0.0
88
+ - Linear static analysis with configuration-dependent loads in
89
+ `Analysis.static(NLgeom=False)`
81
90
  * version 0.4.3 (2026-09-17)
82
91
  - Fixed the eigenvalue shift of the sparse solver of `lb`, which could
83
92
  return buckling loads higher than the critical ones
@@ -24,7 +24,7 @@ Currently these solvers are pretty much compatible with my other repositories
24
24
  Citing this library
25
25
  ===================
26
26
 
27
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.5.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
27
+ Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.5.3). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
28
28
 
29
29
 
30
30
  Documentation
@@ -39,8 +39,17 @@ History
39
39
 
40
40
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
41
41
 
42
- * version 0.5.0 (2026-10-01)
43
- - Configuration-dependent loads and unsymmetric eigenproblems
42
+ * version 0.5.3 (2026-10-06)
43
+ - Configuration-dependent loads, e.g. follower pressures: the load factor
44
+ is passed to the callables that accept `inc`, and the arc-length
45
+ methods use the load vector of the current configuration
46
+ - Unsymmetric matrices in `lb` (automatic detection), `freq` (residual
47
+ check, optional symmetric solvers, kinetic criterion) and `solve`
48
+ - The unsymmetric solvers of `lb` and `freq` use the complex LAPACK
49
+ drivers of `scipy.linalg.eig`, avoiding crashes of the Python process
50
+ with Intel MKL 2025.0.0
51
+ - Linear static analysis with configuration-dependent loads in
52
+ `Analysis.static(NLgeom=False)`
44
53
  * version 0.4.3 (2026-09-17)
45
54
  - Fixed the eigenvalue shift of the sparse solver of `lb`, which could
46
55
  return buckling loads higher than the critical ones
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "structsolve"
7
- version = "0.5.0"
7
+ version = "0.5.3"
8
8
  description = "Structural analysis solvers"
9
9
  readme = "README.md"
10
10
  license = {text = "BSD-2-Clause"}
@@ -4,7 +4,7 @@ import numpy as np
4
4
  import scipy
5
5
  from scipy.sparse import csr_matrix
6
6
  from scipy.sparse.linalg import eigs, eigsh, spsolve
7
- from scipy.linalg import eig, eigh
7
+ from scipy.linalg import eigh
8
8
 
9
9
  from .logger import msg, warn
10
10
  from .sparseutils import remove_null_cols
@@ -193,8 +193,12 @@ def freq(K, M, tol=0, sparse_solver=True,
193
193
  # for effiency reasons, solving:
194
194
  # [M]{u} = (-1/lambda2)[K]{u}
195
195
  # [M]{u} = eigval [K]{u}
196
+ #NOTE complex matrices to call zggev instead of dggev, which crashes
197
+ # the process for some matrices with Intel MKL 2025.0.0, see
198
+ # linear_buckling._eig_complex
199
+ from .linear_buckling import _eig_complex
196
200
  msg('eig() solver...', level=3, silent=silent)
197
- eigvals, peigvecs = eig(a=Meff, b=Keff)
201
+ eigvals, peigvecs = _eig_complex(a=Meff, b=Keff)
198
202
  lambda2 = -1./eigvals
199
203
 
200
204
  if check_rtol is not None and peigvecs.shape[1]:
@@ -82,17 +82,34 @@ def _is_positive_definite(A):
82
82
  return bool(np.all(lu.U.diagonal() > 0))
83
83
 
84
84
 
85
+ def _lu_solve(lu, b):
86
+ """``lu.solve(b)`` for a real or complex right-hand side ``b``
87
+
88
+ The SuperLU factorization of a real matrix rejects a complex ``b``, whose
89
+ real and imaginary parts are then solved separately.
90
+
91
+ """
92
+ b = np.asarray(b)
93
+ if np.iscomplexobj(b):
94
+ return (lu.solve(np.ascontiguousarray(b.real))
95
+ + 1j*lu.solve(np.ascontiguousarray(b.imag)))
96
+ return lu.solve(b)
97
+
98
+
85
99
  def _check_eigenpairs(K, KG, eigvals, eigvecs, rtol, min_rel_gap=1e-4,
86
100
  check_inertia=True):
87
101
  r"""Verify the eigenpairs of `([K] + \lambda [K_G])\{u\} = \{0\}`
88
102
 
103
+ The eigenpairs may be real or complex.
104
+
89
105
  - The relative residual
90
106
  `||K u + \lambda K_G u|| / (||K u|| + |\lambda| ||K_G u||)` of every
91
107
  eigenpair with a finite `\lambda` must not be larger than ``rtol``.
92
108
  - No load multiplier is missing below the lowest positive `\lambda_1`
93
109
  of ``eigvals``: when ``K`` is positive definite, `[K] + s [K_G]` with
94
110
  `s = \lambda_1 (1 - min\_rel\_gap)` must also be positive definite.
95
- Only for symmetric matrices, ``check_inertia=True``.
111
+ Only for symmetric matrices, ``check_inertia=True``, the complex
112
+ `\lambda` are ignored.
96
113
 
97
114
  Returns
98
115
  -------
@@ -100,6 +117,7 @@ def _check_eigenpairs(K, KG, eigvals, eigvecs, rtol, min_rel_gap=1e-4,
100
117
  Description of the failed check, ``None`` when all checks passed.
101
118
 
102
119
  """
120
+ eigvals = np.asarray(eigvals)
103
121
  finite = np.isfinite(eigvals)
104
122
  if not np.any(finite):
105
123
  return 'no finite load multiplier was found'
@@ -117,6 +135,7 @@ def _check_eigenpairs(K, KG, eigvals, eigvecs, rtol, min_rel_gap=1e-4,
117
135
  '{2}'.format(residual[i], rtol, lam[i]))
118
136
  if not check_inertia:
119
137
  return None
138
+ lam = lam[np.isreal(lam)].real
120
139
  positive = lam[lam > 0]
121
140
  if positive.size and _is_positive_definite(K):
122
141
  s = positive.min()*(1 - min_rel_gap)
@@ -200,20 +219,21 @@ def _real_if_close(mu, eigvecs, rtol=1e-8):
200
219
 
201
220
  Each eigenvector of an eigenvalue whose imaginary part is below ``rtol``
202
221
  of its modulus is rotated in the complex plane to make its largest
203
- component real. When all eigenpairs are real, real arrays are returned.
222
+ component real, and its imaginary part is dropped, as the imaginary part
223
+ of the eigenvalue. Complex arrays are returned, see
224
+ :func:`_general_eigenpairs`.
204
225
 
205
226
  """
206
- mu = np.asarray(mu, dtype=complex)
207
- eigvecs = np.asarray(eigvecs, dtype=complex)
227
+ mu = np.array(mu, dtype=complex)
228
+ eigvecs = np.array(eigvecs, dtype=complex)
208
229
  real = np.abs(mu.imag) <= rtol*np.abs(mu)
209
- mu = np.where(real, mu.real, mu)
230
+ mu[real] = mu[real].real
210
231
  for i in np.flatnonzero(real):
211
232
  v = eigvecs[:, i]
212
233
  j = np.argmax(np.abs(v))
213
234
  if v[j] != 0:
214
- eigvecs[:, i] = v*(abs(v[j])/v[j])
215
- if np.all(real):
216
- return mu.real, eigvecs.real
235
+ v = v*(abs(v[j])/v[j])
236
+ eigvecs[:, i] = v.real
217
237
  return mu, eigvecs
218
238
 
219
239
 
@@ -230,12 +250,61 @@ def _sort_eigenpairs_general(mu, eigvecs):
230
250
  return mu[order], eigvecs[:, order]
231
251
 
232
252
 
253
+ def _general_eigenpairs(mu, eigvecs, k=None):
254
+ r"""The first ``k`` finite eigenpairs of a general eigenvalue solver
255
+
256
+ The eigenvalues with a negligible imaginary part are made real with
257
+ :func:`_real_if_close`, the finite eigenpairs are sorted with
258
+ :func:`_sort_eigenpairs_general` and the first ``k`` are kept, all of
259
+ them with ``k=None``. Real arrays are returned when the kept eigenpairs
260
+ are real, even when discarded ones, e.g. spurious eigenvalues far from
261
+ the critical ones, are complex.
262
+
263
+ """
264
+ mu = np.asarray(mu)
265
+ finite = np.isfinite(mu)
266
+ mu, eigvecs = _real_if_close(mu[finite], eigvecs[:, finite])
267
+ mu, eigvecs = _sort_eigenpairs_general(mu, eigvecs)
268
+ if k is not None:
269
+ mu = mu[:k]
270
+ eigvecs = eigvecs[:, :k]
271
+ if np.all(mu.imag == 0):
272
+ return mu.real, eigvecs.real
273
+ return mu, eigvecs
274
+
275
+
276
+ def _eig_complex(a, b=None):
277
+ r"""All eigenpairs of `[a]\{v\} = w [b]\{v\}` with :func:`scipy.linalg.eig`
278
+
279
+ The matrices are converted to complex, such that LAPACK ``zgeev`` or
280
+ ``zggev`` is used instead of ``dgeev`` or ``dggev``. With Intel MKL
281
+ 2025.0.0 the real drivers crash the Python process when computing the
282
+ eigenvectors of some matrices, with an access violation or a heap
283
+ corruption that cannot be caught, deterministically for a given matrix,
284
+ already for 48 x 48 matrices and with any number of threads. The complex
285
+ drivers are not affected, at about twice the cost of the real ones. The
286
+ complex eigenvectors of real eigenvalues are made real by
287
+ :func:`_general_eigenpairs`.
288
+
289
+ """
290
+ a = np.asarray(a, dtype=complex)
291
+ if b is not None:
292
+ b = np.asarray(b, dtype=complex)
293
+ return eig(a=a, b=b)
294
+
295
+
233
296
  def _eig_condensed(K, KG, k):
234
297
  r"""As :func:`_eigh_condensed`, for unsymmetric matrices
235
298
 
236
299
  The dofs where both the row and the column of ``KG`` are null are
237
300
  condensed out with an LU factorization of ``K``, and the condensed
238
- problem is solved with :func:`scipy.linalg.eig`.
301
+ problem is solved with :func:`scipy.linalg.eig`, through the complex
302
+ LAPACK driver ``zggev``, see :func:`_eig_complex`. Neither converting to
303
+ the standard problem `[S]^{-1}[K_{G_{bb}}]` (``dgeev``) nor limiting the
304
+ threads of BLAS/LAPACK avoids the crash of ``dggev`` with Intel MKL
305
+ 2025.0.0. The ``k`` returned eigenpairs, see :func:`_general_eigenpairs`,
306
+ are real when they are all real, and complex eigenvectors are
307
+ back-substituted with the real factorization of `[K_{aa}]`.
239
308
 
240
309
  """
241
310
  KG = csr_matrix(KG)
@@ -259,17 +328,12 @@ def _eig_condensed(K, KG, k):
259
328
  j1 = min(j0 + chunk, b.size)
260
329
  X = lu.solve(Kab[:, j0:j1].toarray())
261
330
  S[:, j0:j1] -= Kba @ X
262
- mu, ub = eig(a=KGbb, b=S)
263
- finite = np.isfinite(mu)
264
- mu, ub = _real_if_close(mu[finite], ub[:, finite])
265
- mu, ub = _sort_eigenpairs_general(mu, ub)
266
- k = min(k, mu.shape[0])
267
- mu = mu[:k]
268
- ub = ub[:, :k]
269
- eigvecs = np.zeros((K.shape[0], k), dtype=ub.dtype)
331
+ mu, ub = _eig_complex(a=KGbb, b=S)
332
+ mu, ub = _general_eigenpairs(mu, ub, k)
333
+ eigvecs = np.zeros((K.shape[0], mu.shape[0]), dtype=ub.dtype)
270
334
  eigvecs[b, :] = ub
271
335
  if a.size:
272
- eigvecs[a, :] = -lu.solve(np.asarray(Kab @ ub))
336
+ eigvecs[a, :] = -_lu_solve(lu, np.asarray(Kab @ ub))
273
337
  return mu, eigvecs
274
338
 
275
339
 
@@ -278,20 +342,21 @@ def _eigs_inverse(K, KG, k, tol):
278
342
 
279
343
  The largest `|\mu|` of `[K]^{-1}[K_G] u = \mu u` are the load
280
344
  multipliers `\lambda = -1/\mu` of smallest modulus. ``K`` may be
281
- unsymmetric, it is factorized once with SuperLU.
345
+ unsymmetric, it is factorized once with SuperLU. The operator is real
346
+ for real matrices, a complex vector is solved with :func:`_lu_solve`.
282
347
 
283
348
  """
349
+ K = csc_matrix(K)
284
350
  with warnings.catch_warnings():
285
351
  warnings.simplefilter("ignore")
286
- lu = splu(csc_matrix(K))
352
+ lu = splu(K)
287
353
  KG = csr_matrix(KG)
288
354
  n = K.shape[0]
289
- OP = LinearOperator((n, n), matvec=lambda x: lu.solve(KG @ x),
290
- dtype=np.result_type(lu.U.dtype, KG.dtype))
355
+ OP = LinearOperator((n, n), matvec=lambda x: _lu_solve(lu, KG @ x),
356
+ dtype=np.result_type(K.dtype, KG.dtype))
291
357
  k = min(k, n - 2)
292
358
  mu, eigvecs = eigs(OP, k=k, which='LM', tol=tol)
293
- mu, eigvecs = _real_if_close(mu, eigvecs)
294
- return _sort_eigenpairs_general(mu, eigvecs)
359
+ return _general_eigenpairs(mu, eigvecs)
295
360
 
296
361
 
297
362
  def _eigsh_cayley(K, KG, k, tol, silent):
@@ -418,7 +483,9 @@ def lb(K, KG, tol=0, sparse_solver=True, silent=False,
418
483
  The relative residual of each eigenpair is verified as above, but not
419
484
  the inertia, which requires symmetric matrices. Eigenvalues whose
420
485
  imaginary part is below ``1e-8`` of their modulus are returned as real,
421
- with real eigenvectors.
486
+ with real eigenvectors. The arrays are real when the returned eigenpairs
487
+ are real, even if other eigenvalues of the solver are complex. A solver
488
+ that raises an error is skipped, as one that fails the verification.
422
489
 
423
490
  ARPACK, used by :func:`scipy.sparse.linalg.eigsh`, returns wrong
424
491
  eigenpairs at random, without any error, when linked against Intel MKL
@@ -426,6 +493,13 @@ def lb(K, KG, tol=0, sparse_solver=True, silent=False,
426
493
  ``dsteqr`` routine of these MKL versions returns wrong eigenvectors for
427
494
  matrices larger than 32 x 32. MKL 2025.0.1 or newer is not affected.
428
495
 
496
+ With Intel MKL 2025.0.0, the real LAPACK drivers ``dgeev`` and ``dggev``
497
+ of :func:`scipy.linalg.eig` crash the Python process for some matrices
498
+ when computing the eigenvectors, with any number of threads. The
499
+ solvers of unsymmetric matrices therefore call :func:`scipy.linalg.eig`
500
+ with complex matrices, i.e. ``zgeev`` and ``zggev``, which are not
501
+ affected.
502
+
429
503
  """
430
504
  msg('Running linear buckling analysis...', silent=silent)
431
505
 
@@ -461,10 +535,10 @@ def lb(K, KG, tol=0, sparse_solver=True, silent=False,
461
535
  name, num_active, K.shape[0]), level=3, silent=silent)
462
536
  try:
463
537
  eigvals, peigvecs = solver()
464
- with np.errstate(divide='ignore'):
538
+ with np.errstate(divide='ignore', invalid='ignore'):
465
539
  error = _check_eigenpairs(K, KG, -1./eigvals, peigvecs,
466
540
  check_rtol)
467
- except (LinAlgError, RuntimeError, ValueError) as e:
541
+ except (LinAlgError, RuntimeError, ValueError, TypeError) as e:
468
542
  error = '{0}: {1}'.format(type(e).__name__, e)
469
543
  if error is None:
470
544
  msg('finished!', level=3, silent=silent)
@@ -488,13 +562,18 @@ def lb(K, KG, tol=0, sparse_solver=True, silent=False,
488
562
  msg('finished!', level=3, silent=silent)
489
563
 
490
564
  num_eigvecs = min(num_eigvalues, peigvecs.shape[1])
565
+ if (np.iscomplexobj(peigvecs)
566
+ and np.all(np.imag(eigvals[:num_eigvecs]) == 0)):
567
+ # all eigenvalues are returned by eig(), only the returned
568
+ # eigenvectors must be real
569
+ peigvecs = peigvecs[:, :num_eigvecs].real
491
570
  if used_cols is not None:
492
571
  eigvecs = np.zeros((size, num_eigvecs), dtype=peigvecs.dtype)
493
572
  eigvecs[used_cols, :] = peigvecs[:, :num_eigvecs]
494
573
  else:
495
574
  eigvecs = peigvecs[:, :num_eigvecs]
496
575
 
497
- with np.errstate(divide='ignore'):
576
+ with np.errstate(divide='ignore', invalid='ignore'):
498
577
  eigvals = -1./eigvals
499
578
 
500
579
  msg('finished!', level=2, silent=silent)
@@ -514,10 +593,9 @@ def _lb_unsymmetric(K, KG, tol, sparse_solver, silent, num_eigvalues,
514
593
  the Notes of :func:`lb`"""
515
594
  if not sparse_solver:
516
595
  msg('eig() solver...', level=3, silent=silent)
517
- mu, eigvecs = eig(a=csr_matrix(KG).toarray(), b=csr_matrix(K).toarray())
518
- finite = np.isfinite(mu)
519
- mu, eigvecs = _real_if_close(mu[finite], eigvecs[:, finite])
520
- mu, eigvecs = _sort_eigenpairs_general(mu, eigvecs)
596
+ mu, eigvecs = _eig_complex(a=csr_matrix(KG).toarray(),
597
+ b=csr_matrix(K).toarray())
598
+ mu, eigvecs = _general_eigenpairs(mu, eigvecs)
521
599
  msg('finished!', level=3, silent=silent)
522
600
  return mu, eigvecs
523
601
  K = csr_matrix(K)
@@ -536,10 +614,10 @@ def _lb_unsymmetric(K, KG, tol, sparse_solver, silent, num_eigvalues,
536
614
  .format(name, num_active, K.shape[0]), level=3, silent=silent)
537
615
  try:
538
616
  mu, eigvecs = solver()
539
- with np.errstate(divide='ignore'):
617
+ with np.errstate(divide='ignore', invalid='ignore'):
540
618
  error = _check_eigenpairs(K, KG, -1./mu, eigvecs, check_rtol,
541
619
  check_inertia=False)
542
- except (LinAlgError, RuntimeError, ValueError) as e:
620
+ except (LinAlgError, RuntimeError, ValueError, TypeError) as e:
543
621
  error = '{0}: {1}'.format(type(e).__name__, e)
544
622
  if error is None:
545
623
  msg('finished!', level=3, silent=silent)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structsolve
3
- Version: 0.5.0
3
+ Version: 0.5.3
4
4
  Summary: Structural analysis solvers
5
5
  Author-email: "Saullo G. P. Castro" <castrosaullo@gmail.com>
6
6
  License: BSD-2-Clause
@@ -61,7 +61,7 @@ Currently these solvers are pretty much compatible with my other repositories
61
61
  Citing this library
62
62
  ===================
63
63
 
64
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.5.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
64
+ Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.5.3). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
65
65
 
66
66
 
67
67
  Documentation
@@ -76,8 +76,17 @@ History
76
76
 
77
77
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
78
78
 
79
- * version 0.5.0 (2026-10-01)
80
- - Configuration-dependent loads and unsymmetric eigenproblems
79
+ * version 0.5.3 (2026-10-06)
80
+ - Configuration-dependent loads, e.g. follower pressures: the load factor
81
+ is passed to the callables that accept `inc`, and the arc-length
82
+ methods use the load vector of the current configuration
83
+ - Unsymmetric matrices in `lb` (automatic detection), `freq` (residual
84
+ check, optional symmetric solvers, kinetic criterion) and `solve`
85
+ - The unsymmetric solvers of `lb` and `freq` use the complex LAPACK
86
+ drivers of `scipy.linalg.eig`, avoiding crashes of the Python process
87
+ with Intel MKL 2025.0.0
88
+ - Linear static analysis with configuration-dependent loads in
89
+ `Analysis.static(NLgeom=False)`
81
90
  * version 0.4.3 (2026-09-17)
82
91
  - Fixed the eigenvalue shift of the sparse solver of `lb`, which could
83
92
  return buckling loads higher than the critical ones
@@ -24,6 +24,7 @@ tests/test_follower_freq_static.py
24
24
  tests/test_follower_loads.py
25
25
  tests/test_freq.py
26
26
  tests/test_linear_buckling.py
27
+ tests/test_linear_buckling_unsymmetric.py
27
28
  tests/test_newton_raphson.py
28
29
  tests/test_sparseutils.py
29
30
  tests/test_static.py
@@ -234,7 +234,13 @@ def test_lb_unsymmetric_complex_pairs():
234
234
  assert np.iscomplexobj(eigvals)
235
235
  mu = eig(KG, K, right=False)
236
236
  ref = -1/mu
237
- assert np.allclose(np.sort_complex(eigvals), np.sort_complex(ref))
237
+ # the complex driver zggev, used by lb(), does not return conjugate
238
+ # pairs with identical real parts, which changes the order of
239
+ # np.sort_complex within a pair: nearest reference of each eigenvalue
240
+ assert eigvals.shape == ref.shape
241
+ dist = np.abs(eigvals[:, None] - ref[None, :])
242
+ assert np.all(dist.min(axis=1) <= 1e-8*np.abs(eigvals))
243
+ assert np.all(dist.min(axis=0) <= 1e-8*np.abs(ref))
238
244
  real = np.isreal(eigvals)
239
245
  # the real ones come first
240
246
  assert not np.any(real[np.argmin(real):]) or np.all(real)
@@ -0,0 +1,241 @@
1
+ """Tests for structsolve.linear_buckling: lb() with unsymmetric matrices"""
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ import textwrap
6
+
7
+ import numpy as np
8
+ import pytest
9
+ from scipy.linalg import eig
10
+ from scipy.sparse import csr_matrix, diags, random as sprandom, identity
11
+
12
+ from structsolve import lb
13
+
14
+
15
+ def relative_residuals(K, KG, eigvals, eigvecs):
16
+ Ku = K @ eigvecs
17
+ KGu = KG @ eigvecs
18
+ return (np.linalg.norm(Ku + KGu*eigvals, axis=0)
19
+ / (np.linalg.norm(Ku, axis=0)
20
+ + np.abs(eigvals)*np.linalg.norm(KGu, axis=0)))
21
+
22
+
23
+ def dense_load_multipliers(K, KG):
24
+ """Finite load multipliers of the full problem with a direct dense eig"""
25
+ mu = eig(a=np.asarray(KG, dtype=complex), b=np.asarray(K, dtype=complex),
26
+ right=False)
27
+ mu = mu[np.isfinite(mu)]
28
+ # the eigenvalues of the null rows and columns of KG
29
+ mu = mu[np.abs(mu) > 1e-10*np.abs(mu).max()]
30
+ return -1./mu
31
+
32
+
33
+ def matrices_complex_KG_block():
34
+ """Dofs 3 to 5 have null rows and columns of KG, which are condensed out,
35
+ and the KG block has a complex pair of eigenvalues"""
36
+ n = 6
37
+ K = np.diag([4., 5., 6., 7., 8., 9.]) + 0.1*(np.eye(n, k=1)
38
+ + np.eye(n, k=-1))
39
+ KG = np.zeros((n, n))
40
+ KG[:3, :3] = [[-1., 0.5, 0.], [-0.5, -1., 0.], [0., 0., -2.]]
41
+ return K, KG
42
+
43
+
44
+ def test_lb_unsymmetric_complex_pair_condensed():
45
+ """Regression test: a complex eigenpair of the condensed problem, even
46
+ when not returned, made the back-substitution of the condensed dofs
47
+ raise a TypeError in the real SuperLU factorization"""
48
+ K, KG = matrices_complex_KG_block()
49
+ ref = dense_load_multipliers(K, KG)
50
+ real = np.abs(ref.imag) <= 1e-8*np.abs(ref)
51
+ assert np.sum(real) == 1 and np.sum(~real) == 2
52
+ lam_real = ref[real].real[0]
53
+ lam_cplx = ref[~real]
54
+
55
+ # only the real eigenpair is returned: real arrays
56
+ eigvals, eigvecs = lb(csr_matrix(K), csr_matrix(KG), num_eigvalues=1,
57
+ symmetric=False, silent=True)
58
+ assert eigvals.dtype == np.float64 and eigvecs.dtype == np.float64
59
+ assert eigvecs.shape == (6, 1)
60
+ np.testing.assert_allclose(eigvals, [lam_real], rtol=1e-10)
61
+ assert np.all(relative_residuals(K, KG, eigvals, eigvecs) < 1e-10)
62
+
63
+ # the real eigenpair first, followed by one of the complex pair
64
+ eigvals, eigvecs = lb(csr_matrix(K), csr_matrix(KG), num_eigvalues=2,
65
+ symmetric=False, silent=True)
66
+ assert eigvals.shape == (2,) and eigvecs.shape == (6, 2)
67
+ assert np.iscomplexobj(eigvals) and np.iscomplexobj(eigvecs)
68
+ assert eigvals[0].imag == 0 and np.all(eigvecs[:, 0].imag == 0)
69
+ np.testing.assert_allclose(eigvals[0].real, lam_real, rtol=1e-10)
70
+ assert np.min(np.abs(lam_cplx - eigvals[1])) <= 1e-10*abs(eigvals[1])
71
+ assert np.all(relative_residuals(K, KG, eigvals, eigvecs) < 1e-10)
72
+
73
+
74
+ def test_lb_unsymmetric_general_eigenpairs_real_when_kept_real():
75
+ """The real/complex decision applies to the returned eigenpairs"""
76
+ from structsolve.linear_buckling import _general_eigenpairs
77
+
78
+ mu = np.array([-0.5 + 0.2j, -0.5 - 0.2j, -0.25 + 1e-14j, -0.1, np.inf])
79
+ v = np.random.RandomState(0).randn(4, 5)*(1 + 1j)
80
+ m, u = _general_eigenpairs(mu, v, 2)
81
+ assert m.dtype == np.float64 and u.dtype == np.float64
82
+ np.testing.assert_allclose(m, [-0.25, -0.1])
83
+ # rotated eigenvector with a positive largest component
84
+ r = v[:, 2].real
85
+ np.testing.assert_allclose(u[:, 0],
86
+ np.sqrt(2)*r*np.sign(r[np.argmax(abs(r))]))
87
+ m, u = _general_eigenpairs(mu, v)
88
+ assert np.iscomplexobj(m) and m.shape == (4,)
89
+ np.testing.assert_allclose(m[:2], [-0.25, -0.1])
90
+ assert np.all(u[:, :2].imag == 0)
91
+
92
+
93
+ @pytest.mark.parametrize('max_dense_size', [2000, 0])
94
+ def test_lb_unsymmetric_complex_eigenpairs(max_dense_size):
95
+ """Genuinely complex eigenpairs, with condensed dofs that receive complex
96
+ eigenvectors from the back-substitution"""
97
+ n, nb = 30, 12
98
+ K = (diags(np.linspace(4., 8., n)) + diags(0.5*np.ones(n - 1), 1)
99
+ + diags(0.5*np.ones(n - 1), -1)).toarray()
100
+ rng = np.random.RandomState(4)
101
+ A = rng.randn(nb, nb)
102
+ KG = np.zeros((n, n))
103
+ KG[:nb, :nb] = -np.eye(nb) + 0.5*(A - A.T)
104
+ ref = dense_load_multipliers(K, KG)
105
+ assert np.all(np.abs(ref.imag) > 1e-3*np.abs(ref))
106
+ k = 4
107
+ eigvals, eigvecs = lb(csr_matrix(K), csr_matrix(KG), num_eigvalues=k,
108
+ symmetric=False, silent=True,
109
+ max_dense_size=max_dense_size)
110
+ assert np.iscomplexobj(eigvals) and np.iscomplexobj(eigvecs)
111
+ assert eigvals.shape == (k,) and eigvecs.shape == (n, k)
112
+ assert np.all(np.abs(eigvals.imag) > 1e-3*np.abs(eigvals))
113
+ # the k load multipliers of smallest modulus, by increasing modulus
114
+ np.testing.assert_allclose(np.abs(eigvals), np.sort(np.abs(ref))[:k],
115
+ rtol=1e-8)
116
+ for lam in eigvals:
117
+ assert np.min(np.abs(ref - lam)) <= 1e-8*abs(lam)
118
+ res = relative_residuals(K, KG, eigvals, eigvecs)
119
+ assert np.all(res < 1e-8)
120
+ # the condensed dofs are not null
121
+ assert np.all(np.linalg.norm(eigvecs[nb:, :], axis=0) > 0)
122
+
123
+
124
+ def test_lb_check_eigenpairs_real_and_complex():
125
+ """_check_eigenpairs with check_inertia=False accepts real and complex
126
+ eigenpairs, and detects wrong ones"""
127
+ from structsolve.linear_buckling import _check_eigenpairs, _eig_condensed
128
+
129
+ K, KG = matrices_complex_KG_block()
130
+ K = csr_matrix(K)
131
+ KG = csr_matrix(KG)
132
+ mu, eigvecs = _eig_condensed(K, KG, 3)
133
+ assert np.iscomplexobj(mu) and np.iscomplexobj(eigvecs)
134
+ lam = -1./mu
135
+ assert _check_eigenpairs(K, KG, lam, eigvecs, 1e-8,
136
+ check_inertia=False) is None
137
+ error = _check_eigenpairs(K, KG, lam, eigvecs[:, ::-1], 1e-8,
138
+ check_inertia=False)
139
+ assert 'relative residual' in error
140
+ # complex load multipliers are ignored by the inertia check
141
+ assert _check_eigenpairs(K, KG, lam, eigvecs, 1e-8) is None
142
+
143
+ mu, eigvecs = _eig_condensed(K, KG, 1)
144
+ assert mu.dtype == np.float64 and eigvecs.dtype == np.float64
145
+ lam = -1./mu
146
+ assert _check_eigenpairs(K, KG, lam, eigvecs, 1e-8,
147
+ check_inertia=False) is None
148
+ error = _check_eigenpairs(K, KG, lam*1.01, eigvecs, 1e-8,
149
+ check_inertia=False)
150
+ assert 'relative residual' in error
151
+
152
+
153
+ @pytest.mark.parametrize('symmetric, solver', [
154
+ (False, '_eig_condensed'), (True, '_eigh_condensed')])
155
+ def test_lb_solver_type_error_falls_back(monkeypatch, symmetric, solver):
156
+ """A TypeError of the first solver is recorded and the next solver,
157
+ eigs() or eigsh(), is used"""
158
+ import structsolve.linear_buckling as linear_buckling
159
+
160
+ n = 40
161
+ k = np.linspace(1., 3., n)
162
+ lambdas = np.linspace(1., 20., n)
163
+ K = diags(k).tocsr()
164
+ KG = diags(-k/lambdas).tocsr()
165
+
166
+ def raise_type_error(*args, **kwargs):
167
+ raise TypeError('Cannot cast array data from dtype complex128')
168
+
169
+ monkeypatch.setattr(linear_buckling, solver, raise_type_error)
170
+ eigvals, eigvecs = lb(K, KG, silent=True, num_eigvalues=5,
171
+ symmetric=symmetric)
172
+ np.testing.assert_allclose(eigvals, lambdas[:5], rtol=1e-8)
173
+ assert eigvals.dtype == np.float64 and eigvecs.dtype == np.float64
174
+ assert np.all(relative_residuals(K, KG, eigvals, eigvecs) < 1e-8)
175
+
176
+
177
+ @pytest.mark.parametrize('sparse_solver, max_dense_size',
178
+ [(True, 2000), (True, 0), (False, 2000)])
179
+ def test_lb_nearly_symmetric(sparse_solver, max_dense_size):
180
+ """A symmetric positive definite K with 1e-12 asymmetric noise, forced
181
+ to the solvers of unsymmetric matrices, gives the load multipliers of
182
+ the symmetric solver, with real eigenvectors"""
183
+ n = 60
184
+ A = sprandom(n, n, density=0.1, random_state=2)
185
+ K = (A @ A.T + identity(n)).tocsr()
186
+ B = sprandom(n//3, n//3, density=0.3, random_state=3)
187
+ KG = np.zeros((n, n))
188
+ KG[:n//3, :n//3] = -(B @ B.T + 0.1*identity(n//3)).toarray()
189
+ KG = csr_matrix(KG)
190
+ noise = sprandom(n, n, density=0.2, random_state=5).toarray()
191
+ Kn = csr_matrix(K.toarray() + 1e-12*abs(K).max()*noise)
192
+ assert np.linalg.norm((Kn - Kn.T).toarray()) > 1e-13*abs(K).max()
193
+
194
+ ref, _ = lb(K, KG, silent=True, num_eigvalues=10)
195
+ eigvals, eigvecs = lb(Kn, KG, silent=True, num_eigvalues=10,
196
+ symmetric=False, sparse_solver=sparse_solver,
197
+ max_dense_size=max_dense_size)
198
+ if not sparse_solver:
199
+ # all eigenvalues are returned, also the spurious ones of the null
200
+ # rows of KG, which may be complex
201
+ assert np.all(eigvals[:10].imag == 0)
202
+ eigvals = eigvals[:10].real
203
+ assert eigvals.dtype == np.float64 and eigvecs.dtype == np.float64
204
+ assert eigvecs.shape == (n, 10)
205
+ np.testing.assert_allclose(eigvals, ref, rtol=1e-8)
206
+ assert np.all(relative_residuals(Kn, KG, eigvals, eigvecs) < 1e-8)
207
+
208
+
209
+ def test_eig_complex_mkl_crash_regression():
210
+ """Regression test: with Intel MKL 2025.0.0, scipy.linalg.eig on these
211
+ real 48 x 48 matrices (LAPACK dggev) corrupts the heap and the process
212
+ exits with 0xc0000374, deterministically and with any number of threads.
213
+ lb() calls the complex driver zggev. Run in a subprocess, which a crash
214
+ does not take down with the test session"""
215
+ code = textwrap.dedent("""
216
+ import numpy as np
217
+ from scipy.sparse import csr_matrix
218
+ from structsolve import lb
219
+ rng = np.random.RandomState(4)
220
+ KG = rng.randn(48, 48)
221
+ K = rng.randn(48, 48) + 48*np.eye(48)
222
+ eigvals, eigvecs = lb(csr_matrix(K), csr_matrix(KG), silent=True,
223
+ num_eigvalues=5, symmetric=False)
224
+ Ku = K @ eigvecs
225
+ KGu = KG @ eigvecs
226
+ res = (np.linalg.norm(Ku + KGu*eigvals, axis=0)
227
+ / (np.linalg.norm(Ku, axis=0)
228
+ + np.abs(eigvals)*np.linalg.norm(KGu, axis=0)))
229
+ assert eigvecs.shape == (48, 5), eigvecs.shape
230
+ assert np.all(res < 1e-8), res
231
+ print('ok')
232
+ """)
233
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
234
+ env = dict(os.environ)
235
+ env['PYTHONPATH'] = os.pathsep.join(
236
+ [root] + [p for p in [env.get('PYTHONPATH')] if p])
237
+ result = subprocess.run([sys.executable, '-c', code], env=env,
238
+ capture_output=True, text=True, timeout=300)
239
+ assert result.returncode == 0, (hex(result.returncode & 0xffffffff),
240
+ result.stderr[-2000:])
241
+ assert result.stdout.strip().endswith('ok')
File without changes
File without changes