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,369 @@
1
+ """Generated external FE reference-case decks.
2
+
3
+ The helpers in this module create deterministic CalculiX/Abaqus-style input
4
+ decks from small FEModel cases. They are intended as reproducible handoff
5
+ artifacts for external solver comparison; they do not require CalculiX to be
6
+ installed and they do not claim numerical agreement by themselves.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
15
+
16
+ import numpy as np
17
+
18
+ from .boundary import BoundaryCondition, LoadCase
19
+ from .elements import BeamElement, ShellElement
20
+ from .fe_core import FEModel, Material
21
+ from .mesh_gen import generate_simple_panel_mesh
22
+
23
+
24
+ DEFAULT_EXTERNAL_REFERENCE_PATH = Path("reports/external_references/external_reference_report.json")
25
+ _DOF_TO_CALCULIX = {"ux": 1, "uy": 2, "uz": 3, "rx": 4, "ry": 5, "rz": 6}
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ExternalReferenceCase:
30
+ """One generated external-reference input deck."""
31
+
32
+ name: str
33
+ kind: str
34
+ inp_path: Path
35
+ metadata_path: Path
36
+ model_summary: Mapping[str, Any]
37
+ load_summary: Mapping[str, Any]
38
+ assumptions: Tuple[str, ...] = ()
39
+
40
+ def to_dict(self) -> Dict[str, Any]:
41
+ return {
42
+ "name": self.name,
43
+ "kind": self.kind,
44
+ "inp_path": str(self.inp_path),
45
+ "metadata_path": str(self.metadata_path),
46
+ "model_summary": dict(self.model_summary),
47
+ "load_summary": dict(self.load_summary),
48
+ "assumptions": list(self.assumptions),
49
+ }
50
+
51
+
52
+ def _fmt(value: float) -> str:
53
+ return f"{float(value):.16g}"
54
+
55
+
56
+ def _material_block(materials: Mapping[str, Material]) -> List[str]:
57
+ lines: List[str] = []
58
+ for material in materials.values():
59
+ lines.extend(
60
+ [
61
+ f"*MATERIAL, NAME={material.name}",
62
+ "*ELASTIC",
63
+ f"{_fmt(material.elastic_modulus)}, {_fmt(material.poisson_ratio)}",
64
+ ]
65
+ )
66
+ if material.density:
67
+ lines.extend(["*DENSITY", _fmt(material.density)])
68
+ return lines
69
+
70
+
71
+ def _element_type(element: Any) -> str:
72
+ if isinstance(element, ShellElement):
73
+ return "S8" if len(element.node_ids) == 8 else "S4"
74
+ if isinstance(element, BeamElement):
75
+ return "B31"
76
+ return "UNKNOWN"
77
+
78
+
79
+ def _element_sets_by_type_and_material(model: FEModel) -> Dict[Tuple[str, str], List[int]]:
80
+ groups: Dict[Tuple[str, str], List[int]] = {}
81
+ for element_id, element in model.mesh.elements.items():
82
+ groups.setdefault((_element_type(element), element.material_name), []).append(int(element_id))
83
+ return groups
84
+
85
+
86
+ def _section_blocks(model: FEModel) -> Tuple[List[str], List[str]]:
87
+ lines: List[str] = []
88
+ assumptions: List[str] = []
89
+ for (element_type, material_name), element_ids in _element_sets_by_type_and_material(model).items():
90
+ elset = f"E_{element_type}_{material_name}"
91
+ ids = ", ".join(str(element_id) for element_id in element_ids)
92
+ lines.append(f"*ELSET, ELSET={elset}")
93
+ lines.append(ids)
94
+ first = model.mesh.elements[element_ids[0]]
95
+ if isinstance(first, ShellElement):
96
+ lines.extend([f"*SHELL SECTION, ELSET={elset}, MATERIAL={material_name}", _fmt(first.thickness)])
97
+ elif isinstance(first, BeamElement):
98
+ area = float(first.cross_section.get("area", 0.01))
99
+ side = np.sqrt(max(area, 1.0e-18))
100
+ lines.extend([f"*BEAM SECTION, ELSET={elset}, MATERIAL={material_name}, SECTION=RECT", f"{_fmt(side)}, {_fmt(side)}"])
101
+ assumptions.append(
102
+ f"Beam element set {elset} is exported as an equivalent square RECT section preserving area; "
103
+ "Iy/Iz/J exact matching is not represented in this v1 deck writer."
104
+ )
105
+ return lines, assumptions
106
+
107
+
108
+ def _boundary_block(model: FEModel) -> List[str]:
109
+ lines: List[str] = []
110
+ if not model.boundary_conditions:
111
+ return lines
112
+ lines.append("*BOUNDARY")
113
+ for bc in model.boundary_conditions:
114
+ for node_id in bc.node_ids:
115
+ for dof_name, value in bc.dof_constraints.items():
116
+ if abs(float(value)) > 0.0:
117
+ continue
118
+ dof = _DOF_TO_CALCULIX.get(dof_name)
119
+ if dof is not None:
120
+ lines.append(f"{int(node_id)}, {dof}, {dof}, 0.")
121
+ return lines
122
+
123
+
124
+ def _load_block(load_case: Optional[LoadCase]) -> Tuple[List[str], Dict[str, Any]]:
125
+ if load_case is None:
126
+ return [], {"name": None, "nodal_loads": 0, "pressure_loads": 0}
127
+ lines: List[str] = []
128
+ if load_case.nodal_loads:
129
+ lines.append("*CLOAD")
130
+ for node_id, values in sorted(load_case.nodal_loads.items()):
131
+ for idx, value in enumerate(np.asarray(values, dtype=float), start=1):
132
+ if abs(float(value)) > 0.0:
133
+ lines.append(f"{int(node_id)}, {idx}, {_fmt(value)}")
134
+ if load_case.pressure_loads:
135
+ lines.append("*DLOAD")
136
+ for element_id, pressure in sorted(load_case.pressure_loads.items()):
137
+ lines.append(f"{int(element_id)}, P, {_fmt(pressure)}")
138
+ if load_case.gravity is not None:
139
+ gx, gy, gz = np.asarray(load_case.gravity, dtype=float).reshape(3)
140
+ magnitude = float(np.linalg.norm([gx, gy, gz]))
141
+ if magnitude > 0.0:
142
+ direction = np.asarray([gx, gy, gz], dtype=float) / magnitude
143
+ lines.append("*DLOAD")
144
+ lines.append(
145
+ f"ALL, GRAV, {_fmt(magnitude)}, "
146
+ f"{_fmt(direction[0])}, {_fmt(direction[1])}, {_fmt(direction[2])}"
147
+ )
148
+ return lines, {
149
+ "name": load_case.name,
150
+ "nodal_loads": len(load_case.nodal_loads),
151
+ "pressure_loads": len(load_case.pressure_loads),
152
+ "has_gravity": load_case.gravity is not None,
153
+ }
154
+
155
+
156
+ def write_calculix_input_deck(
157
+ model: FEModel,
158
+ load_case: Optional[LoadCase],
159
+ output_path: Path | str,
160
+ *,
161
+ analysis: str = "static",
162
+ metadata: Optional[Mapping[str, Any]] = None,
163
+ ) -> ExternalReferenceCase:
164
+ """Write a deterministic CalculiX-style input deck and sidecar metadata."""
165
+
166
+ output = Path(output_path)
167
+ output.parent.mkdir(parents=True, exist_ok=True)
168
+ model.apply_boundary_conditions()
169
+ lines: List[str] = [
170
+ "** Generated by anysolver.external_references",
171
+ f"** Model: {model.name}",
172
+ "*NODE",
173
+ ]
174
+ for node_id, node in sorted(model.mesh.nodes.items()):
175
+ lines.append(f"{int(node_id)}, {_fmt(node.x)}, {_fmt(node.y)}, {_fmt(node.z)}")
176
+ for (element_type, _material_name), element_ids in sorted(_element_sets_by_type_and_material(model).items()):
177
+ if element_type == "UNKNOWN":
178
+ continue
179
+ lines.append(f"*ELEMENT, TYPE={element_type}")
180
+ for element_id in element_ids:
181
+ element = model.mesh.elements[element_id]
182
+ lines.append(f"{int(element_id)}, " + ", ".join(str(int(node_id)) for node_id in element.node_ids))
183
+ supported_element_ids = [
184
+ int(element_id)
185
+ for element_id, element in sorted(model.mesh.elements.items())
186
+ if _element_type(element) != "UNKNOWN"
187
+ ]
188
+ if supported_element_ids:
189
+ lines.append("*ELSET, ELSET=ALL")
190
+ for start in range(0, len(supported_element_ids), 16):
191
+ lines.append(", ".join(str(element_id) for element_id in supported_element_ids[start : start + 16]))
192
+ lines.extend(_material_block(model.materials))
193
+ section_lines, assumptions = _section_blocks(model)
194
+ lines.extend(section_lines)
195
+ lines.extend(_boundary_block(model))
196
+ load_lines, load_summary = _load_block(load_case)
197
+ if analysis == "buckling":
198
+ lines.extend(["*STEP", "*BUCKLE", "5"])
199
+ elif analysis == "frequency":
200
+ lines.extend(["*STEP", "*FREQUENCY", "5"])
201
+ else:
202
+ lines.extend(["*STEP", "*STATIC"])
203
+ lines.extend(load_lines)
204
+ lines.extend(["*NODE FILE", "U", "*EL FILE", "S", "*END STEP", ""])
205
+ output.write_text("\n".join(lines), encoding="utf-8")
206
+
207
+ model_summary = {
208
+ "name": model.name,
209
+ "nodes": len(model.mesh.nodes),
210
+ "elements": len(model.mesh.elements),
211
+ "materials": sorted(model.materials),
212
+ "analysis": analysis,
213
+ }
214
+ sidecar = output.with_suffix(".json")
215
+ payload = {
216
+ "name": output.stem,
217
+ "kind": analysis,
218
+ "model_summary": model_summary,
219
+ "load_summary": load_summary,
220
+ "assumptions": assumptions,
221
+ "metadata": dict(metadata or {}),
222
+ }
223
+ sidecar.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
224
+ return ExternalReferenceCase(
225
+ name=output.stem,
226
+ kind=analysis,
227
+ inp_path=output,
228
+ metadata_path=sidecar,
229
+ model_summary=model_summary,
230
+ load_summary=load_summary,
231
+ assumptions=tuple(assumptions),
232
+ )
233
+
234
+
235
+ def _pressure_plate_case(output_dir: Path) -> ExternalReferenceCase:
236
+ model = generate_simple_panel_mesh(2.0, 1.0, 0.01, num_divisions_x=2, num_divisions_y=1)
237
+ model.name = "external_pressure_plate_s4"
238
+ load_case = LoadCase("pressure")
239
+ for element_id in model.mesh.elements:
240
+ load_case.add_pressure_load(element_id, 1000.0)
241
+ return write_calculix_input_deck(
242
+ model,
243
+ load_case,
244
+ output_dir / "pressure_plate_s4.inp",
245
+ analysis="static",
246
+ metadata={"purpose": "S4 pressure plate external reference deck"},
247
+ )
248
+
249
+
250
+ def _beam_buckling_case(output_dir: Path) -> ExternalReferenceCase:
251
+ model = FEModel("external_beam_column_buckling")
252
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
253
+ section = {"area": 0.02, "Iy": 3.0e-6, "Iz": 5.0e-6, "J": 2.0e-6}
254
+ for i in range(5):
255
+ model.add_node(i + 1, float(i), 0.0, 0.0)
256
+ for i in range(4):
257
+ model.add_element(i + 1, BeamElement(i + 1, [i + 1, i + 2], "steel", section))
258
+ all_nodes = list(model.mesh.nodes)
259
+ model.add_boundary_condition(BoundaryCondition("suppress", all_nodes, {"ux": 0.0, "uz": 0.0, "rx": 0.0, "ry": 0.0}))
260
+ model.add_boundary_condition(BoundaryCondition("pins", [1, 5], {"uy": 0.0}))
261
+ load_case = LoadCase("unit_compression")
262
+ load_case.add_nodal_load(5, [-1.0, 0.0, 0.0, 0.0, 0.0, 0.0])
263
+ return write_calculix_input_deck(
264
+ model,
265
+ load_case,
266
+ output_dir / "beam_column_buckling.inp",
267
+ analysis="buckling",
268
+ metadata={"purpose": "Beam-column buckling external reference deck"},
269
+ )
270
+
271
+
272
+ def _cylindrical_shell_case(output_dir: Path) -> ExternalReferenceCase:
273
+ model = FEModel("external_cylinder_s4_pressure")
274
+ model.add_material("steel", 210.0e9, 0.3, density=7850.0)
275
+ radius = 1.0
276
+ height = 1.0
277
+ n_circ = 8
278
+ n_z = 2
279
+ node_id = 1
280
+ grid: Dict[Tuple[int, int], int] = {}
281
+ for iz in range(n_z + 1):
282
+ z = height * iz / n_z
283
+ for it in range(n_circ):
284
+ theta = 2.0 * np.pi * it / n_circ
285
+ grid[(iz, it)] = node_id
286
+ model.add_node(node_id, radius * np.cos(theta), radius * np.sin(theta), z)
287
+ node_id += 1
288
+ elem_id = 1
289
+ for iz in range(n_z):
290
+ for it in range(n_circ):
291
+ n1 = grid[(iz, it)]
292
+ n2 = grid[(iz, (it + 1) % n_circ)]
293
+ n3 = grid[(iz + 1, (it + 1) % n_circ)]
294
+ n4 = grid[(iz + 1, it)]
295
+ model.add_element(elem_id, ShellElement(elem_id, [n1, n2, n3, n4], "steel", thickness=0.02))
296
+ elem_id += 1
297
+ bottom = [grid[(0, it)] for it in range(n_circ)]
298
+ top = [grid[(n_z, it)] for it in range(n_circ)]
299
+ model.add_boundary_condition(BoundaryCondition("bottom_uz", bottom, {"uz": 0.0}))
300
+ model.add_boundary_condition(BoundaryCondition("top_uz", top, {"uz": 0.0}))
301
+ model.add_boundary_condition(BoundaryCondition("reference_xy", [bottom[0]], {"ux": 0.0, "uy": 0.0}))
302
+ load_case = LoadCase("internal_pressure")
303
+ for element_id in model.mesh.elements:
304
+ load_case.add_pressure_load(element_id, 1000.0)
305
+ return write_calculix_input_deck(
306
+ model,
307
+ load_case,
308
+ output_dir / "cylinder_s4_pressure.inp",
309
+ analysis="static",
310
+ metadata={"purpose": "Cylindrical shell pressure external reference deck"},
311
+ )
312
+
313
+
314
+ def generate_external_reference_cases(output_dir: Path | str = Path("reports/external_references/decks")) -> List[ExternalReferenceCase]:
315
+ """Generate the default external reference decks."""
316
+
317
+ root = Path(output_dir)
318
+ root.mkdir(parents=True, exist_ok=True)
319
+ return [_pressure_plate_case(root), _beam_buckling_case(root), _cylindrical_shell_case(root)]
320
+
321
+
322
+ def generate_external_reference_report(output_dir: Path | str = Path("reports/external_references/decks")) -> Dict[str, Any]:
323
+ cases = generate_external_reference_cases(output_dir)
324
+ return {
325
+ "status": "passed" if cases else "failed",
326
+ "schema_version": 1,
327
+ "cases": [case.to_dict() for case in cases],
328
+ "known_limitations": [
329
+ "Deck generation is a reproducible handoff for external validation; this report does not execute CalculiX.",
330
+ "Beam sections are exported with a simple RECT approximation in v1.",
331
+ "FRD/result parsing and pass/fail numerical comparison are a later batch after external solver execution is available.",
332
+ ],
333
+ }
334
+
335
+
336
+ def _markdown(report: Mapping[str, Any]) -> str:
337
+ lines = ["# External FE Reference Deck Report", "", f"- Status: {report.get('status')}", ""]
338
+ lines.extend(["## Cases", ""])
339
+ for case in report.get("cases", []):
340
+ summary = case.get("model_summary", {})
341
+ lines.append(f"### {case.get('name')}")
342
+ lines.append(f"- Kind: {case.get('kind')}")
343
+ lines.append(f"- Input: `{case.get('inp_path')}`")
344
+ lines.append(f"- Nodes: {summary.get('nodes')}")
345
+ lines.append(f"- Elements: {summary.get('elements')}")
346
+ lines.append("")
347
+ lines.extend(["## Known Limitations", ""])
348
+ for item in report.get("known_limitations", []):
349
+ lines.append(f"- {item}")
350
+ return "\n".join(lines).rstrip() + "\n"
351
+
352
+
353
+ def write_external_reference_report(
354
+ output: Path | str = DEFAULT_EXTERNAL_REFERENCE_PATH,
355
+ *,
356
+ deck_dir: Path | str = Path("reports/external_references/decks"),
357
+ markdown: Optional[Path | str] = None,
358
+ ) -> Dict[str, Any]:
359
+ """Generate decks and write a JSON/Markdown report."""
360
+
361
+ report = generate_external_reference_report(deck_dir)
362
+ output_path = Path(output)
363
+ output_path.parent.mkdir(parents=True, exist_ok=True)
364
+ output_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
365
+ if markdown is not None:
366
+ markdown_path = Path(markdown)
367
+ markdown_path.parent.mkdir(parents=True, exist_ok=True)
368
+ markdown_path.write_text(_markdown(report), encoding="utf-8")
369
+ return report
anysolver/fe_core.py ADDED
@@ -0,0 +1,327 @@
1
+ """
2
+ Core Finite Element Classes
3
+
4
+ This module contains the fundamental classes for FE analysis:
5
+ - DOFManager: Manages degrees of freedom and numbering
6
+ - FEMesh: Stores nodes, elements, and connectivity
7
+ - FEModel: Complete FE model with materials, loads, and results
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING, Any, List, Dict, Tuple, Optional, Union
13
+ import numpy as np
14
+
15
+ if TYPE_CHECKING:
16
+ from .elements import Element
17
+ from .boundary import BoundaryCondition, LoadCase
18
+
19
+
20
+ class DOFManager:
21
+ """
22
+ Manages degrees of freedom for the FE model.
23
+
24
+ Each node has 6 DOFs: [ux, uy, uz, rx, ry, rz]
25
+ (displacements in x,y,z and rotations about x,y,z)
26
+ """
27
+
28
+ DOF_NAMES = ['ux', 'uy', 'uz', 'rx', 'ry', 'rz']
29
+ DOF_PER_NODE = 6
30
+
31
+ def __init__(self):
32
+ self._node_to_dof: Dict[int, List[int]] = {}
33
+ self._dof_to_node: Dict[int, int] = {}
34
+ self._dof_to_local: Dict[int, int] = {}
35
+ self._total_dofs = 0
36
+ self._constrained_dofs: set = set()
37
+
38
+ def add_node(self, node_id: int) -> List[int]:
39
+ """Add a new node and return its DOF indices."""
40
+ dofs = list(range(self._total_dofs, self._total_dofs + self.DOF_PER_NODE))
41
+ self._node_to_dof[node_id] = dofs
42
+ for i, dof in enumerate(dofs):
43
+ self._dof_to_node[dof] = node_id
44
+ self._dof_to_local[dof] = i
45
+ self._total_dofs += self.DOF_PER_NODE
46
+ return dofs
47
+
48
+ def get_node_dofs(self, node_id: int) -> List[int]:
49
+ """Get all DOF indices for a node."""
50
+ return self._node_to_dof.get(node_id, [])
51
+
52
+ def get_dof_info(self, dof: int) -> Tuple[int, int, str]:
53
+ """Get node ID, local DOF index, and name for a global DOF."""
54
+ node_id = self._dof_to_node.get(dof, -1)
55
+ local_idx = self._dof_to_local.get(dof, -1)
56
+ name = self.DOF_NAMES[local_idx] if 0 <= local_idx < 6 else "unknown"
57
+ return node_id, local_idx, name
58
+
59
+ @property
60
+ def total_dofs(self) -> int:
61
+ return self._total_dofs
62
+
63
+ @property
64
+ def active_dofs(self) -> int:
65
+ return self._total_dofs - len(self._constrained_dofs)
66
+
67
+ def constrain_dof(self, dof: int):
68
+ """Mark a DOF as constrained."""
69
+ self._constrained_dofs.add(dof)
70
+
71
+ def is_constrained(self, dof: int) -> bool:
72
+ return dof in self._constrained_dofs
73
+
74
+ def get_free_dofs(self) -> List[int]:
75
+ """Get list of free (unconstrained) DOFs."""
76
+ return [dof for dof in range(self._total_dofs) if dof not in self._constrained_dofs]
77
+
78
+ def create_dof_mask(self) -> Tuple[np.ndarray, np.ndarray]:
79
+ """Create arrays for constrained and free DOFs."""
80
+ free_dofs = np.array(self.get_free_dofs(), dtype=int)
81
+ constrained_dofs = np.array(list(self._constrained_dofs), dtype=int)
82
+ return free_dofs, constrained_dofs
83
+
84
+
85
+ @dataclass
86
+ class Node:
87
+ """A node in the FE mesh."""
88
+ id: int
89
+ x: float
90
+ y: float
91
+ z: float
92
+ dofs: List[int] = field(default_factory=list)
93
+
94
+ def coords(self) -> np.ndarray:
95
+ """Return node coordinates as numpy array."""
96
+ return np.array([self.x, self.y, self.z])
97
+
98
+
99
+ @dataclass
100
+ class Material:
101
+ """Material properties for FE elements.
102
+
103
+ ``hardening_curve`` (e.g. a DNVC208MaterialCurve) enables material
104
+ nonlinearity in the incremental nonlinear solver; None keeps the
105
+ material linear elastic.
106
+ """
107
+ name: str
108
+ elastic_modulus: float # Pa
109
+ poisson_ratio: float
110
+ density: float = 0.0 # kg/m^3
111
+ yield_stress: float = 0.0 # Pa
112
+ hardening_curve: Optional[object] = None
113
+
114
+ @property
115
+ def shear_modulus(self) -> float:
116
+ """Calculate shear modulus."""
117
+ return self.elastic_modulus / (2 * (1 + self.poisson_ratio))
118
+
119
+
120
+ @dataclass
121
+ class FEMesh:
122
+ """
123
+ Finite Element Mesh
124
+
125
+ Stores nodes, elements, and connectivity for the FE model.
126
+ """
127
+ nodes: Dict[int, Node] = field(default_factory=dict)
128
+ elements: Dict[int, 'Element'] = field(default_factory=dict)
129
+ dof_manager: DOFManager = field(default_factory=DOFManager)
130
+ point_masses: Dict[int, float] = field(default_factory=dict)
131
+ revisions: Dict[str, int] = field(default_factory=lambda: {
132
+ "topology": 0,
133
+ "geometry": 0,
134
+ "material": 0,
135
+ "mass": 0,
136
+ "load": 0,
137
+ "boundary": 0,
138
+ "mpc": 0,
139
+ "result_state": 0,
140
+ })
141
+
142
+ def bump_revision(self, category: str) -> None:
143
+ """Increment a mesh/model revision category and clear stale caches."""
144
+ self.revisions[category] = int(self.revisions.get(category, 0)) + 1
145
+ # Element-local matrices depend on an element's geometry and material,
146
+ # not on unrelated elements or MPC topology. Scanning every existing
147
+ # element for every add_element() made model construction O(E**2).
148
+ if category in {"geometry", "material"}:
149
+ for element in self.elements.values():
150
+ for name in (
151
+ "_stiffness_matrix",
152
+ "_mass_matrix",
153
+ "_internal_forces",
154
+ "_nl_cache",
155
+ "_hourglass_stiffness_matrix",
156
+ ):
157
+ if hasattr(element, name):
158
+ setattr(element, name, None)
159
+ if category in {"topology", "mpc"} and hasattr(self, "_sparsity_cache"):
160
+ self._sparsity_cache = {}
161
+ if category in {"topology", "mpc"} and hasattr(self, "_topology_signature_cache"):
162
+ self._topology_signature_cache = {}
163
+
164
+ def revision_signature(self) -> Dict[str, int]:
165
+ return {key: int(value) for key, value in sorted(self.revisions.items())}
166
+
167
+ def add_node(self, node_id: int, x: float, y: float, z: float) -> Node:
168
+ """Add a node to the mesh."""
169
+ node = Node(id=node_id, x=x, y=y, z=z)
170
+ node.dofs = self.dof_manager.add_node(node_id)
171
+ self.nodes[node_id] = node
172
+ self.bump_revision("topology")
173
+ self.bump_revision("geometry")
174
+ return node
175
+
176
+ def add_element(self, element_id: int, element: 'Element'):
177
+ """Add an element to the mesh."""
178
+ self.elements[element_id] = element
179
+ self.bump_revision("topology")
180
+ self.bump_revision("mpc")
181
+
182
+ def set_node_coordinates(self, node_id: int, x: float, y: float, z: float) -> None:
183
+ """Update node coordinates and invalidate geometry-dependent caches."""
184
+ node = self.get_node(node_id)
185
+ if node is None:
186
+ raise ValueError(f"Node {node_id} not found")
187
+ node.x = float(x)
188
+ node.y = float(y)
189
+ node.z = float(z)
190
+ self.bump_revision("geometry")
191
+
192
+ def get_node(self, node_id: int) -> Optional[Node]:
193
+ return self.nodes.get(node_id)
194
+
195
+ def get_element(self, element_id: int) -> Optional['Element']:
196
+ return self.elements.get(element_id)
197
+
198
+ @property
199
+ def num_nodes(self) -> int:
200
+ return len(self.nodes)
201
+
202
+ @property
203
+ def num_elements(self) -> int:
204
+ return len(self.elements)
205
+
206
+ def get_connectivity(self) -> Dict[int, List[int]]:
207
+ """Get element connectivity (element_id -> node_ids)."""
208
+ return {eid: elem.node_ids for eid, elem in self.elements.items()}
209
+
210
+ def get_node_coordinates(self) -> np.ndarray:
211
+ """Get coordinates of all nodes as array (n_nodes, 3)."""
212
+ coords = np.zeros((self.num_nodes, 3))
213
+ for i, (_node_id, node) in enumerate(self.nodes.items()):
214
+ coords[i] = node.coords()
215
+ return coords
216
+
217
+
218
+ @dataclass
219
+ class FEModel:
220
+ """
221
+ Complete Finite Element Model
222
+
223
+ Contains mesh, materials, boundary conditions, loads, and results.
224
+ """
225
+ name: str
226
+ mesh: FEMesh = field(default_factory=FEMesh)
227
+ materials: Dict[str, Material] = field(default_factory=dict)
228
+ boundary_conditions: List['BoundaryCondition'] = field(default_factory=list)
229
+ load_cases: List['LoadCase'] = field(default_factory=list)
230
+ current_material: str = "default"
231
+
232
+ def __post_init__(self):
233
+ if "default" not in self.materials:
234
+ self.materials["default"] = Material(
235
+ name="default",
236
+ elastic_modulus=210e9, # Steel
237
+ poisson_ratio=0.3
238
+ )
239
+
240
+ def add_material(self, name: str, elastic_modulus: float, poisson_ratio: float,
241
+ density: float = 0.0, yield_stress: float = 0.0,
242
+ hardening_curve: Optional[object] = None) -> Material:
243
+ """Add a material to the model."""
244
+ mat = Material(
245
+ name=name,
246
+ elastic_modulus=elastic_modulus,
247
+ poisson_ratio=poisson_ratio,
248
+ density=density,
249
+ yield_stress=yield_stress,
250
+ hardening_curve=hardening_curve
251
+ )
252
+ self.materials[name] = mat
253
+ self.mesh.bump_revision("material")
254
+ return mat
255
+
256
+ def set_material(self, name: str):
257
+ """Set the current material for new elements."""
258
+ if name not in self.materials:
259
+ raise ValueError(f"Material '{name}' not found")
260
+ self.current_material = name
261
+
262
+ def get_material(self, name: str = None) -> Material:
263
+ """Get a material by name, or the current material."""
264
+ name = name or self.current_material
265
+ return self.materials.get(name, self.materials["default"])
266
+
267
+ def add_node(self, node_id: int, x: float, y: float, z: float) -> Node:
268
+ """Add a node to the model."""
269
+ return self.mesh.add_node(node_id, x, y, z)
270
+
271
+ def add_element(self, element_id: int, element: 'Element'):
272
+ """Add an element to the model."""
273
+ self.mesh.add_element(element_id, element)
274
+
275
+ def add_point_mass(self, node_id: int, mass: float):
276
+ """Attach a lumped translational point mass to a node.
277
+
278
+ The mass enters the global mass matrix (so it shifts natural
279
+ frequencies and participates in transient/collision dynamics) and, when
280
+ an acceleration/gravity field is applied, produces the corresponding
281
+ inertial load.
282
+ """
283
+ node_id = int(node_id)
284
+ if self.mesh.get_node(node_id) is None:
285
+ raise ValueError(f"Cannot attach point mass to missing node {node_id}")
286
+ mass = float(mass)
287
+ if not np.isfinite(mass) or mass < 0.0:
288
+ raise ValueError("Point mass must be finite and non-negative")
289
+ if mass == 0.0:
290
+ return
291
+ self.mesh.point_masses[node_id] = self.mesh.point_masses.get(node_id, 0.0) + mass
292
+ self.mesh.bump_revision("mass")
293
+
294
+ def add_boundary_condition(self, bc: 'BoundaryCondition'):
295
+ """Add a boundary condition to the model."""
296
+ self.boundary_conditions.append(bc)
297
+ self.mesh.bump_revision("boundary")
298
+
299
+ def add_load_case(self, load_case: 'LoadCase'):
300
+ """Add a load case to the model."""
301
+ self.load_cases.append(load_case)
302
+ self.mesh.bump_revision("load")
303
+
304
+ def apply_boundary_conditions(self):
305
+ """Apply all boundary conditions to the mesh DOF manager."""
306
+ for bc in self.boundary_conditions:
307
+ bc.apply(self.mesh.dof_manager)
308
+
309
+ def clear_boundary_conditions(self):
310
+ """Clear all boundary conditions."""
311
+ self.boundary_conditions.clear()
312
+ self.mesh.dof_manager = DOFManager()
313
+ # Re-add nodes to reset DOFs
314
+ for node_id, node in self.mesh.nodes.items():
315
+ node.dofs = self.mesh.dof_manager.add_node(node_id)
316
+ self.mesh.bump_revision("boundary")
317
+ self.mesh.bump_revision("topology")
318
+
319
+ def set_node_coordinates(self, node_id: int, x: float, y: float, z: float) -> None:
320
+ """Update node coordinates and invalidate geometry-dependent caches."""
321
+ self.mesh.set_node_coordinates(node_id, x, y, z)
322
+
323
+ def bump_revision(self, category: str) -> None:
324
+ self.mesh.bump_revision(category)
325
+
326
+ def revision_signature(self) -> Dict[str, int]:
327
+ return self.mesh.revision_signature()