【Ruby, Rails】複数の時間から平均を算出する

![スクリーンショット 2021-12-22 13.33.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1246920/ffa7c711-43ac-4db7-cf0a-72ca47ad5705.png)
# はじめに
現在、Ruby on Railsにて睡眠を記録するアプリを作成しています。睡眠の平均時間を表示しようとしたら、一筋縄ではいかず苦労しました。対策を備忘録として残そうと思います。

## 開発環境
Mac OS Big Sur 11.6
VSCode
Ruby 3.0.2p107
Rails 6.1.4.1
MySQL 8.0.27 for macos11.6 on arm64 (Homebrew)

## データ
DB（MySQL）ではSleepLogsテーブルを作成し、以下のデータを保存しています。

sleep_atカラムやwake_atカラムは、データの表示や睡眠時間の計算を行うため、datetime型で保存しています。

|項目名|値の例（値の型）|説明|
|:-----|:-----|:-----:|
|:user_id|1（bigint）|userの外部キー|
|:sleep_at|Thu, 02 Dec 2021 03:58:31.000000000 JST +09:00（datetime）|就寝日時|
|:wake_at|Thu, 02 Dec 2021 09:17:00.000000000 JST +09:00（datetime）|起床日時|
|:satisfaction|1（bigint）|睡眠の満足度|

## やりたいこと
seedを使い、起床時間がランダムなデータを20件ほど作成しています。保存されている日時データから平均を算出し、平均の起床時間など__時間のみのデータ__を表示したいと考えました。画像では完成していますが、平均の起床時間に焦点をあてて、失敗例と成功例を交えつつ紹介します。

![スクリーンショット 2021-12-22 13.33.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1246920/802f0489-75f5-0b1a-1135-f426a896b35b.png)
（特にCSSを当てていないため見にくいです。申し訳ありません。）


# averageを使えばできるのでは？
では、平均起床時間の表示に挑戦します。Railsには指定したカラムの平均を求めるaverageメソッドがあるみたいです。__「これでwake_atカラムの平均を求めればできるのでは〜〜？？(^^)」__と思ったのですが、__できませんでした。__

```***_controller.rb
def show
  # 現在のユーザーの記録のみ取得
  @sleep_logs = SleepLog.where(user_id: params[:id])
  @average = @sleep_logs.average(:wake_at)
end
```
viewファイルについては、以降省略します。

```***.html.erb
<%= @average %>

# => 20211211314945.0
```

## そもそものデータについて考える
wake_atカラムのデータを1件取り出してみます。

```***_controller.rb
def show
  @average = @sleep_logs.first.wake_at
end

# => 2021-12-21 10:27:00 +0900
```
datetime型で保存されているため、起床時間だけではなく、起床した日付も保存されています。また、その中身は起算時からの経過秒数を保持しています。そのため、__各レコードから時間と分のみを取り出さなければならない__と考えました。

# 時間と分を取り出す
こちらの記事を参考にしました。 [Time型カラムに記録された時間インスタンスの、平均時刻を取得する（日付は無視）](https://qiita.com/rubyist_yasunao/items/b08430d939aa9c96261d)


```***_controller.rb
def show
  # 現在のユーザーの記録のみ取得
  @sleep_logs = SleepLog.where(user_id: params[:id])

  # timesに時間と分のみを配列として記録
  times = @sleep_logs.pluck(:wake_at).map { |log| log.hour * 60 + log.min }

  # sumメソッドで合計を求め、sizeメソッドの件数で平均を出す
  time_average = times.sum / times.size

  # formatで出力の表示を調整。time_averageは単位が分なので、時間と分に変換する。
  hour = time_average / 60
  min =  time_average % 60
  @average = format('%02<hour>d:%02<min>d', hour: hour, min: min)
end

# => 09:35
```
とりあえず、これで平均を出力できました。（この平均には誤差があります。@scivolaさま、ありがとうございました。）
showメソッド内をトレースしながら、何をしているか紹介します。


## 現在のユーザーの記録のみ取得
whereを使用して、現在のユーザーが該当する記録を取得しています。

```***_controller.rb
@sleep_logs = SleepLog.where(user_id: params[:id])
```

コンソールで確認すると中身はこんな感じです。

```
[#<SleepLog:0x00000001526123c8
  id: 20,
  user_id: 1,
  sleep_at: Tue, 21 Dec 2021 03:58:31.000000000 JST +09:00,
  wake_at: Tue, 21 Dec 2021 10:27:00.000000000 JST +09:00,
  created_at: Wed, 22 Dec 2021 11:58:31.545341000 JST +09:00,
  updated_at: Wed, 22 Dec 2021 11:58:31.545341000 JST +09:00,
  satisfaction: 3>,
 #<SleepLog:0x0000000152612058
  id: 19,
  user_id: 1,
  sleep_at: Mon, 20 Dec 2021 03:58:31.000000000 JST +09:00,
  wake_at: Mon, 20 Dec 2021 08:02:00.000000000 JST +09:00,
  created_at: Wed, 22 Dec 2021 11:58:31.542132000 JST +09:00,
  updated_at: Wed, 22 Dec 2021 11:58:31.542132000 JST +09:00,
  satisfaction: 1>,

  (省略)
```

## timesに時間と分のみを配列として記録
取得したデータ全体の内、必要としているwake_atカラムのみを抽出。その後、mapメソッドで各データの時間と分のみ取り出して、配列で保存しています。
  
```***_controller.rb
times = @sleep_logs.pluck(:wake_at).map { |log| log.hour * 60 + log.min }

# => [627, 482, 662, 492, 662, 544, 680, 552, 564, 541, 491, 565, 553, 622, 507, 662, 617, 629, 500, 557]
```

## sumメソッドで合計を求め、sizeメソッドの件数で平均を出す
配列の合計を求め、配列の要素数で割り平均を求めます。

injectメソッドを使う方法もあるみたいですが、sumの方が早いみたいですね。
[[Ruby]いろんな処理のベンチマーク選手権](https://qiita.com/1060ki/items/8a60b94358350cce5ede)
  
```***_controller.rb
time_average = times.sum / times.size

# => 575
```

### ［追記］除算の誤差について
今回は多少の誤差を許容しています。正しい平均を求める方法について@scivolaさまからご紹介いただきました
[【Ruby のまずいコード】除算の落とし穴](https://qiita.com/scivola/items/bf56d00a623ff6d3bc31)

## formatで出力の表示を調整
配列の合計を求め、配列の要素数で割り平均を求めます。

formatについてはこちらを参考にしました。
[Rubyのformatメソッドで名前付きのフォーマットを使う](https://qiita.com/saka1_p/items/cb791b06edcf589edc85)
  
```***_controller.rb
hour = time_average / 60
# => 9
min =  time_average % 60
# => 35

@average = format('%02<hour>d:%02<min>d', hour: hour, min: min)

# => "09:35"
```

# 参考にした記事

[Time型カラムに記録された時間インスタンスの、平均時刻を取得する（日付は無視）](https://qiita.com/rubyist_yasunao/items/b08430d939aa9c96261d)
[[Ruby]いろんな処理のベンチマーク選手権](https://qiita.com/1060ki/items/8a60b94358350cce5ede)
[Rubyのformatメソッドで名前付きのフォーマットを使う](https://qiita.com/saka1_p/items/cb791b06edcf589edc85)