structsolve 0.7.0__tar.gz → 0.8.0__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 (38) hide show
  1. {structsolve-0.7.0/structsolve.egg-info → structsolve-0.8.0}/PKG-INFO +4 -2
  2. {structsolve-0.7.0 → structsolve-0.8.0}/README.md +3 -1
  3. {structsolve-0.7.0 → structsolve-0.8.0}/pyproject.toml +1 -1
  4. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/analysis.py +9 -2
  5. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/arc_length.py +22 -45
  6. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/callbacks.py +7 -1
  7. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/linear_buckling.py +68 -29
  8. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/newton_raphson.py +53 -29
  9. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/static.py +32 -1
  10. {structsolve-0.7.0 → structsolve-0.8.0/structsolve.egg-info}/PKG-INFO +4 -2
  11. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve.egg-info/SOURCES.txt +1 -0
  12. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_linear_buckling.py +1 -1
  13. structsolve-0.8.0/tests/test_reuse.py +174 -0
  14. {structsolve-0.7.0 → structsolve-0.8.0}/LICENSE +0 -0
  15. {structsolve-0.7.0 → structsolve-0.8.0}/setup.cfg +0 -0
  16. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/__init__.py +0 -0
  17. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/arc_length_crisfield.py +0 -0
  18. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/arc_length_riks.py +0 -0
  19. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/arpackutils.py +0 -0
  20. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/freq.py +0 -0
  21. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/logger.py +0 -0
  22. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve/sparseutils.py +0 -0
  23. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve.egg-info/dependency_links.txt +0 -0
  24. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve.egg-info/requires.txt +0 -0
  25. {structsolve-0.7.0 → structsolve-0.8.0}/structsolve.egg-info/top_level.txt +0 -0
  26. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_analysis.py +0 -0
  27. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_arc_length.py +0 -0
  28. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_arpackutils.py +0 -0
  29. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_follower_freq_static.py +0 -0
  30. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_follower_loads.py +0 -0
  31. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_freq.py +0 -0
  32. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_linear_buckling_unsymmetric.py +0 -0
  33. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_load_factor_dependent_load.py +0 -0
  34. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_newton_raphson.py +0 -0
  35. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_sparse_first.py +0 -0
  36. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_sparseutils.py +0 -0
  37. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_static.py +0 -0
  38. {structsolve-0.7.0 → structsolve-0.8.0}/tests/test_static_deflection.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structsolve
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Structural analysis solvers
5
5
  Author-email: "Saullo G. P. Castro" <castrosaullo@gmail.com>
6
6
  License: BSD-2-Clause
@@ -65,7 +65,7 @@ Currently these solvers are pretty much compatible with my other repositories
65
65
  Citing this library
66
66
  ===================
67
67
 
68
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.7.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
68
+ Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.8.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
69
69
 
70
70
 
71
71
  Documentation
@@ -80,6 +80,8 @@ History
80
80
 
81
81
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
82
82
 
83
+ * version 0.8.0 (2026-10-14)
84
+ - Fewer evaluations and factorizations, same solutions: `dR/dlbd` is evaluated together with the tangent stiffness matrix only, Newton-Raphson reuses the factorization of the tangent while it is not updated, the follower linear static analysis factorizes `K0` once, and `lb` estimates its shift and the inertia of `K` once for all its attempts
83
85
  * version 0.7.0 (2026-10-13)
84
86
  - `calc_fext` may declare the keyword argument `lbd` and then receives the current load factor with the configuration, for prescribed displacements imposed exactly as linear constraints; the solvers use `dR/dlbd` for the arc-length scale, the first Newton-Raphson predictor and the relative convergence criterion. Callables without `lbd` behave as before
85
87
  * version 0.6.1 (2026-10-08)
@@ -29,7 +29,7 @@ Currently these solvers are pretty much compatible with my other repositories
29
29
  Citing this library
30
30
  ===================
31
31
 
32
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.7.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
32
+ Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.8.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
33
33
 
34
34
 
35
35
  Documentation
@@ -44,6 +44,8 @@ History
44
44
 
45
45
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
46
46
 
47
+ * version 0.8.0 (2026-10-14)
48
+ - Fewer evaluations and factorizations, same solutions: `dR/dlbd` is evaluated together with the tangent stiffness matrix only, Newton-Raphson reuses the factorization of the tangent while it is not updated, the follower linear static analysis factorizes `K0` once, and `lb` estimates its shift and the inertia of `K` once for all its attempts
47
49
  * version 0.7.0 (2026-10-13)
48
50
  - `calc_fext` may declare the keyword argument `lbd` and then receives the current load factor with the configuration, for prescribed displacements imposed exactly as linear constraints; the solvers use `dR/dlbd` for the arc-length scale, the first Newton-Raphson predictor and the relative convergence criterion. Callables without `lbd` behave as before
49
51
  * version 0.6.1 (2026-10-08)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "structsolve"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Structural analysis solvers"
9
9
  readme = "README.md"
10
10
  license = {text = "BSD-2-Clause"}
@@ -2,7 +2,7 @@ from __future__ import absolute_import
2
2
 
3
3
  import numpy as np
4
4
 
5
- from .static import solve
5
+ from .static import solve, _factorize
6
6
  from .logger import msg, warn
7
7
  from . import callbacks
8
8
  from .newton_raphson import _check_convergence
