NYC

fatigue-analysis

SKILL.md

Fatigue Analysis SME Skill

Comprehensive fatigue analysis expertise for offshore structures including mooring lines, risers, and structural components using industry-standard methods and DNV regulations.

When to Use This Skill

Use fatigue analysis when:

  • Mooring line fatigue - Calculate fatigue life of mooring components
  • Riser fatigue - Analyze fatigue damage in flexible and rigid risers
  • Structural fatigue - Assess fatigue in hull, joints, connections
  • S-N curve analysis - Apply appropriate fatigue curves
  • Rainflow counting - Process stress/load time series
  • Miner's rule - Cumulative damage calculation
  • Fatigue design - Size components for target life

Core Knowledge Areas

1. S-N Curve Fundamentals

S-N Curve Equation:

N = a / (Δσ)^m

Where:
- N = Number of cycles to failure
- Δσ = Stress range
- a = S-N curve constant
- m = Slope of S-N curve (typically 3 for steel, 3-5 for welds)

DNV S-N Curves:

import numpy as np

def get_dnv_sn_curve(
    curve_class: str,
    thickness: float = 25
) -> dict:
    """
    Get DNV S-N curve parameters.

    DNV-RP-C203 S-N curves:
    - B1: High strength welds, machined
    - C: Good quality welds
    - D: Normal welds
    - E: Rough welds
    - F, F1, F3: Poor quality, notches
    - G: Severe notches
    - W1, W2, W3: Seawater with cathodic protection

    Args:
        curve_class: DNV curve classification
        thickness: Plate thickness (mm) for thickness effect

    Returns:
        S-N curve parameters
    """
    # DNV-RP-C203 Table 2-1
    sn_curves = {
        'B1': {'log_a1': 15.117, 'm1': 4.0, 'log_a2': 17.146, 'm2': 5.0},
        'B2': {'log_a1': 14.885, 'm1': 4.0, 'log_a2': 16.856, 'm2': 5.0},
        'C':  {'log_a1': 12.592, 'm1': 3.0, 'log_a2': 16.320, 'm2': 5.0},
        'C1': {'log_a1': 12.449, 'm1': 3.0, 'log_a2': 16.081, 'm2': 5.0},
        'C2': {'log_a1': 12.301, 'm1': 3.0, 'log_a2': 15.835, 'm2': 5.0},
        'D':  {'log_a1': 12.164, 'm1': 3.0, 'log_a2': 15.606, 'm2': 5.0},
        'E':  {'log_a1': 11.972, 'm1': 3.0, 'log_a2': 15.350, 'm2': 5.0},
        'F':  {'log_a1': 11.699, 'm1': 3.0, 'log_a2': 14.832, 'm2': 5.0},
        'F1': {'log_a1': 11.546, 'm1': 3.0, 'log_a2': 14.576, 'm2': 5.0},
        'F3': {'log_a1': 11.398, 'm1': 3.0, 'log_a2': 14.330, 'm2': 5.0},
        'G':  {'log_a1': 11.245, 'm1': 3.0, 'log_a2': 14.080, 'm2': 5.0},
        'W1': {'log_a1': 11.764, 'm1': 3.0, 'log_a2': 15.091, 'm2': 5.0},
        'W2': {'log_a1': 11.533, 'm1': 3.0, 'log_a2': 14.706, 'm2': 5.0},
        'W3': {'log_a1': 11.262, 'm1': 3.0, 'log_a2': 14.183, 'm2': 5.0}
    }

    if curve_class not in sn_curves:
        raise ValueError(f"Unknown S-N curve class: {curve_class}")

    params = sn_curves[curve_class]

    # Convert log_a to a
    a1 = 10 ** params['log_a1']
    a2 = 10 ** params['log_a2']

    # Thickness correction (ref thickness = 25mm)
    if thickness > 25:
        t_factor = (25 / thickness) ** 0.25
        a1 *= t_factor ** params['m1']
        a2 *= t_factor ** params['m2']

    return {
        'class': curve_class,
        'a1': a1,
        'm1': params['m1'],
        'a2': a2,
        'm2': params['m2'],
        'thickness_mm': thickness
    }

# Example: Get F3 curve for mooring chain
sn_f3 = get_dnv_sn_curve('F3', thickness=127)  # 127mm chain

