From 023d719d6c61629f98b52a217ab31372db2c500c Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Thu, 24 Aug 2023 13:52:25 -0500 Subject: [PATCH 01/73] add a logic branch to the raider.py cli --- tools/RAiDER/cli/__main__.py | 9 ++++++++- tools/RAiDER/cli/raider.py | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/tools/RAiDER/cli/__main__.py b/tools/RAiDER/cli/__main__.py index 447b82bd4..ad6b85d89 100644 --- a/tools/RAiDER/cli/__main__.py +++ b/tools/RAiDER/cli/__main__.py @@ -29,7 +29,14 @@ def main(): sys.argv = [args.process, *unknowns] - from RAiDER.cli.raider import calcDelays, downloadGNSS, calcDelaysGUNW + if args.process == 'calcDelays': + from RAiDER.cli.raider import calcDelays + elif args.process == 'downloadGNSS': + from RAiDER.cli.raider import downloadGNSS + elif args.process == 'calcDelaysGUNW': + from RAiDER.cli.raider import calcDelaysGUNW + else: + raise NotImplementedError(f'Process {args.process} has not been fully implemented') try: # python >=3.10 interface diff --git a/tools/RAiDER/cli/raider.py b/tools/RAiDER/cli/raider.py index f4eefce37..34ab69b70 100644 --- a/tools/RAiDER/cli/raider.py +++ b/tools/RAiDER/cli/raider.py @@ -312,6 +312,10 @@ def calcDelays(iargs=None): elif (len(wfiles) == 1) and (len(times) == 2) and (interp_method != 'azimuth_time_grid'): logger.warning('Time interpolation did not succeed, defaulting to nearest available date') weather_model_file = wfiles[0] + + elif (interp_method == 'center_time') and len(times)==1: + logger.info('Requested time is provided exactly, will use only one weather model datetime') + weather_model_file = wfiles[0] # TODO: ensure this additional conditional is appropriate; assuming wfiles == 2 ONLY for 'center_time' # value of 'interp_method' parameter @@ -388,6 +392,7 @@ def calcDelays(iargs=None): os.path.basename(wfiles[0]).split('_')[0] + '_' + t.strftime('%Y_%m_%dT%H_%M_%S') + '_timeInterpAziGrid_' + '_'.join(wfiles[0].split('_')[-4:]), ) ds_out.to_netcdf(weather_model_file) + # TODO: test to ensure this error is caught else: n = len(wfiles) From 3669615e2627bcb5e3110257814c04ca746363ef Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Fri, 25 Aug 2023 22:11:34 -0500 Subject: [PATCH 02/73] catch AttributeError in aoi class --- tools/RAiDER/llreader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/RAiDER/llreader.py b/tools/RAiDER/llreader.py index 769b3b32c..93861835c 100644 --- a/tools/RAiDER/llreader.py +++ b/tools/RAiDER/llreader.py @@ -185,6 +185,8 @@ def set_output_xygrid(self, dst_crs=4326): out_proj = CRS.from_epsg(dst_crs.replace('EPSG:', '')) except pyproj.exceptions.CRSError: out_proj = dst_crs + except AttributeError: + out_proj = CRS.from_epsg(dst_crs) out_snwe = transform_bbox(self.bounds(), src_crs=4326, dest_crs=out_proj) logger.debug(f"Output SNWE: {out_snwe}") From f5732bcf0cceff842ea528ca4da38a1bb951943c Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Sun, 27 Aug 2023 22:37:43 -0500 Subject: [PATCH 03/73] add unit tests, fix exception catching order on latlon files --- test/test_llreader.py | 37 +++++++++++++++++++++++++++++++++++++ tools/RAiDER/llreader.py | 8 +++++--- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/test/test_llreader.py b/test/test_llreader.py index 564829ae9..9842fb963 100644 --- a/test/test_llreader.py +++ b/test/test_llreader.py @@ -5,6 +5,7 @@ import pandas as pd from test import GEOM_DIR, TEST_DIR +from pyproj import CRS from RAiDER.cli.raider import calcDelays @@ -41,6 +42,31 @@ def test_latlon_reader_2(): RasterRDR(lat_file='doesnotexist.rdr', lon_file='doesnotexist.rdr') +def test_aoi_epsg(): + bbox = [20, 27, -115, -104] + r = BoundingBox(bbox) + r.set_output_spacing(ll_res=0.05) + test = r.get_output_spacing(4978) + assert test == 0.05 * 1e5 + + +def test_set_output_dir(): + bbox = [20, 27, -115, -104] + r = BoundingBox(bbox) + r.set_output_directory('dummy_directory') + assert r._output_directory == 'dummy_directory' + + +def test_set_xygrid(): + bbox = [20, 27, -115, -104] + crs = CRS.from_epsg(4326) + r = BoundingBox(bbox) + r.set_output_spacing(ll_res=0.1) + r.set_output_xygrid(dst_crs=4978) + r.set_output_xygrid(dst_crs=crs) + assert True + + def test_latlon_reader(): latfile = os.path.join(GEOM_DIR, 'lat.rdr') lonfile = os.path.join(GEOM_DIR, 'lon.rdr') @@ -60,6 +86,17 @@ def test_latlon_reader(): assert all([np.allclose(b, t, rtol=1e-4) for b, t in zip(query.bounds(), bounds_true)]) +def test_badllfiles(station_file): + latfile = os.path.join(GEOM_DIR, 'lat.rdr') + lonfile = os.path.join(GEOM_DIR, 'lon_dummy.rdr') + station_file = station_file + with pytest.raises(ValueError): + RasterRDR(lat_file=latfile, lon_file=lonfile) + with pytest.raises(ValueError): + RasterRDR(lat_file=latfile, lon_file=station_file) + with pytest.raises(ValueError): + RasterRDR(lat_file=station_file, lon_file=lonfile) + def test_read_bbox(): bbox = [20, 27, -115, -104] query = BoundingBox(bbox) diff --git a/tools/RAiDER/llreader.py b/tools/RAiDER/llreader.py index 93861835c..88ab2f45d 100644 --- a/tools/RAiDER/llreader.py +++ b/tools/RAiDER/llreader.py @@ -183,10 +183,12 @@ def set_output_xygrid(self, dst_crs=4326): try: out_proj = CRS.from_epsg(dst_crs.replace('EPSG:', '')) - except pyproj.exceptions.CRSError: - out_proj = dst_crs except AttributeError: - out_proj = CRS.from_epsg(dst_crs) + try: + out_proj = CRS.from_epsg(dst_crs) + except pyproj.exceptions.CRSError: + out_proj = dst_crs + out_snwe = transform_bbox(self.bounds(), src_crs=4326, dest_crs=out_proj) logger.debug(f"Output SNWE: {out_snwe}") From 9bd1aa21ab363ef7694f9ea9ede7847429f45eb9 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Sun, 10 Sep 2023 21:08:22 -0500 Subject: [PATCH 04/73] rebase on dev --- CHANGELOG.md | 3 +-- tools/RAiDER/cli/raider.py | 39 +++++++++++++++++++++++--------------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edec7e546..b086883b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,11 +37,10 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * Return xarray.Dataset types for RAiDER.calcGUNW.tropo_gunw_slc and RAiDER.raider.calcDelayGUNW for easier inspection and testing ## [0.4.4] - -## Fixes * For s1-azimuth-time interpolation, overlapping orbits when one orbit does not cover entire GUNW product errors out. We now ensure state-vectors are both unique and in order before creating a orbit object in ISCE3. ## [0.4.3] ++ Bug fixes, unit tests, docstrings + Prevent ray tracing integration from occuring at exactly top of weather model + Properly expose z_ref (max integration height) parameter, and dont allow higher than weather model + Min version for sentineleof for obtaining restituted orbits. diff --git a/tools/RAiDER/cli/raider.py b/tools/RAiDER/cli/raider.py index 34ab69b70..3ea7e4c1b 100644 --- a/tools/RAiDER/cli/raider.py +++ b/tools/RAiDER/cli/raider.py @@ -294,7 +294,17 @@ def calcDelays(iargs=None): if dl_only: continue - if (len(wfiles) == 0) and (interp_method != 'azimuth_time_grid'): + + ############################################################################ + # Time interpolation + # + # Need to handle various cases, including if the exact weather model time is + # requested, or if one or more datetimes are not available from the weather + # model data provider + ############################################################################ + + # Case 1 - No datetimes were retrieved + if len(wfiles) == 0: logger.error('No weather model data was successfully processed.') if len(params['date_list']) == 1: raise RuntimeError @@ -302,23 +312,25 @@ def calcDelays(iargs=None): else: continue - # nearest weather model time via 'none' is specified - # When interp_method is 'none' only 1 weather model file and one relevant time + # Case 2 - nearest weather model time is requested and retrieved elif (interp_method == 'none') and (len(wfiles) == 1) and (len(times) == 1): weather_model_file = wfiles[0] - # only one time in temporal interpolation worked - # TODO: this seems problematic - unexpected behavior possibly for 'center_time' - elif (len(wfiles) == 1) and (len(times) == 2) and (interp_method != 'azimuth_time_grid'): + # Case 3 - two times were requested (e.g., for interpolation) but only one was + # retrieved. In this case the code will complete but a warning is issued + # that only one time was available + elif len(wfiles)==1 and len(times)==2: logger.warning('Time interpolation did not succeed, defaulting to nearest available date') weather_model_file = wfiles[0] + # Case 4 - Time interpolation is requested, but if the requested time exactly matching + # the available (within _THRESHOLD_SECONDS), only a single time is returned and no + # interpolation is required elif (interp_method == 'center_time') and len(times)==1: logger.info('Requested time is provided exactly, will use only one weather model datetime') weather_model_file = wfiles[0] - # TODO: ensure this additional conditional is appropriate; assuming wfiles == 2 ONLY for 'center_time' - # value of 'interp_method' parameter + # Case 5 - two times requested for interpolation and two retrieved elif (interp_method == 'center_time') and (len(wfiles) == 2): ds1 = xr.open_dataset(wfiles[0]) ds2 = xr.open_dataset(wfiles[1]) @@ -344,12 +356,9 @@ def calcDelays(iargs=None): os.path.basename(wfiles[0]).split('_')[0] + '_' + t.strftime('%Y_%m_%dT%H_%M_%S') + '_timeInterp_' + '_'.join(wfiles[0].split('_')[-4:]), ) ds.to_netcdf(weather_model_file) - elif (interp_method == 'azimuth_time_grid'): - n_files = len(wfiles) - n_times = len(times) - if n_files != n_times: - raise ValueError('The model files for the datetimes for requisite azimuth interpolation were not ' - 'succesfully downloaded or processed') + + # Case 6 - Azimuth grid time, should end up with three times in total + elif (interp_method == 'azimuth_time_grid') and (len(wfiles) == 3): datasets = [xr.open_dataset(f) for f in wfiles] # Each model will require some inspection here @@ -393,7 +402,7 @@ def calcDelays(iargs=None): ) ds_out.to_netcdf(weather_model_file) - # TODO: test to ensure this error is caught + # Case 7 - Anything else errors out else: n = len(wfiles) raise NotImplementedError(f'The {interp_method} with {n} retrieved weather model files was not well posed ' From a346dcbfdc47ddaae306e455f9f689b8ec5110ed Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Fri, 15 Sep 2023 22:55:05 -0500 Subject: [PATCH 05/73] add general exception for get_sv --- tools/RAiDER/cli/raider.py | 3 ++- tools/RAiDER/losreader.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/RAiDER/cli/raider.py b/tools/RAiDER/cli/raider.py index 3ea7e4c1b..7fc9e0dc0 100644 --- a/tools/RAiDER/cli/raider.py +++ b/tools/RAiDER/cli/raider.py @@ -241,7 +241,8 @@ def calcDelays(iargs=None): ): ########################################################### - # weather model calculation + # Weather model calculation + ########################################################### logger.debug('Starting to run the weather model calculation') logger.debug(f'Requested date,time: {t.strftime("%Y%m%d, %H:%M")}') logger.debug('Beginning weather model pre-processing') diff --git a/tools/RAiDER/losreader.py b/tools/RAiDER/losreader.py index c11b9a84c..b73158a5a 100644 --- a/tools/RAiDER/losreader.py +++ b/tools/RAiDER/losreader.py @@ -373,6 +373,9 @@ def filter_ESA_orbit_file_p(path: str) -> bool: raise ValueError( 'get_sv: I cannot parse the statevector file {}'.format(los_file) ) + except: + raise ValueError('get_sv: I cannot parse the statevector file {}'.format(los_file)) + if ref_time: idx = cut_times(svs[0], ref_time, pad=pad) From 004f3b0f02a5a0d33512570706f43a9b75d6f7c4 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Wed, 20 Sep 2023 21:26:46 -0500 Subject: [PATCH 06/73] refactor out weather model weighting into its own functions --- tools/RAiDER/cli/raider.py | 325 +++++++++++++++++----------- tools/RAiDER/models/weatherModel.py | 3 +- 2 files changed, 206 insertions(+), 122 deletions(-) diff --git a/tools/RAiDER/cli/raider.py b/tools/RAiDER/cli/raider.py index 7fc9e0dc0..579e5fced 100644 --- a/tools/RAiDER/cli/raider.py +++ b/tools/RAiDER/cli/raider.py @@ -253,13 +253,11 @@ def calcDelays(iargs=None): logger.warning('interp_method is not specified, defaulting to \'none\', i.e. nearest datetime for delay ' 'calculation') - # Grab the closest two times unless the user specifies 'nearest' via 'none' or None. - # If the model time_delta is not specified then use 6 - # The two datetimes will be combined to a single file and processed - # TODO: make more transparent control flow for GUNW and non-GUNW workflow - if (interp_method in ['none', 'center_time']): - times = get_nearest_wmtimes(t, [model.dtime() if \ - model.dtime() is not None else 6][0]) if interp_method == 'center_time' else [t] + if (interp_method is not 'azimuth_time_grid'): + times = get_nearest_wmtimes( + t, [model.dtime() if model.dtime() is not None else 6][0] + ) if interp_method == 'center_time' else [t] + elif interp_method == 'azimuth_time_grid': step = model.dtime() time_step_hours = step if step is not None else 6 @@ -278,7 +276,7 @@ def calcDelays(iargs=None): wfiles.append(wfile) # catch when requested datetime fails - except RuntimeError as re: + except RuntimeError: continue # catch when something else within weather model class fails @@ -295,119 +293,8 @@ def calcDelays(iargs=None): if dl_only: continue - - ############################################################################ - # Time interpolation - # - # Need to handle various cases, including if the exact weather model time is - # requested, or if one or more datetimes are not available from the weather - # model data provider - ############################################################################ - - # Case 1 - No datetimes were retrieved - if len(wfiles) == 0: - logger.error('No weather model data was successfully processed.') - if len(params['date_list']) == 1: - raise RuntimeError - # skip date and continue processing if multiple dates are requested - else: - continue - - # Case 2 - nearest weather model time is requested and retrieved - elif (interp_method == 'none') and (len(wfiles) == 1) and (len(times) == 1): - weather_model_file = wfiles[0] - - # Case 3 - two times were requested (e.g., for interpolation) but only one was - # retrieved. In this case the code will complete but a warning is issued - # that only one time was available - elif len(wfiles)==1 and len(times)==2: - logger.warning('Time interpolation did not succeed, defaulting to nearest available date') - weather_model_file = wfiles[0] - - # Case 4 - Time interpolation is requested, but if the requested time exactly matching - # the available (within _THRESHOLD_SECONDS), only a single time is returned and no - # interpolation is required - elif (interp_method == 'center_time') and len(times)==1: - logger.info('Requested time is provided exactly, will use only one weather model datetime') - weather_model_file = wfiles[0] - - # Case 5 - two times requested for interpolation and two retrieved - elif (interp_method == 'center_time') and (len(wfiles) == 2): - ds1 = xr.open_dataset(wfiles[0]) - ds2 = xr.open_dataset(wfiles[1]) - - # calculate relative weights of each dataset - date1 = datetime.datetime.strptime(ds1.attrs['datetime'], '%Y_%m_%dT%H_%M_%S') - date2 = datetime.datetime.strptime(ds2.attrs['datetime'], '%Y_%m_%dT%H_%M_%S') - wgts = [ 1 - get_dt(t, date1) / get_dt(date2, date1), 1 - get_dt(date2, t) / get_dt(date2, date1)] - try: - assert np.isclose(np.sum(wgts), 1) - except AssertionError: - logger.error('Time interpolation weights do not sum to one; something is off with query datetime: %s', t) - continue - - # combine datasets - ds = ds1 - for var in ['wet', 'hydro', 'wet_total', 'hydro_total']: - ds[var] = (wgts[0] * ds1[var]) + (wgts[1] * ds2[var]) - ds.attrs['Date1'] = 0 - ds.attrs['Date2'] = 0 - weather_model_file = os.path.join( - os.path.dirname(wfiles[0]), - os.path.basename(wfiles[0]).split('_')[0] + '_' + t.strftime('%Y_%m_%dT%H_%M_%S') + '_timeInterp_' + '_'.join(wfiles[0].split('_')[-4:]), - ) - ds.to_netcdf(weather_model_file) - - # Case 6 - Azimuth grid time, should end up with three times in total - elif (interp_method == 'azimuth_time_grid') and (len(wfiles) == 3): - datasets = [xr.open_dataset(f) for f in wfiles] - - # Each model will require some inspection here - # the subsequent s1 azimuth time grid requires dimension - # inputs to all have same dimensions and either be - # 1d or 3d. - if model._dataset in ['hrrr', 'hrrrak']: - lat_2d = datasets[0].latitude.data - lon_2d = datasets[0].longitude.data - z_1d = datasets[0].z.data - m, n, p = z_1d.shape[0], lat_2d.shape[0], lat_2d.shape[1] - - lat = np.broadcast_to(lat_2d, (m, n, p)) - lon = np.broadcast_to(lon_2d, (m, n, p)) - hgt = np.broadcast_to(z_1d[:, None, None], (m, n, p)) - - else: - raise NotImplementedError('Azimuth Time is currently only implemented for HRRR') - - time_grid = RAiDER.s1_azimuth_timing.get_s1_azimuth_time_grid(lon, - lat, - hgt, - # This is the acq time from loop - t) - - if np.any(np.isnan(time_grid)): - raise ValueError('The azimuth time grid return nans meaning no orbit was downloaded.') - wgts = get_inverse_weights_for_dates(time_grid, - times, - temporal_window_hours=model._time_res) - # combine datasets - ds_out = datasets[0] - for var in ['wet', 'hydro', 'wet_total', 'hydro_total']: - ds_out[var] = sum([wgt * ds[var] for (wgt, ds) in zip(wgts, datasets)]) - ds_out.attrs['Date1'] = 0 - ds_out.attrs['Date2'] = 0 - weather_model_file = os.path.join( - os.path.dirname(wfiles[0]), - # TODO: clean up - os.path.basename(wfiles[0]).split('_')[0] + '_' + t.strftime('%Y_%m_%dT%H_%M_%S') + '_timeInterpAziGrid_' + '_'.join(wfiles[0].split('_')[-4:]), - ) - ds_out.to_netcdf(weather_model_file) - - # Case 7 - Anything else errors out - else: - n = len(wfiles) - raise NotImplementedError(f'The {interp_method} with {n} retrieved weather model files was not well posed ' - 'for the the delay workflow.') + # Get the weather model file + weather_model_file = getWeatherFile(wfiles, params['date_list'], times, t, model._dataset, interp_method) # Now process the delays try: @@ -696,3 +583,199 @@ def combineZTDFiles(): outName=args.out_name, localTime=args.local_time ) + + +def getWeatherFile(wfiles, times, t, interp_method='none'): + ''' + # Time interpolation + # + # Need to handle various cases, including if the exact weather model time is + # requested, or if one or more datetimes are not available from the weather + # model data provider + ''' + + # time interpolation method: number of expected files + ALLOWED_METHODS = {'none': 1, 'center_time': 2, 'azimuth_time_grid': 3} + + Nfiles = len(wfiles) + Ntimes = len(times) + + try: + Nfiles_expected = ALLOWED_METHODS[interp_method] + except KeyError: + raise ValueError('getWeatherFile: interp_method {} is not known'.format(interp_method)) + + Nmatch = (Nfiles_expected == Nfiles) + Tmatch = (Nfiles == Ntimes) + + # Case 1: no files downloaded + if Nfiles==0: + logger.error('No weather model data was successfully processed.') + return None + + # Case 2 - nearest weather model time is requested and retrieved + if (interp_method == 'none'): + weather_model_file = wfiles[0] + + + elif (interp_method == 'center_time'): + + if Nmatch: # Case 3: two weather files downloaded + weather_model_file = combine_weather_files( + wfiles, + t, + interp_method='center_time' + ) + elif Tmatch: # Case 4: Exact time is available without interpolation + logger.warning('Time interpolation is not needed as exact time is available') + weather_model_file = wfiles[0] + elif Nfiles == 1: # Case 5: one file does not download for some reason + logger.warning('getWeatherFile: One datetime is not available to download, defaulting to nearest available date') + weather_model_file = wfiles[0] + else: + raise RuntimeError('getWeatherFile: the number of files downloaded does not match the requested') + + elif (interp_method) == 'azimuth_time_grid': + + if Nmatch: # Case 6: all files downloaded + weather_model_file = combine_weather_files( + wfiles, + t, + interp_method='azimuth_time_grid' + ) + else: + raise RuntimeError( + 'getWeatherFile: the number of files downloaded ({})' + ' does not match the requested ({}) using azimuth ' + 'time grid'.format(Nfiles, Nfiles_expected) + ) + + # Case 7 - Anything else errors out + else: + N = len(wfiles) + raise NotImplementedError(f'The {interp_method} with {N} retrieved weather model files was not well posed ' + 'for the current workflow.') + + return weather_model_file + + +def combine_weather_files(wfiles, t, interp_method='center_time'): + '''Interpolate downloaded weather files and save to a single file''' + + STYLE = {'center_time': '_timeInterp_', 'azimuth_time_grid': '_timeInterpAziGrid_'} + + # read the individual datetime datasets + datasets = [xr.open_dataset(f) for f in wfiles] + + # Pull the datetimes from the datasets + times = [] + for ds in datasets: + times.append(datetime.datetime.strptime(ds.attrs['datetime'], '%Y_%m_%dT%H_%M_%S')) + + model = datasets[0].attrs['model_name'] + + # calculate relative weights of each dataset + if interp_method == 'center_time': + wgts = get_weights_time_interp(times, t) + elif interp_method == 'azimuth_time_grid': + time_grid = get_time_grid_for_aztime_interp(datasets, t, model) + wgts = get_inverse_weights_for_dates(time_grid, times) + + # combine datasets + ds_out = datasets[0] + for var in ['wet', 'hydro', 'wet_total', 'hydro_total']: + ds_out[var] = sum([wgt * ds[var] for (wgt, ds) in zip(wgts, datasets)]) + ds_out.attrs['Date1'] = 0 + ds_out.attrs['Date2'] = 0 + + # Give the weighted combination a new file name + weather_model_file = os.path.join( + os.path.dirname(wfiles[0]), + os.path.basename(wfiles[0]).split('_')[0] + '_' + + t.strftime('%Y_%m_%dT%H_%M_%S') + STYLE[interp_method] + + '_'.join(wfiles[0].split('_')[-4:]), + ) + + # write the combined results to disk + ds_out.to_netcdf(weather_model_file) + + return weather_model_file + + +def combine_files_using_azimuth_time(wfiles, t, times): + '''Combine files using azimuth time interpolation''' + + # read the individual datetime datasets + datasets = [xr.open_dataset(f) for f in wfiles] + + # Pull the datetimes from the datasets + times = [] + for ds in datasets: + times.append(datetime.datetime.strptime(ds.attrs['datetime'], '%Y_%m_%dT%H_%M_%S')) + + model = datasets[0].attrs['model_name'] + + time_grid = get_time_grid_for_aztime_interp(datasets, times, t, model) + + wgts = get_inverse_weights_for_dates(time_grid, times) + + # combine datasets + ds_out = datasets[0] + for var in ['wet', 'hydro', 'wet_total', 'hydro_total']: + ds_out[var] = sum([wgt * ds[var] for (wgt, ds) in zip(wgts, datasets)]) + ds_out.attrs['Date1'] = 0 + ds_out.attrs['Date2'] = 0 + + # Give the weighted combination a new file name + weather_model_file = os.path.join( + os.path.dirname(wfiles[0]), + os.path.basename(wfiles[0]).split('_')[0] + '_' + t.strftime('%Y_%m_%dT%H_%M_%S') + '_timeInterpAziGrid_' + '_'.join(wfiles[0].split('_')[-4:]), + ) + + # write the combined results to disk + ds_out.to_netcdf(weather_model_file) + + return weather_model_file + + +def get_weights_time_interp(times, t): + '''Calculate weights for time interpolation using simple inverse linear weighting''' + date1,date2 = times + wgts = [ 1 - get_dt(t, date1) / get_dt(date2, date1), 1 - get_dt(date2, t) / get_dt(date2, date1)] + + try: + assert np.isclose(np.sum(wgts), 1) + except AssertionError: + logger.error('Time interpolation weights do not sum to one; something is off with query datetime: %s', t) + return None + + return wgts + + +def get_time_grid_for_aztime_interp(datasets, t, model): + '''Calculate the time-varying grid for use with azimuth time interpolation''' + + # Each model will require some inspection here + # the subsequent s1 azimuth time grid requires dimension + # inputs to all have same dimensions and either be + # 1d or 3d. + AZ_TIME_ALLOWED_MODELS = ['hrrr', 'hrrrak'] + + if model.lower() in AZ_TIME_ALLOWED_MODELS: + lat_2d = datasets[0].latitude.data + lon_2d = datasets[0].longitude.data + z_1d = datasets[0].z.data + m, n, p = z_1d.shape[0], lat_2d.shape[0], lat_2d.shape[1] + + lat = np.broadcast_to(lat_2d, (m, n, p)) + lon = np.broadcast_to(lon_2d, (m, n, p)) + hgt = np.broadcast_to(z_1d[:, None, None], (m, n, p)) + + else: + raise NotImplementedError('Azimuth Time is currently only implemented for HRRR') + + time_grid = get_s1_azimuth_time_grid(lon, lat, hgt, t) # This is the acq time from loop + if np.any(np.isnan(time_grid)): + raise ValueError('The Time Grid return nans meaning no orbit was downloaded.') + + return time_grid diff --git a/tools/RAiDER/models/weatherModel.py b/tools/RAiDER/models/weatherModel.py index 267eacb65..70c3571a8 100755 --- a/tools/RAiDER/models/weatherModel.py +++ b/tools/RAiDER/models/weatherModel.py @@ -57,7 +57,7 @@ def __init__(self): self._classname = None self._dataset = None - self._name = None + self._Name = None self._wmLoc = None self._model_level_type = 'ml' @@ -748,6 +748,7 @@ def write(self): "datetime": datetime.datetime.strftime(self._time, "%Y_%m_%dT%H_%M_%S"), 'date_created': datetime.datetime.now().strftime("%Y_%m_%dT%H_%M_%S"), 'title': 'Weather model data and delay calculations', + 'model_name': self._Name } From 13ae10e1b04349d57d980a4737e0b4cedc865c45 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Wed, 20 Sep 2023 22:49:29 -0500 Subject: [PATCH 07/73] add custom exceptions for better error handling --- tools/RAiDER/cli/raider.py | 34 ++++++++++++--------- tools/RAiDER/models/customExceptions.py | 39 +++++++++++++++++++++++++ tools/RAiDER/models/weatherModel.py | 20 ++++--------- tools/RAiDER/processWM.py | 27 +++++++---------- 4 files changed, 77 insertions(+), 43 deletions(-) create mode 100644 tools/RAiDER/models/customExceptions.py diff --git a/tools/RAiDER/cli/raider.py b/tools/RAiDER/cli/raider.py index 579e5fced..a1d171b33 100644 --- a/tools/RAiDER/cli/raider.py +++ b/tools/RAiDER/cli/raider.py @@ -20,12 +20,15 @@ from RAiDER.cli.parser import add_out, add_cpus, add_verbose from RAiDER.cli.validators import DateListAction, date_type from RAiDER.models.allowed import ALLOWED_MODELS +from RAiDER.models.customExceptions import * from RAiDER.utilFcns import get_dt from RAiDER.s1_azimuth_timing import get_s1_azimuth_time_grid, get_inverse_weights_for_dates, get_times_for_azimuth_interpolation import traceback +TIME_INTERPOLATION_METHODS = ['none', 'center_time', 'azimuth_time_grid'] + HELP_MESSAGE = """ Command line options for RAiDER processing. Default options can be found by running @@ -269,26 +272,31 @@ def calcDelays(iargs=None): wfiles = [] for tt in times: try: - wfile = RAiDER.processWM.prepareWeatherModel(model, - tt, - aoi.bounds(), - makePlots=params['verbose']) + wfile = RAiDER.processWM.prepareWeatherModel( + model, + tt, + aoi.bounds(), + makePlots=params['verbose'] + ) wfiles.append(wfile) - # catch when requested datetime fails - except RuntimeError: - continue + except TryToKeepGoingError: + if interp_method in ['azimuth_time_grid', 'none']: + raise DatetimeFailed(model.Model(), tt) + else: + continue - # catch when something else within weather model class fails + # log when something else happens and then re-raise the error except Exception as e: S, N, W, E = wm_bounds logger.info(f'Weather model point bounds are {S:.2f}/{N:.2f}/{W:.2f}/{E:.2f}') logger.info(f'Query datetime: {tt}') msg = f'Downloading and/or preparation of {model._Name} failed.' logger.error(e) + logger.error('Weather model files are: {}'.format(wfiles)) logger.error(msg) - if interp_method == 'azimuth_time_grid': - break + raise + # dont process the delays for download only if dl_only: continue @@ -478,7 +486,7 @@ def calcDelaysGUNW(iargs: list[str] = None) -> xr.Dataset: p.add_argument( '-interp', '--interpolate-time', default='azimuth_time_grid', type=str, - choices=['none', 'center_time', 'azimuth_time_grid'], + choices=TIME_INTERPOLATION_METHODS, help=('How to interpolate across model time steps. Possible options are: ' '[\'none\', \'center_time\', \'azimuth_time_grid\'] ' 'None: means nearest model time; center_time: linearly across center time; ' @@ -595,13 +603,13 @@ def getWeatherFile(wfiles, times, t, interp_method='none'): ''' # time interpolation method: number of expected files - ALLOWED_METHODS = {'none': 1, 'center_time': 2, 'azimuth_time_grid': 3} + EXPECTED_NUM_FILES = {'none': 1, 'center_time': 2, 'azimuth_time_grid': 3} Nfiles = len(wfiles) Ntimes = len(times) try: - Nfiles_expected = ALLOWED_METHODS[interp_method] + Nfiles_expected = EXPECTED_NUM_FILES[interp_method] except KeyError: raise ValueError('getWeatherFile: interp_method {} is not known'.format(interp_method)) diff --git a/tools/RAiDER/models/customExceptions.py b/tools/RAiDER/models/customExceptions.py new file mode 100644 index 000000000..3d8540399 --- /dev/null +++ b/tools/RAiDER/models/customExceptions.py @@ -0,0 +1,39 @@ +class DatetimeFailed(Exception): + def __init__(self, model, time): + msg = f"Weather model {model} failed to download for datetime {time}" + super().__init__(msg) + + +class DatetimeNotAvailable(Exception): + def __init__(self, model, time): + msg = f"Weather model {model} was not found for datetime {time}" + super().__init__(msg) + + +class DatetimeOutsideRange(Exception): + def __init__(self, model, time): + msg = f"Time {time} is outside the available date range for weather model {model}" + super().__init__(msg) + + +class ExistingWeatherModelTooSmall(Exception): + def __init__(self): + msg = 'The weather model passed does not cover all of the input ' \ + 'points; you may need to download a larger area.' + super().__init__(msg) + + +class TryToKeepGoingError(Exception): + def __init__(self, date=None): + if date is not None: + msg = 'The weather model does not exist for date {date}, so I will try to use the closest available date.' + else: + msg = 'I will try to keep going' + super().__init__(msg) + + +class CriticalError(Exception): + def __init__(self): + msg = 'I have experienced a critical error, please take a look at the log files' + super().__init__(msg) + diff --git a/tools/RAiDER/models/weatherModel.py b/tools/RAiDER/models/weatherModel.py index 70c3571a8..37165f07f 100755 --- a/tools/RAiDER/models/weatherModel.py +++ b/tools/RAiDER/models/weatherModel.py @@ -18,6 +18,7 @@ from RAiDER.interpolator import fillna3D from RAiDER.logger import logger from RAiDER.models import plotWeather as plots, weatherModel +from RAiDER.models.customExceptions import * from RAiDER.utilFcns import ( robmax, robmin, write2NETCDF4core, calcgeoh, transform_coords, clip_bbox ) @@ -31,7 +32,6 @@ 'HRRR-AK': 3, } - class WeatherModel(ABC): ''' Implement a generic weather model for getting estimated SAR delays @@ -160,12 +160,9 @@ def fetch(self, out, time): # write the error raised by the weather model API to the log try: self._fetch(out) - err = False - except Exception as E: - err = E - - return err + logger.exception(E) + raise @abstractmethod @@ -308,22 +305,17 @@ def checkTime(self, time): self.Model(), self._valid_range[0].date(), end_time ) - msg = f"Weather model {self.Model()} is not available at: {time}" - if time < self._valid_range[0]: - logger.error(msg) - raise RuntimeError(msg) + raise DatetimeOutsideRange(self.Model(), time) if self._valid_range[1] is not None: if self._valid_range[1] == 'Present': pass elif self._valid_range[1] < time: - logger.error(msg) - raise RuntimeError(msg) + raise DatetimeOutsideRange(self.Model(), time) if time > datetime.datetime.utcnow() - self._lag_time: - logger.error(msg) - raise RuntimeError(msg) + raise DatetimeOutsideRange(self.Model(), time) def setLevelType(self, levelType): diff --git a/tools/RAiDER/processWM.py b/tools/RAiDER/processWM.py index b1b034061..214bf210d 100755 --- a/tools/RAiDER/processWM.py +++ b/tools/RAiDER/processWM.py @@ -15,7 +15,7 @@ from RAiDER.logger import logger from RAiDER.utilFcns import getTimeFromFile from RAiDER.models.weatherModel import make_raw_weather_data_filename, checkContainment_raw - +from RAiDER.models.customExceptions import * def prepareWeatherModel( weather_model, @@ -67,10 +67,10 @@ def prepareWeatherModel( # if no weather model files supplied, check the standard location else: - E = weather_model.fetch(path_wm_raw, time) - if E: - logger.warning (E) - raise RuntimeError + try: + weather_model.fetch(path_wm_raw, time) + except DatetimeOutsideRange: + raise TryToKeepGoingError # If only downloading, exit now if download_only: @@ -89,11 +89,9 @@ def prepareWeatherModel( ) containment = weather_model.checkContainment(ll_bounds) - if not containment and weather_model.Model() in 'GMAO ERA5 ERA5T HRES'.split(): - msg = 'The weather model passed does not cover all of the input ' \ - 'points; you may need to download a larger area.' - logger.error(msg) - raise RuntimeError(msg) + if not containment and weather_model.Model() not in 'HRRR'.split(): + raise ExistingWeatherModelTooSmall + return f # Logging some basic info @@ -131,17 +129,14 @@ def prepareWeatherModel( except Exception as e: logger.exception("Unable to save weathermodel to file") logger.exception(e) - raise RuntimeError("Unable to save weathermodel to file") + raise CriticalError finally: wm = weather_model.Model() del weather_model - if not containment and wm in 'GMAO ERA5 ERA5T HRES'.split(): - msg = 'The weather model passed does not cover all of the input ' \ - 'points; you may need to download a larger area.' - logger.error(msg) - raise RuntimeError(msg) + if not containment and wm not in 'HRRR'.split(): + raise ExistingWeatherModelTooSmall else: return f From 5c1679f5d361686c8699599df2451f3ea8d2a776 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Thu, 21 Sep 2023 20:34:58 -0500 Subject: [PATCH 08/73] allow az time option to proceed with Tmatch --- tools/RAiDER/cli/raider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/RAiDER/cli/raider.py b/tools/RAiDER/cli/raider.py index a1d171b33..6857b4355 100644 --- a/tools/RAiDER/cli/raider.py +++ b/tools/RAiDER/cli/raider.py @@ -645,7 +645,7 @@ def getWeatherFile(wfiles, times, t, interp_method='none'): elif (interp_method) == 'azimuth_time_grid': - if Nmatch: # Case 6: all files downloaded + if Nmatch or Tmatch: # Case 6: all files downloaded weather_model_file = combine_weather_files( wfiles, t, From ed36505ad49e282f0ffc6c9eec89333509ccfdee Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Wed, 27 Sep 2023 21:39:34 -0500 Subject: [PATCH 09/73] roll back model name in weather model --- test/test_weather_model.py | 5 +++-- tools/RAiDER/cli/raider.py | 12 ++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/test/test_weather_model.py b/test/test_weather_model.py index dc2d0d1c2..d5f41ed95 100644 --- a/test/test_weather_model.py +++ b/test/test_weather_model.py @@ -23,6 +23,7 @@ from RAiDER.models.gmao import GMAO from RAiDER.models.merra2 import MERRA2 from RAiDER.models.ncmr import NCMR +from RAiDER.models.customExceptions import * _LON0 = 0 @@ -309,7 +310,7 @@ def test_hrrr(hrrr: HRRR): assert wm._Name == 'HRRR' assert wm._valid_range[0] == datetime.datetime(2016, 7, 15) assert wm._proj.to_epsg() is None - with pytest.raises(RuntimeError): + with pytest.raises(DatetimeOutsideRange): wm.checkTime(datetime.datetime(2010, 7, 15)) wm.checkTime(datetime.datetime(2018, 7, 12)) @@ -329,7 +330,7 @@ def test_hrrrak(hrrrak: HRRRAK): with pytest.raises(ValueError): wm.checkValidBounds([15, 20, 265, 270]) - with pytest.raises(RuntimeError): + with pytest.raises(DatetimeOutsideRange): wm.checkTime(datetime.datetime(2018, 7, 12)) wm.checkTime(datetime.datetime(2018, 7, 15)) diff --git a/tools/RAiDER/cli/raider.py b/tools/RAiDER/cli/raider.py index 6857b4355..19cc2e56a 100644 --- a/tools/RAiDER/cli/raider.py +++ b/tools/RAiDER/cli/raider.py @@ -302,7 +302,7 @@ def calcDelays(iargs=None): continue # Get the weather model file - weather_model_file = getWeatherFile(wfiles, params['date_list'], times, t, model._dataset, interp_method) + weather_model_file = getWeatherFile(wfiles, times, t, model._Name, interp_method) # Now process the delays try: @@ -593,7 +593,7 @@ def combineZTDFiles(): ) -def getWeatherFile(wfiles, times, t, interp_method='none'): +def getWeatherFile(wfiles, times, t, model, interp_method='none'): ''' # Time interpolation # @@ -632,6 +632,7 @@ def getWeatherFile(wfiles, times, t, interp_method='none'): weather_model_file = combine_weather_files( wfiles, t, + model, interp_method='center_time' ) elif Tmatch: # Case 4: Exact time is available without interpolation @@ -648,7 +649,8 @@ def getWeatherFile(wfiles, times, t, interp_method='none'): if Nmatch or Tmatch: # Case 6: all files downloaded weather_model_file = combine_weather_files( wfiles, - t, + t, + model, interp_method='azimuth_time_grid' ) else: @@ -667,7 +669,7 @@ def getWeatherFile(wfiles, times, t, interp_method='none'): return weather_model_file -def combine_weather_files(wfiles, t, interp_method='center_time'): +def combine_weather_files(wfiles, t, model, interp_method='center_time'): '''Interpolate downloaded weather files and save to a single file''' STYLE = {'center_time': '_timeInterp_', 'azimuth_time_grid': '_timeInterpAziGrid_'} @@ -679,8 +681,6 @@ def combine_weather_files(wfiles, t, interp_method='center_time'): times = [] for ds in datasets: times.append(datetime.datetime.strptime(ds.attrs['datetime'], '%Y_%m_%dT%H_%M_%S')) - - model = datasets[0].attrs['model_name'] # calculate relative weights of each dataset if interp_method == 'center_time': From 1af2aca844f94067cb2d0113c252bf6eddd51c19 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Thu, 28 Sep 2023 21:21:40 -0500 Subject: [PATCH 10/73] add a few more custom exceptions --- test/test_GUNW.py | 6 +++--- tools/RAiDER/cli/raider.py | 15 +++++++-------- tools/RAiDER/models/customExceptions.py | 16 +++++++++++++++- tools/RAiDER/s1_azimuth_timing.py | 2 ++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/test/test_GUNW.py b/test/test_GUNW.py index 5089e65c0..1574d872e 100644 --- a/test/test_GUNW.py +++ b/test/test_GUNW.py @@ -2,7 +2,6 @@ import json import os import shutil -import subprocess import unittest from pathlib import Path @@ -23,6 +22,7 @@ check_weather_model_availability ) from RAiDER.cli.raider import calcDelaysGUNW +from RAiDER.models.customExceptions import * def compute_transform(lats, lons): @@ -515,7 +515,7 @@ def test_GUNW_workflow_fails_if_a_download_fails(gunw_azimuth_test, orbit_dict_f '-interp', 'azimuth_time_grid' ] - with pytest.raises(ValueError): + with pytest.raises(WrongNumberOfFiles): calcDelaysGUNW(iargs_1) RAiDER.s1_azimuth_timing.get_s1_azimuth_time_grid.assert_not_called() @@ -534,6 +534,6 @@ def test_value_error_for_file_inputs_when_no_data_available(mocker): '-interp', 'azimuth_time_grid' ] - with pytest.raises(ValueError): + with pytest.raises(NoWeatherModelData): calcDelaysGUNW(iargs) RAiDER.aria.prepFromGUNW.main.assert_not_called() diff --git a/tools/RAiDER/cli/raider.py b/tools/RAiDER/cli/raider.py index 19cc2e56a..82fd5be64 100644 --- a/tools/RAiDER/cli/raider.py +++ b/tools/RAiDER/cli/raider.py @@ -256,7 +256,7 @@ def calcDelays(iargs=None): logger.warning('interp_method is not specified, defaulting to \'none\', i.e. nearest datetime for delay ' 'calculation') - if (interp_method is not 'azimuth_time_grid'): + if (interp_method != 'azimuth_time_grid'): times = get_nearest_wmtimes( t, [model.dtime() if model.dtime() is not None else 6][0] ) if interp_method == 'center_time' else [t] @@ -516,7 +516,7 @@ def calcDelaysGUNW(iargs: list[str] = None) -> xr.Dataset: file_name = iargs.file.split('/')[-1] gunw_id = file_name.replace('.nc', '') if not RAiDER.aria.prepFromGUNW.check_hrrr_dataset_availablity_for_s1_azimuth_time_interpolation(gunw_id): - raise ValueError('The required HRRR data for time-grid interpolation is not available') + raise NoWeatherModelData('The required HRRR data for time-grid interpolation is not available') if not iargs.file and iargs.bucket: # only use GUNW ID for checking if HRRR available @@ -642,7 +642,7 @@ def getWeatherFile(wfiles, times, t, model, interp_method='none'): logger.warning('getWeatherFile: One datetime is not available to download, defaulting to nearest available date') weather_model_file = wfiles[0] else: - raise RuntimeError('getWeatherFile: the number of files downloaded does not match the requested') + raise WrongNumberOfFiles(Nfiles_expected, Nfiles) elif (interp_method) == 'azimuth_time_grid': @@ -654,11 +654,7 @@ def getWeatherFile(wfiles, times, t, model, interp_method='none'): interp_method='azimuth_time_grid' ) else: - raise RuntimeError( - 'getWeatherFile: the number of files downloaded ({})' - ' does not match the requested ({}) using azimuth ' - 'time grid'.format(Nfiles, Nfiles_expected) - ) + raise WrongNumberOfFiles(Nfiles_expected, Nfiles) # Case 7 - Anything else errors out else: @@ -681,6 +677,9 @@ def combine_weather_files(wfiles, t, model, interp_method='center_time'): times = [] for ds in datasets: times.append(datetime.datetime.strptime(ds.attrs['datetime'], '%Y_%m_%dT%H_%M_%S')) + + if len(times)==0: + raise NoWeatherModelData() # calculate relative weights of each dataset if interp_method == 'center_time': diff --git a/tools/RAiDER/models/customExceptions.py b/tools/RAiDER/models/customExceptions.py index 3d8540399..d30cbc341 100644 --- a/tools/RAiDER/models/customExceptions.py +++ b/tools/RAiDER/models/customExceptions.py @@ -31,9 +31,23 @@ def __init__(self, date=None): msg = 'I will try to keep going' super().__init__(msg) - class CriticalError(Exception): def __init__(self): msg = 'I have experienced a critical error, please take a look at the log files' super().__init__(msg) + +class WrongNumberOfFiles(Exception): + def __init__(self, Nexp, Navail): + msg = 'The number of files downloaded does not match the requested, ' + 'I expected {} and got {}, aborting'.format(Nexp, Navail) + super().__init__(msg) + +class NoWeatherModelData(Exception): + def __init__(self, custom_msg=None): + if custom_msg is None: + msg = 'No weather model files were available to download, aborting' + else: + msg = custom_msg + super().__init__(msg) + diff --git a/tools/RAiDER/s1_azimuth_timing.py b/tools/RAiDER/s1_azimuth_timing.py index 70a44ee24..4532e34c2 100644 --- a/tools/RAiDER/s1_azimuth_timing.py +++ b/tools/RAiDER/s1_azimuth_timing.py @@ -330,6 +330,8 @@ def get_inverse_weights_for_dates(azimuth_time_array: np.ndarray, n_dates = len(dates) if n_unique_dates != n_dates: raise ValueError('Dates provided must be unique') + if n_dates == 0: + raise ValueError('No dates provided') if not all([isinstance(date, datetime.datetime) for date in dates]): raise TypeError('dates must be all datetimes') From 33dce06674348362c1082817ca45e74f9eb10427 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Thu, 28 Sep 2023 22:45:52 -0500 Subject: [PATCH 11/73] fix a few caught exceptions --- test/test_GUNW.py | 2 +- test/test_losreader.py | 2 +- test/test_weather_model.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/test_GUNW.py b/test/test_GUNW.py index 1574d872e..981d2de6d 100644 --- a/test/test_GUNW.py +++ b/test/test_GUNW.py @@ -515,7 +515,7 @@ def test_GUNW_workflow_fails_if_a_download_fails(gunw_azimuth_test, orbit_dict_f '-interp', 'azimuth_time_grid' ] - with pytest.raises(WrongNumberOfFiles): + with pytest.raises(RuntimeError): calcDelaysGUNW(iargs_1) RAiDER.s1_azimuth_timing.get_s1_azimuth_time_grid.assert_not_called() diff --git a/test/test_losreader.py b/test/test_losreader.py index 3def26308..2a7125354 100644 --- a/test/test_losreader.py +++ b/test/test_losreader.py @@ -147,7 +147,7 @@ def test_get_sv_3(svs): def test_get_sv_4(svs): true_svs = svs filename = os.path.join(ORB_DIR, 'no_exist.txt') - with pytest.raises(FileNotFoundError): + with pytest.raises(ValueError): get_sv(filename, true_svs[0][0], pad=3*60) diff --git a/test/test_weather_model.py b/test/test_weather_model.py index d5f41ed95..f7300018a 100644 --- a/test/test_weather_model.py +++ b/test/test_weather_model.py @@ -157,12 +157,12 @@ def test_weatherModel_basic1(model: MockWeatherModel): wm.setTime('19720229', fmt='%Y%m%d') # test a leap year assert wm._time == datetime.datetime(1972, 2, 29, 0, 0, 0) - with pytest.raises(RuntimeError): + with pytest.raises(DatetimeOutsideRange): wm.checkTime(datetime.datetime(1950, 1, 1)) wm.checkTime(datetime.datetime(2000, 1, 1)) - with pytest.raises(RuntimeError): + with pytest.raises(DatetimeOutsideRange): wm.checkTime(datetime.datetime.now()) @@ -432,7 +432,7 @@ def test_hrrr_badloc(wm:hrrr=HRRR): with pytest.raises(ValueError): wm._fetch('dummy_filename') - +@pytest.mark.long def test_hrrrak_dl(tmp_path: Path, wm:hrrrak=HRRRAK): wm = wm() d = tmp_path / "files" @@ -444,7 +444,7 @@ def test_hrrrak_dl(tmp_path: Path, wm:hrrrak=HRRRAK): wm._fetch(fname) assert True - +@pytest.mark.long def test_hrrrak_dl2(tmp_path: Path, wm:hrrrak=HRRRAK): # test the international date line crossing wm = wm() From d09c9bb285ac409422b2ae6bf483d1f2b8282661 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Thu, 28 Sep 2023 22:55:31 -0500 Subject: [PATCH 12/73] remove long mark --- test/test_weather_model.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/test_weather_model.py b/test/test_weather_model.py index f7300018a..6ead8cfc2 100644 --- a/test/test_weather_model.py +++ b/test/test_weather_model.py @@ -432,7 +432,6 @@ def test_hrrr_badloc(wm:hrrr=HRRR): with pytest.raises(ValueError): wm._fetch('dummy_filename') -@pytest.mark.long def test_hrrrak_dl(tmp_path: Path, wm:hrrrak=HRRRAK): wm = wm() d = tmp_path / "files" @@ -444,7 +443,6 @@ def test_hrrrak_dl(tmp_path: Path, wm:hrrrak=HRRRAK): wm._fetch(fname) assert True -@pytest.mark.long def test_hrrrak_dl2(tmp_path: Path, wm:hrrrak=HRRRAK): # test the international date line crossing wm = wm() From 243dc698ab41d0f1e70435bcd14f60108d74329b Mon Sep 17 00:00:00 2001 From: Brett Buzzanga Date: Tue, 10 Oct 2023 11:34:32 -0700 Subject: [PATCH 13/73] make weather file directory when it doesnt exist --- CHANGELOG.md | 3 +++ tools/RAiDER/processWM.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e412ab38..5c3f179d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/) and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +* make weather file directory when it doesnt exist + ## [0.4.5] ## Fixes diff --git a/tools/RAiDER/processWM.py b/tools/RAiDER/processWM.py index 214bf210d..2b1bdc742 100755 --- a/tools/RAiDER/processWM.py +++ b/tools/RAiDER/processWM.py @@ -67,6 +67,7 @@ def prepareWeatherModel( # if no weather model files supplied, check the standard location else: + os.makedirs(os.path.dirname(path_wm_raw), exist_ok=True) try: weather_model.fetch(path_wm_raw, time) except DatetimeOutsideRange: @@ -91,7 +92,7 @@ def prepareWeatherModel( containment = weather_model.checkContainment(ll_bounds) if not containment and weather_model.Model() not in 'HRRR'.split(): raise ExistingWeatherModelTooSmall - + return f # Logging some basic info From d7197f0e3eb678ea012655f84d0a3f19ca9ce77e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Oct 2023 02:50:52 +0000 Subject: [PATCH 14/73] Bump ASFHyP3/actions from 0.8.2 to 0.8.3 Bumps [ASFHyP3/actions](https://github.com/asfhyp3/actions) from 0.8.2 to 0.8.3. - [Release notes](https://github.com/asfhyp3/actions/releases) - [Changelog](https://github.com/ASFHyP3/actions/blob/develop/CHANGELOG.md) - [Commits](https://github.com/asfhyp3/actions/compare/v0.8.2...v0.8.3) --- updated-dependencies: - dependency-name: ASFHyP3/actions dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 4 ++-- .github/workflows/changelog.yml | 2 +- .github/workflows/labeled-pr.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/tag.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 671db476e..fc4d13e32 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,13 +12,13 @@ on: jobs: call-version-info-workflow: - uses: ASFHyP3/actions/.github/workflows/reusable-version-info.yml@v0.8.2 + uses: ASFHyP3/actions/.github/workflows/reusable-version-info.yml@v0.8.3 with: python_version: '3.10' call-docker-ghcr-workflow: needs: call-version-info-workflow - uses: ASFHyP3/actions/.github/workflows/reusable-docker-ghcr.yml@v0.8.2 + uses: ASFHyP3/actions/.github/workflows/reusable-docker-ghcr.yml@v0.8.3 with: version_tag: ${{ needs.call-version-info-workflow.outputs.version_tag }} release_branch: main diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 0a50ce126..369b918a5 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -13,6 +13,6 @@ on: jobs: call-changelog-check-workflow: - uses: ASFHyP3/actions/.github/workflows/reusable-changelog-check.yml@v0.8.2 + uses: ASFHyP3/actions/.github/workflows/reusable-changelog-check.yml@v0.8.3 secrets: USER_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/labeled-pr.yml b/.github/workflows/labeled-pr.yml index ee8a5f5ea..103ae29a7 100644 --- a/.github/workflows/labeled-pr.yml +++ b/.github/workflows/labeled-pr.yml @@ -12,4 +12,4 @@ on: jobs: call-labeled-pr-check-workflow: - uses: ASFHyP3/actions/.github/workflows/reusable-labeled-pr-check.yml@v0.8.2 + uses: ASFHyP3/actions/.github/workflows/reusable-labeled-pr-check.yml@v0.8.3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb7e27ce1..310ea6db4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: jobs: call-release-workflow: - uses: ASFHyP3/actions/.github/workflows/reusable-release.yml@v0.8.2 + uses: ASFHyP3/actions/.github/workflows/reusable-release.yml@v0.8.3 with: release_prefix: RAiDER develop_branch: dev diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index ad0761eb0..f2328e9c7 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -7,7 +7,7 @@ on: jobs: call-bump-version-workflow: - uses: ASFHyP3/actions/.github/workflows/reusable-bump-version.yml@v0.8.2 + uses: ASFHyP3/actions/.github/workflows/reusable-bump-version.yml@v0.8.3 with: user: dbekaert email: bekaertdavid@gmail.com From 912d2a4c9bd074305ccda3b29d62037c67fc7f9b Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Tue, 17 Oct 2023 16:18:09 -0800 Subject: [PATCH 15/73] Ensure data/alaska.geojson.zip is included in package build --- pyproject.toml | 2 +- setup.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f01a75a1c..d5815cdb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ zip-safe = false where = ["tools"] [tool.setuptools.package-data] -"*" = ["*.yml", "*.yaml"] +"*" = ["*.yml", "*.yaml", "*.zip"] [tool.isort] known_first_party = "RAiDER" diff --git a/setup.py b/setup.py index 9f163fc77..a8a76bc1e 100755 --- a/setup.py +++ b/setup.py @@ -44,5 +44,4 @@ setup( ext_modules=cython_extensions + pybind_extensions, cmdclass={"build_ext": build_ext}, - package_data={'tools': ['RAiDER/models/*.zip']} ) From 92e55eaac8974e578ab63932eb0ad0610e4c4ca7 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 18 Oct 2023 18:12:07 -0800 Subject: [PATCH 16/73] Update changelog --- CHANGELOG.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3f179d8..1ab2fe4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,20 +7,24 @@ and this project adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/) and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -* make weather file directory when it doesnt exist +## [Unreleased] + +### Fixes +* make weather file directory when it doesn't exist +* Ensures the `models/data/alaska.geojson.zip` file is packaged when building from the source tarball ## [0.4.5] -## Fixes +### Fixes * [#583](https://github.com/dbekaert/RAiDER/issues/583): it appears that since the issues with geo2rdr cropped up during our processing campaign, there has been a new release of ISCE3 that resolves these failures with `geo2rdr` and the time interpolation that uses this ISCE3 routine. * [#584](https://github.com/dbekaert/RAiDER/issues/584): failed Raider step function in hyp3 job submission when HRRR model times are not available (even within the valid model range) - to resolve, we check availability of files when delay workflow called with a) azimuth_grid_interpolation and b) input to workflow is GUNW. If weather model files are unavailable and the GUNW is on s3, do nothing to GUNW (i.e. do not add tropo delay) and exit successfully. If weather model files are unavailable and the GUNW is on local disk, raise `ValueError` * [#587](https://github.com/dbekaert/RAiDER/issues/587): similar to 584 except added here to the mix is control flow in RAiDER.py passes over numerous exceptions in workflow. This is fixed identically as above. * [#596](https://github.com/dbekaert/RAiDER/issues/596): the "prefix" for aws does not include the final netcdf file name, just the sub-directories in the bucket and therefore extra logic must be added to determine the GUNW netcdf file name (and the assocaited reference/secondary dates). We proceed by downloading the data which is needed regardless. Tests are updated. -## Removed +### Removed * Removes `update` option (either `True` or `False`) from calcGUNW workflow which asks whether the GUNW should be updated or not. In existing code, it was not being used/applied, i.e. previous workflow always updated GUNW. Removed input arguments related from respective functions so that it can be updated later. -## Added +### Added * Allow for Hyp3 GUNW workflow with HRRR (i.e. specifying a gunw path in s3) to successfully exit if any of the HRRR model times required for `azimuth-time-grid` interpolation (which is default interpolatin method) are not available when using bucket inputs (i.e. only on the cloud) * For GUNW workflow, when model is HRRR, azimuth_time_grid interpolation used, and using a local GUNW, if requisite weather model files are not available for raise a ValueError (before processing) * Raise a value error if non-unique dates are given in the function `get_inverse_weights_for_dates` in `s1_azimuth_timing.py` @@ -33,7 +37,7 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * Ensures ISCE3 is `>=0.15.0` * Uses correct HyP3 S3 prefix conventions and filename suffix within test patches to improve readability of what tests are mocking (see comments in #597). -## Changed +### Changed * Get only 2 or 3 model times required for azimuth-time-interpolation (previously obtained all 3 as it was easier to implement) - this ensures slightly less failures associated with HRRR availability. Importantly, if a acquisition time occurs during a model time, then we order by distance to the reference time and how early it occurs (so earlier times come first if two times are equidistant to the aquisition time). * Made test names in `test_GUNW.py` more descriptive * Numpy docstrings and general linting to modified function including removing variables that were not being accessed From 10f01c1a014e6baab87eec97720cc70fa4a66ff2 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 18 Oct 2023 22:03:28 -0800 Subject: [PATCH 17/73] make isce3 an optional import for `s1_azimuth_timing.py` which is on the `raider -h` import path. --- tools/RAiDER/s1_azimuth_timing.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/RAiDER/s1_azimuth_timing.py b/tools/RAiDER/s1_azimuth_timing.py index 4532e34c2..034531c7a 100644 --- a/tools/RAiDER/s1_azimuth_timing.py +++ b/tools/RAiDER/s1_azimuth_timing.py @@ -3,11 +3,15 @@ import asf_search as asf import hyp3lib.get_orb -import isce3.ext.isce3 as isce import numpy as np import pandas as pd from shapely.geometry import Point +try: + import isce3.ext.isce3 as isce +except ImportError: + isce = None + from .losreader import get_orbit as get_isce_orbit @@ -84,6 +88,8 @@ def get_azimuth_time_grid(lon_mesh: np.ndarray, Technically, this is "sensor neutral" since it uses an orb object. ''' + if isce is None: + raise ImportError(f'isce3 is required for {__name__}. Use conda to install isce3`') num_iteration = 100 residual_threshold = 1.0e-7 From 20bafab6a167f711d0b28cb55b22dc8822a0a963 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 18 Oct 2023 22:11:20 -0800 Subject: [PATCH 18/73] remove fn name in error message --- tools/RAiDER/s1_azimuth_timing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/RAiDER/s1_azimuth_timing.py b/tools/RAiDER/s1_azimuth_timing.py index 034531c7a..2f963577a 100644 --- a/tools/RAiDER/s1_azimuth_timing.py +++ b/tools/RAiDER/s1_azimuth_timing.py @@ -89,7 +89,7 @@ def get_azimuth_time_grid(lon_mesh: np.ndarray, Technically, this is "sensor neutral" since it uses an orb object. ''' if isce is None: - raise ImportError(f'isce3 is required for {__name__}. Use conda to install isce3`') + raise ImportError(f'isce3 is required for this function. Use conda to install isce3`') num_iteration = 100 residual_threshold = 1.0e-7 From 4349ae8d0293b766446396814d63cfaf41709703 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 18 Oct 2023 22:22:35 -0800 Subject: [PATCH 19/73] drop isce typehints as they still are exectued --- tools/RAiDER/s1_azimuth_timing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/RAiDER/s1_azimuth_timing.py b/tools/RAiDER/s1_azimuth_timing.py index 2f963577a..053d97396 100644 --- a/tools/RAiDER/s1_azimuth_timing.py +++ b/tools/RAiDER/s1_azimuth_timing.py @@ -80,7 +80,7 @@ def get_slc_id_from_point_and_time(lon: float, def get_azimuth_time_grid(lon_mesh: np.ndarray, lat_mesh: np.ndarray, hgt_mesh: np.ndarray, - orb: isce.core.Orbit) -> np.ndarray: + orb) -> np.ndarray: ''' Source: https://github.com/dbekaert/RAiDER/blob/dev/tools/RAiDER/losreader.py#L601C1-L674C22 From 2680b208ed2e8699afd421721be596fbbf214c50 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 20 Oct 2023 10:30:40 -0800 Subject: [PATCH 20/73] Use a forward reference for isce3 typehinting --- tools/RAiDER/s1_azimuth_timing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/RAiDER/s1_azimuth_timing.py b/tools/RAiDER/s1_azimuth_timing.py index 053d97396..8d8d4bfbb 100644 --- a/tools/RAiDER/s1_azimuth_timing.py +++ b/tools/RAiDER/s1_azimuth_timing.py @@ -80,7 +80,7 @@ def get_slc_id_from_point_and_time(lon: float, def get_azimuth_time_grid(lon_mesh: np.ndarray, lat_mesh: np.ndarray, hgt_mesh: np.ndarray, - orb) -> np.ndarray: + orb: 'isce.core.Orbit') -> np.ndarray: ''' Source: https://github.com/dbekaert/RAiDER/blob/dev/tools/RAiDER/losreader.py#L601C1-L674C22 From 2f1ece89318d848b5ef8be33377f1b647eb1430a Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 20 Oct 2023 10:36:56 -0800 Subject: [PATCH 21/73] Better handle isce3 imports --- tools/RAiDER/losreader.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/tools/RAiDER/losreader.py b/tools/RAiDER/losreader.py index b73158a5a..426305f0b 100644 --- a/tools/RAiDER/losreader.py +++ b/tools/RAiDER/losreader.py @@ -9,6 +9,7 @@ import os import datetime import shelve +from abc import ABC from typing import Union from pathlib import PosixPath @@ -17,8 +18,10 @@ import xml.etree.ElementTree as ET except ImportError: ET = None - -from abc import ABC +try: + import isce3.ext.isce3 as isce +except ImportError: + isce = None from RAiDER.constants import _ZREF from RAiDER.utilFcns import ( @@ -178,8 +181,12 @@ class Raytracing(LOS): >>> from RAiDER.losreader import Raytracing >>> import numpy as np """ + def __init__(self, filename=None, los_convention='isce', time=None, look_dir = 'right', pad=600): '''read in and parse a statevector file''' + if isce is None: + raise ImportError(f'isce3 is required for this class. Use conda to install isce3`') + super().__init__() self._ray_trace = True self._file = filename @@ -191,7 +198,6 @@ def __init__(self, filename=None, los_convention='isce', time=None, look_dir = ' raise NotImplementedError() # ISCE3 data structures - import isce3.ext.isce3 as isce if self._time is not None: # __call__ called in checkArgs; keep for modularity self._orbit = get_orbit(self._file, self._time, pad=pad) @@ -231,14 +237,15 @@ def getLookVectors(self, ht, llh, xyz, yy): ''' Calculate look vectors for raytracing ''' + if isce is None: + raise ImportError(f'isce3 is required for this method. Use conda to install isce3`') + # TODO - Modify when isce3 vectorization is available los = np.full(yy.shape + (3,), np.nan) llh = llh.copy() llh[0] = np.deg2rad(llh[0]) llh[1] = np.deg2rad(llh[1]) - import isce3.ext.isce3 as isce - for ii in range(yy.shape[0]): for jj in range(yy.shape[1]): inp = np.array([llh[0][ii, jj], llh[1][ii, jj], ht]) @@ -598,6 +605,9 @@ def state_to_los(svs, llh_targets): >>> svs = losr.read_ESA_Orbit_file(esa_orbit_file) >>> LOS = losr.state_to_los(*svs, [lats, lons, heights], xyz) ''' + if isce is None: + raise ImportError(f'isce3 is required for this function. Use conda to install isce3`') + # check the inputs if np.min(svs.shape) < 4: raise RuntimeError( @@ -606,7 +616,6 @@ def state_to_los(svs, llh_targets): ) # Convert svs to isce3 orbit - import isce3.ext.isce3 as isce orb = isce.core.Orbit([ isce.core.StateVector( isce.core.DateTime(row[0]), @@ -660,6 +669,8 @@ def get_radar_pos(llh, orb): los: ndarray - Satellite incidence angle sr: ndarray - Slant range in meters ''' + if isce is None: + raise ImportError(f'isce3 is required for this function. Use conda to install isce3`') num_iteration = 30 residual_threshold = 1.0e-7 @@ -670,7 +681,6 @@ def get_radar_pos(llh, orb): ) # Get some isce3 constants for this inversion - import isce3.ext.isce3 as isce # TODO - Assuming right-looking for now elp = isce.core.Ellipsoid() dop = isce.core.LUT2d() @@ -763,9 +773,10 @@ def get_orbit(orbit_file: Union[list, str], requested time (should be about 600 seconds) ''' - # First load the state vectors into an isce orbit - import isce3.ext.isce3 as isce + if isce is None: + raise ImportError(f'isce3 is required for this function. Use conda to install isce3`') + # First load the state vectors into an isce orbit svs = np.stack(get_sv(orbit_file, ref_time, pad), axis=-1) sv_objs = [] # format for ISCE From 6c75125cb58cfba6513329199f5cf8e04576a47b Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 20 Oct 2023 10:53:00 -0800 Subject: [PATCH 22/73] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab2fe4da..f15829812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes * make weather file directory when it doesn't exist * Ensures the `models/data/alaska.geojson.zip` file is packaged when building from the source tarball +* Make ISCE3 an optional dependency in `s1_azimuth_timing.py` ## [0.4.5] From 1dfdf2aac72f710d0d935527954ef1805ff33f25 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 20 Oct 2023 16:00:22 -0800 Subject: [PATCH 23/73] Add some info about optional dependencies to the contributing guide --- CONTRIBUTING.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b29736270..eb2173730 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,34 @@ If you get stuck at any point you can create an [issue on GitHub](https://github For more information on contributing to open source projects, [GitHub's own guide](https://guides.github.com/activities/contributing-to-open-source/) is a great starting point if you are new to version control. +## Optional Dependencies + +In order to better support the NISAR SDS (see: [#533](https://github.com/dbekaert/RAiDER/issues/533)), RAiDER has some optional dependencies: + +* ISCE3 +* Pandas +* Rasterio +* Progressbar + +RAiDER distributes two conda packages, `raider-base` a lighter-weight package that does depend on the optional dependencies, and `raider` which includes all dependencies. When using, or adding new, optional dependenices in RAiDER, please follow this pattern: +1. When you import the optional dependency, handle import errors like: + ```python + try: + import optional_dependency + except ImportError: + optional_dependency = None + ``` + Note: you *do not* need to delay imports until use with this pattern. +2. At the top of any function/method that uses the optional dependency, throw if it's missing like: + ```python + if optional_dependency is None: + raise ImportError('optional_dependency is required for this function. Use conda to install optional_dependency') + ``` +3. If you want to add type hints for objects in the optional_dependency, use a forward declaration like: + ```python + def function_that_uses_optional_dependency(obj: 'optional_dependency.obj'): + ``` + Note: the typehint is a string here. ## Git workflows ## From e890c269797580c1a77161324e1c2c8e23802afe Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Tue, 3 Oct 2023 07:20:30 -0500 Subject: [PATCH 24/73] make sure all explicit imports are installed --- environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/environment.yml b/environment.yml index 464a9ee91..31dbc4bec 100644 --- a/environment.yml +++ b/environment.yml @@ -13,6 +13,7 @@ dependencies: # For running - asf_search - boto3 + - cartopy - cdsapi - cfgrib - cmake From b4028f56c749840f67bb34758988a3fd28cfe6d8 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Tue, 3 Oct 2023 06:57:41 -0500 Subject: [PATCH 25/73] add unit tests for downloadGNSSdelays and remove unused code --- test/test_checkArgs.py | 6 +- test/test_dem.py | 11 +- test/test_gnss.py | 63 +++++- tools/RAiDER/delayFcns.py | 9 - tools/RAiDER/dem.py | 34 +--- tools/RAiDER/getStationDelays.py | 54 +++-- tools/RAiDER/gnss/downloadGNSSDelays.py | 257 ++++++++++++++++-------- tools/RAiDER/models/customExceptions.py | 13 ++ 8 files changed, 296 insertions(+), 151 deletions(-) diff --git a/test/test_checkArgs.py b/test/test_checkArgs.py index 5e4604c98..9f210ee80 100644 --- a/test/test_checkArgs.py +++ b/test/test_checkArgs.py @@ -10,11 +10,12 @@ from test import TEST_DIR, pushd from RAiDER.cli import DEFAULT_DICT -from RAiDER.checkArgs import checkArgs, makeDelayFileNames +from RAiDER.checkArgs import checkArgs, makeDelayFileNames, get_raster_ext from RAiDER.llreader import BoundingBox, StationFile, RasterRDR from RAiDER.losreader import Zenith, Conventional, Raytracing from RAiDER.models.gmao import GMAO + SCENARIO_1 = os.path.join(TEST_DIR, "scenario_1") SCENARIO_2 = os.path.join(TEST_DIR, "scenario_2") @@ -180,4 +181,7 @@ def test_makeDelayFileNames_4(): ) +def test_get_raster_ext(): + with pytest.raises(ValueError): + get_raster_ext('dummy_format') diff --git a/test/test_dem.py b/test/test_dem.py index c005ef3e4..f2314fdd4 100644 --- a/test/test_dem.py +++ b/test/test_dem.py @@ -1,7 +1,14 @@ import os import pytest -import numpy as np +from test import TEST_DIR + +from RAiDER.dem import download_dem + + +def test_download_dem_1(): + SCENARIO_1 = os.path.join(TEST_DIR, "scenario_1") + hts = download_dem(outName=os.path.join(SCENARIO_1,'geom', 'hgt.rdr'), overwrite=False) + assert True -from test import pushd diff --git a/test/test_gnss.py b/test/test_gnss.py index 3b9a621ee..b626a84a7 100644 --- a/test/test_gnss.py +++ b/test/test_gnss.py @@ -4,13 +4,20 @@ import pandas as pd -from test import pushd +from test import pushd, TEST_DIR +SCENARIO2_DIR = os.path.join(TEST_DIR, "scenario_2") + from RAiDER.gnss.processDelayFiles import ( addDateTimeToFiles, getDateTime, concatDelayFiles ) +from RAiDER.gnss.downloadGNSSDelays import ( + get_stats_by_llh, get_station_list, download_tropo_delays, + filterToBBox, +) +from RAiDER.models.customExceptions import NoStationDataFoundError def file_len(fname): @@ -99,3 +106,57 @@ def test_concatDelayFiles(tmp_path, temp_file): outName=out_name ) assert file_len(out_name) == file_length + +def test_get_stats_by_llh2(): + stations = get_stats_by_llh(llhBox=[10,18,360-93,360-88]) + assert isinstance(stations, pd.DataFrame) + + +def test_get_stats_by_llh3(): + with pytest.raises(ValueError): + get_stats_by_llh(llhBox=[10,18,-93,-88]) + + + +def test_get_station_list(): + stations, output_file = get_station_list(stationFile=os.path.join(SCENARIO2_DIR, 'stations.csv'), writeStationFile=False) + assert isinstance(stations,list) + assert isinstance(output_file,pd.DataFrame) + + +def test_download_tropo_delays1(): + with pytest.raises(NotImplementedError): + download_tropo_delays(stats=['GUAT', 'SLAC', 'CRSE'], years=[2022], gps_repo='dummy_repo') + + +def test_download_tropo_delays2(): + with pytest.raises(NoStationDataFoundError): + download_tropo_delays(stats=['dummy_station'], years=[2022]) + + +def test_download_tropo_delays2(tmp_path): + stations, output_file = get_station_list(stationFile=os.path.join(SCENARIO2_DIR, 'stations.csv')) + + # spot check a couple of stations + assert 'CAPE' in stations + assert 'FGNW' in stations + assert isinstance(output_file, str) + + # try downloading the delays + download_tropo_delays(stats=stations, years=[2022], writeDir=tmp_path) + assert True + + +def test_filterByBBox1(): + _, station_data = get_station_list(stationFile=os.path.join(SCENARIO2_DIR, 'stations.csv'), writeStationFile=False) + with pytest.raises(ValueError): + filterToBBox(station_data, llhBox=[34,38,-120,-115]) + + +def test_filterByBBox2(): + _, station_data = get_station_list(stationFile=os.path.join(SCENARIO2_DIR, 'stations.csv'), writeStationFile=False) + new_data = filterToBBox(station_data, llhBox=[34,38,240,245]) + for stat in ['CAPE','MHMS','NVCO']: + assert stat not in new_data['ID'].to_list() + for stat in ['FGNW', 'JPLT', 'NVTP', 'WLHG', 'WORG']: + assert stat in new_data['ID'].to_list() diff --git a/tools/RAiDER/delayFcns.py b/tools/RAiDER/delayFcns.py index c56a2f01c..a71de3b21 100755 --- a/tools/RAiDER/delayFcns.py +++ b/tools/RAiDER/delayFcns.py @@ -79,12 +79,3 @@ def make_shared_raw(inarr): return shared_arr_np -def interpolate2(fun, x, y, z): - ''' - helper function to make the interpolation step cleaner - ''' - in_shape = x.shape - out = fun((y.ravel(), x.ravel(), z.ravel())) # note that this re-ordering is on purpose to match the weather model - outData = out.reshape(in_shape) - return outData - diff --git a/tools/RAiDER/dem.py b/tools/RAiDER/dem.py index c21338ea7..6c125a51b 100644 --- a/tools/RAiDER/dem.py +++ b/tools/RAiDER/dem.py @@ -14,42 +14,12 @@ import rasterio from dem_stitcher.stitcher import stitch_dem -from RAiDER.interpolator import interpolateDEM from RAiDER.logger import logger -from RAiDER.utilFcns import rio_open, get_file_and_band - - -def getHeights(ll_bounds, dem_type, dem_file, lats=None, lons=None): - ''' - Fcn to return heights from a DEM, either one that already exists - or will download one if needed. - ''' - # height_type, height_data = heights - if dem_type == 'hgt': - htinfo = get_file_and_band(dem_file) - hts = rio_open(htinfo[0], band=htinfo[1]) - - elif dem_type == 'csv': - # Heights are in the .csv file - hts = pd.read_csv(dem_file)['Hgt_m'].values - - elif dem_type == 'interpolate': - # heights will be vertically interpolated to the heightlvs - hts = None - - elif (dem_type == 'download') or (dem_type == 'dem'): - zvals, metadata = download_dem(ll_bounds, writeDEM=True, outName=dem_file) - - #TODO: check this - lons, lats = np.meshgrid(lons, lats) - # Interpolate to the query points - hts = interpolateDEM(zvals, metadata['transform'], (lats, lons), method='nearest') - - return hts +from RAiDER.utilFcns import rio_open def download_dem( - ll_bounds, + ll_bounds=None, writeDEM=False, outName='warpedDEM', buf=0.02, diff --git a/tools/RAiDER/getStationDelays.py b/tools/RAiDER/getStationDelays.py index 5b0cc4911..f61f8b8db 100644 --- a/tools/RAiDER/getStationDelays.py +++ b/tools/RAiDER/getStationDelays.py @@ -24,28 +24,42 @@ def get_delays_UNR(stationFile, filename, dateList, returnTime=None): Parses and returns a dictionary containing either (1) all the GPS delays, if returnTime is None, or (2) only the delay at the closest times to to returnTime. - Inputs: - stationFile - a .gz station delay file - returnTime - specified time of GPS delay - Outputs: - a dict and CSV file containing the times and delay information - (delay in mm, delay uncertainty, delay gradients) - *NOTE: Due to a formatting error in the tropo SINEX files, the two tropospheric gradient columns - (TGNTOT and TGETOT) are interchanged, as are the formal error columns (_SIG). + + Args: + stationFile: binary - a .gz station delay file + filename: ? - ? + dateList: list of datetime - ? + returnTime: datetime - specified time of GPS delay (default all times) + + Returns: + None + + The function writes a CSV file containing the times and delay information + (delay in mm, delay uncertainty, delay gradients) + + Refer to the following sites to interpret stationFile variable names: + ftp://igs.org/pub/data/format/sinex_tropo.txt + http://geodesy.unr.edu/gps_timeseries/README_trop2.txt + Wet and hydrostratic delays were derived as so: + Constants —> k1 = 0.704, k2 = 0.776, k3 = 3739.0, m = 18.0152/28.9644, + k2' = k2-(k1*m) = 0.33812796398337275, Rv = 461.5 J/(kg·K), ρl = 997 kg/m^3 + + *NOTE: wet delays passed here are computed using + PMV = precipitable water vapor, + P = total atm pressure, + Tm = mean temp of the column, as: + + Wet zenith delay = 10^-6 ρlRv(k2' + k3/Tm) PMV + Hydrostatic zenith delay = Total zenith delay - wet zenith delay = k1*(P/Tm) + + Source —> Hanssen, R. F. (2001) eqns. 6.2.7-10 + + *NOTE: Due to a formatting error in the tropo SINEX files, the two + tropospheric gradient columns (TGNTOT and TGETOT) are interchanged, + as are the formal error columns (_SIG). + Source —> http://geodesy.unr.edu/gps_timeseries/README_trop2.txt) ''' - # Refer to the following sites to interpret stationFile variable names: - # ftp://igs.org/pub/data/format/sinex_tropo.txt - # http://geodesy.unr.edu/gps_timeseries/README_trop2.txt - # Wet and hydrostratic delays were derived as so: - # Constants —> k1 = 0.704, k2 = 0.776, k3 = 3739.0, m = 18.0152/28.9644, - # k2' = k2-(k1*m) = 0.33812796398337275, Rv = 461.5 J/(kg·K), ρl = 997 kg/m^3 - # Note wet delays passed here may be computed as so - # where PMV = precipitable water vapor, P = total atm pressure, Tm = mean temp of the column —> - # Wet zenith delay = 10^-6 ρlRv(k2' + k3/Tm) PMV - # Hydrostatic zenith delay = Total zenith delay - wet zenith delay = k1*(P/Tm) - # Source —> Hanssen, R. F. (2001) eqns. 6.2.7-10 - # sort through station zip files allstationTarfiles = [] # if URL diff --git a/tools/RAiDER/gnss/downloadGNSSDelays.py b/tools/RAiDER/gnss/downloadGNSSDelays.py index 04f1ed283..c4792beb6 100755 --- a/tools/RAiDER/gnss/downloadGNSSDelays.py +++ b/tools/RAiDER/gnss/downloadGNSSDelays.py @@ -13,79 +13,99 @@ from RAiDER.logger import logger, logging from RAiDER.getStationDelays import get_station_data from RAiDER.utilFcns import requests_retry_session +from RAiDER.models.customExceptions import NoStationDataFoundError # base URL for UNR repository _UNR_URL = "http://geodesy.unr.edu/" -def get_station_list(bbox=None, writeLoc=None, userstatList=None, name_appendix=''): +def get_station_list( + bbox=None, + stationFile=None, + userstatList=None, + writeStationFile=True, + writeLoc=None, + name_appendix='' + ): ''' Creates a list of stations inside a lat/lon bounding box from a source - Inputs: - bbox - length-4 list of floats that describes a bounding box. Format is - S N W E + + Args: + bbox: list of float - length-4 list of floats that describes a bounding box. + Format is S N W E + writeLoc: string - Directory to write data + userstatList: list - list of specific IDs to access + name_appendix: str - name to append to output file + + Returns: + stations: list of strings - station IDs to access + output_file: string - file to write delays ''' - writeLoc = os.path.join(writeLoc or os.getcwd(), 'gnssStationList_overbbox' + name_appendix + '.csv') - - if userstatList: - userstatList = read_text_file(userstatList) - - statList = get_stats_by_llh(llhBox=bbox, userstatList=userstatList) + if stationFile: + station_data = pd.read_csv(stationFile) + elif userstatList: + station_data = read_text_file(userstatList) + elif bbox: + station_data = get_stats_by_llh(llhBox=bbox) # write to file and pass final stations list - statList.to_csv(writeLoc, index=False) - stations = list(statList['ID'].values) + if writeStationFile: + output_file = os.path.join(writeLoc or os.getcwd(), 'gnssStationList_overbbox' + name_appendix + '.csv') + station_data.to_csv(output_file, index=False) - return stations, writeLoc + return list(station_data['ID'].values), [output_file if writeStationFile else station_data][0] -def get_stats_by_llh(llhBox=None, baseURL=_UNR_URL, userstatList=None): +def get_stats_by_llh(llhBox=None, baseURL=_UNR_URL): ''' Function to pull lat, lon, height, beginning date, end date, and number of solutions for stations inside the bounding box llhBox. - llhBox should be a tuple with format (lat1, lat2, lon1, lon2), where lat1, lon1 define the lower left-hand corner and lat2, lon2 - define the upper right corner. + llhBox should be a tuple in SNWE format. ''' if llhBox is None: llhBox = [-90, 90, 0, 360] + S,N,W,E = llhBox + if (W < 0) or (E < 0): + raise ValueError('get_stats_by_llh: bounding box must use 0 < lon < 360') - stationHoldings = '{}NGLStationPages/DataHoldings.txt'.format(baseURL) + stationHoldings = '{}NGLStationPages/llh.out'.format(baseURL) # it's a file like object and works just like a file - session = requests_retry_session() - data = session.get(stationHoldings) - stations = [] - for ind, line in enumerate(data.text.splitlines()): # files are iterable - if ind == 0: - continue - statID, lat, lon, height = get_ID(line) - # Only pass if in bbox - # And if user list of stations specified, only pass info for stations within list - if in_box(lat, lon, llhBox) and (not userstatList or statID in userstatList): - # convert lon into range [-180,180] - lon = fix_lons(lon) - stations.append({'ID': statID, 'Lat': lat, 'Lon': lon, 'Hgt_m': height}) - - logger.info('%d stations were found in %s', len(stations), llhBox) - stations = pd.DataFrame(stations) - # Report stations from user's list that do not cover bbox - if userstatList: - userstatList = [ - i for i in userstatList if i not in stations['ID'].to_list()] - if userstatList: - logger.warning( - "The following user-input stations are not covered by the input " - "bounding box %s: %s", - str(llhBox).strip('[]'), str(userstatList).strip('[]') - ) + stations = pd.read_csv( + stationHoldings, + delim_whitespace=True, + names=['ID', 'Lat', 'Lon', 'Hgt_m'] + ) + stations = filterToBBox(stations, llhBox) + stations['Lon'] = ((stations['Lon'].values + 180) % 360) - 180 # convert lons to -180 - 180 + + stations = filterToBBox(stations, llhBox) return stations -def download_tropo_delays(stats, years, gps_repo=None, writeDir='.', numCPUs=8, download=False): +def download_tropo_delays( + stats, years, + gps_repo='UNR', + writeDir='.', + numCPUs=8, + download=False, + ): ''' - Check for and download GNSS tropospheric delays from an archive. If download is True then - files will be physically downloaded, which again is not necessary as data can be virtually accessed. + Check for and download GNSS tropospheric delays from an archive. If + download is True then files will be physically downloaded, but this + is not necessary as data can be virtually accessed. + + Args: + stats: stations - Stations to access + years: list of int - A list of years to be downloaded + gps_repo: string - SNWE bounds target area to ensure weather model contains them + writeDir: string - False if preprocessing weather model data + numCPUs: int - whether to write debug plots + download: bool - True if you want to download even when the weather model exists + + Returns: + None ''' # argument checking @@ -97,7 +117,9 @@ def download_tropo_delays(stats, years, gps_repo=None, writeDir='.', numCPUs=8, # Iterate over stations and years and check or download data stat_year_tup = itertools.product(stats, years) stat_year_tup = ((*tup, writeDir, download) for tup in stat_year_tup) + # Parallelize remote querying of station locations + results = [] with multiprocessing.Pool(numCPUs) as multipool: # only record valid path if gps_repo == 'UNR': @@ -105,8 +127,12 @@ def download_tropo_delays(stats, years, gps_repo=None, writeDir='.', numCPUs=8, fileurl for fileurl in multipool.starmap(download_UNR, stat_year_tup) if fileurl['path'] ] + else: + raise NotImplementedError('download_tropo_delays: gps_repo "{}" not yet implemented'.format(gps_repo)) # Write results to file + if len(results)==0: + raise NoStationDataFoundError(station_list=stats['ID'].to_list(), years=years) statDF = pd.DataFrame(results).set_index('ID') statDF.to_csv(os.path.join(writeDir, '{}gnssStationList_overbbox_withpaths.csv'.format(gps_repo))) @@ -224,52 +250,14 @@ def main(inps=None): # Setup bounding box if bounding_box: - if isinstance(bounding_box, str) and not os.path.isfile(bounding_box): - try: - bbox = [float(val) for val in bounding_box.split()] - except ValueError: - raise Exception( - 'Cannot understand the --bbox argument. String input is incorrect or path does not exist.') - elif isinstance(bounding_box, list): - bbox = bounding_box - - else: - raise Exception('Passing a file with a bounding box not yet supported.') - - long_cross_zero = 1 if bbox[2] * bbox[3] < 0 else 0 - - # if necessary, convert negative longitudes to positive - if bbox[2] < 0: - bbox[2] += 360 - - if bbox[3] < 0: - bbox[3] += 360 - + bbox, long_cross_zero = parse_bbox(bounding_box) # If bbox not specified, query stations across the entire globe else: bbox = [-90, 90, 0, 360] long_cross_zero = 1 # Handle station query - if long_cross_zero == 1: - bbox1 = bbox.copy() - bbox2 = bbox.copy() - bbox1[3] = 360.0 - bbox2[2] = 0.0 - stats1, origstatsFile1 = get_station_list(bbox=bbox1, writeLoc=out, userstatList=station_file, name_appendix='_a') - stats2, origstatsFile2 = get_station_list(bbox=bbox2, writeLoc=out, userstatList=station_file, name_appendix='_b') - stats = stats1 + stats2 - origstatsFile = origstatsFile1[:-6] + '.csv' - file_a = pd.read_csv(origstatsFile1) - file_b = pd.read_csv(origstatsFile2) - frames = [file_a, file_b] - result = pd.concat(frames, ignore_index=True) - result.to_csv(origstatsFile, index=False) - else: - if bbox[3] < bbox[2]: - bbox[3] = 360.0 - stats, origstatsFile = get_station_list( - bbox=bbox, writeLoc=out, userstatList=station_file) + stats = get_stats(bbox, long_cross_zero, out, station_file) # iterate over years years = list(set([i.year for i in dateList])) @@ -299,3 +287,100 @@ def main(inps=None): ) logger.debug('Completed processing') + + +def parse_bbox(bounding_box): + ''' + Parse bounding box arguments + ''' + if isinstance(bounding_box, str) and not os.path.isfile(bounding_box): + try: + bbox = [float(val) for val in bounding_box.split()] + except ValueError: + raise Exception( + 'Cannot understand the --bbox argument. String input is incorrect or path does not exist.') + elif isinstance(bounding_box, list): + bbox = bounding_box + + else: + raise Exception('Passing a file with a bounding box not yet supported.') + + long_cross_zero = 1 if bbox[2] * bbox[3] < 0 else 0 + + # if necessary, convert negative longitudes to positive + if bbox[2] < 0: + bbox[2] += 360 + + if bbox[3] < 0: + bbox[3] += 360 + + return bbox, long_cross_zero + + +def get_stats(bbox, long_cross_zero, out, station_file): + ''' + Pull the stations needed + ''' + if long_cross_zero == 1: + bbox1 = bbox.copy() + bbox2 = bbox.copy() + bbox1[3] = 360.0 + bbox2[2] = 0.0 + stats1, origstatsFile1 = get_station_list(bbox=bbox1, writeLoc=out, stationFile=station_file, name_appendix='_a') + stats2, origstatsFile2 = get_station_list(bbox=bbox2, writeLoc=out, stationFile=station_file, name_appendix='_b') + stats = stats1 + stats2 + origstatsFile = origstatsFile1[:-6] + '.csv' + file_a = pd.read_csv(origstatsFile1) + file_b = pd.read_csv(origstatsFile2) + frames = [file_a, file_b] + result = pd.concat(frames, ignore_index=True) + result.to_csv(origstatsFile, index=False) + else: + if bbox[3] < bbox[2]: + bbox[3] = 360.0 + stats, origstatsFile = get_station_list( + bbox=bbox, + writeLoc=out, + stationFile=station_file + ) + + return stats + + +def filterToBBox(stations, llhBox): + ''' + Filter a dataframe by lat/lon. + *NOTE: llhBox longitude format should be 0-360 + + Args: + stations: DataFrame - a pandas dataframe with "Lat" and "Lon" columns + llhBox: list of float - 4-element list: [S, N, W, E] + + Returns: + a Pandas Dataframe with stations removed that are not inside llhBox + ''' + S,N,W,E = llhBox + if (W<0) or (E<0): + raise ValueError('llhBox longitude format should 0-360') + + # For a user-provided file, need to check the column names + keys = stations.columns + lat_keys = ['lat', 'latitude', 'Lat', 'Latitude'] + lon_keys = ['lon', 'longitude', 'Lon', 'Longitude'] + index = None + for k,key in enumerate(lat_keys): + if key in list(keys): + index = k + break + if index is None: + raise KeyError('filterToBBox: No valid column names found for latitude and longitude') + lon_key = lon_keys[k] + lat_key = lat_keys[k] + + if stations[lon_key].min() < 0: + # convert lon format to -180 to 180 + W,E = [((D+ 180) % 360) - 180 for D in [W,E]] + + mask = (stations[lat_key] > S) & (stations[lat_key] < N) & (stations[lon_key] < E) & (stations[lon_key] > W) + return stations[mask] + diff --git a/tools/RAiDER/models/customExceptions.py b/tools/RAiDER/models/customExceptions.py index d30cbc341..3bc9b69a9 100644 --- a/tools/RAiDER/models/customExceptions.py +++ b/tools/RAiDER/models/customExceptions.py @@ -51,3 +51,16 @@ def __init__(self, custom_msg=None): msg = custom_msg super().__init__(msg) + +class NoStationDataFoundError(Exception): + def __init__(self, station_list=None, years=None): + if (station_list is None) and (years is None): + msg = 'No GNSS station data was found' + elif (years is None): + msg = 'No data was found for GNSS stations {}'.format(station_list) + elif station_list is None: + msg = 'No data was found for years {}'.format(years) + else: + msg = 'No data was found for GNSS stations {} and years {}'.format(station_list, years) + + super().__init__(msg) From 305f584dd0c1c7faa3b9f0838a9f03ebff43bde4 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Tue, 3 Oct 2023 07:33:37 -0500 Subject: [PATCH 26/73] fix a small bug in the dem unit test --- test/test_dem.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/test_dem.py b/test/test_dem.py index f2314fdd4..63a63b106 100644 --- a/test/test_dem.py +++ b/test/test_dem.py @@ -8,7 +8,10 @@ def test_download_dem_1(): SCENARIO_1 = os.path.join(TEST_DIR, "scenario_1") - hts = download_dem(outName=os.path.join(SCENARIO_1,'geom', 'hgt.rdr'), overwrite=False) + hts = download_dem( + outName=os.path.join(SCENARIO_1,'geom', 'hgt.rdr'), + overwrite=False + ) assert True From c65b2627d75eaaac31ed28cb74293bebd92a4bb7 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Tue, 3 Oct 2023 10:11:32 -0500 Subject: [PATCH 27/73] update dem unit test --- test/test_dem.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/test_dem.py b/test/test_dem.py index 63a63b106..52c592f29 100644 --- a/test/test_dem.py +++ b/test/test_dem.py @@ -7,11 +7,12 @@ def test_download_dem_1(): - SCENARIO_1 = os.path.join(TEST_DIR, "scenario_1") - hts = download_dem( - outName=os.path.join(SCENARIO_1,'geom', 'hgt.rdr'), + SCENARIO_1 = os.path.join(TEST_DIR, "scenario_4") + hts, meta = download_dem( + outName=os.path.join(SCENARIO_1,'warpedDEM.dem'), overwrite=False ) - assert True + assert hts.shape == (45,226) + assert meta['crs'] is None From 9ebbbfa29b97bf032d099c181c604126a7cbb3fc Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Thu, 5 Oct 2023 22:07:14 -0500 Subject: [PATCH 28/73] add geocoded file unit test --- test/scenario_0/small_dem.tif | Bin 0 -> 174463 bytes test/test_llreader.py | 12 +++++++++++- tools/RAiDER/cli/validators.py | 1 - tools/RAiDER/dem.py | 12 ++++++------ tools/RAiDER/llreader.py | 5 ++++- 5 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 test/scenario_0/small_dem.tif diff --git a/test/scenario_0/small_dem.tif b/test/scenario_0/small_dem.tif new file mode 100644 index 0000000000000000000000000000000000000000..bab74e11a382d7ec57a904444f8a194be051834b GIT binary patch literal 174463 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⁡Ig*2@1jT} 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

