PR

Tapo P110Mの消費電力をExcelレポート化する【年次集計・電気代試算・セキュリティ判断編】

ハードウェア制御 / 電子工作

TAPO PDU PROJECT VOL.3

スポンサーリンク

Tapo P110Mの消費電力をExcelレポート化する【年次集計・電気代試算・セキュリティ判断編】

こんにちは。ニンジンです🥕

前回は、Tapo P110Mの消費電力データをSQLiteに記録する仕組みと、FastAPI + Uvicornで一括/個別ON-OFF・電力監視ができるWebダッシュボードの構築、通信失敗時の自動再discoverまでを紹介しました。

今回はその記録データを活用して、消費電力履歴をExcelでダウンロードできるようにし、月別・年次の集計レポート、参考単価による電気代の試算機能まで作っていきます。あわせて、「スマホなど別端末からもダッシュボードを見たい」という要望を検討した結果、あえて見送った判断についても紹介します。

この記事で分かること

  • FastAPIで.xlsxファイルをダウンロードさせる方法(openpyxl+StreamingResponse)
  • 15秒間隔でポーリングした生ログをそのまま月別タブに出力すると起きる「データ量問題」の試算
  • energy_today_kwh(本日の積算電力量)のMAX値を使った、壊れにくい日次集計クエリの設計
  • 1〜12月のタブを持つ年次レポートをopenpyxlで生成する方法
  • 参考単価(円/kWh)を使って電気代を試算し、注記付きでExcelに表示する方法
  • 「スマホなど別端末からダッシュボードを見たい」を、認証なしダッシュボードのセキュリティリスクを理由にあえて見送った判断
スポンサーリンク

こんな人におすすめ

  • 前回・前々回の記事からの続きで読んでいる人
  • 自作の監視ツールに「Excelでダウンロードできる集計レポート」を追加したい人
  • 個人開発のWebアプリを外部公開するかどうかで悩んでいる人

スポンサーリンク

消費電力履歴を/api/exportでExcelダウンロードする

まずはシンプルに、SQLiteに溜まった生ログをそのままExcel形式でダウンロードできるようにします。CSVでも良かったのですが、あとで見出しの体裁を整えたり複数タブに分けたりすることを見据えて、最初からopenpyxl.xlsxを組み立てることにしました。

📄web_dashboard.py(追記)VSCode
from io import BytesIO
from openpyxl import Workbook
from fastapi.responses import StreamingResponse

@app.get("/api/export")
async def export_excel(device: str | None = None):
    """消費電力量の記録をExcel(.xlsx)としてダウンロードする。省エネ参考資料としての利用を想定。"""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    if device:
        if device not in DEVICES:
            raise HTTPException(status_code=404, detail="unknown device")
        rows = conn.execute(
            "SELECT * FROM power_readings WHERE device_name = ? ORDER BY id", (device,)
        ).fetchall()
    else:
        rows = conn.execute("SELECT * FROM power_readings ORDER BY id").fetchall()
    conn.close()

    wb = Workbook()
    ws = wb.active
    ws.title = "消費電力履歴"
    headers = ["日時", "デバイス", "瞬間電力(W)", "本日の電力量(kWh)", "当月の電力量(kWh)", "電圧(V)", "電流(A)"]
    ws.append(headers)
    for row in rows:
        label = DEVICES.get(row["device_name"], {}).get("label", row["device_name"])
        ws.append([
            row["timestamp"], label, row["power_w"],
            row["energy_today_kwh"], row["energy_month_kwh"],
            row["voltage_v"], row["current_a"],
        ])

    for col, width in zip("ABCDEFG", [20, 16, 14, 18, 18, 12, 12]):
        ws.column_dimensions[col].width = width
    ws.freeze_panes = "A2"

    buf = BytesIO()
    wb.save(buf)
    buf.seek(0)

    filename = f"tapo_power_history_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
    return StreamingResponse(
        buf,
        media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )

ポイントは、ファイルをディスクに書き出さずにBytesIO上でExcelを組み立て、StreamingResponseでそのままダウンロードさせているところです。Content-Dispositionヘッダーでファイル名を指定すれば、ブラウザ側は普通のファイルダウンロードとして扱ってくれます。

ダッシュボードの各デバイスカードに「Excel」ボタンを追加し、クリックするとwindow.location.hrefでこのエンドポイントに遷移させるだけで、ダウンロードが始まります。

