{"spec_id":"sn-curve-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nsn-curve-basic: S-N Curve (Wöhler Curve)\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-05-20\n\"\"\"\n\nimport math\nimport os\nimport re\nimport sys\n\nimport numpy as np\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\nIMPRINT = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\")\n\n# Remove current dir from sys.path to avoid shadowing the pygal package\n_cwd = sys.path[0] if sys.path[0] else \".\"\nif _cwd in sys.path:\n    sys.path.remove(_cwd)\n\nimport pygal\nfrom cairosvg import svg2png as _svg2png\nfrom pygal.style import Style\n\n\nsys.path.insert(0, _cwd)\n\n# ── Data ──────────────────────────────────────────────────────────────────────\nnp.random.seed(42)\n\nstress_levels = np.array([450, 400, 350, 300, 275, 250, 225, 210, 200, 195])\nbase_cycles = np.array([1e2, 5e2, 2e3, 1e4, 3e4, 1e5, 4e5, 1e6, 5e6, 1e7])\n\ncycles_data: list[float] = []\nstress_data: list[float] = []\n\nfor stress, base_n in zip(stress_levels, base_cycles, strict=True):\n    n_samples = np.random.randint(3, 6)\n    scatter = np.exp(np.random.normal(0, 0.3, n_samples))\n    cycles = base_n * scatter\n    cycles_data.extend(cycles)\n    stress_data.extend([stress] * n_samples)\n\ncycles_arr = np.array(cycles_data)\nstress_arr = np.array(stress_data)\n\n# Basquin equation fit: S = A * N^b  (linear in log-log space)\nlog_cycles = np.log10(cycles_arr)\nlog_stress = np.log10(stress_arr)\ncoeffs = np.polyfit(log_cycles, log_stress, 1)\nb = coeffs[0]\nA = 10 ** coeffs[1]\n\nfit_cycles = np.logspace(2, 7, 100)\nfit_stress = A * (fit_cycles**b)\n\n# Material reference values (MPa)\nultimate_strength = 520\nyield_strength = 350\nendurance_limit = 190\n\n# pygal's logarithmic=True only applies to the x-axis in XY mode.\n# Log10-transform all stress (y) values for a true log-log plot,\n# then map explicit y_labels back to human-readable MPa values.\n_zone = lambda c: (\n    \"Low-Cycle Fatigue\" if c < 1e3 else (\"High-Cycle Fatigue\" if c < 1e6 else \"Near Endurance Limit\")\n)\n\nxy_points = [\n    {\n        \"value\": (float(c), math.log10(float(s))),\n        \"label\": f\"{_zone(float(c))}: {float(c):.2e} cycles @ {float(s):.0f} MPa\",\n    }\n    for c, s in zip(cycles_arr, stress_arr, strict=True)\n]\nfit_points = [\n    {\"value\": (float(c), math.log10(float(s))), \"label\": f\"Fit: {float(c):.2e} → {float(s):.0f} MPa\"}\n    for c, s in zip(fit_cycles, fit_stress, strict=True)\n]\n\nult_log = math.log10(ultimate_strength)\nyld_log = math.log10(yield_strength)\nend_log = math.log10(endurance_limit)\n\n# ── Style ─────────────────────────────────────────────────────────────────────\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=3,\n    opacity=0.75,\n    opacity_hover=1.0,\n)\n\n# ── Chart ─────────────────────────────────────────────────────────────────────\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=\"Steel Fatigue · sn-curve-basic · python · pygal · anyplot.ai\",\n    x_title=\"Cycles to Failure (N)\",\n    y_title=\"Stress Amplitude (MPa)\",\n    logarithmic=True,\n    show_dots=True,\n    dots_size=12,\n    stroke=True,\n    show_x_guides=False,\n    show_y_guides=True,\n    x_label_rotation=45,\n    legend_at_bottom=True,\n    legend_box_size=44,\n    margin=100,\n    # Tighter y-range: start just below endurance limit to reduce empty bottom space\n    range=(math.log10(165), math.log10(600)),\n    value_formatter=lambda xy: f\"{xy[0]:.2e} cycles, {10 ** xy[1]:.0f} MPa\" if isinstance(xy, tuple) else str(xy),\n)\n\n# X-axis: major log decades\nchart.x_labels = [100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000]\n\n# Y-axis: stress labels within the tightened range\ny_tick_vals = [200, 250, 300, 350, 400, 450, 500, 550]\nchart.y_labels = [{\"value\": math.log10(v), \"label\": str(v)} for v in y_tick_vals]\n\n# Series: Test Data first (most prominent), then derived/reference\nchart.add(\"Test Data\", xy_points, dots_size=20, stroke=False, show_dots=True)\nchart.add(\n    \"Basquin Fit (S = A·N^b)\",\n    fit_points,\n    stroke=True,\n    show_dots=False,\n    stroke_style={\"width\": 9, \"dasharray\": \"20, 10\"},\n)\n# Distinct stroke styles per reference line: solid / long-dash / short-dash\nchart.add(\n    f\"Ultimate Strength, Su = {ultimate_strength} MPa\",\n    [(100, ult_log), (1e7, ult_log)],\n    stroke=True,\n    show_dots=False,\n    stroke_style={\"width\": 5, \"opacity\": 0.80},\n)\nchart.add(\n    f\"Yield Strength, Sy = {yield_strength} MPa\",\n    [(100, yld_log), (1e7, yld_log)],\n    stroke=True,\n    show_dots=False,\n    stroke_style={\"width\": 5, \"dasharray\": \"30, 8\", \"opacity\": 0.80},\n)\n# Endurance limit: thicker + shorter dashes for better visibility on light bg\nchart.add(\n    f\"Endurance Limit, Se = {endurance_limit} MPa\",\n    [(100, end_log), (1e7, end_log)],\n    stroke=True,\n    show_dots=False,\n    stroke_style={\"width\": 12, \"dasharray\": \"14, 5\", \"opacity\": 0.90},\n)\n\n\n# ── SVG injection: fatigue region bands ───────────────────────────────────────\n\n\ndef _get_plot_dims(svg: str) -> tuple[float, float]:\n    \"\"\"Return (width, height) of plot area from the inner background rect in pygal SVG.\n\n    pygal renders <g class=\"plot\" transform=\"translate(tx,ty)\"> containing the inner\n    background <rect x=0 y=0 width=W height=H/>.  Coordinates inside that group are\n    relative; we only need W and H to map log-scale x positions.\n    \"\"\"\n    # Find the opening tag of <g class=\"plot\"> (exact class, not \"plot overlay\")\n    plot_tag = re.search(r'<g[^>]*\\bclass=\"plot\"[^>]*>', svg)\n    if plot_tag:\n        # Find the first <rect> after the plot group tag (that's the background rect)\n        rect_m = re.search(r\"<rect\\b[^>]+>\", svg[plot_tag.end() :])\n        if rect_m:\n            attrs: dict[str, float] = {}\n            for attr in (\"width\", \"height\"):\n                am = re.search(rf'\\b{attr}=\"([0-9.]+)\"', rect_m.group(0))\n                if am:\n                    attrs[attr] = float(am.group(1))\n            if \"width\" in attrs and \"height\" in attrs:\n                return attrs[\"width\"], attrs[\"height\"]\n    # Fallback for width=3200, height=1800, margin=100 with our font sizes\n    return 2760.0, 1290.0\n\n\ndef _inject_region_bands(svg: str, theme: str) -> str:\n    \"\"\"Inject colored fatigue-region background bands and labels into pygal SVG.\n\n    Elements are placed in the plot group's local coordinate space (origin = top-left\n    of the inner plot area), so no absolute pixel offset calculation is needed.\n    \"\"\"\n    pw, ph = _get_plot_dims(svg)\n\n    # X-axis: log10(100)=2 → log10(1e7)=7, five decades mapped to [0, pw]\n    def log_to_x(log_n: float) -> float:\n        return (log_n - 2.0) / 5.0 * pw\n\n    x_lcf = log_to_x(3.0)  # LCF | HCF boundary at N = 1 000\n    x_hcf = log_to_x(6.0)  # HCF | Infinite Life boundary at N = 1 000 000\n\n    # Semi-transparent fills (fill + fill-opacity for cairosvg compatibility)\n    if theme == \"light\":\n        region_fills = [(\"#CC8888\", \"0.10\"), (\"#88AA88\", \"0.10\"), (\"#8888BB\", \"0.10\")]\n        div_stroke = \"#6B6A63\"\n        lbl_fill = \"#6B6A63\"\n    else:\n        region_fills = [(\"#BB5555\", \"0.09\"), (\"#55994A\", \"0.09\"), (\"#5577BB\", \"0.09\")]\n        div_stroke = \"#A8A79F\"\n        lbl_fill = \"#A8A79F\"\n\n    # Labels near the top of the plot area (above all data points)\n    lbl_y = 55.0\n    lbl_sz = 38\n\n    parts = ['<g class=\"fatigue-regions\">']\n\n    # Three region fills\n    for rx, rw, (rfill, rop) in [\n        (0.0, x_lcf, region_fills[0]),\n        (x_lcf, x_hcf - x_lcf, region_fills[1]),\n        (x_hcf, pw - x_hcf, region_fills[2]),\n    ]:\n        parts.append(\n            f'<rect x=\"{rx:.1f}\" y=\"0\" width=\"{rw:.1f}\" height=\"{ph:.1f}\" '\n            f'fill=\"{rfill}\" fill-opacity=\"{rop}\" stroke=\"none\"/>'\n        )\n\n    # Subtle vertical dividers at region boundaries\n    for xd in (x_lcf, x_hcf):\n        parts.append(\n            f'<line x1=\"{xd:.1f}\" y1=\"0\" x2=\"{xd:.1f}\" y2=\"{ph:.1f}\" '\n            f'stroke=\"{div_stroke}\" stroke-width=\"2\" stroke-dasharray=\"8,4\" opacity=\"0.30\"/>'\n        )\n\n    # Region labels centered in each band\n    for lx, lbl in [\n        (x_lcf / 2, \"Low-Cycle\"),\n        ((x_lcf + x_hcf) / 2, \"High-Cycle Fatigue\"),\n        ((x_hcf + pw) / 2, \"Infinite Life\"),\n    ]:\n        parts.append(\n            f'<text x=\"{lx:.1f}\" y=\"{lbl_y:.1f}\" text-anchor=\"middle\" '\n            f'fill=\"{lbl_fill}\" font-size=\"{lbl_sz}\" font-family=\"sans-serif\" '\n            f'opacity=\"0.55\">{lbl}</text>'\n        )\n\n    parts.append(\"</g>\")\n    region_block = \"\".join(parts)\n\n    # Insert immediately after the plot background rect so bands appear above bg,\n    # but behind grid guides and data series.\n    plot_tag = re.search(r'<g[^>]*\\bclass=\"plot\"[^>]*>', svg)\n    if plot_tag:\n        after_tag = svg[plot_tag.end() :]\n        bg_rect = re.search(r\"<rect\\b[^>]+>\", after_tag)\n        if bg_rect:\n            insert_pos = plot_tag.end() + bg_rect.end()\n            return svg[:insert_pos] + region_block + svg[insert_pos:]\n\n    # Fallback: insert just before </svg>\n    return svg.replace(\"</svg>\", region_block + \"</svg>\")\n\n\n# ── Render & save ─────────────────────────────────────────────────────────────\nsvg_raw = chart.render()\n\n# Inject region bands into a copy of the SVG, then convert to PNG via cairosvg\nsvg_enhanced = _inject_region_bands(svg_raw.decode(\"utf-8\"), THEME).encode(\"utf-8\")\n_svg2png(bytestring=svg_enhanced, write_to=f\"plot-{THEME}.png\")\n\n# HTML export uses the original SVG (preserves full pygal interactivity)\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(svg_raw)\n"}