Skip to content

Commit bf13f43

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 8e8d9c8 commit bf13f43

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).\
@@ -960,30 +974,13 @@ def v4_tableau():
960974

961975
@v4_route("/graph")
962976
def v4_graph():
963-
964977
session = request.session
965978
ts = request.get_testsuite()
966-
switch_min_mean_local = False
967979

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

11971194
is_multisample = (len(values) > 1)
11981195

1199-
aggregation_fn = min
1200-
if switch_min_mean_local:
1201-
aggregation_fn = lnt.util.stats.agg_mean
1202-
if field.bigger_is_better:
1203-
aggregation_fn = max
1196+
fn_name = options.get('aggregation_function') or ('max' if field.bigger_is_better else 'min')
1197+
aggregation_fn = determine_aggregation_function(fn_name)
1198+
agg_value = aggregation_fn(values)
1199+
1200+
# When aggregating multiple samples, it becomes unclear which sample to use for
1201+
# associated data like the run date, the order, etc. Use the index of the closest
1202+
# value in all the samples.
1203+
closest_value = sorted(values, key=lambda val: abs(val - agg_value))[0]
1204+
agg_index = values.index(closest_value)
12041205

1205-
agg_value, agg_index = \
1206-
aggregation_fn((value, index)
1207-
for (index, value) in enumerate(values))
12081206
pts_y.append(agg_value)
12091207

12101208
# 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)