structsolve 0.6.0__tar.gz → 0.7.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.6.0/structsolve.egg-info → structsolve-0.7.0}/PKG-INFO +10 -4
  2. {structsolve-0.6.0 → structsolve-0.7.0}/README.md +8 -3
  3. {structsolve-0.6.0 → structsolve-0.7.0}/pyproject.toml +2 -1
  4. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/analysis.py +12 -1
  5. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/arc_length.py +38 -5
  6. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/arpackutils.py +36 -4
  7. structsolve-0.7.0/structsolve/callbacks.py +154 -0
  8. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/linear_buckling.py +4 -3
  9. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/newton_raphson.py +46 -10
  10. {structsolve-0.6.0 → structsolve-0.7.0/structsolve.egg-info}/PKG-INFO +10 -4
  11. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve.egg-info/SOURCES.txt +1 -0
  12. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_linear_buckling_unsymmetric.py +3 -0
  13. structsolve-0.7.0/tests/test_load_factor_dependent_load.py +298 -0
  14. structsolve-0.6.0/structsolve/callbacks.py +0 -68
  15. {structsolve-0.6.0 → structsolve-0.7.0}/LICENSE +0 -0
  16. {structsolve-0.6.0 → structsolve-0.7.0}/setup.cfg +0 -0
  17. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/__init__.py +0 -0
  18. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/arc_length_crisfield.py +0 -0
  19. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/arc_length_riks.py +0 -0
  20. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/freq.py +0 -0
  21. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/logger.py +0 -0
  22. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/sparseutils.py +0 -0
  23. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve/static.py +0 -0
  24. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve.egg-info/dependency_links.txt +0 -0
  25. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve.egg-info/requires.txt +0 -0
  26. {structsolve-0.6.0 → structsolve-0.7.0}/structsolve.egg-info/top_level.txt +0 -0
  27. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_analysis.py +0 -0
  28. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_arc_length.py +0 -0
  29. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_arpackutils.py +0 -0
  30. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_follower_freq_static.py +0 -0
  31. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_follower_loads.py +0 -0
  32. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_freq.py +0 -0
  33. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_linear_buckling.py +0 -0
  34. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_newton_raphson.py +0 -0
  35. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_sparse_first.py +0 -0
  36. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_sparseutils.py +0 -0
  37. {structsolve-0.6.0 → structsolve-0.7.0}/tests/test_static.py +0 -0
  38. {structsolve-0.6.0 → structsolve-0.7.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.6.0
3
+ Version: 0.7.0
4
4
  Summary: Structural analysis solvers
5
5
  Author-email: "Saullo G. P. Castro" <castrosaullo@gmail.com>
6
6
  License: BSD-2-Clause
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Operating System :: Microsoft :: Windows
23
23
  Classifier: Operating System :: Unix
24
+ Classifier: Environment :: WebAssembly :: Emscripten
24
25
  Requires-Python: >=3.8
25
26
  Description-Content-Type: text/markdown
26
27
  License-File: LICENSE
@@ -53,6 +54,8 @@ Structural analysis solvers tailored for semi-analytical models
53
54
  - Nonlinear statics using Newton-Raphson
54
55
  - Nonlinear statics using the Arc-Length method
55
56
  - Support for unsymmetric problems, such as those involving follower forces
57
+ - Load vectors that depend on the load factor, e.g. prescribed displacements
58
+ imposed exactly as linear constraints
56
59
 
57
60
  Currently these solvers are pretty much compatible with my other repositories
58
61
  [panels](https://github.com/saullocastro/panels),
@@ -62,7 +65,7 @@ Currently these solvers are pretty much compatible with my other repositories
62
65
  Citing this library
63
66
  ===================
64
67
 
65
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.6.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.7.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
66
69
 
67
70
 
68
71
  Documentation
@@ -77,9 +80,12 @@ History
77
80
 
78
81
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
79
82
 
80
- * version 0.6.0 (2026-10-08)
83
+ * version 0.7.0 (2026-10-13)
84
+ - `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
+ * version 0.6.1 (2026-10-08)
86
+ - Pyodide support: `await micropip.install("structsolve")` in the browser, and the whole test suite runs in Pyodide in GitHub Actions
81
87
  - `lb` and `freq` always run the sparse solvers first, whatever the size of the problem, with verified results; the dense solvers are only a last resort within `max_dense_size`, with a `DenseFallbackWarning`
82
- - ARPACK's `ncv` is capped at 32 when SciPy is linked against the Intel MKL versions whose `dsteqr` is faulty (new `structsolve.arpackutils`)
88
+ - ARPACK's `ncv` is capped at 32 when SciPy is linked against the Intel MKL versions whose `dsteqr` is faulty (new `structsolve.arpackutils`), computing more than 15 eigenpairs in passes whose eigenvectors are made orthonormal with a Rayleigh-Ritz projection
83
89
  - Unsymmetric `lb`: a shift search finds the lowest positive real load multiplier, instead of silently returning negative or complex ones
84
90
  - The test extra no longer requires `buckling` and `composites`
85
91
  * version 0.5.3 (2026-10-06)
@@ -18,6 +18,8 @@ Structural analysis solvers tailored for semi-analytical models
18
18
  - Nonlinear statics using Newton-Raphson
19
19
  - Nonlinear statics using the Arc-Length method
20
20
  - Support for unsymmetric problems, such as those involving follower forces
21
+ - Load vectors that depend on the load factor, e.g. prescribed displacements
22
+ imposed exactly as linear constraints
21
23
 
22
24
  Currently these solvers are pretty much compatible with my other repositories
23
25
  [panels](https://github.com/saullocastro/panels),
@@ -27,7 +29,7 @@ Currently these solvers are pretty much compatible with my other repositories
27
29
  Citing this library
28
30
  ===================
29
31
 
30
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.6.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.7.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
31
33
 
32
34
 
33
35
  Documentation
@@ -42,9 +44,12 @@ History
42
44
 
43
45
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
44
46
 
45
- * version 0.6.0 (2026-10-08)
47
+ * version 0.7.0 (2026-10-13)
48
+ - `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
+ * version 0.6.1 (2026-10-08)
50
+ - Pyodide support: `await micropip.install("structsolve")` in the browser, and the whole test suite runs in Pyodide in GitHub Actions
46
51
  - `lb` and `freq` always run the sparse solvers first, whatever the size of the problem, with verified results; the dense solvers are only a last resort within `max_dense_size`, with a `DenseFallbackWarning`
47
- - ARPACK's `ncv` is capped at 32 when SciPy is linked against the Intel MKL versions whose `dsteqr` is faulty (new `structsolve.arpackutils`)
52
+ - ARPACK's `ncv` is capped at 32 when SciPy is linked against the Intel MKL versions whose `dsteqr` is faulty (new `structsolve.arpackutils`), computing more than 15 eigenpairs in passes whose eigenvectors are made orthonormal with a Rayleigh-Ritz projection
48
53
  - Unsymmetric `lb`: a shift search finds the lowest positive real load multiplier, instead of silently returning negative or complex ones
49
54
  - The test extra no longer requires `buckling` and `composites`
50
55
  * version 0.5.3 (2026-10-06)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "structsolve"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Structural analysis solvers"
9
9
  readme = "README.md"
10
10
  license = {text = "BSD-2-Clause"}
@@ -31,6 +31,7 @@ classifiers = [
31
31
  "Programming Language :: Python :: 3.14",
32
32
  "Operating System :: Microsoft :: Windows",
33
33
  "Operating System :: Unix",
34
+ "Environment :: WebAssembly :: Emscripten",
34
35
  ]
35
36
  dependencies = [
36
37
  "numpy",
@@ -120,7 +120,8 @@ class Analysis(object):
120
120
  ``calc_fext(inc=1., silent=False)``, must return a 1-D array with the
121
121
  external force vector. Required for linear and non-linear static
122
122
  analyses. The non-linear solvers call it with ``inc=1.`` and scale the
123
- returned vector by the load factor.
123
+ returned vector by the load factor. It may declare the optional
124
+ keyword arguments ``c`` and ``lbd``, see the notes below.
124
125
  calc_fint : callable, optional
125
126
  ``calc_fint(c, silent=False)``, must return a 1-D array with the
126
127
  internal force vector for the solution vector ``c``. Required for
@@ -150,6 +151,16 @@ class Analysis(object):
150
151
  configuration ``c`` for a unit load factor, the derivative of the
151
152
  residual with respect to the load factor, used by the arc-length
152
153
  solvers.
154
+ - ``calc_fext(inc=1., c=c, lbd=lbd)`` also receives the current load
155
+ factor when it declares ``lbd``, for a derivative `\partial
156
+ R/\partial \lambda` that depends on the load factor, e.g. prescribed
157
+ displacements imposed exactly as linear constraints, where the
158
+ reference vector ``calc_fext(inc=1.)`` may be null. The solvers then
159
+ use `\partial R/\partial \lambda` for the displacement scale of the
160
+ arc-length methods, for the predictor of the first Newton-Raphson
161
+ increment and as a reference of the relative convergence criterion,
162
+ see :mod:`.callbacks`. The linear static analysis never passes
163
+ ``lbd``.
153
164
 
154
165
  The tangent stiffness matrix may then be unsymmetric, which the solvers
155
166
  support, since they use LU factorizations.
@@ -110,6 +110,20 @@ def _solver_arc_length(an, method, silent=False):
110
110
  solution of the reference vector. The callables that accept ``inc``
111
111
  receive the current load factor, see :mod:`.callbacks`.
112
112
 
113
+ When ``calc_fext`` also accepts the keyword argument ``lbd``, e.g. for
114
+ prescribed displacements imposed exactly, it receives the load factor
115
+ together with the configuration, `\{F_{ext}\}` above being then
116
+ `\partial R/\partial \lambda` at the current `c` and `\lambda`;
117
+ `\{u_{ref}\}` is the linear solution of `\partial R/\partial \lambda`
118
+ at `c = 0`, `\lambda = 0`, since the reference vector may be null, e.g.
119
+ 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.
126
+
113
127
  The initial arc length corresponds to a load factor increment of
114
128
  ``an.initialInc`` along the initial tangent. The convergence and
115
129
  divergence checks are the same as in the Newton-Raphson solver, see
@@ -136,7 +150,13 @@ def _solver_arc_length(an, method, silent=False):
136
150
 
137
151
  fext = an.calc_fext(inc=1., silent=True)
138
152
  kC0 = an.calc_kC(silent=silent)
139
- u_ref = solve(kC0, fext, silent=True)
153
+ lbd_dependent = callbacks.is_load_factor_dependent(an)
154
+ if lbd_dependent:
155
+ # dR/dlbd at c = 0 and lbd = 0
156
+ q0 = callbacks.calc_load_vector(an, np.zeros_like(fext), fext, 0.)
157
+ u_ref = solve(kC0, q0, silent=True)
158
+ else:
159
+ u_ref = solve(kC0, fext, silent=True)
140
160
  scale2 = u_ref.dot(u_ref)
141
161
  if not scale2 > 0:
142
162
  scale2 = 1.
@@ -175,7 +195,8 @@ def _solver_arc_length(an, method, silent=False):
175
195
  level=1, silent=silent)
176
196
  break
177
197
  # load vector of the converged configuration, dR/dlbd
178
- dc_q_conv = solve_kT_conv(callbacks.calc_load_vector(an, c, fext))
198
+ q_conv = callbacks.calc_load_vector(an, c, fext, lbd)
199
+ dc_q_conv = solve_kT_conv(q_conv)
179
200
  norm_q = np.sqrt(inner(dc_q_conv, 1., dc_q_conv, 1.))
180
201
  if arc_length is None:
181
202
  arc_length = an.initialInc*norm_q
@@ -204,8 +225,15 @@ def _solver_arc_length(an, method, silent=False):
204
225
  fint = callbacks.calc_fint(an, c + dc, lbd + dlbd)
205
226
  fext_total = (lbd + dlbd)*fext
206
227
  R = fext_total - fint
228
+ q_ref = None
229
+ 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
+ q_ref = (lbd + dlbd)*q_iter
207
234
  conv, Rnorm, Rrel = _check_convergence(an, R, fint, fext_total,
208
- fext_ref=dlbd*fext)
235
+ fext_ref=dlbd*fext,
236
+ q_ref=q_ref)
209
237
  msg('Iteration: %d, lbd %1.5f, max(|R|) = %1.3e, relative ||R|| = %1.3e'
210
238
  % (iteration, lbd + dlbd, np.abs(R).max(), Rrel), level=2, silent=silent)
211
239
  if conv:
@@ -225,7 +253,11 @@ def _solver_arc_length(an, method, silent=False):
225
253
  except RuntimeError:
226
254
  warn('Singular tangent stiffness matrix', level=2, silent=silent)
227
255
  break
228
- dc_q = solve_kT(callbacks.calc_load_vector(an, c + dc, fext))
256
+ if lbd_dependent:
257
+ dc_q = solve_kT(q_iter)
258
+ else:
259
+ dc_q = solve_kT(callbacks.calc_load_vector(an, c + dc,
260
+ fext))
229
261
  iter_kT = 1
230
262
  dc_R = solve_kT(R)
231
263
 
@@ -261,7 +293,8 @@ def _solver_arc_length(an, method, silent=False):
261
293
  if converged and lbd + dlbd > 1. and lbd < 1.:
262
294
  msg('Load factor 1.0 exceeded, correcting to load factor 1.0...', level=1, silent=silent)
263
295
  t = (1. - lbd)/dlbd
264
- converged, dc1 = _NR_iterations(an, c, t*dc, 1., fext, kT0=None, silent=silent)
296
+ converged, dc1 = _NR_iterations(an, c, t*dc, 1., fext, kT0=None,
297
+ silent=silent, lbd_dependent=lbd_dependent)
265
298
  if converged:
266
299
  msg('Converged at load factor 1.00000', level=2, silent=silent)
267
300
  an.cs.append((c + dc1).copy())
@@ -11,7 +11,9 @@ MKL 2025.0.1 or newer is not affected, neither are the non-symmetric drivers
11
11
  of :func:`scipy.sparse.linalg.eigs`, which do not call ``dsteqr``.
12
12
 
13
13
  :func:`capped_eigsh` keeps ``ncv <= 32`` when :func:`arpack_max_ncv` says so,
14
- computing more than 15 eigenpairs in several calls with deflation.
14
+ computing more than 15 eigenpairs in several calls with deflation, whose
15
+ eigenvectors are combined with a Rayleigh-Ritz projection such that they
16
+ are orthonormal also across the calls.
15
17
 
16
18
  The cap is decided by :func:`arpack_max_ncv` and can be overridden with
17
19
  the module attribute :data:`ARPACK_MAX_NCV` or the environment variable
@@ -32,6 +34,7 @@ import sys
32
34
  import warnings
33
35
 
34
36
  import numpy as np
37
+ from scipy.linalg import cholesky, eigh, solve_triangular
35
38
  from scipy.sparse import csc_matrix
36
39
  from scipy.sparse.linalg import LinearOperator, splu
37
40
  from scipy.sparse.linalg import eigsh as _scipy_eigsh
@@ -281,6 +284,34 @@ def _deflation_shift(w, far_shift):
281
284
  return 0.5*(last[i] + last[i + 1])
282
285
 
283
286
 
287
+ def _rayleigh_ritz(A, M, V):
288
+ r"""Rayleigh-Ritz projection of `[A]\{x\} = w [M]\{x\}` onto `[V]`
289
+
290
+ The eigenvectors of different passes of :func:`capped_eigsh` are only
291
+ `[M]`-orthogonal to the accuracy of ARPACK, e.g. `10^{-6}` between
292
+ close eigenvalues of different passes. `[V]` is `[M]`-orthonormalized
293
+ with the Cholesky factor `[L]` of `[V]^T[M][V]`, and the projected
294
+ problem `[W]^T[A][W]\{y\} = w\{y\}`, with `[W] = [V][L]^{-T}`, is
295
+ solved with the LAPACK driver ``dsyevr`` (``driver='evr'``), which does
296
+ not use the ``dsteqr`` of Intel MKL broken above 32 x 32 matrices, see
297
+ :func:`dsteqr_is_faulty`. The returned eigenvectors `[W]\{y\}` are
298
+ `[M]`-orthonormal to the rounding errors, and the Ritz values are at
299
+ least as accurate as the eigenvalues of the passes.
300
+
301
+ Returns
302
+ -------
303
+ w, x : ndarray
304
+ The Ritz values by increasing value and the Ritz vectors.
305
+
306
+ """
307
+ MV = M @ V
308
+ L = cholesky(0.5*(V.T @ MV + MV.T @ V), lower=True)
309
+ W = solve_triangular(L, V.T, lower=True).T
310
+ H = W.T @ (A @ W)
311
+ w, Y = eigh(0.5*(H + H.T), driver='evr')
312
+ return w, W @ Y
313
+
314
+
284
315
  def capped_eigsh(A, k, M, sigma, which, mode='normal', tol=0):
285
316
  r""":func:`scipy.sparse.linalg.eigsh` with ``ncv`` from :func:`arpack_ncv`
286
317
 
@@ -312,7 +343,9 @@ def capped_eigsh(A, k, M, sigma, which, mode='normal', tol=0):
312
343
  in the study of the panels cylinders it was 30 to 45% faster and gave
313
344
  eigenvalues 5 to 10 times more accurate. Each pass factorizes
314
345
  `[A] - \sigma_d [M]` with SuperLU, and every call starts from
315
- :func:`start_vector`.
346
+ :func:`start_vector`. The eigenvectors of all the passes are finally
347
+ combined with a Rayleigh-Ritz projection, see :func:`_rayleigh_ritz`,
348
+ such that they are `[M]`-orthonormal also across the passes.
316
349
 
317
350
  Returns
318
351
  -------
@@ -359,5 +392,4 @@ def capped_eigsh(A, k, M, sigma, which, mode='normal', tol=0):
359
392
  w = np.concatenate((w, wi))
360
393
  V = np.hstack((V, Vi))
361
394
  release_memory()
362
- order = np.argsort(w, kind='stable')
363
- return w[order], V[:, order]
395
+ return _rayleigh_ritz(A, M, V)
@@ -0,0 +1,154 @@
1
+ r"""Calls to the callables of :class:`.Analysis` used by the non-linear solvers
2
+
3
+ The callables may depend on the load factor `\lambda` and on the
4
+ configuration through their optional keyword arguments, which are passed only
5
+ when the callable declares them explicitly in its signature (a ``**kwargs``
6
+ does not count), such that the callables written for the previous versions
7
+ are called exactly as before:
8
+
9
+ - ``inc``: the current load factor `\lambda`, passed to ``calc_fint``,
10
+ ``calc_kC`` and ``calc_kG``. It allows configuration-dependent loads, e.g.
11
+ follower pressures, whose configuration-dependent part enters
12
+ ``calc_fint`` and whose load stiffness enters ``calc_kC`` or ``calc_kG``,
13
+ both scaled by `\lambda`. The residual remains
14
+
15
+ .. math::
16
+
17
+ \{R(c, \lambda)\} = \lambda \{F_{ext}\} - \{F_{int}(c, \lambda)\}
18
+
19
+ with ``calc_kC + calc_kG`` its exact Jacobian `-\partial R/\partial c`.
20
+
21
+ - ``c``: the current configuration, passed to ``calc_fext``, which must then
22
+ return the load vector of that configuration for a unit load factor, i.e.
23
+ `\partial R/\partial \lambda` at `c`. The arc-length solvers use it in the
24
+ predictor and in the constraint instead of the constant reference vector
25
+ ``calc_fext(inc=1.)``.
26
+
27
+ - ``lbd``: the current load factor `\lambda`, passed to ``calc_fext``
28
+ together with ``c``, for a load vector `\partial R/\partial \lambda` that
29
+ depends on the load factor as well, e.g. prescribed displacements imposed
30
+ exactly as linear constraints `c = [T] c_r + \{c_{p0}\} + \lambda
31
+ \{c_{p1}\}`, whose reduced residual
32
+
33
+ .. math::
34
+
35
+ \{R_r(c_r, \lambda)\} = [T]^T \left( \lambda \{F_{ext}\} -
36
+ \{F_{int}([T] c_r + \{c_{p0}\} + \lambda \{c_{p1}\})\} \right)
37
+
38
+ has `\partial R_r/\partial \lambda = [T]^T(\{F_{ext}\} - [K_T]
39
+ \{c_{p1}\})`, with `[K_T]` evaluated at the current configuration and
40
+ load factor. The keyword ``inc`` keeps its meaning, the factor that
41
+ multiplies the returned vector, and the solvers keep calling
42
+ ``calc_fext(inc=1., ...)``. When ``calc_fext`` declares ``lbd``:
43
+
44
+ - the arc-length solvers pass ``lbd`` with ``c`` in the predictor and in
45
+ the constraint;
46
+ - the arc-length solvers scale the displacements with the linear solution
47
+ of `\partial R/\partial \lambda` at `c = 0, \lambda = 0`, instead of
48
+ the linear solution of the reference vector ``calc_fext(inc=1.)``, and
49
+ the Newton-Raphson solver uses this linear solution as the predictor of
50
+ its first step;
51
+ - the relative convergence criterion also considers the norm of
52
+ `\lambda \, \partial R/\partial \lambda`, see
53
+ :func:`.newton_raphson._check_convergence`, since under a prescribed
54
+ displacement alone the reference vector and the reduced internal force
55
+ vector both vanish at equilibrium.
56
+
57
+ ``lbd`` is never passed by the linear static analysis,
58
+ ``Analysis.static(NLgeom=False)``.
59
+
60
+ """
61
+ import inspect
62
+
63
+
64
+ def accepts_kwarg(func, name):
65
+ """Tell whether ``func`` declares the keyword argument ``name``"""
66
+ try:
67
+ sig = inspect.signature(func)
68
+ except (TypeError, ValueError):
69
+ return False
70
+ par = sig.parameters.get(name)
71
+ return par is not None and par.kind in (par.POSITIONAL_OR_KEYWORD,
72
+ par.KEYWORD_ONLY)
73
+
74
+
75
+ def calc_fint(an, c, lbd):
76
+ r"""``an.calc_fint`` at ``c``, with ``inc=lbd`` when accepted"""
77
+ kwargs = dict(inc=lbd) if accepts_kwarg(an.calc_fint, 'inc') else {}
78
+ return an.calc_fint(c=c, silent=True, **kwargs)
79
+
80
+
81
+ def calc_kT(an, c, lbd):
82
+ r"""Tangent stiffness matrix ``calc_kC + calc_kG`` at ``c``, with
83
+ ``inc=lbd`` passed to the callables that accept it"""
84
+ kwC = dict(inc=lbd) if accepts_kwarg(an.calc_kC, 'inc') else {}
85
+ kwG = dict(inc=lbd) if accepts_kwarg(an.calc_kG, 'inc') else {}
86
+ kC = an.calc_kC(c=c, NLgeom=True, silent=True, **kwC)
87
+ kG = an.calc_kG(c=c, NLgeom=True, silent=True, **kwG)
88
+ return kC + kG
89
+
90
+
91
+ def is_load_configuration_dependent(an):
92
+ """Tell whether ``an.calc_fext`` accepts the configuration ``c``"""
93
+ return accepts_kwarg(an.calc_fext, 'c')
94
+
95
+
96
+ def is_load_factor_dependent(an):
97
+ """Tell whether ``an.calc_fext`` accepts the load factor ``lbd``"""
98
+ return accepts_kwarg(an.calc_fext, 'lbd')
99
+
100
+
101
+ def calc_load_vector(an, c, fext, lbd=None):
102
+ r"""Derivative of the residual with respect to the load factor
103
+
104
+ Returns `\{q\} = \partial R/\partial \lambda` at the configuration ``c``
105
+ and the load factor ``lbd``, used by the solvers in the predictors, in
106
+ the arc-length constraint and in the convergence criterion. It is the
107
+ external load vector only for loads that do not depend on the
108
+ configuration; with prescribed displacements imposed exactly it also
109
+ contains `-[T]^T [K_T] \{c_{p1}\}`, see the module docstring.
110
+
111
+ With prescribed displacements, `\{q\}` is not their reaction force: it
112
+ has the size of the reduced problem and acts on the free degrees of
113
+ freedom. It is the out-of-balance force produced by a unit increment of
114
+ the load factor at the current configuration, with the free degrees of
115
+ freedom held fixed. The reaction is a quantity of the full problem,
116
+ e.g. the generalized reaction work-conjugate to `\lambda`, `P =
117
+ \{c_{p1}\}^T (\{F_{int}(c)\} - \lambda \{F_{ext}\})`. ``structsolve``
118
+ does not compute it; see for instance ``MultiDomain.reaction_pd`` of
119
+ `panels`.
120
+
121
+ ``an.calc_fext`` is called with ``inc=1.`` and only with the keyword
122
+ arguments that it declares:
123
+
124
+ - ``c=c``, when it declares ``c``;
125
+ - ``lbd=lbd``, when it declares ``lbd`` and ``lbd`` is not ``None``.
126
+
127
+ When none of them is passed, `\{q\}` is the constant reference vector,
128
+ and ``fext`` is returned without calling ``an.calc_fext``.
129
+
130
+ Parameters
131
+ ----------
132
+ an : :class:`.Analysis`
133
+ Analysis object.
134
+ c : array-like
135
+ Configuration.
136
+ fext : array-like
137
+ Reference vector ``an.calc_fext(inc=1.)``.
138
+ lbd : float or None, optional
139
+ Load factor.
140
+
141
+ Returns
142
+ -------
143
+ q : array-like
144
+ `\partial R/\partial \lambda`.
145
+
146
+ """
147
+ kwargs = {}
148
+ if is_load_configuration_dependent(an):
149
+ kwargs['c'] = c
150
+ if lbd is not None and is_load_factor_dependent(an):
151
+ kwargs['lbd'] = lbd
152
+ if not kwargs:
153
+ return fext
154
+ return an.calc_fext(inc=1., silent=True, **kwargs)
@@ -862,9 +862,10 @@ def lb(K, KG, tol=0, sparse_solver=True, silent=False,
862
862
  MKL versions returns wrong eigenvectors for matrices larger than
863
863
  32 x 32, which ARPACK uses with the size ``ncv``. When such a LAPACK is
864
864
  detected, ``ncv`` is capped at 32 and more than 15 eigenpairs are
865
- computed in several passes, see :mod:`structsolve.arpackutils`, where
866
- the cap can be overridden, e.g. with the environment variable
867
- ``STRUCTSOLVE_ARPACK_MAX_NCV=0`` to lift it with MKL 2025.0.1 or newer.
865
+ computed in several passes, combined with a Rayleigh-Ritz projection,
866
+ see :mod:`structsolve.arpackutils`, where the cap can be overridden,
867
+ e.g. with the environment variable ``STRUCTSOLVE_ARPACK_MAX_NCV=0`` to
868
+ lift it with MKL 2025.0.1 or newer.
868
869
  The non-symmetric drivers of :func:`scipy.sparse.linalg.eigs` are not
869
870
  affected.
870
871
 
@@ -32,13 +32,14 @@ INC_GROWTH_FACTOR = 1.1111
32
32
  INC_CUT_FACTOR = 0.5
33
33
 
34
34
 
35
- def _check_convergence(an, R, fint, fext_total, fext_ref=None):
36
- """Check the convergence of the residual force vector
35
+ def _check_convergence(an, R, fint, fext_total, fext_ref=None, q_ref=None):
36
+ r"""Check the convergence of the residual force vector
37
37
 
38
38
  The convergence is achieved when either:
39
39
 
40
- - ``||R|| <= relTOL*max(||fext_total||, ||fint||, ||fext_ref||)``, using
41
- the Euclidean norm, if ``an.relTOL`` is not ``None``
40
+ - ``||R|| <= relTOL*max(||fext_total||, ||fint||, ||fext_ref||,
41
+ ||q_ref||)``, using the Euclidean norm, if ``an.relTOL`` is not
42
+ ``None``
42
43
  - ``max(|R|) <= absTOL``, if ``an.absTOL`` is not ``None``
43
44
 
44
45
  Parameters
@@ -54,6 +55,12 @@ def _check_convergence(an, R, fint, fext_total, fext_ref=None):
54
55
  fext_ref : array-like or None, optional
55
56
  Additional reference force vector, e.g. the external force vector of
56
57
  the current increment, useful when the load factor is close to zero.
58
+ q_ref : array-like or None, optional
59
+ Additional reference force vector, the load factor times
60
+ `\partial R/\partial \lambda` when ``calc_fext`` accepts ``lbd``,
61
+ see :mod:`.callbacks`. Under a prescribed displacement alone, the
62
+ external and the internal force vectors of the reduced problem both
63
+ vanish at equilibrium and cannot be the reference.
57
64
 
58
65
  Returns
59
66
  -------
@@ -62,7 +69,8 @@ def _check_convergence(an, R, fint, fext_total, fext_ref=None):
62
69
  Rnorm : float
63
70
  Euclidean norm of the residual.
64
71
  Rrel : float
65
- Relative residual ``||R||/max(||fext_total||, ||fint||, ||fext_ref||)``.
72
+ Relative residual ``||R||/max(||fext_total||, ||fint||, ||fext_ref||,
73
+ ||q_ref||)``.
66
74
 
67
75
  """
68
76
  if an.relTOL is None and an.absTOL is None:
@@ -71,6 +79,8 @@ def _check_convergence(an, R, fint, fext_total, fext_ref=None):
71
79
  ref = max(np.linalg.norm(fext_total), np.linalg.norm(fint))
72
80
  if fext_ref is not None:
73
81
  ref = max(ref, np.linalg.norm(fext_ref))
82
+ if q_ref is not None:
83
+ ref = max(ref, np.linalg.norm(q_ref))
74
84
  Rrel = Rnorm/ref if ref > 0 else Rnorm
75
85
  converged = False
76
86
  if an.relTOL is not None and Rnorm <= an.relTOL*ref:
@@ -128,8 +138,9 @@ def _check_divergence(an, Rnorm, Rnorms, iteration, silent=False):
128
138
  return False
129
139
 
130
140
 
131
- def _NR_iterations(an, c, dc, total, fext, kT0=None, silent=False):
132
- """Newton-Raphson iterations at a fixed load factor
141
+ def _NR_iterations(an, c, dc, total, fext, kT0=None, silent=False,
142
+ lbd_dependent=False):
143
+ r"""Newton-Raphson iterations at a fixed load factor
133
144
 
134
145
  Parameters
135
146
  ----------
@@ -148,6 +159,11 @@ def _NR_iterations(an, c, dc, total, fext, kT0=None, silent=False):
148
159
  ``an.modified_NR=True``. If ``None`` it is calculated at ``c + dc``.
149
160
  silent : bool, optional
150
161
  A boolean to tell whether the log messages should be printed.
162
+ lbd_dependent : bool, optional
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`.
151
167
 
152
168
  Returns
153
169
  -------
@@ -159,6 +175,7 @@ def _NR_iterations(an, c, dc, total, fext, kT0=None, silent=False):
159
175
  """
160
176
  modified_NR = an.modified_NR
161
177
  fext_total = total*fext
178
+ q_ref = None
162
179
  Rnorms = []
163
180
  iter_kT = 0
164
181
  kT = None
@@ -170,8 +187,11 @@ def _NR_iterations(an, c, dc, total, fext, kT0=None, silent=False):
170
187
  if fint is None:
171
188
  fint = callbacks.calc_fint(an, c + dc, total)
172
189
  R = fext_total - fint
190
+ if lbd_dependent:
191
+ q_ref = total*callbacks.calc_load_vector(an, c + dc, fext, total)
173
192
 
174
- conv, Rnorm, Rrel = _check_convergence(an, R, fint, fext_total)
193
+ conv, Rnorm, Rrel = _check_convergence(an, R, fint, fext_total,
194
+ q_ref=q_ref)
175
195
  msg('Iteration: %d, max(|R|) = %1.3e, relative ||R|| = %1.3e'
176
196
  % (iteration, np.abs(R).max(), Rrel), level=2, silent=silent)
177
197
  if conv:
@@ -235,6 +255,17 @@ def _solver_NR(an, silent=False, initialInc=None):
235
255
  factor `\lambda` of the step, see :mod:`.callbacks`, which allows
236
256
  configuration-dependent loads such as follower pressures.
237
257
 
258
+ When ``calc_fext`` accepts the keyword argument ``lbd``, e.g. for
259
+ prescribed displacements imposed exactly, see :mod:`.callbacks`, the
260
+ load vector `\{q\} = \partial R/\partial \lambda` at `c = 0`,
261
+ `\lambda = 0` gives the predictor of the first increment, its linear
262
+ solution `[K_0]^{-1}\{q\}`, instead of the linear solution of the
263
+ 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.
268
+
238
269
  By default full Newton-Raphson is used, i.e. the tangent stiffness matrix
239
270
  ``kT = kC + kG`` is rebuilt at every iteration, which gives quadratic
240
271
  convergence when ``kT`` is the exact Jacobian of ``fint``. With
@@ -283,8 +314,12 @@ def _solver_NR(an, silent=False, initialInc=None):
283
314
 
284
315
  fext = an.calc_fext(inc=1., silent=True)
285
316
  kC0 = an.calc_kC(silent=silent)
317
+ q = None
318
+ if callbacks.is_load_factor_dependent(an):
319
+ # dR/dlbd at c = 0 and lbd = 0
320
+ q = callbacks.calc_load_vector(an, np.zeros_like(fext), fext, 0.)
286
321
  # predictor of the first step: linear solution scaled by the load factor
287
- dc_last = solve(kC0, fext, silent=True)
322
+ dc_last = solve(kC0, fext if q is None else q, silent=True)
288
323
  inc_last = 1.
289
324
  c = np.zeros_like(dc_last)
290
325
  total_last = 0.
@@ -305,7 +340,8 @@ def _solver_NR(an, silent=False, initialInc=None):
305
340
  if an.modified_NR and step_num == 1 and not an.kT_initial_state:
306
341
  kT0 = kC0
307
342
  converged, dc = _NR_iterations(an, c, dc, total, fext, kT0=kT0,
308
- silent=silent)
343
+ silent=silent,
344
+ lbd_dependent=q is not None)
309
345
 
310
346
  if converged:
311
347
  msg('Converged at load factor %1.5f' % total, level=2, silent=silent)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: structsolve
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Structural analysis solvers
5
5
  Author-email: "Saullo G. P. Castro" <castrosaullo@gmail.com>
6
6
  License: BSD-2-Clause
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Operating System :: Microsoft :: Windows
23
23
  Classifier: Operating System :: Unix
24
+ Classifier: Environment :: WebAssembly :: Emscripten
24
25
  Requires-Python: >=3.8
25
26
  Description-Content-Type: text/markdown
26
27
  License-File: LICENSE
@@ -53,6 +54,8 @@ Structural analysis solvers tailored for semi-analytical models
53
54
  - Nonlinear statics using Newton-Raphson
54
55
  - Nonlinear statics using the Arc-Length method
55
56
  - Support for unsymmetric problems, such as those involving follower forces
57
+ - Load vectors that depend on the load factor, e.g. prescribed displacements
58
+ imposed exactly as linear constraints
56
59
 
57
60
  Currently these solvers are pretty much compatible with my other repositories
58
61
  [panels](https://github.com/saullocastro/panels),
@@ -62,7 +65,7 @@ Currently these solvers are pretty much compatible with my other repositories
62
65
  Citing this library
63
66
  ===================
64
67
 
65
- Saullo G. P. Castro (2026). Structural analysis solvers tailored for semi-analytical models (Version 0.6.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.7.0). Zenodo. DOI: https://doi.org/10.5281/zenodo.2581212.
66
69
 
67
70
 
68
71
  Documentation
@@ -77,9 +80,12 @@ History
77
80
 
78
81
  See [CHANGELOG.md](CHANGELOG.md) for the details of each version.
79
82
 
80
- * version 0.6.0 (2026-10-08)
83
+ * version 0.7.0 (2026-10-13)
84
+ - `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
+ * version 0.6.1 (2026-10-08)
86
+ - Pyodide support: `await micropip.install("structsolve")` in the browser, and the whole test suite runs in Pyodide in GitHub Actions
81
87
  - `lb` and `freq` always run the sparse solvers first, whatever the size of the problem, with verified results; the dense solvers are only a last resort within `max_dense_size`, with a `DenseFallbackWarning`
82
- - ARPACK's `ncv` is capped at 32 when SciPy is linked against the Intel MKL versions whose `dsteqr` is faulty (new `structsolve.arpackutils`)
88
+ - ARPACK's `ncv` is capped at 32 when SciPy is linked against the Intel MKL versions whose `dsteqr` is faulty (new `structsolve.arpackutils`), computing more than 15 eigenpairs in passes whose eigenvectors are made orthonormal with a Rayleigh-Ritz projection
83
89
  - Unsymmetric `lb`: a shift search finds the lowest positive real load multiplier, instead of silently returning negative or complex ones
84
90
  - The test extra no longer requires `buckling` and `composites`
85
91
  * version 0.5.3 (2026-10-06)
@@ -27,6 +27,7 @@ tests/test_follower_loads.py
27
27
  tests/test_freq.py
28
28
  tests/test_linear_buckling.py
29
29
  tests/test_linear_buckling_unsymmetric.py
30
+ tests/test_load_factor_dependent_load.py
30
31
  tests/test_newton_raphson.py
31
32
  tests/test_sparse_first.py
32
33
  tests/test_sparseutils.py
@@ -217,6 +217,9 @@ def test_lb_nearly_symmetric(sparse_solver, max_dense_size):
217
217
  assert np.all(relative_residuals(Kn, KG, eigvals, eigvecs) < 1e-8)
218
218
 
219
219
 
220
+ @pytest.mark.skipif(sys.platform == 'emscripten',
221
+ reason='no subprocesses in Pyodide, whose SciPy is not '
222
+ 'linked to MKL')
220
223
  def test_eig_complex_mkl_crash_regression():
221
224
  """Regression test: with Intel MKL 2025.0.0, scipy.linalg.eig on these
222
225
  real 48 x 48 matrices (LAPACK dggev) corrupts the heap and the process
@@ -0,0 +1,298 @@
1
+ r"""Load vector depending on the load factor: prescribed displacements
2
+
3
+ Three hardening springs in series, `f_i(e) = k_i e + a_i e^3`, between a
4
+ wall and the nodes 1, 2 and 3, with the displacement of node 3 prescribed,
5
+ `x_3 = d_0 + \lambda d_1`, and an optional load `\lambda F` on node 1. The
6
+ constraint is imposed exactly, as in the null-space method of `panels`:
7
+
8
+ .. math::
9
+
10
+ \{c\} = [T]\{c_r\} + \{c_{p0}\} + \lambda \{c_{p1}\}, \qquad
11
+ [T] = \begin{bmatrix} 1 & 0 \\ 0 & 1 \\ 0 & 0 \end{bmatrix}, \qquad
12
+ \{c_{p0}\} = \{0, 0, d_0\}, \qquad \{c_{p1}\} = \{0, 0, d_1\}
13
+
14
+ such that the reduced problem has two degrees of freedom and the residual
15
+
16
+ .. math::
17
+
18
+ \{R_r(c_r, \lambda)\} = [T]^T \left(\lambda \{F_{ext}\} -
19
+ \{F_{int}(c)\}\right), \qquad
20
+ \frac{\partial R_r}{\partial \lambda} = [T]^T \left(\{F_{ext}\} -
21
+ [K_T(c)] \{c_{p1}\}\right)
22
+
23
+ whose derivative with respect to the load factor depends on the
24
+ configuration and on the load factor, passed to :class:`structsolve.Analysis`
25
+ with ``calc_fext(inc=1., c=c_r, lbd=lbd)``, see :mod:`structsolve.callbacks`.
26
+ Under the prescribed displacement alone, `F = 0`, the reference vector
27
+ ``calc_fext(inc=1.)`` is null.
28
+
29
+ """
30
+ import numpy as np
31
+ import pytest
32
+ from scipy.optimize import brentq
33
+ from scipy.sparse import csr_matrix
34
+
35
+ from structsolve import Analysis
36
+ from structsolve import callbacks
37
+
38
+
39
+ T = np.array([[1., 0.], [0., 1.], [0., 0.]])
40
+
41
+
42
+ class SpringsProblem(object):
43
+ def __init__(self, k=(100., 150., 200.), a=(20., 40., 60.), d0=0.,
44
+ d1=1., F=0., with_lbd=True):
45
+ self.k = np.asarray(k)
46
+ self.a = np.asarray(a)
47
+ self.cp0 = np.array([0., 0., d0])
48
+ self.cp1 = np.array([0., 0., d1])
49
+ self.Fext = np.array([F, 0., 0.])
50
+ self.with_lbd = with_lbd
51
+ self.an = None
52
+ # (lbd, c_r) of each call to calc_fext with lbd, and (inc, ||R_r||,
53
+ # number of converged increments) of each call to calc_fint
54
+ self.fext_calls = []
55
+ self.fint_calls = []
56
+
57
+ def full(self, c_r, lbd):
58
+ return T @ c_r + self.cp0 + lbd*self.cp1
59
+
60
+ def elongations(self, c):
61
+ return np.array([c[0], c[1] - c[0], c[2] - c[1]])
62
+
63
+ def fint_full(self, c):
64
+ k, a = self.k, self.a
65
+ f = k*self.elongations(c) + a*self.elongations(c)**3
66
+ return np.array([f[0] - f[1], f[1] - f[2], f[2]])
67
+
68
+ def kT_full(self, c):
69
+ ke = self.k + 3*self.a*self.elongations(c)**2
70
+ return np.array([[ke[0] + ke[1], -ke[1], 0.],
71
+ [-ke[1], ke[1] + ke[2], -ke[2]],
72
+ [0., -ke[2], ke[2]]])
73
+
74
+ def residual(self, c_r, lbd):
75
+ return T.T @ (lbd*self.Fext - self.fint_full(self.full(c_r, lbd)))
76
+
77
+ def dR_dlbd(self, c_r, lbd):
78
+ c = self.full(c_r, lbd)
79
+ return T.T @ (self.Fext - self.kT_full(c) @ self.cp1)
80
+
81
+ def reaction(self, c_r, lbd):
82
+ r"""Force of the support on node 3, `c_{p1}^T r / d_1`, with `r =
83
+ F_{int}(c) - \lambda F_{ext}`"""
84
+ c = self.full(c_r, lbd)
85
+ r = self.fint_full(c) - lbd*self.Fext
86
+ return self.cp1 @ r/self.cp1[2]
87
+
88
+ # callables of structsolve.Analysis
89
+ def calc_fext(self, inc=1., silent=True, c=None, lbd=None):
90
+ if c is None:
91
+ return inc*(T.T @ self.Fext)
92
+ self.fext_calls.append((lbd, c.copy()))
93
+ return inc*self.dR_dlbd(c, lbd)
94
+
95
+ def calc_fint(self, c, inc=1., silent=True):
96
+ step = len(self.an.increments) if self.an is not None else 0
97
+ self.fint_calls.append((inc, np.linalg.norm(self.residual(c, inc)),
98
+ step))
99
+ return T.T @ self.fint_full(self.full(c, inc))
100
+
101
+ def calc_kC(self, c=None, NLgeom=False, inc=1., silent=True):
102
+ if c is None or not NLgeom:
103
+ return csr_matrix(T.T @ self.kT_full(np.zeros(3)) @ T)
104
+ return csr_matrix(T.T @ self.kT_full(self.full(c, inc)) @ T)
105
+
106
+ def calc_kG(self, c=None, NLgeom=False, silent=True):
107
+ return csr_matrix((2, 2))
108
+
109
+ def analysis(self, **kwargs):
110
+ if self.with_lbd:
111
+ calc_fext = self.calc_fext
112
+ else:
113
+ calc_fext = lambda inc=1., silent=True: self.calc_fext(inc)
114
+ an = Analysis(calc_fext, self.calc_fint, self.calc_kC, self.calc_kG)
115
+ for key, value in kwargs.items():
116
+ setattr(an, key, value)
117
+ self.an = an
118
+ return an
119
+
120
+ def exact(self, lbd):
121
+ r"""Solution without the load on node 1: the three springs carry the
122
+ same force `P`, found from `\sum_i e_i(P) = x_3`"""
123
+ assert self.Fext[0] == 0.
124
+ x3 = self.cp0[2] + lbd*self.cp1[2]
125
+
126
+ def e(P, i):
127
+ return brentq(lambda ei: self.k[i]*ei + self.a[i]*ei**3 - P,
128
+ -1e4, 1e4, xtol=1e-15, rtol=1e-15)
129
+
130
+ def gap(P):
131
+ return sum(e(P, i) for i in range(3)) - x3
132
+
133
+ P = 0. if x3 == 0 else brentq(gap, -1e4, 1e4, xtol=1e-14,
134
+ rtol=1e-15)
135
+ e1, e2 = e(P, 0), e(P, 1)
136
+ return np.array([e1, e1 + e2]), P
137
+
138
+ def solve_reference(self, lbd, tol=1e-14):
139
+ """Dense Newton-Raphson with continuation on the load factor"""
140
+ c_r = np.zeros(2)
141
+ for l in np.linspace(0, lbd, 21)[1:]:
142
+ for _ in range(100):
143
+ R = self.residual(c_r, l)
144
+ if np.linalg.norm(R) <= tol*max(1., np.linalg.norm(
145
+ self.dR_dlbd(c_r, l))):
146
+ break
147
+ kT = T.T @ self.kT_full(self.full(c_r, l)) @ T
148
+ c_r = c_r + np.linalg.solve(kT, R)
149
+ return c_r
150
+
151
+
152
+ def test_calc_load_vector_passes_lbd_only_when_declared():
153
+ fext = np.array([1., 2.])
154
+ calls = []
155
+
156
+ class A(object):
157
+ calc_fext = None
158
+
159
+ def with_c(inc=1., silent=True, c=None):
160
+ calls.append(('c', c))
161
+ return fext
162
+
163
+ def with_c_lbd(inc=1., silent=True, c=None, lbd=None):
164
+ calls.append(('c, lbd', c, lbd))
165
+ return fext
166
+
167
+ def with_kwargs(inc=1., silent=True, c=None, **kwargs):
168
+ calls.append(('c, **kwargs', c, kwargs))
169
+ return fext
170
+
171
+ def without(inc=1., silent=True):
172
+ calls.append('without')
173
+ return fext
174
+
175
+ an = A()
176
+ c = np.array([3., 4.])
177
+ for func in (with_c, with_c_lbd, with_kwargs, without):
178
+ an.calc_fext = func
179
+ callbacks.calc_load_vector(an, c, fext, 0.5)
180
+ assert calls[0] == ('c', c)
181
+ assert calls[1] == ('c, lbd', c, 0.5)
182
+ # a **kwargs does not count, as for inc
183
+ assert calls[2] == ('c, **kwargs', c, {})
184
+ # the constant reference vector is returned without calling calc_fext
185
+ assert len(calls) == 3
186
+ assert not callbacks.is_load_factor_dependent(A())
187
+ an.calc_fext = with_c_lbd
188
+ assert callbacks.is_load_factor_dependent(an)
189
+
190
+
191
+ @pytest.mark.parametrize('method', ['NR', 'arc_length_riks',
192
+ 'arc_length_crisfield'])
193
+ def test_displacement_control_relative_criterion(method):
194
+ r"""The prescribed displacement alone, with the relative convergence
195
+ criterion only: every converged increment is the exact solution, and
196
+ ``calc_fext`` receives the load factor of the configuration"""
197
+ pb = SpringsProblem(d1=0.8)
198
+ an = pb.analysis(NL_method=method, initialInc=0.2, relTOL=1e-10,
199
+ absTOL=None)
200
+ an.static(NLgeom=True, silent=True)
201
+ assert np.isclose(an.increments[-1], 1.)
202
+ assert len(an.increments) > 2
203
+ for lbd, c_r in zip(an.increments, an.cs):
204
+ ref, P = pb.exact(lbd)
205
+ assert np.allclose(c_r, ref, rtol=1e-8, atol=1e-12), (lbd, c_r, ref)
206
+ assert np.isclose(pb.reaction(c_r, lbd), P, rtol=1e-8)
207
+ assert (np.linalg.norm(pb.residual(c_r, lbd))
208
+ <= 1e-9*np.linalg.norm(lbd*pb.dR_dlbd(c_r, lbd)))
209
+ # the arc-length solvers evaluate dR/dlbd at the configurations and load
210
+ # factors of their iterations
211
+ if method != 'NR':
212
+ assert len(pb.fext_calls) > len(an.increments)
213
+ lbds = [lbd for lbd, _ in pb.fext_calls]
214
+ assert lbds[0] == 0.
215
+ assert all(lbd is not None for lbd in lbds)
216
+ assert len(set(lbds)) > 2
217
+
218
+
219
+ def test_displacement_control_without_lbd_does_not_converge():
220
+ r"""The reason for ``lbd``: without it, the reference vector and the
221
+ reduced internal force vector vanish at equilibrium, and the relative
222
+ criterion cannot be satisfied"""
223
+ pb = SpringsProblem(d1=0.8, with_lbd=False)
224
+ an = pb.analysis(initialInc=0.2, relTOL=1e-10, absTOL=None,
225
+ maxNumIter=10, minInc=0.01)
226
+ an.static(NLgeom=True, silent=True)
227
+ assert len(an.increments) == 0
228
+
229
+
230
+ def test_newton_raphson_first_predictor_is_linear_solution():
231
+ r"""With linear springs the predictor of the first increment,
232
+ `[K_0]^{-1} \partial R/\partial \lambda`, is the solution: each
233
+ increment converges at its first evaluation of the residual"""
234
+ pb = SpringsProblem(a=(0., 0., 0.), d1=0.5)
235
+ an = pb.analysis(initialInc=0.3, relTOL=1e-12)
236
+ an.static(NLgeom=True, silent=True)
237
+ assert np.isclose(an.increments[-1], 1.)
238
+ steps = [step for _, _, step in pb.fint_calls]
239
+ assert steps.count(0) == 1, pb.fint_calls
240
+ ref, _ = pb.exact(1.)
241
+ assert np.allclose(an.cs[-1], ref, rtol=1e-12)
242
+
243
+
244
+ def orders(errors, floor=1e-12):
245
+ """Convergence orders log(e2/e1)/log(e1/e0) above the round-off floor"""
246
+ return [np.log(e2/e1)/np.log(e1/e0)
247
+ for e0, e1, e2 in zip(errors[:-2], errors[1:-1], errors[2:])
248
+ if e2 > floor and e0 < 0.1]
249
+
250
+
251
+ def test_riks_quadratic_with_load_factor_dependent_load_vector():
252
+ r"""The bordered system of Riks is Newton-Raphson applied to the
253
+ augmented system with the exact `\partial R/\partial \lambda`"""
254
+ pb = SpringsProblem(k=(10., 15., 20.), a=(200., 400., 600.), d1=1.)
255
+ an = pb.analysis(NL_method='arc_length_riks', initialInc=0.5,
256
+ relTOL=1e-13, maxNumIter=60)
257
+ an.static(NLgeom=True, silent=True)
258
+ assert np.isclose(an.increments[-1], 1.)
259
+ first = [e for _, e, step in pb.fint_calls if step == 0]
260
+ ps = orders(first)
261
+ assert len(ps) >= 1, first
262
+ assert min(ps) > 1.7, first
263
+
264
+
265
+ @pytest.mark.parametrize('method', ['NR', 'arc_length_riks',
266
+ 'arc_length_crisfield'])
267
+ def test_constant_and_incremented_prescriptions(method):
268
+ r"""A constant prescribed displacement `d_0`, held during the analysis,
269
+ with a load `\lambda F` on node 1 and an incremented displacement
270
+ `\lambda d_1`"""
271
+ pb = SpringsProblem(d0=-0.3, d1=0.6, F=40.)
272
+ an = pb.analysis(NL_method=method, initialInc=0.2, relTOL=1e-11)
273
+ an.static(NLgeom=True, silent=True)
274
+ assert np.isclose(an.increments[-1], 1.)
275
+ for lbd, c_r in zip(an.increments, an.cs):
276
+ ref = pb.solve_reference(lbd)
277
+ assert np.allclose(c_r, ref, rtol=1e-8, atol=1e-12), (lbd, c_r, ref)
278
+ # at lbd = 0 the constant displacement alone is not in equilibrium with
279
+ # c_r = 0: the first increment starts from a non-null residual
280
+ assert np.linalg.norm(pb.residual(np.zeros(2), 0.)) > 1.
281
+
282
+
283
+ @pytest.mark.parametrize('method', ['NR', 'arc_length_riks',
284
+ 'arc_length_crisfield'])
285
+ def test_null_load_vector_at_the_origin(method):
286
+ r"""The last spring is purely cubic, `f_3 = a_3 e^3`, such that
287
+ `\partial R/\partial \lambda = 0` at `c = 0`, `\lambda = 0`: the
288
+ reference of the relative criterion is `\lambda \partial R/\partial
289
+ \lambda` at the current iterate, not at the origin"""
290
+ pb = SpringsProblem(k=(100., 150., 0.), a=(20., 40., 600.), d1=0.5)
291
+ assert np.linalg.norm(pb.dR_dlbd(np.zeros(2), 0.)) == 0.
292
+ an = pb.analysis(NL_method=method, initialInc=0.2, relTOL=1e-10,
293
+ absTOL=None)
294
+ an.static(NLgeom=True, silent=True)
295
+ assert np.isclose(an.increments[-1], 1.)
296
+ for lbd, c_r in zip(an.increments, an.cs):
297
+ ref, P = pb.exact(lbd)
298
+ assert np.allclose(c_r, ref, rtol=1e-8, atol=1e-12), (lbd, c_r, ref)
@@ -1,68 +0,0 @@
1
- r"""Calls to the callables of :class:`.Analysis` used by the non-linear solvers
2
-
3
- The callables may depend on the load factor `\lambda` and on the
4
- configuration through their optional keyword arguments, which are passed only
5
- when the callable declares them explicitly in its signature (a ``**kwargs``
6
- does not count), such that the callables written for the previous versions
7
- are called exactly as before:
8
-
9
- - ``inc``: the current load factor `\lambda`, passed to ``calc_fint``,
10
- ``calc_kC`` and ``calc_kG``. It allows configuration-dependent loads, e.g.
11
- follower pressures, whose configuration-dependent part enters
12
- ``calc_fint`` and whose load stiffness enters ``calc_kC`` or ``calc_kG``,
13
- both scaled by `\lambda`. The residual remains
14
-
15
- .. math::
16
-
17
- \{R(c, \lambda)\} = \lambda \{F_{ext}\} - \{F_{int}(c, \lambda)\}
18
-
19
- with ``calc_kC + calc_kG`` its exact Jacobian `-\partial R/\partial c`.
20
-
21
- - ``c``: the current configuration, passed to ``calc_fext``, which must then
22
- return the load vector of that configuration for a unit load factor, i.e.
23
- `\partial R/\partial \lambda` at `c`. The arc-length solvers use it in the
24
- predictor and in the constraint instead of the constant reference vector
25
- ``calc_fext(inc=1.)``.
26
-
27
- """
28
- import inspect
29
-
30
-
31
- def accepts_kwarg(func, name):
32
- """Tell whether ``func`` declares the keyword argument ``name``"""
33
- try:
34
- sig = inspect.signature(func)
35
- except (TypeError, ValueError):
36
- return False
37
- par = sig.parameters.get(name)
38
- return par is not None and par.kind in (par.POSITIONAL_OR_KEYWORD,
39
- par.KEYWORD_ONLY)
40
-
41
-
42
- def calc_fint(an, c, lbd):
43
- r"""``an.calc_fint`` at ``c``, with ``inc=lbd`` when accepted"""
44
- kwargs = dict(inc=lbd) if accepts_kwarg(an.calc_fint, 'inc') else {}
45
- return an.calc_fint(c=c, silent=True, **kwargs)
46
-
47
-
48
- def calc_kT(an, c, lbd):
49
- r"""Tangent stiffness matrix ``calc_kC + calc_kG`` at ``c``, with
50
- ``inc=lbd`` passed to the callables that accept it"""
51
- kwC = dict(inc=lbd) if accepts_kwarg(an.calc_kC, 'inc') else {}
52
- kwG = dict(inc=lbd) if accepts_kwarg(an.calc_kG, 'inc') else {}
53
- kC = an.calc_kC(c=c, NLgeom=True, silent=True, **kwC)
54
- kG = an.calc_kG(c=c, NLgeom=True, silent=True, **kwG)
55
- return kC + kG
56
-
57
-
58
- def is_load_configuration_dependent(an):
59
- """Tell whether ``an.calc_fext`` accepts the configuration ``c``"""
60
- return accepts_kwarg(an.calc_fext, 'c')
61
-
62
-
63
- def calc_load_vector(an, c, fext):
64
- r"""`\partial R/\partial \lambda` at ``c``: ``calc_fext(inc=1., c=c)``
65
- when ``calc_fext`` accepts ``c``, otherwise the constant ``fext``"""
66
- if is_load_configuration_dependent(an):
67
- return an.calc_fext(inc=1., c=c, silent=True)
68
- return fext
File without changes
File without changes