Repository: SciTools/cartopy

Issue:
Adding a sinusoidal projection
I need to work with MODIS data and it would be nice to have a sinusoidal projection CRS native to Cartopy. Is this possible?

I can try adding it myself as well.



Discussion:
Yep, this is feasible. If you want to give it a shot, that would be awesome! Figuring out the native coordinate extents is about the most complex part of defining a new projection - take a look at the stereographic projection for a recently up-to-date projection: https://github.com/SciTools/cartopy/blob/master/lib/cartopy/crs.py#L1226


Resolving patch:
diff --git a/docs/make_projection.py b/docs/make_projection.py
--- a/docs/make_projection.py
+++ b/docs/make_projection.py
@@ -42,7 +42,7 @@
                   'TransverseMercator': 2, 'LambertCylindrical': 2,
                   'LambertConformal': 2, 'Stereographic': 2, 'Miller': 2,
                   'Orthographic': 2, 'UTM': 2, 'AlbersEqualArea': 2,
-                  'AzimuthalEquidistant': 2,
+                  'AzimuthalEquidistant': 2, 'Sinusoidal': 2,
                   'InterruptedGoodeHomolosine': 3, 'RotatedPole': 3,
                   'OSGB': 4}
 
diff --git a/lib/cartopy/crs.py b/lib/cartopy/crs.py
--- a/lib/cartopy/crs.py
+++ b/lib/cartopy/crs.py
@@ -1,4 +1,4 @@
-# (C) British Crown Copyright 2011 - 2015, Met Office
+# (C) British Crown Copyright 2011 - 2016, Met Office
 #
 # This file is part of cartopy.
 #
@@ -1778,6 +1778,79 @@ def y_limits(self):
         return self._y_limits
 
 
+class Sinusoidal(Projection):
+    """
+    A Sinusoidal projection.
+
+    This projection is equal-area.
+    """
+
+    def __init__(self, central_longitude=0.0, false_easting=0.0,
+                 false_northing=0.0, globe=None):
+        """
+        Kwargs:
+
+            * central_longitude - The central longitude. Defaults to 0.
+            * false_easting - X offset from planar origin in metres.
+                              Defaults to 0.
+            * false_northing - Y offset from planar origin in metres.
+                               Defaults to 0.
+            * globe - A :class:`cartopy.crs.Globe`.
+                      If omitted, a default globe is created.
+        """
+        proj4_params = [('proj', 'sinu'),
+                        ('lon_0', central_longitude),
+                        ('x_0', false_easting),
+                        ('y_0', false_northing)]
+        super(Sinusoidal, self).__init__(proj4_params, globe=globe)
+
+        # Obtain boundary points
+        points = []
+        n = 91
+        geodetic_crs = self.as_geodetic()
+        for lat in np.linspace(-90, 90, n):
+            points.append(
+                self.transform_point(180 + central_longitude,
+                                     lat, geodetic_crs)
+            )
+        for lat in np.linspace(90, -90, n):
+            points.append(
+                self.transform_point(-180 + central_longitude,
+                                     lat, geodetic_crs)
+            )
+        points.append(
+            self.transform_point(180 + central_longitude, -90, geodetic_crs))
+
+        self._boundary = sgeom.LineString(points[::-1])
+        minx, miny, maxx, maxy = self._boundary.bounds
+        self._x_limits = minx, maxx
+        self._y_limits = miny, maxy
+        self._threshold = max(np.abs(self.x_limits + self.y_limits)) * 1e-5
+
+    @property
+    def boundary(self):
+        return self._boundary
+
+    @property
+    def threshold(self):
+        return self._threshold
+
+    @property
+    def x_limits(self):
+        return self._x_limits
+
+    @property
+    def y_limits(self):
+        return self._y_limits
+
+# MODIS data products use a Sinusoidal projection of a spherical Earth
+# http://modis-land.gsfc.nasa.gov/GCTP.html
+MODIS = Sinusoidal(globe=Globe(ellipse=None,
+                               semimajor_axis=6371007.181,
+                               semiminor_axis=6371007.181,
+                               nadgrids='@null'))
+
+
 class _BoundaryPoint(object):
     def __init__(self, distance, kind, data):
         """


Test patch:
diff --git a/lib/cartopy/tests/crs/test_sinusoidal.py b/lib/cartopy/tests/crs/test_sinusoidal.py
new file mode 100644
--- /dev/null
+++ b/lib/cartopy/tests/crs/test_sinusoidal.py
@@ -0,0 +1,87 @@
+# (C) British Crown Copyright 2016, 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 <http://www.gnu.org/licenses/>.
+
+from __future__ import (absolute_import, division, print_function)
+
+import unittest
+
+import numpy as np
+from numpy.testing import assert_almost_equal
+from nose.tools import assert_equal
+
+import cartopy.crs as ccrs
+
+
+class TestSinusoidal(unittest.TestCase):
+    def test_default(self):
+        crs = ccrs.Sinusoidal()
+        expected = ('+ellps=WGS84 +proj=sinu +lon_0=0.0 '
+                    '+x_0=0.0 +y_0=0.0 +no_defs')
+        assert_equal(crs.proj4_init, expected)
+
+        assert_almost_equal(np.array(crs.x_limits),
+                            [-20037508.3428, 20037508.3428],
+                            decimal=4)
+        assert_almost_equal(np.array(crs.y_limits),
+                            [-10001965.7293, 10001965.7293],
+                            decimal=4)
+
+    def test_eccentric_globe(self):
+        globe = ccrs.Globe(semimajor_axis=1000, semiminor_axis=500,
+                           ellipse=None)
+        crs = ccrs.Sinusoidal(globe=globe)
+        expected = ('+a=1000 +b=500 +proj=sinu +lon_0=0.0 +x_0=0.0 '
+                    '+y_0=0.0 +no_defs')
+        assert_equal(crs.proj4_init, expected)
+
+        assert_almost_equal(np.array(crs.x_limits),
+                            [-3141.59, 3141.59], decimal=2)
+        assert_almost_equal(np.array(crs.y_limits),
+                            [-1216.60, 1216.60], decimal=2)
+
+    def test_offset(self):
+        crs = ccrs.Sinusoidal()
+        crs_offset = ccrs.Sinusoidal(false_easting=1234,
+                                     false_northing=-4321)
+        expected = ('+ellps=WGS84 +proj=sinu +lon_0=0.0 +x_0=1234 '
+                    '+y_0=-4321 +no_defs')
+        assert_equal(crs_offset.proj4_init, expected)
+        assert_equal(tuple(np.array(crs.x_limits) + 1234),
+                     crs_offset.x_limits)
+        assert_equal(tuple(np.array(crs.y_limits) - 4321),
+                     crs_offset.y_limits)
+
+    def test_MODIS(self):
+        # Testpoints verified with MODLAND Tile Calculator
+        # http://landweb.nascom.nasa.gov/cgi-bin/developer/tilemap.cgi
+        # Settings: Sinusoidal, Global map coordinates, Forward mapping
+        crs = ccrs.MODIS
+        lons = np.array([-180, -50, 40, 180])
+        lats = np.array([-89.999, 30, 20, 89.999])
+        expected_x = np.array([-349.33, -4814886.99,
+                               4179566.79, 349.33])
+        expected_y = np.array([-10007443.48, 3335851.56,
+                               2223901.04, 10007443.48])
+        assert_almost_equal(crs.transform_points(crs.as_geodetic(),
+                                                 lons, lats),
+                            np.c_[expected_x, expected_y, [0, 0, 0, 0]],
+                            decimal=2)
+
+
+if __name__ == '__main__':
+    import nose
+    nose.runmodule(argv=['-s', '--with-doctest'], exit=False)