|
| 1 | +""" pyplots.ai |
| 2 | +titration-curve: Acid-Base Titration Curve |
| 3 | +Library: plotly 6.6.0 | Python 3.14.3 |
| 4 | +Quality: 90/100 | Created: 2026-03-21 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import plotly.graph_objects as go |
| 9 | +from plotly.subplots import make_subplots |
| 10 | + |
| 11 | + |
| 12 | +# Data — 25 mL of 0.1 M HCl titrated with 0.1 M NaOH |
| 13 | +c_acid = 0.1 |
| 14 | +v_acid = 25.0 |
| 15 | +c_base = 0.1 |
| 16 | +volume_ml = np.concatenate([np.linspace(0.0, 24.0, 80), np.linspace(24.0, 26.0, 30), np.linspace(26.0, 50.0, 50)]) |
| 17 | +volume_ml = np.unique(volume_ml) |
| 18 | + |
| 19 | +ph = np.zeros_like(volume_ml) |
| 20 | +for i, v in enumerate(volume_ml): |
| 21 | + total_vol = (v_acid + v) / 1000.0 |
| 22 | + moles_acid = c_acid * v_acid / 1000.0 |
| 23 | + moles_base = c_base * v / 1000.0 |
| 24 | + diff = moles_acid - moles_base |
| 25 | + |
| 26 | + if diff > 1e-10: |
| 27 | + h_conc = diff / total_vol |
| 28 | + ph[i] = -np.log10(h_conc) |
| 29 | + elif diff < -1e-10: |
| 30 | + oh_conc = -diff / total_vol |
| 31 | + poh = -np.log10(oh_conc) |
| 32 | + ph[i] = 14.0 - poh |
| 33 | + else: |
| 34 | + ph[i] = 7.0 |
| 35 | + |
| 36 | +ph = np.clip(ph, 0, 14) |
| 37 | + |
| 38 | +# Derivative (dpH/dV) using central differences |
| 39 | +dph_dv = np.gradient(ph, volume_ml) |
| 40 | + |
| 41 | +# Equivalence point — theoretical value for strong acid/strong base |
| 42 | +eq_volume = 25.0 |
| 43 | +eq_ph = 7.0 |
| 44 | + |
| 45 | +# Buffer region — where pH changes slowly (flat part of the curve) |
| 46 | +# For strong acid/base: the region before the steep transition where excess acid buffers |
| 47 | +buffer_start = 5.0 |
| 48 | +buffer_end = 20.0 |
| 49 | +buffer_mask_pre = (volume_ml >= buffer_start) & (volume_ml <= buffer_end) |
| 50 | + |
| 51 | +# Plot — dual y-axis |
| 52 | +fig = make_subplots(specs=[[{"secondary_y": True}]]) |
| 53 | + |
| 54 | +# Buffer region shading via filled area |
| 55 | +buffer_vols = volume_ml[buffer_mask_pre] |
| 56 | +buffer_phs = ph[buffer_mask_pre] |
| 57 | +if len(buffer_vols) > 0: |
| 58 | + fig.add_trace( |
| 59 | + go.Scatter( |
| 60 | + x=np.concatenate([buffer_vols, buffer_vols[::-1]]), |
| 61 | + y=np.concatenate([buffer_phs, np.full(len(buffer_phs), 0.0)]), |
| 62 | + fill="toself", |
| 63 | + fillcolor="rgba(48, 105, 152, 0.12)", |
| 64 | + line={"width": 0}, |
| 65 | + name="Buffer Region", |
| 66 | + showlegend=True, |
| 67 | + hoverinfo="skip", |
| 68 | + ), |
| 69 | + secondary_y=False, |
| 70 | + ) |
| 71 | + # Buffer region label centered in shaded area |
| 72 | + fig.add_annotation( |
| 73 | + x=(buffer_start + buffer_end) / 2, |
| 74 | + y=2.8, |
| 75 | + text="Buffer Region", |
| 76 | + showarrow=False, |
| 77 | + font={"size": 16, "color": "rgba(48, 105, 152, 0.8)", "family": "Arial"}, |
| 78 | + ) |
| 79 | + |
| 80 | +# Main pH curve |
| 81 | +fig.add_trace( |
| 82 | + go.Scatter( |
| 83 | + x=volume_ml, |
| 84 | + y=ph, |
| 85 | + mode="lines", |
| 86 | + name="pH", |
| 87 | + line={"color": "#306998", "width": 3.5}, |
| 88 | + hovertemplate="Volume: %{x:.1f} mL<br>pH: %{y:.2f}<extra></extra>", |
| 89 | + ), |
| 90 | + secondary_y=False, |
| 91 | +) |
| 92 | + |
| 93 | +# Derivative curve |
| 94 | +fig.add_trace( |
| 95 | + go.Scatter( |
| 96 | + x=volume_ml, |
| 97 | + y=dph_dv, |
| 98 | + mode="lines", |
| 99 | + name="dpH/dV", |
| 100 | + line={"color": "#E8873A", "width": 2.5, "dash": "dot"}, |
| 101 | + hovertemplate="Volume: %{x:.1f} mL<br>dpH/dV: %{y:.2f}<extra></extra>", |
| 102 | + ), |
| 103 | + secondary_y=True, |
| 104 | +) |
| 105 | + |
| 106 | +# Equivalence point vertical line |
| 107 | +fig.add_vline(x=eq_volume, line_dash="dash", line_color="rgba(120, 120, 120, 0.5)", line_width=1.5) |
| 108 | + |
| 109 | +# Equivalence point marker |
| 110 | +fig.add_trace( |
| 111 | + go.Scatter( |
| 112 | + x=[eq_volume], |
| 113 | + y=[eq_ph], |
| 114 | + mode="markers", |
| 115 | + name="Equivalence Point", |
| 116 | + marker={"size": 14, "color": "#D64545", "symbol": "diamond", "line": {"width": 2, "color": "white"}}, |
| 117 | + showlegend=False, |
| 118 | + hovertemplate="Equivalence Point<br>%{x:.1f} mL, pH %{y:.1f}<extra></extra>", |
| 119 | + ), |
| 120 | + secondary_y=False, |
| 121 | +) |
| 122 | + |
| 123 | +# Equivalence point annotation — offset to avoid overlap with derivative spike |
| 124 | +fig.add_annotation( |
| 125 | + x=eq_volume, |
| 126 | + y=eq_ph, |
| 127 | + text=f"Equivalence Point<br>{eq_volume:.1f} mL, pH {eq_ph:.1f}", |
| 128 | + showarrow=True, |
| 129 | + arrowhead=2, |
| 130 | + arrowsize=1, |
| 131 | + arrowwidth=1.5, |
| 132 | + arrowcolor="#666666", |
| 133 | + ax=90, |
| 134 | + ay=-70, |
| 135 | + font={"size": 16, "color": "#333333", "family": "Arial"}, |
| 136 | + bgcolor="rgba(255, 255, 255, 0.85)", |
| 137 | + bordercolor="rgba(100, 100, 100, 0.3)", |
| 138 | + borderwidth=1, |
| 139 | + borderpad=6, |
| 140 | +) |
| 141 | + |
| 142 | +# Style |
| 143 | +fig.update_layout( |
| 144 | + title={ |
| 145 | + "text": "HCl + NaOH Titration · titration-curve · plotly · pyplots.ai", |
| 146 | + "font": {"size": 28, "family": "Arial", "color": "#2a2a2a"}, |
| 147 | + "x": 0.5, |
| 148 | + "xanchor": "center", |
| 149 | + }, |
| 150 | + template="plotly_white", |
| 151 | + legend={ |
| 152 | + "font": {"size": 18, "family": "Arial"}, |
| 153 | + "x": 0.02, |
| 154 | + "y": 0.98, |
| 155 | + "bgcolor": "rgba(255, 255, 255, 0.9)", |
| 156 | + "bordercolor": "rgba(200, 200, 200, 0.5)", |
| 157 | + "borderwidth": 1, |
| 158 | + }, |
| 159 | + margin={"l": 80, "r": 90, "t": 100, "b": 80}, |
| 160 | + plot_bgcolor="rgba(250, 250, 252, 1)", |
| 161 | + hovermode="x unified", |
| 162 | +) |
| 163 | + |
| 164 | +fig.update_xaxes( |
| 165 | + title={"text": "Volume of NaOH added (mL)", "font": {"size": 22, "family": "Arial"}}, |
| 166 | + tickfont={"size": 18, "family": "Arial"}, |
| 167 | + showgrid=False, |
| 168 | + showline=True, |
| 169 | + linewidth=1, |
| 170 | + linecolor="#CCCCCC", |
| 171 | + zeroline=False, |
| 172 | + ticks="outside", |
| 173 | + tickwidth=1, |
| 174 | + tickcolor="#CCCCCC", |
| 175 | + ticklen=5, |
| 176 | +) |
| 177 | + |
| 178 | +fig.update_yaxes( |
| 179 | + title={"text": "pH", "font": {"size": 22, "family": "Arial"}}, |
| 180 | + tickfont={"size": 18, "family": "Arial"}, |
| 181 | + range=[0, 14], |
| 182 | + showgrid=True, |
| 183 | + gridwidth=1, |
| 184 | + gridcolor="rgba(0, 0, 0, 0.06)", |
| 185 | + showline=True, |
| 186 | + linewidth=1, |
| 187 | + linecolor="#CCCCCC", |
| 188 | + zeroline=False, |
| 189 | + ticks="outside", |
| 190 | + tickwidth=1, |
| 191 | + tickcolor="#CCCCCC", |
| 192 | + ticklen=5, |
| 193 | + dtick=2, |
| 194 | + secondary_y=False, |
| 195 | +) |
| 196 | + |
| 197 | +fig.update_yaxes( |
| 198 | + title={"text": "dpH/dV (mL⁻¹)", "font": {"size": 22, "family": "Arial"}}, |
| 199 | + tickfont={"size": 18, "family": "Arial"}, |
| 200 | + showgrid=False, |
| 201 | + showline=True, |
| 202 | + linewidth=1, |
| 203 | + linecolor="#CCCCCC", |
| 204 | + zeroline=False, |
| 205 | + ticks="outside", |
| 206 | + tickwidth=1, |
| 207 | + tickcolor="#CCCCCC", |
| 208 | + ticklen=5, |
| 209 | + secondary_y=True, |
| 210 | +) |
| 211 | + |
| 212 | +# Save |
| 213 | +fig.write_image("plot.png", width=1600, height=900, scale=3) |
| 214 | +fig.write_html("plot.html") |
0 commit comments