Skip to content
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

WIP: Add from_proj4 function to create CRS from PROJ.4 definitions #1023

Closed
wants to merge 9 commits into from
50 changes: 4 additions & 46 deletions lib/cartopy/_epsg.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,44 +21,22 @@

from __future__ import (absolute_import, division, print_function)

from cartopy._proj4 import _PROJ4Projection
import numpy as np
import shapely.geometry as sgeom

import cartopy.crs as ccrs


_GLOBE_PARAMS = {'datum': 'datum',
'ellps': 'ellipse',
'a': 'semimajor_axis',
'b': 'semiminor_axis',
'f': 'flattening',
'rf': 'inverse_flattening',
'towgs84': 'towgs84',
'nadgrids': 'nadgrids'}


class _EPSGProjection(ccrs.Projection):
class _EPSGProjection(_PROJ4Projection):
Copy link
Member

Choose a reason for hiding this comment

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

Seems highly likely that we will remove _EPSGProjection and replace it with a function that returns an appropriate proj4 string > a real cartopy.crc.CRS.

Copy link
Member

Choose a reason for hiding this comment

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

To be clear: nothing for you to do here, just sharing my thoughts.

def __init__(self, code):
import pyepsg
projection = pyepsg.get(code)
if not isinstance(projection, pyepsg.ProjectedCRS):
raise ValueError('EPSG code does not define a projection')

self.epsg_code = code

proj4_str = projection.as_proj4().strip()
terms = [term.strip('+').split('=') for term in proj4_str.split(' ')]
globe_terms = filter(lambda term: term[0] in _GLOBE_PARAMS, terms)
globe = ccrs.Globe(**{_GLOBE_PARAMS[name]: value for name, value in
globe_terms})
other_terms = []
for term in terms:
if term[0] not in _GLOBE_PARAMS:
if len(term) == 1:
other_terms.append([term[0], None])
else:
other_terms.append(term)
super(_EPSGProjection, self).__init__(other_terms, globe)
proj4_str = str(projection.as_proj4().strip())
super(_EPSGProjection, self).__init__(proj4_str)

# Convert lat/lon bounds to projected bounds.
# GML defines gmd:EX_GeographicBoundingBox as:
Expand All @@ -78,23 +56,3 @@ def __init__(self, code):
def __repr__(self):
return '_EPSGProjection({})'.format(self.epsg_code)

@property
def boundary(self):
x0, x1, y0, y1 = self.bounds
return sgeom.LineString([(x0, y0), (x0, y1), (x1, y1), (x1, y0),
(x0, y0)])

@property
def x_limits(self):
x0, x1, y0, y1 = self.bounds
return (x0, x1)

@property
def y_limits(self):
x0, x1, y0, y1 = self.bounds
return (y0, y1)

@property
def threshold(self):
x0, x1, y0, y1 = self.bounds
return min(x1 - x0, y1 - y0) / 100.
156 changes: 156 additions & 0 deletions lib/cartopy/_proj4.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# (C) British Crown Copyright 2014 - 2018, Met Office
#
# This file is part of cartopy.
#
# cartopy 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.
#
# cartopy 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 cartopy. If not, see <https://www.gnu.org/licenses/>.
"""
Provide support for converting PROJ.4 strings to Projection instances.

"""

from __future__ import (absolute_import, division, print_function)

import cartopy.crs as ccrs
import shapely.geometry as sgeom

_GLOBE_PARAMS = {'datum': 'datum',
'ellps': 'ellipse',
'a': 'semimajor_axis',
'b': 'semiminor_axis',
'f': 'flattening',
'rf': 'inverse_flattening',
'towgs84': 'towgs84',
'nadgrids': 'nadgrids'}
# Map PROJ.4 'proj' parameter to CRS class
PROJ_TO_CRS = {}


def get_proj4_dict(proj4_terms):
"""Convert a PROJ.4 string to a dictionary.

Parameters
----------
proj4_terms: (str, dict, or iterable of key-value pairs)

Returns
-------
get_proj4_dict
All PROJ.4 parameters in a dictionary. Any keys with no value
are set to `None`.

"""
if isinstance(proj4_terms, dict):
return proj4_terms
elif isinstance(proj4_terms, str):
terms = []
for term in proj4_terms.split(' '):
parts = term.strip('+').split('=')
if len(parts) == 1:
terms.append((parts[0], None))
else:
terms.append(tuple(parts[:2]))
else:
# assume list of key value pairs
terms = proj4_terms

return dict(terms)


