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.
Files changed (87) hide show
  1. mapchete_eo/__init__.py +1 -0
  2. mapchete_eo/archives/__init__.py +0 -0
  3. mapchete_eo/archives/base.py +65 -0
  4. mapchete_eo/array/__init__.py +0 -0
  5. mapchete_eo/array/buffer.py +16 -0
  6. mapchete_eo/array/color.py +29 -0
  7. mapchete_eo/array/convert.py +157 -0
  8. mapchete_eo/base.py +528 -0
  9. mapchete_eo/blacklist.txt +175 -0
  10. mapchete_eo/cli/__init__.py +30 -0
  11. mapchete_eo/cli/bounds.py +22 -0
  12. mapchete_eo/cli/options_arguments.py +243 -0
  13. mapchete_eo/cli/s2_brdf.py +77 -0
  14. mapchete_eo/cli/s2_cat_results.py +146 -0
  15. mapchete_eo/cli/s2_find_broken_products.py +93 -0
  16. mapchete_eo/cli/s2_jp2_static_catalog.py +166 -0
  17. mapchete_eo/cli/s2_mask.py +71 -0
  18. mapchete_eo/cli/s2_mgrs.py +45 -0
  19. mapchete_eo/cli/s2_rgb.py +114 -0
  20. mapchete_eo/cli/s2_verify.py +129 -0
  21. mapchete_eo/cli/static_catalog.py +123 -0
  22. mapchete_eo/eostac.py +30 -0
  23. mapchete_eo/exceptions.py +87 -0
  24. mapchete_eo/geometry.py +271 -0
  25. mapchete_eo/image_operations/__init__.py +12 -0
  26. mapchete_eo/image_operations/color_correction.py +136 -0
  27. mapchete_eo/image_operations/compositing.py +247 -0
  28. mapchete_eo/image_operations/dtype_scale.py +43 -0
  29. mapchete_eo/image_operations/fillnodata.py +130 -0
  30. mapchete_eo/image_operations/filters.py +319 -0
  31. mapchete_eo/image_operations/linear_normalization.py +81 -0
  32. mapchete_eo/image_operations/sigmoidal.py +114 -0
  33. mapchete_eo/io/__init__.py +37 -0
  34. mapchete_eo/io/assets.py +492 -0
  35. mapchete_eo/io/items.py +147 -0
  36. mapchete_eo/io/levelled_cubes.py +228 -0
  37. mapchete_eo/io/path.py +144 -0
  38. mapchete_eo/io/products.py +413 -0
  39. mapchete_eo/io/profiles.py +45 -0
  40. mapchete_eo/known_catalogs.py +42 -0
  41. mapchete_eo/platforms/sentinel2/__init__.py +17 -0
  42. mapchete_eo/platforms/sentinel2/archives.py +190 -0
  43. mapchete_eo/platforms/sentinel2/bandpass_adjustment.py +104 -0
  44. mapchete_eo/platforms/sentinel2/brdf/__init__.py +8 -0
  45. mapchete_eo/platforms/sentinel2/brdf/config.py +32 -0
  46. mapchete_eo/platforms/sentinel2/brdf/correction.py +260 -0
  47. mapchete_eo/platforms/sentinel2/brdf/hls.py +251 -0
  48. mapchete_eo/platforms/sentinel2/brdf/models.py +44 -0
  49. mapchete_eo/platforms/sentinel2/brdf/protocols.py +27 -0
  50. mapchete_eo/platforms/sentinel2/brdf/ross_thick.py +136 -0
  51. mapchete_eo/platforms/sentinel2/brdf/sun_angle_arrays.py +76 -0
  52. mapchete_eo/platforms/sentinel2/config.py +181 -0
  53. mapchete_eo/platforms/sentinel2/driver.py +78 -0
  54. mapchete_eo/platforms/sentinel2/masks.py +325 -0
  55. mapchete_eo/platforms/sentinel2/metadata_parser.py +734 -0
  56. mapchete_eo/platforms/sentinel2/path_mappers/__init__.py +29 -0
  57. mapchete_eo/platforms/sentinel2/path_mappers/base.py +56 -0
  58. mapchete_eo/platforms/sentinel2/path_mappers/earthsearch.py +34 -0
  59. mapchete_eo/platforms/sentinel2/path_mappers/metadata_xml.py +135 -0
  60. mapchete_eo/platforms/sentinel2/path_mappers/sinergise.py +105 -0
  61. mapchete_eo/platforms/sentinel2/preprocessing_tasks.py +26 -0
  62. mapchete_eo/platforms/sentinel2/processing_baseline.py +160 -0
  63. mapchete_eo/platforms/sentinel2/product.py +669 -0
  64. mapchete_eo/platforms/sentinel2/types.py +109 -0
  65. mapchete_eo/processes/__init__.py +0 -0
  66. mapchete_eo/processes/config.py +51 -0
  67. mapchete_eo/processes/dtype_scale.py +112 -0
  68. mapchete_eo/processes/eo_to_xarray.py +19 -0
  69. mapchete_eo/processes/merge_rasters.py +235 -0
  70. mapchete_eo/product.py +278 -0
  71. mapchete_eo/protocols.py +56 -0
  72. mapchete_eo/search/__init__.py +14 -0
  73. mapchete_eo/search/base.py +222 -0
  74. mapchete_eo/search/config.py +42 -0
  75. mapchete_eo/search/s2_mgrs.py +314 -0
  76. mapchete_eo/search/stac_search.py +251 -0
  77. mapchete_eo/search/stac_static.py +236 -0
  78. mapchete_eo/search/utm_search.py +251 -0
  79. mapchete_eo/settings.py +24 -0
  80. mapchete_eo/sort.py +48 -0
  81. mapchete_eo/time.py +53 -0
  82. mapchete_eo/types.py +73 -0
  83. mapchete_eo-2025.7.0.dist-info/METADATA +38 -0
  84. mapchete_eo-2025.7.0.dist-info/RECORD +87 -0
  85. mapchete_eo-2025.7.0.dist-info/WHEEL +5 -0
  86. mapchete_eo-2025.7.0.dist-info/entry_points.txt +11 -0
  87. 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])