G{z`y_<%q>oSs*1) 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

jup|P1I5HYeNKtG6WK2Gk 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

SQ&BOq zg+6S*726mtbLh#ey|qKQqa4AbByK*zU$taJn?#{2IGYl3+O?#WmJg!KLFT%{P&AHjlp_4k9m=w9&tt&vO zncl!#tVq-em1K}E3^>cf7e-_}wMh%c!?&XpK11X`G0>uk4CzUn#lEw<&cqqM(lj3m zdZTEaOu-w^NvqExyGkQqwj;^J%l9^9*g{O!&0ML?T&z#5)V$G3&H@|I3D-@n0!WgV z#lqLk>j}$T1f~;t$jW9OOkh47I6g#XI6UVnqqfWoQ5u+##*FAmoe@Ua3<`*niTjAm zOYA|F79Q}Ds_e|qD?4KCL`KghNC@8Oi+^s{31``g@-;?NlisBc()9>bWm!_)Q$tPs=h= zJxNkcCQt23Qguu@y3?uJnWe=~Qk7656Lr8-cDG|fNV7z$JTo-3ktWRz(_CskWD^jq z5=OI&yXh5)@eCg9#y+&0M=Tf7XsDX>L7NOku9`I?>mZVO@>D?|CyKN+vs@BoMkHc~ zN6kporAgM@fK;ErY8%#9q*oOCDNkDup#n z15gOvS=FlNM$Hh_ycv;BtuptZ^{YqR7+Z`3&bbsC97>_r;}Qv;a-Gb41^p>o0$^mJSu$oqVX(vg%mvTcFgIKu=beB@+-B*i79vWq;BwiW(Gqkkiz9ePS zJn76mINoi>L0vr4%$wSMsTQQ3N(Jm%ZOxkv&C%L-(d~&jBpcmhmmo2{!OQSC>>a-Y zUP?-bR{gYJjkP?z+0Av@*&QR-6L{VGLtBiC-{t;W+eI{s5>Si^J`4@cRSRBJzQIhI zL0w*6gl@i-gIHXi+||w>l*``a%qMbo%#CLs8zZZelD{O+RBI|ARCV7*61-GNAW}vz zt@_`V8eN@};kEm@vcp~zMxGoov^D`^4S71tM~EC?G?@c93=2z^1zaMiPJBI4Bfemr z7R+^}L3KM;E)JII_t<)NT;lCs^CFiLw;Q~oxogK;G)+HKVJ}ORKQ0wdeUD+TI$@n| zHhlffRy$O_9J#w@H?t8phCvlI;JVcaH_O~T@#Wde7LbK9!3$2+e7@q06=I#D)3ssZ z%%5c5El*Joo3eUTHc=miL#sT^SkgZdl}1WTO~lIjwoW!d{xrnCSfo|gStdQ@&N^ep zI_2#pW9#{2``hF;K;#SEW<9^EMS54Xy3;gd-c2-D%<0@bKha%*B)^{xm`jv%*U)%2awvbkt{*dtZiKXTBOw&U{~;+e(bmEHm8Z z9%MjvWYt^Vm3(1Y+HA3fePoqb2+Zoqg6G5p@WG+=%(6I^-Kh}y_hAcIR${fat&A?} z@Y@X?~Vzg_7n5KFvD&5G$~0&Vr_OdR{DGK*}n{HRaqyI?MDv zK6au}Bj~=Y$5>=VUaig_rc&JLwc{YSl3VYm)v(yMbJ*RntJ(ys{CvN4_yte^Yc{lN zKDBF3wrf_GYkrw)g^=g$PwBGy;zQoRlnCqHF~%0ZMjW~(po_^|71NCAYAnOr6N^cV z#Oh_o!KRGm9#helk+~g37}>nvoJmb-fP(gI~If*G><^yEF$7CG09(v?OoH9?o*xz+9nEGHHTpZsvC0ZuIlRP zDuKCe^G9{kFCG#>zR+V zNRX!B2{r7|KHuQ^7KP&|_pvUp>+_!$TQyJkivzws^vCYHdyJZDvL3JBPb%yx_kZD5Ku=-{R4SUrNz z5v!w~=VwOG?9nH1R7XL@vLI9=%38uV?hV`Z=;H zbZ*Z(_<&7S((=)_g4@-KWHTjI8`@YWVcT zPL8E|MO;(nZ2DR8>c;D`MXqYyuF;*V-(x4odPL8Y$}=Xkg-8H?ht+*gTzz-e{=NHs zm+e%wS=>L5@9h;}kfPXTPTH8YM@#ZP{q;W;W;`#^aw5C_RYv$!)qa2KexE9S$Nhe` z*N6hJ0RnY3T z$>qQaRH=plVE`B)1_a`DIbVP$v(@MVg1Z1NfD3>E*Z{KtEPx9dZAO^104-{@dbMV; zTcuZNHTu<3nPa8dX_V@MzyPlRD}V~vGOPh|RRAuxRY(H!ce`J2m;3ey0fWI|aF~1T z%N29RV)5AAhCd~d%4G7{T*hBDna*bJQC$REL!;4Ybeec{mPIAjNj1{hhQ?(y08IAV zEsox2H=a+o%isqBgTnwXD4+$+9=}wyfGt%Dg*v7Hs&#suez#|_+p$!76&iJ;o#VD# zE|^=*?+xq3dwrh#MteK+`F(z$f4ArFsQzic&+GvIw5f_#ls8SQXqu*}ycnB7FhXYq zHi^1=;W)6njSRx-B8=pz3R9T1#7kqI@GQ|f&Z$KaTvF!5v2;eapv(i(y*?4U-5kdZ zOYt7RQT%-%$Pyd{0LaoTkt4J)lWz$^P;45U%1P9r3Bk?df}ulT0*NlmlKeX57VcW2 zwk^{sw<;DJVwv~;|l&dO_65NEY6?9`eOxKKKSkBeeSb;qD1&H_9b`}8jSlAqSWnk7`4}{tn zU89C!$W5%@%QnruZ9^EnK`>NTEln{16CB5La|0ylDk^e z=^0;mF2c%K8z#@Qa$Jo0wQW0hF}QP52J6nr8;?AK>HQqPzEqlyIdgD19;mNmt0!R- z&Jlyk#y#9#YsvRqrT?_xoX>&H_)nKZxxh@nih}Rk@0ru}S0|y5b-W*5vd$j9Oy=JF zQv2Tbp8t2VchTR2^7wx{?d15|b{pq)AAg_Lep;o`>MHK+Qt>>+`n`nEmJ+~NQ467@OmieQ zCq&ccv!TQCg7EGiHI!ED;NyvAO?{X>$N>@}JY`qWDkT}X(F#v{xQTJLDMc9MqZo73 zitzQrL8oF_9>e>HEU~aP=w@9-L}rlDl0-ll*COL&jFHjCNJlstk>9H6gC}YHLAS9N z5rh4RQ5An9X$vA{B!`sJm4QgvRU_qNYLZe$7AV*-;p5ale@U)CD@gwr*U5}u@)l9b zd4(z_9L1Hh9#zbjZ!9Hy3P3En9?6*Yn^n55f4f)|IrmPVxeWHV6MdC;c6q6b^w>8bHmcJtd(NE`?H(9ShluD_#(OT_))J zEv3ZOlhnG6QnKSe5v?Ad^opQTI)fSMJw=tVx|LLuAiI@p*;fhK{!M87cizO)rgCnm4^ z<N!{c_AJ1L)3=z&)noU89CQM4E$&MK;ER1TYk!li2n!EALU zu%^@zGouin6v~%URzFXI!tBvhID)nSS-jY=Z5trO>dL zdU58h6`ITg<{>aOuUbocc=2uKuQv-kX`Jn*@UDN)+J8@K7aI|?E=6qEZzJf?x^wC#}@aJGdt%D_X2F0U!lS67Uz)mv86YNwW4<%Fz{@Xzv`<@NKGSrU|C0 zV>)ZLku=lA_(Njuo&~^nKLy;p9g*-dy|OoM?9AJ~RP6pGQFfiR+P8;%=PNP4G_ot- z<#U2?u0yeSLsWvCmxFRLm5}&%4Xivuk{*Y@#O;QTo*GYlD_!-iYqD{(*;9aWEOQQe z6NBLpQ=~e+Ps(>kMt)qgmvHW3NV%q}=GdEiFHSMexYs?~eE%Bl-F?XUM`h`qosjgd z)zW%TX6@X!xLqBanm3nqFp)fiSzRfD`tI55)@OfUi_^h7e}(NEo3=W*ZgRX^(eT@s zsP69PYdWtrcwAb`bDd|-{O26!+jFql&j+Zye~0wFrRcv1CF31S9q}<&a=jlT!~CCg z^1DFG_jC8&YHwNVesiw*?mN%%etO&5f7AMJmFhcJs`bA==X(Cv^w%e|<^I>ocWcanU+)ee4_iTU5Z=zkww^*^H>`rl*xdwU!AYe|^I%9yuo zo~AAI_)h-#j|9(e()ljT{jcbPkKlC&-2PA}0uSQ$Zl<-b^!sc2&CE=fuCS*J;{cDy z+mD9h&=B-+6#Z}LFGXrpTJ!oG941jCxWb2IP*3gL7>=O*_ zaPVTR=x|p7u$cj{;`N8&3Q+3`a3umTtoD$ys4%$)kUICN?))o%1aQjq5D?E$jN}c| z0cJ-IPnix-8u`%e5|Hl_(4_v#KM#*>3voJt!zl9&?&A-FuB@{3Oz#a)ROpbBherhM zu^kD~VqB3Y67g#iv2J;eX1ntGB#`wMk{Klu8vgQE4{|2M@qCRjfZD1e^Kro6u@2Jl z#w;<&>}IbdPm?6DU ztN6Qdxe!v-Ci1Ap%1AGIPkGE5$!Wloe&ac^H9d?^L+^OOC&Q-IL8F`FQ+w=9Xqp*`bAGQ z^D8-!?E3R+<};$i6LSzUGdGTTFwJu4vavgl5j8Pk74yeGv-v!a>oQWkJtmgT)UP(w zvQU(_BNQJHa%}|ED9==vI5hh>RG&~3p-r_14@Un=)O?)NZjtlhPO@nLb3p{sbjMS} zJJick)4xzPGfFhoP?OF~C!IjF;Dq$$1BT;HuCqv#@gMU?4UU~PQ;SmdV^dWFQ?%I% z6nP<)MO2jMGmm*K6jdhDsWDYuKoQeV)csa;&r_9cR#&V)`~Bb#_@emYD_=K_RvI4%TaG)^BFeq;!(Vp$(lE_I|)>GgS54 zK686jjJZ=3Jpy%;Y0)7ohf``b%QChr29*nDmauO|o?I5OYWBAFsMg{$H*60vY)WxH zQNJIyOK85$#gJ9A5LUxUD zw_SP@C1uZHToUbOba#67h6NH>Z!x6@#@g;g>8 zMYP3Im@h{7Eq-&sH5fGpH*tgawS^ZsgTje~CAeugl45w4Lf8O8_)c$Ex`|MABE$uT z7#(#L-E~(LPj(lF52t!KzWP%ea+aidc#C&fhlM!RjhDJYZ*>8^5ykgj+llbS8 z1uRP$Gn07ZiAF1!xR#{ZME4N^l(?~mRPT#xGmE(ka(P{PhvAkqpGvqnh(nEu*p-(V z!1|IMkC*OQeO*_uPP`1X^Z zmQwzDPnz{_f0nP3x(%J$5vE#WlI1g#InGMtAEG)UQkrfjS%;#gBAsa5py^X^F+PEM zJ#e{CTGKz8EVHDxQJ?nJr8=h}6mfG_Yj1duj=E`~xjCkKzoEq{O1dIIxvfx|vwydHp?bnodV{eVfvkFPQku*{ z8y~FtbV2#it@?eT`r#Maldg~hsgpdxdGQT2E0wzc1{(XY7prdhyO!CT$>j0aVp+OMjw;kujqmUgw08Vjqqm$p0?rrRsQ+rP1d97&rXHyhL?{4OOt zDmOeRNxN;s+9Fw08LoE2zPpcgJCkIaLzSBO8W$m;JEezN5r;T0k=bcx*w>r<6}DU% zr`mhL`pKs|95(yay#x!x9F9P{)x5kpvHN?(I1-9@8JXGdNU((v%xy7syQOt&#&p|L zTLTJuwXmC6yL@-He1Etbf6YQ~x4UpR9FfBVTh2pY&Vyhj{E5k$(Xw2hCM=tH)f=wv z`3O|MNcH7;`>SSLy=7P9z`Vn|91+Jn%fXvp!W`AT+}FZHAF?}j&K&F0!&)R=LL_|W z$sF)Ddw4nL}vuz7in+QrPnd(ykdz8$gH-8aqT3(kG_&Yhv(-J{?GSJT})&mHX>Xjjkbn|)O6 z+x4+00?N~u-^*H2-5qh)cIDlfYpa^Qu>FU}{pZs>C&_+C*<3Zr9r@0E|JnjU<$^un zJ)6$mslz<<(fp!WSBk@kSLNPY<-Pyq16|;pDdwprS)8S~*#XL!Alq$lLcX_UG<6ai4F02tLuwN|Rf09LD& zy7hIC0I=BW)=M>t(PXaL>(z?AzTHrx(G`FTt={cD(`E=XCI`L0DoDwBk~eL?7Jyby6>hB0%-HR>GYoTN)J+)_{y*}tt>uD%m%W*@4UYJKhqQWFHEaE z0|3D@R3QdI>)aCwLQu3S$HNfQIL*Uw`##XLu_R8`HPjPY*TvK{Sr$ey)Nw{fbZs9; zIkEGe>7vpNFCn{9Gq~-$&hl|4Cvt^G^whGNpDWce%(Aytt9%16KT{;fT2^(nYX28nFFU7bF}bOt-H zWArXU+T?V41(Y~99@61$c}#hg?lJYtAY9pg?{%d4o!y&f7nXAM=XbiMw<}WI(V$GX z{*%UOx+#_bavYZ<%5ofUFUMeYuA>P}b|sIi>pHwItJyfNF4Dyt7C_qUJ!Z)l+h{J? zwp@L-<=l4t*K^(?vHyQdZrO%mywjWBX(wJe&i?msesZCGaSH|egmXN{8_D}TuPA1F ze%HUsdi>RF!{L@9>tbq*k3;rDJrq0B&$z{*v33^5$+LEr_jmqxzw`ZfZP4m6u$(v5;NReKRC(}4R~4mVf81NvU~hsAJ>UTMVDue?5Pj~!x4`(COXq4&rK!TV zNc+>$95X5Lt~!Riw3`~)c0?IO!_- zhR(6GN4R|LVhg;8ks?6IIRtcKS=VmyHblUOjJp*1;a%#I2b33@d*F;&K52#Ot5-1^ zqeP#KQIr5iX-O#MG^LEvicrP}w-}>ruVOBi7pJ5>4CA}>V{qZLN7;D(hzm(K)9a?}QS;&$Wly9N~);ma^SIvFLW};qeid()J_FDFZR6G{~V7Dg(s%9J?lQ zEN@gbB{#_<17fV$RSpTlrP;ihBC8Mv za{!Gy#TBu_eyJVe(|-lzSrXW$XN$HW3i$DGwy%S+2=uLyn`GgqZGIG z^5ILDUW~3Yb-F~84P9y#gz~-=vD#-X<>;fQEY4w~xQ0pQoXs`0B-gXd^AH7lYrS=^b>_d<+5w5znr)(|)Ol>C_1{2N@pblc>#^EfOj!-7 zL-h5N)B9%a=6z4MbspbTv|#pXrhLCD`TN!Q#`ezKYdWuCLM+iBdSiPVs?^d=+gdk%gWg&1UvJ)NzqiQK0373*^S*7*cecdI6Y3hjRk>nx zju)zTS1!>GD0cXgo3b}s4DEg`#xDijC){sR=8i$f?bi1o)^x)-Ei*{DL z@oYJk^zIwip7W0B-1*OU?!D){cfR%Cd))cBG2yS9q-BD#!br0O=$v%Esna;GkKw*I zs>uE?v)T<`My24vs@DFa%YovZc_HMr)#ZMw|8TEY1{Y*ukPLZ*>`cM06nj_ z_TJ~5dtYbl`VT|d2Gax)Xq|_8#odw~E6G&vA*Ar(F{-;~==p9hw*1c>^Pgu@ZYnkC zV%MYO`N7A^*~y)6jrHak2Y_?W+21|Sxc8m!_Fxm8NrknG+QOSB3-O0;HFxry3_0;vr<4atK+&JIVhb^Qv@@HPy+gJ!vs=A`Ksn>yzjPHp+!{kv z7>2L_Lu@z0bU4FYIm3)Lzxq8ia)G=%<0h){yzurq>>xEXlui)RmDVBMRYd4 z3uM94=Q6||yt|IP`YbyDrL`PILQ@k#{6{+RQaaQ}#uQ^ftUtLK134+5nhH&#@{mNz zxxGwIJ#$~dLw`M-7DnV6#cXfIbX7+X07o2h$0T#dG;<}(;XEu_Iy?d~F?q3D(2~q$ z3StMuN{dEA?TSQvF&k4y+>#S!vP7X*MbNnyq=84Y zox}lBq?Cck6n)9$T*hN^$V6wvw1vR}5GO2$zeI?}#EHnPG(XfjO0;mvM6Agyl7`Hc zO7yMD{I57f0TK*bLGk5Do6!&aERZAb3bcC3%g)8zxyj_9$^^TyOu8V1dBEBVNJ{k@ zGBCpP8ZE20G!!w#1Z~K~G|1G($lO=T)UeA0uuAO8fCS4-#LLXo%*yIrn}CkP+K9j` zAjh06ps4%ApsttHxJn!hJp9(4Y)D7EyiKEUO@u2Clkhh?@kG3aOdN+wdwD-xsZ3OE zy{uErgm6gob4;ACOytdg0Buf;=uVXC&YbH`#Oc5k9yFAAF8Q1!v$8RAE+X8u!oaqf z>A^27Wy!fz&1?(JjM~qX+sYy6P0Ke;+msy3(@p%sN^@II6J5s3dpq>1!ECF{eC0)4 zbjbYX&V0*G%?QrD3DA`52HckQz zPouD}G^0!O{FSlsOcD>m`_;@c`!&@8x74akWduQW1yE%o%#@Q*eF{#U3(&9tP`xM8 zbtuw>DbkfFsw=3=J2VwSuP;pyQt3~-J^UYOo)ipEM zO_0+}_}3+MM3iR0?A}wXrPq{dOf7n`?H<>|-PP>JQXPNEJwnS3B+BiAR8=a{g@f2- zq1Z)-+AsiGJ*3)2rP@2txSYnM(9n9voO$h{#)1m;#9xzbIaS_Qk>6^B}7z1VnffCax?1;5-Cz}zK>CWK(-IwPaR>W!fF3TkXMGJ;L4400#x$UG?7G?F@-+4qBA8D*bKTVa%8P$X5im z*!^+b&CFhtRM%Zt*sU7O4VFq}dD(pcT_t)z5J|H(N?{RJ1x&%MgZEiZ%-(&-f1pPUCNJ*>ELCM#nfiM1&ddN zX<2OlUhET7-5>aS7&aL=U$WN zXmWrCm1$6gK=jW4!_>6W9UAb=3RPT_Ge_Jn&^!|-DHtw zu4~_BZR*CXWfi62UNm8zROuFT=}xj|RQ;u`HpW@L4`!yR*fxypt!eDOGUooR z>h4o)=5%cKvh5zxZBEp02DOJc0B;`hjjoqmKA7u;>(utS=eF6LwwmGY+umkD=k-(1 zstCv~!E5eguS2tMZS7fg(d<@d*`|nUeFn~UsbMBC)Q0Hpo~-8g3FihhX$Em_&amqy zvu^(mZ9emH2J&$bbN~kuaUjOKkYe;A(M7$D|dZ>3Qg{{HLs-|Q95 z?bYjCjHE=q!`XfB;N4;o;u zEh6J-v0CNUv3<~)cqS(wb5dqLhN58ZeImxrv_LrM)H19 z@Ska4w@Y%iaq!OVXHQLV_fGFOQ1u^CbvJT(2XTj30C^vhjYl&&HrDHZ7SATRy*iRp zx>F~DarK?#mEIs^7k&BHoaRSg#)WQP##}j9VdiVOQU@gD#nxX(h3MBu_;(8SmHlwP ziFU7w=^oH-?=E=XZg&5!_YaVHH?V*Jc6%SPur}4KS2PK(c*=ueofQg->DccBLN z_ohH^tkiem#Ci1^QKvS)9~ycWSbK&GdM&qfKIV8&MfzukXy>Q)pQ>`c?DEH}>mHMK z#?x;9FLw8EcOO&u|FL@~(fcpb2WmTwJA`#km>I&+d2P?PTN$lax&5cQ?WP}huH1X> zK1{KoEW7tL`0Zus|* z`v;PK_mTZ4vVA}DfCuvjchn)jbI$7T_gB~S6#n~*8U3%h@s|18zPx|W{p+}Z02weK z11bv#gF-+IxL5(14v0hH?9c>KEftJJqA|GCYA+v*Gk_8)6FdPXlQaMlsZ&G&E@=QJ zEieO8Yd30u8q=xcTme3804Obx1rmtb08$%Z3SBL@0H?P=6)KZ>0akB-D=q*9al!zv z*W5r09hS%daoQ~Qn`NrnUBC?9cQ^o9fEIuSr~zyMTYwh;1<(O@0A2tWzy4klvqHsn>;eVI98L54qokK>ScoA9um$^Lk+Y zXrC|L%;>~E5(y-ZPxq8c|5Dj}%lw)qiLz{*rzzTYpC}3x6r(9HYMQ7*3W}!*s>;H& zuEProz_YB&8ru)8OPoZyuWQsyzb~;&P{75E8vunsdcO-7u?$lf$+7ILY|ApN!!XY? z$9MrBNDTu-(lpIOQ~(z(b70vvjk9pwH;wav;XP<;jQPFt%&?2O^6TQ`IH-fIFro7s zu7*3)A~!WmuEWsn%?|v`>mbvd&nrGs)b%a7Pptg=KQGEy{l7_d4F4uz1Ol7@5Gs0~ z!Acs72B}JAET_UsqOVP>D%>*-tWe_JQLQl==M%*7QtuU2k!@WUFw9M4#>SDwZ5*@l z?0;RzCx8K8G%X88)3q&JlGnB+?3mlPE!%oy%4i(Y=t~p*totNW<+SBX*6KEjThleY zaL)6k%X8dv{i!|Oh`q0C&@aX1LEew;4w&B*RrLW;x9uGR(iD=9N>a*nBMMV+wLGs> z6%;*EM3Eg)62);-1y|!0gkHneahoL@$4t&cT}RdSO<&~)jD=xHjXjLkNmf&5W=aj~ ze&E_ttl2$X_Qrjl+j9hsqc-}Xy>qF}lMzuu13A_h> zz|@2jfnbn*F$hyotS^Pr@aor5)Yzi+R@Jz^?N?Si&NmoitaB%jGgZ$8lI0clRl|TB zMp)6bxpa}3HTjSLV1Nc5Ybjb8%U&bQs`m(>@qz?6IuNYZ#6D}z%wPS*@8m}cD&RIw%}y5l&;_3PGAkF&nk zyaac_S6M$1l^#F`%k#)h9~mO$?QM}J@|J9)o64Ntch`15&t?68mu8sipaW4|%}J^? zI1GMY>!fqe)!VOV9PXFQ5o=9$3NN<2qZj+ac!)5hLDeqW7(;4PuQ7&}WI)85k$Y~B zRqQJlwF}F>VIxptH zPa6xvC@qoWFjfxQRN@zTu$8x#_oT%kTX$nk>KH>LX00TIk7LT(8yUE8;bGI{hp_%m zL>5@%QZmO$Zb;g>NS2ghbM-pzwLZp|X#ro~EP-#tS?6FCACo!FVl(JhmnRg5E1-gYPC0NGM2#p)w&ZilvIThW87h zjFOTvM9EIoYK>v5fs)GYlelPiCZY^J9)JRU&)9^^QakPd5|PQI`A%=;OF2D}NRY;v zYb>aIw4zi-F_Ngz?Gya9j4pKGd<+o`k$3ddVmV6Rb>0@ zW(y@In|Vy-=tQcaZ61cp+9z4)?Gcz&u8~q1eC(@yzoZqix>nb1NhV9Xr1YMaQpu%D ztOYNclUhAINcT2rw0>;RfU3wA*-sdJhA!0t7B|ToI;5RRhKSzIRJCyT=bPe(Z=v*6 zDfd{AD9@&azy_8ibdla`*<01H=t?PQ=yHoaxrD#2}{deeUOmS9`SYjWugc)8B=3PB|f ze#~Kpw4yjy>jNFGtuVV55^G(%4Pr5ci9z_z@gX%eV{a_pVTy*x z)9Wb4ti463_sQ>6%j=QyGlP>C;;7%dQ&n&P_W&77DMUO3%pyWgemEY4#RivdXyv|u z*g}!qJS&MS-e^DbGB?2t&vWx({=^t+Hs-sbGwzlu&sS$YV%%SgqvYAV8V>NbeGOwN z&N*$E?G5AAPa+Hb?0b;T_tdNWXH=8XZ8Hb7yBvdQSm>`DXG9|E+o)f zXyz7P z7@*q|crm6l(C;T3yr$H`vDDvTxbpN_I(}bRyy!++Qq9Rslcx8EN=MVqOSnnxCba%e z)K~W?>TOXdYTgCQX6~@%+!VLvozJ9EbwrX|w$INk<6uHXbkG1kQX!?37P*+!)SqouhX!X_&jw2QgNS%+6XEo$6XKt3 z+r7ot_a5KgV$XZrITn7}T$7Vs?(5zDM%(XPyS?)b72r1)g0dH#=rI^l#iuhI-EVgJ z)Xg{0x3`n%ua2m1j*nG5#-#Ipd(Qd~-RNirs`R$S`Ff3F=B>qKj9qfN`xWxoE;6tW zZuk%g_$*xjun_>n6#*}X+G2?KjjGU%u+XmZ<*mBBkDTwWV)_bp+-(~0W*~&@d~RaX8!xHzVZ*msqC2ii}K$sK>ZCy^G=rik39YFKp#K>2aiPlD2z#KVseO>tSFxU z48H$OYOZV|>+fX%4V>-of~IU-yRabmuZsb&wC+$I+HkJ!Yr6tY=;ckc#;`8=kTUu1 z{^m&s1Fr;a$Daf##BMHdkuS2c&`_Qa8f1!`@=wP6sCfo40?DgP{V;6vkZ}i3KL@cH z2atUJ1&HcPV9QRb;Et|5uU6|#YOd{a*-Y$$&e(${0PZcN3l8Yy@Wi|b`xMWLDG^xE zPmdKO9Qg|t`A{tl4*2ho)~85G4X$Sc&T3Ros`{qlVsJ?N4s`oRw%w3Z4{PlYuPFSF z%<{?dzffkM%TV91I@Isq5z!kP;r0L#!w2I|am`jW@Pv_a@S43TL05oA)b0{HR$Ag)&y(9rp@a=P3z z81>7U9ztOW@j%(}M+=c*3{njiuq!DdpD5`4DRKV{uJIucl??I~11|n6kl7*;gCbDl z7;xtL(Tu%FS0f6887b1Jsob-!eDUi=8gfMtajvRu_O%hR{Zdg8aCaOMULSw~FOvN) zvjH!H0s$|A0)S)y0Dw3^4hS3-z5s*5V6aGJ76lIgK%kIz=mEVJghJy`sJvDy7llV7 z5qRhVJtmY#<1pBRhyddNIRFk>oYrXO08S@UndI($Je$vFj&KDMe>9m)CDgc$HYFN~ z#$=L^19GV+tVrunsbB(KFtAJPRI2ql%@C-;la<3J1$0}uem05Sj!kOM3L%)#hc*c~Pbpa9e08bAh~ zvt52_fHpfj-RkpsyIl8I8;0)Tzqs!AcWbrtjkV+L_T0W#HuZei@%lWCuTN>$>g>Bc zK8I_uQK|TARXGf2%NMpvBoT{MYAflhR%HJQuh;4Et;*oF`xMLkn7|MEWSS`u3=ILM z&`b#gsVgebs65XaF!_KBbS(P9@4M=gypPgavZ;uIo`#{R6e$b0tGevvJFSaaz%Wf4 zJkA)bm;hfgCC~s}vP_|d!?Ntl3eL0b3kcE44J%P4uo7qh)wQyU`tLhVO7!AIQp<4V zxbEuxDKFCurtD0Tqp>kf5}d^;O^tljH_bBqrzkbeYD%oX50bAvz3t>TJ|z!KFAToW z#4AJ4bMoBurB7N2fhmg(B(c(zoh<&q^voKUDeo#pJJ2*VQ9n2i1rqv0kJ7smAkd1Z zJW#81fVstP+}{_pjT;EVGYqpB$VWxYcN>5P0}{%}48t_eGtC1)!deX!l+#Iz{jEAR zvlFo{x{i~y*jDm=ttT~C6x%RP6Rp*Awas10aoYDa*L5{_t>C~k_swHOQ0r|9`cX9- zcSYa#-DOfx>xK_f;SKWRfg+{{@qBL?jKRw@!3oqk4r9N|GRHM6@O3r$Mm^LAErdlmox3Tu zx3fBzd7pq0(;&z(j8iQd*-YPkrATb{nP=(pKEJA7J9gtG>TM<8)#@HC zrRMHiY?rxhTUK*u_>xOUwQgGXu_Z}1j|#dsJjVv%%T8V$!Sil+H~I4}V(-A;uT$9l zaWI8giB)M_BThO19@EgMoAN1riq&o$=4h4QtLb6Q@s%N`F8m9+EL-lOuDr*hVI6b6 zGYHhnEMp+;77{vWD--}D^lH)463chSaiqK!nvq&^(Rj~-4?UNn`BszPe2%puJSdA0 zTKqnHQ2psb7l6{zEFfv^MkK44KM^7{4t~$k_@&2Aa-!q*MW)o}K+~S--%ET?C`j=h zwp$xxVi8q`oys!={Q~0=*-a^w+AYHQ7$3VQZtMm-xJTOq*{V`*2A&{76F6p!LNzf+ z;K&Pr0VSlgDKaSG-2ez)vQi0{cd*hKq&I-@;*60K(NYD6&7k{Zf~xe^)7 zuZYn--x;W7FB)Red(k2(OSwvS9Xx)1(ji+e2>TbAsxEA4RWnURylhtid4CX9Kc}g@ z{-*k=L#%wNM`ud=S*mG~OUZUF#lV%Il!9+BqCB0`y1N&1*Mrj)A{nDGBw=bcF^KS6 zfB|d(C@h&+a&myXxjc{zMG=%R#)?Wvp%JLWH)+&nV@t{}5#^dfpXmk}OIb}UsRBck zlKPQRIweafWhRF(;hiK&A4JT=*qX8NTPAoowpU5CO><50FD9=$;M-X?t?flWm@L6i z)lpEhh6FIV=?0`q?Sr)ys?9a*>Q_}rOK+X_MpUSrs|7`FQ36|;>O)B3BF8d_;(}0W z5nbfbkVn)tWFuPrL+F(flvGZV%2{bi==Ca;w8DzZ+9qYFbTEbv_Lf;nDM;v*f1%bw zhu8ZBNi0Ptva~@UBU+VE>H=-F>&>ms+O(6Ntom#wu4hyEr&y#5_;bzGy47oX{#i== zEzn{HO;)t>-h@q~yG<(x9e{;%4~y@lH=Vr}?h>O<@!f z>ab6jp%Ud}!g?_&K|yjXQ`e{9*U14I%EoWbfel-jCA8F*?x7c#lUZtKDsD!wlB5 zNs(yHA-?pw(q?SowoGA;N1EgS3tmfIwc^CuTYGat%+bsCw=v5Z!(v{&iOuuYbw>s{ zU@SLpCizz^+uW~b?jDV|%#T0mMoXXcc7?dMip1Rv_d{yPnW!R^{FU;qA@6LBEFtC9 z$6MnK+LgnI@V+8kIy(;Ic&hl!7DeHjzQamdLt*Sc4&q&h4?4;Dk@(d(NI8=C?86nU zdJjG1oS0ew4;w*YUs3X13#s{EDYZ9N>dw1k%xz7(&e`;r^iI(oLZya}5#f;N{V&vB zZ(3v#=cND;qVp^tXYJ(Iru8OAo*6B|jyHr%-a3be(t0yY^#-lfR)yj`Zn^UKmiW~8 zZbkRitMxv!tj9W&?e{w!er&d}@Lz=?Qz~iL;=kUdd~$$bOufyvuI1^MnPE_07yt#I zCz9Upr+e@G@^Ap@4xZ(30_cw}^X`K54Z`tC?8+(&0qmsoN)+_ZWcDv`^l&bd44~xe z$pel~kUY|4l<+p)DtG2E!n9EH1{w=iRFGA#x8vidEpzc!l zZkG&i{Q+=H_7F(*k9iGmR@_j{4G&uO&ld5|@eYu?4zC*o3he?AAd<_)1MnKI&P@Ly zVgL~f5b+EE0bvm_7ZDK(7EnC`Z)*gEc?J+*@C=Z#%NT~LKH?ED$t&#PuvrsOW|aw0 z`K}hF4+5~UIEKz3u`rzDaE%IWmlmy(y|9+>X+V^2>lSd1z^~x&?qb_7nO8`1DE-5MTik5epHq84*#<5%I$vF(DjK z>i;p>9dXqiF&7?j%^s2M93oh_VV+Lyy@Gro=u~{H%K9}(` z6{&draE%EmK_SZzAu(wQ49dbR_Snv6Bdq?*EVjU_BD4#(z*0EojGGvagz7K)ATXByAW@VKA|V<;|-K@;=M4fX{50&aznkQe6Mg$qp}WlFm&Y z5@{ckF9Q!~p-*UblF0+HzYq}h1T#$z(yc2pr79CwD-&TglVdh>Wj3<0E3Ks9L$pOFHMs=u&lvT z86^{=Im`hiQwYs1$2~My^E1B-E{5nYEizCO46dO7RB->W3r6!M|FG3P(X9gW{g8*(6N0Pa`bn6@@TUy+zJzBo%n`)Mrp~Ur>)#Q4re> z6(ay{kus%+QjxPKREHY0g+3LvJ}@&sQ~LrGqYkxCDw2&U6-60v@l>b`?)`l|~kwJ#_(U6mLitHBq*$Cl6^-mAz#a zF;R7|WHv`!Q}I8vog1@3Wmd&pc3D(5T|hPAOtI-^wc$0^b#AtKZuae7)_-qyfoJn_ zSk;GMmLignNa^j;+fOw`mK!{Fkur6kS@oMz6{TCXwHo&=Y!=aF6Gd}1zhyJ|19i(v z6a7>X0W_9bbXP@F)kSUAZ$Q@ZOSNfr*7J4uXLh!CZ|4MN8fmEG)wo7GkyIi-+HCMlNa*;rIHytp&gBU?dwcS9K<$d@~ei%=MH*J2H zS$X7tuD6$!4+XoEdS7Ss5vHP!+j}n0euy`Q>L>kDj@aUyvc1d7Fq) z!9X}ad$^mLH7{*a$&WKjkT|1*kHSMB#=cBpf zqxwCb`R#W#&tH%`l$O&FITMkQ(VzEAmDyXSIQN!%6_K~uOS&m_^=TfHZ;{$Lg%X*e z`OO^FSEHJZq&h*VI+2*VMX9y-XjS(<*;$WxFDUd2HL=ZIId_Q{$8?yLW!f`l^be@| z$N(D4to84w`P;18*{vGgRhoA-lohS|)u~$Tse132dPk|#S)sT~HZsG4)?0*ES*M!e zA2_w8wcD$C0g3v-m)NVWn%lB_C9=CIvimKq_U)+p^RF8{v%2rEd5^EsyDBA8}- zs8(&T+q3{1yS}^Wyj#6Xl+S$|4X%6dw_C@*90S06@qM~;yxb9_JC!z@)r8y^HoM(l z_^oxi*SmZF!u##RJMX|d{kW95sQc}}oJGJ~NxFAm!`sirI)S~MS*H2v#k#S=G|#~F zyTe>S$9ucPe1*ikiNza*qFXP)Je7<*AIZF7LOdnHJb}l&smgq< z%Dl11e22??i_5$T$$Y`V+@H*Royr_#UOcbOJhRPw)yjOi&HTO2JUPVtRkPV4Uh=Vi zJj=|K#mw~2&Ai#rJps+!<a!eJrmPi z5z&0@&z&K`{Qc5hE7ZL&#~nG-eO1*xS*@Kt%soTY8u`?HDb)Q<(!E>PJy+L!;nn<8 z);#yB6=l|((bnCK#{G5KJ$c!Dhu7Ub*cO8~S;=PoN7{Xf9{q31ot4{tncKZJ+1ruY z7+c!?C(>Hu+>zVCoMoxAHB*Xvzl>)z4qUc>Ev z)z#jW$)305KGW`Vb?u(*ygUKzy<6;+>FnJ3?wAxH4{3Yx@7xH@9^D?{V9;4r$73==T?cXc* zH(~U@G4%g!!ah0jT!;0%ugjlh_xlg;-Glc0CEDIe-TiUVKY7i3NBAF|_&;g-I-lB} zN$4Ln*`Iql-VwcmQn+dKpNf0@Ley`#Uu{r|;xUWL3Jk+T2M z%fHpjTZ8m|k{}B30=fqUf?$?{d@{fI_Uhh~N z5o;ItSG6$=SH(dqA6TrLwIj=t-5 z8tsjTF_+tIcN=}wcRiioZ*H0%7NYm6zwr2cZC)2=Qsr}c8!nYr1yS7f_kEtnZ-3q3 zce}ifCyH&V@_6}ttyf3YhUxqIKN$B~3*7s5tun8?E5H5lJMKzi^R+582GhN;Lzt~5 zip&!ELGOej$TrWT_WUi4^K$<~Z~MUmL+V>i5GhbhO9d*iG#v>=aD-a=tuCZ58ADL& zMz=<>#5lM_Z$xgB=>WW_y9YZ5HlOz_0ox=nG*x>$e| zJmE!3v$Y#KK~m!yhR>9wjOx#EeF%!sR4i>YQ?CUNQBeQ^WjN7st&(&v+)|z&qo6WZ-gNt7p zzJ+IBx9(g3W%+q>0Bl<(W3w<=9%(zP*geOdZTa@&jpAP;aE|I-x`{wt*Z@Nx> zZ|W$uJ*hLCJvoJ9n5N~QZ#qu-S}>>ky$u}YNK&T-p^ONs9NM)B0> zd;eR&UpY@>*mibyPug~RPDzDoaP8^FX;;T(zUw`IA+2{FJ@>Bb{$|6>`TG%c0D4}h zIJ+phmp@0ry$%aQbl(Sk)%98rU-Na>&u!U#eZ8H1{rOknefD3LRod@Z&qGs<6(HI_i9DDz%}&w8*`>3{)u1|G3{Rx4FTIp$`%k&B6K z%!TqkI8f@JJS%U|78gF(X98ecGky>ul{Ls_{o2Dye@}(C!l+veAXAfo@HQMW$dJoo zR7q>if#ySH_Vb>M7lP4N4MCW(2FcMhY72p1p5v7k;R0Y_g=K8N_)Q93L+50WvNt-| zUmRV$GKNs%J49&l9pltFc@XXV!{~bWqik@9P|_mAc$X5QbWU>aUI((Mrxl)*S(1@n z4GYDd=o13rZpDZ1{gm{4IL$n4S|vFTSu7_6CzYL ziV^jpH(3J1;XA~Dau!)bSREo{Tg-CuN+-b9UnJzTm4h;3OifS$HKxjOTF-g@JO}XV z7ZZk4BxzX6SkWLDT&RVS%6C4Ok2~j*`Izv|ThDoL^5xX~moxTVM|a@LUL0z4lMRzd zNQpgWMEa3XN+~&sod#PIEO~QEEX}GhHt5Wl7pI{xIC%vtU27JL(r~&rDh*0#JLjHs zwpY^m=R&8=GnVu6n@{R-P3PqRe$&CD(x-I*BU6%&avB^^x%)Ece36=RS?MzPlJuzE z8k=->t5r||RnR2el+%Hu$0*r2r(3I)bpEeCieoJ4m20h4wpmkJb6jcFa;}xmf6GV= z@SF8OjZc;!(3uZR<<&}=kWvfHNiPPQ6i%BHYOC1#vt(?A9Ylw@w!SLAOf1wom5&;h zLkHC_r)`_9)&`zbdhuH=RYs2$+Pd2NU2JQld!R1fH`m8<>nbF$ezn3R*h-fbEIo;; zbv@=)DO-h`o|03EKQAYwCsR!*>4;Vf%rIF#aCHwZ`{Xn?XpcHD#VRdZb5t2YxIx7NYf1 z$-$?k6y(MN7vKXPd}G#HxA+3`+kt*i+4ROHoD!K<6LXq;jgns z$*`LnYHxk*zI68Z-<#`yZ^@UXElhm7lfP)=3L9{Ct^3qjyKLk=wQ_OpPXT?!x{%!ZSxNlH50J z+|D77b!I)=cSAgG5rhCbPfO`tH>Py`p)C0aQ{(z`d2Krh$mzc&%N(zJa|`v@diMA1 z9hb27Zer#*4$IQK+U{hl?Wc7A493<*=kX28t9mC!;=LqcfDZrP`iFzM)wim6eygu` z4_e_on@4Z{(b@c$k@B8O#6_ATaJ4o#@_nnB_RPT{t4<5*-OI96-LKuQhN;{9Q&?mk zkJEe)fcBox+j~r%t-7y>pBu-nb$ny*=3guFK3=B$Z!xGm?rZrUAI@LDn_@dni}Uxl zW?EeT+&R|M;eMkw`mTYUd(Rm4`m>~aL5Ki9PwV|(xAtW3dG7X=6q!Ez$7Q+s6StrD z#9tHTzH{in1OY$`<0_m3KpX@ZRMzy~=w#i>x_oq0Ajyv5x;}^KZ=GtV@RC4;XmR=IbxE2Qp&`G&|t8+!(p*y1={)zcaqNbSXH)raw#(!n_ee%n^n&C&5F+z5^<{>y|-l zExQ~gqO=-8>@`8GHb6TY!+OZU>>a~vHa<%^Kx6$vD?mNd-nINCJhI|G`m4VS@xqg) zJ-ej8JEX#}07B$PL~KYxvAnDw#bci_yQ0SfqPTR1M)Zg> z1c%6+Zp0gG#XNFFd~+{LMMEooLqvB-Q`N=e!aMYO!o)4Y%n->;V#n;4NAp8S1TegF zue_9kIYZ*WOeR6RY)GV^IDDWw3p~mby~+HeE`*Fh#B#&DO+j;EJv+>`3_(WgRJf#) zy{vme#7Dxz_Cn;BhDZQQ1TQj_Fh8?Kx#P1*yr{|4AV@@UH5`aW(0zDh*5#axWQT!F;wcD`%W$rN}%>?pc~d_g2E z#vDjSJblJ2uS*C3Ot@vo{LM)0r%WWYO#J9gtewkrH%olh%~b3?gw@W>?la8R#={%S z{H4GAGrm-*POOJYr0qRy#yz{)%yh`k41NFvm&t77PDJI;%Jf9sTFmU}Oarh?JkdgI z*S`d~$c*jKOnAB+?M+kH&g{C)Z12TvA3k(y#k@JsMFTP8@kQh+J(R3Zbd%4F;Y$4D zO9c~7NM=vf7D7z^GfS391cSu<+t4gi(3J%{)b-KL20P6Mv&{xjguT!`97v>b!>tO- zZ5qh64p3ZPJ&dc#H4#x0^u&D<&!rR2JuOVU%F@KJQBsD7%)GWrv1jp7K`O@u2)O|@)1r^p^OH_qP($!1OZD~y8 z+0_K-#(S&4)pb}`3-Z?$a#ejHS34lb1iaPdbXRQ;Rs5^eOy5j=;78Q^ zRyAieU8hmjM{CDT6L&c-HF;=1k<&VLWN&i%*DJVc|i>L zQncaEokmy1W7o}n*@c>0O`BQ8wpqAn00p<(eVfj%S zCBRozsolkpUG3gqjrd=k_`v1IOl00(C1Be1PnK-Uec-PE~ zMfLquHU3}y%TXn;;1rk9HRfJ@=-@@QUZw?I71CgC2wvU_;FxLv1|;HMC19Ql-8K$l zjt<{FqRHkE+DyG&{uWxs7h!AI;a)Ie-TKYd``YFh-`)P+MU-At;8MMn;nhaob+g>| z1lzOB;d8v;+8~Cg`&_DI!^^#R`rb4^+96o``Xqp z+8OI^qrMVjewS)*|4hLSk+~hOhu- z#$99%USuu_P|^j6gCRW%1!CQ9U{7RtV5o@>wMwjVsd}rpHYkm-D7M;_kGH5=Z>%O4qliFO4W$GVBxv zHjLwy&gk}Y-lkXVj;?7guVO}&Wwx>9=3V99duvbt2F}mz4$y5LwCyf=>7_JHjDye} z_0T?K#4c*SXxiZf@wt#ODRF=+<&-uE*&PJm*$f zW9G`>mUrn^%n7{)j+QmEM%m*=ZB{1! zZXEgJe&X;BaNcbEa3!jqlxrd#lqm}_SWZr1Q{-zf&z0CJ}*a<43M zzVXsl5JtRg=pCx>Um0)5700fnYF66wMg3@2qFA>v>=!d=rcql4|7wPB@CK=I_dRDH z0_=Ao;uhxRo(E;0Bx?@pZnp{YZ$)hO?edQf?)NM5xNZOkN%XHua>p&(=Pv2ZZSPJ< zYp%d^CcN`LH1#A9+V4{1P2#$S-Rrgd=$(`94;*pV9(AuC=N67!=OFX;$KIY+^akhP zhOh39%k)18@^|M{=Jm_J2!uk4yBnM9sHN;dUb?*X5}mDhaPS}T<0GkYNhCEw*zX$f2m@hjCkHF#n+D42A=O{8R3?t`7UkGj>7qmS$UML_a9UFX29h4m|zCP zY+nKHPkioIoq5-uTVH-@2Vh>lu5vd9ZkHtZFGO-@WO9F`Ye$B1mq>bNsfO?Xe0Ruv zf6080%6ygcO$@|(y*9hN)B4{~`tNW0|FP_D_WHx5V(zNc6wP{GaH2ugZq_0DhGxOd_E2sI(#O?%b)WQoPxX5j)^|rT`G=WVUQ_#zAz}AlLL;LuP5P7@1-LqH8kd_}4NiNxB#2)t%78;v!<5$ObeMILMbB(ixh z0kbNWO66^!1j1whZOtalzy#sC0G-a;zzO{ZfkUBDKn?mmCXl)SQz_Kig*|ovsnu$g zdb02VTCM;Wwd)0b#bL5n*T4%srqOG&ufP`Tg}ea3x!rD;MhF7)d%a(8m+%7%1B1aZ zz!+>Z5CMwDV)59fC<7yt$p9GHY{p+S0AuGf+1&TX*7B)-ft<9 z#_e(Yd~VM(qSgQzJMIH00m5Z~IIO@2A({Z?^4Y)+dprTs>Hr!YZpU4s0PXjCo&NWS z!QX@VV36-bG8Yi`L}C$Gr^YQD`bT7cc?_fVDVIxSiJE4bz)8AtofKTL#21?ZX`1Morr`*GA0q zQya7`?0D6+QPW<|wk_Lq+czw$bsD#h1AgDQQhSXkxeim9=t|D3uPnPS8@BUH$V1HZ z%uiAj_RP<6B>7E9)Y$qYGkfrqziC7JI=|_R@c<^!8gQLJ@Kpf?!7y>a08q3nB?!XM zs;3Om(9*{a(o0-LO2lz2NW4WW%vnybl=}-8#m%&4$jEY3fgaUS{Cw2OvSndcwXzLq zAx4r#aT(O|TTNHV&K#oSxi%Y{D>^Qmw(U#^M8=0bwrkZh+EYv6YRzx0u=`FH+~sc0 zkM!+5Kr`LQn$NTR5kXx_9SK8S)IAjm(RZC5uB>;wHxJ)SYUN8rRLn~hQ`j9p7hutZ zlUvml<5gKzwT>TGVioo$A4c)ZUm@33yIqM_@`H9kE5dMkRoGkj7v z-SvM{8vX$iFc>DuQEbvRab2?YP8(QC8-6Pu;+ux)U}BY9de?1D-a8*v`2~YvO>=bfav^ZRjU`W4HfbXq@216^=s-Kce1msxpg zs?M?4>oC$qZ`Uy;+Atmc)vnloWU^q6-;@`thM;8-$NS4#L>p z;G?83gm8_zw|G$r-g;7wjFBtDH^6xuQ!IRk#q~q!6#HDnL5K(K__^2S=b|!ve-d4z zs3aXG(0MPT#x0g+xdPqP5R5MIP(en~8Xg=Mk8DB?$9U&1p>%bZ z(7s?SIe7_Qj3y0G+e7s>*fk0v)Vgp3prLMoD17BRmyXCRHKEx3L>rtizzu`e4HK zi$dXoRiQD`VaFIe_T&5BhD@SD5OtiICXu5P+}d0-aw5&=?IqvLN_6Y7 z2g&u`>RODTol#nK#cAI=spRm0upLLrnO8lc{P=<`mVP#LV^Y}MwluJ2W4ziAFz9^2 zp_DM^)p{FMW)!=t(A+5lwi0&n9ua-3;SyajZ!lO~Ijg>}ZQAvYUCG}c{G+NMA zn!{G*{av$^DZ)MH4@M!Y;-ht*L_@mOHY-})n^6+Dr-ExrWUX|GE|zh>+VgPh-7|ny z{+wK!1v_jd&9Jr}dDJOXzZ&%TmDL_tqUA!t)g_au%rTD~6@fwRLCdR$Az@kTLu!^o zg}u*u^(ZW*&vhcC{$3iVF>b$fz_v078Sn;May>^QD z+B@qYW^Eb2G8xBF>OossGJVLO>&aIRd# z8Gj4mnt=iE<3JLH<@O7d&>CwBiQ*LHT9;`oF$go zQ>tt}98z@_#;_>Mi4laf#LcfbOM0US>b5$yIuk$RYR8{ePQ}=n??Ozy3q~l?WO0v` zn_D>{fNjnKw^~kb;A|1L>$NYF+H)z~T|uYmyzI+ZQ!iLd!>P4is@=n!0B-`mR+} z#XWa5tpBODSAnrvbBik0%HeN+i(1Ygc!(S|;ru_S@ZE{kA+@i+RCR4lN8z;^4%ooD zR|DyrgUYg%ddjc=m}QHRxvAeX%iFJ1ZticqJLfnZ9PqVfZSRM=2C~g&CVzB%-v&4( z;#J@^23{{P_qCTGnthRAe8j%6F)i@X#PIpX~D)H>G<>gSKl zva{9ZNI%znIvr^B|3}zUhht~jOVsjI4W+wLLw}vGP4?Zx+xHga<~yFjd#%&{`^I19 z?NaZq`uIi=)_%dh(8uX_M5e*kX( z0MLp65Db-&lL2f%@arP^YrN?NYS%1Ms!%MMP%`0<~~tEQ+M;2L-U$ z+ocB%Eja#>V&$#I_Ri@45c3akdk>I(7jJs+Z+_kp0|=1@01*us(Tc5E3`(K8E?HplQv$O~X{5x!O7!U~%F$oY+j~LLS!3dic4`$`i>WWQ5%JFLl5pN!_^!IUjCXf9l z(e~Z2gC|ghAJ7XJkqa3T1^^NXAdU9uGLGm-%GSyL`OcFHjta9ZUeIqO^2EO)%FPS% zI)`W2hf=#E@lZd}ROFCc_3+;Ykm%*{k8>W$0&FF~=0I$jE2gGf>Xs z%#d^O(~I9kUHU z(~7$++c9#Q*H56d@GmL=gg|@0b0+f3@U_rQ9AYkVOJxNsYeR{$I;0I7L+z}s^2FOyNj!aKmlKt zbO06oXT)C#wgBI>j{*i4DfF7G5~Q>i7RRnJD-*FZ)VCxpuH-LQEN@t)#Q^1z-zBxg z?)E_s)Z0c@OF!04H}+FNRC{K&YiBmwSGM16)^leR|7~_%PqyK26@6!xcVD)9Z^7UI z7Xfe!wHc>_0nKSKRSsd0D&X-f$8CX8_MKt%Ck)g!Ka@Vw6*VMmn!yvq{uZ}Htwm$i zNlg)dKNELO)!A0oQ*N_|ZZ`90mfdfb_jZ?OcGqiA7h!excW)POc6NVoR|9xg2F;EB zF;N`owg~yl!&z39QPZO0v!_anV&j)BJIiv2&sTHJHCvW5ThmcoFm*0g!ymNYPIpya zwqtJ9^LJNsch_-P7Jq&={b%>+MHJo&3AHdq%L z3Iv1}lR6TqIzz95?0lnjr5v+AYHrUJm_a1Z1DThfA?vI zw}X**d6C%pkhy=5d4rf4fF5~;nAwMz`Gc6b1(>;)nK=uO`4O5}b(gsRnHdY1rt=C! zZ;7kEvhaDdbl?QltR-lV7Qr82KY?(cgclPeU?_wZCBTp*ZYxJ1(%r( zkU5i?*_r?v1)!P-pxOi;fB~U87ol1kp&AXLIg6p1C!xA2qB;+t*^!#L{iAuWpc*lt zqkeA&9|B`Tl64ajjO&89uTl@Nzca4|3Q0?py=xb{Tdvyvk>Qn?O+}d3o_0w$u?<%l zYi;>&f0?I|86TPYm!bMCqPm}{+87?70jheds+zB=+Ow+~x2syasv4i5xt*ih$EkUV zq#7})$WDcNMtZQNIgC{zF|RsMI~!N0BUwYHdDTno6Lbt49yCvMdV3#}_nx|iWmsXT zw{eKr@1wbcsanIV`kSG;y|S9N9@?+6`lqt{HUJwrv)eng8$YyLL$sSetJrR+;a0*WH&G2a&n`nHyE2TEC+E@w@xWy}VDo+t42X0mXb<#hhQo++)TZXU1G> z#++})+*7(w&D>-kAOX%i=gwT~&YbVg-1E;I z_s?AW&m3*WTi?5zvB*4ww0#fAYQBzYB_Zkx`Z6VpDWAZ+puc;4!A{E^O*J&!A3YOS zRB?5%Tr11_4Y}3#!+CdjSof@(Rid2@#JkD79B7JkI-lOUsr|Mp#=>8YsULV&!h0Z=D>)ny-_7t>>=Ddtr*WN|jJtSq5u~LkQ z((SY5c+2Iz%Z(CiRM=DIdFxMb{xk{uA*(59^-~=$}a8Ed}iA9JIZzy-a1d2x+DJ zdvdg~9L2`d8gGoJ{~mqK-4kha`RQHV+wZ(N=RX0W9qaIXO}&3l*4_W~J`M4Hqw`-R zAHV_n|FioaxBFkNAK(G||G)cR#rvPj`@hZoAFK19FZ14^`aT2eU*q*1`wI)&;XcLV zQzGFl0Q!Oe&;UT1AP6f+0SIY;A#FedRvrLrM1T!=tX?e^Yycx|kOX=`ACWeI5=Pho zxhMcPWnc#MxB%anfE*J^vxETRoPZp2$@7o_K%r1T4q7My%}AwEX)bUDI-yUgRB6tD z1zNBGbk?hmpapr>0I<6N7Ds3S-DtHtz!t}V0^aceT%JG+t<~lLyuAPy%j47mfO`Ni zcsIBM4~YOiF?if#kd7QmAqfgP!=Q^D}wsR}l%l7+Srg#It z;qZ9;UG{JT&*}648NSbVx8d*g`TV|5w~7S>gF=6hs9*qxA;^LziK8gw4vnL!5<>4@N$Pr`sA@Duqp6CVPOQZ$yjK+~adN*HEDI9L8Z7HN z(zLCMa^ShgtGe|dFKg=qA~38Y5hO8^QyR(1uH&xjI!zm!Dm6|-t1Gxoi+wHtGW5SN zOfwY3+sqQ|&fm*U8;>?h?sTy?xpI@B&^oiUpw2%i9QQpSPpj(nP%oVZ`aE;&`9wcR zJNo~jumv87Kxo7fN+c;IF9%ZzFaZdqP?}*2rr-oNox{oci4sJSG)$<)O0-{CE0v12 zuU4^|#~OeUy4JSGi;R4`NHQAzA}~@r6vN4Kb0F@r)4MY{&N91?H8wV&0WQpP_yhpk zmi4=ETlW>ia$IutzinGnLv?DnmYv?$O3huRdQOg$s?(sa)%Qfv6Z`LgKNH#ufRz#(y&@bOw*XOA&aF@l3NOrGmW3OZ|%@u+mJJ$mq6fF(^7$tlM_oZUcuh+jlL<0f0KLud8c1 z#<#6&y5_&G+odV6;BxJ-)l95k_HHQWw5a_-q7-z=)zX z1Jc;7H;i#OvQv%1l?FYKLzxyyRAlvBPO9Zqo?*^rRlH`hW|iD^Uq`E!cb?dK1dX9t zIwhHBPZzYYrd`tB&9Y1__0Os6fFJ|k_&x`Q0{DIxkK=hhN0;R68s^7tcRrJMFKNxb z(`#*WgPFZ=FXrF3?vy?CxY3*M&xUWD-;USq#`&4sD1Wyti`N@!iFwAVg{ z`uASz;C!({Cos1D`j~qUeXb2jpq3`498(x^ui`N=*Yt)J5*<|Rl@gSuQmdI;FLTf` z2S+#>u2Hi#X9)4rtP=4BU0fN1ZoKWm^H7`JskK`xrLn{qRPfrYDQhnN7Q=XHB>*@9 zlJaIuNx3yAWZW>5(rQXcNP7!m{ElnVrK~N-pAh0Jxl6BMg~Y}e6y>wtd#|D@#dp}m zBGLwZ?iGqShZg;#3;8Ayl$#-@K^hqxYJd^2sYc~ut)NVFj*;GNGg$8g8N3rm?xqVg z7&K#=DU&-=5h%$}5x z?uAcTPeWd8@|CTPbvuJ=6=<@LeFyl3(Wz%gTkC*}68bF61t}L0Yx0Z_*-xqX$V}A> zADNCx{<%q_Q)Zk5WpEkKKlLQC1qDhS1 zOUZdf*le_~&#I6(8GT764JKl48ZS&)j~J#!FPUydkkdE)8kJ=?RqV1jx%zoGAbmfy z6$(7mNGQ*eEE!o)b)+o`3Y86X?lZtb;o zcvMAjUCKo-uJ%8dw~+%y$KiS3l*FYk)-cl5Lz8Ta{A4penqOv7W-Qg5j@Ejh;2Z&g zGV`UhlZheVhJ|WyUJadRGS}4?cGRV0sd!jg+}#Xgb8Yo#xEQYx0KfyEGxmMZ8UI0O z9SNZHHigibuNLQR8E>cM&c;*n=v`QKUE1x2QfM0 zrZ={k-xeNWSgl0jwJnXA7rvKd`z3ry-cm$aQp>erbD>Kak0CN`#Hm%D-LomVqS z-cVH2LN)Nbt!8zca?3W00qdSEfn(*RkJ|b`RH+ARXKL7bnY%bVPDxxO^#jivOBM8N z`MCLZ+~=JG$#foo1ON|~^B!N#d7g3QyzfEtK6}l1KS|GbAE(@#W5#F>RnxI{cDeF? z8g;!uNV>lJ$k(SNb^2G>a%W?*JR7ude$UyT^Cs2z2Y&u6DKc&f-jQEub`p{ z>Sqb^XrpP(K3Z!vqb(!Dxt06rrwi&95n?{EBR;}GEm+jEQK&o$crc<#JStE>B7iF* z*2L(G@PKJvXG-VVmF-QxG(C@poA7X zvDCy(yXLF>XfDuO&q53xf*y+i`MtIa}uEW(UY!qg~$m;l90 zRmD_R#bj5-bXdiNS;agpLVN!|!^=YKB}L>hziXMmJJmo-5w1G2K(u4E9Ar28_C5SN zz6-LioDn!d|FWSH5ehY&%4(oI6t$}@yOS_Dt6s!xaiGLU!K6qtIiy6J?mPjgxf5l) zG$6#=^fV)vzjMjLYyLvaT16x&MWj@Kpa94Wfyfks$RvZvG=#`Rg~%*_#jJk9tNKMO zE=Z&&#q*#;E5}9}3MzubMrl!ZuasK^9_fT#e< zysXN^t;)~~O5CqX^sP%2u1h4b%G8EPB!@+WR6?{=$b^T&v|Yknrn)0?y<-wR>)y!m z`op8KL$Sz7G(5XBl*YS+ue-Jii4z$#Y{5#N!CNjh)4IN#MVa~5CnR&ht1}?#bwSGq z$7{hhR3FEa^ER8L$E>BlWKhM_Q9s0f#e{}SG^t1=vC9Ae&8*vi2n)@$+|A_OO~l{L z+~7^D*vmZGPBer|RH{XcEWf-j%bZ`iqoT9{j?2pSPO8Vg1F}HultA=7%rk{Ggc750 zs|s<;tjQTTgvZ1~0!g#GFf!9S)XXO=%*2YN!Ml%4^JA9-@Jb{gFw7w?M5MO^$iIAi z$doNje5p;O;Lv2S%Pif^)d7btF;`un?wtSoY<|3$^n^qnnXM2$*gd@yhYE0 z1FbuajBK7b;bzR@c0tnFO$`2|+YU-R(y<%IzgwC|#MVlDe@cvmP+Z(m1rE)v2~fZc zfDJcOg*VfcIMby$P^~)9#XQi}J<#0`Q}q#41h3F-22nJrOI%vfWB0%u4N;{=BQy=n z48gu6Y0mtXAQ6>O@y?v#7tbM<6~!XYWXV!Y7(tYsRW!>_pvV$iV7u#Kt#o!z(nvFm zc+%5ov&_>xeF8%iib{l5Q#CkvhNyFrqJ6kHz>_H$h23Mh$(gBuI zBGJ!u7*brE$27~&oODEuRzba2QldmW4Ou)ZlTZYtHoSYnWPM9*GRQ30(_LX!#cI}- zX#m&&*zJ$l@Bo12kl6*2*(H{YD_uIHH<+un$4!cnxJs7m9qU6$sM@6>c=8K)kT1$4OPLg%PL)0PsJ*l zl>SwvhSKEI()=XNRf$VwUr7y<6qI+x5HL1;1PE zy;&W?TgAZHHN@6kj@eaZS#`NweaG3In$8`Y$W3uS#O_(_ld_E*L+zqZ4Wp){L7-Cp zK~Y_cJ*K{4T#HN=!HY4~HGis2th?c5$?+%0QSGx_{T=18KO6GZwOmjgFj$P$&^@)v zwFlM>xK=$n*yXxd?ZDg}!Cs&b01fM2#p~YH?B3<=-qq>b?ZDp^!`~&5+*Op@oyOly z$W}#|P?gHgEVRXJD9+{j(Y;DCoR&UKOj<+b#3L-P^v9|J8ZX$hk=z%%)!ACaNKDlG zUByhh|?Ve$PF{lg$h}X=T?2iUX{GwrM+J66@Um2;cge< zb{OG?8R3=|UbXOHP8{By@!P%g-i5m1{l;6JYSZoa(H+Z33{=D3CEWDF#;wrL68y5d zogD@ML^E5`F&+zGwB1V2;5?jCZGhU^6%f#6cSg1+-_@gC z8Uo!}ncXtZ<(4eCq!$tBx2E}E3(QB=O#0w`g0n)_n_+jCk`X)u2{U>|=0z;h-etDk zv`Th6;Ptmqo@&#L=h-$B;l@hmZg1x{nddkWXC9krzyRsao$1z|>9(3_rf=#NaAan7 z>Na;_u6btGPTR$hR(4R?q}@^8e>H_3J8k~goW|WenP^5@Or3rf2)R`Vx{Ku}V<{(> z!P)4#C19B~owBIJwclw2^3}au-X=RW+ z0PJ4I>}JR8cF62T$?5)c>^95iMxt!iq2z|6b9xswRoJ~tiEb( z=sSqV1e?-Ts*C`>9Z0&kxWFU}Gvh|JFmsRVd+wgRptEdus@{-hR%S|EP`|t?3#2Mj?Lt5!)#8T?3VBBcoF~(@oy&a?>6%9M)T~3_3sA9ZtnN&KFn{H z%Wl?nhkIGugWrPww2!O6(JL3v|UWX@(52|N9S z9uk7%{4F8-sbDpJzIS};U?;01;XC0>|~C@@1}8X?)L8v^>0q`0Vn|R zzZ~($9r4#5@#h`!uNra|^zoMWY^NjdhRW&=?P^YW>Sgp+RPo>CALtyI?RG3gME2_z zhh2UI*aD=?w%Vij2u$1@Dtls=a(ALKR~JLUKKe-5Uk^675NR&wMQ%N2mcDT{IaYO$ zZneK;FBazp?s7)&Z)YLzHy?CR0D(6~bazJdhe!06Nc5*4^j}Ny{~+=YBJ@8_Y+mm2 zUi)%x9PibR@}||@CW1sPZ))uJ=u~l6^Xuahb4lkgtvJJ-fhnXu;wqG(b9zUxnnoyt z3R1#L?o6dZP2*$5isRkpbLGYHPZjhIb!^s}agR>)M)LH>8}zS8fj9v6|8VyQarYN; z_a}4r7jE>Qb@Xp;bhmf$_icBFZ1NV#Y3ESnmG5uJ0OF^9<$cp|B$_^g(ocUb+P7I< zrQ20iC)f)znkv>rr(R|(OF0qWB|;!M8iz-OqF83+^QUEAM?7=YWApE5>=o^1?(ONW z!|w-8cb^@1S8sPmae+Vp`LCP#x19OMo%z?E`RAJU|DbvAb9yIsdN-K&Uw3xi^=}7z z?9Wkm)xRK4Dp#*ntpkZnkAgeJQuU1aVbjKoWf2U^_qW8^uzHCD6hb+|;7uV`P zj68Gt{s$I#>w6A~9e~W^W{vgtwM4#Ss>2&Fy4^0X4!k|GHn+OaJgV^~l;*d+=Fh); z2Alb38TXf%dN1d>bucSz0mtNW89Z(;C6~#9Kp?Ex1Q-DZfI+|zfCM4|2>?Q15T>vK6##{RA$^zx zFaZqi03of|1U3N1^ZI=rzc{P|?)ZEDUmu9D1NQuV|6iZC)8_xb&vVrQy^t%;@<9(Q69l`kYrgHf zPP?kPnR*F$!A|rOAS66Q-%cc@?K=>VBcd3M!7J#)@K`sYeQm zrmV+{I@GZ(t9*pENQ-QYwa79oh`4|&s_`eUOXQ@!uk0+W%CPLjw8^qe)Vu)8^7}C` z%o8g#&NF~pK+ymodq~qYO>`c;C1#3y9)4?m9N*Lr!WW=pmF!4@IDK zY$EN!&~z6AQjcr^{nHQSFcyF`>bN?BjTX^c#nMCr^~6{r!6V5CN|qMsYbQNpaM$BR^dBrVp;<86Q|jE8Sq zR_(=au9AxPbIPm>2X(QGw7AU7*NZtaOY=iJdtVoo?#|wz4AjrGGjmGQwJmJs*tWB~ zbv?Jv6#V026dX3?(CCeEM4@zI3rEs0Ry^>)k5yRrRW$`oP~{aBOHNd|Xako8;0|M% z<@v5%mt{H4PgP{OonEDJC5NZQHZpZ&Md}Tg8L2jOZm3#T zrKYV~t6IM!$t!)iChZ$e)h5Y{rq}@N%cao2U6*4kG2S=r^EBVr-uZvC+zi#h@4No{ zHD5dfKGDtCB;$k5t+p24xAWtRKw>zYH0NUY%c)0F6_$qrXc;DblVv%+ZPn%(mtC56 z00?2(cAcMX*Y_QdRo&*f*L{=t6#q{Dqb$nAcd?bjPwbzDr#>{WW8mW z)?UgvYp-;&J@&?up9yeWO|9~ml%BdH1Yb&NJh{G9K(th!SidVt8_lItV$KCg#q04s*mF3>|eOd5|m}Q*Q;8LKtrBVKcRp zP@)$}*k22QkO7o3j!{ZkNh#$lo|RH=RzoRV?IgplhfLv2L*xp0S}KhoN--46*p&2^ zI>~ykUMMxVq}R(s;EM}oA-5LV+G6TZjA>B7m>H;*OWa;cF`_j;SlZuU1A&h*+Hyxp z%;OVm=}pPHa?aTA9HZiIan3=6xi>7s)Dg);Y8i_`X&C4vbQ?W#Nc_SGbu3w=GL>?Y zP(t}pDgl@QqBKs4QBY_AXuTJrG-`^_`AsTh{UMcfj#koGO9);Pnm#BUq8;djK$?ud ziEULEy<}vw3Q{&&DD11ulEoF9JBwT4$US!goh%#UE<@ z$*dPF)7;eIbFNjC=&?c=9P~3ng7`36+*+hgo|i zVXuXezE{S^-&+t3fDQP+SLXg-iyeKhrG&C~zX9M|-DV+svu2Xgl)Oef@!p(FrnaT) z)0vG<+VVZ8$tq_>w$%CNm9|P&;kz!=9J|vYlDD@3ET&isIj%hEx=y&k zIGYIpJyX_4khv=GPw8~%Z!LDc4&mF-*i~P!H59O!e*5Ok#j*A_$<2AJ8~`1gv-Wn; z+8Zx4Y>kPRcE;DTI1W@ot2Ykp_6|K%+X_rop7yZI+{1BR*+ys8qV2wpO6$5buSvSS zruy?traudOvDRhQYNj2;opD*l@i=!ZT<2+X-KVv7Pb=s> z4*zY9w?}CP-iHkA{-5}rg>76?r9IX# z+RX<~bS<#Z_Ix{1JFX;&`s7k;U)Ve0m;8?}^8e3h`aXI6zsET9|L@R%5O)92 z0RQj>05AIUkMjPI5c01m%mKF-fzvxP!F%q+ssZ0cyJE5)$)Xt-Rc#_Nq9 zUG3`lXAGPws-6QvoyZ96uF#%OoZ)Z0`;Vyl@3RK)uz`;La!_*z5N`W#q{xf3H%BTs zOp@bC`XR~wgAWe8#Z2)}7?iDqzz^c`ZJ_@zH2;vb0I<0WZ#e*PzyKh53{c4oaLo*` z$qX<53vjUh@ZAlrYXOYfv5+3=3pE03I*KjOwaJqMh_cZSdc;qD(#;@mFie}zjGSrC z(<;b-M-aGhR!!|t`Y>qjuzLFtcM~Ti2WbTpqjeKeK?mb%axi-0@QiaM2=I#J{RTw+ zO7eD(+VQX6v5WT#&nXKqyB9FG4NwUek$DX9fee6p05OLcv56S*gADO`3(<7+5Z3^Y z_~&rq=nxvdu*5 zU0+4ETodDGX1co%aLm4 zZ!rz=a~IKjBX9>9P|+DOKN#_iByo-hzyl=GQ6%zJB$166GBqXe0VEIy8ZL1fZLJ#1 z3Zjv)_6#u(g|2LhgwZfSsOR4t&xCO?)PImv#tO#MO1|J|{KygF?1vW;NdE@WH6OAC zATqdNu!}45ykVvpE3&mLqk8;l4+$j_AgLb-&(`FMKF)25<;=+n?>8edIV3VcFL8w= zQvD^8{UtL6B|v@vGYc^D4KXtjFmg>W68SOm^$jvM^ipc)u;k27S0^y+_3K*Djn>1G zgAg&v1a17%NZ5~P;;gF1xlDX<&erdQ-zsPPaj;^6P<0@2|2HoKEHc1>^L&t#e>d{M zEE2mna?uG7%ybV92IFj; zypIsP@S!ddWaTW!BJV#8Ga)-O**nv_JyXR;lM6i*fjra72p|JUl#NJqg*_B`M>D$^ zbeSb{<1$m{0M2~fVs5g94XI8GEA?T?<+ux9P; zQpfOM)dOV|(2GQjkwdgaL+&yobo@CK`A?KZL{ooHZ^=bZ&VxhiIkF=PQ=t};EZMGa zMzUiK6X8cReMhx5NHsZ0G>cR8k5n~403d}_l}%K&MM(8KJe4y&bahqqb0m}H7c=WJ z)UQ7f2AKC1*9ft?Rx+D&c3u zHD_lvTr~Ml_0Y+W&g28+KFKdR(Pb`FXD-mcQn8IkbvIu%`)f7%R8|#WHoIXK4{SET z2!I1@md$Lo(QNj~Y!Vx@I+q%&w$ zyinEEIdNSI5n`g%Auh7-UUsW6lf_fEw^R1pYu9;f7kzA2(|9)3Z2*b@H;Z`pjd?eb zc(;dl_6>J;b5vFvQ&#C>RcCJV>2FK;u?a$jRJdCL-yBRt>y?1t5g<)H*_&X@Q-s=c&-;ebeBbRL@I*U|9`0yba(b5mpdVJS5a45%CMsfZHsnQ zB^lM}B{LC7HT`#$OL^CYgLj8)_m_ECL3sd-060g4xJiWgO@%l^gcv(`7TJRpO?ovw zNprn!u*VE9KVs|nqVAOUPOgmC$5;0V_>)m_5MgBwA7yNbS+-v*GjS`mt3^c0A>IgKn6$wjsRl- z7@!6ml1#z?N+nX6V*~*%m`fzANi@=FBb-PlQZNMeeLtX3XjD2S7L7-wQfXAWWj38p zs8niII;B>vSERw~P^f%Hy%ewjBKA1MXaLBaO(e33a{vKuw@hwU@B;qpbeBr+QfZuu z{WPp#a9BJh7Y&ERVsTiy3@#T9hQ#8wETmpD8D)SoF}p;D)i1hTv{!qD(@S<=05vyj zj)LbgmC0nd&8F9Fx7==bTe?qlRTQ2&ZU;%ro=xF)8{-;Mx*SBjg z8tvzIz2ES7d|S&W3o*pAM*UICJ*HW1)brPX51-fR_v-b2-#&h?p#M3o8??7Pa4Zc2 z!4OOo%)IU^){djAL?n+r$wT1hKIPZ}4MULF09`}y^XCr3ujDZN#PLi61x1l;T^Gg* zydd^QahlMNqv;HEzrF5*pAAG33_@Ih5+sQsNf8V85kB%G!Sd$`uL_A{4L!%`&4R@t_Fo>>4Fh23b`m#wE})-_!lRNGTc!GGWw z3vmHD_#>qM#V@tfRn@nBfDjiG1VxSj1~+V|ZdF<@BkJ&&+1ZbgJ-Z`7MhL^L(o zhn64)TbJe8jwN~KccrUY<2KGgo#&bAlM_kM#Fr7}5(NoGS+|B^rRF+jC8yq(#xZ?S z8TPHK>iRV+H|tf!C6#5GZpEf-nU)`U-uJEZoNF6y-Hy^!ltH-LGe!4??0e43z2X?P z=Za$Xp5MXnmG$?W*3^YnMOwLDlmHeShGWR`oR;~^@4AKMi*UT}Jv(hZ4XIae_J<$J z^&FQ|$nza;#m#g5k7X$F`!1n}a=K?%)pwoWecpFl*7Mo;o-7aF`5o_-~YPf`^_SXT>S#4mFY&K^5BZ4cqY_J>fy9>iBa z5F!L3ckvDv#Aux0;zT!zFpeffNShPl!&iziZTrPoy%nO&VQ0=pE=3oc7vn?1j8UzR zx|quqBLr!0k+M247n=>BgmR87#yZEfRKkxFtzNZ0`)wzP)b<&DCI&4l+uBg%9&h&rCfrNvc6g#31cWF zthsvf%3H~~buZ1ty^~V{VjEe7FCO&8nMNjK$w`+p%rwoJ4yI^LQw21nbk>_iT5L@> zB{t^)Qk$|OaL!VlIOg=`olp92P1)Bw$~5SZ6T*5FDdjRJtobL?-Vsl!^*<$){+~1g z2v8~OK_m?Zo|GaDK)Mw>=mTYav_6PV*t+D|i!^U9c8X59V&)fu(^>RNS<%_cN8%+R zRcr6Cc)uhn7=Ft5q^UvkFwTT1#Sdt2JxY zCQn(J8SzyK61CO10Z0~t`DJxsmee(`Sk=-@I;Z)ADs?emW~^s#F2T*Vx>mheYn!m@ z;-eyDR)E+k(6Sarj6Eit-)oxIYK|S!F#73HD|gSK-lS+v<^G}UUm zXI06O5HvJYb&V?GCN|<{vs+TM?6TEgYT3Ig>f*c?&55k2VHMnyxUjBd%W6(G)iaw| z#?+lTQ1%YMs=J9*E5hbArR0_y5>0q!Rlm3QQM#*i*pgBrs$#AIYusD7b&k=nzN(16 z--(Kv8=~92Dow4ZO4CbQQin*0YO`BIw|$fPpuy7-x+I)Ug%J&jP3_HoGPb0lu-*r> z7toYq6+%tR_9K`Wp9-PePS+VmELlvgR^tRcS+4c;#>sS?lgxE4vEm_3>?u@bHcyY5 zI~T~qI5_|{$rOp12m$P z!EupBRA|)d%ce}>hslvOFWK8XC5-hhS|)0~3?US0T>Qz_N{3cykqsa0m7=l2J4)zJ zN6SqmrIA}BSoP+|CKSr7^LfxUxh%hGBq)T)?xmIa|4Zu3J#CdL=45BjOzQ+NcCoIC z%sM+<9?f+tw6*cEhhGJ3l1i}`EqY;ULoOF>Ka(@Qw?>DjXYC9Rs}J@`&Uz@dt~Jh> zDlW$~8Pi&mqCQ#?nd>t`FLZjo#<8eH>IDus6(sN*C?X0MJCXP!<@lf=F?XNig z_0)Tejqwh6Wcr+!&R8pp?jFX3(3)4}=6wrl(^IlZA7dlDlz=k;4A28Q3IK#cVNkea zHXRR$L}F1mq*g5#j7DQoxa4*{ACO37QC0{7O2Pn2B@8eF5?TOa%m55v10HGsWKIAK znbh)gKb=jclNoffO1=OuX&?$i0#h%VP$!I_11tdofk7b5`5kt>U$9tgRy!q@&1Z(n zsq)EWx?M4t%_h|;*aE3{yIp708Jx;pC129%RE!NeSy8=SYSoxMZqbj(WO7+NrdKT% zwg4@%`-S#xbDrLB6>TPq6FH$wr5GAU5CMhFUuQKuRbB^;09P{mz2^6Qzu;xFcKMaY zZ=jxSG`za6Hm!KQ>SdbBX1+~Nmtr|w+-5nU!SHx|UOy+6U&P~Fr&~QXma(??z_h&y z)Y4sn`~WG^k3`;bsy)rSyz@YiEDZy}2;(}MJx)UF06UL^Zh}IPljP>AjY9g>y2(2@ zzP(B00t<$kV*)@6r@}?GnpV)0rKyGyI13 zLJ=g9L{t?GMBqv;b8RY0)RCe9RdsDySJl;BBP!2Ndzn5jlXB|C)ioV`U)T(M;Zx3Y zJz@YFRz;O$*_K^lNy{_Lw+UAY1vz3jRt>dn+lc&&R6uV1S7zBJm;rNL7d_Q;S+#W| zM^=`cbkngavI%V4x6So^q1EI`aMkxM*L50z1`C7W*iH*{-Ii5lNjvk!D{0dauIw8A#jxm^9tWjqlf`cc zpqj4Tx5byfv5?@IPR4718^-m#@0;HJvS@k+&4$@Ib|XF1(~jZA@sKvH1vO_v>i>#ggyKOk_#$|DWo*>DlYaa*r|iJonuJ-+TytZ^g~OHo*N? zQ}{-U{r^E&EJqX31bmHc^Eqe>0tVOsgfNZ~LRdorpeyo$?_GqdwkFl!yfKDENqCjT zlJZ@I9(_-~5<}Qc58)&zdC;l)ETr(N7|b(?Q7M=ZWmfCoJDPUSt{*}XxBw!&S&I>F zE5qm!5a4UPVK7N(#F&>E;IoHHuxI*t*}Q_I;=FQrsJm5$lnG&uPn=0voaPOb

BVTbI_L!Kn$c%1u&*m#+p+)HAUtGTZE2XqtZ29 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 IAaUj(AXxKP;Q#;t literal 0 HcmV?d00001 diff --git a/test/test_llreader.py b/test/test_llreader.py index 9842fb963..c8b584014 100644 --- a/test/test_llreader.py +++ b/test/test_llreader.py @@ -15,8 +15,9 @@ bounds_from_latlon_rasters, bounds_from_csv ) -SCENARIO2_DIR = os.path.join(TEST_DIR, "scenario_2") +SCENARIO0_DIR = os.path.join(TEST_DIR, "scenario_0") SCENARIO1_DIR = os.path.join(TEST_DIR, "scenario_1", "geom") +SCENARIO2_DIR = os.path.join(TEST_DIR, "scenario_2") @pytest.fixture @@ -139,3 +140,12 @@ def test_readZ_sf(station_file): aoi = StationFile(station_file) assert np.allclose(aoi.readZ(), .1) + +def test_GeocodedFile(): + aoi = GeocodedFile(os.path.join(SCENARIO0_DIR, 'small_dem.tif'), is_dem=True) + z = aoi.readZ() + x,y = aoi.readLL() + assert z.shape == (569,558) + assert x.shape == z.shape + assert True + diff --git a/tools/RAiDER/cli/validators.py b/tools/RAiDER/cli/validators.py index c5d8847d4..78c470248 100755 --- a/tools/RAiDER/cli/validators.py +++ b/tools/RAiDER/cli/validators.py @@ -165,7 +165,6 @@ def get_query_region(args): # TODO: Need to incorporate the cube raise ValueError('No valid query points or bounding box found in the configuration file') - return query diff --git a/tools/RAiDER/dem.py b/tools/RAiDER/dem.py index 6c125a51b..5f4e73313 100644 --- a/tools/RAiDER/dem.py +++ b/tools/RAiDER/dem.py @@ -19,12 +19,12 @@ def download_dem( - ll_bounds=None, - writeDEM=False, - outName='warpedDEM', - buf=0.02, - overwrite=False, -): + ll_bounds=None, + writeDEM=False, + outName='warpedDEM', + buf=0.02, + overwrite=False, + ): """ Download a DEM if one is not already present. Args: diff --git a/tools/RAiDER/llreader.py b/tools/RAiDER/llreader.py index 88ab2f45d..1468a76f5 100644 --- a/tools/RAiDER/llreader.py +++ b/tools/RAiDER/llreader.py @@ -335,6 +335,10 @@ def __init__(self, filename, is_dem=False): self._is_dem = is_dem _, self._proj, self._geotransform = rio_stats(filename) self._type = 'geocoded_file' + try: + self.crs = self.p['crs'] + except KeyError: + self.crs = None def readLL(self): @@ -379,7 +383,6 @@ def get_extent(self): W, E = ds.longitude.min().item(), ds.longitude.max().item() return [S, N, W, E] - ## untested def readLL(self): with xarray.open_dataset(self.path) as ds: From f0a2a41e3420480bdacc6d1e0c13baa1efc2ee08 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Sat, 7 Oct 2023 22:02:26 -0500 Subject: [PATCH 29/73] remove old code and add unit tests for utilFcns --- test/test_util.py | 73 ++++++++++++++++++++++++++++++++++++---- tools/RAiDER/llreader.py | 2 -- tools/RAiDER/utilFcns.py | 28 +++------------ 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index 5539fbb70..e4b7ab188 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -7,7 +7,7 @@ import pyproj import rasterio -from test import TEST_DIR +from test import TEST_DIR, pushd from RAiDER.utilFcns import ( _least_nonzero, cosd, rio_open, sind, @@ -229,11 +229,6 @@ def test_rio_extent(): os.remove("test.tif") -def test_rio_extent2(): - with pytest.raises(AttributeError): - rio_profile(os.path.join(TEST_DIR, "test_geom", "lat.rdr")) - - def test_getTimeFromFile(): name1 = 'abcd_2020_01_01_T00_00_00jijk.xyz' assert getTimeFromFile(name1) == datetime.datetime(2020, 1, 1, 0, 0, 0) @@ -486,3 +481,69 @@ def test_get_nearest_wmtimes_4(): test_out = get_nearest_wmtimes(t0, 1) true_out = [datetime.datetime(2020, 1, 1, 11, 0), datetime.datetime(2020, 1, 1, 12, 0)] assert [t == t0 for t, t0 in zip(test_out, true_out)] + + +def test_rio(): + SCENARIO0_DIR = os.path.join(TEST_DIR, "scenario_0") + geotif = os.path.join(SCENARIO0_DIR, 'small_dem.tif') + profile = rio_profile(geotif) + assert profile['crs'] is not None + + +def test_rio_2(): + SCENARIO0_DIR = os.path.join(TEST_DIR, "scenario_0") + geotif = os.path.join(SCENARIO0_DIR, 'small_dem.tif') + prof = rio_profile(geotif) + del prof['transform'] + with pytest.raises(KeyError): + rio_extents(prof) + + +def test_rio_3(): + SCENARIO0_DIR = os.path.join(TEST_DIR, "scenario_0") + geotif = os.path.join(SCENARIO0_DIR, 'small_dem.tif') + data = rio_open(geotif, returnProj=False, userNDV=None, band=1) + assert data.shape == (569,558) + + +def test_rio_4(): + SCENARIO_DIR = os.path.join(TEST_DIR, "scenario_4") + los = os.path.join(SCENARIO_DIR, 'los.rdr') + inc, hd = rio_open(los, returnProj=False) + assert len(inc.shape) == 2 + assert len(hd.shape) == 2 + + +def test_writeArrayToRaster_2(): + test = np.random.randn(10,10,10) + with pytest.raises(RuntimeError): + writeArrayToRaster(test, 'dummy_file') + + +def test_writeArrayToRaster_3(tmp_path): + test = np.random.randn(10,10) + test = test + test * 1j + with pushd(tmp_path): + fname = os.path.join(tmp_path, 'tmp_file.tif') + writeArrayToRaster(test, fname) + tmp = rio_profile(fname) + assert tmp['dtype'] == np.complex64 + + +def test_writeArrayToRaster_3(tmp_path): + SCENARIO0_DIR = os.path.join(TEST_DIR, "scenario_0") + geotif = os.path.join(SCENARIO0_DIR, 'small_dem.tif') + profile = rio_profile(geotif) + data = rio_open(geotif) + with pushd(tmp_path): + fname = os.path.join(tmp_path, 'tmp_file.nc') + writeArrayToRaster( + data, + fname, + proj=profile['crs'], + gt=profile['transform'], + fmt='nc', + ) + new_fname = os.path.join(tmp_path, 'tmp_file.tif') + prof = rio_profile(new_fname) + assert prof['driver'] == 'GTiff' diff --git a/tools/RAiDER/llreader.py b/tools/RAiDER/llreader.py index 1468a76f5..98ab93b86 100644 --- a/tools/RAiDER/llreader.py +++ b/tools/RAiDER/llreader.py @@ -431,7 +431,5 @@ def bounds_from_csv(station_file): and "Lon" columns, which should be EPSG: 4326 projection (i.e WGS84) ''' stats = pd.read_csv(station_file).drop_duplicates(subset=["Lat", "Lon"]) - if 'Hgt_m' in stats.columns: - use_csv_heights = True snwe = [stats['Lat'].min(), stats['Lat'].max(), stats['Lon'].min(), stats['Lon'].max()] return snwe diff --git a/tools/RAiDER/utilFcns.py b/tools/RAiDER/utilFcns.py index d9f2c23cb..133cf21ec 100755 --- a/tools/RAiDER/utilFcns.py +++ b/tools/RAiDER/utilFcns.py @@ -134,14 +134,7 @@ def rio_profile(fname): with rasterio.open(fname) as src: profile = src.profile - # if 'S1-GUNW' in fname: - # profile['length'] = profile['width'] - # profile['width'] = profile['height'] - if profile["crs"] is None: - raise AttributeError( - f"{fname} does not contain geotransform information" - ) return profile @@ -150,9 +143,6 @@ def rio_extents(profile): gt = profile["transform"].to_gdal() xSize = profile["width"] ySize = profile["height"] - - if profile["crs"] is None or not gt: - raise AttributeError('Profile does not contain geotransform information') W, E = gt[0], gt[0] + (xSize - 1) * gt[1] + (ySize - 1) * gt[2] N, S = gt[3], gt[3] + (xSize - 1) * gt[4] + (ySize - 1) * gt[5] return S, N, W, E @@ -280,12 +270,15 @@ def writeArrayToRaster(array, filename, noDataValue=0., fmt='ENVI', proj=None, g # Geotransform trans = None if gt is not None: - trans = rasterio.Affine.from_gdal(*gt) + try: + trans = rasterio.Affine.from_gdal(*gt) + except TypeError: + trans = gt ## cant write netcdfs with rasterio in a simple way if fmt == 'nc': fmt = 'GTiff' - filename = filename.replace('.nc', '.GTiff') + filename = filename.replace('.nc', '.tif') with rasterio.open(filename, mode="w", count=1, width=array_shp[1], height=array_shp[0], @@ -296,17 +289,6 @@ def writeArrayToRaster(array, filename, noDataValue=0., fmt='ENVI', proj=None, g return -def writeArrayToFile(lats, lons, array, filename, noDataValue=-9999): - ''' - Write a single-dim array of values to a file - ''' - array[np.isnan(array)] = noDataValue - with open(filename, 'w') as f: - f.write('Lat,Lon,Hgt_m\n') - for lat, lon, height in zip(lats, lons, array): - f.write('{},{},{}\n'.format(lat, lon, height)) - - def round_date(date, precision): # First try rounding up # Timedelta since the beginning of time From da425230777fc4ce6c9912d4070cbbb4790f0d8c Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Sat, 7 Oct 2023 22:34:13 -0500 Subject: [PATCH 30/73] more units tests and code cleanup for utilFcns --- test/test_util.py | 32 ++++++++++++++++++++++++++++++ tools/RAiDER/utilFcns.py | 43 ++++++---------------------------------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/test/test_util.py b/test/test_util.py index e4b7ab188..8bc086587 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -14,6 +14,8 @@ writeArrayToRaster, rio_profile, rio_extents, getTimeFromFile, enu2ecef, ecef2enu, transform_bbox, clip_bbox, get_nearest_wmtimes, + robmax,robmin,padLower,convertLons, + projectDelays,floorish, ) @@ -547,3 +549,33 @@ def test_writeArrayToRaster_3(tmp_path): new_fname = os.path.join(tmp_path, 'tmp_file.tif') prof = rio_profile(new_fname) assert prof['driver'] == 'GTiff' + + +def test_robs(): + assert robmin([1, 2, 3, np.nan])==1 + assert robmin([1,2,3])==1 + assert robmax([1, 2, 3, np.nan])==3 + assert robmax([1,2,3])==3 + + +def test_floorish1(): + assert np.isclose(floorish(5.6,0.2), 5.4) +def test_floorish2(): + assert np.isclose(floorish(5.71,0.2),5.6) +def test_floorish3(): + assert np.isclose(floorish(5.71,1),5) + +def test_projectDelays1(): + assert np.allclose(projectDelays(10,45),14.1421312) + + +def test_padLower(): + test = np.random.randn(2,3,4) + val = test[1,2,1] + test[1,2,0] = np.nan + out = padLower(test) + assert out[1,2,0] == val + + +def test_convertLons(): + assert np.allclose(convertLons(np.array([0, 10, -10, 190, 360])), np.array([0, 10, -10, -170, 0])) diff --git a/tools/RAiDER/utilFcns.py b/tools/RAiDER/utilFcns.py index 133cf21ec..dce21673b 100755 --- a/tools/RAiDER/utilFcns.py +++ b/tools/RAiDER/utilFcns.py @@ -324,20 +324,14 @@ def robmin(a): ''' Get the minimum of an array, accounting for empty lists ''' - try: - return np.nanmin(a) - except ValueError: - return 'N/A' + return np.nanmin(a) def robmax(a): ''' Get the minimum of an array, accounting for empty lists ''' - try: - return np.nanmax(a) - except ValueError: - return 'N/A' + return np.nanmax(a) def _get_g_ll(lats): @@ -409,25 +403,6 @@ def padLower(invar): return np.concatenate((new_var[:, :, np.newaxis], invar), axis=2) -def checkShapes(los, lats, lons, hts): - ''' - Make sure that by the time the code reaches here, we have a - consistent set of line-of-sight and position data. - ''' - from RAiDER.losreader import Zenith - test1 = hts.shape == lats.shape == lons.shape - try: - test2 = los.shape[:-1] == hts.shape - except AttributeError: - test2 = los is Zenith - - if not test1 and test2: - raise ValueError( - 'I need lats, lons, heights, and los to all be the same shape. ' + - 'lats had shape {}, lons had shape {}, '.format(lats.shape, lons.shape) + - 'heights had shape {}, and los was not Zenith'.format(hts.shape)) - - def round_time(dt, roundTo=60): ''' Round a datetime object to any time lapse in seconds @@ -453,11 +428,7 @@ def writeDelays(aoi, wetDelay, hydroDelay, # Do different things, depending on the type of input if aoi.type() == 'station_file': - #TODO: why is this a try/except? - try: - df = pd.read_csv(aoi._filename).drop_duplicates(subset=["Lat", "Lon"]) - except ValueError: - df = pd.read_csv(aoi._filename).drop_duplicates(subset=["Lat", "Lon"]) + df = pd.read_csv(aoi._filename).drop_duplicates(subset=["Lat", "Lon"]) df['wetDelay'] = wetDelay df['hydroDelay'] = hydroDelay @@ -492,11 +463,9 @@ def getTimeFromFile(filename): ''' fmt = '%Y_%m_%d_T%H_%M_%S' p = re.compile(r'\d{4}_\d{2}_\d{2}_T\d{2}_\d{2}_\d{2}') - try: - out = p.search(filename).group() - return datetime.strptime(out, fmt) - except BaseException: # TODO: Which error(s)? - raise RuntimeError('The filename for {} does not include a datetime in the correct format'.format(filename)) + out = p.search(filename).group() + return datetime.strptime(out, fmt) + # Part of the following UTM and WGS84 converter is borrowed from https://gist.github.com/twpayne/4409500 From 4e2d6688ce7e953fc35c2711f864241a608317a1 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Sat, 7 Oct 2023 22:38:06 -0500 Subject: [PATCH 31/73] update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f15829812..9756d7d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * make weather file directory when it doesn't exist * Ensures the `models/data/alaska.geojson.zip` file is packaged when building from the source tarball * Make ISCE3 an optional dependency in `s1_azimuth_timing.py` ++ Added unit tests and removed unused and depracated functions ## [0.4.5] From 5e006b9b59fa383bd9debf3f2c32f2925d9ebb82 Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Wed, 11 Oct 2023 14:23:30 -0500 Subject: [PATCH 32/73] fix small bug in download_dem to enforce either ll_bounds or existing dem --- test/test_dem.py | 26 +++++++++++++++++++++++--- tools/RAiDER/dem.py | 25 ++++++++++++++++++------- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/test/test_dem.py b/test/test_dem.py index 52c592f29..a008c7e56 100644 --- a/test/test_dem.py +++ b/test/test_dem.py @@ -1,18 +1,38 @@ import os import pytest -from test import TEST_DIR - +from test import TEST_DIR, pushd from RAiDER.dem import download_dem def test_download_dem_1(): SCENARIO_1 = os.path.join(TEST_DIR, "scenario_4") hts, meta = download_dem( - outName=os.path.join(SCENARIO_1,'warpedDEM.dem'), + demName=os.path.join(SCENARIO_1,'warpedDEM.dem'), overwrite=False ) assert hts.shape == (45,226) assert meta['crs'] is None +def test_download_dem_2(): + with pytest.raises(ValueError): + download_dem() + + +def test_download_dem_3(tmp_path): + with pushd(tmp_path): + fname = os.path.join(tmp_path, 'tmp_file.nc') + with pytest.raises(ValueError): + download_dem(demName=fname) + + +@pytest.mark.long +def test_download_dem_4(tmp_path): + with pushd(tmp_path): + fname = os.path.join(tmp_path, 'tmp_file.nc') + z,m = download_dem(demName=fname, overwrite=True, ll_bounds=[37.9,38.,-91.8,-91.7], writeDEM=True) + assert len(z.shape) == 2 + assert 'crs' in m.keys() + + diff --git a/tools/RAiDER/dem.py b/tools/RAiDER/dem.py index 5f4e73313..7d6099c46 100644 --- a/tools/RAiDER/dem.py +++ b/tools/RAiDER/dem.py @@ -20,10 +20,10 @@ def download_dem( ll_bounds=None, + demName='warpedDEM.dem', + overwrite=False, writeDEM=False, - outName='warpedDEM', buf=0.02, - overwrite=False, ): """ Download a DEM if one is not already present. @@ -37,11 +37,22 @@ def download_dem( zvals: np.array -DEM heights metadata: -metadata for the DEM """ - if os.path.exists(outName) and not overwrite: - logger.info('Using existing DEM: %s', outName) - zvals, metadata = rio_open(outName, returnProj=True) + if os.path.exists(demName): + if overwrite: + download = True + else: + download = False + else: + download = True + + if download and (ll_bounds is None): + raise ValueError('download_dem: Either an existing file or lat/lon bounds must be passed') + if not download: + logger.info('Using existing DEM: %s', demName) + zvals, metadata = rio_open(demName, returnProj=True) else: + # download the dem # inExtent is SNWE # dem-stitcher wants WSEN bounds = [ @@ -56,9 +67,9 @@ def download_dem( dst_area_or_point='Area', ) if writeDEM: - with rasterio.open(outName, 'w', **metadata) as ds: + with rasterio.open(demName, 'w', **metadata) as ds: ds.write(zvals, 1) ds.update_tags(AREA_OR_POINT='Point') - logger.info('Wrote DEM: %s', outName) + logger.info('Wrote DEM: %s', demName) return zvals, metadata From ae393cfb6eea93dca1d067585c1e4f3a7075e20f Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Tue, 24 Oct 2023 17:04:36 -0500 Subject: [PATCH 33/73] fix download_dem function calls --- tools/RAiDER/llreader.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/RAiDER/llreader.py b/tools/RAiDER/llreader.py index 98ab93b86..a92b59745 100644 --- a/tools/RAiDER/llreader.py +++ b/tools/RAiDER/llreader.py @@ -234,7 +234,7 @@ def readZ(self): _, _ = download_dem( self._bounding_box, writeDEM=True, - outName=demFile, + demName=demFile, ) ## interpolate the DEM to the query points @@ -307,7 +307,7 @@ def readZ(self): _, _ = download_dem( self._bounding_box, writeDEM=True, - outName=demFile, + demName=demFile, ) z_out = interpolateDEM(demFile, self.readLL()) @@ -362,7 +362,7 @@ def readZ(self): demFile = self._filename if self._is_dem else 'GLO30_fullres_dem.tif' bbox = self._bounding_box - _, _ = download_dem(bbox, writeDEM=True, outName=demFile) + _, _ = download_dem(bbox, writeDEM=True, demName=demFile) z_out = interpolateDEM(demFile, self.readLL()) return z_out From af02b857c735b2e874400eab358fa3779dbe593d Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 09:39:33 -0800 Subject: [PATCH 34/73] Add pytest GitHub Action --- .github/workflows/test.yml | 70 ++++++++++++++++++++++++++++++ tools/RAiDER/models/credentials.py | 7 ++- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..c846f44e8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,70 @@ +name: Test and build + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +env: + RAIDER_ECMWF_ERA5_UID = ${{ secrets.RAIDER_ECMWF_ERA5_UID }} + RAIDER_ECMWF_ERA5_API_KEY = ${{ secrets.RAIDER_ECMWF_ERA5_API_KEY }} + RAIDER_HRES_EMAIL = ${{ secrets.RAIDER_HRES_EMAIL }} + RAIDER_HRES_API_KEY = ${{ secrets.RAIDER_HRES_API_KEY }} + RAIDER_HRES_URL = ${{ secrets.RAIDER_HRES_URL }} + EARTHDATA_USERNAME = ${{ secrets.EARTHDATA_USERNAME }} + EARTHDATA_PASSWORD = ${{ secrets.EARTHDATA_PASSWORD }} + +jobs: + pytest: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python_version: ['3.9', '3.10', '3.11', '3.12'] + coverage_report_version: ['3.12'] + + steps: + - uses: actions/checkout@v4 + + - uses: mamba-org/setup-micromamba@v1 + with: + environment-file: environment.yml + create-args: >- + python=${{ matrix.python_version }} + + - name: Install raider and Check environment + shell: bash -l {0} + run: | + python -m pip install --no-deps . + + python -c "import RAiDER; from RAiDER.delay import tropo_delay" + python -c "import RAiDER; from RAiDER.interpolator import interp_along_axis" + + python --version + python -c "import numpy; print(numpy.__version__)" + python -c "import pyproj; print(pyproj.__version__)" + + - name: Setup data stores + shell: bash -l {0} + run: | + python -c 'from RAiDER.models.credentials import setup_from_env; setup_from_env()' + + - name: Pytest in conda environment + shell: bash -l {0} + run: | + COV_OPTIONS=$(python -c "import importlib;print(*(' --cov='+p for p in importlib.util.find_spec('RAiDER').submodule_search_locations))") + pytest -m "not long" test/ $COV_OPTIONS --cov-report= + + - name: Report Coverage + if: matrix.python_version == matrix.coverage_report_version + shell: bash -l {0} + run: | + python .circleci/fix_coverage_paths.py .coverage ${PWD}/tools/RAiDER/ + coverage report -mi + coveralls diff --git a/tools/RAiDER/models/credentials.py b/tools/RAiDER/models/credentials.py index e35d7883b..05b427f80 100644 --- a/tools/RAiDER/models/credentials.py +++ b/tools/RAiDER/models/credentials.py @@ -69,7 +69,7 @@ def _check_envs(model): key = os.getenv('RAIDER_ECMWF_ERA5_API_KEY') host = API_URLS['cdsapirc'] - elif model in ('HRES'): + elif model in ('HRES', 'ERAI'): uid = os.getenv('RAIDER_HRES_EMAIL') key = os.getenv('RAIDER_HRES_API_KEY') host = os.getenv('RAIDER_HRES_URL') @@ -145,3 +145,8 @@ def check_api(model: str, f'{api_filename_path}, API ENVIRONMENTALS' f' and API UID and KEY, do not exist !!' f'\nGet API info from ' + '\033[1m' f'{help_url}' + '\033[0m, and add it!') + + +def setup_from_env(): + for model in API_FILENAME.keys(): + check_api(model) From 2dfb10720c8e658ef3a81eaccabe3b32d10e1d30 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 09:40:15 -0800 Subject: [PATCH 35/73] Remove unused and unneeded imports --- tools/RAiDER/cli/__main__.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tools/RAiDER/cli/__main__.py b/tools/RAiDER/cli/__main__.py index ad6b85d89..6812b38b0 100644 --- a/tools/RAiDER/cli/__main__.py +++ b/tools/RAiDER/cli/__main__.py @@ -29,15 +29,6 @@ def main(): sys.argv = [args.process, *unknowns] - if args.process == 'calcDelays': - from RAiDER.cli.raider import calcDelays - elif args.process == 'downloadGNSS': - from RAiDER.cli.raider import downloadGNSS - elif args.process == 'calcDelaysGUNW': - from RAiDER.cli.raider import calcDelaysGUNW - else: - raise NotImplementedError(f'Process {args.process} has not been fully implemented') - try: # python >=3.10 interface process_entry_point = entry_points(group='console_scripts', name=f'{args.process}.py')[0] From 591516d8c565c2da85a34591423d063b762d49e0 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 09:40:49 -0800 Subject: [PATCH 36/73] Update entrypoint setup for Python 3.12 --- tools/RAiDER/cli/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/RAiDER/cli/__main__.py b/tools/RAiDER/cli/__main__.py index 6812b38b0..45a656212 100644 --- a/tools/RAiDER/cli/__main__.py +++ b/tools/RAiDER/cli/__main__.py @@ -31,7 +31,7 @@ def main(): try: # python >=3.10 interface - process_entry_point = entry_points(group='console_scripts', name=f'{args.process}.py')[0] + (process_entry_point,) = entry_points(group='console_scripts', name=f'{args.process}.py') except TypeError: # python 3.8 and 3.9 interface scripts = entry_points()['console_scripts'] From 813c2f3bf8e82394a52599a51d69b7b1ea5329da Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 09:54:36 -0800 Subject: [PATCH 37/73] fix branch names --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c846f44e8..01a93bc9f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,11 +4,11 @@ on: push: branches: - main - - develop + - dev pull_request: branches: - main - - develop + - dev env: RAIDER_ECMWF_ERA5_UID = ${{ secrets.RAIDER_ECMWF_ERA5_UID }} From b943f58cfdddd79e10098a09913494a89ecc2ea9 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 09:56:30 -0800 Subject: [PATCH 38/73] fix env syntax in GHA test.yml --- .github/workflows/test.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 01a93bc9f..32f75549e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,13 +11,13 @@ on: - dev env: - RAIDER_ECMWF_ERA5_UID = ${{ secrets.RAIDER_ECMWF_ERA5_UID }} - RAIDER_ECMWF_ERA5_API_KEY = ${{ secrets.RAIDER_ECMWF_ERA5_API_KEY }} - RAIDER_HRES_EMAIL = ${{ secrets.RAIDER_HRES_EMAIL }} - RAIDER_HRES_API_KEY = ${{ secrets.RAIDER_HRES_API_KEY }} - RAIDER_HRES_URL = ${{ secrets.RAIDER_HRES_URL }} - EARTHDATA_USERNAME = ${{ secrets.EARTHDATA_USERNAME }} - EARTHDATA_PASSWORD = ${{ secrets.EARTHDATA_PASSWORD }} + RAIDER_ECMWF_ERA5_UID: ${{ secrets.RAIDER_ECMWF_ERA5_UID }} + RAIDER_ECMWF_ERA5_API_KEY: ${{ secrets.RAIDER_ECMWF_ERA5_API_KEY }} + RAIDER_HRES_EMAIL: ${{ secrets.RAIDER_HRES_EMAIL }} + RAIDER_HRES_API_KEY: ${{ secrets.RAIDER_HRES_API_KEY }} + RAIDER_HRES_URL: ${{ secrets.RAIDER_HRES_URL }} + EARTHDATA_USERNAME: ${{ secrets.EARTHDATA_USERNAME }} + EARTHDATA_PASSWORD: ${{ secrets.EARTHDATA_PASSWORD }} jobs: pytest: From d0068eabdcdec0e5503186394c67a12f8fa8b583 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 13:07:40 -0800 Subject: [PATCH 39/73] Fix CDSE credentials for orbit download --- test/test_GUNW.py | 78 ++++++++++++++++++++++++++++++- tools/RAiDER/aria/prepFromGUNW.py | 37 +++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/test/test_GUNW.py b/test/test_GUNW.py index 9c2cae40d..0e1f3f3c0 100644 --- a/test/test_GUNW.py +++ b/test/test_GUNW.py @@ -1,5 +1,6 @@ import glob import json +import netrc import os import shutil import unittest @@ -19,7 +20,9 @@ from RAiDER import aws from RAiDER.aria.prepFromGUNW import ( check_hrrr_dataset_availablity_for_s1_azimuth_time_interpolation, - check_weather_model_availability + check_weather_model_availability, + _ensure_orbit_credential, + ESA_CDSE_HOST, ) from RAiDER.cli.raider import calcDelaysGUNW from RAiDER.models.customExceptions import * @@ -539,3 +542,76 @@ def test_value_error_for_file_inputs_when_no_data_available(mocker): with pytest.raises(NoWeatherModelData): calcDelaysGUNW(iargs) RAiDER.aria.prepFromGUNW.main.assert_not_called() + + +def test__ensure_orbit_credential(monkeypatch): + class EmptyNetrc(): + def __init__(self, netrc_file): + self.netrc_file = netrc_file + self.hosts = {} + def __str__(self): + return str(self.hosts) + + # No .netrc, no ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', EmptyNetrc, raising=False) + mp.delenv('ESA_CDSE_USERNAME', raising=False) + mp.delenv('ESA_CDSE_PASSWORD', raising=False) + with pytest.raises(ValueError): + _ensure_orbit_credential() + + # No .netrc, set ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', EmptyNetrc, raising=False) + mp.setenv('ESA_CDSE_USERNAME', 'foo') + mp.setenv('ESA_CDSE_PASSWORD', 'bar') + mp.setattr(Path, 'write_text', lambda self, write_text: write_text) + written_credentials = _ensure_orbit_credential() + assert written_credentials == str({ESA_CDSE_HOST: ('foo', None, 'bar')}) + + class NoCDSENetrc(): + def __init__(self, netrc_file): + self.netrc_file = netrc_file + self.hosts = {'fizz.buzz.org': ('foo', None, 'bar')} + def __str__(self): + return str(self.hosts) + + # No CDSE in .netrc, no ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', NoCDSENetrc, raising=False) + mp.delenv('ESA_CDSE_USERNAME', raising=False) + mp.delenv('ESA_CDSE_PASSWORD', raising=False) + with pytest.raises(ValueError): + _ensure_orbit_credential() + + # No CDSE in .netrc, set ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', NoCDSENetrc, raising=False) + mp.setenv('ESA_CDSE_USERNAME', 'foo') + mp.setenv('ESA_CDSE_PASSWORD', 'bar') + mp.setattr(Path, 'write_text', lambda self, write_text: write_text) + written_credentials = _ensure_orbit_credential() + assert written_credentials == str({'fizz.buzz.org': ('foo', None, 'bar'), ESA_CDSE_HOST: ('foo', None, 'bar')}) + + class CDSENetrc(): + def __init__(self, netrc_file): + self.netrc_file = netrc_file + self.hosts = {ESA_CDSE_HOST: ('foo', None, 'bar')} + def __str__(self): + return str(self.hosts) + + # cdse in .netrc, no ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) + mp.delenv('ESA_CDSE_USERNAME', raising=False) + mp.delenv('ESA_CDSE_PASSWORD', raising=False) + written_credentials = _ensure_orbit_credential() + assert written_credentials is None + + # cdse in .netrc, set ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) + mp.setenv('ESA_CDSE_USERNAME', 'foo') + mp.setenv('ESA_CDSE_PASSWORD', 'bar') + written_credentials = _ensure_orbit_credential() + assert written_credentials is None diff --git a/tools/RAiDER/aria/prepFromGUNW.py b/tools/RAiDER/aria/prepFromGUNW.py index c2b4c984e..bc874976b 100644 --- a/tools/RAiDER/aria/prepFromGUNW.py +++ b/tools/RAiDER/aria/prepFromGUNW.py @@ -6,7 +6,10 @@ # # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ import os +import netrc from datetime import datetime +from pathlib import Path +from platform import system import numpy as np import eof.download import xarray as xr @@ -30,6 +33,39 @@ ## cube spacing in degrees for each model DCT_POSTING = {'HRRR': 0.05, 'HRES': 0.10, 'GMAO': 0.10, 'ERA5': 0.10, 'ERA5T': 0.10} +ESA_CDSE_HOST = 'dataspace.copernicus.eu' + + +def _ensure_orbit_credential() -> None | int: + """Ensure credentials exist for ESA's CDSE to download orbits + + This method will prefer to use CDSE credentials from your `~/.netrc` file if they exist, + otherwise will look for ESA_CDSE_USERNAME and ESA_CDSE_PASSWORD environment variables and + update or create your `~/.netrc` file. + + Returns `None` if the `~/.netrc` file did not need to be updated and the number of characters written if it did. + """ + netrc_name = '_netrc' if system().lower() == 'windows' else '.netrc' + netrc_file = Path.home() / netrc_name + + # netrc needs a netrc file; if missing create an empty one. + if not netrc_file.exists(): + netrc_file.touch() + + netrc_credentials = netrc.netrc(netrc_file) + if ESA_CDSE_HOST in netrc_credentials.hosts: + return + + username = os.environ.get('ESA_CDSE_USERNAME') + password = os.environ.get('ESA_CDSE_PASSWORD') + if username is None and password is None: + raise ValueError('Credentials are required for fetching orbit data from dataspace.copernicus.eu!\n' + 'Either add your credentials to ~/.netrc or set the ESA_CDSE_USERNAME and ESA_CDSE_PASSWORD ' + 'environment variables.') + + netrc_credentials.hosts[ESA_CDSE_HOST] = (username, None, password) + return netrc_file.write_text(str(netrc_credentials)) + def _get_acq_time_from_gunw_id(gunw_id: str, reference_or_secondary: str) -> datetime: # Ex: S1-GUNW-A-R-106-tops-20220115_20211222-225947-00078W_00041N-PP-4be8-v3_0_0 @@ -278,6 +314,7 @@ def get_orbit_file(self): sat = slc.split('_')[0] dt = datetime.strptime(f'{self.dates[0]}T{self.mid_time}', '%Y%m%dT%H:%M:%S') + _ensure_orbit_credential() path_orb = eof.download.download_eofs([dt], [sat], save_dir=orbit_dir) return path_orb From 3cfc485a6fad3159c3d840d5d18550b937197415 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 13:36:14 -0800 Subject: [PATCH 40/73] add ESA CSDE secrets to test environment --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32f75549e..e6d33e7b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,8 @@ env: RAIDER_HRES_URL: ${{ secrets.RAIDER_HRES_URL }} EARTHDATA_USERNAME: ${{ secrets.EARTHDATA_USERNAME }} EARTHDATA_PASSWORD: ${{ secrets.EARTHDATA_PASSWORD }} + ESA_CDSE_USERNAME: ${{ secrets.ESA_CDSE_USERNAME }} + ESA_CDSE_PASSWORD: ${{ secrets.ESA_CDSE_PASSWORD }} jobs: pytest: From 4c3b9164ee76c52801cfbec8d4d95769408c8f20 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 13:57:23 -0800 Subject: [PATCH 41/73] sort environment file --- environment.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/environment.yml b/environment.yml index 31dbc4bec..581ba3388 100644 --- a/environment.yml +++ b/environment.yml @@ -16,15 +16,12 @@ dependencies: - cartopy - cdsapi - cfgrib - - cmake - - cxx-compiler - - cython - dask - dem_stitcher>=2.3.1 - ecmwf-api-client + - h5netcdf - h5py - herbie-data - - h5netcdf - hyp3lib - isce3>=0.15.0 - jsonschema==3.2.0 # this is for ASF DAAC ingest schema validation @@ -34,36 +31,39 @@ dependencies: - numpy - pandas - progressbar - - pybind11 - pydap>3.2.2 - pyproj>=2.2.0 - pyyaml - rasterio>=1.3.0 - - rioxarray - requests + - rioxarray - s3fs - scipy>1.10.0 + - sentineleof>=0.9.1 - shapely - - sysroot_linux-64 - tqdm - xarray # For packaging and testing - autopep8 + - cmake + - cxx-compiler + - cython + - pybind11 - pytest + - pytest-console-scripts - pytest-cov - - pytest-timeout - pytest-mock - - pytest-console-scripts + - pytest-timeout - setuptools_scm >=6.2 + - sysroot_linux-64 # For docs website - mkdocs - - mkdocstrings - - mkdocstrings-python - mkdocs-macros-plugin - mkdocs-material - mkdocs-material-extensions - - sentineleof>=0.8.1 + - mkdocstrings + - mkdocstrings-python # For RAiDER-docs - - jupyterlab - jupyter_contrib_nbextensions + - jupyterlab - wand From 9b3fd6127b497868ac802a2243aca6d59eedf670 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 14:20:32 -0800 Subject: [PATCH 42/73] minor fixes --- tools/RAiDER/aria/prepFromGUNW.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/RAiDER/aria/prepFromGUNW.py b/tools/RAiDER/aria/prepFromGUNW.py index bc874976b..90ab138b1 100644 --- a/tools/RAiDER/aria/prepFromGUNW.py +++ b/tools/RAiDER/aria/prepFromGUNW.py @@ -317,7 +317,7 @@ def get_orbit_file(self): _ensure_orbit_credential() path_orb = eof.download.download_eofs([dt], [sat], save_dir=orbit_dir) - return path_orb + return [str(o) for o in path_orb] ## ------ methods below are not used @@ -418,7 +418,7 @@ def update_yaml(dct_cfg:dict, dst:str='GUNW.yaml'): params = yaml.safe_load(f) except yaml.YAMLError as exc: print(exc) - raise ValueError(f'Something is wrong with the yaml file {example_yaml}') + raise ValueError(f'Something is wrong with the yaml file {template_file}') params = {**params, **dct_cfg} From dec2eb4c1e74aedfc7cc4dd9d26eaa11868b5f2f Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 14:23:16 -0800 Subject: [PATCH 43/73] update sentineleof spec --- environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index 581ba3388..8b04c0a6e 100644 --- a/environment.yml +++ b/environment.yml @@ -39,7 +39,7 @@ dependencies: - rioxarray - s3fs - scipy>1.10.0 - - sentineleof>=0.9.1 + - sentineleof>=0.9.0 - shapely - tqdm - xarray From 6a5d9cb19d2c9c757b8847f05889765cfa6984ff Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 15:37:56 -0800 Subject: [PATCH 44/73] fix download_eof patch --- test/test_GUNW.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/test/test_GUNW.py b/test/test_GUNW.py index 0e1f3f3c0..f5b4f3e5b 100644 --- a/test/test_GUNW.py +++ b/test/test_GUNW.py @@ -222,11 +222,10 @@ def test_azimuth_timing_interp_against_center_time_interp(weather_model_name: st # For prepGUNW side_effect = [ # center-time - orbit_dict_for_azimuth_time_test['reference'], + [Path(orbit_dict_for_azimuth_time_test['reference'])], # azimuth-time - orbit_dict_for_azimuth_time_test['reference'], + [Path(orbit_dict_for_azimuth_time_test['reference'])], ] - side_effect = list(map(str, side_effect)) mocker.patch('eof.download.download_eofs', side_effect=side_effect) @@ -387,11 +386,10 @@ def test_provenance_metadata_for_tropo_group(weather_model_name: str, # For prepGUNW side_effect = [ # center-time - orbit_dict_for_azimuth_time_test['reference'], + [Path(orbit_dict_for_azimuth_time_test['reference'])], # azimuth-time - orbit_dict_for_azimuth_time_test['reference'], + [Path(orbit_dict_for_azimuth_time_test['reference'])], ] - side_effect = list(map(str, side_effect)) mocker.patch('eof.download.download_eofs', side_effect=side_effect) @@ -491,11 +489,10 @@ def test_GUNW_workflow_fails_if_a_download_fails(gunw_azimuth_test, orbit_dict_f # For prepGUNW side_effect = [ # center-time - orbit_dict_for_azimuth_time_test['reference'], + [Path(orbit_dict_for_azimuth_time_test['reference'])], # azimuth-time - orbit_dict_for_azimuth_time_test['reference'], + [Path(orbit_dict_for_azimuth_time_test['reference'])], ] - side_effect = list(map(str, side_effect)) mocker.patch('eof.download.download_eofs', side_effect=side_effect) From 1a5add774b654c7f7d78022667dd33b35e9e884a Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 3 Nov 2023 15:47:08 -0800 Subject: [PATCH 45/73] fix whitespace --- test/test_GUNW.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/test/test_GUNW.py b/test/test_GUNW.py index f5b4f3e5b..8ee6fdd74 100644 --- a/test/test_GUNW.py +++ b/test/test_GUNW.py @@ -221,11 +221,11 @@ def test_azimuth_timing_interp_against_center_time_interp(weather_model_name: st # For prepGUNW side_effect = [ - # center-time - [Path(orbit_dict_for_azimuth_time_test['reference'])], - # azimuth-time - [Path(orbit_dict_for_azimuth_time_test['reference'])], - ] + # center-time + [Path(orbit_dict_for_azimuth_time_test['reference'])], + # azimuth-time + [Path(orbit_dict_for_azimuth_time_test['reference'])], + ] mocker.patch('eof.download.download_eofs', side_effect=side_effect) @@ -385,11 +385,11 @@ def test_provenance_metadata_for_tropo_group(weather_model_name: str, # For prepGUNW side_effect = [ - # center-time - [Path(orbit_dict_for_azimuth_time_test['reference'])], - # azimuth-time - [Path(orbit_dict_for_azimuth_time_test['reference'])], - ] + # center-time + [Path(orbit_dict_for_azimuth_time_test['reference'])], + # azimuth-time + [Path(orbit_dict_for_azimuth_time_test['reference'])], + ] mocker.patch('eof.download.download_eofs', side_effect=side_effect) @@ -488,11 +488,11 @@ def test_GUNW_workflow_fails_if_a_download_fails(gunw_azimuth_test, orbit_dict_f # For prepGUNW side_effect = [ - # center-time - [Path(orbit_dict_for_azimuth_time_test['reference'])], - # azimuth-time - [Path(orbit_dict_for_azimuth_time_test['reference'])], - ] + # center-time + [Path(orbit_dict_for_azimuth_time_test['reference'])], + # azimuth-time + [Path(orbit_dict_for_azimuth_time_test['reference'])], + ] mocker.patch('eof.download.download_eofs', side_effect=side_effect) From bcb1796ed2416331a618abb9eb3e6cfccd865132 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Mon, 6 Nov 2023 11:30:05 -0900 Subject: [PATCH 46/73] Python 3.9 compatibility --- test/test_s1_time_grid.py | 3 ++- tools/RAiDER/aria/prepFromGUNW.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/test_s1_time_grid.py b/test/test_s1_time_grid.py index 0b4102b75..4ff41d113 100644 --- a/test/test_s1_time_grid.py +++ b/test/test_s1_time_grid.py @@ -1,5 +1,6 @@ import datetime from pathlib import Path +from typing import Union import hyp3lib import numpy as np @@ -216,7 +217,7 @@ def test_n_closest_dts(): @pytest.mark.parametrize('input_time, temporal_window, expected_weights', zip(input_times, windows, expected_weights_list)) def test_inverse_weighting(input_time: np.datetime64, - temporal_window: int | float, + temporal_window: Union[int, float], expected_weights: list[float]): """The test is designed to determine valid inverse weighting diff --git a/tools/RAiDER/aria/prepFromGUNW.py b/tools/RAiDER/aria/prepFromGUNW.py index 90ab138b1..9344739bd 100644 --- a/tools/RAiDER/aria/prepFromGUNW.py +++ b/tools/RAiDER/aria/prepFromGUNW.py @@ -10,6 +10,7 @@ from datetime import datetime from pathlib import Path from platform import system +from typing import Optional import numpy as np import eof.download import xarray as xr @@ -36,7 +37,7 @@ ESA_CDSE_HOST = 'dataspace.copernicus.eu' -def _ensure_orbit_credential() -> None | int: +def _ensure_orbit_credential() -> Optional[int]: """Ensure credentials exist for ESA's CDSE to download orbits This method will prefer to use CDSE credentials from your `~/.netrc` file if they exist, From c5da4b4402c9f5b1788088dfd23869a29ae9ed67 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Mon, 6 Nov 2023 11:50:49 -0900 Subject: [PATCH 47/73] Mark test/test_temporal_interpolate.py --- test/test_temporal_interpolate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_temporal_interpolate.py b/test/test_temporal_interpolate.py index 94f8218ab..15b2041c2 100644 --- a/test/test_temporal_interpolate.py +++ b/test/test_temporal_interpolate.py @@ -10,6 +10,7 @@ wm = 'ERA5' if WM == 'ERA-5' else WM +@pytest.mark.long def test_cube_timemean(): """ Test the mean interpolation by computing cube delays at 1:30PM vs mean of 12 PM / 3PM for GMAO """ SCENARIO_DIR = os.path.join(TEST_DIR, "INTERP_TIME") @@ -70,6 +71,7 @@ def test_cube_timemean(): return +@pytest.mark.long def test_cube_weighting(): """ Test the weighting by comparing a small crop with numpy directly """ from datetime import datetime @@ -142,4 +144,3 @@ def test_cube_weighting(): os.remove('temp.yaml') return - From a0322979e1568e24be14e47b1e9aebf740e066b9 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Mon, 6 Nov 2023 12:19:00 -0900 Subject: [PATCH 48/73] install coveralls --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6d33e7b4..0d810535b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,6 +67,7 @@ jobs: if: matrix.python_version == matrix.coverage_report_version shell: bash -l {0} run: | + python -m pip install coveralls python .circleci/fix_coverage_paths.py .coverage ${PWD}/tools/RAiDER/ coverage report -mi coveralls From e17a4911fa4f9ea4971e64a368f17c33002de9a8 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Mon, 6 Nov 2023 13:32:41 -0900 Subject: [PATCH 49/73] add github token to coverage env --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0d810535b..1bc091591 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,8 @@ jobs: - name: Report Coverage if: matrix.python_version == matrix.coverage_report_version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash -l {0} run: | python -m pip install coveralls From 3a2d744c456f602a7c97d17e71af2979a65126ee Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Thu, 9 Nov 2023 22:57:14 -0900 Subject: [PATCH 50/73] try circleci matrix --- .circleci/config.yml | 69 ++++++++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 21ee62ba7..b5d509ab5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,4 +1,5 @@ version: 2.1 + jobs: build: docker: @@ -6,58 +7,76 @@ jobs: user: root steps: - checkout + - run: name: Setup micromamba + shell: /bin/bash -l command: | apt update --yes && apt-get upgrade --yes apt install -y --no-install-recommends wget ca-certificates git - cd $HOME + + cd ${HOME} curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/latest | tar -xvj bin/micromamba + eval "$(${HOME}/bin/micromamba shell hook -s posix)" + + PYTHON_VERSION="<< parameters.python-version >>" + sed -i "s/python>=/c python=${PYTHON_VERSION}" environment.yml + micromamba create -f environment.yml - run: - name: Setup environment + name: Install raider and Check environment + shell: /bin/bash -l command: | eval "$($HOME/bin/micromamba shell hook -s posix)" - micromamba create -f environment.yml micromamba activate RAiDER - pip install coveralls - echo url: https://cds.climate.copernicus.eu/api/v2 > $HOME/.cdsapirc - echo key: $cdsak >> $HOME/.cdsapirc - - echo { > $HOME/.ecmwfapirc - echo ' "url": "https://api.ecmwf.int/v1",' >> $HOME/.ecmwfapirc - echo ' "email": "'$ecmwfu'",' >> $HOME/.ecmwfapirc - echo ' "key": "'$ecmwfk'"' >> $HOME/.ecmwfapirc - echo } >> $HOME/.ecmwfapirc - - echo url: $NCUMloc > $HOME/.ncmrlogin - echo username: $NCUMu >> $HOME/.ncmrlogin - echo password: $NCUMp >> $HOME/.ncmrlogin + + python -m pip install --no-deps . + + python -c "import RAiDER; from RAiDER.delay import tropo_delay" + python -c "import RAiDER; from RAiDER.interpolator import interp_along_axis" python --version python -c "import numpy; print(numpy.__version__)" python -c "import pyproj; print(pyproj.__version__)" + - run: - name: Install RAiDER and test the install + name: Setup data stores + shell: /bin/bash -l command: | eval "$($HOME/bin/micromamba shell hook -s posix)" micromamba activate RAiDER - python -m pip install . - python -c "import RAiDER; from RAiDER.delay import tropo_delay" - python -c "import RAiDER; from RAiDER.interpolator import interp_along_axis" + + python -c 'from RAiDER.models.credentials import setup_from_env; setup_from_env()' + - run: name: Run unit tests shell: /bin/bash -l command: | eval "$($HOME/bin/micromamba shell hook -s posix)" micromamba activate RAiDER + COV_OPTIONS=`python -c "import importlib;print(*(' --cov='+p for p in importlib.util.find_spec('RAiDER').submodule_search_locations))"` pytest -m "not long" test/ $COV_OPTIONS --cov-report= + - run: name: Report coverage + shell: /bin/bash -l command: | - eval "$($HOME/bin/micromamba shell hook -s posix)" - micromamba activate RAiDER - python .circleci/fix_coverage_paths.py .coverage $(pwd)/tools/RAiDER/ - coverage report -mi - coveralls + PYTHON_VERSION="<< parameters.python-version >>" + if [ "${PYTHON_VERSION}" == "3.12" ]; then + eval "$($HOME/bin/micromamba shell hook -s posix)" + micromamba activate RAiDER + + python -m pip install coveralls + python .circleci/fix_coverage_paths.py .coverage $(pwd)/tools/RAiDER/ + coverage report -mi + coveralls + fi + +workflows: + all-tests: + jobs: + - build: + matrix: + parameters: + python-version: ["3.9", "3.10", "3.11", "3.12"] From 71f58d84fdd7f48f7a08effb95ce01c956267cb5 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Thu, 9 Nov 2023 23:01:32 -0900 Subject: [PATCH 51/73] declare job parameters --- .circleci/config.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index b5d509ab5..605de2b24 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,6 +2,9 @@ version: 2.1 jobs: build: + parameters: + python-version: + type: string docker: - image: cimg/base:current user: root From 6d72658288a51fc3e973086928a32fb3c3f86dec Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Thu, 9 Nov 2023 23:03:55 -0900 Subject: [PATCH 52/73] fix sed --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 605de2b24..315e1bf99 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,7 +23,7 @@ jobs: eval "$(${HOME}/bin/micromamba shell hook -s posix)" PYTHON_VERSION="<< parameters.python-version >>" - sed -i "s/python>=/c python=${PYTHON_VERSION}" environment.yml + sed -i "/python>=/c python=${PYTHON_VERSION}" environment.yml micromamba create -f environment.yml - run: From 8ce7c8be4680a6aa1cd0242d599c9fb50f56283a Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Thu, 9 Nov 2023 23:07:40 -0900 Subject: [PATCH 53/73] break apart micromamba install and environment creation --- .circleci/config.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 315e1bf99..66c846cf7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -12,7 +12,7 @@ jobs: - checkout - run: - name: Setup micromamba + name: Install micromamba shell: /bin/bash -l command: | apt update --yes && apt-get upgrade --yes @@ -22,6 +22,13 @@ jobs: curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/latest | tar -xvj bin/micromamba eval "$(${HOME}/bin/micromamba shell hook -s posix)" + - run: + name: Create RAiDER environment + shell: /bin/bash -l + command: | + eval "$($HOME/bin/micromamba shell hook -s posix)" + micromamba activate RAiDER + PYTHON_VERSION="<< parameters.python-version >>" sed -i "/python>=/c python=${PYTHON_VERSION}" environment.yml micromamba create -f environment.yml From 3e6fd098aebc578f1a3e6cf9943911d7a66586a2 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Thu, 9 Nov 2023 23:09:42 -0900 Subject: [PATCH 54/73] Don't try and activate an environment before you've created --- .circleci/config.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 66c846cf7..f4e9dd6e6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,8 +27,7 @@ jobs: shell: /bin/bash -l command: | eval "$($HOME/bin/micromamba shell hook -s posix)" - micromamba activate RAiDER - + PYTHON_VERSION="<< parameters.python-version >>" sed -i "/python>=/c python=${PYTHON_VERSION}" environment.yml micromamba create -f environment.yml From ae00b8d66aa7b6c9fd4b428e7a2a66cf140afb52 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Thu, 9 Nov 2023 23:13:03 -0900 Subject: [PATCH 55/73] fix sed --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f4e9dd6e6..614453880 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -29,7 +29,7 @@ jobs: eval "$($HOME/bin/micromamba shell hook -s posix)" PYTHON_VERSION="<< parameters.python-version >>" - sed -i "/python>=/c python=${PYTHON_VERSION}" environment.yml + sed -i "/python>=/c\ - python=${PYTHON_VERSION}" environment.yml micromamba create -f environment.yml - run: From 70a663d3a95c046cdbb31d7402b40306f22d0e9e Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 10 Nov 2023 19:56:25 -0900 Subject: [PATCH 56/73] remove github pytest worflow and clean up circleci config --- .circleci/config.yml | 16 ++++---- .github/workflows/test.yml | 75 -------------------------------------- 2 files changed, 8 insertions(+), 83 deletions(-) delete mode 100644 .github/workflows/test.yml diff --git a/.circleci/config.yml b/.circleci/config.yml index 614453880..4e11186c6 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -26,17 +26,17 @@ jobs: name: Create RAiDER environment shell: /bin/bash -l command: | - eval "$($HOME/bin/micromamba shell hook -s posix)" + eval "$(${HOME}/bin/micromamba shell hook -s posix)" PYTHON_VERSION="<< parameters.python-version >>" sed -i "/python>=/c\ - python=${PYTHON_VERSION}" environment.yml micromamba create -f environment.yml - run: - name: Install raider and Check environment + name: Install raider and check environment shell: /bin/bash -l command: | - eval "$($HOME/bin/micromamba shell hook -s posix)" + eval "$(${HOME}/bin/micromamba shell hook -s posix)" micromamba activate RAiDER python -m pip install --no-deps . @@ -52,7 +52,7 @@ jobs: name: Setup data stores shell: /bin/bash -l command: | - eval "$($HOME/bin/micromamba shell hook -s posix)" + eval "$(${HOME}/bin/micromamba shell hook -s posix)" micromamba activate RAiDER python -c 'from RAiDER.models.credentials import setup_from_env; setup_from_env()' @@ -61,11 +61,11 @@ jobs: name: Run unit tests shell: /bin/bash -l command: | - eval "$($HOME/bin/micromamba shell hook -s posix)" + eval "$(${HOME}/bin/micromamba shell hook -s posix)" micromamba activate RAiDER COV_OPTIONS=`python -c "import importlib;print(*(' --cov='+p for p in importlib.util.find_spec('RAiDER').submodule_search_locations))"` - pytest -m "not long" test/ $COV_OPTIONS --cov-report= + pytest -m "not long" test/ ${COV_OPTIONS} --cov-report= - run: name: Report coverage @@ -73,11 +73,11 @@ jobs: command: | PYTHON_VERSION="<< parameters.python-version >>" if [ "${PYTHON_VERSION}" == "3.12" ]; then - eval "$($HOME/bin/micromamba shell hook -s posix)" + eval "$(${HOME}/bin/micromamba shell hook -s posix)" micromamba activate RAiDER python -m pip install coveralls - python .circleci/fix_coverage_paths.py .coverage $(pwd)/tools/RAiDER/ + python .circleci/fix_coverage_paths.py .coverage ${PWD}/tools/RAiDER/ coverage report -mi coveralls fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 1bc091591..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Test and build - -on: - push: - branches: - - main - - dev - pull_request: - branches: - - main - - dev - -env: - RAIDER_ECMWF_ERA5_UID: ${{ secrets.RAIDER_ECMWF_ERA5_UID }} - RAIDER_ECMWF_ERA5_API_KEY: ${{ secrets.RAIDER_ECMWF_ERA5_API_KEY }} - RAIDER_HRES_EMAIL: ${{ secrets.RAIDER_HRES_EMAIL }} - RAIDER_HRES_API_KEY: ${{ secrets.RAIDER_HRES_API_KEY }} - RAIDER_HRES_URL: ${{ secrets.RAIDER_HRES_URL }} - EARTHDATA_USERNAME: ${{ secrets.EARTHDATA_USERNAME }} - EARTHDATA_PASSWORD: ${{ secrets.EARTHDATA_PASSWORD }} - ESA_CDSE_USERNAME: ${{ secrets.ESA_CDSE_USERNAME }} - ESA_CDSE_PASSWORD: ${{ secrets.ESA_CDSE_PASSWORD }} - -jobs: - pytest: - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - python_version: ['3.9', '3.10', '3.11', '3.12'] - coverage_report_version: ['3.12'] - - steps: - - uses: actions/checkout@v4 - - - uses: mamba-org/setup-micromamba@v1 - with: - environment-file: environment.yml - create-args: >- - python=${{ matrix.python_version }} - - - name: Install raider and Check environment - shell: bash -l {0} - run: | - python -m pip install --no-deps . - - python -c "import RAiDER; from RAiDER.delay import tropo_delay" - python -c "import RAiDER; from RAiDER.interpolator import interp_along_axis" - - python --version - python -c "import numpy; print(numpy.__version__)" - python -c "import pyproj; print(pyproj.__version__)" - - - name: Setup data stores - shell: bash -l {0} - run: | - python -c 'from RAiDER.models.credentials import setup_from_env; setup_from_env()' - - - name: Pytest in conda environment - shell: bash -l {0} - run: | - COV_OPTIONS=$(python -c "import importlib;print(*(' --cov='+p for p in importlib.util.find_spec('RAiDER').submodule_search_locations))") - pytest -m "not long" test/ $COV_OPTIONS --cov-report= - - - name: Report Coverage - if: matrix.python_version == matrix.coverage_report_version - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash -l {0} - run: | - python -m pip install coveralls - python .circleci/fix_coverage_paths.py .coverage ${PWD}/tools/RAiDER/ - coverage report -mi - coveralls From 3678e0c20e2bbb8edb9984113f411cec39b41a39 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 10 Nov 2023 22:32:02 -0900 Subject: [PATCH 57/73] Update changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9756d7d87..e7a9bcda2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,17 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +* Adds an `ensure_orbit_credentials` function in `prepFromGUNW.py` to ensure ESA CDSE credentials are in `~/.netrc` or provided via environment variables +* Adds a `setup_from_env` function to `models/credentials.py` which will pull *all* credentials needed for acquiring weather model data from environment variables and ensure the correct config file is written. This makes setting up credentials in CI pipelines significantly easier + +### Changed +* `sentineleof` upgraded to version 9 or later to fetch orbits from ESA CDSE ### Fixes +* RAiDER is now tested on Python version 3.9-3.12 +* All typehints are now Python 3.9 compatible +* Python entrypoint loading is now compatible with Python 3.12 * make weather file directory when it doesn't exist * Ensures the `models/data/alaska.geojson.zip` file is packaged when building from the source tarball * Make ISCE3 an optional dependency in `s1_azimuth_timing.py` From e1f47c1665e5785a1024508f8a25512618d422a3 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 10 Nov 2023 22:45:57 -0900 Subject: [PATCH 58/73] Add whitespace --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a9bcda2..eb3744245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + ### Added * Adds an `ensure_orbit_credentials` function in `prepFromGUNW.py` to ensure ESA CDSE credentials are in `~/.netrc` or provided via environment variables * Adds a `setup_from_env` function to `models/credentials.py` which will pull *all* credentials needed for acquiring weather model data from environment variables and ensure the correct config file is written. This makes setting up credentials in CI pipelines significantly easier From 50fa0c3f5a83e307b1fbb8109f00509fa5a6903d Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Fri, 10 Nov 2023 22:47:32 -0900 Subject: [PATCH 59/73] Link issue in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3744245..6e6a61d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes * RAiDER is now tested on Python version 3.9-3.12 * All typehints are now Python 3.9 compatible -* Python entrypoint loading is now compatible with Python 3.12 +* [607](https://github.com/dbekaert/RAiDER/issues/607): Python entrypoint loading is now compatible with Python 3.12 * make weather file directory when it doesn't exist * Ensures the `models/data/alaska.geojson.zip` file is packaged when building from the source tarball * Make ISCE3 an optional dependency in `s1_azimuth_timing.py` From 6a6aba86cb4539e2867a4a7c648f2e681a92f9f2 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Tue, 14 Nov 2023 12:19:56 -0900 Subject: [PATCH 60/73] Update environment.yml Co-authored-by: Charlie Marshak --- environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index 8b04c0a6e..a072656c2 100644 --- a/environment.yml +++ b/environment.yml @@ -39,7 +39,7 @@ dependencies: - rioxarray - s3fs - scipy>1.10.0 - - sentineleof>=0.9.0 + - sentineleof>=0.9.5 - shapely - tqdm - xarray From e300d6d4322d387c718cc174fef8d47038112caf Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Tue, 14 Nov 2023 12:20:20 -0900 Subject: [PATCH 61/73] Update CHANGELOG.md Co-authored-by: Charlie Marshak --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6a61d6b..6c74b8e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * Adds a `setup_from_env` function to `models/credentials.py` which will pull *all* credentials needed for acquiring weather model data from environment variables and ensure the correct config file is written. This makes setting up credentials in CI pipelines significantly easier ### Changed -* `sentineleof` upgraded to version 9 or later to fetch orbits from ESA CDSE +* `sentineleof` upgraded to version 0.9.5 or later to (a) fetch orbits from ESA CDSE and (b) ensure that if CDSE fetch fails, code resorts to ASF orbit repository ### Fixes * RAiDER is now tested on Python version 3.9-3.12 From 9aa8505c91aa5646554f7debb6075c5765e6860d Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 11:07:10 -0900 Subject: [PATCH 62/73] Update hyp3lib to v2 --- environment.yml | 4 +- test/test_GUNW.py | 33 ++++++++--------- test/test_s1_time_grid.py | 2 +- tools/RAiDER/aria/prepFromGUNW.py | 41 +-------------------- tools/RAiDER/models/credentials.py | 21 ++++++----- tools/RAiDER/s1_azimuth_timing.py | 8 +++- tools/RAiDER/s1_orbits.py | 59 ++++++++++++++++++++++++++++++ 7 files changed, 96 insertions(+), 72 deletions(-) create mode 100644 tools/RAiDER/s1_orbits.py diff --git a/environment.yml b/environment.yml index a072656c2..c6a582714 100644 --- a/environment.yml +++ b/environment.yml @@ -22,7 +22,7 @@ dependencies: - h5netcdf - h5py - herbie-data - - hyp3lib + - hyp3lib>=2.0.2 - isce3>=0.15.0 - jsonschema==3.2.0 # this is for ASF DAAC ingest schema validation - lxml @@ -54,7 +54,7 @@ dependencies: - pytest-cov - pytest-mock - pytest-timeout - - setuptools_scm >=6.2 + - setuptools_scm>=6.2 - sysroot_linux-64 # For docs website - mkdocs diff --git a/test/test_GUNW.py b/test/test_GUNW.py index 8ee6fdd74..c5db87743 100644 --- a/test/test_GUNW.py +++ b/test/test_GUNW.py @@ -7,7 +7,6 @@ from pathlib import Path import eof.download -import hyp3lib import jsonschema import numpy as np import pandas as pd @@ -17,13 +16,13 @@ import RAiDER import RAiDER.cli.raider as raider +import RAiDER.s1_azimuth_timing from RAiDER import aws from RAiDER.aria.prepFromGUNW import ( check_hrrr_dataset_availablity_for_s1_azimuth_time_interpolation, check_weather_model_availability, - _ensure_orbit_credential, - ESA_CDSE_HOST, ) +from RAiDER.s1_orbits import _ensure_orbit_credential, ESA_CDSE_HOST from RAiDER.cli.raider import calcDelaysGUNW from RAiDER.models.customExceptions import * @@ -209,7 +208,7 @@ def test_azimuth_timing_interp_against_center_time_interp(weather_model_name: st # https://github.com/dbekaert/RAiDER/blob/ # f77af9ce2d3875b00730603305c0e92d6c83adc2/tools/RAiDER/aria/prepFromGUNW.py#L151-L200 - mocker.patch('hyp3lib.get_orb.downloadSentinelOrbitFile', + mocker.patch('RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile', # Hyp3 Lib returns 2 values side_effect=[ # For azimuth time @@ -263,7 +262,7 @@ def test_azimuth_timing_interp_against_center_time_interp(weather_model_name: st assert RAiDER.processWM.prepareWeatherModel.call_count == 8 # Only calls for azimuth timing for reference and secondary; # There is 1 slc for ref and 2 for secondary, totalling 3 calls - assert hyp3lib.get_orb.downloadSentinelOrbitFile.call_count == 3 + assert RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile.call_count == 3 # Only calls for azimuth timing: once for ref and sec assert RAiDER.s1_azimuth_timing.get_slc_id_from_point_and_time.call_count == 2 # Once for center-time and azimuth-time each @@ -552,16 +551,16 @@ def __str__(self): # No .netrc, no ESA CDSE env variables with monkeypatch.context() as mp: mp.setattr(netrc, 'netrc', EmptyNetrc, raising=False) - mp.delenv('ESA_CDSE_USERNAME', raising=False) - mp.delenv('ESA_CDSE_PASSWORD', raising=False) + mp.delenv('ESA_USERNAME', raising=False) + mp.delenv('ESA_PASSWORD', raising=False) with pytest.raises(ValueError): _ensure_orbit_credential() # No .netrc, set ESA CDSE env variables with monkeypatch.context() as mp: mp.setattr(netrc, 'netrc', EmptyNetrc, raising=False) - mp.setenv('ESA_CDSE_USERNAME', 'foo') - mp.setenv('ESA_CDSE_PASSWORD', 'bar') + mp.setenv('ESA_USERNAME', 'foo') + mp.setenv('ESA_PASSWORD', 'bar') mp.setattr(Path, 'write_text', lambda self, write_text: write_text) written_credentials = _ensure_orbit_credential() assert written_credentials == str({ESA_CDSE_HOST: ('foo', None, 'bar')}) @@ -576,16 +575,16 @@ def __str__(self): # No CDSE in .netrc, no ESA CDSE env variables with monkeypatch.context() as mp: mp.setattr(netrc, 'netrc', NoCDSENetrc, raising=False) - mp.delenv('ESA_CDSE_USERNAME', raising=False) - mp.delenv('ESA_CDSE_PASSWORD', raising=False) + mp.delenv('ESA_USERNAME', raising=False) + mp.delenv('ESA_PASSWORD', raising=False) with pytest.raises(ValueError): _ensure_orbit_credential() # No CDSE in .netrc, set ESA CDSE env variables with monkeypatch.context() as mp: mp.setattr(netrc, 'netrc', NoCDSENetrc, raising=False) - mp.setenv('ESA_CDSE_USERNAME', 'foo') - mp.setenv('ESA_CDSE_PASSWORD', 'bar') + mp.setenv('ESA_USERNAME', 'foo') + mp.setenv('ESA_PASSWORD', 'bar') mp.setattr(Path, 'write_text', lambda self, write_text: write_text) written_credentials = _ensure_orbit_credential() assert written_credentials == str({'fizz.buzz.org': ('foo', None, 'bar'), ESA_CDSE_HOST: ('foo', None, 'bar')}) @@ -600,15 +599,15 @@ def __str__(self): # cdse in .netrc, no ESA CDSE env variables with monkeypatch.context() as mp: mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) - mp.delenv('ESA_CDSE_USERNAME', raising=False) - mp.delenv('ESA_CDSE_PASSWORD', raising=False) + mp.delenv('ESA_USERNAME', raising=False) + mp.delenv('ESA_PASSWORD', raising=False) written_credentials = _ensure_orbit_credential() assert written_credentials is None # cdse in .netrc, set ESA CDSE env variables with monkeypatch.context() as mp: mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) - mp.setenv('ESA_CDSE_USERNAME', 'foo') - mp.setenv('ESA_CDSE_PASSWORD', 'bar') + mp.setenv('ESA_USERNAME', 'foo') + mp.setenv('ESA_PASSWORD', 'bar') written_credentials = _ensure_orbit_credential() assert written_credentials is None diff --git a/test/test_s1_time_grid.py b/test/test_s1_time_grid.py index 4ff41d113..b9929c027 100644 --- a/test/test_s1_time_grid.py +++ b/test/test_s1_time_grid.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Union -import hyp3lib +import hyp3lib.get_orb import numpy as np import pandas as pd import pytest diff --git a/tools/RAiDER/aria/prepFromGUNW.py b/tools/RAiDER/aria/prepFromGUNW.py index 9344739bd..3b7871029 100644 --- a/tools/RAiDER/aria/prepFromGUNW.py +++ b/tools/RAiDER/aria/prepFromGUNW.py @@ -6,67 +6,28 @@ # # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ import os -import netrc from datetime import datetime -from pathlib import Path -from platform import system -from typing import Optional import numpy as np import eof.download import xarray as xr import rasterio -import geopandas as gpd import pandas as pd import yaml import shapely.wkt from dataclasses import dataclass import sys from shapely.geometry import box -from rasterio.crs import CRS import RAiDER -from RAiDER.utilFcns import rio_open, writeArrayToRaster from RAiDER.logger import logger from RAiDER.models import credentials from RAiDER.models.hrrr import HRRR_CONUS_COVERAGE_POLYGON, AK_GEO, check_hrrr_dataset_availability from RAiDER.s1_azimuth_timing import get_times_for_azimuth_interpolation +from RAiDER.s1_orbits import _ensure_orbit_credential ## cube spacing in degrees for each model DCT_POSTING = {'HRRR': 0.05, 'HRES': 0.10, 'GMAO': 0.10, 'ERA5': 0.10, 'ERA5T': 0.10} -ESA_CDSE_HOST = 'dataspace.copernicus.eu' - - -def _ensure_orbit_credential() -> Optional[int]: - """Ensure credentials exist for ESA's CDSE to download orbits - - This method will prefer to use CDSE credentials from your `~/.netrc` file if they exist, - otherwise will look for ESA_CDSE_USERNAME and ESA_CDSE_PASSWORD environment variables and - update or create your `~/.netrc` file. - - Returns `None` if the `~/.netrc` file did not need to be updated and the number of characters written if it did. - """ - netrc_name = '_netrc' if system().lower() == 'windows' else '.netrc' - netrc_file = Path.home() / netrc_name - - # netrc needs a netrc file; if missing create an empty one. - if not netrc_file.exists(): - netrc_file.touch() - - netrc_credentials = netrc.netrc(netrc_file) - if ESA_CDSE_HOST in netrc_credentials.hosts: - return - - username = os.environ.get('ESA_CDSE_USERNAME') - password = os.environ.get('ESA_CDSE_PASSWORD') - if username is None and password is None: - raise ValueError('Credentials are required for fetching orbit data from dataspace.copernicus.eu!\n' - 'Either add your credentials to ~/.netrc or set the ESA_CDSE_USERNAME and ESA_CDSE_PASSWORD ' - 'environment variables.') - - netrc_credentials.hosts[ESA_CDSE_HOST] = (username, None, password) - return netrc_file.write_text(str(netrc_credentials)) - def _get_acq_time_from_gunw_id(gunw_id: str, reference_or_secondary: str) -> datetime: # Ex: S1-GUNW-A-R-106-tops-20220115_20211222-225947-00078W_00041N-PP-4be8-v3_0_0 diff --git a/tools/RAiDER/models/credentials.py b/tools/RAiDER/models/credentials.py index 05b427f80..249203d8f 100644 --- a/tools/RAiDER/models/credentials.py +++ b/tools/RAiDER/models/credentials.py @@ -1,13 +1,13 @@ ''' -API credential information and help url for downloading weather model data - saved in a hidden file in home directory +API credential information and help url for downloading weather model data + saved in a hidden file in home directory api filename weather models UID KEY URL _________________________________________________________________________________ cdsapirc ERA5, ERA5T uid key https://cds.climate.copernicus.eu/api/v2 ecmwfapirc ERAI, HRES email key https://api.ecmwf.int/v1 -netrc GMAO, MERRA2 username password urs.earthdata.nasa.gov - HRRR [public access] +netrc GMAO, MERRA2 username password urs.earthdata.nasa.gov + HRRR [public access] ''' import os @@ -17,10 +17,11 @@ # Filename for the hidden file per model API_FILENAME = {'ERA5' : 'cdsapirc', 'ERA5T' : 'cdsapirc', - 'ERAI' : 'ecmwfapirc', + 'ERAI' : 'ecmwfapirc', # FIXME: Drop 'HRES' : 'ecmwfapirc', 'GMAO' : 'netrc', 'HRRR' : None + # FIXME: Add HRRRAK... } # API urls @@ -70,7 +71,7 @@ def _check_envs(model): host = API_URLS['cdsapirc'] elif model in ('HRES', 'ERAI'): - uid = os.getenv('RAIDER_HRES_EMAIL') + uid = os.getenv('RAIDER_HRES_EMAIL') key = os.getenv('RAIDER_HRES_API_KEY') host = os.getenv('RAIDER_HRES_URL') if host is None: @@ -107,19 +108,19 @@ def check_api(model: str, hidden_ext = '_' if system()=="Windows" else '.' # skip below if model is HRRR as it does not need API - if api_filename: + if api_filename: # Check if the credential api file exists api_filename_path = Path(output_dir) / (hidden_ext + api_filename) api_filename_path = api_filename_path.expanduser() - # if update flag is on, overwrite existing file + # if update flag is on, overwrite existing file if update_flag is True: api_filename_path.unlink(missing_ok=True) - + # Check if API_RC file already exists if api_filename_path.exists(): return None - + # if it does not exist, put UID/KEY inserted, create it elif not api_filename_path.exists() and UID and KEY: # Create file with inputs, do it only once diff --git a/tools/RAiDER/s1_azimuth_timing.py b/tools/RAiDER/s1_azimuth_timing.py index 8d8d4bfbb..c7916e47e 100644 --- a/tools/RAiDER/s1_azimuth_timing.py +++ b/tools/RAiDER/s1_azimuth_timing.py @@ -2,9 +2,9 @@ import warnings import asf_search as asf -import hyp3lib.get_orb import numpy as np import pandas as pd +from hyp3lib.get_orb import downloadSentinelOrbitFile from shapely.geometry import Point try: @@ -12,6 +12,7 @@ except ImportError: isce = None +from .s1_orbits import get_esa_cdse_credentials from .losreader import get_orbit as get_isce_orbit @@ -182,7 +183,10 @@ def get_s1_azimuth_time_grid(lon: np.ndarray, np.datetime64('NaT'), dtype='datetime64[ms]') return az_arr - orb_files = list(map(lambda slc_id: hyp3lib.get_orb.downloadSentinelOrbitFile(slc_id)[0], slc_ids)) + + esa_credentials = get_esa_cdse_credentials() + orb_files = [downloadSentinelOrbitFile(slc_id, esa_credentials=esa_credentials)[0] for slc_id in slc_ids] + # orb_files = list(map(lambda slc_id: hyp3lib.get_orb.downloadSentinelOrbitFile(slc_id, esa_credentials=esa_credentials)[0], slc_ids)) orb = get_isce_orbit(orb_files, dt, pad=600) az_arr = get_azimuth_time_grid(lon_mesh, lat_mesh, hgt_mesh, orb) diff --git a/tools/RAiDER/s1_orbits.py b/tools/RAiDER/s1_orbits.py new file mode 100644 index 000000000..cb5787aff --- /dev/null +++ b/tools/RAiDER/s1_orbits.py @@ -0,0 +1,59 @@ +import netrc +import os +from pathlib import Path +from platform import system +from typing import Optional, Tuple + + +ESA_CDSE_HOST = 'dataspace.copernicus.eu' + + +def _netrc_path() -> Path: + netrc_name = '_netrc' if system().lower() == 'windows' else '.netrc' + return Path.home() / netrc_name + + +def _ensure_orbit_credential() -> Optional[int]: + """Ensure credentials exist for ESA's CDSE to download orbits + + This method will prefer to use CDSE credentials from your `~/.netrc` file if they exist, + otherwise will look for ESA_USERNAME and ESA_PASSWORD environment variables and + update or create your `~/.netrc` file. + + Returns `None` if the `~/.netrc` file did not need to be updated and the number of characters written if it did. + """ + netrc_file = _netrc_path() + + # netrc needs a netrc file; if missing create an empty one. + if not netrc_file.exists(): + netrc_file.touch() + + netrc_credentials = netrc.netrc(netrc_file) + if ESA_CDSE_HOST in netrc_credentials.hosts: + return + + username = os.environ.get('ESA_USERNAME') + password = os.environ.get('ESA_PASSWORD') + if username is None and password is None: + raise ValueError('Credentials are required for fetching orbit data from dataspace.copernicus.eu!\n' + 'Either add your credentials to ~/.netrc or set the ESA_USERNAME and ESA_PASSWORD ' + 'environment variables.') + + netrc_credentials.hosts[ESA_CDSE_HOST] = (username, None, password) + return netrc_file.write_text(str(netrc_credentials)) + + +def get_esa_cdse_credentials() -> Tuple[str, str]: + """Retrieve credentials for ESA's CDSE to download orbits + + This method will prefer to use CDSE credentials from your `~/.netrc` file if they exist, + otherwise will look for ESA_USERNAME and ESA_PASSWORD environment variables and + update or create your `~/.netrc` file. + + Returns `username` and `password` . + """ + _ = _ensure_orbit_credential() + netrc_file = _netrc_path() + netrc_credentials = netrc.netrc(netrc_file) + username, _, password = netrc_credentials.hosts[ESA_CDSE_HOST] + return username, password From a859fd8e7c41f7108a9ee0e4fec000a582fc7bea Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 12:35:35 -0900 Subject: [PATCH 63/73] s1_orbits tests --- test/test_GUNW.py | 76 +----------------------- test/test_s1_orbits.py | 96 +++++++++++++++++++++++++++++++ tools/RAiDER/aria/prepFromGUNW.py | 4 +- tools/RAiDER/s1_azimuth_timing.py | 1 - tools/RAiDER/s1_orbits.py | 4 +- 5 files changed, 101 insertions(+), 80 deletions(-) create mode 100644 test/test_s1_orbits.py diff --git a/test/test_GUNW.py b/test/test_GUNW.py index c5db87743..093404d73 100644 --- a/test/test_GUNW.py +++ b/test/test_GUNW.py @@ -22,7 +22,6 @@ check_hrrr_dataset_availablity_for_s1_azimuth_time_interpolation, check_weather_model_availability, ) -from RAiDER.s1_orbits import _ensure_orbit_credential, ESA_CDSE_HOST from RAiDER.cli.raider import calcDelaysGUNW from RAiDER.models.customExceptions import * @@ -372,7 +371,7 @@ def test_provenance_metadata_for_tropo_group(weather_model_name: str, out_path = shutil.copy(gunw_azimuth_test, tmp_path / out) if interp_method == 'azimuth_time_grid': - mocker.patch('hyp3lib.get_orb.downloadSentinelOrbitFile', + mocker.patch('RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile', # Hyp3 Lib returns 2 values side_effect=[ # For azimuth time @@ -538,76 +537,3 @@ def test_value_error_for_file_inputs_when_no_data_available(mocker): with pytest.raises(NoWeatherModelData): calcDelaysGUNW(iargs) RAiDER.aria.prepFromGUNW.main.assert_not_called() - - -def test__ensure_orbit_credential(monkeypatch): - class EmptyNetrc(): - def __init__(self, netrc_file): - self.netrc_file = netrc_file - self.hosts = {} - def __str__(self): - return str(self.hosts) - - # No .netrc, no ESA CDSE env variables - with monkeypatch.context() as mp: - mp.setattr(netrc, 'netrc', EmptyNetrc, raising=False) - mp.delenv('ESA_USERNAME', raising=False) - mp.delenv('ESA_PASSWORD', raising=False) - with pytest.raises(ValueError): - _ensure_orbit_credential() - - # No .netrc, set ESA CDSE env variables - with monkeypatch.context() as mp: - mp.setattr(netrc, 'netrc', EmptyNetrc, raising=False) - mp.setenv('ESA_USERNAME', 'foo') - mp.setenv('ESA_PASSWORD', 'bar') - mp.setattr(Path, 'write_text', lambda self, write_text: write_text) - written_credentials = _ensure_orbit_credential() - assert written_credentials == str({ESA_CDSE_HOST: ('foo', None, 'bar')}) - - class NoCDSENetrc(): - def __init__(self, netrc_file): - self.netrc_file = netrc_file - self.hosts = {'fizz.buzz.org': ('foo', None, 'bar')} - def __str__(self): - return str(self.hosts) - - # No CDSE in .netrc, no ESA CDSE env variables - with monkeypatch.context() as mp: - mp.setattr(netrc, 'netrc', NoCDSENetrc, raising=False) - mp.delenv('ESA_USERNAME', raising=False) - mp.delenv('ESA_PASSWORD', raising=False) - with pytest.raises(ValueError): - _ensure_orbit_credential() - - # No CDSE in .netrc, set ESA CDSE env variables - with monkeypatch.context() as mp: - mp.setattr(netrc, 'netrc', NoCDSENetrc, raising=False) - mp.setenv('ESA_USERNAME', 'foo') - mp.setenv('ESA_PASSWORD', 'bar') - mp.setattr(Path, 'write_text', lambda self, write_text: write_text) - written_credentials = _ensure_orbit_credential() - assert written_credentials == str({'fizz.buzz.org': ('foo', None, 'bar'), ESA_CDSE_HOST: ('foo', None, 'bar')}) - - class CDSENetrc(): - def __init__(self, netrc_file): - self.netrc_file = netrc_file - self.hosts = {ESA_CDSE_HOST: ('foo', None, 'bar')} - def __str__(self): - return str(self.hosts) - - # cdse in .netrc, no ESA CDSE env variables - with monkeypatch.context() as mp: - mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) - mp.delenv('ESA_USERNAME', raising=False) - mp.delenv('ESA_PASSWORD', raising=False) - written_credentials = _ensure_orbit_credential() - assert written_credentials is None - - # cdse in .netrc, set ESA CDSE env variables - with monkeypatch.context() as mp: - mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) - mp.setenv('ESA_USERNAME', 'foo') - mp.setenv('ESA_PASSWORD', 'bar') - written_credentials = _ensure_orbit_credential() - assert written_credentials is None diff --git a/test/test_s1_orbits.py b/test/test_s1_orbits.py new file mode 100644 index 000000000..0080d6ad3 --- /dev/null +++ b/test/test_s1_orbits.py @@ -0,0 +1,96 @@ +import netrc +from pathlib import Path + +import pytest + +from RAiDER import s1_orbits + + +def test__ensure_orbit_credentials(monkeypatch): + class EmptyNetrc(): + def __init__(self, netrc_file): + self.netrc_file = netrc_file + self.hosts = {} + def __str__(self): + return str(self.hosts) + + # No .netrc, no ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', EmptyNetrc, raising=False) + mp.delenv('ESA_USERNAME', raising=False) + mp.delenv('ESA_PASSWORD', raising=False) + with pytest.raises(ValueError): + s1_orbits._ensure_orbit_credentials() + + # No .netrc, set ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', EmptyNetrc, raising=False) + mp.setenv('ESA_USERNAME', 'foo') + mp.setenv('ESA_PASSWORD', 'bar') + mp.setattr(Path, 'write_text', lambda self, write_text: write_text) + written_credentials = s1_orbits._ensure_orbit_credentials() + assert written_credentials == str({s1_orbits.ESA_CDSE_HOST: ('foo', None, 'bar')}) + + class NoCDSENetrc(): + def __init__(self, netrc_file): + self.netrc_file = netrc_file + self.hosts = {'fizz.buzz.org': ('foo', None, 'bar')} + def __str__(self): + return str(self.hosts) + + # No CDSE in .netrc, no ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', NoCDSENetrc, raising=False) + mp.delenv('ESA_USERNAME', raising=False) + mp.delenv('ESA_PASSWORD', raising=False) + with pytest.raises(ValueError): + s1_orbits._ensure_orbit_credentials() + + # No CDSE in .netrc, set ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', NoCDSENetrc, raising=False) + mp.setenv('ESA_USERNAME', 'foo') + mp.setenv('ESA_PASSWORD', 'bar') + mp.setattr(Path, 'write_text', lambda self, write_text: write_text) + written_credentials = s1_orbits._ensure_orbit_credentials() + assert written_credentials == str({'fizz.buzz.org': ('foo', None, 'bar'), s1_orbits.ESA_CDSE_HOST: ('foo', None, 'bar')}) + + class CDSENetrc(): + def __init__(self, netrc_file): + self.netrc_file = netrc_file + self.hosts = {s1_orbits.ESA_CDSE_HOST: ('foo', None, 'bar')} + def __str__(self): + return str(self.hosts) + + # cdse in .netrc, no ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) + mp.delenv('ESA_USERNAME', raising=False) + mp.delenv('ESA_PASSWORD', raising=False) + written_credentials = s1_orbits._ensure_orbit_credentials() + assert written_credentials is None + + # cdse in .netrc, set ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) + mp.setenv('ESA_USERNAME', 'foo') + mp.setenv('ESA_PASSWORD', 'bar') + written_credentials = s1_orbits._ensure_orbit_credentials() + assert written_credentials is None + + +def test_get_esa_cse_credentials(monkeypatch): + class CDSENetrc(): + def __init__(self, netrc_file): + self.netrc_file = netrc_file + self.hosts = {s1_orbits.ESA_CDSE_HOST: ('foo', None, 'bar')} + def __str__(self): + return str(self.hosts) + + # cdse in .netrc, no ESA CDSE env variables + with monkeypatch.context() as mp: + mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) + username, password = s1_orbits.get_esa_cdse_credentials() + + assert username == 'foo' + assert password == 'bar' diff --git a/tools/RAiDER/aria/prepFromGUNW.py b/tools/RAiDER/aria/prepFromGUNW.py index 3b7871029..1aea117f7 100644 --- a/tools/RAiDER/aria/prepFromGUNW.py +++ b/tools/RAiDER/aria/prepFromGUNW.py @@ -23,7 +23,7 @@ from RAiDER.models import credentials from RAiDER.models.hrrr import HRRR_CONUS_COVERAGE_POLYGON, AK_GEO, check_hrrr_dataset_availability from RAiDER.s1_azimuth_timing import get_times_for_azimuth_interpolation -from RAiDER.s1_orbits import _ensure_orbit_credential +from RAiDER.s1_orbits import _ensure_orbit_credentials ## cube spacing in degrees for each model DCT_POSTING = {'HRRR': 0.05, 'HRES': 0.10, 'GMAO': 0.10, 'ERA5': 0.10, 'ERA5T': 0.10} @@ -276,7 +276,7 @@ def get_orbit_file(self): sat = slc.split('_')[0] dt = datetime.strptime(f'{self.dates[0]}T{self.mid_time}', '%Y%m%dT%H:%M:%S') - _ensure_orbit_credential() + _ensure_orbit_credentials() path_orb = eof.download.download_eofs([dt], [sat], save_dir=orbit_dir) return [str(o) for o in path_orb] diff --git a/tools/RAiDER/s1_azimuth_timing.py b/tools/RAiDER/s1_azimuth_timing.py index c7916e47e..7105f5939 100644 --- a/tools/RAiDER/s1_azimuth_timing.py +++ b/tools/RAiDER/s1_azimuth_timing.py @@ -186,7 +186,6 @@ def get_s1_azimuth_time_grid(lon: np.ndarray, esa_credentials = get_esa_cdse_credentials() orb_files = [downloadSentinelOrbitFile(slc_id, esa_credentials=esa_credentials)[0] for slc_id in slc_ids] - # orb_files = list(map(lambda slc_id: hyp3lib.get_orb.downloadSentinelOrbitFile(slc_id, esa_credentials=esa_credentials)[0], slc_ids)) orb = get_isce_orbit(orb_files, dt, pad=600) az_arr = get_azimuth_time_grid(lon_mesh, lat_mesh, hgt_mesh, orb) diff --git a/tools/RAiDER/s1_orbits.py b/tools/RAiDER/s1_orbits.py index cb5787aff..56adcb00e 100644 --- a/tools/RAiDER/s1_orbits.py +++ b/tools/RAiDER/s1_orbits.py @@ -13,7 +13,7 @@ def _netrc_path() -> Path: return Path.home() / netrc_name -def _ensure_orbit_credential() -> Optional[int]: +def _ensure_orbit_credentials() -> Optional[int]: """Ensure credentials exist for ESA's CDSE to download orbits This method will prefer to use CDSE credentials from your `~/.netrc` file if they exist, @@ -52,7 +52,7 @@ def get_esa_cdse_credentials() -> Tuple[str, str]: Returns `username` and `password` . """ - _ = _ensure_orbit_credential() + _ = _ensure_orbit_credentials() netrc_file = _netrc_path() netrc_credentials = netrc.netrc(netrc_file) username, _, password = netrc_credentials.hosts[ESA_CDSE_HOST] From 0868a0f0f2bbdaa285c377100cb83adc150a4468 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 12:36:53 -0900 Subject: [PATCH 64/73] remove unused import --- test/test_GUNW.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_GUNW.py b/test/test_GUNW.py index 093404d73..69161e5be 100644 --- a/test/test_GUNW.py +++ b/test/test_GUNW.py @@ -1,6 +1,5 @@ import glob import json -import netrc import os import shutil import unittest From 14b752eb40cd29d520ff4146f83a036f272e2b58 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 12:39:44 -0900 Subject: [PATCH 65/73] drop ERAI from credentials.py --- tools/RAiDER/models/credentials.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/RAiDER/models/credentials.py b/tools/RAiDER/models/credentials.py index 249203d8f..b65b0dfc5 100644 --- a/tools/RAiDER/models/credentials.py +++ b/tools/RAiDER/models/credentials.py @@ -17,11 +17,9 @@ # Filename for the hidden file per model API_FILENAME = {'ERA5' : 'cdsapirc', 'ERA5T' : 'cdsapirc', - 'ERAI' : 'ecmwfapirc', # FIXME: Drop 'HRES' : 'ecmwfapirc', 'GMAO' : 'netrc', 'HRRR' : None - # FIXME: Add HRRRAK... } # API urls @@ -70,14 +68,14 @@ def _check_envs(model): key = os.getenv('RAIDER_ECMWF_ERA5_API_KEY') host = API_URLS['cdsapirc'] - elif model in ('HRES', 'ERAI'): + elif model in ('HRES',): uid = os.getenv('RAIDER_HRES_EMAIL') key = os.getenv('RAIDER_HRES_API_KEY') host = os.getenv('RAIDER_HRES_URL') if host is None: host = API_URLS['ecmwfapirc'] - elif model in ('GMAO'): + elif model in ('GMAO',): uid = os.getenv('EARTHDATA_USERNAME') # same as in DockerizedTopsApp key = os.getenv('EARTHDATA_PASSWORD') host = API_URLS['netrc'] From 9b252cbece90774fe19dd1e816c5a6ee0daae75d Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 13:39:25 -0900 Subject: [PATCH 66/73] Fix tests for ERAI and hyp3lib change --- test/test_credentials.py | 32 +++++++++++++++--------------- test/test_s1_time_grid.py | 13 ++++++------ tools/RAiDER/models/credentials.py | 2 +- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/test/test_credentials.py b/test/test_credentials.py index fa9fd88ec..caf0c7a60 100644 --- a/test/test_credentials.py +++ b/test/test_credentials.py @@ -5,26 +5,26 @@ # Test checking/creating ECMWF_RC CAPI file def test_ecmwfApi_createFile(): import ecmwfapi - + #Check extension for hidden files hidden_ext = '_' if system()=="Windows" else '.' # Test creation of ~/.ecmwfapirc file - ecmwf_file = os.path.expanduser('./') + hidden_ext + credentials.API_FILENAME['ERAI'] - credentials.check_api('ERAI', 'dummy', 'dummy', './', update_flag=True) - assert os.path.exists(ecmwf_file) == True,f'{ecmwf_file} does not exist' + ecmwf_file = os.path.expanduser('./') + hidden_ext + credentials.API_FILENAME['HRES'] + credentials.check_api('HRES', 'dummy', 'dummy', './', update_flag=True) + assert os.path.exists(ecmwf_file) == True,f'{ecmwf_file} does not exist' # Get existing ECMWF_API_RC env if exist default_ecmwf_file = os.getenv("ECMWF_API_RC_FILE") if default_ecmwf_file is None: default_ecmwf_file = ecmwfapi.api.DEFAULT_RCFILE_PATH - #Set it to current dir to avoid overwriting ~/.ecmwfapirc file + #Set it to current dir to avoid overwriting ~/.ecmwfapirc file os.environ["ECMWF_API_RC_FILE"] = ecmwf_file key, url, uid = ecmwfapi.api.get_apikey_values() - + # Return to default_ecmwf_file and remove local API file - os.environ["ECMWF_API_RC_FILE"] = default_ecmwf_file + os.environ["ECMWF_API_RC_FILE"] = default_ecmwf_file os.remove(ecmwf_file) #Check if API is written correctly @@ -33,17 +33,17 @@ def test_ecmwfApi_createFile(): # Test checking/creating Copernicus Climate Data Store -# CDS_RC CAPI file +# CDS_RC CAPI file def test_cdsApi_createFile(): import cdsapi #Check extension for hidden files hidden_ext = '_' if system()=="Windows" else '.' - + # Test creation of .cdsapirc file in current dir cds_file = os.path.expanduser('./') + hidden_ext + credentials.API_FILENAME['ERA5'] credentials.check_api('ERA5', 'dummy', 'dummy', './', update_flag=True) - assert os.path.exists(cds_file) == True,f'{cds_file} does not exist' + assert os.path.exists(cds_file) == True,f'{cds_file} does not exist' # Check the content cds_credentials = cdsapi.api.read_config(cds_file) @@ -51,21 +51,21 @@ def test_cdsApi_createFile(): # Remove local API file os.remove(cds_file) - + assert uid == 'dummy', f'{cds_file}: UID was not written correctly' assert key == 'dummy', f'{cds_file}: KEY was not written correctly' -# Test checking/creating EARTHDATA_RC API file +# Test checking/creating EARTHDATA_RC API file def test_netrcApi_createFile(): import netrc - + #Check extension for hidden files hidden_ext = '_' if system()=="Windows" else '.' # Test creation of ~/.cdsapirc file netrc_file = os.path.expanduser('./') + hidden_ext + credentials.API_FILENAME['GMAO'] credentials.check_api('GMAO', 'dummy', 'dummy', './', update_flag=True) - assert os.path.exists(netrc_file) == True,f'{netrc_file} does not exist' + assert os.path.exists(netrc_file) == True,f'{netrc_file} does not exist' # Check the content host = 'urs.earthdata.nasa.gov' @@ -74,6 +74,6 @@ def test_netrcApi_createFile(): # Remove local API file os.remove(netrc_file) - + assert uid == 'dummy', f'{netrc_file}: UID was not written correctly' - assert key == 'dummy', f'{netrc_file}: KEY was not written correctly' \ No newline at end of file + assert key == 'dummy', f'{netrc_file}: KEY was not written correctly' diff --git a/test/test_s1_time_grid.py b/test/test_s1_time_grid.py index b9929c027..49c833c5b 100644 --- a/test/test_s1_time_grid.py +++ b/test/test_s1_time_grid.py @@ -2,7 +2,6 @@ from pathlib import Path from typing import Union -import hyp3lib.get_orb import numpy as np import pandas as pd import pytest @@ -88,7 +87,7 @@ def test_s1_timing_array_wrt_slc_center_time(gunw_azimuth_test: Path, slc_start_time = get_start_time_from_slc_id(slc_ids[n // 2]).to_pydatetime() # Azimuth time grid - mocker.patch('hyp3lib.get_orb.downloadSentinelOrbitFile', + mocker.patch('RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile', # Hyp3 Lib returns 2 values return_value=(orbit_dict_for_azimuth_time_test[ifg_type], '')) mocker.patch('RAiDER.s1_azimuth_timing._asf_query', @@ -104,7 +103,7 @@ def test_s1_timing_array_wrt_slc_center_time(gunw_azimuth_test: Path, assert RAiDER.s1_azimuth_timing._asf_query.call_count == 1 # There are 2 slc ids so it downloads orbits twice call_count = 1 if ifg_type == 'reference' else 2 - assert hyp3lib.get_orb.downloadSentinelOrbitFile.call_count == call_count + assert RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile.call_count == call_count @pytest.mark.parametrize('ifg_type', ['reference', 'secondary']) @@ -134,7 +133,7 @@ def test_s1_timing_array_wrt_variance(gunw_azimuth_test: Path, # Azimuth time grid # Azimuth time grid - mocker.patch('hyp3lib.get_orb.downloadSentinelOrbitFile', + mocker.patch('RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile', # Hyp3 Lib returns 2 values return_value=(orbit_dict_for_azimuth_time_test[ifg_type], '')) mocker.patch('RAiDER.s1_azimuth_timing._asf_query', @@ -149,7 +148,7 @@ def test_s1_timing_array_wrt_variance(gunw_azimuth_test: Path, assert RAiDER.s1_azimuth_timing._asf_query.call_count == 1 # There are 2 slc ids so it downloads orbits twice call_count = 1 if ifg_type == 'reference' else 2 - assert hyp3lib.get_orb.downloadSentinelOrbitFile.call_count == call_count + assert RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile.call_count == call_count def test_n_closest_dts(): @@ -340,7 +339,7 @@ def test_duplicate_orbits(mocker, orbit_paths_for_duplicate_orbit_xml_test): # Hyp3 Lib returns 2 values side_effect = [(o_path, '') for o_path in orbit_paths_for_duplicate_orbit_xml_test] - mocker.patch('hyp3lib.get_orb.downloadSentinelOrbitFile', + mocker.patch('RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile', side_effect=side_effect ) @@ -349,7 +348,7 @@ def test_duplicate_orbits(mocker, orbit_paths_for_duplicate_orbit_xml_test): assert time_grid.shape == (len(hgt), len(lat), len(lon)) assert RAiDER.s1_azimuth_timing.get_slc_id_from_point_and_time.call_count == 1 - assert hyp3lib.get_orb.downloadSentinelOrbitFile.call_count == 4 + assert RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile.call_count == 4 def test_get_times_for_az(): diff --git a/tools/RAiDER/models/credentials.py b/tools/RAiDER/models/credentials.py index b65b0dfc5..85f8eb2d7 100644 --- a/tools/RAiDER/models/credentials.py +++ b/tools/RAiDER/models/credentials.py @@ -5,7 +5,7 @@ api filename weather models UID KEY URL _________________________________________________________________________________ cdsapirc ERA5, ERA5T uid key https://cds.climate.copernicus.eu/api/v2 -ecmwfapirc ERAI, HRES email key https://api.ecmwf.int/v1 +ecmwfapirc HRES email key https://api.ecmwf.int/v1 netrc GMAO, MERRA2 username password urs.earthdata.nasa.gov HRRR [public access] ''' From 9ff276aaab070def3c539f6b6c249fc28b5df600 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 15:34:00 -0900 Subject: [PATCH 67/73] drop hyp3lib since it doesn't support python 3.12 --- environment.yml | 1 - test/test_GUNW.py | 60 +++++++++++++++---------------- tools/RAiDER/s1_azimuth_timing.py | 12 +++---- tools/RAiDER/s1_orbits.py | 23 +++++++++++- 4 files changed, 56 insertions(+), 40 deletions(-) diff --git a/environment.yml b/environment.yml index c6a582714..e7de60279 100644 --- a/environment.yml +++ b/environment.yml @@ -22,7 +22,6 @@ dependencies: - h5netcdf - h5py - herbie-data - - hyp3lib>=2.0.2 - isce3>=0.15.0 - jsonschema==3.2.0 # this is for ASF DAAC ingest schema validation - lxml diff --git a/test/test_GUNW.py b/test/test_GUNW.py index 69161e5be..a57152d2f 100644 --- a/test/test_GUNW.py +++ b/test/test_GUNW.py @@ -206,16 +206,6 @@ def test_azimuth_timing_interp_against_center_time_interp(weather_model_name: st # https://github.com/dbekaert/RAiDER/blob/ # f77af9ce2d3875b00730603305c0e92d6c83adc2/tools/RAiDER/aria/prepFromGUNW.py#L151-L200 - mocker.patch('RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile', - # Hyp3 Lib returns 2 values - side_effect=[ - # For azimuth time - (orbit_dict_for_azimuth_time_test['reference'], ''), - (orbit_dict_for_azimuth_time_test['secondary'], ''), - (orbit_dict_for_azimuth_time_test['secondary'], ''), - ] - ) - # For prepGUNW side_effect = [ # center-time @@ -236,6 +226,15 @@ def test_azimuth_timing_interp_against_center_time_interp(weather_model_name: st ['secondary_slc_id', 'secondary_slc_id'], ]) + mocker.patch( + 'RAiDER.s1_azimuth_timing.get_orbits_from_slc_ids', + side_effect=[ + # For azimuth time + [Path(orbit_dict_for_azimuth_time_test['reference'])], + [Path(orbit_dict_for_azimuth_time_test['secondary']), Path(orbit_dict_for_azimuth_time_test['secondary'])], + ] + ) + side_effect = (weather_model_dict_for_center_time_test[weather_model_name] + weather_model_dict_for_azimuth_time_test[weather_model_name]) # RAiDER needs strings for paths @@ -258,9 +257,8 @@ def test_azimuth_timing_interp_against_center_time_interp(weather_model_name: st # Calls 4 times for azimuth time and 4 times for center time assert RAiDER.processWM.prepareWeatherModel.call_count == 8 - # Only calls for azimuth timing for reference and secondary; - # There is 1 slc for ref and 2 for secondary, totalling 3 calls - assert RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile.call_count == 3 + # Only calls once each ref and sec list of slcs + assert RAiDER.s1_azimuth_timing.get_orbits_from_slc_ids.call_count == 2 # Only calls for azimuth timing: once for ref and sec assert RAiDER.s1_azimuth_timing.get_slc_id_from_point_and_time.call_count == 2 # Once for center-time and azimuth-time each @@ -370,16 +368,6 @@ def test_provenance_metadata_for_tropo_group(weather_model_name: str, out_path = shutil.copy(gunw_azimuth_test, tmp_path / out) if interp_method == 'azimuth_time_grid': - mocker.patch('RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile', - # Hyp3 Lib returns 2 values - side_effect=[ - # For azimuth time - (orbit_dict_for_azimuth_time_test['reference'], ''), - (orbit_dict_for_azimuth_time_test['secondary'], ''), - (orbit_dict_for_azimuth_time_test['secondary'], ''), - ] - ) - # For prepGUNW side_effect = [ # center-time @@ -400,6 +388,14 @@ def test_provenance_metadata_for_tropo_group(weather_model_name: str, ['secondary_slc_id', 'secondary_slc_id'], ]) + mocker.patch( + 'RAiDER.s1_azimuth_timing.get_orbits_from_slc_ids', + side_effect=[ + # For azimuth time + [Path(orbit_dict_for_azimuth_time_test['reference'])], + [Path(orbit_dict_for_azimuth_time_test['secondary']), Path(orbit_dict_for_azimuth_time_test['secondary'])], + ] + ) weather_model_path_dict = (weather_model_dict_for_center_time_test if interp_method == 'center_time' else weather_model_dict_for_azimuth_time_test) @@ -473,15 +469,6 @@ def test_GUNW_workflow_fails_if_a_download_fails(gunw_azimuth_test, orbit_dict_f # The first part is the same mock up as done in test_azimuth_timing_interp_against_center_time_interp # Maybe better mocks could be done - but this is sufficient or simply a factory for this test given # This is reused so many times. - mocker.patch('hyp3lib.get_orb.downloadSentinelOrbitFile', - # Hyp3 Lib returns 2 values - side_effect=[ - # For azimuth time - (orbit_dict_for_azimuth_time_test['reference'], ''), - (orbit_dict_for_azimuth_time_test['secondary'], ''), - (orbit_dict_for_azimuth_time_test['secondary'], ''), - ] - ) # For prepGUNW side_effect = [ @@ -503,6 +490,15 @@ def test_GUNW_workflow_fails_if_a_download_fails(gunw_azimuth_test, orbit_dict_f ['secondary_slc_id', 'secondary_slc_id'], ]) + mocker.patch( + 'RAiDER.s1_azimuth_timing.get_orbits_from_slc_ids', + side_effect=[ + # For azimuth time + [Path(orbit_dict_for_azimuth_time_test['reference'])], + [Path(orbit_dict_for_azimuth_time_test['secondary']), Path(orbit_dict_for_azimuth_time_test['secondary'])], + ] + ) + # These are the important parts of this test # Makes sure that a value error is raised if a download fails via a Runtime Error # There are two weather model files required for this particular mock up. First, one fails. diff --git a/tools/RAiDER/s1_azimuth_timing.py b/tools/RAiDER/s1_azimuth_timing.py index 7105f5939..3fc20d353 100644 --- a/tools/RAiDER/s1_azimuth_timing.py +++ b/tools/RAiDER/s1_azimuth_timing.py @@ -4,7 +4,6 @@ import asf_search as asf import numpy as np import pandas as pd -from hyp3lib.get_orb import downloadSentinelOrbitFile from shapely.geometry import Point try: @@ -12,8 +11,8 @@ except ImportError: isce = None -from .s1_orbits import get_esa_cdse_credentials -from .losreader import get_orbit as get_isce_orbit +from RAiDER.losreader import get_orbit as get_isce_orbit +from RAiDER.s1_orbits import get_orbits_from_slc_ids def _asf_query(point: Point, @@ -184,11 +183,12 @@ def get_s1_azimuth_time_grid(lon: np.ndarray, dtype='datetime64[ms]') return az_arr - esa_credentials = get_esa_cdse_credentials() - orb_files = [downloadSentinelOrbitFile(slc_id, esa_credentials=esa_credentials)[0] for slc_id in slc_ids] - orb = get_isce_orbit(orb_files, dt, pad=600) + orb_files = get_orbits_from_slc_ids(slc_ids) + orb_files = [str(of) for of in orb_files] + orb = get_isce_orbit(orb_files, dt, pad=600) az_arr = get_azimuth_time_grid(lon_mesh, lat_mesh, hgt_mesh, orb) + return az_arr diff --git a/tools/RAiDER/s1_orbits.py b/tools/RAiDER/s1_orbits.py index 56adcb00e..c6ec04f10 100644 --- a/tools/RAiDER/s1_orbits.py +++ b/tools/RAiDER/s1_orbits.py @@ -1,8 +1,11 @@ import netrc import os +import re from pathlib import Path from platform import system -from typing import Optional, Tuple +from typing import List, Optional, Tuple + +import eof.download ESA_CDSE_HOST = 'dataspace.copernicus.eu' @@ -57,3 +60,21 @@ def get_esa_cdse_credentials() -> Tuple[str, str]: netrc_credentials = netrc.netrc(netrc_file) username, _, password = netrc_credentials.hosts[ESA_CDSE_HOST] return username, password + + +def get_orbits_from_slc_ids(slc_ids: List[str], directory=Path.cwd()) -> List[Path]: + """Download all orbit files for a set of SLCs + + This method will ensure that the downloaded orbit files cover the entire acquisition start->stop time + + Returns a list of orbit file paths + """ + _ = _ensure_orbit_credentials() + + missions = {slc_id[0:3] for slc_id in slc_ids} + start_times = {re.split(r'_+', slc_id)[4] for slc_id in slc_ids} + stop_times = {re.split(r'_+', slc_id)[5] for slc_id in slc_ids} + + orb_files = eof.download.download_eofs(list(start_times | stop_times), list(missions), save_dir=str(directory)) + + return orb_files From 8169cdc077b720519a3b6b85980dd78a2887e002 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 16:30:43 -0900 Subject: [PATCH 68/73] more test fixes for hyp3lib --- test/test_s1_time_grid.py | 42 +++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/test/test_s1_time_grid.py b/test/test_s1_time_grid.py index 49c833c5b..152bad866 100644 --- a/test/test_s1_time_grid.py +++ b/test/test_s1_time_grid.py @@ -87,9 +87,13 @@ def test_s1_timing_array_wrt_slc_center_time(gunw_azimuth_test: Path, slc_start_time = get_start_time_from_slc_id(slc_ids[n // 2]).to_pydatetime() # Azimuth time grid - mocker.patch('RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile', - # Hyp3 Lib returns 2 values - return_value=(orbit_dict_for_azimuth_time_test[ifg_type], '')) + mocker.patch( + 'RAiDER.s1_azimuth_timing.get_orbits_from_slc_ids', + side_effect=[ + [Path(orbit_dict_for_azimuth_time_test[ifg_type])], + ] + ) + mocker.patch('RAiDER.s1_azimuth_timing._asf_query', return_value=slc_id_dict_for_azimuth_time_test[ifg_type]) time_grid = get_s1_azimuth_time_grid(lon, lat, hgt, slc_start_time) @@ -101,9 +105,7 @@ def test_s1_timing_array_wrt_slc_center_time(gunw_azimuth_test: Path, assert np.all(abs_diff < 40) assert RAiDER.s1_azimuth_timing._asf_query.call_count == 1 - # There are 2 slc ids so it downloads orbits twice - call_count = 1 if ifg_type == 'reference' else 2 - assert RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile.call_count == call_count + assert RAiDER.s1_azimuth_timing.get_orbits_from_slc_ids.call_count == 1 @pytest.mark.parametrize('ifg_type', ['reference', 'secondary']) @@ -132,10 +134,13 @@ def test_s1_timing_array_wrt_variance(gunw_azimuth_test: Path, slc_start_time = get_start_time_from_slc_id(slc_ids[0]).to_pydatetime() # Azimuth time grid - # Azimuth time grid - mocker.patch('RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile', - # Hyp3 Lib returns 2 values - return_value=(orbit_dict_for_azimuth_time_test[ifg_type], '')) + mocker.patch( + 'RAiDER.s1_azimuth_timing.get_orbits_from_slc_ids', + side_effect=[ + [Path(orbit_dict_for_azimuth_time_test[ifg_type])], + ] + ) + mocker.patch('RAiDER.s1_azimuth_timing._asf_query', return_value=slc_id_dict_for_azimuth_time_test[ifg_type]) X = get_s1_azimuth_time_grid(lon, lat, hgt, slc_start_time) @@ -146,9 +151,7 @@ def test_s1_timing_array_wrt_variance(gunw_azimuth_test: Path, assert np.all(std_hgt < 2e-3) assert RAiDER.s1_azimuth_timing._asf_query.call_count == 1 - # There are 2 slc ids so it downloads orbits twice - call_count = 1 if ifg_type == 'reference' else 2 - assert RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile.call_count == call_count + assert RAiDER.s1_azimuth_timing.get_orbits_from_slc_ids.call_count == 1 def test_n_closest_dts(): @@ -337,18 +340,19 @@ def test_duplicate_orbits(mocker, orbit_paths_for_duplicate_orbit_xml_test): mocker.patch('RAiDER.s1_azimuth_timing.get_slc_id_from_point_and_time', side_effect=[['slc_id_0', 'slc_id_1', 'slc_id_2', 'slc_id_3']]) - # Hyp3 Lib returns 2 values - side_effect = [(o_path, '') for o_path in orbit_paths_for_duplicate_orbit_xml_test] - mocker.patch('RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile', - side_effect=side_effect - ) + mocker.patch( + 'RAiDER.s1_azimuth_timing.get_orbits_from_slc_ids', + side_effect=[ + [Path(o_path) for o_path in orbit_paths_for_duplicate_orbit_xml_test], + ] + ) time_grid = get_s1_azimuth_time_grid(lon, lat, hgt, t) assert time_grid.shape == (len(hgt), len(lat), len(lon)) assert RAiDER.s1_azimuth_timing.get_slc_id_from_point_and_time.call_count == 1 - assert RAiDER.s1_azimuth_timing.downloadSentinelOrbitFile.call_count == 4 + assert RAiDER.s1_azimuth_timing.get_orbits_from_slc_ids.call_count == 1 def test_get_times_for_az(): From d3f7881cbc141320dd3986c8354c83a3fa321790 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 17:04:26 -0900 Subject: [PATCH 69/73] Add tests for s1_orbits.get_orbits_from_slc_ids --- test/test_s1_orbits.py | 31 +++++++++++++++++++++++++++++++ tools/RAiDER/s1_orbits.py | 6 +++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/test/test_s1_orbits.py b/test/test_s1_orbits.py index 0080d6ad3..32769f73c 100644 --- a/test/test_s1_orbits.py +++ b/test/test_s1_orbits.py @@ -1,6 +1,7 @@ import netrc from pathlib import Path +import eof.download import pytest from RAiDER import s1_orbits @@ -94,3 +95,33 @@ def __str__(self): assert username == 'foo' assert password == 'bar' + + +def test_get_orbits_from_slc_ids(mocker): + side_effect = [ + [Path('foo.txt')], + [Path('bar.txt'), Path('fiz.txt')], + ] + mocker.patch('eof.download.download_eofs', + side_effect=side_effect) + + orbit_files = s1_orbits.get_orbits_from_slc_ids( + ['S1A_IW_SLC__1SSV_20150621T120220_20150621T120232_006471_008934_72D8'] + ) + assert orbit_files == [Path('foo.txt')] + assert eof.download.download_eofs.call_count == 1 + eof.download.download_eofs.assert_called_with( + [ '20150621T120220', '20150621T120232'], ['S1A'], save_dir=str(Path.cwd()) + ) + + orbit_files = s1_orbits.get_orbits_from_slc_ids( + ['S1B_IW_SLC__1SDV_20201115T162313_20201115T162340_024278_02E29D_5C54', + 'S1A_IW_SLC__1SDV_20201203T162353_20201203T162420_035524_042744_6D5C'] + ) + assert orbit_files == [Path('bar.txt'), Path('fiz.txt')] + assert eof.download.download_eofs.call_count == 2 + eof.download.download_eofs.assert_called_with( + ['20201115T162313', '20201115T162340', '20201203T162353', '20201203T162420'], + ['S1A', 'S1B'], + save_dir=str(Path.cwd()) + ) diff --git a/tools/RAiDER/s1_orbits.py b/tools/RAiDER/s1_orbits.py index c6ec04f10..559ed1601 100644 --- a/tools/RAiDER/s1_orbits.py +++ b/tools/RAiDER/s1_orbits.py @@ -75,6 +75,10 @@ def get_orbits_from_slc_ids(slc_ids: List[str], directory=Path.cwd()) -> List[Pa start_times = {re.split(r'_+', slc_id)[4] for slc_id in slc_ids} stop_times = {re.split(r'_+', slc_id)[5] for slc_id in slc_ids} - orb_files = eof.download.download_eofs(list(start_times | stop_times), list(missions), save_dir=str(directory)) + orb_files = eof.download.download_eofs( + sorted(list(start_times | stop_times)), + sorted(list(missions)), + save_dir=str(directory) + ) return orb_files From 12c0e27b4488fa17212ecb2d4c151e201077d6c2 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 21:24:02 -0900 Subject: [PATCH 70/73] drop unused get_esa_cdse_credentials --- test/test_s1_orbits.py | 17 ----------------- tools/RAiDER/s1_orbits.py | 16 ---------------- 2 files changed, 33 deletions(-) diff --git a/test/test_s1_orbits.py b/test/test_s1_orbits.py index 32769f73c..2d2f08fcf 100644 --- a/test/test_s1_orbits.py +++ b/test/test_s1_orbits.py @@ -80,23 +80,6 @@ def __str__(self): assert written_credentials is None -def test_get_esa_cse_credentials(monkeypatch): - class CDSENetrc(): - def __init__(self, netrc_file): - self.netrc_file = netrc_file - self.hosts = {s1_orbits.ESA_CDSE_HOST: ('foo', None, 'bar')} - def __str__(self): - return str(self.hosts) - - # cdse in .netrc, no ESA CDSE env variables - with monkeypatch.context() as mp: - mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) - username, password = s1_orbits.get_esa_cdse_credentials() - - assert username == 'foo' - assert password == 'bar' - - def test_get_orbits_from_slc_ids(mocker): side_effect = [ [Path('foo.txt')], diff --git a/tools/RAiDER/s1_orbits.py b/tools/RAiDER/s1_orbits.py index 559ed1601..01e3462b3 100644 --- a/tools/RAiDER/s1_orbits.py +++ b/tools/RAiDER/s1_orbits.py @@ -46,22 +46,6 @@ def _ensure_orbit_credentials() -> Optional[int]: return netrc_file.write_text(str(netrc_credentials)) -def get_esa_cdse_credentials() -> Tuple[str, str]: - """Retrieve credentials for ESA's CDSE to download orbits - - This method will prefer to use CDSE credentials from your `~/.netrc` file if they exist, - otherwise will look for ESA_USERNAME and ESA_PASSWORD environment variables and - update or create your `~/.netrc` file. - - Returns `username` and `password` . - """ - _ = _ensure_orbit_credentials() - netrc_file = _netrc_path() - netrc_credentials = netrc.netrc(netrc_file) - username, _, password = netrc_credentials.hosts[ESA_CDSE_HOST] - return username, password - - def get_orbits_from_slc_ids(slc_ids: List[str], directory=Path.cwd()) -> List[Path]: """Download all orbit files for a set of SLCs From 468ce8c9b32ba719ae3c0a759ef83786fd799ab9 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 21:37:37 -0900 Subject: [PATCH 71/73] update changelog --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c74b8e41..887ca1830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added -* Adds an `ensure_orbit_credentials` function in `prepFromGUNW.py` to ensure ESA CDSE credentials are in `~/.netrc` or provided via environment variables +* Adds an `s1_orbits.py` module which includes: + * `get_orbits_from_slc_ids` to download the associated orbit files for a list of Sentinel-1 SLC IDs + * `_ensure_orbit_credentials` to ensure ESA CSDE credentials have been provides to download orbit files. This should be called before `sentineleof` is used to download orbits. * Adds a `setup_from_env` function to `models/credentials.py` which will pull *all* credentials needed for acquiring weather model data from environment variables and ensure the correct config file is written. This makes setting up credentials in CI pipelines significantly easier ### Changed @@ -20,11 +22,15 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * RAiDER is now tested on Python version 3.9-3.12 * All typehints are now Python 3.9 compatible * [607](https://github.com/dbekaert/RAiDER/issues/607): Python entrypoint loading is now compatible with Python 3.12 +* [610](https://github.com/dbekaert/RAiDER/issues/610): Sentinel-1 orbit availability due to ESA migrating Sentinel-1 orbit files from Copernicus Open Access Hub (Scihub) to the new Copernicus Data Space Ecosystem (CDSE) * make weather file directory when it doesn't exist * Ensures the `models/data/alaska.geojson.zip` file is packaged when building from the source tarball * Make ISCE3 an optional dependency in `s1_azimuth_timing.py` + Added unit tests and removed unused and depracated functions +### Removed +* `hyp3lib`, which was only used for downloading orbit fies, has been removed in favor of `sentineleof` + ## [0.4.5] ### Fixes From 60874b311924b32989031ec0227c9a3afa895c52 Mon Sep 17 00:00:00 2001 From: Joseph H Kennedy Date: Wed, 15 Nov 2023 21:39:49 -0900 Subject: [PATCH 72/73] make ensure_orbit_credentials a public function --- CHANGELOG.md | 2 +- test/test_s1_orbits.py | 14 +++++++------- tools/RAiDER/aria/prepFromGUNW.py | 4 ++-- tools/RAiDER/s1_orbits.py | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 887ca1830..a460f638b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added * Adds an `s1_orbits.py` module which includes: * `get_orbits_from_slc_ids` to download the associated orbit files for a list of Sentinel-1 SLC IDs - * `_ensure_orbit_credentials` to ensure ESA CSDE credentials have been provides to download orbit files. This should be called before `sentineleof` is used to download orbits. + * `ensure_orbit_credentials` to ensure ESA CSDE credentials have been provides to download orbit files. This should be called before `sentineleof` is used to download orbits. * Adds a `setup_from_env` function to `models/credentials.py` which will pull *all* credentials needed for acquiring weather model data from environment variables and ensure the correct config file is written. This makes setting up credentials in CI pipelines significantly easier ### Changed diff --git a/test/test_s1_orbits.py b/test/test_s1_orbits.py index 2d2f08fcf..2c7c8c8e6 100644 --- a/test/test_s1_orbits.py +++ b/test/test_s1_orbits.py @@ -7,7 +7,7 @@ from RAiDER import s1_orbits -def test__ensure_orbit_credentials(monkeypatch): +def test_ensure_orbit_credentials(monkeypatch): class EmptyNetrc(): def __init__(self, netrc_file): self.netrc_file = netrc_file @@ -21,7 +21,7 @@ def __str__(self): mp.delenv('ESA_USERNAME', raising=False) mp.delenv('ESA_PASSWORD', raising=False) with pytest.raises(ValueError): - s1_orbits._ensure_orbit_credentials() + s1_orbits.ensure_orbit_credentials() # No .netrc, set ESA CDSE env variables with monkeypatch.context() as mp: @@ -29,7 +29,7 @@ def __str__(self): mp.setenv('ESA_USERNAME', 'foo') mp.setenv('ESA_PASSWORD', 'bar') mp.setattr(Path, 'write_text', lambda self, write_text: write_text) - written_credentials = s1_orbits._ensure_orbit_credentials() + written_credentials = s1_orbits.ensure_orbit_credentials() assert written_credentials == str({s1_orbits.ESA_CDSE_HOST: ('foo', None, 'bar')}) class NoCDSENetrc(): @@ -45,7 +45,7 @@ def __str__(self): mp.delenv('ESA_USERNAME', raising=False) mp.delenv('ESA_PASSWORD', raising=False) with pytest.raises(ValueError): - s1_orbits._ensure_orbit_credentials() + s1_orbits.ensure_orbit_credentials() # No CDSE in .netrc, set ESA CDSE env variables with monkeypatch.context() as mp: @@ -53,7 +53,7 @@ def __str__(self): mp.setenv('ESA_USERNAME', 'foo') mp.setenv('ESA_PASSWORD', 'bar') mp.setattr(Path, 'write_text', lambda self, write_text: write_text) - written_credentials = s1_orbits._ensure_orbit_credentials() + written_credentials = s1_orbits.ensure_orbit_credentials() assert written_credentials == str({'fizz.buzz.org': ('foo', None, 'bar'), s1_orbits.ESA_CDSE_HOST: ('foo', None, 'bar')}) class CDSENetrc(): @@ -68,7 +68,7 @@ def __str__(self): mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) mp.delenv('ESA_USERNAME', raising=False) mp.delenv('ESA_PASSWORD', raising=False) - written_credentials = s1_orbits._ensure_orbit_credentials() + written_credentials = s1_orbits.ensure_orbit_credentials() assert written_credentials is None # cdse in .netrc, set ESA CDSE env variables @@ -76,7 +76,7 @@ def __str__(self): mp.setattr(netrc, 'netrc', CDSENetrc, raising=False) mp.setenv('ESA_USERNAME', 'foo') mp.setenv('ESA_PASSWORD', 'bar') - written_credentials = s1_orbits._ensure_orbit_credentials() + written_credentials = s1_orbits.ensure_orbit_credentials() assert written_credentials is None diff --git a/tools/RAiDER/aria/prepFromGUNW.py b/tools/RAiDER/aria/prepFromGUNW.py index 1aea117f7..4561abe4f 100644 --- a/tools/RAiDER/aria/prepFromGUNW.py +++ b/tools/RAiDER/aria/prepFromGUNW.py @@ -23,7 +23,7 @@ from RAiDER.models import credentials from RAiDER.models.hrrr import HRRR_CONUS_COVERAGE_POLYGON, AK_GEO, check_hrrr_dataset_availability from RAiDER.s1_azimuth_timing import get_times_for_azimuth_interpolation -from RAiDER.s1_orbits import _ensure_orbit_credentials +from RAiDER.s1_orbits import ensure_orbit_credentials ## cube spacing in degrees for each model DCT_POSTING = {'HRRR': 0.05, 'HRES': 0.10, 'GMAO': 0.10, 'ERA5': 0.10, 'ERA5T': 0.10} @@ -276,7 +276,7 @@ def get_orbit_file(self): sat = slc.split('_')[0] dt = datetime.strptime(f'{self.dates[0]}T{self.mid_time}', '%Y%m%dT%H:%M:%S') - _ensure_orbit_credentials() + ensure_orbit_credentials() path_orb = eof.download.download_eofs([dt], [sat], save_dir=orbit_dir) return [str(o) for o in path_orb] diff --git a/tools/RAiDER/s1_orbits.py b/tools/RAiDER/s1_orbits.py index 01e3462b3..3aaa278a0 100644 --- a/tools/RAiDER/s1_orbits.py +++ b/tools/RAiDER/s1_orbits.py @@ -16,7 +16,7 @@ def _netrc_path() -> Path: return Path.home() / netrc_name -def _ensure_orbit_credentials() -> Optional[int]: +def ensure_orbit_credentials() -> Optional[int]: """Ensure credentials exist for ESA's CDSE to download orbits This method will prefer to use CDSE credentials from your `~/.netrc` file if they exist, @@ -53,7 +53,7 @@ def get_orbits_from_slc_ids(slc_ids: List[str], directory=Path.cwd()) -> List[Pa Returns a list of orbit file paths """ - _ = _ensure_orbit_credentials() + _ = ensure_orbit_credentials() missions = {slc_id[0:3] for slc_id in slc_ids} start_times = {re.split(r'_+', slc_id)[4] for slc_id in slc_ids} From 79e755b865fc2dd9f321b19981f88ba0bf4054fe Mon Sep 17 00:00:00 2001 From: Jeremy Maurer Date: Mon, 20 Nov 2023 17:15:51 -0500 Subject: [PATCH 73/73] Update CHANGELOG.md v0.4.6 release --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a460f638b..598687cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/) and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.4.6] ### Added * Adds an `s1_orbits.py` module which includes: