diff --git a/plots/titration-curve/implementations/plotnine.py b/plots/titration-curve/implementations/plotnine.py new file mode 100644 index 0000000000..82e32b3ce8 --- /dev/null +++ b/plots/titration-curve/implementations/plotnine.py @@ -0,0 +1,156 @@ +""" pyplots.ai +titration-curve: Acid-Base Titration Curve +Library: plotnine 0.15.3 | Python 3.14.3 +Quality: 93/100 | Created: 2026-03-21 +""" + +import numpy as np +import pandas as pd +from plotnine import ( + aes, + element_blank, + element_line, + element_rect, + element_text, + geom_area, + geom_line, + geom_point, + geom_ribbon, + geom_segment, + geom_text, + ggplot, + guide_legend, + labs, + scale_color_manual, + scale_x_continuous, + scale_y_continuous, + theme, + theme_minimal, +) + + +# Data — 25 mL of 0.1 M HCl titrated with 0.1 M NaOH (vectorized) +volume_hcl = 25.0 +conc_hcl = 0.1 +conc_naoh = 0.1 +moles_hcl = volume_hcl * conc_hcl / 1000 + +volume_ml = np.concatenate([np.linspace(0, 24, 80), np.linspace(24, 26, 40), np.linspace(26, 50, 80)]) + +# Vectorized pH calculation +moles_naoh = conc_naoh * volume_ml / 1000 +total_volume_L = (volume_hcl + volume_ml) / 1000 +excess_h = np.clip(moles_hcl - moles_naoh, 1e-14, None) / total_volume_L +excess_oh = np.clip(moles_naoh - moles_hcl, 1e-14, None) / total_volume_L +ph_acid = -np.log10(excess_h) +ph_base = 14.0 + np.log10(excess_oh) +ph = np.where(moles_naoh < moles_hcl - 1e-10, ph_acid, np.where(moles_naoh > moles_hcl + 1e-10, ph_base, 7.0)) + +# Compute derivative dpH/dV using unique-volume spacing to avoid division by zero +_, unique_idx = np.unique(volume_ml, return_index=True) +unique_idx = np.sort(unique_idx) +vol_unique = volume_ml[unique_idx] +ph_unique = ph[unique_idx] +dph_dv_unique = np.gradient(ph_unique, vol_unique) +dph_dv = np.interp(volume_ml, vol_unique, dph_dv_unique) +dph_dv = np.nan_to_num(dph_dv, nan=0.0, posinf=0.0, neginf=0.0) +dph_max = dph_dv.max() +dph_scaled = dph_dv / dph_max * 12 + +# Build long-format dataframe for grammar-of-graphics layering +df_ph = pd.DataFrame({"volume_ml": volume_ml, "value": ph, "series": "pH"}) +df_deriv = pd.DataFrame({"volume_ml": volume_ml, "value": dph_scaled, "series": "dpH/dV (scaled)"}) +df = pd.concat([df_ph, df_deriv], ignore_index=True) + +# Derivative area fill — uses geom_area for distinctive plotnine layering +df_area = pd.DataFrame({"volume_ml": volume_ml, "value": dph_scaled}) + +# Transition region ribbon (±2 mL around equivalence) with stronger visibility +eq_volume = 25.0 +eq_ph = 7.0 +mask = (volume_ml >= 22) & (volume_ml <= 28) +df_ribbon = pd.DataFrame( + {"volume_ml": volume_ml[mask], "ymin": np.clip(ph[mask] - 1.2, 0, 14), "ymax": np.clip(ph[mask] + 1.2, 0, 14)} +) + +# Equivalence point marker and label dataframes for geom_point/geom_text +df_eq = pd.DataFrame({"volume_ml": [eq_volume], "value": [eq_ph]}) +df_eq_label = pd.DataFrame( + { + "volume_ml": [eq_volume + 2.5], + "value": [eq_ph + 1.8], + "label": [f"Equivalence Point\n({eq_volume:.0f} mL, pH {eq_ph:.0f})"], + } +) +df_peak_label = pd.DataFrame( + {"volume_ml": [38.0], "value": [11.0], "label": [f"Peak dpH/dV = {dph_max:.1f}\nat {eq_volume:.0f} mL"]} +) + +# Color palette +palette = {"pH": "#306998", "dpH/dV (scaled)": "#E8A838"} + +plot = ( + ggplot() + # Transition region shading + + geom_ribbon(aes(x="volume_ml", ymin="ymin", ymax="ymax"), data=df_ribbon, fill="#306998", alpha=0.18) + # Derivative area fill — distinctive plotnine geom_area usage + + geom_area(aes(x="volume_ml", y="value"), data=df_area, fill="#E8A838", alpha=0.12) + # Equivalence point vertical reference + + geom_segment( + aes(x="volume_ml", xend="volume_ml", y=0, yend="value"), + data=df_eq, + linetype="dashed", + color="#999999", + size=0.6, + ) + # Main curves via color aesthetic mapping + + geom_line(aes(x="volume_ml", y="value", color="series"), data=df, size=1.5) + # Equivalence point diamond marker + + geom_point( + aes(x="volume_ml", y="value"), data=df_eq, color="#C0392B", fill="#E74C3C", size=5, shape="D", stroke=0.5 + ) + # Annotations via geom_text (idiomatic plotnine, not matplotlib annotate) + + geom_text( + aes(x="volume_ml", y="value", label="label"), + data=df_eq_label, + size=11, + ha="left", + color="#333333", + fontstyle="italic", + ) + + geom_text( + aes(x="volume_ml", y="value", label="label"), + data=df_peak_label, + size=9, + ha="left", + color="#E8A838", + fontweight="bold", + ) + # Scales + + scale_color_manual(values=palette, name=" ", guide=guide_legend(override_aes={"size": 3})) + + scale_x_continuous(breaks=range(0, 55, 5), limits=(0, 50)) + + scale_y_continuous(breaks=range(0, 15, 2), limits=(0, 14)) + + labs(x="Volume of NaOH added (mL)", y="pH / dpH/dV (scaled)", title="titration-curve · plotnine · pyplots.ai") + # Theme — refined minimal with polished details + + theme_minimal() + + theme( + figure_size=(16, 9), + plot_title=element_text(size=24, weight="bold", margin={"b": 15}), + axis_title=element_text(size=20, color="#444444"), + axis_text=element_text(size=16, color="#555555"), + legend_text=element_text(size=16), + legend_title=element_text(size=14), + legend_position=(0.15, 0.85), + legend_background=element_rect(fill="white", alpha=0.85, color="#DDDDDD", size=0.3), + panel_grid_minor=element_blank(), + panel_grid_major_x=element_blank(), + panel_grid_major_y=element_line(color="#E8E8E8", size=0.3), + axis_line_x=element_line(color="#888888", size=0.5), + axis_line_y=element_line(color="#888888", size=0.5), + plot_background=element_rect(fill="white", color="white"), + panel_background=element_rect(fill="#FAFAFA", color="white"), + ) +) + +# Save +plot.save("plot.png", dpi=300) diff --git a/plots/titration-curve/metadata/plotnine.yaml b/plots/titration-curve/metadata/plotnine.yaml new file mode 100644 index 0000000000..ea210b306c --- /dev/null +++ b/plots/titration-curve/metadata/plotnine.yaml @@ -0,0 +1,224 @@ +library: plotnine +specification_id: titration-curve +created: '2026-03-21T22:11:31Z' +updated: '2026-03-21T22:38:37Z' +generated_by: claude-opus-4-5-20251101 +workflow_run: 23389866067 +issue: 4407 +python_version: 3.14.3 +library_version: 0.15.3 +preview_url: https://storage.googleapis.com/pyplots-images/plots/titration-curve/plotnine/plot.png +preview_thumb: https://storage.googleapis.com/pyplots-images/plots/titration-curve/plotnine/plot_thumb.png +preview_html: null +quality_score: 93 +review: + strengths: + - 'Excellent chemistry: vectorized analytical pH calculation produces a physically + accurate titration curve' + - Strong idiomatic plotnine usage with grammar-of-graphics layering (multiple data + frames, geom_area, geom_ribbon) + - Clear visual hierarchy with red diamond equivalence point marker as focal point + - Polished theme with subtle grid, refined panel background, and styled legend + - Custom blue/amber palette with good colorblind accessibility + weaknesses: + - Derivative area fill at alpha=0.12 is nearly invisible — could be slightly more + prominent + - No true secondary y-axis for the derivative (plotnine limitation, handled gracefully + with scaling) + image_description: 'The plot displays an acid-base titration curve with a bold blue + S-shaped pH line rising from ~1 at 0 mL to ~13 at 50 mL, with a sharp vertical + transition around 25 mL. An amber/gold derivative curve (dpH/dV scaled) spikes + sharply at 25 mL, with a faint amber area fill beneath it. A light blue semi-transparent + ribbon shades the transition region (~22–28 mL). A red diamond marker sits at + the equivalence point (25 mL, pH 7) with an italic annotation reading "Equivalence + Point (25 mL, pH 7)." A bold amber label in the upper right reads "Peak dpH/dV + = 57.5 at 25 mL." The legend in the upper left shows "pH" (blue) and "dpH/dV (scaled)" + (amber) with a white background box. The title reads "titration-curve · plotnine + · pyplots.ai" in bold. X-axis: "Volume of NaOH added (mL)" (0–50), Y-axis: "pH + / dpH/dV (scaled)" (0–14). Background is a subtle off-white (#FAFAFA) with faint + horizontal grid lines only. Clean, polished appearance.' + criteria_checklist: + visual_quality: + score: 29 + max: 30 + items: + - id: VQ-01 + name: Text Legibility + score: 8 + max: 8 + passed: true + comment: 'All font sizes explicitly set: title=24, axis_title=20, axis_text=16, + legend_text=16' + - id: VQ-02 + name: No Overlap + score: 6 + max: 6 + passed: true + comment: No overlapping text elements, annotations well-separated + - id: VQ-03 + name: Element Visibility + score: 5 + max: 6 + passed: true + comment: Lines and markers clearly visible; derivative area fill at alpha=0.12 + is very faint + - id: VQ-04 + name: Color Accessibility + score: 4 + max: 4 + passed: true + comment: Blue and amber palette is colorblind-safe with good contrast + - id: VQ-05 + name: Layout & Canvas + score: 4 + max: 4 + passed: true + comment: Good 16:9 layout with balanced margins + - id: VQ-06 + name: Axis Labels & Title + score: 2 + max: 2 + passed: true + comment: Descriptive labels with units on both axes + design_excellence: + score: 16 + max: 20 + items: + - id: DE-01 + name: Aesthetic Sophistication + score: 6 + max: 8 + passed: true + comment: Custom blue/amber palette, styled legend, refined panel background, + clearly above defaults + - id: DE-02 + name: Visual Refinement + score: 5 + max: 6 + passed: true + comment: Subtle y-only grid, minor grid removed, styled axis lines, refined + panel background + - id: DE-03 + name: Data Storytelling + score: 5 + max: 6 + passed: true + comment: Clear focal point at equivalence point with diamond marker, derivative + peak labeled, transition shaded + spec_compliance: + score: 14 + max: 15 + items: + - id: SC-01 + name: Plot Type + score: 5 + max: 5 + passed: true + comment: Correct S-shaped titration curve + - id: SC-02 + name: Required Features + score: 3 + max: 4 + passed: true + comment: Equivalence point marked, derivative overlay, transition shading. + Derivative scaled on primary axis (plotnine lacks twin axes) + - id: SC-03 + name: Data Mapping + score: 3 + max: 3 + passed: true + comment: X=volume 0-50 mL, Y=pH 0-14, all data visible + - id: SC-04 + name: Title & Legend + score: 3 + max: 3 + passed: true + comment: Title format correct, legend labels match data series + data_quality: + score: 15 + max: 15 + items: + - id: DQ-01 + name: Feature Coverage + score: 6 + max: 6 + passed: true + comment: Full S-curve with plateaus, sharp transition, derivative peak, and + equivalence point + - id: DQ-02 + name: Realistic Context + score: 5 + max: 5 + passed: true + comment: 'Real chemistry: 25 mL of 0.1 M HCl titrated with 0.1 M NaOH' + - id: DQ-03 + name: Appropriate Scale + score: 4 + max: 4 + passed: true + comment: Realistic concentrations, correct equivalence at 25 mL and pH 7 + code_quality: + score: 10 + max: 10 + items: + - id: CQ-01 + name: KISS Structure + score: 3 + max: 3 + passed: true + comment: 'Linear flow: imports, data, plot, save. No functions or classes' + - id: CQ-02 + name: Reproducibility + score: 2 + max: 2 + passed: true + comment: Fully deterministic analytical calculation + - id: CQ-03 + name: Clean Imports + score: 2 + max: 2 + passed: true + comment: All imports are used + - id: CQ-04 + name: Code Elegance + score: 2 + max: 2 + passed: true + comment: Clean vectorized NumPy calculations, appropriate complexity + - id: CQ-05 + name: Output & API + score: 1 + max: 1 + passed: true + comment: Saves as plot.png with dpi=300, current API + library_mastery: + score: 9 + max: 10 + items: + - id: LM-01 + name: Idiomatic Usage + score: 5 + max: 5 + passed: true + comment: 'Expert grammar-of-graphics: ggplot + geom layers, aes mappings, + long-format data, guide_legend with override_aes' + - id: LM-02 + name: Distinctive Features + score: 4 + max: 5 + passed: true + comment: Uses geom_area, geom_ribbon, multiple data frames, guide_legend with + override_aes — distinctively plotnine patterns + verdict: APPROVED +impl_tags: + dependencies: [] + techniques: + - annotations + - layer-composition + patterns: + - data-generation + dataprep: + - normalization + styling: + - alpha-blending + - grid-styling