Watering Time: Practical Uses for Python’s Calendar Module

The UI for the timer attached to my sprinkler system makes it
difficult to understand exactly when the various sprinklers will
run. All of the data is there, but with only a few controls and a
small LCD screen, there aren’t a lot of presentation options. Using
Python’s calendar module I was able to write a simple program
to format the data to make it easier to identify cases where I might
be over, or under, watering.

The Problem

We don’t have a large yard, but we’ve tried to invest in making it an
attractive place to spend time. A big part of that, especially in the
southeastern US, is keeping plants properly watered through the hot
summer. A couple of years ago, I had an automated irrigation system
installed, with a programmable timer to control the watering schedule.

http://doughellmann.com/blog/wp-content/uploads/2014/05/controllers_00-proc-08.png

The timer support three “programs”, each of which can be scheduled to
run on different days of the week or month, at multiple times. Each
program can activate the sprinklers in several “zones” (areas of the
yard), running them for different amounts of time. This is, as far as
I can tell, a pretty standard internal model for one of these timers,
and once you get the hang of it programming it is pretty
straightforward.

This spring we decided we needed to change the way we were watering a
particularly troublesome spot in the front of the yard, to run the
system for two short cycles instead of one long cycle (the theory
being that this would allow more water to soak in and be used by the
plants in that area). When I examined the current settings, I
discovered that I had also been watering one zone more than I
realized, because it was scheduled in multiple programs. It wasn’t at
all obvious, given the limitations of how the timer shows its
programming, and I only discovered it when I wrote down the entire
schedule to review it. As part of my audit before updating the
schedule, I decided I would write a program to show the schedule on a
calendar so it easier to understand what was happening without having
to perform the calculations in my head.

Designing the Inputs

The first step was to design an input format to represent all of the
data I had in a way that was easy to collect. I chose a YAML format,
since I have lists and mappings of data and using YAML meant I
wouldn’t need to build a separate parser. The first section of the
input file lists the zones, mapping the number used to identify them
in the timer with the name I use for them in my notes.

zones:
  1: turf
  2: f shrubs
  3: b shrubs
  4: patio
  5: garden

The remainder of the input file describes the schedule for each
program (named A, B, and C), including the times of day when the
program runs (multiples are allowed), the days of the week when the
program runs, and the zones to be watered and for how long.

For example, program A runs on Monday, Wednesday, and Friday at 4:00
AM, watering the front shrubs for 15 minutes and the back shrubs for
15 minutes.

programs:
  A:
    start:
      - '4:00'
    days: MWF
    zones:
      - zone: 2
        time: 15
      - zone: 3
        time: 15

Zones are identified in the program schedule by number, and although
they can be listed in any order they are always run in numerical
order.

There are two ways to express the rules for determining which days the
program is active. A program can either run on odd or even days of the
month, or any combination of explicitly selected days of the week. I
decided to use “odd” and “even” as literal values for those cases, and
to use one or two letter abbreviations for days (where Tuesday is T,
Thursday is Th, Saturday is Sa, and Sunday is Su).

Designing the Output

I decided to generate output using a monthly calendar format. I’m
likely to be the only user of the program, so I didn’t worry about
generating HTML and opted to use a simple text chart format.

$ wateringtime -c

                                                                                      May

