{"spec_id":"sn-curve-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nsn-curve-basic: S-N Curve (Wöhler Curve)\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-05-20\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens\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\n# Okabe-Ito palette\nBRAND = \"#009E73\"  # pos 1 — fatigue test data & Basquin fit\nC_UTS = \"#C475FD\"  # pos 2 — Ultimate Tensile Strength\nC_YS = \"#4467A3\"  # pos 3 — Yield Strength\nC_EL = \"#BD8233\"  # pos 4 — Endurance Limit\n\n# Data — fatigue test results for medium-carbon steel specimens\nnp.random.seed(42)\nA = 800  # Basquin coefficient (MPa)\nb = -0.12  # Basquin exponent\n\nstress_levels = np.array([450, 400, 350, 300, 275, 250, 225, 200, 180, 160])\ncycles_list, stress_list = [], []\n\nfor stress in stress_levels:\n    theoretical_cycles = (stress / A) ** (1 / b)\n    n_samples = np.random.randint(3, 6)\n    scatter = np.exp(np.random.normal(0, 0.3, n_samples))\n    cycles_list.extend(theoretical_cycles * scatter)\n    stress_list.extend([stress] * n_samples)\n\ndf = pd.DataFrame({\"cycles\": cycles_list, \"stress\": stress_list})\n\n# Basquin fit line (smooth curve through full cycle range)\nfit_cycles = np.logspace(2, 8, 100)\nfit_stress = A * fit_cycles**b\nfit_df = pd.DataFrame({\"cycles\": fit_cycles, \"stress\": fit_stress})\n\n# Material property reference lines\nref_df = pd.DataFrame({\"property\": [\"UTS: 520 MPa\", \"YS: 380 MPa\", \"EL: 150 MPa\"], \"stress\": [520, 380, 150]})\n\n# Infinite-life design zone — subtle band below endurance limit\nband_df = pd.DataFrame({\"y1\": [100], \"y2\": [150]})\n\n# Region text labels — annotate the three fatigue life zones\nregion_df = pd.DataFrame(\n    {\n        \"cycles\": [700.0, 150000.0, 800000.0],\n        \"stress\": [580.0, 580.0, 125.0],\n        \"label\": [\"Low-Cycle Fatigue\", \"High-Cycle Fatigue\", \"Infinite Life\"],\n    }\n)\n\n# Shared log scales\nx_scale = alt.Scale(type=\"log\", domain=[100, 1e8])\ny_scale = alt.Scale(type=\"log\", domain=[100, 750])\n\nTITLE = \"sn-curve-basic · python · altair · anyplot.ai\"\n\n# Infinite-life zone fill (band below endurance limit)\nband = alt.Chart(band_df).mark_rect(opacity=0.10, color=C_EL).encode(y=alt.Y(\"y1:Q\", scale=y_scale), y2=alt.Y2(\"y2:Q\"))\n\n# Scatter: individual test specimens\npoints = (\n    alt.Chart(df)\n    .mark_point(size=180, filled=True, opacity=0.75, color=BRAND, stroke=PAGE_BG, strokeWidth=0.8)\n    .encode(\n        x=alt.X(\"cycles:Q\", scale=x_scale, title=\"Cycles to Failure (N)\"),\n        y=alt.Y(\"stress:Q\", scale=y_scale, title=\"Stress Amplitude (MPa)\"),\n        tooltip=[\"cycles:Q\", \"stress:Q\"],\n    )\n)\n\n# Basquin power-law fit line\nfit_line = (\n    alt.Chart(fit_df)\n    .mark_line(strokeWidth=2.5, opacity=0.85, color=BRAND)\n    .encode(x=alt.X(\"cycles:Q\", scale=x_scale), y=alt.Y(\"stress:Q\", scale=y_scale))\n)\n\n# Reference horizontal rules — longer dash pattern to distinguish from grid\nref_rules = (\n    alt.Chart(ref_df)\n    .mark_rule(strokeDash=[14, 6], strokeWidth=3)\n    .encode(\n        y=alt.Y(\"stress:Q\", scale=y_scale),\n        color=alt.Color(\n            \"property:N\",\n            scale=alt.Scale(domain=[\"UTS: 520 MPa\", \"YS: 380 MPa\", \"EL: 150 MPa\"], range=[C_UTS, C_YS, C_EL]),\n            legend=alt.Legend(\n                title=\"Material Properties\", titleFontSize=12, labelFontSize=10, labelLimit=200, orient=\"bottom-right\"\n            ),\n        ),\n    )\n)\n\n# Region text annotations — label the three fatigue life zones\nregion_labels = (\n    alt.Chart(region_df)\n    .mark_text(fontSize=12, fontStyle=\"italic\", align=\"left\", baseline=\"top\", color=INK_SOFT)\n    .encode(x=alt.X(\"cycles:Q\", scale=x_scale), y=alt.Y(\"stress:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Compose all layers — inner view 620×320 so vl-convert padding fits within 3200×1800\nchart = (\n    (band + points + fit_line + ref_rules + region_labels)\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n        title=alt.Title(TITLE, fontSize=16),\n    )\n    .configure_view(fill=PAGE_BG, strokeOpacity=0, continuousWidth=620, continuousHeight=320)\n    .configure_axis(\n        domainColor=INK_SOFT,\n        domainOpacity=1,\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.06,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=10,\n        titleFontSize=12,\n    )\n    .configure_axisX(gridOpacity=0)\n    .configure_title(color=INK, fontWeight=\"bold\", fontSize=16)\n    .configure_legend(\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        titleFontSize=12,\n        labelFontSize=10,\n    )\n)\n\n# Save PNG and interactive HTML\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# PAD-only to exact 3200×1800 — raise if vl-convert overshoots (do NOT crop)\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}