Chris Parmer — home

Skew-T log-P sounding

UWyo radiosonde OUN 72357, 00Z 2024-05-20

Example from the compendium of canonical charts

Skew-T log-P sounding — UWyo radiosonde OUN 72357, 00Z 2024-05-20

Python Code

"""Skew-T log-P sounding — UWyo radiosonde OUN 72357, 00Z 2024-05-20."""
import re


import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots

SKEW = 55   # skew factor in x-units per log10-pressure decade (~45° visual)

# Multiple data source attempts
UWYO_URL = (
    "https://weather.uwyo.edu/cgi-bin/sounding"
    "?region=naconf&TYPE=TEXT:LIST&YEAR=2024&MONTH=05&FROM=2000&TO=2000&STNM=72357"
)
# Iowa State University Mesonet radiosonde API (JSON)
IASTATE_URL = (
    "https://mesonet.agron.iastate.edu/json/raob.py"
    "?station=OUN&ts=2024051200"
)


def parse_uwyo_pre(text):
    m = re.search(r"<pre>(.*?)</pre>", text, re.DOTALL)
    if not m:
        return None
    lines = m.group(1).strip().split("\n")
    data_lines = [l for l in lines if re.match(r"\s*\d", l)]
    rows = []
    for l in data_lines:
        vals = l.split()
        if len(vals) >= 5:
            try:
                rows.append([float(v) for v in vals[:11]])
            except ValueError:
                pass
    if not rows:
        return None
    cols = ["PRES","HGHT","TEMP","DWPT","RELH","MIXR","DRCT","SKNT","THTA","THTE","THTV"]
    df = pd.DataFrame(rows, columns=cols[:len(rows[0])])
    return df[df["PRES"] > 0].dropna(subset=["PRES","TEMP","DWPT"])


def parse_iastate_json(data):
    """Parse Iowa State RAOB JSON response."""
    profiles = data.get("profiles", [])
    if not profiles:
        return None
    profile = profiles[0]
    levels  = profile.get("profile", [])
    rows = []
    for lv in levels:
        try:
            pres = float(lv.get("pres", 0))
            temp = float(lv.get("tmpc", 0))
            dwpt = float(lv.get("dwpc", 0))
            hght = float(lv.get("hght", 0))
            if pres > 0 and abs(temp) < 200 and abs(dwpt) < 200:
                rows.append({"PRES": pres, "TEMP": temp, "DWPT": dwpt, "HGHT": hght})
        except (ValueError, TypeError):
            pass
    if not rows:
        return None
    df = pd.DataFrame(rows)
    return df[df["PRES"] > 0].dropna(subset=["PRES","TEMP","DWPT"])


def synthetic_sounding():
    """Standard atmosphere + moist lapse rate sounding for demonstration."""
    import numpy as np
    pressures = [1013, 1000, 975, 950, 925, 900, 850, 800, 750, 700,
                 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150, 100]
    rows = []
    T_sfc = 22.0   # surface temp °C
    Td_sfc = 14.0  # surface dewpoint °C
    for i, P in enumerate(pressures):
        # Dry lapse ~6.5°C/km, simplified with pressure
        k = np.log(1013 / P) / np.log(1013 / 100)
        T  = T_sfc - 60 * k
        Td = min(T, Td_sfc - 30 * k)
        rows.append({"PRES": float(P), "TEMP": T, "DWPT": Td, "HGHT": float(i * 500)})
    return pd.DataFrame(rows)


def fetch_sounding():
    import requests, warnings
    warnings.filterwarnings("ignore")
    requests.packages.urllib3.disable_warnings()

    for url, parser, label in [
        (UWYO_URL,    lambda t: parse_uwyo_pre(t),  "UWyo HTML"),
        (IASTATE_URL, lambda t: parse_iastate_json(__import__("json").loads(t)), "Iowa State JSON"),
    ]:
        try:
            r = requests.get(url, verify=False, timeout=20)
            if r.status_code == 200:
                df = parser(r.text)
                if df is not None and len(df) > 5:
                    print(f"  using {label}, {len(df)} levels")
                    return df, label
                print(f"  {label}: no usable data")
        except Exception as e:
            print(f"  {label} failed: {e}")

    print("  using synthetic standard-atmosphere sounding")
    return synthetic_sounding(), "Standard atmosphere (synthetic)"


