CSIT-1041: Trending dashboard
[csit.git] / resources / tools / presentation / generator_CPTA.py
1 # Copyright (c) 2018 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Generation of Continuous Performance Trending and Analysis.
15 """
16
17 import datetime
18 import logging
19 import csv
20 import prettytable
21 import plotly.offline as ploff
22 import plotly.graph_objs as plgo
23 import plotly.exceptions as plerr
24 import numpy as np
25 import pandas as pd
26
27 from collections import OrderedDict
28 from utils import split_outliers, archive_input_data, execute_command
29
30
31 # Command to build the html format of the report
32 HTML_BUILDER = 'sphinx-build -v -c conf_cpta -a ' \
33                '-b html -E ' \
34                '-t html ' \
35                '-D version="Generated on {date}" ' \
36                '{working_dir} ' \
37                '{build_dir}/'
38
39 # .css file for the html format of the report
40 THEME_OVERRIDES = """/* override table width restrictions */
41 .wy-nav-content {
42     max-width: 1200px !important;
43 }
44 """
45
46 COLORS = ["SkyBlue", "Olive", "Purple", "Coral", "Indigo", "Pink",
47           "Chocolate", "Brown", "Magenta", "Cyan", "Orange", "Black",
48           "Violet", "Blue", "Yellow"]
49
50
51 def generate_cpta(spec, data):
52     """Generate all formats and versions of the Continuous Performance Trending
53     and Analysis.
54
55     :param spec: Specification read from the specification file.
56     :param data: Full data set.
57     :type spec: Specification
58     :type data: InputData
59     """
60
61     logging.info("Generating the Continuous Performance Trending and Analysis "
62                  "...")
63
64     ret_code = _generate_all_charts(spec, data)
65
66     cmd = HTML_BUILDER.format(
67         date=datetime.date.today().strftime('%d-%b-%Y'),
68         working_dir=spec.environment["paths"]["DIR[WORKING,SRC]"],
69         build_dir=spec.environment["paths"]["DIR[BUILD,HTML]"])
70     execute_command(cmd)
71
72     with open(spec.environment["paths"]["DIR[CSS_PATCH_FILE]"], "w") as \
73             css_file:
74         css_file.write(THEME_OVERRIDES)
75
76     with open(spec.environment["paths"]["DIR[CSS_PATCH_FILE2]"], "w") as \
77             css_file:
78         css_file.write(THEME_OVERRIDES)
79
80     archive_input_data(spec)
81
82     logging.info("Done.")
83
84     return ret_code
85
86
87 def _select_data(in_data, period, fill_missing=False, use_first=False):
88     """Select the data from the full data set. The selection is done by picking
89     the samples depending on the period: period = 1: All, period = 2: every
90     second sample, period = 3: every third sample ...
91
92     :param in_data: Full set of data.
93     :param period: Sampling period.
94     :param fill_missing: If the chosen sample is missing in the full set, its
95     nearest neighbour is used.
96     :param use_first: Use the first sample even though it is not chosen.
97     :type in_data: OrderedDict
98     :type period: int
99     :type fill_missing: bool
100     :type use_first: bool
101     :returns: Reduced data.
102     :rtype: OrderedDict
103     """
104
105     first_idx = min(in_data.keys())
106     last_idx = max(in_data.keys())
107
108     idx = last_idx
109     data_dict = dict()
110     if use_first:
111         data_dict[first_idx] = in_data[first_idx]
112     while idx >= first_idx:
113         data = in_data.get(idx, None)
114         if data is None:
115             if fill_missing:
116                 threshold = int(round(idx - period / 2)) + 1 - period % 2
117                 idx_low = first_idx if threshold < first_idx else threshold
118                 threshold = int(round(idx + period / 2))
119                 idx_high = last_idx if threshold > last_idx else threshold
120
121                 flag_l = True
122                 flag_h = True
123                 idx_lst = list()
124                 inc = 1
125                 while flag_l or flag_h:
126                     if idx + inc > idx_high:
127                         flag_h = False
128                     else:
129                         idx_lst.append(idx + inc)
130                     if idx - inc < idx_low:
131                         flag_l = False
132                     else:
133                         idx_lst.append(idx - inc)
134                     inc += 1
135
136                 for i in idx_lst:
137                     if i in in_data.keys():
138                         data_dict[i] = in_data[i]
139                         break
140         else:
141             data_dict[idx] = data
142         idx -= period
143
144     return OrderedDict(sorted(data_dict.items(), key=lambda t: t[0]))
145
146
147 def _evaluate_results(in_data, trimmed_data, window=10):
148     """Evaluates if the sample value is regress, normal or progress compared to
149     previous data within the window.
150     We use the intervals defined as:
151     - regress: less than median - 3 * stdev
152     - normal: between median - 3 * stdev and median + 3 * stdev
153     - progress: more than median + 3 * stdev
154
155     :param in_data: Full data set.
156     :param trimmed_data: Full data set without the outliers.
157     :param window: Window size used to calculate moving median and moving stdev.
158     :type in_data: pandas.Series
159     :type trimmed_data: pandas.Series
160     :type window: int
161     :returns: Evaluated results.
162     :rtype: list
163     """
164
165     if len(in_data) > 2:
166         win_size = in_data.size if in_data.size < window else window
167         results = [0.66, ]
168         median = in_data.rolling(window=win_size, min_periods=2).median()
169         stdev_t = trimmed_data.rolling(window=win_size, min_periods=2).std()
170
171         first = True
172         for build_nr, value in in_data.iteritems():
173             if first:
174                 first = False
175                 continue
176             if np.isnan(trimmed_data[build_nr]) \
177                     or np.isnan(median[build_nr]) \
178                     or np.isnan(stdev_t[build_nr]) \
179                     or np.isnan(value):
180                 results.append(0.0)
181             elif value < (median[build_nr] - 3 * stdev_t[build_nr]):
182                 results.append(0.33)
183             elif value > (median[build_nr] + 3 * stdev_t[build_nr]):
184                 results.append(1.0)
185             else:
186                 results.append(0.66)
187     else:
188         results = [0.0, ]
189         try:
190             median = np.median(in_data)
191             stdev = np.std(in_data)
192             if in_data.values[-1] < (median - 3 * stdev):
193                 results.append(0.33)
194             elif (median - 3 * stdev) <= in_data.values[-1] <= (
195                     median + 3 * stdev):
196                 results.append(0.66)
197             else:
198                 results.append(1.0)
199         except TypeError:
200             results.append(None)
201     return results
202
203
204 def _generate_trending_traces(in_data, build_info, period, moving_win_size=10,
205                               fill_missing=True, use_first=False,
206                               show_moving_median=True, name="", color=""):
207     """Generate the trending traces:
208      - samples,
209      - moving median (trending plot)
210      - outliers, regress, progress
211
212     :param in_data: Full data set.
213     :param build_info: Information about the builds.
214     :param period: Sampling period.
215     :param moving_win_size: Window size.
216     :param fill_missing: If the chosen sample is missing in the full set, its
217     nearest neighbour is used.
218     :param use_first: Use the first sample even though it is not chosen.
219     :param show_moving_median: Show moving median (trending plot).
220     :param name: Name of the plot
221     :param color: Name of the color for the plot.
222     :type in_data: OrderedDict
223     :type build_info: dict
224     :type period: int
225     :type moving_win_size: int
226     :type fill_missing: bool
227     :type use_first: bool
228     :type show_moving_median: bool
229     :type name: str
230     :type color: str
231     :returns: Generated traces (list) and the evaluated result (float).
232     :rtype: tuple(traces, result)
233     """
234
235     if period > 1:
236         in_data = _select_data(in_data, period,
237                                fill_missing=fill_missing,
238                                use_first=use_first)
239
240     data_x = [key for key in in_data.keys()]
241     data_y = [val for val in in_data.values()]
242
243     hover_text = list()
244     for idx in data_x:
245         hover_text.append("vpp-build: {0}".
246                           format(build_info[str(idx)][1].split("~")[-1]))
247
248     data_pd = pd.Series(data_y, index=data_x)
249
250     t_data, outliers = split_outliers(data_pd, outlier_const=1.5,
251                                       window=moving_win_size)
252     results = _evaluate_results(data_pd, t_data, window=moving_win_size)
253
254     anomalies = pd.Series()
255     anomalies_res = list()
256     for idx, item in enumerate(in_data.items()):
257         item_pd = pd.Series([item[1], ], index=[item[0], ])
258         if item[0] in outliers.keys():
259             anomalies = anomalies.append(item_pd)
260             anomalies_res.append(0.0)
261         elif results[idx] in (0.33, 1.0):
262             anomalies = anomalies.append(item_pd)
263             anomalies_res.append(results[idx])
264     anomalies_res.extend([0.0, 0.33, 0.66, 1.0])
265
266     # Create traces
267     color_scale = [[0.00, "grey"],
268                    [0.25, "grey"],
269                    [0.25, "red"],
270                    [0.50, "red"],
271                    [0.50, "white"],
272                    [0.75, "white"],
273                    [0.75, "green"],
274                    [1.00, "green"]]
275
276     trace_samples = plgo.Scatter(
277         x=data_x,
278         y=data_y,
279         mode='markers',
280         line={
281             "width": 1
282         },
283         name="{name}-thput".format(name=name),
284         marker={
285             "size": 5,
286             "color": color,
287             "symbol": "circle",
288         },
289         text=hover_text,
290         hoverinfo="x+y+text+name"
291     )
292     traces = [trace_samples, ]
293
294     trace_anomalies = plgo.Scatter(
295         x=anomalies.keys(),
296         y=anomalies.values,
297         mode='markers',
298         hoverinfo="none",
299         showlegend=True,
300         legendgroup=name,
301         name="{name}-anomalies".format(name=name),
302         marker={
303             "size": 15,
304             "symbol": "circle-open",
305             "color": anomalies_res,
306             "colorscale": color_scale,
307             "showscale": True,
308             "line": {
309                 "width": 2
310             },
311             "colorbar": {
312                 "y": 0.5,
313                 "len": 0.8,
314                 "title": "Circles Marking Data Classification",
315                 "titleside": 'right',
316                 "titlefont": {
317                     "size": 14
318                 },
319                 "tickmode": 'array',
320                 "tickvals": [0.125, 0.375, 0.625, 0.875],
321                 "ticktext": ["Outlier", "Regression", "Normal", "Progression"],
322                 "ticks": "",
323                 "ticklen": 0,
324                 "tickangle": -90,
325                 "thickness": 10
326             }
327         }
328     )
329     traces.append(trace_anomalies)
330
331     if show_moving_median:
332         data_mean_y = pd.Series(data_y).rolling(
333             window=moving_win_size, min_periods=2).median()
334         trace_median = plgo.Scatter(
335             x=data_x,
336             y=data_mean_y,
337             mode='lines',
338             line={
339                 "shape": "spline",
340                 "width": 1,
341                 "color": color,
342             },
343             name='{name}-trend'.format(name=name)
344         )
345         traces.append(trace_median)
346
347     return traces, results[-1]
348
349
350 def _generate_chart(traces, layout, file_name):
351     """Generates the whole chart using pre-generated traces.
352
353     :param traces: Traces for the chart.
354     :param layout: Layout of the chart.
355     :param file_name: File name for the generated chart.
356     :type traces: list
357     :type layout: dict
358     :type file_name: str
359     """
360
361     # Create plot
362     logging.info("    Writing the file '{0}' ...".format(file_name))
363     plpl = plgo.Figure(data=traces, layout=layout)
364     try:
365         ploff.plot(plpl, show_link=False, auto_open=False, filename=file_name)
366     except plerr.PlotlyEmptyDataError:
367         logging.warning(" No data for the plot. Skipped.")
368
369
370 def _generate_all_charts(spec, input_data):
371     """Generate all charts specified in the specification file.
372
373     :param spec: Specification.
374     :param input_data: Full data set.
375     :type spec: Specification
376     :type input_data: InputData
377     """
378
379     job_name = spec.cpta["data"].keys()[0]
380
381     builds_lst = list()
382     for build in spec.input["builds"][job_name]:
383         status = build["status"]
384         if status != "failed" and status != "not found":
385             builds_lst.append(str(build["build"]))
386
387     # Get "build ID": "date" dict:
388     build_info = OrderedDict()
389     for build in builds_lst:
390         try:
391             build_info[build] = (
392                 input_data.metadata(job_name, build)["generated"][:14],
393                 input_data.metadata(job_name, build)["version"]
394             )
395         except KeyError:
396             build_info[build] = ("", "")
397         logging.info("{}: {}, {}".format(build,
398                                          build_info[build][0],
399                                          build_info[build][1]))
400
401     # Create the header:
402     csv_table = list()
403     header = "Build Number:," + ",".join(builds_lst) + '\n'
404     csv_table.append(header)
405     build_dates = [x[0] for x in build_info.values()]
406     header = "Build Date:," + ",".join(build_dates) + '\n'
407     csv_table.append(header)
408     vpp_versions = [x[1] for x in build_info.values()]
409     header = "VPP Version:," + ",".join(vpp_versions) + '\n'
410     csv_table.append(header)
411
412     results = list()
413     for chart in spec.cpta["plots"]:
414         logging.info("  Generating the chart '{0}' ...".
415                      format(chart.get("title", "")))
416
417         # Transform the data
418         data = input_data.filter_data(chart, continue_on_error=True)
419         if data is None:
420             logging.error("No data.")
421             return
422
423         chart_data = dict()
424         for job in data:
425             for idx, build in job.items():
426                 for test_name, test in build.items():
427                     if chart_data.get(test_name, None) is None:
428                         chart_data[test_name] = OrderedDict()
429                     try:
430                         chart_data[test_name][int(idx)] = \
431                             test["result"]["throughput"]
432                     except (KeyError, TypeError):
433                         pass
434
435         # Add items to the csv table:
436         for tst_name, tst_data in chart_data.items():
437             tst_lst = list()
438             for build in builds_lst:
439                 item = tst_data.get(int(build), '')
440                 tst_lst.append(str(item) if item else '')
441             csv_table.append("{0},".format(tst_name) + ",".join(tst_lst) + '\n')
442
443         for period in chart["periods"]:
444             # Generate traces:
445             traces = list()
446             win_size = 14 if period == 1 else 5 if period < 20 else 3
447             idx = 0
448             for test_name, test_data in chart_data.items():
449                 if not test_data:
450                     logging.warning("No data for the test '{0}'".
451                                     format(test_name))
452                     continue
453                 test_name = test_name.split('.')[-1]
454                 trace, result = _generate_trending_traces(
455                     test_data,
456                     build_info=build_info,
457                     period=period,
458                     moving_win_size=win_size,
459                     fill_missing=True,
460                     use_first=False,
461                     name='-'.join(test_name.split('-')[3:-1]),
462                     color=COLORS[idx])
463                 traces.extend(trace)
464                 results.append(result)
465                 idx += 1
466
467             # Generate the chart:
468             chart["layout"]["xaxis"]["title"] = \
469                 chart["layout"]["xaxis"]["title"].format(job=job_name)
470             _generate_chart(traces,
471                             chart["layout"],
472                             file_name="{0}-{1}-{2}{3}".format(
473                                 spec.cpta["output-file"],
474                                 chart["output-file-name"],
475                                 period,
476                                 spec.cpta["output-file-type"]))
477
478         logging.info("  Done.")
479
480     # Write the tables:
481     file_name = spec.cpta["output-file"] + "-trending"
482     with open("{0}.csv".format(file_name), 'w') as file_handler:
483         file_handler.writelines(csv_table)
484
485     txt_table = None
486     with open("{0}.csv".format(file_name), 'rb') as csv_file:
487         csv_content = csv.reader(csv_file, delimiter=',', quotechar='"')
488         line_nr = 0
489         for row in csv_content:
490             if txt_table is None:
491                 txt_table = prettytable.PrettyTable(row)
492             else:
493                 if line_nr > 1:
494                     for idx, item in enumerate(row):
495                         try:
496                             row[idx] = str(round(float(item) / 1000000, 2))
497                         except ValueError:
498                             pass
499                 try:
500                     txt_table.add_row(row)
501                 except Exception as err:
502                     logging.warning("Error occurred while generating TXT table:"
503                                     "\n{0}".format(err))
504             line_nr += 1
505         txt_table.align["Build Number:"] = "l"
506     with open("{0}.txt".format(file_name), "w") as txt_file:
507         txt_file.write(str(txt_table))
508
509     # Evaluate result:
510     result = "PASS"
511     for item in results:
512         if item is None:
513             result = "FAIL"
514             break
515         if item == 0.66 and result == "PASS":
516             result = "PASS"
517         elif item == 0.33 or item == 0.0:
518             result = "FAIL"
519
520     logging.info("Partial results: {0}".format(results))
521     logging.info("Result: {0}".format(result))
522
523     return result