2018/12/01

[Python] サマータイムのある地域で時刻計算する時は、一度 UTC に変換する

「サマータイムのある地域で」とタイトルに入れたのですが、地域に関わらず、時刻計算する時は UTC に変換してから行った方が良いです。
特にサマータイムのある地域では問題が起こりやすいので、注意が必要ということです。


### 問題が起こる例
2018年の ニューヨークは 11/4 2:00 am までがサマータイムアメリカでは Daylight Saving Time というでした。

```
2018/11/4 2:00 am より前
EDT -04:00

2018/11/4 1:00 am 以降
EST -05:00
```

このような時刻をまたぐ計算を aware のまま行うと、結果がおかしくなります。

```python
>>> import datetime, pytz
>>> dt = datetime.datetime(2018, 11, 4, 7, 0, 0, 0)  # 2018/11/4 7:00 am native
>>> dt_aware = pytz.timezone('America/New_York').localize(dt)
>>> dt_aware
datetime.datetime(2018, 11, 4, 7, 0, tzinfo=)
>>> str(dt_aware)
'2018-11-04 07:00:00-05:00'
```

上記の `dt_aware` から 1日前を計算すると以下のようになります。

```python
>>> calculated = dt_aware - datetime.timedelta(days=1)
>>> calculated
datetime.datetime(2018, 11, 3, 7, 0, tzinfo=)
>>> str(calculated)
'2018-11-03 07:00:00-05:00'
```

ぱっと見正しそうですが、11月3日なのに EST になっていて、間違っています。



これを回避する方法を以下に挙げておきます。
基本的にはタイトルにあるように、UTC に変換するのが一番ですが、全てのケースで使えるわけではありません。

なお、タイムゾーンをコンストラクタで指定すると結果がおかしくなるので、必ず `localize()` を使用します。
> 参考
>
> [[Python] datetime のコンストラクタでタイムゾーンは指定しない方がよい | 穀風](https://kokufu.blogspot.com/2018/11/python-datetime.html)

### 回避策1: もう一度 タイムゾーンを適用する
`datetime` オブジェクトの中の [Unix time](https://ja.wikipedia.org/wiki/UNIX%E6%99%82%E9%96%93) は保持されているので、タイムゾーンを再度適用すると正しい結果になります。

```python
>>> calculated2 = calculated.astimezone(pytz.timezone('America/New_York'))
>>> calculated2
datetime.datetime(2018, 11, 3, 8, 0, tzinfo=)
>>> str(calculated2)
'2018-11-03 08:00:00-04:00'
```

これでも大抵の場合はうまくいきます。
ただ、本来存在しない時刻が途中に出てくるのがイマイチです。


### 回避策2: UTC に変換してから計算する
計算する時には必ず UTC にし原理的にはオフセットが変わらないタイムゾーンなら何でも良い。ただ、そんなことが保証されているのは UTC だけだと思う。、その後、再度タイムゾーンを適用します。

```python
>>> dt_aware_utc = dt_aware.astimezone(pytz.utc)
>>> dt_aware_utc
datetime.datetime(2018, 11, 4, 12, 0, tzinfo=)
>>> calculated_utc = dt_aware_utc - datetime.timedelta(days=1)
>>> calculated_utc
datetime.datetime(2018, 11, 3, 12, 0, tzinfo=)
>>> calculated3 = calculated_utc.astimezone(pytz.timezone('America/New_York'))
>>> calculated3
datetime.datetime(2018, 11, 3, 8, 0, tzinfo=)
>>> str(calculated3)
'2018-11-03 08:00:00-04:00'
```

少々面倒ですが、常に UTC で計算するようにすると、タイムゾーンに関する諸問題を適切に回避できる方法かと思います。


### 回避策3: native に変換してから計算する
大抵の場合は UTC に変換する方法を使用しておけば良いと思います。

ただ、UTC に直す方法がうまく機能しない場合があります。
例えば、「その地域で3日前の17時が Unix time ではどう表されるか」を知りたい場合等かなり限られた条件だが、実際にそういう需要があったのでです。

そういった場合は、native にしてしまうという方法があります。

```python
>>> dt_native = dt_aware.replace(tzinfo=None)  # dt と同等
>>> dt_native
datetime.datetime(2018, 11, 4, 7, 0)
>>> calculated_native = dt_native - datetime.timedelta(days=3)
>>> calculated_native
datetime.datetime(2018, 11, 1, 7, 0)
>>> calculated_native2 = calculated_native.replace(hour=17, minute=0, second=0, microsecond=0)
>>> calculated_native2
datetime.datetime(2018, 11, 1, 17, 0)
>>> calculated4 = pytz.timezone('America/New_York').localize(calculated_native2)
>>> calculated4
datetime.datetime(2018, 11, 1, 17, 0, tzinfo=)
>>> str(calculated4)
'2018-11-01 17:00:00-04:00'
>>> int(calculated4.timestamp())  # Unix time (Python 3.3 以降)
1541106000
```

かなり面倒ですね。

この場合は native な datetime オブジェクトではなく、date オブジェクトに直して、また datetime オブジェクトに戻すという手もつかえますこれも、結構面倒。
また、最初に書いたタイムゾーンを適用し直すという方法も使えますが、タイムゾーンを適用するタイミングによって結果が変わるので注意が必要です。

### まとめ
どの方法を使うせよ、**計算結果に対し自動的にオフセットが変わることは無い** ということを念頭においておくと良いでしょう。

その上で、基本的には UTC を基準にした aware で計算するのがベストサマータイムのあるなしに関わらず、計算する時は UTC に直すのがベストプラクティス。

native を扱わなければいけないケースもありますが、タイムゾーンが落ちることでミスが発生しやすくなります。
どうしても必要な場合、変換はギリギリまで行わず、計算後は awareなるべく UTC に戻すようにすると良いかと思います。

0 件のコメント: