{"spec_id":"sn-curve-basic","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nsn-curve-basic: S-N Curve (Wöhler Curve)\nLibrary: plotly 6.7.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-05-20\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport plotly.graph_objects as go\n\n\n# Theme\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nGRID = \"rgba(26,26,23,0.10)\" if THEME == \"light\" else \"rgba(240,239,232,0.10)\"\n\n# Okabe-Ito palette\nBRAND = \"#009E73\"  # position 1 — Basquin fit line\nC2 = \"#C475FD\"  # position 2 — test data markers\nC3 = \"#4467A3\"  # position 3 — ultimate strength\nC4 = \"#BD8233\"  # position 4 — yield strength\nC5 = \"#AE3030\"  # position 5 — endurance limit\n\n# Data: Steel fatigue test data (Basquin model)\nnp.random.seed(42)\n\nA = 1200  # Fatigue coefficient (MPa)\nb = -0.12  # Fatigue strength exponent\n\nstress_levels = np.array([600, 550, 500, 450, 400, 350, 320, 300, 280, 260, 250, 240])\ncycles_base = (stress_levels / A) ** (1 / b)\n\ncycles = []\nstress = []\nfor s, n_base in zip(stress_levels, cycles_base, strict=False):\n    n_samples = np.random.randint(2, 5)\n    scatter = 10 ** (np.random.normal(0, 0.15, n_samples))\n    for factor in scatter:\n        cycles.append(n_base * factor)\n        stress.append(s)\n\ncycles = np.array(cycles)\nstress = np.array(stress)\n\n# Material properties\nultimate_strength = 650  # MPa\nyield_strength = 450  # MPa\nendurance_limit = 230  # MPa\n\n# Basquin fit line\nfit_cycles = np.logspace(2, 7, 100)\nfit_stress = A * fit_cycles**b\nn_knee = (endurance_limit / A) ** (1 / b)  # transition point to infinite life\n\n# Plot\nfig = go.Figure()\n\nfig.add_trace(\n    go.Scatter(\n        x=fit_cycles,\n        y=fit_stress,\n        mode=\"lines\",\n        name=\"Basquin Fit\",\n        line={\"color\": BRAND, \"width\": 3},\n        hovertemplate=\"Cycles: %{x:.2e}<br>Stress: %{y:.0f} MPa<extra>Basquin Fit</extra>\",\n    )\n)\n\nfig.add_trace(\n    go.Scatter(\n        x=cycles,\n        y=stress,\n        mode=\"markers\",\n        name=\"Test Data\",\n        marker={\"color\": C2, \"size\": 12, \"line\": {\"color\": PAGE_BG, \"width\": 1.5}},\n        hovertemplate=\"Cycles: %{x:.2e}<br>Stress: %{y:.0f} MPa<extra>Test Data</extra>\",\n    )\n)\n\nx_range = [100, 1e7]\n\n# Infinite-life zone fill — opacity raised so the region registers as a meaningful cue\nfig.add_hrect(y0=200, y1=endurance_limit, opacity=0.11, fillcolor=BRAND, layer=\"below\")\n\n# Reference lines — excluded from legend; annotated directly at right edge instead\nfig.add_trace(\n    go.Scatter(\n        x=x_range,\n        y=[ultimate_strength, ultimate_strength],\n        mode=\"lines\",\n        name=f\"Ultimate Strength ({ultimate_strength} MPa)\",\n        line={\"color\": C3, \"width\": 2, \"dash\": \"dash\"},\n        showlegend=False,\n        hovertemplate=f\"Ultimate Strength: {ultimate_strength} MPa<extra></extra>\",\n    )\n)\n\nfig.add_trace(\n    go.Scatter(\n        x=x_range,\n        y=[yield_strength, yield_strength],\n        mode=\"lines\",\n        name=f\"Yield Strength ({yield_strength} MPa)\",\n        line={\"color\": C4, \"width\": 2, \"dash\": \"dash\"},\n        showlegend=False,\n        hovertemplate=f\"Yield Strength: {yield_strength} MPa<extra></extra>\",\n    )\n)\n\nfig.add_trace(\n    go.Scatter(\n        x=x_range,\n        y=[endurance_limit, endurance_limit],\n        mode=\"lines\",\n        name=f\"Endurance Limit ({endurance_limit} MPa)\",\n        line={\"color\": C5, \"width\": 2, \"dash\": \"dash\"},\n        showlegend=False,\n        hovertemplate=f\"Endurance Limit: {endurance_limit} MPa<extra></extra>\",\n    )\n)\n\n# Direct line labels via add_annotation — plotly-native, cleaner than legend entries\nfor y_val, label, color, anchor in [\n    (ultimate_strength, f\"Ult. Strength<br>{ultimate_strength} MPa\", C3, \"top\"),\n    (yield_strength, f\"Yield Strength<br>{yield_strength} MPa\", C4, \"bottom\"),\n    (endurance_limit, f\"End. Limit<br>{endurance_limit} MPa\", C5, \"top\"),\n]:\n    fig.add_annotation(\n        x=8e6,\n        y=y_val,\n        text=label,\n        xanchor=\"right\",\n        yanchor=anchor,\n        showarrow=False,\n        font={\"color\": color, \"size\": 12},\n        bgcolor=ELEVATED_BG,\n        bordercolor=color,\n        borderwidth=1,\n        borderpad=3,\n    )\n\n# Fatigue knee annotation — where Basquin fit intersects endurance limit\nfig.add_annotation(\n    x=n_knee,\n    y=endurance_limit,\n    text=\"Fatigue Knee<br>(~10⁶ cycles)\",\n    showarrow=True,\n    arrowhead=2,\n    arrowcolor=INK_SOFT,\n    arrowsize=1.2,\n    ax=-70,\n    ay=-70,\n    font={\"size\": 10, \"color\": INK_SOFT},\n    bgcolor=ELEVATED_BG,\n    bordercolor=INK_SOFT,\n    borderwidth=1,\n    borderpad=3,\n    xanchor=\"center\",\n)\n\n# Infinite-life zone label inside the green fill region\nfig.add_annotation(\n    x=5e5,\n    y=218,\n    text=\"Infinite Life Zone\",\n    showarrow=False,\n    font={\"size\": 10, \"color\": BRAND},\n    xanchor=\"center\",\n    yanchor=\"middle\",\n)\n\n# Style\nfig.update_layout(\n    autosize=False,\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    hovermode=\"closest\",\n    title={\n        \"text\": \"sn-curve-basic · python · plotly · anyplot.ai\",\n        \"font\": {\"size\": 16, \"color\": INK},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n    },\n    xaxis={\n        \"title\": {\"text\": \"Cycles to Failure (N)\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"type\": \"log\",\n        \"showgrid\": True,\n        \"gridwidth\": 1,\n        \"gridcolor\": GRID,\n        \"showline\": True,\n        \"linewidth\": 1,\n        \"linecolor\": INK_SOFT,\n        \"mirror\": False,\n        \"range\": [2, 7],\n        \"zerolinecolor\": GRID,\n    },\n    yaxis={\n        \"title\": {\"text\": \"Stress Amplitude (MPa)\", \"font\": {\"size\": 12, \"color\": INK}},\n        \"tickfont\": {\"size\": 10, \"color\": INK_SOFT},\n        \"type\": \"log\",\n        \"showgrid\": True,\n        \"gridwidth\": 1,\n        \"gridcolor\": GRID,\n        \"showline\": True,\n        \"linewidth\": 1,\n        \"linecolor\": INK_SOFT,\n        \"mirror\": False,\n        \"range\": [2.3, 2.9],\n        \"zerolinecolor\": GRID,\n    },\n    legend={\n        \"font\": {\"size\": 10, \"color\": INK_SOFT},\n        \"x\": 0.05,\n        \"y\": 0.05,\n        \"xanchor\": \"left\",\n        \"yanchor\": \"bottom\",\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n    },\n    margin={\"l\": 80, \"r\": 60, \"t\": 80, \"b\": 60},\n)\n\n# Save\nfig.write_image(f\"plot-{THEME}.png\", width=800, height=450, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}