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,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)
@@ -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