简体   繁体   中英

Measuring months between two dates : legislative definition of months

I'm looking to build code that represents the definition of months in a piece of Australian legislation - the Interpretations Act (1987).

Please note that I am still a relative novice to Python.

The legal definition

The definition reads as follows:

(1) In any Act, month means a period: (a) starting at the start of any day of one of the calendar months; and. (b) ending: (i) immediately before the start of the corresponding day of the next calendar month; or. (ii) if there is no such day--at the end of the next calendar month.

I've been advised that this definition means that if the start of a month begins on 16/07/2019, for the purposes of a) for example, the relevant month does not conclude until 11:59:59:etc:pm on 15/08/2019 - or functionally, 16/08/2019.

For the purpose of b), then, the "end of a month" is defined at similarly 11:59:59:etc:pm on the relevant final day of the month. So if you have two dates - 31/08/2019 and 30/09/2019 - the relevant month does not conclude until 11:59:59:etc:pm on 30/09/2019 - or functionally, 01/10/2019.

I need to output the difference between two dates in months in order to reflect that the legislation I'm coding asks for a difference between two dates specifically in months.

I'm looking to do this with either datetime or datetime64 objects if possible, to avoid converting between variables unnecessarily.

What I've tried so far.

I've used the below code to find the difference between two dates in months, using relativedelta:

from datetime import datetime
from dateutil import relativedelta
date1 = datetime.strptime('2019-08-15', '%Y-%m-%d')
date2 = datetime.strptime('2020-02-05', '%Y-%m-%d')
r = relativedelta.relativedelta(date2, date1)
r.months + (12*r.years)
print(r)

My expected output for this is 5 months, as there are five complete months and then a fraction of a month that isn't completed by date2. This returns the expected result, and replicates the functionality of a) in the legislation.

However, when I try to replicate b) with the below code:

from datetime import datetime
from dateutil import relativedelta
date1 = datetime.strptime('2019-08-31', '%Y-%m-%d')
date2 = datetime.strptime('2019-11-30', '%Y-%m-%d')
r = relativedelta.relativedelta(date2, date1)
r.months + (12*r.years)
print(r)

This returns the result of 4 months. Because 2019-11-30 is not the end of the relevant calendar month, this is incorrect - I should be getting a result of 3 months for this code, as the month is not completed until 11:59:59:etc.

Expected results

Below are four test cases that I've used to test the results of this code.

from datetime import datetime
from dateutil import relativedelta
date1 = datetime.strptime('2019-08-25', '%Y-%m-%d')
date2 = datetime.strptime('2019-09-10', '%Y-%m-%d')
r = relativedelta.relativedelta(date2, date1)
r.months + (12*r.years)
r.months = 0

from datetime import datetime
from dateutil import relativedelta
date1 = datetime.strptime('2019-08-25', '%Y-%m-%d')
date2 = datetime.strptime('2019-09-25', '%Y-%m-%d')
r = relativedelta.relativedelta(date2, date1)
r.months + (12*r.years)
r.months = 1

from datetime import datetime
from dateutil import relativedelta
date1 = datetime.strptime('2019-08-31', '%Y-%m-%d')
date2 = datetime.strptime('2019-11-30', '%Y-%m-%d')
r = relativedelta.relativedelta(date2, date1)
r.months + (12*r.years)
r.months = 3

from datetime import datetime
from dateutil import relativedelta
date1 = datetime.strptime('2019-08-31', '%Y-%m-%d')
date2 = datetime.strptime('2019-12-01', '%Y-%m-%d')
r = relativedelta.relativedelta(date2, date1)
r.months + (12*r.years)
r.months = 4

EDIT: I've written the inputs for the second two test cases, and after reviewing Alain T.'s response, have revised to the below.

from datetime import datetime
from dateutil import relativedelta
date1 = datetime.strptime('2019-08-01', '%Y-%m-%d')
date2 = datetime.strptime('2019-11-30', '%Y-%m-%d')
r = relativedelta.relativedelta(date2, date1)
r.months + (12*r.years)
r.months = 3

