ANYsolver 0.1.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.
- anysolver/__init__.py +631 -0
- anysolver/anystructure_fem_mode.py +942 -0
- anysolver/arc_length.py +758 -0
- anysolver/assembly.py +950 -0
- anysolver/baselines.py +303 -0
- anysolver/beam_shell_verification.py +4276 -0
- anysolver/beam_validity.py +110 -0
- anysolver/benchmarks.py +495 -0
- anysolver/boundary.py +476 -0
- anysolver/buckling.py +442 -0
- anysolver/buckling_validity.py +91 -0
- anysolver/capacity_workflow.py +350 -0
- anysolver/cases.py +173 -0
- anysolver/composite_strip_verification.py +297 -0
- anysolver/contact.py +3461 -0
- anysolver/corotational.py +371 -0
- anysolver/cylinder_benchmarks.py +364 -0
- anysolver/dynamics.py +723 -0
- anysolver/element_qualification.py +432 -0
- anysolver/elements.py +2724 -0
- anysolver/external_references.py +369 -0
- anysolver/fe_core.py +327 -0
- anysolver/fracture.py +551 -0
- anysolver/imperfections.py +390 -0
- anysolver/jit_compiler.py +108 -0
- anysolver/kernel_warmup.py +180 -0
- anysolver/linalg.py +760 -0
- anysolver/mass_properties.py +179 -0
- anysolver/material_curves.py +231 -0
- anysolver/matrix_assembly.py +558 -0
- anysolver/mesh_gen.py +1065 -0
- anysolver/mesh_load_bc_verification.py +609 -0
- anysolver/modal.py +282 -0
- anysolver/nonlinear.py +300 -0
- anysolver/nonlinear_performance.py +920 -0
- anysolver/nonlinear_performance_batch_b.py +592 -0
- anysolver/nonlinear_performance_batch_c.py +506 -0
- anysolver/nonlinear_performance_bootstrap.py +120 -0
- anysolver/nonlinear_reduced_assembly.py +760 -0
- anysolver/nonlinear_static.py +1518 -0
- anysolver/plasticity.py +419 -0
- anysolver/plasticity_qualification.py +314 -0
- anysolver/production_readiness.py +304 -0
- anysolver/quality_control.py +1497 -0
- anysolver/recovery.py +505 -0
- anysolver/recovery_policy.py +205 -0
- anysolver/reference_cases.py +425 -0
- anysolver/results.py +503 -0
- anysolver/runtime.py +9030 -0
- anysolver/s4_validity.py +326 -0
- anysolver/sesam_fem/__init__.py +55 -0
- anysolver/sesam_fem/__main__.py +80 -0
- anysolver/sesam_fem/diagnostics.py +65 -0
- anysolver/sesam_fem/document.py +814 -0
- anysolver/sesam_fem/exporter.py +84 -0
- anysolver/sesam_fem/importer.py +365 -0
- anysolver/sesam_fem/records.py +257 -0
- anysolver/sesam_fem/schema.py +107 -0
- anysolver/sesam_fem/sif_importer.py +397 -0
- anysolver/sesam_fem/validation.py +62 -0
- anysolver/shell_benchmarks.py +367 -0
- anysolver/test_cases.py +610 -0
- anysolver/validation.py +416 -0
- anysolver/vectorized_nonlinear.py +334 -0
- anysolver/vectorized_stiffness.py +571 -0
- anysolver-0.1.0.dist-info/METADATA +165 -0
- anysolver-0.1.0.dist-info/RECORD +70 -0
- anysolver-0.1.0.dist-info/WHEEL +5 -0
- anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
- anysolver-0.1.0.dist-info/top_level.txt +1 -0
anysolver/boundary.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Boundary Conditions and Load Cases
|
|
3
|
+
|
|
4
|
+
This module provides classes for defining boundary conditions and loads
|
|
5
|
+
for the FE model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .fe_core import DOFManager, FEMesh, Material, Node
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_SMALL = 1.0e-12
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _GravityFallbackMaterial:
|
|
23
|
+
"""Minimal material used when loads are assembled without model context."""
|
|
24
|
+
|
|
25
|
+
density = 7850.0
|
|
26
|
+
elastic_modulus = 210.0e9
|
|
27
|
+
poisson_ratio = 0.3
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def shear_modulus(self) -> float:
|
|
31
|
+
return self.elastic_modulus / (2.0 * (1.0 + self.poisson_ratio))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class BoundaryCondition:
|
|
36
|
+
"""
|
|
37
|
+
Base class for boundary conditions.
|
|
38
|
+
|
|
39
|
+
Boundary conditions constrain specific DOFs of nodes.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
node_ids: List[int]
|
|
44
|
+
dof_constraints: Dict[str, float]
|
|
45
|
+
|
|
46
|
+
def __post_init__(self):
|
|
47
|
+
dof_names = ["ux", "uy", "uz", "rx", "ry", "rz"]
|
|
48
|
+
self._dof_indices = {}
|
|
49
|
+
for dof_name, value in self.dof_constraints.items():
|
|
50
|
+
if dof_name in dof_names:
|
|
51
|
+
self._dof_indices[dof_name] = dof_names.index(dof_name)
|
|
52
|
+
|
|
53
|
+
def apply(self, dof_manager: "DOFManager"):
|
|
54
|
+
"""Apply this boundary condition to the DOF manager."""
|
|
55
|
+
for node_id in self.node_ids:
|
|
56
|
+
node_dofs = dof_manager.get_node_dofs(node_id)
|
|
57
|
+
if not node_dofs:
|
|
58
|
+
continue
|
|
59
|
+
for dof_name, value in self.dof_constraints.items():
|
|
60
|
+
if dof_name in self._dof_indices:
|
|
61
|
+
local_idx = self._dof_indices[dof_name]
|
|
62
|
+
global_dof = node_dofs[local_idx]
|
|
63
|
+
dof_manager.constrain_dof(global_dof)
|
|
64
|
+
|
|
65
|
+
def get_constrained_dofs(self, dof_manager: "DOFManager") -> List[Tuple[int, float]]:
|
|
66
|
+
"""Get list of (global_dof, prescribed_value) pairs."""
|
|
67
|
+
constrained = []
|
|
68
|
+
for node_id in self.node_ids:
|
|
69
|
+
node_dofs = dof_manager.get_node_dofs(node_id)
|
|
70
|
+
if not node_dofs:
|
|
71
|
+
continue
|
|
72
|
+
for dof_name, value in self.dof_constraints.items():
|
|
73
|
+
if dof_name in self._dof_indices:
|
|
74
|
+
local_idx = self._dof_indices[dof_name]
|
|
75
|
+
global_dof = node_dofs[local_idx]
|
|
76
|
+
constrained.append((global_dof, value))
|
|
77
|
+
return constrained
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class FixedSupport(BoundaryCondition):
|
|
82
|
+
"""Fully fixed support: all DOFs constrained to zero."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, name: str, node_ids: List[int]):
|
|
85
|
+
dof_constraints = {dof: 0.0 for dof in ["ux", "uy", "uz", "rx", "ry", "rz"]}
|
|
86
|
+
super().__init__(name, node_ids, dof_constraints)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class PinnedSupport(BoundaryCondition):
|
|
91
|
+
"""Pinned support: translational DOFs fixed, rotations free."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, name: str, node_ids: List[int]):
|
|
94
|
+
dof_constraints = {dof: 0.0 for dof in ["ux", "uy", "uz"]}
|
|
95
|
+
super().__init__(name, node_ids, dof_constraints)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class RollerSupport(BoundaryCondition):
|
|
100
|
+
"""Roller support constraining selected translational DOFs."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, name: str, node_ids: List[int], constrained_directions: Optional[List[str]] = None):
|
|
103
|
+
if constrained_directions is None:
|
|
104
|
+
constrained_directions = ["uy", "uz"]
|
|
105
|
+
dof_constraints = {dof: 0.0 for dof in constrained_directions}
|
|
106
|
+
super().__init__(name, node_ids, dof_constraints)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class SymmetryBC(BoundaryCondition):
|
|
111
|
+
"""Symmetry boundary condition in a global coordinate plane."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, name: str, node_ids: List[int], symmetry_plane: str = "xy"):
|
|
114
|
+
if symmetry_plane == "xy":
|
|
115
|
+
dof_constraints = {"uz": 0.0, "rx": 0.0, "ry": 0.0}
|
|
116
|
+
elif symmetry_plane == "xz":
|
|
117
|
+
dof_constraints = {"uy": 0.0, "rx": 0.0, "rz": 0.0}
|
|
118
|
+
elif symmetry_plane == "yz":
|
|
119
|
+
dof_constraints = {"ux": 0.0, "ry": 0.0, "rz": 0.0}
|
|
120
|
+
else:
|
|
121
|
+
dof_constraints = {}
|
|
122
|
+
super().__init__(name, node_ids, dof_constraints)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class LoadCase:
|
|
127
|
+
"""
|
|
128
|
+
Load case for the FE model.
|
|
129
|
+
|
|
130
|
+
Contains nodal loads, element loads, pressure loads and optional gravity.
|
|
131
|
+
Pressure loads on shell elements are assembled as consistent nodal loads by
|
|
132
|
+
Gauss integration over the element surface, instead of equal area shares.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
name: str
|
|
136
|
+
nodal_loads: Dict[int, np.ndarray] = field(default_factory=dict)
|
|
137
|
+
element_loads: Dict[int, np.ndarray] = field(default_factory=dict)
|
|
138
|
+
pressure_loads: Dict[int, float] = field(default_factory=dict)
|
|
139
|
+
gravity: Optional[np.ndarray] = None
|
|
140
|
+
added_node_masses: Dict[int, float] = field(default_factory=dict)
|
|
141
|
+
|
|
142
|
+
def add_nodal_load(
|
|
143
|
+
self,
|
|
144
|
+
node_id: int,
|
|
145
|
+
load_vector: Optional[np.ndarray] = None,
|
|
146
|
+
forces: Optional[np.ndarray] = None,
|
|
147
|
+
moments: Optional[np.ndarray] = None,
|
|
148
|
+
):
|
|
149
|
+
"""
|
|
150
|
+
Add a nodal load.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
node_id: Node ID to apply load to.
|
|
154
|
+
load_vector: [Fx, Fy, Fz] or [Fx, Fy, Fz, Mx, My, Mz].
|
|
155
|
+
forces: Alternative force vector [Fx, Fy, Fz].
|
|
156
|
+
moments: Optional moment vector [Mx, My, Mz].
|
|
157
|
+
"""
|
|
158
|
+
if load_vector is not None:
|
|
159
|
+
load_vector = np.asarray(load_vector, dtype=float)
|
|
160
|
+
if len(load_vector) == 6:
|
|
161
|
+
load = load_vector.copy()
|
|
162
|
+
else:
|
|
163
|
+
if moments is None:
|
|
164
|
+
moments = np.zeros(3)
|
|
165
|
+
load = np.concatenate([load_vector[:3], np.asarray(moments, dtype=float)[:3]])
|
|
166
|
+
elif forces is not None:
|
|
167
|
+
if moments is None:
|
|
168
|
+
moments = np.zeros(3)
|
|
169
|
+
load = np.concatenate([np.asarray(forces, dtype=float)[:3], np.asarray(moments, dtype=float)[:3]])
|
|
170
|
+
elif moments is not None:
|
|
171
|
+
load = np.concatenate([np.zeros(3), np.asarray(moments, dtype=float)[:3]])
|
|
172
|
+
else:
|
|
173
|
+
load = np.zeros(6)
|
|
174
|
+
|
|
175
|
+
if node_id in self.nodal_loads:
|
|
176
|
+
self.nodal_loads[node_id] += load
|
|
177
|
+
else:
|
|
178
|
+
self.nodal_loads[node_id] = load
|
|
179
|
+
|
|
180
|
+
def add_pressure_load(self, element_id: int, pressure: float):
|
|
181
|
+
"""
|
|
182
|
+
Add a pressure load to a shell element.
|
|
183
|
+
|
|
184
|
+
Positive pressure follows the element normal as defined by the element
|
|
185
|
+
node ordering and natural-coordinate surface Jacobian.
|
|
186
|
+
"""
|
|
187
|
+
self.pressure_loads[element_id] = float(pressure)
|
|
188
|
+
|
|
189
|
+
def set_gravity(self, gx: float = 0.0, gy: float = 0.0, gz: float = -9.81):
|
|
190
|
+
"""Set gravity acceleration."""
|
|
191
|
+
self.gravity = np.array([gx, gy, gz], dtype=float)
|
|
192
|
+
|
|
193
|
+
def set_acceleration(self, ax: float = 0.0, ay: float = 0.0, az: float = 0.0):
|
|
194
|
+
"""Set a body-load acceleration field in x/y/z.
|
|
195
|
+
|
|
196
|
+
Produces the consistent inertial load ``M a`` over the structural mass
|
|
197
|
+
(element mass matrices) plus ``m_i a`` for any added nodal masses. This
|
|
198
|
+
is the same mechanism as :meth:`set_gravity`; use it to describe design
|
|
199
|
+
accelerations (e.g. ship motions) in an arbitrary direction.
|
|
200
|
+
"""
|
|
201
|
+
self.gravity = np.array([ax, ay, az], dtype=float)
|
|
202
|
+
|
|
203
|
+
def add_node_mass(self, node_id: int, mass: float):
|
|
204
|
+
"""Add a lumped translational mass at a node.
|
|
205
|
+
|
|
206
|
+
The added mass contributes an inertial load ``mass * acceleration`` at
|
|
207
|
+
the node's translational DOFs whenever an acceleration/gravity field is
|
|
208
|
+
set. Use the frontend edge/ring helpers to distribute a total mass
|
|
209
|
+
along a plate edge or a cylinder top/bottom ring.
|
|
210
|
+
"""
|
|
211
|
+
mass = float(mass)
|
|
212
|
+
if mass == 0.0:
|
|
213
|
+
return
|
|
214
|
+
self.added_node_masses[int(node_id)] = self.added_node_masses.get(int(node_id), 0.0) + mass
|
|
215
|
+
|
|
216
|
+
def add_distributed_edge_mass(self, node_ids: Sequence[int], total_mass: float):
|
|
217
|
+
"""Distribute ``total_mass`` equally over the given nodes."""
|
|
218
|
+
node_ids = [int(node_id) for node_id in node_ids]
|
|
219
|
+
if not node_ids or float(total_mass) == 0.0:
|
|
220
|
+
return
|
|
221
|
+
share = float(total_mass) / float(len(node_ids))
|
|
222
|
+
for node_id in node_ids:
|
|
223
|
+
self.add_node_mass(node_id, share)
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _surface_jacobian_and_normal(coords: np.ndarray, dN_dxi: np.ndarray, dN_deta: np.ndarray) -> Tuple[float, np.ndarray]:
|
|
227
|
+
"""
|
|
228
|
+
Compute surface Jacobian magnitude and unit normal from shape derivatives.
|
|
229
|
+
"""
|
|
230
|
+
tangent_xi = coords.T @ dN_dxi
|
|
231
|
+
tangent_eta = coords.T @ dN_deta
|
|
232
|
+
normal_raw = np.array(
|
|
233
|
+
[
|
|
234
|
+
tangent_xi[1] * tangent_eta[2] - tangent_xi[2] * tangent_eta[1],
|
|
235
|
+
tangent_xi[2] * tangent_eta[0] - tangent_xi[0] * tangent_eta[2],
|
|
236
|
+
tangent_xi[0] * tangent_eta[1] - tangent_xi[1] * tangent_eta[0],
|
|
237
|
+
]
|
|
238
|
+
)
|
|
239
|
+
det_j = float(np.linalg.norm(normal_raw))
|
|
240
|
+
if det_j < _SMALL:
|
|
241
|
+
return 0.0, np.array([0.0, 0.0, 1.0])
|
|
242
|
+
return det_j, normal_raw / det_j
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def _fallback_lumped_pressure_load(element, mesh: "FEMesh", pressure: float) -> np.ndarray:
|
|
246
|
+
"""
|
|
247
|
+
Fallback for unsupported element topologies.
|
|
248
|
+
|
|
249
|
+
This keeps old behaviour available for non-shell or future custom elements,
|
|
250
|
+
but all 4/8-node shell elements should use the consistent path.
|
|
251
|
+
"""
|
|
252
|
+
coords = element.get_node_coordinates(mesh)
|
|
253
|
+
num_nodes = len(element.node_ids)
|
|
254
|
+
f_elem = np.zeros(num_nodes * 6)
|
|
255
|
+
if num_nodes < 3:
|
|
256
|
+
return f_elem
|
|
257
|
+
|
|
258
|
+
if num_nodes in (4, 8):
|
|
259
|
+
tri1_area = 0.5 * np.linalg.norm(np.cross(coords[1] - coords[0], coords[2] - coords[0]))
|
|
260
|
+
tri2_area = 0.5 * np.linalg.norm(np.cross(coords[0] - coords[2], coords[3] - coords[2]))
|
|
261
|
+
area = tri1_area + tri2_area
|
|
262
|
+
else:
|
|
263
|
+
area = 0.5 * np.linalg.norm(np.cross(coords[1] - coords[0], coords[2] - coords[0]))
|
|
264
|
+
|
|
265
|
+
normal_raw = np.cross(coords[1] - coords[0], coords[2] - coords[0])
|
|
266
|
+
normal_norm = np.linalg.norm(normal_raw)
|
|
267
|
+
normal = normal_raw / normal_norm if normal_norm > _SMALL else np.array([0.0, 0.0, 1.0])
|
|
268
|
+
nodal_force = pressure * area / max(num_nodes, 1) * normal
|
|
269
|
+
for i in range(num_nodes):
|
|
270
|
+
f_elem[i * 6:i * 6 + 3] += nodal_force
|
|
271
|
+
return f_elem
|
|
272
|
+
|
|
273
|
+
def _consistent_pressure_load(self, element, mesh: "FEMesh", pressure: float) -> np.ndarray:
|
|
274
|
+
"""
|
|
275
|
+
Assemble a consistent element pressure vector.
|
|
276
|
+
|
|
277
|
+
For a shell element with shape functions N_i, the translational nodal
|
|
278
|
+
load is:
|
|
279
|
+
|
|
280
|
+
f_i = integral_A N_i * p * n dA
|
|
281
|
+
|
|
282
|
+
Rotational pressure follower effects are deliberately not included here;
|
|
283
|
+
this is a linear static/eigen-prep load vector.
|
|
284
|
+
"""
|
|
285
|
+
if not hasattr(element, "compute_shape_functions") or not hasattr(element, "gauss_points"):
|
|
286
|
+
return self._fallback_lumped_pressure_load(element, mesh, pressure)
|
|
287
|
+
|
|
288
|
+
coords = element.get_node_coordinates(mesh)
|
|
289
|
+
num_nodes = len(element.node_ids)
|
|
290
|
+
f_elem = np.zeros(num_nodes * 6)
|
|
291
|
+
gauss_points = getattr(element, "gauss_points")
|
|
292
|
+
gauss_weights = getattr(element, "gauss_weights")
|
|
293
|
+
|
|
294
|
+
for (xi, eta), weight in zip(gauss_points, gauss_weights):
|
|
295
|
+
N, dN_dxi, dN_deta = element.compute_shape_functions(float(xi), float(eta))
|
|
296
|
+
det_j, normal = self._surface_jacobian_and_normal(coords, dN_dxi, dN_deta)
|
|
297
|
+
if det_j < _SMALL:
|
|
298
|
+
continue
|
|
299
|
+
for i in range(num_nodes):
|
|
300
|
+
f_elem[i * 6:i * 6 + 3] += N[i] * pressure * normal * det_j * float(weight)
|
|
301
|
+
return f_elem
|
|
302
|
+
|
|
303
|
+
def _consistent_gravity_load(
|
|
304
|
+
self,
|
|
305
|
+
element,
|
|
306
|
+
mesh: "FEMesh",
|
|
307
|
+
material: "Material",
|
|
308
|
+
) -> np.ndarray:
|
|
309
|
+
"""
|
|
310
|
+
Assemble element body force from the element mass matrix.
|
|
311
|
+
|
|
312
|
+
With translational acceleration a, the consistent nodal load is M a.
|
|
313
|
+
Rotational acceleration components are zero for ordinary gravity.
|
|
314
|
+
"""
|
|
315
|
+
f_elem = np.zeros(len(element.node_ids) * 6)
|
|
316
|
+
if self.gravity is None:
|
|
317
|
+
return f_elem
|
|
318
|
+
|
|
319
|
+
mass_matrix = element.compute_mass_matrix(mesh, material)
|
|
320
|
+
acceleration = np.zeros_like(f_elem)
|
|
321
|
+
for i in range(len(element.node_ids)):
|
|
322
|
+
acceleration[i * 6:i * 6 + 3] = self.gravity
|
|
323
|
+
return np.asarray(mass_matrix @ acceleration, dtype=float).reshape(-1)
|
|
324
|
+
|
|
325
|
+
def get_load_vector(
|
|
326
|
+
self,
|
|
327
|
+
mesh: "FEMesh",
|
|
328
|
+
dof_manager: "DOFManager",
|
|
329
|
+
material_getter: Optional[Callable[[str], "Material"]] = None,
|
|
330
|
+
) -> np.ndarray:
|
|
331
|
+
"""Assemble the global load vector."""
|
|
332
|
+
total_dofs = dof_manager.total_dofs
|
|
333
|
+
F = np.zeros(total_dofs)
|
|
334
|
+
|
|
335
|
+
# Nodal loads.
|
|
336
|
+
for node_id, load in self.nodal_loads.items():
|
|
337
|
+
node = mesh.get_node(node_id)
|
|
338
|
+
if node:
|
|
339
|
+
for i, dof in enumerate(node.dofs):
|
|
340
|
+
if i < len(load):
|
|
341
|
+
F[dof] += load[i]
|
|
342
|
+
|
|
343
|
+
# User-provided element load vectors.
|
|
344
|
+
for element_id, load in self.element_loads.items():
|
|
345
|
+
element = mesh.get_element(element_id)
|
|
346
|
+
if element is None:
|
|
347
|
+
continue
|
|
348
|
+
dof_mapping = element.get_dof_mapping(mesh)
|
|
349
|
+
load = np.asarray(load, dtype=float)
|
|
350
|
+
for i, dof in enumerate(dof_mapping):
|
|
351
|
+
if i < len(load):
|
|
352
|
+
F[dof] += load[i]
|
|
353
|
+
|
|
354
|
+
# Consistent pressure loads for shell elements.
|
|
355
|
+
for element_id, pressure in self.pressure_loads.items():
|
|
356
|
+
element = mesh.get_element(element_id)
|
|
357
|
+
if element is None or not hasattr(element, "node_ids"):
|
|
358
|
+
continue
|
|
359
|
+
f_elem = self._consistent_pressure_load(element, mesh, pressure)
|
|
360
|
+
dof_mapping = element.get_dof_mapping(mesh)
|
|
361
|
+
for i, dof in enumerate(dof_mapping):
|
|
362
|
+
if i < len(f_elem):
|
|
363
|
+
F[dof] += f_elem[i]
|
|
364
|
+
|
|
365
|
+
# Gravity loads from element mass matrices.
|
|
366
|
+
if self.gravity is not None:
|
|
367
|
+
for element in mesh.elements.values():
|
|
368
|
+
if not hasattr(element, "node_ids"):
|
|
369
|
+
continue
|
|
370
|
+
if material_getter is None:
|
|
371
|
+
material = _GravityFallbackMaterial()
|
|
372
|
+
else:
|
|
373
|
+
material = material_getter(element.material_name)
|
|
374
|
+
f_elem = self._consistent_gravity_load(element, mesh, material)
|
|
375
|
+
dof_mapping = element.get_dof_mapping(mesh)
|
|
376
|
+
for i, dof in enumerate(dof_mapping):
|
|
377
|
+
if i < len(f_elem):
|
|
378
|
+
F[dof] += f_elem[i]
|
|
379
|
+
|
|
380
|
+
# Inertial load from added masses under the acceleration field: both
|
|
381
|
+
# model-level point masses (which also enter the mass matrix) and any
|
|
382
|
+
# load-case-only added masses.
|
|
383
|
+
if self.gravity is not None:
|
|
384
|
+
acceleration = np.asarray(self.gravity, dtype=float)
|
|
385
|
+
combined_masses: Dict[int, float] = {}
|
|
386
|
+
for node_id, mass in getattr(mesh, "point_masses", {}).items():
|
|
387
|
+
combined_masses[int(node_id)] = combined_masses.get(int(node_id), 0.0) + float(mass)
|
|
388
|
+
for node_id, mass in self.added_node_masses.items():
|
|
389
|
+
combined_masses[int(node_id)] = combined_masses.get(int(node_id), 0.0) + float(mass)
|
|
390
|
+
for node_id, mass in combined_masses.items():
|
|
391
|
+
node = mesh.get_node(int(node_id))
|
|
392
|
+
if node is None or mass == 0.0:
|
|
393
|
+
continue
|
|
394
|
+
for axis in range(3):
|
|
395
|
+
F[node.dofs[axis]] += float(mass) * acceleration[axis]
|
|
396
|
+
|
|
397
|
+
return F
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@dataclass
|
|
401
|
+
class InPlaneLoad:
|
|
402
|
+
"""
|
|
403
|
+
In-plane load for stiffened panels.
|
|
404
|
+
|
|
405
|
+
Represents axial, transverse and shear stresses applied to the panel.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
axial_stress: float = 0.0
|
|
409
|
+
transverse_stress: float = 0.0
|
|
410
|
+
shear_stress: float = 0.0
|
|
411
|
+
pressure: float = 0.0
|
|
412
|
+
|
|
413
|
+
def to_dict(self) -> Dict[str, float]:
|
|
414
|
+
return {
|
|
415
|
+
"axial_stress": self.axial_stress,
|
|
416
|
+
"transverse_stress": self.transverse_stress,
|
|
417
|
+
"shear_stress": self.shear_stress,
|
|
418
|
+
"pressure": self.pressure,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class LoadCombination:
|
|
423
|
+
"""Linear combination of load cases."""
|
|
424
|
+
|
|
425
|
+
def __init__(self, name: str, factors: Dict[str, float]):
|
|
426
|
+
self.name = name
|
|
427
|
+
self.factors = factors
|
|
428
|
+
|
|
429
|
+
def get_combined_load_vector(
|
|
430
|
+
self,
|
|
431
|
+
load_cases: List[LoadCase],
|
|
432
|
+
mesh: "FEMesh",
|
|
433
|
+
dof_manager: "DOFManager",
|
|
434
|
+
material_getter: Optional[Callable[[str], "Material"]] = None,
|
|
435
|
+
) -> np.ndarray:
|
|
436
|
+
"""Assemble the factored load vector.
|
|
437
|
+
|
|
438
|
+
Pass ``model.get_material`` when combinations include gravity so each
|
|
439
|
+
element uses its assigned density.
|
|
440
|
+
"""
|
|
441
|
+
F_total = np.zeros(dof_manager.total_dofs)
|
|
442
|
+
for load_case in load_cases:
|
|
443
|
+
if load_case.name in self.factors:
|
|
444
|
+
factor = self.factors[load_case.name]
|
|
445
|
+
F_total += factor * load_case.get_load_vector(
|
|
446
|
+
mesh,
|
|
447
|
+
dof_manager,
|
|
448
|
+
material_getter,
|
|
449
|
+
)
|
|
450
|
+
return F_total
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# Common boundary condition factory functions
|
|
454
|
+
|
|
455
|
+
def create_fixed_support(name: str, node_ids: List[int]) -> FixedSupport:
|
|
456
|
+
"""Create a fixed support boundary condition."""
|
|
457
|
+
return FixedSupport(name, node_ids)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def create_pinned_support(name: str, node_ids: List[int]) -> PinnedSupport:
|
|
461
|
+
"""Create a pinned support boundary condition."""
|
|
462
|
+
return PinnedSupport(name, node_ids)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def create_roller_support(
|
|
466
|
+
name: str,
|
|
467
|
+
node_ids: List[int],
|
|
468
|
+
constrained_directions: Optional[List[str]] = None,
|
|
469
|
+
) -> RollerSupport:
|
|
470
|
+
"""Create a roller support boundary condition."""
|
|
471
|
+
return RollerSupport(name, node_ids, constrained_directions)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def create_symmetry_bc(name: str, node_ids: List[int], symmetry_plane: str = "xy") -> SymmetryBC:
|
|
475
|
+
"""Create a symmetry boundary condition."""
|
|
476
|
+
return SymmetryBC(name, node_ids, symmetry_plane)
|