# ── Thermodynamic constants ──────────────────────────────────────────────────
RD, CP, EPS, LV = 287.05, 1005.0, 0.622, 2.501e6   # J/kg/K, J/kg/K, –, J/kg
KAPPA = RD / CP                                     # ≈ 0.2854
P0 = 1000.0                                         # reference pressure (hPa)
P_BOT, P_TOP = 1050.0, 100.0                        # frame pressure limits
X_LEFT, X_RIGHT = -48.0, 45.0                       # frame temperature limits (skewed °C)

# Pressure levels labelled on the left axis (and drawn as horizontal isobars).
P_LABELS = [1000, 850, 700, 500, 400, 300, 250, 200, 150, 100]


def YP(P):
    """Vertical coordinate: log-pressure, 0 at 1000 hPa, increasing upward."""
    return np.log10(P0 / np.asarray(P, dtype=float))


def xskew(T_C, P):
    """Skew a temperature (°C) at pressure P into the diagram's x-coordinate."""
    return np.asarray(T_C, dtype=float) + SKEW * YP(P)


def es_hpa(T_C):
    """Saturation vapour pressure (hPa) — Bolton (1980)."""
    return 6.112 * np.exp(17.67 * T_C / (T_C + 243.5))


def sat_mixing_ratio(T_C, P):
    """Saturation mixing ratio (kg/kg) at temperature T_C (°C) and pressure P (hPa)."""
    e = es_hpa(T_C)
    return EPS * e / (P - e)


def family_trace(lines, color, name, width=0.8, dash=None):
    """Collapse a family of (value, cx, cy) lines into one trace. Families are labelled
    inline (rotated, in colour) rather than via the legend, so they stay off it."""
    xs, ys = [], []
    for _value, cx, cy in lines:
        xs.extend(list(cx) + [None])
        ys.extend(list(cy) + [None])
    return go.Scatter(x=xs, y=ys, mode="lines", name=name, showlegend=True,
                      hoverinfo="skip", line=dict(color=color, width=width, dash=dash))


