88363


土日を無いものとしてdatetimeに加算をするいい方法はありませんか？  
計算を1億回以上するので、高速な方法がいいです。  
よろしくお願いいたします。

計算例：

[code]

    datetime.datetime(2022, 4, 1, 23, 50, 00) # 2022年4月1日は金曜日
    
[/code]

上記に20分を足して以下の結果を得たい。

[code]

    datetime.datetime(2022, 4, 1, 23, 50, 00) + 20分
    > datetime.datetime(2022, 4, 4, 00, 10, 00)
    
[/code]

10日を足した場合は以下の結果を得たい。  
4/2(土),4/3(日),4/9(土),4/10(日)はカウントせずに、10日後の日時。

[code]

    datetime.datetime(2022, 4, 1, 23, 50, 00) + 10日
    > datetime.datetime(2022, 4, 15, 23, 50, 00)
    
[/code]

必要な要件：

  * 最小単位は分。分単位での加算ができること。
  * 加算する値は1分～100日。
  * 例えば100日足した場合は、その間に現れる土日は全て存在しないものとして計算する。
  * 1億回計算しても現実的な時間で完了すること。
  * 祝日は考慮しない。（追記）
  * 加算前が土日であることは無いものとする。（追記）


CC BY-SA 4.0
2022-04-17T02:17:12.063
88361
2022-05-13T15:41:46.293
2022-04-23T02:35:53.290
52255
52255
question
python
土日を無いものとしてdatetimeに加算をしたい


**更新**

その後、時分秒にも対応していそうな(説明のある)モジュールを見つけました。  
世界の休日(祝日)にも対応しているようです。

