PyNiteFEA 3.0.0__tar.gz → 3.1.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 (36) hide show
  1. {pynitefea-3.0.0 → pynitefea-3.1.0}/PKG-INFO +20 -29
  2. {pynitefea-3.0.0 → pynitefea-3.1.0}/PyNiteFEA.egg-info/PKG-INFO +20 -29
  3. {pynitefea-3.0.0 → pynitefea-3.1.0}/PyNiteFEA.egg-info/SOURCES.txt +1 -1
  4. {pynitefea-3.0.0 → pynitefea-3.1.0}/PyNiteFEA.egg-info/requires.txt +11 -13
  5. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Analysis.py +206 -32
  6. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/FEModel3D.py +30 -49
  7. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Member3D.py +92 -47
  8. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/PhysMember.py +19 -1
  9. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Rendering.py +6 -13
  10. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Report_Template.html +86 -86
  11. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Reporting.py +53 -24
  12. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Section.py +0 -1
  13. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/ShearWall.py +15 -7
  14. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Visualization.py +29 -42
  15. {pynitefea-3.0.0 → pynitefea-3.1.0}/README.md +4 -0
  16. pynitefea-3.1.0/pyproject.toml +70 -0
  17. pynitefea-3.0.0/setup.py +0 -40
  18. {pynitefea-3.0.0 → pynitefea-3.1.0}/LICENSE +0 -0
  19. {pynitefea-3.0.0 → pynitefea-3.1.0}/PyNiteFEA.egg-info/dependency_links.txt +0 -0
  20. {pynitefea-3.0.0 → pynitefea-3.1.0}/PyNiteFEA.egg-info/top_level.txt +0 -0
  21. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/BeamSegY.py +0 -0
  22. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/BeamSegZ.py +0 -0
  23. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/FixedEndReactions.py +0 -0
  24. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/LoadCombo.py +0 -0
  25. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/MainStyleSheet.css +0 -0
  26. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/MatFoundation.py +0 -0
  27. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Material.py +0 -0
  28. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Mesh.py +0 -0
  29. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Node3D.py +0 -0
  30. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Plate3D.py +0 -0
  31. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Quad3D.py +0 -0
  32. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Spring3D.py +0 -0
  33. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/Tri3D.py +0 -0
  34. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/VTKWriter.py +0 -0
  35. {pynitefea-3.0.0 → pynitefea-3.1.0}/Pynite/__init__.py +0 -0
  36. {pynitefea-3.0.0 → pynitefea-3.1.0}/setup.cfg +0 -0
@@ -1,55 +1,42 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyNiteFEA
3
- Version: 3.0.0
4
- Summary: A simple elastic 3D structural finite element library for Python.
5
- Home-page: https://github.com/JWock82/Pynite.git
6
- Author: D. Craig Brinck, PE, SE
7
- Author-email: Building.Code@outlook.com
3
+ Version: 3.1.0
4
+ Summary: A simple 3D structural finite element library for Python.
5
+ Author-email: "D. Craig Brinck, PE, SE" <Building.Code@outlook.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/JWock82/Pynite
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.11
10
10
  Classifier: Programming Language :: Python :: 3.12
11
11
  Classifier: Programming Language :: Python :: 3.13
12
- Classifier: License :: OSI Approved :: MIT License
13
12
  Classifier: Operating System :: OS Independent
14
13
  Requires-Python: >=3.11
15
14
  Description-Content-Type: text/markdown
16
15
  License-File: LICENSE
16
+ Requires-Dist: matplotlib
17
17
  Requires-Dist: numpy>=2.4.0
18
- Requires-Dist: PrettyTable
18
+ Requires-Dist: prettytable
19
19
  Requires-Dist: scipy
20
- Requires-Dist: matplotlib
21
20
  Provides-Extra: all
22
- Requires-Dist: Ipython; extra == "all"
23
- Requires-Dist: vtk>=9.3.0; extra == "all"
24
- Requires-Dist: pyvista[all,trame]>=0.43.4; extra == "all"
25
- Requires-Dist: trame_jupyter_extension; extra == "all"
21
+ Requires-Dist: ipython; extra == "all"
26
22
  Requires-Dist: ipywidgets; extra == "all"
27
- Requires-Dist: pdfkit; extra == "all"
28
- Requires-Dist: Jinja2; extra == "all"
23
+ Requires-Dist: jinja2; extra == "all"
24
+ Requires-Dist: pyvista[all,trame]>=0.43.4; extra == "all"
25
+ Requires-Dist: trame-jupyter-extension; extra == "all"
26
+ Requires-Dist: vtk>=9.3.0; extra == "all"
29
27
  Provides-Extra: vtk
30
- Requires-Dist: IPython; extra == "vtk"
28
+ Requires-Dist: ipython; extra == "vtk"
31
29
  Requires-Dist: vtk>=9.3.0; extra == "vtk"
32
30
  Provides-Extra: pyvista
