Pharmacological Dose-Response Curve — Pygal

A sigmoidal dose-response curve that plots biological response against drug concentration on a logarithmic x-axis, fitted using a four-parameter logistic (4PL) model. This visualization is essential for determining drug potency metrics such as EC50 (half-maximal effective concentration) or IC50 (half-maximal inhibitory concentration), Hill slope steepness, and upper/lower response asymptotes. It enables rapid visual comparison of compound efficacy and is a standard tool in pharmacological analysis.

Pharmacological Dose-Response Curve rendered with Pygal

Python source (Pygal)

""" anyplot.ai
curve-dose-response: Pharmacological Dose-Response Curve
Library: pygal 3.1.3 | Python 3.13.14
Quality: 91/100 | Updated: 2026-06-24
"""

import os

import cairosvg
import numpy as np
import pygal
from pygal.style import Style
from scipy.optimize import curve_fit


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — first two categorical positions
C_A = "#009E73"  # Compound A — Imprint brand green
C_B = "#C475FD"  # Compound B — Imprint lavender
# 95% CI band color-coded to Compound A using RGBA CSS color (pygal SVG feature)
CI_A = "rgba(0, 158, 115, 0.40)"

# Palette tuple matches series add order (11 series total)
CHART_COLORS = (
    C_A,  # 1: Compound A fitted curve
    C_B,  # 2: Compound B fitted curve
    C_A,  # 3: Data points A
    C_B,  # 4: Data points B
    CI_A,  # 5: 95% CI upper (color-coded to Compound A, semi-transparent)
    CI_A,  # 6: 95% CI lower (None label)
    C_A,  # 7: EC50 reference A
    C_B,  # 8: EC50 reference B (None label)
    INK_MUTED,  # 9: Asymptotes (structural, very subtle)
    C_A,  # 10: Error bars A (None label)
    C_B,  # 11: Error bars B (None label)
)


def four_pl(x, bottom, top, ec50, hill):
    return bottom + (top - bottom) / (1 + (ec50 / x) ** hill)


def fmt_concentration(log_val):
    """Format log10 concentration to human-readable units for interactive tooltips."""
    val = 10 ** float(log_val)
    if val >= 1e-6:
        return f"{val * 1e6:.1f} µM"
    if val >= 1e-9:
        return f"{val * 1e9:.1f} nM"
    return f"{val * 1e12:.1f} pM"


# Data
np.random.seed(42)
concentrations = np.logspace(-9, -4, 8)
log_conc = np.log10(concentrations)

# Compound A — potent agonist (EC50 ~100 nM)
response_a_true = four_pl(concentrations, 5, 95, 1e-7, 1.2)
response_a_sem = np.random.uniform(2, 5, len(concentrations))
response_a = response_a_true + np.random.normal(0, response_a_sem)

# Compound B — moderate agonist (EC50 ~1 µM)
response_b_true = four_pl(concentrations, 10, 85, 1e-6, 0.9)
response_b_sem = np.random.uniform(2, 6, len(concentrations))
response_b = response_b_true + np.random.normal(0, response_b_sem)

# Fit 4PL curves
popt_a, pcov_a = curve_fit(four_pl, concentrations, response_a, p0=[0, 100, 1e-7, 1], maxfev=10000)
popt_b, pcov_b = curve_fit(four_pl, concentrations, response_b, p0=[0, 100, 1e-6, 1], maxfev=10000)

# Smooth curves for plotting
conc_smooth = np.logspace(-9.5, -3.5, 200)
log_smooth = np.log10(conc_smooth)
fit_a = four_pl(conc_smooth, *popt_a)
fit_b = four_pl(conc_smooth, *popt_b)

# Extract parameters
bottom_a, top_a, ec50_a, hill_a = popt_a
bottom_b, top_b, ec50_b, hill_b = popt_b
half_a = bottom_a + (top_a - bottom_a) / 2
half_b = bottom_b + (top_b - bottom_b) / 2
log_ec50_a = np.log10(ec50_a)
log_ec50_b = np.log10(ec50_b)

# 95% CI for Compound A via covariance sampling
np.random.seed(99)
param_samples = np.random.multivariate_normal(popt_a, pcov_a, size=200)
fit_ensemble = np.array([four_pl(conc_smooth, *p) for p in param_samples])
ci_lower = np.percentile(fit_ensemble, 2.5, axis=0)
ci_upper = np.percentile(fit_ensemble, 97.5, axis=0)

# Title (49 chars < 67 baseline → keep full title_font_size=66)
title = "curve-dose-response · python · pygal · anyplot.ai"

