Skip to content

Commit ae911de

Browse files
authored
Support for EK80 Broadband Complex Multiplex Data (#1302)
* add modifications to work with ek80 bb complex multiplex data * let convolve choose fft vs dicrete, use drop_ var * add test for bb complex multiplex ek80 data * use drop * add test for env params when dropna has only 1 value * revert small change * add changes for convolving nans efficiently and handling nans in gain compensation * use floats not ints in convolve, move convolve test to appropriate test file * NaN to zeros -> zero out NaNs * remove mark test * unit test assertion message correction * use the original lambda function and do the nan zero nan operation in compress pulse, place the atol 1e-5 back * fix lazy loading
1 parent 1f50c5f commit ae911de

File tree

6 files changed

+113
-15
lines changed

6 files changed

+113
-15
lines changed

echopype/calibrate/calibrate_ek.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,10 @@ def _get_B_theta_phi_m(self):
473473
) ** 2
474474
B_theta_phi_m = 0.5 * 6.0206 * (fac_along + fac_athwart - 0.18 * fac_along * fac_athwart)
475475

476+
# Zero out NaNs that appear due to 1) multiplex ping patterns and 2) single beam transducer
477+
# systems that don't contain angle offset information.
478+
B_theta_phi_m = B_theta_phi_m.fillna(0)
479+
476480
return B_theta_phi_m
477481

478482
def _cal_complex_samples(self, cal_type: str) -> xr.Dataset:

echopype/calibrate/ek80_complex.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,10 @@ def get_transmit_signal(
226226
# but keeping this here for use as standalone function
227227
if waveform_mode == "BB" and np.all(beam["transmit_type"] == "CW"):
228228
raise TypeError("File does not contain BB mode complex samples!")
229-
230229
# Generate all transmit replica
231230
y_all = {}
232231
y_time_all = {}
233-
# TODO: expand to deal with the case with varying tx param across ping_time
232+
# TODO: expand to deal with the case with varying non-NaN tx param across ping_time
234233
tx_param_names = [
235234
"transmit_duration_nominal",
236235
"slope",
@@ -240,16 +239,18 @@ def get_transmit_signal(
240239
for ch in beam["channel"].values:
241240
tx_params = {}
242241
for p in tx_param_names:
243-
tx_params[p] = np.unique(beam[p].sel(channel=ch))
242+
# Extract beam values and filter out NaNs
243+
beam_values = np.unique(beam[p].sel(channel=ch))
244+
# Filter out NaN values
245+
beam_values_without_nan = beam_values[~np.isnan(beam_values)]
246+
tx_params[p] = beam_values_without_nan
244247
if tx_params[p].size != 1:
245248
raise TypeError("File contains changing %s!" % p)
246249
fs_chan = fs.sel(channel=ch).data if isinstance(fs, xr.DataArray) else fs
247250
tx_params["fs"] = fs_chan
248251
y_ch, _ = tapered_chirp(**tx_params)
249-
250252
# Filter and decimate chirp template
251253
y_ch, y_tmp_time = filter_decimate_chirp(coeff_ch=coeff[ch], y_ch=y_ch, fs=fs_chan)
252-
253254
# Fill into output dict
254255
y_all[ch] = y_ch
255256
y_time_all[ch] = y_tmp_time
@@ -278,9 +279,21 @@ def compress_pulse(backscatter: xr.DataArray, chirp: Dict) -> xr.DataArray:
278279
# Select channel `chan` and drop the specific beam dimension if all of the values are nan.
279280
backscatter_chan = backscatter.sel(channel=chan).dropna(dim="beam", how="all")
280281

282+
# Create NaN mask
283+
# If `backscatter_chan` is lazy loaded, then `nan_mask` too will be lazy loaded.
284+
nan_mask = np.isnan(backscatter_chan)
285+
286+
# Zero out backscatter NaN values
287+
# If `nan_mask` is lazy loaded, then resulting `backscatter_chan` will be lazy loaded.
288+
backscatter_chan = xr.where(nan_mask, 0.0 + 0j, backscatter_chan)
289+
290+
# Extract transmit values
281291
tx = chirp[str(chan.values)]
292+
293+
# Compute complex conjugate of transmit values and reverse order of elements
282294
replica = np.flipud(np.conj(tx))
283295

296+
# Apply convolve on backscatter (along range sample dimension) and replica
284297
pc = xr.apply_ufunc(
285298
lambda m: (signal.convolve(m, replica, mode="full")[replica.size - 1 :]),
286299
backscatter_chan,
@@ -291,6 +304,11 @@ def compress_pulse(backscatter: xr.DataArray, chirp: Dict) -> xr.DataArray:
291304
output_dtypes=[np.complex64],
292305
).compute()
293306

307+
# Restore NaN values in the resulting array.
308+
# Computing of `nan_mask` here is necessary in the case when `nan_mask` is lazy loaded
309+
# or else the resulting `pc` will also be lazy loaded.
310+
pc = xr.where(nan_mask.compute(), np.nan, pc)
311+
294312
pc_all.append(pc)
295313

296314
pc_all = xr.concat(pc_all, dim="channel")

echopype/calibrate/env_params.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,13 @@ def harmonize_env_param_time(
5050
if "time1" not in p.coords:
5151
return p
5252

53-
# If there's only 1 time1 value,
54-
# or if after dropping NaN there's only 1 time1 value
55-
if p["time1"].size == 1 or p.dropna(dim="time1").size == 1:
56-
return p.dropna(dim="time1").squeeze(dim="time1").drop("time1")
53+
# If there's only 1 time1 value:
54+
if p["time1"].size == 1:
55+
return p.squeeze(dim="time1").drop_vars("time1")
56+
57+
# If after dropping NaN along time1 dimension there's only 1 time1 value:
58+
if p.dropna(dim="time1").size == 1:
59+
return p.dropna(dim="time1").squeeze(dim="time1").drop_vars("time1")
5760

5861
if ping_time is None:
5962
raise ValueError(

echopype/tests/calibrate/test_calibrate_ek80.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ def test_ek80_BB_range(ek80_cal_path, ek80_ext_path):
177177
pyel_vals = pyel_BB_p_data["range"]
178178
assert np.allclose(pyel_vals, ep_vals)
179179

180-
181180
@pytest.mark.parametrize(
182181
("raw_data_path,raw_file_name,pyecholab_data_path,pyecholab_file_path, dask_array"),
183182
[
@@ -253,7 +252,6 @@ def test_ek80_BB_power_from_complex(
253252
)
254253
assert np.allclose(pyel_vals[idx_to_cmp], ep_vals[idx_to_cmp])
255254

256-
257255
@pytest.mark.parametrize(
258256
("raw_data_path,raw_file_name,pyecholab_data_path,pyecholab_file_path, dask_array"),
259257
[
@@ -371,10 +369,9 @@ def test_ek80_BB_power_echoview(ek80_path):
371369
# Below only compare the first ping
372370
ev_vals = df_real.values[:, :]
373371
ep_vals = pc_mean.values.real[:, :]
374-
assert np.allclose(ev_vals[:, 69:8284], ep_vals[:, 69:], atol=1e-4)
375-
assert np.allclose(ev_vals[:, 90:8284], ep_vals[:, 90:], atol=1e-5)
372+
assert np.allclose(ev_vals[:, 69:], ep_vals[:, 69:], atol=1e-4)
373+
assert np.allclose(ev_vals[:, 90:], ep_vals[:, 90:], atol=1e-5)
376374

377-
378375
def test_ek80_CW_complex_Sv_receiver_sampling_freq(ek80_path):
379376
ek80_raw_path = str(ek80_path.joinpath("D20230804-T083032.raw"))
380377
ed = ep.open_raw(ek80_raw_path, sonar_model="EK80")
@@ -390,3 +387,67 @@ def test_ek80_CW_complex_Sv_receiver_sampling_freq(ek80_path):
390387
# receiver_sampling_frequency substituted with default value in compute_Sv
391388
assert ds_Sv["receiver_sampling_frequency"] is not None
392389
assert np.allclose(ds_Sv["receiver_sampling_frequency"].data, 1500000)
390+
391+
392+
@pytest.mark.parametrize(
393+
("raw_data_path,target_channel_ping_pattern"),
394+
[
395+
(
396+
"NYOS2105-D20210525-T213648.raw",
397+
# (3,4), 2, 1, (3,4), 2, 1, etc...
398+
np.array(
399+
[
400+
[np.nan, np.nan, 1., np.nan, np.nan, 1., np.nan, np.nan, 1., np.nan],
401+
[np.nan, 1., np.nan, np.nan, 1., np.nan, np.nan, 1., np.nan, np.nan],
402+
[ 1., np.nan, np.nan, 1., np.nan, np.nan, 1., np.nan, np.nan, 1.],
403+
[ 1., np.nan, np.nan, 1., np.nan, np.nan, 1., np.nan, np.nan, 1.]
404+
]
405+
),
406+
),
407+
(
408+
"DRIX08-D20231003-T120051.raw",
409+
# 2, 1, 2, 1, 2, 1, etc...
410+
np.array(
411+
[
412+
[np.nan, 1., np.nan, 1, np.nan, 1, np.nan, 1., np.nan, 1],
413+
[1, np.nan, 1., np.nan, 1, np.nan, 1, np.nan, 1., np.nan],
414+
]
415+
),
416+
),
417+
],
418+
)
419+
@pytest.mark.integration
420+
def test_ek80_BB_complex_multiplex_NaNs_and_non_NaNs(raw_data_path, target_channel_ping_pattern):
421+
# Extract bb complex multiplex EK80 data
422+
ed = ep.open_raw(f"echopype/test_data/ek80_bb_complex_multiplex/{raw_data_path}", sonar_model="EK80")
423+
424+
# Compute Sv
425+
ds_Sv = ep.calibrate.compute_Sv(ed,waveform_mode='BB',encode_mode='complex')
426+
427+
# Extract mask for both real backscatter and calibrated Sv:
428+
429+
# EchoData Real (component) Backscatter mask should be 1 if all values along
430+
# a specific range sample and beam dimension are non-NaN; else NaN.
431+
ed_backscatter_r_mask = xr.where(
432+
~np.isnan(ed["Sonar/Beam_group1"]["backscatter_r"]
433+
).all(dim=['range_sample', "beam"]), 1, np.nan)
434+
# Calibrated Sv mask should be 1 if all values along
435+
# a specific range sample are non-NaN; else NaN.
436+
calibrated_Sv_mask = xr.where(
437+
~np.isnan(ds_Sv["Sv"]
438+
).all(dim=['range_sample']), 1, np.nan)
439+
440+
# Check that they are equal
441+
assert np.array_equal(
442+
ed_backscatter_r_mask.data,
443+
calibrated_Sv_mask.data,
444+
equal_nan=True
445+
)
446+
447+
# Check that a slice of calibrated Sv mask is equal to the following array
448+
# with the target channel ping pattern:
449+
assert np.array_equal(
450+
calibrated_Sv_mask.isel(ping_time=slice(0,10)).data,
451+
target_channel_ping_pattern,
452+
equal_nan=True
453+
)

echopype/tests/calibrate/test_ek80_complex.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,4 @@ def test_get_vend_filter_EK80(ch_num, filter_len, has_nan):
7070

7171
assert sel_vend[var_df].values == get_vend_filter_EK80(
7272
vend, channel_id=ch, filter_name=filter_name, param_type="decimation"
73-
)
73+
)

echopype/tests/calibrate/test_env_params.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import dask.array
44
import numpy as np
55
import xarray as xr
6+
import pandas as pd
67

78
import echopype as ep
89
from echopype.calibrate.env_params import (
@@ -132,6 +133,17 @@ def test_harmonize_env_param_time():
132133
# .all computes dask array under the hood
133134
assert (p_new.data == [0.5, 2880.5]).all()
134135

136+
@pytest.mark.unit
137+
def test_harmonize_env_param_time_only_one_non_NaN_along_time1():
138+
# Create data array with time1 dimension with only 1 non-NaN value
139+
data = np.array([1, np.nan, np.nan])
140+
time = pd.date_range(start='2024-01-01', periods=3, freq='D')
141+
da = xr.DataArray(data, dims='time1', coords={'time1': time})
142+
143+
# Check that output da has only 1 value and no dimension
144+
output_da = harmonize_env_param_time(da, None)
145+
assert output_da == 1, "Output data array should just be 1"
146+
assert 'time1' not in output_da.dims, "```harmonize_env_param_time``` should have dropped 'time1' dimension"
135147

136148
@pytest.mark.parametrize(
137149
("user_dict", "channel", "out_dict"),

0 commit comments

Comments
 (0)