Repository: SciTools/cartopy

Issue:
nightshade feature implemented?
### Description
Cartopy is supposed to replace Basemap as I understand it.
But I see no nightshade implemented yet, is that correct?


-Adam


Discussion:
See the `fill_dark_side` function in [this example](https://scitools.org.uk/cartopy/docs/latest/gallery/aurora_forecast.html).
Thanks!
I did see that. But wanted something a bit higher level, compatible with the basemap nightshade method. For the time being I will be doing something like the aurora example I suppose.


Resolving patch:
diff --git a/lib/cartopy/examples/aurora_forecast.py b/lib/cartopy/examples/aurora_forecast.py
--- a/lib/cartopy/examples/aurora_forecast.py
+++ b/lib/cartopy/examples/aurora_forecast.py
@@ -87,80 +87,6 @@ def aurora_cmap():
     return LinearSegmentedColormap('aurora', stops)
 
 
-def sun_pos(dt=None):
-    """This function computes a rough estimate of the coordinates for
-    the point on the surface of the Earth where the Sun is directly
-    overhead at the time dt. Precision is down to a few degrees. This
-    means that the equinoxes (when the sign of the latitude changes)
-    will be off by a few days.
-
-    The function is intended only for visualization. For more precise
-    calculations consider for example the PyEphem package.
-
-    Parameters
-    ----------
-    dt: datetime
-        Defaults to datetime.utcnow()
-
-    Returns
-    -------
-    lat, lng: tuple of floats
-        Approximate coordinates of the point where the sun is
-        in zenith at the time dt.
-
-    """
-    if dt is None:
-        dt = datetime.utcnow()
-
-    axial_tilt = 23.4
-    ref_solstice = datetime(2016, 6, 21, 22, 22)
-    days_per_year = 365.2425
-    seconds_per_day = 24*60*60.0
-
-    days_since_ref = (dt - ref_solstice).total_seconds()/seconds_per_day
-    lat = axial_tilt*np.cos(2*np.pi*days_since_ref/days_per_year)
-    sec_since_midnight = (dt - datetime(dt.year, dt.month, dt.day)).seconds
-    lng = -(sec_since_midnight/seconds_per_day - 0.5)*360
-    return lat, lng
-
-
-def fill_dark_side(ax, time=None, *args, **kwargs):
-    """
-    Plot a fill on the dark side of the planet (without refraction).
-
-    Parameters
-    ----------
-        ax : Matplotlib axes
-            The axes to plot on.
-        time : datetime
-            The time to calculate terminator for. Defaults to datetime.utcnow()
-        **kwargs :
-            Passed on to Matplotlib's ax.fill()
-
-    """
-    lat, lng = sun_pos(time)
-    pole_lng = lng
-    if lat > 0:
-        pole_lat = -90 + lat
-        central_rot_lng = 180
-    else:
-        pole_lat = 90 + lat
-        central_rot_lng = 0
-
-    rotated_pole = ccrs.RotatedPole(pole_latitude=pole_lat,
-                                    pole_longitude=pole_lng,
-                                    central_rotated_longitude=central_rot_lng)
-
-    x = np.empty(360)
-    y = np.empty(360)
-    x[:180] = -90
-    y[:180] = np.arange(-90, 90.)
-    x[180:] = 90
-    y[180:] = np.arange(90, -90., -1)
-
-    ax.fill(x, y, transform=rotated_pole, **kwargs)
-
-
 def main():
     fig = plt.figure(figsize=[10, 5])
 
@@ -180,7 +106,7 @@ def main():
         ax.coastlines(zorder=3)
         ax.stock_img()
         ax.gridlines()
-        fill_dark_side(ax, time=dt, color='black', alpha=0.75)
+        ax.nightshade(dt)
         ax.imshow(img, vmin=0, vmax=100, transform=crs,
                   extent=extent, origin=origin, zorder=2,
                   cmap=aurora_cmap())
diff --git a/lib/cartopy/mpl/_solar.py b/lib/cartopy/mpl/_solar.py
new file mode 100644
--- /dev/null
+++ b/lib/cartopy/mpl/_solar.py
@@ -0,0 +1,127 @@
+# (C) British Crown Copyright 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/>.
+
+from __future__ import (absolute_import, division, print_function)
+import numpy as np
+
+
+def julian_day(date):
+    """
+    Calculate the Julian day from an input datetime.
+
+    Parameters
+    ----------
+    date
+        A UTC datetime object.
+
+    Note
+    ----
+    Algorithm implemented following equations from Chapter 3 (Algorithm 14):
+    Vallado, David 'Fundamentals of Astrodynamics and Applications', (2007)
+
+    Julian day epoch is: noon on January 1, 4713 BC (proleptic Julian)
+                         noon on November 24, 4714 BC (proleptic Gregorian)
+
+    """
+    year = date.year
+    month = date.month
+    day = date.day
+    hour = date.hour
+    minute = date.minute
+    second = date.second
+
+    # January/February correspond to months 13/14 respectively
+    # for the constants to work out properly
+    if month < 3:
+        month += 12
+        year -= 1
+
+    B = 2 - int(year/100) + int(int(year/100)/4)
+    C = ((second/60 + minute)/60 + hour)/24
+
+    JD = (int(365.25*(year + 4716)) + int(30.6001*(month+1)) +
+          day + B - 1524.5 + C)
+    return JD
+
+
+def solar_position(date):
+    """
+    Calculate the latitude and longitude point where the sun is
+    directly overhead for the given date.
+
+    Parameters
+    ----------
+    date
+        A UTC datetime object.
+
+    Returns
+    -------
+    (latitude, longitude) in degrees
+
+    Note
+    ----
+    Algorithm implemented following equations from Chapter 5 (Algorithm 29):
+    Vallado, David 'Fundamentals of Astrodynamics and Applications', (2007)
+
+    """
+    # NOTE: Constants are in degrees in the textbook,
+    #       so we need to convert the values from deg2rad when taking sin/cos
+
+    # Centuries from J2000
+    T_UT1 = (julian_day(date) - 2451545.0)/36525
+
+    # solar longitude (deg)
+    lambda_M_sun = (280.460 + 36000.771*T_UT1) % 360
+
+    # solar anomaly (deg)
+    M_sun = (357.5277233 + 35999.05034*T_UT1) % 360
+
+    # ecliptic longitude
+    lambda_ecliptic = (lambda_M_sun + 1.914666471*np.sin(np.deg2rad(M_sun)) +
+                       0.019994643*np.sin(np.deg2rad(2*M_sun)))
+
+    # obliquity of the ecliptic (epsilon in Vallado's notation)
+    epsilon = 23.439291 - 0.0130042*T_UT1
+
+    # declination of the sun
+    delta_sun = np.rad2deg(np.arcsin(np.sin(np.deg2rad(epsilon)) *
+                                     np.sin(np.deg2rad(lambda_ecliptic))))
+
+    # Greenwich mean sidereal time (seconds)
+    theta_GMST = (67310.54841 +
+                  (876600*3600 + 8640184.812866)*T_UT1 +
+                  0.093104*T_UT1**2 -
+                  6.2e-6*T_UT1**3)
+    # Convert to degrees
+    theta_GMST = (theta_GMST % 86400)/240
+
+    # Right ascension calculations
+    numerator = (np.cos(np.deg2rad(epsilon)) *
+                 np.sin(np.deg2rad(lambda_ecliptic)) /
+                 np.cos(np.deg2rad(delta_sun)))
+    denominator = (np.cos(np.deg2rad(lambda_ecliptic)) /
+                   np.cos(np.deg2rad(delta_sun)))
+
+    alpha_sun = np.rad2deg(np.arctan2(numerator, denominator))
+
+    # longitude is opposite of Greenwich Hour Angle (GHA)
+    # GHA == theta_GMST - alpha_sun
+    lon = -(theta_GMST-alpha_sun)
+    if lon < -180:
+        lon += 360
+
+    return (delta_sun, lon)
diff --git a/lib/cartopy/mpl/geoaxes.py b/lib/cartopy/mpl/geoaxes.py
--- a/lib/cartopy/mpl/geoaxes.py
+++ b/lib/cartopy/mpl/geoaxes.py
@@ -491,6 +491,79 @@ def tissot(self, rad_km=500, lons=None, lats=None, n_samples=80, **kwargs):
                                                  **kwargs)
         return self.add_feature(feature)
 
