ebbef2p-python 0.0.2.dev1__py3-none-any.whl → 1.0.0__py3-none-any.whl

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.
ebbef2p/__init__.py CHANGED
@@ -1,2 +1,24 @@
1
- __all__ = ["structure", "beam", "element"]
2
- #name = "ebbef2p"
1
+ """EBBEF2p: Euler-Bernoulli beams on two-parameter elastic foundations."""
2
+
3
+ from .exceptions import ConvergenceError, EBBEF2pError, ModelError, UnstableModelError
4
+ from .model import Model
5
+ from .results import ElementEndValues, ReactivePressure, Solution
6
+ from .solver import solve
7
+ from .vlasov import VlasovResult, VlasovSoil, solve_vlasov
8
+
9
+ __all__ = [
10
+ "ConvergenceError",
11
+ "EBBEF2pError",
12
+ "ElementEndValues",
13
+ "Model",
14
+ "ModelError",
15
+ "ReactivePressure",
16
+ "Solution",
17
+ "UnstableModelError",
18
+ "VlasovResult",
19
+ "VlasovSoil",
20
+ "solve",
21
+ "solve_vlasov",
22
+ ]
23
+
24
+ __version__ = "1.0.0"
ebbef2p/elements.py ADDED
@@ -0,0 +1,117 @@
1
+ """Element matrices for an Euler-Bernoulli beam on a two-parameter foundation.
2
+
3
+ Two-node element, degrees of freedom ``(w_i, theta_i, w_j, theta_j)`` with
4
+ ``theta = dw/dx``, cubic Hermite interpolation. All foundation and load
5
+ quantities vary linearly along the element, so every matrix below is the
6
+ exact Galerkin integral for that variation.
7
+
8
+ The matrix functions accept scalars or arrays of element properties; for
9
+ arrays of shape ``(n,)`` they return a stack of shape ``(n, 4, 4)`` (or
10
+ ``(n, 4)`` for load vectors).
11
+ """
12
+
13
+ import numpy as np
14
+ from numpy.typing import ArrayLike, NDArray
15
+
16
+
17
+ def _stack(rows: list[list[ArrayLike]]) -> NDArray[np.float64]:
18
+ """Matrix (or stack of matrices) from a nested list of broadcastable entries."""
19
+ entries = np.broadcast_arrays(*(np.asarray(v, dtype=float) for row in rows for v in row))
20
+ m = np.stack(entries, axis=-1)
21
+ return m.reshape((*m.shape[:-1], len(rows), len(rows[0])))
22
+
23
+
24
+ def _rows(*entries: ArrayLike) -> NDArray[np.float64]:
25
+ """Stack broadcastable entries along a new first axis."""
26
+ return np.stack(np.broadcast_arrays(*(np.asarray(v, dtype=float) for v in entries)))
27
+
28
+
29
+ def shape_functions(xi: ArrayLike, l: ArrayLike) -> NDArray[np.float64]:
30
+ """Hermite shape functions at the local coordinate ``xi`` in [0, 1].
31
+
32
+ ``xi`` and ``l`` broadcast against each other; the result has the four
33
+ functions along the first axis.
34
+ """
35
+ xi, l = np.asarray(xi, dtype=float), np.asarray(l, dtype=float)
36
+ return _rows(
37
+ 1 - 3*xi**2 + 2*xi**3,
38
+ l*xi*(1 - xi)**2,
39
+ 3*xi**2 - 2*xi**3,
40
+ l*(xi**3 - xi**2),
41
+ )
42
+
43
+
44
+ def shape_function_derivatives(xi: ArrayLike, l: ArrayLike, order: int) -> NDArray[np.float64]:
45
+ """Derivatives of the shape functions with respect to ``x``."""
46
+ xi, l = np.asarray(xi, dtype=float), np.asarray(l, dtype=float)
47
+ if order == 1:
48
+ return _rows(
49
+ (-6*xi + 6*xi**2)/l,
50
+ 1 - 4*xi + 3*xi**2,
51
+ (6*xi - 6*xi**2)/l,
52
+ 3*xi**2 - 2*xi,
53
+ )
54
+ if order == 2:
55
+ return _rows(
56
+ (-6 + 12*xi)/l**2,
57
+ (-4 + 6*xi)/l,
58
+ (6 - 12*xi)/l**2,
59
+ (-2 + 6*xi)/l,
60
+ )
61
+ if order == 3:
62
+ z = np.zeros_like(xi)
63
+ return _rows(12/l**3 + z, 6/l**2 + z, -12/l**3 + z, 6/l**2 + z)
64
+ raise ValueError("order must be 1, 2 or 3")
65
+
66
+
67
+ def bending_stiffness(EI: ArrayLike, l: ArrayLike) -> NDArray[np.float64]:
68
+ """Flexural stiffness matrix, int EI N''^T N'' dx."""
69
+ EI, l = np.asarray(EI, dtype=float), np.asarray(l, dtype=float)
70
+ c = EI/l**3
71
+ return _stack([
72
+ [12*c, 6*l*c, -12*c, 6*l*c],
73
+ [6*l*c, 4*l**2*c, -6*l*c, 2*l**2*c],
74
+ [-12*c, -6*l*c, 12*c, -6*l*c],
75
+ [6*l*c, 2*l**2*c, -6*l*c, 4*l**2*c],
76
+ ])
77
+
78
+
79
+ def winkler_stiffness(k_i: ArrayLike, k_j: ArrayLike, l: ArrayLike) -> NDArray[np.float64]:
80
+ """First-parameter foundation matrix, int k(x) N^T N dx, k linear."""
81
+ a, b, l = (np.asarray(v, dtype=float) for v in (k_i, k_j, l))
82
+ return _stack([
83
+ [l*(10*a + 3*b)/35, l**2*(15*a + 7*b)/420,
84
+ 9*l*(a + b)/140, -l**2*(7*a + 6*b)/420],
85
+ [l**2*(15*a + 7*b)/420, l**3*(5*a + 3*b)/840,
86
+ l**2*(6*a + 7*b)/420, -l**3*(a + b)/280],
87
+ [9*l*(a + b)/140, l**2*(6*a + 7*b)/420,
88
+ l*(3*a + 10*b)/35, -l**2*(7*a + 15*b)/420],
89
+ [-l**2*(7*a + 6*b)/420, -l**3*(a + b)/280,
90
+ -l**2*(7*a + 15*b)/420, l**3*(3*a + 5*b)/840],
91
+ ])
92
+
93
+
94
+ def shear_stiffness(g_i: ArrayLike, g_j: ArrayLike, l: ArrayLike) -> NDArray[np.float64]:
95
+ """Second-parameter foundation matrix, int g(x) N'^T N' dx, g linear.
96
+
97
+ ``g`` is the shear parameter of the foundation: 2t in Vlasov's notation,
98
+ G_p in Pasternak's.
99
+ """
100
+ a, b, l = (np.asarray(v, dtype=float) for v in (g_i, g_j, l))
101
+ return _stack([
102
+ [3*(a + b)/(5*l), b/10, -3*(a + b)/(5*l), a/10],
103
+ [b/10, l*(3*a + b)/30, -b/10, -l*(a + b)/60],
104
+ [-3*(a + b)/(5*l), -b/10, 3*(a + b)/(5*l), -a/10],
105
+ [a/10, -l*(a + b)/60, -a/10, l*(a + 3*b)/30],
106
+ ])
107
+
108
+
109
+ def distributed_load_vector(q_i: ArrayLike, q_j: ArrayLike, l: ArrayLike) -> NDArray[np.float64]:
110
+ """Consistent nodal loads, int q(x) N dx, q linear."""
111
+ q_i, q_j, l = (np.asarray(v, dtype=float) for v in (q_i, q_j, l))
112
+ return _stack([[
113
+ l*(7*q_i + 3*q_j)/20,
114
+ l**2*(3*q_i + 2*q_j)/60,
115
+ l*(3*q_i + 7*q_j)/20,
116
+ -l**2*(2*q_i + 3*q_j)/60,
117
+ ]])[..., 0, :]
ebbef2p/exceptions.py ADDED
@@ -0,0 +1,23 @@
1
+ """Exceptions raised by EBBEF2p.
2
+
3
+ Each one also derives from the built-in exception that describes it, so
4
+ ``except ValueError`` and the like keep working.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+
10
+ class EBBEF2pError(Exception):
11
+ """Base class for all EBBEF2p errors."""
12
+
13
+
14
+ class ModelError(EBBEF2pError, ValueError):
15
+ """The problem definition is invalid or inconsistent."""
16
+
17
+
18
+ class UnstableModelError(EBBEF2pError, np.linalg.LinAlgError):
19
+ """The beam can move as a rigid body: the system matrix is singular."""
20
+
21
+
22
+ class ConvergenceError(EBBEF2pError, RuntimeError):
23
+ """An iterative procedure (the gamma iteration) did not converge."""
ebbef2p/mesh.py ADDED
@@ -0,0 +1,116 @@
1
+ """Discretisation of a :class:`~ebbef2p.model.Model` into beam elements."""
2
+
3
+ from dataclasses import dataclass
4
+ from itertools import pairwise
5
+
6
+ import numpy as np
7
+ from numpy.typing import NDArray
8
+
9
+ from .exceptions import ModelError
10
+ from .model import Model
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Mesh:
15
+ """Nodes and per-element properties.
16
+
17
+ Attributes
18
+ ----------
19
+ nodes : (n+1,) array
20
+ EI : (n,) array
21
+ k, g : (n, 2) arrays
22
+ Foundation parameters at the start and end of every element.
23
+ q : (n, 2) array
24
+ Distributed load at the start and end of every element.
25
+ """
26
+ nodes: NDArray[np.float64]
27
+ EI: NDArray[np.float64]
28
+ k: NDArray[np.float64]
29
+ g: NDArray[np.float64]
30
+ q: NDArray[np.float64]
31
+
32
+ @property
33
+ def n_elements(self) -> int:
34
+ return len(self.nodes) - 1
35
+
36
+ @property
37
+ def lengths(self) -> NDArray[np.float64]:
38
+ return np.diff(self.nodes)
39
+
40
+ @property
41
+ def element_dofs(self) -> NDArray[np.intp]:
42
+ """Global degrees of freedom of every element, shape (n, 4)."""
43
+ return 2*np.arange(self.n_elements)[:, None] + np.arange(4)[None, :]
44
+
45
+ def node_index(self, x: float, tol: float = 1e-9) -> int:
46
+ """Index of the node at ``x`` (raises if there is none)."""
47
+ span = self.nodes[-1] - self.nodes[0]
48
+ i = int(np.argmin(np.abs(self.nodes - x)))
49
+ if abs(self.nodes[i] - x) > tol*max(1.0, span):
50
+ raise ModelError(f"no node at x = {x}")
51
+ return i
52
+
53
+ def element_at(self, x: float | NDArray[np.float64]) -> NDArray[np.intp]:
54
+ """Index of the element containing ``x`` (right-continuous)."""
55
+ i = np.searchsorted(self.nodes, x, side='right') - 1
56
+ return np.clip(i, 0, self.n_elements - 1)
57
+
58
+
59
+ def end_spring_stiffness(mesh: Mesh) -> tuple[float, float]:
60
+ """Springs representing the foundation beyond each beam end.
61
+
62
+ For a two-parameter foundation extending to infinity, the surface
63
+ deflection outside the beam decays as w_end*exp(-sqrt(k/g)*s), and that
64
+ soil reacts on the beam end with a force sqrt(k*g)*w_end. Returns the
65
+ spring stiffnesses at the start and end of the beam.
66
+ """
67
+ return (float(np.sqrt(mesh.k[0, 0]*mesh.g[0, 0])),
68
+ float(np.sqrt(mesh.k[-1, 1]*mesh.g[-1, 1])))
69
+
70
+
71
+ def _interp_segment(x, x0, x1, v0, v1):
72
+ return v0 + (v1 - v0)*(x - x0)/(x1 - x0)
73
+
74
+
75
+ def build_mesh(model: Model, n_elements: int = 100) -> Mesh:
76
+ """Mesh the model with roughly ``n_elements`` elements.
77
+
78
+ Every key point of the model (segment ends, loads, supports) becomes a
79
+ node; each interval between key points is divided uniformly so that the
80
+ element size is close to ``beam length / n_elements``.
81
+ """
82
+ model.validate()
83
+ x_start, x_end = model.extent
84
+ h = (x_end - x_start)/max(int(n_elements), 1)
85
+
86
+ pts = model.key_points()
87
+ nodes = [pts[0]]
88
+ for a, b in pairwise(pts):
89
+ n = max(1, int(np.ceil((b - a)/h - 1e-9)))
90
+ nodes.extend(np.linspace(a, b, n + 1)[1:])
91
+ nodes = np.array(nodes)
92
+
93
+ n = len(nodes) - 1
94
+ xi, xj = nodes[:-1], nodes[1:]
95
+ xm = 0.5*(xi + xj)
96
+
97
+ EI = np.empty(n)
98
+ for b in model.beams:
99
+ inside = (xm > b.x0) & (xm < b.x1)
100
+ EI[inside] = b.EI
101
+
102
+ k = np.zeros((n, 2))
103
+ g = np.zeros((n, 2))
104
+ for f in model.foundation:
105
+ inside = (xm > f.x0) & (xm < f.x1)
106
+ for col, x in enumerate((xi, xj)):
107
+ k[inside, col] += _interp_segment(x[inside], f.x0, f.x1, *f.k)
108
+ g[inside, col] += _interp_segment(x[inside], f.x0, f.x1, *f.g)
109
+
110
+ q = np.zeros((n, 2))
111
+ for d in model.distributed_loads:
112
+ inside = (xm > d.x0) & (xm < d.x1)
113
+ for col, x in enumerate((xi, xj)):
114
+ q[inside, col] += _interp_segment(x[inside], d.x0, d.x1, d.q0, d.q1)
115
+
116
+ return Mesh(nodes=nodes, EI=EI, k=k, g=g, q=q)
ebbef2p/model.py ADDED
@@ -0,0 +1,189 @@
1
+ """Problem definition: beam segments, foundation, loads and supports.
2
+
3
+ Conventions
4
+ -----------
5
+ * ``x`` runs along the beam axis; ``w`` is the transverse displacement,
6
+ positive downwards (i.e. a settlement is positive).
7
+ * ``theta = dw/dx``.
8
+ * Forces and distributed loads are positive downwards. A concentrated moment
9
+ is positive when it does positive work on a positive ``theta``.
10
+ * No system of units is built in; use any consistent set.
11
+ """
12
+
13
+ from dataclasses import dataclass, field
14
+ from itertools import pairwise
15
+ from typing import Self
16
+
17
+ from .exceptions import ModelError
18
+
19
+ Pair = tuple[float, float]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class BeamSegment:
24
+ """Beam portion ``[x0, x1]`` with constant bending stiffness."""
25
+ x0: float
26
+ x1: float
27
+ E: float
28
+ I: float
29
+
30
+ @property
31
+ def EI(self) -> float:
32
+ return self.E*self.I
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class FoundationSegment:
37
+ """Two-parameter foundation acting on ``[x0, x1]``.
38
+
39
+ ``k`` is the first (Winkler) parameter and ``g`` the second (shear)
40
+ parameter, both given as ``(value at x0, value at x1)`` and varying
41
+ linearly in between. ``g`` equals 2t in Vlasov's notation and G_p in
42
+ Pasternak's. Overlapping segments add up.
43
+ """
44
+ x0: float
45
+ x1: float
46
+ k: Pair = (0.0, 0.0)
47
+ g: Pair = (0.0, 0.0)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class PointLoad:
52
+ x: float
53
+ force: float = 0.0
54
+ moment: float = 0.0
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class DistributedLoad:
59
+ """Load per unit length on ``[x0, x1]``, varying linearly from q0 to q1."""
60
+ x0: float
61
+ x1: float
62
+ q0: float
63
+ q1: float
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Support:
68
+ """Prescribed displacement and/or rotation at ``x``; ``None`` leaves it free."""
69
+ x: float
70
+ w: float | None = 0.0
71
+ theta: float | None = None
72
+
73
+
74
+ def _pair(value: float | Pair) -> Pair:
75
+ if isinstance(value, (int, float)):
76
+ return (float(value), float(value))
77
+ a, b = value
78
+ return (float(a), float(b))
79
+
80
+
81
+ def _optional_float(value: float | None) -> float | None:
82
+ return None if value is None else float(value)
83
+
84
+
85
+ @dataclass
86
+ class Model:
87
+ """A beam on a two-parameter elastic foundation.
88
+
89
+ Parameters
90
+ ----------
91
+ soil_beyond_ends : bool
92
+ If True, the foundation continues indefinitely beyond both beam ends.
93
+ Its effect is represented by an equivalent spring sqrt(k*g) at each
94
+ end, with k and g taken at that end (Vallabhan & Das, 1991).
95
+
96
+ Examples
97
+ --------
98
+ >>> m = Model()
99
+ >>> m.add_beam(0, 10, E=30e6, I=0.01)
100
+ >>> m.add_foundation(0, 10, k=5000, g=2000)
101
+ >>> m.add_point_load(5, force=100)
102
+ """
103
+ beams: list[BeamSegment] = field(default_factory=list)
104
+ foundation: list[FoundationSegment] = field(default_factory=list)
105
+ point_loads: list[PointLoad] = field(default_factory=list)
106
+ distributed_loads: list[DistributedLoad] = field(default_factory=list)
107
+ supports: list[Support] = field(default_factory=list)
108
+ soil_beyond_ends: bool = False
109
+
110
+ # -- building -------------------------------------------------------
111
+ def add_beam(self, x0: float, x1: float, E: float, I: float) -> Self:
112
+ """Add a beam segment with constant E and I."""
113
+ if x1 <= x0:
114
+ raise ModelError("beam segment must have x1 > x0")
115
+ if E <= 0 or I <= 0:
116
+ raise ModelError("E and I must be positive")
117
+ self.beams.append(BeamSegment(float(x0), float(x1), float(E), float(I)))
118
+ return self
119
+
120
+ def add_foundation(self, x0: float, x1: float, k: float | Pair = 0.0,
121
+ g: float | Pair = 0.0) -> Self:
122
+ """Add a foundation segment; ``k`` and ``g`` are scalars or (start, end) pairs."""
123
+ if x1 <= x0:
124
+ raise ModelError("foundation segment must have x1 > x0")
125
+ k, g = _pair(k), _pair(g)
126
+ if min(k) < 0 or min(g) < 0:
127
+ raise ModelError("foundation parameters must be non-negative")
128
+ self.foundation.append(FoundationSegment(float(x0), float(x1), k, g))
129
+ return self
130
+
131
+ def add_point_load(self, x: float, force: float = 0.0, moment: float = 0.0) -> Self:
132
+ """Add a concentrated force and/or moment at ``x``."""
133
+ self.point_loads.append(PointLoad(float(x), float(force), float(moment)))
134
+ return self
135
+
136
+ def add_distributed_load(self, x0: float, x1: float, q0: float,
137
+ q1: float | None = None) -> Self:
138
+ """Add a load varying from ``q0`` at ``x0`` to ``q1`` at ``x1`` (uniform if q1 is None)."""
139
+ if x1 <= x0:
140
+ raise ModelError("distributed load must have x1 > x0")
141
+ q1 = q0 if q1 is None else q1
142
+ self.distributed_loads.append(DistributedLoad(float(x0), float(x1), float(q0), float(q1)))
143
+ return self
144
+
145
+ def add_support(self, x: float, w: float | None = 0.0,
146
+ theta: float | None = None) -> Self:
147
+ """Prescribe ``w`` and/or ``theta`` at ``x`` (``None`` = free).
148
+
149
+ Defaults to a simple support (w = 0, rotation free). Use
150
+ ``theta=0`` for a clamped end.
151
+ """
152
+ if w is None and theta is None:
153
+ raise ModelError("a support must constrain w, theta or both")
154
+ self.supports.append(Support(float(x), _optional_float(w), _optional_float(theta)))
155
+ return self
156
+
157
+ # -- queries ----------------------------------------------------------
158
+ @property
159
+ def extent(self) -> Pair:
160
+ """(x_start, x_end) of the beam."""
161
+ if not self.beams:
162
+ raise ModelError("the model has no beam")
163
+ return (min(b.x0 for b in self.beams), max(b.x1 for b in self.beams))
164
+
165
+ def key_points(self) -> list[float]:
166
+ """Coordinates that must coincide with mesh nodes."""
167
+ pts = set()
168
+ for b in self.beams:
169
+ pts.update((b.x0, b.x1))
170
+ for f in self.foundation:
171
+ pts.update((f.x0, f.x1))
172
+ for d in self.distributed_loads:
173
+ pts.update((d.x0, d.x1))
174
+ pts.update(p.x for p in self.point_loads)
175
+ pts.update(s.x for s in self.supports)
176
+ return sorted(pts)
177
+
178
+ def validate(self, tol: float = 1e-9) -> None:
179
+ """Check that beam segments tile the beam and everything lies on it."""
180
+ if not self.beams:
181
+ raise ModelError("the model has no beam")
182
+ segs = sorted(self.beams, key=lambda b: b.x0)
183
+ for a, b in pairwise(segs):
184
+ if abs(a.x1 - b.x0) > tol:
185
+ raise ModelError(f"beam segments leave a gap or overlap at x = {a.x1}")
186
+ x0, x1 = self.extent
187
+ for x in self.key_points():
188
+ if x < x0 - tol or x > x1 + tol:
189
+ raise ModelError(f"x = {x} lies outside the beam [{x0}, {x1}]")
ebbef2p/plotting.py ADDED
@@ -0,0 +1,36 @@
1
+ """Quick-look plots of a :class:`~ebbef2p.results.Solution` (needs matplotlib)."""
2
+
3
+ import numpy as np
4
+
5
+ from .results import ElementEndValues, Solution
6
+
7
+
8
+ def plot_solution(solution: Solution, axes=None):
9
+ """Deflection, bending moment, shear force and soil pressure, stacked.
10
+
11
+ Returns the array of matplotlib axes.
12
+ """
13
+ import matplotlib.pyplot as plt
14
+
15
+ if axes is None:
16
+ _, axes = plt.subplots(4, 1, sharex=True, figsize=(7, 9))
17
+
18
+ xs = np.linspace(solution.x[0], solution.x[-1], 400)
19
+ panels = [
20
+ (xs, solution.deflection(xs), "w", True),
21
+ (*_pairs(solution.bending_moment()), "M", True),
22
+ (*_pairs(solution.shear_force()), "V", False),
23
+ (*_pairs(solution.reactive_pressure()), "p", True),
24
+ ]
25
+ for ax, (x, y, label, invert) in zip(axes, panels, strict=True):
26
+ ax.plot(x, y)
27
+ ax.axhline(0, color="0.6", lw=0.8)
28
+ ax.set_ylabel(label)
29
+ if invert:
30
+ ax.invert_yaxis()
31
+ axes[-1].set_xlabel("x")
32
+ return axes
33
+
34
+
35
+ def _pairs(values: ElementEndValues):
36
+ return values.x, values.values