Spectrum, Spherical Harmonics, Total Kinetic Energy on irregularly gridded vector fields in Xarray

SHTOOLS and WindsPharm are white paper backed way to calculate Power Spectra, Total Kinetic Energy and Spherical Harmonics of geospatial datasets loaded with Xarray. I verified the validity of SHTools against an internal library we use

And it seems for scalar fields it works a treat :slight_smile: .

However!!! When I try the same approach using a vector field

and so when calculating the total kinetic energy (divergence, vorticity, etc.)

I get a consistent difference which I believe to have narrowed down to explosion of derivatives near the North Pole and some miscalculation of irregularly gridded global models.

I tried calculating them using MetPy’s divergence and vorticity as well

def plot_power_spectra_vector(
    fst_base_path: PosixPath,
    pres_level: Union[float, str],
    validate: bool,
    threshold=1e-9,
    max_marker_size=12,
    min_marker_size=1,
    base_aspect_ratio=(2, 1),  # Width-to-height ratio
    max_height=12  # Max plot height
):
    """
    Plot power spectra for multiple pressure levels with logarithmic marker size scaling and external legend.

    Parameters:
    - fst_base_path (PosixPath): Path to the base FST file.
    - pres_level ([Union[float, str]): Pressure level to plot.
    - threshold (float): Threshold for additional processing (not used directly here).
    - cmap (str): Colormap name for cycling line colors.
    - max_marker_size (int): Maximum marker size (for small x).
    - min_marker_size (int): Minimum marker size (for large x).
    - base_aspect_ratio (tuple): Width-to-height ratio of the plot.
    - max_height (int): Maximum height of the plot.
    """
    a_r = 6.371e6
    # Load dataset and calculate the first spectrum
    ds = load_ds_uv(fst_base_path)
    if ds.UU.dims != ("lat", "lon"):
        ds.UU = ds.UU.transpose("lat", "lon")
        ds.VV = ds.VV.transpose("lat", "lon")


    div = divergence(ds.UU * mu.units('m/s'), ds.VV * mu.units('m/s'), latitude=ds.lat, longitude=ds.lon)
    vort = vorticity(ds.UU * mu.units('m/s'), ds.VV * mu.units('m/s'), latitude=ds.lat, longitude=ds.lon)

    grid_div = pysh.SHGrid.from_xarray(div, grid="GLQ")
    grid_vort = pysh.SHGrid.from_xarray(vort, grid="GLQ")

    coeffs_div = grid_div.expand()
    coeffs_vort = grid_vort.expand()

    spectrum_div = coeffs_div.spectrum()
    spectrum_vort = coeffs_vort.spectrum()
    
    degrees = np.arange(1, len(spectrum_div) + 1)

    leading_factor = (a_r**2) / (4 * np.pi * degrees * (degrees + 1))

    S_div = leading_factor * spectrum_div
    S_rot = leading_factor * spectrum_vort
    S_tot = S_div + S_rot

    wavelength = a_r / degrees
        
    # Calculate dynamic figure dimensions
    base_width = 10
    base_height = 6
    extra_height = 0.2 * max(0, 25 - 4)  # Modest height scaling for extra levels
    fig_height = min(base_height + extra_height, max_height)
    fig_width = fig_height * base_aspect_ratio[0] / base_aspect_ratio[1]
    
    # Create the main plot
    fig, ax1 = plt.subplots(figsize=(fig_width, fig_height))

    x_ref = np.array([16, 128])  # Reference x-range
    y_ref_start = 10  # Starting y-value for the reference line
    y_ref = y_ref_start * (x_ref / x_ref[0]) ** -3  # Calculate y-values for k^-3 slope
    
    x_mid = np.sqrt(x_ref[0] * x_ref[1])  # Geometric midpoint of the x range
    y_mid = y_ref_start * (x_mid / x_ref[0]) ** -3  # Calculate y-value at midpoint
    
    ax1.text(
        x_mid, y_mid * 2,  # Slightly above the midpoint of the line
        r"$k^{-3}$",
        fontsize=12,
        color='black',
        ha='center',  # Center align horizontally
        va='bottom'   # Align text slightly above the line
    )
    
    # Calculate marker sizes based on log degrees
    log_degrees = np.log(degrees)
    marker_sizes = np.interp(
        log_degrees,
        (log_degrees.min(), log_degrees.max()),
        (max_marker_size, min_marker_size)
    )

    # Plot the KE Rot spectrum
    ax1.scatter(
        degrees,
        S_rot,
        label=f"KE Rotational",
        color='blue',
        s=marker_sizes,
        edgecolors='face'
    )
    ax1.plot(
        degrees,
        S_rot,
        color='blue',
        linestyle='-'
    )

    # Plot the KE Rot spectrum
    ax1.scatter(
        degrees,
        S_div,
        label=f"KE Divergent",
        color='green',
        s=marker_sizes,
        edgecolors='face'
    )
    ax1.plot(
        degrees,
        S_div,
        color='green',
        linestyle='-'
    )

    # Plot the KE Total spectrum
    ax1.scatter(
        degrees,
        S_tot,
        label=f"KE Total",
        color='orange',
        s=marker_sizes,
        edgecolors='face'
    )
    ax1.plot(
        degrees,
        S_tot,
        color='orange',
        linestyle='-'
    )

    # Plot the reference line
    ax1.plot(
        x_ref,
        y_ref,
        color='black',
        linestyle='--',
        linewidth=1.5,
        # label=r'$k^{-3}$'
    )

    if validate:
        # Plot /fs/site5/eccc/cmd/s/yor000/projects/power_spectra/field_vector_qe_harmon.txt
        ax1.scatter(
            degrees,
            np.loadtxt(txts[3]),
            label=f"KE Rotational Reference",
            color='blue',
            s=marker_sizes,
            marker="1"
        )
        ax1.plot(
            degrees,
            np.loadtxt(txts[3]),
            color='blue',
            linestyle='dashdot'
        )

        # Plot /fs/site5/eccc/cmd/s/yor000/projects/power_spectra/field_vector_de_harmon.txt
        ax1.scatter(
            degrees,
            np.loadtxt(txts[1]),
            label=f"KE Divergent Reference",
            color='green',
            s=marker_sizes,
            marker="1"
        )
        ax1.plot(
            degrees,
            np.loadtxt(txts[1]),
            color='green',
            linestyle='dashdot'
        )
    
        # Plot # Plot /fs/site5/eccc/cmd/s/yor000/projects/power_spectra/field_vector_ke_harmon.txt
        ax1.scatter(
            degrees,
            np.loadtxt(txts[2]),
            label=f"KE Total Reference",
            color='orange',
            s=marker_sizes,
            marker="1"
        )
        ax1.plot(
            degrees,
            np.loadtxt(txts[2]),
            color='orange',
            linestyle='dashdot'
        )
    
    # Plot the reference line
    ax1.plot(
        x_ref,
        y_ref,
        color='black',
        linestyle='--',
        linewidth=1.5,
        # label=r'$k^{-3}$'
    )
    
    # Set log-log scale for the main plot
    ax1.set_xscale('log')
    # ax1.set_xscale('log')
    # ax1.set_xticks([1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024])
    # ax1.set_xticklabels([1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024])
    # ax1.set_xlim(1, 1024)
    ax1.set_yscale('log')
    
    # Add main labels
    ax1.set_xlabel('Total wave number', fontsize=12)
    ax1.set_ylabel('Power Spectral Density [J/kg]', fontsize=12)
    
    # Add the third axis (above the plot)
    ax3 = ax1.twiny()
    ax3.set_xscale("log")
    ax3.set_xlim(ax1.get_xlim())
    
    # Custom tick positions and labels for the third axis
    custom_ticks = [1, 4, 16, 40, 80, 160, 400]
    custom_labels = [40000, 10000, 2500, 1000, 500, 250, 100]
    ax3.set_xticks(custom_ticks)
    ax3.set_xticklabels(custom_labels)
    ax3.set_xlabel("Wave Length [km]", fontsize=12)
    
    # Add grid
    ax1.grid(True, which='both', linestyle='--', linewidth=0.5)    
    plt.suptitle(
        f"Vector Field Power Spectra for the {fst_base_path.parts[-1]} file",
        fontsize=14,
        x=0.01,       # Align to the left
        y=0.98,       # Slightly below the top of the figure
        ha='left',
        fontweight='bold'
    )
    
    ax1.legend(fontsize=10)
    ax1.grid(True, which='both', linestyle='--', linewidth=0.5)
    plt.tight_layout()
    plt.show()

But I still get a difference. I would to find out there is an easy way to effectively calculate differential calculus on irregular grids (ideally sub global). Anyone have a refernce I can use?