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.

watering controller

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[])

    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 '' 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.

See also