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

1#!/usr/bin/env python3 

2""" 

3InfluxDB から制御用のセンシングデータを取得します。 

4 

5Usage: 

6 sensor.py [-c CONFIG] [-D] 

7 

8Options: 

9 -c CONFIG : CONFIG を設定ファイルとして読み込んで実行します。[default: config.yaml] 

10 -D : デバッグモードで動作します。 

11""" 

12 

13import logging 

14import os 

15 

16import my_lib.notify.slack 

17import my_lib.sensor_data 

18import my_lib.time 

19 

20import unit_cooler.const 

21import unit_cooler.controller.message 

22 

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 

44 

45 

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 

56 

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] 

100 

101 

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] 

194 

195 

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 ) 

213 

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 } 

223 

224 return {"status": 0, "message": None} 

225 

226 

227# NOTE: クーラーの稼働状況を評価する。 

228# (数字が大きいほど稼働状況が活発) 

229def get_cooler_activity(sense_data): 

230 mode_map = {} 

231 

232 for mode in unit_cooler.const.AIRCON_MODE: 

233 mode_map[mode] = 0 

234 

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 

239 

240 logging.info(mode_map) 

241 

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 

249 

250 

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 

256 

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 

262 

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 

270 

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 ) 

278 

279 return mode 

280 

281 

282def get_sense_data(config): 

283 zoneinfo = my_lib.time.get_zoneinfo() 

284 

285 if os.environ.get("DUMMY_MODE", "false") == "true": 

286 start = "-169h" 

287 stop = "-168h" 

288 else: 

289 start = "-1h" 

290 stop = "now()" 

291 

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 

310 

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}) 

324 

325 sense_data[kind] = kind_data 

326 

327 return sense_data 

328 

329 

330if __name__ == "__main__": 

331 # TEST Code 

332 import docopt 

333 import my_lib.config 

334 import my_lib.logger 

335 import my_lib.pretty 

336 

337 args = docopt.docopt(__doc__) 

338 

339 config_file = args["-c"] 

340 debug_mode = args["-D"] 

341 

342 my_lib.logger.init("test", level=logging.DEBUG if debug_mode else logging.INFO) 

343 

344 config = my_lib.config.load(config_file) 

345 

346 sense_data = get_sense_data(config) 

347 

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)))