Stability Index: With spatial calculations#

Bulk Brunt–Väisälä Frequency (N²) Approximation#

The code is computing the Brunt–Väisälä frequency squared using a finite-difference form:

\[ N^2 = \frac{g}{\bar\theta}\,\frac{d\theta}{dz} \;\;\approx\;\; \frac{g}{\bar\theta}\,\frac{\theta_{850}-\theta_{1000}}{\Delta z} \]

Potential Temperature#

Potential temperature at pressure \(p\) is given by Poisson’s equation:

\[ \theta = T \left(\frac{p_0}{p}\right)^\kappa \]

where

  • \(T\) is temperature (K),

  • \(p_0 = 1000\ \text{hPa}\) is the reference pressure,

  • \(\kappa = R_d / c_p\).


Hypsometric Equation (layer thickness)#

The vertical distance between pressure levels is computed using the dry hypsometric relation with a layer-mean temperature:

\[ \Delta z = \frac{R_d\,\bar T}{g}\,\ln\!\left(\frac{p_{1000}}{p_{850}}\right) \]

Final Expression#

Substituting, the bulk \(N^2\) between 1000 and 850 hPa is:

\[ N^2 \;\approx\; \frac{g}{\tfrac{1}{2}(\theta_{1000}+\theta_{850})}\; \frac{\theta_{850}-\theta_{1000}} {\;\dfrac{R_d\,\tfrac{1}{2}(T_{1000}+T_{850})}{g}\,\ln\!\left(\tfrac{p_{1000}}{p_{850}}\right)} \quad [\text{s}^{-2}] \]

Assumptions#

  • Dry air (no virtual temperature correction).

  • Hydrostatic balance.

  • Bulk (layer-mean) approximation.

  • Positive \(N^2\)statically stable, negative ⇒ unstable.

import xarray as xr
import numpy as np
import pandas as pd
from pathlib import Path
import xarray as xr
import matplotlib.pyplot as plt
# Resolve path given this notebook lives in intro_to_climatology_repo/data/
candidates = [Path("air.2024.nc"), Path("../data/air.2024.nc"), Path("data/air.2024.nc")]
nc_path = next((p for p in candidates if p.exists()), None)
if nc_path is None:
    raise FileNotFoundError(f"air.2024.nc not found in: {', '.join(map(str, candidates))}")
print("Using:", nc_path)

ds = xr.open_dataset(nc_path, decode_times=True)
print(ds)
Using: data/air.2024.nc
<xarray.Dataset> Size: 262MB
Dimensions:    (level: 17, lat: 73, lon: 144, time: 366, nbnds: 2)
Coordinates:
  * level      (level) float32 68B 1e+03 925.0 850.0 700.0 ... 30.0 20.0 10.0
  * lat        (lat) float32 292B 90.0 87.5 85.0 82.5 ... -85.0 -87.5 -90.0
  * lon        (lon) float32 576B 0.0 2.5 5.0 7.5 ... 350.0 352.5 355.0 357.5
  * time       (time) datetime64[ns] 3kB 2024-01-01 2024-01-02 ... 2024-12-31
Dimensions without coordinates: nbnds
Data variables:
    air        (time, level, lat, lon) float32 262MB ...
    time_bnds  (time, nbnds) float64 6kB ...
Attributes:
    Conventions:    COARDS
    title:          mean daily NMC reanalysis (2014)
    history:        created 2013/12 by Hoop (netCDF2.3)
    description:    Data is from NMC initialized reanalysis\n(4x/day).  It co...
    platform:       Model
    dataset_title:  NCEP-NCAR Reanalysis 1
    References:     http://www.psl.noaa.gov/data/gridded/data.ncep.reanalysis...
# Pick the air temperature variable (or first variable as fallback)
var_name = "air" if "air" in ds.data_vars else list(ds.data_vars)[0]
da = ds[var_name]
print("Variable:", var_name, "| dims:", da.dims, "| units:", da.attrs.get("units", "unknown"))

# Identify coordinate names robustly
lat_name = next((n for n in ["lat", "latitude", "y"] if n in ds.coords), None)
lon_name = next((n for n in ["lon", "longitude", "x"] if n in ds.coords), None)
lev_name = next((n for n in ["level", "lev", "plev", "isobaricInhPa"] if n in ds.coords), None)
print("Coords -> lat:", lat_name, "lon:", lon_name, "lev:", lev_name)

# Quick slice for plotting: first time, and 500 hPa if available
da2 = da.isel(time=0) if "time" in da.dims else da
if lev_name:
    try:
        # prefer exact 500, else nearest
        if 500 in np.array(da2[lev_name]):
            da2 = da2.sel({lev_name: 500})
        else:
            da2 = da2.sel({lev_name: 500}, method="nearest")
    except Exception as e:
        print("Level selection note:", e)

ax = da2.plot(figsize=(6, 4), cmap="viridis")
title = f"{var_name} at first time"
if lev_name:
    try:
        title += f", {float(da2[lev_name].values):.0f} hPa"
    except Exception:
        pass
ax.axes.set_title(title)
Variable: air | dims: ('time', 'level', 'lat', 'lon') | units: degK
Coords -> lat: lat lon: lon lev: level
Text(0.5, 1.0, 'air at first time, 500 hPa')
_images/31d360e9407b6c7613204d17ddc98d176fc44b7c9dcd3a3cb18a0ea633e80fbf.png
# 3) Select West Pacific point and time
target_lat = 5.0    # degrees North
target_lon = 150.0  # degrees East
# Normalize target lon to dataset's domain
lon_vals = np.asarray(ds[lon_name].values)
if lon_vals.min() >= 0:           # dataset uses 0..360
    target_lon_ds = target_lon % 360
else:                             # dataset uses -180..180
    target_lon_ds = target_lon if target_lon <= 180 else target_lon - 360

# Choose a time (nearest). Use first time if unsure.
time_sel = ds["time"].values[0]
# Example: pick by date nearest to '2024-07-01'
try:
    time_sel = ds["time"].sel(time="2024-07-01", method="nearest").values
except Exception:
    pass

# 4) Extract vertical profile at the selected point/time
prof = da.sel({lat_name: target_lat, lon_name: target_lon_ds}, method="nearest").sel(time=time_sel)

# 5) Prepare pressure and temperature
p = prof[lev_name].values  # expected in hPa (mb)
T = prof.values            # likely in Kelvin; convert to C for readability
units = prof.attrs.get("units", "K")
if units.lower().startswith("k"):
    Tplot = T - 273.15
    xlab = "Temperature (°C)"
else:
    Tplot = T
    xlab = f"Temperature ({units})"

# 6) Plot T vs P
plt.figure(figsize=(5,7))
plt.plot(Tplot, p, marker="o", ms=3, lw=1)
plt.gca().invert_yaxis()
# Optional: set bounds if levels are standard (e.g., 1000→100 hPa)
try:
    plt.ylim(max(p), min(p))
except Exception:
    pass
plt.xlabel(xlab)
plt.ylabel("Pressure (hPa)")
plt.title(f"T–P profile @ ({target_lat}°N, {target_lon}°E), time={np.datetime_as_string(time_sel, unit='D')}")
plt.grid(True, alpha=0.3)
plt.show()
_images/adc722e9e9ad9e2a93cafd818331282b1a6989761b76710c3320ab4368bb2e52.png
# ---------------- Config ----------------
target_lat = 5.0     # degN
target_lon = 150.0   # degE
p_bottom = 1000.0    # hPa
p_top = 850.0        # hPa
# ---------------------------------------

# Physical constants
g = 9.80665         # m s^-2
Rd = 287.05         # J kg^-1 K^-1
cp = 1004.0         # J kg^-1 K^-1
kappa = Rd / cp
p0 = 1000.0         # hPa


time_name = "time" if "time" in ds.dims or "time" in ds.coords else None

if any(v is None for v in [lat_name, lon_name, lev_name, time_name]):
    raise ValueError("Could not detect lat/lon/level/time coordinate names.")

# Map longitude to dataset domain
lon_vals = np.asarray(ds[lon_name].values)
if lon_vals.min() >= 0:           # 0..360
    target_lon_ds = target_lon % 360
else:                             # -180..180
    target_lon_ds = target_lon if target_lon <= 180 else target_lon - 360

# Determine pressure unit for interpolation targets
p_coord = ds[lev_name]
p_vals = np.asarray(p_coord.values)
if np.nanmax(p_vals) > 2000:           # Pa
    p_bottom_val = p_bottom * 100.0
    p_top_val = p_top * 100.0
else:                                   # hPa
    p_bottom_val = p_bottom
    p_top_val = p_top

# Extract T(time, level) at the point
prof = da.sel({lat_name: target_lat, lon_name: target_lon_ds}, method="nearest")

# Sort by pressure (ascending) for safe interpolation
prof_sorted = prof.sortby(lev_name)

# Interpolate T at 1000 and 850 hPa for all times (vectorized)
T_bottom = prof_sorted.interp({lev_name: p_bottom_val})
T_top = prof_sorted.interp({lev_name: p_top_val})

