Skip to content

Commit 8db35a5

Browse files
feat(bokeh): implement titration-curve (#5177)
## Implementation: `titration-curve` - bokeh Implements the **bokeh** version of `titration-curve`. **File:** `plots/titration-curve/implementations/bokeh.py` **Parent Issue:** #4407 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23389866006)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 8ce84df commit 8db35a5

2 files changed

Lines changed: 451 additions & 0 deletions

File tree

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
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

Comments
 (0)