Blow Up by Black Swan

Python-複数の予定から空き時間を算出するプログラム(bit演算利用バージョン)

[2019.6.23追記: スケジュール調整のイメージ画像挿入]

スケジュールなどの時間を幅で取扱う際に、複数のカレンダーから共通して空いている時間帯を抽出したい時があると思います。Googleカレンダーや営業の会社で使われているようなカレンダー、イベント管理システムであれば、複数のカレンダーを横に揃えて表示させることで、簡単に空き時間を見つけることができます。一方で、人間の認識ではいとも簡単にできるこの空き時間帯を抽出する作業ですが、コードに落とし込むとなると「時間」というデータの特殊性からすんなりとはコードが書けなかったりします。というか私の場合、冗長なコードで強引に進める以外の方法が思い浮かばず、その強引なコードでさえ完成させるのに4日程かかりました。それまで徐々に悪くなっていった調子がこのコード作成作業のせいで完全に悪くなり、シャレにならないくらいの状況になってしまいました。笑(今はもう回復しつつあり大丈夫なのですが・・・笑)

[2019/6/23 追記]
スケジュール調整のイメージは、下記のようなスケジュールから予定がない時間を求めるものです。

スケジュール調整のイメージ図

これほど苦労したこの空き時間を探すプログラムですが、あるサイトを見たところ、すごく簡単でわかりやすい方法が書かれていました!!あんなに苦労したのに…との悔しさもある反面、その発想に感嘆し、家から飛び出て「エウレカ!!」と叫び出したくなりました。そのサイトはこの「GoでGoogle Calendar APIをいじって会議室を探す話」です。

ただ一点残念なところがあるとすれば、私がGoがわからないということだったので、今回はこのエウレカな記事を参考にPythonで複数の予定から空き時間を算出するプログラムについて紹介しようと思います。同じように考え苦しむ方が体を壊さないよう笑、少しでもなんらかの参考になれば幸いです。

1. 空き時間を算出するプログラムの諸条件について

まずは今回のプログラムの実行環境や構造について紹介します。

1-1. 実行環境

今回の実行環境は以下になります。

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

また、今回は以下のような状況を前提としています。

  • 10時から18時の間で空いている時間帯を求める
  • 30分を1ブロックとし、そのブロックに予定がない場合は”0″で、予定がある場合は”1″と表す
  • イベントデータは下記を利用(Google Calendar APIのfreebusyのレスポンスを整理したもの)
[{"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"}]

Google Calendar APIの使い方については下記記事でまとめていますので、良ければ参考にして下さい。

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

今回のプログラムの特徴は何と言ってもビット演算の利用です。はじめに紹介した記事で書かれている方法で、今回は30分を1ブロックとし、1ブロック内に予定(イベント)があるかどうかを次のように0と1の2つの方法で表しています。

  • 予定あり -> 1
  • 予定なし -> 0

この表現を用いた場合、例えば「{"start": "2019-05-15T10:30:00+09:00","end": "2019-05-15T12:00:00+09:00"}」のイベントの場合、次のように表現できます。

0111000000000000
 (01 11 00 00 00 00 00 00)

最初の「0」が起点となる10時から10時30分の予定を表し、次の「1」が10時30分から11時の予定を表す、というように30分のブロック単位でそれぞれ0と1のビットで表現されています。このビット表現を用いたプログラムの全体の流れは次のようになります。

  1. 各イベントをビット形式の表現に変える
  2. 各日ごとに、ビット形式に変更された各イベントの論理和を取り、全体として一つのビット表現に収束させる
  3. ビット形式の時間を通常の時間に戻す

既にご存知の方も多いと思いますが、「論理和」とは中学か高校の数学の「集合」分野で習う概念で、丸で表現された2つの領域に対しどちらかの領域に属している領域を求めるものです。次の図がそれです。

和集合の図

2. プログラム例

それでは、実際にプログラム例を紹介していきます。ビット形式に変えるプログラムとビット表現の時間を通常の時間表現に変える2つに分けます。なお、Pythonでのbit演算については次の記事が非常にわかりやすかったので、併せて参考にして頂ければと思います。

Python ビット演算 超入門

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

schedules=[{"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"}]

# スケジュールをビット型に変換する関数
def bit_schedule(schedules):
    bitdays = {}
    for schedule in schedules:
        // それぞれの予定をビット表現に変える関数を利用(下記に記載)
        date_key, date_bit = change_bit(schedule)
        
        # 同日の予定はbit演算の論理和を使って予定あり部分を埋めていく
        if date_key in bitdays.keys():
            bitdays[date_key] = bin(int(bitdays[date_key],0) | int(date_bit,0))
        
        # 同日の予定がまだ登録されていない場合はその日の予定をbitdays変数に新たに格納する
        else:
            bitdays[date_key] = date_bit
    return bitdays

# 個々のイベントをbit形式に変更する関数
def change_bit(date_item):
    # 予定がある時間帯
    start = parse(date_item['start'])
    end = parse(date_item['end'])
    
    # 空き時間を求めるために対象としたい時間帯
    clockin_time = parse("10:00").replace(year=start.year,month=start.month,day=start.day, tzinfo=start.tzinfo)
    clockout_time = parse("18:00").replace(year=start.year,month=start.month,day=start.day, tzinfo=start.tzinfo)
    
    # ビット処理するために必要な時間帯
    date_bit = "0b"  # Pythonでbit演算を行うために必要な記号

    check_duration = clockin_time
    for i in range(16):
        # 予定がある場合
        if start <= check_duration < end:
            date_bit += "1"
        # 予定がない場合
        else:
            date_bit += "0"
        if check_duration >= clockout_time:
            break
        check_duration += timedelta(minutes=30)
    date_key = start.date().isoformat()
    return date_key, date_bit

bitdays = bit_schedule(schedules)
bitdays

上記の戻り値は下記になります。5月15日と5月16日のそれぞれで、イベントのある時間帯を「1」としてbit形式で表現されていることがわかります。

{'2019-05-15': '0b1111011000011100', '2019-05-16': '0b1110000000000000'}

これで空いている時間帯がわかったので、もとの時間表現に戻します。今回は最初のschedules変数のような形式で空いている時間帯に戻しますが、他にも必要な会議時間を取れる時間帯だけを抽出したり、色々な使い方ができますので、ご自身の状況に合わせてプログラムを改良して頂ければと思います。

def back_to_datetime(bitdays):
    free_times = {}
    for key in bitdays.keys():
        date_bit = bitdays[key].replace('0b', '')
        clockin_time = parse(key).replace(hour=10, minute=0,second=0,tzinfo=timezone(timedelta(hours=9)))
        free_duration = []
        start = None
        end = None
        for i, bit in enumerate(date_bit):
            if not start:
                if bit == "0":
                    start = clockin_time + timedelta(minutes=30*i)
                    print('start:',start.isoformat())
            else:
                if bit == '1' or i == len(date_bit)-1:
                    if bit == '1':
                        end = clockin_time + timedelta(minutes=30*(i))
                    else:
                        end = clockin_time + timedelta(minutes=30*(i+1))
                    print('end: ',end.isoformat())
                    free_duration.append({'start':start.isoformat(), 'end':end.isoformat()})
                    start = None
                    end = None
        free_times[key] = free_duration
    return free_times

back_to_datetime(bitdays)

この関数の戻り値は下記です。

{'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'}]}

以上が今回のプログラムになります。

3. まとめ

今回はbit演算を用いて空いている時間帯を求める方法についてまとめました。当初はかなり苦戦しましたが、今回のプログラムのように今ではかなり簡単にプログラムすることができます。インターネットではなかなか乗っていない情報でしたので、もしかしたら同じようなことに悩まれた方は以外にも少ないのかもしれませんが、それでもたとえ少しの方にでも参考になれば幸いです。次回、気分が良い時に最初に考えついた強引に求める方法についてもまとめたいと思います。読んで頂き、ありがとうございました。