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,84 @@
1
+ """SESAM formatted FEM document writer and guarded semantic exporter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .diagnostics import FemDiagnostic, SesamFemError
9
+ from .document import SesamFemDocument
10
+ from .records import records_to_text
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SesamFemExportReport:
15
+ path: Path
16
+ records_written: int
17
+ bytes_written: int
18
+ mode: str
19
+ diagnostics: tuple[FemDiagnostic, ...] = ()
20
+
21
+
22
+ def write_sesam_fem_document(
23
+ document: SesamFemDocument,
24
+ path: str | Path,
25
+ *,
26
+ mode: str = "canonical",
27
+ overwrite: bool = False,
28
+ newline: str = "\r\n",
29
+ ) -> SesamFemExportReport:
30
+ """Write a typed SESAM FEM document back to formatted sequential FEM text."""
31
+
32
+ output_path = Path(path)
33
+ if output_path.exists() and not overwrite:
34
+ raise SesamFemError(f"refusing to overwrite existing file: {output_path}", code="FEM201")
35
+ if mode not in {"canonical", "raw"}:
36
+ raise SesamFemError(f"unsupported FEM write mode: {mode}", code="FEM202")
37
+
38
+ if mode == "raw":
39
+ lines: list[str] = []
40
+ saw_iend = False
41
+ for record in document.raw_records:
42
+ if record.name == "IEND":
43
+ saw_iend = True
44
+ lines.extend(record.raw_lines)
45
+ if not saw_iend:
46
+ lines.append("IEND")
47
+ text = newline.join(lines) + newline
48
+ else:
49
+ text = records_to_text(document.raw_records, newline=newline)
50
+
51
+ output_path.parent.mkdir(parents=True, exist_ok=True)
52
+ tmp_path = output_path.with_name(output_path.name + ".tmp")
53
+ tmp_path.write_text(text, encoding="ascii", newline="")
54
+ tmp_path.replace(output_path)
55
+ return SesamFemExportReport(
56
+ path=output_path,
57
+ records_written=sum(1 for record in document.raw_records if record.name != "") + (
58
+ 0 if any(record.name == "IEND" for record in document.raw_records) else 1
59
+ ),
60
+ bytes_written=len(text.encode("ascii")),
61
+ mode=mode,
62
+ )
63
+
64
+
65
+ def export_sesam_fem(model_or_document: object, path: str | Path, **kwargs: object) -> SesamFemExportReport:
66
+ """Export a SESAM FEM document.
67
+
68
+ Full semantic export from arbitrary FEModel objects is a later gate. Passing
69
+ a SesamFemDocument is supported now for validation, canonicalization and
70
+ fixture round-trips.
71
+ """
72
+
73
+ if isinstance(model_or_document, SesamFemDocument):
74
+ return write_sesam_fem_document(model_or_document, path, **kwargs)
75
+ raise SesamFemError(
76
+ "semantic export from FEModel to SESAM FEM is not implemented in this gate",
77
+ code="FEM203",
78
+ diagnostics=(
79
+ FemDiagnostic(
80
+ "FEM203",
81
+ "pass a SesamFemDocument for round-trip export; FEModel export is deferred",
82
+ ),
83
+ ),
84
+ )
@@ -0,0 +1,365 @@
1
+ """SESAM formatted FEM importer for ANYsolver FEModel objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ import re
8
+ from typing import Optional, Sequence
9
+
10
+ from .diagnostics import FemDiagnostic, SesamFemError, raise_if_errors
11
+ from .document import FemElement, FemMaterial, FemSection, SesamFemDocument, read_sesam_fem_document
12
+ from .schema import DOF_NAMES, get_element_spec
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class SesamFemImportResult:
17
+ document: SesamFemDocument
18
+ model: object | None
19
+ diagnostics: tuple[FemDiagnostic, ...]
20
+ element_count_by_type: dict[int, int]
21
+
22
+
23
+ def import_sesam_fem(
24
+ path: str | Path,
25
+ *,
26
+ strict: bool = True,
27
+ build_model: bool = True,
28
+ ) -> SesamFemImportResult:
29
+ """Import a SESAM formatted FEM file.
30
+
31
+ The document layer is always populated. With ``build_model=True`` the
32
+ importer creates an FEModel for supported beam/shell topology, basic
33
+ material/section data and simple nodal boundary flags.
34
+ """
35
+
36
+ document = read_sesam_fem_document(path, strict=strict)
37
+ diagnostics = list(document.diagnostics)
38
+ model = None
39
+ if build_model:
40
+ model, model_diagnostics = build_fe_model_from_sesam_document(document)
41
+ diagnostics.extend(model_diagnostics)
42
+ if strict:
43
+ raise_if_errors(diagnostics, "SESAM FEM import failed")
44
+ element_count_by_type: dict[int, int] = {}
45
+ for element in document.elements.values():
46
+ element_count_by_type[element.type_code] = element_count_by_type.get(element.type_code, 0) + 1
47
+ return SesamFemImportResult(document, model, tuple(diagnostics), element_count_by_type)
48
+
49
+
50
+ def build_fe_model_from_sesam_document(document: SesamFemDocument) -> tuple[object, tuple[FemDiagnostic, ...]]:
51
+ """Build a native FEModel from supported SESAM FEM document semantics."""
52
+
53
+ from ..boundary import BoundaryCondition
54
+ from ..elements import BeamElement, QuadraticBeamElement, ShellElement
55
+ from ..fe_core import FEModel
56
+
57
+ name = f"sesam:{document.source_path.name}" if document.source_path else "sesam:fem"
58
+ model = FEModel(name=name)
59
+ diagnostics: list[FemDiagnostic] = []
60
+ material_names = _add_materials(model, document, diagnostics)
61
+
62
+ for node in document.nodes.values():
63
+ model.add_node(node.node_id, *node.coordinates)
64
+
65
+ for element in document.elements.values():
66
+ spec = get_element_spec(element.type_code)
67
+ if spec is None:
68
+ diagnostics.append(
69
+ FemDiagnostic(
70
+ "FEM103",
71
+ f"unsupported SESAM element type {element.type_code}",
72
+ context={"element_id": element.element_id},
73
+ )
74
+ )
75
+ continue
76
+ missing_nodes = [node_id for node_id in element.node_ids if node_id not in model.mesh.nodes]
77
+ if missing_nodes:
78
+ diagnostics.append(
79
+ FemDiagnostic(
80
+ "FEM105",
81
+ f"element {element.element_id} references missing nodes {missing_nodes}",
82
+ context={"element_id": element.element_id, "missing_nodes": missing_nodes},
83
+ )
84
+ )
85
+ continue
86
+ material_name = material_names.get(element.material_id or 0, "default")
87
+ try:
88
+ if spec.is_shell:
89
+ thickness = _shell_thickness(document.sections.get(element.section_id or 0))
90
+ solver_element = ShellElement(
91
+ element.element_id,
92
+ list(element.node_ids),
93
+ material_name=material_name,
94
+ thickness=thickness,
95
+ )
96
+ elif spec.type_code == 23:
97
+ cross_section = _beam_section(document.sections.get(element.section_id or 0))
98
+ _apply_beam_orientation(cross_section, document, element)
99
+ solver_element = QuadraticBeamElement(
100
+ element.element_id,
101
+ list(element.node_ids),
102
+ material_name=material_name,
103
+ cross_section=cross_section,
104
+ )
105
+ else:
106
+ cross_section = _beam_section(document.sections.get(element.section_id or 0))
107
+ _apply_beam_orientation(cross_section, document, element)
108
+ solver_element = BeamElement(
109
+ element.element_id,
110
+ list(element.node_ids),
111
+ material_name=material_name,
112
+ cross_section=cross_section,
113
+ )
114
+ except Exception as exc: # Element constructors perform useful validation.
115
+ diagnostics.append(
116
+ FemDiagnostic(
117
+ "FEM130",
118
+ f"could not construct element {element.element_id}: {exc}",
119
+ context={"element_id": element.element_id},
120
+ )
121
+ )
122
+ continue
123
+ _attach_sesam_element_metadata(solver_element, document, element)
124
+ model.add_element(element.element_id, solver_element)
125
+
126
+ for index, boundary in enumerate(document.boundaries, start=1):
127
+ if boundary.node_id not in model.mesh.nodes:
128
+ diagnostics.append(
129
+ FemDiagnostic(
130
+ "FEM105",
131
+ f"boundary references missing node {boundary.node_id}",
132
+ context={"node_id": boundary.node_id},
133
+ )
134
+ )
135
+ continue
136
+ constraints = {
137
+ dof_name: 0.0
138
+ for dof_name, flag in zip(DOF_NAMES, boundary.dof_flags)
139
+ if int(flag) != 0
140
+ }
141
+ if constraints:
142
+ model.add_boundary_condition(
143
+ BoundaryCondition(f"sesam_BNBCD_{index}", [boundary.node_id], constraints)
144
+ )
145
+
146
+ if document.load_records:
147
+ from ..boundary import LoadCase
148
+ load_case = LoadCase("sesam_imported_load")
149
+ has_loads = False
150
+ shell_element_ids = {eid for eid, el in model.mesh.elements.items() if el.__class__.__name__ == "ShellElement"}
151
+
152
+ pressure_loads = {}
153
+ for load_record in document.load_records:
154
+ if load_record.record_name == "BEUSLO" and len(load_record.raw_values) >= 9:
155
+ element_id = int(load_record.raw_values[4])
156
+ if element_id in shell_element_ids:
157
+ # load values start at index 8
158
+ import math
159
+ load_values = [float(v) for v in load_record.raw_values[8:] if math.isfinite(float(v))]
160
+ if load_values:
161
+ pressure_loads[element_id] = pressure_loads.get(element_id, 0.0) + sum(load_values) / len(load_values)
162
+
163
+ elif load_record.record_name == "BGRAV" and len(load_record.raw_values) >= 7:
164
+ gx = float(load_record.raw_values[-3])
165
+ gy = float(load_record.raw_values[-2])
166
+ gz = float(load_record.raw_values[-1])
167
+ if abs(gx) > 0 or abs(gy) > 0 or abs(gz) > 0:
168
+ load_case.set_gravity(gx, gy, gz)
169
+ has_loads = True
170
+
171
+ for element_id, pressure in sorted(pressure_loads.items()):
172
+ if pressure != 0.0:
173
+ load_case.add_pressure_load(element_id, pressure)
174
+ has_loads = True
175
+
176
+ if has_loads:
177
+ model.add_load_case(load_case)
178
+ else:
179
+ diagnostics.append(
180
+ FemDiagnostic(
181
+ "FEM121",
182
+ "SESAM load records were found but yielded no active loads.",
183
+ severity="warning",
184
+ context={"load_records": len(document.load_records)},
185
+ )
186
+ )
187
+ if document.dependencies:
188
+ diagnostics.append(
189
+ FemDiagnostic(
190
+ "FEM122",
191
+ "SESAM dependency records are preserved but not translated into solver MPCs yet",
192
+ severity="warning",
193
+ context={"dependency_records": len(document.dependencies)},
194
+ )
195
+ )
196
+
197
+ setattr(model, "sesam_document", document)
198
+ setattr(model, "sesam_import_diagnostics", tuple(diagnostics))
199
+ return model, tuple(diagnostics)
200
+
201
+
202
+ def _element_transform_ids(document: SesamFemDocument, element: FemElement) -> tuple[int, ...]:
203
+ reference = document.element_references.get(element.element_id)
204
+ if reference is None:
205
+ return ()
206
+ if reference.nodal_transform_ids:
207
+ return tuple(reference.nodal_transform_ids)
208
+ if reference.transform_id is not None:
209
+ return (reference.transform_id,)
210
+ return ()
211
+
212
+
213
+ def _normalise_vector(values: Sequence[float]) -> tuple[float, float, float] | None:
214
+ if len(values) < 3:
215
+ return None
216
+ x, y, z = (float(values[0]), float(values[1]), float(values[2]))
217
+ length = (x * x + y * y + z * z) ** 0.5
218
+ if length <= 1.0e-12:
219
+ return None
220
+ return (x / length, y / length, z / length)
221
+
222
+
223
+ def _mean_vector(vectors: Sequence[Sequence[float]]) -> tuple[float, float, float] | None:
224
+ if not vectors:
225
+ return None
226
+ return _normalise_vector(
227
+ (
228
+ sum(float(vector[0]) for vector in vectors),
229
+ sum(float(vector[1]) for vector in vectors),
230
+ sum(float(vector[2]) for vector in vectors),
231
+ )
232
+ )
233
+
234
+
235
+ def _beam_orientation_vector(document: SesamFemDocument, transform_ids: Sequence[int]) -> tuple[float, float, float] | None:
236
+ vectors = []
237
+ for transform_id in transform_ids:
238
+ unit_vector = document.unit_vectors.get(transform_id)
239
+ if unit_vector is not None:
240
+ vectors.append(unit_vector.vector)
241
+ continue
242
+ transform = document.coordinate_transforms.get(transform_id)
243
+ if transform is not None:
244
+ vectors.append(transform.matrix[2])
245
+ return _mean_vector(vectors)
246
+
247
+
248
+ def _apply_beam_orientation(
249
+ cross_section: dict[str, object],
250
+ document: SesamFemDocument,
251
+ element: FemElement,
252
+ ) -> None:
253
+ orientation = _beam_orientation_vector(document, _element_transform_ids(document, element))
254
+ if orientation is not None:
255
+ cross_section["orientation"] = orientation
256
+
257
+
258
+ def _mean_coordinate_transform_axes(
259
+ document: SesamFemDocument,
260
+ transform_ids: Sequence[int],
261
+ ) -> dict[str, tuple[float, float, float]] | None:
262
+ rows = {"x": [], "y": [], "z": []}
263
+ for transform_id in transform_ids:
264
+ transform = document.coordinate_transforms.get(transform_id)
265
+ if transform is None:
266
+ continue
267
+ rows["x"].append(transform.matrix[0])
268
+ rows["y"].append(transform.matrix[1])
269
+ rows["z"].append(transform.matrix[2])
270
+ axes = {name: _mean_vector(vectors) for name, vectors in rows.items()}
271
+ if any(value is None for value in axes.values()):
272
+ return None
273
+ return {name: value for name, value in axes.items() if value is not None}
274
+
275
+
276
+ def _attach_sesam_element_metadata(solver_element: object, document: SesamFemDocument, element: FemElement) -> None:
277
+ transform_ids = _element_transform_ids(document, element)
278
+ reference = document.element_references.get(element.element_id)
279
+ if reference is not None:
280
+ setattr(solver_element, "sesam_reference", reference)
281
+ if not transform_ids:
282
+ return
283
+ setattr(solver_element, "sesam_transform_ids", transform_ids)
284
+ spec = get_element_spec(element.type_code)
285
+ if spec is not None and spec.is_shell:
286
+ axes = _mean_coordinate_transform_axes(document, transform_ids)
287
+ if axes is not None:
288
+ setattr(solver_element, "sesam_local_axes", axes)
289
+
290
+
291
+ def _add_materials(
292
+ model: object,
293
+ document: SesamFemDocument,
294
+ diagnostics: list[FemDiagnostic],
295
+ ) -> dict[int, str]:
296
+ material_names: dict[int, str] = {0: "default"}
297
+ for material_id, material in document.materials.items():
298
+ name = _material_name(material)
299
+ material_names[material_id] = name
300
+ elastic_modulus = material.elastic_modulus or 210.0e9
301
+ poisson_ratio = material.poisson_ratio if material.poisson_ratio is not None else 0.3
302
+ density = material.density or 0.0
303
+ yield_stress = material.yield_stress or 0.0
304
+ if elastic_modulus < 1.0e9:
305
+ diagnostics.append(
306
+ FemDiagnostic(
307
+ "FEM123",
308
+ f"material {material_id} elastic modulus is unusually small for SI units",
309
+ severity="warning",
310
+ context={"material_id": material_id, "elastic_modulus": elastic_modulus},
311
+ )
312
+ )
313
+ model.add_material(
314
+ name,
315
+ elastic_modulus=elastic_modulus,
316
+ poisson_ratio=poisson_ratio,
317
+ density=density,
318
+ yield_stress=yield_stress,
319
+ )
320
+ return material_names
321
+
322
+
323
+ def _material_name(material: FemMaterial) -> str:
324
+ suffix = _safe_name(material.name) if material.name else str(material.material_id)
325
+ return f"sesam_material_{suffix}"
326
+
327
+
328
+ def _safe_name(value: str) -> str:
329
+ cleaned = re.sub(r"[^0-9A-Za-z_]+", "_", value.strip())
330
+ return cleaned.strip("_") or "unnamed"
331
+
332
+
333
+ def _shell_thickness(section: Optional[FemSection]) -> float:
334
+ if section is not None and section.thickness is not None and section.thickness > 0.0:
335
+ return float(section.thickness)
336
+ return 0.01
337
+
338
+
339
+ def _beam_section(section: Optional[FemSection]) -> dict[str, object]:
340
+ if section is None:
341
+ return {}
342
+ data: dict[str, object] = {}
343
+ if section.name:
344
+ data["name"] = section.name
345
+ if section.area is not None:
346
+ data["area"] = float(section.area)
347
+ if section.iy is not None:
348
+ data["Iy"] = float(section.iy)
349
+ if section.iz is not None:
350
+ data["Iz"] = float(section.iz)
351
+ if section.torsion is not None:
352
+ data["J"] = float(section.torsion)
353
+ if section.web_height is not None:
354
+ data["web_height"] = float(section.web_height)
355
+ if section.web_thickness is not None:
356
+ data["web_thickness"] = float(section.web_thickness)
357
+ if section.flange_width is not None:
358
+ data["flange_width"] = float(section.flange_width)
359
+ if section.flange_thickness is not None:
360
+ data["flange_thickness"] = float(section.flange_thickness)
361
+ if section.section_type:
362
+ data["section_type"] = section.section_type
363
+ elif data.get("flange_width", 0.0) > 0.0:
364
+ data["section_type"] = "T"
365
+ return data