{"spec_id":"titration-curve","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\ntitration-curve: Acid-Base Titration Curve\nLibrary: bokeh 3.9.1 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-24\n\"\"\"\n\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\n# Prevent this file from shadowing the installed bokeh package\n_here = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _here]\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Label, LinearAxis, Range1d, Span\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Theme-adaptive chrome tokens\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 — canonical order, first series always #009E73\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nANYPLOT_AMBER = \"#DDCC77\"  # warning / caution anchor\n\nCURVE_COLOR = IMPRINT_PALETTE[0]  # #009E73 — pH titration curve (first series)\nDERIV_COLOR = IMPRINT_PALETTE[2]  # #4467A3 — dpH/dV derivative (third series, blue)\nEQ_COLOR = IMPRINT_PALETTE[0]  # #009E73 — equivalence point (tied to main curve)\nACID_FILL = ANYPLOT_AMBER  # amber — excess acid region (caution/warm semantic)\nBASE_FILL = IMPRINT_PALETTE[2]  # #4467A3 — excess base region (cool/alkaline semantic)\n\n# ── Data: 25 mL of 0.1 M HCl titrated with 0.1 M NaOH ──────────────────────\nacid_volume_ml = 25.0\nacid_conc = 0.1\nbase_conc = 0.1\neq_vol = acid_volume_ml * acid_conc / base_conc  # 25 mL equivalence\n\nvolume_ml = np.unique(\n    np.concatenate(\n        [\n            np.linspace(0.1, 24.0, 80),\n            np.linspace(24.0, 26.0, 40),  # dense around equivalence point\n            np.linspace(26.0, 50.0, 80),\n        ]\n    )\n)\n\nmoles_acid = acid_conc * acid_volume_ml / 1000\nmoles_base = base_conc * volume_ml / 1000\ntotal_vol_L = (acid_volume_ml + volume_ml) / 1000\n\nph = np.empty_like(volume_ml)\nfor i in range(len(volume_ml)):\n    if moles_base[i] < moles_acid - 1e-10:\n        h_plus = (moles_acid - moles_base[i]) / total_vol_L[i]\n        ph[i] = -np.log10(h_plus)\n    elif moles_base[i] > moles_acid + 1e-10:\n        oh_minus = (moles_base[i] - moles_acid) / total_vol_L[i]\n        ph[i] = 14.0 + np.log10(oh_minus)\n    else:\n        ph[i] = 7.0\n\n# Derivative dpH/dV — central differences\ndph_dv = np.gradient(ph, volume_ml)\ndph_dv = np.where(np.isfinite(dph_dv), dph_dv, 0.0)\neq_ph = 7.0\n\nsource = ColumnDataSource(data={\"volume\": volume_ml, \"ph\": ph, \"dph_dv\": dph_dv})\n\n# ── Canvas: landscape 3200×1800 (hard rule, no deviation) ───────────────────\nW, H = 3200, 1800\n\np = figure(\n    width=W,\n    height=H,\n    x_axis_label=\"Volume of NaOH added (mL)\",\n    y_axis_label=\"pH\",\n    y_range=Range1d(0, 14),\n    title=\"titration-curve · python · bokeh · anyplot.ai\",\n    toolbar_location=None,  # keep PNG dimensions exact; HTML retains interactivity via output_file\n    min_border_bottom=160,  # 34pt tick labels + 42pt axis label\n    min_border_left=180,  # 34pt tick labels + 42pt axis label\n    min_border_top=110,  # 50pt title\n    min_border_right=220,  # right-side dpH/dV axis (label + ticks)\n)\n\n# ── Buffer region shading ────────────────────────────────────────────────────\np.add_layout(BoxAnnotation(left=0, right=15, fill_color=ACID_FILL, fill_alpha=0.09, line_color=None))\np.add_layout(BoxAnnotation(left=35, right=50, fill_color=BASE_FILL, fill_alpha=0.07, line_color=None))\n\n# Region labels (theme-adaptive muted ink)\np.add_layout(\n    Label(\n        x=7.5,\n        y=3.8,\n        text=\"Excess HCl Region\",\n        text_font_size=\"26pt\",\n        text_color=INK_MUTED,\n        text_align=\"center\",\n        text_font_style=\"italic\",\n    )\n)\np.add_layout(\n    Label(\n        x=42.5,\n        y=10.2,\n        text=\"Excess NaOH Region\",\n        text_font_size=\"26pt\",\n        text_color=INK_MUTED,\n        text_align=\"center\",\n        text_font_style=\"italic\",\n    )\n)\n\n# ── Secondary y-axis for derivative (right side) ────────────────────────────\nderiv_max = float(np.max(dph_dv)) * 1.15\np.extra_y_ranges = {\"deriv\": Range1d(start=-deriv_max * 0.05, end=deriv_max)}\nderiv_axis = LinearAxis(\n    y_range_name=\"deriv\",\n    axis_label=\"dpH/dV (mL⁻¹)\",\n    axis_label_text_font_size=\"42pt\",\n    axis_label_text_color=DERIV_COLOR,\n    major_label_text_font_size=\"34pt\",\n    major_label_text_color=DERIV_COLOR,\n    axis_line_color=DERIV_COLOR,\n    major_tick_line_color=None,\n    minor_tick_line_color=None,\n)\np.add_layout(deriv_axis, \"right\")\n\n# ── Derivative curve (dashed, secondary axis) ────────────────────────────────\np.line(\n    \"volume\",\n    \"dph_dv\",\n    source=ColumnDataSource(data={\"volume\": volume_ml, \"dph_dv\": dph_dv}),\n    line_width=3,\n    color=DERIV_COLOR,\n    line_alpha=0.85,\n    line_dash=\"dashed\",\n    y_range_name=\"deriv\",\n    legend_label=\"dpH/dV\",\n)\n\n# ── Main titration curve (solid, prominent) ──────────────────────────────────\np.line(\"volume\", \"ph\", source=source, line_width=5, color=CURVE_COLOR, legend_label=\"pH\")\n\n# ── Equivalence point ─────────────────────────────────────────────────────────\np.add_layout(\n    Span(location=eq_vol, dimension=\"height\", line_color=EQ_COLOR, line_width=2.5, line_dash=\"dashed\", line_alpha=0.7)\n)\np.scatter([eq_vol], [eq_ph], size=22, color=EQ_COLOR, marker=\"diamond\", line_color=PAGE_BG, line_width=2)\np.add_layout(\n    Label(\n        x=eq_vol,\n        y=eq_ph,\n        text=f\"Equivalence Point\\n{eq_vol:.0f} mL, pH {eq_ph:.1f}\",\n        text_font_size=\"26pt\",\n        text_font_style=\"bold\",\n        text_color=EQ_COLOR,\n        x_offset=35,\n        y_offset=-30,\n    )\n)\n\n# ── pH 7 neutral reference line ──────────────────────────────────────────────\np.add_layout(\n    Span(location=7, dimension=\"width\", line_color=INK_MUTED, line_width=1.5, line_dash=\"dotted\", line_alpha=0.4)\n)\n\n# ── Hover tooltip ─────────────────────────────────────────────────────────────\np.add_tools(\n    HoverTool(tooltips=[(\"Volume\", \"@volume{0.1} mL\"), (\"pH\", \"@ph{0.2}\")], mode=\"vline\", line_policy=\"nearest\")\n)\n\n# ── Font sizes (bokeh native-pixel: 50pt title, 42pt axis, 34pt ticks) ──────\np.title.text_font_size = \"50pt\"\np.title.text_font_style = \"normal\"\np.title.text_color = INK\np.title.offset = 10\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.axis_label_standoff = 18\np.yaxis.axis_label_standoff = 18\n\n# ── Theme-adaptive chrome ─────────────────────────────────────────────────────\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.axis_line_width = 1.5\np.yaxis.axis_line_width = 1.5\np.xaxis.major_tick_line_color = None\np.yaxis.major_tick_line_color = None\np.xaxis.minor_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\np.outline_line_color = None\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\n\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.15\np.ygrid.grid_line_width = 1\np.xgrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.10\np.xgrid.grid_line_width = 1\n\n# ── Legend ────────────────────────────────────────────────────────────────────\np.legend.location = \"top_left\"\np.legend.label_text_font_size = \"34pt\"\np.legend.label_text_color = INK_SOFT\np.legend.glyph_height = 35\np.legend.glyph_width = 50\np.legend.spacing = 14\np.legend.padding = 22\np.legend.margin = 20\np.legend.background_fill_alpha = 0.92\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = INK_SOFT\np.legend.border_line_width = 1.5\n\n# ── Save HTML (interactive artifact) then screenshot via Selenium ────────────\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\n\ndriver = webdriver.Chrome(options=opts)\n# Force the viewport to the exact canvas size via CDP (headless=new outer vs inner window differs)\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)  # let bokeh JS render the canvas\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}