ダウンロードした生データ版Excelを開いたところ。日時・デバイス・瞬間電力などの列が並ぶ

▲ ダウンロードした生データ版Excelを開いたところ


スポンサーリンク

生ログをそのまま月別タブにすると何が起きるか

生データのダウンロード自体はこれで動くのですが、「1年分をまとめて見たい」という用途には向きません。今回のダッシュボードは15秒間隔でポーリングしているため、1台あたりの記録件数を計算してみると次のようになります。

  • 1時間あたり: 60分 × 60秒 ÷ 15秒 = 240件
  • 1日あたり: 240件 × 24時間 = 5,760件
  • 1年あたり: 5,760件 × 365日210万件

これをそのまま「1月タブ、2月タブ…」と月別に流し込むと、1タブに数十万行のデータが並ぶことになり、Excelで開くだけで重くなりますし、そもそも人間が眺めて傾向を掴める形ではありません。永久保存(削除・アーカイブなしの設計)にしているぶん、この問題は時間が経つほど深刻になります。

そこで、生データのダウンロードとは別に、日単位に丸めた集計版を作ることにしました。

MAX(energy_today_kwh)を使った日次集計クエリを設計する

「その日1日でどれだけ電力を使ったか」を求めるとき、素朴には「瞬間電力(power_w)を全部足し合わせる」方法を考えがちです。しかし、これは以下の理由で採用しませんでした。

  • 瞬間電力はあくまで「その瞬間」の値であり、単純にSUMしても正しい電力量(kWh)にはならない(ポーリング間隔を掛けて積分する必要があり、通信失敗によるポーリング抜けがあると誤差が乗る)
  • 一方、Tapo P110M自身が計算しているenergy_today_kwh(本日の積算電力量)は、その日のうちにデバイス内部でリセットされながら増え続ける値なので、その日の最後に記録された値(=最大値)がそのまま「その日の合計」になる

そのため、日次集計は素直にMAX(energy_today_kwh)を使うのが一番正確、という結論になりました。

📄web_dashboard.py(抜粋)VSCode
query = """
    SELECT
        date(timestamp) AS day,
        device_name,
        MAX(energy_today_kwh) AS daily_kwh,
        AVG(power_w) AS avg_w,
        MAX(power_w) AS max_w
    FROM power_readings
    WHERE strftime('%Y', timestamp) = ? AND strftime('%m', timestamp) = ?
    GROUP BY day, device_name
    ORDER BY day, device_name
"""
avg_w(平均電力)・max_w(最大電力)は参考情報として、同じクエリの中で一緒に集計しています。

スポンサーリンク

/api/export/yearlyで1〜12月タブの年次レポートを生成する

集計クエリができたので、指定した年の1〜12月ぶんのシートを持つExcelを組み立てます。

📄web_dashboard.py(続き)VSCode
DEFAULT_UNIT_PRICE_YEN = 31.0

@app.get("/api/export/yearly")
async def export_yearly(year: int, device: str | None = None, unit_price: float = DEFAULT_UNIT_PRICE_YEN):
    """指定した年の1〜12月タブを持つExcelを生成する。各タブは日次集計(電力量合計・平均/最大電力・参考金額)。"""
    if device is not None and device not in DEVICES:
        raise HTTPException(status_code=404, detail="unknown device")

    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row

    wb = Workbook()
    wb.remove(wb.active)
    note = (
        f"※参考単価: 1kWhあたり{unit_price:g}円(目安)で試算した金額です。"
        "実際の電気料金は契約している電力会社・プランにより異なります。"
    )
    headers = ["日付", "デバイス", "電力量合計(kWh)", "平均電力(W)", "最大電力(W)", "参考金額(円)"]

    for month in range(1, 13):
        sheet = wb.create_sheet(title=f"{year}年{month}月")
        sheet.append([note])
        sheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers))
        sheet.append(headers)

        # (このあと日次集計クエリを実行し、1日1行ずつ書き込む)

        sheet.freeze_panes = "A3"
    conn.close()
    # ...(Excelをメモリ上に保存してダウンロードさせる処理は/api/exportと同様)