# Robust units handling (K, degK, Kelvin, etc.)
units = str(da.attrs.get("units", "K"))
u = units.lower().replace("degrees", "").replace("degree", "").replace("deg", "").replace("°", "").strip()
if u in ("k", "kelvin") or u.endswith("k"):
    pass  # already Kelvin
elif u in ("c", "celsius"):
    T_bottom = T_bottom + 273.15
    T_top = T_top + 273.15
elif u in ("f", "fahrenheit"):
    T_bottom = (T_bottom - 32.0) * 5.0/9.0 + 273.15
    T_top = (T_top - 32.0) * 5.0/9.0 + 273.15
else:
    print(f"Warning: unrecognized temperature units='{units}', assuming Kelvin")

# Potential temperature at endpoints (Poisson eq. expects hPa ratios)
theta_bottom = T_bottom * (p0 / p_bottom) ** kappa
theta_top = T_top * (p0 / p_top) ** kappa
theta_mean = 0.5 * (theta_bottom + theta_top)

# Hypsometric thickness (dry approx; use layer-mean T)
T_mean = 0.5 * (T_bottom + T_top)
dp_ratio = (p_bottom / p_top)  # unitless ratio; same for Pa or hPa
delta_z = (Rd * T_mean / g) * np.log(dp_ratio)   # meters; vector over time

# Bulk N^2 per day over 1000–850 hPa
N2 = (g / theta_mean) * (theta_top - theta_bottom) / delta_z  # s^-2; DataArray over time
fig, ax = plt.subplots(figsize=(8, 3))
N2.plot(ax=ax, color="tab:blue")
ax.set_title(f"N$^2$ (bulk 1000–850 hPa) @ ({target_lat}°N, {target_lon}°E)")
ax.set_ylabel("s$^{-2}$")
ax.set_xlabel("Date")
ax.grid(True, alpha=0.3)
plt.tight_layout()
_images/28a68c9d15dddbdf431b92d82e30451ad2202bf82cfa90af541b389a16af8f60.png
# Bulk N^2 (1000–850 hPa) for all pixels in 15S–15N, for all times
from pathlib import Path
import numpy as np
import xarray as xr
import matplotlib.pyplot as plt

# ---------------- Config ----------------
p_bottom = 1000.0  # hPa
p_top = 850.0      # hPa
lat_min, lat_max = -15.0, 15.0
# ---------------------------------------

# Physical constants
g = 9.80665         # m s^-2
Rd = 287.05         # J kg^-1 K^-1
cp = 1004.0         # J kg^-1 K^-1
kappa = Rd / cp
p0 = 1000.0         # hPa

# Open dataset (adjust path if needed)
nc_path = Path("data/air.2024.nc")
if not nc_path.exists():
    for p in [Path("air.2024.nc"), Path("../data/air.2024.nc")]:
        if p.exists():
            nc_path = p
            break
print("Using:", nc_path)
ds = xr.open_dataset(nc_path, decode_times=True)

# Identify variable and coordinates
var_name = "air" if "air" in ds.data_vars else list(ds.data_vars)[0]
da = ds[var_name]
lat_name = next((n for n in ["lat", "latitude", "y"] if n in ds.coords), None)
lon_name = next((n for n in ["lon", "longitude", "x"] if n in ds.coords), None)
lev_name = next((n for n in ["level", "lev", "plev", "isobaricInhPa"] if n in ds.coords), None)
time_name = "time" if "time" in ds.dims or "time" in ds.coords else None
if any(v is None for v in [lat_name, lon_name, lev_name, time_name]):
    raise ValueError("Could not detect lat/lon/level/time coordinate names.")

# Subset tropical band (robust to ascending/descending latitude order)
lat_vals = ds[lat_name].values
ascending = (lat_vals[-1] - lat_vals[0]) > 0
lat_slice = slice(lat_min, lat_max) if ascending else slice(lat_max, lat_min)
da_trop = da.sel({lat_name: lat_slice})

# Determine pressure units and targets for interpolation
p_coord = ds[lev_name]
p_vals = np.asarray(p_coord.values)
if np.nanmax(p_vals) > 2000:           # Pa
    p_bottom_val = p_bottom * 100.0
    p_top_val = p_top * 100.0
else:                                   # hPa
    p_bottom_val = p_bottom
    p_top_val = p_top

# Sort by pressure (ascending) and interpolate to 1000/850 hPa across all time/lat/lon
da_sorted = da_trop.sortby(lev_name)
T_bottom = da_sorted.interp({lev_name: p_bottom_val})
T_top = da_sorted.interp({lev_name: p_top_val})

# Robust units handling (K, degK, Kelvin, etc.)
units = str(da.attrs.get("units", "K"))
u = units.lower().replace("degrees", "").replace("degree", "").replace("deg", "").replace("°", "").strip()
if u in ("k", "kelvin") or u.endswith("k"):
    pass  # already Kelvin
elif u in ("c", "celsius"):
    T_bottom = T_bottom + 273.15
    T_top = T_top + 273.15
elif u in ("f", "fahrenheit"):
    T_bottom = (T_bottom - 32.0) * 5.0/9.0 + 273.15
    T_top = (T_top - 32.0) * 5.0/9.0 + 273.15
else:
    print(f"Warning: unrecognized temperature units='{units}', assuming Kelvin")

# Mask where interpolation failed (e.g., 1000 hPa below surface)
valid = np.isfinite(T_bottom) & np.isfinite(T_top)
T_bottom = T_bottom.where(valid)
T_top = T_top.where(valid)

# Potential temperature at endpoints (Poisson equation expects hPa ratios)
theta_bottom = T_bottom * (p0 / p_bottom) ** kappa
theta_top = T_top * (p0 / p_top) ** kappa
theta_mean = 0.5 * (theta_bottom + theta_top)

# Hypsometric thickness (dry; use layer-mean T)
T_mean = 0.5 * (T_bottom + T_top)
dp_ratio = (p_bottom / p_top)  # unitless
delta_z = (Rd * T_mean / g) * np.log(dp_ratio)   # meters

# Bulk N^2 for every time/lat/lon in the tropical band
N2 = (g / theta_mean) * (theta_top - theta_bottom) / delta_z  # s^-2
N2 = N2.rename("N2_1000_850").assign_attrs(units="s^-2", long_name="Bulk Brunt–Väisälä frequency squared (1000–850 hPa)")

print(N2)

# Example 1: Map for a chosen day (nearest)
day = "2024-07-01"
fig, ax = plt.subplots(figsize=(7,3))
N2.sel({time_name: day}, method="nearest").plot(ax=ax, cmap="viridis")
ax.set_title(f"N$^2$ (1000–850 hPa) on {day}")
plt.tight_layout()
plt.show()

# Example 2: Tropical-mean daily series (average over lat,lon in 15S–15N)
N2_mean = N2.mean(dim=[lat_name, lon_name], skipna=True)
fig, ax = plt.subplots(figsize=(8,3))
N2_mean.plot(ax=ax, color="tab:blue")
ax.set_title("Tropical-mean N$^2$ (1000–850 hPa), 15S–15N")
ax.set_ylabel("s$^{-2}$"); ax.set_xlabel("Date"); ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Using: data/air.2024.nc
<xarray.DataArray 'N2_1000_850' (time: 366, lat: 13, lon: 144)> Size: 5MB
array([[[1.45438151e-04, 1.74229465e-04, 2.04747667e-04, ...,
         1.56576612e-04, 1.50732930e-04, 1.39069289e-04],
        [1.44379390e-04, 1.82302789e-04, 2.14797760e-04, ...,
         1.34017226e-04, 1.22970268e-04, 1.22950591e-04],
        [1.23578022e-04, 1.46168422e-04, 1.68797188e-04, ...,
         1.13106816e-04, 1.01467539e-04, 1.05571757e-04],
        ...,
        [1.48090198e-04, 1.60636765e-04, 1.79558523e-04, ...,
         1.12719112e-04, 1.22922931e-04, 1.36116130e-04],
        [1.62414941e-04, 1.89293268e-04, 2.28866668e-04, ...,
         1.05044590e-04, 1.28017013e-04, 1.46754594e-04],
        [1.44882018e-04, 1.88802954e-04, 2.60040718e-04, ...,
         8.51524555e-05, 1.08851125e-04, 1.26565076e-04]],

       [[1.93032171e-04, 2.35483560e-04, 2.56884641e-04, ...,
         1.96457484e-04, 1.78224278e-04, 1.70023971e-04],
        [1.73466983e-04, 2.14600135e-04, 2.38956042e-04, ...,
         1.61240652e-04, 1.43902751e-04, 1.44460528e-04],
        [1.41049365e-04, 1.56612612e-04, 1.65298416e-04, ...,
         1.19844420e-04, 1.16379802e-04, 1.25552545e-04],
...
        [2.54333877e-04, 2.48332101e-04, 2.54699332e-04, ...,
         2.67835279e-04, 2.66103332e-04, 2.63801783e-04],
        [2.75898354e-04, 2.87480169e-04, 3.09121722e-04, ...,
         2.81060770e-04, 2.75367529e-04, 2.74287106e-04],
        [2.81197352e-04, 3.03781780e-04, 3.39139546e-04, ...,
         2.72706103e-04, 2.71895765e-04, 2.73859607e-04]],

       [[2.16650559e-04, 2.39262546e-04, 2.58274808e-04, ...,
         2.20734752e-04, 2.14021779e-04, 2.07655722e-04],
        [1.98547133e-04, 2.27596742e-04, 2.52360158e-04, ...,
         2.15625869e-04, 2.00779160e-04, 1.89392617e-04],
        [1.62404569e-04, 1.92590876e-04, 2.23486377e-04, ...,
         1.78212241e-04, 1.65923775e-04, 1.54252462e-04],
        ...,
        [2.86012871e-04, 2.77413639e-04, 2.65786815e-04, ...,
         2.99703891e-04, 2.90567602e-04, 2.89950191e-04],
        [3.16622597e-04, 3.17094998e-04, 3.13155477e-04, ...,
         3.25579091e-04, 3.15570659e-04, 3.13256335e-04],
        [3.16743890e-04, 3.22656385e-04, 3.31828517e-04, ...,
         3.22199999e-04, 3.15539566e-04, 3.15618215e-04]]])
Coordinates:
  * lat      (lat) float32 52B 15.0 12.5 10.0 7.5 5.0 ... -7.5 -10.0 -12.5 -15.0
  * lon      (lon) float32 576B 0.0 2.5 5.0 7.5 10.0 ... 350.0 352.5 355.0 357.5
  * time     (time) datetime64[ns] 3kB 2024-01-01 2024-01-02 ... 2024-12-31
Attributes:
    units:      s^-2
    long_name:  Bulk Brunt–Väisälä frequency squared (1000–850 hPa)
_images/a4798390d3099bc71fea964f0c3ceda0557a45e4882f48e7d02436a275fad893.png _images/88690ee02f1741ea34d66215d61ce54c61306f4a94a3846ce1a115e677766275.png
# Monthly N^2 (1000–850 hPa) map with Cartopy
import numpy as np
import xarray as xr
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
import cartopy.feature as cfeature

# Expect a DataArray named N2 with dims like (time, lat, lon)
if "N2" not in globals():
    raise NameError("N2 not found. Compute N2 first (time, lat, lon).")

# Coordinate names (robust detection)
lat_name = next((n for n in ["lat", "latitude", "y"] if n in N2.dims or n in N2.coords), None)
lon_name = next((n for n in ["lon", "longitude", "x"] if n in N2.dims or n in N2.coords), None)
time_name = next((n for n in ["time", "date"] if n in N2.dims or n in N2.coords), None)
if any(v is None for v in [lat_name, lon_name, time_name]):
    raise ValueError("Could not detect lat/lon/time coordinate names on N2.")

# Resample to monthly means
N2_mon = N2.resample({time_name: "MS"}).mean(skipna=True)

# Select a month to plot
month_str = "2024-07"  # change as needed
da_mon = N2_mon.sel({time_name: month_str}, method="nearest")

# Shift longitude to [-180, 180] for nicer world map (optional but recommended)
lon_vals = xr.DataArray(N2[lon_name])
if float(lon_vals.max()) > 180:
    lon_shifted = ((lon_vals + 180) % 360) - 180
    da_mon = da_mon.assign_coords({lon_name: lon_shifted}).sortby(lon_name)

# Plot with Cartopy
fig = plt.figure(figsize=(9, 5))
proj = ccrs.PlateCarree()
ax = plt.axes(projection=proj)

# Focus on tropics; widen a bit for context
ax.set_extent([-180, 180, -15, 15], crs=ccrs.PlateCarree())
ax.coastlines(linewidth=0.8)
ax.add_feature(cfeature.BORDERS, linewidth=0.3)
ax.add_feature(cfeature.LAND, facecolor="0.95", edgecolor="none", zorder=0)

# Use xarray's plot with proper transform
pcm = da_mon.plot(
    ax=ax,
    transform=ccrs.PlateCarree(),
    cmap="viridis",
    add_colorbar=True,
    cbar_kwargs={"label": "N² (s$^{-2}$)", "orientation": "horizontal", "pad": 0.05},
)

# Title
tlabel = np.datetime_as_string(da_mon[time_name].values, unit="M")
ax.set_title(f"Monthly mean N² (1000–850 hPa) — {tlabel}")

plt.tight_layout()
plt.show()
_images/ac72ace2f0546086488a621a028e76682a503c7975bb71f6c1d12a8f9d7ac4ee.png