def _split_globe_parameters(proj4_dict):
projection_terms = {}
globe_terms = {}
for name, value in proj4_dict.items():
if name in _GLOBE_PARAMS:
globe_terms[name] = value
else:
projection_terms[name] = value
return projection_terms, globe_terms


def _globe_from_proj4(globe_terms):
"""Create a `Globe` object from PROJ.4 parameters."""
globe = ccrs.Globe(**{_GLOBE_PARAMS[name]: value for name, value in
Copy link
Member

Choose a reason for hiding this comment

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

Style choice... I'd probably get rid of the filter:

globe = ccrs.Globe(**{_GLOBE_PARAMS[name]: value for name, value in proj4_terms.items()
                      if name in _GLOBE_PARAMS})

Copy link
Author

Choose a reason for hiding this comment

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

This was copied directly from the EPSG class ;)

globe_terms.items()})
return globe


class _PROJ4Projection(ccrs.Projection):
Copy link
Member

Choose a reason for hiding this comment

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

I think I'm 👎 on this class existing altogether. (much as I am the EPSG class that already does).

Copy link
Author

Choose a reason for hiding this comment

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

Well right now the EPSG class is based on this class. So either I revert the EPSG class and remove this or I leave this in. I was thinking this could be made public even. It doesn't need to be documented even, but if you want the functionality removed then ok.

Copy link
Author

Choose a reason for hiding this comment

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

FYI I've added this class to pyresample to convert pyresamples AreaDefinitions to CRS objects: pytroll/pyresample#102

def __init__(self, proj4_terms, globe=None, bounds=None):
terms = get_proj4_dict(proj4_terms)
projection_terms, globe_terms = _split_globe_parameters(terms)
if globe is None:
Copy link
Member

Choose a reason for hiding this comment

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

I think we need to raise an exception here is globe is not None and globe_terms is not empty.

Copy link
Author

Choose a reason for hiding this comment

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

I was following the base CRS class which uses Globe() as its default. I can add an exception, but with this default in mind, does that change your opinion?

globe = _globe_from_proj4(globe_terms)

super(_PROJ4Projection, self).__init__(projection_terms, globe)

# FIXME: Can we guess at the bounds if not provided? Maybe transform
Copy link
Member

Choose a reason for hiding this comment

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

Yep, we can do some automatic bounds calculation IFF the target projection doesn't define them itself.
I wouldn't want some projections to be allowed to set its own bounds though (e.g. PlateCarree).

Copy link
Author

Choose a reason for hiding this comment

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

Isn't this how EPSG works? Isn't this also what a lot of the CRS classes already do? They limit or calculate what the bounds are inside the CRS class?

# an array of points and take the min/max of the result?
# It looks like that's what LambertConformal does.
self.bounds = bounds

def __repr__(self):
return '_PROJ4Projection({})'.format(self.proj4_init)

@property
def boundary(self):
x0, x1, y0, y1 = self.bounds
return sgeom.LineString([(x0, y0), (x0, y1), (x1, y1), (x1, y0),
(x0, y0)])

@property
def x_limits(self):
x0, x1, y0, y1 = self.bounds
return (x0, x1)

@property
def y_limits(self):
x0, x1, y0, y1 = self.bounds
return (y0, y1)

@property
def threshold(self):
x0, x1, y0, y1 = self.bounds
return min(abs(x1 - x0), abs(y1 - y0)) / 100.


def _all_subclasses(cls):
return cls.__subclasses__() + [g for s in cls.__subclasses__()
for g in _all_subclasses(s)]


def from_proj4(proj4_terms):
proj4_dict = get_proj4_dict(proj4_terms)

if not PROJ_TO_CRS:
Copy link
Member

Choose a reason for hiding this comment

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

PROJ_TO_CRS definitely needs a docstring - it wasn't clear to me that it was a mapping of the proj4 proj term to coordinate reference system.

Copy link
Member

Choose a reason for hiding this comment

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

