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/assembly.py ADDED
@@ -0,0 +1,950 @@
1
+ """
2
+ Assembly and Solver Module
3
+
4
+ This module provides functions for:
5
+ - assembling global stiffness and load vectors,
6
+ - applying constraints,
7
+ - solving linear systems,
8
+ - post-processing reactions, stresses and displacements.
9
+
10
+ Constraint handling
11
+ -------------------
12
+ The linear solver uses an explicit transformation/reduction of the global
13
+ system. Fixed boundary conditions and beam-shell MPC/eccentricity constraints
14
+ are eliminated before solving:
15
+
16
+ u = T q + u0
17
+ T.T K T q = T.T (F - K u0)
18
+
19
+ For unsupported free-free models, the solver can additionally suppress the six
20
+ rigid body modes by solving an augmented nullspace system:
21
+
22
+ [K_red Q] [q] [F_red]
23
+ [Q.T 0] [lambda] [0 ]
24
+
25
+ where Q contains orthonormal reduced rigid-body modes. This gives a unique gauge
26
+ for the displacement field without introducing artificial support stiffness. If
27
+ the load vector has a rigid-body component, the solve still returns the gauged
28
+ solution and reports the imbalance through solver diagnostics.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import time
35
+ import warnings
36
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple
37
+
38
+ import numpy as np
39
+ from scipy import sparse
40
+ from scipy.sparse.linalg import bicgstab, gmres, minres
41
+
42
+ from .cases import make_result_case
43
+ from .linalg import FactorizationCache, MatrixClass, factorize, factorize_cached
44
+ from .matrix_assembly import (
45
+ assemble_load_matrix,
46
+ assemble_mass_matrix as _canonical_assemble_mass_matrix,
47
+ assemble_system as _canonical_assemble_system,
48
+ )
49
+
50
+ if TYPE_CHECKING:
51
+ from .boundary import LoadCase
52
+ from .fe_core import FEModel, FEMesh
53
+
54
+
55
+ def assemble_system(
56
+ model: "FEModel",
57
+ load_case: Optional["LoadCase"] = None,
58
+ include_mass: bool = False,
59
+ ) -> Tuple[sparse.csr_matrix, np.ndarray, Dict[str, Any]]:
60
+ """Compatibility wrapper around :mod:`anysolver.matrix_assembly`."""
61
+ return _canonical_assemble_system(model, load_case, include_mass)
62
+
63
+
64
+ def assemble_mass_matrix(model: "FEModel") -> Tuple[sparse.csr_matrix, Dict[str, Any]]:
65
+ """Compatibility wrapper around :func:`matrix_assembly.assemble_mass_matrix`."""
66
+ return _canonical_assemble_mass_matrix(model)
67
+
68
+
69
+ def _constraint_value_map(model: "FEModel") -> Dict[int, float]:
70
+ """Return fixed prescribed displacement values keyed by global DOF."""
71
+ dof_manager = model.mesh.dof_manager
72
+ values: Dict[int, float] = {}
73
+ for bc in model.boundary_conditions:
74
+ for dof, value in bc.get_constrained_dofs(dof_manager):
75
+ if dof in values and not np.isclose(values[dof], value):
76
+ raise ValueError(f"Conflicting prescribed displacement for DOF {dof}: {values[dof]} vs {value}")
77
+ values[dof] = float(value)
78
+ return values
79
+
80
+
81
+ def _collect_mpc_constraints(model: "FEModel") -> List[Dict[str, Any]]:
82
+ """Collect linear slave/master constraints from elements."""
83
+ constraints: List[Dict[str, Any]] = []
84
+ for element in model.mesh.elements.values():
85
+ getter = getattr(element, "get_mpc_constraints", None)
86
+ if getter is None:
87
+ continue
88
+ element_constraints = getter(model.mesh)
89
+ if element_constraints:
90
+ constraints.extend(element_constraints)
91
+ return constraints
92
+
93
+
94
+ def build_constraint_transformation(
95
+ K: sparse.csr_matrix,
96
+ F: np.ndarray,
97
+ model: "FEModel",
98
+ ) -> Tuple[sparse.csr_matrix, np.ndarray, sparse.csr_matrix, np.ndarray, np.ndarray, Dict[str, Any]]:
99
+ """
100
+ Build the reduced system using fixed DOFs and linear MPC slave relations.
101
+
102
+ The returned system is K_red q = F_red and the full displacement vector is
103
+ recovered from u = T q + u0.
104
+ """
105
+ total_dofs = int(K.shape[0])
106
+ fixed_values = _constraint_value_map(model)
107
+ mpc_constraints = _collect_mpc_constraints(model)
108
+
109
+ slave_constraints: Dict[int, Dict[str, Any]] = {}
110
+ for constraint in mpc_constraints:
111
+ slave = int(constraint["slave"])
112
+ if slave in fixed_values:
113
+ raise ValueError(f"DOF {slave} is both fixed and used as an MPC slave")
114
+ if slave in slave_constraints:
115
+ raise ValueError(f"DOF {slave} has multiple MPC slave definitions")
116
+ masters = {int(k): float(v) for k, v in constraint.get("masters", {}).items() if abs(float(v)) > 0.0}
117
+ if slave in masters:
118
+ raise ValueError(f"MPC constraint for DOF {slave} references itself as a master")
119
+ slave_constraints[slave] = {
120
+ "masters": masters,
121
+ "value": float(constraint.get("value", 0.0)),
122
+ "label": constraint.get("label", "mpc"),
123
+ }
124
+
125
+ fixed_dofs = set(int(dof) for dof in fixed_values)
126
+ slave_dofs = set(slave_constraints)
127
+ dependent_dofs = fixed_dofs | slave_dofs
128
+ independent_dofs = np.array([dof for dof in range(total_dofs) if dof not in dependent_dofs], dtype=int)
129
+ independent_index = {int(dof): i for i, dof in enumerate(independent_dofs)}
130
+
131
+ # Topological sort of slave_constraints to resolve cascading dependencies
132
+ visited = {} # 0 = unvisited, 1 = visiting, 2 = visited
133
+ topo_order = []
134
+
135
+ def visit(node: int):
136
+ state = visited.get(node, 0)
137
+ if state == 1:
138
+ raise ValueError(f"Circular MPC dependency detected containing DOF {node}")
139
+ if state == 2:
140
+ return
141
+
142
+ visited[node] = 1
143
+ if node in slave_constraints:
144
+ for master in slave_constraints[node]["masters"]:
145
+ if master in slave_constraints:
146
+ visit(master)
147
+ visited[node] = 2
148
+ topo_order.append(node)
149
+
150
+ for slave in slave_constraints:
151
+ if visited.get(slave, 0) == 0:
152
+ visit(slave)
153
+
154
+ # Process and resolve master-slave dependencies recursively in topological order
155
+ for slave in topo_order:
156
+ constraint = slave_constraints[slave]
157
+ resolved_masters: Dict[int, float] = {}
158
+ resolved_value = constraint["value"]
159
+
160
+ for master, coefficient in constraint["masters"].items():
161
+ if master in slave_constraints:
162
+ sub_masters = slave_constraints[master]["resolved_masters"]
163
+ sub_value = slave_constraints[master]["resolved_value"]
164
+ resolved_value += coefficient * sub_value
165
+ for sub_m, sub_coeff in sub_masters.items():
166
+ resolved_masters[sub_m] = resolved_masters.get(sub_m, 0.0) + coefficient * sub_coeff
167
+ else:
168
+ resolved_masters[master] = resolved_masters.get(master, 0.0) + coefficient
169
+
170
+ constraint["resolved_masters"] = resolved_masters
171
+ constraint["resolved_value"] = resolved_value
172
+
173
+ rows: List[int] = []
174
+ cols: List[int] = []
175
+ data: List[float] = []
176
+ u0 = np.zeros(total_dofs, dtype=float)
177
+
178
+ for dof, value in fixed_values.items():
179
+ u0[int(dof)] = float(value)
180
+
181
+ for col, dof in enumerate(independent_dofs):
182
+ rows.append(int(dof))
183
+ cols.append(col)
184
+ data.append(1.0)
185
+
186
+ for slave, constraint in slave_constraints.items():
187
+ value = constraint["resolved_value"]
188
+ for master, coefficient in constraint["resolved_masters"].items():
189
+ if master in fixed_values:
190
+ value += coefficient * fixed_values[master]
191
+ else:
192
+ try:
193
+ col = independent_index[master]
194
+ except KeyError as exc:
195
+ raise ValueError(f"MPC master DOF {master} is outside the active system") from exc
196
+ rows.append(slave)
197
+ cols.append(col)
198
+ data.append(coefficient)
199
+ u0[slave] = value
200
+
201
+ T = sparse.csr_matrix((data, (rows, cols)), shape=(total_dofs, len(independent_dofs)))
202
+ residual_offset = F - K @ u0
203
+ K_red = (T.T @ K @ T).tocsr()
204
+ F_red = np.asarray(T.T @ residual_offset, dtype=float).reshape(-1)
205
+
206
+ info = {
207
+ "method": "transformation",
208
+ "num_total_dofs": total_dofs,
209
+ "num_independent_dofs": int(len(independent_dofs)),
210
+ "num_fixed_dofs": int(len(fixed_dofs)),
211
+ "num_mpc_slave_dofs": int(len(slave_dofs)),
212
+ "num_mpc_constraints": int(len(mpc_constraints)),
213
+ "fixed_dofs": sorted(fixed_dofs),
214
+ "slave_dofs": sorted(slave_dofs),
215
+ }
216
+ return K_red, F_red, T, u0, independent_dofs, info
217
+
218
+
219
+ def reconstruct_full_solution(T: sparse.csr_matrix, q: np.ndarray, u0: np.ndarray) -> np.ndarray:
220
+ """Reconstruct full displacement vector from reduced unknowns."""
221
+ return np.asarray(T @ q + u0, dtype=float).reshape(-1)
222
+
223
+
224
+ def _node_components(model: "FEModel") -> List[List[int]]:
225
+ """Connected components from elements and MPC node relationships."""
226
+ mesh = model.mesh
227
+ adjacency: Dict[int, set] = {int(node_id): set() for node_id in mesh.nodes}
228
+
229
+ def connect(node_ids: List[int]) -> None:
230
+ ids = [int(node_id) for node_id in node_ids if int(node_id) in adjacency]
231
+ for node_id in ids:
232
+ adjacency[node_id].update(other for other in ids if other != node_id)
233
+
234
+ for element in mesh.elements.values():
235
+ connect(list(getattr(element, "node_ids", [])))
236
+ for constraint in _collect_mpc_constraints(model):
237
+ slave_node, _, _ = mesh.dof_manager.get_dof_info(int(constraint["slave"]))
238
+ nodes = [slave_node]
239
+ for master in constraint.get("masters", {}):
240
+ master_node, _, _ = mesh.dof_manager.get_dof_info(int(master))
241
+ nodes.append(master_node)
242
+ connect([node_id for node_id in nodes if node_id >= 0])
243
+
244
+ components: List[List[int]] = []
245
+ visited = set()
246
+ for node_id in sorted(adjacency):
247
+ if node_id in visited:
248
+ continue
249
+ stack = [node_id]
250
+ component = []
251
+ visited.add(node_id)
252
+ while stack:
253
+ current = stack.pop()
254
+ component.append(current)
255
+ for neighbour in sorted(adjacency[current]):
256
+ if neighbour not in visited:
257
+ visited.add(neighbour)
258
+ stack.append(neighbour)
259
+ components.append(sorted(component))
260
+ return components
261
+
262
+
263
+ def _rigid_body_modes_full(model: "FEModel", total_dofs: int) -> Tuple[np.ndarray, List[Dict[str, Any]]]:
264
+ """Build six full-system rigid body modes per connected component."""
265
+ components = _node_components(model)
266
+ modes = np.zeros((total_dofs, 6 * len(components)), dtype=float)
267
+ component_info: List[Dict[str, Any]] = []
268
+ constrained_dofs = set(getattr(model.mesh.dof_manager, "_constrained_dofs", set()))
269
+ for component_index, node_ids in enumerate(components):
270
+ if not node_ids:
271
+ continue
272
+ component_dofs: List[int] = []
273
+ for node_id in node_ids:
274
+ node = model.mesh.get_node(node_id)
275
+ if node is not None:
276
+ component_dofs.extend(int(dof) for dof in node.dofs)
277
+ component_supported = any(dof in constrained_dofs for dof in component_dofs)
278
+ coords = np.asarray([model.mesh.get_node(node_id).coords() for node_id in node_ids], dtype=float)
279
+ origin = np.mean(coords, axis=0)
280
+ base = 6 * component_index
281
+ if component_supported:
282
+ component_info.append(
283
+ {
284
+ "component_index": component_index,
285
+ "node_ids": [int(node_id) for node_id in node_ids],
286
+ "centroid": origin.tolist(),
287
+ "candidate_modes": 0,
288
+ "supported": True,
289
+ }
290
+ )
291
+ continue
292
+ for node_id in node_ids:
293
+ node = model.mesh.get_node(node_id)
294
+ x, y, z = node.coords() - origin
295
+ ux, uy, uz, rx, ry, rz = node.dofs[:6]
296
+
297
+ modes[ux, base + 0] = 1.0
298
+ modes[uy, base + 1] = 1.0
299
+ modes[uz, base + 2] = 1.0
300
+
301
+ modes[uy, base + 3] = -z
302
+ modes[uz, base + 3] = y
303
+ modes[rx, base + 3] = 1.0
304
+
305
+ modes[ux, base + 4] = z
306
+ modes[uz, base + 4] = -x
307
+ modes[ry, base + 4] = 1.0
308
+
309
+ modes[ux, base + 5] = -y
310
+ modes[uy, base + 5] = x
311
+ modes[rz, base + 5] = 1.0
312
+ component_info.append(
313
+ {
314
+ "component_index": component_index,
315
+ "node_ids": [int(node_id) for node_id in node_ids],
316
+ "centroid": origin.tolist(),
317
+ "candidate_modes": 6,
318
+ "supported": False,
319
+ }
320
+ )
321
+ return modes, component_info
322
+
323
+
324
+ def _orthonormalize_columns(matrix: np.ndarray, tolerance: float = 1.0e-10) -> Tuple[np.ndarray, np.ndarray]:
325
+ """Return independent orthonormal columns and the kept column indices."""
326
+ if matrix.size == 0 or matrix.shape[1] == 0:
327
+ return np.zeros((matrix.shape[0], 0), dtype=float), np.zeros(0, dtype=int)
328
+
329
+ kept: List[int] = []
330
+ basis: List[np.ndarray] = []
331
+ scale = max(float(np.linalg.norm(matrix)), 1.0)
332
+ for col in range(matrix.shape[1]):
333
+ vector = np.asarray(matrix[:, col], dtype=float).copy()
334
+ for q in basis:
335
+ vector -= q * float(q @ vector)
336
+ for q in basis:
337
+ vector -= q * float(q @ vector)
338
+ norm = float(np.linalg.norm(vector))
339
+ if norm > tolerance * scale:
340
+ basis.append(vector / norm)
341
+ kept.append(col)
342
+
343
+ if not basis:
344
+ return np.zeros((matrix.shape[0], 0), dtype=float), np.zeros(0, dtype=int)
345
+ return np.column_stack(basis), np.asarray(kept, dtype=int)
346
+
347
+
348
+ def build_reduced_rigid_body_modes(
349
+ model: "FEModel",
350
+ independent_dofs: np.ndarray,
351
+ total_dofs: int,
352
+ ) -> Tuple[np.ndarray, Dict[str, Any]]:
353
+ """Build orthonormal rigid-body modes in reduced independent DOF space."""
354
+ full_modes, component_info = _rigid_body_modes_full(model, total_dofs)
355
+ if len(independent_dofs) == 0:
356
+ return np.zeros((0, 0), dtype=float), {"rank": 0, "kept_mode_indices": [], "components": component_info}
357
+ reduced_modes = full_modes[np.asarray(independent_dofs, dtype=int), :]
358
+ q_modes, kept = _orthonormalize_columns(reduced_modes)
359
+ return q_modes, {
360
+ "rank": int(q_modes.shape[1]),
361
+ "kept_mode_indices": [int(i) for i in kept],
362
+ "component_count": len(component_info),
363
+ "components": component_info,
364
+ "rank_method": "modified_gram_schmidt_with_rank_tolerance",
365
+ "description": "Six rigid-body candidates per connected component: Tx, Ty, Tz, Rx, Ry, Rz.",
366
+ }
367
+
368
+
369
+ def apply_boundary_conditions(K: sparse.csr_matrix, F: np.ndarray, dof_manager: "DOFManager") -> Tuple[sparse.csr_matrix, np.ndarray]:
370
+ """Legacy penalty boundary-condition application."""
371
+ penalty = 1.0e15
372
+ constrained_dofs = getattr(dof_manager, "_constrained_dofs", set())
373
+ if not constrained_dofs:
374
+ return K, F
375
+ K_modified = K.tolil()
376
+ F_modified = F.copy()
377
+ for dof in constrained_dofs:
378
+ K_modified[dof, dof] = penalty
379
+ F_modified[dof] = 0.0
380
+ return K_modified.tocsr(), F_modified
381
+
382
+
383
+ def _solve_reduced_system(K_red: sparse.csr_matrix, F_red: np.ndarray, solver_type: str) -> Tuple[np.ndarray, Dict[str, Any]]:
384
+ if K_red.shape[0] == 0:
385
+ return np.zeros(0), {"status": "converged", "note": "no independent DOFs"}
386
+
387
+ if solver_type == "direct":
388
+ try:
389
+ handle = factorize(K_red, MatrixClass.SYMMETRIC_INDEFINITE)
390
+ if handle.status != "ok":
391
+ return np.zeros(K_red.shape[0]), {
392
+ "status": "failed",
393
+ "error": handle.failure_reason,
394
+ "backend": handle.diagnostics(),
395
+ }
396
+ q = handle.solve(F_red)
397
+ return q, {"status": "converged", "backend": handle.diagnostics()}
398
+ except Exception as exc:
399
+ return np.zeros(K_red.shape[0]), {"status": "failed", "error": str(exc)}
400
+
401
+ if solver_type == "gmres":
402
+ q, info = gmres(K_red, F_red, rtol=1.0e-8, atol=1.0e-12, maxiter=1000)
403
+ return np.asarray(q, dtype=float), {"status": "converged" if info == 0 else "not_converged", "iterations": info}
404
+
405
+ if solver_type == "minres":
406
+ q, info = minres(K_red, F_red, rtol=1.0e-8, maxiter=1000)
407
+ return np.asarray(q, dtype=float), {"status": "converged" if info == 0 else "not_converged", "iterations": info}
408
+
409
+ if solver_type == "bicgstab":
410
+ q, info = bicgstab(K_red, F_red, rtol=1.0e-8, atol=1.0e-12, maxiter=1000)
411
+ return np.asarray(q, dtype=float), {"status": "converged" if info == 0 else "not_converged", "iterations": info}
412
+
413
+ raise ValueError(f"Unknown solver type: {solver_type}")
414
+
415
+
416
+ def _solve_nullspace_augmented_system(
417
+ K_red: sparse.csr_matrix,
418
+ F_red: np.ndarray,
419
+ Q: np.ndarray,
420
+ load_imbalance_tolerance: float = 1.0e-7,
421
+ allow_unbalanced_loads: bool = False,
422
+ ) -> Tuple[np.ndarray, Dict[str, Any]]:
423
+ """Solve free-free reduced system with rigid body modes constrained by Q.T q = 0."""
424
+ n = int(K_red.shape[0])
425
+ r = int(Q.shape[1])
426
+ if n == 0:
427
+ return np.zeros(0), {"status": "converged", "note": "no independent DOFs", "nullspace_rank": r}
428
+ if r == 0:
429
+ q, info = _solve_reduced_system(K_red, F_red, "direct")
430
+ info["nullspace_rank"] = 0
431
+ return q, info
432
+
433
+ Q_sparse = sparse.csr_matrix(Q)
434
+ zero = sparse.csr_matrix((r, r), dtype=float)
435
+ augmented = sparse.bmat([[K_red, Q_sparse], [Q_sparse.T, zero]], format="csr")
436
+ rhs = np.concatenate([F_red, np.zeros(r, dtype=float)])
437
+
438
+ load_components = np.asarray(Q.T @ F_red, dtype=float).reshape(-1)
439
+ load_imbalance_norm = float(np.linalg.norm(load_components))
440
+ load_norm = float(np.linalg.norm(F_red))
441
+ relative_imbalance = load_imbalance_norm / max(load_norm, 1.0)
442
+
443
+ warnings: List[str] = []
444
+ if relative_imbalance > load_imbalance_tolerance:
445
+ message = (
446
+ "The external load vector has a non-zero rigid-body component. "
447
+ "For a physical free-free static solution, use self-equilibrated loads."
448
+ )
449
+ if not allow_unbalanced_loads:
450
+ return np.zeros(n), {
451
+ "status": "incompatible_free_free_load",
452
+ "error": message,
453
+ "nullspace_rank": r,
454
+ "rigid_body_load_components": load_components.tolist(),
455
+ "rigid_body_load_imbalance_norm": load_imbalance_norm,
456
+ "relative_rigid_body_load_imbalance": relative_imbalance,
457
+ }
458
+ warnings.append(
459
+ message
460
+ + " The nullspace solve returned a gauged displacement field and balancing generalized reactions."
461
+ )
462
+
463
+ try:
464
+ handle = factorize(augmented, MatrixClass.SYMMETRIC_INDEFINITE)
465
+ if handle.status != "ok":
466
+ return np.zeros(n), {
467
+ "status": "failed",
468
+ "error": handle.failure_reason,
469
+ "nullspace_rank": r,
470
+ "rigid_body_load_components": load_components.tolist(),
471
+ "relative_rigid_body_load_imbalance": relative_imbalance,
472
+ "backend": handle.diagnostics(),
473
+ }
474
+ solution = handle.solve(rhs)
475
+ solution = np.asarray(solution, dtype=float).reshape(-1)
476
+ if np.any(np.isnan(solution)) or np.any(np.isinf(solution)):
477
+ return np.zeros(n), {
478
+ "status": "singular",
479
+ "error": "NaN/Inf solution in nullspace augmented solve",
480
+ "nullspace_rank": r,
481
+ "rigid_body_load_components": load_components.tolist(),
482
+ "relative_rigid_body_load_imbalance": relative_imbalance,
483
+ "backend": handle.diagnostics(),
484
+ }
485
+ except Exception as exc:
486
+ return np.zeros(n), {
487
+ "status": "failed",
488
+ "error": str(exc),
489
+ "nullspace_rank": r,
490
+ "rigid_body_load_components": load_components.tolist(),
491
+ "relative_rigid_body_load_imbalance": relative_imbalance,
492
+ }
493
+
494
+ q = solution[:n]
495
+ multipliers = solution[n:]
496
+ residual = np.asarray(K_red @ q + Q @ multipliers - F_red, dtype=float).reshape(-1)
497
+ gauge = np.asarray(Q.T @ q, dtype=float).reshape(-1)
498
+
499
+ return q, {
500
+ "status": "converged",
501
+ "method": "rigid_body_nullspace_augmented",
502
+ "nullspace_rank": r,
503
+ "rigid_body_load_components": load_components.tolist(),
504
+ "rigid_body_lagrange_multipliers": multipliers.tolist(),
505
+ "rigid_body_load_imbalance_norm": load_imbalance_norm,
506
+ "relative_rigid_body_load_imbalance": relative_imbalance,
507
+ "augmented_residual_norm": float(np.linalg.norm(residual)),
508
+ "gauge_residual_norm": float(np.linalg.norm(gauge)),
509
+ "warnings": warnings,
510
+ "backend": handle.diagnostics(),
511
+ }
512
+
513
+
514
+ def solve_linear(
515
+ model: "FEModel",
516
+ load_case: Optional["LoadCase"] = None,
517
+ solver_type: str = "direct",
518
+ precond: bool = True,
519
+ constraint_mode: str = "auto",
520
+ allow_unbalanced_free_free: bool = False,
521
+ ) -> Tuple[np.ndarray, Dict[str, Any]]:
522
+ """
523
+ Solve a linear FE problem using fixed-DOF/MPC transformation.
524
+
525
+ constraint_mode:
526
+ - "auto": use the ordinary reduced solve when fixed DOFs exist, otherwise
527
+ use rigid-body nullspace augmentation.
528
+ - "transformation": always use the ordinary reduced solve.
529
+ - "nullspace": always use nullspace augmentation after MPC/fixed reduction.
530
+ """
531
+ mesh = model.mesh
532
+ dof_manager = mesh.dof_manager
533
+
534
+ model.apply_boundary_conditions()
535
+ K, F, assembly_info = assemble_system(model, load_case)
536
+ K_red, F_red, T, u0, independent_dofs, constraint_info = build_constraint_transformation(K, F, model)
537
+
538
+ total_dofs = int(K.shape[0])
539
+ Q, nullspace_info = build_reduced_rigid_body_modes(model, independent_dofs, total_dofs)
540
+ mode = (constraint_mode or "auto").strip().lower()
541
+ if mode not in {"auto", "transformation", "nullspace"}:
542
+ raise ValueError(f"Unknown constraint_mode '{constraint_mode}'. Use auto, transformation or nullspace.")
543
+ use_nullspace = mode == "nullspace" or (mode == "auto" and int(constraint_info["num_fixed_dofs"]) == 0 and Q.shape[1] > 0)
544
+
545
+ solver_info: Dict[str, Any] = {
546
+ "assembly": assembly_info,
547
+ "solver_type": solver_type,
548
+ "constraint_method": "transformation_fixed_plus_mpc_nullspace" if use_nullspace else "transformation_fixed_plus_mpc",
549
+ "constraint_mode": mode,
550
+ "num_free_dofs": int(len(independent_dofs)),
551
+ "num_constrained_dofs": int(constraint_info["num_fixed_dofs"]),
552
+ "num_mpc_slave_dofs": int(constraint_info["num_mpc_slave_dofs"]),
553
+ "solve_time": 0.0,
554
+ "constraint_info": constraint_info,
555
+ "nullspace_info": nullspace_info,
556
+ "convergence_info": {},
557
+ }
558
+
559
+ start_time = time.time()
560
+ if use_nullspace:
561
+ q, convergence_info = _solve_nullspace_augmented_system(
562
+ K_red,
563
+ F_red,
564
+ Q,
565
+ allow_unbalanced_loads=allow_unbalanced_free_free,
566
+ )
567
+ else:
568
+ q, convergence_info = _solve_reduced_system(K_red, F_red, solver_type)
569
+ solver_info["solve_time"] = time.time() - start_time
570
+ solver_info["convergence_info"] = convergence_info
571
+ result_case = make_result_case(
572
+ name=f"linear_static:{getattr(load_case, 'name', 'none')}",
573
+ analysis_type="linear_static",
574
+ load_cases=() if load_case is None else (load_case,),
575
+ assembly_info=assembly_info,
576
+ solver_info=solver_info,
577
+ recovery={"displacements": True, "stresses": "on_demand", "reactions": "on_demand"},
578
+ settings={"constraint_mode": mode, "solver_type": solver_type},
579
+ warnings=convergence_info.get("warnings", ()),
580
+ )
581
+ solver_info["result_case"] = result_case.to_dict()
582
+
583
+ if convergence_info.get("status") != "converged":
584
+ return u0.copy(), solver_info
585
+ return reconstruct_full_solution(T, q, u0), solver_info
586
+
587
+
588
+ def solve_linear_many(
589
+ model: "FEModel",
590
+ load_cases: List[Optional["LoadCase"]],
591
+ constraint_mode: str = "auto",
592
+ factorization_cache: Optional[FactorizationCache] = None,
593
+ ) -> Tuple[np.ndarray, Dict[str, Any]]:
594
+ """Solve several unchanged-stiffness static load cases with one factorization.
595
+
596
+ The returned displacement matrix has one column per load case.
597
+ """
598
+ model.apply_boundary_conditions()
599
+ K, _, assembly_info = assemble_system(model, None)
600
+ F_matrix, load_matrix_info = assemble_load_matrix(model, load_cases)
601
+ zero_load = np.zeros(model.mesh.dof_manager.total_dofs, dtype=float)
602
+ K_red, _, T, u0, independent_dofs, constraint_info = build_constraint_transformation(K, zero_load, model)
603
+ F_red = np.asarray(T.T @ (F_matrix - (K @ u0)[:, None]), dtype=float)
604
+
605
+ total_dofs = int(K.shape[0])
606
+ Q, nullspace_info = build_reduced_rigid_body_modes(model, independent_dofs, total_dofs)
607
+ mode = (constraint_mode or "auto").strip().lower()
608
+ if mode not in {"auto", "transformation"}:
609
+ raise ValueError("solve_linear_many supports constraint_mode 'auto' or 'transformation'")
610
+ if mode == "auto" and int(constraint_info["num_fixed_dofs"]) == 0 and Q.shape[1] > 0:
611
+ raise ValueError("solve_linear_many requires supports; use individual solve_linear for free-free nullspace solves")
612
+
613
+ start = time.time()
614
+ local_cache = factorization_cache or FactorizationCache(name="linear_static_many", max_entries=1)
615
+ revisions = getattr(model, "revision_signature", lambda: getattr(model.mesh, "revision_signature", lambda: {})())()
616
+ stiffness_signature = json.dumps(
617
+ {
618
+ "analysis": "linear_static_many",
619
+ "matrix": "K_reduced",
620
+ "shape": tuple(int(v) for v in K_red.shape),
621
+ "nnz": int(K_red.nnz),
622
+ "model_revision": revisions,
623
+ "constraint_method": constraint_info.get("method"),
624
+ "num_independent_dofs": int(len(independent_dofs)),
625
+ },
626
+ sort_keys=True,
627
+ default=str,
628
+ separators=(",", ":"),
629
+ )
630
+ handle = factorize_cached(
631
+ K_red,
632
+ MatrixClass.SYMMETRIC_INDEFINITE,
633
+ cache=local_cache,
634
+ signature=stiffness_signature,
635
+ )
636
+ if handle.status != "ok":
637
+ info = {
638
+ "status": "failed",
639
+ "error": handle.failure_reason,
640
+ "assembly": assembly_info,
641
+ "load_matrix": load_matrix_info,
642
+ "constraint_info": constraint_info,
643
+ "nullspace_info": nullspace_info,
644
+ "backend": handle.diagnostics(),
645
+ "factorization_cache": local_cache.diagnostics(),
646
+ "solve_time": time.time() - start,
647
+ }
648
+ info["result_case"] = make_result_case(
649
+ name="linear_static_many",
650
+ analysis_type="linear_static_many",
651
+ load_cases=tuple(load_case for load_case in load_cases if load_case is not None),
652
+ assembly_info={**assembly_info, "load_matrix": load_matrix_info},
653
+ solver_info=info,
654
+ recovery={"displacements": True, "stresses": "on_demand", "reactions": "on_demand"},
655
+ settings={"constraint_mode": mode, "num_load_cases": len(load_cases)},
656
+ ).to_dict()
657
+ return np.tile(u0.reshape(-1, 1), (1, len(load_cases))), info
658
+ q_matrix = handle.solve_many(F_red)
659
+ full = np.asarray(T @ q_matrix + u0[:, None], dtype=float)
660
+ info = {
661
+ "status": "converged",
662
+ "assembly": assembly_info,
663
+ "load_matrix": load_matrix_info,
664
+ "constraint_info": constraint_info,
665
+ "nullspace_info": nullspace_info,
666
+ "backend": handle.diagnostics(),
667
+ "factorization_cache": local_cache.diagnostics(),
668
+ "solve_time": time.time() - start,
669
+ "num_result_cases": len(load_cases),
670
+ }
671
+ info["result_case"] = make_result_case(
672
+ name="linear_static_many",
673
+ analysis_type="linear_static_many",
674
+ load_cases=tuple(load_case for load_case in load_cases if load_case is not None),
675
+ assembly_info={**assembly_info, "load_matrix": load_matrix_info},
676
+ solver_info=info,
677
+ recovery={"displacements": True, "stresses": "on_demand", "reactions": "on_demand"},
678
+ settings={"constraint_mode": mode, "num_load_cases": len(load_cases)},
679
+ ).to_dict()
680
+ return full, info
681
+
682
+
683
+ def solve_nonlinear(
684
+ model: "FEModel",
685
+ load_case: "LoadCase",
686
+ max_iterations: int = 20,
687
+ tolerance: float = 1.0e-6,
688
+ method: str = "newton_raphson",
689
+ ) -> Tuple[np.ndarray, Dict[str, Any]]:
690
+ """Deprecated prototype nonlinear solve.
691
+
692
+ Use :func:`anysolver.solve_static_nonlinear` for the qualified incremental
693
+ geometric/material nonlinear API.
694
+ """
695
+ warnings.warn(
696
+ "solve_nonlinear() is a deprecated prototype; use solve_static_nonlinear()",
697
+ DeprecationWarning,
698
+ stacklevel=2,
699
+ )
700
+ mesh = model.mesh
701
+ dof_manager = mesh.dof_manager
702
+ model.apply_boundary_conditions()
703
+
704
+ K, F_ext, assembly_info = assemble_system(model, load_case)
705
+ K_red, F_red, T, u0, independent_dofs, constraint_info = build_constraint_transformation(K, F_ext, model)
706
+ q = np.zeros(len(independent_dofs), dtype=float)
707
+ u = reconstruct_full_solution(T, q, u0)
708
+
709
+ solver_info: Dict[str, Any] = {
710
+ "assembly": assembly_info,
711
+ "method": method,
712
+ "constraint_method": "transformation_fixed_plus_mpc",
713
+ "constraint_info": constraint_info,
714
+ "max_iterations": max_iterations,
715
+ "tolerance": tolerance,
716
+ "iterations": 0,
717
+ "converged": False,
718
+ "residual_history": [],
719
+ "displacement_norm_history": [],
720
+ "iteration_times": [],
721
+ }
722
+
723
+ for iteration in range(max_iterations):
724
+ iter_start = time.time()
725
+ F_int = compute_internal_forces(model, u)
726
+ residual_full = F_ext - F_int
727
+ residual_red = np.asarray(T.T @ residual_full, dtype=float).reshape(-1)
728
+ residual_norm = float(np.linalg.norm(residual_red))
729
+ solver_info["residual_history"].append(residual_norm)
730
+
731
+ if residual_norm < tolerance:
732
+ solver_info["converged"] = True
733
+ solver_info["iterations"] = iteration + 1
734
+ break
735
+
736
+ if method == "newton_raphson":
737
+ K, _, _ = assemble_system(model, load_case)
738
+ K_red, _, T, u0, independent_dofs, constraint_info = build_constraint_transformation(K, np.zeros_like(F_ext), model)
739
+
740
+ dq, convergence = _solve_reduced_system(K_red, residual_red, "direct")
741
+ if convergence.get("status") != "converged":
742
+ solver_info["convergence_info"] = convergence
743
+ break
744
+ q += dq
745
+ u = reconstruct_full_solution(T, q, u0)
746
+ solver_info["displacement_norm_history"].append(float(np.linalg.norm(dq)))
747
+ solver_info["iteration_times"].append(time.time() - iter_start)
748
+
749
+ return u, solver_info
750
+
751
+
752
+ def compute_internal_forces(model: "FEModel", displacements: np.ndarray) -> np.ndarray:
753
+ """Compute internal forces for all elements."""
754
+ mesh = model.mesh
755
+ total_dofs = mesh.dof_manager.total_dofs
756
+ F_int = np.zeros(total_dofs, dtype=float)
757
+
758
+ for elem_id, element in mesh.elements.items():
759
+ material = model.get_material(element.material_name)
760
+ dof_mapping = np.asarray(element.get_dof_mapping(mesh), dtype=np.intp)
761
+ if dof_mapping.size == 0:
762
+ continue
763
+ u_elem = displacements[dof_mapping]
764
+ F_elem = np.asarray(element.compute_internal_forces(mesh, u_elem, material), dtype=float)
765
+ np.add.at(F_int, dof_mapping, F_elem)
766
+ return F_int
767
+
768
+
769
+ def _add_dof_force(force_map: Dict[int, np.ndarray], model: "FEModel", dof: int, value: float) -> None:
770
+ """Accumulate one global-DOF force into a node-indexed six-component map."""
771
+ node_id, local_index, _name = model.mesh.dof_manager.get_dof_info(int(dof))
772
+ if node_id < 0 or local_index < 0:
773
+ return
774
+ force_map.setdefault(int(node_id), np.zeros(6, dtype=float))[int(local_index)] += float(value)
775
+
776
+
777
+ def _compact_force_map(force_map: Dict[int, np.ndarray], tolerance: float = 0.0) -> Dict[int, np.ndarray]:
778
+ """Drop near-zero entries from a node force map."""
779
+ compact: Dict[int, np.ndarray] = {}
780
+ for node_id, values in force_map.items():
781
+ vector = np.asarray(values, dtype=float).reshape(6)
782
+ if np.any(np.abs(vector) > float(tolerance)):
783
+ compact[int(node_id)] = vector
784
+ return compact
785
+
786
+
787
+ def compute_constraint_force_diagnostics(
788
+ model: "FEModel",
789
+ displacements: np.ndarray,
790
+ load_case: Optional["LoadCase"] = None,
791
+ *,
792
+ force_tolerance: float = 0.0,
793
+ ) -> Dict[str, Any]:
794
+ """Return separated support, MPC and nullspace force diagnostics.
795
+
796
+ The raw residual convention is ``K u - F`` on the unreduced global system.
797
+ ``support_reactions`` contains only fixed-DOF residuals. ``mpc_slave_forces``
798
+ contains residuals on slave DOFs, while ``mpc_master_equivalent_forces``
799
+ pushes those slave residuals through the linear MPC coefficients to show
800
+ the equivalent force/moment components seen by master DOFs.
801
+ """
802
+ mesh = model.mesh
803
+ dof_manager = mesh.dof_manager
804
+ model.apply_boundary_conditions()
805
+
806
+ K, _, _ = assemble_system(model)
807
+ if load_case is None:
808
+ F_ext = np.zeros(dof_manager.total_dofs, dtype=float)
809
+ else:
810
+ F_ext = load_case.get_load_vector(mesh, dof_manager, model.get_material)
811
+
812
+ u = np.asarray(displacements, dtype=float).reshape(-1)
813
+ if u.shape[0] != int(K.shape[0]):
814
+ raise ValueError(f"Displacement vector length {u.shape[0]} does not match system size {K.shape[0]}")
815
+
816
+ residual = np.asarray(K @ u - F_ext, dtype=float).reshape(-1)
817
+ fixed_dofs = sorted(int(dof) for dof in getattr(dof_manager, "_constrained_dofs", set()))
818
+ mpc_constraints = _collect_mpc_constraints(model)
819
+
820
+ support_reactions: Dict[int, np.ndarray] = {}
821
+ mpc_slave_forces: Dict[int, np.ndarray] = {}
822
+ mpc_master_equivalent_forces: Dict[int, np.ndarray] = {}
823
+ mpc_constraint_forces: List[Dict[str, Any]] = []
824
+
825
+ for dof in fixed_dofs:
826
+ _add_dof_force(support_reactions, model, dof, residual[dof])
827
+
828
+ for index, constraint in enumerate(mpc_constraints):
829
+ slave = int(constraint["slave"])
830
+ slave_force = float(residual[slave])
831
+ slave_node, slave_local, slave_component = dof_manager.get_dof_info(slave)
832
+ _add_dof_force(mpc_slave_forces, model, slave, slave_force)
833
+
834
+ master_entries: List[Dict[str, Any]] = []
835
+ for master, coefficient in constraint.get("masters", {}).items():
836
+ master_dof = int(master)
837
+ value = float(coefficient) * slave_force
838
+ _add_dof_force(mpc_master_equivalent_forces, model, master_dof, value)
839
+ master_node, master_local, master_component = dof_manager.get_dof_info(master_dof)
840
+ master_entries.append(
841
+ {
842
+ "dof": master_dof,
843
+ "node_id": int(master_node),
844
+ "local_index": int(master_local),
845
+ "component": str(master_component),
846
+ "coefficient": float(coefficient),
847
+ "equivalent_force": value,
848
+ }
849
+ )
850
+
851
+ mpc_constraint_forces.append(
852
+ {
853
+ "index": int(index),
854
+ "label": str(constraint.get("label", f"mpc_{index}")),
855
+ "slave_dof": slave,
856
+ "slave_node_id": int(slave_node),
857
+ "slave_local_index": int(slave_local),
858
+ "slave_component": str(slave_component),
859
+ "slave_force": slave_force,
860
+ "master_equivalent_forces": master_entries,
861
+ "master_equivalent_norm": float(np.linalg.norm([entry["equivalent_force"] for entry in master_entries])),
862
+ }
863
+ )
864
+
865
+ K_red, _, T, _, independent_dofs, constraint_info = build_constraint_transformation(K, F_ext, model)
866
+ reduced_residual = np.asarray(T.T @ residual, dtype=float).reshape(-1)
867
+ Q, nullspace_info = build_reduced_rigid_body_modes(model, independent_dofs, int(K.shape[0]))
868
+ if Q.shape[1] > 0:
869
+ nullspace_generalized_forces = np.asarray(Q.T @ reduced_residual, dtype=float).reshape(-1)
870
+ else:
871
+ nullspace_generalized_forces = np.zeros(0, dtype=float)
872
+
873
+ return {
874
+ "residual": residual,
875
+ "residual_norm": float(np.linalg.norm(residual)),
876
+ "reduced_residual_norm": float(np.linalg.norm(reduced_residual)),
877
+ "fixed_dofs": fixed_dofs,
878
+ "mpc_slave_dofs": sorted(int(constraint["slave"]) for constraint in mpc_constraints),
879
+ "support_reactions": _compact_force_map(support_reactions, force_tolerance),
880
+ "mpc_slave_forces": _compact_force_map(mpc_slave_forces, force_tolerance),
881
+ "mpc_master_equivalent_forces": _compact_force_map(mpc_master_equivalent_forces, force_tolerance),
882
+ "mpc_constraint_forces": mpc_constraint_forces,
883
+ "support_reaction_norm": float(np.linalg.norm(np.concatenate(list(support_reactions.values()))) if support_reactions else 0.0),
884
+ "mpc_slave_force_norm": float(np.linalg.norm(np.concatenate(list(mpc_slave_forces.values()))) if mpc_slave_forces else 0.0),
885
+ "mpc_master_equivalent_force_norm": float(
886
+ np.linalg.norm(np.concatenate(list(mpc_master_equivalent_forces.values()))) if mpc_master_equivalent_forces else 0.0
887
+ ),
888
+ "nullspace_generalized_forces": nullspace_generalized_forces,
889
+ "nullspace_generalized_force_norm": float(np.linalg.norm(nullspace_generalized_forces)),
890
+ "constraint_info": constraint_info,
891
+ "nullspace_info": nullspace_info,
892
+ }
893
+
894
+
895
+ def compute_reactions(model: "FEModel", displacements: np.ndarray, load_case: "LoadCase") -> Dict[int, np.ndarray]:
896
+ """Compute legacy combined reactions at fixed and MPC slave DOFs."""
897
+ diagnostics = compute_constraint_force_diagnostics(model, displacements, load_case)
898
+ reactions: Dict[int, np.ndarray] = {}
899
+ for bucket in ("support_reactions", "mpc_slave_forces"):
900
+ for node_id, values in diagnostics[bucket].items():
901
+ reactions.setdefault(int(node_id), np.zeros(6, dtype=float))
902
+ reactions[int(node_id)] += np.asarray(values, dtype=float).reshape(6)
903
+ return _compact_force_map(reactions)
904
+
905
+
906
+ def compute_stresses(
907
+ model: "FEModel",
908
+ displacements: np.ndarray,
909
+ return_global: bool = False,
910
+ element_ids: Optional[Sequence[int]] = None,
911
+ ) -> Dict[int, Dict[str, np.ndarray]]:
912
+ """Compute stresses for all or selected elements."""
913
+ mesh = model.mesh
914
+ stresses: Dict[int, Dict[str, np.ndarray]] = {}
915
+ displacements = np.asarray(displacements, dtype=float)
916
+ selected = None if element_ids is None else {int(element_id) for element_id in element_ids}
917
+ for elem_id, element in mesh.elements.items():
918
+ if selected is not None and int(elem_id) not in selected:
919
+ continue
920
+ material = model.get_material(element.material_name)
921
+ dof_mapping = np.asarray(element.get_dof_mapping(mesh), dtype=np.intp)
922
+ if dof_mapping.size == 0 or int(dof_mapping.max()) >= displacements.size:
923
+ continue
924
+ try:
925
+ stresses[elem_id] = element.compute_stresses(
926
+ mesh, displacements[dof_mapping], material, return_global=return_global
927
+ )
928
+ except (IndexError, ValueError):
929
+ continue
930
+ return stresses
931
+
932
+
933
+ def extract_node_displacements(displacements: np.ndarray, mesh: "FEMesh") -> Dict[int, np.ndarray]:
934
+ """Extract displacements for each node from the global vector."""
935
+ displacements = np.asarray(displacements, dtype=float)
936
+ return {
937
+ node_id: displacements[np.asarray(node.dofs, dtype=np.intp)]
938
+ for node_id, node in mesh.nodes.items()
939
+ }
940
+
941
+
942
+ def extract_element_displacements(displacements: np.ndarray, mesh: "FEMesh") -> Dict[int, np.ndarray]:
943
+ """Extract element displacement vectors."""
944
+ displacements = np.asarray(displacements, dtype=float)
945
+ elem_displacements: Dict[int, np.ndarray] = {}
946
+ for elem_id, element in mesh.elements.items():
947
+ dof_mapping = np.asarray(element.get_dof_mapping(mesh), dtype=np.intp)
948
+ if dof_mapping.size:
949
+ elem_displacements[elem_id] = displacements[dof_mapping]
950
+ return elem_displacements