+    def nightshade(self, date, delta=0.1, refraction=-0.83,
+                   color="k", alpha=0.75, **kwargs):
+        """
+        Shade the darkside of the Earth, accounting for refraction.
+
+        Parameters
+        ----------
+        date
+            A UTC datetime object used to calculate the position of the sun.
+        delta
+            Float (degrees), stepsize to determine the resolution of the
+            night polygon feature.
+        refraction
+            Float (degrees) to specify the adjustment due to refraction,
+            thickness of the solar disc, elevation etc...
+
+        Note
+        ----
+            Matplotlib keyword arguments can be used when drawing the feature.
+            This allows standard Matplotlib control over aspects such as
+            'color', 'alpha', etc.
+
+        """
+        from ._solar import solar_position
+        # make sure date is UTC, or naive with respect to time zones
+        if date.utcoffset():
+            raise ValueError('datetime instance must be UTC, not {0}'.format(
+                             date.tzname()))
+
+        # Returns the Greenwich hour angle,
+        # need longitude (opposite direction)
+        lat, lon = solar_position(date)
+        pole_lon = lon
+        if lat > 0:
+            pole_lat = -90 + lat
+            central_lon = 180
+        else:
+            pole_lat = 90 + lat
+            central_lon = 0
+
+        rotated_pole = ccrs.RotatedPole(pole_latitude=pole_lat,
+                                        pole_longitude=pole_lon,
+                                        central_rotated_longitude=central_lon)
+
+        npts = int(180/delta)
+        x = np.empty(npts*2)
+        y = np.empty(npts*2)
+
+        # Solve the equation for sunrise/sunset:
+        # https://en.wikipedia.org/wiki/Sunrise_equation#Generalized_equation
+        # NOTE: In the generalized equation on Wikipedia,
+        #       delta == 0. in the rotated pole coordinate system.
+        #       Therefore, the max/min latitude is +/- (90+refraction)
+
+        # Fill latitudes up and then down
+        y[:npts] = np.linspace(-(90+refraction), 90+refraction, npts)
+        y[npts:] = y[:npts][::-1]
+
+        # Solve the generalized equation for omega0, which is the
+        # angle of sunrise/sunset from solar noon
+        omega0 = np.rad2deg(np.arccos(np.sin(np.deg2rad(refraction)) /
+                                      np.cos(np.deg2rad(y))))
+
+        # Fill the longitude values from the offset for midnight.
+        # This needs to be a closed loop to fill the polygon.
+        # Negative longitudes
+        x[:npts] = -(180 - omega0[:npts])
+        # Positive longitudes
+        x[npts:] = 180 - omega0[npts:]
+
+        return self.fill(x, y, color=color, alpha=alpha,
+                         transform=rotated_pole, **kwargs)
+
     def natural_earth_shp(self, name='land', resolution='110m',
                           category='physical', **kwargs):
         """


Test patch:
diff --git a/lib/cartopy/tests/mpl/baseline_images/mpl/test_nightshade/nightshade_platecarree.png b/lib/cartopy/tests/mpl/baseline_images/mpl/test_nightshade/nightshade_platecarree.png
new file mode 100644
Binary files /dev/null and b/lib/cartopy/tests/mpl/baseline_images/mpl/test_nightshade/nightshade_platecarree.png differ
diff --git a/lib/cartopy/tests/mpl/test_nightshade.py b/lib/cartopy/tests/mpl/test_nightshade.py
new file mode 100644
--- /dev/null
+++ b/lib/cartopy/tests/mpl/test_nightshade.py
@@ -0,0 +1,71 @@
+# (C) British Crown Copyright 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/>.
+
+from __future__ import (absolute_import, division, print_function)
+
+from datetime import datetime
+
+import matplotlib.pyplot as plt
+import pytest
+
+import cartopy.crs as ccrs
+from cartopy.tests.mpl import ImageTesting
+from cartopy.mpl._solar import julian_day, solar_position
+
+
+def test_julian_day():
+    # Using Vallado 2007 "Fundamentals of Astrodynamics and Applications"
+    # Example 3.4
+    dt = datetime(1996, 10, 26, 14, 20)
+    jd = julian_day(dt)
+    assert pytest.approx(jd) == 2450383.09722222
+
+
+# Using timeanddate website. Not very many digits of accuracy,
+# but it should do OK for simple testing
+# 1) Early Sept 2018
+#    https://www.timeanddate.com/worldclock/sunearth.html
+#    ?month=9&day=29&year=2018&hour=00&min=00&sec=0&n=&ntxt=&earth=0
+# 2) Later Sept 2018
+#    https://www.timeanddate.com/worldclock/sunearth.html
+#    ?month=9&day=29&year=2018&hour=14&min=00&sec=0&n=&ntxt=&earth=0
+# 3) Different year/month (Feb 1992)
+#    https://www.timeanddate.com/worldclock/sunearth.html
+#    ?month=2&day=14&year=1992&hour=00&min=0&sec=0&n=&ntxt=&earth=0
+# 4) Test in the future near summer solstice (June 2030)
+#    https://www.timeanddate.com/worldclock/sunearth.html
+#    ?month=6&day=21&year=2030&hour=0&min=0&sec=0&n=&ntxt=&earth=0
+
+@pytest.mark.parametrize('dt, true_lat, true_lon', [
+    (datetime(2018, 9, 29, 0, 0), -(2 + 18/60), (177 + 37/60)),
+    (datetime(2018, 9, 29, 14, 0), -(2 + 32/60), -(32 + 25/60)),
+    (datetime(1992, 2, 14, 0, 0), -(13 + 20/60), -(176 + 26/60)),
+    (datetime(2030, 6, 21, 0, 0), (23 + 26/60), -(179 + 34/60))
+    ])
+def test_solar_position(dt, true_lat, true_lon):
+    lat, lon = solar_position(dt)
+    assert pytest.approx(true_lat, 0.1) == lat
+    assert pytest.approx(true_lon, 0.1) == lon
+
+
+@ImageTesting(['nightshade_platecarree'])
+def test_nightshade_image():
+    # Test the actual creation of the image
+    ax = plt.axes(projection=ccrs.PlateCarree())
+    ax.coastlines()
+    dt = datetime(2018, 11, 10, 0, 0)
+    ax.nightshade(dt)