33
- Requires-Dist: pyvista[all,trame]>=0.43.4; extra == "pyvista"
34
- Requires-Dist: trame_jupyter_extension; extra == "pyvista"
35
31
  Requires-Dist: ipywidgets; extra == "pyvista"
32
+ Requires-Dist: pyvista[all,trame]>=0.43.4; extra == "pyvista"
33
+ Requires-Dist: trame-jupyter-extension; extra == "pyvista"
36
34
  Provides-Extra: reporting
37
- Requires-Dist: pdfkit; extra == "reporting"
38
- Requires-Dist: Jinja2; extra == "reporting"
35
+ Requires-Dist: jinja2; extra == "reporting"
39
36
  Provides-Extra: derivations
40
37
  Requires-Dist: jupyterlab; extra == "derivations"
41
38
  Requires-Dist: sympy; extra == "derivations"
42
- Dynamic: author
43
- Dynamic: author-email
44
- Dynamic: classifier
45
- Dynamic: description
46
- Dynamic: description-content-type
47
- Dynamic: home-page
48
39
  Dynamic: license-file
49
- Dynamic: provides-extra
50
- Dynamic: requires-dist
51
- Dynamic: requires-python
52
- Dynamic: summary
53
40
 
54
41
  <div align="center">
55
42
  <img src="https://github.com/JWock82/Pynite/raw/main/Resources/Full Logo No Buffer.png" width=40% align="center"/>
@@ -122,6 +109,10 @@ Here's a list of projects that use Pynite:
122
109
  * Phaenotyp (https://github.com/bewegende-Architektur/Phaenotyp) (https://youtu.be/shloSw9HjVI)
123
110
 
124
111
  # What's New?
112
+ 3.1.0
113
+ * Improved detection of unstable structures.
114
+ * Bug fix for shear walls. Shear walls were erroneously tracking other plates in the model for internal pier/coupling beam force summations, rather than just the plates related to the individual shear wall.
115
+
125
116
  3.0.0
126
117
  * Added pushover analysis! Currently, only steel sections are supported for pushover analysis. The assumed interaction equation used to estimate plastic behavior is currently calibrated toward I-shaped members.
127
118
  * Refactored all elastic stiffness matrix references from `K` and `k` to `Ke` and `ke`. Most users will not notice this change. Power users who were accessing the stiffness matrices directly will want to note this refactor. This clarifies the code base by indicating which type of stiffness these matrices represent, and reduces ambiguity in preparation for addition of further geometric `Kg/kg` and plastic `Km/km` stiffness matrices.
@@ -1,55 +1,42 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyNiteFEA
3
- Version: 3.0.0
4
- Summary: A simple elastic 3D structural finite element library for Python.
5
- Home-page: https://github.com/JWock82/Pynite.git
6
- Author: D. Craig Brinck, PE, SE
7
- Author-email: Building.Code@outlook.com
3
+ Version: 3.1.0
4
+ Summary: A simple 3D structural finite element library for Python.
5
+ Author-email: "D. Craig Brinck, PE, SE" <Building.Code@outlook.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/JWock82/Pynite
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.11
10
10
  Classifier: Programming Language :: Python :: 3.12
11
11
  Classifier: Programming Language :: Python :: 3.13
12
- Classifier: License :: OSI Approved :: MIT License
13
12
  Classifier: Operating System :: OS Independent
14
13
  Requires-Python: >=3.11
15
14
  Description-Content-Type: text/markdown
16
15
  License-File: LICENSE
16
+ Requires-Dist: matplotlib
17
17
  Requires-Dist: numpy>=2.4.0
18
- Requires-Dist: PrettyTable
18
+ Requires-Dist: prettytable
19
19
  Requires-Dist: scipy
20
- Requires-Dist: matplotlib
21
20
  Provides-Extra: all
22
- Requires-Dist: Ipython; extra == "all"
23
- Requires-Dist: vtk>=9.3.0; extra == "all"
24
- Requires-Dist: pyvista[all,trame]>=0.43.4; extra == "all"
25
- Requires-Dist: trame_jupyter_extension; extra == "all"
21
+ Requires-Dist: ipython; extra == "all"
26
22
  Requires-Dist: ipywidgets; extra == "all"
27
- Requires-Dist: pdfkit; extra == "all"
28
- Requires-Dist: Jinja2; extra == "all"
23
+ Requires-Dist: jinja2; extra == "all"
24
+ Requires-Dist: pyvista[all,trame]>=0.43.4; extra == "all"
25
+ Requires-Dist: trame-jupyter-extension; extra == "all"
26
+ Requires-Dist: vtk>=9.3.0; extra == "all"
29
27
  Provides-Extra: vtk
30
- Requires-Dist: IPython; extra == "vtk"
28
+ Requires-Dist: ipython; extra == "vtk"
31
29
  Requires-Dist: vtk>=9.3.0; extra == "vtk"
32
30
  Provides-Extra: pyvista
33
- Requires-Dist: pyvista[all,trame]>=0.43.4; extra == "pyvista"
34
- Requires-Dist: trame_jupyter_extension; extra == "pyvista"
35
31
  Requires-Dist: ipywidgets; extra == "pyvista"
32
+ Requires-Dist: pyvista[all,trame]>=0.43.4; extra == "pyvista"
33
+ Requires-Dist: trame-jupyter-extension; extra == "pyvista"
36
34
  Provides-Extra: reporting
37
- Requires-Dist: pdfkit; extra == "reporting"
38
- Requires-Dist: Jinja2; extra == "reporting"
35
+ Requires-Dist: jinja2; extra == "reporting"
39
36
  Provides-Extra: derivations
40
37
  Requires-Dist: jupyterlab; extra == "derivations"
41
38
  Requires-Dist: sympy; extra == "derivations"
42
- Dynamic: author
43
- Dynamic: author-email
44
- Dynamic: classifier
45
- Dynamic: description
46
- Dynamic: description-content-type
47
- Dynamic: home-page
48
39
  Dynamic: license-file
49
- Dynamic: provides-extra
50
- Dynamic: requires-dist
51
- Dynamic: requires-python
52
- Dynamic: summary
53
40
 
54
41
  <div align="center">
55
42
  <img src="https://github.com/JWock82/Pynite/raw/main/Resources/Full Logo No Buffer.png" width=40% align="center"/>
@@ -122,6 +109,10 @@ Here's a list of projects that use Pynite:
122
109
  * Phaenotyp (https://github.com/bewegende-Architektur/Phaenotyp) (https://youtu.be/shloSw9HjVI)
123
110
 
124
111
  # What's New?
112
+ 3.1.0
113
+ * Improved detection of unstable structures.
114
+ * Bug fix for shear walls. Shear walls were erroneously tracking other plates in the model for internal pier/coupling beam force summations, rather than just the plates related to the individual shear wall.
115
+
125
116
  3.0.0
126
117
  * Added pushover analysis! Currently, only steel sections are supported for pushover analysis. The assumed interaction equation used to estimate plastic behavior is currently calibrated toward I-shaped members.
127
118
  * Refactored all elastic stiffness matrix references from `K` and `k` to `Ke` and `ke`. Most users will not notice this change. Power users who were accessing the stiffness matrices directly will want to note this refactor. This clarifies the code base by indicating which type of stiffness these matrices represent, and reduces ambiguity in preparation for addition of further geometric `Kg/kg` and plastic `Km/km` stiffness matrices.
@@ -1,6 +1,6 @@
1
1
  LICENSE
2
2
  README.md
3
- setup.py
3
+ pyproject.toml
4
4
  PyNiteFEA.egg-info/PKG-INFO
5
5
  PyNiteFEA.egg-info/SOURCES.txt
6
6
  PyNiteFEA.egg-info/dependency_links.txt
@@ -1,30 +1,28 @@
1
+ matplotlib
1
2
  numpy>=2.4.0
2
- PrettyTable
3
+ prettytable
3
4
  scipy
4
- matplotlib
5
5
 
6
6
  [all]
7
- Ipython
8
- vtk>=9.3.0
9
- pyvista[all,trame]>=0.43.4
10
- trame_jupyter_extension
7
+ ipython
11
8
  ipywidgets
12
- pdfkit
13
- Jinja2
9
+ jinja2
10
+ pyvista[all,trame]>=0.43.4
11
+ trame-jupyter-extension
12
+ vtk>=9.3.0
14
13
 
15
14
  [derivations]
16
15
  jupyterlab
17
16
  sympy
18
17
 
19
18
  [pyvista]
20
- pyvista[all,trame]>=0.43.4
21
- trame_jupyter_extension
22
19
  ipywidgets
20
+ pyvista[all,trame]>=0.43.4
21
+ trame-jupyter-extension
23
22
 
24
23
  [reporting]
25
- pdfkit
26
- Jinja2
24
+ jinja2
27
25
 
28
26
  [vtk]
29
- IPython
27
+ ipython
30
28
  vtk>=9.3.0
@@ -2,8 +2,10 @@ from __future__ import annotations # Allows more recent type hints features
2
2
  from typing import TYPE_CHECKING
3
3
  from math import isclose
4
4
 
5
- from numpy import array, atleast_2d, zeros, subtract, matmul, divide, seterr, nanmax
6
- from numpy.linalg import solve
5
+ import warnings
6
+
7
+ from numpy import array, atleast_2d, zeros, subtract, matmul, divide, seterr, nanmax, asarray, isfinite
8
+ from numpy.linalg import solve, norm, LinAlgError
7
9
 
8
10
  from Pynite.LoadCombo import LoadCombo
9
11
 
@@ -166,6 +168,79 @@ def _check_stability(model: FEModel3D, K: NDArray[float64]) -> None:
166
168
  return
167
169
 
168
170
 
171
+ # Exception message shared by the global-instability checks below
172
+ _SINGULAR_MSG = ('The stiffness matrix is singular, which implies rigid body motion. The structure '
173
+ 'is unstable. Aborting analysis.')
174
+
175
+
176
+ def _solve_unknown_disp(K11, rhs, sparse: bool = True, check_stability: bool = True, tol: float = 1e-6):
177
+ """Solves ``K11 @ x = rhs`` for the unknown displacements, raising on a singular stiffness matrix.
178
+
179
+ The nodal stability check (:func:`_check_stability`) only catches degrees of freedom with a zero
180
+ stiffness on the diagonal. It does not catch *global* instability, where every diagonal term is
181
+ non-zero but the assembled matrix is still rank-deficient -- for example a mechanism created by
182
+ member end releases (a beam with an internal hinge and no support there), or a structure with
183
+ insufficient supports (rigid body motion). In those cases the underlying solvers do not reliably
184
+ report the problem: ``scipy.sparse.linalg.spsolve`` may return ``NaN`` (with only a
185
+ ``MatrixRankWarning``) or even finite-but-meaningless values, and ``numpy.linalg.solve`` may miss
186
+ the singularity due to floating point round-off. The model then silently reports erroneous
187
+ results.
188
+
189
+ When ``check_stability`` is ``True`` the computed displacements are verified against equilibrium
190
+ by measuring the relative residual ``||K11 @ x - rhs|| / ||rhs||``. A stable structure -- even a
191
+ numerically ill-conditioned one -- solves to within round-off (the direct solvers are backward
192
+ stable), whereas a singular system yields ``NaN``/``inf`` or finite values with an O(1) residual.
193
+ For the homogeneous ``rhs == 0`` edge case, an invertible system has the unique solution
194
+ ``x == 0``; a non-zero solved displacement is therefore treated as unstable. These checks are
195
+ robust indicators of global instability that, unlike inspecting the factorization's pivot
196
+ magnitudes, do not misclassify legitimately ill-conditioned but stable models. When
197
+ ``check_stability`` is ``False`` the bare solvers are used unchanged.
198
+
199
+ :param K11: The partitioned stiffness matrix for the unknown displacements (square, ``n``x``n``).
200
+ :param rhs: The right-hand side vector (shape ``(n, 1)``).
201
+ :param sparse: Whether ``K11`` is a SciPy sparse matrix. Defaults to ``True``.
202
+ :param check_stability: Whether to check for global instability after solving. Defaults to ``True``.
203
+ :param tol: Relative residual above which the matrix is treated as singular. Defaults to ``1e-6``.
204
+ :raises Exception: If the stiffness matrix is singular (the structure is unstable).
205
+ :return: The solved displacement vector, shape ``(n, 1)``.
206
+ """
207
+
208
+ singular_warning = False
209
+ if sparse:
210
+ from scipy.sparse.linalg import MatrixRankWarning, spsolve
211
+
212
+ K = K11.tocsr()
213
+ # A singular matrix makes `spsolve` emit a `MatrixRankWarning`; we detect that case below via
214
+ # the residual/finiteness check and raise a descriptive error, so suppress the bare warning.
215
+ with warnings.catch_warnings(record=True) as caught_warnings:
216
+ warnings.simplefilter('always', MatrixRankWarning)
217
+ x = spsolve(K, rhs)
218
+ singular_warning = any(warning.category is MatrixRankWarning for warning in caught_warnings)
219
+ else:
220
+ K = asarray(K11, dtype=float)
221
+ try:
222
+ x = solve(K, rhs)
223
+ except LinAlgError as exc:
224
+ raise Exception(_SINGULAR_MSG) from exc
225
+
226
+ x = asarray(x, dtype=float).reshape(len(x), 1)
227
+
228
+ if check_stability:
229
+ residual = norm(K @ x - rhs)
230
+ rhs_norm = norm(rhs)
231
+ if rhs_norm > 0:
232
+ unstable = residual > tol * rhs_norm
233
+ else:
234
+ # For a homogeneous system, an invertible matrix has the unique solution x == 0, so an
235
+ # absolute displacement tolerance is appropriate here.
236
+ unstable = norm(x) > tol
237
+
238
+ if (sparse and singular_warning) or not isfinite(x).all() or unstable:
239
+ raise Exception(_SINGULAR_MSG)
240
+
241
+ return x
242
+
243
+
169
244
  def _first_order(model: FEModel3D, combo_name: str, P1: NDArray[float64], FER1: NDArray[float64], D1_indices: List[int], D2_indices: List[int], D2: NDArray[float64], log: bool = True, sparse: bool = True, check_stability: bool = False, max_iter: int = 30, spring_tolerance: float = 0, member_tolerance: float = 0, num_steps: int = 1) -> None:
170
245
  """Performs the shared first-order elastic/tension-compression-only solution path.
171
246
 
@@ -173,10 +248,6 @@ def _first_order(model: FEModel3D, combo_name: str, P1: NDArray[float64], FER1:
173
248
  first-order workflow lives in one place.
174
249
  """
175
250
 
176
- # Import `scipy` features if the sparse solver is being used
177
- if sparse == True:
178
- from scipy.sparse.linalg import spsolve
179
-
180
251
  # Calculate the incremental enforced displacement vector
181
252
  Delta_D2 = D2/num_steps
182
253
 
@@ -217,19 +288,15 @@ def _first_order(model: FEModel3D, combo_name: str, P1: NDArray[float64], FER1:
217
288
  # All displacements are known, so Delta_D1 is an empty vector
218
289
  Delta_D1 = []
219
290
  else:
220
- try:
221
- # Calculate the unknown displacements Delta_D1
222
- if sparse == True:
223
- # The partitioned stiffness matrix originates as `coo` and is converted to `csr`
224
- # format for mathematical operations. The `@` operator performs matrix multiplication
225
- # on sparse matrices.
226
- Delta_D1 = spsolve(K11, subtract(subtract(Delta_P1, Delta_FER1), K12 @ Delta_D2))
227
- Delta_D1 = Delta_D1.reshape(len(Delta_D1), 1)
228
- else:
229
- Delta_D1 = solve(K11, subtract(subtract(Delta_P1, Delta_FER1), matmul(K12, Delta_D2)))
230
- except:
231
- # Return out of the method if 'K' is singular and provide an error message
232
- raise Exception('The stiffness matrix is singular, which implies rigid body motion. The structure is unstable. Aborting analysis.')
291
+ # Calculate the unknown displacements Delta_D1. The partitioned stiffness matrix
292
+ # originates as `coo` and is converted to `csr`/`csc` for mathematical operations.
293
+ # `_solve_unknown_disp` also detects global instability (a singular matrix) that
294
+ # the bare solvers can silently miss.
295
+ if sparse == True:
296
+ rhs = subtract(subtract(Delta_P1, Delta_FER1), K12 @ Delta_D2)
297
+ else:
298
+ rhs = subtract(subtract(Delta_P1, Delta_FER1), matmul(K12, Delta_D2))
299
+ Delta_D1 = _solve_unknown_disp(K11, rhs, sparse, check_stability)
233
300
 
234
301
  # Store or sum the calculated displacements to the model and the nodes in the model
235
302
  if load_step == 1:
@@ -603,19 +670,12 @@ def _pushover_step(model: FEModel3D, combo_name: str, push_combo: str, step_num:
603
670
 
604
671
  # Calculate change in the local end force vector for this load step
605
672
  Delta_f = sub_member.f(combo_name, Delta_d, Delta_fer)
606
-
607
- sub_member._fxi[combo_name] += Delta_f[0, 0]
608
- sub_member._fyi[combo_name] += Delta_f[1, 0]
609
- sub_member._fzi[combo_name] += Delta_f[2, 0]
610
- sub_member._mxi[combo_name] += Delta_f[3, 0]
611
- sub_member._myi[combo_name] += Delta_f[4, 0]
612
- sub_member._mzi[combo_name] += Delta_f[5, 0]
613
- sub_member._fxj[combo_name] += Delta_f[6, 0]
614
- sub_member._fyj[combo_name] += Delta_f[7, 0]
615
- sub_member._fzj[combo_name] += Delta_f[8, 0]
616
- sub_member._mxj[combo_name] += Delta_f[9, 0]
617
- sub_member._myj[combo_name] += Delta_f[10, 0]
618
- sub_member._mzj[combo_name] += Delta_f[11, 0]
673
+
674
+ # TODO: Before accepting `Delta_f`, check if we've overshot any member section
675
+ # capacity interaction curves, and adjust the load step if necessary
676
+
677
+ # Store the change in the local end force vector for this load step
678
+ sub_member.f_nonlin[combo_name] += Delta_f.reshape(12)
619
679
 
620
680
  else:
621
681
 
@@ -633,6 +693,120 @@ def _pushover_step(model: FEModel3D, combo_name: str, push_combo: str, step_num:
633
693
  print('- Restarting load step due to plastic load reversal')
634
694
 
635
695
 
696
+ def _load_step_ratio(model: FEModel3D, combo_name: str, Delta_f: NDArray[float64]) -> float:
697
+ """
698
+ Determines the fraction of the pushover load step that should be applied.
699
+
700
+ It is desirable to ensure plastic hinges occur at the end of a load step. This method checks
701
+ if the current load step has caused any plastic hinges to form. If so, it calculates the
702
+ fraction of the load step that should be applied to ensure the hinge forms at the end of the
703
+ load step.
704
+
705
+ See Section 12.6 of `Matrix Structural Analysis, Second Edition` for more information.
706
+ """
707
+
708
+ # Define a local helper method for this method
709
+ def tal(p, my, mz, dp, dmy, dmz, Phi):
710
+
711
+ # Initial guesses
712
+ tal_l = 0.0 # Lower bound guess
713
+ tal_u = 1.0 # Upper bound guess
714
+ tal_r = tal_u # Assumed initial value of the root we are seeking
715
+
716
+ # Counter variable
717
+ num_iter = 0
718
+
719
+ while (not isclose(Phi(p + tal_r*dp, my + tal_r*dmy, mz + tal_r*dmz) - 1.0, 0.0)
720
+ and num_iter < 100):
721
+
722
+ # Increment the iteration counter
723
+ num_iter += 1
724
+
725
+ # Calculate `Phi` (the utilization ratio) for the lower and upper guesses
726
+ Phi_tal_l = Phi(p + tal_l*dp, my + tal_l*dmy, mz + tal_l*dmz)
727
+ Phi_tal_u = Phi(p + tal_u*dp, my + tal_u*dmy, mz + tal_u*dmz)
728
+
729
+ # Estimate the root and its `Phi` value
730
+ tal_r = 1 - (Phi_tal_u - 1.0)*(tal_l - tal_u)/(Phi_tal_l - Phi_tal_u)
731
+ Phi_tal_r = Phi(p + tal_r*dp, my + tal_r*dmy, mz + tal_r*dmz)
732
+
733
+ # Replace the lower or upper guess with the new estimate depending on which (Phi - 1)
734
+ # one has the same sign as (Phi - 1) for the new root estimate.
735
+ if (Phi_tal_l - 1.0)*(Phi_tal_r - 1.0) < 0:
736
+ tal_u = tal_r
737
+ elif (Phi_tal_u - 1.0)*(Phi_tal_r - 1.0) < 0:
738
+ tal_l = tal_r
739
+
740
+ # Cap the number of iterations to avoid infinite loops
741
+ if num_iter >= 100:
742
+ raise Exception('Unable to determine the fraction of the pushover load step that should be applied.')
743
+
744
+ # Return the fraction of the pushover load step that should be applied
745
+ return tal_r
746
+
747
+ # Initilize the lowest value for the fraction of the pushover load step that should be applied
748
+ tal_min = 1.0
749
+
750
+ # Step through each physical member in the model
751
+ for phys_member in model.members.values():
752
+
753
+ # Step through each sub-member of the physical member
754
+ for sub_member in phys_member.sub_members.values():
755
+
756
+ # Obtain the member's plastic load capacities
757
+ section = sub_member.section
758
+ Py = section.material.fy*section.A
759
+ Mpy = section.material.fy*section.Zy
760
+ Mpz = section.material.fy*section.Zz
761
+
762
+ # Obtain member end forces for the latest load step
763
+ fxi = sub_member.f_nonlin[combo_name][0]
764
+ myi = sub_member.f_nonlin[combo_name][4]
765
+ mzi = sub_member.f_nonlin[combo_name][5]
766
+ fxj = sub_member.f_nonlin[combo_name][6]
767
+ myj = sub_member.f_nonlin[combo_name][10]
768
+ mzj = sub_member.f_nonlin[combo_name][11]
769
+
770
+ # Check if the yield surface has been exceeded at the i-end of the member
771
+ if sub_member.section.Phi(fxi, myi, mzi) > 1.0:
772
+
773
+ # Convert the member end forces to nondiminensionalized values
774
+ p = abs(fxi/Py)
775
+ m_y = abs(myi/Mpy)
776
+ m_z = abs(mzi/Mpz)
777
+
778
+ dp = abs(Delta_f[0, 0]/Py)
779
+ dmy = abs(Delta_f[4, 0]/Mpy)
780
+ dmz = abs(Delta_f[5, 0]/Mpz)
781
+
782
+ tal_i = tal(p, m_y, m_z, dp, dmy, dmz, sub_member.section.Phi)
783
+
784
+ else: tal_i = 1.0
785
+
786
+ # Check if the yield surface has been exceeded at the j-end of the member
787
+ if sub_member.section.Phi(fxj, myj, mzj) > 1.0:
788
+
789
+ # Convert the member end forces to nondiminensionalized values
790
+ p = abs(fxj/Py)
791
+ m_y = abs(myj/Mpy)
792
+ m_z = abs(mzj/Mpz)
793
+
794
+ dp = abs(Delta_f[6, 0]/Py)
795
+ dmy = abs(Delta_f[10, 0]/Mpy)
796
+ dmz = abs(Delta_f[11, 0]/Mpz)
797
+
798
+ tal_j = tal(p, m_y, m_z, dp, dmy, dmz, sub_member.section.Phi)
799
+
800
+ else: tal_j = 1.0
801
+
802
+ if tal_i < tal_min:
803
+ tal_min = tal_i
804
+ if tal_j < tal_min:
805
+ tal_min = tal_j
806
+
807
+ # Return the fraction of the pushover load step that should be applied
808
+ return tal_min
809
+
636
810
  def _unpartition(model: FEModel3D, V1: NDArray[float64], V2: NDArray[float64], V1_indices: List[int], V2_indices: List[int]) -> NDArray[float64]:
637
811
  """Unpartitions a vector and returns it as a global vector including all dofs.
638
812
 
@@ -1843,7 +1843,7 @@ class FEModel3D():
1843
1843
  else:
1844
1844
  if self.solution == 'Pushover':
1845
1845
  # Use the axial force at the current nonlinear/inelastic load step
1846
- P = member._fxj[combo_name] - member._fxi[combo_name]
1846
+ P = member.f_nonlin[combo_name][6] - member.f_nonlin[combo_name][0]
1847
1847
  else:
1848
1848
  # Calculate the member axial force due to linear/elastic axial strain
1849
1849
  d = member.d(combo_name)
@@ -2266,10 +2266,6 @@ class FEModel3D():
2266
2266
  print('| Analyzing: Linear |')
2267
2267
  print('+-------------------+')
2268
2268
 
2269
- # Import `scipy` features if the sparse solver is being used
2270
- if sparse == True:
2271
- from scipy.sparse.linalg import spsolve
2272
-
2273
2269
  # Prepare the model for analysis
2274
2270
  Analysis._prepare_model(self)
2275
2271
 
@@ -2307,19 +2303,15 @@ class FEModel3D():
2307
2303
  # All displacements are known, so D1 is an empty vector
2308
2304
  D1 = []
2309
2305
  else:
2310
- try:
2311
- # Calculate the unknown displacements D1
2312
- if sparse == True:
2313
- # The partitioned stiffness matrix originates as `coo` and is converted
2314
- # to `csr` format for mathematical operations. The `@` operator performs
2315
- # matrix multiplication on sparse matrices.
2316
- D1 = spsolve(K11.tocsr(), np.subtract(np.subtract(P1, FER1), K12.tocsr() @ D2))
2317
- D1 = D1.reshape(len(D1), 1)
2318
- else:
2319
- D1 = solve(K11, np.subtract(np.subtract(P1, FER1), np.matmul(K12, D2)))
2320
- except:
2321
- # Return out of the method if 'K' is singular and provide an error message
2322
- raise Exception('The stiffness matrix is singular, which implies rigid body motion. The structure is unstable. Aborting analysis.')
2306
+ # Calculate the unknown displacements D1. The partitioned stiffness matrix
2307
+ # originates as `coo` and is converted to `csr`/`csc` for mathematical
2308
+ # operations. `_solve_unknown_disp` also detects global instability (a singular
2309
+ # matrix) that the bare solvers can silently miss.
2310
+ if sparse == True:
2311
+ rhs = np.subtract(np.subtract(P1, FER1), K12.tocsr() @ D2)
2312
+ else:
2313
+ rhs = np.subtract(np.subtract(P1, FER1), np.matmul(K12, D2))
2314
+ D1 = Analysis._solve_unknown_disp(K11, rhs, sparse, check_stability)
2323
2315
 
2324
2316
  # Store the calculated displacements to the model and the nodes in the model
2325
2317
  Analysis._store_displacements(self, D1, D2, D1_indices, D2_indices, combo)
@@ -2657,13 +2649,12 @@ class FEModel3D():
2657
2649
 
2658
2650
  return fig, ax, save_path
2659
2651
 
2660
- def analyze_pushover(self, log=False, check_stability=True, push_combo='Push', max_iter=30, tol=0.01, sparse=True, combo_tags=None, control_node=None, control_direction='DX', control_limit=None, traces=None):
2661
- """Performs a pushover analysis using first-order preload and pushover steps.
2652
+ def analyze_pushover(self, log=False, check_stability=True, push_combo='Push', max_iter=30, tol=0.01, sparse=True, combo_tags=None, control_node=None, control_direction='DX', control_limit=None, traces=None, P_Delta=False):
2653
+ """Performs a pushover analysis with optional second-order P-Delta effects.
2662
2654
 
2663
- Note for future development:
2664
- Pushover + P-Delta infrastructure is intentionally retained internally, but the public
2665
- pushover API currently does not expose a P-Delta option until the implementation is
2666
- production-ready.
2655
+ By default, the initial preload state is established using first-order analysis and the
2656
+ incremental pushover steps omit the geometric stiffness matrix. Set ``P_Delta=True`` to
2657
+ opt into second-order behavior for both the preload and the subsequent pushover steps.
2667
2658
 
2668
2659
  :param log: Prints updates to the console if set to True. Default is False.
2669
2660
  :type log: bool, optional
@@ -2690,6 +2681,9 @@ class FEModel3D():
2690
2681
  :type control_limit: float | None, optional
2691
2682
  :param traces: Optional dictionary of trace names mapped to callables that accept combo_name.
2692
2683
  :type traces: dict | None, optional
2684
+ :param P_Delta: Set to True to include geometric stiffness and second-order effects during
2685
+ pushover analysis. Defaults to False.
2686
+ :type P_Delta: bool, optional
2693
2687
  """
2694
2688
 
2695
2689
  if control_limit is not None and control_node is None:
@@ -2744,9 +2738,7 @@ class FEModel3D():
2744
2738
  # Initialize per-combo pushover result state.
2745
2739
  self._pushover_state = {}
2746
2740
  self._pushover_traces = {}
2747
- # Keep this internal flag for future developer work on pushover + P-Delta support.
2748
- # User-facing pushover is currently first-order only.
2749
- self._pushover_P_Delta = False
2741
+ self._pushover_P_Delta = P_Delta
2750
2742
 
2751
2743
  # Step through each load combination
2752
2744
  for combo in combo_list:
@@ -2759,10 +2751,7 @@ class FEModel3D():
2759
2751
  # Set member end force summations to zero
2760
2752
  for phys_member in self.members.values():
2761
2753
  for sub_member in phys_member.sub_members.values():
2762
- sub_member._fxi, sub_member._fyi, sub_member._fzi = {}, {}, {}
2763
- sub_member._mxi, sub_member._myi, sub_member._mzi = {}, {}, {}
2764
- sub_member._fxj, sub_member._fyj, sub_member._fzj = {}, {}, {}
2765
- sub_member._mxj, sub_member._myj, sub_member._mzj = {}, {}, {}
2754
+ sub_member.f_nonlin = {}
2766
2755
 
2767
2756
  # Get the partitioned global fixed end reaction vector for the load combination
2768
2757
  FER1, FER2 = Analysis._partition(self, self.FER(combo.name), D1_indices, D2_indices)
@@ -2771,29 +2760,22 @@ class FEModel3D():
2771
2760
  P1, P2 = Analysis._partition(self, self.P(combo.name), D1_indices, D2_indices)
2772
2761
 
2773
2762
  # Preload the primary load combination before any pushover increments are applied.
2774
- # User-facing pushover currently uses first-order preload only.
2763
+ # By default, pushover uses the first-order analysis path. Set `P_Delta=True` to opt
2764
+ # into a second-order preload state.
2775
2765
  if log:
2776
- print('- Preloading the primary combination using first-order analysis')
2766
+ print('- Preloading the primary combination using ' + ('P-Delta' if P_Delta else 'first-order') + ' analysis')
2777
2767
 
2778
- Analysis._first_order(self, combo.name, P1, FER1, D1_indices, D2_indices, D2, False, sparse, check_stability, max_iter)
2768
+ if P_Delta:
2769
+ Analysis._PDelta(self, combo.name, P1, FER1, D1_indices, D2_indices, D2, False, sparse, check_stability, max_iter)
2770
+ else:
2771
+ Analysis._first_order(self, combo.name, P1, FER1, D1_indices, D2_indices, D2, False, sparse, check_stability, max_iter)
2779
2772
 
2780
2773
  # Seed the nonlinear end-force history with the elastic preload state from the
2781
2774
  # primary load combination before any pushover increments are applied.
2782
2775
  for phys_member in self.members.values():
2783
2776
  for sub_member in phys_member.sub_members.values():
2784
2777
  f = sub_member.f(combo.name)
2785
- sub_member._fxi[combo.name] = f[0, 0]
2786
- sub_member._fyi[combo.name] = f[1, 0]
2787
- sub_member._fzi[combo.name] = f[2, 0]
2788
- sub_member._mxi[combo.name] = f[3, 0]
2789
- sub_member._myi[combo.name] = f[4, 0]
2790
- sub_member._mzi[combo.name] = f[5, 0]
2791
- sub_member._fxj[combo.name] = f[6, 0]
2792
- sub_member._fyj[combo.name] = f[7, 0]
2793
- sub_member._fzj[combo.name] = f[8, 0]
2794
- sub_member._mxj[combo.name] = f[9, 0]
2795
- sub_member._myj[combo.name] = f[10, 0]
2796
- sub_member._mzj[combo.name] = f[11, 0]
2778
+ sub_member.f_nonlin[combo.name] = f.reshape(12)
2797
2779
 
2798
2780
  # The P-Delta analysis above flagged the solution as a P-Delta solution, but we need to
2799
2781
  # indicate that this is actually a Pushover solution so that the calls to Member3D.f()
@@ -2820,7 +2802,7 @@ class FEModel3D():
2820
2802
  # each load step and can be queried for results during or after the pushover analysis.
2821
2803
  self._pushover_state[combo.name] = {
2822
2804
  'push_combo': push_combo,
2823
- 'P_Delta': False,
2805
+ 'P_Delta': P_Delta,
2824
2806
  'status': 'running',
2825
2807
  'step_num': 0,
2826
2808
  'load_factor': 0.0,
@@ -2841,8 +2823,7 @@ class FEModel3D():
2841
2823
 
2842
2824
  # Run the next pushover load step
2843
2825
  # Note: The validity of the pushover step is checked and handled within the _pushover_step method
2844
- # Keep the internal _pushover_step P_Delta argument for future developer work.
2845
- Analysis._pushover_step(self, combo.name, push_combo, step_num, P1_push, FER1_push, FER2_push, D1_indices, D2_indices, D2, log, sparse, check_stability, tol, False, max_iter)
2826
+ Analysis._pushover_step(self, combo.name, push_combo, step_num, P1_push, FER1_push, FER2_push, D1_indices, D2_indices, D2, log, sparse, check_stability, tol, P_Delta, max_iter)
2846
2827
 
2847
2828
  control_displacement = None
2848
2829
  if control_node is not None: