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,179 @@
1
+ """Model mass properties and rigid-body inertia diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, Dict, List, Tuple
7
+
8
+ import numpy as np
9
+
10
+ from .elements import BeamElement, QuadraticBeamElement, ShellElement
11
+ from .matrix_assembly import assemble_mass_matrix
12
+
13
+ if TYPE_CHECKING:
14
+ from .fe_core import FEModel
15
+
16
+
17
+ @dataclass
18
+ class MassProperties:
19
+ """Integrated and assembled mass diagnostics for an FE model."""
20
+
21
+ total_mass: float
22
+ center_of_mass: np.ndarray
23
+ first_moment: np.ndarray
24
+ inertia_tensor_origin: np.ndarray
25
+ inertia_tensor_center_of_mass: np.ndarray
26
+ rigid_body_mass_matrix: np.ndarray
27
+ assembled_translation_masses: Dict[str, float]
28
+ num_mass_points: int
29
+ skipped_elements: List[int]
30
+ assembly_info: Dict[str, Any]
31
+
32
+ def to_dict(self) -> Dict[str, Any]:
33
+ return {
34
+ "total_mass": float(self.total_mass),
35
+ "center_of_mass": self.center_of_mass.tolist(),
36
+ "first_moment": self.first_moment.tolist(),
37
+ "inertia_tensor_origin": self.inertia_tensor_origin.tolist(),
38
+ "inertia_tensor_center_of_mass": self.inertia_tensor_center_of_mass.tolist(),
39
+ "rigid_body_mass_matrix": self.rigid_body_mass_matrix.tolist(),
40
+ "assembled_translation_masses": dict(self.assembled_translation_masses),
41
+ "num_mass_points": int(self.num_mass_points),
42
+ "skipped_elements": [int(eid) for eid in self.skipped_elements],
43
+ "assembly_info": self.assembly_info,
44
+ }
45
+
46
+
47
+ def _point_inertia(points: List[Tuple[float, np.ndarray]], reference: np.ndarray) -> np.ndarray:
48
+ inertia = np.zeros((3, 3), dtype=float)
49
+ eye = np.eye(3)
50
+ for mass, coords in points:
51
+ r = np.asarray(coords, dtype=float) - reference
52
+ inertia += float(mass) * ((float(r @ r) * eye) - np.outer(r, r))
53
+ return inertia
54
+
55
+
56
+ def _rigid_body_modes(model: "FEModel", reference: np.ndarray) -> np.ndarray:
57
+ total_dofs = model.mesh.dof_manager.total_dofs
58
+ modes = np.zeros((total_dofs, 6), dtype=float)
59
+ for node in model.mesh.nodes.values():
60
+ x, y, z = node.coords() - reference
61
+ ux, uy, uz, rx, ry, rz = node.dofs[:6]
62
+ modes[ux, 0] = 1.0
63
+ modes[uy, 1] = 1.0
64
+ modes[uz, 2] = 1.0
65
+
66
+ modes[uy, 3] = -z
67
+ modes[uz, 3] = y
68
+ modes[rx, 3] = 1.0
69
+
70
+ modes[ux, 4] = z
71
+ modes[uz, 4] = -x
72
+ modes[ry, 4] = 1.0
73
+
74
+ modes[ux, 5] = -y
75
+ modes[uy, 5] = x
76
+ modes[rz, 5] = 1.0
77
+ return modes
78
+
79
+
80
+ def _shell_mass_points(model: "FEModel", element: ShellElement) -> List[Tuple[float, np.ndarray]]:
81
+ material = model.get_material(element.material_name)
82
+ coords = element.get_node_coordinates(model.mesh)
83
+ points: List[Tuple[float, np.ndarray]] = []
84
+ for (xi, eta), weight in zip(element.gauss_points, element.gauss_weights):
85
+ N, dN_dxi, dN_deta = element.compute_shape_functions(float(xi), float(eta))
86
+ _, _, _, det_j = element._local_frame_and_derivatives(coords, dN_dxi, dN_deta)
87
+ mass = float(material.density) * float(element.thickness) * float(det_j) * float(weight)
88
+ points.append((mass, np.asarray(N @ coords, dtype=float)))
89
+ return points
90
+
91
+
92
+ def _beam_mass_points(model: "FEModel", element: BeamElement) -> List[Tuple[float, np.ndarray]]:
93
+ material = model.get_material(element.material_name)
94
+ coords = element.get_node_coordinates(model.mesh)
95
+ if isinstance(element, QuadraticBeamElement):
96
+ length = float(np.linalg.norm(coords[2] - coords[0]))
97
+ points = []
98
+ for xi, weight in zip(element.GAUSS_POINTS, element.GAUSS_WEIGHTS):
99
+ N, _ = element.compute_shape_functions(float(xi))
100
+ mass = float(material.density) * float(element._A) * length / 2.0 * float(weight)
101
+ points.append((mass, np.asarray(N @ coords, dtype=float)))
102
+ return points
103
+ length = float(np.linalg.norm(coords[1] - coords[0]))
104
+ xi_values = (-1.0 / np.sqrt(3.0), 1.0 / np.sqrt(3.0))
105
+ points = []
106
+ for xi in xi_values:
107
+ N = np.array([(1.0 - xi) / 2.0, (1.0 + xi) / 2.0], dtype=float)
108
+ mass = float(material.density) * float(element._A) * length / 2.0
109
+ points.append((mass, np.asarray(N @ coords, dtype=float)))
110
+ return points
111
+
112
+
113
+ def element_mass_points(model: "FEModel") -> Tuple[List[Tuple[float, np.ndarray]], List[int]]:
114
+ """Return quadrature mass points for elements with physical density."""
115
+ points: List[Tuple[float, np.ndarray]] = []
116
+ skipped: List[int] = []
117
+ for elem_id, element in model.mesh.elements.items():
118
+ try:
119
+ if isinstance(element, ShellElement):
120
+ points.extend(_shell_mass_points(model, element))
121
+ elif isinstance(element, BeamElement):
122
+ points.extend(_beam_mass_points(model, element))
123
+ else:
124
+ skipped.append(int(elem_id))
125
+ except Exception:
126
+ skipped.append(int(elem_id))
127
+ return points, skipped
128
+
129
+
130
+ def calculate_mass_properties(model: "FEModel", reference_point: Any = None) -> MassProperties:
131
+ """Calculate integrated mass properties and assembled rigid-body mass.
132
+
133
+ The scalar mass, first moment and inertia tensors are obtained from
134
+ element quadrature plus model point masses. The 6x6 rigid-body mass matrix
135
+ is additionally formed from the assembled mass matrix using rigid
136
+ translation/rotation fields, so shell and beam rotary inertia terms are
137
+ visible in diagnostics.
138
+ """
139
+ points, skipped = element_mass_points(model)
140
+ for node_id, mass in getattr(model.mesh, "point_masses", {}).items():
141
+ node = model.mesh.get_node(int(node_id))
142
+ if node is None:
143
+ raise ValueError(f"Point mass references missing node {node_id}")
144
+ mass_value = float(mass)
145
+ if not np.isfinite(mass_value) or mass_value < 0.0:
146
+ raise ValueError(f"Point mass at node {node_id} must be finite and non-negative")
147
+ if mass_value > 0.0:
148
+ points.append((mass_value, node.coords()))
149
+ total_mass = float(sum(mass for mass, _ in points))
150
+ first_moment = np.zeros(3, dtype=float)
151
+ for mass, coords in points:
152
+ first_moment += float(mass) * np.asarray(coords, dtype=float)
153
+ center = first_moment / total_mass if total_mass > 0.0 else np.zeros(3, dtype=float)
154
+ reference = center if reference_point is None else np.asarray(reference_point, dtype=float).reshape(3)
155
+
156
+ M, assembly_info = assemble_mass_matrix(model)
157
+ modes = _rigid_body_modes(model, reference)
158
+ rbm = np.asarray(modes.T @ (M @ modes), dtype=float)
159
+ tx = modes[:, 0]
160
+ ty = modes[:, 1]
161
+ tz = modes[:, 2]
162
+ assembled_translation_masses = {
163
+ "x": float(tx @ (M @ tx)),
164
+ "y": float(ty @ (M @ ty)),
165
+ "z": float(tz @ (M @ tz)),
166
+ }
167
+
168
+ return MassProperties(
169
+ total_mass=total_mass,
170
+ center_of_mass=center,
171
+ first_moment=first_moment,
172
+ inertia_tensor_origin=_point_inertia(points, np.zeros(3, dtype=float)),
173
+ inertia_tensor_center_of_mass=_point_inertia(points, center),
174
+ rigid_body_mass_matrix=rbm,
175
+ assembled_translation_masses=assembled_translation_masses,
176
+ num_mass_points=len(points),
177
+ skipped_elements=skipped,
178
+ assembly_info=assembly_info,
179
+ )
@@ -0,0 +1,231 @@
1
+ """Nonlinear material hardening curves.
2
+
3
+ Implements the DNV-RP-C208 (September 2019, amended October 2022) section
4
+ 4.6.6 recommendation: the flow stress is a function of true plastic strain
5
+ built from a stepwise linear part with a yield plateau and a power-law part:
6
+
7
+ Part 1: sigma_prop -> sigma_yield over 0 .. eps_p_y1
8
+ Part 2: sigma_yield -> sigma_yield_2 over eps_p_y1 .. eps_p_y2
9
+ Part 3: sigma = K * (eps_p + (sigma_yield_2 / K)**(1/n) - eps_p_y2)**n
10
+
11
+ All evaluations are vectorized over numpy arrays of equivalent plastic
12
+ strain, because the return mapping calls them for every integration point and
13
+ thickness layer at once.
14
+
15
+ The curve is expressed in true stress / true plastic strain exactly as
16
+ tabulated by the RP; the solver consumes it directly as the J2 flow stress.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from typing import Dict, Mapping, Optional
23
+
24
+ import numpy as np
25
+
26
+
27
+ _MPA = 1.0e6
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class FiberSectionPlasticityConfig:
32
+ """Opt-in beam fiber plasticity configuration.
33
+
34
+ The beam fiber model uses an equivalent rectangular fiber grid whose
35
+ coordinates are scaled to match the supplied section ``A``, ``Iy`` and
36
+ ``Iz``. It is intended for monotonic capacity checks of beam/stiffener
37
+ idealizations; shear and torsion remain elastic in v1.
38
+ """
39
+
40
+ num_y: int = 5
41
+ num_z: int = 5
42
+ material_curve: Optional["DNVC208MaterialCurve"] = None
43
+
44
+ def __post_init__(self) -> None:
45
+ if self.num_y <= 0 or self.num_z <= 0:
46
+ raise ValueError("num_y and num_z must be positive")
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class DNVC208MaterialCurve:
51
+ """DNV-RP-C208 stepwise-linear plus power-law flow curve."""
52
+
53
+ sigma_prop: float
54
+ sigma_yield: float
55
+ sigma_yield_2: float
56
+ eps_p_y1: float
57
+ eps_p_y2: float
58
+ K: float
59
+ n: float
60
+
61
+ def __post_init__(self) -> None:
62
+ if self.sigma_prop <= 0.0:
63
+ raise ValueError("sigma_prop must be positive")
64
+ if self.sigma_yield < self.sigma_prop:
65
+ raise ValueError("sigma_yield must be >= sigma_prop")
66
+ if self.sigma_yield_2 < self.sigma_yield:
67
+ raise ValueError("sigma_yield_2 must be >= sigma_yield")
68
+ if not (0.0 < self.eps_p_y1 < self.eps_p_y2):
69
+ raise ValueError("require 0 < eps_p_y1 < eps_p_y2")
70
+ if self.K <= 0.0 or not (0.0 < self.n < 1.0):
71
+ raise ValueError("require K > 0 and 0 < n < 1")
72
+
73
+ @property
74
+ def _power_offset(self) -> float:
75
+ """Plastic-strain offset making Part 3 continuous at eps_p_y2."""
76
+ return (self.sigma_yield_2 / self.K) ** (1.0 / self.n) - self.eps_p_y2
77
+
78
+ def flow_stress(self, eps_p: np.ndarray) -> np.ndarray:
79
+ """Flow (yield) stress as a function of equivalent plastic strain."""
80
+ eps_p = np.maximum(np.asarray(eps_p, dtype=float), 0.0)
81
+ slope_1 = (self.sigma_yield - self.sigma_prop) / self.eps_p_y1
82
+ slope_2 = (self.sigma_yield_2 - self.sigma_yield) / (self.eps_p_y2 - self.eps_p_y1)
83
+ part_1 = self.sigma_prop + slope_1 * eps_p
84
+ part_2 = self.sigma_yield + slope_2 * (eps_p - self.eps_p_y1)
85
+ part_3 = self.K * np.power(np.maximum(eps_p + self._power_offset, 1.0e-12), self.n)
86
+ return np.where(
87
+ eps_p <= self.eps_p_y1,
88
+ part_1,
89
+ np.where(eps_p <= self.eps_p_y2, part_2, part_3),
90
+ )
91
+
92
+ def hardening_modulus(self, eps_p: np.ndarray) -> np.ndarray:
93
+ """d(flow stress)/d(equivalent plastic strain)."""
94
+ eps_p = np.maximum(np.asarray(eps_p, dtype=float), 0.0)
95
+ slope_1 = (self.sigma_yield - self.sigma_prop) / self.eps_p_y1
96
+ slope_2 = (self.sigma_yield_2 - self.sigma_yield) / (self.eps_p_y2 - self.eps_p_y1)
97
+ base = np.maximum(eps_p + self._power_offset, 1.0e-12)
98
+ slope_3 = self.K * self.n * np.power(base, self.n - 1.0)
99
+ return np.where(
100
+ eps_p <= self.eps_p_y1,
101
+ slope_1,
102
+ np.where(eps_p <= self.eps_p_y2, slope_2, slope_3),
103
+ )
104
+
105
+
106
+ def curve_from_properties(properties: dict) -> DNVC208MaterialCurve:
107
+ """Build a curve from an RP-C208 table row (e.g. Table 4-2 .. 4-6)."""
108
+ return DNVC208MaterialCurve(
109
+ sigma_prop=float(properties["sigma_prop"]),
110
+ sigma_yield=float(properties["sigma_yield"]),
111
+ sigma_yield_2=float(properties["sigma_yield_2"]),
112
+ eps_p_y1=float(properties.get("eps_p_y1", 0.004)),
113
+ eps_p_y2=float(properties["eps_p_y2"]),
114
+ K=float(properties["K"]),
115
+ n=float(properties["n"]),
116
+ )
117
+
118
+
119
+ _DNV_C208_LOW_FRACTILE_STEEL_MPA: Dict[str, tuple[tuple[float, float, Mapping[str, float]], ...]] = {
120
+ "S235": (
121
+ (0.0, 16.0, {"sigma_prop": 211.7, "sigma_yield": 236.2, "sigma_yield_2": 243.4, "eps_p_y2": 0.020, "K": 520.0, "n": 0.166}),
122
+ (16.0, 40.0, {"sigma_prop": 202.7, "sigma_yield": 226.1, "sigma_yield_2": 233.2, "eps_p_y2": 0.020, "K": 520.0, "n": 0.166}),
123
+ (40.0, 63.0, {"sigma_prop": 193.7, "sigma_yield": 216.1, "sigma_yield_2": 223.0, "eps_p_y2": 0.020, "K": 520.0, "n": 0.166}),
124
+ (63.0, 100.0, {"sigma_prop": 193.7, "sigma_yield": 216.1, "sigma_yield_2": 223.0, "eps_p_y2": 0.020, "K": 520.0, "n": 0.166}),
125
+ ),
126
+ "S275": (
127
+ (0.0, 16.0, {"sigma_prop": 247.8, "sigma_yield": 276.5, "sigma_yield_2": 282.8, "eps_p_y2": 0.017, "K": 620.0, "n": 0.166}),
128
+ (16.0, 40.0, {"sigma_prop": 238.8, "sigma_yield": 266.4, "sigma_yield_2": 272.6, "eps_p_y2": 0.017, "K": 620.0, "n": 0.166}),
129
+ (40.0, 63.0, {"sigma_prop": 229.8, "sigma_yield": 256.3, "sigma_yield_2": 262.4, "eps_p_y2": 0.017, "K": 620.0, "n": 0.166}),
130
+ ),
131
+ "S355": (
132
+ (0.0, 16.0, {"sigma_prop": 320.0, "sigma_yield": 357.0, "sigma_yield_2": 363.3, "eps_p_y2": 0.015, "K": 740.0, "n": 0.166}),
133
+ (16.0, 40.0, {"sigma_prop": 311.0, "sigma_yield": 346.9, "sigma_yield_2": 353.1, "eps_p_y2": 0.015, "K": 740.0, "n": 0.166}),
134
+ (40.0, 63.0, {"sigma_prop": 301.9, "sigma_yield": 336.9, "sigma_yield_2": 342.9, "eps_p_y2": 0.015, "K": 725.0, "n": 0.166}),
135
+ (63.0, 100.0, {"sigma_prop": 283.9, "sigma_yield": 316.7, "sigma_yield_2": 322.5, "eps_p_y2": 0.015, "K": 725.0, "n": 0.166}),
136
+ ),
137
+ "S420": (
138
+ (0.0, 16.0, {"sigma_prop": 378.7, "sigma_yield": 422.5, "sigma_yield_2": 427.6, "eps_p_y2": 0.012, "K": 738.0, "n": 0.140}),
139
+ (16.0, 40.0, {"sigma_prop": 360.6, "sigma_yield": 402.4, "sigma_yield_2": 407.3, "eps_p_y2": 0.012, "K": 703.0, "n": 0.140}),
140
+ (40.0, 63.0, {"sigma_prop": 351.6, "sigma_yield": 392.3, "sigma_yield_2": 397.1, "eps_p_y2": 0.012, "K": 686.0, "n": 0.140}),
141
+ ),
142
+ "S460": (
143
+ (0.0, 16.0, {"sigma_prop": 414.8, "sigma_yield": 462.8, "sigma_yield_2": 466.9, "eps_p_y2": 0.010, "K": 772.0, "n": 0.120}),
144
+ (16.0, 40.0, {"sigma_prop": 396.7, "sigma_yield": 442.7, "sigma_yield_2": 446.6, "eps_p_y2": 0.010, "K": 745.0, "n": 0.120}),
145
+ (40.0, 63.0, {"sigma_prop": 374.2, "sigma_yield": 417.5, "sigma_yield_2": 421.2, "eps_p_y2": 0.010, "K": 703.0, "n": 0.120}),
146
+ ),
147
+ }
148
+
149
+
150
+ def _thickness_class_label(lower_mm: float, upper_mm: float) -> str:
151
+ if lower_mm <= 0.0:
152
+ return f"t <= {upper_mm:g}"
153
+ return f"{lower_mm:g} < t <= {upper_mm:g}"
154
+
155
+
156
+ def _normalized_thickness_class(value: str) -> str:
157
+ return str(value or "auto").strip().lower().replace(" ", "")
158
+
159
+
160
+ def dnv_c208_steel_properties(
161
+ grade: str,
162
+ thickness: float,
163
+ thickness_class: str = "auto",
164
+ fractile: str = "low",
165
+ ) -> Dict[str, float | str]:
166
+ """Return one validated RP-C208 low-fractile steel table row in SI units.
167
+
168
+ ``thickness`` is in metres, matching solver SI units. The built-in table
169
+ covers the RP-C208 low-fractile curves from section 4.6.6. Mean curves are
170
+ intentionally not guessed; pass explicit properties through
171
+ ``curve_from_properties`` if mean data is required.
172
+
173
+ Automatic selection fails closed when the thickness is outside the table.
174
+ An explicit ``thickness_class`` may select a table row intentionally.
175
+ """
176
+ if fractile.lower() not in {"low", "low_fractile", "5%", "5_percent"}:
177
+ raise NotImplementedError("Built-in RP-C208 mean curves are not available; supply explicit curve properties")
178
+ grade_key = grade.upper().replace(" ", "")
179
+ if grade_key not in _DNV_C208_LOW_FRACTILE_STEEL_MPA:
180
+ raise ValueError(f"Unsupported RP-C208 steel grade {grade!r}; use one of {sorted(_DNV_C208_LOW_FRACTILE_STEEL_MPA)}")
181
+ thickness_mm = float(thickness) * 1000.0
182
+ if thickness_mm <= 0.0:
183
+ raise ValueError("thickness must be positive")
184
+ rows = _DNV_C208_LOW_FRACTILE_STEEL_MPA[grade_key]
185
+ selected: Optional[tuple[float, float, Mapping[str, float]]] = None
186
+ class_key = _normalized_thickness_class(thickness_class)
187
+ automatic = class_key in {"auto", "automatic", "bythickness", "autobyplatethickness"}
188
+ if automatic:
189
+ for row in rows:
190
+ lower, upper, _properties_mpa = row
191
+ if lower < thickness_mm <= upper:
192
+ selected = row
193
+ break
194
+ if selected is None:
195
+ raise ValueError(
196
+ f"Thickness {thickness_mm:g} mm is outside the built-in RP-C208 range "
197
+ f"for {grade_key} (maximum {rows[-1][1]:g} mm)"
198
+ )
199
+ else:
200
+ for row in rows:
201
+ lower, upper, _properties_mpa = row
202
+ if class_key == _normalized_thickness_class(_thickness_class_label(lower, upper)):
203
+ selected = row
204
+ break
205
+ if selected is None:
206
+ labels = [_thickness_class_label(lower, upper) for lower, upper, _properties in rows]
207
+ raise ValueError(f"Unsupported thickness_class {thickness_class!r} for {grade_key}; use one of {labels}")
208
+
209
+ lower, upper, selected_mpa = selected
210
+ properties = {
211
+ "grade": grade_key,
212
+ "thickness_class": _thickness_class_label(lower, upper),
213
+ "thickness_mm": thickness_mm,
214
+ "source": "DNV-RP-C208 section 4.6.6 low-fractile true stress-strain values",
215
+ "E_pa": 210_000.0 * _MPA,
216
+ "sigma_prop": float(selected_mpa["sigma_prop"]) * _MPA,
217
+ "sigma_yield": float(selected_mpa["sigma_yield"]) * _MPA,
218
+ "sigma_yield_2": float(selected_mpa["sigma_yield_2"]) * _MPA,
219
+ "eps_p_y1": float(selected_mpa.get("eps_p_y1", 0.004)),
220
+ "eps_p_y2": float(selected_mpa["eps_p_y2"]),
221
+ "K": float(selected_mpa["K"]) * _MPA,
222
+ "n": float(selected_mpa["n"]),
223
+ }
224
+ return properties
225
+
226
+
227
+ def dnv_c208_steel_curve(grade: str, thickness: float, fractile: str = "low") -> DNVC208MaterialCurve:
228
+ """Return a validated RP-C208 steel curve for a grade and plate thickness."""
229
+
230
+ properties = dnv_c208_steel_properties(grade, thickness, fractile=fractile)
231
+ return curve_from_properties(properties)