データがまだない月(未来の月、あるいは単に記録がない月)は、ヘッダー行だけの空タブになる仕様にしています。1年分の見通しを最初から確保しておいたほうが、あとで見返すときに月をまたいで比較しやすいと考えたためです。

年次レポート版Excel。1月から12月までのシートタブが並んでいる

▲ 年次レポート版。1〜12月のタブが並び、データのない月はヘッダーのみ


スポンサーリンク

参考単価31円/kWhで電気代を試算する

省エネの参考資料として使うなら、電力量(kWh)だけでなく「だいたい何円くらいか」も見えたほうが実感が湧きます。ただし電気料金は契約している電力会社・プランによって単価が変わるため、実際の請求額ではなく、あくまで参考金額として試算する形にしました。

既定の単価は31円/kWhにしています。これは2026年時点で家庭用電気料金の全国平均目安としてよく参照される値をWeb検索で確認して採用したもので、地域や契約プランによって実際の単価は変動します。この前提が伝わるよう、各月シートの1行目に注記(結合セル)として明記しています。

📄web_dashboard.py(抜粋)VSCode
daily_kwh = round(row["daily_kwh"], 3) if row["daily_kwh"] is not None else None
sheet.append([
    row["day"], label, daily_kwh,
    round(row["avg_w"], 2) if row["avg_w"] is not None else None,
    round(row["max_w"], 2) if row["max_w"] is not None else None,
    round(daily_kwh * unit_price) if daily_kwh is not None else None,
])

なお、この「参考金額」列は日次集計版(/api/export/yearly)だけに追加しています。生ログの瞬間電力(W)に単価を掛けても意味のある金額にはならないため、全期間の生データダンプ(/api/export)には追加していません。

ダッシュボード側にも、年の入力欄・参考単価の入力欄(既定31円)・「年別レポート(日次集計・全12ヶ月タブ)」ボタンを追加し、生データの「全期間の生データをエクスポート」ボタンと並べて2階建てにしました。

📄static/dashboard.html(抜粋)VSCode
<input type="number" id="yearInput">
<label>参考単価
  <input type="number" id="unitPriceInput" value="31" step="0.1"> 円/kWh
</label>
<button onclick="exportYearly()">年別レポート(日次集計・全12ヶ月タブ)</button>
<button onclick="exportExcel()">全期間の生データをエクスポート</button>
ダッシュボード画面。年入力欄・参考単価入力欄・年別レポートボタン・全期間の生データをエクスポートボタンが並ぶ

▲ ダッシュボードに追加した年入力・参考単価入力・年別レポートボタン


スポンサーリンク

スマホなど他端末からダッシュボードにアクセスしたいと思ったら

「スマホなど別端末からもダッシュボードを見たい」という気持ちもあったのですが、今回はセキュリティ上の理由から見送りました。今のダッシュボードには認証機能が一切なく、ON/OFFの制御APIも消費電力データの閲覧も誰でも操作できてしまう状態なのと、このPCのセキュリティ性を考えたため、127.0.0.1(このPC上のみ)での運用に留めています。マルチデバイス対応が必要になったときは、次に予定しているRaspberry Pi 4Bへのサーバー移行のタイミングで、認証機能の追加とあわせて改めて設計し直す方針です。


スポンサーリンク

よくある質問

Q生ログをそのまま月別タブに出力しなかったのはなぜですか?

A15秒間隔のポーリングだと1台あたり年間約210万件に達する試算になり、1タブに数十万行を詰め込むとExcelで開くだけで重くなるうえ、人間が眺めて傾向を掴める形にもならないためです。日単位に丸めた集計版(/api/export/yearly)を別途用意することで解決しました。

Q参考単価はどうやって決めましたか? 実際の電気代とずれませんか?

A2026年時点で家庭用電気料金の全国平均目安としてよく参照される31円/kWhをWeb検索で確認して既定値にしていますが、実際の単価は契約している電力会社・プランによって変わります。そのためあくまで「参考金額」と明記し、unit_priceパラメータやダッシュボードの入力欄で利用者が自分の契約単価に合わせて変更できるようにしています。

Qなぜスマホからも見られるようにしなかったのですか?

A今のダッシュボードには認証機能が一切なく、ON/OFF制御まで含めて誰でも操作できてしまう状態のため、セキュリティ上の理由から今回は見送りました。127.0.0.1(PC専用)運用に留め、マルチデバイス対応は次に検討しているRaspberry Pi 4Bサーバー化のタイミングで、認証機能とあわせて設計し直す予定です。

