Browse Source

Allow scheduling according to sunrise, sunset, dawn and dusk

Mark Parncutt 10 years ago
parent
commit
ac2512b021
4 changed files with 251 additions and 2 deletions
  1. 1 0
      CONTRIBUTORS.txt
  2. 147 2
      celery/schedules.py
  3. 1 0
      docs/AUTHORS.txt
  4. 102 0
      docs/userguide/periodic-tasks.rst

+ 1 - 0
CONTRIBUTORS.txt

@@ -180,3 +180,4 @@ Bert Vanderbauwhede, 2014/12/18
 John Anderson, 2014/12/27
 Luke Burden, 2015/01/24
 Mickaël Penhard, 2015/02/15
+Mark Parncutt, 2015/02/16

+ 147 - 2
celery/schedules.py

@@ -22,12 +22,12 @@ from .five import range, string_t
 from .utils import is_iterable
 from .utils.timeutils import (
     weekday, maybe_timedelta, remaining, humanize_seconds,
-    timezone, maybe_make_aware, ffwd
+    timezone, maybe_make_aware, ffwd, localize
 )
 from .datastructures import AttributeDict
 
 __all__ = ['ParseException', 'schedule', 'crontab', 'crontab_parser',
-           'maybe_schedule']
+           'maybe_schedule', 'solar']
 
 schedstate = namedtuple('schedstate', ('is_due', 'next'))
 
@@ -591,3 +591,148 @@ def maybe_schedule(s, relative=False, app=None):
         else:
             s.app = app
     return s
+
+SOLAR_INVALID_LATITUDE = """\
+Argument latitude {lat} is invalid, must be between -90 and 90.\
+"""
+
+SOLAR_INVALID_LONGITUDE = """\
+Argument longitude {lon} is invalid, must be between -180 and 180.\
+"""
+
+SOLAR_INVALID_EVENT = """\
+Argument event \"{event}\" is invalid, must be one of {all_events}.\
+"""
+
+class solar(schedule):
+    """A solar event can be used as the `run_every` value of a
+    :class:`PeriodicTask` to schedule based on certain solar events.
+
+    :param event: Solar event that triggers this task. Available
+        values are: dawn_astronomical, dawn_nautical, dawn_civil,
+        sunrise, solar_noon, sunset, dusk_civil, dusk_nautical,
+        dusk_astronomical
+    :param lat: The latitude of the observer.
+    :param lon: The longitude of the observer.    
+    :param nowfun: Function returning the current date and time
+        (class:`~datetime.datetime`).
+    :param app: Celery app instance.
+    """
+
+    
+    _all_events = ['dawn_astronomical',
+        'dawn_nautical',
+        'dawn_civil',
+        'sunrise',
+        'solar_noon',
+        'sunset',
+        'dusk_civil',
+        'dusk_nautical',
+        'dusk_astronomical']
+    _horizons = {'dawn_astronomical': '-18',
+        'dawn_nautical': '-12',
+        'dawn_civil': '-6',
+        'sunrise': '-0:34',
+        'solar_noon': '0',
+        'sunset': '-0:34',
+        'dusk_civil': '-6',
+        'dusk_nautical': '-12',
+        'dusk_astronomical': '18'}
+    _methods = {'dawn_astronomical': 'next_rising',
+        'dawn_nautical': 'next_rising',
+        'dawn_civil': 'next_rising',
+        'sunrise': 'next_rising',
+        'solar_noon': 'next_transit',
+        'sunset': 'next_setting',
+        'dusk_civil': 'next_setting',
+        'dusk_nautical': 'next_setting',
+        'dusk_astronomical': 'next_setting'}
+    _use_center_l = {'dawn_astronomical': True,
+        'dawn_nautical': True,
+        'dawn_civil': True,
+        'sunrise': False,
+        'solar_noon': True,
+        'sunset': False,
+        'dusk_civil': True,
+        'dusk_nautical': True,
+        'dusk_astronomical': True}
+    
+    def __init__(self, event, lat, lon, nowfun=None, app=None):
+        self.ephem = __import__('ephem')
+        self.event = event
+        self.lat = lat
+        self.lon = lon
+        self.nowfun = nowfun
+        self._app = app
+        
+        if event not in self._all_events:
+            raise ValueError(SOLAR_INVALID_EVENT.format(event=event, all_events=', '.join(self._all_events)))
+        if lat < -90 or lat > 90:
+            raise ValueError(SOLAR_INVALID_LATITUDE.format(lat=lat))
+        if lon < -180 or lon > 180:
+            raise ValueError(SOLAR_INVALID_LONGITUDE.format(lon=lon))
+        
+        cal = self.ephem.Observer()
+        cal.lat = str(lat)
+        cal.lon = str(lon)
+        cal.elev = 0
+        cal.horizon = self._horizons[event]
+        cal.pressure = 0
+        self.cal = cal
+        
+        self.method = self._methods[event]
+        self.use_center = self._use_center_l[event]
+
+    def now(self):
+        return (self.nowfun or self.app.now)()
+    
+    def __reduce__(self):
+        return (self.__class__, (self.event,
+                                 self.lat,
+                                 self.lon), None)
+
+    def __repr__(self):
+        return "<solar: " + self.event + " at latitude " + str(self.lat) + ", longitude " + str(self.lon) + ">"
+
+    def remaining_estimate(self, last_run_at):
+        """Returns when the periodic task should run next as a timedelta,
+        or if it shouldn't run today (e.g. the sun does not rise today),
+        returns the time when the next check should take place."""
+        last_run_at = self.maybe_make_aware(last_run_at)
+        last_run_at_utc = localize(last_run_at, timezone.utc)
+        self.cal.date = last_run_at_utc
+        try:
+            next_utc = getattr(self.cal, self.method)(self.ephem.Sun(), start=last_run_at_utc, use_center=self.use_center)
+        except self.ephem.CircumpolarError:
+            """Sun will not rise/set today. Check again tomorrow
+            (specifically, after the next anti-transit)."""
+            next_utc = self.cal.next_antitransit(self.ephem.Sun()) + timedelta(minutes=1)
+        next = self.maybe_make_aware(next_utc.datetime())
+        now = self.maybe_make_aware(self.now())
+        delta = next - now
+        return delta
+
+    def is_due(self, last_run_at):
+        """Returns tuple of two items `(is_due, next_time_to_run)`,
+        where next time to run is in seconds.
+
+        See :meth:`celery.schedules.schedule.is_due` for more information.
+
+        """
+        rem_delta = self.remaining_estimate(last_run_at)
+        rem = max(rem_delta.total_seconds(), 0)
+        due = rem == 0
+        if due:
+            rem_delta = self.remaining_estimate(self.now())
+            rem = max(rem_delta.total_seconds(), 0)
+        return schedstate(due, rem)
+
+    def __eq__(self, other):
+        if isinstance(other, solar):
+            return (other.event == self.event and
+                    other.lat == self.lat and
+                    other.lon == self.lon)
+        return NotImplemented
+
+    def __ne__(self, other):
+        return not self.__eq__(other)

+ 1 - 0
docs/AUTHORS.txt

@@ -89,6 +89,7 @@ Marcin Kuźmiński <marcin@python-works.com>
 Marcin Lulek <info@webreactor.eu>
 Mark Hellewell <mark.hellewell@gmail.com>
 Mark Lavin <mlavin@caktusgroup.com>
+Mark Parncutt <me@markparncutt.com>
 Mark Stover <stovenator@gmail.com>
 Mark Thurman <mthurman@gmail.com>
 Martin Galpin <m@66laps.com>

+ 102 - 0
docs/userguide/periodic-tasks.rst

@@ -269,6 +269,108 @@ The syntax of these crontab expressions are very flexible.  Some examples:
 
 See :class:`celery.schedules.crontab` for more documentation.
 
