Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
9b07a20
Support speed and NMEA heading from Simrad .raw files
gavinmacaulay Jul 15, 2026
2428db7
remove a missed debug print
gavinmacaulay Jul 15, 2026
50c263c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 15, 2026
d9100c1
fix a test that failed
gavinmacaulay Jul 15, 2026
dac72c6
Merge branch 'main' of https://github.com/gavinmacaulay/echopype
gavinmacaulay Jul 15, 2026
c1d018b
Resolve some of the test failures
gavinmacaulay Jul 16, 2026
12507ef
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 16, 2026
8369a30
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 16, 2026
1ac96c6
Update test_convert_ek80 to use new location of data for test_parse_N…
gavinmacaulay Jul 16, 2026
b64dcdb
Merge remote-tracking branch 'upstream/main' into pr-1703
LOCEANlloydizard Jul 17, 2026
de5216a
Change heading raw file path to match updated asset location
gavinmacaulay Jul 17, 2026
e20981b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2026
3f833fb
Remove accidentally committed files and changes
gavinmacaulay Jul 17, 2026
fb1daf6
Merge branch 'main' of https://github.com/gavinmacaulay/echopype
gavinmacaulay Jul 17, 2026
6d74d01
change checksum
LOCEANlloydizard Jul 17, 2026
e32484b
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 20, 2026
77c0b84
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 20, 2026
c4a43b5
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 21, 2026
8a3ffce
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 21, 2026
0683848
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 21, 2026
a6bc7b4
Resolve a testing warning
gavinmacaulay Jul 22, 2026
b3f2069
Undo a commit to the wrong PR
gavinmacaulay Jul 22, 2026
7c371cb
Apply suggestions from code review
gavinmacaulay Jul 22, 2026
a3c3640
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 22, 2026
8cf2c5c
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 23, 2026
cb3b393
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 23, 2026
fb4e896
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 23, 2026
15fe469
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 24, 2026
be9b96a
Merge branch 'echostack-org:main' into main
gavinmacaulay Jul 26, 2026
ce85731
Merge branch 'echostack-org:main' into main
gavinmacaulay Aug 3, 2026
b12424f
Merge branch 'echostack-org:main' into main
gavinmacaulay Aug 5, 2026
ff04434
Merge branch 'echostack-org:main' into main
gavinmacaulay Aug 6, 2026
c512947
Merge branch 'main' of https://github.com/gavinmacaulay/echopype
gavinmacaulay Aug 7, 2026
611cdd2
Merge branch 'echostack-org:main' into main
gavinmacaulay Aug 7, 2026
f90da53
Add mark.unit label to speed over ground test
gavinmacaulay Aug 7, 2026
493d46d
Merge branch 'echostack-org:main' into main
gavinmacaulay Aug 9, 2026
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
123 changes: 98 additions & 25 deletions echopype/convert/set_groups_base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import abc
import warnings
from collections.abc import Iterable
from typing import List, Set

import numpy as np
Expand All @@ -11,6 +12,10 @@
from ..utils.prov import echopype_prov_attrs, source_files_vars

NMEA_SENTENCE_DEFAULT = ["GGA", "GLL", "RMC"]
Comment thread
gavinmacaulay marked this conversation as resolved.
Outdated
NMEA_SENTENCE_SPEED = ["RMC", "VTG"]
NMEA_SENTENCE_HEADING = ["HDT"]

KNOTS_TO_M_PER_S = 0.51444444444


class SetGroupsBase(abc.ABC):
Expand Down Expand Up @@ -177,10 +182,12 @@ def set_vendor(self) -> xr.Dataset:
raise NotImplementedError

# TODO: move this to be part of parser as it is not a "set" operation
def _extract_NMEA_latlon(self):
"""Get the lat and lon values from the raw nmea data"""
def _extract_selected_NMEA(
self, nmea_sentence_types: list
) -> tuple[Iterable, Iterable, Iterable]:
"""Parse out the selected NMEA messages."""
messages = [string[3:6] for string in self.parser_obj.nmea["nmea_string"]]
idx_loc = np.argwhere(np.isin(messages, NMEA_SENTENCE_DEFAULT)).squeeze()
idx_loc = np.argwhere(np.isin(messages, nmea_sentence_types)).squeeze()
if idx_loc.size == 1: # in case of only 1 matching message
idx_loc = np.expand_dims(idx_loc, axis=0)
nmea_msg = []
Expand All @@ -194,6 +201,43 @@ def _extract_NMEA_latlon(self):
pynmea2.ParseError,
):
nmea_msg.append(None)

