first commit
This commit is contained in:
@@ -0,0 +1,949 @@
|
||||
'''
|
||||
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
|
||||
Reference in New Issue
Block a user