{"spec_id":"sn-curve-basic","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nsn-curve-basic: S-N Curve (Wöhler Curve)\nLibrary: letsplot 4.10.0 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-05-20\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\n\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 = \"#E4E2DB\" if THEME == \"light\" else \"#2F2F2C\"\n\n# Okabe-Ito palette — positions 1-4\nBRAND = \"#009E73\"  # position 1 — data points and fit line\nOI_2 = \"#C475FD\"  # vermillion — Ultimate Strength\nOI_3 = \"#4467A3\"  # blue — Yield Strength\nOI_4 = \"#BD8233\"  # reddish purple — Endurance Limit\n\n# Generate realistic S-N curve data for structural steel\nnp.random.seed(42)\n\nultimate_strength = 500\nyield_strength = 350\nendurance_limit = 200\n\nstress_levels = np.array([450, 400, 350, 320, 300, 280, 260, 240, 220, 210])\n\n# Basquin equation: S = A * N^b  →  N = (S/A)^(1/b)\nA = 800\nb = -0.10\nbase_cycles = (stress_levels / A) ** (1 / b)\n\nall_stress = []\nall_cycles = []\nfor stress, base_N in zip(stress_levels, base_cycles, strict=True):\n    n_specimens = np.random.randint(3, 6)\n    scatter = np.random.lognormal(0, 0.15, n_specimens)\n    cycles = base_N * scatter\n    all_stress.extend([stress] * n_specimens)\n    all_cycles.extend(cycles)\n\ndf = pd.DataFrame({\"stress\": all_stress, \"cycles\": all_cycles})\n\n# Fit line starting at ~300 cycles to avoid crowding near Ultimate Strength at low N\nfit_cycles = np.logspace(2.5, 7, 100)\nfit_stress = A * fit_cycles**b\ndf_fit = pd.DataFrame({\"cycles\": fit_cycles, \"stress\": fit_stress})\n\n# Fatigue regime zones: infinite life / high-cycle / low-cycle\ndf_zone_infinite = pd.DataFrame({\"xmin\": [100.0], \"xmax\": [1e8], \"ymin\": [100.0], \"ymax\": [float(endurance_limit)]})\ndf_zone_highcycle = pd.DataFrame(\n    {\"xmin\": [100.0], \"xmax\": [1e8], \"ymin\": [float(endurance_limit)], \"ymax\": [float(yield_strength)]}\n)\ndf_zone_lowcycle = pd.DataFrame(\n    {\"xmin\": [100.0], \"xmax\": [1e8], \"ymin\": [float(yield_strength)], \"ymax\": [float(ultimate_strength)]}\n)\n\nanyplot_theme = theme(\n    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n    panel_background=element_rect(fill=PAGE_BG),\n    panel_grid_major=element_line(color=GRID, size=0.3),\n    panel_grid_minor=element_blank(),\n    axis_title=element_text(color=INK, size=12),\n    axis_text=element_text(color=INK_SOFT, size=10),\n    axis_line=element_line(color=INK_SOFT),\n    plot_title=element_text(color=INK, size=16),\n    legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n    legend_text=element_text(color=INK_SOFT, size=10),\n    legend_title=element_text(color=INK),\n)\n\nplot = (\n    ggplot()\n    # Zone shading — demarcates fatigue regimes (rendered first, behind data)\n    + geom_rect(\n        data=df_zone_lowcycle,\n        mapping=aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\"),\n        fill=OI_2,\n        alpha=0.07,\n        color=\"transparent\",\n    )\n    + geom_rect(\n        data=df_zone_highcycle,\n        mapping=aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\"),\n        fill=OI_3,\n        alpha=0.07,\n        color=\"transparent\",\n    )\n    + geom_rect(\n        data=df_zone_infinite,\n        mapping=aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\"),\n        fill=OI_4,\n        alpha=0.07,\n        color=\"transparent\",\n    )\n    # Basquin power-law fit line — thicker (size=1.5) to stand apart from scatter points\n    + geom_line(\n        data=df_fit,\n        mapping=aes(x=\"cycles\", y=\"stress\"),\n        color=BRAND,\n        size=1.5,\n        alpha=0.9,\n    )\n    # Test specimen data points with interactive tooltips\n    + geom_point(\n        data=df,\n        mapping=aes(x=\"cycles\", y=\"stress\"),\n        color=BRAND,\n        size=3.5,\n        alpha=0.85,\n        tooltips=layer_tooltips()\n        .line(\"Cycles to failure|@cycles{,.0f}\")\n        .line(\"Stress amplitude|@stress MPa\"),\n    )\n    # Reference lines — distinct linetypes for CVD accessibility\n    + geom_hline(yintercept=ultimate_strength, color=OI_2, size=0.9, linetype=\"dashed\")\n    + geom_hline(yintercept=yield_strength, color=OI_3, size=0.9, linetype=\"dotted\")\n    + geom_hline(yintercept=endurance_limit, color=OI_4, size=0.9, linetype=\"dotdash\")\n    # Inline labels at right side of chart (x=2e6, hjust=1) — well clear of the fit line\n    + geom_text(\n        data=pd.DataFrame({\"cycles\": [2e6], \"stress\": [ultimate_strength * 0.97], \"label\": [\"Ultimate Strength\"]}),\n        mapping=aes(x=\"cycles\", y=\"stress\", label=\"label\"),\n        color=OI_2,\n        size=11,\n        hjust=1,\n    )\n    + geom_text(\n        data=pd.DataFrame({\"cycles\": [2e6], \"stress\": [yield_strength * 1.04], \"label\": [\"Yield Strength\"]}),\n        mapping=aes(x=\"cycles\", y=\"stress\", label=\"label\"),\n        color=OI_3,\n        size=11,\n        hjust=1,\n    )\n    + geom_text(\n        data=pd.DataFrame({\"cycles\": [2e6], \"stress\": [endurance_limit * 1.04], \"label\": [\"Endurance Limit\"]}),\n        mapping=aes(x=\"cycles\", y=\"stress\", label=\"label\"),\n        color=OI_4,\n        size=11,\n        hjust=1,\n    )\n    + scale_x_log10()\n    + scale_y_log10()\n    + labs(\n        title=\"sn-curve-basic · python · letsplot · anyplot.ai\",\n        x=\"Number of Cycles to Failure (N)\",\n        y=\"Stress Amplitude (MPa)\",\n    )\n    + theme_minimal()\n    + anyplot_theme\n    + ggsize(800, 450)\n)\n\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}