diff --git a/MANIFEST.in b/MANIFEST.in index 7a57f88e..5fde7659 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,6 @@ include requirements*.txt include README.md -include LICENSE +include LICENSE.md include setup.py include sentinelhub/config.json diff --git a/Makefile b/Makefile index dcc847fe..f375cf3c 100644 --- a/Makefile +++ b/Makefile @@ -8,22 +8,7 @@ help: @echo "Use 'make upload' to reset config.json and upload the package to PyPi" reset-config: - $(CONFIG) --instance_id "" \ - --aws_access_key_id "" \ - --aws_secret_access_key "" \ - --ogc_base_url "https://services.sentinel-hub.com/ogc/" \ - --gpd_base_url "http://service.geopedia.world/" \ - --aws_metadata_base_url "https://roda.sentinel-hub.com/" \ - --aws_s3_l1c_bucket "sentinel-s2-l1c" \ - --aws_s3_l2a_bucket "sentinel-s2-l2a" \ - --opensearch_url "http://opensearch.sentinel-hub.com/resto/api/collections/Sentinel2/" \ - --max_wfs_records_per_query 100 \ - --max_opensearch_records_per_query 500 \ - --default_start_date "1985-01-01" \ - --max_download_attempts 4 \ - --download_sleep_time 5 \ - --download_timeout_seconds 120 - + $(CONFIG) --reset upload: reset-config $(PYTHON) setup.py sdist diff --git a/README.md b/README.md index bcd31e34..5950138a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -[![](https://img.shields.io/pypi/l/sentinelhub.svg)](https://github.com/sentinel-hub/sentinelhub-py/blob/master/LICENSE.md) [![Package version](https://badge.fury.io/py/sentinelhub.svg)](https://pypi.org/project/sentinelhub/) +[![Supported Python versions](https://img.shields.io/pypi/pyversions/sentinelhub.svg?style=flat-square)](https://pypi.org/project/sentinelhub/) [![Build status](https://travis-ci.org/sentinel-hub/sentinelhub-py.svg?branch=master)](https://travis-ci.org/sentinel-hub/sentinelhub-py) [![Docs status](https://readthedocs.org/projects/sentinelhub-py/badge/?version=latest)](http://sentinelhub-py.readthedocs.io/en/latest/) - +[![Overall downloads](http://pepy.tech/badge/sentinelhub)](http://pepy.tech/project/sentinelhub) +[![Last month downloads](https://img.shields.io/badge/dynamic/json.svg?label=downloads&url=https%3A%2F%2Fpypistats.org%2Fapi%2Fpackages%2Fsentinelhub%2Frecent%3Fperiod%3Dmonth&query=%24.data.last_month&colorB=blue&suffix=%2fmonth)](https://pypistats.org/packages/sentinelhub) +[![](https://img.shields.io/pypi/l/sentinelhub.svg)](https://github.com/sentinel-hub/sentinelhub-py/blob/master/LICENSE.md) # Description diff --git a/docs/source/time_utils.rst b/docs/source/time_utils.rst index 584be48e..0b755015 100644 --- a/docs/source/time_utils.rst +++ b/docs/source/time_utils.rst @@ -12,3 +12,4 @@ Utility functions for processing time/date formats. .. autofunction:: get_current_date .. autofunction:: is_valid_time .. autofunction:: parse_time +.. autofunction:: parse_time_interval diff --git a/sentinelhub/_version.py b/sentinelhub/_version.py index 4179c642..d67e15ab 100644 --- a/sentinelhub/_version.py +++ b/sentinelhub/_version.py @@ -2,4 +2,4 @@ Version of sentinelhub package """ -__version__ = "2.4.2" +__version__ = "2.4.3" diff --git a/sentinelhub/commands.py b/sentinelhub/commands.py index 9b968840..ec3b8666 100644 --- a/sentinelhub/commands.py +++ b/sentinelhub/commands.py @@ -77,8 +77,9 @@ def config_options(func): @click.command() @click.option('--show', is_flag=True, default=False, help='Show current configuration') +@click.option('--reset', is_flag=True, default=False, help='Reset configuration to initial state') @config_options -def config(show, **params): +def config(show, reset, **params): """Inspect and configure parameters in your local sentinelhub configuration file \b @@ -88,7 +89,10 @@ def config(show, **params): sentinelhub.config --max_download_attempts 5 --download_sleep_time 20 --download_timeout_seconds 120 """ sh_config = SHConfig() - updated_params = {} + + if reset: + sh_config.reset() + for param, value in params.items(): if value is not None: try: @@ -100,13 +104,13 @@ def config(show, **params): value = False if getattr(sh_config, param) != value: setattr(sh_config, param, value) - updated_params[param] = value - if updated_params: - sh_config.save() - for param in SHConfig().get_params(): - if param in updated_params: - value = updated_params[param] + old_config = SHConfig() + sh_config.save() + + for param in sh_config.get_params(): + if sh_config[param] != old_config[param]: + value = sh_config[param] if isinstance(value, str): value = "'{}'".format(value) click.echo("The value of parameter '{}' was updated to {}".format(param, value)) diff --git a/sentinelhub/common.py b/sentinelhub/common.py index cdba0996..cb1be495 100644 --- a/sentinelhub/common.py +++ b/sentinelhub/common.py @@ -11,7 +11,10 @@ - BBox, represent a bounding box in a given CRS """ +import shapely.geometry + from .constants import CRS +from .geo_utils import transform_point class BBox: @@ -82,13 +85,25 @@ def get_crs(self): """ return self.crs + def transform(self, target_crs): + """ Transforms BBox from current CRS to target CRS + + :param target_crs: target CRS + :type target_crs: constants.CRS + :return: bounding box in target CRS + :rtype: common.BBox + """ + self.min_x, self.min_y = transform_point(self.get_lower_left(), self.crs, target_crs) + self.max_x, self.max_y = transform_point(self.get_upper_right(), self.crs, target_crs) + self.crs = target_crs + def get_polygon(self, reverse=False): """ Returns a list of coordinates of 5 points describing a polygon. Points are listed in clockwise order, first point is the same as the last. :param reverse: True if x and y coordinates should be switched and False otherwise :type reverse: bool - :return: [[x_1, y_1], ... , [x_5, y_5]] + :return: `[[x_1, y_1], ... , [x_5, y_5]]` :rtype: list(list(float)) """ polygon = [[self.min_x, self.min_y], @@ -101,6 +116,26 @@ def get_polygon(self, reverse=False): polygon[i] = point[::-1] return polygon + def get_geojson(self): + """ Returns polygon geometry in GeoJSON format + + :return: A dictionary in GeoJSON format + :rtype: dict + """ + return {'type': 'Polygon', + 'crs': {'type': 'name', + 'properties': {'name': 'urn:ogc:def:crs:EPSG::{}'.format(self.get_crs().value)}}, + 'coordinates': [self.get_polygon()] + } + + def get_geometry(self): + """ Returns polygon geometry in shapely format + + :return: A polygon in shapely format + :rtype: shapely.geometry.polygon.Polygon + """ + return shapely.geometry.Polygon(self.get_polygon()) + def get_partition(self, num_x=1, num_y=1): """ Partitions bounding box into smaller bounding boxes of the same size. @@ -179,7 +214,7 @@ def _tuple_from_list_or_tuple(bbox): def _tuple_from_str(bbox): """ Parses a string of numbers separated by any combination of commas and spaces - :param bbox: e.g. str of the form 'min_x ,min_y max_x, max_y' + :param bbox: e.g. str of the form `min_x ,min_y max_x, max_y` :return: tuple (min_x,min_y,max_x,max_y) """ return tuple([float(s) for s in bbox.replace(',', ' ').split() if s]) @@ -199,6 +234,6 @@ def _tuple_from_bbox(bbox): """ Converts a BBox instance into a tuple :param bbox: An instance of the BBox type - :return: tuple (min_x,min_y,max_x,max_y) + :return: tuple (min_x, min_y, max_x, max_y) """ return bbox.get_lower_left() + bbox.get_upper_right() diff --git a/sentinelhub/config.py b/sentinelhub/config.py index 9c09f539..8b273e88 100644 --- a/sentinelhub/config.py +++ b/sentinelhub/config.py @@ -2,7 +2,7 @@ Module that collects configuration data from config.json """ -import os.path +import os import json from collections import OrderedDict @@ -42,21 +42,21 @@ class _SHConfig: Private class. """ CONFIG_PARAMS = OrderedDict([ - ('instance_id', str), - ('aws_access_key_id', str), - ('aws_secret_access_key', str), - ('ogc_base_url', str), - ('gpd_base_url', str), - ('aws_metadata_base_url', str), - ('aws_s3_l1c_bucket', str), - ('aws_s3_l2a_bucket', str), - ('opensearch_url', str), - ('max_wfs_records_per_query', int), - ('max_opensearch_records_per_query', int), - ('default_start_date', str), - ('max_download_attempts', int), - ('download_sleep_time', int), - ('download_timeout_seconds', int) + ('instance_id', ''), + ('aws_access_key_id', ''), + ('aws_secret_access_key', ''), + ('ogc_base_url', 'https://services.sentinel-hub.com/ogc/'), + ('gpd_base_url', 'http://service.geopedia.world/'), + ('aws_metadata_base_url', 'https://roda.sentinel-hub.com/'), + ('aws_s3_l1c_bucket', 'sentinel-s2-l1c'), + ('aws_s3_l2a_bucket', 'sentinel-s2-l2a'), + ('opensearch_url', 'http://opensearch.sentinel-hub.com/resto/api/collections/Sentinel2/'), + ('max_wfs_records_per_query', 100), + ('max_opensearch_records_per_query', 500), + ('default_start_date', '1985-01-01'), + ('max_download_attempts', 4), + ('download_sleep_time', 5), + ('download_timeout_seconds', 120) ]) def __init__(self): @@ -65,16 +65,16 @@ def __init__(self): def _check_configuration(self, config): """ - Checks if configuration file has contains all keys. + Checks if configuration file contains all keys. :param config: configuration dictionary read from ``config.json`` :type config: dict """ - for param in self.CONFIG_PARAMS: if param not in config: raise ValueError("Configuration file does not contain '%s' parameter." % param) - for param, param_type in self.CONFIG_PARAMS.items(): + for param, default_param in self.CONFIG_PARAMS.items(): + param_type = type(default_param) if not isinstance(config[param], param_type): raise ValueError("Value of parameter '{}' must be of type {}".format(param, param_type.__name__)) if config['max_wfs_records_per_query'] > 100: @@ -136,21 +136,26 @@ def __init__(self): if not SHConfig._instance: SHConfig._instance = self._SHConfig() + for prop in self._instance.CONFIG_PARAMS: + setattr(self, prop, getattr(self._instance, prop)) + def __getattr__(self, name): + """ This is called only if the class doesn't have the attribute itself + """ return getattr(self._instance, name) def __getitem__(self, name): - return getattr(self._instance, name) + return getattr(self, name) def __dir__(self): return sorted(list(dir(super())) + list(self._instance.CONFIG_PARAMS)) def __str__(self): - return json.dumps(self._instance.get_config(), indent=2) + return json.dumps(self.get_config_dict(), indent=2) def save(self): """Method that saves configuration parameter changes from instance of SHConfig class to global config class and - to ``config.json`` file. + to `config.json` file. Example of use case ``my_config = SHConfig()`` \n @@ -165,6 +170,36 @@ def save(self): if is_changed: self._instance.save_configuration() + def reset(self, params=...): + """ + Resets configuration class to initial values. Use SHConfig.save() method in order to save this change. + + :param params: Parameters which will be reset. Parameters can be specified with a list of names, e.g. + ``['instance_id', 'aws_access_key_id', 'aws_secret_access_key']``, or as a single name, e.g. + ``'ogc_base_url'``. By default all parameters will be reset and default value is ``Ellipsis``. + :type params: Ellipsis or list(str) or str + """ + if params is ...: + params = self.get_params() + if isinstance(params, str): + self._reset_param(params) + elif isinstance(params, (list, tuple)): + for param in params: + self._reset_param(param) + else: + raise ValueError('Parameters must be specified in form of a list of strings or as a single string, instead ' + 'got {}'.format(params)) + + def _reset_param(self, param): + """ Resets a single parameter + + :param param: A configuration parameter + :type param: str + """ + if param not in self._instance.CONFIG_PARAMS: + raise ValueError("Cannot reset unknown parameter '{}'".format(param)) + setattr(self, param, self._instance.CONFIG_PARAMS[param]) + def get_params(self): """Returns a list of parameter names @@ -173,10 +208,18 @@ def get_params(self): """ return list(self._instance.CONFIG_PARAMS) + def get_config_dict(self): + """ Get a dictionary representation of `SHConfig` class + + :return: A dictionary with configuration parameters + :rtype: OrderedDict + """ + return OrderedDict((prop, getattr(self, prop)) for prop in self._instance.CONFIG_PARAMS) + def get_config_location(self): """ Returns location of configuration file on disk - :return: File path of config.json file + :return: File path of `config.json` file :rtype: str """ return self._instance.get_config_file() @@ -184,7 +227,7 @@ def get_config_location(self): def is_eocloud_ogc_url(self): """ Checks if base OGC URL is set to eocloud URL - :return: True if 'eocloud' string is in base OGC URL else False + :return: ``True`` if 'eocloud' string is in base OGC URL else ``False`` :rtype: bool """ return 'eocloud' in self.ogc_base_url diff --git a/sentinelhub/constants.py b/sentinelhub/constants.py index 7cbb7b78..7896f521 100644 --- a/sentinelhub/constants.py +++ b/sentinelhub/constants.py @@ -473,22 +473,42 @@ def has_value(cls, value): """ return any(value == item.value for item in cls) - @staticmethod - def get_string(fmt): + def get_string(self): """ Get file format as string - :param fmt: MimeType enum constant - :type fmt: Enum constant :return: String describing the file format :rtype: str """ - if fmt in [MimeType.TIFF_d8, MimeType.TIFF_d16, MimeType.TIFF_d32f]: - return 'image/{}'.format(fmt.value) - if fmt is MimeType.JP2: + if self in [MimeType.TIFF_d8, MimeType.TIFF_d16, MimeType.TIFF_d32f]: + return 'image/{}'.format(self.value) + if self is MimeType.JP2: return 'image/jpeg2000' - if fmt in [MimeType.RAW, MimeType.REQUESTS_RESPONSE]: - return fmt.value - return mimetypes.types_map['.' + fmt.value] + if self in [MimeType.RAW, MimeType.REQUESTS_RESPONSE]: + return self.value + return mimetypes.types_map['.' + self.value] + + def get_expected_max_value(self): + """ Returns max value of image `MimeType` format and raises an error if it is not an image format + + Note: For `MimeType.TIFF_d32f` it will return ``1.0`` as that is expected maximum for an image even though it + could be higher. + + :return: A maximum value of specified image format + :rtype: int or float + :raises: ValueError + """ + try: + return { + MimeType.TIFF: 65535, + MimeType.TIFF_d8: 255, + MimeType.TIFF_d16: 65535, + MimeType.TIFF_d32f: 1.0, + MimeType.PNG: 255, + MimeType.JPG: 255, + MimeType.JP2: 10000 + }[self] + except IndexError: + raise ValueError('Type {} is not supported by this method'.format(self)) class RequestType(Enum): diff --git a/sentinelhub/download.py b/sentinelhub/download.py index c5bd6a2a..2b513da6 100644 --- a/sentinelhub/download.py +++ b/sentinelhub/download.py @@ -254,7 +254,7 @@ def execute_download_request(request): isinstance(exception, requests.HTTPError) and \ exception.response.status_code == requests.status_codes.codes.NOT_FOUND: raise AwsDownloadFailedException('File in location %s is missing' % request.url) - raise DownloadFailedException(_create_download_failed_message(exception)) + raise DownloadFailedException(_create_download_failed_message(exception, request.url)) _save_if_needed(request, response_content) @@ -328,15 +328,17 @@ def _is_temporal_problem(exception): return isinstance(exception, requests.ConnectionError) -def _create_download_failed_message(exception): +def _create_download_failed_message(exception, url): """ Creates message describing why download has failed :param exception: Exception raised during download :type exception: Exception + :param url: An URL from where download was attempted + :type url: str :return: Error message :rtype: str """ - message = 'Failed to download with {}:\n{}'.format(exception.__class__.__name__, exception) + message = 'Failed to download from:\n{}\nwith {}:\n{}'.format(url, exception.__class__.__name__, exception) if _is_temporal_problem(exception): if isinstance(exception, requests.ConnectionError): diff --git a/sentinelhub/geo_utils.py b/sentinelhub/geo_utils.py index 93f0ae63..567c5a2b 100644 --- a/sentinelhub/geo_utils.py +++ b/sentinelhub/geo_utils.py @@ -4,10 +4,9 @@ import logging import pyproj +from copy import deepcopy from .constants import CRS -from .common import BBox - LOGGER = logging.getLogger(__name__) @@ -72,9 +71,9 @@ def to_utm_bbox(bbox): def get_utm_bbox(img_bbox, transform): """ Get UTM coordinates given a bounding box in pixels and a transform - :param img_bbox: boundaries of bounding box in pixels as [row1, col1, row2, col2] + :param img_bbox: boundaries of bounding box in pixels as `[row1, col1, row2, col2]` :type img_bbox: list - :param transform: georeferencing transform of the image, e.g. (x_upper_left, res_x, 0, y_upper_left, 0, -res_y) + :param transform: georeferencing transform of the image, e.g. `(x_upper_left, res_x, 0, y_upper_left, 0, -res_y)` :type transform: tuple or list :return: UTM coordinates as [east1, north1, east2, north2] :rtype: list @@ -123,7 +122,7 @@ def utm_to_pixel(east, north, transform, truncate=True): :type east: float :param north: north coordinate of point :type north: float - :param transform: georeferencing transform of the image, e.g. (x_upper_left, res_x, 0, y_upper_left, 0, -res_y) + :param transform: georeferencing transform of the image, e.g. `(x_upper_left, res_x, 0, y_upper_left, 0, -res_y)` :type transform: tuple or list :param truncate: Whether to truncate pixel coordinates. Default is ``True`` :type truncate: bool @@ -144,7 +143,7 @@ def pixel_to_utm(row, column, transform): :type row: int or float :param column: column pixel coordinate :type column: int or float - :param transform: georeferencing transform of the image, e.g. (x_upper_left, res_x, 0, y_upper_left, 0, -res_y) + :param transform: georeferencing transform of the image, e.g. `(x_upper_left, res_x, 0, y_upper_left, 0, -res_y)` :type transform: tuple or list :return: east, north UTM coordinates :rtype: float, float @@ -162,7 +161,7 @@ def wgs84_to_pixel(lng, lat, transform, utm_epsg=None, truncate=True): :type lng: float :param lat: latitude of point :type lat: float - :param transform: georeferencing transform of the image, e.g. (x_upper_left, res_x, 0, y_upper_left, 0, -res_y) + :param transform: georeferencing transform of the image, e.g. `(x_upper_left, res_x, 0, y_upper_left, 0, -res_y)` :type transform: tuple or list :param utm_epsg: UTM coordinate reference system enum constants :type utm_epsg: constants.CRS or None @@ -196,7 +195,7 @@ def get_utm_crs(lng, lat, source_crs=CRS.WGS84): def transform_point(point, source_crs, target_crs): """ Maps point form src_crs to tgt_crs - :param point: a tuple (x, y) + :param point: a tuple `(x, y)` :type point: (float, float) :param source_crs: source CRS :type source_crs: constants.CRS @@ -222,9 +221,6 @@ def transform_bbox(bbox, target_crs): :return: bounding box in target CRS :rtype: common.BBox """ - source_crs = bbox.get_crs() - lower_left = bbox.get_lower_left() - upper_right = bbox.get_upper_right() - new_lower_left = transform_point(lower_left, source_crs, target_crs) - new_upper_right = transform_point(upper_right, source_crs, target_crs) - return BBox((new_lower_left, new_upper_right), target_crs) + bbox = deepcopy(bbox) + bbox.transform(target_crs) + return bbox diff --git a/sentinelhub/ogc.py b/sentinelhub/ogc.py index d9fde8a4..5b961fb1 100644 --- a/sentinelhub/ogc.py +++ b/sentinelhub/ogc.py @@ -11,7 +11,7 @@ from base64 import b64encode from urllib.parse import urlencode -from .time_utils import get_current_date, parse_time +from .time_utils import parse_time_interval from .download import DownloadRequest, get_json from .constants import ServiceType, DataSource, MimeType, CRS, OgcConstants, CustomUrlParam from .config import SHConfig @@ -39,48 +39,6 @@ def __init__(self, base_url=None, instance_id=None): 'Set it either in request initialization or in configuration file. ' 'Check http://sentinelhub-py.readthedocs.io/en/latest/configure.html for more info.') - @staticmethod - def _parse_time_interval(time): - """ Parses times into common form - - Parses specified time into common form - tuple of start and end dates, i.e.: - - ``(2017-01-15:T00:00:00, 2017-01-16:T23:59:59)`` - - The parameter can have the following values/format, which will be parsed as: - - * ``None`` -> `[default_start_date from config.json, current date]` - * `YYYY-MM-DD` -> `[YYYY-MM-DD:T00:00:00, YYYY-MM-DD:T23:59:59]` - * `YYYY-MM-DDThh:mm:ss` -> `[YYYY-MM-DDThh:mm:ss, YYYY-MM-DDThh:mm:ss]` - * list or tuple of two dates (`YYYY-MM-DD`) -> `[YYYY-MM-DDT00:00:00, YYYY-MM-DDT23:59:59]`, where the first - (second) element is start (end) date - * list or tuple of two dates (`YYYY-MM-DDThh:mm:ss`) -> `[YYYY-MM-DDThh:mm:ss, YYYY-MM-DDThh:mm:ss]`, - where the first (second) element is start (end) date - - :param time: time window of acceptable acquisitions. See above for all acceptable argument formats. - :type time: ``None``, str of form `YYYY-MM-DD` or `'YYYY-MM-DDThh:mm:ss'`, list or tuple of two such strings - :return: interval of start and end date of the form YYYY-MM-DDThh:mm:ss - :rtype: tuple of start and end date - """ - if time is None or time is OgcConstants.LATEST: - date_interval = (SHConfig().default_start_date, get_current_date()) - else: - if isinstance(time, (str, datetime.date)): - date_interval = (parse_time(time), parse_time(time)) - elif isinstance(time, (tuple, list)) and len(time) == 2: - date_interval = (parse_time(time[0]), parse_time(time[1])) - else: - raise TabError('time must be a string or tuple of 2 strings or list of 2 strings') - if date_interval[0] > date_interval[1]: - raise ValueError('First time must be smaller or equal to second time') - - if len(date_interval[0].split('T')) == 1: - date_interval = (date_interval[0] + 'T00:00:00', date_interval[1]) - if len(date_interval[1].split('T')) == 1: - date_interval = (date_interval[0], date_interval[1] + 'T23:59:59') - - return date_interval - @staticmethod def _filter_dates(dates, time_difference): """ @@ -302,7 +260,7 @@ def get_dates(self, request): if DataSource.is_timeless(request.data_source): return [None] - date_interval = OgcService._parse_time_interval(request.time) + date_interval = parse_time_interval(request.time) LOGGER.debug('date_interval=%s', date_interval) @@ -373,7 +331,7 @@ def __init__(self, bbox, time_interval, *, data_source=DataSource.SENTINEL2_L1C, super().__init__(**kwargs) self.bbox = bbox - self.time_interval = self._parse_time_interval(time_interval) + self.time_interval = parse_time_interval(time_interval) self.data_source = data_source self.maxcc = maxcc diff --git a/sentinelhub/opensearch.py b/sentinelhub/opensearch.py index 8776df2b..e6aef3f8 100644 --- a/sentinelhub/opensearch.py +++ b/sentinelhub/opensearch.py @@ -11,6 +11,7 @@ from .config import SHConfig from .download import get_json from .geo_utils import transform_bbox +from .time_utils import parse_time_interval LOGGER = logging.getLogger(__name__) @@ -46,21 +47,21 @@ def get_tile_info(tile, time, aws_index=None, all_tiles=False): :param tile: tile name (e.g. ``'T10UEV'``) :type tile: str - :param time: time in ISO8601 format - :type time: str + :param time: A single date or a time interval, times have to be in ISO 8601 string + :type time: str or (str, str) :param aws_index: index of tile on AWS :type aws_index: int or None - :param all_tiles: If True it will return list of all tiles otherwise only the first one + :param all_tiles: If ``True`` it will return list of all tiles otherwise only the first one :type all_tiles: bool :return: dictionary with info provided by Opensearch REST service or None if such tile does not exist on AWS. :rtype: dict or None """ - end_date, start_date = _extract_range_from_time(time) + start_date, end_date = parse_time_interval(time) candidates = [] for tile_info in search_iter(start_date=start_date, end_date=end_date): path_props = tile_info['properties']['s3Path'].split('/') - this_tile = ''.join(path_props[1:4]) + this_tile = ''.join(path_props[1: 4]) this_aws_index = int(path_props[-1]) if this_tile == tile.lstrip('T0') and (aws_index is None or aws_index == this_aws_index): candidates.append(tile_info) @@ -69,28 +70,12 @@ def get_tile_info(tile, time, aws_index=None, all_tiles=False): raise TileMissingException if len(candidates) > 1: - LOGGER.info('Obtained %d results for tile=%s, time=%s. Returning the first one', len(candidates), tile, - time) + LOGGER.info('Obtained %d results for tile=%s, time=%s. Returning the first one', len(candidates), tile, time) if all_tiles: return candidates return candidates[0] -def _extract_range_from_time(time): - """ - Extracts time range from datetime - :param time: string representation of datetime - :type: str - :return: pair of strings of length 2 - :rtype: tuple[str] - """ - if len(time.split('T')) == 1: - start_date, end_date = time + 'T00:00:00', time + 'T23:59:59' - else: - start_date, end_date = time, time - return end_date, start_date - - def get_area_info(bbox, date_interval, maxcc=None): """ Get information about all images from specified area and time range @@ -141,13 +126,11 @@ def reduce_by_maxcc(result_list, maxcc): return [tile_info for tile_info in result_list if tile_info['properties']['cloudCover'] <= 100 * float(maxcc)] -def search_iter(text_query=None, tile_id=None, bbox=None, start_date=None, end_date=None, cloud_cover=None): - """ Function that implements Opensearch search queries and returns results +def search_iter(tile_id=None, bbox=None, start_date=None, end_date=None, absolute_orbit=None): + """ A generator function that implements OpenSearch search queries and returns results All parameters for search are optional. - :param text_query: arbitrary text query - :type text_query: str :param tile_id: original tile identification string provided by ESA (e.g. 'S2A_OPER_MSI_L1C_TL_SGS__20160109T230542_A002870_T10UEV_N02.01') :type tile_id: str @@ -157,15 +140,15 @@ def search_iter(text_query=None, tile_id=None, bbox=None, start_date=None, end_d :type start_date: str :param end_date: end of time range in ISO8601 format :type end_date: str - :param cloud_cover: percentage of cloud coverage - :type cloud_cover: float in range [0, 100] - :return: dictionaries containing info provided by Opensearch REST service + :param absolute_orbit: An absolute orbit number of Sentinel-2 L1C products as defined by ESA + :type absolute_orbit: int + :return: An iterator returning dictionaries with info provided by Sentinel Hub OpenSearch REST service :rtype: Iterator[dict] """ if bbox and bbox.get_crs() is not CRS.WGS84: bbox = transform_bbox(bbox, CRS.WGS84) - url_params = _prepare_url_params(bbox, cloud_cover, end_date, start_date, text_query, tile_id) + url_params = _prepare_url_params(tile_id, bbox, end_date, start_date, absolute_orbit) url_params['maxRecords'] = SHConfig().max_opensearch_records_per_query start_index = 1 @@ -185,44 +168,28 @@ def search_iter(text_query=None, tile_id=None, bbox=None, start_date=None, end_d start_index += SHConfig().max_opensearch_records_per_query -def _prepare_url_params(bbox, cloud_cover, end_date, start_date, text_query, tile_id): +def _prepare_url_params(tile_id, bbox, end_date, start_date, absolute_orbit): """ Constructs dict with URL params + :param tile_id: original tile identification string provided by ESA (e.g. + 'S2A_OPER_MSI_L1C_TL_SGS__20160109T230542_A002870_T10UEV_N02.01') + :type tile_id: str :param bbox: bounding box of requested area in WGS84 CRS :type bbox: common.BBox - :param cloud_cover: percentage of cloud coverage - :type cloud_cover: float in range [0, 100] :param start_date: beginning of time range in ISO8601 format :type start_date: str :param end_date: end of time range in ISO8601 format :type end_date: str - :param text_query: arbitrary text query - :type text_query: str - :param tile_id: original tile identification string provided by ESA (e.g. - 'S2A_OPER_MSI_L1C_TL_SGS__20160109T230542_A002870_T10UEV_N02.01') - :type tile_id: str + :param absolute_orbit: An absolute orbit number of Sentinel-2 L1C products as defined by ESA + :type absolute_orbit: int :return: dictionary with parameters as properties when arguments not None :rtype: dict """ - url_params = _add_param({}, text_query, 'q') - url_params = _add_param(url_params, tile_id, 'identifier') - url_params = _add_param(url_params, start_date, 'startDate') - url_params = _add_param(url_params, end_date, 'completionDate') - url_params = _add_param(url_params, cloud_cover, 'cloudCover') - if bbox: - url_params = _add_param(url_params, str(bbox), 'box') - return url_params - - -def _add_param(params, value, key): - """ If value is not None then return dict params with added (key, value) pair - - :param params: dictionary of parameters - :type: dict - :param value: Value - :param key: Key - :return: if value not ``None`` then a copy of params with (key, value) added, otherwise returns params - """ - if value: - params[key] = value - return params + url_params = { + 'identifier': tile_id, + 'startDate': start_date, + 'completionDate': end_date, + 'orbitNumber': absolute_orbit, + 'box': bbox + } + return {key: str(value) for key, value in url_params.items() if value} diff --git a/sentinelhub/time_utils.py b/sentinelhub/time_utils.py index 6bf37135..86b89a13 100644 --- a/sentinelhub/time_utils.py +++ b/sentinelhub/time_utils.py @@ -5,6 +5,9 @@ import datetime import dateutil.parser +from .constants import OgcConstants +from .config import SHConfig + def get_dates_in_range(start_date, end_date): """ Get all dates within input start and end date in ISO 8601 format @@ -53,7 +56,7 @@ def prev_date(date): def iso_to_datetime(date): """ Convert ISO 8601 time format to datetime format - This function converts a date in ISO format, e.g. ``2017-09-14`` to a ``datetime`` instance, e.g. + This function converts a date in ISO format, e.g. ``2017-09-14`` to a `datetime` instance, e.g. ``datetime.datetime(2017,9,14,0,0)`` :param date: date in ISO 8601 format @@ -89,8 +92,7 @@ def get_current_date(): :return: current date in ISO 8601 format :rtype: str """ - date = datetime.datetime.now() - return datetime_to_iso(date) + return datetime_to_iso(datetime.datetime.now()) def is_valid_time(time): @@ -109,7 +111,7 @@ def is_valid_time(time): def parse_time(time_input): - """ Parse input time/date string as ISO 8601 string + """ Parse input time/date string into ISO 8601 string :param time_input: time/date to parse :type time_input: str or datetime.date or datetime.datetime @@ -126,3 +128,45 @@ def parse_time(time_input): if len(time_input) <= 10: return time.date().isoformat() return time.isoformat() + + +def parse_time_interval(time): + """ Parse input into an interval of two times, specifying start and end time, in ISO 8601 format, for example: + + ``(2017-01-15:T00:00:00, 2017-01-16:T23:59:59)`` + + The input time can have the following formats, which will be parsed as: + + * `YYYY-MM-DD` -> `[YYYY-MM-DD:T00:00:00, YYYY-MM-DD:T23:59:59]` + * `YYYY-MM-DDThh:mm:ss` -> `[YYYY-MM-DDThh:mm:ss, YYYY-MM-DDThh:mm:ss]` + * list or tuple of two dates in form `YYYY-MM-DD` -> `[YYYY-MM-DDT00:00:00, YYYY-MM-DDT23:59:59]` + * list or tuple of two dates in form `YYYY-MM-DDThh:mm:ss` -> `[YYYY-MM-DDThh:mm:ss, YYYY-MM-DDThh:mm:ss]`, + * `None` -> `[default_start_date from config.json, current date]` + + All input times can also be specified in `datetime.datetime` format. + + :param time: An input time + :type time: str or datetime.datetime + :return: interval of start and end date of the form `YYYY-MM-DDThh:mm:ss` + :rtype: (str, str) + :raises: ValueError + """ + if time is None or time is OgcConstants.LATEST: + date_interval = (SHConfig().default_start_date, get_current_date()) + else: + if isinstance(time, (str, datetime.date)): + date_interval = (parse_time(time), ) * 2 + elif isinstance(time, (tuple, list)) and len(time) == 2: + date_interval = (parse_time(time[0]), parse_time(time[1])) + else: + raise ValueError('Time must be a string or tuple of 2 strings or list of 2 strings') + + if 'T' not in date_interval[0]: + date_interval = (date_interval[0] + 'T00:00:00', date_interval[1]) + if 'T' not in date_interval[1]: + date_interval = (date_interval[0], date_interval[1] + 'T23:59:59') + + if date_interval[1] < date_interval[0]: + raise ValueError('Start of time interval is larger than end of time interval') + + return date_interval diff --git a/tests/test_common.py b/tests/test_common.py index a3939069..4c315e9c 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -1,6 +1,8 @@ import unittest from tests_all import TestSentinelHub +import shapely.geometry + from sentinelhub import BBox, CRS @@ -113,6 +115,14 @@ def test_bbox_eq(self): self.assertNotEqual(bbox1, bbox4, "Bounding boxes {} and {} should not be the same".format(repr(bbox1), repr(bbox4))) + def test_geometry(self): + bbox = BBox([46.07, 13.23, 46.24, 13.57], CRS.WGS84) + + self.assertTrue(isinstance(bbox.get_geojson(), dict), + "Expected dictionary, got type {}".format(type(bbox.get_geometry()))) + self.assertTrue(isinstance(bbox.get_geometry(), shapely.geometry.polygon.Polygon), + "Expected type {}, got type {}".format(shapely.geometry.polygon.Polygon, + type(bbox.get_geometry()))) if __name__ == '__main__': diff --git a/tests/test_config.py b/tests/test_config.py index d1e5e05f..771df970 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -23,6 +23,23 @@ def test_configuration(self): self.assertEqual(SHConfig()[attr], config[attr], "Expected value {}, got {}".format(config[attr], SHConfig()[attr])) + def test_reset(self): + config = SHConfig() + + old_value = config.instance_id + new_value = 'new' + config.instance_id = new_value + self.assertEqual(config.instance_id, new_value, 'New value was not set') + self.assertEqual(config['instance_id'], new_value, 'New value was not set') + self.assertEqual(config._instance.instance_id, old_value, 'Private value has changed') + + config.reset('ogc_base_url') + config.reset(['aws_access_key_id', 'aws_secret_access_key']) + self.assertEqual(config.instance_id, new_value, 'Instance ID should not reset yet') + config.reset() + self.assertEqual(config.instance_id, config._instance.CONFIG_PARAMS['instance_id'], + 'Instance ID should reset') + if __name__ == '__main__': unittest.main() diff --git a/tests/test_geo_utils.py b/tests/test_geo_utils.py index 5d79f3a2..c5921f72 100644 --- a/tests/test_geo_utils.py +++ b/tests/test_geo_utils.py @@ -9,23 +9,23 @@ def test_wgs84_to_utm33N(self): x, y = geo_utils.wgs84_to_utm(15.525078, 44.1440478, CRS.UTM_33N) expected_x = 541995.694062 expected_y = 4888006.132887 - self.assertAlmostEqual(x, expected_x, delta=1E-2, msg="Expected {}, got {}".format(str(expected_x), str(x))) - self.assertAlmostEqual(y, expected_y, delta=1E-2, msg="Expected {}, got {}".format(str(expected_y), str(y))) + self.assertAlmostEqual(x, expected_x, delta=1E-2, msg='Expected {}, got {}'.format(str(expected_x), str(x))) + self.assertAlmostEqual(y, expected_y, delta=1E-2, msg='Expected {}, got {}'.format(str(expected_y), str(y))) def test_utm33N_wgs84(self): lng, lat = geo_utils.to_wgs84(541995.694062, 4888006.132887, CRS.UTM_33N) expected_lng = 15.525078 expected_lat = 44.1440478 - self.assertAlmostEqual(lng, expected_lng, delta=1E-6, msg="Expected {}, got {}".format(str(expected_lng), + self.assertAlmostEqual(lng, expected_lng, delta=1E-6, msg='Expected {}, got {}'.format(str(expected_lng), str(lng))) - self.assertAlmostEqual(lat, expected_lat, delta=1E-6, msg="Expected {}, got {}".format(str(expected_lat), + self.assertAlmostEqual(lat, expected_lat, delta=1E-6, msg='Expected {}, got {}'.format(str(expected_lat), str(lat))) def test_get_utm_epsg_from_lnglat(self): lng, lat = 15.52, 44.14 expected_crs = CRS.UTM_33N crs = geo_utils.get_utm_crs(lng, lat) - self.assertEqual(crs, expected_crs, msg="Expected {}, got {}".format(expected_crs, crs)) + self.assertEqual(crs, expected_crs, msg='Expected {}, got {}'.format(expected_crs, crs)) def test_bbox_to_resolution(self): bbox = BBox(((111.644, 8.655), (111.7, 8.688)), CRS.WGS84) @@ -33,9 +33,9 @@ def test_bbox_to_resolution(self): expected_resx = 12.02 expected_resy = 7.15 self.assertAlmostEqual(resx, expected_resx, delta=1E-2, - msg="Expected resx {}, got {}".format(str(expected_resx), str(resx))) + msg='Expected resx {}, got {}'.format(str(expected_resx), str(resx))) self.assertAlmostEqual(resy, expected_resy, delta=1E-2, - msg="Expected resy {}, got {}".format(str(expected_resy), str(resy))) + msg='Expected resy {}, got {}'.format(str(expected_resy), str(resy))) def test_get_image_dimensions(self): bbox = BBox(((111.644, 8.655), (111.7, 8.688)), CRS.WGS84) @@ -43,8 +43,19 @@ def test_get_image_dimensions(self): height = geo_utils.get_image_dimension(bbox, width=1202) expected_width = 1203 expected_height = 715 - self.assertEqual(width, expected_width, msg="Expected width {}, got {}".format(expected_width, width)) - self.assertEqual(height, expected_height, msg="Expected height {}, got {}".format(expected_height, height)) + self.assertEqual(width, expected_width, msg='Expected width {}, got {}'.format(expected_width, width)) + self.assertEqual(height, expected_height, msg='Expected height {}, got {}'.format(expected_height, height)) + + def test_bbox_transform(self): + bbox = BBox(((111.644, 8.655), (111.7, 8.688)), CRS.WGS84) + new_bbox = geo_utils.transform_bbox(bbox, CRS.POP_WEB) + expected_bbox = BBox((12428153.23, 967155.41, 12434387.12, 970871.43), CRS.POP_WEB) + + for coord, expected_coord in zip(new_bbox, expected_bbox): + self.assertAlmostEqual(coord, expected_coord, delta=1E-2, + msg='Expected coord {}, got {}'.format(expected_coord, coord)) + self.assertEqual(new_bbox.get_crs(), expected_bbox.get_crs(), + 'Expected CRS {}, got {}'.format(expected_bbox.get_crs(), new_bbox.get_crs())) if __name__ == '__main__': diff --git a/tests/test_ogc.py b/tests/test_ogc.py index 4a43ac6e..cfc85867 100644 --- a/tests/test_ogc.py +++ b/tests/test_ogc.py @@ -116,7 +116,8 @@ def setUpClass(cls): custom_url_params={CustomUrlParam.SHOWLOGO: False, CustomUrlParam.BGCOLOR: "F4F86A", CustomUrlParam.GEOMETRY: geometry_wkt_pop_web}), - result_len=1, img_min=63, img_max=255, img_mean=213.3590, img_median=242.0, tile_num=3), + result_len=1, img_min=63, img_max=MimeType.PNG.get_expected_max_value(), img_mean=213.3590, + img_median=242.0, tile_num=3), # DataSource tests: cls.OgcTestCase('S2 L1C Test', WmsRequest(data_source=DataSource.SENTINEL2_L1C, data_folder=cls.OUTPUT_FOLDER, @@ -130,8 +131,8 @@ def setUpClass(cls): image_format=MimeType.TIFF, layer='BANDS-S2-L2A', width=img_width, height=img_height, bbox=wgs84_bbox, time=('2017-10-01', '2017-10-02')), - result_len=1, img_min=0.0, img_max=65535, img_mean=22743.5164, img_median=21390.0, - tile_num=2), + result_len=1, img_min=0.0, img_max=MimeType.TIFF.get_expected_max_value(), + img_mean=22743.5164, img_median=21390.0, tile_num=2), cls.OgcTestCase('L8 Test', WmsRequest(data_source=DataSource.LANDSAT8, data_folder=cls.OUTPUT_FOLDER, image_format=MimeType.TIFF_d32f, layer='BANDS-L8', @@ -156,7 +157,8 @@ def setUpClass(cls): width=img_width, height=img_height, bbox=wgs84_bbox, time=('2017-10-01', '2017-10-02'), time_difference=datetime.timedelta(hours=1)), - result_len=1, img_min=0.0, img_max=1.0, img_mean=0.104584, img_median=0.06160, tile_num=2), + result_len=1, img_min=0.0, img_max=MimeType.TIFF_d32f.get_expected_max_value(), + img_mean=0.104584, img_median=0.06160, tile_num=2), cls.OgcTestCase('S1 EW Test', WmsRequest(data_source=DataSource.SENTINEL1_EW, data_folder=cls.OUTPUT_FOLDER, image_format=MimeType.TIFF_d32f, layer='BANDS-S1-EW', diff --git a/tests/test_time_utils.py b/tests/test_time_utils.py index 3e684b2f..619d8969 100644 --- a/tests/test_time_utils.py +++ b/tests/test_time_utils.py @@ -8,20 +8,20 @@ class TestTime(TestSentinelHub): def test_get_dates_in_range(self): test_pairs = [ - (('2018-01-01', '2017-12-31'), 0), - (('2017-01-01', '2017-01-31'), 31), - (('2017-02-01', '2017-03-01'), 28+1), - (('2018-02-01', '2018-03-01'), 28+1), - (('2020-02-01', '2020-03-01'), 29+1), - (('2018-01-01', '2018-12-31'), 365), - (('2020-01-01', '2020-12-31'), 366) + (('2018-01-01', '2017-12-31'), 0), + (('2017-01-01', '2017-01-31'), 31), + (('2017-02-01', '2017-03-01'), 28+1), + (('2018-02-01', '2018-03-01'), 28+1), + (('2020-02-01', '2020-03-01'), 29+1), + (('2018-01-01', '2018-12-31'), 365), + (('2020-01-01', '2020-12-31'), 366) ] for daterange, nr_dates in test_pairs: with self.subTest(msg=daterange): start_date, end_date = daterange dates = time_utils.get_dates_in_range(start_date, end_date) self.assertEqual(len(dates), nr_dates, - msg="Expected {} dates, got {}".format(str(len(dates)), str(nr_dates))) + msg='Expected {} dates, got {}'.format(str(len(dates)), str(nr_dates))) def test_next_date(self): test_pairs = [ @@ -32,10 +32,10 @@ def test_next_date(self): ('2018-01-05', '2018-01-06') ] for curr_date, next_date in test_pairs: - with self.subTest(msg="{}/{}".format(curr_date, next_date)): + with self.subTest(msg='{}/{}'.format(curr_date, next_date)): res_date = time_utils.next_date(curr_date) self.assertEqual(res_date, next_date, - msg="Expected {}, got {}".format(curr_date, next_date)) + msg='Expected {}, got {}'.format(curr_date, next_date)) def test_prev_date(self): test_pairs = [ @@ -44,10 +44,10 @@ def test_prev_date(self): ('2018-01-31', '2018-02-01') ] for prev_date, curr_date in test_pairs: - with self.subTest(msg="{}/{}".format(prev_date, curr_date)): + with self.subTest(msg='{}/{}'.format(prev_date, curr_date)): res_date = time_utils.prev_date(curr_date) self.assertEqual(prev_date, res_date, - msg="Expected {}, got {}".format(prev_date, res_date)) + msg='Expected {}, got {}'.format(prev_date, res_date)) def test_iso_to_datetime(self): test_pairs = [ @@ -58,7 +58,7 @@ def test_iso_to_datetime(self): with self.subTest(msg=date_str): res_dt = time_utils.iso_to_datetime(date_str) self.assertEqual(res_dt, date_dt, - msg="Expected {}, got {}".format(date_dt, res_dt)) + msg='Expected {}, got {}'.format(date_dt, res_dt)) def test_datetime_to_iso(self): test_pairs = [ @@ -69,10 +69,12 @@ def test_datetime_to_iso(self): with self.subTest(msg=date_str): res_str = time_utils.datetime_to_iso(date_dt) self.assertEqual(res_str, date_str, - msg="Expected {}, got {}".format(date_str, res_str)) + msg='Expected {}, got {}'.format(date_str, res_str)) def test_get_current_date(self): - pass + current_date = time_utils.get_current_date() + self.assertTrue(isinstance(current_date, str), 'Expected date in str format') + self.assertEqual(len(current_date), 10, 'Expected date length 10, got {}'.format(current_date)) def test_is_valid_time(self): test_pairs = [ @@ -85,10 +87,37 @@ def test_is_valid_time(self): for iso_str, is_ok in test_pairs: with self.subTest(msg=iso_str): self.assertEqual(time_utils.is_valid_time(iso_str), is_ok, - msg="Expected {}, got {}".format(not is_ok, is_ok)) + msg='Expected {}, got {}'.format(not is_ok, is_ok)) def test_parse_time(self): - pass + test_pairs = [ + ('2015.4.12', '2015-04-12'), + ('2015.4.12T12:32:14', '2015-04-12T12:32:14'), + (datetime.date(year=2015, month=2, day=3), '2015-02-03'), + (datetime.datetime(year=2015, month=2, day=3), '2015-02-03T00:00:00') + ] + + for idx, (input_time, exp_time) in enumerate(test_pairs): + with self.subTest(msg='Test case {}'.format(idx + 1)): + parsed_time = time_utils.parse_time(input_time) + self.assertEqual(parsed_time, exp_time, 'Expected {}, got {}'.format(exp_time, parsed_time)) + + def test_parse_time_interval(self): + test_pairs = [ + ('2015.4.12', ('2015-04-12T00:00:00', '2015-04-12T23:59:59')), + ('2015-4-12T5:4:3', ('2015-04-12T05:04:03', '2015-04-12T05:04:03')), + (('2015-4-12', '2017-4-12'), ('2015-04-12T00:00:00', '2017-04-12T23:59:59')), + (('2015-4-12T5:4:3', '2017-4-12T5:4:3'), ('2015-04-12T05:04:03', '2017-04-12T05:04:03')), + (datetime.date(year=2015, month=2, day=3), ('2015-02-03T00:00:00', '2015-02-03T23:59:59')), + ((datetime.date(year=2015, month=2, day=3), datetime.date(year=2015, month=2, day=15)), + ('2015-02-03T00:00:00', '2015-02-15T23:59:59')), + ] + + for idx, (input_time, exp_interval) in enumerate(test_pairs): + with self.subTest(msg='Test case {}'.format(idx + 1)): + parsed_interval = time_utils.parse_time_interval(input_time) + self.assertEqual(parsed_interval, exp_interval, + 'Expected {}, got {}'.format(exp_interval, parsed_interval)) if __name__ == '__main__':