Dates, triangles, and Python

Tuesday morning, which was November 28, John D. Cook started a post with

The numbers in today’s date—11, 28, and 23—make up the sides of a triangle. This doesn’t always happen; the two smaller numbers have to add up to more than the larger number.

He went on to figure out the angles of the plane triangle with side lengths of 11, 28, and 23 and then extended his analysis to triangles on a sphere and a pseudosphere. But I got hung up on the quoted paragraph. Which days can and can’t be the sides of a triangle? And how does the number of such “triangle days” change from year to year?

So I wrote a little Python to answer these questions.

python:
 1:  from datetime import date, timedelta
 2:  
 3:  def isTriangleDay(dt):
 4:    "Can the the year, month, and day of the given date be the sides of a triangle?"
 5:    y = dt.year % 100
 6:    m = dt.month
 7:    d = dt.day
 8:    sides = sorted(int(x) for x in (y, m, d))
 9:    return sides[0] + sides[1] > sides[2]
10:  
11:  def allDays(y):
12:    "Return a list of all days in the given year."
13:    start = date(y, 1, 1)
14:    end = date(y, 12, 31)
15:    numDays = (end - start).days + 1
16:    return [ start + timedelta(days=n) for n in range(numDays) ]
17:  
18:  def triangleDays(y):
19:    "Return a list of all the triangle days in the given year."
20:    return [x for x in allDays(y) if isTriangleDay(x) ]

isTriangleDay is a Boolean function that implements the test Cook described for a datetime.date object. Note that Line 5 extracts just the last two digits of the year, which is what Cook intends. You could, I suppose, change Line 9 to

python:
 9:    return sides[0] + sides[1] >= sides[2]

if you want to accept degenerate triangles, where the three sides collapse onto a single line. I don’t.

The allDays function uses a list comprehension to return a list of all the days in a given year, and triangleDays calls isTriangleDay to filter the results of allDays down to just triangle days. I think both of these functions are self-explanatory.

With these functions defined, I got all the triangle days for 2023 via

python:
print('\n'.join(x.strftime('%Y-%m-%d') for x in triangleDays(2023)))

which returned this list of dates (after reshaping into four columns):

2023-01-23     2023-06-27     2023-09-19     2023-11-17
2023-02-22     2023-06-28     2023-09-20     2023-11-18
2023-02-23     2023-07-17     2023-09-21     2023-11-19
2023-02-24     2023-07-18     2023-09-22     2023-11-20
2023-03-21     2023-07-19     2023-09-23     2023-11-21
2023-03-22     2023-07-20     2023-09-24     2023-11-22
2023-03-23     2023-07-21     2023-09-25     2023-11-23
2023-03-24     2023-07-22     2023-09-26     2023-11-24
2023-03-25     2023-07-23     2023-09-27     2023-11-25
2023-04-20     2023-07-24     2023-09-28     2023-11-26
2023-04-21     2023-07-25     2023-09-29     2023-11-27
2023-04-22     2023-07-26     2023-09-30     2023-11-28
2023-04-23     2023-07-27     2023-10-14     2023-11-29
2023-04-24     2023-07-28     2023-10-15     2023-11-30
2023-04-25     2023-07-29     2023-10-16     2023-12-12
2023-04-26     2023-08-16     2023-10-17     2023-12-13
2023-05-19     2023-08-17     2023-10-18     2023-12-14
2023-05-20     2023-08-18     2023-10-19     2023-12-15
2023-05-21     2023-08-19     2023-10-20     2023-12-16
2023-05-22     2023-08-20     2023-10-21     2023-12-17
2023-05-23     2023-08-21     2023-10-22     2023-12-18
2023-05-24     2023-08-22     2023-10-23     2023-12-19
2023-05-25     2023-08-23     2023-10-24     2023-12-20
2023-05-26     2023-08-24     2023-10-25     2023-12-21
2023-05-27     2023-08-25     2023-10-26     2023-12-22
2023-06-18     2023-08-26     2023-10-27     2023-12-23
2023-06-19     2023-08-27     2023-10-28     2023-12-24
2023-06-20     2023-08-28     2023-10-29     2023-12-25
2023-06-21     2023-08-29     2023-10-30     2023-12-26
2023-06-22     2023-08-30     2023-10-31     2023-12-27
2023-06-23     2023-09-15     2023-11-13     2023-12-28
2023-06-24     2023-09-16     2023-11-14     2023-12-29
2023-06-25     2023-09-17     2023-11-15     2023-12-30
2023-06-26     2023-09-18     2023-11-16     2023-12-31

That’s 136 triangle days for this year. To see how this count changes from year to year, I ran

python:
for y in range(2000, 2051):
  print(f'{y}   {len(triangleDays(y)):3d}')

which returned

2000     0
2001    12
2002    34
2003    54
2004    72
2005    88
2006   102
2007   114
2008   124
2009   132
2010   138
2011   142
2012   144
2013   144
2014   144
2015   144
2016   144
2017   144
2018   144
2019   144
2020   144
2021   142
2022   140
2023   136
2024   132
2025   127
2026   120
2027   113
2028   104
2029    93
2030    82
2031    72
2032    61
2033    51
2034    41
2035    33
2036    25
2037    19
2038    13
2039     8
2040     5
2041     2
2042     1
2043     0
2044     0
2045     0
2046     0
2047     0
2048     0
2049     0
2050     0

I knew there was no point in checking on years later in the century—it was obvious that every year after 2042 would have no triangle days. As you can see, the 2010s were the peak decade for triangle days. We’re now in the early stages of a 20-year decline.

After doing this, I looked back at my code and decided that most serious Python programmers wouldn’t have done it the way I did. Instead of functions that returned lists, they would build allDays and triangleDays as iterators.1 Not because there’s any need to save space—the space used by 366 datetime.date objects is hardly even noticeable—but because that’s more the current style.

So to make myself feel more like a real Pythonista, I rewrote the code like this:

python:
 1:  from datetime import date, timedelta
 2:  
 3:  def isTriangleDay(dt):
 4:    "Can the the year, month, and day of the given date be the sides of a triangle?"
 5:    y = dt.year % 100
 6:    m = dt.month
 7:    d = dt.day
 8:    sides = sorted(int(x) for x in (y, m, d))
 9:    return sides[0] + sides[1] > sides[2]
10:  
11:  def allDays(y):
12:    "Iterator for all days in the given year."
13:    d = date(y, 1, 1)
14:    end = date(y, 12, 31)
15:    while d <= end:
16:      yield d
17:      d = d + timedelta(days=1)
18:  
19:  def triangleDays(y):
20:    "Iterator for all the triangle days in the given year."
21:    return filter(isTriangleDay, allDays(y))

isTriangleDay is unchanged, but allDays now works its way through the days of the year with a while loop and the yield statement, and triangleDays uses the filter function to iterate through just the triangle days.

Using these functions is basically the same as using the list-based versions, except that you can’t pass an iterator to len. So determining the number of triangle days over a range of years can be done by either by converting the iterator to a list before passing it to len,

python:
for y in range(2000, 2051):
  print(f'{y}   {len(list(triangleDays(y))):3d}')

or by using the sum command with an argument that produces a one for each element of triangleDays,

python:
for y in range(2000, 2051):
  print(f'{y}    {sum(1 for x in triangleDays(y))}')

The former sort of defeats the purpose of using an iterator, so I guess it’s better practice to use the latter, even though I find it weird looking.

It may well be that my perception of “real” Python programmers is wrong and they wouldn’t bother with yield and filter in such a piddly little problem as this. But at least I got some practice with them.


  1. A confession: I find it hard to distinguish between between the proper use of the terms generator and iterator. My sense is that generators provide a way of creating iterators. So once the function is written, do you have a generator, an iterator, or both?