28 Commits

Author SHA1 Message Date
47c3fbabf0 Merge pull request 'release/1.0' (#3) from release/1.0 into main
Reviewed-on: #3
2022-12-20 12:05:45 +01:00
d397ce377e [update] add network/station blacklists for mail functionality 2022-12-20 10:23:25 +01:00
d764c5c256 Merge remote-tracking branch 'origin/develop' into develop 2022-12-08 13:23:45 +01:00
a56781dca3 [minor] renamed and added stylesheets to git repository 2022-12-08 13:23:21 +01:00
fc64239c88 don't analysis values of data gaps when cheching for thresholds.
use stream.merge(fill_value=np.nan)
2022-12-06 16:34:08 +01:00
f0ae7da2be [update] add min_sample parameter, which controls ne number of samples that have to be of a specific voltage state before counting them as fail-state 2022-12-06 15:31:09 +01:00
a30cd8c0d4 [update] add trace axis ticking/min-max 2022-12-02 11:15:37 +01:00
a3378874fa [minor] parameter change 2022-12-02 11:15:01 +01:00
d21fb0ca3b [minor] parameter change 2022-11-29 10:42:29 +01:00
19b8df8f7d [minor] add html class for mobile (WIP) 2022-11-29 10:42:15 +01:00
6fc1e073c0 [bugfix] compared timedelta with int 2022-11-24 10:13:21 +01:00
56351ee700 [update] send message (mail) on station timeout 2022-11-23 11:52:26 +01:00
f45c5b20c5 [bugfix] set error state to active until mail is sent 2022-11-23 11:29:05 +01:00
7a2b7add04 [minor] parameter changes 2022-11-23 11:28:36 +01:00
ae0c2ef4e9 [minor] track activity status, modify html output for stylesheet 2022-11-22 18:06:25 +01:00
d35c176aab [update] add channel naming for plots 2022-11-22 15:51:15 +01:00
9444405453 [minor] reformat yaml file 2022-11-22 13:34:58 +01:00
a6d59c8c71 [update] added possibility to modify voltage level in plots from settings in parameters.yaml 2022-11-22 12:07:16 +01:00
3fe5fc48d1 [minor] re-ordered parameters 2022-11-21 10:36:46 +01:00
7da3db260a [update] error tracking + send email functionality 2022-11-17 09:52:04 +01:00
2c1e923920 [minor] add stylesheet to html header 2022-11-16 11:24:48 +01:00
8e42ac11c7 [update] complete rework of status handling (added Warn/Error classes etc.) 2022-11-15 17:19:39 +01:00
4d4324a1e9 [refactor] st_id -> nwst_id 2022-11-15 13:48:56 +01:00
4ba9c20d0f [update] add possibility to add columns with links to other web pages (e.g. seedlink monitor) 2022-11-15 13:44:19 +01:00
cd6b40688b [update] re-read data daily, add daily overlap, add value -1 for voltage lower 1V (e.g. pbox not connected) 2022-11-14 22:31:22 +01:00
04371f92c5 [minor] update README, add check for output dir existence 2022-11-09 16:53:43 +01:00
c723a32274 [update] html writer creates hourly figures of all stations 2022-11-09 16:29:01 +01:00
18dac062ef [bugfix] too many under 1V warnings appearing, moved them to "other" 2022-11-09 09:49:27 +01:00
9 changed files with 871 additions and 207 deletions

View File

@@ -26,12 +26,14 @@ to use the GUI:
Configurations of *datapath*, *networks*, *stations* etc. can be done in the **parameters.yaml** input file. Configurations of *datapath*, *networks*, *stations* etc. can be done in the **parameters.yaml** input file.
The main program is executed by entering The main program with html output is executed by entering
```shell script ```shell script
python survBot.py python survBot.py -html path_for_html_output
``` ```
There are example stylesheets in the folder *stylesheets* that can be copied into the path_for_html_output if desired.
The GUI can be loaded via The GUI can be loaded via
```shell script ```shell script

View File

@@ -1,28 +1,34 @@
# Parameters file for Surveillance Bot # Parameters file for Surveillance Bot
datapath: '/data/SDS/' # SC3 Datapath datapath: "/data/SDS/" # SC3 Datapath
networks: ['1Y', 'HA'] networks: ["1Y", "HA"] # select networks, list or str
stations: '*' stations: "*" # select stations, list or str
locations: '*' locations: "*" # select locations, list or str
channels: ['EX1', 'EX2', 'EX3', 'VEI'] # Specify SOH channels, currently supported EX[1-3] and VEI channels: ["EX1", "EX2", "EX3", "VEI"] # Specify SOH channels, currently supported EX[1-3] and VEI
stations_blacklist: ['TEST', 'EREA'] stations_blacklist: ["TEST", "EREA"] # exclude these stations
networks_blacklist: [] networks_blacklist: [] # exclude these networks
interval: 60 # Perform checks every x seconds interval: 60 # Perform checks every x seconds
n_track: 300 # wait n_track * intervals before performing an action (i.e. send mail/end highlight status)
timespan: 7 # Check data of the recent x days timespan: 7 # Check data of the recent x days
verbosity: 0 verbosity: 0 # verbosity flag
reread_parameters: True # reread parameters file (change parameters on runtime, not for itself/GUI refresh/datapath)
track_changes: True # tracks all changes since GUI startup by text highlighting (GUI only) track_changes: True # tracks all changes since GUI startup by text highlighting (GUI only)
warn_count: False # show number of warnings and errors in table
min_sample: 3 # minimum samples for raising Warn/FAIL
dt_thresh: [300, 1800] # threshold (s) for timing delay colourisation (yellow/red) dt_thresh: [300, 1800] # threshold (s) for timing delay colourisation (yellow/red)
html_figures: True # Create html figure directory and links
reread_parameters: True # reread parameters file (change parameters on runtime, not for itself/GUI refresh/datapath)
POWBOX: POWBOX:
pb_ok: 1 # Voltage for PowBox OK pb_ok: 1 # Voltage for PowBox OK
pb_SOH2: # PowBox channel 2 voltage translations pb_SOH2: # PowBox channel 2 voltage translations
1: {"230V": 'OK', "12V": "OK"} -1: {"230V": "PBox under 1V", "12V": "PBox under 1V"}
1: {"230V": "OK", "12V": "OK"}
2: {"230V": "OFF", "12V": "OK"} 2: {"230V": "OFF", "12V": "OK"}
3: {"230V": "OK", "12V": "overvoltage"} 3: {"230V": "OK", "12V": "overvoltage"}
4: {"230V": "OK", "12V": "undervoltage"} 4: {"230V": "OK", "12V": "undervoltage"}
4.5: {"230V": "OFF", "12V": "overvoltage"} 4.5: {"230V": "OFF", "12V": "overvoltage"}
5: {"230V": "OFF", "12V": "undervoltage"} 5: {"230V": "OFF", "12V": "undervoltage"}
pb_SOH3: # PowBox channel 3 voltage translations pb_SOH3: # PowBox channel 3 voltage translations
-1: {"router": "PBox under 1V", "charger": "PBox under 1V"}
1: {"router": "OK", "charger": "OK"} 1: {"router": "OK", "charger": "OK"}
2: {"router": "OK", "charger": "0 < resets < 3"} 2: {"router": "OK", "charger": "0 < resets < 3"}
2.5: {"router": "OK", "charger": "locked"} 2.5: {"router": "OK", "charger": "locked"}
@@ -30,10 +36,49 @@ POWBOX:
4: {"router": "FAIL", "charger": "0 < resets < 3"} 4: {"router": "FAIL", "charger": "0 < resets < 3"}
5: {"router": "FAIL", "charger": "locked"} 5: {"router": "FAIL", "charger": "locked"}
# Thresholds for program warnings/voltage classifications
THRESHOLDS: THRESHOLDS:
pb_thresh: 0.2 # Threshold for PowBox Voltage check +/- (V) pb_thresh: 0.2 # Threshold for PowBox Voltage check +/- (V)
max_temp: 50 # max temperature for temperature warning max_temp: 50 # max temperature for temperature warning
low_volt: 12 # min voltage for low voltage warning low_volt: 12 # min voltage for low voltage warning
high_volt: 14.8 # max voltage for over voltage warning high_volt: 14.8 # max voltage for over voltage warning
unclassified: 5 # min voltage samples not classified for warning unclassified: 5 # min voltage samples not classified for warning
# ---------------------------------------- OPTIONAL PARAMETERS ---------------------------------------------------------
# add links to html table with specified key as column and value as relative link, interpretable string parameters:
# nw (e.g. 1Y), st (e.g. GR01A), nwst_id (e.g. 1Y.GR01A)
add_links:
# for example: slmon: {"URL": "path/{nw}_{st}.html", "text": "link"}
slmon: {"URL": "../slmon/{nw}_{st}.html", "text": "show"}
24h-plot: {"URL": "../scheli/{nw}/{st}.png", "text": "plot"}
# E-mail notifications
EMAIL:
mailserver: "localhost"
addresses: ["marcel.paffrath@rub.de", "kasper.fischer@rub.de"] # list of mail addresses for info mails
sender: "webmaster@geophysik.ruhr-uni-bochum.de" # mail sender
stations_blacklist: ['GR33'] # do not send emails for specific stations
networks_blacklist: [] # do not send emails for specific network
# names for plotting of the above defined parameter "channels" in the same order
channel_names: ["Temperature (°C)", "230V/12V Status (V)", "Router/Charger State (V)", "Logger Voltage (V)"]
# specify y-ticks (and ylims) giving, (ymin, ymax, step) for each of the above channels (0: default)
CHANNEL_TICKS:
- [-10, 50, 10]
- [1, 5, 1]
- [1, 5, 1]
- [9, 15, 1]
# Factor for channel to SI-units (for plotting)
CHANNEL_UNITS:
EX1: 1e-6
EX2: 1e-6
EX3: 1e-6
VEI: 1e-3
# Transform channel for plotting, perform arithmetic operations in given order, e.g.: PBox EX1 V to deg C: 20 * x -20
CHANNEL_TRANSFORM:
EX1:
- ["*", 20]
- ["-", 20]

39
stylesheets/desktop.css Normal file
View File

@@ -0,0 +1,39 @@
body {
background-color: #ffffff;
place-items: center;
text-align: center;
}
td {
border-radius: 4px;
padding: 0px;
}
th {
background-color: #999;
border-radius: 4px;
padding: 3px 1px;
}
a:link, a:visited {
background-color: #ccc;
color: #000;
text-decoration: none;
display: block;
border-radius: 4px;
border: 1px solid #bbb;
}
a:hover {
background-color: #aaa;
display: block;
}
.blink-bg {
animation: blinkingBackground 2s infinite;
}
@keyframes blinkingBackground{
0% { background-color: #ffcc00;}
50% { background-color: #ff3200;}
100% { background-color: #ffcc00;}
}

43
stylesheets/mobile.css Normal file
View File

@@ -0,0 +1,43 @@
body {
background-color: #ffffff;
place-items: center;
text-align: center;
}
td {
border-radius: 4px;
padding: 10px 2px;
}
th {
background-color: #999;
border-radius: 4px;
padding: 10px, 2px;
}
a:link {
background-color: #ccc;
color: #000;
text-decoration: none;
display: block;
border-radius: 4px;
border: 1px solid #bbb;
}
a:hover {
background-color: #aaa;
display: block;
}
.hidden-mobile {
display: none;
}
.blink-bg {
animation: blinkingBackground 2s infinite;
}
@keyframes blinkingBackground{
0% { background-color: #ffee00;}
50% { background-color: #ff3200;}
100% { background-color: #ffee00;}
}

View File

@@ -6,8 +6,7 @@ ulimit -s 8192
#$ -cwd #$ -cwd
#$ -pe smp 1 #$ -pe smp 1
#$ -N survBot_bg #$ -N survBot_bg
#$ -l os=*stretch
export PYTHONPATH="$PYTHONPATH:/home/marcel/git/code_base/"
source /opt/anaconda3/etc/profile.d/conda.sh source /opt/anaconda3/etc/profile.d/conda.sh
conda activate py37 conda activate py37
@@ -17,4 +16,4 @@ export MKL_NUM_THREADS=1
export NUMEXPR_NUM_THREADS=1 export NUMEXPR_NUM_THREADS=1
export OMP_NUM_THREADS=1 export OMP_NUM_THREADS=1
python survBot.py -html '/home/marcel/public_html/survBot_out.html' python survBot.py -html '/data/www/~marcel/'

View File

@@ -12,13 +12,23 @@ import argparse
import time import time
from datetime import timedelta from datetime import timedelta
import numpy as np import numpy as np
import matplotlib.pyplot as plt
from obspy import read, UTCDateTime, Stream from obspy import read, UTCDateTime, Stream
from obspy.clients.filesystem.sds import Client from obspy.clients.filesystem.sds import Client
from write_utils import write_html_text, write_html_row, write_html_footer, write_html_header, get_print_title_str, \ from write_utils import write_html_text, write_html_row, write_html_footer, write_html_header, get_print_title_str, \
init_html_table, finish_html_table init_html_table, finish_html_table
from utils import get_bg_color from utils import get_bg_color, modify_stream_for_plot, trace_ylabels, trace_yticks
try:
import smtplib
from email.mime.text import MIMEText
mail_functionality = True
except ImportError:
print('Could not import smtplib or mail. Disabled sending mails.')
mail_functionality = False
pjoin = os.path.join pjoin = os.path.join
UP = "\x1B[{length}A" UP = "\x1B[{length}A"
@@ -31,12 +41,12 @@ def read_yaml(file_path):
return yaml.safe_load(f) return yaml.safe_load(f)
def nsl_from_id(st_id): def nsl_from_id(nwst_id):
network, station, location = st_id.split('.') network, station, location = nwst_id.split('.')
return dict(network=network, station=station, location=location) return dict(network=network, station=station, location=location)
def get_st_id(trace): def get_nwst_id(trace):
stats = trace.stats stats = trace.stats
return f'{stats.network}.{stats.station}.' # {stats.location}' return f'{stats.network}.{stats.station}.' # {stats.location}'
@@ -55,16 +65,20 @@ class SurveillanceBot(object):
self.parameter_path = parameter_path self.parameter_path = parameter_path
self.update_parameters() self.update_parameters()
self.starttime = UTCDateTime() self.starttime = UTCDateTime()
self.plot_hour = self.starttime.hour
self.current_day = self.starttime.julday
self.outpath_html = outpath_html self.outpath_html = outpath_html
self.filenames = [] self.filenames = []
self.filenames_read = [] self.filenames_read = []
self.station_list = [] self.station_list = []
self.analysis_print_list = [] self.analysis_print_list = []
self.analysis_results = {} self.analysis_results = {}
self.status_track = {}
self.dataStream = Stream() self.dataStream = Stream()
self.data = {} self.data = {}
self.print_count = 0 self.print_count = 0
self.status_message = '' self.status_message = ''
self.html_fig_dir = 'figures'
self.cl = Client(self.parameters.get('datapath')) # TODO: Check if this has to be loaded again on update self.cl = Client(self.parameters.get('datapath')) # TODO: Check if this has to be loaded again on update
self.get_stations() self.get_stations()
@@ -78,6 +92,8 @@ class SurveillanceBot(object):
self.networks_blacklist = self.parameters.get('networks_blacklist') self.networks_blacklist = self.parameters.get('networks_blacklist')
self.refresh_period = self.parameters.get('interval') self.refresh_period = self.parameters.get('interval')
self.transform_parameters() self.transform_parameters()
add_links = self.parameters.get('add_links')
self.add_links = add_links if add_links else {}
def transform_parameters(self): def transform_parameters(self):
for key in ['networks', 'stations', 'locations', 'channels']: for key in ['networks', 'stations', 'locations', 'channels']:
@@ -99,8 +115,8 @@ class SurveillanceBot(object):
if self.networks_blacklist and nw in self.networks_blacklist: if self.networks_blacklist and nw in self.networks_blacklist:
continue continue
if (networks == ['*'] or nw in networks) and (stations == ['*'] or st in stations): if (networks == ['*'] or nw in networks) and (stations == ['*'] or st in stations):
st_id = f'{nw}.{st}.' nwst_id = f'{nw}.{st}.'
self.station_list.append(st_id) self.station_list.append(nwst_id)
def get_filenames(self): def get_filenames(self):
self.filenames = [] self.filenames = []
@@ -117,31 +133,48 @@ class SurveillanceBot(object):
self.filenames += list(self.cl._get_filenames(network, station, location, channel, self.filenames += list(self.cl._get_filenames(network, station, location, channel,
starttime=t1, endtime=time_now)) starttime=t1, endtime=time_now))
def read_data(self): def read_data(self, re_read_at_hour=1, daily_overlap=2):
'''
read data method reads new data into self.stream
:param re_read_at_hour: update archive at specified hour each day (hours up to 24)
:param daily_overlap: re-read data of previous day until specified hour (hours up to 24)
'''
self.data = {} self.data = {}
# re-read all data every new day
curr_time = UTCDateTime()
current_day = curr_time.julday
current_hour = curr_time.hour
yesterday = (curr_time - 24. * 3600.).julday
if re_read_at_hour is not False and current_day != self.current_day and current_hour == re_read_at_hour:
self.filenames_read = []
self.dataStream = Stream()
self.current_day = current_day
# add all data to current stream # add all data to current stream
for filename in self.filenames: for filename in self.filenames:
if filename in self.filenames_read: if filename in self.filenames_read:
continue continue
try: try:
st_new = read(filename) st_new = read(filename, dtype=float)
julday = UTCDateTime().julday # add file to read filenames to prevent re-reading in case it is not the current day (or end of
# add file to read filenames to prevent re-reading in case it is not the current dayfile # previous day)
if not filename.endswith(str(julday)): if not filename.endswith(f'{current_day:03}') and not (
filename.endswith(f'{yesterday:03}') and current_hour <= daily_overlap):
self.filenames_read.append(filename) self.filenames_read.append(filename)
except Exception as e: except Exception as e:
print(f'Could not read file {filename}:', e) print(f'Could not read file {filename}:', e)
continue continue
self.dataStream += st_new self.dataStream += st_new
self.dataStream.merge() self.dataStream.merge(fill_value=np.nan)
# organise data in dictionary with key for each station # organise data in dictionary with key for each station
for trace in self.dataStream: for trace in self.dataStream:
st_id = get_st_id(trace) nwst_id = get_nwst_id(trace)
if not st_id in self.data.keys(): if not nwst_id in self.data.keys():
self.data[st_id] = Stream() self.data[nwst_id] = Stream()
self.data[st_id].append(trace) self.data[nwst_id].append(trace)
def execute_qc(self): def execute_qc(self):
if self.reread_parameters: if self.reread_parameters:
@@ -152,47 +185,65 @@ class SurveillanceBot(object):
self.analysis_print_list = [] self.analysis_print_list = []
self.analysis_results = {} self.analysis_results = {}
for st_id in sorted(self.station_list): for nwst_id in sorted(self.station_list):
stream = self.data.get(st_id) stream = self.data.get(nwst_id)
if stream: if stream:
nsl = nsl_from_id(st_id) nsl = nsl_from_id(nwst_id)
station_qc = StationQC(stream, nsl, self.parameters, self.keys, qc_starttime, self.verbosity, station_qc = StationQC(self, stream, nsl, self.parameters, self.keys, qc_starttime,
print_func=self.print) self.verbosity, print_func=self.print,
status_track=self.status_track.get(nwst_id))
analysis_print_result = station_qc.return_print_analysis() analysis_print_result = station_qc.return_print_analysis()
station_dict, warn_dict = station_qc.return_analysis() station_dict = station_qc.return_analysis()
else: else:
analysis_print_result = self.get_no_data_station(st_id, to_print=True) analysis_print_result = self.get_no_data_station(nwst_id, to_print=True)
station_dict, warn_dict = self.get_no_data_station(st_id) station_dict = self.get_no_data_station(nwst_id)
self.analysis_print_list.append(analysis_print_result) self.analysis_print_list.append(analysis_print_result)
self.analysis_results[st_id] = (station_dict, warn_dict) self.analysis_results[nwst_id] = station_dict
self.track_status()
self.update_status_message() self.update_status_message()
return 'ok' return 'ok'
def get_no_data_station(self, st_id, no_data='-', to_print=False): def track_status(self):
delay = self.get_station_delay(st_id) """
tracks error status of the last n_track + 1 errors.
"""
n_track = self.parameters.get('n_track')
if not n_track or n_track < 1:
return
for nwst_id, analysis_dict in self.analysis_results.items():
if not nwst_id in self.status_track.keys():
self.status_track[nwst_id] = {}
for key, status in analysis_dict.items():
if not key in self.status_track[nwst_id].keys():
self.status_track[nwst_id][key] = []
track_lst = self.status_track[nwst_id][key]
# pop list until length is n_track + 1
while len(track_lst) > n_track:
track_lst.pop(0)
track_lst.append(status.is_error)
def get_no_data_station(self, nwst_id, no_data='-', to_print=False):
delay = self.get_station_delay(nwst_id)
if not to_print: if not to_print:
status_dict = {} status_dict = {}
warn_dict = {}
for key in self.keys: for key in self.keys:
if key == 'last active': if key == 'last active':
status_dict[key] = timedelta(seconds=int(delay)) status_dict[key] = Status(message=timedelta(seconds=int(delay)), detailed_messages=['No data'])
warn_dict[key] = 'No data within set timespan'
else: else:
status_dict[key] = no_data status_dict[key] = Status(message=no_data, detailed_messages=['No data'])
warn_dict[key] = 'No data' return status_dict
return status_dict, warn_dict
else: else:
items = [st_id.rstrip('.')] + [fancy_timestr(timedelta(seconds=int(delay)))] items = [nwst_id.rstrip('.')] + [fancy_timestr(timedelta(seconds=int(delay)))]
for _ in range(len(self.keys) - 1): for _ in range(len(self.keys) - 1):
items.append(no_data) items.append(no_data)
return items return items
def get_station_delay(self, st_id): def get_station_delay(self, nwst_id):
""" try to get station delay from SDS archive using client""" """ try to get station delay from SDS archive using client"""
locations = ['', '0', '00'] locations = ['', '0', '00']
channels = ['HHZ', 'HHE', 'HHN', 'VEI', 'EX1', 'EX2', 'EX3'] channels = ['HHZ', 'HHE', 'HHN', 'VEI', 'EX1', 'EX2', 'EX3']
network, station = st_id.split('.')[:2] network, station = nwst_id.split('.')[:2]
times = [] times = []
for channel in channels: for channel in channels:
@@ -220,16 +271,20 @@ class SurveillanceBot(object):
:param refresh_period: Update every x seconds :param refresh_period: Update every x seconds
:return: :return:
''' '''
first_exec = True
status = 'ok' status = 'ok'
while status == 'ok' and self.refresh_period > 0: while status == 'ok' and self.refresh_period > 0:
status = self.execute_qc() status = self.execute_qc()
if self.outpath_html: if self.outpath_html:
self.write_html_table() self.write_html_table()
if self.parameters.get('html_figures'):
self.write_html_figures(check_plot_time=not (first_exec))
else: else:
self.print_analysis() self.print_analysis()
time.sleep(self.refresh_period) time.sleep(self.refresh_period)
if not self.outpath_html: if not self.outpath_html:
self.clear_prints() self.clear_prints()
first_exec = False
def console_print(self, itemlist, str_len=21, sep='|', seplen=3): def console_print(self, itemlist, str_len=21, sep='|', seplen=3):
assert len(sep) <= seplen, f'Make sure seperator has less than {seplen} characters' assert len(sep) <= seplen, f'Make sure seperator has less than {seplen} characters'
@@ -240,8 +295,79 @@ class SurveillanceBot(object):
string += item.center(str_len) + sr string += item.center(str_len) + sr
self.print(string, flush=False) self.print(string, flush=False)
def write_html_table(self, default_color='#e6e6e6'): def check_plot_hour(self):
fnout = self.outpath_html ''' Check if new hour started '''
current_hour = UTCDateTime().hour
if not current_hour > self.plot_hour:
return False
if current_hour == 23:
self.plot_hour = 0
else:
self.plot_hour += 1
return True
def get_fig_path_abs(self, nwst_id):
return pjoin(self.outpath_html, self.get_fig_path_rel(nwst_id))
def get_fig_path_rel(self, nwst_id, fig_format='png'):
return os.path.join(self.html_fig_dir, f'{nwst_id.rstrip(".")}.{fig_format}')
def check_fig_dir(self):
fdir = pjoin(self.outpath_html, self.html_fig_dir)
if not os.path.isdir(fdir):
os.mkdir(fdir)
def check_html_dir(self):
if not os.path.isdir(self.outpath_html):
os.mkdir(self.outpath_html)
def write_html_figures(self, check_plot_time=True):
""" Write figures for html (e.g. hourly) """
if check_plot_time and not self.check_plot_hour():
return
for nwst_id in self.station_list:
self.write_html_figure(nwst_id)
def write_html_figure(self, nwst_id):
""" Write figure for html for specified station """
self.check_fig_dir()
fig = plt.figure(figsize=(16, 9))
fnout = self.get_fig_path_abs(nwst_id)
st = self.data.get(nwst_id)
if st:
# TODO: this section might fail, adding try-except block for analysis and to prevent program from crashing
try:
st = modify_stream_for_plot(st, parameters=self.parameters)
st.plot(fig=fig, show=False, draw=False, block=False, equal_scale=False, method='full')
trace_ylabels(fig, self.parameters, self.verbosity)
trace_yticks(fig, self.parameters, self.verbosity)
except Exception as e:
print(f'Could not generate plot for {nwst_id}:')
print(traceback.format_exc())
if len(fig.axes) > 0:
ax = fig.axes[0]
ax.set_title(f'Plot refreshed at (UTC) {UTCDateTime.now().strftime("%Y-%m-%d %H:%M:%S")}. '
f'Refreshed hourly or on FAIL status.')
for ax in fig.axes:
ax.grid(True, alpha=0.1)
fig.savefig(fnout, dpi=150., bbox_inches='tight')
plt.close(fig)
def write_html_table(self, default_color='#e6e6e6', default_header_color='#999', hide_keys_mobile=('other')):
def get_html_class(status=None, check_key=None):
""" helper function for html class if a certain condition is fulfilled """
html_class = None
if status and status.is_active:
html_class = 'blink-bg'
if check_key in hide_keys_mobile:
html_class = 'hidden-mobile'
return html_class
self.check_html_dir()
fnout = pjoin(self.outpath_html, 'survBot_out.html')
if not fnout: if not fnout:
return return
try: try:
@@ -251,18 +377,28 @@ class SurveillanceBot(object):
init_html_table(outfile) init_html_table(outfile)
# First write header items # First write header items
header_items = [dict(text='Station', color=default_color)] header = self.keys.copy()
for check_key in self.keys: # add columns for additional links
item = dict(text=check_key, color=default_color) for key in self.add_links:
header.insert(-1, key)
header_items = [dict(text='Station', color=default_header_color)]
for check_key in header:
html_class = get_html_class(check_key=check_key)
item = dict(text=check_key, color=default_header_color, html_class=html_class)
header_items.append(item) header_items.append(item)
write_html_row(outfile, header_items, html_key='th') write_html_row(outfile, header_items, html_key='th')
# Write all cells # Write all cells
for st_id in self.station_list: for nwst_id in self.station_list:
col_items = [dict(text=st_id.rstrip('.'), color=default_color)] fig_name = self.get_fig_path_rel(nwst_id)
for check_key in self.keys: nwst_id_str = nwst_id.rstrip('.')
status_dict, detailed_dict = self.analysis_results.get(st_id) col_items = [dict(text=nwst_id_str, color=default_color, hyperlink=fig_name,
bold=True, tooltip=f'Show plot of {nwst_id_str}')]
for check_key in header:
if check_key in self.keys:
status_dict = self.analysis_results.get(nwst_id)
status = status_dict.get(check_key) status = status_dict.get(check_key)
message, detailed_message = status.get_status_str()
# get background color # get background color
dt_thresh = [timedelta(seconds=sec) for sec in self.dt_thresh] dt_thresh = [timedelta(seconds=sec) for sec in self.dt_thresh]
@@ -272,12 +408,23 @@ class SurveillanceBot(object):
# add degree sign for temp # add degree sign for temp
if check_key == 'temp': if check_key == 'temp':
if not type(status) in [str]: if not type(message) in [str]:
status = str(status) + deg_str message = str(message) + deg_str
item = dict(text=str(status), tooltip=str(detailed_dict.get(check_key)), html_class = get_html_class(status=status, check_key=check_key)
color=bg_color) item = dict(text=str(message), tooltip=str(detailed_message), color=bg_color,
html_class=html_class)
elif check_key in self.add_links:
value = self.add_links.get(check_key).get('URL')
link_text = self.add_links.get(check_key).get('text')
if not value:
continue
nw, st = nwst_id.split('.')[:2]
hyperlink_dict = dict(nw=nw, st=st, nwst_id=nwst_id)
link = value.format(**hyperlink_dict)
item = dict(text=link_text, tooltip=link, hyperlink=link, color=default_color)
col_items.append(item) col_items.append(item)
write_html_row(outfile, col_items) write_html_row(outfile, col_items)
finish_html_table(outfile) finish_html_table(outfile)
@@ -308,12 +455,13 @@ class SurveillanceBot(object):
class StationQC(object): class StationQC(object):
def __init__(self, stream, nsl, parameters, keys, starttime, verbosity, print_func): def __init__(self, parent, stream, nsl, parameters, keys, starttime, verbosity, print_func, status_track={}):
""" """
Station Quality Check class. Station Quality Check class.
:param nsl: dictionary containing network, station and location (key: str) :param nsl: dictionary containing network, station and location (key: str)
:param parameters: parameters dictionary from parameters.yaml file :param parameters: parameters dictionary from parameters.yaml file
""" """
self.parent = parent
self.stream = stream self.stream = stream
self.nsl = nsl self.nsl = nsl
self.network = nsl.get('network') self.network = nsl.get('network')
@@ -325,52 +473,186 @@ class StationQC(object):
self.last_active = False self.last_active = False
self.print = print_func self.print = print_func
timespan = self.parameters.get('timespan') * 24 * 3600
self.analysis_starttime = self.program_starttime - timespan
self.keys = keys self.keys = keys
self.detailed_status_dict = {key: None for key in self.keys} self.status_dict = {key: Status() for key in self.keys}
self.status_dict = {key: '-' for key in self.keys}
self.activity_check()
self.analyse_channels() if not status_track:
status_track = {}
self.status_track = status_track
def status_ok(self, key, message=None, status_message='OK'): self.start()
self.status_dict[key] = status_message
if message:
self.detailed_status_dict[key] = message
def warn(self, key, detailed_message, status_message='WARN'): @property
# update detailed status if already existing def nwst_id(self):
current_message = self.detailed_status_dict.get(key) return f'{self.network}.{self.station}'
current_message = '' if current_message in [None, '-'] else current_message + ' | '
self.detailed_status_dict[key] = current_message + detailed_message
# this is becoming a little bit too complicated (adding warnings to existing) def status_ok(self, key, detailed_message="Everything OK", status_message='OK', overwrite=False):
current_status_message = self.status_dict.get(key) current_status = self.status_dict.get(key)
current_status_message = '' if current_status_message in [None, 'OK', '-'] else current_status_message + ' | ' # do not overwrite existing warnings or errors
self.status_dict[key] = current_status_message + status_message if not overwrite and (current_status.is_warn or current_status.is_error):
return
self.status_dict[key] = StatusOK(message=status_message, detailed_messages=[detailed_message])
def warn(self, key, detailed_message, last_occurrence=None, count=1):
if key == 'other':
self.status_other(detailed_message, last_occurrence, count)
new_warn = StatusWarn(count=count, show_count=self.parameters.get('warn_count'))
current_status = self.status_dict.get(key)
# change this to something more useful, SMS/EMAIL/PUSH # change this to something more useful, SMS/EMAIL/PUSH
if self.verbosity: if self.verbosity:
self.print(f'{UTCDateTime()}: {detailed_message}', flush=False) self.print(f'{UTCDateTime()}: {detailed_message}', flush=False)
# if error, do not overwrite with warning
if current_status.is_error:
return
if current_status.is_warn:
current_status.count += count
else:
current_status = new_warn
self._update_status(key, current_status, detailed_message, last_occurrence)
# warnings.warn(message) # warnings.warn(message)
def error(self, key, message): # # update detailed status if already existing
self.detailed_status_dict[key] = message # current_message = self.detailed_status_dict.get(key)
self.status_dict[key] = 'FAIL' # current_message = '' if current_message in [None, '-'] else current_message + ' | '
# change this to something more useful, SMS/EMAIL/PUSH # self.detailed_status_dict[key] = current_message + detailed_message
#
# # this is becoming a little bit too complicated (adding warnings to existing)
# current_status_message = self.status_dict.get(key)
# current_status_message = '' if current_status_message in [None, 'OK', '-'] else current_status_message + ' | '
# self.status_dict[key] = current_status_message + status_message
def error(self, key, detailed_message, last_occurrence=None, count=1):
new_error = StatusError(count=count, show_count=self.parameters.get('warn_count'))
current_status = self.status_dict.get(key)
if current_status.is_error:
current_status.count += count
else:
current_status = new_error
# if error is new and not on program-startup set active and refresh plot (using parent class)
if self.search_previous_errors(key, n_errors=1) is True:
self.parent.write_html_figure(self.nwst_id)
if self.verbosity: if self.verbosity:
self.print(f'{UTCDateTime()}: {message}', flush=False) self.print(f'{UTCDateTime()}: {detailed_message}', flush=False)
# warnings.warn(message)
def activity_check(self): # do not send error mail if this is the first run (e.g. program startup) or state was already error (unchanged)
if self.search_previous_errors(key) is True:
self.send_mail(key, status_type='FAIL', additional_message=detailed_message)
# set status to "inactive" after sending info mail
current_status.is_active = False
elif self.search_previous_errors(key) == 'active':
current_status.is_active = True
self._update_status(key, current_status, detailed_message, last_occurrence)
def search_previous_errors(self, key, n_errors=None):
"""
Check n_track + 1 previous statuses for errors.
If first item in list is no error but all others are return True
(first time n_track errors appeared if ALL n_track + 1 are error: error is old)
If last item is error but not all items are error yet return keyword 'active' -> error active, no message sent
In all other cases return False.
This also prevents sending status (e.g. mail) in case of program startup
"""
if n_errors is not None:
n_errors = self.parameters.get('n_track') + 1
previous_errors = self.status_track.get(key)
# only if error list is filled n_track times
if previous_errors and len(previous_errors) == n_errors:
# if first entry was no error but all others are, return True (-> new Fail n_track times)
if not previous_errors[0] and all(previous_errors[1:]):
return True
# in case previous_errors exists, last item is error but not all items are error, error still active
elif previous_errors and previous_errors[-1] and not all(previous_errors):
return 'active'
return False
def send_mail(self, key, status_type, additional_message=''):
""" Send info mail using parameters specified in parameters file """
if not mail_functionality:
if self.verbosity:
print('Mail functionality disabled. Return')
return
mail_params = self.parameters.get('EMAIL')
if not mail_params:
if self.verbosity:
print('parameter "EMAIL" not set in parameter file. Return')
return
stations_blacklist = mail_params.get('stations_blacklist')
if stations_blacklist and self.station in stations_blacklist:
if self.verbosity:
print(f'Station {self.station} listed in blacklist. Return')
return
networks_blacklist = mail_params.get('networks_blacklist')
if networks_blacklist and self.network in networks_blacklist:
if self.verbosity:
print(f'Station {self.station} of network {self.network} listed in blacklist. Return')
return
sender = mail_params.get('sender')
addresses = mail_params.get('addresses')
server = mail_params.get('mailserver')
if not sender or not addresses:
if self.verbosity:
print('Mail sender or addresses not correctly defined. Return')
return
dt = self.get_dt_for_action()
text = f'{key}: Status {status_type} longer than {dt}: ' + additional_message
msg = MIMEText(text)
msg['Subject'] = f'new message on station {self.nwst_id}'
msg['From'] = sender
msg['To'] = ', '.join(addresses)
# send message via SMTP server
s = smtplib.SMTP(server)
s.sendmail(sender, addresses, msg.as_string())
s.quit()
def get_dt_for_action(self):
n_track = self.parameters.get('n_track')
interval = self.parameters.get('interval')
dt = timedelta(seconds=n_track * interval)
return dt
def status_other(self, detailed_message, status_message, last_occurrence=None, count=1):
key = 'other'
new_status = StatusOther(count=count, messages=[status_message])
current_status = self.status_dict.get(key)
if current_status.is_other:
current_status.count += count
current_status.messages.append(status_message)
else:
current_status = new_status
self._update_status(key, current_status, detailed_message, last_occurrence)
def _update_status(self, key, current_status, detailed_message, last_occurrence):
current_status.detailed_messages.append(detailed_message)
current_status.last_occurrence = last_occurrence
self.status_dict[key] = current_status
def activity_check(self, key='last_active'):
self.last_active = self.last_activity() self.last_active = self.last_activity()
if not self.last_active: if not self.last_active:
message = 'FAIL' status = StatusError()
else: else:
message = timedelta(seconds=int(self.program_starttime - self.last_active)) dt_active = timedelta(seconds=int(self.program_starttime - self.last_active))
self.status_dict['last active'] = message status = Status(message=dt_active)
self.check_for_inactive_message(key, dt_active)
self.status_dict['last active'] = status
def last_activity(self): def last_activity(self):
if not self.stream: if not self.stream:
@@ -381,39 +663,56 @@ class StationQC(object):
if len(endtimes) > 0: if len(endtimes) > 0:
return max(endtimes) return max(endtimes)
def check_for_inactive_message(self, key, dt_active):
dt_action = self.get_dt_for_action()
interval = self.parameters.get('interval')
if dt_action <= dt_active < dt_action + timedelta(seconds=interval):
self.send_mail(key, status_type='Inactive')
def start(self):
self.analyse_channels()
def analyse_channels(self): def analyse_channels(self):
timespan = self.parameters.get('timespan') * 24 * 3600
self.analysis_starttime = self.program_starttime - timespan
if self.verbosity > 0: if self.verbosity > 0:
self.print(150 * '#') self.print(150 * '#')
self.print('This is StationQT. Calculating quality for station' self.print('This is StationQT. Calculating quality for station'
' {network}.{station}.{location}'.format(**self.nsl)) ' {network}.{station}.{location}'.format(**self.nsl))
self.activity_check()
self.voltage_analysis() self.voltage_analysis()
self.pb_temp_analysis() self.pb_temp_analysis()
self.pb_power_analysis() self.pb_power_analysis()
self.pb_rout_charge_analysis() self.pb_rout_charge_analysis()
def return_print_analysis(self): def return_print_analysis(self):
items = [f'{self.network}.{self.station}'] items = [self.nwst_id]
for key in self.keys: for key in self.keys:
item = self.status_dict[key] status = self.status_dict[key]
message = status.message
if key == 'last active': if key == 'last active':
items.append(fancy_timestr(item)) items.append(fancy_timestr(message))
elif key == 'temp': elif key == 'temp':
items.append(str(item) + deg_str) items.append(str(message) + deg_str)
else: else:
items.append(str(item)) items.append(str(message))
return items return items
def return_analysis(self): def return_analysis(self):
return self.status_dict, self.detailed_status_dict return self.status_dict
def get_last_occurrence_timestring(self, trace, indices): def get_last_occurrence_timestring(self, trace, indices):
""" returns a nicely formatted string of the timedelta since program starttime and occurrence and abs time""" """ returns a nicely formatted string of the timedelta since program starttime and occurrence and abs time"""
last_occur = self.get_time(trace, indices[-1]) last_occur = self.get_last_occurrence(trace, indices)
if not last_occur: if not last_occur:
return '' return ''
last_occur_dt = timedelta(seconds=int(self.program_starttime - last_occur)) last_occur_dt = timedelta(seconds=int(self.program_starttime - last_occur))
return f', Last occurrence: {last_occur_dt} ({last_occur.strftime("%Y-%m-%d %H:%M:%S")})' return f', Last occurrence: {last_occur_dt} ({last_occur.strftime("%Y-%m-%d %H:%M:%S")})'
def get_last_occurrence(self, trace, indices):
return self.get_time(trace, indices[-1])
def voltage_analysis(self, channel='VEI'): def voltage_analysis(self, channel='VEI'):
""" Analyse voltage channel for over/undervoltage """ """ Analyse voltage channel for over/undervoltage """
key = 'voltage' key = 'voltage'
@@ -432,24 +731,25 @@ class StationQC(object):
undervolt = np.where(voltage < low_volt)[0] undervolt = np.where(voltage < low_volt)[0]
if len(overvolt) == 0 and len(undervolt) == 0: if len(overvolt) == 0 and len(undervolt) == 0:
self.status_ok(key, message=f'U={(voltage[-1])}V') self.status_ok(key, detailed_message=f'U={(voltage[-1])}V')
return return
n_overvolt = 0
n_undervolt = 0
warn_message = f'Trace {trace.get_id()}:' warn_message = f'Trace {trace.get_id()}:'
if len(overvolt) > 0: if len(overvolt) > 0:
# try calculate number of voltage peaks from gaps between indices # try calculate number of voltage peaks from gaps between indices
n_overvolt = len(np.where(np.diff(overvolt) > 1)[0]) + 1 n_overvolt = len(np.where(np.diff(overvolt) > 1)[0]) + 1
warn_message += f' {n_overvolt}x Voltage over {high_volt}V' \ detailed_message = warn_message + f' {n_overvolt}x Voltage over {high_volt}V' \
+ self.get_last_occurrence_timestring(trace, overvolt) + self.get_last_occurrence_timestring(trace, overvolt)
self.warn(key, detailed_message=detailed_message, count=n_overvolt,
last_occurrence=self.get_last_occurrence(trace, overvolt))
if len(undervolt) > 0: if len(undervolt) > 0:
# try calculate number of voltage peaks from gaps between indices # try calculate number of voltage peaks from gaps between indices
n_undervolt = len(np.where(np.diff(undervolt) > 1)[0]) + 1 n_undervolt = len(np.where(np.diff(undervolt) > 1)[0]) + 1
warn_message += f' {n_undervolt}x Voltage under {low_volt}V ' \ detailed_message = warn_message + f' {n_undervolt}x Voltage under {low_volt}V ' \
+ self.get_last_occurrence_timestring(trace, undervolt) + self.get_last_occurrence_timestring(trace, undervolt)
self.warn(key, detailed_message=warn_message, status_message='WARN ({})'.format(n_overvolt + n_undervolt)) self.warn(key, detailed_message=detailed_message, count=n_undervolt,
last_occurrence=self.get_last_occurrence(trace, undervolt))
def pb_temp_analysis(self, channel='EX1'): def pb_temp_analysis(self, channel='EX1'):
""" Analyse PowBox temperature output. """ """ Analyse PowBox temperature output. """
@@ -465,7 +765,7 @@ class StationQC(object):
nsamp_av = int(trace.stats.sampling_rate) * timespan nsamp_av = int(trace.stats.sampling_rate) * timespan
av_temp_str = str(round(np.mean(temp[-nsamp_av:]), 1)) + deg_str av_temp_str = str(round(np.mean(temp[-nsamp_av:]), 1)) + deg_str
# dt of average # dt of average
dt_t_str = str(timedelta(seconds=int(timespan))) dt_t_str = str(timedelta(seconds=int(timespan))).replace(', 0:00:00', '')
# current temp # current temp
cur_temp = round(temp[-1], 1) cur_temp = round(temp[-1], 1)
if self.verbosity > 1: if self.verbosity > 1:
@@ -478,27 +778,29 @@ class StationQC(object):
t_check = np.where(temp > max_temp)[0] t_check = np.where(temp > max_temp)[0]
if len(t_check) > 0: if len(t_check) > 0:
self.warn(key=key, self.warn(key=key,
status_message=cur_temp,
detailed_message=f'Trace {trace.get_id()}: ' detailed_message=f'Trace {trace.get_id()}: '
f'Temperature over {max_temp}\N{DEGREE SIGN} at {trace.get_id()}!' f'Temperature over {max_temp}\N{DEGREE SIGN} at {trace.get_id()}!'
+ self.get_last_occurrence_timestring(trace, t_check)) + self.get_last_occurrence_timestring(trace, t_check),
last_occurrence=self.get_last_occurrence(trace, t_check))
else: else:
self.status_ok(key, self.status_ok(key,
status_message=cur_temp, status_message=cur_temp,
message=f'Average temperature of last {dt_t_str}: {av_temp_str}') detailed_message=f'Average temperature of last {dt_t_str}: {av_temp_str}')
def pb_power_analysis(self, channel='EX2', pb_dict_key='pb_SOH2'): def pb_power_analysis(self, channel='EX2', pb_dict_key='pb_SOH2'):
""" Analyse EX2 channel of PowBox """ """ Analyse EX2 channel of PowBox """
keys = ['230V', '12V'] keys = ['230V', '12V']
st = self.stream.select(channel=channel) st = self.stream.select(channel=channel)
trace = self.get_trace(st, keys) trace = self.get_trace(st, keys)
if not trace: return if not trace:
return
voltage = trace.data * 1e-6 voltage = trace.data * 1e-6
if self.verbosity > 1: if self.verbosity > 1:
self.print(40 * '-') self.print(40 * '-')
self.print('Performing PowBox 12V/230V check (EX2)', flush=False) self.print('Performing PowBox 12V/230V check (EX2)', flush=False)
voltage_check, voltage_dict, last_val = self.pb_voltage_ok(trace, voltage, pb_dict_key, channel=channel, voltage_check, voltage_dict, last_val = self.pb_voltage_ok(trace, voltage, pb_dict_key, channel=channel)
warn_keys=keys)
if voltage_check: if voltage_check:
for key in keys: for key in keys:
self.status_ok(key) self.status_ok(key)
@@ -513,14 +815,15 @@ class StationQC(object):
pb_thresh = self.parameters.get('THRESHOLDS').get('pb_1v') pb_thresh = self.parameters.get('THRESHOLDS').get('pb_1v')
st = self.stream.select(channel=channel) st = self.stream.select(channel=channel)
trace = self.get_trace(st, keys) trace = self.get_trace(st, keys)
if not trace: return if not trace:
return
voltage = trace.data * 1e-6 voltage = trace.data * 1e-6
if self.verbosity > 1: if self.verbosity > 1:
self.print(40 * '-') self.print(40 * '-')
self.print('Performing PowBox Router/Charger check (EX3)', flush=False) self.print('Performing PowBox Router/Charger check (EX3)', flush=False)
voltage_check, voltage_dict, last_val = self.pb_voltage_ok(trace, voltage, pb_dict_key, channel=channel, voltage_check, voltage_dict, last_val = self.pb_voltage_ok(trace, voltage, pb_dict_key, channel=channel)
warn_keys=keys)
if voltage_check: if voltage_check:
for key in keys: for key in keys:
self.status_ok(key) self.status_ok(key)
@@ -532,22 +835,53 @@ class StationQC(object):
def in_depth_voltage_check(self, trace, voltage_dict, soh_params, last_val): def in_depth_voltage_check(self, trace, voltage_dict, soh_params, last_val):
""" Associate values in voltage_dict to error messages specified in SOH_params and warn.""" """ Associate values in voltage_dict to error messages specified in SOH_params and warn."""
for volt_lvl, ind_array in voltage_dict.items(): for volt_lvl, ind_array in voltage_dict.items():
if volt_lvl == 1: continue # No need to do anything here if volt_lvl == 1:
continue # No need to do anything here
if len(ind_array) > 0: if len(ind_array) > 0:
# get result from parameter dictionary for voltage level
result = soh_params.get(volt_lvl) result = soh_params.get(volt_lvl)
for key, message in result.items(): for key, message in result.items():
# if result is OK, continue with next voltage level
if message == 'OK': if message == 'OK':
self.status_ok(key) self.status_ok(key)
continue continue
# try calculate number of voltage peaks from gaps between indices if volt_lvl > 1:
n_occurrences = len(np.where(np.diff(ind_array) > 1)[0]) + 1 n_occurrences = self.calc_occurrences(ind_array)
self.warn(key=key, self.warn(key=key,
detailed_message=f'Trace {trace.get_id()}: ' detailed_message=f'Trace {trace.get_id()}: '
f'Found {n_occurrences} occurrence(s) of {volt_lvl}V: {key}: {message}' f'Found {n_occurrences} occurrence(s) of {volt_lvl}V: {key}: {message}'
+ self.get_last_occurrence_timestring(trace, ind_array), + self.get_last_occurrence_timestring(trace, ind_array),
status_message='WARN ({})'.format(n_occurrences)) count=n_occurrences,
if last_val != 1: last_occurrence=self.get_last_occurrence(trace, ind_array))
self.error(key, message=f'Last PowBox voltage state {last_val}V: {message}') # if last_val == current voltage (which is not 1) -> FAIL or last_val < 1: PBox no data
if volt_lvl == last_val or (volt_lvl == -1 and last_val < 1):
self.error(key, detailed_message=f'Last PowBox voltage state {last_val}V: {message}')
def calc_occurrences(self, ind_array):
# try calculate number of voltage peaks/plateaus from gaps between indices
if len(ind_array) == 0:
return 0
else:
# start index at 1 if there are gaps (n_peaks = n_gaps + 1)
n_occurrences = 1
min_samples = self.parameters.get('min_sample')
if not min_samples:
min_samples = 1
# calculated differences in index array, diff > 1: gap, diff == 1: within peak/plateau
diffs = np.diff(ind_array)
gap_start_inds = np.where(np.diff(ind_array) > 1)[0]
# iterate over all gaps and check "min_samples" before the gap
for gsi in gap_start_inds:
# right boundary index of peak (gap index - 1)
peak_rb_ind = gsi - 1
# left boundary index of peak
peak_lb_ind = max([0, peak_rb_ind - min_samples])
if all(diffs[peak_lb_ind: peak_rb_ind] == 1):
n_occurrences += 1
return n_occurrences
def get_trace(self, stream, keys): def get_trace(self, stream, keys):
if not type(keys) == list: if not type(keys) == list:
@@ -565,7 +899,7 @@ class StationQC(object):
return return
return trace return trace
def pb_voltage_ok(self, trace, voltage, pb_dict_key, warn_keys, channel=None): def pb_voltage_ok(self, trace, voltage, pb_dict_key, channel=None):
""" """
Checks if voltage level is ok everywhere and returns True. If it is not okay it returns a dictionary Checks if voltage level is ok everywhere and returns True. If it is not okay it returns a dictionary
with each voltage value associated to the different steps specified in POWBOX > pb_steps. Also raises with each voltage value associated to the different steps specified in POWBOX > pb_steps. Also raises
@@ -582,21 +916,10 @@ class StationQC(object):
# check if voltage is over or under OK-level (1V), if not return True # check if voltage is over or under OK-level (1V), if not return True
over = np.where(voltage > pb_ok + pb_thresh)[0] over = np.where(voltage > pb_ok + pb_thresh)[0]
under = np.where(voltage < pb_ok - pb_thresh)[0] under = np.where(voltage < pb_ok - pb_thresh)[0]
if len(over) == 0 and len(under) == 0: if len(over) == 0 and len(under) == 0:
return True, {}, last_voltage return True, {}, last_voltage
# Warn in case of voltage under OK-level (1V)
if len(under) > 0:
# try calculate number of occurences from gaps between indices
n_occurrences = len(np.where(np.diff(under) > 1)[0]) + 1
for key in warn_keys:
self.warn(key=key,
detailed_message=f'Trace {trace.get_id()}: '
f'Voltage below {pb_ok}V in {len(under)} samples, {n_occurrences} time(s). '
f'Mean voltage: {np.mean(voltage):.2}'
+ self.get_last_occurrence_timestring(trace, under),
status_message='WARN ({})'.format(n_occurrences))
# Get voltage levels for classification # Get voltage levels for classification
voltage_dict = {} voltage_dict = {}
classified_indices = np.array([]) classified_indices = np.array([])
@@ -607,13 +930,24 @@ class StationQC(object):
voltage_dict[volt] = indices voltage_dict[volt] = indices
classified_indices = np.append(classified_indices, indices) classified_indices = np.append(classified_indices, indices)
# Warn in case of voltage under OK-level (1V)
if len(under) > 0:
# try calculate number of occurences from gaps between indices
n_occurrences = len(np.where(np.diff(under) > 1)[0]) + 1
voltage_dict[-1] = under
self.status_other(detailed_message=f'Trace {trace.get_id()}: '
f'Voltage below {pb_ok}V in {len(under)} samples, {n_occurrences} time(s). '
f'Mean voltage: {np.mean(voltage):.2}'
+ self.get_last_occurrence_timestring(trace, under),
status_message='under 1V ({})'.format(n_occurrences))
# classify last voltage values # classify last voltage values
for volt in voltage_levels: for volt in voltage_levels:
if (last_voltage < volt + pb_thresh) and (last_voltage > volt - pb_thresh): if (last_voltage < volt + pb_thresh) and (last_voltage > volt - pb_thresh):
last_val = volt last_val = volt
break break
else: else:
last_val = np.nan last_val = round(last_voltage, 2)
# in case not all voltage values could be classified # in case not all voltage values could be classified
if not len(classified_indices) == len(voltage): if not len(classified_indices) == len(voltage):
@@ -622,7 +956,7 @@ class StationQC(object):
n_unclassified = len(unclassified_indices) n_unclassified = len(unclassified_indices)
max_uncl = self.parameters.get('THRESHOLDS').get('unclassified') max_uncl = self.parameters.get('THRESHOLDS').get('unclassified')
if max_uncl and n_unclassified > max_uncl: if max_uncl and n_unclassified > max_uncl:
self.warn(key='other', detailed_message=f'Trace {trace.get_id()}: ' self.status_other(detailed_message=f'Trace {trace.get_id()}: '
f'{n_unclassified}/{len(all_indices)} ' f'{n_unclassified}/{len(all_indices)} '
f'unclassified voltage values in channel {trace.get_id()}', f'unclassified voltage values in channel {trace.get_id()}',
status_message=f'{channel}: {n_unclassified} uncl.') status_message=f'{channel}: {n_unclassified} uncl.')
@@ -634,10 +968,100 @@ class StationQC(object):
return trace.stats.starttime + trace.stats.delta * index return trace.stats.starttime + trace.stats.delta * index
class Status(object):
def __init__(self, message=None, detailed_messages=None, count: int = 0, last_occurrence=None, show_count=True):
if message is None:
message = '-'
if detailed_messages is None:
detailed_messages = []
self.show_count = show_count
self.message = message
self.messages = [message]
self.detailed_messages = detailed_messages
self.count = count
self.last_occurrence = last_occurrence
self.is_warn = None
self.is_error = None
self.is_other = False
self.is_active = False
def set_warn(self):
self.is_warn = True
def set_error(self):
self.is_warn = False
self.is_error = True
def set_ok(self):
self.is_warn = False
self.is_error = False
def get_status_str(self):
message = self.message
if self.count > 1 and self.show_count:
message += f' ({self.count})'
detailed_message = ''
for index, dm in enumerate(self.detailed_messages):
if index > 0:
detailed_message += ' | '
detailed_message += dm
return message, detailed_message
class StatusOK(Status):
def __init__(self, message='OK', detailed_messages=None):
super(StatusOK, self).__init__(message=message, detailed_messages=detailed_messages)
self.set_ok()
class StatusWarn(Status):
def __init__(self, message='WARN', count=1, last_occurence=None, detailed_messages=None, show_count=False):
super(StatusWarn, self).__init__(message=message, count=count, last_occurrence=last_occurence,
detailed_messages=detailed_messages, show_count=show_count)
self.set_warn()
class StatusError(Status):
def __init__(self, message='FAIL', count=1, last_occurence=None, detailed_messages=None, show_count=False):
super(StatusError, self).__init__(message=message, count=count, last_occurrence=last_occurence,
detailed_messages=detailed_messages, show_count=show_count)
self.set_error()
class StatusOther(Status):
def __init__(self, messages=None, count=1, last_occurence=None, detailed_messages=None):
super(StatusOther, self).__init__(count=count, last_occurrence=last_occurence,
detailed_messages=detailed_messages)
if messages is None:
messages = []
self.messages = messages
self.is_other = True
def get_status_str(self):
if self.messages == []:
return '-'
message = ''
for index, mes in enumerate(self.messages):
if index > 0:
message += ' | '
message += mes
detailed_message = ''
for index, dm in enumerate(self.detailed_messages):
if index > 0:
detailed_message += ' | '
detailed_message += dm
return message, detailed_message
if __name__ == '__main__': if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Call survBot') parser = argparse.ArgumentParser(description='Call survBot')
parser.add_argument('-html', dest='html_filename', default=None, help='filename for HTML output') parser.add_argument('-html', dest='html_path', default=None, help='filepath for HTML output')
args = parser.parse_args() args = parser.parse_args()
survBot = SurveillanceBot(parameter_path='parameters.yaml', outpath_html=args.html_filename) survBot = SurveillanceBot(parameter_path='parameters.yaml', outpath_html=args.html_path)
survBot.start() survBot.start()

View File

@@ -22,7 +22,6 @@ except ImportError:
except ImportError: except ImportError:
raise ImportError('Could import neither of PySide2, PySide6 or PyQt5') raise ImportError('Could import neither of PySide2, PySide6 or PyQt5')
import matplotlib
from matplotlib.figure import Figure from matplotlib.figure import Figure
if QtGui.__package__ in ['PySide2', 'PyQt5', 'PySide6']: if QtGui.__package__ in ['PySide2', 'PyQt5', 'PySide6']:
@@ -35,7 +34,7 @@ from obspy import UTCDateTime
from survBot import SurveillanceBot from survBot import SurveillanceBot
from write_utils import * from write_utils import *
from utils import get_bg_color from utils import get_bg_color, modify_stream_for_plot, trace_ylabels, trace_yticks
try: try:
from rest_api.utils import get_station_iccid from rest_api.utils import get_station_iccid
@@ -130,10 +129,10 @@ class MainWindow(QtWidgets.QMainWindow):
self.table.setRowCount(len(station_list)) self.table.setRowCount(len(station_list))
self.table.setHorizontalHeaderLabels(keys) self.table.setHorizontalHeaderLabels(keys)
for index, st_id in enumerate(station_list): for index, nwst_id in enumerate(station_list):
item = QtWidgets.QTableWidgetItem() item = QtWidgets.QTableWidgetItem()
item.setText(str(st_id.rstrip('.'))) item.setText(str(nwst_id.rstrip('.')))
item.setData(QtCore.Qt.UserRole, st_id) item.setData(QtCore.Qt.UserRole, nwst_id)
self.table.setVerticalHeaderItem(index, item) self.table.setVerticalHeaderItem(index, item)
self.main_layout.addWidget(self.table) self.main_layout.addWidget(self.table)
@@ -180,38 +179,38 @@ class MainWindow(QtWidgets.QMainWindow):
header_item = self.table.verticalHeaderItem(row_ind) header_item = self.table.verticalHeaderItem(row_ind)
if not header_item: if not header_item:
return return
st_id = header_item.data(QtCore.Qt.UserRole) nwst_id = header_item.data(QtCore.Qt.UserRole)
context_menu = QtWidgets.QMenu() context_menu = QtWidgets.QMenu()
read_sms = context_menu.addAction('Get last SMS') read_sms = context_menu.addAction('Get last SMS')
send_sms = context_menu.addAction('Send SMS') send_sms = context_menu.addAction('Send SMS')
action = context_menu.exec_(self.mapToGlobal(self.last_mouse_loc)) action = context_menu.exec_(self.mapToGlobal(self.last_mouse_loc))
if action == read_sms: if action == read_sms:
self.read_sms(st_id) self.read_sms(nwst_id)
elif action == send_sms: elif action == send_sms:
self.send_sms(st_id) self.send_sms(nwst_id)
def read_sms(self, st_id): def read_sms(self, nwst_id):
""" Read recent SMS over rest_api using whereversim portal """ """ Read recent SMS over rest_api using whereversim portal """
station = st_id.split('.')[1] station = nwst_id.split('.')[1]
iccid = get_station_iccid(station) iccid = get_station_iccid(station)
if not iccid: if not iccid:
print('Could not find iccid for station', st_id) print('Could not find iccid for station', nwst_id)
return return
sms_widget = ReadSMSWidget(parent=self, iccid=iccid) sms_widget = ReadSMSWidget(parent=self, iccid=iccid)
sms_widget.setWindowTitle(f'Recent SMS of station: {st_id}') sms_widget.setWindowTitle(f'Recent SMS of station: {nwst_id}')
if sms_widget.data: if sms_widget.data:
sms_widget.show() sms_widget.show()
else: else:
self.notification('No recent messages found.') self.notification('No recent messages found.')
def send_sms(self, st_id): def send_sms(self, nwst_id):
""" Send SMS over rest_api using whereversim portal """ """ Send SMS over rest_api using whereversim portal """
station = st_id.split('.')[1] station = nwst_id.split('.')[1]
iccid = get_station_iccid(station) iccid = get_station_iccid(station)
sms_widget = SendSMSWidget(parent=self, iccid=iccid) sms_widget = SendSMSWidget(parent=self, iccid=iccid)
sms_widget.setWindowTitle(f'Send SMS to station: {st_id}') sms_widget.setWindowTitle(f'Send SMS to station: {nwst_id}')
sms_widget.show() sms_widget.show()
def set_clear_on_refresh(self): def set_clear_on_refresh(self):
@@ -230,19 +229,19 @@ class MainWindow(QtWidgets.QMainWindow):
self.fill_status_bar() self.fill_status_bar()
for col_ind, check_key in enumerate(self.survBot.keys): for col_ind, check_key in enumerate(self.survBot.keys):
for row_ind, st_id in enumerate(self.survBot.station_list): for row_ind, nwst_id in enumerate(self.survBot.station_list):
status_dict, detailed_dict = self.survBot.analysis_results.get(st_id) status_dict = self.survBot.analysis_results.get(nwst_id)
status = status_dict.get(check_key) status = status_dict.get(check_key)
detailed_message = detailed_dict.get(check_key) message, detailed_message = status.get_status_str()
dt_thresh = [timedelta(seconds=sec) for sec in self.dt_thresh] dt_thresh = [timedelta(seconds=sec) for sec in self.dt_thresh]
bg_color = get_bg_color(check_key, status, dt_thresh) bg_color = get_bg_color(check_key, status, dt_thresh)
if check_key == 'temp': if check_key == 'temp':
if not type(status) in [str]: if not type(message) in [str]:
status = str(status) + deg_str message = str(message) + deg_str
# Continue if nothing changed # Continue if nothing changed
text = str(status) text = str(message)
cur_item = self.table.item(row_ind, col_ind) cur_item = self.table.item(row_ind, col_ind)
if cur_item and text == cur_item.text(): if cur_item and text == cur_item.text():
if not self.parameters.get('track_changes') or self.clear_on_refresh: if not self.parameters.get('track_changes') or self.clear_on_refresh:
@@ -253,9 +252,9 @@ class MainWindow(QtWidgets.QMainWindow):
# Create new data item # Create new data item
item = QtWidgets.QTableWidgetItem() item = QtWidgets.QTableWidgetItem()
item.setText(str(status)) item.setText(str(message))
item.setTextAlignment(QtCore.Qt.AlignCenter) item.setTextAlignment(QtCore.Qt.AlignCenter)
item.setData(QtCore.Qt.UserRole, (st_id, check_key)) item.setData(QtCore.Qt.UserRole, (nwst_id, check_key))
# if text changed (known from above) set highlight color/font else (new init) set to default # if text changed (known from above) set highlight color/font else (new init) set to default
cur_item = self.table.item(row_ind, col_ind) cur_item = self.table.item(row_ind, col_ind)
@@ -310,12 +309,15 @@ class MainWindow(QtWidgets.QMainWindow):
vheader.setSectionResizeMode(index, QtWidgets.QHeaderView.Stretch) vheader.setSectionResizeMode(index, QtWidgets.QHeaderView.Stretch)
def plot_stream(self, item): def plot_stream(self, item):
st_id, check = item.data(QtCore.Qt.UserRole) nwst_id, check = item.data(QtCore.Qt.UserRole)
st = self.survBot.data.get(st_id) st = self.survBot.data.get(nwst_id)
if st: if st:
self.plot_widget = PlotWidget(self) self.plot_widget = PlotWidget(self)
self.plot_widget.setWindowTitle(st_id) self.plot_widget.setWindowTitle(nwst_id)
st = modify_stream_for_plot(st, parameters=self.parameters)
st.plot(equal_scale=False, method='full', block=False, fig=self.plot_widget.canvas.fig) st.plot(equal_scale=False, method='full', block=False, fig=self.plot_widget.canvas.fig)
trace_ylabels(fig=self.plot_widget.canvas.fig, parameters=self.parameters)
trace_yticks(fig=self.plot_widget.canvas.fig, parameters=self.parameters)
self.plot_widget.show() self.plot_widget.show()
def notification(self, text): def notification(self, text):

112
utils.py
View File

@@ -3,18 +3,20 @@
import matplotlib import matplotlib
def get_bg_color(check_key, status, dt_thresh=None, hex=False): def get_bg_color(check_key, status, dt_thresh=None, hex=False):
message = status.message
if check_key == 'last active': if check_key == 'last active':
bg_color = get_time_delay_color(status, dt_thresh) bg_color = get_time_delay_color(message, dt_thresh)
elif check_key == 'temp': elif check_key == 'temp':
bg_color = get_temp_color(status) bg_color = get_temp_color(message)
else: else:
statussplit = status.split(' ') if status.is_warn:
if len(statussplit) > 1 and statussplit[0] == 'WARN': bg_color = get_color('WARNX')(status.count)
x = int(status.split(' ')[-1].lstrip('(').rstrip(')')) elif status.is_error:
bg_color = get_color('WARNX')(x) bg_color = get_color('FAIL')
else: else:
bg_color = get_color(status) bg_color = get_color(message)
if not bg_color: if not bg_color:
bg_color = get_color('undefined') bg_color = get_color('undefined')
@@ -22,16 +24,18 @@ def get_bg_color(check_key, status, dt_thresh=None, hex=False):
bg_color = '#{:02x}{:02x}{:02x}'.format(*bg_color[:3]) bg_color = '#{:02x}{:02x}{:02x}'.format(*bg_color[:3])
return bg_color return bg_color
def get_color(key): def get_color(key):
# some GUI default colors # some GUI default colors
colors_dict = {'FAIL': (255, 50, 0, 255), colors_dict = {'FAIL': (255, 50, 0, 255),
'NO DATA': (255, 255, 125, 255), 'NO DATA': (255, 255, 125, 255),
'WARN': (255, 255, 125, 255), 'WARN': (255, 255, 80, 255),
'WARNX': lambda x: (min([255, 200 + x ** 2]), 255, 125, 255), 'WARNX': lambda x: (min([255, 200 + x ** 2]), 255, 80, 255),
'OK': (125, 255, 125, 255), 'OK': (125, 255, 125, 255),
'undefined': (230, 230, 230, 255)} 'undefined': (230, 230, 230, 255)}
return colors_dict.get(key) return colors_dict.get(key)
def get_time_delay_color(dt, dt_thresh): def get_time_delay_color(dt, dt_thresh):
""" Set color of time delay after thresholds specified in self.dt_thresh """ """ Set color of time delay after thresholds specified in self.dt_thresh """
if dt < dt_thresh[0]: if dt < dt_thresh[0]:
@@ -40,6 +44,7 @@ def get_time_delay_color(dt, dt_thresh):
return get_color('WARN') return get_color('WARN')
return get_color('FAIL') return get_color('FAIL')
def get_temp_color(temp, vmin=-10, vmax=60, cmap='coolwarm'): def get_temp_color(temp, vmin=-10, vmax=60, cmap='coolwarm'):
""" Get an rgba temperature value back from specified cmap, linearly interpolated between vmin and vmax. """ """ Get an rgba temperature value back from specified cmap, linearly interpolated between vmin and vmax. """
if type(temp) in [str]: if type(temp) in [str]:
@@ -49,3 +54,92 @@ def get_temp_color(temp, vmin=-10, vmax=60, cmap='coolwarm'):
rgba = [int(255 * c) for c in cmap(val)] rgba = [int(255 * c) for c in cmap(val)]
return rgba return rgba
def modify_stream_for_plot(st, parameters):
""" copy (if necessary) and modify stream for plotting """
ch_units = parameters.get('CHANNEL_UNITS')
ch_transf = parameters.get('CHANNEL_TRANSFORM')
# if either of both are defined make copy
if ch_units or ch_transf:
st = st.copy()
# modify trace for plotting by multiplying unit factor (e.g. 1e-3 mV to V)
if ch_units:
for tr in st:
channel = tr.stats.channel
unit_factor = ch_units.get(channel)
if unit_factor:
tr.data = tr.data * float(unit_factor)
# modify trace for plotting by other arithmetic expressions
if ch_transf:
for tr in st:
channel = tr.stats.channel
transf = ch_transf.get(channel)
if transf:
tr.data = transform_trace(tr.data, transf)
return st
def transform_trace(data, transf):
"""
Transform trace with arithmetic operations in order, specified in transf
@param data: numpy array
@param transf: list of lists with arithmetic operations (e.g. [['*', '20'], ] -> multiply data by 20
"""
# This looks a little bit hardcoded, however it is safer than using e.g. "eval"
for operator_str, val in transf:
if operator_str == '+':
data = data + val
elif operator_str == '-':
data = data - val
elif operator_str == '*':
data = data * val
elif operator_str == '/':
data = data / val
else:
raise IOError(f'Unknown arithmethic operator string: {operator_str}')
return data
def trace_ylabels(fig, parameters, verbosity=0):
"""
Adds channel names to y-axis if defined in parameters.
Can get mixed up if channel order in stream and channel names defined in parameters.yaml differ, but it is
difficult to assess the correct order from Obspy plotting routing.
"""
names = parameters.get('channel_names')
if not names: # or not len(st.traces):
return
if not len(names) == len(fig.axes):
if verbosity:
print('Mismatch in axis and label lengths. Not adding plot labels')
return
for channel_name, ax in zip(names, fig.axes):
if channel_name:
ax.set_ylabel(channel_name)
def trace_yticks(fig, parameters, verbosity=0):
"""
Adds channel names to y-axis if defined in parameters.
Can get mixed up if channel order in stream and channel names defined in parameters.yaml differ, but it is
difficult to assess the correct order from Obspy plotting routing.
"""
ticks = parameters.get('CHANNEL_TICKS')
if not ticks:
return
if not len(ticks) == len(fig.axes):
if verbosity:
print('Mismatch in axis tick and label lengths. Not changing plot ticks.')
return
for ytick_tripple, ax in zip(ticks, fig.axes):
if not ytick_tripple:
continue
ymin, ymax, step = ytick_tripple
yticks = list(range(ymin, ymax + step, step))
ax.set_yticks(yticks)
ax.set_ylim(ymin - step, ymax + step)

View File

@@ -1,52 +1,68 @@
from datetime import timedelta from datetime import timedelta
def write_html_table_title(fobj, parameters): def write_html_table_title(fobj, parameters):
title = get_print_title_str(parameters) title = get_print_title_str(parameters)
fobj.write(f'<h3>{title}</h3>\n') fobj.write(f'<h3>{title}</h3>\n')
def write_html_text(fobj, text): def write_html_text(fobj, text):
fobj.write(f'<p>{text}</p>\n') fobj.write(f'<p>{text}</p>\n')
def write_html_header(fobj, refresh_rate=10): def write_html_header(fobj, refresh_rate=10):
header = ['<!DOCTYPE html>', header = ['<!DOCTYPE html>',
'<html>', '<html>',
'<head>',
' <link rel="stylesheet" media="only screen and (max-width: 400px)" href="mobile.css" />',
' <link rel="stylesheet" media="only screen and (min-width: 401px)" href="desktop.css" />',
'</head>',
f'<meta http-equiv="refresh" content="{refresh_rate}" >', f'<meta http-equiv="refresh" content="{refresh_rate}" >',
'<meta charset="utf-8">', '<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
'<body>'] '<body>']
# style = ['<style>',
# 'table, th, td {',
# 'border:1px solid black;',
# '}',
# '</style>',]
for item in header: for item in header:
fobj.write(item + '\n') fobj.write(item + '\n')
def init_html_table(fobj): def init_html_table(fobj):
fobj.write('<table style="width:100%">\n') fobj.write('<table style="width:100%">\n')
def finish_html_table(fobj): def finish_html_table(fobj):
fobj.write('</table>\n') fobj.write('</table>\n')
def write_html_footer(fobj): def write_html_footer(fobj):
footer = ['</body>', footer = ['</body>',
'</html>'] '</html>']
for item in footer: for item in footer:
fobj.write(item + '\n') fobj.write(item + '\n')
def write_html_row(fobj, items, html_key='td'): def write_html_row(fobj, items, html_key='td'):
default_space = ' ' default_space = ' '
fobj.write(default_space + '<tr>\n') fobj.write(default_space + '<tr>\n')
for item in items: for item in items:
text = item.get('text') text = item.get('text')
if item.get('bold'):
text = '<b>' + text + '</b>'
if item.get('italic'):
text = '<i>' + text + '</i>'
tooltip = item.get('tooltip') tooltip = item.get('tooltip')
color = item.get('color') color = item.get('color')
# check for black background of headers (shouldnt happen anymore) # check for black background of headers (shouldnt happen anymore)
color = '#e6e6e6' if color == '#000000' else color color = '#e6e6e6' if color == '#000000' else color
fobj.write(2 * default_space + f'<{html_key} bgcolor="{color}" title="{tooltip}">' + text + f'</{html_key}>\n') hyperlink = item.get('hyperlink')
image_str = f'<a href="{hyperlink}">' if hyperlink else ''
html_class = item.get('html_class')
class_str = f' class="{html_class}"' if html_class else ''
fobj.write(2 * default_space + f'<{html_key}{class_str} bgcolor="{color}" title="{tooltip}"> {image_str}'
+ text + f'</{html_key}>\n')
fobj.write(default_space + '</tr>\n') fobj.write(default_space + '</tr>\n')
def get_print_title_str(parameters): def get_print_title_str(parameters):
timespan = parameters.get('timespan') * 24 * 3600 timespan = parameters.get('timespan') * 24 * 3600
tdelta_str = str(timedelta(seconds=int(timespan))) tdelta_str = str(timedelta(seconds=int(timespan))).replace(', 0:00:00', '')
return f'Analysis table of router quality within the last {tdelta_str}' return f'Analysis table of router quality within the last {tdelta_str}'