diff --git a/lnt/server/ui/templates/v4_graph.html b/lnt/server/ui/templates/v4_graph.html index 82a784f4..79270bf1 100644 --- a/lnt/server/ui/templates/v4_graph.html +++ b/lnt/server/ui/templates/v4_graph.html @@ -137,9 +137,18 @@

Controls

{{ 'checked="checked"' if options.xaxis_date else ""}}/> - Mean() as Aggregation - + Aggregation function: + + {# Split this into a new row to avoid making the dialog wider. #} + + + + Hide Line Plot: diff --git a/lnt/server/ui/templates/v4_run.html b/lnt/server/ui/templates/v4_run.html index fc1d9d86..5ff439e6 100644 --- a/lnt/server/ui/templates/v4_run.html +++ b/lnt/server/ui/templates/v4_run.html @@ -252,13 +252,13 @@

Parameters

- + - + + + + diff --git a/lnt/server/ui/views.py b/lnt/server/ui/views.py index 91191e9b..56687df1 100644 --- a/lnt/server/ui/views.py +++ b/lnt/server/ui/views.py @@ -186,6 +186,25 @@ def ts_data(ts): } +def determine_aggregation_function(function_name): + """ + Return the aggregation function associated to the provided function name, or None if + the function name is unsupported. + + This is used by dropdown menus that allow selecting from multiple aggregation functions. + """ + if function_name == 'min': + return lnt.util.stats.safe_min + elif function_name == 'max': + return lnt.util.stats.safe_max + elif function_name == 'mean': + return lnt.util.stats.mean + elif function_name == 'median': + return lnt.util.stats.median + else: + return None + + @db_route('/submitRun', methods=('GET', 'POST')) def submit_run(): """Compatibility url that hardcodes testsuite to 'nts'""" @@ -353,10 +372,10 @@ def __init__(self, run_id): abort(404, "Invalid run id {}".format(run_id)) # Get the aggregation function to use. - aggregation_fn_name = request.args.get('aggregation_fn') - self.aggregation_fn = {'min': lnt.util.stats.safe_min, - 'median': lnt.util.stats.median}.get( - aggregation_fn_name, lnt.util.stats.safe_min) + fn_name = request.args.get('aggregation_function', 'min') + aggregation_fn = determine_aggregation_function(fn_name) + if aggregation_fn is None: + abort(404, "Invalid aggregation function name {}".format(fn_name)) # Get the MW confidence level. try: @@ -428,7 +447,7 @@ def __init__(self, run_id): session, self.run, baseurl=db_url_for('.index', _external=False), result=None, compare_to=compare_to, baseline=baseline, num_comparison_runs=self.num_comparison_runs, - aggregation_fn=self.aggregation_fn, confidence_lv=confidence_lv, + aggregation_fn=aggregation_fn, confidence_lv=confidence_lv, styles=styles, classes=classes) self.sri = self.data['sri'] note = self.data['visible_note'] @@ -516,7 +535,7 @@ def v4_run(id): else: test_min_value_filter = 0.0 - options['aggregation_fn'] = request.args.get('aggregation_fn', 'min') + options['aggregation_function'] = request.args.get('aggregation_function', 'min') # Get the test names. test_info = session.query(ts.Test.name, ts.Test.id).\ @@ -961,30 +980,13 @@ def v4_tableau(): @v4_route("/graph") def v4_graph(): - session = request.session ts = request.get_testsuite() - switch_min_mean_local = False - if 'switch_min_mean_session' not in flask.session: - flask.session['switch_min_mean_session'] = False # Parse the view options. - options = {'min_mean_checkbox': 'min()'} - if 'submit' in request.args: # user pressed a button - if 'switch_min_mean' in request.args: # user checked mean() checkbox - flask.session['switch_min_mean_session'] = \ - options['switch_min_mean'] = \ - bool(request.args.get('switch_min_mean')) - switch_min_mean_local = flask.session['switch_min_mean_session'] - else: # mean() check box is not checked - flask.session['switch_min_mean_session'] = \ - options['switch_min_mean'] = \ - bool(request.args.get('switch_min_mean')) - switch_min_mean_local = flask.session['switch_min_mean_session'] - else: # new page was loaded by clicking link, not submit button - options['switch_min_mean'] = switch_min_mean_local = \ - flask.session['switch_min_mean_session'] - + options = {} + options['aggregation_function'] = \ + request.args.get('aggregation_function') # default determined later based on the field being graphed options['hide_lineplot'] = bool(request.args.get('hide_lineplot')) show_lineplot = not options['hide_lineplot'] options['show_mad'] = show_mad = bool(request.args.get('show_mad')) @@ -1198,15 +1200,18 @@ def trace_name(name, test_name, field_name): is_multisample = (len(values) > 1) - aggregation_fn = min - if switch_min_mean_local: - aggregation_fn = lnt.util.stats.agg_mean - if field.bigger_is_better: - aggregation_fn = max + fn_name = options.get('aggregation_function') or ('max' if field.bigger_is_better else 'min') + aggregation_fn = determine_aggregation_function(fn_name) + if aggregation_fn is None: + abort(404, "Invalid aggregation function name {}".format(fn_name)) + agg_value = aggregation_fn(values) + + # When aggregating multiple samples, it becomes unclear which sample to use for + # associated data like the run date, the order, etc. Use the index of the closest + # value in all the samples. + closest_value = sorted(values, key=lambda val: abs(val - agg_value))[0] + agg_index = values.index(closest_value) - agg_value, agg_index = \ - aggregation_fn((value, index) - for (index, value) in enumerate(values)) pts_y.append(agg_value) # Plotly does not sort X axis in case of type: 'category'. diff --git a/lnt/util/stats.py b/lnt/util/stats.py index e9e23ea1..383ef980 100644 --- a/lnt/util/stats.py +++ b/lnt/util/stats.py @@ -33,24 +33,6 @@ def geometric_mean(values): return reduce(lambda a, b: a * b, [v ** iPow for v in values]) -def agg_mean(pairs): - """Aggregation function in views.py receives input via enumerate and - produces a tuple. - Input: (value, index) - Output: (mean, 0), or (None, None) on invalid input. - """ - if not pairs: - return None, None - my_sum = 0.0 - counter = 0 - for item in pairs: - my_sum += item[0] - counter += 1 - if counter > 0: - return my_sum / counter, 0 - return None, None - - def median(values): if not values: return None diff --git a/tests/server/ui/V4Pages.py b/tests/server/ui/V4Pages.py index 9abe1518..385f388c 100644 --- a/tests/server/ui/V4Pages.py +++ b/tests/server/ui/V4Pages.py @@ -636,10 +636,11 @@ def main(): assert 2 == lines_in_function # Make sure the new option does not break anything - check_html(client, '/db_default/v4/nts/graph?switch_min_mean=yes&plot.0=1.3.2&submit=Update') - check_json(client, '/db_default/v4/nts/graph?switch_min_mean=yes&plot.0=1.3.2&json=true&submit=Update') - check_html(client, '/db_default/v4/nts/graph?switch_min_mean=yes&plot.0=1.3.2') - check_json(client, '/db_default/v4/nts/graph?switch_min_mean=yes&plot.0=1.3.2&json=true') + check_html(client, '/db_default/v4/nts/graph?aggregation_function=mean&plot.0=1.3.2&submit=Update') + check_json(client, '/db_default/v4/nts/graph?aggregation_function=mean&plot.0=1.3.2&json=true&submit=Update') + check_html(client, '/db_default/v4/nts/graph?aggregation_function=mean&plot.0=1.3.2') + check_json(client, '/db_default/v4/nts/graph?aggregation_function=mean&plot.0=1.3.2&json=true') + check_html(client, '/db_default/v4/nts/graph?aggregation_function=nonexistent&plot.0=1.3.2', expected_code=404) app.testing = False error_page = check_html(client, '/explode', expected_code=500) assert re.search("division (or modulo )?by zero", diff --git a/tests/server/ui/statsTester.py b/tests/server/ui/statsTester.py index 30cd2a71..7ffc41fe 100644 --- a/tests/server/ui/statsTester.py +++ b/tests/server/ui/statsTester.py @@ -4,44 +4,35 @@ import lnt.util.stats as stats -INDEX = 0 - class TestLNTStatsTester(unittest.TestCase): - - @staticmethod - def _loc_test_agg_mean(values): - if values is None: - return stats.agg_mean(None) - agg_value, agg_index = stats.agg_mean( - (value, index) for (index, value) in enumerate(values)) - return agg_value, agg_index - - def test_agg_mean(self): - test_list1 = [1, 2, 3, 4, 6] - self.assertEqual(TestLNTStatsTester._loc_test_agg_mean(test_list1), - (3.2, INDEX)) - test_list2 = [1.0, 2.0, 3.0, 4.0] - self.assertEqual(TestLNTStatsTester._loc_test_agg_mean(test_list2), - (2.5, INDEX)) - test_list3 = [1.0] - self.assertEqual(TestLNTStatsTester._loc_test_agg_mean(test_list3), - (1.0, INDEX)) - self.assertEqual(TestLNTStatsTester._loc_test_agg_mean([]), - (None, None)) - self.assertEqual(TestLNTStatsTester._loc_test_agg_mean(None), - (None, None)) - - # Test it exactly how it is called in views.py without indirection - agg_value, agg_index = stats.agg_mean( - (value, index) for (index, value) in enumerate(test_list1)) - self.assertEqual((3.2, INDEX), (agg_value, agg_index)) - agg_value, agg_index = stats.agg_mean( - (value, index) for (index, value) in enumerate(test_list2)) - self.assertEqual((2.5, INDEX), (agg_value, agg_index)) - agg_value, agg_index = stats.agg_mean( - (value, index) for (index, value) in enumerate(test_list3)) - self.assertEqual((1.0, INDEX), (agg_value, agg_index)) + def test_safe_min(self): + self.assertEqual(stats.safe_min([]), None) + self.assertEqual(stats.safe_min([1]), 1) + self.assertEqual(stats.safe_min([1, 2, 3]), 1) + self.assertEqual(stats.safe_min([3, 2, 1]), 1) + self.assertEqual(stats.safe_min([1, 1, 1]), 1) + + def test_safe_max(self): + self.assertEqual(stats.safe_max([]), None) + self.assertEqual(stats.safe_max([1]), 1) + self.assertEqual(stats.safe_max([1, 2, 3]), 3) + self.assertEqual(stats.safe_max([3, 2, 1]), 3) + self.assertEqual(stats.safe_max([1, 1, 1]), 1) + + def test_mean(self): + self.assertEqual(stats.mean([]), None) + self.assertEqual(stats.mean([1]), 1) + self.assertEqual(stats.mean([1, 2, 3]), 2) + self.assertEqual(stats.mean([3, 2, 1]), 2) + self.assertEqual(stats.mean([1, 1, 1]), 1) + + def test_median(self): + self.assertEqual(stats.median([]), None) + self.assertEqual(stats.median([1]), 1) + self.assertEqual(stats.median([1, 2, 3]), 2) + self.assertEqual(stats.median([3, 2, 1]), 2) + self.assertEqual(stats.median([1, 1, 1]), 1) if __name__ == '__main__':