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,814 @@
1
+ """Layer B typed SESAM formatted FEM document model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ from dataclasses import dataclass, replace
8
+ from pathlib import Path
9
+ from typing import Mapping, Optional
10
+
11
+ from .diagnostics import FemDiagnostic, SesamFemError, raise_if_errors
12
+ from .records import FemRawRecord, read_raw_records, strict_int
13
+ from .schema import SUPPORTED_RECORDS, classify_record, get_element_spec
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class FemHeader:
18
+ ident_values: tuple[float, ...] = ()
19
+ ident_text: tuple[str, ...] = ()
20
+ date_text: tuple[str, ...] = ()
21
+ unit_values: tuple[float, ...] = ()
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class FemMaterial:
26
+ material_id: int
27
+ name: Optional[str] = None
28
+ elastic_modulus: Optional[float] = None
29
+ poisson_ratio: Optional[float] = None
30
+ density: Optional[float] = None
31
+ yield_stress: Optional[float] = None
32
+ raw_values: tuple[float, ...] = ()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class FemSection:
37
+ section_id: int
38
+ kind: str
39
+ name: Optional[str] = None
40
+ thickness: Optional[float] = None
41
+ area: Optional[float] = None
42
+ iy: Optional[float] = None
43
+ iz: Optional[float] = None
44
+ torsion: Optional[float] = None
45
+ web_height: Optional[float] = None
46
+ web_thickness: Optional[float] = None
47
+ flange_width: Optional[float] = None
48
+ flange_thickness: Optional[float] = None
49
+ section_type: Optional[str] = None
50
+ raw_values: tuple[float, ...] = ()
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class FemConceptRecord:
55
+ record_name: str
56
+ concept_id: Optional[int]
57
+ text: tuple[str, ...]
58
+ raw_values: tuple[float, ...]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class FemCoordinate:
63
+ coordinate_id: int
64
+ raw_values: tuple[float, ...]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class FemUnitVector:
69
+ transform_id: int
70
+ vector: tuple[float, float, float]
71
+ raw_values: tuple[float, ...]
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class FemCoordinateTransform:
76
+ transform_id: int
77
+ matrix: tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]
78
+ raw_values: tuple[float, ...]
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class FemNode:
83
+ node_id: int
84
+ coordinates: tuple[float, float, float]
85
+ coordinate_system_id: int = 0
86
+ raw_values: tuple[float, ...] = ()
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class FemElementReference:
91
+ element_id: int
92
+ material_id: Optional[int]
93
+ section_id: Optional[int]
94
+ transform_id: Optional[int]
95
+ nodal_transform_ids: tuple[int, ...] = ()
96
+ raw_values: tuple[float, ...] = ()
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class FemElement:
101
+ element_id: int
102
+ type_code: int
103
+ topology: str
104
+ node_ids: tuple[int, ...]
105
+ material_id: Optional[int] = None
106
+ section_id: Optional[int] = None
107
+ raw_values: tuple[float, ...] = ()
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class FemBoundary:
112
+ node_id: int
113
+ dof_flags: tuple[int, ...]
114
+ prescribed_values: tuple[float, ...]
115
+ raw_values: tuple[float, ...]
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class FemDependency:
120
+ record_name: str
121
+ raw_values: tuple[float, ...]
122
+ text: tuple[str, ...]
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class FemLoadRecord:
127
+ record_name: str
128
+ load_case_id: Optional[int]
129
+ target_id: Optional[int]
130
+ raw_values: tuple[float, ...]
131
+ text: tuple[str, ...]
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class SesamFemDocument:
136
+ source_path: Optional[Path]
137
+ header: FemHeader
138
+ raw_records: tuple[FemRawRecord, ...]
139
+ record_counts: Mapping[str, int]
140
+ materials: Mapping[int, FemMaterial]
141
+ sections: Mapping[int, FemSection]
142
+ concepts: tuple[FemConceptRecord, ...]
143
+ coordinate_systems: Mapping[int, FemCoordinate]
144
+ unit_vectors: Mapping[int, FemUnitVector]
145
+ coordinate_transforms: Mapping[int, FemCoordinateTransform]
146
+ nodes: Mapping[int, FemNode]
147
+ elements: Mapping[int, FemElement]
148
+ element_references: Mapping[int, FemElementReference]
149
+ boundaries: tuple[FemBoundary, ...]
150
+ dependencies: tuple[FemDependency, ...]
151
+ load_records: tuple[FemLoadRecord, ...]
152
+ unknown_records: tuple[FemRawRecord, ...]
153
+ diagnostics: tuple[FemDiagnostic, ...] = ()
154
+
155
+ def summary(self) -> dict[str, object]:
156
+ return {
157
+ "source_path": str(self.source_path) if self.source_path else None,
158
+ "record_counts": dict(self.record_counts),
159
+ "materials": len(self.materials),
160
+ "sections": len(self.sections),
161
+ "nodes": len(self.nodes),
162
+ "elements": len(self.elements),
163
+ "unit_vectors": len(self.unit_vectors),
164
+ "coordinate_transforms": len(self.coordinate_transforms),
165
+ "element_types": dict(Counter(element.type_code for element in self.elements.values())),
166
+ "boundaries": len(self.boundaries),
167
+ "dependencies": len(self.dependencies),
168
+ "loads": len(self.load_records),
169
+ "unknown_records": len(self.unknown_records),
170
+ "diagnostics": [item.as_dict() for item in self.diagnostics],
171
+ }
172
+
173
+
174
+ def read_sesam_fem_document(path: str | Path, *, strict: bool = True) -> SesamFemDocument:
175
+ """Read a SESAM formatted sequential FEM document."""
176
+
177
+ raw_records = read_raw_records(path, strict=strict)
178
+ document = parse_sesam_fem_records(raw_records, source_path=Path(path), strict=False)
179
+ from .validation import validate_sesam_fem_document
180
+
181
+ diagnostics = tuple(document.diagnostics) + validate_sesam_fem_document(document)
182
+ document = replace(document, diagnostics=diagnostics)
183
+ if strict:
184
+ raise_if_errors(diagnostics, "SESAM FEM document failed validation")
185
+ return document
186
+
187
+
188
+ def parse_sesam_fem_records(
189
+ raw_records: tuple[FemRawRecord, ...],
190
+ *,
191
+ source_path: str | Path | None = None,
192
+ strict: bool = True,
193
+ ) -> SesamFemDocument:
194
+ """Parse raw records into the typed SESAM FEM document model."""
195
+
196
+ diagnostics: list[FemDiagnostic] = []
197
+ header = _parse_header(raw_records)
198
+ record_counts = Counter(record.name for record in raw_records)
199
+ materials, material_names = _parse_materials(raw_records, diagnostics)
200
+ sections, section_names = _parse_sections(raw_records, diagnostics)
201
+ materials = {
202
+ key: replace(value, name=material_names.get(key, value.name))
203
+ for key, value in materials.items()
204
+ }
205
+ sections = {
206
+ key: replace(value, name=section_names.get(key, value.name))
207
+ for key, value in sections.items()
208
+ }
209
+
210
+ coordinate_systems = _parse_coordinates(raw_records, diagnostics)
211
+ unit_vectors = _parse_unit_vectors(raw_records, diagnostics)
212
+ coordinate_transforms = _parse_coordinate_transforms(raw_records, diagnostics)
213
+ known_transform_ids = set(unit_vectors) | set(coordinate_transforms)
214
+ nodes = _parse_nodes(raw_records, diagnostics)
215
+ element_references = _parse_element_references(raw_records, diagnostics, known_transform_ids)
216
+ elements = _parse_elements(raw_records, element_references, diagnostics)
217
+ boundaries = _parse_boundaries(raw_records, diagnostics)
218
+ concepts = _parse_concepts(raw_records, diagnostics)
219
+ dependencies = _parse_dependencies(raw_records)
220
+ load_records = _parse_loads(raw_records, diagnostics)
221
+
222
+ unknown_records = tuple(record for record in raw_records if record.name not in SUPPORTED_RECORDS)
223
+ for record in unknown_records:
224
+ diagnostics.append(
225
+ FemDiagnostic(
226
+ "FEM110",
227
+ f"unknown FEM record preserved as raw data: {record.name}",
228
+ severity="warning",
229
+ record_name=record.name,
230
+ line_start=record.source_line_start,
231
+ line_end=record.source_line_end,
232
+ context={"classification": classify_record(record.name)},
233
+ )
234
+ )
235
+
236
+ document = SesamFemDocument(
237
+ source_path=Path(source_path) if source_path is not None else None,
238
+ header=header,
239
+ raw_records=raw_records,
240
+ record_counts=dict(record_counts),
241
+ materials=materials,
242
+ sections=sections,
243
+ concepts=concepts,
244
+ coordinate_systems=coordinate_systems,
245
+ unit_vectors=unit_vectors,
246
+ coordinate_transforms=coordinate_transforms,
247
+ nodes=nodes,
248
+ elements=elements,
249
+ element_references=element_references,
250
+ boundaries=boundaries,
251
+ dependencies=dependencies,
252
+ load_records=load_records,
253
+ unknown_records=unknown_records,
254
+ diagnostics=tuple(diagnostics),
255
+ )
256
+ if strict:
257
+ from .validation import validate_sesam_fem_document
258
+
259
+ all_diagnostics = tuple(diagnostics) + validate_sesam_fem_document(document)
260
+ raise_if_errors(all_diagnostics, "SESAM FEM records failed validation")
261
+ document = replace(document, diagnostics=all_diagnostics)
262
+ return document
263
+
264
+
265
+ def _parse_header(records: tuple[FemRawRecord, ...]) -> FemHeader:
266
+ ident = next((record for record in records if record.name == "IDENT"), None)
267
+ date = next((record for record in records if record.name == "DATE"), None)
268
+ units = next((record for record in records if record.name == "UNITS"), None)
269
+ return FemHeader(
270
+ ident_values=ident.numeric_fields if ident else (),
271
+ ident_text=ident.text_fields if ident else (),
272
+ date_text=date.text_fields if date else (),
273
+ unit_values=units.numeric_fields if units else (),
274
+ )
275
+
276
+
277
+ def _parse_materials(
278
+ records: tuple[FemRawRecord, ...],
279
+ diagnostics: list[FemDiagnostic],
280
+ ) -> tuple[dict[int, FemMaterial], dict[int, str]]:
281
+ materials: dict[int, FemMaterial] = {}
282
+ names: dict[int, str] = {}
283
+ for record in records:
284
+ if record.name == "MISOSEL":
285
+ if not record.numeric_fields:
286
+ diagnostics.append(_diag("FEM101", "MISOSEL record has no material id", record))
287
+ continue
288
+ material_id = _int_field(record.numeric_fields[0], "material id", record, diagnostics)
289
+ if material_id is None:
290
+ continue
291
+ if material_id in materials:
292
+ diagnostics.append(_diag("FEM102", f"duplicate material id {material_id}", record))
293
+ continue
294
+ materials[material_id] = FemMaterial(
295
+ material_id=material_id,
296
+ elastic_modulus=_first_in_range(record.numeric_fields[1:], 1.0e7, 1.0e13),
297
+ poisson_ratio=_first_in_range(record.numeric_fields[1:], 0.0, 0.5),
298
+ density=_first_in_range(record.numeric_fields[1:], 10.0, 50000.0),
299
+ yield_stress=_first_in_range(record.numeric_fields[1:], 1.0e6, 5.0e9),
300
+ raw_values=record.numeric_fields,
301
+ )
302
+ elif record.name == "TDMATER" and record.numeric_fields:
303
+ material_id = _int_field(record.numeric_fields[0], "material id", record, diagnostics)
304
+ if material_id is not None and record.text_fields:
305
+ names[material_id] = " ".join(record.text_fields)
306
+ return materials, names
307
+
308
+
309
+ def _parse_sections(
310
+ records: tuple[FemRawRecord, ...],
311
+ diagnostics: list[FemDiagnostic],
312
+ ) -> tuple[dict[int, FemSection], dict[int, str]]:
313
+ sections: dict[int, FemSection] = {}
314
+ names: dict[int, str] = {}
315
+ dimensions: dict[int, dict[str, float]] = {}
316
+ for record in records:
317
+ if record.name in {"GELTH", "GBEAMG"}:
318
+ if not record.numeric_fields:
319
+ diagnostics.append(_diag("FEM101", f"{record.name} record has no section id", record))
320
+ continue
321
+ section_id = _int_field(record.numeric_fields[0], "section id", record, diagnostics)
322
+ if section_id is None:
323
+ continue
324
+ if section_id in sections:
325
+ diagnostics.append(_diag("FEM102", f"duplicate section id {section_id}", record))
326
+ continue
327
+ values = record.numeric_fields
328
+ if record.name == "GELTH":
329
+ sections[section_id] = FemSection(
330
+ section_id=section_id,
331
+ kind="shell_thickness",
332
+ thickness=values[1] if len(values) > 1 and values[1] > 0.0 else None,
333
+ raw_values=values,
334
+ )
335
+ else:
336
+ sections[section_id] = FemSection(
337
+ section_id=section_id,
338
+ kind="beam_section",
339
+ area=values[2] if len(values) > 2 and values[2] > 0.0 else None,
340
+ iy=values[4] if len(values) > 4 and values[4] > 0.0 else None,
341
+ iz=values[5] if len(values) > 5 and values[5] > 0.0 else None,
342
+ torsion=values[3] if len(values) > 3 and values[3] > 0.0 else None,
343
+ raw_values=values,
344
+ )
345
+ elif record.name == "TDSECT" and record.numeric_fields:
346
+ values = record.numeric_fields
347
+ section_id_index = 1 if len(values) > 1 and int(round(values[0])) == 4 else 0
348
+ section_id = _int_field(values[section_id_index], "section id", record, diagnostics)
349
+ if section_id is not None and record.text_fields:
350
+ names[section_id] = " ".join(record.text_fields)
351
+ elif record.name in {"GBARM", "GBOX", "GIORH", "GLSEC", "GPIPE"} and record.numeric_fields:
352
+ section_id = _int_field(record.numeric_fields[0], "section id", record, diagnostics)
353
+ if section_id is not None:
354
+ values = record.numeric_fields
355
+ dim: dict[str, float] = {}
356
+ text_dim: dict[str, object] = {}
357
+ if record.name == "GBARM":
358
+ if len(values) > 1 and values[1] > 0.0:
359
+ dim["web_height"] = float(values[1])
360
+ if len(values) > 2 and values[2] > 0.0:
361
+ dim["web_thickness"] = float(values[2])
362
+ text_dim["section_type"] = "FB"
363
+ elif record.name == "GBOX":
364
+ if len(values) > 1 and values[1] > 0.0:
365
+ dim["web_height"] = float(values[1])
366
+ dim["flange_width"] = float(values[1])
367
+ if len(values) > 2 and values[2] > 0.0:
368
+ dim["web_thickness"] = float(values[2])
369
+ if len(values) > 3 and values[3] > 0.0:
370
+ dim["flange_thickness"] = float(values[3])
371
+ text_dim["section_type"] = "Box"
372
+ elif record.name == "GIORH":
373
+ if len(values) > 1 and values[1] > 0.0:
374
+ dim["web_height"] = float(values[1])
375
+ if len(values) > 2 and values[2] > 0.0:
376
+ dim["web_thickness"] = float(values[2])
377
+ if len(values) > 5 and values[5] > 0.0:
378
+ dim["flange_width"] = float(values[5])
379
+ elif len(values) > 3 and values[3] > 0.0:
380
+ dim["flange_width"] = float(values[3])
381
+ if len(values) > 6 and values[6] > 0.0:
382
+ dim["flange_thickness"] = float(values[6])
383
+ elif len(values) > 4 and values[4] > 0.0:
384
+ dim["flange_thickness"] = float(values[4])
385
+ text_dim["section_type"] = "T"
386
+ elif record.name == "GLSEC":
387
+ if len(values) > 1 and values[1] > 0.0:
388
+ dim["web_height"] = float(values[1])
389
+ if len(values) > 2 and values[2] > 0.0:
390
+ dim["web_thickness"] = float(values[2])
391
+ if len(values) > 3 and values[3] > 0.0:
392
+ dim["flange_width"] = float(values[3])
393
+ if len(values) > 4 and values[4] > 0.0:
394
+ dim["flange_thickness"] = float(values[4])
395
+ text_dim["section_type"] = "L"
396
+ elif record.name == "GPIPE":
397
+ outer = values[2] if len(values) > 2 and values[2] > 0.0 else None
398
+ wall = values[3] if len(values) > 3 and values[3] > 0.0 else None
399
+ if outer is not None:
400
+ dim["web_height"] = float(outer)
401
+ dim["flange_width"] = float(outer)
402
+ if wall is not None:
403
+ dim["web_thickness"] = float(wall)
404
+ dim["flange_thickness"] = float(wall)
405
+ text_dim["section_type"] = "Pipe"
406
+ if dim or text_dim:
407
+ dimensions[section_id] = {**dim, **text_dim} # type: ignore[dict-item]
408
+
409
+ for section_id, dim in dimensions.items():
410
+ name_text = names.get(section_id, "")
411
+ if _is_bulb_section_name(name_text):
412
+ dim["section_type"] = "L-bulb"
413
+ if section_id in sections:
414
+ from dataclasses import replace
415
+ sections[section_id] = replace(sections[section_id], **dim)
416
+ else:
417
+ sections[section_id] = FemSection(section_id=section_id, kind="beam_section", **dim)
418
+
419
+ return sections, names
420
+
421
+
422
+ def _is_bulb_section_name(name: object) -> bool:
423
+ normalized = re.sub(r"[^a-z0-9]+", " ", str(name).lower()).strip()
424
+ words = set(normalized.split())
425
+ # Bulb names are often one token with a maker prefix (HPbulb220x11,
426
+ # BSRAbulb220x11x31x9x9x3), so match "bulb" as a substring too.
427
+ return "bulb" in normalized.replace(" ", "") or "hp" in words
428
+
429
+
430
+ def _parse_coordinates(
431
+ records: tuple[FemRawRecord, ...],
432
+ diagnostics: list[FemDiagnostic],
433
+ ) -> dict[int, FemCoordinate]:
434
+ coordinates: dict[int, FemCoordinate] = {}
435
+ for record in records:
436
+ if record.name != "GCOORD":
437
+ continue
438
+ if not record.numeric_fields:
439
+ diagnostics.append(_diag("FEM101", "GCOORD record has no coordinate id", record))
440
+ continue
441
+ coordinate_id = _int_field(record.numeric_fields[0], "coordinate id", record, diagnostics)
442
+ if coordinate_id is None:
443
+ continue
444
+ if coordinate_id in coordinates:
445
+ diagnostics.append(_diag("FEM102", f"duplicate coordinate id {coordinate_id}", record))
446
+ continue
447
+ coordinates[coordinate_id] = FemCoordinate(coordinate_id, record.numeric_fields)
448
+ return coordinates
449
+
450
+ def _parse_unit_vectors(
451
+ records: tuple[FemRawRecord, ...],
452
+ diagnostics: list[FemDiagnostic],
453
+ ) -> dict[int, FemUnitVector]:
454
+ vectors: dict[int, FemUnitVector] = {}
455
+ for record in records:
456
+ if record.name != "GUNIVEC":
457
+ continue
458
+ values = record.numeric_fields
459
+ if len(values) < 4:
460
+ diagnostics.append(_diag("FEM101", "GUNIVEC record must contain id and vector components", record))
461
+ continue
462
+ transform_id = _int_field(values[0], "unit vector id", record, diagnostics)
463
+ if transform_id is None:
464
+ continue
465
+ if transform_id in vectors:
466
+ diagnostics.append(_diag("FEM102", f"duplicate unit vector id {transform_id}", record))
467
+ continue
468
+ vectors[transform_id] = FemUnitVector(
469
+ transform_id=transform_id,
470
+ vector=(float(values[1]), float(values[2]), float(values[3])),
471
+ raw_values=values,
472
+ )
473
+ return vectors
474
+
475
+
476
+ # BNTRCOS stores the transformation from global to local coordinates. The rows
477
+ # are local axis direction cosines in global coordinates for stress recovery.
478
+ def _parse_coordinate_transforms(
479
+ records: tuple[FemRawRecord, ...],
480
+ diagnostics: list[FemDiagnostic],
481
+ ) -> dict[int, FemCoordinateTransform]:
482
+ transforms: dict[int, FemCoordinateTransform] = {}
483
+ for record in records:
484
+ if record.name != "BNTRCOS":
485
+ continue
486
+ values = record.numeric_fields
487
+ if len(values) < 10:
488
+ diagnostics.append(_diag("FEM101", "BNTRCOS record must contain id and 9 direction cosines", record))
489
+ continue
490
+ transform_id = _int_field(values[0], "coordinate transform id", record, diagnostics)
491
+ if transform_id is None:
492
+ continue
493
+ if transform_id in transforms:
494
+ diagnostics.append(_diag("FEM102", f"duplicate coordinate transform id {transform_id}", record))
495
+ continue
496
+ matrix = (
497
+ (float(values[1]), float(values[2]), float(values[3])),
498
+ (float(values[4]), float(values[5]), float(values[6])),
499
+ (float(values[7]), float(values[8]), float(values[9])),
500
+ )
501
+ transforms[transform_id] = FemCoordinateTransform(transform_id, matrix, values)
502
+ return transforms
503
+
504
+
505
+ def _parse_nodes(records: tuple[FemRawRecord, ...], diagnostics: list[FemDiagnostic]) -> dict[int, FemNode]:
506
+ nodes: dict[int, FemNode] = {}
507
+ for record in records:
508
+ if record.name != "GCOORD":
509
+ continue
510
+ values = record.numeric_fields
511
+ if len(values) < 4:
512
+ diagnostics.append(_diag("FEM101", "GCOORD record must contain node id and coordinates", record))
513
+ continue
514
+ node_id = _int_field(values[0], "node id", record, diagnostics)
515
+ if node_id is None:
516
+ continue
517
+ if node_id in nodes:
518
+ diagnostics.append(_diag("FEM102", f"duplicate node id {node_id}", record))
519
+ continue
520
+ nodes[node_id] = FemNode(
521
+ node_id=node_id,
522
+ coordinate_system_id=0,
523
+ coordinates=(float(values[1]), float(values[2]), float(values[3])),
524
+ raw_values=values,
525
+ )
526
+
527
+ for record in records:
528
+ if record.name != "GNODE":
529
+ continue
530
+ values = record.numeric_fields
531
+ if len(values) < 4:
532
+ diagnostics.append(_diag("FEM101", "GNODE record must contain id and coordinates", record))
533
+ continue
534
+ node_id = _int_field(values[0], "node id", record, diagnostics)
535
+ if node_id is None:
536
+ continue
537
+ if node_id in nodes:
538
+ continue
539
+ if len(values) >= 5:
540
+ coordinate_id = _int_field(values[1], "node coordinate system id", record, diagnostics)
541
+ coords = values[2:5]
542
+ else:
543
+ coordinate_id = 0
544
+ coords = values[1:4]
545
+ if coordinate_id is None:
546
+ continue
547
+ nodes[node_id] = FemNode(
548
+ node_id=node_id,
549
+ coordinate_system_id=coordinate_id,
550
+ coordinates=(float(coords[0]), float(coords[1]), float(coords[2])),
551
+ raw_values=values,
552
+ )
553
+ return nodes
554
+
555
+
556
+ def _parse_element_references(
557
+ records: tuple[FemRawRecord, ...],
558
+ diagnostics: list[FemDiagnostic],
559
+ known_transform_ids: set[int],
560
+ ) -> dict[int, FemElementReference]:
561
+ references: dict[int, FemElementReference] = {}
562
+ for record in records:
563
+ if record.name != "GELREF1":
564
+ continue
565
+ values = record.numeric_fields
566
+ if not values:
567
+ diagnostics.append(_diag("FEM101", "GELREF1 record has no element id", record))
568
+ continue
569
+ element_id = _int_field(values[0], "element id", record, diagnostics)
570
+ if element_id is None:
571
+ continue
572
+ material_id = _optional_int(values[1], "material id", record, diagnostics) if len(values) > 1 else None
573
+ section_id = None
574
+ for index in (8, 2):
575
+ if len(values) > index and values[index] > 0.0:
576
+ section_id = _optional_int(values[index], "section id", record, diagnostics)
577
+ break
578
+ transform_id = (
579
+ _parse_transform_reference(values[11], record, diagnostics, known_transform_ids)
580
+ if len(values) > 11
581
+ else None
582
+ )
583
+ nodal_transform_ids = _parse_nodal_transform_references(values, record, diagnostics, known_transform_ids)
584
+ references[element_id] = FemElementReference(
585
+ element_id,
586
+ material_id,
587
+ section_id,
588
+ transform_id,
589
+ nodal_transform_ids,
590
+ values,
591
+ )
592
+ return references
593
+
594
+
595
+ def _parse_transform_reference(
596
+ value: float,
597
+ record: FemRawRecord,
598
+ diagnostics: list[FemDiagnostic],
599
+ known_transform_ids: set[int],
600
+ ) -> Optional[int]:
601
+ reference = _optional_int(value, "transform id", record, diagnostics)
602
+ if reference is None or reference <= 0:
603
+ return None
604
+ return reference if reference in known_transform_ids else None
605
+
606
+
607
+ def _parse_nodal_transform_references(
608
+ values: tuple[float, ...],
609
+ record: FemRawRecord,
610
+ diagnostics: list[FemDiagnostic],
611
+ known_transform_ids: set[int],
612
+ ) -> tuple[int, ...]:
613
+ if len(values) <= 12 or len(values) <= 11 or values[11] >= 0.0:
614
+ return ()
615
+ tail_count = len(values) - 12
616
+ if tail_count <= 0 or tail_count % 4 != 0:
617
+ return ()
618
+ node_count = tail_count // 4
619
+ transform_values = values[12 + 3 * node_count:12 + 4 * node_count]
620
+ transform_ids: list[int] = []
621
+ for value in transform_values:
622
+ reference = _parse_transform_reference(value, record, diagnostics, known_transform_ids)
623
+ if reference is not None:
624
+ transform_ids.append(reference)
625
+ return tuple(transform_ids)
626
+
627
+ def _parse_elements(
628
+ records: tuple[FemRawRecord, ...],
629
+ references: Mapping[int, FemElementReference],
630
+ diagnostics: list[FemDiagnostic],
631
+ ) -> dict[int, FemElement]:
632
+ elements: dict[int, FemElement] = {}
633
+ for record in records:
634
+ if record.name != "GELMNT1":
635
+ continue
636
+ values = record.numeric_fields
637
+ if len(values) < 3:
638
+ diagnostics.append(_diag("FEM101", "GELMNT1 record is too short", record))
639
+ continue
640
+ element_id = _int_field(values[0], "element id", record, diagnostics)
641
+ if element_id is None:
642
+ continue
643
+ if element_id in elements:
644
+ diagnostics.append(_diag("FEM102", f"duplicate element id {element_id}", record))
645
+ continue
646
+ layout = _element_layout(record, diagnostics)
647
+ if layout is None:
648
+ continue
649
+ type_code, node_start = layout
650
+ spec = get_element_spec(type_code)
651
+ if spec is None:
652
+ diagnostics.append(_diag("FEM103", f"unsupported SESAM element type {type_code}", record))
653
+ continue
654
+ if len(values) < node_start + spec.node_count:
655
+ diagnostics.append(
656
+ _diag(
657
+ "FEM104",
658
+ f"element {element_id} type {type_code} expects {spec.node_count} nodes",
659
+ record,
660
+ )
661
+ )
662
+ continue
663
+ node_ids: list[int] = []
664
+ for value in values[node_start:node_start + spec.node_count]:
665
+ node_id = _int_field(value, "element node id", record, diagnostics)
666
+ if node_id is not None:
667
+ node_ids.append(node_id)
668
+ if len(node_ids) != spec.node_count:
669
+ continue
670
+ internal_id = _int_field(values[1], "element internal id", record, diagnostics) if len(values) > 1 else None
671
+ if spec.is_shell and internal_id is not None:
672
+ reference = references.get(internal_id) or references.get(element_id)
673
+ elif spec.is_beam and internal_id is not None:
674
+ reference = references.get(element_id) or references.get(internal_id)
675
+ else:
676
+ reference = references.get(element_id)
677
+ elements[element_id] = FemElement(
678
+ element_id=element_id,
679
+ type_code=type_code,
680
+ topology=spec.topology,
681
+ node_ids=tuple(node_ids),
682
+ material_id=reference.material_id if reference else None,
683
+ section_id=reference.section_id if reference else None,
684
+ raw_values=values,
685
+ )
686
+ return elements
687
+
688
+
689
+ def _parse_boundaries(
690
+ records: tuple[FemRawRecord, ...],
691
+ diagnostics: list[FemDiagnostic],
692
+ ) -> tuple[FemBoundary, ...]:
693
+ boundaries: list[FemBoundary] = []
694
+ for record in records:
695
+ if record.name != "BNBCD":
696
+ continue
697
+ values = record.numeric_fields
698
+ if len(values) < 2:
699
+ diagnostics.append(_diag("FEM101", "BNBCD record is too short", record))
700
+ continue
701
+ node_id = _int_field(values[0], "boundary node id", record, diagnostics)
702
+ if node_id is None:
703
+ continue
704
+ flags = []
705
+ start_index = 2 if len(values) >= 8 else 1
706
+ for value in values[start_index:start_index + 6]:
707
+ flag = _optional_int(value, "boundary dof flag", record, diagnostics)
708
+ if flag is not None:
709
+ flags.append(flag)
710
+ boundaries.append(
711
+ FemBoundary(
712
+ node_id=node_id,
713
+ dof_flags=tuple(flags),
714
+ prescribed_values=tuple(values[start_index + 6:start_index + 12]),
715
+ raw_values=values,
716
+ )
717
+ )
718
+ return tuple(boundaries)
719
+
720
+
721
+ def _parse_concepts(records: tuple[FemRawRecord, ...], diagnostics: list[FemDiagnostic]) -> tuple[FemConceptRecord, ...]:
722
+ concepts: list[FemConceptRecord] = []
723
+ for record in records:
724
+ if record.name not in {"TDSCONC", "SCONCEPT", "SCONMESH"}:
725
+ continue
726
+ concept_id = None
727
+ if record.numeric_fields:
728
+ concept_id = _optional_int(record.numeric_fields[0], "concept id", record, diagnostics)
729
+ concepts.append(FemConceptRecord(record.name, concept_id, record.text_fields, record.numeric_fields))
730
+ return tuple(concepts)
731
+
732
+
733
+ def _parse_dependencies(records: tuple[FemRawRecord, ...]) -> tuple[FemDependency, ...]:
734
+ return tuple(
735
+ FemDependency(record.name, record.numeric_fields, record.text_fields)
736
+ for record in records
737
+ if record.name == "BLDEP"
738
+ )
739
+
740
+
741
+ def _parse_loads(records: tuple[FemRawRecord, ...], diagnostics: list[FemDiagnostic]) -> tuple[FemLoadRecord, ...]:
742
+ loads: list[FemLoadRecord] = []
743
+ for record in records:
744
+ if record.name not in {"TDLOAD", "BEUSLO", "BNLOAD", "BNACCLO", "BGRAV"}:
745
+ continue
746
+ load_case_id = None
747
+ target_id = None
748
+ if record.numeric_fields:
749
+ load_case_id = _optional_int(record.numeric_fields[0], "load case id", record, diagnostics)
750
+ if len(record.numeric_fields) > 1:
751
+ target_id = _optional_int(record.numeric_fields[1], "load target id", record, diagnostics)
752
+ loads.append(FemLoadRecord(record.name, load_case_id, target_id, record.numeric_fields, record.text_fields))
753
+ return tuple(loads)
754
+
755
+
756
+ def _element_layout(
757
+ record: FemRawRecord,
758
+ diagnostics: list[FemDiagnostic],
759
+ ) -> Optional[tuple[int, int]]:
760
+ values = record.numeric_fields
761
+ candidate_indices = (2, 1)
762
+ for index in candidate_indices:
763
+ if len(values) <= index:
764
+ continue
765
+ try:
766
+ type_code = strict_int(values[index], field_name="element type", record=record)
767
+ except SesamFemError as exc:
768
+ diagnostics.extend(exc.diagnostics)
769
+ continue
770
+ if get_element_spec(type_code) is not None:
771
+ return type_code, 4 if index == 2 else 2
772
+ diagnostics.append(_diag("FEM103", "GELMNT1 record does not contain a supported element type", record))
773
+ return None
774
+
775
+
776
+ def _first_in_range(values: tuple[float, ...], lower: float, upper: float) -> Optional[float]:
777
+ for value in values:
778
+ if lower < abs(float(value)) < upper:
779
+ return float(value)
780
+ return None
781
+
782
+
783
+ def _int_field(
784
+ value: float,
785
+ field_name: str,
786
+ record: FemRawRecord,
787
+ diagnostics: list[FemDiagnostic],
788
+ ) -> Optional[int]:
789
+ try:
790
+ return strict_int(value, field_name=field_name, record=record)
791
+ except SesamFemError as exc:
792
+ diagnostics.extend(exc.diagnostics)
793
+ return None
794
+
795
+
796
+ def _optional_int(
797
+ value: float,
798
+ field_name: str,
799
+ record: FemRawRecord,
800
+ diagnostics: list[FemDiagnostic],
801
+ ) -> Optional[int]:
802
+ if abs(float(value)) < 1.0e-12:
803
+ return 0
804
+ return _int_field(value, field_name, record, diagnostics)
805
+
806
+
807
+ def _diag(code: str, message: str, record: FemRawRecord) -> FemDiagnostic:
808
+ return FemDiagnostic(
809
+ code,
810
+ message,
811
+ record_name=record.name,
812
+ line_start=record.source_line_start,
813
+ line_end=record.source_line_end,
814
+ )