950 lines
33 KiB
Python
Executable File
950 lines
33 KiB
Python
Executable File
'''
|
|
Spatial analyses functions for Digital Earth Africa data.
|
|
'''
|
|
|
|
# Force GeoPandas to use Shapely instead of PyGEOS
|
|
# In a future release, GeoPandas will switch to using Shapely by default.
|
|
import os
|
|
|
|
os.environ['USE_PYGEOS'] = '0'
|
|
|
|
import multiprocessing as mp
|
|
|
|
import dask
|
|
import fiona
|
|
import geopandas as gpd
|
|
import numpy as np
|
|
import odc.geo.xr # adds `.odc.x` attributes to our xarray objects.
|
|
import pandas as pd
|
|
import rasterio.features
|
|
import scipy.interpolate
|
|
import xarray as xr
|
|
from datacube.api.query import query_group_by
|
|
from datacube.model.utils import xr_apply
|
|
from datacube.utils.cog import write_cog
|
|
from datacube.utils.geometry import CRS, Geometry
|
|
from geopy.geocoders import Nominatim
|
|
from rasterstats import zonal_stats
|
|
from shapely.geometry import LineString, MultiLineString, mapping, shape
|
|
from skimage.measure import find_contours, label
|
|
|
|
|
|
def add_geobox(ds, crs=None):
|
|
"""
|
|
Ensure that an xarray DataArray has a GeoBox and .odc.* accessor
|
|
using `odc.geo`.
|
|
|
|
If `ds` is missing a Coordinate Reference System (CRS), this can be
|
|
supplied using the `crs` param.
|
|
|
|
Parameters
|
|
----------
|
|
ds : xarray.Dataset or xarray.DataArray
|
|
Input xarray object that needs to be checked for spatial
|
|
information.
|
|
crs : str, optional
|
|
Coordinate Reference System (CRS) information for the input `ds`
|
|
array. If `ds` already has a CRS, then `crs` is not required.
|
|
Default is None.
|
|
|
|
Returns
|
|
-------
|
|
xarray.Dataset or xarray.DataArray
|
|
The input xarray object with added `.odc.x` attributes to access
|
|
spatial information.
|
|
|
|
"""
|
|
# If a CRS is not found, use custom provided CRS
|
|
if ds.odc.crs is None and crs is not None:
|
|
ds = ds.odc.assign_crs(crs)
|
|
elif ds.odc.crs is None and crs is None:
|
|
raise ValueError(
|
|
"Unable to determine `ds`'s coordinate "
|
|
"reference system (CRS). Please provide a "
|
|
"CRS using the `crs` parameter "
|
|
"(e.g. `crs='EPSG:3577'`)."
|
|
)
|
|
|
|
return ds
|
|
|
|
|
|
def xr_vectorize(
|
|
da,
|
|
attribute_col=None,
|
|
crs=None,
|
|
dtype="float32",
|
|
output_path=None,
|
|
verbose=True,
|
|
**rasterio_kwargs,
|
|
):
|
|
"""
|
|
Vectorises a raster ``xarray.DataArray`` into a vector
|
|
``geopandas.GeoDataFrame``.
|
|
|
|
Parameters
|
|
----------
|
|
da : xarray.DataArray
|
|
The input ``xarray.DataArray`` data to vectorise.
|
|
attribute_col : str, optional
|
|
Name of the attribute column in the resulting
|
|
``geopandas.GeoDataFrame``. Values from ``da`` converted
|
|
to polygons will be assigned to this column. If None,
|
|
the column name will default to 'attribute'.
|
|
crs : str or CRS object, optional
|
|
If ``da``'s coordinate reference system (CRS) cannot be
|
|
determined, provide a CRS using this parameter.
|
|
(e.g. 'EPSG:3577').
|
|
dtype : str, optional
|
|
Data type of must be one of int16, int32, uint8, uint16,
|
|
or float32
|
|
output_path : string, optional
|
|
Provide an optional string file path to export the vectorised
|
|
data to file. Supports any vector file formats supported by
|
|
``geopandas.GeoDataFrame.to_file()``.
|
|
verbose : bool, optional
|
|
Print debugging messages. Default True.
|
|
**rasterio_kwargs :
|
|
A set of keyword arguments to ``rasterio.features.shapes``.
|
|
Can include `mask` and `connectivity`.
|
|
|
|
Returns
|
|
-------
|
|
gdf : geopandas.GeoDataFrame
|
|
|
|
"""
|
|
|
|
# Add GeoBox and odc.* accessor to array using `odc-geo`
|
|
da = add_geobox(da, crs)
|
|
|
|
# Run the vectorizing function
|
|
vectors = rasterio.features.shapes(
|
|
source=da.data.astype(dtype), transform=da.odc.transform, **rasterio_kwargs
|
|
)
|
|
|
|
# Convert the generator into a list
|
|
vectors = list(vectors)
|
|
|
|
# Extract the polygon coordinates and values from the list
|
|
polygons = [polygon for polygon, value in vectors]
|
|
values = [value for polygon, value in vectors]
|
|
|
|
# Convert polygon coordinates into polygon shapes
|
|
polygons = [shape(polygon) for polygon in polygons]
|
|
|
|
# Create a geopandas dataframe populated with the polygon shapes
|
|
attribute_name = attribute_col if attribute_col is not None else "attribute"
|
|
gdf = gpd.GeoDataFrame(
|
|
data={attribute_name: values}, geometry=polygons, crs=da.odc.crs
|
|
)
|
|
|
|
# If a file path is supplied, export to file
|
|
if output_path is not None:
|
|
if verbose:
|
|
print(f"Exporting vector data to {output_path}")
|
|
gdf.to_file(output_path)
|
|
|
|
return gdf
|
|
|
|
|
|
def xr_rasterize(
|
|
gdf,
|
|
da,
|
|
attribute_col=None,
|
|
crs=None,
|
|
name=None,
|
|
output_path=None,
|
|
verbose=True,
|
|
**rasterio_kwargs,
|
|
):
|
|
"""
|
|
Rasterizes a vector ``geopandas.GeoDataFrame`` into a
|
|
raster ``xarray.DataArray``.
|
|
|
|
Parameters
|
|
----------
|
|
gdf : geopandas.GeoDataFrame
|
|
A ``geopandas.GeoDataFrame`` object containing the vector
|
|
data you want to rasterise.
|
|
da : xarray.DataArray or xarray.Dataset
|
|
The shape, coordinates, dimensions, and transform of this object
|
|
are used to define the array that ``gdf`` is rasterized into.
|
|
It effectively provides a spatial template.
|
|
attribute_col : string, optional
|
|
Name of the attribute column in ``gdf`` containing values for
|
|
each vector feature that will be rasterized. If None, the
|
|
output will be a boolean array of 1's and 0's.
|
|
crs : str or CRS object, optional
|
|
If ``da``'s coordinate reference system (CRS) cannot be
|
|
determined, provide a CRS using this parameter.
|
|
(e.g. 'EPSG:3577').
|
|
name : str, optional
|
|
An optional name used for the output ``xarray.DataArray`.
|
|
output_path : string, optional
|
|
Provide an optional string file path to export the rasterized
|
|
data as a GeoTIFF file.
|
|
verbose : bool, optional
|
|
Print debugging messages. Default True.
|
|
**rasterio_kwargs :
|
|
A set of keyword arguments to ``rasterio.features.rasterize``.
|
|
Can include: 'all_touched', 'merge_alg', 'dtype'.
|
|
|
|
Returns
|
|
-------
|
|
da_rasterized : xarray.DataArray
|
|
The rasterized vector data.
|
|
"""
|
|
|
|
# Add GeoBox and odc.* accessor to array using `odc-geo`
|
|
da = add_geobox(da, crs)
|
|
|
|
# Reproject vector data to raster's CRS
|
|
gdf_reproj = gdf.to_crs(crs=da.odc.crs)
|
|
|
|
# If an attribute column is specified, rasterise using vector
|
|
# attribute values. Otherwise, rasterise into a boolean array
|
|
if attribute_col is not None:
|
|
# Use the geometry and attributes from `gdf` to create an iterable
|
|
shapes = zip(gdf_reproj.geometry, gdf_reproj[attribute_col])
|
|
else:
|
|
# Use geometry directly (will produce a boolean numpy array)
|
|
shapes = gdf_reproj.geometry
|
|
|
|
# Rasterise shapes into a numpy array
|
|
im = rasterio.features.rasterize(
|
|
shapes=shapes,
|
|
out_shape=da.odc.geobox.shape,
|
|
transform=da.odc.geobox.transform,
|
|
**rasterio_kwargs,
|
|
)
|
|
|
|
# Convert numpy array to a full xarray.DataArray
|
|
# and set array name if supplied
|
|
da_rasterized = odc.geo.xr.wrap_xr(im=im, gbox=da.odc.geobox)
|
|
da_rasterized = da_rasterized.rename(name)
|
|
|
|
# If a file path is supplied, export to file
|
|
if output_path is not None:
|
|
if verbose:
|
|
print(f"Exporting raster data to {output_path}")
|
|
write_cog(da_rasterized, output_path, overwrite=True)
|
|
|
|
return da_rasterized
|
|
|
|
|
|
def subpixel_contours(
|
|
da,
|
|
z_values=[0.0],
|
|
crs=None,
|
|
attribute_df=None,
|
|
output_path=None,
|
|
min_vertices=2,
|
|
dim="time",
|
|
time_format="%Y-%m-%d",
|
|
errors="ignore",
|
|
verbose=True,
|
|
):
|
|
"""
|
|
Uses `skimage.measure.find_contours` to extract multiple z-value
|
|
contour lines from a two-dimensional array (e.g. multiple elevations
|
|
from a single DEM), or one z-value for each array along a specified
|
|
dimension of a multi-dimensional array (e.g. to map waterlines
|
|
across time by extracting a 0 NDWI contour from each individual
|
|
timestep in an xarray timeseries).
|
|
|
|
Contours are returned as a geopandas.GeoDataFrame with one row per
|
|
z-value or one row per array along a specified dimension. The
|
|
`attribute_df` parameter can be used to pass custom attributes
|
|
to the output contour features.
|
|
|
|
Last modified: May 2023
|
|
|
|
Parameters
|
|
----------
|
|
da : xarray DataArray
|
|
A two-dimensional or multi-dimensional array from which
|
|
contours are extracted. If a two-dimensional array is provided,
|
|
the analysis will run in 'single array, multiple z-values' mode
|
|
which allows you to specify multiple `z_values` to be extracted.
|
|
If a multi-dimensional array is provided, the analysis will run
|
|
in 'single z-value, multiple arrays' mode allowing you to
|
|
extract contours for each array along the dimension specified
|
|
by the `dim` parameter.
|
|
z_values : int, float or list of ints, floats
|
|
An individual z-value or list of multiple z-values to extract
|
|
from the array. If operating in 'single z-value, multiple
|
|
arrays' mode specify only a single z-value.
|
|
crs : string or CRS object, optional
|
|
If ``da``'s coordinate reference system (CRS) cannot be
|
|
determined, provide a CRS using this parameter.
|
|
(e.g. 'EPSG:3577').
|
|
output_path : string, optional
|
|
The path and filename for the output shapefile.
|
|
attribute_df : pandas.Dataframe, optional
|
|
A pandas.Dataframe containing attributes to pass to the output
|
|
contour features. The dataframe must contain either the same
|
|
number of rows as supplied `z_values` (in 'multiple z-value,
|
|
single array' mode), or the same number of rows as the number
|
|
of arrays along the `dim` dimension ('single z-value, multiple
|
|
arrays mode').
|
|
min_vertices : int, optional
|
|
The minimum number of vertices required for a contour to be
|
|
extracted. The default (and minimum) value is 2, which is the
|
|
smallest number required to produce a contour line (i.e. a start
|
|
and end point). Higher values remove smaller contours,
|
|
potentially removing noise from the output dataset.
|
|
dim : string, optional
|
|
The name of the dimension along which to extract contours when
|
|
operating in 'single z-value, multiple arrays' mode. The default
|
|
is 'time', which extracts contours for each array along the time
|
|
dimension.
|
|
time_format : string, optional
|
|
The format used to convert `numpy.datetime64` values to strings
|
|
if applied to data with a "time" dimension. Defaults to
|
|
"%Y-%m-%d".
|
|
errors : string, optional
|
|
If 'raise', then any failed contours will raise an exception.
|
|
If 'ignore' (the default), a list of failed contours will be
|
|
printed. If no contours are returned, an exception will always
|
|
be raised.
|
|
verbose : bool, optional
|
|
Print debugging messages. Default is True.
|
|
|
|
Returns
|
|
-------
|
|
output_gdf : geopandas geodataframe
|
|
A geopandas geodataframe object with one feature per z-value
|
|
('single array, multiple z-values' mode), or one row per array
|
|
along the dimension specified by the `dim` parameter ('single
|
|
z-value, multiple arrays' mode). If `attribute_df` was
|
|
provided, these values will be included in the shapefile's
|
|
attribute table.
|
|
"""
|
|
|
|
def _contours_to_multiline(da_i, z_value, min_vertices=2):
|
|
"""
|
|
Helper function to apply marching squares contour extraction
|
|
to an array and return a data as a shapely MultiLineString.
|
|
The `min_vertices` parameter allows you to drop small contours
|
|
with less than X vertices.
|
|
"""
|
|
|
|
# Extracts contours from array, and converts each discrete
|
|
# contour into a Shapely LineString feature. If the function
|
|
# returns a KeyError, this may be due to an unresolved issue in
|
|
# scikit-image: https://github.com/scikit-image/scikit-image/issues/4830
|
|
# A temporary workaround is to peturb the z-value by a tiny
|
|
# amount (1e-12) before using it to extract the contour.
|
|
try:
|
|
line_features = [
|
|
LineString(i[:, [1, 0]])
|
|
for i in find_contours(da_i.data, z_value)
|
|
if i.shape[0] >= min_vertices
|
|
]
|
|
except KeyError:
|
|
line_features = [
|
|
LineString(i[:, [1, 0]])
|
|
for i in find_contours(da_i.data, z_value + 1e-12)
|
|
if i.shape[0] >= min_vertices
|
|
]
|
|
|
|
# Output resulting lines into a single combined MultiLineString
|
|
return MultiLineString(line_features)
|
|
|
|
def _time_format(i, time_format):
|
|
"""
|
|
Converts numpy.datetime64 into formatted strings;
|
|
otherwise returns data as-is.
|
|
"""
|
|
if isinstance(i, np.datetime64):
|
|
ts = pd.to_datetime(str(i))
|
|
i = ts.strftime(time_format)
|
|
return i
|
|
|
|
# Verify input data is a xr.DataArray
|
|
if not isinstance(da, xr.DataArray):
|
|
raise ValueError(
|
|
"The input `da` is not an xarray.DataArray. "
|
|
"If you supplied an xarray.Dataset, pass in one "
|
|
"of its data variables using the syntax "
|
|
"`da=ds.<variable name>`."
|
|
)
|
|
|
|
# Add GeoBox and odc.* accessor to array using `odc-geo`
|
|
da = add_geobox(da, crs)
|
|
|
|
# If z_values is supplied is not a list, convert to list:
|
|
z_values = (
|
|
z_values
|
|
if (isinstance(z_values, list) or isinstance(z_values, np.ndarray))
|
|
else [z_values]
|
|
)
|
|
|
|
# If dask collection, load into memory
|
|
if dask.is_dask_collection(da):
|
|
if verbose:
|
|
print("Loading data into memory using Dask")
|
|
da = da.compute()
|
|
|
|
# Test number of dimensions in supplied data array
|
|
if len(da.shape) == 2:
|
|
if verbose:
|
|
print("Operating in multiple z-value, single array mode")
|
|
dim = "z_value"
|
|
contour_arrays = {
|
|
_time_format(i, time_format): _contours_to_multiline(da, i, min_vertices)
|
|
for i in z_values
|
|
}
|
|
|
|
else:
|
|
# Test if only a single z-value is given when operating in
|
|
# single z-value, multiple arrays mode
|
|
if verbose:
|
|
print("Operating in single z-value, multiple arrays mode")
|
|
if len(z_values) > 1:
|
|
raise ValueError(
|
|
"Please provide a single z-value when operating "
|
|
"in single z-value, multiple arrays mode"
|
|
)
|
|
|
|
contour_arrays = {
|
|
_time_format(i, time_format): _contours_to_multiline(
|
|
da_i, z_values[0], min_vertices
|
|
)
|
|
for i, da_i in da.groupby(dim)
|
|
}
|
|
|
|
# If attributes are provided, add the contour keys to that dataframe
|
|
if attribute_df is not None:
|
|
try:
|
|
attribute_df.insert(0, dim, contour_arrays.keys())
|
|
|
|
# If this fails, it is due to the applied attribute table not
|
|
# matching the structure of the loaded data
|
|
except ValueError:
|
|
if len(da.shape) == 2:
|
|
raise ValueError(
|
|
f"The provided `attribute_df` contains a different "
|
|
f"number of rows ({len(attribute_df.index)}) "
|
|
f"than the number of supplied `z_values` "
|
|
f"({len(z_values)})."
|
|
)
|
|
else:
|
|
raise ValueError(
|
|
f"The provided `attribute_df` contains a different "
|
|
f"number of rows ({len(attribute_df.index)}) "
|
|
f"than the number of arrays along the '{dim}' "
|
|
f"dimension ({len(da[dim])})."
|
|
)
|
|
|
|
# Otherwise, use the contour keys as the only main attributes
|
|
else:
|
|
attribute_df = list(contour_arrays.keys())
|
|
|
|
# Convert output contours to a geopandas.GeoDataFrame
|
|
contours_gdf = gpd.GeoDataFrame(
|
|
data=attribute_df, geometry=list(contour_arrays.values()), crs=da.odc.crs
|
|
)
|
|
|
|
# Define affine and use to convert array coords to geographic coords.
|
|
# We need to add 0.5 x pixel size to the x and y to obtain the centre
|
|
# point of our pixels, rather than the top-left corner
|
|
affine = da.odc.geobox.transform
|
|
shapely_affine = [
|
|
affine.a,
|
|
affine.b,
|
|
affine.d,
|
|
affine.e,
|
|
affine.xoff + affine.a / 2.0,
|
|
affine.yoff + affine.e / 2.0,
|
|
]
|
|
contours_gdf["geometry"] = contours_gdf.affine_transform(shapely_affine)
|
|
|
|
# Rename the data column to match the dimension
|
|
contours_gdf = contours_gdf.rename({0: dim}, axis=1)
|
|
|
|
# Drop empty timesteps
|
|
empty_contours = contours_gdf.geometry.is_empty
|
|
failed = ", ".join(map(str, contours_gdf[empty_contours][dim].to_list()))
|
|
contours_gdf = contours_gdf[~empty_contours]
|
|
|
|
# Raise exception if no data is returned, or if any contours fail
|
|
# when `errors='raise'. Otherwise, print failed contours
|
|
if empty_contours.all() and errors == "raise":
|
|
raise ValueError(
|
|
"Failed to generate any valid contours; verify that "
|
|
"values passed to `z_values` are valid and present "
|
|
"in `da`"
|
|
)
|
|
elif empty_contours.all() and errors == "ignore":
|
|
if verbose:
|
|
print(
|
|
"Failed to generate any valid contours; verify that "
|
|
"values passed to `z_values` are valid and present "
|
|
"in `da`"
|
|
)
|
|
elif empty_contours.any() and errors == "raise":
|
|
raise Exception(f"Failed to generate contours: {failed}")
|
|
elif empty_contours.any() and errors == "ignore":
|
|
if verbose:
|
|
print(f"Failed to generate contours: {failed}")
|
|
|
|
# If asked to write out file, test if GeoJSON or ESRI Shapefile. If
|
|
# GeoJSON, convert to EPSG:4326 before exporting.
|
|
if output_path and output_path.endswith(".geojson"):
|
|
if verbose:
|
|
print(f"Writing contours to {output_path}")
|
|
contours_gdf.to_crs("EPSG:4326").to_file(filename=output_path)
|
|
|
|
if output_path and output_path.endswith(".shp"):
|
|
if verbose:
|
|
print(f"Writing contours to {output_path}")
|
|
contours_gdf.to_file(filename=output_path)
|
|
|
|
return contours_gdf
|
|
|
|
|
|
def interpolate_2d(ds,
|
|
x_coords,
|
|
y_coords,
|
|
z_coords,
|
|
method='linear',
|
|
factor=1,
|
|
verbose=False,
|
|
**kwargs):
|
|
|
|
"""
|
|
This function takes points with X, Y and Z coordinates, and
|
|
interpolates Z-values across the extent of an existing xarray
|
|
dataset. This can be useful for producing smooth surfaces from point
|
|
data that can be compared directly against satellite data derived
|
|
from an OpenDataCube query.
|
|
|
|
Supported interpolation methods include 'linear', 'nearest' and
|
|
'cubic (using `scipy.interpolate.griddata`), and 'rbf' (using
|
|
`scipy.interpolate.Rbf`).
|
|
|
|
Last modified: February 2020
|
|
|
|
Parameters
|
|
----------
|
|
ds : xarray DataArray or Dataset
|
|
A two-dimensional or multi-dimensional array from which x and y
|
|
dimensions will be copied and used for the area in which to
|
|
interpolate point data.
|
|
x_coords, y_coords : numpy array
|
|
Arrays containing X and Y coordinates for all points (e.g.
|
|
longitudes and latitudes).
|
|
z_coords : numpy array
|
|
An array containing Z coordinates for all points (e.g.
|
|
elevations). These are the values you wish to interpolate
|
|
between.
|
|
method : string, optional
|
|
The method used to interpolate between point values. This string
|
|
is either passed to `scipy.interpolate.griddata` (for 'linear',
|
|
'nearest' and 'cubic' methods), or used to specify Radial Basis
|
|
Function interpolation using `scipy.interpolate.Rbf` ('rbf').
|
|
Defaults to 'linear'.
|
|
factor : int, optional
|
|
An optional integer that can be used to subsample the spatial
|
|
interpolation extent to obtain faster interpolation times, then
|
|
up-sample this array back to the original dimensions of the
|
|
data as a final step. For example, setting `factor=10` will
|
|
interpolate data into a grid that has one tenth of the
|
|
resolution of `ds`. This approach will be significantly faster
|
|
than interpolating at full resolution, but will potentially
|
|
produce less accurate or reliable results.
|
|
verbose : bool, optional
|
|
Print debugging messages. Default False.
|
|
**kwargs :
|
|
Optional keyword arguments to pass to either
|
|
`scipy.interpolate.griddata` (if `method` is 'linear', 'nearest'
|
|
or 'cubic'), or `scipy.interpolate.Rbf` (is `method` is 'rbf').
|
|
|
|
Returns
|
|
-------
|
|
interp_2d_array : xarray DataArray
|
|
An xarray DataArray containing with x and y coordinates copied
|
|
from `ds_array`, and Z-values interpolated from the points data.
|
|
"""
|
|
|
|
# Extract xy and elev points
|
|
points_xy = np.vstack([x_coords, y_coords]).T
|
|
|
|
# Extract x and y coordinates to interpolate into.
|
|
# If `factor` is greater than 1, the coordinates will be subsampled
|
|
# for faster run-times. If the last x or y value in the subsampled
|
|
# grid aren't the same as the last x or y values in the original
|
|
# full resolution grid, add the final full resolution grid value to
|
|
# ensure data is interpolated up to the very edge of the array
|
|
if ds.x[::factor][-1].item() == ds.x[-1].item():
|
|
x_grid_coords = ds.x[::factor].values
|
|
else:
|
|
x_grid_coords = ds.x[::factor].values.tolist() + [ds.x[-1].item()]
|
|
|
|
if ds.y[::factor][-1].item() == ds.y[-1].item():
|
|
y_grid_coords = ds.y[::factor].values
|
|
else:
|
|
y_grid_coords = ds.y[::factor].values.tolist() + [ds.y[-1].item()]
|
|
|
|
# Create grid to interpolate into
|
|
grid_y, grid_x = np.meshgrid(x_grid_coords, y_grid_coords)
|
|
|
|
# Apply scipy.interpolate.griddata interpolation methods
|
|
if method in ('linear', 'nearest', 'cubic'):
|
|
|
|
# Interpolate x, y and z values
|
|
interp_2d = scipy.interpolate.griddata(points=points_xy,
|
|
values=z_coords,
|
|
xi=(grid_y, grid_x),
|
|
method=method,
|
|
**kwargs)
|
|
|
|
# Apply Radial Basis Function interpolation
|
|
elif method == 'rbf':
|
|
|
|
# Interpolate x, y and z values
|
|
rbf = scipy.interpolate.Rbf(x_coords, y_coords, z_coords, **kwargs)
|
|
interp_2d = rbf(grid_y, grid_x)
|
|
|
|
# Create xarray dataarray from the data and resample to ds coords
|
|
interp_2d_da = xr.DataArray(interp_2d,
|
|
coords=[y_grid_coords, x_grid_coords],
|
|
dims=['y', 'x'])
|
|
|
|
# If factor is greater than 1, resample the interpolated array to
|
|
# match the input `ds` array
|
|
if factor > 1:
|
|
interp_2d_da = interp_2d_da.interp_like(ds)
|
|
|
|
return interp_2d_da
|
|
|
|
|
|
def contours_to_arrays(gdf, col):
|
|
"""
|
|
This function converts a polyline shapefile into an array with three
|
|
columns giving the X, Y and Z coordinates of each vertex. This data
|
|
can then be used as an input to interpolation procedures (e.g. using
|
|
a function like `interpolate_2d`.
|
|
|
|
Last modified: October 2021
|
|
|
|
Parameters
|
|
----------
|
|
gdf : Geopandas GeoDataFrame
|
|
A GeoPandas GeoDataFrame of lines to convert into point
|
|
coordinates.
|
|
col : str
|
|
A string giving the name of the GeoDataFrame field to use as
|
|
Z-values.
|
|
|
|
Returns
|
|
-------
|
|
A numpy array with three columns giving the X, Y and Z coordinates
|
|
of each vertex in the input GeoDataFrame.
|
|
|
|
"""
|
|
|
|
# Explode multi-part geometries into multiple single geometries.
|
|
gdf = gdf.explode(ignore_index=True)
|
|
|
|
coords_zvals = []
|
|
|
|
for i in range(0, len(gdf)):
|
|
val = gdf.iloc[i][col]
|
|
|
|
try:
|
|
coords = np.concatenate(
|
|
[np.vstack(x.coords.xy).T for x in gdf.iloc[i].geometry.geoms]
|
|
)
|
|
except Exception:
|
|
coords = np.vstack(gdf.iloc[i].geometry.coords.xy).T
|
|
|
|
coords_zvals.append(
|
|
np.column_stack((coords, np.full(np.shape(coords)[0], fill_value=val)))
|
|
)
|
|
|
|
return np.concatenate(coords_zvals)
|
|
|
|
|
|
def largest_region(bool_array, **kwargs):
|
|
|
|
'''
|
|
Takes a boolean array and identifies the largest contiguous region of
|
|
connected True values. This is returned as a new array with cells in
|
|
the largest region marked as True, and all other cells marked as False.
|
|
|
|
Parameters
|
|
----------
|
|
bool_array : boolean array
|
|
A boolean array (numpy or xarray.DataArray) with True values for
|
|
the areas that will be inspected to find the largest group of
|
|
connected cells
|
|
**kwargs :
|
|
Optional keyword arguments to pass to `measure.label`
|
|
|
|
Returns
|
|
-------
|
|
largest_region : boolean array
|
|
A boolean array with cells in the largest region marked as True,
|
|
and all other cells marked as False.
|
|
|
|
'''
|
|
|
|
# First, break boolean array into unique, discrete regions/blobs
|
|
blobs_labels = label(bool_array, background=0, **kwargs)
|
|
|
|
# Count the size of each blob, excluding the background class (0)
|
|
ids, counts = np.unique(blobs_labels[blobs_labels > 0],
|
|
return_counts=True)
|
|
|
|
# Identify the region ID of the largest blob
|
|
largest_region_id = ids[np.argmax(counts)]
|
|
|
|
# Produce a boolean array where 1 == the largest region
|
|
largest_region = blobs_labels == largest_region_id
|
|
|
|
return largest_region
|
|
|
|
|
|
def transform_geojson_wgs_to_epsg(geojson, EPSG):
|
|
"""
|
|
Takes a geojson dictionary and converts it from WGS84 (EPSG:4326) to desired EPSG
|
|
|
|
Parameters
|
|
----------
|
|
geojson: dict
|
|
a geojson dictionary containing a 'geometry' key, in WGS84 coordinates
|
|
EPSG: int
|
|
numeric code for the EPSG coordinate referecnce system to transform into
|
|
|
|
Returns
|
|
-------
|
|
transformed_geojson: dict
|
|
a geojson dictionary containing a 'coordinates' key, in the desired CRS
|
|
|
|
"""
|
|
gg = Geometry(geojson['geometry'], CRS('epsg:4326'))
|
|
gg = gg.to_crs(CRS(f'epsg:{EPSG}'))
|
|
return gg.__geo_interface__
|
|
|
|
|
|
def zonal_stats_parallel(shp,
|
|
raster,
|
|
statistics,
|
|
out_shp,
|
|
ncpus,
|
|
**kwargs):
|
|
|
|
"""
|
|
Summarizing raster datasets based on vector geometries in parallel.
|
|
Each cpu recieves an equal chunk of the dataset.
|
|
Utilizes the perrygeo/rasterstats package.
|
|
|
|
Parameters
|
|
----------
|
|
shp : str
|
|
Path to shapefile that contains polygons over
|
|
which zonal statistics are calculated
|
|
raster: str
|
|
Path to the raster from which the statistics are calculated.
|
|
This can be a virtual raster (.vrt).
|
|
statistics: list
|
|
list of statistics to calculate. e.g.
|
|
['min', 'max', 'median', 'majority', 'sum']
|
|
out_shp: str
|
|
Path to export shapefile containing zonal statistics.
|
|
ncpus: int
|
|
number of cores to parallelize the operations over.
|
|
kwargs:
|
|
Any other keyword arguments to rasterstats.zonal_stats()
|
|
See https://github.com/perrygeo/python-rasterstats for
|
|
all options
|
|
|
|
Returns
|
|
-------
|
|
Exports a shapefile to disk containing the zonal statistics requested
|
|
|
|
"""
|
|
|
|
# yields n sized chunks from list l (used for splitting task to multiple processes)
|
|
def chunks(l, n):
|
|
for i in range(0, len(l), n):
|
|
yield l[i:i + n]
|
|
|
|
# calculates zonal stats and adds results to a dictionary
|
|
def worker(z, raster, d):
|
|
z_stats = zonal_stats(z, raster, stats=statistics, **kwargs)
|
|
for i in range(0, len(z_stats)):
|
|
d[z[i]['id']] = z_stats[i]
|
|
|
|
# write output polygon
|
|
def write_output(zones, out_shp, d):
|
|
# copy schema and crs from input and add new fields for each statistic
|
|
schema = zones.schema.copy()
|
|
crs = zones.crs
|
|
for stat in statistics:
|
|
schema['properties'][stat] = 'float'
|
|
|
|
with fiona.open(out_shp, 'w', 'ESRI Shapefile', schema, crs) as output:
|
|
for elem in zones:
|
|
for stat in statistics:
|
|
elem['properties'][stat] = d[elem['id']][stat]
|
|
output.write({'properties': elem['properties'], 'geometry': mapping(shape(elem['geometry']))})
|
|
|
|
with fiona.open(shp) as zones:
|
|
jobs = []
|
|
|
|
# create manager dictionary (polygon ids=keys, stats=entries)
|
|
# where multiple processes can write without conflicts
|
|
man = mp.Manager()
|
|
d = man.dict()
|
|
|
|
# split zone polygons into 'ncpus' chunks for parallel processing
|
|
# and call worker() for each
|
|
split = chunks(zones, len(zones)//ncpus)
|
|
for z in split:
|
|
p = mp.Process(target=worker, args=(z, raster, d))
|
|
p.start()
|
|
jobs.append(p)
|
|
|
|
# wait that all chunks are finished
|
|
[j.join() for j in jobs]
|
|
|
|
write_output(zones, out_shp, d)
|
|
|
|
|
|
def reverse_geocode(coords, site_classes=None, state_classes=None):
|
|
"""
|
|
Takes a latitude and longitude coordinate, and performs a reverse
|
|
geocode to return a plain-text description of the location in the
|
|
form:
|
|
|
|
Site, State
|
|
|
|
E.g.: `reverse_geocode(coords=(-35.282163, 149.128835))`
|
|
|
|
'Canberra, Australian Capital Territory'
|
|
|
|
Parameters
|
|
----------
|
|
coords : tuple of floats
|
|
A tuple of (latitude, longitude) coordinates used to perform
|
|
the reverse geocode.
|
|
site_classes : list of strings, optional
|
|
A list of strings used to define the site part of the plain
|
|
text location description. Because the contents of the geocoded
|
|
address can vary greatly depending on location, these strings
|
|
are tested against the address one by one until a match is made.
|
|
|
|
Defaults to:
|
|
|
|
``['city', 'town', 'village', 'suburb', 'hamlet', 'county', 'municipality']``
|
|
|
|
state_classes : list of strings, optional
|
|
A list of strings used to define the state part of the plain
|
|
text location description. These strings are tested against the
|
|
address one by one until a match is made. Defaults to:
|
|
`['state', 'territory']`.
|
|
Returns
|
|
-------
|
|
If a valid geocoded address is found, a plain text location
|
|
description will be returned:
|
|
|
|
'Site, State'
|
|
|
|
If no valid address is found, formatted coordinates will be returned
|
|
instead:
|
|
|
|
'XX.XX S, XX.XX E'
|
|
"""
|
|
|
|
# Run reverse geocode using coordinates
|
|
geocoder = Nominatim(user_agent='Digital Earth Africa')
|
|
out = geocoder.reverse(coords)
|
|
|
|
# Create plain text-coords as fall-back
|
|
lat = f'{-coords[0]:.2f} S' if coords[0] < 0 else f'{coords[0]:.2f} N'
|
|
lon = f'{-coords[1]:.2f} W' if coords[1] < 0 else f'{coords[1]:.2f} E'
|
|
|
|
try:
|
|
|
|
# Get address from geocoded data
|
|
address = out.raw['address']
|
|
|
|
# Use site and state classes if supplied; else use defaults
|
|
default_site_classes = ['city', 'town', 'village', 'suburb', 'hamlet',
|
|
'county', 'municipality']
|
|
default_state_classes = ['state', 'territory']
|
|
site_classes = site_classes if site_classes else default_site_classes
|
|
state_classes = state_classes if state_classes else default_state_classes
|
|
|
|
# Return the first site or state class that exists in address dict
|
|
site = next((address[k] for k in site_classes if k in address), None)
|
|
state = next((address[k] for k in state_classes if k in address), None)
|
|
|
|
# If site and state exist in the data, return this.
|
|
# Otherwise, return N/E/S/W coordinates.
|
|
if site and state:
|
|
|
|
# Return as site, state formatted string
|
|
return f'{site}, {state}'
|
|
|
|
else:
|
|
|
|
# If no geocoding result, return N/E/S/W coordinates
|
|
print('No valid geocoded location; returning coordinates instead')
|
|
return f'{lat}, {lon}'
|
|
|
|
except (KeyError, AttributeError):
|
|
|
|
# If no geocoding result, return N/E/S/W coordinates
|
|
print('No valid geocoded location; returning coordinates instead')
|
|
return f'{lat}, {lon}'
|
|
|
|
|
|
def sun_angles(dc, query):
|
|
"""
|
|
For a given spatiotemporal query, calculate mean sun
|
|
azimuth and elevation for each satellite observation, and
|
|
return these as a new `xarray.Dataset` with 'sun_elevation'
|
|
and 'sun_azimuth' variables.
|
|
|
|
Parameters:
|
|
-----------
|
|
dc : datacube.Datacube object
|
|
Datacube instance used to load data.
|
|
query : dict
|
|
A dictionary containing query parameters used to identify
|
|
satellite observations and load metadata.
|
|
|
|
Returns:
|
|
--------
|
|
sun_angles_ds : xarray.Dataset
|
|
An `xarray.set` containing a 'sun_elevation' and
|
|
'sun_azimuth' variables.
|
|
"""
|
|
# Identify satellite datasets and group outputs using the
|
|
# same approach used to group satellite imagery (i.e. solar day)
|
|
gb = query_group_by(**query)
|
|
datasets = dc.find_datasets(**query)
|
|
dataset_array = dc.group_datasets(datasets, gb)
|
|
|
|
# Load and take the mean of metadata from each product
|
|
sun_azimuth = xr_apply(
|
|
dataset_array,
|
|
lambda t, dd: np.mean([d.metadata.eo_sun_azimuth for d in dd]),
|
|
dtype=float,
|
|
)
|
|
sun_elevation = xr_apply(
|
|
dataset_array,
|
|
lambda t, dd: np.mean([d.metadata.eo_sun_elevation for d in dd]),
|
|
dtype=float,
|
|
)
|
|
|
|
# Combine into new xarray.Dataset
|
|
sun_angles_ds = xr.merge(
|
|
[sun_elevation.rename("sun_elevation"), sun_azimuth.rename("sun_azimuth")]
|
|
)
|
|
|
|
return sun_angles_ds
|