msg_type = (
[x.sentence_type if hasattr(x, "sentence_type") else np.nan for x in nmea_msg]
if nmea_msg
else [np.nan]
)

if nmea_msg:
time, _, _ = xr.coding.times.encode_cf_datetime(
np.array(self.parser_obj.nmea["timestamp"])[idx_loc],
**{
"units": DEFAULT_TIME_ENCODING["units"],
"calendar": DEFAULT_TIME_ENCODING["calendar"],
},
)
time = xr.coding.times.decode_cf_datetime(
time,
units=DEFAULT_TIME_ENCODING["units"],
calendar=DEFAULT_TIME_ENCODING["calendar"],
)
else:
time = [np.nan]

# There can be duplicate timestamps both due to a problem in earlier Simrad raw
# files and if multiple NMEA sentences are used with the same
# timestamp. Remove them here.
if nmea_msg:
time, indices = np.unique(time, return_index=True, sorted=False)
nmea_msg = list(np.array(nmea_msg)[indices])
msg_type = list(np.array(msg_type)[indices])

return nmea_msg, time, msg_type

# TODO: move this to be part of parser as it is not a "set" operation
def _extract_NMEA_latlon(self):
"""Get the lat and lon values from the raw nmea data"""
nmea_msg, time, msg_type = self._extract_selected_NMEA(NMEA_SENTENCE_DEFAULT)
Comment thread
gavinmacaulay marked this conversation as resolved.
Outdated
if nmea_msg:
lat, lon = [], []
for x in nmea_msg:
Expand All @@ -215,28 +259,59 @@ def _extract_NMEA_latlon(self):
)
else:
lat, lon = [np.nan], [np.nan]
msg_type = (
[x.sentence_type if hasattr(x, "sentence_type") else np.nan for x in nmea_msg]
if nmea_msg
else [np.nan]
)

return time, msg_type, lat, lon

# TODO: move this to be part of parser as it is not a "set" operation
def _extract_NMEA_speed(self):
"""Get the speed over ground values from the raw nmea data"""
nmea_msg, time, msg_type = self._extract_selected_NMEA(NMEA_SENTENCE_SPEED)
if nmea_msg:
time1, _, _ = xr.coding.times.encode_cf_datetime(
np.array(self.parser_obj.nmea["timestamp"])[idx_loc],
**{
"units": DEFAULT_TIME_ENCODING["units"],
"calendar": DEFAULT_TIME_ENCODING["calendar"],
},
)
time1 = xr.coding.times.decode_cf_datetime(
time1,
units=DEFAULT_TIME_ENCODING["units"],
calendar=DEFAULT_TIME_ENCODING["calendar"],
)
sog = []
for x in nmea_msg:
try:
# pynmea2 has different names for speed over ground, depending on the NMEA
# message that it comes from
if x.sentence_type == "VTG":
# VTG speed is returned as a Decimal, so fix that
sog.append(
float(x.spd_over_grnd_kts) * KNOTS_TO_M_PER_S
if hasattr(x, "spd_over_grnd_kts") and x.spd_over_grnd_kts is not None
else np.nan
)
else: # only RMC so far
sog.append(
x.spd_over_grnd * KNOTS_TO_M_PER_S
if hasattr(x, "spd_over_grnd") and x.spd_over_grnd is not None
else np.nan
)
except ValueError:
sog.append(np.nan)
else:
sog = [np.nan]

return time, msg_type, sog

# TODO: move this to be part of parser as it is not a "set" operation
def _extract_NMEA_heading(self):
"""Get heading values from the raw nmea data"""
nmea_msg, time, msg_type = self._extract_selected_NMEA(NMEA_SENTENCE_HEADING)
if nmea_msg:
heading = []
for x in nmea_msg:
try:
# HDG speed is returned as a Decimal, so fix that
heading.append(
float(x.heading)
if hasattr(x, "heading") and x.heading is not None
else np.nan
)
except ValueError:
heading.append(np.nan)
else:
time1 = [np.nan]
heading = [np.nan]

return time1, msg_type, lat, lon
return time, msg_type, heading

def _beam_groups_vars(self):
"""Stage beam_group coordinate and beam_group_descr variables sharing
Expand Down Expand Up @@ -462,9 +537,7 @@ def _add_index_data_to_platform_ds(
}
)

return platform_ds.transpose(
"channel", "time1", "time2", "time3", "time4", missing_dims="ignore"
)
return platform_ds.transpose(missing_dims="ignore")

def _add_seafloor_detection_data_to_vendor_ds(
self,
Expand Down
32 changes: 31 additions & 1 deletion echopype/convert/set_groups_ek60.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ def set_platform(self) -> xr.Dataset:
# Collect variables
# Read lat/long from NMEA datagram
time1, msg_type, lat, lon = self._extract_NMEA_latlon()
time10, msg_type_heading, heading = self._extract_NMEA_heading()
time11, msg_type_sog, sog = self._extract_NMEA_speed()

# NMEA dataset: variables filled with np.nan if they do not exist
platform_dict = {"platform_name": "", "platform_type": "", "platform_code_ICES": ""}
Expand All @@ -188,8 +190,10 @@ def set_platform(self) -> xr.Dataset:
# are identical across channels
ch = list(self.sorted_channel.keys())[0]

# Handle potential nan timestamp for time1 and time2
# Handle potential nan timestamp
time1 = self._nan_timestamp_handler(time1)
time10 = self._nan_timestamp_handler(time10)
time11 = self._nan_timestamp_handler(time11)

ds = xr.Dataset(
{
Expand Down Expand Up @@ -243,6 +247,16 @@ def set_platform(self) -> xr.Dataset:
"position_offset_z",
]
},
"heading": (
["time10"],
np.array(heading),
self._varattrs["platform_var_default"]["heading"],
),
"speed_over_ground": (
["time11"],
np.array(sog),
self._varattrs["platform_var_default"]["speed_over_ground"],
),
},
coords={
"time1": (
Expand All @@ -264,6 +278,22 @@ def set_platform(self) -> xr.Dataset:
"orientation data.",
},
),
"time10": (
["time10"],
time10,
{
**self._varattrs["platform_coord_default"]["time1"],
"comment": "Time coordinate corresponding to NMEA heading data.",
},
),
"time11": (
["time11"],
time11,
{
**self._varattrs["platform_coord_default"]["time1"],
"comment": "Time coordinate corresponding to NMEA speed data.",
},
),
},
)

Expand Down
59 changes: 47 additions & 12 deletions echopype/convert/set_groups_ek80.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,11 +329,15 @@ def set_platform(self) -> xr.Dataset:
time2 = np.array(time2) if time2 is not None else [np.nan]
time3 = self.parser_obj.mru1.get("timestamp", None)
time3 = np.array(time3) if time3 is not None else [np.nan]
time10, msg_type_heading, heading_nmea = self._extract_NMEA_heading()
time11, msg_type_sog, sog_nmea = self._extract_NMEA_speed()

# Handle potential nan timestamp for time1, time2, and time3
# Handle potential nan timestamps
time1 = self._nan_timestamp_handler(time1)
time2 = self._nan_timestamp_handler(time2)
time3 = self._nan_timestamp_handler(time3)
time10 = self._nan_timestamp_handler(time10)
time11 = self._nan_timestamp_handler(time11)

# Set MRU1 lat lon attributes
latitude_mru1_attrs = self._varattrs["platform_var_default"]["latitude"].copy()
Expand All @@ -351,6 +355,26 @@ def set_platform(self) -> xr.Dataset:
}
),

# If there is no heading data from an MRU but there is from the NMEA data, use that instead
if "heading" in self.parser_obj.mru0:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know of any cases where there is heading received from both MRU and NMEA? I'm wondering if you can just save those as separate variables.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And I guess the same applies to the NMEA speed sentences

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see many vessel-based systems setup to receive both MRU0 datagrams and heading (from a separate gyrocompass via the HDT message), although I found none in the echopype test dataset and is why I added one 😄 My thinking was that from the user's point of view they shouldn't need to (initially) think too much about where the heading came from and just have it be in a heading variable.

For the NMEA speed, many vessels have it in both RMC and VTG messages, but then some vessels don't have RMC and come don't have VTG and that's a detail that user's don't really need to worry about if they both end up in a speed_over_ground variable.

hdg_data = (
["time2"],
np.array(self.parser_obj.mru0.get("heading", [np.nan])),
self._varattrs["platform_var_default"]["heading"],
)
elif len(heading_nmea) > 0:
hdg_data = (
["time10"],
heading_nmea,
self._varattrs["platform_var_default"]["heading"],
)
else:
hdg_data = (
["time2"],
[np.nan],
self._varattrs["platform_var_default"]["heading"],
)

# Assemble variables into a dataset: variables filled with nan if do not exist
platform_dict = {"platform_name": "", "platform_type": "", "platform_code_ICES": ""}
ds = xr.Dataset(
Expand Down Expand Up @@ -456,17 +480,7 @@ def set_platform(self) -> xr.Dataset:
"standard_name": "sound_frequency",
},
),
"heading": (
["time2"],
np.array(self.parser_obj.mru0.get("heading", [np.nan])),
{
"long_name": "Platform heading (true)",
"standard_name": "platform_orientation",
"units": "degrees_north",
"valid_min": 0.0,
"valid_max": 360.0,
},
),
"heading": hdg_data,
"latitude_mru1": (
["time3"],
np.array(self.parser_obj.mru1.get("latitude", [np.nan])),
Expand All @@ -477,6 +491,11 @@ def set_platform(self) -> xr.Dataset:
np.array(self.parser_obj.mru1.get("longitude", [np.nan])),
longitude_mru1_attrs,
),
"speed_over_ground": (
["time11"],
np.array(sog_nmea),
self._varattrs["platform_var_default"]["speed_over_ground"],
),
},
coords={
"channel": (
Expand Down Expand Up @@ -515,6 +534,22 @@ def set_platform(self) -> xr.Dataset:
"orientation data from the Kongsberg Maritime Binary Datagram.",
},
),
"time10": (
["time10"],
time10,
{
**self._varattrs["platform_coord_default"]["time1"],
"comment": "Time coordinate corresponding to NMEA heading data.",
},
),
"time11": (
["time11"],
time11,
{
**self._varattrs["platform_coord_default"]["time1"],
"comment": "Time coordinate corresponding to NMEA speed data.",
},
),
},
)
ds = ds.assign_attrs(platform_dict)
Expand Down
11 changes: 11 additions & 0 deletions echopype/echodata/convention/1.0.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,16 @@ variable_and_varattributes:
water_level:
long_name: Distance from the platform coordinate system origin to the nominal water level along the z-axis
units: m
speed_over_ground:
long_name: Speed over ground of the platform
standard_name: platform_speed_wrt_ground
units: m/s
valid_min: 0.0
heading:
long_name: Platform heading (true)
standard_name: platform_orientation
units: degrees_north
valid_min: 0.0
valid_max: 360.0
sentence_type:
long_name: NMEA sentence type
2 changes: 1 addition & 1 deletion echopype/echodata/echodata.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ def update_platform(
if v["ext_time_dim_name"] != "scalar"
}
)
time_dims_max = max([int(dim[-1]) for dim in platform.dims if dim.startswith("time")])
time_dims_max = max([int(dim[4:]) for dim in platform.dims if dim.startswith("time")])
new_time_dims = [f"time{time_dims_max + i + 1}" for i in range(len(ext_time_dims))]
# Map each new time dim name to the external time dim name:
new_time_dims_mappings = {new: ext for new, ext in zip(new_time_dims, ext_time_dims)}
Expand Down
30 changes: 30 additions & 0 deletions echopype/tests/convert/test_convert_ek60.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,33 @@ def test_converting_ek60_raw_with_missing_channel_power(ek60_missing_channel_pow
# Check that all empty power channels do not exist in the EchoData Beam group
for _, empty_power_channel_name in empty_power_chs.items():
assert empty_power_channel_name not in ed["Sonar/Beam_group1"]["channel"]


@pytest.mark.unit
def test_parse_speed_over_ground(ek60_path):
"""Make sure we parse speed over ground from a RAW file."""

# This raw file has speed in NMEA VTG and RMC messages
echodata = open_raw(
raw_file=ek60_path/'NBP_B050N-D20180118-T090228.raw',
sonar_model='EK60'
)

# Check that there are data that are not NaN
assert (echodata["Platform"]['speed_over_ground'].sizes == {'time11': 584})
# this .raw file has nan's in the speed over ground data
# assert (not np.any(np.isnan(echodata["Platform"]['speed_over_ground'])))


@pytest.mark.unit
def test_parse_NMEA_heading(ek60_path):
"""Make sure we parse NMEA heading from a RAW file when MRU heading is not present."""

echodata = open_raw(
raw_file=ek60_path/'NBP_B050N-D20180118-T090228.raw',
sonar_model='EK60'
)

# Check that there are non-NaN data
assert (echodata["Platform"]['heading'].sizes == {'time10': 584})
assert (not np.any(np.isnan(echodata["Platform"]['heading'])))
Loading
Loading