Chris Parmer — home

USDA soil texture triangle

ISRIC WoSIS topsoil data with class overlays

Example from the compendium of canonical charts

USDA soil texture triangle — ISRIC WoSIS topsoil data with class overlays

Python Code

"""USDA soil texture triangle — ISRIC WoSIS topsoil data with class overlays."""


import io
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import requests, warnings

URL = "https://raw.githubusercontent.com/bgmartins/soil-classification/master/data/all.csv"

# ── USDA texture class boundary polygons (clay%, silt%, sand%) ───────────────
# Hard-coded canonical USDA.TT vertices (clay=top, silt=right, sand=left)
# Each class is a list of (clay, silt, sand) tuples in ternary coordinates
# Source: R soiltexture package USDA.TT
USDA_CLASSES = {
    "Clay":         [(100,0,0),(40,0,60),(40,20,40),(60,20,20),(60,0,40)],
    "Sandy Clay":   [(35,0,65),(35,20,45),(55,20,25),(55,0,45)],
    "Silty Clay":   [(40,60,0),(40,40,20),(60,20,20),(60,0,40),(100,0,0)],
    "Sandy Clay\nLoam": [(20,0,80),(20,28,52),(35,20,45),(35,0,65)],
    "Clay Loam":    [(27,20,53),(27,40,33),(40,40,20),(40,20,40),(35,20,45),(35,0,65),(20,0,80),(20,28,52)],
    "Silty Clay\nLoam": [(27,40,33),(27,73,0),(40,60,0),(40,40,20)],
    "Silt Loam":    [(0,80,20),(0,100,0),(27,73,0),(27,40,33),(12,40,48),(12,50,38)],
    "Silt":         [(0,100,0),(0,87,13),(8,92,0)],  # small class, simplified
    "Sandy Loam":   [(0,0,100),(0,20,80),(14,20,66),(20,20,60),(20,28,52),(20,0,80)],
    "Loam":         [(7,27,66),(7,53,40),(27,40,33),(27,20,53),(20,28,52),(14,20,66),(0,20,80)],
    "Loamy Sand":   [(0,0,100),(0,16,84),(6,14,80),(6,0,94)],
    "Sand":         [(0,0,100),(0,16,84),(6,14,80),(6,0,94)],
}

# Simplified but correct USDA boundary polygons for clean rendering
USDA_POLYGONS = {
    "Clay":          [(60,20,20),(60,0,40),(100,0,0),(40,0,60),(40,20,40)],
    "Sandy Clay":    [(35,0,65),(55,0,45),(55,20,25),(35,20,45)],
    "Silty Clay":    [(40,40,20),(60,20,20),(100,0,0),(40,0,60),(40,20,40)], # approx
    "Sandy Clay Loam":[(20,0,80),(35,0,65),(35,20,45),(27,20,53),(20,28,52)],
    "Clay Loam":     [(27,20,53),(40,20,40),(40,40,20),(27,40,33),(20,28,52)],  # approx
    "Silty Clay Loam":[(27,40,33),(40,40,20),(40,60,0),(27,73,0)],
    "Silt Loam":     [(0,50,50),(12,50,38),(12,28,60),(7,27,66),(0,23,77),(0,80,20),(27,73,0),(27,40,33),(12,40,48)],
    "Silt":          [(0,80,20),(0,100,0),(8,92,0)],
    "Sandy Loam":    [(0,0,100),(0,20,80),(20,20,60),(20,0,80)],
    "Loam":          [(7,27,66),(7,53,40),(27,40,33),(27,20,53),(20,28,52),(14,20,66)],
    "Loamy Sand":    [(0,0,100),(0,16,84),(6,14,80),(6,0,94)],
    "Sand":          [(0,0,100),(0,16,84),(6,14,80),(6,0,94)],
}

# 12 distinct colors for texture classes
CLASS_COLORS = [
    "#845EEE","#55B685","#E9A23B","#DA5597","#52B3D0",
    "#9467bd","#8c564b","#e377c2","#7f7f7f","#bcbd22","#17becf","#aec7e8"
]
CLASS_LIST = list(USDA_POLYGONS.keys())
CLASS_COLOR_MAP = {c: CLASS_COLORS[i % len(CLASS_COLORS)] for i, c in enumerate(CLASS_LIST)}


def classify_usda(clay, silt, sand):
    """Simple USDA texture classification."""
    if clay >= 40:
        if silt >= 40:
            return "Silty Clay"
        elif sand <= 45:
            return "Clay"
        else:
            return "Sandy Clay"
    elif clay >= 27:
        if sand >= 20 and silt < 28:
            return "Sandy Clay Loam"
        elif silt >= 40:
            return "Silty Clay Loam"
        else:
            return "Clay Loam"
    elif clay >= 7:
        if silt >= 50:
            return "Silt Loam"
        elif sand >= 52:
            return "Sandy Loam"
        else:
            return "Loam"
    else:
        if silt >= 80:
            return "Silt"
        elif sand >= 85:
            return "Sand"
        elif sand >= 70:
            return "Loamy Sand"
        else:
            return "Sandy Loam"


