Coverage for src/unit_cooler/controller/sensor.py: 96%
84 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-07-23 14:35 +0000
« prev ^ index » next coverage.py v7.9.1, created at 2025-07-23 14:35 +0000
1#!/usr/bin/env python3
2"""
3InfluxDB から制御用のセンシングデータを取得します。
5Usage:
6 sensor.py [-c CONFIG] [-D]
8Options:
9 -c CONFIG : CONFIG を設定ファイルとして読み込んで実行します。[default: config.yaml]
10 -D : デバッグモードで動作します。
11"""
13import logging
14import os
16import my_lib.notify.slack
17import my_lib.sensor_data
18import my_lib.time
20import unit_cooler.const
21import unit_cooler.controller.message
23############################################################
24# 屋外の状況を判断する際に参照する閾値 (判定対象は過去一時間の平均)
25#
26# 屋外の照度がこの値未満の場合、冷却の強度を弱める
27LUX_THRESHOLD = 300
28# 太陽の日射量がこの値未満の場合、冷却の強度を弱める
29SOLAR_RAD_THRESHOLD_LOW = 200
30# 太陽の日射量がこの値未満の場合、冷却の強度を強める
31SOLAR_RAD_THRESHOLD_HIGH = 700
32# 太陽の日射量がこの値より大きい場合、昼間とする
33SOLAR_RAD_THRESHOLD_DAYTIME = 50
34# 屋外の湿度がこの値を超えていたら、冷却を停止する
35HUMI_THRESHOLD = 96
36# 屋外の温度がこの値を超えていたら、冷却の強度を大きく強める
37TEMP_THRESHOLD_HIGH_H = 35
38# 屋外の温度がこの値を超えていたら、冷却の強度を強める
39TEMP_THRESHOLD_HIGH_L = 32
40# 屋外の温度がこの値を超えていたら、冷却の強度を少し強める
41TEMP_THRESHOLD_MID = 29
42# 降雨量〔mm/h〕がこの値を超えていたら、冷却を停止する
43RAIN_THRESHOLD_MID = 0.01
46# クーラーの状況を判断する際に参照する閾値
47#
48# クーラー動作中と判定する電力閾値(min)
49AIRCON_POWER_THRESHOLD_WORK = 20
50# クーラー平常運転中と判定する電力閾値(min)
51AIRCON_POWER_THRESHOLD_NORMAL = 500
52# クーラーフル稼働中と判定する電力閾値(min)
53AIRCON_POWER_THRESHOLD_FULL = 900
54# エアコンの冷房動作と判定する温度閾値(min)
55AIRCON_TEMP_THRESHOLD = 20
57COOLER_ACTIVITY_LIST = [
58 {
59 "judge": lambda mode_map: mode_map[unit_cooler.const.AIRCON_MODE.FULL] >= 2,
60 "message": "2 台以上のエアコンがフル稼働しています。(cooler_status: 6)",
61 "status": 6,
62 },
63 {
64 "judge": lambda mode_map: (mode_map[unit_cooler.const.AIRCON_MODE.FULL] >= 1)
65 and (mode_map[unit_cooler.const.AIRCON_MODE.NORMAL] >= 1),
66 "message": "複数台ののエアコンがフル稼働もしくは平常運転しています。(cooler_status: 5)",
67 "status": 5,
68 },
69 {
70 "judge": lambda mode_map: mode_map[unit_cooler.const.AIRCON_MODE.FULL] >= 1,
71 "message": "1 台以上のエアコンがフル稼働しています。(cooler_status: 4)",
72 "status": 4,
73 },
74 {
75 "judge": lambda mode_map: mode_map[unit_cooler.const.AIRCON_MODE.NORMAL] >= 2,
76 "message": "2 台以上のエアコンが平常運転しています。(cooler_status: 4)",
77 "status": 4,
78 },
79 {
80 "judge": lambda mode_map: mode_map[unit_cooler.const.AIRCON_MODE.NORMAL] >= 1,
81 "message": "1 台以上のエアコンが平常運転しています。(cooler_status: 3)",
82 "status": 3,
83 },
84 {
85 "judge": lambda mode_map: mode_map[unit_cooler.const.AIRCON_MODE.IDLE] >= 2,
86 "message": "2 台以上のエアコンがアイドル運転しています。(cooler_status: 2)",
87 "status": 2,
88 },
89 {
90 "judge": lambda mode_map: mode_map[unit_cooler.const.AIRCON_MODE.IDLE] >= 1,
91 "message": "1 台以上のエアコンがアイドル運転しています。(cooler_status: 1)",
92 "status": 1,
93 },
94 {
95 "judge": lambda mode_map: True, # noqa: ARG005
96 "message": "エアコンは稼働していません。(cooler_status: 0)",
97 "status": 0,
98 },
99]
102OUTDOOR_CONDITION_LIST = [
103 {
104 "judge": lambda sense_data: sense_data["rain"][0]["value"] > RAIN_THRESHOLD_MID,
105 "message": lambda sense_data: (
106 "雨が降っているので ({rain:.1f} mm/h) 冷却を停止します。(outdoor_status: -4)"
107 ).format(rain=sense_data["rain"][0]["value"]),
108 "status": -4,
109 },
110 {
111 "judge": lambda sense_data: sense_data["humi"][0]["value"] > HUMI_THRESHOLD,
112 "message": lambda sense_data: (
113 "湿度 ({humi:.1f} %) が " + "{threshold:.1f} % より高いので冷却を停止します。(outdoor_status: -4)"
114 ).format(humi=sense_data["humi"][0]["value"], threshold=HUMI_THRESHOLD),
115 "status": -4,
116 },
117 {
118 "judge": lambda sense_data: (sense_data["temp"][0]["value"] > TEMP_THRESHOLD_HIGH_H)
119 and (sense_data["solar_rad"][0]["value"] > SOLAR_RAD_THRESHOLD_DAYTIME),
120 "message": lambda sense_data: (
121 "日射量 ({solar_rad:,.0f} W/m^2) が "
122 "{solar_rad_threshold:,.0f} W/m^2 より大きく、"
123 "外気温 ({temp:.1f} ℃) が "
124 "{threshold:.1f} ℃ より高いので冷却を大きく強化します。(outdoor_status: 3)"
125 ).format(
126 solar_rad=sense_data["solar_rad"][0]["value"],
127 solar_rad_threshold=SOLAR_RAD_THRESHOLD_DAYTIME,
128 temp=sense_data["temp"][0]["value"],
129 threshold=TEMP_THRESHOLD_HIGH_H,
130 ),
131 "status": 3,
132 },
133 {
134 "judge": lambda sense_data: (sense_data["temp"][0]["value"] > TEMP_THRESHOLD_HIGH_L)
135 and (sense_data["solar_rad"][0]["value"] > SOLAR_RAD_THRESHOLD_DAYTIME),
136 "message": lambda sense_data: (
137 "日射量 ({solar_rad:,.0f} W/m^2) が "
138 "{solar_rad_threshold:,.0f} W/m^2 より大きく、"
139 "外気温 ({temp:.1f} ℃) が "
140 "{threshold:.1f} ℃ より高いので冷却を強化します。(outdoor_status: 2)"
141 ).format(
142 solar_rad=sense_data["solar_rad"][0]["value"],
143 solar_rad_threshold=SOLAR_RAD_THRESHOLD_DAYTIME,
144 temp=sense_data["temp"][0]["value"],
145 threshold=TEMP_THRESHOLD_HIGH_L,
146 ),
147 "status": 2,
148 },
149 {
150 "judge": lambda sense_data: sense_data["solar_rad"][0]["value"] > SOLAR_RAD_THRESHOLD_HIGH,
151 "message": lambda sense_data: (
152 "日射量 ({solar_rad:,.0f} W/m^2) が "
153 "{threshold:,.0f} W/m^2 より大きいので冷却を少し強化します。(outdoor_status: 1)"
154 ).format(
155 solar_rad=sense_data["solar_rad"][0]["value"],
156 threshold=SOLAR_RAD_THRESHOLD_HIGH,
157 ),
158 "status": 1,
159 },
160 {
161 "judge": lambda sense_data: (sense_data["temp"][0]["value"] > TEMP_THRESHOLD_MID)
162 and (sense_data["lux"][0]["value"] < LUX_THRESHOLD),
163 "message": lambda sense_data: (
164 " 外気温 ({temp:.1f} ℃) が {temp_threshold:.1f} ℃ より高いものの、"
165 "照度 ({lux:,.0f} LUX) が {lux_threshold:,.0f} LUX より小さいので、"
166 "冷却を少し弱めます。(outdoor_status: -1)"
167 ).format(
168 temp=sense_data["temp"][0]["value"],
169 temp_threshold=TEMP_THRESHOLD_MID,
170 lux=sense_data["lux"][0]["value"],
171 lux_threshold=LUX_THRESHOLD,
172 ),
173 "status": -1,
174 },
175 {
176 "judge": lambda sense_data: sense_data["lux"][0]["value"] < LUX_THRESHOLD,
177 "message": lambda sense_data: (
178 "照度 ({lux:,.0f} LUX) が {threshold:,.0f} LUX より小さいので冷却を弱めます。(outdoor_status: -2)"
179 ).format(lux=sense_data["lux"][0]["value"], threshold=LUX_THRESHOLD),
180 "status": -2,
181 },
182 {
183 "judge": lambda sense_data: sense_data["solar_rad"][0]["value"] < SOLAR_RAD_THRESHOLD_LOW,
184 "message": lambda sense_data: (
185 "日射量 ({solar_rad:,.0f} W/m^2) が "
186 "{threshold:,.0f} W/m^2 より小さいので冷却を少し弱めます。(outdoor_status: -1)"
187 ).format(
188 solar_rad=sense_data["solar_rad"][0]["value"],
189 threshold=SOLAR_RAD_THRESHOLD_LOW,
190 ),
191 "status": -1,
192 },
193]
196# NOTE: 外部環境の状況を評価する。
197# (数字が大きいほど冷却を強める)
198def get_outdoor_status(sense_data):
199 temp_str = (
200 f"{sense_data['temp'][0]['value']:.1f}" if sense_data["temp"][0]["value"] is not None else "?",
201 )
202 humi_str = (
203 f"{sense_data['humi'][0]['value']:.1f}" if sense_data["humi"][0]["value"] is not None else "?",
204 )
205 solar_rad_str = (
206 f"{sense_data['solar_rad'][0]['value']:,.0f}"
207 if sense_data["solar_rad"][0]["value"] is not None
208 else "?",
209 )
210 lux_str = (
211 f"{sense_data['lux'][0]['value']:,.0f}" if sense_data["lux"][0]["value"] is not None else "?",
212 )
214 logging.info(
215 "気温: %s ℃, 湿度: %s %%, 日射量: %s W/m^2, 照度: %s LUX", temp_str, humi_str, solar_rad_str, lux_str
216 )
218 is_senser_valid = all(
219 sense_data[key][0]["value"] is not None for key in ["temp", "humi", "solar_rad", "lux"]
220 )
222 if not is_senser_valid: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true
223 return {"status": -10, "message": "センサーデータが欠落していますので、冷却を停止します。"}
225 for condition in OUTDOOR_CONDITION_LIST: 225 ↛ 232line 225 didn't jump to line 232 because the loop on line 225 didn't complete
226 if condition["judge"](sense_data): 226 ↛ 225line 226 didn't jump to line 225 because the condition on line 226 was always true
227 return {
228 "status": condition["status"],
229 "message": condition["message"](sense_data),
230 }
232 return {"status": 0, "message": None}
235# NOTE: クーラーの稼働状況を評価する。
236# (数字が大きいほど稼働状況が活発)
237def get_cooler_activity(sense_data):
238 mode_map = {}
240 for mode in unit_cooler.const.AIRCON_MODE:
241 mode_map[mode] = 0
243 temp = sense_data["temp"][0]["value"]
244 for aircon_power in sense_data["power"]:
245 mode = get_cooler_state(aircon_power, temp)
246 mode_map[mode] += 1
248 logging.info(mode_map)
250 for condition in COOLER_ACTIVITY_LIST:
251 if condition["judge"](mode_map):
252 return {
253 "status": condition["status"],
254 "message": condition["message"],
255 }
256 raise AssertionError("This should never be reached.") # pragma: no cover # noqa: TRY003, EM101
259def get_cooler_state(aircon_power, temp):
260 mode = unit_cooler.const.AIRCON_MODE.OFF
261 if temp is None:
262 # NOTE: 外気温がわからないと暖房と冷房の区別がつかないので、致命的エラー扱いにする
263 raise RuntimeError("外気温が不明のため、エアコン動作モードを判断できません。") # noqa: EM101
265 if aircon_power["value"] is None:
266 logging.warning(
267 "%s の消費電力が不明のため、動作モードを判断できません。OFFとみなします。", aircon_power["name"]
268 )
269 return unit_cooler.const.AIRCON_MODE.OFF
271 if temp >= AIRCON_TEMP_THRESHOLD:
272 if aircon_power["value"] > AIRCON_POWER_THRESHOLD_FULL:
273 mode = unit_cooler.const.AIRCON_MODE.FULL
274 elif aircon_power["value"] > AIRCON_POWER_THRESHOLD_NORMAL:
275 mode = unit_cooler.const.AIRCON_MODE.NORMAL
276 elif aircon_power["value"] > AIRCON_POWER_THRESHOLD_WORK:
277 mode = unit_cooler.const.AIRCON_MODE.IDLE
279 logging.info(
280 "%s: %s W, 外気温: %.1f ℃ (mode: %s)",
281 aircon_power["name"],
282 f"{aircon_power['value']:,.0f}",
283 temp,
284 mode,
285 )
287 return mode
290def get_sense_data(config):
291 zoneinfo = my_lib.time.get_zoneinfo()
293 if os.environ.get("DUMMY_MODE", "false") == "true":
294 start = "-169h"
295 stop = "-168h"
296 else:
297 start = "-1h"
298 stop = "now()"
300 sense_data = {}
301 for kind in config["controller"]["sensor"]:
302 kind_data = []
303 for sensor in config["controller"]["sensor"][kind]:
304 data = my_lib.sensor_data.fetch_data(
305 config["controller"]["influxdb"],
306 sensor["measure"],
307 sensor["hostname"],
308 kind,
309 start,
310 stop,
311 last=True,
312 )
313 if data["valid"]:
314 value = data["value"][0]
315 if kind == "rain":
316 # NOTE: 観測している雨量は1分間の降水量なので、1時間雨量に換算
317 value *= 60
319 kind_data.append(
320 {
321 "name": sensor["name"],
322 "time": data["time"][0].replace(tzinfo=zoneinfo),
323 "value": value,
324 }
325 )
326 else:
327 unit_cooler.util.notify_error(
328 config,
329 f"{sensor['name']} のデータを取得できませんでした。",
330 )
331 kind_data.append({"name": sensor["name"], "value": None})
333 sense_data[kind] = kind_data
335 return sense_data
338if __name__ == "__main__":
339 # TEST Code
340 import docopt
341 import my_lib.config
342 import my_lib.logger
343 import my_lib.pretty
345 args = docopt.docopt(__doc__)
347 config_file = args["-c"]
348 debug_mode = args["-D"]
350 my_lib.logger.init("test", level=logging.DEBUG if debug_mode else logging.INFO)
352 config = my_lib.config.load(config_file)
354 sense_data = get_sense_data(config)
356 logging.info(my_lib.pretty.format(sense_data))
357 logging.info(my_lib.pretty.format(get_outdoor_status(sense_data)))
358 logging.info(my_lib.pretty.format(get_cooler_activity(sense_data)))