{"spec_id":"curve-dose-response","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ncurve-dose-response: Pharmacological Dose-Response Curve\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 91/100 | Updated: 2026-06-24\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\nfrom scipy.optimize import curve_fit\nfrom scipy.stats import t as t_dist\n\n\n# Theme-adaptive chrome tokens (Imprint palette)\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\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 palette — positions 1 and 2 for two-series categorical\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data\nnp.random.seed(42)\nconcentrations = np.logspace(-9, -4, 10)\n\n\ndef logistic_4pl(x, bottom, top, ec50, hill):\n    return bottom + (top - bottom) / (1 + (ec50 / x) ** hill)\n\n\ncompound_params = {\n    \"Atorvastatin\": {\"bottom\": 5, \"top\": 95, \"ec50\": 1e-7, \"hill\": 1.2},\n    \"Simvastatin\": {\"bottom\": 8, \"top\": 88, \"ec50\": 3e-6, \"hill\": 1.8},\n}\n\nrows = []\nfor name, params in compound_params.items():\n    true_response = logistic_4pl(concentrations, params[\"bottom\"], params[\"top\"], params[\"ec50\"], params[\"hill\"])\n    noise = np.random.normal(0, 3, size=(5, len(concentrations)))\n    replicates = true_response + noise\n    means = replicates.mean(axis=0)\n    sems = replicates.std(axis=0, ddof=1) / np.sqrt(5)\n    for c, m, s in zip(concentrations, means, sems, strict=True):\n        rows.append(\n            {\n                \"concentration\": c,\n                \"log_conc\": np.log10(c),\n                \"response\": m,\n                \"sem\": s,\n                \"response_upper\": m + s,\n                \"response_lower\": m - s,\n                \"compound\": name,\n            }\n        )\n\ndf = pd.DataFrame(rows)\n\n# Fit 4PL curves, compute smooth fit lines and 95% CI via delta method\nfit_rows = []\nref_rows = []\nci_rows = []\n\nfor name, group in df.groupby(\"compound\"):\n    xdata = group[\"concentration\"].values\n    ydata = group[\"response\"].values\n    params_init = compound_params[name]\n    p0 = [params_init[\"bottom\"], params_init[\"top\"], params_init[\"ec50\"], params_init[\"hill\"]]\n\n    popt, pcov = curve_fit(logistic_4pl, xdata, ydata, p0=p0, maxfev=10000)\n    bottom_fit, top_fit, ec50_fit, hill_fit = popt\n\n    x_smooth = np.logspace(-9.5, -3.5, 200)\n    y_smooth = logistic_4pl(x_smooth, *popt)\n    for xs, ys in zip(x_smooth, y_smooth, strict=True):\n        fit_rows.append({\"log_conc\": np.log10(xs), \"response\": ys, \"compound\": name})\n\n    n = len(xdata)\n    dof = n - len(popt)\n    t_val = t_dist.ppf(0.975, dof)\n\n    jacobian = np.zeros((len(x_smooth), 4))\n    for i, xs in enumerate(x_smooth):\n        ratio = (ec50_fit / xs) ** hill_fit\n        denom = 1 + ratio\n        jacobian[i, 0] = 1 - 1 / denom\n        jacobian[i, 1] = 1 / denom\n        jacobian[i, 2] = -(top_fit - bottom_fit) * hill_fit * ratio / (ec50_fit * denom**2)\n        jacobian[i, 3] = -(top_fit - bottom_fit) * ratio * np.log(ec50_fit / xs) / denom**2\n\n    pred_var = np.array([j @ pcov @ j for j in jacobian])\n    pred_se = np.sqrt(np.maximum(pred_var, 0))\n    ci_upper = logistic_4pl(x_smooth, *popt) + t_val * pred_se\n    ci_lower = logistic_4pl(x_smooth, *popt) - t_val * pred_se\n    for xs, cu, cl in zip(x_smooth, ci_upper, ci_lower, strict=True):\n        ci_rows.append({\"log_conc\": np.log10(xs), \"ci_upper\": cu, \"ci_lower\": cl, \"compound\": name})\n\n    half_response = (bottom_fit + top_fit) / 2\n    ec50_sci = f\"{ec50_fit:.1e}\"\n    ref_rows.append(\n        {\n            \"compound\": name,\n            \"ec50_log\": np.log10(ec50_fit),\n            \"half_response\": half_response,\n            \"bottom_fit\": bottom_fit,\n            \"top_fit\": top_fit,\n            \"ec50_label\": f\"EC₅₀ = {ec50_sci} M\",\n            \"x_left\": -9.5,  # left edge of x-domain for clipped hline\n            \"y_bottom\": 0.0,  # bottom of y-domain for clipped vline\n        }\n    )\n\ndf_fit = pd.DataFrame(fit_rows)\ndf_ci = pd.DataFrame(ci_rows)\ndf_ref = pd.DataFrame(ref_rows)\n\n# Color scale — Imprint positions 1 (#009E73) and 2 (#C475FD)\ncolor_scale = alt.Scale(domain=[\"Atorvastatin\", \"Simvastatin\"], range=[IMPRINT_PALETTE[0], IMPRINT_PALETTE[1]])\n# Shared encoding shortcuts for multi-layer color consistency\ncolor_no_legend = alt.Color(\"compound:N\", scale=color_scale, legend=None)\ncolor_with_legend = alt.Color(\"compound:N\", scale=color_scale, legend=alt.Legend(title=\"Compound\"))\n\n# Nearest-point hover selection\nnearest = alt.selection_point(nearest=True, on=\"pointerover\", fields=[\"log_conc\"], empty=False)\n\nbase_x = alt.X(\n    \"log_conc:Q\",\n    title=\"log₁₀ Concentration (M)\",\n    scale=alt.Scale(domain=[-9.5, -3.5]),\n    axis=alt.Axis(values=list(range(-9, -3))),\n)\nbase_y = alt.Y(\n    \"response:Q\", title=\"Response (%)\", scale=alt.Scale(domain=[0, 105]), axis=alt.Axis(values=[0, 20, 40, 60, 80, 100])\n)\n\n# 95% CI shaded bands\nci_band = (\n    alt.Chart(df_ci)\n    .mark_area(opacity=0.22)\n    .encode(x=alt.X(\"log_conc:Q\"), y=alt.Y(\"ci_lower:Q\"), y2=\"ci_upper:Q\", color=color_no_legend)\n)\n\n# Fitted 4PL curves — legend anchor layer\nfitted_lines = (\n    alt.Chart(df_fit).mark_line(strokeWidth=2.5).encode(x=alt.X(\"log_conc:Q\"), y=base_y, color=color_with_legend)\n)\n\n# SEM error bars\nerror_bars = (\n    alt.Chart(df)\n    .mark_rule(strokeWidth=1.5)\n    .encode(x=alt.X(\"log_conc:Q\"), y=alt.Y(\"response_lower:Q\"), y2=\"response_upper:Q\", color=color_no_legend)\n)\n\n# Invisible hover capture layer\nselect_layer = (\n    alt.Chart(df)\n    .mark_point(size=200, opacity=0)\n    .encode(x=alt.X(\"log_conc:Q\"), y=alt.Y(\"response:Q\"))\n    .add_params(nearest)\n)\n\n# Hover crosshair\nhover_rule = (\n    alt.Chart(df)\n    .mark_rule(strokeWidth=1, color=INK_MUTED, strokeDash=[3, 3])\n    .encode(x=alt.X(\"log_conc:Q\"))\n    .transform_filter(nearest)\n)\n\n# Data points with hover-size interaction and tooltips\ndata_points = (\n    alt.Chart(df)\n    .mark_point(filled=True, stroke=\"white\", strokeWidth=1)\n    .encode(\n        x=base_x,\n        y=base_y,\n        color=color_no_legend,\n        size=alt.condition(nearest, alt.value(100), alt.value(50)),\n        tooltip=[\n            alt.Tooltip(\"compound:N\", title=\"Compound\"),\n            alt.Tooltip(\"log_conc:Q\", title=\"log₁₀ [C]\", format=\".2f\"),\n            alt.Tooltip(\"response:Q\", title=\"Response (%)\", format=\".1f\"),\n            alt.Tooltip(\"sem:Q\", title=\"SEM\", format=\".2f\"),\n        ],\n    )\n)\n\n# EC50 reference lines — clipped to form an \"L\" pointing at the EC50 intersection.\n# Horizontal: left edge → EC50 x-position (avoids cluttering the right half)\nec50_hlines = (\n    alt.Chart(df_ref)\n    .mark_rule(strokeDash=[6, 4], strokeWidth=1.5, opacity=0.5)\n    .encode(x=alt.X(\"x_left:Q\"), x2=\"ec50_log:Q\", y=alt.Y(\"half_response:Q\"), color=color_no_legend)\n)\n\n# Vertical: bottom → half-response (drops down to the x-axis)\nec50_vlines = (\n    alt.Chart(df_ref)\n    .mark_rule(strokeDash=[6, 4], strokeWidth=1.5, opacity=0.5)\n    .encode(x=alt.X(\"ec50_log:Q\"), y=alt.Y(\"y_bottom:Q\"), y2=\"half_response:Q\", color=color_no_legend)\n)\n\n# Top and bottom asymptote guides\nasymptote_top = (\n    alt.Chart(df_ref)\n    .mark_rule(strokeDash=[3, 3], strokeWidth=1, opacity=0.4)\n    .encode(y=alt.Y(\"top_fit:Q\"), color=color_no_legend)\n)\n\nasymptote_bottom = (\n    alt.Chart(df_ref)\n    .mark_rule(strokeDash=[3, 3], strokeWidth=1, opacity=0.4)\n    .encode(y=alt.Y(\"bottom_fit:Q\"), color=color_no_legend)\n)\n\n# EC50 value labels — offset above the intersection to avoid overlap with reference lines\nec50_labels = (\n    alt.Chart(df_ref)\n    .mark_text(fontSize=11, fontWeight=\"bold\", align=\"left\", dx=5, dy=-10)\n    .encode(x=alt.X(\"ec50_log:Q\"), y=alt.Y(\"half_response:Q\"), text=alt.Text(\"ec50_label:N\"), color=color_no_legend)\n)\n\n# Compose — resolve_scale(color=\"independent\") needed because layers span\n# four distinct DataFrames (df, df_fit, df_ci, df_ref); each layer's explicit\n# scale range is identical, so colors stay consistent across layers.\nchart = (\n    (\n        ci_band\n        + asymptote_top\n        + asymptote_bottom\n        + ec50_hlines\n        + ec50_vlines\n        + fitted_lines\n        + error_bars\n        + data_points\n        + ec50_labels\n        + select_layer\n        + hover_rule\n    )\n    .resolve_scale(color=\"independent\")\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"curve-dose-response · python · altair · anyplot.ai\",\n            subtitle=\"4-Parameter Logistic Fit with 95% Confidence Intervals\",\n            fontSize=16,\n            subtitleFontSize=11,\n            subtitleColor=INK_SOFT,\n            color=INK,\n            anchor=\"start\",\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_title(color=INK)\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        gridColor=INK,\n        gridOpacity=0.12,\n        domainColor=INK_SOFT,\n        tickColor=INK_SOFT,\n    )\n    .configure_legend(\n        titleFontSize=10,\n        labelFontSize=10,\n        symbolSize=80,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        fillColor=ELEVATED_BG,\n        strokeColor=INK_SOFT,\n    )\n)\n\n# Save — scale_factor=4.0 with width=620, height=320 targets 3200×1800\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\nchart.save(f\"plot-{THEME}.html\")\n\n# Pad PNG to exact target (vl-convert can land slightly short; never crop)\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n"}