{ "process_graph": { "loadcollection1": { "process_id": "load_collection", "arguments": { "bands": { "from_parameter": "s2_data_bands" }, "featureflags": { "reprojection_type": "binning", "super_sampling": 2 }, "id": "SENTINEL2_L2A", "spatial_extent": { "from_parameter": "spatial_extent" }, "temporal_extent": { "from_parameter": "temporal_extent" } } }, "apply1": { "process_id": "apply", "arguments": { "data": { "from_node": "loadcollection1" }, "process": { "process_graph": { "add1": { "process_id": "add", "arguments": { "x": { "from_parameter": "x" }, "y": 0 } }, "multiply1": { "process_id": "multiply", "arguments": { "x": { "from_node": "add1" }, "y": 0.0001 }, "result": true } } } } }, "loadcollection2": { "process_id": "load_collection", "arguments": { "bands": [ "SCL" ], "id": "SENTINEL2_L2A", "spatial_extent": { "from_parameter": "spatial_extent" }, "temporal_extent": { "from_parameter": "temporal_extent" } } }, "reducedimension1": { "process_id": "reduce_dimension", "arguments": { "data": { "from_node": "loadcollection2" }, "dimension": "bands", "reducer": { "process_graph": { "arrayelement1": { "process_id": "array_element", "arguments": { "data": { "from_parameter": "data" }, "index": 0 } }, "eq1": { "process_id": "eq", "arguments": { "x": { "from_node": "arrayelement1" }, "y": 0 } }, "eq2": { "process_id": "eq", "arguments": { "x": { "from_node": "arrayelement1" }, "y": 3 } }, "or1": { "process_id": "or", "arguments": { "x": { "from_node": "eq1" }, "y": { "from_node": "eq2" } } }, "gt1": { "process_id": "gt", "arguments": { "x": { "from_node": "arrayelement1" }, "y": 7 } }, "or2": { "process_id": "or", "arguments": { "x": { "from_node": "or1" }, "y": { "from_node": "gt1" } } }, "multiply2": { "process_id": "multiply", "arguments": { "x": { "from_node": "or2" }, "y": 1.0 }, "result": true } } } } }, "mask1": { "process_id": "mask", "arguments": { "data": { "from_node": "apply1" }, "mask": { "from_node": "reducedimension1" } } }, "resamplespatial1": { "process_id": "resample_spatial", "arguments": { "align": "upper-left", "data": { "from_node": "reducedimension1" }, "method": "average", "projection": null, "resolution": 300 } }, "apply2": { "process_id": "apply", "arguments": { "data": { "from_node": "resamplespatial1" }, "process": { "process_graph": { "gte1": { "process_id": "gte", "arguments": { "x": { "from_parameter": "x" }, "y": 0.05 }, "result": true } } } } }, "applyneighborhood1": { "process_id": "apply_neighborhood", "arguments": { "data": { "from_node": "apply2" }, "overlap": [ { "dimension": "x", "value": 166, "unit": "px" }, { "dimension": "y", "value": 166, "unit": "px" } ], "process": { "process_graph": { "runudf1": { "process_id": "run_udf", "arguments": { "data": { "from_parameter": "data" }, "runtime": "Python", "udf": "from scipy.ndimage import distance_transform_edt\nimport numpy as np\nimport xarray as xr\nfrom openeo.udf import XarrayDataCube\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"\n Expects the cloud mask as input (in contrast to ``distance_transform_edt``).\n This is necessary, because ``apply_neighborhood`` pads the input with zeros and not ones.\n \"\"\"\n array = cube.get_array()\n # This special case appears to create some issues, so we skip it\n if not array.any():\n return XarrayDataCube(\n xr.DataArray(np.full_like(array, np.inf), dims=[\"t\", \"y\", \"x\"])\n )\n distance = distance_transform_edt(np.logical_not(array))\n return XarrayDataCube(xr.DataArray(distance, dims=[\"t\", \"y\", \"x\"]))\n" }, "result": true } } }, "size": [ { "dimension": "t", "value": "P1D" }, { "dimension": "x", "value": 332, "unit": "px" }, { "dimension": "y", "value": 332, "unit": "px" } ] } }, "apply3": { "process_id": "apply", "arguments": { "data": { "from_node": "applyneighborhood1" }, "process": { "process_graph": { "subtract1": { "process_id": "subtract", "arguments": { "x": { "from_parameter": "x" }, "y": 1 } }, "divide1": { "process_id": "divide", "arguments": { "x": { "from_node": "subtract1" }, "y": 16.666666666666668 }, "result": true } } } } }, "apply4": { "process_id": "apply", "arguments": { "data": { "from_node": "apply3" }, "process": { "process_graph": { "clip1": { "process_id": "clip", "arguments": { "max": 1, "min": 0, "x": { "from_parameter": "x" } }, "result": true } } } } }, "adddimension1": { "process_id": "add_dimension", "arguments": { "data": { "from_node": "apply4" }, "label": "distance_score", "name": "bands", "type": "bands" } }, "mergecubes1": { "process_id": "merge_cubes", "arguments": { "cube1": { "from_node": "mask1" }, "cube2": { "from_node": "adddimension1" } } }, "dimensionlabels1": { "process_id": "dimension_labels", "arguments": { "data": { "from_node": "apply1" }, "dimension": "t" } }, "loadcollection3": { "process_id": "load_collection", "arguments": { "bands": { "from_parameter": "s3_data_bands" }, "featureflags": { "reprojection_type": "binning", "super_sampling": 2 }, "id": "SENTINEL3_SYN_L2_SYN", "spatial_extent": { "from_parameter": "spatial_extent" }, "temporal_extent": { "from_parameter": "temporal_extent" } } }, "apply5": { "process_id": "apply", "arguments": { "data": { "from_node": "loadcollection3" }, "process": { "process_graph": { "add2": { "process_id": "add", "arguments": { "x": { "from_parameter": "x" }, "y": 0 } }, "multiply3": { "process_id": "multiply", "arguments": { "x": { "from_node": "add2" }, "y": 0.0001 }, "result": true } } } } }, "loadcollection4": { "process_id": "load_collection", "arguments": { "bands": [ "CLOUD_flags" ], "id": "SENTINEL3_SYN_L2_SYN", "spatial_extent": { "from_parameter": "spatial_extent" }, "temporal_extent": { "from_parameter": "temporal_extent" } } }, "reducedimension2": { "process_id": "reduce_dimension", "arguments": { "data": { "from_node": "loadcollection4" }, "dimension": "bands", "reducer": { "process_graph": { "arrayelement2": { "process_id": "array_element", "arguments": { "data": { "from_parameter": "data" }, "index": 0 } }, "gt2": { "process_id": "gt", "arguments": { "x": { "from_node": "arrayelement2" }, "y": 0 }, "result": true } } } } }, "applyneighborhood2": { "process_id": "apply_neighborhood", "arguments": { "data": { "from_node": "reducedimension2" }, "overlap": [ { "dimension": "x", "value": 166, "unit": "px" }, { "dimension": "y", "value": 166, "unit": "px" } ], "process": { "process_graph": { "runudf2": { "process_id": "run_udf", "arguments": { "data": { "from_parameter": "data" }, "runtime": "Python", "udf": "from scipy.ndimage import distance_transform_edt\nimport numpy as np\nimport xarray as xr\nfrom openeo.udf import XarrayDataCube\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"\n Expects the cloud mask as input (in contrast to ``distance_transform_edt``).\n This is necessary, because ``apply_neighborhood`` pads the input with zeros and not ones.\n \"\"\"\n array = cube.get_array()\n # This special case appears to create some issues, so we skip it\n if not array.any():\n return XarrayDataCube(\n xr.DataArray(np.full_like(array, np.inf), dims=[\"t\", \"y\", \"x\"])\n )\n distance = distance_transform_edt(np.logical_not(array))\n return XarrayDataCube(xr.DataArray(distance, dims=[\"t\", \"y\", \"x\"]))\n" }, "result": true } } }, "size": [ { "dimension": "t", "value": "P1D" }, { "dimension": "x", "value": 332, "unit": "px" }, { "dimension": "y", "value": 332, "unit": "px" } ] } }, "apply6": { "process_id": "apply", "arguments": { "data": { "from_node": "applyneighborhood2" }, "process": { "process_graph": { "subtract2": { "process_id": "subtract", "arguments": { "x": { "from_parameter": "x" }, "y": 1 } }, "divide2": { "process_id": "divide", "arguments": { "x": { "from_node": "subtract2" }, "y": 16.666666666666668 }, "result": true } } } } }, "apply7": { "process_id": "apply", "arguments": { "data": { "from_node": "apply6" }, "process": { "process_graph": { "clip2": { "process_id": "clip", "arguments": { "max": 1, "min": 0, "x": { "from_parameter": "x" } }, "result": true } } } } }, "adddimension2": { "process_id": "add_dimension", "arguments": { "data": { "from_node": "apply7" }, "label": "distance_score", "name": "bands", "type": "bands" } }, "mergecubes2": { "process_id": "merge_cubes", "arguments": { "cube1": { "from_node": "apply5" }, "cube2": { "from_node": "adddimension2" } } }, "applydimension1": { "process_id": "apply_dimension", "arguments": { "context": { "temporal_extent_input": { "from_parameter": "temporal_extent" }, "temporal_extent_target": { "from_parameter": "temporal_extent_target" }, "interval_days": { "from_parameter": "interval_days" }, "sigma_doy": 10 }, "data": { "from_node": "mergecubes2" }, "dimension": "t", "process": { "process_graph": { "runudf3": { "process_id": "run_udf", "arguments": { "context": { "from_parameter": "context" }, "data": { "from_parameter": "data" }, "runtime": "Python", "udf": "import numpy as np\nimport pandas as pd\n\nimport xarray as xr\nfrom openeo.metadata import CubeMetadata\nfrom datetime import datetime, timezone\n\n\nEPS = 1e-5\n\n\ndef apply_datacube(cube: xr.DataArray, context: dict) -> xr.DataArray:\n \"\"\"\n Computes a composite time series. The input time series is converted to the time series passed\n as ``\"t_target\"`` in ``context``, by computing a weighted sum of input images for each time step in\n ``t_target``. The inputs are weighted by their temporal distance to the target time step and by the distance to\n cloud score (the ``\"distance_score\"`` band of the inputs).\n\n Expects ``cube`` to be an array of dimensions (t, bands, y, x)\n \"\"\"\n\n band_names = cube.get_index(\"bands\")\n assert \"bands\" in cube.dims, (\n f\"cube must have a 'bands' dimension, found '{cube.dims}'\"\n )\n assert \"distance_score\" in band_names, (\n f\"Input cube must have a band 'distance_score' in addition to the input bands. Found bands '{band_names}'\"\n )\n\n sigma_doy = context[\"sigma_doy\"]\n t_target = get_t_target_from_context(context)\n temporal_score = compute_temporal_score(cube.t, t_target, sigma_doy)\n distance_score = cube.sel(bands=\"distance_score\")\n data_bands = cube.sel(bands=[b for b in band_names if b != \"distance_score\"])\n\n composite = _compute_combined_score_no_intermediates(\n distance_score, temporal_score, data_bands\n )\n\n renamed = composite.rename({\"t_target\": \"t\"})\n dims = (\"t\", \"bands\", \"y\", \"x\")\n return renamed.transpose(*dims)\n\n\ndef apply_metadata(metadata: CubeMetadata, context: dict) -> CubeMetadata:\n t_target = get_t_target_from_context(context)\n t_target_str = [d.isoformat() for d in t_target.to_pydatetime()]\n\n metadata = metadata.rename_labels(dimension=\"t\", target=t_target_str)\n metadata = metadata.filter_bands(\n [\n band.name\n for band in metadata.band_dimension.bands\n if band.name != \"distance_score\"\n ]\n )\n return metadata\n\n\ndef compute_temporal_score(\n t: pd.DatetimeIndex, t_target: pd.DatetimeIndex, sigma_doy: float\n) -> xr.DataArray:\n \"\"\"\n Compute the temporal weight for each input and output time step.\n Generates a two-dimensional score, mapping input time steps to output time steps (``len(t) * len(t_target)`` entries).\n\n :param t: time stamps of the input time series\n :param t_target: target time stamps for which the composites are to be computed\n :param sigma_doy: standard deviation of the gaussian window used for temporal weighting\n \"\"\"\n t_values = t.values.astype(\"datetime64[D]\")\n t_target_values = t_target.values.astype(\"datetime64[D]\")\n difference_matrix = t_values[:, np.newaxis] - t_target_values[np.newaxis, :]\n\n arr = np.exp(-0.5 * np.square(difference_matrix.astype(int)) / np.square(sigma_doy))\n return xr.DataArray(\n arr,\n coords={\"t\": t, \"t_target\": t_target},\n dims=[\"t\", \"t_target\"],\n )\n\n\ndef compute_combined_score(\n distance_score: xr.DataArray, temporal_score: xr.DataArray\n) -> xr.DataArray:\n # equivalent to (distance_score * temporal_score).sum(dim=\"t\")\n # return xr.dot(distance_score, temporal_score, dim=\"t\")\n\n combined = distance_score * temporal_score\n # TODO remove EPS and use mask instead\n return combined / (combined.sum(dim=\"t\") + EPS)\n\n\ndef _compute_combined_score_no_intermediates(\n distance_score: xr.DataArray, temporal_score: xr.DataArray, bands: xr.DataArray\n) -> xr.DataArray:\n res = xr.apply_ufunc(\n _compute_normalized_composite,\n distance_score,\n temporal_score,\n bands,\n input_core_dims=[[\"t\", \"y\", \"x\"], [\"t_target\", \"t\"], [\"t\", \"bands\", \"y\", \"x\"]],\n output_core_dims=[[\"t_target\", \"bands\", \"y\", \"x\"]],\n vectorize=True,\n )\n return res\n\n\ndef _compute_normalized_composite(distance_score, temporal_score, bands, **kwargs):\n \"\"\"\n Compute the combined distance-to-cloud and temporal score and the weighted sum applying the score by pixel and\n input/target time stamp to generate the composites\n \"\"\"\n score = np.einsum(\"tyx,Tt->Ttyx\", distance_score, temporal_score)\n # consider pixels as not-observed if the first band has a nan value\n score_masked = np.where(np.isnan(bands[:, 0, ...]), 0, score)\n\n normalization_flat = np.sum(score_masked, axis=1)\n normalization = normalization_flat[:, np.newaxis, ...]\n score_normalized = score_masked / normalization\n\n finite_bands = np.where(np.isfinite(bands), bands, 0)\n weighted_composite = np.einsum(\n \"Ttyx,tbyx->Tbyx\", score_normalized, finite_bands\n ) # original\n\n no_data_mask = (normalization_flat == 0)[:, np.newaxis, ...] | (\n weighted_composite <= 0\n )\n weighted_composite_masked = np.where(no_data_mask, np.nan, weighted_composite)\n return weighted_composite_masked\n\n\ndef compute_t_target(temporal_extent, interval_days) -> pd.DatetimeIndex:\n t_target = xr.date_range(\n temporal_extent[0],\n temporal_extent[1],\n freq=f\"{interval_days}D\",\n inclusive=\"left\",\n )\n return t_target\n\n\ndef apply_datacube_new(cube: xr.DataArray, context: dict) -> xr.DataArray:\n band_names = cube.get_index(\"bands\")\n assert \"bands\" in cube.dims, (\n f\"cube must have a 'bands' dimension, found '{cube.dims}'\"\n )\n assert \"distance_score\" in band_names, (\n f\"Input cube must have a band 'distance_score' in addition to the input bands. Found bands '{band_names}'\"\n )\n assert \"t_target\" in context, (\n f\"The target time dimension 't_target' must be provided in the 'context' dict. Found keys '{context.keys()}' in 'context'.\"\n )\n\n t_target = pd.DatetimeIndex(\n [datetime.fromisoformat(t) for t in context[\"t_target\"]]\n )\n sigma_doy = context.get(\"sigma_doy\", 5)\n temporal_score = compute_temporal_score(cube.t, t_target, sigma_doy)\n distance_score = cube.sel(bands=\"distance_score\")\n data_bands = cube.sel(bands=[b for b in band_names if b != \"distance_score\"])\n\n composite = _compute_combined_score_ng(distance_score, temporal_score, data_bands)\n\n renamed = composite.rename({\"t_target\": \"t\"})\n dims = (\"t\", \"bands\", \"y\", \"x\")\n return renamed.transpose(*dims)\n\n\ndef _compute_combined_score_ng(distance_score, temporal_score, bands):\n mosaic_days = 100\n composites = {}\n\n # TODO convert to \"rolling\"?\n for middle_date in temporal_score.t_target:\n window_start = middle_date - pd.Timedelta(days=mosaic_days / 2)\n window_end = middle_date + pd.Timedelta(days=mosaic_days / 2)\n\n windowed_bands = bands.sel(t=slice(window_start, window_end))\n windowed_bands = xr.where(\n np.abs(windowed_bands.mean(dim=\"t\")) < 5, windowed_bands, np.nan\n )\n windowed_distance_score = distance_score.sel(t=slice(window_start, window_end))\n windowed_temporal_score = temporal_score.sel(\n t=slice(window_start, window_end), t_target=middle_date\n )\n\n score = windowed_distance_score * windowed_temporal_score\n score_nan_masked = xr.where(np.isnan(windowed_bands.isel(bands=0)), 0, score)\n # score_nan_masked = score\n normalizing_coefficient = score_nan_masked.sum(dim=\"t\") + 1e-5\n normalized_score = score_nan_masked / normalizing_coefficient\n\n weighted_bands = normalized_score * windowed_bands\n composite = weighted_bands.sum(skipna=True, dim=\"t\")\n composite = xr.where(normalized_score.sum(dim=\"t\") == 0, np.nan, composite)\n composites[pd.to_datetime(middle_date.item()).strftime(\"%Y-%m-%d\")] = composite\n composite_da = xr.concat(\n [composites[t_target] for t_target in composites],\n dim=xr.IndexVariable(\"t_target\", list(composites.keys())),\n )\n return composite_da\n\n\ndef get_t_target_from_context(context):\n if isinstance(context, dict): # from user parameters\n temporal_extent = context.get(\"temporal_extent_target\")\n if temporal_extent is None or len(temporal_extent) == 0: # use input temporal extent as a fallback if temporal extent target is not set\n temporal_extent = context[\"temporal_extent_input\"]\n interval_days = context[\"interval_days\"]\n t_target = compute_t_target(temporal_extent, interval_days)\n else:\n t_target = context\n\n return t_target\n" }, "result": true } } } } }, "filterbands1": { "process_id": "filter_bands", "arguments": { "bands": { "from_parameter": "s3_data_bands" }, "data": { "from_node": "applydimension1" } } }, "applykernel1": { "process_id": "apply_kernel", "arguments": { "border": 0, "data": { "from_node": "filterbands1" }, "factor": 1.0, "kernel": [ [ 0.00296902, 0.01330621, 0.02193823, 0.01330621, 0.00296902 ], [ 0.01330621, 0.0596343, 0.09832033, 0.0596343, 0.01330621 ], [ 0.02193823, 0.09832033, 0.16210282, 0.09832033, 0.02193823 ], [ 0.01330621, 0.0596343, 0.09832033, 0.0596343, 0.01330621 ], [ 0.00296902, 0.01330621, 0.02193823, 0.01330621, 0.00296902 ] ], "replace_invalid": 0 } }, "applydimension2": { "process_id": "apply_dimension", "arguments": { "context": { "from_node": "dimensionlabels1" }, "data": { "from_node": "applykernel1" }, "dimension": "t", "process": { "process_graph": { "runudf4": { "process_id": "run_udf", "arguments": { "context": { "from_parameter": "context" }, "data": { "from_parameter": "data" }, "runtime": "Python", "udf": "import pandas as pd\nimport xarray as xr\nfrom openeo.metadata import CubeMetadata\n\nfrom datetime import datetime, timezone\n\n\ndef apply_datacube(cube: xr.DataArray, context) -> xr.DataArray:\n \"\"\"\n Interpolate cube to the time series passed as context.\n Currently (2025-09-30), on the CDSE OpenEO backend, the target time series can only be passed as the complete context,\n when using the ``dimension_labels`` process. This is necessary to pass the S2 time series directly\n from an S2 cube with a user-defined temporal extent.\n This UDF supports two cases: passing the target time series ``t_target`` as a pd.Datetimeidex\n (this is what happens when chaining with the dimension_labels process) and passing dictionary as a context,\n which defines the time series through the parameters ``temporal_extent`` (left incl, right excl)\n and ``interval_days``.\n\n Expects ``cube`` to be an array of dimensions (t, bands, y, x)\n \"\"\"\n assert \"bands\" in cube.dims, (\n f\"cube must have a 'bands' dimension, found '{cube.dims}'\"\n )\n\n t_target = get_t_target_from_context(context)\n band_suffix = get_target_band_name_suffix_from_context(context)\n\n # The Wizard passes the temporal extent as a xr.IndexVariable which cannot be understood by xr.interp\n if isinstance(t_target, xr.IndexVariable) or isinstance(t_target, xr.DataArray):\n t_target = t_target.values\n t_target = pd.to_datetime(t_target)\n\n if getattr(t_target, \"tz\", None) is not None:\n t_target = t_target.tz_localize(None)\n\n interpolated = cube.interp(t=t_target)\n interpolated = interpolated.assign_coords(\n bands=[f\"{b}{band_suffix}\" for b in interpolated.coords[\"bands\"].values]\n )\n dims = (\"t\", \"bands\", \"y\", \"x\")\n return interpolated.transpose(*dims)\n\n\ndef apply_metadata(metadata: CubeMetadata, context) -> CubeMetadata:\n t_target = get_t_target_from_context(context)\n band_suffix = get_target_band_name_suffix_from_context(context)\n\n metadata = metadata.rename_labels(dimension=\"t\", target=t_target)\n metadata = metadata.rename_labels(dimension=\"bands\", target=[f\"{b}{band_suffix}\" for b in metadata.band_names])\n return metadata\n\ndef get_target_band_name_suffix_from_context(context):\n if isinstance(context, dict):\n return context.get(\"target_band_name_suffix\", \"\")\n return \"\"\n\ndef get_t_target_from_context(context):\n if isinstance(context, dict): # from user parameters\n temporal_extent = context.get(\"temporal_extent_target\")\n if temporal_extent is None or len(temporal_extent) == 0: # use input temporal extent as a fallback if temporal extent target is not set\n temporal_extent = context[\"temporal_extent_input\"]\n interval_days = context[\"interval_days\"]\n t_target = compute_t_target(temporal_extent, interval_days)\n else:\n t_target = context\n\n return t_target\n\n\ndef compute_t_target(temporal_extent, interval_days) -> pd.DatetimeIndex:\n t_target = xr.date_range(\n temporal_extent[0],\n temporal_extent[1],\n freq=f\"{interval_days}D\",\n inclusive=\"left\",\n )\n return t_target\n" }, "result": true } } } } }, "mergecubes3": { "process_id": "merge_cubes", "arguments": { "cube1": { "from_node": "mergecubes1" }, "cube2": { "from_node": "applydimension2" } } }, "applydimension3": { "process_id": "apply_dimension", "arguments": { "context": { "temporal_extent_input": { "from_parameter": "temporal_extent" }, "temporal_extent_target": { "from_parameter": "temporal_extent_target" }, "interval_days": { "from_parameter": "interval_days" }, "sigma_doy": 20 }, "data": { "from_node": "mergecubes3" }, "dimension": "t", "process": { "process_graph": { "runudf5": { "process_id": "run_udf", "arguments": { "context": { "from_parameter": "context" }, "data": { "from_parameter": "data" }, "runtime": "Python", "udf": "import numpy as np\nimport pandas as pd\n\nimport xarray as xr\nfrom openeo.metadata import CubeMetadata\nfrom datetime import datetime, timezone\n\n\nEPS = 1e-5\n\n\ndef apply_datacube(cube: xr.DataArray, context: dict) -> xr.DataArray:\n \"\"\"\n Computes a composite time series. The input time series is converted to the time series passed\n as ``\"t_target\"`` in ``context``, by computing a weighted sum of input images for each time step in\n ``t_target``. The inputs are weighted by their temporal distance to the target time step and by the distance to\n cloud score (the ``\"distance_score\"`` band of the inputs).\n\n Expects ``cube`` to be an array of dimensions (t, bands, y, x)\n \"\"\"\n\n band_names = cube.get_index(\"bands\")\n assert \"bands\" in cube.dims, (\n f\"cube must have a 'bands' dimension, found '{cube.dims}'\"\n )\n assert \"distance_score\" in band_names, (\n f\"Input cube must have a band 'distance_score' in addition to the input bands. Found bands '{band_names}'\"\n )\n\n sigma_doy = context[\"sigma_doy\"]\n t_target = get_t_target_from_context(context)\n temporal_score = compute_temporal_score(cube.t, t_target, sigma_doy)\n distance_score = cube.sel(bands=\"distance_score\")\n data_bands = cube.sel(bands=[b for b in band_names if b != \"distance_score\"])\n\n composite = _compute_combined_score_no_intermediates(\n distance_score, temporal_score, data_bands\n )\n\n renamed = composite.rename({\"t_target\": \"t\"})\n dims = (\"t\", \"bands\", \"y\", \"x\")\n return renamed.transpose(*dims)\n\n\ndef apply_metadata(metadata: CubeMetadata, context: dict) -> CubeMetadata:\n t_target = get_t_target_from_context(context)\n t_target_str = [d.isoformat() for d in t_target.to_pydatetime()]\n\n metadata = metadata.rename_labels(dimension=\"t\", target=t_target_str)\n metadata = metadata.filter_bands(\n [\n band.name\n for band in metadata.band_dimension.bands\n if band.name != \"distance_score\"\n ]\n )\n return metadata\n\n\ndef compute_temporal_score(\n t: pd.DatetimeIndex, t_target: pd.DatetimeIndex, sigma_doy: float\n) -> xr.DataArray:\n \"\"\"\n Compute the temporal weight for each input and output time step.\n Generates a two-dimensional score, mapping input time steps to output time steps (``len(t) * len(t_target)`` entries).\n\n :param t: time stamps of the input time series\n :param t_target: target time stamps for which the composites are to be computed\n :param sigma_doy: standard deviation of the gaussian window used for temporal weighting\n \"\"\"\n t_values = t.values.astype(\"datetime64[D]\")\n t_target_values = t_target.values.astype(\"datetime64[D]\")\n difference_matrix = t_values[:, np.newaxis] - t_target_values[np.newaxis, :]\n\n arr = np.exp(-0.5 * np.square(difference_matrix.astype(int)) / np.square(sigma_doy))\n return xr.DataArray(\n arr,\n coords={\"t\": t, \"t_target\": t_target},\n dims=[\"t\", \"t_target\"],\n )\n\n\ndef compute_combined_score(\n distance_score: xr.DataArray, temporal_score: xr.DataArray\n) -> xr.DataArray:\n # equivalent to (distance_score * temporal_score).sum(dim=\"t\")\n # return xr.dot(distance_score, temporal_score, dim=\"t\")\n\n combined = distance_score * temporal_score\n # TODO remove EPS and use mask instead\n return combined / (combined.sum(dim=\"t\") + EPS)\n\n\ndef _compute_combined_score_no_intermediates(\n distance_score: xr.DataArray, temporal_score: xr.DataArray, bands: xr.DataArray\n) -> xr.DataArray:\n res = xr.apply_ufunc(\n _compute_normalized_composite,\n distance_score,\n temporal_score,\n bands,\n input_core_dims=[[\"t\", \"y\", \"x\"], [\"t_target\", \"t\"], [\"t\", \"bands\", \"y\", \"x\"]],\n output_core_dims=[[\"t_target\", \"bands\", \"y\", \"x\"]],\n vectorize=True,\n )\n return res\n\n\ndef _compute_normalized_composite(distance_score, temporal_score, bands, **kwargs):\n \"\"\"\n Compute the combined distance-to-cloud and temporal score and the weighted sum applying the score by pixel and\n input/target time stamp to generate the composites\n \"\"\"\n score = np.einsum(\"tyx,Tt->Ttyx\", distance_score, temporal_score)\n # consider pixels as not-observed if the first band has a nan value\n score_masked = np.where(np.isnan(bands[:, 0, ...]), 0, score)\n\n normalization_flat = np.sum(score_masked, axis=1)\n normalization = normalization_flat[:, np.newaxis, ...]\n score_normalized = score_masked / normalization\n\n finite_bands = np.where(np.isfinite(bands), bands, 0)\n weighted_composite = np.einsum(\n \"Ttyx,tbyx->Tbyx\", score_normalized, finite_bands\n ) # original\n\n no_data_mask = (normalization_flat == 0)[:, np.newaxis, ...] | (\n weighted_composite <= 0\n )\n weighted_composite_masked = np.where(no_data_mask, np.nan, weighted_composite)\n return weighted_composite_masked\n\n\ndef compute_t_target(temporal_extent, interval_days) -> pd.DatetimeIndex:\n t_target = xr.date_range(\n temporal_extent[0],\n temporal_extent[1],\n freq=f\"{interval_days}D\",\n inclusive=\"left\",\n )\n return t_target\n\n\ndef apply_datacube_new(cube: xr.DataArray, context: dict) -> xr.DataArray:\n band_names = cube.get_index(\"bands\")\n assert \"bands\" in cube.dims, (\n f\"cube must have a 'bands' dimension, found '{cube.dims}'\"\n )\n assert \"distance_score\" in band_names, (\n f\"Input cube must have a band 'distance_score' in addition to the input bands. Found bands '{band_names}'\"\n )\n assert \"t_target\" in context, (\n f\"The target time dimension 't_target' must be provided in the 'context' dict. Found keys '{context.keys()}' in 'context'.\"\n )\n\n t_target = pd.DatetimeIndex(\n [datetime.fromisoformat(t) for t in context[\"t_target\"]]\n )\n sigma_doy = context.get(\"sigma_doy\", 5)\n temporal_score = compute_temporal_score(cube.t, t_target, sigma_doy)\n distance_score = cube.sel(bands=\"distance_score\")\n data_bands = cube.sel(bands=[b for b in band_names if b != \"distance_score\"])\n\n composite = _compute_combined_score_ng(distance_score, temporal_score, data_bands)\n\n renamed = composite.rename({\"t_target\": \"t\"})\n dims = (\"t\", \"bands\", \"y\", \"x\")\n return renamed.transpose(*dims)\n\n\ndef _compute_combined_score_ng(distance_score, temporal_score, bands):\n mosaic_days = 100\n composites = {}\n\n # TODO convert to \"rolling\"?\n for middle_date in temporal_score.t_target:\n window_start = middle_date - pd.Timedelta(days=mosaic_days / 2)\n window_end = middle_date + pd.Timedelta(days=mosaic_days / 2)\n\n windowed_bands = bands.sel(t=slice(window_start, window_end))\n windowed_bands = xr.where(\n np.abs(windowed_bands.mean(dim=\"t\")) < 5, windowed_bands, np.nan\n )\n windowed_distance_score = distance_score.sel(t=slice(window_start, window_end))\n windowed_temporal_score = temporal_score.sel(\n t=slice(window_start, window_end), t_target=middle_date\n )\n\n score = windowed_distance_score * windowed_temporal_score\n score_nan_masked = xr.where(np.isnan(windowed_bands.isel(bands=0)), 0, score)\n # score_nan_masked = score\n normalizing_coefficient = score_nan_masked.sum(dim=\"t\") + 1e-5\n normalized_score = score_nan_masked / normalizing_coefficient\n\n weighted_bands = normalized_score * windowed_bands\n composite = weighted_bands.sum(skipna=True, dim=\"t\")\n composite = xr.where(normalized_score.sum(dim=\"t\") == 0, np.nan, composite)\n composites[pd.to_datetime(middle_date.item()).strftime(\"%Y-%m-%d\")] = composite\n composite_da = xr.concat(\n [composites[t_target] for t_target in composites],\n dim=xr.IndexVariable(\"t_target\", list(composites.keys())),\n )\n return composite_da\n\n\ndef get_t_target_from_context(context):\n if isinstance(context, dict): # from user parameters\n temporal_extent = context.get(\"temporal_extent_target\")\n if temporal_extent is None or len(temporal_extent) == 0: # use input temporal extent as a fallback if temporal extent target is not set\n temporal_extent = context[\"temporal_extent_input\"]\n interval_days = context[\"interval_days\"]\n t_target = compute_t_target(temporal_extent, interval_days)\n else:\n t_target = context\n\n return t_target\n" }, "result": true } } } } }, "applydimension4": { "process_id": "apply_dimension", "arguments": { "context": { "temporal_extent_input": { "from_parameter": "temporal_extent" }, "temporal_extent_target": { "from_parameter": "temporal_extent_target" }, "interval_days": { "from_parameter": "interval_days" }, "target_band_name_suffix": "_interpolated" }, "data": { "from_node": "applykernel1" }, "dimension": "t", "process": { "process_graph": { "runudf6": { "process_id": "run_udf", "arguments": { "context": { "from_parameter": "context" }, "data": { "from_parameter": "data" }, "runtime": "Python", "udf": "import pandas as pd\nimport xarray as xr\nfrom openeo.metadata import CubeMetadata\n\nfrom datetime import datetime, timezone\n\n\ndef apply_datacube(cube: xr.DataArray, context) -> xr.DataArray:\n \"\"\"\n Interpolate cube to the time series passed as context.\n Currently (2025-09-30), on the CDSE OpenEO backend, the target time series can only be passed as the complete context,\n when using the ``dimension_labels`` process. This is necessary to pass the S2 time series directly\n from an S2 cube with a user-defined temporal extent.\n This UDF supports two cases: passing the target time series ``t_target`` as a pd.Datetimeidex\n (this is what happens when chaining with the dimension_labels process) and passing dictionary as a context,\n which defines the time series through the parameters ``temporal_extent`` (left incl, right excl)\n and ``interval_days``.\n\n Expects ``cube`` to be an array of dimensions (t, bands, y, x)\n \"\"\"\n assert \"bands\" in cube.dims, (\n f\"cube must have a 'bands' dimension, found '{cube.dims}'\"\n )\n\n t_target = get_t_target_from_context(context)\n band_suffix = get_target_band_name_suffix_from_context(context)\n\n # The Wizard passes the temporal extent as a xr.IndexVariable which cannot be understood by xr.interp\n if isinstance(t_target, xr.IndexVariable) or isinstance(t_target, xr.DataArray):\n t_target = t_target.values\n t_target = pd.to_datetime(t_target)\n\n if getattr(t_target, \"tz\", None) is not None:\n t_target = t_target.tz_localize(None)\n\n interpolated = cube.interp(t=t_target)\n interpolated = interpolated.assign_coords(\n bands=[f\"{b}{band_suffix}\" for b in interpolated.coords[\"bands\"].values]\n )\n dims = (\"t\", \"bands\", \"y\", \"x\")\n return interpolated.transpose(*dims)\n\n\ndef apply_metadata(metadata: CubeMetadata, context) -> CubeMetadata:\n t_target = get_t_target_from_context(context)\n band_suffix = get_target_band_name_suffix_from_context(context)\n\n metadata = metadata.rename_labels(dimension=\"t\", target=t_target)\n metadata = metadata.rename_labels(dimension=\"bands\", target=[f\"{b}{band_suffix}\" for b in metadata.band_names])\n return metadata\n\ndef get_target_band_name_suffix_from_context(context):\n if isinstance(context, dict):\n return context.get(\"target_band_name_suffix\", \"\")\n return \"\"\n\ndef get_t_target_from_context(context):\n if isinstance(context, dict): # from user parameters\n temporal_extent = context.get(\"temporal_extent_target\")\n if temporal_extent is None or len(temporal_extent) == 0: # use input temporal extent as a fallback if temporal extent target is not set\n temporal_extent = context[\"temporal_extent_input\"]\n interval_days = context[\"interval_days\"]\n t_target = compute_t_target(temporal_extent, interval_days)\n else:\n t_target = context\n\n return t_target\n\n\ndef compute_t_target(temporal_extent, interval_days) -> pd.DatetimeIndex:\n t_target = xr.date_range(\n temporal_extent[0],\n temporal_extent[1],\n freq=f\"{interval_days}D\",\n inclusive=\"left\",\n )\n return t_target\n" }, "result": true } } } } }, "mergecubes4": { "process_id": "merge_cubes", "arguments": { "cube1": { "from_node": "applydimension3" }, "cube2": { "from_node": "applydimension4" } } }, "applydimension5": { "process_id": "apply_dimension", "arguments": { "context": { "lr_mosaic_bands": { "from_parameter": "s3_data_bands" }, "hr_mosaic_bands": { "from_parameter": "s2_data_bands" }, "lr_interpolated_band_name_suffix": "_interpolated", "output_ndvi": { "from_parameter": "output_ndvi" } }, "data": { "from_node": "mergecubes4" }, "dimension": "bands", "process": { "process_graph": { "runudf7": { "process_id": "run_udf", "arguments": { "context": { "from_parameter": "context" }, "data": { "from_parameter": "data" }, "runtime": "Python", "udf": "import xarray as xr\nimport numpy as np\nfrom openeo.metadata import CubeMetadata\n\n\ndef apply_datacube(cube: xr.DataArray, context: dict) -> xr.DataArray:\n \"\"\"\n Computes the main fusion procedure, equation (3) in [1].\n\n [1]: Senty, Paul, Radoslaw Guzinski, Kenneth Grogan, et al. \u201cFast Fusion of Sentinel-2 and Sentinel-3 Time Series over Rangelands.\u201d Remote Sensing 16, no. 11 (2024): 11. https://doi.org/10.3390/rs16111833.\n \"\"\"\n assert \"bands\" in cube.dims, (\n f\"cube must have a 'bands' dimension, found '{cube.dims}'\"\n )\n assert \"hr_mosaic_bands\" in context, (\n f\"The high resolution mosaic bands 'hr_mosaic_bands' must be provided in the 'context' dict. Found keys '{context.keys()}' in 'context'.\"\n )\n assert \"lr_mosaic_bands\" in context, (\n f\"The low resolution bands 'lr_mosaic_bands' must be provided in the 'context' dict. Found keys '{context.keys()}' in 'context'.\"\n )\n assert \"lr_interpolated_band_name_suffix\" in context, (\n \"The suffix differentiating low resolution interpolated bands from composited bands \"\n f\"'lr_interpolated_band_name_suffix' provided in the 'context' dict. Found keys '{context.keys()}' in 'context'.\"\n )\n\n hr_mosaic_bands = context[\"hr_mosaic_bands\"]\n lr_mosaic_bands = context[\"lr_mosaic_bands\"]\n interpolated_band_suffix = context[\"lr_interpolated_band_name_suffix\"]\n lr_interpolated_bands = [f\"{b}{interpolated_band_suffix}\" for b in lr_mosaic_bands]\n target_bands = context.get(\"target_bands\")\n if target_bands is None or len(target_bands) != len(hr_mosaic_bands):\n target_bands = hr_mosaic_bands\n output_ndvi = context.get(\"output_ndvi\", False)\n\n fused = fuse(\n cube, hr_mosaic_bands, lr_mosaic_bands, lr_interpolated_bands, target_bands\n )\n if output_ndvi:\n nir = cube.sel(bands=\"B8A\")\n red = cube.sel(bands=\"B04\")\n ndvi = (nir - red) / (nir + red)\n # TODO may break on older version of xarray\n ndvi_formatted = ndvi.expand_dims({\"bands\": [\"ndvi\"]}, axis=fused.dims.index(\"bands\"))\n return ndvi_formatted\n\n return fused\n\n\ndef apply_metadata(metadata: CubeMetadata, context: dict) -> CubeMetadata:\n target_bands = context.get(\"target_bands\", context[\"hr_mosaic_bands\"])\n output_ndvi = context.get(\"output_ndvi\", False)\n if output_ndvi:\n target_bands = [\"ndvi\"]\n metadata = metadata.rename_labels(dimension=\"bands\", target=target_bands)\n return metadata\n\n\ndef fuse(cube, hr_mosaic_bands, lr_mosaic_bands, lr_interpolated_bands, target_bands):\n fused_list = [\n # Pixels where there is no data in either lr_m or lr_p are skipped, to avoid\n # only adding or only subtracting from the S2 composite, which would result in unusually high or low\n # (e.g. negative values).\n xr.where(\n (cube.sel(bands=lr_m) == 0)\n | np.isnan(cube.sel(bands=lr_m))\n | (cube.sel(bands=lr_p) == 0)\n | np.isnan(cube.sel(bands=lr_p)),\n cube.sel(bands=hr_m).squeeze(),\n cube.sel(bands=[hr_m, lr_p]).sum(dim=\"bands\")\n - cube.sel(bands=lr_m).squeeze(),\n )\n for (hr_m, lr_m, lr_p) in zip(\n hr_mosaic_bands, lr_mosaic_bands, lr_interpolated_bands\n )\n ]\n\n fused = xr.concat(fused_list, dim=\"bands\")\n fused = fused.assign_coords(bands=target_bands)\n return fused\n" }, "result": true } } } }, "result": true } }, "id": "efast", "summary": "The Efficient Fusion Algorithm Across Spatio-Temporal Scales (EFAST) is a method to create time-series with a fine resolution in space and time from a fine spatial but coarse temporal resolution source (Sentinel-2) and a coarse temporal but fine spatial resolution source (Sentinel-3).", "description": "The Efficient Fusion Algorithm Across Spatio-Temporal Scales (EFAST) [1] is a method to create\ntime-series with a fine resolution in space and time from a fine spatial but coarse temporal resolution\nsource (Sentinel-2) and a coarse temporal but fine spatial resolution source (Sentinel-3).\nIn comparison to other methods, EFAST aims to achieve results outperforming single-source interpolation\nwithout paying the computational cost of other methods leveraging spatial context.\nEFAST is focused on accurately capturing seasonal vegetation changes in homogeneous areas like range lands.\nDHI-GRAS has published a [Python implementation of the algorithm](https://github.com/DHI-GRAS/efast).\nIn the context of the ESA funded [APEx initiative](https://apex.esa.int/), the algorithm has been ported to OpenEO and is implemented in this process graph.\n\nEFAST interpolates Sentinel-2 acquisitions, using a time (temporal distance to target time) and\ndistance-to-cloud weighted compositing scheme. The Sentinel-3 time-series is incorporated to locally update\nthe interpolated Sentinel-2 imagery to accurately track vegetation changes between cloud-free Sentinel-2\nacquisitions. The method is described in detail in [1].\n\nEFAST produces high temporal frequency time-series matching the Sentinel-2 L2A bands which have\ncorresponding Sentinel-3 OLCI bands with matching centre frequencies. Because the application of EFAST is\nfocused on NDVI time-series, the UDP includes a parameter (output_ndvi) to directly compute the NDVI\nfor a given temporal and spatial extent.\n\nIt should be noted that OpenEO provides bands from the SENTINEL_L2A collection in integer format. EFAST\nconverts the data to floating point values for interpolation. Therefore, output bands of EFAST have a\ndifferent data type (floating point) than the corresponding SENTINEL_L2A bands.\n\n\n[1]: Senty, Paul, Radoslaw Guzinski, Kenneth Grogan, et al. \u201cFast Fusion of Sentinel-2 and Sentinel-3 Time Series over Rangelands.\u201d Remote Sensing 16, no. 11 (2024): 1833.\n", "parameters": [ { "name": "temporal_extent", "description": "The date range of the Sentinel-2 L2A and Sentinel-3 SY_2_SYN inputs. The fused (output) time series can optionally be defined by the temporal_extent_target parameter. If temporal_extent_target is not set, the output time series will cover temporal_extent by default", "schema": { "type": "array", "subtype": "temporal-interval" } }, { "name": "temporal_extent_target", "description": "The date range of the fused outputs. Must be completely contained in temporal_extent. This parameter is optional, if no temporal_extent_target is not set, the output will cover the temporal extent of the inputs (defined by the parameter temporal_extent). As per openeo convention, the start date of the temporal extent is included and the end date excluded if if no time of day is set.", "schema": { "type": "array", "subtype": "temporal-interval", "uniqueItems": true, "minItems": 2, "maxItems": 2, "items": { "anyOf": [ { "type": "string", "subtype": "date-time", "format": "date-time" }, { "type": "string", "subtype": "date", "format": "date" }, { "type": "null" } ] } }, "default": null, "optional": true }, { "name": "interval_days", "description": "Interval (in days) of the time series of the output. If, for example, temporal_extent_target is set to [2022-01-01, 2022-01-10] and interval_days is set to 3, the time series of the output will consist of images for [2022-01-01, 2022-01-04, 2022-01-07]. 2022-01-10 is excluded, as the temporal extent is defined including the start date and excluding the end date.", "schema": { "type": "integer" } }, { "name": "spatial_extent", "description": "Region of interest", "schema": { "type": "object", "subtype": "geojson" } }, { "name": "s2_data_bands", "description": "Sentinel-2 L2A bands (names follow the SENTINEL2_L2A collection) used in the fusion procedure. The order should match the corresponding s3_data_bands and fused_band_names parameters.If the NDVI should be computed (parameter output_ndvi is set to True), s2_data_bands must be set to [B04, B8A].", "schema": { "type": "array", "items": { "type": "string" } }, "default": [ "B02", "B03", "B04", "B8A" ], "optional": true }, { "name": "s3_data_bands", "description": "Sentinel-3 SYN L2 SYN bands (names follow the SENTINEL3_SYN_L2_SYN collection) used in the fusion procedure. The order of s3_data_bands must match the order of s2_data_bands, so that bands with closely matching center wavelengths are fused. If the NDVI should be computed (parameter output_ndvi is set to True), s3_data_bands must be set to [Syn_Oa08_reflectance, Syn_Oa17_reflectance].", "schema": { "type": "array", "items": { "type": "string" } }, "default": [ "Syn_Oa04_reflectance", "Syn_Oa06_reflectance", "Syn_Oa08_reflectance", "Syn_Oa17_reflectance" ], "optional": true }, { "name": "output_ndvi", "description": "If set to True, the output includes a single NDVI band. Otherwise, the output includes the bands of the Sentinel-2 input (defined by parameter s2_data_bands). If set to True, the bands B04 and B8A must be included in s2_data_bands, and the bands Syn_Oa08_reflectance and Syn_Oa17_reflectance must be included in s3_data_bands.", "schema": { "type": "boolean" }, "default": false, "optional": true } ] }