{"spec_id":"calibration-beer-lambert","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\ncalibration-beer-lambert: Beer-Lambert Calibration Curve\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 85/100 | Updated: 2026-06-03\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove this script's directory from sys.path so the installed pygal package\n# is found instead of this file (both share the name \"pygal.py\" / \"pygal\")\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _script_dir and p != \"\"]\nos.chdir(_script_dir)  # ensure output files land in the implementation directory\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\nfrom scipy import stats\n\n\n# Theme tokens — Imprint palette\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint categorical palette — first series always brand green\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\nBRAND = IMPRINT_PALETTE[0]  # #009E73 — calibration standards (first series)\nBLUE = IMPRINT_PALETTE[2]  # #4467A3 — regression fit line\nOCHRE = IMPRINT_PALETTE[3]  # #BD8233 — 95% prediction interval band\nRED = IMPRINT_PALETTE[4]  # #AE3030 — unknown sample (semantic: determination result)\n\n# Data: copper sulfate calibration standards at 810 nm\nnp.random.seed(42)\nconcentration = np.array([0.0, 2.0, 4.0, 6.0, 8.0, 10.0, 12.0])\ntrue_absorbance = 0.045 * concentration + 0.003\nnoise = np.random.normal(0, 0.010, len(concentration))\nabsorbance = true_absorbance + noise\nabsorbance[0] = 0.002\n\n# Linear regression\nslope, intercept, r_value, p_value, std_err = stats.linregress(concentration, absorbance)\nr_squared = r_value**2\n\n# Regression line\nconc_fit = np.linspace(-0.3, 12.8, 100)\nabs_fit = slope * conc_fit + intercept\n\n# 95% prediction interval\nn = len(concentration)\nconc_mean = np.mean(concentration)\nse_fit = std_err * np.sqrt(1 + 1 / n + (conc_fit - conc_mean) ** 2 / np.sum((concentration - conc_mean) ** 2))\nt_crit = stats.t.ppf(0.975, n - 2)\nupper_band = abs_fit + t_crit * se_fit\nlower_band = abs_fit - t_crit * se_fit\n\n# Unknown sample determination\nunknown_absorbance = 0.350\nunknown_concentration = (unknown_absorbance - intercept) / slope\n\n# Title with dynamic font size scaling (shrink only when longer than 67-char baseline)\ntitle = \"calibration-beer-lambert · python · pygal · anyplot.ai\"\ntitle_font_size = max(44, round(66 * (67 / len(title)))) if len(title) > 67 else 66\n\n# Custom Style with Imprint palette and theme-adaptive chrome\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=(BRAND, BLUE, OCHRE, RED),\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=2.5,\n    opacity=0.95,\n    opacity_hover=1.0,\n)\n\n# Remove chart border box and axis lines for cleaner look\nborder_css = (\n    \"inline:\"\n    \".chart-background { stroke: none !important; fill: none !important; }\"\n    \" .axis > .line { stroke: transparent !important; }\"\n    \" .plot > .background { stroke: none !important; }\"\n)\n\n# Chart\nchart = pygal.XY(\n    style=custom_style,\n    width=3200,\n    height=1800,\n    title=title,\n    x_title=\"Concentration (mg/L)\",\n    y_title=\"Absorbance\",\n    show_dots=True,\n    dots_size=16,\n    show_x_guides=False,\n    show_y_guides=True,\n    xrange=(-0.3, 13.0),\n    range=(-0.02, 0.62),\n    legend_at_bottom=True,\n    legend_at_bottom_columns=2,\n    legend_box_size=30,\n    truncate_legend=-1,\n    margin=50,\n    margin_top=80,\n    margin_bottom=240,\n    tooltip_fancy_mode=True,\n    tooltip_border_radius=8,\n    print_labels=False,\n    x_value_formatter=lambda x: f\"{x:.1f}\",\n    y_value_formatter=lambda y: f\"{y:.3f}\",\n    css=[\"file://style.css\", \"file://graph.css\", border_css],\n)\n\n# Calibration standards with interactive tooltips\nstd_points = [\n    {\"value\": (float(c), float(a)), \"label\": f\"Standard {i + 1}: {c:.1f} mg/L, A = {a:.4f}\"}\n    for i, (c, a) in enumerate(zip(concentration, absorbance, strict=False))\n]\nchart.add(\"Calibration Standards\", std_points, stroke=False, dots_size=16)\n\n# Regression fit line\nfit_points = [\n    {\"value\": (float(x), float(y)), \"label\": f\"Fit: A = {slope:.4f} × {x:.1f} + {intercept:.4f} = {y:.4f}\"}\n    for x, y in zip(conc_fit, abs_fit, strict=False)\n]\nchart.add(\n    f\"Fit: A = {slope:.4f}·C + {intercept:.4f} (R² = {r_squared:.4f})\",\n    fit_points,\n    show_dots=False,\n    stroke_style={\"width\": 5},\n)\n\n# 95% prediction interval — thicker strokes for clear visual band\npi_points = [\n    {\"value\": (float(x), float(y)), \"label\": f\"Upper 95% PI: {y:.4f} at C = {x:.1f}\"}\n    for x, y in zip(conc_fit, upper_band, strict=False)\n]\npi_points.append(None)\npi_points.extend(\n    {\"value\": (float(x), float(y)), \"label\": f\"Lower 95% PI: {y:.4f} at C = {x:.1f}\"}\n    for x, y in zip(conc_fit, lower_band, strict=False)\n)\nchart.add(\"95% Prediction Interval\", pi_points, show_dots=False, stroke_style={\"width\": 9}, stroke_dasharray=\"8,4\")\n\n# Unknown sample crosshair — horizontal then vertical dashed lines with None gap\nunknown_points = [\n    {\"value\": (-0.3, unknown_absorbance), \"label\": f\"Unknown: A = {unknown_absorbance:.3f}\"},\n    {\n        \"value\": (float(unknown_concentration), unknown_absorbance),\n        \"label\": f\"Intersection: C = {unknown_concentration:.2f} mg/L\",\n    },\n    None,\n    {\"value\": (float(unknown_concentration), unknown_absorbance), \"label\": f\"C = {unknown_concentration:.2f} mg/L\"},\n    {\"value\": (float(unknown_concentration), 0.0), \"label\": f\"Determined: {unknown_concentration:.2f} mg/L\"},\n]\nchart.add(\n    f\"Unknown → {unknown_concentration:.2f} mg/L\",\n    unknown_points,\n    stroke_dasharray=\"14,7\",\n    dots_size=14,\n    stroke_style={\"width\": 4},\n)\n\n# Render to SVG, inject regression equation as in-plot text annotation, then convert to PNG\nchart.render_to_file(f\"plot-{THEME}-tmp.svg\")\nwith open(f\"plot-{THEME}-tmp.svg\", encoding=\"utf-8\") as f:\n    svg_str = f.read()\nos.remove(f\"plot-{THEME}-tmp.svg\")\n\n# Inject equation annotation into the upper-left plot area (above the calibration trend)\n# SVG position tuned for 3200×1800 with margins: data coords (x≈0.4, y≈0.54) map here\neq_label = f\"A = {slope:.4f}·C + {intercept:.4f}   R² = {r_squared:.4f}\"\nannotation_svg = (\n    f'<text x=\"530\" y=\"340\" '\n    f'font-size=\"48\" font-family=\"sans-serif\" font-style=\"italic\" '\n    f'fill=\"{INK_SOFT}\">{eq_label}</text>'\n)\nsvg_annotated = svg_str.replace(\"</svg>\", f\"{annotation_svg}\\n</svg>\")\n\n# Save PNG with injected annotation\ncairosvg.svg2png(\n    bytestring=svg_annotated.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\", output_width=3200, output_height=1800\n)\n\n# Save interactive HTML (original SVG + pygal JS interactivity)\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}