diff --git a/pygal/graph/box.py b/pygal/graph/box.py index 2f94c96..ca9f672 100644 --- a/pygal/graph/box.py +++ b/pygal/graph/box.py @@ -23,6 +23,7 @@ Box plot from __future__ import division from pygal.graph.graph import Graph from pygal.util import compute_scale, decorate +from math import floor class Box(Graph): @@ -123,23 +124,44 @@ class Box(Graph): @staticmethod def _box_points(values): """ - Return a 5-tuple of Q1 - 1.5 * IQR, Q1, Median, Q3, and Q3 + 1.5 * IQR for a list of numeric values + Return a 5-tuple of Q1 - 1.5 * IQR, Q1, Median, Q3, and Q3 + 1.5 * IQR for a list of numeric values. + + The iterator values may include None values. Uses quartile definition from Mendenhall, W. and Sincich, T. L. Statistics for Engineering and the Sciences, 4th ed. Prentice-Hall, 1995. """ - n = len(values) + def median(seq): + n = len(seq) + if n % 2 == 0: # seq has an even length + return (seq[n // 2] + s[n // 2 - 1]) / 2 + else: # seq has an odd length + return seq[n // 2] + + # sort the copy in case the originals must stay in original order + s = sorted([x for x in values if x is not None]) + n = len(s) if not n: return 0, 0, 0, 0, 0 else: - s = sorted(values) # sort the copy in case the originals must stay in original order - if n % 2 == 0: # n is even - q2 = (values[n // 2] + values[n // 2 + 1]) / 2 - else: - q2 = values[(n + 1) // 2] - - q1 = values[int(round((n + 1) / 4))] - q3 = values[int(round((3 * n + 3) / 4))] + q2 = median(s) + # See 'Method 3' in http://en.wikipedia.org/wiki/Quartile + if n % 2 == 0: # even + q1 = median(s[:n // 2]) + q3 = median(s[n // 2:]) + else: # odd + if n == 1: # special case + q1 = s[0] + q3 = s[0] + elif n % 4 == 1: # n is of form 4n + 1 where n >= 1 + m = (n - 1) // 4 + q1 = 0.25 * s[m-1] + 0.75 * s[m] + q3 = 0.75 * s[3*m] + 0.25 * s[3*m + 1] + else: # n is of form 4n + 3 where n >= 1 + m = (n - 3) // 4 + q1 = 0.75 * s[m] + 0.25 * s[m+1] + q3 = 0.25 * s[3*m+1] + 0.75 * s[3*m+2] + iqr = q3 - q1 q0 = q1 - 1.5 * iqr q4 = q3 + 1.5 * iqr diff --git a/pygal/test/test_box.py b/pygal/test/test_box.py new file mode 100644 index 0000000..d664827 --- /dev/null +++ b/pygal/test/test_box.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2013 Kozea +# +# This library is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This library is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with pygal. If not, see . +from pygal.graph.box import Box +from pygal import Box as ghostedBox + + +def test_quartiles(): + a = [-2.0, 3.0, 4.0, 5.0, 8.0] # odd test data + q0, q1, q2, q3, q4 = Box._box_points(a) + + assert q1 == 7.0 / 4.0 + assert q2 == 4.0 + assert q3 == 23 / 4.0 + assert q0 == 7.0 / 4.0 - 6.0 # q1 - 1.5 * iqr + assert q4 == 23 / 4.0 + 6.0 # q3 + 1.5 * iqr + + b = [1.0, 4.0, 6.0, 8.0] # even test data + q0, q1, q2, q3, q4 = Box._box_points(b) + + assert q2 == 5.0 + + c = [2.0, None, 4.0, 6.0, None] # odd with None elements + q0, q1, q2, q3, q4 = Box._box_points(c) + + assert q2 == 4.0 + + d = [4] + q0, q1, q2, q3, q4 = Box._box_points(d) + + assert q0 == 4 + assert q1 == 4 + assert q2 == 4 + assert q3 == 4 + assert q4 == 4 + + +def test_simple_box(): + box = ghostedBox() + box.add('test1', [-1, 2, 3, 3.1, 3.2, 4, 5]) + box.add('test2', [2, 3, 5, 6, 6, 4]) + box.title = 'Box test' + q = box.render_pyquery() + + assert len(q(".axis.y")) == 1 + assert len(q(".legend")) == 2 + assert len(q(".plot .series rect")) == 2 diff --git a/pygal/test/test_config.py b/pygal/test/test_config.py index d828cb8..ee992c6 100644 --- a/pygal/test/test_config.py +++ b/pygal/test/test_config.py @@ -18,7 +18,7 @@ # along with pygal. If not, see . from pygal import ( Line, Dot, Pie, Radar, Config, Bar, Funnel, Worldmap, - SupranationalWorldmap, Histogram, Gauge) + SupranationalWorldmap, Histogram, Gauge, Box) from pygal._compat import u from pygal.test.utils import texts from pygal.test import pytest_generate_tests, make_data @@ -270,7 +270,7 @@ def test_no_data(): def test_include_x_axis(Chart): chart = Chart() if Chart in (Pie, Radar, Funnel, Dot, Gauge, Worldmap, - SupranationalWorldmap, Histogram): + SupranationalWorldmap, Histogram, Box): return if not chart.cls._dual: data = 100, 200, 150 diff --git a/pygal/test/test_graph.py b/pygal/test/test_graph.py index fd711c0..eab95a9 100644 --- a/pygal/test/test_graph.py +++ b/pygal/test/test_graph.py @@ -68,7 +68,9 @@ def test_render_to_png(Chart, datas): def test_metadata(Chart): chart = Chart() v = range(7) - if Chart == pygal.XY: + if Chart in (pygal.Box,): + return # summary charts cannot display per-value metadata + elif Chart == pygal.XY: v = list(map(lambda x: (x, x + 1), v)) elif Chart == pygal.Worldmap or Chart == pygal.SupranationalWorldmap: v = list(map(lambda x: x, i18n.COUNTRIES))