Calculate and plot Turner Angle#

Here we illustrate how to extract data on the native ROMS model grid, and to plot the statistics of the vertical distribution of the so-called Turner angle. The Turner angle was proposed by Ruddick [1], and is defined as

\(\mathrm{Tu} = \tan^{-1}\left( \alpha \frac{\partial \theta}{\partial z} - \beta \frac{\partial S}{\partial z}, \alpha \frac{\partial \theta}{\partial x} - \beta \frac{\partial S}{\partial x} \right)\) where \(\theta\) is the potential temperature, \(S\) is the salinity, \(\alpha\) is the thermal expansion coefficient, and \(\beta\) is the haline contraction coefficient. The Turner angle can be used to investigate the stability of a water column, and the separate roles of salinity and temperature for this stability. It is related to the density ratio, but is perhaps generally more useful for diagnostics. The Wikipedia page is informative, and as they say there, the most relevant ranges are:

  • If −45° < Tu < 45°, the column is statically stable.

  • If −90° < Tu < −45°, the column is unstable to diffusive convection.

  • If 45° < Tu < 90°, the column is unstable to salt fingering.

  • If −90° > Tu or Tu > 90°, the column is statically unstable to Rayleigh–Taylor instability (Unstable).

In this notebook we will be fetching a subset of the full output file and plot the median vertical distribution of the Turner angle in addition to the 5, 25, 75, and 95 percentiles. A plot is made highlighting the different regimes listed above. Note that our code uses the gsw package that is based on TEOS-10, and hence we do a conversion inside the tad function called below from potential temperature and practical salinity to conservative temperature and absolute salinity.

[1]: Ruddick, B. (1983), “A practical indicator of the stability of the water column to double-diffusive activity”, _Deep Sea Res._, 30, pp. 1105-1107.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import BoundaryNorm, ListedColormap

import cmocean.cm as cmo
import xarray as xr
import gsw

from scipy.interpolate import griddata
from pyproj import Geod
from scipy.interpolate import interp1d

# Load the dataset and resample to daily means
ds = xr.open_dataset('./datasets/timeseries_norkyst_2025_deep.nc', engine='netcdf4').resample(ocean_time="D").mean(dim="ocean_time")
ds = ds.isel(s_rho=slice(None, None, -1)) # Invert the vertical coordinate to have depth increasing downwards

# ---------------------------------------- #
# --- Conversion from S-coord to depth --- #
# ---------------------------------------- #
Zo_rho = (ds.hc * ds.s_rho + ds.Cs_r * ds.h) / (ds.hc + ds.h)
z_rho = ds.zeta + (ds.zeta + ds.h) * Zo_rho

ds.coords["z_rho"] = z_rho.transpose('ocean_time', 's_rho')

# ----------------------------------------------------------------------- #
# --- Convert salinity and temperature from ROMS to TEOS-10 standards --- #
# ----------------------------------------------------------------------- #
salt = ds.salt
temp = ds.temp

Convert temperature and salinity#

maxdepth = -450.0
resolution = 200

# Interpolate to fixed depths
zout = np.linspace(maxdepth,-1,resolution)

# Convert salinity and temperature from ROMS to TEOS-10 standards
p = gsw.conversions.p_from_z(ds.z_rho, 57.5) # Lat
SA = gsw.conversions.SA_from_SP(salt, p, 8, 57.5) # Lon, Lat
CT = gsw.conversions.CT_from_pt(SA, temp)
rho0 = gsw.pot_rho_t_exact(SA, CT, p, 0) # Ref level


# ------------------------------------------------ #
# --- Plot vertical profile of SA, CT, and rh0 --- #
# ------------------------------------------------ #

index = 0
fig, ax1 = plt.subplots()

#plt.scatter(Tu_angle[index,:], zout, label='Tu')
ax1.plot(CT[index,:], ds.z_rho[index,:], c='k', lw=2);
ax1.set_xlabel(r'Conservative temperature $[C^{\degree}]$', color='k')
ax1.set_ylabel('Depth [m]', color='k')

ax2 = ax1.twiny()  # instantiate a second Axes that shares the same x-axis
ax2.set_xlabel('Absolute salinity', color='r')
ax2.plot(SA[index,:], ds.z_rho[index,:], c='r', lw=2);
ax2.tick_params(axis='x', labelcolor='r')
fig.tight_layout()

ax3 = ax1.twiny()  # instantiate a second Axes that shares the same x-axis
ax3.spines["top"].set_position(("axes", 1.2))
ax3.set_xlabel(r'Density $[\text{kg/m}^{3}]$', color='c')
ax3.plot(rho0[index,:] - 1000, ds.z_rho[index,:], c='c', lw=2);
ax3.tick_params(axis='x', labelcolor='c')
fig.tight_layout()
example turnerangle

Calculate Turner Angle#

# Declare empty arrays for Turner angle
Tu_angle = np.zeros((SA.shape[0], len(zout)))
N2 = np.zeros((SA.shape[0], len(zout)))

for i in range(SA.shape[0]):

    # Get Turner angle
    Tu, Rs, pmid = gsw.Turner_Rsubrho(SA[i,:],CT[i,:],p[i,:])
    z = gsw.z_from_p(pmid, 57.5)
    f = interp1d(z, Tu, bounds_error=False, fill_value=np.nan)

    n2, pmid = gsw.Nsquared(SA[i,:], CT[i,:], p[i,:], lat=57.5)
    f_n2 = interp1d(gsw.z_from_p(pmid, 57.5), n2, bounds_error=False, fill_value=np.nan)

    Tu_angle[i,:] = f(zout)
    N2[i,:] = f_n2(zout)

Plot the quartiles#

# Loop over levels and compute percentiles
tu = np.zeros((zout.shape[0],5))

for i in range(len(zout)):
    tu_vals = Tu_angle[:,i].ravel()
    tu[i,:] = np.nanquantile(tu_vals, [0.05,0.25,0.50,0.75,0.95])

# Mark out the four regimes listed above
plt.axvspan(-120, -90, facecolor=[1.0, 0.9, 0.9])
plt.axvspan(-90, -45, facecolor='0.95')
plt.axvspan(45, 90, facecolor='0.95')
plt.axvspan(90, 120, facecolor=[1.0, 0.9, 0.9])

# Plot percentiles and median profiles
plt.fill_betweenx(zout, tu[:,0], tu[:,4], color=[0.8, 0.8, 1.0])
plt.fill_betweenx(zout, tu[:,1], tu[:,3], color=[0.5, 0.5, 0.7])
plt.scatter(tu[:,2], zout, color=[0.2, 0.2, 0.5], linewidth = 2)

# Delineate regimes for increased clarity
plt.plot([0,0],[maxdepth, 0],'k-')
plt.plot([-45,-45],[maxdepth, 0], color='0.3', linestyle=':')
plt.plot([45,45],[maxdepth, 0], color='0.3', linestyle=':')
plt.plot([-90,-90],[maxdepth, 0],'r:')
plt.plot([90,90],[maxdepth, 0],'r:')

# Set limits, ticks, and labels

plt.ylim((maxdepth, -1))
plt.xticks([-90, -45, 0, 45, 90])
plt.xlabel('Turner angle [degrees]')
plt.ylabel('Depth [m]')
example turnerangle
/home/runner/micromamba/envs/norkyst/lib/python3.14/site-packages/numpy/lib/_nanfunctions_impl.py:1573: RuntimeWarning: All-NaN slice encountered
  return _nanquantile_unchecked(

Text(26.472222222222214, 0.5, 'Depth [m]')

Classify results by depth#

import pandas as pd

# --- 1. Turner angle classification ---
classes = np.full(Tu_angle.shape, np.nan, dtype=object)

classes[(Tu_angle >= -45) & (Tu_angle <= 45)] = 'D.S'   # Doubly Stable
classes[(Tu_angle < -45) & (Tu_angle >= -90)] = 'D-C'   # Diffusive Convection
classes[(Tu_angle > 45) & (Tu_angle <= 90)] = 'S.F'     # Salt Fingering
classes[(Tu_angle > 90) | (Tu_angle < -90)] = 'U'   # Unstable

# --- 2. Expand depth to match (time, depth) ---
depth_2d = np.broadcast_to(zout, Tu_angle.shape)

# --- 3. Flatten consistently ---
depth_flat = depth_2d.ravel()
class_flat = classes.ravel()

# --- 4. Mask invalid values ---
mask = np.isfinite(depth_flat) & pd.notna(class_flat)

depth_flat = depth_flat[mask]
class_flat = class_flat[mask]

# --- 5. Build dataframe ---
df = pd.DataFrame({
    "depth": np.abs(depth_flat),
    "class": class_flat
})

# --- 6. Depth bins ---
bins = [0, 20, 50, 100, 150, 200, 250, 300, 350, 400, 450]

df["depth_bin"] = pd.cut(
    df["depth"],
    bins=bins,
    include_lowest=True
)

# --- 7. Count occurrences ---
# counts (already computed)
counts = pd.crosstab(df["depth_bin"], df["class"])

# percentage of total points (full table)
counts_percent = 100 * counts / counts.to_numpy().sum()

print("=== Percent per depth bin ===")
print(counts_percent)

# --- total percentage per class ---
total_percent_class = 100 * counts.sum(axis=0) / counts.to_numpy().sum()

print("\n=== Total percent per class ===")
print(total_percent_class)
=== Percent per depth bin ===
class                D-C        D.S       S.F         U
depth_bin
(-0.001, 20.0]  1.725236   2.111748  0.037022  0.453152
(20.0, 50.0]    3.856235   3.126157  0.000000  0.044427
(50.0, 100.0]   5.114991   6.769144  0.007404  0.000000
(100.0, 150.0]  2.055474  10.287737  0.088853  0.000000
(150.0, 200.0]  1.049950  10.533564  0.308025  0.000000
(200.0, 250.0]  1.451271   9.859760  0.580509  0.000000
(250.0, 300.0]  2.378308   8.660240  0.852992  0.000000
(300.0, 350.0]  3.238705   7.604366  1.048470  0.000000
(350.0, 400.0]  3.139485   7.333363  1.411287  0.007404
(400.0, 450.0]  1.054393   2.527878  1.101782  0.180668

=== Total percent per class ===
class
D-C    25.064048
D.S    68.813956
S.F     5.436344
U       0.685652
dtype: float64

Turner Angle Cross-Section#

We calculate here the Turner Angle for the cross-section used in Cross-section notebook.

path_s = './datasets/transect_scoord.nc'
ds_s = xr.open_dataset(path_s, engine='netcdf4')

Zo_rho = (ds_s.hc * ds_s.s_rho + ds_s.Cs_r * ds_s.h) / (ds_s.hc + ds_s.h)
z_rho = ds_s.zeta + (ds_s.zeta + ds_s.h) * Zo_rho

ds_s.coords["z_rho"] = z_rho.transpose()

# Convert salinity and temperature from ROMS to TEOS-10 standards
p = gsw.conversions.p_from_z(ds_s.z_rho, 57.5) # Lat
SA = gsw.conversions.SA_from_SP(ds_s.salt, p, 8, 57.5) # Lon, Lat
CT = gsw.conversions.CT_from_pt(SA, ds_s.temp)
rho0 = gsw.pot_rho_t_exact(SA, CT, p, 0) # Ref level

Plot temperature along the cross-section#

def compute_transect_distance(lons, lats):
    """
    Compute cumulative distance along transect.

    Parameters
    ----------
    lons, lats : 1D arrays

    Returns
    -------
    distance_km : 1D array
        Cumulative distance in km
    """

    geod = Geod(ellps="WGS84")

    distance_km = np.zeros(len(lons))

    for i in range(1, len(lons)):

        _, _, dist_m = geod.inv(
            lons[i-1], lats[i-1],
            lons[i], lats[i]
        )

        distance_km[i] = distance_km[i-1] + dist_m / 1000

    return distance_km

def interpolate_section(var, z_rho, dist, h,
                        nx=100, nz=400,
                        method="linear"):

    # Build scattered points
    dist2d = np.tile(dist, (var.shape[0], 1))

    points = np.column_stack([
        dist2d.ravel(),
        z_rho.ravel()
    ])

    values = var.ravel()

    # Remove NaNs
    mask = np.isfinite(values) & np.isfinite(points[:, 1])

    points = points[mask]
    values = values[mask]

    # Target grid
    dist_grid = np.linspace(dist.min(), dist.max(), nx)

    z_grid = np.linspace(
        np.nanmin(z_rho),
        0,
        nz
    )

    distg, zg = np.meshgrid(dist_grid, z_grid)

    # Interpolate
    var_grid = griddata(
        points,
        values,
        (distg, zg),
        method=method
    )

    # Bathymetry
    h_grid = np.interp(
        dist_grid,
        dist,
        -h
    )

    # Mask below bottom
    var_grid[zg < h_grid[None, :]] = np.nan

    return distg, zg, var_grid, dist_grid, h_grid

distance_km = compute_transect_distance(ds_s.lon_rho.values, ds_s.lat_rho.values)

z_rho = ds_s.z_rho.values
h = ds_s.h.values

distg, zg, temp_grid, dist_grid, h_grid = interpolate_section(
    ds_s.temp.values,
    z_rho,
    distance_km,
    h
)

_, _, salt_grid, _, _ = interpolate_section(
    ds_s.salt.values,
    z_rho,
    distance_km,
    h
)

_, _, dens_grid, _, _ = interpolate_section(
    rho0.values - 1000,
    z_rho,
    distance_km,
    h
)

fig, axes = plt.subplots(
    3, 1,
    figsize=(12, 10),
    sharex=True
)

fields = [
    (temp_grid, cmo.thermal, np.linspace(6, 8, 41), "Temperature"),
    (salt_grid, cmo.haline,  np.linspace(33, 35, 41), "Salinity"),
    (dens_grid, cmo.dense,   np.linspace(25, 28, 41), "Density")
]

for ax, (field, cmap, levels, title) in zip(axes, fields):

    pcm = ax.contourf(
        distg,
        zg,
        field,
        levels=levels,
        cmap=cmap,
        extend="both"
    )

    ax.fill_between(
        dist_grid,
        h_grid,
        zg.min() - 20,
        color="k",
        alpha=0.3
    )

    ax.set_title(title)
    ax.set_ylim(-480, 0)

    plt.colorbar(pcm, ax=ax)

axes[0].set_ylabel("Depth [m]")
axes[1].set_ylabel("Depth [m]")


axes[-1].set_xlabel("Distance [km]")
axes[-1].set_ylabel("Depth [m]")
Temperature, Salinity, Density
Text(96.47222222222221, 0.5, 'Depth [m]')

Calculate Turner Angle for the cross-section#

maxdepth = -450.0
resolution = 400

# Interpolate to fixed depths
zout = np.linspace(maxdepth,-1,resolution)

# Convert salinity and temperature from ROMS to TEOS-10 standards
p = gsw.conversions.p_from_z(ds_s.z_rho[::-1,:], 57.5) # Lat
SA = gsw.conversions.SA_from_SP(ds_s.salt.values[::-1,:], p, 8, 57.5) # Lon, Lat
CT = gsw.conversions.CT_from_pt(SA, ds_s.temp.values[::-1,:])

# Declare empty arrays for Turner angle
Tu_angle = np.zeros((SA.shape[1], len(zout)))

for i in range(SA.shape[1]):

    # Get Turner angle
    Tu, Rs, pmid = gsw.Turner_Rsubrho(SA[:, i],CT[:, i],p[:, i])
    z = gsw.z_from_p(pmid, 57.5)

    f = interp1d(z, Tu, bounds_error=False, fill_value=np.nan)
    Tu_angle[i,:] = f(zout)

Plot Turner Angle across the transect#

from matplotlib.colors import ListedColormap, BoundaryNorm

fig, ax = plt.subplots(figsize=(12,5))

# Create regime array
regime = np.full(Tu_angle.shape, np.nan)

# Codes:
# 0 = Diffusive Convection (blue)
# 1 = Statically Stable (green)
# 2 = Salt Fingering (orange)
# 3 = Rayleigh-Taylor (red)

regime[(-90 <= Tu_angle) & (Tu_angle < -45)] = 0 # D-C
regime[(-45 <= Tu_angle) & (Tu_angle <= 45)] = 1 #S.S
regime[(45 < Tu_angle) & (Tu_angle <= 90)] = 2 #S.F
regime[(Tu_angle < -90) | (Tu_angle > 90)] = 3 #R-T


cmap = ListedColormap([
    'blue',    # Diffusive Convection
    'green',   # Statically Stable
    'orange',  # Salt Fingering
    'red'      # Rayleigh-Taylor
])

norm = BoundaryNorm(
    boundaries=[-0.5, 0.5, 1.5, 2.5, 3.5],
    ncolors=cmap.N
)

pcm = ax.pcolormesh(
    distg,
    zg,
    regime.T,
    cmap=cmap,
    norm=norm,
    shading='auto'
)

# fill land
ax.fill_between(
    dist_grid,
    h_grid,
    zg.min()-20,
    color="k",
    alpha=0.3
)


cbar = plt.colorbar(
    pcm,
    ax=ax,
    ticks=[0, 1, 2, 3]
)

cbar.ax.set_yticklabels([
    'Diffusive\nConvection',
    'Doubly\nStable',
    'Salt\nFingering',
    'Unstable'
])

ax.set_ylim(-480, 0)
ax.set_xlabel("Distance (km)")
ax.set_ylabel("Depth (m)")


plt.show()
example turnerangle

Total running time of the script: (0 minutes 2.395 seconds)

Gallery generated by Sphinx-Gallery