t-hom’s diary

主にVBAネタを扱っているブログです。

ラズパイ本番機でカロリー記録&運動時間記録システムの運用開始

しばらくRaspberry Piの開発機側で運用していたカロリー記録システムと合わせて、最近完成した運動時間記録システムをラズパイの本番機にデプロイした。

動作イメージ

以下の5つのプログラムが動作中。
f:id:t-hom:20210228130148p:plain

体重記録だけはデーモン化してあるので表には出てない。

グラフ3種は外部から通信が入った時にそれらに応じて自動で切り替わるようになっていて、体重計に乗ると体重グラフが、M5 Stackで運動量を記録すると運動量グラフが、M5 Stackでカロリー摂取量0キロカロリーを送付するとカロリーグラフが最前面に表示される。

グラフ切替の仕組み

それぞれのグラフに対する入力があった際に単にプロセスをKillして再度起動させているために最前面に上がってくるだけ。
リアルタイムに更新させたかったけど今のところそんなスキルはない。

カロリーだけ0キロを送らなければいけない仕様にしたのもその関係で、摂取カロリーは連続で入力するのでそのたびグラフの再起動は避けたいからだ。

グラフはtkinterに埋め込んでいて、pythonプログラムのプロセス名を変更するライブラリを使用しているので他のプログラムから用意にプロセスを特定してKillできる。
プロセス間通信とかでKillしなくても更新できる方法を模索したこともあったけど一旦挫折した。

グラフをtkinterに埋め込む部分のコード

変更点に関係するところだけ掲載。
※変更前コードはブログ紹介用にコメントで残したが実際は綺麗に消えてる。

import setproctitle as sp
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, date, timedelta

#tkinterとFigureCanvasTkAggをインポート
import tkinter as tk
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg


#データ加工関係は省略。以前の記事をご参照ください。

#前回はfigやaxを作らずに直接pltにbarを出力していた。
fig, ax = plt.subplots()
 
#plt.title("Calorie Record", fontsize = 22)

#plt.xlabel("Date", fontsize = 22)
ax.set_xlabel('Date')

#plt.ylabel("Calorie", fontsize = 22)
ax.set_ylabel('Calorie')

#plt.grid(True)\
ax.grid(True)
 

#plt.bar(record_date, maximum-minimum, width=0.25, bottom = minimum, tick_label = record_date, align="center", label="Guideline", color = "#98fb98", edgecolor="#008000", lw=0, hatch="/////")
ax.bar(record_date, maximum-minimum, width=0.25, bottom = minimum, tick_label = record_date, align="center", label="Guideline", color = "#98fb98", edgecolor="#008000", lw=0, hatch="/////")

#plt.bar(record_date, breakfast, width=0.2, tick_label = record_date, align="center", label="Breakfast", color = "#f3d394")
ax.bar(record_date, breakfast, width=0.2, tick_label = record_date, align="center", label="Breakfast", color = "#f3d394")

#plt.bar(record_date, lunch, width=0.2, bottom = breakfast, tick_label = record_date, align="center", label="Lunch", color = "#45938b")
ax.bar(record_date, lunch, width=0.2, bottom = breakfast, tick_label = record_date, align="center", label="Lunch", color = "#45938b")

#plt.bar(record_date, dinner, width=0.2, bottom = breakfast+lunch, tick_label = record_date, align="center", label="Dinner", color = "#003a34")
ax.bar(record_date, dinner, width=0.2, bottom = breakfast+lunch, tick_label = record_date, align="center", label="Dinner", color = "#003a34")

#plt.show()

def _destroyWindow():
    root.quit()
    root.destroy()

root = tk.Tk()
root.title(u"Calorie Intake Graph")
root.configure(bg='black')
root.withdraw()
root.protocol('WM_DELETE_WINDOW', _destroyWindow)

#ここでfigをcanvasに入れてルートウインドウの配下にする。
canvas = FigureCanvasTkAgg(fig, master=root)
canvas.draw()
canvas.get_tk_widget().pack(fill=tk.BOTH,expand=1)

root.update()
root.attributes("-zoomed", "1")
root.deiconify()
root.mainloop()

matplotlibってサイトによって書き方がまちまちでうまく行く方法を探すのに苦労した。
棒グラフをpltじゃなくてfigを作ってaxにプロットするサンプルを探し回ってなかなか思うようなコードが見つからずに苦労したんだけど結局たどり着いたのは本家matplotlibのサイト。

ここにそのままやりたいことが載ってた。やっぱ一時情報を当たるべきだな。。英語だけど下手な日本語解説より分かりやすかったりする。
matplotlib.org

figに入ってしまえばあとはtkに乗せるだけ。これは体重グラフで実績があるので楽勝だった。

開発からデプロイまでのフロー

以前はすべてのファイルをWinSCPで本番機に連携していたけど、管理にGitHubを使い始めたので少しマシになった。
f:id:t-hom:20210228135500p:plain

本当はClientで開発してDevにPushした後にDevでPullしてテストって方向で考えてたんだけど、実機でしかうまく動かない部分も出てくるし折角Vim使えるのにわざわざローカルで開発するのも面倒くさくて、結局開発は従来通り実機でやってしまった。そういう意味でラズパイ開発機は、いわゆるTest環境じゃなくて本当の意味でDev環境として使っている。

本番機の変更管理

デプロイの際の本番変更もしっかり管理。
f:id:t-hom:20210228125744p:plain

以上

M5 Stackのバッテリー容量と電源の切り方

M5 Stackに標準で付いてくるバッテリーは110mAhである。
これはつまり110ミリアンペアの電流を1時間流せるという意味だ。

ちなみにスマホのバッテリーは2500mAh~3000mAhくらいあるので、110mAhというのがいかに少ないかが分かっていただけるかと思う。
もちろん消費電力が全然違うのでスマホと比較するようなものではないが。


それでも以下のサイトによるとWiFiに繋いでると90~200mA程度消費するようだ。
ambidata.io

