Repository: OSOceanAcoustics/echopype (language: python)

Issue:
Add unit tests for functions used in `apply_mask` 
This PR addresses the https://github.com/OSOceanAcoustics/echopype/pull/905#discussion_r1053612351 by adding unit tests for `validate_and_collect_mask_input` and `_check_var_name_fill_value`. Additionally, while implementing these changes it was discovered that the `utils/io.py` function `get_file_format` and newly created function `validate_and_collect_mask_input` do not correctly account for `pathlib.Path` input. Both of these functions have been modified to allow for `pathlib.Path` input. 
Sync dev to main to pull in Github Actions updates
## Overview

The versions with the Github Actions are now old, causing errors with PR's. The `main` branch has been updated and CI are running properly, so this PR should also fix the `dev` so that CI is running properly.


Discussion / hints:

Fix patch:
diff --git a/.ci_helpers/docker/http.dockerfile b/.ci_helpers/docker/http.dockerfile
index ff390da72..96d02eb0c 100644
--- a/.ci_helpers/docker/http.dockerfile
+++ b/.ci_helpers/docker/http.dockerfile
@@ -1,3 +1,4 @@
 FROM httpd:2.4
+ARG TARGETPLATFORM
 
 COPY echopype/test_data /usr/local/apache2/htdocs/data
diff --git a/.ci_helpers/docker/minioci.dockerfile b/.ci_helpers/docker/minioci.dockerfile
index 277b251ca..f39d15571 100644
--- a/.ci_helpers/docker/minioci.dockerfile
+++ b/.ci_helpers/docker/minioci.dockerfile
@@ -1,4 +1,5 @@
 FROM minio/minio
+ARG TARGETPLATFORM
 
 # Install git
 RUN microdnf install git
diff --git a/.ci_helpers/py3.10.yaml b/.ci_helpers/py3.10.yaml
index fd2585b91..dde2e4634 100644
--- a/.ci_helpers/py3.10.yaml
+++ b/.ci_helpers/py3.10.yaml
@@ -14,4 +14,3 @@ dependencies:
   - s3fs==2022.5.0
   - matplotlib-base
   - cmocean
-  - mamba=0.20.0
diff --git a/.ci_helpers/py3.8.yaml b/.ci_helpers/py3.8.yaml
index 3623c47d3..0b01c7dbe 100644
--- a/.ci_helpers/py3.8.yaml
+++ b/.ci_helpers/py3.8.yaml
@@ -14,4 +14,3 @@ dependencies:
   - s3fs==2022.5.0
   - matplotlib-base
   - cmocean
-  - mamba=0.20.0
diff --git a/.ci_helpers/py3.9.yaml b/.ci_helpers/py3.9.yaml
index 41ffe9f7d..968693d56 100644
--- a/.ci_helpers/py3.9.yaml
+++ b/.ci_helpers/py3.9.yaml
@@ -14,4 +14,3 @@ dependencies:
   - s3fs==2022.5.0
   - matplotlib-base
   - cmocean
-  - mamba=0.20.0
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 73343caa2..b3a7cba60 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -65,7 +65,7 @@ jobs:
       - name: Install dev tools
         shell: bash -l {0}
         run: |
-          mamba install -c conda-forge --yes --file requirements-dev.txt
+          micromamba install -c conda-forge -n ${{ env.CONDA_ENV }} --yes --file requirements-dev.txt
       - name: Install echopype
         shell: bash -l {0}
         run: |
diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml
index 540a657c8..1d0e7865d 100644
--- a/.github/workflows/docker.yaml
+++ b/.github/workflows/docker.yaml
@@ -47,6 +47,7 @@ jobs:
           context: ./
           file: ./.ci_helpers/docker/${{ matrix.image_name }}.dockerfile
           push: true
+          platforms: linux/amd64,linux/arm64
           tags: |
             ${{ env.IMAGE_SPEC }}:${{ env.DATE_TAG }}
             ${{ env.IMAGE_SPEC }}:latest
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
index 821c59640..7622ce0db 100644
--- a/.github/workflows/pr.yaml
+++ b/.github/workflows/pr.yaml
@@ -61,7 +61,7 @@ jobs:
       - name: Install dev tools
         shell: bash -l {0}
         run: |
-          mamba install -c conda-forge --yes --file requirements-dev.txt
+          micromamba install -c conda-forge -n ${{ env.CONDA_ENV }} --yes --file requirements-dev.txt
       - name: Install echopype
         shell: bash -l {0}
         run: |
diff --git a/.github/workflows/windows-utils.yaml b/.github/workflows/windows-utils.yaml
new file mode 100644
index 000000000..6041a840b
--- /dev/null
+++ b/.github/workflows/windows-utils.yaml
@@ -0,0 +1,69 @@
+name: windows-utils-test
+
+on:
+  push:
+    paths-ignore: [".ci_helpers/docker/**", "**/docker.yaml"]
+  pull_request:
+    paths-ignore: [".ci_helpers/docker/**", "**/docker.yaml"]
+  pull_request_target:
+    paths-ignore: [".ci_helpers/docker/**", "**/docker.yaml"]
+  workflow_dispatch:
+
+env:
+  CONDA_ENV: echopype
+
+jobs:
+  windows-test:
+      name: windows-${{ matrix.python-version }}-build
+      runs-on: "windows-latest"
+      continue-on-error: ${{ matrix.experimental }}
+      strategy:
+        fail-fast: false
+        matrix:
+          include:
+          - python-version: 3.9
+            experimental: false
+      defaults:
+        run:
+          shell: powershell
+      steps:
+        - name: Checkout repo
+          uses: actions/checkout@v3
+          with:
+            fetch-depth: 0 # Fetch all history for all branches and tags.
+        - name: Set environment variables
+          run: |
+            echo "PYTHON_VERSION=${{ matrix.python-version }}" >> $GITHUB_ENV
+        - name: Setup miniconda w/mamba
+          uses: conda-incubator/setup-miniconda@v2
+          with:
+            python-version: ${{ matrix.python-version }}
+            mamba-version: "*"
+            channels: conda-forge,defaults
+            channel-priority: true
+            activate-environment: ${{ env.CONDA_ENV }}
+            environment-file: .ci_helpers/py${{ matrix.python-version }}.yaml
+        # Currently problematic! See https://github.com/mamba-org/mamba/issues/2157
+        # - name: Setup micromamba
+        #   uses: mamba-org/provision-with-micromamba@v15
+        #   with:
+        #     environment-file: .ci_helpers/py${{ matrix.python-version }}.yaml
+        #     environment-name: ${{ env.CONDA_ENV }}
+        #     cache-env: true
+        #     cache-env-key: ${{ runner.os }}-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles(format('.ci_helpers/py{0}.yaml', matrix.python-version)) }}
+        - name: Print conda info
+          run: |
+            conda info
+            conda list
+            conda config --show-sources
+            conda config --show
+            printenv | sort
+        - name: Install dev tools
+          run: |
+            mamba install -c conda-forge -n ${{ env.CONDA_ENV }} --yes --file requirements-dev.txt
+        - name: Install echopype
+          run: |
+            python -m pip install -e .
+        - name: Running all Tests
+          run: |
+            pytest -vvv -rx --cov=echopype --cov-report=xml --log-cli-level=WARNING --disable-warnings echopype/tests/utils |& tee ci_${{ matrix.python-version }}_test_log.log
diff --git a/.gitignore b/.gitignore
index c70dd27a2..a7a4988f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,7 +29,6 @@ toolbox
 *.mat
 ek60/
 azfp/
-temp_echopype_output/
 notebooks/*_dev/
 _echopype_version.py
 !echopype/test_data
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1fcb11335..563fa9322 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -33,14 +33,10 @@ repos:
     hooks:
       - id: flake8
 
-  - repo: https://github.com/executablebooks/jupyter-book
-    rev: v0.12.3
-    hooks:
-    - id: jb-to-sphinx
-      args: ["docs/source"]
-
   - repo: https://github.com/codespell-project/codespell
     rev: v2.2.2
     hooks:
       - id: codespell
-        args: ["--skip=*.ipynb", "-w", "docs/source", "echopype"]
+        # Checks spelling in `docs/source` and `echopype` dirs ONLY
+        # Ignores `.ipynb` files and `_build` folders
+        args: ["--skip=*.ipynb,docs/source/_build", "-w", "docs/source", "echopype"]
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 7b276ef73..5b510cd82 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -5,6 +5,16 @@
 # Required
 version: 2
 
+# Set the version of Python and other tools you might need
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.8"
+  jobs:
+    pre_build:
+      # Generate the Sphinx configuration for this Jupyter Book so it builds.
+      - "jupyter-book config sphinx docs/source"
+
 # Build documentation in the docs/ directory with Sphinx
 sphinx:
    configuration: docs/source/conf.py
@@ -18,7 +28,6 @@ formats: []
 
 # Optionally set the version of Python and requirements required to build your docs
 python:
-   version: "3.8"
    install:
       - requirements: docs/requirements.txt
       - method: pip
diff --git a/CITATION.cff b/CITATION.cff
index bf1535ce3..8eed974bc 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -13,6 +13,9 @@ authors:
 - family-names: "Mayorga"
   given-names: "Emilio"
   orcid: "https://orcid.org/0000-0003-2574-4623"
+- family-names: "Reyes"
+  given-names: "Brandon"
+  orcid: "https://orcid.org/0000-0003-3496-7490"
 - family-names: "Majeed"
   given-names: "Imran"
   orcid: "https://orcid.org/0000-0002-2032-6476"
@@ -23,9 +26,10 @@ title: "echopype"
 doi: 10.5281/zenodo.3906999
 # but pointing to a specific release/version will
 # require having to update this file with each release
-version: 0.5.4
-date-released: 2021-10-01
-url: "https://github.com/OSOceanAcoustics/echopype"
+version: 0.6.4
+date-released: 2023-01-03
+url: "https://echopype.readthedocs.io"
+repository-code: "https://github.com/OSOceanAcoustics/echopype"
 preferred-citation:
   type: article
   authors:
@@ -48,6 +52,7 @@ preferred-citation:
     given-names: "Valentina"
     orcid: "https://orcid.org/0000-0002-3412-0364"
   journal: "arXiv preprint arXiv:2111.00187"
+  doi: https://doi.org/10.48550/arXiv.2111.00187
   month: 10
   title: "Echopype: A Python library for interoperable and scalable processing of water column sonar data for biological information"
   year: 2021
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 8205605e1..650a16081 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -72,6 +72,13 @@ qc
    :no-inheritance-diagram:
    :no-heading:
 
+mask
+^^^^
+
+.. automodapi:: echopype.mask
+   :no-inheritance-diagram:
+   :no-heading:
+
 Utilities
 ---------
 
diff --git a/docs/source/convert.rst b/docs/source/convert.rst
index ecd6a240a..3fd01ddac 100644
--- a/docs/source/convert.rst
+++ b/docs/source/convert.rst
@@ -86,7 +86,7 @@ all files from the same deployment.
     by allowing users to directly write variables that may consume a large amount of memory
     into a temporary zarr store (see `#774 <https://github.com/OSOceanAcoustics/echopype/pull/774>`_).
 
-    This feature is accessible through ``open_raw`` via arguments ``offload_to_zarr`` and ``max_zarr_mb``
+    This feature is accessible through ``open_raw`` via arguments ``use_swap`` and ``max_mb``
     and is only available for the following echosounders: EK60, ES70, EK80, ES80, EA640.
     See :ref:`API reference <api-open_raw>` for usage.
     This is currently a beta feature that will benefit from user feedback.
@@ -179,8 +179,8 @@ The examples below apply equally to both methods, except as noted.
 A destination folder or file path should be specified with the ``save_path``
 argument in these methods in order to control the location of the converted files.
 If the argument is not specified, the converted ``.nc`` and ``.zarr``
-files are saved into a folder called ``temp_echopype_output`` under the
-current execution folder. This folder will be created if it doesn't already exists.
+files are saved into the directory ``~/.echopype/temp_output``.
+This folder will be created if it doesn't already exists.
 
 
 Specify metadata attributes
@@ -227,7 +227,7 @@ the platform code from the
 .. ``combine`` argument (the default is ``combine=False``). In that case,
 .. ``save_path`` must be specified explicitly. If ``save_path`` is only a filename
 .. rather than a full file path, the combined output file will be saved to the
-.. default ``temp_echopype_output`` folder.
+.. default ``~/.echopype/temp_output`` folder.
 
 .. .. code-block:: python
 
diff --git a/docs/source/index.md b/docs/source/index.md
index b58bea38f..cea3c8a57 100644
--- a/docs/source/index.md
+++ b/docs/source/index.md
@@ -35,6 +35,13 @@ of the NOAA Alaska Fisheries Science Center
 for providing low-level file parsing routines for
 Simrad EK60 and EK80 echosounders.
 
+## Citing echopype
+
+Please cite echopype as follows:
+
+Lee, W., Mayorga, E., Setiawan, L., Majeed, I., Nguyen, K., & Staneva, V. (2021). Echopype: A Python library for interoperable and scalable processing of water column sonar data for biological information. arXiv preprint arXiv:2111.00187
+
+Citation information and project metadata are stored in `CITATION.cff`, which uses the [Citation File Format](https://citation-file-format.github.io/).
 
 ## License
 
diff --git a/docs/source/open-converted.ipynb b/docs/source/open-converted.ipynb
index 2714993ad..b1e71e30f 100644
--- a/docs/source/open-converted.ipynb
+++ b/docs/source/open-converted.ipynb
@@ -101,11 +101,11 @@
     "    ed_list.append(ep.open_converted(converted_file))  # already converted files are lazy-loaded\n",
     "```\n",
     "\n",
-    "Finally, we apply `combine_echodata` on this list to combine all the data into a single `EchoData` object. Here, we will store the final combined form in the Zarr path `path_to/combined_echodatas.zarr` and use the client we established above: \n",
+    "Finally, we apply `combine_echodata` on this list to combine all the data into a single `EchoData` object. Here, we will store the final combined form in the Zarr path `path_to/combined_echodata.zarr` and use the client we established above: \n",
     "```python\n",
     "combined_ed = ep.combine_echodata(\n",
     "    ed_list, \n",
-    "    zarr_path='path_to/combined_echodatas.zarr', \n",
+    "    zarr_path='path_to/combined_echodata.zarr', \n",
     "    client=client\n",
     ")\n",
     "```\n",
diff --git a/docs/source/whats-new.md b/docs/source/whats-new.md
index 89e1ced9d..877f1ac29 100644
--- a/docs/source/whats-new.md
+++ b/docs/source/whats-new.md
@@ -4,11 +4,65 @@ What's new
 See [GitHub releases page](https://github.com/OSOceanAcoustics/echopype/releases) for the complete history.
 
 
+# v0.6.4 (2023 March 13)
+
+## Overview
+
+This is a release that includes important performance enhancements that allow user-provided calibration parameters for broadband and narrowband calibration, new functionalities to compute and attach split-beam angles to calibrated Sv dataset, perform frequency-differencing and masking, as well as a number of bug fixes and other improvements.
+
+## New features
+- Allow passing in `cal_params` as a dictionary for narrowband and broadband calibration (#955)
+- Add default chunk encoding for zarr output (#939)
+- Add `add_splitbeam_angle` function to the `consolidate` subpackage (#916, #971)
+- Add a new `mask` subpackage
+  - Add `apply_mask` function to the `mask` subpackage (#912)
+  - Add `frequency-differencing` function to the `mask` subpackage (#901)
+- Allow selection of a subset of channels when combining multiple echodata objects (#892)
+  - This is done via the added `channel_selection` input argument to `combine_echodata`
+- Add default consolidated flag for `echodata.to_zarr` (#855)
+- Generalize and improve efficiency of `compute_MVBS` (#878)
+  - Allow `echo_range` that vary with `ping_time`
+  - Allow `Sv` data that are dask arrays
+- Add `.nbytes` to obtain that data size of an `echodata` object (#874)
+- Add new data variables from the raw data files to facilitate EK80 calibration (#944)
+  - transceiver type, transmit impedance, receive impedance, receiver sampling frequency
+
+## Enhancements and other changes
+- Unify the order of dimensions of `echo_range` across all sonar models (#968)
+- Create and use the default echopype home directory (#896, #954)
+  - This default directory is at `~/.echopype`
+- Refactor the `calibrate` subpackage (#904)
+  - routines for `env_params` and `cal_params` intake and range computation are now in different modules rather than methods in each classes
+  - Tidy up `cal_params` related routines (#953)
+  - Revise `env_params` related routines (#952)
+- Improving pulse compressed broadband Sv computation and echo range computation (#944)
+  - The resulting values are tested against pyEcholab and Echoview outputs
+  - Note there is unresolved discrepancy between pyEcholab/Echopype outputs with Echoview outputs for the first section of Sv values
+- Change input argument names for `open_raw` (#962)
+  - Change `offload_to_zarr` to `use_swap`
+  - Change `max_zarr_mb` to `max_mb`
+
+## Bug fixes
+- Handling of provenance attributes in apply-mask and add-depth, especially for testing (#930)
+- Fix bugs in combining multiple echodata objects (the `combine_echodata` function)
+  - Fix meta_source_filenames bug and enable (meta)source_filenames appending of path and list (#913)
+- Fix bugs in `env_params` intake for calibration (#952)
+
+## Infrastructure
+- Update docker build for arm64 mac silicon chips (#964)
+- Fix windows github actions workflow for utils module (#947)
+- Remove mamba dependency (#946)
+- Fix pre-commit ci and update RTD config for jupyter-book (#934)
+
+
+
+
+
 # v0.6.3 (2022 October 15)
 
 ## Overview
 
-This is a minor release that includes an important performance enhancement for combining large volumes of data residing in individual files into a single entityr, a number of bug fixes, and other smaller improvements.
+This is a minor release that includes an important performance enhancement for combining large volumes of data residing in individual files into a single entity, a number of bug fixes, and other smaller improvements.
 
 ## New features
 - Overhaul `combine_echodata` function
diff --git a/echopype/__init__.py b/echopype/__init__.py
index 31bd5df62..54ad76d3a 100644
--- a/echopype/__init__.py
+++ b/echopype/__init__.py
@@ -2,20 +2,25 @@
 
 from _echopype_version import version as __version__  # noqa
 
-from . import calibrate, consolidate, preprocess, utils
+from . import calibrate, consolidate, mask, preprocess, utils
 from .convert.api import open_raw
 from .echodata.api import open_converted
 from .echodata.combine import combine_echodata
+from .utils.io import init_ep_dir
 from .utils.log import verbose
 
+# Turn off verbosity for echopype
 verbose(override=True)
 
+init_ep_dir()
+
 __all__ = [
     "open_raw",
     "open_converted",
     "combine_echodata",
     "calibrate",
     "consolidate",
+    "mask",
     "preprocess",
     "utils",
     "verbose",
diff --git a/echopype/calibrate/api.py b/echopype/calibrate/api.py
index f74ac0400..b76182706 100644
--- a/echopype/calibrate/api.py
+++ b/echopype/calibrate/api.py
@@ -1,6 +1,7 @@
 import xarray as xr
 
 from ..echodata import EchoData
+from ..echodata.simrad import check_input_args_combination
 from ..utils.log import _init_logger
 from ..utils.prov import echopype_prov_attrs, source_files_vars
 from .calibrate_azfp import CalibrateAZFP
@@ -30,14 +31,7 @@ def _compute_cal(
     if echodata.sonar_model == "EK80":
         if waveform_mode is None or encode_mode is None:
             raise ValueError("waveform_mode and encode_mode must be specified for EK80 calibration")
-        elif waveform_mode not in ("BB", "CW"):
-            raise ValueError("Input waveform_mode not recognized!")
-        elif encode_mode not in ("complex", "power"):
-            raise ValueError("Input encode_mode not recognized!")
-        elif waveform_mode == "BB" and encode_mode == "power":
-            raise ValueError(
-                "Data from broadband ('BB') transmission must be recorded as complex samples"
-            )
+        check_input_args_combination(waveform_mode, encode_mode)
     elif echodata.sonar_model in ("EK60", "AZFP"):
         if waveform_mode is not None and waveform_mode != "CW":
             logger.warning(
@@ -59,6 +53,15 @@ def _compute_cal(
         encode_mode=encode_mode,
     )
 
+    # Perform calibration
+    if cal_type == "Sv":
+        cal_ds = cal_obj.compute_Sv()
+    elif cal_type == "TS":
+        cal_ds = cal_obj.compute_TS()
+    else:
+        raise ValueError("cal_type must be Sv or TS")
+
+    # Add attributes
     def add_attrs(cal_type, ds):
         """Add attributes to backscattering strength dataset.
         cal_type: Sv or TS
@@ -84,13 +87,9 @@ def add_attrs(cal_type, ds):
                 }
             )
 
-    # Perform calibration
-    if cal_type == "Sv":
-        cal_ds = cal_obj.compute_Sv(waveform_mode=waveform_mode, encode_mode=encode_mode)
-    else:
-        cal_ds = cal_obj.compute_TS(waveform_mode=waveform_mode, encode_mode=encode_mode)
-
     add_attrs(cal_type, cal_ds)
+
+    # Add provinance
     # Provenance source files may originate from raw files (echodata.source_files)
     # or converted files (echodata.converted_raw_path)
     if echodata.source_file is not None:
@@ -109,8 +108,8 @@ def add_attrs(cal_type, ds):
         .assign_attrs(prov_dict)
     )
 
+    # Add water_level to the created xr.Dataset
     if "water_level" in echodata["Platform"].data_vars.keys():
-        # add water_level to the created xr.Dataset
         cal_ds["water_level"] = echodata["Platform"].water_level
 
     return cal_ds
diff --git a/echopype/calibrate/cal_params.py b/echopype/calibrate/cal_params.py
new file mode 100644
index 000000000..b21e50a98
--- /dev/null
+++ b/echopype/calibrate/cal_params.py
@@ -0,0 +1,510 @@
+from typing import Dict, List, Literal, Union
+
+import numpy as np
+import xarray as xr
+
+CAL_PARAMS = {
+    "EK60": (
+        "sa_correction",
+        "gain_correction",
+        "equivalent_beam_angle",
+        "angle_offset_alongship",
+        "angle_offset_athwartship",
+        "angle_sensitivity_alongship",
+        "angle_sensitivity_athwartship",
+        "beamwidth_alongship",
+        "beamwidth_athwartship",
+    ),
+    "EK80": (
+        "sa_correction",
+        "gain_correction",
+        "equivalent_beam_angle",
+        "angle_offset_alongship",
+        "angle_offset_athwartship",
+        "angle_sensitivity_alongship",
+        "angle_sensitivity_athwartship",
+        "beamwidth_alongship",
+        "beamwidth_athwartship",
+        "impedance_transmit",  # z_et
+        "impedance_receive",  # z_er
+        "receiver_sampling_frequency",
+    ),
+    "AZFP": ("EL", "DS", "TVR", "VTX", "equivalent_beam_angle", "Sv_offset"),
+}
+
+EK80_DEFAULT_PARAMS = {
+    "impedance_transmit": 75,
+    "impedance_receive": 1000,
+    "receiver_sampling_frequency": {  # default full sampling frequency [Hz]
+        "default": 1500000,
+        "GPT": 500000,
+        "SBT": 50000,
+        "WBAT": 1500000,
+        "WBT TUBE": 1500000,
+        "WBT MINI": 1500000,
+        "WBT": 1500000,
+        "WBT HP": 187500,
+        "WBT LF": 93750,
+    },
+}
+
+
+# TODO: need a function (something like "_check_param_freq_dep")
+# to check user input cal_params and env_params
+
+
+def param2da(p_val: Union[int, float, list], channel: Union[list, xr.DataArray]) -> xr.DataArray:
+    """
+    Organize individual parameter in scalar or list to xr.DataArray with channel coordinate.
+
+    Parameters
+    ----------
+    p_val : int, float, or list
+        A scalar or list holding calibration params for one or more channels.
+        Each param has to be a scalar.
+    channel : list or xr.DataArray
+        Values to use for the output channel coordinate
+
+    Returns
+    -------
+    xr.DataArray
+        A data array with channel coordinate
+    """
+    # TODO: allow passing in np.array as dict values to assemble a frequency-dependent cal da
+
+    if not isinstance(p_val, (int, float, list)):
+        raise ValueError("'p_val' needs to be one of type int, float, or list")
+
+    if isinstance(p_val, list):
+        # Check length if p_val a list
+        if len(p_val) != len(channel):
+            raise ValueError("The lengths of 'p_val' and 'channel' should be identical")
+
+        return xr.DataArray(p_val, dims=["channel"], coords={"channel": channel})
+    else:
+        # if scalar, make a list to form data array
+        return xr.DataArray([p_val] * len(channel), dims=["channel"], coords={"channel": channel})
+
+
+def sanitize_user_cal_dict(
+    sonar_type: Literal["EK60", "EK80", "AZFP"],
+    user_dict: Dict[str, Union[int, float, xr.DataArray]],
+    channel: Union[List, xr.DataArray],
+) -> Dict[str, Union[int, float, xr.DataArray]]:
+    """
+    Creates a blueprint for ``cal_params`` dictionary and
+    check the format/organize user-provided parameters.
+
+    Parameters
+    ----------
+    sonar_type : str
+        Type of sonar, one of "EK60", "EK80", or "AZFP"
+    user_dict : dict
+        A dictionary containing user input calibration parameters
+        as {parameter name: parameter value}.
+        Parameter value has to be a scalar (int or float) or an ``xr.DataArray``.
+        If parameter value is an ``xr.DataArray``, it has to either have 'channel' as a coordinate
+        or have both ``cal_channel_id`` and ``cal_frequency`` as coordinates.
+
+    channel : list or xr.DataArray
+        A list of channels to be calibrated.
+        For EK80 data, this list has to corresponds with the subset of channels
+        selected based on waveform_mode and encode_mode
+    """
+    # Check sonar type
+    if sonar_type not in ["EK60", "EK80", "AZFP"]:
+        raise ValueError("'sonar_type' has to be one of: 'EK60', 'EK80', or 'AZFP'")
+
+    # Make channel a sorted list
+    if not isinstance(channel, (list, xr.DataArray)):
+        raise ValueError("'channel' has to be a list or an xr.DataArray")
+
+    if isinstance(channel, xr.DataArray):
+        channel_sorted = sorted(channel.values)
+    else:
+        channel_sorted = sorted(channel)
+
+    # Screen parameters: only retain those defined in CAL_PARAMS
+    #  -- transform params in scalar or list to xr.DataArray
+    #  -- directly pass through those that are xr.DataArray
+    out_dict = dict.fromkeys(CAL_PARAMS[sonar_type])
+    for p_name, p_val in user_dict.items():
+        if p_name in out_dict:
+            # if p_val an xr.DataArray, check existence and coordinates
+            if isinstance(p_val, xr.DataArray):
+                # if 'channel' is a coordinate, it has to match that of the data
+                if "channel" in p_val.coords:
+                    if not (sorted(p_val.coords["channel"].values) == channel_sorted):
+                        raise ValueError(
+                            f"The 'channel' coordinate of {p_name} has to match "
+                            "that of the data to be calibrated"
+                        )
+                elif "cal_channel_id" in p_val.coords and "cal_frequency" in p_val.coords:
+                    if not (sorted(p_val.coords["cal_channel_id"].values) == channel_sorted):
+                        raise ValueError(
+                            f"The 'cal_channel_id' coordinate of {p_name} has to match "
+                            "that of the data to be calibrated"
+                        )
+                else:
+                    raise ValueError(
+                        f"{p_name} has to either have 'channel' as a coordinate "
+                        "or have both 'cal_channel_id' and 'cal_frequency' as coordinates"
+                    )
+                out_dict[p_name] = p_val
+
+            # If p_val a scalar or list, make it xr.DataArray
+            elif isinstance(p_val, (int, float, list)):
+                # check for list dimension happens within param2da()
+                out_dict[p_name] = param2da(p_val, channel)
+
+            # p_val has to be one of int, float, xr.DataArray
+            else:
+                raise ValueError(f"{p_name} has to be a scalar, list, or an xr.DataArray")
+
+            # TODO: Consider pre-sort the param xr.DataArray?
+
+    return out_dict
+
+
+def _get_interp_da(
+    da_param: Union[None, xr.DataArray],
+    freq_center: xr.DataArray,
+    alternative: Union[int, float, xr.DataArray],
+    BB_factor: float = 1,
+) -> xr.DataArray:
+    """
+    Get interpolated xr.DataArray aligned with the channel coordinate.
+
+    Interpolation at freq_center when da_param contains frequency-dependent xr.DataArray.
+    When da_param is None or does not contain frequency-dependent xr.DataArray,
+    the alternative (a const or an xr.DataArray with coordinate channel) is used.
+
+    Parameters
+    ----------
+    da_param : xr.DataArray or None
+        A data array from the Vendor group or user dict with freq-dependent param values
+    freq_center : xr.DataArray
+        Center frequency (BB) or nominal frequency (CW)
+    alternative : xr.DataArray or int or float
+        Alternative for when freq-dep values do not exist
+    BB_factor : float
+        scaling factor due to BB transmit signal with different center frequency
+        with respect to nominal channel frequency;
+        only applies when ``alternative`` from the Sonar/Beam_groupX group is used
+        for params ``angle_sensitivity_alongship/athwartship`` and
+        ``beamwidth_alongship/athwartship`` (``see get_cal_params_EK`` for detail)
+
+    Returns
+    -------
+    xr.DataArray
+        Data array aligned with the channel coordinate.
+
+    Note
+    ----
+    ``da_param`` is always an xr.DataArray from the Vendor-specific group.
+    It is possible that only a subset of the channels have frequency-dependent parameter values.
+    The output xr.DataArray here is constructed channel-by-channel to allow for this flexibility.
+
+    ``alternative`` can be one of the following:
+
+    - scalar (int or float): this is the case for impedance_transmit
+    - xr.DataArray with coordinates channel, ping_time, and beam:
+        this is the case for parameters angle_offset_alongship, angle_offset_athwartship,
+                                        beamwidth_alongship, beamwidth_athwartship
+    - xr.DataArray with coordinates channel, ping_time:
+        this is the case for sa_correction and gain_correction,
+        which will be direct output of get_vend_cal_params_power()
+    """
+    param = []
+    for ch_id in freq_center["channel"].values:
+        # if frequency-dependent param exists as a data array with desired channel
+        if (
+            da_param is not None
+            and "cal_channel_id" in da_param.coords
+            and ch_id in da_param["cal_channel_id"]
+        ):
+            # interp variable has ping_time dimension from freq_center
+            param.append(
+                da_param.sel(cal_channel_id=ch_id)
+                .interp(cal_frequency=freq_center.sel(channel=ch_id))
+                .data.squeeze()
+            )
+        # if no frequency-dependent param exists, use alternative
+        else:
+            BB_factor_ch = (
+                BB_factor.sel(channel=ch_id) if isinstance(BB_factor, xr.DataArray) else BB_factor
+            )
+            if isinstance(alternative, xr.DataArray):
+                # drop the redundant beam dimension if exist
+                if "beam" in alternative.coords:
+                    param.append(
+                        (alternative.sel(channel=ch_id).isel(beam=0) * BB_factor_ch).data.squeeze()
+                    )
+                else:
+                    param.append((alternative.sel(channel=ch_id) * BB_factor_ch).data.squeeze())
+            elif isinstance(alternative, (int, float)):
+                # expand to have ping_time dimension
+                param.append(
+                    np.array([alternative] * freq_center.sel(channel=ch_id).size).squeeze()
+                    * BB_factor_ch
+                )
+            else:
+                raise ValueError("'alternative' has to be of the type int, float, or xr.DataArray")
+
+    param = np.array(param)
+
+    if "ping_time" in freq_center.coords:
+        if len(param.shape) == 1:  # this means ping_time has length=1
+            param = np.expand_dims(param, axis=1)
+        return xr.DataArray(
+            param,
+            dims=["channel", "ping_time"],
+            coords={"channel": freq_center["channel"], "ping_time": freq_center["ping_time"]},
+        )
+    else:
+        return xr.DataArray(param, dims=["channel"], coords={"channel": freq_center["channel"]})
+
+
+def get_vend_cal_params_power(beam: xr.Dataset, vend: xr.Dataset, param: str) -> xr.DataArray:
+    """
+    Get cal parameters stored in the Vendor_specific group
+    by matching the transmit_duration_nominal with allowable pulse_length.
+
+    Parameters
+    ----------
+    beam : xr.Dataset
+        A subset of Sonar/Beam_groupX that contains only the channels specified for calibration
+    vend : xr.Dataset
+        A subset of Vendor_specific that contains only the channels specified for calibration
+    param : str {"sa_correction", "gain_correction"}
+        Name of parameter to retrieve
+
+    Returns
+    -------
+    An xr.DataArray containing the matched ``param``
+    """
+
+    # Check parameter is among those allowed
+    if param not in ["sa_correction", "gain_correction"]:
+        raise ValueError(f"Unknown parameter {param}")
+
+    # Check parameter exists
+    if param not in vend:
+        raise ValueError(f"{param} does not exist in the Vendor_specific group!")
+
+    # Find idx to select the corresponding param value
+    # by matching beam["transmit_duration_nominal"] with ds_vend["pulse_length"]
+    transmit_isnull = beam["transmit_duration_nominal"].isnull()
+    idxmin = np.abs(beam["transmit_duration_nominal"] - vend["pulse_length"]).idxmin(
+        dim="pulse_length_bin"
+    )
+
+    # fill nan position with 0 (will remove before return)
+    # and convert to int for indexing
+    idxmin = idxmin.where(~transmit_isnull, 0).astype(int)
+
+    # Get param dataarray into correct shape
+    da_param = (
+        vend[param]
+        .expand_dims(dim={"ping_time": idxmin["ping_time"]})  # expand dims for direct indexing
+        .sortby(idxmin.channel)  # sortby in case channel sequence differs in vend and beam
+    )
+
+    # Select corresponding index and clean up the original nan elements
+    da_param = da_param.sel(pulse_length_bin=idxmin, drop=True)
+    return da_param.where(~transmit_isnull, np.nan)  # set the nan elements back to nan
+
+
+def get_cal_params_AZFP(beam: xr.DataArray, vend: xr.DataArray, user_dict: dict) -> dict:
+    """
+    Get cal params using user inputs or values from data file.
+
+    Parameters
+    ----------
+    beam : xr.Dataset
+        A subset of Sonar/Beam_groupX that contains only the channels to be calibrated
+    vend : xr.Dataset
+        A subset of Vendor_specific that contains only the channels to be calibrated
+    user_dict : dict
+        A dictionary containing user-defined calibration parameters.
+        The user-defined calibration parameters will overwrite values in the data file.
+
+    Returns
+    -------
+    A dictionary containing the calibration parameters for the AZFP echosounder
+    """
+    # Use sanitized user dict as blueprint
+    # out_dict contains only and all of the allowable cal params
+    out_dict = sanitize_user_cal_dict(
+        user_dict=user_dict, channel=beam["channel"], sonar_type="AZFP"
+    )
+
+    # Only fill in params that are None
+    for p, v in out_dict.items():
+        if v is None:
+            # Params from Sonar/Beam_group1
+            if p == "equivalent_beam_angle":
+                # equivalent_beam_angle has dims: channel, ping_time, beam --> only need channel
+                out_dict[p] = beam[p].isel(ping_time=0, beam=0).drop(["ping_time", "beam"])
+
+            # Params from Vendor_specific group
+            elif p in ["EL", "DS", "TVR", "VTX", "Sv_offset"]:
+                out_dict[p] = vend[p]  # these params only have the channel dimension
+
+    return out_dict
+
+
+def get_cal_params_EK(
+    waveform_mode: Literal["CW", "BB"],
+    freq_center: xr.DataArray,
+    beam: xr.Dataset,
+    vend: xr.Dataset,
+    user_dict: Dict[str, Union[int, float, xr.DataArray]],
+    default_params: Dict[str, Union[int, float]] = EK80_DEFAULT_PARAMS,
+    sonar_type: str = "EK80",
+) -> Dict:
+    """
+    Get cal parameters from user input, data file, or a set of default values.
+
+    Parameters
+    ----------
+    waveform_mode : str
+        Transmit waveform mode, either "CW" or "BB"
+    freq_center : xr.DataArray
+        Center frequency (BB mode) or nominal frequency (CW mode)
+    beam : xr.Dataset
+        A subset of Sonar/Beam_groupX that contains only the channels to be calibrated
+    vend : xr.Dataset
+        A subset of Vendor_specific that contains only the channels to be calibrated
+    user_dict : dict
+        A dictionary containing user-defined parameters.
+        User-defined parameters take precedance over values in the data file or in default dict.
+    default_params : dict
+        A dictionary containing default parameters
+    sonar_type : str
+        Type of EK sonar, either "EK60" or "EK80"
+    """
+    if not isinstance(waveform_mode, str):
+        raise TypeError("waveform_mode is not type string")
+    elif waveform_mode not in ["CW", "BB"]:
+        raise ValueError("waveform_mode must be 'CW' or 'BB'")
+
+    # Private function to get fs
+    def _get_fs():
+        if "fs_receiver" in vend:
+            return vend["fs_receiver"]
+        else:
+            # loop through channel since transceiver can vary
+            fs = []
+            for ch in vend["channel"]:
+                tcvr_type = vend["transceiver_type"].sel(channel=ch).data.tolist().upper()
+                fs.append(default_params["receiver_sampling_frequency"][tcvr_type])
+            return xr.DataArray(fs, dims=["channel"], coords={"channel": vend["channel"]})
+
+    # Mapping between desired param name with Beam group data variable name
+    PARAM_BEAM_NAME_MAP = {
+        "angle_offset_alongship": "angle_offset_alongship",
+        "angle_offset_athwartship": "angle_offset_athwartship",
+        "angle_sensitivity_alongship": "angle_sensitivity_alongship",
+        "angle_sensitivity_athwartship": "angle_sensitivity_athwartship",
+        "beamwidth_alongship": "beamwidth_twoway_alongship",
+        "beamwidth_athwartship": "beamwidth_twoway_athwartship",
+        "equivalent_beam_angle": "equivalent_beam_angle",
+    }
+    if waveform_mode == "BB":
+        # for BB data equivalent_beam_angle needs to be scaled wrt freq_center
+        PARAM_BEAM_NAME_MAP.pop("equivalent_beam_angle")
+
+    # Use sanitized user dict as blueprint
+    # out_dict contains only and all of the allowable cal params
+    out_dict = sanitize_user_cal_dict(
+        user_dict=user_dict, channel=beam["channel"], sonar_type=sonar_type
+    )
+
+    # Interpolate user-input params that contain freq-dependent info
+    # ie those that has coordinate combination (cal_channel_id, cal_frequency)
+    # TODO: this will need to change for computing frequency-dependent TS
+    for p, v in out_dict.items():
+        if v is not None:
+            if "cal_channel_id" in v.coords:
+                out_dict[p] = _get_interp_da(v, freq_center, np.nan)
+
+    # Only fill in params that are None
+    for p, v in out_dict.items():
+        if v is None:
+            # Those without CW or BB complications
+            if p == "sa_correction":  # pull from data file
+                out_dict[p] = get_vend_cal_params_power(beam=beam, vend=vend, param=p)
+            elif p == "impedance_receive":  # from data file or default dict
+                out_dict[p] = default_params[p] if p not in vend else vend["impedance_receive"]
+            elif p == "receiver_sampling_frequency":  # from data file or default_params
+                out_dict[p] = _get_fs()
+            else:
+                # CW: params do not require interpolation, except for impedance_transmit
+                if waveform_mode == "CW":
+                    if p in PARAM_BEAM_NAME_MAP.keys():
+                        p_beam = PARAM_BEAM_NAME_MAP[p]
+                        # pull from data file, these should always exist
+                        if "beam" in beam[p_beam].coords:
+                            out_dict[p] = beam[p_beam].isel(beam=0).drop("beam")
+                        else:
+                            out_dict[p] = beam[p_beam]
+                    elif p == "gain_correction":
+                        # pull from data file narrowband table
+                        out_dict[p] = get_vend_cal_params_power(beam=beam, vend=vend, param=p)
+                    elif p == "impedance_transmit":
+                        # assemble each channel from data file or default dict
+                        out_dict[p] = _get_interp_da(
+                            da_param=None if p not in vend else vend[p],
+                            freq_center=freq_center,
+                            alternative=default_params[p],  # pull from default dict
+                        )
+                    else:
+                        raise ValueError(f"{p} not in the defined set of calibration parameters.")
+
+                # BB mode: params require interpolation
+                else:
+                    # interpolate for center frequency or use CW values
+                    if p in PARAM_BEAM_NAME_MAP.keys():
+                        # only scale these params if alternative is used
+                        if p in [
+                            "angle_sensitivity_alongship",
+                            "angle_sensitivity_athwartship",
+                            "beamwidth_alongship",
+                            "beamwidth_athwartship",
+                        ]:
+                            BB_factor = freq_center / beam["frequency_nominal"]
+                        else:
+                            BB_factor = 1
+
+                        p_beam = PARAM_BEAM_NAME_MAP[p]
+                        out_dict[p] = _get_interp_da(
+                            da_param=None if p not in vend else vend[p],
+                            freq_center=freq_center,
+                            alternative=beam[p_beam],  # these should always exist
+                            BB_factor=BB_factor,
+                        )
+                    elif p == "equivalent_beam_angle":
+                        # scaled according to frequency ratio
+                        out_dict[p] = beam[p].isel(beam=0).drop("beam") + 20 * np.log10(
+                            beam["frequency_nominal"] / freq_center
+                        )
+                    elif p == "gain_correction":
+                        # interpolate or pull from narrowband table
+                        out_dict[p] = _get_interp_da(
+                            da_param=None
+                            if "gain" not in vend
+                            else vend["gain"],  # freq-dep values
+                            freq_center=freq_center,
+                            alternative=get_vend_cal_params_power(beam=beam, vend=vend, param=p),
+                        )
+                    elif p == "impedance_transmit":
+                        out_dict[p] = _get_interp_da(
+                            da_param=None if p not in vend else vend[p],
+                            freq_center=freq_center,
+                            alternative=default_params[p],  # pull from default dict
+                        )
+                    else:
+                        raise ValueError(f"{p} not in the defined set of calibration parameters.")
+
+    return out_dict
diff --git a/echopype/calibrate/calibrate_azfp.py b/echopype/calibrate/calibrate_azfp.py
index b61c79c7f..dc71cc1f1 100644
--- a/echopype/calibrate/calibrate_azfp.py
+++ b/echopype/calibrate/calibrate_azfp.py
@@ -1,86 +1,31 @@
 import numpy as np
 
 from ..echodata import EchoData
-from ..utils import uwa
-from .calibrate_base import CAL_PARAMS
+from .cal_params import get_cal_params_AZFP
 from .calibrate_ek import CalibrateBase
+from .env_params import get_env_params_AZFP
+from .range import compute_range_AZFP
 
 
 class CalibrateAZFP(CalibrateBase):
-    def __init__(self, echodata: EchoData, env_params, cal_params, **kwargs):
-        super().__init__(echodata, env_params)
+    def __init__(self, echodata: EchoData, env_params=None, cal_params=None, **kwargs):
+        super().__init__(echodata, env_params, cal_params)
 
-        # initialize cal params
-        self.cal_params = dict.fromkeys(CAL_PARAMS["AZFP"])
+        # Set sonar_type
+        self.sonar_type = "AZFP"
 
         # load env and cal parameters
-        self.get_env_params()
-        if cal_params is None:
-            cal_params = {}
-        self.get_cal_params(cal_params)
-
-        # self.range_meter computed under self._cal_power()
-        # because the implementation is different for Sv and TS
-
-    def get_cal_params(self, cal_params):
-        """Get cal params using user inputs or values from data file.
-
-        Parameters
-        ----------
-        cal_params : dict
-        """
-
-        # Get params from Beam_group1
-        self.cal_params["equivalent_beam_angle"] = (
-            cal_params["equivalent_beam_angle"]
-            if "equivalent_beam_angle" in cal_params
-            else self.echodata["Sonar/Beam_group1"]["equivalent_beam_angle"]
+        self.env_params = get_env_params_AZFP(echodata=self.echodata, user_env_dict=self.env_params)
+        self.cal_params = get_cal_params_AZFP(
+            beam=self.echodata["Sonar/Beam_group1"],
+            vend=self.echodata["Vendor_specific"],
+            user_dict=self.cal_params,
         )
 
-        # Get params from the Vendor_specific group
-        for p in ["EL", "DS", "TVR", "VTX", "Sv_offset"]:
-            # substitute if None in user input
-            self.cal_params[p] = (
-                cal_params[p] if p in cal_params else self.echodata["Vendor_specific"][p]
-            )
-
-    def get_env_params(self):
-        """Get env params using user inputs or values from data file.
-
-        Parameters
-        ----------
-        env_params : dict
-        """
-        # Temperature comes from either user input or data file
-        self.env_params["temperature"] = (
-            self.env_params["temperature"]
-            if "temperature" in self.env_params
-            else self.echodata["Environment"]["temperature"]
-        )
-
-        # Salinity and pressure always come from user input
-        if ("salinity" not in self.env_params) or ("pressure" not in self.env_params):
-            raise ReferenceError("Please supply both salinity and pressure in env_params.")
-        else:
-            self.env_params["salinity"] = self.env_params["salinity"]
-            self.env_params["pressure"] = self.env_params["pressure"]
-
-        # Always calculate sound speed and absorption
-        self.env_params["sound_speed"] = uwa.calc_sound_speed(
-            temperature=self.env_params["temperature"],
-            salinity=self.env_params["salinity"],
-            pressure=self.env_params["pressure"],
-            formula_source="AZFP",
-        )
-        self.env_params["sound_absorption"] = uwa.calc_absorption(
-            frequency=self.echodata["Sonar/Beam_group1"]["frequency_nominal"],
-            temperature=self.env_params["temperature"],
-            salinity=self.env_params["salinity"],
-            pressure=self.env_params["pressure"],
-            formula_source="AZFP",
-        )
+        # self.range_meter computed under self._cal_power_samples()
+        # because the implementation is different for Sv and TS
 
-    def compute_range_meter(self, cal_type):
+    def compute_echo_range(self, cal_type):
         """Calculate range (``echo_range``) in meter using AZFP formula.
 
         Note the range calculation differs for Sv and TS per AZFP matlab code.
@@ -91,9 +36,11 @@ def compute_range_meter(self, cal_type):
             'Sv' for calculating volume backscattering strength, or
             'TS' for calculating target strength
         """
-        self.range_meter = self.echodata.compute_range(self.env_params, azfp_cal_type=cal_type)
+        self.range_meter = compute_range_AZFP(
+            echodata=self.echodata, env_params=self.env_params, cal_type=cal_type
+        )
 
-    def _cal_power(self, cal_type, **kwargs):
+    def _cal_power_samples(self, cal_type, **kwargs):
         """Calibrate to get volume backscattering strength (Sv) from AZFP power data.
 
         The calibration formulae used here is based on Appendix G in
@@ -103,19 +50,10 @@ def _cal_power(self, cal_type, **kwargs):
         See calc_Sv_offset() in convert/azfp.py
         """
         # Compute range in meters
-        self.compute_range_meter(
-            cal_type=cal_type
-        )  # range computation different for Sv and TS per AZFP matlab code
+        # range computation different for Sv and TS per AZFP matlab code
+        self.compute_echo_range(cal_type=cal_type)
 
         # Compute derived params
-
-        # Harmonize time coordinate between Beam_groupX data and env_params
-        # Use self.echodata["Sonar/Beam_group1"] because complex sample is always in Beam_group1
-        for p in self.env_params.keys():
-            self.env_params[p] = self.echodata._harmonize_env_param_time(
-                self.env_params[p], ping_time=self.echodata["Sonar/Beam_group1"].ping_time
-            )
-
         # TODO: take care of dividing by zero encountered in log10
         spreading_loss = 20 * np.log10(self.range_meter)
         absorption_loss = 2 * self.env_params["sound_absorption"] * self.range_meter
@@ -126,7 +64,8 @@ def _cal_power(self, cal_type, **kwargs):
         EL = (
             self.cal_params["EL"]
             - 2.5 / a
-            + self.echodata["Sonar/Beam_group1"].backscatter_r / (26214 * a)
+            + self.echodata["Sonar/Beam_group1"]["backscatter_r"].squeeze("beam", drop=True)
+            / (26214 * a)
         )  # eq.(5)  # has beam dim due to backscatter_r
 
         if cal_type == "Sv":
@@ -164,15 +103,10 @@ def _cal_power(self, cal_type, **kwargs):
         # Add env and cal parameters
         out = self._add_params_to_output(out)
 
-        # Order the dimensions
-        out["echo_range"] = out["echo_range"].transpose("channel", "ping_time", "range_sample")
-
-        # Squeeze out the beam dim
-        # doing it here because both out and self.cal_params["equivalent_beam_angle"] has beam dim
-        return out.squeeze("beam", drop=True)
+        return out
 
     def compute_Sv(self, **kwargs):
-        return self._cal_power(cal_type="Sv")
+        return self._cal_power_samples(cal_type="Sv")
 
     def compute_TS(self, **kwargs):
-        return self._cal_power(cal_type="TS")
+        return self._cal_power_samples(cal_type="TS")
diff --git a/echopype/calibrate/calibrate_base.py b/echopype/calibrate/calibrate_base.py
index ae47eccc8..c0efc77b8 100644
--- a/echopype/calibrate/calibrate_base.py
+++ b/echopype/calibrate/calibrate_base.py
@@ -1,43 +1,34 @@
 import abc
 
 from ..echodata import EchoData
-from .env_params import EnvParams
-
-CAL_PARAMS = {
-    "EK": ("sa_correction", "gain_correction", "equivalent_beam_angle"),
-    "AZFP": ("EL", "DS", "TVR", "VTX", "equivalent_beam_angle", "Sv_offset"),
-}
 
 
 class CalibrateBase(abc.ABC):
     """Class to handle calibration for all sonar models."""
 
-    def __init__(self, echodata: EchoData, env_params=None):
+    def __init__(self, echodata: EchoData, env_params=None, cal_params=None):
         self.echodata = echodata
-        if isinstance(env_params, EnvParams):
-            env_params = env_params._apply(echodata)
-        elif env_params is None:
-            env_params = {}
-        elif not isinstance(env_params, dict):
-            raise ValueError(
-                "invalid env_params type; provide an EnvParams instance, a dict, or None"
-            )
-        self.env_params = env_params  # env_params are set in child class
-        self.cal_params = None  # cal_params are set in child class
+        self.sonar_type = None
+
+        if env_params is None:
+            self.env_params = {}
+        elif isinstance(env_params, dict):
+            self.env_params = env_params
+        else:
+            raise ValueError("'env_params' has to be None or a dict")
+
+        if cal_params is None:
+            self.cal_params = {}
+        elif isinstance(cal_params, dict):
+            self.cal_params = cal_params
+        else:
+            raise ValueError("'cal_params' has to be None or a dict")
 
         # range_meter is computed in compute_Sv/TS in child class
         self.range_meter = None
 
     @abc.abstractmethod
-    def get_env_params(self, **kwargs):
-        pass
-
-    @abc.abstractmethod
-    def get_cal_params(self, **kwargs):
-        pass
-
-    @abc.abstractmethod
-    def compute_range_meter(self, **kwargs):
+    def compute_echo_range(self, **kwargs):
         """Calculate range (``echo_range``) in units meter.
 
         Returns
@@ -48,7 +39,7 @@ def compute_range_meter(self, **kwargs):
         pass
 
     @abc.abstractmethod
-    def _cal_power(self, cal_type, **kwargs):
+    def _cal_power_samples(self, cal_type, **kwargs):
         """Calibrate power data for EK60, EK80, and AZFP.
 
         Parameters
diff --git a/echopype/calibrate/calibrate_ek.py b/echopype/calibrate/calibrate_ek.py
index 06bdb0b2a..a1b1fb7f4 100644
--- a/echopype/calibrate/calibrate_ek.py
+++ b/echopype/calibrate/calibrate_ek.py
@@ -1,152 +1,51 @@
+from typing import Dict
+
 import numpy as np
 import xarray as xr
-from scipy import signal
 
 from ..echodata import EchoData
-from ..utils import uwa
+from ..echodata.simrad import retrieve_correct_beam_group
 from ..utils.log import _init_logger
-from .calibrate_base import CAL_PARAMS, CalibrateBase
+from .cal_params import get_cal_params_EK
+from .calibrate_base import CalibrateBase
+from .ek80_complex import compress_pulse, get_filter_coeff, get_tau_effective, get_transmit_signal
+from .env_params import get_env_params_EK60, get_env_params_EK80
+from .range import compute_range_EK, range_mod_TVG_EK
 
 logger = _init_logger(__name__)
 
 
 class CalibrateEK(CalibrateBase):
-    def __init__(self, echodata: EchoData, env_params):
-        super().__init__(echodata, env_params)
-
-        # cal params specific to EK echosounders
-        self.cal_params = dict.fromkeys(CAL_PARAMS["EK"])
+    def __init__(self, echodata: EchoData, env_params, cal_params):
+        super().__init__(echodata, env_params, cal_params)
 
-    def compute_range_meter(self, waveform_mode, encode_mode):
+    def compute_echo_range(self, chan_sel: xr.DataArray = None):
         """
-        Parameters
-        ----------
-        waveform_mode : {"CW", "BB"}
-            Type of transmit waveform.
-            Required only for data from the EK80 echosounder.
-
-            - `"CW"` for narrowband transmission,
-              returned echoes recorded either as complex or power/angle samples
-            - `"BB"` for broadband transmission,
-              returned echoes recorded as complex samples
-
-        encode_mode : {"complex", "power"}
-            Type of encoded return echo data.
-            Required only for data from the EK80 echosounder.
-
-            - `"complex"` for complex samples
-            - `"power"` for power/angle samples, only allowed when
-              the echosounder is configured for narrowband transmission
+        Compute echo range for EK echosounders.
 
         Returns
         -------
         range_meter : xr.DataArray
             range in units meter
         """
-        self.range_meter = self.echodata.compute_range(
-            self.env_params,
-            ek_waveform_mode=waveform_mode,
-            ek_encode_mode=encode_mode,
+        self.range_meter = compute_range_EK(
+            echodata=self.echodata,
+            env_params=self.env_params,
+            waveform_mode=self.waveform_mode,
+            encode_mode=self.encode_mode,
+            chan_sel=chan_sel,
         )
 
-    def _get_vend_cal_params_power(self, param, waveform_mode):
-        """Get cal parameters stored in the Vendor_specific group.
-
-        Parameters
-        ----------
-        param : str {"sa_correction", "gain_correction"}
-            name of parameter to retrieve
-        """
-        ds_vend = self.echodata["Vendor_specific"]
-
-        if ds_vend is None or param not in ds_vend:
-            return None
-
-        if param not in ["sa_correction", "gain_correction"]:
-            raise ValueError(f"Unknown parameter {param}")
-
-        if waveform_mode == "CW" and self.echodata["Sonar/Beam_group2"] is not None:
-            beam = self.echodata["Sonar/Beam_group2"]
-        else:
-            beam = self.echodata["Sonar/Beam_group1"]
-
-        # indexes of frequencies that are for power, not complex
-        relevant_indexes = np.where(
-            np.isin(ds_vend["frequency_nominal"], beam["frequency_nominal"])
-        )[0]
-
-        # Find idx to select the corresponding param value
-        # by matching beam["transmit_duration_nominal"] with ds_vend["pulse_length"]
-        transmit_isnull = beam["transmit_duration_nominal"].isnull()
-        idxmin = np.abs(
-            beam["transmit_duration_nominal"] - ds_vend["pulse_length"][relevant_indexes]
-        ).idxmin(dim="pulse_length_bin")
-
-        # fill nan position with 0 (witll remove before return)
-        # and convert to int for indexing
-        idxmin = idxmin.where(~transmit_isnull, 0).astype(int)
-
-        # Get param dataarray into correct shape
-        da_param = (
-            ds_vend[param][relevant_indexes]
-            .expand_dims(dim={"ping_time": idxmin["ping_time"]})  # expand dims for direct indexing
-            .sortby(idxmin.channel)  # sortby in case channel sequence different in vendor and beam
-        )
-
-        # Select corresponding index and clean up the original nan elements
-        da_param = da_param.sel(pulse_length_bin=idxmin, drop=True)
-        return da_param.where(~transmit_isnull, np.nan)  # set the nan elements back to nan
-
-    def get_cal_params(self, cal_params, waveform_mode, encode_mode):
-        """Get cal params using user inputs or values from data file.
-
-        Parameters
-        ----------
-        cal_params : dict
-        """
-
-        if (
-            encode_mode == "power"
-            and waveform_mode == "CW"
-            and self.echodata["Sonar/Beam_group2"] is not None
-        ):
-            beam = self.echodata["Sonar/Beam_group2"]
-        else:
-            beam = self.echodata["Sonar/Beam_group1"]
-
-        # Params from the Vendor_specific group
-
-        # only execute this if cw and power
-        if waveform_mode == "CW" and beam is not None:
-            params_from_vend = ["sa_correction", "gain_correction"]
-            for p in params_from_vend:
-                # substitute if None in user input
-                self.cal_params[p] = (
-                    cal_params[p]
-                    if p in cal_params
-                    else self._get_vend_cal_params_power(p, waveform_mode=waveform_mode)
-                )
-
-        # Other params
-        self.cal_params["equivalent_beam_angle"] = (
-            cal_params["equivalent_beam_angle"]
-            if "equivalent_beam_angle" in cal_params
-            else beam["equivalent_beam_angle"]
-        )
-
-    def _cal_power(self, cal_type, use_beam_power=False) -> xr.Dataset:
+    def _cal_power_samples(self, cal_type: str, power_ed_group: str = None) -> xr.Dataset:
         """Calibrate power data from EK60 and EK80.
 
         Parameters
         ----------
-        cal_type : str
+        cal_type: str
             'Sv' for calculating volume backscattering strength, or
             'TS' for calculating target strength
-        use_beam_power : bool
-            whether to use beam_power.
-            If ``True`` use ``echodata["Sonar/Beam_group2"]``;
-            if ``False`` use ``echodata["Sonar/Beam_group1"]``.
-            Note ``echodata["Sonar/Beam_group2"]`` could only exist for EK80 data.
+        power_ed_group:
+            The ``EchoData`` beam group path containing the power data
 
         Returns
         -------
@@ -154,24 +53,20 @@ def _cal_power(self, cal_type, use_beam_power=False) -> xr.Dataset:
             The calibrated dataset containing Sv or TS
         """
         # Select source of backscatter data
-        if use_beam_power:
-            beam = self.echodata["Sonar/Beam_group2"]
-        else:
-            beam = self.echodata["Sonar/Beam_group1"]
-
-        # Harmonize time coordinate between Beam_groupX data and env_params
-        for p in self.env_params.keys():
-            self.env_params[p] = self.echodata._harmonize_env_param_time(
-                self.env_params[p], ping_time=beam.ping_time
-            )
+        beam = self.echodata[power_ed_group]
 
         # Derived params
         wavelength = self.env_params["sound_speed"] / beam["frequency_nominal"]  # wavelength
-        range_meter = self.range_meter
+        # range_meter = self.range_meter
 
-        # Transmission loss
-        spreading_loss = 20 * np.log10(range_meter.where(range_meter >= 1, other=1))
-        absorption_loss = 2 * self.env_params["sound_absorption"] * range_meter
+        # TVG compensation with modified range
+        sound_speed = self.env_params["sound_speed"]
+        absorption = self.env_params["sound_absorption"]
+        tvg_mod_range = range_mod_TVG_EK(
+            self.echodata, self.ed_group, self.range_meter, sound_speed
+        )
+        spreading_loss = 20 * np.log10(tvg_mod_range)
+        absorption_loss = 2 * absorption * tvg_mod_range
 
         if cal_type == "Sv":
             # Calc gain
@@ -212,7 +107,7 @@ def _cal_power(self, cal_type, use_beam_power=False) -> xr.Dataset:
 
         # Attach calculated range (with units meter) into data set
         out = out.to_dataset()
-        out = out.merge(range_meter)
+        out = out.merge(self.range_meter)
 
         # Add frequency_nominal to data set
         out["frequency_nominal"] = beam["frequency_nominal"]
@@ -231,444 +126,212 @@ def _cal_power(self, cal_type, use_beam_power=False) -> xr.Dataset:
 
 class CalibrateEK60(CalibrateEK):
     def __init__(self, echodata, env_params, cal_params, **kwargs):
-        super().__init__(echodata, env_params)
+        super().__init__(echodata, env_params, cal_params)
 
-        # load env and cal parameters
-        self.get_env_params()
-        if cal_params is None:
-            cal_params = {}
-        self.get_cal_params(cal_params, waveform_mode="CW", encode_mode="power")
+        # Set sonar_type
+        self.sonar_type = "EK60"
 
-        # default to CW mode recorded as power samples
-        self.compute_range_meter(waveform_mode="CW", encode_mode="power")
+        # Get env_params
+        self.env_params = get_env_params_EK60(echodata=echodata, user_env_dict=env_params)
+        self.waveform_mode = "CW"
+        self.encode_mode = "power"
 
-    def get_env_params(self, **kwargs):
-        """Get env params using user inputs or values from data file.
+        # Compute range
+        self.compute_echo_range()
 
-        EK60 file by default contains only sound speed and absorption.
-        In cases when temperature, salinity, and pressure values are supplied
-        by the user simultaneously, the sound speed and absorption are re-calculated.
+        # Get the right ed_group for CW power samples
+        self.ed_group = retrieve_correct_beam_group(
+            echodata=self.echodata, waveform_mode=self.waveform_mode, encode_mode=self.encode_mode
+        )
 
-        Parameters
-        ----------
-        env_params : dict
-        """
-        # Re-calculate environment parameters if user supply all env variables
-        if (
-            ("temperature" in self.env_params)
-            and ("salinity" in self.env_params)
-            and ("pressure" in self.env_params)
-        ):
-            self.env_params["sound_speed"] = uwa.calc_sound_speed(
-                temperature=self.env_params["temperature"],
-                salinity=self.env_params["salinity"],
-                pressure=self.env_params["pressure"],
-            )
-            self.env_params["sound_absorption"] = uwa.calc_absorption(
-                frequency=self.echodata["Sonar/Beam_group1"]["frequency_nominal"],
-                temperature=self.env_params["temperature"],
-                salinity=self.env_params["salinity"],
-                pressure=self.env_params["pressure"],
-            )
-        # Otherwise get sound speed and absorption from user inputs or raw data file
-        else:
-            self.env_params["sound_speed"] = (
-                self.env_params["sound_speed"]
-                if "sound_speed" in self.env_params
-                else self.echodata["Environment"]["sound_speed_indicative"]
-            )
-            self.env_params["sound_absorption"] = (
-                self.env_params["sound_absorption"]
-                if "sound_absorption" in self.env_params
-                else self.echodata["Environment"]["absorption_indicative"]
-            )
+        # Set the channels to calibrate
+        # For EK60 this is all channels
+        self.chan_sel = self.echodata[self.ed_group]["channel"]
+        beam = self.echodata[self.ed_group]
+
+        # Get cal_params
+        self.cal_params = get_cal_params_EK(
+            waveform_mode=self.waveform_mode,
+            freq_center=beam["frequency_nominal"],
+            beam=beam,
+            vend=self.echodata["Vendor_specific"],
+            user_dict=self.cal_params,
+            sonar_type=self.sonar_type,
+        )
 
     def compute_Sv(self, **kwargs):
-        return self._cal_power(cal_type="Sv")
+        return self._cal_power_samples(cal_type="Sv", power_ed_group=self.ed_group)
 
     def compute_TS(self, **kwargs):
-        return self._cal_power(cal_type="TS")
+        return self._cal_power_samples(cal_type="TS", power_ed_group=self.ed_group)
 
 
 class CalibrateEK80(CalibrateEK):
-    fs = 1.5e6  # default full sampling frequency [Hz]
-    z_et = 75
-    z_er = 1000
+    # Default EK80 params: these parameters are only recorded in later versions of EK80 software
+    EK80_params = {}
+    EK80_params["z_et"] = 75  # transmit impedance
+    EK80_params["z_er"] = 1000  # receive impedance
+    EK80_params["fs"] = {  # default full sampling frequency [Hz]
+        "default": 1500000,
+        "GPT": 500000,
+        "SBT": 50000,
+        "WBAT": 1500000,
+        "WBT TUBE": 1500000,
+        "WBT MINI": 1500000,
+        "WBT": 1500000,
+        "WBT HP": 187500,
+        "WBT LF": 93750,
+    }
 
     def __init__(self, echodata, env_params, cal_params, waveform_mode, encode_mode):
-        super().__init__(echodata, env_params)
-
-        # initialize cal params
-        # cal params are those used by both complex and power data calibration
-        # TODO: add complex data-specific params, like the freq-dependent gain factor
-        self.cal_params = dict.fromkeys(CAL_PARAMS["EK"])
-        # TODO: make waveform_mode and encode_mode class attributes
-
-        # load env and cal parameters
-        self.get_env_params(waveform_mode=waveform_mode, encode_mode=encode_mode)
-        if cal_params is None:
-            cal_params = {}
-        self.get_cal_params(cal_params, waveform_mode=waveform_mode, encode_mode=encode_mode)
+        super().__init__(echodata, env_params, cal_params)
 
-        # self.range_meter computed under self._compute_cal()
-        # because the implementation is different depending on waveform_mode and encode_mode
-
-    def get_env_params(self, waveform_mode=None, encode_mode="complex"):
-        """Get env params using user inputs or values from data file.
-
-        EK80 file by default contains sound speed, temperature, depth, salinity, and acidity,
-        therefore absorption is always calculated unless it is supplied by the user.
-        In cases when temperature, salinity, and pressure values are supplied
-        by the user simultaneously, both the sound speed and absorption are re-calculated.
-
-        Parameters
-        ----------
-        env_params : dict
+        # Set sonar_type
+        self.sonar_type = "EK80"
 
-        waveform_mode : {"CW", "BB"}
-            Type of transmit waveform.
+        # The waveform and encode mode combination checked in calibrate/api.py::_compute_cal
+        # so just doing assignment here
+        self.waveform_mode = waveform_mode
+        self.encode_mode = encode_mode
+        self.echodata = echodata
 
-            - `"CW"` for narrowband transmission,
-              returned echoes recorded either as complex or power/angle samples
-            - (default) `"BB"` for broadband transmission,
-              returned echoes recorded as complex samples
-
-        encode_mode : {"complex", "power"}
-            Type of encoded return echo data.
-
-            - (default) `"complex"` for complex samples
-            - `"power"` for power/angle samples, only allowed when
-              the echosounder is configured for narrowband transmission
-        """
+        # Get the right ed_group given waveform and encode mode
+        self.ed_group = retrieve_correct_beam_group(
+            echodata=self.echodata, waveform_mode=self.waveform_mode, encode_mode=self.encode_mode
+        )
 
-        if (
-            encode_mode == "power"
-            and waveform_mode == "CW"
-            and self.echodata["Sonar/Beam_group2"] is not None
-        ):
-            beam = self.echodata["Sonar/Beam_group2"]
+        # Select the channels to calibrate
+        if self.encode_mode == "power":
+            # Power sample only possible under CW mode,
+            # and all power samples will live in the same group
+            self.chan_sel = self.echodata[self.ed_group]["channel"]
         else:
-            beam = self.echodata["Sonar/Beam_group1"]
+            # Complex samples can be CW or BB, so select based on waveform mode
+            chan_dict = self._get_chan_dict(self.echodata[self.ed_group])
+            self.chan_sel = chan_dict[self.waveform_mode]
 
-        # Use center frequency if in BB mode, else use nominal channel frequency
-        if waveform_mode == "BB":
-            freq = (beam["frequency_start"] + beam["frequency_end"]) / 2
-        else:
-            freq = beam["frequency_nominal"]
-
-        # Re-calculate environment parameters if user supply all env variables
-        if (
-            ("temperature" in self.env_params)
-            and ("salinity" in self.env_params)
-            and ("pressure" in self.env_params)
-        ):
-            self.env_params["sound_speed"] = uwa.calc_sound_speed(
-                temperature=self.env_params["temperature"],
-                salinity=self.env_params["salinity"],
-                pressure=self.env_params["pressure"],
-            )
-            self.env_params["sound_absorption"] = uwa.calc_absorption(
-                frequency=freq,
-                temperature=self.env_params["temperature"],
-                salinity=self.env_params["salinity"],
-                pressure=self.env_params["pressure"],
-            )
-        # Otherwise
-        #  get temperature, salinity, and pressure from raw data file
-        #  get sound speed from user inputs or raw data file
-        #  get absorption from user inputs or computing from env params stored in raw data file
-        else:
-            # pressure is encoded as "depth" in EK80  # TODO: change depth to pressure in EK80 file?
-            for p1, p2 in zip(
-                ["temperature", "salinity", "pressure"],
-                ["temperature", "salinity", "depth"],
-            ):
-                self.env_params[p1] = (
-                    self.env_params[p1]
-                    if p1 in self.env_params
-                    else self.echodata["Environment"][p2]
-                )
-            self.env_params["sound_speed"] = (
-                self.env_params["sound_speed"]
-                if "sound_speed" in self.env_params
-                else self.echodata["Environment"]["sound_speed_indicative"]
-            )
-            self.env_params["sound_absorption"] = (
-                self.env_params["sound_absorption"]
-                if "sound_absorption" in self.env_params
-                else uwa.calc_absorption(
-                    frequency=freq,
-                    temperature=self.env_params["temperature"],
-                    salinity=self.env_params["salinity"],
-                    pressure=self.env_params["pressure"],
-                )
-            )
+        # Subset of the right Sonar/Beam_groupX group given the selected channels
+        beam = self.echodata[self.ed_group].sel(channel=self.chan_sel)
 
-    def _get_vend_cal_params_complex(self, channel_id, filter_name, param_type):
-        """Get filter coefficients stored in the Vendor_specific group attributes.
-
-        Parameters
-        ----------
-        channel_id : str
-            channel id for which the param to be retrieved
-        filter_name : str
-            name of filter coefficients to retrieve
-        param_type : str
-            'coeff' or 'decimation'
-        """
-        if param_type == "coeff":
-            v = self.echodata["Vendor_specific"].attrs[
-                "%s %s filter_r" % (channel_id, filter_name)
-            ] + 1j * np.array(
-                self.echodata["Vendor_specific"].attrs["%s %s filter_i" % (channel_id, filter_name)]
-            )
-            if v.size == 1:
-                v = np.expand_dims(v, axis=0)  # expand dims for convolution
-            return v
+        # Use center frequency if in BB mode, else use nominal channel frequency
+        if self.waveform_mode == "BB":
+            # use true center frequency to interpolate for various cal params
+            self.freq_center = (beam["frequency_start"] + beam["frequency_end"]).sel(
+                channel=self.chan_sel
+            ).isel(beam=0).drop("beam") / 2
         else:
-            return self.echodata["Vendor_specific"].attrs[
-                "%s %s decimation" % (channel_id, filter_name)
-            ]
-
-    def _tapered_chirp(
-        self,
-        transmit_duration_nominal,
-        slope,
-        transmit_power,
-        frequency_nominal=None,
-        frequency_start=None,
-        frequency_end=None,
-    ):
-        """Create a baseline chirp template."""
-        if frequency_start is None and frequency_end is None:  # CW waveform
-            frequency_start = frequency_nominal
-            frequency_end = frequency_nominal
-
-        t = np.arange(0, transmit_duration_nominal, 1 / self.fs)
-        nwtx = int(2 * np.floor(slope * t.size))  # length of tapering window
-        wtx_tmp = np.hanning(nwtx)  # hanning window
-        nwtxh = int(np.round(nwtx / 2))  # half length of the hanning window
-        wtx = np.concatenate(
-            [wtx_tmp[0:nwtxh], np.ones((t.size - nwtx)), wtx_tmp[nwtxh:]]
-        )  # assemble full tapering window
-        y_tmp = (
-            np.sqrt((transmit_power / 4) * (2 * self.z_et))  # amplitude
-            * signal.chirp(t, frequency_start, t[-1], frequency_end)
-            * wtx
-        )  # taper and scale linear chirp
-        return y_tmp / np.max(np.abs(y_tmp)), t  # amp has no actual effect
-
-    def _filter_decimate_chirp(self, y, ch_id):
-        """Filter and decimate the chirp template.
-
-        Parameters
-        ----------
-        y : np.array
-            chirp from _tapered_chirp
-        ch_id : str
-            channel_id to select the right coefficients and factors
-        """
-        # filter coefficients and decimation factor
-        wbt_fil = self._get_vend_cal_params_complex(ch_id, "WBT", "coeff")
-        pc_fil = self._get_vend_cal_params_complex(ch_id, "PC", "coeff")
-        wbt_decifac = self._get_vend_cal_params_complex(ch_id, "WBT", "decimation")
-        pc_decifac = self._get_vend_cal_params_complex(ch_id, "PC", "decimation")
-
-        # WBT filter and decimation
-        ytx_wbt = signal.convolve(y, wbt_fil)
-        ytx_wbt_deci = ytx_wbt[0::wbt_decifac]
+            # use nominal channel frequency for CW pulse
+            self.freq_center = beam["frequency_nominal"].sel(channel=self.chan_sel)
+
+        # Get env_params: depends on waveform mode
+        self.env_params = get_env_params_EK80(
+            echodata=echodata,
+            freq=self.freq_center,
+            user_env_dict=env_params,
+            ed_group=self.ed_group,
+        )
 
-        # PC filter and decimation
-        if len(pc_fil.squeeze().shape) == 0:  # in case it is a single element
-            pc_fil = [pc_fil.squeeze()]
-        ytx_pc = signal.convolve(ytx_wbt_deci, pc_fil)
-        ytx_pc_deci = ytx_pc[0::pc_decifac]
-        ytx_pc_deci_time = np.arange(ytx_pc_deci.size) * 1 / self.fs * wbt_decifac * pc_decifac
+        # Get cal_params: depends on waveform and encode mode
+        self.cal_params = get_cal_params_EK(
+            waveform_mode=self.waveform_mode,
+            freq_center=self.freq_center,
+            beam=beam,  # already subset above
+            vend=self.echodata["Vendor_specific"].sel(channel=self.chan_sel),
+            user_dict=self.cal_params,
+        )
 
-        return ytx_pc_deci, ytx_pc_deci_time
+        # Compute echo range in meters
+        self.compute_echo_range(chan_sel=self.chan_sel)
 
     @staticmethod
-    def _get_tau_effective(ytx, fs_deci, waveform_mode):
-        """Compute effective pulse length.
-
-        Parameters
-        ----------
-        ytx :array
-            transmit signal
-        fs_deci : float
-            sampling frequency of the decimated (recorded) signal
-        waveform_mode : str
-            ``CW`` for CW-mode samples, either recorded as complex or power samples
-            ``BB`` for BB-mode samples, recorded as complex samples
+    def _get_chan_dict(beam: xr.Dataset) -> Dict:
         """
-        if waveform_mode == "BB":
-            ytxa = signal.convolve(ytx, np.flip(np.conj(ytx))) / np.linalg.norm(ytx) ** 2
-            ptxa = abs(ytxa) ** 2
-        elif waveform_mode == "CW":
-            ptxa = np.abs(ytx) ** 2  # energy of transmit signal
-        return ptxa.sum() / (
-            ptxa.max() * fs_deci
-        )  # TODO: verify fs_deci = 1.5e6 in spheroid data sets
-
-    def get_transmit_chirp(self, waveform_mode):
-        """Reconstruct transmit signal and compute effective pulse length.
-
-        Parameters
-        ----------
-        waveform_mode : str
-            ``CW`` for CW-mode samples, either recorded as complex or power samples
-            ``BB`` for BB-mode samples, recorded as complex samples
+        Build dict to select BB and CW channels from complex samples where data
+        from both waveform modes may co-exist.
         """
-        # Make sure it is BB mode data
-        if waveform_mode == "BB" and (
-            ("frequency_start" not in self.echodata["Sonar/Beam_group1"])
-            or ("frequency_end" not in self.echodata["Sonar/Beam_group1"])
-        ):
-            raise TypeError("File does not contain BB mode complex samples!")
-
-        y_all = {}
-        y_time_all = {}
-        tau_effective = {}
-        for chan in self.echodata["Sonar/Beam_group1"].channel.values:
-            # TODO: currently only deal with the case with
-            # a fixed tx key param values within a channel
-            if waveform_mode == "BB":
-                tx_param_names = [
-                    "transmit_duration_nominal",
-                    "slope",
-                    "transmit_power",
-                    "frequency_start",
-                    "frequency_end",
-                ]
-            else:
-                tx_param_names = [
-                    "transmit_duration_nominal",
-                    "slope",
-                    "transmit_power",
-                    "frequency_nominal",
-                ]
-            tx_params = {}
-            for p in tx_param_names:
-                tx_params[p] = np.unique(self.echodata["Sonar/Beam_group1"][p].sel(channel=chan))
-                if tx_params[p].size != 1:
-                    raise TypeError("File contains changing %s!" % p)
-            y_tmp, _ = self._tapered_chirp(**tx_params)
-
-            # Filter and decimate chirp template
-            fs_deci = (
-                1 / self.echodata["Sonar/Beam_group1"].sel(channel=chan)["sample_interval"].values
-            )
-            y_tmp, y_tmp_time = self._filter_decimate_chirp(y_tmp, chan)
-
-            # Compute effective pulse length
-            tau_effective_tmp = self._get_tau_effective(y_tmp, fs_deci, waveform_mode=waveform_mode)
-
-            y_all[chan] = y_tmp
-            y_time_all[chan] = y_tmp_time
-            tau_effective[chan] = tau_effective_tmp
+        # Use center frequency for each ping to select BB or CW channels
+        # when all samples are encoded as complex samples
+        if "frequency_start" in beam and "frequency_end" in beam:
+            # At least some channels are BB
+            # frequency_start and frequency_end are NaN for CW channels
+            freq_center = (beam["frequency_start"] + beam["frequency_end"]) / 2  # has beam dim
+
+            return {
+                # For BB: drop channels containing CW samples (nan in freq start/end)
+                "BB": freq_center.dropna(dim="channel").channel,
+                # For CW: drop channels containing BB samples (not nan in freq start/end)
+                "CW": freq_center.where(np.isnan(freq_center), drop=True).channel,
+            }
 
-        return y_all, y_time_all, tau_effective
+        else:
+            # All channels are CW
+            return {"BB": None, "CW": beam.channel}
 
-    def compress_pulse(self, chirp, chan_BB=None):
-        """Perform pulse compression on the backscatter data.
+    def _get_power_from_complex(
+        self,
+        beam: xr.Dataset,
+        chirp: Dict,
+        z_et,
+        z_er,
+    ) -> xr.DataArray:
+        """
+        Get power from complex samples.
 
         Parameters
         ----------
+        beam : xr.Dataset
+            EchoData["Sonar/Beam_group1"] with selected channel subset
         chirp : dict
-            transmit chirp replica indexed by channel_id
-        chan_BB : str
-            channels that transmit in BB mode
-            (since CW mode can be in mixed in complex samples too)
+            a dictionary containing transmit chirp for BB channels
+
+        Returns
+        -------
+        prx : xr.DataArray
+            Power computed from complex samples
         """
-        backscatter = self.echodata["Sonar/Beam_group1"]["backscatter_r"].sel(
-            channel=chan_BB
-        ) + 1j * self.echodata["Sonar/Beam_group1"]["backscatter_i"].sel(channel=chan_BB)
-
-        pc_all = []
-        for chan in chan_BB:
-            backscatter_chan = (
-                backscatter.sel(channel=chan)
-                .dropna(dim="range_sample", how="all")
-                .dropna(dim="beam", how="all")
-                .dropna(dim="ping_time")
-            )
-            replica = xr.DataArray(np.conj(chirp[str(chan.values)]), dims="window")
-            # Pulse compression via rolling
-            pc = (
-                backscatter_chan.rolling(range_sample=replica.size).construct("window").dot(replica)
-                / np.linalg.norm(chirp[str(chan.values)]) ** 2
-            )
-            # Expand dimension and add name to allow merge
-            pc = pc.expand_dims(dim="channel")
-            pc.name = "pulse_compressed_output"
-            pc_all.append(pc)
 
-        pc_merge = xr.merge(pc_all)
+        def _get_prx(sig):
+            return (
+                beam["beam"].size  # number of transducer sectors
+                * np.abs(sig.mean(dim="beam")) ** 2
+                / (2 * np.sqrt(2)) ** 2
+                * (np.abs(z_er + z_et) / z_er) ** 2
+                / z_et
+            )
 
-        return pc_merge
+        # Compute power
+        if self.waveform_mode == "BB":
+            pc = compress_pulse(
+                backscatter=beam["backscatter_r"] + 1j * beam["backscatter_i"], chirp=chirp
+            )  # has beam dim
+            prx = _get_prx(pc)  # ensure prx is xr.DataArray
+        else:
+            bs_cw = beam["backscatter_r"] + 1j * beam["backscatter_i"]
+            prx = _get_prx(bs_cw)
 
-    def _get_gain_for_complex(self, waveform_mode, chan_sel) -> xr.DataArray:
-        """Get gain factor for calibrating complex samples.
+        prx.name = "received_power"
 
-        Use values from ``gain_correction`` in the Vendor_specific group for CW mode samples,
-        or interpolate ``gain`` to the center frequency of each ping for BB mode samples
-        if nominal frequency is within the calibrated frequencies range
+        return prx
 
-        Parameters
-        ----------
-        waveform_mode : str
-            ``CW`` for CW-mode samples, either recorded as complex or power samples
-            ``BB`` for BB-mode samples, recorded as complex samples
-        chan_sel : xr.DataArray
-            Nominal channel for CW mode samples
-            and a xr.DataArray of selected channels for BB mode samples
+    def _get_B_theta_phi_m(self):
+        """
+        Get transceiver gain compensation for BB mode.
 
-        Returns
-        -------
-        An xr.DataArray
+        ref: https://github.com/CI-CMG/pyEcholab/blob/RHT-EK80-Svf/echolab2/instruments/EK80.py#L4263-L4274  # noqa
         """
-        if waveform_mode == "BB":
-            gain_single = self._get_vend_cal_params_power(
-                "gain_correction", waveform_mode=waveform_mode
-            )
-            gain = []
-            if "gain" in self.echodata["Vendor_specific"].data_vars:
-                # index using channel_id as order of frequency across channel can be arbitrary
-                # reference to freq_center in case some channels are CW complex samples
-                # (already dropped when computing freq_center in the calling function)
-                for ch_id in chan_sel:
-                    # if channel gain exists in data
-                    if ch_id in self.echodata["Vendor_specific"].cal_channel_id:
-                        gain_vec = self.echodata["Vendor_specific"].gain.sel(cal_channel_id=ch_id)
-                        gain_temp = (
-                            gain_vec.interp(
-                                cal_frequency=self.echodata[
-                                    "Vendor_specific"
-                                ].frequency_nominal.sel(channel=ch_id)
-                            ).drop(["cal_channel_id", "cal_frequency"])
-                        ).expand_dims("channel")
-                    # if no freq-dependent gain use CW gain
-                    else:
-                        gain_temp = (
-                            gain_single.sel(channel=ch_id)
-                            .reindex_like(
-                                self.echodata["Sonar/Beam_group1"].backscatter_r, method="nearest"
-                            )
-                            .expand_dims("channel")
-                        )
-                    gain_temp.name = "gain"
-                    gain.append(gain_temp)
-                gain = xr.merge(gain).gain  # select the single data variable
-            else:
-                gain = gain_single
-        elif waveform_mode == "CW":
-            gain = self._get_vend_cal_params_power(
-                "gain_correction", waveform_mode=waveform_mode
-            ).sel(channel=chan_sel)
-
-        return gain
-
-    def _cal_complex(self, cal_type, waveform_mode) -> xr.Dataset:
+        fac_along = (
+            np.abs(-self.cal_params["angle_offset_alongship"])
+            / (self.cal_params["beamwidth_alongship"] / 2)
+        ) ** 2
+        fac_athwart = (
+            np.abs(-self.cal_params["angle_offset_athwartship"])
+            / (self.cal_params["beamwidth_athwartship"] / 2)
+        ) ** 2
+        B_theta_phi_m = 0.5 * 6.0206 * (fac_along + fac_athwart - 0.18 * fac_along * fac_athwart)
+
+        return B_theta_phi_m
+
+    def _cal_complex_samples(self, cal_type: str, complex_ed_group: str) -> xr.Dataset:
         """Calibrate complex data from EK80.
 
         Parameters
@@ -676,143 +339,68 @@ def _cal_complex(self, cal_type, waveform_mode) -> xr.Dataset:
         cal_type : str
             'Sv' for calculating volume backscattering strength, or
             'TS' for calculating target strength
-        waveform_mode : {"CW", "BB"}
-            Type of transmit waveform.
-
-            - `"CW"` for narrowband transmission,
-              returned echoes recorded either as complex or power/angle samples
-            - `"BB"` for broadband transmission,
-              returned echoes recorded as complex samples
+        complex_ed_group : str
+            The ``EchoData`` beam group path containing complex data
 
         Returns
         -------
         xr.Dataset
             The calibrated dataset containing Sv or TS
         """
-        # Transmit replica and effective pulse length
-        chirp, _, tau_effective = self.get_transmit_chirp(waveform_mode=waveform_mode)
+        # Select source of backscatter data
+        beam = self.echodata[complex_ed_group].sel(channel=self.chan_sel)
+        vend = self.echodata["Vendor_specific"].sel(channel=self.chan_sel)
 
-        # use center frequency for each ping to select BB or CW channels
-        # when all samples are encoded as complex samples
-        if (
-            "frequency_start" in self.echodata["Sonar/Beam_group1"]
-            and "frequency_end" in self.echodata["Sonar/Beam_group1"]
-        ):
-            freq_center = (
-                self.echodata["Sonar/Beam_group1"]["frequency_start"]
-                + self.echodata["Sonar/Beam_group1"]["frequency_end"]
-            ) / 2  # has beam dim
-        else:
-            freq_center = None
+        # Get transmit signal
+        tx_coeff = get_filter_coeff(vend)
+        fs = self.cal_params["receiver_sampling_frequency"]
 
-        if waveform_mode == "BB":
-            if freq_center is None:
-                raise ValueError(
-                    "frequency_start and frequency_end should exist in BB mode data, "
-                    "double check the EchoData object!"
-                )
-            # if CW and BB complex samples co-exist
-            # drop those that contain CW samples (nan in freq start/end)
-            chan_sel = freq_center.dropna(dim="channel").channel
-
-            # backscatter data
-            pc = self.compress_pulse(chirp, chan_BB=chan_sel)  # has beam dim
-            prx = (
-                self.echodata["Sonar/Beam_group1"].beam.size
-                * np.abs(pc.mean(dim="beam")) ** 2
-                / (2 * np.sqrt(2)) ** 2
-                * (np.abs(self.z_er + self.z_et) / self.z_er) ** 2
-                / self.z_et
-            )
-        else:
-            if freq_center is None:
-                # when only have CW complex samples
-                chan_sel = self.echodata["Sonar/Beam_group1"].channel
-            else:
-                # if BB and CW complex samples co-exist
-                # drop those that contain BB samples (not nan in freq start/end)
-                chan_sel = freq_center.where(np.isnan(freq_center), drop=True).channel
-
-            # backscatter data
-            backscatter_cw = (
-                self.echodata["Sonar/Beam_group1"]["backscatter_r"]
-                + 1j * self.echodata["Sonar/Beam_group1"]["backscatter_i"]
-            )
-            prx = (
-                self.echodata["Sonar/Beam_group1"].beam.size
-                * np.abs(backscatter_cw.mean(dim="beam")) ** 2
-                / (2 * np.sqrt(2)) ** 2
-                * (np.abs(self.z_er + self.z_et) / self.z_er) ** 2
-                / self.z_et
-            )
-            prx.name = "received_power"
-            prx = prx.to_dataset()
-
-        # Compute derived params
-
-        # Harmonize time coordinate between Beam_groupX data and env_params
-        # Use self.echodata["Sonar/Beam_group1"] because complex sample is always in Beam_group1
-        for p in self.env_params.keys():
-            if "channel" in self.env_params[p].coords:
-                self.env_params[p] = self.env_params[p].sel(channel=chan_sel)
-            self.env_params[p] = self.echodata._harmonize_env_param_time(
-                self.env_params[p], ping_time=self.echodata["Sonar/Beam_group1"].ping_time
-            )
+        # Switch to use Anderson implementation for transmit chirp starting v0.6.4
+        tx, tx_time = get_transmit_signal(beam, tx_coeff, self.waveform_mode, fs)
 
-        sound_speed = self.env_params["sound_speed"]
-        absorption = self.env_params["sound_absorption"].sel(channel=chan_sel)
-        range_meter = self.range_meter.sel(channel=chan_sel)
-        if waveform_mode == "BB":
-            # use true center frequency for BB pulse
-            wavelength = sound_speed / self.echodata["Sonar/Beam_group1"].frequency_nominal.sel(
-                channel=chan_sel
-            )
+        # Params to clarity in use below
+        z_er = self.cal_params["impedance_receive"]
+        z_et = self.cal_params["impedance_transmit"]
+        gain = self.cal_params["gain_correction"]
 
-            # use true center frequency to interpolate for gain factor
-            gain = self._get_gain_for_complex(waveform_mode=waveform_mode, chan_sel=chan_sel)
+        # Transceiver gain compensation for BB mode
+        if self.waveform_mode == "BB":
+            gain = gain - self._get_B_theta_phi_m()
 
-        else:
-            # use nominal channel frequency for CW pulse
-            wavelength = sound_speed / self.echodata["Sonar/Beam_group1"].frequency_nominal.sel(
-                channel=chan_sel
-            )
+        absorption = self.env_params["sound_absorption"]
+        range_meter = self.range_meter
+        sound_speed = self.env_params["sound_speed"]
+        wavelength = sound_speed / self.freq_center
+        transmit_power = beam["transmit_power"]
 
-            # use nominal channel frequency to select gain factor
-            gain = self._get_gain_for_complex(waveform_mode=waveform_mode, chan_sel=chan_sel)
+        # TVG compensation with modified range
+        tvg_mod_range = range_mod_TVG_EK(self.echodata, self.ed_group, range_meter, sound_speed)
+        spreading_loss = 20 * np.log10(tvg_mod_range)
+        absorption_loss = 2 * absorption * tvg_mod_range
 
-        # Transmission loss
-        spreading_loss = 20 * np.log10(range_meter.where(range_meter >= 1, other=1))
-        absorption_loss = 2 * absorption * range_meter
+        # Get power from complex samples
+        prx = self._get_power_from_complex(beam=beam, chirp=tx, z_et=z_et, z_er=z_er)
 
-        # TODO: both Sv and TS are off by ~<0.5 dB from matlab outputs.
-        #  Is this due to the use of 'single' in matlab code?
+        # Compute based on cal_type
         if cal_type == "Sv":
-            # effective pulse length
-            tau_effective = xr.DataArray(
-                data=list(tau_effective.values()),
-                coords=[
-                    self.echodata["Sonar/Beam_group1"].channel,
-                    self.echodata["Sonar/Beam_group1"].ping_time,
-                ],
-                dims=["channel", "ping_time"],
-            ).sel(channel=chan_sel)
-
-            # other params
-            transmit_power = self.echodata["Sonar/Beam_group1"]["transmit_power"].sel(
-                channel=chan_sel
+            # Effective pulse length
+            # compute first assuming all channels are not GPT
+            tau_effective = get_tau_effective(
+                ytx_dict=tx,
+                fs_deci_dict={k: 1 / np.diff(v[:2]) for (k, v) in tx_time.items()},  # decimated fs
+                waveform_mode=self.waveform_mode,
+                channel=self.chan_sel,
+                ping_time=beam["ping_time"],
             )
-            # equivalent_beam_angle has beam dim
-            if waveform_mode == "BB":
-                psifc = self.echodata["Sonar/Beam_group1"]["equivalent_beam_angle"].sel(
-                    channel=chan_sel
-                ) + 10 * np.log10(
-                    self.echodata["Vendor_specific"].frequency_nominal.sel(channel=chan_sel)
-                    / freq_center
-                )
-            elif waveform_mode == "CW":
-                psifc = self.echodata["Sonar/Beam_group1"]["equivalent_beam_angle"].sel(
-                    channel=chan_sel
-                )
+            # Use pulse_duration in place of tau_effective for GPT channels
+            # below assumesthat all transmit parameters are identical
+            # and needs to be changed when allowing transmit parameters to vary by ping
+            ch_GPT = vend["transceiver_type"] == "GPT"
+            tau_effective[ch_GPT] = beam["transmit_duration_nominal"][ch_GPT].isel(ping_time=0)
+
+            # equivalent_beam_angle
+            # TODO: THIS ONE CARRIES THE BEAM DIMENSION AROUND
+            psifc = self.cal_params["equivalent_beam_angle"]
 
             out = (
                 10 * np.log10(prx)
@@ -823,27 +411,29 @@ def _cal_complex(self, cal_type, waveform_mode) -> xr.Dataset:
                 - 10 * np.log10(tau_effective)
                 - psifc
             )
-            out = out.rename_vars({list(out.data_vars.keys())[0]: "Sv"})
 
-        elif cal_type == "TS":
-            transmit_power = self.echodata["Sonar/Beam_group1"]["transmit_power"].sel(
-                channel=chan_sel
-            )
+            # Correct for sa_correction if CW mode
+            if self.waveform_mode == "CW":
+                out = out - 2 * self.cal_params["sa_correction"]
+
+            out.name = "Sv"
+            # out = out.rename_vars({list(out.data_vars.keys())[0]: "Sv"})
 
+        elif cal_type == "TS":
             out = (
                 10 * np.log10(prx)
                 + 2 * spreading_loss
-                + absorption_loss  # has beam dim
+                + absorption_loss
                 - 10 * np.log10(wavelength**2 * transmit_power / (16 * np.pi**2))
                 - 2 * gain
             )
-            out = out.rename_vars({list(out.data_vars.keys())[0]: "TS"})
+            out.name = "TS"
 
         # Attach calculated range (with units meter) into data set
-        out = out.merge(range_meter)
+        out = out.to_dataset().merge(range_meter)
 
         # Add frequency_nominal to data set
-        out["frequency_nominal"] = self.echodata["Sonar/Beam_group1"]["frequency_nominal"]
+        out["frequency_nominal"] = beam["frequency_nominal"]
 
         # Add env and cal parameters
         out = self._add_params_to_output(out)
@@ -851,9 +441,15 @@ def _cal_complex(self, cal_type, waveform_mode) -> xr.Dataset:
         # Squeeze out the beam dim
         # out has beam dim, which came from absorption and absorption_loss
         # self.cal_params["equivalent_beam_angle"] also has beam dim
-        return out.isel(beam=0).drop("beam")
 
-    def _compute_cal(self, cal_type, waveform_mode, encode_mode) -> xr.Dataset:
+        # TODO: out should not have beam dimension at this stage
+        # once that dimension is removed from equivalent_beam_angle
+        if "beam" in out.coords:
+            return out.isel(beam=0).drop("beam")
+        else:
+            return out
+
+    def _compute_cal(self, cal_type) -> xr.Dataset:
         """
         Private method to compute Sv or TS from EK80 data, called by compute_Sv or compute_TS.
 
@@ -863,167 +459,43 @@ def _compute_cal(self, cal_type, waveform_mode, encode_mode) -> xr.Dataset:
             'Sv' for calculating volume backscattering strength, or
             'TS' for calculating target strength
 
-        waveform_mode : {"CW", "BB"}
-            Type of transmit waveform.
-
-            - `"CW"` for narrowband transmission,
-              returned echoes recorded either as complex or power/angle samples
-            - `"BB"` for broadband transmission,
-              returned echoes recorded as complex samples
-
-        encode_mode : {"complex", "power"}
-            Type of encoded return echo data.
-
-            - `"complex"` for complex samples
-            - `"power"` for power/angle samples, only allowed when
-              the echosounder is configured for narrowband transmission
-
         Returns
         -------
         xr.Dataset
             An xarray Dataset containing either Sv or TS.
         """
-        # Raise error for wrong inputs
-        if waveform_mode not in ("BB", "CW"):
-            raise ValueError(
-                "Input waveform_mode not recognized! "
-                "waveform_mode must be either 'BB' or 'CW' for EK80 data."
-            )
-        if encode_mode not in ("complex", "power"):
-            raise ValueError(
-                "Input encode_mode not recognized! "
-                "encode_mode must be either 'complex' or 'power' for EK80 data."
-            )
+        # Set flag_complex: True-complex cal, False-power cal
+        flag_complex = (
+            True if self.waveform_mode == "BB" or self.encode_mode == "complex" else False
+        )
 
-        # Set flag_complex
-        #  - True: complex cal
-        #  - False: power cal
-        # BB: complex only, CW: complex or power
-        if waveform_mode == "BB":
-            if encode_mode == "power":  # BB waveform forces to collect complex samples
-                raise ValueError("encode_mode='power' not allowed when waveform_mode='BB'!")
-            flag_complex = True
-        else:  # waveform_mode="CW"
-            if encode_mode == "complex":
-                flag_complex = True
-            else:
-                flag_complex = False
-
-        # Raise error when waveform_mode and actual recording mode do not match
-        # This simple check is only possible for BB-only data,
-        #   since for data with both BB and CW complex samples,
-        #   frequency_start will exist in echodata["Sonar/Beam_group1"] for the BB channels
-        if waveform_mode == "BB" and "frequency_start" not in self.echodata["Sonar/Beam_group1"]:
-            raise ValueError("waveform_mode='BB' but broadband data not found!")
-
-        # Set use_beam_power
-        #  - True: use self.echodata["Sonar/Beam_group2"] for cal
-        #  - False: use self.echodata["Sonar/Beam_group1"] for cal
-        use_beam_power = False
-
-        # Warn user about additional data in the raw file if another type exists
-        # When both power and complex samples exist:
-        #   complex samples will be stored in echodata["Sonar/Beam_group1"]
-        #   power samples will be stored in echodata["Sonar/Beam_group2"]
-        # When only one type of samples exist,
-        #   all samples with be stored in echodata["Sonar/Beam_group1"]
-        if self.echodata["Sonar/Beam_group2"] is not None:  # both power and complex samples exist
-            # If both beam and beam_power groups exist,
-            #   this means that CW data are encoded as power samples and in beam_power group
-            if waveform_mode == "CW" and encode_mode == "complex":
-                raise ValueError("File does not contain CW complex samples")
-
-            if encode_mode == "power":
-                use_beam_power = True  # switch source of backscatter data
-                logger.info(
-                    "Only power samples are calibrated, but complex samples also exist in the raw data file!"  # noqa
-                )
-            else:
-                logger.info(
-                    "Only complex samples are calibrated, but power samples also exist in the raw data file!"  # noqa
-                )
-        else:  # only power OR complex samples exist
-            if (
-                "backscatter_i" in self.echodata["Sonar/Beam_group1"].variables
-            ):  # data contain only complex samples
-                if encode_mode == "power":
-                    raise TypeError(
-                        "File does not contain power samples! Use encode_mode='complex'"
-                    )  # user selects the wrong encode_mode
-            else:  # data contain only power samples
-                if encode_mode == "complex":
-                    raise TypeError(
-                        "File does not contain complex samples! Use encode_mode='power'"
-                    )  # user selects the wrong encode_mode
-
-        # Compute Sv
         if flag_complex:
             # Complex samples can be BB or CW
-            self.compute_range_meter(waveform_mode=waveform_mode, encode_mode=encode_mode)
-            ds_cal = self._cal_complex(cal_type=cal_type, waveform_mode=waveform_mode)
+            ds_cal = self._cal_complex_samples(cal_type=cal_type, complex_ed_group=self.ed_group)
         else:
             # Power samples only make sense for CW mode data
-            self.compute_range_meter(waveform_mode="CW", encode_mode=encode_mode)
-            ds_cal = self._cal_power(cal_type=cal_type, use_beam_power=use_beam_power)
+            ds_cal = self._cal_power_samples(cal_type=cal_type, power_ed_group=self.ed_group)
 
         return ds_cal
 
-    def compute_Sv(self, waveform_mode="BB", encode_mode="complex"):
+    def compute_Sv(self):
         """Compute volume backscattering strength (Sv).
 
-        Parameters
-        ----------
-        waveform_mode : {"CW", "BB"}
-            Type of transmit waveform.
-
-            - `"CW"` for narrowband transmission,
-              returned echoes recorded either as complex or power/angle samples
-            - (default) `"BB"` for broadband transmission,
-              returned echoes recorded as complex samples
-
-        encode_mode : {"complex", "power"}
-            Type of encoded return echo data.
-
-            - (default) `"complex"` for complex samples
-            - `"power"` for power/angle samples, only allowed when
-              the echosounder is configured for narrowband transmission
-
         Returns
         -------
         Sv : xr.DataSet
             A DataSet containing volume backscattering strength (``Sv``)
             and the corresponding range (``echo_range``) in units meter.
         """
-        return self._compute_cal(
-            cal_type="Sv", waveform_mode=waveform_mode, encode_mode=encode_mode
-        )
+        return self._compute_cal(cal_type="Sv")
 
-    def compute_TS(self, waveform_mode="BB", encode_mode="complex"):
+    def compute_TS(self):
         """Compute target strength (TS).
 
-        Parameters
-        ----------
-        waveform_mode : {"CW", "BB"}
-            Type of transmit waveform.
-
-            - `"CW"` for narrowband transmission,
-              returned echoes recorded either as complex or power/angle samples
-            - (default) `"BB"` for broadband transmission,
-              returned echoes recorded as complex samples
-
-        encode_mode : {"complex", "power"}
-            Type of encoded return echo data.
-
-            - (default) `"complex"` for complex samples
-            - `"power"` for power/angle samples, only allowed when
-              the echosounder is configured for narrowband transmission
-
         Returns
         -------
         TS : xr.DataSet
             A DataSet containing target strength (``TS``)
             and the corresponding range (``echo_range``) in units meter.
         """
-        return self._compute_cal(
-            cal_type="TS", waveform_mode=waveform_mode, encode_mode=encode_mode
-        )
+        return self._compute_cal(cal_type="TS")
diff --git a/echopype/calibrate/ecs_parser.py b/echopype/calibrate/ecs_parser.py
index 11366af55..5cf38d197 100644
--- a/echopype/calibrate/ecs_parser.py
+++ b/echopype/calibrate/ecs_parser.py
@@ -2,8 +2,6 @@
 from datetime import datetime
 from typing import Optional
 
-import numpy as np
-
 SEPARATOR = re.compile(r"#=+#\n")
 STATUS_CRUDE = re.compile(r"#\s+(?P<status>(.+))\s+#\n")  # noqa
 STATUS_FINE = re.compile(r"#\s+(?P<status>\w+) SETTINGS\s*#\n")  # noqa
@@ -112,7 +110,7 @@ def convert_type(input_dict):
                     if v not in self.TvgRangeCorrection_allowed_str:
                         raise ValueError("TvgRangeCorrection contains unexpected setting!")
                 else:
-                    input_dict[k] = np.float(v)
+                    input_dict[k] = float(v)
 
         for status, status_settings in self.parsed_params.items():
             if status == "fileset":  # fileset only has 1 layer of dict
diff --git a/echopype/calibrate/ek80_complex.py b/echopype/calibrate/ek80_complex.py
new file mode 100644
index 000000000..7a85f3ffe
--- /dev/null
+++ b/echopype/calibrate/ek80_complex.py
@@ -0,0 +1,339 @@
+from collections import defaultdict
+from typing import Dict, Literal, Union
+
+import numpy as np
+import xarray as xr
+from scipy import signal
+
+
+def tapered_chirp(
+    fs,
+    transmit_duration_nominal,
+    slope,
+    transmit_power,
+    implementation="Anderson",
+    z_et=None,
+    frequency_nominal=None,
+    frequency_start=None,
+    frequency_end=None,
+):
+    """Create a baseline chirp template."""
+    if frequency_start is None and frequency_end is None:  # CW waveform
+        frequency_start = frequency_nominal
+        frequency_end = frequency_nominal
+
+    if implementation == "Macaulay":
+        # z_et is required for Macaulay implementation
+        if z_et is None:
+            raise ValueError("z_et is needed for Macaulay implementation of transmit chirp!")
+
+        t = np.arange(0, transmit_duration_nominal, 1 / fs)
+        nwtx = int(2 * np.floor(slope * t.size))  # length of tapering window
+        wtx_tmp = np.hanning(nwtx)  # hanning window
+        nwtxh = int(np.round(nwtx / 2))  # half length of the hanning window
+        wtx = np.concatenate(
+            [wtx_tmp[0:nwtxh], np.ones((t.size - nwtx)), wtx_tmp[nwtxh:]]
+        )  # assemble full tapering window
+        chirp_fac = (
+            (frequency_end - frequency_start) / transmit_duration_nominal
+        ) * t / 2 + frequency_start
+        y_tmp = (
+            np.sqrt((transmit_power / 4) * (2 * z_et))  # amplitude
+            * np.cos(2 * np.pi * chirp_fac * t)  # chirp
+            * wtx  # tapering
+        )  # taper and scale linear chirp
+        return y_tmp / np.max(np.abs(y_tmp)), t  # amplitude needs to be normalized
+
+    elif implementation == "Anderson":
+        # Substitute to keep original form in Anderson implementation
+        # source: https://github.com/CRIMAC-WP4-Machine-learning/CRIMAC-Raw-To-Svf-TSf/blob/main/Core/Calculation.py  # noqa
+        tau = transmit_duration_nominal
+        f0 = frequency_start
+        f1 = frequency_end
+
+        nsamples = int(np.floor(tau * fs))
+        t = np.linspace(0, nsamples - 1, num=nsamples) * 1 / fs
+        a = np.pi * (f1 - f0) / tau
+        b = 2 * np.pi * f0
+        y = np.cos(a * t * t + b * t)
+        L = int(np.round(tau * fs * slope * 2.0))  # Length of hanning window
+        w = 0.5 * (1.0 - np.cos(2.0 * np.pi * np.arange(0, L, 1) / (L - 1)))
+        N = len(y)
+        w1 = w[0 : int(len(w) / 2)]
+        w2 = w[int(len(w) / 2) : -1]
+        i0 = 0
+        i1 = len(w1)
+        i2 = N - len(w2)
+        i3 = N
+        y[i0:i1] = y[i0:i1] * w1
+        y[i2:i3] = y[i2:i3] * w2
+
+        return y / np.max(y), t  # amplitude needs to be normalized
+
+    else:
+        raise ValueError("Input implementation type not recognized!")
+
+
+def filter_decimate_chirp(coeff_ch: Dict, y_ch: np.array, fs: float):
+    """Filter and decimate the transmit replica for one channel.
+
+    Parameters
+    ----------
+    coeff_ch : dict
+        a dictionary containing filter coefficients and decimation factors for ``ch_id``
+    y_ch : np.array
+        chirp from _tapered_chirp
+    fs : float
+        system sampling frequency [Hz]
+    """
+    # Get values
+
+    # WBT filter and decimation
+    ytx_wbt = signal.convolve(y_ch, coeff_ch["wbt_fil"])
+    ytx_wbt_deci = ytx_wbt[0 :: coeff_ch["wbt_decifac"]]
+
+    # PC filter and decimation
+    if len(coeff_ch["pc_fil"].squeeze().shape) == 0:  # in case it is a single element
+        coeff_ch["pc_fil"] = [coeff_ch["pc_fil"].squeeze()]
+    ytx_pc = signal.convolve(ytx_wbt_deci, coeff_ch["pc_fil"])
+    ytx_pc_deci = ytx_pc[0 :: coeff_ch["pc_decifac"]]
+    ytx_pc_deci_time = (
+        np.arange(ytx_pc_deci.size) * 1 / fs * coeff_ch["wbt_decifac"] * coeff_ch["pc_decifac"]
+    )
+
+    return ytx_pc_deci, ytx_pc_deci_time
+
+
+def get_vend_filter_EK80(
+    vend: xr.Dataset, channel_id: str, filter_name: str, param_type: Literal["coeff", "decimation"]
+) -> Union[np.ndarray, int]:
+    """
+    Get filter coefficients stored in the Vendor_specific group attributes.
+
+    Parameters
+    ----------
+    vend: xr.Dataset
+        An xr.Dataset from EchoData["Vendor_specific"]
+    channel_id : str
+        channel id for which the param to be retrieved
+    filter_name : str
+        name of filter coefficients to retrieve
+    param_type : str
+        'coeff' or 'decimation'
+
+    Returns
+    -------
+    np.ndarray or int
+        The filter coefficient or the decimation factor
+    """
+    if param_type == "coeff":
+        v = vend.attrs[f"{channel_id} {filter_name} filter_r"] + 1j * np.array(
+            vend.attrs[f"{channel_id} {filter_name} filter_i"]
+        )
+        if v.size == 1:
+            v = np.expand_dims(v, axis=0)  # expand dims for convolution
+        return v
+    else:
+        return vend.attrs[f"{channel_id} {filter_name} decimation"]
+
+
+def get_filter_coeff(vend: xr.Dataset) -> Dict:
+    """
+    Get WBT and PC filter coefficients for constructing the transmit replica.
+
+    Parameters
+    ----------
+    vend: xr.Dataset
+        An xr.Dataset from EchoData["Vendor_specific"]
+
+    Returns
+    -------
+    dict
+        A dictionary indexed by ``channel`` and values being dictionaries containing
+        filter coefficients and decimation factors for constructing the transmit replica.
+    """
+    coeff = defaultdict(dict)
+    for ch_id in vend["channel"].values:
+        # filter coefficients and decimation factor
+        coeff[ch_id]["wbt_fil"] = get_vend_filter_EK80(vend, ch_id, "WBT", "coeff")
+        coeff[ch_id]["pc_fil"] = get_vend_filter_EK80(vend, ch_id, "PC", "coeff")
+        coeff[ch_id]["wbt_decifac"] = get_vend_filter_EK80(vend, ch_id, "WBT", "decimation")
+        coeff[ch_id]["pc_decifac"] = get_vend_filter_EK80(vend, ch_id, "PC", "decimation")
+
+    return coeff
+
+
+def get_tau_effective(
+    ytx_dict: Dict[str, np.array],
+    fs_deci_dict: Dict[str, float],
+    waveform_mode: str,
+    channel: xr.DataArray,
+    ping_time: xr.DataArray,
+):
+    """Compute effective pulse length.
+
+    Parameters
+    ----------
+    ytx_dict : dict
+        A dict of transmit signals, with keys being the ``channel`` and
+        values being either a vector when the transmit signals are identical across all pings
+        or a 2D array when the transmit signals vary across ping
+    fs_deci_dict : dict
+        A dict of sampling frequency of the decimated (recorded) signal,
+        with keys being the ``channel``
+    waveform_mode : str
+        ``CW`` for CW-mode samples, either recorded as complex or power samples
+        ``BB`` for BB-mode samples, recorded as complex samples
+    """
+    tau_effective = {}
+    for ch, ytx in ytx_dict.items():
+        if waveform_mode == "BB":
+            ytxa = signal.convolve(ytx, np.flip(np.conj(ytx))) / np.linalg.norm(ytx) ** 2
+            ptxa = np.abs(ytxa) ** 2
+        elif waveform_mode == "CW":
+            ptxa = np.abs(ytx) ** 2  # energy of transmit signal
+        tau_effective[ch] = ptxa.sum() / (ptxa.max() * fs_deci_dict[ch])
+
+    # set up coordinates
+    if len(ytx.shape) == 1:  # ytx is a vector (transmit signals are identical across pings)
+        coords = {"channel": channel}
+    elif len(ytx.shape) == 2:  # ytx is a matrix (transmit signals vary across pings)
+        coords = {"channel": channel, "ping_time": ping_time}
+
+    vals = np.array(list(tau_effective.values())).squeeze()
+    if vals.size == 1:
+        vals = np.expand_dims(vals, axis=0)
+
+    tau_effective = xr.DataArray(
+        data=vals,
+        coords=coords,
+    )
+
+    return tau_effective
+
+
+def get_transmit_signal(
+    beam: xr.Dataset,
+    coeff: Dict,
+    waveform_mode: str,
+    fs: Union[float, xr.DataArray],
+):
+    """Reconstruct transmit signal and compute effective pulse length.
+
+    Parameters
+    ----------
+    beam : xr.Dataset
+        EchoData["Sonar/Beam_group1"] selected with channel subset
+    coeff : dict
+        a dictionary indexed by ``channel`` and values being dictionaries containing
+        filter coefficients and decimation factors for constructing the transmit replica.
+    waveform_mode : str
+        ``CW`` for CW-mode samples, either recorded as complex or power samples
+        ``BB`` for BB-mode samples, recorded as complex samples
+
+    Return
+    ------
+    y_all
+        Transmit replica (BB: broadband chirp, CW: constant frequency sinusoid)
+    y_time_all
+        Timestamp for the transmit replica
+    """
+    # Make sure it is BB mode data
+    # This is already checked in calibrate_ek
+    # but keeping this here for use as standalone function
+    if waveform_mode == "BB" and (("frequency_start" not in beam) or ("frequency_end" not in beam)):
+        raise TypeError("File does not contain BB mode complex samples!")
+
+    # Generate all transmit replica
+    y_all = {}
+    y_time_all = {}
+    for ch in beam["channel"].values:
+        # TODO: expand to deal with the case with varying tx param across ping_time
+        if waveform_mode == "BB":
+            tx_param_names = [
+                "transmit_duration_nominal",
+                "slope",
+                "transmit_power",
+                "frequency_start",
+                "frequency_end",
+            ]
+        else:
+            tx_param_names = [
+                "transmit_duration_nominal",
+                "slope",
+                "transmit_power",
+                "frequency_nominal",
+            ]
+        tx_params = {}
+        for p in tx_param_names:
+            tx_params[p] = np.unique(beam[p].sel(channel=ch))
+            if tx_params[p].size != 1:
+                raise TypeError("File contains changing %s!" % p)
+        fs_chan = fs.sel(channel=ch).data if isinstance(fs, xr.DataArray) else fs
+        tx_params["fs"] = fs_chan
+        y_ch, _ = tapered_chirp(**tx_params)
+
+        # Filter and decimate chirp template
+        y_ch, y_tmp_time = filter_decimate_chirp(coeff_ch=coeff[ch], y_ch=y_ch, fs=fs_chan)
+
+        # Fill into output dict
+        y_all[ch] = y_ch
+        y_time_all[ch] = y_tmp_time
+
+    return y_all, y_time_all
+
+
+def compress_pulse(backscatter: xr.DataArray, chirp: Dict) -> xr.DataArray:
+    """Perform pulse compression on the backscatter data.
+
+    Parameters
+    ----------
+    backscatter : xr.DataArray
+        complex backscatter samples
+    chirp : dict
+        transmit chirp replica indexed by ``channel``
+
+    Returns
+    -------
+    xr.DataArray
+        A data array containing pulse compression output.
+    """
+    pc_all = []
+    for chan in backscatter["channel"]:
+        backscatter_chan = (
+            backscatter.sel(channel=chan)
+            # .dropna(dim="range_sample", how="all")
+            .dropna(dim="beam", how="all")
+            # .dropna(dim="ping_time")
+        )
+
+        tx = chirp[str(chan.values)]
+        replica = np.flipud(np.conj(tx))
+        pc = xr.apply_ufunc(
+            lambda m: np.apply_along_axis(
+                lambda m: (
+                    signal.convolve(m, replica, mode="full")[tx.size - 1 :]
+                    / np.linalg.norm(tx) ** 2
+                ),
+                axis=2,
+                arr=m,
+            ),
+            backscatter_chan,
+            input_core_dims=[["range_sample"]],
+            output_core_dims=[["range_sample"]],
+            # exclude_dims={"range_sample"},
+        )
+
+        pc_all.append(pc)
+
+    pc_all = xr.DataArray(
+        pc_all,
+        coords={
+            "channel": backscatter["channel"],
+            "ping_time": backscatter["ping_time"],
+            "beam": backscatter["beam"],
+            "range_sample": backscatter["range_sample"],
+        },
+    )
+
+    return pc_all
diff --git a/echopype/calibrate/env_params.py b/echopype/calibrate/env_params.py
index ba6f2d588..593dd074e 100644
--- a/echopype/calibrate/env_params.py
+++ b/echopype/calibrate/env_params.py
@@ -1,203 +1,257 @@
-from typing import Dict, List
+import datetime
+from typing import Dict, Optional, Union
 
 import numpy as np
-import scipy.interpolate
 import xarray as xr
-from typing_extensions import Literal
-
-DataKind = Literal["stationary", "mobile", "organized"]
-InterpMethod = Literal["linear", "nearest", "zero", "slinear", "quadratic", "cubic"]
-ExtrapMethod = Literal["linear", "nearest"]
-VALID_INTERP_METHODS: Dict[DataKind, List[InterpMethod]] = {
-    "stationary": ["linear", "nearest", "zero", "slinear", "quadratic", "cubic"],
-    "mobile": ["linear", "nearest", "cubic"],
-    "organized": ["linear", "nearest"],
-}
-
-
-class EnvParams:
-    def __init__(
-        self,
-        env_params: xr.Dataset,
-        data_kind: DataKind,
-        interp_method: InterpMethod = "linear",
-        extrap_method: ExtrapMethod = "linear",
-    ):
-        """
-        Class to hold and interpolate external environmental data for calibration purposes.
-
-        This class can be used as the `env_params` parameter in `echopype.calibrate.compute_Sv`
-        or `echopype.calibrate.compute_TS`. It is intended to be used with environmental parameters
-        indexed by time. Environmental parameters will be interpolated onto dimensions within
-        the Platform group of the `EchoData` object being used for calibration.
-
-        Parameters
-        ----------
-        env_params : xr.Dataset
-            The environmental parameters to use for calibration. This data will be interpolated with
-            a provided `EchoData` object.
-
-            When `data_kind` is `"stationary"`, env_params must have a coordinate `"time3"`.
-            When `data_kind` is `"mobile"`, env_params must have coordinates `"latitude"`
-            and `"longitude"`.
-            When `data_kind` is `"organized"`, env_params must have coordinates `"time"`,
-            `"latitude"`, and `"longitude"`. This `data_kind` is not currently supported.
-        data_kind : {"stationary", "mobile", "organized"}
-            The type of the environmental parameters.
-
-            `"stationary"`: environmental parameters from a fixed location
-            (for example, a single CTD).
-            `"mobile"` environmental parameters from a moving location (for example, a ship).
-            `"organized"`: environmental parameters from many fixed locations
-            (for example, multiple CTDs).
-        interp_method: {"linear", "nearest", "zero", "slinear", "quadratic", "cubic"}
-            Method for interpolation of environmental parameters with the data from the
-            provided `EchoData` object.
-
-            When `data_kind` is `"stationary"`, valid `interp_method`s are `"linear"`, `"nearest"`,
-            `"zero"`, `"slinear"`, `"quadratic"`, and `"cubic"`
-            (see <https://docs.scipy.org/doc/scipy/reference/reference/generated/scipy.interpolate.interp1d.html>).
-            When `data_kind` is `"mobile"`, valid `interp_method`s are `"linear"`, `"nearest"`, and `"cubic"`
-            (see <https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.griddata.html>).
-            When `data_kind` is `"organized"`, valid `interp_method`s are `"linear"` and `"nearest"`
-            (see <https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.interpn.html>).
-        extrap_method: {"linear", "nearest"}
-            Method for extrapolation of environmental parameters with the data from the
-            provided `EchoData` object. Currently only supported when `data_kind` is `"stationary"`.
-
-        Notes
-        -----
-        Currently cases where `data_kind` is `"organized"` are not supported; support will be added
-        in a future version.
-
-        Examples
-        --------
-        >>> env_params = xr.open_dataset("env_params.nc")
-        >>> EnvParams(env_params, data_kind="mobile", interp_method="linear")
-        >>> echopype.calibrate.compute_Sv(echodata, env_params=env_params)
-        """  # noqa
-        if interp_method not in VALID_INTERP_METHODS[data_kind]:
-            raise ValueError(f"invalid interp_method {interp_method} for data_kind {data_kind}")
-
-        self.env_params = env_params
-        self.data_kind = data_kind
-        self.interp_method = interp_method
-        self.extrap_method = extrap_method
-
-    def _apply(self, echodata) -> Dict[str, xr.DataArray]:
-        if self.data_kind == "stationary":
-            dims = ["time3"]
-        elif self.data_kind == "mobile":
-            dims = ["latitude", "longitude"]
-        elif self.data_kind == "organized":
-            dims = ["time", "latitude", "longitude"]
-        else:
-            raise ValueError("invalid data_kind")
-
-        for dim in dims:
-            if dim not in echodata["Platform"]:
-                raise ValueError(
-                    f"could not interpolate env_params; EchoData is missing dimension {dim}"
-                )
-
-        env_params = self.env_params
-
-        if self.data_kind == "mobile":
-            if np.isnan(echodata["Platform"]["time1"]).all():
-                raise ValueError("cannot perform mobile interpolation without time1")
-            # compute_range needs indexing by ping_time
-            interp_plat = echodata["Platform"].interp(
-                {"time1": echodata["Sonar/Beam_group1"]["ping_time"]}
-            )
-
-            result = {}
-            for var, values in env_params.data_vars.items():
-                points = np.column_stack(
-                    (env_params["latitude"].data, env_params["longitude"].data)
-                )
-                values = values.data
-                xi = np.column_stack(
-                    (
-                        interp_plat["latitude"].data,
-                        interp_plat["longitude"].data,
-                    )
-                )
-                interp = scipy.interpolate.griddata(points, values, xi, method=self.interp_method)
-                result[var] = ("time1", interp)
-            env_params = xr.Dataset(
-                # we expect env_params to have coordinate time1
-                data_vars=result,
-                coords={"time1": interp_plat["ping_time"].data},
-            )
-        else:
-            # TODO: organized case
-            min_max = {
-                dim: {"min": env_params[dim].min(), "max": env_params[dim].max()} for dim in dims
-            }
-
-            extrap = env_params.interp(
-                {dim: echodata["Platform"][dim].data for dim in dims},
-                method=self.extrap_method,
-                # scipy interp uses "extrapolate" but scipy interpn uses None
-                kwargs={"fill_value": "extrapolate" if len(dims) == 1 else None},
-            )
-            # only keep unique indexes; xarray requires that indexes be unique
-            extrap_unique_idx = {dim: np.unique(extrap[dim], return_index=True)[1] for dim in dims}
-            extrap = extrap.isel(**extrap_unique_idx)
-            interp = env_params.interp(
-                {dim: echodata["Platform"][dim].data for dim in dims},
-                method=self.interp_method,
-            )
-            interp_unique_idx = {dim: np.unique(interp[dim], return_index=True)[1] for dim in dims}
-            interp = interp.isel(**interp_unique_idx)
-
-            if self.extrap_method is not None:
-                less = extrap.sel(
-                    {dim: extrap[dim][extrap[dim] < min_max[dim]["min"]] for dim in dims}
-                )
-                middle = interp.sel(
-                    {
-                        dim: interp[dim][
-                            np.logical_and(
-                                interp[dim] >= min_max[dim]["min"],
-                                interp[dim] <= min_max[dim]["max"],
-                            )
-                        ]
-                        for dim in dims
-                    }
-                )
-                greater = extrap.sel(
-                    {dim: extrap[dim][extrap[dim] > min_max[dim]["max"]] for dim in dims}
-                )
-
-                # remove empty datasets (xarray does not allow any dims from any datasets
-                # to be length 0 in combine_by_coords)
-                non_zero_dims = [
-                    ds
-                    for ds in (less, middle, greater)
-                    if all(dim_len > 0 for dim_len in ds.dims.values())
-                ]
-                env_params = xr.combine_by_coords(non_zero_dims)
-
-        # if self.data_kind == "organized":
-        #     # get platform latitude and longitude indexed by ping_time
-        #     interp_plat = echodata["Platform"].interp(
-        #         {"time": echodata["Platform"]["ping_time"]}
-        #     )
-        #     # get env_params latitude and longitude indexed by ping_time
-        #     env_params = env_params.interp(
-        #         {
-        #             "latitude": interp_plat["latitude"],
-        #             "longitude": interp_plat["longitude"],
-        #         }
-        #     )
-
-        if self.data_kind == "stationary":
-            # renaming time3 (from Platform group) to time1 because we expect
-            # environmental parameters to have dimension time1
-            return {
-                var: env_params[var].rename({"time3": "time1"})
-                for var in ("temperature", "salinity", "pressure")
-            }
+
+from ..echodata import EchoData
+from ..utils import uwa
+
+# TODO: create default dict with empty values but specific keys for out_dict
+# like in cal_params, right now it is initiated as {}
+
+
+def harmonize_env_param_time(
+    p: Union[int, float, xr.DataArray],
+    ping_time: Optional[Union[xr.DataArray, datetime.datetime]] = None,
+):
+    """
+    Harmonize time coordinate between Beam_groupX data and env_params to make sure
+    the timestamps are broadcast correctly in calibration and range calculations.
+
+    Regardless of the source, if `p` is an xr.DataArray, the time coordinate name
+    needs to be `time1` to be consistent with the time coordinate in EchoData["Environment"].
+    If `time1` is of length=1, the dimension `time1` is dropped.
+    Otherwise, `p` is interpolated to `ping_time`.
+    If `p` is not an xr.DataArray it is returned directly.
+
+    Parameters
+    ----------
+    p
+        The environment parameter for timestamp check/correction
+    ping_time
+        Beam_groupX ping_time to interpolate env_params timestamps to.
+        Only used if p.time1 has length >1
+
+    Returns
+    -------
+    Environment parameter with correctly broadcasted timestamps
+    """
+    if isinstance(p, xr.DataArray):
+        if "time1" not in p.coords:
+            return p
         else:
-            return {var: env_params[var] for var in ("temperature", "salinity", "pressure")}
+            # If there's only 1 time1 value,
+            # or if after dropping NaN there's only 1 time1 value
+            if p["time1"].size == 1 or p.dropna(dim="time1").size == 1:
+                return p.dropna(dim="time1").squeeze(dim="time1").drop("time1")
+
+            # Direct assignment if all timestamps are identical (EK60 data)
+            elif np.all(p["time1"].values == ping_time.values):
+                return p.rename({"time1": "ping_time"})
+
+            elif ping_time is None:
+                raise ValueError(f"ping_time needs to be provided for interpolating {p.name}")
+
+            else:
+                return p.dropna(dim="time1").interp(time1=ping_time)
+    else:
+        return p
+
+
+def get_env_params_AZFP(echodata: EchoData, user_env_dict: Optional[dict] = None):
+    """Get env params using user inputs or values from data file.
+
+    Parameters
+    ----------
+    echodata : EchoData
+        an echodata object containing the env params to be pulled from
+    user_env_dict : dict
+        user input dict containing env params
+
+    Returns
+    -------
+    A dictionary containing the calibration parameters.
+    """
+    out_dict = {}
+
+    # Temperature comes from either user input or data file
+    out_dict["temperature"] = user_env_dict.get(
+        "temperature", echodata["Environment"]["temperature"]
+    )
+
+    # Salinity and pressure always come from user input
+    if ("salinity" not in user_env_dict) or ("pressure" not in user_env_dict):
+        raise ReferenceError("Please supply both salinity and pressure in env_params.")
+    else:
+        out_dict["salinity"] = user_env_dict["salinity"]
+        out_dict["pressure"] = user_env_dict["pressure"]
+
+    # Always calculate sound speed and absorption
+    out_dict["sound_speed"] = uwa.calc_sound_speed(
+        temperature=out_dict["temperature"],
+        salinity=out_dict["salinity"],
+        pressure=out_dict["pressure"],
+        formula_source="AZFP",
+    )
+    out_dict["sound_absorption"] = uwa.calc_absorption(
+        frequency=echodata["Sonar/Beam_group1"]["frequency_nominal"],
+        temperature=out_dict["temperature"],
+        salinity=out_dict["salinity"],
+        pressure=out_dict["pressure"],
+        formula_source="AZFP",
+    )
+
+    # Harmonize time coordinate between Beam_groupX (ping_time) and env_params (time1)
+    # Note for AZFP data is always in Sonar/Beam_group1
+    for p in out_dict.keys():
+        out_dict[p] = harmonize_env_param_time(
+            out_dict[p], ping_time=echodata["Sonar/Beam_group1"]["ping_time"]
+        )
+
+    return out_dict
+
+
+def get_env_params_EK60(echodata: EchoData, user_env_dict: Optional[Dict] = None) -> Dict:
+    """Get env params using user inputs or values from data file.
+
+    EK60 file by default contains only sound speed and absorption.
+    In cases when temperature, salinity, and pressure values are supplied
+    by the user simultaneously, the sound speed and absorption are re-calculated.
+
+    Parameters
+    ----------
+    echodata : EchoData
+        an echodata object containing the env params to be pulled from
+    user_env_dict : dict
+        user input dict containing env params
+
+    Returns
+    -------
+    A dictionary containing the calibration parameters.
+    """
+    # Initiate input/output dict
+    out_dict = {}
+    if user_env_dict is None:
+        user_env_dict = {}
+
+    # Re-calculate environment parameters if user supply all env variables
+    tsp_all_exist = np.all([p in user_env_dict for p in ["temperature", "salinity", "pressure"]])
+
+    if tsp_all_exist:
+        out_dict["sound_speed"] = uwa.calc_sound_speed(
+            temperature=user_env_dict["temperature"],
+            salinity=user_env_dict["salinity"],
+            pressure=user_env_dict["pressure"],
+        )
+        out_dict["sound_absorption"] = uwa.calc_absorption(
+            frequency=echodata["Sonar/Beam_group1"]["frequency_nominal"],
+            temperature=user_env_dict["temperature"],
+            salinity=user_env_dict["salinity"],
+            pressure=user_env_dict["pressure"],
+        )
+    # Otherwise get sound speed and absorption from user inputs or raw data file
+    else:
+        p_map_dict = {
+            "sound_speed": "sound_speed_indicative",
+            "sound_absorption": "absorption_indicative",
+        }
+        for p_out, p_data in p_map_dict.items():
+            out_dict[p_out] = user_env_dict.get(p_out, echodata["Environment"][p_data])
+
+    # Harmonize time coordinate between Beam_groupX (ping_time) and env_params (time1)
+    # Note for EK60 data is always in Sonar/Beam_group1
+    for p in out_dict.keys():
+        out_dict[p] = harmonize_env_param_time(
+            out_dict[p], ping_time=echodata["Sonar/Beam_group1"]["ping_time"]
+        )
+
+    return out_dict
+
+
+def get_env_params_EK80(
+    echodata: EchoData,
+    freq: xr.DataArray,
+    user_env_dict: Optional[Dict] = None,
+    ed_group: str = None,
+) -> Dict:
+    """Get env params using user inputs or values from data file.
+
+    EK80 file by default contains sound speed, temperature, depth, salinity, and acidity,
+    therefore absorption is always calculated unless it is supplied by the user.
+    In cases when temperature, salinity, and pressure values are supplied
+    by the user simultaneously, both the sound speed and absorption are re-calculated.
+
+    Parameters
+    ----------
+    echodata : EchoData
+        an echodata object containing the env params to be pulled from
+    freq : xr.DataArray
+        center frequency for the selected channels
+    user_env_dict : dict
+        user input dict containing env params
+    ed_group : str
+        the right Sonar/Beam_groupX given waveform and encode mode
+
+    Returns
+    -------
+    A dictionary containing the environmental parameters.
+    """
+
+    # Initiate input/output dict
+    out_dict = {}
+    if user_env_dict is None:
+        user_env_dict = {}
+
+    # Re-calculate environment parameters if user supply all env variables
+    tsp_all_exist = np.all([p in user_env_dict for p in ["temperature", "salinity", "pressure"]])
+
+    if tsp_all_exist:
+        out_dict["sound_speed"] = uwa.calc_sound_speed(
+            temperature=user_env_dict["temperature"],
+            salinity=user_env_dict["salinity"],
+            pressure=user_env_dict["pressure"],
+        )
+        out_dict["sound_absorption"] = uwa.calc_absorption(
+            frequency=freq,
+            temperature=user_env_dict["temperature"],
+            salinity=user_env_dict["salinity"],
+            pressure=user_env_dict["pressure"],
+        )
+    # Otherwise
+    #  get temperature, salinity, and pressure from raw data file
+    #  get sound speed from user inputs or raw data file
+    #  get absorption from user inputs or computing from env params stored in raw data file
+    else:
+        # pressure is encoded as "depth" in EK80  # TODO: change depth to pressure in EK80 file?
+        for p_user, p_data in zip(
+            ["temperature", "salinity", "pressure", "pH", "sound_speed"],
+            ["temperature", "salinity", "depth", "acidity", "sound_speed_indicative"],
+        ):
+            out_dict[p_user] = user_env_dict.get(p_user, echodata["Environment"][p_data])
+
+        out_dict["sound_absorption"] = user_env_dict.get(
+            "sound_absorption",
+            uwa.calc_absorption(
+                frequency=freq,
+                temperature=out_dict["temperature"],
+                salinity=out_dict["salinity"],
+                pressure=out_dict["pressure"],
+                sound_speed=out_dict["sound_speed"],
+                pH=out_dict["pH"],
+                formula_source=(
+                    user_env_dict["formula_source"] if "formula_source" in user_env_dict else "FG"
+                ),
+            ),
+        )
+
+    # Harmonize time coordinate between Beam_groupX (ping_time) and env_params (time1)
+    for p in out_dict.keys():
+        if isinstance(out_dict[p], xr.DataArray):
+            if "channel" in out_dict[p].coords:
+                out_dict[p] = out_dict[p].sel(channel=freq["channel"])  # only ch subset needed
+        out_dict[p] = harmonize_env_param_time(
+            out_dict[p], ping_time=echodata[ed_group]["ping_time"]
+        )
+
+    return out_dict
diff --git a/echopype/calibrate/env_params_old.py b/echopype/calibrate/env_params_old.py
new file mode 100644
index 000000000..ba6f2d588
--- /dev/null
+++ b/echopype/calibrate/env_params_old.py
@@ -0,0 +1,203 @@
+from typing import Dict, List
+
+import numpy as np
+import scipy.interpolate
+import xarray as xr
+from typing_extensions import Literal
+
+DataKind = Literal["stationary", "mobile", "organized"]
+InterpMethod = Literal["linear", "nearest", "zero", "slinear", "quadratic", "cubic"]
+ExtrapMethod = Literal["linear", "nearest"]
+VALID_INTERP_METHODS: Dict[DataKind, List[InterpMethod]] = {
+    "stationary": ["linear", "nearest", "zero", "slinear", "quadratic", "cubic"],
+    "mobile": ["linear", "nearest", "cubic"],
+    "organized": ["linear", "nearest"],
+}
+
+
+class EnvParams:
+    def __init__(
+        self,
+        env_params: xr.Dataset,
+        data_kind: DataKind,
+        interp_method: InterpMethod = "linear",
+        extrap_method: ExtrapMethod = "linear",
+    ):
+        """
+        Class to hold and interpolate external environmental data for calibration purposes.
+
+        This class can be used as the `env_params` parameter in `echopype.calibrate.compute_Sv`
+        or `echopype.calibrate.compute_TS`. It is intended to be used with environmental parameters
+        indexed by time. Environmental parameters will be interpolated onto dimensions within
+        the Platform group of the `EchoData` object being used for calibration.
+
+        Parameters
+        ----------
+        env_params : xr.Dataset
+            The environmental parameters to use for calibration. This data will be interpolated with
+            a provided `EchoData` object.
+
+            When `data_kind` is `"stationary"`, env_params must have a coordinate `"time3"`.
+            When `data_kind` is `"mobile"`, env_params must have coordinates `"latitude"`
+            and `"longitude"`.
+            When `data_kind` is `"organized"`, env_params must have coordinates `"time"`,
+            `"latitude"`, and `"longitude"`. This `data_kind` is not currently supported.
+        data_kind : {"stationary", "mobile", "organized"}
+            The type of the environmental parameters.
+
+            `"stationary"`: environmental parameters from a fixed location
+            (for example, a single CTD).
+            `"mobile"` environmental parameters from a moving location (for example, a ship).
+            `"organized"`: environmental parameters from many fixed locations
+            (for example, multiple CTDs).
+        interp_method: {"linear", "nearest", "zero", "slinear", "quadratic", "cubic"}
+            Method for interpolation of environmental parameters with the data from the
+            provided `EchoData` object.
+
+            When `data_kind` is `"stationary"`, valid `interp_method`s are `"linear"`, `"nearest"`,
+            `"zero"`, `"slinear"`, `"quadratic"`, and `"cubic"`
+            (see <https://docs.scipy.org/doc/scipy/reference/reference/generated/scipy.interpolate.interp1d.html>).
+            When `data_kind` is `"mobile"`, valid `interp_method`s are `"linear"`, `"nearest"`, and `"cubic"`
+            (see <https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.griddata.html>).
+            When `data_kind` is `"organized"`, valid `interp_method`s are `"linear"` and `"nearest"`
+            (see <https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.interpn.html>).
+        extrap_method: {"linear", "nearest"}
+            Method for extrapolation of environmental parameters with the data from the
+            provided `EchoData` object. Currently only supported when `data_kind` is `"stationary"`.
+
+        Notes
+        -----
+        Currently cases where `data_kind` is `"organized"` are not supported; support will be added
+        in a future version.
+
+        Examples
+        --------
+        >>> env_params = xr.open_dataset("env_params.nc")
+        >>> EnvParams(env_params, data_kind="mobile", interp_method="linear")
+        >>> echopype.calibrate.compute_Sv(echodata, env_params=env_params)
+        """  # noqa
+        if interp_method not in VALID_INTERP_METHODS[data_kind]:
+            raise ValueError(f"invalid interp_method {interp_method} for data_kind {data_kind}")
+
+        self.env_params = env_params
+        self.data_kind = data_kind
+        self.interp_method = interp_method
+        self.extrap_method = extrap_method
+
+    def _apply(self, echodata) -> Dict[str, xr.DataArray]:
+        if self.data_kind == "stationary":
+            dims = ["time3"]
+        elif self.data_kind == "mobile":
+            dims = ["latitude", "longitude"]
+        elif self.data_kind == "organized":
+            dims = ["time", "latitude", "longitude"]
+        else:
+            raise ValueError("invalid data_kind")
+
+        for dim in dims:
+            if dim not in echodata["Platform"]:
+                raise ValueError(
+                    f"could not interpolate env_params; EchoData is missing dimension {dim}"
+                )
+
+        env_params = self.env_params
+
+        if self.data_kind == "mobile":
+            if np.isnan(echodata["Platform"]["time1"]).all():
+                raise ValueError("cannot perform mobile interpolation without time1")
+            # compute_range needs indexing by ping_time
+            interp_plat = echodata["Platform"].interp(
+                {"time1": echodata["Sonar/Beam_group1"]["ping_time"]}
+            )
+
+            result = {}
+            for var, values in env_params.data_vars.items():
+                points = np.column_stack(
+                    (env_params["latitude"].data, env_params["longitude"].data)
+                )
+                values = values.data
+                xi = np.column_stack(
+                    (
+                        interp_plat["latitude"].data,
+                        interp_plat["longitude"].data,
+                    )
+                )
+                interp = scipy.interpolate.griddata(points, values, xi, method=self.interp_method)
+                result[var] = ("time1", interp)
+            env_params = xr.Dataset(
+                # we expect env_params to have coordinate time1
+                data_vars=result,
+                coords={"time1": interp_plat["ping_time"].data},
+            )
+        else:
+            # TODO: organized case
+            min_max = {
+                dim: {"min": env_params[dim].min(), "max": env_params[dim].max()} for dim in dims
+            }
+
+            extrap = env_params.interp(
+                {dim: echodata["Platform"][dim].data for dim in dims},
+                method=self.extrap_method,
+                # scipy interp uses "extrapolate" but scipy interpn uses None
+                kwargs={"fill_value": "extrapolate" if len(dims) == 1 else None},
+            )
+            # only keep unique indexes; xarray requires that indexes be unique
+            extrap_unique_idx = {dim: np.unique(extrap[dim], return_index=True)[1] for dim in dims}
+            extrap = extrap.isel(**extrap_unique_idx)
+            interp = env_params.interp(
+                {dim: echodata["Platform"][dim].data for dim in dims},
+                method=self.interp_method,
+            )
+            interp_unique_idx = {dim: np.unique(interp[dim], return_index=True)[1] for dim in dims}
+            interp = interp.isel(**interp_unique_idx)
+
+            if self.extrap_method is not None:
+                less = extrap.sel(
+                    {dim: extrap[dim][extrap[dim] < min_max[dim]["min"]] for dim in dims}
+                )
+                middle = interp.sel(
+                    {
+                        dim: interp[dim][
+                            np.logical_and(
+                                interp[dim] >= min_max[dim]["min"],
+                                interp[dim] <= min_max[dim]["max"],
+                            )
+                        ]
+                        for dim in dims
+                    }
+                )
+                greater = extrap.sel(
+                    {dim: extrap[dim][extrap[dim] > min_max[dim]["max"]] for dim in dims}
+                )
+
+                # remove empty datasets (xarray does not allow any dims from any datasets
+                # to be length 0 in combine_by_coords)
+                non_zero_dims = [
+                    ds
+                    for ds in (less, middle, greater)
+                    if all(dim_len > 0 for dim_len in ds.dims.values())
+                ]
+                env_params = xr.combine_by_coords(non_zero_dims)
+
+        # if self.data_kind == "organized":
+        #     # get platform latitude and longitude indexed by ping_time
+        #     interp_plat = echodata["Platform"].interp(
+        #         {"time": echodata["Platform"]["ping_time"]}
+        #     )
+        #     # get env_params latitude and longitude indexed by ping_time
+        #     env_params = env_params.interp(
+        #         {
+        #             "latitude": interp_plat["latitude"],
+        #             "longitude": interp_plat["longitude"],
+        #         }
+        #     )
+
+        if self.data_kind == "stationary":
+            # renaming time3 (from Platform group) to time1 because we expect
+            # environmental parameters to have dimension time1
+            return {
+                var: env_params[var].rename({"time3": "time1"})
+                for var in ("temperature", "salinity", "pressure")
+            }
+        else:
+            return {var: env_params[var] for var in ("temperature", "salinity", "pressure")}
diff --git a/echopype/calibrate/range.py b/echopype/calibrate/range.py
new file mode 100644
index 000000000..e4945ef0a
--- /dev/null
+++ b/echopype/calibrate/range.py
@@ -0,0 +1,238 @@
+from typing import Dict
+
+import xarray as xr
+
+from ..echodata import EchoData
+from ..echodata.simrad import retrieve_correct_beam_group
+from .env_params import harmonize_env_param_time
+
+DIMENSION_ORDER = ["channel", "ping_time", "range_sample"]
+
+
+def compute_range_AZFP(echodata: EchoData, env_params: Dict, cal_type: str) -> xr.DataArray:
+    """
+    Computes the range (``echo_range``) of AZFP backscatter data in meters.
+
+    Parameters
+    ----------
+    echodata : EchoData
+        An EchoData object holding data from an AZFP echosounder
+    env_params : dict
+        A dictionary holding environmental parameters needed for computing range
+        See echopype.calibrate.env_params.get_env_params_AZFP()
+    cal_type : {"Sv", "TS"}
+
+        - `"Sv"` for calculating volume backscattering strength
+        - `"TS"` for calculating target strength.
+
+        This parameter needs to be specified for data from the AZFP echosounder
+        due to a difference in the range computation given by the manufacturer
+
+    Returns
+    -------
+    xr.DataArray
+        The range (``echo_range``) of the data in meters.
+
+    Notes
+    -----
+    For AZFP echosounder, the returned ``echo_range`` is duplicated along ``ping_time``
+    to conform with outputs from other echosounders, even though within each data
+    file the range is held constant.
+    """
+    # sound_speed should exist already
+    if "sound_speed" not in env_params:
+        raise RuntimeError(
+            "sounds_speed not included in env_params, "
+            "use echopype.calibrate.env_params.get_env_params_AZFP() to compute env_params "
+            "by supplying temperature, salinity, and pressure."
+        )
+    else:
+        sound_speed = env_params["sound_speed"]
+
+    # Check cal_type
+    if cal_type is None:
+        raise ValueError('cal_type must be "Sv" or "TS"')
+
+    # Groups to use
+    vend = echodata["Vendor_specific"]
+    beam = echodata["Sonar/Beam_group1"]
+
+    # Notation below follows p.86 of user manual
+    N = vend["number_of_samples_per_average_bin"]  # samples per bin
+    f = vend["digitization_rate"]  # digitization rate
+    L = vend["lockout_index"]  # number of lockout samples
+
+    # keep this in ref of AZFP matlab code,
+    # set to 1 since we want to calculate from raw data
+    bins_to_avg = 1
+
+    # Harmonize sound_speed time1 and Beam_group1 ping_time
+    sound_speed = harmonize_env_param_time(
+        p=sound_speed,
+        ping_time=beam.ping_time,
+    )
+
+    # Calculate range using parameters for each freq
+    # This is "the range to the centre of the sampling volume
+    # for bin m" from p.86 of user manual
+    if cal_type == "Sv":
+        range_offset = 0
+    else:
+        range_offset = sound_speed * beam["transmit_duration_nominal"] / 4  # from matlab code
+    range_meter = (
+        sound_speed * L / (2 * f)
+        + (sound_speed / 4)
+        * (
+            ((2 * (beam["range_sample"] + 1) - 1) * N * bins_to_avg - 1) / f
+            + beam["transmit_duration_nominal"]
+        )
+        - range_offset
+    )
+
+    # add name to facilitate xr.merge
+    range_meter.name = "echo_range"
+
+    # make order of dims conform with the order of backscatter data
+    return range_meter.transpose(*DIMENSION_ORDER)
+
+
+def compute_range_EK(
+    echodata: EchoData,
+    env_params: Dict,
+    waveform_mode: str = "CW",
+    encode_mode: str = "power",
+    chan_sel=None,
+):
+    """
+    Computes the range (``echo_range``) of EK backscatter data in meters.
+
+    Parameters
+    ----------
+    echodata : EchoData
+        An EchoData object holding data from an AZFP echosounder
+    env_params : dict
+        A dictionary holding environmental parameters needed for computing range
+        See echopype.calibrate.env_params.get_env_params_EK60() or .get_env_params_EK80()
+    waveform_mode : {"CW", "BB"}
+        Type of transmit waveform.
+        Required only for data from the EK80 echosounder.
+
+        - `"CW"` for narrowband transmission,
+            returned echoes recorded either as complex or power/angle samples
+        - `"BB"` for broadband transmission,
+            returned echoes recorded as complex samples
+
+    encode_mode : {"complex", "power"}
+        Type of encoded data format.
+        Required only for data from the EK80 echosounder.
+
+        - `"complex"` for complex samples
+        - `"power"` for power/angle samples, only allowed when
+          the echosounder is configured for narrowband transmission
+
+    Returns
+    -------
+    xr.DataArray
+        The range (``echo_range``) of the data in meters.
+
+    Notes
+    -----
+    The EK80 echosounder can be configured to transmit
+    either broadband (``waveform_mode="BB"``) or narrowband (``waveform_mode="CW"``) signals.
+    When transmitting in broadband mode, the returned echoes must be
+    encoded as complex samples (``encode_mode="complex"``).
+    When transmitting in narrowband mode, the returned echoes can be encoded
+    either as complex samples (``encode_mode="complex"``)
+    or as power/angle combinations (``encode_mode="power"``) in a format
+    similar to those recorded by EK60 echosounders (the "power/angle" format).
+    """
+    # sound_speed should exist already
+    if echodata.sonar_model in ("EK60", "ES70"):
+        ek_str = "EK60"
+    elif echodata.sonar_model in ("EK80", "ES80", "EA640"):
+        ek_str = "EK80"
+    else:
+        raise ValueError("The specified sonar_model is not supported!")
+
+    if "sound_speed" not in env_params:
+        raise RuntimeError(
+            "sounds_speed not included in env_params, "
+            f"use echopype.calibrate.env_params.get_env_params_{ek_str}() to compute env_params "
+        )
+    else:
+        sound_speed = env_params["sound_speed"]
+
+    # Get the right Sonar/Beam_groupX group according to encode_mode
+    ed_group = retrieve_correct_beam_group(echodata, waveform_mode, encode_mode)
+    beam = echodata[ed_group] if chan_sel is None else echodata[ed_group].sel(channel=chan_sel)
+
+    # Harmonize sound_speed time1 and Beam_groupX ping_time
+    sound_speed = harmonize_env_param_time(
+        p=sound_speed,
+        ping_time=beam.ping_time,
+    )
+
+    # Range in meters, not modified for TVG compensation
+    range_meter = beam["range_sample"] * beam["sample_interval"] * sound_speed / 2
+    # make order of dims conform with the order of backscatter data
+    range_meter = range_meter.transpose(*DIMENSION_ORDER)
+
+    # set entries with NaN backscatter data to NaN
+    if "beam" in beam["backscatter_r"].dims:
+        # Drop beam because echo_range does not have beam dimension
+        valid_idx = ~beam["backscatter_r"].isel(beam=0).drop("beam").isnull()
+    else:
+        valid_idx = ~beam["backscatter_r"].isnull()
+    range_meter = range_meter.where(valid_idx)
+
+    # remove time1 if exists as a coordinate
+    if "time1" in range_meter.coords:
+        range_meter = range_meter.drop("time1")
+
+    # add name to facilitate xr.merge
+    range_meter.name = "echo_range"
+
+    return range_meter
+
+
+def range_mod_TVG_EK(
+    echodata: EchoData, ed_group: str, range_meter: xr.DataArray, sound_speed: xr.DataArray
+) -> xr.DataArray:
+    """
+    Modify range for TVG calculation.
+
+    TVG correction factor changes depending when the echo recording starts
+    wrt when the transmit signal is sent out.
+    This depends on whether it is Ex60 or Ex80 style hardware
+    ref: https://github.com/CI-CMG/pyEcholab/blob/RHT-EK80-Svf/echolab2/instruments/EK80.py#L4297-L4308  # noqa
+    """
+
+    def mod_Ex60():
+        # 2-sample shift in the beginning
+        return 2 * beam["sample_interval"] * sound_speed / 2  # [frequency x range_sample]
+
+    def mod_Ex80():
+        mod = sound_speed * beam["transmit_duration_nominal"] / 4
+        if isinstance(mod, xr.DataArray) and "time1" in mod.coords:
+            mod = mod.squeeze().drop("time1")
+        return mod
+
+    beam = echodata[ed_group]
+    vend = echodata["Vendor_specific"]
+
+    # If EK60
+    if echodata.sonar_model in ["EK60", "ES70"]:
+        range_meter = range_meter - mod_Ex60()
+
+    # If EK80:
+    # - compute range first assuming all channels have Ex80 style hardware
+    # - change range for channels with Ex60 style hardware (GPT)
+    elif echodata.sonar_model in ["EK80", "ES80", "EA640"]:
+        range_meter = range_meter - mod_Ex80()
+
+        # Change range for all channels with GPT
+        if "GPT" in vend["transceiver_type"]:
+            ch_GPT = vend["transceiver_type"] == "GPT"
+            range_meter.loc[dict(channel=ch_GPT)] = range_meter.sel(channel=ch_GPT) - mod_Ex60()
+
+    return range_meter
diff --git a/echopype/consolidate/__init__.py b/echopype/consolidate/__init__.py
index d26813ab8..acca09fd8 100644
--- a/echopype/consolidate/__init__.py
+++ b/echopype/consolidate/__init__.py
@@ -1,3 +1,3 @@
-from .api import add_depth, add_location, swap_dims_channel_frequency
+from .api import add_depth, add_location, add_splitbeam_angle, swap_dims_channel_frequency
 
-__all__ = ["swap_dims_channel_frequency", "add_depth", "add_location"]
+__all__ = ["swap_dims_channel_frequency", "add_depth", "add_location", "add_splitbeam_angle"]
diff --git a/echopype/consolidate/api.py b/echopype/consolidate/api.py
index de81481bc..b17ee6041 100644
--- a/echopype/consolidate/api.py
+++ b/echopype/consolidate/api.py
@@ -1,10 +1,15 @@
 import datetime
-from typing import Optional
+import pathlib
+from typing import Optional, Union
 
 import numpy as np
 import xarray as xr
 
+from ..calibrate.ek80_complex import get_filter_coeff
 from ..echodata import EchoData
+from ..echodata.simrad import retrieve_correct_beam_group
+from ..utils.io import validate_source_ds_da
+from .split_beam_angle import add_angle_to_ds, get_angle_complex_samples, get_angle_power_samples
 
 
 def swap_dims_channel_frequency(ds: xr.Dataset) -> xr.Dataset:
@@ -72,7 +77,7 @@ def add_depth(
 
     Returns
     -------
-    The input dataset with a `depth` variable added
+    The input dataset with a `depth` variable (in meters) added
 
     Notes
     -----
@@ -111,7 +116,14 @@ def add_depth(
 
     # Compute depth
     ds["depth"] = mult * ds["echo_range"] * np.cos(tilt / 180 * np.pi) + depth_offset
-    ds["depth"].attrs = {"long_name": "Depth", "standard_name": "depth"}
+    ds["depth"].attrs = {"long_name": "Depth", "standard_name": "depth", "units": "m"}
+
+    # Add history attribute
+    history_attr = (
+        f"{datetime.datetime.utcnow()} +00:00. "
+        "Added based on echo_range or other data in Sv dataset."  # noqa
+    )
+    ds["depth"] = ds["depth"].assign_attrs({"history": history_attr})
 
     return ds
 
@@ -166,11 +178,191 @@ def sel_interp(var):
     interp_ds["longitude"] = sel_interp("longitude")
     # Most attributes are attached automatically via interpolation
     # here we add the history
-    history = (
+    history_attr = (
         f"{datetime.datetime.utcnow()} +00:00. "
         "Interpolated or propagated from Platform latitude/longitude."  # noqa
     )
-    interp_ds["latitude"] = interp_ds["latitude"].assign_attrs({"history": history})
-    interp_ds["longitude"] = interp_ds["longitude"].assign_attrs({"history": history})
+    for da_name in ["latitude", "longitude"]:
+        interp_ds[da_name] = interp_ds[da_name].assign_attrs({"history": history_attr})
 
     return interp_ds.drop_vars("time1")
+
+
+def add_splitbeam_angle(
+    source_Sv: Union[xr.Dataset, str, pathlib.Path],
+    echodata: EchoData,
+    waveform_mode: str,
+    encode_mode: str,
+    pulse_compression: bool = False,
+    storage_options: dict = {},
+    return_dataset: bool = True,
+) -> xr.Dataset:
+    """
+    Add split-beam (alongship/athwartship) angles into the Sv dataset.
+    This function calculates the alongship/athwartship angle using data stored
+    in the Sonar/Beam_groupX groups of an EchoData object.
+
+    In cases when angle data does not already exist or cannot be computed from the data,
+    an error is issued and no angle variables are added to the dataset.
+
+    Parameters
+    ----------
+    source_Sv: xr.Dataset or str or pathlib.Path
+        The Sv Dataset or path to a file containing the Sv Dataset,
+        to which the split-beam angles will be added
+    echodata: EchoData
+        An ``EchoData`` object holding the raw data
+    waveform_mode : {"CW", "BB"}
+        Type of transmit waveform
+
+        - ``"CW"`` for narrowband transmission,
+          returned echoes recorded either as complex or power/angle samples
+        - ``"BB"`` for broadband transmission,
+          returned echoes recorded as complex samples
+
+    encode_mode : {"complex", "power"}
+        Type of encoded return echo data
+
+        - ``"complex"`` for complex samples
+        - ``"power"`` for power/angle samples, only allowed when
+          the echosounder is configured for narrowband transmission
+    pulse_compression: bool, False
+        Whether pulse compression should be used (only valid for
+        ``waveform_mode="BB"`` and ``encode_mode="complex"``)
+    storage_options: dict, default={}
+        Any additional parameters for the storage backend, corresponding to the
+        path provided for ``source_Sv``
+    return_dataset: bool, default=True
+        If ``True``, ``source_Sv`` with split-beam angles added will be returned.
+        ``return_dataset=False`` is useful when ``source_Sv`` is a path and
+        users only want to write the split-beam angle data to this path.
+
+    Returns
+    -------
+    xr.Dataset or None
+        If ``return_dataset=False``, nothing will be returned.
+        If ``return_dataset=True``, either the input dataset ``source_Sv``
+        or a lazy-loaded Dataset (from the path ``source_Sv``)
+        with split-beam angles added will be returned.
+
+    Raises
+    ------
+    ValueError
+        If ``echodata`` has a sonar model that is not analogous to either EK60 or EK80
+    ValueError
+        If the input ``source_Sv`` does not have a ``channel`` dimension
+    ValueError
+        If ``source_Sv`` does not have appropriate dimension lengths in
+        comparison to ``echodata`` data
+    ValueError
+        If the provided ``waveform_mode``, ``encode_mode``, and ``pulse_compression`` are not valid
+    NotImplementedError
+        If an unknown ``beam_type`` is encountered during the split-beam calculation
+
+    Notes
+    -----
+    Split-beam angle data potentially exist for the Simrad EK60 or EK80 echosounders
+    with split-beam transducers and configured to store angle data (along with power samples)
+    or store raw complex samples.
+
+    In most cases where the type of samples collected by the echosounder (power/angle
+    samples or complex samples) and the transmit waveform (broadband or narrowband)
+    are identical across all channels, the channels existing in ``source_Sv`` and `
+    `echodata`` will be identical. If this is not the case, only angle data corresponding
+    to channels existing in ``source_Sv`` will be added.
+    """
+
+    # ensure that echodata was produced by EK60 or EK80-like sensors
+    if echodata.sonar_model not in ["EK60", "ES70", "EK80", "ES80", "EA640"]:
+        raise ValueError(
+            "The sonar model that produced echodata does not have split-beam "
+            "transducers, split-beam angles cannot be added to source_Sv!"
+        )
+
+    # validate the source_Sv type or path (if it is provided)
+    source_Sv, file_type = validate_source_ds_da(source_Sv, storage_options)
+
+    # initialize source_Sv_path
+    source_Sv_path = None
+
+    if isinstance(source_Sv, str):
+        # store source_Sv path so we can use it to write to later
+        source_Sv_path = source_Sv
+
+        # TODO: In the future we can improve this by obtaining the variable names, channels,
+        #  and dimension lengths directly from source_Sv using zarr or netcdf4. This would
+        #  prevent the unnecessary loading in of the coordinates, which the below statement does.
+        # open up Dataset using source_Sv path
+        source_Sv = xr.open_dataset(source_Sv, engine=file_type, chunks={}, **storage_options)
+
+    # raise not implemented error if source_Sv corresponds to MVBS
+    if source_Sv.attrs["processing_function"] == "preprocess.compute_MVBS":
+        raise NotImplementedError("Adding split-beam data to MVBS has not been implemented!")
+
+    # check that the appropriate waveform and encode mode have been given
+    # and obtain the echodata group path corresponding to encode_mode
+    encode_mode_ed_group = retrieve_correct_beam_group(echodata, waveform_mode, encode_mode)
+
+    # check that source_Sv at least has a channel dimension
+    if "channel" not in source_Sv.variables:
+        raise ValueError("The input source_Sv Dataset must have a channel dimension!")
+
+    # Select ds_beam channels from source_Sv
+    ds_beam = echodata[encode_mode_ed_group].sel(channel=source_Sv["channel"].values)
+
+    # Assemble angle param dict
+    angle_param_list = [
+        "angle_sensitivity_alongship",
+        "angle_sensitivity_athwartship",
+        "angle_offset_alongship",
+        "angle_offset_athwartship",
+    ]
+    angle_params = {}
+    for p_name in angle_param_list:
+        angle_params[p_name] = source_Sv[p_name]
+
+    # fail if source_Sv and ds_beam do not have the same lengths
+    # for ping_time, range_sample, and channel
+    same_dim_lens = [
+        ds_beam.dims[dim] == source_Sv.dims[dim] for dim in ["channel", "ping_time", "range_sample"]
+    ]
+    if not same_dim_lens:
+        raise ValueError(
+            "The 'source_Sv' dataset does not have the same dimensions as data in 'echodata'!"
+        )
+
+    # obtain split-beam angles from
+    # CW mode data
+    if waveform_mode == "CW":
+        if encode_mode == "power":  # power data
+            theta, phi = get_angle_power_samples(ds_beam, angle_params)
+        else:  # complex data
+            # operation is identical with BB complex data
+            theta, phi = get_angle_complex_samples(ds_beam, angle_params)
+    # BB mode data
+    else:
+        if pulse_compression:  # with pulse compression
+            # put receiver fs into the same dict for simplicity
+            pc_params = get_filter_coeff(
+                echodata["Vendor_specific"].sel(channel=source_Sv["channel"].values)
+            )
+            pc_params["receiver_sampling_frequency"] = source_Sv["receiver_sampling_frequency"]
+            theta, phi = get_angle_complex_samples(ds_beam, angle_params, pc_params)
+        else:  # without pulse compression
+            # operation is identical with CW complex data
+            theta, phi = get_angle_complex_samples(ds_beam, angle_params)
+
+    # add theta and phi to source_Sv input
+    source_Sv = add_angle_to_ds(
+        theta, phi, source_Sv, return_dataset, source_Sv_path, file_type, storage_options
+    )
+
+    # Add history attribute
+    history_attr = (
+        f"{datetime.datetime.utcnow()} +00:00. "
+        "Calculated using data stored in the Beam groups of the echodata object."  # noqa
+    )
+    for da_name in ["angle_alongship", "angle_athwartship"]:
+        source_Sv[da_name] = source_Sv[da_name].assign_attrs({"history": history_attr})
+
+    return source_Sv
diff --git a/echopype/consolidate/split_beam_angle.py b/echopype/consolidate/split_beam_angle.py
new file mode 100644
index 000000000..5b37b1b85
--- /dev/null
+++ b/echopype/consolidate/split_beam_angle.py
@@ -0,0 +1,330 @@
+"""
+Contains functions necessary to compute the split-beam (alongship/athwartship)
+angles and add them to a Dataset.
+"""
+from typing import List, Optional, Tuple
+
+import numpy as np
+import xarray as xr
+
+from ..calibrate.ek80_complex import compress_pulse, get_transmit_signal
+
+
+def _compute_angle_from_complex(
+    bs: xr.DataArray, beam_type: int, sens: List[xr.DataArray], offset: List[xr.DataArray]
+) -> Tuple[xr.DataArray, xr.DataArray]:
+    """
+    Compute split-beam angles from raw data from transducer sectors.
+
+    Can be used for data from a single channel or multiple channels,
+    depending on what is in ``bs``.
+
+    Parameters
+    ----------
+    bs: xr.DataArray
+        Complex backscatter samples from a single channel or multiple channels
+    beam_type: int
+        The type of beam being considered
+    sens: list of xr.DataArray
+        A list of length two where the first element corresponds to the
+        angle sensitivity alongship and the second corresponds to the
+        angle sensitivity athwartship
+    offset: list of xr.DataArray
+        A list of length two where the first element corresponds to the
+        angle offset alongship and the second corresponds to the
+        angle offset athwartship
+
+    Returns
+    -------
+    theta: xr.DataArray
+        The calculated split-beam alongship angle for a specific channel
+    phi: xr.DataArray
+        The calculated split-beam athwartship angle for a specific channel
+
+    Notes
+    -----
+    This function should only be used for data with complex backscatter.
+    """
+
+    # 4-sector transducer
+    if beam_type == 1:
+        bs_fore = (bs.isel(beam=2) + bs.isel(beam=3)) / 2  # forward
+        bs_aft = (bs.isel(beam=0) + bs.isel(beam=1)) / 2  # aft
+        bs_star = (bs.isel(beam=0) + bs.isel(beam=3)) / 2  # starboard
+        bs_port = (bs.isel(beam=1) + bs.isel(beam=2)) / 2  # port
+
+        bs_theta = bs_fore * np.conj(bs_aft)
+        bs_phi = bs_star * np.conj(bs_port)
+        theta = np.arctan2(np.imag(bs_theta), np.real(bs_theta)) / np.pi * 180
+        phi = np.arctan2(np.imag(bs_phi), np.real(bs_phi)) / np.pi * 180
+
+    # 3-sector transducer with or without center element
+    elif beam_type in [17, 49, 65, 81]:
+        # 3-sector
+        if beam_type == 17:
+            bs_star = bs.isel(beam=0)
+            bs_port = bs.isel(beam=1)
+            bs_fore = bs.isel(beam=2)
+        else:
+            # 3-sector + 1 center element
+            bs_star = (bs.isel(beam=0) + bs.isel(beam=3)) / 2
+            bs_port = (bs.isel(beam=1) + bs.isel(beam=3)) / 2
+            bs_fore = (bs.isel(beam=2) + bs.isel(beam=3)) / 2
+
+        bs_fac1 = bs_fore * np.conj(bs_star)
+        bs_fac2 = bs_fore * np.conj(bs_port)
+        fac1 = np.arctan2(np.imag(bs_fac1), np.real(bs_fac1)) / np.pi * 180
+        fac2 = np.arctan2(np.imag(bs_fac2), np.real(bs_fac2)) / np.pi * 180
+
+        theta = (fac1 + fac2) / np.sqrt(3)
+        phi = fac2 - fac1
+
+    # EC150–3C
+    elif beam_type == 97:
+        raise NotImplementedError
+
+    else:
+        raise ValueError("beam_type not recognized!")
+
+    theta = theta / sens[0] - offset[0]
+    phi = phi / sens[1] - offset[1]
+
+    return theta, phi
+
+
+def get_angle_power_samples(
+    ds_beam: xr.Dataset, angle_params: dict
+) -> Tuple[xr.Dataset, xr.Dataset]:
+    """
+    Obtain split-beam angle from CW mode power samples.
+
+    Parameters
+    ----------
+    ds_beam: xr.Dataset
+        An ``EchoData`` Sonar/Beam_group1 group (complex samples always in Beam_group1)
+    angle_params : dict
+        A dictionary containing angle_offset/angle_sensitivity parameters
+        from the calibrated dataset
+
+    Returns
+    -------
+    theta: xr.Dataset
+        Split-beam alongship angle
+    phi: xr.Dataset
+        Split-beam athwartship angle
+
+    Notes
+    -----
+    Can be used on both EK60 and EK80 data
+
+    Computation done for ``beam_type=1``:
+    ``physical_angle = ((raw_angle * 180 / 128) / sensitivity) - offset``
+    """
+
+    # raw_angle scaling constant
+    conversion_const = 180.0 / 128.0
+
+    def _e2f(angle_type: str) -> xr.Dataset:
+        """Convert electric angle to physical angle for split-beam data"""
+        return (
+            conversion_const
+            * ds_beam[f"angle_{angle_type}"]
+            / angle_params[f"angle_sensitivity_{angle_type}"]
+            - angle_params[f"angle_offset_{angle_type}"]
+        )
+
+    # add split-beam angle if at least one channel is split-beam
+    # in the case when some channels are split-beam and some single-beam
+    # the single-beam channels will be all NaNs and _e2f would run through and output NaNs
+    if not np.all(ds_beam["beam_type"].data == 0):
+        theta = _e2f(angle_type="alongship")  # split-beam alongship angle
+        phi = _e2f(angle_type="athwartship")  # split-beam athwartship angle
+
+    else:
+        raise ValueError(
+            "Computing physical split-beam angle is only available for data "
+            "from split-beam transducers!"
+        )
+
+    # drop the beam dimension in theta and phi, if it exists
+    # this cannot be removed because beam dimension exists in all power data (beam dim: length=1)
+    if "beam" in theta.dims:
+        theta = theta.drop("beam").squeeze(dim="beam")
+        phi = phi.drop("beam").squeeze(dim="beam")
+
+    return theta, phi
+
+
+def get_angle_complex_samples(
+    ds_beam: xr.Dataset, angle_params: dict, pc_params: dict = None
+) -> Tuple[xr.DataArray, xr.DataArray]:
+    """
+    Obtain split-beam angle from CW or BB mode complex samples.
+
+    Parameters
+    ----------
+    ds_beam : xr.Dataset
+        An ``EchoData`` Sonar/Beam_group1 group (complex samples always in Beam_group1)
+    angle_params : dict
+        A dictionary containing angle_offset/angle_sensitivity parameters
+        from the calibrated dataset
+    pc_params : dict
+        Parameters needed for pulse compression
+        This dict also serves as a flag for whether to apply pulse compression
+
+    Returns
+    -------
+    theta : xr.Dataset
+        Split-beam alongship angle
+    phi : xr.Dataset
+        Split-beam athwartship angle
+    """
+
+    # Get complex backscatter samples
+    bs = ds_beam["backscatter_r"] + 1j * ds_beam["backscatter_i"]
+
+    # Pulse compression if pc_params exists
+    if pc_params is not None:
+        tx, tx_time = get_transmit_signal(
+            beam=ds_beam,
+            coeff=pc_params,  # this is filter_coeff with fs added
+            waveform_mode="BB",
+            fs=pc_params["receiver_sampling_frequency"],  # this is the added fs
+        )
+        bs = compress_pulse(backscatter=bs, chirp=tx)  # has beam dim
+
+    # Compute angles
+    # unique beam_type existing in the dataset
+    beam_type_all_ch = np.unique(ds_beam["beam_type"].isel(ping_time=0).data)
+
+    if beam_type_all_ch.size == 1:
+        # If beam_type is the same for all channels, process all channels at once
+        theta, phi = _compute_angle_from_complex(
+            bs=bs,
+            beam_type=beam_type_all_ch[0],  # beam_type for all channels
+            sens=[
+                angle_params["angle_sensitivity_alongship"],
+                angle_params["angle_sensitivity_athwartship"],
+            ],
+            offset=[
+                angle_params["angle_offset_alongship"],
+                angle_params["angle_offset_athwartship"],
+            ],
+        )
+    else:
+        # beam_type different for some channels, process each channel separately
+        theta, phi = [], []
+        for ch_id in bs["channel"].data:
+            theta_ch, phi_ch = _compute_angle_from_complex(
+                bs=bs.sel(channel=ch_id),
+                # beam_type is not time-varying
+                beam_type=(
+                    ds_beam["beam_type"].sel(channel=ch_id).isel(ping_time=0).drop("ping_time")
+                ),
+                sens=[
+                    angle_params["angle_sensitivity_alongship"].sel(channel=ch_id),
+                    angle_params["angle_sensitivity_athwartship"].sel(channel=ch_id),
+                ],
+                offset=[
+                    angle_params["angle_offset_alongship"].sel(channel=ch_id),
+                    angle_params["angle_offset_athwartship"].sel(channel=ch_id),
+                ],
+            )
+            theta.append(theta_ch)
+            phi.append(phi_ch)
+
+        # Combine angles from all channels
+        theta = xr.DataArray(
+            data=theta,
+            coords={
+                "channel": bs["channel"],
+                "ping_time": bs["ping_time"],
+                "range_sample": bs["range_sample"],
+            },
+        )
+        phi = xr.DataArray(
+            data=phi,
+            coords={
+                "channel": bs["channel"],
+                "ping_time": bs["ping_time"],
+                "range_sample": bs["range_sample"],
+            },
+        )
+
+    return theta, phi
+
+
+def add_angle_to_ds(
+    theta: xr.Dataset,
+    phi: xr.Dataset,
+    ds: xr.Dataset,
+    return_dataset: bool,
+    source_ds_path: Optional[str] = None,
+    file_type: Optional[str] = None,
+    storage_options: dict = {},
+) -> Optional[xr.Dataset]:
+    """
+    Adds the split-beam angle data to the provided input ``ds``.
+
+    Parameters
+    ----------
+    theta: xr.Dataset
+        The calculated split-beam alongship angle
+    phi: xr.Dataset
+        The calculated split-beam athwartship angle
+    ds: xr.Dataset
+        The Dataset that ``theta`` and ``phi`` will be added to
+    return_dataset: bool
+        Whether a dataset will be returned or not
+    source_ds_path: str, optional
+        The path to the file corresponding to ``ds``, if it exists
+    file_type: {"netcdf4", "zarr"}, optional
+        The file type corresponding to ``source_ds_path``
+    storage_options: dict, default={}
+        Any additional parameters for the storage backend, corresponding to the
+        path ``source_ds_path``
+
+    Returns
+    -------
+    xr.Dataset or None
+        If ``return_dataset=False``, nothing will be returned. If ``return_dataset=True``
+        either the input dataset ``ds`` or a lazy-loaded Dataset (obtained from
+        the path provided by ``source_ds_path``) with the split-beam angle data added
+        will be returned.
+    """
+
+    # TODO: do we want to add anymore attributes to these variables?
+    # add appropriate attributes to theta and phi
+    theta.attrs["long_name"] = "split-beam alongship angle"
+    phi.attrs["long_name"] = "split-beam athwartship angle"
+
+    if source_ds_path is not None:
+        # put the variables into a Dataset, so they can be written at the same time
+        # add ds attributes to splitb_ds since they will be overwritten by to_netcdf/zarr
+        splitb_ds = xr.Dataset(
+            data_vars={"angle_alongship": theta, "angle_athwartship": phi},
+            coords=theta.coords,
+            attrs=ds.attrs,
+        )
+
+        # release any resources linked to ds (necessary for to_netcdf)
+        ds.close()
+
+        # write the split-beam angle data to the provided path
+        if file_type == "netcdf4":
+            splitb_ds.to_netcdf(path=source_ds_path, mode="a", **storage_options)
+        else:
+            splitb_ds.to_zarr(store=source_ds_path, mode="a", **storage_options)
+
+        if return_dataset:
+            # open up and return Dataset in source_ds_path
+            return xr.open_dataset(source_ds_path, engine=file_type, chunks={}, **storage_options)
+
+    else:
+        # add the split-beam angles to the provided Dataset
+        ds["angle_alongship"] = theta
+        ds["angle_athwartship"] = phi
+
+        if return_dataset:
+            # return input dataset with split-beam angle data
+            return ds
diff --git a/echopype/convert/api.py b/echopype/convert/api.py
index b3bddd133..8160f2299 100644
--- a/echopype/convert/api.py
+++ b/echopype/convert/api.py
@@ -1,4 +1,3 @@
-import warnings
 from pathlib import Path
 from typing import TYPE_CHECKING, Dict, Optional, Tuple
 
@@ -52,6 +51,11 @@ def to_file(
         whether or not to use parallel processing. (Not yet implemented)
     output_storage_options : dict
         Additional keywords to pass to the filesystem class.
+    **kwargs : dict, optional
+        Extra arguments to either `xr.Dataset.to_netcdf`
+        or `xr.Dataset.to_zarr`: refer to each method documentation
+        for a list of all possible arguments.
+
     """
     if parallel:
         raise NotImplementedError("Parallel conversion is not yet implemented.")
@@ -88,13 +92,14 @@ def to_file(
             ),
             engine=engine,
             compress=compress,
+            **kwargs,
         )
 
     # Link path to saved file with attribute as if from open_converted
     echodata.converted_raw_path = output_file
 
 
-def _save_groups_to_file(echodata, output_path, engine, compress=True):
+def _save_groups_to_file(echodata, output_path, engine, compress=True, **kwargs):
     """Serialize all groups to file."""
     # TODO: in terms of chunking, would using rechunker at the end be faster and more convenient?
     # TODO: investigate chunking before we save Dataset to a file
@@ -106,6 +111,7 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True):
         mode="w",
         engine=engine,
         compression_settings=COMPRESSION_SETTINGS[engine] if compress else None,
+        **kwargs,
     )
 
     # Environment group
@@ -116,6 +122,7 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True):
         engine=engine,
         group="Environment",
         compression_settings=COMPRESSION_SETTINGS[engine] if compress else None,
+        **kwargs,
     )
 
     # Platform group
@@ -126,6 +133,7 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True):
         engine=engine,
         group="Platform",
         compression_settings=COMPRESSION_SETTINGS[engine] if compress else None,
+        **kwargs,
     )
 
     # Platform/NMEA group: some sonar model does not produce NMEA data
@@ -137,6 +145,7 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True):
             engine=engine,
             group="Platform/NMEA",
             compression_settings=COMPRESSION_SETTINGS[engine] if compress else None,
+            **kwargs,
         )
 
     # Provenance group
@@ -147,6 +156,7 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True):
         mode="a",
         engine=engine,
         compression_settings=COMPRESSION_SETTINGS[engine] if compress else None,
+        **kwargs,
     )
 
     # Sonar group
@@ -157,6 +167,7 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True):
         mode="a",
         engine=engine,
         compression_settings=COMPRESSION_SETTINGS[engine] if compress else None,
+        **kwargs,
     )
 
     # /Sonar/Beam_groupX group
@@ -169,6 +180,7 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True):
                 engine=engine,
                 group=f"Sonar/Beam_group{i}",
                 compression_settings=COMPRESSION_SETTINGS[engine] if compress else None,
+                **kwargs,
             )
     else:
         io.save_file(
@@ -178,6 +190,7 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True):
             engine=engine,
             group=f"Sonar/{BEAM_SUBGROUP_DEFAULT}",
             compression_settings=COMPRESSION_SETTINGS[engine] if compress else None,
+            **kwargs,
         )
         if echodata["Sonar/Beam_group2"] is not None:
             # some sonar model does not produce Sonar/Beam_group2
@@ -188,6 +201,7 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True):
                 engine=engine,
                 group="Sonar/Beam_group2",
                 compression_settings=COMPRESSION_SETTINGS[engine] if compress else None,
+                **kwargs,
             )
 
     # Vendor_specific group
@@ -198,6 +212,7 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True):
         engine=engine,
         group="Vendor_specific",
         compression_settings=COMPRESSION_SETTINGS[engine] if compress else None,
+        **kwargs,
     )
 
 
@@ -292,13 +307,13 @@ def _check_file(
 
 
 def open_raw(
-    raw_file: Optional["PathHint"] = None,
-    sonar_model: Optional["SonarModelsHint"] = None,
+    raw_file: "PathHint",
+    sonar_model: "SonarModelsHint",
     xml_path: Optional["PathHint"] = None,
     convert_params: Optional[Dict[str, str]] = None,
     storage_options: Optional[Dict[str, str]] = None,
-    offload_to_zarr: bool = False,
-    max_zarr_mb: int = 100,
+    use_swap: bool = False,
+    max_mb: int = 100,
 ) -> Optional[EchoData]:
     """Create an EchoData object containing parsed data from a single raw data file.
 
@@ -327,86 +342,72 @@ def open_raw(
         and need to be added to the converted file
     storage_options : dict
         options for cloud storage
-    offload_to_zarr: bool
+    use_swap: bool
         If True, variables with a large memory footprint will be
-        written to a temporary zarr store called ``temp_echopype_output/parsed2zarr_temp_files``
-        under the current execution folder
-    max_zarr_mb : int
-        maximum MB that each zarr chunk should hold, when offloading
+        written to a temporary zarr store at ``~/.echopype/temp_output/parsed2zarr_temp_files``
+    max_mb : int
+        The maximum data chunk size in Megabytes (MB), when offloading
         variables with a large memory footprint to a temporary zarr store
 
+
     Returns
     -------
     EchoData object
 
+    Raises
+    ------
+    ValueError
+        If ``sonar_model`` is ``None`` or ``sonar_model``
+        given is unsupported.
+    FileNotFoundError
+        If ``raw_file`` is ``None``.
+    TypeError
+        If ``raw_file`` input is neither ``str`` or
+        ``pathlib.Path`` type.
+
     Notes
     -----
-    ``offload_to_zarr=True`` is only available for the following
+    ``use_swap=True`` is only available for the following
     echosounders: EK60, ES70, EK80, ES80, EA640. Additionally, this feature
     is currently in beta.
     """
-    if (sonar_model is None) and (raw_file is None):
-        logger.warning("Please specify the path to the raw data file and the sonar model.")
-        return
-
-    # Check inputs
-    if convert_params is None:
-        convert_params = {}
-    storage_options = storage_options if storage_options is not None else {}
-
-    if sonar_model is None:
-        logger.warning("Please specify the sonar model.")
-
-        if xml_path is None:
-            sonar_model = "EK60"
-            warnings.warn(
-                "Current behavior is to default sonar_model='EK60' when no XML file is passed in as argument. "  # noqa
-                "Specifying sonar_model='EK60' will be required in the future, "
-                "since .raw extension is used for many Kongsberg/Simrad sonar systems.",
-                DeprecationWarning,
-                2,
-            )
-        else:
-            sonar_model = "AZFP"
-            warnings.warn(
-                "Current behavior is to set sonar_model='AZFP' when an XML file is passed in as argument. "  # noqa
-                "Specifying sonar_model='AZFP' will be required in the future.",
-                DeprecationWarning,
-                2,
-            )
-    else:
-        # Uppercased model in case people use lowercase
-        sonar_model = sonar_model.upper()  # type: ignore
-
-        # Check models
-        if sonar_model not in SONAR_MODELS:
-            raise ValueError(
-                f"Unsupported echosounder model: {sonar_model}\nMust be one of: {list(SONAR_MODELS)}"  # noqa
-            )
-
-    # Check paths and file types
     if raw_file is None:
-        raise FileNotFoundError("Please specify the path to the raw data file.")
+        raise FileNotFoundError("The path to the raw data file must be specified.")
 
     # Check for path type
     if isinstance(raw_file, Path):
         raw_file = str(raw_file)
     if not isinstance(raw_file, str):
-        raise TypeError("file must be a string or Path")
+        raise TypeError("File path must be a string or Path")
+
+    if sonar_model is None:
+        raise ValueError("Sonar model must be specified.")
+
+    # Check inputs
+    if convert_params is None:
+        convert_params = {}
+    storage_options = storage_options if storage_options is not None else {}
 
-    assert sonar_model is not None
+    # Uppercased model in case people use lowercase
+    sonar_model = sonar_model.upper()  # type: ignore
+
+    # Check models
+    if sonar_model not in SONAR_MODELS:
+        raise ValueError(
+            f"Unsupported echosounder model: {sonar_model}\nMust be one of: {list(SONAR_MODELS)}"  # noqa
+        )
 
     # Check file extension and existence
     file_chk, xml_chk = _check_file(raw_file, sonar_model, xml_path, storage_options)
 
     # TODO: remove once 'auto' option is added
-    if not isinstance(offload_to_zarr, bool):
-        raise ValueError("offload_to_zarr must be of type bool.")
+    if not isinstance(use_swap, bool):
+        raise ValueError("use_swap must be of type bool.")
 
-    # Ensure offload_to_zarr is 'auto', if it is a string
+    # Ensure use_swap is 'auto', if it is a string
     # TODO: use the following when we allow for 'auto' option
-    # if isinstance(offload_to_zarr, str) and offload_to_zarr != "auto":
-    #     raise ValueError("offload_to_zarr must be a bool or equal to 'auto'.")
+    # if isinstance(use_swap, str) and use_swap != "auto":
+    #     raise ValueError("use_swap must be a bool or equal to 'auto'.")
 
     # TODO: the if-else below only works for the AZFP vs EK contrast,
     #  but is brittle since it is abusing params by using it implicitly
@@ -427,17 +428,16 @@ def open_raw(
 
     # Direct offload to zarr and rectangularization only available for some sonar models
     if sonar_model in ["EK60", "ES70", "EK80", "ES80", "EA640"]:
-
         # Create sonar_model-specific p2z object
         p2z = SONAR_MODELS[sonar_model]["parsed2zarr"](parser)
 
         # Determines if writing to zarr is necessary and writes to zarr
-        p2z_flag = offload_to_zarr is True or (
-            offload_to_zarr == "auto" and p2z.whether_write_to_zarr(mem_mult=0.4)
+        p2z_flag = use_swap is True or (
+            use_swap == "auto" and p2z.whether_write_to_zarr(mem_mult=0.4)
         )
 
         if p2z_flag:
-            p2z.datagram_to_zarr(max_mb=max_zarr_mb)
+            p2z.datagram_to_zarr(max_mb=max_mb)
             # Rectangularize the transmit data
             parser.rectangularize_transmit_ping_data(data_type="complex")
         else:
@@ -485,14 +485,21 @@ def open_raw(
     # Set multi beam groups
     beam_groups = setgrouper.set_beam()
 
-    valid_beam_groups_count = 0
+    beam_group_type = []
     for idx, beam_group in enumerate(beam_groups, start=1):
         if beam_group is not None:
-            valid_beam_groups_count += 1
+            # fill in beam_group_type (only necessary for EK80, ES80, EA640)
+            if idx == 1:
+                # choose the appropriate description key for Beam_group1
+                beam_group_type.append("complex" if "backscatter_i" in beam_group else "power")
+            else:
+                # provide None for all other beam groups (since the description does not have a key)
+                beam_group_type.append(None)
+
             tree_dict[f"Sonar/Beam_group{idx}"] = beam_group
 
     if sonar_model in ["EK80", "ES80", "EA640"]:
-        tree_dict["Sonar"] = setgrouper.set_sonar(beam_group_count=valid_beam_groups_count)
+        tree_dict["Sonar"] = setgrouper.set_sonar(beam_group_type=beam_group_type)
     else:
         tree_dict["Sonar"] = setgrouper.set_sonar()
 
diff --git a/echopype/convert/parse_ad2cp.py b/echopype/convert/parse_ad2cp.py
index 9cfa908e0..3b5fcea70 100644
--- a/echopype/convert/parse_ad2cp.py
+++ b/echopype/convert/parse_ad2cp.py
@@ -370,19 +370,15 @@ def is_echosounder_raw_transmit(self) -> bool:
         return self.data["id"] == 0x24
 
     def is_burst_altimeter(self) -> bool:
-
         return self.data["id"] == 0x1A
 
     def is_dvl_water_track(self) -> bool:
-
         return self.data["id"] == 0x1D
 
     def is_altimeter(self) -> bool:
-
         return self.data["id"] == 0x1E
 
     def is_average_altimeter(self) -> bool:
-
         return self.data["id"] == 0x1F
 
     def is_string(self) -> bool:
diff --git a/echopype/convert/parse_base.py b/echopype/convert/parse_base.py
index f46cb8055..9b6d8170e 100644
--- a/echopype/convert/parse_base.py
+++ b/echopype/convert/parse_base.py
@@ -118,7 +118,6 @@ def parse_raw(self):
                 self.config_datagram["timestamp"].replace(tzinfo=None), "[ms]"
             )
             if "configuration" in self.config_datagram:
-
                 for v in self.config_datagram["configuration"].values():
                     if "pulse_duration" not in v and "pulse_length" in v:
                         # it seems like sometimes this field can appear with the name "pulse_length"
@@ -382,7 +381,6 @@ def _append_zarr_dgram(self, full_dgram: dict):
         if ("power" in reduced_datagram.keys()) and (
             isinstance(reduced_datagram["power"], np.ndarray)
         ):
-
             # Manufacturer-specific power conversion factor
             INDEX2POWER = 10.0 * np.log10(2.0) / 256.0
 
@@ -443,7 +441,6 @@ def pad_shorter_ping(data_list) -> np.ndarray:
         """
         lens = np.array([len(item) for item in data_list])
         if np.unique(lens).size != 1:  # if some pings have different lengths along range
-
             if data_list[0].ndim == 2:
                 # Angle data have an extra dimension for alongship and athwartship samples
                 mask = lens[:, None, None] > np.array([np.arange(lens.max())] * 2).T
diff --git a/echopype/convert/parsed_to_zarr.py b/echopype/convert/parsed_to_zarr.py
index b3b8fae00..95fde46c9 100644
--- a/echopype/convert/parsed_to_zarr.py
+++ b/echopype/convert/parsed_to_zarr.py
@@ -8,7 +8,7 @@
 import pandas as pd
 import zarr
 
-from ..utils.io import check_file_permissions
+from ..utils.io import ECHOPYPE_DIR, check_file_permissions
 
 
 class Parsed2Zarr:
@@ -20,7 +20,6 @@ class Parsed2Zarr:
     """
 
     def __init__(self, parser_obj):
-
         self.temp_zarr_dir = None
         self.zarr_file_name = None
         self.store = None
@@ -41,7 +40,7 @@ def _create_zarr_info(self):
         check_file_permissions(current_dir)
 
         # construct temporary directory that will hold the zarr file
-        out_dir = current_dir.joinpath(Path("temp_echopype_output") / "parsed2zarr_temp_files")
+        out_dir = current_dir / ECHOPYPE_DIR / "temp_output" / "parsed2zarr_temp_files"
         if not out_dir.exists():
             out_dir.mkdir(parents=True)
 
@@ -54,7 +53,6 @@ def _create_zarr_info(self):
         # attempt to find different zarr_file_name, if it already exists
         count = 0
         while Path(zarr_file_name).exists() and count < 10:
-
             # generate new zarr_file_name
             zarr_file_name = str(out_dir / secrets.token_hex(16)) + ".zarr"
             count += 1
@@ -240,13 +238,10 @@ def get_np_chunk(
         """
 
         if isinstance(nan_array, np.ndarray):
-
             # appropriately pad elements of series_chunk, if needed
             padded_elements = []
             for elm in series_chunk.to_list():
-
                 if isinstance(elm, np.ndarray):
-
                     # TODO: ideally this would take place in the parser, do this
                     elm = elm.astype(np.float64)
 
@@ -400,7 +395,6 @@ def _get_zarr_dgrams_size(self) -> int:
 
         size = 0
         for i in self.parser_obj.zarr_datagrams:
-
             size += sum([sys.getsizeof(val) for key, val in i.items()])
 
         return size
diff --git a/echopype/convert/parsed_to_zarr_ek60.py b/echopype/convert/parsed_to_zarr_ek60.py
index bb9e71e25..17f80c8a0 100644
--- a/echopype/convert/parsed_to_zarr_ek60.py
+++ b/echopype/convert/parsed_to_zarr_ek60.py
@@ -21,7 +21,6 @@ def __init__(self, parser_obj):
 
         # get the channel sort rule for EK60 type sensors
         if "transceivers" in self.parser_obj.config_datagram:
-
             # get channel and channel_id association and sort by channel_id
             channels_old = list(self.parser_obj.config_datagram["transceivers"].keys())
 
diff --git a/echopype/convert/set_groups_azfp.py b/echopype/convert/set_groups_azfp.py
index ae5b6fba7..98528af59 100644
--- a/echopype/convert/set_groups_azfp.py
+++ b/echopype/convert/set_groups_azfp.py
@@ -66,7 +66,6 @@ def _create_unique_channel_name(self):
         serial_number = self.parser_obj.unpacked_data["serial_number"]
 
         if serial_number.size == 1:
-
             freq_as_str = self.freq_sorted.astype(int).astype(str)
 
             # TODO: replace str(i+1) with Frequency Number from XML
diff --git a/echopype/convert/set_groups_base.py b/echopype/convert/set_groups_base.py
index 414f23008..60ef3f5fd 100644
--- a/echopype/convert/set_groups_base.py
+++ b/echopype/convert/set_groups_base.py
@@ -290,7 +290,6 @@ def _add_ping_time_dim(
         )
 
         for var_name in add_ping_time_names:
-
             ds[var_name] = (
                 ds[var_name]
                 .expand_dims(dim={"ping_time": ds.ping_time}, axis=ds[var_name].ndim)
diff --git a/echopype/convert/set_groups_ek60.py b/echopype/convert/set_groups_ek60.py
index 9cada09dc..80aab2758 100644
--- a/echopype/convert/set_groups_ek60.py
+++ b/echopype/convert/set_groups_ek60.py
@@ -616,7 +616,6 @@ def set_beam(self) -> List[xr.Dataset]:
         # Construct Dataset with ping-by-ping data from all channels
         ds_backscatter = []
         for ch in self.sorted_channel.keys():
-
             var_dict = {
                 "sample_interval": (
                     ["ping_time"],
@@ -679,7 +678,6 @@ def set_beam(self) -> List[xr.Dataset]:
             }
 
             if not self.parsed2zarr_obj.temp_zarr_dir:
-
                 var_dict["backscatter_r"] = (
                     ["ping_time", "range_sample"],
                     self.parser_obj.ping_data_dict["power"][ch],
@@ -769,7 +767,6 @@ def set_beam(self) -> List[xr.Dataset]:
         return [set_time_encodings(ds)]
 
     def set_vendor(self) -> xr.Dataset:
-
         # Retrieve pulse length, gain, and sa correction
         pulse_length = np.array(
             [
diff --git a/echopype/convert/set_groups_ek80.py b/echopype/convert/set_groups_ek80.py
index abd78a43b..ca7ab0077 100644
--- a/echopype/convert/set_groups_ek80.py
+++ b/echopype/convert/set_groups_ek80.py
@@ -49,10 +49,13 @@ class SetGroupsEK80(SetGroupsBase):
     beamgroups_possible = [
         {
             "name": "Beam_group1",
-            "descr": (
-                "contains backscatter data (either complex samples or uncalibrated power samples)"  # noqa
-                " and other beam or channel-specific data"
-            ),
+            "descr": {
+                "power": "contains backscatter power (uncalibrated) and "
+                "other beam or channel-specific data,"
+                " including split-beam angle data when they exist.",
+                "complex": "contains complex backscatter data and other "
+                "beam or channel-specific data.",
+            },
         },
         {
             "name": "Beam_group2",
@@ -145,12 +148,12 @@ def set_env(self) -> xr.Dataset:
                 },
             )
 
-        vars = ["sound_velocity_source", "transducer_name", "transducer_sound_speed"]
-        for var_name in vars:
-            if var_name in self.parser_obj.environment:
-                dict_env[var_name] = (
+        varnames = ["sound_velocity_source", "transducer_name", "transducer_sound_speed"]
+        for vn in varnames:
+            if vn in self.parser_obj.environment:
+                dict_env[vn] = (
                     ["time1"],
-                    [self.parser_obj.environment[var_name]],
+                    [self.parser_obj.environment[vn]],
                 )
 
         ds = xr.Dataset(
@@ -185,7 +188,7 @@ def set_env(self) -> xr.Dataset:
         )
         return set_time_encodings(ds)
 
-    def set_sonar(self, beam_group_count=1) -> xr.Dataset:
+    def set_sonar(self, beam_group_type: list = ["power", None]) -> xr.Dataset:
         # Collect unique variables
         params = [
             "transducer_frequency",
@@ -203,12 +206,25 @@ def set_sonar(self, beam_group_count=1) -> xr.Dataset:
             for param in params:
                 var[param].append(data[param])
 
-        # Create dataset
+        # obtain the correct beam_group and corresponding description from beamgroups_possible
+        for idx, beam in enumerate(beam_group_type):
+            if beam is None:
+                # obtain values from an element where the key 'descr' does not have keys
+                self._beamgroups.append(self.beamgroups_possible[idx])
+            else:
+                # obtain values from an element where the key 'descr' DOES have keys
+                self._beamgroups.append(
+                    {
+                        "name": self.beamgroups_possible[idx]["name"],
+                        "descr": self.beamgroups_possible[idx]["descr"][beam],
+                    }
+                )
+
         # Add beam_group and beam_group_descr variables sharing a common dimension
         # (beam_group), using the information from self._beamgroups
-        self._beamgroups = self.beamgroups_possible[:beam_group_count]
         beam_groups_vars, beam_groups_coord = self._beam_groups_vars()
 
+        # Create dataset
         sonar_vars = {
             "frequency_nominal": (
                 ["channel"],
@@ -618,7 +634,6 @@ def _add_freq_start_end_ds(self, ds_tmp: xr.Dataset, ch: str) -> xr.Dataset:
             "frequency_start" in self.parser_obj.ping_data_dict.keys()
             and self.parser_obj.ping_data_dict["frequency_start"][ch]
         ):
-
             ds_f_start_end = xr.Dataset(
                 {
                     "frequency_start": (
@@ -779,7 +794,6 @@ def _add_trasmit_pulse_complex(self, ds_tmp: xr.Dataset, ch: str) -> xr.Dataset:
         return ds_tmp
 
     def _assemble_ds_power(self, ch):
-
         ds_tmp = xr.Dataset(
             {
                 "backscatter_r": (
@@ -1055,7 +1069,6 @@ def set_beam(self) -> List[xr.Dataset]:
             ds_invariant_power = self._assemble_ds_ping_invariant(params, "power")
 
         if not self.parsed2zarr_obj.temp_zarr_dir:
-
             # Assemble dataset for backscatter data and other ping-by-ping data
             ds_complex = []
             ds_power = []
@@ -1126,9 +1139,19 @@ def set_vendor(self) -> xr.Dataset:
         """Set the Vendor_specific group."""
         config = self.parser_obj.config_datagram["configuration"]
 
-        # Table for sa_correction and gain indexed by pulse_length (exist for all channels)
+        # Channel-specific parameters
+        # exist for all channels:
+        #   - sa_correction
+        #   - gain (indexed by pulse_length)
+        # may not exist for data from earlier EK80 software:
+        #   - impedance
+        #   - receiver sampling frequency
+        #   - transceiver type
         table_params = [
             "transducer_frequency",
+            "impedance",  # receive impedance (z_er), different from transmit impedance (z_et)
+            "rx_sample_frequency",  # receiver sampling frequency
+            "transceiver_type",
             "pulse_duration",
             "sa_correction",
             "gain",
@@ -1139,7 +1162,8 @@ def set_vendor(self) -> xr.Dataset:
         for ch in self.sorted_channel["all"]:
             v = self.parser_obj.config_datagram["configuration"][ch]
             for p in table_params:
-                param_dict[p].append(v[p])
+                if p in v:  # only for parameter that exist in configuration dict
+                    param_dict[p].append(v[p])
 
         # make values into numpy arrays
         for p in param_dict.keys():
@@ -1191,6 +1215,37 @@ def set_vendor(self) -> xr.Dataset:
             },
         )
 
+        # Parameters that may or may not exist (due to EK80 software version)
+        if "impedance" in param_dict:
+            ds_table["impedance_receive"] = xr.DataArray(
+                param_dict["impedance"],
+                dims=["channel"],
+                coords={"channel": ds_table["channel"]},
+                attrs={
+                    "units": "ohm",
+                    "long_name": "Receiver impedance",
+                },
+            )
+        if "rx_sample_frequency" in param_dict:
+            ds_table["fs_receiver"] = xr.DataArray(
+                param_dict["rx_sample_frequency"].astype(float),
+                dims=["channel"],
+                coords={"channel": ds_table["channel"]},
+                attrs={
+                    "units": "Hz",
+                    "long_name": "Receiver sampling frequency",
+                },
+            )
+        if "transceiver_type" in param_dict:
+            ds_table["transceiver_type"] = xr.DataArray(
+                param_dict["transceiver_type"],
+                dims=["channel"],
+                coords={"channel": ds_table["channel"]},
+                attrs={
+                    "long_name": "Transceiver type",
+                },
+            )
+
         # Broadband calibration parameters: use the zero padding approach
         cal_ch_ids = [
             ch for ch in self.sorted_channel["all"] if "calibration" in config[ch]
@@ -1204,7 +1259,7 @@ def set_vendor(self) -> xr.Dataset:
             #                 f"{config[ch]['channel_id_short']}")
             cal_params = [
                 "gain",
-                "impedance",
+                "impedance",  # transmit impedance (z_et), different from receive impedance (z_er)
                 "phase",
                 "beamwidth_alongship",
                 "beamwidth_athwartship",
@@ -1213,7 +1268,8 @@ def set_vendor(self) -> xr.Dataset:
             ]
             param_dict = {}
             for p in cal_params:
-                param_dict[p] = (["cal_frequency"], config[ch_id]["calibration"][p])
+                if p in config[ch_id]["calibration"]:  # only for parameters that exist in dict
+                    param_dict[p] = (["cal_frequency"], config[ch_id]["calibration"][p])
             ds_ch = xr.Dataset(
                 data_vars=param_dict,
                 coords={
@@ -1234,6 +1290,9 @@ def set_vendor(self) -> xr.Dataset:
             ds_cal.append(ds_ch)
         ds_cal = xr.merge(ds_cal)
 
+        if "impedance" in ds_cal:
+            ds_cal = ds_cal.rename_vars({"impedance": "impedance_transmit"})
+
         #  Save decimation factors and filter coefficients
         coeffs = dict()
         decimation_factors = dict()
diff --git a/echopype/convert/utils/ek_raw_io.py b/echopype/convert/utils/ek_raw_io.py
index 883733f11..61c72c4e5 100644
--- a/echopype/convert/utils/ek_raw_io.py
+++ b/echopype/convert/utils/ek_raw_io.py
@@ -94,7 +94,6 @@ def __init__(
         buffer_size=1024 * 1024,
         storage_options={},
     ):
-
         #  9-28-18 RHT: Changed RawSimradFile to implement BufferedReader instead of
         #  io.FileIO to increase performance.
 
@@ -442,7 +441,6 @@ def read(self, k):
                     raise
 
         elif k > 0:
-
             dgram_list = []
 
             for m in range(k):
diff --git a/echopype/convert/utils/ek_raw_parsers.py b/echopype/convert/utils/ek_raw_parsers.py
index 74754bcef..ec5cf12b7 100644
--- a/echopype/convert/utils/ek_raw_parsers.py
+++ b/echopype/convert/utils/ek_raw_parsers.py
@@ -53,7 +53,6 @@ def header(self, version=0):
         return self._headers[version][:]
 
     def validate_data_header(self, data):
-
         if isinstance(data, dict):
             type_ = data["type"][:3]
             version = int(data["type"][3])
@@ -74,7 +73,6 @@ def validate_data_header(self, data):
         return type_, version
 
     def from_string(self, raw_string, bytes_read):
-
         header = raw_string[:4]
         if sys.version_info.major > 2:
             header = header.decode()
@@ -82,7 +80,6 @@ def from_string(self, raw_string, bytes_read):
         return self._unpack_contents(raw_string, bytes_read, version=version)
 
     def to_string(self, data={}):
-
         id_, version = self.validate_data_header(data)
         datagram_content_str = self._pack_contents(data, version=version)
         return self.finalize_datagram(datagram_content_str)
@@ -174,12 +171,10 @@ def _unpack_contents(self, raw_string, bytes_read, version):
         return data
 
     def _pack_contents(self, data, version):
-
         datagram_fmt = self.header_fmt(version)
         datagram_contents = []
 
         if version == 0:
-
             lengths = [
                 len(data["depth"]),
                 len(data["reflectivity"]),
@@ -273,12 +268,10 @@ def _unpack_contents(self, raw_string, bytes_read, version):
         return data
 
     def _pack_contents(self, data, version):
-
         datagram_fmt = self.header_fmt(version)
         datagram_contents = []
 
         if version == 0:
-
             if len(data["depth"]) != data["transceiver_count"]:
                 logger.warning(
                     "# of depth values %d does not match transceiver count %d",
@@ -362,12 +355,10 @@ def _unpack_contents(self, raw_string, bytes_read, version):
         return data
 
     def _pack_contents(self, data, version):
-
         datagram_fmt = self.header_fmt(version)
         datagram_contents = []
 
         if version == 0:
-
             for field in self.header_fields(version):
                 datagram_contents.append(data[field])
 
@@ -469,12 +460,10 @@ def _unpack_contents(self, raw_string, bytes_read, version):
         return data
 
     def _pack_contents(self, data, version):
-
         datagram_fmt = self.header_fmt(version)
         datagram_contents = []
 
         if version == 0:
-
             for field in self.header_fields(version):
                 datagram_contents.append(data[field])
 
@@ -563,12 +552,10 @@ def _unpack_contents(self, raw_string, bytes_read, version):
         return data
 
     def _pack_contents(self, data, version):
-
         datagram_fmt = self.header_fmt(version)
         datagram_contents = []
 
         if version == 0:
-
             for field in self.header_fields(version):
                 datagram_contents.append(data[field])
 
@@ -819,7 +806,6 @@ def dict_to_dict(xml_dict, data_dict, parse_opts):
 
             #  parse it
             if data["subtype"] == "configuration":
-
                 #  parse the Transceiver section
                 for tcvr in root.iter("Transceiver"):
                     #  parse the Transceiver section
@@ -951,7 +937,6 @@ def dict_to_dict(xml_dict, data_dict, parse_opts):
                         )
 
             elif data["subtype"] == "parameter":
-
                 #  parse the parameter XML datagram
                 for h in root.iter("Channel"):
                     parm_xml = h.attrib
@@ -959,7 +944,6 @@ def dict_to_dict(xml_dict, data_dict, parse_opts):
                     dict_to_dict(parm_xml, data["parameter"], self.parameter_parsing_options)
 
             elif data["subtype"] == "environment":
-
                 #  parse the environment XML datagram
                 for h in root.iter("Environment"):
                     env_xml = h.attrib
@@ -998,7 +982,6 @@ def to_CamelCase(xml_param):
         datagram_contents = []
 
         if version == 0:
-
             for field in self.header_fields(version):
                 datagram_contents.append(data[field])
 
@@ -1064,7 +1047,6 @@ def __init__(self):
         _SimradDatagramParser.__init__(self, "FIL", headers)
 
     def _unpack_contents(self, raw_string, bytes_read, version):
-
         data = {}
         header_values = struct.unpack(
             self.header_fmt(version), raw_string[: self.header_size(version)]
@@ -1094,12 +1076,10 @@ def _unpack_contents(self, raw_string, bytes_read, version):
         return data
 
     def _pack_contents(self, data, version):
-
         datagram_fmt = self.header_fmt(version)
         datagram_contents = []
 
         if version == 0:
-
             pass
 
         elif version == 1:
@@ -1284,7 +1264,6 @@ def __init__(self):
         }
 
     def _unpack_contents(self, raw_string, bytes_read, version):
-
         data = {}
         round6 = lambda x: round(x, ndigits=6)  # noqa
         header_values = struct.unpack(
@@ -1302,7 +1281,6 @@ def _unpack_contents(self, raw_string, bytes_read, version):
         data["bytes_read"] = bytes_read
 
         if version == 0:
-
             data["transceivers"] = {}
 
             for field in ["transect_name", "version", "survey_name", "sounder_name"]:
@@ -1396,12 +1374,10 @@ def _unpack_contents(self, raw_string, bytes_read, version):
         return data
 
     def _pack_contents(self, data, version):
-
         datagram_fmt = self.header_fmt(version)
         datagram_contents = []
 
         if version == 0:
-
             if data["transceiver_count"] != len(data["transceivers"]):
                 logger.warning("Mismatch between 'transceiver_count' and actual # of transceivers")
                 data["transceiver_count"] = len(data["transceivers"])
@@ -1571,7 +1547,6 @@ def __init__(self):
         _SimradDatagramParser.__init__(self, "RAW", headers)
 
     def _unpack_contents(self, raw_string, bytes_read, version):
-
         header_values = struct.unpack(
             self.header_fmt(version), raw_string[: self.header_size(version)]
         )
@@ -1587,7 +1562,6 @@ def _unpack_contents(self, raw_string, bytes_read, version):
         data["bytes_read"] = bytes_read
 
         if version == 0:
-
             if data["count"] > 0:
                 block_size = data["count"] * 2
                 indx = self.header_size(version)
@@ -1614,14 +1588,12 @@ def _unpack_contents(self, raw_string, bytes_read, version):
 
         # RAW3 and RAW4 have the same format, only Datatype Bit 0-1 not used in RAW4
         elif version == 3 or version == 4:
-
             # result = 1j*Data[...,1]; result += Data[...,0]
 
             #  clean up the channel ID
             data["channel_id"] = data["channel_id"].strip("\x00")
 
             if data["count"] > 0:
-
                 #  set the initial block size and indx value.
                 block_size = data["count"] * 2
                 indx = self.header_size(version)
@@ -1676,13 +1648,11 @@ def _unpack_contents(self, raw_string, bytes_read, version):
         return data
 
     def _pack_contents(self, data, version):
-
         datagram_fmt = self.header_fmt(version)
 
         datagram_contents = []
 
         if version == 0:
-
             if data["count"] > 0:
                 if (int(data["mode"]) & 0x1) and (len(data.get("power", [])) != data["count"]):
                     logger.warning(
@@ -1707,7 +1677,6 @@ def _pack_contents(self, data, version):
                 datagram_contents.append(data[field])
 
             if data["count"] > 0:
-
                 if int(data["mode"]) & 0x1:
                     datagram_fmt += "%dh" % (data["count"])
                     datagram_contents.extend(data["power"])
diff --git a/echopype/echodata/combine.py b/echopype/echodata/combine.py
index 16f0e8df6..19cc18ac6 100644
--- a/echopype/echodata/combine.py
+++ b/echopype/echodata/combine.py
@@ -1,5 +1,7 @@
+import itertools
+import re
 from pathlib import Path
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Tuple, Union
 from warnings import warn
 
 import dask.distributed
@@ -15,7 +17,7 @@
 
 
 def check_zarr_path(
-    zarr_path: str, storage_options: Dict[str, Any] = {}, overwrite: bool = False
+    zarr_path: Union[str, Path], storage_options: Dict[str, Any] = {}, overwrite: bool = False
 ) -> str:
     """
     Checks that the zarr path provided to ``combine``
@@ -23,7 +25,7 @@ def check_zarr_path(
 
     Parameters
     ----------
-    zarr_path: str
+    zarr_path: str or Path
         The full save path to the final combined zarr store
     storage_options: dict
         Any additional parameters for the storage
@@ -47,16 +49,16 @@ def check_zarr_path(
     """
 
     if zarr_path is not None:
-        # check that zarr_path is a string
-        if not isinstance(zarr_path, str):
-            raise TypeError(f"zarr_path must be of type {str}")
+        # ensure that zarr_path is a string or Path object, throw an error otherwise
+        if not isinstance(zarr_path, (str, Path)):
+            raise TypeError("The provided zarr_path input must be of type string or pathlib.Path!")
 
         # check that the appropriate suffix was provided
-        if not zarr_path.strip("/").endswith(".zarr"):
-            raise ValueError("The provided zarr_path input must have '.zarr' suffix!")
+        if not (Path(zarr_path).suffix == ".zarr"):
+            raise ValueError("The provided zarr_path input must have a '.zarr' suffix!")
 
     # set default source_file name (will be used only if zarr_path is None)
-    source_file = "combined_echodatas.zarr"
+    source_file = "combined_echodata.zarr"
 
     validated_path = validate_output_path(
         source_file=source_file,
@@ -65,16 +67,21 @@ def check_zarr_path(
         save_path=zarr_path,
     )
 
+    # convert created validated_path to a string if it is in other formats,
+    # since fsspec only accepts strings
+    if isinstance(validated_path, Path):
+        validated_path = str(validated_path.absolute())
+
     # check if validated_path already exists
     fs = fsspec.get_mapper(validated_path, **storage_options).fs  # get file system
     exists = True if fs.exists(validated_path) else False
 
     if exists and not overwrite:
         raise RuntimeError(
-            f"{zarr_path} already exists, please provide a different path or set overwrite=True."
+            f"{validated_path} already exists, please provide a "
+            "different path or set overwrite=True."
         )
     elif exists and overwrite:
-
         logger.info(f"overwriting {validated_path}")
 
         # remove zarr file
@@ -83,6 +90,58 @@ def check_zarr_path(
     return validated_path
 
 
+def _check_channel_selection_form(
+    channel_selection: Optional[Union[List, Dict[str, list]]] = None
+) -> None:
+    """
+    Ensures that the provided user input ``channel_selection`` is in
+    an acceptable form.
+
+    Parameters
+    ----------
+    channel_selection: list of str or dict, optional
+        Specifies what channels should be selected for an ``EchoData`` group
+        with a ``channel`` dimension (before combination).
+    """
+
+    # check that channel selection is None, a list, or a dict
+    if not isinstance(channel_selection, (type(None), list, dict)):
+        raise TypeError("The input channel_selection does not have an acceptable type!")
+
+    if isinstance(channel_selection, list):
+        # make sure each element is a string
+        are_elem_str = [isinstance(elem, str) for elem in channel_selection]
+        if not all(are_elem_str):
+            raise TypeError("Each element of channel_selection must be a string!")
+
+    if isinstance(channel_selection, dict):
+        # make sure all keys are strings
+        are_keys_str = [isinstance(elem, str) for elem in channel_selection.keys()]
+        if not all(are_keys_str):
+            raise TypeError("Each key of channel_selection must be a string!")
+
+        # make sure all keys are of the form Sonar/Beam_group using regular expression
+        are_keys_right_form = [
+            True if re.match("Sonar/Beam_group(\d{1})", elem) else False  # noqa
+            for elem in channel_selection.keys()
+        ]
+        if not all(are_keys_right_form):
+            raise TypeError(
+                "Each key of channel_selection can only be a beam group path of "
+                "the form Sonar/Beam_group!"
+            )
+
+        # make sure all values are a list
+        are_vals_list = [isinstance(elem, list) for elem in channel_selection.values()]
+        if not all(are_vals_list):
+            raise TypeError("Each value of channel_selection must be a list!")
+
+        # make sure all values are a list of strings
+        are_vals_list_str = [set(map(type, elem)) == {str} for elem in channel_selection]
+        if not all(are_vals_list_str):
+            raise TypeError("Each value of channel_selection must be a list of strings!")
+
+
 def check_echodatas_input(echodatas: List[EchoData]) -> Tuple[str, List[str]]:
     """
     Ensures that the input list of ``EchoData`` objects for ``combine_echodata``
@@ -124,7 +183,6 @@ def check_echodatas_input(echodatas: List[EchoData]) -> Tuple[str, List[str]]:
 
     echodata_filenames = []
     for ed in echodatas:
-
         # check sonar model
         if ed.sonar_model is None:
             raise ValueError("all EchoData objects must have non-None sonar_model values")
@@ -148,12 +206,243 @@ def check_echodatas_input(echodatas: List[EchoData]) -> Tuple[str, List[str]]:
     return sonar_model, echodata_filenames
 
 
+def _check_channel_consistency(
+    all_chan_list: List, ed_group: str, channel_selection: Optional[List[str]] = None
+) -> None:
+    """
+    If ``channel_selection = None``, checks that each element in ``all_chan_list`` are
+    the same, else makes sure that each element in ``all_chan_list`` contains all channel
+    names in ``channel_selection``.
+
+    Parameters
+    ----------
+    all_chan_list: list of list
+        A list whose elements correspond to the Datasets to be combined with
+        their values set as a list of the channel dimension names in the Dataset
+    ed_group: str
+        The EchoData group path that produced ``all_chan_list``
+    channel_selection: list of str, optional
+        A list of channel names, which should be a subset of each
+        element in ``all_chan_list``
+
+    Raises
+    ------
+    RuntimeError
+        If ``channel_selection=None`` and all ``channel`` dimensions are not the
+        same across all Datasets.
+    NotImplementedError
+        If ``channel_selection`` is a list and the listed channels are not contained
+        in the ``EchoData`` group for all Datasets and need to be created and
+        padded with NaN. This "expansion" type of combination has not been implemented.
+    """
+
+    if channel_selection is None:
+        # sort each element in list, so correct comparison can be made
+        all_chan_list = list(map(sorted, all_chan_list))
+
+        # determine if the channels are the same across all Datasets
+        all_chans_equal = [all_chan_list[0]] * len(all_chan_list) == all_chan_list
+
+        if not all_chans_equal:
+            # obtain all unique channel names
+            unique_channels = set(itertools.chain.from_iterable(all_chan_list))
+
+            # raise an error if we have varying channel lengths
+            raise RuntimeError(
+                f"For the EchoData group {ed_group} the channels: {unique_channels} are "
+                f"not found in all EchoData objects being combined. Select which "
+                f"channels should be included in the combination using the keyword argument "
+                f"channel_selection in combine_echodata."
+            )
+
+    else:
+        # make channel_selection a set, so it is easier to use
+        channel_selection = set(channel_selection)
+
+        # TODO: if we will allow for expansion, then the below code should be
+        #  replaced with a code section that makes sure the selected channels
+        #  appear at least once in one of the other Datasets
+
+        # determine if channel selection is in each element of all_chan_list
+        eds_num_chan = [
+            channel_selection.intersection(set(ed_chans)) == channel_selection
+            for ed_chans in all_chan_list
+        ]
+
+        if not all(eds_num_chan):
+            # raise a not implemented error if expansion (i.e. padding is necessary)
+            raise NotImplementedError(
+                f"For the EchoData group {ed_group}, some EchoData objects do "
+                f"not contain the selected channels. This type of combine is "
+                f"not currently implemented."
+            )
+
+
+def _create_channel_selection_dict(
+    sonar_model: str,
+    has_chan_dim: Dict[str, bool],
+    user_channel_selection: Optional[Union[List, Dict[str, list]]] = None,
+) -> Dict[str, Optional[list]]:
+    """
+    Constructs the dictionary ``channel_selection_dict``, which specifies
+    the ``channel`` dimension names that should be selected for each
+    ``EchoData`` group. If a group does not have a ``channel`` dimension
+    the dictionary value will be set to ``None``
+
+    Parameters
+    ----------
+    sonar_model: str
+        The name of the sonar model corresponding to ``has_chan_dim``
+    has_chan_dim: dict
+        A dictionary created using an ``EchoData`` object whose keys are
+        the ``EchoData`` groups and whose values specify if that
+        particular group has a ``channel`` dimension
+    user_channel_selection: list or dict, optional
+        A user provided input that will be used to construct the values of
+        ``channel_selection_dict`` (see below for further details)
+
+    Returns
+    -------
+    channel_selection_dict : dict
+        A dictionary with the same keys as ``has_chan_dim`` and values
+        determined by ``sonar_model`` and ``user_channel_selection`` as follows:
+        - If ``user_channel_selection=None``, then the values of the dictionary
+        will be set to ``None``
+        - If ``user_channel_selection`` is a list, then all keys corresponding to
+        an ``EchoData`` group with a ``channel`` dimension will have their values
+        set to the provided list and all other groups will be set to ``None``
+        - If ``user_channel_selection`` is a dictionary, then all keys corresponding to
+        an ``EchoData`` group without a ``channel`` dimension will have their values
+        set as ``None`` and the other group's values will be set as follows:
+            - If ``sonar_model`` is not EK80-like then all values will be set to
+            the union of the values of ``user_channel_selection``
+            - If ``sonar_model`` is EK80-like then the groups ``Sonar, Platform, Vendor_specific``
+            will be set to the union of the values of ``user_channel_selection`` and the rest of
+            the groups will be set to the same value in ``user_channel_selection`` with the same key
+
+    Notes
+    -----
+    See ``tests/echodata/test_echodata_combine.py::test_create_channel_selection_dict`` for example
+    outputs from this function.
+    """
+
+    # base case where the user did not provide selected channels (will be used downstream)
+    if user_channel_selection is None:
+        return {grp: None for grp in has_chan_dim.keys()}
+
+    # obtain the union of all channels for each beam group
+    if isinstance(user_channel_selection, list):
+        union_beam_chans = user_channel_selection[:]
+    else:
+        union_beam_chans = list(set(itertools.chain.from_iterable(user_channel_selection.values())))
+
+    # make channel_selection dictionary where the keys are the EchoData groups and the
+    # values are based on the user provided input user_channel_selection
+    channel_selection_dict = dict()
+    for ed_group, has_chan in has_chan_dim.items():
+        # if there are no channel dimensions in the group, set the value to None
+        if has_chan:
+            if (
+                (not isinstance(user_channel_selection, list))
+                and (sonar_model in ["EK80", "ES80", "EA640"])
+                and (ed_group not in ["Sonar", "Platform", "Vendor_specific"])
+            ):
+                # set value to the user provided input with the same key
+                channel_selection_dict[ed_group] = user_channel_selection[ed_group]
+
+            else:
+                # set value to the union of the values of user_channel_selection
+                channel_selection_dict[ed_group] = union_beam_chans
+
+            # sort channel names to produce consistent output (since we may be using sets)
+            channel_selection_dict[ed_group].sort()
+
+        else:
+            channel_selection_dict[ed_group] = None
+
+    return channel_selection_dict
+
+
+def _check_echodata_channels(
+    echodatas: List[EchoData], user_channel_selection: Optional[Union[List, Dict[str, list]]] = None
+) -> Dict[str, Optional[List[str]]]:
+    """
+    Coordinates the routines that check to make sure each ``EchoData`` group with a ``channel``
+    dimension has consistent channels for all elements in ``echodatas``, taking into account
+    the input ``user_channel_selection``.
+
+    Parameters
+    ----------
+    echodatas: list of EchoData object
+        The list of ``EchoData`` objects to be combined
+    user_channel_selection: list or dict, optional
+        A user provided input that will be used to specify which channels will be
+        selected for each ``EchoData`` group
+
+    Returns
+    -------
+    dict
+        A dictionary with keys corresponding to the ``EchoData`` groups and
+        values specifying the channels that should be selected within that group.
+        For more information on this dictionary see the function ``_create_channel_selection_dict``.
+
+    Raises
+    ------
+    RuntimeError
+        If any ``EchoData`` group has a ``channel`` dimension value
+        with a duplicate value.
+
+    Notes
+    -----
+    For further information on what is deemed consistent, please see the
+    function ``_check_channel_consistency``.
+    """
+
+    # determine if the EchoData group contains a channel dimension
+    has_chan_dim = {grp: "channel" in echodatas[0][grp].dims for grp in echodatas[0].group_paths}
+
+    # create dictionary specifying the channels that should be selected for each group
+    channel_selection = _create_channel_selection_dict(
+        echodatas[0].sonar_model, has_chan_dim, user_channel_selection
+    )
+
+    for ed_group in echodatas[0].group_paths:
+        if "channel" in echodatas[0][ed_group].dims:
+            # get each EchoData's channels as a list of list
+            all_chan_list = [list(ed[ed_group].channel.values) for ed in echodatas]
+
+            # make sure each EchoData does not have repeating channels
+            all_chan_unique = [len(set(ed_chans)) == len(ed_chans) for ed_chans in all_chan_list]
+
+            if not all(all_chan_unique):
+                # get indices of EchoData objects with repeating channel names
+                false_ind = [ind for ind, x in enumerate(all_chan_unique) if not x]
+
+                # get files that produced the EchoData objects with repeated channels
+                files_w_rep_chan = [
+                    echodatas[ind]["Provenance"].source_filenames.values[0] for ind in false_ind
+                ]
+
+                raise RuntimeError(
+                    f"The EchoData objects produced by the following files "
+                    f"have a channel dimension with repeating values, "
+                    f"combine cannot be used: {files_w_rep_chan}"
+                )
+
+            # perform a consistency check for the channel dims across all Datasets
+            _check_channel_consistency(all_chan_list, ed_group, channel_selection[ed_group])
+
+    return channel_selection
+
+
 def combine_echodata(
     echodatas: List[EchoData] = None,
-    zarr_path: Optional[str] = None,
+    zarr_path: Optional[Union[str, Path]] = None,
     overwrite: bool = False,
     storage_options: Dict[str, Any] = {},
     client: Optional[dask.distributed.Client] = None,
+    channel_selection: Optional[Union[List, Dict[str, list]]] = None,
+    consolidated: bool = True,
 ) -> EchoData:
     """
     Combines multiple ``EchoData`` objects into a single ``EchoData`` object.
@@ -164,7 +453,7 @@ def combine_echodata(
     ----------
     echodatas : list of EchoData object
         The list of ``EchoData`` objects to be combined
-    zarr_path: str, optional
+    zarr_path: str or pathlib.Path, optional
         The full save path to the final combined zarr store
     overwrite: bool
         If True, will overwrite the zarr store specified by
@@ -175,6 +464,19 @@ def combine_echodata(
         backend (ignored for local paths)
     client: dask.distributed.Client, optional
         An initialized Dask distributed client
+    channel_selection: list of str or dict, optional
+        Specifies what channels should be selected for an ``EchoData`` group
+        with a ``channel`` dimension (before combination).
+
+        - if a list is provided, then each ``EchoData`` group with a ``channel`` dimension
+        will only contain the channels in the provided list
+        - if a dictionary is provided, the dictionary should have keys specifying only beam
+        groups (e.g. "Sonar/Beam_group1") and values as a list of channel names to select
+        within that beam group. The rest of the ``EchoData`` groups with a ``channel`` dimension
+        will have their selected channels chosen automatically.
+    consolidated: bool
+        Flag to consolidate zarr metadata.
+        Defaults to ``True``
 
     Returns
     -------
@@ -186,6 +488,8 @@ def combine_echodata(
     ------
     ValueError
         If the provided zarr path does not point to a zarr file
+    TypeError
+        If the a provided `zarr_path` input is not of type string or pathlib.Path
     RuntimeError
         If ``zarr_path`` already exists and ``overwrite=False``
     TypeError
@@ -212,6 +516,15 @@ def combine_echodata(
         - the values are not identical
         - the keys ``date_created`` or ``conversion_time``
           do not have the same types
+    RuntimeError
+        If any ``EchoData`` group has a ``channel`` dimension value
+        with a duplicate value.
+    RuntimeError
+        If ``channel_selection=None`` and the ``channel`` dimensions are not the
+        same across the same group under each object in ``echodatas``.
+    NotImplementedError
+        If ``channel_selection`` is a list and the listed channels are not contained
+        in the ``EchoData`` group across all objects in ``echodatas``.
 
     Notes
     -----
@@ -221,7 +534,7 @@ def combine_echodata(
     * The instance attributes ``source_file`` and ``converted_raw_path`` of the combined
       ``EchoData`` object will be copied from the first ``EchoData`` object in the given list.
     * If no ``zarr_path`` is provided, the combined zarr file will be
-      ``'temp_echopype_output/combined_echodatas.zarr'`` under the current working directory.
+      ``'~/.echopype/temp_output/combined_echodata.zarr'``.
     * If no ``client`` is provided, then a client with a local scheduler will be used. The
       created scheduler and client will be shutdown once computation has finished.
     * For each run of this function, we print our the client dashboard link.
@@ -230,7 +543,7 @@ def combine_echodata(
     --------
     Combine lazy loaded ``EchoData`` objects:
 
-    >>> ed1 = echopype.open_converted("file1.nc")
+    >>> ed1 = echopype.open_converted("file1.zarr")
     >>> ed2 = echopype.open_converted("file2.zarr")
     >>> combined = echopype.combine_echodata(echodatas=[ed1, ed2],
     >>>                                      zarr_path="path/to/combined.zarr",
@@ -271,6 +584,12 @@ def combine_echodata(
     # Ensure the list of all EchoData objects to be combined are valid
     sonar_model, echodata_filenames = check_echodatas_input(echodatas)
 
+    # make sure channel_selection is the appropriate type and only contains the beam groups
+    _check_channel_selection_form(channel_selection)
+
+    # perform channel check and get channel selection for each EchoData group
+    ed_group_chan_sel = _check_echodata_channels(echodatas, channel_selection)
+
     # initiate ZarrCombine object
     comb = ZarrCombine()
 
@@ -281,6 +600,8 @@ def combine_echodata(
         storage_options=storage_options,
         sonar_model=sonar_model,
         echodata_filenames=echodata_filenames,
+        ed_group_chan_sel=ed_group_chan_sel,
+        consolidated=consolidated,
     )
 
     if client_created:
diff --git a/echopype/echodata/echodata.py b/echopype/echodata/echodata.py
index 2577bf246..d97e28a22 100644
--- a/echopype/echodata/echodata.py
+++ b/echopype/echodata/echodata.py
@@ -3,7 +3,7 @@
 import warnings
 from html import escape
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Tuple, Union
+from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Tuple
 
 import fsspec
 import numpy as np
@@ -14,11 +14,9 @@
 if TYPE_CHECKING:
     from ..core import EngineHint, FileFormatHint, PathHint, SonarModelsHint
 
-from ..calibrate.env_params import EnvParams
 from ..utils.coding import set_time_encodings
 from ..utils.io import check_file_existence, sanitize_file_path
 from ..utils.log import _init_logger
-from ..utils.uwa import calc_sound_speed
 from .convention import sonarnetcdf_1
 from .sensor_ep_version_mapping import ep_version_mapper
 from .widgets.utils import tree_repr
@@ -57,7 +55,6 @@ def __init__(
         open_kwargs: Optional[Dict[str, Any]] = None,
         parsed2zarr_obj=None,
     ):
-
         # TODO: consider if should open datasets in init
         #  or within each function call when echodata is used. Need to benchmark.
 
@@ -80,12 +77,10 @@ def __init__(
         self._varattrs = sonarnetcdf_1.yaml_dict["variable_and_varattributes"]
 
     def __del__(self):
-
         # TODO: this destructor seems to not work in Jupyter Lab if restart or
         #  even clear all outputs is used. It will work if you explicitly delete the object
 
         if (self.parsed2zarr_obj is not None) and (self.parsed2zarr_obj.zarr_file_name is not None):
-
             # get Path object of temporary zarr file created by Parsed2Zarr
             p2z_temp_file = Path(self.parsed2zarr_obj.zarr_file_name)
 
@@ -208,6 +203,10 @@ def version_info(self) -> Tuple[int]:
                 return tuple([int(i) for i in version_num])
         return None
 
+    @property
+    def nbytes(self) -> float:
+        return float(sum(self[p].nbytes for p in self.group_paths))
+
     @property
     def group_paths(self) -> Set[str]:
         return {i[1:] if i != "/" else "Top-level" for i in self._tree.groups}
@@ -261,306 +260,6 @@ def __setattr__(self, __name: str, __value: Any) -> None:
                         self._tree[group_path].ds = __value
         super().__setattr__(__name, attr_value)
 
-    @staticmethod
-    def _harmonize_env_param_time(
-        p: Union[int, float, xr.DataArray],
-        ping_time: Optional[Union[xr.DataArray, datetime.datetime]] = None,
-    ):
-        """
-        Harmonize time coordinate between Beam_groupX data and env_params to make sure
-        the timestamps are broadcast correctly in calibration and range calculations.
-
-        Regardless of the source, if `p` is an xr.DataArray, the time coordinate name
-        needs to be `time1` to be consistent with the time coordinate in EchoData["Environment"].
-        If `time1` is of length=1, the dimension `time1` is dropped.
-        Otherwise, `p` is interpolated to `ping_time`.
-        If `p` is not an xr.DataArray it is returned directly.
-
-        Parameters
-        ----------
-        p
-            The environment parameter for timestamp check/correction
-        ping_time
-            Beam_groupX ping_time to interpolate env_params timestamps to.
-            Only used if p.time1 has length >1
-
-        Returns
-        -------
-        Environment parameter with correctly broadcasted timestamps
-        """
-        if isinstance(p, xr.DataArray):
-            if "time1" not in p.coords:
-                return p
-            else:
-                # If there's only 1 time1 value,
-                # or if after dropping NaN there's only 1 time1 value
-                if p["time1"].size == 1 or p.dropna(dim="time1").size == 1:
-                    return p.dropna(dim="time1").squeeze(dim="time1").drop("time1")
-
-                # Direct assignment if all timestamps are identical (EK60 data)
-                elif np.all(p["time1"].values == ping_time.values):
-                    return p.rename({"time1": "ping_time"})
-
-                elif ping_time is None:
-                    raise ValueError(f"ping_time needs to be provided for interpolating {p.name}")
-
-                else:
-                    return p.dropna(dim="time1").interp(time1=ping_time)
-        else:
-            return p
-
-    def compute_range(
-        self,
-        env_params=None,
-        azfp_cal_type=None,
-        ek_waveform_mode=None,
-        ek_encode_mode="complex",
-    ):
-        """
-        Computes the range (``echo_range``) of the data contained in this ``EchoData``
-        object, in meters.
-
-        Currently this operation is supported for the following ``sonar_model``:
-        EK60, AZFP, EK80 (see Notes below for detail).
-
-        Parameters
-        ----------
-        env_params : dict
-            Environmental parameters needed for computing range (``echo_range``).
-            Users can supply `"sound speed"` directly,
-            or specify other variables that can be used to compute them,
-            including `"temperature"`, `"salinity"`, and `"pressure"`.
-
-            For EK60 and EK80 echosounders, by default echopype uses
-            environmental variables stored in the data files.
-            For AZFP echosounder, all environmental parameters need to be supplied.
-            AZFP echosounders typically are equipped with an internal temperature
-            sensor, and some are equipped with a pressure sensor, but automatically
-            using these pressure data is not currently supported.
-
-        azfp_cal_type : {"Sv", "TS"}, optional
-
-            - `"Sv"` for calculating volume backscattering strength
-            - `"TS"` for calculating target strength.
-
-            This parameter needs to be specified for data from the AZFP echosounder,
-            due to a difference in computing range (``echo_range``) for Sv and TS.
-
-        ek_waveform_mode : {"CW", "BB"}, optional
-            Type of transmit waveform.
-            Required only for data from the EK80 echosounder.
-
-            - `"CW"` for narrowband transmission,
-              returned echoes recorded either as complex or power/angle samples
-            - `"BB"` for broadband transmission,
-              returned echoes recorded as complex samples
-
-        ek_encode_mode : {"complex", "power"}, optional
-            Type of encoded return echo data.
-            Required only for data from the EK80 echosounder.
-
-            - `"complex"` for complex samples
-            - `"power"` for power/angle samples, only allowed when
-              the echosounder is configured for narrowband transmission
-
-        Returns
-        -------
-        xr.DataArray
-            The range (``echo_range``) of the data in meters.
-
-        Raises
-        ------
-        ValueError
-            - When `sonar_model` is `"AZFP"` but `azfp_cal_type` is not specified or is `None`.
-            - When `sonar_model` is `"EK80"` but `ek_waveform_mode` is not specified or is `None`.
-            - When `sonar_model` is `"EK60"` but `waveform_mode` is `"BB"`
-            - When `sonar_model` is `"AZFP"` and `env_params` does not contain
-              either `"sound_speed"` or all of `"temperature"`, `"salinity"`, and `"pressure"`.
-            - When `sonar_model` is `"EK60"` or `"EK80"`,
-              EchoData["Environment"].sound_speed_indicative does not exist,
-              and `env_params` does not contain either `"sound_speed"`
-              or all of `"temperature"`, `"salinity"`, and `"pressure"`.
-            - When `sonar_model` is not `"AZFP"`, `"EK60"`, or `"EK80"`.
-
-        Notes
-        -----
-        The EK80 echosounder can be configured to transmit
-        either broadband (``waveform_mode="BB"``)
-        or narrowband (``waveform_mode="CW"``) signals.
-        When transmitting in broadband mode, the returned echoes are
-        encoded as complex samples (``encode_mode="complex"``).
-        When transmitting in narrowband mode, the returned echoes can be encoded
-        either as complex samples (``encode_mode="complex"``)
-        or as power/angle combinations (``encode_mode="power"``) in a format
-        similar to those recorded by EK60 echosounders.
-
-        For AZFP echosounder, the returned ``echo_range`` is duplicated along ``ping_time``
-        to conform with outputs from other echosounders, even though within each data
-        file the range is held constant.
-        """
-
-        if isinstance(env_params, EnvParams):
-            env_params = env_params._apply(self)
-
-        if "sound_speed" in env_params:
-            sound_speed = env_params["sound_speed"]
-        elif (
-            self.sonar_model in ("EK60", "EK80") and "sound_speed_indicative" in self["Environment"]
-        ):
-            sound_speed = self["Environment"]["sound_speed_indicative"]
-        elif all([param in env_params for param in ("temperature", "salinity", "pressure")]):
-            sound_speed = calc_sound_speed(
-                env_params["temperature"],
-                env_params["salinity"],
-                env_params["pressure"],
-                formula_source="AZFP" if self.sonar_model == "AZFP" else "Mackenzie",
-            )
-        else:
-            raise ValueError(
-                "sound speed must be specified in env_params, "
-                "with temperature, salinity, and pressure all specified in env_params "
-                "for sound speed to be calculated, "
-                "or in EchoData['Environment'].sound_speed_indicative "
-                "for EK60 and EK80 sonar models"
-            )
-
-        # AZFP
-        if self.sonar_model == "AZFP":
-            cal_type = azfp_cal_type
-            if cal_type is None:
-                raise ValueError("azfp_cal_type must be specified when sonar_model is AZFP")
-
-            # Notation below follows p.86 of user manual
-            N = self["Vendor_specific"]["number_of_samples_per_average_bin"]  # samples per bin
-            f = self["Vendor_specific"]["digitization_rate"]  # digitization rate
-            L = self["Vendor_specific"]["lockout_index"]  # number of lockout samples
-
-            # keep this in ref of AZFP matlab code,
-            # set to 1 since we want to calculate from raw data
-            bins_to_avg = 1
-
-            # Harmonize sound_speed time1 and Beam_group1 ping_time
-            sound_speed = self._harmonize_env_param_time(
-                p=sound_speed,
-                ping_time=self["Sonar/Beam_group1"].ping_time,
-            )
-
-            # Calculate range using parameters for each freq
-            # This is "the range to the centre of the sampling volume
-            # for bin m" from p.86 of user manual
-            if cal_type == "Sv":
-                range_offset = 0
-            else:
-                range_offset = (
-                    sound_speed * self["Sonar/Beam_group1"]["transmit_duration_nominal"] / 4
-                )  # from matlab code
-            range_meter = (
-                sound_speed * L / (2 * f)
-                + (sound_speed / 4)
-                * (
-                    ((2 * (self["Sonar/Beam_group1"].range_sample + 1) - 1) * N * bins_to_avg - 1)
-                    / f
-                    + self["Sonar/Beam_group1"]["transmit_duration_nominal"]
-                )
-                - range_offset
-            )
-
-            range_meter.name = "echo_range"  # add name to facilitate xr.merge
-
-            return range_meter
-
-        # EK
-        elif self.sonar_model in ("EK60", "EK80", "ES70", "ES80", "EA640"):
-            waveform_mode = ek_waveform_mode
-            encode_mode = ek_encode_mode
-
-            # EK60 can only be CW mode
-            if self.sonar_model == "EK60":
-                if waveform_mode is None:
-                    waveform_mode = "CW"  # default to CW mode
-                elif waveform_mode != "CW":
-                    raise ValueError("EK60 must have CW samples")
-
-            # EK80 needs waveform_mode specification
-            if self.sonar_model == "EK80" and waveform_mode is None:
-                raise ValueError("ek_waveform_mode must be specified when sonar_model is EK80")
-
-            # TVG correction factor changes depending when the echo recording starts
-            # wrt when the transmit signal is sent out.
-            # This implementation is different for EK60 and EK80.
-            tvg_correction_factor = TVG_CORRECTION_FACTOR[self.sonar_model]
-
-            if waveform_mode == "CW":
-                if (
-                    self.sonar_model == "EK80"
-                    and encode_mode == "power"
-                    and self["Sonar/Beam_group2"] is not None
-                ):
-                    # if both CW and BB exist and beam_power group is not empty
-                    # this means that CW is recorded in power/angle mode
-                    beam = self["Sonar/Beam_group2"]
-                else:
-                    beam = self["Sonar/Beam_group1"]
-
-                # Harmonize sound_speed time1 and Beam_groupX ping_time
-                sound_speed = self._harmonize_env_param_time(
-                    p=sound_speed,
-                    ping_time=beam.ping_time,
-                )
-
-                sample_thickness = beam["sample_interval"] * sound_speed / 2
-                # TODO: Check with the AFSC about the half sample difference
-                range_meter = (
-                    beam.range_sample - tvg_correction_factor
-                ) * sample_thickness  # [frequency x range_sample]
-            elif waveform_mode == "BB":
-                beam = self["Sonar/Beam_group1"]  # always use the Beam group
-                # TODO: bug: right now only first ping_time has non-nan range
-                shift = beam["transmit_duration_nominal"]  # based on Lar Anderson's Matlab code
-
-                # Harmonize sound_speed time1 and Beam_group1 ping_time
-                sound_speed = self._harmonize_env_param_time(
-                    p=sound_speed,
-                    ping_time=beam.ping_time,
-                )
-
-                # TODO: once we allow putting in arbitrary sound_speed,
-                # change below to use linearly-interpolated values
-                range_meter = (
-                    (beam.range_sample * beam["sample_interval"] - shift) * sound_speed / 2
-                )
-                # TODO: Lar Anderson's code include a slicing by minRange with a default of 0.02 m,
-                #  need to ask why and see if necessary here
-            else:
-                raise ValueError("Input waveform_mode not recognized!")
-
-            # make order of dims conform with the order of backscatter data
-            range_meter = range_meter.transpose("channel", "ping_time", "range_sample")
-            range_meter = range_meter.where(range_meter > 0, 0)  # set negative ranges to 0
-
-            # set entries with NaN backscatter data to NaN
-            if "beam" in beam["backscatter_r"].dims:
-                # Drop beam because echo_range does not have beam dimension
-                valid_idx = ~beam["backscatter_r"].isel(beam=0).drop("beam").isnull()
-            else:
-                valid_idx = ~beam["backscatter_r"].isnull()
-            range_meter = range_meter.where(valid_idx)
-
-            # remove time1 if exists as a coordinate
-            if "time1" in range_meter.coords:
-                range_meter = range_meter.drop("time1")
-
-            # add name to facilitate xr.merge
-            range_meter.name = "echo_range"
-
-            return range_meter
-
-        # OTHERS
-        else:
-            raise ValueError(
-                "this method only supports the following sonar_model: AZFP, EK60, and EK80"
-            )
-
     def update_platform(
         self,
         extra_platform_data: xr.Dataset,
@@ -785,7 +484,15 @@ def _load_group(self, filepath: "PathHint", group: Optional[str] = None):
             **self.open_kwargs,
         )
 
-    def to_netcdf(self, save_path: Optional["PathHint"] = None, **kwargs):
+    def to_netcdf(
+        self,
+        save_path: Optional["PathHint"] = None,
+        compress: bool = True,
+        overwrite: bool = False,
+        parallel: bool = False,
+        output_storage_options: Dict[str, str] = {},
+        **kwargs,
+    ):
         """Save content of EchoData to netCDF.
 
         Parameters
@@ -802,12 +509,33 @@ def to_netcdf(self, save_path: Optional["PathHint"] = None, **kwargs):
             whether or not to use parallel processing. (Not yet implemented)
         output_storage_options : dict
             Additional keywords to pass to the filesystem class.
+        **kwargs : dict, optional
+            Extra arguments to `xr.Dataset.to_netcdf`: refer to
+            xarray's documentation for a list of all possible arguments.
         """
         from ..convert.api import to_file
 
-        return to_file(self, "netcdf4", save_path=save_path, **kwargs)
+        return to_file(
+            echodata=self,
+            engine="netcdf4",
+            save_path=save_path,
+            compress=compress,
+            overwrite=overwrite,
+            parallel=parallel,
+            output_storage_options=output_storage_options,
+            **kwargs,
+        )
 
-    def to_zarr(self, save_path: Optional["PathHint"] = None, **kwargs):
+    def to_zarr(
+        self,
+        save_path: Optional["PathHint"] = None,
+        compress: bool = True,
+        overwrite: bool = False,
+        parallel: bool = False,
+        output_storage_options: Dict[str, str] = {},
+        consolidated: bool = True,
+        **kwargs,
+    ):
         """Save content of EchoData to zarr.
 
         Parameters
@@ -824,10 +552,26 @@ def to_zarr(self, save_path: Optional["PathHint"] = None, **kwargs):
             whether or not to use parallel processing. (Not yet implemented)
         output_storage_options : dict
             Additional keywords to pass to the filesystem class.
+        consolidated : bool
+            Flag to consolidate zarr metadata.
+            Defaults to ``True``
+        **kwargs : dict, optional
+            Extra arguments to `xr.Dataset.to_zarr`: refer to
+            xarray's documentation for a list of all possible arguments.
         """
         from ..convert.api import to_file
 
-        return to_file(self, "zarr", save_path=save_path, **kwargs)
+        return to_file(
+            echodata=self,
+            engine="zarr",
+            save_path=save_path,
+            compress=compress,
+            overwrite=overwrite,
+            parallel=parallel,
+            output_storage_options=output_storage_options,
+            consolidated=consolidated,
+            **kwargs,
+        )
 
     # TODO: Remove below in future versions. They are for supporting old API calls.
     @property
diff --git a/echopype/echodata/sensor_ep_version_mapping/v05x_to_v06x.py b/echopype/echodata/sensor_ep_version_mapping/v05x_to_v06x.py
index 5e54a8eb3..8053aa94a 100644
--- a/echopype/echodata/sensor_ep_version_mapping/v05x_to_v06x.py
+++ b/echopype/echodata/sensor_ep_version_mapping/v05x_to_v06x.py
@@ -47,9 +47,7 @@ def _range_bin_to_range_sample(ed_obj):
     """
 
     for grp_path in ed_obj.group_paths:
-
         if "range_bin" in list(ed_obj[grp_path].coords):
-
             # renames range_bin in the dataset
             ed_obj[grp_path] = ed_obj[grp_path].rename(name_dict={"range_bin": "range_sample"})
 
@@ -76,9 +74,7 @@ def _add_attrs_to_freq(ed_obj):
     """
 
     for grp_path in ed_obj.group_paths:
-
         if "frequency" in list(ed_obj[grp_path].coords):
-
             # creates consistent frequency attributes
             ed_obj[grp_path]["frequency"] = ed_obj[grp_path].frequency.assign_attrs(
                 {
@@ -129,7 +125,6 @@ def get_channel_id(ed_obj, sensor):
     A datarray specifying the channel_ids with dimension frequency.
     """
     if sensor == "AZFP":
-
         # create frequency_nominal variable
         freq_nom = ed_obj["Sonar/Beam_group1"].frequency
 
@@ -144,7 +139,6 @@ def get_channel_id(ed_obj, sensor):
         )
 
     else:
-
         # in the case of EK80 we cannot infer the correct frequency
         # for each channel id, but we can obtain this information
         # from the XML string in the Vendor attribute
@@ -193,9 +187,7 @@ def _frequency_to_channel(ed_obj, sensor):
     channel_id = get_channel_id(ed_obj, sensor)  # all channel ids
 
     for grp_path in ed_obj.group_paths:
-
         if "frequency" in ed_obj[grp_path]:
-
             # add frequency_nominal
             ed_obj[grp_path]["frequency_nominal"] = ed_obj[grp_path].frequency
             ed_obj[grp_path] = ed_obj[grp_path].rename({"frequency": "channel"})
@@ -248,7 +240,6 @@ def _change_beam_var_names(ed_obj, sensor):
     """
 
     if sensor == "EK60":
-
         ed_obj["Sonar/Beam_group1"] = ed_obj["Sonar/Beam_group1"].rename(
             {"beamwidth_receive_alongship": "beamwidth_twoway_alongship"}
         )
@@ -270,9 +261,7 @@ def _change_beam_var_names(ed_obj, sensor):
         )
 
     if sensor in ["EK60", "EK80"]:
-
         for beam_group in ed_obj._tree["Sonar"].children.values():
-
             beam_group.ds.angle_sensitivity_alongship.attrs[
                 "long_name"
             ] = "alongship angle sensitivity of the transducer"
@@ -315,7 +304,6 @@ def _add_comment_to_beam_vars(ed_obj, sensor):
     """
 
     if sensor in ["EK60", "EK80"]:
-
         for beam_group in ed_obj._tree["Sonar"].children.values():
             beam_group.ds.beamwidth_twoway_alongship.attrs["comment"] = (
                 "Introduced in echopype for Simrad echosounders to avoid "
@@ -386,9 +374,7 @@ def _beam_groups_to_convention(ed_obj, set_grp_cls):
     """
 
     for beam_group in ed_obj._tree["Sonar"].children.values():
-
         if "quadrant" in beam_group.ds:
-
             # change quadrant to beam, assign its values
             # to a string starting at 1, and set attributes
             beam_group.ds = beam_group.ds.rename({"quadrant": "beam"})
@@ -444,7 +430,6 @@ def _modify_sonar_group(ed_obj, sensor):
 
     # add sonar_serial_number to EK80 Sonar group
     if sensor == "EK80":
-
         ed_obj["Sonar"] = ed_obj["Sonar"].assign(
             {
                 "sonar_serial_number": (
@@ -558,7 +543,6 @@ def _add_vars_to_platform(ed_obj, sensor):
         ed_obj["Platform"] = ed_obj["Platform"].rename({"heave": "vertical_offset"})
 
     if sensor == "EK80":
-
         ed_obj["Platform"]["drop_keel_offset"] = xr.DataArray(
             data=[ed_obj["Platform"].attrs["drop_keel_offset"]], dims=["time3"]
         )
@@ -630,7 +614,6 @@ def _add_vars_coords_to_environment(ed_obj, sensor):
     """
 
     if sensor == "EK80":
-
         ed_obj["Environment"]["sound_velocity_source"] = (
             ["ping_time"],
             np.array(len(ed_obj["Environment"].ping_time) * ["None"]),
@@ -705,7 +688,6 @@ def _rearrange_azfp_attrs_vars(ed_obj, sensor):
     """
 
     if sensor == "AZFP":
-
         beam_to_plat_vars = ["tilt_x", "tilt_y"]
 
         for var_name in beam_to_plat_vars:
@@ -732,7 +714,6 @@ def _rearrange_azfp_attrs_vars(ed_obj, sensor):
         del beam_to_vendor_attrs["conversion_equation_t"]
 
         for key, val in beam_to_vendor_attrs.items():
-
             ed_obj["Vendor"].attrs[key] = val
             del ed_obj["Sonar/Beam_group1"].attrs[key]
 
@@ -757,14 +738,11 @@ def _rename_mru_time_location_time(ed_obj):
     """
 
     for grp_path in ed_obj.group_paths:
-
         if "location_time" in list(ed_obj[grp_path].coords):
-
             # renames location_time to time1
             ed_obj[grp_path] = ed_obj[grp_path].rename(name_dict={"location_time": "time1"})
 
         if "mru_time" in list(ed_obj[grp_path].coords):
-
             # renames mru_time to time2
             ed_obj[grp_path] = ed_obj[grp_path].rename(name_dict={"mru_time": "time2"})
 
@@ -1005,9 +983,7 @@ def _change_list_attrs_to_str(ed_obj):
     """
 
     for var in ed_obj["Platform"]:
-
         if "valid_range" in ed_obj["Platform"][var].attrs.keys():
-
             attr_val = ed_obj["Platform"][var].attrs["valid_range"]
 
             if not isinstance(attr_val, str):
@@ -1030,7 +1006,6 @@ def _change_vertical_offset_attrs(ed_obj):
     """
 
     if "vertical_offset" in ed_obj["Platform"]:
-
         ed_obj["Platform"]["vertical_offset"].attrs = {
             "long_name": "Platform vertical offset from nominal",
             "units": "m",
diff --git a/echopype/echodata/simrad.py b/echopype/echodata/simrad.py
new file mode 100644
index 000000000..c63052409
--- /dev/null
+++ b/echopype/echodata/simrad.py
@@ -0,0 +1,235 @@
+"""
+Contains functions that are specific to Simrad echo sounders
+"""
+from typing import Optional, Tuple
+
+from .echodata import EchoData
+
+
+def check_input_args_combination(
+    waveform_mode: str, encode_mode: str, pulse_compression: bool = None
+) -> None:
+    """
+    Checks that the ``waveform_mode`` and ``encode_mode`` have
+    the correct values and that the combination of input arguments are valid, without
+    considering the actual data.
+
+    Parameters
+    ----------
+    waveform_mode: str
+        Type of transmit waveform
+    encode_mode: str
+        Type of encoded return echo data
+    pulse_compression: bool
+        States whether pulse compression should be used
+    """
+
+    if waveform_mode not in ["CW", "BB"]:
+        raise ValueError("The input waveform_mode must be either 'CW' or 'BB'!")
+
+    if encode_mode not in ["complex", "power"]:
+        raise ValueError("The input encode_mode must be either 'complex' or 'power'!")
+
+    # BB has complex data only, but CW can have complex or power data
+    if (waveform_mode == "BB") and (encode_mode == "power"):
+        raise ValueError(
+            "Data from broadband ('BB') transmission must be recorded as complex samples"
+        )
+
+    # make sure that we have BB and complex inputs, if pulse compression is selected
+    if pulse_compression is not None:
+        if pulse_compression and ((waveform_mode != "BB") or (encode_mode != "complex")):
+            raise RuntimeError(
+                "Pulse compression can only be used with "
+                "waveform_mode='BB' and encode_mode='complex'"
+            )
+
+
+def _retrieve_correct_beam_group_EK60(
+    echodata: EchoData, waveform_mode: str, encode_mode: str
+) -> Optional[str]:
+    """
+    Ensures that the provided ``waveform_mode`` and ``encode_mode`` are consistent
+    with the EK60-like data supplied by ``echodata``. Additionally, select the
+    appropriate beam group corresponding to this input.
+
+    Parameters
+    ----------
+    echodata: EchoData
+        An ``EchoData`` object holding the data
+    waveform_mode : {"CW", "BB"}
+        Type of transmit waveform
+    encode_mode : {"complex", "power"}
+        Type of encoded return echo data
+
+    Returns
+    -------
+    power_ed_group: str, optional
+        The ``EchoData`` beam group path containing the power data
+    """
+
+    # initialize power EchoData group value
+    power_ed_group = None
+
+    # EK60-like sensors must have 'power' and 'CW' modes only
+    if waveform_mode != "CW":
+        raise RuntimeError("Incorrect waveform_mode input provided!")
+    if encode_mode != "power":
+        raise RuntimeError("Incorrect encode_mode input provided!")
+
+    # ensure that no complex data exists (this should never be triggered)
+    if "backscatter_i" in echodata["Sonar/Beam_group1"].variables:
+        raise RuntimeError(
+            "Provided echodata object does not correspond to an EK60-like "
+            "sensor, but is labeled as data from an EK60-like sensor!"
+        )
+    else:
+        power_ed_group = "Sonar/Beam_group1"
+
+    return power_ed_group
+
+
+def _retrieve_correct_beam_group_EK80(
+    echodata: EchoData, waveform_mode: str, encode_mode: str
+) -> Tuple[Optional[str], Optional[str]]:
+    """
+    Ensures that the provided ``waveform_mode`` and ``encode_mode`` are consistent
+    with the EK80-like data supplied by ``echodata``. Additionally, select the
+    appropriate beam group corresponding to this input.
+
+    Parameters
+    ----------
+    echodata: EchoData
+        An ``EchoData`` object holding the data
+    waveform_mode : {"CW", "BB"}
+        Type of transmit waveform
+    encode_mode : {"complex", "power"}
+        Type of encoded return echo data
+
+    Returns
+    -------
+    power_ed_group: str, optional
+        The ``EchoData`` beam group path containing the power data
+    complex_ed_group: str, optional
+        The ``EchoData`` beam group path containing the complex data
+    """
+
+    # initialize power and complex EchoData group values
+    power_ed_group = None
+    complex_ed_group = None
+
+    if waveform_mode == "BB":
+        # check BB waveform_mode, BB must always have complex data, can have 2 beam groups
+        # when echodata contains CW power and BB complex samples, and frequency_start
+        # variable in Beam_group1
+        if waveform_mode == "BB" and "frequency_start" not in echodata["Sonar/Beam_group1"]:
+            raise ValueError("waveform_mode='BB', but broadband data not found!")
+        elif "backscatter_i" not in echodata["Sonar/Beam_group1"].variables:
+            raise ValueError("waveform_mode='BB', but complex data does not exist!")
+        elif echodata["Sonar/Beam_group2"] is not None:
+            power_ed_group = "Sonar/Beam_group2"
+            complex_ed_group = "Sonar/Beam_group1"
+        else:
+            complex_ed_group = "Sonar/Beam_group1"
+
+    else:
+        # CW can have complex or power data, so we just need to make sure that
+        # 1) complex samples always exist in Sonar/Beam_group1
+        # 2) power samples are in Sonar/Beam_group1 if only one beam group exists
+        # 3) power samples are in Sonar/Beam_group2 if two beam groups exist
+
+        # Raise error if waveform_mode="CW" but CW data does not exist
+        if (
+            encode_mode == "complex"  # only check if encode_mode="complex"
+            and "frequency_start" in echodata["Sonar/Beam_group1"]  # only check is data is BB
+        ):
+            if (
+                echodata["Sonar/Beam_group1"]["channel"].size  # total number of channels
+                == echodata["Sonar/Beam_group1"]["frequency_start"]
+                .dropna(dim="channel")["channel"]
+                .size  # number of BB channel
+            ):  # if all channels are BB
+                raise ValueError("waveform_mode='CW', but all data are broadband (BB)!")
+
+        if echodata["Sonar/Beam_group2"] is None:
+            if encode_mode == "power":
+                # power samples must be in Sonar/Beam_group1 (thus no complex samples)
+                if "backscatter_i" in echodata["Sonar/Beam_group1"].variables:
+                    raise RuntimeError("Data provided does not correspond to encode_mode='power'!")
+                else:
+                    power_ed_group = "Sonar/Beam_group1"
+            elif encode_mode == "complex":
+                # complex samples must be in Sonar/Beam_group1
+                if "backscatter_i" not in echodata["Sonar/Beam_group1"].variables:
+                    raise RuntimeError(
+                        "Data provided does not correspond to encode_mode='complex'!"
+                    )
+                else:
+                    complex_ed_group = "Sonar/Beam_group1"
+
+        else:
+            # complex should be in Sonar/Beam_group1 and power in Sonar/Beam_group2
+            # the RuntimeErrors below should never be triggered
+            if "backscatter_i" not in echodata["Sonar/Beam_group1"].variables:
+                raise RuntimeError(
+                    "Complex data does not exist in Sonar/Beam_group1, "
+                    "input echodata object must have been incorrectly constructed!"
+                )
+            elif "backscatter_r" not in echodata["Sonar/Beam_group2"].variables:
+                raise RuntimeError(
+                    "Power data does not exist in Sonar/Beam_group2, "
+                    "input echodata object must have been incorrectly constructed!"
+                )
+            else:
+                complex_ed_group = "Sonar/Beam_group1"
+                power_ed_group = "Sonar/Beam_group2"
+
+    return power_ed_group, complex_ed_group
+
+
+def retrieve_correct_beam_group(echodata: EchoData, waveform_mode: str, encode_mode: str) -> str:
+    """
+    A function to make sure that the user has provided the correct
+    ``waveform_mode`` and ``encode_mode`` inputs based off of the
+    supplied ``echodata`` object. Additionally, determine the
+    ``EchoData`` beam group corresponding to ``encode_mode``.
+
+    Parameters
+    ----------
+    echodata: EchoData
+        An ``EchoData`` object holding the data corresponding to the
+        waveform and encode modes
+    waveform_mode : {"CW", "BB"}
+        Type of transmit waveform
+    encode_mode : {"complex", "power"}
+        Type of encoded return echo data
+    pulse_compression: bool
+        States whether pulse compression should be used
+
+    Returns
+    -------
+    str
+        The ``EchoData`` beam group path corresponding to the ``encode_mode`` input
+    """
+
+    if echodata.sonar_model in ["EK60", "ES70"]:
+        # initialize complex_data_location (needed only for EK60)
+        complex_ed_group = None
+
+        # check modes against data for EK60 and get power EchoData group
+        power_ed_group = _retrieve_correct_beam_group_EK60(echodata, waveform_mode, encode_mode)
+
+    elif echodata.sonar_model in ["EK80", "ES80", "EA640"]:
+        # check modes against data for EK80 and get power/complex EchoData groups
+        power_ed_group, complex_ed_group = _retrieve_correct_beam_group_EK80(
+            echodata, waveform_mode, encode_mode
+        )
+
+    else:
+        # raise error for unknown or unaccounted for sonar model
+        raise RuntimeError("EchoData was produced by a non-Simrad or unknown Simrad echo sounder!")
+
+    if encode_mode == "complex":
+        return complex_ed_group
+    else:
+        return power_ed_group
diff --git a/echopype/echodata/widgets/utils.py b/echopype/echodata/widgets/utils.py
index ae975bcc7..98378d562 100644
--- a/echopype/echodata/widgets/utils.py
+++ b/echopype/echodata/widgets/utils.py
@@ -23,13 +23,42 @@ def make_key(value: str) -> str:
     return value + str(uuid.uuid4())
 
 
-def _single_node_repr(node):
-    root_path = "root"
+def _single_node_repr(node: DataTree) -> str:
+    """
+    Obtains the string repr for a single node in a
+    ``RenderTree`` or ``DataTree``.
+
+    Parameters
+    ----------
+    node: DataTree
+        A single node obtained from a ``RenderTree`` or ``DataTree``
+
+    Returns
+    -------
+    node_info: str
+        string representation of repr for the input ``node``
+    """
+
+    # initialize node_pathstr
     node_pathstr = "Top-level"
-    if node.name != root_path:
+
+    # obtain the appropriate group name and get its descriptions from the yaml
+    if node.name != "root":
         node_pathstr = node.path[1:]
     sonar_group = SONAR_GROUPS[node_pathstr]
-    node_info = f"{sonar_group['name']}: {sonar_group['description']}"
+
+    if "Beam_group" in sonar_group["name"]:
+        # get description of Beam_group directly from the Sonar group
+        group_descr = str(
+            node.parent["/Sonar"].ds.beam_group_descr.sel(beam_group=sonar_group["name"]).values
+        )
+    else:
+        # get description of group from yaml file
+        group_descr = sonar_group["description"]
+
+    # construct the final node information string for repr
+    node_info = f"{sonar_group['name']}: {group_descr}"
+
     return node_info
 
 
diff --git a/echopype/echodata/zarr_combine.py b/echopype/echodata/zarr_combine.py
index 48d069125..64fdcc43e 100644
--- a/echopype/echodata/zarr_combine.py
+++ b/echopype/echodata/zarr_combine.py
@@ -1,9 +1,10 @@
 from collections import defaultdict
 from itertools import islice
-from typing import Any, Dict, Hashable, List, Set, Tuple
+from typing import Any, Dict, Hashable, List, Optional, Set, Tuple
 
 import dask
 import dask.array
+import fsspec
 import numpy as np
 import pandas as pd
 import xarray as xr
@@ -11,6 +12,7 @@
 from dask.distributed import Lock
 
 from ..utils.coding import COMPRESSION_SETTINGS, get_zarr_compression
+from ..utils.io import env_indep_joinpath
 from ..utils.prov import echopype_prov_attrs
 from .api import open_converted
 from .echodata import EchoData
@@ -24,7 +26,6 @@ class ZarrCombine:
     """
 
     def __init__(self):
-
         # all possible time dimensions
         self.possible_time_dims = {"time1", "time2", "time3", "ping_time"}
 
@@ -69,11 +70,9 @@ def _check_ascending_ds_times(self, ds_list: List[xr.Dataset], ed_name: str) ->
         ed_time_dim = set(ds_list[0].dims).intersection(self.possible_time_dims)
 
         for time in ed_time_dim:
-
             # gather the first time of each Dataset
             first_times = []
             for ds in ds_list:
-
                 times = ds[time].values
                 if isinstance(times, np.ndarray):
                     # store first time if we have an array
@@ -86,7 +85,6 @@ def _check_ascending_ds_times(self, ds_list: List[xr.Dataset], ed_name: str) ->
 
             # skip check if all first times are NaT
             if not np.isnan(first_times).all():
-
                 is_descending = (np.diff(first_times) < np.timedelta64(0, "ns")).any()
 
                 if is_descending:
@@ -95,43 +93,6 @@ def _check_ascending_ds_times(self, ds_list: List[xr.Dataset], ed_name: str) ->
                         f"group {ed_name}, combine cannot be used!"
                     )
 
-    @staticmethod
-    def _check_channels(ds_list: List[xr.Dataset], ed_name: str) -> None:
-        """
-        Makes sure that each Dataset in ``ds_list`` has the
-        same number of channels and the same name for each
-        of these channels.
-
-        Parameters
-        ----------
-        ds_list: list of xr.Dataset
-            List of Datasets to be combined
-        ed_name: str
-            The name of the ``EchoData`` group being combined
-        """
-
-        if "channel" in ds_list[0].dims:
-
-            # check to make sure we have the same number of channels in each ds
-            if np.unique([len(ds["channel"].values) for ds in ds_list]).size == 1:
-
-                # make each array an element of a numpy array
-                channel_arrays = np.array([ds["channel"].values for ds in ds_list])
-
-                # check for unique rows
-                if np.unique(channel_arrays, axis=0).shape[0] > 1:
-
-                    raise RuntimeError(
-                        f"All {ed_name} groups do not have that same channel coordinate, "
-                        f"combine cannot be used!"
-                    )
-
-            else:
-                raise RuntimeError(
-                    f"All {ed_name} groups do not have that same number of channel coordinates, "
-                    f"combine cannot be used!"
-                )
-
     @staticmethod
     def _compare_attrs(attr1: dict, attr2: dict) -> List[str]:
         """
@@ -173,9 +134,7 @@ def _compare_attrs(attr1: dict, attr2: dict) -> List[str]:
         # make sure that all values are identical
         numpy_keys = []
         for key in attr1.keys():
-
             if isinstance(attr1[key], np.ndarray):
-
                 numpy_keys.append(key)
 
                 if not np.allclose(attr1[key], attr2[key], rtol=1e-12, atol=1e-12, equal_nan=True):
@@ -184,7 +143,6 @@ def _compare_attrs(attr1: dict, attr2: dict) -> List[str]:
                         f"same, combine cannot be used!"
                     )
             elif key in ["date_created", "conversion_time"]:
-
                 if not isinstance(attr1[key], type(attr2[key])):
                     raise RuntimeError(
                         f"The attribute {key}'s type amongst the ds lists "
@@ -192,7 +150,6 @@ def _compare_attrs(attr1: dict, attr2: dict) -> List[str]:
                     )
 
             else:
-
                 if attr1[key] != attr2[key]:
                     raise RuntimeError(
                         f"The attribute {key}'s value amongst the ds lists are not the "
@@ -240,7 +197,6 @@ def _get_ds_info(self, ds_list: List[xr.Dataset], ed_name: str) -> None:
         """
 
         self._check_ascending_ds_times(ds_list, ed_name)
-        self._check_channels(ds_list, ed_name)
 
         # Dataframe with column as dim names and rows as the different Datasets
         self.dims_df = pd.DataFrame([ds.dims for ds in ds_list])
@@ -263,7 +219,6 @@ def _get_ds_info(self, ds_list: List[xr.Dataset], ed_name: str) -> None:
 
         # collect Dataset attributes
         for count, ds in enumerate(ds_list):
-
             # get reduced attributes that do not include numpy keys
             red_attrs = {key: val for key, val in ds.attrs.items() if key not in numpy_keys}
 
@@ -354,7 +309,8 @@ def _get_encodings(self, name: str, val: xr.Variable, chnk_shape: List[int]) ->
             var_encoding[name].update(get_zarr_compression(val, COMPRESSION_SETTINGS["zarr"]))
 
         # set the chunk encoding
-        var_encoding[name]["chunks"] = chnk_shape
+        # cast to int type just to be safe
+        var_encoding[name]["chunks"] = [int(c) for c in chnk_shape]
 
         return var_encoding
 
@@ -398,17 +354,14 @@ def _construct_lazy_ds_and_var_info(
         encodings = dict()
         const_names = []
         for name, val in ds_model.variables.items():
-
             # get all dimensions of val that are also append dimensions
             append_dims_in_val = set(val.dims).intersection(self.append_dims)
 
             if not append_dims_in_val:
-
                 # collect the names of all constant variables/dimensions
                 const_names.append(str(name))
 
             else:
-
                 # create lazy DataArray representation
                 temp_arr, chnk_shape = self._get_temp_arr(list(val.dims), val.dtype)
 
@@ -449,9 +402,7 @@ def _get_region(self, ds_ind: int, ds_dims: Set[Hashable]) -> Dict[str, slice]:
         # get the initial region
         region = dict()
         for dim in ds_dims:
-
             if dim not in self.append_dims:
-
                 region[dim] = slice(0, self.dims_df.loc[ds_ind][dim])
 
         return region
@@ -528,7 +479,7 @@ def _get_chunk_dicts(self, dim: str) -> Tuple[Dict[int, np.ndarray], Dict[int, n
         zarr_chunk_size = min(self.dims_max[dim], self.max_append_chunk_size)
 
         # get the indices of the final combined array that are in each uniform chunk
-        uniform_chunk = self._uniform_chunks_as_np_array(x_no_chunk, zarr_chunk_size)
+        uniform_chunk = self._uniform_chunks_as_np_array(x_no_chunk, int(zarr_chunk_size))
 
         # construct a mapping between the uniform chunk and the indices
         uniform_chunk_dict = dict(zip(range(len(uniform_chunk)), uniform_chunk))
@@ -564,14 +515,11 @@ def _get_uniform_to_nonuniform_map(self, dim: str) -> Dict[int, dict]:
         # construct the uniform to non-uniform mapping
         final_mapping = defaultdict(dict)
         for u_key, u_val in uniform_chunk_dict.items():
-
             for og_key, og_val in og_chunk_dict.items():
-
                 # find the intersection of uniform and non-uniform chunk indices
                 intersect = np.intersect1d(u_val, og_val)
 
                 if len(intersect) > 0:
-
                     # get min and max indices in intersect
                     min_val = intersect.min()
                     max_val = intersect.max()
@@ -633,6 +581,7 @@ def write_to_file(
                 compute=True,
                 storage_options=storage_options,
                 synchronizer=zarr.ThreadSynchronizer(),
+                consolidated=False,
             )
 
             # TODO: put a check to make sure that the chunk has been written
@@ -683,9 +632,9 @@ def _append_ds_list_to_zarr(
             compute=False,
             group=zarr_group,
             encoding=encodings,
-            consolidated=None,
             storage_options=storage_options,
             synchronizer=zarr.ThreadSynchronizer(),
+            consolidated=False,
         )
 
         # get all dimensions in ds that are append dimensions
@@ -695,7 +644,6 @@ def _append_ds_list_to_zarr(
         # in ds_list to the zarr store
         delayed_to_zarr = []
         for dim in ds_append_dims:
-
             # collect all variables/coordinates that should be dropped
             drop_names = [
                 var_name
@@ -708,9 +656,15 @@ def _append_ds_list_to_zarr(
 
             for uniform_ind, non_uniform_dict in chunk_mapping.items():
                 for ds_list_ind, dim_slice in non_uniform_dict.items():
-
                     # get ds containing only variables who have dim in their dims
-                    ds_drop = ds_list[ds_list_ind].drop_vars(drop_names)
+                    cur_ds = ds_list[ds_list_ind]
+                    # get all variable names from dataset
+                    ds_keys = [k for k in cur_ds.variables.keys()]
+                    # compare variable names from dataset with the first
+                    # file's variable names and only grab the ones that exists
+                    # in the current dataset
+                    final_drop_names = [name for name in drop_names if name in ds_keys]
+                    ds_drop = ds_list[ds_list_ind].drop_vars(final_drop_names)
 
                     # get xarray region for all dims, except dim
                     region = self._get_region(ds_list_ind, set(ds_drop.dims))
@@ -728,7 +682,12 @@ def _append_ds_list_to_zarr(
                     # write the subset of each Dataset to a zarr file
                     delayed_to_zarr.append(
                         self.write_to_file(
-                            ds_in, lock_name, zarr_path, zarr_group, region, storage_options
+                            ds_in,
+                            lock_name,
+                            zarr_path,
+                            zarr_group,
+                            region,
+                            storage_options,
                         )
                     )
 
@@ -772,7 +731,6 @@ def _append_const_to_zarr(
 
         # write constant vars to zarr using the first element of ds_list
         for var in const_vars:
-
             # make sure to choose the dataset with the largest size for variable
             if var in self.dims_df:
                 ds_list_ind = int(self.dims_df[var].argmax())
@@ -780,7 +738,11 @@ def _append_const_to_zarr(
                 ds_list_ind = int(0)
 
             ds_list[ds_list_ind][[var]].to_zarr(
-                zarr_path, group=zarr_group, mode="a", storage_options=storage_options
+                zarr_path,
+                group=zarr_group,
+                mode="a",
+                storage_options=storage_options,
+                consolidated=False,
             )
 
     def _write_append_dims(
@@ -812,9 +774,7 @@ def _write_append_dims(
         ds_append_dims = set(ds_list[0].dims).intersection(self.append_dims)
 
         for dim in ds_append_dims:
-
             for count, ds in enumerate(ds_list):
-
                 # obtain the appropriate region to write to
                 if count == 0:
                     region = {str(dim): slice(0, self.dims_csum[dim][count])}
@@ -830,6 +790,7 @@ def _write_append_dims(
                     compute=True,
                     storage_options=storage_options,
                     synchronizer=zarr.ThreadSynchronizer(),
+                    consolidated=False,
                 )
 
     def _append_provenance_attr_vars(
@@ -852,15 +813,12 @@ def _append_provenance_attr_vars(
 
         xr_dict = dict()
         for name, val in self.group_attrs.items():
-
             if "attrs" in name:
-
                 # create Dataset variables
                 coord_name = name[:-1] + "_key"
                 xr_dict[name] = {"dims": ["echodata_filename", coord_name], "data": val}
 
             else:
-
                 # create Dataset coordinates
                 xr_dict[name] = {"dims": [name], "data": val}
 
@@ -890,13 +848,11 @@ def _append_provenance_attr_vars(
             group="Provenance",
             mode="a",
             storage_options=storage_options,
-            consolidated=True,
+            consolidated=False,
         )
 
     @staticmethod
-    def _modify_prov_filenames(
-        zarr_path: str, len_eds: int, storage_options: Dict[str, Any] = {}
-    ) -> None:
+    def _modify_prov_filenames(zarr_path: str, storage_options: Dict[str, Any] = {}) -> None:
         """
         After the ``Provenance`` group has been constructed, the
         coordinate ``filenames`` will be filled with zeros. This
@@ -907,8 +863,6 @@ def _modify_prov_filenames(
         ----------
         zarr_path: str
             The full path of the final combined zarr store
-        len_eds: int
-            The number of ``EchoData`` objects being combined
         storage_options: dict
             Any additional parameters for the storage
             backend (ignored for local paths)
@@ -916,10 +870,13 @@ def _modify_prov_filenames(
 
         # obtain the filenames zarr array
         zarr_filenames = zarr.open_array(
-            zarr_path + "/Provenance/filenames", mode="r+", storage_options=storage_options
+            env_indep_joinpath(zarr_path, "Provenance", "filenames"),
+            mode="r+",
+            storage_options=storage_options,
         )
-
-        zarr_filenames[:] = np.arange(len_eds)
+        # Assume that this is 1D so zarr_filenames.shape
+        # is expected to return tuple such as (x,)
+        zarr_filenames[:] = np.arange(*zarr_filenames.shape)
 
     def combine(
         self,
@@ -928,6 +885,8 @@ def combine(
         storage_options: Dict[str, Any] = {},
         sonar_model: str = None,
         echodata_filenames: List[str] = [],
+        ed_group_chan_sel: Dict[str, Optional[List[str]]] = {},
+        consolidated: bool = True,
     ) -> EchoData:
         """
         Combines all ``EchoData`` objects in ``eds`` by
@@ -940,6 +899,7 @@ def combine(
             The full path of the final combined zarr store
         eds: list of EchoData object
             The list of ``EchoData`` objects to be combined
+            The list of ``EchoData`` objects to be combined
         storage_options: dict
             Any additional parameters for the storage
             backend (ignored for local paths)
@@ -947,6 +907,14 @@ def combine(
             The sonar model used for all elements in ``eds``
         echodata_filenames : list of str
             The source files names for all elements in ``eds``
+        ed_group_chan_sel: dict
+            A dictionary with keys corresponding to the ``EchoData`` groups
+            and values specify what channels should be selected within that
+            group. If a value is ``None``, then a subset of channels should
+            not be selected.
+        consolidated: bool
+            Flag to consolidate zarr metadata.
+            Defaults to ``True``
 
         Returns
         -------
@@ -984,18 +952,24 @@ def combine(
 
         # loop through all possible group and write them to a zarr store
         for grp_info in EchoData.group_map.values():
-
             # obtain the appropriate group name
             if grp_info["ep_group"]:
                 ed_group = grp_info["ep_group"]
             else:
                 ed_group = "Top-level"
 
-            # collect the group Dataset from all eds
-            ds_list = [ed[ed_group] for ed in eds if ed_group in ed.group_paths]
+            # collect the group Dataset from all eds that have their channels unselected
+            all_chan_ds_list = [ed[ed_group] for ed in eds if ed_group in ed.group_paths]
 
-            if ds_list:  # necessary because a group may not be present
+            # select only the appropriate channels from each Dataset
+            ds_list = [
+                ds.sel(channel=ed_group_chan_sel[ed_group])
+                if ed_group_chan_sel[ed_group] is not None
+                else ds
+                for ds in all_chan_ds_list
+            ]
 
+            if ds_list:  # necessary because a group may not be present
                 const_names = self._append_ds_list_to_zarr(
                     zarr_path,
                     ds_list=ds_list,
@@ -1005,16 +979,33 @@ def combine(
                 )
 
                 self._append_const_to_zarr(
-                    const_names, ds_list, zarr_path, grp_info["ep_group"], storage_options
+                    const_names,
+                    ds_list,
+                    zarr_path,
+                    grp_info["ep_group"],
+                    storage_options,
                 )
 
-                self._write_append_dims(ds_list, zarr_path, grp_info["ep_group"], storage_options)
+                self._write_append_dims(
+                    ds_list,
+                    zarr_path,
+                    grp_info["ep_group"],
+                    storage_options,
+                )
 
         # append all group attributes before combination to zarr store
-        self._append_provenance_attr_vars(zarr_path, storage_options=storage_options)
+        self._append_provenance_attr_vars(
+            zarr_path,
+            storage_options=storage_options,
+        )
 
         # change filenames numbering to range(len(eds))
-        self._modify_prov_filenames(zarr_path, len_eds=len(eds), storage_options=storage_options)
+        self._modify_prov_filenames(zarr_path, storage_options=storage_options)
+
+        if consolidated:
+            # consolidate at the end if consolidated flag is True
+            store = fsspec.get_mapper(zarr_path, **storage_options)
+            zarr.consolidate_metadata(store=store)
 
         # open lazy loaded combined EchoData object
         ed_combined = open_converted(
@@ -1022,6 +1013,7 @@ def combine(
             chunks={},
             synchronizer=zarr.ThreadSynchronizer(),
             storage_options=storage_options,
+            backend_kwargs={"consolidated": consolidated},
         )  # TODO: is this appropriate for chunks?
 
         return ed_combined
diff --git a/echopype/mask/__init__.py b/echopype/mask/__init__.py
new file mode 100644
index 000000000..24b6c172f
--- /dev/null
+++ b/echopype/mask/__init__.py
@@ -0,0 +1,3 @@
+from .api import apply_mask, frequency_differencing
+
+__all__ = ["frequency_differencing", "apply_mask"]
diff --git a/echopype/mask/api.py b/echopype/mask/api.py
new file mode 100644
index 000000000..7521878d8
--- /dev/null
+++ b/echopype/mask/api.py
@@ -0,0 +1,566 @@
+import datetime
+import operator as op
+import pathlib
+from typing import List, Optional, Union
+
+import numpy as np
+import xarray as xr
+
+from ..utils.io import validate_source_ds_da
+from ..utils.prov import echopype_prov_attrs
+
+# lookup table with key string operator and value as corresponding Python operator
+str2ops = {
+    ">": op.gt,
+    "<": op.lt,
+    "<=": op.le,
+    ">=": op.ge,
+    "==": op.eq,
+}
+
+
+def _validate_and_collect_mask_input(
+    mask: Union[
+        Union[xr.DataArray, str, pathlib.Path], List[Union[xr.DataArray, str, pathlib.Path]]
+    ],
+    storage_options_mask: Union[dict, List[dict]],
+) -> Union[xr.DataArray, List[xr.DataArray]]:
+    """
+    Validate that the input ``mask`` and associated ``storage_options_mask`` are correctly
+    provided to ``apply_mask``. Additionally, form the mask input that should be used
+    in the core routine of ``apply_mask``.
+
+    Parameters
+    ----------
+    mask: xr.DataArray, str, pathlib.Path, or a list of these datatypes
+        The mask(s) to be applied. Can be a single input or list that corresponds to a
+        DataArray or a path. If a path is provided this should point to a zarr or netcdf
+        file with only one data variable in it.
+    storage_options_mask: dict or list of dict, default={}
+        Any additional parameters for the storage backend, corresponding to the
+        path provided for ``mask``. If ``mask`` is a list, then this input should either
+        be a list of dictionaries or a single dictionary with storage options that
+        correspond to all elements in ``mask`` that are paths.
+
+    Returns
+    -------
+    xr.DataArray or list of xr.DataArray
+        If the ``mask`` input is a single value, then the corresponding DataArray will be
+        returned, else a list of DataArrays corresponding to the input masks will be returned
+
+    Raises
+    ------
+    ValueError
+        If ``mask`` is a single-element and ``storage_options_mask`` is not a single dict
+    TypeError
+        If ``storage_options_mask`` is not a list of dict or a dict
+    """
+
+    if isinstance(mask, list):
+        # if storage_options_mask is not a list create a list of
+        # length len(mask) with elements storage_options_mask
+        if not isinstance(storage_options_mask, list):
+            if not isinstance(storage_options_mask, dict):
+                raise TypeError("storage_options_mask must be a list of dict or a dict!")
+
+            storage_options_mask = [storage_options_mask] * len(mask)
+        else:
+            # ensure all element of storage_options_mask are a dict
+            if not all([isinstance(elem, dict) for elem in storage_options_mask]):
+                raise TypeError("storage_options_mask must be a list of dict or a dict!")
+
+        for mask_ind in range(len(mask)):
+            # validate the mask type or path (if it is provided)
+            mask_val, file_type = validate_source_ds_da(
+                mask[mask_ind], storage_options_mask[mask_ind]
+            )
+
+            # replace mask element path with its corresponding DataArray
+            if isinstance(mask_val, (str, pathlib.Path)):
+                # open up DataArray using mask path
+                mask[mask_ind] = xr.open_dataarray(
+                    mask_val, engine=file_type, chunks={}, **storage_options_mask[mask_ind]
+                )
+
+    else:
+        if not isinstance(storage_options_mask, dict):
+            raise ValueError(
+                "The provided input storage_options_mask should be a single "
+                "dict because mask is a single value!"
+            )
+
+        # validate the mask type or path (if it is provided)
+        mask, file_type = validate_source_ds_da(mask, storage_options_mask)
+
+        if isinstance(mask, (str, pathlib.Path)):
+            # open up DataArray using mask path
+            mask = xr.open_dataarray(mask, engine=file_type, chunks={}, **storage_options_mask)
+
+    return mask
+
+
+def _check_var_name_fill_value(
+    source_ds: xr.Dataset, var_name: str, fill_value: Union[int, float, np.ndarray, xr.DataArray]
+) -> None:
+    """
+    Ensures that the inputs ``var_name`` and ``fill_value`` for the function
+    ``apply_mask`` were appropriately provided.
+
+    Parameters
+    ----------
+    source_ds: xr.Dataset
+        A Dataset that contains the variable ``var_name``
+    var_name: str
+        The variable name in ``source_ds`` that the mask should be applied to
+    fill_value: int or float or np.ndarray or xr.DataArray
+        Specifies the value(s) at false indices
+
+    Raises
+    ------
+    TypeError
+        If ``var_name`` or ``fill_value`` are not an accepted type
+    ValueError
+        If the Dataset ``source_ds`` does not contain ``var_name``
+    ValueError
+        If ``fill_value`` is an array and not the same shape as ``var_name``
+    """
+
+    # check the type of var_name
+    if not isinstance(var_name, str):
+        raise TypeError("The input var_name must be a string!")
+
+    # ensure var_name is in source_ds
+    if var_name not in source_ds.variables:
+        raise ValueError("The Dataset source_ds does not contain the variable var_name!")
+
+    # check the type of fill_value
+    if not isinstance(fill_value, (int, float, np.ndarray, xr.DataArray)):
+        raise TypeError(
+            "The input fill_value must be of type int or " "float or np.ndarray or xr.DataArray!"
+        )
+
+    # make sure that fill_values is the same shape as var_name, if it is an array
+    if isinstance(fill_value, (np.ndarray, xr.DataArray)) and (
+        fill_value.shape != source_ds[var_name].shape
+    ):
+        raise ValueError("If fill_value is an array is must be of the same shape as var_name!")
+
+
+def _variable_prov_attrs(
+    masked_da: xr.DataArray, source_mask: Union[xr.DataArray, List[xr.DataArray]]
+) -> dict:
+    """
+    Extract and compose masked Sv provenance attributes from the masked Sv and the
+    masks used to generate it.
+
+    Parameters
+    ----------
+    masked_da: xr.DataArray
+        Masked Sv
+    source_mask: Union[xr.DataArray, List[xr.DataArray]]
+        Individual mask or list of masks used to create the masked Sv
+
+    Returns
+    -------
+    dict
+        Dictionary of provenance attributes (attribute name and value) for the intended variable.
+    """
+    # Modify core variable attributes
+    attrs = {
+        "long_name": "Volume backscattering strength, masked (Sv re 1 m-1)",
+        "actual_range": [
+            round(float(masked_da.min().values), 2),
+            round(float(masked_da.max().values), 2),
+        ],
+    }
+    # Add history attribute
+    history_attr = f"{datetime.datetime.utcnow()} +00:00. " "Created masked Sv dataarray."  # noqa
+    attrs = {**attrs, **{"history": history_attr}}
+
+    # Add attributes from the mask DataArray, if present
+    # Handle only a single mask. If not passed to apply_mask as a single DataArray,
+    # will use the first mask of the list passed to  apply_mask
+    # TODO: Expand it to handle attributes from multiple masks
+    if isinstance(source_mask, xr.DataArray) or (
+        isinstance(source_mask, list) and isinstance(source_mask[0], xr.DataArray)
+    ):
+        use_mask = source_mask[0] if isinstance(source_mask, list) else source_mask
+        if len(use_mask.attrs) > 0:
+            mask_attrs = use_mask.attrs.copy()
+            if "history" in mask_attrs:
+                # concatenate the history string as new line
+                attrs["history"] += f"\n{mask_attrs['history']}"
+                mask_attrs.pop("history")
+            attrs = {**attrs, **mask_attrs}
+
+    return attrs
+
+
+def apply_mask(
+    source_ds: Union[xr.Dataset, str, pathlib.Path],
+    mask: Union[xr.DataArray, str, pathlib.Path, List[Union[xr.DataArray, str, pathlib.Path]]],
+    var_name: str = "Sv",
+    fill_value: Union[int, float, np.ndarray, xr.DataArray] = np.nan,
+    storage_options_ds: dict = {},
+    storage_options_mask: Union[dict, List[dict]] = {},
+) -> xr.Dataset:
+    """
+    Applies the provided mask(s) to the Sv variable ``var_name``
+    in the provided Dataset ``source_ds``.
+
+    Parameters
+    ----------
+    source_ds: xr.Dataset, str, or pathlib.Path
+        Points to a Dataset that contains the variable the mask should be applied to
+    mask: xr.DataArray, str, pathlib.Path, or a list of these datatypes
+        The mask(s) to be applied. Can be a single input or list that corresponds to
+        a DataArray or a path. If a path is provided this should point to a zarr or
+        netcdf file with only one data variable in it.
+    var_name: str, default="Sv"
+        The Sv variable name in ``source_ds`` that the mask should be applied to
+    fill_value: int, float, np.ndarray, or xr.DataArray, default=np.nan
+        Value(s) at masked indices
+    storage_options_ds: dict, default={}
+        Any additional parameters for the storage backend, corresponding to the
+        path provided for ``source_ds``
+    storage_options_mask: dict or list of dict, default={}
+        Any additional parameters for the storage backend, corresponding to the
+        path provided for ``mask``. If ``mask`` is a list, then this input should either
+        be a list of dictionaries or a single dictionary with storage options that
+        correspond to all elements in ``mask`` that are paths.
+
+    Returns
+    -------
+    xr.Dataset
+        A Dataset with the same format of ``source_ds`` with the mask(s) applied to ``var_name``
+
+    Notes
+    -----
+    If the input ``mask`` is a list, then a logical AND will be used to produce the final
+    mask that will be applied to ``var_name``.
+    """
+
+    # validate the source_ds type or path (if it is provided)
+    source_ds, file_type = validate_source_ds_da(source_ds, storage_options_ds)
+
+    if isinstance(source_ds, str):
+        # open up Dataset using source_ds path
+        source_ds = xr.open_dataset(source_ds, engine=file_type, chunks={}, **storage_options_ds)
+
+    # validate and form the mask input to be used downstream
+    mask = _validate_and_collect_mask_input(mask, storage_options_mask)
+
+    # ensure that var_name and fill_value were correctly provided
+    _check_var_name_fill_value(source_ds, var_name, fill_value)
+
+    # select data only, if fill_value is a DataArray (necessary since
+    # xr.where(keep_attrs=True) is not functioning correctly)
+    if isinstance(fill_value, xr.DataArray):
+        fill_value = fill_value.data
+
+    # obtain final mask to be applied to var_name
+    if isinstance(mask, list):
+        # perform a logical AND element-wise operation across the masks
+        final_mask = np.logical_and.reduce(mask)
+
+        # xr.where has issues with attrs when final_mask is an array, so we make it a DataArray
+        final_mask = xr.DataArray(final_mask, coords=mask[0].coords)
+    else:
+        final_mask = mask
+
+    # sanity check to make sure final_mask is the same shape as source_ds[var_name]
+    if final_mask.shape != source_ds[var_name].shape:
+        raise ValueError("Final constructed mask is not the same shape as source_ds[var_name]!")
+
+    # apply the mask to var_name
+    var_name_masked = xr.where(final_mask, x=source_ds[var_name], y=fill_value, keep_attrs=True)
+
+    # obtain a shallow copy of source_ds
+    output_ds = source_ds.copy(deep=False)
+
+    # replace var_name with var_name_masked
+    output_ds[var_name] = var_name_masked
+
+    # Add or modify variable and global (dataset) provenance attributes
+    output_ds[var_name] = output_ds[var_name].assign_attrs(
+        _variable_prov_attrs(output_ds[var_name], mask)
+    )
+
+    process_type = "mask"
+    prov_dict = echopype_prov_attrs(process_type=process_type)
+    prov_dict[f"{process_type}_function"] = "mask.apply_mask"
+
+    output_ds = output_ds.assign_attrs(prov_dict)
+
+    return output_ds
+
+
+def _check_freq_diff_non_data_inputs(
+    freqAB: Optional[List[float]] = None,
+    chanAB: Optional[List[str]] = None,
+    operator: str = ">",
+    diff: Union[float, int] = None,
+) -> None:
+    """
+    Checks that the non-data related inputs of ``frequency_differencing`` (i.e. ``freqAB``,
+    ``chanAB``, ``operator``, ``diff``) were correctly provided.
+
+    Parameters
+    ----------
+    freqAB: list of float, optional
+        The pair of nominal frequencies to be used for frequency-differencing, where
+        the first element corresponds to ``freqA`` and the second element corresponds
+        to ``freqB``
+    chanAB: list of float, optional
+        The pair of channels that will be used to select the nominal frequencies to be
+        used for frequency-differencing, where the first element corresponds to ``freqA``
+        and the second element corresponds to ``freqB``
+    operator: {">", "<", "<=", ">=", "=="}
+        The operator for the frequency-differencing
+    diff: float or int
+        The threshold of Sv difference between frequencies
+    """
+
+    # check that either freqAB or chanAB are provided and they are a list of length 2
+    if (freqAB is None) and (chanAB is None):
+        raise ValueError("Either freqAB or chanAB must be given!")
+    elif (freqAB is not None) and (chanAB is not None):
+        raise ValueError("Only freqAB or chanAB must be given, but not both!")
+    elif freqAB is not None:
+        if not isinstance(freqAB, list):
+            raise TypeError("freqAB must be a list!")
+        elif len(set(freqAB)) != 2:
+            raise ValueError("freqAB must be a list of length 2 with unique elements!")
+    else:
+        if not isinstance(chanAB, list):
+            raise TypeError("chanAB must be a list!")
+        elif len(set(chanAB)) != 2:
+            raise ValueError("chanAB must be a list of length 2 with unique elements!")
+
+    # check that operator is a string and a valid operator
+    if not isinstance(operator, str):
+        raise TypeError("operator must be a string!")
+    else:
+        if operator not in [">", "<", "<=", ">=", "=="]:
+            raise ValueError("Invalid operator!")
+
+    # ensure that diff is a float or an int
+    if not isinstance(diff, (float, int)):
+        raise TypeError("diff must be a float or int!")
+
+
+def _check_source_Sv_freq_diff(
+    source_Sv: xr.Dataset,
+    freqAB: Optional[List[float]] = None,
+    chanAB: Optional[List[str]] = None,
+) -> None:
+    """
+    Ensures that ``source_Sv`` contains ``channel`` as a coordinate and
+    ``frequency_nominal`` as a variable, the provided list input
+    (``freqAB`` or ``chanAB``) are contained in the coordinate ``channel``
+    or variable ``frequency_nominal``, and ``source_Sv`` does not have
+    repeated values for ``channel`` and ``frequency_nominal``.
+
+    Parameters
+    ----------
+    source_Sv: xr.Dataset
+        A Dataset that contains the Sv data to create a mask for
+    freqAB: list of float, optional
+        The pair of nominal frequencies to be used for frequency-differencing, where
+        the first element corresponds to ``freqA`` and the second element corresponds
+        to ``freqB``
+    chanAB: list of float, optional
+        The pair of channels that will be used to select the nominal frequencies to be
+        used for frequency-differencing, where the first element corresponds to ``freqA``
+        and the second element corresponds to ``freqB``
+    """
+
+    # check that channel and frequency nominal are in source_Sv
+    if "channel" not in source_Sv.coords:
+        raise ValueError("The Dataset defined by source_Sv must have channel as a coordinate!")
+    elif "frequency_nominal" not in source_Sv.variables:
+        raise ValueError(
+            "The Dataset defined by source_Sv must have frequency_nominal as a variable!"
+        )
+
+    # make sure that the channel and frequency_nominal values are not repeated in source_Sv
+    if len(set(source_Sv.channel.values)) < source_Sv.channel.size:
+        raise ValueError(
+            "The provided source_Sv contains repeated channel values, this is not allowed!"
+        )
+
+    if len(set(source_Sv.frequency_nominal.values)) < source_Sv.frequency_nominal.size:
+        raise ValueError(
+            "The provided source_Sv contains repeated frequency_nominal "
+            "values, this is not allowed!"
+        )
+
+    # check that the elements of freqAB are in frequency_nominal
+    if (freqAB is not None) and (not all([freq in source_Sv.frequency_nominal for freq in freqAB])):
+        raise ValueError(
+            "The provided list input freqAB contains values that "
+            "are not in the frequency_nominal variable!"
+        )
+
+    # check that the elements of chanAB are in channel
+    if (chanAB is not None) and (not all([chan in source_Sv.channel for chan in chanAB])):
+        raise ValueError(
+            "The provided list input chanAB contains values that are "
+            "not in the channel coordinate!"
+        )
+
+
+def frequency_differencing(
+    source_Sv: Union[xr.Dataset, str, pathlib.Path],
+    storage_options: Optional[dict] = {},
+    freqAB: Optional[List[float]] = None,
+    chanAB: Optional[List[str]] = None,
+    operator: str = ">",
+    diff: Union[float, int] = None,
+) -> xr.DataArray:
+    """
+    Create a mask based on the differences of Sv values using a pair of
+    frequencies. This method is often referred to as the "frequency-differencing"
+    or "dB-differencing" method.
+
+    Parameters
+    ----------
+    source_Sv: xr.Dataset or str or pathlib.Path
+        If a Dataset this value contains the Sv data to create a mask for,
+        else it specifies the path to a zarr or netcdf file containing
+        a Dataset. This input must correspond to a Dataset that has the
+        coordinate ``channel`` and variables ``frequency_nominal`` and ``Sv``.
+    storage_options: dict, optional
+        Any additional parameters for the storage backend, corresponding to the
+        path provided for ``source_Sv``
+    freqAB: list of float, optional
+        The pair of nominal frequencies to be used for frequency-differencing, where
+        the first element corresponds to ``freqA`` and the second element corresponds
+        to ``freqB``. Only one of ``freqAB`` and ``chanAB`` should be provided, and not both.
+    chanAB: list of strings, optional
+        The pair of channels that will be used to select the nominal frequencies to be
+        used for frequency-differencing, where the first element corresponds to ``freqA``
+        and the second element corresponds to ``freqB``. Only one of ``freqAB`` and ``chanAB``
+        should be provided, and not both.
+    operator: {">", "<", "<=", ">=", "=="}
+        The operator for the frequency-differencing
+    diff: float or int
+        The threshold of Sv difference between frequencies
+
+    Returns
+    -------
+    xr.DataArray
+        A DataArray containing the mask for the Sv data. Regions satisfying the thresholding
+        criteria are filled with ``True``, else the regions are filled with ``False``.
+
+    Raises
+    ------
+    ValueError
+        If neither ``freqAB`` or ``chanAB`` are given
+    ValueError
+        If both ``freqAB`` and ``chanAB`` are given
+    TypeError
+        If any input is not of the correct type
+    ValueError
+        If either ``freqAB`` or ``chanAB`` are provided and the list
+        does not contain 2 distinct elements
+    ValueError
+        If ``freqAB`` contains values that are not contained in ``frequency_nominal``
+    ValueError
+        If ``chanAB`` contains values that not contained in ``channel``
+    ValueError
+        If ``operator`` is not one of the following: ``">", "<", "<=", ">=", "=="``
+    ValueError
+        If the path provided for ``source_Sv`` is not a valid path
+    ValueError
+        If ``freqAB`` or ``chanAB`` is provided and the Dataset produced by ``source_Sv``
+        does not contain the coordinate ``channel`` and variable ``frequency_nominal``
+
+    Notes
+    -----
+    This function computes the frequency differencing as follows:
+    ``Sv_freqA - Sv_freqB operator diff``. Thus, if ``operator = "<"``
+    and ``diff = "5"`` the following would be calculated:
+    ``Sv_freqA - Sv_freqB < 5``.
+
+    Examples
+    --------
+    Compute frequency-differencing mask using a mock Dataset and channel selection:
+
+    >>> n = 5 # set the number of ping times and range samples
+    ...
+    >>> # create mock Sv data
+    >>> Sv_da = xr.DataArray(data=np.stack([np.arange(n**2).reshape(n,n), np.identity(n)]),
+    ...                      coords={"channel": ['chan1', 'chan2'],
+    ...                              "ping_time": np.arange(n), "range_sample":np.arange(n)})
+    ...
+    >>> # obtain mock frequency_nominal data
+    >>> freq_nom = xr.DataArray(data=np.array([1.0, 2.0]),
+    ...                         coords={"channel": ['chan1', 'chan2']})
+    ...
+    >>> # construct mock Sv Dataset
+    >>> Sv_ds = xr.Dataset(data_vars={"Sv": Sv_da, "frequency_nominal": freq_nom})
+    ...
+    >>> # compute frequency-differencing mask using channel names
+    >>> echopype.mask.frequency_differencing(source_Sv=mock_Sv_ds, storage_options={}, freqAB=None,
+    ...                                      chanAB = ['chan1', 'chan2'],
+    ...                                      operator = ">=", diff=10.0)
+    <xarray.DataArray 'mask' (ping_time: 5, range_sample: 5)>
+    array([[False, False, False, False, False],
+           [False, False, False, False, False],
+           [ True,  True,  True,  True,  True],
+           [ True,  True,  True,  True,  True],
+           [ True,  True,  True,  True,  True]])
+    Coordinates:
+      * ping_time     (ping_time) int64 0 1 2 3 4
+      * range_sample  (range_sample) int64 0 1 2 3 4
+    """
+
+    # check that non-data related inputs were correctly provided
+    _check_freq_diff_non_data_inputs(freqAB, chanAB, operator, diff)
+
+    # validate the source_Sv type or path (if it is provided)
+    source_Sv, file_type = validate_source_ds_da(source_Sv, storage_options)
+
+    if isinstance(source_Sv, str):
+        # open up Dataset using source_Sv path
+        source_Sv = xr.open_dataset(source_Sv, engine=file_type, chunks={}, **storage_options)
+
+    # check the source_Sv with respect to channel and frequency_nominal
+    _check_source_Sv_freq_diff(source_Sv, freqAB, chanAB)
+
+    # determine chanA and chanB
+    if freqAB is not None:
+        # obtain position of frequency provided in frequency_nominal
+        freqA_pos = np.argwhere(source_Sv.frequency_nominal.values == freqAB[0]).flatten()[0]
+        freqB_pos = np.argwhere(source_Sv.frequency_nominal.values == freqAB[1]).flatten()[0]
+
+        # get channels corresponding to frequencies provided
+        chanA = str(source_Sv.channel.isel(channel=freqA_pos).values)
+        chanB = str(source_Sv.channel.isel(channel=freqB_pos).values)
+
+    else:
+        # get individual channels
+        chanA = chanAB[0]
+        chanB = chanAB[1]
+
+    # get the left-hand side of condition
+    lhs = source_Sv["Sv"].sel(channel=chanA) - source_Sv["Sv"].sel(channel=chanB)
+
+    # create mask using operator lookup table
+    da = xr.where(str2ops[operator](lhs, diff), True, False)
+
+    # assign a name to DataArray
+    da.name = "mask"
+
+    # assign provenance attributes
+    mask_attrs = {"mask_type": "frequency differencing"}
+    history_attr = (
+        f"{datetime.datetime.utcnow()} +00:00. "
+        "Mask created by mask.frequency_differencing. "
+        f"Operation: Sv['{chanA}'] - Sv['{chanB}'] {operator} {diff}"
+    )
+
+    da = da.assign_attrs({**mask_attrs, **{"history": history_attr}})
+
+    return da
diff --git a/echopype/preprocess/api.py b/echopype/preprocess/api.py
index 5b6e4be02..27a0453fc 100644
--- a/echopype/preprocess/api.py
+++ b/echopype/preprocess/api.py
@@ -7,66 +7,10 @@
 import xarray as xr
 
 from ..utils.prov import echopype_prov_attrs
+from .mvbs import get_MVBS_along_channels
 from .noise_est import NoiseEst
 
 
-def _check_range_uniqueness(da):
-    """
-    Check if range (``echo_range``) changes across ping in a given frequency channel.
-    """
-    # squeeze to remove "channel" dim if present
-    # TODO: not sure why not already removed for the AZFP case. Investigate.
-    da = da.squeeze()
-
-    # remove pings with NaN entries if exist
-    # since goal here is to check uniqueness
-    if np.unique(da.isnull(), axis=0).shape[0] != 1:
-        da = da.dropna(dim="ping_time", how="any")
-
-    # remove padded NaN entries if exist for all pings
-    da = da.dropna(dim="range_sample", how="all")
-
-    ping_time_idx = np.argwhere([dim == "ping_time" for dim in da.dims])[0][0]
-    if np.unique(da, axis=ping_time_idx).shape[ping_time_idx] == 1:
-        return xr.DataArray(data=True, coords={"channel": da["channel"].values})
-    else:
-        return xr.DataArray(data=False, coords={"channel": da["channel"].values})
-
-
-def _freq_MVBS(ds, rint, pbin):
-    # squeeze to remove "channel" dim if present
-    # TODO: not sure why not already removed for the AZFP case. Investigate.
-    ds = ds.squeeze()
-
-    # average should be done in linear domain
-    sv = 10 ** (ds["Sv"] / 10)
-
-    # set 1D coordinate using the 1st ping echo_range since identical for all pings
-    # remove padded NaN entries if exist for all pings
-    er = (
-        ds["echo_range"]
-        .dropna(dim="range_sample", how="all")
-        .dropna(dim="ping_time")
-        .isel(ping_time=0)
-    )
-
-    # use first ping er as indexer for sv
-    sv = sv.sel(range_sample=er.range_sample.values)
-    sv.coords["echo_range"] = (["range_sample"], er.values)
-    sv = sv.swap_dims({"range_sample": "echo_range"})
-    sv_groupby_bins = (
-        sv.resample(ping_time=pbin, skipna=True)
-        .mean(skipna=True)
-        .groupby_bins("echo_range", bins=rint, right=False, include_lowest=True)
-        .mean(skipna=True)
-    )
-    sv_groupby_bins.coords["echo_range"] = (["echo_range_bins"], rint[:-1])
-    sv_groupby_bins = sv_groupby_bins.swap_dims({"echo_range_bins": "echo_range"}).drop_vars(
-        "echo_range_bins"
-    )
-    return 10 * np.log10(sv_groupby_bins)
-
-
 def _set_MVBS_attrs(ds):
     """
     Attach common attributes.
@@ -115,18 +59,27 @@ def compute_MVBS(ds_Sv, range_meter_bin=20, ping_time_bin="20S"):
     A dataset containing bin-averaged Sv
     """
 
-    if not ds_Sv["echo_range"].groupby("channel").apply(_check_range_uniqueness).all():
-        raise ValueError(
-            "echo_range variable changes across pings in at least one of the frequency channels."
-        )
-
-    # Groupby freq in case of different echo_range (from different sampling intervals)
+    # create bin information for echo_range
     range_interval = np.arange(0, ds_Sv["echo_range"].max() + range_meter_bin, range_meter_bin)
-    ds_MVBS = (
-        ds_Sv.groupby("channel")
-        .apply(_freq_MVBS, args=(range_interval, ping_time_bin))
-        .to_dataset()
+
+    # create bin information needed for ping_time
+    ping_interval = (
+        ds_Sv.ping_time.resample(ping_time=ping_time_bin, skipna=True).asfreq().ping_time.values
     )
+
+    # calculate the MVBS along each channel
+    MVBS_values = get_MVBS_along_channels(ds_Sv, range_interval, ping_interval)
+
+    # create MVBS dataset
+    ds_MVBS = xr.Dataset(
+        data_vars={"Sv": (["channel", "ping_time", "echo_range"], MVBS_values)},
+        coords={
+            "ping_time": ping_interval,
+            "channel": ds_Sv.channel,
+            "echo_range": range_interval[:-1],
+        },
+    )
+
     # Added this check to support the test in test_process.py::test_compute_MVBS
     if "filenames" in ds_MVBS.variables:
         ds_MVBS = ds_MVBS.drop_vars("filenames")
diff --git a/echopype/preprocess/mvbs.py b/echopype/preprocess/mvbs.py
new file mode 100644
index 000000000..2c0006f35
--- /dev/null
+++ b/echopype/preprocess/mvbs.py
@@ -0,0 +1,461 @@
+"""
+Contains core functions needed to compute the MVBS of an input dataset.
+"""
+
+import warnings
+from typing import Tuple, Union
+
+import dask.array
+import numpy as np
+import xarray as xr
+
+
+def get_bin_indices(
+    echo_range: np.ndarray, bins_er: np.ndarray, times: np.ndarray, bins_time: np.ndarray
+) -> Tuple[np.ndarray, np.ndarray]:
+    """
+    Obtains the bin index of ``echo_range`` and ``times`` based
+    on the binning ``bins_er`` and ``bins_time``, respectively.
+
+    Parameters
+    ----------
+    echo_range: np.ndarray
+        2D array of echo range values
+    bins_er: np.ndarray
+        1D array (used by np.digitize) representing the binning required for ``echo_range``
+    times: np.ndarray
+        1D array corresponding to the time values that should be binned
+    bins_time: np.ndarray
+        1D array (used by np.digitize) representing the binning required for ``times``
+
+    Returns
+    -------
+    digitized_echo_range: np.ndarray
+        2D array of bin indices for ``echo_range``
+    bin_time_ind: np.ndarray
+        1D array of bin indices for ``times``
+    """
+
+    # get bin index for each echo range value
+    digitized_echo_range = np.digitize(echo_range, bins_er, right=False)
+
+    # turn datetime into integers, so we can use np.digitize
+    if isinstance(times, dask.array.Array):
+        times_i8 = times.compute().data.view("i8")
+    else:
+        times_i8 = times.view("i8")
+
+    # turn datetime into integers, so we can use np.digitize
+    bins_time_i8 = bins_time.view("i8")
+
+    # get bin index for each time
+    bin_time_ind = np.digitize(times_i8, bins_time_i8, right=False)
+
+    return digitized_echo_range, bin_time_ind
+
+
+def bin_and_mean_echo_range(
+    arr: Union[np.ndarray, dask.array.Array], digitized_echo_range: np.ndarray, n_bin_er: int
+) -> Union[np.ndarray, dask.array.Array]:
+    """
+    Bins and means ``arr`` with respect to the ``echo_range`` bins.
+
+    Parameters
+    ----------
+    arr: np.ndarray or dask.array.Array
+        2D array (dimension: [``echo_range`` x ``ping_time``]) to bin  along ``echo_range``
+        and compute mean of each bin
+    digitized_echo_range: np.ndarray
+        2D array of bin indices for ``echo_range``
+    n_bin_er: int
+        The number of echo range bins
+
+    Returns
+    -------
+    er_means: np.ndarray or dask.array.Array
+        2D array representing the bin and mean of ``arr`` along ``echo_range``
+    """
+
+    binned_means = []
+    for bin_er in range(1, n_bin_er):
+        # Catch a known warning that can occur, which does not impact the results
+        with warnings.catch_warnings():
+            # ignore warnings caused by taking a mean of an array filled with NaNs
+            warnings.filterwarnings(action="ignore", message="Mean of empty slice")
+
+            # bin and mean echo_range dimension
+            er_selected_data = np.nanmean(arr[:, digitized_echo_range == bin_er], axis=1)
+
+        # collect all echo_range bins
+        binned_means.append(er_selected_data)
+
+    # create full echo_range binned array
+    er_means = np.vstack(binned_means)
+
+    return er_means
+
+
+def get_unequal_rows(mat: np.ndarray, row: np.ndarray) -> np.ndarray:
+    """
+    Obtains those row indices of ``mat`` that are not equal
+    to ``row``.
+
+    Parameters
+    ----------
+    mat: np.ndarray
+        2D array with the same column dimension as the number
+        of elements in ``row``
+    row: np.ndarray
+        1D array with the same number of element elements as
+        the column dimension of ``mat``
+
+    Returns
+    -------
+    row_ind_not_equal: np.ndarray
+        The row indices of ``mat`` that are not equal to ``row``
+
+    Notes
+    -----
+    Elements with NaNs are considered equal if they are in the same position.
+    """
+
+    # compare row against all rows in mat (allowing for NaNs to be equal)
+    element_nan_equal = (mat == row) | (np.isnan(mat) & np.isnan(row))
+
+    # determine if mat row is equal to row
+    row_not_equal = np.logical_not(np.all(element_nan_equal, axis=1))
+
+    if isinstance(row_not_equal, dask.array.Array):
+        row_not_equal = row_not_equal.compute()
+
+    # get those row indices that are not equal to row
+    row_ind_not_equal = np.argwhere(row_not_equal).flatten()
+
+    return row_ind_not_equal
+
+
+def if_all_er_steps_identical(er_chan: Union[xr.DataArray, np.ndarray]) -> bool:
+    """
+    A comprehensive check that determines if all ``echo_range`` values
+    along ``ping_time`` have the same step size. If they do not have
+    the same step sizes, then grouping of the ``echo_range`` values
+    will be necessary.
+
+    Parameters
+    ----------
+    er_chan: xr.DataArray or np.ndarray
+        2D array containing the ``echo_range`` values for each ``ping_time``
+
+    Returns
+    -------
+    bool
+        True, if grouping of ``echo_range`` along ``ping_time`` is necessary, otherwise False
+
+    Notes
+    -----
+    ``er_chan`` should have rows corresponding to ``ping_time`` and columns
+    corresponding to ``range_sample``
+    """
+
+    # grab the in-memory numpy echo_range values, if necessary
+    if isinstance(er_chan, xr.DataArray):
+        er_chan = er_chan.values
+
+    # grab the first ping_time that is not filled with NaNs
+    ping_index = 0
+    while np.all(np.isnan(er_chan[ping_index, :])):
+        ping_index += 1
+
+    # determine those rows of er_chan that are not equal to the row ping_index
+    unequal_ping_ind = get_unequal_rows(er_chan, er_chan[ping_index, :])
+
+    if len(unequal_ping_ind) > 0:
+        # see if all unequal_ping_ind are filled with NaNs
+        all_nans = np.all(np.all(np.isnan(er_chan[unequal_ping_ind, :]), axis=1))
+
+        if all_nans:
+            # All echo_range values have the same step size
+            return False
+        else:
+            # Some echo_range values have different step sizes
+            return True
+    else:
+        # All echo_range values have the same step size
+        return False
+
+
+def if_last_er_steps_identical(er_chan: Union[xr.DataArray, np.ndarray]) -> bool:
+    """
+    An alternative (less comprehensive) check that determines if all
+    ``echo_range`` values along ``ping_time`` have the same step size.
+    If they do not have the same step sizes, then grouping of the
+    ``echo_range`` values will be necessary.
+
+    Parameters
+    ----------
+    er_chan: xr.DataArray or np.ndarray
+        2D array containing the ``echo_range`` values for each ``ping_time``
+
+    Returns
+    -------
+    bool
+        True, if grouping of ``echo_range`` along ``ping_time`` is necessary, otherwise False
+
+    Notes
+    -----
+    It is possible that this method will incorrectly determine if grouping
+    is necessary.
+
+    ``er_chan`` should have rows corresponding to ``ping_time`` and columns
+    corresponding to ``range_sample``
+    """
+
+    # determine the number of NaNs in each ping and find the unique number of NaNs
+    unique_num_nans = np.unique(np.isnan(er_chan.data).sum(axis=1))
+
+    # compute the results, if necessary, to allow for downstream checks
+    if isinstance(unique_num_nans, dask.array.Array):
+        unique_num_nans = unique_num_nans.compute()
+
+    # determine if any value is not 0 or er_chan.shape[1]
+    unexpected_num_nans = False in np.logical_or(
+        unique_num_nans == 0, unique_num_nans == er_chan.shape[1]
+    )
+
+    if unexpected_num_nans:
+        # echo_range varies with ping_time
+        return True
+    else:
+        # make sure that the final echo_range value for each ping_time is the same (account for NaN)
+        num_non_nans = np.logical_not(np.isnan(np.unique(er_chan.data[:, -1]))).sum()
+
+        # compute the results, if necessary, to allow for downstream checks
+        if isinstance(num_non_nans, dask.array.Array):
+            num_non_nans = num_non_nans.compute()
+
+        if num_non_nans > 1:
+            # echo_range varies with ping_time
+            return True
+        else:
+            # echo_range does not vary with ping_time
+            return False
+
+
+def is_er_grouping_needed(
+    echo_range: Union[xr.DataArray, np.ndarray], comprehensive_er_check: bool
+) -> bool:
+    """
+    Determines if ``echo_range`` values along ``ping_time`` can change and
+    thus need to be grouped.
+
+    Parameters
+    ----------
+    echo_range: xr.DataArray or np.ndarray
+        2D array containing the ``echo_range`` values for each ``ping_time``
+    comprehensive_er_check: bool
+        If True, a more comprehensive check will be completed to determine if ``echo_range``
+        grouping along ``ping_time`` is needed, otherwise a less comprehensive check will be done
+
+    Returns
+    -------
+    bool
+        If True grouping of ``echo_range`` will be required, else it will not
+        be necessary
+    """
+
+    if comprehensive_er_check:
+        return if_all_er_steps_identical(echo_range)
+    else:
+        return if_last_er_steps_identical(echo_range)
+
+
+def group_dig_er_bin_mean_echo_range(
+    arr: Union[np.ndarray, dask.array.Array],
+    digitized_echo_range: Union[np.ndarray, dask.array.Array],
+    n_bin_er: int,
+) -> Union[np.ndarray, dask.array.Array]:
+    """
+    Groups the rows of ``arr`` such that they have the same corresponding
+    row values in ``digitized_echo_range``, then applies ``bin_and_mean_echo_range``
+    on each group, and lastly assembles the correctly ordered ``er_means`` array
+    representing the bin and mean of ``arr`` with respect to ``echo_range``.
+
+    Parameters
+    ----------
+    arr: dask.array.Array or np.ndarray
+        The 2D array whose values should be binned
+    digitized_echo_range: dask.array.Array or np.ndarray
+        2D array of bin indices for ``echo_range``
+    n_bin_er: int
+        The number of echo range bins
+
+    Returns
+    -------
+    er_means: dask.array.Array or np.ndarray
+        The bin and mean of ``arr`` with respect to ``echo_range``
+    """
+
+    # compute bin indices to allow for downstream processes (mainly axis argument in unique)
+    if isinstance(digitized_echo_range, dask.array.Array):
+        digitized_echo_range = digitized_echo_range.compute()
+
+    # determine the unique rows of digitized_echo_range and the inverse
+    unique_er_bin_ind, unique_inverse = np.unique(digitized_echo_range, axis=0, return_inverse=True)
+
+    # create groups of row indices using the unique inverse
+    grps_same_ind = [
+        np.argwhere(unique_inverse == grp).flatten() for grp in np.unique(unique_inverse)
+    ]
+
+    # for each group bin and mean arr along echo_range
+    # note: the values appended may not be in the correct final order
+    binned_er = []
+    for count, grp in enumerate(grps_same_ind):
+        binned_er.append(
+            bin_and_mean_echo_range(arr[grp, :], unique_er_bin_ind[count, :], n_bin_er)
+        )
+
+    # construct er_means and put the columns in the correct order
+    binned_er_array = np.hstack(binned_er)
+    correct_column_ind = np.argsort(np.concatenate(grps_same_ind))
+    er_means = binned_er_array[:, correct_column_ind]
+
+    return er_means
+
+
+def bin_and_mean_2d(
+    arr: Union[dask.array.Array, np.ndarray],
+    bins_time: np.ndarray,
+    bins_er: np.ndarray,
+    times: np.ndarray,
+    echo_range: np.ndarray,
+    comprehensive_er_check: bool = True,
+) -> np.ndarray:
+    """
+    Bins and means ``arr`` based on ``times`` and ``echo_range``,
+    and their corresponding bins. If ``arr`` is ``Sv`` then this
+    will compute the MVBS.
+
+    Parameters
+    ----------
+    arr: dask.array.Array or np.ndarray
+        The 2D array whose values should be binned
+    bins_time: np.ndarray
+        1D array (used by np.digitize) representing the binning required for ``times``
+    bins_er: np.ndarray
+        1D array (used by np.digitize) representing the binning required for ``echo_range``
+    times: np.ndarray
+        1D array corresponding to the time values that should be binned
+    echo_range: np.ndarray
+        2D array of echo range values
+    comprehensive_er_check: bool
+        If True, a more comprehensive check will be completed to determine if ``echo_range``
+        grouping along ``ping_time`` is needed, otherwise a less comprehensive check will be done
+
+    Returns
+    -------
+    final_reduced: np.ndarray
+        The final binned and mean ``arr``, if ``arr`` is ``Sv`` then this is the MVBS
+
+    Notes
+    -----
+    This function assumes that ``arr`` has rows corresponding to
+    ``ping_time`` and columns corresponding to ``echo_range``.
+
+    This function should not be run if the number of ``echo_range`` values
+    vary amongst ``ping_times``. This should not occur for our current use
+    of echopype-generated Sv data.
+    """
+
+    # get the number of echo range and time bins
+    n_bin_er = len(bins_er)
+    n_bin_time = len(bins_time)
+
+    # obtain the bin indices for echo_range and times
+    digitized_echo_range, bin_time_ind = get_bin_indices(echo_range, bins_er, times, bins_time)
+
+    # determine if grouping of echo_range values with the same step size is necessary
+    er_grouping_needed = is_er_grouping_needed(echo_range, comprehensive_er_check)
+
+    if er_grouping_needed:
+        # groups, bins, and means arr with respect to echo_range
+        er_means = group_dig_er_bin_mean_echo_range(arr, digitized_echo_range, n_bin_er)
+    else:
+        # bin and mean arr with respect to echo_range
+        er_means = bin_and_mean_echo_range(arr, digitized_echo_range[0, :], n_bin_er)
+
+    # if er_means is a dask array we compute it so the graph does not get too large
+    if isinstance(er_means, dask.array.Array):
+        er_means = er_means.compute()
+
+    # create final reduced array i.e. MVBS
+    final = np.empty((n_bin_time, n_bin_er - 1))
+    for bin_time in range(1, n_bin_time + 1):
+        # obtain er_mean indices corresponding to the time bin
+        indices = np.argwhere(bin_time_ind == bin_time).flatten()
+
+        if len(indices) == 0:
+            # fill values with NaN, if there are no values in the bin
+            final[bin_time - 1, :] = np.nan
+        else:
+            # bin and mean the er_mean time bin
+            final[bin_time - 1, :] = np.nanmean(er_means[:, indices], axis=1)
+
+    return final
+
+
+def get_MVBS_along_channels(
+    ds_Sv: xr.Dataset, echo_range_interval: np.ndarray, ping_interval: np.ndarray
+) -> np.ndarray:
+    """
+    Computes the MVBS of ``ds_Sv`` along each channel for the given
+    intervals.
+
+    Parameters
+    ----------
+    ds_Sv: xr.Dataset
+        A Dataset containing ``Sv`` and ``echo_range`` data with coordinates
+        ``channel``, ``ping_time``, and ``range_sample``
+    echo_range_interval: np.ndarray
+        1D array (used by np.digitize) representing the binning required for ``echo_range``
+    ping_interval: np.ndarray
+        1D array (used by np.digitize) representing the binning required for ``ping_time``
+
+    Returns
+    -------
+    np.ndarray
+        The MVBS value of the input ``ds_Sv`` for all channels
+
+    Notes
+    -----
+    If the values in ``ds_Sv`` are delayed then the binning and mean of ``Sv`` with
+    respect to ``echo_range`` will take place, then the delayed result will be computed,
+    and lastly the binning and mean with respect to ``ping_time`` will be completed. It
+    is necessary to apply a compute midway through this method because Dask graph layers
+    get too large and this makes downstream operations very inefficient.
+    """
+
+    all_MVBS = []
+    for chan in ds_Sv.channel:
+        # squeeze to remove "channel" dim if present
+        # TODO: not sure why not already removed for the AZFP case. Investigate.
+        ds = ds_Sv.sel(channel=chan).squeeze()
+
+        # average should be done in linear domain
+        sv = 10 ** (ds["Sv"] / 10)
+
+        # get MVBS for channel in linear domain
+        chan_MVBS = bin_and_mean_2d(
+            sv.data,
+            bins_time=ping_interval,
+            bins_er=echo_range_interval,
+            times=sv.ping_time.data,
+            echo_range=ds["echo_range"],
+            comprehensive_er_check=True,
+        )
+
+        # apply inverse mapping to get back to the original domain and store values
+        all_MVBS.append(10 * np.log10(chan_MVBS))
+
+    # collect the MVBS values for each channel
+    return np.stack(all_MVBS, axis=0)
diff --git a/echopype/qc/api.py b/echopype/qc/api.py
index 5240d2c77..5b594960a 100644
--- a/echopype/qc/api.py
+++ b/echopype/qc/api.py
@@ -113,7 +113,6 @@ def check_and_correct_reversed_time(
     """
 
     if time_str in combined_group and exist_reversed_time(combined_group, time_str):
-
         logger.warning(
             f"{ed_group} {time_str} reversal detected; {time_str} will be corrected"  # noqa
             " (see https://github.com/OSOceanAcoustics/echopype/pull/297)"
@@ -170,7 +169,11 @@ def create_old_time_array(group: str, old_time_in: xr.DataArray) -> xr.DataArray
 
 
 def orchestrate_reverse_time_check(
-    ed_comb: EchoData, zarr_store: str, possible_time_dims: List[str], storage_options: dict
+    ed_comb: EchoData,
+    zarr_store: str,
+    possible_time_dims: List[str],
+    storage_options: dict,
+    consolidated: bool = True,
 ) -> None:
     """
     Performs a reverse time check of all groups and
@@ -194,6 +197,9 @@ def orchestrate_reverse_time_check(
         ``ed_comb``, which should be checked
     storage_options: dict
         Additional keywords to pass to the filesystem class.
+    consolidated : bool
+        Flag to consolidate zarr metadata.
+        Defaults to ``True``
 
     Notes
     -----
@@ -206,10 +212,15 @@ def orchestrate_reverse_time_check(
 
     # set Provenance attribute to zero in zarr (Dataset needed for metadata creation)
     only_attrs_ds = xr.Dataset(attrs=ed_comb["Provenance"].attrs)
-    only_attrs_ds.to_zarr(zarr_store, group="Provenance", mode="a", storage_options=storage_options)
+    only_attrs_ds.to_zarr(
+        zarr_store,
+        group="Provenance",
+        mode="a",
+        storage_options=storage_options,
+        consolidated=consolidated,
+    )
 
     for group in ed_comb.group_paths:
-
         if group != "Platform/NMEA":
             # Platform/NMEA is skipped because we found that the times which correspond to
             # other non-GPS messages are often out of order and correcting them is not
@@ -221,13 +232,11 @@ def orchestrate_reverse_time_check(
             ed_comb_time_dims = set(ed_comb[group].dims).intersection(possible_time_dims)
 
             for time in ed_comb_time_dims:
-
                 old_time = check_and_correct_reversed_time(
                     combined_group=ed_comb[group], time_str=time, ed_group=group
                 )
 
                 if old_time is not None:
-
                     old_time_array = create_old_time_array(group, old_time)
 
                     # put old times in Provenance and modify attribute
@@ -238,10 +247,18 @@ def orchestrate_reverse_time_check(
                     old_time_ds = old_time_array.to_dataset()
                     old_time_ds.attrs = ed_comb["Provenance"].attrs
                     old_time_ds.to_zarr(
-                        zarr_store, group="Provenance", mode="a", storage_options=storage_options
+                        zarr_store,
+                        group="Provenance",
+                        mode="a",
+                        storage_options=storage_options,
+                        consolidated=consolidated,
                     )
 
                     # save corrected time to zarr store
                     ed_comb[group][[time]].to_zarr(
-                        zarr_store, group=group, mode="r+", storage_options=storage_options
+                        zarr_store,
+                        group=group,
+                        mode="r+",
+                        storage_options=storage_options,
+                        consolidated=consolidated,
                     )
diff --git a/echopype/utils/coding.py b/echopype/utils/coding.py
index 264f8df4d..e2e765ae4 100644
--- a/echopype/utils/coding.py
+++ b/echopype/utils/coding.py
@@ -1,8 +1,10 @@
 from re import search
+from typing import Tuple
 
 import numpy as np
 import xarray as xr
 import zarr
+from dask.array.core import auto_chunks
 from xarray import coding
 
 DEFAULT_TIME_ENCODING = {
@@ -53,6 +55,30 @@ def _encode_dataarray(da, dtype):
     return coding.times.decode_cf_datetime(encoded_data, **read_encoding)
 
 
+def _get_auto_chunk(
+    variable: xr.DataArray, chunk_size: "int | str | float" = "100MB"
+) -> Tuple[int]:
+    """
+    Calculate default chunks for a data array based on desired chunk size
+
+    Parameters
+    ----------
+    variable : xr.DataArray
+        The data array variable to be calculated
+    chunk_size : int or str or float
+        The desired max chunk size for the array.
+        Default is 100MB
+
+    Returns
+    -------
+    tuple
+        The chunks
+    """
+    auto_tuple = tuple(["auto" for i in variable.shape])
+    chunks = auto_chunks(auto_tuple, variable.shape, chunk_size, variable.dtype)
+    return tuple([c[0] if isinstance(c, tuple) else c for c in chunks])
+
+
 def set_time_encodings(ds: xr.Dataset) -> xr.Dataset:
     """
     Set the default encoding for variables.
@@ -99,9 +125,15 @@ def set_zarr_encodings(ds: xr.Dataset, compression_settings: dict) -> dict:
     # create zarr specific encoding
     encoding = dict()
     for name, val in ds.variables.items():
-
         val_encoding = val.encoding
         val_encoding.update(get_zarr_compression(val, compression_settings))
+
+        # If data array is not a dask array yet,
+        # create a custom chunking encoding
+        # currently defaults to 100MB
+        if not ds.chunks and len(val.shape) > 0:
+            chunks = _get_auto_chunk(val)
+            val_encoding.update({"chunks": chunks})
         encoding[name] = val_encoding
 
     return encoding
@@ -130,13 +162,10 @@ def set_storage_encodings(ds: xr.Dataset, compression_settings: dict, engine: st
     """
 
     if compression_settings is not None:
-
         if engine == "zarr":
-
             encoding = set_zarr_encodings(ds, compression_settings)
 
         elif engine == "netcdf4":
-
             encoding = set_netcdf_encodings(ds, compression_settings)
 
         else:
diff --git a/echopype/utils/io.py b/echopype/utils/io.py
index 7f112a055..d867e616b 100644
--- a/echopype/utils/io.py
+++ b/echopype/utils/io.py
@@ -2,11 +2,14 @@
 echopype utilities for file handling
 """
 import os
+import pathlib
+import platform
 import sys
-from pathlib import Path
-from typing import TYPE_CHECKING, Dict, Union
+from pathlib import Path, WindowsPath
+from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union
 
 import fsspec
+import xarray as xr
 from fsspec import FSMap
 from fsspec.implementations.local import LocalFileSystem
 
@@ -27,13 +30,22 @@
 logger = _init_logger(__name__)
 
 
+ECHOPYPE_DIR = Path(os.path.expanduser("~")) / ".echopype"
+
+
+def init_ep_dir():
+    """Initialize hidden directory for echopype"""
+    if not ECHOPYPE_DIR.exists():
+        ECHOPYPE_DIR.mkdir(exist_ok=True)
+
+
 def get_files_from_dir(folder):
     """Retrieves all Netcdf and Zarr files from a given folder"""
     valid_ext = [".nc", ".zarr"]
     return [f for f in os.listdir(folder) if os.path.splitext(f)[1] in valid_ext]
 
 
-def save_file(ds, path, mode, engine, group=None, compression_settings=None):
+def save_file(ds, path, mode, engine, group=None, compression_settings=None, **kwargs):
     """
     Saves a dataset to netcdf or zarr depending on the engine
     If ``compression_settings`` are set, compress all variables with those settings
@@ -44,9 +56,9 @@ def save_file(ds, path, mode, engine, group=None, compression_settings=None):
 
     # Allows saving both NetCDF and Zarr files from an xarray dataset
     if engine == "netcdf4":
-        ds.to_netcdf(path=path, mode=mode, group=group, encoding=encoding)
+        ds.to_netcdf(path=path, mode=mode, group=group, encoding=encoding, **kwargs)
     elif engine == "zarr":
-        ds.to_zarr(store=path, mode=mode, group=group, encoding=encoding)
+        ds.to_zarr(store=path, mode=mode, group=group, encoding=encoding, **kwargs)
     else:
         raise ValueError(f"{engine} is not a supported save format")
 
@@ -58,9 +70,13 @@ def get_file_format(file):
     elif isinstance(file, FSMap):
         file = file.root
 
-    if file.endswith(".nc"):
+    if isinstance(file, str) and file.endswith(".nc"):
         return "netcdf4"
-    elif file.endswith(".zarr"):
+    elif isinstance(file, str) and file.endswith(".zarr"):
+        return "zarr"
+    elif isinstance(file, pathlib.Path) and file.suffix == ".nc":
+        return "netcdf4"
+    elif isinstance(file, pathlib.Path) and file.suffix == ".zarr":
         return "zarr"
     else:
         raise ValueError(f"Unsupported file format: {os.path.splitext(file)[1]}")
@@ -144,10 +160,33 @@ def validate_output_path(
     source_file: str,
     engine: str,
     output_storage_options: Dict = {},
-    save_path: Union[None, Path, str] = None,
+    save_path: Optional[Union[Path, str]] = None,
 ) -> str:
     """
-    Assemble output file names and path.
+    Assembles output file names and path.
+
+    The final resulting file will be saved as provided in save path.
+    If a directory path is provided then the final file name will use
+    the same name as the source file and saved within the directory
+    path in `save_path` or echopype's `temp_output` directory.
+
+    Example 1.
+    source_file - test.raw
+    engine - zarr
+    save_path - /path/dir/
+    output is /path/dir/test.zarr
+
+    Example 2.
+    source_file - test.raw
+    engine - zarr
+    save_path - None
+    output is ~/.echopype/temp_output/test.zarr
+
+    Example 3.
+    source_file - test.raw
+    engine - zarr
+    save_path - /path/dir/myzarr.zarr
+    output is /path/dir/myzarr.zarr
 
     Parameters
     ----------
@@ -158,8 +197,22 @@ def validate_output_path(
     output_storage_options : dict
         Storage options for remote output path
     save_path : str | Path | None
-        Either a directory or a file. If none then the save path is 'temp_echopype_output/'
-        in the current working directory.
+        Either a directory or a file path.
+        If it's not provided, we will save output file(s)
+        in the echopype's `temp_output` directory.
+
+    Returns
+    -------
+    str
+        The final string path of the resulting file.
+
+    Raises
+    ------
+    ValueError
+        If engine is not one of the supported output engine of
+        zarr or netcdf
+    TypeError
+        If `save_path` is not of type Path or str
     """
     if engine not in SUPPORTED_ENGINES:
         ValueError(f"Engine {engine} is not supported for file export.")
@@ -167,12 +220,9 @@ def validate_output_path(
     file_ext = SUPPORTED_ENGINES[engine]["ext"]
 
     if save_path is None:
-        logger.warning("save_path is not provided")
+        logger.warning("A directory or file path is not provided!")
 
-        current_dir = Path.cwd()
-        # Check permission, raise exception if no permission
-        check_file_permissions(current_dir)
-        out_dir = current_dir.joinpath(Path("temp_echopype_output"))
+        out_dir = ECHOPYPE_DIR / "temp_output"
         if not out_dir.exists():
             out_dir.mkdir(parents=True)
 
@@ -181,9 +231,15 @@ def validate_output_path(
     elif not isinstance(save_path, Path) and not isinstance(save_path, str):
         raise TypeError("save_path must be a string or Path")
     else:
+        # convert save_path into a nicely formatted Windows path if we are on
+        # a Windows machine and the path is not a cloud storage path. Then convert back to a string.
+        if platform.system() == "Windows":
+            if isinstance(save_path, str) and ("://" not in save_path):
+                save_path = str(WindowsPath(save_path).absolute())
+
         if isinstance(save_path, str):
             # Clean folder path by stripping '/' at the end
-            if save_path.endswith("/"):
+            if save_path.endswith("/") or save_path.endswith("\\"):
                 save_path = save_path[:-1]
 
             # Determine whether this is a directory or not
@@ -280,3 +336,84 @@ def check_file_permissions(FILE_DIR):
                 TEST_FILE.unlink()
     except Exception:
         raise PermissionError("Writing to specified path is not permitted.")
+
+
+def env_indep_joinpath(*args: Tuple[str, ...]) -> str:
+    """
+    Joins a variable number of paths taking into account the form of
+    cloud storage paths.
+
+    Parameters
+    ----------
+    *args: tuple of str
+        A variable number of strings that should be joined in the order
+        they are provided
+
+    Returns
+    -------
+    joined_path: str
+        Full path constructed by joining all input strings
+    """
+
+    if "://" in args[0]:
+        # join paths for cloud storage path
+        joined_path = r"/".join(args)
+    else:
+        # join paths for non-cloud storage path
+        joined_path = os.path.join(*args)
+
+    return joined_path
+
+
+def validate_source_ds_da(
+    source_ds_da: Union[xr.Dataset, xr.DataArray, str, Path], storage_options: Optional[dict]
+) -> Tuple[Union[xr.Dataset, str, xr.DataArray], Optional[str]]:
+    """
+    This function ensures that ``source_ds_da`` is of the correct
+    type and validates the path of ``source_ds_da``, if it is provided.
+
+    Parameters
+    ----------
+    source_ds_da: xr.Dataset, xr.DataArray, str or pathlib.Path
+        A source that points to a Dataset or DataArray. If the input is a path,
+        it specifies the path to a zarr or netcdf file.
+    storage_options: dict, optional
+        Any additional parameters for the storage backend, corresponding to the
+        path provided for ``source_ds_da``
+
+    Returns
+    -------
+    source_ds_da: xr.Dataset or xr.DataArray or str
+        A Dataset or DataArray which will be the same as the input ``source_ds_da`` or
+        a validated path to a zarr or netcdf file
+    file_type: {"netcdf4", "zarr"}, optional
+        The file type of the input path if ``source_ds_da`` is a path, otherwise ``None``
+    """
+
+    # initialize file_type
+    file_type = None
+
+    # make sure that storage_options is of the appropriate type
+    if not isinstance(storage_options, dict):
+        raise TypeError("storage_options must be a dict!")
+
+    # check that source_ds_da is of the correct type, if it is a path validate
+    # the path and open the Dataset or DataArray using xarray
+    if not isinstance(source_ds_da, (xr.Dataset, xr.DataArray, str, Path)):
+        raise TypeError("source_ds_da must be a Dataset or DataArray or str or pathlib.Path!")
+    elif isinstance(source_ds_da, (str, Path)):
+        # determine if we obtained a zarr or netcdf file
+        file_type = get_file_format(source_ds_da)
+
+        # validate source_ds_da if it is a path
+        source_ds_da = validate_output_path(
+            source_file="blank",  # will be unused since source_ds cannot be none
+            engine=file_type,
+            output_storage_options=storage_options,
+            save_path=source_ds_da,
+        )
+
+        # check that the path exists
+        check_file_existence(file_path=source_ds_da, storage_options=storage_options)
+
+    return source_ds_da, file_type
diff --git a/echopype/utils/prov.py b/echopype/utils/prov.py
index d79280029..bd8d4b1ad 100644
--- a/echopype/utils/prov.py
+++ b/echopype/utils/prov.py
@@ -1,14 +1,20 @@
 from datetime import datetime as dt
 from pathlib import Path
-from typing import Any, Dict, List, Tuple, Union
+from typing import Dict, List, Tuple, Union
 
+import numpy as np
 from _echopype_version import version as ECHOPYPE_VERSION
+from numpy.typing import NDArray
 from typing_extensions import Literal
 
-# TODO: It'd be cleaner to use PathHint, but it leads to a circular import error
-# from ..core import PathHint
+from .log import _init_logger
 
-ProcessType = Literal["conversion", "processing"]
+ProcessType = Literal["conversion", "processing", "mask"]
+# Note that this PathHint is defined differently from the one in ..core
+PathHint = Union[str, Path]
+PathSequenceHint = Union[List[PathHint], Tuple[PathHint], NDArray[PathHint]]
+
+logger = _init_logger(__name__)
 
 
 def echopype_prov_attrs(process_type: ProcessType) -> Dict[str, str]:
@@ -30,8 +36,49 @@ def echopype_prov_attrs(process_type: ProcessType) -> Dict[str, str]:
     return prov_dict
 
 
+def _sanitize_source_files(paths: Union[PathHint, PathSequenceHint]):
+    """
+    Create sanitized list of string paths from heterogeneous path inputs.
+
+    Parameters
+    ----------
+    paths : Union[PathHint, PathSequenceHint]
+        File paths as either a single path string or pathlib Path,
+        a sequence (tuple, list or np.ndarray) of strings or pathlib Paths,
+        or a mixed sequence that may contain another sequence as an element.
+
+    Returns
+    -------
+    paths_list : List[str]
+        List of file paths. Empty list if no source path element was parsed successfully.
+    """
+    sequence_types = (list, tuple, np.ndarray)
+    if isinstance(paths, (str, Path)):
+        return [str(paths)]
+    elif isinstance(paths, sequence_types):
+        paths_list = []
+        for p in paths:
+            if isinstance(p, (str, Path)):
+                paths_list.append(str(p))
+            elif isinstance(p, sequence_types):
+                paths_list += [str(pp) for pp in p if isinstance(pp, (str, Path))]
+            else:
+                logger.warning(
+                    "Unrecognized file path element type, path element will not be"
+                    f" written to (meta)source_file provenance attribute. {p}"
+                )
+        return paths_list
+    else:
+        logger.warning(
+            "Unrecognized file path element type, path element will not be"
+            f" written to (meta)source_file provenance attribute. {paths}"
+        )
+        return []
+
+
 def source_files_vars(
-    source_paths: Union[str, List[Any]], meta_source_paths: Union[str, List[Any]] = None
+    source_paths: Union[PathHint, PathSequenceHint],
+    meta_source_paths: Union[PathHint, PathSequenceHint] = None,
 ) -> Dict[str, Dict[str, Tuple]]:
     """
     Create source_filenames and meta_source_filenames provenance
@@ -39,11 +86,15 @@ def source_files_vars(
 
     Parameters
     ----------
-    source_paths : Union[str, List[Any]]
-        Source file paths as either a single path string or a list of Path-type paths
-    meta_source_paths : Union[str, List[Any]]
-        Source file paths for metadata files (often as XML files),
-        as either a single path string or a list of Path-type paths
+    source_paths : Union[PathHint, PathSequenceHint]
+        Source file paths as either a single path string or pathlib Path,
+        a sequence (tuple, list or np.ndarray) of strings or pathlib Paths,
+        or a mixed sequence that may contain another sequence as an element.
+    meta_source_paths : Union[PathHint, PathSequenceHint]
+        Source file paths for metadata files (often as XML files), as either a
+        single path string or pathlib Path, a sequence (tuple, list or np.ndarray)
+        of strings or pathlib Paths, or a mixed sequence that may contain another
+        sequence as an element.
 
     Returns
     -------
@@ -57,19 +108,10 @@ def source_files_vars(
             meta_source_filenames xarray DataArray with filenames dimension
         source_files_coord : Dict[str, Tuple]
             Single-element dict containing a tuple for creating the
-            filenames coordinate variable DataArray
+            filenames coordinate variable xarray DataArray
     """
 
-    def _source_files(paths):
-        """Handle a plain string containing a single path,
-        a single pathlib Path, or a list of strings or pathlib paths
-        """
-        if isinstance(paths, (str, Path)):
-            return [str(paths)]
-        else:
-            return [str(p) for p in paths if isinstance(p, (str, Path))]
-
-    source_files = _source_files(source_paths)
+    source_files = _sanitize_source_files(source_paths)
     files_vars = dict()
 
     files_vars["source_files_var"] = {
@@ -80,8 +122,10 @@ def _source_files(paths):
         ),
     }
 
-    if meta_source_paths is not None:
-        meta_source_files = _source_files(meta_source_paths)
+    if meta_source_paths is None or meta_source_paths == "":
+        files_vars["meta_source_files_var"] = None
+    else:
+        meta_source_files = _sanitize_source_files(meta_source_paths)
         files_vars["meta_source_files_var"] = {
             "meta_source_filenames": (
                 "filenames",
@@ -89,8 +133,6 @@ def _source_files(paths):
                 {"long_name": "Metadata source filenames"},
             ),
         }
-    else:
-        files_vars["meta_source_files_var"] = None
 
     files_vars["source_files_coord"] = {
         "filenames": (
diff --git a/echopype/utils/uwa.py b/echopype/utils/uwa.py
index 20da758be..bbf8921d0 100644
--- a/echopype/utils/uwa.py
+++ b/echopype/utils/uwa.py
@@ -63,6 +63,7 @@ def calc_absorption(
     salinity=35,
     pressure=10,
     pH=8.1,
+    sound_speed=None,
     formula_source="AM",
 ):
     """
@@ -110,7 +111,10 @@ def calc_absorption(
     """
     if formula_source == "FG":
         f = frequency / 1000.0  # convert from Hz to kHz due to formula
-        c = 1412.0 + 3.21 * temperature + 1.19 * salinity + 0.0167 * pressure
+        if sound_speed is None:
+            c = 1412.0 + 3.21 * temperature + 1.19 * salinity + 0.0167 * pressure
+        else:
+            c = sound_speed
         A1 = 8.86 / c * 10 ** (0.78 * pH - 5)
         P1 = 1.0
         f1 = 2.8 * np.sqrt(salinity / 35) * 10 ** (4 - 1245 / (temperature + 273))
diff --git a/echopype/visualize/api.py b/echopype/visualize/api.py
index fad561eb0..72d5ffc72 100644
--- a/echopype/visualize/api.py
+++ b/echopype/visualize/api.py
@@ -4,6 +4,8 @@
 
 from .plot import _plot_echogram, FacetGrid, QuadMesh
 from ..echodata import EchoData
+from ..calibrate.calibrate_ek import CalibrateEK60, CalibrateEK80
+from ..calibrate.calibrate_azfp import CalibrateAZFP
 from ..utils.log import _init_logger
 
 logger = _init_logger(__name__)
@@ -137,12 +139,35 @@ def create_echogram(
                         "Data from broadband ('BB') transmission must be recorded as complex samples"  # noqa
                     )
 
-            range_in_meter = data.compute_range(
-                env_params=range_kwargs.get('env_params', {}),
-                azfp_cal_type=range_kwargs.get('azfp_cal_type', None),
-                ek_waveform_mode=range_kwargs.get('waveform_mode', 'CW'),
-                ek_encode_mode=range_kwargs.get('encode_mode', 'power'),
-            )
+            # Compute range via calibration objects
+            if data.sonar_model == "AZFP":
+                cal_obj = CalibrateAZFP(
+                    echodata=data,
+                    env_params=range_kwargs.get("env_params", {}),
+                    cal_params=None,
+                    waveform_mode=None,
+                    encode_mode=None,
+                )
+                if range_kwargs["azfp_cal_type"] is None:
+                    raise ValueError("azfp_cal_type must be specified when sonar_model is AZFP")
+                cal_obj.compute_echo_range(cal_type=range_kwargs["azfp_cal_type"])
+            elif data.sonar_model in ("EK60", "EK80", "ES70", "ES80", "EA640"):
+                if data.sonar_model in ["EK60", "ES70"]:
+                    cal_obj = CalibrateEK60(
+                        echodata=data,
+                        env_params=range_kwargs.get("env_params", {}),
+                        cal_params=None,
+                    )
+                else:
+                    cal_obj = CalibrateEK80(
+                        echodata=data,
+                        env_params=range_kwargs.get("env_params", {}),
+                        cal_params=None,
+                        waveform_mode=range_kwargs.get("waveform_mode", "CW"),
+                        encode_mode=range_kwargs.get("encode_mode", "power"),
+                    )
+            range_in_meter = cal_obj.range_meter
+
             range_in_meter.attrs = range_attrs
             if water_level is not None:
                 range_in_meter = _add_water_level(


Test patch:
diff --git a/.ci_helpers/run-test.py b/.ci_helpers/run-test.py
index 900747274..f29b42418 100644
--- a/.ci_helpers/run-test.py
+++ b/.ci_helpers/run-test.py
@@ -32,6 +32,8 @@
     "utils": {},
     "visualize": {},
     "metrics": {},
+    "mask": {},
+    "consolidate": {},
 }
 
 if __name__ == "__main__":
@@ -57,7 +59,7 @@
     )
     args = parser.parse_args()
     if args.local:
-        temp_path = Path("temp_echopype_output")
+        temp_path = Path("~/.echopype/temp_output")
         dump_path = Path("echopype/test_data/dump")
         if temp_path.exists():
             shutil.rmtree(temp_path)
diff --git a/echopype/tests/calibrate/test_cal_params.py b/echopype/tests/calibrate/test_cal_params.py
new file mode 100644
index 000000000..1657fbd62
--- /dev/null
+++ b/echopype/tests/calibrate/test_cal_params.py
@@ -0,0 +1,586 @@
+import pytest
+
+import numpy as np
+import xarray as xr
+
+from echopype.calibrate.cal_params import (
+    CAL_PARAMS, param2da, sanitize_user_cal_dict, _get_interp_da,
+    get_cal_params_AZFP, get_cal_params_EK, get_vend_cal_params_power
+)
+
+
+@pytest.fixture
+def freq_center():
+    return xr.DataArray(
+        [[25, 55]],
+        dims=["ping_time", "channel"],
+        coords={"channel": ["chA", "chB"], "ping_time": [1]}
+    )
+
+
+@pytest.fixture
+def vend_AZFP():
+    """
+    A mock AZFP Vendor_specific group for cal testing.
+    """
+    da = xr.DataArray([10, 20], dims=["channel"], coords={"channel": ["chA", "chB"]})
+    vend = xr.Dataset()
+    for p_name in CAL_PARAMS["AZFP"]:
+        if p_name != "equivalent_beam_angle":
+            da.name = p_name
+            vend[p_name] = da
+    return vend
+
+
+@pytest.fixture
+def beam_AZFP():
+    """
+    A mock AZFP Sonar/Beam_group1 group for cal testing.
+    """
+    beam = xr.Dataset()
+    beam["equivalent_beam_angle"] = xr.DataArray(
+        [[[10, 20]]],
+        dims=["ping_time", "beam", "channel"],
+        coords={"channel": ["chA", "chB"], "ping_time": [1], "beam": [1]},
+    )
+    return beam.transpose("channel", "ping_time", "beam")
+
+
+@pytest.fixture
+def vend_EK():
+    """
+    A mock EK Sonar/Beam_groupX group for cal testing.
+    """
+    vend = xr.Dataset()
+    for p_name in ["sa_correction", "gain_correction"]:
+        vend[p_name] = xr.DataArray(
+            np.array([[10, 20, 30, 40], [110, 120, 130, 140]]),
+            dims=["channel", "pulse_length_bin"],
+            coords={"channel": ["chA", "chB"], "pulse_length_bin": [0, 1, 2, 3]},
+        )
+    vend["pulse_length"] = xr.DataArray(
+            np.array([[64, 128, 256, 512], [128, 256, 512, 1024]]),
+            coords={"channel": vend["channel"], "pulse_length_bin": vend["pulse_length_bin"]}
+    )
+    vend["impedance_receive"] = xr.DataArray(
+        [1000, 2000], coords={"channel": vend["channel"]}
+    )
+    vend["transceiver_type"] = xr.DataArray(
+        ["WBT", "WBT"], coords={"channel": vend["channel"]}
+    )
+    return vend
+
+
+@pytest.fixture
+def beam_EK():
+    """
+    A mock EK Sonar/Beam_groupX group for cal testing.
+    """
+    beam = xr.Dataset()
+    for p_name in [
+        "equivalent_beam_angle",
+        "angle_offset_alongship", "angle_offset_athwartship",
+        "angle_sensitivity_alongship", "angle_sensitivity_athwartship",
+        "beamwidth_twoway_alongship", "beamwidth_twoway_athwartship"
+    ]:
+        beam[p_name] = xr.DataArray(
+            np.array([[[123, 123, 123, 123], [456, 456, 456, 456]]]),
+            dims=["ping_time", "channel", "beam"],
+            coords={"channel": ["chA", "chB"], "ping_time": [1], "beam": [1, 2, 3, 4]},
+        )
+    beam["frequency_nominal"] = xr.DataArray([25, 55], dims=["channel"], coords={"channel": ["chA", "chB"]})
+    return beam.transpose("channel", "ping_time", "beam")
+
+
+@pytest.mark.parametrize(
+    ("p_val", "channel", "da_output"),
+    [
+        # input p_val a scalar, input channel a list
+        (1, ["chA", "chB"], xr.DataArray([1, 1], dims=["channel"], coords={"channel": ["chA", "chB"]})),
+        # input p_val a list, input channel an xr.DataArray
+        (
+            [1, 2],
+            xr.DataArray(["chA", "chB"], dims=["channel"], coords={"channel": ["chA", "chB"]}),
+            xr.DataArray([1, 2], dims=["channel"], coords={"channel": ["chA", "chB"]})
+        ),
+        # input p_val a list with the wrong length: this should fail
+        pytest.param(
+            [1, 2, 3], ["chA", "chB"], None,
+            marks=pytest.mark.xfail(strict=True, reason="Fail since lengths of p_val and channel are not identical")
+        ),
+    ],
+    ids=[
+        "in_p_val_scalar_channel_list",
+        "in_p_val_list_channel_xrda",
+        "in_p_val_list_wrong_length",
+    ]
+)
+def test_param2da(p_val, channel, da_output):
+    da_assembled = param2da(p_val, channel)
+    assert da_assembled.identical(da_output)
+
+
+@pytest.mark.parametrize(
+    ("sonar_type", "user_dict", "channel", "out_dict"),
+    [
+        # sonar_type only allows EK or AZFP
+        pytest.param(
+            "XYZ", None, None, None,
+            marks=pytest.mark.xfail(strict=True, reason="Fail since sonar_type is not 'EK' nor 'AZFP'")
+        ),
+        # input channel
+        #   - is not a list nor an xr.DataArray: fail with value error
+        pytest.param(
+            "EK80", 1, None, None,
+            marks=pytest.mark.xfail(strict=True, reason="Fail since channel has to be either a list or an xr.DataArray"),
+        ),
+        # TODO: input channel has different order than those in the inarg channel
+        # input param dict
+        #   - contains extra param: should come out with only those defined in CAL_PARAMS
+        #   - contains missing param: missing ones (wrt CAL_PARAMS) should be empty
+        pytest.param("EK80", {"extra_param": 1}, ["chA", "chB"], dict.fromkeys(CAL_PARAMS["EK80"])),
+        # input param:
+        #   - is xr.DataArray without channel coorindate: fail with value error
+        pytest.param(
+            "EK80",
+            {"sa_correction": xr.DataArray([1, 1], dims=["some_coords"], coords={"some_coords": ["A", "B"]})},
+            ["chA", "chB"], None,
+            marks=pytest.mark.xfail(strict=True, reason="input sa_correction does not contain a 'channel' coordinate"),
+        ),
+        # input individual param:
+        #   - with channel cooridinate but not identical to argin channel: fail with value error
+        pytest.param(
+            "EK80",
+            {"sa_correction": xr.DataArray([1, 1], dims=["channel"], coords={"channel": ["chA", "B"]})},
+            ["chA", "chB"], None,
+            marks=pytest.mark.xfail(strict=True,
+                reason="input sa_correction contains a 'channel' coordinate but it is not identical with input channel"),
+        ),
+        # input individual param:
+        #   - with channel cooridinate identical to argin channel: should pass
+        pytest.param(
+            "EK80",
+            {"sa_correction": xr.DataArray([1, 1], dims=["channel"], coords={"channel": ["chA", "chB"]})},
+            ["chA", "chB"],
+            dict(dict.fromkeys(CAL_PARAMS["EK80"]),
+                **{"sa_correction": xr.DataArray([1, 1], dims=["channel"], coords={"channel": ["chA", "chB"]})}),
+        ),
+        # input individual param:
+        #   - a scalar needing to be organized to xr.DataArray at output via param2da: should pass
+        pytest.param(
+            "EK80",
+            {"sa_correction": 1},
+            ["chA", "chB"],
+            dict(dict.fromkeys(CAL_PARAMS["EK80"]),
+                **{"sa_correction": xr.DataArray([1, 1], dims=["channel"], coords={"channel": ["chA", "chB"]})}),
+        ),
+        # input individual param:
+        #   - a list needing to be organized to xr.DataArray at output via param2da: should pass
+        pytest.param(
+            "EK80",
+            {"sa_correction": [1, 2]},
+            ["chA", "chB"],
+            dict(dict.fromkeys(CAL_PARAMS["EK80"]),
+                **{"sa_correction": xr.DataArray([1, 2], dims=["channel"], coords={"channel": ["chA", "chB"]})}),
+        ),    
+        # input individual param:
+        #   - a list with wrong length (ie not identical to channel): fail with value error
+        pytest.param(
+            "EK80", {"sa_correction": [1, 2, 3]}, ["chA", "chB"], None,
+            marks=pytest.mark.xfail(strict=True,
+                reason="input sa_correction contains a list of wrong length that does not match that of channel"),
+        ),
+    ],
+    ids=[
+        "sonar_type_invalid",
+        "channel_invalid",
+        "in_extra_param",
+        "in_da_no_channel_coord",
+        "in_da_channel_not_identical",
+        "in_da_channel_identical",
+        "in_scalar",
+        "in_list",
+        "in_list_wrong_length",
+    ],
+)
+def test_sanitize_user_cal_dict(sonar_type, user_dict, channel, out_dict):
+    sanitized_dict = sanitize_user_cal_dict(sonar_type, user_dict, channel)
+    assert isinstance(sanitized_dict, dict)
+    assert len(sanitized_dict) == len(out_dict)
+    for p_name, p_val in sanitized_dict.items():
+        if isinstance(p_val, xr.DataArray):
+            assert p_val.identical(out_dict[p_name])
+        else:
+            assert p_val == out_dict[p_name]
+
+
+@pytest.mark.parametrize(
+    ("da_param", "alternative", "da_output"),
+    [
+        # da_param: alternative is const: output is xr.DataArray with all const
+        (
+            None,
+            1,
+            xr.DataArray([[1], [1]], dims=["channel", "ping_time"], coords={"channel": ["chA", "chB"], "ping_time": [1]})
+        ),
+        # da_param: alternative is xr.DataArray: output selected with the right channel
+        (
+            None,
+            xr.DataArray([1, 1, 2], dims=["channel"], coords={"channel": ["chA", "chB", "chC"]}),
+            xr.DataArray([[1], [1]], dims=["channel", "ping_time"], coords={"channel": ["chA", "chB"], "ping_time": [1]})
+        ),
+        # da_param: xr.DataArray with freq-dependent values/coordinates
+        #   - output should be interpolated with the right values
+        (
+            xr.DataArray(
+                np.array([[1, 2, 3, np.nan, np.nan, np.nan],
+                          [np.nan, np.nan, np.nan, 4, 5, 6],
+                          [np.nan, 2, 3, 4, np.nan, np.nan]]),
+                dims=["cal_channel_id", "cal_frequency"],
+                coords={"cal_channel_id": ["chA", "chB", "chC"],
+                        "cal_frequency": [10, 20, 30, 40, 50, 60]},
+            ),
+            None,
+            xr.DataArray([[2.5], [5.5]], dims=["channel", "ping_time"], coords={"ping_time": [1], "channel": ["chA", "chB"]}),
+        ),
+        # da_param: xr.DataArray with only one channel having freq-dependent values/coordinates
+        #   - that single channel should be interpolated with the right value
+        #   - other channels will use alternative
+        #   - alternative could be of the following form:
+        #       - scalar
+        (
+            xr.DataArray(
+                np.array([[np.nan, np.nan, np.nan, 4, 5, 6]]),
+                dims=["cal_channel_id", "cal_frequency"],
+                coords={"cal_channel_id": ["chB"],
+                        "cal_frequency": [10, 20, 30, 40, 50, 60]},
+            ),
+            75,
+            xr.DataArray(
+                [[75], [5.5]],
+                dims=["channel", "ping_time"],
+                coords={"ping_time": [1], "channel": ["chA", "chB"]}
+            ),
+        ),
+        #       - xr.DataArray with coordinates channel, ping_time, beam
+        (
+            xr.DataArray(
+                np.array([[np.nan, np.nan, np.nan, 4, 5, 6]]),
+                dims=["cal_channel_id", "cal_frequency"],
+                coords={"cal_channel_id": ["chB"],
+                        "cal_frequency": [10, 20, 30, 40, 50, 60]},
+            ),
+            xr.DataArray(
+                np.array([[[100, 200]]] * 4),
+                dims=["beam", "ping_time", "channel"],
+                coords={"beam": [0, 1, 2, 3], "ping_time": [1], "channel": ["chA", "chB"]},
+            ),
+            xr.DataArray(
+                [[100], [5.5]],
+                dims=["channel", "ping_time"],
+                coords={"ping_time": [1], "channel": ["chA", "chB"]}
+            ),
+        ),
+        #       - xr.DataArray with coordinates channel, ping_time
+        (
+            xr.DataArray(
+                np.array([[np.nan, np.nan, np.nan, 4, 5, 6]]),
+                dims=["cal_channel_id", "cal_frequency"],
+                coords={"cal_channel_id": ["chB"],
+                        "cal_frequency": [10, 20, 30, 40, 50, 60]},
+            ),
+            xr.DataArray(
+                np.array([[100], [200]]),
+                dims=["channel", "ping_time"],
+                coords={"ping_time": [1], "channel": ["chA", "chB"]},
+            ),
+            xr.DataArray(
+                [[100], [5.5]],
+                dims=["channel", "ping_time"],
+                coords={"ping_time": [1], "channel": ["chA", "chB"]}
+            ),
+        # TODO: cases where freq_center does not have the ping_time dimension
+        #       this is the case for CW data since freq_center = beam["frequency_nominal"]
+        #       this was caught by the file in test_compute_Sv_ek80_CW_complex()
+        # TODO: cases where freq_center contains only a single frequency
+        #       in this case had to use freq_center.sel(channel=ch_id).size because
+        #       len(freq_center.sel(channel=ch_id)) is an invalid statement
+        #       this was caught by the file in test_compute_Sv_ek80_CW_power_BB_complex()
+        ),
+    ],
+    ids=[
+        "in_None_alt_const",
+        "in_None_alt_da",
+        "in_da_all_channel_out_interp",
+        "in_da_some_channel_alt_scalar",
+        "in_da_some_channel_alt_da3coords",  # channel, ping_time, beam
+        "in_da_some_channel_alt_da2coords",  # channel, ping_time
+    ]
+)
+def test_get_interp_da(freq_center, da_param, alternative, da_output):
+    da_interp = _get_interp_da(da_param, freq_center, alternative)
+    assert da_interp.identical(da_output)
+
+
+@pytest.mark.parametrize(
+    ("user_dict", "out_dict"),
+    [
+        # input param is a scalar
+        (
+            {"EL": 1, "equivalent_beam_angle": 2},
+            dict(
+                {p_name: xr.DataArray([10, 20], dims=["channel"], coords={"channel": ["chA", "chB"]}) for p_name in CAL_PARAMS["AZFP"]},
+                **{
+                    "EL": xr.DataArray([1, 1], dims=["channel"], coords={"channel": ["chA", "chB"]}),
+                    "equivalent_beam_angle": xr.DataArray([2, 2], dims=["channel"], coords={"channel": ["chA", "chB"]}),
+                }
+            ),
+        ),
+        # input param is a list
+        (
+            {"EL": [1, 2], "equivalent_beam_angle": [3, 4]},
+            dict(
+                {p_name: xr.DataArray([10, 20], dims=["channel"], coords={"channel": ["chA", "chB"]}) for p_name in CAL_PARAMS["AZFP"]},
+                **{
+                    "EL": xr.DataArray([1, 2], dims=["channel"], coords={"channel": ["chA", "chB"]}),
+                    "equivalent_beam_angle": xr.DataArray([3, 4], dims=["channel"], coords={"channel": ["chA", "chB"]}),
+                }
+            ),
+        ),
+        # input param is a list of wrong length: this should fail
+        pytest.param(
+            {"EL": [1, 2, 3], "equivalent_beam_angle": [3, 4]}, None,
+            marks=pytest.mark.xfail(strict=True, reason="Fail since lengths of input list and channel are not identical"),
+        ),
+        # input param is an xr.DataArray with coordinate 'channel'
+        (
+            {
+                "EL": xr.DataArray([1, 2], dims=["channel"], coords={"channel": ["chA", "chB"]}),
+                "equivalent_beam_angle": xr.DataArray([3, 4], dims=["channel"], coords={"channel": ["chA", "chB"]}),
+            },
+            dict(
+                {p_name: xr.DataArray([10, 20], dims=["channel"], coords={"channel": ["chA", "chB"]}) for p_name in CAL_PARAMS["AZFP"]},
+                **{
+                    "EL": xr.DataArray([1, 2], dims=["channel"], coords={"channel": ["chA", "chB"]}),
+                    "equivalent_beam_angle": xr.DataArray([3, 4], dims=["channel"], coords={"channel": ["chA", "chB"]}),
+                }
+            ),
+        ),
+        # input param is an xr.DataArray with coordinate 'channel' but wrong length: this should fail
+        pytest.param(
+            {
+                "EL": xr.DataArray([1, 2, 3], dims=["channel"], coords={"channel": ["chA", "chB", "chC"]}),
+                "equivalent_beam_angle": xr.DataArray([3, 4, 5], dims=["channel"], coords={"channel": ["chA", "chB", "chC"]}),
+            }, None,
+            marks=pytest.mark.xfail(strict=True, reason="Fail since lengths of input data array channel and data channel are not identical"),
+        ),
+    ],
+    ids=[
+        "in_scalar",
+        "in_list",
+        "in_list_wrong_length",
+        "in_da",
+        "in_da_wrong_length",
+    ]
+)
+def test_get_cal_params_AZFP(beam_AZFP, vend_AZFP, user_dict, out_dict):
+    cal_dict = get_cal_params_AZFP(beam=beam_AZFP, vend=vend_AZFP, user_dict=user_dict)
+    for p_name, p_val in cal_dict.items():
+        # remove name for all da
+        p_val.name = None
+        out_val = out_dict[p_name]
+        out_val.name = None
+        assert p_val.identical(out_val)
+
+
+# The test above 'test_get_cal_params_AZFP' covers the cases where user input param
+# is one of the following: a scalar, list, and xr.DataArray of coords/dims ('channel')
+# Here we only test for the following new cases:
+#   - where all params are input by user
+#   - input xr.DataArray has coords/dims (cal_channel_id, cal_frequency)
+@pytest.mark.parametrize(
+    ("user_dict", "out_dict"),
+    [
+        # input xr.DataArray has coords/dims (cal_channel_id, cal_frequency)
+        (
+            {
+                "gain_correction": xr.DataArray(
+                    np.array([[1, 2, 3, np.nan, np.nan, np.nan], [np.nan, np.nan, np.nan, 4, 5, 6]]),
+                    dims=["cal_channel_id", "cal_frequency"],
+                    coords={"cal_channel_id": ["chA", "chB"],
+                            "cal_frequency": [10, 20, 30, 40, 50, 60]},
+                ),
+                # add sa_correction here to bypass things going into get_vend_cal_params_power
+                "sa_correction": xr.DataArray(
+                    np.array([111, 222]), dims=["channel"], coords={"channel": ["chA", "chB"]},
+                )
+            },
+            dict(
+                {
+                    p_name: xr.DataArray(
+                        [[123], [456]],
+                        dims=["channel", "ping_time"],
+                        coords={"channel": ["chA", "chB"], "ping_time": [1]},
+                    )
+                    for p_name in CAL_PARAMS["EK80"]
+                },
+                **{
+                    "gain_correction": xr.DataArray(
+                        [[2.5], [5.5]],
+                        dims=["channel", "ping_time"],
+                        coords={"ping_time": [1], "channel": ["chA", "chB"]},
+                    ),
+                    "sa_correction": xr.DataArray(
+                        np.array([111, 222]), dims=["channel"],
+                        coords={"channel": ["chA", "chB"]}
+                    ),
+                    "impedance_transmit": xr.DataArray(
+                        np.array([[75], [75]]), dims=["channel", "ping_time"],
+                        coords={"channel": ["chA", "chB"], "ping_time": [1]}
+                    ),
+                    "impedance_receive": xr.DataArray(
+                        np.array([1000, 2000]), dims=["channel"],
+                        coords={"channel": ["chA", "chB"]}
+                    ),
+                    "receiver_sampling_frequency": xr.DataArray(
+                        np.array([1500000, 1500000]), dims=["channel"],
+                        coords={"channel": ["chA", "chB"]}
+                    ),
+                },
+            ),
+        ),
+        pytest.param(
+            {
+                "gain_correction": xr.DataArray(
+                    np.array([[1, 2, 3, np.nan], [np.nan, 4, 5, 6], [np.nan, 2, 3, np.nan]]),
+                    dims=["cal_channel_id", "cal_frequency"],
+                    coords={"cal_channel_id": ["chA", "chB", "chC"],
+                            "cal_frequency": [10, 20, 30, 40]},
+                ),
+            },
+            None,
+            marks=pytest.mark.xfail(strict=True, reason="Fail since cal_channel_id in input param does not match channel of data"),
+        ),
+    ],
+    ids=[
+        "in_da_freq_dep",
+        "in_da_freq_dep_channel_mismatch",
+    ]
+)
+def test_get_cal_params_EK80_BB(beam_EK, vend_EK, freq_center, user_dict, out_dict):
+    cal_dict = get_cal_params_EK(
+        waveform_mode="BB", freq_center=freq_center, beam=beam_EK, vend=vend_EK, user_dict=user_dict
+    )
+    for p_name, p_val in cal_dict.items():
+        # remove name for all da
+        p_val.name = None
+        out_val = out_dict[p_name]
+        out_val.name = None
+        assert p_val.identical(out_dict[p_name])
+
+
+@pytest.mark.parametrize(
+    ("user_dict", "out_dict"),
+    [
+        # cal_params should not contain:
+        #   impedance_transmit, impedance_receive, receiver_sampling_frequency
+        (
+            {
+                # add sa_correction here to bypass things going into get_vend_cal_params_power
+                "gain_correction": xr.DataArray(
+                    [555, 777], dims=["channel"], coords={"channel": ["chA", "chB"]},
+                ),
+                # add sa_correction here to bypass things going into get_vend_cal_params_power
+                "sa_correction": xr.DataArray(
+                    [111, 222], dims=["channel"], coords={"channel": ["chA", "chB"]},
+                )
+            },
+            dict(
+                {
+                    p_name: xr.DataArray(
+                        [[123], [456]],
+                        dims=["channel", "ping_time"],
+                        coords={"channel": ["chA", "chB"], "ping_time": [1]},
+                    )
+                    for p_name in [
+                        "sa_correction", "gain_correction", "equivalent_beam_angle",
+                        "angle_offset_alongship", "angle_offset_athwartship",
+                        "angle_sensitivity_alongship", "angle_sensitivity_athwartship",
+                        "beamwidth_alongship", "beamwidth_athwartship",
+                    ]
+                },
+                **{
+                    "gain_correction": xr.DataArray(
+                        [555, 777], dims=["channel"], coords={"channel": ["chA", "chB"]},
+                    ),
+                    "sa_correction": xr.DataArray(
+                        [111, 222], dims=["channel"], coords={"channel": ["chA", "chB"]}
+                    ),
+                },
+            ),
+        ),
+    ],
+    ids=[
+        "in_da",
+    ]
+)
+def test_get_cal_params_EK60(beam_EK, vend_EK, freq_center, user_dict, out_dict):
+    # Remove some variables from Vendor group to mimic EK60 data
+    vend_EK = vend_EK.drop("impedance_receive").drop("transceiver_type")
+    cal_dict = get_cal_params_EK(
+        waveform_mode="CW", freq_center=freq_center,
+        beam=beam_EK, vend=vend_EK,
+        user_dict=user_dict, sonar_type="EK60"
+    )
+    for p_name, p_val in cal_dict.items():
+        # remove name for all da
+        p_val.name = None
+        out_val = out_dict[p_name]
+        out_val.name = None
+        assert p_val.identical(out_dict[p_name])
+
+
+@pytest.mark.parametrize(
+    ("param", "beam", "da_output"),
+    [
+        # no NaN entry in transmit_duration_nominal
+        (
+            "sa_correction",
+            xr.DataArray(
+                np.array([[64, 256, 128, 512], [512, 1024, 256, 128]]).T,
+                dims=["ping_time", "channel"],
+                coords={"ping_time": [1, 2, 3, 4], "channel": ["chA", "chB"]},
+                name="transmit_duration_nominal",
+            ).to_dataset(),
+            xr.DataArray(
+                np.array([[10, 30, 20, 40], [130, 140, 120, 110]]).T,
+                dims=["ping_time", "channel"],
+                coords={"ping_time": [1, 2, 3, 4], "channel": ["chA", "chB"]},
+                name="sa_correction",
+            ),
+        ),
+        # with NaN entry in transmit_duration_nominal
+        (
+            "sa_correction",
+            xr.DataArray(
+                np.array([[64, np.nan, 128, 512], [512, 1024, 256, np.nan]]).T,
+                dims=["ping_time", "channel"],
+                coords={"ping_time": [1, 2, 3, 4], "channel": ["chA", "chB"]},
+                name="transmit_duration_nominal",
+            ).to_dataset(),
+            xr.DataArray(
+                np.array([[10, np.nan, 20, 40], [130, 140, 120, np.nan]]).T,
+                dims=["ping_time", "channel"],
+                coords={"ping_time": [1, 2, 3, 4], "channel": ["chA", "chB"]},
+                name="sa_correction",
+            ),
+        ),
+    ],
+    ids=[
+        "in_no_nan",
+        "in_with_nan",
+    ]
+)
+def test_get_vend_cal_params_power(vend_EK, beam, param, da_output):
+    da_param = get_vend_cal_params_power(beam, vend_EK, param)
+    assert da_param.identical(da_output)
diff --git a/echopype/tests/calibrate/test_cal_params_integration.py b/echopype/tests/calibrate/test_cal_params_integration.py
new file mode 100644
index 000000000..638ed3689
--- /dev/null
+++ b/echopype/tests/calibrate/test_cal_params_integration.py
@@ -0,0 +1,190 @@
+import pytest
+
+import numpy as np
+import xarray as xr
+
+import echopype as ep
+
+
+@pytest.fixture
+def azfp_path(test_path):
+    return test_path['AZFP']
+
+
+@pytest.fixture
+def ek60_path(test_path):
+    return test_path['EK60']
+
+
+@pytest.fixture
+def ek80_cal_path(test_path):
+    return test_path['EK80_CAL']
+
+
+def test_cal_params_intake_AZFP(azfp_path):
+    """
+    Test cal param intake for AZFP calibration.
+    """
+    azfp_01a_path = str(azfp_path.joinpath('17082117.01A'))
+    azfp_xml_path = str(azfp_path.joinpath('17041823.XML'))
+    ed = ep.open_raw(azfp_01a_path, sonar_model='AZFP', xml_path=azfp_xml_path)
+
+    # Assemble external cal param and env params
+    chan = ed["Sonar/Beam_group1"]["channel"]
+    EL_ext = xr.DataArray([100, 200, 300, 400], dims=["channel"], coords={"channel": chan}, name="EL")
+    env_ext = {"salinity": 30, "pressure": 10}  # salinity and pressure required for AZFP cal
+
+    # Manually go through cal params intake
+    cal_params_manual = ep.calibrate.cal_params.get_cal_params_AZFP(
+        beam=ed["Sonar/Beam_group1"], vend=ed["Vendor_specific"], user_dict={"EL": EL_ext}
+    )
+
+    # Manually add cal params in Vendor group and construct cal object
+    cal_obj = ep.calibrate.calibrate_azfp.CalibrateAZFP(
+        echodata=ed, cal_params={"EL": EL_ext}, env_params=env_ext
+    )
+
+    # Check cal params ingested from both ways
+    assert cal_obj.cal_params["EL"].identical(cal_params_manual["EL"])
+
+    # Check against the final cal params in the calibration output
+    ds_Sv = ep.calibrate.compute_Sv(ed, cal_params={"EL": EL_ext}, env_params=env_ext)
+    assert ds_Sv["EL"].identical(cal_params_manual["EL"])
+
+
+def test_cal_params_intake_EK60(ek60_path):
+    """
+    Test cal param intake for EK60 calibration.
+    """
+    ed = ep.open_raw(ek60_path / "ncei-wcsd" / "Summer2017-D20170620-T011027.raw", sonar_model="EK60")
+
+    # Assemble external cal param
+    chan = ed["Sonar/Beam_group1"]["channel"]
+    gain_ext = xr.DataArray([100, 200, 300], dims=["channel"], coords={"channel": chan}, name="gain_correction")
+
+    # Manually go through cal params intake
+    cal_params_manual = ep.calibrate.cal_params.get_cal_params_EK(
+        waveform_mode="CW",
+        freq_center=ed["Sonar/Beam_group1"]["frequency_nominal"],
+        beam=ed["Sonar/Beam_group1"],
+        vend=ed["Vendor_specific"],
+        user_dict={"gain_correction": gain_ext},
+        sonar_type="EK60",
+    )
+
+    # Manually add cal params in Vendor group and construct cal object
+    ed["Vendor_specific"]["gain_correction"].data[0, 1] = gain_ext.data[0]  # GPT  18 kHz 009072058c8d 1-1 ES18-11
+    ed["Vendor_specific"]["gain_correction"].data[1, 2] = gain_ext.data[1]  # GPT  38 kHz 009072058146 2-1 ES38B
+    ed["Vendor_specific"]["gain_correction"].data[2, 4] = gain_ext.data[2]  # GPT 120 kHz 00907205a6d0 4-1 ES120-7C
+    cal_obj = ep.calibrate.calibrate_ek.CalibrateEK60(echodata=ed, env_params=None, cal_params=None)
+
+    # Check cal params ingested from both ways
+    assert cal_obj.cal_params["gain_correction"].isel(ping_time=0).drop("ping_time").identical(cal_params_manual["gain_correction"])
+
+    # Check against the final cal params in the calibration output
+    ds_Sv = ep.calibrate.compute_Sv(ed, cal_params={"gain_correction": gain_ext})
+    assert ds_Sv["gain_correction"].identical(cal_params_manual["gain_correction"])
+
+
+def test_cal_params_intake_EK80_BB_complex(ek80_cal_path):
+    """
+    Test frequency-dependent cal param intake for EK80 BB complex calibration.
+    """
+    ed = ep.open_raw(ek80_cal_path / "2018115-D20181213-T094600.raw", sonar_model="EK80")
+
+    # BB channels
+    chan_sel = ["WBT 714590-15 ES70-7C", "WBT 714596-15 ES38-7"]
+
+    # Assemble external freq-dependent cal param
+    len_cal_frequency = ed["Vendor_specific"]["cal_frequency"].size
+    gain_freq_dep = xr.DataArray(
+        np.array([np.arange(len_cal_frequency), (np.arange(len_cal_frequency) + 1000)[::-1]]),
+        dims=["cal_channel_id", "cal_frequency"],
+        coords={
+            "cal_channel_id": chan_sel,
+            "cal_frequency": ed["Vendor_specific"]["cal_frequency"],
+        },
+    )
+
+    # Manually go through cal params intake
+    beam = ed["Sonar/Beam_group1"].sel(channel=chan_sel)
+    vend = ed["Vendor_specific"].sel(channel=chan_sel)
+    freq_center = (
+        (beam["frequency_start"] + beam["frequency_end"])
+        .sel(channel=chan_sel).isel(beam=0).drop("beam") / 2
+    )
+    cal_params_manual = ep.calibrate.cal_params.get_cal_params_EK(
+        "BB", freq_center, beam, vend, {"gain_correction": gain_freq_dep}
+    )
+
+    # Manually add freq-dependent cal params in Vendor group
+    # and construct cal object
+    ed["Vendor_specific"]["gain"].data[1, :] = gain_freq_dep[0, :]  # WBT 714590-15 ES70-7C
+    ed["Vendor_specific"]["gain"].data[2, :] = gain_freq_dep[1, :]  # WBT 714596-15 ES38-7
+    cal_obj = ep.calibrate.calibrate_ek.CalibrateEK80(
+        echodata=ed, waveform_mode="BB", encode_mode="complex", cal_params=None, env_params=None
+    )
+
+    # Check cal params ingested from both ways
+    assert cal_obj.cal_params["gain_correction"].identical(cal_params_manual["gain_correction"])
+
+    # Check against the final cal params in the calibration output
+    ds_Sv = ep.calibrate.compute_Sv(
+        ed, waveform_mode="BB", encode_mode="complex", cal_params={"gain_correction": gain_freq_dep}
+    )
+    cal_params_manual["gain_correction"].name = "gain_correction"
+    assert ds_Sv["gain_correction"].identical(cal_params_manual["gain_correction"])
+
+
+def test_cal_params_intake_EK80_CW_complex(ek80_cal_path):
+    """
+    Test frequency-dependent cal param intake for EK80 CW complex calibration.
+    """
+    ed = ep.open_raw(ek80_cal_path / "2018115-D20181213-T094600.raw", sonar_model="EK80")
+
+    # CW channels
+    chan_sel = ["WBT 714581-15 ES18", "WBT 714583-15 ES120-7C",
+                "WBT 714597-15 ES333-7C", "WBT 714605-15 ES200-7C"]
+
+    # Assemble external freq-dependent cal param
+    len_cal_frequency = ed["Vendor_specific"]["cal_frequency"].size
+    gain_freq_dep = xr.DataArray(
+        np.array([
+            np.arange(len_cal_frequency),
+            (np.arange(len_cal_frequency) + 1000)[::-1],
+            (np.arange(len_cal_frequency) + 2000)[::-1],
+            (np.arange(len_cal_frequency) + 3000)[::-1],
+        ]),
+        dims=["cal_channel_id", "cal_frequency"],
+        coords={
+            "cal_channel_id": chan_sel,
+            "cal_frequency": ed["Vendor_specific"]["cal_frequency"],
+        },
+    )
+
+    # Manually go through cal params intake
+    beam = ed["Sonar/Beam_group1"].sel(channel=chan_sel)
+    vend = ed["Vendor_specific"].sel(channel=chan_sel)
+    freq_center = beam["frequency_nominal"].sel(channel=chan_sel)
+    cal_params_manual = ep.calibrate.cal_params.get_cal_params_EK(
+        "CW", freq_center, beam, vend, {"gain_correction": gain_freq_dep}
+    )
+    cal_params_manual["gain_correction"].name = "gain_correction"
+
+    # Manually add cal params in Vendor group construct cal object
+    ed["Vendor_specific"]["gain_correction"].data[0, 1] = cal_params_manual["gain_correction"].data[0]  # WBT 714581-15 ES18
+    ed["Vendor_specific"]["gain_correction"].data[1, 4] = cal_params_manual["gain_correction"].data[1]  # WBT 714583-15 ES120-7C
+    ed["Vendor_specific"]["gain_correction"].data[4, 4] = cal_params_manual["gain_correction"].data[2]  # WBT 714597-15 ES333-7C
+    ed["Vendor_specific"]["gain_correction"].data[5, 4] = cal_params_manual["gain_correction"].data[3]  # WBT 714605-15 ES200-7C
+    cal_obj = ep.calibrate.calibrate_ek.CalibrateEK80(
+        echodata=ed, waveform_mode="CW", encode_mode="complex", cal_params=None, env_params=None
+    )
+
+    # Check cal params ingested from both ways
+    assert cal_obj.cal_params["gain_correction"].isel(ping_time=0).drop("ping_time").identical(cal_params_manual["gain_correction"])
+
+    # Check against the final cal params in the calibration output
+    ds_Sv = ep.calibrate.compute_Sv(
+        ed, waveform_mode="CW", encode_mode="complex", cal_params={"gain_correction": gain_freq_dep}
+    )
+    assert ds_Sv["gain_correction"].identical(cal_params_manual["gain_correction"])
diff --git a/echopype/tests/calibrate/test_calibrate.py b/echopype/tests/calibrate/test_calibrate.py
index db3fd3b87..b4f16eae2 100644
--- a/echopype/tests/calibrate/test_calibrate.py
+++ b/echopype/tests/calibrate/test_calibrate.py
@@ -3,8 +3,7 @@
 import pytest
 from scipy.io import loadmat
 import echopype as ep
-from echopype.calibrate.calibrate_ek import CalibrateEK80
-from echopype.calibrate.env_params import EnvParams
+from echopype.calibrate.env_params_old import EnvParams
 import xarray as xr
 
 
@@ -28,10 +27,15 @@ def ek80_cal_path(test_path):
     return test_path['EK80_CAL']
 
 
+@pytest.fixture
+def ek80_ext_path(test_path):
+    return test_path['EK80_EXT']
+
+
 def test_compute_Sv_returns_water_level(ek60_path):
 
     # get EchoData object that has the water_level variable under platform and compute Sv of it
-    ed = ep.open_raw(ek60_path / "ncei-wcsd/Summer2017-D20170620-T011027.raw", "EK60")
+    ed = ep.open_raw(ek60_path / "ncei-wcsd" / "Summer2017-D20170620-T011027.raw", "EK60")
     ds_Sv = ep.calibrate.compute_Sv(ed)
 
     # make sure the returned Dataset has water_level and throw an assertion error if the
@@ -81,7 +85,7 @@ def test_compute_Sv_ek60_matlab(ek60_path):
         ek60_path.joinpath('DY1801_EK60-D20180211-T164025.raw')
     )
     ek60_matlab_path = str(
-        ek60_path.joinpath('from_matlab/DY1801_EK60-D20180211-T164025.mat')
+        ek60_path.joinpath('from_matlab', 'DY1801_EK60-D20180211-T164025.mat')
     )
 
     # Convert file
@@ -98,13 +102,11 @@ def test_compute_Sv_ek60_matlab(ek60_path):
     ds_base = loadmat(ek60_matlab_path)
 
     def check_output(da_cmp, cal_type):
-        for fidx in range(5):  # loop through all freq
-            assert np.allclose(
-                da_cmp.isel(channel=0).T.values,
-                ds_base['data']['pings'][0][0][cal_type][0, 0],
-                atol=4e-5,
-                rtol=0,
-            )  # difference due to use of Single in matlab code
+        # ds_base["data"]["pings"][0][0]["Sv"].shape = (1, 5)  [5 channels]
+        for seq, ch in enumerate(ds_base["data"]["config"][0][0]["channelid"][0]):
+            ep_vals = da_cmp.sel(channel=ch).squeeze().data[:, 8:]  # ignore the first 8 samples
+            pyel_vals = ds_base['data']['pings'][0][0][cal_type][0, seq].T[:, 8:]
+            assert np.allclose(pyel_vals, ep_vals)
 
     # Check Sv
     check_output(ds_Sv['Sv'], 'Sv')
@@ -114,6 +116,9 @@ def check_output(da_cmp, cal_type):
 
 
 def test_compute_Sv_ek60_duplicated_freq(ek60_path):
+
+    # TODO: add comparison of actual values in this test
+
     ek60_raw_path = str(
         ek60_path.joinpath('DY1002_EK60-D20100318-T023008_rep_freq.raw')
     )
@@ -133,10 +138,10 @@ def test_compute_Sv_azfp(azfp_path):
     azfp_01a_path = str(azfp_path.joinpath('17082117.01A'))
     azfp_xml_path = str(azfp_path.joinpath('17041823.XML'))
     azfp_matlab_Sv_path = str(
-        azfp_path.joinpath('from_matlab/17082117_matlab_Output_Sv.mat')
+        azfp_path.joinpath('from_matlab', '17082117_matlab_Output_Sv.mat')
     )
     azfp_matlab_TS_path = str(
-        azfp_path.joinpath('from_matlab/17082117_matlab_Output_TS.mat')
+        azfp_path.joinpath('from_matlab', '17082117_matlab_Output_TS.mat')
     )
 
     # Convert to .nc file
@@ -189,89 +194,6 @@ def check_output(base_path, ds_cmp, cal_type):
     check_output(base_path=azfp_matlab_TS_path, ds_cmp=ds_TS, cal_type='TS')
 
 
-def test_compute_Sv_ek80_matlab(ek80_path):
-    """Compare pulse compressed outputs from echopype and Matlab outputs.
-
-    Unresolved: there is a discrepancy between the range vector due to minRange=0.02 m set in Matlab.
-    """
-    ek80_raw_path = str(ek80_path.joinpath('D20170912-T234910.raw'))
-    ek80_matlab_path = str(
-        ek80_path.joinpath('from_matlab/D20170912-T234910_data.mat')
-    )
-
-    echodata = ep.open_raw(ek80_raw_path, sonar_model='EK80')
-    ds_Sv = ep.calibrate.compute_Sv(
-        echodata, waveform_mode='BB', encode_mode='complex'
-    )
-
-    # TODO: resolve discrepancy in range between echopype and Matlab code
-    ds_matlab = loadmat(ek80_matlab_path)
-    Sv_70k = ds_Sv.Sv.isel(channel=0, ping_time=0).dropna('range_sample').values
-
-
-def test_compute_Sv_ek80_pc_echoview(ek80_path):
-    """Compare pulse compressed outputs from echopype and csv exported from EchoView.
-
-    Unresolved: the difference is large and it is not clear why.
-    """
-    ek80_raw_path = str(ek80_path.joinpath('D20170912-T234910.raw'))
-    ek80_bb_pc_test_path = str(
-        ek80_path.joinpath(
-            'from_echoview/70 kHz pulse-compressed power.complex.csv'
-        )
-    )
-
-    echodata = ep.open_raw(ek80_raw_path, sonar_model='EK80')
-
-    # Create a CalibrateEK80 object to perform pulse compression
-    cal_obj = CalibrateEK80(
-        echodata,
-        env_params=None,
-        cal_params=None,
-        waveform_mode="BB",
-        encode_mode="complex",
-    )
-    cal_obj.compute_range_meter(
-        waveform_mode="BB", encode_mode="complex"
-    )  # compute range [m]
-    chirp, _, tau_effective = cal_obj.get_transmit_chirp(waveform_mode="BB")
-    freq_center = (
-        echodata["Sonar/Beam_group1"]["frequency_start"] + echodata["Sonar/Beam_group1"]["frequency_end"]
-    ).dropna(
-        dim="channel"
-    ) / 2  # drop those that contain CW samples (nan in freq start/end)
-    pc = cal_obj.compress_pulse(chirp, chan_BB=freq_center.channel)
-    pc_mean = (
-        pc.pulse_compressed_output.isel(channel=1)
-        .mean(dim='beam')
-        .dropna('range_sample')
-    )
-
-    # Read EchoView pc raw power output
-    df = pd.read_csv(ek80_bb_pc_test_path, header=None, skiprows=[0])
-    df_header = pd.read_csv(
-        ek80_bb_pc_test_path, header=0, usecols=range(14), nrows=0
-    )
-    df = df.rename(
-        columns={
-            cc: vv for cc, vv in zip(df.columns, df_header.columns.values)
-        }
-    )
-    df.columns = df.columns.str.strip()
-    df_real = df.loc[df['Component'] == ' Real', :].iloc[:, 14:]
-
-    # Compare only values for range > 0: difference is surprisingly large
-    range_meter = cal_obj.range_meter.sel(channel='WBT 549762-15 ES70-7C',
-                                          ping_time='2017-09-12T23:49:10.722999808').values
-    first_nonzero_range = np.argwhere(range_meter == 0).squeeze().max()
-    assert np.allclose(
-        df_real.values[:, first_nonzero_range : pc_mean.values.shape[1]],
-        pc_mean.values.real[:, first_nonzero_range:],
-        rtol=0,
-        atol=1.03e-3,
-    )
-
-
 def test_compute_Sv_ek80_CW_complex(ek80_path):
     """Test calibrate CW mode data encoded as complex samples."""
     ek80_raw_path = str(
@@ -322,17 +244,22 @@ def test_compute_Sv_ek80_CW_power_BB_complex(ek80_path):
     assert isinstance(ds_Sv, xr.Dataset)
 
 
-def test_compute_Sv_ek80_CW_complex_BB_complex(ek80_cal_path):
+def test_compute_Sv_ek80_CW_complex_BB_complex(ek80_cal_path, ek80_path):
     """
     Tests calibration for file containing both BB and CW mode data
     with both encoded as complex samples.
     """
-    ek80_raw_path = ek80_cal_path / "2018115-D20181213-T094600.raw"
+    ek80_raw_path = ek80_cal_path / "2018115-D20181213-T094600.raw"  # rx impedance / rx fs / tcvr type
+    # ek80_raw_path = ek80_path / "D20170912-T234910.raw"  # rx impedance / rx fs / tcvr type
+    # ek80_raw_path = ek80_path / "Summer2018--D20180905-T033113.raw"  # BB only, rx impedance / rx fs / tcvr type
+    # ek80_raw_path = ek80_path / "ar2.0-D20201210-T000409.raw"  # CW only, rx impedance / rx fs / tcvr type
+    # ek80_raw_path = ek80_path / "saildrone/SD2019_WCS_v05-Phase0-D20190617-T125959-0.raw"  # rx impedance / tcvr type
+    # ek80_raw_path = ek80_path / "D20200528-T125932.raw"  # CW only,  WBT MINI, rx impedance / rx fs / tcvr type
     ed = ep.open_raw(ek80_raw_path, sonar_model="EK80")
-    ds_Sv = ep.calibrate.compute_Sv(
-        ed, waveform_mode="CW", encode_mode="complex"
-    )
-    assert isinstance(ds_Sv, xr.Dataset)
+    # ds_Sv = ep.calibrate.compute_Sv(
+    #     ed, waveform_mode="CW", encode_mode="complex"
+    # )
+    # assert isinstance(ds_Sv, xr.Dataset)
     ds_Sv = ep.calibrate.compute_Sv(
         ed, waveform_mode="BB", encode_mode="complex"
     )
@@ -344,7 +271,7 @@ def test_env_params(ek60_path):
     Tests EnvParams interpolation
     """
 
-    ed = ep.open_raw(ek60_path / "ncei-wcsd/Summer2017-D20170620-T011027.raw", "EK60")
+    ed = ep.open_raw(ek60_path / "ncei-wcsd" / "Summer2017-D20170620-T011027.raw", "EK60")
 
     # stationary
     # since the raw ping_times go up to 1:43 but the env_params ping_time stops at 1:25,
diff --git a/echopype/tests/calibrate/test_calibrate_ek80.py b/echopype/tests/calibrate/test_calibrate_ek80.py
new file mode 100644
index 000000000..cf50fa459
--- /dev/null
+++ b/echopype/tests/calibrate/test_calibrate_ek80.py
@@ -0,0 +1,252 @@
+import pytest
+import numpy as np
+import pandas as pd
+
+import echopype as ep
+
+
+@pytest.fixture
+def ek80_path(test_path):
+    return test_path['EK80']
+
+
+@pytest.fixture
+def ek80_cal_path(test_path):
+    return test_path['EK80_CAL']
+
+
+@pytest.fixture
+def ek80_ext_path(test_path):
+    return test_path['EK80_EXT']
+
+
+def test_ek80_transmit_chirp(ek80_cal_path, ek80_ext_path):
+    """
+    Test transmit chirp reconstruction against Anderson et al. 2021/pyEcholab implementation
+    """
+    ek80_raw_path = ek80_cal_path / "2018115-D20181213-T094600.raw"  # rx impedance / rx fs / tcvr type
+    ed = ep.open_raw(ek80_raw_path, sonar_model="EK80")
+
+    # Calibration object detail
+    waveform_mode = "BB"
+    encode_mode = "complex"
+    cal_obj = ep.calibrate.calibrate_ek.CalibrateEK80(
+        echodata=ed, waveform_mode=waveform_mode, encode_mode=encode_mode,
+        env_params=None, cal_params=None
+    )
+    fs = cal_obj.cal_params["receiver_sampling_frequency"]
+    filter_coeff = ep.calibrate.ek80_complex.get_filter_coeff(ed["Vendor_specific"].sel(channel=cal_obj.chan_sel))
+    tx, tx_time = ep.calibrate.ek80_complex.get_transmit_signal(
+        ed["Sonar/Beam_group1"].sel(channel=cal_obj.chan_sel), filter_coeff, waveform_mode, fs
+    )
+    tau_effective = ep.calibrate.ek80_complex.get_tau_effective(
+        ytx_dict=tx,
+        fs_deci_dict={k: 1 / np.diff(v[:2]) for (k, v) in tx_time.items()},  # decimated fs
+        waveform_mode=cal_obj.waveform_mode,
+        channel=cal_obj.chan_sel,
+        ping_time=cal_obj.echodata["Sonar/Beam_group1"]["ping_time"],
+    )
+
+    # Load pyEcholab object: channel WBT 714590-15 ES70-7C
+    import pickle
+    with open(ek80_ext_path / "pyecholab/pyel_BB_calibration.pickle", 'rb') as handle:
+        pyecholab_BB = pickle.load(handle)
+
+    # Compare first ping since all params identical
+    ch_sel = "WBT 714590-15 ES70-7C"
+    # receive sampling frequency
+    assert pyecholab_BB["rx_sample_frequency"][0] == fs.sel(channel=ch_sel)
+    # WBT filter
+    assert np.all(pyecholab_BB["filters"][1]["coefficients"] == filter_coeff[ch_sel]["wbt_fil"])
+    assert np.all(pyecholab_BB["filters"][1]["decimation_factor"] == filter_coeff[ch_sel]["wbt_decifac"])
+    # PC filter
+    assert np.all(pyecholab_BB["filters"][2]["coefficients"] == filter_coeff[ch_sel]["pc_fil"])
+    assert np.all(pyecholab_BB["filters"][2]["decimation_factor"] == filter_coeff[ch_sel]["pc_decifac"])
+    # transmit signal
+    assert np.allclose(pyecholab_BB["_tx_signal"][0], tx[ch_sel])
+    # tau effective
+    # use np.isclose for now since difference is 2.997176e-5 (pyecholab) and 2.99717595e-05 (echopype)
+    # will see if it causes downstream major differences
+    assert np.isclose(tau_effective.sel(channel=ch_sel).data, pyecholab_BB["_tau_eff"][0])
+
+
+def test_ek80_BB_params(ek80_cal_path, ek80_ext_path):
+    """
+    Test power from pulse compressed BB data
+    """
+    ek80_raw_path = ek80_cal_path / "2018115-D20181213-T094600.raw"  # rx impedance / rx fs / tcvr type
+    ed = ep.open_raw(ek80_raw_path, sonar_model="EK80")
+
+    # Calibration object detail
+    waveform_mode = "BB"
+    encode_mode = "complex"
+
+    cal_obj = ep.calibrate.calibrate_ek.CalibrateEK80(
+        echodata=ed, waveform_mode=waveform_mode, encode_mode=encode_mode,
+        env_params={"formula_source": "FG"}, cal_params=None
+    )
+
+    z_er = cal_obj.cal_params["impedance_receive"]
+    z_et = cal_obj.cal_params["impedance_transmit"]
+    # B_theta_phi_m = cal_obj._get_B_theta_phi_m()
+    params_BB_map = {
+        # param name mapping: echopype (ep) : pyecholab (pyel)
+        "angle_offset_alongship": "angle_offset_alongship",
+        "angle_offset_athwartship": "angle_offset_athwartship",
+        "beamwidth_alongship": "beam_width_alongship",
+        "beamwidth_athwartship": "beam_width_athwartship",
+        "gain_correction": "gain",  # this is *before* B_theta_phi_m BB correction
+    }
+
+    # Load pyEcholab object: channel WBT 714590-15 ES70-7C
+    import pickle
+    with open(ek80_ext_path / "pyecholab/pyel_BB_calibration.pickle", 'rb') as handle:
+        pyel_BB_cal = pickle.load(handle)
+    with open(ek80_ext_path / "pyecholab/pyel_BB_raw_data.pickle", 'rb') as handle:
+        pyel_BB_raw = pickle.load(handle)
+
+    ch_sel = "WBT 714590-15 ES70-7C"
+
+    # pyecholab calibration object
+    # TODO: need to check B_theta_phi_m values
+    assert pyel_BB_cal["impedance"] == z_er.sel(channel=ch_sel)
+    for p_ep, p_pyel in params_BB_map.items():  # all interpolated BB params
+        assert np.isclose(pyel_BB_cal[p_pyel][0], cal_obj.cal_params[p_ep].sel(channel=ch_sel).isel(ping_time=0))
+    assert pyel_BB_cal["sa_correction"][0] == cal_obj.cal_params["sa_correction"].sel(channel=ch_sel).isel(ping_time=0)
+    assert pyel_BB_cal["sound_speed"] == cal_obj.env_params["sound_speed"]
+    assert np.isclose(
+        pyel_BB_cal["absorption_coefficient"][0],
+        cal_obj.env_params["sound_absorption"].sel(channel=ch_sel).isel(ping_time=0)
+    )
+
+    # pyecholab raw_data object
+    assert pyel_BB_raw["ZTRANSDUCER"] == z_et.sel(channel=ch_sel).isel(ping_time=0)
+    assert pyel_BB_raw["transmit_power"][0] == ed["Sonar/Beam_group1"]["transmit_power"].sel(channel=ch_sel).isel(ping_time=0)
+    assert pyel_BB_raw["transceiver_type"] == ed["Vendor_specific"]["transceiver_type"].sel(channel=ch_sel)
+
+
+def test_ek80_BB_range(ek80_cal_path, ek80_ext_path):
+    ek80_raw_path = ek80_cal_path / "2018115-D20181213-T094600.raw"  # rx impedance / rx fs / tcvr type
+    ed = ep.open_raw(ek80_raw_path, sonar_model="EK80")
+
+    # Calibration object
+    waveform_mode = "BB"
+    encode_mode = "complex"
+    cal_obj = ep.calibrate.calibrate_ek.CalibrateEK80(
+        echodata=ed, waveform_mode=waveform_mode, encode_mode=encode_mode,
+        env_params={"formula_source": "FG"}, cal_params=None
+    )
+
+    ch_sel = "WBT 714590-15 ES70-7C"
+
+    # Load pyecholab pickle
+    import pickle
+    with open(ek80_ext_path / "pyecholab/pyel_BB_p_data.pickle", 'rb') as handle:
+        pyel_BB_p_data = pickle.load(handle)
+
+    # Assert
+    ep_vals = cal_obj.range_meter.sel(channel=ch_sel).isel(ping_time=0).data
+    pyel_vals = pyel_BB_p_data["range"]
+    assert np.allclose(pyel_vals, ep_vals)
+
+
+def test_ek80_BB_power_Sv(ek80_cal_path, ek80_ext_path):
+    ek80_raw_path = ek80_cal_path / "2018115-D20181213-T094600.raw"  # rx impedance / rx fs / tcvr type
+    ed = ep.open_raw(ek80_raw_path, sonar_model="EK80")
+
+    # Calibration object
+    waveform_mode = "BB"
+    encode_mode = "complex"
+    cal_obj = ep.calibrate.calibrate_ek.CalibrateEK80(
+        echodata=ed, waveform_mode=waveform_mode, encode_mode=encode_mode,
+        env_params={"formula_source": "FG"}, cal_params=None
+    )
+
+    # Params needed
+    beam = cal_obj.echodata[cal_obj.ed_group].sel(channel=cal_obj.chan_sel)
+    z_er = cal_obj.cal_params["impedance_receive"]
+    z_et = cal_obj.cal_params["impedance_transmit"]
+    fs = cal_obj.cal_params["receiver_sampling_frequency"]
+    filter_coeff = ep.calibrate.ek80_complex.get_filter_coeff(ed["Vendor_specific"].sel(channel=cal_obj.chan_sel))
+    tx, tx_time = ep.calibrate.ek80_complex.get_transmit_signal(beam, filter_coeff, waveform_mode, fs)
+
+    # Get power from complex samples
+    prx = cal_obj._get_power_from_complex(beam=beam, chirp=tx, z_et=z_et, z_er=z_er)
+
+    ch_sel = "WBT 714590-15 ES70-7C"
+
+    # Load pyecholab pickle
+    import pickle
+    with open(ek80_ext_path / "pyecholab/pyel_BB_p_data.pickle", 'rb') as handle:
+        pyel_BB_p_data = pickle.load(handle)
+
+    # Power: only compare non-Nan, non-Inf values
+    pyel_vals = pyel_BB_p_data["power"]
+    ep_vals = 10 * np.log10(prx.sel(channel=ch_sel).data)
+    assert pyel_vals.shape == ep_vals.shape
+    idx_to_cmp = ~(
+        np.isinf(pyel_vals) | np.isnan(pyel_vals) | np.isinf(ep_vals) | np.isnan(ep_vals)
+    )
+    assert np.allclose(pyel_vals[idx_to_cmp], ep_vals[idx_to_cmp])
+
+    # Sv: only compare non-Nan, non-Inf values
+    # comparing for only the last values now until fixing the range computation
+    ds_Sv = ep.calibrate.compute_Sv(
+        ed, waveform_mode="BB", encode_mode="complex"
+    )
+    pyel_vals = pyel_BB_p_data["sv_data"]
+    ep_vals = ds_Sv["Sv"].sel(channel=ch_sel).squeeze().data
+    assert pyel_vals.shape == ep_vals.shape
+    idx_to_cmp = ~(
+        np.isinf(pyel_vals) | np.isnan(pyel_vals) | np.isinf(ep_vals) | np.isnan(ep_vals)
+    )
+    assert np.allclose(pyel_vals[idx_to_cmp], ep_vals[idx_to_cmp])
+
+
+def test_ek80_BB_power_echoview(ek80_path):
+    """Compare pulse compressed outputs from echopype and csv exported from EchoView.
+
+    Unresolved: the difference is large and it is not clear why.
+    """
+    ek80_raw_path = str(ek80_path.joinpath('D20170912-T234910.raw'))
+    ek80_bb_pc_test_path = str(
+        ek80_path.joinpath(
+            'from_echoview', '70 kHz pulse-compressed power.complex.csv'
+        )
+    )
+
+    echodata = ep.open_raw(ek80_raw_path, sonar_model='EK80')
+
+    # Create a CalibrateEK80 object to perform pulse compression
+    cal_obj = ep.calibrate.calibrate_ek.CalibrateEK80(
+        echodata, env_params=None, cal_params=None, waveform_mode="BB", encode_mode="complex"
+    )
+    beam = echodata["Sonar/Beam_group1"].sel(channel=cal_obj.chan_sel)
+
+    coeff = ep.calibrate.ek80_complex.get_filter_coeff(echodata["Vendor_specific"].sel(channel=cal_obj.chan_sel))
+    chirp, _ = ep.calibrate.ek80_complex.get_transmit_signal(beam, coeff, "BB", cal_obj.cal_params["receiver_sampling_frequency"])
+
+    pc = ep.calibrate.ek80_complex.compress_pulse(
+        backscatter=beam["backscatter_r"] + 1j * beam["backscatter_i"], chirp=chirp)
+    pc_mean = pc.sel(channel="WBT 549762-15 ES70-7C").mean(dim="beam").dropna("range_sample")
+
+    # Read EchoView pc raw power output
+    df = pd.read_csv(ek80_bb_pc_test_path, header=None, skiprows=[0])
+    df_header = pd.read_csv(
+        ek80_bb_pc_test_path, header=0, usecols=range(14), nrows=0
+    )
+    df = df.rename(
+        columns={
+            cc: vv for cc, vv in zip(df.columns, df_header.columns.values)
+        }
+    )
+    df.columns = df.columns.str.strip()
+    df_real = df.loc[df["Component"] == " Real", :].iloc[:, 14:]  # values start at column 15
+
+    # Skip an initial chunk of samples due to unknown larger difference
+    # this difference is also documented in pyecholab tests
+    # Below only compare the first ping
+    ev_vals = df_real.values[:, :]
+    ep_vals = pc_mean.values.real[:, :]
+    assert np.allclose(ev_vals[:, 69:8284], ep_vals[:, 69:], atol=1e-4)
+    assert np.allclose(ev_vals[:, 90:8284], ep_vals[:, 90:], atol=1e-5)
\ No newline at end of file
diff --git a/echopype/tests/echodata/test_echodata_misc.py b/echopype/tests/calibrate/test_env_params.py
similarity index 77%
rename from echopype/tests/echodata/test_echodata_misc.py
rename to echopype/tests/calibrate/test_env_params.py
index 49412349f..941ffc80e 100644
--- a/echopype/tests/echodata/test_echodata_misc.py
+++ b/echopype/tests/calibrate/test_env_params.py
@@ -1,4 +1,4 @@
-from echopype.echodata import EchoData
+from echopype.calibrate.env_params import harmonize_env_param_time
 
 import xarray as xr
 import numpy as np
@@ -7,7 +7,7 @@
 def test_harmonize_env_param_time():
     # Scalar
     p = 10.05
-    assert EchoData._harmonize_env_param_time(p=p) == 10.05
+    assert harmonize_env_param_time(p=p) == 10.05
 
     # time1 length=1, should return length=1 numpy array
     p = xr.DataArray(
@@ -17,7 +17,7 @@ def test_harmonize_env_param_time():
         },
         dims=["time1"]
     )
-    assert EchoData._harmonize_env_param_time(p=p) == 1
+    assert harmonize_env_param_time(p=p) == 1
 
     # time1 length>1, interpolate to tareget ping_time
     p = xr.DataArray(
@@ -29,7 +29,7 @@ def test_harmonize_env_param_time():
     )
     # ping_time target is identical to time1
     ping_time_target = p["time1"].rename({"time1": "ping_time"})
-    p_new = EchoData._harmonize_env_param_time(p=p, ping_time=ping_time_target)
+    p_new = harmonize_env_param_time(p=p, ping_time=ping_time_target)
     assert (p_new["ping_time"] == ping_time_target).all()
     assert (p_new.data == p.data).all()
     # ping_time target requires actual interpolation
@@ -40,10 +40,6 @@ def test_harmonize_env_param_time():
         },
         dims=["ping_time"]
     )
-    p_new = EchoData._harmonize_env_param_time(p=p, ping_time=ping_time_target["ping_time"])
+    p_new = harmonize_env_param_time(p=p, ping_time=ping_time_target["ping_time"])
     assert p_new["ping_time"] == ping_time_target["ping_time"]
     assert p_new.data == 0.5
-
-
-
-    
\ No newline at end of file
diff --git a/echopype/tests/calibrate/test_range_integration.py b/echopype/tests/calibrate/test_range_integration.py
new file mode 100644
index 000000000..2c4457f71
--- /dev/null
+++ b/echopype/tests/calibrate/test_range_integration.py
@@ -0,0 +1,46 @@
+import pytest
+import echopype as ep
+
+
+@pytest.mark.parametrize(
+    (
+        "test_path_key", "sonar_model", "raw_file", "xml_file",
+        "env_params", "cal_params", "waveform_mode", "encode_mode"
+    ),
+    [
+        # AZFP
+        ("AZFP", "AZFP", "17082117.01A", "17041823.XML", {"salinity": 30, "pressure": 10}, {}, None, None),
+        # EK60
+        ("EK60", "EK60", "DY1801_EK60-D20180211-T164025.raw", None, None, None, None, None),
+        # EK80 BB complex
+        ("EK80_CAL", "EK80", "2018115-D20181213-T094600.raw", None, None, None, "BB", "complex"),
+        # EK80 CW complex
+        ("EK80_CAL", "EK80", "2018115-D20181213-T094600.raw", None, None, None, "CW", "complex"),
+        # EK80 CW power
+        ("EK80", "EK80", "Summer2018--D20180905-T033113.raw", None, None, None, "CW", "power"),
+        # TODO: EK80 reduced sampling rate
+    ],
+    ids=[
+        "azfp",
+        "ek60",
+        "ek80_bb_complex",
+        "ek80_cw_complex",
+        "ek80_cw_power",
+    ]
+)
+def test_range_dimensions(
+    test_path, test_path_key, sonar_model, raw_file, xml_file,
+    env_params, cal_params, waveform_mode, encode_mode,
+):
+    if xml_file is not None:
+        ed = ep.open_raw(
+            raw_file=test_path[test_path_key] / raw_file, sonar_model=sonar_model,
+            xml_path=test_path[test_path_key] / xml_file,
+        )
+    else:
+        ed = ep.open_raw(raw_file=test_path[test_path_key] / raw_file, sonar_model=sonar_model)        
+    ds_Sv = ep.calibrate.compute_Sv(
+        echodata=ed, env_params=env_params, cal_params=cal_params,
+        waveform_mode=waveform_mode, encode_mode=encode_mode
+    )
+    assert ds_Sv["echo_range"].dims == ("channel", "ping_time", "range_sample")
diff --git a/echopype/tests/conftest.py b/echopype/tests/conftest.py
index 0d161d57e..b2a65d439 100644
--- a/echopype/tests/conftest.py
+++ b/echopype/tests/conftest.py
@@ -25,6 +25,7 @@ def test_path():
         'AZFP': TEST_DATA_FOLDER / "azfp",
         'AD2CP': TEST_DATA_FOLDER / "ad2cp",
         'EK80_CAL': TEST_DATA_FOLDER / "ek80_bb_with_calibration",
+        'EK80_EXT': TEST_DATA_FOLDER / "ek80_ext",
     }
 
 
diff --git a/echopype/tests/consolidate/test_consolidate.py b/echopype/tests/consolidate/test_consolidate.py
deleted file mode 100644
index a7d9162a7..000000000
--- a/echopype/tests/consolidate/test_consolidate.py
+++ /dev/null
@@ -1,183 +0,0 @@
-import pytest
-
-import numpy as np
-import pandas as pd
-import xarray as xr
-
-import echopype as ep
-
-
-@pytest.fixture(
-    params=[
-        (
-            ("EK60", "DY1002_EK60-D20100318-T023008_rep_freq.raw"),
-            "EK60",
-            None,
-            {},
-        ),
-        (
-            ("EK80_NEW", "D20211004-T233354.raw"),
-            "EK80",
-            None,
-            {'waveform_mode': 'CW', 'encode_mode': 'power'},
-        ),
-        (
-            ("AZFP", "17082117.01A"),
-            "AZFP",
-            ("AZFP", "17041823.XML"),
-            {},
-        ),
-    ],
-    ids=[
-        "ek60_dup_freq",
-        "ek80_cw_power",
-        "azfp",
-    ],
-)
-def test_data_samples(request, test_path):
-    (
-        filepath,
-        sonar_model,
-        azfp_xml_path,
-        range_kwargs,
-    ) = request.param
-    path_model, *paths = filepath
-    filepath = test_path[path_model].joinpath(*paths)
-
-    if azfp_xml_path is not None:
-        path_model, *paths = azfp_xml_path
-        azfp_xml_path = test_path[path_model].joinpath(*paths)
-
-    return (
-        filepath,
-        sonar_model,
-        azfp_xml_path,
-        range_kwargs,
-    )
-
-
-def _check_swap(ds, ds_swap):
-    assert "channel" in ds.dims
-    assert "frequency_nominal" not in ds.dims
-    assert "frequency_nominal" in ds_swap.dims
-    assert "channel" not in ds_swap.dims
-
-
-def test_swap_dims_channel_frequency(test_data_samples):
-    """
-    Test swapping dimension/coordinate from channel to frequency_nominal.
-    """
-    (
-        filepath,
-        sonar_model,
-        azfp_xml_path,
-        range_kwargs,
-    ) = test_data_samples
-    ed = ep.open_raw(filepath, sonar_model, azfp_xml_path)
-    if ed.sonar_model.lower() == 'azfp':
-        avg_temperature = (
-            ed['Environment']['temperature'].mean('time1').values
-        )
-        env_params = {
-            'temperature': avg_temperature,
-            'salinity': 27.9,
-            'pressure': 59,
-        }
-        range_kwargs['env_params'] = env_params
-        if 'azfp_cal_type' in range_kwargs:
-            range_kwargs.pop('azfp_cal_type')
-
-    dup_freq_valueerror = (
-        "Duplicated transducer nominal frequencies exist in the file. "
-        "Operation is not valid."
-    )
-
-    Sv = ep.calibrate.compute_Sv(ed, **range_kwargs)
-    try:
-        Sv_swapped = ep.consolidate.swap_dims_channel_frequency(Sv)
-        _check_swap(Sv, Sv_swapped)
-    except Exception as e:
-        assert isinstance(e, ValueError) is True
-        assert str(e) == dup_freq_valueerror
-
-    MVBS = ep.preprocess.compute_MVBS(Sv)
-    try:
-        MVBS_swapped = ep.consolidate.swap_dims_channel_frequency(MVBS)
-        _check_swap(Sv, MVBS_swapped)
-    except Exception as e:
-        assert isinstance(e, ValueError) is True
-        assert str(e) == dup_freq_valueerror
-
-
-def _build_ds_Sv(channel, range_sample, ping_time, sample_interval):
-    return xr.Dataset(
-        data_vars={
-            "Sv": ( 
-                ("channel", "range_sample", "ping_time"),
-                np.random.random((len(channel), range_sample.size, ping_time.size)),
-            ),
-            "echo_range": (
-                ("channel", "range_sample", "ping_time"),
-                (
-                    np.swapaxes(np.tile(range_sample, (len(channel), ping_time.size, 1)), 1, 2)
-                    * sample_interval
-                ),
-            ),
-        },
-        coords={
-            "channel": channel,
-            "range_sample": range_sample,
-            "ping_time": ping_time,
-        },
-    )
-
-
-def test_add_depth():
-    # Build test Sv dataset
-    channel = ["channel_0", "channel_1", "channel_2"]
-    range_sample = np.arange(100)
-    ping_time = pd.date_range(start="2022-08-10T10:00:00", end="2022-08-10T12:00:00", periods=121)
-    sample_interval = 0.01
-    ds_Sv = _build_ds_Sv(channel, range_sample, ping_time, sample_interval)
-
-    # # no water_level in ds
-    # try:
-    #     ds_Sv_depth = ep.consolidate.add_depth(ds_Sv)
-    # except ValueError:
-    #     ...
-
-    # user input water_level
-    water_level = 10
-    ds_Sv_depth = ep.consolidate.add_depth(ds_Sv, depth_offset=water_level)
-    assert ds_Sv_depth["depth"].equals(ds_Sv["echo_range"] + water_level)
-
-    # user input water_level and tilt
-    tilt = 15
-    ds_Sv_depth = ep.consolidate.add_depth(ds_Sv, depth_offset=water_level, tilt=tilt)
-    assert ds_Sv_depth["depth"].equals(ds_Sv["echo_range"] * np.cos(tilt / 180 * np.pi) + water_level)
-
-    # inverted echosounder
-    ds_Sv_depth = ep.consolidate.add_depth(ds_Sv, depth_offset=water_level, tilt=tilt, downward=False)
-    assert ds_Sv_depth["depth"].equals(-1 * ds_Sv["echo_range"] * np.cos(tilt / 180 * np.pi) + water_level)
-
-    # check attributes
-    assert ds_Sv_depth["depth"].attrs == {"long_name": "Depth", "standard_name": "depth"}
-
-
-def test_add_location(test_path):
-    ed = ep.open_raw(
-        test_path["EK60"] / "Winter2017-D20170115-T150122.raw",
-        sonar_model="EK60"
-    )
-    ds = ep.calibrate.compute_Sv(ed)
-
-    def _check_var(ds_test):
-        assert "latitude" in ds_test
-        assert "longitude" in ds_test
-        assert "time1" not in ds_test
-
-    ds_all = ep.consolidate.add_location(ds=ds, echodata=ed)
-    _check_var(ds_all)
-
-    ds_sel = ep.consolidate.add_location(ds=ds, echodata=ed, nmea_sentence="GGA")
-    _check_var(ds_sel)
diff --git a/echopype/tests/consolidate/test_consolidate_integration.py b/echopype/tests/consolidate/test_consolidate_integration.py
new file mode 100644
index 000000000..f5b41c94f
--- /dev/null
+++ b/echopype/tests/consolidate/test_consolidate_integration.py
@@ -0,0 +1,400 @@
+import pathlib
+
+import pytest
+
+import numpy as np
+import pandas as pd
+import xarray as xr
+import scipy.io as io
+import echopype as ep
+from typing import List
+import tempfile
+import os
+
+"""
+For future reference:
+
+For ``test_add_splitbeam_angle`` the test data is in the following locations:
+- the EK60 raw file is in `test_data/ek60/DY1801_EK60-D20180211-T164025.raw` and the
+associated echoview split-beam data is in `test_data/ek60/splitbeam`.
+- the EK80 raw file is in `test_data/ek80_bb_with_calibration/2018115-D20181213-T094600.raw` and
+the associated echoview split-beam data is in `test_data/ek80_bb_with_calibration/splitbeam`
+"""
+
+
+@pytest.fixture(
+    params=[
+        (
+            ("EK60", "DY1002_EK60-D20100318-T023008_rep_freq.raw"),
+            "EK60",
+            None,
+            {},
+        ),
+        (
+            ("EK80_NEW", "D20211004-T233354.raw"),
+            "EK80",
+            None,
+            {'waveform_mode': 'CW', 'encode_mode': 'power'},
+        ),
+        (
+            ("AZFP", "17082117.01A"),
+            "AZFP",
+            ("AZFP", "17041823.XML"),
+            {},
+        ),
+    ],
+    ids=[
+        "ek60_dup_freq",
+        "ek80_cw_power",
+        "azfp",
+    ],
+)
+def test_data_samples(request, test_path):
+    (
+        filepath,
+        sonar_model,
+        azfp_xml_path,
+        range_kwargs,
+    ) = request.param
+    path_model, *paths = filepath
+    filepath = test_path[path_model].joinpath(*paths)
+
+    if azfp_xml_path is not None:
+        path_model, *paths = azfp_xml_path
+        azfp_xml_path = test_path[path_model].joinpath(*paths)
+
+    return (
+        filepath,
+        sonar_model,
+        azfp_xml_path,
+        range_kwargs,
+    )
+
+
+def _check_swap(ds, ds_swap):
+    assert "channel" in ds.dims
+    assert "frequency_nominal" not in ds.dims
+    assert "frequency_nominal" in ds_swap.dims
+    assert "channel" not in ds_swap.dims
+
+
+def test_swap_dims_channel_frequency(test_data_samples):
+    """
+    Test swapping dimension/coordinate from channel to frequency_nominal.
+    """
+    (
+        filepath,
+        sonar_model,
+        azfp_xml_path,
+        range_kwargs,
+    ) = test_data_samples
+    ed = ep.open_raw(filepath, sonar_model, azfp_xml_path)
+    if ed.sonar_model.lower() == 'azfp':
+        avg_temperature = (
+            ed['Environment']['temperature'].mean('time1').values
+        )
+        env_params = {
+            'temperature': avg_temperature,
+            'salinity': 27.9,
+            'pressure': 59,
+        }
+        range_kwargs['env_params'] = env_params
+        if 'azfp_cal_type' in range_kwargs:
+            range_kwargs.pop('azfp_cal_type')
+
+    dup_freq_valueerror = (
+        "Duplicated transducer nominal frequencies exist in the file. "
+        "Operation is not valid."
+    )
+
+    Sv = ep.calibrate.compute_Sv(ed, **range_kwargs)
+    try:
+        Sv_swapped = ep.consolidate.swap_dims_channel_frequency(Sv)
+        _check_swap(Sv, Sv_swapped)
+    except Exception as e:
+        assert isinstance(e, ValueError) is True
+        assert str(e) == dup_freq_valueerror
+
+    MVBS = ep.preprocess.compute_MVBS(Sv)
+    try:
+        MVBS_swapped = ep.consolidate.swap_dims_channel_frequency(MVBS)
+        _check_swap(Sv, MVBS_swapped)
+    except Exception as e:
+        assert isinstance(e, ValueError) is True
+        assert str(e) == dup_freq_valueerror
+
+
+def _build_ds_Sv(channel, range_sample, ping_time, sample_interval):
+    return xr.Dataset(
+        data_vars={
+            "Sv": (
+                ("channel", "range_sample", "ping_time"),
+                np.random.random((len(channel), range_sample.size, ping_time.size)),
+            ),
+            "echo_range": (
+                ("channel", "range_sample", "ping_time"),
+                (
+                    np.swapaxes(np.tile(range_sample, (len(channel), ping_time.size, 1)), 1, 2)
+                    * sample_interval
+                ),
+            ),
+        },
+        coords={
+            "channel": channel,
+            "range_sample": range_sample,
+            "ping_time": ping_time,
+        },
+    )
+
+
+def test_add_depth():
+    # Build test Sv dataset
+    channel = ["channel_0", "channel_1", "channel_2"]
+    range_sample = np.arange(100)
+    ping_time = pd.date_range(start="2022-08-10T10:00:00", end="2022-08-10T12:00:00", periods=121)
+    sample_interval = 0.01
+    ds_Sv = _build_ds_Sv(channel, range_sample, ping_time, sample_interval)
+
+    # # no water_level in ds
+    # try:
+    #     ds_Sv_depth = ep.consolidate.add_depth(ds_Sv)
+    # except ValueError:
+    #     ...
+
+    # user input water_level
+    water_level = 10
+    ds_Sv_depth = ep.consolidate.add_depth(ds_Sv, depth_offset=water_level)
+    assert ds_Sv_depth["depth"].equals(ds_Sv["echo_range"] + water_level)
+
+    # user input water_level and tilt
+    tilt = 15
+    ds_Sv_depth = ep.consolidate.add_depth(ds_Sv, depth_offset=water_level, tilt=tilt)
+    assert ds_Sv_depth["depth"].equals(ds_Sv["echo_range"] * np.cos(tilt / 180 * np.pi) + water_level)
+
+    # inverted echosounder
+    ds_Sv_depth = ep.consolidate.add_depth(ds_Sv, depth_offset=water_level, tilt=tilt, downward=False)
+    assert ds_Sv_depth["depth"].equals(-1 * ds_Sv["echo_range"] * np.cos(tilt / 180 * np.pi) + water_level)
+
+    # check attributes
+    # assert ds_Sv_depth["depth"].attrs == {"long_name": "Depth", "standard_name": "depth"}
+
+
+def test_add_location(test_path):
+    ed = ep.open_raw(
+        test_path["EK60"] / "Winter2017-D20170115-T150122.raw",
+        sonar_model="EK60"
+    )
+    ds = ep.calibrate.compute_Sv(ed)
+
+    def _check_var(ds_test):
+        assert "latitude" in ds_test
+        assert "longitude" in ds_test
+        assert "time1" not in ds_test
+
+    ds_all = ep.consolidate.add_location(ds=ds, echodata=ed)
+    _check_var(ds_all)
+
+    ds_sel = ep.consolidate.add_location(ds=ds, echodata=ed, nmea_sentence="GGA")
+    _check_var(ds_sel)
+
+
+def _create_array_list_from_echoview_mats(paths_to_echoview_mat: List[pathlib.Path]) -> List[np.ndarray]:
+    """
+    Opens each mat file in ``paths_to_echoview_mat``, selects the first ``ping_time``,
+    and then stores the array in a list.
+
+    Parameters
+    ----------
+    paths_to_echoview_mat: list of pathlib.Path
+        A list of paths corresponding to mat files, where each mat file contains the
+        echoview generated angle alongship and athwartship data for a channel
+
+    Returns
+    -------
+    list of np.ndarray
+        A list of numpy arrays generated by choosing the appropriate data from the mat files.
+        This list will have the same length as ``paths_to_echoview_mat``
+    """
+
+    list_of_mat_arrays = []
+    for mat_file in paths_to_echoview_mat:
+
+        # open mat file and grab appropriate data
+        list_of_mat_arrays.append(io.loadmat(file_name=mat_file)["P0"]["Data_values"][0][0])
+
+    return list_of_mat_arrays
+
+
+@pytest.mark.parametrize(
+    ("sonar_model", "test_path_key", "raw_file_name", "paths_to_echoview_mat",
+     "waveform_mode", "encode_mode", "pulse_compression", "write_Sv_to_file"),
+    [
+        (
+            "EK60", "EK60", "DY1801_EK60-D20180211-T164025.raw",
+            [
+                'splitbeam/DY1801_EK60-D20180211-T164025_angles_T1.mat',
+                'splitbeam/DY1801_EK60-D20180211-T164025_angles_T2.mat',
+                'splitbeam/DY1801_EK60-D20180211-T164025_angles_T3.mat',
+                'splitbeam/DY1801_EK60-D20180211-T164025_angles_T4.mat',
+                'splitbeam/DY1801_EK60-D20180211-T164025_angles_T5.mat'
+            ],
+            "CW", "power", False, False
+        ),
+        (
+            "EK60", "EK60", "DY1801_EK60-D20180211-T164025.raw",
+            [
+                'splitbeam/DY1801_EK60-D20180211-T164025_angles_T1.mat',
+                'splitbeam/DY1801_EK60-D20180211-T164025_angles_T2.mat',
+                'splitbeam/DY1801_EK60-D20180211-T164025_angles_T3.mat',
+                'splitbeam/DY1801_EK60-D20180211-T164025_angles_T4.mat',
+                'splitbeam/DY1801_EK60-D20180211-T164025_angles_T5.mat'
+            ],
+            "CW", "power", False, True
+        ),
+        (
+            "EK80", "EK80_CAL", "2018115-D20181213-T094600.raw",
+            [
+                'splitbeam/2018115-D20181213-T094600_angles_T1.mat',
+                'splitbeam/2018115-D20181213-T094600_angles_T4.mat',
+                'splitbeam/2018115-D20181213-T094600_angles_T6.mat',
+                'splitbeam/2018115-D20181213-T094600_angles_T5.mat'
+            ],
+            "CW", "complex", False, False
+        ),
+        (
+            "EK80", "EK80_CAL", "2018115-D20181213-T094600.raw",
+            [
+                'splitbeam/2018115-D20181213-T094600_angles_T3_nopc.mat',
+                'splitbeam/2018115-D20181213-T094600_angles_T2_nopc.mat',
+            ],
+            "BB", "complex", False, False,
+        ),
+        # (
+        #     "EK80", "EK80", "Summer2018--D20180905-T033113.raw",
+        #     [
+        #         'splitbeam/Summer2018--D20180905-T033113_angles_T1_nopc.mat',  # change this
+        #         'splitbeam/Summer2018--D20180905-T033113_angles_T2_nopc.mat',  # change this
+        #     ],
+        #     "CW", "power", False, False,
+        # ),
+    ],
+    ids=[
+        "ek60_CW_power",
+        "ek60_CW_power_Sv_path",
+        "ek80_CW_complex",
+        "ek80_BB_complex_no_pc",
+        # "ek80_CW_power",
+    ],
+)
+def test_add_splitbeam_angle(sonar_model, test_path_key, raw_file_name, test_path,
+                             paths_to_echoview_mat, waveform_mode, encode_mode,
+                             pulse_compression, write_Sv_to_file):
+
+    # obtain the EchoData object with the data needed for the calculation
+    ed = ep.open_raw(test_path[test_path_key] / raw_file_name, sonar_model=sonar_model)
+
+    # compute Sv as it is required for the split-beam angle calculation
+    ds_Sv = ep.calibrate.compute_Sv(ed, waveform_mode=waveform_mode, encode_mode=encode_mode)
+
+    # initialize temporary directory object
+    temp_dir = None
+
+    # allows us to test for the case when source_Sv is a path
+    if write_Sv_to_file:
+
+        # create temporary directory for mask_file
+        temp_dir = tempfile.TemporaryDirectory()
+
+        # write DataArray to temporary directory
+        zarr_path = os.path.join(temp_dir.name, "Sv_data.zarr")
+        ds_Sv.to_zarr(zarr_path)
+
+        # assign input to a path
+        ds_Sv = zarr_path
+
+    # add the split-beam angles to Sv dataset
+    ds_Sv = ep.consolidate.add_splitbeam_angle(source_Sv=ds_Sv, echodata=ed,
+                                               waveform_mode=waveform_mode,
+                                               encode_mode=encode_mode,
+                                               pulse_compression=pulse_compression)
+
+    # obtain corresponding echoview output
+    full_echoview_path = [test_path[test_path_key] / path for path in paths_to_echoview_mat]
+    echoview_arr_list = _create_array_list_from_echoview_mats(full_echoview_path)
+
+    # compare echoview output against computed output for all channels
+    for chan_ind in range(len(echoview_arr_list)):
+
+        # grabs the appropriate ds data to compare against
+        reduced_angle_alongship = ds_Sv.isel(channel=chan_ind, ping_time=0).angle_alongship.dropna("range_sample")
+        reduced_angle_athwartship = ds_Sv.isel(channel=chan_ind, ping_time=0).angle_athwartship.dropna("range_sample")
+
+        # TODO: make "start" below a parameter in the input so that this is not ad-hoc but something known
+        # for some files the echoview data is shifted by one index, here we account for that
+        if reduced_angle_alongship.shape == (echoview_arr_list[chan_ind].shape[1], ):
+            start = 0
+        else:
+            start = 1
+
+        # check the computed angle_alongship values against the echoview output
+        assert np.allclose(reduced_angle_alongship.values[start:],
+                           echoview_arr_list[chan_ind][0, :], rtol=1e-1, atol=1e-2)
+
+        # check the computed angle_alongship values against the echoview output
+        assert np.allclose(reduced_angle_athwartship.values[start:],
+                           echoview_arr_list[chan_ind][1, :], rtol=1e-1, atol=1e-2)
+
+    if temp_dir:
+        # remove the temporary directory, if it was created
+        temp_dir.cleanup()
+
+
+def test_add_splitbeam_angle_BB_pc(test_path):
+
+    # obtain the EchoData object with the data needed for the calculation
+    ed = ep.open_raw(test_path["EK80_CAL"] / "2018115-D20181213-T094600.raw", sonar_model="EK80")
+
+    # compute Sv as it is required for the split-beam angle calculation
+    ds_Sv = ep.calibrate.compute_Sv(ed, waveform_mode="BB", encode_mode="complex")
+
+    # add the split-beam angles to Sv dataset
+    ds_Sv = ep.consolidate.add_splitbeam_angle(
+        source_Sv=ds_Sv, echodata=ed,
+        waveform_mode="BB", encode_mode="complex", pulse_compression=True
+    )
+
+    # Load pyecholab pickle
+    import pickle
+    with open(test_path["EK80_EXT"] / "pyecholab/pyel_BB_splitbeam.pickle", 'rb') as handle:
+        pyel_BB_p_data = pickle.load(handle)
+
+    # Compare 70kHz channel
+    chan_sel = "WBT 714590-15 ES70-7C"
+
+    # Compare cal params
+    # dict mappgin:  {pyecholab : echopype}
+    cal_params_dict = {
+        "angle_sensitivity_alongship": "angle_sensitivity_alongship",
+        "angle_sensitivity_athwartship": "angle_sensitivity_athwartship",
+        "beam_width_alongship": "beamwidth_alongship",
+        "beam_width_athwartship": "beamwidth_athwartship",
+    }
+    for p_pyel, p_ep in cal_params_dict.items():
+        assert np.allclose(pyel_BB_p_data["cal_parms"][p_pyel],
+                           ds_Sv[p_ep].sel(channel=chan_sel).values)
+
+    # alongship angle
+    pyel_vals = pyel_BB_p_data["alongship_physical"]
+    ep_vals = ds_Sv["angle_alongship"].sel(channel=chan_sel).values
+    assert pyel_vals.shape == ep_vals.shape
+    assert np.allclose(pyel_vals, ep_vals, atol=1e-5)
+
+    # athwartship angle
+    pyel_vals = pyel_BB_p_data["athwartship_physical"]
+    ep_vals = ds_Sv["angle_athwartship"].sel(channel=chan_sel).values
+    assert pyel_vals.shape == ep_vals.shape
+    assert np.allclose(pyel_vals, ep_vals, atol=1e-6)
+
+
+# TODO: need a test for power/angle data, with mock EchoData object
+# containing some channels with single-beam data and some channels with split-beam data
diff --git a/echopype/tests/convert/test_convert_azfp.py b/echopype/tests/convert/test_convert_azfp.py
index ddad4da3b..03afb87f5 100644
--- a/echopype/tests/convert/test_convert_azfp.py
+++ b/echopype/tests/convert/test_convert_azfp.py
@@ -43,10 +43,10 @@ def test_convert_azfp_01a_matlab_raw(azfp_path):
     azfp_01a_path = str(azfp_path.joinpath('17082117.01A'))
     azfp_xml_path = str(azfp_path.joinpath('17041823.XML'))
     azfp_matlab_data_path = str(
-        azfp_path.joinpath('from_matlab/17082117_matlab_Data.mat')
+        azfp_path.joinpath('from_matlab', '17082117_matlab_Data.mat')
     )
     azfp_matlab_output_path = str(
-        azfp_path.joinpath('from_matlab/17082117_matlab_Output_Sv.mat')
+        azfp_path.joinpath('from_matlab', '17082117_matlab_Output_Sv.mat')
     )
 
     # Convert file
@@ -123,7 +123,7 @@ def test_convert_azfp_01a_raw_echoview(azfp_path):
 
     # Read csv files exported by EchoView
     azfp_csv_path = [
-        azfp_path.joinpath('from_echoview/17082117-raw%d.csv' % freq)
+        azfp_path.joinpath('from_echoview', '17082117-raw%d.csv' % freq)
         for freq in [38, 125, 200, 455]
     ]
     channels = []
diff --git a/echopype/tests/convert/test_convert_ek60.py b/echopype/tests/convert/test_convert_ek60.py
index 59de58201..39a8f30c2 100644
--- a/echopype/tests/convert/test_convert_ek60.py
+++ b/echopype/tests/convert/test_convert_ek60.py
@@ -27,7 +27,7 @@ def test_convert_ek60_matlab_raw(ek60_path):
     )
     ek60_matlab_path = str(
         ek60_path.joinpath(
-            'from_matlab/DY1801_EK60-D20180211-T164025_rawData.mat'
+            'from_matlab', 'DY1801_EK60-D20180211-T164025_rawData.mat'
         )
     )
 
@@ -95,7 +95,7 @@ def test_convert_ek60_echoview_raw(ek60_path):
     )
     ek60_csv_path = [
         ek60_path.joinpath(
-            'from_echoview/DY1801_EK60-D20180211-T164025-Power%d.csv' % freq
+            'from_echoview', 'DY1801_EK60-D20180211-T164025-Power%d.csv' % freq
         )
         for freq in [18, 38, 70, 120, 200]
     ]
diff --git a/echopype/tests/convert/test_convert_ek80.py b/echopype/tests/convert/test_convert_ek80.py
index 8da5124bc..74901061a 100644
--- a/echopype/tests/convert/test_convert_ek80.py
+++ b/echopype/tests/convert/test_convert_ek80.py
@@ -93,7 +93,7 @@ def test_convert_ek80_complex_matlab(ek80_path):
     """Compare parsed EK80 CW power/angle data with Matlab parsed data."""
     ek80_raw_path_bb = str(ek80_path.joinpath('D20170912-T234910.raw'))
     ek80_matlab_path_bb = str(
-        ek80_path.joinpath('from_matlab/D20170912-T234910_data.mat')
+        ek80_path.joinpath('from_matlab', 'D20170912-T234910_data.mat')
     )
 
     # Convert file
@@ -155,13 +155,13 @@ def test_convert_ek80_cw_power_angle_echoview(ek80_path):
     freq_list = [18, 38, 70, 120, 200]
     ek80_echoview_power_csv = [
         ek80_path.joinpath(
-            'from_echoview/D20190822-T161221/%dkHz.power.csv' % freq
+            'from_echoview', 'D20190822-T161221', '%dkHz.power.csv' % freq
         )
         for freq in freq_list
     ]
     ek80_echoview_angle_csv = [
         ek80_path.joinpath(
-            'from_echoview/D20190822-T161221/%dkHz.angles.points.csv' % freq
+            'from_echoview', 'D20190822-T161221', '%dkHz.angles.points.csv' % freq
         )
         for freq in freq_list
     ]
@@ -259,7 +259,7 @@ def test_convert_ek80_complex_echoview(ek80_path):
     """Compare parsed EK80 BB data with csv exported by EchoView."""
     ek80_raw_path_bb = ek80_path.joinpath('D20170912-T234910.raw')
     ek80_echoview_bb_power_csv = ek80_path.joinpath(
-        'from_echoview/D20170912-T234910/70 kHz raw power.complex.csv'
+        'from_echoview', 'D20170912-T234910', '70 kHz raw power.complex.csv'
     )
 
     # Convert file
diff --git a/echopype/tests/convert/test_convert_source_target_locs.py b/echopype/tests/convert/test_convert_source_target_locs.py
index c4f22f73f..843f7ad83 100644
--- a/echopype/tests/convert/test_convert_source_target_locs.py
+++ b/echopype/tests/convert/test_convert_source_target_locs.py
@@ -313,7 +313,7 @@ def test_convert_ek(
         return
 
     try:
-        if output_save_path is not None and output_save_path.startswith('/'):
+        if output_save_path is not None and (output_save_path.startswith('/') or output_save_path.startswith('\\')):
             with TemporaryDirectory() as tmpdir:
                 output_save_path = tmpdir + output_save_path
                 to_file(
diff --git a/echopype/tests/convert/test_parsed_to_zarr.py b/echopype/tests/convert/test_parsed_to_zarr.py
index f60d7890a..8ae96c5e7 100644
--- a/echopype/tests/convert/test_parsed_to_zarr.py
+++ b/echopype/tests/convert/test_parsed_to_zarr.py
@@ -3,6 +3,7 @@
 from typing import List, Tuple
 from echopype import open_raw
 from pathlib import Path
+import os.path
 
 
 @pytest.fixture
@@ -55,7 +56,7 @@ def compare_zarr_vars(ed_zarr: xr.Dataset, ed_no_zarr: xr.Dataset,
 
 
 @pytest.mark.parametrize(
-    ["raw_file", "sonar_model", "offload_to_zarr"],
+    ["raw_file", "sonar_model", "use_swap"],
     [
         ("L0003-D20040909-T161906-EK60.raw", "EK60", True),
         pytest.param(
@@ -70,28 +71,28 @@ def compare_zarr_vars(ed_zarr: xr.Dataset, ed_no_zarr: xr.Dataset,
     ],
     ids=["noaa_offloaded", "noaa_not_offloaded"],
 )
-def test_raw2zarr(raw_file, sonar_model, offload_to_zarr, ek60_path):
+def test_raw2zarr(raw_file, sonar_model, use_swap, ek60_path):
     """Tests for memory expansion relief"""
     import os
     from tempfile import TemporaryDirectory
     from echopype.echodata.echodata import EchoData
     name = os.path.basename(raw_file).replace('.raw', '')
-    fname = f"{name}__{offload_to_zarr}.zarr"
+    fname = f"{name}__{use_swap}.zarr"
     file_path = ek60_path / raw_file
     echodata = open_raw(
         raw_file=file_path,
         sonar_model=sonar_model,
-        offload_to_zarr=offload_to_zarr
+        use_swap=use_swap
     )
     # Most likely succeed if it doesn't crash
     assert isinstance(echodata, EchoData)
     with TemporaryDirectory() as tmpdir:
-        output_save_path = tmpdir + f"/{fname}"
+        output_save_path = os.path.join(tmpdir, fname)
         echodata.to_zarr(output_save_path)
         # If it goes all the way to here it is most likely successful
         assert os.path.exists(output_save_path)
 
-    if offload_to_zarr:
+    if use_swap:
         # create a copy of zarr_file_name. The join is necessary so that we are not referencing zarr_file_name
         temp_zarr_path = ''.join(echodata.parsed2zarr_obj.zarr_file_name)
 
@@ -104,7 +105,7 @@ def test_raw2zarr(raw_file, sonar_model, offload_to_zarr, ek60_path):
 @pytest.mark.parametrize(
     ["path_model", "raw_file", "sonar_model"],
     [
-        ("EK60", "ncei-wcsd/Summer2017-D20170615-T190214.raw", "EK60"),
+        ("EK60", os.path.join("ncei-wcsd", "Summer2017-D20170615-T190214.raw"), "EK60"),
         ("EK60", "DY1002_EK60-D20100318-T023008_rep_freq.raw", "EK60"),
         ("EK80",  "Summer2018--D20180905-T033113.raw", "EK80"),
         ("EK80_CAL", "2018115-D20181213-T094600.raw", "EK80"),
@@ -143,8 +144,8 @@ def test_direct_to_zarr_integration(path_model: str, raw_file: str,
 
     raw_file_path = test_path[path_model] / raw_file
 
-    ed_zarr = open_raw(raw_file_path, sonar_model=sonar_model, offload_to_zarr=True, max_zarr_mb=100)
-    ed_no_zarr = open_raw(raw_file_path, sonar_model=sonar_model, offload_to_zarr=False)
+    ed_zarr = open_raw(raw_file_path, sonar_model=sonar_model, use_swap=True, max_mb=100)
+    ed_no_zarr = open_raw(raw_file_path, sonar_model=sonar_model, use_swap=False)
 
     for grp in ed_zarr.group_paths:
 
diff --git a/echopype/tests/echodata/test_echodata.py b/echopype/tests/echodata/test_echodata.py
index f22b38724..4b6eb5513 100644
--- a/echopype/tests/echodata/test_echodata.py
+++ b/echopype/tests/echodata/test_echodata.py
@@ -1,19 +1,25 @@
 from textwrap import dedent
 
+import os
 import fsspec
+from pathlib import Path
+import shutil
 
 from datatree import DataTree
 from zarr.errors import GroupNotFoundError
 
 import echopype
-from echopype.calibrate.env_params import EnvParams
+from echopype.calibrate.env_params_old import EnvParams
 from echopype.echodata import EchoData
 from echopype import open_converted
+from echopype.calibrate.calibrate_ek import CalibrateEK60, CalibrateEK80
 
 import pytest
 import xarray as xr
 import numpy as np
 
+from utils import get_mock_echodata, check_consolidated
+
 
 @pytest.fixture(scope="module")
 def single_ek60_zarr(test_path):
@@ -191,28 +197,46 @@ def range_check_files(request, test_path):
 
 
 class TestEchoData:
+    expected_groups = {
+        'Top-level',
+        'Environment',
+        'Platform',
+        'Platform/NMEA',
+        'Provenance',
+        'Sonar',
+        'Sonar/Beam_group1',
+        'Vendor_specific',
+    }
+
+    @pytest.fixture(scope="class")
+    def mock_echodata(self):
+        return get_mock_echodata()
+
     @pytest.fixture(scope="class")
     def converted_zarr(self, single_ek60_zarr):
         return single_ek60_zarr
 
+    def create_ed(self, converted_raw_path):
+        return EchoData.from_file(converted_raw_path=converted_raw_path)
+
     def test_constructor(self, converted_zarr):
-        ed = EchoData.from_file(converted_raw_path=converted_zarr)
-        expected_groups = [
-            'Top-level',
-            'Environment',
-            'Platform',
-            'Provenance',
-            'Sonar',
-            'Sonar/Beam_group1',
-            'Vendor_specific',
-        ]
+        ed = self.create_ed(converted_zarr)
 
         assert ed.sonar_model == 'EK60'
         assert ed.converted_raw_path == converted_zarr
         assert ed.storage_options == {}
-        for group in expected_groups:
+        for group in self.expected_groups:
             assert isinstance(ed[group], xr.Dataset)
 
+    def test_group_paths(self, converted_zarr):
+        ed = self.create_ed(converted_zarr)
+        assert ed.group_paths == self.expected_groups
+
+    def test_nbytes(self, converted_zarr):
+        ed = self.create_ed(converted_zarr)
+        assert isinstance(ed.nbytes, float)
+        assert ed.nbytes == 4688692.0
+
     def test_repr(self, converted_zarr):
         zarr_path_string = str(converted_zarr.absolute())
         expected_repr = dedent(
@@ -224,16 +248,16 @@ def test_repr(self, converted_zarr):
             │   └── NMEA: contains information specific to the NMEA protocol.
             ├── Provenance: contains metadata about how the SONAR-netCDF4 version of the data were obtained.
             ├── Sonar: contains sonar system metadata and sonar beam groups.
-            │   └── Beam_group1: contains backscatter data (either complex samples or uncalibrated power samples) and other beam or channel-specific data, including split-beam angle data when they exist.
+            │   └── Beam_group1: contains backscatter power (uncalibrated) and other beam or channel-specific data, including split-beam angle data when they exist.
             └── Vendor_specific: contains vendor-specific information about the sonar and the data."""
         )
-        ed = EchoData.from_file(converted_raw_path=converted_zarr)
+        ed = self.create_ed(converted_raw_path=converted_zarr)
         actual = "\n".join(x.rstrip() for x in repr(ed).split("\n"))
         assert expected_repr == actual
 
     def test_repr_html(self, converted_zarr):
         zarr_path_string = str(converted_zarr.absolute())
-        ed = EchoData.from_file(converted_raw_path=converted_zarr)
+        ed = self.create_ed(converted_raw_path=converted_zarr)
         assert hasattr(ed, "_repr_html_")
         html_repr = ed._repr_html_().strip()
         assert (
@@ -251,7 +275,7 @@ def test_repr_html(self, converted_zarr):
     def test_setattr(self, converted_zarr):
         sample_data = xr.Dataset({"x": [0, 0, 0]})
         sample_data2 = xr.Dataset({"y": [0, 0, 0]})
-        ed = EchoData.from_file(converted_raw_path=converted_zarr)
+        ed = self.create_ed(converted_raw_path=converted_zarr)
         current_ed_beam = ed["Sonar/Beam_group1"]
         current_ed_top = ed['Top-level']
         ed["Sonar/Beam_group1"] = sample_data
@@ -264,7 +288,7 @@ def test_setattr(self, converted_zarr):
         assert ed['Top-level'].equals(current_ed_top) is False
 
     def test_getitem(self, converted_zarr):
-        ed = EchoData.from_file(converted_raw_path=converted_zarr)
+        ed = self.create_ed(converted_raw_path=converted_zarr)
         beam = ed['Sonar/Beam_group1']
         assert isinstance(beam, xr.Dataset)
         assert ed['MyGroup'] is None
@@ -276,7 +300,7 @@ def test_getitem(self, converted_zarr):
             assert isinstance(e, ValueError)
 
     def test_setitem(self, converted_zarr):
-        ed = EchoData.from_file(converted_raw_path=converted_zarr)
+        ed = self.create_ed(converted_raw_path=converted_zarr)
         ed['Sonar/Beam_group1'] = ed['Sonar/Beam_group1'].rename({'beam': 'beam_newname'})
 
         assert sorted(ed['Sonar/Beam_group1'].dims.keys()) == ['beam_newname', 'channel', 'ping_time', 'range_sample']
@@ -287,7 +311,7 @@ def test_setitem(self, converted_zarr):
             assert isinstance(e, GroupNotFoundError)
 
     def test_get_dataset(self, converted_zarr):
-        ed = EchoData.from_file(converted_raw_path=converted_zarr)
+        ed = self.create_ed(converted_raw_path=converted_zarr)
         node = DataTree()
         result = ed._EchoData__get_dataset(node)
 
@@ -297,6 +321,28 @@ def test_get_dataset(self, converted_zarr):
         assert result is None
         assert isinstance(ed_result, xr.Dataset)
 
+    @pytest.mark.parametrize("consolidated", [True, False])
+    def test_to_zarr_consolidated(self, mock_echodata, consolidated):
+        """
+        Tests to_zarr consolidation. Currently, this test uses a mock EchoData object that only
+        has attributes. The consolidated flag provided will be used in every to_zarr call (which 
+        is used to write each EchoData group to zarr_path). 
+        """
+        zarr_path = Path('test.zarr')
+        mock_echodata.to_zarr(str(zarr_path), consolidated=consolidated, overwrite=True)
+
+        check = True if consolidated else False
+        zmeta_path = zarr_path / ".zmetadata"
+
+        assert zmeta_path.exists() is check
+
+        if check is True:
+            check_consolidated(mock_echodata, zmeta_path)
+
+        # clean up the zarr file
+        shutil.rmtree(zarr_path)
+
+
 def test_open_converted(ek60_converted_zarr, minio_bucket):  # noqa
     def _check_path(zarr_path):
         storage_options = {}
@@ -326,82 +372,82 @@ def _check_path(zarr_path):
             assert isinstance(e, ValueError) is True
 
 
-def test_compute_range(compute_range_samples):
-    (
-        filepath,
-        sonar_model,
-        azfp_xml_path,
-        azfp_cal_type,
-        ek_waveform_mode,
-        ek_encode_mode,
-    ) = compute_range_samples
-    ed = echopype.open_raw(filepath, sonar_model, azfp_xml_path)
-    rng = np.random.default_rng(0)
-    stationary_env_params = EnvParams(
-        xr.Dataset(
-            data_vars={
-                "pressure": ("time3", np.arange(50)),
-                "salinity": ("time3", np.arange(50)),
-                "temperature": ("time3", np.arange(50)),
-            },
-            coords={
-                "time3": np.arange("2017-06-20T01:00", "2017-06-20T01:25", np.timedelta64(30, "s"), dtype="datetime64[ns]")
-            }
-        ),
-        data_kind="stationary"
-    )
-    if "time3" in ed["Platform"] and sonar_model != "AD2CP":
-        ed.compute_range(stationary_env_params, azfp_cal_type, ek_waveform_mode)
-    else:
-        try:
-            ed.compute_range(stationary_env_params, ek_waveform_mode="CW", azfp_cal_type="Sv")
-        except ValueError:
-            pass
-        else:
-            raise AssertionError
-
-
-    mobile_env_params = EnvParams(
-        xr.Dataset(
-            data_vars={
-                "pressure": ("time", np.arange(100)),
-                "salinity": ("time", np.arange(100)),
-                "temperature": ("time", np.arange(100)),
-            },
-            coords={
-                "latitude": ("time", rng.random(size=100) + 44),
-                "longitude": ("time", rng.random(size=100) - 125),
-            }
-        ),
-        data_kind="mobile"
-    )
-    if "latitude" in ed["Platform"] and "longitude" in ed["Platform"] and sonar_model != "AD2CP" and not np.isnan(ed["Platform"]["time1"]).all():
-        ed.compute_range(mobile_env_params, azfp_cal_type, ek_waveform_mode)
-    else:
-        try:
-            ed.compute_range(mobile_env_params, ek_waveform_mode="CW", azfp_cal_type="Sv")
-        except ValueError:
-            pass
-        else:
-            raise AssertionError
-
-    env_params = {"sound_speed": 343}
-    if sonar_model == "AD2CP":
-        try:
-            ed.compute_range(
-                env_params, ek_waveform_mode="CW", azfp_cal_type="Sv"
-            )
-        except ValueError:
-            pass  # AD2CP is not currently supported in ed.compute_range
-        else:
-            raise AssertionError
-    else:
-        echo_range = ed.compute_range(
-            env_params,
-            azfp_cal_type,
-            ek_waveform_mode,
-        )
-        assert isinstance(echo_range, xr.DataArray)
+# def test_compute_range(compute_range_samples):
+#     (
+#         filepath,
+#         sonar_model,
+#         azfp_xml_path,
+#         azfp_cal_type,
+#         ek_waveform_mode,
+#         ek_encode_mode,
+#     ) = compute_range_samples
+#     ed = echopype.open_raw(filepath, sonar_model, azfp_xml_path)
+#     rng = np.random.default_rng(0)
+#     stationary_env_params = EnvParams(
+#         xr.Dataset(
+#             data_vars={
+#                 "pressure": ("time3", np.arange(50)),
+#                 "salinity": ("time3", np.arange(50)),
+#                 "temperature": ("time3", np.arange(50)),
+#             },
+#             coords={
+#                 "time3": np.arange("2017-06-20T01:00", "2017-06-20T01:25", np.timedelta64(30, "s"), dtype="datetime64[ns]")
+#             }
+#         ),
+#         data_kind="stationary"
+#     )
+#     if "time3" in ed["Platform"] and sonar_model != "AD2CP":
+#         ed.compute_range(stationary_env_params, azfp_cal_type, ek_waveform_mode)
+#     else:
+#         try:
+#             ed.compute_range(stationary_env_params, ek_waveform_mode="CW", azfp_cal_type="Sv")
+#         except ValueError:
+#             pass
+#         else:
+#             raise AssertionError
+
+
+#     mobile_env_params = EnvParams(
+#         xr.Dataset(
+#             data_vars={
+#                 "pressure": ("time", np.arange(100)),
+#                 "salinity": ("time", np.arange(100)),
+#                 "temperature": ("time", np.arange(100)),
+#             },
+#             coords={
+#                 "latitude": ("time", rng.random(size=100) + 44),
+#                 "longitude": ("time", rng.random(size=100) - 125),
+#             }
+#         ),
+#         data_kind="mobile"
+#     )
+#     if "latitude" in ed["Platform"] and "longitude" in ed["Platform"] and sonar_model != "AD2CP" and not np.isnan(ed["Platform"]["time1"]).all():
+#         ed.compute_range(mobile_env_params, azfp_cal_type, ek_waveform_mode)
+#     else:
+#         try:
+#             ed.compute_range(mobile_env_params, ek_waveform_mode="CW", azfp_cal_type="Sv")
+#         except ValueError:
+#             pass
+#         else:
+#             raise AssertionError
+
+#     env_params = {"sound_speed": 343}
+#     if sonar_model == "AD2CP":
+#         try:
+#             ed.compute_range(
+#                 env_params, ek_waveform_mode="CW", azfp_cal_type="Sv"
+#             )
+#         except ValueError:
+#             pass  # AD2CP is not currently supported in ed.compute_range
+#         else:
+#             raise AssertionError
+#     else:
+#         echo_range = ed.compute_range(
+#             env_params,
+#             azfp_cal_type,
+#             ek_waveform_mode,
+#         )
+#         assert isinstance(echo_range, xr.DataArray)
 
 
 def test_nan_range_entries(range_check_files):
@@ -409,11 +455,15 @@ def test_nan_range_entries(range_check_files):
     echodata = echopype.open_raw(ek_file, sonar_model=sonar_model)
     if sonar_model == "EK80":
         ds_Sv = echopype.calibrate.compute_Sv(echodata, waveform_mode='BB', encode_mode='complex')
-        range_output = echodata.compute_range(env_params=[], ek_waveform_mode='BB')
+        cal_obj = CalibrateEK80(
+            echodata, env_params=None, cal_params=None, waveform_mode="BB", encode_mode="complex",
+        )
+        range_output = cal_obj.range_meter
         nan_locs_backscatter_r = ~echodata["Sonar/Beam_group1"].backscatter_r.isel(beam=0).drop("beam").isnull()
     else:
         ds_Sv = echopype.calibrate.compute_Sv(echodata)
-        range_output = echodata.compute_range(env_params=[])
+        cal_obj = CalibrateEK60(echodata, env_params={}, cal_params=None)
+        range_output = cal_obj.range_meter
         nan_locs_backscatter_r = ~echodata["Sonar/Beam_group1"].backscatter_r.isel(beam=0).drop("beam").isnull()
 
     nan_locs_Sv_range = ~ds_Sv.echo_range.isnull()
diff --git a/echopype/tests/echodata/test_echodata_combine.py b/echopype/tests/echodata/test_echodata_combine.py
index 8d29c2072..f554f6706 100644
--- a/echopype/tests/echodata/test_echodata_combine.py
+++ b/echopype/tests/echodata/test_echodata_combine.py
@@ -7,19 +7,13 @@
 
 import echopype
 from echopype.utils.coding import DEFAULT_ENCODINGS
+import os.path
 
 import tempfile
 from dask.distributed import Client
 
-
-@pytest.fixture
-def ek60_test_data(test_path):
-    files = [
-        ("ncei-wcsd", "Summer2017-D20170620-T011027.raw"),
-        ("ncei-wcsd", "Summer2017-D20170620-T014302.raw"),
-        ("ncei-wcsd", "Summer2017-D20170620-T021537.raw"),
-    ]
-    return [test_path["EK60"].joinpath(*f) for f in files]
+from echopype.echodata.combine import _create_channel_selection_dict, _check_echodata_channels, \
+    _check_channel_consistency
 
 
 @pytest.fixture
@@ -32,6 +26,16 @@ def ek60_diff_range_sample_test_data(test_path):
     return [test_path["EK60"].joinpath(*f) for f in files]
 
 
+@pytest.fixture(scope="module")
+def ek60_test_data(test_path):
+    files = [
+        ("ncei-wcsd", "Summer2017-D20170620-T011027.raw"),
+        ("ncei-wcsd", "Summer2017-D20170620-T014302.raw"),
+        ("ncei-wcsd", "Summer2017-D20170620-T021537.raw"),
+    ]
+    return [test_path["EK60"].joinpath(*f) for f in files]
+
+
 @pytest.fixture
 def ek80_test_data(test_path):
     files = [
@@ -145,7 +149,7 @@ def test_combine_echodata(raw_datasets):
 
     # create temporary directory for zarr store
     temp_zarr_dir = tempfile.TemporaryDirectory()
-    zarr_file_name = temp_zarr_dir.name + "/combined_echodatas.zarr"
+    zarr_file_name = os.path.join(temp_zarr_dir.name, "combined_echodata.zarr")
 
     # create dask client
     client = Client()
@@ -216,13 +220,26 @@ def test_combine_echodata(raw_datasets):
     client.close()
 
 
+def test_combine_echodata_channel_selection():
+    """
+    This test ensures that the ``channel_selection`` input
+    of ``combine_echodata`` is producing the correct output
+    for all sonar models except AD2CP.
+    """
+
+    # TODO: Once a mock EchoData structure can be easily formed,
+    #  we should implement this test.
+
+    pytest.skip("This test will not be implemented until after a mock EchoData object can be created.")
+
+
 def test_attr_storage(ek60_test_data):
     # check storage of attributes before combination in provenance group
     eds = [echopype.open_raw(file, "EK60") for file in ek60_test_data]
 
     # create temporary directory for zarr store
     temp_zarr_dir = tempfile.TemporaryDirectory()
-    zarr_file_name = temp_zarr_dir.name + "/combined_echodatas.zarr"
+    zarr_file_name = os.path.join(temp_zarr_dir.name, "combined_echodata.zarr")
 
     # create dask client
     client = Client()
@@ -268,7 +285,7 @@ def test_combined_encodings(ek60_test_data):
 
     # create temporary directory for zarr store
     temp_zarr_dir = tempfile.TemporaryDirectory()
-    zarr_file_name = temp_zarr_dir.name + "/combined_echodatas.zarr"
+    zarr_file_name = os.path.join(temp_zarr_dir.name, "combined_echodata.zarr")
 
     # create dask client
     client = Client()
@@ -315,7 +332,7 @@ def test_combined_echodata_repr(ek60_test_data):
 
     # create temporary directory for zarr store
     temp_zarr_dir = tempfile.TemporaryDirectory()
-    zarr_file_name = temp_zarr_dir.name + "/combined_echodatas.zarr"
+    zarr_file_name = os.path.join(temp_zarr_dir.name, "combined_echodata.zarr")
 
     # create dask client
     client = Client()
@@ -331,7 +348,7 @@ def test_combined_echodata_repr(ek60_test_data):
         │   └── NMEA: contains information specific to the NMEA protocol.
         ├── Provenance: contains metadata about how the SONAR-netCDF4 version of the data were obtained.
         ├── Sonar: contains sonar system metadata and sonar beam groups.
-        │   └── Beam_group1: contains backscatter data (either complex samples or uncalibrated power samples) and other beam or channel-specific data, including split-beam angle data when they exist.
+        │   └── Beam_group1: contains backscatter power (uncalibrated) and other beam or channel-specific data, including split-beam angle data when they exist.
         └── Vendor_specific: contains vendor-specific information about the sonar and the data."""
     )
 
@@ -344,3 +361,151 @@ def test_combined_echodata_repr(ek60_test_data):
 
     # close client
     client.close()
+
+
+@pytest.mark.parametrize(
+    ("all_chan_list", "channel_selection"),
+    [
+        (
+            [['a', 'b', 'c'], ['a', 'b', 'c']],
+            None
+        ),
+        pytest.param(
+            [['a', 'b', 'c'], ['a', 'b']],
+            None,
+            marks=pytest.mark.xfail(strict=True,
+                                    reason="This test should not pass because the channels are not consistent")
+        ),
+        (
+            [['a', 'b', 'c'], ['a', 'b', 'c']],
+            ['a', 'b', 'c']
+        ),
+        (
+            [['a', 'b', 'c'], ['a', 'b', 'c']],
+            ['a', 'b']
+        ),
+        (
+            [['a', 'b', 'c'], ['a', 'b']],
+            ['a', 'b']
+        ),
+        pytest.param(
+            [['a', 'c'], ['a', 'b', 'c']],
+            ['a', 'b'],
+            marks=pytest.mark.xfail(strict=True,
+                                    reason="This test should not pass because we are selecting "
+                                           "channels that do not occur in each Dataset")
+        ),
+    ],
+    ids=["chan_sel_none_pass", "chan_sel_none_fail",
+         "chan_sel_same_as_given_chans", "chan_sel_subset_of_given_chans",
+         "chan_sel_subset_of_given_chans_uneven", "chan_sel_diff_from_some_given_chans"]
+)
+def test_check_channel_consistency(all_chan_list, channel_selection):
+    """
+    Ensures that the channel consistency check for combine works
+    as expected using mock data.
+    """
+
+    _check_channel_consistency(all_chan_list, "test_group", channel_selection)
+
+
+# create duplicated dictionaries used within pytest parameterize
+has_chan_dim_1_beam = {'Top-level': False, 'Environment': False, 'Platform': True,
+                       'Platform/NMEA': False, 'Provenance': False, 'Sonar': True,
+                       'Sonar/Beam_group1': True, 'Vendor_specific': True}
+
+has_chan_dim_2_beam = {'Top-level': False, 'Environment': False, 'Platform': True,
+                       'Platform/NMEA': False, 'Provenance': False, 'Sonar': True,
+                       'Sonar/Beam_group1': True, 'Sonar/Beam_group2': True, 'Vendor_specific': True}
+
+expected_1_beam_none = {'Top-level': None, 'Environment': None, 'Platform': None,
+                        'Platform/NMEA': None, 'Provenance': None, 'Sonar': None,
+                        'Sonar/Beam_group1': None, 'Vendor_specific': None}
+
+expected_1_beam_a_b_sel = {'Top-level': None, 'Environment': None, 'Platform': ['a', 'b'],
+                           'Platform/NMEA': None, 'Provenance': None, 'Sonar': ['a', 'b'],
+                           'Sonar/Beam_group1': ['a', 'b'], 'Vendor_specific': ['a', 'b']}
+
+
+@pytest.mark.parametrize(
+    ("sonar_model", "has_chan_dim", "user_channel_selection", "expected_dict"),
+    [
+        (
+            ["EK60", "ES70", "AZFP"],
+            has_chan_dim_1_beam,
+            [None],
+            expected_1_beam_none
+        ),
+        (
+            ["EK80", "ES80", "EA640"],
+            has_chan_dim_1_beam,
+            [None],
+            expected_1_beam_none
+        ),
+        (
+            ["EK80", "ES80", "EA640"],
+            has_chan_dim_2_beam,
+            [None],
+            {'Top-level': None, 'Environment': None, 'Platform': None, 'Platform/NMEA': None,
+             'Provenance': None, 'Sonar': None, 'Sonar/Beam_group1': None,
+             'Sonar/Beam_group2': None, 'Vendor_specific': None}
+        ),
+        (
+            ["EK60", "ES70", "AZFP"],
+            has_chan_dim_1_beam,
+            [['a', 'b'], {'Sonar/Beam_group1': ['a', 'b']}],
+            expected_1_beam_a_b_sel
+        ),
+        (
+            ["EK80", "ES80", "EA640"],
+            has_chan_dim_1_beam,
+            [['a', 'b'], {'Sonar/Beam_group1': ['a', 'b']}],
+            expected_1_beam_a_b_sel
+        ),
+        (
+            ["EK80", "ES80", "EA640"],
+            has_chan_dim_2_beam,
+            [['a', 'b']],
+            {'Top-level': None, 'Environment': None, 'Platform': ['a', 'b'], 'Platform/NMEA': None,
+             'Provenance': None, 'Sonar': ['a', 'b'], 'Sonar/Beam_group1': ['a', 'b'],
+             'Sonar/Beam_group2': ['a', 'b'], 'Vendor_specific': ['a', 'b']}
+        ),
+        (
+            ["EK80", "ES80", "EA640"],
+            has_chan_dim_2_beam,
+            [{'Sonar/Beam_group1': ['a', 'b'], 'Sonar/Beam_group2': ['c', 'd']}],
+            {'Top-level': None, 'Environment': None, 'Platform': ['a', 'b', 'c', 'd'], 'Platform/NMEA': None,
+             'Provenance': None, 'Sonar': ['a', 'b', 'c', 'd'], 'Sonar/Beam_group1': ['a', 'b'],
+             'Sonar/Beam_group2': ['c', 'd'], 'Vendor_specific': ['a', 'b', 'c', 'd']}
+        ),
+        (
+            ["EK80", "ES80", "EA640"],
+            has_chan_dim_2_beam,
+            [{'Sonar/Beam_group1': ['a', 'b'], 'Sonar/Beam_group2': ['b', 'c', 'd']}],
+            {'Top-level': None, 'Environment': None, 'Platform': ['a', 'b', 'c', 'd'], 'Platform/NMEA': None,
+             'Provenance': None, 'Sonar': ['a', 'b', 'c', 'd'], 'Sonar/Beam_group1': ['a', 'b'],
+             'Sonar/Beam_group2': ['b', 'c', 'd'], 'Vendor_specific': ['a', 'b', 'c', 'd']}
+        ),
+    ],
+    ids=["EK60_no_sel", "EK80_no_sel_1_beam", "EK80_no_sel_2_beam", "EK60_chan_sel",
+         "EK80_chan_sel_1_beam", "EK80_list_chan_sel_2_beam", "EK80_dict_chan_sel_2_beam_diff_beam_group_chans",
+         "EK80_dict_chan_sel_2_beam_overlap_beam_group_chans"]
+)
+def test_create_channel_selection_dict(sonar_model, has_chan_dim,
+                                       user_channel_selection, expected_dict):
+    """
+    Ensures that ``create_channel_selction_dict`` is constructing the correct output
+    for the sonar models ``EK60, EK80, AZFP`` and varying inputs for the input
+    ``user_channel_selection``.
+
+    Notes
+    -----
+    The input ``has_chan_dim`` is unchanged except for the case where we are considering
+    an EK80 sonar model with two beam groups.
+    """
+
+    for model in sonar_model:
+        for usr_sel_chan in user_channel_selection:
+
+            channel_selection_dict = _create_channel_selection_dict(model, has_chan_dim, usr_sel_chan)
+            assert channel_selection_dict == expected_dict
diff --git a/echopype/tests/echodata/test_echodata_simrad.py b/echopype/tests/echodata/test_echodata_simrad.py
new file mode 100644
index 000000000..6be3b535c
--- /dev/null
+++ b/echopype/tests/echodata/test_echodata_simrad.py
@@ -0,0 +1,68 @@
+"""
+Tests functions contained within echodata/simrad.py
+"""
+import pytest
+from echopype.echodata.simrad import retrieve_correct_beam_group, check_input_args_combination
+
+
+@pytest.mark.parametrize(
+    ("waveform_mode", "encode_mode", "pulse_compression"),
+    [
+        pytest.param("CW", "comp_power", None,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason='This test should fail since comp_power '
+                                                    'is not an acceptable choice for encode_mode.')),
+        pytest.param("CB", None, None,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason='This test should fail since CB is not an '
+                                                    'acceptable choice for waveform_mode.')),
+        pytest.param("BB", "power", None,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason='This test should fail since BB and power is '
+                                                    'not an acceptable combination.')),
+        pytest.param("BB", "power", True,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason='This test should fail since BB and complex '
+                                                    'must be used if pulse_compression is True.')),
+        pytest.param("CW", "complex", True,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason='This test should fail since BB and complex '
+                                                    'must be used if pulse_compression is True.')),
+        pytest.param("CW", "power", True,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason='This test should fail since BB and complex '
+                                                    'must be used if pulse_compression is True.')),
+        ("CW", "complex", False),
+        ("CW", "power", False),
+        ("BB", "complex", False),
+        ("BB", "complex", True),
+
+    ],
+    ids=["incorrect_encode_mode", "incorrect_waveform_mode", "BB_power_combo",
+         "BB_power_pc_True", "CW_complex_pc_True", "CW_power_pc_True", "CW_complex_pc_False",
+         "CW_power_pc_False", "BB_complex_pc_False", "BB_complex_pc_True"]
+)
+def test_check_input_args_combination(waveform_mode: str, encode_mode: str,
+                                      pulse_compression: bool):
+    """
+    Ensures that ``check_input_args_combination`` functions correctly when
+    provided various combinations of the input parameters.
+
+    Parameters
+    ----------
+    waveform_mode: str
+        Type of transmit waveform
+    encode_mode: str
+        Type of encoded return echo data
+    pulse_compression: bool
+        States whether pulse compression should be used
+    """
+
+    check_input_args_combination(waveform_mode, encode_mode, pulse_compression)
+
+
+def test_retrieve_correct_beam_group():
+
+    # TODO: create this test once we are happy with the form of retrieve_correct_beam_group
+
+    pytest.skip("We need to add tests for retrieve_correct_beam_group!")
diff --git a/echopype/tests/echodata/test_echodata_structure.py b/echopype/tests/echodata/test_echodata_structure.py
index 9bfc14258..ec335572a 100644
--- a/echopype/tests/echodata/test_echodata_structure.py
+++ b/echopype/tests/echodata/test_echodata_structure.py
@@ -210,6 +210,10 @@ def test_v05x_v06x_conversion_structure(azfp_path, ek60_path, ek80_path):
     0.6.x structure.
     """
 
+    pytest.xfail("PR #881 has caused these tests to fail for EK80 sonar models. While we "
+                 "revise this test structure, these tests will be skipped. Please see issue "
+                 "https://github.com/OSOceanAcoustics/echopype/issues/884 for more information.")
+
     converted_raw_paths_v06x, converted_raw_paths_v05x = \
         _get_conversion_file_lists(azfp_path, ek60_path, ek80_path)
 
diff --git a/echopype/tests/echodata/test_zarr_combine.py b/echopype/tests/echodata/test_zarr_combine.py
index 29085c9ee..c9e064d7e 100644
--- a/echopype/tests/echodata/test_zarr_combine.py
+++ b/echopype/tests/echodata/test_zarr_combine.py
@@ -1,39 +1,58 @@
+from collections import defaultdict
 from echopype.echodata.zarr_combine import ZarrCombine
 from dask.distributed import Client
+import shutil
 import numpy as np
 import xarray as xr
+import echopype
 from echopype.utils.coding import set_time_encodings
+from echopype.echodata import EchoData
+from pathlib import Path
+from echopype.echodata.combine import check_echodatas_input, check_zarr_path, _check_echodata_channels
 from typing import List, Tuple, Dict
 import tempfile
 import pytest
 import zarr
+import os.path
+from utils import check_consolidated
+
+
+@pytest.fixture(scope="module")
+def ek60_test_data(test_path):
+    files = [
+        ("ncei-wcsd", "Summer2017-D20170620-T011027.raw"),
+        ("ncei-wcsd", "Summer2017-D20170620-T014302.raw"),
+        ("ncei-wcsd", "Summer2017-D20170620-T021537.raw"),
+        ("ncei-wcsd", "Summer2017-D20170620-T024811.raw")
+    ]
+    return [test_path["EK60"].joinpath(*f) for f in files]
 
 
 @pytest.fixture(
     params=[
         (
-                {
-                    "randint_low": 10,
-                    "randint_high": 5000,
-                    "num_datasets": 20,
-                    "group": "test_group",
-                    "zarr_name": "combined_echodatas.zarr",
-                    "delayed_ds_list": False
-                }
+            {
+                "randint_low": 10,
+                "randint_high": 5000,
+                "num_datasets": 20,
+                "group": "test_group",
+                "zarr_name": "combined_echodata.zarr",
+                "delayed_ds_list": False,
+            }
         ),
         (
-                {
-                    "randint_low": 10,
-                    "randint_high": 5000,
-                    "num_datasets": 20,
-                    "group": "test_group",
-                    "zarr_name": "combined_echodatas.zarr",
-                    "delayed_ds_list": True
-                }
-        )
+            {
+                "randint_low": 10,
+                "randint_high": 5000,
+                "num_datasets": 20,
+                "group": "test_group",
+                "zarr_name": "combined_echodata.zarr",
+                "delayed_ds_list": True,
+            }
+        ),
     ],
     ids=["in-memory-ds_list", "lazy-ds_list"],
-    scope="module"
+    scope="module",
 )
 def append_ds_list_params(request):
     return list(request.param.values())
@@ -74,8 +93,10 @@ def get_ranges(lengths: np.ndarray) -> List[Tuple[int, int]]:
     return test_ds_ranges
 
 
-def generate_test_ds(append_dims_ranges: Dict[str, Tuple[int, int]],
-                     var_names_dims: Dict[str, str]) -> xr.Dataset:
+def generate_test_ds(
+    append_dims_ranges: Dict[str, Tuple[int, int]],
+    var_names_dims: Dict[str, str],
+) -> xr.Dataset:
     """
     Constructs a test Dataset.
 
@@ -141,9 +162,9 @@ def generate_test_ds(append_dims_ranges: Dict[str, Tuple[int, int]],
     return ds
 
 
-def get_all_test_data(num_datasets: int, randint_low: int,
-                      randint_high: int) -> Tuple[List[xr.Dataset],
-                                                  xr.Dataset, int, int]:
+def get_all_test_data(
+    num_datasets: int, randint_low: int, randint_high: int
+) -> Tuple[List[xr.Dataset], xr.Dataset, int, int]:
     """
     Generates a list of ``num_datasets`` Datasets with variable and
     coordinate lengths between ``[randint_low, randint_high)``.
@@ -172,8 +193,12 @@ def get_all_test_data(num_datasets: int, randint_low: int,
     """
 
     # generate differing time1 and time2 lengths for each Dataset
-    time1_lengths = np.random.randint(low=randint_low, high=randint_high, size=num_datasets)
-    time2_lengths = np.random.randint(low=randint_low, high=randint_high, size=num_datasets)
+    time1_lengths = np.random.randint(
+        low=randint_low, high=randint_high, size=num_datasets
+    )
+    time2_lengths = np.random.randint(
+        low=randint_low, high=randint_high, size=num_datasets
+    )
 
     # get time1 and time2 value ranges based off of lengths
     time1_ranges = get_ranges(time1_lengths)
@@ -187,16 +212,26 @@ def get_all_test_data(num_datasets: int, randint_low: int,
     var_names_dims = {"var1": "time1", "var2": "time2"}
 
     # generate the expected final combined Dataset
-    true_comb = generate_test_ds(append_dims_ranges={"time1": time1_true_range,
-                                                     "time2": time2_true_range},
-                                 var_names_dims=var_names_dims)
+    true_comb = generate_test_ds(
+        append_dims_ranges={
+            "time1": time1_true_range,
+            "time2": time2_true_range,
+        },
+        var_names_dims=var_names_dims,
+    )
 
     # generate the list of Datasets that will be combined
     ds_list = []
     for ind in range(num_datasets):
-        ds_list.append(generate_test_ds(append_dims_ranges={"time1": time1_ranges[ind],
-                                                            "time2": time2_ranges[ind]},
-                                        var_names_dims=var_names_dims))
+        ds_list.append(
+            generate_test_ds(
+                append_dims_ranges={
+                    "time1": time1_ranges[ind],
+                    "time2": time2_ranges[ind],
+                },
+                var_names_dims=var_names_dims,
+            )
+        )
 
     # collect max length for time1 and time2, so we can determine the appropriate chunk shape
     max_time1_len = np.max(time1_lengths)
@@ -229,7 +264,7 @@ def test_append_ds_list_to_zarr(append_ds_list_params):
         num_datasets,
         group,
         zarr_name,
-        delayed_ds_list
+        delayed_ds_list,
     ) = append_ds_list_params
 
     # initialize ZarrCombine
@@ -237,27 +272,33 @@ def test_append_ds_list_to_zarr(append_ds_list_params):
 
     # create temporary directory for zarr store
     temp_zarr_dir = tempfile.TemporaryDirectory()
-    zarr_path = temp_zarr_dir.name + "/" + zarr_name
+    zarr_path = os.path.join(temp_zarr_dir.name, zarr_name)
 
     # obtain a client with a local scheduler
     client = Client()
 
     # generate the ds_list and the known combined form of the list
-    ds_list, true_comb, max_time1_len, max_time2_len = get_all_test_data(randint_low=randint_low,
-                                                                         randint_high=randint_high,
-                                                                         num_datasets=num_datasets)
+    ds_list, true_comb, max_time1_len, max_time2_len = get_all_test_data(
+        randint_low=randint_low,
+        randint_high=randint_high,
+        num_datasets=num_datasets,
+    )
 
     if delayed_ds_list:
 
         # write ds_list to zarr
         for count, ds in enumerate(ds_list):
-            ds_zarr_path = temp_zarr_dir.name + "/ds_sets/file_" + str(count) + ".zarr"
+            ds_zarr_path = os.path.join(
+                temp_zarr_dir.name, "ds_sets", "file_" + str(count) + ".zarr"
+            )
             ds.to_zarr(ds_zarr_path)
 
         # get lazy ds_list
         ds_list_lazy = []
         for count, ds in enumerate(ds_list):
-            ds_zarr_path = temp_zarr_dir.name + "/ds_sets/file_" + str(count) + ".zarr"
+            ds_zarr_path = os.path.join(
+                temp_zarr_dir.name, "ds_sets", "file_" + str(count) + ".zarr"
+            )
 
             ds_list_lazy.append(xr.open_zarr(ds_zarr_path))
 
@@ -284,12 +325,16 @@ def test_append_ds_list_to_zarr(append_ds_list_params):
     # ensure that the final combined file has the correct chunk shapes
     for var_name in final_comb.variables:
 
-        z1 = zarr.open_array(zarr_path + f"/{group}/{var_name}")
+        z1 = zarr.open_array(os.path.join(zarr_path, group, var_name))
 
         if var_name in ["var1", "time1"]:
-            assert z1.chunks == (min(comb.max_append_chunk_size, max_time1_len),)
+            assert z1.chunks == (
+                min(comb.max_append_chunk_size, max_time1_len),
+            )
         else:
-            assert z1.chunks == (min(comb.max_append_chunk_size, max_time2_len),)
+            assert z1.chunks == (
+                min(comb.max_append_chunk_size, max_time2_len),
+            )
 
     # close client and scheduler
     client.close()
@@ -297,4 +342,166 @@ def test_append_ds_list_to_zarr(append_ds_list_params):
     # remove temporary directory
     temp_zarr_dir.cleanup()
 
+@pytest.mark.parametrize("test_param", [
+        "single",
+        pytest.param(
+            "multi",
+            marks=pytest.mark.xfail(
+                reason=(
+                    'Not yet supported. Some bug found at '
+                    '`zarr_combine.py::_append_provenance_attr_vars`.'
+                )
+            )
+        )
+    ]
+)
+def test_combine_echodata_combined_and_others(ek60_test_data, test_param, sonar_model="EK60"):
+        """
+        Integration test for combine_echodata with
+        a single combined ed and a single echodata
+        """
+        eds = [
+            echopype.open_raw(raw_file=file, sonar_model=sonar_model)
+            for file in ek60_test_data
+        ]
+        # create temporary directory for zarr store
+        temp_zarr_dir = tempfile.TemporaryDirectory()
+        first_zarr = (
+            temp_zarr_dir.name
+            + f"/combined_echodata.zarr"
+        )
+        second_zarr = (
+            temp_zarr_dir.name
+            + f"/combined_echodata2.zarr"
+        )
+        combined_ed = echopype.combine_echodata(eds[:2], zarr_path=first_zarr, overwrite=True)
+        if test_param == "single":
+            data_inputs = [combined_ed, eds[2]]
+        else:
+            data_inputs = [combined_ed, eds[2], eds[3]]
+        combined_ed2 = echopype.combine_echodata(
+            data_inputs,
+            zarr_path=second_zarr,
+            overwrite=True
+        )
+
+        assert isinstance(combined_ed, EchoData)
+        assert isinstance(combined_ed2, EchoData)
+
+        # Ensure that they're from the same file source
+        assert eds[0]['Provenance'].source_filenames[0].values == combined_ed['Provenance'].source_filenames[0].values
+        assert eds[1]['Provenance'].source_filenames[0].values == combined_ed['Provenance'].source_filenames[1].values
+        assert eds[2]['Provenance'].source_filenames[0].values == combined_ed2['Provenance'].source_filenames[2].values
+        if test_param == "multi":
+            assert eds[3]['Provenance'].source_filenames[0].values == combined_ed2['Provenance'].source_filenames[3].values
+
+        # Check beam_group1. Should be exactly same xr dataset
+        group_path = "Sonar/Beam_group1"
+        ds0 = eds[0][group_path]
+        filt_ds0 = combined_ed[group_path].sel(ping_time=ds0.ping_time)
+        assert filt_ds0.equals(ds0) is True
+
+        ds1 = eds[1][group_path]
+        filt_ds1 = combined_ed[group_path].sel(ping_time=ds1.ping_time)
+        assert filt_ds1.equals(ds1) is True
+
+        ds2 = eds[2][group_path]
+        filt_ds2 = combined_ed2[group_path].sel(ping_time=ds2.ping_time)
+        assert filt_ds2.equals(ds2) is True
+
+        if test_param == "multi":
+            ds3 = eds[3][group_path]
+            filt_ds3 = combined_ed2[group_path].sel(ping_time=ds3.ping_time)
+            assert filt_ds3.equals(ds3) is True
+
+        filt_combined = combined_ed2[group_path].sel(ping_time=combined_ed[group_path].ping_time)
+        assert filt_combined.equals(combined_ed[group_path])
+
+
+class TestZarrCombine:
+    # initiate ZarrCombine object
+    zarr_combine = ZarrCombine()
+    sonar_model = "EK60"
+
+    def test_constructor(self):
+        # all possible time dimensions
+        assert self.zarr_combine.possible_time_dims == {
+            "time1",
+            "time2",
+            "time3",
+            "ping_time",
+        }
+        # all possible dimensions that we will append to (mainly time dims)
+        assert self.zarr_combine.append_dims == {"filenames"}.union(
+            self.zarr_combine.possible_time_dims
+        )
+        # encodings associated with lazy loaded variables
+        assert self.zarr_combine.lazy_encodings == [
+            "chunks",
+            "preferred_chunks",
+        ]
+        # defaultdict that holds every group's attributes
+        assert self.zarr_combine.group_attrs == defaultdict(list)
+
+        # The sonar_model for the new combined EchoData object
+        assert self.zarr_combine.sonar_model is None
+
+        # The maximum chunk length allowed for every append dimension
+        assert self.zarr_combine.max_append_chunk_size == 1000
+
+        # initialize variables created within class methods
+        # descriptions of these variables can be found in _get_ds_info
+        assert self.zarr_combine.dims_df is None
+        assert self.zarr_combine.dims_sum is None
+        assert self.zarr_combine.dims_csum is None
+        assert self.zarr_combine.dims_max is None
+
+        # Ensure that combine exists
+        assert hasattr(self.zarr_combine, 'combine')
+
+    @pytest.mark.parametrize("consolidated", [True, False])
+    def test_combine_consolidated(self, ek60_test_data, consolidated):
+        zarr_combine = ZarrCombine()
+        eds = [
+            echopype.open_raw(raw_file=file, sonar_model=self.sonar_model)
+            for file in ek60_test_data
+        ]
+        # create temporary directory for zarr store
+        temp_zarr_dir = tempfile.TemporaryDirectory()
+        zarr_file_name = (
+            temp_zarr_dir.name
+            + f"/combined_echodatas_{str(consolidated)}.zarr"
+        )
+
+        zarr_path = check_zarr_path(zarr_file_name)
+
+        _, echodata_filenames = check_echodatas_input(eds)
+
+        # get channel selection for each EchoData group
+        ed_group_chan_sel = _check_echodata_channels(eds, user_channel_selection=None)
+
+        # create dask client
+        client = Client()
+
+        # combine all elements in echodatas by writing to a zarr store
+        combined_echodata = zarr_combine.combine(
+            zarr_path,
+            eds,
+            sonar_model=self.sonar_model,
+            echodata_filenames=echodata_filenames,
+            ed_group_chan_sel=ed_group_chan_sel,
+            consolidated=consolidated,
+        )
+
+        check = True if consolidated else False
+        zmeta_path = Path(zarr_path) / ".zmetadata"
+
+        assert zmeta_path.exists() is check
+
+        if check is True:
+            check_consolidated(combined_echodata, zmeta_path)
+
+        temp_zarr_dir.cleanup()
 
+        # close client
+        client.close()
diff --git a/echopype/tests/echodata/utils.py b/echopype/tests/echodata/utils.py
new file mode 100644
index 000000000..011dafeeb
--- /dev/null
+++ b/echopype/tests/echodata/utils.py
@@ -0,0 +1,150 @@
+import os
+import json
+from pathlib import Path
+
+import xarray as xr
+
+from datatree import DataTree
+
+import numpy as np
+
+from echopype.convert.set_groups_base import SetGroupsBase
+from echopype.echodata.echodata import EchoData
+
+
+class SetGroupsTest(SetGroupsBase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def set_beam(self) -> xr.Dataset:
+        ds = xr.Dataset(
+            attrs={"beam_mode": "vertical", "conversion_equation_t": "type_3"}
+        )
+        return ds
+
+    def set_env(self) -> xr.Dataset:
+        # TODO: add mock data
+        ds = xr.Dataset()
+
+        env_attr_dict = {
+            "notes": "This is a mock env dataset, hence no data is found!"
+        }
+        ds = ds.assign_attrs(env_attr_dict)
+
+        return ds
+
+    def set_platform(self) -> xr.Dataset:
+        # TODO: add mock data
+        ds = xr.Dataset(
+            attrs={
+                "platform_code_ICES": 315,
+                "platform_name": "My mock boat",
+                "platform_type": "Research vessel",
+            }
+        )
+
+        return ds
+
+    def set_nmea(self) -> xr.Dataset:
+        # TODO: add mock data
+        ds = xr.Dataset(
+            attrs={
+                "description": "All Mock NMEA datagrams",
+            }
+        )
+
+        return ds
+
+    def set_sonar(self) -> xr.Dataset:
+        # TODO: add mock data
+        ds = xr.Dataset()
+
+        # Assemble sonar group global attribute dictionary
+        sonar_attr_dict = {
+            "sonar_manufacturer": "Simrad",
+            "sonar_model": self.sonar_model,
+            # transducer (sonar) serial number is not stored in the EK60 raw data file,
+            # so sonar_serial_number can't be populated from the raw datagrams
+            "sonar_serial_number": "",
+            "sonar_software_name": "",
+            "sonar_software_version": "0.1.0",
+            "sonar_type": "echosounder",
+        }
+        ds = ds.assign_attrs(sonar_attr_dict)
+
+        return ds
+
+    def set_vendor(self) -> xr.Dataset:
+        # TODO: add mock data
+        ds = xr.Dataset(attrs={"created_by": "Mock test"})
+        return ds
+
+
+def get_mock_echodata(
+    sonar_model='TEST',
+    file_chk='./test.raw',
+    xml_chk=None,
+):
+    # Setup tree dictionary
+    tree_dict = {}
+
+    setgrouper = SetGroupsTest(
+        parser_obj=None,
+        input_file=file_chk,
+        xml_path=xml_chk,
+        output_path=None,
+        sonar_model=sonar_model,
+        params={"survey_name": "mock_survey"},
+    )
+    tree_dict["/"] = setgrouper.set_toplevel(
+        sonar_model, date_created=np.datetime64("1970-01-01")
+    )
+    tree_dict["Environment"] = setgrouper.set_env()
+    tree_dict["Platform"] = setgrouper.set_platform()
+    tree_dict["Platform/NMEA"] = setgrouper.set_nmea()
+    tree_dict["Provenance"] = setgrouper.set_provenance()
+    tree_dict["Sonar"] = None
+    tree_dict["Sonar/Beam_group1"] = setgrouper.set_beam()
+    tree_dict["Sonar"] = setgrouper.set_sonar()
+    tree_dict["Vendor_specific"] = setgrouper.set_vendor()
+
+    tree = DataTree.from_dict(tree_dict, name="root")
+    echodata = EchoData(
+        source_file=file_chk, xml_path=xml_chk, sonar_model=sonar_model
+    )
+    echodata._set_tree(tree)
+    echodata._load_tree()
+    return echodata
+
+
+def check_consolidated(echodata: EchoData, zmeta_path: Path) -> None:
+    """
+    Checks for the presence of `.zgroup`
+    for every group in echodata within the `.zmetadata`
+    file.
+
+    Parameters
+    ----------
+    echodata : EchoData
+        The echodata object to be checked.
+    zmeta_path : pathlib.Path
+        The path to the .zmetadata for the zarr file.
+    """
+    # Check that every group is in
+    # the zmetadata if consolidated
+    expected_zgroups = [
+        os.path.join(p, '.zgroup') if p != 'Top-level' else '.zgroup'
+        for p in echodata.group_paths
+    ]
+
+    with open(zmeta_path) as f:
+        meta_json = json.load(f)
+
+    file_groups = [
+        k
+        for k in meta_json['metadata'].keys()
+        if k.endswith('.zgroup')
+    ]
+
+    for g in expected_zgroups:
+        assert g in file_groups, f"{g} not Found!"
diff --git a/echopype/tests/mask/test_mask.py b/echopype/tests/mask/test_mask.py
new file mode 100644
index 000000000..933948c6a
--- /dev/null
+++ b/echopype/tests/mask/test_mask.py
@@ -0,0 +1,583 @@
+import pathlib
+
+import pytest
+
+import numpy as np
+import xarray as xr
+import dask.array
+import tempfile
+import os
+
+import echopype as ep
+import echopype.mask
+from echopype.mask.api import (
+    _check_source_Sv_freq_diff,
+    _validate_and_collect_mask_input,
+    _check_var_name_fill_value
+)
+
+from typing import List, Union, Optional
+
+
+def get_mock_freq_diff_data(n: int, n_chan_freq: int, add_chan: bool,
+                            add_freq_nom: bool) -> xr.Dataset:
+    """
+    Creates an in-memory mock Sv Dataset.
+
+    Parameters
+    ----------
+    n: int
+        The number of rows (``ping_time``) and columns (``range_sample``) of
+        each channel matrix
+    n_chan_freq: int
+        Determines the size of the ``channel`` coordinate and ``frequency_nominal``
+        variable. To create mock data with known outcomes for ``frequency_differencing``,
+        this value must be greater than or equal to 3.
+    add_chan: bool
+        If True the ``channel`` dimension will be named "channel", else it will
+        be named "data_coord"
+    add_freq_nom: bool
+        If True the ``frequency_nominal`` variable will be added to the Dataset
+
+    Returns
+    -------
+    mock_Sv_ds: xr.Dataset
+        A mock Sv dataset to be used for ``frequency_differencing`` tests. The Sv
+        data values for the channel coordinate ``chan1`` will be equal to ``mat_A``,
+        ``chan3`` will be equal to ``mat_B``, and all other channel coordinates
+        will retain the value of ``np.identity(n)``.
+
+    Notes
+    -----
+    The mock Sv Data is created in such a way where ``mat_A - mat_B`` will be
+    the identity matrix.
+    """
+
+    if n_chan_freq < 3:
+        raise RuntimeError("The input n_chan_freq must be greater than or equal to 3!")
+
+    # matrix representing freqB
+    mat_B = np.arange(n ** 2).reshape(n, n) - np.identity(n)
+
+    # matrix representing freqA
+    mat_A = np.arange(n ** 2).reshape(n, n)
+
+    # construct channel values
+    chan_vals = ['chan' + str(i) for i in range(1, n_chan_freq + 1)]
+
+    # construct mock Sv data
+    mock_Sv_data = [mat_A, np.identity(n), mat_B] + [np.identity(n) for i in range(3, n_chan_freq)]
+
+    # set channel coordinate name (used for testing purposes)
+    if not add_chan:
+        channel_coord_name = "data_coord"
+    else:
+        channel_coord_name = "channel"
+
+    # create mock Sv DataArray
+    mock_Sv_da = xr.DataArray(data=np.stack(mock_Sv_data),
+                              coords={channel_coord_name: chan_vals, "ping_time": np.arange(n),
+                                      "range_sample": np.arange(n)})
+
+    # create data variables for the Dataset
+    data_vars = {"Sv": mock_Sv_da}
+
+    if add_freq_nom:
+        # construct frequency_values
+        freq_vals = [float(i) for i in range(1, n_chan_freq + 1)]
+
+        # create mock frequency_nominal and add it to the Dataset variables
+        mock_freq_nom = xr.DataArray(data=freq_vals, coords={channel_coord_name: chan_vals})
+        data_vars["frequency_nominal"] = mock_freq_nom
+
+    # create mock Dataset with Sv and frequency_nominal
+    mock_Sv_ds = xr.Dataset(data_vars=data_vars)
+
+    return mock_Sv_ds
+
+
+def get_mock_source_ds_apply_mask(n: int, n_chan: int, is_delayed: bool) -> xr.Dataset:
+    """
+    Constructs a mock ``source_ds`` Dataset input for the
+    ``apply_mask`` function.
+
+    Parameters
+    ----------
+    n: int
+        The number of rows (``x``) and columns (``y``) of
+        each channel matrix
+    n_chan: int
+        Determines the size of the ``channel`` coordinate
+    is_delayed: bool
+        If True, the returned Dataset variables ``var1`` and ``var2`` will be
+        a Dask arrays, else they will be in-memory arrays
+
+    Returns
+    -------
+    xr.Dataset
+        A Dataset with coordinates ``channel, x, y`` and
+        variables ``var1, var2`` (with the created coordinates). The
+        variables will contain square matrices of ones for each ``channel``.
+    """
+
+    # construct channel values
+    chan_vals = ['chan' + str(i) for i in range(1, n_chan + 1)]
+
+    # construct mock variable data for each channel
+    if is_delayed:
+        mock_var_data = [dask.array.ones((n, n)) for i in range(n_chan)]
+    else:
+        mock_var_data = [np.ones((n, n)) for i in range(n_chan)]
+
+    # create mock var1 and var2 DataArrays
+    mock_var1_da = xr.DataArray(data=np.stack(mock_var_data),
+                                coords={"channel": ("channel", chan_vals, {"long_name": "channel name"}),
+                                        "x": np.arange(n), "y": np.arange(n)},
+                                attrs={"long_name": "variable 1"})
+    mock_var2_da = xr.DataArray(data=np.stack(mock_var_data),
+                                coords={"channel": ("channel", chan_vals, {"long_name": "channel name"}),
+                                        "x": np.arange(n),
+                                        "y": np.arange(n)},
+                                attrs={"long_name": "variable 2"})
+
+    # create mock Dataset
+    mock_ds = xr.Dataset(data_vars={"var1": mock_var1_da, "var2": mock_var2_da})
+
+    return mock_ds
+
+
+def create_input_mask(
+    mask: Union[np.ndarray, List[np.ndarray]],
+    mask_file: Optional[Union[str, List[str]]],
+    mask_coords: Union[xr.core.coordinates.DataArrayCoordinates, dict],
+    n_chan: int
+):
+    """
+    A helper function that correctly constructs the mask input, so it can be
+    used for ``apply_mask`` related tests.
+
+    Parameters
+    ----------
+    mask: np.ndarray or list of np.ndarray
+        The mask(s) that should be applied to ``var_name``
+    mask_file: str or list of str, optional
+        If provided, the ``mask`` input will be written to a temporary directory
+        with file name ``mask_file``. This will then be used in ``apply_mask``.
+    mask_coords: xr.core.coordinates.DataArrayCoordinates or dict
+        The DataArray coordinates that should be used for each mask DataArray created
+    n_chan: int
+        Determines the size of the ``channel`` coordinate
+    """
+
+    # initialize temp_dir
+    temp_dir = None
+
+    # make input numpy array masks into DataArrays
+    if isinstance(mask, list):
+
+        # initialize final mask
+        mask_out = []
+
+        # create temporary directory if mask_file is provided
+        if any([isinstance(elem, str) for elem in mask_file]):
+            # create temporary directory for mask_file
+            temp_dir = tempfile.TemporaryDirectory()
+
+        for mask_ind in range(len(mask)):
+
+            # form DataArray from given mask data
+            mask_da = xr.DataArray(data=[mask[mask_ind] for i in range(n_chan)],
+                                   coords=mask_coords, name='mask_' + str(mask_ind))
+
+            if mask_file[mask_ind] is None:
+
+                # set mask value to the DataArray given
+                mask_out.append(mask_da)
+            else:
+
+                # write DataArray to temporary directory
+                zarr_path = os.path.join(temp_dir.name, mask_file[mask_ind])
+                mask_da.to_dataset().to_zarr(zarr_path)
+
+                if isinstance(mask_file[mask_ind], pathlib.Path):
+                    # make zarr_path into a Path object
+                    zarr_path = pathlib.Path(zarr_path)
+
+                # set mask value to created path
+                mask_out.append(zarr_path)
+
+    elif isinstance(mask, np.ndarray):
+
+        # form DataArray from given mask data
+        mask_da = xr.DataArray(data=[mask for i in range(n_chan)],
+                               coords=mask_coords, name='mask_0')
+
+        if mask_file is None:
+
+            # set mask to the DataArray formed
+            mask_out = mask_da
+        else:
+
+            # create temporary directory for mask_file
+            temp_dir = tempfile.TemporaryDirectory()
+
+            # write DataArray to temporary directory
+            zarr_path = os.path.join(temp_dir.name, mask_file)
+            mask_da.to_dataset().to_zarr(zarr_path)
+
+            if isinstance(mask_file, pathlib.Path):
+                # make zarr_path into a Path object
+                zarr_path = pathlib.Path(zarr_path)
+
+            # set mask index to path
+            mask_out = zarr_path
+
+    return mask_out, temp_dir
+
+
+@pytest.mark.parametrize(
+    ("n", "n_chan_freq", "add_chan", "add_freq_nom", "freqAB", "chanAB"),
+    [
+        (5, 3, True, True, [1.0, 3.0], None),
+        (5, 3, True, True, None, ['chan1', 'chan3']),
+        pytest.param(5, 3, False, True, [1.0, 3.0], None,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason="This should fail because the Dataset "
+                                                    "will not have the channel coordinate.")),
+        pytest.param(5, 3, True, False, [1.0, 3.0], None,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason="This should fail because the Dataset "
+                                                    "will not have the frequency_nominal variable.")),
+        pytest.param(5, 3, True, True, [1.0, 4.0], None,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason="This should fail because not all selected frequencies"
+                                                    "are in the frequency_nominal variable.")),
+        pytest.param(5, 3, True, True, None, ['chan1', 'chan4'],
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason="This should fail because not all selected channels"
+                                                    "are in the channel coordinate.")),
+    ],
+    ids=["dataset_input_freqAB_provided", "dataset_input_chanAB_provided", "dataset_no_channel",
+         "dataset_no_frequency_nominal", "dataset_missing_freqAB_in_freq_nom",
+         "dataset_missing_chanAB_in_channel"]
+)
+def test_check_source_Sv_freq_diff(n: int, n_chan_freq: int, add_chan: bool, add_freq_nom: bool,
+                                   freqAB: List[float],
+                                   chanAB: List[str]):
+    """
+    Test the inputs ``source_Sv, freqAB, chanAB`` for ``_check_source_Sv_freq_diff``.
+
+    Parameters
+    ----------
+    n: int
+        The number of rows (``ping_time``) and columns (``range_sample``) of
+        each channel matrix
+    n_chan_freq: int
+        Determines the size of the ``channel`` coordinate and ``frequency_nominal``
+        variable. To create mock data with known outcomes for ``frequency_differencing``,
+        this value must be greater than or equal to 3.
+    add_chan: bool
+        If True the ``channel`` dimension will be named "channel", else it will
+        be named "data_coord"
+    add_freq_nom: bool
+        If True the ``frequency_nominal`` variable will be added to the Dataset
+    freqAB: list of float, optional
+        The pair of nominal frequencies to be used for frequency-differencing, where
+        the first element corresponds to ``freqA`` and the second element corresponds
+        to ``freqB``
+    chanAB: list of float, optional
+        The pair of channels that will be used to select the nominal frequencies to be
+        used for frequency-differencing, where the first element corresponds to ``freqA``
+        and the second element corresponds to ``freqB``
+    """
+
+    source_Sv = get_mock_freq_diff_data(n, n_chan_freq, add_chan, add_freq_nom)
+
+    _check_source_Sv_freq_diff(source_Sv, freqAB=freqAB, chanAB=chanAB)
+
+
+@pytest.mark.parametrize(
+    ("n", "n_chan_freq", "freqAB", "chanAB", "diff", "operator", "mask_truth"),
+    [
+        (5, 4, [1.0, 3.0], None, 1.0, "==", np.identity(5)),
+        (5, 4, None, ['chan1', 'chan3'], 1.0, "==", np.identity(5)),
+        (5, 4, [3.0, 1.0], None, 1.0, "==", np.zeros((5, 5))),
+        (5, 4, None, ['chan3', 'chan1'], 1.0, "==", np.zeros((5, 5))),
+        (5, 4, [1.0, 3.0], None, 1.0, ">=", np.identity(5)),
+        (5, 4, None, ['chan1', 'chan3'], 1.0, ">=", np.identity(5)),
+        (5, 4, [1.0, 3.0], None, 1.0, ">", np.zeros((5, 5))),
+        (5, 4, None, ['chan1', 'chan3'], 1.0, ">", np.zeros((5, 5))),
+        (5, 4, [1.0, 3.0], None, 1.0, "<=", np.ones((5, 5))),
+        (5, 4, None, ['chan1', 'chan3'], 1.0, "<=", np.ones((5, 5))),
+        (5, 4, [1.0, 3.0], None, 1.0, "<", np.ones((5, 5)) - np.identity(5)),
+        (5, 4, None, ['chan1', 'chan3'], 1.0, "<", np.ones((5, 5)) - np.identity(5)),
+    ],
+    ids=["freqAB_sel_op_equals", "chanAB_sel_op_equals", "reverse_freqAB_sel_op_equals",
+         "reverse_chanAB_sel_op_equals", "freqAB_sel_op_ge", "chanAB_sel_op_ge",
+         "freqAB_sel_op_greater", "chanAB_sel_op_greater", "freqAB_sel_op_le",
+         "chanAB_sel_op_le", "freqAB_sel_op_less", "chanAB_sel_op_less"]
+)
+def test_frequency_differencing(n: int, n_chan_freq: int,
+                                freqAB: List[float], chanAB: List[str],
+                                diff: Union[float, int], operator: str,
+                                mask_truth: np.ndarray):
+    """
+    Tests that the output values of ``frequency_differencing`` are what we
+    expect, the output is a DataArray, and that the name of the DataArray is correct.
+
+    Parameters
+    ----------
+    n: int
+        The number of rows (``ping_time``) and columns (``range_sample``) of
+        each channel matrix
+    n_chan_freq: int
+        Determines the size of the ``channel`` coordinate and ``frequency_nominal``
+        variable. To create mock data with known outcomes for ``frequency_differencing``,
+        this value must be greater than or equal to 3.
+    freqAB: list of float, optional
+        The pair of nominal frequencies to be used for frequency-differencing, where
+        the first element corresponds to ``freqA`` and the second element corresponds
+        to ``freqB``
+    chanAB: list of float, optional
+        The pair of channels that will be used to select the nominal frequencies to be
+        used for frequency-differencing, where the first element corresponds to ``freqA``
+        and the second element corresponds to ``freqB``
+    diff: float or int
+        The threshold of Sv difference between frequencies
+    operator: {">", "<", "<=", ">=", "=="}
+        The operator for the frequency-differencing
+    mask_truth: np.ndarray
+        The truth value for the output mask, provided the given inputs
+    """
+
+    # obtain mock Sv Dataset
+    mock_Sv_ds = get_mock_freq_diff_data(n, n_chan_freq, add_chan=True, add_freq_nom=True)
+
+    # obtain the frequency-difference mask for mock_Sv_ds
+    out = ep.mask.frequency_differencing(source_Sv=mock_Sv_ds, storage_options={}, freqAB=freqAB,
+                                         chanAB=chanAB,
+                                         operator=operator, diff=diff)
+
+    # ensure that the output values are correct
+    assert np.all(out == mask_truth)
+
+    # ensure that the output is a DataArray
+    assert isinstance(out, xr.DataArray)
+
+    # test that the output DataArray is correctly names
+    assert out.name == "mask"
+
+
+@pytest.mark.parametrize(
+    ("n", "n_chan", "mask_np", "mask_file", "storage_options_mask"),
+    [
+        (5, 1, np.identity(5), None, {}),
+        (5, 1, [np.identity(5), np.identity(5)], [None, None], {}),
+        (5, 1, [np.identity(5), np.identity(5)], [None, None], [{}, {}]),
+        (5, 1, np.identity(5), "path/to/mask.zarr", {}),
+        (5, 1, [np.identity(5), np.identity(5)], ["path/to/mask0.zarr", "path/to/mask1.zarr"], {}),
+        (5, 1, np.identity(5), pathlib.Path("path/to/mask.zarr"), {}),
+        (5, 1, [np.identity(5), np.identity(5), np.identity(5)],
+         [None, "path/to/mask0.zarr", pathlib.Path("path/to/mask1.zarr")], {})
+    ],
+    ids=["mask_da", "mask_list_da_single_storage", "mask_list_da_list_storage", "mask_str_path",
+         "mask_list_str_path", "mask_pathlib", "mask_mixed_da_str_pathlib"]
+)
+def test_validate_and_collect_mask_input(
+        n: int,
+        n_chan: int,
+        mask_np: Union[np.ndarray, List[np.ndarray]],
+        mask_file: Optional[Union[str, pathlib.Path, List[Union[str, pathlib.Path]]]],
+        storage_options_mask: Union[dict, List[dict]]):
+    """
+    Tests the allowable types for the mask input and corresponding storage options.
+
+    Parameters
+    ----------
+    n: int
+        The number of rows (``x``) and columns (``y``) of
+        each channel matrix
+    n_chan: int
+        Determines the size of the ``channel`` coordinate
+    mask_np: np.ndarray or list of np.ndarray
+        The mask(s) that should be applied to ``var_name``
+    mask_file: str or list of str, optional
+        If provided, the ``mask`` input will be written to a temporary directory
+        with file name ``mask_file``. This will then be used in ``apply_mask``.
+    storage_options_mask: dict or list of dict, default={}
+        Any additional parameters for the storage backend, corresponding to the
+        path provided for ``mask``
+
+    Notes
+    -----
+    The input for ``storage_options_mask`` will only contain the value `{}` or a list of
+    empty dictionaries as other options are already tested in
+    ``test_utils_io.py::test_validate_output_path`` and are therefore not included here.
+    """
+
+    # construct channel values
+    chan_vals = ['chan' + str(i) for i in range(1, n_chan + 1)]
+
+    # create coordinates that will be used by all DataArrays created
+    coords = {"channel": ("channel", chan_vals, {"long_name": "channel name"}),
+              "x": np.arange(n), "y": np.arange(n)}
+
+    # create input mask and obtain temporary directory, if it was created
+    mask, _ = create_input_mask(mask_np, mask_file, coords, n_chan)
+
+    mask_out = _validate_and_collect_mask_input(mask=mask, storage_options_mask=storage_options_mask)
+
+    if isinstance(mask_out, list):
+        for ind, da in enumerate(mask_out):
+
+            # create known solution for mask
+            mask_da = xr.DataArray(data=[mask_np[ind] for i in range(n_chan)],
+                                   coords=coords, name='mask_' + str(ind))
+
+            assert da.identical(mask_da)
+    else:
+
+        # create known solution for mask
+        mask_da = xr.DataArray(data=[mask_np for i in range(n_chan)],
+                               coords=coords, name='mask_0')
+        assert mask_out.identical(mask_da)
+
+
+@pytest.mark.parametrize(
+    ("n", "n_chan", "var_name", "fill_value"),
+    [
+        pytest.param(4, 2, 2.0, np.nan,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason="This should fail because the var_name is not a string.")),
+        pytest.param(4, 2, "var3", np.nan,
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason="This should fail because mock_ds will "
+                                                    "not have var_name=var3 in it.")),
+        pytest.param(4, 2, "var1", "1.0",
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason="This should fail because fill_value is an incorrect type.")),
+        (4, 2, "var1", 1),
+        (4, 2, "var1", 1.0),
+        (2, 1, "var1", np.identity(2)[None, :]),
+        (2, 1, "var1", xr.DataArray(data=np.array([[[1.0, 0], [0, 1]]]),
+                                    coords={"channel": ["chan1"], "ping_time": [0, 1], "range_sample": [0, 1]})
+         ),
+        pytest.param(4, 2, "var1", np.identity(2),
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason="This should fail because fill_value is not the right shape.")),
+        pytest.param(4, 2, "var1",
+                     xr.DataArray(data=np.array([[1.0, 0], [0, 1]]),
+                                  coords={"ping_time": [0, 1], "range_sample": [0, 1]}),
+                     marks=pytest.mark.xfail(strict=True,
+                                             reason="This should fail because fill_value is not the right shape.")),
+    ],
+    ids=["wrong_var_name_type", "no_var_name_ds", "wrong_fill_value_type", "fill_value_int",
+         "fill_value_float", "fill_value_np_array", "fill_value_DataArray",
+         "fill_value_np_array_wrong_shape", "fill_value_DataArray_wrong_shape"]
+)
+def test_check_var_name_fill_value(n: int, n_chan: int, var_name: str,
+                                   fill_value: Union[int, float, np.ndarray, xr.DataArray]):
+    """
+    Ensures that the function ``_check_var_name_fill_value`` is behaving as expected.
+
+    Parameters
+    ----------
+    n: int
+        The number of rows (``x``) and columns (``y``) of
+        each channel matrix
+    n_chan: int
+        Determines the size of the ``channel`` coordinate
+    var_name: {"var1", "var2"}
+        The variable name in the mock Dataset to apply the mask to
+    fill_value: int, float, np.ndarray, or xr.DataArray
+        Value(s) at masked indices
+    """
+
+    # obtain mock Dataset containing var_name
+    mock_ds = get_mock_source_ds_apply_mask(n, n_chan, is_delayed=False)
+
+    _check_var_name_fill_value(source_ds=mock_ds, var_name=var_name, fill_value=fill_value)
+
+
+@pytest.mark.parametrize(
+    ("n", "n_chan", "var_name", "mask", "mask_file", "fill_value", "is_delayed", "var_masked_truth"),
+    [
+        (2, 1, "var1", np.identity(2), None, np.nan, False, np.array([[1, np.nan], [np.nan, 1]])),
+        (2, 1, "var1", np.identity(2), None, 2.0, False, np.array([[1, 2.0], [2.0, 1]])),
+        (2, 1, "var1", np.identity(2), None, np.array([[[np.nan, np.nan], [np.nan, np.nan]]]),
+         False, np.array([[1, np.nan], [np.nan, 1]])),
+        (2, 1, "var1", np.identity(2), None, xr.DataArray(data=np.array([[[np.nan, np.nan], [np.nan, np.nan]]]),
+                                                          coords={"channel": ["chan1"],
+                                                                  "ping_time": [0, 1],
+                                                                  "range_sample": [0, 1]}),
+         False, np.array([[1, np.nan], [np.nan, 1]])),
+        (2, 1, "var1", [np.identity(2), np.array([[0, 1], [0, 1]])], [None, None], 2.0,
+         False, np.array([[2.0, 2.0], [2.0, 1]])),
+        (2, 1, "var1", np.identity(2), None, 2.0, True, np.array([[1, 2.0], [2.0, 1]])),
+        (2, 1, "var1", np.identity(2), "test.zarr", 2.0, True, np.array([[1, 2.0], [2.0, 1]])),
+        (2, 1, "var1", [np.identity(2), np.array([[0, 1], [0, 1]])], ["test0.zarr", "test1.zarr"], 2.0,
+         False, np.array([[2.0, 2.0], [2.0, 1]])),
+        (2, 1, "var1", [np.identity(2), np.array([[0, 1], [0, 1]])], ["test0.zarr", None], 2.0,
+         False, np.array([[2.0, 2.0], [2.0, 1]])),
+    ],
+    ids=["single_mask_default_fill", "single_mask_float_fill", "single_mask_np_array_fill",
+         "single_mask_DataArray_fill", "list_mask_all_np", "single_mask_ds_delayed",
+         "single_mask_as_path", "list_mask_all_path", "list_mask_some_path"]
+)
+def test_apply_mask(n: int, n_chan: int, var_name: str,
+                    mask: Union[np.ndarray, List[np.ndarray]],
+                    mask_file: Optional[Union[str, List[str]]],
+                    fill_value: Union[int, float, np.ndarray, xr.DataArray],
+                    is_delayed: bool, var_masked_truth: np.ndarray):
+    """
+    Ensures that ``apply_mask`` functions correctly.
+
+    Parameters
+    ----------
+    n: int
+        The number of rows (``x``) and columns (``y``) of
+        each channel matrix
+    n_chan: int
+        Determines the size of the ``channel`` coordinate
+    var_name: {"var1", "var2"}
+        The variable name in the mock Dataset to apply the mask to
+    mask: np.ndarray or list of np.ndarray
+        The mask(s) that should be applied to ``var_name``
+    mask_file: str or list of str, optional
+        If provided, the ``mask`` input will be written to a temporary directory
+        with file name ``mask_file``. This will then be used in ``apply_mask``.
+    fill_value: int, float, np.ndarray, or xr.DataArray
+        Value(s) at masked indices
+    var_masked_truth: np.ndarray
+        The true value of ``var_name`` values after the mask has been applied
+    is_delayed: bool
+        If True, makes all variables in constructed mock Dataset Dask arrays,
+        else they will be in-memory arrays
+    """
+
+    # obtain mock Dataset containing var_name
+    mock_ds = get_mock_source_ds_apply_mask(n, n_chan, is_delayed)
+
+    # create input mask and obtain temporary directory, if it was created
+    mask, temp_dir = create_input_mask(mask, mask_file, mock_ds.coords, n_chan)
+
+    # create DataArray form of the known truth value
+    var_masked_truth = xr.DataArray(data=np.stack([var_masked_truth for i in range(n_chan)]),
+                                    coords=mock_ds[var_name].coords, attrs=mock_ds[var_name].attrs)
+    var_masked_truth.name = mock_ds[var_name].name
+
+    # apply the mask to var_name
+    masked_ds = echopype.mask.apply_mask(source_ds=mock_ds, var_name=var_name, mask=mask,
+                                         fill_value=fill_value, storage_options_ds={},
+                                         storage_options_mask={})
+
+    # check that masked_ds[var_name] == var_masked_truth
+    assert masked_ds[var_name].equals(var_masked_truth)
+
+    # check that the output Dataset has lazy elements, if the input was lazy
+    if is_delayed:
+        assert isinstance(masked_ds[var_name].data, dask.array.Array)
+
+    if temp_dir:
+        # remove the temporary directory, if it was created
+        temp_dir.cleanup()
diff --git a/echopype/tests/preprocess/test_preprocess.py b/echopype/tests/preprocess/test_preprocess.py
index 58be7cff0..4e96986b3 100644
--- a/echopype/tests/preprocess/test_preprocess.py
+++ b/echopype/tests/preprocess/test_preprocess.py
@@ -3,6 +3,11 @@
 import xarray as xr
 import echopype as ep
 import pytest
+import dask.array
+from numpy.random import default_rng
+
+from echopype.preprocess.mvbs import bin_and_mean_2d
+from typing import Tuple, Iterable, Union
 
 
 @pytest.fixture(
@@ -507,3 +512,419 @@ def test_preprocess_mvbs(test_data_samples):
             range_kwargs.pop('azfp_cal_type')
     Sv = ep.calibrate.compute_Sv(ed, **range_kwargs)
     assert ep.preprocess.compute_MVBS(Sv) is not None
+
+
+def create_bins(csum_array: np.ndarray) -> Iterable:
+    """
+    Constructs bin ranges based off of a cumulative
+    sum array.
+
+    Parameters
+    ----------
+    csum_array: np.ndarray
+        1D array representing a cumulative sum
+
+    Returns
+    -------
+    bins: list
+        A list whose elements are the lower and upper bin ranges
+    """
+
+    bins = []
+
+    # construct bins
+    for count, csum in enumerate(csum_array):
+
+        if count == 0:
+
+            bins.append([0, csum])
+
+        else:
+
+            # add 0.01 so that left bins don't overlap
+            bins.append([csum_array[count-1] + 0.01, csum])
+
+    return bins
+
+
+def create_echo_range_related_data(ping_bins: Iterable,
+                                   num_pings_in_bin: np.ndarray,
+                                   er_range: list, er_bins: Iterable,
+                                   final_num_er_bins: int,
+                                   create_dask: bool,
+                                   rng: np.random.Generator,
+                                   ping_bin_nan_ind: int) -> Tuple[list, list, list]:
+    """
+    Creates ``echo_range`` values and associated bin information.
+
+    Parameters
+    ----------
+    ping_bins: list
+        A list whose elements are the lower and upper ping time bin ranges
+    num_pings_in_bin: np.ndarray
+        Specifies the number of pings in each ping time bin
+    er_range: list
+        A list whose first element is the lowest and second element is
+        the highest possible number of echo range values in a given bin
+    er_bins:  list
+        A list whose elements are the lower and upper echo range bin ranges
+    final_num_er_bins: int
+        The total number of echo range bins
+    create_dask: bool
+        If True ``final_arrays`` values will be
+        dask arrays, else they will be numpy arrays
+    rng: np.random.Generator
+        The generator for random values
+    ping_bin_nan_ind: int
+        The ping bin index to fill with NaNs
+
+    Returns
+    -------
+    all_er_bin_nums: list of np.ndarray
+        A list whose elements are the number of values in each echo_range
+        bin, for each ping bin
+    ping_times_in_bin: list of np.ndarray
+        A list whose elements are the ping_time values for each corresponding bin
+    final_arrays: list of np.ndarray or dask.array.Array
+        A list whose elements are the echo_range values for a given ping and
+        echo range bin block
+    """
+
+    final_arrays = []
+    all_er_bin_nums = []
+    ping_times_in_bin = []
+
+    # build echo_range array
+    for ping_ind, ping_bin in enumerate(ping_bins):
+
+        # create the ping times associated with each ping bin
+        ping_times_in_bin.append(rng.uniform(ping_bin[0], ping_bin[1], (num_pings_in_bin[ping_ind],)))
+
+        # randomly determine the number of values in each echo_range bin
+        num_er_in_bin = rng.integers(low=er_range[0], high=er_range[1], size=final_num_er_bins)
+
+        # store the number of values in each echo_range bin
+        all_er_bin_nums.append(num_er_in_bin)
+
+        er_row_block = []
+        for count, bin_val in enumerate(er_bins):
+
+            # create a block of echo_range values
+            if create_dask:
+                a = dask.array.random.uniform(bin_val[0], bin_val[1], (num_pings_in_bin[ping_ind],
+                                                                       num_er_in_bin[count]))
+            else:
+                a = rng.uniform(bin_val[0], bin_val[1], (num_pings_in_bin[ping_ind],
+                                                         num_er_in_bin[count]))
+
+            # store the block of echo_range values
+            er_row_block.append(a)
+
+            # set all echo_range values at ping index to NaN
+            if ping_ind == ping_bin_nan_ind:
+                a[:, :] = np.nan
+
+        # collect and construct echo_range row block
+        final_arrays.append(np.concatenate(er_row_block, axis=1))
+
+    return all_er_bin_nums, ping_times_in_bin, final_arrays
+
+
+def construct_2d_echo_range_array(final_arrays: Iterable[np.ndarray],
+                                  ping_csum: np.ndarray,
+                                  create_dask: bool) -> Tuple[Union[np.ndarray, dask.array.Array], int]:
+    """
+    Creates the final 2D ``echo_range`` array with appropriate padding.
+
+    Parameters
+    ----------
+    final_arrays: list of np.ndarray
+        A list whose elements are the echo_range values for a given ping and
+        echo range bin block
+    ping_csum: np.ndarray
+        1D array representing the cumulative sum for the number of ping times
+        in each ping bin
+    create_dask: bool
+        If True ``final_er`` will be a dask array, else it will be a numpy array
+
+    Returns
+    -------
+    final_er: np.ndarray or dask.array.Array
+        The final 2D ``echo_range`` array
+    max_num_er_elem: int
+        The maximum number of ``echo_range`` elements amongst all times
+    """
+
+    # get maximum number of echo_range elements amongst all times
+    max_num_er_elem = max([arr.shape[1] for arr in final_arrays])
+
+    # total number of ping times
+    tot_num_times = ping_csum[-1]
+
+    # pad echo_range dimension with nans and create final echo_range
+    if create_dask:
+        final_er = dask.array.ones(shape=(tot_num_times, max_num_er_elem)) * np.nan
+    else:
+        final_er = np.empty((tot_num_times, max_num_er_elem))
+        final_er[:] = np.nan
+
+    for count, arr in enumerate(final_arrays):
+
+        if count == 0:
+            final_er[0:ping_csum[count], 0:arr.shape[1]] = arr
+        else:
+            final_er[ping_csum[count - 1]:ping_csum[count], 0:arr.shape[1]] = arr
+
+    return final_er, max_num_er_elem
+
+
+def construct_2d_sv_array(max_num_er_elem: int, ping_csum: np.ndarray,
+                          all_er_bin_nums: Iterable[np.ndarray],
+                          num_pings_in_bin: np.ndarray,
+                          create_dask: bool,
+                          ping_bin_nan_ind: int) -> Tuple[Union[np.ndarray, dask.array.Array],
+                                                          np.ndarray]:
+    """
+    Creates the final 2D Sv array with appropriate padding.
+
+    Parameters
+    ----------
+    max_num_er_elem: int
+        The maximum number of ``echo_range`` elements amongst all times
+    ping_csum: np.ndarray
+        1D array representing the cumulative sum for the number of ping times
+        in each ping bin
+    all_er_bin_nums: list of np.ndarray
+        A list whose elements are the number of values in each echo_range
+        bin, for each ping bin
+    num_pings_in_bin: np.ndarray
+        Specifies the number of pings in each ping time bin
+    create_dask: bool
+        If True ``final_sv`` will be a dask array, else it will be a numpy array
+    ping_bin_nan_ind: int
+        The ping bin index to fill with NaNs
+
+    Returns
+    -------
+    final_sv: np.ndarray or dask.array.Array
+        The final 2D Sv array
+    final_MVBS: np.ndarray
+        The final 2D known MVBS array
+    """
+
+    # total number of ping times
+    tot_num_times = ping_csum[-1]
+
+    # pad echo_range dimension with nans and create final sv
+    if create_dask:
+        final_sv = dask.array.ones(shape=(tot_num_times, max_num_er_elem)) * np.nan
+    else:
+        final_sv = np.empty((tot_num_times, max_num_er_elem))
+        final_sv[:] = np.nan
+
+    final_means = []
+    for count, arr in enumerate(all_er_bin_nums):
+
+        # create sv row values using natural numbers
+        sv_row_list = [np.arange(1, num_elem + 1, 1, dtype=np.float64) for num_elem in arr]
+
+        # create final sv row
+        sv_row = np.concatenate(sv_row_list)
+
+        # get final mean which is n+1/2 (since we are using natural numbers)
+        ping_mean = [(len(elem) + 1) / 2.0 for elem in sv_row_list]
+
+        # create sv row block
+        sv_row_block = np.tile(sv_row, (num_pings_in_bin[count], 1))
+
+        if count == ping_bin_nan_ind:
+
+            # fill values with NaNs
+            ping_mean = [np.nan]*len(sv_row_list)
+            sv_row_block[:, :] = np.nan
+
+        # store means for ping
+        final_means.append(ping_mean)
+
+        if count == 0:
+            final_sv[0:ping_csum[count], 0:sv_row_block.shape[1]] = sv_row_block
+        else:
+            final_sv[ping_csum[count - 1]:ping_csum[count], 0:sv_row_block.shape[1]] = sv_row_block
+
+    # create final sv MVBS
+    final_MVBS = np.vstack(final_means)
+
+    return final_sv, final_MVBS
+
+
+def create_known_mean_data(final_num_ping_bins: int,
+                           final_num_er_bins: int,
+                           ping_range: list,
+                           er_range: list, create_dask: bool,
+                           rng: np.random.Generator) -> Tuple[np.ndarray, np.ndarray, Iterable,
+                                                              Iterable, np.ndarray, np.ndarray]:
+    """
+    Orchestrates the creation of ``echo_range``, ``ping_time``, and ``Sv`` arrays
+    where the MVBS is known.
+
+    Parameters
+    ----------
+    final_num_ping_bins: int
+        The total number of ping time bins
+    final_num_er_bins: int
+        The total number of echo range bins
+    ping_range: list
+        A list whose first element is the lowest and second element is
+        the highest possible number of ping time values in a given bin
+    er_range: list
+        A list whose first element is the lowest and second element is
+        the highest possible number of echo range values in a given bin
+    create_dask: bool
+        If True the ``Sv`` and ``echo_range`` values produced will be
+        dask arrays, else they will be numpy arrays.
+    rng: np.random.Generator
+        generator for random integers
+
+    Returns
+    -------
+    final_MVBS: np.ndarray
+        The final 2D known MVBS array
+    final_sv: np.ndarray
+        The final 2D Sv array
+    ping_bins: Iterable
+        A list whose elements are the lower and upper ping time bin ranges
+    er_bins: Iterable
+        A list whose elements are the lower and upper echo range bin ranges
+    final_er: np.ndarray
+        The final 2D ``echo_range`` array
+    final_ping_time: np.ndarray
+        The final 1D ``ping_time`` array
+    """
+
+    # randomly generate the number of pings in each ping bin
+    num_pings_in_bin = rng.integers(low=ping_range[0], high=ping_range[1], size=final_num_ping_bins)
+
+    # create bins for ping_time dimension
+    ping_csum = np.cumsum(num_pings_in_bin)
+    ping_bins = create_bins(ping_csum)
+
+    # create bins for echo_range dimension
+    num_er_in_bin = rng.integers(low=er_range[0], high=er_range[1], size=final_num_er_bins)
+    er_csum = np.cumsum(num_er_in_bin)
+    er_bins = create_bins(er_csum)
+
+    # randomly select one ping bin to fill with NaNs
+    ping_bin_nan_ind = rng.choice(len(ping_bins))
+
+    # create the echo_range data and associated bin information
+    all_er_bin_nums, ping_times_in_bin, final_er_arrays = create_echo_range_related_data(ping_bins, num_pings_in_bin,
+                                                                                         er_range, er_bins,
+                                                                                         final_num_er_bins,
+                                                                                         create_dask,
+                                                                                         rng,
+                                                                                         ping_bin_nan_ind)
+
+    # create the final echo_range array using created data and padding
+    final_er, max_num_er_elem = construct_2d_echo_range_array(final_er_arrays, ping_csum, create_dask)
+
+    # get final ping_time dimension
+    final_ping_time = np.concatenate(ping_times_in_bin).astype('datetime64[ns]')
+
+    # create the final sv array
+    final_sv, final_MVBS = construct_2d_sv_array(max_num_er_elem, ping_csum,
+                                                 all_er_bin_nums, num_pings_in_bin,
+                                                 create_dask, ping_bin_nan_ind)
+
+    return final_MVBS, final_sv, ping_bins, er_bins, final_er, final_ping_time
+
+
+@pytest.fixture(
+    params=[
+        {
+            "create_dask": True,
+            "final_num_ping_bins": 10,
+            "final_num_er_bins": 10,
+            "ping_range": [10, 1000],
+            "er_range": [10, 1000]
+        },
+        {
+            "create_dask": False,
+            "final_num_ping_bins": 10,
+            "final_num_er_bins": 10,
+            "ping_range": [10, 1000],
+            "er_range": [10, 1000]
+        },
+    ],
+    ids=[
+        "delayed_data",
+        "in_memory_data"
+    ],
+)
+def bin_and_mean_2d_params(request):
+    """
+    Obtains all necessary parameters for ``test_bin_and_mean_2d``.
+    """
+
+    return list(request.param.values())
+
+
+def test_bin_and_mean_2d(bin_and_mean_2d_params) -> None:
+    """
+    Tests the function ``bin_and_mean_2d``, which is the core
+    method for ``compute_MVBS``. This is done by creating mock
+    data (which can have varying number of ``echo_range`` values
+    for each ``ping_time``) with known means.
+
+    Parameters
+    ----------
+    create_dask: bool
+        If True the ``Sv`` and ``echo_range`` values produced will be
+        dask arrays, else they will be numpy arrays.
+    final_num_ping_bins: int
+        The total number of ping time bins
+    final_num_er_bins: int
+        The total number of echo range bins
+    ping_range: list
+        A list whose first element is the lowest and second element is
+        the highest possible number of ping time values in a given bin
+    er_range: list
+        A list whose first element is the lowest and second element is
+        the highest possible number of echo range values in a given bin
+    """
+
+    # get all parameters needed to create the mock data
+    create_dask, final_num_ping_bins, final_num_er_bins, ping_range, er_range = bin_and_mean_2d_params
+
+    # randomly generate a seed
+    seed = np.random.randint(low=10, high=100000, size=1)[0]
+
+    print(f"seed used to generate mock data: {seed}")
+
+    # establish generator for random integers
+    rng = default_rng(seed=seed)
+
+    # seed dask random generator
+    if create_dask:
+        dask.array.random.seed(seed=seed)
+
+    # create echo_range, ping_time, and Sv arrays where the MVBS is known
+    known_MVBS, final_sv, ping_bins, er_bins, final_er, final_ping_time = create_known_mean_data(final_num_ping_bins,
+                                                                                                 final_num_er_bins,
+                                                                                                 ping_range, er_range,
+                                                                                                 create_dask,
+                                                                                                 rng)
+
+    # put the created ping bins into a form that works with bin_and_mean_2d
+    digitize_ping_bin = np.array([*ping_bins[0]] + [bin_val[1] for bin_val in ping_bins[1:-1]])
+    digitize_ping_bin = digitize_ping_bin.astype('datetime64[ns]')
+
+    # put the created echo range bins into a form that works with bin_and_mean_2d
+    digitize_er_bin = np.array([*er_bins[0]] + [bin_val[1] for bin_val in er_bins[1:]])
+
+    # calculate MVBS for mock data set
+    calc_MVBS = bin_and_mean_2d(arr=final_sv, bins_time=digitize_ping_bin,
+                                bins_er=digitize_er_bin, times=final_ping_time,
+                                echo_range=final_er, comprehensive_er_check=True)
+
+    # compare known MVBS solution against its calculated counterpart
+    assert np.allclose(calc_MVBS, known_MVBS, atol=1e-10, rtol=1e-10, equal_nan=True)
diff --git a/echopype/tests/test_core.py b/echopype/tests/test_core.py
index dd3161765..a9e693d26 100644
--- a/echopype/tests/test_core.py
+++ b/echopype/tests/test_core.py
@@ -1,10 +1,13 @@
 from typing import TYPE_CHECKING
+import tempfile
+from pathlib import Path
 
 import pytest
 
 if TYPE_CHECKING:
-    from ..core import SonarModelsHint
-from ..core import SONAR_MODELS
+    from echopype.core import SonarModelsHint
+from echopype.core import SONAR_MODELS
+import echopype.core
 
 
 @pytest.mark.parametrize(
diff --git a/echopype/tests/utils/test_coding.py b/echopype/tests/utils/test_coding.py
new file mode 100644
index 000000000..52f9e4aeb
--- /dev/null
+++ b/echopype/tests/utils/test_coding.py
@@ -0,0 +1,33 @@
+import pytest
+import numpy as np
+import xarray as xr
+import math
+import dask
+
+from echopype.utils.coding import _get_auto_chunk
+
+@pytest.mark.parametrize(
+    "chunk",
+    ["auto", "5MB", "10MB", "30MB", "70MB", "100MB", "default"],
+)
+def test__get_auto_chunk(chunk):
+    random_data = 15 + 8 * np.random.randn(10, 1000, 1000)
+
+    da = xr.DataArray(
+        data=random_data,
+        dims=["x", "y", "z"]
+    )
+    
+    if chunk == "auto":
+        dask_data = da.chunk('auto').data
+    elif chunk == "default":
+        dask_data = da.chunk(_get_auto_chunk(da)).data
+    else:
+        dask_data = da.chunk(_get_auto_chunk(da, chunk)).data
+    
+    chunk_byte_size = math.prod(dask_data.chunksize + (dask_data.itemsize,))
+    
+    if chunk in ["auto", "100MB", "default"]:
+        assert chunk_byte_size == dask_data.nbytes, "Default chunk is not equal to data array size!"
+    else:
+        assert chunk_byte_size <= dask.utils.parse_bytes(chunk), "Calculated chunk exceeded max chunk!"
diff --git a/echopype/tests/utils/test_source_filenames.py b/echopype/tests/utils/test_source_filenames.py
new file mode 100644
index 000000000..d0a935b71
--- /dev/null
+++ b/echopype/tests/utils/test_source_filenames.py
@@ -0,0 +1,33 @@
+from pathlib import Path
+
+import numpy as np
+
+from echopype.utils.prov import _sanitize_source_files
+
+
+def test_scalars():
+    """One or more scalar values"""
+    path1 = "/my/path1"
+    path2 = Path("/my/path2")
+
+    # Single scalars
+    assert _sanitize_source_files(path1) == [path1]
+    assert _sanitize_source_files(path2) == [str(path2)]
+    # List of scalars
+    assert _sanitize_source_files([path1, path2]) == [path1, str(path2)]
+
+
+def test_mixed():
+    """A scalar value and a list or ndarray"""
+    path1 = "/my/path1"
+    path2 = Path("/my/path2")
+    # Mixed-type list
+    path_list1 = [path1, path2]
+    # String-type ndarray
+    path_list2 = np.array([path1, str(path2)])
+
+    # A scalar and a list
+    target_path_list = [path1, path1, str(path2)]
+    assert _sanitize_source_files([path1, path_list1]) == target_path_list
+    # A scalar and an ndarray
+    assert _sanitize_source_files([path1, path_list2]) == target_path_list
diff --git a/echopype/tests/utils/test_utils_io.py b/echopype/tests/utils/test_utils_io.py
index ab1c10077..0fa3db6ea 100644
--- a/echopype/tests/utils/test_utils_io.py
+++ b/echopype/tests/utils/test_utils_io.py
@@ -2,8 +2,19 @@
 import fsspec
 from pathlib import Path
 import pytest
+from typing import Tuple
+import tempfile
+import platform
+import xarray as xr
 
-from echopype.utils.io import sanitize_file_path, validate_output_path
+from echopype.utils.io import (
+    sanitize_file_path,
+    validate_output_path,
+    env_indep_joinpath,
+    validate_source_ds_da,
+    init_ep_dir
+)
+import echopype.utils.io
 
 
 @pytest.mark.parametrize(
@@ -11,8 +22,8 @@
     [
         ('https://example.com/test.nc', True, 'nc'),
         ('https://example.com/test.zarr', False, 'zarr'),
-        ('folder/test.nc', False, 'nc'),
-        ('folder/test.zarr', False, 'zarr'),
+        (os.path.join('folder', 'test.nc'), False, 'nc'),
+        (os.path.join('folder', 'test.zarr'), False, 'zarr'),
         (Path('https:/example.com/test.nc'), True, 'nc'),
         (Path('https:/example.com/test.zarr'), True, 'zarr'),
         (Path('folder/test.nc'), False, 'nc'),
@@ -42,17 +53,17 @@ def test_sanitize_file_path(file_path, should_fail, file_type):
     "save_path, engine",
     [
         # Netcdf tests
-        ('folder/new_test.nc', 'netcdf4'),
-        ('folder/new_test.nc', 'zarr'),
-        ('folder/path/new_test.nc', 'netcdf4'),
+        (os.path.join('folder', 'new_test.nc'), 'netcdf4'),
+        (os.path.join('folder', 'new_test.nc'), 'zarr'),
+        (os.path.join('folder', 'path', 'new_test.nc'), 'netcdf4'),
         ('folder/', 'netcdf4'),
         ('s3://ooi-raw-data/', 'netcdf4'),
         (Path('folder/'), 'netcdf4'),
         (Path('folder/new_test.nc'), 'netcdf4'),
         # Zarr tests
-        ('folder/new_test.zarr', 'zarr'),
-        ('folder/new_test.zarr', 'netcdf4'),
-        ('folder/path/new_test.zarr', 'zarr'),
+        (os.path.join('folder', 'new_test.zarr'), 'zarr'),
+        (os.path.join('folder', 'new_test.zarr'), 'netcdf4'),
+        (os.path.join('folder', 'path', 'new_test.zarr'), 'zarr'),
         ('folder/', 'zarr'),
         # Empty tests
         (None, 'netcdf4'),
@@ -66,7 +77,7 @@ def test_sanitize_file_path(file_path, should_fail, file_type):
     ],
 )
 def test_validate_output_path(save_path, engine, minio_bucket):
-    output_root_path = './echopype/test_data/dump'
+    output_root_path = os.path.join('.', 'echopype', 'test_data', 'dump')
     source_file = 'test.raw'
     if engine == 'netcdf4':
         ext = '.nc'
@@ -115,3 +126,196 @@ def test_validate_output_path(save_path, engine, minio_bucket):
         elif save_path == 's3://ooi-raw-data/new_test.nc':
             assert isinstance(e, ValueError) is True
             assert str(e) == 'Only local netcdf4 is supported.'
+
+
+def mock_windows_return(*args: Tuple[str, ...]):
+    """
+    A function to mock what ``os.path.join`` should
+    return on a Windows machine.
+
+    Parameters
+    ----------
+    args: tuple of str
+        A variable number of strings to join
+
+    Returns
+    -------
+    str
+        The input strings joined using Windows syntax
+    """
+    return "\\".join(args)
+
+
+def mock_unix_return(*args: Tuple[str, ...]):
+    """
+    A function to mock what ``os.path.join`` should
+    return on a Unix based machine.
+
+    Parameters
+    ----------
+    args: tuple of str
+        A variable number of strings to join
+
+    Returns
+    -------
+    str
+        The input strings joined using Unix syntax
+
+    Notes
+    -----
+    This function is necessary just in case the tests are being
+    run on a Windows machine.
+    """
+    return r"/".join(args)
+
+
+@pytest.mark.parametrize(
+    "save_path, is_windows, is_cloud",
+    [
+        (r"/folder", False, False),
+        (r"C:\folder", True, False),
+        (r"s3://folder", False, True),
+        (r"s3://folder", True, True),
+    ]
+)
+def test_env_indep_joinpath_mock_return(save_path: str, is_windows: bool, is_cloud: bool, monkeypatch):
+    """
+    Tests the function ``env_indep_joinpath`` using a mock return on varying OS and cloud
+    path scenarios by adding a folder and a file to the input ``save_path``.
+
+    Parameters
+    ----------
+    save_path: str
+        The save path that we want to add a folder and a file to.
+    is_windows: bool
+        If True, signifies that we are "working" on a Windows machine,
+        otherwise on a Unix based machine
+    is_cloud: bool
+        If True, signifies that ``save_path`` corresponds to a cloud path,
+        otherwise it does not
+
+    Notes
+    -----
+    This test uses a monkeypatch for ``os.path.join`` to mimic the join we expect
+    from the function. This allows us to test ``env_indep_joinpath`` on any OS.
+    """
+
+    # assign the appropriate mock return for os.path.join
+    if is_windows:
+        monkeypatch.setattr(os.path, 'join', mock_windows_return)
+    else:
+        monkeypatch.setattr(os.path, 'join', mock_unix_return)
+
+    # add folder and file to path
+    joined_path = env_indep_joinpath(save_path, "output", "data.zarr")
+
+    if is_cloud or (not is_windows):
+        assert joined_path == (save_path + r"/output/data.zarr")
+    else:
+        assert joined_path == (save_path + r"\output\data.zarr")
+
+
+@pytest.mark.parametrize(
+    "save_path, is_windows, is_cloud",
+    [
+        (r"/root/folder", False, False),
+        (r"C:\root\folder", True, False),
+        (r"s3://root/folder", False, True),
+        (r"s3://root/folder", True, True),
+    ]
+)
+def test_env_indep_joinpath_os_dependent(save_path: str, is_windows: bool, is_cloud: bool):
+    """
+    Tests the true output of the function ``env_indep_joinpath`` on varying OS and cloud path
+    scenarios by adding a folder and a file to the input ``save_path``.
+
+    Parameters
+    ----------
+    save_path: str
+        The save path that we want to add a folder and a file to.
+    is_windows: bool
+        If True, signifies that we are working on a Windows machine,
+        otherwise on a Unix based machine
+    is_cloud: bool
+        If True, signifies that ``save_path`` corresponds to a cloud path,
+        otherwise it does not
+
+    Notes
+    -----
+    This test is OS dependent and the testing of parameters will be skipped if
+    they do not correspond to the OS they are being run on.
+    """
+
+    # add folder and file to path
+    joined_path = env_indep_joinpath(save_path, "output", "data.zarr")
+
+    if is_cloud:
+        assert joined_path == r"s3://root/folder/output/data.zarr"
+
+    elif is_windows:
+
+        if platform.system() == "Windows":
+            assert joined_path == r"C:\root\folder\output\data.zarr"
+        else:
+            pytest.skip("Skipping Windows parameters because we are not on a Windows machine.")
+
+    else:
+
+        if platform.system() != "Windows":
+            assert joined_path == r"/root/folder/output/data.zarr"
+        else:
+            pytest.skip("Skipping Unix parameters because we are not on a Unix machine.")
+
+
+@pytest.mark.parametrize(
+    ("source_ds_da_input", "storage_options_input", "true_file_type"),
+    [
+        pytest.param(42, {}, None,
+                     marks=pytest.mark.xfail(
+                         strict=True,
+                         reason='This test should fail because source_ds is not of the correct type.')
+                     ),
+        pytest.param(xr.DataArray(), {}, None),
+        pytest.param({}, 42, None,
+                     marks=pytest.mark.xfail(
+                         strict=True,
+                         reason='This test should fail because storage_options is not of the correct type.')
+                     ),
+        (xr.Dataset(attrs={"test": 42}), {}, None),
+        (os.path.join('folder', 'new_test.nc'), {}, 'netcdf4'),
+        (os.path.join('folder', 'new_test.zarr'), {}, 'zarr')
+    ]
+
+)
+def test_validate_source_ds_da(source_ds_da_input, storage_options_input, true_file_type):
+    """
+    Tests that ``validate_source_ds_da`` has the appropriate outputs.
+    An exhaustive list of combinations of ``source_ds_da`` and ``storage_options``
+    are tested in ``test_validate_output_path`` and are therefore not included here.
+    """
+
+    source_ds_output, file_type_output = validate_source_ds_da(source_ds_da_input, storage_options_input)
+
+    if isinstance(source_ds_da_input, (xr.Dataset, xr.DataArray)):
+        assert source_ds_output.identical(source_ds_da_input)
+        assert file_type_output is None
+    else:
+        assert isinstance(source_ds_output, str)
+        assert file_type_output == true_file_type
+
+def test_init_ep_dir(monkeypatch):
+    temp_user_dir = tempfile.TemporaryDirectory()
+    echopype_dir = Path(temp_user_dir.name) / ".echopype"
+
+    # Create the .echopype in a temp dir instead of user space.
+    # Doing this will avoid accidentally deleting current
+    # working directory
+    monkeypatch.setattr(echopype.utils.io, "ECHOPYPE_DIR", echopype_dir)
+
+    assert echopype.utils.io.ECHOPYPE_DIR.exists() is False
+
+    init_ep_dir()
+
+    assert echopype.utils.io.ECHOPYPE_DIR.exists() is True
+
+    temp_user_dir.cleanup()
diff --git a/echopype/tests/utils/test_utils_log.py b/echopype/tests/utils/test_utils_log.py
index 998d231f4..2326d62b9 100644
--- a/echopype/tests/utils/test_utils_log.py
+++ b/echopype/tests/utils/test_utils_log.py
@@ -1,4 +1,6 @@
 import pytest
+import os.path
+import platform
 
 EXPECTED_MESSAGE = "Testing log function"
 
@@ -29,12 +31,22 @@ def test_set_log_file():
     from echopype.utils import log
     logger = log._init_logger('echopype.testing1')
     from tempfile import TemporaryDirectory
-    with TemporaryDirectory() as tmpdir:
-        tmpfile = tmpdir + "/testfile.log"
-        log._set_logfile(logger, tmpfile)
-        handlers = [h.name for h in logger.handlers]
+    tmpdir = TemporaryDirectory()
+    tmpfile = os.path.join(tmpdir.name, "testfile.log")
+    log._set_logfile(logger, tmpfile)
+    handlers = [h.name for h in logger.handlers]
+
+    assert log.LOGFILE_HANDLE_NAME in handlers
 
-        assert log.LOGFILE_HANDLE_NAME in handlers
+    # when done with temporary directory
+    # see: https://www.scivision.dev/python-tempfile-permission-error-windows/
+    try:
+        tmpdir.cleanup()
+    except Exception as e:
+        if platform.system() == "Windows":
+            pass
+        else:
+            raise e
 
 
 def test_set_verbose(verbose, capsys):
@@ -98,8 +110,18 @@ def test_verbose(id, override, logfile, capsys):
 
     if logfile is not None:
         from tempfile import TemporaryDirectory
-        with TemporaryDirectory() as tmpdir:
-            tmpfile = tmpdir + f'/{logfile}'
-            run_verbose_test(logger, override, tmpfile, capsys)
+        tmpdir = TemporaryDirectory()
+        tmpfile = os.path.join(tmpdir.name, logfile)
+        run_verbose_test(logger, override, tmpfile, capsys)
+
+        # when done with temporary directory
+        # see: https://www.scivision.dev/python-tempfile-permission-error-windows/
+        try:
+            tmpdir.cleanup()
+        except Exception as e:
+            if platform.system() == "Windows":
+                pass
+            else:
+                raise e
     else:
         run_verbose_test(logger, override, logfile, capsys)
diff --git a/echopype/tests/visualize/test_plot.py b/echopype/tests/visualize/test_plot.py
index 012fd1816..2629aa836 100644
--- a/echopype/tests/visualize/test_plot.py
+++ b/echopype/tests/visualize/test_plot.py
@@ -1,7 +1,7 @@
-
 import echopype
 import echopype.visualize
 from echopype.testing import TEST_DATA_FOLDER
+from echopype.calibrate.calibrate_ek import CalibrateEK60
 
 import pytest
 from xarray.plot.facetgrid import FacetGrid
@@ -227,12 +227,12 @@ def test_water_level_echodata(water_level, expect_warning, caplog):
         sonar_model=sonar_model, raw_file=filepath, xml_path=None
     )
 
-    range_in_meter = echodata.compute_range(
-        env_params=range_kwargs.get('env_params', {}),
-        azfp_cal_type=range_kwargs.get('azfp_cal_type', None),
-        ek_waveform_mode=range_kwargs.get('waveform_mode', 'CW'),
-        ek_encode_mode=range_kwargs.get('encode_mode', 'power'),
+    cal_obj = CalibrateEK60(
+        echodata=echodata,
+        env_params=range_kwargs.get("env_params", {}),
+        cal_params=None,
     )
+    range_in_meter = cal_obj.range_meter
 
     single_array = range_in_meter.sel(channel='GPT  18 kHz 009072058c8d 1-1 ES18-11',
                                       ping_time='2017-07-19T21:13:47.984999936').values
@@ -250,8 +250,10 @@ def test_water_level_echodata(water_level, expect_warning, caplog):
         if no_input_water_level is False:
             original_array = (
                 single_array
-                + echodata["Platform"].water_level.sel(channel='GPT  18 kHz 009072058c8d 1-1 ES18-11',
-                                                    time3='2017-07-19T21:13:47.984999936').values
+                + echodata["Platform"].water_level.sel(
+                    channel='GPT  18 kHz 009072058c8d 1-1 ES18-11',
+                    time3='2017-07-19T21:13:47.984999936'
+                ).values
             )
         else:
             original_array = single_array