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 find_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.0, ] * win_size
168         median = in_data.rolling(window=win_size).median()
169         stdev_t = trimmed_data.rolling(window=win_size, min_periods=2).std()
170         m_vals = median.values
171         s_vals = stdev_t.values
172         d_vals = in_data.values
173         for day in range(win_size, in_data.size):
174             if np.isnan(m_vals[day - 1]) or np.isnan(s_vals[day - 1]):
175                 results.append(0.0)
176             elif d_vals[day] < (m_vals[day - 1] - 3 * s_vals[day - 1]):
177                 results.append(0.33)
178             elif (m_vals[day - 1] - 3 * s_vals[day - 1]) <= d_vals[day] <= \
179                     (m_vals[day - 1] + 3 * s_vals[day - 1]):
180                 results.append(0.66)
181             else:
182                 results.append(1.0)
183     else:
184         results = [0.0, ]
185         try:
186             median = np.median(in_data)
187             stdev = np.std(in_data)
188             if in_data.values[-1] < (median - 3 * stdev):
189                 results.append(0.33)
190             elif (median - 3 * stdev) <= in_data.values[-1] <= (
191                     median + 3 * stdev):
192                 results.append(0.66)
193             else:
194                 results.append(1.0)
195         except TypeError:
196             results.append(None)
197     return results
198
199
200 def _generate_trending_traces(in_data, build_info, period, moving_win_size=10,
201                               fill_missing=True, use_first=False,
202                               show_moving_median=True, name="", color=""):
203     """Generate the trending traces:
204      - samples,
205      - moving median (trending plot)
206      - outliers, regress, progress
207
208     :param in_data: Full data set.
209     :param build_info: Information about the builds.
210     :param period: Sampling period.
211     :param moving_win_size: Window size.
212     :param fill_missing: If the chosen sample is missing in the full set, its
213     nearest neighbour is used.
214     :param use_first: Use the first sample even though it is not chosen.
215     :param show_moving_median: Show moving median (trending plot).
216     :param name: Name of the plot
217     :param color: Name of the color for the plot.
218     :type in_data: OrderedDict
219     :type build_info: dict
220     :type period: int
221     :type moving_win_size: int
222     :type fill_missing: bool
223     :type use_first: bool
224     :type show_moving_median: bool
225     :type name: str
226     :type color: str
227     :returns: Generated traces (list) and the evaluated result (float).
228     :rtype: tuple(traces, result)
229     """
230
231     if period > 1:
232         in_data = _select_data(in_data, period,
233                                fill_missing=fill_missing,
234                                use_first=use_first)
235     try:
236         data_x = ["{0}/{1}".format(key, build_info[str(key)][1].split("~")[-1])
237                   for key in in_data.keys()]
238     except KeyError:
239         data_x = [key for key in in_data.keys()]
240     data_y = [val for val in in_data.values()]
241     data_pd = pd.Series(data_y, index=data_x)
242
243     t_data, outliers = find_outliers(data_pd)
244
245     results = _evaluate_results(data_pd, t_data, window=moving_win_size)
246
247     anomalies = pd.Series()
248     anomalies_res = list()
249     for idx, item in enumerate(in_data.items()):
250         item_pd = pd.Series([item[1], ],
251                             index=["{0}/{1}".
252                             format(item[0],
253                                    build_info[str(item[0])][1].split("~")[-1]), ])
254         if item[0] in outliers.keys():
255             anomalies = anomalies.append(item_pd)
256             anomalies_res.append(0.0)
257         elif results[idx] in (0.33, 1.0):
258             anomalies = anomalies.append(item_pd)
259             anomalies_res.append(results[idx])
260     anomalies_res.extend([0.0, 0.33, 0.66, 1.0])
261
262     # Create traces
263     color_scale = [[0.00, "grey"],
264                    [0.25, "grey"],
265                    [0.25, "red"],
266                    [0.50, "red"],
267                    [0.50, "white"],
268                    [0.75, "white"],
269                    [0.75, "green"],
270                    [1.00, "green"]]
271
272     trace_samples = plgo.Scatter(
273         x=data_x,
274         y=data_y,
275         mode='markers',
276         line={
277             "width": 1
278         },
279         name="{name}-thput".format(name=name),
280         marker={
281             "size": 5,
282             "color": color,
283             "symbol": "circle",
284         },
285     )
286     traces = [trace_samples, ]
287
288     trace_anomalies = plgo.Scatter(
289         x=anomalies.keys(),
290         y=anomalies.values,
291         mode='markers',
292         hoverinfo="none",
293         showlegend=False,
294         legendgroup=name,
295         name="{name}: outliers".format(name=name),
296         marker={
297             "size": 15,
298             "symbol": "circle-open",
299             "color": anomalies_res,
300             "colorscale": color_scale,
301             "showscale": True,
302             "line": {
303                 "width": 2
304             },
305             "colorbar": {
306                 "y": 0.5,
307                 "len": 0.8,
308                 "title": "Circles Marking Data Classification",
309                 "titleside": 'right',
310                 "titlefont": {
311                     "size": 14
312                 },
313                 "tickmode": 'array',
314                 "tickvals": [0.125, 0.375, 0.625, 0.875],
315                 "ticktext": ["Outlier", "Regression", "Normal", "Progression"],
316                 "ticks": "",
317                 "ticklen": 0,
318                 "tickangle": -90,
319                 "thickness": 10
320             }
321         }
322     )
323     traces.append(trace_anomalies)
324
325     if show_moving_median:
326         data_mean_y = pd.Series(data_y).rolling(
327             window=moving_win_size, min_periods=2).median()
328         trace_median = plgo.Scatter(
329             x=data_x,
330             y=data_mean_y,
331             mode='lines',
332             line={
333                 "shape": "spline",
334                 "width": 1,
335                 "color": color,
336             },
337             name='{name}-trend'.format(name=name)
338         )
339         traces.append(trace_median)
340
341     return traces, results[-1]
342
343
344 def _generate_chart(traces, layout, file_name):
345     """Generates the whole chart using pre-generated traces.
346
347     :param traces: Traces for the chart.
348     :param layout: Layout of the chart.
349     :param file_name: File name for the generated chart.
350     :type traces: list
351     :type layout: dict
352     :type file_name: str
353     """
354
355     # Create plot
356     logging.info("    Writing the file '{0}' ...".format(file_name))
357     plpl = plgo.Figure(data=traces, layout=layout)
358     try:
359         ploff.plot(plpl, show_link=False, auto_open=False, filename=file_name)
360     except plerr.PlotlyEmptyDataError:
361         logging.warning(" No data for the plot. Skipped.")
362
363
364 def _generate_all_charts(spec, input_data):
365     """Generate all charts specified in the specification file.
366
367     :param spec: Specification.
368     :param input_data: Full data set.
369     :type spec: Specification
370     :type input_data: InputData
371     """
372
373     job_name = spec.cpta["data"].keys()[0]
374
375     builds_lst = list()
376     for build in spec.input["builds"][job_name]:
377         status = build["status"]
378         if status != "failed" and status != "not found":
379             builds_lst.append(str(build["build"]))
380
381     # Get "build ID": "date" dict:
382     build_info = dict()
383     for build in builds_lst:
384         try:
385             build_info[build] = (
386                 input_data.metadata(job_name, build)["generated"][:14],
387                 input_data.metadata(job_name, build)["version"]
388             )
389         except KeyError:
390             pass
391
392     # Create the header:
393     csv_table = list()
394     header = "Build Number:," + ",".join(builds_lst) + '\n'
395     csv_table.append(header)
396     build_dates = [x[0] for x in build_info.values()]
397     header = "Build Date:," + ",".join(build_dates) + '\n'
398     csv_table.append(header)
399     vpp_versions = [x[1] for x in build_info.values()]
400     header = "VPP Version:," + ",".join(vpp_versions) + '\n'
401     csv_table.append(header)
402
403     results = list()
404     for chart in spec.cpta["plots"]:
405         logging.info("  Generating the chart '{0}' ...".
406                      format(chart.get("title", "")))
407
408         # Transform the data
409         data = input_data.filter_data(chart, continue_on_error=True)
410         if data is None:
411             logging.error("No data.")
412             return
413
414         chart_data = dict()
415         for job in data:
416             for idx, build in job.items():
417                 for test_name, test in build.items():
418                     if chart_data.get(test_name, None) is None:
419                         chart_data[test_name] = OrderedDict()
420                     try:
421                         chart_data[test_name][int(idx)] = \
422                             test["result"]["throughput"]
423                     except (KeyError, TypeError):
424                         pass
425
426         # Add items to the csv table:
427         for tst_name, tst_data in chart_data.items():
428             tst_lst = list()
429             for build in builds_lst:
430                 item = tst_data.get(int(build), '')
431                 tst_lst.append(str(item) if item else '')
432             csv_table.append("{0},".format(tst_name) + ",".join(tst_lst) + '\n')
433
434         for period in chart["periods"]:
435             # Generate traces:
436             traces = list()
437             win_size = 10 if period == 1 else 5 if period < 20 else 3
438             idx = 0
439             for test_name, test_data in chart_data.items():
440                 if not test_data:
441                     logging.warning("No data for the test '{0}'".
442                                     format(test_name))
443                     continue
444                 test_name = test_name.split('.')[-1]
445                 trace, result = _generate_trending_traces(
446                     test_data,
447                     build_info=build_info,
448                     period=period,
449                     moving_win_size=win_size,
450                     fill_missing=True,
451                     use_first=False,
452                     name='-'.join(test_name.split('-')[3:-1]),
453                     color=COLORS[idx])
454                 traces.extend(trace)
455                 results.append(result)
456                 idx += 1
457
458             # Generate the chart:
459             chart["layout"]["xaxis"]["title"] = \
460                 chart["layout"]["xaxis"]["title"].format(job=job_name)
461             _generate_chart(traces,
462                             chart["layout"],
463                             file_name="{0}-{1}-{2}{3}".format(
464                                 spec.cpta["output-file"],
465                                 chart["output-file-name"],
466                                 period,
467                                 spec.cpta["output-file-type"]))
468
469         logging.info("  Done.")
470
471     # Write the tables:
472     file_name = spec.cpta["output-file"] + "-trending"
473     with open("{0}.csv".format(file_name), 'w') as file_handler:
474         file_handler.writelines(csv_table)
475
476     txt_table = None
477     with open("{0}.csv".format(file_name), 'rb') as csv_file:
478         csv_content = csv.reader(csv_file, delimiter=',', quotechar='"')
479         line_nr = 0
480         for row in csv_content:
481             if txt_table is None:
482                 txt_table = prettytable.PrettyTable(row)
483             else:
484                 if line_nr > 1:
485                     for idx, item in enumerate(row):
486                         try:
487                             row[idx] = str(round(float(item) / 1000000, 2))
488                         except ValueError:
489                             pass
490                 txt_table.add_row(row)
491             line_nr += 1
492         txt_table.align["Build Number:"] = "l"
493     with open("{0}.txt".format(file_name), "w") as txt_file:
494         txt_file.write(str(txt_table))
495
496     # Evaluate result:
497     result = "PASS"
498     for item in results:
499         if item is None:
500             result = "FAIL"
501             break
502         if item == 0.66 and result == "PASS":
503             result = "PASS"
504         elif item == 0.33 or item == 0.0:
505             result = "FAIL"
506
507     logging.info("Partial results: {0}".format(results))
508     logging.info("Result: {0}".format(result))
509
510     return result