What's the Best Way to Find the Inverse of Datetime.Isocalendar()

What's the best way to find the inverse of datetime.isocalendar()?

Python 3.8 added the fromisocalendar() method:

>>> datetime.fromisocalendar(2011, 22, 1)
datetime.datetime(2011, 5, 30, 0, 0)

Python 3.6 added the %G, %V and %u directives:

>>> datetime.strptime('2011 22 1', '%G %V %u')
datetime.datetime(2011, 5, 30, 0, 0)

Original answer

I recently had to solve this problem myself, and came up with this solution:

import datetime

def iso_year_start(iso_year):
"The gregorian calendar date of the first day of the given ISO year"
fourth_jan = datetime.date(iso_year, 1, 4)
delta = datetime.timedelta(fourth_jan.isoweekday()-1)
return fourth_jan - delta

def iso_to_gregorian(iso_year, iso_week, iso_day):
"Gregorian calendar date for the given ISO year, week and day"
year_start = iso_year_start(iso_year)
return year_start + datetime.timedelta(days=iso_day-1, weeks=iso_week-1)

A few test cases:

>>> iso = datetime.date(2005, 1, 1).isocalendar()
>>> iso
(2004, 53, 6)
>>> iso_to_gregorian(*iso)
datetime.date(2005, 1, 1)

>>> iso = datetime.date(2010, 1, 4).isocalendar()
>>> iso
(2010, 1, 1)
>>> iso_to_gregorian(*iso)
datetime.date(2010, 1, 4)

>>> iso = datetime.date(2010, 1, 3).isocalendar()
>>> iso
(2009, 53, 7)
>>> iso_to_gregorian(*iso)
datetime.date(2010, 1, 3)

Get date from ISO week number in Python

%W takes the first Monday to be in week 1 but ISO defines week 1 to contain 4 January. So the result from

datetime.strptime('2011221', '%Y%W%w')

is off by one iff the first Monday and 4 January are in different weeks.
The latter is the case if 4 January is a Friday, Saturday or Sunday.
So the following should work:

from datetime import datetime, timedelta, date
def tofirstdayinisoweek(year, week):
ret = datetime.strptime('%04d-%02d-1' % (year, week), '%Y-%W-%w')
if date(year, 1, 4).isoweekday() > 4:
ret -= timedelta(days=7)
return ret

Get a date based on week number (python)

We create an initial datetime of for 2015 (2014-12-28 6:00:00 --1st sunday of 1st week), and a timedelta object of 7 days:

w0 = datetime.datetime(2014,12,28,6)
d7 = datetime.timedelta(7)

Then you can simply add multiples of the timedelta object to the initial date like so (n would be your week number):

w0+(d7*n)

Python - Dealing with Dates

One thing to be aware of is that the datetime.datetime week number (what is represented by %Y in strptime & strftime) is 0-indexed. Whereas from your example data, you're using the 1-indexed version.

from datetime import datetime, timedelta
import pandas as pd

my_date = "2020 23"

start = datetime.strptime(my_date + " 5", "%Y %W %w").date() - timedelta(weeks=1)
dates = [start + timedelta(days=i) for i in range(14)]

date_strings = [d.strftime("%d-%m-%Y") for d in dates]
date_codes = ["{}{}_{}".format(*(d - timedelta(days=4)).isocalendar()) for d in dates]

dates_df = pd.DataFrame({"Year_Week": date_codes, "Date": date_strings})

There's quite a lot going on in some of that, so let's break it down:

Import required classes:

from datetime import datetime, timedelta
import pandas as pd

Set up array of required dates:

Firstly, we parse the input string, and we can immediately extract just the date component in the same line using .date():

datetime.strptime(my_date + " 5", "%Y %W %w").date()

As I mentioned above, the datetime week number is 0-indexed, so when we do strptime with %Y on 23, we get the 24th week. This means we then need to jump back by a week to get the day we actually wanted:

start = datetime.strptime(my_date + " 5", "%Y %W %w").date() - timedelta(weeks=1)

Finally we use the list comprehension as you had:

dates = [start + timedelta(days=i) for i in range(14)]

Create our DataFrame columns:

strftime() is the reverse of strptime(), and the format for your Date column is dd-mm-yyyy, which corresponds to the format string used here:

date_strings = [d.strftime("%d-%m-%Y") for d in dates]

The next line has the most going on at once:

  • Firstly, note that date and datetime objects have the isocalendar() method which returns a tuple of (ISO year, ISO week, ISO day). ISO weeks run from Monday=1 to Sunday=7, and weeks start numbering at 1, not 0.

  • Your "weeks" therefore exactly match but are shifted by 4 days to start with Friday=1. So each of your dates has the corresponding ISO week/day number of the day 4 days previously. So we shift your date back by 4 days and then extract the year/week/day numbers: d - timedelta(days=4)).isocalendar()

  • With "{}{}_{}".format() we set up a template to drop in the year/week/day values. Each pair of curly brackets {} indicates where each value passed to format() should be inserted into the string template. For example,

    "{}{}_{}".format(2020, 23, 4)

    would give us "202023_4", the code for 8th June 2020.

  • Using the * on the result from our .isocalendar() function call 'unpacks' the tuple to pass its values individually into format()

Putting it all together as a list comprehension, again using the list of dates we created before:

date_codes = ["{}{}_{}".format(*(d - timedelta(days=4)).isocalendar()) for d in dates]

Building the DataFrame

We pass the data in as a dictionary in the format {"Column Name": column_values_list}:

dates_df = pd.DataFrame({"Year_Week": date_codes, "Date": date_strings})


We could wrap the whole thing up as a function, which would also mean we don't have to use a string as our starting point - we can just pass in the right numbers directly:

from datetime import date, timedelta
import pandas as pd

def create_table(year, week, n=1):
start = date.fromisocalendar(year, week, 5)
dates = [start + timedelta(days=i) for i in range(n * 7)]
date_strings = [d.strftime("%d-%m-%Y") for d in dates]
date_codes = ["{}{}_{}".format(*(d - timedelta(days=4)).isocalendar()) for d in dates]
return pd.DataFrame({"Year_Week": date_codes, "Date": date_strings})

table = create_table(2020, 23, 2)
print(table)

Outputs:

   Year_Week        Date
0 202023_1 05-06-2020
1 202023_2 06-06-2020
2 202023_3 07-06-2020
3 202023_4 08-06-2020
4 202023_5 09-06-2020
5 202023_6 10-06-2020
6 202023_7 11-06-2020
7 202024_1 12-06-2020
8 202024_2 13-06-2020
9 202024_3 14-06-2020
10 202024_4 15-06-2020
11 202024_5 16-06-2020
12 202024_6 17-06-2020
13 202024_7 18-06-2020

Note that we have an optional third parameter n to say how many weeks we want to generate the table for (default is 1). Also, because we're passing the year and week number directly, we can use the built-in date.fromisocalendar() method, which is the reverse of the .isocalendar() method. This takes the year, week & day and returns the corresponding date directly.



Update: adaptions for Python 3.6 compatibility and flexible input

Getting start date in Python 3.6 without using .fromisocalendar()

date.fromisocalendar() was only introduced in Python 3.7, so if you're using an earlier version of Python you'll have to use the more complicated technique of writing out a string to then parse with strptime().

However, if you're using Python 3.6, there were some new formatting directives added for parsing ISO week dates that make this slightly easier and we can use the approach from this SO answer:

def date_from_isoweek(year, week, day):
return datetime.strptime(f"{year:04d} {week:02d} {day:d}", "%G %V %u").date()

We're using an f-string to create the date string to then parse in as a datetime, from which we extract the date component. The e.g. :02d after week inside the braces {} ensures that it's formatted correctly as a 2-digit decimal left-padded with 0 (which we need if our week number is between 1-9).

Allowing input as a start and end date

This is quite easy, as there's a built-in pandas function called date_range() which accepts a start and end parameter which can either be date/datetime objects or strings. It's designed for creating a datetime index, but it's very easy to turn it into a list of dates.

dates = pd.date_range(start, end).date.tolist()

Putting it together

If we refactor our code to separate out the part that creates the list of dates we want in our table, and the part that then formats them to create the data for our columns and puts it into our dataframe, we get this:

def create_table_from_dates(dates):
date_strings = [d.strftime("%d-%m-%Y") for d in dates]
date_codes = [(d - timedelta(days=4)).strftime("%G%V_%u") for d in dates]
return pd.DataFrame({"Year_Week": date_codes, "Date": date_strings})

def create_table_between_dates(start, end):
dates = pd.date_range(start, end).date.tolist()
return create_table_from_dates(dates)

def create_table_by_weeks(year, week, n=1):
friday_as_isoweek_string = f"{year:04d} {week:02d} 5"
start = datetime.strptime(friday_as_isoweek_string, "%G %V %u").date()
dates = [start + timedelta(days=i) for i in range(n * 7)]
return create_table_from_dates(dates)

table_by_weeks = create_table_by_weeks(2020, 23, 2)
table_from_range = create_table_between_dates("2020-06-05", "2020-06-28")

The create_table_by_weeks() has the same signature as our create_table() function from the original answer. The create_table_between_dates() accepts a start and end date, either as date objects or strings. Both of these functions produce the list of dates for the table, and then pass them on to the create_table_from_dates() function (at the top) to create the DataFrame.

Changing the format of the output strings

The part of the code that determines what the Year_week column looks like is this line in the create_table_from_dates() function:

date_codes = [(d - timedelta(days=4)).strftime("%G%V_%u") for d in dates]

specifically the string "%G%V_%u" inside the strftime() method call. You can adjust this using the format codes set out in the table here: https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes

Remember: the way we get our codes is by cheating slightly: because your "weeks" are just the ISO calendar weeks, but shifted to
start on a Friday we just 'steal' the ISO week and day number from
four days previously. If you're just playing around with the order or
extra characters that's fine: changing "%G%V_%u" to "%u_%G%V" will
change 202023_1 to 1_202023. But if you wanted to include things
like the actual date or weekday, you'd have to make sure you got those
components from the true date (not the date 4 days previously).