from datetime import datetime
from dateutil import relativedelta
date1 = datetime.strptime('2019-08-01', '%Y-%m-%d')
date2 = datetime.strptime('2019-12-01', '%Y-%m-%d')
r = relativedelta.relativedelta(date2, date1)
r.months + (12*r.years)
r.months = 4

from datetime import datetime
from dateutil import relativedelta
date1 = datetime.strptime('2019-08-31', '%Y-%m-%d')
date2 = datetime.strptime('2019-12-01', '%Y-%m-%d')
r = relativedelta.relativedelta(date2, date1)
r.months + (12*r.years)
r.months = 3

This can be calculated without converting to date types except for the edge case where dates are the last day of the month (where they actually correspond to day zero of the next month).

from datetime import date

def isLastDay(y,m,d):
    return date.fromordinal(date(y,m,d).toordinal()+1).month != m

def legalMonthDif(date1,date2):
    y1,m1,d1 = map(int,date1.split("-"))
    y2,m2,d2 = map(int,date2.split("-"))
    if isLastDay(y1,m1,d1): m1,d1 = m1+1,0
    if isLastDay(y2,m2,d2): m2,d2 = m2+1,0
    return y2*12+m2 -y1*12-m1 -(d2<d1)

output:

legalMonthDif('2019-08-15','2020-02-05') #5
legalMonthDif('2019-08-31','2019-11-30') #3
legalMonthDif('2019-08-25','2019-09-10') #0
legalMonthDif('2019-08-25','2019-09-25') #1
legalMonthDif('2019-08-31','2019-11-30') #3
legalMonthDif('2019-08-01','2019-12-01') #4 
legalMonthDif('2019-08-31','2019-12-01') #3
legalMonthDif('2019-08-15','2019-12-01') #3

You could also do it completely without the datetime library by implementing a daysOfMonth function to compute the number of days in any month:

