Files
remote-sensing/deafrica_tools/datahandling.py
T
2024-08-28 05:10:02 +00:00

1523 lines
59 KiB
Python

"""
Functions for loading and handling Digital Earth Africa data.
"""
# Import required packages
import os
from osgeo import gdal
import requests
import zipfile
import warnings
import numpy as np
import xarray as xr
import pandas as pd
import datetime
import pytz
from collections import Counter
from datacube.utils import masking
from scipy.ndimage import binary_dilation
from odc.algo import mask_cleanup
from copy import deepcopy
import odc.algo
from skimage.morphology import binary_erosion,binary_dilation,disk
from scipy.ndimage.filters import uniform_filter
from scipy.ndimage.measurements import variance
from datetime import datetime
from dateutil import parser
from deafrica_tools.bandindices import calculate_indices
def _dc_query_only(**kw):
"""
Remove load-only parameters, the rest
can be passed to Query
Returns
=======
dict of query parameters
"""
def _impl(
measurements=None,
output_crs=None,
resolution=None,
resampling=None,
skip_broken_datasets=None,
dask_chunks=None,
fuse_func=None,
align=None,
datasets=None,
progress_cbk=None,
group_by=None,
**query,
):
return query
return _impl(**kw)
def _common_bands(dc, products):
"""
Takes a list of products and returns a list of measurements/bands
that are present in all products
Returns
-------
List of band names
"""
common = None
bands = None
for p in products:
p = dc.index.products.get_by_name(p)
if common is None:
common = set(p.measurements)
bands = list(p.measurements)
else:
common = common.intersection(set(p.measurements))
return [band for band in bands if band in common]
def load_ard(
dc,
products=None,
min_gooddata=0.0,
categories_to_mask_ls=dict(
cloud="high_confidence", cloud_shadow="high_confidence"
),
categories_to_mask_s2=[
"cloud high probability",
"cloud medium probability",
"thin cirrus",
"cloud shadows",
"saturated or defective",
],
categories_to_mask_s1=["invalid data"],
mask_filters=None,
mask_pixel_quality=True,
ls7_slc_off=True,
predicate=None,
dtype="auto",
verbose=True,
**kwargs,
):
"""
Loads analysis ready data.
Loads and combines Landsat USGS Collections 2, Sentinel-2, and Sentinel-1 for
multiple sensors (i.e. ls5t, ls7e, ls8c and ls9 for Landsat; s2a and s2b for Sentinel-2),
optionally applies pixel quality masks, and drops time steps that
contain greater than a minimum proportion of good quality (e.g. non-
cloudy or shadowed) pixels.
The function supports loading the following DE Africa products:
Landsat:
* ls5_sr ('sr' denotes surface reflectance)
* ls7_sr
* ls8_sr
* ls9_sr
* ls5_st ('st' denotes surface temperature)
* ls7_st
* ls8_st
* ls9_st
Sentinel-2:
* s2_l2a
Sentinel-1:
* s1_rtc
Last modified: Feb 2021
Parameters
----------
dc : datacube Datacube object
The Datacube to connect to, i.e. `dc = datacube.Datacube()`.
This allows you to also use development datacubes if required.
products : list
A list of product names to load data from. For example:
* Landsat C2: ``['ls5_sr', 'ls7_sr', 'ls8_sr', 'ls9_sr']``
* Sentinel-2: ``['s2_l2a']``
* Sentinel-1: ``['s1_rtc']``
min_gooddata : float, optional
An optional float giving the minimum percentage of good quality
pixels required for a satellite observation to be loaded.
Defaults to 0.0 which will return all observations regardless of
pixel quality (set to e.g. 0.99 to return only observations with
more than 99% good quality pixels).
categories_to_mask_ls : dict, optional
An optional dictionary that is used to identify poor quality pixels
for masking. This mask is used for both masking out low
quality pixels (e.g. cloud or shadow), and for dropping
observations entirely based on the `min_gooddata` calculation.
categories_to_mask_s2 : list, optional
An optional list of Sentinel-2 Scene Classification Layer (SCL) names
that identify poor quality pixels for masking.
categories_to_mask_s1 : list, optional
An optional list of Sentinel-1 mask names that identify poor
quality pixels for masking.
mask_filters : iterable of tuples, optional
Iterable tuples of morphological operations - ("<operation>", <radius>)
to apply on mask, where:
operation: string, can be one of these morphological operations:
* ``'closing'`` = remove small holes in cloud - morphological closing
* ``'opening'`` = shrinks away small areas of the mask
* ``'dilation'`` = adds padding to the mask
* ``'erosion'`` = shrinks bright regions and enlarges dark regions
radius: int
e.g. ``mask_filters=[('erosion', 5),("opening", 2),("dilation", 2)]``
mask_pixel_quality : bool, optional
An optional boolean indicating whether to apply the poor data
mask to all observations that were not filtered out for having
less good quality pixels than ``min_gooddata``. E.g. if
``min_gooddata=0.99``, the filtered observations may still contain
up to 1% poor quality pixels. The default of ``False`` simply
returns the resulting observations without masking out these
pixels; ``True`` masks them and sets them to NaN using the poor data
mask. This will convert numeric values to floating point values
which can cause memory issues, set to False to prevent this.
ls7_slc_off : bool, optional
An optional boolean indicating whether to include data from
after the Landsat 7 SLC failure (i.e. SLC-off). Defaults to
``True``, which keeps all Landsat 7 observations > May 31 2003.
predicate : function, optional
An optional function that can be passed in to restrict the
datasets that are loaded by the function. A filter function
should take a `datacube.model.Dataset` object as an input (i.e.
as returned from `dc.find_datasets`), and return a boolean.
For example, a filter function could be used to return True on
only datasets acquired in January:
``dataset.time.begin.month == 1``
dtype : string, optional
An optional parameter that controls the data type/dtype that
layers are coerced to after loading. Valid values: ''`native`'',
``'auto'``, ``'float{16|32|64}'``.
When ``'auto'`` is used, the data will be
converted to ``'float32'`` if masking is used, otherwise data will
be returned in the native data type of the data. Be aware that
if data is loaded in its native dtype, nodata and masked
pixels will be returned with the data's native nodata value
(typically ``-999``), not ``NaN``.
NOTE: If loading Landsat, the data is automatically rescaled so
'native' dtype will return a value error.
verbose : bool, optional
If True, print progress statements during loading
**kwargs : dict, optional
A set of keyword arguments to ``dc.load`` that define the
spatiotemporal query used to extract data. This typically
includes ``measurements``, ``x`, ``y``, ``time``, ``resolution``,
``resampling``, ``group_by`` and ``crs``. Keyword arguments can
either be listed directly in the ``load_ard`` call like any
other parameter (e.g. ``measurements=['red']``), or by
passing in a query kwarg dictionary (e.g. ``**query``). For a
list of possible options, see the ``dc.load`` documentation:
https://datacube-core.readthedocs.io/en/latest/dev/api/generate/datacube.Datacube.load.html
Returns
-------
combined_ds : xarray Dataset
An xarray dataset containing only satellite observations that
contains greater than `min_gooddata` proportion of good quality
pixels.
"""
#########
# Setup #
#########
# prevent function altering original query object
kwargs = deepcopy(kwargs)
# We deal with `dask_chunks` separately
dask_chunks = kwargs.pop("dask_chunks", None)
requested_measurements = kwargs.pop("measurements", None)
# Warn user if they combine lazy load with min_gooddata
if verbose:
if (min_gooddata > 0.0) and dask_chunks is not None:
warnings.warn(
"Setting 'min_gooddata' percentage to > 0.0 "
"will cause dask arrays to compute when "
"loading pixel-quality data to calculate "
"'good pixel' percentage. This can "
"slow the return of your dataset."
)
# Verify that products were provided and determine if Sentinel-2
# or Landsat data is being loaded
if not products:
raise ValueError(
"Please provide a list of product names to load data from. "
"Valid options are: Landsat C2 SR: ['ls5_sr', 'ls7_sr', 'ls8_sr', 'ls9_sr'], or "
"Landsat C2 ST: ['ls5_st', 'ls7_st', 'ls8_st', 'ls9_st'], or "
"Sentinel-2: ['s2_l2a'], or"
"Sentinel-1: ['s1_rtc'], or"
)
# convert products to list if user passed as a string
if type(products) == str:
products=[products]
if all(["ls" in product for product in products]):
product_type = "ls"
elif all(["s2" in product for product in products]):
product_type = "s2"
elif all(["s1" in product for product in products]):
product_type = "s1"
# check if the landsat product is surface temperature
st = False
if (product_type == "ls") & (all(["st" in product for product in products])):
st = True
# Check some parameters before proceeding
if (product_type == "ls") & (dtype == "native"):
raise ValueError(
"Cannot load Landsat bands in native dtype "
"as values require rescaling which converts dtype to float"
)
if product_type == "ls":
if any(k in categories_to_mask_ls for k in ("cirrus", "cirrus_confidence")):
raise ValueError(
"'cirrus' categories for the pixel quality mask"
" are not supported by load_ard"
)
# If `measurements` are specified but do not include pixel quality bands,
# add these to `measurements` according to collection
if product_type == "ls":
if verbose:
print("Using pixel quality parameters for USGS Collection 2")
fmask_band = "pixel_quality"
elif product_type == "s2":
if verbose:
print("Using pixel quality parameters for Sentinel 2")
fmask_band = "SCL"
elif product_type == "s1":
if verbose:
print("Using pixel quality parameters for Sentinel 1")
fmask_band = "mask"
measurements = requested_measurements.copy() if requested_measurements else None
# define a list of acceptable aliases to load landsat. We can't rely on 'common'
# measurements as native band names have the same name for different measurements.
ls_aliases = ["pixel_quality", "radiometric_saturation"]
if st:
ls_aliases = [
"surface_temperature",
"surface_temperature_quality",
"atmospheric_transmittance",
"thermal_radiance",
"emissivity",
"emissivity_stddev",
"cloud_distance",
"upwell_radiance",
"downwell_radiance",
] + ls_aliases
else:
ls_aliases = ["red", "green", "blue", "nir", "swir_1", "swir_2"] + ls_aliases
if measurements is not None:
if product_type == "ls":
# check we aren't loading aerosol bands from LS8
aerosol_bands = [
"aerosol_qa",
"qa_aerosol",
"atmos_opacity",
"coastal_aerosol",
"SR_QA_AEROSOL",
]
if any(b in aerosol_bands for b in measurements):
raise ValueError(
"load_ard doesn't support loading aerosol or "
"atmospeheric opacity related bands "
"for Landsat, instead use dc.load()"
)
# check measurements are in acceptable aliases list for landsat
if set(measurements).issubset(ls_aliases):
pass
else:
raise ValueError(
"load_ard does not support all band aliases for Landsat, "
"use only the following band names to load Landsat data: "
+ str(ls_aliases)
)
# Deal with "load all" case: pick a set of bands common across
# all products
if measurements is None:
if product_type == "ls":
measurements = ls_aliases
else:
measurements = _common_bands(dc, products)
# If `measurements` are specified but do not include pq, add.
if measurements:
if fmask_band not in measurements:
measurements.append(fmask_band)
# Get list of data and mask bands so that we can later exclude
# mask bands from being masked themselves (also handle the case of rad_sat)
data_bands = [
band
for band in measurements
if band not in (fmask_band, "radiometric_saturation")
]
mask_bands = [band for band in measurements if band not in data_bands]
#################
# Find datasets #
#################
# Pull out query params only to pass to dc.find_datasets
query = _dc_query_only(**kwargs)
# Extract datasets for each product using subset of dcload_kwargs
dataset_list = []
# Get list of datasets for each product
if verbose:
print("Finding datasets")
for product in products:
# Obtain list of datasets for product
if verbose:
print(f" {product}")
if product_type == "ls":
# handle LS seperately to S2/S1 due to collection_category
# force the user to load Tier 1
datasets = dc.find_datasets(
product=product, collection_category='T1', **query
)
else:
datasets = dc.find_datasets(product=product, **query)
# Remove Landsat 7 SLC-off observations if ls7_slc_off=False
if not ls7_slc_off and product in ["ls7_sr"]:
if verbose:
print(" Ignoring SLC-off observations for ls7")
datasets = [
i
for i in datasets
if i.time.begin < datetime.datetime(2003, 5, 31, tzinfo=pytz.UTC)
]
# Add any returned datasets to list
dataset_list.extend(datasets)
# Raise exception if no datasets are returned
if len(dataset_list) == 0:
raise ValueError(
"No data available for query: ensure that "
"the products specified have data for the "
"time and location requested"
)
# If predicate is specified, use this function to filter the list
# of datasets prior to load (this now redundant as dc.load now supports
# a predicate filter)
if predicate:
if verbose:
print(f"Filtering datasets using filter function")
dataset_list = [ds for ds in dataset_list if predicate(ds)]
# Raise exception if filtering removes all datasets
if len(dataset_list) == 0:
raise ValueError("No data available after filtering with " "filter function")
#############
# Load data #
#############
# Note we always load using dask here so that
# we can lazy load data before filtering by good data
ds = dc.load(
datasets=dataset_list,
measurements=measurements,
dask_chunks={} if dask_chunks is None else dask_chunks,
**kwargs,
)
#print(ds)
####################
# Filter good data #
####################
# need to distinguish between products due to different
# pq band properties
# collection 2 USGS
if product_type == "ls":
mask, _ = masking.create_mask_value(
ds[fmask_band].attrs["flags_definition"], **categories_to_mask_ls
)
pq_mask = (ds[fmask_band] & mask) != 0
# only run if data bands are present
if len(data_bands) > 0:
# identify pixels that will become negative after rescaling (but not 0 values)
invalid = (
((ds[data_bands] < (-1.0 * -0.2 / 0.0000275)) & (ds[data_bands] > 0))
.to_array(dim="band")
.any(dim="band")
)
#merge masks
pq_mask = np.logical_or(pq_mask, pq_mask)
# sentinel 2
if product_type == "s2":
pq_mask = odc.algo.enum_to_bool(mask=ds[fmask_band],
categories=categories_to_mask_s2)
# sentinel 1
if product_type == "s1":
pq_mask = odc.algo.enum_to_bool(mask=ds[fmask_band],
categories=categories_to_mask_s1)
#print(pq_mask)
# The good data percentage calculation has to load in all `fmask`
# data, which can be slow. If the user has chosen no filtering
# by using the default `min_gooddata = 0`, we can skip this step
# completely to save processing time
if min_gooddata > 0.0:
# Compute good data for each observation as % of total pixels.
# Inveerting the pq_mask for this because cloud=True in pq_mask
# and we want to sum good pixels
if verbose:
print("Counting good quality pixels for each time step")
data_perc = (~pq_mask).sum(axis=[1, 2], dtype="int32") / (
pq_mask.shape[1] * pq_mask.shape[2]
)
keep = (data_perc >= min_gooddata).persist()
# Filter by `min_gooddata` to drop low quality observations
total_obs = len(ds.time)
ds = ds.sel(time=keep)
pq_mask = pq_mask.sel(time=keep)
if verbose:
print(
f"Filtering to {len(ds.time)} out of {total_obs} "
f"time steps with at least {min_gooddata:.1%} "
f"good quality pixels"
)
# morpholigcal filtering on cloud masks
if (mask_filters is not None) & (mask_pixel_quality):
if verbose:
print(f"Applying morphological filters to pq mask {mask_filters}")
pq_mask = mask_cleanup(pq_mask, mask_filters=mask_filters)
###############
# Apply masks #
###############
# Generate good quality data mask
mask = None
if mask_pixel_quality:
if verbose:
print("Applying pixel quality/cloud mask")
mask = pq_mask
# Split into data/masks bands, as conversion to float and masking
# should only be applied to data bands
ds_data = ds[data_bands]
ds_masks = ds[mask_bands]
# Remove sentinel-2 pixels valued 1 (scene edges, terrain shadow)
if product_type == "s2":
valid_data_mask = (ds_data > 1).to_array(dim="band").all(dim="band")
ds_data = odc.algo.keep_good_only(ds_data, where=valid_data_mask)
# Mask data if either of the above masks were generated
if mask is not None:
ds_data = odc.algo.erase_bad(ds_data, where=mask)
# Automatically set dtype to either native or float32 depending
# on whether masking was requested
if dtype == "auto":
dtype = "native" if mask is None else "float32"
# Set nodata values using odc.algo tools to reduce peak memory
# use when converting data dtype
if dtype != "native":
ds_data = odc.algo.to_float(ds_data, dtype=dtype)
# Put data and mask bands back together
attrs = ds.attrs
ds = xr.merge([ds_data, ds_masks])
ds.attrs.update(attrs)
###############
# Return data #
###############
# Drop bands not originally requested by user
if requested_measurements:
ds = ds[requested_measurements]
# Apply the scale and offset factors to Collection 2 Landsat. We need
# different factors for different bands. Also handle the case where
# masking_pixel_quaity = False, in which case the dtype is still
# in int, so we convert it to float
if product_type == "ls":
if verbose:
print("Re-scaling Landsat C2 data")
sr_bands = ["red", "green", "blue", "nir", "swir_1", "swir_2"]
radiance_bands = ["thermal_radiance", "upwell_radiance", "downwell_radiance"]
trans_emiss = ["atmospheric_transmittance", "emissivity", "emissivity_stddev"]
qa = ["pixel_quality", "radiometric_saturation"]
if mask_pixel_quality == False:
# set nodata to NaNs before rescaling
# in the case where masking hasn't already done this
for band in ds.data_vars:
if band not in qa:
ds[band] = odc.algo.to_f32(ds[band])
for band in ds.data_vars:
if band == "cloud_distance":
ds[band] = 0.01 * ds[band]
if band == "surface_temperature_quality":
ds[band] = 0.01 * ds[band]
if band in radiance_bands:
ds[band] = 0.001 * ds[band]
if band in trans_emiss:
ds[band] = 0.0001 * ds[band]
if band in sr_bands:
ds[band] = 2.75e-5 * ds[band] - 0.2
if band == "surface_temperature":
ds[band] = ds[band] * 0.00341802 + 149.0
# add back attrs that are lost during scaling calcs
for band in ds.data_vars:
ds[band].attrs.update(attrs)
# If user supplied dask_chunks, return data as a dask array without
# actually loading it in
if dask_chunks is not None:
if verbose:
print(f"Returning {len(ds.time)} time steps as a dask array")
return ds
else:
if verbose:
print(f"Loading {len(ds.time)} time steps")
return ds.compute()
def array_to_geotiff(
fname, data, geo_transform, projection, nodata_val=0, dtype=gdal.GDT_Float32
):
"""
Create a single band GeoTIFF file with data from an array.
Because this works with simple arrays rather than xarray datasets
from DEA, it requires geotransform info (`(upleft_x, x_size,
x_rotation, upleft_y, y_rotation, y_size)`) and projection data
(in "WKT" format) for the output raster. These are typically
obtained from an existing raster using the following GDAL calls:
>>> from osgeo import gdal
>>> gdal_dataset = gdal.Open(raster_path)
>>> geotrans = gdal_dataset.GetGeoTransform()
>>> prj = gdal_dataset.GetProjection()
or alternatively, directly from an xarray dataset:
>>> geotrans = xarraydataset.geobox.transform.to_gdal()
>>> prj = xarraydataset.geobox.crs.wkt
Parameters
----------
fname : str
Output geotiff file path including extension
data : numpy array
Input array to export as a geotiff
geo_transform : tuple
Geotransform for output raster; e.g. `(upleft_x, x_size,
x_rotation, upleft_y, y_rotation, y_size)`
projection : str
Projection for output raster (in "WKT" format)
nodata_val : int, optional
Value to convert to nodata in the output raster; default 0
dtype : gdal dtype object, optional
Optionally set the dtype of the output raster; can be
useful when exporting an array of float or integer values.
Defaults to `gdal.GDT_Float32`
"""
# Set up driver
driver = gdal.GetDriverByName("GTiff")
# Create raster of given size and projection
rows, cols = data.shape
dataset = driver.Create(fname, cols, rows, 1, dtype)
dataset.SetGeoTransform(geo_transform)
dataset.SetProjection(projection)
# Write data to array and set nodata values
band = dataset.GetRasterBand(1)
band.WriteArray(data)
band.SetNoDataValue(nodata_val)
# Close file
dataset = None
def mostcommon_crs(dc, product, query):
"""
Takes a given query and returns the most common CRS for observations
returned for that spatial extent. This can be useful when your study
area lies on the boundary of two UTM zones, forcing you to decide
which CRS to use for your `output_crs` in `dc.load`.
Parameters
----------
dc : datacube Datacube object
The Datacube to connect to, i.e. `dc = datacube.Datacube()`.
This allows you to also use development datacubes if required.
product : str
A product name to load CRSs from
query : dict
A datacube query including x, y and time range to assess for the
most common CRS
Returns
-------
str
A EPSG string giving the most common CRS from all datasets returned
by the query above
"""
# remove dask_chunks & align to prevent func failing
# prevent function altering dictionary kwargs
query = deepcopy(query)
if "dask_chunks" in query:
query.pop("dask_chunks", None)
if "align" in query:
query.pop("align", None)
# List of matching products
matching_datasets = dc.find_datasets(product=product, **query)
# Extract all CRSs
crs_list = [str(i.crs) for i in matching_datasets]
# Identify most common CRS
crs_counts = Counter(crs_list)
crs_mostcommon = crs_counts.most_common(1)[0][0]
# Warn user if multiple CRSs are encountered
if len(crs_counts.keys()) > 1:
warnings.warn(
f"Multiple UTM zones {list(crs_counts.keys())} "
f"were returned for this query. Defaulting to "
f"the most common zone: {crs_mostcommon}",
UserWarning,
)
return crs_mostcommon
def download_unzip(url, output_dir=None, remove_zip=True):
"""
Downloads and unzips a .zip file from an external URL to a local
directory.
Parameters
----------
url : str
A string giving a URL path to the zip file you wish to download
and unzip
output_dir : str, optional
An optional string giving the directory to unzip files into.
Defaults to None, which will unzip files in the current working
directory
remove_zip : bool, optional
An optional boolean indicating whether to remove the downloaded
.zip file after files are unzipped. Defaults to True, which will
delete the .zip file.
"""
# Get basename for zip file
zip_name = os.path.basename(url)
# Raise exception if the file is not of type .zip
if not zip_name.endswith(".zip"):
raise ValueError(
f"The URL provided does not point to a .zip "
f"file (e.g. {zip_name}). Please specify a "
f"URL path to a valid .zip file"
)
# Download zip file
print(f"Downloading {zip_name}")
r = requests.get(url)
with open(zip_name, "wb") as f:
f.write(r.content)
# Extract into output_dir
with zipfile.ZipFile(zip_name, "r") as zip_ref:
zip_ref.extractall(output_dir)
print(
f"Unzipping output files to: "
f"{output_dir if output_dir else os.getcwd()}"
)
# Optionally cleanup
if remove_zip:
os.remove(zip_name)
def wofs_fuser(dest, src):
"""
Fuse two WOfS water measurements represented as `ndarray` objects.
Note: this is a copy of the function located here:
https://github.com/GeoscienceAustralia/digitalearthau/blob/develop/digitalearthau/utils.py
"""
empty = (dest & 1).astype(bool)
both = ~empty & ~((src & 1).astype(bool))
dest[empty] = src[empty]
dest[both] |= src[both]
def dilate(array, dilation=10, invert=True):
"""
Dilate a binary array by a specified nummber of pixels using a
disk-like radial dilation.
By default, invalid (e.g. False or 0) values are dilated. This is
suitable for applications such as cloud masking (e.g. creating a
buffer around cloudy or shadowed pixels). This functionality can
be reversed by specifying `invert=False`.
Parameters
----------
array : array
The binary array to dilate.
dilation : int, optional
An optional integer specifying the number of pixels to dilate
by. Defaults to 10, which will dilate `array` by 10 pixels.
invert : bool, optional
An optional boolean specifying whether to invert the binary
array prior to dilation. The default is True, which dilates the
invalid values in the array (e.g. False or 0 values).
Returns
-------
array
An array of the same shape as `array`, with valid data pixels
dilated by the number of pixels specified by `dilation`.
"""
y, x = np.ogrid[
-dilation : (dilation + 1),
-dilation : (dilation + 1),
]
# disk-like radial dilation
kernel = (x * x) + (y * y) <= (dilation + 0.5) ** 2
# If invert=True, invert True values to False etc
if invert:
array = ~array
return ~binary_dilation(
array.astype(bool), structure=kernel.reshape((1,) + kernel.shape)
)
def _select_along_axis(values, idx, axis):
other_ind = np.ix_(*[np.arange(s) for s in idx.shape])
sl = other_ind[:axis] + (idx,) + other_ind[axis:]
return values[sl]
def first(array: xr.DataArray, dim: str, index_name: str = None) -> xr.DataArray:
"""
Finds the first occuring non-null value along the given dimension.
Parameters
----------
array : xr.DataArray
The array to search.
dim : str
The name of the dimension to reduce by finding the first non-null value.
Returns
-------
reduced : xr.DataArray
An array of the first non-null values.
The `dim` dimension will be removed, and replaced with a coord of the
same name, containing the value of that dimension where the last value
was found.
"""
axis = array.get_axis_num(dim)
idx_first = np.argmax(~pd.isnull(array), axis=axis)
reduced = array.reduce(_select_along_axis, idx=idx_first, axis=axis)
reduced[dim] = array[dim].isel({dim: xr.DataArray(idx_first, dims=reduced.dims)})
if index_name is not None:
reduced[index_name] = xr.DataArray(idx_first, dims=reduced.dims)
return reduced
def last(array: xr.DataArray, dim: str, index_name: str = None) -> xr.DataArray:
"""
Finds the last occuring non-null value along the given dimension.
Parameters
----------
array : xr.DataArray
The array to search.
dim : str
The name of the dimension to reduce by finding the last non-null value.
index_name : str, optional
If given, the name of a coordinate to be added containing the index
of where on the dimension the nearest value was found.
Returns
-------
reduced : xr.DataArray
An array of the last non-null values.
The `dim` dimension will be removed, and replaced with a coord of the
same name, containing the value of that dimension where the last value
was found.
"""
axis = array.get_axis_num(dim)
rev = (slice(None),) * axis + (slice(None, None, -1),)
idx_last = -1 - np.argmax(~pd.isnull(array)[rev], axis=axis)
reduced = array.reduce(_select_along_axis, idx=idx_last, axis=axis)
reduced[dim] = array[dim].isel({dim: xr.DataArray(idx_last, dims=reduced.dims)})
if index_name is not None:
reduced[index_name] = xr.DataArray(idx_last, dims=reduced.dims)
return reduced
def nearest(
array: xr.DataArray, dim: str, target, index_name: str = None
) -> xr.DataArray:
"""
Finds the nearest values to a target label along the given dimension, for
all other dimensions.
E.g. For a DataArray with dimensions ('time', 'x', 'y')
nearest_array = nearest(array, 'time', '2017-03-12')
will return an array with the dimensions ('x', 'y'), with non-null values
found closest for each (x, y) pixel to that location along the time
dimension.
The returned array will include the 'time' coordinate for each x,y pixel
that the nearest value was found.
Parameters
----------
array : xr.DataArray
The array to search.
dim : str
The name of the dimension to look for the target label.
target : same type as array[dim]
The value to look up along the given dimension.
index_name : str, optional
If given, the name of a coordinate to be added containing the index
of where on the dimension the nearest value was found.
Returns
-------
nearest_array : xr.DataArray
An array of the nearest non-null values to the target label.
The `dim` dimension will be removed, and replaced with a coord of the
same name, containing the value of that dimension closest to the
given target label.
"""
before_target = slice(None, target)
after_target = slice(target, None)
da_before = array.sel({dim: before_target})
da_after = array.sel({dim: after_target})
da_before = last(da_before, dim, index_name) if da_before[dim].shape[0] else None
da_after = first(da_after, dim, index_name) if da_after[dim].shape[0] else None
if da_before is None and da_after is not None:
return da_after
if da_after is None and da_before is not None:
return da_before
target = array[dim].dtype.type(target)
is_before_closer = abs(target - da_before[dim]) < abs(target - da_after[dim])
nearest_array = xr.where(is_before_closer, da_before, da_after)
nearest_array[dim] = xr.where(is_before_closer, da_before[dim], da_after[dim])
if index_name is not None:
nearest_array[index_name] = xr.where(
is_before_closer, da_before[index_name], da_after[index_name]
)
return nearest_array
def parallel_apply(ds, dim, func, *args):
"""
Applies a custom function in parallel along the dimension of an
xarray.Dataset or xarray.DataArray.
The function can be any function that can be applied to an
individual xarray.Dataset or xarray.DataArray (e.g. data for a
single timestep). The function should also return data in
xarray.Dataset or xarray.DataArray format.
This function is useful as a simple method for parallising code
that cannot easily be parallised using Dask.
Parameters
----------
ds : xarray.Dataset or xarray.DataArray
xarray data with a dimension `dim` to apply the custom function
along.
dim : string
The dimension along which the custom function will be applied.
func : function
The function that will be applied in parallel to each array
along dimension `dim`. The first argument passed to this
function should be the array along `dim`.
*args :
Any number of arguments that will be passed to `func`.
Returns
-------
xarray.Dataset
A concatenated dataset containing an output for each array
along the input `dim` dimension.
"""
from concurrent.futures import ProcessPoolExecutor
from tqdm import tqdm
from itertools import repeat
with ProcessPoolExecutor() as executor:
# Apply func in parallel
groups = [group for (i, group) in ds.groupby(dim)]
to_iterate = (groups, *(repeat(i, len(groups)) for i in args))
out_list = list(tqdm(executor.map(func, *to_iterate), total=len(groups)))
# Combine to match the original dataset
return xr.concat(out_list, dim=ds[dim])
def pan_sharpen_brovey(band_1, band_2, band_3, pan_band):
"""
Brovey pan sharpening on surface reflectance input using numexpr
and return three xarrays.
Parameters
----------
band_1, band_2, band_3 : xarray.DataArray or numpy.array
Three input multispectral bands, either as xarray.DataArrays or
numpy.arrays. These bands should have already been resampled to
the spatial resolution of the panchromatic band.
pan_band : xarray.DataArray or numpy.array
A panchromatic band corresponding to the above multispectral
bands that will be used to pan-sharpen the data.
Returns
-------
band_1_sharpen, band_2_sharpen, band_3_sharpen : numpy.arrays
Three numpy arrays equivelent to `band_1`, `band_2` and `band_3`
pan-sharpened to the spatial resolution of `pan_band`.
"""
# Calculate total
exp = 'band_1 + band_2 + band_3'
total = numexpr.evaluate(exp)
# Perform Brovey Transform in form of: band/total*panchromatic
exp = 'a/b*c'
band_1_sharpen = numexpr.evaluate(exp, local_dict={'a': band_1,
'b': total,
'c': pan_band})
band_2_sharpen = numexpr.evaluate(exp, local_dict={'a': band_2,
'b': total,
'c': pan_band})
band_3_sharpen = numexpr.evaluate(exp, local_dict={'a': band_3,
'b': total,
'c': pan_band})
return band_1_sharpen, band_2_sharpen, band_3_sharpen
def load_s1_by_orbits(dc,query):
'''
Function to query and load ascending and descending Sentinel-1 data
and add a variable to denote acquisition orbits
Parameters:
dc: connected datacube
query: a query dictionary to define spatial extent, measurements, time range and spatial resolution
Returns:
Queried dataset with variable 'is_ascending' added to denote orbit path
'''
# load ascending data
print('\nQuerying and loading Sentinel-1 ascending data...')
ds_s1_ascending=load_ard(dc=dc,products=['s1_rtc'],resampling='bilinear',
dtype='native',sat_orbit_state='ascending',**query)
# add an variable denoting data source
ds_s1_ascending['is_ascending']=xr.DataArray(np.ones(len(ds_s1_ascending.time)),
dims=('time'),coords={'time': ds_s1_ascending.time})
# load descending data
print('\nQuerying and loading Sentinel-1 descending data...')
ds_s1_descending=load_ard(dc=dc,products=['s1_rtc'],resampling='bilinear',
dtype='native',sat_orbit_state='descending',**query)
# add an variable denoting data source
ds_s1_descending['is_ascending']=xr.DataArray(np.zeros(len(ds_s1_descending.time)),
dims=('time'),coords={'time': ds_s1_descending.time})
# merge datasets together
ds_s1=xr.concat([ds_s1_ascending,ds_s1_descending],dim='time').sortby('time')
return ds_s1
def filter_obs_by_orbit(ds_s1):
'''
Function to impliment per-pixel filtering of Sentinel-1 observations
to keep only observations from the orbit (ascending/descending) with higher frequency over time.
Each of the Sentinel-1 observations was acquired from either a descending or ascending orbit,
which has impacts on the local incidence angle and backscattering value.
Here we do the filtering to minimise the effects of inconsistent looking angle and obit direction for each individual pixel.
Parameters:
ds_s1: xarray.Dataset
Time-series observations of Sentinel-1 data,
with two required variables: 'is_ascending' denoting orbit path and 'mask' to identify acquisition exent
Returns:
ds_s1_filtered: xarray.Dataset
Filtered dataset
'''
print('\nFiltering Sentinel-1 product by orbit...')
cnt_ascending=((ds_s1["is_ascending"]==1)&(ds_s1['mask']!=0)).sum(dim='time')
cnt_descending=((ds_s1["is_ascending"]==0)&(ds_s1['mask']!=0)).sum(dim='time')
ds_s1_filtered=ds_s1.where(((cnt_ascending>=cnt_descending)&(ds_s1["is_ascending"]==1))|
((cnt_ascending<cnt_descending)&(ds_s1["is_ascending"]==0)))
# remove intermediate variable
ds_s1_filtered=ds_s1_filtered.drop_vars(["is_ascending"])
# drop all-nan time steps
ds_s1_filtered=ds_s1_filtered.dropna(dim='time',how='all')
return ds_s1_filtered
def lee_filter(da, size):
"""
Function to apply lee filter of specified window size.
Adapted from https://stackoverflow.com/questions/39785970/speckle-lee-filter-in-python
Parameters:
da: Xarray.dataArray, input single-time image data
size: integer, filtering size in pixels
Returns:
numpy array of filtered image
"""
img = da.values
img_mean = uniform_filter(img, (size, size))
img_sqr_mean = uniform_filter(img**2, (size, size))
img_variance = img_sqr_mean - img_mean**2
overall_variance = variance(img)
img_weights = img_variance / (img_variance + overall_variance)
img_output = img_mean + img_weights * (img - img_mean)
return img_output
def preprocess_s1(ds_s1,filter_size=None,s1_orbit_filtering=True):
'''
Function to implement preprocessing on Sentinel-1 data,
including speckle filtering (optional), filtering observations by orbit (optional) and conversion to dB
Parameters:
ds_s1: xarray.Dataset
Time-series of Sentinel-1 data, with variable 'vh' required
filter_size: integer or None
Speckle filtering size
s1_orbit_filtering: Boolean
Whether to filter Sentinel-1 observations by orbit
Returns:
xarray.Dataset
Preprocessed Sentinel-1 data
'''
ds_s1_filtered=ds_s1
# apply Lee filtering if required
if not filter_size is None:
print('Applying Lee filtering using filtering size of {} pixels...'.format(filter_size))
# The lee filter above doesn't handle null values
# We therefore set null values to 0 before applying the filter
ds_s1_filtered = ds_s1.where(np.isfinite(ds_s1), 0)
# Create a new entry in dataset corresponding to filtered VV and VH data
ds_s1_filtered["vh"] = ds_s1_filtered.vh.groupby("time").apply(lee_filter, size=filter_size)
# Null pixels should remain null, but also including pixels changed to 0 due to the filtering
ds_s1_filtered['vh'] = ds_s1_filtered.vh.where(ds_s1_filtered.vh!=0,np.nan)
# filter observations by orbit if required
if s1_orbit_filtering:
ds_s1_filtered=filter_obs_by_orbit(ds_s1_filtered)
# Scale to plot data in decibels
ds_s1_filtered['vh'] = 10 * np.log10(ds_s1_filtered.vh)
return ds_s1_filtered
def get_mean_number_freq_valid_obs(da,mask,time_step):
'''
Calculate mean number of clear observations within each year/timestep in a masked zone
Parameters:
da: xarray.DataArray
Time series of a single satellite band/variable (e.g. MNDWI)
mask: xarray.DataArray or None
A mask where the calculation is restricted within
time_step: string
Pre-defined time step for temporal aggregation, e.g. '1Y'
Returns:
n_valid_obs: xarray.DataArray
Average number of valid observations within the coastal zone and for each time step
freq_valid: xarray.DataArray
Average frequency of valid observations within the coastal zone and for each time step
'''
n_valid_obs=(~da.isnull()).resample(time=time_step).sum('time').compute()
freq_valid=(~da.isnull()).resample(time=time_step).mean('time').compute()
if mask is None:
n_valid_obs=n_valid_obs.mean(dim=['x','y'])
freq_valid=freq_valid.mean(dim=['x','y'])
else:
n_valid_obs=n_valid_obs.where(mask).mean(dim=['x','y'])
freq_valid=freq_valid.where(mask).mean(dim=['x','y'])
return n_valid_obs,freq_valid
def create_coastal_mask(da,buffer_pixels):
'''
Create a simplified coastal zone mask based on time series of Sentinel-2 MNDWI data
Parameters:
ds_summaries: xarray.DataArray
Time series of Sentinel-2 MNDWI data
buffer_pixels: integer
Number of pixels to buffer coastal zone
Returns:
coastal_mask: xarray.DataArray
A single time buffered coastal zone mask (0: non-coastal and 1: coastal)
'''
print('\nCalculating simplified coastal zone mask...')
# apply thresholding and re-apply nodata values
nodata = da.isnull()
thresholded_ds = da>=0
thresholded_ds = thresholded_ds.where(~nodata)
# use 20% ~ 80% wet frequency to identify potential coastal zone
coastal_mask=(thresholded_ds.mean(dim='time') >= 0.2)&(thresholded_ds.mean(dim='time') <= 0.8)
# buffering
print('\nApplying buffering of {} Sentinel-2 pixels (parameter buffer_pixels)...'.format(buffer_pixels))
coastal_mask=xr.apply_ufunc(binary_dilation,coastal_mask.compute(),disk(buffer_pixels))
return coastal_mask
def choose_product(ds_ls,ds_s2,ds_s1,ds_ls_s2,time_step,**kwargs):
'''
Rule-based guide on choosing the best availabel dataset in a given time step and optionally within a coastal zone mask
Parameters:
ds_ls: xarray.Dataset
Time series Landsat data
ds_s2: xarray.Dataset
Time series Sentinel-2 data
ds_s1: xarray.Dataset
Time series Sentinel-1 data
ds_ls_s2: xarray.Dataset or None
Time series of combined Landsat and Sentinel-2 data.
time_step: string
Time step for temporal composition
**kwargs: A set of optional parameters including:
thresh_n_valid: integer
Threhold of minimum average number of valid observations within each time step
thresh_freq: float
Threshold of minimum frequency of valid observations within each time step
buffer_pixels: integer
Number of pixels to buffer coastal zone
coastal_masking: Boolean
whether to calculate a coastal zone mask and restrict the comparison of the products within the mask
Returns:
Xarray.Dataset of the best product
String of the best product name: 'ls', 's2', 's1' or 'ls_s2'
'''
# check if optional parameters are defined otherwise set default values
thresh_n_valid=10 if "thresh_n_valid" not in kwargs else kwargs["thresh_n_valid"]
thresh_freq=0.2 if "thresh_freq" not in kwargs else kwargs["thresh_freq"]
buffer_pixels=100 if "buffer_pixels" not in kwargs else kwargs["buffer_pixels"]
print('\nThreshold number of valid observations (parameter thresh_n_valid): {}'.format(thresh_n_valid))
print('\nThreshold frequency of valid observations (parameter thresh_freq): {}'.format(thresh_freq))
# create mask if requested
coastal_masking=False if "coastal_masking" not in kwargs else kwargs["coastal_masking"]
if coastal_masking==True:
# calculate index
ds_s2 = calculate_indices(ds_s2, index='MNDWI', satellite_mission='s2')
mask=create_coastal_mask(ds_s2['MNDWI'],buffer_pixels)
else:
print('\nNo coastal masking required, using all pixels within the selected region...')
mask=None
# calculate mean number and fraction of clear observations within each timestep and the mask
print('\nCalculating number and frequency of valid observations...')
n_valid_obs_s2,freq_valid_s2=get_mean_number_freq_valid_obs(ds_s2['green'],mask,time_step)
print('\nSentinel-2: Average number and frequency of valid observations: {:.0f} and {:.2f}'.format(n_valid_obs_s2.mean().values,freq_valid_s2.mean().values))
n_valid_obs_ls,freq_valid_ls=get_mean_number_freq_valid_obs(ds_ls['green'],mask,time_step)
print('\nLandsat: Average number and frequency of valid observations: {:.0f} and {:.2f}'.format(n_valid_obs_ls.mean().values,freq_valid_ls.mean().values))
# n_valid_obs_s1,freq_valid_s1=get_mean_number_freq_valid_obs(ds_s1['vh'],mask,time_step) # dont need this as sentinel-1 will only be chosen when optical datasets are not sufficient
if not ds_ls_s2 is None:
n_valid_obs_ls_s2,freq_valid_ls_s2=get_mean_number_freq_valid_obs(ds_ls_s2['green'],mask,time_step)
print('\nCombined Landsat and Sentinel-2 product: Average number and frequency of valid observations: {:.0f} and {:.2f}'.format(n_valid_obs_ls_s2.mean().values,freq_valid_ls_s2.mean().values))
# apply decision rules
print('\nApplying rules to choose product...')
# if Sentinel-2 meets requirements
if ((n_valid_obs_s2>=thresh_n_valid).all()) and ((freq_valid_s2>=thresh_freq).all()):
print('\nSentinel-2 product has met the minimum required average number and frequency of valid observations within all time periods')
# if combined product is available, choose combined product if it has both higher number and frequency
if not ds_ls_s2 is None:
if ((n_valid_obs_ls_s2>n_valid_obs_s2).all()) and ((freq_valid_ls_s2>freq_valid_s2).all()):
ds_selected, product_name=ds_ls_s2,'ls_s2'
print('\nChoosing combined Landsat and Sentinel-2 product as it has both higher number and frequency of valid observations within all time periods')
else:
ds_selected, product_name=ds_s2,'s2'
print('\nChoosing Sentinel-2 product as neither Landsat or the combined product meets both requirements or is significantly better than Sentinel-2')
# if combined product is unavailable, choose Landsat if it has both higher number and frequency
elif ((n_valid_obs_ls>=n_valid_obs_s2).all()) and ((freq_valid_ls>=freq_valid_s2).all()):
ds_selected, product_name=ds_ls,'ls'
print('\nChoosing Landsat product as it has both higher average number and frequency of valid observations within all time periods')
# otherwise choose Sentinel-2
else:
ds_selected, product_name=ds_s2,'s2'
print('\nChoosing Sentinel-2 product as Landsat product does not meet both requirements or is not significantly better than Sentinel-2')
# if Sentinel-2 doesn't meet both requirements,but Landsat does, either choose Landsat or combined product if available
elif ((n_valid_obs_ls>=thresh_n_valid).all()) and ((freq_valid_ls>=thresh_freq).all()):
print('\nSentinel-2 does not meet the minimum required average number and frequency of valid observations within all time periods, but Landsat does')
if not ds_ls_s2 is None:
ds_selected, product_name=ds_ls_s2,'ls_s2'
print('\nChoosing combined Landsat and Sentinel-2 product as it has both higher number and frequency of valid observations within all time periods')
else:
ds_selected, product_name=ds_ls,'ls'
print('\nChoosing Landsat product')
# if neither Sentinel-2 or Landsat meet both requirements, choose combined product if it meets requirements
elif not ds_ls_s2 is None:
print('\nNeither Sentinel-2 or Landsat meets the minimum required average number and frequency of valid observations within all time periods')
# but the combined product meet requirements
if ((n_valid_obs_ls_s2>=thresh_n_valid).all()) and ((freq_valid_ls_s2>=thresh_freq).all()):
ds_selected, product_name=ds_ls_s2,'ls_s2'
print('\nChoosing combined Landsat and Sentinel-2 product as it meets the minimum required average number and frequency of valid observations within all time periods')
else:
ds_selected, product_name=ds_s1,'s1'
print('\nChoosing Sentinel-1 as no other products available that meet the requirements')
# otherwise choose Sentinel-1
else:
print('\nNeither Sentinel-2 or Landsat meets the minimum required average number and frequency of valid observations within all time periods')
ds_selected, product_name=ds_s1,'s1'
print('\nChoosing Sentinel-1 product as no other products available that meet the requirements')
print('\nBest available product: ',product_name)
return ds_selected, product_name
def load_combined_ls_s2(dc,query):
'''function to query and load combined Landsat and Sentinel-2 data
Parameters:
dc: connected datacube
query: a query dictionary to define spatial extent, time range, measurements and spatial resolution for both datasets
Returns:
ds_combined: Combined data as xarray.Dataset
'''
print('Querying and loading combined Landsat and Sentinel-2 products...')
# Load available Landsat data resampled to Sentinel-2 resolution
ds_ls = load_ard(dc=dc, products=['ls8_sr', 'ls9_sr'],align=(10, 10),
resampling='bilinear',**query)
# add an variable denoting data source (for future analysis)
is_ls=xr.DataArray(np.ones(len(ds_ls.time)),dims=('time'),coords={'time': ds_ls.time})
ds_ls['is_ls'] = is_ls
# Load Sentinel-2 data
ds_s2 = load_ard(dc=dc,products=['s2_l2a'],resampling='bilinear',
align=(10, 10),mask_filters=[("opening", 2), ("dilation", 5)],**query)
# add an variable denoting data source (for future analysis)
is_ls=xr.DataArray(np.zeros(len(ds_s2.time)),dims=('time'),coords={'time': ds_s2.time})
ds_s2['is_ls'] = is_ls
# merge two datasets together
ds_combined=xr.concat([ds_ls,ds_s2],dim='time').sortby('time')
return ds_combined
def load_best_available_ds(dc, lat_range, lon_range, time_range, time_step, **kwargs):
'''
Function to query, load and compare different products, select and return the best available product
Parameters:
dc: connected datacube
lat_range: range of latitudes in tuple or list
lon_range: range of longitude in tuple or list
time_range: range of time to query the data in tuple or list
time_step: string, pre-defined time step for temporal aggregation, e.g. '1Y'
**kwargs: A set of optional parameters on data query or comparison between products which may include:
combine_ls_s2: A boolean value indicating whether to include merged/stacked Landsat and Sentinel-2 products as an option. Default to False.
set_resolution: integer of spatial resolution in metres to query all products
coastal_masking: A boolean value indicating whether to calculate a mask
and restrict the comparison of the products within the masked zone.
set_product: Set this to only query and load a pre-selected product, 'ls','s2','ls_s2' or 's1'
i.e. no other products will be queried or compared.
thresh_n_valid: Threhold of minimum average number of valid observations within each time step, integer
thresh_freq: Threshold of minimum frequency of valid observations within each time step, float between 0~1
buffer_pixels: Number of pixels to buffer coastal zone, integer
Returns:
ds_selected: selected product as xarray.Dataset
product_name: name of selected product in string format, i.e. 'ls','s2','ls_s2','s1'
'''
# parse input time range to accommodate queries before and after 2017
min_time=min(parser.parse(time_range_i,default=datetime(1987,1,1,0,0))
for time_range_i in time_range)
if min_time<datetime(2017,1,1,0,0):
ls_only=True
else:
ls_only=False
# set resolution for query based on optional user input
if "set_resolution" not in kwargs:
print('No resolution pre-set, using default resolutions for individual products...')
resolution_ls=(-30,30)
resolution_s2=(-10,10)
resolution_s1=(-20,20)
else:
print('Using pre-set spatial resolution for all products...')
resolution_ls=resolution_s2=resolution_s1=(kwargs["set_resolution"]*(-1),kwargs["set_resolution"])
# create base query for all products
query = {'x': lon_range,'y': lat_range,'time': time_range,
'measurements': ['red', 'green', 'blue', 'swir_1'],
'resolution': resolution_ls, 'group_by':'solar_day',
'dask_chunks': {'time': 1}}
# Identify the most common projection system in the input query
output_crs = mostcommon_crs(dc=dc, product='ls8_sr', query=query)
# update base query
query.update({'output_crs':output_crs,'min_gooddata':0.2})
# check if product is pre-set by user
set_product=None if not "set_product" in kwargs else kwargs["set_product"]
if not set_product is None:
product_name=set_product
# check if allowing combining Landsat and Sentinel-2 as an option
combine_ls_s2=False if not "combine_ls_s2" in kwargs else kwargs["combine_ls_s2"]
# query and load specified products as user provided as possible
if set_product=='ls':
print('\nPre-selected product: Landsat')
ds_selected=load_ard(dc=dc, products=['ls8_sr', 'ls9_sr'],
resampling='bilinear',**query)
elif set_product=='s2':
print('\nPre-selected product: Sentinel-2')
if ls_only:
raise ValueError("Querying date earlier than 2018, please change your pre-selected product as Landsat or query time range.")
query.update({'resolution': resolution_s2})
ds_selected= load_ard(dc=dc,products=['s2_l2a'],resampling='bilinear',
mask_filters=[("opening", 2), ("dilation", 5)],**query)
elif set_product=='s1':
print('\nPre-selected product: Sentinel-1')
if ls_only:
raise ValueError("Querying date earlier than 2018, please change your pre-selected product as Landsat or query time range.")
query.update({'resolution': resolution_s1,'measurements': ['vh','mask']})
ds_selected=load_s1_by_orbits(dc,query)
elif set_product=='ls_s2':
print('\nPre-selected product: combined Landsat and Sentinel-2 products')
if ("combine_ls_s2" in kwargs)and(combine_ls_s2==False):
raise ValueError("Conflicting: requesting querying combination of Landsat and Sentinel-2 products while parameter combine_ls_s2 is disabled. Please change parameter and try to run the function again.")
else:
query.update({'resolution': resolution_s2})
ds_selected=load_combined_ls_s2(dc,query)
else: # no preselection of product or wrong input of product name
print('\nNo pre-selection of products, querying and compare all products...')
if ls_only:
print('\nQuerying date earlier than 2018, only Landsat data will be queried and loaded.')
# Load available Landsat data
ds_ls = load_ard(dc=dc, products=['ls8_sr', 'ls9_sr'],
resampling='bilinear',**query)
ds_selected=ds_ls
product_name='ls'
else:
if combine_ls_s2==True:
query.update({'resolution': resolution_s2})
ds_ls_s2=load_combined_ls_s2(dc,query)
else:
print('\nCombined Landsat and Sentinel-2 products excluded from comparison')
ds_ls_s2=None
# Load available Landsat data
print('\nQuerying and loading Landsat data...')
ds_ls = load_ard(dc=dc, products=['ls8_sr', 'ls9_sr'],
resampling='bilinear',**query)
# Load Sentinel-2 data
print('\nQuerying and Sentinel-2 data...')
query.update({'resolution': resolution_s2})
ds_s2 = load_ard(dc=dc,products=['s2_l2a'],resampling='bilinear',
mask_filters=[("opening", 2), ("dilation", 5)],**query)
# query and filter Sentinel-1 data by orbit
query.update({'resolution': resolution_s1,'measurements': ['vh','mask']})
ds_s1=load_s1_by_orbits(dc,query)
# apply rules to choose best product
ds_selected,product_name=choose_product(ds_ls,ds_s2,ds_s1,ds_ls_s2,time_step,**kwargs)
# drop all-nan time steps
ds_selected=ds_selected.dropna(dim='time',how='all')
return ds_selected,product_name