@@ -302,6 +302,8 @@ class Analysis(object):
302
302
  configuration-dependent load, see :meth:`.static`"""
303
303
  msg('Configuration-dependent load: load correction iterations',
304
304
  level=2, silent=silent)
305
+ # k0 is factorized once for all the corrections
306
+ solve_k0 = None
305
307
  for iteration in range(1, self.maxNumIter + 1):
306
308
  fext = self.calc_fext(inc=1., c=c, silent=True)
307
309
  fint = k0 @ c
@@ -313,7 +315,12 @@ class Analysis(object):
313
315
  return c
314
316
  if not np.isfinite(Rnorm):
315
317
  break
316
- c = c + solve(k0, R, silent=True)
318
+ if solve_k0 is None:
319
+ try:
320
+ solve_k0 = _factorize(k0)
321
+ except RuntimeError:
322
+ break
323
+ c = c + solve_k0(R)
317
324
  warn('The load correction iterations did not converge, the load may '
318
325
  'exceed the critical load of the linearized problem', level=1,
319
326
  silent=silent)
@@ -8,12 +8,9 @@ control the arc-length scaling and adaptation.
8
8
 
9
9
  """
10
10
  import numpy as np
11
- from scipy.sparse import csc_matrix
12
- from scipy.sparse.linalg import splu
13
11
 
14
12
  from .logger import msg, warn
15
- from .static import solve
16
- from .sparseutils import remove_null_cols
13
+ from .static import solve, _factorize
17
14
  from . import callbacks
18
15
  from .newton_raphson import (_check_convergence, _check_divergence,
19
16
  _NR_iterations, INC_CUT_FACTOR)
@@ -31,34 +28,6 @@ MIN_GROWTH_FACTOR = 0.5
31
28
  MAX_NUM_STEPS = 1000
32
29
 
33
30
 
34
- def _factorize(kT):
35
- """LU factorization of the tangent stiffness matrix
36
-
37
- Null rows and columns are removed, the corresponding values of the
38
- solution are zero.
39
-
40
- Parameters
41
- ----------
42
- kT : sparse matrix
43
- Tangent stiffness matrix.
44
-
45
- Returns
46
- -------
47
- solve_kT : callable
48
- Function that returns the solution ``x`` of ``kT x = b``.
49
-
50
- """
51
- kT_red, used_cols = remove_null_cols(kT, silent=True)
52
- lu = splu(csc_matrix(kT_red))
53
-
54
- def solve_kT(b):
55
- x = np.zeros(b.shape[0], dtype=np.result_type(b, lu.U.dtype))
56
- x[used_cols] = lu.solve(b[used_cols])
57
- return x
58
-
59
- return solve_kT
60
-
61
-
62
31
  def _solver_arc_length(an, method, silent=False):
