Constructed Pourbaix Diagrams from MP API are not matching with Pourbaix Diagrams from Materials Projects' Website

Good day, I have tried to use the MP API to generate my own Pourbaix diagrams locally, and I seem to notice from large differences between the API and the website.

There are some diagrams with clearly mistaken phases, such as CO2 from Carbon’s Pourbaix Diagram:

There are some patterned errors, particularly in Group 1A or 2A elements, where a different oxide is shown:


In other cases, phase sizes are vastly different:

Other times, new phases appear or disappear:

Please see the code below (conducted for all elements except S, Cr, Se, Ag - for a different discussion)

from mp_api.client import MPRester
import matplotlib.pyplot as plt
import numpy as np
from collections.abc import Iterable
from matplotlib import rcParams
from matplotlib import pyplot as plt
from pymatgen.analysis.pourbaix_diagram import PourbaixDiagram, PourbaixPlotter, PourbaixEntry, MultiEntry
from matplotlib.patches import Polygon
from scipy.spatial import ConvexHull

def _is_constant(vals, tol=1e-9):

True if all values equal to a constant within tolerance

return np.nanmax(vals) - np.nanmin(vals) < tol

def remove_default_guides(ax, ph_target=7.0, v_target=0.0, tol=1e-6):
“”“Remove the vertical pH=ph_target and horizontal V=v_target lines
that PourbaixPlotter draws by default.”“”
to_remove =
for ln in ax.get_lines():
x = np.array(ln.get_xdata(), dtype=float)
y = np.array(ln.get_ydata(), dtype=float)

    # Identify a vertical line at pH ≈ ph_target
    if _is_constant(x) and np.isfinite(x).all():
        if np.isclose(x[0], ph_target, atol=tol):
            to_remove.append(ln)
            continue

    # Identify a horizontal line at V ≈ v_target
    if _is_constant(y) and np.isfinite(y).all():
        if np.isclose(y[0], v_target, atol=tol):
            to_remove.append(ln)
            continue

# Remove the matched lines
for ln in to_remove:
    ln.remove()

-------------------------------

GLOBAL FONT: SERIF

-------------------------------

rcParams[“font.family”] = “sans-serif”
rcParams[“mathtext.fontset”] = “cm” # computer modern math look

-------------------------------

Get MPR API Key and Select Element

-------------------------------

#el_list = [“Li”, “Be”, “B”, “C”, “N”, “F”, “Na”, “Mg”, “Al”, “Si”, “P”, “Si”, “Cl”, “K”, “Ca”, “Sc”, “Ti”, “V”, “Cr”, “Mn”, “Fe”, “Co”, “Ni”, “Cu”, “Zn”, “Ga”, “Ge”, “As”, “Se”, “Br”]
el_list = [“C”]

for element in el_list:

with MPRester() as mpr:
    # 'Fe' is the element of interest; O and H are added automatically
    pbx_entries = mpr.get_pourbaix_entries(element, use_gibbs=300)

print(f"Number of Pourbaix entries: {len(pbx_entries)}")

# -------------------------------
# CREATE DIAGRAM & BASE PLOT
# -------------------------------
pbx = PourbaixDiagram(pbx_entries)
plotter = PourbaixPlotter(pbx)


#fig, ax = plt.subplots(figsize=(10, 6))
ax = plotter.get_pourbaix_plot(
    limits=((-2, 16), (-4, 4)),
)

# Now retrieve the figure that the plotter actually created
fig = ax.figure

# -------------------------------
# Auto font scaling based on size
# -------------------------------
base_width = 12
scale = fig.get_figwidth() / base_width

# ------------------------------------------------------
# STEP 0 — Reference lines: pH=0 (vertical) and E=1.6 V vs RHE (horizontal)
# ------------------------------------------------------

# Dark yellow (choose a readable, print-safe tone)
dark_yellow = "#b8860b"   # DarkGoldenRod

# A fine dashed pattern (short dashes)
fine_dash = (0, (2, 3))   # on=2pt, off=3pt

# Create the two guide lines (no legend entries)
ph0_line = ax.axvline(
    0.0, color=dark_yellow, linestyle=fine_dash, linewidth=1.2,
    zorder=4, label="_nolegend_"
)
E16_line = ax.axhline(
    1.6, color=dark_yellow, linestyle=fine_dash, linewidth=1.2,
    zorder=4, label="_nolegend_"
)

# Intersection marker at (pH=0, E=1.6 V vs RHE)
# Use a small solid circle; we'll make it responsive too.
pt_line = ax.plot(
    [0.0], [1.6], "o",
    color=dark_yellow, markerfacecolor=dark_yellow,
    markersize=4.0, zorder=5
)[0]

# --- Register originals so the resize callback can scale them ---
# (Do this once, right after creating the artists.)
if not hasattr(ax, "_resp_lines_extra"):
    ax._resp_lines_extra = []
if not hasattr(ax, "_orig_linewidths_extra"):
    ax._orig_linewidths_extra = []

ax._resp_lines_extra.extend([ph0_line, E16_line])
ax._orig_linewidths_extra.extend([ph0_line.get_linewidth(), E16_line.get_linewidth()])

# Store the point (single Line2D) and its original marker size
ax._resp_point_extra = pt_line
ax._orig_markersize_point = pt_line.get_markersize()

#remove default lines
remove_default_guides(ax, ph_target=7.0, v_target=0.0)

# ------------------------------------------------------
# STEP 1 — Sample (pH, V) space and label regions
# ------------------------------------------------------
pH_vals = np.linspace(-2, 16, 200)
V_vals  = np.linspace(-4, 4, 200)

sample_points = []
sample_labels = []

for pH in pH_vals:
    for V in V_vals:
        entry = pbx.get_stable_entry(pH, V)
        if entry is not None:
            sample_points.append([pH, V])
            sample_labels.append(entry.name)

sample_points = np.array(sample_points)

# ------------------------------------------------------
# STEP 2 — Group points by phase
# ------------------------------------------------------
unique_phases = sorted(set(sample_labels))

phase_points = {ph: [] for ph in unique_phases}

for (pt, ph) in zip(sample_points, sample_labels):
    phase_points[ph].append(pt)


# ------------------------------------------------------
# STEP 3 — For each phase, take convex hull to form polygon
# ------------------------------------------------------
for ph, pts in phase_points.items():
    pts = np.array(pts)
    if len(pts) < 3:
        continue  # need ≥3 points to form hull

    hull = ConvexHull(pts)
    poly = pts[hull.vertices]  # polygon vertices

    # Color by solid vs aqueous/ion
    if "(s)" in ph:
        facecolor = (0.55, 0.65, 1.0, 0.35)   # blue
    else:
        facecolor = (1.0, 0.75, 0.90, 0.35)    # pink

    patch = Polygon(poly, closed=True,
                    facecolor=facecolor,
                    edgecolor="black",
                    linewidth=0.8,
                    zorder=0.3)
    ax.add_patch(patch)

# ------------------------------------------------------
# STEP 4 — Double the label sizes (keep black)
# ------------------------------------------------------
for txt in ax.texts:
    txt.set_fontsize(txt.get_fontsize() * 1.25)
    txt.set_color("black")

# ------------------------------------------------------
# STEP 5 — Fix legend using the default red dashed water lines
# ------------------------------------------------------
dashed = [ln for ln in ax.get_lines() if ln.get_linestyle() == "--"]
if len(dashed) >= 2:
    dashed[0].set_label("Hydrogen Stability Line")
    dashed[1].set_label("Oxygen Stability Line")

#ax.legend(frameon=False, fontsize=14, loc="upper center", ncol=2)
ax.legend(frameon=False, loc="upper center", ncol=2)



# Create legend
leg = ax.legend(
    frameon=False,
    fontsize=14,
    loc="upper left",
    bbox_to_anchor=(0.76, 1.10)
)

# Store baseline anchor so we can scale relative to it
ax._legend_baseline_anchor = (0.76, 1.10)


# ------------------------------------------------------
# Axes labels and ticks
# ------------------------------------------------------
ax.set_xlabel("pH", fontsize=18)
ax.set_ylabel("Applied Potential (V vs. SHE)", fontsize=18)
ax.tick_params(labelsize=14)