つまりWiFiにつなぎっぱなしにするような使い方では30分~1時間ほどでバッテリーを使いつくすと思われる。

そして消費電力を抑えるDeepsleepというモードでも10mA。
ということは、Deepsleepでも110mA/10mAで11時間しか持たないことになる。


そしてM5スタックを電源OFFする方法を調べていたところ、電源ボタンを素早く2回クリックすれば良いことが分かった。
ただし以下のサイトによると電源ボタンを素早く2回クリックしてもDeepsleepに移行するだけとのこと。
zenn.dev


これじゃ毎回バッテリーボトムを取り外さないと、11時間sleepさせておくだけでバッテリーを使い切ってしまうではないか。
(後に勘違いであることが分かる)

それで物理スイッチモジュールを発注してみたんだが、そういえば最後にチャージしてから4~5日経過してるけど普通に起動してくることに気づいた。
あれ、、、計算と違う。

ちゃんと公式のドキュメント読んでみたところ、「Power off: Quickly double-click the red power button on the left」とのこと。
docs.m5stack.com

DeepspleepじゃなくてちゃんとPower offって書いてある。。
公式がPower offって言ってるんだから前述の記事の方が間違ってるのでは。

更にGitHubで見つけた次のDiscussionによると、「This is a hardware problem of the IP5306 5V buck converter going into standby after 32 seconds when the load current remains below 45mA.」とのこと。
github.com

つまり、M5 Stackはバッテリー駆動時に消費電力が45mAを32秒以上下回った場合はスタンバイに移行するとのこと。

仮に電源ボタンを素早く2回クリックする操作がDeepsleepであるという記事が正しかったとしても、Deepsleepの消費電力は10mA程度なので32秒でスタンバイに移行するということである。

物理スイッチを使用すればStandbyよりも更に消費電力が低いものと思われるが、ちょっとこのスイッチケースも微妙なところがあり。。
f:id:t-hom:20210227182346p:plain

公式の謳う電OFFの方法で電源を切っておいた場合、4~5日後も普通に使用できているので、私の運用方法では別にわざわざ物理スイッチ付けなくても良いかなと思った。

GitHubに誤って自宅WiFiパスワードをPushしてしまったので対処した件

GitHubを使い始めた背景

最近、Raspberry PiやM5 Stackでの開発が本格化してきたため、レガシーなファイル名によるバージョン管理では限界が来ている。
ラズパイの本番機は先日綺麗にしたのだが、開発機はこのありさま。。
f:id:t-hom:20210227132729p:plain

ステージング用に準備したフォルダの筈が、面倒くさくなってVNC接続してそのままVimで色々と作り始めた次第。
完全にworkフォルダの二の舞に。。

ちなみにworkフォルダの様子。。もう何のプロジェクト用だったか分からないファイルが沢山。
f:id:t-hom:20210227132917p:plain

いい加減Gitで管理しようということで、再入門してみた。
※個人のVBAプロジェクトで見よう見まねで使ったことはあるが、書籍を通しで読んで本格的にやるのは初めて。

購入したのはこちらの書籍。

わかばちゃんと学ぶ Git使い方入門

わかばちゃんと学ぶ Git使い方入門

一切コマンドが登場せず、SourceTreeというGuiツールのみで解説されているので、操作の種類や概念を学ぶのにとても良い。
ただし、記載内容はやや古く、SourceTreeの導入部分と、GitHubのプライベートリポジトリが有料という記載は現在は当てはまらない。

トラブル発生

さくっと読了して早速Raspberry PiとM5 StackのプロジェクトをGitHubにPushしたのだが。。。
そこで凡ミス発生。

M5 Stack用のソースコードにssidとpasswordが記載された状態でそのままアップロードしてしまったのだ。

Gitはバージョン管理システムなので、普通に消しても履歴上は残ってしまう。つまり過去履歴をたどるとssidとpasswordが出てきてしまう。
今回はPrivateリポジトリにしていたので他人に見られることは無くてセーフなんだけど、Publicにしてたらと思うとゾッとするミスである。

履歴の削除対応

履歴の削除は次のように行うようだ。結局コマンドが必要になる。
docs.github.com

なお、記載されているBFG は今ひとつ使い方が分からなかったので、filter-branch を使用することにした。
書かれている手順でやったことは次のとおり。

1) 機密データを含むリポジトリのローカルコピーが履歴にまだない場合は、ローカルコンピュータにリポジトリのクローンを作成します。

ローカルで作業してるのでクローンする必要はないかと思ったけど、指示と違うことをやってしまうと怖いので、現在Source Treeで作業しているフォルダーとは違う場所にフォルダーを作って、GitHubからCloneした。

2) リポジトリのワーキングディレクトリに移動します。

そのとおり作業

3) 次のコマンドを実行します。機密データを含むファイルへのパスは、ファイル名だけではなく、削除するファイルへのパスで置き換えます。

コマンドが示されているが、Unix/Linuxに慣れてないと意味が分からんと思う。GitHub使う時点で慣れてる前提かな。
とりあえず補足しておいた。
f:id:t-hom:20210227135815p:plain

それから、PATH-TO-YOUR-FILE-WITH-SENSITIVE-DATAは削除したいファイルのパスに置き換えるらしいんだけど、「パス」って何のパスなのかも初心者には辛いところ。

GitHub上にパスがあればコピーしてくれば良いんだけど、ここでは消したいファイルを削除してコミットした前提でたどり方を説明する。なお、私の環境では本当に消したいファイルは既に消したあとなので、このあとの説明は適当なファイルを例に挙げている。

過去に消したファイルには、コミット履歴から辿れるので、まずはコミットログを開く。
f:id:t-hom:20210227140545p:plain

ファイルを削除する前のコミット履歴を開く
f:id:t-hom:20210227140833p:plain

Browse Fileをクリック
f:id:t-hom:20210227141016p:plain

特定の履歴に飛ぶのでフォルダーから消したいファイルまでたどる。
f:id:t-hom:20210227141232p:plain

