33from dataclasses import dataclass
44from functools import lru_cache
55from pathlib import Path
6- from typing import Literal
6+ from typing import Any , Literal
77
8- import dask . array
8+ import boto3
99import numpy as np
1010import rasterio
1111import xarray as xr
12+ import zarr
1213from boto3 import Session
1314from cyclopts import App
1415from dotenv import load_dotenv
15- from google .cloud .storage import Client as StorageClient
1616from numpy .typing import DTypeLike
1717from obstore .auth .boto3 import Boto3CredentialProvider
18- from obstore .auth .google import GoogleCredentialProvider
19- from obstore .store import GCSStore , LocalStore , ObjectStore , S3Store
18+ from obstore .store import LocalStore , ObjectStore , S3Store
2019from odc .geo .geobox import GeoBox
2120from odc .geo .xr import wrap_xr
2221from pyproj import Transformer
2625from tilebox .datasets .query import TimeInterval
2726from tilebox .workflows import Client as WorkflowsClient
2827from tilebox .workflows import ExecutionContext , Task
29- from tilebox .workflows .cache import GoogleStorageCache
28+ from tilebox .workflows .cache import AmazonS3Cache
3029from tilebox .workflows .observability .logging import configure_console_logging , configure_otel_logging_axiom , get_logger
3130from tilebox .workflows .observability .tracing import configure_otel_tracing_axiom
3231from zarr .codecs import BloscCodec
@@ -42,25 +41,30 @@ class S2Product:
4241 dtype : DTypeLike
4342
4443
44+ _SCENE_CLASSIFICATION_PRODUCT = S2Product ("scene_classification" , 20 , np .uint8 )
45+ _S2_BANDS = {
46+ "B02" : S2Product ("blue" , 10 , np .uint16 ),
47+ "B03" : S2Product ("green" , 10 , np .uint16 ),
48+ "B04" : S2Product ("red" , 10 , np .uint16 ),
49+ "B05" : S2Product ("rededge1" , 20 , np .uint16 ),
50+ "B06" : S2Product ("rededge2" , 20 , np .uint16 ),
51+ "B07" : S2Product ("rededge3" , 20 , np .uint16 ),
52+ "B08" : S2Product ("nir" , 10 , np .uint16 ),
53+ "B8A" : S2Product ("nir08" , 20 , np .uint16 ),
54+ "B11" : S2Product ("swir16" , 20 , np .uint16 ),
55+ "B12" : S2Product ("swir22" , 20 , np .uint16 ),
56+ }
4557_S2_PRODUCTS = {
46- "B02" : S2Product ("blue band" , 20 , np .uint16 ),
47- "B03" : S2Product ("green band" , 20 , np .uint16 ),
48- "B04" : S2Product ("red band" , 20 , np .uint16 ),
49- "B05" : S2Product ("red edge 1 band" , 20 , np .uint16 ),
50- "B06" : S2Product ("red edge 2 band" , 20 , np .uint16 ),
51- "B07" : S2Product ("red edge 3 band" , 20 , np .uint16 ),
52- "B0A" : S2Product ("broad NIR band" , 10 , np .uint16 ),
53- "B8A" : S2Product ("narrow NIR band" , 20 , np .uint16 ),
54- "B11" : S2Product ("SWIR 1 band" , 20 , np .uint16 ),
55- "B12" : S2Product ("SWIR 2 band" , 20 , np .uint16 ),
56- "SCL" : S2Product ("scene classification" , 20 , np .uint8 ),
58+ ** _S2_BANDS ,
59+ "SCL" : _SCENE_CLASSIFICATION_PRODUCT ,
5760}
58- """The S2 products we are reading for each granule"""
61+ """The S2 bands we are reading for each granule"""
62+
5963
60- ZARR_GCS_BUCKET = "creodias-data"
61- ZARR_GCS_PROJECT = "careful-striker-387117"
62- CACHE_PREFIX = "s2-clay"
64+ OUTPUT_BUCKET = "s2-clay"
65+ OUTPUT_ZARR_PREFIX = "sentinel2-zarr"
6366SPATIAL_CHUNK_SIZE = 256
67+ COMPRESSOR = BloscCodec (cname = "lz4hc" , clevel = 5 , shuffle = "shuffle" )
6468
6569
6670@lru_cache
@@ -88,9 +92,19 @@ def sentinel2_data_store() -> ObjectStore:
8892
8993
9094@lru_cache
91- def zarr_storage (prefix : str ) -> ObjectStore :
95+ def output_bucket (prefix : str ) -> ObjectStore :
9296 """An object store for writing the output Zarr datacube to"""
93- return GCSStore (bucket = ZARR_GCS_BUCKET , prefix = prefix , credential_provider = GoogleCredentialProvider ())
97+ return S3Store (
98+ bucket = OUTPUT_BUCKET ,
99+ endpoint = "https://obs.eu-nl.otc.t-systems.com" ,
100+ prefix = prefix ,
101+ access_key_id = os .environ ["OTC_ACCESS_KEY_ID" ],
102+ secret_access_key = os .environ ["OTC_SECRET_ACCESS_KEY" ],
103+ )
104+
105+
106+ def open_zarr_store (path : str ) -> ZarrObjectStore :
107+ return ZarrObjectStore (output_bucket (path ))
94108
95109
96110@dataclass (frozen = True , order = True )
@@ -221,42 +235,48 @@ class InitializeZarrDatacube(Task):
221235 n_x : int
222236
223237 def execute (self , context : ExecutionContext ) -> None :
224- dataset = xr .Dataset ()
225- encodings = {}
226- compressor = BloscCodec (cname = "lz4hc" , clevel = 5 , shuffle = "shuffle" )
238+ zarr_store = open_zarr_store (f"{ OUTPUT_ZARR_PREFIX } /{ context .current_task .job .id } /cube" ) # type: ignore[attr-defined]
239+
240+ n_band = len (_S2_BANDS )
241+
227242 for variable_name , product in _S2_PRODUCTS .items ():
228- dataset [variable_name ] = (
229- ["time" , "y" , "x" ],
230- dask .array .zeros (
231- (self .n_time , self .n_y , self .n_x ),
232- chunks = (1 , SPATIAL_CHUNK_SIZE , SPATIAL_CHUNK_SIZE ),
233- dtype = product .dtype ,
234- ),
243+ zarr .create_array (
244+ store = zarr_store ,
245+ name = variable_name ,
246+ shape = (self .n_time , self .n_y , self .n_x ),
247+ chunks = (1 , SPATIAL_CHUNK_SIZE , SPATIAL_CHUNK_SIZE ),
248+ dimension_names = ("time" , "y" , "x" ),
249+ dtype = product .dtype ,
250+ compressors = COMPRESSOR ,
251+ fill_value = 0 ,
252+ attributes = {
253+ "band_name" : product .name ,
254+ },
235255 )
236- dataset .attrs ["long_name" ] = product .name
237- encodings [variable_name ] = {"compressors" : (compressor ,)}
238256
239- dataset ["mosaic" ] = (
240- ["band" , "y" , "x" ],
241- dask .array .zeros (
242- (10 , self .n_y , self .n_x ),
243- chunks = (1 , SPATIAL_CHUNK_SIZE , SPATIAL_CHUNK_SIZE ),
244- dtype = np .uint16 ,
245- ),
257+ zarr .create_array (
258+ store = zarr_store ,
259+ name = "mosaic" ,
260+ shape = (n_band , self .n_y , self .n_x ),
261+ chunks = (1 , SPATIAL_CHUNK_SIZE , SPATIAL_CHUNK_SIZE ),
262+ dimension_names = ("band" , "y" , "x" ),
263+ dtype = np .uint16 ,
264+ compressors = COMPRESSOR ,
265+ fill_value = 0 ,
246266 )
247- encodings ["mosaic" ] = {"_FillValue" : 0 , "scale_factor" : 1 / 10000 , "compressors" : (compressor ,)}
248-
249- zarr_prefix = f"{ CACHE_PREFIX } /{ context .current_task .job .id } /cube" # type: ignore[attr-defined]
250- zarr_store = ZarrObjectStore (zarr_storage (zarr_prefix ))
251- dataset .to_zarr (
252- zarr_store , # type: ignore[arg-type]
253- encoding = encodings ,
254- compute = False ,
255- mode = "w" ,
256- consolidated = False ,
257- zarr_format = 3 ,
267+
268+ bands = zarr .create_array (
269+ store = zarr_store ,
270+ name = "band" ,
271+ shape = (n_band ,),
272+ chunks = (n_band ,),
273+ dimension_names = ("band" ,),
274+ dtype = "S20" ,
275+ compressors = COMPRESSOR ,
258276 )
259- dims = f"time={ self .n_time } , y={ self .n_y } , x={ self .n_x } "
277+ bands [:] = [product .name for product in _S2_PRODUCTS .values ()]
278+
279+ dims = f"time={ self .n_time } , y={ self .n_y } , x={ self .n_x } , band={ n_band } "
260280 logger .info (f"Successfully initialized a Zarr datacube with shape { dims } " )
261281 context .current_task .display = f"InitZarrCube({ dims } )" # type: ignore[attr-defined]
262282
@@ -294,11 +314,11 @@ class GranuleProductToZarr(Task):
294314 """The time index of the granule in the output Zarr datacube"""
295315
296316 def execute (self , context : ExecutionContext ) -> None :
297- variable_name = Path (self .product_location ).stem .split ("_" )[- 2 ] # B02, B03, B04 or SCL
317+ variable_name = Path (self .product_location ).stem .split ("_" )[- 2 ] # B02, B03, B04, ..., B11, B12, SCL
298318 context .current_task .display = f"ProductToZarr({ variable_name } )" # type: ignore[attr-defined]
299319
300320 tracer = context ._runner .tracer ._tracer # type: ignore[arg-defined], # noqa: SLF001
301- with tracer .start_span ("read_product" ):
321+ with tracer .start_span (f "read_product/ { variable_name } " ):
302322 logger .info (f"Reading product { self .product_location } " )
303323 buffer = bytes (sentinel2_data_store ().get (self .product_location ).bytes ())
304324 logger .info (f"Product read, size={ len (buffer )} bytes" )
@@ -307,34 +327,21 @@ def execute(self, context: ExecutionContext) -> None:
307327 arr = product .read (1 )
308328 src_grid = GeoBox (shape = arr .shape , affine = product .transform , crs = product .crs )
309329
310- with tracer .start_span ("reproject" ):
330+ with tracer .start_span (f "reproject/ { variable_name } " ):
311331 dataset = xr .Dataset ({variable_name : (["y" , "x" ], arr )})
312332 dataset [variable_name ] = wrap_xr (dataset [variable_name ], gbox = src_grid ) # add source spatial_ref metadata
313333
314334 target_grid : GeoBox = pickle .loads (context .job_cache ["target_grid" ]) # type: ignore[attr-defined] # noqa: S301
315335 target_dataset = dataset .odc .reproject (how = target_grid , resampling = Resampling .nearest , dst_nodata = 0 )
316- target_dataset = xr .Dataset (
317- {variable_name : (("time" , "y" , "x" ), target_dataset [variable_name ].expand_dims ("time" ).to_numpy ())}
318- )
319-
336+ reprojected_product = target_dataset [variable_name ].expand_dims ("time" ).to_numpy ()
320337 logger .info (f"Projected variable { variable_name } of product { self .product_location } to target grid" )
321338
322- with tracer .start_span ("write_zarr" ):
323- zarr_prefix = f"{ CACHE_PREFIX } /{ context .current_task .job .id } /cube" # type: ignore[attr-defined]
324- zarr_store = ZarrObjectStore (zarr_storage (zarr_prefix ))
325- target_dataset .to_zarr (
326- zarr_store , # type: ignore[arg-type]
327- region = {
328- "time" : slice (self .time_index , self .time_index + 1 ),
329- "y" : slice (0 , target_grid .shape .y ),
330- "x" : slice (0 , target_grid .shape .x ),
331- },
332- write_empty_chunks = False ,
333- safe_chunks = False , # our grid size is not an exact multiple of chunk size
334- consolidated = False ,
335- zarr_format = 3 ,
339+ with tracer .start_span (f"write_to_zarr/{ variable_name } " ):
340+ zarr_group = zarr .open_group (
341+ open_zarr_store (f"{ OUTPUT_ZARR_PREFIX } /{ context .current_task .job .id } /cube" ), mode = "a"
336342 )
337-
343+ product_array : zarr .Array = zarr_group [variable_name ] # type: ignore[arg-type]
344+ product_array [self .time_index , :, :] = reprojected_product
338345 logger .info (f"Successfully wrote variable { variable_name } to Zarr datacube" )
339346
340347
@@ -347,39 +354,45 @@ def execute(self, context: ExecutionContext) -> None:
347354
348355 tracer = context ._runner .tracer ._tracer # type: ignore[arg-defined], # noqa: SLF001
349356
350- zarr_prefix = f"{ CACHE_PREFIX } /{ context .current_task .job .id } /cube" # type: ignore[attr-defined]
351- zarr_store = ZarrObjectStore ( zarr_storage ( zarr_prefix ) )
357+ zarr_prefix = f"{ OUTPUT_ZARR_PREFIX } /{ context .current_task .job .id } /cube" # type: ignore[attr-defined]
358+ zarr_store = open_zarr_store ( zarr_prefix )
352359 cube = xr .open_zarr (zarr_store , zarr_format = 3 , consolidated = False )
353360
361+ mosaic : zarr .Array = zarr .open_group (zarr_store , mode = "a" )["mosaic" ] # type: ignore[arg-type]
362+
354363 valid = cube .SCL [:, y_start :y_end , x_start :x_end ].isin ([2 , 4 , 5 , 6 , 11 ]).compute ()
355364
356- for i , band in enumerate ([ "B04" , "B03" , "B02" ]): # red, green, blue
365+ for i , band in enumerate (_S2_BANDS ):
357366 with tracer .start_span (f"band_{ band } " ):
358367 has_data = cube [band ][:, y_start :y_end , x_start :x_end ] != 0
359- mosaic_arr = (
360- cube [band ][:, y_start :y_end , x_start :x_end ].where (valid & has_data ).quantile (0.25 , dim = "time" )
361- / 10000
362- ).compute ()
363- mosaic = xr .Dataset ({"mosaic" : mosaic_arr }).drop_vars ("quantile" ).expand_dims ({"band" : 1 })
364- mosaic .to_zarr (
365- zarr_store , # type: ignore[arg-type]
366- region = {
367- "band" : slice (i , i + 1 ),
368- "y" : slice (y_start , y_end ),
369- "x" : slice (x_start , x_end ),
370- },
371- write_empty_chunks = False ,
372- safe_chunks = False , # our grid size is not an exact multiple of chunk size
373- consolidated = False ,
374- zarr_format = 3 ,
368+ mosaic_band = (
369+ (
370+ cube [band ][:, y_start :y_end , x_start :x_end ]
371+ .where (valid & has_data )
372+ .quantile (0.25 , dim = "time" )
373+ .astype (np .uint16 )
374+ )
375+ .compute ()
376+ .to_numpy ()
375377 )
378+ mosaic [i , y_start :y_end , x_start :x_end ] = mosaic_band
376379
377380 context .progress ("compute-mosaic" ).done (1 )
378381
379382
380383app = App ()
381384
382385
386+ class OTCBucketCache (AmazonS3Cache ):
387+ def __init__ (self , bucket : str , s3_client : Any , prefix : str = "jobs" ) -> None :
388+ self .bucket = bucket
389+ self .prefix = Path (prefix )
390+ self ._s3 = s3_client
391+
392+ def group (self , key : str ) -> "OTCBucketCache" :
393+ return OTCBucketCache (self .bucket , self ._s3 , prefix = (self .prefix / key ).as_posix ())
394+
395+
383396@app .default
384397def main (tasks : Literal ["all" , "compute-only" , "data-only" ] = "all" , cluster : str | None = None ) -> None :
385398 if Path (".env" ).exists ():
@@ -393,7 +406,14 @@ def main(tasks: Literal["all", "compute-only", "data-only"] = "all", cluster: st
393406
394407 client = WorkflowsClient () # a workflow client for https://api.tilebox.com
395408
396- cache = GoogleStorageCache (StorageClient (project = ZARR_GCS_PROJECT ).bucket (ZARR_GCS_BUCKET ), prefix = CACHE_PREFIX )
409+ cache_client = boto3 .client (
410+ "s3" ,
411+ endpoint_url = "https://obs.eu-nl.otc.t-systems.com" ,
412+ aws_access_key_id = os .environ ["OTC_ACCESS_KEY_ID" ],
413+ aws_secret_access_key = os .environ ["OTC_SECRET_ACCESS_KEY" ],
414+ region_name = "eu-nl" ,
415+ )
416+ cache = OTCBucketCache (OUTPUT_BUCKET , cache_client , prefix = "cache/jobs" )
397417
398418 selected_tasks = [
399419 Sentinel2ToZarr ,
0 commit comments