def generate():
    print("fetching UWyo OUN 72357 radiosonde 00Z 2024-05-20 …")
    df, source = fetch_sounding()
    df = df[(df["PRES"] <= P_BOT) & (df["PRES"] >= P_TOP)].copy()

    # Hand-built Skew-T log-P, the way real sounding software (MetPy, SHARPpy) draws it:
    # a plain log-pressure y-axis with the temperature axis sheared ~45°, then the full
    # thermodynamic background drawn as line families — isotherms, isobars, dry adiabats,
    # moist (pseudo-)adiabats and mixing-ratio lines — over alternating shaded bands.
    Y_BOT, Y_TOP = float(YP(P_BOT)), float(YP(P_TOP))
    Pc = np.exp(np.linspace(np.log(P_BOT), np.log(P_TOP), 120))   # smooth pressure samples

    fig = go.Figure()

    # ── Background curve families ───────────────────────────────────────────────
    # Each family is a hand-drawn set of lines (one per "tick" value). We keep the
    # value with each line so we can label it like an axis tick further below.
    ISO_C  = "#9a9ca6"                  # isotherms
    DRY_C  = "rgba(233,162,59,0.55)"    # dry adiabats
    MOIST_C = "rgba(60,165,120,0.65)"   # moist adiabats
    MIX_C  = "rgba(132,94,238,0.60)"    # mixing-ratio lines

    # Isotherms: straight lines of constant T, sheared to the right (sampled over the
    # full pressure range so they can be labelled at any level, like the other families).
    isotherms = [(T, xskew(T, Pc), YP(Pc)) for T in range(-110, 51, 10)]
    fig.add_trace(family_trace(isotherms, ISO_C, "Isotherms (°C)", width=0.9))  # solid

    # Dry adiabats: constant potential temperature θ — T(P) = θ·(P/1000)^κ.
    dry = []
    for theta_C in range(-30, 200, 10):
        T_C = (theta_C + 273.15) * (Pc / P0) ** KAPPA - 273.15
        dry.append((theta_C, xskew(T_C, Pc), YP(Pc)))
    fig.add_trace(family_trace(dry, DRY_C, "Dry adiabats (θ, °C)", width=0.8))  # solid (source)

    # Moist (pseudo-)adiabats: integrate the saturated lapse rate upward from P_BOT.
    moist = []
    for T0_C in range(-20, 41, 5):
        T = T0_C + 273.15
        T_path = [T]
        for i in range(1, len(Pc)):
            dlnP = np.log(Pc[i]) - np.log(Pc[i - 1])
            ws = sat_mixing_ratio(T - 273.15, Pc[i - 1])
            dTdlnP = (RD * T + LV * ws) / (CP + (LV * LV * ws * EPS) / (RD * T * T))
            T = T + dTdlnP * dlnP
            T_path.append(T)
        moist.append((T0_C, xskew(np.array(T_path) - 273.15, Pc), YP(Pc)))
    fig.add_trace(family_trace(moist, MOIST_C, "Moist adiabats (°C)", width=0.9, dash="dot"))

    # Mixing-ratio lines: constant saturation mixing ratio w (dashed).
    mix_vals = [0.4, 1, 2, 3, 5, 8, 12, 20]   # g/kg
    mixing = []
    for w_gkg in mix_vals:
        w = w_gkg / 1000.0
        e = w * Pc / (EPS + w)                          # required vapour pressure
        T_C = 243.5 * np.log(e / 6.112) / (17.67 - np.log(e / 6.112))
        seg = Pc >= 250                                 # conventionally lower troposphere
        mixing.append((w_gkg, xskew(T_C[seg], Pc[seg]), YP(Pc[seg])))
    fig.add_trace(family_trace(mixing, MIX_C, "Mixing ratio (g/kg)", width=0.8, dash="dash"))  # dashed (source)

    # ── The sounding ────────────────────────────────────────────────────────────
    fig.add_trace(go.Scatter(
        x=xskew(df["TEMP"], df["PRES"]), y=YP(df["PRES"]),
        mode="lines", name="Temperature", line=dict(color=PINK, width=2.8),
        customdata=np.c_[df["TEMP"], df["PRES"]],
        hovertemplate="T = %{customdata[0]:.1f}°C @ %{customdata[1]:.0f} hPa<extra></extra>",
    ))
    fig.add_trace(go.Scatter(
        x=xskew(df["DWPT"], df["PRES"]), y=YP(df["PRES"]),
        mode="lines", name="Dewpoint", line=dict(color=TEAL, width=2.8),
        customdata=np.c_[df["DWPT"], df["PRES"]],
        hovertemplate="Td = %{customdata[0]:.1f}°C @ %{customdata[1]:.0f} hPa<extra></extra>",
    ))
    # ── Real height scale (km + kft) from the sounding's measured geopotential ───
    H = df["HGHT"].astype(float).values     # geopotential height, metres
    P = df["PRES"].astype(float).values
    order = np.argsort(H)
    Hs, Ps = H[order], P[order]

    def y_at_height(h_m):
        if h_m < Hs[0] or h_m > Hs[-1]:
            return None
        return float(YP(np.interp(h_m, Hs, Ps)))

    kft_ticks = [(h, y_at_height(h * 304.8)) for h in range(0, 56, 5)]
    kft_ticks = [(h, y) for h, y in kft_ticks if y is not None]

    # ── Frame: cream "paper" + alternating diagonal isotherm bands + isobars ────
    # Base paper fill.
    shapes = [dict(type="rect", xref="x", yref="y", layer="below",
                   x0=X_LEFT, x1=X_RIGHT, y0=Y_BOT, y1=Y_TOP,
                   line=dict(width=0), fillcolor="#f8f3e2")]
    # Every other 10 °C isotherm band, shaded as a skewed parallelogram (clipped to the
    # frame) so the alternating stripes run diagonally along the isotherms, not flat.
    for T in range(-110, 50, 10):
        if (T // 10) % 2 != 0:
            continue
        x0, x1 = T + SKEW * Y_BOT, (T + 10) + SKEW * Y_BOT
        x2, x3 = (T + 10) + SKEW * Y_TOP, T + SKEW * Y_TOP
        shapes.append(dict(type="path", xref="x", yref="y", layer="below",
                           path=f"M {x0},{Y_BOT} L {x1},{Y_BOT} L {x2},{Y_TOP} "
                                f"L {x3},{Y_TOP} Z",
                           fillcolor="rgba(206,186,140,0.28)", line=dict(width=0)))
    # Isobars (horizontal) drawn over the bands, then the frame border.
    shapes += [dict(type="line", xref="x", yref="y", layer="below",
                    x0=X_LEFT, x1=X_RIGHT, y0=YP(p), y1=YP(p),
                    line=dict(color="#cbb98f", width=1)) for p in P_LABELS]
    shapes.append(dict(type="rect", xref="x", yref="y", layer="below",
                       x0=X_LEFT, x1=X_RIGHT, y0=Y_BOT, y1=Y_TOP,
                       line=dict(color="#b29a63", width=1.2), fillcolor="rgba(0,0,0,0)"))

    # ── Tick labels for every hand-drawn family (each family is its own "axis") ──
    # Anchor a label where the line meets a frame edge ("top"/"bottom"), or where it
    # crosses a target pressure (a number, hPa) — inside the box.
    def line_anchor(cx, cy, where):
        xs, ys = np.asarray(cx, float), np.asarray(cy, float)
        if where == "top":
            idx = np.argsort(ys)[::-1]
        elif where == "bottom":
            idx = np.argsort(ys)
        elif where == "left":
            idx = np.argsort(xs)
        else:                                   # nearest a target pressure
            idx = np.argsort(np.abs(ys - float(YP(where))))
        for i in idx:
            if X_LEFT + 1.5 <= xs[i] <= X_RIGHT - 1.5 and Y_BOT <= ys[i] <= Y_TOP:
                return float(xs[i]), float(ys[i])
        return None

    annotations = []

    def label_family(lines, where, color, angle, fmt, dy=0, dx=0, edge_tol=None, size=9):
        for value, cx, cy in lines:
            anc = line_anchor(cx, cy, where)
            if anc is None:
                continue
            # Only label lines that actually reach the requested edge (avoids labelling
            # a line that merely clips a corner far from its edge).
            if edge_tol is not None:
                if where == "bottom" and anc[1] > Y_BOT + edge_tol:
                    continue
                if where == "top" and anc[1] < Y_TOP - edge_tol:
                    continue
            annotations.append(dict(
                x=anc[0], y=anc[1], xref="x", yref="y", text=fmt(value),
                showarrow=False, xshift=dx, yshift=dy, textangle=angle,
                font=dict(size=size, color=color)))

    # Isotherms (°C) labelled along the bottom (source convention); mixing ratio (g/kg)
    # at the top of each (250 hPa) line. Dry adiabats exit the left edge, so label there
    # (avoids colliding with the isotherm labels now on the bottom). Moist on the curve.
    label_family(isotherms, "bottom", MUTED, -45, lambda v: f"{v}°C", dy=-13, size=12)
    label_family(mixing, "top", "#7048d8", -45, lambda v: f"{v:g}", dy=10, dx=6)
    label_family(dry, "left", "#b97d1e", 45, lambda v: f"{v}", dx=8)
    label_family(moist, 600, "#2f8f63", 50, lambda v: f"{v}", dx=-9)

    # ── Inline family names, rotated to follow each line, in the family colour ───
    # Convert a data-space slope to the on-screen text angle for the 1280×800 export.
    PLOT_W, PLOT_H = 1280 - (70 + 70), 800 - (80 + 40)
    ppx = PLOT_W / (X_RIGHT - X_LEFT)
    ppy = PLOT_H / (Y_TOP - Y_BOT)

    def find_line(lines, value):
        return next(((v, cx, cy) for v, cx, cy in lines if v == value), None)

    def name_chip(x, y, text, line_color, line_width, text_color, angle=0.0, size=12, dx=0, dy=0):
        # A bordered "chip": the border uses the exact colour AND width of the family's
        # line, so it reads as a continuation of that reference line into the label.
        # The text uses a darker, readable shade. (Plotly borders can't be dashed.)
        annotations.append(dict(
            x=x, y=y, xref="x", yref="y", text=text, showarrow=False, textangle=angle,
            xshift=dx, yshift=dy, font=dict(size=size, color=text_color),
            bgcolor="rgba(255,255,255,0.94)", bordercolor=line_color,
            borderwidth=line_width, borderpad=3))

    def name_on_line(line, p_target, text, line_color, line_width, text_color,
                     size=12, dx=0, dy=0):
        if line is None:
            return
        _v, cx, cy = line
        xs, ys = np.asarray(cx, float), np.asarray(cy, float)
        i = int(np.argmin(np.abs(ys - float(YP(p_target)))))
        i0, i1 = max(i - 2, 0), min(i + 2, len(xs) - 1)
        ddx, ddy = xs[i1] - xs[i0], ys[i1] - ys[i0]
        angle = float(np.degrees(np.arctan2(-ddy * ppy, ddx * ppx)))
        angle = (angle + 90) % 180 - 90      # keep text upright (-90°…90°)
        if not (X_LEFT + 2 <= xs[i] <= X_RIGHT - 2):
            return
        name_chip(float(xs[i]), float(ys[i]), text, line_color, line_width, text_color,
                  angle, size, dx, dy)

    # Each chip's border = its family line's exact (colour, width); text = readable shade.
    name_on_line(find_line(dry, 30), 520, "dry adiabat", DRY_C, 0.8, "#c5881f", size=13)
    # Moist adiabat & mixing ratio: lifted above their lines (+dy), in the clear wedge
    # to the right of the sounding so they don't sit on the temperature/dewpoint traces.
    name_on_line(find_line(moist, 30), 680, "moist adiabat", MOIST_C, 0.9, "#2f8f63", size=13, dy=11)
    name_on_line(find_line(mixing, 8), 400, "mixing ratio", MIX_C, 0.8, "#7048d8", size=12, dy=11)
    name_on_line(find_line(isotherms, -30), 650, "isotherm", ISO_C, 0.9, "#787b85", size=13)
    # Isobars are horizontal lines (drawn as shapes): a flat chip on the 500 hPa line.
    name_chip(X_LEFT + 16, float(YP(500)), "isobar", "#cbb98f", 1.0, "#787b85", angle=0, size=13)

    # Dummy trace so the overlaying right-hand height axis (kft) renders.
    fig.add_trace(go.Scatter(x=[X_LEFT], y=[Y_BOT], xaxis="x", yaxis="y2",
                             mode="markers", marker=dict(opacity=0),
                             showlegend=False, hoverinfo="skip"))

    fig.update_layout(
        title=dict(text=f"Skew-T log-P — OUN Norman OK ({source})", x=0.5),
        shapes=shapes,
        annotations=annotations,
        xaxis=dict(visible=False, showgrid=False, zeroline=False,
                   range=[X_LEFT, X_RIGHT], autorange=False, fixedrange=True),
        yaxis=dict(title=dict(text="Pressure (hPa)"), tickmode="array",
                   tickvals=[float(YP(p)) for p in P_LABELS],
                   ticktext=[str(p) for p in P_LABELS],
                   range=[Y_BOT, Y_TOP], autorange=False,
                   showgrid=False, zeroline=False, side="left"),
        # Right height scale in thousands of feet, from the measured geopotential.
        yaxis2=dict(title=dict(text="kft"), tickmode="array",
                    tickvals=[y for _, y in kft_ticks],
                    ticktext=[str(h) for h, _ in kft_ticks],
                    range=[Y_BOT, Y_TOP], autorange=False,
                    overlaying="y", side="right", showgrid=False, zeroline=False),
        legend=dict(orientation="h", y=1.10),
        margin=dict(t=80, b=40, l=70, r=70),
        height=640,
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly