Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion echopype/calibrate/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"EK60": CalibrateEK60,
"EK80": CalibrateEK80,
"AZFP": CalibrateAZFP,
"AZFP6": CalibrateAZFP,
"ES70": CalibrateEK60,
"ES80": CalibrateEK80,
"EA640": CalibrateEK80,
Expand All @@ -36,7 +37,7 @@ def _compute_cal(
if waveform_mode is None or encode_mode is None:
raise ValueError("waveform_mode and encode_mode must be specified for EK80 calibration")
check_input_args_combination(waveform_mode=waveform_mode, encode_mode=encode_mode)
elif echodata.sonar_model in ("EK60", "AZFP"):
elif echodata.sonar_model in ("EK60", "AZFP", "AZFP6"):
if waveform_mode is not None and waveform_mode != "CW":
logger.warning(
"This sonar model transmits only narrowband signals (waveform_mode='CW'). "
Expand Down
7 changes: 6 additions & 1 deletion echopype/calibrate/cal_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,13 @@ def get_cal_params_AZFP(beam: xr.DataArray, vend: xr.DataArray, user_dict: dict)
if p == "equivalent_beam_angle":
out_dict[p] = beam[p] # has only channel dim

elif p == "Sv_offset" and p in vend:
out_dict[p] = vend[
p
] # Sv_offset may not be echodata, will be calculated in calibrateAZFP

# Params from Vendor_specific group
elif p in ["EL", "DS", "TVR", "VTX0", "Sv_offset"]:
elif p in ["EL", "DS", "TVR", "VTX0"]:
out_dict[p] = vend[p] # these params only have the channel dimension

return out_dict
Expand Down
143 changes: 141 additions & 2 deletions echopype/calibrate/calibrate_azfp.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,119 @@
import numpy as np
import xarray
from scipy.interpolate import LinearNDInterpolator

from ..echodata import EchoData
from ..utils.log import _init_logger
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

logger = _init_logger(__name__)

# Common Sv_offset values for frequency > 38 kHz
SV_OFFSET_HF = {
150: 1.4,
200: 1.4,
250: 1.3,
300: 1.1,
500: 0.8,
700: 0.5,
900: 0.3,
1000: 0.3,
}
SV_OFFSET_LF = {
500: 1.1,
1000: 0.7,
}
SV_OFFSET = {
38000.0: {**SV_OFFSET_LF},
67000.0: {
**SV_OFFSET_HF,
500: 1.1,
},
120000.0: {
**SV_OFFSET_HF,
150: 1.4,
250: 1.3,
},
125000.0: {
**SV_OFFSET_HF,
150: 1.4,
250: 1.3,
},
130000.0: {
**SV_OFFSET_HF,
150: 1.4,
250: 1.3,
},
200000.0: {
**SV_OFFSET_HF,
150: 1.4,
250: 1.3,
},
417000.0: {
**SV_OFFSET_HF,
},
455000.0: {
**SV_OFFSET_HF,
250: 1.3,
},
769000.0: {
**SV_OFFSET_HF,
150: 1.4,
},
}


def _calc_azfp_Sv_offset(
frequency: float | int,
pulse_length: float | int,
) -> float:
"""

Based on code provided by @IanTBlack

Linearly interpolate an SV offset for a given pulse length and frequency.
The predefined offsets used in this function can be found in Table 3 of
the AZFP Operator's Manual.

:param pulse_length: A pulse length value (in microseconds) from an
AZFP config file for a given frequency channel.
:param frequency: The AZFP frequency channel.
:return: Either the known Sv offset if it is predefined, or an interpolated
value for less common pulse lengths.
"""

pulse_length = int(pulse_length) # Convert to an integer for consistency.
frequency = int(frequency)

# Check if the specified freq is known values
if frequency in SV_OFFSET.keys() and pulse_length in SV_OFFSET[frequency]:
return SV_OFFSET[frequency][pulse_length]

# convert dict to a grid
xs, ys, zs = [], [], []
for x_val, inner in SV_OFFSET.items(): # frequencies
for y_val, z_val in inner.items(): # phases
xs.append(x_val)
ys.append(y_val)
zs.append(z_val) # known offsets
xs, ys, zs = np.array(xs), np.array(ys), np.array(zs)
points = np.column_stack([xs, ys])
interp = LinearNDInterpolator(points, zs)
zq = interp(frequency, pulse_length)

# Check if outside of calibration grid
if np.isnan(zq):
raise ValueError(
f"Pulse lengths less than 150 or greater than 1000 usecs for {frequency}kHz "
"are not supported. Set cal_params={'Sv_offset' : VALUES} "
"to provide your own Sv_offset."
)

return np.round(zq, 1)


class CalibrateAZFP(CalibrateBase):
def __init__(
Expand All @@ -14,7 +122,7 @@ def __init__(
super().__init__(echodata, env_params, cal_params, ecs_file)

# Set sonar_type
self.sonar_type = "AZFP"
self.sonar_type = "AZFP" # ULS5 and ULS6 use the same calculations currently

# Screen for ECS file: currently not support
if self.ecs_file is not None:
Expand All @@ -30,6 +138,37 @@ def __init__(

# self.range_meter computed under self._cal_power_samples()
# because the implementation is different for Sv and TS
self.compute_Sv_offset()

def compute_Sv_offset(self):
"""
If Sv_offset isn't in cal_params or the echodata then calculate it.
"""
if self.cal_params["Sv_offset"] is None:
Sv_offset = []
for freq, pulse_len in zip(
self.echodata["Vendor_specific"]["frequency_nominal"].values,
self.echodata["Vendor_specific"]["XML_transmit_duration_nominal"].values[0],
):
try:
Sv_offset.append(_calc_azfp_Sv_offset(freq, pulse_len * 1e6))
except ValueError:
logger.warning(
f"The Sv for {freq}kHz and pulse length {pulse_len}us "
"is uncalibrated (Sv_offset=0.0)"
)
Sv_offset.append(0.0)

Sv_offset = xarray.DataArray(
Sv_offset,
coords=[("channel", self.echodata["Vendor_specific"].coords["channel"].values)],
dims=["channel"],
name="Sv_offset",
)
Sv_offset.channel.attrs = (
self.echodata["Vendor_specific"].coords["channel"].attrs.copy()
)
self.cal_params["Sv_offset"] = Sv_offset

def compute_echo_range(self, cal_type):
"""Calculate range (``echo_range``) in meter using AZFP formula.
Expand All @@ -53,8 +192,8 @@ def _cal_power_samples(self, cal_type, **kwargs):
the GU-100-AZFP-01-R50 Operator's Manual.
Note a Sv_offset factor that varies depending on frequency is used
in the calibration as documented on p.90.
See calc_Sv_offset() in convert/azfp.py
"""

# Compute range in meters
# range computation different for Sv and TS per AZFP matlab code
self.compute_echo_range(cal_type=cal_type)
Expand Down
4 changes: 2 additions & 2 deletions echopype/convert/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@

# flake8: noqa
from .parse_ad2cp import ParseAd2cp
from .parse_azfp import ParseAZFP
from .parse_azfp6 import ParseAZFP6
from .parse_base import ParseBase
from .parse_ek60 import ParseEK60
from .parse_ek80 import ParseEK80
from .parse_uls5 import ParseULS5
from .parse_uls6 import ParseULS6
from .set_groups_ad2cp import SetGroupsAd2cp
from .set_groups_azfp import SetGroupsAZFP
from .set_groups_azfp6 import SetGroupsAZFP6
Expand Down
4 changes: 2 additions & 2 deletions echopype/convert/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,13 @@ def open_raw(
- ``ES70``: Kongsberg Simrad ES70 echosounder
- ``EK80``: Kongsberg Simrad EK80 echosounder
- ``EA640``: Kongsberg EA640 echosounder
- ``AZFP``: ASL Environmental Sciences AZFP echosounder
- ``AZFP``: ASL Environmental Sciences AZFP echosounder (ULS5)
- ``AZFP6``: ASL Environmental Sciences AZFP echosounder (ULS6)
- ``AD2CP``: Nortek Signature series ADCP
(tested with Signature 500 and Signature 1000)

xml_path : str
path to XML config file used by AZFP
path to XML config file used by AZFP (ULS5 only)
include_bot : bool, default `False`
Include bottom depth file in parsing. Only used by EK60/EK80.
include_index : bool, default `False`
Expand Down
Loading
Loading