|
| 1 | +""" pyplots.ai |
| 2 | +titration-curve: Acid-Base Titration Curve |
| 3 | +Library: bokeh 3.9.0 | Python 3.14.3 |
| 4 | +Quality: 91/100 | Created: 2026-03-21 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +from bokeh.io import export_png, save |
| 9 | +from bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Label, LinearAxis, Range1d, Span |
| 10 | +from bokeh.plotting import figure |
| 11 | +from bokeh.resources import CDN |
| 12 | + |
| 13 | + |
| 14 | +# Data - Strong acid/strong base titration: 25 mL of 0.1 M HCl with 0.1 M NaOH |
| 15 | +acid_volume_ml = 25.0 |
| 16 | +acid_concentration = 0.1 |
| 17 | +base_concentration = 0.1 |
| 18 | +equivalence_volume = acid_volume_ml * acid_concentration / base_concentration # 25 mL |
| 19 | + |
| 20 | +volume_ml = np.unique( |
| 21 | + np.concatenate([np.linspace(0.1, 24.0, 80), np.linspace(24.0, 26.0, 40), np.linspace(26.0, 50.0, 80)]) |
| 22 | +) |
| 23 | + |
| 24 | +moles_acid = acid_concentration * acid_volume_ml / 1000 |
| 25 | +moles_base = base_concentration * volume_ml / 1000 |
| 26 | +total_volume_L = (acid_volume_ml + volume_ml) / 1000 |
| 27 | + |
| 28 | +ph = np.empty_like(volume_ml) |
| 29 | +for i in range(len(volume_ml)): |
| 30 | + if moles_base[i] < moles_acid - 1e-10: |
| 31 | + h_plus = (moles_acid - moles_base[i]) / total_volume_L[i] |
| 32 | + ph[i] = -np.log10(h_plus) |
| 33 | + elif moles_base[i] > moles_acid + 1e-10: |
| 34 | + oh_minus = (moles_base[i] - moles_acid) / total_volume_L[i] |
| 35 | + ph[i] = 14.0 + np.log10(oh_minus) |
| 36 | + else: |
| 37 | + ph[i] = 7.0 |
| 38 | + |
| 39 | +# Derivative dpH/dV using central differences |
| 40 | +dph_dv = np.gradient(ph, volume_ml) |
| 41 | +dph_dv = np.where(np.isfinite(dph_dv), dph_dv, 0.0) |
| 42 | +eq_ph = 7.0 |
| 43 | + |
| 44 | +# Colors |
| 45 | +CURVE_COLOR = "#306998" |
| 46 | +DERIV_COLOR = "#D55E00" |
| 47 | +EQ_COLOR = "#009E73" |
| 48 | +ACID_BUFFER_COLOR = "#E69F00" |
| 49 | +BASE_BUFFER_COLOR = "#56B4E9" |
| 50 | +BG_COLOR = "#F7F7F7" |
| 51 | +AXIS_COLOR = "#444444" |
| 52 | + |
| 53 | +source = ColumnDataSource(data={"volume": volume_ml, "ph": ph, "dph_dv": dph_dv}) |
| 54 | + |
| 55 | +# Plot |
| 56 | +p = figure( |
| 57 | + width=4800, |
| 58 | + height=2700, |
| 59 | + x_axis_label="Volume of NaOH added (mL)", |
| 60 | + y_axis_label="pH", |
| 61 | + y_range=Range1d(0, 14), |
| 62 | + title="titration-curve · bokeh · pyplots.ai", |
| 63 | + toolbar_location=None, |
| 64 | +) |
| 65 | + |
| 66 | +# Buffer region shading - regions where pH changes slowly (excess acid / excess base) |
| 67 | +# Acid-excess region: 0-15 mL (excess HCl dominates, pH slowly rises) |
| 68 | +acid_buffer = BoxAnnotation(left=0, right=15, fill_color=ACID_BUFFER_COLOR, fill_alpha=0.08, line_color=None) |
| 69 | +p.add_layout(acid_buffer) |
| 70 | + |
| 71 | +# Base-excess region: 35-50 mL (excess NaOH dominates, pH plateaus) |
| 72 | +basic_buffer = BoxAnnotation(left=35, right=50, fill_color=BASE_BUFFER_COLOR, fill_alpha=0.08, line_color=None) |
| 73 | +p.add_layout(basic_buffer) |
| 74 | + |
| 75 | +# Region labels - chemically accurate for strong acid/strong base system |
| 76 | +p.add_layout( |
| 77 | + Label( |
| 78 | + x=7.5, |
| 79 | + y=4.5, |
| 80 | + text="Excess HCl Region", |
| 81 | + text_font_size="20pt", |
| 82 | + text_color="#C68600", |
| 83 | + text_alpha=0.85, |
| 84 | + text_align="center", |
| 85 | + text_font_style="italic", |
| 86 | + ) |
| 87 | +) |
| 88 | +p.add_layout( |
| 89 | + Label( |
| 90 | + x=42.5, |
| 91 | + y=9.5, |
| 92 | + text="Excess NaOH Region", |
| 93 | + text_font_size="20pt", |
| 94 | + text_color="#3A8BC2", |
| 95 | + text_alpha=0.85, |
| 96 | + text_align="center", |
| 97 | + text_font_style="italic", |
| 98 | + ) |
| 99 | +) |
| 100 | + |
| 101 | +# Secondary y-axis for derivative |
| 102 | +deriv_max = float(np.max(dph_dv)) * 1.15 |
| 103 | +p.extra_y_ranges = {"deriv": Range1d(start=-deriv_max * 0.05, end=deriv_max)} |
| 104 | +deriv_axis = LinearAxis( |
| 105 | + y_range_name="deriv", |
| 106 | + axis_label="dpH/dV (mL⁻¹)", |
| 107 | + axis_label_text_font_size="22pt", |
| 108 | + major_label_text_font_size="18pt", |
| 109 | + axis_line_color=DERIV_COLOR, |
| 110 | + axis_label_text_color=DERIV_COLOR, |
| 111 | + major_label_text_color=DERIV_COLOR, |
| 112 | + major_tick_line_color=None, |
| 113 | + minor_tick_line_color=None, |
| 114 | +) |
| 115 | +p.add_layout(deriv_axis, "right") |
| 116 | + |
| 117 | +# Derivative curve |
| 118 | +deriv_source = ColumnDataSource(data={"volume": volume_ml, "dph_dv": dph_dv}) |
| 119 | +p.line( |
| 120 | + "volume", |
| 121 | + "dph_dv", |
| 122 | + source=deriv_source, |
| 123 | + line_width=4, |
| 124 | + color=DERIV_COLOR, |
| 125 | + line_alpha=0.8, |
| 126 | + line_dash="dotdash", |
| 127 | + y_range_name="deriv", |
| 128 | + legend_label="dpH/dV", |
| 129 | +) |
| 130 | + |
| 131 | +# Main titration curve |
| 132 | +p.line("volume", "ph", source=source, line_width=5, color=CURVE_COLOR, legend_label="pH") |
| 133 | + |
| 134 | +# Equivalence point vertical dashed line |
| 135 | +p.add_layout( |
| 136 | + Span( |
| 137 | + location=equivalence_volume, |
| 138 | + dimension="height", |
| 139 | + line_color=EQ_COLOR, |
| 140 | + line_width=3, |
| 141 | + line_dash="dashed", |
| 142 | + line_alpha=0.8, |
| 143 | + ) |
| 144 | +) |
| 145 | + |
| 146 | +# Equivalence point marker |
| 147 | +p.scatter([equivalence_volume], [eq_ph], size=26, color=EQ_COLOR, marker="diamond", line_color="white", line_width=2) |
| 148 | + |
| 149 | +# Equivalence point annotation - offset to reduce congestion |
| 150 | +p.add_layout( |
| 151 | + Label( |
| 152 | + x=equivalence_volume, |
| 153 | + y=eq_ph, |
| 154 | + text=f"Equivalence Point\n{equivalence_volume:.0f} mL, pH {eq_ph:.1f}", |
| 155 | + text_font_size="22pt", |
| 156 | + text_font_style="bold", |
| 157 | + text_color=EQ_COLOR, |
| 158 | + x_offset=35, |
| 159 | + y_offset=-30, |
| 160 | + ) |
| 161 | +) |
| 162 | + |
| 163 | +# pH 7 reference line |
| 164 | +p.add_layout( |
| 165 | + Span(location=7, dimension="width", line_color="#999999", line_width=1.5, line_dash="dotted", line_alpha=0.35) |
| 166 | +) |
| 167 | + |
| 168 | +# Hover tool |
| 169 | +p.add_tools( |
| 170 | + HoverTool(tooltips=[("Volume", "@volume{0.1} mL"), ("pH", "@ph{0.2}")], mode="vline", line_policy="nearest") |
| 171 | +) |
| 172 | + |
| 173 | +# Style |
| 174 | +p.title.text_font_size = "36pt" |
| 175 | +p.title.text_font_style = "normal" |
| 176 | +p.title.text_color = "#2B2B2B" |
| 177 | +p.title.offset = 10 |
| 178 | + |
| 179 | +p.xaxis.axis_label_text_font_size = "26pt" |
| 180 | +p.yaxis.axis_label_text_font_size = "26pt" |
| 181 | +p.xaxis.major_label_text_font_size = "20pt" |
| 182 | +p.yaxis.major_label_text_font_size = "20pt" |
| 183 | +p.xaxis.axis_label_standoff = 18 |
| 184 | +p.yaxis.axis_label_standoff = 18 |
| 185 | + |
| 186 | +p.xaxis.axis_line_color = AXIS_COLOR |
| 187 | +p.yaxis.axis_line_color = AXIS_COLOR |
| 188 | +p.xaxis.axis_line_width = 1.5 |
| 189 | +p.yaxis.axis_line_width = 1.5 |
| 190 | + |
| 191 | +p.xaxis.major_tick_line_color = None |
| 192 | +p.yaxis.major_tick_line_color = None |
| 193 | +p.xaxis.minor_tick_line_color = None |
| 194 | +p.yaxis.minor_tick_line_color = None |
| 195 | + |
| 196 | +p.outline_line_color = None |
| 197 | +p.background_fill_color = BG_COLOR |
| 198 | +p.border_fill_color = "#FFFFFF" |
| 199 | + |
| 200 | +p.ygrid.grid_line_alpha = 0.18 |
| 201 | +p.ygrid.grid_line_width = 1 |
| 202 | +p.ygrid.grid_line_dash = [6, 4] |
| 203 | +p.xgrid.grid_line_alpha = 0.12 |
| 204 | +p.xgrid.grid_line_width = 1 |
| 205 | +p.xgrid.grid_line_dash = [6, 4] |
| 206 | + |
| 207 | +p.min_border_left = 120 |
| 208 | +p.min_border_right = 180 |
| 209 | +p.min_border_bottom = 80 |
| 210 | +p.min_border_top = 60 |
| 211 | + |
| 212 | +# Legend |
| 213 | +p.legend.location = "top_left" |
| 214 | +p.legend.label_text_font_size = "22pt" |
| 215 | +p.legend.glyph_height = 35 |
| 216 | +p.legend.glyph_width = 50 |
| 217 | +p.legend.spacing = 14 |
| 218 | +p.legend.padding = 22 |
| 219 | +p.legend.margin = 20 |
| 220 | +p.legend.background_fill_alpha = 0.9 |
| 221 | +p.legend.background_fill_color = "#FFFFFF" |
| 222 | +p.legend.border_line_color = "#CCCCCC" |
| 223 | +p.legend.border_line_width = 1.5 |
| 224 | + |
| 225 | +# Save |
| 226 | +export_png(p, filename="plot.png") |
| 227 | +save(p, filename="plot.html", resources=CDN, title="titration-curve · bokeh · pyplots.ai") |
0 commit comments