Identify zooplankton school from glider echosounder data with external location and environmental data incorporated#

This notebook accompanies the following paper:

Echopype: Interoperable and scalable echosounder data processing with Echopype
Authors: Wu-Jung Lee, Landung Setiawan, Caesar Tuguinay, Emilio Mayorga, and Valentina Staneva
ICES Journal of Marine Science 2024: https://doi.org/10.1093/icesjms/fsae133

Introduction#

Description#

This notebook uses echosounder data from a glider to illustrate how Echopype can use external location (latitude and longitude) and environmental (temperature, salinity and pressure) data in the processing, if the data do not already exist in the echosounder raw files or if users would like to udpdate them. The data are provided by Delphine Mossman from the Department of Marine and Coastal Sciences at Rutgers University. The data were a 15-min section of echosounder data collected by an Acoustic Zooplankton and Fisher Profiler (AZFP) mounted on a Slocum glider deployed in the Southern Ocean off Antarctica (Ross Sea) in 2018.

In this notebook we attempt to correct for the glider orientation and identify zooplankton swarms in the echogram following steps detailed in the 2021 M.S. thesis from Ailey Sheehan, another member of the same Rutgers team. The computations are available in the associated GitHub repository.

Outline#

  1. Open and convert raw AZFP data

  2. Update EchoData Platform group with glider data and metadata

  3. Calibrate backscatter data with glider mean environmental data

  4. Remove background noise

  5. Align echosounder data according to depth

  6. Compute and apply shoal mask on corrected Sv

Running the notebook#

This notebook can be run with a conda environment created using the conda environment file. It uses data files found in the notebooks/example_data/glider_AZFP directory in this repository.

Note#

We encourage importing Echopype as ep for consistency.

from pathlib import Path

import geopandas as gpd
import pandas as pd
import xarray as xr
from scipy.spatial.transform import Rotation as R
import numpy as np

import matplotlib.pyplot as plt
import cartopy.crs as ccrs

import echopype as ep
import echopype.colormap

import warnings
warnings.simplefilter("ignore", category=DeprecationWarning)

Path setup#

# Set path to example data
data_path = Path('./example_data/glider_AZFP')

Data processing workflow#

Open and convert raw AZFP data#

To open an AZFP data file (*.01A files), the accompanying XML metadata file must be provided.

# Open RAW
azfp_data_fpath = data_path / "18011107.01A"
ed = ep.open_raw(
    azfp_data_fpath, 
    xml_path=data_path / "18011107.XML", 
    sonar_model="AZFP"
)
ed
EchoData: standardized raw data from Internal Memory
    • <xarray.DatasetView> Size: 0B
      Dimensions:  ()
      Data variables:
          *empty*
      Attributes:
          conventions:                 CF-1.7, SONAR-netCDF4-1.0, ACDD-1.3
          keywords:                    AZFP
          sonar_convention_authority:  ICES
          sonar_convention_name:       SONAR-netCDF4
          sonar_convention_version:    1.0
          summary:                     
          title:                       
          date_created:                2018-01-11T07:44:40Z

      • <xarray.DatasetView> Size: 188B
        Dimensions:                 (channel: 3)
        Coordinates:
          * channel                 (channel) <U11 132B '59006-38-1' ... '59006-200-3'
        Data variables:
            absorption_indicative   (channel) float64 24B nan nan nan
            sound_speed_indicative  float64 8B nan
            frequency_nominal       (channel) float64 24B 3.8e+04 1.25e+05 2e+05

      • <xarray.DatasetView> Size: 380B
        Dimensions:              (time1: 1, time2: 1, channel: 3)
        Coordinates:
          * time1                (time1) datetime64[ns] 8B 2018-01-11T07:44:40
          * time2                (time2) datetime64[ns] 8B 2018-01-11T07:44:40
          * channel              (channel) <U11 132B '59006-38-1' ... '59006-200-3'
        Data variables: (12/21)
            latitude             (time1) float64 8B nan
            longitude            (time1) float64 8B nan
            pitch                (time2) float64 8B nan
            roll                 (time2) float64 8B nan
            vertical_offset      (time2) float64 8B nan
            water_level          float64 8B nan
            ...                   ...
            MRU_rotation_y       float64 8B nan
            MRU_rotation_z       float64 8B nan
            position_offset_x    float64 8B nan
            position_offset_y    float64 8B nan
            position_offset_z    float64 8B nan
            frequency_nominal    (channel) float64 24B 3.8e+04 1.25e+05 2e+05
        Attributes:
            platform_name:       
            platform_type:       
            platform_code_ICES:  

      • <xarray.DatasetView> Size: 304B
        Dimensions:                (filenames: 1)
        Coordinates:
          * filenames              (filenames) int64 8B 0
        Data variables:
            source_filenames       (filenames) <U37 148B 'example_data/glider_AZFP/18...
            meta_source_filenames  (filenames) <U37 148B 'example_data/glider_AZFP/18...
        Attributes:
            conversion_software_name:     echopype
            conversion_software_version:  0.11.1
            conversion_time:              2026-04-18T19:38:12+00:00

      • <xarray.DatasetView> Size: 372B
        Dimensions:           (beam_group: 1)
        Coordinates:
          * beam_group        (beam_group) <U11 44B 'Beam_group1'
        Data variables:
            beam_group_descr  (beam_group) <U82 328B 'contains backscatter power (unc...
        Attributes:
            sonar_manufacturer:      ASL Environmental Sciences
            sonar_model:             AZFP
            sonar_serial_number:     59006
            sonar_software_name:     AZFP
            sonar_software_version:  based on AZFP Matlab version 1.4
            sonar_type:              echosounder

        • <xarray.DatasetView> Size: 22MB
          Dimensions:                      (channel: 3, ping_time: 920,
                                            range_sample: 1999, beam_group: 1)
          Coordinates:
            * channel                      (channel) <U11 132B '59006-38-1' ... '59006-...
            * ping_time                    (ping_time) datetime64[ns] 7kB 2018-01-11T07...
            * range_sample                 (range_sample) int64 16kB 0 1 2 ... 1997 1998
            * beam_group                   (beam_group) <U11 44B 'Beam_group1'
          Data variables: (12/16)
              frequency_nominal            (channel) float64 24B 3.8e+04 1.25e+05 2e+05
              beam_type                    (channel) int64 24B 0 0 0
              beam_direction_x             (channel) float64 24B nan nan nan
              beam_direction_y             (channel) float64 24B nan nan nan
              beam_direction_z             (channel) float64 24B nan nan nan
              backscatter_r                (channel, ping_time, range_sample) float32 22MB ...
              ...                           ...
              transmit_frequency_start     (channel) float64 24B 3.8e+04 1.25e+05 2e+05
              transmit_frequency_stop      (channel) float64 24B 3.8e+04 1.25e+05 2e+05
              transmit_type                <U2 8B 'CW'
              beam_stabilisation           int8 1B 0
              non_quantitative_processing  int16 2B 0
              sample_time_offset           float64 8B 0.0
          Attributes:
              beam_mode:              
              conversion_equation_t:  type_4

      • <xarray.DatasetView> Size: 148kB
        Dimensions:                                         (channel: 3,
                                                             ping_time: 920,
                                                             ancillary_len: 5,
                                                             ad_len: 2,
                                                             phase_number: 1, cpu: 0,
                                                             serial_number: 2,
                                                             XML_sonar_serial_number: 2)
        Coordinates:
          * channel                                         (channel) <U11 132B '5900...
          * ping_time                                       (ping_time) datetime64[ns] 7kB ...
          * ancillary_len                                   (ancillary_len) int64 40B ...
          * ad_len                                          (ad_len) int64 16B 0 1
          * phase_number                                    (phase_number) int64 8B 1
          * cpu                                             (cpu) float64 0B 
          * serial_number                                   (serial_number) int64 16B ...
          * XML_sonar_serial_number                         (XML_sonar_serial_number) int64 16B ...
        Data variables: (12/71)
            frequency_nominal                               (channel) float64 24B 3.8...
            digitization_rate                               (channel) int64 24B 20000...
            lock_out_index                                  (channel) int64 24B 0 0 0
            number_of_bins_per_channel                      (channel) int64 24B 1999 ...
            number_of_samples_per_average_bin               (channel) int64 24B 1 1 1
            board_number                                    (channel) int64 24B 0 1 2
            ...                                              ...
            tilt_X_c                                        float64 8B 0.0
            tilt_X_d                                        float64 8B 0.0
            tilt_Y_a                                        float64 8B 0.0
            tilt_Y_b                                        float64 8B 0.0
            tilt_Y_c                                        float64 8B 0.0
            tilt_Y_d                                        float64 8B 0.0

AZFP raw data files doesn’t contain latitude longitude data. Therefore, the Platform group should have NaNs for this location data:

# Print lat lon array data
print("Latitude Data:", ed["Platform"]["latitude"].data)
print("Longitude Data:", ed["Platform"]["longitude"].data)
Latitude Data: [nan]
Longitude Data: [nan]

Update EchoData Platform group with glider data and metadata#

Here we update the EchoData object with metadata entries for this deployment, and the GPS locations from the glider netCDF data.

Load external glider netCDF file#

The glider data are available on a Rutgers erddap server. We have previously downloaded and subset that dataset to make it easier to use in this example.

# Open Glider Dataset
glider_nc_fpath = data_path / "ru32-20180109T0531-profile-sci-delayed-subset.nc"
ds_glider = xr.open_dataset(glider_nc_fpath)
ds_glider
<xarray.Dataset> Size: 454kB
Dimensions:                    (time: 915)
Coordinates:
  * time                       (time) datetime64[ns] 7kB 2018-01-11T07:26:20....
Data variables: (12/59)
    latitude                   (time) float64 7kB ...
    longitude                  (time) float64 7kB ...
    depth                      (time) float32 4kB ...
    trajectory                 (time) <U18 66kB ...
    source_file                (time) <U31 113kB ...
    beta_700nm                 (time) float32 4kB ...
    ...                         ...
    sci_water_pressure         (time) float32 4kB ...
    sound_speed                (time) float32 4kB ...
    temperature                (time) float32 4kB ...
    u                          (time) float32 4kB ...
    v                          (time) float32 4kB ...
    water_depth                (time) float32 4kB ...
Attributes: (12/72)
    cdm_data_type:                   Profile
    cdm_profile_variables:           profile_id
    comment:                         Glider was deployed/recovered from the R...
    contributor_name:                Grace Saba, Dave Aragon, Chip Haldeman, ...
    contributor_role:                Principal Investigator, Glider Pilot, Gl...
    Conventions:                     CF-1.6, COARDS, ACDD-1.3
    ...                              ...
    time_coverage_resolution:        PTS
    time_coverage_start:             2018-01-09T05:29:54Z
    title:                           ru32-20180109T0531 Delayed Science Profile
    uuid:                            82c40f91-87ab-40bb-a1ea-3f1c366a0378
    Westernmost_Easting:             164.36469500000004
    wmo_id:                          7801506

The external glider data time range encompasses that of the AZFP data. Let’s compare the two time ranges:

# Show glider start and end time
print("Glider Start Time:", ds_glider.time.min().values)
print("Glider End Time:", ds_glider.time.max().values)
Glider Start Time: 2018-01-11T07:26:20.320559872
Glider End Time: 2018-01-11T08:00:28.788059904
# Show AZFP start and end time
print("AZFP Start Time", ed["Sonar/Beam_group1"]["ping_time"].min().values)
print("AZFP End Time", ed["Sonar/Beam_group1"]["ping_time"].max().values)
AZFP Start Time 2018-01-11T07:44:40.000000000
AZFP End Time 2018-01-11T07:59:59.000000000

Update EchoData Top-level and Platform group attributes#

While not required for data processing, it’s always a good idea to include as much metadata as possible. We’ll take advantage of the metadata found in the glider netCDF file to populate some of these metadata.

# Manually populate additional metadata about the dataset and the platform
# -- SONAR-netCDF4 Top-level Group attributes
ed["Top-level"] = ed["Top-level"].assign_attrs(
    title="2018 Ross Sea Slocum glider AZFP echosounder data from Rutgers University",
    summary=ds_glider.attrs.get("summary", ""),
)

# -- SONAR-netCDF4 Platform Group attributes
ed["Platform"] = ed["Platform"].assign_attrs(
    platform_type=ds_glider.attrs.get("platform_type", ""),
    platform_name="Rutgers r32 Slocum Webb G2 glider",
    platform_code_ICES=ds_glider.attrs.get("wmo_id", ""),
)

Here are the updated attributes in the “Top-level” group:

# Show top level attributes
ed['Top-level'].attrs
{'conventions': 'CF-1.7, SONAR-netCDF4-1.0, ACDD-1.3',
 'keywords': 'AZFP',
 'sonar_convention_authority': 'ICES',
 'sonar_convention_name': 'SONAR-netCDF4',
 'sonar_convention_version': '1.0',
 'summary': 'This project integrated an Acoustic Zooplankton and Fish Profiler (AZFP) multi-frequency echo sounder into a Slocum Webb G2 glider. The AZFP is complemented with existing glider sensors including a CTD, a WET Labs BB2FL ECO puck configured for simultaneous chlorophyll fluorescence (phytoplankton biomass) and optical backscatter measurements, and an Aanderaa Optode for measuring dissolved oxygen. This glider deployment is located in the polynya of Terra Nova Bay (western Ross Sea, Antarctica), and is focused on investigating relationships between phytoplankton-zooplankton-fish distributions and the physical drivers of zooplankton and silverfish species and size distributions.',
 'title': '2018 Ross Sea Slocum glider AZFP echosounder data from Rutgers University',
 'date_created': '2018-01-11T07:44:40Z'}

