{"spec_id":"sn-curve-basic","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nsn-curve-basic: S-N Curve (Wöhler Curve)\nLibrary: plotnine 0.15.4 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-05-20\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    annotate,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_hline,\n    geom_line,\n    geom_point,\n    geom_ribbon,\n    geom_rug,\n    ggplot,\n    labs,\n    scale_shape_manual,\n    scale_x_log10,\n    scale_y_log10,\n    theme,\n    theme_minimal,\n)\n\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\"\n\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\"]\n\n# Data - S-N curve for steel specimen fatigue testing\nnp.random.seed(42)\n\n# Material properties (MPa)\nultimate_strength = 550\nyield_strength = 350\nendurance_limit = 250\n\n# Using Basquin equation: S = A * N^b\nA = 1200\nb = -0.12\nsigma_logN = 0.3  # Log-normal scatter in cycles\n\nstress_levels = np.array([500, 450, 400, 375, 350, 325, 300, 280, 270, 260, 255])\n\ncycles = []\nstress = []\npoint_type = []\n\nfor s in stress_levels:\n    n_expected = (s / A) ** (1 / b)\n    n_tests = np.random.randint(3, 6)\n    scatter = np.random.lognormal(0, sigma_logN, n_tests)\n    n_values = n_expected * scatter\n    cycles.extend(n_values)\n    stress.extend([s] * n_tests)\n    point_type.extend([\"failure\"] * n_tests)\n\n# Runout data points (did not fail) — marked with distinct marker shape\nrunout_cycles = [1e7, 2e7, 5e7]\nrunout_stress = [endurance_limit - 10, endurance_limit - 5, endurance_limit + 5]\ncycles.extend(runout_cycles)\nstress.extend(runout_stress)\npoint_type.extend([\"runout\"] * 3)\n\ndf = pd.DataFrame({\"cycles\": cycles, \"stress\": stress, \"type\": point_type})\n\n# Basquin fit line with ±2σ scatter band (converts log-N scatter to stress bounds)\nfit_cycles = np.logspace(2, 7, 100)\nfit_stress = A * fit_cycles**b\nfit_stress_upper = fit_stress * np.exp(2 * sigma_logN * abs(b))\nfit_stress_lower = fit_stress * np.exp(-2 * sigma_logN * abs(b))\ndf_fit = pd.DataFrame(\n    {\"cycles\": fit_cycles, \"stress\": fit_stress, \"stress_upper\": fit_stress_upper, \"stress_lower\": fit_stress_lower}\n)\n\nanyplot_theme = theme(\n    figure_size=(8, 4.5),\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=INK, size=0.3, alpha=0.10),\n    panel_grid_minor=element_line(color=INK, size=0.2, alpha=0.05),\n    panel_border=element_blank(),\n    axis_line_x=element_line(color=INK_SOFT, size=0.8),\n    axis_line_y=element_line(color=INK_SOFT, size=0.8),\n    axis_title=element_text(color=INK, size=10),\n    axis_text=element_text(color=INK_SOFT, size=8),\n    plot_title=element_text(color=INK, size=12, weight=\"bold\"),\n    legend_background=element_rect(fill=ELEVATED_BG, color=None),\n    legend_text=element_text(color=INK_SOFT, size=8),\n    legend_title=element_text(color=INK, size=9),\n    plot_margin=0.05,\n)\n\nplot = (\n    ggplot()\n    # ±2σ scatter band via geom_ribbon — idiomatic plotnine uncertainty visualization\n    + geom_ribbon(df_fit, aes(x=\"cycles\", ymin=\"stress_lower\", ymax=\"stress_upper\"), fill=IMPRINT[0], alpha=0.12)\n    # Basquin fit line\n    + geom_line(df_fit, aes(x=\"cycles\", y=\"stress\"), color=IMPRINT[0], size=1.2, alpha=0.85)\n    # Data points — shape mapped to type for runout vs failure distinction\n    + geom_point(df, aes(x=\"cycles\", y=\"stress\", shape=\"type\"), color=IMPRINT[0], size=4.2, alpha=0.80)\n    # geom_rug exposes marginal data density along both axes — distinctive plotnine feature\n    + geom_rug(df, aes(x=\"cycles\", y=\"stress\"), color=IMPRINT[0], alpha=0.35, size=0.5)\n    # Reference lines with Okabe-Ito colors; endurance limit slightly thicker as focal point\n    + geom_hline(yintercept=ultimate_strength, linetype=\"dashed\", color=IMPRINT[1], size=0.9, alpha=0.85)\n    + geom_hline(yintercept=yield_strength, linetype=\"dashed\", color=IMPRINT[2], size=0.9, alpha=0.85)\n    + geom_hline(yintercept=endurance_limit, linetype=\"dashed\", color=IMPRINT[3], size=1.2, alpha=0.90)\n    + annotate(\n        \"text\",\n        x=1e3,\n        y=ultimate_strength + 20,\n        label=\"Ultimate Strength (550 MPa)\",\n        size=10,\n        color=IMPRINT[1],\n        ha=\"left\",\n    )\n    + annotate(\n        \"text\", x=2e5, y=yield_strength + 20, label=\"Yield Strength (350 MPa)\", size=10, color=IMPRINT[2], ha=\"left\"\n    )\n    + annotate(\n        \"text\", x=1e3, y=endurance_limit - 25, label=\"Endurance Limit (250 MPa)\", size=10, color=IMPRINT[3], ha=\"left\"\n    )\n    # Logarithmic scales\n    + scale_x_log10()\n    + scale_y_log10()\n    # Shape scale distinguishes runout (triangle) from failure (circle)\n    + scale_shape_manual(values={\"failure\": \"o\", \"runout\": \"^\"}, name=\"Test Result\")\n    # Labels\n    + labs(\n        x=\"Number of Cycles to Failure (N)\",\n        y=\"Stress Amplitude (MPa)\",\n        title=\"sn-curve-basic · python · plotnine · anyplot.ai\",\n    )\n    + theme_minimal()\n    + anyplot_theme\n)\n\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\")\n"}