date_codes = [
(d - timedelta(days=4)).strftime("%G%V_%u") + d.strftime(" %a %d %b")
for d in dates
]

would give us dates like 202023_1 Fri 05 Jun

If it is only the year/week/day you want to work with, we can extract this format string as a variable fmt, and pass it into create_table_from_dates() from the two other functions, and make it a keyword argument (with a default) for both of these:

def create_table_from_dates(dates, fmt):
date_strings = [d.strftime("%d-%m-%Y") for d in dates]
date_codes = [(d - timedelta(days=4)).strftime(fmt) for d in dates]
return pd.DataFrame({"Year_Week": date_codes, "Date": date_strings})

def create_table_between_dates(start, end, fmt="%G%V_%u"):
dates = pd.date_range(start, end).date.tolist()
return create_table_from_dates(dates, fmt)

def create_table_by_weeks(year, week, n=1, fmt="%G%V_%u"):
friday_as_isoweek_string = f"{year:04d} {week:02d} 5"
start = datetime.strptime(friday_as_isoweek_string, "%G %V %u").date()
dates = [start + timedelta(days=i) for i in range(n * 7)]
return create_table_from_dates(dates, fmt)

table = create_table_by_weeks(2020, 23, 2, fmt="%u_%G%V")
print(table)

Will give this output:

   Year_Week        Date
0 1_202023 05-06-2020
1 2_202023 06-06-2020
2 3_202023 07-06-2020
3 4_202023 08-06-2020
4 5_202023 09-06-2020
5 6_202023 10-06-2020
6 7_202023 11-06-2020
7 1_202024 12-06-2020
8 2_202024 13-06-2020
9 3_202024 14-06-2020
10 4_202024 15-06-2020
11 5_202024 16-06-2020
12 6_202024 17-06-2020
13 7_202024 18-06-2020

Getting a date from a week number

I think the Sunday vs. Monday distinction between weekday and strftime using %W is moot - you could use isoweekday to get those to line up, or %U in strftime if you wanted Sunday as the first day of the week. The real problem is that strftime, based on the underlying C function, determines the first week of the year differently than the ISO definition. With %W the docs say: " All days in a new year preceding the first Monday are considered to be in week 0". ISO calendars count the week containing the first Thursday as the first week, for reasons I do not understand.

Two ways I found to work with ISO weeks, either just getting datetime.date instances back or supporting a variety of operations, are:

  1. this answer with a simple timedelta approach:
    What's the best way to find the inverse of datetime.isocalendar()?
  2. this third-party library: https://pypi.python.org/pypi/isoweek/

How to derive the week start for a given (iso) weeknumber / year in python

If you're limited to stdlib you could do the following:

>>> datetime.datetime.strptime('2011, 4, 0', '%Y, %U, %w')
datetime.datetime(2011, 1, 23, 0, 0)

Calculate year week number subtracting week number in Python

Other than doing the math yourself, here's an attempt using datetime. Doing everything using ISO week numbers (using methods from this answer):

>>> import datetime
>>> date = '201642'
>>> weeks = 43
>>> year = date[:4]
>>> week = date[4:]

>>> start = iso_to_gregorian(int(year), int(week), 1)
>>> start
datetime.date(2016, 10, 17)
>>> start.isocalendar()
(2016, 42, 1)
>>> offset_weeks = datetime.timedelta(weeks=43)
>>> end = start - offset_weeks
>>> end
datetime.date(2015, 12, 21)
>>> end.isocalendar()
(2015, 52, 1)
>>> '{}{}'.format(*end.isocalendar())
'201552'

A modified version of @Hildy's answer below:

In [29]: start = datetime.datetime.strptime(date + '0', '%Y%W%w')

In [30]: start
Out[30]: datetime.datetime(2016, 10, 23, 0, 0)

In [31]: start.strftime('%Y%W')
Out[31]: '201642'

In [32]: end = start - datetime.timedelta(weeks=43)

In [33]: end
Out[33]: datetime.datetime(2015, 12, 27, 0, 0)

In [34]: end.strftime('%Y%W')
Out[34]: '201551'

Get Monday and Sunday and last year's Monday and Sunday, same week

If using dateutil is not a problem, just use it :)

The relativedelta is the object you need
Here you will be able to substract one year to the current date.

from datetime import *
from dateutil.relativedelta import *
NOW = datetime.now()
last_monday = NOW+relativedelta(years=-1, weekday=MO)
last_sunday = NOW+relativedelta(years=-1, weekday=SU)

How to calculate number of weeks between two ISO dates

Is this what you are looking for?

from datetime import date

def convert(value):
(year, week) = [int(x) for x in value.split('_')]
result = date.fromisocalendar(year, week, 1)
return result

def distance(date1, date2):
date1 = convert(date1)
date2 = convert(date2)
diff = date1 - date2
weeks = diff.days / 7
return weeks

print(distance('2021_01', '2020_53'))
print(distance('2020_40', '2020_30'))
print(distance('2021_01', '1991_01'))

Output

1.0
10.0
1566.0


Related Topics



Leave a reply



Submit