diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8bbec950..6ba1b78b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,7 @@ The format is based on `Keep a Changelog ` Unreleased ---------- +- Changed how `assert_lines` checks the limits of lines (@nkorinek, #243) - Changed changelog to an rst file. (@nkorinek, #266) - Add a vignette for testing vector data plots. (@nkorinek, #208) - Add ``pillow`` as a dev requirement (@lwasser, #253) @@ -27,6 +28,9 @@ Unreleased (@nkorinek, #121) - Changed tolerance functionality from relative tolerance to absolute tolerance. (@ryla5068, #234) +- Made checking line coverage optional for `base.assert_line()` + (@ryla5068, #239) +- Fixed bugs involving line tests (@ryla5068, #239) - Improved handling of datasets with different shapes in base.assert_xy() (@ryla5068, #233) - Bug fix for handling object datatypes in base.assert_xy() (@ryla5068, #232) diff --git a/dev-requirements.txt b/dev-requirements.txt index 6ef8f581..36d65867 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -10,4 +10,5 @@ setuptools==46.1.3 pre-commit==1.20.0 pip==19.0.3 descartes==1.1.0 +seaborn>=0.9.1 pillow==7.1.2 diff --git a/matplotcheck/base.py b/matplotcheck/base.py index a7c9213e..37e91e98 100644 --- a/matplotcheck/base.py +++ b/matplotcheck/base.py @@ -47,11 +47,11 @@ def _is_line(self): """ if self.ax.lines: - for l in self.ax.lines: + for line in self.ax.lines: if ( - not l.get_linestyle() - or not l.get_linewidth() - or l.get_linewidth() > 0 + not line.get_linestyle() + or not line.get_linewidth() + or line.get_linewidth() > 0 ): return True @@ -68,11 +68,11 @@ def _is_scatter(self): if self.ax.collections: return True elif self.ax.lines: - for l in self.ax.lines: + for line in self.ax.lines: if ( - l.get_linestyle() == "None" - or l.get_linewidth() == "None" - or l.get_linewidth() == 0 + line.get_linestyle() == "None" + or line.get_linewidth() == "None" + or line.get_linewidth() == 0 ): return True return False @@ -482,9 +482,9 @@ def assert_lims( """ # Get axis limit values if axis == "x": - lims = [int(l) for l in self.ax.get_xlim()] + lims = [int(xlim) for xlim in self.ax.get_xlim()] elif axis == "y": - lims = [int(l) for l in self.ax.get_ylim()] + lims = [int(ylim) for ylim in self.ax.get_ylim()] else: raise ValueError( "axis must be one of the following string ['x', 'y']" @@ -673,7 +673,7 @@ def assert_legend_labels( legend_texts = [ t.get_text().lower() for leg in legends for t in leg.get_texts() ] - labels_exp = [l.lower() for l in labels_exp] + labels_exp = [label.lower() for label in labels_exp] num_exp_labs = len(labels_exp) num_actual_labs = len(legend_texts) @@ -786,9 +786,12 @@ def get_xy(self, points_only=False): if points_only: xy_coords = [ val - for l in self.ax.lines - if (l.get_linestyle() == "None" or l.get_linewidth() == "None") - for val in l.get_xydata() + for line in self.ax.lines + if ( + line.get_linestyle() == "None" + or line.get_linewidth() == "None" + ) + for val in line.get_xydata() ] # .plot() xy_coords += [ val @@ -799,7 +802,7 @@ def get_xy(self, points_only=False): else: xy_coords = [ - val for l in self.ax.lines for val in l.get_xydata() + val for line in self.ax.lines for val in line.get_xydata() ] # .plot() xy_coords += [ val for c in self.ax.collections for val in c.get_offsets() @@ -983,8 +986,8 @@ def assert_xlabel_ydata( This is only testing the numbers in x-axis labels. """ x_data = [ - "".join(c for c in l.get_text()) - for l in self.ax.xaxis.get_majorticklabels() + "".join(c for c in label.get_text()) + for label in self.ax.xaxis.get_majorticklabels() ] y_data = self.get_xy()["y"] xy_data = pd.DataFrame(data={"x": x_data, "y": y_data}) @@ -1068,13 +1071,12 @@ def assert_line( self, slope_exp, intercept_exp, - xtime=False, + check_coverage=True, message_no_line="Expected line not displayed", message_data="Line does not cover data set", ): """Asserts that there exists a line on Axes `ax` with slope `slope_exp` - and y-intercept `intercept_exp` and goes at least from x coordinate - `min_val` to x coordinate `max_val` + and y-intercept `intercept_exp` and Parameters ---------- @@ -1082,48 +1084,76 @@ def assert_line( Expected slope of line intercept_exp : float Expeted y intercept of line - xtime : boolean - Set ``True`` if x-axis values are datetime + check_coverage : boolean (default = True) + If `check_coverage` is `True`, function will check that the goes at + least from x coordinate `min_val` to x coordinate `max_val`. If the + line does not cover the entire dataset, and `AssertionError` with + be thrown with message `message_data`. message_no_line : string The error message to be displayed if the line does not exist. message_data : string The error message to be displayed if the line exists but does not - cover the dataset. + cover the dataset, and if `check_coverage` is `True`. Raises ------- AssertionError - with message `m` or `m2` if no line exists that covers the dataset + with message `message_no_line` or `message_data` if no line exists + that covers the dataset. """ - flag_exist, flag_length = False, False - xy = self.get_xy(points_only=True) - min_val, max_val = min(xy["x"]), max(xy["x"]) + flag_exist = False + + if check_coverage: + flag_length = False + xy = self.get_xy(points_only=True) + min_val, max_val = min(xy["x"]), max(xy["x"]) + + for line in self.ax.lines: + # Here we will get the verticies for the line and reformat them in + + # the way that get_slope_yintercept() expects + data = line.get_data() + path_verts = np.column_stack((data[0], data[1])) - for l in self.ax.lines: - path_verts = self.ax.transData.inverted().transform( - l._transformed_path.get_fully_transformed_path().vertices - ) slope, y_intercept = self.get_slope_yintercept(path_verts) if math.isclose(slope, slope_exp, abs_tol=1e-4) and math.isclose( y_intercept, intercept_exp, abs_tol=1e-4 ): flag_exist = True line_x_vals = [coord[0] for coord in path_verts] - if min(line_x_vals) <= min_val and max(line_x_vals) >= max_val: - flag_length = True - break + + # This check ensures that the minimum and maximum values of the + # line are within or very close to the minimum and maximum + # values in the pandas dataframe provided. This accounts for + # small errors sometimes found in matplotlib plots. + if check_coverage: + if ( + math.isclose(min(line_x_vals), min_val, abs_tol=1e-4) + or min(line_x_vals) <= min_val + ) and ( + math.isclose(max(line_x_vals), max_val, abs_tol=1e-4) + or max(line_x_vals) >= max_val + ): + flag_length = True + break assert flag_exist, message_no_line - assert flag_length, message_data + if check_coverage: + assert flag_length, message_data - def assert_lines_of_type(self, line_types): + def assert_lines_of_type(self, line_types, check_coverage=True): """Asserts each line of type in `line_types` exist on `ax` Parameters ---------- - line_types : list of strings + line_types : string or list of strings Acceptable strings in line_types are as follows - ``['regression', 'onetoone']``. + ``['linear-regression', 'onetoone']``. + check_coverage : boolean (default = True) + If `check_coverage` is `True`, function will check that the goes at + least from x coordinate `min_val` to x coordinate `max_val`. If the + line does not cover the entire dataset, and `AssertionError` with + be thrown with message `message_data`. Raises ------- @@ -1134,31 +1164,40 @@ def assert_lines_of_type(self, line_types): ----- If `line_types` is empty, assertion is passed. """ - if line_types: - for line_type in line_types: - if line_type == "regression": - xy = self.get_xy(points_only=True) - slope_exp, intercept_exp, _, _, _ = stats.linregress( - xy.x, xy.y + if isinstance(line_types, str): + line_types = [line_types] + + for line_type in line_types: + if line_type == "linear-regression": + xy = self.get_xy(points_only=True) + # Check that there is xy data for this line. Some one-to-one + # lines do not produce xy data. + if xy.empty: + raise AssertionError( + "linear-regression line not displayed properly" ) - elif line_type == "onetoone": - slope_exp, intercept_exp = 1, 0 - else: - raise ValueError( - "each string in line_types must be from the following " - + '["regression","onetoone"]' - ) - - self.assert_line( - slope_exp, - intercept_exp, - message_no_line="{0} line not displayed properly".format( - line_type - ), - message_data="{0} line does not cover dataset".format( - line_type - ), + slope_exp, intercept_exp, _, _, _ = stats.linregress( + xy.x, xy.y ) + elif line_type == "onetoone": + slope_exp, intercept_exp = 1, 0 + else: + raise ValueError( + "each string in line_types must be from the following " + + '["linear-regression","onetoone"]' + ) + + self.assert_line( + slope_exp, + intercept_exp, + message_no_line="{0} line not displayed properly".format( + line_type + ), + message_data="{0} line does not cover dataset".format( + line_type + ), + check_coverage=check_coverage, + ) # HISTOGRAM FUNCTIONS diff --git a/matplotcheck/tests/test_base_lines.py b/matplotcheck/tests/test_base_lines.py new file mode 100644 index 00000000..e2280ec9 --- /dev/null +++ b/matplotcheck/tests/test_base_lines.py @@ -0,0 +1,191 @@ +import pytest +from matplotcheck.base import PlotTester +import matplotlib.pyplot as plt +import pandas as pd +import seaborn as sns +from scipy import stats + +"""Fixtures""" + + +@pytest.fixture +def pd_df_reg_data(): + """Create a pandas dataframe with points that are roughly along the same + line.""" + data = { + "A": [1.2, 1.9, 3.0, 4.1, 4.6, 6.0, 6.9, 8.4, 9.0], + "B": [2.4, 3.9, 6.1, 7.8, 9.0, 11.5, 15.0, 16.2, 18.6], + } + + return pd.DataFrame(data) + + +@pytest.fixture +def pd_df_reg_one2one_data(): + """Create a pandas dataframe with points that are along a one to one + line.""" + data = { + "A": [0.0, 2.0], + "B": [0.0, 2.0], + } + + return pd.DataFrame(data) + + +@pytest.fixture +def pt_reg_data(pd_df_reg_data): + """Create a PlotTester object with a regression line""" + fig, ax = plt.subplots() + sns.regplot("A", "B", data=pd_df_reg_data, ax=ax) + + return PlotTester(ax) + + +@pytest.fixture +def pt_multiple_reg(pd_df_reg_data): + """Create a PlotTester object with multiple regression line""" + fig, ax = plt.subplots() + sns.regplot("A", "B", data=pd_df_reg_data[:5], ax=ax) + sns.regplot("A", "B", data=pd_df_reg_data[5:], ax=ax) + + return PlotTester(ax) + + +@pytest.fixture +def pt_one2one(): + """Create a PlotTester object a one-to-one line""" + fig, ax = plt.subplots() + ax.plot((0, 1), (0, 1), transform=ax.transAxes, ls="--", c="k") + + return PlotTester(ax) + + +@pytest.fixture +def pt_reg_one2one(pd_df_reg_data): + """Create a PlotTester object with a regression line and a one-to-one + line""" + fig, ax = plt.subplots() + sns.regplot("A", "B", data=pd_df_reg_data, ax=ax) + ax.plot((0, 1), (0, 1), transform=ax.transAxes, ls="--", c="k") + + return PlotTester(ax) + + +@pytest.fixture +def pt_one2one_reg_close(pd_df_reg_one2one_data): + """Create a PlotTester object with a regression line that doesn't cover + all the points in a plot.""" + fig, ax = plt.subplots() + sns.regplot("A", "B", data=pd_df_reg_one2one_data, ax=ax, fit_reg=False) + ax.plot( + (0.0001, 1.9999), + (0.0001, 1.9999), + transform=ax.transAxes, + ls="--", + c="k", + ) + return PlotTester(ax) + + +@pytest.fixture +def pt_one2one_reg(pd_df_reg_one2one_data): + """Create a PlotTester object with a regression line that is plotted so the + points are not covered by the regression line.""" + fig, ax = plt.subplots() + sns.regplot("A", "B", data=pd_df_reg_one2one_data, ax=ax, fit_reg=False) + ax.plot((0, 1), (0, 1), transform=ax.transAxes, ls="--", c="k") + return PlotTester(ax) + + +def test_reg_plot(pd_df_reg_data, pt_reg_data): + """Test that assert_line() correctly passes when given the correct slope + and intercept.""" + # Get the correct slope and intercept for the data + slope_exp, intercept_exp, _, _, _ = stats.linregress( + pd_df_reg_data.A, pd_df_reg_data.B + ) + + pt_reg_data.assert_line(slope_exp, intercept_exp) + + +def test_reg_plot_slope_fails(pd_df_reg_data, pt_reg_data): + """Check that assert_line() correctly falis when given an incorrect + slope.""" + _, intercept_exp, _, _, _ = stats.linregress( + pd_df_reg_data.A, pd_df_reg_data.B + ) + with pytest.raises(AssertionError, match="Expected line not displayed"): + pt_reg_data.assert_line(1, intercept_exp) + + +def test_reg_plot_intercept_fails(pd_df_reg_data, pt_reg_data): + """Check that assert_line() correctly fails when given an incorrect + intercept""" + slope_exp, _, _, _, _ = stats.linregress( + pd_df_reg_data.A, pd_df_reg_data.B + ) + + with pytest.raises(AssertionError, match="Expected line not displayed"): + pt_reg_data.assert_line(slope_exp, 1) + + +def test_line_type_reg(pt_reg_data): + """Check that assert_lines_of_type() correctly passes when checking for a + linear-regression line.""" + pt_reg_data.assert_lines_of_type("linear-regression") + + +def test_line_type_one2one(pt_one2one): + """Check that assert_lines_of_type() correctly passes when checking for a + one-to-one line.""" + pt_one2one.assert_lines_of_type("onetoone", check_coverage=False) + + +def test_line_type_reg_one2one(pt_reg_one2one): + """Check that assert_lines_of_type() correctly passes when checking for + both a linear-regression line and a one-to-one line.""" + pt_reg_one2one.assert_lines_of_type( + ["linear-regression", "onetoone"], check_coverage=False + ) + + +def test_line_type_reg_fails(pt_one2one): + """Check that assert_lines_of_type() correctly fails when checking for a + linear-regression line, but one does not exist.""" + with pytest.raises( + AssertionError, match="linear-regression line not displayed properly" + ): + pt_one2one.assert_lines_of_type("linear-regression") + + +def test_line_type_one2one_fails(pt_reg_data): + """Check that assert_lines_of_type() correctly fails when checking for a + one-to-one line, but one does not exist.""" + with pytest.raises( + AssertionError, match="onetoone line not displayed properly" + ): + pt_reg_data.assert_lines_of_type("onetoone") + + +def test_multi_reg_plot_line_fails(pt_multiple_reg): + """Check that multiple regression lines fails when the not all points are + used to make the regression lines.""" + with pytest.raises( + AssertionError, match="linear-regression line not displayed properly" + ): + pt_multiple_reg.assert_lines_of_type("linear-regression") + + +def test_reg_one2one_fails(pt_one2one_reg): + """Testing that a regression line that doesn't cover all the points in a + plot fails.""" + with pytest.raises( + AssertionError, match="linear-regression line does not cover dataset" + ): + pt_one2one_reg.assert_lines_of_type("linear-regression") + + +def test_reg_one2one_passes_close_lims(pt_one2one_reg_close): + """Testing that a regression line that is slightly out of coverage still + passes.""" + pt_one2one_reg_close.assert_lines_of_type("linear-regression") diff --git a/matplotcheck/tests/test_lines.py b/matplotcheck/tests/test_lines.py index 14d68c12..cd53d1e7 100644 --- a/matplotcheck/tests/test_lines.py +++ b/matplotcheck/tests/test_lines.py @@ -166,6 +166,6 @@ def test_get_lines_by_collection(multiline_geo_plot): [(2, 1), (3, 1), (4, 1), (5, 2)], ] ] - sorted_lines_list = sorted([sorted(l) for l in lines_list]) + sorted_lines_list = sorted([sorted(line) for line in lines_list]) assert sorted_lines_list == multiline_geo_plot.get_lines_by_collection() plt.close("all") diff --git a/matplotcheck/timeseries.py b/matplotcheck/timeseries.py index 80a0071a..7315d219 100644 --- a/matplotcheck/timeseries.py +++ b/matplotcheck/timeseries.py @@ -104,7 +104,7 @@ def assert_xticks_locs( """ if loc_exp: - xlims = [mdates.num2date(l) for l in self.ax.get_xlim()] + xlims = [mdates.num2date(limit) for limit in self.ax.get_xlim()] if tick_size == "large": ticks = self.ax.xaxis.get_majorticklocs() elif tick_size == "small": diff --git a/matplotcheck/vector.py b/matplotcheck/vector.py index ab77c8da..ca8b777b 100644 --- a/matplotcheck/vector.py +++ b/matplotcheck/vector.py @@ -340,7 +340,7 @@ def get_lines_by_collection(self): for c in self.ax.collections if type(c) == matplotlib.collections.LineCollection ] - return sorted([sorted(l) for l in lines_grouped]) + return sorted([sorted(lines) for lines in lines_grouped]) def get_lines_by_attributes(self): """Returns a sorted list of lists where each list contains line @@ -390,7 +390,7 @@ def get_lines_by_attributes(self): ["color", "lwidth", "lstyle"], sort=False ) ] - return sorted([sorted(l) for l in lines_grouped]) + return sorted([sorted(lines) for lines in lines_grouped]) def assert_lines(self, lines_expected, m="Incorrect Line Data"): """Asserts the line data in Axes ax is equal to lines_expected with @@ -452,7 +452,7 @@ def assert_lines_grouped_by_type( for c in ax_exp.collections if type(c) == matplotlib.collections.LineCollection ] - grouped_exp = sorted([sorted(l) for l in grouped_exp]) + grouped_exp = sorted([sorted(lines) for lines in grouped_exp]) plt.close(fig) np.testing.assert_equal(groups, grouped_exp, m) elif lines_expected is None: