Coverage for src/unit_cooler/controller/sensor.py: 97%
81 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-28 08:08 +0000
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-28 08:08 +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.1
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 )
217 for condition in OUTDOOR_CONDITION_LIST: 217 ↛ 224line 217 didn't jump to line 224 because the loop on line 217 didn't complete
218 if condition["judge"](sense_data): 218 ↛ 217line 218 didn't jump to line 217 because the condition on line 218 was always true
219 return {
220 "status": condition["status"],
221 "message": condition["message"](sense_data),
222 }
224 return {"status": 0, "message": None}
227# NOTE: クーラーの稼働状況を評価する。
228# (数字が大きいほど稼働状況が活発)
229def get_cooler_activity(sense_data):
230 mode_map = {}
232 for mode in unit_cooler.const.AIRCON_MODE:
233 mode_map[mode] = 0
235 temp = sense_data["temp"][0]["value"]
236 for aircon_power in sense_data["power"]:
237 mode = get_cooler_state(aircon_power, temp)
238 mode_map[mode] += 1
240 logging.info(mode_map)
242 for condition in COOLER_ACTIVITY_LIST:
243 if condition["judge"](mode_map):
244 return {
245 "status": condition["status"],
246 "message": condition["message"],
247 }
248 raise AssertionError("This should never be reached.") # pragma: no cover # noqa: TRY003, EM101
251def get_cooler_state(aircon_power, temp):
252 mode = unit_cooler.const.AIRCON_MODE.OFF
253 if temp is None:
254 # NOTE: 外気温がわからないと暖房と冷房の区別がつかないので、致命的エラー扱いにする
255 raise RuntimeError("外気温が不明のため、エアコン動作モードを判断できません。") # noqa: EM101
257 if aircon_power["value"] is None:
258 logging.warning(
259 "%s の消費電力が不明のため、動作モードを判断できません。OFFとみなします。", aircon_power["name"]
260 )
261 return unit_cooler.const.AIRCON_MODE.OFF
263 if temp >= AIRCON_TEMP_THRESHOLD:
264 if aircon_power["value"] > AIRCON_POWER_THRESHOLD_FULL:
265 mode = unit_cooler.const.AIRCON_MODE.FULL
266 elif aircon_power["value"] > AIRCON_POWER_THRESHOLD_NORMAL:
267 mode = unit_cooler.const.AIRCON_MODE.NORMAL
268 elif aircon_power["value"] > AIRCON_POWER_THRESHOLD_WORK:
269 mode = unit_cooler.const.AIRCON_MODE.IDLE
271 logging.info(
272 "%s: %s W, 外気温: %.1f ℃ (mode: %s)",
273 aircon_power["name"],
274 f"{aircon_power['value']:,.0f}",
275 temp,
276 mode,
277 )
279 return mode
282def get_sense_data(config):
283 zoneinfo = my_lib.time.get_zoneinfo()
285 if os.environ.get("DUMMY_MODE", "false") == "true":
286 start = "-169h"
287 stop = "-168h"
288 else:
289 start = "-1h"
290 stop = "now()"
292 sense_data = {}
293 for kind in config["controller"]["sensor"]:
294 kind_data = []
295 for sensor in config["controller"]["sensor"][kind]:
296 data = my_lib.sensor_data.fetch_data(
297 config["controller"]["influxdb"],
298 sensor["measure"],
299 sensor["hostname"],
300 kind,
301 start,
302 stop,
303 last=True,
304 )
305 if data["valid"]:
306 value = data["value"][0]
307 if kind == "rain":
308 # NOTE: 観測している雨量は1分間の降水量なので、1時間雨量に換算
309 value *= 60
311 kind_data.append(
312 {
313 "name": sensor["name"],
314 "time": data["time"][0].replace(tzinfo=zoneinfo),
315 "value": value,
316 }
317 )
318 else:
319 unit_cooler.util.notify_error(
320 config,
321 f"{sensor['name']} のデータを取得できませんでした。",
322 )
323 kind_data.append({"name": sensor["name"], "value": None})
325 sense_data[kind] = kind_data
327 return sense_data
330if __name__ == "__main__":
331 # TEST Code
332 import docopt
333 import my_lib.config
334 import my_lib.logger
335 import my_lib.pretty
337 args = docopt.docopt(__doc__)
339 config_file = args["-c"]
340 debug_mode = args["-D"]
342 my_lib.logger.init("test", level=logging.DEBUG if debug_mode else logging.INFO)
344 config = my_lib.config.load(config_file)
346 sense_data = get_sense_data(config)
348 logging.info(my_lib.pretty.format(sense_data))
349 logging.info(my_lib.pretty.format(get_outdoor_status(sense_data)))
350 logging.info(my_lib.pretty.format(get_cooler_activity(sense_data)))