Skip to content

Commit feeacdd

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

2 files changed

Lines changed: 380 additions & 0 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
""" pyplots.ai
2+
titration-curve: Acid-Base Titration Curve
3+
Library: plotnine 0.15.3 | Python 3.14.3
4+
Quality: 93/100 | Created: 2026-03-21
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from plotnine import (
10+
aes,
11+
element_blank,
12+
element_line,
13+
element_rect,
14+
element_text,
15+
geom_area,
16+
geom_line,
17+
geom_point,
18+
geom_ribbon,
19+
geom_segment,
20+
geom_text,
21+
ggplot,
22+
guide_legend,
23+
labs,
24+
scale_color_manual,
25+
scale_x_continuous,
26+
scale_y_continuous,
27+
theme,
28+
theme_minimal,
29+
)
30+
31+
32+
# Data — 25 mL of 0.1 M HCl titrated with 0.1 M NaOH (vectorized)
33+
volume_hcl = 25.0
34+
conc_hcl = 0.1
35+
conc_naoh = 0.1
36+
moles_hcl = volume_hcl * conc_hcl / 1000
37+
38+
volume_ml = np.concatenate([np.linspace(0, 24, 80), np.linspace(24, 26, 40), np.linspace(26, 50, 80)])
39+
40+
# Vectorized pH calculation
41+
moles_naoh = conc_naoh * volume_ml / 1000
42+
total_volume_L = (volume_hcl + volume_ml) / 1000
43+
excess_h = np.clip(moles_hcl - moles_naoh, 1e-14, None) / total_volume_L
44+
excess_oh = np.clip(moles_naoh - moles_hcl, 1e-14, None) / total_volume_L
45+
ph_acid = -np.log10(excess_h)
46+
ph_base = 14.0 + np.log10(excess_oh)
47+
ph = np.where(moles_naoh < moles_hcl - 1e-10, ph_acid, np.where(moles_naoh > moles_hcl + 1e-10, ph_base, 7.0))
48+
49+
# Compute derivative dpH/dV using unique-volume spacing to avoid division by zero
50+
_, unique_idx = np.unique(volume_ml, return_index=True)
51+
unique_idx = np.sort(unique_idx)
52+
vol_unique = volume_ml[unique_idx]
53+
ph_unique = ph[unique_idx]
54+
dph_dv_unique = np.gradient(ph_unique, vol_unique)
55+
dph_dv = np.interp(volume_ml, vol_unique, dph_dv_unique)
56+
dph_dv = np.nan_to_num(dph_dv, nan=0.0, posinf=0.0, neginf=0.0)
57+
dph_max = dph_dv.max()
58+
dph_scaled = dph_dv / dph_max * 12
59+
60+
# Build long-format dataframe for grammar-of-graphics layering
61+
df_ph = pd.DataFrame({"volume_ml": volume_ml, "value": ph, "series": "pH"})
62+
df_deriv = pd.DataFrame({"volume_ml": volume_ml, "value": dph_scaled, "series": "dpH/dV (scaled)"})
63+
df = pd.concat([df_ph, df_deriv], ignore_index=True)
64+
65+
# Derivative area fill — uses geom_area for distinctive plotnine layering
66+
df_area = pd.DataFrame({"volume_ml": volume_ml, "value": dph_scaled})
67+
68+
# Transition region ribbon (±2 mL around equivalence) with stronger visibility
69+
eq_volume = 25.0
70+
eq_ph = 7.0
71+
mask = (volume_ml >= 22) & (volume_ml <= 28)
72+
df_ribbon = pd.DataFrame(
73+
{"volume_ml": volume_ml[mask], "ymin": np.clip(ph[mask] - 1.2, 0, 14), "ymax": np.clip(ph[mask] + 1.2, 0, 14)}
74+
)
75+
76+
# Equivalence point marker and label dataframes for geom_point/geom_text
77+
df_eq = pd.DataFrame({"volume_ml": [eq_volume], "value": [eq_ph]})
78+
df_eq_label = pd.DataFrame(
79+
{
80+
"volume_ml": [eq_volume + 2.5],
81+
"value": [eq_ph + 1.8],
82+
"label": [f"Equivalence Point\n({eq_volume:.0f} mL, pH {eq_ph:.0f})"],
83+
}
84+
)
85+
df_peak_label = pd.DataFrame(
86+
{"volume_ml": [38.0], "value": [11.0], "label": [f"Peak dpH/dV = {dph_max:.1f}\nat {eq_volume:.0f} mL"]}
87+
)
88+
89+
# Color palette
90+
palette = {"pH": "#306998", "dpH/dV (scaled)": "#E8A838"}
91+
92+
plot = (
93+
ggplot()
94+
# Transition region shading
95+
+ geom_ribbon(aes(x="volume_ml", ymin="ymin", ymax="ymax"), data=df_ribbon, fill="#306998", alpha=0.18)
96+
# Derivative area fill — distinctive plotnine geom_area usage
97+
+ geom_area(aes(x="volume_ml", y="value"), data=df_area, fill="#E8A838", alpha=0.12)
98+
# Equivalence point vertical reference
99+
+ geom_segment(
100+
aes(x="volume_ml", xend="volume_ml", y=0, yend="value"),
101+
data=df_eq,
102+
linetype="dashed",
103+
color="#999999",
104+
size=0.6,
105+
)
106+
# Main curves via color aesthetic mapping
107+
+ geom_line(aes(x="volume_ml", y="value", color="series"), data=df, size=1.5)
108+
# Equivalence point diamond marker
109+
+ geom_point(
110+
aes(x="volume_ml", y="value"), data=df_eq, color="#C0392B", fill="#E74C3C", size=5, shape="D", stroke=0.5
111+
)
112+
# Annotations via geom_text (idiomatic plotnine, not matplotlib annotate)
113+
+ geom_text(
114+
aes(x="volume_ml", y="value", label="label"),
115+
data=df_eq_label,
116+
size=11,
117+
ha="left",
118+
color="#333333",
119+
fontstyle="italic",
120+
)
121+
+ geom_text(
122+
aes(x="volume_ml", y="value", label="label"),
123+
data=df_peak_label,
124+
size=9,
125+
ha="left",
126+
color="#E8A838",
127+
fontweight="bold",
128+
)
129+
# Scales
130+
+ scale_color_manual(values=palette, name=" ", guide=guide_legend(override_aes={"size": 3}))
131+
+ scale_x_continuous(breaks=range(0, 55, 5), limits=(0, 50))
132+
+ scale_y_continuous(breaks=range(0, 15, 2), limits=(0, 14))
133+
+ labs(x="Volume of NaOH added (mL)", y="pH / dpH/dV (scaled)", title="titration-curve · plotnine · pyplots.ai")
134+
# Theme — refined minimal with polished details
135+
+ theme_minimal()
136+
+ theme(
137+
figure_size=(16, 9),
138+
plot_title=element_text(size=24, weight="bold", margin={"b": 15}),
139+
axis_title=element_text(size=20, color="#444444"),
140+
axis_text=element_text(size=16, color="#555555"),
141+
legend_text=element_text(size=16),
142+
legend_title=element_text(size=14),
143+
legend_position=(0.15, 0.85),
144+
legend_background=element_rect(fill="white", alpha=0.85, color="#DDDDDD", size=0.3),
145+
panel_grid_minor=element_blank(),
146+
panel_grid_major_x=element_blank(),
147+
panel_grid_major_y=element_line(color="#E8E8E8", size=0.3),
148+
axis_line_x=element_line(color="#888888", size=0.5),
149+
axis_line_y=element_line(color="#888888", size=0.5),
150+
plot_background=element_rect(fill="white", color="white"),
151+
panel_background=element_rect(fill="#FAFAFA", color="white"),
152+
)
153+
)
154+
155+
# Save
156+
plot.save("plot.png", dpi=300)
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
library: plotnine
2+
specification_id: titration-curve
3+
created: '2026-03-21T22:11:31Z'
4+
updated: '2026-03-21T22:38:37Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 23389866067
7+
issue: 4407
8+
python_version: 3.14.3
9+
library_version: 0.15.3
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/titration-curve/plotnine/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/titration-curve/plotnine/plot_thumb.png
12+
preview_html: null
13+
quality_score: 93
14+
review:
15+
strengths:
16+
- 'Excellent chemistry: vectorized analytical pH calculation produces a physically
17+
accurate titration curve'
18+
- Strong idiomatic plotnine usage with grammar-of-graphics layering (multiple data
19+
frames, geom_area, geom_ribbon)
20+
- Clear visual hierarchy with red diamond equivalence point marker as focal point
21+
- Polished theme with subtle grid, refined panel background, and styled legend
22+
- Custom blue/amber palette with good colorblind accessibility
23+
weaknesses:
24+
- Derivative area fill at alpha=0.12 is nearly invisible — could be slightly more
25+
prominent
26+
- No true secondary y-axis for the derivative (plotnine limitation, handled gracefully
27+
with scaling)
28+
image_description: 'The plot displays an acid-base titration curve with a bold blue
29+
S-shaped pH line rising from ~1 at 0 mL to ~13 at 50 mL, with a sharp vertical
30+
transition around 25 mL. An amber/gold derivative curve (dpH/dV scaled) spikes
31+
sharply at 25 mL, with a faint amber area fill beneath it. A light blue semi-transparent
32+
ribbon shades the transition region (~22–28 mL). A red diamond marker sits at
33+
the equivalence point (25 mL, pH 7) with an italic annotation reading "Equivalence
34+
Point (25 mL, pH 7)." A bold amber label in the upper right reads "Peak dpH/dV
35+
= 57.5 at 25 mL." The legend in the upper left shows "pH" (blue) and "dpH/dV (scaled)"
36+
(amber) with a white background box. The title reads "titration-curve · plotnine
37+
· pyplots.ai" in bold. X-axis: "Volume of NaOH added (mL)" (0–50), Y-axis: "pH
38+
/ dpH/dV (scaled)" (0–14). Background is a subtle off-white (#FAFAFA) with faint
39+
horizontal grid lines only. Clean, polished appearance.'
40+
criteria_checklist:
41+
visual_quality:
42+
score: 29
43+
max: 30
44+
items:
45+
- id: VQ-01
46+
name: Text Legibility
47+
score: 8
48+
max: 8
49+
passed: true
50+
comment: 'All font sizes explicitly set: title=24, axis_title=20, axis_text=16,
51+
legend_text=16'
52+
- id: VQ-02
53+
name: No Overlap
54+
score: 6
55+
max: 6
56+
passed: true
57+
comment: No overlapping text elements, annotations well-separated
58+
- id: VQ-03
59+
name: Element Visibility
60+
score: 5
61+
max: 6
62+
passed: true
63+
comment: Lines and markers clearly visible; derivative area fill at alpha=0.12
64+
is very faint
65+
- id: VQ-04
66+
name: Color Accessibility
67+
score: 4
68+
max: 4
69+
passed: true
70+
comment: Blue and amber palette is colorblind-safe with good contrast
71+
- id: VQ-05
72+
name: Layout & Canvas
73+
score: 4
74+
max: 4
75+
passed: true
76+
comment: Good 16:9 layout with balanced margins
77+
- id: VQ-06
78+
name: Axis Labels & Title
79+
score: 2
80+
max: 2
81+
passed: true
82+
comment: Descriptive labels with units on both axes
83+
design_excellence:
84+
score: 16
85+
max: 20
86+
items:
87+
- id: DE-01
88+
name: Aesthetic Sophistication
89+
score: 6
90+
max: 8
91+
passed: true
92+
comment: Custom blue/amber palette, styled legend, refined panel background,
93+
clearly above defaults
94+
- id: DE-02
95+
name: Visual Refinement
96+
score: 5
97+
max: 6
98+
passed: true
99+
comment: Subtle y-only grid, minor grid removed, styled axis lines, refined
100+
panel background
101+
- id: DE-03
102+
name: Data Storytelling
103+
score: 5
104+
max: 6
105+
passed: true
106+
comment: Clear focal point at equivalence point with diamond marker, derivative
107+
peak labeled, transition shaded
108+
spec_compliance:
109+
score: 14
110+
max: 15
111+
items:
112+
- id: SC-01
113+
name: Plot Type
114+
score: 5
115+
max: 5
116+
passed: true
117+
comment: Correct S-shaped titration curve
118+
- id: SC-02
119+
name: Required Features
120+
score: 3
121+
max: 4
122+
passed: true
123+
comment: Equivalence point marked, derivative overlay, transition shading.
124+
Derivative scaled on primary axis (plotnine lacks twin axes)
125+
- id: SC-03
126+
name: Data Mapping
127+
score: 3
128+
max: 3
129+
passed: true
130+
comment: X=volume 0-50 mL, Y=pH 0-14, all data visible
131+
- id: SC-04
132+
name: Title & Legend
133+
score: 3
134+
max: 3
135+
passed: true
136+
comment: Title format correct, legend labels match data series
137+
data_quality:
138+
score: 15
139+
max: 15
140+
items:
141+
- id: DQ-01
142+
name: Feature Coverage
143+
score: 6
144+
max: 6
145+
passed: true
146+
comment: Full S-curve with plateaus, sharp transition, derivative peak, and
147+
equivalence point
148+
- id: DQ-02
149+
name: Realistic Context
150+
score: 5
151+
max: 5
152+
passed: true
153+
comment: 'Real chemistry: 25 mL of 0.1 M HCl titrated with 0.1 M NaOH'
154+
- id: DQ-03
155+
name: Appropriate Scale
156+
score: 4
157+
max: 4
158+
passed: true
159+
comment: Realistic concentrations, correct equivalence at 25 mL and pH 7
160+
code_quality:
161+
score: 10
162+
max: 10
163+
items:
164+
- id: CQ-01
165+
name: KISS Structure
166+
score: 3
167+
max: 3
168+
passed: true
169+
comment: 'Linear flow: imports, data, plot, save. No functions or classes'
170+
- id: CQ-02
171+
name: Reproducibility
172+
score: 2
173+
max: 2
174+
passed: true
175+
comment: Fully deterministic analytical calculation
176+
- id: CQ-03
177+
name: Clean Imports
178+
score: 2
179+
max: 2
180+
passed: true
181+
comment: All imports are used
182+
- id: CQ-04
183+
name: Code Elegance
184+
score: 2
185+
max: 2
186+
passed: true
187+
comment: Clean vectorized NumPy calculations, appropriate complexity
188+
- id: CQ-05
189+
name: Output & API
190+
score: 1
191+
max: 1
192+
passed: true
193+
comment: Saves as plot.png with dpi=300, current API
194+
library_mastery:
195+
score: 9
196+
max: 10
197+
items:
198+
- id: LM-01
199+
name: Idiomatic Usage
200+
score: 5
201+
max: 5
202+
passed: true
203+
comment: 'Expert grammar-of-graphics: ggplot + geom layers, aes mappings,
204+
long-format data, guide_legend with override_aes'
205+
- id: LM-02
206+
name: Distinctive Features
207+
score: 4
208+
max: 5
209+
passed: true
210+
comment: Uses geom_area, geom_ribbon, multiple data frames, guide_legend with
211+
override_aes — distinctively plotnine patterns
212+
verdict: APPROVED
213+
impl_tags:
214+
dependencies: []
215+
techniques:
216+
- annotations
217+
- layer-composition
218+
patterns:
219+
- data-generation
220+
dataprep:
221+
- normalization
222+
styling:
223+
- alpha-blending
224+
- grid-styling

0 commit comments

Comments
 (0)