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/results.py ADDED
@@ -0,0 +1,503 @@
1
+ """
2
+ Results Module
3
+
4
+ This module provides classes for storing and processing FE analysis results.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ from dataclasses import dataclass, field
9
+ from functools import lru_cache
10
+ from typing import TYPE_CHECKING, List, Dict, Tuple, Optional, Any
11
+ import numpy as np
12
+
13
+ if TYPE_CHECKING:
14
+ from .fe_core import FEModel, FEMesh
15
+ from .recovery import RecoveryConfig, ResourceConfig
16
+
17
+
18
+ @dataclass
19
+ class FEResult:
20
+ """
21
+ Complete FE analysis result.
22
+
23
+ Stores displacements, stresses, reactions, and solver information.
24
+ """
25
+ model_name: str
26
+ displacements: np.ndarray # Global displacement vector
27
+ node_displacements: Dict[int, np.ndarray] = field(default_factory=dict)
28
+ element_stresses: Dict[int, Dict[str, np.ndarray]] = field(default_factory=dict)
29
+ reactions: Dict[int, np.ndarray] = field(default_factory=dict)
30
+ solver_info: Dict[str, Any] = field(default_factory=dict)
31
+ assembly_info: Dict[str, Any] = field(default_factory=dict)
32
+ result_case: Optional[Dict[str, Any]] = None
33
+
34
+ # Additional results
35
+ strains: Dict[int, Dict[str, np.ndarray]] = field(default_factory=dict)
36
+ forces: Dict[int, Dict[str, np.ndarray]] = field(default_factory=dict)
37
+
38
+ def __post_init__(self):
39
+ if self.displacements is not None:
40
+ self.max_displacement = np.max(np.abs(self.displacements))
41
+ self.max_displacement_node = np.argmax(np.abs(self.displacements))
42
+ else:
43
+ self.max_displacement = 0.0
44
+ self.max_displacement_node = -1
45
+
46
+ def get_node_displacement(self, node_id: int) -> Optional[np.ndarray]:
47
+ """Get displacement for a specific node."""
48
+ return self.node_displacements.get(node_id)
49
+
50
+ def get_element_stress(self, element_id: int) -> Optional[Dict[str, np.ndarray]]:
51
+ """Get stresses for a specific element."""
52
+ return self.element_stresses.get(element_id)
53
+
54
+ def get_reaction(self, node_id: int) -> Optional[np.ndarray]:
55
+ """Get reaction forces for a specific node."""
56
+ return self.reactions.get(node_id)
57
+
58
+ def get_max_displacement(self) -> Tuple[float, int]:
59
+ """Get maximum displacement and its node ID."""
60
+ return self.max_displacement, self.max_displacement_node
61
+
62
+ def get_displacement_norm(self) -> float:
63
+ """Get the norm of the displacement vector."""
64
+ return np.linalg.norm(self.displacements)
65
+
66
+ def to_dict(self) -> Dict[str, Any]:
67
+ """Convert result to dictionary."""
68
+ return {
69
+ 'model_name': self.model_name,
70
+ 'max_displacement': self.max_displacement,
71
+ 'displacement_norm': self.get_displacement_norm(),
72
+ 'num_nodes': len(self.node_displacements),
73
+ 'num_elements': len(self.element_stresses),
74
+ 'solver_info': self.solver_info,
75
+ 'assembly_info': self.assembly_info,
76
+ 'result_case': self.result_case,
77
+ 'node_displacements': {k: v.tolist() for k, v in self.node_displacements.items()},
78
+ 'reactions': {k: v.tolist() for k, v in self.reactions.items()}
79
+ }
80
+
81
+ def summary(self) -> str:
82
+ """Generate a summary string."""
83
+ lines = [
84
+ f"FE Analysis Result: {self.model_name}",
85
+ f"Max Displacement: {self.max_displacement:.6e} m",
86
+ f"Displacement Norm: {self.get_displacement_norm():.6e} m",
87
+ f"Number of Nodes: {len(self.node_displacements)}",
88
+ f"Number of Elements: {len(self.element_stresses)}",
89
+ f"Solver: {self.solver_info.get('solver_type', 'unknown')}",
90
+ f"Convergence: {self.solver_info.get('convergence_info', {}).get('status', 'unknown')}"
91
+ ]
92
+ return "\n".join(lines)
93
+
94
+
95
+ @dataclass
96
+ class StressResult:
97
+ """Stress results for a single element."""
98
+ element_id: int
99
+ element_type: str
100
+
101
+ # Shell stresses (if applicable)
102
+ membrane_stress_xx: Optional[np.ndarray] = None
103
+ membrane_stress_yy: Optional[np.ndarray] = None
104
+ membrane_stress_xy: Optional[np.ndarray] = None
105
+ bending_stress_xx: Optional[np.ndarray] = None
106
+ bending_stress_yy: Optional[np.ndarray] = None
107
+ bending_stress_xy: Optional[np.ndarray] = None
108
+ shear_stress_xz: Optional[np.ndarray] = None
109
+ shear_stress_yz: Optional[np.ndarray] = None
110
+ von_mises_stress: Optional[np.ndarray] = None
111
+
112
+ # Beam stresses (if applicable)
113
+ axial_stress: Optional[float] = None
114
+ bending_stress_y: Optional[float] = None
115
+ bending_stress_z: Optional[float] = None
116
+ shear_stress_y: Optional[float] = None
117
+ shear_stress_z: Optional[float] = None
118
+ torsional_stress: Optional[float] = None
119
+
120
+ def get_max_von_mises(self) -> float:
121
+ """Get maximum von Mises stress."""
122
+ if self.von_mises_stress is not None:
123
+ return np.max(np.abs(self.von_mises_stress))
124
+ return 0.0
125
+
126
+ def to_dict(self) -> Dict[str, Any]:
127
+ """Convert to dictionary."""
128
+ result = {
129
+ 'element_id': self.element_id,
130
+ 'element_type': self.element_type,
131
+ 'max_von_mises': self.get_max_von_mises()
132
+ }
133
+
134
+ if self.axial_stress is not None:
135
+ result['axial_stress'] = self.axial_stress
136
+ if self.bending_stress_y is not None:
137
+ result['bending_stress_y'] = self.bending_stress_y
138
+ if self.bending_stress_z is not None:
139
+ result['bending_stress_z'] = self.bending_stress_z
140
+
141
+ return result
142
+
143
+
144
+ @dataclass
145
+ class DisplacementResult:
146
+ """Displacement results for a single node."""
147
+ node_id: int
148
+ ux: float = 0.0
149
+ uy: float = 0.0
150
+ uz: float = 0.0
151
+ rx: float = 0.0
152
+ ry: float = 0.0
153
+ rz: float = 0.0
154
+
155
+ def to_array(self) -> np.ndarray:
156
+ """Convert to numpy array."""
157
+ return np.array([self.ux, self.uy, self.uz, self.rx, self.ry, self.rz])
158
+
159
+ def to_dict(self) -> Dict[str, float]:
160
+ """Convert to dictionary."""
161
+ return {
162
+ 'node_id': self.node_id,
163
+ 'ux': self.ux,
164
+ 'uy': self.uy,
165
+ 'uz': self.uz,
166
+ 'rx': self.rx,
167
+ 'ry': self.ry,
168
+ 'rz': self.rz
169
+ }
170
+
171
+
172
+ def create_fe_result(
173
+ model: 'FEModel',
174
+ displacements: np.ndarray,
175
+ solver_info: Dict[str, Any],
176
+ assembly_info: Dict[str, Any] = None,
177
+ recovery_config: Optional["RecoveryConfig"] = None,
178
+ resource_config: Optional["ResourceConfig"] = None,
179
+ ) -> FEResult:
180
+ """
181
+ Create a complete FE result from solver output.
182
+
183
+ Args:
184
+ model: The FE model
185
+ displacements: Global displacement vector
186
+ solver_info: Solver information dictionary
187
+ assembly_info: Assembly information dictionary
188
+
189
+ Returns:
190
+ FEResult object with all results
191
+ """
192
+ from .assembly import compute_reactions
193
+ from .recovery import (
194
+ default_recovery_config,
195
+ enforce_memory_limit,
196
+ estimate_model_memory,
197
+ filter_reactions,
198
+ recover_element_stresses,
199
+ recover_element_stresses_with_report,
200
+ recovery_metadata,
201
+ select_node_displacements,
202
+ )
203
+
204
+ policy_requested = recovery_config is not None or resource_config is not None
205
+ recovery = default_recovery_config(recovery_config)
206
+ memory_estimate = estimate_model_memory(model, recovery_config=recovery) if policy_requested else None
207
+ if memory_estimate is not None:
208
+ enforce_memory_limit(memory_estimate, resource_config, context="create_fe_result")
209
+ policy_metadata = recovery_metadata(recovery, resource_config, memory_estimate) if policy_requested else {}
210
+ result_solver_info = dict(solver_info)
211
+ if policy_requested:
212
+ result_solver_info["recovery_policy"] = policy_metadata
213
+
214
+ # Extract node displacements
215
+ node_displacements = select_node_displacements(model, displacements, recovery)
216
+
217
+ # Compute reactions (need a load case)
218
+ reactions = {}
219
+ if 'load_case' in solver_info:
220
+ load_case = solver_info['load_case']
221
+ reactions = filter_reactions(compute_reactions(model, displacements, load_case), recovery, model)
222
+
223
+ # Compute stresses
224
+ if policy_requested:
225
+ element_stresses, stress_report = recover_element_stresses_with_report(
226
+ model,
227
+ displacements,
228
+ recovery,
229
+ resource_config=resource_config,
230
+ )
231
+ result_solver_info["recovery_policy"]["execution"] = {"element_stress_recovery": stress_report.to_dict()}
232
+ else:
233
+ element_stresses = recover_element_stresses(model, displacements, recovery)
234
+
235
+ result_case = solver_info.get("result_case")
236
+ if policy_requested and isinstance(result_case, dict):
237
+ result_case = dict(result_case)
238
+ result_case["recovery"] = {**dict(result_case.get("recovery", {})), **recovery.to_dict()}
239
+ metadata = dict(result_case.get("metadata", {}))
240
+ metadata.update({key: value for key, value in policy_metadata.items() if key != "recovery"})
241
+ result_case["metadata"] = metadata
242
+
243
+ return FEResult(
244
+ model_name=model.name,
245
+ displacements=displacements,
246
+ node_displacements=node_displacements,
247
+ element_stresses=element_stresses,
248
+ reactions=reactions,
249
+ solver_info=result_solver_info,
250
+ assembly_info=assembly_info or {},
251
+ result_case=result_case,
252
+ )
253
+
254
+
255
+ def post_process_results(result: FEResult) -> Dict[str, Any]:
256
+ """
257
+ Perform additional post-processing on FE results.
258
+
259
+ Args:
260
+ result: The FE result to post-process
261
+
262
+ Returns:
263
+ Dictionary with post-processed results
264
+ """
265
+ post_processed = {
266
+ 'global': {
267
+ 'max_displacement': result.max_displacement,
268
+ 'displacement_norm': result.get_displacement_norm(),
269
+ 'total_strain_energy': 0.0 # Would need stress-strain integration
270
+ },
271
+ 'nodes': {},
272
+ 'elements': {}
273
+ }
274
+
275
+ # Process node results
276
+ for node_id, disp in result.node_displacements.items():
277
+ post_processed['nodes'][node_id] = {
278
+ 'displacement_magnitude': np.linalg.norm(disp[:3]),
279
+ 'rotation_magnitude': np.linalg.norm(disp[3:])
280
+ }
281
+
282
+ # Process element results
283
+ for elem_id, stresses in result.element_stresses.items():
284
+ elem_result = {'max_stress': 0.0}
285
+
286
+ # Find maximum stress component
287
+ for stress_name, stress_values in stresses.items():
288
+ if isinstance(stress_values, np.ndarray):
289
+ max_stress = np.max(np.abs(stress_values))
290
+ else:
291
+ max_stress = abs(stress_values)
292
+
293
+ if max_stress > elem_result['max_stress']:
294
+ elem_result['max_stress'] = max_stress
295
+
296
+ post_processed['elements'][elem_id] = elem_result
297
+
298
+ return post_processed
299
+
300
+
301
+ def compare_with_analytical(result: FEResult, analytical_result: Dict[str, Any]) -> Dict[str, Any]:
302
+ """
303
+ Compare FE results with analytical/semi-analytical results.
304
+
305
+ Args:
306
+ result: FE result
307
+ analytical_result: Analytical result dictionary
308
+
309
+ Returns:
310
+ Dictionary with comparison metrics
311
+ """
312
+ comparison = {
313
+ 'displacement': {},
314
+ 'stress': {},
315
+ 'overall': {}
316
+ }
317
+
318
+ # Compare displacements if available
319
+ if 'max_displacement' in analytical_result:
320
+ fe_max_disp = result.max_displacement
321
+ analytical_max_disp = analytical_result['max_displacement']
322
+ comparison['displacement']['fe'] = fe_max_disp
323
+ comparison['displacement']['analytical'] = analytical_max_disp
324
+ comparison['displacement']['ratio'] = fe_max_disp / analytical_max_disp if analytical_max_disp > 0 else float('inf')
325
+ comparison['displacement']['error_percent'] = abs(fe_max_disp - analytical_max_disp) / analytical_max_disp * 100 if analytical_max_disp > 0 else float('inf')
326
+
327
+ # Compare stresses if available
328
+ if 'max_stress' in analytical_result:
329
+ fe_max_stress = 0.0
330
+ for elem_stresses in result.element_stresses.values():
331
+ for stresses in elem_stresses.values():
332
+ if isinstance(stresses, np.ndarray):
333
+ max_s = np.max(np.abs(stresses))
334
+ else:
335
+ max_s = abs(stresses)
336
+ if max_s > fe_max_stress:
337
+ fe_max_stress = max_s
338
+
339
+ analytical_max_stress = analytical_result['max_stress']
340
+ comparison['stress']['fe'] = fe_max_stress
341
+ comparison['stress']['analytical'] = analytical_max_stress
342
+ comparison['stress']['ratio'] = fe_max_stress / analytical_max_stress if analytical_max_stress > 0 else float('inf')
343
+ comparison['stress']['error_percent'] = abs(fe_max_stress - analytical_max_stress) / analytical_max_stress * 100 if analytical_max_stress > 0 else float('inf')
344
+
345
+ return comparison
346
+
347
+
348
+ _QUAD4_NODE_NATURAL = np.array([[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]], dtype=float)
349
+ _QUAD8_NODE_NATURAL = np.array(
350
+ [
351
+ [-1.0, -1.0],
352
+ [1.0, -1.0],
353
+ [1.0, 1.0],
354
+ [-1.0, 1.0],
355
+ [0.0, -1.0],
356
+ [1.0, 0.0],
357
+ [0.0, 1.0],
358
+ [-1.0, 0.0],
359
+ ],
360
+ dtype=float,
361
+ )
362
+
363
+ _RECOVERED_STRESS_COMPONENTS = ("xx", "yy", "zz", "xy", "yz", "xz")
364
+
365
+
366
+ def _polynomial_basis(points: np.ndarray, num_gauss: int) -> np.ndarray:
367
+ """Polynomial basis matched to the Gauss rule for stress extrapolation."""
368
+ xi = points[:, 0]
369
+ eta = points[:, 1]
370
+ if num_gauss >= 9:
371
+ return np.column_stack(
372
+ [np.ones_like(xi), xi, eta, xi * eta, xi**2, eta**2, xi**2 * eta, xi * eta**2, xi**2 * eta**2]
373
+ )
374
+ if num_gauss >= 4:
375
+ return np.column_stack([np.ones_like(xi), xi, eta, xi * eta])
376
+ return np.ones((points.shape[0], 1), dtype=float)
377
+
378
+
379
+ @lru_cache(maxsize=16)
380
+ def _cached_gauss_to_node_extrapolation(
381
+ num_nodes: int,
382
+ gauss_points_flat: Tuple[float, ...],
383
+ ) -> Optional[np.ndarray]:
384
+ """Build one operator per shell topology and Gauss rule."""
385
+
386
+ gauss_points = np.asarray(gauss_points_flat, dtype=float).reshape(-1, 2)
387
+ num_gauss = gauss_points.shape[0]
388
+ if num_gauss == 0:
389
+ return None
390
+ if num_nodes == 4:
391
+ node_natural = _QUAD4_NODE_NATURAL
392
+ elif num_nodes == 8:
393
+ node_natural = _QUAD8_NODE_NATURAL
394
+ else:
395
+ return None
396
+ P_gauss = _polynomial_basis(gauss_points, num_gauss)
397
+ P_nodes = _polynomial_basis(node_natural, num_gauss)
398
+ coefficients, *_ = np.linalg.lstsq(P_gauss, np.eye(num_gauss), rcond=None)
399
+ operator = P_nodes @ coefficients
400
+ operator.setflags(write=False)
401
+ return operator
402
+
403
+
404
+ def _gauss_to_node_extrapolation(element: Any) -> Optional[np.ndarray]:
405
+ """Return the cached (num_nodes, num_gauss) operator for one shell."""
406
+
407
+ gauss_points = np.asarray(getattr(element, "gauss_points", ()), dtype=float).reshape(-1, 2)
408
+ return _cached_gauss_to_node_extrapolation(
409
+ int(getattr(element, "num_nodes", 0)),
410
+ tuple(float(value) for value in gauss_points.reshape(-1)),
411
+ )
412
+
413
+
414
+ def _von_mises_surface(components: Dict[str, float], suffix: str) -> float:
415
+ sxx = components[f"global_xx_{suffix}"]
416
+ syy = components[f"global_yy_{suffix}"]
417
+ szz = components[f"global_zz_{suffix}"]
418
+ sxy = components[f"global_xy_{suffix}"]
419
+ syz = components[f"global_yz_{suffix}"]
420
+ sxz = components[f"global_xz_{suffix}"]
421
+ return float(
422
+ np.sqrt(
423
+ 0.5 * ((sxx - syy) ** 2 + (syy - szz) ** 2 + (szz - sxx) ** 2)
424
+ + 3.0 * (sxy**2 + syz**2 + sxz**2)
425
+ )
426
+ )
427
+
428
+
429
+ def recover_nodal_stresses(
430
+ model: "FEModel",
431
+ displacements: np.ndarray,
432
+ element_ids: Optional[Any] = None,
433
+ ) -> Dict[str, Any]:
434
+ """Gauss-to-node extrapolated, patch-averaged shell surface stresses.
435
+
436
+ Shell integration-point stresses are second-order accurate inside the
437
+ element but underestimate surface peaks on coarse meshes, especially for
438
+ the 2x2-point S4. This recovery evaluates the global-frame top/bottom
439
+ surface stresses at the integration points, extrapolates them to the
440
+ element nodes with the polynomial basis matched to the Gauss rule, and
441
+ averages contributions from all shell elements sharing each node. Nodal
442
+ von Mises values are computed from the averaged global components, so the
443
+ result is frame-consistent on distorted meshes.
444
+
445
+ Supported for 4-node and 8-node quadrilateral shells; other element types
446
+ are skipped. Returns per-node averaged components/von Mises, the
447
+ per-element nodal (unaveraged) values, and the peak recovered von Mises.
448
+ """
449
+ from .elements import ShellElement
450
+
451
+ keys = [f"global_{component}_{surface}" for surface in ("top", "bot") for component in _RECOVERED_STRESS_COMPONENTS]
452
+ selected = None if element_ids is None else {int(element_id) for element_id in element_ids}
453
+ node_sums: Dict[int, Dict[str, float]] = {}
454
+ node_counts: Dict[int, int] = {}
455
+ element_nodal: Dict[int, Dict[str, np.ndarray]] = {}
456
+ skipped: List[int] = []
457
+ for element_id, element in model.mesh.elements.items():
458
+ if selected is not None and int(element_id) not in selected:
459
+ continue
460
+ if not isinstance(element, ShellElement):
461
+ continue
462
+ operator = _gauss_to_node_extrapolation(element)
463
+ if operator is None:
464
+ skipped.append(int(element_id))
465
+ continue
466
+ material = model.get_material(element.material_name)
467
+ stresses = element.compute_stresses(model.mesh, displacements, material, return_global=True)
468
+ if any(key not in stresses for key in keys):
469
+ skipped.append(int(element_id))
470
+ continue
471
+ nodal_values = {key: operator @ np.asarray(stresses[key], dtype=float).reshape(-1) for key in keys}
472
+ element_nodal[int(element_id)] = nodal_values
473
+ for local_index, node_id in enumerate(element.node_ids):
474
+ entry = node_sums.setdefault(int(node_id), {key: 0.0 for key in keys})
475
+ for key in keys:
476
+ entry[key] += float(nodal_values[key][local_index])
477
+ node_counts[int(node_id)] = node_counts.get(int(node_id), 0) + 1
478
+
479
+ nodal: Dict[int, Dict[str, float]] = {}
480
+ max_von_mises = 0.0
481
+ max_von_mises_node: Optional[int] = None
482
+ for node_id, sums in node_sums.items():
483
+ count = max(node_counts.get(node_id, 1), 1)
484
+ averaged = {key: value / count for key, value in sums.items()}
485
+ vm_top = _von_mises_surface(averaged, "top")
486
+ vm_bot = _von_mises_surface(averaged, "bot")
487
+ averaged["von_mises_top"] = vm_top
488
+ averaged["von_mises_bot"] = vm_bot
489
+ averaged["von_mises"] = max(vm_top, vm_bot)
490
+ nodal[node_id] = averaged
491
+ if averaged["von_mises"] > max_von_mises:
492
+ max_von_mises = averaged["von_mises"]
493
+ max_von_mises_node = int(node_id)
494
+
495
+ return {
496
+ "method": "gauss_extrapolation_nodal_average",
497
+ "stress_frame": "global",
498
+ "nodal": nodal,
499
+ "element_nodal": element_nodal,
500
+ "max_von_mises": float(max_von_mises),
501
+ "max_von_mises_node": max_von_mises_node,
502
+ "skipped_element_ids": sorted(skipped),
503
+ }