#!/usr/bin/env python
# -*- coding: UTF-8 -*-
# Copyright (c) 2011-2023, wradlib developers.
# Distributed under the MIT License. See LICENSE.txt for more info.
"""
Polar Grid Functions
^^^^^^^^^^^^^^^^^^^^
.. autosummary::
:nosignatures:
:toctree: generated/
{}
"""
__all__ = [
"georeference",
"spherical_to_xyz",
"spherical_to_proj",
"spherical_to_polyvert",
"spherical_to_centroids",
"centroid_to_polyvert",
"sweep_centroids",
"maximum_intensity_projection",
"GeorefPolarMethods",
]
__doc__ = __doc__.format("\n ".join(__all__))
__doctest_requires__ = {"spherical*": ["osgeo"]}
import warnings
from functools import singledispatch
import numpy as np
from pyproj import CRS
from xarray import DataArray, Dataset, apply_ufunc
from xradar.georeference import add_crs
from wradlib.georef import misc, projection
from wradlib.util import docstring, has_import, import_optional
osr = import_optional("osgeo.osr")
[docs]@singledispatch
def spherical_to_xyz(
r,
phi,
theta,
site,
*,
re=None,
ke=4.0 / 3.0,
squeeze=False,
strict_dims=False,
):
"""Transforms spherical coordinates (r, phi, theta) to cartesian
coordinates (x, y, z) centered at site (aeqd).
It takes the shortening of the great circle
distance with increasing elevation angle as well as the resulting
increase in height into account.
Parameters
----------
r : :class:`numpy:numpy.ndarray`
Contains the radial distances in meters.
phi : :class:`numpy:numpy.ndarray`
Contains the azimuthal angles in degree.
theta: :class:`numpy:numpy.ndarray`
Contains the elevation angles in degree.
site : sequence
the lon / lat / alt coordinates of the radar location and its altitude
a.m.s.l. (in meters)
Keyword Arguments
-----------------
re : float
earth's radius [m], defaults to None (calculating from given latitude)
ke : float
adjustment factor to account for the refractivity gradient that
affects radar beam propagation. In principle this is wavelength-
dependend. The default of 4/3 is a good approximation for most
weather radar wavelengths.
squeeze : bool
If True, returns squeezed array. Defaults to False.
strict_dims : bool
If True, generates output of (theta, phi, r, 3) in any case.
If False, dimensions with same length are "merged".
Returns
-------
xyz : :class:`numpy:numpy.ndarray`
Array of shape (..., 3). Contains cartesian coordinates.
aeqd : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Spatial Reference System (AEQD-Projection).
"""
centalt = site[2]
# if no radius is given, get the approximate radius of the WGS84
# ellipsoid for the site's latitude
if re is None:
re = projection.get_earth_radius(site[1])
# Set up aeqd-projection sitecoord-centered, wgs84 datum and ellipsoid
# use world azimuthal equidistant projection
projstr = (
f"+proj=aeqd +lon_0={site[0]:f} +x_0=0 +y_0=0 "
f"+lat_0={site[1]:f} +ellps=WGS84 +datum=WGS84 "
"+units=m +no_defs"
)
else:
# Set up aeqd-projection sitecoord-centered, assuming spherical earth
# use Sphere azimuthal equidistant projection
projstr = (
f"+proj=aeqd +lon_0={site[0]:f} +lat_0={site[1]:f} "
f"+a={re:f} +b={re:f} +units=m +no_defs"
)
osr = import_optional("osgeo.osr")
if has_import(osr):
aeqd = projection.projstr_to_osr(projstr)
else:
aeqd = projstr
r = np.asanyarray(r)
theta = np.asanyarray(theta)
phi = np.asanyarray(phi)
if r.ndim:
r = r.reshape((1,) * (3 - r.ndim) + r.shape)
if phi.ndim:
phi = phi.reshape((1,) + phi.shape + (1,) * (2 - phi.ndim))
if not theta.ndim:
theta = np.broadcast_to(theta, phi.shape)
dims = 3
if not strict_dims:
if phi.ndim and theta.ndim and (theta.shape[0] == phi.shape[1]):
dims -= 1
if r.ndim and theta.ndim and (theta.shape[0] == r.shape[2]):
dims -= 1
if theta.ndim and phi.ndim:
theta = theta.reshape(theta.shape + (1,) * (dims - theta.ndim))
z = misc.bin_altitude(r, theta, centalt, re=re, ke=ke)
dist = misc.site_distance(r, theta, z, re=re, ke=ke)
if (not strict_dims) and phi.ndim and r.ndim and (r.shape[2] == phi.shape[1]):
z = np.squeeze(z)
dist = np.squeeze(dist)
phi = np.squeeze(phi)
x = dist * np.cos(np.radians(90 - phi))
y = dist * np.sin(np.radians(90 - phi))
if z.ndim:
z = np.broadcast_to(z, x.shape)
xyz = np.stack((x, y, z), axis=-1)
if xyz.ndim == 1:
xyz.shape = (1,) * 3 + xyz.shape
elif xyz.ndim == 2:
xyz.shape = (xyz.shape[0],) + (1,) * 2 + (xyz.shape[1],)
if squeeze:
xyz = np.squeeze(xyz)
return xyz, aeqd
@spherical_to_xyz.register(Dataset)
@spherical_to_xyz.register(DataArray)
def _spherical_to_xyz_xarray(obj, **kwargs):
"""Transforms spherical coordinates (r, phi, theta) to cartesian
coordinates (x, y, z) centered at site (aeqd).
It takes the shortening of the great circle
distance with increasing elevation angle as well as the resulting
increase in height into account.
Parameters
----------
obj : :py:class:`xarray:xarray.DataArray` | :py:class:`xarray:xarray.Dataset`
Keyword Arguments
-----------------
re : float
earth's radius [m], defaults to None (calculating from given latitude)
ke : float
adjustment factor to account for the refractivity gradient that
affects radar beam propagation. In principle this is wavelength-
dependend. The default of 4/3 is a good approximation for most
weather radar wavelengths.
Returns
-------
xyz : :py:class:`xarray:xarray.DataArray`
Array of shape (..., 3). Contains cartesian coordinates.
aeqd : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Spatial Reference System (AEQD-Projection).
"""
dim0 = obj.wrl.util.dim0()
r = obj.range.expand_dims(dim={dim0: len(obj[dim0])}).assign_coords(
{dim0: obj[dim0]}
)
phi = obj.azimuth.expand_dims(dim={"range": len(obj.range)}, axis=-1).assign_coords(
range=obj.range
)
theta = obj.elevation
site = (obj.longitude.values, obj.latitude.values, obj.altitude.values)
kwargs.setdefault("squeeze", True)
out, aeqd = apply_ufunc(
spherical_to_xyz,
r,
phi,
theta,
site,
input_core_dims=[
[dim0, "range"],
[dim0, "range"],
[dim0],
[None],
],
output_core_dims=[[dim0, "range", "xyz"], []],
dask="parallelized",
kwargs=kwargs,
dask_gufunc_kwargs=dict(allow_rechunk=True),
)
out.name = "spherical_to_xyz"
return out, aeqd
[docs]@singledispatch
def spherical_to_proj(r, phi, theta, site, *, crs=None, re=None, ke=4.0 / 3.0):
"""Transforms spherical coordinates (r, phi, theta) to projected
coordinates centered at site in given projection.
It takes the shortening of the great circle
distance with increasing elevation angle as well as the resulting
increase in height into account.
Parameters
----------
r : :class:`numpy:numpy.ndarray`
Contains the radial distances.
phi : :class:`numpy:numpy.ndarray`
Contains the azimuthal angles.
theta: :class:`numpy:numpy.ndarray`
Contains the elevation angles.
site : sequence
the lon / lat coordinates of the radar location and its altitude
a.m.s.l. (in meters)
if site is of length two, altitude is assumed to be zero
Keyword Arguments
-----------------
crs : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Spatial Reference System (Projection).
Defaults to wgs84 (epsg 4326).
re : float
earth's radius [m]
ke : float
adjustment factor to account for the refractivity gradient that
affects radar beam propagation. In principle this is wavelength-
dependend. The default of 4/3 is a good approximation for most
weather radar wavelengths.
Returns
-------
coords : :class:`numpy:numpy.ndarray`
Array of shape (..., 3). Contains projected map coordinates.
Examples
--------
A few standard directions (North, South, North, East, South, West) with
different distances (amounting to roughly 1°) from a site
located at 48°N 9°E
>>> r = np.array([0., 0., 111., 111., 111., 111.,])*1000
>>> az = np.array([0., 180., 0., 90., 180., 270.,])
>>> th = np.array([0., 0., 0., 0., 0., 0.5,])
>>> csite = (9.0, 48.0, 0)
>>> coords = spherical_to_proj(r, az, th, csite)
>>> for coord in coords:
... print( '{0:7.4f}, {1:7.4f}, {2:7.4f}'.format(*coord))
...
9.0000, 48.0000, 0.0000
9.0000, 48.0000, 0.0000
9.0000, 48.9981, 725.7160
10.4872, 47.9904, 725.7160
9.0000, 47.0017, 725.7160
7.5131, 47.9904, 1694.2234
Here, the coordinates of the east and west directions won't come to lie on
the latitude of the site because the beam doesn't travel along the latitude
circle but along a great circle.
See :ref:`/notebooks/basics/wradlib_workflow.ipynb#\
Georeferencing-and-Projection`.
"""
if crs is None:
crs = projection.get_default_projection()
xyz, aeqd = spherical_to_xyz(r, phi, theta, site, re=re, ke=ke, squeeze=True)
# reproject aeqd to destination projection
coords = projection.reproject(xyz, src_crs=aeqd, trg_crs=crs)
return coords
@spherical_to_proj.register(Dataset)
@spherical_to_proj.register(DataArray)
def _spherical_to_proj_xarray(obj, **kwargs):
"""Transforms spherical coordinates (r, phi, theta) to projected
coordinates centered at site in given projection.
It takes the shortening of the great circle
distance with increasing elevation angle as well as the resulting
increase in height into account.
Parameters
----------
obj : :py:class:`xarray:xarray.DataArray` | :py:class:`xarray:xarray.Dataset`
Keyword Arguments
-----------------
crs : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Spatial Reference System (Projection).
Defaults to wgs84 (epsg 4326).
ke : float
adjustment factor to account for the refractivity gradient that
affects radar beam propagation. In principle this is wavelength-
dependend. The default of 4/3 is a good approximation for most
weather radar wavelengths.
Returns
-------
coords : :py:class:`xarray:xarray.DataArray`
Array of shape (..., 3). Contains projected map coordinates.
"""
dim0 = obj.wrl.util.dim0()
r = obj.range.expand_dims(dim={dim0: len(obj[dim0])}).assign_coords(
{dim0: obj[dim0]}
)
phi = obj.azimuth.expand_dims(dim={"range": len(obj.range)}, axis=-1).assign_coords(
range=obj.range
)
theta = obj.elevation
site = (obj.longitude.values, obj.latitude.values, obj.altitude.values)
out = apply_ufunc(
spherical_to_proj,
r,
phi,
theta,
site,
input_core_dims=[
[dim0, "range"],
[dim0, "range"],
[dim0],
[None],
],
output_core_dims=[[dim0, "range", "xyz"]],
dask="parallelized",
kwargs=kwargs,
dask_gufunc_kwargs=dict(allow_rechunk=True),
)
out.name = "spherical_to_proj"
return out
[docs]def centroid_to_polyvert(centroid, delta, /):
"""Calculates the 2-D Polygon vertices necessary to form a rectangular
polygon around the centroid's coordinates.
The vertices order will be clockwise, as this is the convention used
by ESRI's shapefile format for a polygon.
Parameters
----------
centroid : array-like
List of 2-D coordinates of the center point of the rectangle.
delta : scalar or :class:`numpy:numpy.ndarray`
Symmetric distances of the vertices from the centroid in each
direction. If ``delta`` is scalar, it is assumed to apply to
both dimensions.
Returns
-------
vertices : :class:`numpy:numpy.ndarray`
An array with 5 vertices per centroid.
Note
----
The function can currently only deal with 2-D data (If you come up with a
higher dimensional version of 'clockwise' you're welcome to add it).
The data is then assumed to be organized within the ``centroid`` array with
the last dimension being the 2-D coordinates of each point.
Examples
--------
>>> centroid_to_polyvert([0., 1.], [0.5, 1.5])
array([[-0.5, -0.5],
[-0.5, 2.5],
[ 0.5, 2.5],
[ 0.5, -0.5],
[-0.5, -0.5]])
>>> centroid_to_polyvert(np.arange(4).reshape((2,2)), 0.5)
array([[[-0.5, 0.5],
[-0.5, 1.5],
[ 0.5, 1.5],
[ 0.5, 0.5],
[-0.5, 0.5]],
<BLANKLINE>
[[ 1.5, 2.5],
[ 1.5, 3.5],
[ 2.5, 3.5],
[ 2.5, 2.5],
[ 1.5, 2.5]]])
"""
cent = np.asanyarray(centroid)
if cent.shape[-1] != 2:
raise ValueError("Parameter 'centroid' dimensions need " "to be (..., 2)")
dshape = [1] * cent.ndim
dshape.insert(-1, 5)
dshape[-1] = 2
d = np.array(
[[-1.0, -1.0], [-1.0, 1.0], [1.0, 1.0], [1.0, -1.0], [-1.0, -1.0]]
).reshape(tuple(dshape))
return np.asanyarray(centroid)[..., None, :] + d * np.asanyarray(delta)
[docs]@singledispatch
def spherical_to_polyvert(r, phi, theta, site, *, crs=None):
"""
Generate 3-D polygon vertices directly from spherical coordinates
(r, phi, theta).
This is an alternative to :func:`~wradlib.georef.polar.centroid_to_polyvert`
which does not use centroids, but generates the polygon vertices by simply
connecting the corners of the radar bins.
Both azimuth and range arrays are assumed to be equidistant and to contain
only unique values. For further information refer to the documentation of
:func:`~wradlib.georef.polar.spherical_to_xyz`.
Parameters
----------
r : :class:`numpy:numpy.ndarray`
Array of ranges [m]; r defines the exterior boundaries of the range
bins! (not the centroids). Thus, values must be positive!
phi : :class:`numpy:numpy.ndarray`
Array of azimuth angles containing values between 0° and 360°.
The angles are assumed to describe the pointing direction fo the main
beam lobe!
The first angle can start at any values, but make sure the array is
sorted continuously positively clockwise and the angles are
equidistant. An angle if 0 degree is pointing north.
theta : float
Elevation angle of scan
site : sequence
the lon/lat/alt coordinates of the radar location
crs : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Projection
Returns
-------
output : :class:`numpy:numpy.ndarray`
A 3-d array of polygon vertices with shape(num_vertices,
num_vertex_nodes, 2). The last dimension carries the xyz-coordinates
either in `aeqd` or given crs.
aeqd : :py:class:`gdal:osgeo.aeqosr.SpatialReference`
only returned if crs is None
Examples
--------
>>> import wradlib.georef as georef # noqa
>>> import numpy as np
>>> from matplotlib import collections
>>> import matplotlib.pyplot as pl
>>> #pl.interactive(True)
>>> # define the polar coordinates and the site coordinates in lat/lon
>>> r = np.array([50., 100., 150., 200.]) * 1000
>>> # _check_polar_coords fails in next line
>>> # az = np.array([0., 45., 90., 135., 180., 225., 270., 315., 360.])
>>> az = np.array([0., 45., 90., 135., 180., 225., 270., 315.])
>>> el = 1.0
>>> site = (9.0, 48.0, 0)
>>> polygons, aeqd = georef.spherical_to_polyvert(r, az, el, site)
>>> # plot the resulting mesh
>>> fig = pl.figure()
>>> ax = fig.add_subplot(111)
>>> #polycoll = mpl.collections.PolyCollection(vertices,closed=True, facecolors=None) # noqa
>>> polycoll = collections.PolyCollection(polygons[...,:2], closed=True, facecolors='None') # noqa
>>> ret = ax.add_collection(polycoll, autolim=True)
>>> pl.autoscale()
>>> pl.show()
"""
# prepare the range and azimuth array so they describe the boundaries of
# a bin, not the centroid
r, phi = _check_polar_coords(r, phi)
r = np.insert(r, 0, r[0] - _get_range_resolution(r))
phi = phi - 0.5 * _get_azimuth_resolution(phi)
phi = np.append(phi, phi[0])
phi = np.where(phi < 0, phi + 360.0, phi)
# generate a grid of polar coordinates of bin corners
r, phi = np.meshgrid(r, phi)
coords, aeqd = spherical_to_xyz(r, phi, theta, site, squeeze=True, strict_dims=True)
if crs is not None:
coords = projection.reproject(coords, src_crs=aeqd, trg_crs=crs)
llc = coords[:-1, :-1]
ulc = coords[:-1, 1:]
urc = coords[1:, 1:]
lrc = coords[1:, :-1]
vertices = np.stack((llc, ulc, urc, lrc, llc), axis=-2).reshape((-1, 5, 3))
if crs is None:
return vertices, aeqd
else:
return vertices
@spherical_to_polyvert.register(Dataset)
@spherical_to_polyvert.register(DataArray)
def _spherical_to_polyvert_xarray(obj, **kwargs):
"""
Generate 3-D polygon vertices directly from spherical coordinates
(r, phi, theta).
This is an alternative to :func:`~wradlib.georef.polar.centroid_to_polyvert`
which does not use centroids, but generates the polygon vertices by simply
connecting the corners of the radar bins.
Both azimuth and range arrays are assumed to be equidistant and to contain
only unique values. For further information refer to the documentation of
:func:`~wradlib.georef.polar.spherical_to_xyz`.
Currently only works for PPI.
Parameters
----------
obj : :py:class:`xarray:xarray.DataArray` | :py:class:`xarray:xarray.Dataset`
Keyword Arguments
-----------------
crs : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Spatial Reference System (Projection).
Defaults to wgs84 (epsg 4326).
ke : float
adjustment factor to account for the refractivity gradient that
affects radar beam propagation. In principle this is wavelength-
dependend. The default of 4/3 is a good approximation for most
weather radar wavelengths.
Returns
-------
xyz : :py:class:`xarray:xarray.DataArray`
Array of shape (..., 3). Contains cartesian coordinates.
crs : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Spatial Reference System (Projection).
Defaults to wgs84 (epsg 4326).
"""
# Todo: check if this works for elevation too
obj.wrl.util.dim0()
rdiff = obj.range.diff("range").median() / 2.0
r = obj.range + rdiff
phi = obj.azimuth
theta = obj.elevation.median("azimuth")
site = (obj.longitude.values, obj.latitude.values, obj.altitude.values)
output_core_dims = [["bins", "vert", "xy"]]
if kwargs.get("crs", None) is None:
output_core_dims.append([])
keep_attrs = kwargs.pop("keep_attrs", None)
out = apply_ufunc(
spherical_to_polyvert,
r,
phi,
theta.values,
site,
input_core_dims=[["range"], ["azimuth"], [None], [None]],
output_core_dims=output_core_dims,
dask="parallelized",
kwargs=kwargs,
keep_attrs=keep_attrs,
dask_gufunc_kwargs=dict(allow_rechunk=True),
)
if kwargs.get("crs", None) is None:
out[0].name = "spherical_to_polyvert"
else:
out.name = "spherical_to_polyvert"
return out
[docs]@singledispatch
def spherical_to_centroids(r, phi, theta, site, *, crs=None):
"""
Generate 3-D centroids of the radar bins from the sperical
coordinates (r, phi, theta).
Both azimuth and range arrays are assumed to be equidistant and to contain
only unique values. The ranges are assumed to define the exterior
boundaries of the range bins (thus they must be positive). The angles are
assumed to describe the pointing direction fo the main beam lobe.
For further information refer to the documentation of
:func:`~wradlib.georef.polar.spherical_to_xyz`.
Parameters
----------
r : :class:`numpy:numpy.ndarray`
Array of ranges [m]; r defines the exterior boundaries of the range
bins! (not the centroids). Thus, values must be positive!
phi : :class:`numpy:numpy.ndarray`
Array of azimuth angles containing values between 0° and 360°.
The angles are assumed to describe the pointing direction fo the main
beam lobe!
The first angle can start at any values, but make sure the array is
sorted continuously positively clockwise and the angles are
equidistant. An angle if 0 degree is pointing north.
theta : float
Elevation angle of scan
site : sequence
the lon/lat/alt coordinates of the radar location
crs : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Projection
Returns
-------
output : :class:`numpy:numpy.ndarray`
A 3-d array of bin centroids with shape(num_rays, num_bins, 3).
The last dimension carries the xyz-coordinates
either in `aeqd` or given crs.
aeqd : :py:class:`gdal:osgeo.osr.SpatialReference`
only returned if crs is None
Note
----
Azimuth angles of 360 deg are internally converted to 0 deg.
"""
# make sure the range and azimuth angles have the right properties
r, phi = _check_polar_coords(r, phi)
r = r - 0.5 * _get_range_resolution(r)
# generate a polar grid and convert to lat/lon
r, phi = np.meshgrid(r, phi)
coords, aeqd = spherical_to_xyz(r, phi, theta, site, squeeze=True)
if crs is None:
return coords, aeqd
else:
return projection.reproject(coords, src_crs=aeqd, trg_crs=crs)
@spherical_to_centroids.register(Dataset)
@spherical_to_polyvert.register(DataArray)
def _spherical_to_centroids_xarray(obj, **kwargs):
"""
Generate 3-D centroids of the radar bins from the sperical
coordinates (r, phi, theta).
Both azimuth and range arrays are assumed to be equidistant and to contain
only unique values. The ranges are assumed to define the exterior
boundaries of the range bins (thus they must be positive). The angles are
assumed to describe the pointing direction fo the main beam lobe.
For further information refer to the documentation of
:func:`~wradlib.georef.polar.spherical_to_xyz`.
Parameters
----------
obj : :py:class:`xarray:xarray.DataArray` | :py:class:`xarray:xarray.Dataset`
Keyword Arguments
-----------------
crs : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Spatial Reference System (Projection).
Defaults to wgs84 (epsg 4326).
ke : float
adjustment factor to account for the refractivity gradient that
affects radar beam propagation. In principle this is wavelength-
dependend. The default of 4/3 is a good approximation for most
weather radar wavelengths.
Currently only works for PPI.
Returns
-------
xyz : :py:class:`xarray:xarray.DataArray`
Array of shape (..., 3). Contains cartesian coordinates.
crs : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Spatial Reference System (Projection).
Defaults to wgs84 (epsg 4326).
Note
----
Azimuth angles of 360 deg are internally converted to 0 deg.
"""
# Todo: check if this works for elevation too
rdiff = obj.range.diff("range").median() / 2.0
r = obj.range + rdiff
phi = obj.azimuth
theta = obj.elevation.median("azimuth")
site = (obj.longitude.values, obj.latitude.values, obj.altitude.values)
output_core_dims = [["azimuth", "range", "xyz"]]
if kwargs.get("crs", None) is None:
output_core_dims.append([])
keep_attrs = kwargs.pop("keep_attrs", None)
out = apply_ufunc(
spherical_to_centroids,
r,
phi,
theta.values,
site,
input_core_dims=[["range"], ["azimuth"], [None], [None]],
output_core_dims=output_core_dims,
dask="parallelized",
kwargs=kwargs,
keep_attrs=keep_attrs,
dask_gufunc_kwargs=dict(allow_rechunk=True),
)
if kwargs.get("crs", None) is None:
out[0].name = "spherical_to_centroids"
else:
out.name = "spherical_to_centroids"
return out
def _check_polar_coords(r, az, /):
"""
Contains a lot of checks to make sure the polar coordinates are adequate.
Parameters
----------
r : :class:`numpy:numpy.ndarray`
range gates in any unit
az : :class:`numpy:numpy.ndarray`
azimuth angles in degree
"""
r = np.array(r, "f4")
az = np.array(az, "f4")
az[az == 360.0] = 0.0
if 0.0 in r:
raise ValueError(
"Invalid polar coordinates: "
"0 is not a valid range gate specification "
"(the centroid of a range gate must be positive)."
)
if len(np.unique(r)) != len(r):
raise ValueError(
"Invalid polar coordinates: "
"Range gate specification contains duplicate "
"entries."
)
if len(np.unique(az)) != len(az):
raise ValueError(
"Invalid polar coordinates: "
"Azimuth specification contains duplicate entries."
)
if not _is_sorted(r):
raise ValueError("Invalid polar coordinates: " "Range array must be sorted.")
if len(np.unique(r[1:] - r[:-1])) > 1:
raise ValueError(
"Invalid polar coordinates: " "Range gates are not equidistant."
)
if len(np.where(az >= 360.0)[0]) > 0:
raise ValueError(
"Invalid polar coordinates: "
"Azimuth angles must not be greater than "
"or equal to 360 deg."
)
if not _is_sorted(az):
# it is ok if the azimuth angle array is not sorted, but it has to be
# 'continuously clockwise', e.g. it could start at 90° and stop at °89
az_right = az[np.where(np.logical_and(az <= 360, az >= az[0]))[0]]
az_left = az[np.where(az < az[0])]
if (not _is_sorted(az_right)) or (not _is_sorted(az_left)):
raise ValueError(
"Invalid polar coordinates: " "Azimuth array is not sorted clockwise."
)
if len(np.unique(np.sort(az)[1:] - np.sort(az)[:-1])) > 1:
warnings.warn(
"The azimuth angles of the current " "dataset are not equidistant.",
UserWarning,
)
# print('Invalid polar coordinates: Azimuth angles '
# 'are not equidistant.')
# exit()
return r, az
def _is_sorted(x):
"""
Returns True when array x is sorted
"""
return np.all(x[:-1] <= x[1:])
def _get_range_resolution(x):
"""
Returns the range resolution based on
the array x of the range gates' exterior limits
"""
if len(x) <= 1:
raise ValueError(
"The range gate array has to contain at least "
"two values for deriving the resolution."
)
res = np.unique(x[1:] - x[:-1])
if len(res) > 1:
raise ValueError("The resolution of the range array is ambiguous.")
return res[0]
def _get_azimuth_resolution(x):
"""
Returns the azimuth resolution based on the array x of the beams'
azimuth angles
"""
res = np.unique(np.sort(x)[1:] - np.sort(x)[:-1])
if len(res) > 1:
raise ValueError("The resolution of the azimuth angle array is ambiguous.")
return res[0]
[docs]def sweep_centroids(nrays, rscale, nbins, elangle, /):
"""Construct sweep centroids native coordinates.
Parameters
----------
nrays : int
number of rays
rscale : float
length [m] of a range bin
nbins : int
number of range bins
elangle : float
elevation angle [deg]
Returns
-------
coordinates : :py:class:`numpy:numpy.ndarray`
array of shape (nrays,nbins,3) containing native centroid radar
coordinates (slant range, azimuth, elevation)
"""
ascale = 360.0 / nrays
azimuths = ascale / 2.0 + np.linspace(0, 360.0, nrays, endpoint=False)
ranges = np.arange(nbins) * rscale + rscale / 2.0
coordinates = np.empty((nrays, nbins, 3), dtype=float)
coordinates[:, :, 0] = np.tile(ranges, (nrays, 1))
coordinates[:, :, 1] = np.transpose(np.tile(azimuths, (nbins, 1)))
coordinates[:, :, 2] = elangle
return coordinates
[docs]def maximum_intensity_projection(
data, *, r=None, az=None, angle=None, elev=None, autoext=True
):
"""Computes the maximum intensity projection along an arbitrary cut \
through the ppi from polar data.
Parameters
----------
data : :class:`numpy:numpy.ndarray`
Array containing polar data (azimuth, range)
Keyword Arguments
-----------------
r : :class:`numpy:numpy.ndarray`
Array containing range data
az : :class:`numpy:numpy.ndarray`
Array containing azimuth data
angle : float
angle of slice, Defaults to 0. Should be between 0 and 180.
0. means horizontal slice, 90. means vertical slice
elev : float
elevation angle of scan, Defaults to 0.
autoext : bool
This routine uses numpy.digitize to bin the data.
As this function needs bounds, we create one set of coordinates more
than would usually be provided by `r` and `az`.
Returns
-------
xs : :class:`numpy:numpy.ndarray`
meshgrid x array
ys : :class:`numpy:numpy.ndarray`
meshgrid y array
mip : :class:`numpy:numpy.ndarray`
Array containing the maximum intensity projection (range, range*2)
"""
# this may seem odd at first, but d1 and d2 are also used in several
# plotting functions and thus it may be easier to compare the functions
d1 = r
d2 = az
# providing 'reasonable defaults', based on the data's shape
if d1 is None:
d1 = np.arange(data.shape[1], dtype=np.float_)
if d2 is None:
d2 = np.arange(data.shape[0], dtype=np.float_)
if angle is None:
angle = 0.0
if elev is None:
elev = 0.0
if autoext:
# the ranges need to go 'one bin further', assuming some regularity
# we extend by the distance between the preceding bins.
x = np.append(d1, d1[-1] + (d1[-1] - d1[-2]))
# the angular dimension is supposed to be cyclic, so we just add the
# first element
y = np.append(d2, d2[0])
else:
# no autoext basically is only useful, if the user supplied the correct
# dimensions himself.
x = d1
y = d2
# roll data array to specified azimuth, assuming equidistant azimuth angles
ind = (d2 >= angle).nonzero()[0][0]
data = np.roll(data, ind, axis=0)
# build cartesian range array, add delta to last element to compensate for
# open bound (np.digitize)
dc = np.linspace(-np.max(d1), np.max(d1) + 0.0001, num=d1.shape[0] * 2 + 1)
# get height values from polar data and build cartesian height array
# add delta to last element to compensate for open bound (np.digitize)
hp = np.zeros((y.shape[0], x.shape[0]))
hc = misc.bin_altitude(x, elev, 0, re=6370040.0)
hp[:] = hc
hc[-1] += 0.0001
# create meshgrid for polar data
xx, yy = np.meshgrid(x, y)
# create meshgrid for cartesian slices
xs, ys = np.meshgrid(dc, hc)
# xs, ys = np.meshgrid(dc,x)
# convert polar coordinates to cartesian
xxx = xx * np.cos(np.radians(90.0 - yy))
# yyy = xx * np.sin(np.radians(90.-yy))
# digitize coordinates according to cartesian range array
range_dig1 = np.digitize(xxx.ravel(), dc)
range_dig1.shape = xxx.shape
# digitize heights according polar height array
height_dig1 = np.digitize(hp.ravel(), hc)
# reshape accordingly
height_dig1.shape = hp.shape
# what am I doing here?!
range_dig1 = range_dig1[0:-1, 0:-1]
height_dig1 = height_dig1[0:-1, 0:-1]
# create height and range masks
height_mask = [(height_dig1 == i).ravel().nonzero()[0] for i in range(1, len(hc))]
range_mask = [(range_dig1 == i).ravel().nonzero()[0] for i in range(1, len(dc))]
# create mip output array, set outval to inf
mip = np.zeros((d1.shape[0], 2 * d1.shape[0]))
mip[:] = np.inf
# fill mip array,
# in some cases there are no values found in the specified range and height
# then we fill in nans and interpolate afterwards
for i in range(0, len(range_mask)):
mask1 = range_mask[i]
found = False
for j in range(0, len(height_mask)):
mask2 = np.intersect1d(mask1, height_mask[j])
# this is to catch the ValueError from the max() routine when
# calculating on empty array
try:
mip[j, i] = data.ravel()[mask2].max()
if not found:
found = True
except ValueError:
if found:
mip[j, i] = np.nan
# interpolate nans inside image, do not touch outvals
good = ~np.isnan(mip)
xp = good.ravel().nonzero()[0]
fp = mip[~np.isnan(mip)]
x = np.isnan(mip).ravel().nonzero()[0]
mip[np.isnan(mip)] = np.interp(x, xp, fp)
# reset outval to nan
mip[mip == np.inf] = np.nan
return xs, ys, mip
[docs]def georeference(obj, **kwargs):
"""Georeference Dataset/DataArray.
.. versionadded:: 1.5
This function adds georeference data to xarray Dataset/DataArray `obj`.
Parameters
----------
obj : :py:class:`xarray:xarray.Dataset` or :py:class:`xarray:xarray.DataArray`
Keyword Arguments
-----------------
crs : :py:class:`gdal:osgeo.osr.SpatialReference`, :py:class:`cartopy.crs.CRS` or None
If GDAL OSR SRS, output is in this projection, else AEQD.
re : float
earth's radius [m]
ke : float
adjustment factor to account for the refractivity gradient that
affects radar beam propagation. In principle this is wavelength-
dependend. The default of 4/3 is a good approximation for most
weather radar wavelengths.
Returns
----------
obj : :py:class:`xarray:xarray.Dataset` or :py:class:`xarray:xarray.DataArray`
"""
trg_crs = kwargs.pop("crs", "None")
re = kwargs.pop("re", None)
ke = kwargs.pop("ke", 4.0 / 3.0)
# adding xyz aeqd-coordinates
site = (
obj.coords["longitude"].values,
obj.coords["latitude"].values,
obj.coords["altitude"].values,
)
if site == (0.0, 0.0, 0.0):
re = 6378137.0
# create meshgrid to overcome dimension problem with spherical_to_xyz
r, az = np.meshgrid(obj["range"], obj["azimuth"])
# GDAL OSR, convert to this crs
if has_import(osr) and isinstance(trg_crs, osr.SpatialReference):
xyz = spherical_to_proj(
r, az, obj["elevation"], site, crs=trg_crs, re=re, ke=ke
)
# other crs, convert to aeqd
elif trg_crs:
xyz, trg_crs = spherical_to_xyz(
r, az, obj["elevation"], site, re=re, ke=ke, squeeze=True
)
# crs, convert to aeqd and add offset
else:
xyz, trg_crs = spherical_to_xyz(
r, az, obj["elevation"], site, re=re, ke=ke, squeeze=True
)
xyz += np.array(site).T
# calculate center point
# use first range bins
ax = tuple(range(xyz.ndim - 2))
center = np.mean(xyz[..., 0, :], axis=ax)
# calculate ground range
gr = np.sqrt((xyz[..., 0] - center[0]) ** 2 + (xyz[..., 1] - center[1]) ** 2)
# dimension handling
dim0 = obj["azimuth"].dims[-1]
if obj["elevation"].dims:
dimlist = list(obj["elevation"].dims)
else:
dimlist = list(obj["azimuth"].dims)
# xyz is an array of cartesian coordinates for every spherical coordinate,
# so the possible dimensions are: elevation, azimuth, range, 3.
# For 2d, it either has (elevation, range, 3) or (azimuth, range, 3) dimensions.
# For 3d, the only option is the full (elevation, azimuth, range, 3) dimensions.
# Thus, adding the following two lines for the 3d case should not break other functionalities,
# and there should not be a case with more than 3 dimensions
if xyz.ndim > 3:
dimlist += ["azimuth"]
dimlist += ["range"]
# add xyz, ground range coordinates
obj.coords["x"] = (dimlist, xyz[..., 0])
obj.coords["y"] = (dimlist, xyz[..., 1])
obj.coords["z"] = (dimlist, xyz[..., 2])
obj.coords["gr"] = (dimlist, gr)
# adding rays, bins coordinates
if obj.sweep_mode == "azimuth_surveillance":
bins, rays = np.meshgrid(obj["range"], obj["azimuth"], indexing="xy")
else:
bins, rays = np.meshgrid(obj["range"], obj["elevation"], indexing="xy")
obj.coords["rays"] = ([dim0, "range"], rays)
obj.coords["bins"] = ([dim0, "range"], bins)
proj_crs = CRS.from_wkt(trg_crs.ExportToWkt(["FORMAT=WKT2_2018"]))
obj = add_crs(obj, crs=proj_crs)
return obj
[docs]class GeorefPolarMethods:
"""wradlib xarray SubAccessor methods for Georef Polar Methods."""
[docs] @docstring(georeference)
def georeference(self, *args, **kwargs):
if not isinstance(self, GeorefPolarMethods):
return georeference(self, *args, **kwargs)
else:
return georeference(self._obj, *args, **kwargs)
[docs] @docstring(_spherical_to_xyz_xarray)
def spherical_to_xyz(self, *args, **kwargs):
if not isinstance(self, GeorefPolarMethods):
return spherical_to_xyz(self, *args, **kwargs)
else:
return spherical_to_xyz(self._obj, *args, **kwargs)
[docs] @docstring(_spherical_to_proj_xarray)
def spherical_to_proj(self, *args, **kwargs):
if not isinstance(self, GeorefPolarMethods):
return spherical_to_proj(self, *args, **kwargs)
else:
return spherical_to_proj(self._obj, *args, **kwargs)
[docs] @docstring(_spherical_to_polyvert_xarray)
def spherical_to_polyvert(self, *args, **kwargs):
if not isinstance(self, GeorefPolarMethods):
return spherical_to_polyvert(self, *args, **kwargs)
else:
return spherical_to_polyvert(self._obj, *args, **kwargs)
[docs] @docstring(_spherical_to_centroids_xarray)
def spherical_to_centroids(self, *args, **kwargs):
if not isinstance(self, GeorefPolarMethods):
return spherical_to_centroids(self, *args, **kwargs)
else:
return spherical_to_centroids(self._obj, *args, **kwargs)