{"spec_id":"curve-dose-response","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\ncurve-dose-response: Pharmacological Dose-Response Curve\nLibrary: letsplot 4.10.1 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-24\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\nfrom scipy.optimize import curve_fit\n\n\nLetsPlot.setup_html()\n\n# Theme tokens (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\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\"\n\n# Imprint palette — first series always #009E73\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nCOLOR_A = IMPRINT_PALETTE[0]  # brand green — Compound A\nCOLOR_B = IMPRINT_PALETTE[1]  # lavender — Compound B\n\n# Data — synthetic dose-response for two pharmacological compounds\nnp.random.seed(42)\nconcentrations = np.logspace(-9, -4, 8)\n\n# 4PL model: response = Bottom + (Top - Bottom) / (1 + (EC50/conc)^Hill)\nfour_pl = lambda conc, bottom, top, ec50, hill: bottom + (top - bottom) / (1 + (ec50 / conc) ** hill)\n\n# Compound A — high potency, steep Hill slope\nbottom_a, top_a, ec50_a, hill_a = 5.0, 95.0, 1e-7, 1.2\nresponse_a = four_pl(concentrations, bottom_a, top_a, ec50_a, hill_a)\nresponse_a_noisy = response_a + np.random.normal(0, 3, len(concentrations))\nsem_a = np.random.uniform(2, 5, len(concentrations))\n\n# Compound B — lower potency, shallower Hill slope\nbottom_b, top_b, ec50_b, hill_b = 8.0, 85.0, 5e-6, 0.9\nresponse_b = four_pl(concentrations, bottom_b, top_b, ec50_b, hill_b)\nresponse_b_noisy = response_b + np.random.normal(0, 3.5, len(concentrations))\nsem_b = np.random.uniform(2.5, 5.5, len(concentrations))\n\n# Fit 4PL model to noisy data\npopt_a, pcov_a = curve_fit(four_pl, concentrations, response_a_noisy, p0=[5, 95, 1e-7, 1], maxfev=10000)\npopt_b, pcov_b = curve_fit(four_pl, concentrations, response_b_noisy, p0=[8, 85, 5e-6, 1], maxfev=10000)\n\n# Smooth fitted curves\nconc_smooth = np.logspace(-9.5, -3.5, 200)\nfit_a = four_pl(conc_smooth, *popt_a)\nfit_b = four_pl(conc_smooth, *popt_b)\n\n# 95% CI for Compound A via parameter covariance propagation\nperr_a = np.sqrt(np.diag(pcov_a))\nci_a_upper = four_pl(conc_smooth, popt_a[0] - perr_a[0], popt_a[1] + perr_a[1], popt_a[2], popt_a[3])\nci_a_lower = four_pl(conc_smooth, popt_a[0] + perr_a[0], popt_a[1] - perr_a[1], popt_a[2], popt_a[3])\n\n# EC50 and half-response values\nec50_fit_a = popt_a[2]\nec50_fit_b = popt_b[2]\nhalf_response_a = popt_a[0] + (popt_a[1] - popt_a[0]) / 2\nhalf_response_b = popt_b[0] + (popt_b[1] - popt_b[0]) / 2\n\n# Asymptote values for both compounds\ntop_asym_a, bottom_asym_a = popt_a[1], popt_a[0]\ntop_asym_b, bottom_asym_b = popt_b[1], popt_b[0]\n\n# DataFrames\ndf_points = pd.DataFrame(\n    {\n        \"concentration\": np.concatenate([concentrations, concentrations]),\n        \"log_conc\": np.concatenate([np.log10(concentrations), np.log10(concentrations)]),\n        \"response\": np.concatenate([response_a_noisy, response_b_noisy]),\n        \"sem\": np.concatenate([sem_a, sem_b]),\n        \"ymin\": np.concatenate([response_a_noisy - sem_a, response_b_noisy - sem_b]),\n        \"ymax\": np.concatenate([response_a_noisy + sem_a, response_b_noisy + sem_b]),\n        \"compound\": [\"Compound A\"] * len(concentrations) + [\"Compound B\"] * len(concentrations),\n    }\n)\n\ndf_fit = pd.DataFrame(\n    {\n        \"log_conc\": np.concatenate([np.log10(conc_smooth), np.log10(conc_smooth)]),\n        \"response\": np.concatenate([fit_a, fit_b]),\n        \"concentration\": np.concatenate([conc_smooth, conc_smooth]),\n        \"compound\": [\"Compound A\"] * len(conc_smooth) + [\"Compound B\"] * len(conc_smooth),\n    }\n)\n\ndf_ci = pd.DataFrame({\"log_conc\": np.log10(conc_smooth), \"ymin\": ci_a_lower, \"ymax\": ci_a_upper})\n\n# EC50 crosshair reference lines\ndf_ec50_h = pd.DataFrame(\n    {\n        \"log_conc\": [np.log10(conc_smooth[0]), np.log10(ec50_fit_a), np.log10(conc_smooth[0]), np.log10(ec50_fit_b)],\n        \"response\": [half_response_a, half_response_a, half_response_b, half_response_b],\n        \"compound\": [\"Compound A\", \"Compound A\", \"Compound B\", \"Compound B\"],\n    }\n)\n\ndf_ec50_v = pd.DataFrame(\n    {\n        \"log_conc\": [np.log10(ec50_fit_a), np.log10(ec50_fit_a), np.log10(ec50_fit_b), np.log10(ec50_fit_b)],\n        \"response\": [0, half_response_a, 0, half_response_b],\n        \"compound\": [\"Compound A\", \"Compound A\", \"Compound B\", \"Compound B\"],\n    }\n)\n\n# EC50 annotation labels\nec50_label_a = f\"EC₅₀ = {ec50_fit_a:.1e} M\"\nec50_label_b = f\"EC₅₀ = {ec50_fit_b:.1e} M\"\ndf_ec50_labels = pd.DataFrame(\n    {\n        \"log_conc\": [np.log10(ec50_fit_a), np.log10(ec50_fit_b)],\n        \"response\": [half_response_a + 8, half_response_b + 8],\n        \"label\": [ec50_label_a, ec50_label_b],\n        \"compound\": [\"Compound A\", \"Compound B\"],\n    }\n)\n\ntitle = \"curve-dose-response · python · letsplot · anyplot.ai\"\n\n# Plot\nplot = (\n    ggplot()\n    # 95% CI band for Compound A\n    + geom_ribbon(data=df_ci, mapping=aes(x=\"log_conc\", ymin=\"ymin\", ymax=\"ymax\"), fill=COLOR_A, alpha=0.12)\n    # Asymptote lines — dashed for both compounds (spec requires dashed, not dotted)\n    + geom_hline(yintercept=top_asym_a, linetype=\"dashed\", color=COLOR_A, size=0.6, alpha=0.35)\n    + geom_hline(yintercept=bottom_asym_a, linetype=\"dashed\", color=COLOR_A, size=0.6, alpha=0.35)\n    + geom_hline(yintercept=top_asym_b, linetype=\"dashed\", color=COLOR_B, size=0.6, alpha=0.35)\n    + geom_hline(yintercept=bottom_asym_b, linetype=\"dashed\", color=COLOR_B, size=0.6, alpha=0.35)\n    # EC50 crosshair reference lines\n    + geom_line(\n        data=df_ec50_h,\n        mapping=aes(x=\"log_conc\", y=\"response\", color=\"compound\"),\n        linetype=\"dashed\",\n        size=0.7,\n        alpha=0.6,\n    )\n    + geom_line(\n        data=df_ec50_v,\n        mapping=aes(x=\"log_conc\", y=\"response\", color=\"compound\"),\n        linetype=\"dashed\",\n        size=0.7,\n        alpha=0.6,\n    )\n    # Fitted sigmoid curves with interactive tooltips\n    + geom_line(\n        data=df_fit,\n        mapping=aes(x=\"log_conc\", y=\"response\", color=\"compound\"),\n        size=2.2,\n        tooltips=layer_tooltips().line(\"@compound\").line(\"Conc: @concentration\").line(\"Response: @response\"),\n    )\n    # Error bars (SEM)\n    + geom_errorbar(\n        data=df_points, mapping=aes(x=\"log_conc\", ymin=\"ymin\", ymax=\"ymax\", color=\"compound\"), width=0.08, size=0.7\n    )\n    # Data points with tooltips\n    + geom_point(\n        data=df_points,\n        mapping=aes(x=\"log_conc\", y=\"response\", color=\"compound\", fill=\"compound\"),\n        size=5,\n        shape=21,\n        stroke=1.5,\n        tooltips=layer_tooltips().line(\"@compound\").line(\"Conc: @concentration\").line(\"Response: @{response} ± @{sem}\"),\n    )\n    # EC50 value annotations\n    + geom_text(\n        data=df_ec50_labels,\n        mapping=aes(x=\"log_conc\", y=\"response\", label=\"label\", color=\"compound\"),\n        size=5,\n        fontface=\"italic\",\n    )\n    + scale_color_manual(values=[COLOR_A, COLOR_B])\n    + scale_fill_manual(values=[COLOR_A, COLOR_B])\n    + scale_x_continuous(breaks=list(range(-9, -3)), labels=[\"1e-9\", \"1e-8\", \"1e-7\", \"1e-6\", \"1e-5\", \"1e-4\"])\n    + labs(x=\"Concentration (M)\", y=\"Response (%)\", title=title, color=\"\", fill=\"\")\n    + theme(\n        axis_title=element_text(size=12, color=INK),\n        axis_text=element_text(size=10, color=INK_SOFT),\n        plot_title=element_text(size=16, color=INK, face=\"bold\"),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        panel_grid_major_x=element_blank(),\n        panel_grid_minor_x=element_blank(),\n        panel_grid_major_y=element_line(color=INK_SOFT, size=0.3),\n        panel_grid_minor_y=element_blank(),\n        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        axis_line_x=element_line(color=INK_SOFT, size=0.8),\n        axis_line_y=element_line(color=INK_SOFT, size=0.8),\n        axis_ticks=element_line(color=INK_SOFT, size=0.5),\n        legend_position=[0.18, 0.88],\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT, size=0),\n        plot_margin=[30, 30, 20, 20],\n    )\n    + guides(fill=\"none\")\n    + ggsize(800, 450)\n)\n\n# Save\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}