Chris Parmer — home

Hodograph

Meteorology — upper-air wind profile, OUN sounding

Example from the compendium of canonical charts

Meteorology — Hodograph (upper-air wind profile, OUN sounding)

Python Code

"""Meteorology — Hodograph (upper-air wind profile, OUN sounding)."""


import numpy as np
import plotly.graph_objects as go


URL = "https://mesonet.agron.iastate.edu/json/raob.py?station=OUN&ts=2024051200"


def load_sounding():
    try:
        data = fetch_json(URL)
        # API returns either 'soundings' or 'profiles' depending on version
        if "soundings" in data:
            profile = data["soundings"][0]["profile"]
        elif "profiles" in data:
            profile = data["profiles"][0]["profile"]
        else:
            raise KeyError(f"unexpected keys: {list(data.keys())}")
        print(f"  profile length: {len(profile)}")
        print(f"  sample keys: {list(profile[0].keys()) if profile else 'empty'}")
        return profile
    except Exception as e:
        print(f"  data load failed ({e})")
        return None


def generate():
    profile = load_sounding()
    synthetic = False

    if profile is not None:
        hght_list, drct_list, sknt_list = [], [], []
        for lvl in profile:
            h = lvl.get("hght")
            d = lvl.get("drct")
            s = lvl.get("sknt")
            if h is not None and d is not None and s is not None:
                try:
                    hght_list.append(float(h))
                    drct_list.append(float(d))
                    sknt_list.append(float(s))
                except (ValueError, TypeError):
                    pass

        HGHT = np.array(hght_list)
        DRCT = np.array(drct_list)
        SKNT = np.array(sknt_list)
        print(f"  valid levels: {len(HGHT)}, height range: {HGHT.min():.0f}–{HGHT.max():.0f} m")
    else:
        synthetic = True
        print("  using synthetic hodograph (severe-storm environment)")
        # Synthetic supercell hodograph
        HGHT = np.array([0, 500, 1000, 1500, 2000, 3000, 4000, 5000, 6000, 8000, 10000, 12000])
        DRCT = np.array([180, 200, 220, 240, 260, 280, 300, 310, 320, 330, 340, 350])
        SKNT = np.array([10, 18, 28, 38, 45, 52, 58, 62, 65, 68, 70, 72])

    # Convert to u, v components (meteorological convention)
    d_rad = DRCT * np.pi / 180
    u = -SKNT * np.sin(d_rad)
    v = -SKNT * np.cos(d_rad)

    fig = go.Figure()

    # Concentric range rings
    for r in [20, 40, 60, 80]:
        theta = np.linspace(0, 2 * np.pi, 200)
        fig.add_trace(go.Scatter(
            x=r * np.cos(theta),
            y=r * np.sin(theta),
            mode="lines",
            line=dict(color="#e0e0e8", width=1),
            showlegend=False,
            hoverinfo="skip",
        ))
        # Ring labels
        fig.add_annotation(
            x=r, y=2,
            text=f"{r} kt",
            showarrow=False,
            font=dict(size=8, color=MUTED),
        )

    # Hodograph trace — colored by height
    # Use a multi-segment approach with per-segment color
    fig.add_trace(go.Scatter(
        x=u,
        y=v,
        mode="lines+markers",
        name="Wind profile" + (" (synthetic)" if synthetic else ""),
        line=dict(color=VIOLET, width=2.5),
        marker=dict(
            color=HGHT,
            colorscale="Plasma",
            size=8,
            showscale=True,
            colorbar=dict(
                title="Height (m)",
                thickness=14,
                len=0.7,
            ),
            line=dict(color="white", width=0.5),
        ),
        hovertemplate=(
            "U=%{x:.1f} kt<br>"
            "V=%{y:.1f} kt<br>"
            "Height=%{marker.color:.0f} m<extra></extra>"
        ),
    ))

    # Label a few key levels
    for target_h in [1000, 3000, 6000]:
        idx = np.argmin(np.abs(HGHT - target_h))
        fig.add_annotation(
            x=u[idx],
            y=v[idx],
            text=f"  {target_h // 1000}km",
            showarrow=False,
            font=dict(size=9, color=MUTED),
        )

    # Surface marker
    fig.add_trace(go.Scatter(
        x=[u[0]],
        y=[v[0]],
        mode="markers",
        marker=dict(symbol="star", size=12, color="#E9A23B"),
        name="Surface",
        hovertemplate=f"SFC: U={u[0]:.1f}, V={v[0]:.1f} kt<extra></extra>",
    ))

    # Axis extents
    all_pts = np.concatenate([np.abs(u), np.abs(v)])
    r_max = max(float(np.nanmax(all_pts)) * 1.1, 40)

    fig.update_layout(
        xaxis=dict(
            title="U-component (kt)",
            range=[-r_max, r_max],
            zeroline=True,
            zerolinewidth=1.5,
            zerolinecolor="#888",
            scaleanchor="y",
            constrain="domain",
        ),
        yaxis=dict(
            title="V-component (kt)",
            range=[-r_max, r_max],
            zeroline=True,
            zerolinewidth=1.5,
            zerolinecolor="#888",
            constrain="domain",
        ),
        legend=dict(
            x=1.12, y=1.0,
            xanchor="left",
            font=dict(size=10),
        ),
        margin=dict(t=20, b=60, l=70, r=140),
    )

    return fig


if __name__ == "__main__":
    generate()

Made with Plotly