def generate():
    print("fetching WoSIS topsoil sand/silt/clay (streaming first ~3 MB) …")
    warnings.filterwarnings("ignore")
    requests.packages.urllib3.disable_warnings()

    # Stream first 3MB to get ~18k rows without downloading 30MB
    r = requests.get(URL, verify=False, timeout=60, stream=True,
                     headers={"Range": "bytes=0-3000000"})
    raw = b""
    for chunk in r.iter_content(65536):
        raw += chunk
        if len(raw) >= 3_000_000:
            break

    text = raw.decode("utf-8", errors="ignore")
    # Trim last partial line
    text = text[:text.rfind("\n")]

    df = pd.read_csv(io.StringIO(text), low_memory=False)
    print(f"  raw rows: {len(df)}, columns sample: {list(df.columns[:8])}")

    # Find sand/silt/clay columns (flexible naming)
    sand_col = next((c for c in df.columns if "sand" in c.lower() and "avg" in c.lower()), None)
    silt_col = next((c for c in df.columns if "silt" in c.lower() and "avg" in c.lower()), None)
    clay_col = next((c for c in df.columns if "clay" in c.lower() and "avg" in c.lower()), None)

    if not all([sand_col, silt_col, clay_col]):
        # Try simpler names
        sand_col = next((c for c in df.columns if "sand" in c.lower()), None)
        silt_col = next((c for c in df.columns if "silt" in c.lower()), None)
        clay_col = next((c for c in df.columns if "clay" in c.lower()), None)

    print(f"  columns: sand={sand_col}, silt={silt_col}, clay={clay_col}")

    df = df.dropna(subset=[sand_col, silt_col, clay_col]).copy()
    df["sand"] = pd.to_numeric(df[sand_col], errors="coerce")
    df["silt"] = pd.to_numeric(df[silt_col], errors="coerce")
    df["clay"] = pd.to_numeric(df[clay_col], errors="coerce")
    df = df.dropna(subset=["sand","silt","clay"])

    # Normalize rows to 100
    total = df["sand"] + df["silt"] + df["clay"]
    df = df[total > 5].copy()
    total = total[total > 5]
    df["sand"] = df["sand"] / total * 100
    df["silt"] = df["silt"] / total * 100
    df["clay"] = df["clay"] / total * 100

    # Classify and subsample
    df["class"] = df.apply(lambda r: classify_usda(r["clay"], r["silt"], r["sand"]), axis=1)
    df = df.sample(n=min(1500, len(df)), random_state=42)
    print(f"  after filter+subsample: {len(df)} rows")

    fig = go.Figure()

    # Class boundary polygons
    for cls, verts in USDA_POLYGONS.items():
        clay_v  = [v[0] for v in verts] + [verts[0][0]]
        silt_v  = [v[1] for v in verts] + [verts[0][1]]
        sand_v  = [v[2] for v in verts] + [verts[0][2]]
        color   = CLASS_COLOR_MAP[cls]
        fig.add_trace(go.Scatterternary(
            a=clay_v, b=silt_v, c=sand_v,
            mode="lines",
            line=dict(color="rgba(0,0,0,0.3)", width=1),
            fill="toself",
            fillcolor=(f"rgba({int(color[1:3],16)},{int(color[3:5],16)},{int(color[5:7],16)},0.12)"
                       if color.startswith("#") else color),
            name=cls,
            showlegend=True,
            hoverinfo="skip",
        ))

    # Sample points colored by class
    for cls in CLASS_LIST:
        sub = df[df["class"] == cls]
        if len(sub) == 0:
            continue
        fig.add_trace(go.Scatterternary(
            a=sub["clay"], b=sub["silt"], c=sub["sand"],
            mode="markers",
            marker=dict(color=CLASS_COLOR_MAP[cls], size=4, opacity=0.7),
            name=cls,
            showlegend=False,
            hovertemplate="Clay=%{a:.1f}%<br>Silt=%{b:.1f}%<br>Sand=%{c:.1f}%<br>" + cls + "<extra></extra>",
        ))

    fig.update_layout(
        title=dict(text="USDA Soil Texture Triangle — WoSIS Topsoil Samples", x=0.5),
        ternary=dict(
            sum=100,
            aaxis=dict(title="Clay %", min=0),
            baxis=dict(title="Silt %", min=0),
            caxis=dict(title="Sand %", min=0),
        ),
        legend=dict(
            orientation="v", x=1.02, y=0.5,
            font=dict(size=10),
            itemsizing="constant",
        ),
        margin=dict(t=60, b=40, l=60, r=160),
        height=520,
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly