diff --git a/carta/constants.py b/carta/constants.py index f58fc8a..12e72d0 100644 --- a/carta/constants.py +++ b/carta/constants.py @@ -52,8 +52,8 @@ class LabelType(StrEnum): class BeamType(StrEnum): """Beam types.""" - OPEN = "Open" - SOLID = "Solid" + OPEN = "open" + SOLID = "solid" # BlueprintJS colour palettes (2 and 4) @@ -103,7 +103,7 @@ class BeamType(StrEnum): class PaletteColor(StrEnum): - """Palette colours used for overlay elements. + """Palette colours used for WCS overlay elements. Members of this enum class have additional attributes. @@ -129,9 +129,9 @@ def __init__(self, value): Overlay = StrEnum('Overlay', [(c.upper(), c) for c in ("global", "title", "grid", "border", "ticks", "axes", "numbers", "labels", "colorbar")] + [('BEAM', 'beam.settingsForDisplay')]) -Overlay.__doc__ = """Overlay elements. +Overlay.__doc__ = """WCS overlay elements. - Member values are paths to stores corresponding to these elements, relative to the overlay store. + Member values are paths to stores corresponding to these elements, relative to the WCS overlay store. """ @@ -214,3 +214,95 @@ class GridMode(StrEnum): """Grid modes.""" DYNAMIC = "dynamic" FIXED = "fixed" + + +class FontFamily(IntEnum): + """Font family used in WCS overlay components.""" + SANS_SERIF = 0 + TIMES = 1 + ARIAL = 2 + PALATINO = 3 + COURIER_NEW = 4 + + +class FontStyle(IntEnum): + """Font style used in WCS overlay components.""" + NORMAL = 0 + ITALIC = 1 + BOLD = 2 + BOLD_ITALIC = 3 + + +class ColorbarPosition(StrEnum): + """Colorbar positions.""" + RIGHT = "right" + TOP = "top" + BOTTOM = "bottom" + + +class SpectralSystem(StrEnum): + """Spectral systems.""" + LSRK = "LSRK" + LSRD = "LSRD" + BARY = "BARYCENT" + TOPO = "TOPOCENT" + + +class SpectralUnit(StrEnum): + """Spectral units.""" + KMS = "km/s" + MS = "m/s" + GHZ = "GHz" + MHZ = "MHz" + KHZ = "kHz" + HZ = "Hz" + M = "m" + MM = "mm" + UM = "um" + NM = "nm" + ANGSTROM = "Angstrom" + + +SPECTRAL_TYPE_DESCRIPTION = { + "VRAD": "Radio velocity", + "VOPT": "Optical velocity", + "FREQ": "Frequency", + "WAVE": "Vacuum wavelength", + "AWAV": "Air wavelength", +} + + +SPECTRAL_TYPE_UNITS = { + "VRAD": (SpectralUnit.KMS, SpectralUnit.MS), + "VOPT": (SpectralUnit.KMS, SpectralUnit.MS), + "FREQ": (SpectralUnit.GHZ, SpectralUnit.MHZ, SpectralUnit.KHZ, SpectralUnit.HZ), + "WAVE": (SpectralUnit.MM, SpectralUnit.M, SpectralUnit.UM, SpectralUnit.NM, SpectralUnit.ANGSTROM), + "AWAV": (SpectralUnit.MM, SpectralUnit.M, SpectralUnit.UM, SpectralUnit.NM, SpectralUnit.ANGSTROM), +} + + +class SpectralType(StrEnum): + """Spectral types. + + Members of this enum class have additional attributes. + + Attributes + ---------- + description : string + The human-readable description of this type. + units : set of :obj:`carta.constants.SpectralUnit` + The units supported for this type. + default_unit : :obj:`carta.constants.SpectralUnit` + The default unit for this type. + """ + + def __init__(self, value): + self.description = SPECTRAL_TYPE_DESCRIPTION[self.name] + self.units = set(SPECTRAL_TYPE_UNITS[self.name]) + self.default_unit = SPECTRAL_TYPE_UNITS[self.name][0] + + VRAD = "VRAD", + VOPT = "VOPT", + FREQ = "FREQ", + WAVE = "WAVE", + AWAV = "AWAV", diff --git a/carta/contours.py b/carta/contours.py new file mode 100644 index 0000000..a876836 --- /dev/null +++ b/carta/contours.py @@ -0,0 +1,186 @@ +"""This module contains functionality for interacting with the contours of an image. The class in this module should not be instantiated directly. When an image object is created, a contours object is automatically created as a property.""" + +from .util import BasePathMixin +from .constants import Colormap, SmoothingMode, ContourDashMode +from .validation import validate, Number, Color, Constant, Boolean, IterableOf, all_optional, vargs + + +class Contours(BasePathMixin): + """Utility object for collecting image functions related to contours. + + Parameters + ---------- + image : :obj:`carta.image.Image` object + The image associated with this contours object. + + Attributes + ---------- + image : :obj:`carta.image.Image` object + The image associated with this contours object. + session : :obj:`carta.session.Session` object + The session object associated with this contours object. + """ + + def __init__(self, image): + self.image = image + self.session = image.session + self._base_path = f"{image._base_path}.contourConfig" + + @validate(*all_optional(IterableOf(Number()), Constant(SmoothingMode), Number())) + def configure(self, levels=None, smoothing_mode=None, smoothing_factor=None): + """Configure contours. + + Parameters + ---------- + levels : {0} + The contour levels. This may be a numeric numpy array; e.g. the output of ``arange``. If this is unset, the current configured levels will be used. + smoothing_mode : {1} + The smoothing mode. If this is unset, the frontend default will be used. + smoothing_factor : {2} + The smoothing kernel size in pixels. If this is unset, the frontend default will be used. + """ + if levels is not None or smoothing_mode is not None or smoothing_factor is not None: + if levels is None: + levels = self.macro("", "levels") + if smoothing_mode is None: + smoothing_mode = self.macro("", "smoothingMode") + if smoothing_factor is None: + smoothing_factor = self.macro("", "smoothingFactor") + self.call_action("setContourConfiguration", levels, smoothing_mode, smoothing_factor) + + @validate(Constant(ContourDashMode)) + def set_dash_mode(self, dash_mode): + """Set the contour dash mode. + + Parameters + ---------- + dash_mode : {0} + The dash mode. + """ + self.call_action("setDashMode", dash_mode) + + @validate(Number()) + def set_thickness(self, thickness): + """Set the contour thickness. + + Parameters + ---------- + thickness : {0} + The thickness. + """ + self.call_action("setThickness", thickness) + + @validate(Color()) + def set_color(self, color): + """Set the contour color. + + This automatically disables use of the contour colormap. + + Parameters + ---------- + color : {0} + The color. The default is green. + """ + self.call_action("setColor", color) + self.call_action("setColormapEnabled", False) + + @validate(Constant(Colormap)) + def set_colormap(self, colormap): + """Set the contour colormap. + + This also automatically enables the colormap. + + Parameters + ---------- + colormap : {0} + The colormap. The default is :obj:`carta.constants.Colormap.VIRIDIS`. + """ + self.call_action("setColormap", colormap) + self.call_action("setColormapEnabled", True) + + @validate(*all_optional(Number(-1, 1), Number(0, 2))) + def set_bias_and_contrast(self, bias=None, contrast=None): + """Set the contour bias and contrast. + + Parameters + ---------- + bias : {0} + The colormap bias. The initial value is ``0``. + contrast : {1} + The colormap contrast. The initial value is ``1``. + """ + if bias is not None: + self.call_action("setColormapBias", bias) + if contrast is not None: + self.call_action("setColormapContrast", contrast) + + def apply(self): + """Apply the contour configuration.""" + self.image.call_action("applyContours") + + @validate(*all_optional(*vargs(configure, set_dash_mode, set_thickness, set_color, set_colormap, set_bias_and_contrast))) + def plot(self, levels=None, smoothing_mode=None, smoothing_factor=None, dash_mode=None, thickness=None, color=None, colormap=None, bias=None, contrast=None): + """Configure contour levels, scaling, dash, and colour or colourmap; and apply contours; in a single step. + + If both a colour and a colourmap are provided, the colourmap will be visible. + + Parameters + ---------- + levels : {0} + The contour levels. This may be a numeric numpy array; e.g. the output of ``arange``. If this is unset, the current configured levels will be used. + smoothing_mode : {1} + The smoothing mode. If this is unset, the frontend default will be used. + smoothing_factor : {2} + The smoothing kernel size in pixels. If this is unset, the frontend default will be used. + dash_mode : {3} + The dash mode. + thickness : {4} + The thickness. + color : {5} + The color. The default is green. + colormap : {6} + The colormap. The default is :obj:`carta.constants.Colormap.VIRIDIS`. + bias : {7} + The colormap bias. + contrast : {8} + The colormap contrast. + """ + changes_made = False + + for method, args in [ + (self.configure, (levels, smoothing_mode, smoothing_factor)), + (self.set_dash_mode, (dash_mode,)), + (self.set_thickness, (thickness,)), + (self.set_color, (color,)), + (self.set_colormap, (colormap,)), + (self.set_bias_and_contrast, (bias, contrast)), + ]: + if any(a is not None for a in args): + method(*args) + changes_made = True + + if changes_made: + self.apply() + + def clear(self): + """Clear the contours.""" + self.image.call_action("clearContours", True) + + @validate(Boolean()) + def set_visible(self, state): + """Set the contour visibility. + + Parameters + ---------- + state : {0} + The desired visibility state. + """ + self.call_action("setVisible", state) + + def show(self): + """Show the contours.""" + self.set_visible(True) + + def hide(self): + """Hide the contours.""" + self.set_visible(False) diff --git a/carta/image.py b/carta/image.py index d71037b..0567520 100644 --- a/carta/image.py +++ b/carta/image.py @@ -3,12 +3,16 @@ Image objects should not be instantiated directly, and should only be created through methods on the :obj:`carta.session.Session` object. """ -from .constants import Colormap, Scaling, SmoothingMode, ContourDashMode, Polarization, SpatialAxis +from .constants import Polarization, SpatialAxis, SpectralSystem, SpectralType, SpectralUnit from .util import Macro, cached, BasePathMixin from .units import AngularSize, WorldCoordinate -from .validation import validate, Number, Color, Constant, Boolean, NoneOr, IterableOf, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, all_optional +from .validation import validate, Number, Constant, Boolean, Evaluate, Attr, Attrs, OneOf, Size, Coordinate, NoneOr from .metadata import parse_header + +from .raster import Raster +from .contours import Contours from .vector_overlay import VectorOverlay +from .wcs_overlay import ImageWCSOverlay class Image(BasePathMixin): @@ -29,6 +33,14 @@ class Image(BasePathMixin): The session object associated with this image. image_id : integer The ID identifying this image within the session. + raster : :obj:`carta.raster.Raster` + Sub-object with functions related to the raster image. + contours : :obj:`carta.contours.Contours` + Sub-object with functions related to the contours. + vectors : :obj:`carta.vector_overlay.VectorOverlay` + Sub-object with functions related to the vector overlay. + wcs : :obj:`carta.wcs_overlay.ImageWCSOverlay` + Sub-object with functions related to the WCS overlay. """ def __init__(self, session, image_id): @@ -39,7 +51,10 @@ def __init__(self, session, image_id): self._frame = Macro("", self._base_path) # Sub-objects grouping related functions + self.raster = Raster(self) + self.contours = Contours(self) self.vectors = VectorOverlay(self) + self.wcs = ImageWCSOverlay(self) @classmethod def new(cls, session, directory, file_name, hdu, append, image_arithmetic, make_active=True, update_directory=False): @@ -372,7 +387,7 @@ def set_center(self, x, y): if not self.valid_wcs: raise ValueError("Cannot parse world coordinates. This image does not contain valid WCS information. Please use image coordinates (in pixels) instead.") - number_format_x, number_format_y, _ = self.session.number_format() + number_format_x, number_format_y = self.session.wcs.numbers.format x_value = WorldCoordinate.with_format(number_format_x).from_string(x, SpatialAxis.X) y_value = WorldCoordinate.with_format(number_format_y).from_string(y, SpatialAxis.Y) self.call_action("setCenterWcs", str(x_value), str(y_value)) @@ -417,259 +432,103 @@ def set_zoom_level(self, zoom, absolute=True): """ self.call_action("setZoom", zoom, absolute) - # STYLE - - @validate(Constant(Colormap), Boolean(), NoneOr(Number()), NoneOr(Number())) - def set_colormap(self, colormap, invert=False, bias=None, contrast=None): - """Set the colormap. + # SPECTRAL CONVERSION - By default the colormap is not inverted, and the bias and contrast are reset to the frontend defaults of ``0`` and ``1`` respectively. + @property + @cached + def is_pv(self): + """Whether this is a spatial-spectral image. - Parameters - ---------- - colormap : {0} - The colormap. - invert : {1} - Whether the colormap should be inverted. - bias : {2} - A custom bias. - contrast : {3} - A custom contrast. + Returns + ------- + boolean + Whether this is a spatial-spectral image. """ - self.call_action("renderConfig.setColorMap", colormap) - self.call_action("renderConfig.setInverted", invert) - if bias is not None: - self.call_action("renderConfig.setBias", bias) - else: - self.call_action("renderConfig.resetBias") - if contrast is not None: - self.call_action("renderConfig.setContrast", contrast) - else: - self.call_action("renderConfig.resetContrast") + return self.get_value("isPVImage") - # TODO check whether this works as expected - @validate(Constant(Scaling), NoneOr(Number()), NoneOr(Number()), NoneOr(Number(0, 100)), NoneOr(Number()), NoneOr(Number())) - def set_scaling(self, scaling, alpha=None, gamma=None, rank=None, min=None, max=None): - """Set the colormap scaling. + @property + @cached + def spectral_systems_supported(self): + """The spectral systems supported by this image. - Parameters - ---------- - scaling : {0} - The scaling type. - alpha : {1} - The alpha value (only applicable to ``LOG`` and ``POWER`` scaling types). - gamma : {2} - The gamma value (only applicable to the ``GAMMA`` scaling type). - rank : {3} - The clip percentile rank. If this is set, *min* and *max* are ignored, and will be calculated automatically. - min : {4} - Custom clip minimum. Only used if both *min* and *max* are set. Ignored if *rank* is set. - max : {5} - Custom clip maximum. Only used if both *min* and *max* are set. Ignored if *rank* is set. + Returns + ------- + set of :obj:`carta.constants.SpectralSystem` + The supported spectral systems. """ - self.call_action("renderConfig.setScaling", scaling) - if scaling in (Scaling.LOG, Scaling.POWER) and alpha is not None: - self.call_action("renderConfig.setAlpha", alpha) - elif scaling == Scaling.GAMMA and gamma is not None: - self.call_action("renderConfig.setGamma", gamma) - if rank is not None: - self.set_clip_percentile(rank) - elif min is not None and max is not None: - self.call_action("renderConfig.setCustomScale", min, max) + return {SpectralSystem(s) for s in self.get_value("spectralSystemsSupported")} - @validate(Boolean()) - def set_raster_visible(self, state): - """Set the raster image visibility. + @property + @cached + def spectral_coordinate_types_supported(self): + """The spectral coordinate types supported by this image. - Parameters - ---------- - state : {0} - The desired visibility state. + Returns + ------- + set of :obj:`carta.constants.SpectralType` + The supported spectral coordinate types. """ - self.call_action("renderConfig.setVisible", state) - - def show_raster(self): - """Show the raster image.""" - self.set_raster_visible(True) - - def hide_raster(self): - """Hide the raster image.""" - self.set_raster_visible(False) - - # CONTOURS + types = {v['type'] for v in self.get_value("spectralCoordsSupported").values()} - {"CHANNEL"} + return {SpectralType(t) for t in types} - @validate(*all_optional(IterableOf(Number()), Constant(SmoothingMode), Number())) - def configure_contours(self, levels=None, smoothing_mode=None, smoothing_factor=None): - """Configure contours. + @validate(Constant(SpectralSystem)) + def set_spectral_system(self, spectral_system): + """Set the coordinate system used for the spectral axis in the image viewer. - Parameters - ---------- - levels : {0} - The contour levels. This may be a numeric numpy array; e.g. the output of ``arange``. If this is unset, the current configured levels will be used. - smoothing_mode : {1} - The smoothing mode. If this is unset, the frontend default will be used. - smoothing_factor : {2} - The smoothing kernel size in pixels. If this is unset, the frontend default will be used. - """ - if levels is None: - levels = self.macro("contourConfig", "levels") - if smoothing_mode is None: - smoothing_mode = self.macro("contourConfig", "smoothingMode") - if smoothing_factor is None: - smoothing_factor = self.macro("contourConfig", "smoothingFactor") - self.call_action("contourConfig.setContourConfiguration", levels, smoothing_mode, smoothing_factor) - - @validate(*all_optional(Constant(ContourDashMode), Number())) - def set_contour_dash(self, dash_mode=None, thickness=None): - """Set the contour dash style. + This is only applicable to spatial-spectral images, such as position-velocity images or cubes with permuted axes like ``RA-FREQ-DEC``. Parameters ---------- - dash_mode : {0} - The dash mode. - thickness : {1} - The dash thickness. - """ - if dash_mode is not None: - self.call_action("contourConfig.setDashMode", dash_mode) - if thickness is not None: - self.call_action("contourConfig.setThickness", thickness) - - @validate(Color()) - def set_contour_color(self, color): - """Set the contour color. + spectral_system : {0} + The spectral system to use. - This automatically disables use of the contour colormap. - - Parameters - ---------- - color : {0} - The color. The default is green. - """ - self.call_action("contourConfig.setColor", color) - self.call_action("contourConfig.setColormapEnabled", False) - - @validate(Constant(Colormap), NoneOr(Number()), NoneOr(Number())) - def set_contour_colormap(self, colormap, bias=None, contrast=None): - """Set the contour colormap. - - This automatically enables use of the contour colormap. - - Parameters - ---------- - colormap : {0} - The colormap. The default is :obj:`carta.constants.Colormap.VIRIDIS`. - bias : {1} - The colormap bias. - contrast : {2} - The colormap contrast. + Raises + ------ + ValueError + If this is not a spatial-spectral image, or the system is not supported. """ - self.call_action("contourConfig.setColormap", colormap) - self.call_action("contourConfig.setColormapEnabled", True) - if bias is not None: - self.call_action("contourConfig.setColormapBias", bias) - if contrast is not None: - self.call_action("contourConfig.setColormapContrast", contrast) - - def apply_contours(self): - """Apply the contour configuration.""" - self.call_action("applyContours") + if not self.is_pv: + raise ValueError("Cannot set spectral system. This is not a position-velocity image.") + spectral_system = SpectralSystem(spectral_system) + if spectral_system not in self.spectral_systems_supported: + raise ValueError(f"Cannot set spectral system. Unsupported system: {spectral_system}.") + self.call_action("setSpectralSystem", spectral_system) - @validate(*all_optional(*configure_contours.VARGS, *set_contour_dash.VARGS, *set_contour_color.VARGS, *set_contour_colormap.VARGS)) - def plot_contours(self, levels=None, smoothing_mode=None, smoothing_factor=None, dash_mode=None, thickness=None, color=None, colormap=None, bias=None, contrast=None): - """Configure contour levels, scaling, dash, and colour or colourmap; and apply contours; in a single step. + @validate(Constant(SpectralType), NoneOr(Constant(SpectralUnit))) + def set_spectral_coordinate(self, spectral_type, spectral_unit=None): + """Set the coordinate type and unit used for the spectral axis in the image viewer. - If both a colour and a colourmap are provided, the colourmap will be visible. + This is only applicable to spatial-spectral images, such as position-velocity images or cubes with permuted axes like ``RA-FREQ-DEC``. Parameters ---------- - levels : {0} - The contour levels. This may be a numeric numpy array; e.g. the output of ``arange``. If this is unset, the current configured levels will be used. - smoothing_mode : {1} - The smoothing mode. If this is unset, the frontend default will be used. - smoothing_factor : {2} - The smoothing kernel size in pixels. If this is unset, the frontend default will be used. - dash_mode : {3} - The dash mode. - thickness : {4} - The dash thickness. - color : {5} - The color. The default is green. - colormap : {6} - The colormap. The default is :obj:`carta.constants.Colormap.VIRIDIS`. - bias : {7} - The colormap bias. - contrast : {8} - The colormap contrast. - """ - self.configure_contours(levels, smoothing_mode, smoothing_factor) - self.set_contour_dash(dash_mode, thickness) - if color is not None: - self.set_contour_color(color) - if colormap is not None: - self.set_contour_colormap(colormap, bias, contrast) - self.apply_contours() - - def clear_contours(self): - """Clear the contours.""" - self.call_action("clearContours", True) - - @validate(Boolean()) - def set_contours_visible(self, state): - """Set the contour visibility. - - Parameters - ---------- - state : {0} - The desired visibility state. - """ - self.call_action("contourConfig.setVisible", state) - - def show_contours(self): - """Show the contours.""" - self.set_contours_visible(True) - - def hide_contours(self): - """Hide the contours.""" - self.set_contours_visible(False) + spectral_type : {0} + The spectral type to use. + spectral_unit : {1} + The spectral unit to use. If this is omitted, the default unit for the type will be used. - # HISTOGRAM - - @validate(Boolean()) - def use_cube_histogram(self, contours=False): - """Use the cube histogram. - - Parameters - ---------- - contours : {0} - Apply to the contours. By default this is applied to the raster image. + Raises + ------ + ValueError + If this is not a spatial-spectral image, or the type is not supported, or the unit is not supported. """ - self.call_action(f"renderConfig.setUseCubeHistogram{'Contours' if contours else ''}", True) + if not self.is_pv: + raise ValueError("Cannot set spectral coordinate. This is not a position-velocity image.") - @validate(Boolean()) - def use_channel_histogram(self, contours=False): - """Use the channel histogram. + spectral_type = SpectralType(spectral_type) + description = spectral_type.description + if spectral_type not in self.spectral_coordinate_types_supported: + raise ValueError(f"Cannot set spectral coordinate. Unsupported type: {description}.") - Parameters - ---------- - contours : {0} - Apply to the contours. By default this is applied to the raster image. - """ - self.call_action(f"renderConfig.setUseCubeHistogram{'Contours' if contours else ''}", False) - - @validate(Number(0, 100)) - def set_clip_percentile(self, rank): - """Set the clip percentile. + if spectral_unit is not None: + spectral_unit = SpectralUnit(spectral_unit) + if spectral_unit not in spectral_type.units: + raise ValueError(f"Cannot set spectral coordinate. Unsupported unit: {spectral_unit}.") + else: + spectral_unit = spectral_type.default_unit - Parameters - ---------- - rank : {0} - The percentile rank. - """ - preset_ranks = [90, 95, 99, 99.5, 99.9, 99.95, 99.99, 100] - self.call_action("renderConfig.setPercentileRank", rank) - if rank not in preset_ranks: - self.call_action("renderConfig.setPercentileRank", -1) # select 'custom' rank button + spectral_coordinate_string = description if spectral_unit is None else f"{description} ({spectral_unit})" + self.call_action("setSpectralCoordinate", spectral_coordinate_string) # CLOSE diff --git a/carta/preferences.py b/carta/preferences.py new file mode 100644 index 0000000..8e36f2e --- /dev/null +++ b/carta/preferences.py @@ -0,0 +1,54 @@ +"""This module contains functionality for interacting with the CARTA UI preferences. The class in this module should not be instantiated directly. When a session object is created, a preferences object is automatically created as a property.""" + +from .util import BasePathMixin +from .validation import validate, String, Any + + +class Preferences(BasePathMixin): + """This class is a low-level interface to the CARTA UI preferences. + + No validation is performed on any of the preference names or values passed as parameters to the functions in this class. + + Parameters + ---------- + session : :obj:`carta.session.Session` + The session object associated with this component. + + Attributes + ---------- + session : :obj:`carta.session.Session` + The session object associated with this component. + """ + + def __init__(self, session): + self.session = session + self._base_path = "preferenceStore" + + @validate(String()) + def get(self, name): + """Get the value of a preference. + + Parameters + ---------- + name : {0} + The name of the preference. + + Returns + ------- + any value + The value of the preference. + """ + return self.get_value(name) + + @validate(String(), Any()) + def set(self, name, value): + """Set the value of a preference. + + Parameters + ---------- + name : {0} + The name of the preference. + value : {1} + The new value for the preference. + """ + self.call_action("setPreference", name, value) diff --git a/carta/raster.py b/carta/raster.py new file mode 100644 index 0000000..2f6d9dd --- /dev/null +++ b/carta/raster.py @@ -0,0 +1,213 @@ +"""This module contains functionality for interacting with the raster component of an image. The class in this module should not be instantiated directly. When an image object is created, a raster object is automatically created as a property.""" + +from .util import BasePathMixin +from .constants import Colormap, Scaling, Auto, PaletteColor +from .validation import validate, Number, Constant, Boolean, all_optional, Union + + +class Raster(BasePathMixin): + """Utility object for collecting image functions related to the raster component. + + Parameters + ---------- + image : :obj:`carta.image.Image` object + The image associated with this raster component. + + Attributes + ---------- + image : :obj:`carta.image.Image` object + The image associated with this raster component. + session : :obj:`carta.session.Session` object + The session object associated with this raster component. + """ + + def __init__(self, image): + self.image = image + self.session = image.session + self._base_path = f"{image._base_path}.renderConfig" + + @validate(Constant(Colormap), Boolean()) + def set_colormap(self, colormap, invert=False): + """Set the raster colormap. + + Parameters + ---------- + colormap : {0} + The colormap. + invert : {1} + Whether the colormap should be inverted. This is false by default. + """ + self.call_action("setColorMap", colormap) + self.call_action("setInverted", invert) + + @validate(*all_optional(Constant(Scaling), Number(0.1, 1000000), Number(0.1, 2))) + def set_scaling(self, scaling=None, alpha=None, gamma=None): + """Set the raster colormap scaling options. + + Parameters + ---------- + scaling : {0} + The scaling type. + alpha : {1} + The alpha value (only applicable to ``LOG`` and ``POWER`` scaling types, but set regardless of the scaling parameter provided). + gamma : {2} + The gamma value (only applicable to the ``GAMMA`` scaling type, but set regardless of the scaling parameter provided). + """ + if scaling is not None: + self.call_action("setScaling", scaling) + + if alpha is not None: + self.call_action("setAlpha", alpha) + + if gamma is not None: + self.call_action("setGamma", gamma) + + @validate(*all_optional(Number(0, 100), Number(), Number())) + def set_clip(self, rank=None, min=None, max=None): + """Set the raster clip options. + + Parameters + ---------- + rank : {0} + The clip percentile rank. If this is set, *min* and *max* are ignored, and will be calculated automatically. + min : {1} + Custom clip minimum. Only used if both *min* and *max* are set. Ignored if *rank* is set. + max : {2} + Custom clip maximum. Only used if both *min* and *max* are set. Ignored if *rank* is set. + """ + + preset_ranks = [90, 95, 99, 99.5, 99.9, 99.95, 99.99, 100] + + if rank is not None: + self.call_action("setPercentileRank", rank) + if rank not in preset_ranks: + self.call_action("setPercentileRank", -1) # select 'custom' rank button + + elif min is not None and max is not None: + self.call_action("setCustomScale", min, max) + + @validate(*all_optional(Union(Number(-1, 1), Constant(Auto)), Union(Number(0, 2), Constant(Auto)))) + def set_bias_and_contrast(self, bias=None, contrast=None): + """Set the raster bias and contrast. + + Parameters + ---------- + bias : {0} + A custom bias. Use :obj:`carta.constants.Auto.AUTO` to reset the bias to the frontend default of ``0``. + contrast : {1} + A custom contrast. Use :obj:`carta.constants.Auto.AUTO` to reset the contrast to the frontend default of ``1``. + """ + if bias is Auto.AUTO: + self.call_action("resetBias") + elif bias is not None: + self.call_action("setBias", bias) + + if contrast is Auto.AUTO: + self.call_action("resetContrast") + elif contrast is not None: + self.call_action("setContrast", contrast) + + @validate(Boolean()) + def set_visible(self, state): + """Set the raster component visibility. + + Parameters + ---------- + state : {0} + The desired visibility state. + """ + self.call_action("setVisible", state) + + def show(self): + """Show the raster component.""" + self.set_visible(True) + + def hide(self): + """Hide the raster component.""" + self.set_visible(False) + + +class SessionRaster: + """Utility object for collecting global raster image settings. + + Parameters + ---------- + session : :obj:`carta.session.Session` object + The session associated with these settings. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session associated with these settings. + """ + + def __init__(self, session): + self.session = session + + @property + def pixel_grid_visible(self): + """Whether the pixel grid is visible. + + Returns + ------- + boolean + The visibility. + """ + return self.session._preferences.get("pixelGridVisible") + + @property + def pixel_grid_color(self): + """The pixel grid color. + + Returns + ------- + a member of :obj:`carta.constants.color.PaletteColor` + The color. + """ + return PaletteColor(self.session._preferences.get("pixelGridColor")) + + @validate(Boolean()) + def set_pixel_grid_visible(self, visible): + """Set the visibility of the pixel grid. + + Parameters + ---------- + visible : {0} + Whether the pixel grid should be visible. + """ + self.session._preferences.set("pixelGridVisible", visible) + + def show_pixel_grid(self): + """Show the pixel grid.""" + self.set_pixel_grid_visible(True) + + def hide_pixel_grid(self): + """Hide the pixel grid.""" + self.set_pixel_grid_visible(False) + + @validate(Constant(PaletteColor)) + def set_pixel_grid_color(self, color): + """Set the color of the pixel grid. + + Parameters + ---------- + color : {0} + The color. + """ + self.session._preferences.set("pixelGridColor", color) + + @validate(*all_optional(Boolean(), Constant(PaletteColor))) + def set_pixel_grid(self, visible=None, color=None): + """Set the pixel grid properties. + + Parameters + ---------- + visible : {0} + Whether the pixel grid should be visible. + color : {1} + The pixel grid color. + """ + if visible is not None: + self.set_pixel_grid_visible(visible) + if color is not None: + self.set_pixel_grid_color(color) diff --git a/carta/session.py b/carta/session.py index d201ed8..1896b95 100644 --- a/carta/session.py +++ b/carta/session.py @@ -10,11 +10,14 @@ import posixpath from .image import Image -from .constants import CoordinateSystem, LabelType, BeamType, PaletteColor, Overlay, PanelMode, GridMode, ComplexComponent, NumberFormat, Polarization +from .constants import PanelMode, GridMode, ComplexComponent, Polarization from .backend import Backend from .protocol import Protocol -from .util import logger, Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl -from .validation import validate, String, Number, Color, Constant, Boolean, NoneOr, OneOf, IterableOf, MapOf, Union +from .util import Macro, split_action_path, CartaBadID, CartaBadSession, CartaBadUrl +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 class Session: @@ -46,6 +49,10 @@ class Session: ---------- session_id : integer The ID of the CARTA frontend session associated with this object. + wcs : :obj:`carta.wcs_overlay.SessionWCSOverlay` + Sub-object with functions related to the WCS overlay. + raster : :obj:`carta.raster.SessionRaster` + Sub-object with functions related to the global raster image settings. """ def __init__(self, session_id, protocol, browser=None, backend=None): @@ -53,9 +60,11 @@ def __init__(self, session_id, protocol, browser=None, backend=None): self._protocol = protocol self._browser = browser self._backend = backend + self._preferences = Preferences(self) - # This is a local point of reference for paths, and may not be in sync with the frontend's starting directory - self._pwd = None + # Sub-objects grouping related functions + self.wcs = SessionWCSOverlay(self) + self.raster = SessionRaster(self) def __del__(self): """Delete this session object.""" @@ -522,6 +531,23 @@ def active_frame(self): image_id = self.get_value("activeFrame.frameInfo.fileId") return Image(self, image_id) + def image_by_id(self, image_id): + """Return an image object with the specified ID. + + This is a helper function which constructs a :obj:`carta.image.Image` object with the specified ID, without checking whether an image with that ID is currently open. It is the caller's responsibility to ensure this. + + Parameters + ---------- + image_id : integer + The ID of the image to return. + + Returns + ------- + :obj:`carta.image.Image` + The image with the specified ID. + """ + return Image(self, image_id) + def clear_spatial_reference(self): """Clear the spatial reference.""" self.call_action("clearSpatialReference") @@ -578,363 +604,6 @@ def set_viewer_grid(self, rows, columns, grid_mode=GridMode.FIXED): self.call_action("preferenceStore.setPreference", "imagePanelColumns", columns) self.call_action("preferenceStore.setPreference", "imagePanelMode", grid_mode) - # CANVAS AND OVERLAY - @validate(Number(), Number()) - def set_view_area(self, width, height): - """Set the dimensions of the view area. - - Parameters - ---------- - width : {0} - The new width, in pixels, divided by the device pixel ratio. - height : {1} - The new height, in pixels, divided by the device pixel ratio. - """ - self.call_action("overlayStore.setViewDimension", width, height) - - @validate(Constant(CoordinateSystem)) - def set_coordinate_system(self, system=CoordinateSystem.AUTO): - """Set the coordinate system. - - Parameters - ---------- - system : {0} - The coordinate system. - """ - self.call_action("overlayStore.global.setSystem", system) - - def coordinate_system(self): - """Get the coordinate system. - - Returns - ---------- - :obj:`carta.constants.CoordinateSystem` - The coordinate system. - """ - return CoordinateSystem(self.get_value("overlayStore.global.system")) - - @validate(Constant(LabelType)) - def set_label_type(self, label_type): - """Set the label type. - - Parameters - ---------- - label_type : {0} - The label type. - """ - self.call_action("overlayStore.global.setLabelType", label_type) - - @validate(NoneOr(String()), NoneOr(String()), NoneOr(String())) - def set_text(self, title=None, label_x=None, label_y=None): - """Set custom title and/or the axis label text. - - Parameters - ---------- - title : {0} - The title text. - label_x : {1} - The X-axis text. - label_y : {2} - The Y-axis text. - """ - if title is not None: - self.call_action("overlayStore.title.setCustomTitleString", title) - self.call_action("overlayStore.title.setCustomText", True) - if label_x is not None: - self.call_action("overlayStore.labels.setCustomLabelX", label_x) - if label_y is not None: - self.call_action("overlayStore.labels.setCustomLabelX", label_y) - if label_x is not None or label_y is not None: - self.call_action("overlayStore.labels.setCustomText", True) - - def clear_text(self): - """Clear all custom title and axis text.""" - self.call_action("overlayStore.title.setCustomText", False) - self.call_action("overlayStore.labels.setCustomText", False) - - @validate(OneOf(Overlay.TITLE, Overlay.NUMBERS, Overlay.LABELS), NoneOr(String()), NoneOr(Number())) - def set_font(self, component, font=None, font_size=None): - """Set the font and/or font size of an overlay component. - - TODO: can we get the allowed font names from somewhere? - - Parameters - ---------- - component : {0} - The overlay component. - font : {1} - The font name. - font_size : {2} - The font size. - """ - if font is not None: - self.call_action(f"overlayStore.{component}.setFont", font) - if font_size is not None: - self.call_action(f"overlayStore.{component}.setFontSize", font_size) - - @validate(NoneOr(Constant(NumberFormat)), NoneOr(Constant(NumberFormat))) - def set_custom_number_format(self, x_format=None, y_format=None): - """Set a custom X and Y number format. - - Parameters - ---------- - x_format : {0} - The X format. If this is unset, the last custom X format to be set will be restored. - x_format : {1} - The Y format. If this is unset, the last custom Y format to be set will be restored. - """ - if x_format is not None: - self.call_overlay_action(Overlay.NUMBERS, "setFormatX", x_format) - if y_format is not None: - self.call_overlay_action(Overlay.NUMBERS, "setFormatY", y_format) - self.call_overlay_action(Overlay.NUMBERS, "setCustomFormat", True) - - def clear_custom_number_format(self): - """Disable the custom X and Y number format.""" - self.call_overlay_action(Overlay.NUMBERS, "setCustomFormat", False) - - def number_format(self): - """Return the current X and Y number formats, and whether they are a custom setting. - - If the image has no WCS information, both the X and Y formats will be ``None``. - - If a custom number format is not set, the format is derived from the coordinate system. - - Returns - ------- - tuple (a member of :obj:`carta.constants.NumberFormat` or ``None``, a member of :obj:`carta.constants.NumberFormat` or ``None``, boolean) - A tuple containing the X format, the Y format, and whether a custom format is set. - """ - number_format_x = self.get_overlay_value(Overlay.NUMBERS, "formatTypeX") - number_format_y = self.get_overlay_value(Overlay.NUMBERS, "formatTypeY") - custom_format = self.get_overlay_value(Overlay.NUMBERS, "customFormat") - return NumberFormat(number_format_x), NumberFormat(number_format_y), custom_format - - @validate(NoneOr(Constant(BeamType)), NoneOr(Number()), NoneOr(Number()), NoneOr(Number())) - def set_beam(self, beam_type=None, width=None, shift_x=None, shift_y=None): - """Set the beam properties. - - Parameters - ---------- - beam_type : {0} - The beam type. - width : {1} - The beam width. - shift_x : {2} - The X position. - shift_y : {3} - The Y position. - """ - if beam_type is not None: - self.call_action(f"overlayStore.{Overlay.BEAM}.setBeamType", beam_type) - if width is not None: - self.call_action(f"overlayStore.{Overlay.BEAM}.setWidth", width) - if shift_x is not None: - self.call_action(f"overlayStore.{Overlay.BEAM}.setShiftX", shift_x) - if shift_y is not None: - self.call_action(f"overlayStore.{Overlay.BEAM}.setShiftY", shift_y) - - @validate(Constant(PaletteColor), Constant(Overlay)) - def set_color(self, color, component=Overlay.GLOBAL): - """Set the custom color on an overlay component, or the global color. - - Parameters - ---------- - color : {0} - The color. - component : {1} - The overlay component. - """ - self.call_action(f"overlayStore.{component}.setColor", color) - if component not in (Overlay.GLOBAL, Overlay.BEAM): - self.call_action(f"overlayStore.{component}.setCustomColor", True) - - @validate(Constant(Overlay, exclude=(Overlay.GLOBAL,))) - def clear_color(self, component): - """Clear the custom color from an overlay component. - - Parameters - ---------- - component : {0} - The overlay component. - """ - if component == Overlay.BEAM: - logger.warning("Cannot clear the color from the beam component. A color must be set on this component explicitly.") - return - - self.call_action(f"overlayStore.{component}.setCustomColor", False) - - @validate(Constant(Overlay)) - def color(self, component): - """The color of an overlay component. - - If called on the global overlay options, this function returns the global (default) overlay color. For any single overlay component other than the beam, it returns its custom color if a custom color is enabled, otherwise None. For the beam it returns the beam color. - - Parameters - ---------- - component : {0} - The overlay component. - - Returns - ------- - A member of :obj:`carta.constants.PaletteColor` or None - The color of the component or None if no custom color is set on the component. - """ - if component in (Overlay.GLOBAL, Overlay.BEAM) or self.get_value(f"overlayStore.{component}.customColor"): - return PaletteColor(self.get_value(f"overlayStore.{component}.color")) - - @validate(Constant(PaletteColor)) - def palette_to_rgb(self, color): - """Convert a palette colour to RGB. - - The RGB value depends on whether the session is using the light theme or the dark theme. - - Parameters - ---------- - color : {0} - The colour to convert. - - Returns - ------- - string - The RGB value of the palette colour in the session's current theme, as a 6-digit hexadecimal with a leading ``#``. - """ - color = PaletteColor(color) - if self.get_value("darkTheme"): - return color.rgb_dark - return color.rgb_light - - @validate(Number(min=0, interval=Number.EXCLUDE), OneOf(Overlay.GRID, Overlay.BORDER, Overlay.TICKS, Overlay.AXES, Overlay.COLORBAR)) - def set_width(self, width, component): - """Set the line width of an overlay component. - - Parameters - ---------- - component : {0} - The overlay component. - """ - self.call_action(f"overlayStore.{component}.setWidth", width) - - @validate(OneOf(Overlay.GRID, Overlay.BORDER, Overlay.TICKS, Overlay.AXES, Overlay.COLORBAR)) - def width(self, component): - """The line width of an overlay component. - - Parameters - ---------- - component : {0} - The overlay component. - - Returns - ---------- - number - The line width of the component. - """ - return self.get_value(f"overlayStore.{component}.width") - - @validate(Constant(Overlay, exclude=(Overlay.GLOBAL,)), Boolean()) - def set_visible(self, component, visible): - """Set the visibility of an overlay component. - - Ticks cannot be shown or hidden in AST, but it is possible to set the width to a very small non-zero number to make them effectively invisible. - - Parameters - ---------- - component : {0} - The overlay component. - visible : {1} - The visibility state. - """ - if component == Overlay.TICKS: - logger.warning("Ticks cannot be shown or hidden.") - return - - self.call_action(f"overlayStore.{component}.setVisible", visible) - - @validate(Constant(Overlay, exclude=(Overlay.GLOBAL,))) - def visible(self, component): - """Whether an overlay component is visible. - - Ticks cannot be shown or hidden in AST. - - Parameters - ---------- - component : {0} - The overlay component. - - Returns - ------- - boolean or None - Whether the component is visible, or None for an invalid component. - """ - if component == Overlay.TICKS: - logger.warning("Ticks cannot be shown or hidden.") - return - - return self.get_value(f"overlayStore.{component}.visible") - - @validate(Constant(Overlay, exclude=(Overlay.GLOBAL,))) - def show(self, component): - """Show an overlay component. - - Parameters - ---------- - component : {0} - The overlay component. - """ - self.set_visible(component, True) - - @validate(Constant(Overlay, exclude=(Overlay.GLOBAL,))) - def hide(self, component): - """Hide an overlay component. - - Parameters - ---------- - component : {0} - The overlay component. - """ - self.set_visible(component, False) - - def call_overlay_action(self, component, path, *args, **kwargs): - """Helper method for calling overlay component actions. - - This method calls :obj:`carta.session.Session.call_action` after prepending this component's base path to the path parameter. - - Parameters - ---------- - component : a member of :obj:`carta.constants.Overlay` - The overlay component to use as the base of the path. - path : string - The path to an action relative to this overlay component. - *args - A variable-length list of parameters. These are passed unmodified to :obj:`carta.Session.call_action`. - **kwargs - Arbitrary keyword parameters. These are passed unmodified to :obj:`carta.Session.call_action`. - """ - self.call_action(f"overlayStore.{component}.{path}", *args, **kwargs) - - def get_overlay_value(self, component, path): - """Helper method for retrieving the values of overlay component attributes. - - This method calls :obj:`carta.session.Session.get_value` after prepending this component's base path to the path parameter. - - Parameters - ---------- - component : a member of :obj:`carta.constants.Overlay` - The overlay component to use as the base of the path. - path : string - The path to an attribute relative to this overlay component. - - Returns - ------- - object - The value of the attribute, deserialized from a JSON string. - """ - return self.get_value(f"overlayStore.{component}.{path}") - - def toggle_labels(self): - """Toggle the overlay labels.""" - self.call_action("overlayStore.toggleLabels") - # PROFILES (TODO) @validate(Number(), Number()) diff --git a/carta/util.py b/carta/util.py index cce6125..38f66d4 100644 --- a/carta/util.py +++ b/carta/util.py @@ -207,3 +207,15 @@ def macro(self, target, variable): """ target = f"{self._base_path}.{target}" if target else self._base_path return Macro(target, variable) + + +def camel(*parts): + """Convert an iterable of strings to a camel case string.""" + parts = [p for p in parts if p] + parts[1:] = [p.title() for p in parts[1:]] + return "".join(parts) + + +def snake(*parts): + """Convert an iterable of strings to a snake case string.""" + return "_".join([p for p in parts if p]) diff --git a/carta/validation.py b/carta/validation.py index 78fd9ec..10f2f04 100644 --- a/carta/validation.py +++ b/carta/validation.py @@ -3,6 +3,7 @@ import re import functools import inspect +import itertools from .util import CartaValidationFailed from .units import AngularSize, WorldCoordinate @@ -44,6 +45,28 @@ def description(self): return "UNKNOWN" +class Any(Parameter): + """Any value. This class is used to skip validation for a specific parameter.""" + + def validate(self, value, parent): + """Always pass. + + See :obj:`carta.validation.Parameter.validate` for general information about this method. + """ + pass + + @property + def description(self): + """A human-readable description of this parameter descriptor. + + Returns + ------- + string + The description. + """ + return "any value" + + class InstanceOf(Parameter): """A parameter which is an instance of the provided type or tuple of types. @@ -250,6 +273,11 @@ def description(self): return " ".join(desc) +Number.POSITIVE = Number(min=0, interval=Number.EXCLUDE) +Number.PERCENTAGE = Number(0, 100) +Number.ID = Number(min=0, step=1) + + class Boolean(Parameter): """A boolean parameter.""" @@ -892,3 +920,20 @@ def all_optional(*vargs): The same parameters in the same order, but with all non-optional parameters made optional (that is, wrapped in a obj:`carta.validation.NoneOr` parameter). """ return tuple(NoneOr(param) if not isinstance(param, NoneOr) else param for param in vargs) + + +def vargs(*functions): + """Helper function for extracting validation parameters from functions. + + For improved legibility in functions which reuse validation parameters from other functions. + + Parameters + ---------- + *functions : iterable of functions + + Returns + ------- + iterable of :obj:`carta.validation.Parameter` objects + The validation parameters of the given functions, in order, unpacked into a 1D sequence. + """ + return itertools.chain.from_iterable(f.VARGS for f in functions) diff --git a/carta/vector_overlay.py b/carta/vector_overlay.py index dc30064..ff4bf6e 100644 --- a/carta/vector_overlay.py +++ b/carta/vector_overlay.py @@ -2,7 +2,7 @@ from .util import logger, Macro, BasePathMixin from .constants import Colormap, VectorOverlaySource, Auto -from .validation import validate, Number, Color, Constant, Boolean, all_optional, Union +from .validation import validate, Number, Color, Constant, Boolean, all_optional, Union, vargs class VectorOverlay(BasePathMixin): @@ -59,7 +59,8 @@ def configure(self, angular_source=None, intensity_source=None, pixel_averaging_ """ # Avoid doing a lot of needless work for a no-op - if any(name != "self" and arg is not None for name, arg in locals().items()): + args = (angular_source, intensity_source, pixel_averaging_enabled, pixel_averaging, fractional_intensity, threshold_enabled, threshold, debiasing, q_error, u_error) + if any(a is not None for a in args): if pixel_averaging is not None and pixel_averaging_enabled is None: pixel_averaging_enabled = True if threshold is not None and threshold_enabled is None: @@ -92,28 +93,28 @@ def configure(self, angular_source=None, intensity_source=None, pixel_averaging_ self.call_action("setVectorOverlayConfiguration", *args) - @validate(*all_optional(Number(), Union(Number(), Constant(Auto)), Union(Number(), Constant(Auto)), Number(), Number(), Number())) - def set_style(self, thickness=None, intensity_min=None, intensity_max=None, length_min=None, length_max=None, rotation_offset=None): - """Set the styling (line thickness, intensity range, line length range, rotation offset) of vector overlay. + @validate(Number()) + def set_thickness(self, thickness): + """Set the vector overlay line thickness. Parameters ---------- thickness : {0} The line thickness in pixels. The initial value is ``1``. - intensity_min : {1} + """ + self.call_action("setThickness", thickness) + + @validate(*all_optional(Union(Number(), Constant(Auto)), Union(Number(), Constant(Auto)))) + def set_intensity_range(self, intensity_min=None, intensity_max=None): + """Set the vector overlay intensity range. + + Parameters + ---------- + intensity_min : {0} The minimum value of intensity in Jy/pixel. Use :obj:`carta.constants.Auto.AUTO` to clear the custom value and calculate it automatically. - intensity_max : {2} + intensity_max : {1} The maximum value of intensity in Jy/pixel. Use :obj:`carta.constants.Auto.AUTO` to clear the custom value and calculate it automatically. - length_min : {3} - The minimum value of line length in pixels. The initial value is ``0``. - length_max : {4} - The maximum value of line length in pixels. The initial value is ``20``. - rotation_offset : {5} - The rotation offset in degrees. The initial value is ``0``. """ - if thickness is not None: - self.call_action("setThickness", thickness) - if intensity_min is not None or intensity_max is not None: if intensity_min is None: intensity_min = self.macro("", "intensityMin") @@ -127,17 +128,35 @@ def set_style(self, thickness=None, intensity_min=None, intensity_max=None, leng self.call_action("setIntensityRange", intensity_min, intensity_max) - if length_min is not None and length_max is not None: - self.call_action("setLengthRange", length_min, length_max) + @validate(Number(), Number()) + def set_length_range(self, length_min, length_max): + """Set the vector overlay length range. + + Parameters + ---------- + length_min : {0} + The minimum value of line length in pixels. The initial value is ``0``. + length_max : {1} + The maximum value of line length in pixels. The initial value is ``20``. + """ + self.call_action("setLengthRange", length_min, length_max) + + @validate(Number()) + def set_rotation_offset(self, rotation_offset): + """Set the vector overlay rotation offset. - if rotation_offset is not None: - self.call_action("setRotationOffset", rotation_offset) + Parameters + ---------- + rotation_offset : {0} + The rotation offset in degrees. The initial value is ``0``. + """ + self.call_action("setRotationOffset", rotation_offset) @validate(Color()) def set_color(self, color): """Set the vector overlay color. - This automatically disables use of the vector overlay colormap. + This automatically disables the colormap. Parameters ---------- @@ -147,22 +166,31 @@ def set_color(self, color): self.call_action("setColor", color) self.call_action("setColormapEnabled", False) - @validate(*all_optional(Constant(Colormap), Number(-1, 1), Number(0, 2))) - def set_colormap(self, colormap=None, bias=None, contrast=None): - """Set the vector overlay colormap and/or the colormap options. + @validate(Constant(Colormap)) + def set_colormap(self, colormap): + """Set the vector overlay colormap. + + This also automatically enables the colormap. Parameters ---------- colormap : {0} - The colormap. The initial value is :obj:`carta.constants.Colormap.VIRIDIS`. If this parameter is set, the overlay colormap is automatically enabled. - bias : {1} + The colormap. The initial value is :obj:`carta.constants.Colormap.VIRIDIS`. + """ + self.call_action("setColormap", colormap) + self.call_action("setColormapEnabled", True) + + @validate(*all_optional(Number(-1, 1), Number(0, 2))) + def set_bias_and_contrast(self, bias=None, contrast=None): + """Set the vector overlay bias and contrast. + + Parameters + ---------- + bias : {0} The colormap bias. The initial value is ``0``. - contrast : {2} + contrast : {1} The colormap contrast. The initial value is ``1``. """ - if colormap is not None: - self.call_action("setColormap", colormap) - self.call_action("setColormapEnabled", True) if bias is not None: self.call_action("setColormapBias", bias) if contrast is not None: @@ -172,9 +200,9 @@ def apply(self): """Apply the vector overlay configuration.""" self.image.call_action("applyVectorOverlay") - @validate(*all_optional(*configure.VARGS, *set_style.VARGS, *set_color.VARGS, *set_colormap.VARGS)) + @validate(*all_optional(*vargs(configure, set_thickness, set_intensity_range, set_length_range, set_rotation_offset, set_color, set_colormap, set_bias_and_contrast))) def plot(self, angular_source=None, intensity_source=None, pixel_averaging_enabled=None, pixel_averaging=None, fractional_intensity=None, threshold_enabled=None, threshold=None, debiasing=None, q_error=None, u_error=None, thickness=None, intensity_min=None, intensity_max=None, length_min=None, length_max=None, rotation_offset=None, color=None, colormap=None, bias=None, contrast=None): - """Set the vector overlay configuration, styling and color or colormap; and apply vector overlay; in a single step. + """Configure, style, and apply the vector overlay in a single step. If both a color and a colormap are provided, the colormap will be enabled. @@ -223,23 +251,19 @@ def plot(self, angular_source=None, intensity_source=None, pixel_averaging_enabl """ changes_made = False - configure_args = (angular_source, intensity_source, pixel_averaging_enabled, pixel_averaging, fractional_intensity, threshold_enabled, threshold, debiasing, q_error, u_error) - if any(v is not None for v in configure_args): - self.configure(*configure_args) - changes_made = True - - set_style_args = (thickness, intensity_min, intensity_max, length_min, length_max, rotation_offset) - if any(v is not None for v in set_style_args): - self.set_style(*set_style_args) - changes_made = True - - if color is not None: - self.set_color(color) - changes_made = True - - if colormap is not None or bias is not None or contrast is not None: - self.set_colormap(colormap, bias, contrast) - changes_made = True + for method, args in [ + (self.configure, (angular_source, intensity_source, pixel_averaging_enabled, pixel_averaging, fractional_intensity, threshold_enabled, threshold, debiasing, q_error, u_error)), + (self.set_thickness, (thickness,)), + (self.set_intensity_range, (intensity_min, intensity_max)), + (self.set_length_range, (length_min, length_max)), + (self.set_rotation_offset, (rotation_offset,)), + (self.set_color, (color,)), + (self.set_colormap, (colormap,)), + (self.set_bias_and_contrast, (bias, contrast)), + ]: + if any(a is not None for a in args): + method(*args) + changes_made = True if changes_made: self.apply() diff --git a/carta/wcs_overlay.py b/carta/wcs_overlay.py new file mode 100644 index 0000000..a82a4f7 --- /dev/null +++ b/carta/wcs_overlay.py @@ -0,0 +1,1559 @@ +"""This module contains functionality for interacting with the WCS overlay. The classes in this module should not be instantiated directly. When a session object is created, an overlay object is automatically created as a property, and overlay component objects are created as its subproperties.""" + +import re +from operator import attrgetter + +from .util import BasePathMixin +from .constants import CoordinateSystem, LabelType, BeamType, PaletteColor, Overlay, NumberFormat, FontFamily, FontStyle, ColorbarPosition +from .validation import validate, String, Number, Constant, Boolean, NoneOr, IterableOf, all_optional + + +class SessionWCSOverlay(BasePathMixin): + """Utility object for collecting functions related to the global WCS overlay settings for the session. Most functions are additionally grouped in subcomponents, which can be accessed directly by name or looked up in a mapping by `carta.constants.Overlay` enum. + + Parameters + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay object. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay object. + global\\_ : :obj:`carta.wcs_overlay.Global` object + The global settings subcomponent. + title : :obj:`carta.wcs_overlay.Title` object + The title settings subcomponent. + grid : :obj:`carta.wcs_overlay.Grid` object + The grid settings subcomponent. + border : :obj:`carta.wcs_overlay.Border` object + The border settings subcomponent. + ticks : :obj:`carta.wcs_overlay.Ticks` object + The ticks settings subcomponent. + axes : :obj:`carta.wcs_overlay.Axes` object + The axes settings subcomponent. + numbers : :obj:`carta.wcs_overlay.Numbers` object + The numbers settings subcomponent. + labels : :obj:`carta.wcs_overlay.Labels` object + The labels settings subcomponent. + colorbar : :obj:`carta.wcs_overlay.Colorbar` object + The colorbar settings subcomponent. + beam : :obj:`carta.wcs_overlay.Beam` object + The beam settings subcomponent. + """ + + def __init__(self, session): + self.session = session + self._base_path = "overlayStore" + + self._components = {} + for component in Overlay: + comp = OverlayComponent.CLASS[component](self) + self._components[component] = comp + name = component.name.lower() + # This is a reserved word. + if name == "global": + name += "_" + setattr(self, f"{name}", comp) + + @validate(Constant(Overlay)) + def get(self, component): + """Access an overlay component subobject by name. + + Parameters + ---------- + component : {0} + The component to access. + + Returns + ------- + A member of :obj:`carta.wcs_overlay.OverlayComponent` + The overlay component object. + """ + return self._components[component] + + @validate(Constant(PaletteColor)) + def palette_to_rgb(self, color): + """Convert a palette colour to RGB. + + The RGB value depends on whether the session is using the light theme or the dark theme. + + Parameters + ---------- + color : {0} + The colour to convert. + + Returns + ------- + string + The RGB value of the palette colour in the session's current theme, as a 6-digit hexadecimal with a leading ``#``. + """ + color = PaletteColor(color) + if self.session.get_value("darkTheme"): + return color.rgb_dark + return color.rgb_light + + @validate(Number(), Number()) + def set_view_area(self, width, height): + """Set the dimensions of the view area. + + Parameters + ---------- + width : {0} + The new width, in pixels, divided by the device pixel ratio. + height : {1} + The new height, in pixels, divided by the device pixel ratio. + """ + self.call_action("setViewDimension", width, height) + + +class OverlayComponent(BasePathMixin): + """A single WCS overlay component. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + + CLASS = {} + """Mapping of :obj:`carta.constants.Overlay` enums to component classes. This mapping is used to select the appropriate subclass when an overlay component object is constructed in the wrapper.""" + + def __init_subclass__(cls, **kwargs): + """Automatically register subclasses in mapping from overlay component enums to classes.""" + super().__init_subclass__(**kwargs) + + OverlayComponent.CLASS[cls.COMPONENT] = cls + + def __init__(self, overlay): + self._base_path = f"overlayStore.{self.COMPONENT}" + self.session = overlay.session + + +class HasColor: + """Components which inherit this class have a palette color setting.""" + + @property + def color(self): + """The color of this component. + + Returns + ------- + a member of :obj:`carta.constants.color.PaletteColor` + The color. + """ + return PaletteColor(self.get_value("color")) + + @validate(Constant(PaletteColor)) + def set_color(self, color): + """Set the color of this component. + + Parameters + ---------- + color : {0} + The color. + """ + self.call_action("setColor", color) + + +class HasCustomColor(HasColor): + """Components which inherit this class have a palette color setting and a custom color flag.""" + + @property + def custom_color(self): + """Whether a custom color is applied to this component. + + Returns + ------- + boolean + Whether a custom color is applied. + """ + return self.get_value("customColor") + + @validate(Constant(PaletteColor)) + def set_color(self, color): + """Set the color of this component. + + This automatically enables the use of a custom color. + + Parameters + ---------- + color : {0} + The color. + """ + self.call_action("setColor", color) + self.set_custom_color(True) + + @validate(Boolean()) + def set_custom_color(self, state): + """Set whether a custom color should be applied to this component. + + Parameters + ---------- + state : {0} + Whether a custom color should be applied to this component. By default the global color is applied. + """ + self.call_action("setCustomColor", state) + + +class HasCustomText: + """Components which inherit this class have a custom text flag. Different components have different text properties, which are set separately.""" + + @property + def custom_text(self): + """Whether custom text is applied to this component. + + Returns + ------- + boolean + Whether custom text is applied. + """ + return self.get_value("customText") + + @validate(Boolean()) + def set_custom_text(self, state): + """Set whether custom text should be applied to this component. + + Parameters + ---------- + state : {0} + Whether custom text should be applied to this component. By default the text is generated automatically. + """ + self.call_action("setCustomText", state) + + +class HasFont: + """Components which inherit this class have a font setting.""" + + @property + def font(self): + """The font of this component. + + Returns + ------- + member of :obj:`carta.constants.FontFamily` + The font family. + member of :obj:`carta.constants.FontStyle` + The font style. + """ + font_id = self.get_value("font") + # Special fix for broken pattern in frontend + if font_id == 9: + font_id = 10 + elif font_id == 10: + font_id = 9 + font_family, font_style = divmod(font_id, 4) + return FontFamily(font_family), FontStyle(font_style) + + @property + def font_size(self): + """The font size of this component. + + Returns + ------- + number + The font size, in pixels. + """ + return self.get_value("fontSize") + + @validate(*all_optional(Constant(FontFamily), Constant(FontStyle))) + def set_font(self, font_family, font_style): + """Set the font of this component. + + Parameters + ---------- + font_family : {0} + The font family. + font_style : {1} + The font style. + """ + if font_family is None or font_style is None: + current_family, current_style = self.font + if font_family is None: + font_family = current_family + if font_style is None: + font_style = current_style + font_id = 4 * font_family + font_style + # Special fix for broken pattern in frontend + if font_id == 9: + font_id = 10 + elif font_id == 10: + font_id = 9 + self.call_action("setFont", font_id) + + @validate(Number()) + def set_font_size(self, font_size): + """Set the font size of this component. + + Parameters + ---------- + font_size : {0} + The font size, in pixels. + """ + self.call_action("setFontSize", font_size) + + +class HasVisibility: + """Components which inherit this class have a visibility setting, including ``show`` and ``hide`` aliases.""" + + @property + def visible(self): + """The visibility of this component. + + Returns + ------- + boolean + Whether this component is visible. + """ + return self.get_value("visible") + + @validate(Boolean()) + def set_visible(self, state): + """Set the visibility of this component. + + Parameters + ---------- + visible : {0} + Whether this component should be visible. + """ + self.call_action("setVisible", state) + + def show(self): + """Show this component.""" + self.set_visible(True) + + def hide(self): + """Hide this component.""" + self.set_visible(False) + + +class HasWidth: + """Components which inherit this class have a width setting.""" + + @property + def width(self): + """The width of this component. + + Returns + ------- + boolean + The width. + """ + return self.get_value("width") + + @validate(Number.POSITIVE) + def set_width(self, width): + """Set the width of this component. + + Parameters + ---------- + width : {0} + The width. + """ + self.call_action("setWidth", width) + + +class HasRotation: + """Components which inherit this class have a rotation setting.""" + + @property + def rotation(self): + """The rotation of this component. + + Returns + ------- + number + The rotation in degrees. + """ + return self.get_value("rotation") + + @validate(Number(min=-90, max=90, step=90)) + def set_rotation(self, rotation): + """Set the rotation of this component. + + Parameters + ---------- + rotation: {0} + The rotation in degrees. + """ + self.call_action("setRotation", rotation) + + +class HasCustomPrecision: + """Components which inherit this class have a precision setting and a custom precision flag.""" + + @property + def precision(self): + """The precision of this component. + + Returns + ------- + number + The precision. + """ + return self.get_value("precision") + + @property + def custom_precision(self): + """Whether a custom precision is applied to this component. + + Returns + ------- + boolean + Whether a custom precision is applied. + """ + return self.get_value("customPrecision") + + @validate(Number(min=0)) + def set_precision(self, precision): + """Set the precision of this component. + + This also automatically enables the custom precision. + + Parameters + ---------- + precision : {0} + The precision. + """ + self.call_action("setPrecision", precision) + self.set_custom_precision(True) + + @validate(Boolean()) + def set_custom_precision(self, state): + """Set whether a custom precision should be applied to this component. + + Parameters + ---------- + state + Whether a custom precision should be applied. + """ + self.call_action("setCustomPrecision", state) + + +class ImageWCSConnector: + """This is a helper mixin with functions which let a session WCS component delegate calls to image WCS components.""" + + ANY_IDS = NoneOr(IterableOf(Number.ID)) + + def _images(self, image_ids=None): + """Internal helper function for fetching image objects.""" + if image_ids is None: + return self.session.image_list() + return [self.session.image_by_id(image_id) for image_id in image_ids] + + def _get_image_wcs_properties(self, image_ids, property_path): + """Internal helper function for fetching wcs properties from multiple images.""" + images = self._images(image_ids) + return tuple(attrgetter(property_path)(image.wcs) for image in images) + + def _call_image_wcs_functions(self, image_ids, function_path, *function_args): + """Internal helper function for executing wcs functions on multiple images.""" + images = self._images(image_ids) + for image in images: + attrgetter(function_path)(image.wcs)(*function_args) + + +class Global(HasColor, OverlayComponent): + """The global WCS overlay configuration. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + COMPONENT = Overlay.GLOBAL + + @property + def tolerance(self): + """The tolerance. + + Returns + ------- + number + The tolerance, as a percentage. + """ + return self.get_value("tolerance") + + @property + def coordinate_system(self): + """The coordinate system. + + Returns + ------- + a member of :obj:`carta.constants.CoordinateSystem` + The coordinate system. + """ + return CoordinateSystem(self.get_value("system")) + + @property + def labelling(self): + """The labelling (internal or external). + + Returns + ------- + a member of :obj:`carta.constants.LabelType` + The type of labelling. + """ + return LabelType(self.get_value("labelType")) + + @validate(Number.PERCENTAGE) + def set_tolerance(self, tolerance): + """Set the tolerance. + + Parameters + ---------- + tolerance : {0} + The tolerance, as a percentage. + """ + self.call_action("setTolerance", tolerance) + + @validate(Constant(CoordinateSystem)) + def set_coordinate_system(self, coordinate_system): + """Set the coordinate system. + + Parameters + ---------- + coordinate_system : {0} + The coordinate system. + """ + self.call_action("setSystem", coordinate_system) + + @validate(Constant(LabelType)) + def set_labelling(self, labelling): + """Set the type of labelling (internal or external). + + Parameters + ---------- + labelling : {0} + The type of labelling. + """ + self.call_action("setLabelType", labelling) + + +class Title(HasCustomColor, HasCustomText, HasFont, HasVisibility, ImageWCSConnector, OverlayComponent): + """The WCS overlay title configuration. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + COMPONENT = Overlay.TITLE + + @validate(ImageWCSConnector.ANY_IDS) + def text(self, image_ids=None): + """The custom title text for the specified images. + + Parameters + ---------- + image_ids : {0} + The images to query. + + Returns + ------- + tuple of string + The title text of the specified images. + """ + return self._get_image_wcs_properties(image_ids, "title.text") + + @validate(String(), ImageWCSConnector.ANY_IDS) + def set_text(self, title_text, image_ids=None): + """Set the custom title text for the specified images. + + This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. + + Parameters + ---------- + title_text : {0} + The custom title text for the specified images. + image_ids : {1} + The images to configure. + """ + self._call_image_wcs_functions(image_ids, "title.set_text", title_text) + + +class Grid(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): + """The WCS overlay grid configuration. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + COMPONENT = Overlay.GRID + + @property + def gap(self): + """The gap. + + Returns + ------- + number + The X gap. + number + The Y gap. + """ + return self.get_value("gapX"), self.get_value("gapY") + + @property + def custom_gap(self): + """Whether a custom gap is applied to this component. + + Returns + ------- + boolean + Whether a custom gap is applied. + """ + return self.get_value("customGap") + + @validate(*all_optional(Number.POSITIVE, Number.POSITIVE)) + def set_gap(self, gap_x, gap_y): + """Set the gap. + + This also automatically enables the custom gap. + + Parameters + ---------- + gap_x : {0} + The X gap. + gap_y : {1} + The Y gap. + """ + if gap_x is not None: + self.call_action("setGapX", gap_x) + if gap_y is not None: + self.call_action("setGapY", gap_y) + if gap_x is not None or gap_y is not None: + self.set_custom_gap(True) + + @validate(Boolean()) + def set_custom_gap(self, state): + """Set whether a custom gap should be applied to this component. + + Parameters + ---------- + state : {0} + Whether a custom gap should be applied. + """ + self.call_action("setCustomGap", state) + + +class Border(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): + """The WCS overlay border configuration. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + COMPONENT = Overlay.BORDER + + +class Axes(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): + """The WCS overlay axes configuration. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + COMPONENT = Overlay.AXES + + +class Numbers(HasCustomColor, HasFont, HasVisibility, HasCustomPrecision, OverlayComponent): + """The WCS overlay numbers configuration. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + COMPONENT = Overlay.NUMBERS + + @property + def format(self): + """The X and Y number format. + + If the image has no WCS information, both the X and Y formats will be ``None``. + + If a custom number format is not set, this returns the default format set by the coordinate system. + + Returns + ------- + a member of :obj:`carta.constants.NumberFormat` or ``None`` + The X format. + a member of :obj:`carta.constants.NumberFormat` or ``None`` + The Y format. + """ + format_x = self.get_value("formatTypeX") + format_y = self.get_value("formatTypeY") + return NumberFormat(format_x), NumberFormat(format_y) + + @property + def custom_format(self): + """Whether a custom format is applied to the numbers. + + Returns + ------- + boolean + Whether a custom format is applied. + """ + return self.get_value("customFormat") + + @validate(*all_optional(Constant(NumberFormat), Constant(NumberFormat))) + def set_format(self, format_x=None, format_y=None): + """Set the X and/or Y number format. + + This also automatically enables the custom number format, if either of the format parameters is set. If only one format is provided, the other will be set to the last previously used custom format, or to the system default. + + Parameters + ---------- + format_x : {0} + The X format. + format_y : {1} + The Y format. + """ + if format_x is not None: + self.call_action("setFormatX", format_x) + if format_y is not None: + self.call_action("setFormatY", format_y) + if format_x is not None or format_y is not None: + self.set_custom_format(True) + + @validate(Boolean()) + def set_custom_format(self, state): + """Set whether a custom format should be applied to the numbers. + + Parameters + ---------- + state : {0} + Whether a custom format should be applied. + """ + self.call_action("setCustomFormat", state) + + +class Labels(HasCustomColor, HasCustomText, HasFont, HasVisibility, OverlayComponent): + """The WCS overlay labels configuration. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + COMPONENT = Overlay.LABELS + + @property + def text(self): + """The label text. + + If a custom label text has not been set, these values will be blank. + + Returns + ------- + string + The X label text. + string + The Y label text. + """ + return self.get_value("customLabelX"), self.get_value("customLabelY") + + @validate(*all_optional(String(), String())) + def set_text(self, label_x=None, label_y=None): + """Set the label text. + + This also automatically enables the custom label text. + + Parameters + ---------- + label_x : {0} + The X-axis label text. + label_y : {1} + The Y-axis label text. + """ + if label_x is not None: + self.call_action("setCustomLabelX", label_x) + if label_y is not None: + self.call_action("setCustomLabelY", label_y) + if label_x is not None or label_y is not None: + self.call_action("setCustomText", True) + + +class Ticks(HasCustomColor, HasWidth, OverlayComponent): + """The WCS overlay ticks configuration. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + COMPONENT = Overlay.TICKS + + @property + def density(self): + """The density. + + Returns + ------- + number + The X density. + number + The Y density. + """ + return self.get_value("densityX"), self.get_value("densityY") + + @property + def custom_density(self): + """Whether a custom density is applied to the ticks. + + Returns + ------- + boolean + Whether a custom density is applied. + """ + return self.get_value("customDensity") + + @property + def draw_on_all_edges(self): + """Whether the ticks are drawn on all edges. + + Returns + ------- + boolean + Whether the ticks are drawn on all edges. + """ + return self.get_value("drawAll") + + @property + def minor_length(self): + """The minor tick length. + + Returns + ------- + number + The minor length, as a percentage. + """ + return self.get_value("length") + + @property + def major_length(self): + """The major tick length. + + Returns + ------- + number + The major length, as a percentage. + """ + return self.get_value("majorLength") + + @validate(*all_optional(Number.POSITIVE, Number.POSITIVE)) + def set_density(self, density_x=None, density_y=None): + """Set the density. + + This also automatically enables the custom density. + """ + if density_x is not None: + self.call_action("setDensityX", density_x) + if density_y is not None: + self.call_action("setDensityY", density_y) + if density_x is not None or density_y is not None: + self.set_custom_density(True) + + @validate(Boolean()) + def set_custom_density(self, state): + """Set whether a custom density should be applied to the ticks. + + Parameters + ---------- + state : {0} + Whether a custom density should be applied. + """ + self.call_action("setCustomDensity", state) + + @validate(Boolean()) + def set_draw_on_all_edges(self, state): + """Set whether the ticks should be drawn on all edges. + + Parameters + ---------- + state : {0} + Whether the ticks should be drawn on all edges. + """ + self.call_action("setDrawAll", state) + + @validate(Number.PERCENTAGE) + def set_minor_length(self, length): + """Set the minor tick length. + + Parameters + ---------- + length : {0} + The minor tick length, as a percentage. + """ + self.call_action("setLength", length) + + @validate(Number.PERCENTAGE) + def set_major_length(self, length): + """Set the major tick length. + + Parameters + ---------- + length : {0} + The major tick length, as a percentage. + """ + self.call_action("setMajorLength", length) + + +class ColorbarComponent: + """Base class for components of the WCS overlay colorbar. + + Attributes + ---------- + colorbar : :obj:`carta.wcs_overlay.Colorbar` object + The colorbar object associated with this colorbar component. + session : :obj:`carta.session.Session` object + The session object associated with this colorbar component. + """ + + def __init__(self, colorbar): + self.colorbar = colorbar + self.session = colorbar.session + + def call_action(self, path, *args, **kwargs): + """Convenience wrapper for the colorbar object's generic action method. + + This method calls :obj:`carta.wcs_overlay.Colorbar.call_action` after inserting this subcomponent's name prefix into the path parameter. It assumes that the action name starts with a lowercase word, and that the prefix should be inserted after this word with a leading capital letter. + + Parameters + ---------- + path : string + The path to an action relative to the colorbar object's store, with this subcomponent's name prefix omitted. + *args + A variable-length list of parameters. These are passed unmodified to the colorbar method. + **kwargs + Arbitrary keyword parameters. These are passed unmodified to the colorbar method. + + Returns + ------- + object or None + The unmodified return value of the colorbar method. + """ + path = re.sub(r"(?:.*\.)*(.*?)([A-Z].*)", rf"\1{self.PREFIX.title()}\2", path) + self.colorbar.call_action(path, *args, **kwargs) + + def get_value(self, path, return_path=None): + """Convenience wrapper for the colorbar object's generic method for retrieving attribute values. + + This method calls :obj:`carta.wcs_overlay.Colorbar.get_value` after inserting this subcomponent's name prefix into the path parameter. It assumes that the attribute name starts with a lowercase letter, that the prefix should be inserted before it, and that the first letter of the original name should be capitalised. + + Parameters + ---------- + path : string + The path to an attribute relative to the colorbar object's store, with this subcomponent's name prefix omitted. + return_path : string, optional + Specifies a subobject of the attribute value which should be returned instead of the whole object. + + Returns + ------- + object + The unmodified return value of the colorbar method. + """ + def rewrite(m): + before, first, rest = m.groups() + return f"{before}{self.PREFIX}{first.upper()}{rest}" + + path = re.sub(r"((?:.*\.)?.*?)(.)(.*)", rewrite, path) + return self.colorbar.get_value(path, return_path=return_path) + + +class ColorbarBorder(HasVisibility, HasWidth, HasCustomColor, ColorbarComponent): + """The WCS overlay colorbar border configuration. + + Attributes + ---------- + colorbar : :obj:`carta.wcs_overlay.Colorbar` object + The colorbar object associated with this colorbar component. + """ + PREFIX = "border" + + +class ColorbarTicks(HasVisibility, HasWidth, HasCustomColor, ColorbarComponent): + """The WCS overlay colorbar ticks configuration. + + Attributes + ---------- + colorbar : :obj:`carta.wcs_overlay.Colorbar` object + The colorbar object associated with this colorbar component. + """ + PREFIX = "tick" + + @property + def density(self): + """The colorbar ticks density. + + Returns + ------- + number + The density. + """ + return self.get_value("density") + + @property + def length(self): + """The colorbar ticks length. + + Returns + ------- + number + The length. + """ + return self.get_value("len") + + @validate(Number.POSITIVE) + def set_density(self, density): + """Set the colorbar ticks density. + + Parameters + ---------- + density : {0} + The density. + """ + self.call_action("setDensity", density) + + @validate(Number.POSITIVE) + def set_length(self, length): + """Set the colorbar ticks length. + + Parameters + ---------- + length : {0} + The length. + """ + self.call_action("setLen", length) + + +class ColorbarNumbers(HasVisibility, HasCustomPrecision, HasCustomColor, HasFont, HasRotation, ColorbarComponent): + """The WCS overlay colorbar numbers configuration. + + Attributes + ---------- + colorbar : :obj:`carta.wcs_overlay.Colorbar` object + The colorbar object associated with this colorbar component. + """ + PREFIX = "number" + + +class ColorbarLabel(HasVisibility, HasCustomColor, HasCustomText, HasFont, HasRotation, ImageWCSConnector, ColorbarComponent): + """The WCS overlay colorbar label configuration. + + Attributes + ---------- + colorbar : :obj:`carta.wcs_overlay.Colorbar` object + The colorbar object associated with this colorbar component. + """ + PREFIX = "label" + + @validate(ImageWCSConnector.ANY_IDS) + def text(self, image_ids=None): + """The custom colorbar label text for the specified images. + + Parameters + ---------- + image_ids : {0} + The images to query. + + Returns + ------- + tuple of string + The colorbar label text of the specified images. + """ + return self._get_image_wcs_properties(image_ids, "colorbar.label.text") + + @validate(String(), ImageWCSConnector.ANY_IDS) + def set_text(self, label_text, image_ids=None): + """Set the custom colorbar label text for the specified images. + + This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. + + Parameters + ---------- + label_text : {0} + The custom colorbar label text for the specified images. + image_ids : {1} + The images to configure. + + """ + self._call_image_wcs_functions(image_ids, "colorbar.label.set_text", label_text) + + +class ColorbarGradient(HasVisibility, ColorbarComponent): + """The WCS overlay colorbar gradient configuration. + + Attributes + ---------- + colorbar : :obj:`carta.wcs_overlay.Colorbar` object + The colorbar object associated with this colorbar component. + """ + PREFIX = "gradient" + + +class Colorbar(HasCustomColor, HasVisibility, HasWidth, OverlayComponent): + """The WCS overlay colorbar configuration. + + This component has subcomponents which are configured separately through properties on this object. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + border : :obj:`carta.wcs_overlay.ColorbarBorder` object + The border subcomponent. + ticks : :obj:`carta.wcs_overlay.ColorbarTicks` object + The ticks subcomponent. + numbers : :obj:`carta.wcs_overlay.ColorbarNumbers` object + The numbers subcomponent. + label : :obj:`carta.wcs_overlay.ColorbarLabel` object + The label subcomponent. + gradient : :obj:`carta.wcs_overlay.ColorbarGradient` object + The gradient subcomponent. + """ + COMPONENT = Overlay.COLORBAR + + def __init__(self, overlay): + super().__init__(overlay) + self.border = ColorbarBorder(self) + self.ticks = ColorbarTicks(self) + self.numbers = ColorbarNumbers(self) + self.label = ColorbarLabel(self) + self.gradient = ColorbarGradient(self) + + @property + def interactive(self): + """Whether the colorbar is interactive. + + Returns + ------- + boolean + Whether the colorbar is interactive. + """ + return self.get_value("interactive") + + @property + def offset(self): + """The colorbar offset. + + Returns + ------- + number + The offset, in pixels. + """ + return self.get_value("offset") + + @property + def position(self): + """The colorbar position. + + Returns + ------- + a member of :obj:`carta.constants.ColorbarPosition` + The position. + """ + return ColorbarPosition(self.get_value("position")) + + @validate(Boolean()) + def set_interactive(self, state): + """Set whether the colorbar should be interactive. + + Parameters + ---------- + state : {0} + Whether the colorbar should be interactive. + """ + self.call_action("setInteractive", state) + + @validate(Number(min=0)) + def set_offset(self, offset): + """Set the colorbar offset. + + Parameters + ---------- + offset : {0} + The offset, in pixels. + """ + self.call_action("setOffset", offset) + + @validate(Constant(ColorbarPosition)) + def set_position(self, position): + """Set the colorbar position. + + Parameters + ---------- + offset : {0} + The position. + """ + self.call_action("setPosition", position) + + +class Beam(ImageWCSConnector, OverlayComponent): + """The WCS overlay beam configuration. + + All beam settings are per-image. Through this object, settings can be retrieved or applied to a single image, all images, or a subset of images. + + Attributes + ---------- + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + COMPONENT = Overlay.BEAM + + @validate(ImageWCSConnector.ANY_IDS) + def position(self, image_ids=None): + """The beam position. + + Parameters + ---------- + image_ids : {0} + The images to query. By default, values will be returned for all images. + + Returns + ------- + tuple of (number, number) tuples + The X and Y beam positions of the specified images, in pixels. + """ + return self._get_image_wcs_properties(image_ids, "beam.position") + + @validate(ImageWCSConnector.ANY_IDS) + def type(self, image_ids=None): + """The beam type. + + Parameters + ---------- + image_ids : {0} + The images to query. By default, values will be returned for all images. + + Returns + ------- + tuple of members of :obj:`carta.constants.BeamType` + The beam types of the specified images. + """ + return self._get_image_wcs_properties(image_ids, "beam.type") + + @validate(ImageWCSConnector.ANY_IDS) + def color(self, image_ids=None): + """The color of this component. + + Parameters + ---------- + image_ids : {0} + The images to query. By default, values will be returned for all images. + + Returns + ------- + tuple of members of :obj:`carta.constants.color.PaletteColor` + The colors of the beam in the specified images. + """ + return self._get_image_wcs_properties(image_ids, "beam.color") + + @validate(ImageWCSConnector.ANY_IDS) + def visible(self, image_ids=None): + """The visibility of this component. + + Parameters + ---------- + image_ids : {0} + The images to query. By default, values will be returned for all images. + + Returns + ------- + tuple of boolean + Whether the beam is visible in the specified images. + """ + return self._get_image_wcs_properties(image_ids, "beam.visible") + + @validate(ImageWCSConnector.ANY_IDS) + def width(self, image_ids=None): + """The width of this component. + + Parameters + ---------- + image_ids : {0} + The images to query. By default, values will be returned for all images. + + Returns + ------- + tuple of boolean + The width of the beam in the specified images. + """ + return self._get_image_wcs_properties(image_ids, "beam.width") + + @validate(*all_optional(Number(), Number(), ImageWCSConnector.ANY_IDS)) + def set_position(self, position_x=None, position_y=None, image_ids=None): + """Set the beam position. + + Parameters + ---------- + position_x : {0} + The X position, in pixels. + position_y : {1} + The Y position, in pixels. + image_ids : {2} + The images to configure. By default, the settings will be changed for all images. + """ + self._call_image_wcs_functions(image_ids, "beam.set_position", position_x, position_y) + + @validate(Constant(BeamType), ImageWCSConnector.ANY_IDS) + def set_type(self, beam_type, image_ids=None): + """Set the beam type. + + Parameters + ---------- + beam_type : {0} + The beam type. + image_ids : {1} + The images to configure. By default, the settings will be changed for all images. + """ + self._call_image_wcs_functions(image_ids, "beam.set_type", beam_type) + + @validate(Constant(PaletteColor), ImageWCSConnector.ANY_IDS) + def set_color(self, color, image_ids=None): + """Set the color of this component. + + Parameters + ---------- + color : {0} + The color. + image_ids : {1} + The images to configure. By default, the settings will be changed for all images. + """ + self._call_image_wcs_functions(image_ids, "beam.set_color", color) + + @validate(Boolean(), ImageWCSConnector.ANY_IDS) + def set_visible(self, state, image_ids=None): + """Set the visibility of this component. + + Parameters + ---------- + visible : {0} + Whether this component should be visible. + image_ids : {1} + The images to configure. By default, the settings will be changed for all images. + """ + self._call_image_wcs_functions(image_ids, "beam.set_visible", state) + + @validate(ImageWCSConnector.ANY_IDS) + def show(self, image_ids=None): + """Show this component. + + Parameters + ---------- + image_ids : {0} + The images to configure. By default, the settings will be changed for all images. + """ + self.set_visible(True, image_ids) + + @validate(ImageWCSConnector.ANY_IDS) + def hide(self, image_ids=None): + """Hide this component. + + Parameters + ---------- + image_ids : {0} + The images to configure. By default, the settings will be changed for all images. + """ + self.set_visible(False, image_ids) + + @validate(Number.POSITIVE, ImageWCSConnector.ANY_IDS) + def set_width(self, width, image_ids=None): + """Set the width of this component. + + Parameters + ---------- + width : {0} + The width. + image_ids : {1} + The images to configure. By default, the settings will be changed for all images. + """ + self._call_image_wcs_functions(image_ids, "beam.set_width", width) + + +class ImageWCSOverlay(BasePathMixin): + """Utility object for collecting functions related to the WCS overlay settings for individual images. These functions are grouped in subcomponents, which can be accessed directly by name or looked up in a mapping by `carta.constants.Overlay` enum. + + This object is only used to access WCS settings that are applied per-image. Global WCS settings are accessed through the :obj:`carta.wcs_overlay.SessionWCSOverlay` object. + + Parameters + ---------- + image : :obj:`carta.image.Image` object + The image object associated with this overlay object. + + Attributes + ---------- + title : :obj:`carta.wcs_overlay.ImageWCSOverlay.ImageTitle` object + The title settings subcomponent. + colorbar : :obj:`carta.wcs_overlay.ImageWCSOverlay.ImageColorbar` object + The colorbar settings subcomponent. + beam : :obj:`carta.wcs_overlay.ImageWCSOverlay.ImageBeam` object + The beam settings subcomponent. + """ + + class ImageTitle: + """The image WCS overlay title configuration. + + Attributes + ---------- + image : :obj:`carta.image.Image` object + The image object associated with this overlay component. + """ + + def __init__(self, image): + self.image = image + + @property + def text(self): + """The custom title text for this image. + + Returns + ------- + string + The title text. + """ + return self.image.get_value("titleCustomText") + + @validate(String()) + def set_text(self, title_text): + """Set the custom title text for this image. + + This also automatically enables custom title text for all images. It can be disabled with :obj:`carta.wcs_overlay.Title.set_custom_text`. + + Parameters + ---------- + title_text : {0} + The custom title text. + """ + self.image.call_action("setTitleCustomText", title_text) + self.image.session.wcs.title.set_custom_text(True) + + class ImageColorbar: + """The image WCS overlay title configuration. + + Attributes + ---------- + label : :obj:`carta.wcs_overlay.ImageWCSOverlay.ImageColorbar.ImageColorbarLabel` object + The label subcomponent. + """ + + class ImageColorbarLabel: + """The image WCS overlay colorbar label configuration. + + Attributes + ---------- + image : :obj:`carta.image.Image` object + The image object associated with this overlay component. + """ + + def __init__(self, image): + self.image = image + + @property + def text(self): + """The custom colorbar label text for this image. + + Returns + ------- + string + The title text. + """ + return self.image.get_value("colorbarLabelCustomText") + + def set_text(self, label_text): + """Set the custom colorbar label text for this image. + + This also automatically enables custom colorbar label text for all images. It can be disabled with :obj:`carta.wcs_overlay.ColorbarLabel.set_custom_text`. + + Parameters + ---------- + label_text : {0} + The custom colorbar label text. + """ + self.image.call_action("setColorbarLabelCustomText", label_text) + self.image.session.wcs.colorbar.label.set_custom_text(True) + + def __init__(self, image): + self.label = self.ImageColorbarLabel(image) + + class ImageBeam(HasColor, HasVisibility, HasWidth, BasePathMixin): + """The image WCS overlay beam configuration. + + Attributes + ---------- + image : :obj:`carta.image.Image` object + The image object associated with this overlay component. + session : :obj:`carta.session.Session` object + The session object associated with this overlay component. + """ + + def __init__(self, image): + self._base_path = f"{image._base_path}.overlayBeamSettings" + self.session = image.session + + @property + def position(self): + """The beam position. + + Returns + ------- + number + The X beam position, in pixels. + number + The Y beam position, in pixels. + """ + return self.get_value("shiftX"), self.get_value("shiftY") + + @property + def type(self): + """The beam type. + + Returns + ------- + a member of :obj:`carta.constants.BeamType` + The beam type. + """ + return BeamType(self.get_value("type")) + + @validate(*all_optional(Number(), Number())) + def set_position(self, position_x=None, position_y=None): + """Set the beam position. + + Parameters + ---------- + position_x : {0} + The X position, in pixels. + position_y : {1} + The Y position, in pixels. + """ + if position_x is not None: + self.call_action("setShiftX", position_x) + if position_y is not None: + self.call_action("setShiftY", position_y) + + @validate(Constant(BeamType)) + def set_type(self, beam_type): + """Set the beam type. + + Parameters + ---------- + beam_type : {0} + The beam type. + """ + self.call_action("setType", beam_type) + + def __init__(self, image): + self._components = {} + for component, clazz in { + Overlay.TITLE: self.ImageTitle, + Overlay.COLORBAR: self.ImageColorbar, + Overlay.BEAM: self.ImageBeam + }.items(): + comp = clazz(image) + self._components[component] = comp + name = component.name.lower() + setattr(self, f"{name}", comp) diff --git a/docs/source/carta.rst b/docs/source/carta.rst index 6310a0c..2cf84c0 100644 --- a/docs/source/carta.rst +++ b/docs/source/carta.rst @@ -25,6 +25,14 @@ carta.constants module :undoc-members: :show-inheritance: +carta.contours module +--------------------- + +.. automodule:: carta.contours + :members: + :undoc-members: + :show-inheritance: + carta.image module ------------------ @@ -41,6 +49,14 @@ carta.metadata module :undoc-members: :show-inheritance: +carta.preferences module +------------------------ + +.. automodule:: carta.preferences + :members: + :undoc-members: + :show-inheritance: + carta.protocol module --------------------- @@ -49,6 +65,14 @@ carta.protocol module :undoc-members: :show-inheritance: +carta.raster module +------------------- + +.. automodule:: carta.raster + :members: + :undoc-members: + :show-inheritance: + carta.session module -------------------- @@ -97,3 +121,11 @@ carta.vector_overlay module :undoc-members: :show-inheritance: +carta.wcs_overlay module +------------------------ + +.. automodule:: carta.wcs_overlay + :members: + :undoc-members: + :show-inheritance: + diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst index 7823fb7..004988b 100644 --- a/docs/source/quickstart.rst +++ b/docs/source/quickstart.rst @@ -194,19 +194,20 @@ Properties specific to individual images can be accessed through image objects: img.set_zoom(4) # change colormap - img.set_colormap(Colormap.VIRIDIS) + img.raster.set_colormap(Colormap.VIRIDIS) # more advanced options - img.set_colormap(Colormap.VIRIDIS, invert=True) - img.set_scaling(Scaling.LOG, alpha=100, min=-0.5, max=30) + img.raster.set_colormap(Colormap.VIRIDIS, invert=True) + img.raster.set_scaling(Scaling.LOG, alpha=100) + img.raster.set_clip(min=-0.5, max=30) # add contours levels = np.arange(5, 5 * 5, 4) - img.configure_contours(levels) - img.apply_contours() + img.contours.configure(levels) + img.contours.apply() # use a constant colour - img.set_contour_color("red") + img.contours.set_color("red") # or use a colourmap - img.set_contour_colormap(Colormap.REDS) + img.contours.set_colormap(Colormap.REDS) Changing session properties --------------------------- @@ -215,14 +216,14 @@ Properties which affect the whole session can be set through the session object: .. code-block:: python - from carta.constants import CoordinateSystem, PaletteColor, Overlay + from carta.constants import CoordinateSystem, PaletteColor - # change some overlay properties - session.set_view_area(1000, 1000) - session.set_coordinate_system(CoordinateSystem.FK5) - session.set_color(PaletteColor.RED) - session.set_color(PaletteColor.VIOLET, Overlay.TICKS) - session.show(Overlay.TITLE) + # change some WCS overlay properties + session.wcs.set_view_area(1000, 1000) + session.wcs.global_.set_coordinate_system(CoordinateSystem.FK5) + session.wcs.global_.set_color(PaletteColor.RED) + session.wcs.ticks.set_color(PaletteColor.VIOLET) + session.wcs.title.show() Saving or displaying an image ----------------------------- diff --git a/tests/test_contours.py b/tests/test_contours.py new file mode 100644 index 0000000..f159f63 --- /dev/null +++ b/tests/test_contours.py @@ -0,0 +1,129 @@ +import pytest + +from carta.contours import Contours +from carta.constants import Colormap as CM, SmoothingMode as SM, ContourDashMode as CDM + +# FIXTURES + + +@pytest.fixture +def contours(image): + return Contours(image) + + +@pytest.fixture +def call_action(contours, mock_call_action): + return mock_call_action(contours) + + +@pytest.fixture +def method(contours, mock_method): + return mock_method(contours) + + +@pytest.fixture +def image_call_action(image, mock_call_action): + return mock_call_action(image) + + +# TESTS +@pytest.mark.parametrize("args,kwargs,expected_args", [ + ([], {}, None), + ([[1, 2, 3], SM.GAUSSIAN_BLUR, 4], {}, [[1, 2, 3], SM.GAUSSIAN_BLUR, 4]), + ([], {"levels": [1, 2, 3]}, [[1, 2, 3], "M(smoothingMode)", "M(smoothingFactor)"]), + ([], {"smoothing_mode": SM.GAUSSIAN_BLUR}, ["M(levels)", SM.GAUSSIAN_BLUR, "M(smoothingFactor)"]), + ([], {"smoothing_factor": 4}, ["M(levels)", "M(smoothingMode)", 4]), +]) +def test_configure(contours, call_action, method, args, kwargs, expected_args): + method("macro", lambda _, v: f"M({v})") + contours.configure(*args, **kwargs) + if expected_args is None: + call_action.assert_not_called() + else: + call_action.assert_called_with("setContourConfiguration", *expected_args) + + +def test_set_dash_mode(mocker, contours, call_action): + contours.set_dash_mode(CDM.DASHED) + call_action.assert_called_with("setDashMode", CDM.DASHED) + + +def test_set_thickness(mocker, contours, call_action): + contours.set_thickness(2) + call_action.assert_called_with("setThickness", 2) + + +def test_set_color(mocker, contours, call_action): + contours.set_color("blue") + call_action.assert_has_calls([ + mocker.call("setColor", "blue"), + mocker.call("setColormapEnabled", False), + ]) + + +def test_set_colormap(mocker, contours, call_action): + contours.set_colormap(CM.VIRIDIS) + call_action.assert_has_calls([ + mocker.call("setColormap", CM.VIRIDIS), + mocker.call("setColormapEnabled", True), + ]) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([0.5, 1.5], {}, [("setColormapBias", 0.5), ("setColormapContrast", 1.5)]), + ([], {"bias": 0.5}, [("setColormapBias", 0.5)]), + ([], {"contrast": 1.5}, [("setColormapContrast", 1.5)]), +]) +def test_set_bias_and_contrast(mocker, contours, call_action, args, kwargs, expected_calls): + contours.set_bias_and_contrast(*args, **kwargs) + call_action.assert_has_calls([mocker.call(*call) for call in expected_calls]) + + +def test_apply(contours, image_call_action): + contours.apply() + image_call_action.assert_called_with("applyContours") + + +def test_clear(contours, image_call_action): + contours.clear() + image_call_action.assert_called_with("clearContours", True) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([[1, 2, 3], SM.GAUSSIAN_BLUR, 4, CDM.DASHED, 2, "blue", CM.VIRIDIS, 0.5, 1.5], {}, [("configure", [1, 2, 3], SM.GAUSSIAN_BLUR, 4), ("set_dash_mode", CDM.DASHED), ("set_thickness", 2), ("set_color", "blue"), ("set_colormap", CM.VIRIDIS), ("set_bias_and_contrast", 0.5, 1.5), ("apply",)]), + ([], {"smoothing_mode": SM.GAUSSIAN_BLUR}, [("configure", None, SM.GAUSSIAN_BLUR, None), ("apply",)]), + ([], {"dash_mode": CDM.DASHED}, [("set_dash_mode", CDM.DASHED), ("apply",)]), + ([], {"thickness": 2}, [("set_thickness", 2), ("apply",)]), + ([], {"color": "blue"}, [("set_color", "blue"), ("apply",)]), + ([], {"colormap": CM.VIRIDIS}, [("set_colormap", CM.VIRIDIS), ("apply",)]), + ([], {"bias": 0.5}, [("set_bias_and_contrast", 0.5, None), ("apply",)]), +]) +def test_plot(contours, method, args, kwargs, expected_calls): + mocks = {} + for method_name in ("configure", "set_dash_mode", "set_thickness", "set_color", "set_colormap", "set_bias_and_contrast", "apply"): + mocks[method_name] = method(method_name, None) + + contours.plot(*args, **kwargs) + + for method_name, *expected_args in expected_calls: + mocks[method_name].assert_called_with(*expected_args) + + +@pytest.mark.parametrize("state", [True, False]) +def test_set_visible(contours, call_action, state): + contours.set_visible(state) + call_action.assert_called_with("setVisible", state) + + +def test_show(contours, method): + mock_set_visible = method("set_visible", None) + contours.show() + mock_set_visible.assert_called_with(True) + + +def test_hide(contours, method): + mock_set_visible = method("set_visible", None) + contours.hide() + mock_set_visible.assert_called_with(False) diff --git a/tests/test_image.py b/tests/test_image.py index fa88675..72ba134 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -2,8 +2,7 @@ from carta.image import Image from carta.util import CartaValidationFailed -from carta.constants import NumberFormat as NF, SpatialAxis as SA - +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 @@ -33,6 +32,11 @@ def session_call_action(session, mock_call_action): return mock_call_action(session) +@pytest.fixture +def session_get_value(session, mock_get_value): + return mock_get_value(session) + + @pytest.fixture def session_method(session, mock_method): return mock_method(session) @@ -79,7 +83,10 @@ def test_new(session, session_call_action, session_method, args, kwargs, expecte @pytest.mark.parametrize("name,classname", [ + ("raster", "Raster"), + ("contours", "Contours"), ("vectors", "VectorOverlay"), + ("wcs", "ImageWCSOverlay"), ]) def test_subobjects(image, name, classname): assert getattr(image, name).__class__.__name__ == classname @@ -144,9 +151,9 @@ def test_set_center_valid_pixels(image, property_, call_action, x, y): ("12h34m56.789s", "5h34m56.789s", NF.HMS, NF.HMS, "12:34:56.789", "5:34:56.789"), ("12d34m56.789s", "12d34m56.789s", NF.DMS, NF.DMS, "12:34:56.789", "12:34:56.789"), ]) -def test_set_center_valid_wcs(image, property_, session_method, call_action, x, y, x_fmt, y_fmt, x_norm, y_norm): +def test_set_center_valid_wcs(image, property_, mock_property, call_action, x, y, x_fmt, y_fmt, x_norm, y_norm): property_("valid_wcs", True) - session_method("number_format", [(x_fmt, y_fmt, None)]) + mock_property("carta.wcs_overlay.Numbers")("format", (x_fmt, y_fmt)) image.set_center(x, y) call_action.assert_called_with("setCenterWcs", x_norm, y_norm) @@ -160,11 +167,11 @@ def test_set_center_valid_wcs(image, property_, session_method, call_action, x, (123, "123", True, NF.DEGREES, NF.DEGREES, "Cannot mix image and world coordinates"), ("123", 123, True, NF.DEGREES, NF.DEGREES, "Cannot mix image and world coordinates"), ]) -def test_set_center_invalid(image, property_, session_method, call_action, x, y, wcs, x_fmt, y_fmt, error_contains): +def test_set_center_invalid(image, property_, mock_property, call_action, x, y, wcs, x_fmt, y_fmt, error_contains): property_("width", 200) property_("height", 200) property_("valid_wcs", wcs) - session_method("number_format", [(x_fmt, y_fmt, None)]) + mock_property("carta.wcs_overlay.Numbers")("format", (x_fmt, y_fmt)) with pytest.raises(Exception) as e: image.set_center(x, y) @@ -198,3 +205,182 @@ def test_zoom_to_size_invalid(image, property_, axis, val, wcs, error_contains): with pytest.raises(Exception) as e: image.zoom_to_size(val, axis) assert error_contains in str(e.value) + + +# PER-IMAGE WCS + +def test_set_custom_colorbar_label(session, image, call_action, mock_method): + label_set_custom_text = mock_method(session.wcs.colorbar.label)("set_custom_text", None) + image.wcs.colorbar.label.set_text("Custom text here!") + call_action.assert_called_with("setColorbarLabelCustomText", "Custom text here!") + label_set_custom_text.assert_called_with(True) + + +def test_colorbar_label(image, get_value): + get_value.side_effect = ["Custom text here!"] + text = image.wcs.colorbar.label.text + get_value.assert_called_with("colorbarLabelCustomText") + assert text == "Custom text here!" + + +def test_set_custom_title(session, image, call_action, mock_method): + title_set_custom_text = mock_method(session.wcs.title)("set_custom_text", None) + image.wcs.title.set_text("Custom text here!") + call_action.assert_called_with("setTitleCustomText", "Custom text here!") + title_set_custom_text.assert_called_with(True) + + +def test_title(image, get_value): + get_value.side_effect = ["Custom text here!"] + text = image.wcs.title.text + get_value.assert_called_with("titleCustomText") + assert text == "Custom text here!" + + +def test_beam_set_position(mocker, image, session_call_action): + image.wcs.beam.set_position(2, 3) + session_call_action.assert_has_calls([ + mocker.call("frameMap[0].overlayBeamSettings.setShiftX", 2), + mocker.call("frameMap[0].overlayBeamSettings.setShiftY", 3), + ]) + + +def test_beam_position(mocker, image, session_get_value): + session_get_value.side_effect = [2, 3] + pos_x, pos_y = image.wcs.beam.position + session_get_value.assert_has_calls([ + mocker.call("frameMap[0].overlayBeamSettings.shiftX", return_path=None), + mocker.call("frameMap[0].overlayBeamSettings.shiftY", return_path=None), + ]) + assert pos_x == 2 + assert pos_y == 3 + + +def test_beam_set_type(image, session_call_action): + image.wcs.beam.set_type(BT.SOLID) + session_call_action.assert_called_with("frameMap[0].overlayBeamSettings.setType", BT.SOLID) + + +def test_beam_type(image, session_get_value): + session_get_value.side_effect = ["solid"] + beam_type = image.wcs.beam.type + session_get_value.assert_called_with("frameMap[0].overlayBeamSettings.type", return_path=None) + assert beam_type is BT.SOLID + + +def test_beam_set_color(image, session_call_action): + image.wcs.beam.set_color(PC.ROSE) + session_call_action.assert_called_with("frameMap[0].overlayBeamSettings.setColor", PC.ROSE) + + +def test_beam_color(image, session_get_value): + session_get_value.side_effect = ["auto-rose"] + color = image.wcs.beam.color + session_get_value.assert_called_with("frameMap[0].overlayBeamSettings.color", return_path=None) + assert color is PC.ROSE + + +def test_beam_set_visible(image, session_call_action): + image.wcs.beam.set_visible(True) + session_call_action.assert_called_with("frameMap[0].overlayBeamSettings.setVisible", True) + + +def test_beam_show_hide(mocker, image, session_call_action): + image.wcs.beam.show() + image.wcs.beam.hide() + session_call_action.assert_has_calls([ + mocker.call("frameMap[0].overlayBeamSettings.setVisible", True), + mocker.call("frameMap[0].overlayBeamSettings.setVisible", False), + ]) + + +def test_beam_visible(image, session_get_value): + session_get_value.side_effect = [True] + visible = image.wcs.beam.visible + session_get_value.assert_called_with("frameMap[0].overlayBeamSettings.visible", return_path=None) + assert visible + + +def test_beam_set_width(image, session_call_action): + image.wcs.beam.set_width(2) + session_call_action.assert_called_with("frameMap[0].overlayBeamSettings.setWidth", 2) + + +def test_beam_width(image, session_get_value): + session_get_value.side_effect = [2] + width = image.wcs.beam.width + session_get_value.assert_called_with("frameMap[0].overlayBeamSettings.width", return_path=None) + assert width == 2 + + +def test_spectral_systems_supported(image, get_value): + get_value.side_effect = [{"LSRK", "LSRD"}] + systems = image.spectral_systems_supported + get_value.assert_called_with("spectralSystemsSupported") + assert systems == {SS.LSRK, SS.LSRD} + + +def test_spectral_coordinate_types_supported(image, get_value): + get_value.side_effect = [{"one": {'type': 'AWAV', 'unit': 'Angstrom'}, "two": {'type': 'AWAV', 'unit': 'm'}, "three": {'type': 'FREQ', 'unit': 'GHz'}}] + types = image.spectral_coordinate_types_supported + get_value.assert_called_with("spectralCoordsSupported") + assert types == {ST.AWAV, ST.FREQ} + + +def test_set_spectral_system(image, property_, call_action): + property_("is_pv", True) + property_("spectral_systems_supported", {SS.LSRK, SS.LSRD}) + image.set_spectral_system(SS.LSRK) + call_action.assert_called_with("setSpectralSystem", SS.LSRK) + + +def test_set_spectral_system_no_pv(image, property_): + property_("is_pv", False) + with pytest.raises(ValueError) as e: + image.set_spectral_system(SS.LSRK) + assert "not a position-velocity image" in str(e.value) + + +def test_set_spectral_system_bad_system(image, property_): + property_("is_pv", True) + property_("spectral_systems_supported", {SS.LSRK, SS.LSRD}) + with pytest.raises(ValueError) as e: + image.set_spectral_system(SS.BARY) + assert "Unsupported system: BARYCENT" in str(e.value) + + +def test_set_spectral_coordinate(image, property_, call_action): + property_("is_pv", True) + property_("spectral_coordinate_types_supported", {ST.VRAD, ST.VOPT}) + image.set_spectral_coordinate(ST.VRAD, SU.MS) + call_action.assert_called_with("setSpectralCoordinate", "Radio velocity (m/s)") + + +def test_set_spectral_coordinate_default_unit(image, property_, call_action): + property_("is_pv", True) + property_("spectral_coordinate_types_supported", {ST.VRAD, ST.VOPT}) + image.set_spectral_coordinate(ST.VRAD) + call_action.assert_called_with("setSpectralCoordinate", "Radio velocity (km/s)") + + +def test_set_spectral_coordinate_no_pv(image, property_): + property_("is_pv", False) + with pytest.raises(ValueError) as e: + image.set_spectral_coordinate(ST.VRAD) + assert "not a position-velocity image" in str(e.value) + + +def test_set_spectral_coordinate_bad_type(image, property_): + property_("is_pv", True) + property_("spectral_coordinate_types_supported", {ST.VRAD, ST.VOPT}) + with pytest.raises(ValueError) as e: + image.set_spectral_coordinate(ST.FREQ) + assert "Unsupported type: Frequency" in str(e.value) + + +def test_set_spectral_coordinate_bad_unit(image, property_): + property_("is_pv", True) + property_("spectral_coordinate_types_supported", {ST.VRAD, ST.VOPT}) + with pytest.raises(ValueError) as e: + image.set_spectral_coordinate(ST.VRAD, SU.HZ) + assert "Unsupported unit: Hz" in str(e.value) diff --git a/tests/test_raster.py b/tests/test_raster.py new file mode 100644 index 0000000..a3cccd2 --- /dev/null +++ b/tests/test_raster.py @@ -0,0 +1,188 @@ +import pytest + +from carta.raster import Raster, SessionRaster +from carta.constants import Colormap as CM, Scaling as SC, Auto, PaletteColor as PC +from carta.util import CartaValidationFailed + +# FIXTURES + + +@pytest.fixture +def raster(image): + """Return a vector overlay object which uses the image fixture. + """ + return Raster(image) + + +@pytest.fixture +def call_action(raster, mock_call_action): + return mock_call_action(raster) + + +@pytest.fixture +def method(raster, mock_method): + return mock_method(raster) + + +@pytest.fixture +def session_raster(session): + return SessionRaster(session) + + +@pytest.fixture +def session_raster_method(session_raster, mock_method): + return mock_method(session_raster) + + +@pytest.fixture +def session_call_action(session, mock_call_action): + return mock_call_action(session) + + +@pytest.fixture +def session_get_value(session, mock_get_value): + return mock_get_value(session) + +# TESTS + + +@pytest.mark.parametrize("colormap", [CM.VIRIDIS]) +@pytest.mark.parametrize("invert", [True, False]) +def test_set_colormap(mocker, raster, call_action, colormap, invert): + raster.set_colormap(colormap, invert) + call_action.assert_has_calls([ + mocker.call("setColorMap", colormap), + mocker.call("setInverted", invert), + ]) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ((SC.LINEAR, 5, 0.5), {}, [("setScaling", SC.LINEAR), ("setAlpha", 5), ("setGamma", 0.5), ]), + ([], {"scaling": SC.LINEAR}, [("setScaling", SC.LINEAR)]), + ([], {"alpha": 5}, [("setAlpha", 5)]), + ([], {"gamma": 0.5}, [("setGamma", 0.5)]), +]) +def test_set_scaling_valid(mocker, raster, call_action, args, kwargs, expected_calls): + raster.set_scaling(*args, **kwargs) + call_action.assert_has_calls([mocker.call(*call) for call in expected_calls]) + + +@pytest.mark.parametrize("kwargs", [ + {"alpha": 0}, + {"gamma": 0}, + {"alpha": 2000000}, + {"gamma": 5}, +]) +def test_set_scaling_invalid(raster, kwargs): + with pytest.raises(CartaValidationFailed): + raster.set_scaling(**kwargs) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ((99, 10, 1000), {}, [("setPercentileRank", 99)]), + ([], {"rank": 98}, [("setPercentileRank", 98), ("setPercentileRank", -1)]), + ([], {"min": 10, "max": 1000}, [("setCustomScale", 10, 1000)]), + ([], {"min": 10}, []), + ([], {"max": 1000}, []), +]) +def test_set_clip_valid(mocker, raster, call_action, args, kwargs, expected_calls): + raster.set_clip(*args, **kwargs) + call_action.assert_has_calls([mocker.call(*call) for call in expected_calls]) + + +@pytest.mark.parametrize("kwargs", [ + {"rank": -1}, + {"rank": 101}, +]) +def test_set_clip_invalid(raster, kwargs): + with pytest.raises(CartaValidationFailed): + raster.set_clip(**kwargs) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([0.5, 0.5], {}, [("setBias", 0.5), ("setContrast", 0.5)]), + ([], {"bias": Auto.AUTO, "contrast": Auto.AUTO}, [("resetBias",), ("resetContrast",)]), +]) +def test_set_bias_and_contrast_valid(mocker, raster, call_action, args, kwargs, expected_calls): + raster.set_bias_and_contrast(*args, **kwargs) + call_action.assert_has_calls([mocker.call(*call) for call in expected_calls]) + + +@pytest.mark.parametrize("kwargs", [ + {"bias": -5}, + {"contrast": -1}, + {"bias": 2}, + {"contrast": 5}, +]) +def test_set_bias_and_contrast_invalid(raster, kwargs): + with pytest.raises(CartaValidationFailed): + raster.set_bias_and_contrast(**kwargs) + + +@pytest.mark.parametrize("state", [True, False]) +def test_set_visible(raster, call_action, state): + raster.set_visible(state) + call_action.assert_called_with("setVisible", state) + + +def test_show(raster, method): + mock_set_visible = method("set_visible", None) + raster.show() + mock_set_visible.assert_called_with(True) + + +def test_hide(raster, method): + mock_set_visible = method("set_visible", None) + raster.hide() + mock_set_visible.assert_called_with(False) + +# GLOBAL RASTER SETTINGS + + +def test_pixel_grid_visible(session_raster, session_get_value): + session_get_value.side_effect = [True] + visible = session_raster.pixel_grid_visible + session_get_value.assert_called_with("preferenceStore.pixelGridVisible", return_path=None) + assert visible + + +@pytest.mark.parametrize("state", [True, False]) +def test_set_pixel_grid_visible(session_raster, session_call_action, state): + session_raster.set_pixel_grid_visible(state) + session_call_action.assert_called_with("preferenceStore.setPreference", "pixelGridVisible", state) + + +def test_show_hide_pixel_grid(mocker, session_raster, session_raster_method): + set_visible = session_raster_method("set_pixel_grid_visible", None) + session_raster.show_pixel_grid() + session_raster.hide_pixel_grid() + set_visible.assert_has_calls([ + mocker.call(True), + mocker.call(False), + ]) + + +def test_pixel_grid_color(session_raster, session_get_value): + session_get_value.side_effect = [PC.BLUE] + color = session_raster.pixel_grid_color + session_get_value.assert_called_with("preferenceStore.pixelGridColor", return_path=None) + assert color is PC.BLUE + + +def test_set_pixel_grid_color(session_raster, session_call_action): + session_raster.set_pixel_grid_color(PC.BLUE) + session_call_action.assert_called_with("preferenceStore.setPreference", "pixelGridColor", PC.BLUE) + + +@pytest.mark.parametrize("args,kwargs,expected_calls", [ + ([], {}, []), + ([True, PC.BLUE], {}, [("preferenceStore.setPreference", "pixelGridVisible", True), ("preferenceStore.setPreference", "pixelGridColor", PC.BLUE)]), + ([], {"visible": True}, [("preferenceStore.setPreference", "pixelGridVisible", True)]), + ([], {"color": PC.BLUE}, [("preferenceStore.setPreference", "pixelGridColor", PC.BLUE)]), +]) +def test_set_pixel_grid(mocker, session_raster, session_call_action, args, kwargs, expected_calls): + session_raster.set_pixel_grid(*args, **kwargs) + session_call_action.assert_has_calls([mocker.call(*call) for call in expected_calls]) diff --git a/tests/test_session.py b/tests/test_session.py index 509ec85..594d301 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1,8 +1,8 @@ import pytest from carta.image import Image -from carta.util import CartaValidationFailed, Macro -from carta.constants import CoordinateSystem, NumberFormat as NF, ComplexComponent as CC, Polarization as Pol +from carta.util import Macro +from carta.constants import ComplexComponent as CC, Polarization as Pol # FIXTURES @@ -26,8 +26,15 @@ def method(session, mock_method): # TODO fill in missing session tests +@pytest.mark.parametrize("name,classname", [ + ("wcs", "SessionWCSOverlay"), +]) +def test_subobjects(session, name, classname): + assert getattr(session, name).__class__.__name__ == classname + # PATHS + @pytest.mark.parametrize("path, expected_path", [ ("foo", "/current/dir/foo"), ("/foo", "/foo"), @@ -241,64 +248,3 @@ def test_open_hypercube_bad(mocker, session, call_action, method, paths, expecte with pytest.raises(Exception) as e: session.open_hypercube(paths, append) assert expected_error in str(e.value) - - -# OVERLAY - - -@pytest.mark.parametrize("system", CoordinateSystem) -def test_set_coordinate_system(session, call_action, system): - session.set_coordinate_system(system) - call_action.assert_called_with("overlayStore.global.setSystem", system) - - -def test_set_coordinate_system_invalid(session): - with pytest.raises(CartaValidationFailed) as e: - session.set_coordinate_system("invalid") - assert "Invalid function parameter" in str(e.value) - - -def test_coordinate_system(session, get_value): - get_value.return_value = "AUTO" - system = session.coordinate_system() - get_value.assert_called_with("overlayStore.global.system") - assert isinstance(system, CoordinateSystem) - - -@pytest.mark.parametrize("x", NF) -@pytest.mark.parametrize("y", NF) -def test_set_custom_number_format(mocker, session, call_action, x, y): - session.set_custom_number_format(x, y) - call_action.assert_has_calls([ - mocker.call("overlayStore.numbers.setFormatX", x), - mocker.call("overlayStore.numbers.setFormatY", y), - mocker.call("overlayStore.numbers.setCustomFormat", True), - ]) - - -@pytest.mark.parametrize("x,y", [ - ("invalid", "invalid"), - (NF.DEGREES, "invalid"), - ("invalid", NF.DEGREES), -]) -def test_set_custom_number_format_invalid(session, x, y): - with pytest.raises(CartaValidationFailed) as e: - session.set_custom_number_format(x, y) - assert "Invalid function parameter" in str(e.value) - - -def test_clear_custom_number_format(session, call_action): - session.clear_custom_number_format() - call_action.assert_called_with("overlayStore.numbers.setCustomFormat", False) - - -def test_number_format(session, get_value, mocker): - get_value.side_effect = [NF.DEGREES, NF.DEGREES, False] - x, y, _ = session.number_format() - get_value.assert_has_calls([ - mocker.call("overlayStore.numbers.formatTypeX"), - mocker.call("overlayStore.numbers.formatTypeY"), - mocker.call("overlayStore.numbers.customFormat"), - ]) - assert isinstance(x, NF) - assert isinstance(y, NF) diff --git a/tests/test_vector_overlay.py b/tests/test_vector_overlay.py index 5fa4a86..246993a 100644 --- a/tests/test_vector_overlay.py +++ b/tests/test_vector_overlay.py @@ -4,35 +4,30 @@ from carta.util import Macro from carta.constants import VectorOverlaySource as VOS, Auto, Colormap as CM +# FIXTURES + @pytest.fixture def vector_overlay(image): return VectorOverlay(image) -@pytest.fixture -def get_value(vector_overlay, mock_get_value): - return mock_get_value(vector_overlay) - - @pytest.fixture def call_action(vector_overlay, mock_call_action): return mock_call_action(vector_overlay) @pytest.fixture -def image_call_action(image, mock_call_action): - return mock_call_action(image) +def method(vector_overlay, mock_method): + return mock_method(vector_overlay) @pytest.fixture -def property_(mock_property): - return mock_property("carta.vector_overlay.VectorOverlay") +def image_call_action(image, mock_call_action): + return mock_call_action(image) -@pytest.fixture -def method(vector_overlay, mock_method): - return mock_method(vector_overlay) +# TESTS @pytest.mark.parametrize("args,kwargs,expected_args", [ @@ -74,25 +69,38 @@ def test_configure(vector_overlay, call_action, method, args, kwargs, expected_a call_action.assert_called_with("setVectorOverlayConfiguration", *expected_args) -@pytest.mark.parametrize("args,kwargs,expected_calls", [ +def test_set_thickness(vector_overlay, call_action): + vector_overlay.set_thickness(5) + call_action.assert_called_with("setThickness", 5) + + +@pytest.mark.parametrize("args,kwargs,expected_args", [ # Nothing - ((), {}, ()), + ((), {}, None), # Everything - ((1, 2, 3, 4, 5, 6), {}, ( - ("setThickness", 1), - ("setIntensityRange", 2, 3), - ("setLengthRange", 4, 5), - ("setRotationOffset", 6), - )), + ((2, 3), {}, ("setIntensityRange", 2, 3)), # No intensity min; auto intensity max - ((), {"intensity_max": Auto.AUTO}, (("setIntensityRange", "M(intensityMin)", Macro.UNDEFINED),)), + ((), {"intensity_max": Auto.AUTO}, ("setIntensityRange", "M(intensityMin)", Macro.UNDEFINED)), # Auto intensity min; no intensity max - ((), {"intensity_min": Auto.AUTO}, (("setIntensityRange", Macro.UNDEFINED, "M(intensityMax)"),)), + ((), {"intensity_min": Auto.AUTO}, ("setIntensityRange", Macro.UNDEFINED, "M(intensityMax)")), ]) -def test_set_style(mocker, vector_overlay, call_action, method, args, kwargs, expected_calls): +def test_set_intensity_range(vector_overlay, call_action, method, args, kwargs, expected_args): method("macro", lambda _, v: f"M({v})") - vector_overlay.set_style(*args, **kwargs) - call_action.assert_has_calls([mocker.call(*call) for call in expected_calls]) + vector_overlay.set_intensity_range(*args, **kwargs) + if expected_args is not None: + call_action.assert_called_with(*expected_args) + else: + call_action.assert_not_called() + + +def test_set_length_range(vector_overlay, call_action): + vector_overlay.set_length_range(2, 3) + call_action.assert_called_with("setLengthRange", 2, 3) + + +def test_set_rotation_offset(vector_overlay, call_action): + vector_overlay.set_rotation_offset(5) + call_action.assert_called_with("setRotationOffset", 5) def test_set_color(mocker, vector_overlay, call_action): @@ -103,15 +111,22 @@ def test_set_color(mocker, vector_overlay, call_action): ]) +def test_set_colormap(mocker, vector_overlay, call_action): + vector_overlay.set_colormap(CM.VIRIDIS) + call_action.assert_has_calls([ + mocker.call("setColormap", CM.VIRIDIS), + mocker.call("setColormapEnabled", True), + ]) + + @pytest.mark.parametrize("args,kwargs,expected_calls", [ ([], {}, []), - ([CM.VIRIDIS, 0.5, 1.5], {}, [("setColormap", CM.VIRIDIS), ("setColormapEnabled", True), ("setColormapBias", 0.5), ("setColormapContrast", 1.5)]), - ([CM.VIRIDIS], {}, [("setColormap", CM.VIRIDIS), ("setColormapEnabled", True)]), + ([0.5, 1.5], {}, [("setColormapBias", 0.5), ("setColormapContrast", 1.5)]), ([], {"bias": 0.5}, [("setColormapBias", 0.5)]), ([], {"contrast": 1.5}, [("setColormapContrast", 1.5)]), ]) -def test_set_colormap(mocker, vector_overlay, call_action, args, kwargs, expected_calls): - vector_overlay.set_colormap(*args, **kwargs) +def test_set_bias_and_contrast(mocker, vector_overlay, call_action, args, kwargs, expected_calls): + vector_overlay.set_bias_and_contrast(*args, **kwargs) call_action.assert_has_calls([mocker.call(*call) for call in expected_calls]) @@ -127,13 +142,13 @@ def test_clear(vector_overlay, image_call_action): @pytest.mark.parametrize("args,kwargs,expected_calls", [ ([], {}, []), - ([VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5, 1, 2, 3, 4, 5, 6, "blue", CM.VIRIDIS, 0.5, 1.5], {}, [("configure", VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5), ("set_style", 1, 2, 3, 4, 5, 6), ("set_color", "blue"), ("set_colormap", CM.VIRIDIS, 0.5, 1.5), ("apply",)]), - ([], {"pixel_averaging": 1, "thickness": 2, "color": "blue", "bias": 0.5}, [("configure", None, None, None, 1, None, None, None, None, None, None), ("set_style", 2, None, None, None, None, None), ("set_color", "blue"), ("set_colormap", None, 0.5, None), ("apply",)]), - ([], {"thickness": 2}, [("set_style", 2, None, None, None, None, None), ("apply",)]), + ([VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5, 1, 2, 3, 4, 5, 6, "blue", CM.VIRIDIS, 0.5, 1.5], {}, [("configure", VOS.CURRENT, VOS.CURRENT, True, 1, 2, True, 3, True, 4, 5), ("set_thickness", 1), ("set_intensity_range", 2, 3), ("set_length_range", 4, 5), ("set_rotation_offset", 6), ("set_color", "blue"), ("set_colormap", CM.VIRIDIS), ("set_bias_and_contrast", 0.5, 1.5), ("apply",)]), + ([], {"pixel_averaging": 1, "thickness": 2, "color": "blue", "bias": 0.5}, [("configure", None, None, None, 1, None, None, None, None, None, None), ("set_thickness", 2), ("set_color", "blue"), ("set_bias_and_contrast", 0.5, None), ("apply",)]), + ([], {"thickness": 2}, [("set_thickness", 2), ("apply",)]), ]) def test_plot(vector_overlay, method, args, kwargs, expected_calls): mocks = {} - for method_name in ("configure", "set_style", "set_color", "set_colormap", "apply"): + for method_name in ("configure", "set_thickness", "set_intensity_range", "set_length_range", "set_rotation_offset", "set_color", "set_colormap", "set_bias_and_contrast", "apply"): mocks[method_name] = method(method_name, None) vector_overlay.plot(*args, **kwargs) diff --git a/tests/test_wcs_overlay.py b/tests/test_wcs_overlay.py new file mode 100644 index 0000000..e462b92 --- /dev/null +++ b/tests/test_wcs_overlay.py @@ -0,0 +1,876 @@ +import pytest + +from carta.util import CartaValidationFailed +from carta.wcs_overlay import ImageWCSConnector +from carta.constants import NumberFormat as NF, Overlay as O, CoordinateSystem as CS, PaletteColor as PC, FontFamily as FF, FontStyle as FS, LabelType as LT, ColorbarPosition as CP, BeamType as BT + + +# FIXTURES + + +@pytest.fixture +def overlay(session): + return session.wcs + + +@pytest.fixture +def get_value(overlay, mock_get_value): + return mock_get_value(overlay) + + +@pytest.fixture +def component_get_value(overlay, mocker): + def func(comp_enum, mock_value=None): + return mocker.patch.object(overlay.get(comp_enum), "get_value", return_value=mock_value) + return func + + +@pytest.fixture +def session_get_value(session, mock_get_value): + return mock_get_value(session) + + +@pytest.fixture +def call_action(overlay, mock_call_action): + return mock_call_action(overlay) + + +@pytest.fixture +def component_call_action(overlay, mock_call_action): + def func(comp_enum): + return mock_call_action(overlay.get(comp_enum)) + return func + + +@pytest.fixture +def method(overlay, mock_method): + return mock_method(overlay) + + +@pytest.fixture +def component_method(overlay, mock_method): + def func(comp_enum): + return mock_method(overlay.get(comp_enum)) + return func + + +@pytest.fixture +def image_beam_method(image, mock_method): + return mock_method(image.wcs.beam) + + +@pytest.fixture +def image_beam_property(mock_property): + return mock_property("carta.wcs_overlay.ImageWCSOverlay.ImageBeam") + + +@pytest.fixture +def mock_images(image, mocker): + return mocker.patch.object(ImageWCSConnector, "_images", return_value=[image]) + + +# TESTS + + +@pytest.mark.parametrize("name,classname", [ + ("global_", "Global"), + ("title", "Title"), + ("grid", "Grid"), + ("border", "Border"), + ("ticks", "Ticks"), + ("axes", "Axes"), + ("numbers", "Numbers"), + ("labels", "Labels"), + ("colorbar", "Colorbar"), + ("beam", "Beam"), +]) +def test_subobjects(overlay, name, classname): + assert getattr(overlay, name).__class__.__name__ == classname + + +@pytest.mark.parametrize("name,classname", [ + ("border", "ColorbarBorder"), + ("ticks", "ColorbarTicks"), + ("numbers", "ColorbarNumbers"), + ("label", "ColorbarLabel"), + ("gradient", "ColorbarGradient"), +]) +def test_colorbar_subobjects(overlay, name, classname): + assert getattr(overlay.colorbar, name).__class__.__name__ == classname + + +@pytest.mark.parametrize("theme_is_dark,expected_rgb", [(True, "#f5498b"), (False, "#c22762")]) +def test_palette_to_rgb(overlay, session_get_value, theme_is_dark, expected_rgb): + session_get_value.side_effect = [theme_is_dark] + rgb = overlay.palette_to_rgb(PC.ROSE) + assert rgb == expected_rgb + + +def test_set_view_area(overlay, call_action): + overlay.set_view_area(100, 200) + call_action.assert_called_with("setViewDimension", 100, 200) + + +# COMPONENT TESTS + +@pytest.mark.parametrize("comp_enum", [O.GLOBAL]) +def test_set_color(overlay, comp_enum, component_call_action): + comp = overlay.get(comp_enum) + comp_call_action = component_call_action(comp_enum) + comp.set_color(PC.ROSE) + comp_call_action.assert_called_with("setColor", "auto-rose") + + +@pytest.mark.parametrize("comp_enum", set(O) - {O.GLOBAL, O.BEAM}) +def test_set_color_with_custom_flag(mocker, overlay, comp_enum, component_call_action): + comp = overlay.get(comp_enum) + comp_call_action = component_call_action(comp_enum) + comp.set_color(PC.ROSE) + comp_call_action.assert_has_calls([ + mocker.call("setColor", "auto-rose"), + mocker.call("setCustomColor", True), + ]) + + +@pytest.mark.parametrize("comp_enum", set(O) - {O.GLOBAL, O.BEAM}) +def test_set_custom_color(overlay, comp_enum, component_call_action): + comp = overlay.get(comp_enum) + comp_call_action = component_call_action(comp_enum) + comp.set_custom_color(True) + comp_call_action.assert_called_with("setCustomColor", True) + + +@pytest.mark.parametrize("comp_enum", set(O) - {O.BEAM}) +def test_color(overlay, component_get_value, comp_enum): + comp_get_value = component_get_value(comp_enum, "auto-rose") + comp = overlay.get(comp_enum) + color = comp.color + comp_get_value.assert_called_with("color") + assert color is PC.ROSE + + +@pytest.mark.parametrize("comp_enum", set(O) - {O.GLOBAL, O.BEAM}) +def test_custom_color(overlay, component_get_value, comp_enum): + comp_get_value = component_get_value(comp_enum, True) + comp = overlay.get(comp_enum) + custom_color = comp.custom_color + comp_get_value.assert_called_with("customColor") + assert custom_color is True + + +@pytest.mark.parametrize("comp_enum", [O.TITLE, O.LABELS]) +def test_set_custom_text(overlay, comp_enum, component_call_action): + comp = overlay.get(comp_enum) + comp_call_action = component_call_action(comp_enum) + comp.set_custom_text(True) + comp_call_action.assert_called_with("setCustomText", True) + + +@pytest.mark.parametrize("comp_enum", [O.TITLE, O.LABELS]) +def test_custom_text(overlay, component_get_value, comp_enum): + comp_get_value = component_get_value(comp_enum, True) + comp = overlay.get(comp_enum) + custom_text = comp.custom_text + comp_get_value.assert_called_with("customText") + assert custom_text is True + + +@pytest.mark.parametrize("comp_enum", [O.TITLE, O.NUMBERS, O.LABELS]) +def test_set_font(overlay, component_call_action, comp_enum): + comp = overlay.get(comp_enum) + comp_call_action = component_call_action(comp_enum) + comp.set_font(FF.TIMES, FS.ITALIC) + comp_call_action.assert_called_with("setFont", 5) + + +@pytest.mark.parametrize("comp_enum", [O.TITLE, O.NUMBERS, O.LABELS]) +def test_font(overlay, component_get_value, comp_enum): + comp = overlay.get(comp_enum) + comp_get_value = component_get_value(comp_enum, 5) + family, style = comp.font + comp_get_value.assert_called_with("font") + assert family is FF.TIMES + assert style is FS.ITALIC + + +@pytest.mark.parametrize("comp_enum", [O.TITLE, O.NUMBERS, O.LABELS]) +def test_set_font_arial(overlay, component_call_action, comp_enum): + comp = overlay.get(comp_enum) + comp_call_action = component_call_action(comp_enum) + comp.set_font(FF.ARIAL, FS.BOLD) + comp_call_action.assert_called_with("setFont", 9) + + +@pytest.mark.parametrize("comp_enum", [O.TITLE, O.NUMBERS, O.LABELS]) +def test_font_arial(overlay, component_get_value, comp_enum): + comp = overlay.get(comp_enum) + comp_get_value = component_get_value(comp_enum, 9) + family, style = comp.font + comp_get_value.assert_called_with("font") + assert family is FF.ARIAL + assert style is FS.BOLD + + +@pytest.mark.parametrize("comp_enum", [O.TITLE, O.NUMBERS, O.LABELS]) +def test_set_font_size(overlay, component_call_action, comp_enum): + comp = overlay.get(comp_enum) + comp_call_action = component_call_action(comp_enum) + comp.set_font_size(20) + comp_call_action.assert_called_with("setFontSize", 20) + + +@pytest.mark.parametrize("comp_enum", [O.TITLE, O.NUMBERS, O.LABELS]) +def test_font_size(overlay, component_get_value, comp_enum): + comp = overlay.get(comp_enum) + comp_get_value = component_get_value(comp_enum, 20) + comp.font_size + comp_get_value.assert_called_with("fontSize") + + +@pytest.mark.parametrize("comp_enum", set(O) - {O.GLOBAL, O.TICKS, O.BEAM}) +def test_set_visible(overlay, component_call_action, comp_enum): + comp = overlay.get(comp_enum) + comp_call_action = component_call_action(comp_enum) + comp.set_visible(True) + comp_call_action.assert_called_with("setVisible", True) + + +@pytest.mark.parametrize("comp_enum", set(O) - {O.GLOBAL, O.TICKS, O.BEAM}) +def test_show_hide(mocker, overlay, component_method, comp_enum): + comp = overlay.get(comp_enum) + comp_method = component_method(comp_enum)("set_visible", None) + + comp.show() + comp.hide() + + comp_method.assert_has_calls([ + mocker.call(True), + mocker.call(False), + ]) + + +@pytest.mark.parametrize("comp_enum", set(O) - {O.GLOBAL, O.TICKS, O.BEAM}) +def test_visible(overlay, component_get_value, comp_enum): + comp = overlay.get(comp_enum) + comp_get_value = component_get_value(comp_enum, True) + visible = comp.visible + comp_get_value.assert_called_with("visible") + assert visible is True + + +@pytest.mark.parametrize("comp_enum", [O.GRID, O.BORDER, O.AXES, O.TICKS, O.COLORBAR]) +def test_set_width(overlay, component_call_action, comp_enum): + comp = overlay.get(comp_enum) + comp_call_action = component_call_action(comp_enum) + comp.set_width(5) + comp_call_action.assert_called_with("setWidth", 5) + + +@pytest.mark.parametrize("comp_enum", [O.GRID, O.BORDER, O.AXES, O.TICKS, O.COLORBAR]) +def test_width(overlay, component_get_value, comp_enum): + comp = overlay.get(comp_enum) + comp_get_value = component_get_value(comp_enum, 5) + width = comp.width + comp_get_value.assert_called_with("width") + assert width == 5 + + +def test_global_set_tolerance(overlay, component_call_action): + global_call_action = component_call_action(O.GLOBAL) + overlay.global_.set_tolerance(85) + global_call_action.assert_called_with("setTolerance", 85) + + +def test_global_tolerance(overlay, component_get_value): + global_get_value = component_get_value(O.GLOBAL, 85) + tolerance = overlay.global_.tolerance + global_get_value.assert_called_with("tolerance") + assert tolerance == 85 + + +def test_global_set_labelling(overlay, component_call_action): + global_call_action = component_call_action(O.GLOBAL) + overlay.global_.set_labelling(LT.EXTERIOR) + global_call_action.assert_called_with("setLabelType", LT.EXTERIOR) + + +def test_global_labelling(overlay, component_get_value): + global_get_value = component_get_value(O.GLOBAL, "Exterior") + labelling = overlay.global_.labelling + global_get_value.assert_called_with("labelType") + assert labelling is LT.EXTERIOR + + +@pytest.mark.parametrize("system", CS) +def test_global_set_coordinate_system(overlay, component_call_action, system): + global_call_action = component_call_action(O.GLOBAL) + overlay.global_.set_coordinate_system(system) + global_call_action.assert_called_with("setSystem", system) + + +def test_global_set_coordinate_system_invalid(overlay): + with pytest.raises(CartaValidationFailed) as e: + overlay.global_.set_coordinate_system("invalid") + assert "Invalid function parameter" in str(e.value) + + +def test_global_coordinate_system(overlay, component_get_value): + global_get_value = component_get_value(O.GLOBAL, "AUTO") + system = overlay.global_.coordinate_system + global_get_value.assert_called_with("system") + assert isinstance(system, CS) + + +def test_grid_set_gap(mocker, overlay, component_call_action): + grid_call_action = component_call_action(O.GRID) + overlay.grid.set_gap(2, 3) + grid_call_action.assert_has_calls([ + mocker.call("setGapX", 2), + mocker.call("setGapY", 3), + mocker.call("setCustomGap", True), + ]) + + +def test_grid_set_custom_gap(overlay, component_call_action): + grid_call_action = component_call_action(O.GRID) + overlay.grid.set_custom_gap(False) + grid_call_action.assert_called_with("setCustomGap", False) + + +def test_grid_gap(mocker, overlay, component_get_value): + grid_get_value = component_get_value(O.GRID) + grid_get_value.side_effect = [2, 3] + gap_x, gap_y = overlay.grid.gap + grid_get_value.assert_has_calls([mocker.call("gapX"), mocker.call("gapY")]) + assert gap_x == 2 + assert gap_y == 3 + + +def test_grid_custom_gap(overlay, component_get_value): + grid_get_value = component_get_value(O.GRID, True) + custom_gap = overlay.grid.custom_gap + grid_get_value.assert_called_with("customGap") + assert custom_gap is True + + +@pytest.mark.parametrize("x", NF) +@pytest.mark.parametrize("y", NF) +def test_numbers_set_format(mocker, overlay, component_call_action, x, y): + numbers_call_action = component_call_action(O.NUMBERS) + overlay.numbers.set_format(x, y) + numbers_call_action.assert_has_calls([ + mocker.call("setFormatX", x), + mocker.call("setFormatY", y), + mocker.call("setCustomFormat", True), + ]) + + +@pytest.mark.parametrize("x,y", [ + ("invalid", "invalid"), + (NF.DEGREES, "invalid"), + ("invalid", NF.DEGREES), +]) +def test_numbers_set_format_invalid(overlay, x, y): + with pytest.raises(CartaValidationFailed) as e: + overlay.numbers.set_format(x, y) + assert "Invalid function parameter" in str(e.value) + + +@pytest.mark.parametrize("val", [True, False]) +def test_numbers_set_custom_format(overlay, component_call_action, val): + numbers_call_action = component_call_action(O.NUMBERS) + overlay.numbers.set_custom_format(val) + numbers_call_action.assert_called_with("setCustomFormat", val) + + +def test_numbers_format(overlay, component_get_value, mocker): + numbers_get_value = component_get_value(O.NUMBERS) + numbers_get_value.side_effect = [NF.DEGREES, NF.DEGREES] + x, y = overlay.numbers.format + numbers_get_value.assert_has_calls([ + mocker.call("formatTypeX"), + mocker.call("formatTypeY"), + ]) + assert isinstance(x, NF) + assert isinstance(y, NF) + + +def test_numbers_set_precision(mocker, overlay, component_call_action): + numbers_call_action = component_call_action(O.NUMBERS) + overlay.numbers.set_precision(3) + numbers_call_action.assert_has_calls([ + mocker.call("setPrecision", 3), + mocker.call("setCustomPrecision", True), + ]) + + +def test_numbers_set_custom_precision(overlay, component_call_action): + numbers_call_action = component_call_action(O.NUMBERS) + overlay.numbers.set_custom_precision(False) + numbers_call_action.assert_called_with("setCustomPrecision", False) + + +def test_numbers_precision(overlay, component_get_value): + numbers_get_value = component_get_value(O.NUMBERS, 3) + precision = overlay.numbers.precision + numbers_get_value.assert_called_with("precision") + assert precision == 3 + + +def test_numbers_custom_precision(overlay, component_get_value): + numbers_get_value = component_get_value(O.NUMBERS) + numbers_get_value.side_effect = [True] + custom_precision = overlay.numbers.custom_precision + numbers_get_value.assert_called_with("customPrecision") + assert custom_precision is True + + +def test_labels_set_text(mocker, overlay, component_call_action): + labels_call_action = component_call_action(O.LABELS) + overlay.labels.set_text("AAA", "BBB") + labels_call_action.assert_has_calls([ + mocker.call("setCustomLabelX", "AAA"), + mocker.call("setCustomLabelY", "BBB"), + mocker.call("setCustomText", True), + ]) + + +def test_labels_text(mocker, overlay, component_get_value): + labels_get_value = component_get_value(O.LABELS) + labels_get_value.side_effect = ["AAA", "BBB"] + label_x, label_y = overlay.labels.text + labels_get_value.assert_has_calls([mocker.call("customLabelX"), mocker.call("customLabelY")]) + assert label_x == "AAA" + assert label_y == "BBB" + + +def test_ticks_set_density(mocker, overlay, component_call_action): + ticks_call_action = component_call_action(O.TICKS) + overlay.ticks.set_density(2, 3) + ticks_call_action.assert_has_calls([ + mocker.call("setDensityX", 2), + mocker.call("setDensityY", 3), + mocker.call("setCustomDensity", True), + ]) + + +def test_ticks_set_custom_density(overlay, component_call_action): + ticks_call_action = component_call_action(O.TICKS) + overlay.ticks.set_custom_density(False) + ticks_call_action.assert_called_with("setCustomDensity", False) + + +def test_ticks_density(mocker, overlay, component_get_value): + ticks_get_value = component_get_value(O.TICKS) + ticks_get_value.side_effect = [2, 3] + density_x, density_y = overlay.ticks.density + ticks_get_value.assert_has_calls([mocker.call("densityX"), mocker.call("densityY")]) + assert density_x == 2 + assert density_y == 3 + + +def test_ticks_custom_density(overlay, component_get_value): + ticks_get_value = component_get_value(O.TICKS, True) + custom_density = overlay.ticks.custom_density + ticks_get_value.assert_called_with("customDensity") + assert custom_density is True + + +def test_ticks_set_draw_on_all_edges(overlay, component_call_action): + ticks_call_action = component_call_action(O.TICKS) + overlay.ticks.set_draw_on_all_edges(False) + ticks_call_action.assert_called_with("setDrawAll", False) + + +def test_ticks_draw_on_all_edges(overlay, component_get_value): + ticks_get_value = component_get_value(O.TICKS, True) + draw_on_all_edges = overlay.ticks.draw_on_all_edges + ticks_get_value.assert_called_with("drawAll") + assert draw_on_all_edges is True + + +def test_ticks_set_minor_length(overlay, component_call_action): + ticks_call_action = component_call_action(O.TICKS) + overlay.ticks.set_minor_length(3) + ticks_call_action.assert_called_with("setLength", 3) + + +def test_ticks_minor_length(overlay, component_get_value): + ticks_get_value = component_get_value(O.TICKS, 3) + minor_length = overlay.ticks.minor_length + ticks_get_value.assert_called_with("length") + assert minor_length == 3 + + +def test_ticks_set_major_length(overlay, component_call_action): + ticks_call_action = component_call_action(O.TICKS) + overlay.ticks.set_major_length(3) + ticks_call_action.assert_called_with("setMajorLength", 3) + + +def test_ticks_major_length(overlay, component_get_value): + ticks_get_value = component_get_value(O.TICKS, 3) + major_length = overlay.ticks.major_length + ticks_get_value.assert_called_with("majorLength") + assert major_length == 3 + + +def test_colorbar_set_interactive(overlay, component_call_action): + colorbar_call_action = component_call_action(O.COLORBAR) + overlay.colorbar.set_interactive(False) + colorbar_call_action.assert_called_with("setInteractive", False) + + +def test_colorbar_interactive(overlay, component_get_value): + colorbar_get_value = component_get_value(O.COLORBAR, True) + interactive = overlay.colorbar.interactive + colorbar_get_value.assert_called_with("interactive") + assert interactive is True + + +def test_colorbar_set_offset(overlay, component_call_action): + colorbar_call_action = component_call_action(O.COLORBAR) + overlay.colorbar.set_offset(3) + colorbar_call_action.assert_called_with("setOffset", 3) + + +def test_colorbar_offset(overlay, component_get_value): + colorbar_get_value = component_get_value(O.COLORBAR, 3) + offset = overlay.colorbar.offset + colorbar_get_value.assert_called_with("offset") + assert offset == 3 + + +def test_colorbar_set_position(overlay, component_call_action): + colorbar_call_action = component_call_action(O.COLORBAR) + overlay.colorbar.set_position(CP.BOTTOM) + colorbar_call_action.assert_called_with("setPosition", CP.BOTTOM) + + +def test_colorbar_position(overlay, component_get_value): + colorbar_get_value = component_get_value(O.COLORBAR, "bottom") + position = overlay.colorbar.position + colorbar_get_value.assert_called_with("position") + assert position is CP.BOTTOM + + +def test_colorbar_set_border_properties(mocker, overlay, component_call_action): + colorbar_call_action = component_call_action(O.COLORBAR) + + overlay.colorbar.border.set_visible(False) + overlay.colorbar.border.set_width(3) + overlay.colorbar.border.set_color(PC.ROSE) + overlay.colorbar.border.set_custom_color(False) + + colorbar_call_action.assert_has_calls([ + mocker.call("setBorderVisible", False), + mocker.call("setBorderWidth", 3), + mocker.call("setBorderColor", PC.ROSE), + mocker.call("setBorderCustomColor", True), + mocker.call("setBorderCustomColor", False), + ]) + + +def test_colorbar_get_border_properties(mocker, overlay, component_get_value): + colorbar_get_value = component_get_value(O.COLORBAR) + colorbar_get_value.side_effect = [True, 3, "auto-rose", True] + + visible = overlay.colorbar.border.visible + width = overlay.colorbar.border.width + color = overlay.colorbar.border.color + custom_color = overlay.colorbar.border.custom_color + + colorbar_get_value.assert_has_calls([ + mocker.call("borderVisible", return_path=None), + mocker.call("borderWidth", return_path=None), + mocker.call("borderColor", return_path=None), + mocker.call("borderCustomColor", return_path=None), + ]) + + assert visible is True + assert width == 3 + assert color is PC.ROSE + assert custom_color is True + + +def test_colorbar_set_ticks_properties(mocker, overlay, component_call_action): + colorbar_call_action = component_call_action(O.COLORBAR) + + overlay.colorbar.ticks.set_visible(False) + overlay.colorbar.ticks.set_width(3) + overlay.colorbar.ticks.set_color(PC.ROSE) + overlay.colorbar.ticks.set_custom_color(False) + overlay.colorbar.ticks.set_density(3) + overlay.colorbar.ticks.set_length(3) + + colorbar_call_action.assert_has_calls([ + mocker.call("setTickVisible", False), + mocker.call("setTickWidth", 3), + mocker.call("setTickColor", PC.ROSE), + mocker.call("setTickCustomColor", True), + mocker.call("setTickCustomColor", False), + mocker.call("setTickDensity", 3), + mocker.call("setTickLen", 3), + ]) + + +def test_colorbar_get_ticks_properties(mocker, overlay, component_get_value): + colorbar_get_value = component_get_value(O.COLORBAR) + colorbar_get_value.side_effect = [True, 3, "auto-rose", True, 3, 3] + + visible = overlay.colorbar.ticks.visible + width = overlay.colorbar.ticks.width + color = overlay.colorbar.ticks.color + custom_color = overlay.colorbar.ticks.custom_color + density = overlay.colorbar.ticks.density + length = overlay.colorbar.ticks.length + + colorbar_get_value.assert_has_calls([ + mocker.call("tickVisible", return_path=None), + mocker.call("tickWidth", return_path=None), + mocker.call("tickColor", return_path=None), + mocker.call("tickCustomColor", return_path=None), + mocker.call("tickDensity", return_path=None), + mocker.call("tickLen", return_path=None), + ]) + + assert visible is True + assert width == 3 + assert color is PC.ROSE + assert custom_color is True + assert density == 3 + assert length == 3 + + +def test_colorbar_set_numbers_properties(mocker, overlay, component_call_action): + colorbar_call_action = component_call_action(O.COLORBAR) + + overlay.colorbar.numbers.set_visible(False) + overlay.colorbar.numbers.set_precision(3) + overlay.colorbar.numbers.set_custom_precision(False) + overlay.colorbar.numbers.set_color(PC.ROSE) + overlay.colorbar.numbers.set_custom_color(False) + overlay.colorbar.numbers.set_font(FF.ARIAL, FS.BOLD) + overlay.colorbar.numbers.set_font_size(20) + overlay.colorbar.numbers.set_rotation(90) + + colorbar_call_action.assert_has_calls([ + mocker.call("setNumberVisible", False), + mocker.call("setNumberPrecision", 3), + mocker.call("setNumberCustomPrecision", True), + mocker.call("setNumberCustomPrecision", False), + mocker.call("setNumberColor", PC.ROSE), + mocker.call("setNumberCustomColor", True), + mocker.call("setNumberCustomColor", False), + mocker.call("setNumberFont", 9), + mocker.call("setNumberFontSize", 20), + mocker.call("setNumberRotation", 90), + ]) + + +def test_colorbar_get_numbers_properties(mocker, overlay, component_get_value): + colorbar_get_value = component_get_value(O.COLORBAR) + colorbar_get_value.side_effect = [True, 3, True, "auto-rose", True, 9, 20, 90] + + visible = overlay.colorbar.numbers.visible + precision = overlay.colorbar.numbers.precision + custom_precision = overlay.colorbar.numbers.custom_precision + color = overlay.colorbar.numbers.color + custom_color = overlay.colorbar.numbers.custom_color + family, style = overlay.colorbar.numbers.font + font_size = overlay.colorbar.numbers.font_size + rotation = overlay.colorbar.numbers.rotation + + colorbar_get_value.assert_has_calls([ + mocker.call("numberVisible", return_path=None), + mocker.call("numberPrecision", return_path=None), + mocker.call("numberCustomPrecision", return_path=None), + mocker.call("numberColor", return_path=None), + mocker.call("numberCustomColor", return_path=None), + mocker.call("numberFont", return_path=None), + mocker.call("numberFontSize", return_path=None), + mocker.call("numberRotation", return_path=None), + ]) + + assert visible is True + assert precision == 3 + assert custom_precision is True + assert color is PC.ROSE + assert custom_color is True + assert family is FF.ARIAL + assert style is FS.BOLD + assert font_size == 20 + assert rotation == 90 + + +def test_colorbar_set_label_properties(mocker, overlay, component_call_action): + colorbar_call_action = component_call_action(O.COLORBAR) + + overlay.colorbar.label.set_visible(False) + overlay.colorbar.label.set_color(PC.ROSE) + overlay.colorbar.label.set_custom_color(False) + overlay.colorbar.label.set_custom_text(False) + overlay.colorbar.label.set_font(FF.ARIAL, FS.BOLD) + overlay.colorbar.label.set_font_size(20) + overlay.colorbar.label.set_rotation(90) + + colorbar_call_action.assert_has_calls([ + mocker.call("setLabelVisible", False), + mocker.call("setLabelColor", PC.ROSE), + mocker.call("setLabelCustomColor", True), + mocker.call("setLabelCustomColor", False), + mocker.call("setLabelCustomText", False), + mocker.call("setLabelFont", 9), + mocker.call("setLabelFontSize", 20), + mocker.call("setLabelRotation", 90), + ]) + + +def test_colorbar_get_label_properties(mocker, overlay, component_get_value): + colorbar_get_value = component_get_value(O.COLORBAR) + colorbar_get_value.side_effect = [True, "auto-rose", True, True, 9, 20, 90] + + visible = overlay.colorbar.label.visible + color = overlay.colorbar.label.color + custom_color = overlay.colorbar.label.custom_color + custom_text = overlay.colorbar.label.custom_text + family, style = overlay.colorbar.label.font + font_size = overlay.colorbar.label.font_size + rotation = overlay.colorbar.label.rotation + + colorbar_get_value.assert_has_calls([ + mocker.call("labelVisible", return_path=None), + mocker.call("labelColor", return_path=None), + mocker.call("labelCustomColor", return_path=None), + mocker.call("labelCustomText", return_path=None), + mocker.call("labelFont", return_path=None), + mocker.call("labelFontSize", return_path=None), + mocker.call("labelRotation", return_path=None), + ]) + + assert visible is True + assert color is PC.ROSE + assert custom_color is True + assert custom_text is True + assert family is FF.ARIAL + assert style is FS.BOLD + assert font_size == 20 + assert rotation == 90 + + +def test_colorbar_set_gradient_properties(mocker, overlay, component_call_action): + colorbar_call_action = component_call_action(O.COLORBAR) + overlay.colorbar.gradient.set_visible(False) + colorbar_call_action.assert_has_calls([ + mocker.call("setGradientVisible", False), + ]) + + +def test_colorbar_get_gradient_properties(mocker, overlay, component_get_value): + colorbar_get_value = component_get_value(O.COLORBAR) + colorbar_get_value.side_effect = [True] + visible = overlay.colorbar.gradient.visible + colorbar_get_value.assert_has_calls([ + mocker.call("gradientVisible", return_path=None), + ]) + assert visible is True + + +# PER-IMAGE WCS +# These tests check that the per-image functions are called. Those functions are tested in test_image. + +def test_beam_set_position(overlay, mock_images, image_beam_method): + image_beam_set_position = image_beam_method("set_position", None) + overlay.beam.set_position(2, 3, [0]) + image_beam_set_position.assert_called_with(2, 3) + + +def test_beam_position(overlay, mock_images, image_beam_property): + image_beam_property("position", (2, 3)) + pos_x, pos_y = overlay.beam.position([0])[0] + assert pos_x == 2 + assert pos_y == 3 + + +def test_beam_set_type(overlay, mock_images, image_beam_method): + image_beam_set_type = image_beam_method("set_type", None) + overlay.beam.set_type(BT.SOLID, [0]) + image_beam_set_type.assert_called_with(BT.SOLID) + + +def test_beam_type(overlay, mock_images, image_beam_property): + image_beam_property("type", BT.SOLID) + beam_type = overlay.beam.type([0])[0] + assert beam_type is BT.SOLID + + +def test_beam_set_color(overlay, mock_images, image_beam_method): + image_beam_set_color = image_beam_method("set_color", None) + overlay.beam.set_color(PC.ROSE, [0]) + image_beam_set_color.assert_called_with(PC.ROSE) + + +def test_beam_color(overlay, mock_images, image_beam_property): + image_beam_property("color", PC.ROSE) + color = overlay.beam.color([0])[0] + assert color is PC.ROSE + + +def test_beam_set_visible(overlay, mock_images, image_beam_method): + image_beam_set_visible = image_beam_method("set_visible", None) + overlay.beam.set_visible(True, [0]) + image_beam_set_visible.assert_called_with(True) + + +def test_beam_show_hide(mocker, overlay, mock_images, image_beam_method): + image_beam_set_visible = image_beam_method("set_visible", None) + + overlay.beam.show([0]) + overlay.beam.hide([0]) + + image_beam_set_visible.assert_has_calls([ + mocker.call(True), + mocker.call(False), + ]) + + +def test_beam_visible(overlay, mock_images, image_beam_property): + image_beam_property("visible", True) + visible = overlay.beam.visible([0])[0] + assert visible + + +def test_beam_set_width(overlay, mock_images, image_beam_method): + image_beam_set_width = image_beam_method("set_width", None) + overlay.beam.set_width(2, [0]) + image_beam_set_width.assert_called_with(2) + + +def test_beam_width(overlay, mock_images, image_beam_property): + image_beam_property("width", 2) + width = overlay.beam.width([0])[0] + assert width == 2 + + +def test_colorbar_set_text(overlay, mock_images, image, mock_method): + image_set_colorbar_text = mock_method(image.wcs.colorbar.label)("set_text", None) + overlay.colorbar.label.set_text("Custom text here!", [0]) + image_set_colorbar_text.assert_called_with("Custom text here!") + + +def test_colorbar_text(overlay, mock_images, image, mock_property): + mock_property("carta.wcs_overlay.ImageWCSOverlay.ImageColorbar.ImageColorbarLabel")("text", "Custom text here!") + text = overlay.colorbar.label.text([0])[0] + assert text == "Custom text here!" + + +def test_title_set_text(overlay, mock_images, image, mock_method): + image_set_title_text = mock_method(image.wcs.title)("set_text", None) + overlay.title.set_text("Custom text here!", [0]) + image_set_title_text.assert_called_with("Custom text here!") + + +def test_title_text(overlay, mock_images, image, mock_property): + mock_property("carta.wcs_overlay.ImageWCSOverlay.ImageTitle")("text", "Custom text here!") + text = overlay.title.text([0])[0] + assert text == "Custom text here!"