ファイルを開いたらメニューからCopy Pathでクリップボードに値がコピーされる。
f:id:t-hom:20210227141604p:plain

あとはコマンドのPATH-TO-YOUR-FILE-WITH-SENSITIVE-DATAの部分をコピーしたパスに置き換える。
例えばcalorie_graph.pyを履歴から削除したい場合は次のコマンドになる。改行せずに1行で入力しきったのでバックスラッシュはない。

git filter-branch --force --index-filter "git rm --cached --ignore-unmatch pi/m5_visualizer/calorie_graph.py" --prune-empty --tag-name-filter cat -- --all

4) 機密データを含むファイルを、誤って再度コミットしないようにするため、.gitignore に追加します。

その通り対応。
YOUR-FILE-WITH-SENSITIVE-DATAをファイル名に置き換える。これはパスじゃなくて単にファイル名でOK。

5) リポジトリの履歴から削除対象をすべて削除したこと、すべてのブランチがチェックアウトされたことをダブルチェックします。

具体的にどうするのか分からなかったのでやってない。要は確認なので、所詮情報の消し漏れで困るのは私ひとりの為、この項目はスキップした。業務で対応してる方はちゃんと調べるなり、サポートに問い合わせるなりしないとまずいと思う。

6) リポジトリの状態が整ったら、ローカルでの変更をフォースプッシュして、GitHub リポジトリと、プッシュしたすべてのブランチに上書きします。

その通り対応。

7) 機密データをタグ付きリリースから削除するため、Git タグに対しても次のようにフォースプッシュする必要があります。

その通り対応したが、結果は「すでに対応済」のようなメッセージが出て特に何も起こらなかった。

8) GitHub Support or GitHub Premium Support に連絡し、GitHub 上で、キャッシュされているビューと、プルリクエストでの機密データへの参照を削除するよう依頼します。

その通り対応。ここまでの操作ログを張り付けて、前述のページに従って作業したのでキャッシュ削除して欲しい旨を英語で連絡した。返事待ち。

9) コラボレータには、 作成したブランチを古い (汚染された) リポジトリ履歴からリベースする (マージしない) よう伝えます。 マージコミットを 1 回でも行うと、パージで問題が発生したばかりの汚染された履歴の一部または全部が再導入されてしまいます。

具体的にどうすれば良いのか不明で私ひとりの開発なので、この作業の代わりにSourceTreeで使っていたローカルのファイルをすべて削除したうえ、GitHubから新たにプロジェクトをクローンしてきてそちらを正とした。

10) 一定の時間が経過し、git filter-branch に意図しない副作用がないことが確信できるようになったら、次のコマンドによって、ローカルリポジトリのすべてのオブジェクトが強制的に参照から外されガベージコレクトされるようにします (Git 1.8.5 以降を使用)。

これもよく分からないので特に何もしてない。
恐らくGitHubからクローンしなおしてるので問題ないと思う。

以上で履歴の削除対応は完了とした。
繰り返しになるが、仕事に使ってる方や完全削除しないと他人に迷惑のかかるデータを消す場合は、サポートに問い合わせをしてでも作業を完遂したことを確認すべきだと思う。

私の場合は最悪自分しか困らないシチュエーションだったのである程度緩い対応で済ませたが、真似して痛い目を見ても責任は負いかねるのであしからず。

再発防止

Wi-Fi接続用のSSIDとパスワード情報を他のファイルに分離することにした。
ありがたいことに以下のサイトで実例を見つけたので真似してやってみた。
scrapbox.io

ライブラリフォルダってどこなのか一瞬分からなかったので備忘録としてスクショ張っておく。
f:id:t-hom:20210227143535p:plain

このヘッダーのコードは参照先サイトと同じく、そのまま。
f:id:t-hom:20210227143757p:plain

前述の場所に置いておくとスケッチメニューからライブラリのインクルードでssid_defineが見つかるので、メインコードを開いた状態でそれをクリックすると、include文がコードの先頭に追記される。
参照先サイトではdefine文でssid_defineを取り込むような表記になってたけど、エラーになったので多分記載ミスだと思う。

あとはこんな感じでメインコードからMY_SSIDとMY_SSID_PASSを参照できるようになる。

#include <ssid_define.h>
#include <M5Stack.h>
#include <WiFi.h>

int p;
int digit[4];

const char* ssid     = MY_SSID;
const char* password = MY_SSID_PASS;

//以下略

これでこのコードをGitHubにアップロードしてもパスワード流出の危険はなくなった。

終わりに

今回はPrivateリポジトリで発生したミスなので流出の危険性は低く、しかも自分の自宅Wifiパスワードなので万が一流出しても困るのは私だけ、また所詮電波の届く範囲からしか接続されることはないのでリスクはほとんど無いようなものである。

だからじっくり対応できたしこの程度の対応で済ませたんだけど、もしこれが公開リポジトリで、他人の個人情報や公開サービスのパスワードなんてことになったら目も当てられない。

なので、GitHub使い始めたばかりの方はこういう凡ミスを起こしたときに焦らず確実に対処できるように、問題のないファイルを使って履歴削除の練習をしておくと良いと思う。
※サポートへのキャッシュ削除依頼は、本当に削除が必要な場合以外はしないでください。

以上。ああ、疲れた。

2/28 追記

GitHub Supportから返信が来てサーバー側のキャッシュをクリアしてくれたので追記。

私がサポートに送った内容全文

メールではなくてWebからの問い合わせとなる。markdown記法が使えるので#が付いてるのは見出し。
依頼事項と対照リポジトリ/ファイル/操作内容をコマンドプロンプトから貼り付けておいた。
コマンドと結果が分かりづらいのでコマンドだけ赤色にしておいた。

このときはmainブランチしかなかったので特にブランチ指定はしてないけど、複数ブランチに消したいファイルがあれば伝えた方が良いのかもしれない。
ただ後述する返信を見るとリポジトリに対してガベージコレクションをかけただけっぽいのでそんなに細かい内容を送らなくても良かったのかも。
まぁ送って間違いはないと思う。何か間違ってることがあればついでに指摘してくれるだろうし。

