PyCBA 0.5.2__tar.gz → 0.6.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 (27) hide show
  1. {pycba-0.5.2/src/PyCBA.egg-info → pycba-0.6.0}/PKG-INFO +6 -4
  2. {pycba-0.5.2 → pycba-0.6.0}/pyproject.toml +3 -2
  3. {pycba-0.5.2 → pycba-0.6.0/src/PyCBA.egg-info}/PKG-INFO +6 -4
  4. {pycba-0.5.2 → pycba-0.6.0}/src/PyCBA.egg-info/SOURCES.txt +1 -0
  5. pycba-0.6.0/src/pycba/__init__.py +23 -0
  6. pycba-0.6.0/src/pycba/analysis.py +567 -0
  7. {pycba-0.5.2 → pycba-0.6.0}/src/pycba/beam.py +74 -37
  8. {pycba-0.5.2 → pycba-0.6.0}/src/pycba/load.py +34 -169
  9. {pycba-0.5.2 → pycba-0.6.0}/src/pycba/results.py +8 -4
  10. pycba-0.6.0/src/pycba/types.py +68 -0
  11. {pycba-0.5.2 → pycba-0.6.0}/tests/test_basic.py +144 -0
  12. pycba-0.5.2/src/pycba/__init__.py +0 -15
  13. pycba-0.5.2/src/pycba/analysis.py +0 -372
  14. {pycba-0.5.2 → pycba-0.6.0}/LICENSE +0 -0
  15. {pycba-0.5.2 → pycba-0.6.0}/README.md +0 -0
  16. {pycba-0.5.2 → pycba-0.6.0}/setup.cfg +0 -0
  17. {pycba-0.5.2 → pycba-0.6.0}/setup.py +0 -0
  18. {pycba-0.5.2 → pycba-0.6.0}/src/PyCBA.egg-info/dependency_links.txt +0 -0
  19. {pycba-0.5.2 → pycba-0.6.0}/src/PyCBA.egg-info/requires.txt +0 -0
  20. {pycba-0.5.2 → pycba-0.6.0}/src/PyCBA.egg-info/top_level.txt +0 -0
  21. {pycba-0.5.2 → pycba-0.6.0}/src/pycba/bridge.py +0 -0
  22. {pycba-0.5.2 → pycba-0.6.0}/src/pycba/inf_lines.py +0 -0
  23. {pycba-0.5.2 → pycba-0.6.0}/src/pycba/pattern.py +0 -0
  24. {pycba-0.5.2 → pycba-0.6.0}/src/pycba/utils.py +0 -0
  25. {pycba-0.5.2 → pycba-0.6.0}/src/pycba/vehicle.py +0 -0
  26. {pycba-0.5.2 → pycba-0.6.0}/tests/test_bridge.py +0 -0
  27. {pycba-0.5.2 → pycba-0.6.0}/tests/test_inf_lines.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: PyCBA
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: Python Continuous Beam Analysis
5
5
  Author-email: Colin Caprani <colin.caprani@monash.edu>
6
6
  License: Apache 2.0
@@ -20,11 +20,12 @@ Classifier: Natural Language :: English
20
20
  Classifier: Operating System :: POSIX :: Linux
21
21
  Classifier: Operating System :: MacOS :: MacOS X
22
22
  Classifier: Operating System :: Microsoft :: Windows
23
- Classifier: Programming Language :: Python :: 3.8
24
23
  Classifier: Programming Language :: Python :: 3.9
25
24
  Classifier: Programming Language :: Python :: 3.10
26
25
  Classifier: Programming Language :: Python :: 3.11
27
- Requires-Python: >=3.8
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Requires-Python: >=3.9
28
29
  Description-Content-Type: text/markdown
29
30
  License-File: LICENSE
30
31
  Requires-Dist: matplotlib
@@ -32,6 +33,7 @@ Requires-Dist: numpy
32
33
  Requires-Dist: scipy>=1.6.0
33
34
  Provides-Extra: test
34
35
  Requires-Dist: pytest>=6.2.2; extra == "test"
36
+ Dynamic: license-file
35
37
 
36
38
  # PyCBA - Python Continuous Beam Analysis
37
39
 
@@ -21,12 +21,13 @@ classifiers = [
21
21
  "Operating System :: POSIX :: Linux",
22
22
  "Operating System :: MacOS :: MacOS X",
23
23
  "Operating System :: Microsoft :: Windows",
24
- "Programming Language :: Python :: 3.8",
25
24
  "Programming Language :: Python :: 3.9",
26
25
  "Programming Language :: Python :: 3.10",
27
26
  "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
28
29
  ]
29
- requires-python = ">=3.8"
30
+ requires-python = ">=3.9"
30
31
  dependencies = [
31
32
  "matplotlib",
32
33
  "numpy",
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: PyCBA
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: Python Continuous Beam Analysis
5
5
  Author-email: Colin Caprani <colin.caprani@monash.edu>
6
6
  License: Apache 2.0
@@ -20,11 +20,12 @@ Classifier: Natural Language :: English
20
20
  Classifier: Operating System :: POSIX :: Linux
21
21
  Classifier: Operating System :: MacOS :: MacOS X
22
22
  Classifier: Operating System :: Microsoft :: Windows
23
- Classifier: Programming Language :: Python :: 3.8
24
23
  Classifier: Programming Language :: Python :: 3.9
25
24
  Classifier: Programming Language :: Python :: 3.10
26
25
  Classifier: Programming Language :: Python :: 3.11
27
- Requires-Python: >=3.8
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Requires-Python: >=3.9
28
29
  Description-Content-Type: text/markdown
29
30
  License-File: LICENSE
30
31
  Requires-Dist: matplotlib
@@ -32,6 +33,7 @@ Requires-Dist: numpy
32
33
  Requires-Dist: scipy>=1.6.0
33
34
  Provides-Extra: test
34
35
  Requires-Dist: pytest>=6.2.2; extra == "test"
36
+ Dynamic: license-file
35
37
 
36
38
  # PyCBA - Python Continuous Beam Analysis
37
39
 
@@ -15,6 +15,7 @@ src/pycba/inf_lines.py
15
15
  src/pycba/load.py
16
16
  src/pycba/pattern.py
17
17
  src/pycba/results.py
18
+ src/pycba/types.py
18
19
  src/pycba/utils.py
19
20
  src/pycba/vehicle.py
20
21
  tests/test_basic.py
@@ -0,0 +1,23 @@
1
+ """
2
+ PyCBA - Continuous Beam Analysis in Python
3
+ """
4
+
5
+ __version__ = "0.6.0"
6
+
7
+ from .analysis import BeamAnalysis
8
+ from .beam import Beam
9
+ from .load import (
10
+ LoadCNL,
11
+ MemberResults,
12
+ LoadMatrix,
13
+ LoadType,
14
+ parse_LM,
15
+ add_LM,
16
+ factor_LM,
17
+ )
18
+ from .results import BeamResults, Envelopes
19
+ from .inf_lines import InfluenceLines
20
+ from .utils import parse_beam_string
21
+ from .bridge import BridgeAnalysis
22
+ from .vehicle import Vehicle, make_train, VehicleLibrary
23
+ from .pattern import LoadPattern
@@ -0,0 +1,567 @@
1
+ """
2
+ PyCBA - Continuous Beam Analysis
3
+
4
+ Implements the direct stiffness method for linear-elastic continuous beam
5
+ analysis. The primary entry point is :class:`BeamAnalysis`, which assembles
6
+ the global stiffness matrix, applies boundary conditions (including prescribed
7
+ displacements / support settlements), solves for nodal displacements, and
8
+ recovers reactions and member load effects.
9
+
10
+ **PyCBA is unit-agnostic.** No conversions are performed; any internally
11
+ consistent set of units (e.g. kN/m/kNm, or N/mm/Nmm) may be used as long as
12
+ all inputs share the same system. The only exception is :meth:`BeamAnalysis.plot_results`,
13
+ which scales deflections by 1×10³ and labels the axis "mm" — that label is
14
+ only correct when the length unit is metres.
15
+
16
+ Sign conventions
17
+ ----------------
18
+ * Vertical displacements and forces: **positive upward**.
19
+ * Rotations and moments: **positive counter-clockwise**.
20
+ * Settlement (prescribed displacement): negative value = downward.
21
+ * UDL / point loads: positive value = downward acting load.
22
+ * Moment loads: positive value = counter-clockwise.
23
+
24
+ Restraint vector ``R``
25
+ ~~~~~~~~~~~~~~~~~~~~~~
26
+ One entry per nodal DOF (vertical then rotational, node by node):
27
+
28
+ * ``-1`` — fully fixed (zero displacement unless overridden by ``D``).
29
+ * ``0`` — free.
30
+ * ``+k`` — elastic spring with stiffness *k* in consistent
31
+ force/length (translational) or force·length/angle (rotational) units.
32
+
33
+ An OO Python adaptation of CBA, originally written for MATLAB:
34
+ http://www.colincaprani.com/programming/matlab/
35
+ """
36
+
37
+ from typing import Union, Optional
38
+ import numpy as np
39
+ import matplotlib.pyplot as plt
40
+ from .beam import Beam, LoadMatrix
41
+ from .results import BeamResults
42
+ from .load import add_LM
43
+
44
+
45
+ class BeamAnalysis:
46
+ """
47
+ Direct-stiffness continuous beam analyser.
48
+
49
+ Assembles the global stiffness matrix from individual span stiffness
50
+ matrices, applies support boundary conditions (including elastic springs
51
+ and prescribed displacements), solves the linear system for nodal
52
+ displacements, and recovers support reactions and distributed load effects
53
+ along each member.
54
+
55
+ After calling :meth:`analyze`, results are available through
56
+ :attr:`beam_results`:
57
+
58
+ * ``beam_results.R`` — reactions at fully-fixed DOFs.
59
+ * ``beam_results.Rs`` — spring forces ``k_s × u_i`` at spring DOFs.
60
+ * ``beam_results.D`` — global nodal displacement vector.
61
+ * ``beam_results.results`` — concatenated member load-effect arrays
62
+ (``x``, ``M``, ``V``, ``D``, ``R``).
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ L: np.ndarray,
68
+ EI: Union[float, np.ndarray],
69
+ R: np.ndarray,
70
+ LM: Optional[LoadMatrix] = None,
71
+ eletype: Optional[np.ndarray] = None,
72
+ D: Optional[list] = None,
73
+ ):
74
+ """
75
+ Construct a beam analysis object.
76
+
77
+ Parameters
78
+ ----------
79
+ L : array_like of float
80
+ Span lengths. Length ``N`` for an ``N``-span beam.
81
+ EI : float or array_like of float
82
+ Flexural rigidity of each span. A scalar value is applied to all
83
+ spans; otherwise one value per span is required.
84
+ R : array_like of int or float
85
+ Nodal restraint vector, length ``2(N+1)``. Two entries per node
86
+ (vertical DOF then rotational DOF), ordered left to right:
87
+
88
+ * ``-1`` — fully restrained (zero displacement unless overridden
89
+ by ``D``).
90
+ * ``0`` — free.
91
+ * ``+k`` — elastic spring stiffness in consistent units.
92
+
93
+ LM : list of list, optional
94
+ Load matrix: a list of load descriptors, each of the form
95
+ ``[span, load_type, value, a, c]``. Load types:
96
+
97
+ 1. UDL — ``value`` is load intensity; set ``a = c = 0``.
98
+ 2. Point load — ``value`` at distance ``a`` from the span start.
99
+ 3. Partial UDL — ``value`` intensity from ``a`` for length ``c``.
100
+ 4. Moment load — ``value`` at distance ``a`` from the span start.
101
+
102
+ eletype : array_like of int, optional
103
+ Element type for each span, controlling which end(s) carry moment:
104
+
105
+ 1. Fixed–fixed (default).
106
+ 2. Fixed–pinned (moment release at right end).
107
+ 3. Pinned–fixed (moment release at left end).
108
+ 4. Pinned–pinned (moment releases at both ends).
109
+
110
+ At an internal hinge, only one of the two members meeting at that
111
+ node should have a pinned end.
112
+ D : list, optional
113
+ Prescribed-displacement vector, length ``2(N+1)`` (same as ``R``).
114
+ Use ``None`` for DOFs whose displacement is unknown (the default).
115
+ Provide a float for DOFs with a known displacement (e.g. a support
116
+ settlement — negative = downward). Fixed supports (``R = -1``)
117
+ default to zero displacement unless ``D`` provides an explicit
118
+ override.
119
+
120
+ Raises
121
+ ------
122
+ ValueError
123
+ If ``R`` and ``D`` have different lengths, or ``EI`` is not
124
+ scalar and its length differs from ``len(L)``.
125
+ """
126
+ self.npts = 100
127
+ self._beam_results = None
128
+
129
+ if eletype is None:
130
+ self.eletype = np.ones((len(L), 1))
131
+ else:
132
+ self.eletype = eletype
133
+ # Create the beam
134
+ self._beam = Beam(L=L, EI=EI, R=R, LM=LM, eletype=self.eletype, D=D)
135
+
136
+ self._n = self._beam.no_spans
137
+ self._no_nodes = self._n + 1
138
+ self._nDOF = 2 * self._no_nodes
139
+
140
+ @property
141
+ def beam_results(self) -> BeamResults:
142
+ """
143
+ BeamResults : Post-analysis results object.
144
+
145
+ ``None`` until :meth:`analyze` has been called successfully.
146
+ Provides nodal displacements (``D``), reactions at fixed supports
147
+ (``R``), spring forces (``Rs``), and per-member load-effect arrays
148
+ (``vRes``, ``results``).
149
+ """
150
+ return self._beam_results
151
+
152
+ @property
153
+ def beam(self) -> Beam:
154
+ """
155
+ Beam : The underlying :class:`~pycba.beam.Beam` object.
156
+
157
+ Provides direct access to span geometry, stiffness matrices,
158
+ restraints, and prescribed displacements.
159
+ """
160
+ return self._beam
161
+
162
+ def set_loads(self, LM: LoadMatrix):
163
+ """
164
+ Replace the current load matrix with a new one.
165
+
166
+ Any loads previously added via :meth:`add_udl`, :meth:`add_pl`,
167
+ :meth:`add_pudl`, or :meth:`add_ml` are discarded.
168
+
169
+ Parameters
170
+ ----------
171
+ LM : list of list
172
+ New load matrix in the same format as the ``LM`` argument of
173
+ :meth:`__init__`.
174
+ """
175
+ self._beam.loads = LM
176
+
177
+ def add_udl(self, i_span: int, w: float):
178
+ """
179
+ Append a full-span uniformly-distributed load.
180
+
181
+ Parameters
182
+ ----------
183
+ i_span : int
184
+ 1-based span index.
185
+ w : float
186
+ Load intensity. Positive values act downward.
187
+ """
188
+ load = [i_span, 1, w]
189
+ self._beam.add_load(load)
190
+
191
+ def add_pl(self, i_span: int, p: float, a: float):
192
+ """
193
+ Append a point load.
194
+
195
+ Parameters
196
+ ----------
197
+ i_span : int
198
+ 1-based span index.
199
+ p : float
200
+ Load magnitude. Positive values act downward.
201
+ a : float
202
+ Distance from the left end of the span to the load.
203
+ """
204
+ load = [i_span, 2, p, a]
205
+ self._beam.add_load(load)
206
+
207
+ def add_pudl(self, i_span: int, w: float, a: float, c: float):
208
+ """
209
+ Append a partial uniformly-distributed load.
210
+
211
+ Any portion of the load that extends beyond the end of the span is
212
+ silently ignored.
213
+
214
+ Parameters
215
+ ----------
216
+ i_span : int
217
+ 1-based span index.
218
+ w : float
219
+ Load intensity. Positive values act downward.
220
+ a : float
221
+ Distance from the left end of the span to the start of the load.
222
+ c : float
223
+ Length (cover) of the partial UDL.
224
+ """
225
+ load = [i_span, 3, w, a, c]
226
+ self._beam.add_load(load)
227
+
228
+ def add_ml(self, i_span: int, m: float, a: float):
229
+ """
230
+ Append a concentrated moment load.
231
+
232
+ Parameters
233
+ ----------
234
+ i_span : int
235
+ 1-based span index.
236
+ m : float
237
+ Moment magnitude. Positive values are counter-clockwise.
238
+ a : float
239
+ Distance from the left end of the span to the load.
240
+ """
241
+ load = [i_span, 4, m, a]
242
+ self._beam.add_load(load)
243
+
244
+ def analyze(self, npts: Optional[int] = None) -> int:
245
+ """
246
+ Execute the direct-stiffness analysis.
247
+
248
+ Assembles the unrestricted global stiffness matrix, validates the
249
+ model, applies boundary conditions (including spring supports and
250
+ prescribed displacements), solves for nodal displacements, and
251
+ recovers support reactions. Results are stored in
252
+ :attr:`beam_results`.
253
+
254
+ Parameters
255
+ ----------
256
+ npts : int, optional
257
+ Number of evaluation points along each member for computing
258
+ distributed load effects (bending moment, shear, deflection).
259
+ Must be greater than 3; defaults to 100 if omitted or ``≤ 3``.
260
+
261
+ Returns
262
+ -------
263
+ int
264
+ ``0`` on successful completion.
265
+
266
+ Raises
267
+ ------
268
+ ValueError
269
+ If the model is invalid (see :meth:`_validate`) or if the
270
+ structure is geometrically unstable (see :meth:`_solver`).
271
+ """
272
+ if npts and npts > 3:
273
+ self.npts = npts
274
+
275
+ restraints = self._beam.restraints
276
+ d_presc = self._beam.prescribed_displacements
277
+ fU = self._forces()
278
+
279
+ self._validate(restraints, d_presc, fU)
280
+
281
+ f = np.copy(fU)
282
+ ksysU = self._assemble()
283
+ ksys = np.copy(ksysU)
284
+ ksys, f = self._apply_bc(ksys, f)
285
+ d = self._solver(ksys, f)
286
+ r, rs = self._reactions(ksysU, d, fU)
287
+
288
+ self._beam_results = BeamResults(self._beam, d, r, self.npts, rs)
289
+ return 0
290
+
291
+ def _forces(self) -> np.ndarray:
292
+ """
293
+ Build the unrestricted global nodal force vector from the load matrix.
294
+
295
+ Iterates over spans, retrieves each span's released end forces
296
+ (consistent nodal loads adjusted for element type / moment releases),
297
+ and accumulates them in the global vector with the sign reversal
298
+ required by the direct stiffness method (loads oppose the reactions
299
+ they generate).
300
+
301
+ Returns
302
+ -------
303
+ f : np.ndarray, shape (nDOF,)
304
+ Unrestricted global nodal force vector.
305
+ """
306
+ self._beam._set_loads()
307
+
308
+ f = np.zeros(self._nDOF)
309
+
310
+ for i in range(self._n):
311
+ dof_i = 2 * i
312
+ fmbr = self._beam.get_ref(i)
313
+ # Cumulatively apply forces in opposite direction
314
+ f[dof_i : dof_i + 4] -= fmbr
315
+ return f
316
+
317
+ def _validate(
318
+ self,
319
+ restraints: list,
320
+ d_presc: list,
321
+ fU: np.ndarray,
322
+ ) -> None:
323
+ """
324
+ Pre-analysis model validity check.
325
+
326
+ Currently checks for the one combination that is physically
327
+ inconsistent with the direct elimination method: a DOF that
328
+ simultaneously has a spring support, a prescribed displacement, *and*
329
+ a non-zero consistent nodal load. In that case the BC enforcement
330
+ sets ``f[i] = d_i``, silently overwriting the load — no warning is
331
+ possible after the fact, so this must be caught before the solve.
332
+
333
+ Parameters
334
+ ----------
335
+ restraints : list
336
+ Beam restraint vector (same as ``R``).
337
+ d_presc : list
338
+ Prescribed-displacement vector (``None`` entries = free DOFs).
339
+ fU : np.ndarray, shape (nDOF,)
340
+ Unrestricted nodal force vector from :meth:`_forces`.
341
+
342
+ Raises
343
+ ------
344
+ ValueError
345
+ If any DOF simultaneously carries a spring restraint, a prescribed
346
+ displacement, and a non-zero consistent nodal load.
347
+ """
348
+ for i in range(self._nDOF):
349
+ if restraints[i] > 0 and d_presc[i] is not None and fU[i] != 0.0:
350
+ raise ValueError(
351
+ f"Invalid model at DOF {i}: a spring support, a prescribed "
352
+ f"displacement, and a non-zero external nodal load "
353
+ f"(fU[{i}] = {fU[i]}) cannot coexist. The elimination "
354
+ "method would silently discard the nodal load. "
355
+ "Remove the prescribed displacement, the spring, or the "
356
+ "external load at this DOF."
357
+ )
358
+
359
+ def _assemble(self) -> np.ndarray:
360
+ """
361
+ Assemble the unrestricted global stiffness matrix.
362
+
363
+ Loops over spans and overlaps each span's 4×4 stiffness matrix into
364
+ the ``2(N+1) × 2(N+1)`` global matrix using the standard connectivity
365
+ pattern. Spring stiffnesses (``R > 0``) are added to the diagonal
366
+ here so that the returned matrix is the *complete* unrestricted system,
367
+ which is required by :meth:`_reactions` to recover spring forces
368
+ correctly.
369
+
370
+ Returns
371
+ -------
372
+ ksys : np.ndarray, shape (nDOF, nDOF)
373
+ Unrestricted global stiffness matrix including spring contributions.
374
+ """
375
+ ksys = np.zeros((self._nDOF, self._nDOF))
376
+
377
+ for i in range(self._n):
378
+ kb = self._beam.get_span_k(i)
379
+ dof_i = 2 * i
380
+ ksys[dof_i : dof_i + 4, dof_i : dof_i + 4] += kb
381
+
382
+ # Add spring stiffness before copying so ksys (used for reactions)
383
+ # includes the spring contribution and spring forces are not lost.
384
+ r_vec = self._beam.restraints
385
+ for i in range(self._nDOF):
386
+ if r_vec[i] > 0:
387
+ ksys[i][i] += r_vec[i]
388
+
389
+ return ksys
390
+
391
+ def _apply_bc(self, k: np.ndarray, f: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
392
+ """
393
+ Impose boundary conditions using the direct elimination method.
394
+
395
+ For each DOF with a known displacement ``d_i`` (either explicitly
396
+ prescribed via ``D`` or implicitly zero for fully-fixed supports),
397
+ the corresponding row and column are zeroed, the diagonal is set to
398
+ one, and the right-hand side is updated by subtracting
399
+ ``k[:, i] * d_i`` before overwriting ``f[i] = d_i``. This preserves
400
+ the displacement field exactly and transfers the constraint contribution
401
+ to the remaining free DOFs.
402
+
403
+ Spring DOFs (``R > 0``) without a prescribed displacement are *not*
404
+ eliminated — their stiffness is already on the diagonal of ``k`` from
405
+ :meth:`_assemble` and they remain as free unknowns.
406
+
407
+ Parameters
408
+ ----------
409
+ k : np.ndarray, shape (nDOF, nDOF)
410
+ Unrestricted global stiffness matrix (modified in-place).
411
+ f : np.ndarray, shape (nDOF,)
412
+ Global nodal force vector (modified in-place).
413
+
414
+ Returns
415
+ -------
416
+ k : np.ndarray, shape (nDOF, nDOF)
417
+ Restricted stiffness matrix with constrained rows/columns zeroed.
418
+ f : np.ndarray, shape (nDOF,)
419
+ Modified force vector incorporating prescribed displacement values.
420
+ """
421
+ r = self._beam.restraints
422
+ d = self._beam.prescribed_displacements
423
+ for i in range(self._nDOF):
424
+ # Determine prescribed value: explicit settlement, or fixed support (= 0)
425
+ if d[i] is not None:
426
+ di = d[i]
427
+ elif r[i] < 0:
428
+ di = 0.0
429
+ else:
430
+ continue # free or spring-only DOF: nothing to eliminate
431
+
432
+ # Subtract full column i from RHS (including diagonal) before zeroing
433
+ f -= k[:, i] * di
434
+ k[i, :] = 0.0
435
+ k[:, i] = 0.0
436
+ k[i, i] = 1.0
437
+ f[i] = di
438
+ return k, f
439
+
440
+ def _reactions(self, k: np.ndarray, d: np.ndarray, f: np.ndarray) -> tuple:
441
+ """
442
+ Recover support reactions and spring forces from the solved displacement field.
443
+
444
+ Uses the unrestricted global stiffness matrix ``k`` (i.e. ``ksysU``
445
+ from :meth:`analyze`) so that spring contributions are included in the
446
+ residual calculation.
447
+
448
+ For fully-fixed DOFs (``restraints[i] == -1``) the nodal residual
449
+ ``(k @ d - f)[i]`` equals the support reaction directly, because those
450
+ DOFs have zero (or prescribed) displacement and no ambiguity with
451
+ spring terms.
452
+
453
+ For spring DOFs (``restraints[i] > 0``) the residual also contains
454
+ structural coupling and any applied nodal load, so it does *not* in
455
+ general equal the spring force alone. The spring force is therefore
456
+ computed explicitly as ``k_s * u_i``.
457
+
458
+ Parameters
459
+ ----------
460
+ k : np.ndarray, shape (nDOF, nDOF)
461
+ Unrestricted global stiffness matrix (including spring terms).
462
+ d : np.ndarray, shape (nDOF,)
463
+ Solved global nodal displacement vector (m, rad).
464
+ f : np.ndarray, shape (nDOF,)
465
+ Unrestricted nodal force vector ``fU`` from :meth:`_forces`.
466
+
467
+ Returns
468
+ -------
469
+ r : np.ndarray
470
+ Reactions at fully-fixed DOFs (``restraints[i] == -1``), in DOF
471
+ order.
472
+ rs : np.ndarray
473
+ Spring forces ``-k_s * u_i`` (upward positive) at spring DOFs
474
+ (``restraints[i] > 0``), in DOF order.
475
+ """
476
+ residual = k @ d - f
477
+ restraints = self._beam.restraints
478
+ # For fixed DOFs the full nodal residual equals the support reaction,
479
+ # because d[i] = 0 leaves no ambiguity about which term dominates.
480
+ r = np.array([residual[i] for i in range(self._nDOF) if restraints[i] < 0])
481
+ # For spring DOFs, residual[i] = k_s*u_i + structural coupling - f_applied[i],
482
+ # so it is NOT purely the spring force when external nodal loads are present.
483
+ # Use -k_s*u_i explicitly: negative because the spring reaction is upward
484
+ # (positive) when the displacement is downward (negative).
485
+ rs = np.array(
486
+ [-restraints[i] * d[i] for i in range(self._nDOF) if restraints[i] > 0]
487
+ )
488
+ return r, rs
489
+
490
+ def _solver(self, A: np.ndarray, b: np.ndarray) -> np.ndarray:
491
+ """
492
+ Solve the restricted linear system ``A x = b`` for nodal displacements.
493
+
494
+ Parameters
495
+ ----------
496
+ A : np.ndarray, shape (nDOF, nDOF)
497
+ Restricted global stiffness matrix from :meth:`_apply_bc`.
498
+ b : np.ndarray, shape (nDOF,)
499
+ Restricted force vector from :meth:`_apply_bc`.
500
+
501
+ Returns
502
+ -------
503
+ x : np.ndarray, shape (nDOF,)
504
+ Nodal displacement vector.
505
+
506
+ Raises
507
+ ------
508
+ ValueError
509
+ If ``A`` is singular, indicating a geometrically unstable
510
+ structure (insufficient support restraints).
511
+ """
512
+ try:
513
+ x = np.linalg.solve(A, b)
514
+ except np.linalg.LinAlgError as exc:
515
+ raise ValueError(
516
+ "Structure is geometrically unstable: the stiffness matrix is "
517
+ "singular. Check that sufficient support restraints are defined."
518
+ ) from exc
519
+ return x
520
+
521
+ def plot_results(self):
522
+ """
523
+ Plot bending moment, shear force, and deflection diagrams.
524
+
525
+ Produces a three-panel figure of bending moment, shear force, and
526
+ deflection along the beam. Bending moment is plotted with the
527
+ sagging-positive convention (y-axis inverted so sagging appears below
528
+ the beam line).
529
+
530
+ .. note::
531
+ The axis labels ("kNm", "kN", "mm") and the deflection scaling
532
+ (×1000) assume a kN / m unit system. If a different consistent
533
+ unit system is used the plots will still be correct in shape but
534
+ the labels and deflection axis values will need interpretation.
535
+
536
+ Has no effect and prints a warning if :meth:`analyze` has not been
537
+ called yet.
538
+ """
539
+ if self._beam_results is None:
540
+ print("Nothing to plot - run analysis first")
541
+ return
542
+ res = self._beam_results.results
543
+ L = self._beam.length
544
+
545
+ fig, axs = plt.subplots(3, 1)
546
+
547
+ ax = axs[0]
548
+ ax.plot([0, L], [0, 0], "k", lw=2)
549
+ ax.plot(res.x, res.M, "r")
550
+ ax.invert_yaxis()
551
+ ax.grid()
552
+ ax.set_ylabel("Bending Moment (kNm)")
553
+
554
+ ax = axs[1]
555
+ ax.plot([0, L], [0, 0], "k", lw=2)
556
+ ax.plot(res.x, res.V, "r")
557
+ ax.grid()
558
+ ax.set_ylabel("Shear Force (kN)")
559
+
560
+ ax = axs[2]
561
+ ax.plot([0, L], [0, 0], "k", lw=2)
562
+ ax.plot(res.x, res.D * 1e3, "r")
563
+ ax.grid()
564
+ ax.set_ylabel("Deflection (mm)")
565
+ ax.set_xlabel("Distance along beam (m)")
566
+
567
+ plt.show()