diff --git a/carta/constants.py b/carta/constants.py index 12e72d0..b5d0fbd 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -216,6 +216,89 @@ class GridMode(StrEnum): FIXED = "fixed" +class FileType(IntEnum): + """File types corresponding to the protobuf enum.""" + CASA = 0 + CRTF = 1 + DS9_REG = 2 + FITS = 3 + HDF5 = 4 + MIRIAD = 5 + UNKNOWN = 6 + + +class RegionType(IntEnum): + """Region types corresponding to the protobuf enum.""" + + def __init__(self, value): + self.is_annotation = self.name.startswith("ANN") + self.label = f"{self.name[3:].title()} - Ann" if self.is_annotation else self.name.title() + + POINT = 0 + LINE = 1 + POLYLINE = 2 + RECTANGLE = 3 + ELLIPSE = 4 + # ANNULUS = 5 is not actually implemented + POLYGON = 6 + ANNPOINT = 7 + ANNLINE = 8 + ANNPOLYLINE = 9 + ANNRECTANGLE = 10 + ANNELLIPSE = 11 + ANNPOLYGON = 12 + ANNVECTOR = 13 + ANNRULER = 14 + ANNTEXT = 15 + ANNCOMPASS = 16 + + +class CoordinateType(IntEnum): + """Coordinate types corresponding to the protobuf enum.""" + PIXEL = 0 + WORLD = 1 + + +class PointShape(IntEnum): + """Point annotation shapes corresponding to the protobuf enum.""" + SQUARE = 0 + BOX = 1 + CIRCLE = 2 + CIRCLE_LINED = 3 + DIAMOND = 4 + DIAMOND_LINED = 5 + CROSS = 6 + X = 7 + + +class TextPosition(IntEnum): + """Text annotation positions corresponding to the protobuf enum.""" + CENTER = 0 + UPPER_LEFT = 1 + UPPER_RIGHT = 2 + LOWER_LEFT = 3 + LOWER_RIGHT = 4 + TOP = 5 + BOTTOM = 6 + LEFT = 7 + RIGHT = 8 + + +class AnnotationFontStyle(StrEnum): + """Font styles which may be used in annotations.""" + NORMAL = "Normal" + BOLD = "Bold" + ITALIC = "Italic" + BOLD_ITALIC = "Italic Bold" + + +class AnnotationFont(StrEnum): + """Fonts which may be used in annotations.""" + HELVETICA = "Helvetica" + TIMES = "Times" + COURIER = "Courier" + + class FontFamily(IntEnum): """Font family used in WCS overlay components.""" SANS_SERIF = 0 diff --git a/carta/image.py b/carta/image.py index 0567520..d80c696 100644 --- a/carta/image.py +++ b/carta/image.py @@ -3,16 +3,18 @@ Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ + from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit -from .util import Macro, cached, BasePathMixin +from .util import Macro, cached, BasePathMixin, Point as Pt from .units import AngularSize, WorldCoordinate -from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr +from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr, IterableOf, Point from .metadata import parse_header from .raster import Raster from .contours import Contours from .vector_overlay import VectorOverlay from .wcs_overlay import ImageWCSOverlay +from .region import RegionSet class Image(BasePathMixin): @@ -41,6 +43,8 @@ class Image(BasePathMixin): Sub-object with functions related to the vector overlay. wcs : :obj:`carta.wcs_overlay.ImageWCSOverlay` Sub-object with functions related to the WCS overlay. + regions : :obj:`carta.region.RegionSet` object + Functions for manipulating regions associated with this image. """ def __init__(self, session, image_id): @@ -55,6 +59,7 @@ def __init__(self, session, image_id): self.contours = Contours(self) self.vectors = VectorOverlay(self) self.wcs = ImageWCSOverlay(self) + self.regions = RegionSet(self) @classmethod def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_active=True, update_directory=False): @@ -530,6 +535,113 @@ def set_spectral_coordinate(self, spectral_type, spectral_unit=None): spectral_coordinate_string = description if spectral_unit is None else f"{description} ({spectral_unit})" self.call_action("setSpectralCoordinate", spectral_coordinate_string) + # COORDINATE AND SIZE CONVERSIONS + + @validate(IterableOf(Point.WorldCoordinatePoint())) + def from_world_coordinate_points(self, points): + """Convert world coordinate points to image coordinate points. + + The points must have string values which can be parsed as world coordinates using the current globally set coordinate system (and any custom number formats). + + Parameters + ---------- + points : {0} + Points with string values which are valid world coordinates. + + Returns + ------- + iterable of numeric points + Points with numeric values which are image coordinates. + """ + points = [Pt(*p) for p in points] + converted_points = self.call_action("getImagePosFromWCS", points) + return [Pt(**p).as_tuple() for p in converted_points] + + @validate(IterableOf(Point.NumericPoint())) + def to_world_coordinate_points(self, points): + """Convert image coordinate points to world coordinate points. + + The points must be numeric. + + Parameters + ---------- + points : {0} + Points with numeric values which are valid image coordinates. + + Returns + ------- + iterable of string coordinate points + Points with string values which are world coordinates. + """ + points = [Pt(*p) for p in points] + converted_points = self.call_action("getWCSFromImagePos", points) + return [Pt(**p).as_tuple() for p in converted_points] + + @validate(Size.AngularSize(), Constant(SpatialAxis)) + def from_angular_size(self, size, axis): + """Convert angular size to pixel size. + + Parameters + ---------- + size : {0} + The angular size. + axis : {1} + The axis. + + Returns + ------- + float + The pixel size. + """ + arcsec = AngularSize.from_string(size).arcsec + if axis == SpatialAxis.X: + return self.call_action("getImageXValueFromArcsec", arcsec) + if axis == SpatialAxis.Y: + return self.call_action("getImageYValueFromArcsec", arcsec) + + @validate(IterableOf(Point.AngularSizePoint())) + def from_angular_size_points(self, points): + """Convert angular size points to pixel size points. + + The points must have string values which can be parsed as angular sizes. + + Parameters + ---------- + points : {0} + Points with string values which are valid angular sizes. + + Returns + ------- + iterable of numeric points + Points with numeric values which are pixel sizes. + """ + converted_points = [] + for x, y in points: + converted_points.append((self.from_angular_size(x, SpatialAxis.X), self.from_angular_size(y, SpatialAxis.Y))) + return converted_points + + @validate(IterableOf(Point.NumericPoint())) + def to_angular_size_points(self, points): + """Convert pixel size points to angular size points. + + The points must be numeric. + + Parameters + ---------- + points : {0} + Points with numeric values which are valid image coordinates. + + Returns + ------- + iterable of angular size points + Points with string values which are angular sizes. + """ + converted_points = [] + for p in points: + converted = self.call_action("getWcsSizeInArcsec", Pt(*p)) + converted_points.append(Pt(**converted).as_tuple()) + return converted_points + # CLOSE def close(self): diff --git a/carta/region.py b/carta/region.py new file mode 100644 index 0000000..7ac65b4 --- /dev/null +++ b/carta/region.py @@ -0,0 +1,2173 @@ +"""This module contains region classes which represent single regions or annotations loaded in the session, and a region set class which represents all regions and annotations associated with an image. + +Region and annotation objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.region.RegionSet` object. +""" + +import posixpath +import math + +from .util import Macro, BasePathMixin, Point as Pt, cached, CartaBadResponse, CartaValidationFailed +from .constants import FileType, RegionType, CoordinateType, PointShape, TextPosition, AnnotationFontStyle, AnnotationFont, SpatialAxis +from .validation import validate, Constant, IterableOf, Number, String, Point, NoneOr, Boolean, OneOf, InstanceOf, MapOf, Color, all_optional, Union, Size +from .units import AngularSize + + +class RegionSet(BasePathMixin): + """Utility object for collecting region-related image functions. + + Parameters + ---------- + image : :obj:`carta.image.Image` object + The image associated with this region set. + + Attributes + ---------- + image : :obj:`carta.image.Image` object + The image associated with this region set. + session : :obj:`carta.session.Session` object + The session object associated with this region set. + """ + + def __init__(self, image): + self.image = image + self.session = image.session + self._base_path = f"{image._base_path}.regionSet" + + @validate(NoneOr(Boolean())) + def list(self, ignore_cursor=True): + """Return the list of regions associated with this image. + + Parameters + ---------- + ignore_cursor : {0} + Ignore the cursor region. This is set by default. + + Returns + ------- + list of :obj:`carta.region.Region` objects. + """ + region_list = self.get_value("regionList") + if ignore_cursor: + region_list = region_list[1:] + return Region.from_list(self, region_list) + + @validate(Number()) + def get(self, region_id): + """Return the region with the given region ID. + + Parameters + ---------- + region_id : {0} + The region ID. + + Returns + ------- + :obj:`carta.region.Region` object + The region with the given ID. + + Raises + ------ + ValueError + If there is no region with the given ID. + """ + try: + region_type = self.get_value(f"regionMap[{region_id}]", return_path="regionType") + except CartaBadResponse: + raise ValueError(f"Could not find region with ID {region_id}.") + return Region.existing(region_type, self, region_id) + + @validate(String()) + def import_from(self, path): + """Import regions into this image from a file. + + Parameters + ---------- + path : {0} + The path to the region file, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + + Raises + ------ + CartaActionFailed + If the file does not exist or is not a region file. + """ + directory, file_name = posixpath.split(path) + directory = self.session.resolve_file_path(directory) + + file_type = FileType(self.session.call_action("backendService.getRegionFileInfo", directory, file_name, return_path="fileInfo.type")) + + self.session.call_action("importRegion", directory, file_name, file_type, self.image._frame) + + @validate(String(), Constant(CoordinateType), OneOf(FileType.CRTF, FileType.DS9_REG), NoneOr(IterableOf(Number()))) + def export_to(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileType.CRTF, region_ids=None): + """Export regions from this image into a file. + + Parameters + ---------- + path : {0} + The path where the file should be saved, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + coordinate_type : {1} + The coordinate type to use (world coordinates by default). + file_type : {2} + The region file type to use (CRTF by default). + region_ids : {3} + The region IDs to include. By default all regions will be included (except the cursor). + """ + directory, file_name = posixpath.split(path) + directory = self.session.resolve_file_path(directory) + + if region_ids is None: + region_ids = [r["id"] for r in self.get_value("regionList")[1:]] + + self.session.call_action("exportRegions", directory, file_name, coordinate_type, file_type, region_ids, self.image._frame) + + @validate(Constant(RegionType), IterableOf(Point.NumericPoint()), Number(), String()) + def add_region(self, region_type, points, rotation=0, name=""): + """Add a new region or annotation to this image. + + This is a generic low-level function. Also see the higher-level functions for adding regions of specific types, such as :obj:`carta.region.RegionSet.add_point`. + + Parameters + ---------- + region_type : {0} + The type of the region. + points : {1} + The control points defining the region, in image coordinates. How these values are interpreted depends on the region type. + rotation : {2} + The rotation of the region, in degrees. Defaults to zero. + name : {3} + The name of the region. Defaults to the empty string. + """ + return Region.new(self, region_type, points, rotation, name) + + def _from_world_coordinates(self, points): + """Internal utility function for coercing world or image coordinates to image coordinates. This is used in various region functions to simplify accepting both world and image coordinates. + + The points provided must either all be world coordinates or all be image coordinates. This can be enforced with the appropriate validation on the calling method. + + If the points provided are world coordinates, the method on the image object is called successfully and returns the points transformed into image coordinates, which this method returns. + + If the points provided are image coordinates, the type validation on the image method fails. This exception is caught silently by this method, and the unmodified points are returned. + + See also :obj:`carta.region.RegionSet._from_angular_sizes`. + + Parameters + ---------- + points : iterable of points which are all either world or image coordinates + The input points. + + Returns + ------- + iterable of points which are image coordinates + The output points. + """ + try: + points = self.image.from_world_coordinate_points(points) + except CartaValidationFailed: + pass + return points + + def _from_angular_sizes(self, points): + """Internal utility function for coercing angular or pixel sizes to pixel sizes. This is used in various region functions to simplify accepting both angular and pixel sizes. + + The points provided must either all be angular sizes or all be pixel sizes. This can be enforced with the appropriate validation on the calling method. + + If the points provided are angular sizes, the method on the image object is called successfully and returns the points transformed into pixel sizes. + + If the points provided are in pixel sizes, the type validation on the image method fails. This exception is caught silently by this method, and the unmodified points are returned. + + See also :obj:`carta.region.RegionSet._from_world_coordinates`. + + Parameters + ---------- + points : iterable of points which are all either angular or pixel sizes + The input points. + + Returns + ------- + iterable of points which are pixel sizes + The output points. + """ + try: + points = self.image.from_angular_size_points(points) + except CartaValidationFailed: + pass + return points + + @validate(Point.CoordinatePoint(), Boolean(), String()) + def add_point(self, center, annotation=False, name=""): + """Add a new point region or point annotation to this image. + + Parameters + ---------- + center : {0} + The center position. + annotation : {1} + Whether the region should be an annotation. Defaults to ``False``. + name : {2} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.Region` or :obj:`carta.region.PointAnnotation` object + A new region object. + """ + [center] = self._from_world_coordinates([center]) + region_type = RegionType.ANNPOINT if annotation else RegionType.POINT + return self.add_region(region_type, [center], name=name) + + @validate(Point.CoordinatePoint(), Point.SizePoint(), Number(), Boolean(), String()) + def add_rectangle(self, center, size, rotation=0, annotation=False, name=""): + """Add a new rectangular region or rectangular annotation to this image with the center point and size provided. + + Parameters + ---------- + center : {0} + The center position. + size : {1} + The size. The two values will be interpreted as the width and height, respectively. + rotation : {2} + The rotation, in degrees. Defaults to zero. + annotation : {3} + Whether this region should be an annotation. Defaults to ``False``. + name : {4} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.RectangularRegion` object + A new region object. + """ + [center] = self._from_world_coordinates([center]) + [size] = self._from_angular_sizes([size]) + region_type = RegionType.ANNRECTANGLE if annotation else RegionType.RECTANGLE + return self.add_region(region_type, [center, size], rotation, name) + + @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), Number(), Boolean(), String()) + def add_rectangle_from_corners(self, bottom_left, top_right, rotation=0, annotation=False, name=""): + """Add a new rectangular region or rectangular annotation to this image with the bottom-left and top-right corners provided. + + Parameters + ---------- + bottom_left : {0} + The bottom-left corner position. + top_right : {1} + The top-right corner position. + rotation : {2} + The rotation, in degrees. Defaults to zero. + annotation : {3} + Whether this region should be an annotation. Defaults to ``False``. + name : {4} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.RectangularRegion` object + A new region object. + """ + [bottom_left] = self._from_world_coordinates([bottom_left]) + [top_right] = self._from_world_coordinates([top_right]) + center, size = RectangularRegion._center_size_from_corners(bottom_left, top_right) + return self.add_rectangle(center, size, rotation, annotation, name) + + @validate(Point.CoordinatePoint(), Point.SizePoint(), Number(), Boolean(), String()) + def add_ellipse(self, center, semi_axes, rotation=0, annotation=False, name=""): + """Add a new elliptical region or elliptical annotation to this image with the center point and semi-axes provided. + + Parameters + ---------- + center : {0} + The center position. + semi_axes : {1} + The semi-axes. The two values will be interpreted as the north-south and east-west axes, respectively. + rotation : {2} + The rotation, in degrees. Defaults to zero. + annotation : {3} + Whether this region should be an annotation. Defaults to ``False``. + name : {4} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.EllipticalRegion` object + A new region object. + """ + [center] = self._from_world_coordinates([center]) + [semi_axes] = self._from_angular_sizes([semi_axes]) + + region_type = RegionType.ANNELLIPSE if annotation else RegionType.ELLIPSE + return self.add_region(region_type, [center, semi_axes], rotation, name) + + @validate(Point.CoordinatePoint(), Point.SizePoint(), Number(), Boolean(), String()) + def add_ellipse_from_size(self, center, size, rotation=0, annotation=False, name=""): + """Add a new elliptical region or elliptical annotation to this image with the center point and size provided. + + The width and height will be used to calculate the semi-axes: the north-south semi-axis is equal to half of the height, and the east-west semi-axis is equal to half of the width. + + Parameters + ---------- + center : {0} + The center position. + size : {1} + The size. The two values will be interpreted as the width and height, respectively. + rotation : {2} + The rotation, in degrees. Defaults to zero. + annotation : {3} + Whether this region should be an annotation. Defaults to ``False``. + name : {4} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.EllipticalRegion` object + A new region object. + """ + [size] = self._from_angular_sizes([size]) + width, height = size + semi_axes = height / 2, width / 2 + return self.add_ellipse(center, semi_axes, rotation, annotation, name) + + @validate(Point.CoordinatePoint(), Point.SizePoint(), Number(), Boolean(), String()) + def add_ellipse_from_corners(self, bottom_left, top_right, rotation=0, annotation=False, name=""): + """Add a new elliptical region or elliptical annotation to this image with the bottom-left and top-right corners provided. + + Parameters + ---------- + bottom_left : {0} + The bottom-left corner position. + top_right : {1} + The top-right corner position. + rotation : {2} + The rotation, in degrees. Defaults to zero. + annotation : {3} + Whether this region should be an annotation. Defaults to ``False``. + name : {4} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.EllipticalRegion` object + A new region object. + """ + [bottom_left] = self._from_world_coordinates([bottom_left]) + [top_right] = self._from_world_coordinates([top_right]) + center, size = RectangularRegion._center_size_from_corners(bottom_left, top_right) + return self.add_ellipse_from_size(center, size, rotation, annotation, name) + + @validate(Union(IterableOf(Point.NumericPoint(), 3), IterableOf(Point.WorldCoordinatePoint(), 3)), Boolean(), String()) + def add_polygon(self, points, annotation=False, name=""): + """Add a new polygonal region or polygonal annotation to this image. + + Parameters + ---------- + points : {0} + The positions of the vertices, either all in world coordinates or all in image coordinates. + annotation : {1} + Whether this region should be an annotation. Defaults to ``False``. + name : {2} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.PolygonRegion` object + A new region object. + """ + points = self._from_world_coordinates(points) + region_type = RegionType.ANNPOLYGON if annotation else RegionType.POLYGON + return self.add_region(region_type, points, name=name) + + @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), Boolean(), String()) + def add_line(self, start, end, annotation=False, name=""): + """Add a new line region or line annotation to this image. + + Parameters + ---------- + start : {0} + The start position. + end : {1} + The end position. + annotation : {2} + Whether this region should be an annotation. Defaults to ``False``. + name : {3} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.LineRegion` object + A new region object. + """ + [start, end] = self._from_world_coordinates([start, end]) + region_type = RegionType.ANNLINE if annotation else RegionType.LINE + return self.add_region(region_type, [start, end], name=name) + + @validate(Union(IterableOf(Point.NumericPoint(), 3), IterableOf(Point.WorldCoordinatePoint(), 3)), Boolean(), String()) + def add_polyline(self, points, annotation=False, name=""): + """Add a new polyline region or polyline annotation to this image. + + Parameters + ---------- + points : {0} + The positions of the vertices, either all in world coordinates or all in image coordinates. + annotation : {1} + Whether this region should be an annotation. Defaults to ``False``. + name : {2} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.PolylineRegion` object + A new region object. + """ + points = self._from_world_coordinates(points) + region_type = RegionType.ANNPOLYLINE if annotation else RegionType.POLYLINE + return self.add_region(region_type, points, name=name) + + @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), String()) + def add_vector(self, start, end, name=""): + """Add a new vector annotation to this image. + + Parameters + ---------- + start : {0} + The start position. + end : {1} + The end position. + name : {2} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.VectorAnnotation` object + A new region object. + """ + [start] = self._from_world_coordinates([start]) # Parsed separately in case they are mismatched + [end] = self._from_world_coordinates([end]) # Parsed separately in case they are mismatched + return self.add_region(RegionType.ANNVECTOR, [start, end], name=name) + + @validate(Point.CoordinatePoint(), Point.SizePoint(), String(), Number(), String()) + def add_text(self, center, size, text, rotation=0, name=""): + """Add a new text annotation to this image. + + Parameters + ---------- + center : {0} + The center position. + size : {1} + The size. The two values will be interpreted as the width and height, respectively. + text : {2} + The text content to display. + rotation : {3} + The rotation, in degrees. Defaults to zero. + name : {4} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.TextAnnotation` object + A new region object. + """ + [center] = self._from_world_coordinates([center]) + [size] = self._from_angular_sizes([size]) + region = self.add_region(RegionType.ANNTEXT, [center, size], rotation, name) + region.set_text(text) + return region + + @validate(Point.CoordinatePoint(), Number(), String()) + def add_compass(self, center, length, name=""): + """Add a new compass annotation to this image. + + Parameters + ---------- + center : {0} + The origin position of the compass. + length : {1} + The length of the compass points, in pixels. + name : {2} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.CompassAnnotation` object + A new region object. + """ + [center] = self._from_world_coordinates([center]) + return self.add_region(RegionType.ANNCOMPASS, [center, (length, length)], name=name) + + @validate(Point.CoordinatePoint(), Point.CoordinatePoint(), String()) + def add_ruler(self, start, end, name=""): + """Add a new ruler annotation to this image. + + Parameters + ---------- + start : {0} + The start position. + end : {1} + The end position. + name : {2} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.RulerAnnotation` object + A new region object. + """ + [start] = self._from_world_coordinates([start]) # Parsed separately in case they are mismatched + [end] = self._from_world_coordinates([end]) # Parsed separately in case they are mismatched + return self.add_region(RegionType.ANNRULER, [start, end], name=name) + + def clear(self): + """Delete all regions except for the cursor region.""" + for region in self.list(): + region.delete() + + +class Region(BasePathMixin): + """Utility object which provides access to one region associated with an image. + + # TODO find out what happens to region IDs when you match/unmatch or delete. + + Parameters + ---------- + region_set : :obj:`carta.region.RegionSet` object + The region set containing this region. + region_id : integer + The ID of this region. + + Attributes + ---------- + region_set : :obj:`carta.region.RegionSet` object + The region set containing this region. + region_id : integer + The ID of this region. + session : :obj:`carta.session.Session` object + The session object associated with this region. + """ + + CUSTOM_CLASS = {} + """Mapping of :obj:`carta.constants.RegionType` types to region and annotation classes. This mapping is used to select the appropriate subclass when a region or annotation object is constructed in the wrapper.""" + + def __init_subclass__(cls, **kwargs): + """Automatically register subclasses in mapping from region types to classes.""" + super().__init_subclass__(**kwargs) + + for t in cls.REGION_TYPES: + Region.CUSTOM_CLASS[t] = cls + + def __init__(self, region_set, region_id): + self.region_set = region_set + self.session = region_set.session + self.region_id = region_id + + self._base_path = f"{region_set._base_path}.regionMap[{region_id}]" + self._region = Macro("", self._base_path) + + def __repr__(self): + """A human-readable representation of this region.""" + return f"{self.region_id}:{self.region_type.label}" + + # CREATE OR CONNECT + + @classmethod + @validate(Constant(RegionType)) + def region_class(cls, region_type): + """The region class associated for this type. + + Not every type maps to a specific class; some types have no specific functionality and use the default :obj:`carta.region.Region` class. + + Parameters + ---------- + region_type : {0} + The region type. + + Returns + ------- + class object + The region class. + """ + region_type = RegionType(region_type) + return cls.CUSTOM_CLASS.get(region_type, Region) + + @classmethod + @validate(Constant(RegionType), InstanceOf(RegionSet), Number()) + def existing(cls, region_type, region_set, region_id): + """Create a region object corresponding to an existing region. + + This is an internal helper method which should not be used directly. + + Parameters + ---------- + region_type : {0} + The region type. + region_set : {1} + The region set containing the region. + region_id : {2} + The ID of the region. + + Returns + ------- + :obj:`carta.region.Region` object + The region object. + """ + return cls.region_class(region_type)(region_set, region_id) + + @classmethod + @validate(InstanceOf(RegionSet), Constant(RegionType), IterableOf(Point.NumericPoint()), Number(), String()) + def new(cls, region_set, region_type, points, rotation=0, name=""): + """Create a new region. + + This is an internal helper method which should not be used directly. + + Parameters + ---------- + region_set : {0} + The region set in which to create this region. + region_type : {1} + The region type. + points : {2} + The control points, in pixels. These may be coordinates or sizes; how they are interpreted depends on the region type. + rotation : {3} + The rotation, in degrees. Defaults to zero. + name : {4} + The name. Defaults to the empty string. + + Returns + ------- + :obj:`carta.region.Region` object + The region object. + """ + points = [Pt(*point) for point in points] + region_id = region_set.call_action("addRegionAsync", region_type, points, rotation, name, return_path="regionId") + return cls.existing(region_type, region_set, region_id) + + @classmethod + @validate(InstanceOf(RegionSet), IterableOf(MapOf(String(), Number(), required_keys={"type", "id"}))) + def from_list(cls, region_set, region_list): + """Create region objects corresponding to a list of existing regions in a single region set. + + This is an internal helper method which should not be used directly. + + Parameters + ---------- + region_set : {0} + The region set which contains these regions. + region_list : {1} + A list of dictionaries containing region types and IDs. + + Returns + ------- + iterable of :obj:`carta.region.Region` objects + The region objects. + """ + return [cls.existing(r["type"], region_set, r["id"]) for r in region_list] + + # GET PROPERTIES + + @property + @cached + def region_type(self): + """The region type. + + Returns + ------- + :obj:`carta.constants.RegionType` object + The type. + """ + return RegionType(self.get_value("regionType")) + + @property + def center(self): + """The center position, in image coordinates. + + Returns + ------- + number + The X coordinate of the center position. + number + The Y coordinate of the center position. + """ + return Pt(**self.get_value("center")).as_tuple() + + @property + def wcs_center(self): + """The center position, in world coordinates. + + Returns + ------- + string + The X coordinate of the center position. + string + The Y coordinate of the center position. + """ + [center] = self.region_set.image.to_world_coordinate_points([self.center]) + return center + + @property + def control_points(self): + """The control points. + + Returns + ------- + iterable of tuples of two numbers + The control points, in pixels. + """ + return [Pt(**p).as_tuple() for p in self.get_value("controlPoints")] + + @property + def name(self): + """The name. + + Returns + ------- + string + The name. + """ + return self.get_value("name") + + @property + def color(self): + """The color. + + Returns + ------- + string + The color. + """ + return self.get_value("color") + + # SET PROPERTIES + + @validate(Point.CoordinatePoint()) + def set_center(self, center): + """Set the center position. + + Both image and world coordinates are accepted, but both values must match. + + Parameters + ---------- + center : {0} + The new center position. + """ + [center] = self.region_set._from_world_coordinates([center]) + self.call_action("setCenter", Pt(*center)) + + @validate(Point.SizePoint()) + def translate(self, delta): + """Translate the region by the measurement provided. + + Both pixel and angular measurements are accepted, but both values must match. + + Parameters + ---------- + delta : {0} + The translation distance. + """ + [(cx, cy)] = self.region_set._from_world_coordinates([self.center]) + [(dx, dy)] = self.region_set._from_angular_sizes([delta]) + self.set_center((cx + dx, cy + dy)) + + @validate(Number(), Point.NumericPoint()) + def set_control_point(self, index, point): + """Update the value of a single control point. + + Parameters + ---------- + index : {0} + The index of the control point to update. + point : {1} + The new value for the control point, in pixels. + """ + self.call_action("setControlPoint", index, Pt(*point)) + + @validate(IterableOf(Point.NumericPoint())) + def set_control_points(self, points): + """Update all the control points. + + Parameters + ---------- + points : {0} + The new control points, in pixels. + """ + self.call_action("setControlPoints", [Pt(*p) for p in points]) + + @validate(String()) + def set_name(self, name): + """Set the name. + + Parameters + ---------- + name : {0} + The new name. + """ + self.call_action("setName", name) + + @validate(Color()) + def set_color(self, color): + """Set the color. + + Parameters + ---------- + color : {0} + The new color. + """ + self.call_action("setColor", color) + + def lock(self): + """Lock this region.""" + self.call_action("setLocked", True) + + def unlock(self): + """Unlock this region.""" + self.call_action("setLocked", False) + + def focus(self): + """Center the image view on this region.""" + self.call_action("focusCenter") + + # IMPORT AND EXPORT + + @validate(String(), Constant(CoordinateType), OneOf(FileType.CRTF, FileType.DS9_REG)) + def export_to(self, path, coordinate_type=CoordinateType.WORLD, file_type=FileType.CRTF): + """Export this region into a file. + + Parameters + ---------- + path : {0} + The path where the file should be saved, either relative to the session's current directory or an absolute path relative to the CARTA backend's root directory. + coordinate_type : {1} + The coordinate type to use (world coordinates by default). + file_type : {2} + The region file type to use (CRTF by default). + """ + self.region_set.export_to(path, coordinate_type, file_type, [self.region_id]) + + def delete(self): + """Delete this region.""" + self.region_set.call_action("deleteRegion", self._region) + + +class HasSizeMixin: + """This is a mixin class for regions which have a size (all of them except for point regions and annotations). These regions also have a line style. + + Subclasses which have size functions partially implemented natively should use this mixin and override unimplemented functions as appropriate. + """ + + # GET PROPERTIES + + @property + def size(self): + """The size, in pixels. + + Returns + ------- + number + The width. + number + The height. + """ + size = self.get_value("size") + return Pt(**size).as_tuple() + + @property + def wcs_size(self): + """The size, in angular size units. + + Returns + ------- + string + The width. + string + The height. + """ + size = self.get_value("wcsSize") + return (f"{size['x']}\"", f"{size['y']}\"") + + @property + def line_width(self): + """The line width. + + Returns + ------- + number + The line width, in pixels. + """ + return self.get_value("lineWidth") + + @property + def dash_length(self): + """The dash length. + + Returns + ------- + number + The dash length, in pixels. + """ + return self.get_value("dashLength") + + # SET PROPERTIES + + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + Both pixel and angular sizes are accepted, but both values must match. + + Signs will be ignored; only the magnitudes of the values will be used. + + Parameters + ---------- + size : {0} + The new size. + """ + [(w, h)] = self.region_set._from_angular_sizes([size]) + self.call_action("setSize", Pt(abs(w), abs(h))) + + @validate(Number()) + def scale(self, factor): + """Scale by the factor provided. + + The sign will be ignored; only the magnitude of the value will be used. + + Parameters + ---------- + factor : {0} + The scaling factor to apply to the region. + """ + w, h = self.size + self.set_size((factor * w, factor * h)) + + @validate(*all_optional(Number(), Number())) + def set_line_style(self, line_width=None, dash_length=None): + """Set the line style. + + All parameters are optional. Omitted properties will be left unmodified. + + To set the line color, see :obj:`carta.region.Region.set_color`. + + Parameters + ---------- + line_width : {0} + The new line width, in pixels. + dash_length : {1} + The new dash length, in pixels. + """ + if line_width is not None: + self.call_action("setLineWidth", line_width) + if dash_length is not None: + self.call_action("setDashLength", dash_length) + + +class HasRotationMixin: + """This is a mixin class for regions which can be rotated natively.""" + + # GET PROPERTIES + + @property + def rotation(self): + """The rotation, in degrees. + + Returns + ------- + number + The rotation. + """ + return self.get_value("rotation") + + # SET PROPERTIES + + @validate(Number()) + def set_rotation(self, angle): + """Set the rotation. + + Parameters + ---------- + angle : {0} + The new rotation, in degrees. + """ + self.call_action("setRotation", angle) + + @validate(Number()) + def rotate(self, rotation): + """Rotate this region anticlockwise by the angle provided. + + The rotation provided will be added to the current rotation of the region. + + Parameters + ---------- + rotation : {0} + The rotation to apply, in degrees. + """ + self.set_rotation(self.rotation + rotation) + + +class HasVerticesMixin: + """This is a mixin class for regions which are defined by an arbitrary number of vertices (polygons and polylines). It assumes that all control points of the region should be interpreted as coordinates.""" + + # GET PROPERTIES + + @property + def center(self): + """The geometric center, in image coordinates. + + This overrides the native behaviour of the frontend (which returns the center point of the region bounding box). + + Returns + ------- + number + The X coordinate of the geometric center. + number + The Y coordinate of the geometric center. + """ + all_vx, all_vy = zip(*self.vertices) + return (sum(all_vx) / len(all_vx), sum(all_vy) / len(all_vy)) + + @property + def vertices(self): + """The vertices, in image coordinates. + + This is an alias of :obj:`carta.region.Region.control_points`. + + Returns + ------- + iterable of tuples of two numbers + The vertices. + """ + return self.control_points + + @property + def wcs_vertices(self): + """The vertices, in world coordinates. + + Returns + ------- + iterable of tuples of two strings + The vertices. + """ + return self.region_set.image.to_world_coordinate_points(self.control_points) + + # SET PROPERTIES + + @validate(Number(), Point.CoordinatePoint()) + def set_vertex(self, index, point): + """Update the value of a single vertex. + + Parameters + ---------- + index : {0} + The index of the vertex to update. + point : {1} + The new value for the vertex, in image or world coordinates. + """ + [point] = self.region_set._from_world_coordinates([point]) + self.set_control_point(index, point) + + @validate(Union(IterableOf(Point.NumericPoint(), 3), IterableOf(Point.WorldCoordinatePoint(), 3))) + def set_vertices(self, points): + """Update all the vertices. + + Both image and world coordinates are accepted, but all values must match. + + Parameters + ---------- + points : {0} + The new vertices, in image or world coordinates. + """ + points = self.region_set._from_world_coordinates(points) + self.set_control_points(points) + + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + The size of a polygon or polyline region is computed as the size of its bounding box. + + The region will be scaled to the size provided, with the geometric center preserved. + + Both pixel and angular sizes are accepted, but both values must match. + + Signs will be ignored; only the magnitudes of the values will be used. + + This function will have no effect if any component of either the existing size or the provided size is zero. + + Parameters + ---------- + size : {0} + The new width and height, in that order. + """ + [(nw, nh)] = self.region_set._from_angular_sizes([size]) + w, h = self.size + # No-op + if all((nw, nh, w, h)): + fx, fy = abs(nw / w), abs(nh / h) + cx, cy = self.center + new_vertices = [((vx - cx) * fx + cx, (vy - cy) * fy + cy) for (vx, vy) in self.vertices] + self.set_vertices(new_vertices) + + @validate(Point.CoordinatePoint()) + def set_center(self, center): + """Set the geometric center. + + Each vertex in the region will be translated by the difference between the current geometric center and the position provided, so that the relative positions of the vertices are preserved. + + Both image and world coordinates are accepted, but both values must match. + + Parameters + ---------- + center : {0} + The new center position. + """ + [(nx, ny)] = self.region_set._from_world_coordinates([center]) + cx, cy = self.center + new_vertices = [(vx + nx - cx, vy + ny - cy) for (vx, vy) in self.vertices] + self.set_vertices(new_vertices) + + @validate(Number()) + def rotate(self, rotation): + """Rotate this region anticlockwise by the angle provided. + + Polygonal and polyline regions do not store a separate rotation property, and cannot be rotated natively. The rotation provided will be applied to each vertex relative to the geometric center of the region. + + Parameters + ---------- + rotation : {0} + The rotation to apply, in degrees. + """ + cx, cy = self.center + deltas = [(x - cx, y - cy) for (x, y) in self.vertices] + + rot = math.radians(rotation) + sin_rot, cos_rot = math.sin(rot), math.cos(rot) + + new_vertices = [] + + for (dx, dy) in deltas: + x = cx + dx * cos_rot - dy * sin_rot + y = cy + dy * cos_rot + dx * sin_rot + new_vertices.append((x, y)) + + self.set_vertices(new_vertices) + + +class HasEndpointsMixin: + """This is a mixin class for regions which are defined by two endpoints. It assumes that the region has two control points and both should be interpreted as coordinates.""" + + # GET PROPERTIES + + @property + def endpoints(self): + """The endpoints, in image coordinates. + + This is an alias of :obj:`carta.region.Region.control_points`. + + Returns + ------- + iterable containing two tuples of two numbers + The endpoints. + """ + return self.control_points + + @property + def wcs_endpoints(self): + """The endpoints, in world coordinates. + + Returns + ------- + iterable containing two tuples of two strings + The endpoints. + """ + return self.region_set.image.to_world_coordinate_points(self.control_points) + + @property + def length(self): + """The Euclidean distance between the endpoints, in pixels. + + Returns + ------- + float + The length. + """ + return math.hypot(*self.size) + + @property + def wcs_length(self): + """The Euclidean distance between the endpoints, in angular size units. + + Returns + ------- + float + The length. + """ + arcsec_size = [AngularSize.from_string(s).arcsec for s in self.wcs_size] + return str(AngularSize.from_arcsec(math.hypot(*arcsec_size))) + + # SET PROPERTIES + + @validate(*all_optional(Point.CoordinatePoint(), Point.CoordinatePoint())) + def set_endpoints(self, start=None, end=None): + """Update the endpoints. + + Both parameters are optional. If an endpoint is omitted, it will not be modified. + + Both image and world coordinates are accepted, but both values in each point must match. + + Parameters + ---------- + start : {0} + The new start position, in image or world coordinates. + end : {1} + The new end position, in image or world coordinates. + """ + if start is not None: + [start] = self.region_set._from_world_coordinates([start]) + self.set_control_point(0, start) + if end is not None: + [end] = self.region_set._from_world_coordinates([end]) + self.set_control_point(1, end) + + @validate(Size()) + def set_length(self, length): + """Update the length. + + Parameters + ---------- + length : {0} + The new length, in pixels or angular size units. + """ + if isinstance(length, str): + length = self.length * AngularSize.from_string(length).arcsec / AngularSize.from_string(self.wcs_length).arcsec + + rad = math.radians(self.rotation) + + self.set_size((length * math.sin(rad), -1 * length * math.cos(rad))) + + +class HasFontMixin: + """This is a mixin class for annotations which have font properties.""" + + # GET PROPERTIES + + @property + def font_size(self): + """The font size. + + Returns + ------- + number + The font size, in pixels. + """ + return self.get_value("fontSize") + + @property + def font_style(self): + """The font style. + + Returns + ------- + :obj:`carta.constants.AnnotationFontStyle` + The font style. + """ + return AnnotationFontStyle(self.get_value("fontStyle")) + + @property + def font(self): + """The font. + + Returns + ------- + :obj:`carta.constants.AnnotationFont` + The font. + """ + return AnnotationFont(self.get_value("font")) + + # SET PROPERTIES + + @validate(*all_optional(Constant(AnnotationFont), Number(), Constant(AnnotationFontStyle))) + def set_font(self, font=None, font_size=None, font_style=None): + """Set the font properties. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + font : {0} + The font face. + font_size : {1} + The font size, in pixels. + font_style : {2} + The font style. + """ + if font: + self.call_action("setFont", font) + if font_size is not None: + self.call_action("setFontSize", font_size) + if font_style: + self.call_action("setFontStyle", font_style) + + +class HasPointerMixin: + """This is a mixin class for annotations which have a pointer style.""" + + # GET PROPERTIES + + @property + def pointer_width(self): + """The pointer width. + + Returns + ------- + number + The pointer width, in pixels. + """ + return self.get_value("pointerWidth") + + @property + def pointer_length(self): + """The pointer length. + + Returns + ------- + number + The pointer length, in pixels. + """ + return self.get_value("pointerLength") + + # SET PROPERTIES + + @validate(*all_optional(Number(), Number())) + def set_pointer_style(self, pointer_width=None, pointer_length=None): + """Set the pointer style. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + pointer_width : {0} + The pointer width, in pixels. + pointer_length : {1} + The pointer length, in pixels. + """ + + if pointer_width is not None: + self.call_action("setPointerWidth", pointer_width) + if pointer_length is not None: + self.call_action("setPointerLength", pointer_length) + + +class LineRegion(HasEndpointsMixin, HasRotationMixin, HasSizeMixin, Region): + """A line region or annotation.""" + REGION_TYPES = (RegionType.LINE, RegionType.ANNLINE) + """The region types corresponding to this class.""" + + # CONVERSION + + @validate(*all_optional(Boolean(), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def as_polyline(self, oversampling=False, density=10, delete=False): + """Create a new polyline region with the same dimensions and style as this line region or annotation. + + This function does not delete the original region by default. Also see :obj:`carta.region.LineRegion.to_polyline`. + + Parameters + ---------- + oversampling : {0} + Whether to add more vertices to the polyline, using the configured per-degree density. By default the polyline will only have three vertices: the endpoints and the center. + density : {1} + The approximate number of vertices to add per degree of the line length (the default is 10). Vertices will divide the line into equal segments. This parameter is ignored if oversampling is disabled. + delete : {2} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolylineRegion` object + A new region object. + """ + + # The endpoints of a line have the rotation already applied, so we can use them as-is + (sx, sy), (ex, ey) = self.endpoints + + # Number of points excluding the end point + # The frontend requires polylines to have at least 3 points, so we *must* include the center (for now) + num_points = 2 + + if oversampling: + # Adjust to approximate points per degree + arcsec_l = AngularSize.from_string(self.wcs_length).arcsec + num_points = max(round(arcsec_l * density / 3600), 2) + + # Plot points on line + points = [] + dx, dy = (ex - sx) / num_points, (ey - sy) / num_points + + for i in range(num_points + 1): + points.append((sx + i * dx, sy + i * dy)) + + polyline = self.region_set.add_polyline(points, annotation=(self.region_type == RegionType.ANNLINE), name=self.name) + + polyline.set_color(self.color) + polyline.set_line_style(self.line_width, self.dash_length) + + if delete: + self.delete() + + return polyline + + @validate(*all_optional(Boolean(), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def to_polyline(self, oversampling=False, density=10, delete=True): + """Create a new polyline region with the same dimensions and style as this line region or annotation. + + This function is a convenience wrapper around :obj:`carta.region.LineRegion.as_polyline` that deletes the original region by default. + + Parameters + ---------- + oversampling : {0} + Whether to add more vertices to the polyline, using the configured per-degree density. By default the polyline will only have three vertices: the endpoints and the center. + density : {1} + The approximate number of vertices to add per degree of the line length (the default is 10). Vertices will divide the line into equal segments. This parameter is ignored if oversampling is disabled. + delete : {2} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolylineRegion` object + A new region object. + """ + return self.as_polyline(oversampling, density, delete) + + +class PolylineRegion(HasVerticesMixin, HasSizeMixin, Region): + """A polyline region or annotation.""" + REGION_TYPES = (RegionType.POLYLINE, RegionType.ANNPOLYLINE) + """The region types corresponding to this class.""" + + +class PolygonRegion(HasVerticesMixin, HasSizeMixin, Region): + """A polygonal region or annotation.""" + REGION_TYPES = (RegionType.POLYGON, RegionType.ANNPOLYGON) + """The region types corresponding to this class.""" + + +class RectangularRegion(HasRotationMixin, HasSizeMixin, Region): + """A rectangular region or annotation.""" + REGION_TYPES = (RegionType.RECTANGLE, RegionType.ANNRECTANGLE) + """The region types corresponding to this class.""" + + @staticmethod + def _center_size_from_corners(bottom_left, top_right): + """Internal utility function for calculating a center point and a size from a bottom-left and top-right corner. + + The corner points provided must be in image coordinates, and the returned center and size values are in image coordinates and pixel sizes, respectively. + + Parameters + ---------- + bottom_left : pair of numbers + The bottom-left corner in image coordinates. + top_right : pair of numbers + The top-right corner in image coordinates. + + Returns + ------- + pair of numbers + The center point in image coordinates. + pair of numbers + The size in pixels. + """ + bl_x, bl_y = bottom_left + tr_x, tr_y = top_right + w, h = tr_x - bl_x, tr_y - bl_y + center = w / 2 + bl_x, h / 2 + bl_y + return center, (w, h) + + # GET PROPERTIES + + @property + def corners(self): + """The corner positions, in image coordinates. + + Returns + ------- + iterable containing two tuples of two numbers + The bottom-left and top-right corner positions, in image coordinates. + """ + cx, cy = self.center + w, h = self.size + dx, dy = w / 2, h / 2 + return ((cx - dx, cy - dy), (cx + dx, cy + dy)) + + @property + def wcs_corners(self): + """The corner positions, in world coordinates. + + Returns + ------- + iterable containing two tuples of two strings + The bottom-left and top-right corner positions, in world coordinates. + """ + return self.region_set.image.to_world_coordinate_points(self.corners) + + # SET PROPERTIES + + @validate(*all_optional(Point.CoordinatePoint(), Point.CoordinatePoint())) + def set_corners(self, bottom_left=None, top_right=None): + """Update the corner positions. + + Both parameters are optional. If a position is omitted, it will not be modified. + + The corner positions will be used to calculate the updated center position and size. + + Both image and world coordinates are accepted, but both values in each point must match. + + Parameters + ---------- + bottom_left : {0} + The new bottom-left corner position, in image or world coordinates. + top_right : {1} + The new top-right corner position, in image or world coordinates. + """ + if bottom_left is None and top_right is None: + return + + if bottom_left is None or top_right is None: + current_bottom_left, current_top_right = self.corners + + if bottom_left is not None: + [bottom_left] = self.region_set._from_world_coordinates([bottom_left]) + else: + bottom_left = current_bottom_left + + if top_right is not None: + [top_right] = self.region_set._from_world_coordinates([top_right]) + else: + top_right = current_top_right + + center, size = self._center_size_from_corners(bottom_left, top_right) + self.set_control_points([center, size]) + + # CONVERSION + + @validate(*all_optional(Boolean(), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def as_polygon(self, oversampling=False, density=10, delete=False): + """Create a new polygonal region with the same dimensions and style as this rectangular region or annotation. + + This function does not delete the original region by default. Also see :obj:`carta.region.RectangularRegion.to_polygon`. + + Parameters + ---------- + oversampling : {0} + Whether to add vertices to the sides of the rectangle, using the configured per-degree density. By default the polygon will only have four vertices at the corners. + density : {1} + The approximate number of vertices to add per degree of the rectangle perimeter (the default is 10). Vertices will divide each face into equal segments. This parameter is ignored if oversampling is disabled. + delete : {2} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolygonRegion` object + A new region object. + """ + cx, cy = self.center + w, h = self.size + rot = math.radians(self.rotation) + + # Number of points per face (just starting corner by default) + num_points_x = 1 + num_points_y = 1 + + if oversampling: + # Adjust to approximate points per degree + arcsec_w, arcsec_h = (AngularSize.from_string(s).arcsec for s in self.wcs_size) + num_points_x = max(round(arcsec_w * density / 3600), 1) + num_points_y = max(round(arcsec_h * density / 3600), 1) + + # The four corners relative to the center, before rotation + corners = ( + (-w / 2, -h / 2), + (-w / 2, h / 2), + (w / 2, h / 2), + (w / 2, -h / 2), + ) + + # Number of points per face, clockwise from bottom left corner + face_points = (num_points_y, num_points_x, num_points_y, num_points_x) + + # All points relative to the center + deltas = [] + + # Plot points on faces (each face includes the start corner and excludes the end corner) + for (sx, sy), (ex, ey), num in zip(corners, corners[1:] + corners[:1], face_points): + dx, dy = (ex - sx) / num, (ey - sy) / num + for i in range(num): + deltas.append((sx + i * dx, sy + i * dy)) + + # The final points, after rotation and translation + points = [] + + sin_rot, cos_rot = math.sin(rot), math.cos(rot) + + for (dx, dy) in deltas: + x = cx + dx * cos_rot - dy * sin_rot + y = cy + dy * cos_rot + dx * sin_rot + points.append((x, y)) + + polygon = self.region_set.add_polygon(points, annotation=(self.region_type == RegionType.ANNRECTANGLE), name=self.name) + + polygon.set_color(self.color) + polygon.set_line_style(self.line_width, self.dash_length) + + if delete: + self.delete() + + return polygon + + @validate(*all_optional(Boolean(), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def to_polygon(self, oversampling=False, density=10, delete=True): + """Create a new polygonal region with the same dimensions and style as this rectangular region or annotation. + + This function is a convenience wrapper around :obj:`carta.region.RectangularRegion.as_polygon` that deletes the original region by default. + + Parameters + ---------- + oversampling : {0} + Whether to add vertices to the sides of the rectangle, using the configured per-degree density. By default the polygon will only have four vertices at the corners. + density : {1} + The approximate number of vertices to add per degree of the rectangle perimeter (the default is 10). Vertices will divide each face into equal segments. This parameter is ignored if oversampling is disabled. + delete : {2} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolygonRegion` object + A new region object. + """ + return self.as_polygon(oversampling, density, delete) + + +class EllipticalRegion(HasRotationMixin, HasSizeMixin, Region): + """An elliptical region or annotation.""" + REGION_TYPES = (RegionType.ELLIPSE, RegionType.ANNELLIPSE) + """The region types corresponding to this class.""" + + # GET PROPERTIES + + @property + def semi_axes(self): + """The semi-axes, in pixels. + + The north-south semi-axis is equal to half of the height, and the east-west semi-axis is equal to half of the width. + + Returns + ------- + number + The north-south semi-axis, in pixels. + number + The east-west semi-axis, in pixels. + """ + return super().size + + @property + def wcs_semi_axes(self): + """The semi-axes, in angular size units. + + The north-south semi-axis is equal to half of the height, and the east-west semi-axis is equal to half of the width. + + Returns + ------- + string + The north-south semi-axis, in angular size units. + string + The east-west semi-axis, in angular size units. + """ + return super().wcs_size + + @property + def size(self): + """The size, in pixels. + + The width is equal to twice the east-west semi-axis, and the height is equal to twice the north-south semi-axis. + + Returns + ------- + number + The width. + number + The height. + """ + semi_ns, semi_ew = self.semi_axes + return (semi_ew * 2, semi_ns * 2) + + @property + def wcs_size(self): + """The size, in angular size units. + + The width is equal to twice the east-west semi-axis, and the height is equal to twice the north-south semi-axis. + + Returns + ------- + string + The width. + string + The height. + """ + [size] = self.region_set.image.to_angular_size_points([self.size]) + return size + + # SET PROPERTIES + + @validate(Point.SizePoint()) + def set_semi_axes(self, semi_axes): + """Set the semi-axes. + + Both pixel and angular sizes are accepted, but both values must match. + + Parameters + ---------- + size : {0} + The new north-south and east-west semi-axes, in that order. + """ + [semi_axes] = self.region_set._from_angular_sizes([semi_axes]) + super().set_size(semi_axes) + + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + The width and height will be used to calculate the semi-axes: the north-south semi-axis is equal to half of the height, and the east-west semi-axis is equal to half of the width. + + Both pixel and angular sizes are accepted, but both values must match. + + Parameters + ---------- + size : {0} + The new width and height, in that order. + """ + [size] = self.region_set._from_angular_sizes([size]) + width, height = size + super().set_size([height / 2, width / 2]) + + # CONVERSION + + @validate(*all_optional(Number(min=4), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def as_polygon(self, num_vertices=None, density=10, delete=False): + """Create a new polygonal region with the same dimensions and style as this elliptical region or annotation, approximating the shape of the ellipse. + + This function does not delete the original region by default. Also see :obj:`carta.region.EllipticalRegion.to_polygon`. + + By default, the number of vertices to use for the approximation is derived from the angular size of the ellipse circumference and the configured number of vertices per degree, with a minimum of 12. Vertices will be distributed more densely near the major axis and more sparsely near the minor axis. + + Parameters + ---------- + num_vertices : {0} + The number of vertices to use. If this parameter is not provided, the number is calculated dynamically from the configured density and the region size. + density : {1} + The approximate number of vertices to add per degree of the ellipse circumference (the default is 10). This parameter is ignored if an exact number of vertices is provided. + delete : {2} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolygonRegion` object + A new region object. + """ + cx, cy = self.center + b, a = self.semi_axes + rot = math.radians(self.rotation) + + if num_vertices is None: + # Semi-axes in arcseconds + wcs_b, wcs_a = (AngularSize.from_string(ax).arcsec for ax in self.wcs_semi_axes) + perimeter = math.pi * (3 * (wcs_a + wcs_b) - math.sqrt((3 * wcs_a + wcs_b) * (3 * wcs_b + wcs_a))) + # Approximately 10 vertices per degree (minimum: 12) + num_vertices = max(round(density * perimeter / 3600), 12) + + angles = [i * 2 * math.pi / num_vertices for i in range(num_vertices)] + + points = [] + sin_rot, cos_rot = math.sin(rot), math.cos(rot) + + for theta in angles: + rot_a = a * math.cos(theta) + rot_b = b * math.sin(theta) + x = cx + cos_rot * rot_a - sin_rot * rot_b + y = cy + sin_rot * rot_a + cos_rot * rot_b + points.append((x, y)) + + polygon = self.region_set.add_polygon(points, annotation=(self.region_type == RegionType.ANNELLIPSE), name=self.name) + + polygon.set_color(self.color) + polygon.set_line_style(self.line_width, self.dash_length) + + if delete: + self.delete() + + return polygon + + @validate(*all_optional(Number(min=4), Number(min=0, interval=Number.EXCLUDE), Boolean())) + def to_polygon(self, num_vertices=None, density=10, delete=True): + """Create a new polygonal region with the same dimensions and style as this elliptical region or annotation, approximating the shape of the ellipse. + + This function is a convenience wrapper around :obj:`carta.region.EllipticalRegion.as_polygon` that deletes the original region by default. + + By default, the number of vertices to use for the approximation is derived from the angular size of the ellipse circumference and the configured number of vertices per degree, with a minimum of 12. Vertices will be distributed more densely near the major axis and more sparsely near the minor axis. + + Parameters + ---------- + num_vertices : {0} + The number of vertices to use. If this parameter is not provided, the number is calculated dynamically from the configured density and the region size. + density : {1} + The approximate number of vertices to add per degree of the ellipse circumference (the default is 10). This parameter is ignored if an exact number of vertices is provided. + delete : {2} + Whether to delete the original region. + + Returns + ------- + :obj:`carta.region.PolygonRegion` object + A new region object. + """ + return self.as_polygon(num_vertices, density, delete) + + +class PointAnnotation(Region): + """A point annotation.""" + REGION_TYPES = (RegionType.ANNPOINT,) + """The region types corresponding to this class.""" + + # GET PROPERTIES + + @property + def point_shape(self): + """The point shape. + + Returns + ------- + :obj:`carta.constants.PointShape` object + The point shape. + """ + return PointShape(self.get_value("pointShape")) + + @property + def point_width(self): + """The point width. + + Returns + ------- + number + The point width, in pixels. + """ + return self.get_value("pointWidth") + + # SET PROPERTIES + + @validate(*all_optional(Constant(PointShape), Number())) + def set_point_style(self, point_shape=None, point_width=None): + """Set the point style. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + point_shape : {0} + The point shape. + point_width : {1} + The point width, in pixels. + """ + if point_shape is not None: + self.call_action("setPointShape", point_shape) + if point_width is not None: + self.call_action("setPointWidth", point_width) + + +class TextAnnotation(HasFontMixin, HasRotationMixin, HasSizeMixin, Region): + """A text annotation.""" + REGION_TYPES = (RegionType.ANNTEXT,) + """The region types corresponding to this class.""" + + # GET PROPERTIES + + @property + def text(self): + """The text content. + + Returns + ------- + string + The text content. + """ + return self.get_value("text") + + @property + def position(self): + """The position of the text in this annotation. + + Returns + ------- + :obj:`carta.constants.TextPosition` + The text position. + """ + return TextPosition(self.get_value("position")) + + # SET PROPERTIES + + @validate(String()) + def set_text(self, text): + """Set the text content. + + Parameters + ---------- + text : {0} + The text content. + """ + self.call_action("setText", text) + + @validate(Constant(TextPosition)) + def set_text_position(self, text_position): + """Set the position of the text in this annotation. + + Parameters + ---------- + text_position : {0} + The position of the text. + """ + self.call_action("setPosition", text_position) + + +class VectorAnnotation(HasPointerMixin, HasEndpointsMixin, HasRotationMixin, HasSizeMixin, Region): + """A vector annotation.""" + REGION_TYPES = (RegionType.ANNVECTOR,) + """The region types corresponding to this class.""" + + +class CompassAnnotation(HasFontMixin, HasPointerMixin, HasSizeMixin, Region): + """A compass annotation.""" + REGION_TYPES = (RegionType.ANNCOMPASS,) + """The region types corresponding to this class.""" + + # GET PROPERTIES + + @property + def labels(self): + """The north and east labels. + + Returns + ------- + string + The north label. + string + The east label. + """ + return self.get_value("northLabel"), self.get_value("eastLabel") + + @property + def point_length(self): + """The length of the compass points, in pixels. + + Returns + ------- + number + The length of the compass points. + """ + return self.get_value("length") + + @property + def label_offsets(self): + """The offsets of the north and east labels. + + Returns + ------- + tuple of two numbers + The offset of the north label, in pixels. + tuple of two numbers + The offset of the east label, in pixels. + """ + return Pt(**self.get_value("northTextOffset")).as_tuple(), Pt(**self.get_value("eastTextOffset")).as_tuple() + + @property + def arrowheads_visible(self): + """The visibility of the north and east arrowheads. + + Returns + ------- + boolean + Whether the north arrowhead is visible. + boolean + Whether the east arrowhead is visible. + """ + return self.get_value("northArrowhead"), self.get_value("eastArrowhead") + + # SET PROPERTIES + + @validate(Point.SizePoint()) + def set_size(self, size): + """Set the size. + + Both pixel and angular sizes are accepted, but both values must match. + + The width and height of this annotation cannot be set independently. If two different values are provided, the smaller value will be used (after conversion to pixel units). + + This function is provided for compatibility. Also see :obj:`carta.region.CompassAnnotation.set_point_length` for a more convenient way to resize this annotation. + + Parameters + ---------- + size : {0} + The new size. + """ + [(w, h)] = self.region_set._from_angular_sizes([size]) + self.call_action("setLength", min(abs(w), abs(h))) + + @validate(*all_optional(String(), String())) + def set_label(self, north_label=None, east_label=None): + """Set the north and east labels. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + north_label : {0} + The north label. + east_label : {1} + The east label. + """ + if north_label is not None: + self.call_action("setLabel", north_label, True) + if east_label is not None: + self.call_action("setLabel", east_label, False) + + @validate(Size(), NoneOr(Constant(SpatialAxis))) + def set_point_length(self, length, spatial_axis=None): + """Set the length of the compass points. + + If the length is provided in angular size units, a spatial axis must also be provided in order for the angular size to be converted to pixels. + + Parameters + ---------- + length : {0} + The length, in pixels or angular size units. + spatial_axis : {1} + The spatial axis which should be used to convert angular size units. This parameter is ignored if the length is provided in pixels. + + Raises + ------ + ValueError + If the length is in angular size units, but no spatial axis is provided. + """ + if isinstance(length, str): + if spatial_axis is None: + raise ValueError("Please specify a spatial axis to convert length from angular size units, or use pixels instead.") + length = self.region_set.image.from_angular_size(length, spatial_axis) + self.call_action("setLength", length) + + @validate(*all_optional(Point.NumericPoint(), Point.NumericPoint())) + def set_label_offset(self, north_offset=None, east_offset=None): + """Set the north and east label offsets. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + north_offset : {0} + The north label offset, in pixels. + east_offset : {1} + The east label offset, in pixels. + """ + if north_offset is not None: + north_offset = Pt(*north_offset) + self.call_action("setNorthTextOffset", north_offset.x, True) + self.call_action("setNorthTextOffset", north_offset.y, False) + if east_offset is not None: + east_offset = Pt(*east_offset) + self.call_action("setEastTextOffset", east_offset.x, True) + self.call_action("setEastTextOffset", east_offset.y, False) + + @validate(*all_optional(Boolean(), Boolean())) + def set_arrowhead_visible(self, north=None, east=None): + """Set the north and east arrowhead visibility. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + north : {0} + Whether the north arrowhead should be visible. + east : {1} + Whether the east arrowhead should be visible. + """ + if north is not None: + self.call_action("setNorthArrowhead", north) + if east is not None: + self.call_action("setEastArrowhead", east) + + +class RulerAnnotation(HasFontMixin, HasEndpointsMixin, HasRotationMixin, HasSizeMixin, Region): + """A ruler annotation.""" + REGION_TYPES = (RegionType.ANNRULER,) + """The region types corresponding to this class.""" + + # GET PROPERTIES + + @property + def auxiliary_lines_visible(self): + """The visibility of the auxiliary lines. + + Returns + ------- + boolean + Whether the auxiliary lines are visible. + """ + return self.get_value("auxiliaryLineVisible") + + @property + def auxiliary_lines_dash_length(self): + """The dash length of the auxiliary lines. + + Returns + ------- + number + The dash length of the auxiliary lines, in pixels. + """ + return self.get_value("auxiliaryLineDashLength") + + @property + def text_offset(self): + """The X and Y text offsets. + + Returns + ------- + number + The X offset of the text, in pixels. + number + The Y offset of the text, in pixels. + """ + return Pt(**self.get_value("textOffset")).as_tuple() + + # SET PROPERTIES + + @validate(*all_optional(Boolean(), Number())) + def set_auxiliary_lines_style(self, visible=None, dash_length=None): + """Set the auxiliary line style. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + visible : {0} + Whether the auxiliary lines should be visible. + dash_length : {1} + The dash length of the auxiliary lines, in pixels. + """ + if visible is not None: + self.call_action("setAuxiliaryLineVisible", visible) + if dash_length is not None: + self.call_action("setAuxiliaryLineDashLength", dash_length) + + @validate(*all_optional(Number(), Number())) + def set_text_offset(self, offset_x=None, offset_y=None): + """Set the text offset. + + All parameters are optional. Omitted properties will be left unmodified. + + Parameters + ---------- + offset_x : {0} + The X offset of the text, in pixels. + offset_y : {1} + The Y offset of the text, in pixels. + """ + if offset_x is not None: + self.call_action("setTextOffset", offset_x, True) + if offset_y is not None: + self.call_action("setTextOffset", offset_y, False) diff --git a/carta/session.py b/carta/session.py index 1896b95..fc0ddc8 100644 --- a/carta/session.py +++ b/carta/session.py @@ -13,8 +13,9 @@ from .constants import PanelMode, GridMode, ComplexComponent, Polarization from .backend import Backend from .protocol import Protocol -from .util import Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl +from .util import Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl, Point as Pt from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, IterableOf, MapOf, Union + from .wcs_overlay import SessionWCSOverlay from .raster import SessionRaster from .preferences import Preferences @@ -334,8 +335,7 @@ def ls(self): list The list of files and subdirectories in the frontend file browser's current starting directory. """ - self.call_action("fileBrowserStore.getFileList", self.pwd()) - file_list = self.get_value("fileBrowserStore.fileList") + file_list = self.call_action("backendService.getFileList", self.pwd(), 2) items = [] if "files" in file_list: items.extend([f["name"] for f in file_list["files"]]) @@ -610,7 +610,7 @@ def set_viewer_grid(self, rows, columns, grid_mode=GridMode.FIXED): def set_cursor(self, x, y): """Set the curson position. - TODO: this is a precursor to making z-profiles available, but currently the relevant functionality is not exposed by the frontend. + TODO: this is a precursor to making z-profiles available, but currently the relevant functionality is not exposed by the frontend. There is also a frontend issue which is preventing the cursor from being updated correctly (it is updated only in the profiles). Parameters ---------- @@ -620,7 +620,7 @@ def set_cursor(self, x, y): The Y position. """ - self.active_frame().call_action("regionSet.regions[0].setControlPoint", 0, [x, y]) + self.active_frame().regions.call_action("updateCursorRegionPosition", Pt(x, y)) # SAVE IMAGE diff --git a/carta/units.py b/carta/units.py index fe63a21..05ed965 100644 --- a/carta/units.py +++ b/carta/units.py @@ -14,7 +14,13 @@ class AngularSize: Child class instances have a string representation in a normalized format which can be parsed by the frontend. """ FORMATS = {} + """A mapping of units to angular size subclasses.""" NAME = "angular size" + """A descriptive name.""" + SYMBOL_UNIT_REGEX = "" + """All recognised input units which are a single character long.""" + WORD_UNIT_REGEX = "" + """All recognised input units which are multiple characters long.""" def __init__(self, value): self.value = value @@ -35,8 +41,8 @@ def _update_unit_regex(cls, units): symbols = {u for u in units if len(u) <= 1} words = units - symbols - cls.SYMBOL_UNIT_REGEX = rf"^(\d+(?:\.\d+)?)({'|'.join(symbols)})$" - cls.WORD_UNIT_REGEX = rf"^(\d+(?:\.\d+)?)\s*({'|'.join(words)})$" + cls.SYMBOL_UNIT_REGEX = rf"^(-?\d+(?:\.\d+)?)({'|'.join(symbols)})$" + cls.WORD_UNIT_REGEX = rf"^(-?\d+(?:\.\d+)?)\s*({'|'.join(words)})$" @classmethod def valid(cls, value): @@ -91,6 +97,37 @@ def from_string(cls, value): return cls.FORMATS[unit](float(value)) return cls(float(value)) + @classmethod + def from_arcsec(cls, arcsec): + """Construct an angular size object from a numeric value in arcseconds. + + If this method is called on the parent :obj:`carta.units.AngularSize` class, it will automatically guess the most appropriate unit subclass. If it is called on a unit subclass, it will return an instance of that subclass. + + Parameters + ---------- + arcsec : float + The angular size in arcseconds. + + Returns + ------- + :obj:`carta.units.AngularSize` object + The angular size object. + """ + + if cls is AngularSize: + if arcsec < 0.002: + unit = MilliarcsecSize + elif arcsec < 120: + unit = ArcsecSize + elif arcsec < 7200: + unit = ArcminSize + else: + unit = DegreesSize + else: + unit = cls + + return unit(arcsec / unit.ARCSEC_FACTOR) + def __str__(self): """The canonical string representation of this size.""" if type(self) is AngularSize: @@ -98,52 +135,97 @@ def __str__(self): value = self.value * self.FACTOR return f"{value:g}{self.OUTPUT_UNIT}" + @property + def arcsec(self): + """The numeric value in arcseconds. + + Returns + ------- + float + The numeric value of this angular size, in arcseconds. + """ + if type(self) is AngularSize: + raise NotImplementedError() + return self.value * self.ARCSEC_FACTOR + class DegreesSize(AngularSize): """An angular size in degrees.""" NAME = "degree" + """A descriptive name.""" INPUT_UNITS = {"deg", "degree", "degrees"} + """All recognised input unit strings.""" OUTPUT_UNIT = "deg" + """The canonical output unit string.""" FACTOR = 1 + """The scaling factor to use when representing the value using the output unit.""" + ARCSEC_FACTOR = 3600 + """The scaling factor to use when converting the value to arcseconds.""" class ArcminSize(AngularSize): """An angular size in arcminutes.""" NAME = "arcminute" + """A descriptive name.""" INPUT_UNITS = {"'", "arcminutes", "arcminute", "arcmin", "amin", "′"} + """All recognised input unit strings.""" OUTPUT_UNIT = "'" + """The canonical output unit string.""" FACTOR = 1 + """The scaling factor to use when representing the value using the output unit.""" + ARCSEC_FACTOR = 60 + """The scaling factor to use when converting the value to arcseconds.""" class ArcsecSize(AngularSize): """An angular size in arcseconds.""" NAME = "arcsecond" + """A descriptive name.""" INPUT_UNITS = {"\"", "", "arcseconds", "arcsecond", "arcsec", "asec", "″"} + """All recognised input unit strings.""" OUTPUT_UNIT = "\"" + """The canonical output unit string.""" FACTOR = 1 + """The scaling factor to use when representing the value using the output unit.""" + ARCSEC_FACTOR = FACTOR + """The scaling factor to use when converting the value to arcseconds.""" class MilliarcsecSize(AngularSize): """An angular size in milliarcseconds.""" NAME = "milliarcsecond" + """A descriptive name.""" INPUT_UNITS = {"milliarcseconds", "milliarcsecond", "milliarcsec", "mas"} + """All recognised input unit strings.""" OUTPUT_UNIT = "\"" + """The canonical output unit string.""" FACTOR = 1e-3 + """The scaling factor to use when representing the value using the output unit.""" + ARCSEC_FACTOR = FACTOR + """The scaling factor to use when converting the value to arcseconds.""" class MicroarcsecSize(AngularSize): """An angular size in microarcseconds.""" NAME = "microarcsecond" + """A descriptive name.""" INPUT_UNITS = {"microarcseconds", "microarcsecond", "microarcsec", "µas", "uas"} + """All recognised input unit strings.""" OUTPUT_UNIT = "\"" + """The canonical output unit string.""" FACTOR = 1e-6 + """The scaling factor to use when representing the value using the output unit.""" + ARCSEC_FACTOR = FACTOR + """The scaling factor to use when converting the value to arcseconds.""" class WorldCoordinate: """A world coordinate.""" FMT = None + """The number format.""" FORMATS = {} + """A mapping of number formats to world coordinate subclasses.""" def __init_subclass__(cls, **kwargs): """Automatically register subclasses corresponding to number formats.""" @@ -202,11 +284,14 @@ def from_string(cls, value, axis): class DegreesCoordinate(WorldCoordinate): """A world coordinate in decimal degree format.""" FMT = NumberFormat.DEGREES + """The number format.""" DEGREE_UNITS = DegreesSize.INPUT_UNITS + """All recognised degree units.""" REGEX = { "DEGREE_UNIT": rf"^-?(\d+(?:\.\d+)?)\s*({'|'.join(DEGREE_UNITS)})$", "DECIMAL": r"^-?\d+(\.\d+)?$", } + """All recognised input string formats.""" @classmethod def from_string(cls, value, axis): @@ -307,11 +392,13 @@ def as_tuple(self): class HMSCoordinate(SexagesimalCoordinate): """A world coordinate in HMS format.""" FMT = NumberFormat.HMS + """The number format.""" # Temporarily allow negative H values to account for frontend custom format oddity REGEX = { "COLON": r"^(-?(?:\d|[01]\d|2[0-3]))?:([0-5]?\d)?:([0-5]?\d(?:\.\d+)?)?$", "LETTER": r"^(?:(-?(?:\d|[01]\d|2[0-3]))h)?(?:([0-5]?\d)m)?(?:([0-5]?\d(?:\.\d+)?)s)?$", } + """All recognised input string formats.""" @classmethod def from_string(cls, value, axis): @@ -346,10 +433,12 @@ def from_string(cls, value, axis): class DMSCoordinate(SexagesimalCoordinate): """A world coordinate in DMS format.""" FMT = NumberFormat.DMS + """The number format.""" REGEX = { "COLON": r"^(-?\d+)?:([0-5]?\d)?:([0-5]?\d(?:\.\d+)?)?$", "LETTER": r"^(?:(-?\d+)d)?(?:([0-5]?\d)m)?(?:([0-5]?\d(?:\.\d+)?)s)?$", } + """All recognised input string formats.""" @classmethod def from_string(cls, value, axis): diff --git a/carta/util.py b/carta/util.py index 38f66d4..6df380c 100644 --- a/carta/util.py +++ b/carta/util.py @@ -5,6 +5,8 @@ import functools import re +from .units import AngularSize, WorldCoordinate + logger = logging.getLogger("carta_scripting") logger.setLevel(logging.WARN) logger.addHandler(logging.StreamHandler()) @@ -209,6 +211,91 @@ def macro(self, target, variable): return Macro(target, variable) +class Point: + """A pair of coordinates or sizes. + + This object may have numeric or string values, and may be used to represent a pair of coordinates or a pair of sizes. + + Numbers should be interpreted as pixel values, and strings should be valid WCS coordinates or angular sizes. Different types of values should not be combined. + + Functions exposed to the user should accept pairs of points as two-item iterables, and use this object internally to ensure that the correct serialization is sent to the frontend. It is the responsibility of calling functions to ensure that the object is interpreted correctly. + + Parameters + ---------- + x : number or string + The *x* value. + y : number or string + The *y* value. + """ + + def __init__(self, x, y): + self.x = x + self.y = y + + def __eq__(self, other): + """Check for equality by comparing x and y attributes.""" + return hasattr(other, "x") and hasattr(other, "y") and self.x == other.x and self.y == other.y + + @classmethod + def is_pixel(cls, x, y): + """Whether this is a pair of pixel values. + + Returns + ------- + boolean + Whether both values are numeric and should be interpreted as pixels. + """ + return isinstance(x, (int, float)) and isinstance(y, (int, float)) + + @classmethod + def is_wcs_coordinate(cls, x, y): + """Whether this is a pair of world coordinates. + + Returns + ------- + boolean + Whether both values are strings and can be parsed as valid world coordinates. + """ + return isinstance(x, str) and isinstance(y, str) and WorldCoordinate.valid(x) and WorldCoordinate.valid(y) + + @classmethod + def is_angular_size(cls, x, y): + """Whether this is a pair of angular sizes. + + Returns + ------- + boolean + Whether both values are strings and can be parsed as valid angular sizes. + """ + return isinstance(x, str) and isinstance(y, str) and AngularSize.valid(x) and AngularSize.valid(y) + + def json(self): + """The JSON serialization of this object. + + This is the format in which these values should be sent to the frontend. + + Returns + ------- + dict + A dictionary which can be coerced to a 2D point object in the frontend after serialization and deserialization. + """ + return {"x": self.x, "y": self.y} + + def as_tuple(self): + """The tuple representation of this object. + + This is the format in which these values should be returned to the user. + + Returns + ------- + number or string + The X value. + number or string + The Y value. + """ + return self.x, self.y + + def camel(*parts): """Convert an iterable of strings to a camel case string.""" parts = [p for p in parts if p] diff --git a/carta/validation.py b/carta/validation.py index 10f2f04..9e68d15 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -579,8 +579,10 @@ class MapOf(IterableOf): The value parameter descriptor. """ - def __init__(self, key_param, value_param, min_size=None, max_size=None): + def __init__(self, key_param, value_param, min_size=None, max_size=None, required_keys=set(), exact_keys=False): self.value_param = value_param + self.required_keys = required_keys + self.exact_keys = exact_keys super().__init__(key_param, min_size, max_size) def validate(self, value, parent): @@ -597,6 +599,13 @@ def validate(self, value, parent): raise ValueError(f"{value} is not a dictionary, but {self.description} was expected.") raise e + if self.exact_keys: + if self.required_keys != set(value): + raise ValueError(f"Dictionary {value} does not have required exact keys {self.required_keys}.") + else: + if not self.required_keys <= set(value): + raise ValueError(f"Required keys {self.required_keys} not found in dictionary {value}.") + super().validate(value, parent) @property @@ -747,6 +756,70 @@ def __init__(self): super().__init__(*options, description="a number, a string in H:M:S or D:M:S format, or a numeric string with degree units") +class Point(Union): + """A pair of numbers, WCS coordinate strings, or angular size strings, as an iterable with two values (which will be evaluated as ``x`` and ``y`` values in that order). Numbers will be interpreted as pixel values. + + More fine-grained combinations of value options (numeric only, world coordinate only, angular size only, any coordinate, any size) are provided as local classes which may also be used individually. + + The two values must always match (numbers, string coordinates and string sizes can't be mixed). + """ + + class NumericPoint(Union): + """A pair of numbers.""" + + def __init__(self): + options = ( + IterableOf(Number(), min_size=2, max_size=2), + ) + super().__init__(*options, description="a pair of numbers") + + class WorldCoordinatePoint(Union): + """A pair of WCS coordinate strings.""" + + def __init__(self): + options = ( + IterableOf(Coordinate.WorldCoordinate(), min_size=2, max_size=2), + ) + super().__init__(*options, description="a pair of coordinate strings") + + class AngularSizePoint(Union): + """A pair of angular size strings.""" + + def __init__(self): + options = ( + IterableOf(Size.AngularSize(), min_size=2, max_size=2), + ) + super().__init__(*options, description="a pair of size strings") + + class CoordinatePoint(Union): + """A pair of coordinates.""" + + def __init__(self): + options = ( + Point.NumericPoint(), + Point.WorldCoordinatePoint(), + ) + super().__init__(*options, description="a pair of numbers or coordinate strings") + + class SizePoint(Union): + """A pair of angular sizes.""" + + def __init__(self): + options = ( + Point.NumericPoint(), + Point.AngularSizePoint(), + ) + super().__init__(*options, description="a pair of numbers or size strings") + + def __init__(self): + options = ( + self.NumericPoint(), + self.WorldCoordinatePoint(), + self.AngularSizePoint(), + ) + super().__init__(*options, description="a pair of numbers, coordinate strings, or size strings") + + class Attr(str): """A wrapper for arguments to be passed to the :obj:`carta.validation.Evaluate` descriptor. These arguments are string names of properties on the parent object of the decorated method, which will be evaluated at runtime.""" pass diff --git a/docs/requirements.txt b/docs/requirements.txt index 99eff4e..2952a33 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ -Sphinx==7.3.7 +Sphinx>=7 sphinx-rtd-theme==2.0.0 sphinxcontrib-napoleon==0.7 -simplejson==3.17.6 +simplejson>=3.17.6 sphinx-tabs==3.4.5 diff --git a/docs/source/carta.rst b/docs/source/carta.rst index 2cf84c0..54998fe 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -73,6 +73,14 @@ carta.raster module :undoc-members: :show-inheritance: +carta.region module +------------------- + +.. automodule:: carta.region + :members: + :undoc-members: + :show-inheritance: + carta.session module -------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index 0831f18..6d4c9fa 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -2,7 +2,7 @@ carta-python: a scripting wrapper for CARTA =========================================== .. toctree:: - :maxdepth: 4 + :maxdepth: 2 :caption: Contents: introduction diff --git a/tests/test_image.py b/tests/test_image.py index 72ba134..9a1c275 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,9 +1,10 @@ import pytest from carta.image import Image -from carta.util import CartaValidationFailed +from carta.util import CartaValidationFailed, Point as Pt from carta.constants import NumberFormat as NF, SpatialAxis as SA, PaletteColor as PC, BeamType as BT, SpectralSystem as SS, SpectralType as ST, SpectralUnit as SU + # FIXTURES @@ -87,6 +88,7 @@ def test_new(session, session_call_action, session_method, args, kwargs, expecte ("contours", "Contours"), ("vectors", "VectorOverlay"), ("wcs", "ImageWCSOverlay"), + ("regions", "RegionSet"), ]) def test_subobjects(image, name, classname): assert getattr(image, name).__class__.__name__ == classname @@ -207,6 +209,73 @@ def test_zoom_to_size_invalid(image, property_, axis, val, wcs, error_contains): assert error_contains in str(e.value) +def test_from_world_coordinate_points(image, call_action): + call_action.return_value = [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 5, "y": 6}] + points = image.from_world_coordinate_points([("1", "2"), ("3", "4"), ("5", "6")]) + call_action.assert_called_with("getImagePosFromWCS", [Pt("1", "2"), Pt("3", "4"), Pt("5", "6")]) + assert points == [(1, 2), (3, 4), (5, 6)] + + +def test_from_world_coordinate_points_invalid(image): + with pytest.raises(CartaValidationFailed) as e: + image.from_world_coordinate_points([(1, 2), (3, 4), (5, 6)]) + assert "not a pair of coordinate strings" in str(e.value) + + +def test_to_world_coordinate_points(image, call_action): + call_action.return_value = [{"x": "1", "y": "2"}, {"x": "3", "y": "4"}, {"x": "5", "y": "6"}] + points = image.to_world_coordinate_points([(1, 2), (3, 4), (5, 6)]) + call_action.assert_called_with("getWCSFromImagePos", [Pt(1, 2), Pt(3, 4), Pt(5, 6)]) + assert points == [("1", "2"), ("3", "4"), ("5", "6")] + + +def test_to_world_coordinate_points_invalid(image): + with pytest.raises(CartaValidationFailed) as e: + image.to_world_coordinate_points([("1", "2"), ("3", "4"), ("5", "6")]) + assert "not a pair of numbers" in str(e.value) + + +@pytest.mark.parametrize("size,axis,expected_call", [ + ("100\"", SA.X, ("getImageXValueFromArcsec", 100)), + ("100\"", SA.Y, ("getImageYValueFromArcsec", 100)), +]) +def test_from_angular_size(image, call_action, size, axis, expected_call): + image.from_angular_size(size, axis) + call_action.assert_called_with(*expected_call) + + +@pytest.mark.parametrize("size,error_contains", [ + (100, "a string was expected"), + ("100abc", "not an angular size"), +]) +def test_from_angular_size_invalid(image, size, error_contains): + with pytest.raises(CartaValidationFailed) as e: + image.from_angular_size(size, SA.X) + assert error_contains in str(e.value) + + +def test_from_angular_size_points(mocker, image, method): + mock_from_angular_size = method("from_angular_size", [1, 2, 3, 4]) + points = image.from_angular_size_points([("1", "2"), ("3", "4")]) + mock_from_angular_size.assert_has_calls([ + mocker.call("1", SA.X), + mocker.call("2", SA.Y), + mocker.call("3", SA.X), + mocker.call("4", SA.Y), + ]) + assert points == [(1, 2), (3, 4)] + + +def test_to_angular_size_points(mocker, image, call_action): + call_action.side_effect = [{"x": "1", "y": "2"}, {"x": "3", "y": "4"}] + points = image.to_angular_size_points([(1, 2), (3, 4)]) + call_action.assert_has_calls([ + mocker.call("getWcsSizeInArcsec", Pt(1, 2)), + mocker.call("getWcsSizeInArcsec", Pt(3, 4)), + ]) + assert points == [("1", "2"), ("3", "4")] + + # PER-IMAGE WCS def test_set_custom_colorbar_label(session, image, call_action, mock_method): diff --git a/tests/test_region.py b/tests/test_region.py new file mode 100644 index 0000000..38e0820 --- /dev/null +++ b/tests/test_region.py @@ -0,0 +1,1122 @@ +import pytest +import math + +from carta.region import Region, HasSizeMixin +from carta.constants import RegionType as RT, FileType as FT, CoordinateType as CT, AnnotationFontStyle as AFS, AnnotationFont as AF, PointShape as PS, TextPosition as TP, SpatialAxis as SA +from carta.util import Point as Pt, Macro + +# FIXTURES + +# Session and image mocks + + +@pytest.fixture +def session_call_action(session, mock_call_action): + return mock_call_action(session) + + +@pytest.fixture +def session_method(session, mock_method): + return mock_method(session) + + +@pytest.fixture +def image_method(image, mock_method): + return mock_method(image) + + +@pytest.fixture +def mock_to_world(image_method): + return image_method("to_world_coordinate_points", lambda l: [(str(x), str(y)) for (x, y) in l]) + + +@pytest.fixture +def mock_to_angular(image_method): + return image_method("to_angular_size_points", lambda l: [(str(x), str(y)) for (x, y) in l]) + + +# Regionset mocks + + +@pytest.fixture +def regionset_get_value(image, mock_get_value): + return mock_get_value(image.regions) + + +@pytest.fixture +def regionset_call_action(image, mock_call_action): + return mock_call_action(image.regions) + + +@pytest.fixture +def regionset_method(image, mock_method): + return mock_method(image.regions) + + +@pytest.fixture +def mock_from_world(regionset_method): + return regionset_method("_from_world_coordinates", lambda l: [(int(x), int(y)) for (x, y) in l]) + + +@pytest.fixture +def mock_from_angular(regionset_method): + return regionset_method("_from_angular_sizes", lambda l: [(int(x), int(y)) for (x, y) in l]) + + +# The region-specific mocks are all factories, so that they can be used to mock different region subclasses (specified by region type) + + +@pytest.fixture +def region(image, mock_property): + def func(region_type=None, region_id=0): + clazz = Region if region_type is None else Region.region_class(region_type) + mock_property(f"carta.region.{clazz.__name__}")("region_type", region_type) + reg = clazz(image.regions, region_id) + return reg + return func + + +@pytest.fixture +def get_value(mocker): + def func(reg, mock_value=None): + return mocker.patch.object(reg, "get_value", return_value=mock_value) + return func + + +@pytest.fixture +def call_action(mock_call_action): + def func(reg): + return mock_call_action(reg) + return func + + +@pytest.fixture +def property_(mock_property): + def func(reg): + return mock_property(f"carta.region.{reg.__class__.__name__}") + return func + + +@pytest.fixture +def method(mock_method): + def func(reg): + return mock_method(reg) + return func + + +# TESTS + +# REGION SET + + +@pytest.mark.parametrize("ignore_cursor,expected_items", [ + (True, [2, 3]), + (False, [1, 2, 3]), +]) +def test_regionset_list(mocker, image, regionset_get_value, ignore_cursor, expected_items): + regionset_get_value.side_effect = [[1, 2, 3]] + mock_from_list = mocker.patch.object(Region, "from_list") + image.regions.list(ignore_cursor) + regionset_get_value.assert_called_with("regionList") + mock_from_list.assert_called_with(image.regions, expected_items) + + +def test_regionset_get(mocker, image, regionset_get_value): + regionset_get_value.side_effect = [RT.RECTANGLE] + mock_existing = mocker.patch.object(Region, "existing") + image.regions.get(1) + regionset_get_value.assert_called_with("regionMap[1]", return_path="regionType") + mock_existing.assert_called_with(RT.RECTANGLE, image.regions, 1) + + +def test_regionset_import_from(mocker, image, session_method, session_call_action): + session_method("resolve_file_path", ["/path/to/directory/"]) + session_call_action.side_effect = [FT.CRTF, None] + image.regions.import_from("input_region_file") + session_call_action.assert_has_calls([ + mocker.call("backendService.getRegionFileInfo", "/path/to/directory/", "input_region_file", return_path="fileInfo.type"), + mocker.call("importRegion", "/path/to/directory/", "input_region_file", FT.CRTF, image._frame), + ]) + + +@pytest.mark.parametrize("coordinate_type", [CT.PIXEL, CT.WORLD]) +@pytest.mark.parametrize("file_type", [FT.CRTF, FT.DS9_REG]) +@pytest.mark.parametrize("region_ids,expected_region_ids", [(None, [2, 3, 4]), ([2, 3], [2, 3]), ([4], [4])]) +def test_regionset_export_to(mocker, image, session_method, session_call_action, regionset_get_value, coordinate_type, file_type, region_ids, expected_region_ids): + session_method("resolve_file_path", ["/path/to/directory/"]) + regionset_get_value.side_effect = [[{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}]] + image.regions.export_to("output_region_file", coordinate_type, file_type, region_ids) + session_call_action.assert_called_with("exportRegions", "/path/to/directory/", "output_region_file", coordinate_type, file_type, expected_region_ids, image._frame) + + +def test_regionset_add_region(mocker, image): + mock_new = mocker.patch.object(Region, "new") + image.regions.add_region(RT.RECTANGLE, [(10, 10), (100, 100)], 90, "name") + mock_new.assert_called_with(image.regions, RT.RECTANGLE, [(10, 10), (100, 100)], 90, "name") + + +@pytest.mark.parametrize("func,args,kwargs,expected_args,expected_kwargs", [ + ("add_point", [(10, 10)], {}, [RT.POINT, [(10, 10)]], {"name": ""}), + ("add_point", [("10", "10")], {}, [RT.POINT, [(10, 10)]], {"name": ""}), + ("add_point", [(10, 10)], {"annotation": True}, [RT.ANNPOINT, [(10, 10)]], {"name": ""}), + ("add_point", [(10, 10)], {"name": "my region"}, [RT.POINT, [(10, 10)]], {"name": "my region"}), + + ("add_rectangle", [(10, 10), (20, 30)], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle", [("10", "10"), ("20", "30")], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle", [("10", "10"), (20, 30)], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle", [(10, 10), ("20", "30")], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle", [(10, 10), (20, 30)], {"annotation": True}, [RT.ANNRECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle", [(10, 10), (20, 30)], {"name": "my region"}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, "my region"], {}), + ("add_rectangle", [(10, 10), (20, 30)], {"rotation": 45}, [RT.RECTANGLE, [(10, 10), (20, 30)], 45, ""], {}), + + ("add_rectangle_from_corners", [(0, -5), (20, 25)], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle_from_corners", [("0", "-5"), ("20", "25")], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle_from_corners", [("0", "-5"), (20, 25)], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle_from_corners", [(0, -5), ("20", "25")], {}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle_from_corners", [(0, -5), (20, 25)], {"annotation": True}, [RT.ANNRECTANGLE, [(10, 10), (20, 30)], 0, ""], {}), + ("add_rectangle_from_corners", [(0, -5), (20, 25)], {"name": "my region"}, [RT.RECTANGLE, [(10, 10), (20, 30)], 0, "my region"], {}), + ("add_rectangle_from_corners", [(0, -5), (20, 25)], {"rotation": 45}, [RT.RECTANGLE, [(10, 10), (20, 30)], 45, ""], {}), + + ("add_ellipse", [(10, 10), (10, 15)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse", [("10", "10"), ("10", "15")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse", [("10", "10"), (10, 15)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse", [(10, 10), ("10", "15")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse", [(10, 10), (10, 15)], {"annotation": True}, [RT.ANNELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse", [(10, 10), (10, 15)], {"name": "my region"}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, "my region"], {}), + ("add_ellipse", [(10, 10), (10, 15)], {"rotation": 45}, [RT.ELLIPSE, [(10, 10), (10, 15)], 45, ""], {}), + + ("add_ellipse_from_size", [(10, 10), (30, 20)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_size", [("10", "10"), ("30", "20")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_size", [("10", "10"), (30, 20)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_size", [(10, 10), ("30", "20")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_size", [(10, 10), (30, 20)], {"annotation": True}, [RT.ANNELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_size", [(10, 10), (30, 20)], {"name": "my region"}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, "my region"], {}), + ("add_ellipse_from_size", [(10, 10), (30, 20)], {"rotation": 45}, [RT.ELLIPSE, [(10, 10), (10, 15)], 45, ""], {}), + + ("add_ellipse_from_corners", [(-5, 0), (25, 20)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_corners", [("-5", "0"), ("25", "20")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_corners", [("-5", "0"), (25, 20)], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_corners", [(-5, 0), ("25", "20")], {}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_corners", [(-5, 0), (25, 20)], {"annotation": True}, [RT.ANNELLIPSE, [(10, 10), (10, 15)], 0, ""], {}), + ("add_ellipse_from_corners", [(-5, 0), (25, 20)], {"name": "my region"}, [RT.ELLIPSE, [(10, 10), (10, 15)], 0, "my region"], {}), + ("add_ellipse_from_corners", [(-5, 0), (25, 20)], {"rotation": 45}, [RT.ELLIPSE, [(10, 10), (10, 15)], 45, ""], {}), + + ("add_polygon", [[(10, 10), (20, 20), (30, 30)]], {}, [RT.POLYGON, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polygon", [[("10", "10"), ("20", "20"), ("30", "30")]], {}, [RT.POLYGON, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polygon", [[(10, 10), (20, 20), (30, 30)]], {"annotation": True}, [RT.ANNPOLYGON, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polygon", [[(10, 10), (20, 20), (30, 30)]], {"name": "my region"}, [RT.POLYGON, [(10, 10), (20, 20), (30, 30)]], {"name": "my region"}), + + ("add_line", [(10, 10), (20, 20)], {}, [RT.LINE, [(10, 10), (20, 20)]], {"name": ""}), + ("add_line", [("10", "10"), ("20", "20")], {}, [RT.LINE, [(10, 10), (20, 20)]], {"name": ""}), + ("add_line", [(10, 10), (20, 20)], {"annotation": True}, [RT.ANNLINE, [(10, 10), (20, 20)]], {"name": ""}), + ("add_line", [(10, 10), (20, 20)], {"name": "my region"}, [RT.LINE, [(10, 10), (20, 20)]], {"name": "my region"}), + + ("add_polyline", [[(10, 10), (20, 20), (30, 30)]], {}, [RT.POLYLINE, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polyline", [[("10", "10"), ("20", "20"), ("30", "30")]], {}, [RT.POLYLINE, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polyline", [[(10, 10), (20, 20), (30, 30)]], {"annotation": True}, [RT.ANNPOLYLINE, [(10, 10), (20, 20), (30, 30)]], {"name": ""}), + ("add_polyline", [[(10, 10), (20, 20), (30, 30)]], {"name": "my region"}, [RT.POLYLINE, [(10, 10), (20, 20), (30, 30)]], {"name": "my region"}), + + ("add_vector", [(10, 10), (20, 20)], {}, [RT.ANNVECTOR, [(10, 10), (20, 20)]], {"name": ""}), + ("add_vector", [("10", "10"), ("20", "20")], {}, [RT.ANNVECTOR, [(10, 10), (20, 20)]], {"name": ""}), + ("add_vector", [(10, 10), (20, 20)], {"name": "my region"}, [RT.ANNVECTOR, [(10, 10), (20, 20)]], {"name": "my region"}), + + ("add_text", [(10, 10), (20, 20), "text goes here"], {}, [RT.ANNTEXT, [(10, 10), (20, 20)], 0, ""], {}), + ("add_text", [("10", "10"), ("20", "20"), "text goes here"], {}, [RT.ANNTEXT, [(10, 10), (20, 20)], 0, ""], {}), + ("add_text", [("10", "10"), (20, 20), "text goes here"], {}, [RT.ANNTEXT, [(10, 10), (20, 20)], 0, ""], {}), + ("add_text", [(10, 10), ("20", "20"), "text goes here"], {}, [RT.ANNTEXT, [(10, 10), (20, 20)], 0, ""], {}), + ("add_text", [(10, 10), (20, 20), "text goes here"], {"name": "my region"}, [RT.ANNTEXT, [(10, 10), (20, 20)], 0, "my region"], {}), + ("add_text", [(10, 10), (20, 20), "text goes here"], {"rotation": 45}, [RT.ANNTEXT, [(10, 10), (20, 20)], 45, ""], {}), + + ("add_compass", [(10, 10), 100], {}, [RT.ANNCOMPASS, [(10, 10), (100, 100)]], {"name": ""}), + ("add_compass", [("10", "10"), 100], {}, [RT.ANNCOMPASS, [(10, 10), (100, 100)]], {"name": ""}), + ("add_compass", [(10, 10), 100], {"name": "my region"}, [RT.ANNCOMPASS, [(10, 10), (100, 100)]], {"name": "my region"}), + + ("add_ruler", [(10, 10), (20, 20)], {}, [RT.ANNRULER, [(10, 10), (20, 20)]], {"name": ""}), + ("add_ruler", [("10", "10"), ("20", "20")], {}, [RT.ANNRULER, [(10, 10), (20, 20)]], {"name": ""}), + ("add_ruler", [(10, 10), (20, 20)], {"name": "my region"}, [RT.ANNRULER, [(10, 10), (20, 20)]], {"name": "my region"}), +]) +def test_regionset_add_region_with_type(mocker, image, regionset_method, mock_from_world, mock_from_angular, region, func, args, kwargs, expected_args, expected_kwargs): + mock_add_region = regionset_method("add_region", None) + + if func == "add_text": + text_annotation = region(region_type=RT.ANNTEXT) + mock_add_region.return_value = text_annotation + mock_set_text = mocker.patch.object(text_annotation, "set_text") + + getattr(image.regions, func)(*args, **kwargs) + + mock_add_region.assert_called_with(*expected_args, **expected_kwargs) + + if func == "add_text": + mock_set_text.assert_called_with(args[2]) + + +def test_regionset_clear(mocker, image, regionset_method, method, region): + regionlist = [region(), region(), region()] + mock_deletes = [method(r)("delete", None) for r in regionlist] + regionset_method("list", [regionlist]) + + image.regions.clear() + + for m in mock_deletes: + m.assert_called_with() + + +def test_region_type(image, get_value): + reg = Region(image.regions, 0) # Bypass the default to test the real region_type + reg_get_value = get_value(reg, 3) + + region_type = reg.region_type + + reg_get_value.assert_called_with("regionType") + assert region_type == RT.RECTANGLE + + +@pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) +@pytest.mark.parametrize("func,args,kwargs,expected_vertices,expected_delete", ( + # Corners only + # (Note: apparent brc is actual blc because rectangle is rotated) + ("as_polygon", [], {}, [(45.0, 10.0), (-5.0, 10.0), (-5.0, 50.0), (45.0, 50.0)], False), + # Corners only + delete + ("as_polygon", [], {"delete": True}, [(45.0, 10.0), (-5.0, 10.0), (-5.0, 50.0), (45.0, 50.0)], True), + # Corners only + alias that deletes + ("to_polygon", [], {}, [(45.0, 10.0), (-5.0, 10.0), (-5.0, 50.0), (45.0, 50.0)], True), + # Oversampling; default density of 10 vertices per degree; just enough for 1 extra vertex per long side + ("as_polygon", [], {"oversampling": True}, [(45.0, 10.0), (20.0, 10.0), (-5.0, 10.0), (-5.0, 50.0), (20.0, 50.0), (45.0, 50.0)], False), + # Oversampling; density of 20 vertices per degree; 3 extra per long side + 1 extra per short side + ("as_polygon", [], {"oversampling": True, "density": 20}, [(45.0, 10.0), (32.5, 10.0), (20.0, 10.0), (7.5, 10.0), (-5.0, 10.0), (-5.0, 30.0), (-5.0, 50.0), (7.5, 50.0), (20.0, 50.0), (32.5, 50.0), (45.0, 50.0), (45.0, 30.0)], False), + # Oversampling; very low density; won't drop below 4 corners + ("as_polygon", [], {"oversampling": True, "density": 1}, [(45.0, 10.0), (-5.0, 10.0), (-5.0, 50.0), (45.0, 50.0)], False), +)) +def test_rectangle_to_polygon(mocker, regionset_method, region, property_, method, region_type, func, args, kwargs, expected_vertices, expected_delete): + mock_add_polygon = regionset_method("add_polygon", None) + + reg = region(region_type) + property_(reg)("center", (20, 30)) + property_(reg)("size", (40, 50)) + property_(reg)("wcs_size", ("0.1deg", "0.2deg")) + property_(reg)("rotation", 90) + property_(reg)("name", "something") + property_(reg)("color", "green") + property_(reg)("line_width", 1) + property_(reg)("dash_length", 2) + mock_delete = method(reg)("delete", None) + + polygon = region(region_type=RT.POLYGON) + mock_add_polygon.return_value = polygon + mock_set_color = mocker.patch.object(polygon, "set_color") + mock_set_line_style = mocker.patch.object(polygon, "set_line_style") + + getattr(reg, func)(*args, **kwargs) + + mock_add_polygon.assert_called_with(expected_vertices, annotation=(region_type == RT.ANNRECTANGLE), name="something") + mock_set_color.assert_called_with("green") + mock_set_line_style.assert_called_with(1, 2) + + if expected_delete: + mock_delete.assert_called() + else: + mock_delete.assert_not_called() + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE}) +@pytest.mark.parametrize("func,args,kwargs,expected_vertices,expected_delete", ( + # Endpoints and midpoint only + ("as_polyline", [], {}, [(10, 10), (20, 20), (30, 30)], False), + # Endpoints and midpoint only + delete + ("as_polyline", [], {"delete": True}, [(10, 10), (20, 20), (30, 30)], True), + # Endpoints and midpoint only + alias that deletes + ("to_polyline", [], {}, [(10, 10), (20, 20), (30, 30)], True), + # Oversampling; default density of 10 vertices per degree - 5 segments + ("as_polyline", [], {"oversampling": True}, [(10.0, 10.0), (14.0, 14.0), (18.0, 18.0), (22.0, 22.0), (26.0, 26.0), (30.0, 30.0)], False), + # Oversampling; density of 20 vertices per degree - 10 segments + ("as_polyline", [], {"oversampling": True, "density": 20}, [(10.0, 10.0), (12.0, 12.0), (14.0, 14.0), (16.0, 16.0), (18.0, 18.0), (20.0, 20.0), (22.0, 22.0), (24.0, 24.0), (26.0, 26.0), (28.0, 28.0), (30.0, 30.0)], False), + # Oversampling; very low density; won't fall below 3 vertices + ("as_polyline", [], {"oversampling": True, "density": 1}, [(10, 10), (20, 20), (30, 30)], False), +)) +def test_line_to_polyline(mocker, regionset_method, region, property_, method, region_type, func, args, kwargs, expected_vertices, expected_delete): + mock_add_polyline = regionset_method("add_polyline", None) + + reg = region(region_type) + + property_(reg)("endpoints", [(10, 10), (30, 30)]) + property_(reg)("wcs_length", "0.5deg") + property_(reg)("name", "something") + property_(reg)("color", "green") + property_(reg)("line_width", 1) + property_(reg)("dash_length", 2) + mock_delete = method(reg)("delete", None) + + polyline = region(region_type=RT.POLYLINE) + mock_add_polyline.return_value = polyline + mock_set_color = mocker.patch.object(polyline, "set_color") + mock_set_line_style = mocker.patch.object(polyline, "set_line_style") + + getattr(reg, func)(*args, **kwargs) + + mock_add_polyline.assert_called_with(expected_vertices, annotation=(region_type == RT.ANNLINE), name="something") + mock_set_color.assert_called_with("green") + mock_set_line_style.assert_called_with(1, 2) + + if expected_delete: + mock_delete.assert_called() + else: + mock_delete.assert_not_called() + + +@pytest.mark.parametrize("region_type", {RT.ELLIPSE, RT.ANNELLIPSE}) +@pytest.mark.parametrize("func,args,kwargs,expected_num_vertices,expected_delete", ( + # Specific number of vertices + ("as_polygon", [], {"num_vertices": 8}, 8, False), + # Specific number of vertices + delete + ("as_polygon", [], {"num_vertices": 8, "delete": True}, 8, True), + # Specific number of vertices + alias that deletes + ("to_polygon", [], {"num_vertices": 8}, 8, True), + # Default density of 10 vertices per degree; perimeter approx. 2 deg -> 20 vertices + ("as_polygon", [], {}, 20, False), + # Density of 20 vertices per degree -> 40 vertices + ("as_polygon", [], {"density": 20}, 40, False), + # Density of 5 vertices per degree -> would be 10, but minimum is 12 + ("as_polygon", [], {"density": 5}, 12, False), +)) +def test_ellipse_to_polygon(mocker, regionset_method, region, property_, method, region_type, func, args, kwargs, expected_num_vertices, expected_delete): + mock_add_polygon = regionset_method("add_polygon", None) + + reg = region(region_type) + property_(reg)("center", (20, 30)) + property_(reg)("semi_axes", (20, 25)) + property_(reg)("wcs_semi_axes", ("0.2deg", "0.42deg")) + property_(reg)("rotation", 45) + property_(reg)("name", "something") + property_(reg)("color", "green") + property_(reg)("line_width", 1) + property_(reg)("dash_length", 2) + mock_delete = method(reg)("delete", None) + + polygon = region(region_type=RT.POLYGON) + mock_add_polygon.return_value = polygon + mock_set_color = mocker.patch.object(polygon, "set_color") + mock_set_line_style = mocker.patch.object(polygon, "set_line_style") + + getattr(reg, func)(*args, **kwargs) + + mock_add_polygon.assert_called_with(mocker.ANY, annotation=(region_type == RT.ANNELLIPSE), name="something") + assert len(mock_add_polygon.call_args.args[0]) == expected_num_vertices + mock_set_color.assert_called_with("green") + mock_set_line_style.assert_called_with(1, 2) + + if expected_delete: + mock_delete.assert_called() + else: + mock_delete.assert_not_called() + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +def test_center(region, get_value, region_type): + reg = region(region_type) + reg_get_value = get_value(reg, {"x": 20, "y": 30}) + + center = reg.center + + reg_get_value.assert_called_with("center") + assert center == (20, 30) + + +@pytest.mark.parametrize("region_type", {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +def test_center_poly(region, method, property_, region_type): + reg = region(region_type) + property_(reg)("vertices", [(10, 20), (20, 40), (30, 30)]) + + center = reg.center + + assert center == (20, 30) + + +@pytest.mark.parametrize("region_type", {t for t in RT}) +def test_wcs_center(region, property_, mock_to_world, region_type): + reg = region(region_type) + property_(reg)("center", (20, 30)) + + wcs_center = reg.wcs_center + + mock_to_world.assert_called_with([(20, 30)]) + assert wcs_center == ("20", "30") + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +def test_size(region, get_value, region_type): + reg = region(region_type) + reg_get_value = get_value(reg, {"x": 20, "y": 30}) + + size = reg.size + + reg_get_value.assert_called_with("size") + if region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: + assert size == (60, 40) # The frontend size returned for an ellipse is the semi-axes, which we double and swap + else: + assert size == (20, 30) + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +def test_wcs_size(region, get_value, property_, mock_to_angular, region_type): + reg = region(region_type) + + if region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: + # Bypasses wcsSize to call own (overridden) size and converts to angular units + property_(reg)("size", (20, 30)) + else: + reg_get_value = get_value(reg, {"x": "20", "y": "30"}) + + size = reg.wcs_size + + if region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: + mock_to_angular.assert_called_with([(20, 30)]) + assert size == ("20", "30") + else: + reg_get_value.assert_called_with("wcsSize") + assert size == ("20\"", "30\"") + + +def test_control_points(region, get_value): + reg = region() + get_value(reg, [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 5, "y": 6}]) + points = reg.control_points + assert points == [(1, 2), (3, 4), (5, 6)] + + +@pytest.mark.parametrize("method_name,value_name", [ + ("name", "name"), + ("color", "color"), +]) +def test_common_properties(region, get_value, method_name, value_name): + reg = region() + mock_value_getter = get_value(reg, "dummy") + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == "dummy" + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +@pytest.mark.parametrize("method_name,value_name", [ + ("line_width", "lineWidth"), + ("dash_length", "dashLength"), +]) +def test_line_style_properties(region, get_value, region_type, method_name, value_name): + reg = region(region_type) + mock_value_getter = get_value(reg, "dummy") + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == "dummy" + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +@pytest.mark.parametrize("value,expected_value", [ + ((20, 30), Pt(20, 30)), + (("20", "30"), Pt(20, 30)), +]) +def test_set_center(region, mock_from_world, call_action, region_type, value, expected_value): + reg = region(region_type) + mock_call = call_action(reg) + reg.set_center(value) + mock_call.assert_called_with("setCenter", expected_value) + + +@pytest.mark.parametrize("region_type", {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +@pytest.mark.parametrize("value,expected_value", [ + ((15, 25), [(5, 15), (15, 35), (25, 25)]), + (("15", "25"), [(5, 15), (15, 35), (25, 25)]), +]) +def test_set_center_poly(region, mock_from_world, method, property_, region_type, value, expected_value): + reg = region(region_type) + + property_(reg)("vertices", [(10, 10), (20, 30), (30, 20)]) + property_(reg)("center", (20, 20)) + mock_set_vertices = method(reg)("set_vertices", None) + reg.set_center(value) + + mock_set_vertices.assert_called_with(expected_value) + + +@pytest.mark.parametrize("region_type", {t for t in RT}) +@pytest.mark.parametrize("value,expected_value", [ + ((20, 30), (40, 50)), + (("20", "30"), (40, 50)), +]) +def test_translate(region, mock_from_world, mock_from_angular, method, property_, region_type, value, expected_value): + reg = region(region_type) + property_(reg)("center", (20, 20)) + mock_set_center = method(reg)("set_center", None) + + reg.translate(value) + + mock_set_center.assert_called_with(expected_value) + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT, RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +@pytest.mark.parametrize("value,expected_value", [ + ((20, 30), Pt(20, 30)), + ((-20, -30), Pt(20, 30)), + (("20", "30"), Pt(20, 30)), + (("-20", "-30"), Pt(20, 30)), +]) +def test_set_size(region, mock_from_angular, call_action, region_type, value, expected_value): + reg = region(region_type) + mock_call = call_action(reg) + reg.set_size(value) + + if region_type == RT.ANNCOMPASS: + mock_call.assert_called_with("setLength", min(expected_value.x, expected_value.y)) + elif region_type in {RT.ELLIPSE, RT.ANNELLIPSE}: + mock_call.assert_called_with("setSize", Pt(expected_value.y / 2, expected_value.x / 2)) + else: + mock_call.assert_called_with("setSize", expected_value) + + +@pytest.mark.parametrize("region_type", {RT.POLYGON, RT.POLYLINE, RT.ANNPOLYGON, RT.ANNPOLYLINE}) +@pytest.mark.parametrize("value,expected_value", [ + ((20, 30), [(10.0, 5.0), (20.0, 35.0), (30.0, 20.0)]), + ((-20, -30), [(10.0, 5.0), (20.0, 35.0), (30.0, 20.0)]), + (("20", "30"), [(10.0, 5.0), (20.0, 35.0), (30.0, 20.0)]), + (("-20", "-30"), [(10.0, 5.0), (20.0, 35.0), (30.0, 20.0)]), +]) +def test_set_size_poly(region, mock_from_angular, method, property_, region_type, value, expected_value): + reg = region(region_type) + property_(reg)("vertices", [(10, 10), (20, 30), (30, 20)]) + property_(reg)("size", (20, 20)) + property_(reg)("center", (20, 20)) + mock_set_vertices = method(reg)("set_vertices", None) + + reg.set_size(value) + + mock_set_vertices.assert_called_with(expected_value) + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +def test_scale(region, method, property_, region_type): + reg = region(region_type) + property_(reg)("size", (20, 30)) + mock_set_size = method(reg)("set_size", None) + + reg.scale(2) + + mock_set_size.assert_called_with((40, 60)) + + +def test_set_control_point(region, call_action): + reg = region() + mock_call = call_action(reg) + reg.set_control_point(3, (20, 30)) + mock_call.assert_called_with("setControlPoint", 3, Pt(20, 30)) + + +def test_set_control_points(region, call_action): + reg = region() + mock_call = call_action(reg) + reg.set_control_points([(20, 30), (40, 50)]) + mock_call.assert_called_with("setControlPoints", [Pt(20, 30), Pt(40, 50)]) + + +def test_set_name(region, call_action): + reg = region() + mock_call = call_action(reg) + reg.set_name("My region name") + mock_call.assert_called_with("setName", "My region name") + + +def test_set_color(region, call_action): + reg = region() + mock_call = call_action(reg) + reg.set_color("blue") + mock_call.assert_called_with("setColor", "blue") + + +@pytest.mark.parametrize("region_type", {t for t in RT} - {RT.POINT, RT.ANNPOINT}) +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([2, 3], {}, [("setLineWidth", 2), ("setDashLength", 3)]), + ([2], {"dash_length": 3}, [("setLineWidth", 2), ("setDashLength", 3)]), + ([], {"line_width": 2}, [("setLineWidth", 2)]), +]) +def test_set_line_style(mocker, region, call_action, region_type, args, kwargs, expected_calls): + reg = region(region_type) + mock_call = call_action(reg) + reg.set_line_style(*args, **kwargs) + mock_call.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +def test_lock(region, call_action): + reg = region() + mock_call = call_action(reg) + reg.lock() + mock_call.assert_called_with("setLocked", True) + + +def test_unlock(region, call_action): + reg = region() + mock_call = call_action(reg) + reg.unlock() + mock_call.assert_called_with("setLocked", False) + + +def test_focus(region, call_action): + reg = region() + mock_call = call_action(reg) + reg.focus() + mock_call.assert_called_with("focusCenter") + + +@pytest.mark.parametrize("args,kwargs,expected_params", [ + (["/path/to/file"], {}, ["/path/to/file", CT.WORLD, FT.CRTF]), + (["/path/to/file", CT.PIXEL, FT.DS9_REG], {}, ["/path/to/file", CT.PIXEL, FT.DS9_REG]), + (["/path/to/file"], {"coordinate_type": CT.PIXEL}, ["/path/to/file", CT.PIXEL, FT.CRTF]), + (["/path/to/file"], {"file_type": FT.DS9_REG}, ["/path/to/file", CT.WORLD, FT.DS9_REG]), +]) +def test_export_to(region, regionset_method, args, kwargs, expected_params): + reg = region() + mock_export = regionset_method("export_to", None) + + reg.export_to(*args, **kwargs) + + mock_export.assert_called_with(*expected_params, [reg.region_id]) + + +def test_delete(region, regionset_call_action): + reg = region() + reg.delete() + regionset_call_action.assert_called_with("deleteRegion", Macro("", f"{reg.region_set._base_path}.regionMap[{reg.region_id}]")) + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.RECTANGLE, RT.ANNRECTANGLE, RT.ELLIPSE, RT.ANNELLIPSE, RT.ANNTEXT, RT.ANNVECTOR, RT.ANNRULER}) +def test_rotation(region, get_value, region_type): + reg = region(region_type) + mock_rotation = get_value(reg, "dummy") + value = reg.rotation + mock_rotation.assert_called_with("rotation") + assert value == "dummy" + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.RECTANGLE, RT.ANNRECTANGLE, RT.ELLIPSE, RT.ANNELLIPSE, RT.ANNTEXT, RT.ANNVECTOR, RT.ANNRULER}) +def test_set_rotation(region, call_action, region_type): + reg = region(region_type) + mock_call = call_action(reg) + reg.set_rotation(45) + mock_call.assert_called_with("setRotation", 45) + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.RECTANGLE, RT.ANNRECTANGLE, RT.ELLIPSE, RT.ANNELLIPSE, RT.ANNTEXT, RT.ANNVECTOR, RT.ANNRULER}) +def test_rotate(region, method, property_, region_type): + reg = region(region_type) + property_(reg)("rotation", 45) + mock_set_rotation = method(reg)("set_rotation", None) + + reg.rotate(10) + + mock_set_rotation.assert_called_with(55) + + +@pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) +def test_rotate_poly(region, method, property_, region_type): + reg = region(region_type) + property_(reg)("vertices", [(10, 20), (20, 40), (30, 30)]) + property_(reg)("center", (20, 30)) + mock_set_vertices = method(reg)("set_vertices", None) + + reg.rotate(90) + + # Rotation is applied anti-clockwise. + mock_set_vertices.assert_called_with([(30.0, 20.0), (10.0, 30.0), (20.0, 40.0)]) + + +@pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) +def test_vertices(region, property_, region_type): + reg = region(region_type) + property_(reg)("control_points", [(10, 10), (20, 30), (30, 20)]) + vertices = reg.vertices + assert vertices == [(10, 10), (20, 30), (30, 20)] + + +@pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) +def test_wcs_vertices(region, property_, mock_to_world, region_type): + reg = region(region_type) + property_(reg)("control_points", [(10, 10), (20, 30), (30, 20)]) + vertices = reg.wcs_vertices + assert vertices == [("10", "10"), ("20", "30"), ("30", "20")] + + +@pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) +@pytest.mark.parametrize("vertex", [(30, 40), ("30", "40")]) +def test_set_vertex(region, method, mock_from_world, region_type, vertex): + reg = region(region_type) + mock_set_control_point = method(reg)("set_control_point", None) + reg.set_vertex(1, vertex) + mock_set_control_point.assert_called_with(1, (30, 40)) + + +@pytest.mark.parametrize("region_type", {RT.POLYLINE, RT.POLYGON, RT.ANNPOLYLINE, RT.ANNPOLYGON}) +@pytest.mark.parametrize("vertices", [ + [(10, 10), (20, 30), (30, 20)], + [("10", "10"), ("20", "30"), ("30", "20")], +]) +def test_set_vertices(region, method, mock_from_world, region_type, vertices): + reg = region(region_type) + mock_set_control_points = method(reg)("set_control_points", None) + reg.set_vertices(vertices) + mock_set_control_points.assert_called_with([(10, 10), (20, 30), (30, 20)]) + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +def test_endpoints(region, property_, region_type): + reg = region(region_type) + property_(reg)("control_points", [(10, 10), (20, 30)]) + endpoints = reg.endpoints + assert endpoints == [(10, 10), (20, 30)] + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +def test_wcs_endpoints(region, property_, mock_to_world, region_type): + reg = region(region_type) + property_(reg)("control_points", [(10, 10), (20, 30)]) + endpoints = reg.wcs_endpoints + assert endpoints == [("10", "10"), ("20", "30")] + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +def test_length(region, property_, region_type): + reg = region(region_type) + property_(reg)("size", (30, 40)) + length = reg.length + assert length == 50 + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +def test_wcs_length(region, property_, region_type): + reg = region(region_type) + property_(reg)("wcs_size", ("30", "40")) + length = reg.wcs_length + assert length == "50\"" + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([(10, 10), (20, 30)], {}, [(0, (10, 10)), (1, (20, 30))]), + ([("10", "10"), ("20", "30")], {}, [(0, (10, 10)), (1, (20, 30))]), + ([(10, 10), ("20", "30")], {}, [(0, (10, 10)), (1, (20, 30))]), + ([], {"start": (10, 10)}, [(0, (10, 10))]), + ([], {"end": (20, 30)}, [(1, (20, 30))]), +]) +def test_set_endpoints(mocker, region, method, mock_from_world, region_type, args, kwargs, expected_calls): + reg = region(region_type) + mock_set_control_point = method(reg)("set_control_point", None) + reg.set_endpoints(*args, **kwargs) + mock_set_control_point.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("region_type", {RT.LINE, RT.ANNLINE, RT.ANNVECTOR, RT.ANNRULER}) +@pytest.mark.parametrize("length", [math.sqrt(800), str(math.sqrt(800))]) +def test_set_length(mocker, region, property_, region_type, length): + reg = region(region_type) + + property_(reg)("length", 100) + property_(reg)("wcs_length", "100") + property_(reg)("rotation", 45) + mock_region_set_size = mocker.patch.object(HasSizeMixin, "set_size") + + reg.set_length(length) + + mock_region_set_size.assert_called() + (s1, s2), = mock_region_set_size.call_args.args + assert math.isclose(s1, 20) + assert math.isclose(s2, -20) + + +@pytest.mark.parametrize("region_type", {RT.ANNTEXT, RT.ANNCOMPASS, RT.ANNRULER}) +@pytest.mark.parametrize("method_name,value_name,mocked_value,expected_value", [ + ("font_size", "fontSize", 20, 20), + ("font_style", "fontStyle", "Bold", AFS.BOLD), + ("font", "font", "Courier", AF.COURIER), +]) +def test_font_properties(region, get_value, region_type, method_name, value_name, mocked_value, expected_value): + reg = region(region_type) + mock_value_getter = get_value(reg, mocked_value) + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == expected_value + + +@pytest.mark.parametrize("region_type", {RT.ANNTEXT, RT.ANNCOMPASS, RT.ANNRULER}) +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([AF.COURIER, 20, AFS.BOLD], {}, [("setFont", AF.COURIER), ("setFontSize", 20), ("setFontStyle", AFS.BOLD)]), + ([], {"font": AF.COURIER, "font_size": 20, "font_style": AFS.BOLD}, [("setFont", AF.COURIER), ("setFontSize", 20), ("setFontStyle", AFS.BOLD)]), + ([AF.COURIER], {"font_style": AFS.BOLD}, [("setFont", AF.COURIER), ("setFontStyle", AFS.BOLD)]), + ([], {"font_size": 20}, [("setFontSize", 20)]), +]) +def test_set_font(mocker, region, call_action, region_type, args, kwargs, expected_calls): + reg = region(region_type) + mock_action_caller = call_action(reg) + reg.set_font(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("region_type", {RT.ANNVECTOR, RT.ANNCOMPASS}) +@pytest.mark.parametrize("method_name,value_name", [ + ("pointer_width", "pointerWidth"), + ("pointer_length", "pointerLength"), +]) +def test_pointer_properties(region, get_value, region_type, method_name, value_name): + reg = region(region_type) + mock_value_getter = get_value(reg, "dummy") + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == "dummy" + + +@pytest.mark.parametrize("region_type", {RT.ANNVECTOR, RT.ANNCOMPASS}) +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([2, 20], {}, [("setPointerWidth", 2), ("setPointerLength", 20)]), + ([], {"pointer_length": 20}, [("setPointerLength", 20)]), +]) +def test_set_pointer_style(mocker, region, call_action, region_type, args, kwargs, expected_calls): + reg = region(region_type) + mock_action_caller = call_action(reg) + reg.set_pointer_style(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) +def test_corners(region, property_, region_type): + reg = region(region_type) + property_(reg)("center", (100, 200)) + property_(reg)("size", (30, 40)) + + bottom_left, top_right = reg.corners + + assert bottom_left == (85, 180) + assert top_right == (115, 220) + + +@pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) +def test_wcs_corners(region, property_, mock_to_world, region_type): + reg = region(region_type) + property_(reg)("corners", [(85, 180), (115, 220)]) + + bottom_left, top_right = reg.wcs_corners + + assert bottom_left == ("85", "180") + assert top_right == ("115", "220") + + +@pytest.mark.parametrize("region_type", {RT.RECTANGLE, RT.ANNRECTANGLE}) +@pytest.mark.parametrize("args,kwargs,expected_args", [ + ([], {}, None), + ([(75, 170), (135, 240)], {}, [(105.0, 205.0), (60, 70)]), + ([(75, 170)], {}, [(95.0, 195.0), (40, 50)]), + ([], {"top_right": (135, 240)}, [(110.0, 210.0), (50, 60)]), +]) +def test_set_corners(region, method, property_, mock_from_world, region_type, args, kwargs, expected_args): + reg = region(region_type) + property_(reg)("corners", [(85, 180), (115, 220)]) + mock_set_control_points = method(reg)("set_control_points", None) + + reg.set_corners(*args, **kwargs) + + if expected_args is None: + mock_set_control_points.assert_not_called() + else: + mock_set_control_points.assert_called_with(expected_args) + + +@pytest.mark.parametrize("region_type", {RT.ELLIPSE, RT.ANNELLIPSE}) +def test_semi_axes(mocker, region, region_type): + reg = region(region_type) + mocker.patch("carta.region.HasSizeMixin.size", new_callable=mocker.PropertyMock, return_value=(20, 30)) + + semi_axes = reg.semi_axes + + assert semi_axes == (20, 30) + + +@pytest.mark.parametrize("region_type", {RT.ELLIPSE, RT.ANNELLIPSE}) +def test_wcs_semi_axes(mocker, region, region_type): + reg = region(region_type) + mocker.patch("carta.region.HasSizeMixin.wcs_size", new_callable=mocker.PropertyMock, return_value=("20", "30")) + + semi_axes = reg.wcs_semi_axes + + assert semi_axes == ("20", "30") + + +@pytest.mark.parametrize("region_type", {RT.ELLIPSE, RT.ANNELLIPSE}) +@pytest.mark.parametrize("semi_axes", [(20, 30), ("20", "30")]) +def test_set_semi_axes(mocker, region, mock_from_angular, region_type, semi_axes): + reg = region(region_type) + mock_region_set_size = mocker.patch.object(HasSizeMixin, "set_size") + + reg.set_semi_axes(semi_axes) + + mock_region_set_size.assert_called_with((20, 30)) + + +@pytest.mark.parametrize("method_name,value_name,mocked_value,expected_value", [ + ("point_shape", "pointShape", 2, PS.CIRCLE), + ("point_width", "pointWidth", 5, 5), +]) +def test_point_properties(region, get_value, method_name, value_name, mocked_value, expected_value): + reg = region(RT.ANNPOINT) + mock_value_getter = get_value(reg, mocked_value) + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == expected_value + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([PS.CIRCLE, 5], {}, [("setPointShape", PS.CIRCLE), ("setPointWidth", 5)]), + ([], {"point_shape": PS.CIRCLE}, [("setPointShape", PS.CIRCLE)]), + ([], {"point_width": 5}, [("setPointWidth", 5)]), +]) +def test_set_point_style(mocker, region, call_action, args, kwargs, expected_calls): + reg = region(RT.ANNPOINT) + mock_action_caller = call_action(reg) + reg.set_point_style(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("method_name,value_name,mocked_value,expected_value", [ + ("text", "text", "my text", "my text"), + ("position", "position", 3, TP.LOWER_LEFT), +]) +def test_text_properties(region, get_value, method_name, value_name, mocked_value, expected_value): + reg = region(RT.ANNTEXT) + mock_value_getter = get_value(reg, mocked_value) + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == expected_value + + +def test_set_text(region, call_action): + reg = region(RT.ANNTEXT) + mock_action_caller = call_action(reg) + reg.set_text("my text") + mock_action_caller.assert_called_with("setText", "my text") + + +def test_set_text_position(region, call_action): + reg = region(RT.ANNTEXT) + mock_action_caller = call_action(reg) + reg.set_text_position(TP.LOWER_LEFT) + mock_action_caller.assert_called_with("setPosition", TP.LOWER_LEFT) + + +@pytest.mark.parametrize("method_name,value_names,mocked_values,expected_value", [ + ("labels", ["northLabel", "eastLabel"], ["N", "E"], ("N", "E")), + ("point_length", ["length"], [100], 100), + ("label_offsets", ["northTextOffset", "eastTextOffset"], [{"x": 1, "y": 2}, {"x": 3, "y": 4}], ((1, 2), (3, 4))), + ("arrowheads_visible", ["northArrowhead", "eastArrowhead"], [True, False], (True, False)), +]) +def test_compass_properties(region, mocker, method_name, value_names, mocked_values, expected_value): + reg = region(RT.ANNCOMPASS) + mock_value_getter = mocker.patch.object(reg, "get_value", side_effect=mocked_values) + value = getattr(reg, method_name) + mock_value_getter.assert_has_calls([mocker.call(name) for name in value_names]) + assert value == expected_value + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + (["N", "E"], {}, [("setLabel", "N", True), ("setLabel", "E", False),]), + (["N"], {}, [("setLabel", "N", True)]), + ([], {"east_label": "E"}, [("setLabel", "E", False)]), +]) +def test_set_label(mocker, region, call_action, args, kwargs, expected_calls): + reg = region(RT.ANNCOMPASS) + mock_action_caller = call_action(reg) + reg.set_label(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("args,kwargs,expected_calls,error_contains", [ + ([100], {}, [("setLength", 100)], None), + (["100", SA.X], {}, [("setLength", 100)], None), + (["100"], {"spatial_axis": SA.X}, [("setLength", 100)], None), + (["100"], {}, [], "Please specify a spatial axis"), +]) +def test_set_point_length(mocker, region, call_action, image_method, args, kwargs, expected_calls, error_contains): + reg = region(RT.ANNCOMPASS) + mock_action_caller = call_action(reg) + image_method("from_angular_size", [100]) + + if error_contains is None: + reg.set_point_length(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + else: + with pytest.raises(ValueError) as e: + reg.set_point_length(*args, **kwargs) + assert error_contains in str(e.value) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([(1, 2), (3, 4)], {}, [("setNorthTextOffset", 1, True), ("setNorthTextOffset", 2, False), ("setEastTextOffset", 3, True), ("setEastTextOffset", 4, False)]), + ([(1, 2)], {}, [("setNorthTextOffset", 1, True), ("setNorthTextOffset", 2, False)]), + ([], {"east_offset": (3, 4)}, [("setEastTextOffset", 3, True), ("setEastTextOffset", 4, False)]), +]) +def test_set_label_offset(mocker, region, call_action, args, kwargs, expected_calls): + reg = region(RT.ANNCOMPASS) + mock_action_caller = call_action(reg) + reg.set_label_offset(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([True, False], {}, [("setNorthArrowhead", True), ("setEastArrowhead", False)]), + ([True], {}, [("setNorthArrowhead", True)]), + ([], {"east": False}, [("setEastArrowhead", False)]), +]) +def test_set_arrowhead_visible(mocker, region, call_action, args, kwargs, expected_calls): + reg = region(RT.ANNCOMPASS) + mock_action_caller = call_action(reg) + reg.set_arrowhead_visible(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("method_name,value_name,mocked_value,expected_value", [ + ("auxiliary_lines_visible", "auxiliaryLineVisible", True, True), + ("auxiliary_lines_dash_length", "auxiliaryLineDashLength", 5, 5), + ("text_offset", "textOffset", {"x": 1, "y": 2}, (1, 2)), +]) +def test_ruler_properties(region, get_value, method_name, value_name, mocked_value, expected_value): + reg = region(RT.ANNRULER) + mock_value_getter = get_value(reg, mocked_value) + value = getattr(reg, method_name) + mock_value_getter.assert_called_with(value_name) + assert value == expected_value + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([True, 5], {}, [("setAuxiliaryLineVisible", True), ("setAuxiliaryLineDashLength", 5)]), + ([True], {}, [("setAuxiliaryLineVisible", True)]), + ([], {"dash_length": 5}, [("setAuxiliaryLineDashLength", 5)]), +]) +def test_set_auxiliary_lines_style(mocker, region, call_action, args, kwargs, expected_calls): + reg = region(RT.ANNRULER) + mock_action_caller = call_action(reg) + reg.set_auxiliary_lines_style(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([1, 2], {}, [("setTextOffset", 1, True), ("setTextOffset", 2, False)]), + ([1], {}, [("setTextOffset", 1, True)]), + ([], {"offset_y": 2}, [("setTextOffset", 2, False)]), +]) +def test_set_text_offset(mocker, region, call_action, args, kwargs, expected_calls): + reg = region(RT.ANNRULER) + mock_action_caller = call_action(reg) + reg.set_text_offset(*args, **kwargs) + mock_action_caller.assert_has_calls([mocker.call(*c) for c in expected_calls]) diff --git a/tests/test_session.py b/tests/test_session.py index 594d301..cf79849 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -56,12 +56,11 @@ def test_pwd(session, call_action, get_value): assert pwd == "/current/dir" -def test_ls(session, method, call_action, get_value): +def test_ls(session, method, call_action): method("pwd", ["/current/dir"]) - get_value.side_effect = [{"files": [{"name": "foo.fits"}, {"name": "bar.fits"}], "subdirectories": [{"name": "baz"}]}] + call_action.side_effect = [{"files": [{"name": "foo.fits"}, {"name": "bar.fits"}], "subdirectories": [{"name": "baz"}]}] ls = session.ls() - call_action.assert_called_with("fileBrowserStore.getFileList", "/current/dir") - get_value.assert_called_with("fileBrowserStore.fileList") + call_action.assert_called_with("backendService.getFileList", "/current/dir", 2) assert ls == ["bar.fits", "baz/", "foo.fits"] diff --git a/tests/test_units.py b/tests/test_units.py index b9c422c..b8c449b 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -11,11 +11,11 @@ ("123 deg", True), ("123 degree", True), ("123 degrees", True), + ("-123deg", True), ("123 arcmin", False), ("123cm", False), ("abc", False), - ("-123", False), ("123px", False), ]) def test_degrees_size_valid(size, valid): @@ -35,11 +35,11 @@ def test_degrees_size_valid(size, valid): ("123 amin", True), ("123'", True), ("123′", True), + ("-123arcmin", True), ("123 degrees", False), ("123cm", False), ("abc", False), - ("-123", False), ("123px", False), ]) def test_arcmin_size_valid(size, valid): @@ -60,11 +60,11 @@ def test_arcmin_size_valid(size, valid): ("123", True), ("123\"", True), ("123″", True), + ("-123", True), ("123 degrees", False), ("123cm", False), ("abc", False), - ("-123", False), ("123px", False), ]) def test_arcsec_size_valid(size, valid): @@ -82,11 +82,11 @@ def test_arcsec_size_valid(size, valid): ("123 milliarcsecond", True), ("123 milliarcsec", True), ("123 mas", True), + ("-123mas", True), ("123 degrees", False), ("123cm", False), ("abc", False), - ("-123", False), ("123px", False), ]) def test_milliarcsec_size_valid(size, valid): @@ -106,11 +106,11 @@ def test_milliarcsec_size_valid(size, valid): ("123 microarcsec", True), ("123 µas", True), ("123 uas", True), + ("-123uas", True), ("123 degrees", False), ("123cm", False), ("abc", False), - ("-123", False), ("123px", False), ]) def test_microarcsec_size_valid(size, valid): @@ -130,6 +130,7 @@ def test_microarcsec_size_valid(size, valid): ("123 amin", "123'"), ("123'", "123'"), ("123′", "123'"), + ("-123'", "-123'"), ]) def test_arcmin_size_from_string(size, norm): assert str(ArcminSize.from_string(size)) == norm @@ -148,6 +149,7 @@ def test_arcmin_size_from_string(size, norm): ("123", "123\""), ("123\"", "123\""), ("123″", "123\""), + ("-123", "-123\""), ]) def test_arcsec_size_from_string(size, norm): assert str(ArcsecSize.from_string(size)) == norm @@ -161,6 +163,7 @@ def test_arcsec_size_from_string(size, norm): ("123 deg", "123deg"), ("123 degree", "123deg"), ("123 degrees", "123deg"), + ("-123deg", "-123deg"), ]) def test_degrees_size_from_string(size, norm): assert str(DegreesSize.from_string(size)) == norm @@ -176,6 +179,7 @@ def test_degrees_size_from_string(size, norm): ("123 milliarcsecond", "0.123\""), ("123 milliarcsec", "0.123\""), ("123 mas", "0.123\""), + ("-123mas", "-0.123\""), ]) def test_milliarcsec_size_from_string(size, norm): assert str(MilliarcsecSize.from_string(size)) == norm @@ -193,6 +197,7 @@ def test_milliarcsec_size_from_string(size, norm): ("123 microarcsec", "0.000123\""), ("123 µas", "0.000123\""), ("123 uas", "0.000123\""), + ("-123uas", "-0.000123\""), ]) def test_microarcsec_size_from_string(size, norm): assert str(MicroarcsecSize.from_string(size)) == norm @@ -212,13 +217,33 @@ def test_angular_size_from_string_one_invalid(clazz, size): assert "not in a recognized" in str(e.value) -@pytest.mark.parametrize("size", ["123cm", "abc", "-123", "123px"]) +@pytest.mark.parametrize("size", ["123cm", "abc", "123px"]) def test_angular_size_from_string_all_invalid(size): with pytest.raises(ValueError) as e: AngularSize.from_string(size) assert "not in a recognized angular size format" in str(e.value) +@pytest.mark.parametrize("val,norm", [ + (10800, "3deg"), + (6000, "100'"), + (100, "100\""), + (0.001, "0.001\""), +]) +def test_angular_size_from_arcsec(val, norm): + assert str(AngularSize.from_arcsec(val)) == norm + + +@pytest.mark.parametrize("size,val", [ + ("3deg", 10800), + ("100'", 6000), + ("100\"", 100), + ("1mas", 0.001), +]) +def test_angular_size_arcsec(size, val): + assert AngularSize.from_string(size).arcsec == val + + @pytest.mark.parametrize("coord,valid", [ ("0deg", True), ("123 degrees", True), diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..5bb8b3f --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,36 @@ +from carta.util import Point as Pt + + +def test_point_equality(): + assert Pt(1, 2) == Pt(1, 2) + assert Pt("1", "2") == Pt("1", "2") + assert Pt(1, 2) != Pt("1", "2") + assert Pt(1, 2) != (1, 2) + + +def test_point_is_pixel(): + assert Pt.is_pixel(1, 2) + assert not Pt.is_pixel("1", 2) + assert not Pt.is_pixel("1", "2") + + +def test_point_is_wcs(): + assert Pt.is_wcs_coordinate("123", "123") + assert Pt.is_wcs_coordinate("12:34:56", "12:34:56") + assert not Pt.is_wcs_coordinate(1, 2) + + +def test_point_is_angular(): + assert Pt.is_angular_size("123", "123") + assert Pt.is_angular_size("123'", "123'") + assert not Pt.is_angular_size(1, 2) + + +def test_point_json(): + assert Pt(1, 2).json() == {"x": 1, "y": 2} + assert Pt("1", "2").json() == {"x": "1", "y": "2"} + + +def test_point_tuple(): + assert Pt(1, 2).as_tuple() == (1, 2) + assert Pt("1", "2").as_tuple() == ("1", "2") diff --git a/tests/test_validation.py b/tests/test_validation.py index 3cbe650..5bb43e0 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,6 +1,6 @@ import pytest -from carta.validation import Size, Coordinate +from carta.validation import Size, Coordinate, Point @pytest.mark.parametrize('val', [123, "123arcmin", "123arcsec", "123deg", "123degree", "123degrees", "123 arcmin", "123 arcsec", "123 deg", "123 degree", "123 degrees", "123", "123\"", "123'"]) @@ -29,3 +29,35 @@ def test_coordinate_invalid(val): with pytest.raises(ValueError) as e: v.validate(val, None) assert "not a number, a string in H:M:S or D:M:S format, or a numeric string with degree units" in str(e.value) + + +@pytest.mark.parametrize('clazz,val', [ + (Point.NumericPoint, (123, 123)), + (Point.WorldCoordinatePoint, ("123deg", "123deg")), + (Point.AngularSizePoint, ("123'", "123'")), + (Point.CoordinatePoint, (123, 123)), + (Point.CoordinatePoint, ("123deg", "123deg")), + (Point.SizePoint, (123, 123)), + (Point.SizePoint, ("123'", "123'")), + (Point, (123, 123)), + (Point, ("123'", "123'")), + (Point, ("123deg", "123deg")), +]) +def test_point_valid(clazz, val): + clazz().validate(val, None) + + +@pytest.mark.parametrize('clazz,val,error_contains', [ + (Point, (123, "123"), "not a pair of numbers, coordinate strings, or size strings"), + (Point.CoordinatePoint, (123, "123"), "not a pair of numbers or coordinate strings"), + (Point.SizePoint, (123, "123"), "a pair of numbers or size strings"), + (Point.NumericPoint, ("123", "123"), "not a pair of numbers"), + (Point.WorldCoordinatePoint, ("123'", "123'"), "not a pair of coordinate strings"), + (Point.WorldCoordinatePoint, (123, 123), "not a pair of coordinate strings"), + (Point.AngularSizePoint, ("12:34", "12:34"), "not a pair of size strings"), + (Point.AngularSizePoint, (123, 123), "not a pair of size strings"), +]) +def test_point_invalid(clazz, val, error_contains): + with pytest.raises(ValueError) as e: + clazz().validate(val, None) + assert error_contains in str(e.value)