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,314 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from itertools import product
|
|
7
|
+
from typing import List, Literal, Optional, Tuple, Union
|
|
8
|
+
|
|
9
|
+
from mapchete.geometry import reproject_geometry
|
|
10
|
+
from mapchete.types import Bounds
|
|
11
|
+
from rasterio.crs import CRS
|
|
12
|
+
from shapely import prepare
|
|
13
|
+
from shapely.geometry import box, mapping, shape
|
|
14
|
+
from shapely.geometry.base import BaseGeometry
|
|
15
|
+
|
|
16
|
+
from mapchete_eo.geometry import (
|
|
17
|
+
bounds_to_geom,
|
|
18
|
+
repair_antimeridian_geometry,
|
|
19
|
+
transform_to_latlon,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
LATLON_LEFT = -180
|
|
23
|
+
LATLON_RIGHT = 180
|
|
24
|
+
LATLON_WIDTH = LATLON_RIGHT - LATLON_LEFT
|
|
25
|
+
LATLON_WIDTH_OFFSET = LATLON_WIDTH / 2
|
|
26
|
+
MIN_LATITUDE = -80.0
|
|
27
|
+
MAX_LATITUDE = 84
|
|
28
|
+
LATLON_HEIGHT = MAX_LATITUDE - MIN_LATITUDE
|
|
29
|
+
LATLON_HEIGHT_OFFSET = -MIN_LATITUDE
|
|
30
|
+
|
|
31
|
+
# width in degrees
|
|
32
|
+
UTM_ZONE_WIDTH = 6
|
|
33
|
+
UTM_ZONES = [f"{ii:02d}" for ii in range(1, LATLON_WIDTH // UTM_ZONE_WIDTH + 1)]
|
|
34
|
+
|
|
35
|
+
# NOTE: each latitude band is 8° high except the most northern one ("X") is 12°
|
|
36
|
+
LATITUDE_BAND_HEIGHT = 8
|
|
37
|
+
LATITUDE_BANDS = list("CDEFGHJKLMNPQRSTUVWX")
|
|
38
|
+
|
|
39
|
+
# column names seem to span over three UTM zones (8 per zone)
|
|
40
|
+
COLUMNS_PER_ZONE = 8
|
|
41
|
+
SQUARE_COLUMNS = list("ABCDEFGHJKLMNPQRSTUVWXYZ")
|
|
42
|
+
|
|
43
|
+
# rows are weird. zone 01 starts at -80° with "M", then zone 02 with "S", then zone 03 with "M" and so on
|
|
44
|
+
# SQUARE_ROW_START = ["M", "S"]
|
|
45
|
+
# SQUARE_ROW_START = ["B", "G"] # manual offset so the naming starts on the South Pole
|
|
46
|
+
SQUARE_ROW_START = ["A", "F"]
|
|
47
|
+
SQUARE_ROWS = list("ABCDEFGHJKLMNPQRSTUV")
|
|
48
|
+
|
|
49
|
+
# 100 x 100 km
|
|
50
|
+
TILE_WIDTH_M = 100_000
|
|
51
|
+
TILE_HEIGHT_M = 100_000
|
|
52
|
+
# overlap for bottom and right
|
|
53
|
+
TILE_OVERLAP_M = 9_800
|
|
54
|
+
|
|
55
|
+
# source point of UTM zone from where tiles start
|
|
56
|
+
# UTM_TILE_SOURCE_LEFT = 99_960.0
|
|
57
|
+
UTM_TILE_SOURCE_LEFT = 100_000
|
|
58
|
+
UTM_TILE_SOURCE_BOTTOM = 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InvalidMGRSSquare(Exception):
|
|
62
|
+
"""Raised when an invalid square index has been given"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class MGRSCell:
|
|
67
|
+
utm_zone: str
|
|
68
|
+
latitude_band: str
|
|
69
|
+
|
|
70
|
+
def tiles(self) -> List[S2Tile]:
|
|
71
|
+
# TODO: this is incredibly slow
|
|
72
|
+
def tiles_generator():
|
|
73
|
+
for column_index, row_index in self._global_square_indexes:
|
|
74
|
+
tile = self.tile(
|
|
75
|
+
grid_square=self._global_square_index_to_grid_square(
|
|
76
|
+
column_index, row_index
|
|
77
|
+
),
|
|
78
|
+
column_index=column_index,
|
|
79
|
+
row_index=row_index,
|
|
80
|
+
)
|
|
81
|
+
if tile.latlon_geometry.intersects(self.latlon_geometry):
|
|
82
|
+
yield tile
|
|
83
|
+
|
|
84
|
+
return list(tiles_generator())
|
|
85
|
+
|
|
86
|
+
def tile(
|
|
87
|
+
self,
|
|
88
|
+
grid_square: str,
|
|
89
|
+
column_index: Optional[int] = None,
|
|
90
|
+
row_index: Optional[int] = None,
|
|
91
|
+
) -> S2Tile:
|
|
92
|
+
if column_index is None or row_index is None:
|
|
93
|
+
for column_index, row_index in self._global_square_indexes:
|
|
94
|
+
if (
|
|
95
|
+
self._global_square_index_to_grid_square(column_index, row_index)
|
|
96
|
+
== grid_square
|
|
97
|
+
):
|
|
98
|
+
break
|
|
99
|
+
else: # pragma: no cover
|
|
100
|
+
raise InvalidMGRSSquare(
|
|
101
|
+
f"global square index could not be determined for {self.utm_zone}{self.latitude_band}{grid_square}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return S2Tile(
|
|
105
|
+
utm_zone=self.utm_zone,
|
|
106
|
+
latitude_band=self.latitude_band,
|
|
107
|
+
grid_square=grid_square,
|
|
108
|
+
global_column_index=column_index,
|
|
109
|
+
global_row_index=row_index,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@cached_property
|
|
113
|
+
def _global_square_indexes(self) -> List[Tuple[int, int]]:
|
|
114
|
+
"""Return global row/column indexes of squares within MGRSCell."""
|
|
115
|
+
|
|
116
|
+
# reproject cell bounds to UTM
|
|
117
|
+
utm_bounds = Bounds(
|
|
118
|
+
*reproject_geometry(
|
|
119
|
+
self.latlon_geometry, src_crs="EPSG:4326", dst_crs=self.crs
|
|
120
|
+
).bounds
|
|
121
|
+
)
|
|
122
|
+
# get min/max column index values based on tile grid source and tile width/height
|
|
123
|
+
min_col = UTM_ZONES.index(self.utm_zone) * COLUMNS_PER_ZONE
|
|
124
|
+
max_col = min_col + COLUMNS_PER_ZONE
|
|
125
|
+
|
|
126
|
+
# count rows from UTM zone bottom
|
|
127
|
+
min_row = math.floor(
|
|
128
|
+
(utm_bounds.bottom - UTM_TILE_SOURCE_BOTTOM) / TILE_HEIGHT_M
|
|
129
|
+
)
|
|
130
|
+
max_row = math.floor((utm_bounds.top - UTM_TILE_SOURCE_BOTTOM) / TILE_HEIGHT_M)
|
|
131
|
+
return list(product(range(min_col, max_col + 1), range(min_row, max_row + 1)))
|
|
132
|
+
|
|
133
|
+
def _global_square_index_to_grid_square(
|
|
134
|
+
self, column_index: int, row_index: int
|
|
135
|
+
) -> str:
|
|
136
|
+
# determine row offset (alternating rows at bottom start at "A" or "F")
|
|
137
|
+
start_row = SQUARE_ROW_START[
|
|
138
|
+
UTM_ZONES.index(self.utm_zone) % len(SQUARE_ROW_START)
|
|
139
|
+
]
|
|
140
|
+
start_row_idx = SQUARE_ROWS.index(start_row)
|
|
141
|
+
|
|
142
|
+
square_column_idx = column_index % len(SQUARE_COLUMNS)
|
|
143
|
+
square_row_idx = (row_index + start_row_idx) % len(SQUARE_ROWS)
|
|
144
|
+
|
|
145
|
+
return f"{SQUARE_COLUMNS[square_column_idx]}{SQUARE_ROWS[square_row_idx]}"
|
|
146
|
+
|
|
147
|
+
@cached_property
|
|
148
|
+
def latlon_bounds(self) -> Bounds:
|
|
149
|
+
left = LATLON_LEFT + UTM_ZONE_WIDTH * UTM_ZONES.index(self.utm_zone)
|
|
150
|
+
bottom = MIN_LATITUDE + LATITUDE_BAND_HEIGHT * LATITUDE_BANDS.index(
|
|
151
|
+
self.latitude_band
|
|
152
|
+
)
|
|
153
|
+
right = left + UTM_ZONE_WIDTH
|
|
154
|
+
top = bottom + (12 if self.latitude_band == "X" else LATITUDE_BAND_HEIGHT)
|
|
155
|
+
return Bounds(left, bottom, right, top)
|
|
156
|
+
|
|
157
|
+
@cached_property
|
|
158
|
+
def crs(self) -> CRS:
|
|
159
|
+
# 7 for south, 6 for north
|
|
160
|
+
hemisphere_code = "7" if self.hemisphere == "S" else "6"
|
|
161
|
+
return CRS.from_string(f"EPSG:32{hemisphere_code}{self.utm_zone}")
|
|
162
|
+
|
|
163
|
+
@cached_property
|
|
164
|
+
def latlon_geometry(self) -> BaseGeometry:
|
|
165
|
+
return shape(self.latlon_bounds)
|
|
166
|
+
|
|
167
|
+
@cached_property
|
|
168
|
+
def hemisphere(self) -> Union[Literal["S"], Literal["N"]]:
|
|
169
|
+
return "S" if self.latitude_band < "N" else "N"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class S2Tile:
|
|
174
|
+
utm_zone: str
|
|
175
|
+
latitude_band: str
|
|
176
|
+
grid_square: str
|
|
177
|
+
global_column_index: Optional[int] = None
|
|
178
|
+
global_row_index: Optional[int] = None
|
|
179
|
+
|
|
180
|
+
@cached_property
|
|
181
|
+
def crs(self) -> CRS:
|
|
182
|
+
# 7 for south, 6 for north
|
|
183
|
+
hemisphere = "7" if self.latitude_band < "N" else "6"
|
|
184
|
+
return CRS.from_string(f"EPSG:32{hemisphere}{self.utm_zone}")
|
|
185
|
+
|
|
186
|
+
@cached_property
|
|
187
|
+
def bounds(self) -> Bounds:
|
|
188
|
+
base_bottom = UTM_TILE_SOURCE_BOTTOM + self.square_row * TILE_WIDTH_M
|
|
189
|
+
left = UTM_TILE_SOURCE_LEFT + self.square_column * TILE_WIDTH_M
|
|
190
|
+
bottom = base_bottom - TILE_OVERLAP_M
|
|
191
|
+
right = left + TILE_WIDTH_M + TILE_OVERLAP_M
|
|
192
|
+
top = base_bottom + TILE_HEIGHT_M
|
|
193
|
+
return Bounds(left, bottom, right, top)
|
|
194
|
+
|
|
195
|
+
@cached_property
|
|
196
|
+
def __geo_interface__(self) -> dict:
|
|
197
|
+
return mapping(box(*self.bounds))
|
|
198
|
+
|
|
199
|
+
@cached_property
|
|
200
|
+
def mgrs_cell(self) -> MGRSCell:
|
|
201
|
+
return MGRSCell(self.utm_zone, self.latitude_band)
|
|
202
|
+
|
|
203
|
+
@cached_property
|
|
204
|
+
def latlon_geometry(self) -> BaseGeometry:
|
|
205
|
+
# return repair_antimeridian_geometry(shape(self.latlon_bounds))
|
|
206
|
+
return repair_antimeridian_geometry(transform_to_latlon(shape(self), self.crs))
|
|
207
|
+
|
|
208
|
+
@cached_property
|
|
209
|
+
def latlon_bounds(self) -> Bounds:
|
|
210
|
+
return Bounds.from_inp(self.latlon_geometry)
|
|
211
|
+
|
|
212
|
+
@cached_property
|
|
213
|
+
def tile_id(self) -> str:
|
|
214
|
+
return f"{self.utm_zone}{self.latitude_band}{self.grid_square}"
|
|
215
|
+
|
|
216
|
+
@cached_property
|
|
217
|
+
def square_column(self) -> int:
|
|
218
|
+
if self.global_column_index is None:
|
|
219
|
+
return self._global_square_idx[0] % COLUMNS_PER_ZONE
|
|
220
|
+
return self.global_column_index % COLUMNS_PER_ZONE
|
|
221
|
+
|
|
222
|
+
@cached_property
|
|
223
|
+
def square_row(self) -> int:
|
|
224
|
+
if self.global_row_index is None:
|
|
225
|
+
return self._global_square_idx[1]
|
|
226
|
+
return self.global_row_index
|
|
227
|
+
|
|
228
|
+
@cached_property
|
|
229
|
+
def _global_square_idx(self) -> Tuple[int, int]:
|
|
230
|
+
"""
|
|
231
|
+
Square index based on bottom-left corner of global AOI.
|
|
232
|
+
"""
|
|
233
|
+
for column_index, row_index in self.mgrs_cell._global_square_indexes:
|
|
234
|
+
if (
|
|
235
|
+
self.mgrs_cell._global_square_index_to_grid_square(
|
|
236
|
+
column_index, row_index
|
|
237
|
+
)
|
|
238
|
+
== self.grid_square
|
|
239
|
+
):
|
|
240
|
+
return (column_index, row_index)
|
|
241
|
+
else: # pragma: no cover
|
|
242
|
+
raise InvalidMGRSSquare(
|
|
243
|
+
f"global square index could not be determined for {self.utm_zone}{self.latitude_band}{self.grid_square}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
@cached_property
|
|
247
|
+
def hemisphere(self) -> Union[Literal["S"], Literal["N"]]:
|
|
248
|
+
return "S" if self.latitude_band < "N" else "N"
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
def from_tile_id(tile_id: str) -> S2Tile:
|
|
252
|
+
tile_id = tile_id.lstrip("T")
|
|
253
|
+
utm_zone = tile_id[:2]
|
|
254
|
+
latitude_band = tile_id[2]
|
|
255
|
+
grid_square = tile_id[3:]
|
|
256
|
+
try:
|
|
257
|
+
int(utm_zone)
|
|
258
|
+
except Exception:
|
|
259
|
+
raise ValueError(f"invalid UTM zone given: {utm_zone}")
|
|
260
|
+
|
|
261
|
+
return MGRSCell(utm_zone, latitude_band).tile(grid_square)
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def from_grid_code(grid_code: str) -> S2Tile:
|
|
265
|
+
return S2Tile.from_tile_id(grid_code.lstrip("MGRS-"))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def s2_tiles_from_bounds(
|
|
269
|
+
left: float, bottom: float, right: float, top: float
|
|
270
|
+
) -> List[S2Tile]:
|
|
271
|
+
bounds = Bounds(left, bottom, right, top)
|
|
272
|
+
|
|
273
|
+
# determine zones in eastern-western direction
|
|
274
|
+
min_zone_idx = math.floor((left + LATLON_WIDTH_OFFSET) / UTM_ZONE_WIDTH)
|
|
275
|
+
max_zone_idx = math.floor((right + LATLON_WIDTH_OFFSET) / UTM_ZONE_WIDTH)
|
|
276
|
+
|
|
277
|
+
min_latitude_band_idx = math.floor(
|
|
278
|
+
(bottom + LATLON_HEIGHT_OFFSET) / LATITUDE_BAND_HEIGHT
|
|
279
|
+
)
|
|
280
|
+
max_latitude_band_idx = min(
|
|
281
|
+
[
|
|
282
|
+
math.floor((top + LATLON_HEIGHT_OFFSET) / LATITUDE_BAND_HEIGHT),
|
|
283
|
+
len(LATITUDE_BANDS),
|
|
284
|
+
]
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# in order to also get overlapping tiles from other UTM cells, we also
|
|
288
|
+
# query the neighbors:
|
|
289
|
+
min_zone_idx -= 1
|
|
290
|
+
max_zone_idx += 1
|
|
291
|
+
min_latitude_band_idx -= 1
|
|
292
|
+
max_latitude_band_idx += 1
|
|
293
|
+
|
|
294
|
+
aoi = bounds_to_geom(bounds)
|
|
295
|
+
prepare(aoi)
|
|
296
|
+
|
|
297
|
+
def tiles_generator():
|
|
298
|
+
for utm_zone_idx in range(min_zone_idx, max_zone_idx + 1):
|
|
299
|
+
for latitude_band_idx in range(
|
|
300
|
+
# clamp latitude index to range of 0 and number of latitude bands
|
|
301
|
+
max(min_latitude_band_idx, 0),
|
|
302
|
+
min(max_latitude_band_idx + 1, len(LATITUDE_BANDS)),
|
|
303
|
+
):
|
|
304
|
+
cell = MGRSCell(
|
|
305
|
+
utm_zone=UTM_ZONES[utm_zone_idx % len(UTM_ZONES)],
|
|
306
|
+
latitude_band=LATITUDE_BANDS[latitude_band_idx],
|
|
307
|
+
)
|
|
308
|
+
for tile in cell.tiles():
|
|
309
|
+
# bounds check seems to be faster
|
|
310
|
+
# if aoi.intersects(box(*tile.latlon_bounds)):
|
|
311
|
+
if aoi.intersects(tile.latlon_geometry):
|
|
312
|
+
yield tile
|
|
313
|
+
|
|
314
|
+
return list(tiles_generator())
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from typing import Any, Callable, Dict, Generator, Iterator, List, Optional, Set, Union
|
|
5
|
+
|
|
6
|
+
from mapchete import Timer
|
|
7
|
+
from mapchete.path import MPathLike
|
|
8
|
+
from mapchete.tile import BufferedTilePyramid
|
|
9
|
+
from mapchete.types import Bounds, BoundsLike
|
|
10
|
+
from pystac import Item
|
|
11
|
+
from pystac_client import Client
|
|
12
|
+
from shapely.geometry import shape
|
|
13
|
+
from shapely.geometry.base import BaseGeometry
|
|
14
|
+
|
|
15
|
+
from mapchete_eo.product import blacklist_products
|
|
16
|
+
from mapchete_eo.search.base import CatalogSearcher, StaticCatalogWriterMixin
|
|
17
|
+
from mapchete_eo.search.config import StacSearchConfig
|
|
18
|
+
from mapchete_eo.settings import mapchete_eo_settings
|
|
19
|
+
from mapchete_eo.types import TimeRange
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class STACSearchCatalog(StaticCatalogWriterMixin, CatalogSearcher):
|
|
25
|
+
endpoint: str
|
|
26
|
+
blacklist: Set[str] = (
|
|
27
|
+
blacklist_products(mapchete_eo_settings.blacklist)
|
|
28
|
+
if mapchete_eo_settings.blacklist
|
|
29
|
+
else set()
|
|
30
|
+
)
|
|
31
|
+
config_cls = StacSearchConfig
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
collections: Optional[List[str]] = None,
|
|
36
|
+
stac_item_modifiers: Optional[List[Callable[[Item], Item]]] = None,
|
|
37
|
+
endpoint: Optional[MPathLike] = None,
|
|
38
|
+
):
|
|
39
|
+
if collections:
|
|
40
|
+
self.collections = collections
|
|
41
|
+
else: # pragma: no cover
|
|
42
|
+
raise ValueError("collections must be given")
|
|
43
|
+
self.client = Client.open(endpoint or self.endpoint)
|
|
44
|
+
self.id = self.client.id
|
|
45
|
+
self.description = self.client.description
|
|
46
|
+
self.stac_extensions = self.client.stac_extensions
|
|
47
|
+
self.eo_bands = self._eo_bands()
|
|
48
|
+
self.stac_item_modifiers = stac_item_modifiers
|
|
49
|
+
|
|
50
|
+
def search(
|
|
51
|
+
self,
|
|
52
|
+
time: Optional[Union[TimeRange, List[TimeRange]]] = None,
|
|
53
|
+
bounds: Optional[BoundsLike] = None,
|
|
54
|
+
area: Optional[BaseGeometry] = None,
|
|
55
|
+
search_kwargs: Optional[Dict[str, Any]] = None,
|
|
56
|
+
) -> Generator[Item, None, None]:
|
|
57
|
+
config = self.config_cls(**search_kwargs or {})
|
|
58
|
+
if bounds:
|
|
59
|
+
bounds = Bounds.from_inp(bounds)
|
|
60
|
+
if time is None: # pragma: no cover
|
|
61
|
+
raise ValueError("time must be set")
|
|
62
|
+
if area is None and bounds is None: # pragma: no cover
|
|
63
|
+
raise ValueError("either bounds or area have to be given")
|
|
64
|
+
|
|
65
|
+
if area is not None and area.is_empty: # pragma: no cover
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
def _searches():
|
|
69
|
+
for time_range in time if isinstance(time, list) else [time]:
|
|
70
|
+
search = self._search(
|
|
71
|
+
time_range=time_range, bounds=bounds, area=area, config=config
|
|
72
|
+
)
|
|
73
|
+
logger.debug("found %s products", search.matched())
|
|
74
|
+
matched = search.matched() or 0
|
|
75
|
+
if matched > config.catalog_chunk_threshold:
|
|
76
|
+
spatial_search_chunks = SpatialSearchChunks(
|
|
77
|
+
bounds=bounds,
|
|
78
|
+
area=area,
|
|
79
|
+
grid="geodetic",
|
|
80
|
+
zoom=config.catalog_chunk_zoom,
|
|
81
|
+
)
|
|
82
|
+
logger.debug(
|
|
83
|
+
"too many products (%s), query catalog in %s chunks",
|
|
84
|
+
matched,
|
|
85
|
+
len(spatial_search_chunks),
|
|
86
|
+
)
|
|
87
|
+
for counter, chunk_kwargs in enumerate(spatial_search_chunks, 1):
|
|
88
|
+
with Timer() as duration:
|
|
89
|
+
chunk_search = self._search(
|
|
90
|
+
time_range=time_range,
|
|
91
|
+
config=config,
|
|
92
|
+
**chunk_kwargs,
|
|
93
|
+
)
|
|
94
|
+
yield chunk_search
|
|
95
|
+
logger.debug(
|
|
96
|
+
"returned chunk %s/%s (%s items) in %s",
|
|
97
|
+
counter,
|
|
98
|
+
len(spatial_search_chunks),
|
|
99
|
+
chunk_search.matched(),
|
|
100
|
+
duration,
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
yield search
|
|
104
|
+
|
|
105
|
+
for search in _searches():
|
|
106
|
+
for count, item in enumerate(search.items(), 1):
|
|
107
|
+
item_path = item.get_self_href()
|
|
108
|
+
# logger.debug("item %s/%s ...", count, search.matched())
|
|
109
|
+
if item_path in self.blacklist: # pragma: no cover
|
|
110
|
+
logger.debug("item %s found in blacklist and skipping", item_path)
|
|
111
|
+
else:
|
|
112
|
+
yield item
|
|
113
|
+
|
|
114
|
+
def _eo_bands(self) -> List[str]:
|
|
115
|
+
for collection_name in self.collections:
|
|
116
|
+
collection = self.client.get_collection(collection_name)
|
|
117
|
+
if collection:
|
|
118
|
+
item_assets = collection.extra_fields.get("item_assets", {})
|
|
119
|
+
for v in item_assets.values():
|
|
120
|
+
if "eo:bands" in v and "data" in v.get("roles", []):
|
|
121
|
+
return ["eo:bands"]
|
|
122
|
+
else: # pragma: no cover
|
|
123
|
+
raise ValueError(f"cannot find collection {collection}")
|
|
124
|
+
else: # pragma: no cover
|
|
125
|
+
logger.debug("cannot find eo:bands definition from collections")
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
@cached_property
|
|
129
|
+
def default_search_params(self):
|
|
130
|
+
return {
|
|
131
|
+
"collections": self.collections,
|
|
132
|
+
"bbox": None,
|
|
133
|
+
"intersects": None,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def _search(
|
|
137
|
+
self,
|
|
138
|
+
time_range: Optional[TimeRange] = None,
|
|
139
|
+
bounds: Optional[Bounds] = None,
|
|
140
|
+
area: Optional[BaseGeometry] = None,
|
|
141
|
+
config: StacSearchConfig = StacSearchConfig(),
|
|
142
|
+
**kwargs,
|
|
143
|
+
):
|
|
144
|
+
if time_range is None: # pragma: no cover
|
|
145
|
+
raise ValueError("time_range not provided")
|
|
146
|
+
|
|
147
|
+
if bounds is not None:
|
|
148
|
+
if shape(bounds).is_empty: # pragma: no cover
|
|
149
|
+
raise ValueError("bounds empty")
|
|
150
|
+
kwargs.update(bbox=",".join(map(str, bounds)))
|
|
151
|
+
elif area is not None:
|
|
152
|
+
if area.is_empty: # pragma: no cover
|
|
153
|
+
raise ValueError("area empty")
|
|
154
|
+
kwargs.update(intersects=area)
|
|
155
|
+
|
|
156
|
+
start = (
|
|
157
|
+
time_range.start.date()
|
|
158
|
+
if isinstance(time_range.start, datetime)
|
|
159
|
+
else time_range.start
|
|
160
|
+
)
|
|
161
|
+
end = (
|
|
162
|
+
time_range.end.date()
|
|
163
|
+
if isinstance(time_range.end, datetime)
|
|
164
|
+
else time_range.end
|
|
165
|
+
)
|
|
166
|
+
search_params = dict(
|
|
167
|
+
self.default_search_params,
|
|
168
|
+
datetime=f"{start}/{end}",
|
|
169
|
+
query=[f"eo:cloud_cover<={config.max_cloud_cover}"],
|
|
170
|
+
**kwargs,
|
|
171
|
+
)
|
|
172
|
+
if (
|
|
173
|
+
bounds is None
|
|
174
|
+
and area is None
|
|
175
|
+
and kwargs.get("bbox", kwargs.get("intersects")) is None
|
|
176
|
+
): # pragma: no cover
|
|
177
|
+
raise ValueError("no bounds or area given")
|
|
178
|
+
logger.debug("query catalog using params: %s", search_params)
|
|
179
|
+
with Timer() as duration:
|
|
180
|
+
result = self.client.search(**search_params, limit=config.catalog_pagesize)
|
|
181
|
+
logger.debug("query took %s", str(duration))
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
def get_collections(self):
|
|
185
|
+
for collection_name in self.collections:
|
|
186
|
+
yield self.client.get_collection(collection_name)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class SpatialSearchChunks:
|
|
190
|
+
bounds: Bounds
|
|
191
|
+
area: BaseGeometry
|
|
192
|
+
search_kw: str
|
|
193
|
+
tile_pyramid: BufferedTilePyramid
|
|
194
|
+
zoom: int
|
|
195
|
+
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
bounds: Optional[BoundsLike] = None,
|
|
199
|
+
area: Optional[BaseGeometry] = None,
|
|
200
|
+
zoom: int = 6,
|
|
201
|
+
grid: str = "geodetic",
|
|
202
|
+
):
|
|
203
|
+
if bounds is not None:
|
|
204
|
+
self.bounds = Bounds.from_inp(bounds)
|
|
205
|
+
self.area = None
|
|
206
|
+
self.search_kw = "bbox"
|
|
207
|
+
elif area is not None:
|
|
208
|
+
self.bounds = None
|
|
209
|
+
self.area = area
|
|
210
|
+
self.search_kw = "intersects"
|
|
211
|
+
else: # pragma: no cover
|
|
212
|
+
raise ValueError("either area or bounds have to be given")
|
|
213
|
+
self.zoom = zoom
|
|
214
|
+
self.tile_pyramid = BufferedTilePyramid(grid)
|
|
215
|
+
|
|
216
|
+
@cached_property
|
|
217
|
+
def _chunks(self) -> List[Union[Bounds, BaseGeometry]]:
|
|
218
|
+
if self.bounds is not None:
|
|
219
|
+
bounds = self.bounds
|
|
220
|
+
# if bounds cross the antimeridian, snap them to CRS bouds
|
|
221
|
+
if self.bounds.left < self.tile_pyramid.left:
|
|
222
|
+
logger.warning("snap left bounds value back to CRS bounds")
|
|
223
|
+
bounds = Bounds(
|
|
224
|
+
self.tile_pyramid.left,
|
|
225
|
+
self.bounds.bottom,
|
|
226
|
+
self.bounds.right,
|
|
227
|
+
self.bounds.top,
|
|
228
|
+
)
|
|
229
|
+
if self.bounds.right > self.tile_pyramid.right:
|
|
230
|
+
logger.warning("snap right bounds value back to CRS bounds")
|
|
231
|
+
bounds = Bounds(
|
|
232
|
+
self.bounds.left,
|
|
233
|
+
self.bounds.bottom,
|
|
234
|
+
self.tile_pyramid.right,
|
|
235
|
+
self.bounds.top,
|
|
236
|
+
)
|
|
237
|
+
return [
|
|
238
|
+
list(Bounds.from_inp(tile.bbox.intersection(shape(bounds))))
|
|
239
|
+
for tile in self.tile_pyramid.tiles_from_bounds(bounds, zoom=self.zoom)
|
|
240
|
+
]
|
|
241
|
+
else:
|
|
242
|
+
return [
|
|
243
|
+
tile.bbox.intersection(self.area)
|
|
244
|
+
for tile in self.tile_pyramid.tiles_from_geom(self.area, zoom=self.zoom)
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
def __len__(self) -> int:
|
|
248
|
+
return len(self._chunks)
|
|
249
|
+
|
|
250
|
+
def __iter__(self) -> Iterator[dict]:
|
|
251
|
+
return iter([{self.search_kw: chunk} for chunk in self._chunks])
|