From ec27512a81223f089051ba3864fba26ecae0e706 Mon Sep 17 00:00:00 2001 From: Michael C Ryan Date: Fri, 25 Oct 2024 11:20:05 -0400 Subject: [PATCH] Solved 995. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hello, I’ve prepared some code on my fork and re-based it with the latest upstream , but I haven’t submitted the PR yet as my test environment isn’t fully ready. However, if you want to review the changes, you can check out my issue #995 branch. The commit is quite substantial since it touches multiple areas of the code, so I wanted to give you a heads-up on my approach. For every instance where sonar_model was being checked against model lists for device-specific routines, I replaced it with a single constant command. This eliminates the need to modify the code in those places, as it now applies uniformly to all sonar models. However, device-specific functions still run on a per-device basis, as the SONAR_MODELS dictionary now includes new keys for each model. The values of these keys reference sonar-specific functions, where I’ve wrapped your existing code into functions. These functions live in the core.py script alongside the SONAR_MODELS. This follows the suggestion made by @emiliom. For devices where no specific action is needed, I’ve implemented empty lambda functions that follow the same parameter schema. I should mention that there are additional changes on my branch that originally aimed to address issue #995, but they’ve expanded beyond the initial scope. However, I believe this new feature is valuable enough to keep (though it’s still unfinished until I can correct it for other models beyond the EK60 and EK80, which are complete once they pass the checks). The feature: I found a way to automatically parse the sonar_model from the raw data using open raw, by checking the data right at the outset. I’ve set the sonar_model argument to None by default, making it optional. For EK60 and EK80 raw data, if a sonar_model argument is provided, it’s ignored in favor of what’s available in the raw data. For other models, we still require the second argument, but only until I finish the parsing code for those models as well. I’ve mostly repurposed code that the devs already wrote. As I mentioned, my testing environment is still incomplete, but once I have the necessary test data, I should be able to pass the checks. --- .ci_helpers/docker/setup-services.py | 2 +- echopype/calibrate/api.py | 38 +++--- echopype/calibrate/calibrate_base.py | 17 +-- echopype/calibrate/range.py | 44 +------ echopype/convert/api.py | 67 +++++----- echopype/core.py | 177 +++++++++++++++++++++++++++ echopype/test_data/README.md | 3 - 7 files changed, 236 insertions(+), 112 deletions(-) diff --git a/.ci_helpers/docker/setup-services.py b/.ci_helpers/docker/setup-services.py index f1a72f57b..559d9bd11 100755 --- a/.ci_helpers/docker/setup-services.py +++ b/.ci_helpers/docker/setup-services.py @@ -32,7 +32,7 @@ def parse_args(): parser.add_argument("--deploy", action="store_true", help="Flag to setup docker services") parser.add_argument( "--http-server", - default="docker_httpserver_1", + default="docker-httpserver-1", help="Flag for specifying docker http server id.", ) parser.add_argument( diff --git a/echopype/calibrate/api.py b/echopype/calibrate/api.py index e06a0b8a0..883d638be 100644 --- a/echopype/calibrate/api.py +++ b/echopype/calibrate/api.py @@ -6,6 +6,7 @@ from ..utils.prov import echopype_prov_attrs, source_files_vars from .calibrate_azfp import CalibrateAZFP from .calibrate_ek import CalibrateEK60, CalibrateEK80 +from ..core import SONAR_MODELS CALIBRATOR = { "EK60": CalibrateEK60, @@ -32,22 +33,11 @@ def _compute_cal( waveform_mode = "BB" if waveform_mode == "FM" else waveform_mode # Check on waveform_mode and encode_mode inputs - if echodata.sonar_model == "EK80": - if waveform_mode is None or encode_mode is None: - raise ValueError("waveform_mode and encode_mode must be specified for EK80 calibration") - check_input_args_combination(waveform_mode=waveform_mode, encode_mode=encode_mode) - elif echodata.sonar_model in ("EK60", "AZFP"): - if waveform_mode is not None and waveform_mode != "CW": - logger.warning( - "This sonar model transmits only narrowband signals (waveform_mode='CW'). " - "Calibration will be in CW mode", - ) - if encode_mode is not None and encode_mode != "power": - logger.warning( - "This sonar model only record data as power or power/angle samples " - "(encode_mode='power'). Calibration will be done on the power samples.", - ) - + + SONAR_MODELS[echodata.sonar_model]['waveform_mode_check'](waveform_mode) + SONAR_MODELS[echodata.sonar_model]['encode_mode_check'](encode_mode) + + # Set up calibration object cal_obj = CALIBRATOR[echodata.sonar_model]( echodata, @@ -83,13 +73,15 @@ def add_attrs(cal_type, ds): }[cal_type], "units": "dB", } - if echodata.sonar_model == "EK80": - ds[cal_type] = ds[cal_type].assign_attrs( - { - "waveform_mode": waveform_mode, - "encode_mode": encode_mode, - } - ) + + + + + + + SONAR_MODELS[echodata.sonar_model]['add_attrs_check'](cal_type, ds, waveform_mode, encode_mode) + + add_attrs(cal_type, cal_ds) diff --git a/echopype/calibrate/calibrate_base.py b/echopype/calibrate/calibrate_base.py index d4a9f9d75..a3d38e0eb 100644 --- a/echopype/calibrate/calibrate_base.py +++ b/echopype/calibrate/calibrate_base.py @@ -3,6 +3,7 @@ from ..echodata import EchoData from ..utils.log import _init_logger from .ecs import ECSParser +from ..core import SONAR_MODELS logger = _init_logger(__name__) @@ -98,20 +99,8 @@ def _check_echodata_backscatter_size(self): If the size is above 2 GiB, raises a warning showing a recommended workflow that will not overwhelm the system memory. """ - # Initialize total nbytes - if self.echodata.sonar_model in ["EK60", "AZFP"]: - total_nbytes = self.echodata["Sonar/Beam_group1"]["backscatter_r"].nbytes - elif self.echodata.sonar_model == "EK80": - # Select source of backscatter data - beam = self.echodata[self.ed_beam_group] - - # Go through waveform and encode cases - if (self.waveform_mode == "BB") or ( - self.waveform_mode == "CW" and self.encode_mode == "complex" - ): - total_nbytes = beam["backscatter_r"].nbytes + beam["backscatter_i"].nbytes - elif self.waveform_mode == "CW" and self.encode_mode == "power": - total_nbytes = beam["backscatter_r"].nbytes + + total_nbytes = SONAR_MODELS[self.echodata.sonar_model]['backscatter_check'](self.echodata, self.ed_beam_group, self.waveform_mode, self.encode_mode) # Compute GigaBytes from Bytes total_gb = total_nbytes / (1024**3) diff --git a/echopype/calibrate/range.py b/echopype/calibrate/range.py index 72a8401ee..efd8b8b55 100644 --- a/echopype/calibrate/range.py +++ b/echopype/calibrate/range.py @@ -9,9 +9,6 @@ DIMENSION_ORDER = ["channel", "ping_time", "range_sample"] -def range_meter_modification_handler(sonar_model): - return str(SONAR_MODELS[sonar_model]['parser']).split(".Parse")[1].split("'>")[0] - def compute_range_AZFP(echodata: EchoData, env_params: Dict, cal_type: str) -> xr.DataArray: """ Computes the range (``echo_range``) of AZFP backscatter data in meters. @@ -149,19 +146,12 @@ def compute_range_EK( or as power/angle combinations (``encode_mode="power"``) in a format similar to those recorded by EK60 echosounders (the "power/angle" format). """ - # sound_speed should exist already - #if echodata.sonar_model in ("EK60", "ES70"): - # ek_str = "EK60" - #elif echodata.sonar_model in ("EK80", "ES80", "EA640"): - # ek_str = "EK80" - #else: - # raise ValueError("The specified sonar_model is not supported!") if "sound_speed" not in env_params: raise RuntimeError( - "sounds_speed not included in env_params, " - f"use echopype.calibrate.env_params.get_env_params_{range_meter_modification_handler(echodata.sonar_model)}() to compute env_params " - ) + "sounds_speed not included in env_params, " + + f"use echopype.calibrate.env_params.get_env_params_"+str(SONAR_MODELS[echodata.sonar_model]['parser']).split(".Parse")[1].split("'>")[0]+f"() to compute env_params " + ) else: sound_speed = env_params["sound_speed"] @@ -172,7 +162,7 @@ def compute_range_EK( if chan_sel is None else echodata[ed_beam_group].sel(channel=chan_sel) ) - + # Range in meters, not modified for TVG compensation range_meter = beam["range_sample"] * beam["sample_interval"] * sound_speed / 2 # make order of dims conform with the order of backscatter data @@ -208,32 +198,8 @@ def range_mod_TVG_EK( ref: https://github.com/CI-CMG/pyEcholab/blob/RHT-EK80-Svf/echolab2/instruments/EK80.py#L4297-L4308 # noqa """ - def mod_Ex60(): - # 2-sample shift in the beginning - return 2 * beam["sample_interval"] * sound_speed / 2 # [frequency x range_sample] - - def mod_Ex80(): - mod = sound_speed * beam["transmit_duration_nominal"] / 4 - if isinstance(mod, xr.DataArray) and "time1" in mod.coords: - mod = mod.squeeze().drop_vars("time1") - return mod - beam = echodata[ed_beam_group] vend = echodata["Vendor_specific"] - # If EK60 - if echodata.sonar_model in ["EK60", "ES70"]: - range_meter = range_meter - mod_Ex60() - - # If EK80: - # - compute range first assuming all channels have Ex80 style hardware - # - change range for channels with Ex60 style hardware (GPT) - elif echodata.sonar_model in ["EK80", "ES80", "EA640"]: - range_meter = range_meter - mod_Ex80() - - # Change range for all channels with GPT - if "GPT" in vend["transceiver_type"]: - ch_GPT = vend["transceiver_type"] == "GPT" - range_meter.loc[dict(channel=ch_GPT)] = range_meter.sel(channel=ch_GPT) - mod_Ex60() - + range_meter = SONAR_MODELS[echodata.sonar_model]['range_meter'](range_meter, beam, vend, sound_speed) return range_meter diff --git a/echopype/convert/api.py b/echopype/convert/api.py index be26a7ece..795c7c6b6 100644 --- a/echopype/convert/api.py +++ b/echopype/convert/api.py @@ -200,39 +200,42 @@ def _save_groups_to_file(echodata, output_path, engine, compress=True, **kwargs) **kwargs, ) + SONAR_MODELS[echodata.sonar_model]['save_file_check'](echodata, output_path, engine, compress, COMPRESSION_SETTINGS, BEAM_SUBGROUP_DEFAULT, **kwargs) + + # /Sonar/Beam_groupX group - if echodata.sonar_model == "AD2CP": - for i in range(1, len(echodata["Sonar"]["beam_group"]) + 1): - io.save_file( - echodata[f"Sonar/Beam_group{i}"], - path=output_path, - mode="a", - engine=engine, - group=f"Sonar/Beam_group{i}", - compression_settings=COMPRESSION_SETTINGS[engine] if compress else None, - **kwargs, - ) - else: - io.save_file( - echodata[f"Sonar/{BEAM_SUBGROUP_DEFAULT}"], - path=output_path, - mode="a", - engine=engine, - group=f"Sonar/{BEAM_SUBGROUP_DEFAULT}", - compression_settings=COMPRESSION_SETTINGS[engine] if compress else None, - **kwargs, - ) - if echodata["Sonar/Beam_group2"] is not None: - # some sonar model does not produce Sonar/Beam_group2 - io.save_file( - echodata["Sonar/Beam_group2"], - path=output_path, - mode="a", - engine=engine, - group="Sonar/Beam_group2", - compression_settings=COMPRESSION_SETTINGS[engine] if compress else None, - **kwargs, - ) + # if echodata.sonar_model == "AD2CP": + # for i in range(1, len(echodata["Sonar"]["beam_group"]) + 1): + # io.save_file( + # echodata[f"Sonar/Beam_group{i}"], + # path=output_path, + # mode="a", + # engine=engine, + # group=f"Sonar/Beam_group{i}", + # compression_settings=COMPRESSION_SETTINGS[engine] if compress else None, + # **kwargs, + # ) + # else: + # io.save_file( + # echodata[f"Sonar/{BEAM_SUBGROUP_DEFAULT}"], + # path=output_path, + # mode="a", + # engine=engine, + # group=f"Sonar/{BEAM_SUBGROUP_DEFAULT}", + # compression_settings=COMPRESSION_SETTINGS[engine] if compress else None, + # **kwargs, + # ) + # if echodata["Sonar/Beam_group2"] is not None: + # # some sonar model does not produce Sonar/Beam_group2 + # io.save_file( + # echodata["Sonar/Beam_group2"], + # path=output_path, + # mode="a", + # engine=engine, + # group="Sonar/Beam_group2", + # compression_settings=COMPRESSION_SETTINGS[engine] if compress else None, + # **kwargs, + # ) # Vendor_specific group io.save_file( diff --git a/echopype/core.py b/echopype/core.py index 01e31ff19..d8767e316 100644 --- a/echopype/core.py +++ b/echopype/core.py @@ -1,10 +1,14 @@ import os import re from typing import TYPE_CHECKING, Any, Callable, Dict, Union +import xarray as xr from fsspec.mapping import FSMap from typing_extensions import Literal +from .utils.log import _init_logger +from .utils import io + from .convert.parse_ad2cp import ParseAd2cp from .convert.parse_azfp import ParseAZFP from .convert.parse_azfp6 import ParseAZFP6 @@ -15,6 +19,10 @@ from .convert.set_groups_azfp6 import SetGroupsAZFP6 from .convert.set_groups_ek60 import SetGroupsEK60 from .convert.set_groups_ek80 import SetGroupsEK80 +from .echodata.simrad import check_input_args_combination + + +logger = _init_logger(__name__) if TYPE_CHECKING: # Please keep SonarModelsHint updated with the keys of the SONAR_MODELS dict @@ -40,6 +48,138 @@ def inner(test_ext: str): return inner +def mod_Ex80(beam, sound_speed): + mod = sound_speed * beam["transmit_duration_nominal"] / 4 + if isinstance(mod, xr.DataArray) and "time1" in mod.coords: + mod = mod.squeeze().drop_vars("time1") + return mod + + + +def mod_Ex60(beam, sound_speed): + # 2-sample shift in the beginning + return 2 * beam["sample_interval"] * sound_speed / 2 # [frequency x range_sample] + +def range_meter_Ex80(beam, vend, sound_speed): + range_meter = range_meter - mod_Ex80(beam, sound_speed) + + # Change range for all channels with GPT + if "GPT" in vend["transceiver_type"]: + ch_GPT = vend["transceiver_type"] == "GPT" + range_meter.loc[dict(channel=ch_GPT)] = range_meter.sel(channel=ch_GPT) - mod_Ex60(beam, sound_speed) + +def EK80_waveform_mode_check(waveform_mode, encode_mode): + if waveform_mode is None: + raise ValueError("The waveform_mode must be specified for EK80 calibration") + +def EK80_encode_mode_check(encode_mode): + if encode_mode is None: + raise ValueError("The encode_mode must be specified for EK80 calibration") + +def EK80_mode_checks(waveform_mode, encode_mode): + EK80_waveform_mode_check(waveform_mode) + EK80_encode_mode_check(encode_mode) + check_input_args_combination(waveform_mode=waveform_mode, encode_mode=encode_mode) + + +def EK60_waveform_mode_check(waveform_mode, encode_mode): + if waveform_mode is not None and waveform_mode != "CW": + logger.warning( + "This sonar model transmits only narrowband signals (waveform_mode='CW'). " + "Calibration will be in CW mode", + ) +def EK60_encode_mode_check(encode_mode): + if encode_mode is not None and encode_mode != "power": + logger.warning( + "This sonar model only record data as power or power/angle samples " + "(encode_mode='power'). Calibration will be done on the power samples.", + ) + +def EK60_mode_checks(waveform_mode, encode_mode): + EK60_waveform_mode_check(waveform_mode) + EK60_encode_mode_check(encode_mode) + + + +def AZFP_waveform_mode_check(waveform_mode): + if waveform_mode is not None and waveform_mode != "CW": + logger.warning( + "This sonar model transmits only narrowband signals (waveform_mode='CW'). " + "Calibration will be in CW mode", + ) + +def AZFP_encode_mode_check(encode_mode): + if encode_mode is not None and encode_mode != "power": + logger.warning( + "This sonar model only record data as power or power/angle samples " + "(encode_mode='power'). Calibration will be done on the power samples.", + ) + +def AZFP_mode_checks(waveform_mode, encode_mode): + AZFP_waveform_mode_check(waveform_mode) + AZFP_encode_mode_check(encode_mode) + +def EK80_add_attrs_check(cal_type, ds, waveform_mode, encode_mode): + ds[cal_type] = ds[cal_type].assign_attrs( + { + "waveform_mode": waveform_mode, + "encode_mode": encode_mode, + } + ) + +def EK60_backscatter_check(echodata, ed_beam_group, waveform_mode, encode_mode): + return echodata["Sonar/Beam_group1"]["backscatter_r"].nbytes + +def AZFP_backscatter_check(echodata, ed_beam_group, waveform_mode, encode_mode): + return echodata["Sonar/Beam_group1"]["backscatter_r"].nbytes + +def EK80_backscatter_check(echodata, ed_beam_group, waveform_mode, encode_mode): + # Select source of backscatter data + beam = echodata[ed_beam_group] + + # Go through waveform and encode cases + if (waveform_mode == "BB") or ( + waveform_mode == "CW" and encode_mode == "complex" + ): + total_nbytes = beam["backscatter_r"].nbytes + beam["backscatter_i"].nbytes + if waveform_mode == "CW" and encode_mode == "power": + total_nbytes = beam["backscatter_r"].nbytes + +def AD2CP_save_file_check(echodata, output_path, engine, compress, COMPRESSION_SETTINGS, BEAM_SUBGROUP_DEFAULT, **kwargs): + for i in range(1, len(echodata["Sonar"]["beam_group"]) + 1): + io.save_file( + echodata[f"Sonar/Beam_group{i}"], + path=output_path, + mode="a", + engine=engine, + group=f"Sonar/Beam_group{i}", + compression_settings=COMPRESSION_SETTINGS[engine] if compress else None, + **kwargs, + ) +def BASE_save_file_check(echodata, output_path, engine, compress, COMPRESSION_SETTINGS, BEAM_SUBGROUP_DEFAULT, **kwargs): + io.save_file( + echodata[f"Sonar/{BEAM_SUBGROUP_DEFAULT}"], + path=output_path, + mode="a", + engine=engine, + group=f"Sonar/{BEAM_SUBGROUP_DEFAULT}", + compression_settings=COMPRESSION_SETTINGS[engine] if compress else None, + **kwargs, + ) + if echodata["Sonar/Beam_group2"] is not None: + # some sonar model does not produce Sonar/Beam_group2 + io.save_file( + echodata["Sonar/Beam_group2"], + path=output_path, + mode="a", + engine=engine, + group="Sonar/Beam_group2", + compression_settings=COMPRESSION_SETTINGS[engine] if compress else None, + **kwargs, + ) + + + SONAR_MODELS: Dict["SonarModelsHint", Dict[str, Any]] = { "AZFP": { "validate_ext": validate_azfp_ext, @@ -49,6 +189,10 @@ def inner(test_ext: str): "parser": ParseAZFP, "parsed2zarr": None, "set_groups": SetGroupsAZFP, + "mode_checks" : AZFP_mode_checks, + "add_attrs_check" : lambda cal_type, ds, waveform_mode, encode_mode : None, + "backscatter_check" : AZFP_backscatter_check, + "save_file_check" : BASE_save_file_check }, "AZFP6": { "validate_ext": validate_ext(".azfp"), @@ -58,6 +202,10 @@ def inner(test_ext: str): "parser": ParseAZFP6, "parsed2zarr": None, "set_groups": SetGroupsAZFP6, + "mode_checks" : lambda: None, + "add_attrs_check" : lambda cal_type, ds, waveform_mode, encode_mode : None, + "backscatter_check" : lambda echodata, ed_beam_group, waveform_mode, encode_mode : None, + "save_file_check" : BASE_save_file_check }, "EK60": { "validate_ext": validate_ext(".raw"), @@ -66,6 +214,11 @@ def inner(test_ext: str): "accepts_idx": True, "parser": ParseEK60, "set_groups": SetGroupsEK60, + "range_meter": lambda range_meter, beam, vend, sound_speed : range_meter - mod_Ex60(beam, sound_speed), + "mode_checks" : EK60_mode_checks, + "add_attrs_check" : lambda cal_type, ds, waveform_mode, encode_mode : None, + "backscatter_check" : EK60_backscatter_check, + "save_file_check" : BASE_save_file_check }, "ES70": { "validate_ext": validate_ext(".raw"), @@ -74,6 +227,11 @@ def inner(test_ext: str): "accepts_idx": False, "parser": ParseEK60, "set_groups": SetGroupsEK60, + "range_meter": lambda range_meter, beam, vend, sound_speed : range_meter - mod_Ex60(beam, sound_speed), + "mode_checks" : lambda: None, + "add_attrs_check" : lambda cal_type, ds, waveform_mode, encode_mode : None, + "backscatter_check" : lambda echodata, ed_beam_group, waveform_mode, encode_mode : None, + "save_file_check" : BASE_save_file_check }, "EK80": { "validate_ext": validate_ext(".raw"), @@ -82,6 +240,11 @@ def inner(test_ext: str): "accepts_idx": True, "parser": ParseEK80, "set_groups": SetGroupsEK80, + "range_meter": lambda range_meter, beam, vend, sound_speed : range_meter_Ex80(beam, vend, sound_speed), + "mode_checks" : EK80_mode_checks, + "add_attrs_check" : EK80_add_attrs_check, + "backscatter_check" : EK80_backscatter_check, + "save_file_check" : BASE_save_file_check }, "ES80": { "validate_ext": validate_ext(".raw"), @@ -90,6 +253,11 @@ def inner(test_ext: str): "accepts_idx": False, "parser": ParseEK80, "set_groups": SetGroupsEK80, + "range_meter": lambda range_meter, beam, vend, sound_speed : range_meter_Ex80(beam, vend, sound_speed), + "mode_checks" : lambda: None, + "add_attrs_check" : lambda cal_type, ds, waveform_mode, encode_mode : None, + "backscatter_check" : lambda echodata, ed_beam_group, waveform_mode, encode_mode : None, + "save_file_check" : BASE_save_file_check }, "EA640": { "validate_ext": validate_ext(".raw"), @@ -98,6 +266,11 @@ def inner(test_ext: str): "accepts_idx": False, "parser": ParseEK80, "set_groups": SetGroupsEK80, + "range_meter": lambda range_meter, beam, vend, sound_speed : range_meter - mod_Ex80(beam, sound_speed), + "mode_checks" : lambda: None, + "add_attrs_check" : lambda cal_type, ds, waveform_mode, encode_mode : None, + "backscatter_check" : lambda echodata, ed_beam_group, waveform_mode, encode_mode : None, + "save_file_check" : BASE_save_file_check }, "AD2CP": { "validate_ext": validate_ext(".ad2cp"), @@ -107,5 +280,9 @@ def inner(test_ext: str): "parser": ParseAd2cp, "parsed2zarr": None, "set_groups": SetGroupsAd2cp, + "mode_checks" : lambda: None, + "add_attrs_check" : lambda cal_type, ds, waveform_mode, encode_mode : None, + "backscatter_check" : lambda echodata, ed_beam_group, waveform_mode, encode_mode : None, + "save_file_check" : AD2CP_save_file_check }, } diff --git a/echopype/test_data/README.md b/echopype/test_data/README.md index d3295604e..c79ad71f3 100644 --- a/echopype/test_data/README.md +++ b/echopype/test_data/README.md @@ -11,8 +11,6 @@ Most of these files are stored on Git LFS but the ones that aren't (due to file - 2019118 group2survey-D20191214-T081342.raw: Contains 6 channels but only 2 of those channels collect ping data - D20200528-T125932.raw: Data collected from WBT mini (instead of WBT), from @emlynjdavies - Green2.Survey2.FM.short.slow.-D20191004-T211557.raw: Contains 2-in-1 transducer, from @FletcherFT (reduced from 104.9 MB to 765 KB in test data updates) -- raw4-D20220514-T172704.raw: Contains RAW4 datagram, 1 channel only, from @cornejotux -- D20210330-T123857.raw: do not contain filter coefficients ### EA640 @@ -24,7 +22,6 @@ Most of these files are stored on Git LFS but the ones that aren't (due to file - Winter2017-D20170115-T150122.raw: Contains a change of recording length in the middle of the file - 2015843-D20151023-T190636.raw: Not used in tests but contains ranges are not constant across ping times - SH1701_consecutive_files_w_range_change: Not used in tests. [Folder](https://drive.google.com/drive/u/1/folders/1PaDtL-xnG5EK3N3P1kGlXa5ub16Yic0f) on shared drive that contains sequential files with ranges that are not constant across ping times. -- NBP_B050N-D20180118-T090228.raw: split-beam setup without angle data ### AZFP