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