plt.tight_layout()


# ------------------------------------------
# Dynamic font + line scaling on window resize
# ------------------------------------------

base_width = fig.get_figwidth()  # initial width in inches

# Store original line widths once
if not hasattr(ax, "_orig_linewidths"):
    ax._orig_linewidths = [ln.get_linewidth() for ln in ax.get_lines()]

def on_resize(event):
    new_width = fig.get_figwidth()
    scale = new_width / base_width

    # ---- Text scaling ----
    ax.xaxis.label.set_fontsize(14 * scale)
    ax.yaxis.label.set_fontsize(14 * scale)

    for t in ax.get_xticklabels() + ax.get_yticklabels():
        t.set_fontsize(14 * scale)

    leg = ax.get_legend()
    
    if leg:
    	# Scale legend text
    	for txt in leg.get_texts():
    		txt.set_fontsize(12 * scale)

    	# Scale legend line thickness
    	legend_lines = leg.get_lines()

    	# Store original legend line widths once
    	if not hasattr(leg, "_orig_linewidths"):
    		leg._orig_linewidths = [ln.get_linewidth() for ln in legend_lines]

    	for ln, orig_w in zip(legend_lines, leg._orig_linewidths):
    		ln.set_linewidth(orig_w * scale)

    for txt in ax.texts:   # phase labels
    	txt.set_fontsize(14 * 1.25 * scale)

    # ---- Line scaling (NEW) ----
    for ln, orig_w in zip(ax.get_lines(), ax._orig_linewidths):
        ln.set_linewidth(orig_w * scale)

	# ---- NEW: scale the reference guide lines & intersection marker ----
    if hasattr(ax, "_resp_lines_extra") and hasattr(ax, "_orig_linewidths_extra"):
        for ln, w0 in zip(ax._resp_lines_extra, ax._orig_linewidths_extra):
            ln.set_linewidth(w0 * scale)

    if hasattr(ax, "_resp_point_extra") and hasattr(ax, "_orig_markersize_point"):
        ax._resp_point_extra.set_markersize(ax._orig_markersize_point * scale)

    fig.canvas.draw_idle()

fig.canvas.mpl_connect("resize_event", on_resize)

# ------------------------------------------------------


plt.savefig('Pourbaix_' + element + '.png')

I am also attaching a Google Drive link which is a comprehensive comparison between MP API and MP Pourbaix Diagram’s Webpage. This PDF covers all elements except for f-block elements and those without Pourbaix Diagrams on the Webpage.

Thank you, and I am looking forward to your response.

Hey @jttomacruz, the difference lies only in the use_gibbs=300 that you’ve specified to get_pourbaix_entries. That option uses an ML model to estimate the entropic contribution to the Gibbs energy, and we don’t use that when generating the Pourbaix diagrams on the website

You can verify this yourself by setting use_gibbs = None (or don’t specify it at all), the plots you get from the API then align with those from the website

1 Like

Hello! Thank you for the help!

Upon applying this edit:

use_gibbs = None

I was able to replicate all the Pourbaix diagrams (61/62) with the exception of Co:

Is there a reason for this?

In addition, I would also like to apply the ion concentration control in the Python script. What setting is appropriate for that?

Thank you!

Without adjusting the concentrations on the website, I get exactly the same Pourbaix diagram from the web:

that I do from the API:

from mp_api.client import MPRester
from pymatgen.analysis.pourbaix_diagram import PourbaixDiagram, PourbaixPlotter

with MPRester() as mpr:
    pbx_entries = MPRester().get_pourbaix_entries("Co")
pbx_diag = PourbaixDiagram(pbx_entries)
pbx_plotter = PourbaixPlotter(pbx_diag)
pbx_plotter.get_pourbaix_plot()

The figure you sent looks to have ~0.05 concentration of Co ion set, which if you want to do that using the API, there’s a conc_dict option in PhaseDiagram (see here) :

pbx_diag = PourbaixDiagram(pbx_entries,conc_dict={"Co": 0.05})
pbx_plotter = PourbaixPlotter(pbx_diag)
pbx_plotter.get_pourbaix_plot()