Dear Person in Charge,


Sorry to bother you. I wrongly pushed a file which include Wi-Fi password to my repository.
I've finished step 7 of following instruction.
https://docs.github.com/en/github/authenticating-to-github/removing-sensitive-data-from-a-repository#purging-a-file-from-your-repositorys-history


Could you please remove cached view?


Note: please tell me if I'm asking something wrong. I'm almost newbie for Git.

# Repository(Private)

https://github.com/thom-jp/health-tracking-pi

# Removal Target

m5/client/client.ino

# Removal Operation Log

Microsoft Windows [Version 10.0.19041.804]
(c) 2020 Microsoft Corporation. All rights reserved.

C:\Users\ho_>cd OneDrive\dev\htp

C:\Users\ho_\OneDrive\dev\htp>git clone https://github.com/thom-jp/health-tracking-pi
Cloning into 'health-tracking-pi'...
remote: Enumerating objects: 71, done.
remote: Counting objects: 100% (71/71), done.
remote: Compressing objects: 100% (67/67), done.
remote: Total 71 (delta 5), reused 63 (delta 1), pack-reused 0
Receiving objects: 100% (71/71), 705.90 KiB | 966.00 KiB/s, done.
Resolving deltas: 100% (5/5), done.

C:\Users\ho_\OneDrive\dev\htp>cd health-tracking-pi

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>git filter-branch --force --index-filter "git rm --cached --ignore-unmatch m5/client/client.ino" --prune-empty --tag-name-filter cat -- --all
WARNING: git-filter-branch has a glut of gotchas generating mangled history
rewrites. Hit Ctrl-C before proceeding to abort, then use an
alternative filtering tool such as 'git filter-repo'
(https://github.com/newren/git-filter-repo/) instead. See the
filter-branch manual page for more details; to squelch this warning,
set FILTER_BRANCH_SQUELCH_WARNING=1.
Proceeding with filter-branch...

Rewrite 31f5c3bed9103b44a88e1111f3d63b26d6279447 (5/7) (3 seconds passed, remaining 1 predicted) rm 'm5/client/client.ino'
Rewrite 4c74c75abb9cea3100de7586e183411f9ae0eebd (7/7) (4 seconds passed, remaining 0 predicted)
Ref 'refs/heads/main' was rewritten
Ref 'refs/remotes/origin/main' was rewritten
WARNING: Ref 'refs/remotes/origin/main' is unchanged

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>echo "m5/client/client.ino" >> .gitignore

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>git add .gitignore

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>git commit -m "Add client.ino to .gitignore"
[main c945e1d] Add client.ino to .gitignore
1 file changed, 1 insertion(+)
create mode 100644 .gitignore

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>git push origin --force --all
Enumerating objects: 69, done.
Counting objects: 100% (69/69), done.
Delta compression using up to 6 threads
Compressing objects: 100% (64/64), done.
Writing objects: 100% (69/69), 704.03 KiB | 140.81 MiB/s, done.
Total 69 (delta 5), reused 60 (delta 3), pack-reused 0
remote: Resolving deltas: 100% (5/5), done.
To https://github.com/thom-jp/health-tracking-pi

- 4c74c75...c945e1d main -> main (forced update)

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>git push origin --force --tags
Everything up-to-date

C:\Users\ho_\OneDrive\dev\htp\health-tracking-pi>

GitHubサポートからの返信メール

Thanks for reaching out! I've run a garbage collection and cleared our cached views for `thom-jp/health-tracking-pi`, so any sensitive data removed should no longer be accessible on GitHub.
If there are any remaining commits with sensitive data in them, you can send us the SHAs and we can check where else those commits might still be referenced. Otherwise, feel free to let us know if there's anything else we can assist you with!

お礼の返信もしておいた

I really appreciate for your prompt action.
Wishing you all the success in your future endeavors.

Sincerely Yours,
Thom

私もヘルプデスクで働いた経験があるが、丁寧にお礼を言ってくれるユーザーは意外と覚えているもので、次回の対応は率先して行うようになる。チームで対応してるのでSLAのファーストレスポンスギリギリまで取らない案件と、率先してピックアップされる案件がある。だって丁寧なユーザーでさっさと自分の手を埋めてしまったほうが横柄なユーザーが他のスタッフに回るし。Hahaha.

Python Pandasで週ごとの集計~週間運動時間グラフ表示

前回は摂取カロリー記録に使用しているM5 Stackに運動時間を入力する仕組みを追加した。
thom.hateblo.jp

入力されたデータをWifiでラズパイに飛ばして記録するところまでは完成している。

今回は記録した運動時間をmatplotlibでグラフ化して表示させる部分を作成した。

動作イメージ

週単位で運動時間を集計し、棒グラフがピンクの斜線のエリアに到達すればその週のノルマは達成したという意味になる。
f:id:t-hom:20210225003552p:plain

もともと紙で記録していた運動時間をシステム化したものであるが、実際の運動量を入力したところ2週間前の運動時間が150分に満たないことが判明。
紙の運用で△を全部潰したので達成!って思ってたんだけど、そういえば最低1つ〇を消し込んで達成というルールで設計したのを忘れていた。
f:id:t-hom:20210225003750p:plain

電子記録でグラフ化すればこんな間違いも防げる。

コード

カロリー記録よりもシンプルに見えて、実際には今回の方が難しかった。

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, date, timedelta
df = pd.read_csv("/home/pi/test_exercise.csv", header=None, names=["timestamp", "exercise_time"], parse_dates=["timestamp"])

print('\n--print dataframe which generated from csv--')
print(df)


base_day = datetime.now()
base_day = base_day.replace(hour=0, minute=0, second=0, microsecond=0)
print(base_day)
while base_day.weekday() != 5:
    base_day = base_day+timedelta(days=1)

dummy_data = []
for i in range(0, 7*7)[::-1]:
    sublist = []
    sublist.append((base_day -timedelta(days=i)))
    sublist.append(0)
    dummy_data.append(sublist)

dummy_columns = ["timestamp","exercise_time"]
dummy_df = pd.DataFrame(data=dummy_data,columns=dummy_columns)

print('\n--print dummy dataframe for data completeness--')
print(dummy_df)

df = df.append(dummy_df,ignore_index=True)

df["date"] = df["timestamp"].dt.strftime("%Y-%m-%d")
df = df.groupby(["date"]).agg({"exercise_time":"sum"}).reset_index()

print('\n--print completed data--')
print(df)

df["week"] = df['date'].apply(lambda x: -1 * ((base_day - datetime.strptime(x, '%Y-%m-%d')).days // 7))
df = df.groupby(['week']).agg({"exercise_time":"sum"}).reset_index()
print(df)


print('\n--print tailed data--')
df = df.tail(7)
print(df)


week = df["week"].values
exercise_time = df["exercise_time"].values
minimum = np.array([150 for i in df.index])
maximum = np.array([300 for i in df.index])
 
plt.title("Exercise Record", fontsize = 22)
plt.xlabel("Week", fontsize = 22)
plt.ylabel("Time", fontsize = 22)
plt.grid(True)
 

plt.bar(week, maximum-minimum, width=0.25, bottom = minimum, tick_label = week, align="center", label="Guideline", color = "none", edgecolor="#ff1493", lw=0, hatch="/////")

plt.bar(week, exercise_time, width=0.2, tick_label = week, align="center", label="Breakfast", color = "#dc143c")

plt.show()


悩んだポイントは「どうやって週単位のデータに変換するか」である。

1週間の定義を日曜~土曜としたいのだが、集計日を基準にしてしまうと1週間の範囲が日々変動することになる。

そこで、基準日(base_day)に一旦Nowを代入した後、その曜日(Weekday)が土曜になるまでbase_dayを進める。
こうしていつ集計しても当週の土曜が最終データになるようにダミーデータが作られ、基準日からの日数を7で割った整数に-1を掛けることで「何週前」を表現することにした。

今回新たに覚えた技は、データフレームのシリーズに対する加工である。
シリーズというのはデータ列のことで、例えば日付もシリーズとして格納されている。

シリーズ同士の計算はpythonが勝手にやってくれるんだけど、シリーズと単一データの計算は普通にやってもうまくいかない。
例えば次のような計算は失敗する。

df['week']  = base_day - df['date']

正しく計算するには、計算をラムダ関数にしてシリーズに適用させれば良いことが分かった。

df["week"] = df['date'].apply(lambda x: -1 * ((base_day - datetime.strptime(x, '%Y-%m-%d')).days // 7))

ああなるほど、関数型言語だなぁと思った。
また一つ、pandasの扱いが上達した気がして満足である。

おわりに

これで体重記録・摂取カロリー記録・運動量記録が揃った。
あとダイエット関連で足りないのは筋トレか。これは種目ごとの積み上げグラフとかにしたいなぁと思うけど、そもそも筋トレは始めてすらいないので紙での記録開始がまず先決かな。

開発するなら、ついでにメトロノームも実装してしまおうかと思う。

M5 Stack Basic ボタン長押しで動作モード変更するプログラム

先日実装した摂取カロリー記録システムだが、既に紙での記録より便利なので2末を待たずに電子記録に完全移行した。
実はカロリー以外にも一週間の運動時間を紙に記録していたのだが、カロリー記録で味をしめたのでこちらも電子化したい。

単純にもう一台M5 Stackを買ってクライアントごと分けるということを思いついたけど、そんなことをせずともボタンの長押しを活用すれば1台でカバーできることに気づいた。

つまりカロリー記録モードと運動時間記録モードを長押しで切り替えるのである。
図示するとこんな感じ。
f:id:t-hom:20210223210248p:plain

それで、開発を始めることにしたのだが、既存のM5 Stackはカロリー記録で本番稼働中なので一時的とはいえ開発中のプログラムを書き込んだり本番プログラムにロールバックしたりというのは面倒くさい。
本番稼働に影響を与えない形で開発したい。

というわけで、結局2代目ゲット。
f:id:t-hom:20210223211546p:plain

外観が全く同じなので、テプラでPRD・DEVシールを張って見分けるようにした。

M5 Stackのコード

力業で機能拡張したので褒められた内容ではないけど、とりあえず掲載。

//Under Development
#include <M5Stack.h>
#include <WiFi.h>

int p;
int digit[4];
char modeChr;

const char* ssid     = "xx999x-999x99-9";
const char* password = "99x999xx9999x";

const int port = 49153;
const IPAddress server_ip(192, 168, 1, 101);
WiFiClient client;

void setup() {
  // init lcd, serial, but don't init sd card
  M5.begin(true, false, true);
  M5.Power.begin();

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
      delay(500);
  }
  modeChr = 'C';
  resetNumber();
}

// Add the main program code into the continuous loop() function
void loop() {
  // update button state
  M5.update();
 
  // if you want to use Releasefor("was released for"), use .wasReleasefor(int time) below
  if (M5.BtnA.wasReleased() || M5.BtnA.pressedFor(1000, 200)) {
    if(digit[p]++ >= 9){digit[p]=0;}
    displayNumber();
  } else if (M5.BtnB.wasReleased() || M5.BtnB.pressedFor(1000, 200)) {
    if(++p >= 4){p=0;}
    displayNumber();
  } else if (M5.BtnC.wasReleased() || M5.BtnC.pressedFor(1000, 200)) {
    int x = 0;
    x = digit[0]*1000 + digit[1]*100 + digit[2]*10 + digit[3];
    
    bool result = sendData(x);
    if (result){
      M5.Lcd.println("Done!");
      modeChr = 'C';
    }
    else {
      M5.Lcd.println("Failed!");
    }
    soundFeedback(result);
    
    delay(1000);
    resetNumber();
  } else if (M5.BtnB.wasReleasefor(700)) {
    resetNumber();
  } else if (M5.BtnA.wasReleasefor(700)) {
    if (modeChr == 'C') {
      modeChr = 'E';
    } else {
      modeChr = 'C';
    }
    resetNumber();
  }
}

void soundFeedback(bool result){
    if (result){
      M5.Speaker.tone(659, 200);
      delay(200);
    }
    else {
      M5.Speaker.tone(440, 100);
      delay(100);
      M5.Speaker.mute();
      delay(100);
      M5.Speaker.tone(440, 100);
      delay(100);
      M5.Speaker.mute();
      delay(100);
      M5.Speaker.tone(440, 100);
      delay(100);
    }
    M5.Speaker.mute();
}

bool sendData(int n) {
  M5.Lcd.setTextSize(2);
  M5.Lcd.println("Connecting...");

  if (modeChr == 'C') {
    M5.Lcd.println("Sending " + String(n) + " kcal.");  
  } else {
    M5.Lcd.println("Sending " + String(n) + " mins.");  
  }
  
  if (!client.connect(server_ip, port)) {
      return false;
  }
  if (modeChr == 'C') {
    client.print("C " + String(n));
  } else {
    client.print("E " + String(n));
  }
  
  return true;
}

void resetNumber() {
  if(modeChr =='C') {
    p = 1;
  } else {
    p = 2;
  }
  for (int i=0; i<4; i++){
    digit[i] = {0};
  }
  displayNumber();
}

void displayNumber() {
  M5.Lcd.clear(BLACK);
  M5.Lcd.setCursor(0, 0);
  M5.Lcd.setTextSize(10);
  if (modeChr == 'C') {
    for(int i=0; i<4; i++) {
      if(i==p){M5.Lcd.setTextColor(YELLOW);}
      else {M5.Lcd.setTextColor(WHITE);}
      M5.Lcd.print(digit[i]);
    }
    M5.Lcd.setTextColor(WHITE);
    M5.Lcd.setTextSize(5);
    M5.Lcd.setCursor(180, 18);
    M5.Lcd.println("kcal");
  } else {
    for(int i=0; i<4; i++) {
      if(i==p){M5.Lcd.setTextColor(RED);}
      else {M5.Lcd.setTextColor(WHITE);}
      M5.Lcd.print(digit[i]);
    }
    M5.Lcd.setTextColor(WHITE);
    M5.Lcd.setTextSize(5);
    M5.Lcd.setCursor(180, 18);
    M5.Lcd.println("mins");
  }
}

サーバープログラムも1つで、送信する数値に1文字のモードコード(C=Calorie, E=Exercise)を付加することで書き込み先のファイルを判定させるようにした。
PythonでSplitしやすいようにコードと値の間はスペース区切りとしている。

開発中プログラムでは接続先ポートも変えて、万が一本番側のサーバーが稼働していてもつながらないようにした。

ラズパイ側のコード

こっちもまあ褒められたものではない。

import socket
import time
import csv
import os
from datetime import datetime

def meal_type(timestamp):
    if 0 <= timestamp.hour <= 10:
        return "Breakfast"
    elif 11 <= timestamp.hour <= 16:
        return "Lunch"
    else:
        return "Dinner"

def record_calorie(cal):
    file_path = '/home/pi/test_calorie.csv'
    with open(file_path,'a',newline='') as f:
        w = csv.writer(f)
        timestamp =  datetime.now()
        w.writerow([timestamp.strftime('%Y-%m-%d %H:%M:%S'), meal_type(timestamp), cal])
        print([timestamp.strftime('%Y-%m-%d %H:%M:%S'), meal_type(timestamp) ,cal])

def record_exercise(minutes):
    file_path = '/home/pi/test_exercise.csv'
    with open(file_path,'a',newline='') as f:
        w = csv.writer(f)
        timestamp =  datetime.now()
        w.writerow([timestamp.strftime('%Y-%m-%d %H:%M:%S'), minutes])
        print([timestamp.strftime('%Y-%m-%d %H:%M:%S'), minutes])

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind(("", 49153))
    while True:
        s.listen(1)
        conn, addr = s.accept()
        try:
            data = conn.recv(16).decode('utf8').split()
            print(data)
            if data[0] == "C":
                record_calorie(int(data[1]))
            elif data[0] == "E":
                record_exercise(int(data[1]))
            time.sleep(1)
        except socket.error:
            pass
        except KeyboardInterrupt:
            conn.close()
            s.close()
        conn.close()

受信したデータをカンマで区切って、最初のデータがCかEかで書き込み先のファイルや書き込み内容を振り分けている。
とりいそぎ、うまくいったようなので集計してグラフ表示させるプログラムができたら本番運用に乗せられる。

最後今回の開発がPRDサーバーとPRDクライアントに影響を与えていないことを確認するためにテスト書き込みを行って、テストデータを消しておしまい。

以上

新しくWebカメラを買う前に、部屋が映る範囲をFusion360で検証してみた。

最近オンライン英会話を始めたのでWebカメラを使うんだけど、画角が広すぎて困っている。
部屋がかなり広く映り込むので、片付けをサボると英会話に参加するハードルが上がるのだ。
グリーンバックを買えば良いのかもしれないが、そもそもグリーンバックでカバーできるのかもよく分かってない。

もう少し狭角のカメラが欲しいんだけど、そもそも今のカメラでどこまで映ってるのかを製図ソフトFusion 360でシミュレートしてみた。

今使っているのはこちら。広角120度とある。

部屋のサイズを測って、カメラの位置や理論上映り込むであろ120度のラインをFusion360で書いてみた。
f:id:t-hom:20210221134850p:plain
※建築用CADってわけでもないのでメートルはセンチ→ミリに縮尺変換している。

赤丸の位置がカメラである。Bandicamという録画ツールで実際にカメラ映像を見てみると、概ね理論どおりの範囲が映り込んでいることが分かった。

画角60度の製品を見つけたのでこちらもシミュレートしてみる。
f:id:t-hom:20210221140224p:plain

そうすると、かなり安全地帯(映り込まない場所)が増えることが分かった。
またグリーンバックの設置位置を検討したところ、最低幅160cm程度は必要であることが分かった。
(追記:と思ったけど人物と背景の境界認識できれば良いのでそんなデカいのは要らないのかも。市販品にそんな横幅が広いものはあまりなかった。)

ということで、まずはカメラの調達に出かけようと思う。

以上

Python: CSVデータをpandasで加工してmatplotlibでグラフ化する

今回もカロリー記録システムの開発記録である。
実は本日2本目の記事となる。なんとなく筆が乗ったというか、vimが乗ったというか、一気に開発を進めてしまった。

前回はカロリーをグラフ表示させるためのmatplotlib出力部分を作った。
thom.hateblo.jp

今回はこのグラフにデータを受け渡す部分。恐らく今回の開発で最難関になるだろう部分に取り組んだ。
pandasというライブラリでcsvからデータを読み取ってごにょごにょするんだけど、そのごにょごにょが凄く難しい。
単にpandasに慣れ親しんでいないためというのもあるけど、前回も体重記録を使えるデータに変換するのに大変に苦労した覚えがある。

コード

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, date, timedelta
df = pd.read_csv("/home/pi/calorie.csv", header=None, names=["timestamp","meal_type", "calorie"], parse_dates=["timestamp"])

print('\n--print dataframe which generated from csv--')
print(df)

dummy_data = []
for i in range(0, 7)[::-1]:
    for meal in ["Breakfast","Lunch","Dinner"]:
        sublist = []
        sublist.append((datetime.now()-timedelta(days=i)))
        sublist.append(meal)
        sublist.append(0)
        dummy_data.append(sublist)

dummy_columns = ["timestamp","meal_type","calorie"]
dummy_df = pd.DataFrame(data=dummy_data,columns=dummy_columns)

print('\n--print dummy dataframe for data completeness--')
print(dummy_df)

df = df.append(dummy_df,ignore_index=True)

df["date"] = df["timestamp"].dt.strftime("%Y-%m-%d")
df = df.groupby(["date","meal_type"]).agg({"calorie":"sum"}).reset_index()

print('\n--print completed data--')
print(df)

df = df.pivot_table(index="date", columns="meal_type", values="calorie")
print('\n--print pivot data--')
print(df)

print('\n--print tailed data--')
df = df.tail(7)
print(df)

record_date = [datetime.strptime(d,"%Y-%m-%d").strftime("%m/%d") for d in df.index]
minimum = np.array([1600 for i in df.index])
breakfast = df["Breakfast"].values
lunch = df["Lunch"].values
dinner = df["Dinner"].values
maximum = np.array([2400 for i in df.index])
 
plt.title("Calorie Record", fontsize = 22)
plt.xlabel("Date", fontsize = 22)
plt.ylabel("Calorie", fontsize = 22)
plt.grid(True)
 

plt.bar(record_date, maximum-minimum, width=0.25, bottom = minimum, tick_label = record_date, align="center", label="Guideline", color = "#98fb98", edgecolor="#008000", lw=0, hatch="/////")

plt.bar(record_date, breakfast, width=0.2, tick_label = record_date, align="center", label="Breakfast", color = "#f3d394")
plt.bar(record_date, lunch, width=0.2, bottom = breakfast, tick_label = record_date, align="center", label="Lunch", color = "#45938b")
plt.bar(record_date, dinner, width=0.2, bottom = breakfast+lunch, tick_label = record_date, align="center", label="Dinner", color = "#003a34")

#plt.legend(loc="upper right", fontsize=10)

plt.show()

決して褒められたコードではない。しかしなんとか動いたのでこれが今の限界。

動作イメージ

今日書いた別の記事と同じなので変わり映えしないけど、しいて言えば今日から電子的な記録を始めた実データを使った関係で前日までのデータは空っぽである。
f:id:t-hom:20210220232356p:plain

※朝ごはん(薄橙)が無いのは今朝クリニックで検査があった関係で食べてない為。

動作の詳細説明

このコードはデータが加工されていくそれぞれのステップでprintするように作っているので、そのステップごとのprint結果と何をしているのかを解説していく。

1) CSVから実際に記録されたカロリーデータを取得する

これは実データを読み込んでpandasライブラリのデータフレームという型になったものをprintしている。

--print dataframe which generated from csv--
            timestamp meal_type  calorie
0 2021-02-20 12:22:37     Lunch      498
1 2021-02-20 12:22:48     Lunch       51
2 2021-02-20 12:23:03     Lunch      456
3 2021-02-20 17:58:05    Dinner       84
4 2021-02-20 17:58:16    Dinner      321
5 2021-02-20 17:58:27    Dinner      108
6 2021-02-20 17:58:38    Dinner      765

2) 不足データを補完するためのダミーデータ作成

実データと同じ形式のDataFrameを過去7日分、それぞれBreakfast・Lunch・Dinnerを用意し、カロリーは0としておく。
集計されたときに項目の欠落を防止しつつ、実データと同じ日付でもカロリーに影響を与えない。

--print dummy dataframe for data completeness--
                    timestamp  meal_type  calorie
0  2021-02-14 23:11:14.317393  Breakfast        0
1  2021-02-14 23:11:14.317469      Lunch        0
2  2021-02-14 23:11:14.317488     Dinner        0
3  2021-02-15 23:11:14.317507  Breakfast        0
4  2021-02-15 23:11:14.317524      Lunch        0
5  2021-02-15 23:11:14.317541     Dinner        0
6  2021-02-16 23:11:14.317559  Breakfast        0
7  2021-02-16 23:11:14.317576      Lunch        0
8  2021-02-16 23:11:14.317593     Dinner        0
9  2021-02-17 23:11:14.317611  Breakfast        0
10 2021-02-17 23:11:14.317628      Lunch        0
11 2021-02-17 23:11:14.317644     Dinner        0
12 2021-02-18 23:11:14.317662  Breakfast        0
13 2021-02-18 23:11:14.317679      Lunch        0
14 2021-02-18 23:11:14.317696     Dinner        0
15 2021-02-19 23:11:14.317713  Breakfast        0
16 2021-02-19 23:11:14.317729      Lunch        0
17 2021-02-19 23:11:14.317746     Dinner        0
18 2021-02-20 23:11:14.317764  Breakfast        0
19 2021-02-20 23:11:14.317778      Lunch        0
20 2021-02-20 23:11:14.317793     Dinner        0

3) 実データのDataFrameとダミーのDataFrameを結合した後、dateとmeal_typeでグルーピング

--print completed data--
          date  meal_type  calorie
0   2021-02-14  Breakfast        0
1   2021-02-14     Dinner        0
2   2021-02-14      Lunch        0
3   2021-02-15  Breakfast        0
4   2021-02-15     Dinner        0
5   2021-02-15      Lunch        0
6   2021-02-16  Breakfast        0
7   2021-02-16     Dinner        0
8   2021-02-16      Lunch        0
9   2021-02-17  Breakfast        0
10  2021-02-17     Dinner        0
11  2021-02-17      Lunch        0
12  2021-02-18  Breakfast        0
13  2021-02-18     Dinner        0
14  2021-02-18      Lunch        0
15  2021-02-19  Breakfast        0
16  2021-02-19     Dinner        0
17  2021-02-19      Lunch        0
18  2021-02-20  Breakfast        0
19  2021-02-20     Dinner     1278
20  2021-02-20      Lunch     1005

4) meal_typeを列としてピボット集計する

--print pivot data--
meal_type   Breakfast  Dinner  Lunch
date                                
2021-02-14          0       0      0
2021-02-15          0       0      0
2021-02-16          0       0      0
2021-02-17          0       0      0
2021-02-18          0       0      0
2021-02-19          0       0      0
2021-02-20          0    1278   1005

5) 過去7日分だけ取り出す。

今回は実データが本日分しかないので加工結果は変わらない。
もしそれ以上のデータがあるとダミーもその分用意したり、グラフが細くなって見づらくなったりする。

--print tailed data--
meal_type   Breakfast  Dinner  Lunch
date                                
2021-02-14          0       0      0
2021-02-15          0       0      0
2021-02-16          0       0      0
2021-02-17          0       0      0
2021-02-18          0       0      0
2021-02-19          0       0      0
2021-02-20          0    1278   1005

6) numpyのndarray形式でそれぞれの列を抜き出してmatplotlibでplotする

ここは特に画面出力とかはしていない。
本当はDataFrameのままmatplotlibで出力する方法があるんだろうけど、前回の記事でnumpyのndarray形式をPlotするやり方の実績があるのでとりあえずデータを前回と同様の方式に合わせた。

CSVにカロリーを記録するプログラムの変更

以前書いたこちらの記事であるが、今回のプログラム作成にあたりこちらも変更した。
thom.hateblo.jp

