無人店舗のエアコンをIoT化して、月70万円節約した話

に公開3

どんな問題を解決したのか

こんにちは、rsugiです。

個人開発で、美容系の無人店舗(のオーナー)向けにLINE予約システムを提供しています。

お客さんは、事前にLINE予約して入店用のPINをゲットします。
当日は(スマートロックの)PINで入室し、美容系機器を利用したのち退室します。
店舗での利用時間を快適に過ごしていただくためにエアコンを稼働させています。

イメージ図

先日、オーナーから無駄にエアコンをONにしていて経費がかさんでいるという相談を受けました。

スイッチボットのスケジュールはON, OFFを固定で組むことができるのですが、お客さんの予約に応じてON, OFFしたい。かなり節約になるはずとのことでした。

ということで調査したところ、

空白の部分は誰もいないのにエアコンが稼働(空稼働) している時間でした。

少し人気店の予約イメージ

新規出店時の予約イメージ

「そこそこ予約が増えてきても半分以上が空稼働している状態」
確かに無駄に稼働している時間が多いですね。

こまめにON/OFFしすぎると逆に電気代が上がるので、

「後続予約が2h以内に存在する場合はOFFにしない」という要件(例: 24日)も追加され、
こんな感じで着地しました。

少し人気店の予約イメージ(施策後)


新規出店時の予約イメージ(施策後)

この施策により空稼働を大幅に削減でき、1ヶ月/50店舗で70万円ほどの節約になりました。


施策前後の比較

  • オーナーもwin
  • 開発者(私)もwin
  • (事業の継続性が上がるので間接的に)利用者もwin
  • (節電効果で)地球もwin

関係者全員がwinになる、意義のある機能を開発できました。

今回はその話をします。

対象読者

  • 個人開発のリアルを知りたい人/個人開発をしたい人
  • プロダクト志向な人/事業にコミットする開発をしたい人
  • 要件定義に興味がある人

いいね!してね

この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!
Xで情報発信しているのでフォローお願いします!rsugi8

それでは以下が本編です。

説明すること

  • 要件定義する
  • 基本設計・詳細設計
  • リトライ方法の検討
  • SQLのパフォーマンス問題

要件定義

用語

まずは要望、要求、要件の違いを確認します

概要

要望 :電気代を減らしたい!
 ↓
要求 :人がいる時だけ稼働させたい/稼働時間を制限したい
 ↓
要件 :センサー+スマートリモコンでIoT制御、Web管理画面で制御・監視可能に

となりました。

詳細

💡 1. 要望(Wants)

  • 電気代が高すぎるので何とか削減したい
  • 無駄なエアコンの稼働をなくしたい
  • 店舗で人がいない時間帯は止めてほしい
  • 利用時間に快適性は保ちたい

📌 2. 要求(Needs)

  • 店舗の営業時間内(8時〜23時)で、人がいる時だけエアコンを稼働させたい
  • 全店舗の捜査結果を管理画面からモニタリングできる必要がある
  • 店舗、季節ごとに設定したい温度,風量などが異なる

📐 3. 要件(Requirements)
⚙️ 機能要件(Functional Requirements)

  • 各店舗のエアコンにIoT制御デバイス(スマートリモコン)を設置(済み)
  • (先頭の)予約の入室n分前にONにする
  • 退室後、n分後にOFFにする
  • (後続の)予約と2h以上間隔がある場合はOFFにする
  • (最後の)予約の退室n分後にOFFにする
  • 管理者用ダッシュボードに以下の機能を搭載:
    • 遠隔からのON/OFF、温度、風量の設定を変更できる
    • エアコン操作ログを見れる

🏗️ 非機能要件(Non-functional Requirements)

  • 最大1回までの失敗を前提とする(室温がクレームにつながるため)

基本設計(と詳細設計)

1. システム概要

各店舗のエアコンをIoTデバイスで制御し、必要なときのみ稼働させることで電気代を削減。
管理者はWebダッシュボードで稼働状況をモニタリング・遠隔操作可能。
※詳細設計はキャプチャを貼るので割愛

2. システム構成図

  • Heroku
    • Scheduler(Railsサーバー): 1 worker 512MB
    • jawsDB: MySQL RAM 1GB
    • 非同期処理用のワーカーはありません
  • 外部API
    • SwitchBot API

