From 16184fa3a41749c24a82a93c1a1cdeefbe615b2d Mon Sep 17 00:00:00 2001 From: Matic Lubej Date: Mon, 4 Sep 2023 17:40:51 +0200 Subject: [PATCH 1/4] add io related extra tasks from eo-learn --- extra-tasks/geopedia/geopedia.py | 287 ++++++++++++++++++++++++++ extra-tasks/geopedia/requirements.txt | 1 + extra-tasks/geopedia/test_geopedia.py | 32 +++ 3 files changed, 320 insertions(+) create mode 100644 extra-tasks/geopedia/geopedia.py create mode 100644 extra-tasks/geopedia/requirements.txt create mode 100644 extra-tasks/geopedia/test_geopedia.py diff --git a/extra-tasks/geopedia/geopedia.py b/extra-tasks/geopedia/geopedia.py new file mode 100644 index 0000000..68e398b --- /dev/null +++ b/extra-tasks/geopedia/geopedia.py @@ -0,0 +1,287 @@ +""" +Module for adding data obtained from sentinelhub package to existing EOPatches + +Copyright (c) 2017- Sinergise and contributors +For the full list of contributors, see https://github.com/sentinel-hub/eo-learn/blob/master/CREDITS.md. + +This source code is licensed under the MIT license, see the LICENSE file in the root directory of this source tree. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import geopandas as gpd +import numpy as np +import rasterio.transform +import rasterio.warp + +from eolearn.core import EOPatch, EOTask, FeatureType +from eolearn.core.types import Feature +from sentinelhub import CRS, BBox, GeopediaFeatureIterator, GeopediaWmsRequest, MimeType, SHConfig + +LOGGER = logging.getLogger(__name__) + + +class AddGeopediaFeatureTask(EOTask): + """ + Task for adding a feature from Geopedia to an existing EOPatch. + + At the moment the Geopedia supports only WMS requestes in EPSG:3857, therefore to add feature to EOPatch + in arbitrary CRS and arbitrary service type the following steps are performed: + * transform BBOX from EOPatch's CRS to EPSG:3857 + * get raster from Geopedia Request in EPSG:3857 + * vectorize the returned raster using rasterio + * project vectorized raster back to EOPatch's CRS + * rasterize back and add raster to EOPatch + """ + + def __init__( + self, + feature: Feature, + layer: str | int, + theme: str, + raster_value: dict[str, tuple[float, list[float]]] | float, + raster_dtype: np.dtype | type = np.uint8, + no_data_val: float = 0, + image_format: MimeType = MimeType.PNG, + mean_abs_difference: float = 2, + ): + self.feature_type, self.feature_name = self.parse_feature(feature) + + self.raster_value = raster_value + self.raster_dtype = raster_dtype + self.no_data_val = no_data_val + self.mean_abs_difference = mean_abs_difference + + self.layer = layer + self.theme = theme + + self.image_format = image_format + + def _get_wms_request(self, bbox: BBox | None, size_x: int, size_y: int) -> GeopediaWmsRequest: + """ + Returns WMS request. + """ + if bbox is None: + raise ValueError("Bbox has to be defined!") + + bbox_3857 = bbox.transform(CRS.POP_WEB) + + return GeopediaWmsRequest( + layer=self.layer, + theme=self.theme, + bbox=bbox_3857, + width=size_x, + height=size_y, + image_format=self.image_format, + ) + + def _reproject(self, eopatch: EOPatch, src_raster: np.ndarray) -> np.ndarray: + """ + Re-projects the raster data from Geopedia's CRS (POP_WEB) to EOPatch's CRS. + """ + if not eopatch.bbox: + raise ValueError("To reproject raster data, eopatch.bbox has to be defined!") + + height, width = src_raster.shape + + dst_raster = np.ones((height, width), dtype=self.raster_dtype) + + src_bbox = eopatch.bbox.transform(CRS.POP_WEB) + src_transform = rasterio.transform.from_bounds(*src_bbox, width=width, height=height) + + dst_bbox = eopatch.bbox + dst_transform = rasterio.transform.from_bounds(*dst_bbox, width=width, height=height) + + rasterio.warp.reproject( + src_raster, + dst_raster, + src_transform=src_transform, + src_crs=CRS.POP_WEB.ogc_string(), + src_nodata=0, + dst_transform=dst_transform, + dst_crs=eopatch.bbox.crs.ogc_string(), + dst_nodata=self.no_data_val, + ) + + return dst_raster + + def _to_binary_mask(self, array: np.ndarray, binaries_raster_value: float) -> np.ndarray: + """ + Returns binary mask (0 and raster_value) + """ + # check where the transparency is not zero + return (array[..., -1] > 0).astype(self.raster_dtype) * binaries_raster_value + + def _map_from_binaries( + self, + eopatch: EOPatch, + dst_shape: int | tuple[int, int], + request_data: np.ndarray, + binaries_raster_value: float, + ) -> np.ndarray: + """ + Each request represents a binary class which will be mapped to the scalar `raster_value` + """ + if self.feature_name in eopatch[self.feature_type]: + raster = eopatch[self.feature_type][self.feature_name].squeeze() + else: + raster = np.ones(dst_shape, dtype=self.raster_dtype) * self.no_data_val + + new_raster = self._reproject(eopatch, self._to_binary_mask(request_data, binaries_raster_value)) + + # update raster + raster[new_raster != 0] = new_raster[new_raster != 0] + + return raster + + def _map_from_multiclass( + self, + eopatch: EOPatch, + dst_shape: int | tuple[int, int], + request_data: np.ndarray, + multiclass_raster_value: dict[str, tuple[float, list[float]]], + ) -> np.ndarray: + """ + `raster_value` is a dictionary specifying the intensity values for each class and the corresponding label value. + + A dictionary example for GLC30 LULC mapping is: + raster_value = {'no_data': (0,[0,0,0,0]), + 'cultivated land': (1,[193, 243, 249, 255]), + 'forest': (2,[73, 119, 20, 255]), + 'grassland': (3,[95, 208, 169, 255]), + 'shrubland': (4,[112, 179, 62, 255]), + 'water': (5,[154, 86, 1, 255]), + 'wetland': (6,[244, 206, 126, 255]), + 'tundra': (7,[50, 100, 100, 255]), + 'artificial surface': (8,[20, 47, 147, 255]), + 'bareland': (9,[202, 202, 202, 255]), + 'snow and ice': (10,[251, 237, 211, 255])} + """ + raster = np.ones(dst_shape, dtype=self.raster_dtype) * self.no_data_val + + for value, intensities in multiclass_raster_value.values(): + raster[np.mean(np.abs(request_data - intensities), axis=-1) < self.mean_abs_difference] = value + + return self._reproject(eopatch, raster) + + def execute(self, eopatch: EOPatch) -> EOPatch: + """ + Add requested feature to this existing EOPatch. + """ + data_arr = eopatch[FeatureType.MASK]["IS_DATA"] + _, height, width, _ = data_arr.shape + + request = self._get_wms_request(eopatch.bbox, width, height) + + (request_data,) = np.asarray(request.get_data()) + + if isinstance(self.raster_value, dict): + raster = self._map_from_multiclass(eopatch, (height, width), request_data, self.raster_value) + elif isinstance(self.raster_value, (int, float)): + raster = self._map_from_binaries(eopatch, (height, width), request_data, self.raster_value) + else: + raise ValueError("Unsupported raster value type") + + if self.feature_type is FeatureType.MASK_TIMELESS and raster.ndim == 2: + raster = raster[..., np.newaxis] + + eopatch[self.feature_type][self.feature_name] = raster + + return eopatch + + +class GeopediaVectorImportTask(EOTask): + """A task for importing `Geopedia `__ features into EOPatch vector features""" + + def __init__( + self, + feature: Feature, + geopedia_table: str | int, + reproject: bool = True, + clip: bool = False, + config: SHConfig | None = None, + **kwargs: Any, + ): + """ + :param feature: A vector feature into which to import data + :param geopedia_table: A Geopedia table from which to retrieve features + :param reproject: Should the geometries be transformed to coordinate reference system of the requested bbox? + :param clip: Should the geometries be clipped to the requested bbox, or should be geometries kept as they are? + :param config: A configuration object with credentials + :param kwargs: Additional args that will be passed to `GeopediaFeatureIterator` + """ + self.feature = self.parse_feature(feature, allowed_feature_types=lambda fty: fty.is_vector()) + self.config = config or SHConfig() + self.reproject = reproject + self.clip = clip + self.geopedia_table = geopedia_table + self.geopedia_kwargs = kwargs + self.dataset_crs: CRS | None = None + + def _load_vector_data(self, bbox: BBox | None) -> gpd.GeoDataFrame: + """Loads vector data from geopedia table""" + prepared_bbox = bbox.transform_bounds(CRS.POP_WEB) if bbox else None + + geopedia_iterator = GeopediaFeatureIterator( + layer=self.geopedia_table, + bbox=prepared_bbox, + offset=0, + gpd_session=None, + config=self.config, + **self.geopedia_kwargs, + ) + geopedia_features = list(geopedia_iterator) + + geometry = geopedia_features[0].get("geometry") + if not geometry: + raise ValueError(f'Geopedia table "{self.geopedia_table}" does not contain geometries!') + + self.dataset_crs = CRS(geometry["crs"]["properties"]["name"]) # always WGS84 + return gpd.GeoDataFrame.from_features(geopedia_features, crs=self.dataset_crs.pyproj_crs()) + + def _reproject_and_clip(self, vectors: gpd.GeoDataFrame, bbox: BBox | None) -> gpd.GeoDataFrame: + """Method to reproject and clip vectors to the EOPatch crs and bbox""" + + if self.reproject: + if not bbox: + raise ValueError("To reproject vector data, eopatch.bbox has to be defined!") + + vectors = vectors.to_crs(bbox.crs.pyproj_crs()) + + if self.clip: + if not bbox: + raise ValueError("To clip vector data, eopatch.bbox has to be defined!") + + bbox_crs = bbox.crs.pyproj_crs() + if vectors.crs != bbox_crs: + raise ValueError("To clip, vectors should be in same CRS as EOPatch bbox!") + + extent = gpd.GeoSeries([bbox.geometry], crs=bbox_crs) + vectors = gpd.clip(vectors, extent, keep_geom_type=True) + + return vectors + + def execute(self, eopatch: EOPatch | None = None, *, bbox: BBox | None = None) -> EOPatch: + """ + :param eopatch: An existing EOPatch. If none is provided it will create a new one. + :param bbox: A bounding box for which to load data. By default, if none is provided, it will take a bounding box + of given EOPatch. If given EOPatch is not provided it will load the entire dataset. + :return: An EOPatch with an additional vector feature + """ + if bbox is None and eopatch is not None: + bbox = eopatch.bbox + + vectors = self._load_vector_data(bbox) + minx, miny, maxx, maxy = vectors.total_bounds + final_bbox = bbox or BBox((minx, miny, maxx, maxy), crs=CRS(vectors.crs)) + + eopatch = eopatch or EOPatch(bbox=final_bbox) + if eopatch.bbox is None: + eopatch.bbox = final_bbox + + eopatch[self.feature] = self._reproject_and_clip(vectors, bbox) + + return eopatch diff --git a/extra-tasks/geopedia/requirements.txt b/extra-tasks/geopedia/requirements.txt new file mode 100644 index 0000000..0ab502c --- /dev/null +++ b/extra-tasks/geopedia/requirements.txt @@ -0,0 +1 @@ +eo-learn[IO]==1.5.0 # last tested version diff --git a/extra-tasks/geopedia/test_geopedia.py b/extra-tasks/geopedia/test_geopedia.py new file mode 100644 index 0000000..aab0881 --- /dev/null +++ b/extra-tasks/geopedia/test_geopedia.py @@ -0,0 +1,32 @@ +""" +Copyright (c) 2017- Sinergise and contributors +For the full list of contributors, see https://github.com/sentinel-hub/eo-learn/blob/master/CREDITS.md. + +This source code is licensed under the MIT license, see the LICENSE file in the root directory of this source tree. +""" +from __future__ import annotations + +import pytest +from geopedia import GeopediaVectorImportTask + +from eolearn.core import FeatureType +from sentinelhub import CRS, BBox + + +@pytest.mark.parametrize( + argnames="reproject, clip, n_features, bbox, crs", + ids=["simple", "bbox", "bbox_full", "bbox_smaller"], + argvalues=[ + (False, False, 193, None, None), + (False, False, 193, BBox([857000, 6521500, 861000, 6525500], CRS("epsg:2154")), None), + (True, True, 193, BBox([657089, 5071037, 661093, 5075039], CRS.UTM_31N), CRS.UTM_31N), + (True, True, 125, BBox([657690, 5071637, 660493, 5074440], CRS.UTM_31N), CRS.UTM_31N), + ], +) +def test_import_from_geopedia(reproject, clip, n_features, bbox, crs): + feature = FeatureType.VECTOR_TIMELESS, "lpis_iacs" + import_task = GeopediaVectorImportTask(feature=feature, geopedia_table=3447, reproject=reproject, clip=clip) + eop = import_task.execute(bbox=bbox) + assert len(eop[feature]) == n_features, "Wrong number of features!" + to_crs = crs or import_task.dataset_crs + assert eop[feature].crs.to_epsg() == to_crs.epsg From 921db33795a0a05ceecadcdd801f1c8c57d6d5bc Mon Sep 17 00:00:00 2001 From: Matic Lubej Date: Tue, 5 Sep 2023 11:56:42 +0200 Subject: [PATCH 2/4] add geodb task --- extra-tasks/geodb/geodb.py | 128 +++++++++++++++++++++++++++++ extra-tasks/geodb/requirements.txt | 3 + 2 files changed, 131 insertions(+) create mode 100644 extra-tasks/geodb/geodb.py create mode 100644 extra-tasks/geodb/requirements.txt diff --git a/extra-tasks/geodb/geodb.py b/extra-tasks/geodb/geodb.py new file mode 100644 index 0000000..dea6df3 --- /dev/null +++ b/extra-tasks/geodb/geodb.py @@ -0,0 +1,128 @@ +""" +Module with tasks that integrate with GeoDB + +To use tasks from this module you have to install dependencies defined in `requirements-geodb.txt`. + +Copyright (c) 2017- Sinergise and contributors +For the full list of contributors, see https://github.com/sentinel-hub/eo-learn/blob/master/CREDITS.md. + +This source code is licensed under the MIT license, see the LICENSE file in the root directory of this source tree. +""" +from __future__ import annotations + +from typing import Any + +import geopandas as gpd + +from eolearn.core import EOPatch, EOTask +from eolearn.core.types import Feature +from sentinelhub import CRS, BBox, SHConfig + + +class GeoDBVectorImportTask(EOTask): + """A task for importing vector data from `geoDB `__ + into EOPatch + """ + + def __init__( + self, + feature: Feature, + geodb_client: Any, + geodb_collection: str, + geodb_db: str, + reproject: bool = True, + clip: bool = False, + config: SHConfig | None = None, + **kwargs: Any, + ): + """ + :param feature: A vector feature into which to import data + :param geodb_client: an instance of GeoDBClient + :param geodb_collection: The name of the collection to be queried + :param geodb_db: The name of the database the collection resides in [current database] + :param reproject: Should the geometries be transformed to coordinate reference system of the requested bbox? + :param clip: Should the geometries be clipped to the requested bbox, or should be geometries kept as they are? + :param config: A configuration object with credentials + :param kwargs: Additional args that will be passed to `geodb_client.get_collection_by_bbox` call + (e.g. where="id>-1", operator="and") + """ + self.feature = self.parse_feature(feature, allowed_feature_types=lambda fty: fty.is_vector()) + self.config = config or SHConfig() + self.reproject = reproject + self.clip = clip + self.geodb_client = geodb_client + self.geodb_db = geodb_db + self.geodb_collection = geodb_collection + self.geodb_kwargs = kwargs + self._dataset_crs: CRS | None = None + + @property + def dataset_crs(self) -> CRS: + """Provides a "crs" of dataset, loads it lazily (i.e. the first time it is needed) + + :return: Dataset's CRS + """ + if self._dataset_crs is None: + srid = self.geodb_client.get_collection_srid(collection=self.geodb_collection, database=self.geodb_db) + self._dataset_crs = CRS(f"epsg:{srid}") + + return self._dataset_crs + + def _load_vector_data(self, bbox: BBox | None) -> Any: + """Loads vector data from geoDB table""" + prepared_bbox = bbox.transform_bounds(self.dataset_crs).geometry.bounds if bbox else None + + if "comparison_mode" not in self.geodb_kwargs: + self.geodb_kwargs["comparison_mode"] = "intersects" + + return self.geodb_client.get_collection_by_bbox( + collection=self.geodb_collection, + database=self.geodb_db, + bbox=prepared_bbox, + bbox_crs=self.dataset_crs.epsg, + **self.geodb_kwargs, + ) + + def _reproject_and_clip(self, vectors: gpd.GeoDataFrame, bbox: BBox | None) -> gpd.GeoDataFrame: + """Method to reproject and clip vectors to the EOPatch crs and bbox""" + + if self.reproject: + if not bbox: + raise ValueError("To reproject vector data, eopatch.bbox has to be defined!") + + vectors = vectors.to_crs(bbox.crs.pyproj_crs()) + + if self.clip: + if not bbox: + raise ValueError("To clip vector data, eopatch.bbox has to be defined!") + + bbox_crs = bbox.crs.pyproj_crs() + if vectors.crs != bbox_crs: + raise ValueError("To clip, vectors should be in same CRS as EOPatch bbox!") + + extent = gpd.GeoSeries([bbox.geometry], crs=bbox_crs) + vectors = gpd.clip(vectors, extent, keep_geom_type=True) + + return vectors + + def execute(self, eopatch: EOPatch | None = None, *, bbox: BBox | None = None) -> EOPatch: + """ + :param eopatch: An existing EOPatch. If none is provided it will create a new one. + :param bbox: A bounding box for which to load data. By default, if none is provided, it will take a bounding box + of given EOPatch. If given EOPatch is not provided it will load the entire dataset. + :return: An EOPatch with an additional vector feature + """ + if bbox is None and eopatch is not None: + bbox = eopatch.bbox + + vectors = self._load_vector_data(bbox) + minx, miny, maxx, maxy = vectors.total_bounds + final_bbox = bbox or BBox((minx, miny, maxx, maxy), crs=CRS(vectors.crs)) + + eopatch = eopatch or EOPatch(bbox=final_bbox) + if eopatch.bbox is None: + eopatch.bbox = final_bbox + + eopatch[self.feature] = self._reproject_and_clip(vectors, bbox) + + return eopatch diff --git a/extra-tasks/geodb/requirements.txt b/extra-tasks/geodb/requirements.txt new file mode 100644 index 0000000..26e84bf --- /dev/null +++ b/extra-tasks/geodb/requirements.txt @@ -0,0 +1,3 @@ +eo-learn[IO]==1.5.0 # last tested version +python-dotenv +xcube-geodb @ git+https://github.com/dcs4cop/xcube-geodb.git From 0abf420cd28bf60daeefb0d991532652f871520f Mon Sep 17 00:00:00 2001 From: Matic Lubej Date: Tue, 5 Sep 2023 12:49:36 +0200 Subject: [PATCH 3/4] add meteoblue extra task from eolearn --- .../test_meteoblue_raster_input.bin | Bin 0 -> 1060 bytes .../test_meteoblue_vector_input.bin | 3 + extra-tasks/meteoblue/meteoblue.py | 293 ++++++++++++++++++ extra-tasks/meteoblue/requirements.txt | 2 + extra-tasks/meteoblue/test_meteoblue.py | 179 +++++++++++ 5 files changed, 477 insertions(+) create mode 100644 extra-tasks/meteoblue/TestInputs/test_meteoblue_raster_input.bin create mode 100644 extra-tasks/meteoblue/TestInputs/test_meteoblue_vector_input.bin create mode 100644 extra-tasks/meteoblue/meteoblue.py create mode 100644 extra-tasks/meteoblue/requirements.txt create mode 100644 extra-tasks/meteoblue/test_meteoblue.py diff --git a/extra-tasks/meteoblue/TestInputs/test_meteoblue_raster_input.bin b/extra-tasks/meteoblue/TestInputs/test_meteoblue_raster_input.bin new file mode 100644 index 0000000000000000000000000000000000000000..97121f4b8e9d689993da32daa6798172bdb34780 GIT binary patch literal 1060 zcmdth`%6;+6bEou&c-q%S&~IIMM5p}I-O~cbhH2t2MVYZV9YIm;hyH;5+=0&zUpR;FPlx}I$LD6I zW@IM@J2`%S5~(jr1QMw)JW&#N%03%rg z5>y?=>g#YU)qtkS1}tuNpr zwi;1iXhP-gr#O^Th2@&ds0b}VHslh#m4>L}Hdt+?fYmbK(c9tUeFp^jvyd!yK%LSKCz2R2sBKU` z-UmB+t?*0f0M82++-nEmyKDeL%KG8_V-`*|+u@ke2AkemVVm3nrAc;}BOM@9vGA#l zg)6z<8$Jt^lY#OH3eMfIz)+?c9);7;oK8c7o`#@{G|+o#$P>_z*h)e1xD`Uj%uo|! nhElLWUbYSXq=7su8t literal 0 HcmV?d00001 diff --git a/extra-tasks/meteoblue/TestInputs/test_meteoblue_vector_input.bin b/extra-tasks/meteoblue/TestInputs/test_meteoblue_vector_input.bin new file mode 100644 index 0000000..8950ffe --- /dev/null +++ b/extra-tasks/meteoblue/TestInputs/test_meteoblue_vector_input.bin @@ -0,0 +1,3 @@ + + +NEMS4$[>B >BI>B0>B4>B9>BX>BD]>Ba>B$i@L@F.@4@@@:@!@@"$PCX=CzC.ٍCm̘CҰCArC`CaC08BdailyJ¥ κ Rh  2 m above gnd"°C*mean2JHAẨA tuple[EOPatch, BBox]: + if bbox is not None: + if eopatch is None: + eopatch = EOPatch(bbox=bbox) + elif eopatch.bbox is None: + eopatch.bbox = bbox + elif eopatch.bbox != bbox: + raise ValueError("Provided eopatch.bbox and bbox are not the same") + return eopatch, bbox + + if eopatch is None or eopatch.bbox is None: + raise ValueError("Bounding box is not provided") + return eopatch, eopatch.bbox + + def _prepare_time_intervals(self, eopatch: EOPatch, time_interval: RawTimeIntervalType | None) -> list[str]: + """Prepare a list of time intervals for which data will be collected from meteoblue services""" + timestamps = eopatch.timestamps + + if timestamps is None: + if not time_interval: + raise ValueError( + "Time interval should either be defined with `eopatch.timestamps` or `time_interval` parameter" + ) + + serialized_start_time, serialized_end_time = serialize_time(parse_time_interval(time_interval)) + return [f"{serialized_start_time}/{serialized_end_time}"] + + time_intervals: list[str] = [] + for timestamp in timestamps: + start_time = timestamp - self.time_difference + end_time = timestamp + self.time_difference + + serizalized_start_time, serizalized_end_time = serialize_time((start_time, end_time)) + + time_intervals.append(f"{serizalized_start_time}/{serizalized_end_time}") + + return time_intervals + + @abstractmethod + def _get_data(self, query: dict) -> tuple[Any, list[dt.datetime]]: + """It should return an output feature object and a list of timestamps""" + + def execute( + self, + eopatch: EOPatch | None = None, + *, + query: dict | None = None, + bbox: BBox | None = None, + time_interval: RawTimeIntervalType | None = None, + ) -> EOPatch: + """Execute method that adds new meteoblue data into an EOPatch + + :param eopatch: An EOPatch in which data will be added. If not provided a new EOPatch will be created. + :param bbox: A bounding box of a request. Should be provided if eopatch parameter is not provided. + :param query: meteoblue dataset API query definition. This query takes precedence over one defined in __init__. + :param time_interval: An interval for which data should be downloaded. If not provided then timestamps from + provided eopatch will be used. + :raises ValueError: Raises an exception when no query is set during Task initialization or the execute method. + """ + eopatch, bbox = self._get_modified_eopatch(eopatch, bbox) + + time_intervals = self._prepare_time_intervals(eopatch, time_interval) + + geometry = Geometry(bbox.geometry, bbox.crs).transform(CRS.WGS84) + geojson = shapely.geometry.mapping(geometry.geometry) + + query = query if query is not None else self.query + if query is None: + raise ValueError("Query has to specified in execute method or during task initialization") + + executable_query = { + "units": self.units, + "geometry": geojson, + "format": "protobuf", + "timeIntervals": time_intervals, + "queries": [query], + } + result_data, result_timestamps = self._get_data(executable_query) + + if not eopatch.timestamps and result_timestamps: + eopatch.timestamps = result_timestamps + + eopatch[self.feature] = result_data + return eopatch + + +class MeteoblueVectorTask(BaseMeteoblueTask): + """Obtains weather data from meteoblue services as a vector feature + + The data is obtained as a VECTOR feature in a ``geopandas.GeoDataFrame`` where columns include latitude, longitude, + timestamp and a column for each weather variable. All data is downloaded from the + meteoblue dataset API (). + + A meteoblue API key is required to retrieve data. + """ + + def _get_data(self, query: dict) -> tuple[gpd.GeoDataFrame, list[dt.datetime]]: + """Provides a GeoDataFrame with information about weather control points and an empty list of timestamps""" + result = self.client.querySync(query) + dataframe = meteoblue_to_dataframe(result) + geometry = gpd.points_from_xy(dataframe.Longitude, dataframe.Latitude) + crs = CRS.WGS84.pyproj_crs() + gdf = gpd.GeoDataFrame(dataframe, geometry=geometry, crs=crs) + return gdf, [] + + +class MeteoblueRasterTask(BaseMeteoblueTask): + """Obtains weather data from meteoblue services as a raster feature + + It returns a 4D numpy array with dimensions (time, height, width, weather variables) which should be stored as a + DATA feature. Data is resampled to WGS84 plate carrée to a specified resolution using the + meteoblue dataset API (). + + A meteoblue API key is required to retrieve data. + """ + + def _get_data(self, query: dict) -> tuple[np.ndarray, list[dt.datetime]]: + """Return a 4-dimensional numpy array of shape (time, height, width, weather variables) and a list of + timestamps + """ + result = self.client.querySync(query) + + data = meteoblue_to_numpy(result) + timestamps = _meteoblue_timestamps_from_geometry(result.geometries[0]) + return data, timestamps + + +def meteoblue_to_dataframe(result: Any) -> pd.DataFrame: + """Transform a meteoblue dataset API result to a dataframe + + :param result: A response of meteoblue API of type `Dataset_pb2.DatasetApiProtobuf` + :returns: A dataframe with columns TIMESTAMP, Longitude, Latitude and aggregation columns + """ + geometry = result.geometries[0] + code_names = [f"{code.code}_{code.level}_{code.aggregation}" for code in geometry.codes] + + if not geometry.timeIntervals: + return pd.DataFrame(columns=[TIMESTAMP_COLUMN, "Longitude", "Latitude", *code_names]) + + dataframes = [] + for index, time_interval in enumerate(geometry.timeIntervals): + timestamps = _meteoblue_timestamps_from_time_interval(time_interval) + + n_locations = len(geometry.lats) + n_timesteps = len(timestamps) + + dataframe = pd.DataFrame( + { + TIMESTAMP_COLUMN: np.tile(timestamps, n_locations), # type: ignore[arg-type] # numpy can do this + "Longitude": np.repeat(geometry.lons, n_timesteps), + "Latitude": np.repeat(geometry.lats, n_timesteps), + } + ) + + for code, code_name in zip(geometry.codes, code_names): + dataframe[code_name] = np.array(code.timeIntervals[index].data) + + dataframes.append(dataframe) + + return pd.concat(dataframes, ignore_index=True) + + +def meteoblue_to_numpy(result: Any) -> np.ndarray: + """Transform a meteoblue dataset API result to a dataframe + + :param result: A response of meteoblue API of type `Dataset_pb2.DatasetApiProtobuf` + :returns: A 4D numpy array with shape (time, height, width, weather variables) + """ + geometry = result.geometries[0] + + n_locations = len(geometry.lats) + n_codes = len(geometry.codes) + n_time_intervals = len(geometry.timeIntervals) + geo_ny = geometry.ny + geo_nx = geometry.nx + + # meteoblue data is using dimensions (n_variables, n_time_intervals, ny, nx, n_timesteps) + # Individual time intervals may have different number of timesteps (not a dimension) + # Therefore we have to first transpose each code individually and then transpose everything again + def map_code(code: Any) -> np.ndarray: + """Transpose a single code""" + code_data = np.array([t.data for t in code.timeIntervals]) + + code_n_timesteps = code_data.size // n_locations // n_time_intervals + code_data = code_data.reshape((n_time_intervals, geo_ny, geo_nx, code_n_timesteps)) + + # transpose from shape (n_time_intervals, geo_ny, geo_nx, code_n_timesteps) + # to (n_time_intervals, code_n_timesteps, geo_ny, geo_nx) + # and flip the y-axis (meteoblue is using northward facing axis + # but the standard for EOPatch features is a southward facing axis) + return np.flip(code_data.transpose((0, 3, 1, 2)), axis=2) + + data = np.array(list(map(map_code, geometry.codes))) + + n_timesteps = data.size // n_locations // n_codes + data = data.reshape((n_codes, n_timesteps, geo_ny, geo_nx)) + + return data.transpose((1, 2, 3, 0)) + + +def _meteoblue_timestamps_from_geometry(geometry_pb: Any) -> list[dt.datetime]: + """Transforms a protobuf geometry object into a list of datetime objects""" + return list(pd.core.common.flatten(map(_meteoblue_timestamps_from_time_interval, geometry_pb.timeIntervals))) + + +def _meteoblue_timestamps_from_time_interval(timestamp_pb: Any) -> list[dt.datetime]: + """Transforms a protobuf timestamp object into a list of datetime objects""" + if timestamp_pb.timestrings: + # Time intervals like weekly data, return an `array of strings` as timestamps + # For time indications like `20200801T0000-20200802T235959` we only return the first date as datetime + return list(map(_parse_timestring, timestamp_pb.timestrings)) + + # Regular time intervals return `start, end and stride` as a time axis + # We convert it into an array of daytime + time_range = range(timestamp_pb.start, timestamp_pb.end, timestamp_pb.stride) + return list(map(dt.datetime.fromtimestamp, time_range)) + + +def _parse_timestring(timestring: str) -> dt.datetime: + """A helper method to parse specific timestrings obtained from meteoblue service""" + if "-" in timestring: + timestring = timestring.split("-")[0] + return dateutil.parser.parse(timestring) diff --git a/extra-tasks/meteoblue/requirements.txt b/extra-tasks/meteoblue/requirements.txt new file mode 100644 index 0000000..8862173 --- /dev/null +++ b/extra-tasks/meteoblue/requirements.txt @@ -0,0 +1,2 @@ +eo-learn[IO]==1.5.0 # last tested version +meteoblue_dataset_sdk>=1,<2 diff --git a/extra-tasks/meteoblue/test_meteoblue.py b/extra-tasks/meteoblue/test_meteoblue.py new file mode 100644 index 0000000..52bc26d --- /dev/null +++ b/extra-tasks/meteoblue/test_meteoblue.py @@ -0,0 +1,179 @@ +""" +Module containing tests for Meteoblue tasks + +Copyright (c) 2017- Sinergise and contributors +For the full list of contributors, see the CREDITS file in the root directory of this source tree. + +This source code is licensed under the MIT license, see the LICENSE file in the root directory of this source tree. +""" +from __future__ import annotations + +import datetime as dt +import os + +import numpy as np +import pytest +from meteoblue import MeteoblueRasterTask, MeteoblueVectorTask +from meteoblue_dataset_sdk.protobuf.dataset_pb2 import DatasetApiProtobuf + +from eolearn.core import EOPatch, FeatureType +from sentinelhub import CRS, BBox + +RASTER_QUERY = { + "domain": "NEMS4", + "gapFillDomain": None, + "timeResolution": "hourly", + "codes": [{"code": 11, "level": "2 m above gnd"}], + "transformations": [ + {"type": "aggregateTimeInterval", "aggregation": "mean"}, + { + "type": "spatialTransformV2", + "gridResolution": 0.02, + "interpolationMethod": "linear", + "spatialAggregation": "mean", + "disjointArea": "keep", + }, + ], +} + +VECTOR_QUERY = { + "domain": "NEMS4", + "gapFillDomain": None, + "timeResolution": "daily", + "codes": [{"code": 11, "level": "2 m above gnd", "aggregation": "mean"}], + "transformations": None, +} + +UNITS = { + "temperature": "C", + "velocity": "km/h", + "length": "metric", + "energy": "watts", +} + +BBOX = BBox([7.52, 47.50, 7.7, 47.6], crs=CRS.WGS84) +TIME_INTERVAL = dt.datetime(year=2020, month=8, day=1), dt.datetime(year=2020, month=8, day=3) + + +def test_meteoblue_raster_task(mocker): + """Unit test for MeteoblueRasterTask""" + mocker.patch( + "meteoblue_dataset_sdk.Client.querySync", + return_value=_load_meteoblue_client_response("test_meteoblue_raster_input.bin"), + ) + + feature = FeatureType.DATA, "WEATHER-DATA" + meteoblue_task = MeteoblueRasterTask(feature, "dummy-api-key", query=RASTER_QUERY, units=UNITS) + + eopatch = meteoblue_task.execute(bbox=BBOX, time_interval=TIME_INTERVAL) + + assert eopatch.bbox == BBOX + assert eopatch.timestamps == [dt.datetime(2020, 8, 1)] + + data = eopatch[feature] + assert data.shape == (1, 6, 10, 1) + assert data.dtype == np.float64 + + assert round(np.mean(data), 5) == 23.79214 + assert round(np.std(data), 5) == 0.3996 + assert round(data[0, -1, 0, 0], 5) == 23.74646 + + +def test_meteoblue_vector_task(mocker): + """Unit test for MeteoblueVectorTask""" + mocker.patch( + "meteoblue_dataset_sdk.Client.querySync", + return_value=_load_meteoblue_client_response("test_meteoblue_vector_input.bin"), + ) + + feature = FeatureType.VECTOR, "WEATHER-DATA" + meteoblue_task = MeteoblueVectorTask(feature, "dummy-api-key", query=VECTOR_QUERY, units=UNITS) + + eopatch = EOPatch(bbox=BBOX) + eopatch = meteoblue_task.execute(eopatch, time_interval=TIME_INTERVAL) + + assert eopatch.bbox == BBOX + + data = eopatch[feature] + assert len(data.index) == 18 + assert data.crs.to_epsg() == 4326 + + data_series = data["11_2 m above gnd_mean"] + assert round(data_series.mean(), 5) == 23.75278 + assert round(data_series.std(), 5) == 2.99785 + + +def test_meteoblue_query_precedence(mocker): + """Unit test for query precedence in a MeteoblueTask""" + mocker.patch( + "meteoblue_dataset_sdk.Client.querySync", + return_value=_load_meteoblue_client_response("test_meteoblue_vector_input.bin"), + ) + + feature = FeatureType.VECTOR, "WEATHER-DATA" + meteoblue_task_no_query = MeteoblueVectorTask(feature, "dummy-api-key", units=UNITS) + meteoblue_task_with_query = MeteoblueVectorTask(feature, "dummy-api-key", query=RASTER_QUERY, units=UNITS) + + eopatch = EOPatch(bbox=BBOX) + + with pytest.raises(ValueError): + meteoblue_task_no_query.execute(eopatch, time_interval=TIME_INTERVAL) + + spy_no_query = mocker.spy(meteoblue_task_no_query, "_get_data") + meteoblue_task_no_query.execute(eopatch, time_interval=TIME_INTERVAL, query=VECTOR_QUERY) + spy_no_query.assert_called_once_with( + { + "units": {"temperature": "C", "velocity": "km/h", "length": "metric", "energy": "watts"}, + "geometry": { + "type": "Polygon", + "coordinates": (((7.52, 47.5), (7.52, 47.6), (7.7, 47.6), (7.7, 47.5), (7.52, 47.5)),), + }, + "format": "protobuf", + "timeIntervals": ["2020-08-01T00:00:00/2020-08-03T00:00:00"], + "queries": [ + { + "domain": "NEMS4", + "gapFillDomain": None, + "timeResolution": "daily", + "codes": [{"code": 11, "level": "2 m above gnd", "aggregation": "mean"}], + "transformations": None, + } + ], + } + ) + + spy_with_query = mocker.spy(meteoblue_task_with_query, "_get_data") + meteoblue_task_with_query.execute(eopatch, time_interval=TIME_INTERVAL, query=VECTOR_QUERY) + spy_with_query.assert_called_once_with( + { + "units": {"temperature": "C", "velocity": "km/h", "length": "metric", "energy": "watts"}, + "geometry": { + "type": "Polygon", + "coordinates": (((7.52, 47.5), (7.52, 47.6), (7.7, 47.6), (7.7, 47.5), (7.52, 47.5)),), + }, + "format": "protobuf", + "timeIntervals": ["2020-08-01T00:00:00/2020-08-03T00:00:00"], + "queries": [ + { + "domain": "NEMS4", + "gapFillDomain": None, + "timeResolution": "daily", + "codes": [{"code": 11, "level": "2 m above gnd", "aggregation": "mean"}], + "transformations": None, + } + ], + } + ) + + +def _load_meteoblue_client_response(filename): + """Loads locally stored responses of meteoblue client + + To update content of saved files use: + with open('', 'wb') as fp: + fp.write(result.SerializeToString()) + """ + path = os.path.join(os.path.dirname(__file__), "TestInputs", filename) + + with open(path, "rb") as fp: + return DatasetApiProtobuf.FromString(fp.read()) From 08f8bb349efc47595b7d7525403c36265ed5cfe4 Mon Sep 17 00:00:00 2001 From: Matic Lubej Date: Tue, 5 Sep 2023 12:50:29 +0200 Subject: [PATCH 4/4] update meteoblue tasks in existing notebooks --- GEM-data/meteoblue.py | 293 ++++++++++++++++++++++++++++++++++++ GEM-data/weather-data.ipynb | 2 +- 2 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 GEM-data/meteoblue.py diff --git a/GEM-data/meteoblue.py b/GEM-data/meteoblue.py new file mode 100644 index 0000000..e8feb7b --- /dev/null +++ b/GEM-data/meteoblue.py @@ -0,0 +1,293 @@ +""" +Module with tasks that provide data from meteoblue services + +Copyright (c) 2017- Sinergise and contributors +For the full list of contributors, see https://github.com/sentinel-hub/eo-learn/blob/master/CREDITS.md. + +This source code is licensed under the MIT license, see the LICENSE file in the root directory of this source tree. +""" +from __future__ import annotations + +import datetime as dt +from abc import ABCMeta, abstractmethod +from typing import Any + +import dateutil.parser +import geopandas as gpd +import numpy as np +import pandas as pd +import shapely.geometry + +try: + import meteoblue_dataset_sdk + from meteoblue_dataset_sdk.caching import FileCache +except ImportError as exception: + raise ImportError("This module requires an installation of meteoblue_dataset_sdk package") from exception + + +from eolearn.core import EOPatch, EOTask +from eolearn.core.constants import TIMESTAMP_COLUMN +from eolearn.core.types import Feature +from sentinelhub import CRS, BBox, Geometry, parse_time_interval, serialize_time +from sentinelhub.types import RawTimeIntervalType + + +class BaseMeteoblueTask(EOTask, metaclass=ABCMeta): + """A base task implementing the logic that is common for all meteoblue tasks""" + + def __init__( + self, + feature: Feature, + apikey: str, + query: dict | None = None, + units: dict | None = None, + time_difference: dt.timedelta = dt.timedelta(minutes=30), # noqa: B008, RUF100 + cache_folder: str | None = None, + cache_max_age: int = 604800, + ): + """ + :param feature: A feature in which meteoblue data will be stored + :param apikey: meteoblue API key + :param query: meteoblue dataset API query definition. If set to None (default) the query has to be set + in the execute method instead. + :param units: meteoblue dataset API units definition. If set to None (default) request will use default units + as specified in https://docs.meteoblue.com/en/weather-apis/dataset-api/dataset-api#units + :param time_difference: The size of a time interval around each timestamp for which data will be collected. It + is used only in a combination with ``time_interval`` parameter from ``execute`` method. + :param cache_folder: Path to cache_folder. If set to None (default) requests will not be cached. + :param cache_max_age: Maximum age in seconds to use a cached result. Default 1 week. + """ + self.feature = self.parse_feature(feature) + cache = None + if cache_folder: + cache = FileCache(path=cache_folder, max_age=cache_max_age) + + self.client = meteoblue_dataset_sdk.Client(apikey=apikey, cache=cache) + self.query = query + self.units = units + self.time_difference = time_difference + + @staticmethod + def _get_modified_eopatch(eopatch: EOPatch | None, bbox: BBox | None) -> tuple[EOPatch, BBox]: + if bbox is not None: + if eopatch is None: + eopatch = EOPatch(bbox=bbox) + elif eopatch.bbox is None: + eopatch.bbox = bbox + elif eopatch.bbox != bbox: + raise ValueError("Provided eopatch.bbox and bbox are not the same") + return eopatch, bbox + + if eopatch is None or eopatch.bbox is None: + raise ValueError("Bounding box is not provided") + return eopatch, eopatch.bbox + + def _prepare_time_intervals(self, eopatch: EOPatch, time_interval: RawTimeIntervalType | None) -> list[str]: + """Prepare a list of time intervals for which data will be collected from meteoblue services""" + timestamps = eopatch.timestamps + + if timestamps is None: + if not time_interval: + raise ValueError( + "Time interval should either be defined with `eopatch.timestamps` or `time_interval` parameter" + ) + + serialized_start_time, serialized_end_time = serialize_time(parse_time_interval(time_interval)) + return [f"{serialized_start_time}/{serialized_end_time}"] + + time_intervals: list[str] = [] + for timestamp in timestamps: + start_time = timestamp - self.time_difference + end_time = timestamp + self.time_difference + + serizalized_start_time, serizalized_end_time = serialize_time((start_time, end_time)) + + time_intervals.append(f"{serizalized_start_time}/{serizalized_end_time}") + + return time_intervals + + @abstractmethod + def _get_data(self, query: dict) -> tuple[Any, list[dt.datetime]]: + """It should return an output feature object and a list of timestamps""" + + def execute( + self, + eopatch: EOPatch | None = None, + *, + query: dict | None = None, + bbox: BBox | None = None, + time_interval: RawTimeIntervalType | None = None, + ) -> EOPatch: + """Execute method that adds new meteoblue data into an EOPatch + + :param eopatch: An EOPatch in which data will be added. If not provided a new EOPatch will be created. + :param bbox: A bounding box of a request. Should be provided if eopatch parameter is not provided. + :param query: meteoblue dataset API query definition. This query takes precedence over one defined in __init__. + :param time_interval: An interval for which data should be downloaded. If not provided then timestamps from + provided eopatch will be used. + :raises ValueError: Raises an exception when no query is set during Task initialization or the execute method. + """ + eopatch, bbox = self._get_modified_eopatch(eopatch, bbox) + + time_intervals = self._prepare_time_intervals(eopatch, time_interval) + + geometry = Geometry(bbox.geometry, bbox.crs).transform(CRS.WGS84) + geojson = shapely.geometry.mapping(geometry.geometry) + + query = query if query is not None else self.query + if query is None: + raise ValueError("Query has to specified in execute method or during task initialization") + + executable_query = { + "units": self.units, + "geometry": geojson, + "format": "protobuf", + "timeIntervals": time_intervals, + "queries": [query], + } + result_data, result_timestamps = self._get_data(executable_query) + + if not eopatch.timestamps and result_timestamps: + eopatch.timestamps = result_timestamps + + eopatch[self.feature] = result_data + return eopatch + + +class MeteoblueVectorTask(BaseMeteoblueTask): + """Obtains weather data from meteoblue services as a vector feature + + The data is obtained as a VECTOR feature in a ``geopandas.GeoDataFrame`` where columns include latitude, longitude, + timestamp and a column for each weather variable. All data is downloaded from the + meteoblue dataset API (). + + A meteoblue API key is required to retrieve data. + """ + + def _get_data(self, query: dict) -> tuple[gpd.GeoDataFrame, list[dt.datetime]]: + """Provides a GeoDataFrame with information about weather control points and an empty list of timestamps""" + result = self.client.querySync(query) + dataframe = meteoblue_to_dataframe(result) + geometry = gpd.points_from_xy(dataframe.Longitude, dataframe.Latitude) + crs = CRS.WGS84.pyproj_crs() + gdf = gpd.GeoDataFrame(dataframe, geometry=geometry, crs=crs) + return gdf, [] + + +class MeteoblueRasterTask(BaseMeteoblueTask): + """Obtains weather data from meteoblue services as a raster feature + + It returns a 4D numpy array with dimensions (time, height, width, weather variables) which should be stored as a + DATA feature. Data is resampled to WGS84 plate carrée to a specified resolution using the + meteoblue dataset API (). + + A meteoblue API key is required to retrieve data. + """ + + def _get_data(self, query: dict) -> tuple[np.ndarray, list[dt.datetime]]: + """Return a 4-dimensional numpy array of shape (time, height, width, weather variables) and a list of + timestamps + """ + result = self.client.querySync(query) + + data = meteoblue_to_numpy(result) + timestamps = _meteoblue_timestamps_from_geometry(result.geometries[0]) + return data, timestamps + + +def meteoblue_to_dataframe(result: Any) -> pd.DataFrame: + """Transform a meteoblue dataset API result to a dataframe + + :param result: A response of meteoblue API of type `Dataset_pb2.DatasetApiProtobuf` + :returns: A dataframe with columns TIMESTAMP, Longitude, Latitude and aggregation columns + """ + geometry = result.geometries[0] + code_names = [f"{code.code}_{code.level}_{code.aggregation}" for code in geometry.codes] + + if not geometry.timeIntervals: + return pd.DataFrame(columns=[TIMESTAMP_COLUMN, "Longitude", "Latitude", *code_names]) + + dataframes = [] + for index, time_interval in enumerate(geometry.timeIntervals): + timestamps = _meteoblue_timestamps_from_time_interval(time_interval) + + n_locations = len(geometry.lats) + n_timesteps = len(timestamps) + + dataframe = pd.DataFrame( + { + TIMESTAMP_COLUMN: np.tile(timestamps, n_locations), # type: ignore[arg-type] # numpy can do this + "Longitude": np.repeat(geometry.lons, n_timesteps), + "Latitude": np.repeat(geometry.lats, n_timesteps), + } + ) + + for code, code_name in zip(geometry.codes, code_names): + dataframe[code_name] = np.array(code.timeIntervals[index].data) + + dataframes.append(dataframe) + + return pd.concat(dataframes, ignore_index=True) + + +def meteoblue_to_numpy(result: Any) -> np.ndarray: + """Transform a meteoblue dataset API result to a dataframe + + :param result: A response of meteoblue API of type `Dataset_pb2.DatasetApiProtobuf` + :returns: A 4D numpy array with shape (time, height, width, weather variables) + """ + geometry = result.geometries[0] + + n_locations = len(geometry.lats) + n_codes = len(geometry.codes) + n_time_intervals = len(geometry.timeIntervals) + geo_ny = geometry.ny + geo_nx = geometry.nx + + # meteoblue data is using dimensions (n_variables, n_time_intervals, ny, nx, n_timesteps) + # Individual time intervals may have different number of timesteps (not a dimension) + # Therefore we have to first transpose each code individually and then transpose everything again + def map_code(code: Any) -> np.ndarray: + """Transpose a single code""" + code_data = np.array([t.data for t in code.timeIntervals]) + + code_n_timesteps = code_data.size // n_locations // n_time_intervals + code_data = code_data.reshape((n_time_intervals, geo_ny, geo_nx, code_n_timesteps)) + + # transpose from shape (n_time_intervals, geo_ny, geo_nx, code_n_timesteps) + # to (n_time_intervals, code_n_timesteps, geo_ny, geo_nx) + # and flip the y-axis (meteoblue is using northward facing axis + # but the standard for EOPatch features is a southward facing axis) + return np.flip(code_data.transpose((0, 3, 1, 2)), axis=2) + + data = np.array(list(map(map_code, geometry.codes))) + + n_timesteps = data.size // n_locations // n_codes + data = data.reshape((n_codes, n_timesteps, geo_ny, geo_nx)) + + return data.transpose((1, 2, 3, 0)) + + +def _meteoblue_timestamps_from_geometry(geometry_pb: Any) -> list[dt.datetime]: + """Transforms a protobuf geometry object into a list of datetime objects""" + return list(pd.core.common.flatten(map(_meteoblue_timestamps_from_time_interval, geometry_pb.timeIntervals))) + + +def _meteoblue_timestamps_from_time_interval(timestamp_pb: Any) -> list[dt.datetime]: + """Transforms a protobuf timestamp object into a list of datetime objects""" + if timestamp_pb.timestrings: + # Time intervals like weekly data, return an `array of strings` as timestamps + # For time indications like `20200801T0000-20200802T235959` we only return the first date as datetime + return list(map(_parse_timestring, timestamp_pb.timestrings)) + + # Regular time intervals return `start, end and stride` as a time axis + # We convert it into an array of daytime + time_range = range(timestamp_pb.start, timestamp_pb.end, timestamp_pb.stride) + return list(map(dt.datetime.fromtimestamp, time_range)) + + +def _parse_timestring(timestring: str) -> dt.datetime: + """A helper method to parse specific timestrings obtained from meteoblue service""" + if "-" in timestring: + timestring = timestring.split("-")[0] + return dateutil.parser.parse(timestring) diff --git a/GEM-data/weather-data.ipynb b/GEM-data/weather-data.ipynb index cc958e2..2162d23 100644 --- a/GEM-data/weather-data.ipynb +++ b/GEM-data/weather-data.ipynb @@ -23,9 +23,9 @@ "import datetime\n", "\n", "import matplotlib.pyplot as plt\n", + "from meteoblue import MeteoblueRasterTask, MeteoblueVectorTask\n", "\n", "from eolearn.core import EOPatch, FeatureType\n", - "from eolearn.io.extra.meteoblue import MeteoblueRasterTask, MeteoblueVectorTask\n", "from sentinelhub import CRS, BBox, to_utm_bbox" ] },