Claude Code Plugins

Community-maintained marketplace

Feedback

Fatigue analysis for offshore structures including S-N curves, rainflow counting, Miner's rule, and DNV standards

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name fatigue-analysis
version 1.0.0
description Fatigue analysis for offshore structures including S-N curves, rainflow counting, Miner's rule, and DNV standards
author workspace-hub
category subject-matter-expert
tags fatigue, s-n-curve, rainflow-counting, miners-rule, dnv, mooring-fatigue, structural-fatigue
platforms engineering

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!