GPSマルチユニットを使って、そろそろ冷房つけたらを通知する

SORACOM Orbit試してみた4回目です。（お蔵入りさせてたけど）

---

実は去年作ってたんですが、夏終わりそうだったので、お蔵入りさせて、
夏が来たので、いよいよ公開することにしました。

---

COVID-19の影響で、在宅勤務が続いてるわけです。
家で仕事をするとなると、一番問題になるのが、電気代。

暑い夏、冷房をつけないと、室内でも熱中症になる可能性があります。
とはいえ、電気代のことを考えると、できる限りつけたくない。
(限界達すると、もういいかな・・・なりますが)

あとは家、風が通るので、窓を開けておけば、そこそこ涼しいので、
なんとか凌いできているのですが、
気温がそこまで高くなくても、湿度が高いとだんだん耐えられなくなってくるわけで。。。

じゃあ、どうするかってことで、
ということで、手元にあるソラコムのGPSマルチユニットが温度と湿度を測定できるので、
これを使って、暑さ指数(WBGT値)を測定し、閾値を超えたら、通知する仕組みを作ってみました。

なお、ソラコムさんの[SORACOM Summer Challenge 2020](https://blog.soracom.com/ja-jp/2020/08/24/soracom-summer-challenge-2020-accomplishment/)にも、同じ仕組みを作っている方がいて、
出遅れた(この内容自体は2020年7月ぐらいから作ろうと思ってて作れてなかった)と思ったのも現実。

## 構成図
好きなAWSサービスはAWS Lambda。好きなSORACOMサービスはSORACOM Funkな人ですが、
今回に関しては、この2つは一切出てきません。
AWSすら出てこないので、通知先を除けば、**SORACOMサービスオンリー**です。

![構成図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/cc4c7545-5576-f63f-06df-edd29a10592e.png)

GPSマルチユニットからの温度・湿度を元に、SORACOM Orbit上で暑さ指数(WBGT値)を計算。
SORACOM Harvestに保存して、SORACOM　Lagoonで可視化、
Lagoon側で閾値を設定し、超えたらアラート通知します。

### ソースコード
今回はTinyGoでやっています。
結局でどの言語でやるのがいいのか、未だ結論出ていません。（最近はAsssembly　Scriptでも書きました）

```golang
package main

import (
	"strings"
	"math"
	"strconv"
	"github.com/moznion/go-json-ice/serializer"
	"github.com/moznion/jsonparser"
	sdk "github.com/soracom/orbit-sdk-tinygo"
)

type Output struct {
	Lat  float64 `json:"lat"`
	Lon  float64 `json:"lon"`
	Bat  int64   `json:"bat"`
	Rs   int64   `json:"rs"`
	Temp float64 `json:"temp"`
	Humi float64 `json:humi"`
	X    float64 `json:"x"`
	Y    float64 `json:"y"`
	Z    float64 `json:"z"`
	Type int64   `json:"type"`
	Name string
	Wbgt float64
	VirusWarningLevel int
}
type GpsMultiUnitErrorCode sdk.ErrorCode

const (
	OkErrorCode   GpsMultiUnitErrorCode = 0
	ExecErrorCode                       = -1
)

// application entry point, but the orbit runtime never executes this.
func main() {
}

