ライフワークバランスを確認するツールを作った話 - 1

:::note info
この記事は[LITALICO Engineers Advent Calendar 2024](https://qiita.com/advent-calendar/2024/litalico) カレンダー3 の 11日目の記事です
:::

# はじめに

私ごとですが、昨年結婚をしまして、1人での生活との違いを感じております

特に家事に関して、今まで1人のときは、気ままに、気が向いたらやっていました。一緒に暮らす方がいるとそれではいけないなと考え、きちんと一定の周期で取り組むようにしようと。一旦、Google カレンダーの定期的な予定の登録を使って登録しておりました

そうしますと、周期の問題で、複数の家事が集中する日、時間帯ができまして。それがたまたま、仕事も出社であるとか、忙しいとかになると破綻し、翌日に回そうとか、一回スキップで良いだろうとか、きちんと回せなくなり、見かねた相方が対応してくれたり、これはいかんなと

また、ライフワークバランスに関して、改めて、振り返ってみたのも同じ時期で、ワークを抑えて、ライフを充実させる方向に考えていたんですが、それってどの程度が自分にとって適切なのか正解がないことにも気づいんたんですよね。あれ、ライフの方見える化できてないなと。

上記、諸々を解決するために、ツールを作ったので、そちらについて、記事を書きたいと思います

# ツールの機能

カレンダーに、家事の予定を登録するのがゴールです

![スクリーンショット 2024-12-07 13.52.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277180/a09191bc-6da6-0cbd-20bc-4a61d5694871.png)

ツールを作っていく中で機能を拡張し、特に組み合わせ最適のロジックを変更しました

- 家事の必要時間と周期を登録し、組み合わせ最適化し、適切に家事の予定をカレンダーに登録する
    - 家事の周期を守った上で、予定がなるべく分散するようにする（1st）
    - 仕事の予定、場所を考慮して、家事の予定を組み立てる（2nd）
    - 自分だけではなく、相方の予定も考慮して、家事の予定を組み立てる（3rd）
- 前日に、家事の予定を通知する（1st）
- ライフワークバランスを可視化する（4nd）

# システム構成

![システム構成図.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277180/ca5de687-19d0-310d-26a7-e0639b736b01.png)


個人的な開発なので、なるべく、ランニングコストを抑えたいなと、サーバレスアーキテクチャを採用しております

Amplify Gen 2使ってみたい欲もあったのですが、時間なかったので、今回は、使い慣れたAmplify CLIを選びました

## Amplify CLIでの構築

AWSのリソースに関しては、Amplify CLIである程度、設定可能です

例）AppSyncの追加

```
$ amplify add api
```
あとは、コンソール上で質問に回答していくだけで、APIを追加できます、この際、AppSyncの設定だけではなく、API Gatewayの設定も同時にしてくれます、オプションに回答することで、storage（DynamoDB）のテーブル追加も追加可能です

何故か、Amplify CLIは、AppSync推しなんですよね。
Lambdaも下記で追加可能ではあるのですが

```
$ amplify add function
```

細かい設定（DynamoDBの特定レコードの更新をトリガーにLambdaをキックするとか、外部連携用に、LambdaとAPI Gatewayを紐づけたり、Step Functionsを紐付けたりが一回だとできないので、その点は直接コードを修正しましょう

※ Lambda Function URLs使っても良いのですが、個人的には、セキュリティ面に懸念あり、なんだかんだ、API Gatewayと組み合わせるのが好きです


# 組み合わせ最適化に関して

組み合わせ最適化と聞くと、なんだか難しいように感じるかもしれませんが、
条件を指定して、どの組み合わせが一番目的に合うかを計算させる形になります
[wikipedia](https://ja.wikipedia.org/wiki/%E7%B5%84%E5%90%88%E3%81%9B%E6%9C%80%E9%81%A9%E5%8C%96#%E9%9D%9E%E5%BD%A2%E5%BC%8F%E7%9A%84%E5%AE%9A%E7%BE%A9)にもかなり詳しく解説が書かれております

## 組み合わせ最適化の考え方

今回のケースでは、基本的には、シフトスケジューリング問題を解くのとほぼ同じです。
決定変数としては、「どの家事をいつ予定するか」、制約条件としては、「周期、同時実行の可否、業務・睡眠時間」などをそれぞれ数式化し、当初、目的関数として、「家事予定日の分散、各家事の設定周期と予定間隔の差分の最小化」などとして解きました

当初、周期を完全に固定にしていたのですが、2nd以降の業務の考慮など、条件が増えた際に、希望通りの分散にならず、どうしても家事の集中する日が発生しました。そのため、窓掃除は14日周期とした場合、周期よりも短い13日の組み合わせも計算し、目的関数で、14日の組み合わせをより評価するとしました

```
制約: 
- 家事毎の決まった周期以内で実行できるようにする
- 家事毎の必要時間の確保
- 家事は業務時間に予定しない
- 家事は睡眠時間に予定せず、1日に最低6時間は睡眠時間を確保するようにする
    - 睡眠時間をなるべく一定の開始・終了とするは目的関数で担保
- 各食事時間を最低45分確保、食事同士の間隔を設定範囲内に配置する（朝-昼: 4時間~6時間、昼-夜: 6時間~9時間）
- 朝の業務時間開始と、夜の睡眠時間前に、身支度の時間を確保する（朝: 30分、夜: 90分）
- 家事毎の実行不可時間帯（掃除は日中のみ、ゴミ出しは朝のみ、買い物はお店の営業時間のみなど）
- 同時実行の可否
    - 例えば、洗濯や炊飯など、家電に任せておけるので、他の家事と並行可能、買い物、ゴミ出しなどは、特定の組み合わせ同士並行可能、調理、掃除はどの家事とも並行不可
    
目的関数: 
- 家事の予定の登録された日の分散
- 各家事の設定周期と予定間隔の差分の最小化
- 特定家事（掃除や洗濯など、音が大きい家事）の配置曜日、時間の評価
    - 土日など、ゆっくりしたい日や時間帯をなるべく避けるように
```

制約を多くしすぎると、組み合わせを解くのに、想定外の時間のかかるケースがあるので、一旦最低限のみとしております

## 組み合わせ最適化をプログラムする上でのポイント

### 決定変数をどう表すか

今回の場合、決定変数として、どの家事をいつ予定するかになるのですが、これをプログラム上でどう扱うかが最初の肝です

何をしたいか、改めて考えてみましょう
例えば、日毎の家事分散だけ考えるのであれば、日単位の変数と家事を割り当てていく形でも良いです
この場合は、カレンダー上の表示は日毎の表示になります

![スクリーンショット 2024-12-07 13.39.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277180/5fc745e2-53c8-14db-7cc6-d5fd37e88c66.png)

個人的には、上記だと、業務の影響を考えづらいなど思いました
最終的なアウトプットをGoogle カレンダーで考えていたので、Googleカレンダー上で取り扱いやすい15分をコマと考え、単位としました

そのため、1日は、24 x 4 = 96個のコマというオブジェクトを持った配列で表現しております
それぞれのコマオブジェクトに、家事を配置していく形です

```python
# 月毎の日数を取得
days = calendar.monthrange(year, month)

# 月毎の日数を元に、決定変数を初期化
# 1時間を15分毎の4コマとしている
housework_schedules = [None] * days * 24 * 4
```

### 制約、目的関数の計算

決定変数から、制約、目的関数の数式がそのまま計算しやすければ言うことなしですが、今回の場合はそのまま数式で考えると、計算量が多くなります

- 例）制約: 家事毎の決まった周期以内で実行できるようにする
    - 配置しようとしている家事と、同じ家事がどこに配置されているか、前のコマを検索
    - 直近の同じ家事のコマを見つけたら、配置予定のコマとの位置を元に、間隔を計算する
    - 上記の間隔が、設定した周期以内か、比較

上記でも良いのですが、組み合わせ最適化を解く際、何回も試行を行い、目的関数が最小になる組み合わせを探し出すので、1つ1つの数式を簡単にできないかを考えるのが重要です

例えば、前述のコマとは別に、家事毎の配置を別の配列で保持すると、同じ制約でも計算が楽になります

- 例）制約: 家事毎の決まった周期以内で実行できるようにする
    - その家事の配置配列から、最後の要素を取り出し、その差分が周期以内か比較する

```python
# 想定している家事、後で、DynamoDBから保存済みの家事を読み込み
houseworks = []

# 家事毎の予定を別で保持
shedules_per_housework = [[None] * 1 for 1 in range(len(houseworks))]

...

# 決定関数のindexから該当時間を計算
def cals_time_by_index(index):
    return datetime.datetime(year, month, index // (24 * 4), index % (24 * 4), (index % 24) * 15, 0)

...

# 家事予定をセットする関数
def set_housework(index, housework):
    # 決定変数に配置
    housework_schedules[index] = housework

    # 家事毎の予定にも配置
    schedules_per_housework[housework["type_key"]][-1] = {
        "started_at": cals_time_by_index(index),
        ...
    }

    ...

# 同じ家事の予定の間隔を計算
def calc_interval(index, housework):
    # 同じ家事の最後の予定を取得
    lsat_same_housework = schedules_per_housework[housework["type_key"]][-1]

    # 差分を計算
    return cals_time_by_index(index) - lsat_same_housework["started_at"]
```

同様に、目的関数に関しても、

- 家事の予定の登録された日の分散
であれば、コマではなく、日付ごとの配列を持った方が楽ですし

- 特定家事（掃除や洗濯など、音が大きい家事）の配置曜日、時間の評価
であれば、元々のコマの配列から簡単に計算できそうなど

それぞれ、計算中のデータの保持に関して考えてあげましょう

# 終わりに

ご覧いただきありがとうございます

具体のコードを載せない方針なので、1記事でいけるかなと思っていたのですが、
方針や設計に関することだけでも、書きたいことが多すぎて記事を分割させていただきます

明日は、仕事のカレンダーとの同期について書きます
よろしければ、ご覧ください