Skip to content

Commit a86db1b

Browse files
authored
Make aggregation function selection consistent across Run and Chart pages (#42)
Currently, the Run page (e.g. /db_default/v4/nts/<runid>) provides a way to select the aggregation function used when there are multiple samples in the run (that option is located under "View Options"). The supported options are min and median. Similarly, the Graph page (e.g. /db_default/v4/nts/graph?<params>) provides a way to use mean instead of min to aggregate multiple data points for the same order on the graph. That is provided as a checkbox under the "Settings 🔧" menu. This patch makes both selection mechanisms consistent by using a dropdown menu that provides min, max, mean and median in both cases. As a drive-by, it reimplements how we select the sample to use as a reference for related data: we now select the point closest to the aggregated value instead of selecting the first point unconditionally.
1 parent 7485824 commit a86db1b

File tree

6 files changed

+89
-101
lines changed

6 files changed

+89
-101
lines changed

lnt/server/ui/templates/v4_graph.html

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,18 @@ <h4>Controls</h4>
137137
{{ 'checked="checked"' if options.xaxis_date else ""}}/></td>
138138
</tr>
139139
<tr>
140-
<td>Mean() as Aggregation</td>
141-
<td><input type="checkbox" name="switch_min_mean" value="yes"
142-
{{ 'checked="checked"' if options.switch_min_mean else ""}}/></td>
140+
<td>Aggregation function:</td>
141+
</tr>
142+
{# Split this into a new row to avoid making the dialog wider. #}
143+
<tr>
144+
<td>
145+
<select name="aggregation_function" id="aggregation_function">
146+
<option value="min" {{ 'selected="selected"' if options.aggregation_function == 'min' else '' }}>Minimum</option>
147+
<option value="max" {{ 'selected="selected"' if options.aggregation_function == 'max' else '' }}>Maximum</option>
148+
<option value="mean" {{ 'selected="selected"' if options.aggregation_function == 'mean' else '' }}>Mean</option>
149+
<option value="median" {{ 'selected="selected"' if options.aggregation_function == 'median' else '' }}>Median</option>
150+
</select>
151+
</td>
143152
</tr>
144153
<tr>
145154
<td>Hide Line Plot:</td>

lnt/server/ui/templates/v4_run.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -252,13 +252,13 @@ <h4>Parameters</h4>
252252
<td><input id="test_min_value_filter" type="text" name="test_min_value_filter" value="{{ options.test_min_value_filter }}"/></td>
253253
</tr>
254254
<tr>
255-
<td><label for="agg_func">Aggregation Function</label></td>
255+
<td><label for="aggregation_function">Aggregation Function</label></td>
256256
<td>
257-
<select id="agg_func" name="aggregation_fn">
258-
<option value="min" {{ ('selected="selected"' if "min" == options.aggregation_fn else '')|safe}}>
259-
Minimum</option>
260-
<option value="median" {{ ('selected="selected"' if "median" == options.aggregation_fn else '')|safe}}>
261-
Median</option>
257+
<select id="aggregation_function" name="aggregation_function">
258+
<option value="min" {{ 'selected="selected"' if options.aggregation_function == 'min' else '' }}>Minimum</option>
259+
<option value="max" {{ 'selected="selected"' if options.aggregation_function == 'max' else '' }}>Maximum</option>
260+
<option value="mean" {{ 'selected="selected"' if options.aggregation_function == 'mean' else '' }}>Mean</option>
261+
<option value="median" {{ 'selected="selected"' if options.aggregation_function == 'median' else '' }}>Median</option>
262262
</select>
263263
</td>
264264
</tr>

lnt/server/ui/views.py

Lines changed: 39 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,25 @@ def ts_data(ts):
186186
}
187187

188188

189+
def determine_aggregation_function(function_name):
190+
"""
191+
Return the aggregation function associated to the provided function name, or None if
192+
the function name is unsupported.
193+
194+
This is used by dropdown menus that allow selecting from multiple aggregation functions.
195+
"""
196+
if function_name == 'min':
197+
return lnt.util.stats.safe_min
198+
elif function_name == 'max':
199+
return lnt.util.stats.safe_max
200+
elif function_name == 'mean':
201+
return lnt.util.stats.mean
202+
elif function_name == 'median':
203+
return lnt.util.stats.median
204+
else:
205+
return None
206+
207+
189208
@db_route('/submitRun', methods=('GET', 'POST'))
190209
def submit_run():
191210
"""Compatibility url that hardcodes testsuite to 'nts'"""
@@ -353,10 +372,10 @@ def __init__(self, run_id):
353372
abort(404, "Invalid run id {}".format(run_id))
354373

355374
# Get the aggregation function to use.
356-
aggregation_fn_name = request.args.get('aggregation_fn')
357-
self.aggregation_fn = {'min': lnt.util.stats.safe_min,
358-
'median': lnt.util.stats.median}.get(
359-
aggregation_fn_name, lnt.util.stats.safe_min)
375+
fn_name = request.args.get('aggregation_function', 'min')
376+
aggregation_fn = determine_aggregation_function(fn_name)
377+
if aggregation_fn is None:
378+
abort(404, "Invalid aggregation function name {}".format(fn_name))
360379

361380
# Get the MW confidence level.
362381
try:
@@ -428,7 +447,7 @@ def __init__(self, run_id):
428447
session, self.run, baseurl=db_url_for('.index', _external=False),
429448
result=None, compare_to=compare_to, baseline=baseline,
430449
num_comparison_runs=self.num_comparison_runs,
431-
aggregation_fn=self.aggregation_fn, confidence_lv=confidence_lv,
450+
aggregation_fn=aggregation_fn, confidence_lv=confidence_lv,
432451
styles=styles, classes=classes)
433452
self.sri = self.data['sri']
434453
note = self.data['visible_note']
@@ -516,7 +535,7 @@ def v4_run(id):
516535
else:
517536
test_min_value_filter = 0.0
518537

