Skip to content

Commit f330c0b

Browse files
feat(plotly): implement titration-curve (#5174)
## Implementation: `titration-curve` - plotly Implements the **plotly** version of `titration-curve`. **File:** `plots/titration-curve/implementations/plotly.py` **Parent Issue:** #4407 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23389866054)* --------- 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 164cd6b commit f330c0b

2 files changed

Lines changed: 443 additions & 0 deletions

File tree

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

Comments
 (0)