+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+
| Mon                    | Tue                    | Wed                    | Thu                    | Fri                    | Sat                    | Sun                    |
+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+
|                        |                        |                        | (1)                    | (2)                    | (3)                    | (4)                    |
|                        |                        |                        |                        | 03:15-03:30 - f shrubs | 03:00-03:30 - turf     | 03:15-03:30 - f shrubs |
|                        |                        |                        |                        | 03:30-03:45 - b shrubs |                        | 03:30-03:45 - b shrubs |
|                        |                        |                        |                        | 03:45-03:55 - patio    |                        | 03:45-03:55 - patio    |
|                        |                        |                        |                        | 03:55-04:00 - garden   |                        | 03:55-04:00 - garden   |
|                        |                        |                        |                        | 04:00-04:15 - f shrubs |                        |                        |
|                        |                        |                        |                        | 04:15-04:30 - b shrubs |                        |                        |
+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+
| (5)                    | (6)                    | (7)                    | (8)                    | (9)                    | (10)                   | (11)                   |
| 03:00-03:30 - turf     | 03:15-03:30 - f shrubs | 04:00-04:15 - f shrubs | 03:15-03:30 - f shrubs | 04:00-04:15 - f shrubs | 03:00-03:30 - turf     |                        |
| 04:00-04:15 - f shrubs | 03:30-03:45 - b shrubs | 04:15-04:30 - b shrubs | 03:30-03:45 - b shrubs | 04:15-04:30 - b shrubs | 03:15-03:30 - f shrubs |                        |
| 04:15-04:30 - b shrubs | 03:45-03:55 - patio    |                        | 03:45-03:55 - patio    |                        | 03:30-03:45 - b shrubs |                        |
|                        | 03:55-04:00 - garden   |                        | 03:55-04:00 - garden   |                        | 03:45-03:55 - patio    |                        |
|                        |                        |                        |                        |                        | 03:55-04:00 - garden   |                        |
+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+
| (12)                   | (13)                   | (14)                   | (15)                   | (16)                   | (17)                   | (18)                   |
| 03:00-03:30 - turf     |                        | 03:15-03:30 - f shrubs |                        | 03:15-03:30 - f shrubs | 03:00-03:30 - turf     | 03:15-03:30 - f shrubs |
| 03:15-03:30 - f shrubs |                        | 03:30-03:45 - b shrubs |                        | 03:30-03:45 - b shrubs |                        | 03:30-03:45 - b shrubs |
| 03:30-03:45 - b shrubs |                        | 03:45-03:55 - patio    |                        | 03:45-03:55 - patio    |                        | 03:45-03:55 - patio    |
| 03:45-03:55 - patio    |                        | 03:55-04:00 - garden   |                        | 03:55-04:00 - garden   |                        | 03:55-04:00 - garden   |
| 03:55-04:00 - garden   |                        | 04:00-04:15 - f shrubs |                        | 04:00-04:15 - f shrubs |                        |                        |
| 04:00-04:15 - f shrubs |                        | 04:15-04:30 - b shrubs |                        | 04:15-04:30 - b shrubs |                        |                        |
| 04:15-04:30 - b shrubs |                        |                        |                        |                        |                        |                        |
+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+
| (19)                   | (20)                   | (21)                   | (22)                   | (23)                   | (24)                   | (25)                   |
| 03:00-03:30 - turf     | 03:15-03:30 - f shrubs | 04:00-04:15 - f shrubs | 03:15-03:30 - f shrubs | 04:00-04:15 - f shrubs | 03:00-03:30 - turf     |                        |
| 04:00-04:15 - f shrubs | 03:30-03:45 - b shrubs | 04:15-04:30 - b shrubs | 03:30-03:45 - b shrubs | 04:15-04:30 - b shrubs | 03:15-03:30 - f shrubs |                        |
| 04:15-04:30 - b shrubs | 03:45-03:55 - patio    |                        | 03:45-03:55 - patio    |                        | 03:30-03:45 - b shrubs |                        |
|                        | 03:55-04:00 - garden   |                        | 03:55-04:00 - garden   |                        | 03:45-03:55 - patio    |                        |
|                        |                        |                        |                        |                        | 03:55-04:00 - garden   |                        |
+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+
| (26)                   | (27)                   | (28)                   | (29)                   | (30)                   | (31)                   |                        |
| 03:00-03:30 - turf     |                        | 03:15-03:30 - f shrubs |                        | 03:15-03:30 - f shrubs | 03:00-03:30 - turf     |                        |
| 03:15-03:30 - f shrubs |                        | 03:30-03:45 - b shrubs |                        | 03:30-03:45 - b shrubs |                        |                        |
| 03:30-03:45 - b shrubs |                        | 03:45-03:55 - patio    |                        | 03:45-03:55 - patio    |                        |                        |
| 03:45-03:55 - patio    |                        | 03:55-04:00 - garden   |                        | 03:55-04:00 - garden   |                        |                        |
| 03:55-04:00 - garden   |                        | 04:00-04:15 - f shrubs |                        | 04:00-04:15 - f shrubs |                        |                        |
| 04:00-04:15 - f shrubs |                        | 04:15-04:30 - b shrubs |                        | 04:15-04:30 - b shrubs |                        |                        |
| 04:15-04:30 - b shrubs |                        |                        |                        |                        |                        |                        |
+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+------------------------+

