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
0 commit comments