Skip to content

Commit 638876d

Browse files
authored
Acoustics SPL bugfix (#379)
Fixes a bug where calculating SPLs in lower frequency bands was failing because the bandwidth resolution was on par with the frequency resolution, i.e. numpy.trapz was failing to calculate the integral because the input was only 1D over frequency. Also expanded testing to cover this particular case.
1 parent 7c45c96 commit 638876d

File tree

2 files changed

+153
-84
lines changed

2 files changed

+153
-84
lines changed

mhkit/acoustics/analysis.py

Lines changed: 70 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,44 @@ def _validate_method(
473473
return method_name, method_arg
474474

475475

476+
def _create_frequency_bands(octave, fmin, fmax):
477+
"""
478+
Calculates frequency bands based on the specified octave, minimum and
479+
maximum frequency limits.
480+
481+
Parameters
482+
----------
483+
octave: int
484+
Octave to subdivide spectral density level by.
485+
fmin : int, optional
486+
Lower frequency band limit (lower limit of the hydrophone). Default is 10 Hz.
487+
fmax : int, optional
488+
Upper frequency band limit (Nyquist frequency). Default is 100,000 Hz.
489+
490+
Returns
491+
-------
492+
octave_bins: numpy.array
493+
Array of octave bin edges
494+
band: dict(str, numpy.array)
495+
Dictionary containing the frequency band edges and center frequency
496+
"""
497+
498+
bandwidth = 2 ** (1 / octave)
499+
half_bandwidth = 2 ** (1 / (octave * 2))
500+
501+
band = {}
502+
band["center_freq"] = 10 ** np.arange(
503+
np.log10(fmin),
504+
np.log10(fmax * bandwidth),
505+
step=np.log10(bandwidth),
506+
)
507+
band["lower_limit"] = band["center_freq"] / half_bandwidth
508+
band["upper_limit"] = band["center_freq"] * half_bandwidth
509+
octave_bins = np.append(band["lower_limit"], band["upper_limit"][-1])
510+
511+
return octave_bins, band
512+
513+
476514
def band_aggregate(
477515
spsdl: xr.DataArray,
478516
octave: int = 3,
@@ -531,18 +569,7 @@ def band_aggregate(
531569
fn = spsdl["freq"].max().values
532570
fmax = _fmax_warning(fn, fmax)
533571

534-
bandwidth = 2 ** (1 / octave)
535-
half_bandwidth = 2 ** (1 / (octave * 2))
536-
537-
band = {}
538-
band["center_freq"] = 10 ** np.arange(
539-
np.log10(fmin),
540-
np.log10(fmax * bandwidth),
541-
step=np.log10(bandwidth),
542-
)
543-
band["lower_limit"] = band["center_freq"] / half_bandwidth
544-
band["upper_limit"] = band["center_freq"] * half_bandwidth
545-
octave_bins = np.append(band["lower_limit"], band["upper_limit"][-1])
572+
octave_bins, band = _create_frequency_bands(octave, fmin, fmax)
546573

547574
# Use xarray binning methods
548575
spsdl_group = spsdl.groupby_bins("freq", octave_bins, labels=band["center_freq"])
@@ -732,8 +759,7 @@ def sound_pressure_level(
732759

733760
def _band_sound_pressure_level(
734761
spsd: xr.DataArray,
735-
bandwidth: int,
736-
half_bandwidth: int,
762+
octave: int,
737763
fmin: int = 10,
738764
fmax: int = 100000,
739765
) -> xr.DataArray:
@@ -744,10 +770,8 @@ def _band_sound_pressure_level(
744770
----------
745771
spsd: xarray.DataArray (time, freq)
746772
Mean square sound pressure spectral density.
747-
bandwidth : int or float
748-
Bandwidth to average over.
749-
half_bandwidth : int or float
750-
Half-bandwidth, used to set upper and lower bandwidth limits.
773+
octave: int
774+
Octave to subdivide spectral density level by.
751775
fmin : int, optional
752776
Lower frequency band limit (lower limit of the hydrophone). Default is 10 Hz.
753777
fmax : int, optional
@@ -763,10 +787,8 @@ def _band_sound_pressure_level(
763787
# Type checks
764788
if not isinstance(spsd, xr.DataArray):
765789
raise TypeError("'spsd' must be an xarray.DataArray.")
766-
if not isinstance(bandwidth, (int, float)):
767-
raise TypeError("'bandwidth' must be a numeric type (int or float).")
768-
if not isinstance(half_bandwidth, (int, float)):
769-
raise TypeError("'half_bandwidth' must be a numeric type (int or float).")
790+
if not isinstance(octave, int) or (octave <= 0):
791+
raise TypeError("'octave' must be a positive integer.")
770792
if not isinstance(fmin, int):
771793
raise TypeError("'fmin' must be an integer.")
772794
if not isinstance(fmax, int):
@@ -795,28 +817,35 @@ def _band_sound_pressure_level(
795817
# Reference value of sound pressure
796818
reference = 1e-12 # Pa^2, = 1 uPa^2
797819

798-
band = {}
799-
band["center_freq"] = 10 ** np.arange(
800-
np.log10(fmin),
801-
np.log10(fmax * bandwidth),
802-
step=np.log10(bandwidth),
803-
)
804-
band["lower_limit"] = band["center_freq"] / half_bandwidth
805-
band["upper_limit"] = band["center_freq"] * half_bandwidth
806-
octave_bins = np.append(band["lower_limit"], band["upper_limit"][-1])
820+
_, band = _create_frequency_bands(octave, fmin, fmax)
807821

808822
# Manual trapezoidal rule to get Pa^2
809823
pressure_squared = xr.DataArray(
810824
coords={"time": spsd["time"], "freq_bins": band["center_freq"]},
811825
dims=["time", "freq_bins"],
812826
)
813827
for i, key in enumerate(band["center_freq"]):
814-
band_min = octave_bins[i]
815-
band_max = octave_bins[i + 1]
816-
pressure_squared.loc[{"freq_bins": key}] = np.trapz(
817-
spsd.sel(freq=slice(band_min, band_max)),
818-
spsd["freq"].sel(freq=slice(band_min, band_max)),
819-
)
828+
# Min and max band limits
829+
band_range = [band["lower_limit"][i], band["upper_limit"][i]]
830+
831+
# Integrate spectral density by frequency
832+
x = spsd["freq"].sel(freq=slice(*band_range))
833+
if len(x) < 2:
834+
# Interpolate between band frequencies if width is narrow
835+
bandwidth = band_range[1] / band_range[0]
836+
# Use smaller set of dataset to speed up interpolation
837+
spsd_slc = spsd.sel(
838+
freq=slice(
839+
None, # Only happens at low frequency
840+
band_range[1] * bandwidth * 2,
841+
)
842+
)
843+
spsd_slc = spsd_slc.interp(freq=band_range)
844+
x = band_range
845+
else:
846+
spsd_slc = spsd.sel(freq=slice(*band_range))
847+
848+
pressure_squared.loc[{"freq_bins": key}] = np.trapz(spsd_slc, x)
820849

821850
# Mean square sound pressure level in dB rel 1 uPa
822851
mspl = 10 * np.log10(pressure_squared / reference)
@@ -845,12 +874,8 @@ def third_octave_sound_pressure_level(
845874
mspl: xarray.DataArray (time, freq_bins)
846875
Sound pressure level [dB re 1 uPa] indexed by time and third octave bands
847876
"""
848-
849-
# Third octave bin frequencies
850-
bandwidth = 2 ** (1 / 3)
851-
half_bandwidth = 2 ** (1 / 6)
852-
853-
mspl = _band_sound_pressure_level(spsd, bandwidth, half_bandwidth, fmin, fmax)
877+
octave = 3
878+
mspl = _band_sound_pressure_level(spsd, octave, fmin, fmax)
854879
mspl.attrs = {
855880
"units": "dB re 1 uPa",
856881
"long_name": "Third Octave Sound Pressure Level",
@@ -881,11 +906,8 @@ def decidecade_sound_pressure_level(
881906
Sound pressure level [dB re 1 uPa] indexed by time and decidecade bands
882907
"""
883908

884-
# Decidecade bin frequencies
885-
bandwidth = 2 ** (1 / 10)
886-
half_bandwidth = 2 ** (1 / 20)
887-
888-
mspl = _band_sound_pressure_level(spsd, bandwidth, half_bandwidth, fmin, fmax)
909+
octave = 10
910+
mspl = _band_sound_pressure_level(spsd, octave, fmin, fmax)
889911
mspl.attrs = {
890912
"units": "dB re 1 uPa",
891913
"long_name": "Decidecade Sound Pressure Level",

mhkit/tests/acoustics/test_analysis.py

Lines changed: 83 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def test_averaging(self):
5757

5858
# Frequency average into # octave bands
5959
octave = 3
60-
td_spsdl_mean = acoustics.band_aggregate(td_spsdl, octave, fmin=50)
60+
td_spsdl_mean = acoustics.band_aggregate(td_spsdl, octave, fmin=10, fmax=100000)
6161

6262
# Time average into 30 s bins
6363
lbin = 30
@@ -81,29 +81,29 @@ def test_averaging(self):
8181
)
8282
cd_spsdl_50 = np.array(
8383
[
84-
[73.71803613, 70.97557445, 69.79906778, 69.04934313, 67.56449352],
85-
[73.72245955, 71.53327285, 70.55206775, 68.69638127, 67.75243522],
86-
[73.64022645, 72.24548986, 70.09995522, 69.00394292, 68.22919418],
87-
[73.1301846, 71.99940268, 70.56372046, 69.01366589, 67.19515351],
88-
[74.67880072, 71.27235403, 70.23024477, 67.4915765, 66.73024553],
84+
[63.45507, 64.753525, 65.04905, 67.15576, 73.47938],
85+
[62.77437, 64.58199, 65.18464, 66.37395, 72.30796],
86+
[64.76277, 64.950264, 65.80557, 67.88482, 73.24013],
87+
[63.654488, 62.31394, 65.598816, 67.370674, 71.52472],
88+
[62.45623, 62.461388, 62.111694, 66.06419, 72.324936],
8989
]
9090
)
9191
cd_spsdl_25 = np.array(
9292
[
93-
[72.42136105, 70.37422873, 68.60783404, 67.56108417, 66.4751517],
94-
[71.95173902, 71.03281659, 69.59019407, 67.79615712, 66.73980611],
95-
[71.12756436, 70.68228634, 69.53891917, 68.126758, 67.48463198],
96-
[71.71909635, 70.1849931, 69.22647784, 68.14102709, 66.18740693],
97-
[72.25521793, 70.18087912, 68.97354823, 66.71295946, 65.35302077],
93+
[59.33189297, 62.89503765, 61.60455799, 64.80938911, 70.59576607],
94+
[60.37440872, 60.69928551, 61.9694643, 64.91986465, 70.00148964],
95+
[61.1297617, 63.02504444, 64.41207123, 66.37802315, 71.38513947],
96+
[59.52737236, 59.45869541, 62.48176765, 66.0959053, 70.06054497],
97+
[58.55439758, 59.88098335, 59.66310596, 63.86431885, 70.20335197],
9898
]
9999
)
100100
cd_spsdl_75 = np.array(
101101
[
102-
[75.29614796, 71.86901413, 71.08418954, 69.6835928, 68.26993291],
103-
[74.51608597, 72.82376854, 71.31219865, 70.38580566, 69.01731822],
104-
[75.17013043, 73.45962974, 71.30593827, 71.50687178, 69.49805535],
105-
[74.38176106, 73.13456376, 72.13861655, 70.45825381, 67.93458589],
106-
[75.52387419, 72.99604074, 71.26831962, 68.90629303, 67.79114848],
102+
[66.33672714, 67.13593102, 67.34234238, 68.7525959, 75.30982399],
103+
[64.58539009, 66.84792709, 67.11526108, 69.7322197, 74.50746346],
104+
[66.56425095, 67.85562325, 69.30602646, 69.83069992, 74.79984283],
105+
[67.34252357, 65.65701294, 67.48604202, 70.948246, 73.59340286],
106+
[66.26214409, 65.43437958, 64.36196518, 67.67719078, 74.33639717],
107107
]
108108
)
109109

@@ -118,13 +118,17 @@ def test_freq_loss(self):
118118
self.assertEqual(fmin, 39.84375)
119119

120120
def test_spl(self):
121-
td_spl = acoustics.sound_pressure_level(self.spsd, fmin=50)
121+
td_spl = acoustics.sound_pressure_level(self.spsd, fmin=10, fmax=100000)
122122

123123
# Decidecade octave sound pressure level
124-
td_spl10 = acoustics.decidecade_sound_pressure_level(self.spsd, fmin=50)
124+
td_spl10 = acoustics.decidecade_sound_pressure_level(
125+
self.spsd, fmin=10, fmax=100000
126+
)
125127

126128
# Median third octave sound pressure level
127-
td_spl3 = acoustics.third_octave_sound_pressure_level(self.spsd, fmin=50)
129+
td_spl3 = acoustics.third_octave_sound_pressure_level(
130+
self.spsd, fmin=10, fmax=100000
131+
)
128132

129133
cc = np.array(
130134
[
@@ -136,31 +140,74 @@ def test_spl(self):
136140
],
137141
dtype="datetime64[ns]",
138142
)
139-
cd_spl = np.array(
140-
[97.48727775, 98.21888437, 96.99586637, 97.43571891, 96.60915502]
143+
cd_spl_head = np.array([98.12284, 98.639824, 97.62718, 97.85709, 96.98539])
144+
cd_spl_tail = np.array([98.420975, 98.10879, 97.430115, 97.99395, 97.95798])
145+
146+
cd_spl10_freq_head = np.array(
147+
[10.0, 10.717735, 11.486984, 12.311444, 13.195079]
148+
)
149+
cd_spl10_head = np.array(
150+
[
151+
[64.40389, 64.78221, 63.64469, 67.782845, 73.05421],
152+
[56.934277, 62.80422, 66.329056, 67.336395, 65.79995],
153+
[67.16593, 69.089096, 69.5835, 67.69844, 63.657196],
154+
[66.010025, 67.567635, 68.16686, 66.72678, 64.52246],
155+
[63.887203, 68.73698, 72.71424, 72.98322, 69.08172],
156+
]
141157
)
142-
cd_spl10 = np.array(
158+
cd_spl10_freq_tail = np.array(
159+
[38217.031333, 40960.0, 43899.841025, 47050.684621, 50427.675171]
160+
)
161+
cd_spl10_tail = np.array(
143162
[
144-
[82.06503071, 78.20349846, 79.78088446, 75.31281183, 82.1194826],
145-
[82.66175023, 79.77804574, 82.86005403, 77.57078269, 76.7598224],
146-
[77.48975416, 82.72580274, 83.88251531, 74.71242694, 74.01377947],
147-
[79.11312683, 76.56114947, 82.18953494, 75.40888015, 74.80285354],
148-
[81.26751434, 82.29074565, 80.08831394, 75.75364773, 73.52176641],
163+
[77.38338, 73.43317, 72.7755, 72.53339, np.nan],
164+
[77.72596, 73.57787, 73.16561, 72.637436, np.nan],
165+
[77.61121, 73.59171, 73.20265, 72.57601, np.nan],
166+
[77.3753, 73.35718, 72.89339, 72.386765, np.nan],
167+
[77.31649, 73.806496, 73.296, 72.73348, np.nan],
149168
]
150169
)
151-
cd_spl3 = np.array(
170+
cd_spl3_freq_head = np.array([10.0, 12.59921, 15.874011, 20.0, 25.198421])
171+
cd_spl3_head = np.array(
152172
[
153-
[86.5847236, 84.98068691, 85.61056131, 83.55067796, 84.41810962],
154-
[87.5449842, 84.48841036, 84.09406069, 85.81895309, 86.71437852],
155-
[86.37334939, 84.08914125, 86.01614536, 83.36059983, 84.54635288],
156-
[84.21413445, 84.63996392, 82.52906024, 84.54731095, 83.45652422],
157-
[86.90033232, 84.8217658, 83.85297355, 82.92231618, 81.39163217],
173+
[68.88561, 75.65294, 68.29522, 75.80323, 82.53724],
174+
[62.806908, 69.76993, 62.64113, 73.26091, 83.27883],
175+
[71.73166, 68.541534, 68.056076, 75.438034, 84.268715],
176+
[70.84345, 68.65471, 63.4681, 72.818085, 77.38771],
177+
[69.23148, 74.04387, 64.49707, 74.146164, 79.52727],
178+
]
179+
)
180+
cd_spl3_freq_tail = np.array(
181+
[20480.0, 25803.183102, 32509.973544, 40960.0, 51606.366204]
182+
)
183+
cd_spl3_tail = np.array(
184+
[
185+
[80.37833, 81.21788, 83.5725, 80.37073, 72.06452],
186+
[81.848434, 81.772064, 83.928505, 80.70311, 72.164345],
187+
[81.13474, 81.67803, 83.96902, 80.6636, 72.07929],
188+
[81.532005, 81.694954, 83.796875, 80.38368, 71.94872],
189+
[80.70353, 81.6905, 83.76083, 80.53248, 72.248276],
158190
]
159191
)
160192

161-
np.testing.assert_allclose(td_spl.head().values, cd_spl, atol=1e-6)
162-
np.testing.assert_allclose(td_spl10.head().values, cd_spl10, atol=1e-6)
163-
np.testing.assert_allclose(td_spl3.head().values, cd_spl3, atol=1e-6)
193+
np.testing.assert_allclose(td_spl.head().values, cd_spl_head, atol=1e-6)
194+
np.testing.assert_allclose(td_spl.tail().values, cd_spl_tail, atol=1e-6)
195+
np.testing.assert_allclose(
196+
td_spl10["freq_bins"].head().values, cd_spl10_freq_head, atol=1e-6
197+
)
198+
np.testing.assert_allclose(td_spl10.head().values, cd_spl10_head, atol=1e-6)
199+
np.testing.assert_allclose(
200+
td_spl10["freq_bins"].tail().values, cd_spl10_freq_tail, atol=1e-6
201+
)
202+
np.testing.assert_allclose(td_spl10.tail().values, cd_spl10_tail, atol=1e-6)
203+
np.testing.assert_allclose(
204+
td_spl3["freq_bins"].head().values, cd_spl3_freq_head, atol=1e-6
205+
)
206+
np.testing.assert_allclose(td_spl3.head().values, cd_spl3_head, atol=1e-6)
207+
np.testing.assert_allclose(
208+
td_spl3["freq_bins"].tail().values, cd_spl3_freq_tail, atol=1e-6
209+
)
210+
np.testing.assert_allclose(td_spl3.tail().values, cd_spl3_tail, atol=1e-6)
164211
np.testing.assert_equal(td_spl["time"].head().values, cc)
165212

166213
def test_sound_pressure_spectral_density(self):

0 commit comments

Comments
 (0)