Skip to content

Commit 8aa1073

Browse files
authored
Merge pull request #2743 from dcamron/spherical-calculations
Derivatives & map factors
2 parents 64853bb + f963218 commit 8aa1073

9 files changed

Lines changed: 2061 additions & 816 deletions

File tree

conftest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,23 @@ def array_type(request):
182182
return lambda d, u, *, mask=None: quantity(numpy.array(d), u)
183183
else:
184184
raise ValueError(f'Unsupported array_type option {request.param}')
185+
186+
187+
@pytest.fixture
188+
def geog_data(request):
189+
"""Create data to use for testing calculations on geographic coordinates."""
190+
# Generate a field of u and v on a lat/lon grid
191+
crs = pyproj.CRS(request.param)
192+
proj = pyproj.Proj(crs)
193+
a = numpy.arange(4)[None, :]
194+
arr = numpy.r_[a, a, a] * metpy.units.units('m/s')
195+
lons = numpy.array([-100, -90, -80, -70]) * metpy.units.units.degree
196+
lats = numpy.array([45, 55, 65]) * metpy.units.units.degree
197+
lon_arr, lat_arr = numpy.meshgrid(lons.m_as('degree'), lats.m_as('degree'))
198+
factors = proj.get_factors(lon_arr, lat_arr)
199+
200+
return (crs, lons, lats, arr, arr, factors.parallel_scale, factors.meridional_scale,
201+
metpy.calc.lat_lon_grid_deltas(lons.m, numpy.zeros_like(lons.m),
202+
geod=crs.get_geod())[0][0],
203+
metpy.calc.lat_lon_grid_deltas(numpy.zeros_like(lats.m), lats.m,
204+
geod=crs.get_geod())[1][:, 0])

docs/_templates/overrides/metpy.calc.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,16 @@ Mathematical Functions
149149

150150
cross_section_components
151151
first_derivative
152+
geospatial_gradient
153+
geospatial_laplacian
152154
gradient
153155
laplacian
154156
lat_lon_grid_deltas
155157
normal_component
156158
second_derivative
157159
tangential_component
158160
unit_vectors_from_cross_section
161+
vector_derivative
159162

160163

161164
Apparent Temperature

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ install_requires =
4848
pandas>=1.0.0
4949
pint>=0.15
5050
pooch>=1.2.0
51-
pyproj>=2.5.0
51+
pyproj>=2.6.1
5252
scipy>=1.4.0
5353
traitlets>=5.0.5
5454
xarray>=0.18.0

src/metpy/calc/kinematics.py

Lines changed: 504 additions & 156 deletions
Large diffs are not rendered by default.

src/metpy/calc/tools.py

Lines changed: 439 additions & 3 deletions
Large diffs are not rendered by default.

src/metpy/xarray.py

Lines changed: 78 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,11 @@ def pyproj_crs(self):
280280
"""Return the coordinate reference system (CRS) as a pyproj object."""
281281
return self.crs.to_pyproj()
282282

283+
@property
284+
def pyproj_proj(self):
285+
"""Return the Proj object corresponding to the coordinate reference system (CRS)."""
286+
return Proj(self.pyproj_crs)
287+
283288
def _fixup_coordinate_map(self, coord_map):
284289
"""Ensure sure we have coordinate variables in map, not coordinate names."""
285290
new_coord_map = {}
@@ -430,9 +435,46 @@ def coordinates(self, *args):
430435
access a single coordinate, use the appropriate attribute on the accessor, or use tuple
431436
unpacking.
432437
438+
If latitude and/or longitude are requested here, and yet are not present on the
439+
DataArray, an on-the-fly computation from the CRS and y/x dimension coordinates is
440+
attempted.
441+
433442
"""
443+
latitude = None
444+
longitude = None
434445
for arg in args:
435-
yield self._axis(arg)
446+
try:
447+
yield self._axis(arg)
448+
except AttributeError as exc:
449+
if (
450+
(arg == 'latitude' and latitude is None)
451+
or (arg == 'longitude' and longitude is None)
452+
):
453+
# Try to compute on the fly
454+
try:
455+
latitude, longitude = _build_latitude_longitude(self._data_array)
456+
except Exception:
457+
# Attempt failed, re-raise original error
458+
raise exc from None
459+
# Otherwise, warn and yield result
460+
warnings.warn(
461+
'Latitude and longitude computed on-demand, which may be an '
462+
'expensive operation. To avoid repeating this computation, assign '
463+
'these coordinates ahead of time with '
464+
'.metpy.assign_latitude_longitude().'
465+
)
466+
if arg == 'latitude':
467+
yield latitude
468+
else:
469+
yield longitude
470+
elif arg == 'latitude' and latitude is not None:
471+
# We have this from previous computation
472+
yield latitude
473+
elif arg == 'longitude' and longitude is not None:
474+
# We have this from previous computation
475+
yield longitude
476+
else:
477+
raise exc
436478

437479
@property
438480
def time(self):
@@ -465,7 +507,7 @@ def longitude(self):
465507
return self._axis('longitude')
466508

467509
def coordinates_identical(self, other):
468-
"""Return whether or not the coordinates of other match this DataArray's."""
510+
"""Return whether the coordinates of other match this DataArray's."""
469511
return (len(self._data_array.coords) == len(other.coords)
470512
and all(coord_name in other.coords and other[coord_name].identical(coord_var)
471513
for coord_name, coord_var in self._data_array.coords.items()))
@@ -476,6 +518,36 @@ def time_deltas(self):
476518
us_diffs = np.diff(self._data_array.values).astype('timedelta64[us]').astype('int64')
477519
return units.Quantity(us_diffs / 1e6, 's')
478520

