From beb0e4cc6ed1540aa856619c88b90e9103d9b7c7 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Wed, 4 Dec 2024 13:23:32 +0000 Subject: [PATCH 1/8] Move Python export tests from glue-qt to glue-core --- .../histogram/tests/test_python_export.py | 61 ++++ .../viewers/image/tests/test_python_export.py | 124 ++++++++ .../matplotlib/tests/test_python_export.py | 62 ++++ .../profile/tests/test_python_export.py | 87 +++++ .../scatter/tests/test_python_export.py | 299 ++++++++++++++++++ 5 files changed, 633 insertions(+) create mode 100644 glue/viewers/histogram/tests/test_python_export.py create mode 100644 glue/viewers/image/tests/test_python_export.py create mode 100644 glue/viewers/matplotlib/tests/test_python_export.py create mode 100644 glue/viewers/profile/tests/test_python_export.py create mode 100644 glue/viewers/scatter/tests/test_python_export.py diff --git a/glue/viewers/histogram/tests/test_python_export.py b/glue/viewers/histogram/tests/test_python_export.py new file mode 100644 index 000000000..07cb695ae --- /dev/null +++ b/glue/viewers/histogram/tests/test_python_export.py @@ -0,0 +1,61 @@ +from astropy.utils import NumpyRNGContext + +from glue.core import Data, DataCollection +from glue.core.application_base import Application +from glue.viewers.histogram.viewer import SimpleHistogramViewer +from glue.viewers.matplotlib.tests.test_python_export import BaseTestExportPython, random_with_nan + + +class TestExportPython(BaseTestExportPython): + + def setup_method(self, method): + + with NumpyRNGContext(12345): + self.data = Data(**dict((name, random_with_nan(100, nan_index=idx + 1)) for idx, name in enumerate('abcdefgh'))) + self.data_collection = DataCollection([self.data]) + self.app = Application(self.data_collection) + self.viewer = self.app.new_data_viewer(SimpleHistogramViewer) + self.viewer.add_data(self.data) + self.viewer.state.x_att = self.data.id['a'] + + def teardown_method(self, method): + self.viewer = None + self.app = None + + def test_simple(self, tmpdir): + self.assert_same(tmpdir) + + def test_simple_visual(self, tmpdir): + self.viewer.state.layers[0].color = 'blue' + self.viewer.state.layers[0].alpha = 0.5 + self.assert_same(tmpdir) + + def test_simple_visual_legend(self, tmpdir): + self.viewer.state.legend.visible = True + self.viewer.state.layers[0].color = 'blue' + self.viewer.state.layers[0].alpha = 0.5 + self.assert_same(tmpdir) + + def test_cumulative(self, tmpdir): + self.viewer.state.cumulative = True + self.assert_same(tmpdir) + + def test_normalize(self, tmpdir): + self.viewer.state.normalize = True + self.assert_same(tmpdir) + + def test_subset(self, tmpdir): + self.data_collection.new_subset_group('mysubset', self.data.id['a'] > 0.5) + self.assert_same(tmpdir) + + def test_subset_legend(self, tmpdir): + self.viewer.state.legend.visible = True + self.data_collection.new_subset_group('mysubset', self.data.id['a'] > 0.5) + self.assert_same(tmpdir) + + def test_empty(self, tmpdir): + self.viewer.state.x_min = 10 + self.viewer.state.x_max = 11 + self.viewer.state.hist_x_min = 10 + self.viewer.state.hist_x_max = 11 + self.assert_same(tmpdir) diff --git a/glue/viewers/image/tests/test_python_export.py b/glue/viewers/image/tests/test_python_export.py new file mode 100644 index 000000000..e67e648be --- /dev/null +++ b/glue/viewers/image/tests/test_python_export.py @@ -0,0 +1,124 @@ +import pytest +import numpy as np +import matplotlib.pyplot as plt +from astropy.utils import NumpyRNGContext +from astropy.wcs import WCS + +from glue.core import Data, DataCollection +from glue.core.coordinates import AffineCoordinates +from glue.core.application_base import Application +from glue.viewers.image.viewer import SimpleImageViewer +from glue.viewers.matplotlib.tests.test_python_export import BaseTestExportPython + + +class TestExportPython(BaseTestExportPython): + + def setup_method(self, method): + + with NumpyRNGContext(12345): + self.data = Data(cube=np.random.random((30, 50, 20))) + # Create data versions with WCS and affine coordinates + matrix = np.array([[2, 3, 4, -1], [1, 2, 2, 2], [1, 1, 1, -2], [0, 0, 0, 1]]) + affine = AffineCoordinates(matrix, units=['Mm', 'Mm', 'km'], labels=['xw', 'yw', 'zw']) + + self.data_wcs = Data(label='cube', cube=self.data['cube'], coords=WCS(naxis=3)) + self.data_affine = Data(label='cube', cube=self.data['cube'], coords=affine) + self.data_collection = DataCollection([self.data, self.data_wcs, self.data_affine]) + self.app = Application(self.data_collection) + self.viewer = self.app.new_data_viewer(SimpleImageViewer) + self.viewer.add_data(self.data) + # FIXME: On some platforms, using an integer label size + # causes some of the labels to be non-deterministically + # shifted by one pixel, so we pick a non-round font size + # to avoid this. + self.viewer.state.x_ticklabel_size = 8.21334111 + self.viewer.state.y_ticklabel_size = 8.21334111 + + def teardown_method(self, method): + self.viewer = None + self.app = None + + def assert_same(self, tmpdir, tol=0.1): + BaseTestExportPython.assert_same(self, tmpdir, tol=tol) + + def viewer_load(self, coords): + if coords is not None: + self.viewer.add_data(getattr(self, f'data_{coords}')) + self.viewer.remove_data(self.data) + + @pytest.mark.parametrize('coords', [None, 'wcs', 'affine']) + def test_simple(self, tmpdir, coords): + self.viewer_load(coords) + self.assert_same(tmpdir) + + @pytest.mark.parametrize('coords', [None, 'wcs', 'affine']) + def test_simple_legend(self, tmpdir, coords): + self.viewer_load(coords) + self.viewer.state.show_legend = True + self.assert_same(tmpdir) + + @pytest.mark.parametrize('coords', [None, 'wcs', 'affine']) + def test_simple_att(self, tmpdir, coords): + self.viewer_load(coords) + self.viewer.state.x_att = self.viewer.state.reference_data.pixel_component_ids[1] + self.viewer.state.y_att = self.viewer.state.reference_data.pixel_component_ids[0] + if coords == 'affine': + pytest.xfail('Known issue with axis label rendering') + self.assert_same(tmpdir) + + @pytest.mark.parametrize('coords', [None, 'wcs', 'affine']) + def test_simple_visual(self, tmpdir, coords): + self.viewer_load(coords) + self.viewer.state.legend.visible = True + self.viewer.state.layers[0].cmap = plt.cm.RdBu + self.viewer.state.layers[0].v_min = 0.2 + self.viewer.state.layers[0].v_max = 0.8 + self.viewer.state.layers[0].stretch = 'sqrt' + self.viewer.state.layers[0].stretch = 'sqrt' + self.viewer.state.layers[0].contrast = 0.9 + self.viewer.state.layers[0].bias = 0.6 + self.assert_same(tmpdir) + + @pytest.mark.parametrize('coords', [None, 'wcs', 'affine']) + def test_slice(self, tmpdir, coords): + self.viewer_load(coords) + self.viewer.state.x_att = self.viewer.state.reference_data.pixel_component_ids[1] + self.viewer.state.y_att = self.viewer.state.reference_data.pixel_component_ids[0] + self.viewer.state.slices = (2, 3, 4) + if coords == 'affine': + pytest.xfail('Known issue with axis label rendering') + self.assert_same(tmpdir) + + @pytest.mark.parametrize('coords', [None, 'wcs', 'affine']) + def test_aspect(self, tmpdir, coords): + self.viewer_load(coords) + self.viewer.state.aspect = 'auto' + self.assert_same(tmpdir) + + @pytest.mark.parametrize('coords', [None, 'wcs', 'affine']) + def test_subset(self, tmpdir, coords): + self.viewer_load(coords) + self.data_collection.new_subset_group('mysubset', self.data.id['cube'] > 0.5) + self.assert_same(tmpdir) + + @pytest.mark.parametrize('coords', [None, 'wcs', 'affine']) + def test_subset_legend(self, tmpdir, coords): + self.viewer_load(coords) + self.data_collection.new_subset_group('mysubset', + self.viewer.state.reference_data.id['cube'] > 0.5) + self.viewer.state.legend.visible = True + self.assert_same(tmpdir, tol=0.15) # transparency and such + + @pytest.mark.parametrize('coords', [None, 'wcs', 'affine']) + def test_subset_slice(self, tmpdir, coords): + self.viewer_load(coords) + self.data_collection.new_subset_group('mysubset', self.data.id['cube'] > 0.5) + self.test_slice(tmpdir, coords) + + @pytest.mark.parametrize('coords', [None, 'wcs', 'affine']) + def test_subset_transposed(self, tmpdir, coords): + self.viewer_load(coords) + self.data_collection.new_subset_group('mysubset', self.data.id['cube'] > 0.5) + self.viewer.state.x_att = self.data.pixel_component_ids[0] + self.viewer.state.y_att = self.data.pixel_component_ids[1] + self.assert_same(tmpdir) diff --git a/glue/viewers/matplotlib/tests/test_python_export.py b/glue/viewers/matplotlib/tests/test_python_export.py new file mode 100644 index 000000000..24213c8f2 --- /dev/null +++ b/glue/viewers/matplotlib/tests/test_python_export.py @@ -0,0 +1,62 @@ +import os +import sys +import pytest +import subprocess + +from glue.config import settings + +import numpy as np + +from matplotlib.testing.compare import compare_images + +__all__ = ['random_with_nan', 'BaseTestExportPython'] + + +def random_with_nan(nsamples, nan_index): + x = np.random.random(nsamples) + x[nan_index] = np.nan + return x + + +class BaseTestExportPython: + + def assert_same(self, tmpdir, tol=0.1): + + os.chdir(tmpdir.strpath) + + expected = tmpdir.join('expected.png').strpath + script = tmpdir.join('actual.py').strpath + actual = tmpdir.join('glue_plot.png').strpath + + self.viewer.axes.figure.savefig(expected) + + self.viewer.export_as_script(script) + subprocess.call([sys.executable, script]) + + msg = compare_images(expected, actual, tol=tol) + + if msg: + + from base64 import b64encode + + print("SCRIPT:") + with open(script, 'r') as f: + print(f.read()) + + print("EXPECTED:") + with open(expected, 'rb') as f: + print(b64encode(f.read()).decode()) + + print("ACTUAL:") + with open(actual, 'rb') as f: + print(b64encode(f.read()).decode()) + + pytest.fail(msg, pytrace=False) + + def test_color_settings(self, tmpdir): + settings.FOREGROUND_COLOR = '#a51d2d' + settings.BACKGROUND_COLOR = '#99c1f1' + self.viewer._update_appearance_from_settings() + self.assert_same(tmpdir) + settings.reset_defaults() + self.viewer._update_appearance_from_settings() diff --git a/glue/viewers/profile/tests/test_python_export.py b/glue/viewers/profile/tests/test_python_export.py new file mode 100644 index 000000000..589a41288 --- /dev/null +++ b/glue/viewers/profile/tests/test_python_export.py @@ -0,0 +1,87 @@ +from astropy.utils import NumpyRNGContext + +from glue.core import Data, DataCollection +from glue.core.application_base import Application +from glue.viewers.profile.viewer import SimpleProfileViewer +from glue.viewers.matplotlib.tests.test_python_export import BaseTestExportPython, random_with_nan +from glue.viewers.profile.tests.test_state import SimpleCoordinates + + +class TestExportPython(BaseTestExportPython): + + def setup_method(self, method): + + self.data = Data(label='d1') + self.data.coords = SimpleCoordinates() + with NumpyRNGContext(12345): + self.data['x'] = random_with_nan(48, 5).reshape((6, 4, 2)) + self.data['y'] = random_with_nan(48, 12).reshape((6, 4, 2)) + self.data_collection = DataCollection([self.data]) + self.app = Application(self.data_collection) + self.viewer = self.app.new_data_viewer(SimpleProfileViewer) + self.viewer.add_data(self.data) + # Make legend location deterministic + self.viewer.state.legend.location = 'lower left' + + def teardown_method(self, method): + self.viewer = None + self.app = None + + def test_simple(self, tmpdir): + self.assert_same(tmpdir) + + def test_simple_legend(self, tmpdir): + self.viewer.state.legend.visible = True + self.assert_same(tmpdir) + + def test_color(self, tmpdir): + self.viewer.state.layers[0].color = '#ac0567' + self.assert_same(tmpdir) + + def test_linewidth(self, tmpdir): + self.viewer.state.layers[0].linewidth = 7.25 + self.assert_same(tmpdir) + + def test_max(self, tmpdir): + self.viewer.state.function = 'maximum' + self.assert_same(tmpdir) + + def test_min(self, tmpdir): + self.viewer.state.function = 'minimum' + self.assert_same(tmpdir) + + def test_mean(self, tmpdir): + self.viewer.state.function = 'mean' + self.assert_same(tmpdir) + + def test_median(self, tmpdir): + self.viewer.state.function = 'median' + self.assert_same(tmpdir) + + def test_sum(self, tmpdir): + self.viewer.state.function = 'sum' + self.assert_same(tmpdir) + + def test_normalization(self, tmpdir): + self.viewer.state.normalize = True + self.assert_same(tmpdir) + + def test_subset(self, tmpdir): + self.viewer.state.function = 'mean' + self.data_collection.new_subset_group('mysubset', self.data.id['x'] > 0.25) + self.assert_same(tmpdir) + + def test_subset_legend(self, tmpdir): + self.viewer.state.legend.visible = True + self.viewer.state.function = 'mean' + self.viewer.state.layers[0].linewidth = 7.25 + self.data_collection.new_subset_group('mysubset', self.data.id['x'] > 0.25) + self.assert_same(tmpdir) + + def test_xatt(self, tmpdir): + self.viewer.x_att = self.data.pixel_component_ids[1] + self.assert_same(tmpdir) + + def test_profile_att(self, tmpdir): + self.viewer.layers[0].state.attribute = self.data.id['y'] + self.assert_same(tmpdir) diff --git a/glue/viewers/scatter/tests/test_python_export.py b/glue/viewers/scatter/tests/test_python_export.py new file mode 100644 index 000000000..c725a5750 --- /dev/null +++ b/glue/viewers/scatter/tests/test_python_export.py @@ -0,0 +1,299 @@ +from itertools import product + +import numpy as np +import matplotlib.pyplot as plt +from astropy.utils import NumpyRNGContext + +from glue.core import Data, DataCollection +from glue.core.application_base import Application +from glue.viewers.scatter.viewer import SimpleScatterViewer +from glue_qt.viewers.matplotlib.tests.test_python_export import BaseTestExportPython, random_with_nan + + +class TestExportPython(BaseTestExportPython): + + def setup_method(self, method): + + with NumpyRNGContext(12345): + self.data = Data(**dict((name, random_with_nan(100, nan_index=idx + 1)) for idx, name in enumerate('abcdefgh'))) + self.data['angle'] = np.random.uniform(0, 360, 100) + self.data_collection = DataCollection([self.data]) + self.app = Application(self.data_collection) + self.viewer = self.app.new_data_viewer(SimpleScatterViewer) + self.viewer.add_data(self.data) + self.viewer.state.x_att = self.data.id['a'] + self.viewer.state.y_att = self.data.id['b'] + + def teardown_method(self, method): + self.viewer = None + self.app = None + + def test_simple(self, tmpdir): + self.assert_same(tmpdir) + + def test_simple_legend(self, tmpdir): + self.viewer.state.legend.visible = True + self.assert_same(tmpdir) + + def test_simple_nofill(self, tmpdir): + self.viewer.state.layers[0].fill = False + self.viewer.state.layers[0].size_scaling = 10 + self.assert_same(tmpdir) + + def test_simple_visual(self, tmpdir): + self.viewer.state.layers[0].color = 'blue' + self.viewer.state.layers[0].markersize = 30 + self.viewer.state.layers[0].alpha = 0.5 + self.assert_same(tmpdir) + + def test_simple_visual_legend(self, tmpdir): + self.viewer.state.legend.visible = True + self.viewer.state.layers[0].color = 'blue' + self.viewer.state.layers[0].markersize = 30 + self.viewer.state.layers[0].alpha = 0.5 + self.assert_same(tmpdir) + + def test_cmap_mode(self, tmpdir): + self.viewer.state.layers[0].cmap_mode = 'Linear' + self.viewer.state.layers[0].cmap_att = self.data.id['c'] + self.viewer.state.layers[0].cmap = plt.cm.BuGn + self.viewer.state.layers[0].cmap_vmin = 0.2 + self.viewer.state.layers[0].cmap_vmax = 0.7 + self.viewer.state.layers[0].alpha = 0.8 + self.assert_same(tmpdir) + + def test_cmap_mode_nofill(self, tmpdir): + self.viewer.state.layers[0].fill = False + self.test_cmap_mode(tmpdir) + + def test_size_mode(self, tmpdir): + self.viewer.state.layers[0].size_mode = 'Linear' + self.viewer.state.layers[0].size_att = self.data.id['d'] + self.viewer.state.layers[0].size_vmin = 0.1 + self.viewer.state.layers[0].size_vmax = 0.8 + self.viewer.state.layers[0].size_scaling = 0.4 + self.viewer.state.layers[0].alpha = 0.7 + self.assert_same(tmpdir) + + def test_size_mode_legend(self, tmpdir): + self.viewer.state.legend.visible = True + self.viewer.state.layers[0].size_mode = 'Linear' + self.viewer.state.layers[0].size_att = self.data.id['d'] + self.viewer.state.layers[0].size_vmin = 0.1 + self.viewer.state.layers[0].size_vmax = 0.8 + self.viewer.state.layers[0].size_scaling = 0.4 + self.viewer.state.layers[0].alpha = 0.7 + self.assert_same(tmpdir) + + def test_size_mode_nofill(self, tmpdir): + self.viewer.state.layers[0].fill = False + self.test_size_mode(tmpdir) + + def test_line(self, tmpdir): + self.viewer.state.layers[0].line_visible = True + self.viewer.state.layers[0].linewidth = 10 + self.viewer.state.layers[0].linestype = 'dashed' + self.viewer.state.layers[0].color = 'orange' + self.viewer.state.layers[0].alpha = 0.7 + self.viewer.state.layers[0].markersize = 100 + self.assert_same(tmpdir, tol=5) + + def test_line_cmap(self, tmpdir): + self.viewer.state.layers[0].cmap_mode = 'Linear' + self.viewer.state.layers[0].cmap_vmin = 0.2 + self.viewer.state.layers[0].cmap_vmax = 0.7 + self.viewer.state.layers[0].cmap = plt.cm.BuGn + self.test_line(tmpdir) + + def test_errorbarx(self, tmpdir): + self.viewer.state.layers[0].xerr_visible = True + self.viewer.state.layers[0].xerr_att = self.data.id['e'] + self.viewer.state.layers[0].color = 'purple' + self.viewer.state.layers[0].alpha = 0.5 + self.assert_same(tmpdir) + + def test_errorbary(self, tmpdir): + self.viewer.state.layers[0].yerr_visible = True + self.viewer.state.layers[0].yerr_att = self.data.id['f'] + self.viewer.state.layers[0].color = 'purple' + self.viewer.state.layers[0].alpha = 0.5 + self.assert_same(tmpdir) + + def test_errorbarxy(self, tmpdir): + self.viewer.state.layers[0].xerr_visible = True + self.viewer.state.layers[0].xerr_att = self.data.id['e'] + self.viewer.state.layers[0].yerr_visible = True + self.viewer.state.layers[0].yerr_att = self.data.id['f'] + self.viewer.state.layers[0].color = 'purple' + self.viewer.state.layers[0].alpha = 0.5 + self.assert_same(tmpdir) + + def test_errorbarxy_legend(self, tmpdir): + self.viewer.state.legend.visible = True + self.viewer.state.layers[0].xerr_visible = True + self.viewer.state.layers[0].xerr_att = self.data.id['e'] + self.viewer.state.layers[0].yerr_visible = True + self.viewer.state.layers[0].yerr_att = self.data.id['f'] + self.viewer.state.layers[0].color = 'purple' + self.viewer.state.layers[0].alpha = 0.5 + self.assert_same(tmpdir) + + def test_errorbarxy_cmap(self, tmpdir): + self.viewer.state.layers[0].cmap_mode = 'Linear' + self.viewer.state.layers[0].cmap_vmin = 0.2 + self.viewer.state.layers[0].cmap_vmax = 0.7 + self.viewer.state.layers[0].cmap = plt.cm.BuGn + self.test_errorbarxy(tmpdir) + + def _vector_common(self, tmpdir): + self.viewer.state.layers[0].vector_visible = True + self.viewer.state.layers[0].vy_att = self.data.id['g'] + self.viewer.state.layers[0].vector_arrowhead = True + self.viewer.state.layers[0].vector_origin = 'tail' + self.viewer.state.layers[0].vector_scaling = 1.5 + self.viewer.state.layers[0].color = 'teal' + self.viewer.state.layers[0].alpha = 0.9 + self.assert_same(tmpdir, tol=1) + + def test_vector_cartesian(self, tmpdir): + self.viewer.state.layers[0].vector_mode = 'Cartesian' + self.viewer.state.layers[0].vx_att = self.data.id['h'] + self._vector_common(tmpdir) + + def test_vector_polar(self, tmpdir): + self.viewer.state.layers[0].vector_mode = 'Polar' + self.viewer.state.layers[0].vx_att = self.data.id['angle'] + self._vector_common(tmpdir) + + def test_vector_cartesian_cmap(self, tmpdir): + self.viewer.state.layers[0].cmap_mode = 'Linear' + self.viewer.state.layers[0].cmap_vmin = 0.2 + self.viewer.state.layers[0].cmap_vmax = 0.7 + self.viewer.state.layers[0].cmap = plt.cm.BuGn + self.test_vector_cartesian(tmpdir) + + def test_vector_cartesian_xflip(self, tmpdir): + # Regression test for a bug that caused vectors to not be flipped + self.viewer.state.layers[0].vector_mode = 'Cartesian' + self.viewer.state.layers[0].vx_att = self.data.id['h'] + self.viewer.state.flip_x() + self._vector_common(tmpdir) + + def test_subset(self, tmpdir): + self.data_collection.new_subset_group('mysubset', self.data.id['a'] > 0.5) + self.assert_same(tmpdir) + + def test_density_map_with_subset(self, tmpdir): + self.viewer.state.dpi = 2 + self.viewer.state.layers[0].density_map = True + self.data_collection.new_subset_group('mysubset', self.data.id['a'] > 0.5) + self.assert_same(tmpdir) + + def test_density_map_cmap_with_subset(self, tmpdir): + self.viewer.state.dpi = 2 + self.viewer.state.layers[0].density_map = True + self.viewer.state.layers[0].cmap_mode = 'Linear' + self.viewer.state.layers[0].cmap_vmin = 0.2 + self.viewer.state.layers[0].cmap_vmax = 0.7 + self.viewer.state.layers[0].cmap = plt.cm.BuGn + self.data_collection.new_subset_group('mysubset', self.data.id['a'] > 0.5) + self.assert_same(tmpdir) + + def test_density_map_cmap_with_subset_legend(self, tmpdir): + self.viewer.state.legend.visible = True + self.viewer.state.dpi = 2 + self.viewer.state.layers[0].density_map = True + self.viewer.state.layers[0].cmap_mode = 'Linear' + self.viewer.state.layers[0].cmap_vmin = 0.2 + self.viewer.state.layers[0].cmap_vmax = 0.7 + self.viewer.state.layers[0].cmap = plt.cm.BuGn + self.data_collection.new_subset_group('mysubset', self.data.id['a'] > 0.5) + self.assert_same(tmpdir) + + def test_cmap_mode_change(self, tmpdir): + # Regression test for a bug that caused scatter markers to not change + # color when going from Linear to Fixed mode + self.viewer.state.layers[0].size_mode = 'Linear' + self.viewer.state.layers[0].cmap_mode = 'Linear' + self.viewer.state.layers[0].cmap_mode = 'Fixed' + self.assert_same(tmpdir) + + def test_density_map_change(self, tmpdir): + # Regression test for a bug that caused the density map to still + # be visible if using color-coding with the density map then + # switching to markers. + self.viewer.state.layers[0].density_map = True + self.viewer.state.layers[0].cmap_mode = 'Linear' + self.viewer.state.layers[0].cmap = plt.cm.BuGn + self.viewer.state.layers[0].density_map = False + self.assert_same(tmpdir) + + def test_simple_polar_plot_degrees(self, tmpdir): + self.viewer.state.plot_mode = 'polar' + self.viewer.state.angle_unit = 'degrees' + self.viewer.state.x_att = self.data.id['c'] + self.viewer.state.y_att = self.data.id['d'] + self.assert_same(tmpdir) + + def test_simple_polar_plot_radians(self, tmpdir): + self.viewer.state.plot_mode = 'polar' + self.viewer.state.angle_unit = 'radians' + self.viewer.state.x_att = self.data.id['c'] + self.viewer.state.y_att = self.data.id['d'] + self.assert_same(tmpdir) + + def _fullsphere_common_test(self, tmpdir): + # Note that all the full-sphere projections have the same bounds, + # so we can use the same sets of min/max values + x_bounds = self.viewer.state.x_min, self.viewer.state.x_max + y_bounds = self.viewer.state.y_min, self.viewer.state.y_max + for order in product([True, False], repeat=2): + self.viewer.state.x_min, self.viewer.state.x_max = sorted(x_bounds, reverse=order[0]) + self.viewer.state.y_min, self.viewer.state.y_max = sorted(y_bounds, reverse=order[1]) + self.viewer.state.plot_mode = 'aitoff' + self.viewer.state.x_att = self.data.id['c'] + self.viewer.state.y_att = self.data.id['d'] + self.assert_same(tmpdir) + self.viewer.state.plot_mode = 'hammer' + self.viewer.state.x_att = self.data.id['e'] + self.viewer.state.y_att = self.data.id['f'] + self.assert_same(tmpdir) + self.viewer.state.plot_mode = 'lambert' + self.viewer.state.x_att = self.data.id['g'] + self.viewer.state.y_att = self.data.id['h'] + self.assert_same(tmpdir) + self.viewer.state.plot_mode = 'mollweide' + self.viewer.state.x_att = self.data.id['a'] + self.viewer.state.y_att = self.data.id['b'] + self.assert_same(tmpdir) + + def test_full_sphere_degrees(self, tmpdir): + self.viewer.state.angle_unit = 'degrees' + self._fullsphere_common_test(tmpdir) + + def test_full_sphere_radians(self, tmpdir): + self.viewer.state.angle_unit = 'radians' + self._fullsphere_common_test(tmpdir) + + def test_cmap_size_noncartesian(self, tmpdir): + self.viewer.state.layers[0].size_mode = 'Linear' + self.viewer.state.layers[0].cmap_mode = 'Linear' + for proj in ['polar', 'aitoff', 'hammer', 'lambert', 'mollweide']: + self.viewer.state.plot_mode = proj + self.assert_same(tmpdir) + + def test_vectors_noncartesian(self, tmpdir): + for proj in ['polar', 'aitoff', 'hammer', 'lambert', 'mollweide']: + self.viewer.state.plot_mode = proj + self._vector_common(tmpdir) + + def test_errorbarxy_noncartesian(self, tmpdir): + self.viewer.state.layers[0].xerr_visible = True + self.viewer.state.layers[0].xerr_att = self.data.id['e'] + self.viewer.state.layers[0].yerr_visible = True + self.viewer.state.layers[0].yerr_att = self.data.id['f'] + self.viewer.state.layers[0].color = 'purple' + self.viewer.state.layers[0].alpha = 0.5 + for proj in ['polar', 'aitoff', 'hammer', 'lambert', 'mollweide']: + self.viewer.state.plot_mode = proj + self.assert_same(tmpdir) From 99bad70519748432ffd2b5a528df23601e91fd33 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Wed, 4 Dec 2024 13:23:45 +0000 Subject: [PATCH 2/8] Fixes to scatter viewer matplotlib mix-in --- glue/viewers/scatter/viewer.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/glue/viewers/scatter/viewer.py b/glue/viewers/scatter/viewer.py index c986fd706..553b692fd 100644 --- a/glue/viewers/scatter/viewer.py +++ b/glue/viewers/scatter/viewer.py @@ -30,6 +30,12 @@ def setup_callbacks(self): self.state.add_callback('y_axislabel', self._update_polar_ticks) self._update_axes() + def limits_to_mpl(self, *args): + # These projections throw errors if we try to set the limits + if self.state.plot_mode in ['aitoff', 'hammer', 'lambert', 'mollweide']: + return + super().limits_to_mpl(*args) + def _update_ticks(self, *args): radians = hasattr(self.state, 'angle_unit') and self.state.angle_unit == 'radians' if self.state.x_att is not None: @@ -63,8 +69,6 @@ def _update_polar_ticks(self, *args): def _update_projection(self, *args): self.figure.delaxes(self.axes) _, self.axes = init_mpl(self.figure, projection=self.state.plot_mode) - self.remove_all_toolbars() - self.initialize_toolbar() for layer in self.layers: layer._set_axes(self.axes) layer.state.vector_mode = 'Cartesian' From cb76aca6f024fd406f5ed9771ad1489fa1447276 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Wed, 4 Dec 2024 13:34:29 +0000 Subject: [PATCH 3/8] Fix SimpleScatterViewer.__init__ --- glue/viewers/scatter/viewer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/glue/viewers/scatter/viewer.py b/glue/viewers/scatter/viewer.py index 553b692fd..cef3e0558 100644 --- a/glue/viewers/scatter/viewer.py +++ b/glue/viewers/scatter/viewer.py @@ -193,6 +193,7 @@ class SimpleScatterViewer(MatplotlibScatterMixin, SimpleMatplotlibViewer): _data_artist_cls = ScatterLayerArtist _subset_artist_cls = ScatterLayerArtist - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, session, parent=None, state=None): + proj = None if not state or not state.plot_mode else state.plot_mode + SimpleMatplotlibViewer.__init__(self, session, parent=parent, state=state, projection=proj) MatplotlibScatterMixin.setup_callbacks(self) From c872610c05dcccc15248a77755bc2d2ef1e381a1 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Wed, 4 Dec 2024 13:46:55 +0000 Subject: [PATCH 4/8] Make sure all layer artist properties that need to be updated following change to plot_mode are updated before attempting to draw --- glue/viewers/scatter/viewer.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/glue/viewers/scatter/viewer.py b/glue/viewers/scatter/viewer.py index cef3e0558..76289a996 100644 --- a/glue/viewers/scatter/viewer.py +++ b/glue/viewers/scatter/viewer.py @@ -1,3 +1,5 @@ +from echo import delay_callback + from glue.core.subset import roi_to_subset_state from glue.core.util import update_ticks from glue.core.roi_pretransforms import FullSphereLongitudeTransform, ProjectionMplTransform, RadianTransform @@ -30,12 +32,6 @@ def setup_callbacks(self): self.state.add_callback('y_axislabel', self._update_polar_ticks) self._update_axes() - def limits_to_mpl(self, *args): - # These projections throw errors if we try to set the limits - if self.state.plot_mode in ['aitoff', 'hammer', 'lambert', 'mollweide']: - return - super().limits_to_mpl(*args) - def _update_ticks(self, *args): radians = hasattr(self.state, 'angle_unit') and self.state.angle_unit == 'radians' if self.state.x_att is not None: @@ -71,9 +67,10 @@ def _update_projection(self, *args): _, self.axes = init_mpl(self.figure, projection=self.state.plot_mode) for layer in self.layers: layer._set_axes(self.axes) - layer.state.vector_mode = 'Cartesian' - layer.state._update_points_mode() - layer.update() + with delay_callback(layer.state, 'vector_mode'): + layer.state.vector_mode = 'Cartesian' + layer.state._update_points_mode() + layer.update() self.axes.callbacks.connect('xlim_changed', self.limits_from_mpl) self.axes.callbacks.connect('ylim_changed', self.limits_from_mpl) self.update_x_axislabel() @@ -197,3 +194,9 @@ def __init__(self, session, parent=None, state=None): proj = None if not state or not state.plot_mode else state.plot_mode SimpleMatplotlibViewer.__init__(self, session, parent=parent, state=state, projection=proj) MatplotlibScatterMixin.setup_callbacks(self) + + def limits_to_mpl(self, *args): + # These projections throw errors if we try to set the limits + if self.state.plot_mode in ['aitoff', 'hammer', 'lambert', 'mollweide']: + return + super().limits_to_mpl(*args) From 8c661c7bd15df5088d64b87bcf4c499efaf5338b Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Wed, 4 Dec 2024 14:58:41 +0000 Subject: [PATCH 5/8] Fix test affine WCS to avoid corner case with auto axis placement --- glue/viewers/image/tests/test_python_export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glue/viewers/image/tests/test_python_export.py b/glue/viewers/image/tests/test_python_export.py index e67e648be..4b7c86bfa 100644 --- a/glue/viewers/image/tests/test_python_export.py +++ b/glue/viewers/image/tests/test_python_export.py @@ -18,7 +18,7 @@ def setup_method(self, method): with NumpyRNGContext(12345): self.data = Data(cube=np.random.random((30, 50, 20))) # Create data versions with WCS and affine coordinates - matrix = np.array([[2, 3, 4, -1], [1, 2, 2, 2], [1, 1, 1, -2], [0, 0, 0, 1]]) + matrix = np.array([[2, 3, 0, -1], [1, 2, 0, 2], [0, 0, 1, -2], [0, 0, 0, 1]]) affine = AffineCoordinates(matrix, units=['Mm', 'Mm', 'km'], labels=['xw', 'yw', 'zw']) self.data_wcs = Data(label='cube', cube=self.data['cube'], coords=WCS(naxis=3)) From 06dee9b91d160213e23680395f1a0c6f70ca22a8 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Wed, 4 Dec 2024 15:07:34 +0000 Subject: [PATCH 6/8] Fix import --- glue/viewers/scatter/tests/test_python_export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glue/viewers/scatter/tests/test_python_export.py b/glue/viewers/scatter/tests/test_python_export.py index c725a5750..c89d4919a 100644 --- a/glue/viewers/scatter/tests/test_python_export.py +++ b/glue/viewers/scatter/tests/test_python_export.py @@ -7,7 +7,7 @@ from glue.core import Data, DataCollection from glue.core.application_base import Application from glue.viewers.scatter.viewer import SimpleScatterViewer -from glue_qt.viewers.matplotlib.tests.test_python_export import BaseTestExportPython, random_with_nan +from glue.viewers.matplotlib.tests.test_python_export import BaseTestExportPython, random_with_nan class TestExportPython(BaseTestExportPython): From 488b1b38823dcab3872134a66ef69fba1ed621c2 Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Mon, 6 Jan 2025 15:08:10 +0000 Subject: [PATCH 7/8] Fixed race condition in updating of x/y attributes when x was temporarily equal to y --- glue/viewers/image/state.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/glue/viewers/image/state.py b/glue/viewers/image/state.py index 9258646b9..59f5db95e 100644 --- a/glue/viewers/image/state.py +++ b/glue/viewers/image/state.py @@ -257,7 +257,7 @@ def _on_yatt_change(self, *args): self.y_att_world = self.y_att @defer_draw - def _on_xatt_world_change(self, *args): + def _on_xatt_world_change(self, *args, forced=False): if self.x_att_world is not None: @@ -279,8 +279,11 @@ def _on_xatt_world_change(self, *args): else: self.x_att = self.x_att_world + if not forced: + self._on_yatt_world_change(forced=True) + @defer_draw - def _on_yatt_world_change(self, *args): + def _on_yatt_world_change(self, *args, forced=False): if self.y_att_world is not None: @@ -302,6 +305,9 @@ def _on_yatt_world_change(self, *args): else: self.y_att = self.y_att_world + if not forced: + self._on_xatt_world_change(forced=True) + def _set_reference_data(self): if self.reference_data is None: for layer in self.layers: From 3afdd507fa8cbd7dbdff922d8fb60784b008d73d Mon Sep 17 00:00:00 2001 From: Thomas Robitaille Date: Tue, 7 Jan 2025 10:32:37 +0000 Subject: [PATCH 8/8] Adjust affine matrix to avoid three correlated axes in final plot --- glue/core/coordinates.py | 4 ++++ glue/viewers/image/tests/test_python_export.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/glue/core/coordinates.py b/glue/core/coordinates.py index 63f280daa..7eac0d11a 100644 --- a/glue/core/coordinates.py +++ b/glue/core/coordinates.py @@ -209,6 +209,10 @@ def __setgluestate__(cls, rec, context): units=rec['units'], labels=rec['labels']) + @property + def axis_correlation_matrix(self): + return self._matrix[:-1, :-1] != 0 + # Kept for backward-compatibility WCSCoordinates = WCS diff --git a/glue/viewers/image/tests/test_python_export.py b/glue/viewers/image/tests/test_python_export.py index 4b7c86bfa..fff360a5e 100644 --- a/glue/viewers/image/tests/test_python_export.py +++ b/glue/viewers/image/tests/test_python_export.py @@ -18,7 +18,7 @@ def setup_method(self, method): with NumpyRNGContext(12345): self.data = Data(cube=np.random.random((30, 50, 20))) # Create data versions with WCS and affine coordinates - matrix = np.array([[2, 3, 0, -1], [1, 2, 0, 2], [0, 0, 1, -2], [0, 0, 0, 1]]) + matrix = np.array([[2, 0, 0, -1], [0, 2, 1, 2], [0, 3, 1, -2], [0, 0, 0, 1]]) affine = AffineCoordinates(matrix, units=['Mm', 'Mm', 'km'], labels=['xw', 'yw', 'zw']) self.data_wcs = Data(label='cube', cube=self.data['cube'], coords=WCS(naxis=3))