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 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)