#!/usr/bin/env python
# -*- coding: UTF-8 -*-
# Copyright (c) 2011-2020, wradlib developers.
# Distributed under the MIT License. See LICENSE.txt for more info.
"""
Polar Grid Functions
^^^^^^^^^^^^^^^^^^^^
.. autosummary::
:nosignatures:
:toctree: generated/
{}
"""
__all__ = [
"spherical_to_xyz",
"spherical_to_proj",
"spherical_to_polyvert",
"spherical_to_centroids",
"centroid_to_polyvert",
"sweep_centroids",
"maximum_intensity_projection",
]
__doc__ = __doc__.format("\n ".join(__all__))
__doctest_requires__ = {"spherical*": ["osgeo"]}
import warnings
import numpy as np
from wradlib.georef import misc, projection
from wradlib.util import has_import, import_optional
[docs]def spherical_to_xyz(
r, phi, theta, sitecoords, 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 sitecoords (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.
sitecoords : sequence
the lon / lat coordinates of the radar location and its altitude
a.m.s.l. (in meters)
if sitecoords is of length two, altitude is assumed to be zero
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-
dependent. 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.
rad : :py:class:`gdal:osgeo.osr.SpatialReference`
Destination Spatial Reference System (Projection).
Defaults to wgs84 (epsg 4326).
"""
# if site altitude is present, use it, else assume it to be zero
try:
centalt = sitecoords[2]
except IndexError:
centalt = 0.0
# 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(sitecoords[1])
# Set up aeqd-projection sitecoord-centered, wgs84 datum and ellipsoid
# use world azimuthal equidistant projection
projstr = (
f"+proj=aeqd +lon_0={sitecoords[0]:f} +x_0=0 +y_0=0 "
f"+lat_0={sitecoords[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={sitecoords[0]:f} +lat_0={sitecoords[1]:f} "
f"+a={re:f} +b={re:f} +units=m +no_defs"
)
osr = import_optional("osgeo.osr")
if has_import(osr):
rad = projection.proj4_to_osr(projstr)
else:
rad = 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, ke=ke)
dist = misc.site_distance(r, theta, z, 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, rad
[docs]def spherical_to_proj(r, phi, theta, sitecoords, proj=None, re=None, ke=4.0 / 3.0):
"""Transforms spherical coordinates (r, phi, theta) to projected
coordinates centered at sitecoords 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.
sitecoords : sequence
the lon / lat coordinates of the radar location and its altitude
a.m.s.l. (in meters)
if sitecoords is of length two, altitude is assumed to be zero
proj : :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-
dependent. 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)
>>> 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 proj is None:
proj = projection.get_default_projection()
xyz, rad = spherical_to_xyz(r, phi, theta, sitecoords, re=re, ke=ke, squeeze=True)
# reproject aeqd to destination projection
coords = projection.reproject(xyz, projection_source=rad, projection_target=proj)
return coords
[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]def spherical_to_polyvert(r, phi, theta, sitecoords, proj=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
sitecoords : sequence
the lon/lat/alt coordinates of the radar location
proj : :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 proj.
proj : :py:class:`gdal:osgeo.osr.SpatialReference`
only returned if proj 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
>>> sitecoords = (9.0, 48.0, 0)
>>> polygons, proj = georef.spherical_to_polyvert(r, az, el, sitecoords)
>>> # 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, rad = spherical_to_xyz(
r, phi, theta, sitecoords, squeeze=True, strict_dims=True
)
if proj is not None:
coords = projection.reproject(
coords, projection_source=rad, projection_target=proj
)
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 proj is None:
return vertices, rad
else:
return vertices
[docs]def spherical_to_centroids(r, phi, theta, sitecoords, proj=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
sitecoords : sequence
the lon/lat/alt coordinates of the radar location
proj : :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 proj.
proj : :py:class:`gdal:osgeo.osr.SpatialReference`
only returned if proj 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, rad = spherical_to_xyz(r, phi, theta, sitecoords, squeeze=True)
if proj is None:
return coords, rad
else:
return projection.reproject(
coords, projection_source=rad, projection_target=proj
)
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 == np.sort(x))
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)
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