Skip to content

Commit 867a860

Browse files
Adapt workflow for Open Telekom Cloud
1 parent 64feecb commit 867a860

2 files changed

Lines changed: 118 additions & 96 deletions

File tree

‎s2-clay/.env.example‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ AXIOM_API_KEY=
33
TILEBOX_API_KEY=
44
AXIOM_TRACES_DATASET=workflow-traces
55
AXIOM_LOGS_DATASET=workflow-logs
6+
OTC_ACCESS_KEY_ID=
7+
OTC_SECRET_ACCESS_KEY=

‎s2-clay/sentinel2zarr.py‎

Lines changed: 116 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,19 @@
33
from dataclasses import dataclass
44
from functools import lru_cache
55
from pathlib import Path
6-
from typing import Literal
6+
from typing import Any, Literal
77

8-
import dask.array
8+
import boto3
99
import numpy as np
1010
import rasterio
1111
import xarray as xr
12+
import zarr
1213
from boto3 import Session
1314
from cyclopts import App
1415
from dotenv import load_dotenv
15-
from google.cloud.storage import Client as StorageClient
1616
from numpy.typing import DTypeLike
1717
from 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
2019
from odc.geo.geobox import GeoBox
2120
from odc.geo.xr import wrap_xr
2221
from pyproj import Transformer
@@ -26,7 +25,7 @@
2625
from tilebox.datasets.query import TimeInterval
2726
from tilebox.workflows import Client as WorkflowsClient
2827
from tilebox.workflows import ExecutionContext, Task
29-
from tilebox.workflows.cache import GoogleStorageCache
28+
from tilebox.workflows.cache import AmazonS3Cache
3029
from tilebox.workflows.observability.logging import configure_console_logging, configure_otel_logging_axiom, get_logger
3130
from tilebox.workflows.observability.tracing import configure_otel_tracing_axiom
3231
from 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"
6366
SPATIAL_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

380383
app = 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
384397
def 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

Comments
 (0)