{"spec_id":"spc-xbar-r","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nspc-xbar-r: Statistical Process Control Chart (X-bar/R)\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-20\n\"\"\"\n\nimport io\nimport os\nimport sys\n\n\n# Script filename shadows the installed `pygal` package when run as `python pygal.py`;\n# dropping the script directory from sys.path lets the real package resolve.\nsys.path.pop(0)\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom PIL import Image, ImageColor, ImageDraw\nfrom pygal.style import Style\n\n\n# Theme tokens\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\n# Imprint palette — data colors are theme-independent\nC_NORMAL = \"#009E73\"  # brand green — in-control data (Imprint pos. 1)\nC_OOC = \"#AE3030\"  # matte red — out-of-control (semantic anchor: error)\nC_UCL_LCL = \"#4467A3\"  # blue — control limit lines (Imprint pos. 3)\nC_WARN = \"#DDCC77\"  # amber — warning limits (semantic anchor: caution)\n\n# Data — CNC shaft diameter measurements, subgroup size n=5\nnp.random.seed(42)\nn_samples = 30\nsubgroup_size = 5\n\n# SPC constants for n=5\nA2 = 0.577\nD3 = 0.0\nD4 = 2.114\n\ntarget = 25.00\nprocess_std = 0.02\nmeasurements = np.random.normal(target, process_std, (n_samples, subgroup_size))\n\n# Inject out-of-control scenarios\nmeasurements[7] += 0.06  # tool wear — upward shift\nmeasurements[18] -= 0.07  # recalibration overshoot — downward shift\nmeasurements[24] += 0.08  # material batch change — upward shift\n\nsample_means = measurements.mean(axis=1)\nsample_ranges = measurements.max(axis=1) - measurements.min(axis=1)\n\nx_bar_bar = sample_means.mean()\nr_bar = sample_ranges.mean()\n\nxbar_ucl = x_bar_bar + A2 * r_bar\nxbar_lcl = x_bar_bar - A2 * r_bar\nxbar_uw = x_bar_bar + (2 / 3) * A2 * r_bar\nxbar_lw = x_bar_bar - (2 / 3) * A2 * r_bar\n\nr_ucl = D4 * r_bar\nr_lcl = D3 * r_bar\nr_uw = r_bar + (2 / 3) * (r_ucl - r_bar)\n\nxbar_ooc = (sample_means > xbar_ucl) | (sample_means < xbar_lcl)\nr_ooc = sample_ranges > r_ucl\n\n# Title — length-aware font scaling (baseline 67 chars = font size 66)\ntitle_str = \"spc-xbar-r · python · pygal · anyplot.ai\"\nn_title = len(title_str)\ntitle_font_size = round(66 * 67 / n_title) if n_title > 67 else 66\n\n# Series color order must match chart.add() call order exactly:\n# slot 1=normal data, 2=OOC overlay, 3=center line, 4=UCL, 5=LCL, 6=+2σ, 7=-2σ\nchart_colors = (C_NORMAL, C_OOC, INK, C_UCL_LCL, C_UCL_LCL, C_WARN, C_WARN)\n\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=chart_colors,\n    title_font_size=title_font_size,\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    font_family=\"'Helvetica Neue', 'Segoe UI', sans-serif\",\n)\n\nsample_ids = list(range(1, n_samples + 1))\n\ncommon_config = {\n    \"width\": 3200,\n    \"height\": 900,\n    \"style\": custom_style,\n    \"show_y_guides\": True,\n    \"show_x_guides\": False,\n    \"margin\": 40,\n    \"margin_left\": 160,\n    \"margin_right\": 80,\n    \"print_values\": False,\n    \"legend_at_bottom\": True,\n    \"legend_at_bottom_columns\": 4,\n    \"legend_box_size\": 22,\n    \"js\": [],\n    \"explicit_size\": True,\n    \"dots_size\": 14,\n    \"stroke_style\": {\"width\": 4, \"linecap\": \"round\", \"linejoin\": \"round\"},\n    \"truncate_label\": -1,\n    \"truncate_legend\": -1,\n}\n\n# X-bar Chart (top panel)\nxbar_y_min = min(sample_means.min(), xbar_lcl)\nxbar_y_max = max(sample_means.max(), xbar_ucl)\nxbar_y_pad = (xbar_y_max - xbar_y_min) * 0.18\n\nxbar_chart = pygal.XY(\n    **common_config,\n    title=title_str,\n    x_title=\"\",\n    y_title=\"X̄ (mm)\",\n    margin_bottom=80,\n    margin_top=60,\n    range=(xbar_y_min - xbar_y_pad, xbar_y_max + xbar_y_pad),\n    xrange=(0, n_samples + 1),\n    value_formatter=lambda y: f\"{y:.3f}\" if isinstance(y, (int, float)) else str(y),\n)\nxbar_chart.x_labels = [float(i) for i in sample_ids]\n\n# Normal data line with per-point OOC dot color override (slot 1 = C_NORMAL)\nxbar_data = [\n    {\"value\": (float(i + 1), float(sample_means[i])), **({\"color\": C_OOC, \"dots_size\": 17} if xbar_ooc[i] else {})}\n    for i in range(n_samples)\n]\nxbar_chart.add(\"Sample Mean\", xbar_data, stroke_style={\"width\": 4, \"linecap\": \"round\"}, show_dots=True)\n\n# OOC overlay — explicit legend entry; empty list keeps color slot 2 = C_OOC\nooc_xbar_pts = [(float(i + 1), float(sample_means[i])) for i in range(n_samples) if xbar_ooc[i]]\nxbar_chart.add(\"Out of Control\", ooc_xbar_pts, stroke=False, show_dots=True, dots_size=17)\n\n# Center line (slot 3 = INK)\nxbar_chart.add(\n    f\"CL = {x_bar_bar:.3f}\",\n    [(0.5, x_bar_bar), (n_samples + 0.5, x_bar_bar)],\n    show_dots=False,\n    stroke_style={\"width\": 4, \"linecap\": \"round\"},\n)\n\n# UCL / LCL (slots 4, 5 = C_UCL_LCL — distinct blue, not reusing OOC red)\nxbar_chart.add(\n    f\"UCL = {xbar_ucl:.3f}\",\n    [(0.5, xbar_ucl), (n_samples + 0.5, xbar_ucl)],\n    show_dots=False,\n    stroke_style={\"width\": 3, \"dasharray\": \"16, 8\", \"linecap\": \"round\"},\n)\nxbar_chart.add(\n    f\"LCL = {xbar_lcl:.3f}\",\n    [(0.5, xbar_lcl), (n_samples + 0.5, xbar_lcl)],\n    show_dots=False,\n    stroke_style={\"width\": 3, \"dasharray\": \"16, 8\", \"linecap\": \"round\"},\n)\n\n# Warning limits (slots 6, 7 = C_WARN amber)\nxbar_chart.add(\n    \"+2σ Warning\",\n    [(0.5, xbar_uw), (n_samples + 0.5, xbar_uw)],\n    show_dots=False,\n    stroke_style={\"width\": 2.5, \"dasharray\": \"8, 5, 3, 5\", \"linecap\": \"round\"},\n)\nxbar_chart.add(\n    \"-2σ Warning\",\n    [(0.5, xbar_lw), (n_samples + 0.5, xbar_lw)],\n    show_dots=False,\n    stroke_style={\"width\": 2.5, \"dasharray\": \"8, 5, 3, 5\", \"linecap\": \"round\"},\n)\n\n# R Chart (bottom panel) — same series order keeps color slots aligned\nr_y_max = max(sample_ranges.max(), r_ucl)\nr_y_pad = r_y_max * 0.18\n\nr_chart = pygal.XY(\n    **common_config,\n    title=\"\",\n    x_title=\"Sample Number\",\n    y_title=\"R (mm)\",\n    margin_bottom=100,\n    margin_top=20,\n    range=(0, r_y_max + r_y_pad),\n    xrange=(0, n_samples + 1),\n    value_formatter=lambda y: f\"{y:.3f}\" if isinstance(y, (int, float)) else str(y),\n)\nr_chart.x_labels = [float(i) for i in sample_ids]\n\nrange_pts = [\n    {\"value\": (float(i + 1), float(sample_ranges[i])), **({\"color\": C_OOC, \"dots_size\": 17} if r_ooc[i] else {})}\n    for i in range(n_samples)\n]\nr_chart.add(\"Sample Range\", range_pts, stroke_style={\"width\": 4, \"linecap\": \"round\"}, show_dots=True)\n\n# Always add OOC series to keep color slot 2 = C_OOC aligned\nooc_r_pts = [(float(i + 1), float(sample_ranges[i])) for i in range(n_samples) if r_ooc[i]]\nr_chart.add(\"Out of Control\", ooc_r_pts, stroke=False, show_dots=True, dots_size=17)\n\nr_chart.add(\n    f\"CL = {r_bar:.3f}\",\n    [(0.5, r_bar), (n_samples + 0.5, r_bar)],\n    show_dots=False,\n    stroke_style={\"width\": 4, \"linecap\": \"round\"},\n)\nr_chart.add(\n    f\"UCL = {r_ucl:.3f}\",\n    [(0.5, r_ucl), (n_samples + 0.5, r_ucl)],\n    show_dots=False,\n    stroke_style={\"width\": 3, \"dasharray\": \"16, 8\", \"linecap\": \"round\"},\n)\nr_chart.add(\n    f\"LCL = {r_lcl:.3f}\",\n    [(0.5, r_lcl), (n_samples + 0.5, r_lcl)],\n    show_dots=False,\n    stroke_style={\"width\": 3, \"dasharray\": \"16, 8\", \"linecap\": \"round\"},\n)\nr_chart.add(\n    \"+2σ Warning\",\n    [(0.5, r_uw), (n_samples + 0.5, r_uw)],\n    show_dots=False,\n    stroke_style={\"width\": 2.5, \"dasharray\": \"8, 5, 3, 5\", \"linecap\": \"round\"},\n)\n\n# Render and composite into 3200×1800 PNG\nchart_h = 900\npng_xbar = cairosvg.svg2png(bytestring=xbar_chart.render(), output_width=3200, output_height=chart_h)\npng_r = cairosvg.svg2png(bytestring=r_chart.render(), output_width=3200, output_height=chart_h)\n\nxbar_img = Image.open(io.BytesIO(png_xbar))\nr_img = Image.open(io.BytesIO(png_r))\n\nbg_rgb = ImageColor.getrgb(PAGE_BG)\ncombined = Image.new(\"RGB\", (3200, 1800), bg_rgb)\ncombined.paste(xbar_img, (0, 0))\ncombined.paste(r_img, (0, chart_h))\n\ndraw = ImageDraw.Draw(combined)\ndraw.line([(160, chart_h), (3120, chart_h)], fill=ImageColor.getrgb(INK_MUTED), width=1)\n\ncombined.save(f\"plot-{THEME}.png\", dpi=(300, 300))\n\n# HTML — both charts as interactive SVG stacked vertically\nxbar_svg = xbar_chart.render().decode(\"utf-8\")\nr_svg = r_chart.render().decode(\"utf-8\")\nhtml_content = (\n    \"<!DOCTYPE html>\\n<html>\\n<head>\\n\"\n    '<meta charset=\"utf-8\">\\n<style>\\n'\n    f\"body{{background:{PAGE_BG};margin:0;padding:20px;font-family:sans-serif;}}\\n\"\n    \".spc{{width:100%;max-width:1400px;display:block;margin:0 auto;}}\\n\"\n    \"</style>\\n</head>\\n<body>\\n\"\n    f'<div class=\"spc\">{xbar_svg}</div>\\n'\n    f'<div class=\"spc\">{r_svg}</div>\\n'\n    \"</body>\\n</html>\"\n)\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(html_content)\n"}