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.
- anysolver/__init__.py +631 -0
- anysolver/anystructure_fem_mode.py +942 -0
- anysolver/arc_length.py +758 -0
- anysolver/assembly.py +950 -0
- anysolver/baselines.py +303 -0
- anysolver/beam_shell_verification.py +4276 -0
- anysolver/beam_validity.py +110 -0
- anysolver/benchmarks.py +495 -0
- anysolver/boundary.py +476 -0
- anysolver/buckling.py +442 -0
- anysolver/buckling_validity.py +91 -0
- anysolver/capacity_workflow.py +350 -0
- anysolver/cases.py +173 -0
- anysolver/composite_strip_verification.py +297 -0
- anysolver/contact.py +3461 -0
- anysolver/corotational.py +371 -0
- anysolver/cylinder_benchmarks.py +364 -0
- anysolver/dynamics.py +723 -0
- anysolver/element_qualification.py +432 -0
- anysolver/elements.py +2724 -0
- anysolver/external_references.py +369 -0
- anysolver/fe_core.py +327 -0
- anysolver/fracture.py +551 -0
- anysolver/imperfections.py +390 -0
- anysolver/jit_compiler.py +108 -0
- anysolver/kernel_warmup.py +180 -0
- anysolver/linalg.py +760 -0
- anysolver/mass_properties.py +179 -0
- anysolver/material_curves.py +231 -0
- anysolver/matrix_assembly.py +558 -0
- anysolver/mesh_gen.py +1065 -0
- anysolver/mesh_load_bc_verification.py +609 -0
- anysolver/modal.py +282 -0
- anysolver/nonlinear.py +300 -0
- anysolver/nonlinear_performance.py +920 -0
- anysolver/nonlinear_performance_batch_b.py +592 -0
- anysolver/nonlinear_performance_batch_c.py +506 -0
- anysolver/nonlinear_performance_bootstrap.py +120 -0
- anysolver/nonlinear_reduced_assembly.py +760 -0
- anysolver/nonlinear_static.py +1518 -0
- anysolver/plasticity.py +419 -0
- anysolver/plasticity_qualification.py +314 -0
- anysolver/production_readiness.py +304 -0
- anysolver/quality_control.py +1497 -0
- anysolver/recovery.py +505 -0
- anysolver/recovery_policy.py +205 -0
- anysolver/reference_cases.py +425 -0
- anysolver/results.py +503 -0
- anysolver/runtime.py +9030 -0
- anysolver/s4_validity.py +326 -0
- anysolver/sesam_fem/__init__.py +55 -0
- anysolver/sesam_fem/__main__.py +80 -0
- anysolver/sesam_fem/diagnostics.py +65 -0
- anysolver/sesam_fem/document.py +814 -0
- anysolver/sesam_fem/exporter.py +84 -0
- anysolver/sesam_fem/importer.py +365 -0
- anysolver/sesam_fem/records.py +257 -0
- anysolver/sesam_fem/schema.py +107 -0
- anysolver/sesam_fem/sif_importer.py +397 -0
- anysolver/sesam_fem/validation.py +62 -0
- anysolver/shell_benchmarks.py +367 -0
- anysolver/test_cases.py +610 -0
- anysolver/validation.py +416 -0
- anysolver/vectorized_nonlinear.py +334 -0
- anysolver/vectorized_stiffness.py +571 -0
- anysolver-0.1.0.dist-info/METADATA +165 -0
- anysolver-0.1.0.dist-info/RECORD +70 -0
- anysolver-0.1.0.dist-info/WHEEL +5 -0
- anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
- 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()
|