# Style — canonical pygal sizing for 3200×1800 canvas
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=CHART_COLORS,
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=2.5,
    font_family="sans-serif",
)

# Chart
chart = pygal.XY(
    width=3200,
    height=1800,
    style=custom_style,
    title=title,
    x_title="log₁₀ Concentration (M)",
    y_title="Response (%)",
    show_dots=False,
    stroke=True,
    show_x_guides=False,
    show_y_guides=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=4,
    legend_box_size=28,
    x_label_rotation=0,
    truncate_legend=-1,
    range=(0, 105),
    allow_interruptions=True,
    js=[],
    print_values=False,
    value_formatter=lambda y: f"{y:.1f}%",
    x_value_formatter=fmt_concentration,
)

# Restrict y-axis ticks to 5 clean quartile levels — reduces grid clutter
chart.y_labels = [0, 25, 50, 75, 100]

# Fitted curves (solid, prominent — primary data layer)
chart.add(
    f"Compound A  EC₅₀={ec50_a:.1e} M",
    list(zip(log_smooth.tolist(), fit_a.tolist(), strict=True)),
    show_dots=False,
    stroke_style={"width": 8, "linecap": "round", "linejoin": "round"},
)
chart.add(
    f"Compound B  EC₅₀={ec50_b:.1e} M",
    list(zip(log_smooth.tolist(), fit_b.tolist(), strict=True)),
    show_dots=False,
    stroke_style={"width": 8, "linecap": "round", "linejoin": "round"},
)

# Data points (scatter, no connecting line)
chart.add(
    "Data A ± SEM",
    list(zip(log_conc.tolist(), response_a.tolist(), strict=True)),
    stroke=False,
    show_dots=True,
    dots_size=14,
)
chart.add(
    "Data B ± SEM",
    list(zip(log_conc.tolist(), response_b.tolist(), strict=True)),
    stroke=False,
    show_dots=True,
    dots_size=14,
)

# 95% CI bounds for Compound A — color-coded to Compound A via RGBA semi-transparent stroke
chart.add(
    "95% CI (A)",
    list(zip(log_smooth.tolist(), ci_upper.tolist(), strict=True)),
    show_dots=False,
    stroke_style={"width": 3, "dasharray": "8, 5"},
)
chart.add(
    None,
    list(zip(log_smooth.tolist(), ci_lower.tolist(), strict=True)),
    show_dots=False,
    stroke_style={"width": 3, "dasharray": "8, 5"},
)

# EC50 reference lines — vertical + horizontal crosshair per compound
chart.add(
    "EC₅₀ refs.",
    [(log_ec50_a, 0), (log_ec50_a, half_a), None, (log_smooth[0], half_a), (log_ec50_a, half_a)],
    show_dots=False,
    dots_size=0,
    stroke_style={"width": 3, "dasharray": "18, 10"},
)
chart.add(
    None,
    [(log_ec50_b, 0), (log_ec50_b, half_b), None, (log_smooth[0], half_b), (log_ec50_b, half_b)],
    show_dots=False,
    dots_size=0,
    stroke_style={"width": 3, "dasharray": "18, 10"},
)

# Asymptote lines (very fine dash — background reference only)
x_lo, x_hi = log_smooth[0], log_smooth[-1]
chart.add(
    "Asymptotes",
    [
        (x_lo, top_a),
        (x_hi, top_a),
        None,
        (x_lo, bottom_a),
        (x_hi, bottom_a),
        None,
        (x_lo, top_b),
        (x_hi, top_b),
        None,
        (x_lo, bottom_b),
        (x_hi, bottom_b),
    ],
    show_dots=False,
    dots_size=0,
    stroke_style={"width": 1.5, "dasharray": "4, 8"},
)

# Error bars — stems and caps
cap = 0.06
for resp, sem in [(response_a, response_a_sem), (response_b, response_b_sem)]:
    pts = []
    for i in range(len(log_conc)):
        x = log_conc[i]
        lo = resp[i] - sem[i]
        hi = resp[i] + sem[i]
        pts.extend([(x, lo), (x, hi), None, (x - cap, lo), (x + cap, lo), None, (x - cap, hi), (x + cap, hi), None])
    chart.add(None, pts, stroke=True, show_dots=False, dots_size=0, stroke_style={"width": 2})

# Save
svg = chart.render(is_unicode=True)
with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:
    f.write(svg)
cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=f"plot-{THEME}.png", dpi=96)

Part of Pharmacological Dose-Response Curve on anyplot.ai.

Other implementations