ANYmaterial 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.
@@ -0,0 +1,135 @@
1
+ """Structural material models for finite-element analysis.
2
+
3
+ The package owns material behaviour and nothing else: elastic symmetry and
4
+ compliance, the reductions a shell or beam formulation needs, nonlinear
5
+ hardening curves, directional yield criteria, and a serializable
6
+ specification. Meshes, sections and solvers live elsewhere.
7
+
8
+ Elastic compliance uses engineering Voigt order ``[11, 22, 33, 23, 13, 12]``:
9
+
10
+ ``[eps11, eps22, eps33, gamma23, gamma13, gamma12] = S @
11
+ [sig11, sig22, sig33, tau23, tau13, tau12]``
12
+
13
+ This ordering is the family-wide convention. It is stated here because this
14
+ package defines it, and every consumer is expected to match it rather than
15
+ transposing at the boundary.
16
+
17
+ The package deliberately does not import ANYsolver. Materials are described
18
+ here and consumed there, never the other way round, so the dependency stays
19
+ acyclic and a material can be validated without a solver present.
20
+
21
+ ``anymaterial.gui`` is not imported here, so importing the package never
22
+ requires a display or a tkinter build.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from .contract import (
28
+ ENGINEERING_VOIGT_ORDER,
29
+ SUPPORTED_ELASTIC_SYMMETRIES,
30
+ StructuralMaterial,
31
+ elastic_compliance_matrix,
32
+ is_isotropic_material,
33
+ is_orthotropic_material,
34
+ material_symmetry,
35
+ )
36
+ from .curves import (
37
+ DNVC208MaterialCurve,
38
+ HardeningCurve,
39
+ LinearHardeningCurve,
40
+ PiecewiseLinearCurve,
41
+ PowerLawHardeningCurve,
42
+ curve_from_properties,
43
+ )
44
+ from .isotropic import IsotropicMaterial
45
+ from .library import (
46
+ STEEL_DENSITY,
47
+ STEEL_POISSON_RATIO,
48
+ LibraryEntry,
49
+ MaterialLibrary,
50
+ add_to_user_library,
51
+ available_grades,
52
+ builtin_library,
53
+ dnv_c208_steel_curve,
54
+ dnv_c208_steel_properties,
55
+ library,
56
+ steel,
57
+ thickness_classes,
58
+ user_library_path,
59
+ )
60
+ from .plot import CurveSeries, curve_svg, sample_curve, write_curve_svg
61
+ from .orthotropic import OrthotropicMaterial
62
+ from .reductions import (
63
+ BeamMaterialProperties,
64
+ beam_material_properties,
65
+ shell_characteristic_modulus,
66
+ shell_material_matrices,
67
+ )
68
+ from .spec import (
69
+ MaterialSpec,
70
+ build_hardening_curve,
71
+ hardening_descriptor,
72
+ load_specs,
73
+ save_specs,
74
+ spec_from_material,
75
+ )
76
+ from .validation import material_validation_errors, validate_material
77
+ from .yield_criteria import (
78
+ Hill48Yield,
79
+ hill48_coefficients,
80
+ hill48_equivalent_stress,
81
+ hill48_strengths,
82
+ )
83
+
84
+ __version__ = "0.1.0"
85
+
86
+ __all__ = [
87
+ "BeamMaterialProperties",
88
+ "CurveSeries",
89
+ "DNVC208MaterialCurve",
90
+ "ENGINEERING_VOIGT_ORDER",
91
+ "HardeningCurve",
92
+ "Hill48Yield",
93
+ "IsotropicMaterial",
94
+ "LibraryEntry",
95
+ "LinearHardeningCurve",
96
+ "MaterialLibrary",
97
+ "MaterialSpec",
98
+ "OrthotropicMaterial",
99
+ "PiecewiseLinearCurve",
100
+ "PowerLawHardeningCurve",
101
+ "STEEL_DENSITY",
102
+ "STEEL_POISSON_RATIO",
103
+ "SUPPORTED_ELASTIC_SYMMETRIES",
104
+ "StructuralMaterial",
105
+ "add_to_user_library",
106
+ "available_grades",
107
+ "beam_material_properties",
108
+ "build_hardening_curve",
109
+ "builtin_library",
110
+ "curve_from_properties",
111
+ "curve_svg",
112
+ "dnv_c208_steel_curve",
113
+ "dnv_c208_steel_properties",
114
+ "elastic_compliance_matrix",
115
+ "hardening_descriptor",
116
+ "hill48_coefficients",
117
+ "hill48_equivalent_stress",
118
+ "hill48_strengths",
119
+ "is_isotropic_material",
120
+ "is_orthotropic_material",
121
+ "library",
122
+ "load_specs",
123
+ "material_symmetry",
124
+ "material_validation_errors",
125
+ "sample_curve",
126
+ "save_specs",
127
+ "shell_characteristic_modulus",
128
+ "shell_material_matrices",
129
+ "spec_from_material",
130
+ "steel",
131
+ "thickness_classes",
132
+ "user_library_path",
133
+ "validate_material",
134
+ "write_curve_svg",
135
+ ]
@@ -0,0 +1,351 @@
1
+ """Command line interface.
2
+
3
+ ``python -m anymaterial <command>``, or ``anymaterial <command>`` once
4
+ installed. Every command takes ``--json`` for machine-readable output, so the
5
+ tool is usable from a script without parsing formatted text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from typing import Any, Dict, List, Sequence
14
+
15
+ import numpy as np
16
+
17
+ from .contract import elastic_compliance_matrix, material_symmetry
18
+ from .library import (
19
+ STATUSES,
20
+ LibraryEntry,
21
+ add_to_user_library,
22
+ available_grades,
23
+ dnv_c208_steel_curve,
24
+ dnv_c208_steel_properties,
25
+ library,
26
+ thickness_classes,
27
+ user_library_path,
28
+ )
29
+ from .plot import sample_curve, write_curve_svg
30
+ from .reductions import beam_material_properties, shell_material_matrices
31
+ from .spec import MaterialSpec, load_specs
32
+ from .validation import material_validation_errors
33
+
34
+ __all__ = ["main"]
35
+
36
+
37
+ def _print_json(payload: Any) -> None:
38
+ print(json.dumps(payload, indent=2, sort_keys=True, default=float))
39
+
40
+
41
+ def _command_grades(args: argparse.Namespace) -> int:
42
+ payload = {grade: list(thickness_classes(grade)) for grade in available_grades()}
43
+ if args.json:
44
+ _print_json(payload)
45
+ return 0
46
+ print("DNV-RP-C208 low-fractile steel grades (thickness in mm):")
47
+ for grade, classes in payload.items():
48
+ print(f" {grade}: {', '.join(classes)}")
49
+ return 0
50
+
51
+
52
+ def _command_properties(args: argparse.Namespace) -> int:
53
+ properties = dnv_c208_steel_properties(
54
+ args.grade, args.thickness, thickness_class=args.thickness_class
55
+ )
56
+ if args.json:
57
+ _print_json(properties)
58
+ return 0
59
+ print(f"{properties['grade']} thickness {properties['thickness_mm']:g} mm "
60
+ f"class {properties['thickness_class']}")
61
+ print(f" source {properties['source']}")
62
+ print(f" E {float(properties['E_pa']) / 1.0e9:.1f} GPa")
63
+ for key in ("sigma_prop", "sigma_yield", "sigma_yield_2", "K"):
64
+ print(f" {key:<15} {float(properties[key]) / 1.0e6:.1f} MPa")
65
+ for key in ("eps_p_y1", "eps_p_y2", "n"):
66
+ print(f" {key:<15} {float(properties[key]):g}")
67
+ return 0
68
+
69
+
70
+ def _command_curve(args: argparse.Namespace) -> int:
71
+ curve = dnv_c208_steel_curve(args.grade, args.thickness)
72
+ strains = np.linspace(0.0, float(args.max_strain), int(args.points))
73
+ stresses = curve.flow_stress(strains)
74
+ moduli = curve.hardening_modulus(strains)
75
+ if args.json:
76
+ _print_json(
77
+ {
78
+ "grade": args.grade.upper(),
79
+ "thickness_m": float(args.thickness),
80
+ "parameters": curve.as_dict(),
81
+ "plastic_strain": [float(value) for value in strains],
82
+ "flow_stress_pa": [float(value) for value in stresses],
83
+ "hardening_modulus_pa": [float(value) for value in moduli],
84
+ }
85
+ )
86
+ return 0
87
+ print(f"{'eps_p':>10} {'sigma [MPa]':>14} {'H [MPa]':>14}")
88
+ for strain, stress, modulus in zip(strains, stresses, moduli):
89
+ print(f"{strain:>10.5f} {stress / 1.0e6:>14.2f} {modulus / 1.0e6:>14.2f}")
90
+ return 0
91
+
92
+
93
+ def _describe(spec: MaterialSpec) -> Dict[str, Any]:
94
+ """Summarize one specification, including its derived reductions."""
95
+
96
+ material = spec.build()
97
+ compliance = elastic_compliance_matrix(material)
98
+ plane_stress, transverse_shear, drilling = shell_material_matrices(material)
99
+ beam = beam_material_properties(material)
100
+ return {
101
+ "name": spec.name,
102
+ "symmetry": material_symmetry(material),
103
+ "density": float(spec.density),
104
+ "yield_stress": float(spec.yield_stress),
105
+ "nonlinear": spec.is_nonlinear,
106
+ "hardening": dict(spec.hardening) if spec.hardening else None,
107
+ "constants": dict(spec.constants),
108
+ "compliance_diagonal": [float(value) for value in np.diag(compliance)],
109
+ "shell_plane_stress": [[float(value) for value in row] for row in plane_stress],
110
+ "shell_transverse_shear": [float(value) for value in np.diag(transverse_shear)],
111
+ "shell_drilling_shear": float(drilling),
112
+ "beam": {
113
+ "axial_modulus": beam.axial_modulus,
114
+ "shear_modulus_xy": beam.shear_modulus_xy,
115
+ "shear_modulus_xz": beam.shear_modulus_xz,
116
+ },
117
+ }
118
+
119
+
120
+ def _command_library(args: argparse.Namespace) -> int:
121
+ entries = library().find(
122
+ category=args.category, status=args.status, text=args.search,
123
+ nonlinear=True if args.nonlinear else None,
124
+ )
125
+ if args.json:
126
+ _print_json({
127
+ "user_library": str(user_library_path()),
128
+ "materials": [entry.to_dict() for entry in entries],
129
+ })
130
+ return 0
131
+ if not entries:
132
+ print("no materials match")
133
+ return 0
134
+ # Sized to the content: a fixed width truncated the longer alloy names into
135
+ # ambiguity, which is the one thing a material listing must not do.
136
+ name_width = max(len("material"), *(len(entry.name) for entry in entries))
137
+ category_width = max(len("category"), *(len(entry.category) for entry in entries))
138
+ print(
139
+ f"{'material':<{name_width}} {'category':<{category_width}} "
140
+ f"{'status':<10} {'yield':>9} hardening"
141
+ )
142
+ for entry in entries:
143
+ yield_mpa = f"{entry.spec.yield_stress / 1.0e6:.0f} MPa" if entry.spec.yield_stress else "-"
144
+ hardening = entry.spec.hardening["kind"] if entry.spec.hardening else "elastic"
145
+ print(
146
+ f"{entry.name:<{name_width}} {entry.category:<{category_width}} "
147
+ f"{entry.status:<10} {yield_mpa:>9} {hardening}"
148
+ )
149
+ print()
150
+ print(f"user library: {user_library_path()}")
151
+ # Said every time the list is printed, because the whole risk this library
152
+ # carries is a looked-up number being used as a design value. Only shown for
153
+ # the statuses actually listed, so the warning stays worth reading.
154
+ shown = {entry.status for entry in entries}
155
+ if "measured" in shown:
156
+ print("status 'measured' is the MEAN of a test campaign, NOT a characteristic value:")
157
+ print("the mean yield exceeds the nominal, so using it as a design strength is")
158
+ print("unconservative. Use it to validate a model against the tests it came from.")
159
+ if "indicative" in shown:
160
+ print("status 'indicative' means a typical published figure, not a design value:")
161
+ print("check it against the governing standard or the mill certificate.")
162
+ if shown <= {"tabulated"}:
163
+ print("all listed materials are 'tabulated': reproduced from a standard's own table.")
164
+ return 0
165
+
166
+
167
+ def _command_plot(args: argparse.Namespace) -> int:
168
+ catalogue = library()
169
+ series = []
170
+ for name in args.materials:
171
+ entry = catalogue.get(name)
172
+ curve = entry.spec.hardening_curve()
173
+ if curve is None:
174
+ raise ValueError(
175
+ f"{name!r} is elastic and has no flow curve to plot; "
176
+ "list the nonlinear materials with `anymaterial library --nonlinear`"
177
+ )
178
+ series.append(sample_curve(curve, name, max_strain=args.max_strain, samples=args.points))
179
+
180
+ path = write_curve_svg(
181
+ args.output, series, overwrite=args.overwrite, title=args.title, stress_unit="MPa"
182
+ )
183
+ if args.json:
184
+ _print_json({"output": str(path), "materials": list(args.materials)})
185
+ else:
186
+ print(f"wrote {path} ({len(series)} curve(s))")
187
+ return 0
188
+
189
+
190
+ def _command_add(args: argparse.Namespace) -> int:
191
+ hardening = None
192
+ if args.grade:
193
+ hardening = {"kind": "dnv_c208", "grade": args.grade, "thickness": args.thickness}
194
+ elif args.hardening_modulus is not None:
195
+ hardening = {
196
+ "kind": "linear",
197
+ "sigma_yield": args.yield_stress * 1.0e6,
198
+ "hardening_modulus": args.hardening_modulus * 1.0e6,
199
+ }
200
+
201
+ entry = LibraryEntry(
202
+ spec=MaterialSpec(
203
+ name=args.name,
204
+ symmetry="isotropic",
205
+ constants={
206
+ "elastic_modulus": args.elastic_modulus * 1.0e9,
207
+ "poisson_ratio": args.poisson_ratio,
208
+ },
209
+ density=args.density,
210
+ yield_stress=args.yield_stress * 1.0e6,
211
+ hardening=hardening,
212
+ ),
213
+ category=args.category,
214
+ status="user",
215
+ standard=args.standard,
216
+ source=args.source,
217
+ notes=args.notes,
218
+ )
219
+ path = add_to_user_library(entry, replace_existing=args.replace)
220
+ if args.json:
221
+ _print_json({"added": entry.name, "library": str(path)})
222
+ else:
223
+ print(f"added {entry.name!r} to {path}")
224
+ return 0
225
+
226
+
227
+ def _command_show(args: argparse.Namespace) -> int:
228
+ summaries = [_describe(spec) for spec in load_specs(args.input)]
229
+ if args.json:
230
+ _print_json({"materials": summaries})
231
+ return 0
232
+ for summary in summaries:
233
+ print(f"{summary['name']} ({summary['symmetry']})")
234
+ print(f" density {summary['density']:g} kg/m3")
235
+ print(f" yield stress {summary['yield_stress'] / 1.0e6:g} MPa")
236
+ print(f" nonlinear {summary['nonlinear']}")
237
+ if summary["hardening"]:
238
+ print(f" hardening {summary['hardening']}")
239
+ for key, value in sorted(summary["constants"].items()):
240
+ print(f" {key:<15} {value:g}")
241
+ print(f" beam E {summary['beam']['axial_modulus'] / 1.0e9:.2f} GPa")
242
+ print(f" drilling G {summary['shell_drilling_shear'] / 1.0e9:.2f} GPa")
243
+ return 0
244
+
245
+
246
+ def _command_validate(args: argparse.Namespace) -> int:
247
+ results: List[Dict[str, Any]] = []
248
+ exit_code = 0
249
+ for spec in load_specs(args.input):
250
+ try:
251
+ material = spec.build()
252
+ except ValueError as error:
253
+ results.append({"name": spec.name, "valid": False, "errors": [str(error)]})
254
+ exit_code = 1
255
+ continue
256
+ errors = list(material_validation_errors(material))
257
+ results.append({"name": spec.name, "valid": not errors, "errors": errors})
258
+ if errors:
259
+ exit_code = 1
260
+
261
+ if args.json:
262
+ _print_json({"materials": results})
263
+ return exit_code
264
+ for result in results:
265
+ status = "ok" if result["valid"] else "INVALID"
266
+ print(f"{result['name']}: {status}")
267
+ for message in result["errors"]:
268
+ print(f" - {message}")
269
+ return exit_code
270
+
271
+
272
+ def main(argv: Sequence[str] | None = None) -> int:
273
+ parser = argparse.ArgumentParser(prog="anymaterial", description=__doc__.splitlines()[0])
274
+ parser.add_argument("--json", action="store_true", help="print machine-readable JSON")
275
+ sub = parser.add_subparsers(dest="command", required=True)
276
+
277
+ sub.add_parser("grades", help="list tabulated steel grades and thickness classes")
278
+
279
+ catalogue = sub.add_parser("library", help="list the materials in the library")
280
+ catalogue.add_argument("--category", help="filter by category, e.g. aluminium")
281
+ # Derived from the library's own list, so the two cannot drift apart.
282
+ catalogue.add_argument("--status", choices=STATUSES)
283
+ catalogue.add_argument("--search", help="filter by name or category substring")
284
+ catalogue.add_argument("--nonlinear", action="store_true", help="only materials with a flow curve")
285
+
286
+ plot = sub.add_parser("plot", help="plot flow curves to an SVG file")
287
+ plot.add_argument("materials", nargs="+", help="library material names")
288
+ plot.add_argument("--output", "-o", default="flow_curves.svg")
289
+ plot.add_argument("--title", default="Flow curves")
290
+ plot.add_argument("--max-strain", type=float, default=0.10)
291
+ plot.add_argument("--points", type=int, default=201)
292
+ plot.add_argument("--overwrite", action="store_true")
293
+
294
+ add = sub.add_parser("add", help="add an isotropic material to the user library")
295
+ add.add_argument("name")
296
+ add.add_argument("--elastic-modulus", type=float, required=True, help="E in GPa")
297
+ add.add_argument("--poisson-ratio", type=float, default=0.3)
298
+ add.add_argument("--density", type=float, default=7850.0, help="kg/m3")
299
+ add.add_argument("--yield-stress", type=float, default=0.0, help="MPa")
300
+ add.add_argument("--hardening-modulus", type=float, help="bilinear plastic modulus in MPa")
301
+ add.add_argument("--grade", help="attach a DNV-RP-C208 curve for this grade instead")
302
+ add.add_argument("--thickness", type=float, default=0.010, help="metres, with --grade")
303
+ add.add_argument("--category", default="other")
304
+ add.add_argument("--standard", help="the standard the numbers come from")
305
+ add.add_argument("--source", help="where the numbers came from")
306
+ add.add_argument("--notes")
307
+ add.add_argument("--replace", action="store_true", help="overwrite a material of the same name")
308
+
309
+ properties = sub.add_parser("properties", help="show one RP-C208 table row in SI units")
310
+ properties.add_argument("grade")
311
+ properties.add_argument("thickness", type=float, help="plate thickness in metres")
312
+ properties.add_argument(
313
+ "--thickness-class",
314
+ default="auto",
315
+ help="select a table row explicitly, e.g. '40 < t <= 63'",
316
+ )
317
+
318
+ curve = sub.add_parser("curve", help="sample a flow curve")
319
+ curve.add_argument("grade")
320
+ curve.add_argument("thickness", type=float, help="plate thickness in metres")
321
+ curve.add_argument("--points", type=int, default=11)
322
+ curve.add_argument("--max-strain", type=float, default=0.1)
323
+
324
+ show = sub.add_parser("show", help="summarize materials in a JSON file")
325
+ show.add_argument("input")
326
+
327
+ validate = sub.add_parser("validate", help="validate materials in a JSON file")
328
+ validate.add_argument("input")
329
+
330
+ args = parser.parse_args(argv)
331
+ handlers = {
332
+ "grades": _command_grades,
333
+ "library": _command_library,
334
+ "plot": _command_plot,
335
+ "add": _command_add,
336
+ "properties": _command_properties,
337
+ "curve": _command_curve,
338
+ "show": _command_show,
339
+ "validate": _command_validate,
340
+ }
341
+ try:
342
+ return handlers[args.command](args)
343
+ except (FileExistsError, FileNotFoundError, KeyError, NotImplementedError, ValueError) as error:
344
+ # A bad grade, an out-of-range thickness or a malformed file is a usage
345
+ # error, not a crash: report it on stderr and exit non-zero.
346
+ print(f"error: {error}", file=sys.stderr)
347
+ return 2
348
+
349
+
350
+ if __name__ == "__main__": # pragma: no cover - process entry point
351
+ raise SystemExit(main())
@@ -0,0 +1,127 @@
1
+ """The structural material contract.
2
+
3
+ A consumer needs four things from a material: a name, a density, a declared
4
+ elastic symmetry, and a 6x6 engineering compliance. Everything else -- shell
5
+ plane-stress matrices, beam axial and shear constants, yield surfaces -- is
6
+ derived from those, in :mod:`anymaterial.reductions` and elsewhere.
7
+
8
+ The contract is a :class:`typing.Protocol` rather than a base class on purpose.
9
+ A solver should accept a material without importing the class that defines it,
10
+ which is what allows this package and its consumers to be released
11
+ independently. Validation is therefore written against attributes, not types.
12
+
13
+ Voigt order is engineering ``[11, 22, 33, 23, 13, 12]``:
14
+
15
+ ``[eps11, eps22, eps33, gamma23, gamma13, gamma12] = S @
16
+ [sig11, sig22, sig33, tau23, tau13, tau12]``
17
+
18
+ Shear strains are engineering angles, twice the tensor components. Getting
19
+ this wrong scales every shear term by two and is not detectable from the
20
+ diagonal, so the order is stated once here and matched everywhere.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any, Protocol, Tuple, runtime_checkable
26
+
27
+ import numpy as np
28
+
29
+ __all__ = [
30
+ "ENGINEERING_VOIGT_ORDER",
31
+ "SUPPORTED_ELASTIC_SYMMETRIES",
32
+ "StructuralMaterial",
33
+ "elastic_compliance_matrix",
34
+ "is_isotropic_material",
35
+ "is_orthotropic_material",
36
+ "material_symmetry",
37
+ ]
38
+
39
+
40
+ ENGINEERING_VOIGT_ORDER: Tuple[str, ...] = ("11", "22", "33", "23", "13", "12")
41
+
42
+ SUPPORTED_ELASTIC_SYMMETRIES = frozenset({"isotropic", "orthotropic"})
43
+
44
+
45
+ @runtime_checkable
46
+ class StructuralMaterial(Protocol):
47
+ """Minimal contract for a structural material."""
48
+
49
+ name: str
50
+ density: float
51
+ elastic_symmetry: str
52
+
53
+ def elastic_compliance_matrix(self) -> np.ndarray:
54
+ """Return the 6x6 engineering compliance in Voigt order."""
55
+
56
+
57
+ def material_symmetry(material: Any) -> str:
58
+ """Return the normalized declared elastic symmetry.
59
+
60
+ Duck-typed isotropic objects that predate the declaration stay recognizable
61
+ when they expose the historical ``elastic_modulus`` and ``poisson_ratio``
62
+ fields. That fallback exists for material records arriving from older
63
+ callers and deserializers, not as an alternative to declaring the symmetry.
64
+ """
65
+
66
+ symmetry = getattr(material, "elastic_symmetry", None)
67
+ if symmetry is None and hasattr(material, "elastic_modulus") and hasattr(material, "poisson_ratio"):
68
+ return "isotropic"
69
+ if not isinstance(symmetry, str) or not symmetry.strip():
70
+ raise ValueError(
71
+ "Structural material must declare elastic_symmetry as 'isotropic' or 'orthotropic'"
72
+ )
73
+ return symmetry.strip().lower()
74
+
75
+
76
+ def is_isotropic_material(material: Any) -> bool:
77
+ """Return whether a material declares isotropic elasticity."""
78
+
79
+ try:
80
+ return material_symmetry(material) == "isotropic"
81
+ except ValueError:
82
+ return False
83
+
84
+
85
+ def is_orthotropic_material(material: Any) -> bool:
86
+ """Return whether a material declares orthotropic elasticity."""
87
+
88
+ try:
89
+ return material_symmetry(material) == "orthotropic"
90
+ except ValueError:
91
+ return False
92
+
93
+
94
+ def elastic_compliance_matrix(material: Any) -> np.ndarray:
95
+ """Return a material's compliance with a stable numpy representation.
96
+
97
+ A material that provides ``elastic_compliance_matrix()`` is asked for it.
98
+ A legacy isotropic record that does not is assembled from ``E`` and ``nu``,
99
+ so the two reach every reduction by the same path and there is one place
100
+ for the algebra to be wrong.
101
+ """
102
+
103
+ provider = getattr(material, "elastic_compliance_matrix", None)
104
+ if callable(provider):
105
+ matrix = np.asarray(provider(), dtype=float)
106
+ elif is_isotropic_material(material):
107
+ E = float(material.elastic_modulus)
108
+ nu = float(material.poisson_ratio)
109
+ G = E / (2.0 * (1.0 + nu))
110
+ matrix = np.array(
111
+ [
112
+ [1.0 / E, -nu / E, -nu / E, 0.0, 0.0, 0.0],
113
+ [-nu / E, 1.0 / E, -nu / E, 0.0, 0.0, 0.0],
114
+ [-nu / E, -nu / E, 1.0 / E, 0.0, 0.0, 0.0],
115
+ [0.0, 0.0, 0.0, 1.0 / G, 0.0, 0.0],
116
+ [0.0, 0.0, 0.0, 0.0, 1.0 / G, 0.0],
117
+ [0.0, 0.0, 0.0, 0.0, 0.0, 1.0 / G],
118
+ ],
119
+ dtype=float,
120
+ )
121
+ else:
122
+ raise ValueError("Structural material must provide elastic_compliance_matrix()")
123
+ if matrix.shape != (6, 6):
124
+ raise ValueError(
125
+ f"Material elastic compliance must have shape (6, 6), received {matrix.shape}"
126
+ )
127
+ return matrix