blob: d99a36797fd358cb7b04d9d72114b33a65cee7a3 [file] [log] [blame]
Brad Bishop6e60e8b2018-02-01 10:27:11 -05001#
2# Copyright (c) 2017, Intel Corporation.
3#
4# This program is free software; you can redistribute it and/or modify it
5# under the terms and conditions of the GNU General Public License,
6# version 2, as published by the Free Software Foundation.
7#
8# This program is distributed in the hope it will be useful, but WITHOUT
9# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
10# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
11# more details.
12#
13"""Handling of build perf test reports"""
Brad Bishopd7bf8c12018-02-25 22:55:05 -050014from collections import OrderedDict, Mapping, namedtuple
Brad Bishop6e60e8b2018-02-01 10:27:11 -050015from datetime import datetime, timezone
16from numbers import Number
17from statistics import mean, stdev, variance
18
19
Brad Bishopd7bf8c12018-02-25 22:55:05 -050020AggregateTestData = namedtuple('AggregateTestData', ['metadata', 'results'])
21
22
Brad Bishop6e60e8b2018-02-01 10:27:11 -050023def isofmt_to_timestamp(string):
24 """Convert timestamp string in ISO 8601 format into unix timestamp"""
25 if '.' in string:
26 dt = datetime.strptime(string, '%Y-%m-%dT%H:%M:%S.%f')
27 else:
28 dt = datetime.strptime(string, '%Y-%m-%dT%H:%M:%S')
29 return dt.replace(tzinfo=timezone.utc).timestamp()
30
31
32def metadata_xml_to_json(elem):
33 """Convert metadata xml into JSON format"""
34 assert elem.tag == 'metadata', "Invalid metadata file format"
35
36 def _xml_to_json(elem):
37 """Convert xml element to JSON object"""
38 out = OrderedDict()
39 for child in elem.getchildren():
40 key = child.attrib.get('name', child.tag)
41 if len(child):
42 out[key] = _xml_to_json(child)
43 else:
44 out[key] = child.text
45 return out
46 return _xml_to_json(elem)
47
48
49def results_xml_to_json(elem):
50 """Convert results xml into JSON format"""
51 rusage_fields = ('ru_utime', 'ru_stime', 'ru_maxrss', 'ru_minflt',
52 'ru_majflt', 'ru_inblock', 'ru_oublock', 'ru_nvcsw',
53 'ru_nivcsw')
54 iostat_fields = ('rchar', 'wchar', 'syscr', 'syscw', 'read_bytes',
55 'write_bytes', 'cancelled_write_bytes')
56
57 def _read_measurement(elem):
58 """Convert measurement to JSON"""
59 data = OrderedDict()
60 data['type'] = elem.tag
61 data['name'] = elem.attrib['name']
62 data['legend'] = elem.attrib['legend']
63 values = OrderedDict()
64
65 # SYSRES measurement
66 if elem.tag == 'sysres':
67 for subel in elem:
68 if subel.tag == 'time':
69 values['start_time'] = isofmt_to_timestamp(subel.attrib['timestamp'])
70 values['elapsed_time'] = float(subel.text)
71 elif subel.tag == 'rusage':
72 rusage = OrderedDict()
73 for field in rusage_fields:
74 if 'time' in field:
75 rusage[field] = float(subel.attrib[field])
76 else:
77 rusage[field] = int(subel.attrib[field])
78 values['rusage'] = rusage
79 elif subel.tag == 'iostat':
80 values['iostat'] = OrderedDict([(f, int(subel.attrib[f]))
81 for f in iostat_fields])
82 elif subel.tag == 'buildstats_file':
83 values['buildstats_file'] = subel.text
84 else:
85 raise TypeError("Unknown sysres value element '{}'".format(subel.tag))
86 # DISKUSAGE measurement
87 elif elem.tag == 'diskusage':
88 values['size'] = int(elem.find('size').text)
89 else:
90 raise Exception("Unknown measurement tag '{}'".format(elem.tag))
91 data['values'] = values
92 return data
93
94 def _read_testcase(elem):
95 """Convert testcase into JSON"""
96 assert elem.tag == 'testcase', "Expecting 'testcase' element instead of {}".format(elem.tag)
97
98 data = OrderedDict()
99 data['name'] = elem.attrib['name']
100 data['description'] = elem.attrib['description']
101 data['status'] = 'SUCCESS'
102 data['start_time'] = isofmt_to_timestamp(elem.attrib['timestamp'])
103 data['elapsed_time'] = float(elem.attrib['time'])
104 measurements = OrderedDict()
105
106 for subel in elem.getchildren():
107 if subel.tag == 'error' or subel.tag == 'failure':
108 data['status'] = subel.tag.upper()
109 data['message'] = subel.attrib['message']
110 data['err_type'] = subel.attrib['type']
111 data['err_output'] = subel.text
112 elif subel.tag == 'skipped':
113 data['status'] = 'SKIPPED'
114 data['message'] = subel.text
115 else:
116 measurements[subel.attrib['name']] = _read_measurement(subel)
117 data['measurements'] = measurements
118 return data
119
120 def _read_testsuite(elem):
121 """Convert suite to JSON"""
122 assert elem.tag == 'testsuite', \
123 "Expecting 'testsuite' element instead of {}".format(elem.tag)
124
125 data = OrderedDict()
126 if 'hostname' in elem.attrib:
127 data['tester_host'] = elem.attrib['hostname']
128 data['start_time'] = isofmt_to_timestamp(elem.attrib['timestamp'])
129 data['elapsed_time'] = float(elem.attrib['time'])
130 tests = OrderedDict()
131
132 for case in elem.getchildren():
133 tests[case.attrib['name']] = _read_testcase(case)
134 data['tests'] = tests
135 return data
136
137 # Main function
138 assert elem.tag == 'testsuites', "Invalid test report format"
139 assert len(elem) == 1, "Too many testsuites"
140
141 return _read_testsuite(elem.getchildren()[0])
142
143
144def aggregate_metadata(metadata):
145 """Aggregate metadata into one, basically a sanity check"""
146 mutable_keys = ('pretty_name', 'version_id')
147
148 def aggregate_obj(aggregate, obj, assert_str=True):
149 """Aggregate objects together"""
150 assert type(aggregate) is type(obj), \
151 "Type mismatch: {} != {}".format(type(aggregate), type(obj))
152 if isinstance(obj, Mapping):
153 assert set(aggregate.keys()) == set(obj.keys())
154 for key, val in obj.items():
155 aggregate_obj(aggregate[key], val, key not in mutable_keys)
156 elif isinstance(obj, list):
157 assert len(aggregate) == len(obj)
158 for i, val in enumerate(obj):
159 aggregate_obj(aggregate[i], val)
160 elif not isinstance(obj, str) or (isinstance(obj, str) and assert_str):
161 assert aggregate == obj, "Data mismatch {} != {}".format(aggregate, obj)
162
163 if not metadata:
164 return {}
165
166 # Do the aggregation
167 aggregate = metadata[0].copy()
168 for testrun in metadata[1:]:
169 aggregate_obj(aggregate, testrun)
170 aggregate['testrun_count'] = len(metadata)
171 return aggregate
172
173
174def aggregate_data(data):
175 """Aggregate multiple test results JSON structures into one"""
176
177 mutable_keys = ('status', 'message', 'err_type', 'err_output')
178
179 class SampleList(list):
180 """Container for numerical samples"""
181 pass
182
183 def new_aggregate_obj(obj):
184 """Create new object for aggregate"""
185 if isinstance(obj, Number):
186 new_obj = SampleList()
187 new_obj.append(obj)
188 elif isinstance(obj, str):
189 new_obj = obj
190 else:
191 # Lists and and dicts are kept as is
192 new_obj = obj.__class__()
193 aggregate_obj(new_obj, obj)
194 return new_obj
195
196 def aggregate_obj(aggregate, obj, assert_str=True):
197 """Recursive "aggregation" of JSON objects"""
198 if isinstance(obj, Number):
199 assert isinstance(aggregate, SampleList)
200 aggregate.append(obj)
201 return
202
203 assert type(aggregate) == type(obj), \
204 "Type mismatch: {} != {}".format(type(aggregate), type(obj))
205 if isinstance(obj, Mapping):
206 for key, val in obj.items():
207 if not key in aggregate:
208 aggregate[key] = new_aggregate_obj(val)
209 else:
210 aggregate_obj(aggregate[key], val, key not in mutable_keys)
211 elif isinstance(obj, list):
212 for i, val in enumerate(obj):
213 if i >= len(aggregate):
214 aggregate[key] = new_aggregate_obj(val)
215 else:
216 aggregate_obj(aggregate[i], val)
217 elif isinstance(obj, str):
218 # Sanity check for data
219 if assert_str:
220 assert aggregate == obj, "Data mismatch {} != {}".format(aggregate, obj)
221 else:
222 raise Exception("BUG: unable to aggregate '{}' ({})".format(type(obj), str(obj)))
223
224 if not data:
225 return {}
226
227 # Do the aggregation
228 aggregate = data[0].__class__()
229 for testrun in data:
230 aggregate_obj(aggregate, testrun)
231 return aggregate
232
233
234class MeasurementVal(float):
235 """Base class representing measurement values"""
236 gv_data_type = 'number'
237
238 def gv_value(self):
239 """Value formatting for visualization"""
240 if self != self:
241 return "null"
242 else:
243 return self
244
245
246class TimeVal(MeasurementVal):
247 """Class representing time values"""
248 quantity = 'time'
249 gv_title = 'elapsed time'
250 gv_data_type = 'timeofday'
251
252 def hms(self):
253 """Split time into hours, minutes and seconeds"""
254 hhh = int(abs(self) / 3600)
255 mmm = int((abs(self) % 3600) / 60)
256 sss = abs(self) % 60
257 return hhh, mmm, sss
258
259 def __str__(self):
260 if self != self:
261 return "nan"
262 hh, mm, ss = self.hms()
263 sign = '-' if self < 0 else ''
264 if hh > 0:
265 return '{}{:d}:{:02d}:{:02.0f}'.format(sign, hh, mm, ss)
266 elif mm > 0:
267 return '{}{:d}:{:04.1f}'.format(sign, mm, ss)
268 elif ss > 1:
269 return '{}{:.1f} s'.format(sign, ss)
270 else:
271 return '{}{:.2f} s'.format(sign, ss)
272
273 def gv_value(self):
274 """Value formatting for visualization"""
275 if self != self:
276 return "null"
277 hh, mm, ss = self.hms()
278 return [hh, mm, int(ss), int(ss*1000) % 1000]
279
280
281class SizeVal(MeasurementVal):
282 """Class representing time values"""
283 quantity = 'size'
284 gv_title = 'size in MiB'
285 gv_data_type = 'number'
286
287 def __str__(self):
288 if self != self:
289 return "nan"
290 if abs(self) < 1024:
291 return '{:.1f} kiB'.format(self)
292 elif abs(self) < 1048576:
293 return '{:.2f} MiB'.format(self / 1024)
294 else:
295 return '{:.2f} GiB'.format(self / 1048576)
296
297 def gv_value(self):
298 """Value formatting for visualization"""
299 if self != self:
300 return "null"
301 return self / 1024
302
303def measurement_stats(meas, prefix=''):
304 """Get statistics of a measurement"""
305 if not meas:
306 return {prefix + 'sample_cnt': 0,
307 prefix + 'mean': MeasurementVal('nan'),
308 prefix + 'stdev': MeasurementVal('nan'),
309 prefix + 'variance': MeasurementVal('nan'),
310 prefix + 'min': MeasurementVal('nan'),
311 prefix + 'max': MeasurementVal('nan'),
312 prefix + 'minus': MeasurementVal('nan'),
313 prefix + 'plus': MeasurementVal('nan')}
314
315 stats = {'name': meas['name']}
316 if meas['type'] == 'sysres':
317 val_cls = TimeVal
318 values = meas['values']['elapsed_time']
319 elif meas['type'] == 'diskusage':
320 val_cls = SizeVal
321 values = meas['values']['size']
322 else:
323 raise Exception("Unknown measurement type '{}'".format(meas['type']))
324 stats['val_cls'] = val_cls
325 stats['quantity'] = val_cls.quantity
326 stats[prefix + 'sample_cnt'] = len(values)
327
328 mean_val = val_cls(mean(values))
329 min_val = val_cls(min(values))
330 max_val = val_cls(max(values))
331
332 stats[prefix + 'mean'] = mean_val
333 if len(values) > 1:
334 stats[prefix + 'stdev'] = val_cls(stdev(values))
335 stats[prefix + 'variance'] = val_cls(variance(values))
336 else:
337 stats[prefix + 'stdev'] = float('nan')
338 stats[prefix + 'variance'] = float('nan')
339 stats[prefix + 'min'] = min_val
340 stats[prefix + 'max'] = max_val
341 stats[prefix + 'minus'] = val_cls(mean_val - min_val)
342 stats[prefix + 'plus'] = val_cls(max_val - mean_val)
343
344 return stats
345