1年以上前に投稿した「スプリンクラー制御システムの作成(概要編)」の続きとなります。概要の紹介だけ行って投稿が止まってしまっていましたので、これから記事を分けて各部分を少しずつ紹介していきたいと思います。
今回は太陽光発電の発電データを蓄積する部分の実装について紹介します。この部分では「AWS Step Functions」を使用しました。ここでStep Functionsを使用する理由は全くなかったのですが、AWSで使ったことがなかったサービスを使ってみたかったため、無理やり使用しました。
AWS Step Functionsとは?
AWS公式サイトを見ると、「分散アプリケーションのための視覚的なワークフロー」と書かれています。また、「220 以上の AWS サービスにおいて、コードをメンテナンスすることなくワークフローを自動化することができます。」とも書かれています。
どうやら、プログラムコードを書かずにAWSサービスを呼び出すことができそうです。プログラムの記述なしで、発電データをDynamoDBへ蓄積することを目指します。
実装範囲と仕様
「スプリンクラー制御システムの作成(概要編)」で紹介した全体図のうち、下の図のように赤い点線で囲った部分が今回の対象となります。チャージコントローラで収集したデータをRaspberry Piで受信し、API GatewayへREST APIを発行して登録、Step Functionsを通してDynamoDBへ蓄積します。
- 5分ごとにデータを収集します
- パネル電圧、パネル電流、発電電力、バッテリ電圧など
- 収集したデータは個別にDynamoDBに保存するのではなく、1日分をまとめて1エントリとします
「1日分をまとめて1エントリ」に加工する部分を、Step Functionsを活用してプログラムを書くことなく実装するのが目標です。収集したデータは下のスクリーンショットのようにWebブラウザからグラフで閲覧できるのですが(その部分については後日記事に書くかもしれません)、その際に1日分のデータが1つにまとまっている方がグラフ表示のパフォーマンスが上がるため、まとめる処理が必要となっています。
DynamoDBに保存されるデータは、下のように1日1エントリで5分ごとのデータがまとめられています。1日の最初のデータは新規にDynamoDBエントリが作成されますが、それ以降はそのエントリに新しいデータを追記していき、上書き保存していくことになります。
{
"date": {
"S": "2023-09-17"
},
"00:00:02": {
"M": {
"batt_voltage": {
"N": "12.91"
},
"charge_current": {
"N": "0"
},
"charge_power": {
"N": "0"
},
...
}
},
"00:05:02": {
"M": {
"batt_voltage": {
"N": "12.91"
},
"charge_current": {
"N": "0"
},
"charge_power": {
"N": "0"
},
...
}
},
"00:10:02": {
...
},
...
}
一番上の1日ごとのグラフの「発電時間(黄色い線)」が夏至の日(6/21)あたりを頂点とするグラフになっているのが分かりますでしょうか?
アルゴリズム
アルゴリズムというほどのものではないですが、処理内容をフローチャートで示すと下の図のようになります。説明もいらないほど簡単な内容となります。
これをStep Functionsのステートマシンに落とし込みます。AWSコンソールのStep Functionsのページで作成します。
ほぼフローチャートと同じ構造となりました。DynamoDBへデータ保存する際は、DynamoDB独特のデータ構造で渡す必要があるため、最初に「MakeNewParam」というステートを設置してデータ加工をしています。
各ステートの詳細についてはここでは説明しませんが、次の節のSAMテンプレートの記述を見ればご理解いただけると思います。
SAMによる管理
スプリンクラーシステム全体はAWS Serverless Application Model (SAM)で管理していますので、Step Functions部分をSAMテンプレートで記述します。該当部分のみを抜き出すと、以下のようになります。CloudFormationと比較して、SAMの場合はAWS::Serverless::StateMachineというタイプでAPI仕様や権限(Role)を含めて簡潔に記述可能です。
SolarUpdateStateMachine:
Type: AWS::Serverless::StateMachine
Properties:
DefinitionUri: statemachine/solar_update.asl.json
DefinitionSubstitutions:
DDBGetItem: !Sub arn:${AWS::Partition}:states:::dynamodb:getItem
DDBPutItem: !Sub arn:${AWS::Partition}:states:::dynamodb:putItem
DDBUpdateItem: !Sub arn:${AWS::Partition}:states:::dynamodb:updateItem
DDBTable: !Ref DynamoDBTable
Policies:
- DynamoDBReadPolicy:
TableName: !Ref DynamoDBTable
- DynamoDBWritePolicy:
TableName: !Ref DynamoDBTable
Events:
HttpRequest:
Type: Api
Properties:
Path: /update
Method: post
RestApiId:
Ref: ApiGatewayApi
ステートマシンの実体は別ファイルのstatemachine/solar_update.asl.jsonで記述します。
{
"StartAt": "MakeNewParam",
"States": {
"MakeNewParam": {
"Type": "Pass",
"Parameters": {
"date.$": "$.date",
"time.$": "$.time",
"newdata.$": "States.Format('\\{\"M\":\\{\"pv_voltage\":\\{\"N\":\"{}\"\\},\"batt_voltage\":\\{\"N\":\"{}\"\\},\"charge_voltage\":\\{\"N\":\"{}\"\\},\"charge_current\":\\{\"N\":\"{}\"\\},\"charge_power\":\\{\"N\":\"{}\"\\},\"discharge_voltage\":\\{\"N\":\"{}\"\\},\"discharge_current\":\\{\"N\":\"{}\"\\},\"discharge_power\":\\{\"N\":\"{}\"\\},\"buck_temp\":\\{\"N\":\"{}\"\\},\"env_temp\":\\{\"N\":\"{}\"\\},\"total_power\":\\{\"N\":\"{}\"\\}\\}\\}', $.pv_voltage, $.batt_voltage, $.charge_voltage, $.charge_current, $.charge_power, $.discharge_voltage, $.discharge_current, $.discharge_power, $.buck_temp, $.env_temp, $.total_power)"
},
"Next": "GetDynamoDB"
},
"GetDynamoDB": {
"Type": "Task",
"Resource": "${DDBGetItem}",
"Parameters": {
"TableName": "${DDBTable}",
"Key": {
"date": {
"S.$": "$.date"
}
}
},
"ResultPath": "$.getItem",
"Next": "CheckExist"
},
"CheckExist": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.getItem.Item.date",
"IsPresent": true,
"Next": "AppendDynamoDB"
}
],
"Default": "PutDynamoDB"
},
"AppendDynamoDB": {
"Type": "Task",
"Resource": "${DDBUpdateItem}",
"Parameters": {
"TableName": "${DDBTable}",
"Key": {
"date": {
"S.$": "$.date"
}
},
"UpdateExpression": "set #T = :rawdata",
"ReturnValues": "NONE",
"ExpressionAttributeNames": {"#T.$": "$.time"},
"ExpressionAttributeValues": {
":rawdata.$": "States.StringToJson($.newdata)"
}
},
"End": true
},
"PutDynamoDB": {
"Type": "Task",
"Resource": "${DDBPutItem}",
"Parameters": {
"TableName": "${DDBTable}",
"Item.$": "States.StringToJson(States.Format('\\{\"date\":\\{\"S\":\"{}\"\\},\"{}\":{}\\}', $.date, $.time, $.newdata))"
},
"End": true
}
}
}
Step Functionsを使ってみた感想
充電状況のデータをDynamoDBへ保存する部分をStep Functionsで実装してみましたが、正直、今回のような用途ではStep Functionsは適切ではなかったと思いました。理由はふたつあります。
- DynamoDBへアクセスする部分の記述は、AWS SDKを使ってプログラムを記述した場合のAPI発行記述とほとんど変わらない(簡単にならない)
- Lambdaで記述した場合に比べ、使用料金が高くなる
前者ですが、DynamoDBからデータを取得したり保存したりする記述は、Pythonなどの言語からAWS SDKを使ってDynamoDBのAPIを呼び出す記述とほとんど同じです。今回のような単純な用途では、Python等でプログラムを記述してLambdaで実現した方が早かったと思いました。
ステートマシン特有のデータの流れを意識した設計をしなければいけないところも時間がかかった要因と考えられます。プログラムではデータを変数に入れておいて、使う際に変数を参照すれば良いのですが、ステートマシンの場合は変数という概念がなく、一つのJSONデータを流していく構造となっているため、使うデータを上から下まで流さなければいけません。
Step Functionsの使用料金は、遷移したステート数で課金されます。課金の仕組みは以下となります。
- 4,000回/月の状態遷移は無料
- それ以降は1,000回の状態遷移ごとに$0.025($0.000025/1回の状態遷移)
今回のシステムでは5分ごとにステートマシンが起動されます。1回の起動あたりの状態遷移回数は6回となります。月当たりの料金は以下のようになります。
- 31日あたりの起動回数 = 60 / 5 * 24 * 31 = 8,928回
- 30日あたりのステート遷移数 = 8,928 * 6 = 53,568回
無料の4,000回を除いた49,568回が課金されます。実際に2023年10月の請求は下のようになっていました。
$1.24と僅かですが、料金がかかっています。仮にLambdaで実装した場合を考えますと、1ヶ月の呼び出し回数が8,928回であればLambdaの無料枠に充分収まりますので、料金はかかりません。
まとめ
今回は適切ではない用途でStep Functionsを使ったため、Step Functionsの良さが出ない結果となってしまいました。一方、Step Functionsにはいくつかの「フロー」という処理が用意されています。
この「フロー」を活用するような用途だとStep Functionsの良さが出てくるのではないかと思います。特にWaitやParallelがポイントとなるような気がします。
長時間待つような処理の場合、ただsleepするだけでもLambdaやFargateは時間課金のため、何もしていなくても課金されます。そもそもLambdaは15分の実行時間制限があるため、15分以上待つ処理はできません。しかし、1時間待とうが1日待とうがStep Functionsならば1状態遷移ですので、安く実装可能です。
Parallelを使用して、入ってきたデータ数に応じてLambdaを並列で動作させて、最後に結果を統合して出力、という用途も面白そうです。
次はStep Functionsの特徴を活かした用途で使用してみたいと思います。