print(f"S-N Curve F3 (Chain):")
print(f"  a1 = {sn_f3['a1']:.2e}, m1 = {sn_f3['m1']}")
print(f"  a2 = {sn_f3['a2']:.2e}, m2 = {sn_f3['m2']}")

Calculate Cycles to Failure:

def calculate_cycles_to_failure(
    stress_range: float,
    sn_curve: dict
) -> float:
    """
    Calculate cycles to failure for given stress range.

    N = a / (Δσ)^m

    Args:
        stress_range: Stress range (MPa)
        sn_curve: S-N curve parameters from get_dnv_sn_curve()

    Returns:
        Cycles to failure
    """
    # Use first segment if stress range is high
    # Switch to second segment if N > 1e7 (DNV bi-linear curve)

    N1 = sn_curve['a1'] / (stress_range ** sn_curve['m1'])

    if N1 <= 1e7:
        return N1
    else:
        # Use second segment
        N2 = sn_curve['a2'] / (stress_range ** sn_curve['m2'])
        return N2

# Example
stress_range = 50  # MPa
N = calculate_cycles_to_failure(stress_range, sn_f3)

print(f"Stress range: {stress_range} MPa")
print(f"Cycles to failure: {N:.2e}")
print(f"Years at 1 Hz: {N / (365.25 * 24 * 3600):.2f}")

2. Rainflow Counting

Rainflow Algorithm:

def rainflow_counting(
    time_series: np.ndarray,
    bin_width: float = None
) -> tuple[np.ndarray, np.ndarray]:
    """
    Rainflow cycle counting algorithm.

    ASTM E1049-85 standard implementation.

    Args:
        time_series: Stress or load time series
        bin_width: Bin width for histogram (None = auto)

    Returns:
        (ranges, counts) - Stress ranges and cycle counts
    """
    # Simple peak-valley extraction
    peaks_valleys = []
    for i in range(1, len(time_series) - 1):
        if (time_series[i] > time_series[i-1] and time_series[i] > time_series[i+1]) or \
           (time_series[i] < time_series[i-1] and time_series[i] < time_series[i+1]):
            peaks_valleys.append(time_series[i])

    # Rainflow counting
    stack = []
    ranges = []

    for value in peaks_valleys:
        stack.append(value)

        while len(stack) >= 3:
            # Check for cycle
            X = abs(stack[-2] - stack[-3])
            Y = abs(stack[-1] - stack[-2])

            if len(stack) == 3:
                if Y >= X:
                    # Extract cycle
                    ranges.append(X)
                    stack.pop(-2)
                    stack.pop(-2)
                else:
                    break
            else:
                Z = abs(stack[-3] - stack[-4])
                if Y >= X and X >= Z:
                    # Extract cycle
                    ranges.append(X)
                    stack.pop(-2)
                    stack.pop(-2)
                else:
                    break

    # Create histogram
    ranges = np.array(ranges)

    if bin_width is None:
        bin_width = (np.max(ranges) - np.min(ranges)) / 20

    bins = np.arange(0, np.max(ranges) + bin_width, bin_width)
    counts, bin_edges = np.histogram(ranges, bins=bins)

    # Use bin centers
    bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2

    return bin_centers, counts

# Example: Mooring tension time series
t = np.linspace(0, 3600, 36000)  # 1 hour
tension = 2000 + 300 * np.sin(2*np.pi*t/10) + 100 * np.sin(2*np.pi*t/3) + 50*np.random.randn(len(t))

ranges, counts = rainflow_counting(tension, bin_width=10)

print(f"Rainflow cycles:")
print(f"  Total cycles: {np.sum(counts)}")
print(f"  Max range: {np.max(ranges):.1f} kN")

3. Miner's Rule (Cumulative Damage)

Palmgren-Miner Damage:

def calculate_fatigue_damage_miners_rule(
    stress_ranges: np.ndarray,
    cycle_counts: np.ndarray,
    sn_curve: dict,
    design_factor: float = 10.0
) -> dict:
    """
    Calculate fatigue damage using Miner's rule.

    D = Σ(n_i / N_i)

    Where:
    - n_i = number of cycles at stress range i
    - N_i = cycles to failure at stress range i

    Args:
        stress_ranges: Array of stress ranges (MPa)
        cycle_counts: Array of cycle counts for each range
        sn_curve: S-N curve parameters
        design_factor: Safety factor (DNV: 10 for mooring)

    Returns:
        Fatigue damage and life prediction
    """
    total_damage = 0.0
    damage_breakdown = []

    for stress_range, n_cycles in zip(stress_ranges, cycle_counts):
        if stress_range > 0:
            # Cycles to failure
            N = calculate_cycles_to_failure(stress_range, sn_curve)

            # Damage contribution
            damage = n_cycles / N

            total_damage += damage

            damage_breakdown.append({
                'stress_range': stress_range,
                'cycles': n_cycles,
                'N_failure': N,
                'damage': damage,
                'damage_percent': 0  # Will be filled later
            })

    # Calculate percentage contributions
    for item in damage_breakdown:
        item['damage_percent'] = (item['damage'] / total_damage * 100) if total_damage > 0 else 0

    # Apply design factor
    total_damage_with_df = total_damage * design_factor

    # Fatigue life
    if total_damage > 0:
        fatigue_life = 1.0 / total_damage  # In units of analysis duration
    else:
        fatigue_life = np.inf

    return {
        'total_damage': total_damage,
        'damage_with_design_factor': total_damage_with_df,
        'fatigue_life': fatigue_life,
        'utilization': total_damage_with_df,
        'passed': total_damage_with_df <= 1.0,
        'breakdown': damage_breakdown
    }

# Example: Calculate fatigue damage
# Assume 1 hour of data, scale to 25 years
hours_per_year = 8760
design_life_years = 25
scale_factor = hours_per_year * design_life_years

# Convert tension ranges to stress (simplified)
stress_ranges = ranges / 100  # kN to MPa (simplified)
cycle_counts_scaled = counts * scale_factor

fatigue_result = calculate_fatigue_damage_miners_rule(
    stress_ranges,
    cycle_counts_scaled,
    sn_f3,
    design_factor=10.0
)

print(f"Fatigue Analysis Results:")
print(f"  Total damage: {fatigue_result['total_damage']:.4f}")
print(f"  With DF=10: {fatigue_result['damage_with_design_factor']:.4f}")
print(f"  Utilization: {fatigue_result['utilization']*100:.1f}%")
print(f"  Passed: {fatigue_result['passed']}")
print(f"  Fatigue life: {fatigue_result['fatigue_life']:.1f} years")

4. Spectral Fatigue Analysis

Narrow-Band Spectral Method:

def spectral_fatigue_narrow_band(
    spectrum: np.ndarray,
    frequencies: np.ndarray,
    sn_curve: dict,
    duration: float,
    design_factor: float = 10.0
) -> dict:
    """
    Calculate fatigue damage using narrow-band spectral method.

    Assumes Rayleigh distribution of stress ranges.

    Args:
        spectrum: Stress response spectrum S(f)
        frequencies: Frequency array (Hz)
        sn_curve: S-N curve parameters
        duration: Duration of analysis (seconds)
        design_factor: Safety factor

    Returns:
        Fatigue damage
    """
    # Spectral moments
    m0 = np.trapz(spectrum, frequencies)
    m2 = np.trapz(spectrum * frequencies**2, frequencies)
    m4 = np.trapz(spectrum * frequencies**4, frequencies)

    # Zero-crossing frequency
    f0 = np.sqrt(m2 / m0)

    # Number of zero crossings in duration
    N0 = f0 * duration

    # Standard deviation of stress
    sigma = np.sqrt(m0)

    # Damage integral for Rayleigh distribution
    # D = N0 * (2*sigma)^m * Γ(1 + m/2) / a

    m = sn_curve['m1']  # Use first slope
    a = sn_curve['a1']

    from scipy.special import gamma

    damage = N0 * (2 * sigma)**m * gamma(1 + m/2) / a

    # Apply design factor
    damage_with_df = damage * design_factor

    # Fatigue life
    if damage > 0:
        fatigue_life = duration / damage
    else:
        fatigue_life = np.inf

    return {
        'total_damage': damage,
        'damage_with_design_factor': damage_with_df,
        'fatigue_life_seconds': fatigue_life,
        'fatigue_life_years': fatigue_life / (365.25 * 24 * 3600),
        'sigma_stress': sigma,
        'zero_crossing_freq': f0
    }

# Example
freq_hz = np.linspace(0.01, 0.5, 500)
S_stress = 100 * freq_hz**(-2)  # Simplified stress spectrum

fatigue_spectral = spectral_fatigue_narrow_band(
    S_stress,
    freq_hz,
    sn_f3,
    duration=3600,  # 1 hour
    design_factor=10.0
)

# Scale to 25 years
fatigue_spectral['damage_25yr'] = fatigue_spectral['total_damage'] * 8760 * 25

print(f"Spectral Fatigue (25 years):")
print(f"  Damage: {fatigue_spectral['damage_25yr']:.4f}")
print(f"  Utilization: {fatigue_spectral['damage_25yr'] * 10:.1f}%")

5. Mooring Line Fatigue

Chain Fatigue at Fairlead:

def mooring_chain_fatigue_analysis(
    tension_time_series: np.ndarray,
    chain_diameter: float,
    chain_grade: str = 'R4',
    design_life_years: float = 25,
    time_step: float = 0.1
) -> dict:
    """
    Complete mooring chain fatigue analysis.

    Args:
        tension_time_series: Tension time series (kN)
        chain_diameter: Chain diameter (mm)
        chain_grade: Chain grade (R3, R4, R5)
        design_life_years: Design life (years)
        time_step: Time step (seconds)

    Returns:
        Fatigue results
    """
    # Chain properties
    grade_factors = {'R3': 0.0219, 'R4': 0.0246, 'R5': 0.0273}
    MBL = grade_factors[chain_grade] * chain_diameter**2  # tonnes

    # Cross-sectional area (nominal)
    d_mm = chain_diameter
    A = np.pi * (d_mm/2)**2  # mm²

    # Convert tension to stress
    stress_time_series = tension_time_series * 1000 / A  # MPa

    # Rainflow counting
    stress_ranges, cycle_counts = rainflow_counting(stress_time_series)

    # Duration of time series
    duration_hours = len(tension_time_series) * time_step / 3600

    # Scale to design life
    hours_total = 8760 * design_life_years
    scale_factor = hours_total / duration_hours

    cycle_counts_scaled = cycle_counts * scale_factor

    # Select S-N curve (DNV: F3 for chain at connector)
    sn_curve = get_dnv_sn_curve('F3', thickness=chain_diameter)

    # Calculate damage
    fatigue_result = calculate_fatigue_damage_miners_rule(
        stress_ranges,
        cycle_counts_scaled,
        sn_curve,
        design_factor=10.0  # DNV-OS-E301
    )

    return {
        'chain_diameter_mm': chain_diameter,
        'chain_grade': chain_grade,
        'MBL_tonnes': MBL,
        'design_life_years': design_life_years,
        'fatigue_damage': fatigue_result['total_damage'],
        'utilization': fatigue_result['utilization'],
        'passed': fatigue_result['passed'],
        'fatigue_life_years': fatigue_result['fatigue_life'],
        'stress_ranges': stress_ranges,
        'cycle_counts': cycle_counts_scaled
    }

# Example
tension = 2000 + 400 * np.sin(2*np.pi*np.arange(36000)/100)  # 1 hour, varied tension

chain_fatigue = mooring_chain_fatigue_analysis(
    tension,
    chain_diameter=127,  # mm
    chain_grade='R4',
    design_life_years=25,
    time_step=0.1
)

print(f"Mooring Chain Fatigue:")
print(f"  Diameter: {chain_fatigue['chain_diameter_mm']} mm {chain_fatigue['chain_grade']}")
print(f"  MBL: {chain_fatigue['MBL_tonnes']:.1f} tonnes")
print(f"  Damage (25 years): {chain_fatigue['fatigue_damage']:.4f}")
print(f"  Utilization: {chain_fatigue['utilization']*100:.1f}%")
print(f"  Status: {'PASS' if chain_fatigue['passed'] else 'FAIL'}")

Complete Examples

Example 1: Complete Fatigue Assessment

