Understanding the vertical profile of ocean nitrate

Average, section and sample the World Ocean Atlas through the water column, using January nitrate as the example.

The data

This example uses January nitrate from the World Ocean Atlas 2023, NOAA's climatology of ocean properties, on a 1° grid. NOAA serves each month as a separate file over OPeNDAP, so this opens just the January one:

python
import nctoolkit as nc

ds = nc.open_thredds("https://www.ncei.noaa.gov/thredds-ocean/dodsC/woa23/DATA/nitrate/netcdf/all/1.00/woa23_all_n01_01.nc")
ds.subset(variables="n_an")
ds.run()

Each monthly file covers the top 800 m on 43 standard depth levels. Along with the objectively analysed mean, n_an, the file carries other statistics such as the plain mean and the number of observations, and subset() keeps only the analysed field.

run() then fetches that field once. Every analysis below starts from a copy() of ds, so they all work from the local copy instead of each going back to the server.

Vertical averaging

vertical_mean() collapses the water column into one value per grid cell. fixed=True tells it that the depth levels are the same everywhere, which is true of a z-level product like this. pub_plot then maps the result:

python
vm = ds.copy()
vm.vertical_mean(fixed=True)
vm.pub_plot(colours="viridis", limits=[0, 40],
            legend="Nitrate, 0–800 m mean (µmol/kg)")

World map of January nitrate averaged over the top 800 metres, high in the Southern Ocean, the North Pacific and the eastern tropical Pacific, and low in the subtropical gyres

The mean is weighted by layer thickness

The depth levels are packed close together near the surface — each is 5 m apart down to 100 m, but 50 m apart below 500 m. vertical_mean() weights each level by the thickness of water it stands for, using the depth bounds stored in the file. That matters: at the point in the last section, the thickness-weighted mean is 10.7 µmol/kg, while a plain average of the 43 levels would give 6.6, because it over-counts the crowded surface levels.

Where the seafloor is shallower than 800 m, the mean covers only the water that is there.

Nitrate at 200 m

An average merges every depth into one number. To see a single depth instead, use vertical_interp(), which interpolates every grid cell to the depths you ask for. As with vertical_mean(), fixed=True says the levels are the same everywhere:

python
d200 = ds.copy()
d200.vertical_interp(levels=200, fixed=True)
d200.pub_plot(colours="viridis", limits=[0, 40],
              legend="Nitrate at 200 m (µmol/kg)")

World map of January nitrate at 200 metres, near zero across the subtropical gyres and high in the North Pacific, the Southern Ocean and the eastern tropical Pacific

200 m is one of the atlas's standard depths

The World Ocean Atlas already has a level at exactly 200 m, so here vertical_interp() returns that level unchanged. It does real work at a depth between two levels: ask for 110 m, and each cell is interpolated linearly between the 100 m and 125 m levels. To get several depths at once, pass a list, such as levels=[50, 110, 200].

Zonal averaging

zonal_mean() averages around each line of latitude instead, which keeps depth intact and gives a latitude-by-depth section of the whole ocean:

python
zm = ds.copy()
zm.zonal_mean()

Latitude by depth section of zonal mean January nitrate, low near the surface in the subtropics and high at depth near the equator and in the Southern Ocean

Show the plotting code
python
import matplotlib.pyplot as plt

def style(ax):
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.spines["left"].set_color("#c9d6da")
    ax.spines["bottom"].set_color("#c9d6da")
    ax.tick_params(colors="#56717d", labelsize=9)

# a latitude-by-depth section, reused for the transect below
def section(df, title):
    grid = df.pivot_table(index="depth", columns="lat", values="n_an")

    fig, ax = plt.subplots(figsize=(9, 4.5))
    # anywhere without ocean, such as below the seafloor, shows as grey
    ax.set_facecolor("#d9dee0")
    mesh = ax.pcolormesh(grid.columns, grid.index, grid.values, cmap="viridis",
                         vmin=0, vmax=40, shading="nearest")
    ax.invert_yaxis()
    style(ax)
    ax.set_xlabel("Latitude (°N)")
    ax.set_ylabel("Depth (m)")
    ax.set_title(title, fontsize=12)
    fig.colorbar(mesh, ax=ax, pad=0.02).set_label("Nitrate (µmol/kg)")
    return fig

fig = section(zm.to_dataframe().reset_index(), "Zonal mean nitrate, January")
fig.savefig("nitrate_zonal_mean.png", dpi=110, bbox_inches="tight")

A transect down the Atlantic

To look at one basin on its own, to_transect() cuts a line through it. This is the same 30°W meridian as the transects example, extended south to 60°S, and because the dataset has depth levels, the transect keeps them:

python
tr = ds.copy()
tr.to_transect(start=[-30, -60], end=[-30, 65], nsteps=126)

Latitude by depth section of January nitrate along 30 degrees west from 60 south to 65 north

Show the plotting code
python
# section() is defined in the zonal mean plotting code above
fig = section(tr.to_dataframe().reset_index(), "Nitrate along 30°W, January")
fig.savefig("nitrate_transect.png", dpi=110, bbox_inches="tight")

Every point along this line, at every depth, has data. Most nearby meridians have gaps, where the line crosses the Azores or runs into shallow water near land.

A single profile

Finally, regrid() with a single lon and lat interpolates the dataset to one location. Here that is 35°W, 40°N, in the middle of the North Atlantic, and the result is a vertical profile. A vertical_mean() of that profile gives the value the map above shows at the same place:

python
pt = ds.copy()
pt.regrid(lon=-35, lat=40)

pt_mean = pt.copy()
pt_mean.vertical_mean(fixed=True)

Vertical profile of January nitrate at 35 degrees west, 40 north, rising from under 2 micromoles per kilogram at the surface to almost 20 at 800 metres, with the 0 to 800 metre mean of 10.7 marked as a dashed line

Show the plotting code
python
df = pt.to_dataframe().reset_index()
mean = pt_mean.to_dataframe().n_an.values[0]

fig, ax = plt.subplots(figsize=(5, 6))
ax.plot(df.n_an, df.depth, color="#087f8c", linewidth=2.4, marker="o",
        markersize=3)
ax.axvline(mean, color="#d76b45", linestyle="--", linewidth=1.6,
           label=f"0–800 m mean: {mean:.1f} µmol/kg")
ax.set_ylim(810, -10)
style(ax)
ax.grid(color="#e5eef0", linewidth=0.8)
ax.set_axisbelow(True)
ax.set_xlabel("Nitrate (µmol/kg)")
ax.set_ylabel("Depth (m)")
ax.set_title("Nitrate at 35°W, 40°N, January", fontsize=12)
ax.legend(fontsize=9, loc="lower left", facecolor="white", edgecolor="none",
          framealpha=1)
fig.savefig("nitrate_profile.png", dpi=110, bbox_inches="tight")