mapchete-eo 2026.2.0__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 (89) hide show
  1. mapchete_eo/__init__.py +1 -0
  2. mapchete_eo/array/__init__.py +0 -0
  3. mapchete_eo/array/buffer.py +16 -0
  4. mapchete_eo/array/color.py +29 -0
  5. mapchete_eo/array/convert.py +163 -0
  6. mapchete_eo/base.py +653 -0
  7. mapchete_eo/blacklist.txt +175 -0
  8. mapchete_eo/cli/__init__.py +30 -0
  9. mapchete_eo/cli/bounds.py +22 -0
  10. mapchete_eo/cli/options_arguments.py +227 -0
  11. mapchete_eo/cli/s2_brdf.py +77 -0
  12. mapchete_eo/cli/s2_cat_results.py +130 -0
  13. mapchete_eo/cli/s2_find_broken_products.py +77 -0
  14. mapchete_eo/cli/s2_jp2_static_catalog.py +166 -0
  15. mapchete_eo/cli/s2_mask.py +71 -0
  16. mapchete_eo/cli/s2_mgrs.py +45 -0
  17. mapchete_eo/cli/s2_rgb.py +114 -0
  18. mapchete_eo/cli/s2_verify.py +129 -0
  19. mapchete_eo/cli/static_catalog.py +82 -0
  20. mapchete_eo/eostac.py +30 -0
  21. mapchete_eo/exceptions.py +87 -0
  22. mapchete_eo/image_operations/__init__.py +12 -0
  23. mapchete_eo/image_operations/blend_functions.py +579 -0
  24. mapchete_eo/image_operations/color_correction.py +136 -0
  25. mapchete_eo/image_operations/compositing.py +266 -0
  26. mapchete_eo/image_operations/dtype_scale.py +43 -0
  27. mapchete_eo/image_operations/fillnodata.py +130 -0
  28. mapchete_eo/image_operations/filters.py +319 -0
  29. mapchete_eo/image_operations/linear_normalization.py +81 -0
  30. mapchete_eo/image_operations/sigmoidal.py +114 -0
  31. mapchete_eo/io/__init__.py +37 -0
  32. mapchete_eo/io/assets.py +496 -0
  33. mapchete_eo/io/items.py +162 -0
  34. mapchete_eo/io/levelled_cubes.py +259 -0
  35. mapchete_eo/io/path.py +155 -0
  36. mapchete_eo/io/products.py +423 -0
  37. mapchete_eo/io/profiles.py +45 -0
  38. mapchete_eo/platforms/sentinel2/__init__.py +17 -0
  39. mapchete_eo/platforms/sentinel2/_mapper_registry.py +89 -0
  40. mapchete_eo/platforms/sentinel2/bandpass_adjustment.py +104 -0
  41. mapchete_eo/platforms/sentinel2/brdf/__init__.py +8 -0
  42. mapchete_eo/platforms/sentinel2/brdf/config.py +32 -0
  43. mapchete_eo/platforms/sentinel2/brdf/correction.py +260 -0
  44. mapchete_eo/platforms/sentinel2/brdf/hls.py +251 -0
  45. mapchete_eo/platforms/sentinel2/brdf/models.py +44 -0
  46. mapchete_eo/platforms/sentinel2/brdf/protocols.py +27 -0
  47. mapchete_eo/platforms/sentinel2/brdf/ross_thick.py +136 -0
  48. mapchete_eo/platforms/sentinel2/brdf/sun_angle_arrays.py +76 -0
  49. mapchete_eo/platforms/sentinel2/config.py +241 -0
  50. mapchete_eo/platforms/sentinel2/driver.py +43 -0
  51. mapchete_eo/platforms/sentinel2/masks.py +329 -0
  52. mapchete_eo/platforms/sentinel2/metadata_parser/__init__.py +6 -0
  53. mapchete_eo/platforms/sentinel2/metadata_parser/base.py +56 -0
  54. mapchete_eo/platforms/sentinel2/metadata_parser/default_path_mapper.py +135 -0
  55. mapchete_eo/platforms/sentinel2/metadata_parser/models.py +78 -0
  56. mapchete_eo/platforms/sentinel2/metadata_parser/s2metadata.py +639 -0
  57. mapchete_eo/platforms/sentinel2/preconfigured_sources/__init__.py +57 -0
  58. mapchete_eo/platforms/sentinel2/preconfigured_sources/guessers.py +108 -0
  59. mapchete_eo/platforms/sentinel2/preconfigured_sources/item_mappers.py +171 -0
  60. mapchete_eo/platforms/sentinel2/preconfigured_sources/metadata_xml_mappers.py +217 -0
  61. mapchete_eo/platforms/sentinel2/preprocessing_tasks.py +50 -0
  62. mapchete_eo/platforms/sentinel2/processing_baseline.py +163 -0
  63. mapchete_eo/platforms/sentinel2/product.py +747 -0
  64. mapchete_eo/platforms/sentinel2/source.py +114 -0
  65. mapchete_eo/platforms/sentinel2/types.py +114 -0
  66. mapchete_eo/processes/__init__.py +0 -0
  67. mapchete_eo/processes/config.py +51 -0
  68. mapchete_eo/processes/dtype_scale.py +112 -0
  69. mapchete_eo/processes/eo_to_xarray.py +19 -0
  70. mapchete_eo/processes/merge_rasters.py +239 -0
  71. mapchete_eo/product.py +323 -0
  72. mapchete_eo/protocols.py +61 -0
  73. mapchete_eo/search/__init__.py +14 -0
  74. mapchete_eo/search/base.py +285 -0
  75. mapchete_eo/search/config.py +113 -0
  76. mapchete_eo/search/s2_mgrs.py +313 -0
  77. mapchete_eo/search/stac_search.py +278 -0
  78. mapchete_eo/search/stac_static.py +197 -0
  79. mapchete_eo/search/utm_search.py +251 -0
  80. mapchete_eo/settings.py +25 -0
  81. mapchete_eo/sort.py +60 -0
  82. mapchete_eo/source.py +109 -0
  83. mapchete_eo/time.py +62 -0
  84. mapchete_eo/types.py +76 -0
  85. mapchete_eo-2026.2.0.dist-info/METADATA +91 -0
  86. mapchete_eo-2026.2.0.dist-info/RECORD +89 -0
  87. mapchete_eo-2026.2.0.dist-info/WHEEL +4 -0
  88. mapchete_eo-2026.2.0.dist-info/entry_points.txt +11 -0
  89. mapchete_eo-2026.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,496 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import math
