class Analyser: def __init__( self, host="localhost", port="8086", sms_credentials=(), max_temperature=23, verbose=False, dry_run=False, ): self._client = InfluxDBClient(host, 8086) self.sms_credentials = sms_credentials self.verbose = verbose self.dry_run = dry_run self.max_temperature = max_temperature if self.verbose: def _query(*args, **kwargs): print(kwargs["query"]) return self._client._query(*args, **kwargs) self._client._query = self._client.query self._client.query = _query def log(self, channel, message=""): fg.orange = Style(RgbFg(255, 150, 50)) icons = { "logo": (fg.white + ASCII_ART, fg.rs), "header": (" " + ef.bold, rs.bold_dim), "subheader": (ef.i + fg.white + " ", fg.rs + rs.i + "\r\n"), "info": (" " + "🤷 " + fg.white, fg.rs), "error": (" " + "💥 " + fg.orange, fg.rs), "check": (" " + "🎉 " + fg.green, fg.rs), "phone": (" " + "📱", ""), "end": ("\r\n", ""), } before, after = icons.get(channel, "") print(f"{before} {message} {after}") def run(self, fermenters, date, group_time): self.log("logo") self.log( "header", f"Recherche d'anomalies pour les fermenteurs {unpack(fermenters)}") msg = "" if date != "now": msg += f"pour la date {date}, " msg += f"par tranches de {group_time} minutes." self.log("subheader", msg) for fermenter in fermenters: try: context = self.analyse(fermenter, start_time=date, group_time=group_time) except Anomaly as e: self.send_alert(e) else: self.log( "check", f"Pas d'anomalies detectées pour {fermenter} (consigne à {context['setpoint']}°C): {unpack_and_round(context['temperatures'])}.", ) self.log("end") def get_temperatures(self, fermenter, start_time, group_time, tries=2): if start_time == "now": start_time = "now()" since = group_time * 3 query = f""" SELECT mean("value") FROM "autogen"."mqtt_consumer_float" WHERE ("topic" = 'fermenters/{fermenter}/temperature') AND time >= {start_time} -{since}m AND time <= {start_time} GROUP BY time({group_time}m) fill(previous) """ response = self._client.query(query=query, database="telegraf") if not response: if tries: return self.get_temperatures(fermenter, start_time, group_time * 2, tries - 1) else: raise Anomaly("no-temperatures", {"fermenter": fermenter}) return [ temp for _, temp in response.raw["series"][0]["values"] if temp ] def get_setpoint(self, fermenter): query = f""" SELECT last("value") FROM "autogen"."mqtt_consumer_float" WHERE ("topic" = 'fermenters/{fermenter}/setpoint') """ response = self._client.query(query=query, database="telegraf") return response.raw["series"][0]["values"][0][-1] def get_cooling_info(self, fermenter, start_time="now"): if start_time == "now": start_time = "now()" query = f""" SELECT last("value") FROM "autogen"."mqtt_consumer_float" WHERE ("topic" = 'fermenters/{fermenter}/cooling') AND time <= {start_time} """ response = self._client.query(query=query, database="telegraf") return response.raw["series"][0]["values"][0][1] def analyse(self, fermenter, start_time, group_time): """Analyses the data, trying to find problems. Alerts if during group_time, the temperature rises whereas it's supposed to be cooling. """ all_temperatures = self.get_temperatures(fermenter, start_time, group_time) # Do the computation on the last 6 values (= last 30mn) context = dict( fermenter=fermenter, temperatures=all_temperatures, is_cooling=self.get_cooling_info(fermenter, start_time), setpoint=self.get_setpoint(fermenter), max_temp=self.max_temperature, ) if self.verbose: pprint(context) self.check_temperature_convergence(**context) # self.check_temperature_max(**context) return context def check_temperature_max(self, fermenter, temperatures, max_temp, *args, **kwargs): # Did we exceed the max? if any([temp > max_temp for temp in temperatures]): raise Anomaly( "temperature-exceeds-max", { "fermenter": fermenter, "temperatures": temperatures }, ) def check_temperature_convergence(self, fermenter, temperatures, is_cooling, setpoint, *args, **kwargs): is_decreasing = all(i >= j for i, j in zip(temperatures, temperatures[1:])) is_increasing = any(i < j for i, j in zip(temperatures, temperatures[1:])) if setpoint < temperatures[-1]: if (is_increasing and is_cooling and (temperatures[-1] - temperatures[0]) > 0.5): raise Anomaly( "temperature-rising", { "fermenter": fermenter, "temperatures": temperatures, "setpoint": setpoint, }, ) elif (setpoint > temperatures[-1] and is_decreasing and temperatures[0] - temperatures[-1] > 0.5): raise Anomaly( "temperature-falling", { "fermenter": fermenter, "temperatures": temperatures, "setpoint": setpoint, }, ) def send_alert(self, anomaly): context = anomaly.context anomaly_type = anomaly.message send = True message_type = "error" if anomaly_type == "temperature-rising": message = ( f"""Le fermenteur {context['fermenter']} grimpe en température """ f"""({unpack_and_round(context['temperatures'])}), alors qu'il est """ f"""sensé refroidir (consigne à {context['setpoint']}°C)!""" ) elif anomaly_type == "temperature-falling": message = ( f"Attention, le fermenteur {context['fermenter']} descends en temperature " f"({unpack_and_round(context['temperatures'])}) alors qu'il est sensé monter." ) elif anomaly_type == "no-temperatures": message = f"Aucune température n'est enregistrée par le fermenteur {context['fermenter']}." send = False message_type = "info" else: message = anomaly_type self.log(message_type, message) if send and not self.dry_run: self.send_multiple_sms(message) def send_multiple_sms(self, message): for (user, password) in self.sms_credentials: requests.get( "https://smsapi.free-mobile.fr/sendmsg", params={ "user": user, "pass": password, "msg": message }, ) self.log("phone", f"SMS envoyé à {user}")
class Analyser: def __init__( self, host="localhost", port="8086", signal_group_id=None, signal_cli="/usr/bin/signal-cli", max_authorized_delta=0.5, verbose=False, dry_run=False, send_test_message=False, db='db.json', reset_db=False, ): self._client = InfluxDBClient(host, 8086) self.signal_group_id = signal_group_id self.verbose = verbose self.dry_run = dry_run self.send_test_message = send_test_message self.signal_cli = signal_cli self.max_authorized_delta = max_authorized_delta self.db = TinyDB(db) if reset_db: self.db.truncate() if self.verbose: def _query(*args, **kwargs): print(kwargs["query"]) return self._client._query(*args, **kwargs) self._client._query = self._client.query self._client.query = _query def log(self, channel, message=""): fg.orange = Style(RgbFg(255, 150, 50)) icons = { "logo": (fg.white + ASCII_ART, fg.rs), "header": (" " + ef.bold, rs.bold_dim), "subheader": (ef.i + fg.white + " ", fg.rs + rs.i + "\r\n"), "info": (" " + "🤷 " + fg.white, fg.rs), "error": (" " + "💥 " + fg.orange, fg.rs), "check": (" " + "🎉 " + fg.green, fg.rs), "phone": (" " + "📱", ""), "debug": (" " + "🐛", fg.rs), "end": ("\r\n", bg.rs), } before, after = icons.get(channel, "") print(f"{bg.black}{before} {message} {after}") def update_state(self, fermenter, state): Fermenter = Query() self.db.upsert({'id': fermenter, 'state': state}, Fermenter.id == fermenter) def get_state(self, fermenter): Fermenter = Query() data = self.db.get(Fermenter.id == fermenter) if data: return data['state'] else: return STATE.OK # Consider things are okay by default. def run(self, fermenters, date, group_time): self.log("logo") self.log( "header", f"Recherche d'anomalies pour les fermenteurs {unpack(fermenters)}" ) msg = "" if date != "now": msg += f"pour la date {date}, " msg += f"par tranches de {group_time} minutes." self.log("subheader", msg) for fermenter in fermenters: try: context = self.analyse( fermenter, start_time=date, group_time=group_time ) except Anomaly as e: self.send_alert(e) else: self.log( "check", f"Pas d'anomalies détectées pour {fermenter} (consigne à {context['setpoint']}°C): {unpack_and_round(context['temperatures'])}.", ) self.update_state(fermenter, state=STATE.OK) if self.send_test_message: self.send_alert(Anomaly( ("Ceci est un message de test envoyé par le système " "de supervision de la brasserie"))) self.log("end") def get_temperatures(self, fermenter, start_time, group_time, tries=2): if start_time == "now": start_time = "now()" since = group_time * 3 query = f""" SELECT mean("value") FROM "autogen"."mqtt_consumer_float" WHERE ("topic" = 'fermenters/{fermenter}/temperature') AND time >= {start_time} -{since}m AND time <= {start_time} GROUP BY time({group_time}m) fill(previous) """ response = self._client.query(query=query, database="telegraf") if not response: if tries: return self.get_temperatures( fermenter, start_time, group_time * 2, tries - 1 ) else: raise Anomaly(STATE.NO_DATA, {"fermenter": fermenter}) temperatures = [temp for _, temp in response.raw["series"][0]["values"] if temp] if not temperatures: raise Anomaly(STATE.NO_DATA, {"fermenter": fermenter}) return temperatures def get_setpoint(self, fermenter): query = f""" SELECT last("value") FROM "autogen"."mqtt_consumer_float" WHERE ("topic" = 'fermenters/{fermenter}/setpoint') """ response = self._client.query(query=query, database="telegraf") return response.raw["series"][0]["values"][0][-1] def get_cooling_info(self, fermenter, start_time="now"): if start_time == "now": start_time = "now()" query = f""" SELECT last("value") FROM "autogen"."mqtt_consumer_float" WHERE ("topic" = 'fermenters/{fermenter}/cooling') AND time <= {start_time} """ response = self._client.query(query=query, database="telegraf") return response.raw["series"][0]["values"][0][1] def analyse(self, fermenter, start_time, group_time): all_temperatures = self.get_temperatures(fermenter, start_time, group_time) # Do the computation on the last 6 values (= last 30mn) context = dict( fermenter=fermenter, temperatures=all_temperatures, is_cooling=self.get_cooling_info(fermenter, start_time), setpoint=self.get_setpoint(fermenter), acceptable_delta=self.max_authorized_delta ) if self.verbose: pprint(context) self.check_temperature_convergence(**context) return context def check_temperature_convergence( self, fermenter, temperatures, is_cooling, setpoint, acceptable_delta, *args, **kwargs ): # That's here that we detect if problems occured. # We check : # - Should the temperature be falling? rising? # - Is it rising or falling? Are we going in the right direction? # - If we are going in the wrong direction, at what pace? is it acceptable? # - If we are about to send an alert, filter-out false positives : # - delta to setpoint > 0.5°C # - # If setpoint < last_temp, then we're going the wrong way. # Ex : Setpoint = 0 # Mesured temperature = 21, 20, 19, 18 # Then we're OK. # # But… Setpoint = 0 # Mesured temperature = 6,7,8 # We should raise. # So we need to know : # 1. If we're increasing or decreasing # 2. If we should be increasing or decreasing. last_temp = temperatures[-1] should_decrease = setpoint < last_temp should_increase = setpoint > last_temp inner_delta = temperatures[0] - temperatures[-1] absolute_delta = last_temp - setpoint is_decreasing = inner_delta > 0 is_increasing = inner_delta < 0 if (should_decrease and is_increasing and is_cooling and abs(inner_delta) > acceptable_delta and abs(absolute_delta) > acceptable_delta ): raise Anomaly( STATE.TEMP_RISING, { "fermenter": fermenter, "temperatures": temperatures, "setpoint": setpoint, }, ) elif ( should_increase and is_decreasing and abs(inner_delta) > acceptable_delta and abs(absolute_delta) > acceptable_delta ): raise Anomaly( STATE.TEMP_FALLING, { "fermenter": fermenter, "temperatures": temperatures, "setpoint": setpoint, }, ) def send_alert(self, anomaly): context = anomaly.context anomaly_type = anomaly.message send = True message_type = "error" if anomaly_type == STATE.TEMP_RISING: message = ( f"""Le fermenteur {context['fermenter']} grimpe en température """ f"""({unpack_and_round(context['temperatures'])}), alors qu'il est """ f"""sensé refroidir (consigne à {context['setpoint']}°C)!""" ) elif anomaly_type == STATE.TEMP_FALLING: message = ( f"Attention, le fermenteur {context['fermenter']} descends en temperature " f"({unpack_and_round(context['temperatures'])}) alors qu'il est sensé monter" f" (consigne à {context['setpoint']}°C)" ) elif anomaly_type == STATE.NO_DATA: message = f"Aucune température n'est enregistrée par le fermenteur {context['fermenter']}." else: message = anomaly_type self.log(message_type, message) if send and not self.dry_run: # Send the message first, then change the state in the database. if self.get_state(anomaly.context['fermenter']) == anomaly.message: self.log('debug', 'message already sent, not sending it again') else: self.send_signal_message(message) self.update_state(anomaly.context['fermenter'], anomaly.message) def send_signal_message(self, message): command = f'{self.signal_cli} send -m "{message}" -g {self.signal_group_id}' resp = delegator.run(command) self.log("debug", command) if resp.err: self.log("error", resp.err) else: self.log("phone", f"Message de groupe envoyé à {self.signal_group_id}")