{"spec_id":"sn-curve-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nsn-curve-basic: S-N Curve (Wöhler Curve)\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-05-20\n\"\"\"\n\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\n# Remove the current directory from sys.path to avoid circular imports with bokeh.py\nsys.path = [p for p in sys.path if p not in (\"\", \".\", os.getcwd(), os.path.dirname(__file__))]\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Label, Span\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\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\nBRAND = \"#009E73\"  # Okabe-Ito position 1 — data series\nULT_COLOR = \"#C475FD\"  # Okabe-Ito position 2 — Ultimate Strength line\nENDU_COLOR = \"#4467A3\"  # Okabe-Ito position 3 — Endurance Limit line\nYIELD_COLOR = \"#BD8233\"  # Okabe-Ito position 4 — Yield Strength line\n\n# Data — S-N fatigue test results for steel specimens (Basquin equation)\nnp.random.seed(42)\n\nA = 1200  # MPa coefficient\nb = -0.12  # Basquin exponent\n\nstress_levels = np.array([450, 400, 350, 320, 300, 280, 260, 250, 240, 230, 220, 210])\n\ncycles_list = []\nstress_list = []\n\nfor stress_val in stress_levels:\n    N_theoretical = (stress_val / A) ** (1 / b)\n    n_specimens = np.random.randint(2, 5)\n    scatter = np.random.lognormal(0, 0.3, n_specimens)\n    cycles_actual = N_theoretical * scatter\n    cycles_list.extend(cycles_actual)\n    stress_list.extend([stress_val] * n_specimens)\n\ncycles = np.array(cycles_list)\nstress = np.array(stress_list)\n\nultimate_strength = 500  # MPa\nyield_strength = 350  # MPa\nendurance_limit = 200  # MPa\n\ncycles_fit = np.logspace(2, 7.3, 100)\nstress_fit = A * (cycles_fit**b)\n\nsource = ColumnDataSource(data={\"cycles\": cycles, \"stress\": stress})\nsource_fit = ColumnDataSource(data={\"cycles_fit\": cycles_fit, \"stress_fit\": stress_fit})\n\n# Plot\np = figure(\n    width=3200,\n    height=1800,\n    title=\"sn-curve-basic · python · bokeh · anyplot.ai\",\n    x_axis_label=\"Number of Cycles to Failure (N)\",\n    y_axis_label=\"Stress Amplitude (MPa)\",\n    x_axis_type=\"log\",\n    y_axis_type=\"log\",\n    y_range=(150, 650),\n    x_range=(100, 2e7),\n    toolbar_location=None,\n    min_border_bottom=220,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=50,\n)\n\n# Infinite-life zone shaded below the endurance limit\np.add_layout(BoxAnnotation(top=endurance_limit, bottom=0, fill_color=ENDU_COLOR, fill_alpha=0.12, line_color=None))\n\np.line(\n    x=\"cycles_fit\",\n    y=\"stress_fit\",\n    source=source_fit,\n    line_width=7,\n    line_color=BRAND,\n    line_alpha=0.9,\n    legend_label=\"Basquin Fit (S = A·N^b)\",\n)\n\np.scatter(\n    x=\"cycles\",\n    y=\"stress\",\n    source=source,\n    size=18,\n    fill_color=BRAND,\n    fill_alpha=0.65,\n    line_color=PAGE_BG,\n    line_width=1.5,\n    legend_label=\"Fatigue Test Data\",\n)\n\nhover = HoverTool(tooltips=[(\"Cycles to Failure\", \"@cycles{0.00e+0}\"), (\"Stress Amplitude\", \"@stress{0} MPa\")])\np.add_tools(hover)\n\n# Reference lines for material properties\np.add_layout(\n    Span(location=ultimate_strength, dimension=\"width\", line_color=ULT_COLOR, line_width=3, line_dash=\"dashed\")\n)\np.add_layout(Span(location=yield_strength, dimension=\"width\", line_color=YIELD_COLOR, line_width=3, line_dash=\"dashed\"))\np.add_layout(Span(location=endurance_limit, dimension=\"width\", line_color=ENDU_COLOR, line_width=3, line_dash=\"dashed\"))\n\np.add_layout(\n    Label(\n        x=150,\n        y=535,\n        text=f\"Ultimate Strength ({ultimate_strength} MPa)\",\n        text_font_size=\"28pt\",\n        text_color=ULT_COLOR,\n        text_font_style=\"bold\",\n    )\n)\np.add_layout(\n    Label(\n        x=150,\n        y=368,\n        text=f\"Yield Strength ({yield_strength} MPa)\",\n        text_font_size=\"28pt\",\n        text_color=YIELD_COLOR,\n        text_font_style=\"bold\",\n    )\n)\np.add_layout(\n    Label(\n        x=150,\n        y=210,\n        text=f\"Endurance Limit ({endurance_limit} MPa)  ← infinite life\",\n        text_font_size=\"28pt\",\n        text_color=ENDU_COLOR,\n        text_font_style=\"bold\",\n    )\n)\n\n# Style — font sizes per default-style-guide.md \"Visual Sizing Defaults\" for bokeh\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\n\n# Y-only grid for cleaner line+scatter chart\np.xgrid.grid_line_color = None\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.10\n\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\n\np.legend.location = \"bottom_left\"\np.legend.label_text_font_size = \"34pt\"\np.legend.label_text_color = INK_SOFT\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = INK_SOFT\np.legend.glyph_width = 55\np.legend.glyph_height = 40\np.legend.spacing = 18\np.legend.padding = 24\n\n# Save HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Save PNG via headless Chrome (Selenium)\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\n\ndriver = webdriver.Chrome(options=opts)\n# Override emulated viewport to exactly W×H regardless of browser chrome overhead\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}