Chris Parmer — home

Piper diagram

USGS groundwater major-ion chemistry, New Mexico

Example from the compendium of canonical charts

Piper diagram — USGS groundwater major-ion chemistry, New Mexico

Python Code

"""Piper diagram — USGS groundwater major-ion chemistry, New Mexico."""


import numpy as np
import pandas as pd
import plotly.graph_objects as go

URL = (
    "https://www.waterqualitydata.us/data/Result/search"
    "?statecode=US%3A35&countycode=US%3A35%3A028"
    "&characteristicName=Calcium&characteristicName=Magnesium"
    "&characteristicName=Sodium&characteristicName=Chloride"
    "&characteristicName=Sulfate&characteristicName=Bicarbonate"
    "&mimeType=csv&zip=no&dataProfile=resultPhysChem&providers=NWIS"
)

# Equivalent weights (g/eq) — convert mg/L → meq/L before plotting ion ratios.
EW = {"Calcium": 20.04, "Magnesium": 12.15, "Sodium": 22.99,
      "Chloride": 35.45, "Sulfate": 48.03, "Bicarbonate": 61.02}

# ── Piper geometry (single shared cartesian frame) ─────────────────────────────
# All three panels share one cartesian coordinate system. The two base triangles
# are ternary plots (barycentric placement on a 3-component simplex); the diamond
# is a sheared 2-axis grid on %(Na+K) × %(SO4+Cl). Each sample is shown in all
# three. A single gap G sets the whole composition: the diamond sits at the
# projection intersection, nestling down into the gap with its lower edges
# parallel to the triangles' inner edges (bottom vertex at height H·G, a
# perpendicular gap H·G from each inner edge). Smaller G ⇒ tighter and lower.
H = np.sqrt(3) / 2          # apex height of a unit-side triangle
G = 0.30                    # gap between the two triangles
TICKS = [0.2, 0.4, 0.6, 0.8]

# Cation triangle: Ca (bottom-left), Na+K (bottom-right), Mg (apex)
CA, NAK, MG = (0.0, 0.0), (1.0, 0.0), (0.5, H)
# Anion triangle: HCO3+CO3 (bottom-left), Cl (bottom-right), SO4 (apex)
HCO3, CL, SO4 = (1 + G, 0.0), (2 + G, 0.0), (1.5 + G, H)
# Diamond centre + corners (at the projection intersection above the gap).
DCX, DCY = 1 + G / 2, H * (1 + G)
D_BOT = (DCX, H * G)
D_TOP = (DCX, H * (2 + G))
D_LEFT = (DCX - 0.5, DCY)
D_RIGHT = (DCX + 0.5, DCY)

# Standard hydrochemical facies → theme colours
TYPE_COLORS = {
    "Ca–HCO₃":  GREEN,
    "Na–Cl":    PINK,
    "Ca–Cl":    ORANGE,
    "Na–HCO₃":  TEAL,
    "Mixed":    VIOLET,
}


def _lerp(p, q, t):
    return (p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t)


def _tri_xy(fl, fr, ft, vl, vr, vt):
    """Barycentric point in a triangle: fractions (sum=1) on (left, right, top)."""
    return (fl * vl[0] + fr * vr[0] + ft * vt[0],
            fl * vl[1] + fr * vr[1] + ft * vt[1])


def _diamond_xy(nak, so4cl):
    """Place a sample in the diamond from %(Na+K) and %(SO4+Cl) (fractions).

    Bottom = Na–HCO₃, left = Ca–HCO₃, right = Na–Cl, top = Ca–Cl.
    """
    return (DCX + 0.5 * (nak + so4cl - 1), DCY + H * (so4cl - nak))


# ── Synthetic fallback ─────────────────────────────────────────────────────────
def _synthetic():
    """Representative clustered groundwater (meq/L) if the WQP fetch fails."""
    rng = np.random.default_rng(42)
    # cluster centres in meq/L: (Ca, Mg, Na, HCO3, SO4, Cl, n)
    clusters = [
        (3.5, 1.2, 0.8, 4.0, 0.8, 0.5, 16),   # Ca–HCO3 fresh recharge
        (1.0, 0.6, 6.0, 1.2, 1.5, 5.5, 14),   # Na–Cl saline
        (2.2, 1.4, 2.6, 2.4, 2.0, 2.2, 12),   # mixed
    ]
    rows = []
    for ca, mg, na, hco3, so4, cl, n in clusters:
        for _ in range(n):
            j = lambda v: max(0.05, v * rng.uniform(0.6, 1.4))
            rows.append({"Ca": j(ca), "Mg": j(mg), "Na": j(na),
                         "HCO3": j(hco3), "SO4": j(so4), "Cl": j(cl)})
    return pd.DataFrame(rows)


def generate():
    print("fetching USGS Water Quality Portal NM major ions …")
    df = None
    try:
        df_raw = fetch_csv(URL, low_memory=False)
        df = _parse_wqp(df_raw)
    except Exception as e:
        print(f"  WQP fetch failed ({e})")

    if df is None or len(df) == 0:
        print("  using synthetic representative data")
        df = _synthetic()

    _plot_piper(df)


def _parse_wqp(df_raw):
    ion_cols = ["Calcium", "Magnesium", "Sodium", "Chloride", "Sulfate", "Bicarbonate"]
    needed = ["MonitoringLocationIdentifier", "ActivityStartDate",
              "CharacteristicName", "ResultMeasureValue"]
    available = [c for c in needed if c in df_raw.columns]
    if len(available) < 4:
        print(f"  unexpected columns: {list(df_raw.columns[:10])}")
        return None

    df_raw = df_raw[available].copy()
    df_raw = df_raw[df_raw["CharacteristicName"].isin(ion_cols)]
    df_raw["ResultMeasureValue"] = pd.to_numeric(df_raw["ResultMeasureValue"], errors="coerce")
    df_raw = df_raw.dropna(subset=["ResultMeasureValue"])

    pivot = df_raw.pivot_table(
        index=["MonitoringLocationIdentifier", "ActivityStartDate"],
        columns="CharacteristicName",
        values="ResultMeasureValue",
        aggfunc="mean",
    ).reset_index()
    pivot.columns.name = None

    for ion in ion_cols:
        if ion not in pivot.columns:
            pivot[ion] = np.nan
    pivot = pivot.dropna(subset=ion_cols)
    if len(pivot) == 0:
        return None

    # mg/L → meq/L
    return pd.DataFrame({
        "Ca":   pivot["Calcium"] / EW["Calcium"],
        "Mg":   pivot["Magnesium"] / EW["Magnesium"],
        "Na":   pivot["Sodium"] / EW["Sodium"],
        "HCO3": pivot["Bicarbonate"] / EW["Bicarbonate"],
        "SO4":  pivot["Sulfate"] / EW["Sulfate"],
        "Cl":   pivot["Chloride"] / EW["Chloride"],
    })


def _classify(camg, hco3):
    """Five standard Piper water types from cation/anion dominance (%)."""
    nak, so4cl = 100 - camg, 100 - hco3
    if camg > 50 and hco3 > 50:
        return "Ca–HCO₃"
    if nak > 50 and so4cl > 50:
        return "Na–Cl"
    if camg > 50 and so4cl > 50:
        return "Ca–Cl"
    if nak > 50 and hco3 > 50:
        return "Na–HCO₃"
    return "Mixed"


# ── Frame: outlines, grids, ticks, axis titles, direction arrows ───────────────
def _line(x0, y0, x1, y1, color, width):
    return dict(type="line", x0=x0, y0=y0, x1=x1, y1=y1,
                line=dict(color=color, width=width), layer="below")


def _frame_shapes():
    shapes = []
    # 20 % grid lines inside each triangle.
    for vl, vr, vt in [(CA, NAK, MG), (HCO3, CL, SO4)]:
        for k in TICKS:
            for a, b in [
                (_lerp(vl, vr, k), _lerp(vl, vt, k)),
                (_lerp(vr, vl, k), _lerp(vr, vt, k)),
                (_lerp(vt, vl, k), _lerp(vt, vr, k)),
            ]:
                shapes.append(_line(a[0], a[1], b[0], b[1], GRID, 0.7))
    # Diamond grid (constant Na+K and constant SO4+Cl).
    for k in TICKS:
        a, b = _diamond_xy(k, 0), _diamond_xy(k, 1)
        shapes.append(_line(a[0], a[1], b[0], b[1], GRID, 0.7))
        a, b = _diamond_xy(0, k), _diamond_xy(1, k)
        shapes.append(_line(a[0], a[1], b[0], b[1], GRID, 0.7))
    # Outlines.
    for tri in [(CA, NAK, MG), (HCO3, CL, SO4)]:
        for i in range(3):
            p, q = tri[i], tri[(i + 1) % 3]
            shapes.append(_line(p[0], p[1], q[0], q[1], TEXT, 1.5))
    diamond = [D_BOT, D_RIGHT, D_TOP, D_LEFT, D_BOT]
    for p, q in zip(diamond, diamond[1:]):
        shapes.append(_line(p[0], p[1], q[0], q[1], TEXT, 1.5))
    return shapes


def _normal(vstart, vend, opposite, dist):
    """Unit outward normal of edge vstart→vend (pointing away from `opposite`)."""
    ex, ey = vend[0] - vstart[0], vend[1] - vstart[1]
    nx, ny = -ey, ex
    n = np.hypot(nx, ny)
    nx, ny = nx / n, ny / n
    mx, my = (vstart[0] + vend[0]) / 2, (vstart[1] + vend[1]) / 2
    if (mx - opposite[0]) * nx + (my - opposite[1]) * ny < 0:
        nx, ny = -nx, -ny
    return nx * dist, ny * dist


def _edge_tick(vstart, vend, opposite, value):
    """Tick for `value` (fraction) where the component runs 0 at vstart → 1 at vend."""
    p = _lerp(vstart, vend, value)
    ox, oy = _normal(vstart, vend, opposite, 0.035)
    xa = "right" if ox < -0.005 else ("left" if ox > 0.005 else "center")
    return dict(x=p[0] + ox, y=p[1] + oy, text=f"{int(value * 100)}",
                showarrow=False, font=dict(size=9, color=MUTED),
                xanchor=xa, yanchor="middle")


def _axis_arrow(vstart, vend, opposite, od=0.075, span=0.24):
    """Arrow alongside an edge pointing vstart→vend (direction of increase)."""
    ex, ey = vend[0] - vstart[0], vend[1] - vstart[1]
    e = np.hypot(ex, ey)
    ex, ey = ex / e, ey / e
    nx, ny = _normal(vstart, vend, opposite, od)
    mx, my = (vstart[0] + vend[0]) / 2 + nx, (vstart[1] + vend[1]) / 2 + ny
    return dict(x=mx + ex * span, y=my + ey * span,
                ax=mx - ex * span, ay=my - ey * span,
                xref="x", yref="y", axref="x", ayref="y", text="",
                showarrow=True, arrowhead=2, arrowsize=1, arrowwidth=1.2,
                arrowcolor=MUTED)


def _axis_title(vstart, vend, opposite, text, dist, angle):
    mx, my = (vstart[0] + vend[0]) / 2, (vstart[1] + vend[1]) / 2
    ox, oy = _normal(vstart, vend, opposite, dist)
    return dict(x=mx + ox, y=my + oy, text=f"<b>{text}</b>", showarrow=False,
                font=dict(size=13, color=TEXT), textangle=angle,
                xanchor="center", yanchor="middle")


