{"spec_id":"sn-curve-basic","library":"makie","language":"julia","code":"# anyplot.ai\n# sn-curve-basic: S-N Curve (Wöhler Curve)\n# Library: makie 0.21.9 | Julia 1.11.9\n# Quality: 91/100 | Created: 2026-09-02\n\nusing CairoMakie\nusing Colors\nusing Random\nusing Statistics\n\nRandom.seed!(42)\n\n# --- Theme tokens -----------------------------------------------------------\nconst THEME    = get(ENV, \"ANYPLOT_THEME\", \"light\")\nconst PAGE_BG  = THEME == \"light\" ? colorant\"#FAF8F1\" : colorant\"#1A1A17\"\nconst INK      = THEME == \"light\" ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\nconst INK_SOFT = THEME == \"light\" ? colorant\"#4A4A44\" : colorant\"#B8B7B0\"\nconst IMPRINT_PALETTE = [\n    colorant\"#009E73\", colorant\"#C475FD\", colorant\"#4467A3\", colorant\"#BD8233\",\n    colorant\"#AE3030\", colorant\"#2ABCCD\", colorant\"#954477\", colorant\"#99B314\",\n]\n\n# --- Data: fatigue test coupons across 8 stress levels, 4 specimens each ---\nstress_levels = [550.0, 500.0, 450.0, 400.0, 350.0, 300.0, 275.0, 260.0]\nspecimens_per_level = 4\nsigma_f_prime = 900.0   # Basquin fatigue strength coefficient (MPa)\nbasquin_b = -0.09       # Basquin fatigue strength exponent\n\nstress = Float64[]\ncycles = Float64[]\nfor s in stress_levels\n    mean_n = 0.5 * (s / sigma_f_prime)^(1 / basquin_b)\n    for _ in 1:specimens_per_level\n        n_i = mean_n * 10^(0.09 * randn())\n        push!(stress, s)\n        push!(cycles, n_i)\n    end\nend\n\n# Basquin power-law fit (least squares in log-log space)\nlog_n = log10.(cycles)\nlog_s = log10.(stress)\nslope = sum((log_n .- mean(log_n)) .* (log_s .- mean(log_s))) / sum((log_n .- mean(log_n)) .^ 2)\nintercept = mean(log_s) - slope * mean(log_n)\n\nfit_cycles = exp10.(range(log10(50.0), log10(1.5e6); length = 200))\nfit_stress = exp10.(intercept .+ slope .* log10.(fit_cycles))\n\nultimate_strength = 600.0\nyield_strength = 450.0\nendurance_limit = 250.0\n\n# Fatigue-region boundaries: low-cycle/high-cycle split at the conventional\n# 10^3-cycle mark; high-cycle/infinite-life split at the cycle count where the\n# Basquin fit crosses the endurance limit.\nxlim_lo, xlim_hi = 50.0, 1.5e6\nlow_cycle_boundary = 1.0e3\ninfinite_life_boundary = clamp(\n    exp10((log10(endurance_limit) - intercept) / slope),\n    low_cycle_boundary * 1.5,\n    xlim_hi * 0.98,\n)\n\n# --- Plot --------------------------------------------------------------------\nfig = Figure(\n    size            = (1600, 900),\n    fontsize        = 14,\n    backgroundcolor = PAGE_BG,\n)\n\nax = Axis(\n    fig[1, 1];\n    title             = \"sn-curve-basic · julia · makie · anyplot.ai\",\n    titlesize         = 20,\n    titlecolor        = INK,\n    xlabel            = \"Cycles to Failure, N\",\n    ylabel            = \"Stress Amplitude (MPa)\",\n    xlabelsize        = 14,\n    ylabelsize        = 14,\n    xlabelcolor       = INK,\n    ylabelcolor       = INK,\n    xticklabelsize    = 12,\n    yticklabelsize    = 12,\n    xticklabelcolor   = INK_SOFT,\n    yticklabelcolor   = INK_SOFT,\n    xtickcolor        = INK_SOFT,\n    ytickcolor        = INK_SOFT,\n    xscale            = log10,\n    yscale            = log10,\n    yticks            = [200, 250, 300, 400, 500, 600, 700],\n    ytickformat       = ys -> string.(round.(Int, ys)),\n    backgroundcolor   = PAGE_BG,\n    topspinevisible    = false,\n    rightspinevisible  = false,\n    leftspinecolor     = INK_SOFT,\n    bottomspinecolor   = INK_SOFT,\n    xgridcolor         = RGBAf(INK.r, INK.g, INK.b, 0.15),\n    ygridcolor         = RGBAf(INK.r, INK.g, INK.b, 0.15),\n    xminorgridvisible  = false,\n    yminorgridvisible  = false,\n)\n\nxlims!(ax, xlim_lo, xlim_hi)\nylims!(ax, 200.0, 700.0)\n\n# Fatigue-region demarcation: subtle dotted separators + bottom-edge labels,\n# called out per the spec's low-cycle / high-cycle / infinite-life regions.\nvlines!(ax, [low_cycle_boundary, infinite_life_boundary];\n        color = RGBAf(INK_SOFT.r, INK_SOFT.g, INK_SOFT.b, 0.4),\n        linestyle = :dot, linewidth = 1.5)\n\nregion_label_y = 207.0\nmid_low  = exp10(0.5 * (log10(xlim_lo) + log10(low_cycle_boundary)))\nmid_high = exp10(0.5 * (log10(low_cycle_boundary) + log10(infinite_life_boundary)))\nmid_inf  = exp10(0.5 * (log10(infinite_life_boundary) + log10(xlim_hi)))\ntext!(ax, mid_low, region_label_y; text = \"Low-Cycle\", fontsize = 11,\n      color = INK_SOFT, align = (:center, :bottom))\ntext!(ax, mid_high, region_label_y; text = \"High-Cycle\", fontsize = 11,\n      color = INK_SOFT, align = (:center, :bottom))\ntext!(ax, mid_inf, region_label_y; text = \"Infinite Life\", fontsize = 11,\n      color = INK_SOFT, align = (:center, :bottom))\n\nhlines!(ax, [ultimate_strength]; color = IMPRINT_PALETTE[2], linestyle = :dash,\n        linewidth = 2.5, label = \"Ultimate Strength\")\nhlines!(ax, [yield_strength]; color = IMPRINT_PALETTE[3], linestyle = :dash,\n        linewidth = 2.5, label = \"Yield Strength\")\nhlines!(ax, [endurance_limit]; color = IMPRINT_PALETTE[4], linestyle = :dash,\n        linewidth = 2.5, label = \"Endurance Limit\")\n\nlines!(ax, fit_cycles, fit_stress; color = INK_SOFT, linewidth = 3,\n       label = \"Basquin fit\")\n\nscatter!(ax, cycles, stress; color = IMPRINT_PALETTE[1], markersize = 16,\n         strokewidth = 1.5, strokecolor = PAGE_BG, label = \"Test specimens\")\n\n# Annotate where the Basquin fit crosses the endurance limit — the point\n# beyond which infinite fatigue life is predicted.\nn_label = infinite_life_boundary >= 1e6 ?\n    string(round(infinite_life_boundary / 1e6; digits = 2), \"M\") :\n    string(round(Int, infinite_life_boundary / 1e3), \"k\")\ntext!(ax, infinite_life_boundary * 0.97, endurance_limit * 1.1;\n      text = \"fit crosses endurance limit\\nN ≈ $(n_label)\",\n      fontsize = 11, color = INK_SOFT, align = (:right, :bottom))\n\naxislegend(ax; position = :rt, labelcolor = INK_SOFT, framevisible = false,\n           labelsize = 12)\n\n# --- Save --------------------------------------------------------------------\nsave(\"plot-$(THEME).png\", fig; px_per_unit = 2)\n"}