Я календарь переверну: использование Workalendar для определения календарных дней

Привет, Хабр!

На связи Горбачёв Никита и Скиданова Анна, участники профессионального сообщества NTA.

Работа с календарными данными является неотъемлемой частью многих приложений и проектов, связанных с планированием событий, управлением ресурсами и анализом данных. Базовой библиотекой для работы с датами и временем является datetime, но иногда её функционала оказывается недостаточно для решения каких‑то задач, и приходится обращаться к сторонним библиотекам.

В посте решим задачу расчёта отклонений с помощью различных инструментов, в том числе с помощью библиотеки Workalendar, покажем преимущества её использования по сравнению с другими подходами и продемонстрируем доступный функционал.

d5d9448d75df8ba68a7b54c8c3908370.pngНавигация по постуО Workalendar кратко

Workalendar — это библиотека с открытым исходным кодом, которая представляет собой набор готовых календарей для разных стран и регионов, а также содержит функции для работы с праздниками, выходными и другими особыми датами. Библиотека предоставляет возможность создания пользовательских календарей и настройки правил для определения праздничных и выходных.

Постановка задачи

Имеется датасет, описывающий совершение неких операций. Он может содержать много вспомогательных столбцов, но нас интересуют следующие:

Также имеется строгий регламент, в котором прописаны правила закрытия операции. Так, если процесс открылся в рабочий день (кроме пятницы), то закрыт от должен быть не позднее 18:00 следующего дня. Если процесс начался в выходные или пятницу, то для закрытия выделяются 2 суток и 9 часов, то же самое касается и праздников. И, наконец, процесс, открытый в последний рабочий день месяца, должен быть закрыт не позднее, чем через 1 сутки и 20,5 часа. При этом важно заметить, что при подсчёте времени между открытием и закрытием учитываются только рабочие дни.

Данные условия могут быть представлены в виде следующей таблицы:

Рабочие дни (кроме пятницы)

Последний рабочий день месяца

Пятницы, субботы, воскресенья и праздничные дни

День начала процесса + 1д 18–00

(42 часа)

День начала процесса + 1д 20–30

(44.5 часа)

День начала процесса + 2д 9–00

(57 часов)

Суть задачи заключается в том, чтобы рассчитать отклонения во времени закрытия операций, выразив эти отклонения в часах.

Пример: если операция была открыта 09.10.2023 (время открытия всегда считается равным 00:00), а закрыта 10.10.2023 в 19:30, то отклонение будет составлять 1 час 30 минут.

Решение задачи различными инструментами

Excel

Первый инструмент, который приходит в голову, когда речь заходит об обработке данных — это Excel. Он есть практически на каждом компьютере.

Решение поставленной задачи можно свести к двум шагам:

  1. Подготовка вспомогательных данных, а именно определение, к какому дню недели относится дата, является ли она праздником или последним рабочим днём месяца.

  2. Непосредственно расчёт отклонений для каждой отдельной категории.

Для демонстрации подготовили небольшую таблицу, где содержатся все категории, для удобства восприятия, подписанные и выделенные цветом.

Исходные данные

Исходные данные

Определим день недели, прописав следующую функцию:

=ДЕНЬНЕД(B2;2)

Она возвращает день недели в виде цифры, параметр 2 указывает на то, что нумерация дней начинается с понедельника. Вместо этого также можно прописать ТЕКСТ (B2; «дддд»), и тогда вместо цифр будут возвращаться названия дней.

Теперь определим праздники. Здесь поджидает первый подводный камень — получить праздничные дни в Excel не так просто. Сам Excel не предоставляет нужного функционала, а на просторах интернета отсутствуют источники, из которых можно сделать простой и удобный импорт в нужном формате (если вы знаете — поделитесь в комментариях). Поэтому рассмотрим несколько подходов к решению проблемы:

  1. Путь упертого человека — открыть в интернете производственные календари и начать забивать праздники вручную. Этот способ неплох в случае, если вам нужно обработать 1–2 года. При большем разбросе дат процесс может затянуться. Что, помимо траты времени, может обернуться появление человеческого фактора — нажали не ту цифру, пропустили дату, и результаты искажены.

  2. Путь продвинутого человека — скачать и распарить производственные календари. Можно написать код самому или найти готовый, однако и то, и то решение может занять достаточное количество времени. Ведь чужой код — потемки, а для написания своего придется тратить время на изучение подходов и т. д.

  3. Путь человека ищущего — наиболее удачный вариант (по нашему скромному мнению), найденный на просторах интернета, где автор предлагает создать авто‑обновляемый производственный календарь — однажды написав функцию, вы будете обеспечены календарём на прошедшие и будущие годы. Сайт содержит подробное описание каждого шага, а также видео‑инструкцию, следуя которой у нас получилось реализовать данный вариант. Но и здесь есть недостатки: календарные данные берутся с сайта, который инициативные люди обновляют каждый год, следовательно, существует возможность прекращения его деятельности; представленные данные начинаются с 2013 года.

Предположим, что, используя один из предложенных методов, мы подгрузили праздники в новую колонку. Теперь можно рассчитать разницу во времени между датами:

=(ЧИСТРАБДНИ(B2+1;C2;$B$18:$B$32)*24*60+ЧАС(C2)*60+МИНУТЫ(C2))/60

Определим последние дни месяца. В данном случае сложность заключается в том, чтобы найти именно последний рабочий день.

=РАБДЕНЬ(ЕСЛИ(ДЕНЬНЕД(КОНМЕСЯЦА(B2;0);2)<6;КОНМЕСЯЦА(B2;0);КОНМЕСЯЦА(B2;0)-(ДЕНЬНЕД(КОНМЕСЯЦА(B2;0);2)-5))+1; -1; $B$18:$B$33)

Здесь используется функция КОНМЕСЯЦА () которая, возвращает последний день месяца. Затем полученная дата проверяется на принадлежность к выходным и к праздникам, при необходимости сдвигаясь на один или несколько дней назад. Таким образом, для каждой даты открытия будет получен последний рабочий день месяца, и затем их можно будет сравнить.

Создадим ещё один на этот раз бинарный столбец с метками, указывающими принадлежности даты открытия операции к праздникам:

=ЕСЛИ(ЕНД(ПОИСКПОЗ(B2;$B$18:$B$31;0));0;1)

Этап подготовки окончен. Осталось произвести непосредственно расчёт отклонений для каждого случая.

  1. Рабочие дни.

=ЕСЛИ(И(D2<5;F2<>B2;G2<>1);E2-42;0)
  1. Выходные и пятницы.

=ЕСЛИ(И(ИЛИ(D2=7;D2=5;D2=6);F2<>B2;G2<>1);E2-57;0)
  1. Последние дни месяца.

=ЕСЛИ(И(B2=F2;G2<>1);E2-44,5;0)
  1. Праздничные дни.

=ЕСЛИ(G2=1;E2-57;0)

Объединяем все в результирующий столбец, а затем приводим к легкому для восприятия виду.

=СУММ(ЕСЛИ(J2<0;0;J2);ЕСЛИ(H2<0;0;H2);ЕСЛИ(I2<0;0;I2);ЕСЛИ(K2<0;0;K2))
=ЦЕЛОЕ(L2)&"."&ТЕКСТ(L2/24;"мм")&""

Результирующая таблица

Результирующая таблица

Excel позволяет решать поставленную задачу, однако проблема, связанная с получение праздничных дней, может приводить к появлению ошибок.

DAX

DAX — это язык формул, используемый, например, Power BI и Power Pivot в Excel. Он позволяет создавать формулы для выполнения сложных вычислений и запросов к данным в таблицах. В статье «Калькулятор рабочих дней в DAX» уже рассматривалось решение задачи, похожей на нашу, а именно — подсчёт рабочих дней между датами для проверки выполнения задачи в срок.

При данном подходе также имеется проблема, связанная с отсутствием готового и удобного производственного календаря.

Holidays

Решим предложенную задачу с помощью языка программирования python, пока без использования workalender. Для начала установим библиотеку holidays, это модуль для определения праздников и выходных, который наиболее часто предлагается в сети при запросах по решению подобных задач.

Сразу пропишем все зависимости:

!pip install holidays

import pandas as pd
import datetime
from datetime import timedelta
from pandas.tseries.offsets import BMonthEnd
import holidays

После установки считываем обрабатываемую таблицу и создаём экземпляр класса для русского языка:

df_h = pd.read_excel('table.xlsx', usecols = ['дата открытия', 'дата закрытия'])
cal_h = holidays.RU()

Далее определим три метода.

Первый для определения праздников:

def is_holiday(date):
    return date in cal_h

Для нахождения последнего дня месяца:

def last_day(date):
    if date.day > 27:
        date = date - timedelta(days = 7)
    return BMonthEnd().rollforward(date)

Здесь используется метод BMonthEnd ().rollforward (date), который ищет последний рабочий день в месяце после указанной даты. У него есть недостаток — если ему на вход попадёт последний день месяца, который выпал на выходные, то он вернёт последний рабочий день следующего месяца (например, для даты 30.09.2023 он выдаст результат 31.10.2023). Поэтому необходимо проводить корректировку передаваемой даты.

Третий метод для расчета количества часов между открытием и закрытием:

def calculate_time(date1, date2):
    n_works_day = 0
    while (date1.date() != date2.date()):
        date1 += datetime.timedelta(days = 1)
        if(date1.date() not in cal_h and date1.weekday() not in [5, 6]):
            n_works_day += 1

    return n_works_day * 24 + date2.hour + date2.minute / 60

В цикле происходит пошаговая проверка всех дней, начиная от дня открытия и до закрытия, так как при подсчёте времени необходимо учитывать только рабочие дни.

Далее определим новые столбцы с метками категорий:

# день недели
df_h['День недели'] = df_h['дата открытия'].dt.weekday
# праздники
df_h['Holiday']= df_h['дата открытия'].apply(is_holiday)
# время между датами
 df_h['Время между датами'] = df_h.apply(lambda x: calculate_time (x['дата открытия'], x['дата закрытия']),axis = 1)

Теперь можно рассчитать отклонение для каждого случая. Для это из колонки 'Время между датами' отнимаем максимально допустимое время из таблицы 1. В таком случае, если норма превышена, значения получатся положительными, иначе — отрицательными.

# праздники
df_h.loc[(df_h['Holiday'] == True),'Отклонение в часах'] = df_h['Время между датами'] – 57
# последние дни
df_h.loc[(df_h['дата открытия'] == df_h['дата открытия'].apply(last_day)),'Отклонение в часах'] = df_h['Время между датами'] - 44.5
# рабочие дни
df_h.loc[((df_h['День недели'].apply(lambda x: x in [0, 1, 2, 3])) & (df_h['Время между датами'].isnull())),'Отклонение в часах'] = df_h['Время между датами'] - 42
# выходные дни
df_h.loc[((df_h['День недели'].apply(lambda x: x in [4, 5, 6])) & (df_h['Отклонение в часах'].isnull())),'Отклонение в часах'] = df_h['Время между датами'] - 57

Осталось заменить отрицательные значения на 0 или «нет» и готово.

df_h['Отклонение в часах'].loc[df_h['Отклонение в часах'] < 0] = 0

Workalendar

Наконец, попробуем решить задачу с помощью библиотеки Workalendar. Для этого установим библиотеку, зависимости и создадим экземпляр класса:

!pip install workalendar

import pandas as pd
import datetime
from datetime import timedelta
from datetime import datetime
from workalendar.europe import Russia

df_w = pd.read_excel('table.xlsx', usecols = ['дата открытия', 'дата закрытия'])
cal_w = Russia()

Аналогично предыдущему разделу определим метод для расчёта количества времени между датами. В данном случае это может быть написано практически в одну строку, так как в методе cal_w.get_working_days_delta (date1, date2) автоматически отсеиваются праздничные и выходные дни.

def calculate_time (date1, date2):
    n_works_day_between = cal_w.get_working_days_delta(date1.date(), date2.date())  
    return n_works_day_between * 24 + date2.hour + date2.minute / 60
# день недели
df_w['День недели'] = df_w['дата открытия'].dt.weekday
# время между датами
df_w['Время между датами'] = df_w.apply(lambda x: calculate_time(x['дата открытия'], x['дата закрытия']), axis = 1)

Cразу можно переходить к расчетам. Писать отдельные функции для получения праздничных и последних дней не будем, так как они реализуются одной строкой, которую можно вставить непосредственно в вычисления. В таком случае не будут плодиться лишние столбцы:

#праздники
df_w.loc[(df_w['дата открытия'].apply(lambda x: cal_w.is_holiday(x)) == True),'Отклонение в часах'] = df_w['Время между датами'] – 57
#последние дни
df_w.loc[(df_w['дата открытия'] == df_w['дата открытия'].apply(lambda x: cal_w.add_working_days(datetime(x.year, x.month % 12 + 1, 1), -1))),'Отклонение в часах'] = df_w['Время между датами'] - 44.5
#рабочие дни 
Df_w.loc[((df_w['День недели'].apply(lambda x: x in [0, 1, 2, 3])) & (df_w['Отклонение в часах'].isnull())),'Отклонение в часах'] = df_w['Время между датами'] - 42
#выходные дни
df_w.loc[((df_w['День недели'].apply(lambda x: x in [4, 5, 6])) & (df_w['Отклонение в часах'].isnull())),'Отклонение в часах'] = df_w['Время между датами'] - 57
# убираем отрицательные значения
df_w['Отклонение в часах'].loc[df_w['Отклонение в часах'] < 0] = 0

Готово.

Сравнение результатов Holidays и Workalendar

Сравним результаты, полученные последними двумя способами, построив график.

Если всё совпадет, то мы увидим только один набор точек.

import matplotlib.pyplot as plt
d = list(range(12))
fig, ax = plt.subplots(figsize = (10, 6))
ax.scatter(d, df_w['Отклонение в часах'], c = 'red', s = 105)
ax.scatter(d, df_h['Отклонение в часах'], s = 100)
plt.legend(['Workalendar', 'holidays'], loc = 'upper center', shadow = True, fontsize = 'x-large')

График сравнения результатов

График сравнения результатов

Как мы видим, присутствует одно несовпадение: библиотека holidays не определяет 13 июня 2016 года как праздник.

Проведя ещё несколько сравнений, можно сделать вывод, что библиотека holidays не учитывает переносы праздников, выпавших на выходные дни. Несколько примеров неправильного срабатывания приведено в таблице.

Дни

workalendar

holidays

1 июля 2020

True

False

2 мая 2022

True

False

6 ноября 2023

True

False

31 декабря 2021

True

False

14 июня 2021

True

False

Дополнительные методы библиотеки Workalendar (и много примеров)

Библиотека Workalendar имеет ещё несколько интересных методов, помимо тех, которые уже были использованы ранее.

В ней доступны календари для более чем 60 стран. Например, можно посмотреть праздники для Австралии.

from workalendar.oceania import Australia
cal_au = Australia()
cal_au.get_calendar_holidays(2023)
Output:
[(datetime.date(2023, 1, 1), 'New year'),
 (datetime.date(2023, 1, 26), 'Australia Day'),
 (datetime.date(2023, 4, 7), 'Good Friday'),
 (datetime.date(2023, 4, 10), 'Easter Monday'),
 (datetime.date(2023, 12, 25), 'Christmas Day'),
 (datetime.date(2023, 12, 26), 'Boxing Day'),
 (datetime.date(2023, 1, 2), "New Year's Day shift"),
 (datetime.date(2023, 4, 25), 'Anzac Day')]

