{"spec_id":"titration-curve","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ntitration-curve: Acid-Base Titration Curve\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 93/100 | Updated: 2026-06-24\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove the script's own directory from sys.path before importing altair;\n# this file is named altair.py which otherwise shadows the installed library.\n_this_dir = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _this_dir]\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Theme-adaptive chrome — Imprint palette\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 categorical palette (positions 1–8, theme-independent)\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Series colors — Imprint categorical order\nCLR_CURVE = IMPRINT_PALETTE[0]  # #009E73 brand green — pH titration curve (first series)\nCLR_DERIV = IMPRINT_PALETTE[1]  # #C475FD lavender — dpH/dV derivative (second series)\nCLR_EQUIV = IMPRINT_PALETTE[4]  # #AE3030 matte red — equivalence point annotation (semantic)\n\n# Strong acid/strong base: 25 mL of 0.1 M HCl titrated with 0.1 M NaOH\nC_acid = 0.1\nV_acid = 25.0\nC_base = 0.1\nV_equiv = C_acid * V_acid / C_base  # 25.0 mL\n\nvolume = np.unique(\n    np.concatenate(\n        [\n            np.linspace(0.1, V_equiv - 0.5, 60),\n            np.linspace(V_equiv - 0.5, V_equiv - 0.01, 30),\n            np.linspace(V_equiv + 0.01, V_equiv + 0.5, 30),\n            np.linspace(V_equiv + 0.5, 50.0, 50),\n        ]\n    )\n)\n\nph = np.zeros_like(volume)\nfor i, v in enumerate(volume):\n    total_vol = V_acid + v\n    moles_acid = C_acid * V_acid - C_base * v\n    if moles_acid > 1e-10:\n        ph[i] = -np.log10(moles_acid / total_vol)\n    elif moles_acid < -1e-10:\n        moles_base_excess = -moles_acid\n        ph[i] = 14.0 + np.log10(moles_base_excess / total_vol)\n    else:\n        ph[i] = 7.0\n\n# Insert the exact equivalence point\nequiv_idx = np.searchsorted(volume, V_equiv)\nvolume = np.insert(volume, equiv_idx, V_equiv)\nph = np.insert(ph, equiv_idx, 7.0)\n\n# Derivative (dpH/dV) using central differences\ndph_dv = np.gradient(ph, volume)\ndph_dv = np.nan_to_num(dph_dv, nan=0.0, posinf=0.0, neginf=0.0)\n\ndf = pd.DataFrame({\"volume_ml\": volume, \"ph\": ph, \"dph_dv\": dph_dv})\n\n# Annotation data\nequiv_pt = pd.DataFrame({\"volume_ml\": [V_equiv], \"ph\": [7.0]})\nequiv_line = pd.DataFrame({\"volume_ml\": [V_equiv, V_equiv], \"ph\": [0, 14]})\nequiv_label = pd.DataFrame(\n    {\"volume_ml\": [V_equiv + 0.8], \"ph\": [3.5], \"label\": [f\"Equivalence Point\\n{V_equiv:.0f} mL, pH 7.0\"]}\n)\nref_line_df = pd.DataFrame({\"volume_ml\": [0, 50], \"ph\": [7, 7]})\n\n# Scale definitions\nx_scale = alt.Scale(domain=[0, 50])\ny_scale = alt.Scale(domain=[0, 14])\n# Cap the derivative axis so regional gradients remain visible\n# (the equivalence-point spike exceeds 100 pH/mL; clipping reveals the pre/post-EP trend)\nDERIV_DISPLAY_MAX = 25.0\nderiv_scale = alt.Scale(domain=[0, DERIV_DISPLAY_MAX])\n\n# Shared base axis styling using theme-adaptive tokens\naxis_props_base = {\n    \"labelFontSize\": 10,\n    \"titleFontSize\": 12,\n    \"titleFontWeight\": \"bold\",\n    \"titleColor\": INK,\n    \"labelColor\": INK_SOFT,\n    \"domainColor\": INK_SOFT,\n    \"domainWidth\": 1.5,\n    \"tickColor\": INK_SOFT,\n    \"tickSize\": 5,\n    \"labelPadding\": 5,\n}\n# Y-axis: include subtle horizontal grid; X-axis: no grid (reduces visual noise)\ny_axis_props = {**axis_props_base, \"gridOpacity\": 0.15, \"gridWidth\": 0.5, \"gridColor\": INK}\nx_axis_props = {**axis_props_base, \"gridOpacity\": 0}\n\n# pH 7 horizontal reference line\nref_line = (\n    alt.Chart(ref_line_df)\n    .mark_line(strokeWidth=1, strokeDash=[4, 4], color=INK_MUTED, opacity=0.6)\n    .encode(x=alt.X(\"volume_ml:Q\", scale=x_scale), y=alt.Y(\"ph:Q\", scale=y_scale))\n)\n\n# Equivalence point: vertical dashed line\nequiv_vline = (\n    alt.Chart(equiv_line)\n    .mark_line(strokeWidth=1.5, strokeDash=[8, 5], color=CLR_EQUIV, opacity=0.7)\n    .encode(x=alt.X(\"volume_ml:Q\", scale=x_scale), y=alt.Y(\"ph:Q\", scale=y_scale))\n)\n\n# Equivalence point: diamond marker at pH 7\nequiv_marker = (\n    alt.Chart(equiv_pt)\n    .mark_point(size=200, shape=\"diamond\", filled=True, color=CLR_EQUIV, stroke=\"white\", strokeWidth=2.0)\n    .encode(x=alt.X(\"volume_ml:Q\", scale=x_scale), y=alt.Y(\"ph:Q\", scale=y_scale))\n)\n\n# Equivalence point: text annotation\nequiv_annotation = (\n    alt.Chart(equiv_label)\n    .mark_text(fontSize=11, fontWeight=\"bold\", color=CLR_EQUIV, align=\"left\", dx=8, lineBreak=\"\\n\")\n    .encode(x=alt.X(\"volume_ml:Q\", scale=x_scale), y=alt.Y(\"ph:Q\", scale=y_scale), text=\"label:N\")\n)\n\n# Primary titration curve (pH, left y-axis)\ntitration_line = (\n    alt.Chart(df)\n    .mark_line(strokeWidth=3, interpolate=\"monotone\")\n    .encode(\n        x=alt.X(\n            \"volume_ml:Q\", scale=x_scale, title=\"Volume of NaOH added (mL)\", axis=alt.Axis(tickCount=10, **x_axis_props)\n        ),\n        y=alt.Y(\"ph:Q\", scale=y_scale, title=\"pH\", axis=alt.Axis(titlePadding=10, **y_axis_props)),\n        color=alt.value(CLR_CURVE),\n        tooltip=[\n            alt.Tooltip(\"volume_ml:Q\", title=\"Volume (mL)\", format=\".1f\"),\n            alt.Tooltip(\"ph:Q\", title=\"pH\", format=\".2f\"),\n        ],\n    )\n)\n\n# mark_line legend: line swatches rendered at ph=-100 (outside [0,14] domain, clipped out)\nlegend_df = pd.DataFrame(\n    {\n        \"volume_ml\": [0, 50, 0, 50],\n        \"ph\": [-100.0, -100.0, -100.0, -100.0],\n        \"label\": [\"pH (titration curve)\", \"pH (titration curve)\", \"dpH/dV (derivative)\", \"dpH/dV (derivative)\"],\n    }\n)\n\nlegend_chart = (\n    alt.Chart(legend_df)\n    .mark_line(clip=True)\n    .encode(\n        x=alt.X(\"volume_ml:Q\", scale=x_scale),\n        y=alt.Y(\"ph:Q\", scale=y_scale),\n        color=alt.Color(\n            \"label:N\",\n            scale=alt.Scale(domain=[\"pH (titration curve)\", \"dpH/dV (derivative)\"], range=[CLR_CURVE, CLR_DERIV]),\n            legend=alt.Legend(\n                title=None,\n                orient=\"top-right\",\n                labelFontSize=10,\n                symbolSize=150,\n                symbolStrokeWidth=2.5,\n                padding=8,\n                cornerRadius=4,\n                fillColor=ELEVATED_BG,\n                strokeColor=INK_SOFT,\n                labelColor=INK_SOFT,\n            ),\n        ),\n    )\n)\n\n# Primary layer (left pH axis)\nprimary_layer = ref_line + titration_line + equiv_vline + equiv_marker + equiv_annotation + legend_chart\n\n# Derivative curve (dpH/dV, right y-axis) — clipped at DERIV_DISPLAY_MAX to surface regional gradients\nderiv_line = (\n    alt.Chart(df)\n    .mark_line(strokeWidth=2, strokeDash=[5, 3], interpolate=\"monotone\", opacity=0.85, clip=True)\n    .encode(\n        x=alt.X(\"volume_ml:Q\", scale=x_scale),\n        y=alt.Y(\n            \"dph_dv:Q\",\n            title=\"dpH/dV (pH/mL, clipped at 25)\",\n            scale=deriv_scale,\n            axis=alt.Axis(\n                domain=False,\n                titleColor=CLR_DERIV,\n                labelColor=CLR_DERIV,\n                titleFontSize=12,\n                titleFontWeight=\"bold\",\n                labelFontSize=10,\n                gridOpacity=0,\n                tickColor=CLR_DERIV,\n                tickSize=5,\n                labelPadding=5,\n                titlePadding=10,\n            ),\n        ),\n        color=alt.value(CLR_DERIV),\n        tooltip=[\n            alt.Tooltip(\"volume_ml:Q\", title=\"Volume (mL)\", format=\".1f\"),\n            alt.Tooltip(\"dph_dv:Q\", title=\"dpH/dV\", format=\".2f\"),\n        ],\n    )\n)\n\n# Dual y-axis chart via resolve_scale\nTITLE = \"titration-curve · python · altair · anyplot.ai\"\nchart = (\n    alt.layer(primary_layer, deriv_line)\n    .resolve_scale(y=\"independent\")\n    .properties(\n        width=620,\n        height=320,\n        title=alt.Title(\n            TITLE,\n            fontSize=16,\n            fontWeight=\"bold\",\n            color=INK,\n            subtitle=\"HCl (0.1 M, 25 mL) titrated with NaOH (0.1 M)  ·  Strong Acid / Strong Base\",\n            subtitleFontSize=11,\n            subtitleColor=INK_SOFT,\n            subtitlePadding=6,\n            anchor=\"start\",\n            offset=8,\n        ),\n    )\n    .configure_view(strokeWidth=0, strokeOpacity=0, fill=PAGE_BG)\n    .configure(background=PAGE_BG, padding={\"left\": 15, \"right\": 15, \"top\": 8, \"bottom\": 8})\n    .interactive()\n)\n\n# Save PNG — pad canvas to exactly 3200×1800 (landscape target)\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\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\nchart.save(f\"plot-{THEME}.html\")\n"}