[businesstimedelta 1.0.1](https://pypi.org/project/businesstimedelta/)

> Python's timedelta for business time. This module helps you calculate the
> exact working time between two datetimes. It supports common scenarios such
> as custom schedules, holidays, and time zones.

[business-duration 0.66](https://pypi.org/project/business-duration/)

> Calculates business duration in days, hours, minutes and seconds by
> excluding weekends, public holidays and non-business hours

上記で休日対応のために使えると書いてあるモジュールがこちら。  
[holidays 0.13](https://pypi.org/project/holidays/)

> A fast, efficient Python library for generating country- and subdivision-
> (e.g. state or province) specific sets of government-designated holidays on
> the fly. It aims to make determining whether a specific date is a holiday as
> fast and flexible as possible.

もう一つありました。  
[businesstime 0.3.0](https://pypi.org/project/businesstime/)

> A simple utility for calculating business time aware timedeltas between two
> datetimes

**ただし**
どうもいずれのモジュールも就業時刻(開始・終了)を設定しなければならないようなので、土日だけを排除したいという使い方は出来ないかもしれませんね。  
あと2つの時刻の差分計算が主で、時間の加算/減算に使う意識は無いか少ないように見えます。

公開されたソースコードを基に自分で作ってみるのも手かもしれません。

* * *

**> >ここから**  
回答をもとに質問者が作成したコード

[code]

    dt = pd.date_range('2000/1/3 00:00:00', periods=100000, freq='min').to_series(name="datetime").reset_index(drop=True)
    
    workday = businesstimedelta.WorkDayRule(
        start_time=time(0),
        end_time=time(23, 59, 59, 999999),
        working_days=[0, 1, 2, 3, 4])
    
    def hoge(x, offset_minutes):
        return x + businesstimedelta.BusinessTimeDelta(workday, timedelta=timedelta(minutes=offset_minutes))
    
    dt.apply(hoge, offset_minutes=1)
    
[/code]

結果

[code]

    0        2000-01-03 00:01:00+00:00
    1        2000-01-03 00:02:00+00:00
    2        2000-01-03 00:03:00+00:00
    3        2000-01-03 00:04:00+00:00
    4        2000-01-03 00:05:00+00:00
                        ...           
    999995   2001-11-27 10:36:00+00:00
    999996   2001-11-27 10:37:00+00:00
    999997   2001-11-27 10:38:00+00:00
    999998   2001-11-27 10:39:00+00:00
    999999   2001-11-27 10:40:00+00:00
    Name: datetime, Length: 1000000, dtype: datetime64[ns, UTC]
    
[/code]

上記コードで10万回計算していますが、実行時間は4秒でした。ただし、applyではなくfor文を使うと2分かかりました。別回答のBDayを使った方法だとfor文でも3秒でした。  
**< <ここまで：質問者が実験結果を挿入**

* * *

**以下は必要とする機能が無いとのコメントがあり、情報としてだけ残します**

時刻まで考慮されているかは不明ですが、日付だけならばこの辺の記事やモジュールが使えるかもしれません。  
[Most recent previous business day in
Python](https://stackoverflow.com/q/2224742/9014308)

  * pandas の [pandas.tseries.offsets.BusinessDay](https://pandas.pydata.org/docs/reference/api/pandas.tseries.offsets.BusinessDay.html#pandas.tseries.offsets.BusinessDay) とその別名 [pandas.tseries.offsets.BDay](https://pandas.pydata.org/docs/reference/api/pandas.tseries.offsets.BDay.html)  
上記記事の承認回答が以下になります。

>
[code]

>     import datetime
>     # BDay is business day, not birthday...
>     from pandas.tseries.offsets import BDay
>  
>     today = datetime.datetime.today()
>     print(today - BDay(4))
>  
[/code]

  * 上記記事でモジュールの作者が紹介回答していたもの [business_calendar 0.2.1](https://pypi.org/project/business_calendar/)

  * 同様に回答の一つで紹介されていたモジュール [timeboard 0.2.3](https://pypi.org/project/timeboard/)

  * 上記記事の他の回答のソースコードなど  
→「高速で」というニーズのためには試してみるのが良いかもしれません。

  * そのまま使えるわけでは無さそうですが、考え方を応用できるかもしれない記事  
[【Python】土曜・日曜を除いた第一営業日を求める](https://buralog.jp/python-first-business-day-of-
month/)  
[Python – Get first working/business day of
month](https://varunver.wordpress.com/2019/04/18/python-get-first-working-
business-day-of-month/)

* * *

明確に時刻も扱っている記事は、回答が未承認ですがこちら。  
[Python Business hours calculation - add hours and result should be business
opening hours](https://stackoverflow.com/q/70889551/9014308)  
一応コメントでは動作する回答だと認められているようですが、質問者の望むものでは無かったようです。

  * 質問者が自力で見つけたと書いているモジュール [sla-calculator 1.0.0](https://pypi.org/project/sla-calculator/) / [sla-checker 0.0.2](https://pypi.org/project/sla-checker/)


CC BY-SA 4.0
2022-04-17T03:12:22.180
88362
2022-04-18T04:07:03.537
2022-04-18T04:07:03.537
26370
26370
88361
answer


それほど高速ではないけれど pandas使う方法

[code]

    import pandas as pd
    
    # 1日分の日時の場合
    # dt = pd.Timestamp('2022/4/1 23:50:00')
    # 開始日時から連続した 10日分の日時
    dt = pd.date_range('2022/4/1 23:50:00', periods=10, freq='D')
    
    dt += pd.Timedelta('20 min')
    dt += pd.offsets.BDay(0) # 営業日加算, 月曜から金曜に合わせる
    dt
    
[/code]

結果

[code]

    DatetimeIndex(['2022-04-04 00:10:00', '2022-04-04 00:10:00',
                   '2022-04-04 00:10:00', '2022-04-05 00:10:00',
                   '2022-04-06 00:10:00', '2022-04-07 00:10:00',
                   '2022-04-08 00:10:00', '2022-04-11 00:10:00',
                   '2022-04-11 00:10:00', '2022-04-11 00:10:00'],
                  dtype='datetime64[ns]', freq=None)
    
[/code]

* * *

#### (追記) NumPy 使用の場合

(pandasのコードに比較的近い) NumPyで試してみました。  
34128000 件の arrayに対し 30 BusinessDay ＆ 20分の日時加算で  
colabでの確認では 2秒ほどの処理時間  
1億の場合, 3回分で合計 5〜6秒ほど (一度にすべて保持が難しい場合 を想定)

pandasの `BusinessDay` と NumPyの `np.busday_*` について

  * NumPyでは `roll='forward'` などオプションが指定でき, 細かい指定が可能

  * pandasに合わせるなら, 加算日 > 0 は `roll='backward'`, 0なら `roll='forward'`にするとよさそう

  * pandas NumPyどちらも, BusinessDayと時間の同時加算はムリ。利用時の手順に委ねられる (先に時間を加算するか後にするか)

  * 加算する日数がそれぞれ異なる場合は, (pandasでは無理そうで) NumPyのみ可能

[code]

    import numpy as np
    arr = np.arange('2022-04', '2023-05', dtype='datetime64[s]')
    display(len(arr))
    # 加算値
    minutes = 20
    days = 30
    
    arr += np.timedelta64(minutes, 'm')
    
    # 営業日加算＆補正と 時刻の分離・復元
    dts = arr.astype('datetime64[D]')
    # pandasに合わせる場合: roll=days and 'backward' or 'forward'
    r = np.busday_offset(dts, days, roll='backward') +(arr -dts)
    
    # 時刻の検証
    #from datetime import datetime, timedelta, timezone
    #for n, dt in enumerate(np.arange('2022-04', '2023-05', dtype='datetime64[s]')):
    #    dt = dt.item()   # datetime.fromtimestamp(random.randint(*daterange), tz=JST)
    #    v = addtimedelta(dt, days=days, minutes=minutes)
    #    assert r[n].item() == v, f'{dt} => {r[n].item()} {v}'
    
[/code]

* * *

#### (追記2) 日数だけ加算時のベンチマーク

  * 一日未満の時間は, 先に加算しておくとよい
  * 結果からわかること 
    * pandasでの普通の加算と NumPy での演算が高速
    * apply/map を使った加算だとかなり遅くなる。高速化を試みても pandasでの普通の加算よりも遅い。

[code]

    #!pip install perfplot
    #!pip install swifter
    
    import pandas as pd
    import numpy as np
    import swifter
    import perfplot
    
    # 加算値
    days = 30
    
    def pd_offsets_BDay(x):
        return x +pd.offsets.BDay(days)
    
    bd = pd.offsets.BDay(days)
    def pd_offsets(x):
        return x +bd
    
    def series_map(x):
        return x.map(bd.apply)
    
    def apply_add(x):
        return x.apply(lambda dt: dt +bd)
    
    from operator import add
    import functools
    addbd = functools.partial(add, bd)
    def apply_opadd(x):
        return x.apply(addbd)
    
    def swifter_apply_add(x):
        return x.swifter.apply(lambda dt: dt +bd)
    
    def np_busday_offset(x):
        arr = x.to_numpy()
        dates = arr.astype('datetime64[D]')
        return pd.Series(np.busday_offset(dates, days, roll='forward') +(arr -dates), index=x.index)
    
    out = perfplot.bench(
        setup=lambda n: pd.date_range('2022/4/1', periods=n, freq='s').to_series() +pd.offsets.BDay(0),
        n_range=10 ** np.arange(7),
        kernels=[
            pd_offsets_BDay,
            pd_offsets,
            series_map,
            apply_add,
            apply_opadd,
            swifter_apply_add,
            np_busday_offset,
        ],
        equality_check=lambda ser, ser2: ser.equals(ser2),
        xlabel="len(x)",
    )
    out.show()
    out.save("perf.png")
    
[/code]

[![ベンチマーク](https://i.stack.imgur.com/llHLY.png)](https://i.stack.imgur.com/llHLY.png)

* * *

#### 素の Python

ちなみに素の Pythonで(ランダムな日時で) NumPy処理同等のことをループ使うと  
10000_0000 件で 500秒ほど

質問に明記されてなかった点 (判明したこと)

  * 時間の加算が先か後かで, 営業日 => 土日, あるいは土日 => 営業日に切り替わる場合に 演算に影響出そう  
素の Pythonでは同時に加算可能であり, 問題出そう？

  * ~~土日で始まる場合があるかどうか。その場合 1日の加算は月曜になるのか, (営業日にしたあと加算で)火曜になるのか~~

[code]

    from datetime import datetime, timedelta, timezone
    JST = timezone(timedelta(hours=+9), 'JST')
    
    # 加算日時を timedeltaへ変換
    def addtimedelta(dt, days=0, **kwargs):
        q, mod = divmod(days, 5)
        dt += timedelta(weeks=q, **kwargs)
        w = dt.weekday()
        if not mod:
            # 週末なら加え過ぎた分を元に戻す, もしくは週を加えなかった場合には次の週へ
            return dt if w < 5 else dt +timedelta(days=(q and 4 or 7) -w)
        elif w >= 5:    # 土日始まりなら
            return dt +timedelta(days=7-1-w +mod)
        if w + mod >= 5:
            mod += 2
        return dt +timedelta(days=mod)
    
    import pandas as pd
    import random
    drange = [pd.Timestamp(dt).timestamp()for dt in
            ('2022-4-1 10:00:00', '2022-7-1 20:00:00')]
    for _ in range(10000_0000):
        dt = datetime.fromtimestamp(random.randint(*drange), tz=JST)
        r = addtimedelta(dt, days=30, hours=1, minutes=23, seconds=4)
        #print(dt, '=>', r)
    
[/code]

* * *

_**< <<**_  
回答をもとに質問者が作成したコード

[code]

    import pandas as pd
    #from pandas.tseries.offsets import BDay
    from pandas.tseries.offsets import BDay as offsets_BDay
    #import pandas.tseries.offsets as offsets   # あるいは
    from datetime import datetime, timedelta
    
    dt = pd.date_range('2000/1/3 00:00:00', periods=100000, freq='min').to_series(name="datetime")
    
    def hoge(x, offset_minutes):
        return x + timedelta(minutes=offset_minutes % 1440) + offsets_BDay(offset_minutes // 1440)
    
    dt.apply(hoge, offset_minutes=1)
    
[/code]

結果 (左の列は計算前、右の列は計算後の日時)

[code]

    2000-01-03 00:00:00   2000-01-03 00:01:00
    2000-01-03 00:01:00   2000-01-03 00:02:00
    2000-01-03 00:02:00   2000-01-03 00:03:00
    2000-01-03 00:03:00   2000-01-03 00:04:00
    2000-01-03 00:04:00   2000-01-03 00:05:00
                                  ...        
    2000-03-12 10:35:00   2000-03-13 10:36:00
    2000-03-12 10:36:00   2000-03-13 10:37:00
    2000-03-12 10:37:00   2000-03-13 10:38:00
    2000-03-12 10:38:00   2000-03-13 10:39:00
    2000-03-12 10:39:00   2000-03-13 10:40:00
    Freq: T, Name: datetime, Length: 100000, dtype: datetime64[ns]
    
[/code]

上記コードで10万回計算していますが、実行時間は3秒でした。1億回計算しても現実的な時間で収まりそうです。  
祝日を考慮する場合、BDayの代わりにpandas.tseries.offsets.CustomBusinessDayを使うとよさそうです。試してはいません。  
**< <<**

* * *

編集提案がありましたが, 少し加工しています

  * 例えば `dt += pd.offsets.BDay(0)` では 0日なので変化無しに思えて, 実際には月曜から金曜に収まるよう 1〜2日加算される
  * 他に `dt - pd.offsets.MonthEnd()` では, `dt`の日付に応じて先月月末に合わせる結果になる
  * `offsets` では普通の加算・減算とは異なり, 内部では別の処理に置き換えられている

高速化を図るなら `.` のアトリビュートを減らすのはある程度有効  
とは言え, `BDay` ではなく, 明示的に(それを利用していると判るように) `offsets.BDay` (もしくは
`offsets_BDay`など)のように修飾したほうがよいでしょう (なので加工しました)


CC BY-SA 4.0
2022-04-17T03:44:56.963
88363
2022-05-13T15:41:46.293
2022-05-13T15:41:46.293
43025
43025
88361
answer


月曜日0:00であれば、`7day * (add_time / 5day) + add_time % 5day`
を加算すれば求まるので、あとは月曜日にあわせて計算すればいいんじゃないでしょうか。

[code]

    # coding: utf-8
    
    import random, time
    from datetime import datetime, timedelta
    
    ONE_WEEK = 60 * 60 * 24 * 7
    WEEKEND = 60 * 60 * 24 * 2
    WEEKDAY = 60 * 60 * 24 * 5
    
    NUM_DATA = 1000000
    # とりあえず、タイムスタンプで最小の月曜日を求める
    # 60 * 60 * 24 * (7 - datetime.fromtimestamp(0).weekday()) で求まるかと思ったけど、
    # 日本時間だとずれる。
    BASE = datetime(1970, 1, 1, 0, 0, 0)
    BASE += timedelta(7 - BASE.weekday())
    BASE = BASE.timestamp()
    
    if __name__ == '__main__':
        # ランダムでデータを作成
        data = []
        add_time = []
        for i in range(NUM_DATA):
            data.append(datetime.fromtimestamp(datetime(2022, 1, 1).timestamp() + random.randrange(0, 60 * 60 * 24 * 365)))
            #add_time.append(random.randrange(1, 60 * 24 * 100))
            add_time.append(random.randrange(60 * 24 * 5, 60 * 24 * 10))
    
        start = time.time()
    
        result = []
        for d, a in zip(data, add_time):
            # タイムスタンプなので、秒単位にする。
            a *= 60
            t = int(d.timestamp())
            # 月曜日からの時間を求める
            n = (t - BASE) % ONE_WEEK
            # 計算を月曜日基準にする。
            a += n
            t -= n
            result.append(datetime.fromtimestamp(t + ONE_WEEK * (a // WEEKDAY) + a % WEEKDAY))
        print('time = %.5f.' % (time.time() - start))
    
        for i in range(5):
            print('(%s, %d) + (%d, %d) => (%s, %d).' % (repr(data[i]), data[i].weekday(), add_time[i] // (60 * 24), add_time[i] % (60 * 24), repr(result[i]), result[i].weekday()))
    
    
[/code]


CC BY-SA 4.0
2022-04-17T16:21:08.830
88373
2022-04-17T23:58:14.833
2022-04-17T23:58:14.833
15473
15473
88361
answer


datetimeを使う方法です。

#### コード

[code]

    import datetime
    def addTime(base, add):
        friday = 4 #金曜日
        weekEndDays = 2 #土日の２日 
        toWeekend = datetime.timedelta(days = friday - base.weekday(), hours = 23 - base.hour, minutes = 59 - base.minute + 1)
        num = ((add - toWeekend).days // (7 - weekEndDays)) #加算日数 翌週初からの土日の回数、当該週は-1
        #無条件にweekEndDaysを加算しているが当該週はnumが-1となるため相殺
        cadd = datetime.timedelta(days=weekEndDays + add.days + weekEndDays * num, seconds=add.seconds)
        return base + cadd
    
[/code]

#### 呼び出し方

[code]

        #起点
        year = 2022
        month = 4
        day = 4
        hour = 0
        minute = 1
        basetime = datetime.datetime(year, month, day, hour, minute)
    
        #加算時刻
        days = 14
        hours = 23
        minutes = 59
        addtime = datetime.timedelta(days=days, hours=hours, minutes=minutes)
    
        result = addTime(basetime, addtime)
    
[/code]

> 1億回計算しても現実的な時間で完了すること。

100000000回呼び出したときの経過時間は543秒でした。  
単にdatetimeとtimedeltaを加算したときの経過時間は218秒です。  
ちなみに100000000回の空ループは15秒です。

#### 実行環境

  * Python 3.10.4
  * Ubuntu 20.04.4 LTS (WSL2)
  * プロセッサ Intel(R) Core(TM) i5-6300U CPU @ 2.40GHz 2.50 GHz


CC BY-SA 4.0
2022-04-18T12:25:06.717
88381
2022-04-20T00:31:33.387
2022-04-20T00:31:33.387
35558
35558
88361
answer