+.. _beat-solar:
+
+Solar schedules
+=================
+
+If you have a task that should be executed according to sunrise,
+sunset, dawn or dusk, you can use the
+:class:`~celery.schedules.solar` schedule type:
+
+.. code-block:: python
+
+    from celery.schedules import solar
+    
+    CELERYBEAT_SCHEDULE = {
+    	# Executes at sunset in Melbourne
+    	'add-at-melbourne-sunset': {
+    		'task': 'tasks.add',
+    		'schedule': solar('sunset', -37.81753, 144.96715),  
+    		'args': (16, 16),
+    	},
+    }
+
+The arguments are simply: ``solar(event, latitude, longitude)``
+
+Be sure to use the correct sign for latitude and longitude:
+
++---------------+-------------------+----------------------+
+| **Sign**      | **Argument**      | **Meaning**          |
++---------------+-------------------+----------------------+
+| ``+``         | ``latitude``      | North                |
++---------------+-------------------+----------------------+
+| ``-``         | ``latitude``      | South                |
++---------------+-------------------+----------------------+
+| ``+``         | ``longitude``     | East                 |
++---------------+-------------------+----------------------+
+| ``-``         | ``longitude``     | West                 |
++---------------+-------------------+----------------------+
+
+Possible event types are:
+
++-----------------------------------------+--------------------------------------------+
+| **Event**                               | **Meaning**                                |
++-----------------------------------------+--------------------------------------------+
+| ``dawn_astronomical``                   | Execute at the moment after which the sky  |
+|                                         | is no longer completely dark. This is when |
+|                                         | the sun is 18 degrees below the horizon.   |
++-----------------------------------------+--------------------------------------------+
+| ``dawn_nautical``                       | Execute when there is enough sunlight for  |
+|                                         | the horizon and some objects to be         |
+|                                         | distinguishable; formally, when the sun is |
+|                                         | 12 degrees below the horizon.              |
++-----------------------------------------+--------------------------------------------+
+| ``dawn_civil``                          | Execute when there is enough light for     |
+|                                         | objects to be distinguishable so that      |
+|                                         | outdoor activities can commence;           |
+|                                         | formally, when the Sun is 6 degrees below  |
+|                                         | the horizon.                               |
++-----------------------------------------+--------------------------------------------+
+| ``sunrise``                             | Execute when the upper edge of the sun     |
+|                                         | appears over the eastern horizon in the    |
+|                                         | morning.                                   |
++-----------------------------------------+--------------------------------------------+
+| ``solar_noon``                          | Execute when the sun is highest above the  |
+|                                         | horizon on that day.                       |
++-----------------------------------------+--------------------------------------------+
+| ``sunset``                              | Execute when the trailing edge of the sun  |
+|                                         | disappears over the western horizon in the |
+|                                         | evening.                                   |
++-----------------------------------------+--------------------------------------------+
+| ``dusk_civil``                          | Execute at the end of civil twilight, when |
+|                                         | objects are still distinguishable and some |
+|                                         | stars and planets are visible. Formally,   |
+|                                         | when the sun is 6 degrees below the        |
+|                                         | horizon.                                   |
++-----------------------------------------+--------------------------------------------+
+| ``dusk_nautical``                       | Execute when the sun is 12 degrees below   |
+|                                         | the horizon. Objects are no longer         |
+|                                         | distinguishable, and the horizon is no     |
+|                                         | longer visible to the naked eye.           |
++-----------------------------------------+--------------------------------------------+
+| ``dusk_astronomical``                   | Execute at the moment after which the sky  |
+|                                         | becomes completely dark; formally, when    |
+|                                         | the sun is 18 degrees below the horizon.   |
++-----------------------------------------+--------------------------------------------+
+
+All solar events are calculated using UTC, and are therefore
+unaffected by your timezone setting.
+
+In polar regions, the sun may not rise or set every day. The scheduler
+is able to handle these cases, i.e. a ``sunrise`` event won't run on a day
+when the sun doesn't rise. The one exception is ``solar_noon``, which is
+formally defined as the moment the sun transits the celestial meridian,
+and will occur every day even if the sun is below the horizon.
+
+Twilight is defined as the period between dawn and sunrise, and between
+sunset and dusk. You can schedule an event according to "twilight"
+depending on your definition of twilight (civil, nautical or astronomical),
+and whether you want the event to take place at the beginning or end
+of twilight, using the appropriate event from the list above.
+
+See :class:`celery.schedules.solar` for more documentation.
+
 .. _beat-starting:
 
 Starting the Scheduler