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.
Files changed (70) hide show
  1. anysolver/__init__.py +631 -0
  2. anysolver/anystructure_fem_mode.py +942 -0
  3. anysolver/arc_length.py +758 -0
  4. anysolver/assembly.py +950 -0
  5. anysolver/baselines.py +303 -0
  6. anysolver/beam_shell_verification.py +4276 -0
  7. anysolver/beam_validity.py +110 -0
  8. anysolver/benchmarks.py +495 -0
  9. anysolver/boundary.py +476 -0
  10. anysolver/buckling.py +442 -0
  11. anysolver/buckling_validity.py +91 -0
  12. anysolver/capacity_workflow.py +350 -0
  13. anysolver/cases.py +173 -0
  14. anysolver/composite_strip_verification.py +297 -0
  15. anysolver/contact.py +3461 -0
  16. anysolver/corotational.py +371 -0
  17. anysolver/cylinder_benchmarks.py +364 -0
  18. anysolver/dynamics.py +723 -0
  19. anysolver/element_qualification.py +432 -0
  20. anysolver/elements.py +2724 -0
  21. anysolver/external_references.py +369 -0
  22. anysolver/fe_core.py +327 -0
  23. anysolver/fracture.py +551 -0
  24. anysolver/imperfections.py +390 -0
  25. anysolver/jit_compiler.py +108 -0
  26. anysolver/kernel_warmup.py +180 -0
  27. anysolver/linalg.py +760 -0
  28. anysolver/mass_properties.py +179 -0
  29. anysolver/material_curves.py +231 -0
  30. anysolver/matrix_assembly.py +558 -0
  31. anysolver/mesh_gen.py +1065 -0
  32. anysolver/mesh_load_bc_verification.py +609 -0
  33. anysolver/modal.py +282 -0
  34. anysolver/nonlinear.py +300 -0
  35. anysolver/nonlinear_performance.py +920 -0
  36. anysolver/nonlinear_performance_batch_b.py +592 -0
  37. anysolver/nonlinear_performance_batch_c.py +506 -0
  38. anysolver/nonlinear_performance_bootstrap.py +120 -0
  39. anysolver/nonlinear_reduced_assembly.py +760 -0
  40. anysolver/nonlinear_static.py +1518 -0
  41. anysolver/plasticity.py +419 -0
  42. anysolver/plasticity_qualification.py +314 -0
  43. anysolver/production_readiness.py +304 -0
  44. anysolver/quality_control.py +1497 -0
  45. anysolver/recovery.py +505 -0
  46. anysolver/recovery_policy.py +205 -0
  47. anysolver/reference_cases.py +425 -0
  48. anysolver/results.py +503 -0
  49. anysolver/runtime.py +9030 -0
  50. anysolver/s4_validity.py +326 -0
  51. anysolver/sesam_fem/__init__.py +55 -0
  52. anysolver/sesam_fem/__main__.py +80 -0
  53. anysolver/sesam_fem/diagnostics.py +65 -0
  54. anysolver/sesam_fem/document.py +814 -0
  55. anysolver/sesam_fem/exporter.py +84 -0
  56. anysolver/sesam_fem/importer.py +365 -0
  57. anysolver/sesam_fem/records.py +257 -0
  58. anysolver/sesam_fem/schema.py +107 -0
  59. anysolver/sesam_fem/sif_importer.py +397 -0
  60. anysolver/sesam_fem/validation.py +62 -0
  61. anysolver/shell_benchmarks.py +367 -0
  62. anysolver/test_cases.py +610 -0
  63. anysolver/validation.py +416 -0
  64. anysolver/vectorized_nonlinear.py +334 -0
  65. anysolver/vectorized_stiffness.py +571 -0
  66. anysolver-0.1.0.dist-info/METADATA +165 -0
  67. anysolver-0.1.0.dist-info/RECORD +70 -0
  68. anysolver-0.1.0.dist-info/WHEEL +5 -0
  69. anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
  70. 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)