acspype 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
acspype/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .dev import ACSDev
2
+ from .tscor import ACSTSCor
3
+ from .stream import ACSStream
4
+ from .processing import parse_packet, calibrate_packet
5
+
acspype/core.py ADDED
@@ -0,0 +1,28 @@
1
+ import numpy as np
2
+
3
+ NUM_PAT = "[+-]?[0-9]*[.]?[0-9]+" # REGEX for any number, float or int, positive or negative.
4
+
5
+ PACKET_REGISTRATION = b'\xff\x00\xff\x00' # Start of every ACS packet.
6
+ PAD_BYTE = b'\x00' # End of every ACS packet.
7
+ WVL_BYTE_OFFSET = 4 + 2 + 1 + 1 + 1 + 3 + 2 + 2 + 2 + 2 + 2 + 2 + 2 + 4 + 1 # See Process Data section in ACS manual.
8
+ NUM_CHECKSUM_BYTES = 2
9
+ PACKET_HEAD = '!4cHBBl7HIBB' # struct descriptor for the static header of a packet.
10
+ PACKET_TAIL = 'Hx' # struct descriptor for the static tail of a packet.
11
+ LPR = len(PACKET_REGISTRATION)
12
+
13
+ class DefaultSerial:
14
+ BAUDRATE: int = 115200
15
+ BYTESIZE: int = 8
16
+ PARITY: str = 'N'
17
+ STOPBITS: int = 1
18
+ FLOWCONTROL: int = 0
19
+ TIMEOUT: int = 3
20
+
21
+ # Raw pressure counts are no longer output by an ACS and can be safely ignored. The reserved_1 and reserved_2 variables are single byte variables that are not used by the ACS and can be ignored.
22
+ ACS_VARS_TO_IGNORE = ['raw_pressure', 'reserved_1', 'reserved_2']
23
+
24
+ #---------- File Creation ----------#
25
+ ENCODING = {'time': {'units': 'nanoseconds since 1900-01-01'}} # xr.Dataset to netcdf encoding for time
26
+
27
+ #---------- PHYSICAL QUANTITIES ----------#
28
+ EST_FLOW_CELL_VOLUME = 30 # in mL, from the ACS Protocol Document, Rev Q.
acspype/dev.py ADDED
@@ -0,0 +1,229 @@
1
+ from datetime import datetime
2
+ import numpy as np
3
+ import re
4
+ from scipy import interpolate
5
+ import xarray as xr
6
+
7
+ from acspype.core import NUM_PAT
8
+
9
+
10
+ class ACSDev:
11
+ """
12
+ A class for parsing ACS .dev files and putting them into a format that is easier to work with for larger or
13
+ multiple file datasets.
14
+
15
+ Generally, users will not call individual functions, but rather use the class to obtain attributes, which are
16
+ created at class instantiation or convert the data to an xarray dataset using the to_xarray function.
17
+ """
18
+
19
+ def __init__(self, filepath: str) -> None:
20
+ """
21
+ Run the following functions at instantiation to parse the .dev file and store the info as class attributes.
22
+
23
+ :param filepath: The filepath to the .dev file.
24
+ :return: None
25
+ """
26
+
27
+ self._filepath = filepath
28
+ self.__read_dev()
29
+ self.__parse_metadata()
30
+ self.__parse_tbins()
31
+ self.__parse_offsets()
32
+ self.__build_interp_funcs()
33
+ self.__check_parse()
34
+
35
+
36
+ def __read_dev(self) -> None:
37
+ """
38
+ Import the .dev file as a text file.
39
+ The file contents are stored as a list of strings in the class attribute self._content.
40
+
41
+ :return: None
42
+ """
43
+
44
+ with open(self._filepath, 'r') as _file:
45
+ self._content = _file.readlines()
46
+
47
+
48
+ def __parse_metadata(self) -> None:
49
+ """
50
+ Parse the .dev file for individual sensor metadata.
51
+ Sensor specific metadata are stored as class attributes.
52
+
53
+ :return: None
54
+ """
55
+
56
+ metadata_lines = [line for line in self._content if 'C and A offset' not in line]
57
+ for line in metadata_lines:
58
+ if 'ACS Meter' in line:
59
+ self.sensor_type = re.findall('(.*?)\n', line)[0]
60
+ elif 'Serial' in line:
61
+ self.sn_hexdec = re.findall('(.*?)\t', line)[0]
62
+ self.sn = 'ACS-' + str(int(self.sn_hexdec[-6:], 16)).zfill(5) # Convert to sn shown on product sticker.
63
+ elif 'structure version' in line:
64
+ self.structure_version = int(re.findall(f'({NUM_PAT})\t', line)[0])
65
+ elif 'tcal' in line or 'Tcal' in line:
66
+ self.tcal, self.ical = [float(v) for v in re.findall(f': ({NUM_PAT}) C', line)]
67
+ cal_date_str = re.findall('file on (.*?)[.]', line)[0].replace(' ', '')
68
+ try: # Sometimes the file date is entered as yyyy or yy. This should handle both cases.
69
+ self.cal_date = datetime.strptime(cal_date_str, '%m/%d/%Y').strftime('%Y-%m-%d')
70
+ except:
71
+ self.cal_date = datetime.strptime(cal_date_str, '%m/%d/%y').strftime('%Y-%m-%d')
72
+ elif 'Depth calibration' in line:
73
+ (self.depth_cal_1,
74
+ self.depth_cal_2) = [float(v) for v in re.findall(f'({NUM_PAT})', line)]
75
+ elif 'Baud' in line:
76
+ self.baudrate = int(re.findall(f'({NUM_PAT})\t', line)[0])
77
+ elif 'Path' in line:
78
+ self.path_length = float(re.findall(f'({NUM_PAT})\t', line)[0])
79
+ elif 'wavelengths' in line:
80
+ self.num_wavelength = int(re.findall(f'({NUM_PAT})\t', line)[0])
81
+ elif 'number of temperature bins' in line:
82
+ self.num_tbin = int(re.findall(f'({NUM_PAT})\t', line)[0])
83
+ elif 'maxANoise' in line:
84
+ (self.max_a_noise, self.max_c_noise, self.max_a_nonconform, self.max_c_nonconform,
85
+ self.max_a_difference, self.max_c_difference, self.min_a_counts,
86
+ self.min_c_counts, self.min_r_counts, self.max_temp_sd,
87
+ self.max_depth_sd) = [float(v) for v in re.findall(f'({NUM_PAT})\t', line)]
88
+
89
+
90
+ def __parse_tbins(self) -> None:
91
+ """
92
+ Parse the .dev file for temperature bin information.
93
+
94
+ :return: None
95
+ """
96
+ tbin_line = [line for line in self._content if '; temperature bins' in line][0]
97
+ tbins = tbin_line.split('\t')
98
+ tbins = [v for v in tbins if v] # Toss empty strings.
99
+ tbins = [v for v in tbins if v != '\n'] # Toss newline characters.
100
+ self.tbin = np.array([float(v) for v in tbins if 'temperature bins' not in v]) # Convert to float and toss comment.
101
+
102
+
103
+ def __parse_offsets(self) -> None:
104
+ """
105
+ Parse the .dev file for a and c offsets. Data are saved as class attributes for access at a later time.
106
+
107
+ :return: None
108
+ """
109
+
110
+ offset_lines = [line for line in self._content if 'C and A offset' in line]
111
+
112
+ # Create holder arrays to loop over and append data to.
113
+ c_wvls = []
114
+ a_wvls = []
115
+ c_offs = []
116
+ a_offs = []
117
+ c_deltas = []
118
+ a_deltas = []
119
+ wavelength_color_schemes = []
120
+
121
+ for line in offset_lines:
122
+ offsets, c_delta, a_delta = line.split('\t\t')[:-1]
123
+ c_wvl, a_wvl, wvl_color, c_off, a_off = offsets.split('\t')
124
+
125
+ # Convert strings to proper pythonic datatypes.
126
+ c_wvl = float(c_wvl.replace('C', ''))
127
+ a_wvl = float(a_wvl.replace('A', ''))
128
+ c_off = float(c_off)
129
+ a_off = float(a_off)
130
+ c_delta = [float(v) for v in c_delta.split('\t')]
131
+ a_delta = [float(v) for v in a_delta.split('\t')]
132
+
133
+ # Append files to holder arrays.
134
+ c_wvls.append(c_wvl)
135
+ a_wvls.append(a_wvl)
136
+ c_offs.append(c_off)
137
+ a_offs.append(a_off)
138
+ c_deltas.append(c_delta)
139
+ a_deltas.append(a_delta)
140
+ wavelength_color_schemes.append(wvl_color)
141
+
142
+ # Convert holder arrays to numpy arrays.
143
+ self.c_wavelength = np.array(c_wvls)
144
+ self.a_wavelength = np.array(a_wvls)
145
+ self.c_offset = np.array(c_offs)
146
+ self.a_offset = np.array(a_offs)
147
+ self.c_delta_t = np.array(c_deltas)
148
+ self.a_delta_t = np.array(a_deltas)
149
+ self.wavelength_color_schemes = wavelength_color_schemes
150
+
151
+
152
+ def __build_interp_funcs(self) -> None:
153
+ """
154
+ Build interpolation functions for the a and c delta_t values and store as class attributes.
155
+
156
+ :return: None
157
+ """
158
+ self.func_a_delta_t = interpolate.interp1d(self.tbin, self.a_delta_t, axis=1)
159
+ self.func_c_delta_t = interpolate.interp1d(self.tbin, self.c_delta_t, axis=1)
160
+ self.delta_t_interp_method = 'scipy.interpolate.interp1d'
161
+
162
+
163
+ def __check_parse(self) -> None:
164
+ """
165
+ Verify that the shape of the data is as expected.
166
+
167
+ :return: None
168
+ """
169
+
170
+ if len(self.a_wavelength) != self.num_wavelength:
171
+ raise ValueError('Mismatch between number of wavelengths extracted for A and expected from file.'
172
+ 'Please verify the .dev file integrity.')
173
+ if len(self.c_wavelength) != self.num_wavelength:
174
+ raise ValueError('Mismatch between number of wavelengths extracted for C and expected from file.'
175
+ 'Please verify the .dev file integrity.')
176
+ if len(self.c_wavelength) != len(self.a_wavelength):
177
+ raise ValueError('Mismatch between number of wavelengths extracted for A and C.'
178
+ 'Please verify the .dev file integrity.')
179
+ if np.array(self.a_delta_t).shape != (len(self.a_wavelength), self.num_tbin):
180
+ raise ValueError('Mismatch between length of A wavelengths and number of temperature bins.'
181
+ 'Please verify the .dev file integrity.')
182
+ if np.array(self.c_delta_t).shape != (len(self.a_wavelength), self.num_tbin):
183
+ raise ValueError('Mismatch between length of C wavelengths and number of temperature bins.'
184
+ 'Please verify the .dev file integrity.')
185
+
186
+
187
+ def to_xarray(self) -> xr.Dataset:
188
+ """
189
+ Convert the parsed .dev file files to an xarray dataset
190
+
191
+ Returns: An appropriately dimensioned xarray dataset containing device file files.
192
+ """
193
+ ds = xr.Dataset()
194
+ ds = ds.assign_coords({'a_wavelength': self.a_wavelength,
195
+ 'c_wavelength': self.c_wavelength,
196
+ 'temperature_bin': self.tbin})
197
+
198
+ ds['a_offset'] = (['a_wavelength'], self.a_offset)
199
+ ds['a_delta_t'] = (['a_wavelength', 'temperature_bin'], self.a_delta_t)
200
+
201
+ ds['c_offset'] = (['c_wavelength'], np.array(self.c_offset))
202
+ ds['c_delta_t'] = (['c_wavelength', 'temperature_bin'], self.c_delta_t)
203
+
204
+ ds.attrs['device_filepath'] = self._filepath
205
+ ds.attrs['sensor_type'] = self.sensor_type
206
+ ds.attrs['serial_number_hexdec'] = self.sn_hexdec
207
+ ds.attrs['serial_number'] = self.sn
208
+ ds.attrs['device_file_structure_version'] = self.structure_version
209
+ ds.attrs['tcal'] = self.tcal
210
+ ds.attrs['ical'] = self.ical
211
+ ds.attrs['calibration_date'] = self.cal_date
212
+ ds.attrs['depth_cal_1'] = self.depth_cal_1
213
+ ds.attrs['depth_cal_2'] = self.depth_cal_2
214
+ ds.attrs['baudrate'] = self.baudrate
215
+ ds.attrs['path_length'] = self.path_length
216
+ ds.attrs['number_of_wavelength_bins'] = self.num_wavelength
217
+ ds.attrs['number_of_temperature_bins'] = self.num_tbin
218
+ ds.attrs['max_a_noise'] = self.max_a_noise
219
+ ds.attrs['max_c_noise'] = self.max_c_noise
220
+ ds.attrs['max_a_nonconform'] = self.max_a_nonconform
221
+ ds.attrs['max_c_nonconform'] = self.max_c_nonconform
222
+ ds.attrs['max_a_difference'] = self.max_a_difference
223
+ ds.attrs['max_c_difference'] = self.max_c_difference
224
+ ds.attrs['min_a_counts'] = self.min_a_counts
225
+ ds.attrs['min_c_counts'] = self.min_c_counts
226
+ ds.attrs['min_r_counts'] = self.min_r_counts
227
+ ds.attrs['max_temp_sd'] = self.max_temp_sd
228
+ ds.attrs['max_depth_sd'] = self.max_depth_sd
229
+ return ds
@@ -0,0 +1,142 @@
1
+ import numpy as np
2
+ from scipy.interpolate import CubicSpline
3
+ from typing import Union
4
+ import xarray as xr
5
+
6
+
7
+ def find_discontinuity_index(a_wavelengths: Union[list,tuple, np.array, xr.DataArray],
8
+ c_wavelengths: Union[list,tuple, np.array, xr.DataArray],
9
+ min_band: int = 535, max_band: int = 600) -> int:
10
+ """
11
+
12
+ This code is modified from the OPTAA processing utilities in the ooi-data-explorations repo.
13
+ https://github.com/IanTBlack/ooi-data-explorations/blob/master/python/ooi_data_explorations/uncabled/utilities/utilities_optaa.py#L104
14
+
15
+ Find the last wavelength index of the first filter based on wavelength differences.
16
+ This function assumes that the discontinuity occurs between 535 nm and 600 nm, which is buffered from the values in the ACS Protocol Document, Rev Q.
17
+
18
+ :param a_wavelengths: Absorption wavelengths
19
+ :param c_wavelengths: Attenuation wavelengths
20
+ :return: The last wavelength index at the discontinuity.
21
+ """
22
+
23
+ # Copy and convert to numpy arrays because we are paranoid about global variables.
24
+ a_wavelengths = np.array(a_wavelengths).copy()
25
+ c_wavelengths = np.array(c_wavelengths).copy()
26
+
27
+ # Set values outside the range to NaN
28
+ a_wavelengths[(a_wavelengths < min_band) | (a_wavelengths > max_band)] = np.nan
29
+ c_wavelengths[(c_wavelengths < min_band) | (c_wavelengths > max_band)] = np.nan
30
+
31
+ # Find the index of the discontinuity
32
+ didx = int(np.nanargmin(np.diff(a_wavelengths) + np.diff(c_wavelengths)))
33
+ return didx
34
+
35
+
36
+
37
+ def _compute_discontinuity_offset(values: Union[list, tuple, np.array],
38
+ wavelength: Union[list, tuple, np.array],
39
+ didx: int) -> float:
40
+ """
41
+ This code is modified from the OPTAA processing utilities in the ooi-data-explorations repo.
42
+ https://github.com/IanTBlack/ooi-data-explorations/blob/master/python/ooi_data_explorations/uncabled/utilities/utilities_optaa.py#L212
43
+
44
+ Compute the scalar discontinuity offset to be applied to the second half of an ACS spectra.
45
+ NOTE: If the input values contain an inf value, the function will return -999. This is to prevent math errors associated with creating a cubic spline on infinite values.
46
+ Spectra with infinite values should be removed at some point in the processing pipeline.
47
+
48
+ :param values: The incoming absorption or attenuation values. It is highly recommended that these values be
49
+ representative of ACS data that have been converted to 'geophysical' units (1/m) and corrected for the effects
50
+ of internal temperature on output. That is to say, the recommended input is the measured (a_m and c_m) in the
51
+ ACS protocol documents and manual. acspype inputs would be a_m_discontinuity and c_m_discontinuity.
52
+ :param wavelength: The wavelength bins of the values.
53
+ :param didx: The index of discontinuity.
54
+ :return: The discontinuity offset for the second half of the spectrum.
55
+ """
56
+ _wavelength = np.copy(wavelength)
57
+ _values = np.copy(values)
58
+ _didx = int(np.copy(didx))
59
+
60
+ x = _wavelength[_didx - 2:_didx + 1]
61
+ y = _values[_didx - 2:_didx + 1]
62
+
63
+ if True in np.isinf(y):
64
+ return -999
65
+ else:
66
+ cubic_spline = CubicSpline(x, y)
67
+ interp = cubic_spline(_wavelength[_didx + 1], extrapolate=True)
68
+ offset = interp - _values[_didx + 1]
69
+ return offset
70
+
71
+
72
+ def _apply_discontinuity_offset(values: Union[list, tuple, np.array],
73
+ offset: float,
74
+ didx: int) -> np.array:
75
+
76
+ """
77
+ This code is modified from the OPTAA processing utilities in the ooi-data-explorations repo.
78
+ https://github.com/IanTBlack/ooi-data-explorations/blob/master/python/ooi_data_explorations/uncabled/utilities/utilities_optaa.py#L212
79
+
80
+ Apply a pre-determined discontinuity offset to values after the discontinuity index.
81
+
82
+ :param values: The measured values to apply the discontinuity offset to.
83
+ :param offset: The scalar offset to apply to the values after the discontinuity index.
84
+ :param didx: The discontinuity index computed from find_discontinuity_index.
85
+ :return: A discontinuity-corrected spectra.
86
+ """
87
+
88
+ _values = np.copy(values)
89
+ _offset = np.copy(offset)
90
+ _didx = int(np.copy(didx))
91
+ _values[_didx + 1:] = _values[_didx + 1:] + _offset
92
+ return _values
93
+
94
+
95
+ def compute_discontinuity_offset(measured, wavelength_dim, discontinuity_index):
96
+ """
97
+ This is a wrapper function for _compute_discontinuity_offset that is vectorized for Xarray.
98
+
99
+ :param measured: The measured values
100
+ :param wavelength_dim: The dimension to calculate the offset on.
101
+ :param discontinuity_index
102
+ :return: The scalar offset for a given spectrum.
103
+ """
104
+
105
+ offset = xr.apply_ufunc(_compute_discontinuity_offset, measured,
106
+ kwargs = {'wavelength': measured[wavelength_dim].values, 'didx': discontinuity_index},
107
+ input_core_dims = [[wavelength_dim]],
108
+ output_core_dims = [[]],
109
+ vectorize = True)
110
+ return offset
111
+
112
+
113
+ def apply_discontinuity_offset(measured, offset, wavelength_dim, discontinuity_index):
114
+ """
115
+ This is a wrapper function for _apply_discontinuity_offset that is vectorized for Xarray.
116
+ :param measured: The measured values.
117
+ :param offset: The pre-determined discontinuity offset.
118
+ :param wavelength_dim: The wavelength dimension to apply the offset to.
119
+ :param discontinuity_index: The index of discontinuity.
120
+ :return: Spectra with the discontinuity offset applied.
121
+ """
122
+
123
+ dc = xr.apply_ufunc(_apply_discontinuity_offset, measured, offset,
124
+ kwargs = {'didx': discontinuity_index},
125
+ input_core_dims=[[wavelength_dim],[]],
126
+ output_core_dims = [[wavelength_dim]],
127
+ vectorize = True)
128
+ return dc
129
+
130
+
131
+ def discontinuity_correction(measured, wavelength_dim, discontinuity_index):
132
+ """
133
+ This is a convenience function for computing the discontinuity offset and applying it to the measured values in Xarray.
134
+ :param measured: The measured values.
135
+ :param wavelength_dim: The wavelength dimension to apply the correction to.
136
+ :param discontinuity_index: The index of discontinuity.
137
+ :return:
138
+ """
139
+
140
+ offset = compute_discontinuity_offset(measured, wavelength_dim, discontinuity_index)
141
+ dc = apply_discontinuity_offset(measured, offset, wavelength_dim, discontinuity_index)
142
+ return dc, offset
@@ -0,0 +1,64 @@
1
+ import xarray as xr
2
+
3
+
4
+ def compute_chl_alh(absorption: xr.DataArray, alh_star: float = 0.0104) -> xr.DataArray:
5
+ """
6
+ Compute chlorophyll-a from absorption line height via Roesler and Barnard 2013.
7
+ https://www.sciencedirect.com/science/article/pii/S2211122013000509
8
+
9
+ :param absorption: Absorption data with wavelength as a coordinate.
10
+ :param alh_star: Absorption line height coefficient value, default is 0.0104,
11
+ which is the average from Table 1 in Roesler and Barnard 2013.
12
+ :return: Chlorophyll-a concentration in mg/m^3.
13
+ """
14
+
15
+ a650 = absorption.sel(wavelength=650, method='nearest')
16
+ a676 = absorption.sel(wavelength=676, method='nearest')
17
+ a715 = absorption.sel(wavelength=715, method='nearest')
18
+
19
+ abl = ((a715 - a650) / (715 - 650)) * (676 - 650) + a650 #EQ 1 in Roesler and Barnard 2013
20
+ alh = a676 - abl #EQ 2 in Roesler and Barnard 2013
21
+ chl_alh = alh / alh_star #EQ 3 in Roesler and Barnard 2013
22
+
23
+ chl_alh.attrs['alh_star'] = alh_star
24
+ chl_alh.attrs['method'] = 'Roesler and Barnard, 2013'
25
+ chl_alh.attrs['ancillary_variables'] = str(absorption.name)
26
+ return chl_alh
27
+
28
+ #
29
+ # def compute_poc(attenuation: xr.DataArray, method = 'gardner_et_al_2006'):
30
+ # """
31
+ # Compute particulate organic carbon (POC) from attenuation based on linear models defined by the issued method.
32
+ #
33
+ # :param attenuation: The particulate attenuation coefficient (c_p).
34
+ # :param method: The method to use for POC calculation. Options are:
35
+ # gardner_et_al_2006
36
+ # behrenfeld_and_boss_2006
37
+ # stramski_et_al_2008
38
+ # cetenic_et_al_2012
39
+ # goni_et_al_2021 -> Oregon Coast, August, 2011, Sigma 22-23
40
+ # :return: POC in mg/m3
41
+ # """
42
+ #
43
+ # if method == 'gardner_et_al_2006':
44
+ # m = 381 #POC to c_p slope in units of mgC/m2
45
+ # c_p = attenuation.sel(wavelength=660, method='nearest')
46
+ # b = 9.4
47
+ # elif method == 'behrenfeld_and_boss_2006':
48
+ # m = 585
49
+ # c_p = attenuation.sel(wavelength=660, method='nearest')
50
+ # b = 7.6
51
+ # elif method == 'stramski_et_al_2008':
52
+ # m = 458
53
+ # c_p = attenuation.sel(wavelength=660, method='nearest')
54
+ # b = 0
55
+ # elif method == 'cetenic_et_al_2012':
56
+ # m = 391
57
+ # c_p = attenuation.sel(wavelength=660, method='nearest')
58
+ # b = -5.8
59
+ # elif method == 'goni_et_al_2021':
60
+ # m = 38.9* 12.01
61
+ # c_p = attenuation.sel(wavelength=650, method='nearest')
62
+ # b = 0
63
+ # poc = m * c_p + b
64
+ # return poc