-
Notifications
You must be signed in to change notification settings - Fork 364
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
Changes from all commits
4583fad
45f77ea
343bd3c
8d555e4
a14f148
37d8e02
79b1db3
6af95e9
64cb8ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Style choice... I'd probably get rid of the filter:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI I've added this class to pyresample to convert pyresamples |
||
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was following the base CRS class which uses |
||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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... There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. May want to be slightly more defensive here - what if There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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, | ||
|
@@ -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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd want to assert that the |
||
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"): | ||
|
@@ -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, | ||
|
@@ -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']) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is repeated handling of things like There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 What would be missing from this approach is the user knowing that |
||
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 | ||
|
@@ -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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.