diff --git a/doc/developer_guide/data.rst b/doc/developer_guide/data.rst index 91b67789e..6ecd18a9a 100644 --- a/doc/developer_guide/data.rst +++ b/doc/developer_guide/data.rst @@ -65,7 +65,11 @@ The methods you need to define are: (``view=[(1, 2, 3), (4, 3, 4)]``), and so on. If a view is specified, only that subset of values should be returned. For example if the data has an overall shape of ``(10,)`` and ``view=slice(1, 6, 2)``, ``get_data`` should - return an array with shape ``(3,)``. + return an array with shape ``(3,)``. By default, :meth:`BaseCartesianData.get_data ` + will return values for pixel and world :class:`~glue.core.component_id.ComponentID` + objects as well as any linked :class:`~glue.core.component_id.ComponentID`, so we + recommend that your implementation calls :meth:`BaseCartesianData.get_data ` + for any :class:`~glue.core.component_id.ComponentID` you do not expose yourself. * :meth:`~glue.core.data.BaseCartesianData.get_mask`: given a :class:`~glue.core.subset.SubsetState` object (described in `Subset states`_) and optionally a ``view``, return a boolean array describing which values are @@ -138,6 +142,17 @@ While developing your data class, one way to make sure that glue doesn't crash if you haven't yet implemented support for a specific subset state is to interpret any unimplemented subset state as simply indicating an empty subset. +Linking +------- + +You should be able to link data objects that inherit from +:class:`~glue.core.data.BaseCartesianData` with other datasets - however +for this to work properly you should make sure that your implementation of +:meth:`~glue.core.data.BaseCartesianData.get_data` calls +:meth:`BaseCartesianData.get_data ` +for any unrecognized :class:`~glue.core.component_id.ComponentID`, as the base +implementation will handle returning linked values. + Using your data object ---------------------- diff --git a/doc/developer_guide/random_data.py b/doc/developer_guide/random_data.py index aff1995e6..ac55033db 100644 --- a/doc/developer_guide/random_data.py +++ b/doc/developer_guide/random_data.py @@ -27,10 +27,10 @@ def get_kind(self, cid): return 'numerical' def get_data(self, cid, view=None): - if cid in self.pixel_component_ids: - return super(RandomData, self).get_data(cid, view=view) - else: + if cid is self.data_cid: return np.random.random(view_shape(self.shape, view)) + else: + return super(RandomData, self).get_data(cid, view=view) def get_mask(self, subset_state, view=None): return subset_state.to_mask(self, view=view) diff --git a/glue/core/data.py b/glue/core/data.py index e01cc8b8a..80984b16e 100644 --- a/glue/core/data.py +++ b/glue/core/data.py @@ -398,6 +398,8 @@ class BaseCartesianData(BaseData, metaclass=abc.ABCMeta): def __init__(self, coords=None): super(BaseCartesianData, self).__init__() self._coords = coords + self._externally_derivable_components = OrderedDict() + self._pixel_aligned_data = OrderedDict() @property def coords(self): @@ -448,13 +450,21 @@ def get_data(self, cid, view=None): return broadcast_to(pix, self.shape)[view] elif cid in self.world_component_ids: comp = self._world_components[cid] - if view is None: - return comp.data - else: - return comp[view] + elif cid in self._externally_derivable_components: + comp = self._externally_derivable_components[cid] else: raise IncompatibleAttribute(cid) + # Note that above we have extracted Component objects from internal + # properties - we don't actually expose Component objects in this class, + # only in the Data class, but we use these components internally for + # convenience. + + if view is None: + return comp.data + else: + return comp[view] + @abc.abstractmethod def get_mask(self, subset_state, view=None): """ @@ -596,6 +606,9 @@ def world_component_ids(self): self._world_component_ids = [] self._world_components = {} for i in range(self.ndim): + # Note: we use a Component object here for convenience but we + # don't actually expose it via the BaseCartesianData API - in + # get_data we extract the data from the component. comp = CoordinateComponent(self, i, world=True) label = axis_label(self.coords, i) cid = ComponentID(label, parent=self) @@ -603,6 +616,96 @@ def world_component_ids(self): self._world_components[cid] = comp return self._world_component_ids + def _set_externally_derivable_components(self, derivable_components): + """ + Externally deriable components are components identified by component + IDs from other datasets. + + This method is meant for internal use only and is called by the link + manager. The ``derivable_components`` argument should be set to a + dictionary where the keys are the derivable component IDs, and the + values are DerivedComponent instances which can be used to get the + data. + """ + + # Note that even though Component objects are not normally exposed as + # part of the BaseCartesianData API, we use these internally here as + # a convenience, and extract the data from them in get_data. The actual + # derived components are however used in the Data class. + + if len(self._externally_derivable_components) == 0 and len(derivable_components) == 0: + + return + + elif len(self._externally_derivable_components) == len(derivable_components): + + for key in derivable_components: + if key in self._externally_derivable_components: + if self._externally_derivable_components[key].link is not derivable_components[key].link: + break + else: + break + else: + return # Unchanged! + + self._externally_derivable_components = derivable_components + + if self.hub: + msg = ExternallyDerivableComponentsChangedMessage(self) + self.hub.broadcast(msg) + + def _get_external_link(self, cid): + if cid in self._externally_derivable_components: + return self._externally_derivable_components[cid].link + else: + return None + + def _get_coordinate_transform(self, world_cid): + if world_cid in self._world_components: + def transform(values): + return self._world_components._calculate(view=values) + return transform + else: + return None + + def _set_pixel_aligned_data(self, pixel_aligned_data): + """ + Pixel-aligned data are datasets that contain pixel component IDs + that are equivalent (identically, not transformed) with all pixel + component IDs in the present dataset. + + Note that the other datasets may have more but not fewer dimensions, so + this information may not be symmetric between datasets with differing + numbers of dimensions. + """ + + # First check if anything has changed, as if not then we should just + # leave things as-is and avoid emitting a message. + if len(self._pixel_aligned_data) == len(pixel_aligned_data): + for data in self._pixel_aligned_data: + if data not in pixel_aligned_data or pixel_aligned_data[data] != self._pixel_aligned_data[data]: + break + else: + return + + self._pixel_aligned_data = pixel_aligned_data + if self.hub: + msg = PixelAlignedDataChangedMessage(self) + self.hub.broadcast(msg) + + @property + def pixel_aligned_data(self): + """ + Information about other datasets in the same data collection that have + matching or a subset of pixel component IDs. + + This is returned as a dictionary where each key is a dataset with + matching pixel component IDs, and the value is the order in which the + pixel component IDs of the other dataset can be found in the current + one. + """ + return self._pixel_aligned_data + class Data(BaseCartesianData): """ @@ -650,8 +753,6 @@ def __init__(self, label="", coords=None, **kwargs): # Components self._components = OrderedDict() - self._externally_derivable_components = OrderedDict() - self._pixel_aligned_data = OrderedDict() self._pixel_component_ids = ComponentIDList() self._world_component_ids = ComponentIDList() @@ -1007,77 +1108,6 @@ def add_component(self, component, label): return component_id - def _set_externally_derivable_components(self, derivable_components): - """ - Externally deriable components are components identified by component - IDs from other datasets. - - This method is meant for internal use only and is called by the link - manager. The ``derivable_components`` argument should be set to a - dictionary where the keys are the derivable component IDs, and the - values are DerivedComponent instances which can be used to get the - data. - """ - - if len(self._externally_derivable_components) == 0 and len(derivable_components) == 0: - - return - - elif len(self._externally_derivable_components) == len(derivable_components): - - for key in derivable_components: - if key in self._externally_derivable_components: - if self._externally_derivable_components[key].link is not derivable_components[key].link: - break - else: - break - else: - return # Unchanged! - - self._externally_derivable_components = derivable_components - - if self.hub: - msg = ExternallyDerivableComponentsChangedMessage(self) - self.hub.broadcast(msg) - - def _set_pixel_aligned_data(self, pixel_aligned_data): - """ - Pixel-aligned data are datasets that contain pixel component IDs - that are equivalent (identically, not transformed) with all pixel - component IDs in the present dataset. - - Note that the other datasets may have more but not fewer dimensions, so - this information may not be symmetric between datasets with differing - numbers of dimensions. - """ - - # First check if anything has changed, as if not then we should just - # leave things as-is and avoid emitting a message. - if len(self._pixel_aligned_data) == len(pixel_aligned_data): - for data in self._pixel_aligned_data: - if data not in pixel_aligned_data or pixel_aligned_data[data] != self._pixel_aligned_data[data]: - break - else: - return - - self._pixel_aligned_data = pixel_aligned_data - if self.hub: - msg = PixelAlignedDataChangedMessage(self) - self.hub.broadcast(msg) - - @property - def pixel_aligned_data(self): - """ - Information about other datasets in the same data collection that have - matching or a subset of pixel component IDs. - - This is returned as a dictionary where each key is a dataset with - matching pixel component IDs, and the value is the order in which the - pixel component IDs of the other dataset can be found in the current - one. - """ - return self._pixel_aligned_data - @contract(link=ComponentLink, label='cid_like|None', returns=DerivedComponent) diff --git a/glue/core/fixed_resolution_buffer.py b/glue/core/fixed_resolution_buffer.py index 9dee26301..973fa03a8 100644 --- a/glue/core/fixed_resolution_buffer.py +++ b/glue/core/fixed_resolution_buffer.py @@ -1,7 +1,7 @@ import numpy as np from glue.core import Data -from glue.core.exceptions import IncompatibleDataException -from glue.core.component import CoordinateComponent, DaskComponent +from glue.core.exceptions import IncompatibleAttribute, IncompatibleDataException +from glue.core.component import DaskComponent from glue.core.coordinate_helpers import dependent_axes from glue.utils import unbroadcast, broadcast_to, broadcast_arrays_minimal @@ -48,30 +48,35 @@ def translate_pixel(data, pixel_coords, target_cid): # TODO: check that things are efficient if the PixelComponentID is in a # pixel-aligned dataset. - component = data.get_component(target_cid) - - if hasattr(component, 'link'): - link = component.link - values_all = [] - dimensions_all = [] - for cid in link._from: - values, dimensions = translate_pixel(data, pixel_coords, cid) - values_all.append(values) - dimensions_all.extend(dimensions) - # Unbroadcast arrays to smallest common shape for performance - if len(values_all) > 0: - shape = values_all[0].shape - values_all = broadcast_arrays_minimal(*values_all) - results = link._using(*values_all) - result = broadcast_to(results, shape) + link = data._get_external_link(target_cid) + + if link is None: + if target_cid in data.main_components or target_cid in data.derived_components: + raise Exception("Dependency on non-pixel component", target_cid) + elif target_cid in data.coordinate_components: + if isinstance(data, Data): + comp = data.get_component(target_cid) + else: + comp = data._world_components[target_cid] + return comp._calculate(view=pixel_coords), dependent_axes(data.coords, comp.axis) else: - result = None - return result, sorted(set(dimensions_all)) - elif isinstance(component, CoordinateComponent): - # FIXME: Hack for now - if we pass arrays in the view, it's interpreted - return component._calculate(view=pixel_coords), dependent_axes(data.coords, component.axis) + raise IncompatibleAttribute(target_cid) + + values_all = [] + dimensions_all = [] + for cid in link._from: + values, dimensions = translate_pixel(data, pixel_coords, cid) + values_all.append(values) + dimensions_all.extend(dimensions) + # Unbroadcast arrays to smallest common shape for performance + if len(values_all) > 0: + shape = values_all[0].shape + values_all = broadcast_arrays_minimal(*values_all) + results = link._using(*values_all) + result = broadcast_to(results, shape) else: - raise Exception("Dependency on non-pixel component", target_cid) + result = None + return result, sorted(set(dimensions_all)) class AnyScalar(object): diff --git a/glue/core/layer_artist.py b/glue/core/layer_artist.py index 85c0612aa..d98889291 100644 --- a/glue/core/layer_artist.py +++ b/glue/core/layer_artist.py @@ -124,6 +124,10 @@ def disable(self, reason): Used by the UI """ self._disabled_reason = reason + # If layer is already disabled, avoid continuing to not repeatadly + # disable layer and emit messages which might force a redraw + if not self._enabled: + return self._enabled = False self.clear() if self._layer is not None and self._layer.hub is not None: diff --git a/glue/core/link_manager.py b/glue/core/link_manager.py index a747000fa..494676651 100644 --- a/glue/core/link_manager.py +++ b/glue/core/link_manager.py @@ -24,7 +24,7 @@ from glue.core.contracts import contract from glue.core.link_helpers import LinkCollection from glue.core.component_link import ComponentLink -from glue.core.data import Data +from glue.core.data import Data, BaseCartesianData from glue.core.component import DerivedComponent from glue.core.exceptions import IncompatibleAttribute from glue.core.subset import Subset @@ -231,7 +231,7 @@ def update_externally_derivable_components(self, data=None): data_collection = self.data_collection # Only keep actual Data instances since only they support links for now - data_collection = [d for d in data_collection if isinstance(d, Data)] + data_collection = [d for d in data_collection if isinstance(d, BaseCartesianData)] for data in data_collection: links = discover_links(data, self._links | self._inverse_links) @@ -295,18 +295,20 @@ def _find_identical_reference_cid(data, cid): truly belongs to the dataset (not via a link). Returns None if there is no strictly identical component in the dataset. """ - try: - target_comp = data.get_component(cid) - except IncompatibleAttribute: + + if cid in data.main_components or cid in data.coordinate_components or cid in data.derived_components: + return cid + + link = data._get_external_link(cid) + + if link is None: return None - if isinstance(target_comp, DerivedComponent): - if target_comp.link.identity: - updated_cid = target_comp.link.get_from_ids()[0] - return _find_identical_reference_cid(data, updated_cid) - else: - return None + + if link.identity: + updated_cid = link.get_from_ids()[0] + return _find_identical_reference_cid(data, updated_cid) else: - return cid + return None def is_equivalent_cid(data, cid1, cid2): diff --git a/glue/core/tests/test_base_cartesian_data.py b/glue/core/tests/test_base_cartesian_data.py new file mode 100644 index 000000000..0eb6f9bf8 --- /dev/null +++ b/glue/core/tests/test_base_cartesian_data.py @@ -0,0 +1,151 @@ +import numpy as np +from numpy.testing import assert_allclose + +from glue.core.component_id import ComponentID + +from ..data_collection import DataCollection +from ..link_helpers import ComponentLink +from ..data import BaseCartesianData +from ..coordinates import IdentityCoordinates + + +class ExampleBaseData(BaseCartesianData): + + def __init__(self, cid_label='x', coords=None): + super().__init__(coords=coords) + self.data_cid = ComponentID(label=cid_label, parent=self) + self._array = np.arange(12).reshape((1, 4, 3)) + + @property + def label(self): + return "Example Data" + + @property + def shape(self): + return (1, 4, 3) + + @property + def main_components(self): + return [self.data_cid] + + def get_kind(self, cid): + return 'numerical' + + def get_data(self, cid, view=None): + if cid is self.data_cid: + if view is None: + return self._array + else: + return self._array[view] + else: + return super(ExampleBaseData, self).get_data(cid, view=view) + + def get_mask(self, subset_state, view=None): + return subset_state.to_mask(self, view=view) + + def compute_statistic(self, statistic, cid, + axis=None, finite=True, + positive=False, subset_state=None, + percentile=None, random_subset=None): + raise NotImplementedError() + + def compute_histogram(self, cid, + range=None, bins=None, log=False, + subset_state=None, subset_group=None): + raise NotImplementedError() + + +def test_data_coords(): + + # Make sure that world_component_ids works in both the case where + # coords is not defined and when it is defined. + + data1 = ExampleBaseData() + assert len(data1.pixel_component_ids) == 3 + assert len(data1.world_component_ids) == 0 + + data2 = ExampleBaseData(coords=IdentityCoordinates(n_dim=3)) + assert len(data2.pixel_component_ids) == 3 + assert len(data2.world_component_ids) == 3 + + for idx in range(3): + + assert_allclose(data2[data2.world_component_ids[idx]], + data2[data2.pixel_component_ids[idx]]) + + assert_allclose(data2[data2.world_component_ids[idx], (0, slice(2), 2)], + data2[data2.pixel_component_ids[idx], (0, slice(2), 2)]) + + +def test_linking(): + + data1 = ExampleBaseData(cid_label='x') + data2 = ExampleBaseData(cid_label='y') + data3 = ExampleBaseData(cid_label='z') + + dc = DataCollection([data1, data2, data3]) + + cid1 = data1.main_components[0] + cid2 = data2.main_components[0] + cid3 = data3.main_components[0] + + def double(x): + return 2 * x + + def halve(x): + return x / 2. + + link1 = ComponentLink([cid1], cid2, using=double, inverse=halve) + link2 = ComponentLink([cid2], cid3, using=double, inverse=halve) + + dc.add_link(link1) + dc.add_link(link2) + + assert_allclose(data1[cid2], data1[cid1] * 2) + assert_allclose(data1[cid3], data1[cid1] * 4) + + assert_allclose(data2[cid1], data1[cid1] / 2) + assert_allclose(data3[cid1], data1[cid1] / 4) + + assert_allclose(data2[cid2], data1[cid2] / 2) + assert_allclose(data3[cid2], data1[cid2] / 4) + + assert_allclose(data2[cid3], data1[cid3] / 2) + assert_allclose(data3[cid3], data1[cid3] / 4) + + +def test_pixel_aligned(): + + data1 = ExampleBaseData(cid_label='x') + data2 = ExampleBaseData(cid_label='y') + data3 = ExampleBaseData(cid_label='z') + + dc = DataCollection([data1, data2, data3]) + + def double(x): + return 2 * x + + def halve(x): + return x / 2. + + for dim in range(3): + + cid1 = data1.pixel_component_ids[dim] + cid2 = data2.pixel_component_ids[dim] + cid3 = data3.pixel_component_ids[dim] + + link1 = ComponentLink([cid1], cid2) + + if dim == 1: + link2 = ComponentLink([cid2], cid3, using=double, inverse=halve) + else: + link2 = ComponentLink([cid2], cid3) + + dc.add_link(link1) + dc.add_link(link2) + + assert len(data1.pixel_aligned_data) == 1 + assert data1.pixel_aligned_data[data2] == [0, 1, 2] + assert len(data2.pixel_aligned_data) == 1 + assert data2.pixel_aligned_data[data1] == [0, 1, 2] + assert len(data3.pixel_aligned_data) == 0 diff --git a/glue/core/tests/test_data.py b/glue/core/tests/test_data.py index 798b82728..71df34216 100644 --- a/glue/core/tests/test_data.py +++ b/glue/core/tests/test_data.py @@ -13,7 +13,7 @@ from ..component_id import ComponentID from ..component_link import ComponentLink, CoordinateComponentLink, BinaryComponentLink from ..coordinates import Coordinates, IdentityCoordinates -from ..data import Data, pixel_label, BaseCartesianData +from ..data import Data, pixel_label from ..link_helpers import LinkSame from ..data_collection import DataCollection from ..exceptions import IncompatibleAttribute @@ -1127,47 +1127,3 @@ def test_compute_histogram_dask_mixed(): result = data.compute_histogram([data.id['y'], data.id['x']], range=[[-0.5, 11.25], [-0.5, 11.25]], bins=[2, 3], subset_state=data.id['x'] > 4.5) assert_allclose(result, [[0, 1, 0], [0, 2, 2]]) - - -def test_base_cartesian_data_coords(): - - # Make sure that world_component_ids works in both the case where - # coords is not defined and when it is defined. - - class CustomData(BaseCartesianData): - - def get_kind(self): - pass - - def compute_histogram(self): - pass - - def compute_statistic(self): - pass - - def get_mask(self): - pass - - @property - def shape(self): - return (1, 4, 3) - - @property - def main_components(self): - return [] - - data1 = CustomData() - assert len(data1.pixel_component_ids) == 3 - assert len(data1.world_component_ids) == 0 - - data2 = CustomData(coords=IdentityCoordinates(n_dim=3)) - assert len(data2.pixel_component_ids) == 3 - assert len(data2.world_component_ids) == 3 - - for idx in range(3): - - assert_allclose(data2[data2.world_component_ids[idx]], - data2[data2.pixel_component_ids[idx]]) - - assert_allclose(data2[data2.world_component_ids[idx], (0, slice(2), 2)], - data2[data2.pixel_component_ids[idx], (0, slice(2), 2)]) diff --git a/glue/core/tests/test_fixed_resolution_buffer.py b/glue/core/tests/test_fixed_resolution_buffer.py index 1d01019d9..6fa2c7a2c 100644 --- a/glue/core/tests/test_fixed_resolution_buffer.py +++ b/glue/core/tests/test_fixed_resolution_buffer.py @@ -4,7 +4,10 @@ from numpy.testing import assert_equal from glue.core import Data, DataCollection -from glue.core.link_helpers import LinkSame +from glue.core.link_helpers import LinkSame, ComponentLink +from glue.core.fixed_resolution_buffer import compute_fixed_resolution_buffer + +from .test_base_cartesian_data import ExampleBaseData ARRAY = np.arange(3024).reshape((6, 7, 8, 9)).astype(float) @@ -104,3 +107,52 @@ def test_data_is_target_full_bounds(self, bounds, expected): buffer = self.data3.compute_fixed_resolution_buffer(target_data=self.data1, bounds=bounds, target_cid=self.data3.id['x']) assert_equal(buffer, expected) + + +def test_base_cartesian_data(): + + # Make sure that things work properly when using a BaseCartesianData + # subclass that isn't necessarily Data. + + data1 = ExampleBaseData(cid_label='x') + data2 = ExampleBaseData(cid_label='y') + + dc = DataCollection([data1, data2]) + + def add_one(x): + return x + 1 + + def sub_one(x): + return x - 1 + + link1 = ComponentLink([data1.pixel_component_ids[0]], data2.pixel_component_ids[0]) + link2 = ComponentLink([data1.pixel_component_ids[1]], data2.pixel_component_ids[1], using=add_one, inverse=sub_one) + link3 = ComponentLink([data1.pixel_component_ids[2]], data2.pixel_component_ids[2], using=sub_one, inverse=add_one) + + dc.add_link(link1) + dc.add_link(link2) + dc.add_link(link3) + + assert_equal(compute_fixed_resolution_buffer(data1, + target_data=data1, + bounds=[(-1, 1, 3), (0, 3, 4), 1], + target_cid=data1.main_components[0]), + np.array([[-np.inf, -np.inf, -np.inf, -np.inf], + [1, 4, 7, 10], + [-np.inf, -np.inf, -np.inf, -np.inf]])) + + assert_equal(compute_fixed_resolution_buffer(data2, + target_data=data2, + bounds=[(-1, 1, 3), (0, 3, 4), 1], + target_cid=data2.main_components[0]), + np.array([[-np.inf, -np.inf, -np.inf, -np.inf], + [1, 4, 7, 10], + [-np.inf, -np.inf, -np.inf, -np.inf]])) + + assert_equal(compute_fixed_resolution_buffer(data1, + target_data=data2, + bounds=[(-1, 1, 3), (0, 3, 4), 1], + target_cid=data1.main_components[0]), + np.array([[-np.inf, -np.inf, -np.inf, -np.inf], + [-np.inf, 2, 5, 8], + [-np.inf, -np.inf, -np.inf, -np.inf]])) diff --git a/glue/tests/test_qglue.py b/glue/tests/test_qglue.py index 38542d649..cb0e26810 100644 --- a/glue/tests/test_qglue.py +++ b/glue/tests/test_qglue.py @@ -138,7 +138,7 @@ def get_mask(self): @property def shape(self): - pass + return () @property def main_components(self): diff --git a/glue/viewers/image/layer_artist.py b/glue/viewers/image/layer_artist.py index 6bd5c68a1..5dbfcd817 100644 --- a/glue/viewers/image/layer_artist.py +++ b/glue/viewers/image/layer_artist.py @@ -104,7 +104,7 @@ def get_handle_legend(self): return None, None, None def enable(self): - if hasattr(self, 'composite_image'): + if not self.enabled and hasattr(self, 'composite_image'): self.composite_image.invalidate_cache() super(ImageLayerArtist, self).enable()