With regards to the case of projection specialisation, do you have an idea in mind for handling the case where multiple CRS classes implement the same projection. This is true of cases such as NorthPolarStereographic (https://github.com/SciTools/cartopy/blob/master/lib/cartopy/crs.py#L1376).

There is potentially a case to be made that such "projections" shouldn't exist at all - they are perhaps better modelled as instances of their base projections...

Copy link
Author

Choose a reason for hiding this comment

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

I guess I'll find out when I get there. I already have it implemented for Stereographic where the North and South cases are handled inside the class method.

# initialize this here instead of at import
for crs_class in _all_subclasses(ccrs.CRS):
cls_proj = getattr(crs_class, '_proj4_proj', None)
if cls_proj is not None and cls_proj not in PROJ_TO_CRS:
PROJ_TO_CRS[cls_proj] = crs_class

if 'proj' not in proj4_dict:
raise ValueError("Missing PROJ.4 parameter: proj")

proj = proj4_dict['proj']
Copy link
Member

Choose a reason for hiding this comment

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

May want to be slightly more defensive here - what if proj isn't in the dict.

Copy link
Author

Choose a reason for hiding this comment

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

Then you get a KeyError because your PROJ.4 string doesn't make sense...but ok.

crs_class = PROJ_TO_CRS.get(proj)

# couldn't find a known CRS class
if crs_class is None:
# we don't want to allow non-CRS/generic Projection classes
raise ValueError("Projection '{}' is not implemented yet.".format(
proj))

Choose a reason for hiding this comment

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

W391 blank line at end of file

projection_dict, globe_dict = _split_globe_parameters(proj4_dict)
globe = _globe_from_proj4(globe_dict)
return crs_class.from_proj4(projection_dict, globe=globe)
74 changes: 74 additions & 0 deletions lib/cartopy/crs.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ class Projection(six.with_metaclass(ABCMeta, CRS)):
'MultiPolygon': '_project_multipolygon',
}

@classmethod
def from_proj4(cls, proj4_dict, **kwargs):
raise NotImplementedError("'{}' can not be created from a "
"PROJ.4 description.".format(cls.__name__))

@abstractproperty
def boundary(self):
pass
Expand Down Expand Up @@ -1039,6 +1044,8 @@ class LambertConformal(Projection):

"""

_proj4_proj = 'lcc'

def __init__(self, central_longitude=-96.0, central_latitude=39.0,
false_easting=0.0, false_northing=0.0,
secant_latitudes=None, standard_parallels=None,
Expand Down Expand Up @@ -1134,6 +1141,36 @@ def __init__(self, central_longitude=-96.0, central_latitude=39.0,
self._x_limits = bounds[0], bounds[2]
self._y_limits = bounds[1], bounds[3]

@classmethod
def from_proj4(cls, proj4_dict, **kwargs):
Copy link
Member

Choose a reason for hiding this comment

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

I'd want to assert that the proj parameter is set correctly (in this case, lcc).

assert proj4_dict.get('proj') == 'lcc'
p_kwargs = {}

if 'no_defs' in proj4_dict:
lat_1 = proj4_dict.get('lat_1')
lat_2 = proj4_dict.get('lat_2')
else:
lat_1 = proj4_dict.get('lat_1', 33.)
lat_2 = proj4_dict.get('lat_2', 45.)
if lat_1 is not None and lat_2 is not None:
p_kwargs['standard_parallels'] = (float(lat_1), float(lat_2))
elif lat_1 is not None:
p_kwargs['standard_parallels'] = (float(lat_1),)
elif lat_2 is not None:
raise ValueError("'lat_2' specified without 'lat_1'")

if 'lon_0' in proj4_dict:
p_kwargs['central_longitude'] = float(proj4_dict['lon_0'])
if 'lat_0' in proj4_dict:
p_kwargs['central_latitude'] = float(proj4_dict['lat_0'])
if 'x_0' in proj4_dict:
p_kwargs['false_easting'] = float(proj4_dict['x_0'])
if 'y_0' in proj4_dict:
p_kwargs['false_northing'] = float(proj4_dict['y_0'])

kwargs.update(p_kwargs)
return cls(**kwargs)

def __eq__(self, other):
res = super(LambertConformal, self).__eq__(other)
if hasattr(other, "cutoff"):
Expand Down Expand Up @@ -1309,6 +1346,9 @@ def y_limits(self):


class Stereographic(Projection):

_proj4_proj = 'stere'

def __init__(self, central_latitude=0.0, central_longitude=0.0,
false_easting=0.0, false_northing=0.0,
true_scale_latitude=None,
Expand Down Expand Up @@ -1356,6 +1396,35 @@ def __init__(self, central_latitude=0.0, central_longitude=0.0,
self._boundary = sgeom.LinearRing(coords.T)
self._threshold = np.diff(self._x_limits)[0] * 1e-3

@classmethod
def from_proj4(cls, proj4_dict, **kwargs):
assert proj4_dict.get('proj') == 'stere'
p_kwargs = {}

if 'lon_0' in proj4_dict:
p_kwargs['central_longitude'] = float(proj4_dict['lon_0'])
if 'lat_ts' in proj4_dict:
p_kwargs['true_scale_latitude'] = float(proj4_dict['lat_ts'])
if 'k_0' in proj4_dict:
p_kwargs['scale_factor'] = float(proj4_dict['k_0'])
if 'x_0' in proj4_dict:
p_kwargs['false_easting'] = float(proj4_dict['x_0'])
if 'y_0' in proj4_dict:
p_kwargs['false_northing'] = float(proj4_dict['y_0'])
Copy link
Member

Choose a reason for hiding this comment

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

There is repeated handling of things like false_northing happening. Can we factorise this?

Copy link
Author

Choose a reason for hiding this comment

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

I thought about that, but for this initial commit I didn't do it because it felt like I would be trying to hard to reduce duplicate code and I wasn't sure if all CRS classes supported the false easting and northing.

Copy link
Member

Choose a reason for hiding this comment

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

Not all projections do support false_easting / false_northing, but those that do all map to y_0. In some respects, if the proj4_terms includes this, then whether or not the projection supports it I'd be tempted to try passing the keyword on to the __init__ anyway. At least then the user gets a message about false_northing not being a valid keyword.

What would be missing from this approach is the user knowing that y_0 was the term that mapped to false_northing. Perhaps we could make use of inspect to determine ahead-of-time what the __init__ supports?

if 'lat_0' in proj4_dict:
# forced by North and South specific classes
lat_0 = float(proj4_dict['lat_0'])
if lat_0 == -90.:
cls = SouthPolarStereo
elif lat_0 == 90.:
cls = NorthPolarStereo
else:
p_kwargs['central_latitude'] = lat_0

kwargs.update(p_kwargs)
return cls(**kwargs)


@property
def boundary(self):
return self._boundary
Expand Down Expand Up @@ -2006,3 +2075,8 @@ def epsg(code):
"""
import cartopy._epsg
return cartopy._epsg._EPSGProjection(code)


def from_proj4(proj4_terms):
from cartopy._proj4 import from_proj4
return from_proj4(proj4_terms)
40 changes: 40 additions & 0 deletions lib/cartopy/tests/test_crs.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,46 @@ def test_utm(self):
decimal=1)


class TestFromPROJ4(object):
def test_lcc(self):
p_str1 = "+proj=lcc +datum=WGS84 +lat_0=25 +lat_1=25 +lon_0=-95"
crs1 = ccrs.from_proj4(p_str1)
p_str2 = "+proj=lcc +datum=WGS84 +lat_0=25 +lat_1=25 +lon_0=-95 " \
"+lat_2=45"
crs2 = ccrs.from_proj4(p_str2)
p_str3 = "+proj=lcc +datum=WGS84 +lat_0=25 +lat_1=25 +lon_0=-95 " \
"+no_defs"
crs3 = ccrs.from_proj4(p_str3)

assert isinstance(crs1, ccrs.LambertConformal)
assert isinstance(crs2, ccrs.LambertConformal)
assert isinstance(crs3, ccrs.LambertConformal)
x, y = crs1.transform_point(-135.0, 42., ccrs.PlateCarree())
assert_arr_almost_eq([x, y],
[-3199404.236416136, 2517302.7077927846])
# crs2 should have the same parameters as crs1 through defaults
x, y = crs2.transform_point(-135.0, 42., ccrs.PlateCarree())
assert_arr_almost_eq([x, y],
[-3199404.236416136, 2517302.7077927846])

assert crs1 == crs2

def test_stereographic(self):
p_str1 = "+proj=stere +datum=WGS84 +lat_0=90 +lat_ts=45 +lon_0=-150"
crs1 = ccrs.from_proj4(p_str1)
assert isinstance(crs1, ccrs.NorthPolarStereo)
p_str2 = "+proj=stere +datum=WGS84 +lat_0=-90 +lat_ts=45 +lon_0=-150"
crs2 = ccrs.from_proj4(p_str2)
assert isinstance(crs2, ccrs.SouthPolarStereo)
p_str3 = "+proj=stere +datum=WGS84 +lat_0=80 +lon_0=-150"
crs3 = ccrs.from_proj4(p_str3)
assert isinstance(crs3, ccrs.Stereographic)

x, y = crs1.transform_point(-145., 74., ccrs.PlateCarree())
assert_arr_almost_eq([x, y],
[133820.7681726163, -1529578.3794087067])


def test_pickle():
# check that we can pickle a simple CRS
fh = BytesIO()
Expand Down