Skip to content

Commit 412ea05

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

File tree

2 files changed

+506
-0
lines changed

2 files changed

+506
-0
lines changed
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
""" pyplots.ai
2+
titration-curve: Acid-Base Titration Curve
3+
Library: pygal 3.1.0 | Python 3.14.3
4+
Quality: 83/100 | Created: 2026-03-21
5+
"""
6+
7+
import io
8+
9+
import cairosvg
10+
import numpy as np
11+
import pygal
12+
from PIL import Image, ImageDraw, ImageFont
13+
from pygal.style import Style
14+
15+
16+
# Data — 25 mL of 0.1 M HCl titrated with 0.1 M NaOH
17+
ca, va = 0.1, 25.0
18+
cb = 0.1
19+
equivalence_vol = va * ca / cb # 25 mL
20+
21+
volume_ml = np.linspace(0.01, 50.0, 500)
22+
23+
ph = np.empty_like(volume_ml)
24+
for i, v in enumerate(volume_ml):
25+
moles_h = ca * va - cb * v
26+
total_vol = va + v
27+
if moles_h > 1e-10:
28+
ph[i] = -np.log10(moles_h / total_vol)
29+
elif moles_h < -1e-10:
30+
oh_conc = -moles_h / total_vol
31+
ph[i] = 14.0 + np.log10(oh_conc)
32+
else:
33+
ph[i] = 7.0
34+
35+
# Derivative dpH/dV
36+
dpH_dV = np.gradient(ph, volume_ml)
37+
dpH_dV = np.clip(dpH_dV, 0, None)
38+
39+
# Equivalence point — known analytically for strong acid/strong base
40+
eq_vol = equivalence_vol # 25.0 mL
41+
eq_ph = 7.0
42+
43+
# Colors
44+
line_blue = "#306998"
45+
deriv_orange = "#D35400"
46+
eq_red = "#C0392B"
47+
bg_canvas = "#FAFCFF"
48+
bg_plot = "#F0F4F8"
49+
text_dark = "#1A1F36"
50+
grid_subtle = "#D5DAE2"
51+
buffer_fill = "#306998" # semi-transparent blue for buffer/transition zone
52+
53+
# Shared style settings
54+
_style_common = {
55+
"background": bg_canvas,
56+
"plot_background": bg_plot,
57+
"foreground": text_dark,
58+
"foreground_strong": text_dark,
59+
"foreground_subtle": "#E2E6EA",
60+
"title_font_size": 56,
61+
"label_font_size": 34,
62+
"major_label_font_size": 32,
63+
"legend_font_size": 40,
64+
"value_font_size": 22,
65+
"stroke_width": 4,
66+
"font_family": "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
67+
"title_font_family": "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
68+
"label_font_family": "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
69+
"value_font_family": "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
70+
"legend_font_family": "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",
71+
"opacity": 1.0,
72+
"opacity_hover": 0.85,
73+
}
74+
75+
ph_style = Style(**_style_common, colors=(line_blue, eq_red, "#7F8C8D"))
76+
deriv_style = Style(**_style_common, colors=(deriv_orange, eq_red))
77+
78+
# Subsample for performance
79+
step = 3
80+
curve_pts = [(float(volume_ml[i]), float(ph[i])) for i in range(0, len(volume_ml), step)]
81+
deriv_pts = [(float(volume_ml[i]), float(dpH_dV[i])) for i in range(0, len(volume_ml), step)]
82+
83+
# Equivalence point vertical dashed line (for both panels)
84+
eq_line_ph = [(float(eq_vol), 0.0), (float(eq_vol), 14.0)]
85+
86+
# pH 7 reference line
87+
ref_ph7 = [(0.0, 7.0), (50.0, 7.0)]
88+
89+
# pH chart (upper panel)
90+
ph_chart = pygal.XY(
91+
style=ph_style,
92+
width=4800,
93+
height=1800,
94+
title="titration-curve · pygal · pyplots.ai",
95+
x_title="Volume of NaOH added (mL)",
96+
y_title="pH",
97+
show_dots=False,
98+
dots_size=0,
99+
show_x_guides=False,
100+
show_y_guides=True,
101+
range=(0.0, 14.0),
102+
xrange=(0.0, 50.0),
103+
legend_at_bottom=True,
104+
legend_at_bottom_columns=3,
105+
legend_box_size=30,
106+
truncate_legend=-1,
107+
margin=30,
108+
margin_top=80,
109+
margin_left=160,
110+
margin_right=90,
111+
margin_bottom=140,
112+
tooltip_fancy_mode=True,
113+
tooltip_border_radius=8,
114+
x_value_formatter=lambda x: f"{x:.1f}",
115+
y_value_formatter=lambda y: f"{y:.1f}",
116+
)
117+
118+
ph_chart.add(
119+
"pH (0.1 M HCl + 0.1 M NaOH)",
120+
curve_pts,
121+
show_dots=False,
122+
stroke_style={"width": 6, "linecap": "round", "linejoin": "round"},
123+
)
124+
125+
ph_chart.add(
126+
f"Equivalence Point ({eq_vol:.1f} mL, pH {eq_ph:.1f})",
127+
eq_line_ph,
128+
show_dots=True,
129+
dots_size=8,
130+
stroke_style={"width": 3, "dasharray": "14,8"},
131+
)
132+
133+
ph_chart.add("pH 7 Reference", ref_ph7, show_dots=False, stroke_style={"width": 2, "dasharray": "6,6"})
134+
135+
# Derivative chart (lower panel)
136+
eq_line_deriv = [(float(eq_vol), 0.0), (float(eq_vol), float(np.max(dpH_dV) * 1.05))]
137+
138+
deriv_chart = pygal.XY(
139+
style=deriv_style,
140+
width=4800,
141+
height=900,
142+
title="",
143+
x_title="Volume of NaOH added (mL)",
144+
y_title="dpH/dV",
145+
show_dots=False,
146+
dots_size=0,
147+
show_x_guides=False,
148+
show_y_guides=True,
149+
xrange=(0.0, 50.0),
150+
legend_at_bottom=True,
151+
legend_at_bottom_columns=2,
152+
legend_box_size=30,
153+
truncate_legend=-1,
154+
margin=30,
155+
margin_top=20,
156+
margin_left=160,
157+
margin_right=90,
158+
margin_bottom=140,
159+
tooltip_fancy_mode=True,
160+
tooltip_border_radius=8,
161+
x_value_formatter=lambda x: f"{x:.1f}",
162+
y_value_formatter=lambda y: f"{y:.2f}",
163+
)
164+
165+
deriv_chart.add(
166+
"dpH/dV (derivative)",
167+
deriv_pts,
168+
show_dots=False,
169+
stroke_style={"width": 5, "linecap": "round", "linejoin": "round"},
170+
)
171+
172+
deriv_chart.add(
173+
f"Equivalence ({eq_vol:.1f} mL)",
174+
eq_line_deriv,
175+
show_dots=True,
176+
dots_size=8,
177+
stroke_style={"width": 3, "dasharray": "14,8"},
178+
)
179+
180+
# Render to PNG and compose
181+
ph_png = cairosvg.svg2png(bytestring=ph_chart.render(), output_width=4800, output_height=1800)
182+
deriv_png = cairosvg.svg2png(bytestring=deriv_chart.render(), output_width=4800, output_height=900)
183+
184+
ph_img = Image.open(io.BytesIO(ph_png))
185+
deriv_img = Image.open(io.BytesIO(deriv_png))
186+
combined = Image.new("RGB", (4800, 2700), bg_canvas)
187+
combined.paste(ph_img, (0, 0))
188+
combined.paste(deriv_img, (0, 1800))
189+
190+
# Buffer/transition zone shading on the pH panel
191+
# For strong acid/strong base: shade the steep transition zone (~20-30 mL)
192+
# Map data coordinates to pixel coordinates on the pH panel
193+
# pH panel plot area approx: x from ~320 to ~4710, y from ~150 to ~1580
194+
plot_x_left, plot_x_right = 320, 4710
195+
plot_y_top, plot_y_bottom = 150, 1580
196+
x_data_min, x_data_max = 0.0, 50.0
197+
y_data_min, y_data_max = 0.0, 14.0
198+
199+
200+
def data_to_px(vx, vy):
201+
px = plot_x_left + (vx - x_data_min) / (x_data_max - x_data_min) * (plot_x_right - plot_x_left)
202+
py = plot_y_bottom - (vy - y_data_min) / (y_data_max - y_data_min) * (plot_y_bottom - plot_y_top)
203+
return int(px), int(py)
204+
205+
206+
# Draw semi-transparent buffer zone overlay
207+
buffer_overlay = Image.new("RGBA", (4800, 2700), (0, 0, 0, 0))
208+
buf_draw = ImageDraw.Draw(buffer_overlay)
209+
210+
# Transition zone: 20-30 mL (the steep part of the S-curve)
211+
buf_x1, _ = data_to_px(20.0, 0)
212+
buf_x2, _ = data_to_px(30.0, 0)
213+
_, buf_y1 = data_to_px(0, 14.0)
214+
_, buf_y2 = data_to_px(0, 0.0)
215+
buf_draw.rectangle([(buf_x1, buf_y1), (buf_x2, buf_y2)], fill=(48, 105, 152, 28))
216+
217+
# Label the shaded zone
218+
try:
219+
font_zone = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 34)
220+
except OSError:
221+
font_zone = ImageFont.load_default()
222+
_, label_y = data_to_px(0, 3.0)
223+
buf_draw.text((buf_x1 + 16, label_y), "Transition\nZone", fill=(48, 105, 152, 160), font=font_zone)
224+
225+
combined = Image.alpha_composite(combined.convert("RGBA"), buffer_overlay).convert("RGB")
226+
227+
# Panel divider
228+
draw = ImageDraw.Draw(combined)
229+
draw.line([(160, 1800), (4710, 1800)], fill="#B0BEC5", width=2)
230+
231+
# Annotation overlay
232+
try:
233+
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 42)
234+
font_body = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 34)
235+
except OSError:
236+
font_title = ImageFont.load_default()
237+
font_body = font_title
238+
239+
ann_x, ann_y = 3200, 120
240+
ann_w, ann_h = 1500, 160
241+
draw.rounded_rectangle(
242+
[(ann_x, ann_y), (ann_x + ann_w, ann_y + ann_h)], radius=16, fill="#FFFFFF", outline=grid_subtle, width=2
243+
)
244+
draw.text((ann_x + 24, ann_y + 18), f"Equivalence: {eq_vol:.1f} mL, pH {eq_ph:.1f}", fill=eq_red, font=font_title)
245+
draw.text((ann_x + 24, ann_y + 80), "25 mL of 0.1 M HCl titrated with 0.1 M NaOH", fill="#5D6D7E", font=font_body)
246+
247+
combined.save("plot.png", dpi=(300, 300))
248+
249+
# HTML version with interactive SVG
250+
ph_svg = ph_chart.render(is_unicode=True).replace('<?xml version="1.0" encoding="utf-8"?>', "")
251+
deriv_svg = deriv_chart.render(is_unicode=True).replace('<?xml version="1.0" encoding="utf-8"?>', "")
252+
253+
html_content = (
254+
"<!DOCTYPE html>\n<html>\n<head>\n"
255+
" <title>titration-curve · pygal · pyplots.ai</title>\n"
256+
" <style>\n"
257+
f" body {{ font-family: 'Helvetica Neue', sans-serif; background: {bg_canvas};"
258+
" margin: 0; padding: 40px 20px; }\n"
259+
" .container { max-width: 1200px; margin: 0 auto; }\n"
260+
" .chart { width: 100%; margin: 8px 0; }\n"
261+
" .divider { border: none; border-top: 1px solid #CFD8DC; margin: 0; }\n"
262+
" .info { text-align: center; color: #5D6D7E; font-size: 14px; margin-top: 12px; }\n"
263+
" </style>\n</head>\n<body>\n"
264+
" <div class='container'>\n"
265+
f" <div class='chart'>{ph_svg}</div>\n"
266+
" <hr class='divider'/>\n"
267+
f" <div class='chart'>{deriv_svg}</div>\n"
268+
" <p class='info'>Hover over data points for pH and volume details</p>\n"
269+
" </div>\n</body>\n</html>"
270+
)
271+
272+
with open("plot.html", "w") as f:
273+
f.write(html_content)

0 commit comments

Comments
 (0)