{"spec_id":"curve-dose-response","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\ncurve-dose-response: Pharmacological Dose-Response Curve\nLibrary: pygal 3.1.3 | Python 3.13.15\nQuality: 94/100 | Updated: 2026-08-17\n\"\"\"\n\nimport os\n\nimport cairosvg\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\nfrom scipy.optimize import curve_fit\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 — first two categorical positions\nC_A = \"#009E73\"  # Compound A — Imprint brand green\nC_B = \"#C475FD\"  # Compound B — Imprint lavender\n# 95% CI band color-coded to Compound A using RGBA CSS color (pygal SVG feature)\nCI_A = \"rgba(0, 158, 115, 0.40)\"\n\n# Palette tuple matches series add order (11 series total)\nCHART_COLORS = (\n    C_A,  # 1: Compound A fitted curve\n    C_B,  # 2: Compound B fitted curve\n    C_A,  # 3: Data points A\n    C_B,  # 4: Data points B\n    CI_A,  # 5: 95% CI upper (color-coded to Compound A, semi-transparent)\n    CI_A,  # 6: 95% CI lower (None label)\n    C_A,  # 7: EC50 reference A\n    C_B,  # 8: EC50 reference B (None label)\n    INK_MUTED,  # 9: Asymptotes (structural, very subtle)\n    C_A,  # 10: Error bars A (None label)\n    C_B,  # 11: Error bars B (None label)\n)\n\n\ndef four_pl(x, bottom, top, ec50, hill):\n    return bottom + (top - bottom) / (1 + (ec50 / x) ** hill)\n\n\ndef fmt_concentration(log_val):\n    \"\"\"Format log10 concentration to human-readable units for interactive tooltips.\"\"\"\n    val = 10 ** float(log_val)\n    if val >= 1e-6:\n        return f\"{val * 1e6:.1f} µM\"\n    if val >= 1e-9:\n        return f\"{val * 1e9:.1f} nM\"\n    return f\"{val * 1e12:.1f} pM\"\n\n\n# Data\nnp.random.seed(42)\nconcentrations = np.logspace(-9, -4, 8)\nlog_conc = np.log10(concentrations)\n\n# Compound A — potent agonist (EC50 ~100 nM)\nresponse_a_true = four_pl(concentrations, 5, 95, 1e-7, 1.2)\nresponse_a_sem = np.random.uniform(2, 5, len(concentrations))\nresponse_a = response_a_true + np.random.normal(0, response_a_sem)\n\n# Compound B — moderate agonist (EC50 ~1 µM)\nresponse_b_true = four_pl(concentrations, 10, 85, 1e-6, 0.9)\nresponse_b_sem = np.random.uniform(2, 6, len(concentrations))\nresponse_b = response_b_true + np.random.normal(0, response_b_sem)\n\n# Fit 4PL curves\npopt_a, pcov_a = curve_fit(four_pl, concentrations, response_a, p0=[0, 100, 1e-7, 1], maxfev=10000)\npopt_b, pcov_b = curve_fit(four_pl, concentrations, response_b, p0=[0, 100, 1e-6, 1], maxfev=10000)\n\n# Smooth curves for plotting\nconc_smooth = np.logspace(-9.5, -3.5, 200)\nlog_smooth = np.log10(conc_smooth)\nfit_a = four_pl(conc_smooth, *popt_a)\nfit_b = four_pl(conc_smooth, *popt_b)\n\n# Extract parameters\nbottom_a, top_a, ec50_a, hill_a = popt_a\nbottom_b, top_b, ec50_b, hill_b = popt_b\nhalf_a = bottom_a + (top_a - bottom_a) / 2\nhalf_b = bottom_b + (top_b - bottom_b) / 2\nlog_ec50_a = np.log10(ec50_a)\nlog_ec50_b = np.log10(ec50_b)\n\n# 95% CI for Compound A via covariance sampling\nnp.random.seed(99)\nparam_samples = np.random.multivariate_normal(popt_a, pcov_a, size=200)\nfit_ensemble = np.array([four_pl(conc_smooth, *p) for p in param_samples])\nci_lower = np.percentile(fit_ensemble, 2.5, axis=0)\nci_upper = np.percentile(fit_ensemble, 97.5, axis=0)\n\n# Title (49 chars < 67 baseline → keep full title_font_size=66)\ntitle = \"curve-dose-response · python · pygal · anyplot.ai\"\n\n# Style — canonical pygal sizing for 3200×1800 canvas\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=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=2.5,\n    font_family=\"sans-serif\",\n)\n\n# Chart\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=title,\n    x_title=\"log₁₀ Concentration (M)\",\n    y_title=\"Response (%)\",\n    show_dots=False,\n    stroke=True,\n    show_x_guides=False,\n    show_y_guides=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=3,\n    legend_box_size=28,\n    x_label_rotation=0,\n    truncate_legend=-1,\n    range=(0, 105),\n    allow_interruptions=True,\n    js=[],\n    print_values=False,\n    value_formatter=lambda y: f\"{y:.1f}%\",\n    x_value_formatter=fmt_concentration,\n)\n\n# Restrict y-axis ticks to 5 clean quartile levels — reduces grid clutter\nchart.y_labels = [0, 25, 50, 75, 100]\n\n# Fitted curves (solid, prominent — primary data layer)\nchart.add(\n    f\"Compound A  EC₅₀={ec50_a:.1e} M, Hill={hill_a:.1f}\",\n    list(zip(log_smooth.tolist(), fit_a.tolist(), strict=True)),\n    show_dots=False,\n    stroke_style={\"width\": 8, \"linecap\": \"round\", \"linejoin\": \"round\"},\n)\nchart.add(\n    f\"Compound B  EC₅₀={ec50_b:.1e} M, Hill={hill_b:.1f}\",\n    list(zip(log_smooth.tolist(), fit_b.tolist(), strict=True)),\n    show_dots=False,\n    stroke_style={\"width\": 8, \"linecap\": \"round\", \"linejoin\": \"round\"},\n)\n\n# Data points (scatter, no connecting line)\nchart.add(\n    \"Data A ± SEM\",\n    list(zip(log_conc.tolist(), response_a.tolist(), strict=True)),\n    stroke=False,\n    show_dots=True,\n    dots_size=14,\n)\nchart.add(\n    \"Data B ± SEM\",\n    list(zip(log_conc.tolist(), response_b.tolist(), strict=True)),\n    stroke=False,\n    show_dots=True,\n    dots_size=14,\n)\n\n# 95% CI bounds for Compound A — color-coded to Compound A via RGBA semi-transparent stroke\nchart.add(\n    \"95% CI (A)\",\n    list(zip(log_smooth.tolist(), ci_upper.tolist(), strict=True)),\n    show_dots=False,\n    stroke_style={\"width\": 3, \"dasharray\": \"8, 5\"},\n)\nchart.add(\n    None,\n    list(zip(log_smooth.tolist(), ci_lower.tolist(), strict=True)),\n    show_dots=False,\n    stroke_style={\"width\": 3, \"dasharray\": \"8, 5\"},\n)\n\n# EC50 reference lines — vertical + horizontal crosshair per compound\nchart.add(\n    \"EC₅₀ refs.\",\n    [(log_ec50_a, 0), (log_ec50_a, half_a), None, (log_smooth[0], half_a), (log_ec50_a, half_a)],\n    show_dots=False,\n    dots_size=0,\n    stroke_style={\"width\": 3, \"dasharray\": \"18, 10\"},\n)\nchart.add(\n    None,\n    [(log_ec50_b, 0), (log_ec50_b, half_b), None, (log_smooth[0], half_b), (log_ec50_b, half_b)],\n    show_dots=False,\n    dots_size=0,\n    stroke_style={\"width\": 3, \"dasharray\": \"18, 10\"},\n)\n\n# Asymptote lines (very fine dash — background reference only)\nx_lo, x_hi = log_smooth[0], log_smooth[-1]\nchart.add(\n    \"Asymptotes\",\n    [\n        (x_lo, top_a),\n        (x_hi, top_a),\n        None,\n        (x_lo, bottom_a),\n        (x_hi, bottom_a),\n        None,\n        (x_lo, top_b),\n        (x_hi, top_b),\n        None,\n        (x_lo, bottom_b),\n        (x_hi, bottom_b),\n    ],\n    show_dots=False,\n    dots_size=0,\n    stroke_style={\"width\": 1.5, \"dasharray\": \"4, 8\"},\n)\n\n# Error bars — stems and caps\ncap = 0.06\nfor resp, sem in [(response_a, response_a_sem), (response_b, response_b_sem)]:\n    pts = []\n    for i in range(len(log_conc)):\n        x = log_conc[i]\n        lo = resp[i] - sem[i]\n        hi = resp[i] + sem[i]\n        pts.extend([(x, lo), (x, hi), None, (x - cap, lo), (x + cap, lo), None, (x - cap, hi), (x + cap, hi), None])\n    chart.add(None, pts, stroke=True, show_dots=False, dots_size=0, stroke_style={\"width\": 2})\n\n# Save\nsvg = chart.render(is_unicode=True)\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(svg)\ncairosvg.svg2png(bytestring=svg.encode(\"utf-8\"), write_to=f\"plot-{THEME}.png\", dpi=96)\n"}