//export uplink
func uplink() sdk.ErrorCode {
	inputBuffer, err := sdk.GetInputBuffer()
	if err != nil {
		sdk.Log(err.Error())
		return -1
	}
	sdk.Log("Input Buffer: " + string(inputBuffer) + "\n")

	output, err := convertInputToOutput(inputBuffer)
	if err != nil {
		sdk.Log(err.Error())
		return sdk.ErrorCode(ExecErrorCode)
	}

	tagValueName, err := sdk.GetTagValue("name")
	if err != nil {
		sdk.Log(err.Error())
		return -1
	}
	sdk.Log("Name: " + string(tagValueName) + "\n")
	output.Name = string(tagValueName)

	if output.Bat != -1 {
		wbgt := CalcWbgt(output.Temp, output.Humi )
		sdk.Log("Wbgt: " + strconv.FormatFloat(wbgt, 'g', 4, 64) + "\n")
		output.Wbgt = wbgt
	
		// Dry Alert.
		virusWarningLevel := checkDryAlert(output.Temp, output.Humi)
		sdk.Log("virusWarningLevel: " + strconv.Itoa(virusWarningLevel) + "\n")
		output.VirusWarningLevel = virusWarningLevel
	} else {
		output.Wbgt = 0
		output.VirusWarningLevel = 0
	}


	serializedOutput, err := MarshalOutputAsJSON(output)
	if err != nil {
		sdk.Log(err.Error())
		return sdk.ErrorCode(ExecErrorCode)
	}

	sdk.SetOutputJSON(string(serializedOutput))

	return sdk.ErrorCode(0)
}

func convertInputToOutput(input []byte) (*Output, error) {
	lat, err := GetFloatForLocation(input, "lat")
	if err != nil {
		return nil, err
	}
	lon, err := GetFloatForLocation(input, "lon")
	if err != nil {
		return nil, err
	}
	bat, err := jsonparser.GetInt(input, "bat")
	if err != nil {
		return nil, err
	}
	rs, err := jsonparser.GetInt(input, "rs")
	if err != nil {
		return nil, err
	}

	temp, err := jsonparser.GetFloat(input, "temp")
	if err != nil {
		return nil, err
	}

	humi, err := jsonparser.GetFloat(input, "humi")
	if err != nil {
		return nil, err
	}

	x, err := jsonparser.GetFloat(input, "x")
	if err != nil {
		if strings.HasPrefix(err.Error(), "Value is not a number") {
			x = 0
		} else {
			return nil, err
		}
	}
	y, err := jsonparser.GetFloat(input, "y")
	if err != nil {
		return nil, err
	}

	z, err := jsonparser.GetFloat(input, "z")
	if err != nil {
		return nil, err
	}

	sendType, err := jsonparser.GetInt(input, "type")
	if err != nil {
		return nil, err
	}

	return &Output{
		Lat:  lat,
		Lon:  lon,
		Bat:  int64(bat),
		Rs:   int64(rs),
		Temp: temp,
		Humi: humi,
		X:    x,
		Y:    y,
		Z:    z,
		Type: int64(sendType),
	}, nil
}

func MarshalOutputAsJSON(s *Output) ([]byte, error) {
	buff := make([]byte, 1, 210)
	buff[0] = '{'
	if float64(s.Lat) != 0 {
		buff = append(buff, "\"lat\":"...)
		buff = serializer.AppendSerializedFloat(buff, float64(s.Lat))
		buff = append(buff, ',')
	}
	if float64(s.Lon) != 0 {
		buff = append(buff, "\"lon\":"...)
		buff = serializer.AppendSerializedFloat(buff, float64(s.Lon))
		buff = append(buff, ',')
	}
	buff = append(buff, "\"bat\":"...)
	buff = serializer.AppendSerializedInt(buff, int64(s.Bat))
	buff = append(buff, ',')
	buff = append(buff, "\"rs\":"...)
	buff = serializer.AppendSerializedInt(buff, int64(s.Rs))
	buff = append(buff, ',')
	buff = append(buff, "\"temp\":"...)
	buff = serializer.AppendSerializedFloat(buff, float64(s.Temp))
	buff = append(buff, ',')
	buff = append(buff, "\"humi\":"...)
	buff = serializer.AppendSerializedFloat(buff, float64(s.Humi))
	buff = append(buff, ',')
	buff = append(buff, "\"x\":"...)
	buff = serializer.AppendSerializedFloat(buff, float64(s.X))
	buff = append(buff, ',')
	buff = append(buff, "\"y\":"...)
	buff = serializer.AppendSerializedFloat(buff, float64(s.Y))
	buff = append(buff, ',')
	buff = append(buff, "\"z\":"...)
	buff = serializer.AppendSerializedFloat(buff, float64(s.Z))
	buff = append(buff, ',')
	buff = append(buff, "\"type\":"...)
	buff = serializer.AppendSerializedInt(buff, int64(s.Type))
	buff = append(buff, ',')
	buff = append(buff, "\"name\":"...)
	buff = serializer.AppendSerializedString(buff, s.Name)
	buff = append(buff, ',')
	buff = append(buff, "\"wbgt\":"...)
	buff = serializer.AppendSerializedFloat(buff, float64(s.Wbgt))
	buff = append(buff, ',')	
	buff = append(buff, "\"virusWarningLevel\":"...)
	buff = serializer.AppendSerializedInt(buff, int64(s.VirusWarningLevel))
	buff = append(buff, ',')

	if buff[len(buff)-1] == ',' {
		buff[len(buff)-1] = '}'
	} else {
		buff = append(buff, '}')
	}
	return buff, nil
}

// based on https://github.com/moznion/jsonparser/blob/master/parser.go#GetFloat
// GetFloat returns the value retrieved by `Get`, cast to a float64 if possible.
// The offset is the same as in `Get`.
// If key data type do not match, it will return an error.
func GetFloatForLocation(data []byte, keys ...string) (val float64, err error) {
	v, t, _, e := jsonparser.Get(data, keys...)

	if e != nil {
		return 0, e
	}

	if t != jsonparser.Number {
		return 0, nil
	}

	return jsonparser.ParseFloat(v)
}

func CalcWbgt(temp float64, humi float64 ) (wbgt float64){
	return math.Round(((0.735 * temp) + (0.0374 * humi) + (0.00292 * temp * humi)) - 4.064)	
}
func checkDryAlert(temp float64, humi float64 ) (isDry int){
	ePow := (7.5 * temp) / (temp + 237.3)
	e := 6.1078 * (math.Pow(10, ePow)) // 飽和水蒸気圧
	a := (217 * e) / (temp + 273.15) // 飽和水蒸気量
	rhP := humi / 100 
	ah := a * rhP // 絶対湿度
	sdk.Log("absoluteHumidity: " + strconv.FormatFloat(ah, 'g', 4, 64) + "\n")
	dryFlag := 0;
	if ah < 7 {
		dryFlag = 2
	} else {
		if ah < 11 {
			dryFlag = 1
		}
	}
	return dryFlag
}