これまで生のタイムスタンプだけ残してて、pandasで集計するときに時間によって朝食・昼食・夕食に分ければいいやと思ってたんだけど。。
そもそもデータ受信時に判定させて、一緒に記録しといたら楽なんじゃね?ということに気づいて改善。

カロリー記録時間をベースに以下の判定基準とした。
0時~11時:朝飯
11時~17時:昼飯
17時~24時:晩飯

深夜1時に何か食べるのが朝飯かと言われると微妙なんだが、当日の2時までは前日の晩飯扱いとかプログラム的に面倒なので。

一応コードも載せておこう。ブログに書いとけばぶっ飛んでも安心。(GitHub使えよといわれそうだけど。。)

import socket
import time
import csv
import os
from datetime import datetime

def meal_type(timestamp):
    if 0 <= timestamp.hour <= 10:
        return "Breakfast"
    elif 11 <= timestamp.hour <= 16:
        return "Lunch"
    else:
        return "Dinner"

def record_calorie(cal):
    file_path = '/home/pi/calorie.csv'
    with open(file_path,'a',newline='') as f:
        w = csv.writer(f)
        timestamp =  datetime.now()
        w.writerow([timestamp.strftime('%Y-%m-%d %H:%M:%S'), meal_type(timestamp), cal])
        print([timestamp.strftime('%Y-%m-%d %H:%M:%S'), meal_type(timestamp) ,cal])

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind(("", 49152))
    while True:
        s.listen(1)
        conn, addr = s.accept()
        try:
            data = conn.recv(16).decode('utf8')
            record_calorie(data)
            time.sleep(1)
        except socket.error:
            pass
        except KeyboardInterrupt:
            conn.close()
            s.close()
        conn.close()

おわりに

pandas、numpy、matplotlibはどれも非常に有用なツールであるが、私にとってはどれも非常に難しく感じる。
書籍が充実してなくて基本ネットで調べるしかないが、公式を当たると表記が専門的で難しく、有志のサイトを当たっても基礎知識がない状態でサンプルを見よう見まねで手探りで作っている状態だ。

でも以前に体重データ集計する際に使ったときよりは、pandasデータフレームについてずいぶん理解が進んだ気がする。

さて、今のところ摂取カロリー目安は固定にしているが、たぶん3か月もすればこの基準値では痩せなくなる。なぜなら体重の減少と同時に消費カロリーも減少するため。
そのあたりの計算は以下の記事で書いた。
thom.hateblo.jp

なので、次は摂取カロリー目安を体重記録と連動させて計算によって上下させる機能を追加したい。
あとはこのグラフをtkinterに埋め込むのと、カロリーデータ受信時に自動でグラフが起動するようにしたい。

既に運用でカバーできるくらいのところまでは作れたので、2月中はM5 Stackでの電子記録と紙記録を併用して、3月からは完全に電子記録に切り替えようと思う。

とりあえず、今回はここまで。

当ブログは、amazon.co.jpを宣伝しリンクすることによってサイトが紹介料を獲得できる手段を提供することを目的に設定されたアフィリエイト宣伝プログラムである、 Amazonアソシエイト・プログラムの参加者です。