Skip to content

Commit 14d8fa2

Browse files
committed
Make aggregation function selection consistent across Run and Chart pages
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 14d8fa2

File tree

6 files changed

+81
-102
lines changed

6 files changed

+81
-102
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: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,23 @@ 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. This is used by
192+
dropdown menus that allow selecting from multiple aggregation functions.
193+
"""
194+
if function_name == 'min':
195+
return lnt.util.stats.safe_min
196+
elif function_name == 'max':
197+
return lnt.util.stats.safe_max
198+
elif function_name == 'mean':
199+
return lnt.util.stats.mean
200+
elif function_name == 'median':
201+
return lnt.util.stats.median
202+
else:
203+
assert False, f'Invalid aggregation function name {function_name}'
204+
205+
189206
@db_route('/submitRun', methods=('GET', 'POST'))
190207
def submit_run():
191208
"""Compatibility url that hardcodes testsuite to 'nts'"""
@@ -353,10 +370,7 @@ def __init__(self, run_id):
353370
abort(404, "Invalid run id {}".format(run_id))
354371

355372
# 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)
373+
aggregation_fn = determine_aggregation_function(request.args.get('aggregation_function', 'min'))
360374

361375
# Get the MW confidence level.
362376
try:
@@ -428,7 +442,7 @@ def __init__(self, run_id):
428442
session, self.run, baseurl=db_url_for('.index', _external=False),
429443
result=None, compare_to=compare_to, baseline=baseline,
430444
num_comparison_runs=self.num_comparison_runs,
431-
aggregation_fn=self.aggregation_fn, confidence_lv=confidence_lv,
445+
aggregation_fn=aggregation_fn, confidence_lv=confidence_lv,
432446
styles=styles, classes=classes)
433447
self.sri = self.data['sri']
434448
note = self.data['visible_note']
@@ -516,7 +530,7 @@ def v4_run(id):
516530
else:
517531
test_min_value_filter = 0.0
518532

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

521535
# Get the test names.
522536
test_info = session.query(ts.Test.name, ts.Test.id).\
@@ -961,30 +975,13 @@ def v4_tableau():
961975

962976
@v4_route("/graph")
963977
def v4_graph():
964-
965978
session = request.session
966979
ts = request.get_testsuite()
967-
switch_min_mean_local = False
968980

969-
if 'switch_min_mean_session' not in flask.session:
970-
flask.session['switch_min_mean_session'] = False
971981
# 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-
982+
options = {}
983+
options['aggregation_function'] = \
984+
request.args.get('aggregation_function') # default determined later based on the field being graphed
988985
options['hide_lineplot'] = bool(request.args.get('hide_lineplot'))
989986
show_lineplot = not options['hide_lineplot']
990987
options['show_mad'] = show_mad = bool(request.args.get('show_mad'))
@@ -1198,15 +1195,16 @@ def trace_name(name, test_name, field_name):
11981195

11991196
is_multisample = (len(values) > 1)
12001197

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
1198+
fn_name = options.get('aggregation_function') or ('max' if field.bigger_is_better else 'min')
1199+
aggregation_fn = determine_aggregation_function(fn_name)
1200+
agg_value = aggregation_fn(values)
1201+
1202+
# When aggregating multiple samples, it becomes unclear which sample to use for
1203+
# associated data like the run date, the order, etc. Use the index of the closest
1204+
# value in all the samples.
1205+
closest_value = sorted(values, key=lambda val: abs(val - agg_value))[0]
1206+
agg_index = values.index(closest_value)
12061207

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

12121210
# 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: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -636,10 +636,10 @@ 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')
643643
app.testing = False
644644
error_page = check_html(client, '/explode', expected_code=500)
645645
assert re.search("division (or modulo )?by zero",

tests/server/ui/statsTester.py

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

55
import lnt.util.stats as stats
66

7-
INDEX = 0
8-
9-
107
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))
8+
def test_safe_min(self):
9+
self.assertEqual(stats.safe_min([]), None)
10+
self.assertEqual(stats.safe_min([1]), 1)
11+
self.assertEqual(stats.safe_min([1, 2, 3]), 1)
12+
self.assertEqual(stats.safe_min([3, 2, 1]), 1)
13+
self.assertEqual(stats.safe_min([1, 1, 1]), 1)
14+
15+
def test_safe_max(self):
16+
self.assertEqual(stats.safe_max([]), None)
17+
self.assertEqual(stats.safe_max([1]), 1)
18+
self.assertEqual(stats.safe_max([1, 2, 3]), 3)
19+
self.assertEqual(stats.safe_max([3, 2, 1]), 3)
20+
self.assertEqual(stats.safe_max([1, 1, 1]), 1)
21+
22+
def test_mean(self):
23+
self.assertEqual(stats.mean([]), None)
24+
self.assertEqual(stats.mean([1]), 1)
25+
self.assertEqual(stats.mean([1, 2, 3]), 2)
26+
self.assertEqual(stats.mean([3, 2, 1]), 2)
27+
self.assertEqual(stats.mean([1, 1, 1]), 1)
28+
29+
def test_median(self):
30+
self.assertEqual(stats.median([]), None)
31+
self.assertEqual(stats.median([1]), 1)
32+
self.assertEqual(stats.median([1, 2, 3]), 2)
33+
self.assertEqual(stats.median([3, 2, 1]), 2)
34+
self.assertEqual(stats.median([1, 1, 1]), 1)
4535

4636

4737
if __name__ == '__main__':

0 commit comments

Comments
 (0)