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
@@ -0,0 +1,558 @@
1
+ """Explicit stiffness, mass and load assembly APIs.
2
+
3
+ This module is the step-3 public assembly interface. It keeps K, M and F
4
+ assembly separate so modal, buckling and nonlinear solvers can choose exactly
5
+ which matrices they need without side effects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import time
13
+ from typing import TYPE_CHECKING, Any, Callable, Dict, Mapping, Optional, Sequence, Tuple
14
+
15
+ import numpy as np
16
+ from scipy import sparse
17
+
18
+ if TYPE_CHECKING:
19
+ from .boundary import LoadCase
20
+ from .fe_core import FEModel
21
+
22
+
23
+ class AssemblyError(ValueError):
24
+ """Raised when an element returns an invalid matrix or load contribution."""
25
+
26
+
27
+ def _base_info(model: "FEModel", matrix_type: str) -> Dict[str, Any]:
28
+ mesh = model.mesh
29
+ return {
30
+ "matrix_type": matrix_type,
31
+ "num_elements": 0,
32
+ "num_nodes": mesh.num_nodes,
33
+ "total_dofs": mesh.dof_manager.total_dofs,
34
+ "assembly_time": 0.0,
35
+ "element_times": {},
36
+ "skipped_elements": [],
37
+ "diagnostics": {},
38
+ "revision_signature": getattr(mesh, "revision_signature", lambda: {})(),
39
+ }
40
+
41
+
42
+ def _check_element_matrix_shape(element_id: int, matrix_name: str, matrix: np.ndarray, expected_size: int) -> np.ndarray:
43
+ matrix = np.asarray(matrix, dtype=float)
44
+ expected_shape = (expected_size, expected_size)
45
+ if matrix.shape != expected_shape:
46
+ raise AssemblyError(
47
+ f"Element {element_id} returned {matrix_name} with shape {matrix.shape}; "
48
+ f"expected {expected_shape}."
49
+ )
50
+ if not np.all(np.isfinite(matrix)):
51
+ raise AssemblyError(f"Element {element_id} returned non-finite values in {matrix_name}.")
52
+ return matrix
53
+
54
+
55
+ def _relative_symmetry_error(matrix: sparse.spmatrix | np.ndarray) -> float:
56
+ if sparse.issparse(matrix):
57
+ diff = matrix - matrix.T
58
+ numerator = float(sparse.linalg.norm(diff))
59
+ denominator = max(float(sparse.linalg.norm(matrix)), 1.0)
60
+ return numerator / denominator
61
+ dense = np.asarray(matrix, dtype=float)
62
+ return float(np.linalg.norm(dense - dense.T) / max(np.linalg.norm(dense), 1.0))
63
+
64
+
65
+ def _topology_signature(mesh: Any, matrix_type: str) -> str:
66
+ revisions = getattr(mesh, "revision_signature", lambda: {})()
67
+ cache_key = (
68
+ str(matrix_type),
69
+ int(revisions.get("topology", 0)),
70
+ int(revisions.get("mpc", 0)),
71
+ )
72
+ cache = getattr(mesh, "_topology_signature_cache", None)
73
+ if cache is None:
74
+ cache = {}
75
+ mesh._topology_signature_cache = cache
76
+ cached = cache.get(cache_key)
77
+ if cached is not None:
78
+ return str(cached)
79
+
80
+ payload = {
81
+ "matrix_type": matrix_type,
82
+ "topology_revision": revisions.get("topology", 0),
83
+ "mpc_revision": revisions.get("mpc", 0),
84
+ "elements": [
85
+ {
86
+ "id": int(elem_id),
87
+ "class": element.__class__.__name__,
88
+ "node_ids": [int(node_id) for node_id in getattr(element, "node_ids", [])],
89
+ "dofs": [int(dof) for dof in element.get_dof_mapping(mesh)],
90
+ }
91
+ for elem_id, element in mesh.elements.items()
92
+ ],
93
+ }
94
+ text = json.dumps(payload, sort_keys=True, separators=(",", ":"))
95
+ signature = hashlib.sha256(text.encode("utf-8")).hexdigest()
96
+ cache[cache_key] = signature
97
+ return signature
98
+
99
+
100
+ def _scatter_element_matrix(
101
+ element_matrix: np.ndarray,
102
+ dof_mapping: np.ndarray,
103
+ rows: list,
104
+ cols: list,
105
+ data: list,
106
+ ) -> None:
107
+ """Append element matrix entries to COO triplet buffers (vectorized)."""
108
+ n_local = dof_mapping.size
109
+ values = element_matrix.ravel()
110
+ mask = values != 0.0
111
+ if not np.any(mask):
112
+ return
113
+ rows.append(np.repeat(dof_mapping, n_local)[mask])
114
+ cols.append(np.tile(dof_mapping, n_local)[mask])
115
+ data.append(values[mask])
116
+
117
+
118
+ def _triplets_to_csr(rows: list, cols: list, data: list, total_dofs: int) -> sparse.csr_matrix:
119
+ """Build a CSR matrix from COO triplet buffers; duplicates are summed."""
120
+ if not data:
121
+ return sparse.csr_matrix((total_dofs, total_dofs), dtype=float)
122
+ coo = sparse.coo_matrix(
123
+ (np.concatenate(data), (np.concatenate(rows), np.concatenate(cols))),
124
+ shape=(total_dofs, total_dofs),
125
+ dtype=float,
126
+ )
127
+ return coo.tocsr()
128
+
129
+
130
+ def _get_cached_sparsity_pattern(mesh: "FEMesh", matrix_type: str) -> Tuple[np.ndarray, np.ndarray]:
131
+ """Retrieve or build the cached row and column indices for global matrix COO assembly."""
132
+ if not hasattr(mesh, "_sparsity_cache"):
133
+ mesh._sparsity_cache = {}
134
+
135
+ signature = _topology_signature(mesh, matrix_type)
136
+
137
+ if matrix_type in mesh._sparsity_cache:
138
+ cached = mesh._sparsity_cache[matrix_type]
139
+ if cached.get("signature") == signature:
140
+ return cached["rows"], cached["cols"]
141
+
142
+ rows_list = []
143
+ cols_list = []
144
+ for _, element in mesh.elements.items():
145
+ dof_mapping = np.asarray(element.get_dof_mapping(mesh), dtype=np.intp)
146
+ if dof_mapping.size == 0:
147
+ continue
148
+ n_local = dof_mapping.size
149
+ rows_list.append(np.repeat(dof_mapping, n_local))
150
+ cols_list.append(np.tile(dof_mapping, n_local))
151
+
152
+ rows_concat = np.concatenate(rows_list) if rows_list else np.empty(0, dtype=np.intp)
153
+ cols_concat = np.concatenate(cols_list) if cols_list else np.empty(0, dtype=np.intp)
154
+
155
+ mesh._sparsity_cache[matrix_type] = {
156
+ "rows": rows_concat,
157
+ "cols": cols_concat,
158
+ "signature": signature,
159
+ }
160
+ return rows_concat, cols_concat
161
+
162
+
163
+ def _assemble_element_matrix(
164
+ model: "FEModel",
165
+ matrix_type: str,
166
+ element_matrix_getter: Callable[[Any, Any, Any], np.ndarray],
167
+ ) -> Tuple[sparse.csr_matrix, Dict[str, Any]]:
168
+ mesh = model.mesh
169
+ total_dofs = mesh.dof_manager.total_dofs
170
+ info = _base_info(model, matrix_type)
171
+ start_time = time.time()
172
+
173
+ # Precompute shell matrices in a JIT-compiled batch for stiffness and mass assembly
174
+ precomputed = {}
175
+ vectorized_shell_groups = []
176
+ if matrix_type in {"stiffness", "mass"}:
177
+ from .elements import ShellElement
178
+ from .jit_compiler import JIT_ENABLED, JIT_DISABLED_REASON, jit_diagnostics
179
+ from .vectorized_stiffness import compute_shell_mass_matrices_jit, compute_shell_stiffness_matrices_jit
180
+
181
+ groups = {}
182
+ for elem_id, element in mesh.elements.items():
183
+ if (
184
+ isinstance(element, ShellElement)
185
+ and getattr(element, "_is_quadrilateral", False)
186
+ and not (getattr(element, "_is_8node", False) and bool(getattr(element, "reduced_integration", False)))
187
+ ):
188
+ key = (
189
+ element.num_nodes,
190
+ element.thickness,
191
+ element.drilling_stabilization,
192
+ element.reduced_integration,
193
+ element.hourglass_stabilization,
194
+ element.material_name,
195
+ )
196
+ if key not in groups:
197
+ groups[key] = []
198
+ groups[key].append((elem_id, element))
199
+
200
+ for key, elem_list in groups.items():
201
+ num_nodes, thickness, drilling_stabilization, _reduced_integration, _hourglass_stabilization, material_name = key
202
+ material = model.get_material(material_name)
203
+ E = float(material.elastic_modulus)
204
+ nu = float(material.poisson_ratio)
205
+ G = float(material.shear_modulus)
206
+
207
+ n_elem = len(elem_list)
208
+ coords_all = np.zeros((n_elem, num_nodes, 3))
209
+ for idx, (elem_id, element) in enumerate(elem_list):
210
+ coords_all[idx] = element.get_node_coordinates(mesh)
211
+
212
+ first_element = elem_list[0][1]
213
+ is_4node = first_element._is_4node
214
+ gauss_points = first_element.gauss_points
215
+ gauss_weights = first_element.gauss_weights
216
+
217
+ if matrix_type == "mass":
218
+ kernel_name = "compute_shell_mass_matrices_jit"
219
+ batched = compute_shell_mass_matrices_jit(
220
+ coords_all,
221
+ is_4node,
222
+ thickness,
223
+ float(material.density),
224
+ gauss_points,
225
+ gauss_weights,
226
+ )
227
+ else:
228
+ kernel_name = "compute_shell_stiffness_matrices_jit"
229
+ if is_4node:
230
+ shear_points = np.empty((0, 2))
231
+ shear_weights = np.empty(0)
232
+ else:
233
+ shear_points = first_element.shear_gauss_points
234
+ shear_weights = first_element.shear_gauss_weights
235
+ batched = compute_shell_stiffness_matrices_jit(
236
+ coords_all,
237
+ is_4node,
238
+ thickness,
239
+ drilling_stabilization,
240
+ E,
241
+ nu,
242
+ G,
243
+ gauss_points,
244
+ gauss_weights,
245
+ shear_points,
246
+ shear_weights,
247
+ )
248
+
249
+ for idx, (elem_id, element) in enumerate(elem_list):
250
+ precomputed[elem_id] = batched[idx]
251
+ jit_info = jit_diagnostics()
252
+ vectorized_shell_groups.append(
253
+ {
254
+ "shell_order": "S4" if is_4node else "Q8",
255
+ "num_elements": int(n_elem),
256
+ "num_nodes": int(num_nodes),
257
+ "material": str(material_name),
258
+ "thickness": float(thickness),
259
+ "jit_enabled": bool(JIT_ENABLED),
260
+ "jit_disabled_reason": JIT_DISABLED_REASON,
261
+ "kernel": kernel_name,
262
+ "parallel_kernel": True,
263
+ "parallel_threads": jit_info.get("num_threads"),
264
+ "backend": jit_info.get("backend"),
265
+ }
266
+ )
267
+
268
+ # Retrieve or build cached sparsity pattern
269
+ rows_concat, cols_concat = _get_cached_sparsity_pattern(mesh, matrix_type)
270
+
271
+ data_list = []
272
+ for elem_id, element in mesh.elements.items():
273
+ elem_start = time.time()
274
+ material = model.get_material(element.material_name)
275
+ dof_mapping = np.asarray(element.get_dof_mapping(mesh), dtype=np.intp)
276
+ if dof_mapping.size == 0:
277
+ info["skipped_elements"].append(int(elem_id))
278
+ continue
279
+
280
+ if elem_id in precomputed:
281
+ element_matrix = precomputed[elem_id]
282
+ else:
283
+ element_matrix = element_matrix_getter(element, mesh, material)
284
+
285
+ element_matrix = _check_element_matrix_shape(
286
+ int(elem_id),
287
+ matrix_type,
288
+ element_matrix,
289
+ int(dof_mapping.size),
290
+ )
291
+ if matrix_type in {"stiffness", "mass", "geometric_stiffness"}:
292
+ local_symmetry = _relative_symmetry_error(element_matrix)
293
+ if local_symmetry > 1.0e-8:
294
+ raise AssemblyError(
295
+ f"Element {elem_id} returned nonsymmetric {matrix_type}; "
296
+ f"relative symmetry error {local_symmetry:.3e}."
297
+ )
298
+ data_list.append(np.asarray(element_matrix, dtype=float).ravel())
299
+
300
+ info["element_times"][int(elem_id)] = time.time() - elem_start
301
+ info["num_elements"] += 1
302
+
303
+ if not data_list:
304
+ matrix = sparse.csr_matrix((total_dofs, total_dofs), dtype=float)
305
+ info["diagnostics"]["assembled_symmetry_error"] = 0.0
306
+ info["sparsity_signature"] = _topology_signature(mesh, matrix_type)
307
+ info["assembly_time"] = time.time() - start_time
308
+ return matrix, info
309
+
310
+ data_concat = np.concatenate(data_list)
311
+ coo = sparse.coo_matrix(
312
+ (data_concat, (rows_concat, cols_concat)),
313
+ shape=(total_dofs, total_dofs),
314
+ dtype=float,
315
+ )
316
+ matrix = coo.tocsr()
317
+ info["diagnostics"]["assembled_symmetry_error"] = _relative_symmetry_error(matrix)
318
+ if matrix_type in {"stiffness", "mass"}:
319
+ info["diagnostics"]["vectorized_shell_groups"] = vectorized_shell_groups
320
+ info["diagnostics"]["vectorized_shell_element_count"] = int(len(precomputed))
321
+ info["diagnostics"]["scalar_shell_element_count"] = int(info["num_elements"] - len(precomputed))
322
+ info["sparsity_signature"] = _topology_signature(mesh, matrix_type)
323
+ info["assembly_time"] = time.time() - start_time
324
+ return matrix, info
325
+
326
+
327
+ def assemble_stiffness_matrix(model: "FEModel") -> Tuple[sparse.csr_matrix, Dict[str, Any]]:
328
+ """Assemble the global stiffness matrix K only."""
329
+ return _assemble_element_matrix(
330
+ model,
331
+ "stiffness",
332
+ lambda element, mesh, material: element.compute_stiffness_matrix(mesh, material),
333
+ )
334
+
335
+
336
+ def assemble_mass_matrix(model: "FEModel") -> Tuple[sparse.csr_matrix, Dict[str, Any]]:
337
+ """Assemble the global mass matrix M only, including any added point masses."""
338
+ matrix, info = _assemble_element_matrix(
339
+ model,
340
+ "mass",
341
+ lambda element, mesh, material: element.compute_mass_matrix(mesh, material),
342
+ )
343
+ matrix = _add_point_masses_to_matrix(model, matrix)
344
+ info["diagnostics"]["point_mass_count"] = int(len(getattr(model.mesh, "point_masses", {}) or {}))
345
+ return matrix, info
346
+
347
+
348
+ def _add_point_masses_to_matrix(model: "FEModel", matrix: sparse.csr_matrix) -> sparse.csr_matrix:
349
+ """Add lumped point masses to the translational-DOF diagonal of ``matrix``."""
350
+ point_masses = getattr(model.mesh, "point_masses", None)
351
+ if not point_masses:
352
+ return matrix
353
+ total_dofs = model.mesh.dof_manager.total_dofs
354
+ diagonal = np.zeros(total_dofs, dtype=float)
355
+ for node_id, mass in point_masses.items():
356
+ node = model.mesh.get_node(int(node_id))
357
+ if node is None or float(mass) == 0.0:
358
+ continue
359
+ for axis in range(3):
360
+ diagonal[node.dofs[axis]] += float(mass)
361
+ if not diagonal.any():
362
+ return matrix
363
+ return (matrix + sparse.diags(diagonal, 0, shape=(total_dofs, total_dofs), format="csr")).tocsr()
364
+
365
+
366
+ def _get_element_state(element_states: Optional[Any], element_id: int, element: Any) -> Any:
367
+ if element_states is None:
368
+ return None
369
+ if callable(element_states):
370
+ try:
371
+ return element_states(element_id, element)
372
+ except TypeError:
373
+ return element_states(element_id)
374
+ if isinstance(element_states, Mapping):
375
+ if element_id in element_states:
376
+ return element_states[element_id]
377
+ element_id_text = str(element_id)
378
+ if element_id_text in element_states:
379
+ return element_states[element_id_text]
380
+ return None
381
+
382
+
383
+ def assemble_geometric_stiffness_matrix(
384
+ model: "FEModel",
385
+ element_states: Optional[Any] = None,
386
+ ) -> Tuple[sparse.csr_matrix, Dict[str, Any]]:
387
+ """Assemble the global geometric stiffness matrix KG only.
388
+
389
+ ``element_states`` supplies the reference stress/resultant state for each
390
+ element. The current beam-column implementation accepts a numeric value or
391
+ a mapping with ``axial_compression`` positive in compression.
392
+ """
393
+ mesh = model.mesh
394
+ total_dofs = mesh.dof_manager.total_dofs
395
+ info = _base_info(model, "geometric_stiffness")
396
+ start_time = time.time()
397
+
398
+ # Retrieve or build cached sparsity pattern
399
+ rows_concat, cols_concat = _get_cached_sparsity_pattern(mesh, "geometric_stiffness")
400
+
401
+ data_list = []
402
+ for elem_id, element in mesh.elements.items():
403
+ elem_start = time.time()
404
+ material = model.get_material(element.material_name)
405
+ dof_mapping = np.asarray(element.get_dof_mapping(mesh), dtype=np.intp)
406
+ if dof_mapping.size == 0:
407
+ info["skipped_elements"].append(int(elem_id))
408
+ continue
409
+
410
+ state = _get_element_state(element_states, int(elem_id), element)
411
+ getter = getattr(element, "compute_geometric_stiffness_matrix", None)
412
+ if getter is None:
413
+ element_matrix = np.zeros((dof_mapping.size, dof_mapping.size), dtype=float)
414
+ else:
415
+ element_matrix = getter(mesh, material, state)
416
+ element_matrix = _check_element_matrix_shape(
417
+ int(elem_id),
418
+ "geometric_stiffness",
419
+ element_matrix,
420
+ int(dof_mapping.size),
421
+ )
422
+ data_list.append(np.asarray(element_matrix, dtype=float).ravel())
423
+
424
+ info["element_times"][int(elem_id)] = time.time() - elem_start
425
+ info["num_elements"] += 1
426
+
427
+ info["state_source"] = "none" if element_states is None else type(element_states).__name__
428
+
429
+ if not data_list:
430
+ matrix = sparse.csr_matrix((total_dofs, total_dofs), dtype=float)
431
+ info["diagnostics"]["assembled_symmetry_error"] = 0.0
432
+ info["sparsity_signature"] = _topology_signature(mesh, "geometric_stiffness")
433
+ info["assembly_time"] = time.time() - start_time
434
+ return matrix, info
435
+
436
+ data_concat = np.concatenate(data_list)
437
+ coo = sparse.coo_matrix(
438
+ (data_concat, (rows_concat, cols_concat)),
439
+ shape=(total_dofs, total_dofs),
440
+ dtype=float,
441
+ )
442
+ matrix = coo.tocsr()
443
+ info["diagnostics"]["assembled_symmetry_error"] = _relative_symmetry_error(matrix)
444
+ info["sparsity_signature"] = _topology_signature(mesh, "geometric_stiffness")
445
+ info["assembly_time"] = time.time() - start_time
446
+ return matrix, info
447
+
448
+
449
+ def assemble_load_vector(model: "FEModel", load_case: Optional["LoadCase"] = None) -> Tuple[np.ndarray, Dict[str, Any]]:
450
+ """Assemble the global external load vector F only."""
451
+ total_dofs = model.mesh.dof_manager.total_dofs
452
+ start_time = time.time()
453
+ if load_case is None:
454
+ load_vector = np.zeros(total_dofs, dtype=float)
455
+ load_name = None
456
+ else:
457
+ load_vector = load_case.get_load_vector(model.mesh, model.mesh.dof_manager, model.get_material)
458
+ load_vector = np.asarray(load_vector, dtype=float).reshape(-1)
459
+ load_name = load_case.name
460
+
461
+ if load_vector.shape != (total_dofs,):
462
+ raise AssemblyError(f"Load vector shape {load_vector.shape} does not match total DOFs {(total_dofs,)}.")
463
+ if not np.all(np.isfinite(load_vector)):
464
+ raise AssemblyError(f"Load case {load_name!r} produced non-finite load vector values.")
465
+
466
+ return load_vector, {
467
+ "vector_type": "load",
468
+ "load_case": load_name,
469
+ "num_nodes": model.mesh.num_nodes,
470
+ "total_dofs": total_dofs,
471
+ "assembly_time": time.time() - start_time,
472
+ "load_norm": float(np.linalg.norm(load_vector)),
473
+ }
474
+
475
+
476
+ def assemble_load_matrix(
477
+ model: "FEModel",
478
+ load_cases: Sequence[Optional["LoadCase"]],
479
+ ) -> Tuple[np.ndarray, Dict[str, Any]]:
480
+ """Assemble a dense load matrix with one column per load case."""
481
+ start = time.time()
482
+ vectors = []
483
+ infos = []
484
+ names = []
485
+ for load_case in load_cases:
486
+ vector, info = assemble_load_vector(model, load_case)
487
+ vectors.append(vector)
488
+ infos.append(info)
489
+ names.append(None if load_case is None else load_case.name)
490
+ total_dofs = model.mesh.dof_manager.total_dofs
491
+ matrix = np.column_stack(vectors) if vectors else np.zeros((total_dofs, 0), dtype=float)
492
+ return matrix, {
493
+ "vector_type": "load_matrix",
494
+ "load_cases": names,
495
+ "num_load_cases": len(names),
496
+ "total_dofs": total_dofs,
497
+ "assembly_time": time.time() - start,
498
+ "columns": infos,
499
+ "load_norms": [float(np.linalg.norm(matrix[:, idx])) for idx in range(matrix.shape[1])],
500
+ "revision_signature": getattr(model.mesh, "revision_signature", lambda: {})(),
501
+ }
502
+
503
+
504
+ def assemble_damping_matrix(
505
+ model: "FEModel",
506
+ rayleigh_alpha: float = 0.0,
507
+ rayleigh_beta: float = 0.0,
508
+ ) -> Tuple[sparse.csr_matrix, Dict[str, Any]]:
509
+ """Assemble Rayleigh damping C = alpha M + beta K."""
510
+ start = time.time()
511
+ M, mass_info = assemble_mass_matrix(model)
512
+ K, stiffness_info = assemble_stiffness_matrix(model)
513
+ C = (float(rayleigh_alpha) * M + float(rayleigh_beta) * K).tocsr()
514
+ return C, {
515
+ "matrix_type": "damping",
516
+ "rayleigh_alpha": float(rayleigh_alpha),
517
+ "rayleigh_beta": float(rayleigh_beta),
518
+ "mass": mass_info,
519
+ "stiffness": stiffness_info,
520
+ "assembly_time": time.time() - start,
521
+ "diagnostics": {"assembled_symmetry_error": _relative_symmetry_error(C)},
522
+ "revision_signature": getattr(model.mesh, "revision_signature", lambda: {})(),
523
+ }
524
+
525
+
526
+ def assemble_system(
527
+ model: "FEModel",
528
+ load_case: Optional["LoadCase"] = None,
529
+ include_mass: bool = False,
530
+ ) -> Tuple[sparse.csr_matrix, np.ndarray, Dict[str, Any]]:
531
+ """Compatibility wrapper returning K, F and assembly metadata.
532
+
533
+ The mass matrix is assembled separately and returned in info["mass_matrix"]
534
+ only when include_mass is true. It is never added to stiffness.
535
+ """
536
+ start_time = time.time()
537
+ K, stiffness_info = assemble_stiffness_matrix(model)
538
+ F, load_info = assemble_load_vector(model, load_case)
539
+
540
+ info: Dict[str, Any] = {
541
+ "num_elements": stiffness_info["num_elements"],
542
+ "num_nodes": model.mesh.num_nodes,
543
+ "total_dofs": model.mesh.dof_manager.total_dofs,
544
+ "includes_mass_matrix": bool(include_mass),
545
+ "assembly_time": 0.0,
546
+ "stiffness": stiffness_info,
547
+ "load": load_info,
548
+ # Backwards-compatible keys used by older diagnostics/tests.
549
+ "element_times": stiffness_info.get("element_times", {}),
550
+ }
551
+
552
+ if include_mass:
553
+ M, mass_info = assemble_mass_matrix(model)
554
+ info["mass_matrix"] = M
555
+ info["mass"] = mass_info
556
+
557
+ info["assembly_time"] = time.time() - start_time
558
+ return K, F, info