22
\$\begingroup\$

The basic idea here is that I want to measure the number of seconds between two python datetime objects. However, I only want to count hours between 8:00 and 17:00, as well as skipping weekends (saturday and sunday). This works, but I wondered if anyone had clever ideas to make it cleaner.

START_HOUR = 8 STOP_HOUR = 17 KEEP = (STOP_HOUR - START_HOUR)/24.0 def seconds_between(a, b): weekend_seconds = 0 current = a while current < b: current += timedelta(days = 1) if current.weekday() in (5,6): weekend_seconds += 24*60*60*KEEP a_stop_hour = datetime(a.year, a.month, a.day, STOP_HOUR) seconds = max(0, (a_stop_hour - a).total_seconds()) b_stop_hour = datetime(b.year, b.month, b.day, STOP_HOUR) if b_stop_hour > b: b_stop_hour = datetime(b.year, b.month, b.day-1, STOP_HOUR) seconds += (b - b_stop_hour).total_seconds() return (b_stop_hour - a_stop_hour).total_seconds() * KEEP + seconds - weekend_seconds 
\$\endgroup\$
2
  • \$\begingroup\$ What about other holidays? It sounds as if by excluding weekends you mean to exclude all free days. Note that in Arabic countries, Friday is not a work day, but sunday is. \$\endgroup\$ Commented Jan 15, 2020 at 1:04
  • \$\begingroup\$ @RolandIllig, good points, although my use case from 7 years ago wouldn't have been concerned about other possible holidays. \$\endgroup\$ Commented Jan 15, 2020 at 1:38

3 Answers 3

15
\$\begingroup\$

1. Issues

Your code fails in the following corner cases:

  1. a and b on the same day, for example:

    >>> a = datetime(2012, 11, 22, 8) >>> a.weekday() 3 # Thursday >>> seconds_between(a, a + timedelta(seconds = 100)) 54100.0 # Expected 100 
  2. a or b at the weekend, for example:

    >>> a = datetime(2012, 11, 17, 8) >>> a.weekday() 5 # Saturday >>> seconds_between(a, a + timedelta(seconds = 100)) 21700.0 # Expected 0 
  3. a after STOP_HOUR or b before START_HOUR, for example:

    >>> a = datetime(2012, 11, 19, 23) >>> a.weekday() 0 # Monday >>> seconds_between(a, a + timedelta(hours = 2)) 28800.0 # Expected 0 

Also, you count the weekdays by looping over all the days between the start and end of the interval. That means that the computation time is proportional to the size of the interval:

>>> from timeit import timeit >>> a = datetime(1, 1, 1) >>> timeit(lambda:seconds_between(a, a + timedelta(days=999999)), number=1) 1.7254137992858887 

For comparison, in this extreme case the revised code below is about 100,000 times faster:

>>> timeit(lambda:office_time_between(a, a + timedelta(days=999999)), number=100000) 1.6366889476776123 

The break even point is about 4 days:

>>> timeit(lambda:seconds_between(a, a + timedelta(days=4)), number=100000) 1.5806620121002197 >>> timeit(lambda:office_time_between(a, a + timedelta(days=4)), number=100000) 1.5950188636779785 

2. Improvements

barracel's answer has two very good ideas, which I adopted:

  1. compute the sum in seconds rather than days;

  2. add up whole days and subtract part days if necessary.

and I made the following additional improvements:

  1. handle corner cases correctly;

  2. run in constant time regardless of how far apart a and b are;

  3. compute the sum as a timedelta object rather than an integer;

  4. move common code out into functions for clarity;

  5. docstrings!

3. Revised code

from datetime import datetime, timedelta def clamp(t, start, end): "Return `t` clamped to the range [`start`, `end`]." return max(start, min(end, t)) def day_part(t): "Return timedelta between midnight and `t`." return t - t.replace(hour = 0, minute = 0, second = 0) def office_time_between(a, b, start = timedelta(hours = 8), stop = timedelta(hours = 17)): """ Return the total office time between `a` and `b` as a timedelta object. Office time consists of weekdays from `start` to `stop` (default: 08:00 to 17:00). """ zero = timedelta(0) assert(zero <= start <= stop <= timedelta(1)) office_day = stop - start days = (b - a).days + 1 weeks = days // 7 extra = (max(0, 5 - a.weekday()) + min(5, 1 + b.weekday())) % 5 weekdays = weeks * 5 + extra total = office_day * weekdays if a.weekday() < 5: total -= clamp(day_part(a) - start, zero, office_day) if b.weekday() < 5: total -= clamp(stop - day_part(b), zero, office_day) return total 
\$\endgroup\$
2
  • 3
    \$\begingroup\$ This code is broken for a couple of cases, for instance if you give it a Monday and Friday in the same week it gives a negative timedelta. The issue is with the calculation of extra but I don't have a solution yet. \$\endgroup\$ Commented Mar 16, 2017 at 6:22
  • \$\begingroup\$ @radman's comment on the error of Gareth Rees' is caused by 'extra' value. when the first day starts on Monday and ends on Friday or Saturday, this issue happens so need to adjust accordingly. so change extra = (max(0, 5 - a.weekday()) + min(5, 1 + b.weekday())) % 5 to if a.weekday()==0 and (b.weekday()==4 or b.weekday()==5): extra = 5 else: extra = (max(0, 5 - a.weekday()) + min(5, 1 + b.weekday())) % 5 \$\endgroup\$ Commented Jan 15, 2020 at 0:52
8
\$\begingroup\$

I think the initial calculation between the two dates looks cleaner using a generator expression + sum. The posterior correction is easier to understand if you do the intersection of hours by thinking in seconds of the day

from datetime import datetime from datetime import timedelta START_HOUR = 8 * 60 * 60 STOP_HOUR = 17 * 60 * 60 KEEP = STOP_HOUR - START_HOUR def seconds_between(a, b): days = (a + timedelta(x + 1) for x in xrange((b - a).days)) total = sum(KEEP for day in days if day.weekday() < 5) aseconds = (a - a.replace(hour=0, minute=0, second=0)).seconds bseconds = (b - b.replace(hour=0, minute=0, second=0)).seconds if aseconds > START_HOUR: total -= min(KEEP, aseconds - START_HOUR) if bseconds < STOP_HOUR: total -= min(KEEP, STOP_HOUR - bseconds) return total 
\$\endgroup\$
0
2
\$\begingroup\$

The code below is a bit of a hybrid between the two approaches mentioned. I think it should work for all scenarios. No work done outside working hours is counted.

from datetime import datetime from datetime import timedelta def adjust_hour_delta(t, start, stop): start_hour = start.seconds//3600 end_hour = stop.seconds//3600 zero = timedelta(0) if t - t.replace(hour = start_hour, minute = 0, second = 0) < zero: t = t.replace(hour = start_hour, minute = 0, second = 0) elif t - t.replace(hour = end_hour, minute = 0, second = 0) > zero: t = t.replace(hour = end_hour, minute = 0, second = 0) # Now get the delta delta = timedelta(hours=t.hour, minutes=t.minute, seconds = t.second) return delta def full_in_between_working_days(a, b): working = 0 b = b - timedelta(days=1) while b.date() > a.date(): if b.weekday() < 5: working += 1 b = b - timedelta(days=1) return working def office_time_between(a, b, start = timedelta(hours = 8), stop = timedelta(hours = 17)): """ Return the total office time between `a` and `b` as a timedelta object. Office time consists of weekdays from `start` to `stop` (default: 08:00 to 17:00). """ zero = timedelta(0) assert(zero <= start <= stop <= timedelta(1)) office_day = stop - start working_days = full_in_between_working_days(a, b) total = office_day * working_days # Calculate the time adusted deltas for the the start and end days a_delta = adjust_hour_delta(a, start, stop) b_delta = adjust_hour_delta(b, start, stop) if a.date() == b.date(): # If this was a weekend, ignore if a.weekday() < 5: total = total + b_delta - a_delta else: # We now consider if the start day was a weekend if a.weekday() > 4: a_worked = zero else: a_worked = stop - a_delta # And if the end day was a weekend if b.weekday() > 4: b_worked = zero else: b_worked = b_delta - start total = total + a_worked + b_worked return total 
\$\endgroup\$
2
  • \$\begingroup\$ Testing this for these two dates, I get 1 working day when it should be 4: 2018-02-26 18:07:17 and 2018-03-04 14:41:04 \$\endgroup\$ Commented Mar 6, 2018 at 18:52
  • \$\begingroup\$ You have presented an alternative solution, but haven't reviewed the code. Please edit to show what aspects of the question code prompted you to write this version, and in what ways it's an improvement over the original. It may be worth (re-)reading How to Answer. \$\endgroup\$ Commented Jan 15, 2020 at 8:10

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.