```

### WBGT計算
暑さ指数(WBGT値)に関しては、以下に[環境省の熱中症予防サイトに記載された計算式](https://www.wbgt.env.go.jp/wbgt_detail.php#wbgt_2)を使ってます。
`WBGT＝0.735×Ta＋0.0374×RH＋0.00292×Ta×RH＋7.619×SR－4.557×SR2－0.0572×WS－4.064`
※Taは気温（℃）、RHは相対湿度（％）、SRは全天日射量（kW/m2)、WSは平均風速(m/s)。室内なので、全天日射量と平均風速は０にしてます。

```golang
func CalcWbgt(temp float64, humi float64 ) (wbgt float64){
	return math.Round(((0.735 * temp) + (0.0374 * humi) + (0.00292 * temp * humi)) - 4.064)	
}
```

### 絶対湿度計算（冬用）
冬、乾燥しすぎると、ウィルスは繁殖しやすいということで、絶対湿度計算も入れてあります。
[室温・湿度管理でインフル予防　20度以上、50～60％が理想](https://style.nikkei.com/article/DGXKZO93955790T11C15A1W13001?channel=DF140920160919)
[相対湿度から絶対湿度への計算方法](https://komoriss.com/relative-humidity-volumetric-humidity/)
個人的にはこっちの計算の方が面倒だったです。

```golang
func checkDryAlert(temp float64, humi float64 ) (isDry int){

	ePow := (7.5 * temp) / (temp + 237.3)
	e := 6.1078 * (math.Pow(10, ePow)) // 飽和水蒸気圧
	a := (217 * e) / (temp + 273.15) // 飽和水蒸気量
	rhP := humi / 100 
	ah := a * rhP // 絶対湿度
	sdk.Log("absoluteHumidity: " + strconv.FormatFloat(ah, 'g', 4, 64) + "\n")
	dryFlag := 0;
	if ah < 7 {
		dryFlag = 2
	} else {
		if ah < 11 {
			dryFlag = 1
		}
	}
	return dryFlag
}
```

### 充電時の話
この仕組みを作る前から、温度と湿度は可視化していた(湿度高いー、うげーとか思ってた)ので、
ちょこちょこみていたのですが、
充電時に、温度と湿度の値が大きく変化することに気づきました。

計算上はいい数字になるんですが、それもどうなのよってことで、ちょっと補正を入れようかな。。。と思ったんですが、
定量的に温度40度前後、湿度40%前後（ **注記 昨年9月ぐらいの値で、湿度は時期によりますが、若干低下傾向** ）になるので、無理と相成りました。

無論、可能ではあると思いますが、
それもそれでどうなのよってことになりましたので、
充電中(battery_level = -1)のときは、
計算をしないようにしました。

### 位置情報がNull(nil)だった場合の対処
GPSマルチユニットの位置情報は室内だと窓際を除けば取れないことが多々あります。
その場合、GetFloatだとエラーになってしまうので、
GetFloatForLocationという関数を独自に定義しました。
数値型じゃない、nilとか、ないと思いますが、文字列とかきた場合は、0を返すようにしています。

```golang
func GetFloatForLocation(data []byte, keys ...string) (val float64, err error) {
	v, t, _, e := jsonparser.Get(data, keys...)

	if e != nil {
		return 0, e
	}

	if t != jsonparser.Number {
		return 0, nil
	}

	return jsonparser.ParseFloat(v)
}
```

ちなみに、緯度0経度0になりますが、そこには[ヌル島](https://ja.wikipedia.org/wiki/%E3%83%8C%E3%83%AB%E5%B3%B6)っていう島があることになっているそうですね（初めて知りました。）

余談はさておき、もしかしたら、もっといい方法あるかもしれませんが、一旦これで。

## Lagoonでの表示およびアラート設定
OrbitからはJSONで送っているので、そのままキー(`wbgt`)を指定すれば取り出せます。

![SORACOM　Lagoonで可視化](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/69a3ac74-d685-2b53-c926-7e9d7ad07f10.png)

なお、グラフ上、10時から11時半過ぎまで値0になっていますが、
このGPSマルチユニットの充電をしておりまして、上記した通り、計算しないようにしているので、0になっています。

アラート設定に関してはこんな感じです。

![アラート設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/b3e97790-5bbb-505b-0446-fc653c244b22.png)

[環境省の熱中症予防情報サイトの暑さ指数について](https://www.wbgt.env.go.jp/wbgt.php)によれば、WBGT値が28以上から厳重警戒とのことなので、
27以上の場合にはアラートを飛ばすように設定しています。

通知はこんな感じ。今回は、LINEとEMailに送るようにしています（テストで送っているので、閾値を下げてます）

![LINE](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/e5d5cfb9-2298-9f80-786d-2b8254955723.jpeg)

![メール](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72752/ae223642-4a4b-d1d0-f8b2-13aec094b725.png)

## まとめ
Orbitがない(使わない)場合は、SORACOM FunkでAWS Lambdaにデータ渡して、
そこで計算して通知する(現にそれは作りました)って感じになりそうですが、
Lagoonのアラートみたいに、一定回数閾値を超えたらとなると、CloudWatchアラートまで必要なので、
使うサービスが増えてしまいます。

そういう点では、Orbitで計算を実装することで、
SORACOMサービスだけでできてしまうのは大変いいですね。

SORACOM Summer Challenge 2020の事例のように、
自動でエアコンつけられるまでできればよかったんですが、
そこまでの技術力はなかったです。反省。