Q生ログはこの先も無限に増え続けるのですか?

A現状の実装では削除・アーカイブ処理を持たないため、記録し続ける限りDBは肥大化していきます。将来的には「直近数ヶ月ぶんだけ生データを保持し、それより古いものは日次集計に丸めて元データを削除する」といった保持ポリシーの導入が望ましいと考えていますが、今回はまだ実装せず今後の課題としています。

QCSVではなくExcel(.xlsx)出力にしたのはなぜですか?

A1つのファイルに1〜12月ぶんの複数タブを持たせたかったため、openpyxlで.xlsxを組み立てる形にしました。CSVは1ファイル1テーブルが基本のため、月ごとにファイルを分けるか1ファイルに全月を詰め込むかの選択になり、どちらも今回の「月ごとにタブを切り替えて見る」という使い方には合いませんでした。


スポンサーリンク

まとめ・次回予告

今回は、Tapo P110Mの消費電力履歴をExcelでダウンロードできるようにし、生ログのままでは扱いづらいという課題から日次集計の年次レポートを設計・実装、参考単価による電気代の試算まで紹介しました。

  • openpyxl+StreamingResponseでディスクに書き出さずにExcelをダウンロードさせる方法
  • 15秒間隔ポーリングの生ログをそのまま月別タブにすると年間約210万件に達してしまう問題と、MAX(energy_today_kwh)を使った壊れにくい日次集計の設計
  • 1〜12月タブの年次レポートに、契約により変動する旨を明記したうえで参考単価による電気代試算を追加
  • 「スマホからも見たい」という要望を、認証機能のないダッシュボードを公開するリスクを理由にあえて見送り、127.0.0.1運用に留めた判断

Tapo P110M PDUシステムのPoCとしての3部作はこれで一区切りです。今後、24時間稼働の実運用を見据えてRaspberry Pi 4Bをサーバーとして常時稼働させる続編も検討していますので、実現したらまたこのブログで紹介したいと思います。


ニンジン🥕アイコン

Follow me on X

本ブログの中の人「ニンジン🥕」はXやってます。新着記事はいち早くポストでお知らせしているので、見逃したくない方は @ninjin_py_vba をフォローしてみてください🥕

@ninjin_py_vba をフォロー

この記事が役に立ったら、シェアしてもらえると嬉しいです🥕

Xでポスト はてブ LINE Pocket

PROJECT LOG — SERIES INDEX
スポンサーリンク

シリーズ記事一覧

電子工作・自動化ツールの製作記録をシリーズごとにまとめています。気になるテーマからお読みください。


Tapo P110Mの消費電力をExcelレポート化する【年次集計・電気代試算・セキュリティ判断編】
FastAPI+openpyxlでTapo P110Mの消費電力をExcelレポート化。15秒間隔の生ログを日次集計する設計や、31円/kWhでの電気代試算、外部公開を見送った理由まで解説します。
Tapo P110M × FastAPIで電力監視ダッシュボードを作る【SQLite記録・リアルタイム堅牢化編】
Tapo P110MのデータをSQLiteに記録し、FastAPI+Uvicornでリアルタイム電力監視ダッシュボードを作る方法を解説。通信失敗時の自動復旧など堅牢化のポイントも紹介します。
Tapo P110M × python-kasaで自作PDUを作る【環境構築・Unsupported device(TPAP)エラー解決編】
Tapo P110Mをpython-kasaで自作PDU化。「Unsupported device」TPAPエラーの原因と対処法、MACアドレスでIP変動に強くする設計、個別/一括ON-OFFの実装コードまで解説します。
Raspberry Pi Pico Wで作る熱中症警戒アラート表示器
Raspberry Pi Pico WとDHT11、0.96インチOLEDで熱中症警戒アラート表示器を自作。気温・湿度から簡易的な暑さ指数を計算し、5段階の顔アイコンで危険度を表示する作り方をコード付きで解説します。
CustomTkinterでオリジナルのメニューバー(File・表示・設定・Help)を自作する
CustomTkinterには無いメニューバー(File・表示・設定・Help)を、CTkFrameとCTkButtonで自作する方法を、5段階のサンプルコードとダウンロード付きで解説します。

コメント