nusa 0.4.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.
- nusa/__init__.py +57 -0
- nusa/_mesh.py +174 -0
- nusa/analysis.py +131 -0
- nusa/element.py +463 -0
- nusa/img/nusa-logo.png +0 -0
- nusa/mesh.py +142 -0
- nusa/model.py +471 -0
- nusa/node.py +46 -0
- nusa/post.py +101 -0
- nusa/reporting.py +130 -0
- nusa/result.py +240 -0
- nusa/version.py +8 -0
- nusa/visualization.py +277 -0
- nusa-0.4.0.dist-info/METADATA +298 -0
- nusa-0.4.0.dist-info/RECORD +18 -0
- nusa-0.4.0.dist-info/WHEEL +5 -0
- nusa-0.4.0.dist-info/licenses/LICENSE +21 -0
- nusa-0.4.0.dist-info/top_level.txt +1 -0
nusa/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""NuSA: Numerical Structural Analysis in Python."""
|
|
2
|
+
|
|
3
|
+
from .version import __version__
|
|
4
|
+
from .analysis import LinearStaticAnalysis, solve
|
|
5
|
+
from .result import StaticResult
|
|
6
|
+
from .reporting import simple_report
|
|
7
|
+
from .post import element_field, nodal_field
|
|
8
|
+
from .visualization import (
|
|
9
|
+
plot_model,
|
|
10
|
+
plot_deformed_shape,
|
|
11
|
+
plot_element_field,
|
|
12
|
+
plot_moment_diagram,
|
|
13
|
+
plot_nodal_field,
|
|
14
|
+
plot_shear_diagram,
|
|
15
|
+
)
|
|
16
|
+
from .node import Node
|
|
17
|
+
from .element import Bar, Beam, Element, LinearTriangle, Spring, Truss
|
|
18
|
+
from .model import (
|
|
19
|
+
Model,
|
|
20
|
+
BarModel,
|
|
21
|
+
BeamModel,
|
|
22
|
+
LinearTriangleModel,
|
|
23
|
+
SpringModel,
|
|
24
|
+
TrussModel,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__author__ = "P.J. De Los Santos"
|
|
28
|
+
__email__ = "delossantosmfq@gmail.com"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"__version__",
|
|
32
|
+
"solve",
|
|
33
|
+
"LinearStaticAnalysis",
|
|
34
|
+
"StaticResult",
|
|
35
|
+
"simple_report",
|
|
36
|
+
"element_field",
|
|
37
|
+
"nodal_field",
|
|
38
|
+
"plot_model",
|
|
39
|
+
"plot_deformed_shape",
|
|
40
|
+
"plot_nodal_field",
|
|
41
|
+
"plot_element_field",
|
|
42
|
+
"plot_moment_diagram",
|
|
43
|
+
"plot_shear_diagram",
|
|
44
|
+
"Model",
|
|
45
|
+
"Element",
|
|
46
|
+
"Node",
|
|
47
|
+
"Spring",
|
|
48
|
+
"Bar",
|
|
49
|
+
"Truss",
|
|
50
|
+
"Beam",
|
|
51
|
+
"LinearTriangle",
|
|
52
|
+
"SpringModel",
|
|
53
|
+
"BarModel",
|
|
54
|
+
"TrussModel",
|
|
55
|
+
"BeamModel",
|
|
56
|
+
"LinearTriangleModel",
|
|
57
|
+
]
|
nusa/_mesh.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# ***********************************
|
|
2
|
+
# Author: Pedro Jorge De Los Santos
|
|
3
|
+
# E-mail: delossantosmfq@gmail.com
|
|
4
|
+
# Blog: numython.github.io
|
|
5
|
+
# License: MIT License
|
|
6
|
+
# ***********************************
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_gmsh_command(gmsh_executable, arguments, *, windows=None):
|
|
18
|
+
"""Resolve Gmsh and wrap Windows batch launchers when needed."""
|
|
19
|
+
resolved = shutil.which(gmsh_executable)
|
|
20
|
+
if resolved is None:
|
|
21
|
+
raise RuntimeError(
|
|
22
|
+
f"Gmsh executable {gmsh_executable!r} was not found on PATH. "
|
|
23
|
+
"Install Gmsh, verify it with 'gmsh --version', or pass an "
|
|
24
|
+
"explicit path with gmsh_executable=..."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if windows is None:
|
|
28
|
+
windows = os.name == "nt"
|
|
29
|
+
|
|
30
|
+
if windows and resolved.lower().endswith((".bat", ".cmd")):
|
|
31
|
+
return [
|
|
32
|
+
os.environ.get("COMSPEC", "cmd.exe"),
|
|
33
|
+
"/d",
|
|
34
|
+
"/c",
|
|
35
|
+
resolved,
|
|
36
|
+
*arguments,
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
return [resolved, *arguments]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_meshio():
|
|
43
|
+
try:
|
|
44
|
+
import meshio
|
|
45
|
+
except ModuleNotFoundError as exc:
|
|
46
|
+
raise ModuleNotFoundError(
|
|
47
|
+
"NuSA mesh utilities require meshio, which is a default NuSA "
|
|
48
|
+
"dependency. Reinstall NuSA or install meshio explicitly."
|
|
49
|
+
) from exc
|
|
50
|
+
return meshio
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_triangle_mesh(filename):
|
|
54
|
+
"""Read mesh points and triangular connectivity with meshio."""
|
|
55
|
+
meshio = _load_meshio()
|
|
56
|
+
mesh = meshio.read(filename)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
triangles = np.asarray(mesh.cells_dict["triangle"], dtype=int)
|
|
60
|
+
except KeyError as exc:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Mesh file {str(filename)!r} does not contain triangle cells"
|
|
63
|
+
) from exc
|
|
64
|
+
|
|
65
|
+
used_points = np.unique(triangles)
|
|
66
|
+
remap = np.full(len(mesh.points), -1, dtype=int)
|
|
67
|
+
remap[used_points] = np.arange(len(used_points), dtype=int)
|
|
68
|
+
|
|
69
|
+
points = np.asarray(mesh.points)[used_points]
|
|
70
|
+
triangles = remap[triangles]
|
|
71
|
+
return points, triangles
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SimpleGMSH:
|
|
75
|
+
"""Minimal Gmsh .geo builder used by :class:`nusa.mesh.Modeler`."""
|
|
76
|
+
|
|
77
|
+
def __init__(self):
|
|
78
|
+
self.ID_POINT = 0
|
|
79
|
+
self.ID_LINE = 0
|
|
80
|
+
self.ID_CIRCLE = 10000
|
|
81
|
+
self.ID_LINE_LOOP = 0
|
|
82
|
+
self.ID_PLANE_SURFACE = 0
|
|
83
|
+
self.GMSH_CODE = []
|
|
84
|
+
|
|
85
|
+
def add_point(self, coords, esize=0.1):
|
|
86
|
+
self.ID_POINT += 1
|
|
87
|
+
x, y = coords[0], coords[1]
|
|
88
|
+
name = str(self.ID_POINT)
|
|
89
|
+
self.GMSH_CODE.append(
|
|
90
|
+
f"Point({name}) = {{ {x},{y},0,{esize} }};"
|
|
91
|
+
)
|
|
92
|
+
return name
|
|
93
|
+
|
|
94
|
+
def add_line(self, p0, p1):
|
|
95
|
+
self.ID_LINE += 1
|
|
96
|
+
name = str(self.ID_LINE)
|
|
97
|
+
self.GMSH_CODE.append(f"Line({name}) = {{ {p0},{p1} }};")
|
|
98
|
+
return name
|
|
99
|
+
|
|
100
|
+
def add_circle_arc(self, center, start, end):
|
|
101
|
+
self.ID_CIRCLE += 1
|
|
102
|
+
name = str(self.ID_CIRCLE)
|
|
103
|
+
self.GMSH_CODE.append(
|
|
104
|
+
f"Circle({name}) = {{ {start},{center},{end} }};"
|
|
105
|
+
)
|
|
106
|
+
return name
|
|
107
|
+
|
|
108
|
+
def add_line_loop(self, *lines):
|
|
109
|
+
self.ID_LINE_LOOP += 1
|
|
110
|
+
name = str(self.ID_LINE_LOOP)
|
|
111
|
+
self.GMSH_CODE.append(
|
|
112
|
+
f"Line Loop({name}) = {{{','.join(lines)}}};"
|
|
113
|
+
)
|
|
114
|
+
return name
|
|
115
|
+
|
|
116
|
+
def add_plane_surface(self, *loops):
|
|
117
|
+
self.ID_PLANE_SURFACE += 1
|
|
118
|
+
name = str(self.ID_PLANE_SURFACE)
|
|
119
|
+
self.GMSH_CODE.append(
|
|
120
|
+
f"Plane Surface({name}) = {{{','.join(loops)}}};"
|
|
121
|
+
)
|
|
122
|
+
return name
|
|
123
|
+
|
|
124
|
+
def delete_surfaces(self, *surfaces):
|
|
125
|
+
self.GMSH_CODE.append(
|
|
126
|
+
f"Delete {{ Surface{{ {','.join(surfaces)} }}; }}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def get_code(self):
|
|
130
|
+
return "\n".join(self.GMSH_CODE)
|
|
131
|
+
|
|
132
|
+
def generate_mesh(self, verbose=False, gmsh_executable="gmsh"):
|
|
133
|
+
"""Generate a 2D triangular mesh using the external Gmsh executable."""
|
|
134
|
+
with tempfile.TemporaryDirectory(prefix="nusa-gmsh-") as tmpdir:
|
|
135
|
+
tmpdir = Path(tmpdir)
|
|
136
|
+
geo_path = tmpdir / "model.geo"
|
|
137
|
+
msh_path = tmpdir / "model.msh"
|
|
138
|
+
geo_path.write_text(self.get_code(), encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
arguments = [
|
|
141
|
+
"-2",
|
|
142
|
+
str(geo_path),
|
|
143
|
+
"-format",
|
|
144
|
+
"msh2",
|
|
145
|
+
"-o",
|
|
146
|
+
str(msh_path),
|
|
147
|
+
]
|
|
148
|
+
command = _resolve_gmsh_command(gmsh_executable, arguments)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
result = subprocess.run(
|
|
152
|
+
command,
|
|
153
|
+
check=True,
|
|
154
|
+
text=True,
|
|
155
|
+
capture_output=not verbose,
|
|
156
|
+
)
|
|
157
|
+
except FileNotFoundError as exc:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
f"Gmsh command {command[0]!r} could not be started"
|
|
160
|
+
) from exc
|
|
161
|
+
except subprocess.CalledProcessError as exc:
|
|
162
|
+
details = (exc.stderr or exc.stdout or "").strip()
|
|
163
|
+
message = "Gmsh failed while generating the 2D mesh"
|
|
164
|
+
if details:
|
|
165
|
+
message += f": {details}"
|
|
166
|
+
raise RuntimeError(message) from exc
|
|
167
|
+
|
|
168
|
+
if verbose and result.stdout:
|
|
169
|
+
print(result.stdout, end="")
|
|
170
|
+
|
|
171
|
+
if not msh_path.exists():
|
|
172
|
+
raise RuntimeError("Gmsh completed without creating a mesh file")
|
|
173
|
+
|
|
174
|
+
return read_triangle_mesh(msh_path)
|
nusa/analysis.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Linear-static finite-element analysis orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .model import Model
|
|
8
|
+
from .result import StaticResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _element_dof_indices(model, element):
|
|
12
|
+
indices = []
|
|
13
|
+
for node in element.nodes:
|
|
14
|
+
node_index = model._get_node_index(node)
|
|
15
|
+
base = model.dof * node_index
|
|
16
|
+
indices.extend(base + component for component in range(model.dof))
|
|
17
|
+
return indices
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _assemble_stiffness(model):
|
|
21
|
+
model._validate_topology()
|
|
22
|
+
size = model.dof * model.n_nodes
|
|
23
|
+
stiffness = np.zeros((size, size), dtype=float)
|
|
24
|
+
|
|
25
|
+
for element in model.elements:
|
|
26
|
+
element_stiffness = np.asarray(
|
|
27
|
+
element.get_element_stiffness(), dtype=float
|
|
28
|
+
)
|
|
29
|
+
indices = _element_dof_indices(model, element)
|
|
30
|
+
stiffness[np.ix_(indices, indices)] += element_stiffness
|
|
31
|
+
|
|
32
|
+
return stiffness
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _partition_system(stiffness, loads, prescribed):
|
|
36
|
+
prescribed_dofs = np.flatnonzero(~np.isnan(prescribed))
|
|
37
|
+
free_dofs = np.flatnonzero(np.isnan(prescribed))
|
|
38
|
+
|
|
39
|
+
reduced = stiffness[np.ix_(free_dofs, free_dofs)]
|
|
40
|
+
rhs = loads[free_dofs].copy()
|
|
41
|
+
|
|
42
|
+
if prescribed_dofs.size:
|
|
43
|
+
coupling = stiffness[np.ix_(free_dofs, prescribed_dofs)]
|
|
44
|
+
rhs -= coupling @ prescribed[prescribed_dofs]
|
|
45
|
+
|
|
46
|
+
return prescribed_dofs, free_dofs, reduced, rhs
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LinearStaticAnalysis:
|
|
50
|
+
"""Perform a small-displacement linear-static finite-element analysis."""
|
|
51
|
+
|
|
52
|
+
def solve(self, model):
|
|
53
|
+
"""Solve *model* and return an independent :class:`StaticResult`."""
|
|
54
|
+
if not isinstance(model, Model):
|
|
55
|
+
raise TypeError("LinearStaticAnalysis.solve() requires a Model")
|
|
56
|
+
|
|
57
|
+
nodes = tuple(model.nodes)
|
|
58
|
+
elements = tuple(model.elements)
|
|
59
|
+
stiffness = _assemble_stiffness(model)
|
|
60
|
+
loads = np.asarray(model.applied_loads, dtype=float).copy()
|
|
61
|
+
prescribed = np.asarray(
|
|
62
|
+
model.prescribed_displacements, dtype=float
|
|
63
|
+
).copy()
|
|
64
|
+
|
|
65
|
+
(
|
|
66
|
+
prescribed_dofs,
|
|
67
|
+
free_dofs,
|
|
68
|
+
reduced,
|
|
69
|
+
rhs,
|
|
70
|
+
) = _partition_system(stiffness, loads, prescribed)
|
|
71
|
+
|
|
72
|
+
if reduced.size:
|
|
73
|
+
if np.linalg.matrix_rank(reduced) < reduced.shape[0]:
|
|
74
|
+
raise np.linalg.LinAlgError(
|
|
75
|
+
"Singular stiffness matrix: the model may be "
|
|
76
|
+
"underconstrained or contain a mechanism."
|
|
77
|
+
)
|
|
78
|
+
free_displacements = np.linalg.solve(reduced, rhs)
|
|
79
|
+
else:
|
|
80
|
+
free_displacements = np.empty(0, dtype=float)
|
|
81
|
+
|
|
82
|
+
displacements = prescribed.copy()
|
|
83
|
+
displacements[free_dofs] = free_displacements
|
|
84
|
+
|
|
85
|
+
nodal_forces = stiffness @ displacements
|
|
86
|
+
reactions = np.zeros_like(nodal_forces)
|
|
87
|
+
reactions[prescribed_dofs] = (
|
|
88
|
+
nodal_forces[prescribed_dofs] - loads[prescribed_dofs]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
node_indices = {node: index for index, node in enumerate(nodes)}
|
|
92
|
+
connectivity = tuple(
|
|
93
|
+
tuple(node_indices[node] for node in element.nodes)
|
|
94
|
+
for element in elements
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
element_results = []
|
|
98
|
+
for element in elements:
|
|
99
|
+
indices = _element_dof_indices(model, element)
|
|
100
|
+
u_e = displacements[indices]
|
|
101
|
+
values = {
|
|
102
|
+
name: float(value)
|
|
103
|
+
for name, value in element.compute_results(u_e).items()
|
|
104
|
+
}
|
|
105
|
+
element_results.append(values)
|
|
106
|
+
|
|
107
|
+
return StaticResult(
|
|
108
|
+
model_name=model.name,
|
|
109
|
+
node_objects=nodes,
|
|
110
|
+
node_labels=tuple(node.label for node in nodes),
|
|
111
|
+
node_coordinates=np.array(
|
|
112
|
+
[[node.x, node.y] for node in nodes], dtype=float
|
|
113
|
+
),
|
|
114
|
+
element_objects=elements,
|
|
115
|
+
element_labels=tuple(element.label for element in elements),
|
|
116
|
+
element_types=tuple(element.etype for element in elements),
|
|
117
|
+
connectivity=connectivity,
|
|
118
|
+
displacement_dofs=model.displacement_dofs,
|
|
119
|
+
force_dofs=model.force_dofs,
|
|
120
|
+
applied_loads=loads,
|
|
121
|
+
prescribed_displacements=prescribed,
|
|
122
|
+
displacements=displacements,
|
|
123
|
+
nodal_forces=nodal_forces,
|
|
124
|
+
reactions=reactions,
|
|
125
|
+
element_results=tuple(element_results),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def solve(model):
|
|
130
|
+
"""Solve *model* using the default linear-static analysis."""
|
|
131
|
+
return LinearStaticAnalysis().solve(model)
|