I used Python’s calendar module to find the days of the month as
weeks. I treat Monday as day 0, which leaves the weekend at the end of
each row of output. That’s different than a typical American calendar,
but it works out well because I may have weekends as a special case,
depending on how bad the drought is here in Georgia and whether we
have watering restrictions in place.

Building Tables with PrettyTable

In addition to the calendar output mode, I included a simple table
output mode to report the settings in a form that is easy to carry
outside and program into the controller. (I still have to do that step
by hand.)

$ wateringtime
+------+----------+
| Zone | Name     |
+------+----------+
|    1 | turf     |
|    2 | f shrubs |
|    3 | b shrubs |
|    4 | patio    |
|    5 | garden   |
+------+----------+
+---------+-------------+------+-------+
| Program | Start Times | Days | Zones |
+---------+-------------+------+-------+
|    A    |     4:00    | MWF  | 2(15) |
|         |             |      | 3(15) |
|    B    |     3:00    | MSa  | 1(30) |
|    C    |     3:15    | even | 2(15) |
|         |             |      | 3(15) |
|         |             |      | 4(10) |
|         |             |      | 5(5)  |
+---------+-------------+------+-------+

That second table contains all of the information I need to reprogram
the timer quickly. This simpler output mode is implemented in
simple.py in two functions.

show_zones() prints the table with the names and id numbers of
the watering zones, taken from the input YAML file.

def show_zones(data):
    t = prettytable.PrettyTable(
        field_names=('Zone', 'Name'),
        print_empty=False,
    )
    t.padding_width = 1
    t.align['Zone'] = 'r'
    t.align['Name'] = 'l'

    for z in sorted(data['zones'].items()):
        t.add_row(z)

    print t.get_string()

It starts by building a PrettyTable object, configured with
two columns. Then it adds one row at a time to the table, where the
data for each row is held in a tuple with two members. The
get_string() method of the table returns the formatted results,
complete with headings and decorations.

The program list is a little more complex, since some cells of the
table have multiple lines. PrettyTable handles that easily,
but I need to build the multi-line strings myself by combining the
zone data.

def show_programs(data):
    t = prettytable.PrettyTable(
        field_names=('Program', 'Start Times', 'Days', 'Zones'),
        print_empty=False,
    )
    t.padding_width = 1
    t.align['Zones'] = 'l'

    for p, pdata in sorted(data['programs'].items()):
        zones = 'n'.join('%(zone)s(%(time)s)' % z
                          for z in sorted(pdata['zones'],
                                          key=operator.itemgetter('zone')))
        t.add_row((p, 'n'.join(pdata['start']), pdata['days'],
                   zones))
    print t.get_string()

Adding Algorithms to Data

The simple output format works with the data in the YAML data
structure directly. The processing it does is very basic, since it is
primarily formatting the existing values. For the calendar view, I
knew I would need some more complex algorithms. I have several
different rules to apply to decide if a program should be included in
the output for a given day, for example, and I want to compute and
show the actual start and end time for each watering event, not just
the program start time. I decided to create a Program class
to help with some of those calculations.

class Program(object):

    def __init__(self, name, pdata):
        self.name = name
        self.data = pdata
        self.days = pdata['days']
        self._day_checker = self._make_day_checker(self.days)

    def _make_day_checker(self, s):
        """Parse a 'days' string

        A days string either contains 'odd', 'even', or 1-2 letter
        abbreviations for the days of the week.
        """
        if s == 'odd':
            return lambda dow, dom: bool(dom % 2)
        elif s == 'even':
            return lambda dow, dom: not bool(dom % 2)
        else:
            valid=[
                self._day_abbr[m]
                for m in re.findall('([MTWF]|Tu|Th|Sa|Su)', s)
            ]
            return lambda dow, dom, valid=valid: dow in valid

    _day_abbr = {
        'M': calendar.MONDAY,
        'T': calendar.TUESDAY,
        'Tu': calendar.TUESDAY,
        'W': calendar.WEDNESDAY,
        'Th': calendar.THURSDAY,
        'F': calendar.FRIDAY,
        'Sa': calendar.SATURDAY,
        'Su': calendar.SUNDAY,
    }

    def occurs_on_day(self, dow, dom):
        """Tests whether the program runs on a given day.

        :param dow: Day of week
        :param dom: Day of month
        """
        return self._day_checker(dow, dom)

The first piece of data I addressed was the rules for which days a
program is active. I have three different modes, and I knew I didn’t
want to test the mode each time a date was checked because the mode
doesn’t change after the YAML file is parsed. I decided to define
_make_day_checker() a factory method that returns a callable to
perform the test. For the “odd” and “even” modes, it returns a
function that looks at the day of the month to see if it is odd or
even respectively. For the explicit day list, I use a regular
expression to parse the string into individual abbreviations, and then
convert those to numbers using a dictionary that maps between the
abbreviations and values from calendar. The public API
occurs_on_day() wraps the checker function.

Next I defined a property to sort the zones before returning them,
just in case I enter values out of order:

    @property
    def zones(self):
        """Returns the zones used in the program, sorted by zone id.
        """
        return sorted(self.data['zones'], key=operator.itemgetter('zone'))

Another property converts the string representation of the program
start times to datetime.time instances, which are easier to
manipulate and use for sorting:

    @property
    def start_times(self):
        return sorted(datetime.datetime.strptime(t, '%H:%M').time()
                      for t in self.data['start'])

A final property produces a series of run time values with the start
and end times as well as the zone id. It is used to build the schedule
part of a calendar cell, which shows the times and zones when the
sprinklers are running. I perform the calculations to find the start
and end times myself, because datetime.time objects do not
work with datetime.timedelta objects.

    @property
    def run_times(self):
        """Returns iterable of start, end, and zone name tuples.
        """
        for s in self.start_times:
            for z in self.zones:
                # FIXME: Convert to datetime and use timedelta?
                h, m = s.hour, s.minute
                m += z['time']
                h += m / 60
                m = m % 60
                e = datetime.time(h, m)
                yield s, e, z['zone']
                s = e

Building the Calendar

With Program in place, the next task was to figure out how to
construct the calendar grid. I knew that Python includes a calendar
module
, and that I could have it give me a list of weeks containing
the days of the month. To build my table, then, I would just need to
iterate over the weeks and days, deciding what to put in each cell.

I started by setting up the data I would be working with and the table
object.

def show(args, data):
    programs = [Program(*p) for p in data['programs'].items()]
    programs.sort(key=lambda p: p.start_times[0])

    t = prettytable.PrettyTable(
        field_names=calendar.day_abbr,
        print_empty=False,
        hrules=prettytable.ALL,
    )
    t.align = 'l'

    cal = calendar.Calendar(calendar.MONDAY)
    month_data = cal.monthdays2calendar(args.year, args.month)

Each row of the calendar is based on a week, and each cell is a
day. There are two nested loops to iterate over the calendar days and
determine the cell and row contents. Some weeks contain days from
multiple months, the end of one month and the beginning of the
next. The output of monthdays2calendar() reports the day of the
month as 0 for days in a week that fall outside of the current
month in either direction, and I skip them in the output (filling the
cell with a blank string to preserve the table structure).

    for week in month_data:
        row = []
        for dom, dow in week:
            if not dom:
                # Zero days are from another month; leave the cell blank.
                row.append('')
                continue

For the remaining days, I loop over the programs that occur on that
day and place a watering event (with start time, end time, and zone)
on each line of the cell. The datetime.time values are
formatted to show only the hour and minutes, and the zone name is used
instead of the zone number so I don’t have to do that conversion in my
head as I read the calendar.

            # Show the day and all watering events on that day.
            lines = ['(%s)' % dom]
            for p in (p for p in programs
                      if p.occurs_on_day(dow, dom)):
                if args.verbose:
                    lines.append('')
                    lines.append('{name} ({days})'.format(name=p.name,
                                                          days=p.days))
                for s, e, z in p.run_times:
                    name = data['zones'][z]
                    lines.append(
                        '{s}-{e} - {name}'.format(
                            s=s.strftime('%H:%M'),
                            e=e.strftime('%H:%M'),
                            name=name,
                        )
                    )
            row.append('n'.join(lines))
        t.add_row(row)

Before printing the table, I use its width to center the month name
over the top.

    formatted = t.get_string()
    # Center the name of the month over the output calendar.
    print 'n{:^{width}}n'.format(
        calendar.month_name[args.month],
        width=len(formatted.splitlines()[0]),
    )
    print formatted

Conclusion

With an hour of work, I was able to create a simple script to let me
visualize the watering schedule more clearly. I found one case of
potential over-watering, where a zone was in a program it shouldn’t
have been, and I was able to modify the timer’s programming to fix
that and make the other adjustments I needed very easily.