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