def complete_fatigue_assessment(
    tension_file: str,
    output_dir: str = 'reports/fatigue'
) -> dict:
    """
    Complete fatigue assessment from tension time series.

    Args:
        tension_file: CSV file with tension time series
        output_dir: Output directory

    Returns:
        Fatigue assessment results
    """
    import pandas as pd
    import plotly.graph_objects as go
    from plotly.subplots import make_subplots
    from pathlib import Path

    output_path = Path(output_dir)
    output_path.mkdir(parents=True, exist_ok=True)

    # Load tension data
    df = pd.read_csv(tension_file)
    tension = df['Tension'].values  # kN
    time = df['Time'].values  # seconds

    # Rainflow counting
    ranges, counts = rainflow_counting(tension)

    # Chain properties
    chain_diameter = 127  # mm
    sn_curve = get_dnv_sn_curve('F3', thickness=chain_diameter)

    # Calculate fatigue
    fatigue = mooring_chain_fatigue_analysis(
        tension,
        chain_diameter=chain_diameter,
        design_life_years=25,
        time_step=time[1] - time[0]
    )

    # Create visualizations
    fig = make_subplots(
        rows=2, cols=2,
        subplot_titles=(
            'Tension Time Series',
            'Rainflow Histogram',
            'S-N Curve with Load Points',
            'Damage Breakdown'
        )
    )

    # Plot 1: Time series
    fig.add_trace(
        go.Scatter(x=time, y=tension, name='Tension', line=dict(width=1)),
        row=1, col=1
    )

    # Plot 2: Rainflow histogram
    fig.add_trace(
        go.Bar(x=ranges, y=counts, name='Cycle Counts'),
        row=1, col=2
    )

    # Plot 3: S-N curve
    stress_plot = np.logspace(0, 3, 100)
    N_plot = sn_curve['a1'] / stress_plot**sn_curve['m1']

    fig.add_trace(
        go.Scatter(
            x=N_plot, y=stress_plot,
            mode='lines', name='S-N Curve F3',
            line=dict(color='red')
        ),
        row=2, col=1
    )

    # Add load points
    stress_ranges_chain = fatigue['stress_ranges']
    N_values = [calculate_cycles_to_failure(s, sn_curve) for s in stress_ranges_chain]

    fig.add_trace(
        go.Scatter(
            x=N_values, y=stress_ranges_chain,
            mode='markers', name='Load Points',
            marker=dict(size=8)
        ),
        row=2, col=1
    )

    fig.update_xaxes(type='log', title_text='Cycles N', row=2, col=1)
    fig.update_yaxes(type='log', title_text='Stress Range (MPa)', row=2, col=1)

    # Plot 4: Damage breakdown (top contributors)
    breakdown = fatigue_result['breakdown'][:10]  # Top 10
    damage_pct = [item['damage_percent'] for item in breakdown]
    stress_labels = [f"{item['stress_range']:.1f} MPa" for item in breakdown]

    fig.add_trace(
        go.Bar(x=stress_labels, y=damage_pct, name='Damage %'),
        row=2, col=2
    )

    fig.update_layout(height=800, showlegend=True, title_text='Fatigue Assessment Report')
    fig.write_html(output_path / 'fatigue_assessment.html')

    # Export summary
    summary = pd.DataFrame({
        'Parameter': [
            'Chain Diameter (mm)',
            'Chain Grade',
            'MBL (tonnes)',
            'Design Life (years)',
            'Total Damage',
            'Utilization (%)',
            'Fatigue Life (years)',
            'Status'
        ],
        'Value': [
            fatigue['chain_diameter_mm'],
            fatigue['chain_grade'],
            f"{fatigue['MBL_tonnes']:.1f}",
            fatigue['design_life_years'],
            f"{fatigue['fatigue_damage']:.4f}",
            f"{fatigue['utilization']*100:.1f}",
            f"{fatigue['fatigue_life_years']:.1f}",
            'PASS' if fatigue['passed'] else 'FAIL'
        ]
    })

    summary.to_csv(output_path / 'fatigue_summary.csv', index=False)

    print(f"✓ Fatigue assessment complete")
    print(f"  Output: {output_dir}")
    print(f"  Status: {'PASS' if fatigue['passed'] else 'FAIL'}")

    return fatigue

Resources

  • DNV-RP-C203: Fatigue Design of Offshore Steel Structures
  • DNV-OS-E301: Position Mooring (Section 7: Fatigue)
  • API RP 2SK: Design and Analysis of Stationkeeping Systems for Floating Structures
  • ASTM E1049: Standard Practices for Cycle Counting in Fatigue Analysis
  • BS 7608: Code of Practice for Fatigue Design and Assessment of Steel Structures

Use this skill for all fatigue analysis in DigitalModel!

Weekly Installs
10
First Seen
Jan 24, 2026
Installed on
claude-code9
antigravity8
windsurf8
codex8
trae8
opencode8