3. 機能一覧

機能ID 機能名 説明
1 スケジュール制御 管理画面から店舗ごとに入室x分前、退室y分後を指定できる
2 遠隔操作機能 管理画面から店舗ごとにON/OFF・温度・風量を操作する
3 操作ログ記録・閲覧 各店舗の制御履歴を一覧表示

1はコスパが悪い、固定で良いとのことだったのでドロップさせました。

4. 画面設計

項目 内容例
スケジュール一覧 店舗名、スイッチボット端末、スケジュールの連携
実行履歴一覧 実行履歴

スケジュール一覧は「店舗」「端末」「スケジュール」を関係づけるのみ
※「操作」の選択肢は固定のレコードを用意することで画面作成を省略

スケジュール一覧

実行履歴一覧は、最低限わかるレベルにして工数削減

実行履歴一覧

5. データベース設計

STORE_SWITCH_BOT_COMMAND_HISTORIES: 実行履歴
STORE_SWITCH_BOT_DEVICES: 店舗,デバイス,スケジュールの連携
※一部カラムは割愛しています

6. 非機能要件

項目 要件
ログ保存期間 1ヶ月
リトライ機構 失敗したら1回リトライ
障害時通知 通信失敗、異常検知時にアラートを管理者に通知(Slack)
  • ログ保存期間: 1ヶ月で3万レコード程度だったのでバッチ処理で削除する
  • リトライ機構: 同じ操作を2回実行することでリトライと同等の処理としました(後述)
  • 障害時通知: スイッチボットAPIが予期せぬステータスコードを返したらslackに通知するようにしました。

リトライ方法の検討

検討内容

入室時に快適な室温でないと、お客さんからのクレームにつながります。1回失敗したときに再試行する仕組みを考えました。

13:00 ~ 13:30の予約の場合、

  • 何分前にONにすればいいのか?(失敗時に何分前に再試行すべきか?)
  • 何分後にOFFにすればいいのか?(失敗時に何分後に再試行すべきか?)

※ちなみにHeroku Schedulerの仕様として、「10分に1回」「毎時n分に実行」「毎日h時に実行」 という選択肢しかありません。まじキチィ

結論

スケジューラー: 10分に1回
処理対象: 直前20分以内の操作スケジュール
としました。

「エアコンに同じ命令(ON or OFF)を飛ばしても良い」と解釈したことで、対象範囲(矢印)を広げるだけで実質リトライという処理にできました。成功/失敗のステータス管理をしなくて済んだのはラッキーでした。


×が処理対象範囲に2回含まれる(=リトライしている)

入室日時の20分前、10分前に1回ずつ
退室日時の10分後、20分後に1回ずつ
実行されるようになりました!

SQLのパフォーマンス問題

1インスタンスのjawsDB(MySQL RAM 1GB)をお客さん画面と管理画面で共有しており、スロークエリやn+1が発生するとお客さん画面の動作も影響を受けてしまいます。
高頻度(10分に1回)で実行するので、キツめの障害になることが予想されます。

(この障害があったら1日吹っ飛ぶな、やばいと思いながら)
クエリ2つ使って欲しい値をselectして、mapとかfilterとかで対応(割愛)
配列操作でn+1が発生しないようにクエリを書きました。

実行計画も大丈夫だったので無事リリース!
(window関数使えばもっと効率的に書けるっぽい。がそれはまた次のタイミングで使おう)

