diff --git a/glue/core/component.py b/glue/core/component.py index 9857b5e37..7ce9b6a70 100644 --- a/glue/core/component.py +++ b/glue/core/component.py @@ -2,6 +2,7 @@ import numpy as np import pandas as pd +import shapely from glue.core.coordinate_helpers import dependent_axes, pixel2world_single_axis from glue.utils import shape_to_string, coerce_numeric, categorical_ndarray @@ -13,7 +14,7 @@ DASK_INSTALLED = False __all__ = ['Component', 'DerivedComponent', 'CategoricalComponent', - 'CoordinateComponent', 'DateTimeComponent'] + 'CoordinateComponent', 'DateTimeComponent', 'ExtendedComponent'] class Component(object): @@ -107,6 +108,13 @@ def datetime(self): """ return False + @property + def extended(self): + """ + Whether or not or not the datatype represents an extended region + """ + return False + def __str__(self): return "%s with shape %s" % (self.__class__.__name__, shape_to_string(self.shape)) @@ -549,3 +557,98 @@ def categorical(self): @property def datetime(self): return False + + +class ExtendedComponent(Component): + """ + A data component representing an extent or a region. + + This component can be used when a dataset describes regions or ranges + and is typically used with a :class:`~glue.core.data.RegionData` object. + For example, a :class:`~glue.core.data.RegionData` object might provide + properties of geographic regions, and the boundaries of these regions + would be an ExtendedComponent. + + Data loaders are required to know how to convert regions to a list + of Shapely objects which can be used to initialize an ExtendedComponent. + + A circular region can be represented as: + + circle = shapely.Point(x, y).buffer(rad) + + A range in one dimension can be represented as: + + range = shapely.LineString([[x0,0],[x1,0]]) + + (This is a bit of an odd representation, since we are forced to specify a y + coordinate for this line. We adopt a convention of y == 0.) + + ExtendedComponents are NOT used directly in linking. Instead, ExtendedComponents + always have corresponding ComponentIDs that represent the x (and y) coordinates + over which the regions are defined. If not specified otherwise, a + :class:`~glue.core.data.RegionData` object will create `representative points` + for each region, representing a point near the center of the reigon that is + guaranteed to be inside the region. + + NOTE: that this implementation does not support regions in more than + two dimensions. (Shapely has limited support for 3D shapes, but not more). + + Parameters + ---------- + data : list of :class:`~shapely.Geometry` objects + The data to store. + center_comp_ids : list of :class:`glue.core.component_id.ComponentID` objects + The ComponentIDs of the `center` of the extended region. These do not + have to be the literal center of the region, but they must be in the x (and y) + coordinates of the regions. These componentIDs are used in the linking + framework to allow an ExtendedComponent to be linked to other components. + units : `str`, optional + Unit description. + + Attributes + ---------- + x : ComponentID + The ComponentID of the x coordinate at the center of the extended region. + y : ComponentID + The ComponentID of the y coordinate at the center of the extended region. + + Raises + ------ + TypeError + If data is not a list of shapely.Geometry objects + ValueError + If center_comp_ids is not a list of length 1 or 2 + """ + def __init__(self, data, center_comp_ids, units=None): + if not all(isinstance(s, shapely.Geometry) for s in data): + raise TypeError( + "Input data for a ExtendedComponent should be a list of shapely.Geometry objects" + ) + if len(center_comp_ids) == 2: + self.x = center_comp_ids[0] + self.y = center_comp_ids[1] + elif len(center_comp_ids) == 1: + self.x = center_comp_ids[0] + self.y = None + else: + raise ValueError( + "ExtendedComponent must be initialized with one or two ComponentIDs" + ) + self.units = units + self._data = data + + @property + def extended(self): + return True + + @property + def numeric(self): + return False + + @property + def datetime(self): + return False + + @property + def categorical(self): + return False diff --git a/glue/core/data.py b/glue/core/data.py index 5293a488b..bc1bcfcb7 100644 --- a/glue/core/data.py +++ b/glue/core/data.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd +import shapely from fast_histogram import histogram1d, histogram2d @@ -36,7 +37,7 @@ # Note: leave all the following imports for component and component_id since # they are here for backward-compatibility (the code used to live in this # file) -from glue.core.component import Component, CoordinateComponent, DerivedComponent +from glue.core.component import Component, CoordinateComponent, DerivedComponent, ExtendedComponent from glue.core.component_id import ComponentID, ComponentIDDict, PixelComponentID try: @@ -2057,3 +2058,162 @@ def pixel_label(i, ndim): if 1 <= ndim <= 3: label += " [{0}]".format('xyz'[ndim - 1 - i]) return label + + +class RegionData(Data): + """ + A glue Data object for storing data that is associated with a region. + + This object can be used when a dataset describes 2D regions or 1D ranges. It + contains exactly one :class:`~glue.core.component.ExtendedComponent` object + which contains the boundaries of the regions, and must also contain + one or two components that give the center of the regions in whatever data + coordinates the regions are described in. Links in glue are not made + directly on the ExtendedComponent, but instead on the center components. + Thus, a subset that includes the center of a region will include that region, + but a subset that includes just a little part of the region will not include + that region. These center components are not the same pixel components. For + example, a dataset that is a table of 2D regions with have a single PixelComponent, + but must have two of these center components. + + A typical use case for this object is to store the properties of geographic + regions, where the boundaries of the regions are stored in an ExtendedComponent, + and the centers of the regions are stored in two components, one for the + longitude and one for the latitude. Additional components may describe + arbitrary properties of these geographic regions (e.g. population, area, etc). + + + Parameters + ---------- + label : `str`, optional + The label of the data. + coords : :class:`~glue.core.coordinates.Coordinates`, optional + The coordinates associated with the data. + **kwargs + All other keyword arguments are passed to the :class:`~glue.core.data.Data` + constructor. + + Attributes + ---------- + extended_component_id : :class:`~glue.core.component_id.ComponentID` + The ID of the ExtendedComponent object that contains the boundaries of + the regions. + center_x_id : :class:`~glue.core.component_id.ComponentID` + The ID of the Component object that contains the x-coordinate of the + center of the regions. This is actually stored in the component + with the extended_component_id, but it is convenient to have it here. + center_y_id : :class:`~glue.core.component_id.ComponentID` + The ID of the Component object that contains the y-coordinate of the + center of the regions. This is actually stored in the component + with the extended_component_id, but it is convenient to have it here. + + Examples + -------- + There are two main options for initializing a RegionData object. The first is + to simply pass in a list of Shapely objects with dimesionality N, from which + we will create N+1 components: one ExtendedComponent with the boundaries, and + N regular Component(s) with the center coordinates computed from the Shapley + method :meth:`~shapely.geometry.base.BaseGeometry.representative_point`. + + >>> geometries = [shapely.geometry.Point(0, 0).buffer(1), shapely.geometry.Point(1, 1).buffer(1)] + >>> my_region_data = RegionData(label='My Regions', boundary=geometries) + + This will create a RegionData object with three components: one ExtendedComponent + with label "geo" and two regular Components with labels "Center [x] for boundary" + and "Center [y] for boundary". + + The second is to explicitly create an ExtendedComponent (which requires passing + in the ComponentIDs for the center coordinates) and then use `add_component` to + add this component to a RegionData object. You might use this point if your + dataset already contains points that represent the centers of your regions and + you want to avoid re-calculating them. For example: + + >>> center_x = [0, 1] + >>> center_y = [0, 1] + >>> geometries = [shapely.geometry.Point(0, 0).buffer(1), shapely.geometry.Point(1, 1).buffer(1)] + + >>> my_region_data = RegionData(label='My Regions') + >>> # Region IDs are created and returned when we add a Component to a Data object + >>> cen_x_id = my_region_data.add_component(center_x, label='Center [x]') + >>> cen_y_id = my_region_data.add_component(center_y, label='Center [y]') + >>> extended_comp = ExtendedComponent(geometries, center_comp_ids=[cen_x_id, cen_y_id]) + >>> my_region_data.add_component(extended_comp, label='boundaries') + + """ + + def __init__(self, label="", coords=None, **kwargs): + self._extended_component_id = None + self._center_x_id = None + self._center_y_id = None + # __init__ calls add_component which deals with ExtendedComponent logic + super().__init__(label=label, coords=coords, **kwargs) + + def __repr__(self): + return f'RegionData (label: {self.label} | extended_component: {self.extended_component_id})' + + @property + def center_x_id(self): + return self.get_component(self.extended_component_id).x + + @property + def center_y_id(self): + return self.get_component(self.extended_component_id).y + + @property + def extended_component_id(self): + return self._extended_component_id + + @contract(component='component_like', label='cid_like') + def add_component(self, component, label): + """ Add a new component to this data set, allowing only one ExtendedComponent + + If component is an array of Shapely objects then we use + :meth:`~shapely.geometry.base.BaseGeometry.representative_point` to + create two new components for the center coordinates of the regions and + add them to the RegionData object as well. + + If component is an ExtendedComponent, then we simply add it to the + RegionData object. + + We do this here instead of extending `Component.autotyped` because + we only want to use :class:`~glue.core.component.ExtendedComponent` objects + in the context of a :class:`~glue.core.data.RegionData` object. + + Parameters + ---------- + component : :class:`~glue.core.component.Component` or array-like + Object to add. If this is an array of Shapely objects, then we + create two new components for the center coordinates of the regions + as well. + label : `str` or :class:`~glue.core.component_id.ComponentID` + The label. If this is a string, a new + :class:`glue.core.component_id.ComponentID` + with this label will be created and associated with the Component. + + Raises + ------ + `ValueError`, if the RegionData already has an extended component + """ + + if not isinstance(component, Component): + if all(isinstance(s, shapely.Geometry) for s in component): + center_x = [] + center_y = [] + for s in component: + rep = s.representative_point() + center_x.append(rep.x) + center_y.append(rep.y) + cen_x_id = super().add_component(np.asarray(center_x), f"Center [x] for {label}") + cen_y_id = super().add_component(np.asarray(center_y), f"Center [y] for {label}") + ext_component = ExtendedComponent(np.asarray(component), center_comp_ids=[cen_x_id, cen_y_id]) + self._extended_component_id = super().add_component(ext_component, label) + return self._extended_component_id + + if isinstance(component, ExtendedComponent): + if self.extended_component_id is not None: + raise ValueError(f"Cannot add another ExtendedComponent; existing extended component: {self.extended_component_id}") + else: + self._extended_component_id = super().add_component(component, label) + return self._extended_component_id + else: + return super().add_component(component, label) diff --git a/glue/core/state.py b/glue/core/state.py index 05558b641..ff38befbd 100644 --- a/glue/core/state.py +++ b/glue/core/state.py @@ -66,12 +66,14 @@ def load(rec, context) from matplotlib.colors import Colormap from matplotlib import cm from astropy.wcs import WCS +import shapely from glue import core -from glue.core.data import Data +from glue.core.data import Data, RegionData from glue.core.component_id import ComponentID, PixelComponentID from glue.core.component import (Component, CategoricalComponent, - DerivedComponent, CoordinateComponent) + DerivedComponent, CoordinateComponent, + ExtendedComponent) from glue.core.subset import (OPSYM, SYMOP, CompositeSubsetState, SubsetState, Subset, RoiSubsetState, InequalitySubsetState, RangeSubsetState) @@ -1228,6 +1230,16 @@ def _load_datetime64(rec, context): return np.datetime64(rec['datetime64']) +@saver(shapely.Geometry) +def _save_shapelygeometry(shape, context): + return {'shapelygeometry': str(shapely.to_wkt(shape))} + + +@loader(shapely.Geometry) +def _load_shapelygeometry(rec, context): + return shapely.from_wkt(rec['shapelygeometry']) + + def apply_inplace_patches(rec): """ Apply in-place patches to a loaded session file. Ideally this should be @@ -1263,3 +1275,170 @@ def apply_inplace_patches(rec): contents = state['contents'] if 'st__coords' not in contents: contents['st__coords'] = ['x'] + + +@saver(RegionData, version=1) +def _save_regiondata(data, context): + result = dict( + components=[ + (context.id(c), context.id(data.get_component(c))) for c in data._components + ], + subsets=[context.id(s) for s in data.subsets], + label=data.label, + ) + + if data.coords is not None: + result["coords"] = context.id(data.coords) + + if data._extended_component_id is not None: + result["_extended_component_id"] = context.id(data._extended_component_id) + + result["style"] = context.do(data.style) + + def save_cid_tuple(cids): + return tuple(context.id(cid) for cid in cids) + + result["_key_joins"] = [ + [context.id(k), save_cid_tuple(v0), save_cid_tuple(v1)] + for k, (v0, v1) in data._key_joins.items() + ] + result["uuid"] = data.uuid + + result["primary_owner"] = [ + context.id(cid) for cid in data.components if cid.parent is data + ] + # Filter out keys/values that can't be serialized + meta_filtered = OrderedDict() + for key, value in data.meta.items(): + try: + context.do(key) + context.do(value) + except GlueSerializeError: + continue + else: + meta_filtered[key] = value + result["meta"] = context.do(meta_filtered) + + return result + + +@loader(RegionData, version=1) +def _load_regiondata(rec, context): + """ + Custom load function for RegionData. + This is the same as the chain of logic in + _save_data_5 for Data, but result is an RegionData object + instead. + """ + + label = rec["label"] + result = RegionData(label=label) + + # we manually rebuild pixel/world components, so + # we override this function. This is pretty ugly + result._create_pixel_and_world_components = lambda ndim: None + + comps = [list(map(context.object, [cid, comp])) for cid, comp in rec["components"]] + + for icomp, (cid, comp) in enumerate(comps): + if isinstance(comp, CoordinateComponent): + comp._data = result + + # For backward compatibility, we need to check for cases where + # the component ID for the pixel components was not a PixelComponentID + # and upgrade it to one. This can be removed once we no longer + # support pre-v0.8 session files. + if not comp.world and not isinstance(cid, PixelComponentID): + cid = PixelComponentID(comp.axis, cid.label, parent=cid.parent) + comps[icomp] = (cid, comp) + + result.add_component(comp, cid) + + assert result._world_component_ids == [] + + coord = [c for c in comps if isinstance(c[1], CoordinateComponent)] + coord = [x[0] for x in sorted(coord, key=lambda x: x[1])] + + if getattr(result, "coords") is not None: + assert len(coord) == result.ndim * 2 + # Might black formatting break this? + result._world_component_ids = coord[: len(coord) // 2] + result._pixel_component_ids = coord[len(coord) // 2 :] # noqa E203 + else: + assert len(coord) == result.ndim + result._pixel_component_ids = coord + + # We can now re-generate the coordinate links + result._set_up_coordinate_component_links(result.ndim) + + for s in rec["subsets"]: + result.add_subset(context.object(s)) + + result.style = context.object(rec["style"]) + + if "primary_owner" in rec: + for cid in rec["primary_owner"]: + cid = context.object(cid) + cid.parent = result + yield result + + def load_cid_tuple(cids): + return tuple(context.object(cid) for cid in cids) + + result._key_joins = dict( + (context.object(k), (load_cid_tuple(v0), load_cid_tuple(v1))) + for k, v0, v1 in rec["_key_joins"] + ) + if "uuid" in rec and rec["uuid"] is not None: + result.uuid = rec["uuid"] + else: + result.uuid = str(uuid.uuid4()) + if "meta" in rec: + result.meta.update(context.object(rec["meta"])) + + result._extended_component_id = context.object(rec["_extended_component_id"]) + + def fix_special_component_ids(ext_data): + """ + We need to update the .x and .y attributes on the extended component + to be the actual component IDs in the data object. This is a fragile + way to do it because it assumes that the component labels are + unique. + """ + ext_comp = ext_data.get_component(ext_data.extended_component_id) + old_x = ext_comp.x + old_y = ext_comp.y + + for comp_id in ext_data.component_ids(): + if comp_id.label == old_x.label: + new_x = comp_id + elif comp_id.label == old_y.label: + new_y = comp_id + ext_comp.x = new_x + ext_comp.y = new_y + yield fix_special_component_ids(result) + + +@saver(ExtendedComponent) +def _save_extended_component(component, context): + if not context.include_data and hasattr(component, "_load_log"): + log = component._load_log + return dict(log=context.id(log), log_item=log.id(component)) + + data_to_save = [x for x in component.data] + + return dict(data=context.do(data_to_save), + x=context.do(component.x), + y=context.do(component.y), + units=component.units) + + +@loader(ExtendedComponent) +def _load_extended_component(rec, context): + if "log" in rec: + return context.object(rec["log"]).component(rec["log_item"]) + + data_to_load = np.asarray([x for x in context.object(rec["data"])]) + return ExtendedComponent(data=data_to_load, + center_comp_ids=[context.object(rec['x']), context.object(rec['y'])], + units=rec["units"]) diff --git a/glue/core/tests/test_component.py b/glue/core/tests/test_component.py index 7448e6597..a1924eece 100644 --- a/glue/core/tests/test_component.py +++ b/glue/core/tests/test_component.py @@ -7,13 +7,14 @@ from unittest.mock import MagicMock from astropy.wcs import WCS +from shapely.geometry import MultiPolygon, Polygon, Point, LineString from glue import core from glue.tests.helpers import requires_astropy from ..coordinates import Coordinates from ..component import (Component, DerivedComponent, CoordinateComponent, - CategoricalComponent) + CategoricalComponent, ExtendedComponent) from ..component_id import ComponentID from ..data import Data from ..parse import ParsedCommand, ParsedComponentLink @@ -403,3 +404,52 @@ def test_coordinate_component_1d_coord(): data = Data(flux=np.random.random(5), coords=wcs, label='data') np.testing.assert_equal(data['Frequency'], [1, 2, 3, 4, 5]) + + +class TestExtendedComponent(object): + + def setup_method(self): + + self.cen_x_id = ComponentID('x') + self.cen_y_id = ComponentID('y') + + poly_1 = Polygon([(20, 20), (60, 20), (60, 40), (20, 40)]) + poly_2 = Polygon([(60, 50), (60, 70), (80, 70), (80, 50)]) + poly_3 = Polygon([(10, 10), (15, 10), (15, 15), (10, 15)]) + poly_4 = Polygon([(10, 20), (15, 20), (15, 30), (10, 30), (12, 25)]) + + polygons = MultiPolygon([poly_3, poly_4]) + self.polys = np.array([poly_1, poly_2, polygons]) + self.poly2d = ExtendedComponent(self.polys, center_comp_ids=[self.cen_x_id, self.cen_y_id]) + + circle_1 = Point(1.0, 0.0).buffer(1) + circle_2 = Point(2.0, 3.0).buffer(2) + circles = np.array([circle_1, circle_2]) + self.circles = ExtendedComponent(circles, center_comp_ids=[self.cen_x_id, self.cen_y_id]) + + ranges = np.array([LineString([(0, 0), (1, 0)]), LineString([(0, 0), (4, 0)])]) + self.ranges = ExtendedComponent(ranges, center_comp_ids=[self.cen_x_id]) + + def test_basic_proprties(self): + assert self.poly2d.ndim == 1 + assert isinstance(self.poly2d, ExtendedComponent) + assert self.poly2d.shape == (3,) + assert self.poly2d.x == self.cen_x_id + assert self.poly2d.y == self.cen_y_id + + assert self.ranges.ndim == 1 + assert isinstance(self.ranges, ExtendedComponent) + assert self.ranges.shape == (2,) + assert self.ranges.x == self.cen_x_id + assert self.ranges.y is None + + def test_incorrect_inputs(self): + with pytest.raises(TypeError, match='Input data for a ExtendedComponent should be a list of shapely.Geometry objects'): + bad_data = np.array([1, 2, 3]) + bad_data_comp = ExtendedComponent(bad_data, center_comp_ids=[self.cen_x_id, self.cen_y_id]) + + with pytest.raises(ValueError, match='ExtendedComponent must be initialized with one or two ComponentIDs'): + no_center_ids_comp = ExtendedComponent(self.polys, center_comp_ids=[]) + + with pytest.raises(ValueError, match='ExtendedComponent must be initialized with one or two ComponentIDs'): + no_center_ids_comp = ExtendedComponent(self.polys, center_comp_ids=[self.cen_x_id, self.cen_y_id, self.cen_x_id]) diff --git a/glue/core/tests/test_regiondata.py b/glue/core/tests/test_regiondata.py new file mode 100644 index 000000000..fa9e962f8 --- /dev/null +++ b/glue/core/tests/test_regiondata.py @@ -0,0 +1,109 @@ +import pytest +from numpy.testing import assert_array_equal + +import numpy as np +from shapely.geometry import MultiPolygon, Polygon, Point + +from glue.core.data import Data, RegionData +from glue.core.component import ExtendedComponent +from glue.core.state import GlueUnSerializer +from glue.core.tests.test_application_base import MockApplication +from glue.core.link_helpers import LinkSame + +poly_1 = Polygon([(20, 20), (60, 20), (60, 40), (20, 40)]) +poly_2 = Polygon([(60, 50), (60, 70), (80, 70), (80, 50)]) +poly_3 = Polygon([(10, 10), (15, 10), (15, 15), (10, 15)]) +poly_4 = Polygon([(10, 20), (15, 20), (15, 30), (10, 30), (12, 25)]) + +polygons = MultiPolygon([poly_3, poly_4]) +SHAPELY_POLYGON_ARRAY = np.array([poly_1, poly_2, polygons]) + +SHAPELY_CIRCLE_ARRAY = np.array([Point(0, 0).buffer(1), Point(1, 1).buffer(1)]) +CENTER_X = [0, 1] +CENTER_Y = [0, 1] + + +class TestRegionData(object): + + def setup_method(self): + self.region_data = RegionData(label='My Regions', boundary=SHAPELY_POLYGON_ARRAY) + + self.manual_region_data = RegionData(label='My Manual Regions') + + self.cen_x_id = self.manual_region_data.add_component(CENTER_X, label='Center [x]') + self.cen_y_id = self.manual_region_data.add_component(CENTER_Y, label='Center [y]') + self.extended_comp = ExtendedComponent(SHAPELY_CIRCLE_ARRAY, center_comp_ids=[self.cen_x_id, self.cen_y_id]) + self.manual_region_data.add_component(self.extended_comp, label='circles') + + def test_basic_properties_simple(self): + assert self.region_data.label == 'My Regions' + assert self.region_data.shape == SHAPELY_POLYGON_ARRAY.shape + assert self.region_data.ndim == 1 + assert self.region_data.size == 3 + assert_array_equal(self.region_data['boundary'], SHAPELY_POLYGON_ARRAY) + assert len(self.region_data.main_components) == 3 + component_labels = [cid.label for cid in self.region_data.main_components] + assert 'boundary' in component_labels + assert 'Center [x] for boundary' in component_labels + assert 'Center [y] for boundary' in component_labels + + def test_basic_properties_manual(self): + assert self.manual_region_data.label == 'My Manual Regions' + assert self.manual_region_data.shape == np.asarray(SHAPELY_CIRCLE_ARRAY).shape + assert self.manual_region_data.ndim == 1 + assert self.manual_region_data.size == 2 + assert_array_equal(self.manual_region_data['circles'], SHAPELY_CIRCLE_ARRAY) + assert len(self.region_data.main_components) == 3 + component_labels = [cid.label for cid in self.manual_region_data.main_components] + assert 'circles' in component_labels + assert 'Center [x]' in component_labels + assert 'Center [y]' in component_labels + + +class TestRegionDataSaveRestore(object): + + @pytest.fixture(autouse=True) + def setup_method(self, tmpdir): + app = MockApplication() + geodata = RegionData(label='My Regions', regions=SHAPELY_POLYGON_ARRAY) + catdata = Data(label='catdata', x=np.array([1, 2, 3, 4]), y=np.array([10, 20, 30, 40])) + app.data_collection.append(geodata) + app.data_collection.append(catdata) + + app.data_collection.add_link(LinkSame(geodata.id['Center [x] for regions'], catdata.id['x'])) + app.data_collection.add_link(LinkSame(geodata.id['Center [y] for regions'], catdata.id['y'])) + + session_file = tmpdir.mkdir("session").join('test.glu') + app.save_session(session_file) + + with open(session_file, "r") as f: + session = f.read() + + state = GlueUnSerializer.loads(session) + ga = state.object("__main__") + dc = ga.session.data_collection + + self.reg_before = app.data_collection[0] + self.cat_before = app.data_collection[1] + + self.reg_after = dc[0] + self.cat_after = dc[1] + + def test_data_roundtrip(self): + assert_array_equal(self.reg_before['regions'], self.reg_after['regions']) + assert_array_equal(self.cat_before['x'], self.cat_after['x']) + assert_array_equal(self.cat_before['y'], self.cat_after['y']) + + def test_component_ids_are_restored_correctly(self): + for data in [self.reg_before, self.reg_after]: + assert data.extended_component_id == data.id['regions'] + assert data.extended_component_id == data.id['regions'] + + assert data.components[1] == data.get_component(data.components[3]).x + assert data.components[2] == data.get_component(data.components[3]).y + + def test_links_still_work(self): + for data in [(self.reg_before, self.cat_before), (self.reg_after, self.cat_after)]: + reg_data, cat_data = data + assert_array_equal(reg_data[reg_data.get_component(reg_data.extended_component_id).x], cat_data.id['x']) + assert_array_equal(reg_data[reg_data.get_component(reg_data.extended_component_id).y], cat_data.id['y']) diff --git a/setup.cfg b/setup.cfg index c94696d1b..d64186bd0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,6 +43,7 @@ install_requires = # warning. Once the deprecation phase is over, we can remove this # dependency as well as all the compatibility imports. glue-qt>=0.1.0 + shapely>=2.0 [options.entry_points] glue.plugins =