From c869f67d3c9570a5ded78a2a20050fb720ea0df4 Mon Sep 17 00:00:00 2001 From: mohamed sabri zairi Date: Fri, 2 Oct 2026 12:43:39 +0200 Subject: [PATCH] Add Material and SNCurve data model - Material dataclass holding the WG6 nomenclature parameters; each parameter (name, symbol, unit, WG6 key) is defined once in the field metadata and NOMENCLATURE is built from it. - No required-parameter checks at the material level: each method checks the parameters it needs. - No unit conversion: values are given in the nomenclature units. - SNCurve: Basquin curve with knee, fit from test points, stress/life conversions and survival probability shift. - Dict / JSON round trips with WG6 key aliases. - Unit tests in tests/data_parsing/test_material.py. --- src/fatpy/data_parsing/material.py | 632 +++++++++++++++++++++++++++- tests/data_parsing/test_material.py | 312 ++++++++++++++ 2 files changed, 943 insertions(+), 1 deletion(-) create mode 100644 tests/data_parsing/test_material.py diff --git a/src/fatpy/data_parsing/material.py b/src/fatpy/data_parsing/material.py index de810c8..bff75ca 100644 --- a/src/fatpy/data_parsing/material.py +++ b/src/fatpy/data_parsing/material.py @@ -1 +1,631 @@ -"""Material properties parsing module.""" +"""Material properties parsing module. + +Container for the material properties used as input to FatPy fatigue methods. +The module only stores data: it does not check whether the parameters a +method needs are defined (each method does that itself) and it never +converts units. + +Parameter names follow the WG6 nomenclature file ``nomenclature_v01.xlsx``. + +Conventions: + - Units are fixed by the nomenclature (see `NOMENCLATURE`): stresses + and moduli in MPa (also E and G), lengths in mm, etc. Values must be + given in these units. + - Fatigue limits are stress amplitudes. + - A parameter that is not known is ``None``. + - WG6 keys ending in ``-1`` (e.g. ``fat_lim_ten_-1``) are not valid Python + names, so the attribute is spelled ``fat_lim_ten_m1`` instead. + `Material.from_dict` and `Material.describe` accept both spellings. + +Examples: + >>> steel = Material( + ... material_name="42CrMo4", + ... ult_tensile_strength=1100, + ... yield_strength=980, + ... fat_lim_ten_m1=488, + ... fat_lim_tor_m1=404, + ... ) + >>> steel.fat_lim_ten_m1 + 488 +""" + +import json +import math +from dataclasses import dataclass, field, fields +from pathlib import Path +from statistics import NormalDist +from typing import Any, Literal, Mapping + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +__all__ = ["Material", "SNCurve", "ParamSpec", "NOMENCLATURE", "LoadMode"] + + +# --------------------------------------------------------------------------- +# S-N curve +# --------------------------------------------------------------------------- + +LoadMode = Literal["ten", "ben", "rben", "tor", "tor_A", "tor_B"] + + +@dataclass +class SNCurve: + """One S-N curve of the material: σ_a^w · N = C, constant below the knee. + + A material can hold several curves (different load modes, stress ratios, + survival probabilities, specimens...). Give each one a clear + `description`. + + Attributes: + description: Free-text identifier (e.g. "push-pull, batch A, R=-1"). + fatigue_limit: Stress amplitude at the knee, in MPa. + exponent: Basquin exponent w (slope in log-log space is ``-1/w``). + n_cycles_at_limit: Number of cycles at the knee. + load_mode: Type of loading, one of `LoadMode`. + stress_ratio: Load ratio R = σ_min / σ_max. + survival_prob: Survival probability the curve represents (0.5 = + median curve). + stdev_log_stress: Scatter of ``log10(stress_amp)`` around the fit. + stress_amp: Raw stress-amplitude test points, in MPa. + n_cycles: Raw cycles-to-failure test points. + runout: ``True`` for a specimen that did not fail (ran out). + """ + + description: str + fatigue_limit: float + exponent: float + n_cycles_at_limit: float + load_mode: LoadMode = "ten" + stress_ratio: float = -1.0 + survival_prob: float = 0.5 + stdev_log_stress: float | None = None + # experimental points (optional, ignored by ==) + stress_amp: NDArray[np.float64] | None = field(default=None, compare=False) + n_cycles: NDArray[np.float64] | None = field(default=None, compare=False) + runout: NDArray[np.bool_] | None = field(default=None, compare=False) + + @classmethod + def from_test_points( + cls, + description: str, + stress_amp: ArrayLike, + n_cycles: ArrayLike, + runout: ArrayLike | None = None, + *, + n_cycles_at_limit: float = 1e7, + **kwargs: Any, + ) -> "SNCurve": + """Fit the exponent and fatigue limit from raw test points. + + Performs a least-squares fit in log-log space using only the failed + (non-runout) specimens. + + Args: + description: Free-text identifier for the resulting curve. + stress_amp: Stress amplitude of each specimen, in MPa. + n_cycles: Cycles to failure (or to runout) of each specimen. + runout: ``True`` for specimens that did not fail. Defaults to + treating every specimen as failed. + n_cycles_at_limit: Life, in cycles, at which the fatigue limit + is evaluated (the assumed knee position). + **kwargs: Extra fields forwarded to the `SNCurve` constructor + (e.g. `load_mode`, `stress_ratio`). + + Returns: + The fitted `SNCurve`. + + Raises: + ValueError: If fewer than two specimens failed, or if the fitted + curve does not decrease with life. + """ + s = np.asarray(stress_amp, dtype=float) + n = np.asarray(n_cycles, dtype=float) + ro = np.zeros_like(s, bool) if runout is None else np.asarray(runout, bool) + broken = ~ro + if broken.sum() < 2: + raise ValueError("At least two failed specimens are needed for the fit") + slope, intercept = np.polyfit(np.log10(n[broken]), np.log10(s[broken]), 1) + if slope >= 0: + raise ValueError( + "Stress does not decrease with life in the test points; " + "check the order of stress_amp and n_cycles" + ) + residuals = np.log10(s[broken]) - (intercept + slope * np.log10(n[broken])) + return cls( + description=description, + fatigue_limit=float( + 10 ** (intercept + slope * math.log10(n_cycles_at_limit)) + ), + exponent=float(-1.0 / slope), + n_cycles_at_limit=n_cycles_at_limit, + stdev_log_stress=( + float(np.std(residuals, ddof=1)) if broken.sum() > 2 else None + ), + stress_amp=s, + n_cycles=n, + runout=ro, + **kwargs, + ) + + @property + def coefficient(self) -> float: + """C in σ_a^w · N = C.""" + return float(self.fatigue_limit**self.exponent * self.n_cycles_at_limit) + + def stress_amp_at(self, n_cycles: float) -> float: + """Stress amplitude for a given life, flat at the fatigue limit beyond the knee. + + Args: + n_cycles: Target life, in cycles. + + Returns: + Stress amplitude, in MPa. + """ + n = max(n_cycles, 1.0) + if n >= self.n_cycles_at_limit: + return self.fatigue_limit + return float( + self.fatigue_limit * (self.n_cycles_at_limit / n) ** (1.0 / self.exponent) + ) + + def cycles_at(self, stress_amp: float) -> float: + """Life for a given stress amplitude, infinite at or below the fatigue limit. + + Args: + stress_amp: Stress amplitude, in MPa. + + Returns: + Life, in cycles (``math.inf`` at or below the fatigue limit). + """ + if stress_amp <= self.fatigue_limit: + return math.inf + return float( + self.n_cycles_at_limit * (self.fatigue_limit / stress_amp) ** self.exponent + ) + + def at_survival_prob(self, survival_prob: float) -> "SNCurve": + """Return the same curve shifted to another survival probability. + + Assumes a log-normal scatter of the stress amplitude with standard + deviation `stdev_log_stress` and the same slope. + + Args: + survival_prob: Target survival probability, strictly between 0 + and 1 (e.g. 0.975 for a design curve). + + Returns: + A new `SNCurve` (test points are not copied). + + Raises: + ValueError: If `stdev_log_stress` is not defined, or (from + `statistics.NormalDist`) if `survival_prob` is outside (0, 1). + """ + if self.stdev_log_stress is None: + raise ValueError(f"S-N curve '{self.description}' has no stdev_log_stress") + z = NormalDist().inv_cdf + shift = z(1.0 - survival_prob) - z(1.0 - self.survival_prob) + return SNCurve( + description=f"{self.description} (P_s = {survival_prob:g})", + fatigue_limit=self.fatigue_limit * 10 ** (shift * self.stdev_log_stress), + exponent=self.exponent, + n_cycles_at_limit=self.n_cycles_at_limit, + load_mode=self.load_mode, + stress_ratio=self.stress_ratio, + survival_prob=survival_prob, + stdev_log_stress=self.stdev_log_stress, + ) + + def to_dict(self) -> dict[str, Any]: + """All defined fields as plain Python values (arrays become lists).""" + out: dict[str, Any] = {} + for f in fields(self): + value = getattr(self, f.name) + if isinstance(value, np.ndarray): + value = value.tolist() + if value is not None: + out[f.name] = value + return out + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "SNCurve": + """Inverse of `to_dict`.""" + data = dict(data) + for name, dtype in ( + ("stress_amp", float), + ("n_cycles", float), + ("runout", bool), + ): + if data.get(name) is not None: + data[name] = np.asarray(data[name], dtype=dtype) + return cls(**data) + + +# --------------------------------------------------------------------------- +# Material +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ParamSpec: + """Nomenclature entry of a material parameter (one row of the WG6 file). + + Attributes: + key: Attribute name used on `Material`. + name: Human-readable parameter name. + symbol: Mathematical symbol (e.g. ``σ_y``). + unit: Physical unit the value must be given in (e.g. ``MPa``). + group: Section of the nomenclature the parameter belongs to. + wg6_key: Spelling used in the WG6 Excel file, if different from + ``key``. + proposed: ``True`` if ``key`` is not yet part of the Excel file. + """ + + key: str + name: str + symbol: str + unit: str + group: str + wg6_key: str | None = None + proposed: bool = False + + +def _param( + name: str, + symbol: str, + unit: str, + group: str, + wg6_key: str | None = None, + proposed: bool = False, +) -> Any: + """Optional `Material` field carrying its nomenclature entry as metadata.""" + spec = dict( + name=name, + symbol=symbol, + unit=unit, + group=group, + wg6_key=wg6_key, + proposed=proposed, + ) + return field(default=None, metadata={"spec": spec}) + + +@dataclass +class Material: + """Material data passed as input to FatPy fatigue methods. + + Fill in only what you know; everything else stays ``None``. Each + parameter's name, symbol and unit are listed in `NOMENCLATURE` (or + with `Material.describe`). + """ + + material_name: str + source: str | None = None # reference of the data (paper, DOI, test report) + # physical / elastic + density: float | None = _param("Density", "ρ", "kg/m³", "physical") + elastic_modulus: float | None = _param("Young's modulus", "E", "MPa", "elastic") + elastic_modulus_shear: float | None = _param( + "Elastic modulus in torsion", "G", "MPa", "elastic" + ) + poissons_ratio: float | None = _param("Poisson's ratio", "ν", "-", "elastic") + poissons_ratio_pl: float | None = _param( + "Poisson's ratio of plasticized material", "ν_pl", "-", "elastic" + ) + poissons_ratio_eff: float | None = _param( + "Effective Poisson's ratio", "ν_eff", "-", "elastic" + ) + thermal_expansion_coef: float | None = _param( + "Thermal expansion coefficient", "α", "1/°C", "physical" + ) + # static + yield_strength: float | None = _param("Yield strength", "σ_y", "MPa", "static") + yield_strain: float | None = _param("Yield strain", "ε_y", "-", "static") + ult_tensile_strength: float | None = _param( + "Ultimate tensile strength", "σ_UTS", "MPa", "static" + ) + fract_toughness: float | None = _param( + "Fracture toughness", "K_IC", "MPa√m", "static" + ) + thresh_sif_range: float | None = _param( + "Range of the threshold stress intensity factor", + "ΔK_th", + "MPa√m", + "static", + wg6_key="D_K_TH", + proposed=True, + ) + hardness: float | None = _param("Hardness", "HV/HB/HRC", "-", "static") + # fatigue limits (amplitudes) + fat_lim_ten_m1: float | None = _param( + "Fatigue limit in fully reversed push-pull", + "FL_ten,-1", + "MPa", + "fatigue limit", + wg6_key="fat_lim_ten_-1", + ) + fat_lim_ten_0: float | None = _param( + "Fatigue limit in repeated tension", "FL_ten,0", "MPa", "fatigue limit" + ) + fat_lim_ben_m1: float | None = _param( + "Fatigue limit in reversed bending", + "FL_ben,-1", + "MPa", + "fatigue limit", + wg6_key="fat_lim_ben_-1", + ) + fat_lim_ben_0: float | None = _param( + "Fatigue limit in repeated bending", "FL_ben,0", "MPa", "fatigue limit" + ) + fat_lim_rben_m1: float | None = _param( + "Fatigue limit in rotating bending", + "RBEND", + "MPa", + "fatigue limit", + wg6_key="RBEND", + proposed=True, + ) + fat_lim_tor_m1: float | None = _param( + "Fatigue limit in fully reversed torsion", + "FL_tor,-1", + "MPa", + "fatigue limit", + wg6_key="fat_lim_tor_-1", + ) + fat_lim_tor_0: float | None = _param( + "Fatigue limit in repeated torsion", "FL_tor,0", "MPa", "fatigue limit" + ) + fat_lim_tor_A_m1: float | None = _param( + "Fatigue limit in reversed torsion, mode A", + "FL_tor,A,-1", + "MPa", + "fatigue limit", + wg6_key="fat_lim_tor_A_-1", + ) + fat_lim_tor_B_m1: float | None = _param( + "Fatigue limit in reversed torsion, mode B", + "FL_tor,B,-1", + "MPa", + "fatigue limit", + wg6_key="fat_lim_tor_B-1", + ) + # cyclic / strain-life + fat_strength_coef: float | None = _param( + "Fatigue strength coefficient", "σ_f'", "MPa", "cyclic" + ) + fat_strength_exp: float | None = _param( + "Fatigue strength exponent", "b", "-", "cyclic" + ) + fat_ductility_coef: float | None = _param( + "Fatigue ductility coefficient", "ε_f'", "-", "cyclic" + ) + fat_ductility_exp: float | None = _param( + "Fatigue ductility exponent", "c", "-", "cyclic" + ) + fat_strength_coef_tor: float | None = _param( + "Fatigue strength coefficient in torsion", "τ_f'", "MPa", "cyclic" + ) + fat_strength_exp_tor: float | None = _param( + "Fatigue strength exponent in torsion", "b_t", "-", "cyclic" + ) + fat_ductility_coef_tor: float | None = _param( + "Fatigue ductility coefficient in torsion", "γ_f'", "-", "cyclic" + ) + fat_ductility_exp_tor: float | None = _param( + "Fatigue ductility exponent in torsion", "c_t", "-", "cyclic" + ) + cyc_hard_coef: float | None = _param( + "Cyclic hardening coefficient", "K'", "MPa", "cyclic", wg6_key="cyc_hard_coeff" + ) + cyc_hard_exp: float | None = _param( + "Cyclic hardening exponent", "n'", "-", "cyclic", wg6_key="cyc_hard_ext" + ) + eps_ac: float | None = _param("Non-damaging plastic strain", "ε_ac", "-", "cyclic") + # coefficients of specific methods + neuber_coef: float | None = _param( + "Neuber's coefficient", "neuber_coef", "-", "method" + ) + bergmann_coef: float | None = _param( + "Bergmann's coefficient", + "bergmann_coef", + "-", + "method", + wg6_key="bergmann_coeff", + ) + walker_exp: float | None = _param("Walker's exponent", "walker_exp", "-", "method") + socie_coef_ten: float | None = _param( + "Tensile coefficient in Socie's combined model", "socie_coef_ten", "-", "method" + ) + socie_coef_tor: float | None = _param( + "Shear coefficient in Socie's combined model", "socie_coef_tor", "-", "method" + ) + WB_coef: float | None = _param( + "Wang's and Brown's coefficient", "S_WB", "-", "method" + ) + socie_findley_coef: float | None = _param( + "Coefficient in Socie's proposal of Findley", + "C_FIN", + "-", + "method", + wg6_key="C_FIN", + proposed=True, + ) + char_length: float | None = _param( + "Material characteristic length", + "L_CHAR", + "mm", + "method", + wg6_key="L_CHAR", + proposed=True, + ) + # microstructure + grain_size: float | None = _param("Grain size", "d", "µm", "microstructure") + precipitate_size: float | None = _param( + "Precipitate size", "d_precip", "nm", "microstructure" + ) + precipitate_vol_frac: float | None = _param( + "Precipitate volume fraction", "V_precip", "%", "microstructure" + ) + inclusion_size: float | None = _param( + "Inclusion size", "d_incl", "µm", "microstructure" + ) + inclusion_density: float | None = _param( + "Inclusion density", "ρ_incl", "1/mm³", "microstructure" + ) + disloc_density: float | None = _param( + "Dislocation density", "ρ_dislocation", "m⁻²", "microstructure" + ) + stacking_fault_energy: float | None = _param( + "Stacking fault energy", "SFE", "mJ/m²", "microstructure" + ) + res_stress_micro: float | None = _param( + "Residual stresses (micro-scale)", "σ_res_micro", "MPa", "microstructure" + ) + # S-N curves and parameters outside the nomenclature + sn_curves: list[SNCurve] = field(default_factory=list) + extra: dict[str, Any] = field(default_factory=dict) + + def __repr__(self) -> str: + """Compact representation showing only the defined parameters.""" + items = [f"material_name={self.material_name!r}"] + items += [ + f"{k}={getattr(self, k)!r}" + for k in NOMENCLATURE + if getattr(self, k) is not None + ] + if self.sn_curves: + items.append(f"sn_curves=<{len(self.sn_curves)}>") + if self.extra: + items.append(f"extra={self.extra!r}") + return f"Material({', '.join(items)})" + + @staticmethod + def describe(key: str) -> ParamSpec: + """Name, symbol and unit of a parameter. + + Args: + key: Attribute name or WG6 spelling. + + Returns: + The `ParamSpec` describing `key`. + + Raises: + KeyError: If `key` is not in the nomenclature. + """ + return NOMENCLATURE[_ALIASES.get(key, key)] + + def sn_curve( + self, load_mode: LoadMode, stress_ratio: float = -1.0 + ) -> SNCurve | None: + """First S-N curve matching a load mode and stress ratio. + + Args: + load_mode: One of `LoadMode`. + stress_ratio: Load ratio R = σ_min / σ_max. + + Returns: + The matching `SNCurve`, or ``None`` if there isn't one. + """ + for curve in self.sn_curves: + if curve.load_mode == load_mode and curve.stress_ratio == stress_ratio: + return curve + return None + + # -- dictionaries / files ------------------------------------------------- + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Material": + """Create a material from a dict with WG6 keys. + + ``None`` values are skipped and keys that are not in the nomenclature + are kept in `extra`. + + Args: + data: Mapping of WG6 keys (or attribute names) to values, e.g. + one row of a materials table. Must contain ``material_name``. + + Returns: + The resulting `Material`. + """ + known = {f.name for f in fields(cls)} - {"extra"} + kwargs: dict[str, Any] = {} + extra: dict[str, Any] = {} + for raw_key, value in data.items(): + if value is None: + continue + key = _ALIASES.get(raw_key, raw_key) + if key == "sn_curves": + value = [ + c if isinstance(c, SNCurve) else SNCurve.from_dict(c) for c in value + ] + if key in known: + kwargs[key] = value + else: + extra[raw_key] = value + return cls(**kwargs, extra=extra) + + def to_dict(self, include_sn_curves: bool = False) -> dict[str, Any]: + """All defined parameters with their WG6 keys. + + Args: + include_sn_curves: Also export the S-N curves. + + Returns: + Mapping from WG6 key to value. + """ + out: dict[str, Any] = {"material_name": self.material_name} + if self.source: + out["source"] = self.source + for key, spec in NOMENCLATURE.items(): + value = getattr(self, key) + if value is not None: + out[spec.wg6_key if spec.wg6_key and not spec.proposed else key] = value + out.update(self.extra) + if include_sn_curves and self.sn_curves: + out["sn_curves"] = [c.to_dict() for c in self.sn_curves] + return out + + def to_json(self, path: str | Path) -> None: + """Save the material, S-N curves included, to a JSON file.""" + text = json.dumps( + self.to_dict(include_sn_curves=True), indent=2, ensure_ascii=False + ) + Path(path).write_text(text, encoding="utf-8") + + @classmethod + def from_json(cls, path: str | Path) -> "Material": + """Load a material saved with `to_json`.""" + return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8"))) + + def summary(self) -> str: + """Readable table of the defined parameters, grouped as in the nomenclature.""" + header = f"Material: {self.material_name}" + if self.source: + header += f" ({self.source})" + lines = [header] + group = None + for key, spec in NOMENCLATURE.items(): + value = getattr(self, key) + if value is None: + continue + if spec.group != group: + group = spec.group + lines.append(f" [{group}]") + lines.append( + f" {spec.name:<45} {spec.symbol:<12} = {value:g} {spec.unit}" + ) + for c in self.sn_curves: + lines.append( + f" [S-N curve] {c.description}: {c.load_mode}, R={c.stress_ratio:g}, " + f"FL={c.fatigue_limit:g}, w={c.exponent:g}, N_k={c.n_cycles_at_limit:g}" + ) + return "\n".join(lines) + + +#: Every material parameter, keyed by attribute name (built from the fields). +NOMENCLATURE: dict[str, ParamSpec] = { + f.name: ParamSpec(key=f.name, **f.metadata["spec"]) + for f in fields(Material) + if "spec" in f.metadata +} +_ALIASES: dict[str, str] = { + s.wg6_key: s.key for s in NOMENCLATURE.values() if s.wg6_key is not None +} diff --git a/tests/data_parsing/test_material.py b/tests/data_parsing/test_material.py new file mode 100644 index 0000000..a847852 --- /dev/null +++ b/tests/data_parsing/test_material.py @@ -0,0 +1,312 @@ +"""Test functions for the material data model. + +Covers the `SNCurve` class (regression fit, stress/life conversions, +survival probability shift, dict round trips) and the nomenclature-driven +`Material` container (WG6 aliases, dict/JSON round trips). +""" + +from dataclasses import fields +from pathlib import Path +from statistics import NormalDist + +import numpy as np +import pytest + +from fatpy.data_parsing.material import NOMENCLATURE, Material, SNCurve + +# =========================================================================== +# SNCurve +# =========================================================================== + + +@pytest.fixture +def curve() -> SNCurve: + """A fully defined torsion curve with known scatter.""" + return SNCurve( + description="median", + fatigue_limit=300.0, + exponent=5.0, + n_cycles_at_limit=1e6, + load_mode="tor", + stdev_log_stress=0.1, + ) + + +def test_sncurve_requires_its_parameters() -> None: + """A curve cannot be built without fatigue limit, exponent and knee.""" + with pytest.raises(TypeError): + SNCurve(description="incomplete") # type: ignore[call-arg] + + +def test_from_test_points_fits_exact_power_law() -> None: + """Two points with no scatter are fit exactly (w=2, FL=10 at N=1e6).""" + fitted = SNCurve.from_test_points( + "synthetic", + stress_amp=[1000.0, 100.0], + n_cycles=[100.0, 10_000.0], + n_cycles_at_limit=1e6, + ) + assert fitted.exponent == pytest.approx(2.0) + assert fitted.fatigue_limit == pytest.approx(10.0) + assert fitted.n_cycles_at_limit == 1e6 + assert fitted.stdev_log_stress is None + assert fitted.stress_amp_at(100.0) == pytest.approx(1000.0) + assert fitted.cycles_at(1000.0) == pytest.approx(100.0) + assert fitted.stress_amp_at(1e6) == pytest.approx(fitted.fatigue_limit) + assert fitted.coefficient == pytest.approx(1e8) + + +def test_from_test_points_ignores_runouts() -> None: + """A runout far off the trend line does not affect the regression.""" + fitted = SNCurve.from_test_points( + "with runout", + stress_amp=[1000.0, 100.0, 5000.0], + n_cycles=[100.0, 10_000.0, 50.0], + runout=[False, False, True], + n_cycles_at_limit=1e6, + ) + assert fitted.exponent == pytest.approx(2.0) + assert fitted.fatigue_limit == pytest.approx(10.0) + + +def test_from_test_points_forwards_extra_fields() -> None: + """Keyword arguments such as `load_mode` reach the constructor.""" + fitted = SNCurve.from_test_points( + "tor", [1000.0, 100.0], [100.0, 10_000.0], load_mode="tor" + ) + assert fitted.load_mode == "tor" + + +def test_from_test_points_requires_two_failures() -> None: + """Fitting needs at least two broken (non-runout) specimens.""" + with pytest.raises(ValueError, match="two failed"): + SNCurve.from_test_points("bad", stress_amp=[1000.0], n_cycles=[100.0]) + with pytest.raises(ValueError, match="two failed"): + SNCurve.from_test_points( + "bad", + stress_amp=[1000.0, 100.0], + n_cycles=[100.0, 10_000.0], + runout=[True, False], + ) + + +def test_from_test_points_rejects_increasing_stress() -> None: + """A non-decreasing stress-vs-life trend is refused, not silently fit.""" + with pytest.raises(ValueError, match="does not decrease"): + SNCurve.from_test_points( + "wrong order", stress_amp=[100.0, 1000.0], n_cycles=[100.0, 10_000.0] + ) + + +def test_stress_amp_and_cycles_are_inverse(curve: SNCurve) -> None: + """`cycles_at` and `stress_amp_at` invert each other above the limit.""" + n = curve.cycles_at(450.0) + assert curve.stress_amp_at(n) == pytest.approx(450.0) + + +def test_below_fatigue_limit_is_infinite_life(curve: SNCurve) -> None: + """At or below the fatigue limit the life is infinite.""" + assert curve.cycles_at(300.0) == float("inf") + assert curve.stress_amp_at(1e9) == 300.0 + + +def test_at_survival_prob_unchanged_at_same_probability(curve: SNCurve) -> None: + """Asking for the curve's own survival probability changes nothing.""" + assert curve.at_survival_prob(0.5).fatigue_limit == pytest.approx(300.0) + + +def test_at_survival_prob_matches_lognormal_formula(curve: SNCurve) -> None: + """A higher (safer) survival probability lowers the fatigue limit.""" + z = NormalDist().inv_cdf + expected = 300.0 * 10 ** ((z(1.0 - 0.975) - z(0.5)) * 0.1) + design = curve.at_survival_prob(0.975) + assert design.fatigue_limit == pytest.approx(expected) + assert design.fatigue_limit < curve.fatigue_limit + assert curve.at_survival_prob(0.1).fatigue_limit > curve.fatigue_limit + + +def test_at_survival_prob_requires_stdev(curve: SNCurve) -> None: + """`at_survival_prob` refuses a curve with no scatter information.""" + curve.stdev_log_stress = None + with pytest.raises(ValueError, match="stdev_log_stress"): + curve.at_survival_prob(0.975) + + +@pytest.mark.parametrize("survival_prob", [0.0, 1.0]) +def test_at_survival_prob_out_of_range_raises( + curve: SNCurve, survival_prob: float +) -> None: + """Probabilities outside (0, 1) are rejected (by `NormalDist`).""" + with pytest.raises(ValueError): + curve.at_survival_prob(survival_prob) + + +def test_sncurve_dict_round_trip_with_arrays() -> None: + """Arrays become lists in `to_dict` and are rebuilt by `from_dict`.""" + original = SNCurve( + description="c1", + fatigue_limit=300.0, + exponent=5.0, + n_cycles_at_limit=1e6, + stress_amp=np.array([300.0, 250.0]), + n_cycles=np.array([1e6, 5e6]), + runout=np.array([False, True]), + ) + data = original.to_dict() + assert data["stress_amp"] == [300.0, 250.0] + assert data["runout"] == [False, True] + assert "stdev_log_stress" not in data + rebuilt = SNCurve.from_dict(data) + assert rebuilt == original + assert rebuilt.runout is not None and rebuilt.runout.dtype == bool + + +def test_sncurve_equality_ignores_test_points(curve: SNCurve) -> None: + """`==` compares the curve parameters only, never raising on arrays.""" + with_points = SNCurve.from_dict(curve.to_dict() | {"stress_amp": [500.0, 400.0]}) + assert with_points == curve + + +# =========================================================================== +# Material +# =========================================================================== + + +@pytest.fixture +def steel() -> Material: + """A partially filled-in `Material`; everything else stays ``None``.""" + return Material( + material_name="42CrMo4", + source="unit test", + elastic_modulus=210_000.0, + yield_strength=980.0, + fat_lim_ten_m1=488.0, + fat_lim_tor_m1=404.0, + ) + + +def test_unset_parameters_are_none(steel: Material) -> None: + """No check at creation: unknown parameters simply stay ``None``.""" + assert steel.hardness is None + assert Material(material_name="empty").fat_lim_ten_m1 is None + + +def test_nomenclature_covers_every_parameter_field() -> None: + """Every field except identity/containers has a nomenclature entry.""" + others = {"material_name", "source", "sn_curves", "extra"} + assert set(NOMENCLATURE) == {f.name for f in fields(Material)} - others + + +def test_describe_accepts_wg6_alias() -> None: + """`describe` resolves the WG6 ``-1`` spelling to the attribute.""" + spec = Material.describe("fat_lim_ten_-1") + assert spec.key == "fat_lim_ten_m1" + assert spec.symbol == "FL_ten,-1" + assert spec.unit == "MPa" + + +def test_describe_unknown_key_raises() -> None: + """A key outside the nomenclature raises `KeyError`.""" + with pytest.raises(KeyError): + Material.describe("hardnes") + + +def test_describe_marks_new_entries_as_proposed() -> None: + """Rows not yet in the WG6 Excel file are flagged `proposed`.""" + for key in ("socie_findley_coef", "char_length", "thresh_sif_range"): + assert Material.describe(key).proposed is True + + +def test_from_dict_normalizes_aliases_and_keeps_extra() -> None: + """`from_dict` maps WG6 keys to attributes and keeps unknown ones.""" + material = Material.from_dict( + { + "material_name": "S355", + "fat_lim_ten_-1": 250.0, + "cyc_hard_coeff": 1100.0, + "hardness": None, + "unknown_param": 1.23, + } + ) + assert material.fat_lim_ten_m1 == 250.0 + assert material.cyc_hard_coef == 1100.0 + assert material.hardness is None + assert material.extra == {"unknown_param": 1.23} + + +def test_from_dict_without_material_name_raises() -> None: + """``material_name`` is the only required field.""" + with pytest.raises(TypeError): + Material.from_dict({"fat_lim_ten_-1": 250.0}) + + +def test_to_dict_uses_wg6_spelling_and_skips_none(steel: Material) -> None: + """`to_dict` uses WG6 spellings and omits undefined parameters.""" + data = steel.to_dict() + assert data["fat_lim_ten_-1"] == 488.0 + assert "hardness" not in data + + +def test_to_dict_uses_plain_key_for_proposed_parameters() -> None: + """A ``proposed`` nomenclature entry keeps its Python spelling.""" + data = Material(material_name="S355", char_length=12.5).to_dict() + assert data["char_length"] == 12.5 + assert "L_CHAR" not in data + + +def test_to_dict_excludes_sn_curves_by_default(steel: Material, curve: SNCurve) -> None: + """`to_dict` omits S-N curves unless `include_sn_curves` is set.""" + steel.sn_curves.append(curve) + assert "sn_curves" not in steel.to_dict() + assert "sn_curves" in steel.to_dict(include_sn_curves=True) + + +def test_dict_round_trip(steel: Material, curve: SNCurve) -> None: + """A material, curves included, survives `to_dict` / `from_dict`.""" + steel.sn_curves.append(curve) + assert Material.from_dict(steel.to_dict(include_sn_curves=True)) == steel + + +def test_from_dict_accepts_existing_sn_curve_instances(curve: SNCurve) -> None: + """`from_dict` passes through `SNCurve` objects given directly.""" + material = Material.from_dict({"material_name": "S355", "sn_curves": [curve]}) + assert material.sn_curves == [curve] + + +def test_json_round_trip(steel: Material, curve: SNCurve, tmp_path: Path) -> None: + """A material (with an S-N curve) survives a JSON round trip.""" + steel.sn_curves.append(curve) + path = tmp_path / "material.json" + steel.to_json(path) + assert Material.from_json(path) == steel + + +def test_sn_curve_lookup(steel: Material, curve: SNCurve) -> None: + """`sn_curve` finds a curve by load mode and stress ratio.""" + steel.sn_curves.append(curve) + assert steel.sn_curve("tor") is curve + assert steel.sn_curve("tor", stress_ratio=0.0) is None + assert steel.sn_curve("ten") is None + + +def test_summary_lists_name_parameters_and_curves( + steel: Material, curve: SNCurve +) -> None: + """`summary` shows the name, defined parameters (with units) and curves.""" + steel.sn_curves.append(curve) + text = steel.summary() + assert "42CrMo4" in text + assert "Young's modulus" in text and "MPa" in text + assert "[S-N curve] median" in text + assert "Hardness" not in text + + +def test_repr_shows_only_defined_values(steel: Material, curve: SNCurve) -> None: + """`__repr__` shows set parameters, curve count and extras only.""" + steel.sn_curves.append(curve) + steel.extra["custom"] = 1.0 + text = repr(steel) + assert "elastic_modulus=210000.0" in text + assert "hardness" not in text + assert "sn_curves=<1>" in text + assert "extra={'custom': 1.0}" in text