|
| 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