店舗ごとに、2つ目以降の予約を取得するクエリ
        WITH filtered_reservations AS (
          SELECT
            sr.*,
            sbd.id as `switch_bot_device_id`,
            sbd.device_id as `switch_bot_device_uuid`,
            sbdc.id as `command_id`
          FROM store_reservations as sr
          INNER JOIN store_switch_bot_devices as ssbd
            ON sr.store_id = ssbd.store_id
          INNER JOIN switch_bot_devices as sbd
            ON ssbd.switch_bot_device_id = sbd.id
          INNER JOIN switch_bot_device_schedules as ssbds
            ON ssbd.switch_bot_device_schedule_id = ssbds.id
          INNER JOIN switch_bot_device_commands as sbdc
            ON ssbds.switch_bot_device_command_id = sbdc.id
          WHERE sr.start_datetime BETWEEN :from AND :to
            AND sr.cancel_status = 0
        )
        SELECT
            previous_reservation.id as `previous_reservation_id`,
            previous_reservation.store_id as `store_id`,
            previous_reservation.end_datetime AS `previous_end_datetime`,
            previous_reservation.switch_bot_device_id as `previous_switch_bot_device_id`,
            previous_reservation.switch_bot_device_uuid as `previous_switch_bot_device_uuid`,
            next_reservation.id as `next_reservation_id`,
            next_reservation.start_datetime as `next_start_datetime`,
            next_reservation.switch_bot_device_id as `next_switch_bot_device_id`,
            next_reservation.switch_bot_device_uuid as `next_switch_bot_device_uuid`,
            next_reservation.command_id as `next_command_id`
        FROM filtered_reservations AS previous_reservation
        LEFT JOIN filtered_reservations AS next_reservation
        ON previous_reservation.store_id = next_reservation.store_id
        AND next_reservation.start_datetime > previous_reservation.end_datetime
        AND next_reservation.start_datetime = (
            SELECT MIN(nr.start_datetime)
            FROM filtered_reservations AS nr
            WHERE nr.store_id = previous_reservation.store_id
            AND nr.start_datetime > previous_reservation.end_datetime
        )
        WHERE (
            next_reservation.id IS NULL
            OR TIMESTAMPDIFF(MINUTE, previous_reservation.end_datetime, next_reservation.start_datetime) > :interval_minutes
        )
        ;
店舗ごと最も早い予約のみを取得するクエリ
        WITH filtered_reservations AS (
          SELECT
            sr.*,
            sbd.id as `switch_bot_device_id`,
            sbd.device_id as `switch_bot_device_uuid`,
            sbdc.id as `command_id`
          FROM store_reservations as sr
          INNER JOIN store_switch_bot_devices as ssbd
            ON sr.store_id = ssbd.store_id
          INNER JOIN switch_bot_devices as sbd
            ON ssbd.switch_bot_device_id = sbd.id
          INNER JOIN switch_bot_device_schedules as ssbds
            ON ssbd.switch_bot_device_schedule_id = ssbds.id
          INNER JOIN switch_bot_device_commands as sbdc
            ON ssbds.switch_bot_device_command_id = sbdc.id
          WHERE sr.start_datetime BETWEEN :from AND :to
          AND sr.cancel_status = 0
        )

        SELECT
            sr.store_id,
            MIN(sr.start_datetime) AS start_datetime,
            MAX(sr.switch_bot_device_id) AS switch_bot_device_id,
            MAX(sr.switch_bot_device_uuid) AS switch_bot_device_uuid,
            MAX(sr.command_id) AS command_id
        FROM filtered_reservations as sr
        GROUP BY sr.store_id, sr.switch_bot_device_id, sr.switch_bot_device_uuid
        ;

まとめ

toB向けの機能追加は「売上アップor経費削減」のどちらかに貢献しなくてはなりません。
今回は明確に経費削減で、施策後に定量的なメリットが見込めるという状況でした。

この金額を元に1ヶ月でx万円の経費削減が見込まれるので、この機能を追加でx万円をいただきたいですという形で料金をいただいています(工数ベースじゃなくて価値ベース)

この予約システム開発が開始して3年ほど経過し、
「要件定義、設計、実装、テスト、保守」と「価格設定」を1人で考えて試行錯誤してきました。

今回は紹介しやすい事例だったので記事にしました。
個人開発したい人/している人の参考になればと思います。

この記事が参考になった方は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!また、質問や疑問点があればコメントください。

Xで情報発信しているのでフォローお願いします!rsugi8

Discussion

braahmaNabraahmaNa

こんにちは。
とても楽しく読ませて頂きました。
もしお答え頂けたら有り難いのですが、
メンテナンス費についてはどうされているのですか?
LINE予約システムの契約費に含まれる形でしょうか?

r-sugir-sugi

毎月の保守費として請求しています(定型的な作業やエラー対応など)

braahmaNabraahmaNa

シンプルに保守料金請求されてるんですね
構築費だけ出せば無料で永久に面倒見て貰える、という認識の顧客多い気がしてて;
ご返信有難う御座いました