Blow Up by Black Swan

Python-複数の予定から空き時間を算出するプログラム(純粋に演算する方法)

以前、ビット演算を利用して複数の予定がある場合に空き時間を算出するプログラムについて書きましたが、今回は計算のしやすさを脇に置いといて時刻をそのまま利用するプログラムについてまとめました。

スケジュール調整のイメージは前回と同じで下記のように複数人や複数の予定から予定がない時間帯を求めるものです。

スケジュール調整例の図

以前の記事でも書いたように最初に思いついた今回の強引に計算を行う方法は、求める結果が出るコードになるまでかなり苦戦しました。当初はもっとスパゲッティーなコードだったのですが、今回の記事化にあたり見直した結果、少しはマシなスパゲッティにはなったのかなと思います(笑)。ただ、わかりにくにことには変わりはないので、コメント多めで算出する流れも画像で挿入しています。

今回、この記事を書いた理由はこのようなプログラムがググってもヒットしなかったからですが、おそらくこの方法よりももっとわかりやすい方法はあるんじゃないかと思うので、もしあれば教えて頂けると嬉しいです。

この記事がどなたかの参考になれば幸いです。

1.プログラム全般について

1-1. 実行環境と前提条件

今回のプログラムの諸環境は以下になります。

  • OS: MACOSX
  • Python: 3.7.3(jupyter lab で実行)
  • 利用した Python モジュール
    • python-dateutil: 2.8.0

今回も前提とする条件は前回と同じとします。

  • 10 時から 18 時の間で予定のない時間帯を求める
  • イベントデータ等は下記を利用(Google Calendar API の freebusy のレスポンスを整理したもの。clockinは対象時間の始め、clockoutは対象時間の終わりのこと)
events =[{"start": "2019-05-15T09:30:00+09:00","end": "2019-05-15T11:00:00+09:00"},
         {"start": "2019-05-15T10:30:00+09:00","end": "2019-05-15T12:00:00+09:00"},
         {"start": "2019-05-15T12:30:00+09:00","end": "2019-05-15T13:30:00+09:00"},
         {"start": "2019-05-15T15:30:00+09:00","end": "2019-05-15T16:30:00+09:00"},
         {"start": "2019-05-15T16:00:00+09:00","end": "2019-05-15T17:00:00+09:00"},
         {"start": "2019-05-16T10:00:00+09:00","end": "2019-05-16T11:00:00+09:00"},
         {"start": "2019-05-16T10:30:00+09:00","end": "2019-05-16T11:30:00+09:00"}]
clockin_time = "10:00"
clockout_time = "18:00"


Google Calendar API については以前記事にしていますので、よければ参考にして頂ければと思います。

1-2. プログラムの流れ

全体のプログラムの流れは次のようになります。イメージとしては、その下の図のようになります。

  1. イベントを日毎に分け、昇順(スタート時間が早い順)にソートする
  2. それぞれの日のイベントを順に比較し、予定がある時間帯を求める
  3. 2 の結果から任意の時間帯の中で予定のない時間帯を求める
予定のない時間帯を求めるプログラムのフロー図

ポイントとしては、1)どのように複数のイベントから予定のある時間帯を算出するか、2)その予定のある時間帯からどのようにして任意の時間帯の中で予定のない時間帯を求めるか、の2点です。

それでは次章からプログラムについて紹介していきます。

2.プログラム例

各役割ごとに関数を作成しており、実行用関数はget_free_periodです。実行フローは1-2で説明したものに沿っています。各フローの中でさらにオリジナル関数を利用していますが、そちらは以降で説明しています。

from datetime import timedelta, timezone
from dateutil.parser import parse

def get_free_period(events, clockin_time, clockout_time):
    # 1)イベントの整理(日にち単位で分類する) -> 2-1で説明
    dict_events = order_events(events)
    
    # 2)予定のある時間を求める
    keys = dict_events.keys()
    busy_period = {}
    for key in keys:
        # get_busyperiod関数 -> 2-2で説明
        list_busyperiod = get_busyperiod(dict_events[key])
        busy_period[key] = list_busyperiod
    
    # 3)決められた時間帯の中で予定のない時間を求める
    keys = busy_period.keys()
    free_period = {}
    for key in keys:
        clockin, clockout = time_calculator(key, clockin_time, clockout_time)
        # get_freeperiod -> 2-4で説明
        list_freeperiod = get_freeperiod(busy_period[key], clockin, clockout)
        free_period[key] = list_freeperiod
    
    return free_period


2-1.order_event関数

まずは、イベントを整理する関数です。この関数では、引数にイベントの開始と終了時間をもつ辞書を要素とするリストを取ります。この関数を実行することで、日にちごとにイベントを分けることができます。

def order_events(events):
    dict_events = {}
    for event in events:
        key = parse(event['start']).date().isoformat()
        if dict_events.get(key, None):
           dict_events[key].append(event)
        else:
            dict_events[key] = [event]
    return dict_events


この関数を前提となるデータを引数として実行した場合の戻り値は下記になります。

{
  '2019-05-15': [{'start': '2019-05-15T09:30:00+09:00', 'end': '2019-05-15T11:00:00+09:00'},
                 {'start': '2019-05-15T10:30:00+09:00', 'end': '2019-05-15T12:00:00+09:00'},
                 {'start': '2019-05-15T12:30:00+09:00', 'end': '2019-05-15T13:30:00+09:00'},
                 {'start': '2019-05-15T15:30:00+09:00', 'end': '2019-05-15T16:30:00+09:00'},
                 {'start': '2019-05-15T16:00:00+09:00', 'end': '2019-05-15T17:00:00+09:00'}],
  '2019-05-16': [{'start': '2019-05-16T10:00:00+09:00', 'end': '2019-05-16T11:00:00+09:00'},
                 {'start': '2019-05-16T10:30:00+09:00', 'end': '2019-05-16T11:30:00+09:00'}]
}

2-2. get_busyperiod関数

order_events関数で整理したイベントを、さらに各日付ごとイベントを順々に比較し、予定がある時間帯を求めていきます。上記のスケジュール図を見てもらえるとわかると思いますが、これは誰か一人、どれか1つでもイベントがある時間帯は「予定あり」の時間帯とする必要があります。そして、イベントの時間帯の一部でも重複している場合は、早い時間帯と遅い時間帯をそれぞれstart、endとする、「予定あり」の時間帯としてlist_busyperiodに格納していきます。なお、この関数はget_busy_period関数のfor文の中で利用されているので、1日分のイベントに対応する形でプログラムを組んでいます。

# 複数のイベントから忙しい時間帯を求める関数
def get_busyperiod(list_events):
    # スケジュールを昇順(スタート時間が早い順)でソート
    list_events = sorted(list_events, key=lambda s: s['start'])
    
    # イベントが1つだけの場合は時間帯の重複を計算する必要がないため、list_eventsを返す
    if len(list_events) == 1:
        return list_events
    
    # 複数のイベントが存在する場合、イベントを順に比較し重複する時間帯を求めていく
    list_busyperiod = []
    for i, event in enumerate(list_events):
        # 1) 1つ目のイベント -> 比較対象がないためlist_busyperiodに追加
        if i == 0:
            list_busyperiod.append(event)
        # 2) 2つ目以降のイベント -> list_busyperiodの1つ前のイベントと時間帯を比較
        else:
            # 2-1) list_busyperiodに登録された最後のイベントと被っている時間帯がある
            if list_busyperiod[-1]['start'] <= event['start'] <= list_busyperiod[-1]['end']:
                list_busyperiod[-1]['start'] = min(list_busyperiod[-1]['start'],event['start'])
                list_busyperiod[-1]['end'] = max(list_busyperiod[-1]['end'],event['end'])
            # 2-2) list_busyperiodに登録された最後のイベントと被っている時間帯がない
            else:
                list_busyperiod.append(event)
    return list_busyperiod  


この関数を実行した時の戻り値は下記になります。

# 5/15の場合
[{'end': '2019-05-15T12:00:00+09:00', 'start': '2019-05-15T09:30:00+09:00'},
 {'end': '2019-05-15T13:30:00+09:00', 'start': '2019-05-15T12:30:00+09:00'},
 {'end': '2019-05-15T17:00:00+09:00', 'start': '2019-05-15T15:30:00+09:00'}]

 # 5/16の場合
 [{'end': '2019-05-16T11:30:00+09:00', 'start': '2019-05-16T10:00:00+09:00'}]

2-3. time_calculator関数

これは、始業時間と就業時間など、予定のない時間帯を求めるための任意の時間帯の始まりと終わりの文字列の時刻を求める関数です。今回は10時始業、18時終業の企業で、就業時間内で空いている時間帯を探すイメージでプログラムを組んでいますが、13時から20時、15時から17時と任意の時間帯にセットすることもできます。前提となるデータでclockin_timeとclockout_timeという文字列の時間を格納する変数が与えられていますが、これを該当日の時刻に変換するのがこの関数の役割です。この関数についてもfor文の中で、実行されているので、5/15と5/16共にその都度、clockin、clockoutが算定されます。なお、戻り値は文字列になりますが、Pythonでは時刻が文字列であっても演算子を使って比較ができます。この後のプログラムで時刻を文字列型のまま扱っているため、clockin、clockout共に文字列型で返すようにしています。

# 区切り時間を計算する関数
def time_calculator(key, clockin_time, clockout_time):
    dt = parse(key)
    clockin_time = parse(clockin_time).time()
    clockout_time = parse(clockout_time).time()
    clockin = dt.replace(hour=clockin_time.hour,minute=clockin_time.minute, tzinfo=timezone(timedelta(hours=9)))
    clockout = dt.replace(hour=clockout_time.hour,minute=clockout_time.minute, tzinfo=timezone(timedelta(hours=9)))
    return clockin.isoformat(), clockout.isoformat()


戻り値は以下になります。

# 5/15のclockinとclockout
clockin: 2019-05-15T10:00:00+09:00, clockout: 2019-05-15T18:00:00+09:00

# 5/16のclockinとclockout
clockin: 2019-05-16T10:00:00+09:00, clockout: 2019-05-16T18:00:00+09:00

ちなみに文字列型の時刻の比較例は以下になります。ご自身の環境で実行してみれば、正しく比較演算がなされていることがわかると思います。

# 文字列の比較例
"2019-05-16T10:00:00+09:00" < "2019-05-16T18:00:00+09:00"

# 戻り値 => True

2-4.get_freeperiod関数

最後が、任意の時間帯で自由時間を求めるget_freeperiod関数です。コード量が多く、ネストが深い関数で正直、もっと簡潔なコードにできるんじゃないかと思いますが、私の現在のスキルとしてはこのレベルが限界でした。

この関数は対象の時間帯の中で、今まで求めた「予定あり」の時間帯から「予定のない」時間帯を求めます。ネストが深いので条件分岐のところにコメントを記述しています。

# 1日の中で空いている時間を求める
def get_freeperiod(list_busyperiod, clockin, clockout):
    list_busyperiod = sorted(list_busyperiod, key=lambda s: s['start'])
    #print("list_busyperiod: {}".format(list_busyperiod))
    list_freeperiod = []
    
    # itemは予定が入っている時間帯を指している
    for i, item in enumerate(list_busyperiod):
        #print("i: {}, item: {}".format(i, item))
        # list_freeperiodに要素が追加されていない場合
        if not list_freeperiod:
            #print(1)
            # 1) イベント(item)がclockinよりも早い時間で終了する
            if item['end'] <= clockin:
                continue
            # 2) イベント(item)がclockoutよりも遅く始まる
            elif clockout <= item['start']:
                return None  #要編集
            # 3) イベントがclockinからclockoutの時間帯を埋め尽くしている
            elif (item['start'] <= clockin) and (clockout <= item['end']):
                return 'BUSY'  #要編集
            # 4) それ以外
            else:
                #print(12)
                # 4-1)イベントがclockinよりも早く始まる場合
                if item['start'] <= clockin:
                    start = item['end']
                    # ①次の要素が存在しない場合
                    if i == len(list_busyperiod)-1:
                        end = clockout
                    # ②次の要素が存在する場合(イベントがclockinからclockoutの全時間帯を埋め尽くしている場合は(3)で除外している)
                    else:
                        end = list_busyperiod[i+1]['start']
                    list_freeperiod.append({'start': start, 'end': end})
                # 4-2)イベントがclockinよりも遅く始まる場合
                else:
                    # イベント前の空白時間を求める部分
                    start = clockin
                    end = item['start']
                    list_freeperiod.append({'start': start, 'end': end})
                    # イベント後から次回イベントまでの空白時間を求める部分
                    start = item['end']
                    # ①次の要素が存在しない場合、またはイベント(item)の終了時間がclockoutを超える場合
                    if (i == len(list_busyperiod)-1) or (clockout < item['end']):
                        end = clockout
                    else:
                        end = list_busyperiod[i+1]['start']
                    list_freeperiod.append({'start': start, 'end': end})

        #list_freeperiodに1つ以上の要素が存在する場合
        else:
            #print(2)
            # 1)イベントがclockoutよりも遅く終わる場合(自由時間はもう存在しない)
            if clockout <= item['end']:
                continue
            # 2)それ以外の場合
            else:
                start = item['end']
                # 2-1)最後のイベントの場合
                if i == len(list_busyperiod)-1:
                    end = clockout
                # 2-2)次のイベントの開始時間がclockoutよりも遅い時間の場合
                elif clockout < list_busyperiod[i+1]['start']:
                    end = clockout
                # 2-3) それ以外の場合
                else:
                    end = list_busyperiod[i+1]['start']
                list_freeperiod.append({'start': start, 'end': end})
    return list_freeperiod


3.プログラムの実行

get_busy_period関数内で利用する関数は以上になります。最後にこの関数を1-1で紹介したデータを引数として実行してみます。

get_free_period(events, clockin_time, clockout_time)

戻り値は以下になります。

{'2019-05-15': [
    {'start': '2019-05-15T12:00:00+09:00', 'end': '2019-05-15T12:30:00+09:00'},
    {'start': '2019-05-15T13:30:00+09:00', 'end': '2019-05-15T15:30:00+09:00'},
    {'start': '2019-05-15T17:00:00+09:00', 'end': '2019-05-15T18:00:00+09:00'}
 ],
 '2019-05-16': [
     {'start': '2019-05-16T11:30:00+09:00', 'end': '2019-05-16T18:00:00+09:00'}
 ]
}

4.まとめ

複数のイベントから空き時間を求めるプログラムは以上になります。かなり様々な状況を考慮し、プログラムに落とし込む必要があったため、一つの機能を持ったプログラムとしては今までで一番大変でした。正直、プログラムとしてはわかりにくく、雑然としているので、正確に予定のない時間帯を求める必要がない場合は、冒頭で紹介したbit演算を利用した方法の方が圧倒的に使い勝手がよく、保守性が高いので、そちらも参考にして頂ければと思います。

読んで頂き、ありがとうございました。