def _frame_annotations():
    ann = []
    # (vstart, vend, opposite, title, title-angle): component 0 at vstart → 100 at vend.
    axes = [
        (NAK, CA, MG, "Ca²⁺", 0),
        (CA, MG, NAK, "Mg²⁺", -60),
        (MG, NAK, CA, "Na⁺+K⁺", 60),
        (HCO3, CL, SO4, "Cl⁻", 0),
        (SO4, HCO3, CL, "HCO₃⁻+CO₃²⁻", -60),
        (CL, SO4, HCO3, "SO₄²⁻", 60),
        (D_LEFT, D_TOP, D_RIGHT, "SO₄²⁻+Cl⁻", 60),
        (D_RIGHT, D_TOP, D_LEFT, "Ca²⁺+Mg²⁺", -60),
    ]
    for vs, ve, op, title, angle in axes:
        for k in TICKS:
            ann.append(_edge_tick(vs, ve, op, k))
        ann.append(_axis_arrow(vs, ve, op))
        ann.append(_axis_title(vs, ve, op, title, 0.20 if "+" in title else 0.14, angle))
    return ann


def _plot_piper(df):
    df = df.copy()
    cat = df["Ca"] + df["Mg"] + df["Na"]
    an = df["HCO3"] + df["SO4"] + df["Cl"]
    df = df[(cat > 0) & (an > 0)]
    cat, an = cat[df.index], an[df.index]

    df["ca"] = df["Ca"] / cat
    df["mg"] = df["Mg"] / cat
    df["nak"] = df["Na"] / cat
    df["hco3"] = df["HCO3"] / an
    df["so4"] = df["SO4"] / an
    df["cl"] = df["Cl"] / an
    df["so4cl"] = df["so4"] + df["cl"]
    df["type"] = [_classify((ca + mg) * 100, h * 100)
                  for ca, mg, h in zip(df["ca"], df["mg"], df["hco3"])]
    print(f"  plotting {len(df)} samples")

    fig = go.Figure()

    for wtype, color in TYPE_COLORS.items():
        sub = df[df["type"] == wtype]
        if len(sub) == 0:
            continue

        cat_pts = [_tri_xy(r.ca, r.nak, r.mg, CA, NAK, MG) for r in sub.itertuples()]
        an_pts = [_tri_xy(r.hco3, r.cl, r.so4, HCO3, CL, SO4) for r in sub.itertuples()]
        di_pts = [_diamond_xy(r.nak, r.so4cl) for r in sub.itertuples()]

        marker = dict(color=color, size=8, opacity=0.9,
                      line=dict(color="white", width=0.5))
        hover = [
            (f"<b>{wtype}</b><br>Ca {r.ca*100:.0f}%  Mg {r.mg*100:.0f}%  "
             f"Na+K {r.nak*100:.0f}%<br>HCO₃ {r.hco3*100:.0f}%  "
             f"SO₄ {r.so4*100:.0f}%  Cl {r.cl*100:.0f}%<extra></extra>")
            for r in sub.itertuples()
        ]
        for i, pts in enumerate((cat_pts, an_pts, di_pts)):
            xs, ys = zip(*pts)
            fig.add_trace(go.Scatter(
                x=xs, y=ys, mode="markers", marker=marker, name=wtype,
                legendgroup=wtype, showlegend=(i == 0), hovertemplate=hover))

    annotations = _frame_annotations()
    annotations.append(dict(
        x=DCX, y=-0.22, xref="x", yref="y", showarrow=False,
        text="Axis values are % of total cations / anions (meq L⁻¹)",
        font=dict(size=11, color=MUTED), xanchor="center"))

    fig.update_layout(
        shapes=_frame_shapes(),
        annotations=annotations,
        xaxis=dict(visible=False, range=[-0.25, 2 + G + 0.25],
                   scaleanchor="y", scaleratio=1, constrain="domain"),
        yaxis=dict(visible=False, range=[-0.28, H * (2 + G) + 0.15],
                   constrain="domain"),
        legend=dict(title="<b>Water type</b>", x=0.99, y=0.99,
                    xanchor="right", yanchor="top",
                    bgcolor="rgba(255,255,255,0.7)", bordercolor=GRID, borderwidth=1),
        margin=dict(t=20, b=20, l=20, r=20),
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly