Skip to content

Gridcell rotations #3096

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 31, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
390 changes: 390 additions & 0 deletions lib/iris/analysis/_grid_angles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,390 @@
# (C) British Crown Copyright 2010 - 2018, Met Office
#
# This file is part of Iris.
#
# Iris is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the
# Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Iris is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Iris. If not, see <http://www.gnu.org/licenses/>.
"""
Code to implement vector rotation by angles, and inferring gridcell angles
from coordinate points and bounds.

"""
from __future__ import (absolute_import, division, print_function)
from six.moves import (filter, input, map, range, zip) # noqa

import numpy as np

import iris


def _3d_xyz_from_latlon(lon, lat):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These functions ( _3d_xyz_from_latlon and '_latlon_from_xyz') seems quite general purpose functions (not just specific to the case of rotating grid cells)... could we not put them somewhere else so they can be used by other things?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pp-mo have you logged this suggestion in a labelled issue yet? Might be better to merge first and think about where things should live later.

"""
Return locations of (lon, lat) in 3D space.

Args:

* lon, lat: (arrays in degrees)

Returns:

xyz : (array, dtype=float64)
cartesian coordinates on a unit sphere. Dimension 0 maps x,y,z.

"""
lon1 = np.deg2rad(lon).astype(np.float64)
lat1 = np.deg2rad(lat).astype(np.float64)

x = np.cos(lat1) * np.cos(lon1)
y = np.cos(lat1) * np.sin(lon1)
z = np.sin(lat1)

result = np.concatenate([array[np.newaxis] for array in (x, y, z)])

return result


def _latlon_from_xyz(xyz):
"""
Return arrays of lons+lats angles from xyz locations.

Args:

* xyz: (array)
positions array, of dims (3, <others>), where index 0 maps x/y/z.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit confusing. I know that the docs in general are not finished though, so I won't go on.


Returns:

lonlat : (array)
spherical angles, of dims (2, <others>), in radians.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so many commas...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to work out how to capture outstanding review issues on things we merged in advance.
Perhaps like this ... #3114 ?

Dim 0 maps longitude, latitude.

"""
lons = np.arctan2(xyz[1], xyz[0])
axial_radii = np.sqrt(xyz[0] * xyz[0] + xyz[1] * xyz[1])
lats = np.arctan2(xyz[2], axial_radii)
return np.array([lons, lats])


def _angle(p, q, r):
"""
Return angle (in _radians_) of grid wrt local east.
Anticlockwise +ve, as usual.
{P, Q, R} are consecutive points in the same row,
eg {v(i,j),f(i,j),v(i+1,j)}, or {T(i-1,j),T(i,j),T(i+1,j)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this not be:
{v(i,j),v(i,j),v(i+1,j)}

Why are v and T used here?

Also, I assume these function docs strings are not quite finalised yet? So I will reserve comments on the wording till then

Copy link
Member Author

@pp-mo pp-mo Jul 30, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is inherited docs + it does need improving.
Let's defer that : #3109

( I think the 'v', 'f', and 'T' are referring to different types of model value sited at different gridcell locations (i.e. stagger), so 'v' is on a v-grid, while 'T' (and 'f'?) will be on the 'T' grid. )

Calculate dot product of PR with lambda_hat at Q.
This gives us cos(required angle).
Disciminate between +/- angles by comparing latitudes of P and R.
p, q, r, are all 2-element arrays [lon, lat] of angles in degrees.

"""
# old_style = True
old_style = False
if old_style:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you plan to determine whether or not old_style should be used?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, this was an alternative calculation that I used for evaluation.
I have since removed it (cf. pp-mo#45)

Though... I accept that I still don't have a full understanding of this calculation 😬 .
We should probably make more effort to explain exactly what this is doing.
Put that also down to #3109 !

mid_lons = np.deg2rad(q[0])

pr = _3d_xyz_from_latlon(r[0], r[1]) - _3d_xyz_from_latlon(p[0], p[1])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E222 multiple spaces after operator

pr_norm = np.sqrt(np.sum(pr**2, axis=0))
pr_top = pr[1] * np.cos(mid_lons) - pr[0] * np.sin(mid_lons)

index = pr_norm == 0
pr_norm[index] = 1

cosine = np.maximum(np.minimum(pr_top / pr_norm, 1), -1)
cosine[index] = 0

psi = np.arccos(cosine) * np.sign(r[1] - p[1])
psi[index] = np.nan
else:
# Calculate unit vectors.
midpt_lons, midpt_lats = q[0], q[1]
lmb_r, phi_r = (np.deg2rad(arr) for arr in (midpt_lons, midpt_lats))
phi_hatvec_x = -np.sin(phi_r) * np.cos(lmb_r)
phi_hatvec_y = -np.sin(phi_r) * np.sin(lmb_r)
phi_hatvec_z = np.cos(phi_r)
shape_xyz = (1,) + midpt_lons.shape
phi_hatvec = np.concatenate([arr.reshape(shape_xyz)
for arr in (phi_hatvec_x,
phi_hatvec_y,
phi_hatvec_z)])
lmb_hatvec_z = np.zeros(midpt_lons.shape)
lmb_hatvec_y = np.cos(lmb_r)
lmb_hatvec_x = -np.sin(lmb_r)
lmb_hatvec = np.concatenate([arr.reshape(shape_xyz)
for arr in (lmb_hatvec_x,
lmb_hatvec_y,
lmb_hatvec_z)])

pr = _3d_xyz_from_latlon(r[0], r[1]) - _3d_xyz_from_latlon(p[0], p[1])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E222 multiple spaces after operator


# Dot products to form true-northward / true-eastward projections.
pr_cmpt_e = np.sum(pr * lmb_hatvec, axis=0)
pr_cmpt_n = np.sum(pr * phi_hatvec, axis=0)
psi = np.arctan2(pr_cmpt_n, pr_cmpt_e)

# TEMPORARY CHECKS:
# ensure that the two unit vectors are perpendicular.
dotprod = np.sum(phi_hatvec * lmb_hatvec, axis=0)
assert np.allclose(dotprod, 0.0)
# ensure that the vector components carry the original magnitude.
mag_orig = np.sum(pr * pr)
mag_rot = np.sum(pr_cmpt_e * pr_cmpt_e) + np.sum(pr_cmpt_n * pr_cmpt_n)
rtol = 1.e-3
check = np.allclose(mag_rot, mag_orig, rtol=rtol)
if not check:
print (mag_rot, mag_orig)
assert np.allclose(mag_rot, mag_orig, rtol=rtol)

return psi


def gridcell_angles(x, y=None, cell_angle_boundpoints='mid-lhs, mid-rhs'):
"""
Calculate gridcell orientations for an arbitrary 2-dimensional grid.

The input grid is defined by two 2-dimensional coordinate arrays with the
same dimensions (ny, nx), specifying the geolocations of a 2D mesh.

Input values may be coordinate points (ny, nx) or bounds (ny, nx, 4).
However, if points, the edges in the X direction are assumed to be
connected by wraparound.

Input can be either two arrays, two coordinates, or a single cube
containing two suitable coordinates identified with the 'x' and'y' axes.

Args:

The inputs (x [,y]) can be any of the folliwing :

* x (:class:`~iris.cube.Cube`):
a grid cube with 2D X and Y coordinates, identified by 'axis'.
The coordinates must be 2-dimensional with the same shape.
The two dimensions represent grid dimensions in the order Y, then X.

* x, y (:class:`~iris.coords.Coord`):
X and Y coordinates, specifying grid locations on the globe.
The coordinates must be 2-dimensional with the same shape.
The two dimensions represent grid dimensions in the order Y, then X.
If there is no coordinate system, they are assumed to be true
longitudes and latitudes. Units must convertible to 'degrees'.

* x, y (2-dimensional arrays of same shape (ny, nx)):
longitude and latitude cell center locations, in degrees.
The two dimensions represent grid dimensions in the order Y, then X.

* x, y (3-dimensional arrays of same shape (ny, nx, 4)):
longitude and latitude cell bounds, in degrees.
The first two dimensions are grid dimensions in the order Y, then X.
The last index maps cell corners anticlockwise from bottom-left.

Optional Args:

* cell_angle_boundpoints (string):
Controls which gridcell bounds locations are used to calculate angles,
if the inputs are bounds or bounded coordinates.
Valid values are 'lower-left, lower-right', which takes the angle from
the lower left to the lower right corner, and 'mid-lhs, mid-rhs' which
takes an angles between the average of the left-hand and right-hand
pairs of corners. The default is 'mid-lhs, mid-rhs'.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any chance it would ever need to be upper-left, upper right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original code I was working from (ex-IDL) used only point values.
I extended it to use bounds because that is much more logical, and also works properly for regional data.
What I found was that, while 'mid-lhs..mid-rhs' was a more obvious choice, 'bottom-left..bottom-right' was closer to the 'official' cell angles in the model output file (when included).
So I hedged my bets by making it switchable, but only supported 2 options = 'seems most logical' and 'best match to official values'.

We can perhaps standardise on just the 'mid-lhs..mid-rhs' option, if I could convince myself that the differences are not significant. I don't really want to make 'bottom-left..bottom-right' the default, as it seems the poorer choice.

Copy link
Member Author

@pp-mo pp-mo Jul 30, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

standardise on just the 'mid-lhs..mid-rhs' option, if I could convince myself that the differences are not significant

But unfortunately I checked, and the discrepancies are significant in some areas. At least a few degrees for practical purposes.
Also, the errors for quasi-ideal square-ish gridcells rotated at various angles seem to be ~linear in the size of the cells for the 'bottom-left..bottom-right' method while ~square for the 'mid-lhs..mid-rhs'.
So that means I'm not comfortable 'standardising' on either of these exclusively 😞


Returns:

angles : (2-dimensional cube)

Cube of angles of grid-x vector from true Eastward direction for
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about, if the input was a cube, the grid cell angles are an aux coord on the original cube? Or is that too confusing?

It just seems wasteful to have a copy of the lat and lon coord somewhere else. I guess this is an example of where shared coordinates would be useful

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can have gridcell angles in the file, but as they are not a CF concept they never appear as an aux-coord, but instead just as a separate cube.

Talking of inefficient, of course the "angles" cube has another copy of the lats + lons with all their bounds!
We really do need to fix this ...

each gridcell, in radians.
It also has longitude and latitude coordinates. If coordinates
were input the output has identical ones : If the input was 2d
arrays, the output coords have no bounds; or, if the input was 3d
arrays, the output coords have bounds and centrepoints which are
the average of the 4 bounds.

"""
cube = None
if hasattr(x, 'add_aux_coord'):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren't you going isinstance?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because duck typing is "Better" ™️ !

# Passed a cube : extract 'x' and ;'y' axis coordinates.
cube = x # Save for later checking.
x, y = cube.coord(axis='x'), cube.coord(axis='y')

# Now should have either 2 coords or 2 arrays.
if not hasattr(x, 'shape') and hasattr(y, 'shape'):
msg = ('Inputs (x,y) must have array shape property.'
'Got type(x)={} and type(y)={}.')
raise ValueError(msg.format(type(x), type(y)))

x_coord, y_coord = None, None
if hasattr(x, 'bounds') and hasattr(y, 'bounds'):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As before, why not check for isinstance to check it's a cube?

x_coord, y_coord = x.copy(), y.copy()
x_coord.convert_units('degrees')
y_coord.convert_units('degrees')
if x_coord.ndim != 2 or y_coord.ndim != 2:
msg = ('Coordinate inputs must have 2-dimensional shape. ',
'Got x-shape of {} and y-shape of {}.')
raise ValueError(msg.format(x_coord.shape, y_coord.shape))
if x_coord.shape != y_coord.shape:
msg = ('Coordinate inputs must have same shape. ',
'Got x-shape of {} and y-shape of {}.')
raise ValueError(msg.format(x_coord.shape, y_coord.shape))
# NOTE: would like to check that dims are in correct order, but can't do that
# if there is no cube.
# TODO: **document** -- another input format requirement
# x_dims, y_dims = (cube.coord_dims(co) for co in (x_coord, y_coord))
# if x_dims != (0, 1) or y_dims != (0, 1):
# msg = ('Coordinate inputs must map to cube dimensions (0, 1). ',
# 'Got x-dims of {} and y-dims of {}.')
# raise ValueError(msg.format(x_dims, y_dims))
if x_coord.has_bounds() and y_coord.has_bounds():
x, y = x_coord.bounds, y_coord.bounds
else:
x, y = x_coord.points, y_coord.points

elif hasattr(x, 'bounds') or hasattr(y, 'bounds'):
# One was a Coord, and the other not ?
is_and_not = ('x', 'y')
if hasattr(y, 'bounds'):
is_and_not = reversed(is_and_not)
msg = 'Input {!r} is a Coordinate, but {!r} is not.'
raise ValueError(*is_and_not)

# Now have either 2 points arrays or 2 bounds arrays.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be more explicit, i.e.
# Now we should have either 2 points arrays of shape (nx, ny) or 2 bounds arrays of shape (nx, ny, 4)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok!

# Construct (lhs, mid, rhs) where these represent 3 adjacent points with
# increasing longitudes.
if x.ndim == 2:
# PROBLEM: we can't use this if data is not full-longitudes,
# i.e. rhs of array must connect to lhs (aka 'circular' coordinate).
# But we have no means of checking that ?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️
This is concerning

Copy link
Member Author

@pp-mo pp-mo Jul 27, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but that's how it is.
I don't see any practical alternative.
Instead, I have stated this behaviour in the docstring.

The only extra thing I can imagine to do here would be to insist on 'circular' if we are passed an x-coord, but I'm very down on that : As a rare example of non-CF metadata, I think 'circular' it is a bit of a mistake, and I don't think Iris uses the concept at all consistently -- I guess it is probably is not set in many cases where it would be appropriate.


# Use previous + subsequent points along longitude-axis as references.
# NOTE: we also have no way to check that dim #2 really is the 'X' dim.
mid = np.array([x, y])
lhs = np.roll(mid, 1, 2)
rhs = np.roll(mid, -1, 2)
if not x_coord:
# Create coords for result cube : with no bounds.
y_coord = iris.coords.AuxCoord(x, standard_name='latitude',
units='degrees')
x_coord = iris.coords.AuxCoord(y, standard_name='longitude',
units='degrees')
else:
# Get lhs and rhs locations by averaging top+bottom each side.
# NOTE: so with bounds, we *don't* need full circular longitudes.
xyz = _3d_xyz_from_latlon(x, y)
angle_boundpoints_vals = {'mid-lhs, mid-rhs': '03_to_12',
'lower-left, lower-right': '0_to_1'}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda confusing names 03_to_12 and 0_to_1. I initially thought it may have something to do with a clock.
I understand them now, but I will try to think up something informative

bounds_pos = angle_boundpoints_vals.get(cell_angle_boundpoints)
if bounds_pos == '0_to_1':
lhs_xyz = xyz[..., 0]
rhs_xyz = xyz[..., 1]
elif bounds_pos == '03_to_12':
lhs_xyz = 0.5 * (xyz[..., 0] + xyz[..., 3])
rhs_xyz = 0.5 * (xyz[..., 1] + xyz[..., 2])
else:
msg = ('unrecognised cell_angle_boundpoints of "{}", '
'must be one of {}')
raise ValueError(msg.format(cell_angle_boundpoints,
list(angle_boundpoints_vals.keys())))
if not x_coord:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe better to do
if not x_coord and y_coord
I'm not sure if you catch the example someone supplying giving gridcell_angles a coord and an array as arguments. So maybe it would, instead, be better to check that earlier?

Copy link
Member Author

@pp-mo pp-mo Jul 27, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code above checked for "one coord + one non-coord", and will error that, here.

# Create bounded coords for result cube.
# Use average lhs+rhs points in 3d to get 'mid' points, as coords
# with no points are not allowed.
mid_xyz = 0.5 * (lhs_xyz + rhs_xyz)
mid_latlons = _latlon_from_xyz(mid_xyz)
# Create coords with given bounds, and averaged centrepoints.
x_coord = iris.coords.AuxCoord(
points=mid_latlons[0], bounds=x,
standard_name='longitude', units='degrees')
y_coord = iris.coords.AuxCoord(
points=mid_latlons[1], bounds=y,
standard_name='latitude', units='degrees')
# Convert lhs and rhs points back to latlon form -- IN DEGREES !
lhs = np.rad2deg(_latlon_from_xyz(lhs_xyz))
rhs = np.rad2deg(_latlon_from_xyz(rhs_xyz))
# mid is coord.points, whether input or made up.
mid = np.array([x_coord.points, y_coord.points])

# Do the angle calcs, and return as a suitable cube.
angles = _angle(lhs, mid, rhs)
result = iris.cube.Cube(angles,
long_name='gridcell_angle_from_true_east',
units='radians')
result.add_aux_coord(x_coord, (0, 1))
result.add_aux_coord(y_coord, (0, 1))
return result


def true_vectors_from_grid_vectors(u_cube, v_cube,
grid_angles_cube=None,
grid_angles_kwargs=None):
"""
Rotate distance vectors from grid-oriented to true-latlon-oriented.

.. Note::

This operation overlaps somewhat in function with
:func:`iris.analysis.cartography.rotate_winds`.
However, that routine only rotates vectors according to transformations
between coordinate systems.
This function, by contrast, can rotate vectors by arbitrary angles.
Most commonly, the angles are estimated solely from grid sampling
points, using :func:`gridcell_angles` : This allows operation on
complex meshes defined by two-dimensional coordinates, such as most
ocean grids.

Args:

* u_cube, v_cube : (cube)
Cubes of grid-u and grid-v vector components.
Units should be differentials of true-distance, e.g. 'm/s'.

Optional args:

* grid_angles_cube : (cube)
gridcell orientation angles.
Units must be angular, i.e. can be converted to 'radians'.
If not provided, grid angles are estimated from 'u_cube' using the
:func:`gridcell_angles` method.

* grid_angles_kwargs : (dict or None)
Additional keyword args to be passed to the :func:`gridcell_angles`
method, if it is used.

Returns:

true_u, true_v : (cube)
Cubes of true-north oriented vector components.
Units are same as inputs.

.. Note::

Vector magnitudes will always be the same as the inputs.

"""
u_out, v_out = (cube.copy() for cube in (u_cube, v_cube))
if not grid_angles_cube:
grid_angles_kwargs = grid_angles_kwargs or {}
grid_angles_cube = gridcell_angles(u_cube, **grid_angles_kwargs)
gridangles = grid_angles_cube.copy()
gridangles.convert_units('radians')
uu, vv, aa = (cube.data for cube in (u_out, v_out, gridangles))
mags = np.sqrt(uu*uu + vv*vv)
angs = np.arctan2(vv, uu) + aa
uu, vv = mags * np.cos(angs), mags * np.sin(angs)

# Promote all to masked arrays, and also apply mask at bad (NaN) angles.
mask = np.isnan(aa)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can aa i.e. the grid cell angles also be masked, or just nans?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hadn't thought of that.
I think it doesn't much matter, as the results would just be NaN at those points.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... however, now I'm wondering if we should return masked arrays in every case.
Iris is usually more intelligent than that.

for cube in (u_out, v_out, aa):
if hasattr(cube.data, 'mask'):
mask |= cube.data.mask
u_out.data = np.ma.masked_array(uu, mask=mask)
v_out.data = np.ma.masked_array(vv, mask=mask)

return u_out, v_out
Loading