Также для каждой страны, помимо праздничных дней, официально признанных нерабочими, можно получить и просто праздники, свойственные только этой стране или этой культуре. Продолжая с той же Австралией, можно получить День Канберры, который является государственным праздником, но при этом не является выходным.

cal_au.get_canberra_day(2000)
Output:
(datetime.date(2000, 3, 13), 'Canberra Day')

Для России представлено большое количество православных и католических праздников, например:

чистый понедельник

великая суббота

пепельная среда

Code

cal.get_clean_monday (2023)

 cal.get_easter_monday (2023)

cal.get_ash_wednesday (2001)

Output

datetime.date (2023, 2, 27)

datetime.date (2023, 4, 17)

datetime.date (2001, 2, 28)

Функция cal.get_calendar_holidays (year) выводит даты и наименования всех праздников за указанный год (как это было показано выше). Аналогом ему является cal.holideys (year), которая также возвращает праздники, но отсортированные.

cal.holidays(2023)
Output:
[(datetime.date(2023, 1, 1), 'New year'),
 (datetime.date(2023, 1, 2), 'Day After New Year'),
 (datetime.date(2023, 1, 3), 'Third Day after New Year'),
 (datetime.date(2023, 1, 4), 'Fourth Day after New Year'),
 (datetime.date(2023, 1, 5), 'Fifth Day after New Year'),
 (datetime.date(2023, 1, 6), 'Sixth Day after New Year'),
 (datetime.date(2023, 1, 7), 'Christmas'),
 (datetime.date(2023, 1, 8), 'Eighth Day after New Year'),
 (datetime.date(2023, 2, 23), 'Defendence of the Fatherland'),
 (datetime.date(2023, 3, 8), "International Women's Day"),
 (datetime.date(2023, 5, 1), 'The Day of Spring and Labour'),
 (datetime.date(2023, 5, 9), 'Victory Day'),
 (datetime.date(2023, 6, 12), 'National Day'),
 (datetime.date(2023, 11, 4), 'Day of Unity'),
 (datetime.date(2023, 11, 6), 'Day of Unity shift')]

Следующий метод позволяет получать фиксированные даты праздников. Чтобы было понятно, покажем её работу на примере дня единства. Если вывести все праздники за 2023 год (выше), то, кроме самого дня единства, выводится так же и 6 ноября, которое обозначено как сдвинутый день единства. В случае же использования cal.get_fixed_holidays (2023) данный праздник не указывается.

Метод cal.get_first_weekday_after (date, name_week_day) возвращает первый указанный день недели после переданной даты. Например, получим ближайший вторник после 6 октября 2023 года, где TUE предварительно подгружается из библиотеки:

from workalendar.core import TUE
cal.get_first_weekday_after(datetime(2023, 10, 6), TUE)
Output:
datetime.datetime(2023, 10, 10, 0, 0)

Также, говоря о праздниках, можно получать только даты с помощью cal.holidays_set (year) или только наименования — cal.get_holiday_label (datetime (year, month, day))

Кроме того, библиотека позволяет получить n‑ый день недели в году (при этом нумерация дней недели начинается с понедельника):

# получим 40-вое воскресенье в 2023 году
Cal.get_iso_week_date(2023, 40, 7)
Output:
datetime.date(2023, 10, 8)

Метод cal.get_last_weekday_in_month (year, month, number_week_day) позволяет получить дату последнего дня недели в месяце, например, получим последнюю среду в октябре 2023 года. Или же получить n-ный указанный день недели в месяце, в данном случае добавляется n=2, которая показывает какую по счёту среду мы хотим получить:

# последняя среда в октябре 2023
cal.get_last_weekday_in_month(2023, 10, 2)
Output:
datetime.date(2023, 10, 25) 
# вторая среда в октябре 2023
cal.get_nth_weekday_in_month(2023, 10, 2, 2)
Output:
datetime.date(2023, 10, 11)

Метод cal.is_holiday (date), который проверяет является ли данная дата праздником. Существует и метод cal.is_working_day (date), который проверяет что день является рабочим.

cal.is_working_day(datetime(2023, 3, 8))
Output:
False

Метод add_working_day () позволяет добавлять рабочие дни к дате, а есть метод, позволяющий их отнимать:

cal.sub_working_days(datetime(2023,10,8), 1) 
Output:
datetime.date(2023, 10, 6)

Выходим на новый уровень: добавление собственных праздников

Плюс данной библиотеки заключается в том, что она имеет открытый исходный код, и сами разработчики говорят о возможности добавления собственных флагов или праздников. Это может быть полезно в тех случаях, когда праздничный день является не строгим и становится нерабочим по решению главы компании или организации.

Попробуем добавить несколько своих праздников.

Для этого скачаем библиотеку. Откроем класс, который нужно изменить. В нашем случае это класс Russia, который в библиотеке можно найти по следующему пути: workalendar_master\\workalendar\\europe\\russia.py.

Откроем и определим в нём метод get_variable_days (self, year) следующим образом:

def get_variable_days(self, year):
    days = super().get_variable_days(year)
    days.append(
            (Russia.get_nth_weekday_in_month(year, 12, FRI, 2), 'Second Friday'),)
    days.append(
            (date(2022, 10, 13), 'Not Bad Day'),)
    return days

Здесь мы указываем постоянный праздник, который выпадает на каждую вторую пятницу декабря, и называем его «Second Friday». Также указываем единоразовый праздничный день 13 октября 2022 года с названием «Not Bad Day».

Также можно использовать параметры, чтобы добавленный нами день определялся только при выполнении какого‑то условия. Для этого создадим конструктор класса, где определим бинарный параметр для включения праздника и пропишем его добавление в методе get_variable_days ().

    def __init__(self, january_25 = False, **kwargs):
        super().__init__(**kwargs)
        self.january_25 = january_25
    def get_variable_days(self, year):
        days = super().get_variable_days(year)
        days.append(
            (Russia.get_nth_weekday_in_month(year, 12, FRI, 2), 'Second Friday'),)
        days.append(
            (date(2022, 10, 13), 'Not Bad Day'),)
        if self.january_25:
            days.append((date(year, 1, 25), "The best of the best days"))
        return days

После всех манипуляций нужно сохранить изменения, и подгрузить данную библиотеку с помощью команды pip install путь_до_библиотеки и можно пользоваться.

Итог

Кратко пройдемся по рассмотренным инструментам.

Excel и DAX — привычны для многих, позволяют получить точные результаты, но до этого заставляют хорошенько повозиться с загрузкой всех праздничных дней из внешних источников.

Библиотека Holidays предоставляет простой функционал для проверки дат на принадлежность к праздникам и получения списка праздничных дней, но при этом содержит в себе некорректные данные. Кроме того, библиотека, по сути, возвращает словарь фиксированных праздников и не предоставляет практически никакого дополнительного функционала для работы с датами. Из плюсов: в получаемый словарь легко можно добавить свои праздники, однако для каждого нового скрипта это действие придется повторять.

Workalendar также предоставляет простой метод для проверки дней на принадлежность к праздникам, но при этом имеет гораздо более точный календарь, который учитывает переносы праздников, выпавших на выходные, рассчитывает праздники с плавающей датой и т. д. Обладает большим набором различных функций, которые значительно облегчают работу с датами и позволяют сократить объем кода до пары строк. Разработчиками библиотеки приводится подход к добавлению новых дней, которые, прописав однажды, в дальнейшем можно будет включать и выключать с помощью флага.

© Habrahabr.ru