519-
options['aggregation_fn'] = request.args.get('aggregation_fn', 'min')
538+
options['aggregation_function'] = request.args.get('aggregation_function', 'min')
520539

521540
# Get the test names.
522541
test_info = session.query(ts.Test.name, ts.Test.id).\
@@ -961,30 +980,13 @@ def v4_tableau():
961980

962981
@v4_route("/graph")
963982
def v4_graph():
964-
965983
session = request.session
966984
ts = request.get_testsuite()
967-
switch_min_mean_local = False
968985

969-
if 'switch_min_mean_session' not in flask.session:
970-
flask.session['switch_min_mean_session'] = False
971986
# Parse the view options.
972-
options = {'min_mean_checkbox': 'min()'}
973-
if 'submit' in request.args: # user pressed a button
974-
if 'switch_min_mean' in request.args: # user checked mean() checkbox
975-
flask.session['switch_min_mean_session'] = \
976-
options['switch_min_mean'] = \
977-
bool(request.args.get('switch_min_mean'))
978-
switch_min_mean_local = flask.session['switch_min_mean_session']
979-
else: # mean() check box is not checked
980-
flask.session['switch_min_mean_session'] = \
981-
options['switch_min_mean'] = \
982-
bool(request.args.get('switch_min_mean'))
983-
switch_min_mean_local = flask.session['switch_min_mean_session']
984-
else: # new page was loaded by clicking link, not submit button
985-
options['switch_min_mean'] = switch_min_mean_local = \
986-
flask.session['switch_min_mean_session']
987-
987+
options = {}
988+
options['aggregation_function'] = \
989+
request.args.get('aggregation_function') # default determined later based on the field being graphed
988990
options['hide_lineplot'] = bool(request.args.get('hide_lineplot'))
989991
show_lineplot = not options['hide_lineplot']
990992
options['show_mad'] = show_mad = bool(request.args.get('show_mad'))
@@ -1198,15 +1200,18 @@ def trace_name(name, test_name, field_name):
11981200

11991201
is_multisample = (len(values) > 1)
12001202

1201-
aggregation_fn = min
1202-
if switch_min_mean_local:
1203-
aggregation_fn = lnt.util.stats.agg_mean
1204-
if field.bigger_is_better:
1205-
aggregation_fn = max
1203+
fn_name = options.get('aggregation_function') or ('max' if field.bigger_is_better else 'min')
1204+
aggregation_fn = determine_aggregation_function(fn_name)
1205+
if aggregation_fn is None:
1206+
abort(404, "Invalid aggregation function name {}".format(fn_name))
1207+
agg_value = aggregation_fn(values)
1208+
1209+
# When aggregating multiple samples, it becomes unclear which sample to use for
1210+
# associated data like the run date, the order, etc. Use the index of the closest
1211+
# value in all the samples.
1212+
closest_value = sorted(values, key=lambda val: abs(val - agg_value))[0]
1213+
agg_index = values.index(closest_value)
12061214

1207-
agg_value, agg_index = \
1208-
aggregation_fn((value, index)
1209-
for (index, value) in enumerate(values))
12101215
pts_y.append(agg_value)
12111216

12121217
# Plotly does not sort X axis in case of type: 'category'.

lnt/util/stats.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,6 @@ def geometric_mean(values):
3333
return reduce(lambda a, b: a * b, [v ** iPow for v in values])
3434

3535

36-
def agg_mean(pairs):
37-
"""Aggregation function in views.py receives input via enumerate and
38-
produces a tuple.
39-
Input: (value, index)
40-
Output: (mean, 0), or (None, None) on invalid input.
41-
"""
42-
if not pairs:
43-
return None, None
44-
my_sum = 0.0
45-
counter = 0
46-
for item in pairs:
47-
my_sum += item[0]
48-
counter += 1
49-
if counter > 0:
50-
return my_sum / counter, 0
51-
return None, None
52-
53-
5436
def median(values):
5537
if not values:
5638
return None

tests/server/ui/V4Pages.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -636,10 +636,11 @@ def main():
636636
assert 2 == lines_in_function
637637

638638
# Make sure the new option does not break anything
639-
check_html(client, '/db_default/v4/nts/graph?switch_min_mean=yes&plot.0=1.3.2&submit=Update')
640-
check_json(client, '/db_default/v4/nts/graph?switch_min_mean=yes&plot.0=1.3.2&json=true&submit=Update')
641-
check_html(client, '/db_default/v4/nts/graph?switch_min_mean=yes&plot.0=1.3.2')
642-
check_json(client, '/db_default/v4/nts/graph?switch_min_mean=yes&plot.0=1.3.2&json=true')
639+
check_html(client, '/db_default/v4/nts/graph?aggregation_function=mean&plot.0=1.3.2&submit=Update')
640+
check_json(client, '/db_default/v4/nts/graph?aggregation_function=mean&plot.0=1.3.2&json=true&submit=Update')
641+
check_html(client, '/db_default/v4/nts/graph?aggregation_function=mean&plot.0=1.3.2')
642+
check_json(client, '/db_default/v4/nts/graph?aggregation_function=mean&plot.0=1.3.2&json=true')
643+
check_html(client, '/db_default/v4/nts/graph?aggregation_function=nonexistent&plot.0=1.3.2', expected_code=404)
643644
app.testing = False
644645
error_page = check_html(client, '/explode', expected_code=500)
645646
assert re.search("division (or modulo )?by zero",

tests/server/ui/statsTester.py

Lines changed: 27 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,35 @@
44

55
import lnt.util.stats as stats
66

7-
INDEX = 0
8-
97

108
class TestLNTStatsTester(unittest.TestCase):
11-
12-
@staticmethod
13-
def _loc_test_agg_mean(values):
14-
if values is None:
15-
return stats.agg_mean(None)
16-
agg_value, agg_index = stats.agg_mean(
17-
(value, index) for (index, value) in enumerate(values))
18-
return agg_value, agg_index
19-
20-
def test_agg_mean(self):
21-
test_list1 = [1, 2, 3, 4, 6]
22-
self.assertEqual(TestLNTStatsTester._loc_test_agg_mean(test_list1),
23-
(3.2, INDEX))
24-
test_list2 = [1.0, 2.0, 3.0, 4.0]
25-
self.assertEqual(TestLNTStatsTester._loc_test_agg_mean(test_list2),
26-
(2.5, INDEX))
27-
test_list3 = [1.0]
28-
self.assertEqual(TestLNTStatsTester._loc_test_agg_mean(test_list3),
29-
(1.0, INDEX))
30-
self.assertEqual(TestLNTStatsTester._loc_test_agg_mean([]),
31-
(None, None))
32-
self.assertEqual(TestLNTStatsTester._loc_test_agg_mean(None),
33-
(None, None))
34-
35-
# Test it exactly how it is called in views.py without indirection
36-
agg_value, agg_index = stats.agg_mean(
37-
(value, index) for (index, value) in enumerate(test_list1))
38-
self.assertEqual((3.2, INDEX), (agg_value, agg_index))
39-
agg_value, agg_index = stats.agg_mean(
40-
(value, index) for (index, value) in enumerate(test_list2))
41-
self.assertEqual((2.5, INDEX), (agg_value, agg_index))
42-
agg_value, agg_index = stats.agg_mean(
43-
(value, index) for (index, value) in enumerate(test_list3))
44-
self.assertEqual((1.0, INDEX), (agg_value, agg_index))
9+
def test_safe_min(self):
10+
self.assertEqual(stats.safe_min([]), None)
11+
self.assertEqual(stats.safe_min([1]), 1)
12+
self.assertEqual(stats.safe_min([1, 2, 3]), 1)
13+
self.assertEqual(stats.safe_min([3, 2, 1]), 1)
14+
self.assertEqual(stats.safe_min([1, 1, 1]), 1)
15+
16+
def test_safe_max(self):
17+
self.assertEqual(stats.safe_max([]), None)
18+
self.assertEqual(stats.safe_max([1]), 1)
19+
self.assertEqual(stats.safe_max([1, 2, 3]), 3)
20+
self.assertEqual(stats.safe_max([3, 2, 1]), 3)
21+
self.assertEqual(stats.safe_max([1, 1, 1]), 1)
22+
23+
def test_mean(self):
24+
self.assertEqual(stats.mean([]), None)
25+
self.assertEqual(stats.mean([1]), 1)
26+
self.assertEqual(stats.mean([1, 2, 3]), 2)
27+
self.assertEqual(stats.mean([3, 2, 1]), 2)
28+
self.assertEqual(stats.mean([1, 1, 1]), 1)
29+
30+
def test_median(self):
31+
self.assertEqual(stats.median([]), None)
32+
self.assertEqual(stats.median([1]), 1)
33+
self.assertEqual(stats.median([1, 2, 3]), 2)
34+
self.assertEqual(stats.median([3, 2, 1]), 2)
35+
self.assertEqual(stats.median([1, 1, 1]), 1)
4536

4637

4738
if __name__ == '__main__':

0 commit comments

Comments
 (0)