diff --git a/echopype/consolidate/__init__.py b/echopype/consolidate/__init__.py index acca09fd8..049d7ef11 100644 --- a/echopype/consolidate/__init__.py +++ b/echopype/consolidate/__init__.py @@ -1,3 +1,13 @@ -from .api import add_depth, add_location, add_splitbeam_angle, swap_dims_channel_frequency +from .api import ( + add_depth, + add_location, + add_splitbeam_angle, + swap_dims_channel_frequency, +) -__all__ = ["swap_dims_channel_frequency", "add_depth", "add_location", "add_splitbeam_angle"] +__all__ = [ + "swap_dims_channel_frequency", + "add_depth", + "add_location", + "add_splitbeam_angle", +] diff --git a/echopype/consolidate/api.py b/echopype/consolidate/api.py index 96659df90..489cda523 100644 --- a/echopype/consolidate/api.py +++ b/echopype/consolidate/api.py @@ -15,13 +15,21 @@ from ..utils.io import get_file_format, open_source from ..utils.log import _init_logger from ..utils.prov import add_processing_level -from .ek_depth_utils import ( +from .split_beam_angle import ( + get_angle_complex_samples, + get_angle_power_samples, +) +from .utils_ek_depth import ( ek_use_beam_angles, ek_use_platform_angles, ek_use_platform_vertical_offsets, ) -from .loc_utils import check_and_drop_loc_time_dim_duplicates, check_loc_vars_validity, sel_nmea -from .split_beam_angle import get_angle_complex_samples, get_angle_power_samples +from .utils_loc import ( + check_and_drop_loc_time_dim_duplicates, + check_loc_vars_validity, + get_dim_0, + sel_nmea, +) logger = _init_logger(__name__) @@ -30,7 +38,7 @@ def swap_dims_channel_frequency(ds: Union[xr.Dataset, str, pathlib.Path]) -> xr.Dataset: """ - Use frequency_nominal in place of channel to be dataset dimension and coorindate. + Use frequency_nominal in place of channel to be dataset dimension and coordinate. This is useful because the nominal transducer frequencies are commonly used to refer to data collected from a specific transducer. @@ -74,6 +82,8 @@ def add_depth( use_platform_vertical_offsets: bool = False, use_platform_angles: bool = False, use_beam_angles: bool = False, + waveform_mode: Optional[str] = None, + encode_mode: Optional[str] = None, ) -> xr.Dataset: """ Create a depth data variable based on data in Sv dataset, Echodata object, and/or @@ -110,6 +120,10 @@ def add_depth( Currently only implemented for EK60/EK80 sonar models. If `tilt` is specified, Beam group angle values will not be used. In the current implementation cannot be used in tandem with `use_platform_angles`. + waveform_mode : Optional[str], default None + Type of transmit waveform. Must be specified when `use_beam_angles` is True. + encode_mode : Optional[str], default None + Type of encode mode. Must be specified when `use_beam_angles` is True. Returns ------- @@ -118,6 +132,9 @@ def add_depth( # Open Sv dataset ds = open_source(ds, "dataset", {}) + # Check that `channel` or `frequency_nominal` is the first dimension in the Sv + _ = get_dim_0(ds["Sv"]) # return nothing since it's not used + # Raise `ValueError` if `echodata` is needed but not passed in if (not echodata) and (use_platform_vertical_offsets or use_platform_angles or use_beam_angles): raise ValueError( @@ -160,6 +177,18 @@ def add_depth( f"`use_platform/beam_...` not implemented yet for `{sonar_model}`." ) + # Raise error if sonar model EK80 and use_beam_angles is true but wave form and encode mode + # is not provided + if ( + use_beam_angles + and sonar_model == "EK80" + and (waveform_mode is None or encode_mode is None) + ): + raise ValueError( + "When `use_beam_angles` is True and sonar model is EK80, " + "both `waveform_mode` and `encode_mode` must be specified." + ) + # Initialize transducer depth to 0.0 (no effect on depth) transducer_depth = 0.0 if isinstance(depth_offset, Number): @@ -205,14 +234,12 @@ def add_depth( # Compute echo range scaling in EK systems using platform angle data echo_range_scaling = ek_use_platform_angles(echodata["Platform"], ds["ping_time"]) elif use_beam_angles: - # Identify beam group name by checking channel values of `ds` - if echodata["Sonar/Beam_group1"]["channel"].equals(ds["channel"]): - beam_group_name = "Beam_group1" - else: - beam_group_name = "Beam_group2" + # check that the appropriate waveform and encode mode have been given + # and obtain the echodata group path corresponding to encode_mode + ed_beam_group = retrieve_correct_beam_group(echodata, waveform_mode, encode_mode) # Compute echo range scaling in EK systems using beam angle data - echo_range_scaling = ek_use_beam_angles(echodata[f"Sonar/{beam_group_name}"]) + echo_range_scaling = ek_use_beam_angles(echodata[ed_beam_group]) # Set orientation multiplier. 1 if facing downwards, -1 if facing upwards orientation_mult = 1 if downward else -1 @@ -233,7 +260,7 @@ def add_depth( history_attr + f" Sv `echo_range`" f"{', Echodata `Platform` Vertical Offsets' if (used_platform_vertical_offsets) else ''}" f"{', Echodata `Platform` Angles' if (used_platform_angles) else ''}" - f"{', Echodata `%s` Angles' % (beam_group_name) if (used_beam_angles) else ''}" + f"{', Echodata `%s` Angles' % (ed_beam_group.replace('Sonar/', '')) if (used_beam_angles) else ''}" # noqa "." ) ds["depth"] = ds["depth"].assign_attrs({"history": history_attr}) @@ -287,6 +314,9 @@ def add_location( ds = open_source(ds, "dataset", {}) echodata = open_source(echodata, "echodata", {}) + # Check that `channel` or `frequency_nominal` is the first dimension in the Sv + _ = get_dim_0(ds["Sv"]) # return nothing since it's not used + # Grab lat lon names if echodata.sonar_model.startswith("EK") and datagram_type in ["MRU1", "IDX"]: lat_name = f"latitude_{datagram_type.lower()}" @@ -475,6 +505,12 @@ def add_splitbeam_angle( source_Sv = open_source(source_Sv, "dataset", storage_options) echodata = open_source(echodata, "echodata", storage_options) + # Check that `channel` or `frequency_nominal` is the first dimension in the Sv + dim_0 = get_dim_0(source_Sv["Sv"]) # return nothing since it's not used + + # Grab corresponding channel values + Sv_channels = source_Sv["channel"].values + # ensure that echodata was produced by EK60 or EK80-like sensors if echodata.sonar_model not in ["EK60", "ES70", "EK80", "ES80", "EA640"]: raise ValueError( @@ -490,12 +526,12 @@ def add_splitbeam_angle( # and obtain the echodata group path corresponding to encode_mode ed_beam_group = retrieve_correct_beam_group(echodata, waveform_mode, encode_mode) - # check that source_Sv at least has a channel dimension - if "channel" not in source_Sv.variables: - raise ValueError("The input source_Sv Dataset must have a channel dimension!") + # Select channels in selected beam group + ds_beam = echodata[ed_beam_group].sel({"channel": Sv_channels}) - # Select ds_beam channels from source_Sv - ds_beam = echodata[ed_beam_group].sel(channel=source_Sv["channel"].values) + # Swap dim for ds_beam if dim_0 is frequency_nominal + if dim_0 == "frequency_nominal": + ds_beam = swap_dims_channel_frequency(ds_beam) # Assemble angle param dict angle_param_list = [ @@ -512,10 +548,9 @@ def add_splitbeam_angle( raise ValueError(f"source_Sv does not contain the necessary parameter {p_name}!") # fail if source_Sv and ds_beam do not have the same lengths - # for ping_time, range_sample, and channel + # for dim_0, ping_time, range_sample same_size_lens = [ - ds_beam.sizes[dim] == source_Sv.sizes[dim] - for dim in ["channel", "ping_time", "range_sample"] + ds_beam.sizes[dim] == source_Sv.sizes[dim] for dim in [dim_0, "ping_time", "range_sample"] ] if not same_size_lens: raise ValueError( @@ -534,9 +569,7 @@ def add_splitbeam_angle( else: if pulse_compression: # with pulse compression # put receiver fs into the same dict for simplicity - pc_params = get_filter_coeff( - echodata["Vendor_specific"].sel(channel=source_Sv["channel"].values) - ) + pc_params = get_filter_coeff(echodata["Vendor_specific"].sel({"channel": Sv_channels})) pc_params["receiver_sampling_frequency"] = source_Sv["receiver_sampling_frequency"] # Add dictionary entry to keep/drop last hanning window's zero value @@ -568,8 +601,7 @@ def add_splitbeam_angle( else f"{datetime.datetime.now(datetime.UTC)}. `depth` calculated using:" ) history_attr = ( - history_attr - + "Calculated using data stored in the Beam groups of the echodata object." # noqa + history_attr + "Calculated using data stored in the Beam groups of the echodata object." ) for da_name in ["angle_alongship", "angle_athwartship"]: source_Sv[da_name] = source_Sv[da_name].assign_attrs({"history": history_attr}) diff --git a/echopype/consolidate/ek_depth_utils.py b/echopype/consolidate/utils_ek_depth.py similarity index 100% rename from echopype/consolidate/ek_depth_utils.py rename to echopype/consolidate/utils_ek_depth.py diff --git a/echopype/consolidate/loc_utils.py b/echopype/consolidate/utils_loc.py similarity index 92% rename from echopype/consolidate/loc_utils.py rename to echopype/consolidate/utils_loc.py index fb4ce6bb4..4027d9dfe 100644 --- a/echopype/consolidate/loc_utils.py +++ b/echopype/consolidate/utils_loc.py @@ -10,6 +10,32 @@ logger = _init_logger(__name__) +SUPPORTED_DIM_0_NAMES = ["channel", "frequency_nominal"] + + +def get_dim_0(da_Sv: xr.DataArray) -> str: + """ + Get the name of the first dimension of the Sv data array. + + Parameters + ---------- + ds : xr.Dataset + The input dataset. + + Returns + ------- + str + The name of the first dimension. + """ + dim_0 = list(da_Sv.dims)[0] + if dim_0 in SUPPORTED_DIM_0_NAMES: + return dim_0 + else: + raise ValueError( + f"The first dimension of the the source Sv must be one of {SUPPORTED_DIM_0_NAMES}." + ) + + def compute_invalid_check(lat_var: xr.DataArray, lon_var: xr.DataArray, validity_check: str): """Helper function to check if loc vars are invalid in 4 separate ways.""" if validity_check == "missing": diff --git a/echopype/tests/commongrid/test_commongrid_api.py b/echopype/tests/commongrid/test_commongrid_api.py index cba7e71ce..5c1791a6a 100644 --- a/echopype/tests/commongrid/test_commongrid_api.py +++ b/echopype/tests/commongrid/test_commongrid_api.py @@ -1282,7 +1282,7 @@ def test_resample_matches_echoview_match_geometry(test_path): atol=0.003, rtol=0, ) - + @pytest.mark.integration def test_resample_shared_depth_and_range_geometry(test_path): """ diff --git a/echopype/tests/consolidate/test_add_depth.py b/echopype/tests/consolidate/test_add_depth.py index 18526fd32..c4eef88de 100644 --- a/echopype/tests/consolidate/test_add_depth.py +++ b/echopype/tests/consolidate/test_add_depth.py @@ -8,7 +8,7 @@ from echopype.utils.align import align_to_ping_time import echopype as ep -from echopype.consolidate.ek_depth_utils import ( +from echopype.consolidate.utils_ek_depth import ( ek_use_platform_vertical_offsets, ek_use_platform_angles, ek_use_beam_angles ) @@ -72,7 +72,7 @@ def test_ek_use_platform_vertical_offsets_output(): }, coords={"time2": time2_da} ) - transducer_depth = ep.consolidate.ek_depth_utils.ek_use_platform_vertical_offsets( + transducer_depth = ek_use_platform_vertical_offsets( platform_ds, ping_time_da ) @@ -110,7 +110,7 @@ def test_ek_use_platform_angles_output(): }, coords={"time2": time2_da} ) - echo_range_scaling = ep.consolidate.ek_depth_utils.ek_use_platform_angles(platform_ds, ping_time_da) # noqa: E501 + echo_range_scaling = ek_use_platform_angles(platform_ds, ping_time_da) # noqa: E501 # The two 1.0s here are from the interpolation assert np.allclose(echo_range_scaling.values, np.array([0.0, 0.0, 1.0, 1.0, 1/np.sqrt(2)])) @@ -143,7 +143,7 @@ def test_ek_use_beam_angles_output(caplog): ep.utils.log.verbose(override=False) # Compute beam angle echo range scaling - echo_range_scaling = ep.consolidate.ek_depth_utils.ek_use_beam_angles(beam_ds) + echo_range_scaling = ek_use_beam_angles(beam_ds) # Turn off logger verbosity ep.utils.log.verbose(override=True) @@ -174,7 +174,7 @@ def test_warning_zero_vector(caplog): ep.utils.log.verbose(override=False) # Compute beam angle echo range scaling - echo_range_scaling = ep.consolidate.ek_depth_utils.ek_use_beam_angles(beam_ds) + echo_range_scaling = ek_use_beam_angles(beam_ds) # Verify the correct warning assert "Some beam direction vectors are zero" in caplog.text @@ -198,7 +198,7 @@ def test_warning_zero_vector(caplog): ], ) -def test_ek_depth_utils_dims(relpath, sonar_model, compute_Sv_kwargs, ek60_path, ek80_path): +def test_utils_ek_depth_dims(relpath, sonar_model, compute_Sv_kwargs, ek60_path, ek80_path): """ Tests `ek_use_platform_vertical_offsets`, `ek_use_platform_angles`, and `ek_use_beam_angles` for correct dimensions. @@ -233,7 +233,7 @@ def test_ek_depth_utils_dims(relpath, sonar_model, compute_Sv_kwargs, ek60_path, @pytest.mark.integration -def test_ek_depth_utils_group_variable_NaNs_logger_warnings(caplog, ek80_path): +def test_utils_ek_depth_group_variable_NaNs_logger_warnings(caplog, ek80_path): """ Tests `ek_use_platform_vertical_offsets`, `ek_use_platform_angles`, and `ek_use_beam_angles` for correct logger warnings when NaNs exist in group @@ -316,6 +316,8 @@ def test_add_depth_tilt_depth_use_arg_logger_warnings(caplog, ek80_path): tilt=0.1, use_platform_vertical_offsets=True, use_beam_angles=True, + waveform_mode="CW", + encode_mode="power", ) # Check if the expected warnings are logged @@ -491,18 +493,18 @@ def test_add_depth_EK_with_platform_angles(subpath, sonar_model, compute_Sv_kwar ) -import os # noqa: E402, F811 -import pytest # noqa: E402 - @pytest.mark.integration @pytest.mark.parametrize("subpath, sonar_model, compute_Sv_kwargs", [ - ("NBP_B050N-D20180118-T090228.raw", "EK60", {}), - ("ncei-wcsd/Summer2017-D20170620-T021537.raw", "EK60", {}), + ("NBP_B050N-D20180118-T090228.raw", "EK60", {"encode_mode": "power", "waveform_mode": "CW"}), + ("ncei-wcsd/Summer2017-D20170620-T021537.raw", "EK60", {"encode_mode": "power", "waveform_mode": "CW"}), ("ncei-wcsd/SH1707/Reduced_D20170826-T205615.raw", "EK80", {"waveform_mode": "BB", "encode_mode": "complex"}), # noqa: E501 ("ncei-wcsd/SH2106/EK80/Reduced_Hake-D20210701-T131621.raw", "EK80", {"waveform_mode": "CW", "encode_mode": "power"}), # noqa: E501 ]) def test_add_depth_EK_with_beam_angles(subpath, sonar_model, compute_Sv_kwargs, ek60_path, ek80_path): # noqa: E501 - """Test `depth` values when using EK Beam angles to compute it.""" + """ + Test `depth` values when using EK Beam angles to compute it. + Note that compute_Sv and add_depth share similar kwargs, so we can use the same dictionary for both functions. + """ base = ek60_path if sonar_model == "EK60" else ek80_path raw_file = base / subpath if not os.path.isfile(raw_file): @@ -518,7 +520,7 @@ def test_add_depth_EK_with_beam_angles(subpath, sonar_model, compute_Sv_kwargs, ed["Sonar/Beam_group1"]["beam_direction_z"].values = ed["Sonar/Beam_group1"]["beam_direction_z"].fillna(1).values # noqa: E501 # Compute `depth` using beam angle values - ds_Sv_with_depth = ep.consolidate.add_depth(ds_Sv, ed, use_beam_angles=True) + ds_Sv_with_depth = ep.consolidate.add_depth(ds_Sv, ed, use_beam_angles=True, **compute_Sv_kwargs) # Check history attribute history_attribute = ds_Sv_with_depth["depth"].attrs["history"] @@ -537,30 +539,63 @@ def test_add_depth_EK_with_beam_angles(subpath, sonar_model, compute_Sv_kwargs, equal_nan=True, ) - # Replace Beam Angle NaN values - ed["Sonar/Beam_group1"]["beam_direction_x"].values = ed["Sonar/Beam_group1"]["beam_direction_x"].fillna(0).values # noqa: E501 - ed["Sonar/Beam_group1"]["beam_direction_y"].values = ed["Sonar/Beam_group1"]["beam_direction_y"].fillna(0).values # noqa: E501 - ed["Sonar/Beam_group1"]["beam_direction_z"].values = ed["Sonar/Beam_group1"]["beam_direction_z"].fillna(1).values # noqa: E501 - # Compute `depth` using beam angle values - ds_Sv_with_depth = ep.consolidate.add_depth(ds_Sv, ed, use_beam_angles=True) +@pytest.mark.integration +@pytest.mark.parametrize("file, sonar_model, compute_Sv_kwargs", [ + ("NBP_B050N-D20180118-T090228.raw", "EK60", {"encode_mode": "power", "waveform_mode": "CW"}), + ("ncei-wcsd/SH1707/Reduced_D20170826-T205615.raw", "EK80", {"waveform_mode": "BB", "encode_mode": "complex"}), + ("ncei-wcsd/SH2106/EK80/Reduced_Hake-D20210701-T131621.raw", "EK80", {"waveform_mode": "CW", "encode_mode": "power"}) +]) +def test_add_depth_with_dim_swap_and_beam_angles(file, sonar_model, compute_Sv_kwargs, ek80_path, ek60_path): + """ + Test adding depth to Sv dataset after swapping dimension/coordinate + from channel to frequency_nominal. + Asserts that the output dataset has swapped channel dim to frequency_nominal + and contains the depth variable. + """ + if sonar_model == "EK60": + ed = ep.open_raw(ek60_path / file, sonar_model=sonar_model) + else: + ed = ep.open_raw(ek80_path / file, sonar_model=sonar_model) - # Check history attribute - history_attribute = ds_Sv_with_depth["depth"].attrs["history"] - history_attribute_without_time = history_attribute[32:] - assert history_attribute_without_time == ( - ". `depth` calculated using: Sv `echo_range`, Echodata `Beam_group1` Angles." - ) + ds_Sv = ep.calibrate.compute_Sv(ed, **compute_Sv_kwargs) - # Compute echo range scaling values - echo_range_scaling = ek_use_beam_angles(ed["Sonar/Beam_group1"]) + ds_Sv = ep.consolidate.swap_dims_channel_frequency(ds_Sv) - # Check if depth is equal to echo range scaling value * echo range - assert np.allclose( - ds_Sv_with_depth["depth"].data, - (echo_range_scaling * ds_Sv["echo_range"]).transpose("channel", "ping_time", "range_sample").data, # noqa: E501 - equal_nan=True + # swap dims in beam_group to test with dim_0 = frequency_nominal + ed["Sonar/Beam_group1"] = ep.consolidate.swap_dims_channel_frequency(ed["Sonar/Beam_group1"]) + ds_Sv_with_depth = ep.consolidate.add_depth(ds_Sv, ed) + # Check that channel dim has been swapped to frequency_nominal + assert "channel" not in ds_Sv_with_depth.sizes + assert "frequency_nominal" in ds_Sv_with_depth.sizes + # Check that depth has been added + assert "depth" in ds_Sv_with_depth.data_vars + + +@pytest.mark.integration +def test_add_depth_missing_beam_angle_kwargs_raises(ek80_path): + """ + Test that add_depth raises when `use_beam_angles=True` but + waveform_mode/encode_mode are not provided. + """ + ed = ep.open_raw( + ek80_path / "ncei-wcsd/SH2106/EK80/Reduced_Hake-D20210701-T131621.raw", + sonar_model="EK80", + ) + ds_Sv = ep.calibrate.compute_Sv( + ed, + waveform_mode="CW", + encode_mode="power", ) + with pytest.raises( + ValueError, + match=r"When `use_beam_angles` is True and sonar model is EK80, both `waveform_mode` and `encode_mode` must be specified\.", + ): + ep.consolidate.add_depth( + ds_Sv, + ed, + use_beam_angles=True, + ) @pytest.mark.integration @@ -593,7 +628,7 @@ def test_add_depth_EK_with_beam_angles_with_different_beam_groups( ds_Sv = ep.calibrate.compute_Sv(ed, **compute_Sv_kwargs) # Compute `depth` using beam angle values - ds_Sv = ep.consolidate.add_depth(ds_Sv, ed, use_beam_angles=True) + ds_Sv = ep.consolidate.add_depth(ds_Sv, ed, use_beam_angles=True, **compute_Sv_kwargs) # Check history attribute history_attribute = ds_Sv["depth"].attrs["history"] diff --git a/echopype/tests/consolidate/test_add_location.py b/echopype/tests/consolidate/test_add_location.py index 74b7478ec..ce64e8884 100644 --- a/echopype/tests/consolidate/test_add_location.py +++ b/echopype/tests/consolidate/test_add_location.py @@ -6,7 +6,7 @@ import xarray as xr import echopype as ep -from echopype.consolidate.loc_utils import sel_nmea +from echopype.consolidate.utils_loc import sel_nmea # from echopype.testing import TEST_DATA_FOLDER @@ -196,6 +196,75 @@ def _tests(ds_test, location_type, nmea_sentence=None): _tests(ds_sel, location_type, nmea_sentence="GGA") +@pytest.mark.integration +@pytest.mark.parametrize( + ["sonar_model", "path_model", "raw_and_xml_paths", "lat_lon_name_dict", "extras"], + [ + ( + "AZFP", + "AZFP", + ("17082117.01A", "17041823.XML"), + {"lat_name": "latitude", "lon_name": "longitude"}, + {'longitude': -60.0, 'latitude': 45.0, 'salinity': 27.9, 'pressure': 59}, + ), + ], +) +def test_add_location_with_dim_swap( + sonar_model, + path_model, + raw_and_xml_paths, + lat_lon_name_dict, + extras, + test_path +): + """ + Test adding location to Sv dataset after swapping dimension/coordinate + from channel to frequency_nominal. + Asserts that the output dataset has swapped channel dim to frequency_nominal + and contains the latitude and longitude variable. + """ + + raw_path = test_path[path_model] / raw_and_xml_paths[0] + xml_path = test_path[path_model] / raw_and_xml_paths[1] + + ed = ep.open_raw(raw_path, xml_path=xml_path, sonar_model=sonar_model) + point_ds = xr.Dataset( + { + lat_lon_name_dict["lat_name"]: (["time"], np.array([float(extras['latitude'])])), + lat_lon_name_dict["lon_name"]: (["time"], np.array([float(extras['longitude'])])), + }, + coords={ + "time": (["time"], np.array([ed["Sonar/Beam_group1"]["ping_time"].values.min()])) + }, + ) + ed.update_platform( + point_ds, + variable_mappings={ + lat_lon_name_dict["lat_name"]: lat_lon_name_dict["lat_name"], + lat_lon_name_dict["lon_name"]: lat_lon_name_dict["lon_name"] + } + ) + + env_params = { + "temperature": ed["Environment"]["temperature"].values.mean(), + "salinity": extras["salinity"], + "pressure": extras["pressure"], + } + + ds = ep.calibrate.compute_Sv(echodata=ed, env_params=env_params) + + ds = ep.consolidate.swap_dims_channel_frequency(ds) + + ds_all = ep.consolidate.add_location(ds=ds, echodata=ed) + + # Check that channel dim has been swapped to frequency_nominal + assert "channel" not in ds_all.sizes + assert "frequency_nominal" in ds_all.sizes + # Check that latitude and longitude have been added + assert "latitude" in ds_all.data_vars + assert "longitude" in ds_all.data_vars + + @pytest.mark.parametrize( ("raw_path, sonar_model, datagram_type, parse_idx, time_dim_name, compute_Sv_kwargs"), [ diff --git a/echopype/tests/consolidate/test_consolidate_integration.py b/echopype/tests/consolidate/test_consolidate_integration.py index 0ab3e507b..f872b68d9 100644 --- a/echopype/tests/consolidate/test_consolidate_integration.py +++ b/echopype/tests/consolidate/test_consolidate_integration.py @@ -73,11 +73,11 @@ def test_data_samples(request, test_path): ) -def _check_swap(ds, ds_swap): - assert "channel" in ds.dims - assert "frequency_nominal" not in ds.dims - assert "frequency_nominal" in ds_swap.dims - assert "channel" not in ds_swap.dims +def _check_swap(ds_with_dim_channel, ds_with_dim_freq): + assert "channel" in ds_with_dim_channel.dims + assert "frequency_nominal" not in ds_with_dim_channel.dims + assert "frequency_nominal" in ds_with_dim_freq.dims + assert "channel" not in ds_with_dim_freq.dims def test_swap_dims_channel_frequency(test_data_samples): @@ -102,7 +102,7 @@ def test_swap_dims_channel_frequency(test_data_samples): if 'azfp_cal_type' in range_kwargs: range_kwargs.pop('azfp_cal_type') - dup_freq_valueerror = ( + dup_freq_value_error = ( "Duplicated transducer nominal frequencies exist in the file. " "Operation is not valid." ) @@ -113,7 +113,7 @@ def test_swap_dims_channel_frequency(test_data_samples): _check_swap(Sv, Sv_swapped) except Exception as e: assert isinstance(e, ValueError) is True - assert str(e) == dup_freq_valueerror + assert str(e) == dup_freq_value_error MVBS = ep.commongrid.compute_MVBS(Sv) try: @@ -121,7 +121,7 @@ def test_swap_dims_channel_frequency(test_data_samples): _check_swap(Sv, MVBS_swapped) except Exception as e: assert isinstance(e, ValueError) is True - assert str(e) == dup_freq_valueerror + assert str(e) == dup_freq_value_error def _create_array_list_from_echoview_mats(paths_to_echoview_mat: List[pathlib.Path]) -> List[np.ndarray]: # noqa: E501 @@ -289,6 +289,99 @@ def test_add_splitbeam_angle(sonar_model, test_path_key, raw_file_name, test_pat temp_dir.cleanup() +@pytest.mark.integration +@pytest.mark.parametrize( + ("sonar_model", "raw_file_name"), + [ + # ek60_CW_power + ( + "EK60", "DY1801_EK60-D20180211-T164025.raw", + ), + # ek80_CW_power + ( + "EK80", "Summer2018--D20180905-T033113.raw", + ), + ], + ids=[ + "ek60_CW_power", + "ek80_CW_power", + ], +) +def test_add_splitbeam_angle_with_dim_swap(sonar_model, raw_file_name, test_path): + """ + Test adding split-beam angle to Sv dataset after swapping dimension/coordinate + from channel to frequency_nominal. + Asserts that the output dataset has swapped channel dim to frequency_nominal + and contains the split-beam angle variables. + """ + + ed = ep.open_raw(test_path[sonar_model] / raw_file_name, sonar_model=sonar_model) + + waveform_mode = "CW" + encode_mode = "power" + + ds_Sv = ep.calibrate.compute_Sv(ed, waveform_mode=waveform_mode, encode_mode=encode_mode) + + ds_Sv = ep.consolidate.swap_dims_channel_frequency(ds_Sv) + + ds_Sv = ep.consolidate.add_splitbeam_angle(source_Sv=ds_Sv, echodata=ed, + waveform_mode=waveform_mode, + encode_mode=encode_mode, + to_disk=False) + print(ds_Sv["angle_alongship"].attrs["history"]) + # Check that channel dim has been swapped to frequency_nominal + assert "channel" not in ds_Sv.sizes + assert "frequency_nominal" in ds_Sv.sizes + # Check that split-beam angles were added to the dataset + assert "angle_alongship" in ds_Sv.data_vars + assert "angle_athwartship" in ds_Sv.data_vars + + +@pytest.mark.integration +def test_all_consolidate_functions_missing_channel_and_frequency_nominal_raises_error(test_path): + """Test that add_splitbeam_angle raises when neither frequency_nominal nor channel exists.""" + + ed = ep.open_raw( + test_path["EK80"] / "Summer2018--D20180905-T033113.raw", + sonar_model="EK80", + ) + + waveform_mode = "CW" + encode_mode = "power" + + ds_Sv = ep.calibrate.compute_Sv( + ed, + waveform_mode=waveform_mode, + encode_mode=encode_mode, + ) + + # Remove channel + ds_Sv["Sv"] = ds_Sv["Sv"].isel(channel=0).drop_vars("channel") + + # Test that the same errors are raised for missing valid `dim_0` for all consolidate functions + with pytest.raises( + ValueError, + match="The first dimension of", + ): + ep.consolidate.add_splitbeam_angle( + source_Sv=ds_Sv, + echodata=ed, + waveform_mode=waveform_mode, + encode_mode=encode_mode, + to_disk=False, + ) + with pytest.raises( + ValueError, + match="The first dimension of", + ): + ep.consolidate.add_location(ds_Sv, ed) + with pytest.raises( + ValueError, + match="The first dimension of", + ): + ep.consolidate.add_depth(ds_Sv, ed) + + def test_add_splitbeam_angle_BB_pc(test_path): # obtain the EchoData object with the data needed for the calculation