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