521+
@property
522+
def grid_deltas(self):
523+
"""Return the horizontal dimensional grid deltas suitable for vector derivatives."""
524+
if (
525+
(hasattr(self, 'crs')
526+
and self.crs._attrs['grid_mapping_name'] == 'latitude_longitude')
527+
or (hasattr(self, 'longitude') and self.longitude.squeeze().ndim == 1
528+
and hasattr(self, 'latitude') and self.latitude.squeeze().ndim == 1)
529+
):
530+
# Calculate dx and dy on ellipsoid (on equator and 0 deg meridian, respectively)
531+
from .calc.tools import nominal_lat_lon_grid_deltas
532+
crs = getattr(self, 'pyproj_crs', CRS('+proj=latlon'))
533+
dx, dy = nominal_lat_lon_grid_deltas(
534+
self.longitude.metpy.unit_array,
535+
self.latitude.metpy.unit_array,
536+
crs.get_geod()
537+
)
538+
else:
539+
# Calculate dx and dy in projection space
540+
try:
541+
dx = np.diff(self.x.metpy.unit_array)
542+
dy = np.diff(self.y.metpy.unit_array)
543+
except AttributeError:
544+
raise AttributeError(
545+
'Grid deltas cannot be calculated since horizontal dimension coordinates '
546+
'cannot be found.'
547+
)
548+
549+
return {'dx': dx, 'dy': dy}
550+
479551
def find_axis_name(self, axis):
480552
"""Return the name of the axis corresponding to the given identifier.
481553
@@ -1115,7 +1187,7 @@ def _build_latitude_longitude(da):
11151187
"""Build latitude/longitude coordinates from DataArray's y/x coordinates."""
11161188
y, x = da.metpy.coordinates('y', 'x')
11171189
xx, yy = np.meshgrid(x.values, y.values)
1118-
lonlats = np.stack(Proj(da.metpy.pyproj_crs)(xx, yy, inverse=True, radians=False), axis=-1)
1190+
lonlats = np.stack(da.metpy.pyproj_proj(xx, yy, inverse=True, radians=False), axis=-1)
11191191
longitude = xr.DataArray(lonlats[..., 0], dims=(y.name, x.name),
11201192
coords={y.name: y, x.name: x},
11211193
attrs={'units': 'degrees_east', 'standard_name': 'longitude'})
@@ -1136,7 +1208,7 @@ def _build_y_x(da, tolerance):
11361208
'must be 2D')
11371209

11381210
# Convert to projected y/x
1139-
xxyy = np.stack(Proj(da.metpy.pyproj_crs)(
1211+
xxyy = np.stack(da.metpy.pyproj_proj(
11401212
longitude.values,
11411213
latitude.values,
11421214
inverse=False,
@@ -1305,7 +1377,8 @@ def _wrap_output_like_not_matching_units(result, match):
13051377
):
13061378
result = units.Quantity(result)
13071379
return (
1308-
xr.DataArray(result, coords=match.coords, dims=match.dims) if output_xarray
1380+
xr.DataArray(result, coords=match.coords, dims=match.dims)
1381+
if output_xarray and result is not None
13091382
else result
13101383
)
13111384

@@ -1450,98 +1523,6 @@ def dataarray_arguments(bound_args):
14501523
yield value
14511524

14521525

1453-
def add_grid_arguments_from_xarray(func):
1454-
"""Fill in optional arguments like dx/dy from DataArray arguments."""
1455-
@functools.wraps(func)
1456-
def wrapper(*args, **kwargs):
1457-
bound_args = signature(func).bind(*args, **kwargs)
1458-
bound_args.apply_defaults()
1459-
1460-
# Search for DataArray with valid latitude and longitude coordinates to find grid
1461-
# deltas and any other needed parameter
1462-
grid_prototype = None
1463-
for da in dataarray_arguments(bound_args):
1464-
if hasattr(da.metpy, 'latitude') and hasattr(da.metpy, 'longitude'):
1465-
grid_prototype = da
1466-
break
1467-
1468-
# Fill in x_dim/y_dim
1469-
if (
1470-
grid_prototype is not None
1471-
and 'x_dim' in bound_args.arguments
1472-
and 'y_dim' in bound_args.arguments
1473-
):
1474-
try:
1475-
bound_args.arguments['x_dim'] = grid_prototype.metpy.find_axis_number('x')
1476-
bound_args.arguments['y_dim'] = grid_prototype.metpy.find_axis_number('y')
1477-
except AttributeError:
1478-
# If axis number not found, fall back to default but warn.
1479-
warnings.warn('Horizontal dimension numbers not found. Defaulting to '
1480-
'(..., Y, X) order.')
1481-
1482-
# Fill in vertical_dim
1483-
if (
1484-
grid_prototype is not None
1485-
and 'vertical_dim' in bound_args.arguments
1486-
):
1487-
try:
1488-
bound_args.arguments['vertical_dim'] = (
1489-
grid_prototype.metpy.find_axis_number('vertical')
1490-
)
1491-
except AttributeError:
1492-
# If axis number not found, fall back to default but warn.
1493-
warnings.warn(
1494-
'Vertical dimension number not found. Defaulting to (..., Z, Y, X) order.'
1495-
)
1496-
1497-
# Fill in dz
1498-
if (
1499-
grid_prototype is not None
1500-
and 'dz' in bound_args.arguments
1501-
and bound_args.arguments['dz'] is None
1502-
):
1503-
try:
1504-
vertical_coord = grid_prototype.metpy.vertical
1505-
bound_args.arguments['dz'] = np.diff(vertical_coord.metpy.unit_array)
1506-
except (AttributeError, ValueError):
1507-
# Skip, since this only comes up in advection, where dz is optional (may not
1508-
# need vertical at all)
1509-
pass
1510-
1511-
# Fill in dx/dy
1512-
if (
1513-
'dx' in bound_args.arguments and bound_args.arguments['dx'] is None
1514-
and 'dy' in bound_args.arguments and bound_args.arguments['dy'] is None
1515-
):
1516-
if grid_prototype is not None:
1517-
bound_args.arguments['dx'], bound_args.arguments['dy'] = (
1518-
grid_deltas_from_dataarray(grid_prototype, kind='actual')
1519-
)
1520-
elif 'dz' in bound_args.arguments:
1521-
# Handle advection case, allowing dx/dy to be None but dz to not be None
1522-
if bound_args.arguments['dz'] is None:
1523-
raise ValueError(
1524-
'Must provide dx, dy, and/or dz arguments or input DataArray with '
1525-
'proper coordinates.'
1526-
)
1527-
else:
1528-
raise ValueError('Must provide dx/dy arguments or input DataArray with '
1529-
'latitude/longitude coordinates.')
1530-
1531-
# Fill in latitude
1532-
if 'latitude' in bound_args.arguments and bound_args.arguments['latitude'] is None:
1533-
if grid_prototype is not None:
1534-
bound_args.arguments['latitude'] = (
1535-
grid_prototype.metpy.latitude
1536-
)
1537-
else:
1538-
raise ValueError('Must provide latitude argument or input DataArray with '
1539-
'latitude/longitude coordinates.')
1540-
1541-
return func(*bound_args.args, **bound_args.kwargs)
1542-
return wrapper
1543-
1544-
15451526
def add_vertical_dim_from_xarray(func):
15461527
"""Fill in optional vertical_dim from DataArray argument."""
15471528
@functools.wraps(func)

0 commit comments

Comments
 (0)