63
32
  r"""Arc-length solver
64
33
 
@@ -117,12 +86,13 @@ def _solver_arc_length(an, method, silent=False):
117
86
  `\{u_{ref}\}` is the linear solution of `\partial R/\partial \lambda`
118
87
  at `c = 0`, `\lambda = 0`, since the reference vector may be null, e.g.
119
88
  under a prescribed displacement alone; and `\lambda \, \partial
120
- R/\partial \lambda`, evaluated at the current iterate before each
121
- convergence check, is an additional reference of the relative
122
- convergence criterion. The same `\partial R/\partial \lambda` is used
123
- in the bordered solution when the tangent stiffness matrix is updated at
124
- that iterate; with ``an.modified_NR = True`` it is one call to
125
- ``calc_fext`` per iteration.
89
+ R/\partial \lambda` is an additional reference of the relative
90
+ convergence criterion. `\partial R/\partial \lambda` is evaluated
91
+ together with the tangent stiffness matrix, at the same iterate, i.e.
92
+ once per update of the tangent stiffness matrix, and used in the
93
+ bordered solution; the last one evaluated is the reference of the next
94
+ convergence checks. At the converged state it is kept with the tangent
95
+ stiffness matrix when the step is repeated with a shorter arc length.
126
96
 
127
97
  The initial arc length corresponds to a load factor increment of
128
98
  ``an.initialInc`` along the initial tangent. The convergence and
@@ -194,8 +164,12 @@ def _solver_arc_length(an, method, silent=False):
194
164
  warn('Singular tangent stiffness matrix at the last converged state! Analysis stopped',
195
165
  level=1, silent=silent)
196
166
  break
197
- # load vector of the converged configuration, dR/dlbd
198
- q_conv = callbacks.calc_load_vector(an, c, fext, lbd)
167
+ # load vector of the converged configuration, dR/dlbd, kept with
168
+ # the tangent stiffness matrix when the step is repeated
169
+ if step_num == 1 and lbd_dependent:
170
+ q_conv = q0
171
+ else:
172
+ q_conv = callbacks.calc_load_vector(an, c, fext, lbd)
199
173
  dc_q_conv = solve_kT_conv(q_conv)
200
174
  norm_q = np.sqrt(inner(dc_q_conv, 1., dc_q_conv, 1.))
201
175
  if arc_length is None:
@@ -218,6 +192,8 @@ def _solver_arc_length(an, method, silent=False):
218
192
  Rnorms = []
219
193
  solve_kT = solve_kT_conv
220
194
  dc_q = dc_q_conv
195
+ # the last dR/dlbd evaluated, with the tangent stiffness matrix
196
+ q_iter = q_conv
221
197
  iter_kT = 0
222
198
  iteration = 0
223
199
  while True:
@@ -227,9 +203,6 @@ def _solver_arc_length(an, method, silent=False):
227
203
  R = fext_total - fint
228
204
  q_ref = None
229
205
  if lbd_dependent:
230
- # dR/dlbd at the current iterate
231
- q_iter = callbacks.calc_load_vector(an, c + dc, fext,
232
- lbd + dlbd)
233
206
  q_ref = (lbd + dlbd)*q_iter
234
207
  conv, Rnorm, Rrel = _check_convergence(an, R, fint, fext_total,
235
208
  fext_ref=dlbd*fext,
@@ -254,6 +227,9 @@ def _solver_arc_length(an, method, silent=False):
254
227
  warn('Singular tangent stiffness matrix', level=2, silent=silent)
255
228
  break
256
229
  if lbd_dependent:
230
+ # dR/dlbd at the iterate of the tangent stiffness matrix
231
+ q_iter = callbacks.calc_load_vector(an, c + dc, fext,
232
+ lbd + dlbd)
257
233
  dc_q = solve_kT(q_iter)
258
234
  else:
259
235
  dc_q = solve_kT(callbacks.calc_load_vector(an, c + dc,
@@ -293,8 +269,9 @@ def _solver_arc_length(an, method, silent=False):
293
269
  if converged and lbd + dlbd > 1. and lbd < 1.:
294
270
  msg('Load factor 1.0 exceeded, correcting to load factor 1.0...', level=1, silent=silent)
295
271
  t = (1. - lbd)/dlbd
296
- converged, dc1 = _NR_iterations(an, c, t*dc, 1., fext, kT0=None,
297
- silent=silent, lbd_dependent=lbd_dependent)
272
+ converged, dc1, _ = _NR_iterations(an, c, t*dc, 1., fext,
273
+ kT0=None, silent=silent, lbd_dependent=lbd_dependent,
274
+ q=q_iter)
298
275
  if converged:
299
276
  msg('Converged at load factor 1.00000', level=2, silent=silent)
300
277
  an.cs.append((c + dc1).copy())
@@ -52,7 +52,13 @@ are called exactly as before:
52
52
  `\lambda \, \partial R/\partial \lambda`, see
53
53
  :func:`.newton_raphson._check_convergence`, since under a prescribed
54
54
  displacement alone the reference vector and the reduced internal force
55
- vector both vanish at equilibrium.
55
+ vector both vanish at equilibrium;
56
+ - ``calc_fext(c=c, lbd=lbd)`` is called at the configurations where the
57
+ tangent stiffness matrix is updated, right after ``calc_kC`` and
58
+ ``calc_kG`` at the same ``c`` and load factor, such that the callables
59
+ may share their computations, and the last `\partial R/\partial
60
+ \lambda` evaluated is the reference of the convergence checks until the
61
+ next update.
56
62
 
57
63
  ``lbd`` is never passed by the linear static analysis,
58
64
  ``Analysis.static(NLgeom=False)``.
@@ -27,23 +27,11 @@ SHIFT_SEARCH_MAX_GROWTH = 1e10
27
27
  PARITY_REL_GAP = 1e-4
28
28
 
29
29
 
30
- def _estimate_sigma(K, KG, safety=10., max_iter=50, rel_tol=1e-3):
31
- r"""Shift of the Cayley mode used by :func:`lb`
32
-
33
- With ``sigma > 0``, ``eigsh(A=KG, M=K, sigma=sigma, mode='cayley',
34
- which='SM')`` returns the eigenvalues `\mu` of ``KG u = mu K u`` with the
35
- smallest `|(\mu + \sigma)/(\mu - \sigma)|`. This ordering selects the most
36
- negative `\mu`, i.e. the lowest positive load multipliers `-1/\mu`, only
37
- when `\sigma` is larger than `|\mu|` of these critical eigenvalues.
38
- Otherwise the eigenvalues closest to `-\sigma` are returned.
39
-
40
- The largest `|\mu|` is estimated with power iterations on
41
- `[K]^{-1}[K_G]`, whose norm ratio in the `[K]`-norm increases towards the
42
- largest `|\mu|`, and multiplied by ``safety``. The default shift ``1.`` is
43
- returned when ``K`` is singular, not positive definite, or the linear
44
- solution is inaccurate.
45
-
46
- """
30
+ def _estimate_mu_max(K, KG, max_iter=50, rel_tol=1e-3):
31
+ r"""Largest `|\mu|` of ``KG u = mu K u``, estimated by power
32
+ iterations, see :func:`_estimate_sigma`, or ``None`` when ``K`` is
33
+ singular, not positive definite, or the linear solution is
34
+ inaccurate"""
47
35
  try:
48
36
  with warnings.catch_warnings():
49
37
  warnings.simplefilter("ignore")
@@ -54,7 +42,7 @@ def _estimate_sigma(K, KG, safety=10., max_iter=50, rel_tol=1e-3):
54
42
  for i in range(max_iter):
55
43
  xKx = x @ Kx
56
44
  if not np.isfinite(xKx) or xKx <= 0:
57
- return 1.
45
+ return None
58
46
  x = x / np.sqrt(xKx)
59
47
  rhs = KG @ x
60
48
  y = lu.solve(rhs)
@@ -62,19 +50,45 @@ def _estimate_sigma(K, KG, safety=10., max_iter=50, rel_tol=1e-3):
62
50
  if i == 0:
63
51
  residual = np.linalg.norm(Ky - rhs)
64
52
  if not residual <= 1e-6 * np.linalg.norm(rhs):
65
- return 1.
53
+ return None
66
54
  # ||y||_K / ||x||_K, with ||x||_K = 1
67
55
  ratio = np.sqrt(abs(y @ Ky))
68
56
  if not np.isfinite(ratio) or ratio == 0:
69
- return 1.
57
+ return None
70
58
  converged = i > 0 and ratio - mu_max <= rel_tol * ratio
71
59
  mu_max = max(mu_max, ratio)
72
60
  if converged:
73
61
  break
74
62
  x, Kx = y, Ky
75
- return safety * mu_max
63
+ return mu_max
76
64
  except Exception:
65
+ return None
66
+
67
+
68
+ def _estimate_sigma(K, KG, safety=10., max_iter=50, rel_tol=1e-3,
69
+ mu_max=False):
70
+ r"""Shift of the Cayley mode used by :func:`lb`
71
+
72
+ With ``sigma > 0``, ``eigsh(A=KG, M=K, sigma=sigma, mode='cayley',
73
+ which='SM')`` returns the eigenvalues `\mu` of ``KG u = mu K u`` with the
74
+ smallest `|(\mu + \sigma)/(\mu - \sigma)|`. This ordering selects the most
75
+ negative `\mu`, i.e. the lowest positive load multipliers `-1/\mu`, only
76
+ when `\sigma` is larger than `|\mu|` of these critical eigenvalues.
77
+ Otherwise the eigenvalues closest to `-\sigma` are returned.
78
+
79
+ The largest `|\mu|` is estimated with power iterations on
80
+ `[K]^{-1}[K_G]`, whose norm ratio in the `[K]`-norm increases towards the
81
+ largest `|\mu|`, and multiplied by ``safety``. The default shift ``1.`` is
82
+ returned when ``K`` is singular, not positive definite, or the linear
83
+ solution is inaccurate. An estimate of :func:`_estimate_mu_max` may be
84
+ given as ``mu_max``, e.g. to try another ``safety`` factor.
85
+
86
+ """
87
+ if mu_max is False:
88
+ mu_max = _estimate_mu_max(K, KG, max_iter=max_iter, rel_tol=rel_tol)
89
+ if mu_max is None:
77
90
  return 1.
91
+ return safety * mu_max
78
92
 
79
93
 
80
94
  def _negative_pivots(A):
@@ -135,7 +149,7 @@ def _lu_solve(lu, b):
135
149
 
136
150
 
137
151
  def _check_eigenpairs(K, KG, eigvals, eigvecs, rtol, min_rel_gap=1e-4,
138
- check_inertia=True):
152
+ check_inertia=True, K_positive_definite=None):
139
153
  r"""Verify the eigenpairs of `([K] + \lambda [K_G])\{u\} = \{0\}`
140
154
 
141
155
  The eigenpairs may be real or complex.
@@ -155,6 +169,10 @@ def _check_eigenpairs(K, KG, eigvals, eigvecs, rtol, min_rel_gap=1e-4,
155
169
  symmetric matrices, ``check_inertia=True``, the complex `\lambda` are
156
170
  ignored. Missing negative load multipliers are not detected.
157
171
 
172
+ Whether ``K`` is positive definite may be given as
173
+ ``K_positive_definite``, a boolean or a function returning it, e.g. to
174
+ verify several solutions with one factorization of ``K``.
175
+
158
176
  Returns
159
177
  -------
160
178
  error : str or None
@@ -181,7 +199,11 @@ def _check_eigenpairs(K, KG, eigvals, eigvecs, rtol, min_rel_gap=1e-4,
181
199
  return None
182
200
  lam = lam[np.isreal(lam)].real
183
201
  positive = lam[lam > 0]
184
- if positive.size and _is_positive_definite(K):
202
+ if K_positive_definite is None:
203
+ K_positive_definite = _is_positive_definite
204
+ if positive.size and (K_positive_definite(K) if
205
+ callable(K_positive_definite) else
206
+ K_positive_definite):
185
207
  s = positive.max()*(1 - min_rel_gap)
186
208
  expected = int(np.count_nonzero(positive < s))
187
209
  count = _negative_pivots(K + s*KG) if expected else None
@@ -599,15 +621,16 @@ def _eigs_shift_search(K, KG, k, tol, check_rtol, silent):
599
621
  return mu, eigvecs
600
622
 
601
623
 
602
- def _eigsh_cayley(K, KG, k, tol, silent, safety=10.):
624
+ def _eigsh_cayley(K, KG, k, tol, silent, safety=10., mu_max=False):
603
625
  r"""Solution with :func:`scipy.sparse.linalg.eigsh` in Cayley mode
604
626
 
605
627
  The shift is estimated with :func:`_estimate_sigma` and the given
606
- ``safety`` factor. ARPACK's ``ncv`` is chosen by
628
+ ``safety`` factor, from the estimate ``mu_max`` of
629
+ :func:`_estimate_mu_max` when given. ARPACK's ``ncv`` is chosen by
607
630
  :func:`.arpackutils.capped_eigsh`, which avoids the Intel MKL bug.
608
631
 
609
632
  """
610
- sigma = _estimate_sigma(K, KG, safety=safety)
633
+ sigma = _estimate_sigma(K, KG, safety=safety, mu_max=mu_max)
611
634
  msg('sigma={0}'.format(sigma), level=4, silent=silent)
612
635
  k = min(k, K.shape[0] - 2)
613
636
  mu, eigvecs = capped_eigsh(A=KG, k=k, M=K, sigma=sigma, which='SM',
@@ -901,16 +924,32 @@ def lb(K, KG, tol=0, sparse_solver=True, silent=False,
901
924
  msg('{0} of {1} rows of KG are non-null'.format(num_active,
902
925
  K.shape[0]), level=3, silent=silent)
903
926
 
927
+ # the estimate of the shift and the inertia of K are computed once
928
+ # for all the attempts
929
+ once = {}
930
+
931
+ def mu_max():
932
+ if 'mu_max' not in once:
933
+ once['mu_max'] = _estimate_mu_max(K, KG)
934
+ return once['mu_max']
935
+
936
+ def K_positive_definite(K):
937
+ if 'pd' not in once:
938
+ once['pd'] = _is_positive_definite(K)
939
+ return once['pd']
940
+
904
941
  def verify(mu, eigvecs):
905
942
  with np.errstate(divide='ignore', invalid='ignore'):
906
- return _check_eigenpairs(K, KG, -1./mu, eigvecs, check_rtol)
943
+ return _check_eigenpairs(K, KG, -1./mu, eigvecs, check_rtol,
944
+ K_positive_definite=K_positive_definite)
907
945
 
908
946
  strategies = [
909
947
  ('eigsh() in Cayley mode',
910
- lambda: _eigsh_cayley(K, KG, num_eigvalues, tol, silent)),
948
+ lambda: _eigsh_cayley(K, KG, num_eigvalues, tol, silent,
949
+ mu_max=mu_max())),
911
950
  ('eigsh() in Cayley mode with a 10 times larger shift',
912
951
  lambda: _eigsh_cayley(K, KG, num_eigvalues, tol, silent,
913
- safety=100.)),
952
+ safety=100., mu_max=mu_max())),
914
953
  ]
915
954
  dense = ('eigh() with condensed dofs',
916
955
  lambda: _eigh_condensed(K, KG, num_eigvalues))
@@ -8,7 +8,7 @@ and they are shared with the arc-length solvers of
8
8
  import numpy as np
9
9
 
10
10
  from .logger import msg, warn
11
- from .static import solve
11
+ from .static import solve, _factorize
12
12
  from . import callbacks
13
13
 
14
14
 
@@ -139,7 +139,7 @@ def _check_divergence(an, Rnorm, Rnorms, iteration, silent=False):
139
139
 
140
140
 
141
141
  def _NR_iterations(an, c, dc, total, fext, kT0=None, silent=False,
142
- lbd_dependent=False):
142
+ lbd_dependent=False, q=None):
143
143
  r"""Newton-Raphson iterations at a fixed load factor
144
144
 
145
145
  Parameters
@@ -161,9 +161,15 @@ def _NR_iterations(an, c, dc, total, fext, kT0=None, silent=False,
161
161
  A boolean to tell whether the log messages should be printed.
162
162
  lbd_dependent : bool, optional
163
163
  Whether ``calc_fext`` accepts ``lbd``, see :mod:`.callbacks`;
164
- ``total*q``, with `\{q\} = \partial R/\partial \lambda` evaluated
165
- at the current iterate, is then an additional reference of the
166
- relative convergence criterion, see :func:`._check_convergence`.
164
+ ``total*q``, with `\{q\} = \partial R/\partial \lambda`, is then an
165
+ additional reference of the relative convergence criterion, see
166
+ :func:`._check_convergence`. `\{q\}` is evaluated together with the
167
+ tangent stiffness matrix, at the same iterate, and the last one
168
+ evaluated is the reference until the next update of the tangent.
169
+ q : array-like or None, optional
170
+ The last `\{q\}` evaluated before these iterations, the reference
171
+ until the first update of the tangent stiffness matrix, used only
172
+ when ``lbd_dependent=True``.
167
173
 
168
174
  Returns
169
175
  -------
@@ -171,14 +177,18 @@ def _NR_iterations(an, c, dc, total, fext, kT0=None, silent=False,
171
177
  Whether the iterations converged.
172
178
  dc : array-like
173
179
  Solution increment, such that ``c + dc`` is the solution.
180
+ q : array-like or None
181
+ The last `\{q\}` evaluated, ``None`` when ``lbd_dependent=False``.
174
182
 
175
183
  """