The external glider file name, glider_nc_fpath.name, is used in update_platform only to store it as provenance information recording the origin of the data.

Update latitude and longitude in EchoData Platform group#

# Update platform
ed.update_platform(
    ds_glider,
    variable_mappings={"latitude": "latitude", "longitude": "longitude"},
    extra_platform_data_file_name=glider_nc_fpath.name,
)

latitude and longitude variables are now found in the Platform group. Note also the global platform attributes added in the previous step.

# Show updated platform
ed['Platform']
<xarray.Dataset> Size: 21kB
Dimensions:              (time3: 872, time2: 1, channel: 3)
Coordinates:
  * time3                (time3) datetime64[ns] 7kB 2018-01-11T07:26:30.39312...
  * time2                (time2) datetime64[ns] 8B 2018-01-11T07:44:40
  * channel              (channel) <U11 132B '59006-38-1' ... '59006-200-3'
Data variables: (12/21)
    latitude             (time3) float64 7kB -75.02 -75.02 ... -75.02 -75.02
    longitude            (time3) float64 7kB 165.5 165.5 165.5 ... 165.5 165.5
    pitch                (time2) float64 8B nan
    roll                 (time2) float64 8B nan
    vertical_offset      (time2) float64 8B nan
    water_level          float64 8B nan
    ...                   ...
    MRU_rotation_y       float64 8B nan
    MRU_rotation_z       float64 8B nan
    position_offset_x    float64 8B nan
    position_offset_y    float64 8B nan
    position_offset_z    float64 8B nan
    frequency_nominal    (channel) float64 24B 3.8e+04 1.25e+05 2e+05
Attributes:
    platform_name:       Rutgers r32 Slocum Webb G2 glider
    platform_type:       Slocum Glider
    platform_code_ICES:  7801506

The source of the location data is preserved as provenance “history” attributes in the latitude and longitude variables. Note: the above history print out exposed currently unintended behavior in handling the history attribute and will be corrected in a future echopype version. See issue #1552.

# Show platform history
ed['Platform']['latitude'].history
'2026-04-18 19:38:13.817601+00:00. `depth` calculated using:. Added from external platform data, from file ru32-20180109T0531-profile-sci-delayed-subset.nc. From external latitude variable.'

Plot glider location#

Extract and join together the latitude and longitude variables from the Platform group in the ed EchoData object. Convert to a Pandas DataFrame first, then to a GeoPandas GeoDataFrame for convenient viewing and manipulation.

# Create gps dataframe
gps_df = ed['Platform'].latitude.to_dataframe().join(ed['Platform'].longitude.to_dataframe())
gps_df.head(3)
latitude longitude
time3
2018-01-11 07:26:30.393129984 -75.018896 165.499855
2018-01-11 07:48:05.826169856 -75.017433 165.500057
2018-01-11 07:48:06.832060160 -75.017432 165.500058
# Create geodataframe from gps_df
gps_gdf = gpd.GeoDataFrame(
    gps_df,
    geometry=gpd.points_from_xy(gps_df['longitude'], gps_df['latitude']), 
)

try:
    gps_gdf = gps_gdf.set_crs("EPSG:4326")
except Exception:
    gps_gdf = gps_gdf.set_crs("+proj=longlat +datum=WGS84 +no_defs +type=crs")
# Plot lat lon bounding box points
print("Minimum Longitude:", gps_df.longitude.min())
print("Maximum Longitude:", gps_df.longitude.max())
print("Minimum Latitude:", gps_df.latitude.min())
print("Maximum Latitude:", gps_df.latitude.max())
Minimum Longitude: 165.49765868921702
Maximum Longitude: 165.50005833333333
Minimum Latitude: -75.01889590423247
Maximum Latitude: -75.01737333333334

Using matplotlib and cartopy, plot the individual GPS points with a South Polar Stereo map projection.

# Plot on Polar Map
_, ax = plt.subplots(figsize=(5, 5), subplot_kw={"projection": ccrs.SouthPolarStereo()})
ax.set_extent([-180, 180, -90, -65], crs=ccrs.PlateCarree())
ax.coastlines(resolution='110m')
ax.stock_img()
ax.gridlines()
gps_gdf.plot(ax=ax, markersize=10, color='red', transform=ccrs.PlateCarree(), aspect=None)
<GeoAxes: >
_images/0b3106e51c23354434f6ea6c1f81834545682bab19265cd1fd88aac802c9e688.png

Calibrate backscatter data with glider mean environmental data#

We’ll use the environmental data (temperature, salinity and pressure) found in the external glider file and the calibration parameters stored in the AZFP files to calibrate the backscatter data and create a volume backscattering strength (Sv) dataset.

# Grab and average environmental parameters
env_params_means = {}
for env_var in ["temperature", "salinity", "pressure"]:
    env_params_means[env_var] = float(ds_glider[env_var].mean().values)
env_params_means
{'temperature': -1.0206379890441895,
 'salinity': 34.53364181518555,
 'pressure': 38.69340133666992}
# Compute Sv
ds_Sv = ep.calibrate.compute_Sv(ed, env_params=env_params_means)
ds_Sv
<xarray.Dataset> Size: 88MB
Dimensions:                (channel: 3, ping_time: 920, range_sample: 1999,
                            filenames: 1)
Coordinates:
  * channel                (channel) <U11 132B '59006-38-1' ... '59006-200-3'
  * ping_time              (ping_time) datetime64[ns] 7kB 2018-01-11T07:44:40...
  * range_sample           (range_sample) int64 16kB 0 1 2 3 ... 1996 1997 1998
  * filenames              (filenames) int64 8B 0
Data variables: (12/18)
    Sv                     (channel, ping_time, range_sample) float64 44MB -1...
    echo_range             (channel, ping_time, range_sample) float64 44MB 0....
    frequency_nominal      (channel) float64 24B 3.8e+04 1.25e+05 2e+05
    sound_speed            float64 8B 1.444e+03
    sound_absorption       (channel) float64 24B 0.009111 0.03012 0.04501
    temperature            float64 8B -1.021
    ...                     ...
    TVR                    (channel) float64 24B 156.2 168.5 165.9
    VTX0                   (channel) float64 24B 157.0 146.7 133.3
    equivalent_beam_angle  (channel) float64 24B 0.1306 0.01071 0.01071
    Sv_offset              (channel) float64 24B 0.7 0.3 0.3
    source_filenames       (filenames) <U37 148B 'example_data/glider_AZFP/18...
    water_level            float64 8B nan
Attributes:
    processing_software_name:     echopype
    processing_software_version:  0.11.1
    processing_time:              2026-04-18T19:38:15+00:00
    processing_function:          calibrate.compute_Sv
# Plot Sv
ds_Sv["Sv"].plot(
    col="channel", yincrease=False, y="range_sample",
    vmin=-100, vmax=-60, cmap="ep.ek500"
)
<xarray.plot.facetgrid.FacetGrid at 0x7f46dda750d0>
_images/7269cfac4569d3363d38958e8899aa789ecc01cf1070b1e99144331502ef4ce9.png

Note that the top portion of the echograms have extremely high Sv values. Let’s drop these since they are most likely from noise or bubbles near the sea surface.

# Select Sv
ds_Sv = ds_Sv.isel(range_sample=slice(150,None))
# Plot selected Sv:
g = ds_Sv["Sv"].plot(
    col="channel", yincrease=False, y="range_sample",
    vmin=-100, vmax=-60, cmap="ep.ek500"
)

# Iterate through each facet
for ax in g.axes.flatten():
    labels = [label.get_text() for label in ax.get_xticklabels()]
    # Set the current ticks
    ax.set_xticks(ax.get_xticks())
    # Apply rotation
    ax.set_xticklabels(labels, rotation=45)
    # Set x label
    ax.set_xlabel("Ping Time (HH:MM)")

plt.show()
/tmp/ipykernel_250/992655323.py:8: FutureWarning: self.axes is deprecated since 2022.11 in order to align with matplotlibs plt.subplots, use self.axs instead.
  for ax in g.axes.flatten():
_images/4f114485167a717cbdc3879174d78da7f3812782561aca5c1d32e2d94fc98058.png

Remove background noise#

Following De Robertis and Higginbottom (2007), we can remove background noise by estimating it from the mean calibrated power of a collection of pings.

# Remove background noise
ds_Sv = ep.clean.remove_background_noise(
    ds_Sv,
    ping_num=5,
    range_sample_num=5,
    # Threshold value from https://github.com/a-sheehan/Echopype-Processing-Pipeline-for-AZFP-and-Glider-Data/blob/main/Sheehan_azfp_forloop.ipynb
    SNR_threshold="2.0dB",
)
# Plot corrected Sv
g = ds_Sv["Sv_corrected"].plot(
    col="channel", yincrease=False, y="range_sample", vmin=-100, vmax=-60, cmap="ep.ek500"
)

# Iterate through each facet
for ax in g.axes.flatten():
    labels = [label.get_text() for label in ax.get_xticklabels()]
    # Set the current ticks
    ax.set_xticks(ax.get_xticks())
    # Apply rotation
    ax.set_xticklabels(labels, rotation=45)
    # Set x label
    ax.set_xlabel("Ping Time (HH:MM)")

plt.show()
/tmp/ipykernel_250/3887182110.py:7: FutureWarning: self.axes is deprecated since 2022.11 in order to align with matplotlibs plt.subplots, use self.axs instead.
  for ax in g.axes.flatten():
_images/1faebaabbf553a6650e67caf3037ca78388b90bf215c9bac39bc09e6ca8e7a02.png

Since we’re most interested in 125 kHz for capturing zooplankton schools, let’s look at the Sv histograms (with and without background noise removed) for the 125 kHz channels:

# Extract and flatten data from 125 kHz channels
data_Sv = ds_Sv["Sv"].sel(channel="59006-125-2").values.ravel() # Flatten
data_Sv_corrected = ds_Sv["Sv_corrected"].sel(channel="59006-125-2").values.ravel() # Flatten

# Create a figure and axis
fig, ax = plt.subplots(figsize=(8, 5))

# Plot histograms
ax.hist(data_Sv, bins=100, color='blue', alpha=0.2, label='125 kHz Sv')
ax.hist(data_Sv_corrected, bins=100, color='red', alpha=0.2, label='Corrected 125 kHz Sv')

# Add labels, title, and legend
ax.set_xlabel("Volume backscattering strength (Sv re 1 m-1)[dB]")
ax.set_ylabel("Sv Value Frequency")
ax.set_title("Histogram Comparison between original Sv and background noise removed Sv")
ax.legend()

plt.show()
_images/c9a860d7bcc1aa53e8eac1fa4feb83e623d7228f04500acd7b6ea656699bd66e.png

From both the histogram and the echogram images above, we can see that a significant amount of Sv values, between -120 and -90 dB, surrounding the orange and yellow regions in the 125 kHz channel have been removed. Being that the majority of values removed were so weak, we suspect that it was mostly background noise that was removed.

Align echosounder data according to depth#

What are range_sample and echo_range?#

Since the glider is going downwards, displaying the echogram as we did in the Sv plots above is not correct since it does not display the backscatter at its respective depth. For Sv, we have range_sample which is the number of samples a particular echo samples measured starting from the moment the ping was transmitted.

# Show `range_sample`
ds_Sv["range_sample"]
<xarray.DataArray 'range_sample' (range_sample: 1849)> Size: 15kB
array([ 150,  151,  152, ..., 1996, 1997, 1998], shape=(1849,))
Coordinates:
  * range_sample  (range_sample) int64 15kB 150 151 152 153 ... 1996 1997 1998
Attributes:
    long_name:  Along-range sample number, base 0

To get the actual range (distance) of how far this echo pixel is from the transducer surface, the Sv dataset object also contains the echo_range variable that is calculated based on the sound speed (calculated from the environmental data) and sampling interval:

# Show `echo_range`
ds_Sv["echo_range"]
<xarray.DataArray 'echo_range' (channel: 3, ping_time: 920, range_sample: 1849)> Size: 41MB
array([[[ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        ...,
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902]],

       [[ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
...
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902]],

       [[ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        ...,
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902],
        [ 5.77733777,  5.81344613,  5.84955449, ..., 72.43337229,
         72.46948066, 72.50558902]]], shape=(3, 920, 1849))
Coordinates:
  * channel       (channel) <U11 132B '59006-38-1' '59006-125-2' '59006-200-3'
  * ping_time     (ping_time) datetime64[ns] 7kB 2018-01-11T07:44:40 ... 2018...
  * range_sample  (range_sample) int64 15kB 150 151 152 153 ... 1996 1997 1998
Attributes:
    long_name:  Range distance
    units:      m
# Plot `echo_range`
g = ds_Sv["echo_range"].plot(col="channel", y="range_sample", yincrease=False)

# Iterate through each facet
for ax in g.axes.flatten():
    labels = [label.get_text() for label in ax.get_xticklabels()]
    # Set the current ticks
    ax.set_xticks(ax.get_xticks())
    # Apply rotation
    ax.set_xticklabels(labels, rotation=45)
    # Set x label
    ax.set_xlabel("Ping Time (HH:MM)")

plt.show()
/tmp/ipykernel_250/3346364644.py:5: FutureWarning: self.axes is deprecated since 2022.11 in order to align with matplotlibs plt.subplots, use self.axs instead.
  for ax in g.axes.flatten():
_images/9bbd354c5c04f7f04c1ca6e0a4eb4f070dbe506f42edb670ca84d7bf9df59316.png
# Show `echo_range` dimensions
ds_Sv["echo_range"].dims
('channel', 'ping_time', 'range_sample')

This is a 3D array, with the same dimensions as the Sv array. In this case, the echo_range is equal across channels and pings; however, this is not always the case and so we have to construct the echo_range variable as a 3D array.

Since echo_range is calculated from the surface of the transducer, we can use the echo_range variable and external parameters to compute a depth array that captures this downward movement of the glider and position the echogram at the right depth.

Adding depth to Sv#

We can use external parameters from the ds_glider to compute depth.

We must first convert the glider dataset’s pitch and roll, which are in radians, into tilt, which is in degrees.

# Correct pitch based on the processing from:
# https://github.com/a-sheehan/Echopype-Processing-Pipeline-for-AZFP-and-Glider-Data/blob/main/Sheehan_azfp_forloop.ipynb
correct_pitch = ds_glider["m_pitch"].where(
    (ds_glider["m_pitch"]*180/np.pi < -15) & (ds_glider["m_pitch"]*180/np.pi > -30),
    other=np.nan
)

# Convert pitch and roll from radians to degrees
pitch = np.rad2deg(correct_pitch)
roll = np.rad2deg(ds_glider["m_roll"])

# Compute tilt in degrees from pitch roll rotations
yaw = np.zeros_like(pitch.values)
yaw_pitch_roll_euler_angles_stack = np.column_stack([yaw, pitch.values, roll.values])
yaw_rot_pitch_roll = R.from_euler("ZYX", yaw_pitch_roll_euler_angles_stack, degrees=True)
glider_tilt = yaw_rot_pitch_roll.as_matrix()[:, -1, -1]
glider_tilt = xr.DataArray(
    glider_tilt, dims="time", coords={"time": ds_glider["time"]}
)
glider_tilt_in_degrees = np.rad2deg(np.arccos(glider_tilt))
glider_tilt_in_degrees.attrs = {
    "long_name": "Tilt",
    "units": "Degrees"
}

Plotting glider tilt:

# Plot glider tilt
glider_tilt_in_degrees.plot.scatter()
<matplotlib.collections.PathCollection at 0x7f46e4c2f090>
_images/8c7f115d7622a8128723ceaa63c493b78d0da04fafdf1e28082f94c803b33d63.png

Plotting glider specified depth:

# Plot glider depth
ds_glider["depth"].dropna(dim="time").plot.line()
[<matplotlib.lines.Line2D at 0x7f46e0bc3090>]
_images/4673d9efb9b077771abff9d85b372ef90d61f3e42957a2adfbaf7bd7bf409d25.png

We use both angular and vertical offset information alongside echo_range in ep.consolidate.add_depth to correctly compute the depth that each backscatter data point corresponds to:

# Add depth to Sv dataset incorporating Glider depth and tilt data
ds_Sv = ep.consolidate.add_depth(
    ds_Sv,
    depth_offset=ds_glider["depth"].dropna("time"),
    tilt=glider_tilt_in_degrees.dropna("time"),
)

Plot the depth array:

# Plot the depth array
g = ds_Sv["depth"].plot(col="channel", y="range_sample", yincrease=False)

# Iterate through each facet
for ax in g.axes.flatten():
    labels = [label.get_text() for label in ax.get_xticklabels()]
    # Set the current ticks
    ax.set_xticks(ax.get_xticks())
    # Apply rotation
    ax.set_xticklabels(labels, rotation=45)
    # Set x label
    ax.set_xlabel("Ping Time (HH:MM)")

plt.show()
/tmp/ipykernel_250/2361482427.py:5: FutureWarning: self.axes is deprecated since 2022.11 in order to align with matplotlibs plt.subplots, use self.axs instead.
  for ax in g.axes.flatten():
_images/3002e402cd1303fa0e7d196d3d8255a3b5a43a480ae226f4182e13bcc8347810.png

Plot the 125 kHz channel of the Sv corrected dataset using depth as a 2D coordinate grid:

# Create a single subplot
fig, ax = plt.subplots(1,1,figsize=(12,6))

# Plot 125 kHz Sv corrected channel as a 2D mesh with depth and ping time coords
pcolormesh = plt.pcolormesh(
    ds_Sv["ping_time"].broadcast_like(ds_Sv["range_sample"]).values.T,
    ds_Sv["depth"].sel(channel="59006-125-2").values, 
    ds_Sv["Sv_corrected"].sel(channel="59006-125-2").values,
    shading='auto',
    vmin=-100,
    vmax=-60,
    cmap="ep.ek500",
)
plt.gca().invert_yaxis()

# Add title and axis labels
plt.title("125 kHz Sv (with background noise removed)")
plt.xlabel("Ping Time (DD HH:MM)")
plt.ylabel("Depth (m)")

# Add colorbar
plt.colorbar(pcolormesh, label="Volume backscattering strength (Sv re 1 m-1)[dB]")

# Show plot
plt.show()
/tmp/ipykernel_250/3509394515.py:5: UserWarning: The input coordinates to pcolormesh are interpreted as cell centers, but are not monotonically increasing or decreasing. This may lead to incorrectly calculated cell edges, in which case, please supply explicit cell edges to pcolormesh.
  pcolormesh = plt.pcolormesh(
_images/7adc3bda13c1ba6098a55b4c935f040029752d3b4b0f2ef2b3cec64167ce8a4d.png

Note that the first few roll and pitch angles may be inaccurate, and hence there is a small “jump” near the start of the dive in the depth of the echogram.

Compute and apply shoal mask on corrected Sv#

With the calibrated backscatter volume data in an easily manipulable array format, here we use echopype’s shoal detection functionality to extract the zooplankton schools seen in the echogram above. There are multiple options to perform shoal detection, and below we use the one used by Echoview, with the implementation adapted from another open-source fisheries acoustics package, Echopy.

from echopype.mask import detect_shoal

shoal_mask = detect_shoal(
    ds_Sv,
    method="echoview",
    params={
        "var_name": "Sv_corrected",
        "channel": "59006-125-2",
        "idim": np.arange(len(ds_Sv["range_sample"])+1),
        "jdim": np.arange(len(ds_Sv["ping_time"])+1),
        "thr": -90,
        "mincan": (5, 5),
        "maxlink": (30, 2),
        "minsho": (30, 2),
    }
)

The mask is binary, where 1 is within a zooplankton school, and 0 otherwise:

# Plot the school mask
shoal_mask.plot(yincrease=False, y="range_sample", cmap="gray")
<matplotlib.collections.QuadMesh at 0x7f46e6d69010>
_images/0a4a5f6a3d573f075c1bcce055c0e7138df3306a44b2ac0e9f415e3b2a5b429e.png
# Apply shoal mask on the corrected Sv
ds_shoal_masked_Sv = ep.mask.apply_mask(ds_Sv, shoal_mask, var_name="Sv_corrected")

Plot the 125 kHz channel of the shoal masked Sv dataset using depth as a 2D coordinate grid:

# Create a single subplot
fig, ax = plt.subplots(1,1,figsize=(12,6))

# Plot shoal masked 38 kHz Sv channel as a 2D mesh with depth and ping time coords
pcolormesh = plt.pcolormesh(
    ds_shoal_masked_Sv["ping_time"].broadcast_like(ds_shoal_masked_Sv["range_sample"]).values.T,
    ds_shoal_masked_Sv["depth"].sel(channel="59006-125-2").values, 
    ds_shoal_masked_Sv["Sv_corrected"].sel(channel="59006-125-2").values,
    shading='auto',
    vmin=-100,
    vmax=-60,
    cmap="ep.ek500",
)
plt.gca().invert_yaxis()

# Add title and axis labels
plt.title("125 kHz Sv (with background noise removed and shoal mask applied)")
plt.xlabel("Ping Time (DD HH:MM)")
plt.ylabel("Depth (m)")

# Add colorbar
plt.colorbar(pcolormesh, label="Volume backscattering strength (Sv re 1 m-1)[dB]")

# Show plot
plt.show()
/tmp/ipykernel_250/2654804491.py:5: UserWarning: The input coordinates to pcolormesh are interpreted as cell centers, but are not monotonically increasing or decreasing. This may lead to incorrectly calculated cell edges, in which case, please supply explicit cell edges to pcolormesh.
  pcolormesh = plt.pcolormesh(
_images/2c1bc04c221167fc96ac4f8f18e92f12e394bd9febb30149be853c14318262e7.png

Package versions#

import datetime
print(f"echopype: {ep.__version__}, xarray: {xr.__version__}, geopandas: {gpd.__version__}")

print(f"\n{datetime.datetime.utcnow()} +00:00")
echopype: 0.11.1, xarray: 2026.2.0, geopandas: 1.1.2

2026-04-18 19:42:11.009823 +00:00