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,259 @@
1
+ import logging
2
+ from typing import List, Optional
3
+
4
+ import numpy as np
5
+ import numpy.ma as ma
6
+ from numpy.typing import DTypeLike
7
+ import xarray as xr
8
+ from mapchete.pretty import pretty_bytes
9
+ from mapchete.protocols import GridProtocol
10
+ from mapchete.types import NodataVals, NodataVal
11
+ from rasterio.enums import Resampling
12
+
13
+ from mapchete_eo.array.convert import to_dataset
14
+ from mapchete_eo.exceptions import (
15
+ CorruptedSlice,
16
+ EmptySliceException,
17
+ EmptyStackException,
18
+ NoSourceProducts,
19
+ )
20
+ from mapchete_eo.io.products import products_to_slices
21
+ from mapchete_eo.protocols import EOProductProtocol
22
+ from mapchete_eo.sort import SortMethodConfig, TargetDateSort
23
+ from mapchete_eo.types import MergeMethod
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def read_levelled_cube_to_np_array(
29
+ products: List[EOProductProtocol],
30
+ target_height: int,
31
+ grid: GridProtocol,
32
+ assets: Optional[List[str]] = None,
33
+ eo_bands: Optional[List[str]] = None,
34
+ resampling: Resampling = Resampling.nearest,
35
+ nodatavals: NodataVals = None,
36
+ merge_products_by: Optional[str] = None,
37
+ merge_method: MergeMethod = MergeMethod.first,
38
+ sort: SortMethodConfig = TargetDateSort(),
39
+ product_read_kwargs: dict = {},
40
+ raise_empty: bool = True,
41
+ out_dtype: DTypeLike = np.uint16,
42
+ out_fill_value: NodataVal = 0,
43
+ read_mask: Optional[np.ndarray] = None,
44
+ ) -> ma.MaskedArray:
45
+ """
46
+ Read products as slices into a cube by filling up nodata gaps with next slice.
47
+
48
+ If a read_mask is provided, only the pixels marked True are considered to be read.
49
+ """
50
+ if len(products) == 0: # pragma: no cover
51
+ raise NoSourceProducts("no products to read")
52
+ bands = assets or eo_bands
53
+ if bands is None: # pragma: no cover
54
+ raise ValueError("either assets or eo_bands have to be set")
55
+ out_shape = (target_height, len(bands), *grid.shape)
56
+
57
+ # 2D read_mask shape
58
+ if read_mask is None:
59
+ read_mask = np.ones(grid.shape, dtype=bool)
60
+ elif read_mask.ndim != 2: # pragma: no cover
61
+ raise ValueError(
62
+ "read_mask must be 2-dimensional, not %s-dimensional",
63
+ read_mask.ndim,
64
+ )
65
+ out: ma.MaskedArray = ma.masked_array(
66
+ data=np.full(out_shape, out_fill_value, dtype=out_dtype),
67
+ mask=np.ones(out_shape, dtype=bool),
68
+ fill_value=out_fill_value,
69
+ )
70
+
71
+ if not read_mask.any():
72
+ logger.debug("nothing to read")
73
+ return out
74
+
75
+ # extrude mask to match each layer
76
+ layer_read_mask = np.stack([read_mask for _ in bands])
77
+
78
+ def _cube_read_mask() -> np.ndarray:
79
+ # This is only needed for debug output, thus there is no need to materialize always
80
+ return np.stack([layer_read_mask for _ in range(target_height)])
81
+
82
+ logger.debug(
83
+ "empty cube with shape %s has %s and %s pixels to be filled",
84
+ out.shape,
85
+ pretty_bytes(out.size * out.itemsize),
86
+ _cube_read_mask().sum(),
87
+ )
88
+
89
+ logger.debug("sort products into slices ...")
90
+ slices = products_to_slices(
91
+ products=products, group_by_property=merge_products_by, sort=sort
92
+ )
93
+ logger.debug(
94
+ "generating levelled cube with height %s from %s slices",
95
+ target_height,
96
+ len(slices),
97
+ )
98
+
99
+ slices_read_count, slices_skip_count = 0, 0
100
+
101
+ # pick slices one by one
102
+ for slice_count, slice_ in enumerate(slices, 1):
103
+ # all filled up? let's get outta here!
104
+ if not out.mask.any():
105
+ logger.debug("cube has no pixels to be filled, quitting!")
106
+ break
107
+
108
+ # generate 2D mask of holes to be filled in output cube
109
+ cube_nodata_mask = np.logical_and(out.mask.any(axis=0).any(axis=0), read_mask)
110
+
111
+ # read slice
112
+ try:
113
+ logger.debug(
114
+ "see if slice %s %s has some of the %s unmasked pixels for cube",
115
+ slice_count,
116
+ slice_,
117
+ cube_nodata_mask.sum(),
118
+ )
119
+ with slice_.cached():
120
+ slice_array = slice_.read(
121
+ merge_method=merge_method,
122
+ product_read_kwargs=dict(
123
+ product_read_kwargs,
124
+ assets=assets,
125
+ eo_bands=eo_bands,
126
+ grid=grid,
127
+ resampling=resampling,
128
+ nodatavals=nodatavals,
129
+ raise_empty=raise_empty,
130
+ read_mask=cube_nodata_mask.copy(),
131
+ out_dtype=out_dtype,
132
+ ),
133
+ )
134
+ slices_read_count += 1
135
+ except (EmptySliceException, CorruptedSlice) as exc:
136
+ logger.debug("skipped slice %s: %s", slice_, str(exc))
137
+ slices_skip_count += 1
138
+ continue
139
+
140
+ # if slice was not empty, fill pixels into cube
141
+ logger.debug("add slice %s array to cube", slice_)
142
+
143
+ # iterate through layers of cube
144
+ for layer_index in range(target_height):
145
+ # go to next layer if layer is full
146
+ if not out[layer_index].mask.any():
147
+ logger.debug("layer %s: full, jump to next", layer_index)
148
+ continue
149
+
150
+ # determine empty patches of current layer
151
+ empty_patches = np.logical_and(out[layer_index].mask, layer_read_mask)
152
+ remaining_pixels_for_layer = (~slice_array[empty_patches].mask).sum()
153
+
154
+ # when slice has nothing to offer for this layer, skip
155
+ if remaining_pixels_for_layer == 0:
156
+ logger.debug(
157
+ "layer %s: slice has no pixels for this layer, jump to next",
158
+ layer_index,
159
+ )
160
+ continue
161
+
162
+ # insert slice data into empty patches of layer
163
+ logger.debug(
164
+ "layer %s: fill with %s pixels ...",
165
+ layer_index,
166
+ remaining_pixels_for_layer,
167
+ )
168
+ out[layer_index][empty_patches] = slice_array[empty_patches]
169
+
170
+ # report on layer fill status
171
+ logger.debug(
172
+ "layer %s: %s",
173
+ layer_index,
174
+ _percent_full(
175
+ remaining=np.logical_and(
176
+ out[layer_index].mask, layer_read_mask
177
+ ).sum(),
178
+ total=layer_read_mask.sum(),
179
+ ),
180
+ )
181
+
182
+ # remove slice values which were just inserted for next layer
183
+ slice_array[empty_patches] = ma.masked
184
+
185
+ if slice_array.mask.all():
186
+ logger.debug("slice fully inserted into cube, skipping")
187
+ break
188
+
189
+ # report on layer fill status
190
+ logger.debug(
191
+ "cube is %s",
192
+ _percent_full(
193
+ remaining=np.logical_and(out.mask, _cube_read_mask()).sum(),
194
+ total=_cube_read_mask().sum(),
195
+ ),
196
+ )
197
+
198
+ logger.debug(
199
+ "%s slices read, %s slices skipped", slices_read_count, slices_skip_count
200
+ )
201
+
202
+ if raise_empty and out.mask.all():
203
+ raise EmptyStackException("all slices in stack are empty or corrupt")
204
+
205
+ return out
206
+
207
+
208
+ def read_levelled_cube_to_xarray(
209
+ products: List[EOProductProtocol],
210
+ target_height: int,
211
+ assets: Optional[List[str]] = None,
212
+ eo_bands: Optional[List[str]] = None,
213
+ grid: Optional[GridProtocol] = None,
214
+ resampling: Resampling = Resampling.nearest,
215
+ nodatavals: NodataVals = None,
216
+ merge_products_by: Optional[str] = None,
217
+ merge_method: MergeMethod = MergeMethod.first,
218
+ sort: SortMethodConfig = TargetDateSort(),
219
+ product_read_kwargs: dict = {},
220
+ raise_empty: bool = True,
221
+ slice_axis_name: str = "layers",
222
+ band_axis_name: str = "bands",
223
+ x_axis_name: str = "x",
224
+ y_axis_name: str = "y",
225
+ read_mask: Optional[np.ndarray] = None,
226
+ ) -> xr.Dataset:
227
+ """
228
+ Read products as slices into a cube by filling up nodata gaps with next slice.
229
+ """
230
+ assets = assets or []
231
+ eo_bands = eo_bands or []
232
+ variables = assets or eo_bands
233
+ return to_dataset(
234
+ read_levelled_cube_to_np_array(
235
+ products=products,
236
+ target_height=target_height,
237
+ assets=assets,
238
+ eo_bands=eo_bands,
239
+ grid=grid,
240
+ resampling=resampling,
241
+ nodatavals=nodatavals,
242
+ merge_products_by=merge_products_by,
243
+ merge_method=merge_method,
244
+ sort=sort,
245
+ product_read_kwargs=product_read_kwargs,
246
+ raise_empty=raise_empty,
247
+ read_mask=read_mask,
248
+ ),
249
+ slice_names=[f"layer-{ii}" for ii in range(target_height)],
250
+ band_names=variables,
251
+ slice_axis_name=slice_axis_name,
252
+ band_axis_name=band_axis_name,
253
+ x_axis_name=x_axis_name,
254
+ y_axis_name=y_axis_name,
255
+ )
256
+
257
+
258
+ def _percent_full(remaining: int, total: int, ndigits: int = 2) -> str:
259
+ return f"{round(100 * (total - remaining) / total, ndigits=ndigits)}% full ({remaining} remaining emtpy pixels)"
mapchete_eo/io/path.py ADDED
@@ -0,0 +1,155 @@
1
+ import hashlib
2
+ import logging
3
+ from contextlib import contextmanager
4
+ from enum import Enum
5
+ from tempfile import TemporaryDirectory
6
+ from typing import Generator, Tuple, Union
7
+ from xml.etree.ElementTree import Element, fromstring
8
+
9
+ import fsspec
10
+ import pystac
11
+ from mapchete.io import copy
12
+ from mapchete.path import MPath
13
+ from mapchete.settings import IORetrySettings
14
+ from retry import retry
15
+
16
+ from mapchete_eo.exceptions import AssetKeyError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ COMMON_RASTER_EXTENSIONS = [".tif", ".jp2"]
22
+
23
+
24
+ @retry(logger=logger, **dict(IORetrySettings()))
25
+ def open_xml(path: MPath) -> Element:
26
+ """Parse an XML file path into an etree root element."""
27
+ logger.debug("open %s", path)
28
+ return fromstring(path.read_text())
29
+
30
+
31
+ class ProductPathGenerationMethod(str, Enum):
32
+ """Option to generate product cache path."""
33
+
34
+ # <cache_basepath>/<product-id>
35
+ product_id = "product_id"
36
+
37
+ # <cache_basepath>/<product-hash>
38
+ hash = "hash"
39
+
40
+ # <cache_basepath>/<product-day>/<product-month>/<product-year>/<product-id>
41
+ date_day_first = "date_day_first"
42
+
43
+ # <cache_basepath>/<product-year>/<product-month>/<product-day>/<product-id>
44
+ date_year_first = "date_year_first"
45
+
46
+
47
+ def get_product_cache_path(
48
+ item: pystac.Item,
49
+ basepath: MPath,
50
+ path_generation_method: ProductPathGenerationMethod = ProductPathGenerationMethod.product_id,
51
+ ) -> MPath:
52
+ """
53
+ Create product path with high cardinality prefixes optimized for S3.
54
+
55
+ product_path_generation option:
56
+
57
+ "product_id":
58
+ <cache_basepath>/<product-id>
59
+
60
+ "product_hash":
61
+ <cache_basepath>/<product-hash>
62
+
63
+ "date_day_first":
64
+ <cache_basepath>/<product-day>/<product-month>/<product-year>/<product-id>
65
+
66
+ "date_year_first":
67
+ <cache_basepath>/<product-year>/<product-month>/<product-day>/<product-id>
68
+ """
69
+ path_generation_method = ProductPathGenerationMethod[path_generation_method]
70
+ if path_generation_method == ProductPathGenerationMethod.product_id:
71
+ return basepath / item.id
72
+
73
+ elif path_generation_method == ProductPathGenerationMethod.hash:
74
+ return basepath / hashlib.md5(f"{item.id}".encode()).hexdigest()
75
+
76
+ else:
77
+ if item.datetime is None: # pragma: no cover
78
+ raise AttributeError(f"stac item must have a valid datetime object: {item}")
79
+ elif path_generation_method == ProductPathGenerationMethod.date_day_first:
80
+ return (
81
+ basepath
82
+ / item.datetime.day
83
+ / item.datetime.month
84
+ / item.datetime.year
85
+ / item.id
86
+ )
87
+
88
+ elif path_generation_method == ProductPathGenerationMethod.date_year_first:
89
+ return (
90
+ basepath
91
+ / item.datetime.year
92
+ / item.datetime.month
93
+ / item.datetime.day
94
+ / item.id
95
+ )
96
+
97
+
98
+ def path_in_paths(path, existing_paths) -> bool:
99
+ """Check if path is contained in list of existing paths independent of path prefix."""
100
+ if path.startswith("s3://"):
101
+ return path.lstrip("s3://") in existing_paths
102
+ else:
103
+ for existing_path in existing_paths:
104
+ if existing_path.endswith(path):
105
+ return True
106
+ else:
107
+ return False
108
+
109
+
110
+ @contextmanager
111
+ @retry(logger=logger, **dict(IORetrySettings()))
112
+ def cached_path(path: MPath, active: bool = True) -> Generator[MPath, None, None]:
113
+ """If path is remote, download to temporary directory and return path."""
114
+ if active and path.is_remote():
115
+ with TemporaryDirectory() as tempdir:
116
+ tempfile = MPath(tempdir) / path.name
117
+ logger.debug("%s is remote, download to %s", path, tempfile)
118
+ copy(
119
+ path,
120
+ tempfile,
121
+ )
122
+ yield tempfile
123
+ else:
124
+ yield path
125
+
126
+
127
+ def asset_mpath(
128
+ item: pystac.Item,
129
+ asset: Union[str, Tuple[str, ...]],
130
+ fs: fsspec.AbstractFileSystem = None,
131
+ absolute_path: bool = True,
132
+ ) -> MPath:
133
+ """Return MPath instance with asset href."""
134
+
135
+ def _asset_mpath(
136
+ item: pystac.Item,
137
+ asset: str,
138
+ fs: fsspec.AbstractFileSystem = None,
139
+ absolute_path: bool = True,
140
+ ) -> MPath:
141
+ asset_path = MPath(item.assets[asset].href, fs=fs)
142
+ if absolute_path and not asset_path.is_absolute():
143
+ return MPath(item.get_self_href(), fs=fs).parent / asset_path
144
+ else:
145
+ return asset_path
146
+
147
+ for single_asset in asset if isinstance(asset, tuple) else (asset,):
148
+ try:
149
+ return _asset_mpath(item, single_asset, fs=fs, absolute_path=absolute_path)
150
+ except KeyError:
151
+ pass
152
+ else:
153
+ raise AssetKeyError(
154
+ f"{item.id} no asset named '{asset}' found in assets: {', '.join(item.assets.keys())}"
155
+ )