Skip to content

Commit 3ae80c5

Browse files
authored
Merge pull request #36 from isaschmitz/isabelle-adcp-matlab-reader-after-refactoring
[FEAT] addition of new adcp-mat reader
2 parents a190f2b + 4c9bfa2 commit 3ae80c5

4 files changed

Lines changed: 319 additions & 2 deletions

File tree

ctd_tools/parameters.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
SOUNDSPEED = 'speed_of_sound'
2828
CHLOROPHYLL = 'chlorophyll'
2929
FLUORESCENCE = 'fluorescence'
30+
ECHO_INTENSITY = 'echo_intensity'
31+
CORRELATION = 'correlation'
32+
DIRECTION = 'direction'
33+
MAGNITUDE = 'magnitude'
34+
PITCH = 'pitch'
35+
ROLL = 'roll'
36+
HEADING = 'heading'
37+
BATTERY_VOLTAGE = 'battery_voltage'
3038

3139
# Meta data should use standardized values from https://cfconventions.org/
3240
metadata = {
@@ -141,6 +149,46 @@
141149
'units': 'm/s',
142150
'long_name': 'Speed of sound in sea water',
143151
'standard_name': 'speed_of_sound_in_sea_water',
152+
},
153+
ECHO_INTENSITY: {
154+
'units': 'dB',
155+
'long_name': 'Echo intensity',
156+
'standard_name': 'echo_intensity',
157+
},
158+
CORRELATION: {
159+
'units': 'unitless',
160+
'long_name': 'Correlation',
161+
'standard_name': 'correlation',
162+
},
163+
DIRECTION: {
164+
'units': 'degrees',
165+
'long_name': 'Current direction',
166+
'standard_name': 'direction',
167+
},
168+
MAGNITUDE: {
169+
'units': 'm/s',
170+
'long_name': 'Current magnitude',
171+
'standard_name': 'magnitude',
172+
},
173+
PITCH: {
174+
'units': 'degrees',
175+
'long_name': 'Pitch angle',
176+
'standard_name': 'platform_pitch_angle',
177+
},
178+
ROLL: {
179+
'units': 'degrees',
180+
'long_name': 'Roll angle',
181+
'standard_name': 'platform_roll_angle',
182+
},
183+
HEADING: {
184+
'units': 'degrees',
185+
'long_name': 'Heading angle',
186+
'standard_name': 'platform_heading_angle',
187+
},
188+
BATTERY_VOLTAGE: {
189+
'units': 'volts',
190+
'long_name': 'Battery voltage',
191+
'standard_name': 'battery_voltage',
144192
}
145193
}
146194

ctd_tools/readers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def __getattr__(name):
5959
# Build __all__ from registry
6060
__all__ = [
6161
'AbstractReader',
62+
'AdcpMatlabReader',
6263
'CsvReader',
6364
'NetCdfReader',
6465
'NortekAsciiReader',
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
from __future__ import annotations
2+
import numpy as np
3+
import xarray as xr
4+
import scipy.io
5+
import pandas as pd
6+
from datetime import datetime, timedelta
7+
import re
8+
import ctd_tools.parameters as ctdparams
9+
from ctd_tools.readers.base import AbstractReader
10+
11+
class AdcpMatlabReader(AbstractReader):
12+
""" Reads ADCP data from a matlab (.mat) file into a xarray Dataset.
13+
14+
This class is used to read ADCP files, which are stored in .mat files.
15+
The provided data is expected to be in a matlab format, and this reader
16+
is designed to detect the format, rename the variables under CF standards and create an xarra Dataset.
17+
As there are various versions of variable names and file structures,
18+
the reader will detect the version and parse accordingly.
19+
20+
Attributes:
21+
----------
22+
data : xr.Dataset
23+
The xarray Dataset containing the ADCP data previously stored in a .mat file.
24+
input_file : str
25+
The path to the input ADCP file containing the sensor data stored in MATLAB .mat file.
26+
27+
Methods:
28+
-------
29+
__init__(input_file):
30+
Initializes the AdcpMatlabReader with the input file.
31+
__read():
32+
Reads the ADCP file and processes the data into an xarray Dataset.
33+
get_data():
34+
Returns the xarray Dataset containing the sensor data.
35+
_detect_format():
36+
Detects the format of the ADCP -mat input file and redirects accordingly.
37+
_parse_time():
38+
Handles different time formats in the ADCP .mat files.
39+
_add_time():
40+
Adds time coordinates to the dataset based on the detected format.
41+
_add_data_and_coords():
42+
Adds data variables and coordinates to the dataset based on the detected format.
43+
_add_metadata():
44+
Adds common metadata attributes to the dataset.
45+
"""
46+
47+
def __init__(self, input_file, mapping=None):
48+
super().__init__(input_file, mapping)
49+
self.dataset = None
50+
self.format = None
51+
self._read()
52+
53+
def _read(self):
54+
self.data = scipy.io.loadmat(self.input_file, struct_as_record=False)
55+
self.format = self._detect_format()
56+
if not self.format:
57+
raise ValueError(f"Could not detect ADCP format in {self.input_file}.")
58+
data_vars, coords = self._add_data_and_coords()
59+
self.dataset = xr.Dataset(data_vars=data_vars, coords=coords)
60+
# Assign meta information for all attributes of the xarray Dataset
61+
for key in (list(self.dataset.data_vars.keys()) + list(self.dataset.coords.keys())):
62+
super()._assign_metadata_for_key_to_xarray_dataset(self.dataset, key)
63+
64+
def get_data(self):
65+
return self.dataset
66+
67+
def _detect_format(self):
68+
keys = self.data.keys()
69+
if "dat_u" in keys and "dat_timesteps" in keys:
70+
return "v17"
71+
elif "SerYear" in keys and "RDIBin1Mid" in keys:
72+
return "v13"
73+
elif "DS_19_12_ndaysens" in keys and "DS_19_12_v" in keys:
74+
return "v12"
75+
elif "sens" in keys and "wt" in keys:
76+
return "v11"
77+
return None
78+
79+
def _parse_time(self, arr, fmt):
80+
if fmt in ("v12", "v17"):
81+
time_raw = arr
82+
return pd.to_datetime(time_raw - 719529, unit="D")
83+
84+
elif fmt == "v11":
85+
sens_struct = self.data['sens']
86+
if isinstance(sens_struct, np.ndarray):
87+
sens_struct = sens_struct[0, 0]
88+
89+
time_raw = sens_struct.time
90+
if hasattr(time_raw, 'flatten'):
91+
time_raw = time_raw.flatten()
92+
93+
return pd.to_datetime(time_raw, unit='s', errors='coerce')
94+
elif fmt == "v13":
95+
year = self.data['SerYear'].astype(np.int32).flatten()
96+
year = np.where(year > 50, year + 1900, year + 2000)
97+
month = self.data['SerMon'].flatten()
98+
day = self.data['SerDay'].flatten()
99+
hour = self.data['SerHour'].flatten()
100+
minute = self.data['SerMin'].flatten()
101+
second = self.data['SerSec'].flatten() + self.data['SerHund'].flatten() / 100
102+
return pd.to_datetime({
103+
'year': year,
104+
'month': month,
105+
'day': day,
106+
'hour': hour,
107+
'minute': minute,
108+
'second': second
109+
})
110+
def _add_time(self):
111+
fmt = self.format
112+
if fmt == "v17":
113+
time = self._parse_time(self.data["dat_timesteps"].flatten(), fmt)
114+
115+
elif fmt == "v12":
116+
time = self._parse_time(self.data["DS_19_12_ndaysens"].flatten(), fmt)
117+
118+
elif fmt == "v13":
119+
time = self._parse_time(None, fmt)
120+
121+
elif fmt == "v11":
122+
time = self._parse_time(self.data['sens'], fmt)
123+
124+
else:
125+
raise ValueError(f"Unsupported format {fmt} for time parsing.")
126+
return time
127+
128+
def _add_data_and_coords(self):
129+
fmt = self.format
130+
data_vars = {}
131+
coords = {}
132+
time = self._add_time()
133+
134+
if fmt == "v17":
135+
depth_bins = self.data['dat_binrange'].flatten()
136+
coords = {
137+
"time": time,
138+
"bin": depth_bins,
139+
}
140+
data_vars = {
141+
ctdparams.EAST_VELOCITY: (("time", "bin"), self.data["dat_u"]),
142+
ctdparams.NORTH_VELOCITY: (("time", "bin"), self.data["dat_v"]),
143+
ctdparams.UP_VELOCITY: (("time", "bin"), self.data["dat_w"]),
144+
ctdparams.TEMPERATURE: (("time"), self.data['dat_t'].flatten()),
145+
ctdparams.ECHO_INTENSITY: (("time", "bin"), self.data['dat_echoa']),
146+
ctdparams.CORRELATION: (("time", "bin"), self.data['dat_corra']),
147+
ctdparams.PITCH: (("time"), self.data['dat_pitch'].flatten()),
148+
ctdparams.ROLL: (("time"), self.data['dat_roll'].flatten()),
149+
ctdparams.HEADING: (("time"), self.data['dat_head'].flatten()),
150+
ctdparams.BATTERY_VOLTAGE: (("time"), self.data['dat_batt'].flatten()),
151+
}
152+
153+
elif fmt == "v13":
154+
155+
bin1_mid = np.squeeze(self.data.get("RDIBin1Mid", [np.nan]))
156+
bin_size = np.squeeze(self.data.get("RDIBinSize", [np.nan]))
157+
num_bins = self.data['SerBins'].shape[1]
158+
depth = bin1_mid + bin_size * np.arange(num_bins)
159+
160+
coords = {
161+
"time": time,
162+
"bin": depth,
163+
}
164+
165+
data_vars = {
166+
ctdparams.EAST_VELOCITY: (("time", "bin"), self.data['SerEmmpersec'] / 1000), # mm/s to m/s
167+
ctdparams.NORTH_VELOCITY: (("time", "bin"), self.data['SerNmmpersec'] / 1000),
168+
ctdparams.UP_VELOCITY: (("time", "bin"), self.data['SerVmmpersec'] / 1000),
169+
ctdparams.TEMPERATURE: (("time"), self.data['AnT100thDeg'].flatten() / 100),
170+
ctdparams.ECHO_INTENSITY: (("time", "bin"), self.data['SerEA1cnt']),
171+
ctdparams.CORRELATION: (("time", "bin"), self.data['SerC1cnt']),
172+
ctdparams.DIRECTION: (("time", "bin"), self.data['SerDir10thDeg'] / 10), # 10th degrees to degrees
173+
ctdparams.MAGNITUDE: (("time", "bin"), self.data['SerMagmmpersec'] / 1000),
174+
ctdparams.PITCH: (("time"), self.data['AnP100thDeg'].flatten() / 100),
175+
ctdparams.ROLL: (("time"), self.data['AnR100thDeg'].flatten() / 100),
176+
ctdparams.HEADING: (("time"), self.data['AnH100thDeg'].flatten() / 100),
177+
ctdparams.BATTERY_VOLTAGE: (("time"), self.data['AnBatt'].flatten() / 10), # Tenths of volts
178+
}
179+
180+
181+
elif fmt == "v12":
182+
183+
depth_bins = self.data['DS_19_12_binrange'].flatten()
184+
coords = {
185+
"time": time,
186+
"bin": depth_bins,
187+
}
188+
189+
data_vars = {
190+
ctdparams.EAST_VELOCITY: (("time", "bin"), self.data['DS_19_12_u']),
191+
ctdparams.NORTH_VELOCITY: (("time", "bin"), self.data['DS_19_12_v']),
192+
ctdparams.UP_VELOCITY: (("time", "bin"), self.data['DS_19_12_w']),
193+
ctdparams.TEMPERATURE: (("time"), self.data['DS_19_12_t'].flatten()),
194+
ctdparams.ECHO_INTENSITY: (("time", "bin"), self.data['DS_19_12_echoa']),
195+
ctdparams.CORRELATION: (("time", "bin"), self.data['DS_19_12_corra']),
196+
ctdparams.PITCH: (("time"), self.data['DS_19_12_pitch'].flatten()),
197+
ctdparams.ROLL: (("time"), self.data['DS_19_12_roll'].flatten()),
198+
ctdparams.HEADING: (("time"), self.data['DS_19_12_head'].flatten()),
199+
ctdparams.BATTERY_VOLTAGE: (("time"), self.data['DS_19_12_batt'].flatten()),
200+
}
201+
202+
elif fmt == "v11":
203+
204+
sens_struct = self.data['sens']
205+
if isinstance(sens_struct, np.ndarray):
206+
sens_struct = sens_struct[0, 0] # unwrap from ndarray container
207+
wt_struct = self.data['wt']
208+
if isinstance(wt_struct, np.ndarray):
209+
wt_struct = wt_struct[0, 0]
210+
211+
# Extract data from 'sens' and 'wt'
212+
salinity = sens_struct.s.flatten()
213+
temperature = sens_struct.t.flatten()
214+
pitch = sens_struct.p.flatten()
215+
roll = sens_struct.r.flatten()
216+
heading = sens_struct.h.flatten()
217+
battery_voltage = sens_struct.v.flatten()
218+
east_velocity_raw = wt_struct.vel
219+
220+
# Reshape the data to (n_time, total_depth)
221+
n_time, n_depth, n_velocity_components = east_velocity_raw.shape
222+
total_depth = n_depth * n_velocity_components
223+
east_velocity = east_velocity_raw.reshape(-1, total_depth)
224+
225+
depth_bins = wt_struct.r.flatten()
226+
coords = {
227+
"time": time,
228+
"depth_bin": depth_bins[:east_velocity.shape[1]],
229+
}
230+
231+
# Organize data variables to return
232+
data_vars = {
233+
ctdparams.EAST_VELOCITY: (("time", "depth_bin"), east_velocity),
234+
ctdparams.TEMPERATURE: (("time"), temperature),
235+
ctdparams.SALINITY: (("time"), salinity),
236+
ctdparams.PITCH: (("time"), pitch),
237+
ctdparams.ROLL: (("time"), roll),
238+
ctdparams.HEADING: (("time"), heading),
239+
ctdparams.BATTERY_VOLTAGE: (("time"), battery_voltage),
240+
}
241+
242+
return data_vars, coords
243+
244+
def _add_metadata(self):
245+
# Add common metadata attributes
246+
self.dataset.attrs.update({
247+
"Conventions": "CF-1.8",
248+
"title": "ADCP Data",
249+
"source": "Acoustic Doppler Current Profiler",
250+
})
251+
252+
@staticmethod
253+
def format_key() -> str:
254+
return 'adcp-matlab'
255+
256+
@staticmethod
257+
def format_name() -> str:
258+
return 'ADCP Matlab'
259+
260+
@staticmethod
261+
def file_extension() -> str | None:
262+
return None

ctd_tools/readers/registry.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ class ReaderMetadata:
102102
format_key="nortek-ascii",
103103
file_extension=None
104104
),
105+
ReaderMetadata(
106+
class_name="AdcpMatlabReader",
107+
module_name=".adcp_matlab_reader",
108+
format_name="ADCP Matlab",
109+
format_key="adcp-matlab",
110+
file_extension= None
111+
),
105112
ReaderMetadata(
106113
class_name="RcmMatlabReader",
107114
module_name=".rcm_matlab_reader",
@@ -115,10 +122,9 @@ class ReaderMetadata:
115122
format_name="SeaBird ASCII",
116123
format_key="sbe-ascii",
117124
file_extension=None
118-
)
125+
),
119126
]
120127

121-
122128
# Utility functions to extract information from registry
123129
def get_reader_modules() -> Dict[str, str]:
124130
"""Get mapping of class names to module names for lazy loading."""

0 commit comments

Comments
 (0)