From e8920b6125e3c85aa50b0d4f439e6f3b017fde76 Mon Sep 17 00:00:00 2001 From: Mark Messner Date: Wed, 25 Jun 2025 09:59:34 -0400 Subject: [PATCH 1/4] Fatigue works --- examples/A617-fatigue.csv | 81 +++++++++++++++++++ examples/fatigue-demo.py | 32 ++++++++ pycreep/fatigue.py | 161 ++++++++++++++++++++++++++++++++++++++ pycreep/methods.py | 36 +++++++++ 4 files changed, 310 insertions(+) create mode 100644 examples/A617-fatigue.csv create mode 100644 examples/fatigue-demo.py create mode 100644 pycreep/fatigue.py diff --git a/examples/A617-fatigue.csv b/examples/A617-fatigue.csv new file mode 100644 index 0000000..9dbd866 --- /dev/null +++ b/examples/A617-fatigue.csv @@ -0,0 +1,81 @@ +Strain range,R,Temp (C),Heat/Lot ID,Cycles +0.036450961,-1,850,Yukawa,75.21741756 +0.014954006,-1,850,Yukawa,138.3973613 +0.026822562,-1,850,Yukawa,129.7052657 +0.017588035,-1,850,Yukawa,205.2729761 +0.014231616,-1,850,Yukawa,376.3218742 +0.009863814,-1,850,Yukawa,341.5998648 +0.009867647,-1,850,Yukawa,280.3663584 +0.009016596,-1,850,Yukawa,734.7955132 +0.008029606,-1,850,Yukawa,929.6364334 +0.006958316,-1,850,Yukawa,1235.864882 +0.006635537,-1,850,Yukawa,1206.028962 +0.004950047,-1,850,Yukawa,1979.326069 +0.00511943,-1,850,Yukawa,2323.507551 +0.004174646,-1,850,Yukawa,2631.712654 +0.003951646,-1,850,Yukawa,3496.960304 +0.004225379,-1,850,Yukawa,5657.831618 +0.003687669,-1,850,Yukawa,6485.570953 +0.003001571,-1,850,Yukawa,18773.86086 +0.002978575,-1,850,Yukawa,29645.78296 +0.002976696,-1,850,Yukawa,40867.1717 +0.002295122,-1,850,Yukawa,108535.6949 +0.002137544,-1,850,Yukawa,554004.1577 +0.003020836,-1,850,Yukawa,725554.5398 +0.028706352,-1,850,INL,132.8995194 +0.029490534,-1,850,INL,148.4971176 +0.019740827,-1,850,INL,205.1459222 +0.019327614,-1,850,INL,304.5747656 +0.009647737,-1,850,INL,841.3761172 +0.005869328,-1,850,INL,1507.156003 +0.005827332,-1,850,INL,1836.393784 +0.003951646,-1,850,INL,3496.960304 +0.003922324,-1,850,INL,4880.678088 +0.002945171,-1,850,INL,9174.994437 +0.002964452,-1,850,INL,10509.25301 +0.00985089,-1,850,Totemeier,665.3727018 +0.009845151,-1,850,Totemeier,894.8549879 +0.002958412,-1,850,Totemeier,29646.86268 +0.023283702,-1,950,Yukawa,183.7683065 +0.020655176,-1,950,Yukawa,192.9355521 +0.0201126,-1,950,Yukawa,163.504856 +0.0201126,-1,950,Yukawa,293.2448839 +0.015514988,-1,950,Yukawa,349.4140633 +0.010133987,-1,950,Yukawa,258.381941 +0.010133987,-1,950,Yukawa,441.3877705 +0.010133987,-1,950,Yukawa,608.6325567 +0.007169541,-1,950,Yukawa,645.2457587 +0.006152014,-1,950,Yukawa,684.0614828 +0.006111209,-1,950,Yukawa,491.2832121 +0.006234443,-1,950,Yukawa,1000 +0.006276071,-1,950,Yukawa,1976.89265 +0.005174553,-1,950,Yukawa,1595.731547 +0.006360162,-1,950,Yukawa,3580.23229 +0.004323527,-1,950,Yukawa,2157.933489 +0.004209955,-1,950,Yukawa,2521.687885 +0.00429485,-1,950,Yukawa,3908.104549 +0.004238065,-1,950,Yukawa,7954.891298 +0.003038498,-1,950,Yukawa,4392.442966 +0.002024689,-1,950,Yukawa,7954.891298 +0.002423221,-1,950,Yukawa,12090.72745 +0.003058786,-1,950,Yukawa,8270.800945 +0.001894334,-1,950,Yukawa,92506.73683 +0.001647263,-1,950,Yukawa,714698.0902 +0.020382082,-1,950,INL,392.7175758 +0.019979197,-1,950,INL,356.2845912 +0.019979197,-1,950,INL,296.113886 +0.010066771,-1,950,INL,651.5586104 +0.01,-1,950,INL,677.4337158 +0.010066771,-1,950,INL,864.1219815 +0.010066771,-1,950,INL,907.2285356 +0.00603041,-1,950,INL,1433.666717 +0.006070675,-1,950,INL,1741.866229 +0.003938907,-1,950,INL,3004.701048 +0.003058786,-1,950,INL,5825.426077 +0.002978437,-1,950,INL,6941.249131 +0.003038498,-1,950,INL,9028.228389 +0.002521936,-1,950,INL,16835.09784 +0.002038208,-1,950,INL,89843.85372 +0.010201652,-1,950,Totemeier,510.7933601 +0.01,-1,950,Totemeier,690.7540934 +0.003018344,-1,950,Totemeier,13197.97798 \ No newline at end of file diff --git a/examples/fatigue-demo.py b/examples/fatigue-demo.py new file mode 100644 index 0000000..c181062 --- /dev/null +++ b/examples/fatigue-demo.py @@ -0,0 +1,32 @@ +import pandas as pd +from pycreep import fatigue +import numpy as np + +import matplotlib.pyplot as plt + +if __name__ == "__main__": + data = pd.read_csv("/Users/messner/Work/Projects/A617-fatigue/A617-fatigue.csv") + + fatigue_analysis = fatigue.LumpedTemperatureFatigueAnalysis( + fatigue.DiercksEquation(4), [850, 950], data + ).analyze() + + for T, inds in fatigue_analysis.temperature_groups.items(): + (l,) = plt.loglog( + fatigue_analysis.cycles[inds], + fatigue_analysis.strain_range[inds], + "o", + label=f"T={T}C", + ) + erange = np.logspace( + np.log10(fatigue_analysis.strain_range[inds].min()), + np.log10(fatigue_analysis.strain_range[inds].max()), + 100, + ) + pred = fatigue_analysis.predict(np.full_like(erange, T), erange) + plt.loglog(pred, erange, ls="--", color=l.get_color(), label="Prediction") + + plt.xlabel("Cycles") + plt.ylabel("Strain Range") + plt.legend(loc="best") + plt.show() diff --git a/pycreep/fatigue.py b/pycreep/fatigue.py new file mode 100644 index 0000000..d6162f3 --- /dev/null +++ b/pycreep/fatigue.py @@ -0,0 +1,161 @@ +"""Correlate fatigue data into fatigue curves""" + +import numpy as np + +from pycreep import methods, dataset + + +class FatigueAnalysis(dataset.DataSet): + """ + Superclass for analysis of fatigue data + + Args: + data: dataset as a pandas dataframe + + Keyword Args: + cycles_field (str): field in array giving cycles to use, default is + "Cycles" + temp_field (str): field in array giving temperature, default + is "Temp (C)" + strain_range_field (str): field in array giving strain range, default is + "Strain range" + r_ratio_field (str): field in array giving the R ratio, default is "R" + heat_field (str): filed in array giving heat ID, default is + "Heat/Lot ID" + input_temp_units (str): temperature units, default is "C" + analysis_temp_units (str): temperature units for analysis, + default is "C" + """ + + def __init__( + self, + data, + cycles_field="Cycles", + temp_field="Temp (C)", + strain_range_field="Strain range", + r_ratio_field="R", + heat_field="Heat/Lot ID", + input_temp_units="degC", + analysis_temp_units="degC", + ): + super().__init__(data) + + self.add_field_units("cycles", cycles_field, "", "") + self.add_field_units( + "temperature", temp_field, input_temp_units, analysis_temp_units + ) + self.add_field_units("strain_range", strain_range_field, "", "") + self.add_field_units("r", r_ratio_field, "", "") + self.add_heat_field(heat_field) + + self.analysis_temp_units = analysis_temp_units + + +class LumpedTemperatureFatigueAnalysis(FatigueAnalysis): + """ + Fatigue analysis binning data by temperature + + Args: + method: method to use to correlate strain range to cycles + temperature_bins: list of temperature bins to use for analysis + data: dataset as a pandas dataframe + + Keyword Args: + temperature_range (float): range of temperatures on either side of the bins to collect, default 50 + cycles_field (str): field in array giving cycles to use, default is + "Cycles" + temp_field (str): field in array giving temperature, default + is "Temp (C)" + strain_range_field (str): field in array giving strain range, default is + "Strain range" + r_ratio_field (str): field in array giving the R ratio, default is "R" + heat_field (str): filed in array giving heat ID, default is + "Heat/Lot ID" + input_temp_units (str): temperature units, default is "C" + analysis_temp_units (str): temperature units for analysis, + default is "C" + """ + + def __init__( + self, method, temperature_bins, *args, temperature_range=50.0, **kwargs + ): + super().__init__(*args, **kwargs) + + self.method = method + self.temperature_bins = sorted(temperature_bins) + self.temperature_range = temperature_range + self.fields["temperature_groups"] = lambda self: { + T: np.where( + np.logical_and( + self.temperature < T + self.temperature_range, + self.temperature > T - self.temperature_range, + ) + )[0] + for T in self.temperature_bins + } + + def analyze(self): + """ + Analyze by fitting the methods to the data + """ + self.submodels = { + T: self.method(self.strain_range[inds], self.cycles[inds]) + for T, inds in self.temperature_groups.items() + } + return self + + def predict(self, temperature, erange): + """ + Predict the number of cycles to failure given the temperature and strain range + + Args: + temperature (array like): temperature values to predict for + erange (array like): strain range values to predict for + """ + preds = np.zeros_like(erange) + + for i, (T, de) in enumerate(zip(temperature, erange)): + mi = methods.find_nearest_index(self.temperature_bins, T) + preds[i] = self.submodels[self.temperature_bins[mi]].predict(de) + + return preds + + +class DiercksEquation: + """ + Diercks method: + + 1/sqrt(log10(Nf)) = p(log10(strain_range)) + + Args: + order (int): polynomial order to use for the regression + """ + + def __init__(self, order): + self.order = order + + def __call__(self, strain_range, cycles): + lr = np.log10(strain_range) + lc = 1.0 / np.sqrt(np.log10(cycles)) + return DiercksFit(np.polyfit(lr, lc, self.order)) + + +class DiercksFit: + """ + Actual method to predict fatigue with a Diercks equation. + + Args: + p (np.array): polynomial coefficients for the Diercks equation + """ + + def __init__(self, p): + self.p = p + + def predict(self, strain_range): + """ + Predict the number of cycles to failure given the strain range + Args: + strain_range (array like): strain range values to predict for + """ + A = np.polyval(self.p, np.log10(strain_range)) + return 10 ** ((1 / A) ** 2) diff --git a/pycreep/methods.py b/pycreep/methods.py index d4c219e..4f71d45 100644 --- a/pycreep/methods.py +++ b/pycreep/methods.py @@ -2,6 +2,8 @@ Mathematical helper functions used in multiple modules """ +import bisect + import numpy as np import numpy.linalg as la import scipy.optimize as opt @@ -120,3 +122,37 @@ def asme_tensile_analysis(T, R, order, Tref=21.0): p = np.concatenate((p, [1.0])) # Add the constant term return p, R2 + + +def find_nearest_index(sorted_list, target): + """ + Finds the index of the element closest to the target in a sorted list. + + Args: + sorted_list (list): A list of numbers sorted in ascending order. + target (int or float): The value to find the nearest element to. + + Returns: + int: The index of the nearest element in the sorted list. + """ + # Find the insertion point for the target + # bisect_left returns an index where the target could be inserted + # to maintain sorted order, and all elements to its left are < target. + # bisect_right returns an index where the target could be inserted, + # and all elements to its left are <= target. + # For finding the *nearest* value, bisect_left is often preferred as a starting point. + idx = bisect.bisect_left(sorted_list, target) + + if idx == 0: # Target is smaller than or equal to the first element + return 0 + if idx == len(sorted_list): # Target is larger than the last element + return len(sorted_list) - 1 + + # Compare the element at 'idx' and the element before it ('idx-1') + # to find which is closer to the target. + before = sorted_list[idx - 1] + after = sorted_list[idx] + + if abs(target - before) <= abs(target - after): + return idx - 1 + return idx From 6f76622b21fc6288dadb774ec5282d19c3cce2aa Mon Sep 17 00:00:00 2001 From: Mark Messner Date: Wed, 25 Jun 2025 11:14:43 -0400 Subject: [PATCH 2/4] Linting --- pycreep/fatigue.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pycreep/fatigue.py b/pycreep/fatigue.py index d6162f3..b55b79a 100644 --- a/pycreep/fatigue.py +++ b/pycreep/fatigue.py @@ -1,3 +1,4 @@ +# pylint: disable=too-few-public-methods """Correlate fatigue data into fatigue curves""" import numpy as np @@ -61,7 +62,8 @@ class LumpedTemperatureFatigueAnalysis(FatigueAnalysis): data: dataset as a pandas dataframe Keyword Args: - temperature_range (float): range of temperatures on either side of the bins to collect, default 50 + temperature_range (float): range of temperatures on either side of the bins + to collect, default 50 cycles_field (str): field in array giving cycles to use, default is "Cycles" temp_field (str): field in array giving temperature, default From 915fee39ae1922c55c63a74c818da89958120d5b Mon Sep 17 00:00:00 2001 From: Mark Messner Date: Wed, 25 Jun 2025 11:22:11 -0400 Subject: [PATCH 3/4] Update pycreep/fatigue.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pycreep/fatigue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pycreep/fatigue.py b/pycreep/fatigue.py index b55b79a..fbe01ed 100644 --- a/pycreep/fatigue.py +++ b/pycreep/fatigue.py @@ -21,7 +21,7 @@ class FatigueAnalysis(dataset.DataSet): strain_range_field (str): field in array giving strain range, default is "Strain range" r_ratio_field (str): field in array giving the R ratio, default is "R" - heat_field (str): filed in array giving heat ID, default is + heat_field (str): field in array giving heat ID, default is "Heat/Lot ID" input_temp_units (str): temperature units, default is "C" analysis_temp_units (str): temperature units for analysis, From e8d09cc3c7146f1026a0f0d84cdfbd1adc6493c8 Mon Sep 17 00:00:00 2001 From: Mark Messner Date: Wed, 25 Jun 2025 11:25:46 -0400 Subject: [PATCH 4/4] Reviewer fixes --- examples/fatigue-demo.py | 2 +- pycreep/fatigue.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/fatigue-demo.py b/examples/fatigue-demo.py index c181062..4ce3d3c 100644 --- a/examples/fatigue-demo.py +++ b/examples/fatigue-demo.py @@ -5,7 +5,7 @@ import matplotlib.pyplot as plt if __name__ == "__main__": - data = pd.read_csv("/Users/messner/Work/Projects/A617-fatigue/A617-fatigue.csv") + data = pd.read_csv("A617-fatigue.csv") fatigue_analysis = fatigue.LumpedTemperatureFatigueAnalysis( fatigue.DiercksEquation(4), [850, 950], data diff --git a/pycreep/fatigue.py b/pycreep/fatigue.py index fbe01ed..b1c987e 100644 --- a/pycreep/fatigue.py +++ b/pycreep/fatigue.py @@ -96,6 +96,10 @@ def __init__( for T in self.temperature_bins } + for T, inds in self.temperature_groups.items(): + if len(inds) == 0: + raise ValueError(f"No data found for temperature {T} C!") + def analyze(self): """ Analyze by fitting the methods to the data