176
184
  modified_NR = an.modified_NR
177
185
  fext_total = total*fext
186
+ if not lbd_dependent:
187
+ q = None
178
188
  q_ref = None
179
189
  Rnorms = []
180
190
  iter_kT = 0
181
- kT = None
191
+ solve_kT = None
182
192
  fint = None
183
193
 
184
194
  iteration = 0
@@ -187,28 +197,37 @@ def _NR_iterations(an, c, dc, total, fext, kT0=None, silent=False,
187
197
  if fint is None:
188
198
  fint = callbacks.calc_fint(an, c + dc, total)
189
199
  R = fext_total - fint
190
- if lbd_dependent:
191
- q_ref = total*callbacks.calc_load_vector(an, c + dc, fext, total)
200
+ if q is not None:
201
+ q_ref = total*q
192
202
 
193
203
  conv, Rnorm, Rrel = _check_convergence(an, R, fint, fext_total,
194
204
  q_ref=q_ref)
195
205
  msg('Iteration: %d, max(|R|) = %1.3e, relative ||R|| = %1.3e'
196
206
  % (iteration, np.abs(R).max(), Rrel), level=2, silent=silent)
197
207
  if conv:
198
- return True, dc
208
+ return True, dc, q
199
209
  if _check_divergence(an, Rnorm, Rnorms, iteration, silent=silent):
200
- return False, dc
201
-
202
- if modified_NR and iteration > 1 and iter_kT < an.compute_every_n:
203
- iter_kT += 1
204
- elif modified_NR and iteration == 1 and kT0 is not None:
205
- kT = kT0
206
- iter_kT = 1
207
- else:
208
- kT = callbacks.calc_kT(an, c + dc, total)
209
- iter_kT = 1
210
-
211
- varc = solve(kT, R, silent=True)
210
+ return False, dc, q
211
+
212
+ # the factorization of the tangent stiffness matrix is reused while
213
+ # the matrix is not updated
214
+ try:
215
+ if modified_NR and iteration > 1 and iter_kT < an.compute_every_n:
216
+ iter_kT += 1
217
+ elif modified_NR and iteration == 1 and kT0 is not None:
218
+ solve_kT = _factorize(kT0)
219
+ iter_kT = 1
220
+ else:
221
+ solve_kT = _factorize(callbacks.calc_kT(an, c + dc, total))
222
+ iter_kT = 1
223
+ if lbd_dependent:
224
+ # dR/dlbd at the iterate of the tangent stiffness matrix
225
+ q = callbacks.calc_load_vector(an, c + dc, fext, total)
226
+ except RuntimeError:
227
+ warn('Singular tangent stiffness matrix', level=2, silent=silent)
228
+ return False, dc, q
229
+
230
+ varc = solve_kT(R)
212
231
 
213
232
  eta = 1.
214
233
  fint = None
@@ -261,16 +280,19 @@ def _solver_NR(an, silent=False, initialInc=None):
261
280
  `\lambda = 0` gives the predictor of the first increment, its linear
262
281
  solution `[K_0]^{-1}\{q\}`, instead of the linear solution of the
263
282
  reference vector, which is null under a prescribed displacement alone,
264
- and `\lambda \|q\|`, with `\{q\}` evaluated at the current iterate
265
- before each convergence check, is an additional reference of the
266
- relative convergence criterion. With ``an.modified_NR = True`` this is
267
- one call to ``calc_fext`` per iteration.
283
+ and `\lambda \|q\|` is an additional reference of the relative
284
+ convergence criterion. `\{q\}` is evaluated together with the tangent
285
+ stiffness matrix, at the same iterate, i.e. ``calc_fext`` is called once
286
+ per update of the tangent stiffness matrix, and the last `\{q\}`
287
+ evaluated is the reference of the next convergence checks, also in the
288
+ next step.
268
289
 
269
290
  By default full Newton-Raphson is used, i.e. the tangent stiffness matrix
270
291
  ``kT = kC + kG`` is rebuilt at every iteration, which gives quadratic
271
292
  convergence when ``kT`` is the exact Jacobian of ``fint``. With
272
293
  ``an.modified_NR = True`` the tangent stiffness matrix is rebuilt only at
273
294
  the first iteration of each step and every ``an.compute_every_n``
295
+ iterations, and its LU factorization is reused in the other
274
296
  iterations.
275
297
 
276
298
  With ``an.line_search = True``, the full Newton step (``eta = 1``) is tried
@@ -315,7 +337,8 @@ def _solver_NR(an, silent=False, initialInc=None):
315
337
  fext = an.calc_fext(inc=1., silent=True)
316
338
  kC0 = an.calc_kC(silent=silent)
317
339
  q = None
318
- if callbacks.is_load_factor_dependent(an):
340
+ lbd_dependent = callbacks.is_load_factor_dependent(an)
341
+ if lbd_dependent:
319
342
  # dR/dlbd at c = 0 and lbd = 0
320
343
  q = callbacks.calc_load_vector(an, np.zeros_like(fext), fext, 0.)
321
344
  # predictor of the first step: linear solution scaled by the load factor
@@ -339,12 +362,13 @@ def _solver_NR(an, silent=False, initialInc=None):
339
362
  kT0 = None
340
363
  if an.modified_NR and step_num == 1 and not an.kT_initial_state:
341
364
  kT0 = kC0
342
- converged, dc = _NR_iterations(an, c, dc, total, fext, kT0=kT0,
343
- silent=silent,
344
- lbd_dependent=q is not None)
365
+ converged, dc, q_new = _NR_iterations(an, c, dc, total, fext,
366
+ kT0=kT0, silent=silent, lbd_dependent=lbd_dependent, q=q)
345
367
 
346
368
  if converged:
347
369
  msg('Converged at load factor %1.5f' % total, level=2, silent=silent)
370
+ # the dR/dlbd of a step that failed is not kept
371
+ q = q_new
348
372
  c = c + dc
349
373
  total_last = total
350
374
  an.cs.append(c.copy()) #NOTE copy required
@@ -1,5 +1,6 @@
1
1
  import numpy as np
2
- from scipy.sparse.linalg import spsolve
2
+ from scipy.sparse import csc_matrix
3
+ from scipy.sparse.linalg import spsolve, splu
3
4
 
4
5
  from .logger import msg
5
6
  from .sparseutils import remove_null_cols
@@ -43,6 +44,36 @@ def solve(a, b, silent=False, **kwargs):
43
44
  return x
44
45
 
45
46
 
47
+ def _factorize(kT):
48
+ """LU factorization of the tangent stiffness matrix
49
+
50
+ Null rows and columns are removed, the corresponding values of the
51
+ solution are zero, as in :func:`.solve`. The factorization is reused by
52
+ the non-linear solvers while the tangent stiffness matrix is not
53
+ updated.
54
+
55
+ Parameters
56
+ ----------
57
+ kT : sparse matrix
58
+ Tangent stiffness matrix.
59
+
60
+ Returns
61
+ -------
62
+ solve_kT : callable
63
+ Function that returns the solution ``x`` of ``kT x = b``.
64
+
65
+ """
66
+ kT_red, used_cols = remove_null_cols(kT, silent=True)
67
+ lu = splu(csc_matrix(kT_red))
68
+
69
+ def solve_kT(b):
70
+ x = np.zeros(b.shape[0], dtype=np.result_type(b, lu.U.dtype))
71
+ x[used_cols] = lu.solve(b[used_cols])
72
+ return x
73
+
74
+ return solve_kT
75
+
76
+
46
77
  def static(K, fext, silent=False):
47
78
  r"""Linear static analysis
48
79
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structsolve
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Structural analysis solvers
5
5
  Author-email: "Saullo G. P. Castro" <castrosaullo@gmail.com>
6
6
  License: BSD-2-Clause
@@ -65,7 +65,7 @@ Currently these solvers are pretty much compatible with my other repositories
65
65
  Citing this library
66
66
  ===================
67
67
 
68
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.7.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
68
+ Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.8.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
69
69
 
70
70
 
71
71
  Documentation
@@ -80,6 +80,8 @@ History
80
80
 
81
81
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
82
82
 
83
+ * version 0.8.0 (2026-10-14)
84
+ - Fewer evaluations and factorizations, same solutions: `dR/dlbd` is evaluated together with the tangent stiffness matrix only, Newton-Raphson reuses the factorization of the tangent while it is not updated, the follower linear static analysis factorizes `K0` once, and `lb` estimates its shift and the inertia of `K` once for all its attempts
83
85
  * version 0.7.0 (2026-10-13)
84
86
  - `calc_fext` may declare the keyword argument `lbd` and then receives the current load factor with the configuration, for prescribed displacements imposed exactly as linear constraints; the solvers use `dR/dlbd` for the arc-length scale, the first Newton-Raphson predictor and the relative convergence criterion. Callables without `lbd` behave as before
85
87
  * version 0.6.1 (2026-10-08)
@@ -29,6 +29,7 @@ tests/test_linear_buckling.py
29
29
  tests/test_linear_buckling_unsymmetric.py
30
30
  tests/test_load_factor_dependent_load.py
31
31
  tests/test_newton_raphson.py
32
+ tests/test_reuse.py
32
33
  tests/test_sparse_first.py
33
34
  tests/test_sparseutils.py
34
35
  tests/test_static.py
@@ -169,7 +169,7 @@ def test_lb_sparse_raises_when_verification_fails(monkeypatch):
169
169
 
170
170
  K, KG = load_saved_matrices('plate_ssss_Nxx')
171
171
 
172
- def wrong_eigsh(K, KG, k, tol, silent, safety=10.):
172
+ def wrong_eigsh(K, KG, k, tol, silent, safety=10., mu_max=False):
173
173
  rng = np.random.RandomState(0)
174
174
  return -rng.rand(k), rng.randn(K.shape[0], k)
175
175
 
@@ -0,0 +1,174 @@
1
+ r"""Reuse of evaluations and factorizations by the solvers
2
+
3
+ - With a ``calc_fext`` that declares ``lbd``, `\partial R/\partial \lambda`
4
+ is evaluated only together with the tangent stiffness matrix, right after
5
+ ``calc_kC`` and ``calc_kG`` at the same configuration, see
6
+ :mod:`structsolve.callbacks`; the solutions are those of the tests of
7
+ ``test_load_factor_dependent_load.py``.
8
+ - Newton-Raphson factorizes the tangent stiffness matrix once per update.
9
+ - The linear static analysis with a configuration-dependent load factorizes
10
+ ``K0`` once.
11
+ - :func:`.lb` estimates its shift and the positive definiteness of ``K``
12
+ once for all its attempts.
13
+
14
+ """
15
+ import numpy as np
16
+ import pytest
17
+ from scipy.sparse import csr_matrix
18
+
19
+ from structsolve import Analysis, lb
20
+ from structsolve import linear_buckling, newton_raphson, analysis
21
+ from test_load_factor_dependent_load import SpringsProblem
22
+ from test_linear_buckling import load_saved_matrices
23
+
24
+
25
+ class Recorder(SpringsProblem):
26
+ r"""The springs, recording the sequence of the calls with ``c``"""
27
+ def __init__(self, **kwargs):
28
+ super().__init__(**kwargs)
29
+ self.events = []
30
+
31
+ def calc_fext(self, inc=1., silent=True, c=None, lbd=None):
32
+ if c is not None:
33
+ self.events.append(('fext', c.copy(), lbd))
34
+ return super().calc_fext(inc=inc, silent=silent, c=c, lbd=lbd)
35
+
36
+ def calc_kC(self, c=None, NLgeom=False, inc=1., silent=True):
37
+ if c is not None and NLgeom:
38
+ self.events.append(('kC', c.copy(), inc))
39
+ return super().calc_kC(c=c, NLgeom=NLgeom, inc=inc, silent=silent)
40
+
41
+ def calc_kG(self, c=None, NLgeom=False, silent=True):
42
+ if c is not None and NLgeom:
43
+ self.events.append(('kG', c.copy(), None))
44
+ return super().calc_kG(c=c, NLgeom=NLgeom, silent=silent)
45
+
46
+
47
+ def check_events(pb, origin_calls=1):
48
+ r"""Every `\partial R/\partial \lambda` but those at the origin follows
49
+ the tangent stiffness matrix at the same configuration and load factor,
50
+ and there is at most one per tangent"""
51
+ ev = pb.events
52
+ fext = [i for i, e in enumerate(ev) if e[0] == 'fext']
53
+ tangents = [i for i, e in enumerate(ev) if e[0] == 'kC']
54
+ assert len(fext) <= len(tangents) + origin_calls
55
+ for i in fext:
56
+ _, c, lbd = ev[i]
57
+ if i < 2 or ev[i - 1][0] != 'kG':
58
+ assert not np.any(c) and lbd == 0., ev[i]
59
+ continue
60
+ assert np.array_equal(ev[i - 1][1], c)
61
+ assert ev[i - 2][0] == 'kC' and np.array_equal(ev[i - 2][1], c)
62
+ assert ev[i - 2][2] == lbd
63
+ return len(fext), len(tangents)
64
+
65
+
66
+ def count(monkeypatch, module, name):
67
+ calls = []
68
+ func = getattr(module, name)
69
+
70
+ def wrapper(*args, **kwargs):
71
+ calls.append(1)
72
+ return func(*args, **kwargs)
73
+
74
+ monkeypatch.setattr(module, name, wrapper)
75
+ return calls
76
+
77
+
78
+ @pytest.mark.parametrize('method', ['NR', 'arc_length_riks',
79
+ 'arc_length_crisfield'])
80
+ @pytest.mark.parametrize('modified_NR', [False, True])
81
+ def test_load_vector_evaluated_with_the_tangent(method, modified_NR):
82
+ pb = Recorder(d0=-0.3, d1=0.6, F=40.)
83
+ an = pb.analysis(NL_method=method, initialInc=0.2, relTOL=1e-11,
84
+ modified_NR=modified_NR)
85
+ an.static(NLgeom=True, silent=True)
86
+ assert np.isclose(an.increments[-1], 1.)
87
+ for lbd, c_r in zip(an.increments, an.cs):
88
+ ref = pb.solve_reference(lbd)
89
+ assert np.allclose(c_r, ref, rtol=1e-8, atol=1e-12), (lbd, c_r, ref)
90
+ n_fext, n_tangents = check_events(pb)
91
+ # dR/dlbd at the origin, then at most one per tangent
92
+ assert n_fext >= 2
93
+ if method == 'NR':
94
+ # one per tangent, none at the converged iterates
95
+ assert n_fext == n_tangents + 1
96
+
97
+
98
+ def test_newton_raphson_one_factorization_per_tangent(monkeypatch):
99
+ factorizations = count(monkeypatch, newton_raphson, '_factorize')
100
+ for modified_NR in (False, True):
101
+ del factorizations[:]
102
+ pb = Recorder(k=(10., 15., 20.), a=(200., 400., 600.), d1=1.)
103
+ an = pb.analysis(NL_method='NR', initialInc=0.5, relTOL=1e-12,
104
+ modified_NR=modified_NR, compute_every_n=3,
105
+ kT_initial_state=True)
106
+ an.static(NLgeom=True, silent=True)
107
+ assert np.isclose(an.increments[-1], 1.)
108
+ n_tangents = sum(1 for e in pb.events if e[0] == 'kC')
109
+ assert len(factorizations) == n_tangents
110
+ corrections = sum(1 for e in pb.fint_calls) - len(an.increments)
111
+ if modified_NR:
112
+ # the factorization is reused between the updates
113
+ assert len(factorizations) < corrections
114
+
115
+
116
+ def test_newton_raphson_singular_tangent_cuts_the_step():
117
+ """A singular tangent stops the iterations of the step with a warning,
118
+ the step being repeated with a smaller increment"""
119
+ pb = SpringsProblem(F=10., d1=0.)
120
+ calls = []
121
+
122
+ def calc_kC(c=None, NLgeom=False, inc=1., silent=True):
123
+ if c is not None and NLgeom and not calls:
124
+ calls.append(1)
125
+ return csr_matrix(np.array([[1., 1.], [1., 1.]]))
126
+ return pb.calc_kC(c=c, NLgeom=NLgeom, inc=inc, silent=silent)
127
+
128
+ an = pb.analysis(initialInc=0.5, relTOL=1e-10)
129
+ an.calc_kC = calc_kC
130
+ an.static(NLgeom=True, silent=True)
131
+ assert calls and np.isclose(an.increments[-1], 1.)
132
+
133
+
134
+ def test_linear_static_follower_factorizes_K0_once(monkeypatch):
135
+ factorizations = count(monkeypatch, analysis, '_factorize')
136
+ K0 = csr_matrix(np.array([[4., -1., 0.], [-1., 4., -1.], [0., -1., 3.]]))
137
+ F0 = np.array([1., 0., 2.])
138
+ B = 0.3*np.array([[0., 1., 0.], [0., 0., 1.], [1., 0., 0.]])
139
+
140
+ def calc_fext(inc=1., silent=True, c=None):
141
+ return inc*(F0 if c is None else F0 + B @ c)
142
+
143
+ an = Analysis(calc_fext, None, lambda c=None, NLgeom=False, silent=True:
144
+ K0, None)
145
+ an.relTOL = 1e-12
146
+ _, cs = an.static(NLgeom=False, silent=True)
147
+ exact = np.linalg.solve(K0.toarray() - B, F0)
148
+ assert np.allclose(cs[-1], exact, rtol=1e-10)
149
+ assert len(factorizations) == 1
150
+
151
+
152
+ def test_lb_estimates_once_for_all_attempts(monkeypatch):
153
+ K, KG = load_saved_matrices('plate_ssss_Nxx')
154
+ ref, _ = lb(K, KG, silent=True)
155
+ estimates = count(monkeypatch, linear_buckling, '_estimate_mu_max')
156
+ inertia = count(monkeypatch, linear_buckling, '_is_positive_definite')
157
+ eigsh_cayley = linear_buckling._eigsh_cayley
158
+ attempts = []
159
+
160
+ def first_fails(K, KG, k, tol, silent, safety=10., mu_max=False):
161
+ attempts.append(safety)
162
+ mu, v = eigsh_cayley(K, KG, k, tol, silent, safety=safety,
163
+ mu_max=mu_max)
164
+ if len(attempts) == 1:
165
+ # a wrong eigenvector
166
+ v = v[:, ::-1]
167
+ return mu, v
168
+
169
+ monkeypatch.setattr(linear_buckling, '_eigsh_cayley', first_fails)
170
+ eigvals, _ = lb(K, KG, silent=True)
171
+ assert attempts == [10., 100.]
172
+ assert np.allclose(eigvals[:5], ref[:5], rtol=1e-8)
173
+ assert len(estimates) == 1
174
+ assert len(inertia) == 1
File without changes
File without changes