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