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..4ce3d3c --- /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("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..b1c987e --- /dev/null +++ b/pycreep/fatigue.py @@ -0,0 +1,167 @@ +# pylint: disable=too-few-public-methods +"""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): 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, + 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 + } + + 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 + """ + 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