Skip to content

Commit 6f806c8

Browse files
authored
Improve flexibility and robustness of Acoustics Module (#433)
There are three main changes in this pull request: 1. Added the option to determine the number of datapoints in each FFT when creating the sound pressure PSDs. This was currently hardcoded to the maximum FFT length, i.e. the total number of datapoints in each window, which might be more resolution and require more storage space than desired 2. Changed names of bin and windowing related attributes on the PSDs to make them easier to understand 3. Gain was improperly added - the sign has been corrected Minor changes are refactoring some of the argument type checks to clean them up.
1 parent f3b9780 commit 6f806c8

File tree

7 files changed

+76
-66
lines changed

7 files changed

+76
-66
lines changed

mhkit/acoustics/analysis.py

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@
5050
from mhkit.dolfyn.time import epoch2dt64, dt642epoch
5151

5252

53+
def _check_numeric(value, name: str):
54+
if np.issubdtype(type(value), np.ndarray):
55+
value = value.item()
56+
if not (
57+
isinstance(value, (int, float))
58+
or np.issubdtype(type(value), np.integer)
59+
or np.issubdtype(type(value), np.floating)
60+
):
61+
raise TypeError(f"{name} must be a numeric type (int or float).")
62+
63+
5364
def _fmax_warning(
5465
fn: Union[int, float, np.ndarray], fmax: Union[int, float, np.ndarray]
5566
) -> Union[int, float, np.ndarray]:
@@ -69,11 +80,6 @@ def _fmax_warning(
6980
The adjusted maximum frequency limit, ensuring it does not exceed the Nyquist frequency.
7081
"""
7182

72-
if not isinstance(fn, (int, float, np.ndarray)):
73-
raise TypeError("'fn' must be a numeric type (int or float).")
74-
if not isinstance(fmax, (int, float, np.ndarray)):
75-
raise TypeError("'fmax' must be a numeric type (int or float).")
76-
7783
if fmax > fn:
7884
warnings.warn(
7985
f"`fmax` = {fmax} is greater than the Nyquist frequency. Setting"
@@ -120,10 +126,8 @@ def minimum_frequency(
120126
if not np.issubdtype(water_depth.dtype, np.number):
121127
raise TypeError("'water_depth' must be a numeric type or array of numerics.")
122128

123-
if not isinstance(c, (int, float)):
124-
raise TypeError("'c' must be a numeric type (int or float).")
125-
if not isinstance(c_seabed, (int, float)):
126-
raise TypeError("'c_seabed' must be a numeric type (int or float).")
129+
_check_numeric(c, "c")
130+
_check_numeric(c_seabed, "c_seabed")
127131

128132
if np.any(water_depth <= 0):
129133
raise ValueError("All elements of 'water_depth' must be positive numbers.")
@@ -143,6 +147,7 @@ def sound_pressure_spectral_density(
143147
pressure: xr.DataArray,
144148
fs: Union[int, float],
145149
bin_length: Union[int, float] = 1,
150+
fft_length: Optional[Union[int, float]] = None,
146151
rms: bool = True,
147152
) -> xr.DataArray:
148153
"""
@@ -167,6 +172,8 @@ def sound_pressure_spectral_density(
167172
Data collection sampling rate [Hz]
168173
bin_length: int or float
169174
Length of time in seconds to create FFTs. Default: 1.
175+
fft_length: int or float, optional
176+
Length of FFT to use. If None, uses bin_length * fs. Default: None.
170177
rms: bool
171178
If True, calculates the mean-squared SPSD. Set to False to
172179
calculate standard SPSD. Default: True.
@@ -180,20 +187,23 @@ def sound_pressure_spectral_density(
180187
# Type checks
181188
if not isinstance(pressure, xr.DataArray):
182189
raise TypeError("'pressure' must be an xarray.DataArray.")
183-
if not isinstance(fs, (int, float)):
184-
raise TypeError("'fs' must be a numeric type (int or float).")
185-
if not isinstance(bin_length, (int, float)):
186-
raise TypeError("'bin_length' must be a numeric type (int or float).")
190+
_check_numeric(fs, "fs")
191+
_check_numeric(bin_length, "bin_length")
187192

188193
# Ensure that 'pressure' has a 'time' coordinate
189194
if "time" not in pressure.dims:
190195
raise ValueError("'pressure' must be indexed by 'time' dimension.")
191196

192197
# window length of each time series
193198
nbin = bin_length * fs
199+
if fft_length is not None:
200+
_check_numeric(fft_length, "fft_length")
201+
nfft = fft_length
202+
else:
203+
nfft = nbin
194204

195205
# Use dolfyn PSD functionality
196-
binner = VelBinner(n_bin=nbin, fs=fs, n_fft=nbin)
206+
binner = VelBinner(n_bin=nbin, fs=fs, n_fft=nfft)
197207
# Always 50% overlap if numbers reshape perfectly
198208
# Mean square sound pressure
199209
psd = binner.power_spectral_density(pressure, freq_units="Hz")
@@ -221,9 +231,9 @@ def sound_pressure_spectral_density(
221231
"units": pressure.units + "^2/Hz",
222232
"long_name": long_name,
223233
"fs": fs,
224-
"nbin": str(bin_length) + " s",
234+
"bin_length": bin_length,
225235
"overlap": "50%",
226-
"nfft": nbin,
236+
"n_fft": nfft,
227237
},
228238
)
229239

@@ -234,6 +244,7 @@ def apply_calibration(
234244
spsd: xr.DataArray,
235245
sensitivity_curve: xr.DataArray,
236246
fill_value: Union[float, int, np.ndarray],
247+
interp_method: str = "linear",
237248
) -> xr.DataArray:
238249
"""
239250
Applies custom calibration to spectral density values.
@@ -248,6 +259,9 @@ def apply_calibration(
248259
fill_value: float or int
249260
Value with which to fill missing values from the calibration curve,
250261
in units of dB rel 1 V^2/uPa^2.
262+
interp_method: str
263+
Interpolation method to use when interpolating the calibration curve
264+
to the frequencies in 'spsd'. Default is 'linear'.
251265
252266
Returns
253267
-------
@@ -259,8 +273,7 @@ def apply_calibration(
259273
raise TypeError("'spsd' must be an xarray.DataArray.")
260274
if not isinstance(sensitivity_curve, xr.DataArray):
261275
raise TypeError("'sensitivity_curve' must be an xarray.DataArray.")
262-
if not isinstance(fill_value, (int, float, np.ndarray)):
263-
raise TypeError("'fill_value' must be a numeric type (int or float).")
276+
_check_numeric(fill_value, "fill_value")
264277

265278
# Ensure 'freq' dimension exists in 'spsd'
266279
if "freq" not in spsd.dims:
@@ -295,7 +308,7 @@ def apply_calibration(
295308
freq = sensitivity_curve.dims[0]
296309
# Interpolate calibration curve to desired value
297310
calibration = sensitivity_curve.interp(
298-
{freq: spsd_calibrated["freq"]}, method="linear"
311+
{freq: spsd_calibrated["freq"]}, method=interp_method
299312
)
300313
# Fill missing with provided value
301314
calibration = calibration.fillna(fill_value)
@@ -340,6 +353,7 @@ def sound_pressure_spectral_density_level(spsd: xr.DataArray) -> xr.DataArray:
340353
attrs={
341354
"units": "dB re 1 uPa^2/Hz",
342355
"long_name": "Sound Pressure Spectral Density Level",
356+
"time_resolution": spsd.attrs["bin_length"],
343357
},
344358
)
345359

@@ -571,8 +585,8 @@ def band_aggregate(
571585
for val in octave:
572586
if not isinstance(val, int) or (val <= 0):
573587
raise TypeError("'octave' must contain positive integers.")
574-
if not isinstance(fmin, int) or (fmin <= 0):
575-
raise TypeError("'fmin' must be a positive integer.")
588+
_check_numeric(fmin, "fmin")
589+
_check_numeric(fmax, "fmax")
576590
if fmax <= fmin: # also checks that fmax is positive
577591
raise ValueError("'fmax' must be greater than 'fmin'.")
578592

mhkit/acoustics/graphics.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import xarray as xr
2525
import matplotlib.pyplot as plt
2626

27-
from .analysis import _fmax_warning
27+
from .analysis import _fmax_warning, _check_numeric
2828

2929

3030
def plot_spectrogram(
@@ -61,8 +61,9 @@ def plot_spectrogram(
6161
Figure plot axis
6262
"""
6363

64-
if not isinstance(fmin, (int, float)) or not isinstance(fmax, (int, float)):
65-
raise TypeError("fmin and fmax must be numeric types.")
64+
# Type checks
65+
_check_numeric(fmin, "fmin")
66+
_check_numeric(fmax, "fmax")
6667
if fmin >= fmax:
6768
raise ValueError("fmin must be less than fmax.")
6869

@@ -128,8 +129,8 @@ def plot_spectra(
128129
Figure plot axis
129130
"""
130131

131-
if not isinstance(fmin, (int, float)) or not isinstance(fmax, (int, float)):
132-
raise TypeError("fmin and fmax must be numeric types.")
132+
_check_numeric(fmin, "fmin")
133+
_check_numeric(fmax, "fmax")
133134
if fmin >= fmax:
134135
raise ValueError("fmin must be less than fmax.")
135136

mhkit/acoustics/io.py

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
import xarray as xr
5050
from scipy.io import wavfile
5151

52+
from mhkit.acoustics.analysis import _check_numeric
53+
5254

5355
def _read_wav_metadata(f: BinaryIO) -> dict:
5456
"""
@@ -132,10 +134,9 @@ def _calculate_voltage_and_time(
132134
raise TypeError("Raw audio data 'raw' must be a numpy.ndarray.")
133135
if not isinstance(bits_per_sample, int):
134136
raise TypeError("'bits_per_sample' must be an integer.")
135-
if not isinstance(peak_voltage, (int, float)):
136-
raise TypeError("'peak_voltage' must be numeric (int or float).")
137137
if not isinstance(start_time, (str, np.datetime64)):
138138
raise TypeError("'start_time' must be a string or np.datetime64.")
139+
_check_numeric(peak_voltage, "peak_voltage")
139140

140141
length = raw.shape[0] // fs # length of recording in seconds
141142

@@ -156,7 +157,7 @@ def _calculate_voltage_and_time(
156157
raw_voltage = raw.astype(float) / max_count * peak_voltage
157158

158159
# Get time
159-
end_time = np.datetime64(start_time) + np.timedelta64(length * 1000, "ms")
160+
end_time = np.datetime64(start_time) + np.timedelta64(length * 1000000000, "ns")
160161
time = pd.date_range(start_time, end_time, raw.size + 1)
161162

162163
return raw_voltage, time, max_count
@@ -196,15 +197,12 @@ def read_hydrophone(
196197

197198
if not isinstance(filename, (str, Path)):
198199
raise TypeError("Filename must be a string or a pathlib.Path object.")
199-
if not isinstance(peak_voltage, (int, float)):
200-
raise TypeError("'peak_voltage' must be numeric (int or float).")
201-
if sensitivity is not None and not isinstance(sensitivity, (int, float)):
202-
raise TypeError("'sensitivity' must be numeric (int, float) or None.")
203-
if not isinstance(gain, (int, float)):
204-
raise TypeError("'gain' must be numeric (int or float).")
200+
if sensitivity is not None:
201+
_check_numeric(sensitivity, "sensitivity")
202+
_check_numeric(peak_voltage, "peak_voltage")
203+
_check_numeric(gain, "gain")
205204
if not isinstance(start_time, (str, np.datetime64)):
206205
raise TypeError("'start_time' must be a string or np.datetime64")
207-
208206
if (sensitivity is not None) and (sensitivity > 0):
209207
raise ValueError(
210208
"Hydrophone calibrated sensitivity should be entered as a negative number."
@@ -225,9 +223,9 @@ def read_hydrophone(
225223
# If sensitivity is provided, convert to sound pressure
226224
if sensitivity is not None:
227225
# Subtract gain
228-
# Hydrophone with sensitivity of -177 dB and gain of -3 dB = sensitivity of -174 dB
226+
# Hydrophone with sensitivity of -177 dB and gain of 3 dB = sensitivity of -174 dB
229227
if gain:
230-
sensitivity -= gain
228+
sensitivity += gain
231229
# Convert calibration from dB rel 1 V/uPa into ratio
232230
sensitivity = 10 ** (sensitivity / 20) # V/uPa
233231

@@ -511,25 +509,22 @@ def export_audio(
511509
-------
512510
None
513511
"""
512+
514513
if not isinstance(filename, str):
515514
raise TypeError("'filename' must be a string.")
516-
517515
if not isinstance(pressure, xr.DataArray):
518516
raise TypeError("'pressure' must be an xarray.DataArray.")
519-
520517
if not hasattr(pressure, "values") or not isinstance(pressure.values, np.ndarray):
521518
raise TypeError("'pressure.values' must be a numpy.ndarray.")
522-
523-
if not hasattr(pressure, "sensitivity") or not isinstance(
524-
pressure.sensitivity, (int, float)
525-
):
526-
raise TypeError("'pressure.sensitivity' must be a numeric type (int or float).")
527-
528-
if not hasattr(pressure, "fs") or not isinstance(pressure.fs, (int, float)):
529-
raise TypeError("'pressure.fs' must be a numeric type (int or float).")
530-
531-
if not isinstance(gain, (int, float)):
532-
raise TypeError("'gain' must be a numeric type (int or float).")
519+
if hasattr(pressure, "sensitivity"):
520+
_check_numeric(pressure.sensitivity, "pressure.sensitivity")
521+
else:
522+
raise AttributeError("'pressure' must have a 'sensitivity' attribute.")
523+
if hasattr(pressure, "fs"):
524+
_check_numeric(pressure.fs, "pressure.fs")
525+
else:
526+
raise AttributeError("'pressure' must have a 'fs' attribute.")
527+
_check_numeric(gain, "gain")
533528

534529
# Convert from Pascals to UPa
535530
upa = pressure.values.T * 1e6

mhkit/acoustics/sel.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,7 @@ def sound_exposure_level(
134134
exposure = np.trapezoid(band * w, band["freq"])
135135

136136
# Sound exposure level (L_{E,p}) = (L_{p,rms} + 10log10(t))
137-
sel = 10 * np.log10(exposure / reference) + 10 * np.log10(
138-
spsd.attrs["nfft"] / spsd.attrs["fs"] # n_points / (n_points/s)
139-
)
137+
sel = 10 * np.log10(exposure / reference) + 10 * np.log10(spsd.attrs["bin_length"])
140138

141139
out = xr.DataArray(
142140
sel.astype(np.float32),
@@ -145,7 +143,7 @@ def sound_exposure_level(
145143
"units": "dB re 1 uPa^2 s",
146144
"long_name": long_name,
147145
"weighting_group": group,
148-
"integration_time": spsd.attrs["nbin"],
146+
"integration_time": spsd.attrs["bin_length"],
149147
"freq_band_min": fmin,
150148
"freq_band_max": fmax,
151149
},

mhkit/acoustics/spl.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import numpy as np
2020
import xarray as xr
2121

22-
from .analysis import _fmax_warning, _create_frequency_bands
22+
from .analysis import _check_numeric, _fmax_warning, _create_frequency_bands
2323

2424

2525
def _argument_check(spsd, fmin, fmax):
@@ -45,10 +45,8 @@ def _argument_check(spsd, fmin, fmax):
4545
# Type checks
4646
if not isinstance(spsd, xr.DataArray):
4747
raise TypeError("'spsd' must be an xarray.DataArray.")
48-
if not isinstance(fmin, int):
49-
raise TypeError("'fmin' must be an integer.")
50-
if not isinstance(fmax, int):
51-
raise TypeError("'fmax' must be an integer.")
48+
_check_numeric(fmin, "fmin")
49+
_check_numeric(fmax, "fmax")
5250

5351
# Ensure 'freq' and 'time' dimensions are present
5452
if ("freq" not in spsd.dims) or ("time" not in spsd.dims):
@@ -59,9 +57,9 @@ def _argument_check(spsd, fmin, fmax):
5957
raise ValueError(
6058
"'spsd' must have 'fs' (sampling frequency) in its attributes."
6159
)
62-
if "nfft" not in spsd.attrs:
60+
if "n_fft" not in spsd.attrs:
6361
raise ValueError(
64-
"'spsd' must have 'nfft' (sampling frequency) in its attributes."
62+
"'spsd' must have 'n_fft' (number of points in each FFT) in its attributes."
6563
)
6664

6765
# Value checks
@@ -119,6 +117,7 @@ def sound_pressure_level(
119117
attrs={
120118
"units": "dB re 1 uPa",
121119
"long_name": "Sound Pressure Level",
120+
"time_resolution": spsd.attrs["bin_length"],
122121
"freq_band_min": fmin,
123122
"freq_band_max": fmax,
124123
},
@@ -235,6 +234,7 @@ def third_octave_sound_pressure_level(
235234
{
236235
"units": "dB re 1 uPa",
237236
"long_name": "Third Octave Sound Pressure Level",
237+
"time_resolution": spsd.attrs["bin_length"],
238238
}
239239
)
240240

@@ -272,6 +272,7 @@ def decidecade_sound_pressure_level(
272272
{
273273
"units": "dB re 1 uPa",
274274
"long_name": "Decidecade Sound Pressure Level",
275+
"time_resolution": spsd.attrs["bin_length"],
275276
}
276277
)
277278

mhkit/tests/acoustics/test_analysis.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ def test_sound_pressure_spectral_density(self):
5252
self.assertIn("freq", spsd.dims)
5353
self.assertIn("time", spsd.dims)
5454
self.assertEqual(spsd.attrs["units"], "Pa^2/Hz")
55-
self.assertEqual(spsd.attrs["nbin"], f"{bin_length} s")
55+
self.assertEqual(spsd.attrs["bin_length"], bin_length)
56+
self.assertEqual(spsd.attrs["n_fft"], bin_length * fs)
5657

5758
# Calculate expected number of segments
5859
overlap = 0.0

mhkit/tests/acoustics/test_io.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ def test_calculate_voltage_and_time(self):
5353
np.testing.assert_allclose(raw_voltage, expected_raw_voltage, atol=1e-6)
5454

5555
# Expected time array
56-
time_interval = pd.Timedelta(seconds=1 / fs)
57-
expected_time = pd.date_range(
58-
start=start_time, periods=len(raw) + 1, freq=time_interval
56+
end_time = np.datetime64(start_time) + np.timedelta64(
57+
raw.size * 1000000000, "ns"
5958
)
59+
expected_time = pd.date_range(start_time, end_time, raw.size + 1)
6060
pd.testing.assert_index_equal(time, expected_time)
6161

6262
def test_read_iclisten_metadata(self):

0 commit comments

Comments
 (0)