def daysOfMonth(y,m):
    return 30+(m+m//8)%2-(m==2)*(2-(y%4==0 and not y%100==0 or y%400==0))

def legalMonthDif(date1,date2):
    y1,m1,d1 = map(int,date1.split("-"))
    y2,m2,d2 = map(int,date2.split("-"))
    if daysOfMonth(y1,m1) == d1: m1,d1 = m1+1,0
    if daysOfMonth(y2,m2) == d2: m2,d2 = m2+1,0
    return y2*12+m2 -y1*12-m1 -(d2<d1)
dates = [('2019-07-16','2019-08-15'),('2019-08-31','2019-09-30'),
         ('2019-08-15','2020-02-05'),('2019-08-31','2019-11-30'),
         ('2019-08-25','2019-09-10'),('2019-08-25','2019-09-25'),
         ('2019-08-31','2019-12-01'),('2019-08-15' , '2019-12-01'),
         ('2019-08-01', '2019-11-30'),('2019-08-01', '2019-12-01')]

Using Pandas date-time functionality. This relies on the fact that adding months to a timestamp will truncate to the end of the month if the resulting date doesn't exist - providing a means to test for the (b)(ii) part of the spec.

import pandas as pd

def f(a,b):
    earlier,later = sorted((a,b))
    rel_months = later.month - earlier.month
    delta_months = rel_months + (later.year - earlier.year) * 12
    period_end = earlier + pd.DateOffset(months=delta_months)

    # sentinals for implementing logic of (b)(ii) of the definition
    period_end_isEOM = period_end + pd.tseries.offsets.MonthEnd(0)
    later_isEOM = later == later + pd.tseries.offsets.MonthEnd(0)
    next_month = period_end + pd.tseries.offsets.MonthBegin(0)

    # begin with the delta - period_end == later - then adjust
    months = delta_months
    # this is straightforward
    if period_end > later:
        months -= 1

    # did period_end get truncated to the end of a month
    if period_end_isEOM and (period_end.day < earlier.day):
        # actual end of period would be beginning of next month
        if later < next_month:    # probably also means later_isEOM or later == period_end
            months -= 1
    return months 

for a,b in dates:
   a, b = map(pd.Timestamp, (a,b))
   c = f(a,b)
   print(f'{a.date()} - {b.date()} --> {c}')

>>>
2019-07-16 - 2019-08-15 --> 0
2019-08-31 - 2019-09-30 --> 0
2019-08-15 - 2020-02-05 --> 5
2019-08-31 - 2019-11-30 --> 2
2019-08-25 - 2019-09-10 --> 0
2019-08-25 - 2019-09-25 --> 1
2019-08-31 - 2019-12-01 --> 3
2019-08-15 - 2019-12-01 --> 3
2019-08-01 - 2019-11-30 --> 3
2019-08-01 - 2019-12-01 --> 4
>>> 

pd.TimeStamp is an instance of datetime.datetime

This appears to work - only the OP can judge - but I can't help thinking that there is some builtin functionality I'm still not utilizing. Should be able to subclass pandas.DateOffset and customize it to make the calcs easier.


Solutions using a subclass of Pandas.DateOffset.

from pandas import DateOffset, Timestamp
from pandas.tseries.offsets import MonthBegin

class LegislativeMonth(DateOffset):
    def __init__(self, n=1, normalize=False, months=1):
        # restricted to months
        kwds = {'months':months}
        super().__init__(n=1, normalize=False, **kwds)
    def apply(self,other):
        end_date = super().apply(other)
        if end_date.day < other.day:
            # truncated to month end
            end_date = end_date + MonthBegin(1)
        return end_date

for a,b in dates:
   earlier,later = sorted(map(Timestamp, (a,b)))
   delta_months = later.month - earlier.month
   delta_months += (later.year - earlier.year) * 12
   end_of_period = earlier + LegislativeMonth(months=delta_months)
   if end_of_period > later:
       delta_months -= 1
   print(f'{earlier.date()} - {later.date()} --> {delta_months}')

# another

one_month = LegislativeMonth(months=1)
for a,b in dates:
   earlier,later = sorted(map(Timestamp, (a,b)))
   end_period = earlier
   months = 0
   while later >= end_period + one_month:
       months += 1
       end_period += one_month
   print(f'{earlier.date()} - {later.date()} --> {months}')

Finally it looks like relativedelta will do what you want if you ensure that it is called with the earlier date as the first item - (earlier,later)

from datetime import datetime
from dateutil.relativedelta import relativedelta

for a,b in dates:
##   earlier,later = sorted(map(Timestamp, (a,b)))
    earlier,later = sorted((datetime.strptime(a, '%Y-%m-%d'),
                            datetime.strptime(b, '%Y-%m-%d')))
    rd = relativedelta(earlier,later)
    print(f'{earlier.date()} - {later.date()} --> {abs(rd.months)}')

Using the dates at the top of this post all print the following:

2019-07-16 - 2019-08-15 --> 0
2019-08-31 - 2019-09-30 --> 0
2019-08-15 - 2020-02-05 --> 5
2019-08-31 - 2019-11-30 --> 2
2019-08-25 - 2019-09-10 --> 0
2019-08-25 - 2019-09-25 --> 1
2019-08-31 - 2019-12-01 --> 3
2019-08-15 - 2019-12-01 --> 3
2019-08-01 - 2019-11-30 --> 3
2019-08-01 - 2019-12-01 --> 4

I ended up writing the below functions which capture the intended functionality of this legislation:

def find_corresponding_date(start_date):
day = start_date.day
month = start_date.month
year = start_date.year
next_month = month + 1
next_year = year

if month == 12:
    next_month = 1
    next_year = year + 1
try:
    new_date = py_datetime(year=next_year, month=next_month, day=day)
except ValueError:
    next_month = next_month + 1
    if next_month == 13:
        next_month = 1
        next_year = next_year + 1
    new_date = py_datetime(year=next_year, month=next_month, day=1)
    return new_date

else:
    return new_date


def toPyDateTime(numpyDate):
    return py_datetime.strptime(str(numpyDate), "%Y-%m-%d")


def count_months(sdate, edate):
    start_date = toPyDateTime(sdate)
    end_date = toPyDateTime(edate)
    count = 0
    corres_date = start_date
    while(True):
        corres_date = find_corresponding_date(corres_date)
        if(corres_date > end_date):
            return count
            break
        else:
            count = count + 1

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM