mapchete-eo 2025.7.0__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mapchete_eo/__init__.py +1 -0
- mapchete_eo/archives/__init__.py +0 -0
- mapchete_eo/archives/base.py +65 -0
- mapchete_eo/array/__init__.py +0 -0
- mapchete_eo/array/buffer.py +16 -0
- mapchete_eo/array/color.py +29 -0
- mapchete_eo/array/convert.py +157 -0
- mapchete_eo/base.py +528 -0
- mapchete_eo/blacklist.txt +175 -0
- mapchete_eo/cli/__init__.py +30 -0
- mapchete_eo/cli/bounds.py +22 -0
- mapchete_eo/cli/options_arguments.py +243 -0
- mapchete_eo/cli/s2_brdf.py +77 -0
- mapchete_eo/cli/s2_cat_results.py +146 -0
- mapchete_eo/cli/s2_find_broken_products.py +93 -0
- mapchete_eo/cli/s2_jp2_static_catalog.py +166 -0
- mapchete_eo/cli/s2_mask.py +71 -0
- mapchete_eo/cli/s2_mgrs.py +45 -0
- mapchete_eo/cli/s2_rgb.py +114 -0
- mapchete_eo/cli/s2_verify.py +129 -0
- mapchete_eo/cli/static_catalog.py +123 -0
- mapchete_eo/eostac.py +30 -0
- mapchete_eo/exceptions.py +87 -0
- mapchete_eo/geometry.py +271 -0
- mapchete_eo/image_operations/__init__.py +12 -0
- mapchete_eo/image_operations/color_correction.py +136 -0
- mapchete_eo/image_operations/compositing.py +247 -0
- mapchete_eo/image_operations/dtype_scale.py +43 -0
- mapchete_eo/image_operations/fillnodata.py +130 -0
- mapchete_eo/image_operations/filters.py +319 -0
- mapchete_eo/image_operations/linear_normalization.py +81 -0
- mapchete_eo/image_operations/sigmoidal.py +114 -0
- mapchete_eo/io/__init__.py +37 -0
- mapchete_eo/io/assets.py +492 -0
- mapchete_eo/io/items.py +147 -0
- mapchete_eo/io/levelled_cubes.py +228 -0
- mapchete_eo/io/path.py +144 -0
- mapchete_eo/io/products.py +413 -0
- mapchete_eo/io/profiles.py +45 -0
- mapchete_eo/known_catalogs.py +42 -0
- mapchete_eo/platforms/sentinel2/__init__.py +17 -0
- mapchete_eo/platforms/sentinel2/archives.py +190 -0
- mapchete_eo/platforms/sentinel2/bandpass_adjustment.py +104 -0
- mapchete_eo/platforms/sentinel2/brdf/__init__.py +8 -0
- mapchete_eo/platforms/sentinel2/brdf/config.py +32 -0
- mapchete_eo/platforms/sentinel2/brdf/correction.py +260 -0
- mapchete_eo/platforms/sentinel2/brdf/hls.py +251 -0
- mapchete_eo/platforms/sentinel2/brdf/models.py +44 -0
- mapchete_eo/platforms/sentinel2/brdf/protocols.py +27 -0
- mapchete_eo/platforms/sentinel2/brdf/ross_thick.py +136 -0
- mapchete_eo/platforms/sentinel2/brdf/sun_angle_arrays.py +76 -0
- mapchete_eo/platforms/sentinel2/config.py +181 -0
- mapchete_eo/platforms/sentinel2/driver.py +78 -0
- mapchete_eo/platforms/sentinel2/masks.py +325 -0
- mapchete_eo/platforms/sentinel2/metadata_parser.py +734 -0
- mapchete_eo/platforms/sentinel2/path_mappers/__init__.py +29 -0
- mapchete_eo/platforms/sentinel2/path_mappers/base.py +56 -0
- mapchete_eo/platforms/sentinel2/path_mappers/earthsearch.py +34 -0
- mapchete_eo/platforms/sentinel2/path_mappers/metadata_xml.py +135 -0
- mapchete_eo/platforms/sentinel2/path_mappers/sinergise.py +105 -0
- mapchete_eo/platforms/sentinel2/preprocessing_tasks.py +26 -0
- mapchete_eo/platforms/sentinel2/processing_baseline.py +160 -0
- mapchete_eo/platforms/sentinel2/product.py +669 -0
- mapchete_eo/platforms/sentinel2/types.py +109 -0
- mapchete_eo/processes/__init__.py +0 -0
- mapchete_eo/processes/config.py +51 -0
- mapchete_eo/processes/dtype_scale.py +112 -0
- mapchete_eo/processes/eo_to_xarray.py +19 -0
- mapchete_eo/processes/merge_rasters.py +235 -0
- mapchete_eo/product.py +278 -0
- mapchete_eo/protocols.py +56 -0
- mapchete_eo/search/__init__.py +14 -0
- mapchete_eo/search/base.py +222 -0
- mapchete_eo/search/config.py +42 -0
- mapchete_eo/search/s2_mgrs.py +314 -0
- mapchete_eo/search/stac_search.py +251 -0
- mapchete_eo/search/stac_static.py +236 -0
- mapchete_eo/search/utm_search.py +251 -0
- mapchete_eo/settings.py +24 -0
- mapchete_eo/sort.py +48 -0
- mapchete_eo/time.py +53 -0
- mapchete_eo/types.py +73 -0
- mapchete_eo-2025.7.0.dist-info/METADATA +38 -0
- mapchete_eo-2025.7.0.dist-info/RECORD +87 -0
- mapchete_eo-2025.7.0.dist-info/WHEEL +5 -0
- mapchete_eo-2025.7.0.dist-info/entry_points.txt +11 -0
- mapchete_eo-2025.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import Any, Callable, Dict, Generator, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from mapchete import Bounds
|
|
6
|
+
from mapchete.types import BoundsLike
|
|
7
|
+
from pystac import Item, Catalog, Collection
|
|
8
|
+
from mapchete.io.vector import bounds_intersect
|
|
9
|
+
from mapchete.path import MPathLike
|
|
10
|
+
from pystac.stac_io import StacIO
|
|
11
|
+
from pystac_client import Client
|
|
12
|
+
from shapely.geometry import shape
|
|
13
|
+
from shapely.geometry.base import BaseGeometry
|
|
14
|
+
|
|
15
|
+
from mapchete_eo.search.base import (
|
|
16
|
+
CatalogSearcher,
|
|
17
|
+
FSSpecStacIO,
|
|
18
|
+
StaticCatalogWriterMixin,
|
|
19
|
+
filter_items,
|
|
20
|
+
)
|
|
21
|
+
from mapchete_eo.search.config import StacStaticConfig
|
|
22
|
+
from mapchete_eo.time import time_ranges_intersect
|
|
23
|
+
from mapchete_eo.types import TimeRange
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
StacIO.set_default(FSSpecStacIO)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class STACStaticCatalog(StaticCatalogWriterMixin, CatalogSearcher):
|
|
32
|
+
config_cls = StacStaticConfig
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
baseurl: MPathLike,
|
|
37
|
+
stac_item_modifiers: Optional[List[Callable[[Item], Item]]] = None,
|
|
38
|
+
):
|
|
39
|
+
self.client = Client.from_file(str(baseurl), stac_io=FSSpecStacIO())
|
|
40
|
+
self.id = self.client.id
|
|
41
|
+
self.description = self.client.description
|
|
42
|
+
self.stac_extensions = self.client.stac_extensions
|
|
43
|
+
self.collections = [c.id for c in self.client.get_children()]
|
|
44
|
+
self.eo_bands = self._eo_bands()
|
|
45
|
+
self.stac_item_modifiers = stac_item_modifiers
|
|
46
|
+
|
|
47
|
+
def search(
|
|
48
|
+
self,
|
|
49
|
+
time: Optional[Union[TimeRange, List[TimeRange]]] = None,
|
|
50
|
+
bounds: Optional[BoundsLike] = None,
|
|
51
|
+
area: Optional[BaseGeometry] = None,
|
|
52
|
+
search_kwargs: Optional[Dict[str, Any]] = None,
|
|
53
|
+
) -> Generator[Item, None, None]:
|
|
54
|
+
config = self.config_cls(**search_kwargs or {})
|
|
55
|
+
if area is None and bounds:
|
|
56
|
+
bounds = Bounds.from_inp(bounds)
|
|
57
|
+
area = shape(bounds)
|
|
58
|
+
for item in filter_items(
|
|
59
|
+
self._raw_search(time=time, area=area),
|
|
60
|
+
max_cloud_cover=config.max_cloud_cover,
|
|
61
|
+
):
|
|
62
|
+
yield item
|
|
63
|
+
|
|
64
|
+
def _raw_search(
|
|
65
|
+
self,
|
|
66
|
+
time: Optional[Union[TimeRange, List[TimeRange]]] = None,
|
|
67
|
+
area: Optional[BaseGeometry] = None,
|
|
68
|
+
) -> Generator[Item, None, None]:
|
|
69
|
+
if area is not None and area.is_empty:
|
|
70
|
+
return
|
|
71
|
+
logger.debug("iterate through children")
|
|
72
|
+
for collection in self.client.get_collections():
|
|
73
|
+
if time:
|
|
74
|
+
for time_range in time if isinstance(time, list) else [time]:
|
|
75
|
+
for item in _all_intersecting_items(
|
|
76
|
+
collection,
|
|
77
|
+
area=area,
|
|
78
|
+
time_range=time_range,
|
|
79
|
+
):
|
|
80
|
+
item.make_asset_hrefs_absolute()
|
|
81
|
+
yield item
|
|
82
|
+
else:
|
|
83
|
+
for item in _all_intersecting_items(
|
|
84
|
+
collection,
|
|
85
|
+
area=area,
|
|
86
|
+
):
|
|
87
|
+
item.make_asset_hrefs_absolute()
|
|
88
|
+
yield item
|
|
89
|
+
|
|
90
|
+
def _eo_bands(self) -> List[str]:
|
|
91
|
+
for collection in self.client.get_children():
|
|
92
|
+
eo_bands = collection.extra_fields.get("properties", {}).get("eo:bands")
|
|
93
|
+
if eo_bands:
|
|
94
|
+
return eo_bands
|
|
95
|
+
else:
|
|
96
|
+
warnings.warn(
|
|
97
|
+
"Unable to read eo:bands definition from collections. "
|
|
98
|
+
"Trying now to get information from assets ..."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# see if eo:bands can be found in properties
|
|
102
|
+
item = _get_first_item(self.client.get_children())
|
|
103
|
+
eo_bands = item.properties.get("eo:bands")
|
|
104
|
+
if eo_bands:
|
|
105
|
+
return eo_bands
|
|
106
|
+
|
|
107
|
+
# look through the assets and collect eo:bands
|
|
108
|
+
out = {}
|
|
109
|
+
for asset in item.assets.values():
|
|
110
|
+
for eo_band in asset.extra_fields.get("eo:bands", []):
|
|
111
|
+
out[eo_band["name"]] = eo_band
|
|
112
|
+
if out:
|
|
113
|
+
return [v for v in out.values()]
|
|
114
|
+
|
|
115
|
+
logger.debug("cannot find eo:bands definition")
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
def get_collections(
|
|
119
|
+
self,
|
|
120
|
+
time: Optional[Union[TimeRange, List[TimeRange]]] = None,
|
|
121
|
+
bounds: Optional[BoundsLike] = None,
|
|
122
|
+
area: Optional[BaseGeometry] = None,
|
|
123
|
+
):
|
|
124
|
+
if area is None and bounds is not None:
|
|
125
|
+
area = Bounds.from_inp(bounds).geometry
|
|
126
|
+
for collection in self.client.get_children():
|
|
127
|
+
if time:
|
|
128
|
+
for time_range in time if isinstance(time, list) else [time]:
|
|
129
|
+
if _collection_extent_intersects(
|
|
130
|
+
collection,
|
|
131
|
+
area=area,
|
|
132
|
+
time_range=time_range,
|
|
133
|
+
):
|
|
134
|
+
yield collection
|
|
135
|
+
else:
|
|
136
|
+
if _collection_extent_intersects(collection, area=area):
|
|
137
|
+
yield collection
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _get_first_item(collections):
|
|
141
|
+
for collection in collections:
|
|
142
|
+
for item in collection.get_all_items():
|
|
143
|
+
return item
|
|
144
|
+
else:
|
|
145
|
+
for child in collection.get_children():
|
|
146
|
+
return _get_first_item(child)
|
|
147
|
+
else:
|
|
148
|
+
raise ValueError("collections contain no items")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _all_intersecting_items(
|
|
152
|
+
collection: Union[Catalog, Collection],
|
|
153
|
+
area: BaseGeometry,
|
|
154
|
+
time_range: Optional[TimeRange] = None,
|
|
155
|
+
):
|
|
156
|
+
# collection items
|
|
157
|
+
logger.debug("checking items...")
|
|
158
|
+
for item in collection.get_items():
|
|
159
|
+
# yield item if it intersects with extent
|
|
160
|
+
logger.debug("item %s", item.id)
|
|
161
|
+
if _item_extent_intersects(item, area=area, time_range=time_range):
|
|
162
|
+
logger.debug("item %s within search parameters", item.id)
|
|
163
|
+
yield item
|
|
164
|
+
|
|
165
|
+
# collection children
|
|
166
|
+
logger.debug("checking collections...")
|
|
167
|
+
for child in collection.get_children():
|
|
168
|
+
# yield collection if it intersects with extent
|
|
169
|
+
logger.debug("collection %s", collection.id)
|
|
170
|
+
if _collection_extent_intersects(child, area=area, time_range=time_range):
|
|
171
|
+
logger.debug("found catalog %s with intersecting items", child.id)
|
|
172
|
+
yield from _all_intersecting_items(child, area=area, time_range=time_range)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _item_extent_intersects(
|
|
176
|
+
item: Item,
|
|
177
|
+
area: Optional[BaseGeometry] = None,
|
|
178
|
+
time_range: Optional[TimeRange] = None,
|
|
179
|
+
) -> bool:
|
|
180
|
+
# NOTE: bounds intersect is faster but in the current implementation cannot
|
|
181
|
+
# handle item footprints going over the Antimeridian (and have been split up into
|
|
182
|
+
# MultiPolygon geometries)
|
|
183
|
+
# spatial_intersect = bounds_intersect(item.bbox, bounds) if bounds else True
|
|
184
|
+
spatial_intersect = shape(item.geometry).intersects(area) if area else True
|
|
185
|
+
if time_range and item.datetime:
|
|
186
|
+
temporal_intersect = time_ranges_intersect(
|
|
187
|
+
(item.datetime, item.datetime), (time_range.start, time_range.end)
|
|
188
|
+
)
|
|
189
|
+
logger.debug(
|
|
190
|
+
"spatial intersect: %s, temporal intersect: %s",
|
|
191
|
+
spatial_intersect,
|
|
192
|
+
temporal_intersect,
|
|
193
|
+
)
|
|
194
|
+
return spatial_intersect and temporal_intersect
|
|
195
|
+
else:
|
|
196
|
+
logger.debug("spatial intersect: %s", spatial_intersect)
|
|
197
|
+
return spatial_intersect
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _collection_extent_intersects(
|
|
201
|
+
catalog, area: Optional[BaseGeometry] = None, time_range: Optional[TimeRange] = None
|
|
202
|
+
):
|
|
203
|
+
"""
|
|
204
|
+
Collection extent items (spatial, temporal) is a list of items, e.g. list of bounds values.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def _intersects_spatially():
|
|
208
|
+
for b in catalog.extent.spatial.to_dict().get("bbox", [[]]):
|
|
209
|
+
if bounds_intersect(area.bounds, b):
|
|
210
|
+
logger.debug("spatial intersect: True")
|
|
211
|
+
return True
|
|
212
|
+
else:
|
|
213
|
+
logger.debug("spatial intersect: False")
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
def _intersects_temporally():
|
|
217
|
+
for t in catalog.extent.temporal.to_dict().get("interval", [[]]):
|
|
218
|
+
if time_ranges_intersect((time_range.start, time_range.end), t):
|
|
219
|
+
logger.debug("temporal intersect: True")
|
|
220
|
+
return True
|
|
221
|
+
else:
|
|
222
|
+
logger.debug("temporal intersect: False")
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
spatial_intersect = _intersects_spatially() if area else True
|
|
226
|
+
if time_range:
|
|
227
|
+
temporal_intersect = _intersects_temporally()
|
|
228
|
+
logger.debug(
|
|
229
|
+
"spatial intersect: %s, temporal intersect: %s",
|
|
230
|
+
spatial_intersect,
|
|
231
|
+
temporal_intersect,
|
|
232
|
+
)
|
|
233
|
+
return spatial_intersect and temporal_intersect
|
|
234
|
+
else:
|
|
235
|
+
logger.debug("spatial intersect: %s", spatial_intersect)
|
|
236
|
+
return spatial_intersect
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Callable, Dict, Generator, List, Optional, Set, Union
|
|
4
|
+
|
|
5
|
+
from mapchete.io.vector import fiona_open
|
|
6
|
+
from mapchete.path import MPath, MPathLike
|
|
7
|
+
from mapchete.types import Bounds, BoundsLike
|
|
8
|
+
from pystac.collection import Collection
|
|
9
|
+
from pystac.item import Item
|
|
10
|
+
from shapely.errors import GEOSException
|
|
11
|
+
from shapely.geometry import shape
|
|
12
|
+
from shapely.geometry.base import BaseGeometry
|
|
13
|
+
|
|
14
|
+
from mapchete_eo.exceptions import ItemGeometryError
|
|
15
|
+
from mapchete_eo.product import blacklist_products
|
|
16
|
+
from mapchete_eo.search.base import (
|
|
17
|
+
CatalogSearcher,
|
|
18
|
+
StaticCatalogWriterMixin,
|
|
19
|
+
filter_items,
|
|
20
|
+
)
|
|
21
|
+
from mapchete_eo.search.config import UTMSearchConfig
|
|
22
|
+
from mapchete_eo.search.s2_mgrs import S2Tile, s2_tiles_from_bounds
|
|
23
|
+
from mapchete_eo.settings import mapchete_eo_settings
|
|
24
|
+
from mapchete_eo.time import day_range, to_datetime
|
|
25
|
+
from mapchete_eo.types import TimeRange
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UTMSearchCatalog(StaticCatalogWriterMixin, CatalogSearcher):
|
|
31
|
+
endpoint: str
|
|
32
|
+
id: str
|
|
33
|
+
day_subdir_schema: str
|
|
34
|
+
stac_json_endswith: str
|
|
35
|
+
description: str
|
|
36
|
+
stac_extensions: List[str]
|
|
37
|
+
blacklist: Set[str] = (
|
|
38
|
+
blacklist_products(mapchete_eo_settings.blacklist)
|
|
39
|
+
if mapchete_eo_settings.blacklist
|
|
40
|
+
else set()
|
|
41
|
+
)
|
|
42
|
+
config_cls = UTMSearchConfig
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
endpoint: Optional[MPathLike] = None,
|
|
47
|
+
collections: List[str] = [],
|
|
48
|
+
stac_item_modifiers: Optional[List[Callable[[Item], Item]]] = None,
|
|
49
|
+
):
|
|
50
|
+
self.endpoint = endpoint or self.endpoint
|
|
51
|
+
if len(collections) == 0: # pragma: no cover
|
|
52
|
+
raise ValueError("no collections provided")
|
|
53
|
+
self.collections = collections
|
|
54
|
+
self.eo_bands = self._eo_bands()
|
|
55
|
+
self.stac_item_modifiers = stac_item_modifiers
|
|
56
|
+
|
|
57
|
+
def search(
|
|
58
|
+
self,
|
|
59
|
+
time: Optional[Union[TimeRange, List[TimeRange]]] = None,
|
|
60
|
+
bounds: Optional[BoundsLike] = None,
|
|
61
|
+
area: Optional[BaseGeometry] = None,
|
|
62
|
+
search_kwargs: Optional[Dict[str, Any]] = None,
|
|
63
|
+
) -> Generator[Item, None, None]:
|
|
64
|
+
config = self.config_cls(**search_kwargs or {})
|
|
65
|
+
if bounds:
|
|
66
|
+
bounds = Bounds.from_inp(bounds)
|
|
67
|
+
|
|
68
|
+
for item in filter_items(
|
|
69
|
+
self._raw_search(time=time, bounds=bounds, area=area),
|
|
70
|
+
max_cloud_cover=config.max_cloud_cover,
|
|
71
|
+
):
|
|
72
|
+
yield item
|
|
73
|
+
|
|
74
|
+
def _raw_search(
|
|
75
|
+
self,
|
|
76
|
+
time: Optional[Union[TimeRange, List[TimeRange]]] = None,
|
|
77
|
+
bounds: Optional[Bounds] = None,
|
|
78
|
+
area: Optional[BaseGeometry] = None,
|
|
79
|
+
config: UTMSearchConfig = UTMSearchConfig(),
|
|
80
|
+
) -> Generator[Item, None, None]:
|
|
81
|
+
if time is None:
|
|
82
|
+
raise ValueError("time must be given")
|
|
83
|
+
if area is not None and area.is_empty:
|
|
84
|
+
return
|
|
85
|
+
if area is not None:
|
|
86
|
+
area = area
|
|
87
|
+
bounds = Bounds.from_inp(area)
|
|
88
|
+
elif bounds is not None:
|
|
89
|
+
bounds = Bounds.from_inp(bounds)
|
|
90
|
+
area = shape(bounds)
|
|
91
|
+
for time_range in time if isinstance(time, list) else [time]:
|
|
92
|
+
start_time = (
|
|
93
|
+
time_range.start
|
|
94
|
+
if isinstance(time_range.start, datetime.date)
|
|
95
|
+
else datetime.datetime.strptime(time_range.start, "%Y-%m-%d")
|
|
96
|
+
)
|
|
97
|
+
end_time = (
|
|
98
|
+
time_range.end
|
|
99
|
+
if isinstance(time_range.end, datetime.date)
|
|
100
|
+
else datetime.datetime.strptime(time_range.end, "%Y-%m-%d")
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
logger.debug(
|
|
104
|
+
"determine items from %s to %s over %s...",
|
|
105
|
+
start_time,
|
|
106
|
+
end_time,
|
|
107
|
+
bounds,
|
|
108
|
+
)
|
|
109
|
+
if config.search_index:
|
|
110
|
+
logger.debug(
|
|
111
|
+
"use existing search index at %s", str(config.search_index)
|
|
112
|
+
)
|
|
113
|
+
for item in items_from_static_index(
|
|
114
|
+
bounds=bounds,
|
|
115
|
+
start_time=start_time,
|
|
116
|
+
end_time=end_time,
|
|
117
|
+
index_path=config.search_index,
|
|
118
|
+
):
|
|
119
|
+
try:
|
|
120
|
+
item_path = item.get_self_href()
|
|
121
|
+
if item_path in self.blacklist: # pragma: no cover
|
|
122
|
+
logger.debug(
|
|
123
|
+
"item %s found in blacklist and skipping", item_path
|
|
124
|
+
)
|
|
125
|
+
elif area.intersects(shape(item.geometry)):
|
|
126
|
+
yield item
|
|
127
|
+
except GEOSException as exc:
|
|
128
|
+
raise ItemGeometryError(
|
|
129
|
+
f"item {item.get_self_href()} geometry could not be resolved: {str(exc)}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
else:
|
|
133
|
+
logger.debug("using dumb ls directory search at %s", str(self.endpoint))
|
|
134
|
+
for item in items_from_directories(
|
|
135
|
+
bounds=bounds,
|
|
136
|
+
start_time=start_time,
|
|
137
|
+
end_time=end_time,
|
|
138
|
+
endpoint=self.endpoint,
|
|
139
|
+
day_subdir_schema=self.day_subdir_schema,
|
|
140
|
+
stac_json_endswith=self.stac_json_endswith,
|
|
141
|
+
):
|
|
142
|
+
item_path = item.get_self_href()
|
|
143
|
+
if item_path in self.blacklist: # pragma: no cover
|
|
144
|
+
logger.debug(
|
|
145
|
+
"item %s found in blacklist and skipping", item_path
|
|
146
|
+
)
|
|
147
|
+
elif area.intersects(shape(item.geometry)):
|
|
148
|
+
yield item
|
|
149
|
+
|
|
150
|
+
def _eo_bands(self) -> list:
|
|
151
|
+
for collection_name in self.collections:
|
|
152
|
+
for (
|
|
153
|
+
collection_properties
|
|
154
|
+
) in UTMSearchConfig().sinergise_aws_collections.values():
|
|
155
|
+
if collection_properties["id"] == collection_name:
|
|
156
|
+
collection = Collection.from_dict(
|
|
157
|
+
collection_properties["path"].read_json()
|
|
158
|
+
)
|
|
159
|
+
if collection:
|
|
160
|
+
summary = collection.summaries.to_dict()
|
|
161
|
+
if "eo:bands" in summary:
|
|
162
|
+
return summary["eo:bands"]
|
|
163
|
+
else:
|
|
164
|
+
raise ValueError(f"cannot find collection {collection}")
|
|
165
|
+
else:
|
|
166
|
+
logger.debug(
|
|
167
|
+
"cannot find eo:bands definition from collections %s",
|
|
168
|
+
self.collections,
|
|
169
|
+
)
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
def get_collections(self):
|
|
173
|
+
"""
|
|
174
|
+
yeild transformed collection from:
|
|
175
|
+
https://sentinel-s2-l2a-stac.s3.amazonaws.com/sentinel-s2-l2a.json,
|
|
176
|
+
or https://sentinel-s2-l1c-stac.s3.amazonaws.com/sentinel-s2-l1c.json,
|
|
177
|
+
etc.
|
|
178
|
+
"""
|
|
179
|
+
for collection_properties in self.config.sinergise_aws_collections.values():
|
|
180
|
+
collection = Collection.from_dict(collection_properties["path"].read_json())
|
|
181
|
+
for collection_name in self.collections:
|
|
182
|
+
if collection_name == collection.id:
|
|
183
|
+
yield collection
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def items_from_static_index(
|
|
187
|
+
bounds: Bounds,
|
|
188
|
+
start_time: Union[datetime.datetime, datetime.date],
|
|
189
|
+
end_time: Union[datetime.datetime, datetime.date],
|
|
190
|
+
index_path: MPathLike,
|
|
191
|
+
) -> Generator[Item, None, None]:
|
|
192
|
+
index_path = MPath.from_inp(index_path)
|
|
193
|
+
|
|
194
|
+
start_time = to_datetime(start_time)
|
|
195
|
+
# add day at end_time to include last day
|
|
196
|
+
end_time = to_datetime(end_time + datetime.timedelta(days=1))
|
|
197
|
+
|
|
198
|
+
# open index and determine which S2Tiles are covered
|
|
199
|
+
with fiona_open(index_path) as index:
|
|
200
|
+
# look at entries in every S2Tile and match with timestamp
|
|
201
|
+
for s2tile_feature in index.filter(bbox=bounds):
|
|
202
|
+
with fiona_open(
|
|
203
|
+
index_path.parent / s2tile_feature.properties["path"]
|
|
204
|
+
) as s2tile:
|
|
205
|
+
for item_feature in s2tile.filter(bbox=bounds):
|
|
206
|
+
# remove timezone info in order to compare with start_time and end_time
|
|
207
|
+
timestamp = to_datetime(
|
|
208
|
+
item_feature.properties["datetime"]
|
|
209
|
+
).replace(tzinfo=None)
|
|
210
|
+
|
|
211
|
+
if start_time <= timestamp <= end_time:
|
|
212
|
+
yield Item.from_dict(
|
|
213
|
+
MPath.from_inp(item_feature.properties["path"]).read_json()
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def items_from_directories(
|
|
218
|
+
bounds: Bounds,
|
|
219
|
+
start_time: Union[datetime.datetime, datetime.date],
|
|
220
|
+
end_time: Union[datetime.datetime, datetime.date],
|
|
221
|
+
endpoint: MPathLike,
|
|
222
|
+
day_subdir_schema: str = "{year}/{month:02d}/{day:02d}",
|
|
223
|
+
stac_json_endswith: str = "T{tile_id}.json",
|
|
224
|
+
) -> Generator[Item, None, None]:
|
|
225
|
+
# get Sentinel-2 tiles over given bounds
|
|
226
|
+
s2_tiles = s2_tiles_from_bounds(*bounds)
|
|
227
|
+
|
|
228
|
+
# for each day within time range, look for tiles
|
|
229
|
+
for day in day_range(start_date=start_time, end_date=end_time):
|
|
230
|
+
day_path = MPath.from_inp(endpoint) / day_subdir_schema.format(
|
|
231
|
+
year=day.year, month=day.month, day=day.day
|
|
232
|
+
)
|
|
233
|
+
for item in find_items(
|
|
234
|
+
day_path,
|
|
235
|
+
s2_tiles,
|
|
236
|
+
product_endswith=stac_json_endswith,
|
|
237
|
+
):
|
|
238
|
+
yield item
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def find_items(
|
|
242
|
+
path: MPath,
|
|
243
|
+
s2_tiles: List[S2Tile],
|
|
244
|
+
product_endswith: str = "T{tile_id}.json",
|
|
245
|
+
) -> Generator[Item, None, None]:
|
|
246
|
+
match_parts = tuple(
|
|
247
|
+
product_endswith.format(tile_id=s2_tile.tile_id) for s2_tile in s2_tiles
|
|
248
|
+
)
|
|
249
|
+
for product_path in path.ls():
|
|
250
|
+
if product_path.endswith(match_parts):
|
|
251
|
+
yield Item.from_file(product_path)
|
mapchete_eo/settings.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from mapchete.path import MPath, MPathLike
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
from rasterio.crs import CRS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Settings(BaseSettings):
|
|
9
|
+
"""
|
|
10
|
+
Combine default settings with env variables.
|
|
11
|
+
|
|
12
|
+
All settings can be set in the environment by adding the 'MHUB_' prefix
|
|
13
|
+
and the settings in uppercase, e.g. MAPCHETE_EO_.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
default_cache_location: MPathLike = MPath("s3://eox-mhub-cache/")
|
|
17
|
+
default_catalog_crs: CRS = CRS.from_epsg(4326)
|
|
18
|
+
blacklist: Optional[MPathLike] = None
|
|
19
|
+
|
|
20
|
+
# read from environment
|
|
21
|
+
model_config = SettingsConfigDict(env_prefix="MAPCHETE_EO_")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
mapchete_eo_settings: Settings = Settings()
|
mapchete_eo/sort.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module holds all code required to sort products or slices.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Callable, List, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from mapchete_eo.protocols import DateTimeProtocol
|
|
10
|
+
from mapchete_eo.time import timedelta, to_datetime
|
|
11
|
+
from mapchete_eo.types import DateTimeLike
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SortMethodConfig(BaseModel):
|
|
15
|
+
func: Callable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sort_objects_by_target_date(
|
|
19
|
+
objects: List[DateTimeProtocol],
|
|
20
|
+
target_date: Optional[DateTimeLike] = None,
|
|
21
|
+
reverse: bool = False,
|
|
22
|
+
**kwargs,
|
|
23
|
+
) -> List[DateTimeProtocol]:
|
|
24
|
+
"""
|
|
25
|
+
Return sorted list of onjects according to their distance to the target_date.
|
|
26
|
+
|
|
27
|
+
Default for target date is the middle between the objects start date and end date.
|
|
28
|
+
"""
|
|
29
|
+
if len(objects) == 0:
|
|
30
|
+
return objects
|
|
31
|
+
|
|
32
|
+
if target_date is None:
|
|
33
|
+
time_list = [to_datetime(object.datetime) for object in objects]
|
|
34
|
+
start_time = min(time_list)
|
|
35
|
+
end_time = max(time_list)
|
|
36
|
+
target_datetime = start_time + (end_time - start_time) / 2
|
|
37
|
+
else:
|
|
38
|
+
target_datetime = to_datetime(target_date)
|
|
39
|
+
|
|
40
|
+
objects.sort(key=lambda x: timedelta(x.datetime, target_datetime), reverse=reverse)
|
|
41
|
+
|
|
42
|
+
return objects
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TargetDateSort(SortMethodConfig):
|
|
46
|
+
func: Callable = sort_objects_by_target_date
|
|
47
|
+
target_date: Optional[DateTimeLike] = None
|
|
48
|
+
reverse: bool = False
|
mapchete_eo/time.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from typing import List, Tuple, Union
|
|
3
|
+
|
|
4
|
+
import dateutil.parser
|
|
5
|
+
|
|
6
|
+
from mapchete_eo.types import DateTimeLike
|
|
7
|
+
|
|
8
|
+
_time = {"min": datetime.datetime.min.time(), "max": datetime.datetime.max.time()}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def to_datetime(t: DateTimeLike, append_time="min") -> datetime.datetime:
|
|
12
|
+
"""Convert input into datetime object."""
|
|
13
|
+
if isinstance(t, datetime.datetime):
|
|
14
|
+
return t
|
|
15
|
+
elif isinstance(t, datetime.date):
|
|
16
|
+
return datetime.datetime.combine(t, _time[append_time])
|
|
17
|
+
else:
|
|
18
|
+
return dateutil.parser.parse(t)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def time_ranges_intersect(
|
|
22
|
+
t1: Tuple[DateTimeLike, DateTimeLike],
|
|
23
|
+
t2: Tuple[DateTimeLike, DateTimeLike],
|
|
24
|
+
) -> bool:
|
|
25
|
+
"""Check if two time ranges intersect."""
|
|
26
|
+
t1_start = to_datetime(t1[0], "min").replace(tzinfo=None)
|
|
27
|
+
t1_end = to_datetime(t1[1], "max").replace(tzinfo=None)
|
|
28
|
+
t2_start = to_datetime(t2[0], "min").replace(tzinfo=None)
|
|
29
|
+
t2_end = to_datetime(t2[1], "max").replace(tzinfo=None)
|
|
30
|
+
return (t1_start <= t2_start <= t1_end) or (t2_start <= t1_start <= t2_end)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def timedelta(date: DateTimeLike, target: DateTimeLike, seconds: bool = True):
|
|
34
|
+
"""Return difference between two time stamps."""
|
|
35
|
+
delta = to_datetime(date) - to_datetime(target)
|
|
36
|
+
if seconds:
|
|
37
|
+
return abs(delta.total_seconds())
|
|
38
|
+
else:
|
|
39
|
+
return abs(delta.days)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def day_range(
|
|
43
|
+
start_date: Union[datetime.datetime, datetime.date],
|
|
44
|
+
end_date: Union[datetime.datetime, datetime.date],
|
|
45
|
+
) -> List[datetime.date]:
|
|
46
|
+
start_date = (
|
|
47
|
+
start_date.date() if isinstance(start_date, datetime.datetime) else start_date
|
|
48
|
+
)
|
|
49
|
+
end_date = end_date.date() if isinstance(end_date, datetime.datetime) else end_date
|
|
50
|
+
return [
|
|
51
|
+
start_date + datetime.timedelta(n)
|
|
52
|
+
for n in range(int((end_date - start_date).days) + 1)
|
|
53
|
+
]
|
mapchete_eo/types.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from pydantic import PositiveInt
|
|
9
|
+
from pystac import Asset
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GeodataType(str, Enum):
|
|
13
|
+
vector = "vector"
|
|
14
|
+
raster = "raster"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MergeMethod(str, Enum):
|
|
18
|
+
"""
|
|
19
|
+
Available methods to merge assets from multiple items.
|
|
20
|
+
|
|
21
|
+
first: first pixel value from the list is returned
|
|
22
|
+
average: average value from the list is returned
|
|
23
|
+
all: any consecutive value is added and all collected are returned
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
first = "first"
|
|
27
|
+
average = "average"
|
|
28
|
+
all = "all"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DateLike = Union[str, datetime.date]
|
|
32
|
+
DateTimeLike = Union[DateLike, datetime.datetime]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class BandLocation:
|
|
37
|
+
"""A class representing the location of a specific band."""
|
|
38
|
+
|
|
39
|
+
asset_name: str
|
|
40
|
+
band_index: PositiveInt = 1
|
|
41
|
+
nodataval: float = 0
|
|
42
|
+
roles: List[str] = field(default_factory=list)
|
|
43
|
+
eo_band_name: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def from_asset(
|
|
47
|
+
asset: Asset,
|
|
48
|
+
name: str,
|
|
49
|
+
band_index: PositiveInt,
|
|
50
|
+
) -> BandLocation:
|
|
51
|
+
try:
|
|
52
|
+
bands_info = asset.extra_fields.get(
|
|
53
|
+
"eo:bands", asset.extra_fields.get("bands", [])
|
|
54
|
+
)
|
|
55
|
+
band_info = bands_info[band_index - 1]
|
|
56
|
+
eo_band_name = band_info.get("eo:common_name", band_info.get("name"))
|
|
57
|
+
except KeyError:
|
|
58
|
+
eo_band_name = None
|
|
59
|
+
return BandLocation(
|
|
60
|
+
asset_name=name,
|
|
61
|
+
band_index=band_index,
|
|
62
|
+
nodataval=asset.extra_fields.get("nodata", 0),
|
|
63
|
+
roles=asset.roles or [],
|
|
64
|
+
eo_band_name=eo_band_name,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class TimeRange:
|
|
70
|
+
"""A class handling time ranges."""
|
|
71
|
+
|
|
72
|
+
start: DateTimeLike
|
|
73
|
+
end: DateTimeLike
|