5
+ from typing import Callable, List, Optional, Union
6
+
7
+ import fsspec
8
+ import numpy as np
9
+ import numpy.ma as ma
10
+ import pystac
11
+ from affine import Affine
12
+ from mapchete import Timer
13
+ from mapchete.io import copy, fiona_open, rasterio_open
14
+ from mapchete.io.raster import ReferencedRaster, read_raster, read_raster_window
15
+ from mapchete.path import MPath
16
+ from mapchete.protocols import GridProtocol
17
+ from mapchete.settings import IORetrySettings
18
+ from mapchete.types import Grid, NodataVal
19
+ from numpy.typing import DTypeLike
20
+ from pydantic import BaseModel
21
+ from rasterio.dtypes import dtype_ranges
22
+ from rasterio.enums import Resampling
23
+ from rasterio.features import rasterize
24
+ from rasterio.profiles import Profile
25
+ from rasterio.vrt import WarpedVRT
26
+ from retry import retry
27
+
28
+ from mapchete_eo.io.path import COMMON_RASTER_EXTENSIONS, asset_mpath, cached_path
29
+ from mapchete_eo.io.profiles import COGDeflateProfile
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class STACRasterBandProperties(BaseModel):
35
+ nodata: Optional[NodataVal] = None
36
+ data_type: Optional[str] = None
37
+ scale: float = 1.0
38
+ offset: float = 0.0
39
+
40
+ @staticmethod
41
+ def from_asset(
42
+ asset: pystac.Asset,
43
+ nodataval: Optional[NodataVal] = None,
44
+ ) -> STACRasterBandProperties:
45
+ if asset.extra_fields.get("raster:offset", {}):
46
+ properties = dict(
47
+ offset=asset.extra_fields.get("raster:offset"),
48
+ scale=asset.extra_fields.get("raster:scale"),
49
+ nodata=asset.extra_fields.get("nodata", nodataval),
50
+ )
51
+ else:
52
+ properties = asset.extra_fields.get("raster:bands", [{}])[0]
53
+ properties.update(
54
+ nodata=(
55
+ nodataval
56
+ if properties.get("nodata") is None
57
+ else properties.get("nodata")
58
+ ),
59
+ )
60
+
61
+ return STACRasterBandProperties(
62
+ **properties,
63
+ )
64
+
65
+
66
+ def asset_to_np_array(
67
+ item: pystac.Item,
68
+ asset: str,
69
+ indexes: Union[List[int], int] = 1,
70
+ grid: Optional[GridProtocol] = None,
71
+ resampling: Resampling = Resampling.nearest,
72
+ nodataval: NodataVal = None,
73
+ apply_offset: bool = True,
74
+ ) -> ma.MaskedArray:
75
+ """
76
+ Read grid window of STAC Items and merge into a 2D ma.MaskedArray.
77
+
78
+ This is the main read method which is one way or the other being called from everywhere
79
+ whenever a band is being read!
80
+ """
81
+ # get path early to catch an eventual asset missing error early
82
+ path = asset_mpath(item, asset)
83
+
84
+ # find out asset details if raster:bands is available
85
+ band_properties = STACRasterBandProperties.from_asset(
86
+ item.assets[asset], nodataval=nodataval
87
+ )
88
+
89
+ logger.debug("reading asset %s and indexes %s ...", asset, indexes)
90
+ array = read_raster(
91
+ inp=path,
92
+ indexes=indexes,
93
+ grid=grid,
94
+ resampling=resampling.name,
95
+ dst_nodata=band_properties.nodata,
96
+ ).array
97
+ if apply_offset and band_properties.offset:
98
+ logger.debug(
99
+ "apply offset %s and scale %s to asset %s",
100
+ band_properties.offset,
101
+ band_properties.scale,
102
+ asset,
103
+ )
104
+ data_type = band_properties.data_type or array.dtype
105
+
106
+ # determine value range for the target data_type
107
+ clip_min, clip_max = dtype_ranges[str(data_type)]
108
+
109
+ # increase minimum clip value to avoid collission with nodata value
110
+ if clip_min == band_properties.nodata:
111
+ clip_min += 1
112
+
113
+ array[~array.mask] = (
114
+ (
115
+ ((array[~array.mask] * band_properties.scale) + band_properties.offset)
116
+ / band_properties.scale
117
+ )
118
+ .round()
119
+ .clip(clip_min, clip_max)
120
+ .astype(data_type, copy=False)
121
+ .data
122
+ )
123
+ return array
124
+
125
+
126
+ def get_assets(
127
+ item: pystac.Item,
128
+ assets: List[str],
129
+ dst_dir: MPath,
130
+ src_fs: fsspec.AbstractFileSystem = None,
131
+ overwrite: bool = False,
132
+ resolution: Union[None, float, int] = None,
133
+ convert_profile: Optional[Profile] = None,
134
+ item_href_in_dst_dir: bool = True,
135
+ ignore_if_exists: bool = False,
136
+ ) -> pystac.Item:
137
+ """
138
+ Copy or convert assets depending on settings.
139
+
140
+ Conversion is triggered if either resolution or convert_profile is provided.
141
+ """
142
+ for asset in assets:
143
+ path = asset_mpath(item, asset, fs=src_fs)
144
+ # convert if possible
145
+ if should_be_converted(path, resolution=resolution, profile=convert_profile):
146
+ item = convert_asset(
147
+ item,
148
+ asset,
149
+ dst_dir,
150
+ src_fs=src_fs,
151
+ resolution=resolution,
152
+ overwrite=overwrite,
153
+ ignore_if_exists=ignore_if_exists,
154
+ profile=convert_profile or COGDeflateProfile(),
155
+ item_href_in_dst_dir=item_href_in_dst_dir,
156
+ )
157
+ continue
158
+
159
+ # copy
160
+ item = copy_asset(
161
+ item,
162
+ asset,
163
+ dst_dir,
164
+ overwrite=overwrite,
165
+ ignore_if_exists=ignore_if_exists,
166
+ item_href_in_dst_dir=item_href_in_dst_dir,
167
+ )
168
+
169
+ return item
170
+
171
+
172
+ def copy_asset(
173
+ item: pystac.Item,
174
+ asset: str,
175
+ dst_dir: MPath,
176
+ src_fs: fsspec.AbstractFileSystem = None,
177
+ overwrite: bool = False,
178
+ item_href_in_dst_dir: bool = True,
179
+ ignore_if_exists: bool = False,
180
+ ) -> pystac.Item:
181
+ """Copy asset from one place to another."""
182
+ src_path = asset_mpath(item, asset, fs=src_fs)
183
+ output_path = dst_dir / src_path.name
184
+
185
+ # write relative path into asset.href if Item will be in the same directory
186
+ if item_href_in_dst_dir and not output_path.is_absolute(): # pragma: no cover
187
+ item.assets[asset].href = src_path.name
188
+ else:
189
+ item.assets[asset].href = str(output_path)
190
+
191
+ # TODO make this check optional
192
+ if output_path.exists():
193
+ if ignore_if_exists:
194
+ logger.debug("ignore existing asset %s", output_path)
195
+ return item
196
+ elif overwrite:
197
+ logger.debug("overwrite exsiting asset %s", output_path)
198
+ else:
199
+ raise IOError(f"{output_path} already exists")
200
+ else:
201
+ dst_dir.makedirs()
202
+
203
+ with Timer() as t:
204
+ logger.debug("copy asset %s to %s ...", src_path, dst_dir)
205
+ copy(
206
+ src_path,
207
+ output_path,
208
+ overwrite=overwrite,
209
+ )
210
+ logger.debug("copied asset '%s' in %s", asset, t)
211
+
212
+ return item
213
+
214
+
215
+ def convert_asset(
216
+ item: pystac.Item,
217
+ asset: str,
218
+ dst_dir: MPath,
219
+ src_fs: fsspec.AbstractFileSystem = None,
220
+ overwrite: bool = False,
221
+ resolution: Union[None, float, int] = None,
222
+ profile: Optional[Profile] = None,
223
+ item_href_in_dst_dir: bool = True,
224
+ ignore_if_exists: bool = False,
225
+ ) -> pystac.Item:
226
+ """
227
+ Convert asset to a different format.
228
+ """
229
+ src_path = asset_mpath(item, asset, fs=src_fs)
230
+ output_path = dst_dir / src_path.name
231
+ profile = profile or COGDeflateProfile()
232
+
233
+ # write relative path into asset.href if Item will be in the same directory
234
+ if item_href_in_dst_dir and not output_path.is_absolute(): # pragma: no cover
235
+ item.assets[asset].href = src_path.name
236
+ else:
237
+ item.assets[asset].href = str(output_path)
238
+
239
+ # TODO make this check optional
240
+ if output_path.exists():
241
+ if ignore_if_exists:
242
+ logger.debug("ignore existing asset %s", output_path)
243
+ return item
244
+ elif overwrite:
245
+ logger.debug("overwrite exsiting asset %s", output_path)
246
+ else:
247
+ raise IOError(f"{output_path} already exists")
248
+ else:
249
+ dst_dir.makedirs()
250
+
251
+ with Timer() as t:
252
+ convert_raster(src_path, output_path, resolution, profile)
253
+ logger.debug("converted asset '%s' in %s", asset, t)
254
+
255
+ return item
256
+
257
+
258
+ @retry(logger=logger, **dict(IORetrySettings()))
259
+ def convert_raster(
260
+ src_path: MPath,
261
+ dst_path: MPath,
262
+ resolution: Union[None, float, int] = None,
263
+ profile: Optional[Profile] = None,
264
+ ) -> None:
265
+ with rasterio_open(src_path, "r") as src:
266
+ meta = src.meta.copy()
267
+ if profile:
268
+ meta.update(**profile)
269
+ src_transform = src.transform
270
+ if resolution:
271
+ logger.debug(
272
+ "converting %s to %s using %sm resolution with profile %s ...",
273
+ src_path,
274
+ dst_path,
275
+ resolution,
276
+ profile,
277
+ )
278
+ src_res = src.transform[0]
279
+ dst_transform = Affine.from_gdal(
280
+ *(
281
+ src_transform[2],
282
+ resolution,
283
+ 0.0,
284
+ src_transform[5],
285
+ 0.0,
286
+ -resolution,
287
+ )
288
+ )
289
+ dst_width = int(math.ceil(src.width * (src_res / resolution)))
290
+ dst_height = int(math.ceil(src.height * (src_res / resolution)))
291
+ meta.update(
292
+ transform=dst_transform,
293
+ width=dst_width,
294
+ height=dst_height,
295
+ )
296
+ logger.debug("convert %s to %s with settings %s", src_path, dst_path, meta)
297
+ with rasterio_open(dst_path, "w", **meta) as dst:
298
+ with WarpedVRT(
299
+ src,
300
+ width=meta["width"],
301
+ height=meta["height"],
302
+ transform=meta["transform"],
303
+ ) as warped:
304
+ dst.write(warped.read())
305
+
306
+
307
+ def get_metadata_assets(
308
+ item: pystac.Item,
309
+ dst_dir: MPath,
310
+ overwrite: bool = False,
311
+ metadata_parser_classes: Optional[tuple] = None,
312
+ resolution: Union[None, float, int] = None,
313
+ convert_profile: Optional[Profile] = None,
314
+ metadata_asset_names: List[str] = ["metadata", "granule_metadata"],
315
+ ):
316
+ """Copy STAC item metadata and its metadata assets."""
317
+ for metadata_asset in metadata_asset_names:
318
+ try:
319
+ src_metadata_xml = MPath(item.assets[metadata_asset].href)
320
+ break
321
+ except KeyError:
322
+ pass
323
+ else: # pragma: no cover
324
+ raise KeyError("no 'metadata' or 'granule_metadata' asset found")
325
+
326
+ # copy metadata.xml
327
+ dst_metadata_xml = dst_dir / src_metadata_xml.name
328
+ if overwrite or not dst_metadata_xml.exists():
329
+ copy(src_metadata_xml, dst_metadata_xml, overwrite=overwrite)
330
+
331
+ item.assets[metadata_asset].href = src_metadata_xml.name
332
+ if metadata_parser_classes is None: # pragma: no cover
333
+ raise TypeError("no metadata parser class given")
334
+
335
+ for metadata_parser_cls in metadata_parser_classes:
336
+ src_metadata = metadata_parser_cls.from_metadata_xml(src_metadata_xml)
337
+ dst_metadata = metadata_parser_cls.from_metadata_xml(dst_metadata_xml)
338
+ break
339
+ else: # pragma: no cover
340
+ raise TypeError(
341
+ f"could not parse {src_metadata_xml} with {metadata_parser_classes}"
342
+ )
343
+
344
+ # copy assets
345
+ original_asset_paths = src_metadata.assets
346
+ for asset, dst_path in dst_metadata.assets.items():
347
+ src_path = original_asset_paths[asset]
348
+
349
+ if overwrite or not dst_path.exists():
350
+ # convert if possible
351
+ if should_be_converted(
352
+ src_path, resolution=resolution, profile=convert_profile
353
+ ): # pragma: no cover
354
+ convert_raster(src_path, dst_path, resolution, convert_profile)
355
+ else:
356
+ logger.debug("copy %s ...", asset)
357
+ copy(src_path, dst_path, overwrite=overwrite)
358
+
359
+ return item
360
+
361
+
362
+ @retry(logger=logger, **dict(IORetrySettings()))
363
+ def should_be_converted(
364
+ path: MPath,
365
+ resolution: Union[None, float, int] = None,
366
+ profile: Optional[Profile] = None,
367
+ ) -> bool:
368
+ """Decide whether a raster file should be converted or not."""
369
+ if path.endswith(tuple(COMMON_RASTER_EXTENSIONS)):
370
+ # see if it even pays off to convert based on resolution
371
+ if resolution is not None:
372
+ with rasterio_open(path) as src:
373
+ src_resolution = src.transform[0]
374
+ if src_resolution != resolution:
375
+ return True
376
+
377
+ # when profile is given, check if profile differs from remote file
378
+ elif profile is not None:
379
+ with rasterio_open(path) as src:
380
+ for key, value in profile.items():
381
+ if value == "COG":
382
+ # TODO check if file is really a valid cog
383
+ value = "GTiff"
384
+ if key in src.meta and src.meta[key] != value:
385
+ logger.debug(
386
+ "different value for %s required: %s should become %s",
387
+ key,
388
+ src.meta[key],
389
+ value,
390
+ )
391
+ return True
392
+ return False
393
+
394
+ return False
395
+
396
+
397
+ def _read_vector_mask(mask_path):
398
+ logger.debug("open %s with Fiona", mask_path)
399
+ with cached_path(mask_path) as cached:
400
+ try:
401
+ with fiona_open(cached) as src:
402
+ return list([dict(f) for f in src])
403
+ except ValueError as e:
404
+ # this happens if GML file is empty
405
+ for message in ["Null layer: ", "'hLayer' is NULL in 'OGR_L_GetName'"]:
406
+ if message in str(e):
407
+ return []
408
+ else: # pragma: no cover
409
+ raise
410
+
411
+
412
+ @retry(logger=logger, **dict(IORetrySettings()))
413
+ def read_mask_as_raster(
414
+ path: MPath,
415
+ indexes: Optional[List[int]] = None,
416
+ dst_grid: Optional[GridProtocol] = None,
417
+ resampling: Resampling = Resampling.nearest,
418
+ rasterize_value_func: Callable = lambda feature: feature.get("id", 1),
419
+ rasterize_feature_filter: Callable = lambda feature: True,
420
+ dtype: Optional[DTypeLike] = None,
421
+ masked: bool = True,
422
+ cached_read: bool = False,
423
+ ) -> ReferencedRaster:
424
+ """
425
+ Read mask as array regardless of source data type (raster or vector).
426
+ """
427
+ if dst_grid:
428
+ dst_grid = Grid.from_obj(dst_grid)
429
+ try:
430
+ with cached_path(path, active=cached_read) as path:
431
+ if path.suffix in COMMON_RASTER_EXTENSIONS:
432
+ if dst_grid:
433
+ array = read_raster_window(
434
+ path, grid=dst_grid, indexes=indexes, resampling=resampling
435
+ )
436
+ # sum up bands to 2D mask and keep dtype
437
+ array = array.sum(axis=0, dtype=array.dtype)
438
+ mask = ReferencedRaster(
439
+ data=array if masked else array.data,
440
+ transform=dst_grid.transform,
441
+ bounds=dst_grid.bounds,
442
+ crs=dst_grid.crs,
443
+ )
444
+ else:
445
+ with rasterio_open(path) as src:
446
+ mask = ReferencedRaster(
447
+ src.read(indexes, masked=masked).sum(
448
+ axis=0, dtype=src.dtypes[0]
449
+ ),
450
+ transform=src.transform,
451
+ bounds=src.bounds,
452
+ crs=src.crs,
453
+ )
454
+
455
+ # make sure output has correct dtype
456
+ if dtype:
457
+ mask.data = mask.data.astype(dtype)
458
+ return mask
459
+
460
+ else:
461
+ if dst_grid:
462
+ features = [
463
+ feature
464
+ for feature in _read_vector_mask(path)
465
+ if rasterize_feature_filter(feature)
466
+ ]
467
+ features_values = [
468
+ (feature["geometry"], rasterize_value_func(feature))
469
+ for feature in features
470
+ ]
471
+ return ReferencedRaster(
472
+ data=(
473
+ rasterize(
474
+ features_values,
475
+ out_shape=dst_grid.shape,
476
+ transform=dst_grid.transform,
477
+ dtype=np.uint8,
478
+ ).astype(dtype)
479
+ if features_values
480
+ else np.zeros(dst_grid.shape, dtype=dtype)
481
+ ),
482
+ transform=dst_grid.transform,
483
+ crs=dst_grid.crs,
484
+ bounds=dst_grid.bounds,
485
+ )
486
+ else: # pragma: no cover
487
+ raise ValueError("out_shape and out_transform have to be provided.")
488
+ except Exception as exception: # pragma: no cover
489
+ # This is a hack because some tool using aiohttp does not raise a
490
+ # ClientResponseError directly but masks it as a generic Exception and thus
491
+ # preventing our retry mechanism to kick in.
492
+ if repr(exception).startswith('Exception("ClientResponseError'):
493
+ raise ConnectionError(repr(exception)).with_traceback(
494
+ exception.__traceback__
495
+ ) from exception
496
+ raise
@@ -0,0 +1,162 @@
1
+ import logging
2
+ from typing import Any, List, Optional, Tuple, Union
3
+
4
+ import numpy.ma as ma
5
+ import pystac
6
+ from mapchete.geometry import repair_antimeridian_geometry
7
+ from mapchete.protocols import GridProtocol
8
+ from mapchete.types import Bounds, NodataVals
9
+ from rasterio.enums import Resampling
10
+ from shapely.geometry import mapping, shape
11
+
12
+ from mapchete_eo.exceptions import EmptyProductException
13
+ from mapchete_eo.io.assets import asset_to_np_array
14
+ from mapchete_eo.types import BandLocation
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def item_to_np_array(
20
+ item: pystac.Item,
21
+ band_locations: List[BandLocation],
22
+ grid: Optional[GridProtocol] = None,
23
+ resampling: Resampling = Resampling.nearest,
24
+ nodatavals: NodataVals = None,
25
+ raise_empty: bool = False,
26
+ apply_offset: bool = True,
27
+ ) -> ma.MaskedArray:
28
+ """
29
+ Read window of STAC Item and merge into a 3D ma.MaskedArray.
30
+ """
31
+ logger.debug("reading %s assets from item %s...", len(band_locations), item.id)
32
+ out = ma.stack(
33
+ [
34
+ asset_to_np_array(
35
+ item,
36
+ band_location.asset_name,
37
+ indexes=band_location.band_index,
38
+ grid=grid,
39
+ resampling=expanded_resampling,
40
+ nodataval=nodataval,
41
+ apply_offset=apply_offset,
42
+ )
43
+ for band_location, expanded_resampling, nodataval in zip(
44
+ band_locations,
45
+ expand_params(resampling, len(band_locations)),
46
+ expand_params(nodatavals, len(band_locations)),
47
+ )
48
+ ]
49
+ )
50
+
51
+ if raise_empty and out.mask.all():
52
+ raise EmptyProductException(
53
+ f"all required assets of {item} over grid {grid} are empty."
54
+ )
55
+
56
+ return out
57
+
58
+
59
+ def expand_params(param: Any, length: int) -> List[Any]:
60
+ """
61
+ Expand parameters if they are not a list.
62
+ """
63
+ if isinstance(param, list):
64
+ if len(param) != length:
65
+ raise ValueError(f"length of {param} must be {length} but is {len(param)}")
66
+ return param
67
+ return [param for _ in range(length)]
68
+
69
+
70
+ def get_item_property(
71
+ item: pystac.Item,
72
+ property: Union[str, Tuple[str, ...]],
73
+ default: Any = None,
74
+ ) -> Any:
75
+ """
76
+ Return item property.
77
+
78
+ A valid property can be a special property like "year" from the items datetime property
79
+ or any key in the item properties or extra_fields.
80
+
81
+ Search order of properties is based on the pystac LayoutTemplate search order:
82
+
83
+ https://pystac.readthedocs.io/en/stable/_modules/pystac/layout.html#LayoutTemplate
84
+ - The object's attributes
85
+ - Keys in the ``properties`` attribute, if it exists.
86
+ - Keys in the ``extra_fields`` attribute, if it exists.
87
+
88
+ Some special keys can be used in template variables:
89
+
90
+ +--------------------+--------------------------------------------------------+
91
+ | Template variable | Meaning |
92
+ +====================+========================================================+
93
+ | ``year`` | The year of an Item's datetime, or |
94
+ | | start_datetime if datetime is null |
95
+ +--------------------+--------------------------------------------------------+
96
+ | ``month`` | The month of an Item's datetime, or |
97
+ | | start_datetime if datetime is null |
98
+ +--------------------+--------------------------------------------------------+
99
+ | ``day`` | The day of an Item's datetime, or |
100
+ | | start_datetime if datetime is null |
101
+ +--------------------+--------------------------------------------------------+
102
+ | ``date`` | The date (iso format) of an Item's |
103
+ | | datetime, or start_datetime if datetime is null |
104
+ +--------------------+--------------------------------------------------------+
105
+ | ``collection`` | The collection ID of an Item's collection. |
106
+ +--------------------+--------------------------------------------------------+
107
+ """
108
+
109
+ def _get_item_property(item: pystac.Item, property: str) -> Any:
110
+ if property == "id":
111
+ return item.id
112
+ elif property in ["year", "month", "day", "date", "datetime"]:
113
+ if item.datetime is None: # pragma: no cover
114
+ raise ValueError(
115
+ f"STAC item has no datetime attached, thus cannot get property {property}"
116
+ )
117
+ elif property == "date":
118
+ return item.datetime.date().isoformat()
119
+ elif property == "datetime":
120
+ return item.datetime
121
+ else:
122
+ return item.datetime.__getattribute__(property)
123
+ elif property == "collection":
124
+ return item.collection_id
125
+ elif property in item.properties:
126
+ return item.properties[property]
127
+ elif property in item.extra_fields:
128
+ return item.extra_fields[property]
129
+ elif property == "stac_extensions":
130
+ return item.stac_extensions
131
+ else:
132
+ raise KeyError
133
+
134
+ for prop in property if isinstance(property, tuple) else (property,):
135
+ try:
136
+ return _get_item_property(item, prop)
137
+ except KeyError:
138
+ pass
139
+ else:
140
+ if default is not None:
141
+ return default
142
+ raise KeyError(
143
+ f"item {item.id} does not have property {property} in its datetime, properties "
144
+ f"({', '.join(item.properties.keys())}) or extra_fields "
145
+ f"({', '.join(item.extra_fields.keys())})"
146
+ )
147
+
148
+
149
+ def item_fix_footprint(
150
+ item: pystac.Item, bbox_width_threshold: float = 180.0
151
+ ) -> pystac.Item:
152
+ bounds = Bounds.from_inp(item.bbox)
153
+
154
+ if bounds.width > bbox_width_threshold:
155
+ logger.debug("item %s crosses Antimeridian, fixing ...", item.id)
156
+
157
+ if item.geometry:
158
+ geometry = repair_antimeridian_geometry(geometry=shape(item.geometry))
159
+ item.geometry = mapping(geometry)
160
+ item.bbox = list(geometry.bounds)
161
+
162
+ return item